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
7 changes: 7 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,16 @@ jobs:
with:
toolchain: '1.97.0'
targets: wasm32-wasip1
components: rustfmt
- run: python -m pip install -r requirements.txt
- run: python tools/build_playground.py
- run: node tools/verify_playground.mjs
- name: Compile and render the Rust quickstart examples
run: python tools/verify_rust_examples.py
- uses: actions/upload-artifact@v4
with:
name: rust-quickstart-verification
path: examples/rust/verification
- uses: actions/upload-artifact@v4
with:
name: playground-verification
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@ docs/assets/playground/fonts.zip
docs/assets/playground/wasi/
docs/assets/playground/build.json
docs/assets/playground/verification.json
examples/rust/target/
examples/rust/verification/
/output/
/invoice.pdf
121 changes: 121 additions & 0 deletions docs/getting-started/rust.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
---
title: Generate PDFs from Rust with HTML and CSS
description: Use the published Fullbleed Rust crate to render HTML/CSS into a PDF, register explicit fonts, and generate PNG previews. Includes runnable invoice and report examples.
---
# Generate PDFs from Rust

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.

## Create your first PDF

With Rust 1.85 or newer and a working native Rust toolchain:

```bash
cargo new invoice-demo
cd invoice-demo
cargo add fullbleed@=2.5.0
```

Replace `src/main.rs` with:

```rust
use fullbleed::FullBleed;

fn main() -> Result<(), Box<dyn std::error::Error>> {
let html = "<h1>Invoice INV-1042</h1><p>Consulting: USD 1,200.00</p>";
let css = "@page { size: A4; margin: 20mm; } h1 { color: #175c52; }";
let engine = FullBleed::builder().build()?;
let pdf = engine.render_to_buffer(html, css)?;
std::fs::write("invoice.pdf", pdf)?;
Ok(())
}
```

Run it:

```bash
cargo run --release
```

Open `invoice.pdf` in the project directory. `render_to_buffer` returns PDF
bytes, which you can also return from an HTTP handler or write to your own
storage. For a file directly, use
`engine.render_to_file(html, css, "invoice.pdf")?`.

