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 } + +
+
+[![A two-page project outline with a cream title panel, green display typography, numbered headings, and an automatically numbered table.](assets/numbered-report/page-1.png)](assets/numbered-report/numbered-report.pdf) +
+
+ +**Two pages · Editable HTML/CSS · Automatic numbering** + +CSS assigns chapter, section, table-row, and page numbers. A warm cover panel, +large Inter headings, green decision cards, and orange accents give a short +project outline a clear visual structure. + +[Open the PDF](assets/numbered-report/numbered-report.pdf){ .md-button } +[Download the project](assets/numbered-report/project.zip){ .md-button } + +[Preview page 2](assets/numbered-report/page-2.png) · +[Customize the report](guides/numbered-reports.md) + +
+
+ ## Northstar Studio invoice { #styled-invoice }
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 } + +[![Project outline with a cream title panel, oversized green heading, numbered subsections, and a deliverables table.](../assets/numbered-report/page-1.png)](../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.

+ +
+
+

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 BRIEF

Make 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.DELIVERABLEREADY WHEN
Project briefScope and owners are recorded.
Working prototypeThe core workflow can be demonstrated.
Handoff packageThe 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.

+ +

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()