diff --git a/examples/web_frameworks/README.md b/examples/web_frameworks/README.md index 381e710..64ee826 100644 --- a/examples/web_frameworks/README.md +++ b/examples/web_frameworks/README.md @@ -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. @@ -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 ``` @@ -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 @@ -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. diff --git a/examples/web_frameworks/build_starter.py b/examples/web_frameworks/build_starter.py new file mode 100644 index 0000000..447812e --- /dev/null +++ b/examples/web_frameworks/build_starter.py @@ -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() diff --git a/examples/web_frameworks/check_examples.py b/examples/web_frameworks/check_examples.py index f342775..98ab85e 100644 --- a/examples/web_frameworks/check_examples.py +++ b/examples/web_frameworks/check_examples.py @@ -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 @@ -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 @@ -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 @@ -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 & Partners" + data["customer"] = "North & ${rows}" escaped = out / "escaped-text.pdf" escaped.write_bytes(render_invoice(data)) inspect(escaped, [data["customer"], "USD 1,870.00"]) @@ -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)) diff --git a/examples/web_frameworks/check_http.py b/examples/web_frameworks/check_http.py new file mode 100644 index 0000000..adb55d7 --- /dev/null +++ b/examples/web_frameworks/check_http.py @@ -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 diff --git a/examples/web_frameworks/demo.py b/examples/web_frameworks/demo.py new file mode 100644 index 0000000..850d5d9 --- /dev/null +++ b/examples/web_frameworks/demo.py @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: MIT +"""Fixed local-demo assets shared by the three framework adapters.""" +from pathlib import Path + +STATIC = Path(__file__).resolve().parent / "static" +DEMO_HEADERS = {"Cache-Control": "no-store"} + + +def page_html() -> str: + return (STATIC / "index.html").read_text(encoding="utf-8") + + +def preview_bytes() -> bytes: + return (STATIC / "invoice.png").read_bytes() diff --git a/examples/web_frameworks/django_app.py b/examples/web_frameworks/django_app.py index f96b4f5..7b6149d 100644 --- a/examples/web_frameworks/django_app.py +++ b/examples/web_frameworks/django_app.py @@ -8,6 +8,7 @@ from django.urls import path from django.views.decorators.http import require_safe +from demo import DEMO_HEADERS, page_html, preview_bytes from invoice import PDF_HEADERS, load_invoice, render_invoice @@ -21,6 +22,16 @@ INSTALLED_APPS = [] +@require_safe +def index(request) -> HttpResponse: + return HttpResponse(page_html(), content_type="text/html", headers=DEMO_HEADERS) + + +@require_safe +def preview(request) -> HttpResponse: + return HttpResponse(preview_bytes(), content_type="image/png", headers=DEMO_HEADERS) + + @require_safe def invoice_pdf(request, invoice_id: str) -> HttpResponse: invoice = load_invoice(invoice_id) @@ -31,7 +42,10 @@ def invoice_pdf(request, invoice_id: str) -> HttpResponse: ) -urlpatterns = [path("invoices/.pdf", invoice_pdf)] +urlpatterns = [ + path("", index), path("preview.png", preview), + path("invoices/.pdf", invoice_pdf), +] if __name__ == "__main__": diff --git a/examples/web_frameworks/fastapi_app.py b/examples/web_frameworks/fastapi_app.py index 307dd65..7d86fa4 100644 --- a/examples/web_frameworks/fastapi_app.py +++ b/examples/web_frameworks/fastapi_app.py @@ -2,7 +2,9 @@ """Run: python -m uvicorn fastapi_app:app --host 127.0.0.1 --port 8000""" from fastapi import FastAPI, HTTPException, Response +from fastapi.responses import HTMLResponse +from demo import DEMO_HEADERS, page_html, preview_bytes from invoice import PDF_HEADERS, load_invoice, render_invoice @@ -13,6 +15,16 @@ class PDFResponse(Response): app = FastAPI(title="Fullbleed invoice example") +@app.get("/", response_class=HTMLResponse, include_in_schema=False) +def index() -> HTMLResponse: + return HTMLResponse(page_html(), headers=DEMO_HEADERS) + + +@app.get("/preview.png", include_in_schema=False) +def preview() -> Response: + return Response(preview_bytes(), media_type="image/png", headers=DEMO_HEADERS) + + @app.get("/invoices/{invoice_id}.pdf", response_class=PDFResponse) def invoice_pdf(invoice_id: str) -> PDFResponse: invoice = load_invoice(invoice_id) diff --git a/examples/web_frameworks/flask_app.py b/examples/web_frameworks/flask_app.py index 0e0b1f8..0a6be24 100644 --- a/examples/web_frameworks/flask_app.py +++ b/examples/web_frameworks/flask_app.py @@ -3,12 +3,23 @@ from flask import Flask, Response, abort +from demo import DEMO_HEADERS, page_html, preview_bytes from invoice import PDF_HEADERS, load_invoice, render_invoice app = Flask(__name__) +@app.get("/") +def index() -> Response: + return Response(page_html(), mimetype="text/html", headers=DEMO_HEADERS) + + +@app.get("/preview.png") +def preview() -> Response: + return Response(preview_bytes(), mimetype="image/png", headers=DEMO_HEADERS) + + @app.get("/invoices/.pdf") def invoice_pdf(invoice_id: str) -> Response: invoice = load_invoice(invoice_id) diff --git a/examples/web_frameworks/fonts/.gitattributes b/examples/web_frameworks/fonts/.gitattributes new file mode 100644 index 0000000..be597a2 --- /dev/null +++ b/examples/web_frameworks/fonts/.gitattributes @@ -0,0 +1,2 @@ +# Preserve upstream license bytes, including line endings and trailing spaces. +*-OFL.txt -text whitespace=-blank-at-eol diff --git a/examples/web_frameworks/fonts/BebasNeue-OFL.txt b/examples/web_frameworks/fonts/BebasNeue-OFL.txt new file mode 100644 index 0000000..da95714 --- /dev/null +++ b/examples/web_frameworks/fonts/BebasNeue-OFL.txt @@ -0,0 +1,93 @@ +Copyright © 2010 by Dharma Type. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/examples/web_frameworks/fonts/BebasNeue-Regular.ttf b/examples/web_frameworks/fonts/BebasNeue-Regular.ttf new file mode 100644 index 0000000..c328c6e Binary files /dev/null and b/examples/web_frameworks/fonts/BebasNeue-Regular.ttf differ diff --git a/examples/web_frameworks/fonts/DMSerifDisplay-Italic.ttf b/examples/web_frameworks/fonts/DMSerifDisplay-Italic.ttf new file mode 100644 index 0000000..5369414 Binary files /dev/null and b/examples/web_frameworks/fonts/DMSerifDisplay-Italic.ttf differ diff --git a/examples/web_frameworks/fonts/DMSerifDisplay-OFL.txt b/examples/web_frameworks/fonts/DMSerifDisplay-OFL.txt new file mode 100644 index 0000000..be384f0 --- /dev/null +++ b/examples/web_frameworks/fonts/DMSerifDisplay-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2014-2018 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. Copyright 2019 Google LLC. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/examples/web_frameworks/fonts/DMSerifDisplay-Regular.ttf b/examples/web_frameworks/fonts/DMSerifDisplay-Regular.ttf new file mode 100644 index 0000000..bc5ed0c Binary files /dev/null and b/examples/web_frameworks/fonts/DMSerifDisplay-Regular.ttf differ diff --git a/examples/web_frameworks/fonts/sources.json b/examples/web_frameworks/fonts/sources.json new file mode 100644 index 0000000..0dc3f83 --- /dev/null +++ b/examples/web_frameworks/fonts/sources.json @@ -0,0 +1,36 @@ +{ + "license": "OFL-1.1", + "source_commit": "9710da1eacb3be272583c3224dcb70f9da6eadbb", + "files": [ + { + "file": "BebasNeue-Regular.ttf", + "url": "https://raw.githubusercontent.com/google/fonts/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/bebasneue/BebasNeue-Regular.ttf", + "bytes": 61400, + "sha256": "08e4623805102d819f58601e46e345648846075e363b2ceb23313c2d1c83ec73" + }, + { + "file": "BebasNeue-OFL.txt", + "url": "https://raw.githubusercontent.com/google/fonts/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/bebasneue/OFL.txt", + "bytes": 4337, + "sha256": "72082f6cb4d04be2ecf7cc7d9e1e7d73787f0af8a5a278a47cade70c16b78341" + }, + { + "file": "DMSerifDisplay-Regular.ttf", + "url": "https://raw.githubusercontent.com/google/fonts/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/dmserifdisplay/DMSerifDisplay-Regular.ttf", + "bytes": 76580, + "sha256": "8cc3643535edf039aa5d95440a8542735e9197e4f4b8d9303e980fefbf5ab616" + }, + { + "file": "DMSerifDisplay-Italic.ttf", + "url": "https://raw.githubusercontent.com/google/fonts/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/dmserifdisplay/DMSerifDisplay-Italic.ttf", + "bytes": 71208, + "sha256": "df74c0ac387baeaeb0fe4f2324e1668e6a3ed8c09cd9796fe162c71753e19e45" + }, + { + "file": "DMSerifDisplay-OFL.txt", + "url": "https://raw.githubusercontent.com/google/fonts/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/dmserifdisplay/OFL.txt", + "bytes": 4605, + "sha256": "a3e5cdd67d4571dd0a24fcc968de0efde7ae97ef752daf0906e4767619dd7231" + } + ] +} diff --git a/examples/web_frameworks/invoice.py b/examples/web_frameworks/invoice.py index a8ab4cb..ca25c6e 100644 --- a/examples/web_frameworks/invoice.py +++ b/examples/web_frameworks/invoice.py @@ -4,10 +4,13 @@ from decimal import Decimal from html import escape from importlib import resources +from pathlib import Path +from string import Template from typing import Any import fullbleed +ROOT = Path(__file__).resolve().parent PDF_HEADERS = { "Content-Disposition": 'attachment; filename="invoice.pdf"', @@ -25,9 +28,9 @@ def load_invoice(invoice_id: str) -> dict[str, Any] | None: "issued": "2026-10-01", "due": "2026-10-31", "items": [ - {"description": "Design workshop", "quantity": 8, "unit_price": "125.00"}, - {"description": "Implementation", "quantity": 6, "unit_price": "95.00"}, - {"description": "Review and handoff", "quantity": 2, "unit_price": "150.00"}, + {"description": "Design workshop", "detail": "Positioning, priorities, and a clear creative direction", "quantity": 8, "unit_price": "125.00"}, + {"description": "Implementation", "detail": "A cohesive visual system, from layout to launch", "quantity": 6, "unit_price": "95.00"}, + {"description": "Review and handoff", "detail": "Final refinements, documentation, and delivery", "quantity": 2, "unit_price": "150.00"}, ], } @@ -37,47 +40,26 @@ def render_invoice(data: dict[str, Any]) -> bytes: amounts = [Decimal(item["unit_price"]) * item["quantity"] for item in data["items"]] total = sum(amounts, Decimal("0.00")) rows = "".join( - f"{escape(item['description'])}" - f"{item['quantity']}" - f"USD {Decimal(item['unit_price']):,.2f}" - f"USD {amount:,.2f}" + f"{escape(item['description'])}" + f"

{escape(item.get('detail', ''))}

" + f"{escape(str(item['quantity']))}" + f"{Decimal(item['unit_price']):,.2f}" + f"{amount:,.2f}" for item, amount in zip(data["items"], amounts) ) - html = f""" -Invoice -
-

Northstar Studio

-

Invoice {escape(data['number'])}

-

Issued {escape(data['issued'])} · Due {escape(data['due'])}

-

BILL TO

-

{escape(data['customer'])}

- - - {rows}
DescriptionQtyUnit priceAmount
-

Total due: USD {total:,.2f}

-

Thank you for your business.

-
""" - css = """ -@page { size: A4; margin: 20mm; } -body { font-family: Inter, sans-serif; color: #18312e; font-size: 10pt; - line-height: 1.45; margin: 0; } -.brand { color: #175c52; font-size: 13pt; font-weight: 700; margin: 0 0 24pt; } -h1 { font-size: 27pt; margin: 0 0 6pt; } -.dates, .label, .note { color: #526762; } -.dates { margin: 0 0 25pt; } -.recipient { margin-bottom: 24pt; } -.recipient p { margin: 0 0 4pt; } -.label { font-size: 8pt; font-weight: 700; } -table { width: 100%; border-collapse: collapse; } -th, td { text-align: left; padding: 10pt 8pt; border-bottom: 0.6pt solid #d6e2df; } -th { background: #edf4f1; color: #175c52; font-size: 9pt; } -.number { text-align: right; white-space: nowrap; } -.total { margin: 24pt 0 0; text-align: right; font-size: 14pt; } -.note { margin-top: 42pt; font-size: 9pt; } -""" + # Template substitution runs once: placeholder-like text inside a customer + # value stays literal. Only application-owned row markup enters unescaped. + html = Template((ROOT / "templates/invoice.html").read_text(encoding="utf-8")).substitute( + number=escape(data["number"]), customer=escape(data["customer"]), + issued=escape(data["issued"]), due=escape(data["due"]), rows=rows, + total=f"{total:,.2f}", + ) + css = (ROOT / "templates/invoice.css").read_text(encoding="utf-8") font = resources.files("fullbleed_assets").joinpath("fonts/Inter-Variable.ttf") engine = fullbleed.PdfEngine( - font_files=[str(font)], + font_files=[str(font), *(str(ROOT / "fonts" / name) for name in [ + "DMSerifDisplay-Regular.ttf", "DMSerifDisplay-Italic.ttf", "BebasNeue-Regular.ttf", + ])], document_title=f"Invoice {data['number']}", document_lang="en", ) diff --git a/examples/web_frameworks/requirements-check.txt b/examples/web_frameworks/requirements-check.txt index e105dff..553228c 100644 --- a/examples/web_frameworks/requirements-check.txt +++ b/examples/web_frameworks/requirements-check.txt @@ -3,3 +3,4 @@ -r requirements-flask.txt -r requirements-django.txt httpx2==2.13.1 +pypdf==6.19.0 diff --git a/examples/web_frameworks/static/index.html b/examples/web_frameworks/static/index.html new file mode 100644 index 0000000..358e99f --- /dev/null +++ b/examples/web_frameworks/static/index.html @@ -0,0 +1,54 @@ + + + + + + + Fullbleed · Python invoice starter + + + +
fullbleed
Python invoice starter
+
+
+

From application data to paper

+

Make the details
look the part.

+

A designed invoice, rendered by Fullbleed and delivered by your Python web framework.

+ Download invoice +

Generated on request · A4 PDF · Fictional sample

+
InvoiceINV-1042
Total dueUSD 1,870.00
+

Make it yours: edit the included HTML and CSS, then download again. The README shows where to change the template and connect your own authorized invoice lookup.

+

Read the Python integration guide

+
+
Northstar Studio invoice with oversized green typography, detailed line items, and an orange-accented total panel.
Included invoice preview. Downloads use your current template; the README explains how to update this saved preview after editing.
+
+
A local integration example using fictional data. Add your application's access controls before serving real invoices.
+ + diff --git a/examples/web_frameworks/static/invoice.png b/examples/web_frameworks/static/invoice.png new file mode 100644 index 0000000..0018a8c Binary files /dev/null and b/examples/web_frameworks/static/invoice.png differ diff --git a/examples/web_frameworks/templates/invoice.css b/examples/web_frameworks/templates/invoice.css new file mode 100644 index 0000000..235023f --- /dev/null +++ b/examples/web_frameworks/templates/invoice.css @@ -0,0 +1,48 @@ +@page { size: A4; margin: 0; } +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { font-family: Inter; font-size: 10pt; line-height: 1.45; color: #17382e; } +h1, h2, h3, p, figure { margin: 0; } +h1, h2, h3 { font-weight: 400; } +.page { width: 210mm; height: 296.9mm; padding: 15mm 16mm; position: relative; break-after: page; background: #f8f5ec; } +.page:last-child { break-after: auto; } +.kicker { font-size: 7.2pt; font-weight: 600; letter-spacing: 1.4pt; text-transform: uppercase; } +.serif { font-family: 'DM Serif Display'; font-weight: 400; } +.display { font-family: 'Bebas Neue'; font-weight: 400; } +.muted { color: #5b6c64; } +.small { font-size: 8pt; } +.rule { border-top: .6pt solid #9eafa4; } +.row { display: flex; justify-content: space-between; gap: 16pt; } +.grid-2 { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); gap: 24pt; } +.grid-2 > * { min-width: 0; } +.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16pt; } +.footer { position: absolute; left: 16mm; right: 16mm; bottom: 12mm; border-top: .6pt solid #9eafa4; padding-top: 8pt; display: flex; justify-content: space-between; gap: 12pt; font-size: 6.6pt; letter-spacing: .45pt; } +table { width: 100%; border-collapse: collapse; font-size: 9pt; } +th { font-size: 7.5pt; font-weight: 600; text-align: left; } +th, td { padding: 10pt 0; border-bottom: .55pt solid #bdc8bf; vertical-align: top; } +.number { text-align: right; white-space: nowrap; } +.money { font-variant-numeric: tabular-nums; } +strong { font-weight: 600; } +a { color: inherit; } + +.invoice .masthead { display: grid; grid-template-columns: 1.05fr .8fr 1fr; gap: 18pt; align-items: center; padding-bottom: 22pt; border-bottom: 1pt solid #17382e; } +.invoice .brand { font: 31pt/.88 'DM Serif Display'; letter-spacing: -1.3pt; } +.invoice .brand span { display: block; line-height: .98; } +.invoice .brand small { display: block; font: 6.5pt Inter; letter-spacing: 2.1pt; margin-top: 11pt; } +.invoice .brand-note { border-left: 1pt solid #c24929; padding: 4pt 0 4pt 16pt; line-height: 1.8; } +.invoice .address { font-size: 7.5pt; line-height: 1.7; border-left: 1pt solid #c24929; padding-left: 16pt; } +.invoice .title-row { display: grid; grid-template-columns: 3.3fr 1fr; gap: 20pt; align-items: center; padding: 22pt 0 16pt; } +.invoice h1 { font: 91pt/.9 'Bebas Neue'; letter-spacing: .3pt; } +.invoice .title-note { color: #b44123; font: 14pt/1.2 'Bebas Neue'; letter-spacing: .5pt; } +.invoice .details { border-top: .6pt solid #9eafa4; border-bottom: .6pt solid #9eafa4; padding: 15pt 0; margin-bottom: 10pt; } +.invoice .details p { margin-top: 5pt; font-size: 8.2pt; } +.invoice .details > div + div { border-left: .6pt solid #c7cdc4; padding-left: 14pt; } +.invoice th { color: #52665a; } +.invoice td { padding: 12pt 0; } +.invoice .item-note { color: #617069; font-size: 8pt; margin-top: 4pt; } +.invoice .amounts { display: grid; grid-template-columns: 1fr 1.25fr; gap: 27pt; margin-top: 23pt; } +.invoice .thanks { font: italic 22pt/1.2 'DM Serif Display'; color: #b44123; padding-top: 8pt; } +.invoice .summary-line { display: flex; justify-content: space-between; padding: 5pt 0; font-size: 8.5pt; } +.invoice .total { background: #17382e; color: #fff8e8; padding: 16pt; margin-top: 9pt; border-left: 4pt solid #d96238; } +.invoice .total .value { font: 38pt/1.1 'Bebas Neue'; margin-top: 7pt; } +.invoice .payment { margin-top: 20pt; padding-top: 12pt; border-top: .6pt solid #9eafa4; display: grid; grid-template-columns: 1fr 1fr; gap: 24pt; font-size: 8pt; } diff --git a/examples/web_frameworks/templates/invoice.html b/examples/web_frameworks/templates/invoice.html new file mode 100644 index 0000000..e70a145 --- /dev/null +++ b/examples/web_frameworks/templates/invoice.html @@ -0,0 +1,28 @@ + +Northstar Studio invoice +
+
+
NorthstarStudioDESIGN WITH DIRECTION
+
Brand
Digital
Experiences
That matter.
+
42 Cedar Avenue
Portland, OR 97205
hello@example.invalid
northstar.example.invalid
+
+

INVOICE

GOOD WORK.
CLEAR DETAILS.
LET'S MAKE
WHAT'S NEXT.

+
+

Prepared for

$customer
Attn. Avery Morgan
Portland, Oregon

+

The project

Autumn launch
Brand workshop, digital
design & handoff

+

Invoice $number

Issued · $issued
Due · $due
Currency · USD

+
+ + + $rows +
DESCRIPTIONHOURSRATEAMOUNT
+
+

A little clarity.
A lot of possibility.

Thank you for inviting us
to help shape your next chapter.

+
Subtotal · USD$total
Tax · 0% in this sample0.00
TOTAL DUE
USD $total
+
+
+

Payment details

Reference $number with your payment.
Payment instructions are supplied separately.

+

A note from the studio

Questions about a line item?
Write to hello@example.invalid.

+
+ +