This first example uses standard PDF fonts. For explicit typography and
broader character coverage, register the font files your document uses.
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
[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:

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

Open `output/rust-invoice/document.pdf` or `page-1.png` in the same directory.
Replace both `invoice` input filenames with `report` or `notice` for the
other designs, and choose a new output directory. The report produces three
pages. These examples pin Fullbleed 2.5.0 and include a Cargo lockfile.

You can also use **Save HTML**, **Save CSS**, and **Download the bundled fonts**
in the playground. Extract the fonts ZIP, then pass your saved HTML, CSS,
`fonts` directory, and a fresh output directory to `from-files`.

## Register fonts and create previews

Font registration is explicit. For example, with the downloaded `fonts`
directory beside your application:

```rust
let engine = FullBleed::builder()
.register_font_file("fonts/Inter-Variable.ttf")
.build()?;
let css = "body { font-family: 'Inter'; font-size: 11pt; }";
let (pdf, glyphs) = engine.render_with_glyph_report(html, css)?;
if !glyphs.missing().is_empty() {
return Err("A character is not covered by the supplied fonts.".into());
}
std::fs::write("invoice.pdf", pdf)?;
```

Keep font license notices with redistributed assets. Register the additional
families and styles used by your CSS; the designed example registers all four
playground font files.

For one PNG per page, render the same source at your chosen DPI:

```rust
for (index, png) in engine.render_image_pages(html, css, 96)?.iter().enumerate() {
std::fs::write(format!("page-{}.png", index + 1), png)?;
}
```

The PDF and PNG calls are separate render operations. To preview an existing
Fullbleed PDF file, use
`engine.render_finalized_pdf_image_pages("invoice.pdf", 96)?`.

## Keep building

Use [the published Rust API reference](https://docs.rs/fullbleed/2.5.0/fullbleed/)
for `FullBleed`, its builder, compiled templates, and document types. Check
[CSS coverage](../css-coverage.md) before adapting a web layout, and inspect
your actual output when changing fonts or content. These examples use trusted
local inputs; file and HTML handling should follow your application's trust
boundary.

[Runnable Rust source](https://github.com/fullbleed-engine/docs/tree/main/examples/rust)
· [More document designs](../examples.md)
· [Python quickstart](quickstart.md)
· [Ask a usage question](https://github.com/fullbleed-engine/fullbleed-official/discussions)
2 changes: 2 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ python -m pip install fullbleed
```

Python 3.10–3.14 · Windows, macOS, Linux

Using Rust? [Start with the native crate](getting-started/rust.md).
</div>
<figure markdown>
[![Northstar Studio invoice with editorial typography, vermilion rules, and a forest-green total panel.](assets/showcase/invoice-1.png)](assets/showcase/invoice.pdf)
Expand Down
1 change: 1 addition & 0 deletions docs/playground.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ For the other examples, replace `invoice` in the input filenames with `report`
or `notice`. You can keep editing and add `--watch` to rebuild after each save.

[Use Fullbleed in Python](getting-started/quickstart.md){ .md-button .md-button--primary }
[Use Fullbleed in Rust](getting-started/rust.md){ .md-button }
[Run the Python notebook](getting-started/notebook.md){ .md-button }
[CSS support](css-coverage.md){ .md-button }

Expand Down
48 changes: 48 additions & 0 deletions examples/rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 18 additions & 0 deletions examples/rust/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[package]
name = "fullbleed-rust-examples"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
license = "MIT"
publish = false

[dependencies]
fullbleed = "=2.5.0"

[[bin]]
name = "hello"
path = "src/hello.rs"

[[bin]]
name = "from-files"
path = "src/from_files.rs"
25 changes: 25 additions & 0 deletions examples/rust/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Fullbleed from Rust

These examples use the published `fullbleed` 2.5.0 crate. They require Rust
1.85 or newer and a working native Rust toolchain; they do not require Python.

From the root of this documentation repository:

```bash
cargo run --release --locked --manifest-path examples/rust/Cargo.toml --bin hello
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
```

The first command writes `invoice.pdf`. The second writes the designed
Northstar invoice to `output/rust-invoice/document.pdf`, plus a 96-DPI PNG for
each page. Replace both `invoice` input filenames with `report` or `notice`
to render the other playground designs. Use a new output directory for each
document so previews from an earlier, longer document do not remain there.

`from-files` also accepts HTML/CSS exported from the browser playground and
its extracted fonts folder. Font files and licenses are in
`docs/assets/playground/fonts`; keep the license notices when redistributing them.

The [Rust quickstart](https://docs.fullbleed.dev/getting-started/rust/) explains
the API and how to start a new Cargo project. These are document-generation
examples, not a sandbox for untrusted HTML or file paths.
39 changes: 39 additions & 0 deletions examples/rust/src/from_files.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
use fullbleed::FullBleed;
use std::{fs, path::PathBuf};

fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args_os().skip(1).map(PathBuf::from).collect();
let [html_path, css_path, fonts_dir, output_dir] = args.as_slice() else {
return Err("Usage: from-files HTML CSS FONTS_DIR OUTPUT_DIR".into());
};
let html = fs::read_to_string(html_path)?;
let css = fs::read_to_string(css_path)?;
let mut builder = FullBleed::builder();
for name in [
"Inter-Variable.ttf",
"DMSerifDisplay-Regular.ttf",
"DMSerifDisplay-Italic.ttf",
"BebasNeue-Regular.ttf",
] {
let font = fonts_dir.join(name);
fs::metadata(&font)?;
builder = builder.register_font_file(font);
}
let engine = builder.build()?;
let (pdf, glyphs) = engine.render_with_glyph_report(&html, &css)?;
if !glyphs.missing().is_empty() {
return Err("The supplied fonts do not cover all document characters.".into());
}
let pages = engine.render_image_pages(&html, &css, 96)?;
fs::create_dir_all(output_dir)?;
fs::write(output_dir.join("document.pdf"), pdf)?;
for (index, png) in pages.iter().enumerate() {
fs::write(output_dir.join(format!("page-{}.png", index + 1)), png)?;
}
println!(
"Rendered {} page(s) to {}",
pages.len(),
output_dir.display()
);
Ok(())
}
10 changes: 10 additions & 0 deletions examples/rust/src/hello.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
use fullbleed::FullBleed;

fn main() -> Result<(), Box<dyn std::error::Error>> {
let html = "<h1>Invoice INV-1042</h1><p>Consulting: USD 1,200.00</p>";
let css = "@page { size: A4; margin: 20mm; } h1 { color: #175c52; }";
let engine = FullBleed::builder().build()?;
let pdf = engine.render_to_buffer(html, css)?;
std::fs::write("invoice.pdf", pdf)?;
Ok(())
}
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ nav:
- Python notebook: getting-started/notebook.md
- Install: getting-started/installation.md
- Your first PDF: getting-started/quickstart.md
- Use Rust: getting-started/rust.md
- Fonts and previews: getting-started/first-pdf.md
- Examples and downloads: examples.md
- Build a document:
Expand Down
75 changes: 75 additions & 0 deletions tools/verify_rust_examples.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Compile the documented Rust examples and compare their actual document output."""
from pathlib import Path
import hashlib
import json
import os
import re
import subprocess

ROOT = Path(__file__).resolve().parents[1]
EXAMPLES = ROOT / "examples/rust"
EVIDENCE = EXAMPLES / "verification"
ASSETS = ROOT / "docs/assets"


def run(args, cwd=ROOT):
result = subprocess.run(args, cwd=cwd, text=True, capture_output=True, encoding="utf-8")
if result.returncode:
raise SystemExit(f"{' '.join(args)} failed:\n{result.stdout}\n{result.stderr}")
return result


def sha256(path):
return hashlib.sha256(path.read_bytes()).hexdigest()


guide = (ROOT / "docs/getting-started/rust.md").read_text(encoding="utf-8")
hello = (EXAMPLES / "src/hello.rs").read_text(encoding="utf-8").strip()
assert re.findall(r"```rust\n(.*?)\n```", guide, re.S)[0].strip() == hello
run(["cargo", "fmt", "--manifest-path", str(EXAMPLES / "Cargo.toml"), "--check"])
run(["cargo", "build", "--release", "--locked", "--manifest-path", str(EXAMPLES / "Cargo.toml")])
suffix = ".exe" if os.name == "nt" else ""
bin_dir = EXAMPLES / "target/release"
hello_dir = EVIDENCE / "hello"
hello_dir.mkdir(parents=True, exist_ok=True)
run([str(bin_dir / ("hello" + suffix))], hello_dir)
assert (hello_dir / "invoice.pdf").read_bytes().startswith(b"%PDF-1.7")

playground = json.loads((ASSETS / "playground/verification.json").read_text())
assert playground["ok"] and playground["engine"] == "2.5.0"
report = {
"ok": True,
"engine": "2.5.0",
"rustc": run(["rustc", "--version"]).stdout.strip(),
"platform": os.name,
"hello_pdf_sha256": sha256(hello_dir / "invoice.pdf"),
"documented_hello_matches_source": True,
"fixtures": [],
}
for fixture in playground["fixtures"]:
name = fixture["name"]
if name not in {"invoice", "report", "notice"}:
continue
output = EVIDENCE / name
result = run([
str(bin_dir / ("from-files" + suffix)),
str(ASSETS / f"showcase/{name}.html"),
str(ASSETS / f"showcase/{name}.css"),
str(ASSETS / "playground/fonts"),
str(output),
])
expected_pages = fixture["pages"]
assert f"Rendered {expected_pages} page(s)" in result.stdout
hashes = {"document.pdf": sha256(output / "document.pdf")}
assert hashes["document.pdf"] == fixture["hashes"]["output.pdf"], name
for index in range(1, expected_pages + 1):
file = f"page-{index}.png"
hashes[file] = sha256(output / file)
assert hashes[file] == fixture["hashes"][file], f"{name}/{file}"
report["fixtures"].append({
"name": name, "pages": expected_pages,
"matches_playground_pdf_and_pngs": True, "sha256": hashes,
})
assert len(report["fixtures"]) == 3
(EVIDENCE / "verification.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
print(json.dumps(report, indent=2))
Loading