Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 63 additions & 19 deletions examples/web_frameworks/README.md
Original file line number Diff line number Diff line change
@@ -1,36 +1,50 @@
# PDF invoice responses with FastAPI, Flask, and Django

Each app serves the same fictional invoice at
Each app opens a small download page at `http://127.0.0.1:8000/` and serves the same fictional invoice at
`http://127.0.0.1:8000/invoices/INV-1042.pdf`. Fullbleed renders the document into
bytes; the framework returns those bytes as an `application/pdf` attachment.
Unknown invoice IDs return HTTP 404.

The shared [invoice renderer](invoice.py) uses Python `Decimal` for the total,
escapes text for HTML, embeds the bundled Inter font, and creates an engine per
escapes text for HTML, embeds Inter plus the included DM Serif Display and Bebas Neue fonts, and creates an engine per
request. Rendering stays in memory, so concurrent requests do not share output
filenames. The endpoint uses a fixed download filename and disables response
caching.

## Run one app

From a repository checkout, enter this directory in a Python virtual environment:
Use Python 3.10–3.14. Extract the downloadable starter and open its
`fullbleed-python-invoice` directory, or use `examples/web_frameworks` in a
repository checkout. Create and activate a virtual environment first:

```bash
cd examples/web_frameworks
python -m venv .venv
```

Activate it on macOS or Linux:

```bash
source .venv/bin/activate
```

Or in Windows PowerShell:

```powershell
.venv\Scripts\Activate.ps1
```

Choose one framework. Only install its dependencies; these packages are separate
from the dependency-free Fullbleed runtime. The examples are checked with
Fullbleed 2.4.0 and the framework versions pinned in the requirement files.
Fullbleed 2.5.6 and the framework versions pinned in the requirement files.

