diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 8274465..b7685d7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -102,6 +102,17 @@ jobs: output/reference-env/bin/python -I tools/verify_quickstarts.py - name: Verify the downloadable Node invoice and guide run: python tools/verify_node_examples.py + - name: Verify the downloadable Python web starter and actual HTTP servers + run: python tools/verify_python_web_starter.py + - uses: actions/upload-artifact@v4 + if: always() + with: + name: python-web-starter-verification + path: | + output/python-web-verification/*.json + output/python-web-verification/*.log + output/python-web-verification/checks + retention-days: 14 - uses: actions/setup-dotnet@v4 with: dotnet-version: '10.0.x' diff --git a/docs/assets/python-web/invoice.pdf b/docs/assets/python-web/invoice.pdf new file mode 100644 index 0000000..2b1cd79 Binary files /dev/null and b/docs/assets/python-web/invoice.pdf differ diff --git a/docs/assets/python-web/invoice.png b/docs/assets/python-web/invoice.png new file mode 100644 index 0000000..0018a8c Binary files /dev/null and b/docs/assets/python-web/invoice.png differ diff --git a/docs/assets/python-web/project.zip b/docs/assets/python-web/project.zip new file mode 100644 index 0000000..c600aae Binary files /dev/null and b/docs/assets/python-web/project.zip differ diff --git a/docs/assets/python-web/source.json b/docs/assets/python-web/source.json new file mode 100644 index 0000000..c2bb183 --- /dev/null +++ b/docs/assets/python-web/source.json @@ -0,0 +1,151 @@ +{ + "schema": "fullbleed.python-web-starter.v1", + "engineVersion": "2.5.6", + "sourceRepository": "https://github.com/fullbleed-engine/fullbleed-official", + "sourceCommit": "76769ccd58426717c10cf0d988838182c203382f", + "files": [ + { + "path": ".gitignore", + "bytes": 28, + "sha256": "d3b4beec83000bfbd6f81222b601d367412f30ef10bf55a6bc51b275bdfd905f" + }, + { + "path": "LICENSE", + "bytes": 1079, + "sha256": "36a2115938f6e68cf0d87eccff7a6a3e57c17017e909cbd640d51aff1fa0c5a9" + }, + { + "path": "README.md", + "bytes": 5145, + "sha256": "b70edbd2649dbf4ea64ebeedaba6522ef8b484c02309febfde22be8babd99b78" + }, + { + "path": "check_examples.py", + "bytes": 6388, + "sha256": "2e675d4b0a1d55cba31fa0dd8d4f1eb6ce779af32ade71b90c2a0b2c63988628" + }, + { + "path": "check_http.py", + "bytes": 4400, + "sha256": "740ae2957d1651b17642201df800019eec8521a1f9829c99379449399c98a043" + }, + { + "path": "demo.py", + "bytes": 394, + "sha256": "82eec29da66e5edfb5460cf0c8ea9d5595f499e26b5688c8c61f07ae92a24fb3" + }, + { + "path": "django_app.py", + "bytes": 1631, + "sha256": "90707de21765ba8f72e22e271b6fd0a3a9513b2416056472d01efdc27f7e8f16" + }, + { + "path": "fastapi_app.py", + "bytes": 1081, + "sha256": "c5d94fedf92142755995066be17338da6edbaa7a9b37cc8cd376f64dd47a1202" + }, + { + "path": "flask_app.py", + "bytes": 808, + "sha256": "8195fb6020b1a0c5e53026099860973965a1c643b4c319676fa39047cee023d7" + }, + { + "path": "fonts/.gitattributes", + "bytes": 120, + "sha256": "5de00bfac958cb296380be2135dbbc986dd1f25103ea0f6495231c61affa9c72" + }, + { + "path": "fonts/BebasNeue-OFL.txt", + "bytes": 4337, + "sha256": "72082f6cb4d04be2ecf7cc7d9e1e7d73787f0af8a5a278a47cade70c16b78341" + }, + { + "path": "fonts/BebasNeue-Regular.ttf", + "bytes": 61400, + "sha256": "08e4623805102d819f58601e46e345648846075e363b2ceb23313c2d1c83ec73" + }, + { + "path": "fonts/DMSerifDisplay-Italic.ttf", + "bytes": 71208, + "sha256": "df74c0ac387baeaeb0fe4f2324e1668e6a3ed8c09cd9796fe162c71753e19e45" + }, + { + "path": "fonts/DMSerifDisplay-OFL.txt", + "bytes": 4605, + "sha256": "a3e5cdd67d4571dd0a24fcc968de0efde7ae97ef752daf0906e4767619dd7231" + }, + { + "path": "fonts/DMSerifDisplay-Regular.ttf", + "bytes": 76580, + "sha256": "8cc3643535edf039aa5d95440a8542735e9197e4f4b8d9303e980fefbf5ab616" + }, + { + "path": "fonts/sources.json", + "bytes": 1592, + "sha256": "01c23f8e23d039ef6a9945183ff98fd0022975227ae949de4f1669f06ad18db7" + }, + { + "path": "invoice.py", + "bytes": 2868, + "sha256": "45b7ca7f4fd9aa4201aacb295d9b8f03b772244325d0935bb6205806d6b862d6" + }, + { + "path": "requirements-check.txt", + "bytes": 183, + "sha256": "cbfe62b7f173711ea4ba4c165a684ddb2c70c9f7954ab7dac3980977e0a59659" + }, + { + "path": "requirements-django.txt", + "bytes": 15, + "sha256": "56cbd1c0a43496327d9af1bf231abd19bb1b0e2f0a14734319fe3108dda90b13" + }, + { + "path": "requirements-fastapi.txt", + "bytes": 33, + "sha256": "5feb6a8b0c09ed30d831399662a75525ea956cd35c0311ecfa178f87d5a25941" + }, + { + "path": "requirements-flask.txt", + "bytes": 13, + "sha256": "35fde835d2cdf4ccbfb67738c7e5d2f8829fd441d3cbcb34b1f4c948a0a11cd4" + }, + { + "path": "static/index.html", + "bytes": 4187, + "sha256": "acfd0db1efab50b1229ad937e7bd3075a3b7146de0dfddb33247d0b85bc429a3" + }, + { + "path": "static/invoice.png", + "bytes": 130289, + "sha256": "a38d23651c355d1b87bc16fa37fe9121a377a12e6d515b4cd1871750c2241175" + }, + { + "path": "templates/invoice.css", + "bytes": 3546, + "sha256": "cda22b39ab1f7a1eeac7cd76d162b05cd3568c2430c099123ba30a2fbd58a296" + }, + { + "path": "templates/invoice.html", + "bytes": 2299, + "sha256": "c8c522d0f983c9917587083e5472af39c515c447b33ebf3edf2fafcf2f21673e" + } + ], + "checkedAt": "2026-10-04T23:24:23.430920+00:00", + "assets": [ + { + "file": "project.zip", + "bytes": 230094, + "sha256": "6bcd44c551aeace4e658bf96b7528ea7237d386dfebd52de5d48bbdab4712f1c" + }, + { + "file": "invoice.pdf", + "bytes": 91537, + "sha256": "2e908a21e5f9ac5b1fa761d678833c62f3acc772ba7162d8da1b900f861320e6" + }, + { + "file": "invoice.png", + "bytes": 130289, + "sha256": "a38d23651c355d1b87bc16fa37fe9121a377a12e6d515b4cd1871750c2241175" + } + ] +} diff --git a/docs/guides/web-frameworks.md b/docs/guides/web-frameworks.md index 076f041..29261e3 100644 --- a/docs/guides/web-frameworks.md +++ b/docs/guides/web-frameworks.md @@ -6,23 +6,37 @@ description: Serve downloadable PDF invoices from FastAPI, Flask, or Django with Fullbleed returns PDF bytes from Python data and HTML/CSS. Pass those bytes to your framework's response object to serve an invoice, report, or statement. -The examples below generate the same one-page invoice and send it as a download. +The starter below generates a designed one-page invoice and sends it as a +download. It includes a browser page, editable HTML/CSS, and the same document +renderer behind three small framework adapters. -[Get the complete examples](https://github.com/fullbleed-engine/fullbleed-official/tree/master/examples/web_frameworks){ .md-button .md-button--primary } -[Try a PDF in your browser](../getting-started/notebook.md){ .md-button } +[Download the Python starter](../assets/python-web/project.zip){ .md-button .md-button--primary } +[Open the sample PDF](../assets/python-web/invoice.pdf){ .md-button } + +[![Northstar Studio invoice with large green typography, three line items, and an orange-accented total panel.](../assets/python-web/invoice.png)](../assets/python-web/invoice.pdf) + +The [source record and download hashes](../assets/python-web/source.json) identify +the exact source files, fonts, package version, and generated sample. This is a +fictional integration example; connect your application's authorized record +lookup before serving real invoices. ## Set up the examples -Use a Python 3.10–3.14 virtual environment, then get the example source: +Use Python 3.10–3.14. Extract the starter, open its `fullbleed-python-invoice` +directory, and create a virtual environment: ```bash -git clone https://github.com/fullbleed-engine/fullbleed-official.git -cd fullbleed-official/examples/web_frameworks +python -m venv .venv ``` +Activate it with `source .venv/bin/activate` on macOS/Linux or +`.venv\Scripts\Activate.ps1` in Windows PowerShell. A repository checkout also +works: the [source directory](https://github.com/fullbleed-engine/fullbleed-official/tree/master/examples/web_frameworks) +is `examples/web_frameworks`. + Choose one framework below. Each requirement file installs that framework; `fullbleed` remains a separate package. The examples are checked with Fullbleed -2.4.0, FastAPI 0.142.2, Flask 3.1.3, and Django 5.2.17. +2.5.6, FastAPI 0.142.2, Flask 3.1.3, and Django 5.2.17. Each app serves this fictional record: @@ -30,14 +44,15 @@ Each app serves this fictional record: http://127.0.0.1:8000/invoices/INV-1042.pdf ``` -Open that URL after starting the app. The browser downloads `invoice.pdf`, which +Open `http://127.0.0.1:8000/` after starting the app and select **Download invoice**. +The browser downloads `invoice.pdf`, which contains three line items and a total of **USD 1,870.00**. An unknown invoice ID returns HTTP 404. Stop the server before trying another framework on the same port. ## FastAPI ```bash -python -m pip install fullbleed -r requirements-fastapi.txt +python -m pip install fullbleed==2.5.6 -r requirements-fastapi.txt python -m uvicorn fastapi_app:app --host 127.0.0.1 --port 8000 ``` @@ -73,7 +88,7 @@ finishes. See [FastAPI's concurrency documentation](https://fastapi.tiangolo.com ## Flask ```bash -python -m pip install fullbleed -r requirements-flask.txt +python -m pip install fullbleed==2.5.6 -r requirements-flask.txt python -m flask --app flask_app run --host 127.0.0.1 --port 8000 ``` @@ -101,7 +116,7 @@ for response bodies, media types, and headers. ## Django ```bash -python -m pip install fullbleed -r requirements-django.txt +python -m pip install fullbleed==2.5.6 -r requirements-django.txt python django_app.py runserver 127.0.0.1:8000 --noreload ``` @@ -134,10 +149,29 @@ for response construction and headers. ## The shared document renderer [`invoice.py`](https://github.com/fullbleed-engine/fullbleed-official/blob/master/examples/web_frameworks/invoice.py) -contains the fictional record lookup, HTML/CSS template, and `render_invoice()`. +contains the fictional record lookup and `render_invoice()`. It calculates line totals with `Decimal`, escapes text before inserting it into -HTML, and embeds the Inter font shipped with Fullbleed. A new `PdfEngine` renders -each request into memory. +HTML, and registers the Inter font shipped with Fullbleed alongside the included +DM Serif Display and Bebas Neue families. A new `PdfEngine` renders each request +into memory. Font licenses and pinned source hashes are included in `fonts/`. + +## Edit the HTML and CSS + +Change `templates/invoice.html` for document structure and +`templates/invoice.css` for typography, spacing, columns, and color. The renderer +reads those files for each request, so a new download uses your edits without +restarting the server. Its Python `string.Template` placeholders include +`$customer`, `$number`, `$rows`, and `$total`; write a literal dollar sign as `$$`. +Text values are escaped before substitution and are not interpreted again as +template markup. + +The included layout is a fixed one-page A4 sample for three line items. Adapt +its geometry and pagination before using longer records, and review the final +PDF. This starter leaves tax, discounts, invoice numbering, payment collection, +and application authentication to your application. `static/index.html` is the +local demo page; replace it with your application's interface when integrating. + +## Return a private PDF attachment The response headers are shared too: @@ -154,22 +188,36 @@ it does not need JSON or base64 encoding, and requests do not share output files Replace `load_invoice()` with your application's authorized record lookup. It returns an invoice number, customer, date strings, and items with descriptions, -integer quantities, and unit-price strings. Keep access checks before rendering. -This sample omits tax and discounts. +integer quantities, unit-price strings, and an optional item `detail`. +Keep access checks before rendering. ## Verify and deploy your workflow -From the repository root, with Fullbleed installed: +From the extracted starter's directory, with Fullbleed installed: + +```bash +python -m pip install -r requirements-check.txt +python check_examples.py --out output/check +``` + +The check exercises each framework's test client and starts the documented +local servers in turn. It verifies actual HTTP downloads, an independent PDF +reader's text and totals, page count, embedded fonts, headers, 404/405 responses, +and matching simultaneous downloads. It also checks font/license hashes, +literal markup and placeholder-like customer text, and the saved preview. +Servers stop when the checks finish. + +Review `output/check/verification.json`, its PDFs, and the PNG preview after a +layout change. To refresh the download page's saved preview, run: ```bash -python -m pip install -r examples/web_frameworks/requirements-check.txt -python examples/web_frameworks/check_examples.py --out target/web-framework-check +python check_examples.py --out output/check --update-preview ``` -The check exercises each framework's test client, saves its PDF response, and -verifies the expected text and total, page count, embedded font, HTTP headers, -404 behavior, and repeated output bytes. It also emits a PNG preview and -`verification.json`. Review the preview after changing the document's layout. +The browser page shows a saved preview of the included invoice. Its **Download +invoice** link always renders the current HTML/CSS. Verification covers these +synthetic fixtures and local servers; it does not establish production capacity +or PDF standards conformance. The launch commands above run local development servers. Use your framework's deployment setup for a public app. For large jobs, render in your job queue and diff --git a/tools/verify_python_web_starter.py b/tools/verify_python_web_starter.py new file mode 100644 index 0000000..c82e158 --- /dev/null +++ b/tools/verify_python_web_starter.py @@ -0,0 +1,64 @@ +"""Install and exercise the exact downloadable Python web starter in isolation.""" +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +ASSETS = ROOT / "docs/assets/python-web" +OUT = ROOT / "output/python-web-verification" + + +def main(): + OUT.mkdir(parents=True, exist_ok=False) + source = json.loads((ASSETS / "source.json").read_text(encoding="utf-8")) + assert source["engineVersion"] == "2.5.6" + for item in source["assets"]: + data = (ASSETS / item["file"]).read_bytes() + assert len(data) == item["bytes"] + assert hashlib.sha256(data).hexdigest() == item["sha256"], item["file"] + unpacked = OUT / "project with spaces" + with zipfile.ZipFile(ASSETS / "project.zip") as zipped: + assert zipped.testzip() is None + for item in zipped.infolist(): + assert (unpacked / item.filename).resolve().is_relative_to(unpacked.resolve()) + zipped.extractall(unpacked) + project = unpacked / "fullbleed-python-invoice" + manifest = json.loads((project / "MANIFEST.json").read_text(encoding="utf-8")) + assert manifest["sourceCommit"] == source["sourceCommit"] + assert manifest["files"] == source["files"] + for item in manifest["files"]: + data = (project / item["path"]).read_bytes() + assert hashlib.sha256(data).hexdigest() == item["sha256"], item["path"] + venv = OUT / "venv" + subprocess.run([sys.executable, "-m", "venv", str(venv)], check=True) + python = venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + commands = [ + ("install", [str(python), "-m", "pip", "install", "--disable-pip-version-check", + "fullbleed==2.5.6", "-r", "requirements-check.txt"]), + ("verify", [str(python), "check_examples.py", "--out", str(OUT / "checks")]), + ] + for name, command in commands: + with (OUT / (name + ".log")).open("w", encoding="utf-8") as log: + result = subprocess.run(command, cwd=project, stdout=log, stderr=subprocess.STDOUT, timeout=240) + if result.returncode: + raise RuntimeError(f"{name} failed; inspect {OUT / (name + '.log')}") + for framework in ["fastapi", "flask", "django"]: + for suffix in ["", "-http"]: + assert (OUT / "checks" / f"{framework}{suffix}.pdf").read_bytes() == (ASSETS / "invoice.pdf").read_bytes() + assert (OUT / "checks/preview/invoice_page1.png").read_bytes() == (ASSETS / "invoice.png").read_bytes() + report = {"ok": True, "checkedAt": datetime.now(timezone.utc).isoformat(), + "sourceCommit": source["sourceCommit"], "engineVersion": "2.5.6", + "manifestFilesVerified": len(manifest["files"]), + "frameworkClientsPassed": 3, "actualHttpServersPassed": 3, + "pdfAndPreviewMatchPublishedAssets": True, + "scope": "Isolated ZIP install and local HTTP verification; no production deployment claim."} + (OUT / "verification.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8") + print(json.dumps(report)) + + +if __name__ == "__main__": main()