maqiis / document-builder
Pembangun dokumen surat berbasis blok dengan keluaran HTML, cetak, dan PDF
Requires
- php: ^8.1
- ext-curl: *
- ext-dom: *
- ext-mbstring: *
- illuminate/console: ^10.0|^11.0|^12.0|^13.0
- illuminate/contracts: ^10.0|^11.0|^12.0|^13.0
- illuminate/http: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- ext-gd: *
- laravel/framework: ^10.0|^11.0|^12.0|^13.0
- livewire/livewire: ^2.12|^3.0
- mpdf/mpdf: ^8.3
- phpunit/phpunit: ^10.0
Suggests
- livewire/livewire: Diperlukan oleh komponen penyusun Blade (document-builder::template-builder)
- mpdf/mpdf: Diperlukan oleh MpdfEngine untuk menghasilkan PDF
Provides
None
Conflicts
None
Replaces
None
README
Penyusun surat berbasis blok yang dicetak persis seperti tampilannya di layar.
Template disimpan sebagai schema JSON. Satu renderer PHP mengubahnya menjadi HTML, satu stylesheet memberinya gaya, dan satu paginator memecahnya menjadi halaman. Kanvas builder, halaman cetak, dan PDF semuanya berasal dari rantai yang sama.
Prinsip: yang dicetak adalah DOM yang sudah dipaginasi
Paginator memecah dokumen menjadi kotak halaman di browser, dengan kop dan kaki disalin ke setiap
halaman. Halaman-halaman itulah yang tampil di builder dan yang dikirim ke window.print().
Layar dan cetak tidak "dibuat mirip" — keduanya DOM yang sama.
schema.json
│
├─► HtmlRenderer (PHP) ──► HTML mengalir + document.css
│ │
│ ┌─────────────┴─────────────┐
│ ▼ ▼
│ paginate-dom.mjs PdfEngine (PHP)
│ (tempel → ukur luapan → pindah) mpdf memaginasi sendiri
│ │
│ ┌─────────┴─────────┐
│ ▼ ▼
│ kanvas builder cetak browser
Paginator mengukur, tidak menaksir. Setiap fragmen ditempel ke halaman nyata yang sudah memiliki kop dan kakinya sendiri, lalu badan halaman diperiksa apakah meluap. Margin yang kolaps, header tabel yang diulang, dan kop yang hanya muncul di halaman pertama dihitung oleh mesin tata letak browser — mesin yang sama yang nanti mencetak.
Memasang
| Aplikasi Anda | Mulai dari |
|---|---|
| Blade + Livewire | TUTORIAL.md §1A, lalu examples/livewire/ |
| Inertia + React | TUTORIAL.md §1B, lalu examples/inertia-react/ |
| PHP tanpa Laravel | bagian "Memakai core tanpa Laravel" di bawah |
Paket ini privat (tidak ada di Packagist) — daftarkan dulu repository vcs-nya, lihat
TUTORIAL.md §1A langkah 1.
composer require maqiis/document-builder:^1.0 mpdf/mpdf php artisan document-builder:install # publish config, checklist, lalu doctor php artisan document-builder:doctor # kapan pun: engine PDF, font, gambar, variabel, render
Tipe blok
| Tipe | Kegunaan | Dipecah antar halaman |
|---|---|---|
letterhead |
kop surat: logo, empat baris teks, garis bawah | tidak |
letterhead-image |
kop gambar penuh lebar, menembus tepi kiri/kanan/atas | tidak |
letter-meta |
Nomor / Lampiran / Hal, dengan teks kanan opsional (mis. tempat & tanggal) | tidak |
paragraph |
teks dengan <b>, <i>, <u>, <br> |
tidak (pindah utuh) |
signature |
1–3 kolom tanda tangan | tidak |
table |
tabel dengan header yang diulang di tiap halaman | per baris |
list |
daftar poin atau bernomor bertingkat 1. / a. / 1) |
per butir |
image |
gambar dari sumber yang diizinkan | tidak |
qrcode |
QR dari teks atau variabel | tidak |
spacer |
jarak vertikal | tidak |
divider |
garis pemisah | tidak |
recipient |
daftar penerima: pembuka, satu entri per butir koleksi recipients, penutup |
tidak |
Kop dan kaki dapat tampil di all, first-only, atau except-first. Teks boleh memuat variabel
seperti {{ student.name }}; {{ page }} dan {{ pages }} diisi saat paginasi.
Sumber gambar — src pada image dan letterhead-image, logo pada letterhead, dan gambar
tanda tangan per kolom — juga boleh berupa variabel, mis. {{ school.letterhead }} yang nilainya
URL atau data URI. Nilainya disisipkan mentah (bukan HTML) lalu tetap diperiksa
ImageSourcePolicy, jadi variabel tidak bisa dipakai melewati daftar izin. Variabel yang tidak
dikenal, termasuk {{ page }}/{{ pages }}, menghasilkan penanda alih-alih URL rusak.
Variabel bawaan
Tersedia di setiap aplikasi tanpa didaftarkan, dan tampil di panel variabel builder:
| Path | Contoh (Kamis, 24 September 2026) |
|---|---|
today.long |
24 September 2026 |
today.short |
24/09/2026 |
today.full |
Kamis, 24 September 2026 |
today.day |
Kamis |
today.date |
24 |
today.month |
September |
today.month_roman |
IX (untuk nomor surat, mis. 001/SK/{{ today.month_roman }}/{{ today.year }}) |
today.year |
2026 |
page, pages |
nomor halaman dan jumlah halaman |
Tanggal dihitung saat render dengan zona waktu aplikasi, termasuk saat mencetak dengan resolver
data milik aplikasi. Katalog atau resolver aplikasi yang mengisi path yang sama selalu menang.
Matikan dengan document-builder.variables.builtin = false. Tanpa Laravel, bungkus resolver Anda
sendiri: (new BuiltinVariables(new DateTimeImmutable))->resolver($resolver), dan panggil
->register($registry) untuk panelnya.
Variabel tambahan per builder
Katalog bersama (VariableRegistry) berlaku untuk semua builder. Bila satu builder perlu variabel
yang hanya relevan untuknya, teruskan saat memasang komponen; entri ditambahkan ke salinan katalog
dan tidak mengubah katalog bersama:
<livewire:document-builder::template-builder :template="$definition" :extra-variables="[ ['path' => 'employee.name', 'label' => 'Nama Pegawai', 'sample' => 'Ahmad Fauzi', 'group' => 'Pegawai'], ]" />
group boleh dihilangkan (bawaan Umum). Panel dan nilai contoh pratinjau memakainya di setiap
update Livewire karena disimpan di property publik $extraVariables yang #[Locked]. Path yang sama
dengan katalog bersama menimpanya.
Blok penerima & koleksi
Blok recipient merender satu entri per butir koleksi recipients. Di dalam itemText,
{{ recipient.* }} dibaca dari butir yang sedang dirender; path lain tetap dari dokumen. Data
koleksi berupa list di resolver:
new ArrayVariableResolver([ 'recipient' => ['name' => 'Bapak Ahmad'], // penerima pertama, untuk blok lain 'recipients' => [['name' => 'Bapak Ahmad'], ['name' => 'Ibu Siti']], ]);
Untuk kanvas builder, daftarkan contoh butir lewat VariableRegistry::defineCollection('recipients', [...])
agar perulangannya terlihat. Aturan render: resolver yang tidak mengimplementasikan
CollectionVariableResolver → blok dirender sekali (penerima tunggal); koleksi kosong → blok tidak
dirender; baris yang kosong setelah variabel diisi dibuang; penomoran auto hanya muncul bila
penerimanya lebih dari satu.
Watermark
Teks diagonal samar di setiap halaman, mis. "DRAF", "RAHASIA", "SALINAN". Watermark tampil di kanvas builder, di cetak browser, dan di PDF (mpdf maupun Gotenberg).
"watermark": { "text": "DRAF", "opacity": 0.12 }
-
Di template. Isi kolom Watermark di panel Halaman builder. Pilihan cepat tersedia, teks bebas juga boleh. Teks polos saja, tanpa markup dan variabel. Teks maksimal
Watermark::MAX_LENGTH(40) karakter, dan opasitas dibatasi 0,05–0,5. Tanpa key ini, atau dengan teks kosong, dokumen tidak memakai watermark, jadi schema lama tetap sah. -
Saat render. Aplikasi bisa menimpa teks per cetak atau unduhan, sementara opasitas tetap dari template:
$renderer->render($template, watermark: $surat->disetujui ? null : 'DRAF'); // null = pakai template $renderer->render($template, watermark: ''); // matikan
Tanpa Laravel, pakai
$template->withWatermark('DRAF').
Ukuran huruf di browser meniru mpdf. Ukurannya mulai dari 120pt, lalu diturunkan sampai teks muat di sisi pendek kertas. Lebar teks diukur di DOM, bukan ditaksir. Watermark disalin ke tiap halaman sesudah paginasi, sehingga tidak pernah memengaruhi jumlah halaman.
Font (FontRegistry)
Font dipilih per-dokumen lewat style.fontFamily, bukan per-blok. Tiga yang pertama metriknya
identik dengan Times New Roman, Arial, dan Courier New. Browser memakai font mana pun di stack CSS
yang terpasang (semuanya bermetrik sama); mpdf memakai berkas Liberation yang disertakan di
resources/fonts/liberation/ (SIL OFL), karena dalam mode utf-8 mpdf tidak memakai font inti PDF
dan diam-diam jatuh ke DejaVu yang 16% lebih lebar — baris yang pas di layar lalu terlipat di PDF.
resolvedCss() menaruh keluarga mpdf paling depan di font-family, sebab mpdf hanya membaca nama
pertama.
| Key | Tampilan | Dukungan mpdf |
|---|---|---|
tinos (bawaan) |
mirip Times New Roman | ya (liberationserif) |
arimo |
mirip Arial | ya (liberationsans) |
cousine |
mirip Courier New | ya (liberationmono) |
almarai |
Arab & Latin (Google Fonts, berkas disertakan di resources/fonts/almarai/) |
tidak — lihat di bawah |
FontRegistry::supportsMpdf() menandai almarai sebagai tidak didukung mpdf: terverifikasi
lewat rendering PDF sungguhan bahwa parser TTF mpdf menghasilkan glyph salah/rusak untuknya (dan
untuk beberapa font Arab Google Fonts lain yang dicoba) — MpdfEngine::render() melempar
PdfRenderingException lebih dulu kalau font ini dipilih, mengarahkan ke engine gotenberg (lihat
bagian berikutnya), bukan diam-diam menghasilkan PDF yang rusak.
Untuk font yang butuh berkasnya sendiri (almarai), FontRegistry::fontFaceCss() menyisipkan
@font-face berbentuk data URI base64 lewat AssetLoader::fontFace() — hanya sampai ke
RenderedDocument::css() (dibaca browser/Chromium), tidak pernah ke resolvedCss() (dibaca mpdf),
jadi tidak perlu langkah "buang untuk mpdf" seperti krom halaman.
RTL: blok paragraph punya properti direction (ltr/bawaan, atau rtl), dirender sebagai
atribut HTML dir — bukan cuma CSS direction, supaya karakter netral (spasi, tanda baca, angka)
ikut algoritma Unicode Bidi yang benar. Belum ada di tipe blok lain; tambahkan pola yang sama di
renderer blok terkait kalau dibutuhkan.
Memakai core tanpa Laravel
src/ tidak bergantung pada Laravel sama sekali.
use Maqiis\DocumentBuilder\Pdf\MpdfEngine; use Maqiis\DocumentBuilder\Render\HtmlRenderer; use Maqiis\DocumentBuilder\Render\RenderContext; use Maqiis\DocumentBuilder\Schema\Template; use Maqiis\DocumentBuilder\Variable\ArrayVariableResolver; $template = Template::fromArray(json_decode($json, true)); // melempar SchemaValidationException $context = RenderContext::sample()->withResolver(new ArrayVariableResolver([ 'student' => ['name' => 'Fatimah Az-Zahra'], ])); $document = (new HtmlRenderer)->render($template, $context); $html = $document->fullHtml(autoPrint: true); // halaman cetak mandiri, tanpa permintaan jaringan $pdf = (new MpdfEngine)->render($document); // bytes PDF
RenderContext::sample() memakai kebijakan gambar yang longgar dan tanpa pembangkit QR — cukup
untuk percobaan. Untuk produksi, susun RenderContext dengan ImageSourcePolicy berdaftar-izin dan
QrCodeGenerator sungguhan.
Menambahkan engine PDF
Implementasikan Maqiis\DocumentBuilder\Pdf\PdfEngine, lalu tambahkan cabang match di
DocumentBuilderServiceProvider dan pilih lewat DOCUMENT_BUILDER_PDF_ENGINE.
RenderedDocument menyajikan dua rupa, dan engine mengambil yang cocok:
- engine yang memaginasi sendiri memakai
headerHtmlForEngine(),bodyHtml(),footerHtmlForEngine(), danresolvedCss()— CSS tanpa custom property dan tanpa krom halaman; - engine berbasis browser (misalnya headless Chrome) cukup memakai
fullHtml(), yang sudah memuat paginator dan memberi sinyaldocument-builder:paginatedsaat selesai. Engine seperti ini menghasilkan PDF yang identik dengan cetak browser.
Engine PDF: Gotenberg (Chromium sungguhan)
Maqiis\DocumentBuilder\Pdf\GotenbergEngine merender lewat Gotenberg —
layanan HTTP terpisah yang membungkus Chromium headless, bukan library PHP. Pilih lewat
DOCUMENT_BUILDER_PDF_ENGINE=gotenberg dan GOTENBERG_URL (bawaan http://gotenberg:3000).
Kenapa ini ada, bukan sekadar alternatif: GotenbergEngine memakai fullHtml() yang sama persis
dipakai kanvas builder dan cetak browser, jadi kesetiaan layar=cetak di jalur ini didapat gratis dari
fakta keduanya sama-sama Chromium — bukan hasil penyesuaian tata letak manual seperti
neutralizeTopBleed() milik MpdfEngine. Untuk dokumen yang butuh kesetiaan tertinggi terhadap hasil
cetak browser tanpa bergantung pada layanan HTTP terpisah, MpdfEngine tetap jadi pilihan utama.
(Catatan sejarah: percobaan awal membuat MpdfEngine merender font berkas sendiri seperti Almarai
sempat tampak gagal total — glyph salah, tidak konsisten antar frasa. Penyebabnya bukan keterbatasan
mesin TTF mpdf, melainkan cara pendaftarannya: mpdf versi modern tidak memproses @font-face di
CSS sama sekali (hanya ada jalur itu di parser SVG), jadi mendaftarkan font lewat CSS — cara yang
benar untuk browser/Gotenberg — membuat mpdf diam-diam substitusi ke font bawaannya sendiri. Jalur
yang benar adalah opsi konstruktor fontDir/fontdata, lihat FontRegistry::mpdfFontFiles() dan
MpdfEngine::mpdfConfig() — dengan itu Almarai pun tertanam dan tershaping benar di mpdf.)
Jalankan Gotenberg sebagai container terpisah, mis. di compose.yml:
gotenberg: image: gotenberg/gotenberg:8 restart: unless-stopped
GotenbergEngine mengambil fullHtml() yang sama persis dipakai kanvas builder dan cetak browser —
kesetiaan layar=cetak di jalur ini didapat gratis dari fakta keduanya sama-sama Chromium, bukan hasil
penyesuaian kedua seperti neutralizeTopBleed() milik MpdfEngine. Satu-satunya penyesuaian:
window.print() (dipanggil fullHtml(autoPrint:true) untuk pengguna sungguhan) diganti penanda
global yang dipoll Gotenberg lewat waitForExpression, supaya PDF baru diambil setelah
paginateDocument() benar-benar selesai — Chromium headless tidak punya dialog cetak untuk dipicu.
Test terhadap instance Gotenberg sungguhan sengaja dilewati di suite default (lihat
GotenbergEngineTest) — jalankan manual:
docker run -d -p 3000:3000 gotenberg/gotenberg:8 DOCUMENT_BUILDER_TEST_GOTENBERG_URL=http://localhost:3000 ./vendor/bin/phpunit --filter Gotenberg
Unggah gambar (ImageUploadStorage)
DocumentBuilderServiceProvider mengikat Maqiis\DocumentBuilder\Laravel\ImageUploadStorage —
dipakai host app saat pengguna mengunggah gambar di UI builder (mis. lewat properti Livewire
ber-tipe file) untuk mengubah berkas jadi src yang ditulis ke schema. Dipilih lewat
document-builder.images.upload_strategy:
data-uri(bawaan): berkas ditempel langsung ke schema sebagai base64. Tidak butuh disk apa pun, selalu lolosImageSourcePolicytanpa konfigurasi tambahan.filesystem: disimpan kedocument-builder.images.upload_disk(bawaan diskpublic) dan hanya URL-nya yang ditulis ke schema. URL disk itu otomatis ditambahkan keallowedPrefixesmilikImageSourcePolicysaat strategi ini aktif.upload_diskSENGAJA bawaannyapublic, bukanconfig('filesystems.default'): disk default aplikasi seringkali disklocalbawaan Laravel yang privat (rootstorage/app, tanpa symlink maupun configurl) — memakainya menghasilkansrcyang lolosImageSourcePolicytapi 404 di browser.FilesystemImageUploadStorage::store()melemparRuntimeExceptionlebih dulu kalau disk yang dikonfigurasi berdriverlocaltanpa configurl, supaya salah konfigurasi ketahuan saat mengunggah, bukan diam-diam menghasilkansrcyang salah.
MpdfEngine juga bisa menerima Media\ImageResolver opsional lewat constructor, untuk mengganti src
tiap <img> khusus di salinan HTML yang diserahkan ke mpdf — supaya berkas di disk lokal dibaca
langsung dari filesystem saat merender PDF, bukan di-fetch lewat HTTP. Sama sekali tidak mempengaruhi
HTML yang dilihat browser. DocumentBuilderServiceProvider mengikat Laravel\StorageImageResolver
sebagai implementasinya, yang mendelegasikan ke ImageUploadStorage yang sedang aktif — otomatis
no-op selama strategi masih data-uri.
API publik & versi
Kelas yang boleh Anda pakai dijaga semver; daftar persisnya konstanta PUBLIC di
tests/PublicApiTest.php. Kelas lain bertanda @internal — bebas berubah di rilis minor, dan IDE
serta static analyser Anda akan memperingatkan saat memakainya.
| Perubahan | Tingkat rilis |
|---|---|
Template::CURRENT_VERSION naik |
major — SchemaMigrator wajib berisi langkah upgrade-nya |
ContractPayload::VERSION naik |
major |
| Tipe blok atau properti baru | minor (konsumen wajib toleran pada yang tidak dikenal) |
| Teks label berubah | patch |
| Signature API publik berubah | major |
Riwayat perubahan di CHANGELOG.md; langkah naik versi di UPGRADING.md.
Batasan yang diketahui
- Paragraf tidak dipecah di tengah. Paragraf yang tidak muat pindah utuh ke halaman berikutnya. Ini batasan kualitas tata letak, bukan kesetiaan: builder memperlihatkan perpindahan yang sama.
- mpdf memaginasi sendiri. Jumlah halaman mpdf cocok dengan cetak browser pada ketiga fixture, tetapi posisi pemenggalan baris di dalam halaman belum dibandingkan dan bisa berbeda tipis.
- Zona bertinggi
autodi mpdf diberi ruang cadangan, karena mpdf tidak bisa mengukurnya lebih dulu. Menetapkan tinggi kop dan kaki secara numerik memberi kesetiaan PDF tertinggi. - CSS dokumen: tanpa
px, tanpa aturan untuk selectorhtml, tanpacurrentColor, tanpa flexbox pada gaya blok — masing-masing merusak keluaran mpdf atau dompdf. letterhead-imagewajib berada di zona bertinggiauto. SchemaValidator memaksakannya diam-diam. Tingginya selalu dihitung dari rasio gambar dan lebar kertas, tidak bisa disunting manual. mpdf memaku kop padamargin_headerdan mengabaikan margin atas negatif di dalamnya, jadiMpdfEnginemenurunkanmargin_headerke 0 dan menetralkan margin itu khusus untuk PDF — lihatLetterheadImageRendererdanMpdfEngine::neutralizeTopBleed().- Sunting inline di kanvas hanya untuk teks tanpa variabel. Pratinjau builder dirender dengan
DocumentRenderer::render(..., editable: true)sehingga region teks bertandadata-edit-*(lihatRenderContext::editAttr()); double-click menyunting, blur menyimpan, Escape membatalkan. Region yang memuat{{ ... }}sengaja tidak ditandai, karena kanvas menampilkan nilai contoh dan commit akan menimpa tokennya. Region seperti itu tetap disunting lewat inspektor. Cetak/PDF tidak pernah memuat atribut ini. - Berkas yatim tidak dibersihkan.
FilesystemImageUploadStoragetidak menghapus berkas lama saat sebuah blok diunggah ulang atau blok dihapus.
Test
# PHP (tanpa bootstrap Laravel) ./vendor/bin/phpunit --testsuite=DocumentBuilder # algoritma paginasi node --test "packages/document-builder/tests/js/*.test.mjs" # layar = cetak, tanpa isi terpotong (butuh Chrome di host) bin/document-builder-verify-print tests/fixtures/surat-tabel-panjang.json 3
verify-print.sh memeriksa tiga hal: jumlah halaman di layar sama dengan jumlah halaman tercetak,
tidak ada badan halaman yang meluap lalu terpotong, dan tabel yang pecah mengulang header-nya tanpa
kehilangan baris. Pemeriksaan kedua penting karena layar dan cetak bisa sama-sama memotong isi.