diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e8dc7bd..da7398e 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -13,6 +13,27 @@ concurrency: cancel-in-progress: true jobs: + rust-download: + name: Rust starter (Windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: '1.97.0' + components: rustfmt + - run: python -m pip install pypdf==6.19.0 Pillow==12.3.0 + - name: Verify the actual standalone Rust download + run: python tools/verify_rust_starter.py + - uses: actions/upload-artifact@v4 + if: always() + with: + name: rust-starter-verification-Windows + path: output/rust-starter-verification + retention-days: 14 markdown: name: Markdown starter (${{ matrix.os }}) strategy: @@ -175,6 +196,18 @@ jobs: retention-days: 7 - name: Compile and render the Rust quickstart examples run: python tools/verify_rust_examples.py + - name: Verify the actual standalone Rust download + run: | + python -m pip install Pillow==12.3.0 + python tools/build_rust_starter.py + git diff --exit-code -- docs/assets/rust-starter + python tools/verify_rust_starter.py + - uses: actions/upload-artifact@v4 + if: always() + with: + name: rust-starter-verification-Linux + path: output/rust-starter-verification + retention-days: 14 - name: Verify the downloadable PDF regression starter run: | python -m venv output/pdf-regression-env @@ -337,7 +370,7 @@ jobs: path: site deploy: if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' - needs: [build, aspnet, chinese, markdown, tagged-notice] + needs: [build, aspnet, chinese, markdown, tagged-notice, rust-download] runs-on: ubuntu-latest permissions: pages: write diff --git a/docs/assets/rust-starter/project.zip b/docs/assets/rust-starter/project.zip new file mode 100644 index 0000000..44f6756 Binary files /dev/null and b/docs/assets/rust-starter/project.zip differ diff --git a/docs/assets/rust-starter/source.json b/docs/assets/rust-starter/source.json new file mode 100644 index 0000000..c0ff08e --- /dev/null +++ b/docs/assets/rust-starter/source.json @@ -0,0 +1,118 @@ +{ + "schema": "fullbleed.rust-starter.v1", + "repository": "https://github.com/fullbleed-engine/docs", + "engine": "2.5.11", + "prefix": "fullbleed-rust-starter/", + "files": [ + { + "path": ".gitignore", + "source": null, + "bytes": 31, + "sha256": "d146e8d4d5305c063b357cf9ccfc176ee55c480da3b676f7af92726284ea8190" + }, + { + "path": "Cargo.lock", + "source": "examples/rust/Cargo.lock", + "bytes": 1299, + "sha256": "48e97f97f7ff701bebbbc391b29114b6f5f45611a3824220243cbea5834e99f2" + }, + { + "path": "Cargo.toml", + "source": "examples/rust/Cargo.toml", + "bytes": 272, + "sha256": "be5886957217168275d1a771b759974cf7d85dde29910d7c741ca8dc0d510be1" + }, + { + "path": "LICENSE", + "source": "examples/rust/LICENSE", + "bytes": 1079, + "sha256": "36a2115938f6e68cf0d87eccff7a6a3e57c17017e909cbd640d51aff1fa0c5a9" + }, + { + "path": "README.md", + "source": "examples/rust/STARTER.md", + "bytes": 3177, + "sha256": "851609cca16a16f8edff04c18077f71317e9057e1a63631fe790d880d0b213af" + }, + { + "path": "fonts/BebasNeue-OFL.txt", + "source": "docs/assets/playground/fonts/BebasNeue-OFL.txt", + "bytes": 4337, + "sha256": "72082f6cb4d04be2ecf7cc7d9e1e7d73787f0af8a5a278a47cade70c16b78341" + }, + { + "path": "fonts/BebasNeue-Regular.ttf", + "source": "docs/assets/playground/fonts/BebasNeue-Regular.ttf", + "bytes": 61400, + "sha256": "08e4623805102d819f58601e46e345648846075e363b2ceb23313c2d1c83ec73" + }, + { + "path": "fonts/DMSerifDisplay-Italic.ttf", + "source": "docs/assets/playground/fonts/DMSerifDisplay-Italic.ttf", + "bytes": 71208, + "sha256": "df74c0ac387baeaeb0fe4f2324e1668e6a3ed8c09cd9796fe162c71753e19e45" + }, + { + "path": "fonts/DMSerifDisplay-OFL.txt", + "source": "docs/assets/playground/fonts/DMSerifDisplay-OFL.txt", + "bytes": 4512, + "sha256": "aa49c876a28d8c0b0a3027d6c25b9c607eee8c0fb96165011d42a6ace1408fe1" + }, + { + "path": "fonts/DMSerifDisplay-Regular.ttf", + "source": "docs/assets/playground/fonts/DMSerifDisplay-Regular.ttf", + "bytes": 76580, + "sha256": "8cc3643535edf039aa5d95440a8542735e9197e4f4b8d9303e980fefbf5ab616" + }, + { + "path": "fonts/Inter-OFL.txt", + "source": "docs/assets/playground/fonts/Inter-OFL.txt", + "bytes": 4377, + "sha256": "5b9321a4298cfeb6b34354164a1c3afc3db114569984c502b9b35d988fd58c57" + }, + { + "path": "fonts/Inter-Variable.ttf", + "source": "docs/assets/playground/fonts/Inter-Variable.ttf", + "bytes": 876576, + "sha256": "29160a80ff49ddcab2c97711247e08b1fab27a484a329ce8b813d820dc559031" + }, + { + "path": "src/from_files.rs", + "source": "examples/rust/src/from_files.rs", + "bytes": 1462, + "sha256": "3d61f51a714d6c63aa91f5c0cbacdbac816fa950380fdfa6c3d57cf2fc475f6b" + }, + { + "path": "src/hello.rs", + "source": "examples/rust/src/hello.rs", + "bytes": 383, + "sha256": "17b2b595c5f237e70f8ff85b7fdb1eaeedd12133a4af69ca4c1d25683c889221" + }, + { + "path": "templates/invoice.css", + "source": "docs/assets/showcase/invoice.css", + "bytes": 3546, + "sha256": "cda22b39ab1f7a1eeac7cd76d162b05cd3568c2430c099123ba30a2fbd58a296" + }, + { + "path": "templates/invoice.html", + "source": "docs/assets/showcase/invoice.html", + "bytes": 2933, + "sha256": "195725eeeb32c521e19b47dbacbb55048bebb6df8566ab135cdc41fbf33fe57a" + }, + { + "path": "templates/report.css", + "source": "docs/assets/showcase/report.css", + "bytes": 4277, + "sha256": "519591ae75c239ec602324f008dd99108ebeae0b9306df7215fa5f0442905473" + }, + { + "path": "templates/report.html", + "source": "docs/assets/showcase/report.html", + "bytes": 7756, + "sha256": "46b0e975d43914c8b4492218feae09ea0ad684091a9f9ee3b18f66a87202e521" + } + ], + "zip_bytes": 587627, + "zip_sha256": "1747ac09391cae27c644a48bec9986d105f450884571238165b68eb445794b56" +} diff --git a/docs/getting-started/rust.md b/docs/getting-started/rust.md index 2f47dbe..1980819 100644 --- a/docs/getting-started/rust.md +++ b/docs/getting-started/rust.md @@ -8,6 +8,9 @@ Use the [`fullbleed` crate](https://crates.io/crates/fullbleed) directly in a Rust application. The engine renders static HTML and CSS into print documents; you do not need Python or a browser to render them. +For editable invoice and report templates with fonts included, +[start with the complete Rust project](#render-a-designed-invoice-or-report). + ## Create your first PDF With Rust 1.85 or newer and a working native Rust toolchain: @@ -50,30 +53,41 @@ The Python wheel's bundled fonts are not included in the Rust crate. ## Render a designed invoice or report -The runnable example includes font registration, missing-glyph checking, and -PNG previews. It uses the same source and fonts as the +The standalone project includes font registration, missing-glyph checking, and +PNG previews. It contains the Rust source, pinned Cargo dependencies, editable +HTML/CSS, and four font faces with their license notices. It uses the same +document source and fonts as the [editable browser playground](../playground.md). [![Northstar invoice rendered with Fullbleed, with cream paper, serif typography, and a forest-green total panel.](../assets/showcase/invoice-1.png)](../assets/showcase/invoice.pdf) -Clone the documentation repository and run the example from its root: +[Download the Rust project](../assets/rust-starter/project.zip){ .md-button .md-button--primary } +[Source and checksums](../assets/rust-starter/source.json){ .md-button } + +Extract the ZIP and open a terminal in `fullbleed-rust-starter`: + +```bash +cargo run --release --locked --bin from-files -- templates/invoice.html templates/invoice.css fonts output/invoice +``` + +Open `output/invoice/document.pdf` and `output/invoice/page-1.png`. For the +three-page report: ```bash -git clone https://github.com/fullbleed-engine/docs.git fullbleed-docs -cd fullbleed-docs -cargo run --release --locked --manifest-path examples/rust/Cargo.toml --bin from-files -- docs/assets/showcase/invoice.html docs/assets/showcase/invoice.css docs/assets/playground/fonts output/rust-invoice +cargo run --release --locked --bin from-files -- templates/report.html templates/report.css fonts output/report ``` -Open `output/rust-invoice/document.pdf` or `page-1.png` in the same directory. -Replace both `invoice` input filenames with `report` for the report design, -and choose a new output directory. The report produces three -pages. These examples pin Fullbleed 2.5.11 and include a Cargo lockfile. +Edit the files in `templates/` to customize the content and design. Render to a +new output directory after edits, so previews from a longer previous document +do not remain. The first Cargo build downloads dependencies and needs internet +access. These examples pin Fullbleed 2.5.11 and include a Cargo lockfile; the ZIP +is compiled and rendered on Windows and Linux with Rust 1.97.0. For the service notice or your own edits, choose **Download project** in the -playground. Extract the ZIP into `my-project` in this repository, then run: +playground. Extract its ZIP into `my-project` inside the Rust starter, then run: ```bash -cargo run --release --locked --manifest-path examples/rust/Cargo.toml --bin from-files -- my-project/input.html my-project/style.css my-project/fonts output/my-project +cargo run --release --locked --bin from-files -- my-project/input.html my-project/style.css my-project/fonts output/my-project ``` The Rust example reads the exported HTML, CSS, and fonts directly. The individual @@ -132,5 +146,8 @@ boundary. This walkthrough was written by an AI coding agent for the Fullbleed project. The [runnable Rust examples are compiled and rendered in documentation CI](https://github.com/fullbleed-engine/docs/actions/workflows/docs.yml), where their fixture PDFs and PNG previews are compared with the playground's -output. See the [verification source](https://github.com/fullbleed-engine/docs/blob/main/tools/verify_rust_examples.py) -for the checks and their scope. +output. The [download verifier](https://github.com/fullbleed-engine/docs/blob/main/tools/verify_rust_starter.py) +also extracts the actual ZIP, compiles it outside the source tree, and checks +the invoice, report, edited HTML/CSS, failure handling, and repeated output. +See the [example verification source](https://github.com/fullbleed-engine/docs/blob/main/tools/verify_rust_examples.py) +for the playground comparisons and their scope. diff --git a/docs/index.md b/docs/index.md index 82e7bed..77e6cf9 100644 --- a/docs/index.md +++ b/docs/index.md @@ -150,6 +150,12 @@ Pick your application stack. Generate a PDF locally or download one from the bro Use a native Rust toolchain and the published crate. + [Download the styled Rust project](assets/rust-starter/project.zip){ .md-button .md-button--primary } + + The ZIP includes editable invoice/report templates, fonts, and a Cargo + lockfile. [Run the project](getting-started/rust.md#render-a-designed-invoice-or-report), + or start with the smaller example below. + ```bash cargo new invoice-demo cd invoice-demo diff --git a/examples/rust/LICENSE b/examples/rust/LICENSE new file mode 100644 index 0000000..4c99bbd --- /dev/null +++ b/examples/rust/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/rust/README.md b/examples/rust/README.md index 9563c13..d1fa353 100644 --- a/examples/rust/README.md +++ b/examples/rust/README.md @@ -3,6 +3,10 @@ These examples use the published `fullbleed` 2.5.11 crate. They require Rust 1.85 or newer and a working native Rust toolchain; they do not require Python. +For a standalone project with the templates, fonts, and license notices, use the +[Rust starter ZIP](https://docs.fullbleed.dev/assets/rust-starter/project.zip). +Its [README](STARTER.md) contains commands that run from the extracted directory. + From the root of this documentation repository: ```bash diff --git a/examples/rust/STARTER.md b/examples/rust/STARTER.md new file mode 100644 index 0000000..cbbd06e --- /dev/null +++ b/examples/rust/STARTER.md @@ -0,0 +1,74 @@ +# Rust PDF starter + +Generate a designed invoice and a three-page report from editable HTML/CSS. +This project includes the Rust source, a Cargo lockfile, four font faces, and +their license notices. The sample data is fictional. + +## Run the invoice + +Install a native Rust toolchain with a linker. The project declares Rust 1.85 +as its minimum; the downloadable project is checked with Rust 1.97.0 on Windows +and Linux. No Python installation or browser is needed to render the documents. +Cargo downloads the pinned dependencies on the first build, so that build needs +internet access. + +Extract the ZIP, open a terminal in `fullbleed-rust-starter`, and run: + +```sh +cargo run --release --locked --bin from-files -- templates/invoice.html templates/invoice.css fonts output/invoice +``` + +Open `output/invoice/document.pdf` and `output/invoice/page-1.png`. The PNG is +rendered from the finalized PDF at 96 DPI. For the report: + +```sh +cargo run --release --locked --bin from-files -- templates/report.html templates/report.css fonts output/report +``` + +Open `output/report/document.pdf` and its three page previews. + +## Make it yours + +Edit the HTML for the content and the CSS for typography, colors, tables, and +page layout. Then render to a new output directory: + +```sh +cargo run --release --locked --bin from-files -- templates/invoice.html templates/invoice.css fonts output/edited-invoice +``` + +Use a new output directory when changing page counts: this small example does +not remove old preview files from an existing directory. Review every page +after changing content or styles. See the engine's +[supported CSS](https://docs.fullbleed.dev/css-coverage/) when adapting a web layout. + +`src/from_files.rs` registers the four fonts explicitly and rejects characters +that the engine reports as missing. For another font, keep its license notice, +add its filename to the registration list, and use its family name in CSS. +Keep the font files beside the project when moving it. + +The renderer takes trusted local paths and static HTML/CSS; it is not an HTTP +server or a sandbox. It does not execute document JavaScript. These examples +produce ordinary PDFs and do not select an accessibility or archival profile. + +## Reuse the Rust API + +`src/hello.rs` contains the smaller `FullBleed::builder()` and +`render_to_buffer()` example. Run it with: + +```sh +cargo run --release --locked --bin hello +``` + +It writes `invoice.pdf` in the current directory. In an application, return the +PDF bytes from your handler or write them to your storage. Use the full +[Rust walkthrough](https://docs.fullbleed.dev/getting-started/rust/) for font +registration and preview details. + +The project pins the published `fullbleed` crate to 2.5.11. `Cargo.lock` records +the resolved dependency versions. No local Fullbleed checkout is required. +The code and templates are MIT licensed; the fonts are SIL OFL 1.1. + +This starter was assembled by an AI coding agent for Fullbleed from the +[checked Rust examples](https://github.com/fullbleed-engine/docs/tree/main/examples/rust). +The download is extracted, compiled, and rendered in documentation CI; its +checks cover the included fixtures and edited synthetic input. diff --git a/tools/build_rust_starter.py b/tools/build_rust_starter.py new file mode 100644 index 0000000..fffda24 --- /dev/null +++ b/tools/build_rust_starter.py @@ -0,0 +1,58 @@ +"""Bundle a standalone Rust project using the existing checked sources.""" +from hashlib import sha256 +from pathlib import Path +import json +import tomllib +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +DESTINATION = ROOT / 'docs/assets/rust-starter' +PREFIX = 'fullbleed-rust-starter/' +SOURCES = { + 'Cargo.toml': 'examples/rust/Cargo.toml', + 'Cargo.lock': 'examples/rust/Cargo.lock', + 'src/hello.rs': 'examples/rust/src/hello.rs', + 'src/from_files.rs': 'examples/rust/src/from_files.rs', + 'LICENSE': 'examples/rust/LICENSE', + 'README.md': 'examples/rust/STARTER.md', +} +for name in ['invoice.html', 'invoice.css', 'report.html', 'report.css']: + SOURCES['templates/' + name] = 'docs/assets/showcase/' + name +for name in ['Inter-Variable.ttf', 'Inter-OFL.txt', 'DMSerifDisplay-Regular.ttf', + 'DMSerifDisplay-Italic.ttf', 'DMSerifDisplay-OFL.txt', + 'BebasNeue-Regular.ttf', 'BebasNeue-OFL.txt']: + SOURCES['fonts/' + name] = 'docs/assets/playground/fonts/' + name +GENERATED = {'.gitignore': b'/target/\n/output/\n/invoice.pdf\n'} + + +def main(): + DESTINATION.mkdir(parents=True, exist_ok=True) + files = [] + archive_path = DESTINATION / 'project.zip' + with zipfile.ZipFile(archive_path, 'w', zipfile.ZIP_DEFLATED, compresslevel=9) as archive: + for name in sorted(SOURCES.keys() | GENERATED.keys()): + if name in SOURCES: + source = ROOT / SOURCES[name] + data = source.read_bytes() if name.endswith('.ttf') else source.read_text(encoding='utf-8').encode('utf-8') + else: + data = GENERATED[name] + entry = zipfile.ZipInfo(PREFIX + name, (2026, 1, 1, 0, 0, 0)) + entry.compress_type = zipfile.ZIP_DEFLATED + entry.create_system = 3 + entry.external_attr = 0o100644 << 16 + archive.writestr(entry, data) + files.append({'path': name, 'source': SOURCES.get(name), 'bytes': len(data), + 'sha256': sha256(data).hexdigest()}) + cargo = tomllib.loads((ROOT / SOURCES['Cargo.toml']).read_text(encoding='utf-8')) + manifest = {'schema': 'fullbleed.rust-starter.v1', + 'repository': 'https://github.com/fullbleed-engine/docs', + 'engine': cargo['dependencies']['fullbleed'].removeprefix('='), + 'prefix': PREFIX, 'files': files, + 'zip_bytes': archive_path.stat().st_size, + 'zip_sha256': sha256(archive_path.read_bytes()).hexdigest()} + (DESTINATION / 'source.json').write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8', newline='\n') + print(json.dumps({'files': len(files), 'bytes': manifest['zip_bytes'], 'sha256': manifest['zip_sha256']})) + + +if __name__ == '__main__': + main() diff --git a/tools/verify_rust_starter.py b/tools/verify_rust_starter.py new file mode 100644 index 0000000..3c79c8d --- /dev/null +++ b/tools/verify_rust_starter.py @@ -0,0 +1,157 @@ +"""Compile and exercise the actual downloadable Rust ZIP outside the source tree.""" +from hashlib import sha256 +from importlib.metadata import version +from pathlib import Path, PurePosixPath +import argparse +import json +import os +import platform +import shutil +import subprocess +import tempfile +import zipfile + +from PIL import Image +from pypdf import PdfReader + +ROOT = Path(__file__).resolve().parents[1] +ASSETS = ROOT / 'docs/assets/rust-starter' + + +def digest(path): + return sha256(path.read_bytes()).hexdigest() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--out', type=Path, default=ROOT / 'output/rust-starter-verification') + parser.add_argument('--archive', type=Path, default=ASSETS / 'project.zip') + parser.add_argument('--manifest', type=Path, default=ASSETS / 'source.json') + parser.add_argument('--target-dir', type=Path, default=ROOT / 'examples/rust/target') + args = parser.parse_args() + out = args.out.resolve() + out.mkdir(parents=True, exist_ok=False) + checks, commands, fixtures = [], [], [] + report = {'ok': False, 'platform': platform.system(), + 'verificationVersions': {'python': platform.python_version(), 'pypdf': version('pypdf'), 'pillow': version('pillow')}, 'checks': checks, + 'commands': commands, 'fixtures': fixtures} + env = {**os.environ, 'CARGO_TARGET_DIR': str(args.target_dir.resolve())} + + def check(label, condition): + if not condition: + raise AssertionError(label) + checks.append(label) + + def run(label, command, cwd, expected=0): + result = subprocess.run(list(map(str, command)), cwd=cwd, env=env, + capture_output=True, text=True, encoding='utf-8', timeout=900) + (out / (label + '.stdout.txt')).write_text(result.stdout, encoding='utf-8') + (out / (label + '.stderr.txt')).write_text(result.stderr, encoding='utf-8') + commands.append({'label': label, 'exit': result.returncode}) + check(label + ' exit status', result.returncode == expected) + return result + + def inspect(name, pages, markers=()): + folder = out / name + reader = PdfReader(folder / 'document.pdf') + check(name + ' page count', len(reader.pages) == pages) + text = '\n'.join(page.extract_text() for page in reader.pages) + (out / (name + '-text.txt')).write_text(text, encoding='utf-8') + compact = ''.join(text.split()) + check(name + ' expected text', all(''.join(marker.split()) in compact for marker in markers)) + check(name + ' every page has text', all(len(page.extract_text().strip()) > 10 for page in reader.pages)) + check(name + ' one finalized preview per page', len(list(folder.glob('page-*.png'))) == pages) + for index in range(1, pages + 1): + with Image.open(folder / f'page-{index}.png') as image: + check(f'{name} preview {index} has visible content', + min(image.size) > 300 and image.convert('RGB').getextrema() != ((255, 255),) * 3) + files = {path.name: digest(path) for path in sorted(folder.iterdir()) if path.suffix in {'.pdf', '.png'}} + fixtures.append({'name': name, 'pages': pages, 'files': files}) + return reader, files + + try: + manifest = json.loads(args.manifest.read_text(encoding='utf-8')) + check('archive checksum and size', digest(args.archive) == manifest['zip_sha256'] + and args.archive.stat().st_size == manifest['zip_bytes']) + prefix = manifest['prefix'] + check('expected archive root', prefix == 'fullbleed-rust-starter/') + with tempfile.TemporaryDirectory(prefix='fullbleed rust download ') as temporary: + workspace = Path(temporary).resolve() + check('project is outside the repository', not workspace.is_relative_to(ROOT)) + with zipfile.ZipFile(args.archive) as archive: + names = [prefix + item['path'] for item in manifest['files']] + check('archive contains exactly the manifest files', sorted(archive.namelist()) == sorted(names)) + check('archive paths are confined', all(not PurePosixPath(name).is_absolute() + and '..' not in PurePosixPath(name).parts and '\\' not in name for name in names)) + for item in manifest['files']: + data = archive.read(prefix + item['path']) + check('file digest: ' + item['path'], len(data) == item['bytes'] + and sha256(data).hexdigest() == item['sha256']) + archive.extractall(workspace) + project = workspace / prefix + readme = (project / 'README.md').read_text(encoding='utf-8') + guide = (ROOT / 'docs/getting-started/rust.md').read_text(encoding='utf-8') + for name in ['invoice', 'report']: + command = f'cargo run --release --locked --bin from-files -- templates/{name}.html templates/{name}.css fonts output/{name}' + check(name + ' command appears in README and guide', command in readme and command in guide) + check('font license notices included', all((project / 'fonts' / name).is_file() + for name in ['Inter-OFL.txt', 'DMSerifDisplay-OFL.txt', 'BebasNeue-OFL.txt'])) + lock_hash = digest(project / 'Cargo.lock') + run('format', ['cargo', 'fmt', '--check'], project) + run('build', ['cargo', 'build', '--release', '--locked'], project) + metadata = json.loads(run('metadata', ['cargo', 'metadata', '--format-version', '1', '--locked'], project).stdout) + core = next(package for package in metadata['packages'] if package['name'] == 'fullbleed') + check('published pinned crate, no local engine path', core['version'] == manifest['engine'] + and core['source'].startswith('registry+')) + check('Cargo lockfile unchanged', digest(project / 'Cargo.lock') == lock_hash) + report['engine'] = core['version'] + report['rustc'] = run('rustc', ['rustc', '--version'], project).stdout.strip() + report['source_zip_sha256'] = manifest['zip_sha256'] + target = Path(metadata['target_directory']) / 'release' + suffix = '.exe' if os.name == 'nt' else '' + render = target / ('from-files' + suffix) + for name, pages, markers in [ + ('invoice', 1, ['Northstar', 'Maple & Finch', 'NS-1042', '$1,870.00']), + ('report', 3, ['Common Ground', 'Small actions.', 'Clear allocations.', '$420,000.00']), + ]: + result = run(name, ['cargo', 'run', '--release', '--locked', '--bin', 'from-files', '--', + 'templates/' + name + '.html', 'templates/' + name + '.css', + 'fonts', 'output/' + name], project) + shutil.copytree(project / 'output' / name, out / name) + check(name + ' renderer confirms page count', f'Rendered {pages} page(s)' in result.stdout) + inspect(name, pages, markers) + run('repeat', [render, 'templates/invoice.html', 'templates/invoice.css', 'fonts', out / 'repeat'], project) + _, repeated = inspect('repeat', 1) + check('repeat PDF and finalized PNG bytes match', repeated == fixtures[0]['files']) + + (project / 'edited.html').write_text('

Rust starter edit

Custom HTML & CSS, checked.

', encoding='utf-8') + (project / 'edited.css').write_text('@page {size:A5;margin:20pt} body {font-family:Inter;color:#175c52}', encoding='utf-8') + run('edited', [render, 'edited.html', 'edited.css', 'fonts', out / 'edited'], project) + edited, _ = inspect('edited', 1, ['Rust starter edit', 'Custom HTML & CSS, checked.']) + check('edited CSS changes the paper to A5', + abs(float(edited.pages[0].mediabox.width) - 419.528) < .01 + and abs(float(edited.pages[0].mediabox.height) - 595.276) < .01) + + run('usage', [render], project, expected=1) + run('missing-fonts', [render, 'edited.html', 'edited.css', 'absent-fonts', out / 'missing-fonts'], project, expected=1) + (project / 'missing.html').write_text('

' + chr(0x10ffff) + '

', encoding='utf-8') + missing = run('missing-glyph', [render, 'missing.html', 'edited.css', 'fonts', out / 'missing-glyph'], project, expected=1) + check('uncovered characters produce the expected error', 'do not cover all document characters' in missing.stderr) + check('failed inputs leave no PDF', not (out / 'missing-fonts/document.pdf').exists() + and not (out / 'missing-glyph/document.pdf').exists()) + run('recovery', [render, 'edited.html', 'edited.css', 'fonts', out / 'recovery'], project) + _, recovered = inspect('recovery', 1) + check('render after failures matches edited PDF and PNG', recovered == fixtures[3]['files']) + run('hello', [target / ('hello' + suffix)], out) + hello = PdfReader(out / 'invoice.pdf') + check('minimal API example produces its documented PDF', len(hello.pages) == 1 + and 'INV-1042' in hello.pages[0].extract_text()) + report['ok'] = True + finally: + (out / 'verification.json').write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + print(json.dumps({'ok': report['ok'], 'checks': len(checks), 'engine': report['engine'], + 'platform': report['platform'], 'evidence': str(out)})) + + +if __name__ == '__main__': + main()