Examples
Each example exists twice: in Rust (crates/lamina/examples/) and in TypeScript (packages/pdf/examples/). Both write the same bytes, the golden in fixtures/golden/examples/. CI checks that in Rust and in every JavaScript runtime: Node, Chromium, Firefox, WebKit, Deno, Bun and workerd. The source below is those files, and each PDF link is the golden itself.
Run them yourself with cargo run -p lamina --example <name> (written to target/examples-out/), or cargo xtask examples to compare all of them with the goldens.
One A4 page with a line of text in a standard 14 font. Open the PDF.
// The smallest document: one A4 page with a line of text in a standard 14 font. The TS twin of// crates/lamina/examples/hello.rs: the same bytes (fixtures/golden/examples/hello.pdf).import { Builder, Font } from "@lamina/pdf";
/** The document's bytes: the same on every run and every platform. Needs `init({ text: true })`. */export function hello(): Uint8Array { // Everything that would come from the environment (file ID, dates) is given. const b = new Builder({ id: new TextEncoder().encode("lamina-ex-hello!"), producer: "lamina examples", creationDate: "D:20261030120000Z", }); const page = b.page("A4"); // Page space: points from the top-left corner, y down; `at[1]` is the text's baseline. page.surface().text("Hello, world!", { font: Font.standard("Helvetica"), size: 24, at: [72, 96] }); page.finish(); // Free the document at once (the saved bytes are a copy): a Worker or a timing loop calling // this many times shouldn't wait for the garbage collector to release WebAssembly memory. const doc = b.finish(); try { return doc.save(); } finally { doc.free(); }}//! The smallest document: one A4 page with a line of text in a standard 14 font.//!//! ```sh//! cargo run -p lamina --example hello -- [out-dir] # writes <out-dir>/hello.pdf (default target/examples-out)//! ```
use std::error::Error;use std::path::PathBuf;
use lamina::{BuildOptions, Builder, CancelToken, PageSize, PointPt, Pt, SaveOptions, Standard14};
fn main() -> Result<(), Box<dyn Error>> { let out = PathBuf::from(std::env::args().nth(1).unwrap_or_else(|| "target/examples-out".into())); std::fs::create_dir_all(&out)?; std::fs::write(out.join("hello.pdf"), build()?)?; Ok(())}
/// The document's bytes: the same on every run and every platform.pub(crate) fn build() -> Result<Vec<u8>, Box<dyn Error>> { // Everything that would come from the environment (file ID, dates) is given. let options = BuildOptions::default() .with_id(*b"lamina-ex-hello!") .with_producer("lamina examples") .with_creation_date("D:20261030120000Z".parse()?); let mut b = Builder::new(options); let helvetica = b.standard_font(Standard14::Helvetica)?; let mut page = b.page(PageSize::A4); // Page space: points from the top-left corner, y down; `y` is the text's baseline. let _ = page.surface().text( "Hello, world!", &helvetica, Pt::new(24.0), PointPt::from_points(72.0, 96.0), )?; page.finish()?; let mut doc = b.finish()?; Ok(doc.save(&SaveOptions::new(), &CancelToken::new())?.bytes)}invoice
Section titled “invoice”An embedded, subset font and a standard 14 one, a coloured band, right-aligned text boxes, hairlines and a link. It’s tagged: headings, a table and a link element, with the decorations marked as artifacts. Open the PDF.
// An invoice: an embedded, subset font and a standard 14 one, a coloured band, right-aligned// text boxes, hairlines and a link, tagged (headings, a table, a link element; the bar and the// rules as artifacts). The TS twin of crates/lamina/examples/invoice.rs.import { Builder, Font, type Rect } from "@lamina/pdf";
const ITEMS: readonly (readonly [item: string, qty: number, price: number])[] = [ ["Widgets, blue (W-100)", 3, 12.0], ["Gadget with fine-tuned dials", 1, 79.9], ["Café-grade sprockets", 12, 1.25], ["Delivery", 1, 4.5],];
/** * The document's bytes. Needs `init({ text: true })`. * * @param notoSans - `fixtures/fonts/NotoSans-Regular.ttf` (the caller reads files; the package doesn't). */export function invoice(notoSans: Uint8Array): Uint8Array { const b = new Builder({ id: new TextEncoder().encode("lamina-ex-invoic"), producer: "lamina examples", creationDate: "D:20261030120000Z", tagged: true, }); const font = Font.fromBytes(notoSans); const bold = Font.standard("Helvetica-Bold"); // Registered in the Rust example's order, so the objects are numbered the same. b.font(font); b.font(bold); const page = b.page("A4"); const s = page.surface(); // The accent bar and the hairlines are decoration (artifacts); the rest is the structure a // screen reader follows: a heading, a paragraph, a table, the total, a link. s.artifact("layout", (s) => s .fillColor("#1e78c8") .path((p) => p.rect([0, 0, 595.28, 14])) .fill() .fillColor([0]), ); s.text("Invoice 42", { font: bold, size: 24, at: [56, 80], tag: "H1" }); s.text("Acme Widgets Ltd · 1 Example Street · London", { font, size: 10, at: [56, 100], tag: "P" }); let total = 0; s.tag("Table", (s) => { ITEMS.forEach(([item, qty, price], row) => { const y = 160 + 24 * row; const amount = qty * price; total += amount; s.tag("TR", (s) => { s.text(item, { font, size: 11, at: [56, y], tag: "TD" }); const cell = (box: Rect, text: string) => s.textBox(text, { font, size: 11, box, align: "end", tag: "TD" }); cell([330, y - 12, 40, 16], String(qty)); cell([439, y - 12, 100, 16], `€ ${amount.toFixed(2)}`); s.artifact("layout", (s) => s .strokeColor([0.8]) .path((p) => p.moveTo(56, y + 8).lineTo(539, y + 8)) .stroke({ width: 0.5 }), ); }); }); }); s.textBox(`Total € ${total.toFixed(2)}`, { font, size: 14, box: [339, 262, 200, 20], align: "end", tag: "P" }); s.text("Pay online at example.com/pay/42", { font, size: 11, at: [56, 320], fill: [0.12, 0.47, 0.78], link: { target: { type: "uri", uri: "https://example.com/pay/42" }, contents: "Pay invoice 42 online" }, tag: "P", }); page.finish(); b.metadata({ title: "Invoice 42", author: "Acme Widgets Ltd" }).lang("en-GB"); return b.finish().save();}//! An invoice: an embedded, subset font (Noto Sans), a bold standard 14 heading, a table of//! line items with right-aligned amounts, rules and an accent bar, a link and metadata, tagged//! (B8a): `H1`, `P`, a `Table` of `TR`/`TD`, the link in a `Link` element, the bar and the//! rules as artifacts.//!//! ```sh//! cargo run -p lamina --example invoice -- [out-dir] # writes <out-dir>/invoice.pdf//! ```
use std::error::Error;use std::path::PathBuf;
use lamina::{ Alignment, ArtifactKind, BuildOptions, Builder, CancelToken, Color, FillRule, LinkTarget, Metadata, PageSize, PointPt, Pt, RectPt, SaveOptions, Standard14, StrokeStyle, Surface, TagKind, TextBoxOptions,};
const ITEMS: [(&str, u32, f64); 4] = [ ("Widgets, blue (W-100)", 3, 12.00), ("Gadget with fine-tuned dials", 1, 79.90), ("Café-grade sprockets", 12, 1.25), ("Delivery", 1, 4.50),];
fn main() -> Result<(), Box<dyn Error>> { let out = PathBuf::from(std::env::args().nth(1).unwrap_or_else(|| "target/examples-out".into())); std::fs::create_dir_all(&out)?; std::fs::write(out.join("invoice.pdf"), build()?)?; Ok(())}
fn rect(x: f64, y: f64, w: f64, h: f64) -> RectPt { RectPt::from_xywh(Pt::new(x), Pt::new(y), Pt::new(w), Pt::new(h))}
/// The document's bytes.pub(crate) fn build() -> Result<Vec<u8>, Box<dyn Error>> { let mut doc = builder()?.finish()?; Ok(doc.save(&SaveOptions::new(), &CancelToken::new())?.bytes)}
/// The invoice, drawn and not yet finished (its tests read the tagging diagnostics).pub(crate) fn builder() -> Result<Builder, Box<dyn Error>> { let at = PointPt::from_points; let options = BuildOptions::default() .with_id(*b"lamina-ex-invoic") .with_producer("lamina examples") .with_creation_date("D:20261030120000Z".parse()?) .with_tagged(true); let mut b = Builder::new(options); let font = b.font_bytes(include_bytes!("../../../fixtures/fonts/NotoSans-Regular.ttf").to_vec())?; let bold = b.standard_font(Standard14::HelveticaBold)?; let mut page = b.page(PageSize::A4); let s = page.surface(); // The accent bar and the hairlines are decoration (artifacts); the rest is the // structure a screen reader follows: a heading, a paragraph, a table, the total, a link. s.artifact(ArtifactKind::Layout, |s| { s.fill_color(Color::hex("#1e78c8")?) .path(|p| { p.rect(rect(0.0, 0.0, 595.28, 14.0)); }) .fill(FillRule::NonZero) .fill_color(Color::gray(0.0)); Ok(()) })?; let _ = s .text("Invoice 42", &bold, Pt::new(24.0), at(56.0, 80.0))? .tag(TagKind::H1); let _ = s .text( "Acme Widgets Ltd · 1 Example Street · London", &font, Pt::new(10.0), at(56.0, 100.0), )? .tag(TagKind::P); let right = TextBoxOptions::default().with_align(Alignment::End); let hairline = StrokeStyle { width: Pt::new(0.5), ..StrokeStyle::default() }; let cell = |s: &mut Surface, text: &str, r: RectPt| { s.tag(TagKind::TD, |s| { s.text_box(text, &font, Pt::new(11.0), r, &right).map(|_| ()) }) }; let mut total = 0.0; s.tag(TagKind::Table, |s| { for (row, (item, qty, price)) in (0_u32..).zip(ITEMS) { let y = 160.0 + 24.0 * f64::from(row); let amount = f64::from(qty) * price; total += amount; s.tag(TagKind::TR, |s| { let _ = s.text(item, &font, Pt::new(11.0), at(56.0, y))?.tag(TagKind::TD); cell(s, &qty.to_string(), rect(330.0, y - 12.0, 40.0, 16.0))?; cell(s, &format!("€ {amount:.2}"), rect(439.0, y - 12.0, 100.0, 16.0))?; s.artifact(ArtifactKind::Layout, |s| { s.stroke_color(Color::gray(0.8)) .path(|p| { p.move_to(at(56.0, y + 8.0)).line_to(at(539.0, y + 8.0)); }) .stroke(&hairline); Ok(()) }) })?; } Ok(()) })?; s.tag(TagKind::P, |s| { s.text_box( &format!("Total € {total:.2}"), &font, Pt::new(14.0), rect(339.0, 262.0, 200.0, 20.0), &right, ) .map(|_| ()) })?; let _ = s .text( "Pay online at example.com/pay/42", &font, Pt::new(11.0), at(56.0, 320.0), )? .fill(Color::rgb(0.12, 0.47, 0.78)) .link( LinkTarget::Uri("https://example.com/pay/42".into()), Some("Pay invoice 42 online".into()), ) .tag(TagKind::P); page.finish()?; b.metadata( Metadata::default() .with_title("Invoice 42") .with_author("Acme Widgets Ltd"), ) .lang("en-GB"); Ok(b)}multilingual
Section titled “multilingual”Latin, Arabic, Devanagari, Hebrew and Chinese, each in its own embedded font: a shaped line and a paragraph broken into a narrow box. Open the PDF.
// Five scripts, each with its own embedded font: a shaped line and a paragraph broken into a// narrow box (right-to-left for Arabic and Hebrew). The TS twin of// crates/lamina/examples/multilingual.rs.import { Builder, type Direction, Font } from "@lamina/pdf";
/** Each script's line, its paragraph and the paragraph's direction (fonts in the same order). */export const SCRIPTS: readonly (readonly [line: string, paragraph: string, direction: Direction])[] = [ [ "Latin: the office staff fill the affine waffle flask", "A paragraph breaks at spaces and fits the width of its box, line by line.", "ltr", ], ["مرحبا بالعالم السلام عليكم", "مرحبا بالعالم السلام عليكم مرحبا بالعالم السلام عليكم", "rtl"], ["नमस्ते दुनिया। हिन्दी पाठ का परीक्षण", "नमस्ते दुनिया। हिन्दी पाठ का परीक्षण नमस्ते दुनिया।", "ltr"], ["שלום עולם וברוכים הבאים", "שלום עולם וברוכים הבאים שלום עולם וברוכים הבאים", "rtl"], ["你好世界,这是中文字体子集测试。", "你好世界,这是中文字体子集测试。你好世界,这是中文。", "ltr"],];
/** * The document's bytes. Needs `init({ text: true })`. * * @param fonts - Noto Sans, Noto Sans Arabic, Devanagari, Hebrew and SC (`fixtures/fonts/`), in * the order of {@link SCRIPTS}. */export function multilingual(fonts: readonly Uint8Array[]): Uint8Array { const b = new Builder({ id: new TextEncoder().encode("lamina-ex-multil"), producer: "lamina examples", creationDate: "D:20261030120000Z", }); const loaded = fonts.map((bytes) => Font.fromBytes(bytes)); for (const font of loaded) b.font(font); const page = b.page("A4"); const s = page.surface(); SCRIPTS.forEach(([line, paragraph, direction], row) => { const font = loaded[row]; if (font === undefined) throw new Error(`no font for script ${row}`); const y = 80 + 140 * row; s.text(line, { font, size: 16, at: [56, y] }); // A narrow box breaks the paragraph into lines; right-to-left paragraphs start at the right. s.textBox(paragraph, { font, size: 12, box: [56, y + 14, 220, 100], direction, align: "start" }); }); page.finish(); return b.finish().save();}//! Five scripts on one page: Latin, Arabic and Hebrew (right to left), Devanagari and//! simplified Chinese, each shaped with HarfRust and bidi-resolved, as single lines and as//! wrapped paragraphs in text boxes. Fonts are Noto subsets from `fixtures/fonts/` (OFL),//! passed as bytes.//!//! ```sh//! cargo run -p lamina --example multilingual -- [out-dir] # writes <out-dir>/multilingual.pdf//! ```
use std::error::Error;use std::path::PathBuf;
use lamina::{ Alignment, BuildOptions, Builder, CancelToken, Direction, PageSize, PointPt, Pt, RectPt, SaveOptions, TextBoxOptions,};
/// Each script's font, its line, its paragraph and the paragraph's direction.const SCRIPTS: [(&[u8], &str, &str, Direction); 5] = [ ( include_bytes!("../../../fixtures/fonts/NotoSans-Regular.ttf"), "Latin: the office staff fill the affine waffle flask", "A paragraph breaks at spaces and fits the width of its box, line by line.", Direction::LeftToRight, ), ( include_bytes!("../../../fixtures/fonts/NotoSansArabic-Regular.ttf"), "مرحبا بالعالم السلام عليكم", "مرحبا بالعالم السلام عليكم مرحبا بالعالم السلام عليكم", Direction::RightToLeft, ), ( include_bytes!("../../../fixtures/fonts/NotoSansDevanagari-Regular.ttf"), "नमस्ते दुनिया। हिन्दी पाठ का परीक्षण", "नमस्ते दुनिया। हिन्दी पाठ का परीक्षण नमस्ते दुनिया।", Direction::LeftToRight, ), ( include_bytes!("../../../fixtures/fonts/NotoSansHebrew-Regular.ttf"), "שלום עולם וברוכים הבאים", "שלום עולם וברוכים הבאים שלום עולם וברוכים הבאים", Direction::RightToLeft, ), ( include_bytes!("../../../fixtures/fonts/NotoSansSC-Regular.otf"), "你好世界,这是中文字体子集测试。", "你好世界,这是中文字体子集测试。你好世界,这是中文。", Direction::LeftToRight, ),];
fn main() -> Result<(), Box<dyn Error>> { let out = PathBuf::from(std::env::args().nth(1).unwrap_or_else(|| "target/examples-out".into())); std::fs::create_dir_all(&out)?; std::fs::write(out.join("multilingual.pdf"), build()?)?; Ok(())}
/// The document's bytes.pub(crate) fn build() -> Result<Vec<u8>, Box<dyn Error>> { let options = BuildOptions::default() .with_id(*b"lamina-ex-multil") .with_producer("lamina examples") .with_creation_date("D:20261030120000Z".parse()?); let mut b = Builder::new(options); let fonts = SCRIPTS .iter() .map(|(bytes, ..)| b.font_bytes(bytes.to_vec())) .collect::<Result<Vec<_>, _>>()?; let mut page = b.page(PageSize::A4); let s = page.surface(); for (row, ((_, line, paragraph, direction), font)) in (0_u32..).zip(SCRIPTS.iter().zip(&fonts)) { let y = 80.0 + 140.0 * f64::from(row); let _ = s.text(line, font, Pt::new(16.0), PointPt::from_points(56.0, y))?; // A narrow box breaks the paragraph into lines; right-to-left paragraphs start at the right. let options = TextBoxOptions::default() .with_direction(*direction) .with_align(Alignment::Start); let area = RectPt::from_xywh(Pt::new(56.0), Pt::new(y + 14.0), Pt::new(220.0), Pt::new(100.0)); s.text_box(paragraph, font, Pt::new(12.0), area, &options)?; } page.finish()?; let mut doc = b.finish()?; Ok(doc.save(&SaveOptions::new(), &CancelToken::new())?.bytes)}images and vectors
Section titled “images and vectors”Paths, dashed strokes, gray, RGB, CMYK and hex colour, transparency and a blend mode, a clip, a rotated transform, a JPEG and an RGBA image with a soft mask. Open the PDF.
// Vector graphics and images: rectangles, circles, Bézier curves, dashed round-capped strokes,// gray/RGB/CMYK/hex colour, transparency and a blend mode, a clip, a rotated transform, a JPEG// passed through and an RGBA image with a soft mask. Needs only the base module. The TS twin of// crates/lamina/examples/vectors.rs (fixtures/golden/examples/vectors.pdf).import { Builder, Image, Matrix } from "@lamina/pdf";
/** A 16×16 RGBA gradient whose alpha falls off towards the right. */function gradient(): Uint8Array { const out = new Uint8Array(16 * 16 * 4); for (let y = 0; y < 16; y++) { for (let x = 0; x < 16; x++) { out.set([x * 16, y * 16, 200, 255 - x * 15], (y * 16 + x) * 4); } } return out;}
/** * The document's bytes. * * @param photo - `fixtures/images/photo-rgb.jpg`. */export function imagesAndVectors(photo: Uint8Array): Uint8Array { const b = new Builder({ id: new TextEncoder().encode("lamina-ex-vector"), producer: "lamina examples", creationDate: "D:20261030120000Z", }); const jpeg = Image.jpeg(photo); const glow = Image.rgba(16, 16, gradient()); const page = b.page("A4"); const s = page.surface(); const dashed = { width: 3, cap: "round", dash: [12, 8] } as const; s.fillColor([0.9, 0.3, 0.2]) .path((p) => p.rect([56, 56, 160, 100])) .fill(); s.fillColor([0.8, 0.1, 0, 0]) .path((p) => p.circle([330, 106], 50)) .fill(); s.strokeColor("#2a9d8f") .path((p) => p.moveTo(56, 220).cubicTo(180, 140, 300, 300, 539, 200)) .stroke(dashed); // Translucent overlapping discs, the second multiplied onto the first. s.save().opacity(0.6, 1).fillColor([0.3]); s.path((p) => p.circle([120, 330], 60)).fill(); s.blendMode("multiply").fillColor([1, 0.8, 0]); s.path((p) => p.circle([190, 330], 60)) .fill() .restore(); // The photo clipped to an ellipse, and a rotated, alpha-masked gradient. s.save() .path((p) => p.ellipse([300, 270, 239, 120])) .clip(); s.image(jpeg, [300, 250, 239, 160]).restore(); s.save().transform(Matrix.after(Matrix.translate(150, 520), Matrix.rotate(30))); s.image(glow, [-60, -60, 120, 120]).restore(); page.finish(); return b.finish().save();}//! Vector graphics and images: rectangles, circles, Bézier curves, dashed and round-capped//! strokes, gray/RGB/CMYK/hex colour, transparency and a blend mode, a clip, a rotated//! transform, a JPEG passed through and an RGBA image with a soft mask.//!//! ```sh//! cargo run -p lamina --example vectors -- [out-dir] # writes <out-dir>/vectors.pdf//! ```
use std::error::Error;use std::path::PathBuf;
use lamina::{ BlendMode, BuildOptions, Builder, CancelToken, Color, Dash, FillRule, Image, LineCap, Matrix, PageSize, PointPt, Pt, RectPt, SaveOptions, StrokeStyle,};
fn main() -> Result<(), Box<dyn Error>> { let out = PathBuf::from(std::env::args().nth(1).unwrap_or_else(|| "target/examples-out".into())); std::fs::create_dir_all(&out)?; std::fs::write(out.join("vectors.pdf"), build()?)?; Ok(())}
fn rect(x: f64, y: f64, w: f64, h: f64) -> RectPt { RectPt::from_xywh(Pt::new(x), Pt::new(y), Pt::new(w), Pt::new(h))}
/// A 16×16 RGBA gradient whose alpha falls off towards the right.fn gradient() -> Vec<u8> { (0_u8..16) .flat_map(|y| (0_u8..16).flat_map(move |x| [x * 16, y * 16, 200, 255 - x * 15])) .collect()}
/// The document's bytes.pub(crate) fn build() -> Result<Vec<u8>, Box<dyn Error>> { let at = PointPt::from_points; let options = BuildOptions::default() .with_id(*b"lamina-ex-vector") .with_producer("lamina examples") .with_creation_date("D:20261030120000Z".parse()?); let mut b = Builder::new(options); let photo = Image::jpeg(include_bytes!("../../../fixtures/images/photo-rgb.jpg").to_vec())?; let glow = Image::rgba(16, 16, &gradient())?; let mut page = b.page(PageSize::A4); let s = page.surface(); let dashed = StrokeStyle { width: Pt::new(3.0), cap: LineCap::Round, dash: Some(Dash { array: vec![Pt::new(12.0), Pt::new(8.0)], phase: Pt::ZERO, }), ..StrokeStyle::default() }; s.fill_color(Color::rgb(0.9, 0.3, 0.2)) .path(|p| { p.rect(rect(56.0, 56.0, 160.0, 100.0)); }) .fill(FillRule::NonZero); s.fill_color(Color::cmyk(0.8, 0.1, 0.0, 0.0)) .path(|p| { p.circle(at(330.0, 106.0), Pt::new(50.0)); }) .fill(FillRule::NonZero); s.stroke_color(Color::hex("#2a9d8f")?) .path(|p| { p.move_to(at(56.0, 220.0)) .cubic_to(at(180.0, 140.0), at(300.0, 300.0), at(539.0, 200.0)); }) .stroke(&dashed); // Translucent overlapping discs, the second multiplied onto the first. s.save().opacity(0.6, 1.0).fill_color(Color::gray(0.3)); s.path(|p| { p.circle(at(120.0, 330.0), Pt::new(60.0)); }) .fill(FillRule::NonZero); s.blend_mode(BlendMode::Multiply).fill_color(Color::rgb(1.0, 0.8, 0.0)); s.path(|p| { p.circle(at(190.0, 330.0), Pt::new(60.0)); }) .fill(FillRule::NonZero) .restore(); // The photo clipped to an ellipse, and a rotated, alpha-masked gradient. s.save() .path(|p| { p.ellipse(rect(300.0, 270.0, 239.0, 120.0)); }) .clip(FillRule::NonZero); s.image(&photo, rect(300.0, 250.0, 239.0, 160.0)).restore(); s.save() .transform(Matrix::translate(Pt::new(150.0), Pt::new(520.0)).after(&Matrix::rotate(30.0))); s.image(&glow, rect(-60.0, -60.0, 120.0, 120.0)).restore(); page.finish()?; let mut doc = b.finish()?; Ok(doc.save(&SaveOptions::new(), &CancelToken::new())?.bytes)}links and outlines
Section titled “links and outlines”URI, page and named-destination links, an outline with a heading, colour and styles, page labels, and the outline shown when the file opens. Open the PDF.
// Navigation: URI, page and named-destination links, an outline with a heading, colour and// styles, page labels and the outline shown on open. The TS twin of// crates/lamina/examples/links_and_outlines.rs.import { Builder, Font, type Rect } from "@lamina/pdf";
const TITLES = ["Title page", "Contents", "Chapter 1", "Chapter 2"] as const;
/** The document's bytes. Needs `init({ text: true })`. */export function linksAndOutlines(): Uint8Array { const row = (y: number): Rect => [36, y - 15, 220, 20]; const b = new Builder({ id: new TextEncoder().encode("lamina-ex-links!"), producer: "lamina examples", creationDate: "D:20261030120000Z", }); const font = Font.standard("Helvetica"); b.font(font); b.destination("chapter-2", 3); TITLES.forEach((title, index) => { const page = b.page("A5"); const s = page.surface(); s.text(title, { font, size: 18, at: [36, 60] }); if (index === 0) { s.text("Visit example.com", { font, size: 12, at: [36, 100] }); s.link(row(100), { type: "uri", uri: "https://example.com/" }, "Example"); s.text("Go to chapter 1", { font, size: 12, at: [36, 125] }); s.link(row(125), { type: "page", index: 2 }); s.text("Go to chapter 2 (named)", { font, size: 12, at: [36, 150] }); s.link(row(150), { type: "named", name: "chapter-2" }); } else { s.text("Back to the title page", { font, size: 12, at: [36, 100], fill: [0, 0.2, 0.8], link: { target: { type: "page", index: 0 }, contents: "Title page" }, }); } page.finish(); }); b.outline([ { title: "Title page", target: { type: "page", index: 0 } }, { title: "Contents", target: { type: "page", index: 1 }, color: [0, 0, 0.6] }, { title: "Chapters", open: true, bold: true, children: [ { title: "Chapter 1", target: { type: "page", index: 2 } }, { title: "Chapter 2", target: { type: "named", name: "chapter-2" }, italic: true }, ], }, ]); b.pageLabels([ { start: 0, style: "lowerRoman" }, { start: 2, style: "decimal" }, ]).viewerPreferences({ pageMode: "outlines" }); return b.finish().save();}//! Navigation: four A5 pages with a URI link, links to pages (forward and back) and through a//! named destination, a linked text run, a nested outline (bookmarks) opened in the viewer,//! and page labels i, ii, 1, 2.//!//! ```sh//! cargo run -p lamina --example links_and_outlines -- [out-dir] # writes <out-dir>/links_and_outlines.pdf//! ```
use std::error::Error;use std::path::PathBuf;
use lamina::{ BuildOptions, Builder, CancelToken, Color, Fit, LabelStyle, LinkTarget, OutlineEntry, PageLabel, PageMode, PageSize, PointPt, Pt, RectPt, SaveOptions, Standard14, ViewerPreferences,};
const TITLES: [&str; 4] = ["Title page", "Contents", "Chapter 1", "Chapter 2"];
fn main() -> Result<(), Box<dyn Error>> { let out = PathBuf::from(std::env::args().nth(1).unwrap_or_else(|| "target/examples-out".into())); std::fs::create_dir_all(&out)?; std::fs::write(out.join("links_and_outlines.pdf"), build()?)?; Ok(())}
/// The document's bytes.pub(crate) fn build() -> Result<Vec<u8>, Box<dyn Error>> { let at = PointPt::from_points; let row = |y: f64| RectPt::from_xywh(Pt::new(36.0), Pt::new(y - 15.0), Pt::new(220.0), Pt::new(20.0)); let options = BuildOptions::default() .with_id(*b"lamina-ex-links!") .with_producer("lamina examples") .with_creation_date("D:20261030120000Z".parse()?); let mut b = Builder::new(options); let helvetica = b.standard_font(Standard14::Helvetica)?; b.destination("chapter-2", 3, Fit::Page); for (index, title) in (0_u32..).zip(TITLES) { let mut page = b.page(PageSize::A5); let s = page.surface(); let _ = s.text(title, &helvetica, Pt::new(18.0), at(36.0, 60.0))?; if index == 0 { let _ = s.text("Visit example.com", &helvetica, Pt::new(12.0), at(36.0, 100.0))?; s.link( row(100.0), LinkTarget::Uri("https://example.com/".into()), Some("Example".into()), ); let _ = s.text("Go to chapter 1", &helvetica, Pt::new(12.0), at(36.0, 125.0))?; s.link(row(125.0), LinkTarget::page(2), None); let _ = s.text("Go to chapter 2 (named)", &helvetica, Pt::new(12.0), at(36.0, 150.0))?; s.link(row(150.0), LinkTarget::Named("chapter-2".into()), None); } else { let _ = s .text("Back to the title page", &helvetica, Pt::new(12.0), at(36.0, 100.0))? .fill(Color::rgb(0.0, 0.2, 0.8)) .link(LinkTarget::page(0), Some("Title page".into())); } page.finish()?; } b.outline(vec![ OutlineEntry::new("Title page", LinkTarget::page(0)), OutlineEntry::new("Contents", LinkTarget::page(1)).with_color(0.0, 0.0, 0.6), OutlineEntry::heading("Chapters") .open(true) .bold() .with_child(OutlineEntry::new("Chapter 1", LinkTarget::page(2))) .with_child(OutlineEntry::new("Chapter 2", LinkTarget::Named("chapter-2".into())).italic()), ])?; b.page_labels(vec![ PageLabel::new(0, Some(LabelStyle::LowerRoman)), PageLabel::new(2, Some(LabelStyle::Decimal)), ]) .viewer_preferences(ViewerPreferences::default().with_page_mode(PageMode::Outlines)); let mut doc = b.finish()?; Ok(doc.save(&SaveOptions::new(), &CancelToken::new())?.bytes)}generate then edit
Section titled “generate then edit”Generate a file, open its bytes, edit the metadata in one transaction and save incrementally. Open the PDF.
// Generate a document, then open its bytes and edit them in one transaction, and save// incrementally: the generated bytes unchanged, then the appended revision. The TS twin of// crates/lamina/examples/generate_then_edit.rs.import { Builder, Document, Font } from "@lamina/pdf";
/** The document's bytes: the generated file followed by the edit's revision. Needs `init({ text: true })`. */export function generateThenEdit(): Uint8Array { // 1. Generate and save (a new document is always a full rewrite). const b = new Builder({ id: new TextEncoder().encode("lamina-ex-edit!!"), producer: "lamina examples", creationDate: "D:20261030120000Z", }); const page = b.page("A4"); page.surface().text("Draft report", { font: Font.standard("Helvetica"), size: 20, at: [72, 96] }); page.finish(); b.metadata({ title: "Draft report" }); const generated = b.finish().save();
// 2. Open the bytes and edit them: one transaction, which `undo` could revert. const doc = Document.open(generated); doc.transact((tx) => tx.setInfo({ title: "Final report", author: "Ada Lovelace" }, "D:20261030130000Z"));
// 3. Save incrementally: the generated bytes, unchanged, then the appended revision. const edited = doc.save({ mode: "incremental" }); if (!generated.every((byte, i) => edited[i] === byte) || doc.info.title !== "Final report") { throw new Error("the incremental save did not append to the generated file"); } doc.free(); return edited;}//! Generate, then edit: build a document, save it, open the bytes, change the Info//! dictionary with an op (undoable and serialisable), and save incrementally: the original//! bytes stay as they are, with one revision appended.//!//! ```sh//! cargo run -p lamina --example generate_then_edit -- [out-dir] # writes <out-dir>/generate_then_edit.pdf//! ```
use std::error::Error;use std::path::PathBuf;
use lamina::{ BuildOptions, Builder, CancelToken, Document, InfoPatch, Metadata, Op, OpenOptions, PageSize, PointPt, Pt, SaveMode, SaveOptions, SetInfo, Standard14,};
fn main() -> Result<(), Box<dyn Error>> { let out = PathBuf::from(std::env::args().nth(1).unwrap_or_else(|| "target/examples-out".into())); std::fs::create_dir_all(&out)?; std::fs::write(out.join("generate_then_edit.pdf"), build()?)?; Ok(())}
/// The document's bytes: the generated file followed by the edit's revision.pub(crate) fn build() -> Result<Vec<u8>, Box<dyn Error>> { // 1. Generate and save (a new document is always a full rewrite). let options = BuildOptions::default() .with_id(*b"lamina-ex-edit!!") .with_producer("lamina examples") .with_creation_date("D:20261030120000Z".parse()?); let mut b = Builder::new(options); let helvetica = b.standard_font(Standard14::Helvetica)?; let mut page = b.page(PageSize::A4); let _ = page.surface().text( "Draft report", &helvetica, Pt::new(20.0), PointPt::from_points(72.0, 96.0), )?; page.finish()?; b.metadata(Metadata::default().with_title("Draft report")); let generated = b.finish()?.save(&SaveOptions::new(), &CancelToken::new())?.bytes;
// 2. Open the bytes and edit them with an op: one transaction, which `undo` could revert. let mut doc = Document::open_bytes(generated.clone(), OpenOptions::default())?; let patch = InfoPatch::new() .title(Some("Final report")) .author(Some("Ada Lovelace")); doc.apply(vec![Op::from(SetInfo::new(patch, Some("D:20261030130000Z".parse()?)))])?;
// 3. Save incrementally: the generated bytes, unchanged, then the appended revision. let options = SaveOptions::new().with_mode(SaveMode::Incremental); let edited = doc.save(&options, &CancelToken::new())?.bytes; if !edited.starts_with(&generated) || doc.info()?.title.as_deref() != Some("Final report") { return Err("the incremental save did not append to the generated file".into()); } Ok(edited)}report (TypeScript)
Section titled “report (TypeScript)”A 100-page report with two embedded fonts: the performance case each runtime times. It has no golden file. Every runtime checks the report’s SHA-256 instead, and so does the Rust benchmark that builds the same bytes.
// A 100-page report with two embedded fonts: a heading, a paragraph broken into a box, a table// with hairlines, a line of Chinese (glyphs the fixture subset has) and a footer on every page.// It has no Rust golden: it is the §4 performance case (pdf-library plan: a 100-page report in// ≤ 500 ms, < 64 MB in a Worker) that the runtime matrix (B6c) times in every runtime, checking// that each produces the same bytes (test/suite/examples.ts). Its Rust twin is the// `report_100_pages` bench in crates/lamina/benches/report.rs, which checks it builds the same// bytes (REPORT_SHA256): change both together.import { Builder, Font } from "@lamina/pdf";
/** The number of pages. */export const REPORT_PAGES = 100;
const PARAGRAPH = "Quarterly figures for every region follow. Each table lists the units shipped, the revenue " + "and the change against the previous quarter; totals are at the end of the report.";
/** * The document's bytes. Needs `init({ text: true })`. * * @param notoSans - `fixtures/fonts/NotoSans-Regular.ttf`. * @param notoSansSC - `fixtures/fonts/NotoSansSC-Regular.otf`. */export function report(notoSans: Uint8Array, notoSansSC: Uint8Array): Uint8Array { const b = new Builder({ id: new TextEncoder().encode("lamina-ex-report"), producer: "lamina examples", creationDate: "D:20261030120000Z", }); try { const font = Font.fromBytes(notoSans); const chinese = Font.fromBytes(notoSansSC); b.font(font); b.font(chinese); for (let n = 1; n <= REPORT_PAGES; n++) { const page = b.page("A4"); const s = page.surface(); s.text(`Region ${n}: quarterly report`, { font, size: 20, at: [56, 72] }); s.textBox(PARAGRAPH, { font, size: 11, box: [56, 88, 483, 60] }); s.text("你好世界,这是中文字体子集测试。", { font: chinese, size: 11, at: [56, 166] }); for (let row = 0; row < 20; row++) { const y = 200 + 24 * row; const units = (n * 37 + row * 11) % 500; s.text(`Product ${row + 1}`, { font, size: 10, at: [56, y] }); s.textBox(String(units), { font, size: 10, box: [300, y - 11, 60, 14], align: "end" }); s.textBox(`€ ${(units * 12.5).toFixed(2)}`, { font, size: 10, box: [400, y - 11, 139, 14], align: "end" }); s.strokeColor([0.8]) .path((p) => p.moveTo(56, y + 7).lineTo(539, y + 7)) .stroke({ width: 0.5 }); } s.text(`Page ${n} of ${REPORT_PAGES}`, { font, size: 9, at: [56, 800] }); page.finish(); } b.metadata({ title: "Quarterly report", author: "lamina examples" }).lang("en-GB"); // Free the document at once (the saved bytes are a copy): a Worker or a timing loop calling // this many times shouldn't wait for the garbage collector to release WebAssembly memory. const doc = b.finish(); try { return doc.save(); } finally { doc.free(); } } finally { b.free(); // a no-op after finish(); frees the builder now when drawing threw }}Worker
Section titled “Worker”A Cloudflare Worker that generates a PDF per request. See the Workers quick start.
open untrusted (TypeScript)
Section titled “open untrusted (TypeScript)”Opening someone else’s file with a password and tighter limits, with the outcome returned as a value. It writes no PDF. See Encrypted files.
// Open bytes from someone else: a password, tighter limits than the defaults, and the outcome as// a value rather than an exception (the docs site's encrypted files, limits and errors guides).// It has no Rust golden: it writes nothing; test/examples.test.ts checks what it reports.import { Document, isLaminaError } from "@lamina/pdf";
/** What opening a file gave: its page count and warning codes, or why it didn't open. */export type Opened = | { readonly ok: true; readonly pages: number; readonly warnings: readonly string[] } | { readonly ok: false; readonly code: string; readonly hint: string | undefined };
/** * Opens `bytes` with `password` (empty: none) and limits for an upload endpoint: decoded streams * of at most 16 MiB each and 64 MiB in all. Needs `init()`. */export function openUntrusted(bytes: Uint8Array, password = ""): Opened { try { const doc = Document.open(bytes, { ...(password === "" ? {} : { password }), limits: { maxStreamDecoded: 16 << 20, maxDecodedTotal: 64 << 20 }, }); try { // Repairs and dropped values are warnings: the file opened, but not exactly as written. return { ok: true, pages: doc.pageCount, warnings: doc.warnings.map((w) => w.code) }; } finally { doc.free(); } } catch (e) { // `password-required`, `wrong-password`, `not-pdf`, `limit-exceeded`, …: a coded LaminaError // whose message and hint never contain text from the file (ADR 0026 §4). if (isLaminaError(e)) return { ok: false, code: e.code, hint: e.hint }; throw e; }}worker client (TypeScript)
Section titled “worker client (TypeScript)”The same API in a Web Worker: open, count the pages, save with progress, and abort a save. It runs in browsers. See Off the main thread.
// `@lamina/pdf/worker`: the sync API's async twin in a Web Worker, so a large file never blocks// the page. Open, count the pages, save with progress, and abort a save the user cancels. Runs// in browsers (test/browser/worker.browser.test.ts).import { isLaminaError } from "@lamina/pdf";import { connect } from "@lamina/pdf/worker";
/** What the example saw: the page count, the saved size, and whether the abort was reported. */export interface WorkerClientResult { readonly pages: number; readonly savedBytes: number; readonly progressEvents: number; readonly abortedAsCancelled: boolean;}
/** * Opens `bytes` in a worker, saves them, then starts a second save and aborts it. * * @param bytes - any PDF; it is copied to the worker (`transfer: true` would move it). */export async function workerClient(bytes: Uint8Array): Promise<WorkerClientResult> { const client = await connect(); try { await using doc = await client.open(bytes); const pages = await doc.pageCount();
let progressEvents = 0; const saved = await doc.save({ mode: "fullRewrite" }, { onProgress: () => progressEvents++ });
// A cancel button: abort the signal and the save stops at its next step. const controller = new AbortController(); const saving = doc.save({ mode: "fullRewrite" }, { signal: controller.signal }); controller.abort(new Error("the user cancelled")); let abortedAsCancelled = false; try { await saving; } catch (e) { abortedAsCancelled = isLaminaError(e, "cancelled"); }
return { pages, savedBytes: saved.byteLength, progressEvents, abortedAsCancelled }; } finally { client.terminate(); }}