diff --git a/.gitattributes b/.gitattributes
index 6081089..41ac126 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -46,3 +46,6 @@ docs/assets/node-queue/*.pdf binary
examples/matplotlib-report/** text eol=lf
docs/assets/matplotlib-report/** -text
docs/assets/matplotlib-report/*.pdf binary
+examples/numbered-report/** text eol=lf
+docs/assets/numbered-report/** -text
+docs/assets/numbered-report/*.pdf binary
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 61ab47e..a6d783f 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -13,6 +13,32 @@ concurrency:
cancel-in-progress: true
jobs:
+ numbered-report:
+ name: Numbered report (${{ 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/numbered-report/requirements.txt pypdf==6.19.0 pypdfium2==5.14.0 Pillow==12.3.0
+ - name: Verify the exact download and edited section numbering
+ run: |
+ python tools/build_numbered_report.py
+ git diff --exit-code -- docs/assets/numbered-report/project.zip docs/assets/numbered-report/source.json
+ python tools/verify_numbered_report.py
+ - uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: numbered-report-verification-${{ matrix.os }}
+ path: output/numbered-report-verification
+ retention-days: 14
matplotlib:
name: Matplotlib report (${{ matrix.os }})
strategy:
@@ -456,7 +482,7 @@ jobs:
path: site
deploy:
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
- needs: [build, aspnet, chinese, arabic, markdown, tagged-notice, rust-download, matplotlib]
+ needs: [build, aspnet, chinese, arabic, markdown, tagged-notice, rust-download, matplotlib, numbered-report]
runs-on: ubuntu-latest
permissions:
pages: write
diff --git a/docs/assets/numbered-report/numbered-report.pdf b/docs/assets/numbered-report/numbered-report.pdf
new file mode 100644
index 0000000..38e35c4
Binary files /dev/null and b/docs/assets/numbered-report/numbered-report.pdf differ
diff --git a/docs/assets/numbered-report/page-1.png b/docs/assets/numbered-report/page-1.png
new file mode 100644
index 0000000..f0dd4b4
Binary files /dev/null and b/docs/assets/numbered-report/page-1.png differ
diff --git a/docs/assets/numbered-report/page-2.png b/docs/assets/numbered-report/page-2.png
new file mode 100644
index 0000000..0aa1a77
Binary files /dev/null and b/docs/assets/numbered-report/page-2.png differ
diff --git a/docs/assets/numbered-report/project.zip b/docs/assets/numbered-report/project.zip
new file mode 100644
index 0000000..25d7635
Binary files /dev/null and b/docs/assets/numbered-report/project.zip differ
diff --git a/docs/assets/numbered-report/render.json b/docs/assets/numbered-report/render.json
new file mode 100644
index 0000000..1937a81
--- /dev/null
+++ b/docs/assets/numbered-report/render.json
@@ -0,0 +1,11 @@
+{
+ "version": "2.5.18",
+ "pages": 2,
+ "pdf_sha256": "4eeb4c3282b8788453c0b1df45a186e04bdff6a7f18cb08f9b7b411913d9f860",
+ "html_sha256": "60eb5812ae7bc49b928d525aa9426e1ef4dd7fdd3cdca8aded84b1f73707bc4b",
+ "css_sha256": "7777c211a03725360577bbade9889fb843e75ea86c1fc42b39d8648986b6bc7c",
+ "font_sha256": "29160a80ff49ddcab2c97711247e08b1fab27a484a329ce8b813d820dc559031",
+ "missing_glyphs": [],
+ "deterministic_replay": true,
+ "fictional_content": true
+}
diff --git a/docs/assets/numbered-report/source.json b/docs/assets/numbered-report/source.json
new file mode 100644
index 0000000..cb1631a
--- /dev/null
+++ b/docs/assets/numbered-report/source.json
@@ -0,0 +1,37 @@
+{
+ "source": "examples/numbered-report",
+ "files": [
+ {
+ "path": "LICENSE",
+ "bytes": 1079,
+ "sha256": "36a2115938f6e68cf0d87eccff7a6a3e57c17017e909cbd640d51aff1fa0c5a9"
+ },
+ {
+ "path": "README.md",
+ "bytes": 1467,
+ "sha256": "ffc953efb779d3b5d03a8e9b01eb7c9209317935b2a81dca38cbd40997b9f80e"
+ },
+ {
+ "path": "render.py",
+ "bytes": 2095,
+ "sha256": "d5434b5a8cb2efd17e58513d690765e5984d17283881a40d2ad8b3799da888ec"
+ },
+ {
+ "path": "report.css",
+ "bytes": 3557,
+ "sha256": "7777c211a03725360577bbade9889fb843e75ea86c1fc42b39d8648986b6bc7c"
+ },
+ {
+ "path": "report.html",
+ "bytes": 2918,
+ "sha256": "60eb5812ae7bc49b928d525aa9426e1ef4dd7fdd3cdca8aded84b1f73707bc4b"
+ },
+ {
+ "path": "requirements.txt",
+ "bytes": 18,
+ "sha256": "75d4b9591839d030cd0b987ea45133605bdf19743b768e7016ba2c7e5713de5a"
+ }
+ ],
+ "zip_bytes": 5682,
+ "zip_sha256": "b0abc7244daa3858b834f265229b10d538116ff5578e364f4c99fb3e8573ba00"
+}
diff --git a/docs/assets/numbered-report/verification.json b/docs/assets/numbered-report/verification.json
new file mode 100644
index 0000000..3b6c367
--- /dev/null
+++ b/docs/assets/numbered-report/verification.json
@@ -0,0 +1,38 @@
+{
+ "ok": true,
+ "platform": "Windows",
+ "version": "2.5.18",
+ "zip_sha256": "b0abc7244daa3858b834f265229b10d538116ff5578e364f4c99fb3e8573ba00",
+ "baseline": {
+ "pages": 2,
+ "sha256": "4eeb4c3282b8788453c0b1df45a186e04bdff6a7f18cb08f9b7b411913d9f860",
+ "sections": [
+ "1.1",
+ "1.2",
+ "1.3",
+ "2.1",
+ "2.2",
+ "2.3"
+ ],
+ "two_independent_readers": true,
+ "embedded_fonts": true,
+ "text_within_page_bounds": true
+ },
+ "edited": {
+ "pages": 3,
+ "sha256": "8c003003e2f539ec1f2141c96696c4612319c027b7a9f51e4d082bff63482cc9",
+ "sections": [
+ "1.1",
+ "1.2",
+ "1.3",
+ "1.4",
+ "2.1",
+ "2.2",
+ "2.3"
+ ],
+ "two_independent_readers": true,
+ "embedded_fonts": true,
+ "text_within_page_bounds": true
+ },
+ "scope": "This downloaded report and one inserted section; no complete CSS counter or PDF standards conformance claim."
+}
diff --git a/docs/examples.md b/docs/examples.md
index ae628ea..8f956f0 100644
--- a/docs/examples.md
+++ b/docs/examples.md
@@ -9,16 +9,41 @@ Designed invoices, reports, notices, and statements. These examples use Fullblee
embedded typography, grids, tables, color, and vector graphics to turn structured
data into finished pages.
-Every preview comes from the actual downloadable PDF. The first four examples
-were generated with the public **Fullbleed 2.4.0** wheel; the pandas report below
+Every preview comes from the actual downloadable PDF. The Northstar invoice,
+Common Ground report, Riverton notice, and Hillside statements were generated
+with the public **Fullbleed 2.4.0** wheel; the pandas report below
uses **2.5.2**, the bilingual invoice uses **2.5.8**, and the Markdown brief uses
-**2.5.10**. All sample data and organizations are fictional.
+**2.5.10**. The numbered report uses **2.5.18**. All sample data and organizations
+are fictional.
[Download showcase sources](assets/showcase/source.zip){ .md-button .md-button--primary }
[Browse the source](https://github.com/fullbleed-engine/fullbleed-official/tree/b57e8ea5bb8315f04f7daac41a3489d21a2be158/examples/design_showcase){ .md-button }
[Edit these designs in your browser](playground.md){ .md-button }
+## From brief to handoff { #numbered-report }
+
+
diff --git a/docs/guides/numbered-reports.md b/docs/guides/numbered-reports.md
new file mode 100644
index 0000000..5dbd0dd
--- /dev/null
+++ b/docs/guides/numbered-reports.md
@@ -0,0 +1,119 @@
+---
+title: Numbered PDF reports with HTML and CSS in Python
+description: Create a designed PDF report with automatic chapter, section, table-row, and page numbers. Download the editable HTML/CSS and Python project.
+---
+
+# Numbered reports with print CSS
+
+Keep chapter and section numbers in the stylesheet. When you add a section or
+reorder a chapter, CSS counters assign its number during PDF generation.
+This two-page project outline pairs automatic numbering with large Inter
+headings, a warm cover panel, a deliverables table, and restrained green and
+orange accents.
+
+[Open the PDF](../assets/numbered-report/numbered-report.pdf){ .md-button .md-button--primary }
+[Get the editable project](../assets/numbered-report/project.zip){ .md-button }
+
+[](../assets/numbered-report/numbered-report.pdf)
+
+The fictional report uses **Fullbleed 2.5.18** and the Inter font bundled with
+that wheel. These previews come from its saved PDF.
+[View page 2](../assets/numbered-report/page-2.png).
+
+## Run and edit
+
+Extract the ZIP, open `fullbleed-numbered-report`, and use Python 3.10 or newer
+in a virtual environment:
+
+```sh
+python -m pip install -r requirements.txt
+python render.py --out output
+```
+
+Open `output/numbered-report.pdf`. The directory also contains finalized PDF
+previews, a glyph report, and `render.json` with input/font hashes and a repeated
+render check. The script requires a fresh output folder.
+
+Edit `report.html` for the content and `report.css` for the design. Change the
+palette, type sizes, page margins, or the table's columns. Run again with
+`python render.py --out output-edited`. The font is registered explicitly from
+the installed wheel; rendering needs no browser or font download.
+
+## Chapters and sections
+
+The headings are siblings inside `main`. A chapter increments one counter and
+resets another; subsequent section headings inherit that reset:
+
+```html
+
+ Define the work
+ Set an outcome
+ Agree on the boundaries
+ Deliver with confidence
+ Record the decisions
+
+```
+
+```css
+main { counter-reset: chapter; }
+.chapter { counter-increment: chapter; counter-reset: section; }
+.section { counter-increment: section; }
+.chapter::before { content: counter(chapter, decimal-leading-zero) " / "; }
+.section::before { content: counter(chapter) "." counter(section) " "; }
+```
+
+The section labels become `1.1`, `1.2`, then `2.1`. Keep this sibling structure
+when editing the sample: wrapping headings in additional containers changes
+counter scope. The second chapter's `.next-page` class forces a page break;
+the numbers continue from the document structure.
+
+## Table rows and page footers
+
+The first page's table resets a separate counter on `tbody`, increments it on
+each row, and prints it in an empty first cell:
+
+```css
+.deliverables tbody { counter-reset: deliverable; }
+.deliverables tbody tr { counter-increment: deliverable; }
+.deliverables td.number::before {
+ content: counter(deliverable, decimal-leading-zero);
+}
+```
+
+Page numbers use page-margin boxes. They reflect final pagination, including
+additional pages caused by longer content:
+
+```css
+@page {
+ size: A4;
+ margin: 17mm 19mm 18mm;
+ @bottom-right {
+ content: counter(page) " / " counter(pages);
+ font-family: Inter;
+ font-size: 8pt;
+ }
+}
+```
+
+Set the margin-box font with explicit `font-family` and `font-size`
+declarations, as the starter does for both footers.
+
+## Check the result
+
+Review both PDF previews after changes. The starter stops before writing a PDF
+if the registered font lacks a required glyph. Its repeat check compares bytes
+within the same installed runtime; it does not promise identical PDF bytes
+across engine versions.
+
+The download is checked with independent PDF readers for section numbers,
+table labels, embedded fonts, and page bounds. Its editing check inserts a
+section and verifies that the next chapter still begins at `2.1`. These checks
+cover this report and the supplied counter patterns, rather than complete CSS
+counter or PDF standards conformance.
+
+[Read the verification record](../assets/numbered-report/verification.json) for
+the package version, PDF hashes, and scoped checks.
+
+For data-driven documents, see [invoices](invoices.md) and
+[variable-data documents](bank-statements.md). For a Markdown content workflow,
+start with the [Markdown brief](markdown-pdf.md).
diff --git a/examples/numbered-report/LICENSE b/examples/numbered-report/LICENSE
new file mode 100644
index 0000000..4c99bbd
--- /dev/null
+++ b/examples/numbered-report/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/numbered-report/README.md b/examples/numbered-report/README.md
new file mode 100644
index 0000000..b8ad269
--- /dev/null
+++ b/examples/numbered-report/README.md
@@ -0,0 +1,33 @@
+# The numbered report
+
+A fictional, two-page project outline with editable HTML and print CSS. Chapters,
+subsections, table rows, and page footers receive their numbers from the stylesheet.
+
+Use Python 3.10 or newer in a virtual environment:
+
+```sh
+python -m pip install -r requirements.txt
+python render.py --out output
+```
+
+Open `output/numbered-report.pdf` and both `page-*.png` previews. The command also
+saves input/font hashes, a glyph report, and a deterministic replay result in
+`render.json`. It requires a fresh output directory so old pages cannot remain.
+
+Edit `report.html` to change the content. Keep chapter and section headings as
+siblings inside `main`: `.chapter` resets the section counter, and `.section`
+increments it. Insert another section without typing a number yourself.
+Edit `report.css` for colors, typography, page size, margins, and page breaks.
+The second chapter explicitly starts a new page; longer content can add pages.
+
+The pinned wheel supplies the registered Inter font. Rendering does not fetch
+remote fonts or require a browser. After editing, run with another output path:
+
+```sh
+python render.py --out output-edited
+```
+
+Inspect every page before sharing. The glyph check stops the script before it
+writes a PDF when the registered font cannot cover the text. This example does
+not claim PDF/UA, PDF/A, or complete CSS counter conformance. The sample content
+and Northstar project are fictional. Source code is MIT licensed.
diff --git a/examples/numbered-report/render.py b/examples/numbered-report/render.py
new file mode 100644
index 0000000..3bf28fe
--- /dev/null
+++ b/examples/numbered-report/render.py
@@ -0,0 +1,50 @@
+"""Render the editable report and retain previews of its final PDF."""
+from pathlib import Path
+import argparse
+import hashlib
+from importlib.metadata import version
+import json
+
+import fullbleed
+import fullbleed_assets
+
+
+def digest(data):
+ return hashlib.sha256(data).hexdigest()
+
+
+def main():
+ root = Path(__file__).resolve().parent
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--out', type=Path, default=root/'output', help='New output directory')
+ args = parser.parse_args()
+ args.out.mkdir(parents=True, exist_ok=False)
+ font = Path(fullbleed_assets.asset_path('fonts/Inter-Variable.ttf'))
+ if not font.is_file():
+ raise FileNotFoundError(font)
+ html = (root/'report.html').read_text(encoding='utf-8')
+ css = (root/'report.css').read_text(encoding='utf-8')
+ engine = fullbleed.PdfEngine(font_files=[str(font)],
+ document_title='From brief to handoff', document_lang='en-US')
+ pdf, missing = engine.render_pdf_with_glyph_report(html, css)
+ (args.out/'glyph-report.json').write_text(json.dumps(missing, indent=2)+'\n', encoding='utf-8')
+ if missing:
+ raise ValueError('Missing glyphs; no PDF written. See glyph-report.json.')
+ pdf = bytes(pdf)
+ if pdf != bytes(engine.render_pdf(html, css)):
+ raise ValueError('The repeated render changed.')
+ path = args.out/'numbered-report.pdf'
+ path.write_bytes(pdf)
+ previews = engine.render_finalized_pdf_image_pages(str(path), 110)
+ for i, png in enumerate(previews, 1):
+ (args.out/f'page-{i}.png').write_bytes(bytes(png))
+ record = dict(version=version('fullbleed'), pages=len(previews), pdf_sha256=digest(pdf),
+ html_sha256=digest((root/'report.html').read_bytes()),
+ css_sha256=digest((root/'report.css').read_bytes()), font_sha256=digest(font.read_bytes()),
+ missing_glyphs=missing, deterministic_replay=True, fictional_content=True)
+ (args.out/'render.json').write_text(json.dumps(record, indent=2)+'\n', encoding='utf-8')
+ print(json.dumps(record))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/examples/numbered-report/report.css b/examples/numbered-report/report.css
new file mode 100644
index 0000000..44c59e5
--- /dev/null
+++ b/examples/numbered-report/report.css
@@ -0,0 +1,42 @@
+@page {
+ size: A4; margin: 17mm 19mm 18mm;
+ @bottom-left { content: "FULLBLEED / THE NUMBERED REPORT"; font-family: Inter; font-size: 7pt; color: #526b66; }
+ @bottom-right { content: counter(page) " / " counter(pages); font-family: Inter; font-size: 8pt; color: #123f3a; }
+}
+* { box-sizing: border-box; }
+body { margin: 0; font-family: Inter; font-size: 10.5pt; line-height: 1.5; color: #263f3b; }
+p { margin: 0 0 9pt; }
+.masthead { display: flex; justify-content: space-between; border-bottom: 2pt solid #123f3a; padding-bottom: 10pt; margin-bottom: 21pt; }
+.brand { color: #123f3a; font-size: 10pt; font-weight: 800; letter-spacing: 1.8pt; }
+.edition { font-size: 8pt; letter-spacing: 1pt; }
+.hero { background: #f4eee3; padding: 22pt 24pt 17pt; border-left: 5pt solid #ea6d3f; margin-bottom: 22pt; }
+.eyebrow { font-size: 7.5pt; font-weight: 700; letter-spacing: 1.4pt; color: #a64425; }
+h1 { margin: 10pt 0 13pt; font-size: 38pt; line-height: 1.03; letter-spacing: -1.5pt; font-weight: 800; color: #123f3a; }
+.deck { font-size: 11pt; line-height: 1.55; margin-bottom: 17pt; }
+.metadata { display: flex; justify-content: space-between; border-top: 0.6pt solid #c4c9bc; padding-top: 10pt; font-size: 6.8pt; letter-spacing: 0.6pt; }
+main { counter-reset: chapter; }
+.chapter { counter-increment: chapter; counter-reset: section; font-size: 21pt; line-height: 1.15; margin: 0 0 18pt; color: #123f3a; letter-spacing: -0.5pt; break-after: avoid; }
+.chapter::before { content: counter(chapter, decimal-leading-zero) " / "; color: #d05d32; }
+.section { counter-increment: section; font-size: 11.5pt; line-height: 1.3; font-weight: 750; color: #123f3a; margin: 13pt 0 6pt; break-after: avoid; }
+.section::before { content: counter(chapter) "." counter(section) " "; color: #b95330; }
+.callout { padding: 11pt 14pt; background: #e7efea; border-left: 2pt solid #123f3a; margin: 12pt 0 15pt; break-inside: avoid; }
+.callout-label { display: block; font-size: 7pt; font-weight: 750; letter-spacing: 1pt; margin-bottom: 5pt; }
+.callout p { margin: 0; font-size: 10.5pt; }
+.deliverables { border-collapse: collapse; width: 100%; margin-top: 10pt; font-size: 9pt; }
+.deliverables th { padding: 8pt; background: #123f3a; color: white; text-align: left; font-size: 7pt; letter-spacing: 0.8pt; }
+.deliverables td { padding: 9pt 8pt; border-bottom: 0.6pt solid #bccdc6; }
+.deliverables tbody { counter-reset: deliverable; }
+.deliverables tbody tr { counter-increment: deliverable; }
+.deliverables td.number::before { content: counter(deliverable, decimal-leading-zero); color: #b95330; font-weight: 700; }
+.deliverables .number { width: 35pt; }
+.deliverables tr { break-inside: avoid; }
+.next-page { break-before: page; padding-top: 9pt; margin-bottom: 10pt; }
+.chapter-deck { font-size: 14pt; line-height: 1.4; color: #62776e; margin-bottom: 24pt; }
+.decision-card { padding: 23pt; border: 0.75pt solid #c2cfc7; background: #f7f8f1; margin: 17pt 0 21pt; break-inside: avoid; }
+h4 { font-size: 21pt; line-height: 1.2; letter-spacing: -0.5pt; margin: 10pt 0 12pt; color: #123f3a; }
+.card-meta { font-size: 7pt; letter-spacing: 0.8pt; margin-top: 15pt; margin-bottom: 0; color: #526b66; }
+ul { margin: 10pt 0 22pt; padding-left: 15pt; }
+li { margin-bottom: 4pt; padding-left: 3pt; }
+.closing { background: #123f3a; color: #ffffff; padding: 25pt; margin-top: 27pt; break-inside: avoid; }
+.closing p { font-size: 27pt; line-height: 1.13; font-weight: 700; letter-spacing: -0.8pt; margin: 0 0 17pt; }
+.closing span { font-size: 7pt; letter-spacing: 1.1pt; color: #c7d7cf; }
diff --git a/examples/numbered-report/report.html b/examples/numbered-report/report.html
new file mode 100644
index 0000000..15bb9b8
--- /dev/null
+++ b/examples/numbered-report/report.html
@@ -0,0 +1,35 @@
+
+
+
FULLBLEEDFIELD NOTES / 01
+
+
THE NUMBERED REPORT
+
From brief
to handoff.
+
A practical project outline, numbered by CSS.
Edit the content. Keep the structure.
+
PROJECT / NORTHSTARSTATUS / WORKING DRAFT
+
+
+ Define the work
+ Set an outcome
+ Describe the change the project should make. Give the team a concrete result to work toward and a clear way to recognize it.
+ THE WORKING BRIEFMake the first successful handoff easy to understand, easy to repeat, and easy to verify.
+ Agree on the boundaries
+ Record what is included, who owns each decision, and what would change the plan. Keep the scope close to the work.
+ Choose the deliverables
+ | NO. | DELIVERABLE | READY WHEN |
+ | Project brief | Scope and owners are recorded. |
+ | Working prototype | The core workflow can be demonstrated. |
+ | Handoff package | The next person can reproduce the result. |
+
+ Deliver with confidence
+ A useful handoff makes the next step clear.
+ Record the decisions
+ Explain the choices that affect the result. Include the assumptions that matter, the alternatives considered, and the evidence behind the decision.
+ DECISION LOG / EXAMPLE
Keep the document editable.
Store the content and styles with the project. A future edit should be a small, reviewable change to the source.
OWNER / PROJECT TEAM · REVIEW / NEXT MILESTONE
+ Check the handoff
+ Open the final PDF. Check its text, page breaks, headings, and tables. Then rerun the generation command from a fresh output folder.
+ - The document answers the brief.
- Every section has a useful heading.
- The source, assets, and run command travel together.
+ Leave a clear next step
+ Name the next owner and the action they can take. Keep this instruction near the final output so the work can continue without a separate explanation.
+ One stylesheet.
Every heading numbered.
HTML + CSS / EDITABLE SOURCE INCLUDED
+
+
diff --git a/examples/numbered-report/requirements.txt b/examples/numbered-report/requirements.txt
new file mode 100644
index 0000000..50c3546
--- /dev/null
+++ b/examples/numbered-report/requirements.txt
@@ -0,0 +1 @@
+fullbleed==2.5.18
diff --git a/mkdocs.yml b/mkdocs.yml
index c11ada9..e98a6a2 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -81,6 +81,7 @@ nav:
- Examples and downloads: examples.md
- Build a document:
- Invoices from data: guides/invoices.md
+ - Numbered reports: guides/numbered-reports.md
- Markdown to PDF: guides/markdown-pdf.md
- Chinese text and fonts: guides/chinese-pdf.md
- Arabic and English PDFs: guides/arabic-pdf.md
diff --git a/tools/build_numbered_report.py b/tools/build_numbered_report.py
new file mode 100644
index 0000000..bcc3dfa
--- /dev/null
+++ b/tools/build_numbered_report.py
@@ -0,0 +1,34 @@
+"""Package the numbered report's explicit sources reproducibly."""
+from hashlib import sha256
+from pathlib import Path
+import json
+import zipfile
+
+ROOT = Path(__file__).resolve().parents[1]
+SOURCE = ROOT/'examples/numbered-report'
+DESTINATION = ROOT/'docs/assets/numbered-report'
+FILES = ['LICENSE', 'README.md', 'requirements.txt', 'render.py', 'report.html', 'report.css']
+
+
+def main():
+ DESTINATION.mkdir(parents=True, exist_ok=True)
+ records = []
+ archive_path = DESTINATION/'project.zip'
+ with zipfile.ZipFile(archive_path, 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
+ for name in sorted(FILES):
+ data = (SOURCE/name).read_bytes()
+ entry = zipfile.ZipInfo('fullbleed-numbered-report/'+name, (2026, 1, 1, 0, 0, 0))
+ # Preserve the published ZIP bytes on Windows and Unix hosts.
+ entry.create_system = 0
+ entry.compress_type = zipfile.ZIP_DEFLATED
+ entry.external_attr = 0o100644 << 16
+ archive.writestr(entry, data)
+ records.append(dict(path=name, bytes=len(data), sha256=sha256(data).hexdigest()))
+ manifest = dict(source='examples/numbered-report', files=records,
+ zip_bytes=archive_path.stat().st_size, zip_sha256=sha256(archive_path.read_bytes()).hexdigest())
+ (DESTINATION/'source.json').write_bytes((json.dumps(manifest, indent=2)+'\n').encode('utf-8'))
+ print(json.dumps(dict(files=len(records), zip_bytes=manifest['zip_bytes'], zip_sha256=manifest['zip_sha256'])))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/tools/verify_numbered_report.py b/tools/verify_numbered_report.py
new file mode 100644
index 0000000..ecdd982
--- /dev/null
+++ b/tools/verify_numbered_report.py
@@ -0,0 +1,111 @@
+"""Execute the downloadable report, edit it, and inspect both PDFs independently."""
+from hashlib import sha256
+from importlib.metadata import version
+from pathlib import Path
+import argparse
+import json
+import os
+import platform
+import re
+import subprocess
+import sys
+import zipfile
+
+from pypdf import PdfReader
+import pypdfium2 as pdfium
+
+ROOT = Path(__file__).resolve().parents[1]
+ASSETS = ROOT/'docs/assets/numbered-report'
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--out', type=Path, default=ROOT/'output/numbered-report-verification')
+ parser.add_argument('--expected-version', default='2.5.18')
+ args = parser.parse_args()
+ out = args.out.resolve()
+ out.mkdir(parents=True, exist_ok=False)
+ assert version('fullbleed') == args.expected_version
+ manifest = json.loads((ASSETS/'source.json').read_text(encoding='utf-8'))
+ archive_path = ASSETS/'project.zip'
+ assert sha256(archive_path.read_bytes()).hexdigest() == manifest['zip_sha256']
+ extracted = out/'project with spaces'
+ extracted.mkdir()
+ project = extracted/'fullbleed-numbered-report'
+ with zipfile.ZipFile(archive_path) as archive:
+ assert archive.testzip() is None
+ assert set(archive.namelist()) == {'fullbleed-numbered-report/'+r['path'] for r in manifest['files']}
+ for item in manifest['files']:
+ data = archive.read('fullbleed-numbered-report/'+item['path'])
+ assert len(data) == item['bytes'] and sha256(data).hexdigest() == item['sha256']
+ assert data == (ROOT/'examples/numbered-report'/item['path']).read_bytes()
+ path = project/item['path']
+ assert path.resolve().is_relative_to(project.resolve())
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_bytes(data)
+
+ def run(label):
+ destination = out/label
+ result = subprocess.run([sys.executable, '-I', str(project/'render.py'), '--out', str(destination)],
+ cwd=out, env={**os.environ, 'PYTHONUTF8':'1', 'PYTHONPATH':''},
+ capture_output=True, text=True, encoding='utf-8', timeout=120)
+ (out/(label+'.stdout.txt')).write_text(result.stdout, encoding='utf-8')
+ (out/(label+'.stderr.txt')).write_text(result.stderr, encoding='utf-8')
+ assert result.returncode == 0, (label, result.stderr)
+ record = json.loads((destination/'render.json').read_text())
+ assert record['deterministic_replay'] and not record['missing_glyphs']
+ assert record['version'] == args.expected_version
+ return destination
+
+ def inspect(folder, sections, pages=None):
+ path = folder/'numbered-report.pdf'
+ reader = PdfReader(path)
+ assert len(reader.pages) == pages if pages else len(reader.pages) > 0
+ text = '\n'.join(page.extract_text() for page in reader.pages)
+ assert re.findall(r'\b[12]\.[1-4]\b', text) == sections, text
+ compact = re.sub(r'\s+', '', text)
+ for label in ['01Projectbrief', '02Workingprototype', '03Handoffpackage']:
+ assert label in compact, (label, text)
+ fonts = [ref.get_object() for page in reader.pages for ref in page['/Resources']['/Font'].values()]
+ assert fonts and all(font.get('/Subtype') == '/Type0' and
+ '/FontFile2' in font['/DescendantFonts'][0]['/FontDescriptor'] for font in fonts)
+ other = []
+ with pdfium.PdfDocument(str(path)) as document:
+ for i in range(len(document)):
+ page = document[i]
+ textpage = page.get_textpage()
+ other.append(textpage.get_text_range())
+ boxes = [textpage.get_charbox(j) for j in range(textpage.count_chars())]
+ painted = [(l,b,r,t) for l,b,r,t in boxes if r > l and t > b]
+ assert painted and all(l >= -.2 and b >= -.2 and r <= page.get_width()+.2 and t <= page.get_height()+.2
+ for l,b,r,t in painted), (folder.name, i, 'text outside page')
+ page.render(scale=110/72).to_pil().save(folder/f'pdfium-{i+1}.png')
+ textpage.close()
+ page.close()
+ other = '\n'.join(other)
+ assert re.findall(r'\b[12]\.[1-4]\b', other) == sections, other
+ assert '\ufffd' not in text+other
+ if pages:
+ assert all(f'{i+1} / {pages}' in page.extract_text() for i, page in enumerate(reader.pages))
+ (folder/'pypdf.txt').write_text(text, encoding='utf-8')
+ (folder/'pdfium.txt').write_text(other, encoding='utf-8')
+ return dict(pages=len(reader.pages), sha256=sha256(path.read_bytes()).hexdigest(), sections=sections,
+ two_independent_readers=True, embedded_fonts=True, text_within_page_bounds=True)
+
+ baseline = inspect(run('render'), ['1.1','1.2','1.3','2.1','2.2','2.3'], pages=2)
+ html = (project/'report.html').read_text(encoding='utf-8')
+ marker = '
'
+ assert html.count(marker) == 1
+ html = html.replace(marker, ' Assign the next owner
\n'+marker)
+ (project/'report.html').write_text(html, encoding='utf-8')
+ edited = inspect(run('edited'), ['1.1','1.2','1.3','1.4','2.1','2.2','2.3'])
+ assert edited['sha256'] != baseline['sha256']
+ result = dict(ok=True, platform=platform.system(), version=version('fullbleed'),
+ zip_sha256=manifest['zip_sha256'], baseline=baseline, edited=edited,
+ scope='This downloaded report and one inserted section; no complete CSS counter or PDF standards conformance claim.')
+ (out/'verification.json').write_text(json.dumps(result, indent=2)+'\n', encoding='utf-8')
+ print(json.dumps(result))
+
+
+if __name__ == '__main__':
+ main()