Search by

maqiis / document-builder

xdn27

Pembangun dokumen surat berbasis blok dengan keluaran HTML, cetak, dan PDF

Package info

github.com/xdn27/document-builder

pkg:composer/maqiis/document-builder

Statistics

Installs: 47

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.9.10 2026-10-06 07:07 UTC

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(), dan resolvedCss() — CSS tanpa custom property dan tanpa krom halaman;
  • engine berbasis browser (misalnya headless Chrome) cukup memakai fullHtml(), yang sudah memuat paginator dan memberi sinyal document-builder:paginated saat 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 lolos ImageSourcePolicy tanpa konfigurasi tambahan.
  • filesystem: disimpan ke document-builder.images.upload_disk (bawaan disk public) dan hanya URL-nya yang ditulis ke schema. URL disk itu otomatis ditambahkan ke allowedPrefixes milik ImageSourcePolicy saat strategi ini aktif. upload_disk SENGAJA bawaannya public, bukan config('filesystems.default'): disk default aplikasi seringkali disk local bawaan Laravel yang privat (root storage/app, tanpa symlink maupun config url) — memakainya menghasilkan src yang lolos ImageSourcePolicy tapi 404 di browser. FilesystemImageUploadStorage::store() melempar RuntimeException lebih dulu kalau disk yang dikonfigurasi berdriver local tanpa config url, supaya salah konfigurasi ketahuan saat mengunggah, bukan diam-diam menghasilkan src yang 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 auto di 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 selector html, tanpa currentColor, tanpa flexbox pada gaya blok — masing-masing merusak keluaran mpdf atau dompdf.
  • letterhead-image wajib berada di zona bertinggi auto. SchemaValidator memaksakannya diam-diam. Tingginya selalu dihitung dari rasio gambar dan lebar kertas, tidak bisa disunting manual. mpdf memaku kop pada margin_header dan mengabaikan margin atas negatif di dalamnya, jadi MpdfEngine menurunkan margin_header ke 0 dan menetralkan margin itu khusus untuk PDF — lihat LetterheadImageRenderer dan MpdfEngine::neutralizeTopBleed().
  • Sunting inline di kanvas hanya untuk teks tanpa variabel. Pratinjau builder dirender dengan DocumentRenderer::render(..., editable: true) sehingga region teks bertanda data-edit-* (lihat RenderContext::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. FilesystemImageUploadStorage tidak 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.