diff --git a/.gitattributes b/.gitattributes index 127d3c3..cd53f53 100644 --- a/.gitattributes +++ b/.gitattributes @@ -34,3 +34,8 @@ examples/markdown/** -text whitespace=cr-at-eol examples/markdown/fonts/OFL.txt whitespace=cr-at-eol,-blank-at-eol docs/assets/markdown-starter/** -text whitespace=cr-at-eol docs/assets/markdown-starter/*.pdf binary +examples/arabic-invoice/** text eol=lf +examples/arabic-invoice/fonts/*.ttf binary +examples/arabic-invoice/fonts/OFL.txt -text -whitespace +docs/assets/arabic-invoice/** -text +docs/assets/arabic-invoice/*.pdf binary diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index da7398e..d9ff7b4 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -13,6 +13,37 @@ concurrency: cancel-in-progress: true jobs: + arabic: + name: Arabic and English example (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - name: Install the public engine and independent PDF readers + run: | + python -m pip install --only-binary=:all: -r examples/arabic-invoice/requirements.txt pypdf==6.19.0 pypdfium2==5.14.0 Pillow==12.3.0 + - name: Verify the actual ZIP, fonts, guide, and customized PDF + run: | + python tools/build_arabic_example.py + git diff --exit-code -- docs/assets/arabic-invoice/project.zip docs/assets/arabic-invoice/source.json + python tools/verify_arabic_example.py + - uses: actions/upload-artifact@v4 + if: always() + with: + name: arabic-verification-${{ matrix.os }} + path: | + output/arabic-verification/*.json + output/arabic-verification/*.txt + output/arabic-verification/*.png + output/arabic-verification/render/ + output/arabic-verification/edited/ + retention-days: 14 rust-download: name: Rust starter (Windows) runs-on: windows-latest @@ -370,7 +401,7 @@ jobs: path: site deploy: if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' - needs: [build, aspnet, chinese, markdown, tagged-notice, rust-download] + needs: [build, aspnet, chinese, arabic, markdown, tagged-notice, rust-download] runs-on: ubuntu-latest permissions: pages: write diff --git a/docs/assets/arabic-invoice/invoice-pdfium.png b/docs/assets/arabic-invoice/invoice-pdfium.png new file mode 100644 index 0000000..cc7c24a Binary files /dev/null and b/docs/assets/arabic-invoice/invoice-pdfium.png differ diff --git a/docs/assets/arabic-invoice/invoice.pdf b/docs/assets/arabic-invoice/invoice.pdf new file mode 100644 index 0000000..3e7e04d Binary files /dev/null and b/docs/assets/arabic-invoice/invoice.pdf differ diff --git a/docs/assets/arabic-invoice/invoice.png b/docs/assets/arabic-invoice/invoice.png new file mode 100644 index 0000000..ef1af72 Binary files /dev/null and b/docs/assets/arabic-invoice/invoice.png differ diff --git a/docs/assets/arabic-invoice/project.zip b/docs/assets/arabic-invoice/project.zip new file mode 100644 index 0000000..0983442 Binary files /dev/null and b/docs/assets/arabic-invoice/project.zip differ diff --git a/docs/assets/arabic-invoice/source.json b/docs/assets/arabic-invoice/source.json new file mode 100644 index 0000000..c40e75c --- /dev/null +++ b/docs/assets/arabic-invoice/source.json @@ -0,0 +1,80 @@ +{ + "schema": "fullbleed.arabic-example.v1", + "fullbleed": "2.5.14", + "files": [ + { + "path": "README.md", + "bytes": 1827, + "sha256": "b499731a680a686c5362e684255242343b5809e8b7ae8862e50ab86494e5955b" + }, + { + "path": "LICENSE", + "bytes": 1079, + "sha256": "36a2115938f6e68cf0d87eccff7a6a3e57c17017e909cbd640d51aff1fa0c5a9" + }, + { + "path": "requirements.txt", + "bytes": 18, + "sha256": "e7669031cdf83cbb13fa8bf6da805f3f691d4a77fc64f22547e66020bc2b7abd" + }, + { + "path": "font-source.json", + "bytes": 689, + "sha256": "9153f1a7779bd57aacd098ab6a06665208890b727ab72bec9f8347bad6c23156" + }, + { + "path": "render.py", + "bytes": 5557, + "sha256": "c2bfebc08ad6c933301c3cc9890e72954bfa4b4452af3abbc344ad1f702df506" + }, + { + "path": "invoice.css", + "bytes": 2951, + "sha256": "bf927cee6a86814c549c2c9ad546d2fd0800c94ce5028d7bb7cf48421d48e5c0" + }, + { + "path": "data.json", + "bytes": 684, + "sha256": "587cb3903c03481653870ec8fe09e66cb3002a01d28a8bf7f6ca45a89fe50f38" + }, + { + "path": "fonts/NotoSansArabic-Regular.ttf", + "bytes": 240456, + "sha256": "ceea25b464a656dc3b26849bab9356740401af62aedf1bfa8b7f0d9b75925b1b" + }, + { + "path": "fonts/NotoSansArabic-Bold.ttf", + "bytes": 248468, + "sha256": "ed2711b387750ae991b6a980b8dd16fdd65e6702b97e9f52adf3a8edf09ef4df" + }, + { + "path": "fonts/OFL.txt", + "bytes": 4377, + "sha256": "0dab92d0544f7b233403f14b84a663bdbfa746982eda629e7f4f9ffe1b036feb" + } + ], + "zip_bytes": 223321, + "zip_sha256": "3f82e1c5a9eeba12ec6ba60bfaa7ce36ff2f017d3856541631de697b5216b7e5", + "fonts": { + "license": "SIL Open Font License 1.1", + "repository": "https://github.com/notofonts/noto-fonts", + "commit": "6cfca385c8757fadf537923d59a671116a865d48", + "files": [ + { + "file": "fonts/NotoSansArabic-Regular.ttf", + "sha256": "ceea25b464a656dc3b26849bab9356740401af62aedf1bfa8b7f0d9b75925b1b", + "source": "hinted/ttf/NotoSansArabic/NotoSansArabic-Regular.ttf" + }, + { + "file": "fonts/NotoSansArabic-Bold.ttf", + "sha256": "ed2711b387750ae991b6a980b8dd16fdd65e6702b97e9f52adf3a8edf09ef4df", + "source": "hinted/ttf/NotoSansArabic/NotoSansArabic-Bold.ttf" + }, + { + "file": "fonts/OFL.txt", + "sha256": "0dab92d0544f7b233403f14b84a663bdbfa746982eda629e7f4f9ffe1b036feb", + "source": "LICENSE" + } + ] + } +} diff --git a/docs/assets/arabic-invoice/verification.json b/docs/assets/arabic-invoice/verification.json new file mode 100644 index 0000000..dc1c4cd --- /dev/null +++ b/docs/assets/arabic-invoice/verification.json @@ -0,0 +1,324 @@ +{ + "schema": "fullbleed.arabic-example-verification.v1", + "ok": true, + "engine": "2.5.14", + "platform": "Windows", + "readers": { + "pypdf": "6.19.0", + "pypdfium2": "5.14.0" + }, + "checks": [ + { + "name": "required installed engine version", + "passed": true + }, + { + "name": "ZIP hash matches the manifest", + "passed": true + }, + { + "name": "exact expected ZIP file set", + "passed": true + }, + { + "name": "ZIP paths stay within the project", + "passed": true + }, + { + "name": "source and hash: README.md", + "passed": true + }, + { + "name": "source and hash: LICENSE", + "passed": true + }, + { + "name": "source and hash: requirements.txt", + "passed": true + }, + { + "name": "source and hash: font-source.json", + "passed": true + }, + { + "name": "source and hash: render.py", + "passed": true + }, + { + "name": "source and hash: invoice.css", + "passed": true + }, + { + "name": "source and hash: data.json", + "passed": true + }, + { + "name": "source and hash: fonts/NotoSansArabic-Regular.ttf", + "passed": true + }, + { + "name": "source and hash: fonts/NotoSansArabic-Bold.ttf", + "passed": true + }, + { + "name": "source and hash: fonts/OFL.txt", + "passed": true + }, + { + "name": "render exit", + "passed": true + }, + { + "name": "replay exit", + "passed": true + }, + { + "name": "exact-input PDFs are byte-identical", + "passed": true + }, + { + "name": "exact-input previews are byte-identical", + "passed": true + }, + { + "name": "explicit fonts cover all sample glyphs", + "passed": true + }, + { + "name": "one A4 page", + "passed": true + }, + { + "name": "PDF text: فاتورة", + "passed": true + }, + { + "name": "PDF text: نوى للتصميم", + "passed": true + }, + { + "name": "PDF text: شركة أفق", + "passed": true + }, + { + "name": "PDF text: NAWA STUDIO", + "passed": true + }, + { + "name": "PDF text: Ufuq Company", + "passed": true + }, + { + "name": "PDF text: NW-2048", + "passed": true + }, + { + "name": "PDF text: 2026-10-07", + "passed": true + }, + { + "name": "PDF text: 2026-10-21", + "passed": true + }, + { + "name": "PDF text: Brand identity design", + "passed": true + }, + { + "name": "PDF text: Document templates", + "passed": true + }, + { + "name": "PDF text: Handover sessions", + "passed": true + }, + { + "name": "PDF text: 875.00", + "passed": true + }, + { + "name": "PDF text: 325.00", + "passed": true + }, + { + "name": "PDF text: 190.00", + "passed": true + }, + { + "name": "PDF text: 1,390.00", + "passed": true + }, + { + "name": "PDF text: شكراً", + "passed": true + }, + { + "name": "PDF text: يُرجى", + "passed": true + }, + { + "name": "each font has a Unicode map", + "passed": true + }, + { + "name": "font program embedded: /DXNUJO+Inter-Regular", + "passed": true + }, + { + "name": "each font has a Unicode map", + "passed": true + }, + { + "name": "font program embedded: /XRAYZI+NotoSansArabic-Bold", + "passed": true + }, + { + "name": "each font has a Unicode map", + "passed": true + }, + { + "name": "font program embedded: /ZKUOGT+NotoSansArabic-Regular", + "passed": true + }, + { + "name": "regular and bold Arabic faces used", + "passed": true + }, + { + "name": "independent PDFium text: NW-2048", + "passed": true + }, + { + "name": "independent PDFium text: 1,390.00", + "passed": true + }, + { + "name": "independent PDFium text: 2026-10-07", + "passed": true + }, + { + "name": "independent PDFium text: 2026-10-21", + "passed": true + }, + { + "name": "independent PDFium text: شكراً", + "passed": true + }, + { + "name": "independent PDFium text: يُرجى", + "passed": true + }, + { + "name": "no injected bidi controls or replacement characters", + "passed": true + }, + { + "name": "glyph boxes stay inside the A4 page", + "passed": true + }, + { + "name": "one executable guide snippet", + "passed": true + }, + { + "name": "guide-snippet exit", + "passed": true + }, + { + "name": "guide renders marked Arabic and isolated fields", + "passed": true + }, + { + "name": "edited exit", + "passed": true + }, + { + "name": "edited data and escaped HTML text reach PDF", + "passed": true + }, + { + "name": "new data and theme change the output", + "passed": true + }, + { + "name": "integer cents calculate the edited total", + "passed": true + }, + { + "name": "invalid-quantity exit", + "passed": true + }, + { + "name": "invalid amounts fail before a PDF is written", + "passed": true + }, + { + "name": "missing-glyph exit", + "passed": true + }, + { + "name": "missing glyph is reported without a new PDF", + "passed": true + }, + { + "name": "missing-font exit", + "passed": true + }, + { + "name": "missing font has an explicit diagnostic", + "passed": true + }, + { + "name": "changed-font exit", + "passed": true + }, + { + "name": "changed font is rejected before parsing", + "passed": true + } + ], + "commands": [ + { + "label": "render", + "exit": 0 + }, + { + "label": "replay", + "exit": 0 + }, + { + "label": "guide-snippet", + "exit": 0 + }, + { + "label": "edited", + "exit": 0 + }, + { + "label": "invalid-quantity", + "exit": 1 + }, + { + "label": "missing-glyph", + "exit": 1 + }, + { + "label": "missing-font", + "exit": 1 + }, + { + "label": "changed-font", + "exit": 1 + } + ], + "zip_sha256": "3f82e1c5a9eeba12ec6ba60bfaa7ce36ff2f017d3856541631de697b5216b7e5", + "pdf_sha256": "e723f1e4ee7ed710990e119c48d19dd16f75f2708456e2ddb7f62ea1797ef28e", + "native_png_sha256": "bf89ee5db355bfc146b948f032f17f3f50e703dc330fe8ce77f4d54b5359ae38", + "fonts": [ + "/DXNUJO+Inter-Regular", + "/XRAYZI+NotoSansArabic-Bold", + "/ZKUOGT+NotoSansArabic-Regular" + ], + "total_cents": 139000, + "pages": 1 +} diff --git a/docs/engine/font-registration.md b/docs/engine/font-registration.md index baec759..ce7ce01 100644 --- a/docs/engine/font-registration.md +++ b/docs/engine/font-registration.md @@ -9,6 +9,8 @@ it does not search system fonts. The [Chinese-text guide](../guides/chinese-pdf.md) includes a runnable bilingual invoice, pinned font preparation, and the current variable-font weight and glyph-report limitations. +The [Arabic and English invoice](../guides/arabic-pdf.md) includes static regular +and bold Noto Sans Arabic files with a font manifest and glyph checks. When regular and italic faces share a family name, normal text selects the regular face even if the italic file was registered first. The default family diff --git a/docs/examples.md b/docs/examples.md index 6c97039..03b2c6e 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -199,6 +199,29 @@ pinned font preparation, and a missing-character gate. Rendered with Fullbleed +## Nawa Studio Arabic and English invoice + +
+
+[![Nawa Studio invoice with Arabic and English text, a dark green title panel, coral accent, and USD 1,390.00 total.](assets/arabic-invoice/invoice.png)](assets/arabic-invoice/invoice.pdf) +
+
+ +**One page · Arabic and English · Fonts included** + +An editable invoice with RTL Arabic blocks, isolated dates and references, +regular and bold Arabic fonts, and an itemized service table. Change the JSON +data, CSS, or HTML structure and render locally. The ZIP includes its exact +font files and licenses. Rendered with Fullbleed **2.5.14**; all details are +fictional. + +[Open the PDF](assets/arabic-invoice/invoice.pdf) · +[Build this invoice](guides/arabic-pdf.md) · +[Download the project](assets/arabic-invoice/project.zip) + +
+
+ ## Northstar Engineering Markdown brief
diff --git a/docs/getting-started/first-pdf.md b/docs/getting-started/first-pdf.md index 8474f44..420221a 100644 --- a/docs/getting-started/first-pdf.md +++ b/docs/getting-started/first-pdf.md @@ -31,6 +31,8 @@ Open `output/invoice.pdf` and the images under `output/preview`. Inspect every p For Simplified Chinese, use the [Chinese-font invoice recipe](../guides/chinese-pdf.md). It registers Noto Sans SC explicitly and includes a missing-character check. +For Arabic and English, the [bilingual invoice recipe](../guides/arabic-pdf.md) +includes its fonts and shows how to isolate dates and references in RTL text. ## Repeatable output diff --git a/docs/guides/arabic-pdf.md b/docs/guides/arabic-pdf.md new file mode 100644 index 0000000..f36de6a --- /dev/null +++ b/docs/guides/arabic-pdf.md @@ -0,0 +1,105 @@ +--- +title: Arabic and English PDFs in Python +description: Generate a styled Arabic and English invoice with Fullbleed, bundled project fonts, RTL layout, and isolated dates and invoice references. +--- + +# Arabic and English in one PDF + +Write Arabic in its normal Unicode order, register an Arabic font, and give +Arabic blocks a right-to-left direction. Isolate dates and references so their +left-to-right order survives the surrounding Arabic text. + +This fictional invoice uses **Fullbleed 2.5.14**, static Noto Sans Arabic regular +and bold fonts, and Inter. The download includes the fonts and their license. +The image below is rendered from the actual PDF. + +[Download the PDF](../assets/arabic-invoice/invoice.pdf){ .md-button .md-button--primary } +[Get the runnable project](../assets/arabic-invoice/project.zip){ .md-button } + +[![Arabic and English Nawa Studio invoice with a dark green title panel, coral accent, three service rows and a USD 1,390.00 total.](../assets/arabic-invoice/invoice.png)](../assets/arabic-invoice/invoice.pdf) + +## Run and customize it + +Extract the ZIP and open its `arabic-invoice` folder. In a Python 3.11 or newer +virtual environment, run: + +```sh +python -m pip install -r requirements.txt +python render.py +``` + +Open `output/invoice.pdf` and `output/preview/invoice_page1.png`. Rendering reads +local font files and needs no browser, network access or system-font installation. + +Edit **data.json** for the studio, customer, dates, reference and services. +Amounts use integer USD cents; the renderer calculates each line and the total +and escapes inserted HTML text. Edit **invoice.css** for colors, typography, +spacing and layout. The HTML structure is in **render.py**. + +## Keep fields in the intended direction + +This smaller example runs from the extracted project directory: + +```python +from importlib.resources import files +from pathlib import Path +import fullbleed + +engine = fullbleed.PdfEngine( + font_files=[ + "fonts/NotoSansArabic-Regular.ttf", + str(files("fullbleed_assets").joinpath("fonts/Inter-Variable.ttf")), + ], + document_lang="ar", + document_title="Arabic and English example", +) +html = ''' +

مرحباً بالعالم

+

رقم الفاتورة INV-2048 +مستحق في 2026-10-21.

+''' +css = ''' +@page { size: A4; margin: 20mm; } +body { font-family: "Noto Sans Arabic", Inter; font-size: 18pt; line-height: 1.8; } +p { text-align: right; } +bdi { font-family: Inter; } +''' +pdf, missing = engine.render_pdf_with_glyph_report(html, css) +if missing: + raise RuntimeError(f"Project fonts lack required characters: {missing}") +Path("arabic.pdf").write_bytes(pdf) +engine.render_finalized_pdf_image_pages_to_dir("arabic.pdf", "preview", 110, "arabic") +``` + +`text-align: right` chooses an edge; `direction: rtl` establishes the text's base +direction. An inline `direction: ltr` declaration alone does not isolate a date. +Use ``, or combine `direction: ltr` with `unicode-bidi: isolate` +in CSS. Fullbleed resolves styled siblings in the surrounding paragraph's bidi +context. See the [HTML bidirectional-text defaults](https://html.spec.whatwg.org/multipage/rendering.html#bidi-rendering). + +Do not manually reverse Arabic strings. Keep a connected Arabic word together +when styling it; this example does not test splitting its letters into separate +styled spans. Save Python, JSON, HTML and CSS as UTF-8. + +## Fonts and output checks + +Naming a CSS font family does not install it. This project registers the exact +regular and bold files included in its [font manifest](../assets/arabic-invoice/source.json). +`render.py` verifies their hashes before rendering. Keep the included OFL notice +when distributing those fonts. Inter is registered from the installed Fullbleed +package. `document_lang="ar"` describes the document language; it supplies no glyphs. + +The [retained checks](../assets/arabic-invoice/verification.json) exercise the ZIP, +an edited invoice and theme, exact-input replay, the guide snippet, missing and +changed fonts, and an unsupported character. Independent readers inspect the +Arabic and English text, dates, total, embedded fonts and page bounds; native and +PDFium previews are retained too. + +Inspect the final pages after customization. PDF readers can reconstruct a +mixed Arabic/English line differently on copy/paste; one reader's full extracted +line is not a substitute for checking field geometry and text with another. +This recipe verifies its sample, without a general language-coverage or PDF +standards-conformance claim. + +[Invoices from data](invoices.md) · [Font registration](../engine/font-registration.md) · +[Chinese text and fonts](chinese-pdf.md) diff --git a/docs/guides/invoices.md b/docs/guides/invoices.md index dec2ede..64d4137 100644 --- a/docs/guides/invoices.md +++ b/docs/guides/invoices.md @@ -10,6 +10,9 @@ Edit the sample data, render a preview, and download the PDF in Colab or local J To return an invoice from your app, see the [FastAPI, Flask, and Django examples](web-frameworks.md). +For a bilingual layout, download the [Arabic and English invoice](arabic-pdf.md) +with its fonts included, or the [Chinese and English example](chinese-pdf.md). + ## A compact JSON invoice The [agent-workflow invoice](https://github.com/fullbleed-engine/fullbleed-official/tree/v2.4.0/examples/agent_workflows) loads JSON, escapes text, builds an HTML table, embeds Inter, writes a PDF, and checks that the invoice ID, customer, and total survived rendering. diff --git a/docs/index.md b/docs/index.md index 77e6cf9..009cb44 100644 --- a/docs/index.md +++ b/docs/index.md @@ -234,7 +234,7 @@ Pick your application stack. Generate a PDF locally or download one from the bro Turn JSON or CSV into itemized invoices. Keep data, layout, and font assets explicit. - [Invoice guide →](guides/invoices.md) + [Invoice guide →](guides/invoices.md) · [Arabic and English example](guides/arabic-pdf.md) - **Reports that flow across pages** diff --git a/examples/arabic-invoice/LICENSE b/examples/arabic-invoice/LICENSE new file mode 100644 index 0000000..4c99bbd --- /dev/null +++ b/examples/arabic-invoice/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Fullbleed Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/examples/arabic-invoice/README.md b/examples/arabic-invoice/README.md new file mode 100644 index 0000000..deaf2e5 --- /dev/null +++ b/examples/arabic-invoice/README.md @@ -0,0 +1,41 @@ +# Arabic and English invoice + +A fictional A4 invoice using Fullbleed, local Noto Sans Arabic regular/bold +faces, and the Inter font bundled with Fullbleed. All names and amounts are +sample data. The template code is MIT-licensed; keep `fonts/OFL.txt` with the +bundled Arabic fonts. + +Use a Python 3.11 or newer virtual environment: + +```sh +python -m pip install -r requirements.txt +python render.py +``` + +Open `output/invoice.pdf` and `output/preview/invoice_page1.png`. Font files +are already in the ZIP. Rendering needs no network access or system fonts. + +Edit `data.json` for the studio, customer, reference, dates and services. +Amounts use integer USD cents; quantities are positive integers. The script +calculates line totals and escapes inserted HTML text. Edit `invoice.css` +for colors, type, spacing and layout, or `render.py` for the HTML structure. + +```sh +python render.py --data data.json --out output/custom +``` + +Keep Arabic strings in logical Unicode order. Do not reverse them manually. +Use `direction: rtl` for Arabic blocks and `` around dates, +references and other fields that must retain their LTR order. Keep an Arabic +word together when styling its text; this sample does not test styling +individual connected letters as separate spans. + +The renderer verifies pinned font bytes, writes a glyph report and refuses to +write a new PDF if a registered font lacks a character. If you intentionally +replace a font, update its manifest hash and CSS family name. Inspect every +page after changing the content or styling. Readers can reconstruct mixed RTL +and LTR text differently on copy/paste even when the page looks correct. + +`render.json` records the engine version, calculated total, PDF size and hash. +The guide and independent output checks are at +https://docs.fullbleed.dev/guides/arabic-pdf/ . diff --git a/examples/arabic-invoice/data.json b/examples/arabic-invoice/data.json new file mode 100644 index 0000000..129be00 --- /dev/null +++ b/examples/arabic-invoice/data.json @@ -0,0 +1,16 @@ +{ + "reference": "NW-2048", + "issued": "2026-10-07", + "due": "2026-10-21", + "studio_ar": "نوى للتصميم", + "studio_en": "NAWA STUDIO", + "customer_ar": "شركة أفق", + "customer_en": "Ufuq Company", + "project_ar": "هوية بصرية ونظام مستندات", + "project_en": "Brand identity & document system", + "items": [ + {"ar": "تصميم الهوية البصرية", "en": "Brand identity design", "quantity": 1, "unit_cents": 87500}, + {"ar": "قوالب المستندات", "en": "Document templates", "quantity": 1, "unit_cents": 32500}, + {"ar": "جلسات التسليم", "en": "Handover sessions", "quantity": 2, "unit_cents": 9500} + ] +} diff --git a/examples/arabic-invoice/font-source.json b/examples/arabic-invoice/font-source.json new file mode 100644 index 0000000..8a44f31 --- /dev/null +++ b/examples/arabic-invoice/font-source.json @@ -0,0 +1,10 @@ +{ + "license": "SIL Open Font License 1.1", + "repository": "https://github.com/notofonts/noto-fonts", + "commit": "6cfca385c8757fadf537923d59a671116a865d48", + "files": [ + {"file": "fonts/NotoSansArabic-Regular.ttf", "sha256": "ceea25b464a656dc3b26849bab9356740401af62aedf1bfa8b7f0d9b75925b1b", "source": "hinted/ttf/NotoSansArabic/NotoSansArabic-Regular.ttf"}, + {"file": "fonts/NotoSansArabic-Bold.ttf", "sha256": "ed2711b387750ae991b6a980b8dd16fdd65e6702b97e9f52adf3a8edf09ef4df", "source": "hinted/ttf/NotoSansArabic/NotoSansArabic-Bold.ttf"}, + {"file": "fonts/OFL.txt", "sha256": "0dab92d0544f7b233403f14b84a663bdbfa746982eda629e7f4f9ffe1b036feb", "source": "LICENSE"} + ] +} diff --git a/examples/arabic-invoice/fonts/NotoSansArabic-Bold.ttf b/examples/arabic-invoice/fonts/NotoSansArabic-Bold.ttf new file mode 100644 index 0000000..8819e4d Binary files /dev/null and b/examples/arabic-invoice/fonts/NotoSansArabic-Bold.ttf differ diff --git a/examples/arabic-invoice/fonts/NotoSansArabic-Regular.ttf b/examples/arabic-invoice/fonts/NotoSansArabic-Regular.ttf new file mode 100644 index 0000000..ce21d4f Binary files /dev/null and b/examples/arabic-invoice/fonts/NotoSansArabic-Regular.ttf differ diff --git a/examples/arabic-invoice/fonts/OFL.txt b/examples/arabic-invoice/fonts/OFL.txt new file mode 100644 index 0000000..c82d72e --- /dev/null +++ b/examples/arabic-invoice/fonts/OFL.txt @@ -0,0 +1,94 @@ +Copyright 2018 The Noto Project Authors (github.com/googlei18n/noto-fonts) + +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/examples/arabic-invoice/invoice.css b/examples/arabic-invoice/invoice.css new file mode 100644 index 0000000..6582782 --- /dev/null +++ b/examples/arabic-invoice/invoice.css @@ -0,0 +1,42 @@ +@page { size: A4; margin: 14mm; } +* { box-sizing: border-box; } +body { margin: 0; font-family: "Noto Sans Arabic", Inter; font-size: 10pt; line-height: 1.65; color: #213c37; } +h1, h2, p { margin: 0; } +.latin { font-family: Inter; direction: ltr; } +.rtl { direction: rtl; text-align: right; } +.masthead { display: flex; justify-content: space-between; align-items: center; padding-bottom: 6mm; border-bottom: 1.2pt solid #213c37; } +.brand-ar { font-size: 22pt; font-weight: 700; line-height: 1.25; } +.brand-en { font-size: 8pt; letter-spacing: 2pt; } +.edition { font-size: 8pt; color: #62726b; } +.hero { margin-top: 7mm; padding: 8mm 9mm; background: #213c37; color: #fffaf0; border-right: 3mm solid #dd7853; display: flex; justify-content: space-between; align-items: center; } +.hero h1 { font-size: 43pt; font-weight: 700; line-height: 1.3; } +.hero .subtitle { font-size: 10pt; letter-spacing: 2pt; color: #c7d5bd; } +.ref-label { font-size: 8pt; color: #c7d5bd; } +.reference { font-size: 19pt; font-weight: 700; margin-top: 1mm; } +.parties { display: flex; gap: 8mm; margin: 8mm 0; } +.project { width: 53%; } +.customer { width: 47%; padding-right: 5mm; border-right: 2pt solid #dd7853; } +.eyebrow { font-size: 8pt; color: #62726b; margin-bottom: 2mm; } +.customer h2 { font-size: 20pt; font-weight: 700; line-height: 1.45; } +.secondary { color: #62726b; font-size: 9pt; } +.project-name { font-size: 13pt; font-weight: 700; } +.dates { border-top: 0.6pt solid #ccd3c7; border-bottom: 0.6pt solid #ccd3c7; padding: 3.5mm 0; margin-bottom: 6mm; display: flex; justify-content: space-between; } +.dates p { font-size: 9pt; } +.dates bdi { font-family: Inter; font-size: 10pt; } +table { border-collapse: collapse; width: 100%; table-layout: fixed; } +th { background: #edf0e6; font-size: 9pt; font-weight: 400; padding: 3mm; text-align: right; border-bottom: 1pt solid #213c37; } +th .latin { display: block; font-size: 7pt; color: #62726b; } +td { padding: 4.5mm 3mm; border-bottom: 0.6pt solid #d6dcd0; vertical-align: middle; } +.money { font-family: Inter; font-size: 10pt; text-align: right; direction: ltr; } +.qty { text-align: center; } +.service { font-size: 12pt; font-weight: 700; line-height: 1.65; } +.service-en { font-size: 8pt; font-weight: 400; color: #62726b; } +.summary { display: flex; gap: 9mm; margin-top: 7mm; align-items: flex-start; } +.total { width: 46%; background: #213c37; color: #fffaf0; padding: 5mm 6mm; } +.total-label { display: flex; justify-content: space-between; font-size: 9pt; color: #c7d5bd; } +.total-value { font-size: 29pt; font-weight: 700; margin-top: 2mm; line-height: 1.3; } +.currency { font-size: 10pt; margin-right: 2mm; } +.note { width: 54%; padding-top: 1mm; } +.thanks { font-size: 18pt; font-weight: 700; margin-bottom: 2mm; } +.note p { font-size: 9pt; } +.footer { margin-top: 10mm; padding-top: 3mm; border-top: 0.6pt solid #ccd3c7; display: flex; justify-content: space-between; font-size: 7pt; color: #62726b; } diff --git a/examples/arabic-invoice/render.py b/examples/arabic-invoice/render.py new file mode 100644 index 0000000..6c644f5 --- /dev/null +++ b/examples/arabic-invoice/render.py @@ -0,0 +1,82 @@ +"""Render a fictional bilingual invoice from local, explicitly registered fonts.""" +from hashlib import sha256 +from html import escape +from importlib.metadata import version +from importlib.resources import files +from pathlib import Path +import argparse +import json + +import fullbleed + +ROOT = Path(__file__).resolve().parent + + +def money(cents): + return f"{cents // 100:,}.{cents % 100:02}" + + +def render(data_path, out): + data = json.loads(Path(data_path).read_text(encoding="utf-8")) + manifest = json.loads((ROOT / "font-source.json").read_text(encoding="utf-8")) + for asset in manifest["files"]: + path = ROOT / asset["file"] + if not path.is_file() or sha256(path.read_bytes()).hexdigest() != asset["sha256"]: + raise ValueError("Missing or changed project font asset: " + asset["file"]) + out = Path(out) + out.mkdir(parents=True, exist_ok=True) + e = lambda key: escape(str(data[key])) + rows = [] + total = 0 + for item in data["items"]: + quantity, unit = item["quantity"], item["unit_cents"] + if type(quantity) is not int or quantity <= 0 or type(unit) is not int or unit < 0: + raise ValueError("Use positive integer quantities and nonnegative integer USD cents") + amount = quantity * unit + total += amount + rows.append(f'''{money(amount)}{money(unit)} + {quantity}

{escape(item['ar'])}

+

{escape(item['en'])}

''') + html = f''' +

DESIGN / DOCUMENTS
EST. 2026

+

{e('studio_ar')}

{e('studio_en')}

+

REFERENCE

{e('reference')}

+

فاتورة

INVOICE

+

المشروع / PROJECT

+

{e('project_ar')}

{e('project_en')}

+

فاتورة إلى / BILL TO

{e('customer_ar')}

{e('customer_en')}

+

تاريخ الاستحقاق {e('due')}

+

تاريخ الإصدار {e('issued')}

+ + + + {''.join(rows)}

المبلغ

AMOUNT / USD

السعر

RATE / USD

العدد

QTY

الخدمة

SERVICE
+
TOTAL DUEالإجمالي
+

USD{money(total)}

+

شكراً لثقتكم

يُرجى استخدام رقم الفاتورة {e('reference')} عند التواصل معنا.

+

Please include the invoice reference in your correspondence.

+

FICTIONAL EXAMPLE · FULLBLEED

نموذج توضيحي — جميع الأسماء والمبالغ خيالية

+ ''' + font_paths = [ROOT / "fonts/NotoSansArabic-Regular.ttf", ROOT / "fonts/NotoSansArabic-Bold.ttf"] + engine = fullbleed.PdfEngine(font_files=[str(p) for p in font_paths] + [str(files("fullbleed_assets").joinpath("fonts/Inter-Variable.ttf"))], + document_title=data["reference"] + " / Bilingual invoice", document_lang="ar") + css = (ROOT / "invoice.css").read_text(encoding="utf-8") + pdf, missing = engine.render_pdf_with_glyph_report(html, css) + (out / "glyph-report.json").write_text(json.dumps(missing, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + if missing: + raise RuntimeError("A project font lacks required characters; see glyph-report.json") + (out / "invoice.html").write_text(html, encoding="utf-8") + (out / "invoice.pdf").write_bytes(pdf) + engine.render_finalized_pdf_image_pages_to_dir(str(out / "invoice.pdf"), str(out / "preview"), 110, "invoice") + result = {"engine": version("fullbleed"), "reference": data["reference"], "total_cents": total, + "bytes": len(pdf), "pdf_sha256": sha256(pdf).hexdigest(), "missing_glyphs": missing} + (out / "render.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") + return result + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--data", type=Path, default=ROOT / "data.json") + parser.add_argument("--out", type=Path, default=ROOT / "output") + args = parser.parse_args() + print(json.dumps(render(args.data, args.out))) diff --git a/examples/arabic-invoice/requirements.txt b/examples/arabic-invoice/requirements.txt new file mode 100644 index 0000000..08dc47c --- /dev/null +++ b/examples/arabic-invoice/requirements.txt @@ -0,0 +1 @@ +fullbleed==2.5.14 diff --git a/mkdocs.yml b/mkdocs.yml index f688879..dec367b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -83,6 +83,7 @@ nav: - Invoices from data: guides/invoices.md - Markdown to PDF: guides/markdown-pdf.md - Chinese text and fonts: guides/chinese-pdf.md + - Arabic and English PDFs: guides/arabic-pdf.md - pandas DataFrame to PDF: guides/pandas-to-pdf.md - Rebuild as you edit: guides/render-watch.md - Check PDFs in CI: guides/pdf-regression-ci.md diff --git a/tools/build_arabic_example.py b/tools/build_arabic_example.py new file mode 100644 index 0000000..6646352 --- /dev/null +++ b/tools/build_arabic_example.py @@ -0,0 +1,37 @@ +"""Package the bilingual invoice, including exact pinned Arabic font files.""" +from hashlib import sha256 +from pathlib import Path +import json +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "examples/arabic-invoice" +DEST = ROOT / "docs/assets/arabic-invoice" +FILES = ["README.md", "LICENSE", "requirements.txt", "font-source.json", "render.py", + "invoice.css", "data.json", "fonts/NotoSansArabic-Regular.ttf", + "fonts/NotoSansArabic-Bold.ttf", "fonts/OFL.txt"] + + +def build(): + DEST.mkdir(parents=True, exist_ok=True) + target = DEST / "project.zip" + manifest = [] + with zipfile.ZipFile(target, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive: + for name in FILES: + data = (SOURCE / name).read_bytes() + info = zipfile.ZipInfo("arabic-invoice/" + name, date_time=(2026, 10, 7, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + info.create_system = 3 + info.external_attr = 0o100644 << 16 + archive.writestr(info, data, compresslevel=9) + manifest.append({"path": name, "bytes": len(data), "sha256": sha256(data).hexdigest()}) + result = {"schema": "fullbleed.arabic-example.v1", "fullbleed": "2.5.14", "files": manifest, + "zip_bytes": target.stat().st_size, "zip_sha256": sha256(target.read_bytes()).hexdigest(), + "fonts": json.loads((SOURCE / "font-source.json").read_text(encoding="utf-8"))} + (DEST / "source.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8", newline="\n") + print(json.dumps({"zip_bytes": result["zip_bytes"], "zip_sha256": result["zip_sha256"], "files": len(FILES)})) + return result + + +if __name__ == "__main__": + build() diff --git a/tools/verify_arabic_example.py b/tools/verify_arabic_example.py new file mode 100644 index 0000000..b48726d --- /dev/null +++ b/tools/verify_arabic_example.py @@ -0,0 +1,171 @@ +"""Verify the actual bilingual starter ZIP and PDFs with independent readers.""" +from hashlib import sha256 +from importlib.metadata import version +from pathlib import Path +import argparse +import json +import os +import platform +import re +import shutil +import subprocess +import sys +import unicodedata +import zipfile + +import pypdfium2 as pdfium +from pypdf import PdfReader + +ROOT = Path(__file__).resolve().parents[1] +ASSETS = ROOT / "docs/assets/arabic-invoice" + + +def compact(text): + return "".join(text.split()) + + +def verify(args): + out = args.out.resolve() + out.mkdir(parents=True, exist_ok=True) + extracted = out / "project with spaces" + extracted.mkdir(exist_ok=False) + checks, commands = [], [] + report = {"schema": "fullbleed.arabic-example-verification.v1", "ok": False, + "engine": version("fullbleed"), "platform": platform.system(), + "readers": {"pypdf": version("pypdf"), "pypdfium2": version("pypdfium2")}, + "checks": checks, "commands": commands} + + def check(name, passed): + checks.append({"name": name, "passed": bool(passed)}) + if not passed: + raise AssertionError(name) + + def run(label, arguments, expected=0): + result = subprocess.run([sys.executable, "-I", *map(str, arguments)], cwd=project, + env={**os.environ, "PYTHONUTF8": "1", "PYTHONPATH": ""}, + capture_output=True, text=True, encoding="utf-8", timeout=240) + (out / (label + ".stdout.txt")).write_text(result.stdout, encoding="utf-8") + (out / (label + ".stderr.txt")).write_text(result.stderr, encoding="utf-8") + commands.append({"label": label, "exit": result.returncode}) + check(label + " exit", result.returncode == expected) + return result + + try: + check("required installed engine version", version("fullbleed") == "2.5.14") + manifest = json.loads((ASSETS / "source.json").read_text(encoding="utf-8")) + archive_path = args.zip or ASSETS / "project.zip" + check("ZIP hash matches the manifest", sha256(archive_path.read_bytes()).hexdigest() == manifest["zip_sha256"]) + with zipfile.ZipFile(archive_path) as archive: + expected = {"arabic-invoice/" + item["path"] for item in manifest["files"]} + check("exact expected ZIP file set", set(archive.namelist()) == expected) + check("ZIP paths stay within the project", all(not Path(name).is_absolute() and ".." not in Path(name).parts for name in expected)) + for item in manifest["files"]: + data = archive.read("arabic-invoice/" + item["path"]) + check("source and hash: " + item["path"], data == (ROOT / "examples/arabic-invoice" / item["path"]).read_bytes() and sha256(data).hexdigest() == item["sha256"]) + archive.extractall(extracted) + project = extracted / "arabic-invoice" + run("render", ["render.py", "--out", out / "render"]) + run("replay", ["render.py", "--out", out / "replay"]) + pdf_path = out / "render/invoice.pdf" + pdf = pdf_path.read_bytes() + check("exact-input PDFs are byte-identical", pdf == (out / "replay/invoice.pdf").read_bytes()) + native_png = out / "render/preview/invoice_page1.png" + check("exact-input previews are byte-identical", native_png.read_bytes() == (out / "replay/preview/invoice_page1.png").read_bytes()) + check("explicit fonts cover all sample glyphs", json.loads((out / "render/glyph-report.json").read_text(encoding="utf-8")) == []) + reader = PdfReader(pdf_path) + check("one A4 page", len(reader.pages) == 1 and abs(float(reader.pages[0].mediabox.width) - 595.276) < .2 and abs(float(reader.pages[0].mediabox.height) - 841.89) < .2) + text = "\n".join(page.extract_text() for page in reader.pages) + (out / "pypdf.txt").write_text(text, encoding="utf-8") + for phrase in ["فاتورة", "نوى للتصميم", "شركة أفق", "NAWA STUDIO", "Ufuq Company", "NW-2048", + "2026-10-07", "2026-10-21", "Brand identity design", "Document templates", "Handover sessions", + "875.00", "325.00", "190.00", "1,390.00", "شكراً", "يُرجى"]: + check("PDF text: " + phrase, compact(phrase) in compact(text)) + descriptors = [] + for ref in reader.pages[0]["/Resources"]["/Font"].values(): + font = ref.get_object() + check("each font has a Unicode map", "/ToUnicode" in font) + descriptor = font["/DescendantFonts"][0].get_object()["/FontDescriptor"].get_object() + descriptors.append(str(descriptor["/FontName"])) + check("font program embedded: " + descriptors[-1], "/FontFile2" in descriptor or "/FontFile3" in descriptor) + check("regular and bold Arabic faces used", any("NotoSansArabic-Regular" in f for f in descriptors) and any("NotoSansArabic-Bold" in f for f in descriptors)) + document = pdfium.PdfDocument(pdf) + page = document[0] + textpage = page.get_textpage() + other = textpage.get_text_range() + (out / "pdfium.txt").write_text(other, encoding="utf-8") + for phrase in ["NW-2048", "1,390.00", "2026-10-07", "2026-10-21", "شكراً", "يُرجى"]: + check("independent PDFium text: " + phrase, compact(phrase) in compact(other)) + check("no injected bidi controls or replacement characters", not any(unicodedata.category(ch) == "Cf" or ch == "\ufffd" for ch in text + other)) + boxes = [textpage.get_charbox(index) for index in range(textpage.count_chars())] + painted = [(l, b, r, t) for l, b, r, t in boxes if r > l and t > b] + check("glyph boxes stay inside the A4 page", bool(painted) and all(l >= 0 and b >= 0 and r <= page.get_width() + .1 and t <= page.get_height() + .1 for l, b, r, t in painted)) + page.render(scale=110 / 72).to_pil().save(out / "pdfium.png") + textpage.close() + page.close() + document.close() + + guide = (ROOT / "docs/guides/arabic-pdf.md").read_text(encoding="utf-8") + snippets = re.findall(r"```python\n(.*?)```", guide, flags=re.S) + check("one executable guide snippet", len(snippets) == 1) + (project / "guide-snippet.py").write_text(snippets[0], encoding="utf-8") + run("guide-snippet", ["guide-snippet.py"]) + guide_text = PdfReader(project / "arabic.pdf").pages[0].extract_text() + check("guide renders marked Arabic and isolated fields", all(phrase in guide_text for phrase in ["مرحباً", "INV-2048", "2026-10-21"])) + + data = json.loads((project / "data.json").read_text(encoding="utf-8")) + data["customer_ar"] = "مؤسسة بيان" + data["customer_en"] = "Mira & Co " + data["reference"] = "TEST-2049" + data["items"] = [{"ar": "إعداد قالب مخصص", "en": "Custom template", "quantity": 2, "unit_cents": 12345}] + edited = project / "edited.json" + edited.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + css_path = project / "invoice.css" + css_path.write_text(css_path.read_text(encoding="utf-8").replace("#213c37", "#25375d"), encoding="utf-8") + run("edited", ["render.py", "--data", edited, "--out", out / "edited"]) + edited_pdf = (out / "edited/invoice.pdf").read_bytes() + edited_text = "".join(page.extract_text() for page in PdfReader(out / "edited/invoice.pdf").pages) + check("edited data and escaped HTML text reach PDF", all(compact(p) in compact(edited_text) for p in ["مؤسسة بيان", "Mira & Co ", "TEST-2049", "246.90"])) + check("new data and theme change the output", edited_pdf != pdf) + check("integer cents calculate the edited total", json.loads((out / "edited/render.json").read_text())["total_cents"] == 24690) + + data["items"][0]["quantity"] = -1 + edited.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + invalid = run("invalid-quantity", ["render.py", "--data", edited, "--out", out / "invalid-quantity"], expected=1) + check("invalid amounts fail before a PDF is written", "positive integer quantities" in invalid.stderr and not (out / "invalid-quantity/invoice.pdf").exists()) + data["items"][0]["quantity"] = 1 + data["customer_ar"] += " 字" + edited.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + run("missing-glyph", ["render.py", "--data", edited, "--out", out / "missing-glyph"], expected=1) + check("missing glyph is reported without a new PDF", bool(json.loads((out / "missing-glyph/glyph-report.json").read_text(encoding="utf-8"))) and not (out / "missing-glyph/invoice.pdf").exists()) + font_path = project / "fonts/NotoSansArabic-Regular.ttf" + original_font = font_path.read_bytes() + try: + font_path.unlink() + missing = run("missing-font", ["render.py", "--out", out / "missing-font"], expected=1) + check("missing font has an explicit diagnostic", "Missing or changed project font asset" in missing.stderr) + font_path.write_bytes(b"changed font") + changed = run("changed-font", ["render.py", "--out", out / "changed-font"], expected=1) + check("changed font is rejected before parsing", "Missing or changed project font asset" in changed.stderr) + finally: + font_path.write_bytes(original_font) + + report.update(ok=True, zip_sha256=manifest["zip_sha256"], pdf_sha256=sha256(pdf).hexdigest(), + native_png_sha256=sha256(native_png.read_bytes()).hexdigest(), fonts=descriptors, + total_cents=139000, pages=1) + if args.update_assets: + shutil.copyfile(pdf_path, ASSETS / "invoice.pdf") + shutil.copyfile(native_png, ASSETS / "invoice.png") + shutil.copyfile(out / "pdfium.png", ASSETS / "invoice-pdfium.png") + (ASSETS / "verification.json").write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", newline="\n") + return report + finally: + (out / "verification.json").write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", newline="\n") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path, default=ROOT / "output/arabic-verification") + parser.add_argument("--zip", type=Path) + parser.add_argument("--update-assets", action="store_true") + result = verify(parser.parse_args()) + print(json.dumps({"ok": result["ok"], "checks": len(result["checks"]), "pdf_sha256": result["pdf_sha256"]}))