### 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
```

Open the PDF URL above. FastAPI's interactive API documentation is available at
Open `http://127.0.0.1:8000/` and select **Download invoice**. FastAPI's interactive API documentation is available at
`http://127.0.0.1:8000/docs` and declares the PDF response type.
The route uses a normal `def`, which FastAPI runs in its thread pool, so the
synchronous renderer is not called directly on the async event loop.
Expand All @@ -39,14 +53,14 @@ See [FastAPI's concurrency guide](https://fastapi.tiangolo.com/async/#path-opera
### 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
```

### 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
```

Expand All @@ -56,11 +70,23 @@ that project's settings.

## Use your application data

Edit `templates/invoice.html` for the document structure and
`templates/invoice.css` for its typography, columns, colors, and spacing.
The renderer reads these files for each download, so template edits appear
without restarting the server. The HTML uses Python `string.Template`
placeholders such as `$customer`, `$number`, `$rows`, and `$total`. A literal
dollar sign in the template is written as `$$`.

The included design is a fixed one-page A4 sample for three line items.
When adapting it for longer invoices, change the page geometry and pagination,
then inspect the resulting pages. This starter does not implement tax,
discounts, invoice numbering, payment collection, or application authentication.

Replace `load_invoice()` with your application's authorized record lookup. Keep
access checks in that lookup or in the route before rendering. The helper expects
an invoice number, customer, issued/due date strings, and an item list containing
description, integer quantity, and unit-price strings. This small example omits
tax and discounts. Its record is fictional and requires no database.
description, an optional `detail`, integer quantity, and unit-price strings.
Its record is fictional and requires no database.

These launch commands run local development servers. Use your framework's
deployment setup for a public application. For large jobs, move rendering to your
Expand All @@ -70,15 +96,33 @@ compiled document families.

## Check all three examples

From the repository root, with a built Fullbleed wheel installed:
From this directory, with Fullbleed installed:

```bash
python -m pip install -r requirements-check.txt
python check_examples.py --out output/check
```

The check uses each framework's test client and starts all three documented
local servers in turn. It checks real HTTP downloads, paired requests, headers,
404/405 responses, expected text and totals with an independent PDF reader,
embedded fonts, and matching bytes. Each server stops when its checks finish.
It also verifies FastAPI's OpenAPI media type, literal markup and placeholder-like
customer text, and the saved preview. CI runs it with a built wheel on Windows
and Linux and retains the outputs.

After editing the invoice, update the page's saved preview with:

```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 uses each framework's test client, saves the returned PDFs, checks
headers, missing-record responses, expected text and total, embedded fonts, and
repeat-request bytes. It also checks FastAPI's OpenAPI media type and literal
markup-like customer text, then emits a PNG preview and `verification.json`.
CI runs it with the built wheel on Windows and Linux and retains the outputs.
The download page's screenshot is a saved sample; the PDF download is rendered
on request from the current template. Review `output/check/preview/invoice_page1.png`
and the PDF after a layout change.

## License

Example code and the Fullbleed engine are MIT licensed. The included display
fonts use SIL OFL 1.1; their licenses, pinned upstream sources, and hashes are in
`fonts/`. Inter and its license ship with the installed Fullbleed package.
74 changes: 74 additions & 0 deletions examples/web_frameworks/build_starter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""Build the standalone source ZIP and its actual PDF/PNG sample assets."""
import argparse
from datetime import datetime, timezone
import hashlib
from importlib import metadata
import json
from pathlib import Path
import subprocess
import zipfile

import fullbleed
from invoice import load_invoice, render_invoice

ROOT = Path(__file__).resolve().parent
REPOSITORY = ROOT.parents[1]
FILES = ["README.md", "invoice.py", "demo.py", "fastapi_app.py", "flask_app.py", "django_app.py",
"check_examples.py", "check_http.py", "requirements-fastapi.txt", "requirements-flask.txt",
"requirements-django.txt", "requirements-check.txt", "templates/invoice.html",
"templates/invoice.css", "static/index.html", "static/invoice.png"]


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out", type=Path, required=True)
args = parser.parse_args()
out = args.out.resolve()
source_commit = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=REPOSITORY, text=True).strip()
dirty = subprocess.check_output(["git", "status", "--porcelain", "--", str(ROOT)], cwd=REPOSITORY, text=True).strip()
if dirty:
raise SystemExit("Commit the example source before packaging its public download.")
out.mkdir(parents=True, exist_ok=False)
# Package committed bytes, independent of checkout newline conversion.
def committed(path: Path) -> bytes:
return subprocess.check_output(["git", "show", source_commit+":"+path.relative_to(REPOSITORY).as_posix()], cwd=REPOSITORY)
content = {name: committed(ROOT/name) for name in FILES}
for path in sorted((ROOT/"fonts").iterdir()):
if path.is_file(): content[path.relative_to(ROOT).as_posix()] = committed(path)
for item in json.loads(content["fonts/sources.json"])["files"]:
data = content["fonts/"+item["file"]]
assert len(data) == item["bytes"]
assert hashlib.sha256(data).hexdigest() == item["sha256"], item["file"]
content["LICENSE"] = committed(REPOSITORY/"LICENSE")
content[".gitignore"] = b".venv/\n__pycache__/\noutput/\n"
pdf = render_invoice(load_invoice("INV-1042"))
(out/"invoice.pdf").write_bytes(pdf)
previews = fullbleed.PdfEngine().render_finalized_pdf_image_pages_to_dir(
str(out/"invoice.pdf"), str(out/"preview"), 110, "invoice")
assert len(previews) == 1
png = Path(previews[0]).read_bytes()
assert png == content["static/invoice.png"], "Refresh and review the saved preview before packaging."
(out/"invoice.png").write_bytes(png)
digest = lambda data: hashlib.sha256(data).hexdigest()
manifest = {"schema": "fullbleed.python-web-starter.v1", "engineVersion": metadata.version("fullbleed"),
"sourceRepository": "https://github.com/fullbleed-engine/fullbleed-official",
"sourceCommit": source_commit,
"files": [{"path": name, "bytes": len(data), "sha256": digest(data)} for name,data in sorted(content.items())]}
content["MANIFEST.json"] = (json.dumps(manifest, indent=2)+"\n").encode()
archive = out/"project.zip"
with zipfile.ZipFile(archive, "x", compression=zipfile.ZIP_DEFLATED) as zipped:
for name,data in sorted(content.items()):
item = zipfile.ZipInfo("fullbleed-python-invoice/"+name, (2026,1,1,0,0,0))
item.compress_type = zipfile.ZIP_DEFLATED
item.external_attr = 0o100644 << 16
zipped.writestr(item,data)
manifest["checkedAt"] = datetime.now(timezone.utc).isoformat()
manifest["assets"] = [{"file": name, "bytes": (out/name).stat().st_size,
"sha256": digest((out/name).read_bytes())} for name in ["project.zip","invoice.pdf","invoice.png"]]
(out/"source.json").write_text(json.dumps(manifest,indent=2)+"\n",encoding="utf-8")
print(json.dumps({"sourceCommit":source_commit,"assets":manifest["assets"]}))


if __name__ == "__main__": main()
30 changes: 26 additions & 4 deletions examples/web_frameworks/check_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
import json
import os
from pathlib import Path
import shutil

import fullbleed
from pypdf import PdfReader

from invoice import load_invoice, render_invoice

Expand All @@ -25,9 +27,15 @@ def inspect(path: Path, markers: list[str]) -> dict:
for marker in markers:
assert marker in text, (path.name, "missing PDF text", marker)
assert report["page_count"] == 1, (path.name, "unexpected page count")
assert report["profile"]["embedded_font_count"] >= 1, (path.name, "font not embedded")
assert report["profile"]["embedded_font_count"] >= 4, (path.name, "design fonts not embedded")
assert not report["warnings"], (path.name, report["warnings"])
assert not report["composition"]["issues"], (path.name, report["composition"])
independent = PdfReader(path)
assert len(independent.pages) == 1
independent_text = "".join(independent.pages[0].extract_text().split())
for marker in markers:
assert "".join(marker.split()) in independent_text, (path.name, "independent text", marker)
assert independent_text.count("Designworkshop") == 1, (path.name, "duplicate text")
return report


Expand Down Expand Up @@ -63,9 +71,15 @@ def check_client(name: str, client, out: Path) -> dict:
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--out", type=Path, default=Path("target/web-framework-check"))
parser.add_argument("--update-preview", action="store_true", help="Refresh the local demo's saved preview after editing the invoice.")
args = parser.parse_args()
out = args.out.resolve()
out.mkdir(parents=True, exist_ok=True)
fonts = Path(__file__).resolve().parent / "fonts"
for item in json.loads((fonts / "sources.json").read_text(encoding="utf-8"))["files"]:
font_bytes = (fonts / item["file"]).read_bytes()
assert len(font_bytes) == item["bytes"]
assert hashlib.sha256(font_bytes).hexdigest() == item["sha256"], item["file"]

from fastapi.testclient import TestClient
from fastapi_app import app as fastapi_app
Expand All @@ -89,7 +103,7 @@ def main() -> int:
# Exercise literal markup-like customer text independently of the fixed HTTP sample.
data = load_invoice("INV-1042")
assert data is not None
data["customer"] = "North <East> & Partners"
data["customer"] = "North <East> & ${rows}"
escaped = out / "escaped-text.pdf"
escaped.write_bytes(render_invoice(data))
inspect(escaped, [data["customer"], "USD 1,870.00"])
Expand All @@ -98,16 +112,24 @@ def main() -> int:
str(out / "fastapi.pdf"), str(out / "preview"), 110, "invoice"
)
assert len(previews) == 1 and Path(previews[0]).read_bytes().startswith(b"\x89PNG\r\n\x1a\n")
saved_preview = Path(__file__).resolve().parent / "static/invoice.png"
if args.update_preview:
shutil.copyfile(previews[0], saved_preview)
assert Path(previews[0]).read_bytes() == saved_preview.read_bytes(), "Saved preview is stale; rerun with --update-preview."
from check_http import check_servers
http = check_servers(out, (out / "fastapi.pdf").read_bytes())
report = {
"ok": True,
"checked_at": datetime.now(timezone.utc).isoformat(),
"versions": {name: metadata.version(name) for name in ["fullbleed", "fastapi", "Flask", "Django", "httpx2"]},
"versions": {name: metadata.version(name) for name in ["fullbleed", "fastapi", "Flask", "Django", "httpx2", "pypdf"]},
"frameworks": results,
"http_servers": http,
"all_framework_pdf_bytes_identical": True,
"literal_customer_text_check": "passed",
"font_and_license_source_hashes": "passed",
"fastapi_openapi_pdf_type": "passed",
"preview": str(previews[0]),
"scope": "Framework test clients, PDF content, headers, font embedding, and repeat requests; no throughput or standards-conformance claim.",
"scope": "Test clients and actual local HTTP servers, independent PDF text, headers, font embedding, saved preview, and matching parallel responses; no throughput, hosted-deployment, or standards-conformance claim.",
}
(out / "verification.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, indent=2))
Expand Down
96 changes: 96 additions & 0 deletions examples/web_frameworks/check_http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# SPDX-License-Identifier: MIT
"""Exercise the documented local server commands using actual HTTP responses."""
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
import hashlib
import os
from pathlib import Path
import socket
import subprocess
import sys
import time
from urllib.error import HTTPError, URLError
from urllib.request import Request, build_opener, ProxyHandler

ROOT = Path(__file__).resolve().parent
OPEN = build_opener(ProxyHandler({})).open


def fetch(url: str, method: str = "GET"):
try:
response = OPEN(Request(url, method=method), timeout=20)
except HTTPError as error:
response = error
with response:
return response.status, response.headers, response.read()


@contextmanager
def server(name: str, out: Path):
with socket.socket() as listener:
listener.bind(("127.0.0.1", 0))
port = listener.getsockname()[1]
commands = {
"fastapi": ["-m", "uvicorn", "fastapi_app:app", "--host", "127.0.0.1", "--port", str(port)],
"flask": ["-m", "flask", "--app", "flask_app", "run", "--host", "127.0.0.1", "--port", str(port)],
"django": ["django_app.py", "runserver", f"127.0.0.1:{port}", "--noreload"],
}
env = {key: os.environ[key] for key in ["PATH", "SystemRoot", "WINDIR", "TEMP", "TMP", "HOME", "LANG"] if key in os.environ}
env.update(PYTHONNOUSERSITE="1", PYTHONUNBUFFERED="1")
origin = f"http://127.0.0.1:{port}"
with (out / f"{name}-server.log").open("w", encoding="utf-8") as log:
process = subprocess.Popen([sys.executable, *commands[name]], cwd=ROOT,
env=env, stdout=log, stderr=subprocess.STDOUT)
try:
deadline = time.monotonic() + 20
while True:
if process.poll() is not None:
raise AssertionError(f"{name} exited during startup; inspect its server log.")
try:
assert fetch(origin)[0] == 200
break
except URLError:
if time.monotonic() >= deadline:
raise TimeoutError(f"{name} did not start in 20 seconds.")
time.sleep(0.1)
yield origin
finally:
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=10)


def check_servers(out: Path, expected_pdf: bytes) -> list[dict]:
records = []
for name in ["fastapi", "flask", "django"]:
with server(name, out) as origin:
code, headers, page = fetch(origin)
assert code == 200 and headers.get_content_type() == "text/html"
# The app reads HTML as text, normalizing CRLF in Windows checkouts.
expected_html = (ROOT / "static/index.html").read_text(encoding="utf-8")
assert page.decode("utf-8") == expected_html
code, headers, preview = fetch(origin + "/preview.png")
assert code == 200 and headers.get_content_type() == "image/png"
assert preview == (ROOT / "static/invoice.png").read_bytes()
url = origin + "/invoices/INV-1042.pdf"
with ThreadPoolExecutor(max_workers=2) as pool:
responses = list(pool.map(fetch, [url, url]))
for code, headers, pdf in responses:
assert code == 200 and pdf == expected_pdf
assert headers.get_content_type() == "application/pdf"
assert headers["Content-Disposition"] == 'attachment; filename="invoice.pdf"'
assert headers["Cache-Control"] == "private, no-store"
assert fetch(origin + "/invoices/DOES-NOT-EXIST.pdf")[0] == 404
assert fetch(url, "POST")[0] == 405
(out / f"{name}-http.pdf").write_bytes(responses[0][2])
records.append({"framework": name, "ok": True, "pdf_status": 200,
"missing_status": 404, "post_status": 405,
"parallel_responses_identical": True,
"sha256": hashlib.sha256(expected_pdf).hexdigest(),
"home_and_preview_match": True})
records[-1]["server_stopped"] = True
return records
Loading
Loading