# Lamina PDF 0.2.0-alpha.2 > Lamina PDF creates, reads, edits and saves PDF files from TypeScript and Rust: one Rust core, run as WebAssembly in browsers, Node, Deno, Bun and Cloudflare Workers (`@laminapdf/sdk`), or natively from the `laminapdf` crate. Positions are top-left points of the displayed page and page indexes start at 0; every error has a stable code with its own page. # Lamina PDF Create, read, edit and save PDFs from Rust and TypeScript, with one Rust core that runs natively and as WebAssembly. Lamina PDF creates, reads, edits and saves PDFs. One Rust core does the work, and it runs natively and as WebAssembly in browsers, Node, Deno, Bun and Cloudflare Workers. It's for apps that make documents, such as invoices, reports and tickets, and for apps that open documents other people send. - **[`laminapdf`](https://crates.io/crates/laminapdf)** on crates.io is the Rust crate. Its [API reference](/reference/rust.md) is on docs.rs. - **[`@laminapdf/sdk`](https://www.npmjs.com/package/@laminapdf/sdk)** on npm is the TypeScript API over the WebAssembly build. After one `await init()`, every call is synchronous, and the API is the same in every runtime. Its [API reference](/reference/sdk/readme.md) is on this site. > **Alpha releases:** Both packages are alpha releases, so name the alpha when you install them: `npm install @laminapdf/sdk@alpha` and `cargo add laminapdf@0.2.0-alpha`. [Install](/install.md) lists every published package. ## What works today | Area | What you get | Guide | | --- | --- | --- | | Generate | Pages, text with shaping, bidi and line breaking, embedded and subset fonts, the standard 14, vectors, colour, images, tagged PDF | [Fonts and text](/guides/fonts-and-text.md), [Images and vectors](/guides/images-and-vectors.md) | | Navigate | Links, named destinations, outlines, page labels, Info and XMP metadata | [Links, outlines and metadata](/guides/links-outlines-and-metadata.md) | | Edit | Ops in transactions, undo and redo, incremental or full saves that respect signatures | [Edit a PDF](/guides/edit-a-pdf.md), [Save a PDF](/guides/save-a-pdf.md) | | Read safely | Repair of damaged files as warnings, resource limits, strict mode, passwords | [Open password-protected files](/guides/password-protected-files.md), [Open files you don't trust](/guides/untrusted-files.md) | | Errors | One coded error and warning shape on every surface | [Errors and warnings](/concepts/errors-and-warnings.md) | Every code block on this site is a tested example file, so the code you see is the code that runs. Each example writes the same bytes in Rust and in every JavaScript runtime. ## Where to go next Start with [your first PDF](/start/first-pdf.md), then read [how Lamina PDF thinks about a PDF](/concepts/how-lamina-thinks.md). When you're ready for your own setup, pick [your runtime](/quick-start/browser.md). --- # Install Install Lamina PDF from npm or crates.io, and find every published package. Lamina PDF is published to npm as [`@laminapdf/sdk`](https://www.npmjs.com/package/@laminapdf/sdk) and to crates.io as [`laminapdf`](https://crates.io/crates/laminapdf). Both are alpha releases, so name the alpha when you install them. A plain `npm install` or `cargo add` picks a `0.0.0` placeholder that only reserves the name. ## TypeScript: `@laminapdf/sdk` Install the `alpha` tag: ```sh npm install @laminapdf/sdk@alpha ``` pnpm, Yarn and Bun take the same name (`pnpm add @laminapdf/sdk@alpha`), and Deno takes `npm:@laminapdf/sdk@alpha`. The package brings [`@laminapdf/wasm`](https://www.npmjs.com/package/@laminapdf/wasm), its WebAssembly modules, as a dependency. The package is ESM only. It has one entry per runtime (the `workerd`, `deno`, `browser`, `node` and `default` export conditions), and each entry loads the `.wasm` its own way. The [quick starts](/quick-start/browser.md) show each one. ## Rust: `laminapdf` Add the crate at the alpha version: ```sh cargo add laminapdf@0.2.0-alpha ``` The command writes `laminapdf = "0.2.0-alpha"` to `Cargo.toml`. That requirement takes the newest 0.2.0 alpha. The default features are `text` (fonts and text, which includes the builder and text extraction) and `saslprep` (passwords for AES-256 files). For reading and editing only, add `--no-default-features`. ## Every published package Most projects need only the two above. The table links every published package to its registry page: | Package | Registry | What it holds | | --- | --- | --- | | `@laminapdf/sdk` | [npm](https://www.npmjs.com/package/@laminapdf/sdk) | The TypeScript API, for every JavaScript runtime | | `@laminapdf/wasm` | [npm](https://www.npmjs.com/package/@laminapdf/wasm) | The WebAssembly modules under `@laminapdf/sdk` | | `@laminapdf/render` | [npm](https://www.npmjs.com/package/@laminapdf/render) | The interface that page renderers implement | | `@laminapdf/render-pdfium` | [npm](https://www.npmjs.com/package/@laminapdf/render-pdfium) | The default web renderer, built on PDFium | | `@laminapdf/render-hayro` | [npm](https://www.npmjs.com/package/@laminapdf/render-hayro) | A second web renderer, built on hayro | | `@laminapdf/viewer` | [npm](https://www.npmjs.com/package/@laminapdf/viewer) | The core of a web PDF viewer, one worker per document | | `laminapdf` | [crates.io](https://crates.io/crates/laminapdf) | The Rust API: open, read, edit, save and build PDFs | | `laminapdf-syntax` | [crates.io](https://crates.io/crates/laminapdf-syntax) | Objects, the lexer, the parser and stream filters | | `laminapdf-cos` | [crates.io](https://crates.io/crates/laminapdf-cos) | File structure: the tolerant reader and the writer | | `laminapdf-model` | [crates.io](https://crates.io/crates/laminapdf-model) | Typed views of pages, outlines, metadata and labels | | `laminapdf-ops` | [crates.io](https://crates.io/crates/laminapdf-ops) | Editing ops, transactions, undo and redo | | `laminapdf-save` | [crates.io](https://crates.io/crates/laminapdf-save) | Incremental and full saves | | `laminapdf-content` | [crates.io](https://crates.io/crates/laminapdf-content) | The content-stream interpreter | | `laminapdf-text` | [crates.io](https://crates.io/crates/laminapdf-text) | Fonts, shaping and line breaking | | `laminapdf-build` | [crates.io](https://crates.io/crates/laminapdf-build) | The builder that generates PDFs | | `laminapdf-source` | [crates.io](https://crates.io/crates/laminapdf-source) | Byte sources filled range by range, for remote files | | `laminapdf-render` | [crates.io](https://crates.io/crates/laminapdf-render) | The Rust interface that page renderers implement | | `laminapdf-render-pdfium` | [crates.io](https://crates.io/crates/laminapdf-render-pdfium) | The native PDFium renderer | ## Try a change before it's released An unreleased change is only in the repository, so build the packages from a checkout. You need Rust (the toolchain in `rust-toolchain.toml` installs itself), Node 22 or newer and pnpm: ```sh pnpm install cargo xtask wasm # builds packages/wasm/dist/ and packages/sdk/dist/, then tests them ``` Then run `pnpm pack` in `packages/wasm` and in `packages/sdk`, and install both `.tgz` files in one command: `@laminapdf/sdk` depends on `@laminapdf/wasm` at the same version, which the registry may not have yet. In Rust, use a path dependency: `laminapdf = { path = "../laminapdf/crates/laminapdf" }`. ## API docs - TypeScript: the [`@laminapdf/sdk` reference](/reference/sdk/readme.md), generated from the package's TSDoc comments. - Rust: the [Rust API](/reference/rust.md) page, which links each crate's docs on docs.rs. --- # Your first PDF Write hello.pdf from Node in five minutes. You'll write `hello.pdf`, one A4 page that says "Hello, world!", from a short Node script. It takes about five minutes. ## Make a project You need Node 24.2 or newer, which runs `.ts` files directly. Create a folder and install `@laminapdf/sdk` from npm: ```sh mkdir hello-pdf && cd hello-pdf npm init -y && npm pkg set type=module npm install @laminapdf/sdk@alpha ``` Lamina PDF is in alpha, so the install names the `alpha` tag. `@laminapdf/sdk` brings its WebAssembly modules, `@laminapdf/wasm`, with it. [Install](/install.md) covers other package managers and Rust. ## Write the page Save this app as `hello.ts`. It loads Lamina PDF, builds the page and writes it to `hello.pdf`: `packages/sdk/examples/quick-start/node.ts`: ```ts // Builds a one-page PDF that says "Hello, world!" and writes it to hello.pdf, in Node. import { writeFile } from "node:fs/promises"; import { Builder, type BuilderOptions, Font, init } from "@laminapdf/sdk"; /** The space between the page's edge and the text, in points (72 points = 1 inch). */ const MARGIN = 72; /** The text's size in points; its baseline sits this far below the margin. */ const FONT_SIZE = 24; /** Builds the page and hands its bytes to `save`, which writes hello.pdf unless you pass another `save` function. */ export async function hello( save: (pdf: Uint8Array) => void | Promise = (pdf) => writeFile("hello.pdf", pdf), options: BuilderOptions = {}, ): Promise { await init({ text: true }); using builder = new Builder(options); const page = builder.page("A4"); // Points from the page's top-left corner, y down; the text's baseline sits FONT_SIZE points below the margin. page.surface().text("Hello, world!", { font: Font.standard("Helvetica"), size: FONT_SIZE, at: [MARGIN, MARGIN + FONT_SIZE], }); page.finish(); using doc = builder.finish(); await save(doc.save()); } // `node hello.ts` runs it (Node 24.2 or newer); imported, it only exports `hello`. if (import.meta.main) await hello(); ``` ## Run it ```sh node hello.ts ``` Node runs the TypeScript file as it is. Open `hello.pdf` in any PDF viewer: one page, with "Hello, world!" near its top-left corner. ## What happened `init({ text: true })` loaded Lamina PDF's text module, the one that draws fonts; after it, every call is synchronous. The builder made an A4 page, drew the text on it and finished into a document, `doc.save()` turned that document into bytes, and `save` wrote them to `hello.pdf`. `using` freed the builder and the document as soon as the function ended. Next, see [how Lamina PDF thinks about a PDF](/concepts/how-lamina-thinks.md) and [where things go on a page](/concepts/pages-and-coordinates.md). To use laminapdf somewhere other than Node, pick [your runtime](/quick-start/browser.md). --- # Use Lamina PDF with coding agents Point a coding agent at the docs inside the installed package, so its answers match the version you use. A coding agent guesses a new library's names from what it saw in training, which may be an older version or none at all. Each package therefore carries its own copy of these docs as Markdown. The copy sits in `node_modules/@laminapdf/sdk/docs/` for TypeScript and in the `laminapdf` crate's `docs/` folder for Rust, and every file names the version it describes. ## Add the snippet to your agent's instructions Copy this snippet into your project's `AGENTS.md`, `CLAUDE.md` or the file your agent reads. It sends the agent to the installed docs first, and it lists the rules where Lamina PDF differs from other PDF libraries: `packages/sdk/examples/AGENTS.md`: ```md ## Lamina PDF This project uses Lamina PDF (`@laminapdf/sdk` in TypeScript, the `laminapdf` crate in Rust) to create, read, edit and save PDF files. - **Read the installed docs first.** They match the installed version: `node_modules/@laminapdf/sdk/docs/llms.txt` lists every page, and `docs/api/` holds the API reference. In Rust they are in the `laminapdf` crate's `docs/` folder. Online: https://docs.laminapdf.com/llms.txt (the latest version). - **Call `await init()` once before anything else.** After it every call is synchronous. Fonts and text need `await init({ text: true })`; reading text needs the extract or text module (`await init({ extract: true })` or `{ text: true }`). Rust needs no setup. - **Positions are points from the top-left corner** of the page as it is displayed, with y going down. A text position is its baseline. Page numbers in the API start at 0 (`page(0)` is the first page). - **Branch on an error's `code`, never its message.** Every error is a `LaminaPdfError` with a stable `code` (such as `wrong-password`) and a `hint`; `docs/errors/.md` explains each one. Warnings, which say what Lamina PDF repaired in a damaged file, use the same codes. - **Free what you open.** A `Document` or `Builder` holds WebAssembly memory: declare it with `using`, or call `free()` when you are done. - **Check `docs/renames.json`** when a name in older code or an older answer doesn't exist: it says what each renamed item is called now. ``` ## What the package docs hold The folder starts at `llms.txt`, an index in the [llms.txt](https://llmstxt.org/) format whose links stay inside the folder: - `pages/`: every page of this site as Markdown, with the examples written out in full. - `errors/`: a page per error and warning code, the same text as the [code pages](/reference/codes.md). - `api/`: the TypeScript API reference (npm package only; Rust's is on docs.rs). - `renames.json`: what each renamed item is called now, so an agent holding an old name finds the new one. - `AGENTS.md`: the snippet above. ## The same docs online This site serves the same Markdown for the latest version. Each page has a twin at its address with `.md` in place of the final slash, such as [`/install.md`](https://docs.laminapdf.com/install.md), and [`/llms.txt`](https://docs.laminapdf.com/llms.txt) lists them all. [`/llms-full.txt`](https://docs.laminapdf.com/llms-full.txt) puts every page in one file for agents that read a whole site at once. Prefer the package copy while you work in a project, because it matches the code the agent calls. --- # Browser and bundlers Load @laminapdf/sdk in a web page through Vite or another bundler. In a browser, `@laminapdf/sdk` finds its `.wasm` files with `new URL("…", import.meta.url)`. Your bundler emits them as assets next to your code, so they load from your own origin. This app builds one A4 page that says "Hello, world!" and offers it to your user as `hello.pdf`: `packages/sdk/examples/quick-start/browser.ts`: ```ts // Builds a one-page PDF that says "Hello, world!" in the browser and offers it as a download. import { Builder, type BuilderOptions, Font, init } from "@laminapdf/sdk"; /** The space between the page's edge and the text, in points (72 points = 1 inch). */ const MARGIN = 72; /** The text's size in points; its baseline sits this far below the margin. */ const FONT_SIZE = 24; /** Offers the PDF to your user as hello.pdf. */ function download(pdf: Uint8Array): void { // `slice()` copies the bytes into a plain ArrayBuffer, which is what a Blob accepts. const url = URL.createObjectURL(new Blob([pdf.slice()], { type: "application/pdf" })); const link = document.createElement("a"); link.href = url; link.download = "hello.pdf"; link.click(); // Revoke the URL after the click has been handled; revoking it at once can cancel the download. setTimeout(() => URL.revokeObjectURL(url), 0); } /** Builds the page and hands its bytes to `save`, which offers a download unless you pass another `save` function. */ export async function hello( save: (pdf: Uint8Array) => void | Promise = download, options: BuilderOptions = {}, ): Promise { await init({ text: true }); using builder = new Builder(options); const page = builder.page("A4"); // Points from the page's top-left corner, y down; the text's baseline sits FONT_SIZE points below the margin. page.surface().text("Hello, world!", { font: Font.standard("Helvetica"), size: FONT_SIZE, at: [MARGIN, MARGIN + FONT_SIZE], }); page.finish(); using doc = builder.finish(); await save(doc.save()); } ``` Import `hello` from your page's script and call it when your user asks for the file, for example from a button: `button.addEventListener("click", () => hello())`. Then start the page with your bundler, such as `npx vite`. Lamina PDF's tests bundle this app with Vite, run it in Chromium, Firefox and WebKit, and check that it builds the reference PDF byte for byte. - **Modules on demand:** a page that never draws text never downloads the text module; see [three modules in JavaScript](/concepts/how-lamina-thinks.md#three-modules-in-javascript). - **Vite:** exclude the package from dependency pre-bundling (`optimizeDeps.exclude: ["@laminapdf/sdk"]` in `vite.config.ts`), so the `.wasm` files stay next to the JavaScript that loads them. - **Content Security Policy:** compiling WebAssembly needs `script-src 'wasm-unsafe-eval'`. Nothing else is needed. Next: draw text with [fonts](/guides/fonts-and-text.md), or [open and edit](/guides/edit-a-pdf.md) a file. --- # Node Generate a PDF from Node with @laminapdf/sdk. In Node, `@laminapdf/sdk` reads its `.wasm` files from the `@laminapdf/wasm` package as bytes. Call `await init({ text: true })` once; after that, every call is synchronous. This app builds one A4 page that says "Hello, world!" and writes it to `hello.pdf`: `packages/sdk/examples/quick-start/node.ts`: ```ts // Builds a one-page PDF that says "Hello, world!" and writes it to hello.pdf, in Node. import { writeFile } from "node:fs/promises"; import { Builder, type BuilderOptions, Font, init } from "@laminapdf/sdk"; /** The space between the page's edge and the text, in points (72 points = 1 inch). */ const MARGIN = 72; /** The text's size in points; its baseline sits this far below the margin. */ const FONT_SIZE = 24; /** Builds the page and hands its bytes to `save`, which writes hello.pdf unless you pass another `save` function. */ export async function hello( save: (pdf: Uint8Array) => void | Promise = (pdf) => writeFile("hello.pdf", pdf), options: BuilderOptions = {}, ): Promise { await init({ text: true }); using builder = new Builder(options); const page = builder.page("A4"); // Points from the page's top-left corner, y down; the text's baseline sits FONT_SIZE points below the margin. page.surface().text("Hello, world!", { font: Font.standard("Helvetica"), size: FONT_SIZE, at: [MARGIN, MARGIN + FONT_SIZE], }); page.finish(); using doc = builder.finish(); await save(doc.save()); } // `node hello.ts` runs it (Node 24.2 or newer); imported, it only exports `hello`. if (import.meta.main) await hello(); ``` Save it as `hello.ts` in a project with `@laminapdf/sdk` [installed](/install.md), and run it with Node 24.2 or newer, which runs TypeScript files as they are: ```sh node hello.ts ``` Node 22 can't parse the `using` that frees the builder and the document, so compile the app with `tsc` first there. Lamina PDF's tests run this app in Node 22 (compiled), 24 and 26 and check that it builds the reference PDF byte for byte. A long-running server should free documents the same way; [how Lamina PDF thinks about a PDF](/concepts/how-lamina-thinks.md#you-free-what-you-create) explains why. Next: draw text with [fonts](/guides/fonts-and-text.md), or [open and edit](/guides/edit-a-pdf.md) a file. --- # Deno Use @laminapdf/sdk in Deno. In Deno, `@laminapdf/sdk` fetches its `.wasm` files from next to its own code, through `file:` URLs. Reading them needs `--allow-read`, and writing `hello.pdf` needs `--allow-write`. Call `await init({ text: true })` once; after that, every call is synchronous. This app builds one A4 page that says "Hello, world!" and writes it to `hello.pdf`: `packages/sdk/examples/quick-start/deno.ts`: ```ts // Builds a one-page PDF that says "Hello, world!" and writes it to hello.pdf, in Deno. import { Builder, type BuilderOptions, Font, init } from "@laminapdf/sdk"; /** The space between the page's edge and the text, in points (72 points = 1 inch). */ const MARGIN = 72; /** The text's size in points; its baseline sits this far below the margin. */ const FONT_SIZE = 24; /** Builds the page and hands its bytes to `save`, which writes hello.pdf unless you pass another `save` function. */ export async function hello( save: (pdf: Uint8Array) => void | Promise = (pdf) => Deno.writeFile("hello.pdf", pdf), options: BuilderOptions = {}, ): Promise { await init({ text: true }); using builder = new Builder(options); const page = builder.page("A4"); // Points from the page's top-left corner, y down; the text's baseline sits FONT_SIZE points below the margin. page.surface().text("Hello, world!", { font: Font.standard("Helvetica"), size: FONT_SIZE, at: [MARGIN, MARGIN + FONT_SIZE], }); page.finish(); using doc = builder.finish(); await save(doc.save()); } // `deno run --allow-read --allow-write hello.ts` runs it; imported, it only exports `hello`. if (import.meta.main) await hello(); ``` Save it as `hello.ts` in a project with `@laminapdf/sdk` [installed](/install.md), and run it: ```sh deno run --allow-read --allow-write hello.ts ``` Lamina PDF's tests run this app in Deno and check that it builds the reference PDF byte for byte. Next: draw text with [fonts](/guides/fonts-and-text.md), or [open and edit](/guides/edit-a-pdf.md) a file. --- # Bun Use @laminapdf/sdk in Bun. Bun loads `@laminapdf/sdk` the way Node does: it reads the `.wasm` files from the `@laminapdf/wasm` package. Call `await init({ text: true })` once; after that, every call is synchronous. This app builds one A4 page that says "Hello, world!" and writes it to `hello.pdf` with `Bun.write`: `packages/sdk/examples/quick-start/bun.ts`: ```ts // Builds a one-page PDF that says "Hello, world!" and writes it to hello.pdf, in Bun. import { Builder, type BuilderOptions, Font, init } from "@laminapdf/sdk"; /** The space between the page's edge and the text, in points (72 points = 1 inch). */ const MARGIN = 72; /** The text's size in points; its baseline sits this far below the margin. */ const FONT_SIZE = 24; /** Builds the page and hands its bytes to `save`, which writes hello.pdf unless you pass another `save` function. */ export async function hello( save: (pdf: Uint8Array) => void | Promise = async (pdf) => { await Bun.write("hello.pdf", pdf); }, options: BuilderOptions = {}, ): Promise { await init({ text: true }); using builder = new Builder(options); const page = builder.page("A4"); // Points from the page's top-left corner, y down; the text's baseline sits FONT_SIZE points below the margin. page.surface().text("Hello, world!", { font: Font.standard("Helvetica"), size: FONT_SIZE, at: [MARGIN, MARGIN + FONT_SIZE], }); page.finish(); using doc = builder.finish(); await save(doc.save()); } // `bun hello.ts` runs it; imported, it only exports `hello`. if (import.meta.main) await hello(); ``` Save it as `hello.ts` in a project with `@laminapdf/sdk` [installed](/install.md), and run it: ```sh bun hello.ts ``` Lamina PDF's tests run this app in Bun and check that it builds the reference PDF byte for byte. Next: draw text with [fonts](/guides/fonts-and-text.md), or [open and edit](/guides/edit-a-pdf.md) a file. --- # Cloudflare Workers Generate PDFs per request in a Cloudflare Worker. Cloudflare Workers can't compile WebAssembly at run time. So Wrangler bundles the package's entry for Cloudflare Workers, which imports both `.wasm` files as `WebAssembly.Module`s. `init` instantiates them once per isolate. This Cloudflare Worker returns a one-page letter at `/?name=…` in a standard font, and the same letter at `/embedded?name=…` in a font bundled with the Cloudflare Worker. Lamina PDF's tests run it in workerd, Cloudflare's runtime: `packages/sdk/examples/worker/src/index.ts`: ```ts // A Worker that generates a one-page letter per request: at `/?name=` in a standard font, which // needs no font file, and at `/embedded?name=` in a font bundled with the Worker, which the PDF // embeds. A name the font can't draw gets a 400 with the error as JSON. The bundled font is a file // in `src/fonts/` that Wrangler bundles as bytes (wrangler.toml's `rules`); put the fonts your // documents need there. import { Builder, Font, init, isLaminaPdfError, type LaminaPdfError } from "@laminapdf/sdk"; import notoSans from "./fonts/NotoSans-Regular.ttf"; /** The longest `?name=` the letter takes. */ const MAX_NAME = 100; /** The letter's font for each path: Helvetica, a standard 14 font, or the bundled Noto Sans. */ const FONTS = new Map([ ["/", Font.standard("Helvetica")], ["/embedded", Font.fromBytes(notoSans)], ]); /** A one-page letter to `name` in `font`. */ function letter(name: string, font: Font): Uint8Array { const b = new Builder({ producer: "lamina Worker example" }); try { const page = b.page("A4"); page.surface().text(`Hello, ${name}!`, { font, size: 24, at: [72, 96] }); page.finish(); return saveAndFree(b); } finally { // A no-op after finish(); when drawing threw, it frees the builder's WebAssembly side now // rather than whenever the garbage collector gets to it (an isolate serves many requests). b.free(); } } /** Finishes `b`, saves the document and frees it (the bytes are a copy in JS memory). */ function saveAndFree(b: Builder): Uint8Array { const doc = b.finish(); try { return doc.save(); } finally { doc.free(); } } const pdf = (bytes: Uint8Array): Response => new Response(bytes, { headers: { "content-type": "application/pdf" } }); /** * Whether `e` says the font can't draw the name: the caller's input, not a server fault. Other * errors, such as `invalid-font` for a broken font file, stay a 500. */ function isNameError(e: unknown): e is LaminaPdfError { return isLaminaPdfError(e, "missing-glyph") || isLaminaPdfError(e, "encoding-full"); } /** A 400 with the error's ADR 0026 object as JSON. */ const badRequest = (body: unknown): Response => Response.json(body, { status: 400 }); // A default export because that is how a Worker module exposes its handlers (the named-exports // rule is for package code). export default { async fetch(request: Request): Promise { // Loads both modules once per isolate; later requests return at once. await init({ text: true }); const url = new URL(request.url); const font = FONTS.get(url.pathname); if (font === undefined) return new Response("not found", { status: 404 }); const name = url.searchParams.get("name") ?? "world"; if (name.length > MAX_NAME) return badRequest({ message: `name is longer than ${MAX_NAME} characters` }); try { return pdf(letter(name, font)); } catch (e) { // A script the font lacks (`missing-glyph`: Chinese in either font) or, in Helvetica, more // distinct non-WinAnsi Latin letters than its 37 spare codes (`encoding-full`). if (isNameError(e)) return badRequest(e.toJSON()); throw e; } }, }; ``` Its Wrangler configuration bundles font files as `Data` modules, so importing a font gives its bytes: `packages/sdk/examples/worker/wrangler.toml`: ```toml # A Cloudflare Worker that generates a PDF per request with @laminapdf/sdk. Run `npx wrangler dev` # in this folder. Wrangler bundles the package's `workerd` export condition, which imports both # `.wasm` files as `WebAssembly.Module`s (Workers can't compile WebAssembly at run time). name = "lamina-pdf-example" main = "src/index.ts" compatibility_date = "2026-10-01" # Font files are bundled with the Worker as ArrayBuffers. [[rules]] type = "Data" globs = ["**/*.ttf", "**/*.otf"] fallthrough = true ``` TypeScript learns what a font import is from a declaration file next to the code: `packages/sdk/examples/worker/src/data.d.ts`: ```ts // Font files import as their bytes: Wrangler bundles them as `Data` modules (wrangler.toml's // `rules`), and this file tells TypeScript what such an import is. declare module "*.ttf" { const data: ArrayBuffer; export default data; } declare module "*.otf" { const data: ArrayBuffer; export default data; } ``` To run it, install `@laminapdf/sdk` and `wrangler` in the Cloudflare Worker's folder (`npm install @laminapdf/sdk@alpha wrangler`), put a copy of [Noto Sans](https://fonts.google.com/noto/specimen/Noto+Sans) at `src/fonts/NotoSans-Regular.ttf`, and run `npx wrangler dev`. - **Errors the caller caused** become a `400` with the error's JSON: a name the font can't draw (`missing-glyph`, `encoding-full`). Anything else, such as a broken font file, stays a `500`. See [Errors and warnings](/concepts/errors-and-warnings.md). - **Memory:** the letter frees its builder and document as soon as the bytes are saved, because one isolate serves many requests. Next: the [Cloudflare Worker recipe](/recipes/cloudflare-worker.md) says what to change in it, and [Troubleshooting](/troubleshooting.md) covers a Cloudflare Worker that can't load the WebAssembly. --- # Rust Generate a PDF with the Lamina PDF crate. The `laminapdf` crate is the one to depend on: it wraps Lamina PDF's core crates in one API. Add it with `cargo add laminapdf@0.2.0-alpha`, then build a document. [Install](/install.md#rust-laminapdf) explains the version. This is the `hello` example, which is tested on every change: `crates/laminapdf/examples/hello.rs`: ````rust //! The smallest document: one A4 page with a line of text in a standard 14 font. //! //! ```sh //! cargo run -p laminapdf --example hello -- [out-dir] # writes /hello.pdf (default target/examples-out) //! ``` use std::error::Error; use std::path::PathBuf; use laminapdf::{BuildOptions, Builder, PageSize, PointPt, Pt, SaveOptions, Standard14}; fn main() -> Result<(), Box> { 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(BuildOptions::new())?)?; Ok(()) } /// The document's bytes. `options` can fix the file ID, the producer and the creation date, so /// that the same input gives the same bytes on every run. pub(crate) fn build(options: BuildOptions) -> Result, Box> { 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. 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())?.bytes) } ```` To run it, save it as `src/main.rs` in a project that depends on `laminapdf`, then run `cargo run`. It writes `target/examples-out/hello.pdf`, or `hello.pdf` in the folder you pass: `cargo run -- out`. - **Same input, same bytes:** `build` takes `BuildOptions`: fix the file ID, the producer and the creation date in them, and the output never changes; see [how Lamina PDF thinks about a PDF](/concepts/how-lamina-thinks.md#same-input-same-bytes). - **Stopping a save:** `save` runs to the end. To let another thread stop it, pass a `CancelToken` to `save_with`, or call `save_job` for a job that you step through with a work budget. - The [Rust API reference](/reference/rust.md) documents every public item. --- # How Lamina PDF thinks about a PDF Bytes in and out, the same bytes every time, edits kept apart from the opened file, two ways to save, memory you free, three JavaScript modules. The rest of the docs build on six ideas. The first two follow from one choice: Lamina PDF treats a PDF as bytes, which you give it and it gives back. ## Bytes in, bytes out Lamina PDF's core never touches the file system or the network, and it never reads the clock. Your app reads the file, hands Lamina PDF the bytes and stores whatever comes back. The only fetches are the ones you ask for, such as opening a remote file by range requests. That's why the same code runs in a browser, on a server and inside a Cloudflare Worker. ## Same input, same bytes Because Lamina PDF reads no clock, you pass in dates and file IDs yourself. It then writes the same bytes for the same input, on every run and every platform, so you can test, cache and compare its output byte for byte. ## The file you opened never changes When you open a document, Lamina PDF keeps its bytes exactly as they were. Every edit goes into an [overlay](/reference/glossary.md#overlay) on top of them. Reading the document reads the overlay first, so you see your edits; undo and redo move through them. ## Two ways to save A save writes the overlay out in one of two ways: - **[Incremental save](/reference/glossary.md#incremental-save):** the original bytes, unchanged, followed by a new revision that holds your edits. Signatures over the original stay valid. - **[Full rewrite](/reference/glossary.md#full-rewrite):** the whole file written again, smaller and tidier, but no longer the original bytes. The default, `auto`, saves incrementally when it can. A signed file saves only incrementally, and a save fails if your edits break its signatures, unless you pass `invalidateSignatures: true`. [Save a PDF](/guides/save-a-pdf.md) has the details. This example builds a document, opens its bytes, changes the title and saves incrementally. The saved file starts with the original bytes: `packages/sdk/examples/generate-then-edit.ts`: ```ts // Generate a document, then open its bytes, edit them and save incrementally. The edit changes // the Info dictionary in one transaction, and the incremental save keeps the generated bytes as // they are, with one revision appended. import { Builder, type BuilderOptions, Document, Font } from "@laminapdf/sdk"; /** * The generated file followed by the edit's revision. Needs `init({ text: true })`. * * @param options - can fix the file ID, the producer and the creation date. * @param editedAt - the modification date to record, as a PDF date string. */ export function generateThenEdit(options: BuilderOptions = {}, editedAt?: string): Uint8Array { return edit(generate(options), editedAt); } /** A one-page draft, saved (a new document is always saved as a full rewrite). */ export function generate(options: BuilderOptions = {}): Uint8Array { using b = new Builder(options); 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" }); using doc = b.finish(); return doc.save(); } /** Opens `bytes`, edits the Info dictionary in one transaction (which `undo` could revert) and saves incrementally. */ function edit(bytes: Uint8Array, editedAt?: string): Uint8Array { using doc = Document.open(bytes); doc.transact((tx) => tx.setInfo({ title: "Final report", author: "Ada Lovelace" }, editedAt)); return doc.save({ mode: "incremental" }); } ``` ## You free what you create In JavaScript, documents and builders live in WebAssembly memory. The garbage collector frees them eventually, but late, so free each one when you're done: `using doc = …` frees it at the end of the block, and `doc.free()` frees it at once. A short script can skip this, but a long-running server shouldn't. ## Three modules in JavaScript In JavaScript, Lamina PDF is WebAssembly, split into three modules so a page downloads only what it uses: - **Base:** open, read, edit and save; vector and image pages. - **Extract:** everything in base, plus text extraction. - **Text:** everything in base, plus fonts, shaped text and extraction. `init()` loads the base module, and `init({ text: true })` or `init({ extract: true })` loads one of the others. Call `loadText()` or `loadExtract()` to add a module later, when a page first needs it. Rust has one crate, with features instead. --- # Pages and coordinates Points from the top-left corner of the page as it's displayed, y going down, and pages numbered from 0. Every position you pass Lamina PDF is in [page space](/reference/glossary.md#page-space): points measured from the top-left corner of the page, with y going down. It's the way a screen or a sheet of paper reads, so a number means the same thing in every part of the API. ## Points A point is 1/72 of an inch. An A4 page is about 595 × 842 points, and a US Letter page is 612 × 792. Some pages set a [user unit](/reference/glossary.md#user-unit), which scales the file's own units; Lamina PDF applies it, so a point is always 1/72 inch. ## The top-left corner of the page you see The origin is the top-left corner of the page as a viewer displays it: its visible area (its [crop box](/reference/glossary.md#crop-box)), turned the way the page is rotated. So `[72, 96]` is one inch from the left edge and 96 points from the top, whichever way the page is rotated in the file. For text, the second number is the baseline, the line the letters sit on. Letters reach up from it, and descenders such as "g" and "p" hang below it. This app places "Hello, world!" one inch from the top-left corner, with its baseline one text size lower: `packages/sdk/examples/quick-start/node.ts`: ```ts // Builds a one-page PDF that says "Hello, world!" and writes it to hello.pdf, in Node. import { writeFile } from "node:fs/promises"; import { Builder, type BuilderOptions, Font, init } from "@laminapdf/sdk"; /** The space between the page's edge and the text, in points (72 points = 1 inch). */ const MARGIN = 72; /** The text's size in points; its baseline sits this far below the margin. */ const FONT_SIZE = 24; /** Builds the page and hands its bytes to `save`, which writes hello.pdf unless you pass another `save` function. */ export async function hello( save: (pdf: Uint8Array) => void | Promise = (pdf) => writeFile("hello.pdf", pdf), options: BuilderOptions = {}, ): Promise { await init({ text: true }); using builder = new Builder(options); const page = builder.page("A4"); // Points from the page's top-left corner, y down; the text's baseline sits FONT_SIZE points below the margin. page.surface().text("Hello, world!", { font: Font.standard("Helvetica"), size: FONT_SIZE, at: [MARGIN, MARGIN + FONT_SIZE], }); page.finish(); using doc = builder.finish(); await save(doc.save()); } // `node hello.ts` runs it (Node 24.2 or newer); imported, it only exports `hello`. if (import.meta.main) await hello(); ``` ## Pages count from 0 The first page is page 0 everywhere: `doc.page(0)`, a link's target, a page label's start. ## PDF's own coordinates Inside the file, PDF measures from the bottom-left corner with y going up, before rotation. That's [user space](/reference/glossary.md#user-space). Lamina PDF converts for you, so you only meet it in Rust, in names that contain `UserSpace` or `user_space`. --- # Errors and warnings One coded error shape on every surface, and warnings for what Lamina PDF repaired. Real PDFs are often damaged, so Lamina PDF separates a call that failed from one that worked around a problem. An error stops the call. A warning lets it finish and tells you what was repaired, dropped or approximated. Both carry a stable code your program can branch on. ## Errors stop the call In TypeScript an error is a `LaminaPdfError`, and in Rust it's a `laminapdf::Error`. Branch on its `code`, never on its `message`: `isLaminaPdfError(e, "wrong-password")` checks the code and narrows the type in one step. ## Warnings say what was repaired A warning means the call went ahead, but Lamina PDF had to repair, drop or approximate something in the file. Read them with `doc.warnings` in TypeScript or `doc.warnings()` in Rust. Show them to the user when the difference matters, such as a page that may not look the way its author intended. ## Every surface has the same fields Errors and warnings have the same fields in TypeScript and Rust: `code`, `kind`, `message`, `hint`, `location`, `data` and `docsUrl`. `toJSON()` in TypeScript gives the same object as `serde` does in Rust, so a server and a browser can pass them to each other unchanged. - **`kind` sorts errors by who can fix them**, such as `input`, `limit`, `usage`, `state` or `io`. For example, a server returns a 4xx status for `input` and `limit` and a 5xx for the rest, as the [Cloudflare Workers quick start](/quick-start/workers.md) does. - **Text from the document is untrusted**, so it never appears in `message` or `hint`. It goes only in `data`, where you can escape it before you show it. This example opens someone else's file and returns the error's code and hint as a value, instead of letting the error escape: `packages/sdk/examples/open-untrusted.ts`: ```ts // Open bytes from someone else: an optional password, tighter limits than the defaults, and the // outcome as a value rather than an exception. The example writes nothing: it returns what it found. import { Document, isLaminaPdfError } from "@laminapdf/sdk"; const MiB = 1024 * 1024; /** Limits for an upload endpoint: each decoded stream, and all of them together. */ const LIMITS = { maxStreamDecoded: 16 * MiB, maxDecodedTotal: 64 * MiB } as const; /** 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` if the file needs one, under {@link LIMITS}. Needs `init()`. */ export function openUntrusted(bytes: Uint8Array, password?: string): Opened { try { using doc = Document.open(bytes, { ...(password === undefined ? {} : { password }), limits: LIMITS }); // 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) }; } catch (e) { // `password-required`, `wrong-password`, `not-pdf`, `limit-exceeded`, …: a coded error whose // message and hint never contain text from the file. Anything else is a bug: let it throw. if (isLaminaPdfError(e)) return { ok: false, code: e.code, hint: e.hint }; throw e; } } ``` The [error and warning codes](/reference/codes.md) list every code with its kind, a summary and a hint. Next: make a PDF with [text and fonts](/guides/fonts-and-text.md). --- # What Lamina PDF writes and what it borrows Lamina PDF writes the parts that decide a PDF's bytes, borrows the rest from permissive projects, and pins and checks everything it borrows. Add `@laminapdf/viewer` to your app, and it downloads about 2 MB of WebAssembly from a project called EmbedPDF. That WebAssembly is PDFium, the C++ engine behind Chrome's PDF viewer. You may wonder why a Rust library ships someone else's C++, and what else comes along with it. Lamina PDF follows one rule: it writes the parts that decide what your PDF contains. It borrows the rest only from permissively licensed projects that do the job better, and it pins and checks every borrowed piece. ## Lamina PDF writes the parts that decide your bytes Lamina PDF's own Rust code reads, edits and saves your PDFs. Those parts take files from strangers, and they decide every byte of the file you save. A bug there corrupts documents or breaks signatures, so Lamina PDF owns them. Rust also keeps that code memory-safe while it reads damaged or hostile files. And because Lamina PDF wrote it, the same input gives the same bytes in a browser, on a server and inside a Cloudflare Worker. ## It borrows the work others already do well Fonts and text shaping are deep problems with good open-source answers, so Lamina PDF reuses them. `skrifa` reads font files, `subsetter` cuts them down to the glyphs you use, and HarfRust shapes Arabic, Hindi and other scripts. ICU4X finds line breaks, and `pdf-writer` writes PDF syntax. Each one is permissively licensed Rust that compiles to WebAssembly. Lamina PDF measured krilla, a complete PDF generator built on the same crates. But krilla can't add content to a file you opened, so Lamina PDF builds on the crates beneath it. ## Drawing pages is the one job it hands to PDFium Lamina PDF hands one big job to another project: turning pages into pixels. Real-world PDFs often break the specification, and PDFium draws them for everyone who opens a PDF in Chrome. Lamina PDF measured the alternative, hayro, a renderer written in Rust. It drew 91% of 2,196 test pages the same as PDFium or close to it, and most of the rest went wrong on fonts and annotations. A viewer that becomes an editor needs that fidelity from the start, so PDFium draws the pages. `@embedpdf/pdfium` is PDFium compiled to WebAssembly. ## PDFium only draws, and only in a Web Worker PDFium is C++ reading files you didn't write, so Lamina PDF keeps it at arm's length. The viewer runs it inside a Web Worker, away from your page. PDFium never changes the document either: every edit goes through Lamina PDF, which also writes the saved file. Text, selection and links come from Lamina PDF's own reading of the file, never from the renderer. Lamina PDF also checks that the two agree: its tests compare each page's size and every character's position with PDFium's. ## You pay for PDFium only when you show pages `@laminapdf/sdk`, the package that creates, reads and edits PDFs, depends only on `@laminapdf/wasm`, Lamina PDF's own WebAssembly. PDFium comes only with `@laminapdf/render-pdfium` and the viewer built on it. For pages as pixels on a server or in a Cloudflare Worker, `@laminapdf/render-hayro` is the lighter choice: pure Rust, with no C++, and a smaller download than PDFium. Every night, Lamina PDF's tests draw thousands of pages with both renderers, so hayro can take over once it matches PDFium. ## Every dependency is permissively licensed Lamina PDF is `MIT OR Apache-2.0`, so you can use it in a closed product or a paid service. That promise only holds if every dependency allows the same. So Lamina PDF accepts no GPL, AGPL or LGPL code in anything it ships, and a licence check enforces the rule on every change. MuPDF is a fine PDF engine, but it is AGPL, so Lamina PDF never uses it, in any form. Copyleft tools such as veraPDF run as separate programs in Lamina PDF's tests and are never shipped. ## Everything borrowed is pinned and checked A dependency can change under you, so Lamina PDF's lockfiles pin every one to an exact version. For PDFium, Lamina PDF goes further: the build checks the SHA-256 of `pdfium.wasm`, and any other file fails the build. `@embedpdf/pdfium` ships no notices for the libraries compiled into it, such as libpng, zlib and FreeType. So `@laminapdf/render-pdfium` includes a third-party notices file, and an app that bundles it ships that file too. An update to PDFium arrives as its own reviewed change, and the next release picks up its security fixes. ## What this means for your app You can build Lamina PDF into closed software, and `@laminapdf/sdk` brings no third-party engine with it. Add the viewer when you need to show pages, and ship PDFium's notices file with it. Next, see [how Lamina PDF thinks about a PDF](/concepts/how-lamina-thinks.md), or start with [your first PDF](/start/first-pdf.md). --- # Text and fonts Draw text in any script with your own fonts or the standard 14, one line at a time or as a paragraph that wraps in a box. You'll draw text on a page: single lines, and paragraphs that wrap to fit a box, in any script and direction. You need this whenever a PDF holds words, from a one-line label to a report in five languages. Text needs the text module: `init({ text: true })` or `loadText()` in TypeScript, and the default `text` feature in Rust. ## Pick a font Lamina PDF draws with two kinds of font: - **The [standard 14 fonts](/reference/glossary.md#standard-14-fonts)** (`Font.standard("Helvetica")`, `Standard14::Helvetica` in Rust) need no font file and aren't embedded, because every PDF reader has them. They cover Latin text only, without shaping. - **Your own fonts** (`Font.fromBytes(bytes)`: TrueType, OpenType or a font collection) handle any script. Lamina PDF shapes their text, so letters join and ligatures form, and embeds only the glyphs you draw. A font is embedded once per document, however many `Font` objects name it. ## Draw a line or a paragraph `surface.text(str, { font, size, at })` draws one line, with `at[1]` its baseline. `surface.textBox(str, { font, size, box, direction, align })` wraps a paragraph to fit its box and lays out each line's words in display order, so right-to-left text reads correctly. When the text doesn't fit, it reports `overflow`. This example draws five scripts (Latin with ligatures, Arabic, Devanagari, Hebrew and Chinese), each in its own embedded font: **TypeScript** `packages/sdk/examples/multilingual.ts`: ```ts // Five scripts on one page, each as a single line and as a wrapped paragraph. The scripts are // Latin, Arabic and Hebrew (right to left), Devanagari and simplified Chinese; each is shaped and // bidi-resolved, and each paragraph wraps in a text box. Each script needs a font that covers it, // such as the Noto fonts (OFL). import { Builder, type BuilderOptions, type Direction, Font } from "@laminapdf/sdk"; /** Each script's line, its paragraph and the paragraph's direction, in the order of the fonts. */ 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"], ]; const MARGIN = 56; const FIRST_BASELINE = 80; /** The distance from one script's line to the next one's. */ const SCRIPT_HEIGHT = 140; /** The paragraph's box: this far below the line, this wide and this tall. */ const PARAGRAPH_DROP = 14; const PARAGRAPH_WIDTH = 220; const PARAGRAPH_HEIGHT = 100; /** * The document's bytes. Needs `init({ text: true })`. * * @param fonts - one font file per script, in the order of {@link SCRIPTS}. * @param options - can fix the file ID, the producer and the creation date. */ export function multilingual(fonts: readonly Uint8Array[], options: BuilderOptions = {}): Uint8Array { if (fonts.length !== SCRIPTS.length) throw new Error(`${SCRIPTS.length} fonts, one per script, are needed`); using b = new Builder(options); 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) return; const y = FIRST_BASELINE + SCRIPT_HEIGHT * row; s.text(line, { font, size: 16, at: [MARGIN, y] }); // A narrow box breaks the paragraph into lines; right-to-left paragraphs start at the right. const box = [MARGIN, y + PARAGRAPH_DROP, PARAGRAPH_WIDTH, PARAGRAPH_HEIGHT] as const; s.textBox(paragraph, { font, size: 12, box, direction, align: "start" }); }); page.finish(); using doc = b.finish(); return doc.save(); } ``` **Rust** `crates/laminapdf/examples/multilingual.rs`: ````rust //! Five scripts on one page, each as a single line and as a wrapped paragraph. //! //! The scripts are Latin, Arabic and Hebrew (right to left), Devanagari and simplified Chinese; //! each is shaped and bidi-resolved, and each paragraph wraps in a text box. Each script needs a //! font that covers it, such as the Noto fonts (OFL). //! //! ```sh //! cargo run -p laminapdf --example multilingual -- latin.ttf arabic.ttf devanagari.ttf hebrew.ttf chinese.otf //! ``` use std::error::Error; use std::path::PathBuf; use laminapdf::{ Alignment, BuildOptions, Builder, Direction, PageSize, PointPt, Pt, RectPt, SaveOptions, TextBoxOptions, }; /// Each script's line, its paragraph and the paragraph's direction, in the order of the fonts. const SCRIPTS: [(&str, &str, Direction); 5] = [ ( "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, ), ( "مرحبا بالعالم السلام عليكم", "مرحبا بالعالم السلام عليكم مرحبا بالعالم السلام عليكم", Direction::RightToLeft, ), ( "नमस्ते दुनिया। हिन्दी पाठ का परीक्षण", "नमस्ते दुनिया। हिन्दी पाठ का परीक्षण नमस्ते दुनिया।", Direction::LeftToRight, ), ( "שלום עולם וברוכים הבאים", "שלום עולם וברוכים הבאים שלום עולם וברוכים הבאים", Direction::RightToLeft, ), ( "你好世界,这是中文字体子集测试。", "你好世界,这是中文字体子集测试。你好世界,这是中文。", Direction::LeftToRight, ), ]; const MARGIN: f64 = 56.0; const FIRST_BASELINE: f64 = 80.0; /// The distance from one script's line to the next one's. const SCRIPT_HEIGHT: f64 = 140.0; /// The paragraph's box: this far below the line, this wide and this tall. const PARAGRAPH_DROP: f64 = 14.0; const PARAGRAPH_WIDTH: f64 = 220.0; const PARAGRAPH_HEIGHT: f64 = 100.0; fn main() -> Result<(), Box> { let mut args: Vec = std::env::args().skip(1).collect(); let out = PathBuf::from(if args.len() > SCRIPTS.len() { args.remove(SCRIPTS.len()) } else { "target/examples-out".into() }); let fonts = args.iter().map(std::fs::read).collect::, _>>()?; std::fs::create_dir_all(&out)?; std::fs::write(out.join("multilingual.pdf"), build(BuildOptions::new(), fonts)?)?; Ok(()) } /// The document's bytes, with one font per script in `fonts` (in the order of [`SCRIPTS`]). /// `options` can fix the file ID, the producer and the creation date. pub(crate) fn build(options: BuildOptions, fonts: Vec>) -> Result, Box> { if fonts.len() != SCRIPTS.len() { return Err(format!("{} fonts, one per script, are needed", SCRIPTS.len()).into()); } let mut b = Builder::new(options); let fonts = fonts .into_iter() .map(|bytes| b.font_bytes(bytes)) .collect::, _>>()?; 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 = FIRST_BASELINE + SCRIPT_HEIGHT * f64::from(row); s.text(line, font, Pt::new(16.0), PointPt::from_points(MARGIN, y))?; // A narrow box breaks the paragraph into lines; right-to-left paragraphs start at the right. let options = TextBoxOptions::new() .with_direction(*direction) .with_align(Alignment::Start); let area = RectPt::from_points(MARGIN, y + PARAGRAPH_DROP, PARAGRAPH_WIDTH, PARAGRAPH_HEIGHT); s.text_box(paragraph, font, Pt::new(12.0), area, &options)?; } page.finish()?; let mut doc = b.finish()?; Ok(doc.save(&SaveOptions::new())?.bytes) } ```` [See the output](/recipes/multilingual.md). Text extracted from it, with pdf.js, pypdf or pdftotext, equals the input strings, ligatures included. ## When a font can't draw the text - **A missing character:** text a font can't encode fails with `missing-glyph`, never with a blank box. - **Too many letters for a standard 14 font:** a standard 14 font holds at most 37 distinct letters beyond its Western European set (WinAnsi); one more fails with `encoding-full`. Use your own font instead. - **A font that may not be embedded:** a font whose licence bits forbid embedding (restricted, or bitmap only) fails with `font-not-embeddable`. In Rust, `EmbedPolicy::Override` embeds it anyway, if you hold the rights. A font marked "no subsetting" is embedded whole. > **Details:** Lamina PDF shapes your own fonts' text with HarfRust, a Rust port of the HarfBuzz shaper, and subsets each font to the glyphs you draw. It embeds them as [Type 0](/reference/glossary.md#type-0) fonts with `/W` widths and a [ToUnicode](/reference/glossary.md#tounicode) [CMap](/reference/glossary.md#cmap), which is what lets the text be extracted again. Line breaking follows [UAX #14](https://www.unicode.org/reports/tr14/), and the order of mixed-direction text follows [UAX #9](https://www.unicode.org/reports/tr9/) (bidi). The licence bits are the font's `fsType`. Next: [shapes, colour and images](/guides/images-and-vectors.md). --- # Shapes, colour and images Draw lines, shapes and images on a page, in any colour, with transparency, clipping and transforms. You'll draw lines, shapes and images on a page, in colour and with transparency. You need this for anything visual in a PDF: a logo, a chart, the rules of a table or a photo. Shapes and images need only the base module (`init()`). ## Draw a shape `surface.path(p => …)` returns the shape unpainted. Paint it with `fill`, `stroke` or `fillStroke`, or call `clip` to limit what comes after to its inside. A value that makes no shape, such as NaN or a negative line width, fails `page.finish()` with `invalid-geometry`. ## Colour it A colour is gray `[g]`, RGB `[r, g, b]`, CMYK `[c, m, y, k]` or hex. Opacity and blend modes set how a drawing mixes with what's already on the page: in the example, `multiply` darkens where a shape overlaps what's below it. ## Place an image - `Image.jpeg(bytes)` embeds a JPEG as it is, without decoding it. - `Image.png(bytes)` keeps the PNG's transparency. - `Image.rgba(width, height, pixels)` takes raw pixels. ## Move, turn and scale A `Matrix` rotates, scales or moves what you draw. It works in [page space](/reference/glossary.md#page-space), like every other position. This example draws rectangles, circles, curves and dashed strokes in several colours, with transparency, a clip and a rotation, and places a JPEG and a transparent image: **TypeScript** `packages/sdk/examples/images-and-vectors.ts`: ```ts // Images and vector graphics on one A4 page. It draws shapes and a dashed Bézier curve in gray, // RGB, CMYK and hex colour, overlaps two translucent discs with a blend mode, clips a JPEG to an // ellipse and rotates an RGBA image with a soft mask. Needs only the base module. import { Builder, type BuilderOptions, Image, Matrix, type Surface } from "@laminapdf/sdk"; /** The RGBA gradient's side, in pixels. */ const GLOW_SIDE = 16; /** * The document's bytes, with `photo` (a JPEG) on the page. * * @param options - can fix the file ID, the producer and the creation date. */ export function imagesAndVectors(photo: Uint8Array, options: BuilderOptions = {}): Uint8Array { using b = new Builder(options); const jpeg = Image.jpeg(photo); const glow = Image.rgba(GLOW_SIDE, GLOW_SIDE, gradient()); const page = b.page("A4"); const s = page.surface(); shapes(s); overlappingDiscs(s); images(s, jpeg, glow); page.finish(); using doc = b.finish(); return doc.save(); } /** A square gradient whose alpha falls off towards the right. */ function gradient(): Uint8Array { const out = new Uint8Array(GLOW_SIDE * GLOW_SIDE * 4); for (let y = 0; y < GLOW_SIDE; y++) { for (let x = 0; x < GLOW_SIDE; x++) out.set([x * 16, y * 16, 200, 255 - x * 15], (y * GLOW_SIDE + x) * 4); } return out; } /** A rectangle, a circle and a dashed, round-capped Bézier curve. */ function shapes(s: Surface): void { 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({ width: 3, cap: "round", dash: [12, 8] }); } /** Two translucent discs, the second multiplied onto the first. */ function overlappingDiscs(s: Surface): void { 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 the gradient rotated by 30 degrees. */ function images(s: Surface, photo: Image, glow: Image): void { s.save() .path((p) => p.ellipse([300, 270, 239, 120])) .clip(); s.image(photo, [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(); } ``` **Rust** `crates/laminapdf/examples/images_and_vectors.rs`: ````rust //! Images and vector graphics on one A4 page. //! //! It draws shapes and a dashed Bézier curve in gray, RGB, CMYK and hex colour, overlaps two //! translucent discs with a blend mode, clips a JPEG to an ellipse and rotates an RGBA image //! with a soft mask. //! //! ```sh //! cargo run -p laminapdf --example images_and_vectors -- photo.jpg [out-dir] # writes images_and_vectors.pdf //! ``` use std::error::Error; use std::path::PathBuf; use laminapdf::{ BlendMode, BuildOptions, Builder, Color, Dash, FillRule, Image, LineCap, Matrix, PageSize, PathBuilder, PointPt, Pt, RectPt, SaveOptions, StrokeStyle, Surface, }; /// The RGBA gradient's side, in pixels. const GLOW_SIDE: u8 = 16; fn main() -> Result<(), Box> { let mut args = std::env::args().skip(1); let photo = std::fs::read(args.next().ok_or("usage: images_and_vectors [out-dir]")?)?; let out = PathBuf::from(args.next().unwrap_or_else(|| "target/examples-out".into())); std::fs::create_dir_all(&out)?; std::fs::write(out.join("images_and_vectors.pdf"), build(BuildOptions::new(), photo)?)?; Ok(()) } /// The document's bytes, with `photo` (a JPEG) on the page. `options` can fix the file ID, the /// producer and the creation date. pub(crate) fn build(options: BuildOptions, photo: Vec) -> Result, Box> { let mut b = Builder::new(options); let photo = Image::jpeg(photo)?; let glow = Image::rgba(u32::from(GLOW_SIDE), u32::from(GLOW_SIDE), &gradient())?; let mut page = b.page(PageSize::A4); let s = page.surface(); shapes(s)?; overlapping_discs(s); images(s, &photo, &glow); page.finish()?; let mut doc = b.finish()?; Ok(doc.save(&SaveOptions::new())?.bytes) } /// A square gradient whose alpha falls off towards the right. fn gradient() -> Vec { (0..GLOW_SIDE) .flat_map(|y| (0..GLOW_SIDE).flat_map(move |x| [x * 16, y * 16, 200, 255 - x * 15])) .collect() } /// A rectangle, a circle and a dashed, round-capped Bézier curve. fn shapes(s: &mut Surface) -> Result<(), Box> { let at = PointPt::from_points; s.fill_color(Color::rgb(0.9, 0.3, 0.2)) .path(|p| _ = p.rect(RectPt::from_points(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); let dashed = StrokeStyle { width: Pt::new(3.0), cap: LineCap::Round, dash: Some(Dash::new(vec![12.0, 8.0], 0.0)), ..StrokeStyle::default() }; 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); Ok(()) } /// Two translucent discs, the second multiplied onto the first. fn overlapping_discs(s: &mut Surface) { let disc = |x: f64| move |p: &mut PathBuilder| _ = p.circle(PointPt::from_points(x, 330.0), Pt::new(60.0)); s.save().opacity(0.6, 1.0).fill_color(Color::gray(0.3)); s.path(disc(120.0)).fill(FillRule::NonZero); s.blend_mode(BlendMode::Multiply).fill_color(Color::rgb(1.0, 0.8, 0.0)); s.path(disc(190.0)).fill(FillRule::NonZero).restore(); } /// The photo clipped to an ellipse, and the gradient rotated by 30 degrees. fn images(s: &mut Surface, photo: &Image, glow: &Image) { s.save() .path(|p| _ = p.ellipse(RectPt::from_points(300.0, 270.0, 239.0, 120.0))) .clip(FillRule::NonZero); s.image(photo, RectPt::from_points(300.0, 250.0, 239.0, 160.0)) .restore(); let placement = Matrix::translate(150.0, 520.0).after(&Matrix::rotate(30.0)); s.save().transform(placement); s.image(glow, RectPt::from_points(-60.0, -60.0, 120.0, 120.0)).restore(); } ```` [See the output](/recipes/images-and-vectors.md). > **Details:** A PNG's alpha channel, and an RGBA image's, becomes a soft mask: a second image that holds the transparency. Next: [links, outlines and metadata](/guides/links-outlines-and-metadata.md). --- # Links, outlines and metadata Add links, named destinations, an outline, page labels, viewer preferences and metadata to a document. You'll make a document readers can find their way around: links between pages and to the web, an outline (the bookmarks panel), page numbers that read i, ii, 1, 2, and a title. You need this for any document longer than a page or two. ## Link to a page or the web `surface.link(rect, target, contents)` makes a clickable area. The target is a web address (`{ type: "uri" }`), a page (`{ type: "page", index }`) or a named destination (`{ type: "named", name }`). A text run takes a `link` option too. The builder checks every target when it finishes: a missing page fails with `page-index-out-of-range`, and a missing name with `unknown-destination`. `builder.destination(name, index)` names a page, so links can point to it by name. ## Add an outline and page labels - **Outline:** `builder.outline([...])` builds the bookmarks a viewer shows beside the pages, with nested entries, colour, bold, italic and `open` (an entry shown expanded). - **Page labels:** `builder.pageLabels([{ start, style }])` sets the page numbers a viewer shows. The example below uses roman numerals for the front matter, then decimal from page 3. - **Viewer preferences:** for example, `pageMode: "outlines"` opens the file with the outline showing. ## Set the metadata `builder.metadata({ title, author, … })` sets the document's title, author and the rest (see Details). This example builds four pages that link to each other and to the web, with an outline, page labels and the outline shown on open. It draws its labels as text, so it needs the text module. It sets no metadata ([Edit a PDF](/guides/edit-a-pdf.md) does). The example: **TypeScript** `packages/sdk/examples/links-and-outlines.ts`: ```ts // Links and outlines: four A5 pages joined by links, bookmarks and page labels. The title page // links to a website, to a page and through a named destination; every other page has a linked // text run back to it. The outline is nested and shown when the file opens. import { Builder, type BuilderOptions, Font, type LinkTarget, type Surface } from "@laminapdf/sdk"; const TITLES = ["Title page", "Contents", "Chapter 1", "Chapter 2"] as const; const MARGIN = 36; const TITLE_BASELINE = 60; const TITLE_SIZE = 18; const LINK_SIZE = 12; /** The baselines of the title page's three links. */ const LINK_BASELINES = [100, 125, 150] as const; /** A link's clickable box: this wide, this tall, starting this far above the baseline. */ const LINK_WIDTH = 220; const LINK_HEIGHT = 20; const LINK_RISE = 15; /** * The document's bytes. Needs `init({ text: true })`. * * @param options - can fix the file ID, the producer and the creation date. */ export function linksAndOutlines(options: BuilderOptions = {}): Uint8Array { using b = new Builder(options); 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: TITLE_SIZE, at: [MARGIN, TITLE_BASELINE] }); if (index === 0) titlePageLinks(s, font); else { const link = { target: { type: "page", index: 0 }, contents: "Title page" } as const; s.text("Back to the title page", { font, size: LINK_SIZE, at: [MARGIN, 100], fill: [0, 0.2, 0.8], link }); } page.finish(); }); navigation(b); using doc = b.finish(); return doc.save(); } /** The title page's links: to a website, to a page, and to a named destination. */ function titlePageLinks(s: Surface, font: Font): void { const links: readonly (readonly [label: string, target: LinkTarget, contents?: string])[] = [ ["Visit example.com", { type: "uri", uri: "https://example.com/" }, "Example"], ["Go to chapter 1", { type: "page", index: 2 }], ["Go to chapter 2 (named)", { type: "named", name: "chapter-2" }], ]; links.forEach(([label, target, contents], i) => { const baseline = LINK_BASELINES[i] ?? 0; s.text(label, { font, size: LINK_SIZE, at: [MARGIN, baseline] }); s.link([MARGIN, baseline - LINK_RISE, LINK_WIDTH, LINK_HEIGHT], target, contents); }); } /** The outline (bookmarks), the page labels, and the viewer opening with the outline shown. */ function navigation(b: Builder): void { 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" }); } ``` **Rust** `crates/laminapdf/examples/links_and_outlines.rs`: ````rust //! Links and outlines: four A5 pages joined by links, bookmarks and page labels. //! //! The title page has a URI link, a link to a page and one through a named destination; every //! other page has a linked text run back to it. The outline (bookmarks) is nested and open in //! the viewer, and the pages are labelled i, ii, 1, 2. //! //! ```sh //! cargo run -p laminapdf --example links_and_outlines -- [out-dir] # writes /links_and_outlines.pdf //! ``` use std::error::Error; use std::path::PathBuf; use laminapdf::{ BuildOptions, Builder, Color, Fit, FontHandle, LabelStyle, LinkTarget, OutlineEntry, PageLabel, PageMode, PageSize, PointPt, Pt, RectPt, SaveOptions, Standard14, Surface, ViewerPreferences, }; const TITLES: [&str; 4] = ["Title page", "Contents", "Chapter 1", "Chapter 2"]; const MARGIN: f64 = 36.0; const TITLE_AT: PointPt = PointPt::from_points(MARGIN, 60.0); const BACK_AT: PointPt = PointPt::from_points(MARGIN, 100.0); const TITLE_SIZE: Pt = Pt::new(18.0); const LINK_SIZE: Pt = Pt::new(12.0); /// The baselines of the title page's three links. const LINK_BASELINES: [f64; 3] = [100.0, 125.0, 150.0]; /// A link's clickable box: this wide, this tall, starting this far above the baseline. const LINK_WIDTH: f64 = 220.0; const LINK_HEIGHT: f64 = 20.0; const LINK_RISE: f64 = 15.0; fn main() -> Result<(), Box> { 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(BuildOptions::new())?)?; Ok(()) } /// The document's bytes. `options` can fix the file ID, the producer and the creation date. pub(crate) fn build(options: BuildOptions) -> Result, Box> { 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(); s.text(title, &helvetica, TITLE_SIZE, TITLE_AT)?; if index == 0 { title_page_links(s, &helvetica)?; } else { s.text("Back to the title page", &helvetica, LINK_SIZE, BACK_AT)? .fill(Color::rgb(0.0, 0.2, 0.8)) .link(LinkTarget::page(0), Some("Title page".into())); } page.finish()?; } navigation(&mut b)?; let mut doc = b.finish()?; Ok(doc.save(&SaveOptions::new())?.bytes) } /// The title page's links: to a website, to a page, and to a named destination. fn title_page_links(s: &mut Surface, font: &FontHandle) -> Result<(), Box> { let site = LinkTarget::Uri("https://example.com/".into()); let links = [ ("Visit example.com", site, Some("Example")), ("Go to chapter 1", LinkTarget::page(2), None), ("Go to chapter 2 (named)", LinkTarget::Named("chapter-2".into()), None), ]; for ((label, target, contents), baseline) in links.into_iter().zip(LINK_BASELINES) { s.text(label, font, LINK_SIZE, PointPt::from_points(MARGIN, baseline))?; let area = RectPt::from_points(MARGIN, baseline - LINK_RISE, LINK_WIDTH, LINK_HEIGHT); s.link(area, target, contents.map(String::from)); } Ok(()) } /// The outline (bookmarks), the page labels, and the viewer opening with the outline shown. fn navigation(b: &mut Builder) -> Result<(), Box> { 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)); Ok(()) } ```` [See the output](/recipes/links-and-outlines.md). > **Details:** The metadata goes into two places: an XMP packet, the XML that describes the file, and the Info dictionary, the older list of the same facts. Lamina PDF writes both from the same values, so they always agree. Next: [accessible PDFs](/guides/accessible-pdfs.md). --- # Accessible PDFs Build PDF/UA-1 documents that screen readers can follow, with every machine-checkable rule checked when the builder finishes. This guide builds a report that screen-reader users can read, which you need whenever a document has to meet accessibility law or a client's accessibility policy. ## What PDF/UA asks for PDF/UA-1 ("Universal Accessibility") is the standard for accessible PDF. A PDF/UA file is tagged: its structure tree says which content is a heading, a paragraph, a list, a table or a figure, and in what order to read it. Everything else, such as running headers and decorative rules, is marked as an artifact, which assistive technology skips. The file also needs a title, a language, alternate text for every figure, a description for every link and embedded fonts that map each glyph to characters. Turn the profile on with one option: `profiles: ["PDF/UA-1"]` in TypeScript, `BuildOptions::default().with_profile(Profile::PdfUa1)` in Rust. Then `finish()` checks the rules a program can check and fails with a coded error for the first one the document breaks: | Rule | Error | | --- | --- | | The metadata has a title | [`missing-title`](/reference/codes/missing-title.md) | | The document has a language (`lang`) | [`missing-lang`](/reference/codes/missing-lang.md) | | Everything drawn is tagged or an artifact | [`untagged-content`](/reference/codes/untagged-content.md) | | Every figure and formula has alternate text | [`missing-alt-text`](/reference/codes/missing-alt-text.md) | | Headings start at `H1` and skip no level going down | [`heading-level-skipped`](/reference/codes/heading-level-skipped.md) | | One unnumbered `H` per section, never beside `H1`–`H6` | [`unnumbered-heading-misused`](/reference/codes/unnumbered-heading-misused.md) | | Table cells sit in rows, rows in tables, list items in lists | [`invalid-structure-nesting`](/reference/codes/invalid-structure-nesting.md) | | Every link has a `Link` element and a description | [`link-without-structure`](/reference/codes/link-without-structure.md), [`link-without-contents`](/reference/codes/link-without-contents.md) | | Every font is embedded (no standard 14 font) | [`font-not-embedded`](/reference/codes/font-not-embedded.md) | | Every glyph maps to Unicode | [`glyph-without-unicode`](/reference/codes/glyph-without-unicode.md) | | The PDF version is 1.7 or lower | [`profile-version-conflict`](/reference/codes/profile-version-conflict.md) | The builder writes the rest for you. It puts the PDF/UA identifier in the document's XMP metadata, the XML packet in the file that describes it. It also asks viewers to show the title instead of the file name, and orders links by the structure. ## The example The report below draws its running header as an artifact. Then come a heading, a paragraph, a table with header cells, a bar chart as a figure with alternate text, and a link with a description. A small `line` helper gives each line of text its structure element. **TypeScript** `packages/sdk/examples/pdf-ua-report.ts`: ```ts // An accessible report that screen readers can follow, checked against PDF/UA-1 when it is built. import { Builder, Font, type Surface, type Tag, type TagName } from "@laminapdf/sdk"; const MARGIN = 56; const ROW = 18; const COLUMN = 144; const HEADER_BASELINE = 40; const TITLE_BASELINE = 90; const TABLE_TOP = 150; const LINK_BASELINE = 400; const CHART_LEFT = 320; const CHART_BASE = 360; const BAR_WIDTH = 36; const BAR_PITCH = 50; const BODY = 11; /** The font sizes of the title and the headings; other text is `BODY`. */ const SIZES: Partial> = { H1: 22, H2: 15 }; const BLUE = [0.12, 0.47, 0.78] as const; const QUARTERS = [ ["Q1", 120], ["Q2", 135], ["Q3", 160], ["Q4", 150], ] as const; const CHART_ALT = "Bar chart of sales by quarter: 120, 135, 160 and 150 thousand euros."; /** * The report's bytes. Needs `init({ text: true })`. * * @param notoSans - the bytes of the Noto Sans font file. */ export function pdfUaReport(notoSans: Uint8Array): Uint8Array { using b = new Builder({ profiles: ["PDF/UA-1"] }); b.metadata({ title: "Annual sales report 2026" }); b.lang("en-GB"); const font = Font.fromBytes(notoSans); b.font(font); const page = b.page("A4"); draw(page.surface(), font); page.finish(); using doc = b.finish(); return doc.save(); } /** One line of text with its baseline at `y`, as the structure element `tag`, sized by it. */ function line(s: Surface, font: Font, tag: Tag, text: string, x: number, y: number): void { const size = (typeof tag === "string" ? SIZES[tag] : undefined) ?? BODY; s.tag(tag, (s) => s.text(text, { font, size, at: [x, y] })); } function draw(s: Surface, font: Font): void { // The running header is decoration: an artifact, which screen readers skip. s.artifact("pagination", (s) => s.text("Acme Widgets", { font, size: BODY, at: [MARGIN, HEADER_BASELINE] })); line(s, font, "H1", "Annual sales report 2026", MARGIN, TITLE_BASELINE); line(s, font, "P", "Sales grew in three quarters of four.", MARGIN, TITLE_BASELINE + 2 * ROW); table(s, font); chart(s); const link = { target: { type: "uri", uri: "https://example.com/accounts" }, contents: "The full 2026 accounts", } as const; s.tag("P", (s) => s.text("The full accounts", { font, size: BODY, at: [MARGIN, LINK_BASELINE], fill: [...BLUE], link }), ); } /** A heading and a table of the quarters, whose first row holds the columns' header cells. */ function table(s: Surface, font: Font): void { line(s, font, "H2", "Sales by quarter", MARGIN, TABLE_TOP); const header: Tag = { type: "TH", scope: "column" }; s.tag("Table", (s) => { s.tag("TR", (s) => { line(s, font, header, "Quarter", MARGIN, TABLE_TOP + ROW); line(s, font, header, "Sales (k€)", MARGIN + COLUMN, TABLE_TOP + ROW); }); QUARTERS.forEach(([quarter, sales], i) => { const y = TABLE_TOP + ROW * (i + 2); s.tag("TR", (s) => { line(s, font, "TD", quarter, MARGIN, y); line(s, font, "TD", sales.toFixed(0), MARGIN + COLUMN, y); }); }); }); } /** The quarters as bars standing on the chart's base: one figure with alternate text. */ function chart(s: Surface): void { s.tag({ type: "Figure", alt: CHART_ALT }, (s) => { s.fillColor([...BLUE]); QUARTERS.forEach(([, sales], i) => { s.path((p) => p.rect([CHART_LEFT + BAR_PITCH * i, CHART_BASE - sales, BAR_WIDTH, sales])).fill(); }); }); } ``` **Rust** `crates/laminapdf/examples/pdf_ua_report.rs`: ```rust //! An accessible report that screen readers can follow, checked against PDF/UA-1 when it is built. use laminapdf::{ ArtifactKind, BuildError, BuildOptions, Builder, Color, FillRule, FontHandle, LinkTarget, Metadata, PageSize, PointPt, Profile, Pt, RectPt, SaveOptions, Surface, TableScope, Tag, TagKind, }; const MARGIN: f64 = 56.0; const ROW: f64 = 18.0; const COLUMN: f64 = 144.0; const HEADER_AT: PointPt = PointPt::from_points(MARGIN, 40.0); const TITLE_BASELINE: f64 = 90.0; const TABLE_TOP: f64 = 150.0; const LINK_AT: PointPt = PointPt::from_points(MARGIN, 400.0); const CHART_LEFT: f64 = 320.0; const CHART_BASE: f64 = 360.0; const BAR_WIDTH: Pt = Pt::new(36.0); const BAR_PITCH: f64 = 50.0; const BODY: Pt = Pt::new(11.0); /// The font sizes of the title and the headings; other text is `BODY`. const SIZES: [(TagKind, Pt); 2] = [(TagKind::H1, Pt::new(22.0)), (TagKind::H2, Pt::new(15.0))]; const BLUE: Color = Color::Rgb(0.12, 0.47, 0.78); const QUARTERS: [(&str, f64); 4] = [("Q1", 120.0), ("Q2", 135.0), ("Q3", 160.0), ("Q4", 150.0)]; const CHART_ALT: &str = "Bar chart of sales by quarter: 120, 135, 160 and 150 thousand euros."; fn main() -> Result<(), Box> { let mut args = std::env::args().skip(1); let font = std::fs::read(args.next().ok_or("arguments: [out-dir]")?)?; let out = std::path::PathBuf::from(args.next().unwrap_or_else(|| "target/examples-out".into())); std::fs::create_dir_all(&out)?; Ok(std::fs::write(out.join("pdf_ua_report.pdf"), build(font)?)?) } pub(crate) fn build(font: Vec) -> Result, Box> { let mut b = Builder::new(BuildOptions::new().with_profile(Profile::PdfUa1)); b.metadata(Metadata::default().with_title("Annual sales report 2026")); b.lang("en-GB"); let font = b.font_bytes(font)?; let mut page = b.page(PageSize::A4); draw(page.surface(), &font)?; page.finish()?; Ok(b.finish()?.save(&SaveOptions::new())?.bytes) } /// One line of text with its baseline at `y`, as the structure element `tag`, sized by it. fn line(s: &mut Surface, font: &FontHandle, tag: impl Into, text: &str, x: f64, y: f64) -> Result<(), BuildError> { let tag = tag.into(); let size = SIZES.iter().find(|e| e.0 == tag.kind()).map_or(BODY, |e| e.1); s.tag(tag, |s| s.text(text, font, size, PointPt::from_points(x, y)).map(drop)) } fn draw(s: &mut Surface, font: &FontHandle) -> Result<(), BuildError> { // The running header is decoration: an artifact, which screen readers skip. let header = |s: &mut Surface| s.text("Acme Widgets", font, BODY, HEADER_AT).map(drop); s.artifact(ArtifactKind::Pagination, header)?; line(s, font, TagKind::H1, "Annual sales report 2026", MARGIN, TITLE_BASELINE)?; let summary = "Sales grew in three quarters of four."; line(s, font, TagKind::P, summary, MARGIN, TITLE_BASELINE + 2.0 * ROW)?; table(s, font)?; chart(s)?; let link = LinkTarget::Uri("https://example.com/accounts".into()); s.tag(TagKind::P, |s| { let text = s.text("The full accounts", font, BODY, LINK_AT)?; text.fill(BLUE).link(link, Some("The full 2026 accounts".into())); Ok(()) }) } /// A heading and a table of the quarters, whose first row holds the columns' header cells. fn table(s: &mut Surface, font: &FontHandle) -> Result<(), BuildError> { line(s, font, TagKind::H2, "Sales by quarter", MARGIN, TABLE_TOP)?; let header = Tag::new(TagKind::TH).with_scope(TableScope::Column); s.tag(TagKind::Table, |s| { s.tag(TagKind::TR, |s| { line(s, font, header.clone(), "Quarter", MARGIN, TABLE_TOP + ROW)?; line(s, font, header.clone(), "Sales (k€)", MARGIN + COLUMN, TABLE_TOP + ROW) })?; for (row, (quarter, sales)) in (2_u8..).zip(QUARTERS) { let y = TABLE_TOP + ROW * f64::from(row); s.tag(TagKind::TR, |s| { line(s, font, TagKind::TD, quarter, MARGIN, y)?; line(s, font, TagKind::TD, &format!("{sales:.0}"), MARGIN + COLUMN, y) })?; } Ok(()) }) } /// The quarters as bars standing on the chart's base: one figure with alternate text. fn chart(s: &mut Surface) -> Result<(), BuildError> { s.tag(Tag::new(TagKind::Figure).with_alt(CHART_ALT), |s| { s.fill_color(BLUE); for (i, (_, sales)) in (0_u8..).zip(QUARTERS) { let x = CHART_LEFT + BAR_PITCH * f64::from(i); let bar = RectPt::from_xywh(Pt::new(x), Pt::new(CHART_BASE - sales), BAR_WIDTH, Pt::new(sales)); s.path(|p| _ = p.rect(bar)).fill(FillRule::NonZero); } Ok(()) }) } ``` [See the output](/recipes/pdf-ua-report.md). ## What a program can't check About half of PDF/UA's requirements need a person. Lamina PDF can check that a figure has alternate text, but not that the text describes the figure. It can check that headings go down one level at a time. It can't check that the right text is a heading, that the reading order makes sense or that colour isn't the only way the document conveys meaning. Read the document with a screen reader before you publish it. ## How Lamina PDF checks its own output Every example on this site that claims PDF/UA-1 is validated each night with veraPDF, the open-source validator the PDF Association backs. > **Details:** PDF/UA-1 is ISO 14289-1. veraPDF checks it against the Matterhorn Protocol, the PDF Association's list of PDF/UA failure conditions; most of the builder's checks are Matterhorn conditions a program can test; the version check comes from PDF/UA-1's base, PDF 1.7. Next: to make a document both accessible and archival, add a PDF/A profile next to `PDF/UA-1`, as [Archival PDFs](/guides/archival-pdfs.md) shows. --- # Archival PDFs Build PDF/A-2b, PDF/A-2u and PDF/A-3b documents that look the same in years to come, and attach files such as an e-invoice's XML. This guide builds PDF/A documents, which you need when a file must stay readable and look the same for decades: contracts, records kept by law, e-invoices. Lamina PDF writes PDF/A as it generates a document; it doesn't convert existing files to PDF/A. ## What PDF/A asks for PDF/A is the archival subset of PDF. A PDF/A file carries everything it needs to be displayed: its fonts are embedded, its colours are defined, and nothing depends on the viewer, the network or a password. Lamina PDF writes three PDF/A profiles: - **PDF/A-2b** ("basic"): the document looks the same everywhere. - **PDF/A-2u** ("Unicode"): PDF/A-2b, and every glyph maps to Unicode, so the text can be searched and copied. - **PDF/A-3b:** PDF/A-2b that may carry attached files of any kind. Choose one with the `profiles` option (`BuildOptions::with_profile` in Rust). The builder then writes two things the standard asks for. It writes the PDF/A identification in the document's XMP metadata, the XML packet in the file that describes it. That packet always agrees with the Info dictionary, the older list of the same facts (title, author, dates) that PDF readers also show. It also writes an output intent: a declaration of what device colours mean. The intent embeds an ICC profile, the standard file format that describes a colour space. Lamina PDF's output intent is sRGB, so RGB and gray colour are defined. `DeviceCMYK` is CMYK colour given as plain numbers, with no profile to say which inks they mean (`Color::cmyk` in Rust, four numbers in TypeScript), so the sRGB intent leaves it undefined. `finish()` fails with a coded error for each rule the document breaks: | Rule | Error | | --- | --- | | Every font is embedded (no standard 14 font) | [`font-not-embedded`](/reference/codes/font-not-embedded.md) | | No `DeviceCMYK` colour or CMYK image (the output intent is sRGB) | [`device-color-without-output-intent`](/reference/codes/device-color-without-output-intent.md) | | PDF/A-2u: every glyph maps to Unicode | [`glyph-without-unicode`](/reference/codes/glyph-without-unicode.md) | | PDF/A-2b, PDF/A-2u: no attached files | [`embedded-file-forbidden`](/reference/codes/embedded-file-forbidden.md) | | Every attached file has a MIME type | [`embedded-file-without-mime-type`](/reference/codes/embedded-file-without-mime-type.md) | | The PDF version is 1.7 or lower | [`profile-version-conflict`](/reference/codes/profile-version-conflict.md) | For CMYK colour under PDF/A, give the colour an ICC profile instead (`Color::icc` in Rust): an ICC-based colour defines itself. Transparency is allowed in PDF/A-2 and PDF/A-3. ## An archived letter The letter below is written twice, as PDF/A-2b and as PDF/A-2u. It embeds its font, places a photo and draws a translucent band across the top. The PDF/A-2u copy is also PDF/UA-1, the accessibility standard (see [Accessible PDFs](/guides/accessible-pdfs.md)): a document can be both archival and accessible. **TypeScript** `packages/sdk/examples/pdf-a-archive.ts`: ```ts // An archival letter, written as PDF/A-2b and as PDF/A-2u that is also PDF/UA-1. import { Builder, Font, Image, PAGE_SIZES, type Profile, type Surface } from "@laminapdf/sdk"; const MARGIN = 56; const BAND_HEIGHT = 70; const TITLE_BASELINE = 110; /** The top of the letter's text and of the photo beside it, and the height of both. */ const BLOCK_TOP = 130; const BLOCK_HEIGHT = 120; const LETTER_WIDTH = 300; const PHOTO_LEFT = 380; const PHOTO_WIDTH = 160; const FOOTER_BASELINE = 300; const TITLE = 22; const BODY = 11; const BAND_OPACITY = 0.35; const PHOTO_ALT = "A photograph of the sprockets, packed in a box"; const LETTER = "Thank you for your order of 12 sprockets. This letter confirms the delivery date and the price we agreed. " + "Keep it: PDF/A files look the same in any viewer, now and in the future."; /** PDF/A-2b, or PDF/A-2u (whose text maps to Unicode) with PDF/UA-1. */ const PROFILES: Record<"2b" | "2u", Profile[]> = { "2b": ["PDF/A-2b"], "2u": ["PDF/A-2u", "PDF/UA-1"], }; /** * The letter's bytes. Needs `init({ text: true })`. * * @param level - `2b` or `2u`. * @param notoSans - the bytes of the Noto Sans font file. * @param photo - the bytes of a JPEG photo. */ export function pdfAArchive(level: "2b" | "2u", notoSans: Uint8Array, photo: Uint8Array): Uint8Array { using b = new Builder({ profiles: PROFILES[level], tagged: true }); b.metadata({ title: "Order confirmation 42", author: "Acme Widgets Ltd" }); b.lang("en-GB"); const font = Font.fromBytes(notoSans); b.font(font); const page = b.page("A4"); draw(page.surface(), font, Image.jpeg(photo)); page.finish(); using doc = b.finish(); return doc.save(); } function draw(s: Surface, font: Font, photo: Image): void { // A translucent band across the top: decoration, and transparency, which PDF/A-2 allows. const [width] = PAGE_SIZES.A4; s.artifact("layout", (s) => { s.save().opacity(BAND_OPACITY, 1).fillColor([0.12, 0.47, 0.78]); s.path((p) => p.rect([0, 0, width, BAND_HEIGHT])).fill(); s.restore(); }); s.tag("H1", (s) => s.text("Order confirmation 42", { font, size: TITLE, at: [MARGIN, TITLE_BASELINE] })); s.tag("P", (s) => s.textBox(LETTER, { font, size: BODY, box: [MARGIN, BLOCK_TOP, LETTER_WIDTH, BLOCK_HEIGHT] })); s.image(photo, [PHOTO_LEFT, BLOCK_TOP, PHOTO_WIDTH, BLOCK_HEIGHT], PHOTO_ALT); s.tag("P", (s) => s.text("Acme Widgets Ltd, 1 Example Street, London", { font, size: BODY, at: [MARGIN, FOOTER_BASELINE] }), ); } ``` **Rust** `crates/laminapdf/examples/pdf_a_archive.rs`: ```rust //! An archival letter, written as PDF/A-2b and as PDF/A-2u that is also PDF/UA-1. //! //! Run it with `cargo run -p laminapdf --example pdf_a_archive -- [out-dir]`: it writes //! `pdf_a_archive-2b.pdf` and `pdf_a_archive-2u.pdf`. use std::error::Error; use std::path::PathBuf; use laminapdf::{ ArtifactKind, BuildError, BuildOptions, Builder, Color, FillRule, FontHandle, Image, Metadata, PageSize, PointPt, Profile, Pt, RectPt, SaveOptions, Surface, TagKind, TextBoxOptions, }; const MARGIN: f64 = 56.0; const BAND_HEIGHT: f64 = 70.0; const TITLE_AT: PointPt = PointPt::from_points(MARGIN, 110.0); /// The top of the letter's text and of the photo beside it, and the height of both. const BLOCK_TOP: f64 = 130.0; const BLOCK_HEIGHT: f64 = 120.0; const LETTER_WIDTH: f64 = 300.0; const PHOTO_LEFT: f64 = 380.0; const PHOTO_WIDTH: f64 = 160.0; const FOOTER_AT: PointPt = PointPt::from_points(MARGIN, 300.0); const TITLE: Pt = Pt::new(22.0); const BODY: Pt = Pt::new(11.0); const BAND_OPACITY: f64 = 0.35; const PHOTO_ALT: &str = "A photograph of the sprockets, packed in a box"; const LETTER: &str = "Thank you for your order of 12 sprockets. This letter confirms the delivery date and the \ price we agreed. Keep it: PDF/A files look the same in any viewer, now and in the future."; fn main() -> Result<(), Box> { let mut args = std::env::args().skip(1); let usage = "arguments: [out-dir]"; let font = std::fs::read(args.next().ok_or(usage)?)?; let photo = std::fs::read(args.next().ok_or(usage)?)?; let out = PathBuf::from(args.next().unwrap_or_else(|| "target/examples-out".into())); std::fs::create_dir_all(&out)?; let a2b = build(Level::A2b, font.clone(), photo.clone())?; std::fs::write(out.join("pdf_a_archive-2b.pdf"), a2b)?; let a2u = build(Level::A2u, font, photo)?; Ok(std::fs::write(out.join("pdf_a_archive-2u.pdf"), a2u)?) } /// Which archive to write. #[derive(Debug, Clone, Copy)] pub(crate) enum Level { /// PDF/A-2b: the letter looks the same everywhere. A2b, /// PDF/A-2u, whose text maps to Unicode, and PDF/UA-1. A2u, } /// The letter's bytes, in `font` (a TrueType or OpenType file) with `photo` (a JPEG). pub(crate) fn build(level: Level, font: Vec, photo: Vec) -> Result, Box> { let options = match level { Level::A2b => BuildOptions::new().with_profile(Profile::PdfA2b), Level::A2u => BuildOptions::new() .with_profile(Profile::PdfA2u) .with_profile(Profile::PdfUa1), }; let mut b = Builder::new(options.with_tagged(true)); b.metadata( Metadata::default() .with_title("Order confirmation 42") .with_author("Acme Widgets Ltd"), ); b.lang("en-GB"); let font = b.font_bytes(font)?; let photo = Image::jpeg(photo)?; let mut page = b.page(PageSize::A4); draw(page.surface(), &font, &photo)?; page.finish()?; let mut doc = b.finish()?; Ok(doc.save(&SaveOptions::new())?.bytes) } fn draw(s: &mut Surface, font: &FontHandle, photo: &Image) -> Result<(), BuildError> { // A translucent band across the top: decoration, and transparency, which PDF/A-2 allows. let width = PageSize::A4.width.get(); s.artifact(ArtifactKind::Layout, |s| { s.save() .opacity(BAND_OPACITY, 1.0) .fill_color(Color::rgb(0.12, 0.47, 0.78)); s.path(|p| _ = p.rect(RectPt::from_points(0.0, 0.0, width, BAND_HEIGHT))) .fill(FillRule::NonZero); s.restore(); Ok(()) })?; s.tag(TagKind::H1, |s| { s.text("Order confirmation 42", font, TITLE, TITLE_AT).map(drop) })?; let body = RectPt::from_points(MARGIN, BLOCK_TOP, LETTER_WIDTH, BLOCK_HEIGHT); s.tag(TagKind::P, |s| { s.text_box(LETTER, font, BODY, body, &TextBoxOptions::new()).map(drop) })?; let beside = RectPt::from_points(PHOTO_LEFT, BLOCK_TOP, PHOTO_WIDTH, BLOCK_HEIGHT); s.figure(photo, beside, Some(PHOTO_ALT)); let footer = "Acme Widgets Ltd, 1 Example Street, London"; s.tag(TagKind::P, |s| s.text(footer, font, BODY, FOOTER_AT).map(drop)) } ``` [See the output](/recipes/pdf-a-archive.md). ## Attaching files: a hybrid invoice PDF/A-3 lets a document carry other files, provided each one is associated with the document and says how it relates to it: its source, the data it was made from, or another representation of the same content. E-invoicing formats such as Factur-X and ZUGFeRD use this to put the machine-readable invoice (XML) inside the human-readable one (PDF). `attach(name, bytes, mime, { relationship })` adds a file, its MIME type and its relationship, and lists it in the document's attachments. The example uses a made-up XML schema; a real e-invoice follows the EN 16931 standard and adds its own XMP metadata. **TypeScript** `packages/sdk/examples/pdf-a3-invoice-with-xml.ts`: ```ts // A PDF/A-3b invoice that carries the same invoice as XML, attached for software to read. import { Builder, Font, PAGE_SIZES, type Surface } from "@laminapdf/sdk"; const MARGIN = 56; /** The page's right edge, less the margin: where the rule under the items ends. */ const RIGHT = PAGE_SIZES.A4[0] - MARGIN; const AMOUNTS = 460; const ROW = 22; const TITLE_BASELINE = 80; const FIRST_ROW = 130; /** * How far above the next row's baseline the rule sits, and how far below it the total and * the note are. */ const RULE_RISE = 7; const TOTAL_DROP = 18; const NOTE_DROP = 68; /** How far left of the amounts column the total starts, to fit its "Total" label. */ const TOTAL_INDENT = 40; const TITLE = 24; const TOTAL = 14; const BODY = 11; const ITEMS = [ ["Sprockets", 12, 1.25], ["Gadget", 1, 79.9], ["Delivery", 1, 4.5], ] as const; /** The invoice's total. */ function total(): number { return ITEMS.reduce((sum, [, qty, price]) => sum + qty * price, 0); } /** The invoice as XML, in a made-up schema (a real e-invoice follows EN 16931, as Factur-X does). */ function invoiceXml(): string { const lines = ITEMS.map( ([item, qty, price]) => ` \n`, ); return `\n\n${lines.join("")} ${total().toFixed(2)}\n\n`; } /** * The invoice's bytes. Needs `init({ text: true })`. * * @param notoSans - the bytes of the Noto Sans font file. */ export function pdfA3InvoiceWithXml(notoSans: Uint8Array): Uint8Array { using b = new Builder({ profiles: ["PDF/A-3b"] }); b.metadata({ title: "Invoice 42", author: "Acme Widgets Ltd" }); const font = Font.fromBytes(notoSans); b.font(font); const page = b.page("A4"); draw(page.surface(), font); page.finish(); b.attach("invoice-42.xml", new TextEncoder().encode(invoiceXml()), "text/xml", { relationship: "alternative", description: "Invoice 42 as XML", }); using doc = b.finish(); return doc.save(); } function draw(s: Surface, font: Font): void { s.text("Invoice 42", { font, size: TITLE, at: [MARGIN, TITLE_BASELINE] }); let y = FIRST_ROW; for (const [item, qty, price] of ITEMS) { s.text(`${qty} × ${item}`, { font, size: BODY, at: [MARGIN, y] }); s.text(`€ ${(qty * price).toFixed(2)}`, { font, size: BODY, at: [AMOUNTS, y] }); y += ROW; } const rule = y - RULE_RISE; s.strokeColor([0.5]) .path((p) => p.moveTo(MARGIN, rule).lineTo(RIGHT, rule)) .stroke(); s.text(`Total € ${total().toFixed(2)}`, { font, size: TOTAL, at: [AMOUNTS - TOTAL_INDENT, y + TOTAL_DROP] }); s.text("The invoice is attached as invoice-42.xml.", { font, size: BODY, at: [MARGIN, y + NOTE_DROP], link: { target: { type: "uri", uri: "https://example.com/invoices/42" }, contents: "Invoice 42 online" }, }); } ``` **Rust** `crates/laminapdf/examples/pdf_a3_invoice_with_xml.rs`: ```rust //! A PDF/A-3b invoice that carries the same invoice as XML, attached for software to read. //! //! Run it with `cargo run -p laminapdf --example pdf_a3_invoice_with_xml -- [out-dir]`. use std::error::Error; use std::fmt::Write as _; use std::path::PathBuf; use laminapdf::{ AfRelationship, Attachment, BuildError, BuildOptions, Builder, Color, FontHandle, LinkTarget, Metadata, PageSize, PointPt, Profile, Pt, SaveOptions, StrokeStyle, Surface, }; const MARGIN: f64 = 56.0; /// The page's right edge, less the margin: where the rule under the items ends. const RIGHT: f64 = PageSize::A4.width.get() - MARGIN; const AMOUNTS: f64 = 460.0; const ROW: f64 = 22.0; const TITLE_BASELINE: f64 = 80.0; const FIRST_ROW: f64 = 130.0; /// How far above the next row's baseline the rule sits, and how far below it the total and /// the note are. const RULE_RISE: f64 = 7.0; const TOTAL_DROP: f64 = 18.0; const NOTE_DROP: f64 = 68.0; /// How far left of the amounts column the total starts, to fit its "Total" label. const TOTAL_INDENT: f64 = 40.0; const TITLE: Pt = Pt::new(24.0); const TOTAL: Pt = Pt::new(14.0); const BODY: Pt = Pt::new(11.0); const ITEMS: [(&str, u32, f64); 3] = [("Sprockets", 12, 1.25), ("Gadget", 1, 79.90), ("Delivery", 1, 4.50)]; fn main() -> Result<(), Box> { let mut args = std::env::args().skip(1); let font = std::fs::read(args.next().ok_or("arguments: [out-dir]")?)?; let out = PathBuf::from(args.next().unwrap_or_else(|| "target/examples-out".into())); std::fs::create_dir_all(&out)?; Ok(std::fs::write(out.join("pdf_a3_invoice_with_xml.pdf"), build(font)?)?) } /// The invoice's total. fn total() -> f64 { ITEMS.iter().map(|(_, qty, price)| f64::from(*qty) * price).sum() } /// The invoice as XML, in a made-up schema (a real e-invoice follows EN 16931, as Factur-X does). fn invoice_xml() -> Result { let mut xml = String::from("\n\n"); for (item, qty, price) in ITEMS { writeln!(xml, " ")?; } writeln!(xml, " {:.2}\n", total())?; Ok(xml) } /// The invoice's bytes, in `font` (a TrueType or OpenType file). pub(crate) fn build(font: Vec) -> Result, Box> { let mut b = Builder::new(BuildOptions::new().with_profile(Profile::PdfA3b)); b.metadata( Metadata::default() .with_title("Invoice 42") .with_author("Acme Widgets Ltd"), ); let font = b.font_bytes(font)?; let mut page = b.page(PageSize::A4); draw(page.surface(), &font)?; page.finish()?; let xml = Attachment::new("invoice-42.xml", invoice_xml()?.into_bytes(), "text/xml") .with_relationship(AfRelationship::Alternative) .with_description("Invoice 42 as XML"); b.attach(xml); let mut doc = b.finish()?; Ok(doc.save(&SaveOptions::new())?.bytes) } fn draw(s: &mut Surface, font: &FontHandle) -> Result<(), BuildError> { let at = PointPt::from_points; s.text("Invoice 42", font, TITLE, at(MARGIN, TITLE_BASELINE))?; let mut y = FIRST_ROW; for (item, qty, price) in ITEMS { s.text(&format!("{qty} × {item}"), font, BODY, at(MARGIN, y))?; let amount = f64::from(qty) * price; s.text(&format!("€ {amount:.2}"), font, BODY, at(AMOUNTS, y))?; y += ROW; } let rule = y - RULE_RISE; s.stroke_color(Color::gray(0.5)) .path(|p| _ = p.move_to(at(MARGIN, rule)).line_to(at(RIGHT, rule))) .stroke(&StrokeStyle::default()); let total_at = at(AMOUNTS - TOTAL_INDENT, y + TOTAL_DROP); s.text(&format!("Total € {:.2}", total()), font, TOTAL, total_at)?; let note = "The invoice is attached as invoice-42.xml."; let note = s.text(note, font, BODY, at(MARGIN, y + NOTE_DROP))?; let online = LinkTarget::Uri("https://example.com/invoices/42".into()); note.link(online, Some("Invoice 42 online".into())); Ok(()) } ``` [See the output](/recipes/pdf-a3-invoice-with-xml.md). ## How Lamina PDF checks its own output Every example on this site that claims a PDF/A profile is validated each night with veraPDF, the open-source PDF/A and PDF/UA validator the PDF Association backs. > **Details:** PDF/A is ISO 19005: PDF/A-2 is part 2 and PDF/A-3 part 3. PDF/A-2, PDF/A-3 and PDF/UA-1 all build on PDF 1.7, which is why a PDF 2.0 document can't claim any of them. Next: open a PDF and change it, in [Edit a PDF](/guides/edit-a-pdf.md). --- # Edit a PDF Change an existing PDF in transactions you can undo, and replay edits stored as JSON. You'll open a PDF, change its title and author, and save the edit after the original bytes. You need this whenever your app changes a file someone gave it: a corrected title, a rotated scan, a report marked final. This example generates a draft report, opens its bytes and retitles it in one transaction. Then it saves incrementally, which keeps the draft's bytes at the start of the result, unchanged: **TypeScript** `packages/sdk/examples/generate-then-edit.ts`: ```ts // Generate a document, then open its bytes, edit them and save incrementally. The edit changes // the Info dictionary in one transaction, and the incremental save keeps the generated bytes as // they are, with one revision appended. import { Builder, type BuilderOptions, Document, Font } from "@laminapdf/sdk"; /** * The generated file followed by the edit's revision. Needs `init({ text: true })`. * * @param options - can fix the file ID, the producer and the creation date. * @param editedAt - the modification date to record, as a PDF date string. */ export function generateThenEdit(options: BuilderOptions = {}, editedAt?: string): Uint8Array { return edit(generate(options), editedAt); } /** A one-page draft, saved (a new document is always saved as a full rewrite). */ export function generate(options: BuilderOptions = {}): Uint8Array { using b = new Builder(options); 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" }); using doc = b.finish(); return doc.save(); } /** Opens `bytes`, edits the Info dictionary in one transaction (which `undo` could revert) and saves incrementally. */ function edit(bytes: Uint8Array, editedAt?: string): Uint8Array { using doc = Document.open(bytes); doc.transact((tx) => tx.setInfo({ title: "Final report", author: "Ada Lovelace" }, editedAt)); return doc.save({ mode: "incremental" }); } ``` **Rust** `crates/laminapdf/examples/generate_then_edit.rs`: ````rust //! Generate a document, then open its bytes, edit them and save incrementally. //! //! The edit changes the Info dictionary with an op (undoable and serialisable), and the //! incremental save keeps the generated bytes as they are, with one revision appended. //! //! ```sh //! cargo run -p laminapdf --example generate_then_edit -- [out-dir] # writes /generate_then_edit.pdf //! ``` use std::error::Error; use std::path::PathBuf; use laminapdf::{ BuildOptions, Builder, Document, InfoPatch, Metadata, Op, OpenOptions, PageSize, PdfDate, PointPt, Pt, SaveMode, SaveOptions, SetInfo, Standard14, }; fn main() -> Result<(), Box> { let out = PathBuf::from(std::env::args().nth(1).unwrap_or_else(|| "target/examples-out".into())); std::fs::create_dir_all(&out)?; let bytes = build(BuildOptions::new(), None)?; std::fs::write(out.join("generate_then_edit.pdf"), bytes)?; Ok(()) } /// The generated file followed by the edit's revision. `edited_at` is recorded as the /// modification date; `options` can fix the file ID, the producer and the creation date. pub(crate) fn build(options: BuildOptions, edited_at: Option) -> Result, Box> { let generated = generate(options)?; edit(generated, edited_at) } /// A one-page draft, saved (a new document is always saved as a full rewrite). pub(crate) fn generate(options: BuildOptions) -> Result, Box> { let mut b = Builder::new(options); let helvetica = b.standard_font(Standard14::Helvetica)?; let mut page = b.page(PageSize::A4); 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")); Ok(b.finish()?.save(&SaveOptions::new())?.bytes) } /// Opens `bytes`, edits the Info dictionary in one transaction (which `undo` could revert) and /// saves incrementally: `bytes` unchanged, then the appended revision. fn edit(bytes: Vec, edited_at: Option) -> Result, Box> { let mut doc = Document::open_bytes(bytes, OpenOptions::new())?; let patch = InfoPatch::new() .title(Some("Final report")) .author(Some("Ada Lovelace")); doc.apply(vec![Op::from(SetInfo::new(patch, edited_at))])?; let options = SaveOptions::new().with_mode(SaveMode::Incremental); Ok(doc.save(&options)?.bytes) } ```` [See the output](/recipes/generate-then-edit.md). Your edits live beside the bytes you opened and never change them. Nothing reaches a file until you save. [How Lamina PDF thinks about a PDF](/concepts/how-lamina-thinks.md) explains why it works this way. ## Group edits into a transaction `doc.transact(tx => …)` collects edits, such as `setInfo` and `rotatePage`, and applies them together when the callback returns. If the callback throws, none of them apply. The callback is synchronous, and editing or saving the same document inside it fails with `nested-transaction`. ## Undo and redo `doc.undo()` reverts the last transaction as a whole, and `doc.redo()` applies it again. A transaction is the unit of undo, so group the edits a user would expect to undo together. ## Replay edits stored as JSON Every edit is also a plain JSON object, such as `{ op: "rotatePage", args: { … } }`. So you can store edits, send them to a server and replay them later. `doc.apply(ops)` applies a list of them as one transaction, and `doc.applyLog({ v: 1, ops })` replays a saved log. Next: [save a PDF](/guides/save-a-pdf.md). --- # Save a PDF Save edits as a revision appended to the original bytes or as a whole new file, and see when a file refuses a save. You'll save an edited PDF, either by appending your changes to the original bytes or by writing the whole file again. The choice matters when a file is signed, was damaged, or is password-protected, because each one limits how it can be saved. This example, from [Edit a PDF](/guides/edit-a-pdf.md), asks for an [incremental save](/reference/glossary.md#incremental-save), which keeps the original bytes at the start of the result, unchanged: **TypeScript** `packages/sdk/examples/generate-then-edit.ts`: ```ts // Generate a document, then open its bytes, edit them and save incrementally. The edit changes // the Info dictionary in one transaction, and the incremental save keeps the generated bytes as // they are, with one revision appended. import { Builder, type BuilderOptions, Document, Font } from "@laminapdf/sdk"; /** * The generated file followed by the edit's revision. Needs `init({ text: true })`. * * @param options - can fix the file ID, the producer and the creation date. * @param editedAt - the modification date to record, as a PDF date string. */ export function generateThenEdit(options: BuilderOptions = {}, editedAt?: string): Uint8Array { return edit(generate(options), editedAt); } /** A one-page draft, saved (a new document is always saved as a full rewrite). */ export function generate(options: BuilderOptions = {}): Uint8Array { using b = new Builder(options); 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" }); using doc = b.finish(); return doc.save(); } /** Opens `bytes`, edits the Info dictionary in one transaction (which `undo` could revert) and saves incrementally. */ function edit(bytes: Uint8Array, editedAt?: string): Uint8Array { using doc = Document.open(bytes); doc.transact((tx) => tx.setInfo({ title: "Final report", author: "Ada Lovelace" }, editedAt)); return doc.save({ mode: "incremental" }); } ``` **Rust** `crates/laminapdf/examples/generate_then_edit.rs`: ````rust //! Generate a document, then open its bytes, edit them and save incrementally. //! //! The edit changes the Info dictionary with an op (undoable and serialisable), and the //! incremental save keeps the generated bytes as they are, with one revision appended. //! //! ```sh //! cargo run -p laminapdf --example generate_then_edit -- [out-dir] # writes /generate_then_edit.pdf //! ``` use std::error::Error; use std::path::PathBuf; use laminapdf::{ BuildOptions, Builder, Document, InfoPatch, Metadata, Op, OpenOptions, PageSize, PdfDate, PointPt, Pt, SaveMode, SaveOptions, SetInfo, Standard14, }; fn main() -> Result<(), Box> { let out = PathBuf::from(std::env::args().nth(1).unwrap_or_else(|| "target/examples-out".into())); std::fs::create_dir_all(&out)?; let bytes = build(BuildOptions::new(), None)?; std::fs::write(out.join("generate_then_edit.pdf"), bytes)?; Ok(()) } /// The generated file followed by the edit's revision. `edited_at` is recorded as the /// modification date; `options` can fix the file ID, the producer and the creation date. pub(crate) fn build(options: BuildOptions, edited_at: Option) -> Result, Box> { let generated = generate(options)?; edit(generated, edited_at) } /// A one-page draft, saved (a new document is always saved as a full rewrite). pub(crate) fn generate(options: BuildOptions) -> Result, Box> { let mut b = Builder::new(options); let helvetica = b.standard_font(Standard14::Helvetica)?; let mut page = b.page(PageSize::A4); 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")); Ok(b.finish()?.save(&SaveOptions::new())?.bytes) } /// Opens `bytes`, edits the Info dictionary in one transaction (which `undo` could revert) and /// saves incrementally: `bytes` unchanged, then the appended revision. fn edit(bytes: Vec, edited_at: Option) -> Result, Box> { let mut doc = Document::open_bytes(bytes, OpenOptions::new())?; let patch = InfoPatch::new() .title(Some("Final report")) .author(Some("Ada Lovelace")); doc.apply(vec![Op::from(SetInfo::new(patch, edited_at))])?; let options = SaveOptions::new().with_mode(SaveMode::Incremental); Ok(doc.save(&options)?.bytes) } ```` ## Choose a save mode Every save either appends a revision to the bytes you opened (an incremental save) or writes the whole file again (a [full rewrite](/reference/glossary.md#full-rewrite)). You pick one with the `mode` option: | `mode` | What it writes | | --- | --- | | `auto` (the default) | Incremental when that's allowed, otherwise a full rewrite | | `incremental` | The original bytes unchanged, then one appended revision | | `fullRewrite` | The whole file again | `doc.save(options)` returns the bytes, and `doc.saveWithReport(options)` also says what it wrote. `doc.saveJob()` does the same work in steps, so a page can save a large file without freezing. The same file with the same edits always saves to the same bytes. ## When a file refuses a save Every save makes a plan before it writes. The plan reads the file's save constraint: what its signatures, permissions, repairs and encryption allow. Then it picks a mode or refuses: - **A signed file** saves only incrementally, and only with edits its signatures allow. Anything else fails with `would-invalidate-signatures`. Pass `invalidateSignatures: true` to save anyway, which removes the broken signatures from the output. - **A repaired file**, one Lamina PDF had to fix as it opened, can't safely take an appended revision. It needs a full rewrite, so asking for `incremental` fails with `requires-rewrite`. - **An edited encrypted file** saves only as a full rewrite without its encryption, and only when you pass `removeEncryption: true`. Otherwise every mode fails with `encrypted-base`. - **An unchanged document** always saves incrementally, which returns its bytes unchanged. > **Details:** The save constraint covers signature fields, the [DocMDP](/reference/glossary.md#docmdp) and [UR3](/reference/glossary.md#ur3) permissions, a signature's `/Lock` entry (the form fields it locks), document timestamps, and whether the file needed repair. Next: [open password-protected files](/guides/password-protected-files.md). --- # Open password-protected files Open PDFs that need a password, ask again when it is missing or wrong, and save them afterwards. You'll open a PDF that needs a password, and ask your user again when the password is missing or wrong. You need this for bank statements, contracts and other files that people protect before they share them. This example opens an upload with an optional password and returns the outcome as a value instead of throwing. Its test opens a file encrypted with AES-256 three times: with the right password, with none and with a wrong one: `packages/sdk/examples/open-untrusted.ts`: ```ts // Open bytes from someone else: an optional password, tighter limits than the defaults, and the // outcome as a value rather than an exception. The example writes nothing: it returns what it found. import { Document, isLaminaPdfError } from "@laminapdf/sdk"; const MiB = 1024 * 1024; /** Limits for an upload endpoint: each decoded stream, and all of them together. */ const LIMITS = { maxStreamDecoded: 16 * MiB, maxDecodedTotal: 64 * MiB } as const; /** 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` if the file needs one, under {@link LIMITS}. Needs `init()`. */ export function openUntrusted(bytes: Uint8Array, password?: string): Opened { try { using doc = Document.open(bytes, { ...(password === undefined ? {} : { password }), limits: LIMITS }); // 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) }; } catch (e) { // `password-required`, `wrong-password`, `not-pdf`, `limit-exceeded`, …: a coded error whose // message and hint never contain text from the file. Anything else is a bug: let it throw. if (isLaminaPdfError(e)) return { ok: false, code: e.code, hint: e.hint }; throw e; } } ``` ## Pass the password Pass the password when you open the file: `Document.open(bytes, { password })`. The password goes only to the WebAssembly module. Lamina PDF never stores or logs it, and never puts it in an error. A PDF can have two passwords. The user password opens the file, and the owner password also lifts its restrictions, such as on printing or copying. Lamina PDF checks your password against both: - **Older files** (RC4 and AES-128) check the user password first. - **AES-256 files** check the owner password first, as PDF 2.0 requires. So a password that matches both counts as the owner's. ## When the password is missing or wrong Opening fails with `password-required` when you pass no password, and with `wrong-password` when it matches neither. Nothing else has changed, so ask your user and open the file again. ## Passwords with accents and other symbols A character such as "é" can be written in more than one way in Unicode, so the same password can arrive as different bytes. In TypeScript, Lamina PDF tries the password as you give it and, after `wrong-password`, again in its NFKC form, the Unicode form that makes such spellings match. Older files also accept a password's Latin-1 bytes. AES-256 files accept the password as given or in the normalised form that PDF 2.0 asks for (SASLprep). ## Save an encrypted file Once you've edited an encrypted file, it saves only as an unencrypted [full rewrite](/reference/glossary.md#full-rewrite), with `removeEncryption: true`. Without that option, saving fails with `encrypted-base` (see [save a PDF](/guides/save-a-pdf.md#when-a-file-refuses-a-save)). Lamina PDF can't yet write encrypted files. > **Details:** Lamina PDF reads files encrypted with the standard security handler: RC4 (revisions 2–4), AES-128 (revision 4) and AES-256 (revisions 5 and 6), including crypt filters and unencrypted metadata. It decrypts strings and streams as the file loads. Next: [open files you don't trust](/guides/untrusted-files.md). --- # Open files you don't trust Cap how much work an uploaded PDF can make Lamina PDF do, and refuse damaged files with strict mode. You'll open uploads with caps on how much an untrusted file can make Lamina PDF decode, read or draw. You need this on any server or page that opens PDFs from strangers, because a small file can ask for gigabytes of work. This example opens uploads with tighter limits than the defaults: at most 16 MiB decoded from one stream and 64 MiB in all: `packages/sdk/examples/open-untrusted.ts`: ```ts // Open bytes from someone else: an optional password, tighter limits than the defaults, and the // outcome as a value rather than an exception. The example writes nothing: it returns what it found. import { Document, isLaminaPdfError } from "@laminapdf/sdk"; const MiB = 1024 * 1024; /** Limits for an upload endpoint: each decoded stream, and all of them together. */ const LIMITS = { maxStreamDecoded: 16 * MiB, maxDecodedTotal: 64 * MiB } as const; /** 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` if the file needs one, under {@link LIMITS}. Needs `init()`. */ export function openUntrusted(bytes: Uint8Array, password?: string): Opened { try { using doc = Document.open(bytes, { ...(password === undefined ? {} : { password }), limits: LIMITS }); // 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) }; } catch (e) { // `password-required`, `wrong-password`, `not-pdf`, `limit-exceeded`, …: a coded error whose // message and hint never contain text from the file. Anything else is a bug: let it throw. if (isLaminaPdfError(e)) return { ok: false, code: e.code, hint: e.hint }; throw e; } } ``` ## Set only the limits you need That work takes many forms: a stream that inflates to gigabytes, a page tree thousands of levels deep, drawings nested inside drawings. Lamina PDF keeps every file within the `limits` you pass when you open it. Every limit has a safe default, so you set only the ones you want to change. ## Hard and soft limits - **Hard limits** stop the call with `limit-exceeded`. Its `data.limit` names the limit, so you can tell the user which one. - **Soft limits** drop the excess with a warning and carry on. | Limit | Kind | Default | What it caps | | --- | --- | --- | --- | | `maxNestingDepth` | hard | 256 | arrays and dictionaries inside each other; saved graphics states (`q`) | | `maxStreamDecoded` | hard | 64 MiB | bytes one stream decodes to, per filter stage | | `maxDecodedTotal` | hard | 1 GiB | bytes one call (`open`, one page's content, one read) decodes, across all streams | | `maxFilterChain` | hard | 8 | filters on one stream | | `maxResolveDepth` | hard | 32 | indirect objects in flight while following a chain of references | | `maxPageTreeDepth` | hard | 256 | `/Kids` levels below the page-tree root (a deeper subtree is skipped with a warning) | | `maxTreeDepth` | hard | 64 | name and number tree levels, outline depth | | `maxStringLength` | hard | 16 MiB | bytes in one string or name | | `maxXrefSections` | soft | 4,096 | `/Prev` hops in the [cross-reference table](/reference/glossary.md#cross-reference-table) chain (past it, Lamina PDF rebuilds the table by scanning) | | `maxObjectNumber` | soft | 8,388,607 | the highest object number kept | | `maxWarnings` | soft | 1,000 | warnings kept per document (then one `warnings-truncated`) | | `maxContentOperators` | hard | 10,000,000 | drawing operators run in one content call, counting a form's each time it is drawn | | `maxXobjectDepth` | hard | 32 | forms drawn inside forms | | `maxImagePixels` | hard (reserved) | 2²⁸ | pixels of one image | | `maxReadWindow` | hard | 256 MiB | one read from a range source (`Document.openRange`) | These are the TypeScript names. In Rust they're builder methods, such as `Limits::new().with_max_stream_decoded(…)`. ## Refuse damaged files Lamina PDF repairs damaged files as it opens them and reports each repair as a warning. Pass `strict: true` to refuse any file that needs repair instead. It then fails with `strict`. Next: [keep the page responsive](/guides/keep-the-page-responsive.md). --- # Keep the page responsive Run Lamina PDF in a Web Worker with @laminapdf/sdk/worker, so a large file never freezes the page. You'll run Lamina PDF in a Web Worker, so opening or saving a large file never freezes your page. You need this in a browser app once files get big: on the main thread, a [full rewrite](/reference/glossary.md#full-rewrite) of a 50 MB file blocks the page for about 200 ms. `@laminapdf/sdk` is synchronous after `init()`. `@laminapdf/sdk/worker` runs the same API in a Web Worker, where you call the same methods and each returns a `Promise`. This example opens a file in a Web Worker and saves it there. The save reports its progress, and aborting the signal stops the work, for example when the user presses Cancel: `packages/sdk/examples/worker-client.ts`: ```ts // Saving a PDF in a Web Worker, so a large file never blocks the page: `@laminapdf/sdk/worker` // runs the same API there, and each method returns a Promise. The save reports its progress step // by step, and aborting the signal (a Cancel button, say) stops the work. import { type CallOptions, connect } from "@laminapdf/sdk/worker"; /** What the save reports to and listens to. */ export type SaveInWorkerOptions = Pick; /** * Opens `bytes` in a Web Worker and saves them there as a full rewrite. * * @param bytes - any PDF; it is copied to the worker (`transfer: true` would move it). * @param options - `onProgress` is called after each step of the save. Aborting `signal` stops * the work wherever it is (starting the Web Worker, opening, or at the save's next step), and * the promise rejects with a `cancelled` error. * @returns the saved file. */ export async function saveInWorker(bytes: Uint8Array, options: SaveInWorkerOptions = {}): Promise { const client = await connect(options); try { await using doc = await client.open(bytes, options); return await doc.save({ mode: "fullRewrite" }, options); } finally { client.terminate(); } } ``` ## Call the same methods, awaited Getters become methods: `await doc.pageCount()`. `doc.page(i)` returns a remote page, and `page.text()` returns a `PageText`, once you've called `client.loadExtract()`. The Web Worker leaves out a few members, each for a reason: - **`transact`:** its callback can't cross to the Web Worker, so pass the edits to `apply(ops)` instead. - **`saveJob`:** the Web Worker's `save` already works in steps. - **`ensure`:** the Web Worker fetches the byte ranges a call needs by itself. - **`free`:** call `doc.close()`, or use `await using`, instead. - **`Builder`:** build on the main thread, or in a Web Worker of your own. ## Cancel a call Pass an `AbortSignal` as `signal` to any call. An aborted call rejects with `cancelled`, and `error.cause` holds the signal's reason. What happens to the work depends on where it was: - A call that hasn't started never runs. - `save` stops at its next step, and the document stays usable. - Any other call runs to the end in the Web Worker, because it is one synchronous call. `client.terminate()` stops it at once. ## Move bytes instead of copying them Saved bytes and page text come back transferred, not copied. Your input is copied unless you pass `transfer: true`, which moves it and leaves your buffer empty. ## Recover from a crash If the Web Worker dies, pending calls reject with `worker-crashed`, and `client.on("crashed", …)` fires. `client.restart()` starts a fresh Web Worker, and you open your documents again. `connect()` also checks that the Web Worker comes from the same `@laminapdf/sdk` build as your page. It rejects with `worker-version-mismatch` otherwise. ## Bundlers and a content security policy Vite and webpack bundle the Web Worker with no setup. In Vite, exclude `@laminapdf/sdk` from `optimizeDeps`, as for the main entry. To host the Web Worker's file yourself, for example under a strict content security policy, pass `connect({ worker: () => new Worker(url, { type: "module" }) })`. Next: browse the [recipes](/recipes.md), with the PDF each one makes, or the [troubleshooting](/troubleshooting.md) page when something goes wrong. --- # Recipes Complete Lamina PDF programs, from one page of text to an archived invoice, each with its code and the PDF it writes. Each recipe is a complete program to copy: what it makes, the code, how it works and what to change. Pick the one closest to the document you need, and change it from there. Most recipes exist twice, in Rust and in TypeScript, and both write exactly the same bytes. Lamina PDF's tests check that on every change, in Rust and in every JavaScript runtime: Node, Chromium, Firefox, WebKit, Deno, Bun and workerd. Each recipe shows the tested code, and its reference PDF is the file that code writes. To run a Rust recipe from a checkout of Lamina PDF, use `cargo run -p laminapdf --example `. Its header comment lists the arguments it takes, such as the font or image files it reads. ## Create a PDF - [Hello, world](/recipes/hello.md): one page of text in a standard 14 font. - [An invoice](/recipes/invoice.md): an embedded font, a table of line items, and tags for screen readers. - [Multilingual text](/recipes/multilingual.md): five scripts, each in its own embedded font. - [Images and vectors](/recipes/images-and-vectors.md): paths, colour, transparency, a clip and two images. - [Links and outlines](/recipes/links-and-outlines.md): links, an outline and page labels. ## Edit a PDF - [Generate, then edit](/recipes/generate-then-edit.md): open a file's bytes, change its metadata and save incrementally. ## Read a PDF - [Extract text for an LLM](/recipes/extract-markdown.md): Markdown, JSON with boxes, and chunks for retrieval, in TypeScript and Rust. ## Meet a standard - [A PDF/UA report](/recipes/pdf-ua-report.md): an accessible report a screen reader can follow. - [A PDF/A archive](/recipes/pdf-a-archive.md): a letter for long-term archiving. - [A PDF/A-3 invoice with XML](/recipes/pdf-a3-invoice-with-xml.md): an invoice with its data attached. ## Run it in production (TypeScript) - [A 100-page report](/recipes/report.md): the case Lamina PDF times in every runtime. - [Open someone else's file](/recipes/open-untrusted.md): a password and tighter limits, with the outcome as a value. - [Save in a Web Worker](/recipes/web-worker-client.md): progress and cancelling, off the main thread. - [A Cloudflare Worker](/recipes/cloudflare-worker.md): a PDF per request. Next: [Hello, world](/recipes/hello.md), the smallest PDF Lamina PDF writes. --- # Hello, world One A4 page with a line of text in a standard 14 font, the smallest PDF Lamina PDF writes, in TypeScript and Rust. You'll make the smallest PDF Lamina PDF writes: one A4 page that says "Hello, world!" in Helvetica. Start from it to check that a new setup works, or as the skeleton of a document of your own. The function builds the page and returns the PDF's bytes. In TypeScript it needs the text module, `init({ text: true })`. Both languages write the same bytes: `packages/sdk/examples/hello.ts`: ```ts // The smallest document: one A4 page with a line of text in a standard 14 font. import { Builder, type BuilderOptions, Font } from "@laminapdf/sdk"; /** * The document's bytes. Needs `init({ text: true })`. * * @param options - can fix the file ID, the producer and the creation date, so that the same * input gives the same bytes on every run. */ export function hello(options: BuilderOptions = {}): Uint8Array { using b = new Builder(options); 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(); using doc = b.finish(); return doc.save(); } ``` `crates/laminapdf/examples/hello.rs`: ````rust //! The smallest document: one A4 page with a line of text in a standard 14 font. //! //! ```sh //! cargo run -p laminapdf --example hello -- [out-dir] # writes /hello.pdf (default target/examples-out) //! ``` use std::error::Error; use std::path::PathBuf; use laminapdf::{BuildOptions, Builder, PageSize, PointPt, Pt, SaveOptions, Standard14}; fn main() -> Result<(), Box> { 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(BuildOptions::new())?)?; Ok(()) } /// The document's bytes. `options` can fix the file ID, the producer and the creation date, so /// that the same input gives the same bytes on every run. pub(crate) fn build(options: BuildOptions) -> Result, Box> { 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. 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())?.bytes) } ```` The result is one page with the line near its top-left corner: [open the reference PDF](/examples/pdf/hello.pdf). ## How it works - The builder makes an A4 page, and the page's surface is what you draw on. - `at` is in points from the top-left corner of the page, with y going down. Its second number is the text's baseline. See [Pages and coordinates](/concepts/pages-and-coordinates.md). - Helvetica is one of the [standard 14 fonts](/reference/glossary.md#standard-14-fonts), which every PDF viewer has, so the PDF embeds no font. - Finishing the builder gives a document, and saving the document gives its bytes. `using` in TypeScript frees both as soon as the function ends. ## What to change - **The text and where it goes:** change the string, the size and `at`. - **The page size:** name another size, such as `"Letter"`, or pass `[width, height]` in points. - **The font:** for characters outside the standard 14 fonts' Latin set, embed a font. See [Text and fonts](/guides/fonts-and-text.md). - **A file ID or a date of your own:** pass them in the builder's options. Without them, the file ID is a hash of the content and the PDF has no creation date, so the same code writes the same bytes on every run. Next: [an invoice](/recipes/invoice.md), with an embedded font and a table of line items. --- # An invoice A tagged one-page invoice with an embedded font, a table of line items with right-aligned amounts and a total, in TypeScript and Rust. You'll make a one-page invoice: a heading, an address, a table of line items with right-aligned amounts, and a total. It's tagged, so a screen reader reads it in order. Start from it for any document built around a table, such as a quote or a receipt. The function takes a font file, draws the invoice and returns the PDF's bytes. In TypeScript it needs the text module, `init({ text: true })`. Both languages write the same bytes: `packages/sdk/examples/invoice.ts`: ```ts // An invoice that screen readers can follow, with an embedded font and a table of line items. // The heading, the address, the table of items and the total are tagged, so a screen reader // reads them in order. import { Builder, type BuilderOptions, Font, PAGE_SIZES, type Rect, type Surface } from "@laminapdf/sdk"; 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], ]; const ADDRESS = "Acme Widgets Ltd · 1 Example Street · London"; const PAGE = "A4"; const [PAGE_WIDTH] = PAGE_SIZES[PAGE]; const MARGIN = 56; const RIGHT_EDGE = PAGE_WIDTH - MARGIN; const TITLE_AT = [MARGIN, 80] as const; const ADDRESS_AT = [MARGIN, 100] as const; const TITLE = 24; const BODY = 11; const FIRST_ROW = 160; const ROW_HEIGHT = 24; /** The quantity's and the amount's columns, each a width ending this far left of the right edge. */ const COLUMNS = [ [40, 169], [100, 0], ] as const; /** A cell's box starts this far above its row's baseline and is this tall. */ const CELL_RISE = 12; const CELL_HEIGHT = 16; /** * The document's bytes, in `fontBytes` (a TrueType or OpenType file). Needs `init({ text: true })`. * * @param options - can fix the file ID, the producer and the creation date. */ export function invoice(fontBytes: Uint8Array, options: BuilderOptions = {}): Uint8Array { using b = new Builder({ ...options, tagged: true }); const font = Font.fromBytes(fontBytes); const bold = Font.standard("Helvetica-Bold"); // Registered up front, in this order, so the embedded font is the document's first. b.font(font); b.font(bold); const page = b.page(PAGE); const s = page.surface(); s.text("Invoice 42", { font: bold, size: TITLE, at: TITLE_AT, tag: "H1" }); s.text(ADDRESS, { font, size: BODY, at: ADDRESS_AT, tag: "P" }); items(s, font); const total = ITEMS.reduce((sum, [, qty, price]) => sum + qty * price, 0); const box = cell(COLUMNS[1], rowBaseline(ITEMS.length)); s.textBox(`Total € ${total.toFixed(2)}`, { font, size: BODY, box, align: "end", tag: "P" }); page.finish(); b.metadata({ title: "Invoice 42", author: "Acme Widgets Ltd" }).lang("en-GB"); using doc = b.finish(); return doc.save(); } /** The table of line items: what, how many, and the amount, right-aligned. */ function items(s: Surface, font: Font): void { s.tag("Table", (s) => { ITEMS.forEach(([item, qty, price], row) => { const y = rowBaseline(row); const cells = [ [String(qty), COLUMNS[0]], [`€ ${(qty * price).toFixed(2)}`, COLUMNS[1]], ] as const; s.tag("TR", (s) => { s.text(item, { font, size: BODY, at: [MARGIN, y], tag: "TD" }); for (const [text, column] of cells) { s.textBox(text, { font, size: BODY, box: cell(column, y), align: "end", tag: "TD" }); } }); }); }); } /** The baseline of row `row` of the table. */ function rowBaseline(row: number): number { return FIRST_ROW + ROW_HEIGHT * row; } /** The box of a cell in `column` (width, distance from the right edge) on the row at `baseline`. */ function cell([width, inset]: readonly [number, number], baseline: number): Rect { return [RIGHT_EDGE - inset - width, baseline - CELL_RISE, width, CELL_HEIGHT]; } ``` `crates/laminapdf/examples/invoice.rs`: ```rust //! An invoice that screen readers can follow, with an embedded font and a table of line items. //! //! The heading, the address, the table of items and the total are tagged, so a screen reader //! reads them in order. Run it with `cargo run -p laminapdf --example invoice -- [out-dir]`. use laminapdf::{ Alignment, BuildError, BuildOptions, Builder, FontHandle, Metadata, PageSize, PointPt, Pt, RectPt, SaveOptions, Standard14, 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), ]; const ADDRESS: &str = "Acme Widgets Ltd · 1 Example Street · London"; const PAGE: PageSize = PageSize::A4; const MARGIN: f64 = 56.0; const RIGHT_EDGE: f64 = PAGE.width.get() - MARGIN; const TITLE_AT: PointPt = PointPt::from_points(MARGIN, 80.0); const ADDRESS_AT: PointPt = PointPt::from_points(MARGIN, 100.0); const TITLE: Pt = Pt::new(24.0); const BODY: Pt = Pt::new(11.0); const FIRST_ROW: f64 = 160.0; const ROW_HEIGHT: f64 = 24.0; /// The quantity's and the amount's columns, each a width ending this far left of the right edge. const COLUMNS: [(f64, f64); 2] = [(40.0, 169.0), (100.0, 0.0)]; /// A cell's box starts this far above its row's baseline and is this tall. const CELL_RISE: f64 = 12.0; const CELL_HEIGHT: f64 = 16.0; fn main() -> Result<(), Box> { let mut args = std::env::args().skip(1); let font = std::fs::read(args.next().ok_or("usage: invoice [out-dir]")?)?; let out = std::path::PathBuf::from(args.next().unwrap_or_else(|| "target/examples-out".into())); std::fs::create_dir_all(&out)?; let pdf = build(BuildOptions::new(), font)?; Ok(std::fs::write(out.join("invoice.pdf"), pdf)?) } /// The bytes, in `font` (TrueType or OpenType); `options` can fix the file ID, producer and date. pub(crate) fn build(options: BuildOptions, font: Vec) -> Result, Box> { let mut b = Builder::new(options.with_tagged(true)); draw(&mut b, font)?; Ok(b.finish()?.save(&SaveOptions::new())?.bytes) } /// Draws the invoice's one page into `b` and sets its title, author and language. pub(crate) fn draw(b: &mut Builder, font: Vec) -> Result<(), Box> { let (font, bold) = (b.font_bytes(font)?, b.standard_font(Standard14::HelveticaBold)?); let mut page = b.page(PAGE); let s = page.surface(); s.text("Invoice 42", &bold, TITLE, TITLE_AT)?.tag(TagKind::H1); s.text(ADDRESS, &font, BODY, ADDRESS_AT)?.tag(TagKind::P); items(s, &font)?; let total: f64 = ITEMS.iter().map(|(_, qty, price)| f64::from(*qty) * price).sum(); let (total, below_items) = (format!("Total € {total:.2}"), u32::try_from(ITEMS.len())?); let area = cell(COLUMNS[1], row_baseline(below_items)); s.tag(TagKind::P, |s| right_aligned(s, &font, &total, area))?; page.finish()?; let metadata = Metadata::default().with_title("Invoice 42"); b.metadata(metadata.with_author("Acme Widgets Ltd")).lang("en-GB"); Ok(()) } /// The table of line items: what, how many, and the amount, right-aligned. fn items(s: &mut Surface, font: &FontHandle) -> Result<(), BuildError> { s.tag(TagKind::Table, |s| { for (row, (item, qty, price)) in (0_u32..).zip(ITEMS) { let y = row_baseline(row); s.tag(TagKind::TR, |s| { s.text(item, font, BODY, PointPt::from_points(MARGIN, y))? .tag(TagKind::TD); let amount = format!("€ {:.2}", f64::from(qty) * price); for (text, column) in [qty.to_string(), amount].iter().zip(COLUMNS) { s.tag(TagKind::TD, |s| right_aligned(s, font, text, cell(column, y)))?; } Ok(()) })?; } Ok(()) }) } /// The baseline of row `row` of the table. fn row_baseline(row: u32) -> f64 { FIRST_ROW + ROW_HEIGHT * f64::from(row) } /// The box of a cell in `column` (width, distance from the right edge) on the row at `baseline`. fn cell((width, inset): (f64, f64), baseline: f64) -> RectPt { RectPt::from_points(RIGHT_EDGE - inset - width, baseline - CELL_RISE, width, CELL_HEIGHT) } /// `text` in `area`, in the body size, aligned to its right edge. fn right_aligned(s: &mut Surface, font: &FontHandle, text: &str, area: RectPt) -> Result<(), BuildError> { let right = TextBoxOptions::new().with_align(Alignment::End); s.text_box(text, font, BODY, area, &right).map(|_| ()) } ``` The result is one A4 page with its body set in Noto Sans: [open the reference PDF](/examples/pdf/invoice.pdf). ## How it works - The body uses your font file, and Lamina PDF embeds only the glyphs the invoice draws. The heading uses Helvetica Bold, one of the [standard 14 fonts](/reference/glossary.md#standard-14-fonts), so it embeds nothing. - Each quantity and amount is a text box aligned to its `end`, so the numbers line up on the right. `cell` works out each box from its column's width and its distance from the right margin. - `tagged: true` (`with_tagged(true)` in Rust) gives the document a structure for screen readers. The `tag` option and `s.tag(…)` make the heading an `H1`, the address and the total each a paragraph (`P`), and the items a `Table` of rows (`TR`) and cells (`TD`). - `metadata` sets the title and author, and `lang("en-GB")` tells a screen reader which language to speak. ## What to change - **The line items:** edit `ITEMS`. Each row sits `ROW_HEIGHT` points below the one before, and the total follows the last row. - **The columns:** each pair in `COLUMNS` is a column's width and its distance from the right margin, in points. - **The currency:** change the `€` in the amount and total strings. Your font needs a glyph for the new symbol, or drawing fails with `missing-glyph`. See [Text and fonts](/guides/fonts-and-text.md). - **A PDF/UA-1 invoice:** add `profiles: ["PDF/UA-1"]` (`with_profile(Profile::PdfUa1)` in Rust) to check the rules for accessible PDFs. PDF/UA-1 needs every font embedded, so load a bold font file for the heading. See [Accessible PDFs](/guides/accessible-pdfs.md). Next: [multilingual text](/recipes/multilingual.md), with five scripts, each in its own embedded font. --- # Multilingual text Latin, Arabic, Devanagari, Hebrew and Chinese on one page, each in its own embedded font, as a shaped line and a wrapped paragraph. You'll put five scripts on one page: Latin, Arabic, Devanagari, Hebrew and Chinese, each as a single line and as a paragraph wrapped in a box. Start from it whenever your text goes beyond Latin, or mixes left-to-right and right-to-left writing. The function takes one font file per script, draws each script's line and paragraph, and returns the PDF's bytes. In TypeScript it needs the text module, `init({ text: true })`. Both languages write the same bytes: `packages/sdk/examples/multilingual.ts`: ```ts // Five scripts on one page, each as a single line and as a wrapped paragraph. The scripts are // Latin, Arabic and Hebrew (right to left), Devanagari and simplified Chinese; each is shaped and // bidi-resolved, and each paragraph wraps in a text box. Each script needs a font that covers it, // such as the Noto fonts (OFL). import { Builder, type BuilderOptions, type Direction, Font } from "@laminapdf/sdk"; /** Each script's line, its paragraph and the paragraph's direction, in the order of the fonts. */ 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"], ]; const MARGIN = 56; const FIRST_BASELINE = 80; /** The distance from one script's line to the next one's. */ const SCRIPT_HEIGHT = 140; /** The paragraph's box: this far below the line, this wide and this tall. */ const PARAGRAPH_DROP = 14; const PARAGRAPH_WIDTH = 220; const PARAGRAPH_HEIGHT = 100; /** * The document's bytes. Needs `init({ text: true })`. * * @param fonts - one font file per script, in the order of {@link SCRIPTS}. * @param options - can fix the file ID, the producer and the creation date. */ export function multilingual(fonts: readonly Uint8Array[], options: BuilderOptions = {}): Uint8Array { if (fonts.length !== SCRIPTS.length) throw new Error(`${SCRIPTS.length} fonts, one per script, are needed`); using b = new Builder(options); 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) return; const y = FIRST_BASELINE + SCRIPT_HEIGHT * row; s.text(line, { font, size: 16, at: [MARGIN, y] }); // A narrow box breaks the paragraph into lines; right-to-left paragraphs start at the right. const box = [MARGIN, y + PARAGRAPH_DROP, PARAGRAPH_WIDTH, PARAGRAPH_HEIGHT] as const; s.textBox(paragraph, { font, size: 12, box, direction, align: "start" }); }); page.finish(); using doc = b.finish(); return doc.save(); } ``` `crates/laminapdf/examples/multilingual.rs`: ````rust //! Five scripts on one page, each as a single line and as a wrapped paragraph. //! //! The scripts are Latin, Arabic and Hebrew (right to left), Devanagari and simplified Chinese; //! each is shaped and bidi-resolved, and each paragraph wraps in a text box. Each script needs a //! font that covers it, such as the Noto fonts (OFL). //! //! ```sh //! cargo run -p laminapdf --example multilingual -- latin.ttf arabic.ttf devanagari.ttf hebrew.ttf chinese.otf //! ``` use std::error::Error; use std::path::PathBuf; use laminapdf::{ Alignment, BuildOptions, Builder, Direction, PageSize, PointPt, Pt, RectPt, SaveOptions, TextBoxOptions, }; /// Each script's line, its paragraph and the paragraph's direction, in the order of the fonts. const SCRIPTS: [(&str, &str, Direction); 5] = [ ( "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, ), ( "مرحبا بالعالم السلام عليكم", "مرحبا بالعالم السلام عليكم مرحبا بالعالم السلام عليكم", Direction::RightToLeft, ), ( "नमस्ते दुनिया। हिन्दी पाठ का परीक्षण", "नमस्ते दुनिया। हिन्दी पाठ का परीक्षण नमस्ते दुनिया।", Direction::LeftToRight, ), ( "שלום עולם וברוכים הבאים", "שלום עולם וברוכים הבאים שלום עולם וברוכים הבאים", Direction::RightToLeft, ), ( "你好世界,这是中文字体子集测试。", "你好世界,这是中文字体子集测试。你好世界,这是中文。", Direction::LeftToRight, ), ]; const MARGIN: f64 = 56.0; const FIRST_BASELINE: f64 = 80.0; /// The distance from one script's line to the next one's. const SCRIPT_HEIGHT: f64 = 140.0; /// The paragraph's box: this far below the line, this wide and this tall. const PARAGRAPH_DROP: f64 = 14.0; const PARAGRAPH_WIDTH: f64 = 220.0; const PARAGRAPH_HEIGHT: f64 = 100.0; fn main() -> Result<(), Box> { let mut args: Vec = std::env::args().skip(1).collect(); let out = PathBuf::from(if args.len() > SCRIPTS.len() { args.remove(SCRIPTS.len()) } else { "target/examples-out".into() }); let fonts = args.iter().map(std::fs::read).collect::, _>>()?; std::fs::create_dir_all(&out)?; std::fs::write(out.join("multilingual.pdf"), build(BuildOptions::new(), fonts)?)?; Ok(()) } /// The document's bytes, with one font per script in `fonts` (in the order of [`SCRIPTS`]). /// `options` can fix the file ID, the producer and the creation date. pub(crate) fn build(options: BuildOptions, fonts: Vec>) -> Result, Box> { if fonts.len() != SCRIPTS.len() { return Err(format!("{} fonts, one per script, are needed", SCRIPTS.len()).into()); } let mut b = Builder::new(options); let fonts = fonts .into_iter() .map(|bytes| b.font_bytes(bytes)) .collect::, _>>()?; 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 = FIRST_BASELINE + SCRIPT_HEIGHT * f64::from(row); s.text(line, font, Pt::new(16.0), PointPt::from_points(MARGIN, y))?; // A narrow box breaks the paragraph into lines; right-to-left paragraphs start at the right. let options = TextBoxOptions::new() .with_direction(*direction) .with_align(Alignment::Start); let area = RectPt::from_points(MARGIN, y + PARAGRAPH_DROP, PARAGRAPH_WIDTH, PARAGRAPH_HEIGHT); s.text_box(paragraph, font, Pt::new(12.0), area, &options)?; } page.finish()?; let mut doc = b.finish()?; Ok(doc.save(&SaveOptions::new())?.bytes) } ```` The result is one A4 page, with each script set in its own Noto font: [open the reference PDF](/examples/pdf/multilingual.pdf). ## How it works - `Font.fromBytes` (`font_bytes` in Rust) loads each font, which must cover its script. The builder embeds only the glyphs the page draws. - Lamina PDF shapes the text with each font, so Arabic letters join, Devanagari letters combine into clusters and the Latin line forms ligatures. - `textBox` breaks each paragraph into lines that fit its 220-point-wide box. It breaks where Unicode's line-breaking rules allow: between words, and between Chinese characters. - `direction: "rtl"` (`Direction::RightToLeft`) makes the Arabic and Hebrew paragraphs right to left, so `align: "start"` starts their lines at the box's right edge. - Each font is embedded with its [ToUnicode](/reference/glossary.md#tounicode) mapping, so text copied or extracted from the PDF equals the strings you passed in, ligatures included. ## What to change - **Your own scripts:** change `SCRIPTS` and pass one font per row, in the same order. A character the font lacks fails with `missing-glyph`. - **The paragraph's box:** `PARAGRAPH_WIDTH` and `PARAGRAPH_HEIGHT` set its size. Lines that don't fit are left out, unless you pass `overflow: "error"` (`Overflow::Error`) to fail with `text-overflow` instead. - **Alignment and spacing:** `align` also takes `"end"`, `"left"`, `"right"` or `"center"`, and `lineHeight` (`with_line_height`) sets the distance between baselines, 1.2 times the size by default. - **Ligatures and other OpenType features:** `features: ["liga=0"]` on `text` or `textBox` turns the ligatures off. In Rust, call `features` on the run or `with_feature` on the text box's options. See [Text and fonts](/guides/fonts-and-text.md). Next: [images and vectors](/recipes/images-and-vectors.md), with shapes, colour, transparency and two images. --- # Images and vectors Shapes, a dashed curve, gray, RGB, CMYK and hex colour, transparency, a blend mode, a clip, a rotation, a JPEG and an RGBA image on one page. You'll draw shapes and images on one A4 page: a rectangle, a circle, a dashed curve, two see-through discs, a photo cut to an ellipse and a rotated gradient that fades out. Start from it for a logo, a chart, the rules of a table or a page with photos. The function takes a JPEG's bytes, draws the page and returns the PDF's bytes. It draws no text, so in TypeScript it needs only the base module, `init()`. Both languages write the same bytes: `packages/sdk/examples/images-and-vectors.ts`: ```ts // Images and vector graphics on one A4 page. It draws shapes and a dashed Bézier curve in gray, // RGB, CMYK and hex colour, overlaps two translucent discs with a blend mode, clips a JPEG to an // ellipse and rotates an RGBA image with a soft mask. Needs only the base module. import { Builder, type BuilderOptions, Image, Matrix, type Surface } from "@laminapdf/sdk"; /** The RGBA gradient's side, in pixels. */ const GLOW_SIDE = 16; /** * The document's bytes, with `photo` (a JPEG) on the page. * * @param options - can fix the file ID, the producer and the creation date. */ export function imagesAndVectors(photo: Uint8Array, options: BuilderOptions = {}): Uint8Array { using b = new Builder(options); const jpeg = Image.jpeg(photo); const glow = Image.rgba(GLOW_SIDE, GLOW_SIDE, gradient()); const page = b.page("A4"); const s = page.surface(); shapes(s); overlappingDiscs(s); images(s, jpeg, glow); page.finish(); using doc = b.finish(); return doc.save(); } /** A square gradient whose alpha falls off towards the right. */ function gradient(): Uint8Array { const out = new Uint8Array(GLOW_SIDE * GLOW_SIDE * 4); for (let y = 0; y < GLOW_SIDE; y++) { for (let x = 0; x < GLOW_SIDE; x++) out.set([x * 16, y * 16, 200, 255 - x * 15], (y * GLOW_SIDE + x) * 4); } return out; } /** A rectangle, a circle and a dashed, round-capped Bézier curve. */ function shapes(s: Surface): void { 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({ width: 3, cap: "round", dash: [12, 8] }); } /** Two translucent discs, the second multiplied onto the first. */ function overlappingDiscs(s: Surface): void { 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 the gradient rotated by 30 degrees. */ function images(s: Surface, photo: Image, glow: Image): void { s.save() .path((p) => p.ellipse([300, 270, 239, 120])) .clip(); s.image(photo, [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(); } ``` `crates/laminapdf/examples/images_and_vectors.rs`: ````rust //! Images and vector graphics on one A4 page. //! //! It draws shapes and a dashed Bézier curve in gray, RGB, CMYK and hex colour, overlaps two //! translucent discs with a blend mode, clips a JPEG to an ellipse and rotates an RGBA image //! with a soft mask. //! //! ```sh //! cargo run -p laminapdf --example images_and_vectors -- photo.jpg [out-dir] # writes images_and_vectors.pdf //! ``` use std::error::Error; use std::path::PathBuf; use laminapdf::{ BlendMode, BuildOptions, Builder, Color, Dash, FillRule, Image, LineCap, Matrix, PageSize, PathBuilder, PointPt, Pt, RectPt, SaveOptions, StrokeStyle, Surface, }; /// The RGBA gradient's side, in pixels. const GLOW_SIDE: u8 = 16; fn main() -> Result<(), Box> { let mut args = std::env::args().skip(1); let photo = std::fs::read(args.next().ok_or("usage: images_and_vectors [out-dir]")?)?; let out = PathBuf::from(args.next().unwrap_or_else(|| "target/examples-out".into())); std::fs::create_dir_all(&out)?; std::fs::write(out.join("images_and_vectors.pdf"), build(BuildOptions::new(), photo)?)?; Ok(()) } /// The document's bytes, with `photo` (a JPEG) on the page. `options` can fix the file ID, the /// producer and the creation date. pub(crate) fn build(options: BuildOptions, photo: Vec) -> Result, Box> { let mut b = Builder::new(options); let photo = Image::jpeg(photo)?; let glow = Image::rgba(u32::from(GLOW_SIDE), u32::from(GLOW_SIDE), &gradient())?; let mut page = b.page(PageSize::A4); let s = page.surface(); shapes(s)?; overlapping_discs(s); images(s, &photo, &glow); page.finish()?; let mut doc = b.finish()?; Ok(doc.save(&SaveOptions::new())?.bytes) } /// A square gradient whose alpha falls off towards the right. fn gradient() -> Vec { (0..GLOW_SIDE) .flat_map(|y| (0..GLOW_SIDE).flat_map(move |x| [x * 16, y * 16, 200, 255 - x * 15])) .collect() } /// A rectangle, a circle and a dashed, round-capped Bézier curve. fn shapes(s: &mut Surface) -> Result<(), Box> { let at = PointPt::from_points; s.fill_color(Color::rgb(0.9, 0.3, 0.2)) .path(|p| _ = p.rect(RectPt::from_points(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); let dashed = StrokeStyle { width: Pt::new(3.0), cap: LineCap::Round, dash: Some(Dash::new(vec![12.0, 8.0], 0.0)), ..StrokeStyle::default() }; 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); Ok(()) } /// Two translucent discs, the second multiplied onto the first. fn overlapping_discs(s: &mut Surface) { let disc = |x: f64| move |p: &mut PathBuilder| _ = p.circle(PointPt::from_points(x, 330.0), Pt::new(60.0)); s.save().opacity(0.6, 1.0).fill_color(Color::gray(0.3)); s.path(disc(120.0)).fill(FillRule::NonZero); s.blend_mode(BlendMode::Multiply).fill_color(Color::rgb(1.0, 0.8, 0.0)); s.path(disc(190.0)).fill(FillRule::NonZero).restore(); } /// The photo clipped to an ellipse, and the gradient rotated by 30 degrees. fn images(s: &mut Surface, photo: &Image, glow: &Image) { s.save() .path(|p| _ = p.ellipse(RectPt::from_points(300.0, 270.0, 239.0, 120.0))) .clip(FillRule::NonZero); s.image(photo, RectPt::from_points(300.0, 250.0, 239.0, 160.0)) .restore(); let placement = Matrix::translate(150.0, 520.0).after(&Matrix::rotate(30.0)); s.save().transform(placement); s.image(glow, RectPt::from_points(-60.0, -60.0, 120.0, 120.0)).restore(); } ```` The result is one A4 page with every shape and both images: [open the reference PDF](/examples/pdf/images_and_vectors.pdf). ## How it works - `path(p => …)` builds a shape and returns it unpainted. `fill` and `stroke` paint it, and `clip` limits what's drawn next to its inside. See [Shapes, colour and images](/guides/images-and-vectors.md). - A colour of one, three or four numbers from 0 to 1 is gray, RGB or CMYK, and a string such as `"#2a9d8f"` is hex. Rust has `Color::gray`, `rgb`, `cmyk` and `hex`. - `save()` and `restore()` bracket each group of drawings, so the opacity, blend mode, clip and rotation set inside stop at `restore()`. - Both discs are 60% opaque, and `multiply` darkens the area where the second disc covers the first. - `Image.jpeg` embeds the photo as it is, without decoding it. `Image.rgba` takes raw pixels, and their alpha becomes a soft mask: a second image that holds the transparency. - The matrix turns the gradient 30 degrees clockwise, then moves it to (150, 520). The gradient is drawn centred on the origin, so it turns about its own centre. Like every position, the matrix works in [page space](/reference/glossary.md#page-space). ## What to change - **A PNG instead of the JPEG:** `Image.png(bytes)` (`Image::png` in Rust) keeps the PNG's transparency. - **A shape with a hole:** draw both outlines in one path and fill it with `"evenOdd"` (`FillRule::EvenOdd`), so the inner shape stays empty. - **A filled shape with an outline:** `fillStroke(rule, style)` (`fill_stroke` in Rust) paints both at once. - **Another blend mode:** `"screen"` lightens where `"multiply"` darkens. `BlendMode` lists all sixteen. - **Alternate text:** pass a description as the third argument of `image` (`figure` in Rust), and a screen reader reads it out. See [Accessible PDFs](/guides/accessible-pdfs.md). Next: [links and outlines](/recipes/links-and-outlines.md), with four pages joined by links, an outline and page labels. --- # Links and outlines Four A5 pages joined by web, page and named links, with a nested outline, page labels that read i, ii, 1, 2 and the outline shown on open. You'll make four A5 pages joined by links to a website and between pages, an outline (the bookmarks panel) and page numbers that read i, ii, 1, 2. Start from it for a manual, a report or any document longer than a page or two. The function draws the four pages, links them and returns the PDF's bytes. It draws text, so in TypeScript it needs the text module, `init({ text: true })`. Both languages write the same bytes: `packages/sdk/examples/links-and-outlines.ts`: ```ts // Links and outlines: four A5 pages joined by links, bookmarks and page labels. The title page // links to a website, to a page and through a named destination; every other page has a linked // text run back to it. The outline is nested and shown when the file opens. import { Builder, type BuilderOptions, Font, type LinkTarget, type Surface } from "@laminapdf/sdk"; const TITLES = ["Title page", "Contents", "Chapter 1", "Chapter 2"] as const; const MARGIN = 36; const TITLE_BASELINE = 60; const TITLE_SIZE = 18; const LINK_SIZE = 12; /** The baselines of the title page's three links. */ const LINK_BASELINES = [100, 125, 150] as const; /** A link's clickable box: this wide, this tall, starting this far above the baseline. */ const LINK_WIDTH = 220; const LINK_HEIGHT = 20; const LINK_RISE = 15; /** * The document's bytes. Needs `init({ text: true })`. * * @param options - can fix the file ID, the producer and the creation date. */ export function linksAndOutlines(options: BuilderOptions = {}): Uint8Array { using b = new Builder(options); 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: TITLE_SIZE, at: [MARGIN, TITLE_BASELINE] }); if (index === 0) titlePageLinks(s, font); else { const link = { target: { type: "page", index: 0 }, contents: "Title page" } as const; s.text("Back to the title page", { font, size: LINK_SIZE, at: [MARGIN, 100], fill: [0, 0.2, 0.8], link }); } page.finish(); }); navigation(b); using doc = b.finish(); return doc.save(); } /** The title page's links: to a website, to a page, and to a named destination. */ function titlePageLinks(s: Surface, font: Font): void { const links: readonly (readonly [label: string, target: LinkTarget, contents?: string])[] = [ ["Visit example.com", { type: "uri", uri: "https://example.com/" }, "Example"], ["Go to chapter 1", { type: "page", index: 2 }], ["Go to chapter 2 (named)", { type: "named", name: "chapter-2" }], ]; links.forEach(([label, target, contents], i) => { const baseline = LINK_BASELINES[i] ?? 0; s.text(label, { font, size: LINK_SIZE, at: [MARGIN, baseline] }); s.link([MARGIN, baseline - LINK_RISE, LINK_WIDTH, LINK_HEIGHT], target, contents); }); } /** The outline (bookmarks), the page labels, and the viewer opening with the outline shown. */ function navigation(b: Builder): void { 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" }); } ``` `crates/laminapdf/examples/links_and_outlines.rs`: ````rust //! Links and outlines: four A5 pages joined by links, bookmarks and page labels. //! //! The title page has a URI link, a link to a page and one through a named destination; every //! other page has a linked text run back to it. The outline (bookmarks) is nested and open in //! the viewer, and the pages are labelled i, ii, 1, 2. //! //! ```sh //! cargo run -p laminapdf --example links_and_outlines -- [out-dir] # writes /links_and_outlines.pdf //! ``` use std::error::Error; use std::path::PathBuf; use laminapdf::{ BuildOptions, Builder, Color, Fit, FontHandle, LabelStyle, LinkTarget, OutlineEntry, PageLabel, PageMode, PageSize, PointPt, Pt, RectPt, SaveOptions, Standard14, Surface, ViewerPreferences, }; const TITLES: [&str; 4] = ["Title page", "Contents", "Chapter 1", "Chapter 2"]; const MARGIN: f64 = 36.0; const TITLE_AT: PointPt = PointPt::from_points(MARGIN, 60.0); const BACK_AT: PointPt = PointPt::from_points(MARGIN, 100.0); const TITLE_SIZE: Pt = Pt::new(18.0); const LINK_SIZE: Pt = Pt::new(12.0); /// The baselines of the title page's three links. const LINK_BASELINES: [f64; 3] = [100.0, 125.0, 150.0]; /// A link's clickable box: this wide, this tall, starting this far above the baseline. const LINK_WIDTH: f64 = 220.0; const LINK_HEIGHT: f64 = 20.0; const LINK_RISE: f64 = 15.0; fn main() -> Result<(), Box> { 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(BuildOptions::new())?)?; Ok(()) } /// The document's bytes. `options` can fix the file ID, the producer and the creation date. pub(crate) fn build(options: BuildOptions) -> Result, Box> { 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(); s.text(title, &helvetica, TITLE_SIZE, TITLE_AT)?; if index == 0 { title_page_links(s, &helvetica)?; } else { s.text("Back to the title page", &helvetica, LINK_SIZE, BACK_AT)? .fill(Color::rgb(0.0, 0.2, 0.8)) .link(LinkTarget::page(0), Some("Title page".into())); } page.finish()?; } navigation(&mut b)?; let mut doc = b.finish()?; Ok(doc.save(&SaveOptions::new())?.bytes) } /// The title page's links: to a website, to a page, and to a named destination. fn title_page_links(s: &mut Surface, font: &FontHandle) -> Result<(), Box> { let site = LinkTarget::Uri("https://example.com/".into()); let links = [ ("Visit example.com", site, Some("Example")), ("Go to chapter 1", LinkTarget::page(2), None), ("Go to chapter 2 (named)", LinkTarget::Named("chapter-2".into()), None), ]; for ((label, target, contents), baseline) in links.into_iter().zip(LINK_BASELINES) { s.text(label, font, LINK_SIZE, PointPt::from_points(MARGIN, baseline))?; let area = RectPt::from_points(MARGIN, baseline - LINK_RISE, LINK_WIDTH, LINK_HEIGHT); s.link(area, target, contents.map(String::from)); } Ok(()) } /// The outline (bookmarks), the page labels, and the viewer opening with the outline shown. fn navigation(b: &mut Builder) -> Result<(), Box> { 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)); Ok(()) } ```` The result is four A5 pages with an outline and page labels: [open the reference PDF](/examples/pdf/links_and_outlines.pdf). ## How it works - `s.link(rect, target, contents)` puts a clickable box over each label on the title page. A target is a web address (`uri`), a page by its index from 0, or a named destination. See [Pages and coordinates](/concepts/pages-and-coordinates.md#pages-count-from-0). - `destination("chapter-2", 3)` names the fourth page, so a link or an outline entry can go there by name. When the builder finishes, a missing page fails with `page-index-out-of-range` and a missing name with `unknown-destination`. - On the other pages, the `link` option of `text` (`.link(…)` on the run in Rust) makes the blue run itself the link, with no box to work out. - `contents` describes a link in words, for viewers and screen readers. Accessible PDFs need one on every link. - `outline` builds the bookmarks. "Chapters" has no target, so it's a heading, shown open and in bold with both chapters inside it. - `pageLabels` numbers the pages i and ii, then 1 and 2 from the third page. `pageMode: "outlines"` asks the viewer to open the file with the outline showing. ## What to change - **Where a page link lands:** add a `fit` to the target, such as `fit: { type: "width" }` (`Fit::Width` in Rust) to fill the window with the page's width. `destination` takes one too. - **The page numbers:** a label also takes a `prefix` and a `first` number, so `{ start: 2, style: "decimal", prefix: "A-" }` numbers the chapters A-1 and A-2. - **The panel on open:** `pageMode` also takes `"thumbnails"`, `"attachments"` or `"none"`. - **A title and an author:** add `metadata({ title, author })`, and `displayDocTitle: true` in the viewer preferences so viewers show the title instead of the file name. See [Links, outlines and metadata](/guides/links-outlines-and-metadata.md). Next: [generate, then edit](/recipes/generate-then-edit.md), which opens a PDF you made and changes its metadata. --- # Generate, then edit Build a one-page draft, open its bytes, retitle it in one transaction and save the edit as a revision after the original bytes. You'll generate a one-page draft report, then open its bytes, retitle it and save the edit as a revision after them. You need this whenever your app changes a PDF it already has, such as a file a user uploads or one it wrote earlier. One function builds and saves the draft. Another opens those bytes, changes the title and author, and saves again. In TypeScript the draft needs the text module, `init({ text: true })`. Both languages write the same bytes: `packages/sdk/examples/generate-then-edit.ts`: ```ts // Generate a document, then open its bytes, edit them and save incrementally. The edit changes // the Info dictionary in one transaction, and the incremental save keeps the generated bytes as // they are, with one revision appended. import { Builder, type BuilderOptions, Document, Font } from "@laminapdf/sdk"; /** * The generated file followed by the edit's revision. Needs `init({ text: true })`. * * @param options - can fix the file ID, the producer and the creation date. * @param editedAt - the modification date to record, as a PDF date string. */ export function generateThenEdit(options: BuilderOptions = {}, editedAt?: string): Uint8Array { return edit(generate(options), editedAt); } /** A one-page draft, saved (a new document is always saved as a full rewrite). */ export function generate(options: BuilderOptions = {}): Uint8Array { using b = new Builder(options); 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" }); using doc = b.finish(); return doc.save(); } /** Opens `bytes`, edits the Info dictionary in one transaction (which `undo` could revert) and saves incrementally. */ function edit(bytes: Uint8Array, editedAt?: string): Uint8Array { using doc = Document.open(bytes); doc.transact((tx) => tx.setInfo({ title: "Final report", author: "Ada Lovelace" }, editedAt)); return doc.save({ mode: "incremental" }); } ``` `crates/laminapdf/examples/generate_then_edit.rs`: ````rust //! Generate a document, then open its bytes, edit them and save incrementally. //! //! The edit changes the Info dictionary with an op (undoable and serialisable), and the //! incremental save keeps the generated bytes as they are, with one revision appended. //! //! ```sh //! cargo run -p laminapdf --example generate_then_edit -- [out-dir] # writes /generate_then_edit.pdf //! ``` use std::error::Error; use std::path::PathBuf; use laminapdf::{ BuildOptions, Builder, Document, InfoPatch, Metadata, Op, OpenOptions, PageSize, PdfDate, PointPt, Pt, SaveMode, SaveOptions, SetInfo, Standard14, }; fn main() -> Result<(), Box> { let out = PathBuf::from(std::env::args().nth(1).unwrap_or_else(|| "target/examples-out".into())); std::fs::create_dir_all(&out)?; let bytes = build(BuildOptions::new(), None)?; std::fs::write(out.join("generate_then_edit.pdf"), bytes)?; Ok(()) } /// The generated file followed by the edit's revision. `edited_at` is recorded as the /// modification date; `options` can fix the file ID, the producer and the creation date. pub(crate) fn build(options: BuildOptions, edited_at: Option) -> Result, Box> { let generated = generate(options)?; edit(generated, edited_at) } /// A one-page draft, saved (a new document is always saved as a full rewrite). pub(crate) fn generate(options: BuildOptions) -> Result, Box> { let mut b = Builder::new(options); let helvetica = b.standard_font(Standard14::Helvetica)?; let mut page = b.page(PageSize::A4); 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")); Ok(b.finish()?.save(&SaveOptions::new())?.bytes) } /// Opens `bytes`, edits the Info dictionary in one transaction (which `undo` could revert) and /// saves incrementally: `bytes` unchanged, then the appended revision. fn edit(bytes: Vec, edited_at: Option) -> Result, Box> { let mut doc = Document::open_bytes(bytes, OpenOptions::new())?; let patch = InfoPatch::new() .title(Some("Final report")) .author(Some("Ada Lovelace")); doc.apply(vec![Op::from(SetInfo::new(patch, edited_at))])?; let options = SaveOptions::new().with_mode(SaveMode::Incremental); Ok(doc.save(&options)?.bytes) } ```` The result is the draft's bytes, unchanged, followed by one revision that retitles it "Final report" and names Ada Lovelace as its author: [open the reference PDF](/examples/pdf/generate_then_edit.pdf). ## How it works - A new document has no original bytes to append to, so the draft's save is a [full rewrite](/reference/glossary.md#full-rewrite). - `Document.open` (`Document::open_bytes` in Rust) reads the draft. Your edits sit beside its bytes and never change them; see [How Lamina PDF thinks about a PDF](/concepts/how-lamina-thinks.md). - `setInfo` changes the Info dictionary: the title, author and other facts a viewer shows in a file's properties. `transact` applies it as one transaction, which `undo` reverts as a whole. In Rust, `apply` takes the list of ops as one transaction. - `mode: "incremental"` (`SaveMode::Incremental`) asks for an [incremental save](/reference/glossary.md#incremental-save): the draft's bytes first, then one revision with the edit. - `editedAt` is the modification date to record, a PDF date such as `D:20261030120000Z`. Leave it out and no date is written, because Lamina PDF never reads the clock. - The builder also wrote the draft's title into its XMP metadata, an XML copy of the same facts. `setInfo` leaves that copy alone, so it still says "Draft report". ## What to change - **More edits in the same revision:** add them inside the callback, such as `tx.rotatePage(doc.page(0).id, 90)` (`RotatePage` in Rust). See [Edit a PDF](/guides/edit-a-pdf.md). - **Removing an entry:** pass `null` for it, as in `{ author: null }` (`.author(None)` in Rust). - **Your own file:** open its bytes in place of the draft's. A signed, repaired or encrypted file limits how it saves; [Save a PDF](/guides/save-a-pdf.md#when-a-file-refuses-a-save) lists the cases. - **A whole new file:** pass `mode: "fullRewrite"` (`SaveMode::FullRewrite`), or leave `mode` out and let the save choose. See [Choose a save mode](/guides/save-a-pdf.md#choose-a-save-mode). - **Edits stored as JSON:** replay a saved log with `doc.applyLog({ v: 1, ops })` (`apply_log` in Rust), as [Edit a PDF](/guides/edit-a-pdf.md#replay-edits-stored-as-json) shows. Next: [a PDF/UA report](/recipes/pdf-ua-report.md), an accessible report checked as it's built. --- # Extract text for an LLM Turn a PDF into Markdown in reading order, JSON with a box for every block, and chunks sized for retrieval, in TypeScript and Rust. You'll turn a PDF into three outputs an LLM or a search index can use: Markdown in reading order, JSON giving every paragraph and table its page and box, and 400-token chunks. In TypeScript the function returns the three outputs and needs the extract module, `init({ extract: true })`. In Rust, the program instead extracts once and writes the three files. Both give the same JSON: `packages/sdk/examples/extract-markdown.ts`: ```ts // Extract a PDF's text for an LLM: Markdown in reading order, JSON with a box for every block, // and chunks sized for retrieval. // // Tables drawn with lines come out as Markdown tables, and text a reader can't see (white, // invisible or off the page) is left out of all three. import { type Chunk, Document, type Extraction } from "@laminapdf/sdk"; /** What a PDF gives an LLM or a search index. */ export interface ForAnLlm { /** The text in reading order, with a `` comment before each page. */ readonly markdown: string; /** Every block with its page and box, the text left out, the file's warnings and the chunks. */ readonly json: Extraction; /** About 400 tokens each, a new one at each heading. */ readonly chunks: readonly Chunk[]; } /** The Markdown, the JSON and the chunks of `bytes`. Needs `init({ extract: true })`. */ export function extractForAnLlm(bytes: Uint8Array): ForAnLlm { using doc = Document.open(bytes); // Page markers let a reader cite the page a passage came from. const markdown = doc.extract({ format: "markdown", pageMarkers: true }); // The JSON carries the same chunks, and each block's place in the Markdown. const json = doc.extract({ format: "json", pageMarkers: true, chunk: { maxTokens: 400 } }); return { markdown, json, chunks: json.chunks ?? [] }; } ``` `crates/laminapdf/examples/extract_markdown.rs`: ````rust //! Extract a PDF's text for an LLM: Markdown in reading order, JSON with a box for every //! block, and chunks sized for retrieval. //! //! Tables drawn with lines come out as Markdown tables, and text a reader can't see (white, //! invisible or off the page) is left out of all three. //! //! ```sh //! cargo run -p laminapdf --example extract_markdown -- [out-dir] //! # writes /.md, .json and .chunks.jsonl //! ``` use std::error::Error; use std::path::{Path, PathBuf}; use laminapdf::extract::{ChunkOptions, ExtractOptions, MarkdownOptions, chunks_jsonl}; use laminapdf::{Document, OpenOptions}; fn main() -> Result<(), Box> { let mut args = std::env::args().skip(1); let input = PathBuf::from(args.next().ok_or("usage: extract_markdown [out-dir]")?); let out = PathBuf::from(args.next().unwrap_or_else(|| "target/examples-out".into())); std::fs::create_dir_all(&out)?; let stem = input.file_stem().and_then(|s| s.to_str()).unwrap_or("document"); write_outputs(&std::fs::read(&input)?, &out, stem) } /// Writes the Markdown, the JSON and the chunks of `bytes` into `out`. pub(crate) fn write_outputs(bytes: &[u8], out: &Path, stem: &str) -> Result<(), Box> { let doc = Document::open_bytes(bytes.to_vec(), OpenOptions::new())?; let extraction = doc.extract(&ExtractOptions::new())?; // Page markers let a reader cite the page a passage came from. let markdown = extraction.to_markdown(&MarkdownOptions::new().with_page_markers(true)); std::fs::write(out.join(format!("{stem}.md")), &markdown.text)?; // About 400 tokens a chunk, starting a new one at each heading. let chunks = extraction.chunks(&markdown, &ChunkOptions::new().with_max_tokens(400)); std::fs::write(out.join(format!("{stem}.chunks.jsonl")), chunks_jsonl(&chunks))?; let json = doc.extract_json(&extraction, &markdown, Some(chunks)); std::fs::write(out.join(format!("{stem}.json")), json)?; Ok(()) } ```` Run the Rust program on any PDF: `cargo run -p laminapdf --example extract_markdown -- report.pdf out`. ## How it works - `extract` (`doc.extract` in TypeScript) reads every page's text, joins it into lines and paragraphs, and puts the paragraphs in reading order. A two-column page reads down the left column, then the right; a title across both columns comes first. - Tables drawn with lines become table blocks with their cells, and Markdown writes them as tables. A cell that spans columns or rows makes the table HTML, since Markdown tables can't span. - Text a reader can't see never reaches the outputs: invisible text (such as the hidden layer of a scanned page), text off the page and text too small to read. The JSON lists each such run under `dropped`, with the reason and how many characters it held. - `to_markdown` (`format: "markdown"` in TypeScript) writes headings, paragraphs and tables, with a `` comment before each page, so a passage can be traced to its page. - `chunks` (`format: "chunks"`, or the `chunk` option with the JSON) groups whole paragraphs and tables up to the budget, starting a new chunk at each heading. Each chunk records the blocks, pages and boxes it came from, and where its text sits in the Markdown. - `extract_json` (`format: "json"`) writes the `laminapdf.extract/1` document: the pages, every block with its box in points from the top-left corner, the dropped text, the file's warnings and the chunks. ## What to change - **Only some pages:** `pages: [0, 1]` in TypeScript, `ExtractOptions::new().with_pages(vec![0, 1])` in Rust (page indexes start at 0). - **Tables without lines:** `tables: "lattice-and-stream"` (`.with_tables(TableMode::LatticeAndStream)`) also looks for text lined up in columns. It finds more tables, and now and then a pair of columns that isn't one. - **Bigger or smaller chunks:** change `maxTokens` (`with_max_tokens`). A token here is about four characters, a rough count that runs low for code, numbers and scripts other than Latin, so leave room. - **Overlap between chunks:** `overlap: 80` (`.with_overlap(80)`) repeats up to about 80 tokens of whole paragraphs from the end of the previous chunk. - **One output (TypeScript):** set `format` to `"markdown"`, `"text"`, `"json"` or `"chunks"`. - **A long file in steps (Rust):** `doc.extract_job(&options)` and `step` do a page per unit of work, so a long file can report progress or stop between steps. Next: [all recipes](/recipes.md). --- # A PDF/UA report A one-page sales report a screen reader can follow, with headings, a table, a described chart and a link, checked against PDF/UA-1 as it's built. You'll build a one-page sales report that a screen reader can follow: headings, a table, a chart described in words and a link. You need this when a document has to meet accessibility law or a client's accessibility policy. The function turns on the PDF/UA-1 profile, then draws each part of the report inside a tag that says what it is: a heading, a table cell, a figure. In TypeScript it needs the text module, `init({ text: true })`. Both languages write the same bytes: `packages/sdk/examples/pdf-ua-report.ts`: ```ts // An accessible report that screen readers can follow, checked against PDF/UA-1 when it is built. import { Builder, Font, type Surface, type Tag, type TagName } from "@laminapdf/sdk"; const MARGIN = 56; const ROW = 18; const COLUMN = 144; const HEADER_BASELINE = 40; const TITLE_BASELINE = 90; const TABLE_TOP = 150; const LINK_BASELINE = 400; const CHART_LEFT = 320; const CHART_BASE = 360; const BAR_WIDTH = 36; const BAR_PITCH = 50; const BODY = 11; /** The font sizes of the title and the headings; other text is `BODY`. */ const SIZES: Partial> = { H1: 22, H2: 15 }; const BLUE = [0.12, 0.47, 0.78] as const; const QUARTERS = [ ["Q1", 120], ["Q2", 135], ["Q3", 160], ["Q4", 150], ] as const; const CHART_ALT = "Bar chart of sales by quarter: 120, 135, 160 and 150 thousand euros."; /** * The report's bytes. Needs `init({ text: true })`. * * @param notoSans - the bytes of the Noto Sans font file. */ export function pdfUaReport(notoSans: Uint8Array): Uint8Array { using b = new Builder({ profiles: ["PDF/UA-1"] }); b.metadata({ title: "Annual sales report 2026" }); b.lang("en-GB"); const font = Font.fromBytes(notoSans); b.font(font); const page = b.page("A4"); draw(page.surface(), font); page.finish(); using doc = b.finish(); return doc.save(); } /** One line of text with its baseline at `y`, as the structure element `tag`, sized by it. */ function line(s: Surface, font: Font, tag: Tag, text: string, x: number, y: number): void { const size = (typeof tag === "string" ? SIZES[tag] : undefined) ?? BODY; s.tag(tag, (s) => s.text(text, { font, size, at: [x, y] })); } function draw(s: Surface, font: Font): void { // The running header is decoration: an artifact, which screen readers skip. s.artifact("pagination", (s) => s.text("Acme Widgets", { font, size: BODY, at: [MARGIN, HEADER_BASELINE] })); line(s, font, "H1", "Annual sales report 2026", MARGIN, TITLE_BASELINE); line(s, font, "P", "Sales grew in three quarters of four.", MARGIN, TITLE_BASELINE + 2 * ROW); table(s, font); chart(s); const link = { target: { type: "uri", uri: "https://example.com/accounts" }, contents: "The full 2026 accounts", } as const; s.tag("P", (s) => s.text("The full accounts", { font, size: BODY, at: [MARGIN, LINK_BASELINE], fill: [...BLUE], link }), ); } /** A heading and a table of the quarters, whose first row holds the columns' header cells. */ function table(s: Surface, font: Font): void { line(s, font, "H2", "Sales by quarter", MARGIN, TABLE_TOP); const header: Tag = { type: "TH", scope: "column" }; s.tag("Table", (s) => { s.tag("TR", (s) => { line(s, font, header, "Quarter", MARGIN, TABLE_TOP + ROW); line(s, font, header, "Sales (k€)", MARGIN + COLUMN, TABLE_TOP + ROW); }); QUARTERS.forEach(([quarter, sales], i) => { const y = TABLE_TOP + ROW * (i + 2); s.tag("TR", (s) => { line(s, font, "TD", quarter, MARGIN, y); line(s, font, "TD", sales.toFixed(0), MARGIN + COLUMN, y); }); }); }); } /** The quarters as bars standing on the chart's base: one figure with alternate text. */ function chart(s: Surface): void { s.tag({ type: "Figure", alt: CHART_ALT }, (s) => { s.fillColor([...BLUE]); QUARTERS.forEach(([, sales], i) => { s.path((p) => p.rect([CHART_LEFT + BAR_PITCH * i, CHART_BASE - sales, BAR_WIDTH, sales])).fill(); }); }); } ``` `crates/laminapdf/examples/pdf_ua_report.rs`: ```rust //! An accessible report that screen readers can follow, checked against PDF/UA-1 when it is built. use laminapdf::{ ArtifactKind, BuildError, BuildOptions, Builder, Color, FillRule, FontHandle, LinkTarget, Metadata, PageSize, PointPt, Profile, Pt, RectPt, SaveOptions, Surface, TableScope, Tag, TagKind, }; const MARGIN: f64 = 56.0; const ROW: f64 = 18.0; const COLUMN: f64 = 144.0; const HEADER_AT: PointPt = PointPt::from_points(MARGIN, 40.0); const TITLE_BASELINE: f64 = 90.0; const TABLE_TOP: f64 = 150.0; const LINK_AT: PointPt = PointPt::from_points(MARGIN, 400.0); const CHART_LEFT: f64 = 320.0; const CHART_BASE: f64 = 360.0; const BAR_WIDTH: Pt = Pt::new(36.0); const BAR_PITCH: f64 = 50.0; const BODY: Pt = Pt::new(11.0); /// The font sizes of the title and the headings; other text is `BODY`. const SIZES: [(TagKind, Pt); 2] = [(TagKind::H1, Pt::new(22.0)), (TagKind::H2, Pt::new(15.0))]; const BLUE: Color = Color::Rgb(0.12, 0.47, 0.78); const QUARTERS: [(&str, f64); 4] = [("Q1", 120.0), ("Q2", 135.0), ("Q3", 160.0), ("Q4", 150.0)]; const CHART_ALT: &str = "Bar chart of sales by quarter: 120, 135, 160 and 150 thousand euros."; fn main() -> Result<(), Box> { let mut args = std::env::args().skip(1); let font = std::fs::read(args.next().ok_or("arguments: [out-dir]")?)?; let out = std::path::PathBuf::from(args.next().unwrap_or_else(|| "target/examples-out".into())); std::fs::create_dir_all(&out)?; Ok(std::fs::write(out.join("pdf_ua_report.pdf"), build(font)?)?) } pub(crate) fn build(font: Vec) -> Result, Box> { let mut b = Builder::new(BuildOptions::new().with_profile(Profile::PdfUa1)); b.metadata(Metadata::default().with_title("Annual sales report 2026")); b.lang("en-GB"); let font = b.font_bytes(font)?; let mut page = b.page(PageSize::A4); draw(page.surface(), &font)?; page.finish()?; Ok(b.finish()?.save(&SaveOptions::new())?.bytes) } /// One line of text with its baseline at `y`, as the structure element `tag`, sized by it. fn line(s: &mut Surface, font: &FontHandle, tag: impl Into, text: &str, x: f64, y: f64) -> Result<(), BuildError> { let tag = tag.into(); let size = SIZES.iter().find(|e| e.0 == tag.kind()).map_or(BODY, |e| e.1); s.tag(tag, |s| s.text(text, font, size, PointPt::from_points(x, y)).map(drop)) } fn draw(s: &mut Surface, font: &FontHandle) -> Result<(), BuildError> { // The running header is decoration: an artifact, which screen readers skip. let header = |s: &mut Surface| s.text("Acme Widgets", font, BODY, HEADER_AT).map(drop); s.artifact(ArtifactKind::Pagination, header)?; line(s, font, TagKind::H1, "Annual sales report 2026", MARGIN, TITLE_BASELINE)?; let summary = "Sales grew in three quarters of four."; line(s, font, TagKind::P, summary, MARGIN, TITLE_BASELINE + 2.0 * ROW)?; table(s, font)?; chart(s)?; let link = LinkTarget::Uri("https://example.com/accounts".into()); s.tag(TagKind::P, |s| { let text = s.text("The full accounts", font, BODY, LINK_AT)?; text.fill(BLUE).link(link, Some("The full 2026 accounts".into())); Ok(()) }) } /// A heading and a table of the quarters, whose first row holds the columns' header cells. fn table(s: &mut Surface, font: &FontHandle) -> Result<(), BuildError> { line(s, font, TagKind::H2, "Sales by quarter", MARGIN, TABLE_TOP)?; let header = Tag::new(TagKind::TH).with_scope(TableScope::Column); s.tag(TagKind::Table, |s| { s.tag(TagKind::TR, |s| { line(s, font, header.clone(), "Quarter", MARGIN, TABLE_TOP + ROW)?; line(s, font, header.clone(), "Sales (k€)", MARGIN + COLUMN, TABLE_TOP + ROW) })?; for (row, (quarter, sales)) in (2_u8..).zip(QUARTERS) { let y = TABLE_TOP + ROW * f64::from(row); s.tag(TagKind::TR, |s| { line(s, font, TagKind::TD, quarter, MARGIN, y)?; line(s, font, TagKind::TD, &format!("{sales:.0}"), MARGIN + COLUMN, y) })?; } Ok(()) }) } /// The quarters as bars standing on the chart's base: one figure with alternate text. fn chart(s: &mut Surface) -> Result<(), BuildError> { s.tag(Tag::new(TagKind::Figure).with_alt(CHART_ALT), |s| { s.fill_color(BLUE); for (i, (_, sales)) in (0_u8..).zip(QUARTERS) { let x = CHART_LEFT + BAR_PITCH * f64::from(i); let bar = RectPt::from_xywh(Pt::new(x), Pt::new(CHART_BASE - sales), BAR_WIDTH, Pt::new(sales)); s.path(|p| _ = p.rect(bar)).fill(FillRule::NonZero); } Ok(()) }) } ``` The result is one A4 page that reads, in order, as a title, a summary, a table of four quarters, a bar chart and a link: [open the reference PDF](/examples/pdf/pdf_ua_report.pdf). ## How it works - `profiles: ["PDF/UA-1"]` (`Profile::PdfUa1` in Rust) tags the document and makes `finish()` check the rules a program can check. It fails with a coded error for the first rule the report breaks; [Accessible PDFs](/guides/accessible-pdfs.md#what-pdfua-asks-for) lists them. - `metadata` gives the report its title and `lang` its language, which PDF/UA-1 asks for. The font is an embedded Noto Sans, because PDF/UA-1 refuses the [standard 14 fonts](/reference/glossary.md#standard-14-fonts). - `tag` puts what its callback draws inside a structure element, such as `H1`, `P` or `TD`, in reading order. The `line` helper tags one line of text and sizes it by its tag: 22 points for `H1`, 15 for `H2`, 11 for the rest. - The table's first row holds `TH` header cells with a column scope, so a screen reader can name each cell's column. - The four bars are one `Figure` with alternate text, the sentence a screen reader reads in place of the drawing. The running header is an artifact, which screen readers skip. - The link's `contents`, "The full 2026 accounts", is its description. The builder makes the link a `Link` element inside its paragraph. ## What to change - **The numbers:** change `QUARTERS`, then change `CHART_ALT` to match. The builder checks that the chart has alternate text, not that the text is right; see [What a program can't check](/guides/accessible-pdfs.md#what-a-program-cant-check). - **A list:** nest an `"L"`, an `"LI"` for each item, and inside each a `"Lbl"` for the bullet and an `"LBody"` for the text. An `L` holds `LI` items or a nested `L`, and an `LI` holds a `Lbl` and an `LBody`: anything else inside them fails `finish()` with `invalid-structure-nesting`. - **A passage in another language:** tag it with its own, as in `{ type: "P", lang: "fr-FR" }` (`Tag::new(TagKind::P).with_lang("fr-FR")` in Rust). - **Accessible and archival at once:** add a PDF/A profile beside it, as in `profiles: ["PDF/A-2u", "PDF/UA-1"]`. The [PDF/A archive](/recipes/pdf-a-archive.md) does this. Next: [a PDF/A archive](/recipes/pdf-a-archive.md), a letter written for long-term keeping. --- # A PDF/A archive An order confirmation for long-term archiving, written as PDF/A-2b and as PDF/A-2u that is also PDF/UA-1, with an embedded font and a photo. You'll write a letter confirming an order as a PDF/A file, which looks the same in any viewer for decades. You need it for documents kept for years, often by law: contracts, records, letters to customers. The recipe writes the letter twice, at two PDF/A levels. The function takes the level, `2b` or `2u`, and builds the same letter under that level's profiles. In TypeScript it needs the text module, `init({ text: true })`. Both languages write the same bytes: `packages/sdk/examples/pdf-a-archive.ts`: ```ts // An archival letter, written as PDF/A-2b and as PDF/A-2u that is also PDF/UA-1. import { Builder, Font, Image, PAGE_SIZES, type Profile, type Surface } from "@laminapdf/sdk"; const MARGIN = 56; const BAND_HEIGHT = 70; const TITLE_BASELINE = 110; /** The top of the letter's text and of the photo beside it, and the height of both. */ const BLOCK_TOP = 130; const BLOCK_HEIGHT = 120; const LETTER_WIDTH = 300; const PHOTO_LEFT = 380; const PHOTO_WIDTH = 160; const FOOTER_BASELINE = 300; const TITLE = 22; const BODY = 11; const BAND_OPACITY = 0.35; const PHOTO_ALT = "A photograph of the sprockets, packed in a box"; const LETTER = "Thank you for your order of 12 sprockets. This letter confirms the delivery date and the price we agreed. " + "Keep it: PDF/A files look the same in any viewer, now and in the future."; /** PDF/A-2b, or PDF/A-2u (whose text maps to Unicode) with PDF/UA-1. */ const PROFILES: Record<"2b" | "2u", Profile[]> = { "2b": ["PDF/A-2b"], "2u": ["PDF/A-2u", "PDF/UA-1"], }; /** * The letter's bytes. Needs `init({ text: true })`. * * @param level - `2b` or `2u`. * @param notoSans - the bytes of the Noto Sans font file. * @param photo - the bytes of a JPEG photo. */ export function pdfAArchive(level: "2b" | "2u", notoSans: Uint8Array, photo: Uint8Array): Uint8Array { using b = new Builder({ profiles: PROFILES[level], tagged: true }); b.metadata({ title: "Order confirmation 42", author: "Acme Widgets Ltd" }); b.lang("en-GB"); const font = Font.fromBytes(notoSans); b.font(font); const page = b.page("A4"); draw(page.surface(), font, Image.jpeg(photo)); page.finish(); using doc = b.finish(); return doc.save(); } function draw(s: Surface, font: Font, photo: Image): void { // A translucent band across the top: decoration, and transparency, which PDF/A-2 allows. const [width] = PAGE_SIZES.A4; s.artifact("layout", (s) => { s.save().opacity(BAND_OPACITY, 1).fillColor([0.12, 0.47, 0.78]); s.path((p) => p.rect([0, 0, width, BAND_HEIGHT])).fill(); s.restore(); }); s.tag("H1", (s) => s.text("Order confirmation 42", { font, size: TITLE, at: [MARGIN, TITLE_BASELINE] })); s.tag("P", (s) => s.textBox(LETTER, { font, size: BODY, box: [MARGIN, BLOCK_TOP, LETTER_WIDTH, BLOCK_HEIGHT] })); s.image(photo, [PHOTO_LEFT, BLOCK_TOP, PHOTO_WIDTH, BLOCK_HEIGHT], PHOTO_ALT); s.tag("P", (s) => s.text("Acme Widgets Ltd, 1 Example Street, London", { font, size: BODY, at: [MARGIN, FOOTER_BASELINE] }), ); } ``` `crates/laminapdf/examples/pdf_a_archive.rs`: ```rust //! An archival letter, written as PDF/A-2b and as PDF/A-2u that is also PDF/UA-1. //! //! Run it with `cargo run -p laminapdf --example pdf_a_archive -- [out-dir]`: it writes //! `pdf_a_archive-2b.pdf` and `pdf_a_archive-2u.pdf`. use std::error::Error; use std::path::PathBuf; use laminapdf::{ ArtifactKind, BuildError, BuildOptions, Builder, Color, FillRule, FontHandle, Image, Metadata, PageSize, PointPt, Profile, Pt, RectPt, SaveOptions, Surface, TagKind, TextBoxOptions, }; const MARGIN: f64 = 56.0; const BAND_HEIGHT: f64 = 70.0; const TITLE_AT: PointPt = PointPt::from_points(MARGIN, 110.0); /// The top of the letter's text and of the photo beside it, and the height of both. const BLOCK_TOP: f64 = 130.0; const BLOCK_HEIGHT: f64 = 120.0; const LETTER_WIDTH: f64 = 300.0; const PHOTO_LEFT: f64 = 380.0; const PHOTO_WIDTH: f64 = 160.0; const FOOTER_AT: PointPt = PointPt::from_points(MARGIN, 300.0); const TITLE: Pt = Pt::new(22.0); const BODY: Pt = Pt::new(11.0); const BAND_OPACITY: f64 = 0.35; const PHOTO_ALT: &str = "A photograph of the sprockets, packed in a box"; const LETTER: &str = "Thank you for your order of 12 sprockets. This letter confirms the delivery date and the \ price we agreed. Keep it: PDF/A files look the same in any viewer, now and in the future."; fn main() -> Result<(), Box> { let mut args = std::env::args().skip(1); let usage = "arguments: [out-dir]"; let font = std::fs::read(args.next().ok_or(usage)?)?; let photo = std::fs::read(args.next().ok_or(usage)?)?; let out = PathBuf::from(args.next().unwrap_or_else(|| "target/examples-out".into())); std::fs::create_dir_all(&out)?; let a2b = build(Level::A2b, font.clone(), photo.clone())?; std::fs::write(out.join("pdf_a_archive-2b.pdf"), a2b)?; let a2u = build(Level::A2u, font, photo)?; Ok(std::fs::write(out.join("pdf_a_archive-2u.pdf"), a2u)?) } /// Which archive to write. #[derive(Debug, Clone, Copy)] pub(crate) enum Level { /// PDF/A-2b: the letter looks the same everywhere. A2b, /// PDF/A-2u, whose text maps to Unicode, and PDF/UA-1. A2u, } /// The letter's bytes, in `font` (a TrueType or OpenType file) with `photo` (a JPEG). pub(crate) fn build(level: Level, font: Vec, photo: Vec) -> Result, Box> { let options = match level { Level::A2b => BuildOptions::new().with_profile(Profile::PdfA2b), Level::A2u => BuildOptions::new() .with_profile(Profile::PdfA2u) .with_profile(Profile::PdfUa1), }; let mut b = Builder::new(options.with_tagged(true)); b.metadata( Metadata::default() .with_title("Order confirmation 42") .with_author("Acme Widgets Ltd"), ); b.lang("en-GB"); let font = b.font_bytes(font)?; let photo = Image::jpeg(photo)?; let mut page = b.page(PageSize::A4); draw(page.surface(), &font, &photo)?; page.finish()?; let mut doc = b.finish()?; Ok(doc.save(&SaveOptions::new())?.bytes) } fn draw(s: &mut Surface, font: &FontHandle, photo: &Image) -> Result<(), BuildError> { // A translucent band across the top: decoration, and transparency, which PDF/A-2 allows. let width = PageSize::A4.width.get(); s.artifact(ArtifactKind::Layout, |s| { s.save() .opacity(BAND_OPACITY, 1.0) .fill_color(Color::rgb(0.12, 0.47, 0.78)); s.path(|p| _ = p.rect(RectPt::from_points(0.0, 0.0, width, BAND_HEIGHT))) .fill(FillRule::NonZero); s.restore(); Ok(()) })?; s.tag(TagKind::H1, |s| { s.text("Order confirmation 42", font, TITLE, TITLE_AT).map(drop) })?; let body = RectPt::from_points(MARGIN, BLOCK_TOP, LETTER_WIDTH, BLOCK_HEIGHT); s.tag(TagKind::P, |s| { s.text_box(LETTER, font, BODY, body, &TextBoxOptions::new()).map(drop) })?; let beside = RectPt::from_points(PHOTO_LEFT, BLOCK_TOP, PHOTO_WIDTH, BLOCK_HEIGHT); s.figure(photo, beside, Some(PHOTO_ALT)); let footer = "Acme Widgets Ltd, 1 Example Street, London"; s.tag(TagKind::P, |s| s.text(footer, font, BODY, FOOTER_AT).map(drop)) } ``` The result is one A4 letter under a translucent blue band, with a photo beside its text: open the [PDF/A-2b](/examples/pdf/pdf_a_archive-2b.pdf) or the [PDF/A-2u](/examples/pdf/pdf_a_archive-2u.pdf) reference PDF. ## How it works - PDF/A-2b makes the letter look the same everywhere. PDF/A-2u also maps every glyph to Unicode, so the text can be searched and copied. That copy is PDF/UA-1 too, so a screen reader can follow it. - PDF/A needs every font embedded, so the letter uses Noto Sans rather than one of the [standard 14 fonts](/reference/glossary.md#standard-14-fonts). `finish()` checks rules like this one and fails with a coded error for the first one the letter breaks. - The builder writes what PDF/A asks for: the PDF/A identification in the file's XMP metadata, and an sRGB output intent that says what its RGB colours mean. See [What PDF/A asks for](/guides/archival-pdfs.md#what-pdfa-asks-for). - Both copies draw the same tagged content: an `H1` title, `P` paragraphs and the photo as a figure with alternate text. PDF/A-2b doesn't ask for tags, but PDF/UA-1 does. - The blue band is drawn at 35% opacity, as a layout artifact that screen readers skip. PDF/A-2 allows transparency. - `textBox` breaks the letter into lines inside its box, beside the photo. The JPEG goes into the file as it is. ## What to change - **The letter:** change `LETTER`. `textBox` leaves out lines that don't fit, so make `BLOCK_HEIGHT` larger for a longer letter, or pass `overflow: "error"` to fail with `text-overflow` instead. - **The photo:** pass any RGB or gray JPEG, or `Image.png(bytes)` for a PNG (`Image::png` in Rust). A CMYK JPEG fails `finish()` with `device-color-without-output-intent`, because the output intent is sRGB. - **Other profiles:** pass the ones you need, such as `["PDF/A-2u"]` alone or `["PDF/A-2b", "PDF/UA-1"]`. A document can claim PDF/UA-1 and at most one PDF/A profile. - **An attached file:** PDF/A-2 refuses attachments, so switch to PDF/A-3b, as the [PDF/A-3 invoice](/recipes/pdf-a3-invoice-with-xml.md) does. Next: [a PDF/A-3 invoice with XML](/recipes/pdf-a3-invoice-with-xml.md), with its data attached for software to read. --- # A PDF/A-3 invoice with XML A PDF/A-3b invoice that carries the same invoice as an attached XML file, for people to read and for software to process. You'll write a one-page invoice as PDF/A-3b, with the same invoice attached as XML. You need this for e-invoices such as Factur-X and ZUGFeRD, where a person reads the PDF and the buyer's software reads the XML. The function draws the invoice, writes the same items as XML and attaches that file before it finishes. In TypeScript it needs the text module, `init({ text: true })`. Both languages write the same bytes: `packages/sdk/examples/pdf-a3-invoice-with-xml.ts`: ```ts // A PDF/A-3b invoice that carries the same invoice as XML, attached for software to read. import { Builder, Font, PAGE_SIZES, type Surface } from "@laminapdf/sdk"; const MARGIN = 56; /** The page's right edge, less the margin: where the rule under the items ends. */ const RIGHT = PAGE_SIZES.A4[0] - MARGIN; const AMOUNTS = 460; const ROW = 22; const TITLE_BASELINE = 80; const FIRST_ROW = 130; /** * How far above the next row's baseline the rule sits, and how far below it the total and * the note are. */ const RULE_RISE = 7; const TOTAL_DROP = 18; const NOTE_DROP = 68; /** How far left of the amounts column the total starts, to fit its "Total" label. */ const TOTAL_INDENT = 40; const TITLE = 24; const TOTAL = 14; const BODY = 11; const ITEMS = [ ["Sprockets", 12, 1.25], ["Gadget", 1, 79.9], ["Delivery", 1, 4.5], ] as const; /** The invoice's total. */ function total(): number { return ITEMS.reduce((sum, [, qty, price]) => sum + qty * price, 0); } /** The invoice as XML, in a made-up schema (a real e-invoice follows EN 16931, as Factur-X does). */ function invoiceXml(): string { const lines = ITEMS.map( ([item, qty, price]) => ` \n`, ); return `\n\n${lines.join("")} ${total().toFixed(2)}\n\n`; } /** * The invoice's bytes. Needs `init({ text: true })`. * * @param notoSans - the bytes of the Noto Sans font file. */ export function pdfA3InvoiceWithXml(notoSans: Uint8Array): Uint8Array { using b = new Builder({ profiles: ["PDF/A-3b"] }); b.metadata({ title: "Invoice 42", author: "Acme Widgets Ltd" }); const font = Font.fromBytes(notoSans); b.font(font); const page = b.page("A4"); draw(page.surface(), font); page.finish(); b.attach("invoice-42.xml", new TextEncoder().encode(invoiceXml()), "text/xml", { relationship: "alternative", description: "Invoice 42 as XML", }); using doc = b.finish(); return doc.save(); } function draw(s: Surface, font: Font): void { s.text("Invoice 42", { font, size: TITLE, at: [MARGIN, TITLE_BASELINE] }); let y = FIRST_ROW; for (const [item, qty, price] of ITEMS) { s.text(`${qty} × ${item}`, { font, size: BODY, at: [MARGIN, y] }); s.text(`€ ${(qty * price).toFixed(2)}`, { font, size: BODY, at: [AMOUNTS, y] }); y += ROW; } const rule = y - RULE_RISE; s.strokeColor([0.5]) .path((p) => p.moveTo(MARGIN, rule).lineTo(RIGHT, rule)) .stroke(); s.text(`Total € ${total().toFixed(2)}`, { font, size: TOTAL, at: [AMOUNTS - TOTAL_INDENT, y + TOTAL_DROP] }); s.text("The invoice is attached as invoice-42.xml.", { font, size: BODY, at: [MARGIN, y + NOTE_DROP], link: { target: { type: "uri", uri: "https://example.com/invoices/42" }, contents: "Invoice 42 online" }, }); } ``` `crates/laminapdf/examples/pdf_a3_invoice_with_xml.rs`: ```rust //! A PDF/A-3b invoice that carries the same invoice as XML, attached for software to read. //! //! Run it with `cargo run -p laminapdf --example pdf_a3_invoice_with_xml -- [out-dir]`. use std::error::Error; use std::fmt::Write as _; use std::path::PathBuf; use laminapdf::{ AfRelationship, Attachment, BuildError, BuildOptions, Builder, Color, FontHandle, LinkTarget, Metadata, PageSize, PointPt, Profile, Pt, SaveOptions, StrokeStyle, Surface, }; const MARGIN: f64 = 56.0; /// The page's right edge, less the margin: where the rule under the items ends. const RIGHT: f64 = PageSize::A4.width.get() - MARGIN; const AMOUNTS: f64 = 460.0; const ROW: f64 = 22.0; const TITLE_BASELINE: f64 = 80.0; const FIRST_ROW: f64 = 130.0; /// How far above the next row's baseline the rule sits, and how far below it the total and /// the note are. const RULE_RISE: f64 = 7.0; const TOTAL_DROP: f64 = 18.0; const NOTE_DROP: f64 = 68.0; /// How far left of the amounts column the total starts, to fit its "Total" label. const TOTAL_INDENT: f64 = 40.0; const TITLE: Pt = Pt::new(24.0); const TOTAL: Pt = Pt::new(14.0); const BODY: Pt = Pt::new(11.0); const ITEMS: [(&str, u32, f64); 3] = [("Sprockets", 12, 1.25), ("Gadget", 1, 79.90), ("Delivery", 1, 4.50)]; fn main() -> Result<(), Box> { let mut args = std::env::args().skip(1); let font = std::fs::read(args.next().ok_or("arguments: [out-dir]")?)?; let out = PathBuf::from(args.next().unwrap_or_else(|| "target/examples-out".into())); std::fs::create_dir_all(&out)?; Ok(std::fs::write(out.join("pdf_a3_invoice_with_xml.pdf"), build(font)?)?) } /// The invoice's total. fn total() -> f64 { ITEMS.iter().map(|(_, qty, price)| f64::from(*qty) * price).sum() } /// The invoice as XML, in a made-up schema (a real e-invoice follows EN 16931, as Factur-X does). fn invoice_xml() -> Result { let mut xml = String::from("\n\n"); for (item, qty, price) in ITEMS { writeln!(xml, " ")?; } writeln!(xml, " {:.2}\n", total())?; Ok(xml) } /// The invoice's bytes, in `font` (a TrueType or OpenType file). pub(crate) fn build(font: Vec) -> Result, Box> { let mut b = Builder::new(BuildOptions::new().with_profile(Profile::PdfA3b)); b.metadata( Metadata::default() .with_title("Invoice 42") .with_author("Acme Widgets Ltd"), ); let font = b.font_bytes(font)?; let mut page = b.page(PageSize::A4); draw(page.surface(), &font)?; page.finish()?; let xml = Attachment::new("invoice-42.xml", invoice_xml()?.into_bytes(), "text/xml") .with_relationship(AfRelationship::Alternative) .with_description("Invoice 42 as XML"); b.attach(xml); let mut doc = b.finish()?; Ok(doc.save(&SaveOptions::new())?.bytes) } fn draw(s: &mut Surface, font: &FontHandle) -> Result<(), BuildError> { let at = PointPt::from_points; s.text("Invoice 42", font, TITLE, at(MARGIN, TITLE_BASELINE))?; let mut y = FIRST_ROW; for (item, qty, price) in ITEMS { s.text(&format!("{qty} × {item}"), font, BODY, at(MARGIN, y))?; let amount = f64::from(qty) * price; s.text(&format!("€ {amount:.2}"), font, BODY, at(AMOUNTS, y))?; y += ROW; } let rule = y - RULE_RISE; s.stroke_color(Color::gray(0.5)) .path(|p| _ = p.move_to(at(MARGIN, rule)).line_to(at(RIGHT, rule))) .stroke(&StrokeStyle::default()); let total_at = at(AMOUNTS - TOTAL_INDENT, y + TOTAL_DROP); s.text(&format!("Total € {:.2}", total()), font, TOTAL, total_at)?; let note = "The invoice is attached as invoice-42.xml."; let note = s.text(note, font, BODY, at(MARGIN, y + NOTE_DROP))?; let online = LinkTarget::Uri("https://example.com/invoices/42".into()); note.link(online, Some("Invoice 42 online".into())); Ok(()) } ``` The result is one A4 page with three line items and a total of € 99.40, carrying `invoice-42.xml`: [open the reference PDF](/examples/pdf/pdf_a3_invoice_with_xml.pdf). Viewers that show attachments list it there. ## How it works - `profiles: ["PDF/A-3b"]` (`Profile::PdfA3b` in Rust) asks for PDF/A-2b's rules and allows attached files of any kind. `finish()` fails with a coded error for the first rule the invoice breaks; see [Archival PDFs](/guides/archival-pdfs.md). - PDF/A needs every font embedded, so the invoice uses Noto Sans rather than one of the [standard 14 fonts](/reference/glossary.md#standard-14-fonts). - `invoiceXml` writes the line items and the total from the same `ITEMS` the page draws, so the PDF and the XML always agree. Its schema is made up for the recipe. - `attach` adds the file with its name, its bytes and its MIME type, `text/xml`. PDF/A needs a MIME type on every attachment, or `finish()` fails with `embedded-file-without-mime-type`. - `relationship: "alternative"` (`AfRelationship::Alternative`) says the XML is another form of the same content, and the builder associates it with the document. `description` is the text a viewer shows beside the file. ## What to change - **The items:** change `ITEMS`. The page, the total and the XML all follow. - **A real e-invoice:** replace `invoiceXml` with XML that follows EN 16931, the European e-invoicing standard, as Factur-X and ZUGFeRD do. Those formats also need XMP entries of their own, which the builder can't add yet. - **Other attachments:** attach the file the invoice was made from with `relationship: "source"`, or the data behind it with `"data"` (`AfRelationship::Source`, `AfRelationship::Data`). Each needs its own name and MIME type. - **Accessible too:** add `"PDF/UA-1"` to `profiles`, set `lang` and tag the content, as the [PDF/UA report](/recipes/pdf-ua-report.md) does. Next: [a 100-page report](/recipes/report.md), the case Lamina PDF times in every runtime. --- # A 100-page report A 100-page A4 report with two embedded fonts and a table on every page, the document Lamina PDF times in each JavaScript runtime. You'll make a 100-page report with two embedded fonts: on every page, a heading, a paragraph, a line of Chinese and a table of 20 products. Start from it for any long document you generate from data, such as monthly statements or a catalogue. The function draws one region's page at a time in a loop, then returns the PDF's bytes: `packages/sdk/examples/report.ts`: ```ts // A 100-page report with two embedded fonts. Each page has a heading, a paragraph broken into a // box, a line of Chinese, a table of products with rules between the rows, and a page number. import { Builder, type BuilderOptions, Font, PAGE_SIZES, type Rect, type Surface } from "@laminapdf/sdk"; /** The number of pages. */ const REPORT_PAGES = 100; const PRODUCTS = 20; const PRICE = 12.5; const [PAGE_WIDTH] = PAGE_SIZES.A4; const MARGIN = 56; const RIGHT_EDGE = PAGE_WIDTH - MARGIN; const HEADING_AT = [MARGIN, 72] as const; const PARAGRAPH_BOX = [MARGIN, 88, RIGHT_EDGE - MARGIN, 60] as const; const CHINESE_AT = [MARGIN, 166] as const; const FOOTER_AT = [MARGIN, 800] as const; const FIRST_ROW = 200; const ROW_HEIGHT = 24; /** The units' and the revenue's columns (left edge, width), both right-aligned. */ const UNITS_COLUMN = [300, 60] as const; const REVENUE_COLUMN = [400, RIGHT_EDGE - 400] as const; /** A cell's box starts this far above its row's baseline and is this tall; the rule is this far below. */ const CELL_RISE = 11; const CELL_HEIGHT = 14; const RULE_DROP = 7; /** Font sizes: the heading, the paragraph and the Chinese line, the table, the page number. */ const SIZES = { heading: 20, body: 11, table: 10, footer: 9 } as const; 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 latinFont - a font for the text, such as Noto Sans. * @param chineseFont - a font with Chinese glyphs, such as Noto Sans SC. * @param options - can fix the file ID, the producer and the creation date. */ export function report( latinFont: Uint8Array, chineseFont: Uint8Array, options: BuilderOptions = {}, ): Uint8Array { using b = new Builder(options); const font = Font.fromBytes(latinFont); const chinese = Font.fromBytes(chineseFont); b.font(font); b.font(chinese); for (let region = 1; region <= REPORT_PAGES; region++) { const page = b.page("A4"); const s = page.surface(); s.text(`Region ${region}: quarterly report`, { font, size: SIZES.heading, at: HEADING_AT }); s.textBox(PARAGRAPH, { font, size: SIZES.body, box: PARAGRAPH_BOX }); s.text("你好世界,这是中文字体子集测试。", { font: chinese, size: SIZES.body, at: CHINESE_AT }); products(s, font, region); s.text(`Page ${region} of ${REPORT_PAGES}`, { font, size: SIZES.footer, at: FOOTER_AT }); page.finish(); } b.metadata({ title: "Quarterly report", author: "lamina examples" }).lang("en-GB"); using doc = b.finish(); return doc.save(); } /** Made-up sales figures: the units of `product` that `region` shipped, from 0 to 499. */ function unitsShipped(region: number, product: number): number { return (region * 37 + product * 11) % 500; } /** A region's table: one row per product, with its units and revenue right-aligned. */ function products(s: Surface, font: Font, region: number): void { for (let product = 0; product < PRODUCTS; product++) { const y = FIRST_ROW + ROW_HEIGHT * product; const units = unitsShipped(region, product); const size = SIZES.table; s.text(`Product ${product + 1}`, { font, size, at: [MARGIN, y] }); s.textBox(String(units), { font, size, box: cell(UNITS_COLUMN, y), align: "end" }); s.textBox(`€ ${(units * PRICE).toFixed(2)}`, { font, size, box: cell(REVENUE_COLUMN, y), align: "end" }); s.strokeColor([0.8]) .path((p) => p.moveTo(MARGIN, y + RULE_DROP).lineTo(RIGHT_EDGE, y + RULE_DROP)) .stroke({ width: 0.5 }); } } /** The box of a cell in `column` (left edge, width) on the row at `baseline`. */ function cell([left, width]: readonly [number, number], baseline: number): Rect { return [left, baseline - CELL_RISE, width, CELL_HEIGHT]; } ``` The result is 100 A4 pages, one per region. It has no reference PDF to open. Instead, Lamina PDF's tests build and time the report in every JavaScript runtime and check its SHA-256 there, as does the Rust benchmark that builds the same bytes. ## How it works - It needs the text module, `init({ text: true })`, because it draws with embedded fonts. - `Font.fromBytes` reads each font file, and `b.font` registers both before the first page. Each font is embedded once for the whole report, with only the glyphs its 100 pages draw. - `s.text` draws one line, with `at[1]` as its baseline, and `s.textBox` breaks the paragraph into lines that fit `PARAGRAPH_BOX`. - In the table, the units and the revenue are text boxes as wide as their columns, with `align: "end"` to right-align them. `cell` works out each box from its column and the row's baseline. - `strokeColor([0.8])` sets a light gray, and each rule is a path from the left margin to the right edge, stroked 0.5 points wide. - `metadata` and `lang("en-GB")` set the title, author and language once for the document, and `using` frees the builder and the document when the function returns. ## What to change - **Your data:** replace `unitsShipped` with your own figures, and set `REPORT_PAGES` and `PRODUCTS` to your counts. - **A longer table:** 20 rows end at 656 points, above the page number at 800. For more rows, lower `ROW_HEIGHT` or carry the rest over to the next page. - **Another script:** load a font that covers it and draw its text in that font, as the Chinese line does. See [Text and fonts](/guides/fonts-and-text.md). - **The file's details:** pass `producer` and `creationDate` in `options`, such as `creationDate: "D:20261104120000Z"`. The builder never reads the clock, so a report without a date has none. Next: [open someone else's file](/recipes/open-untrusted.md), with a password and tighter limits. --- # Open someone else's file Open an uploaded PDF with an optional password and tighter limits, and get its page count and warnings, or the error, back as a value. You'll open a PDF from someone else, with a password if it needs one and tighter limits than the defaults, and get the outcome as a value. You need this wherever you accept uploads: a stranger's file can be damaged, locked or built to use up memory. The function opens the bytes under its limits and returns the page count and warning codes, or the error's code and hint: `packages/sdk/examples/open-untrusted.ts`: ```ts // Open bytes from someone else: an optional password, tighter limits than the defaults, and the // outcome as a value rather than an exception. The example writes nothing: it returns what it found. import { Document, isLaminaPdfError } from "@laminapdf/sdk"; const MiB = 1024 * 1024; /** Limits for an upload endpoint: each decoded stream, and all of them together. */ const LIMITS = { maxStreamDecoded: 16 * MiB, maxDecodedTotal: 64 * MiB } as const; /** 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` if the file needs one, under {@link LIMITS}. Needs `init()`. */ export function openUntrusted(bytes: Uint8Array, password?: string): Opened { try { using doc = Document.open(bytes, { ...(password === undefined ? {} : { password }), limits: LIMITS }); // 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) }; } catch (e) { // `password-required`, `wrong-password`, `not-pdf`, `limit-exceeded`, …: a coded error whose // message and hint never contain text from the file. Anything else is a bug: let it throw. if (isLaminaPdfError(e)) return { ok: false, code: e.code, hint: e.hint }; throw e; } } ``` It writes no PDF. The value it returns is the result: `{ ok: true, pages, warnings }` for a file that opened, or `{ ok: false, code, hint }`, such as `password-required` for a locked file opened without its password. ## How it works - It needs only `init()`: opening a file uses the base module, without fonts or text. - `Document.open` gets the password only when you pass one, and tries it against both of the file's passwords. See [Open password-protected files](/guides/password-protected-files.md). - `LIMITS` allows 16 MiB decoded from one stream and 64 MiB in all, against defaults of 64 MiB and 1 GiB. A file that needs more fails with `limit-exceeded`, and every other limit keeps its default. - A file can open and still carry warnings: each one names a repair or a dropped value, and the function returns their codes. - `isLaminaPdfError(e)` picks out Lamina PDF's coded errors, whose message and hint never contain text from the file. Any other error is a bug, so the function throws it again. - `using` frees the document as soon as the function returns, so the WebAssembly side keeps no copy of the file. ## What to change - **Ask for the password again:** on `password-required` or `wrong-password`, ask your user and call `openUntrusted` again with their answer. - **Name the limit:** on `limit-exceeded`, return `e.data?.limit` too. It names the limit the file went over as Rust spells it, such as `max_stream_decoded` for `maxStreamDecoded`. - **Other limits:** add any limit from the table in [Open files you don't trust](/guides/untrusted-files.md#hard-and-soft-limits), such as a lower `maxNestingDepth`. - **Refuse damaged files:** add `strict: true` to the options, and a file that needs repair fails with `strict` instead of opening with warnings. Next: [save in a Web Worker](/recipes/web-worker-client.md), so a large file never freezes the page. --- # Save in a Web Worker Open and save a PDF in a Web Worker, so a large file never freezes the page, with progress as it saves and a signal that stops the work. You'll save a PDF in a Web Worker, reporting progress as it goes and stopping when the user presses Cancel. You need this in a browser app once files get large, because a save on the main thread freezes the page until it ends. `saveInWorker` starts a Web Worker, opens the bytes there and saves them as a [full rewrite](/reference/glossary.md#full-rewrite), handing your `onProgress` and `signal` to each call: `packages/sdk/examples/worker-client.ts`: ```ts // Saving a PDF in a Web Worker, so a large file never blocks the page: `@laminapdf/sdk/worker` // runs the same API there, and each method returns a Promise. The save reports its progress step // by step, and aborting the signal (a Cancel button, say) stops the work. import { type CallOptions, connect } from "@laminapdf/sdk/worker"; /** What the save reports to and listens to. */ export type SaveInWorkerOptions = Pick; /** * Opens `bytes` in a Web Worker and saves them there as a full rewrite. * * @param bytes - any PDF; it is copied to the worker (`transfer: true` would move it). * @param options - `onProgress` is called after each step of the save. Aborting `signal` stops * the work wherever it is (starting the Web Worker, opening, or at the save's next step), and * the promise rejects with a `cancelled` error. * @returns the saved file. */ export async function saveInWorker(bytes: Uint8Array, options: SaveInWorkerOptions = {}): Promise { const client = await connect(options); try { await using doc = await client.open(bytes, options); return await doc.save({ mode: "fullRewrite" }, options); } finally { client.terminate(); } } ``` The promise resolves to the saved file, or rejects with `cancelled` once the signal aborts. Lamina PDF's tests run it in Chromium, Firefox and WebKit. ## How it works - `connect` starts the Web Worker file that ships with `@laminapdf/sdk` and checks that it comes from the same build as your page. - `client.open` copies the bytes into the Web Worker and returns a remote document, whose methods return promises. `await using` closes it there when the block ends. - The Web Worker saves in steps of 20,000 units of work, about one per object in a full rewrite. Between steps it calls `onProgress` with `done`, `total` and `phase`, at most every 50 ms, so a small file saves in one step and reports none. - Aborting the signal rejects the call at once with `cancelled`, and `error.cause` holds the signal's reason. A save stops at its next step. - `client.terminate()` in `finally` stops the Web Worker however the save ended, which also stops an open that was still running. ## What to change - **A progress bar:** in `onProgress`, set a `` element's `value` to `done` and, when the save knows it, its `max` to `total`. - **Large files:** pass `transfer: true` to `client.open` to move the buffer instead of copying it, which leaves your copy empty. See [Keep the page responsive](/guides/keep-the-page-responsive.md#move-bytes-instead-of-copying-them). - **Several files:** call `connect` once, reuse the client for each file, and terminate it when the page no longer needs it. - **Edits before the save:** apply them with `await doc.apply(ops)`, then save in the default `auto` mode. It appends them as an [incremental save](/reference/glossary.md#incremental-save) when the file allows one. See [Save a PDF](/guides/save-a-pdf.md#choose-a-save-mode). Next: [a Cloudflare Worker](/recipes/cloudflare-worker.md) that generates a PDF per request. --- # A Cloudflare Worker A Cloudflare Worker that returns a one-page letter per request, in a standard 14 font or a bundled one, and a 400 for a name the font can't draw. You'll make a Cloudflare Worker that returns a one-page letter to the name in its URL. You need this to make PDFs on request, such as tickets or receipts, without running a server of your own. The Cloudflare Worker draws the letter in Helvetica at `/?name=` and in a bundled Noto Sans at `/embedded?name=`, then returns the PDF: `packages/sdk/examples/worker/src/index.ts`: ```ts // A Worker that generates a one-page letter per request: at `/?name=` in a standard font, which // needs no font file, and at `/embedded?name=` in a font bundled with the Worker, which the PDF // embeds. A name the font can't draw gets a 400 with the error as JSON. The bundled font is a file // in `src/fonts/` that Wrangler bundles as bytes (wrangler.toml's `rules`); put the fonts your // documents need there. import { Builder, Font, init, isLaminaPdfError, type LaminaPdfError } from "@laminapdf/sdk"; import notoSans from "./fonts/NotoSans-Regular.ttf"; /** The longest `?name=` the letter takes. */ const MAX_NAME = 100; /** The letter's font for each path: Helvetica, a standard 14 font, or the bundled Noto Sans. */ const FONTS = new Map([ ["/", Font.standard("Helvetica")], ["/embedded", Font.fromBytes(notoSans)], ]); /** A one-page letter to `name` in `font`. */ function letter(name: string, font: Font): Uint8Array { const b = new Builder({ producer: "lamina Worker example" }); try { const page = b.page("A4"); page.surface().text(`Hello, ${name}!`, { font, size: 24, at: [72, 96] }); page.finish(); return saveAndFree(b); } finally { // A no-op after finish(); when drawing threw, it frees the builder's WebAssembly side now // rather than whenever the garbage collector gets to it (an isolate serves many requests). b.free(); } } /** Finishes `b`, saves the document and frees it (the bytes are a copy in JS memory). */ function saveAndFree(b: Builder): Uint8Array { const doc = b.finish(); try { return doc.save(); } finally { doc.free(); } } const pdf = (bytes: Uint8Array): Response => new Response(bytes, { headers: { "content-type": "application/pdf" } }); /** * Whether `e` says the font can't draw the name: the caller's input, not a server fault. Other * errors, such as `invalid-font` for a broken font file, stay a 500. */ function isNameError(e: unknown): e is LaminaPdfError { return isLaminaPdfError(e, "missing-glyph") || isLaminaPdfError(e, "encoding-full"); } /** A 400 with the error's ADR 0026 object as JSON. */ const badRequest = (body: unknown): Response => Response.json(body, { status: 400 }); // A default export because that is how a Worker module exposes its handlers (the named-exports // rule is for package code). export default { async fetch(request: Request): Promise { // Loads both modules once per isolate; later requests return at once. await init({ text: true }); const url = new URL(request.url); const font = FONTS.get(url.pathname); if (font === undefined) return new Response("not found", { status: 404 }); const name = url.searchParams.get("name") ?? "world"; if (name.length > MAX_NAME) return badRequest({ message: `name is longer than ${MAX_NAME} characters` }); try { return pdf(letter(name, font)); } catch (e) { // A script the font lacks (`missing-glyph`: Chinese in either font) or, in Helvetica, more // distinct non-WinAnsi Latin letters than its 37 spare codes (`encoding-full`). if (isNameError(e)) return badRequest(e.toJSON()); throw e; } }, }; ``` Open `/?name=Ada` and your browser shows one A4 page that says "Hello, Ada!" in 24-point type. To configure Wrangler and run it, follow the [Cloudflare Workers quick start](/quick-start/workers.md). ## How it works - `init({ text: true })` loads the WebAssembly modules the first time an isolate runs `fetch`, and returns at once after that. An isolate is one running copy of the Cloudflare Worker, which serves many requests. - `FONTS` maps each path to its font, made once per isolate. Helvetica is one of the [standard 14 fonts](/reference/glossary.md#standard-14-fonts), so that PDF embeds no font. Noto Sans arrives as bytes from `src/fonts/`, and that PDF embeds only the glyphs the letter uses. - An unknown path gets a 404, and a name longer than `MAX_NAME` characters gets a 400 before anything is drawn. - `isNameError` turns two codes into a 400 with the error's JSON. `missing-glyph` means a character the font lacks, and `encoding-full` means more than 37 different letters outside Helvetica's Western European set. Any other error, such as a broken font file, becomes a 500. - `letter` frees its builder in `finally`, and `saveAndFree` frees the document once its bytes are copied out, because the isolate goes on to serve other requests. ## What to change - **The document:** draw your own pages in `letter`, and keep both `finally` blocks, so a request that fails still frees what it made. - **Your fonts:** put the font files your documents need in `src/fonts/`, import each one and add it to `FONTS`. The quick start's Wrangler configuration bundles every `.ttf` and `.otf` file as bytes. - **Errors the caller caused:** add a code to `isNameError` when the request can cause it, such as `text-overflow` for a text box drawn with `overflow: "error"`. See [Errors and warnings](/concepts/errors-and-warnings.md). - **A download name:** add `content-disposition: attachment; filename="letter.pdf"` to the headers in `pdf`, so browsers save the file instead of showing it. Next: [Troubleshooting](/troubleshooting.md), for the problems people hit most and how to fix them. --- # Troubleshooting The problems people hit with Lamina PDF, from a missing init() to a signed file that won't save, with the cause and the fix of each. Each entry starts from what you see, then gives the cause and the fix. Errors carry a `code`, and every code has a page that says what to do: search the [error and warning codes](/reference/codes.md) for one that isn't here. ## Every call throws `not-initialized` **Cause:** a function of `@laminapdf/sdk` ran before `init()` finished loading the WebAssembly. **Fix:** `await init()` once, before anything else. After it, every call is synchronous. See [`not-initialized`](/reference/codes/not-initialized.md). ## Drawing text throws `text-module-not-loaded` **Cause:** the builder lives in the base module, which has no fonts or text. Lamina PDF loads only the modules you ask for, so a page downloads only what it uses. **Fix:** load the text module before you create the builder: `init({ text: true })`, or `loadText()` after `init()`. See [`text-module-not-loaded`](/reference/codes/text-module-not-loaded.md) and [Text and fonts](/guides/fonts-and-text.md). ## Reading text throws `extract-module-not-loaded` **Cause:** the document was opened in the base module, which can't extract text. **Fix:** load the extract module, with `init({ extract: true })` or `loadExtract()`, or the text module, before you open the document. A Cloudflare Worker can't fetch it, so import it and pass it: `import extract from "@laminapdf/wasm/lamina_pdf_extract.wasm"`, then `await loadExtract(extract)`. See [`extract-module-not-loaded`](/reference/codes/extract-module-not-loaded.md). ## The browser blocks the WebAssembly **Cause:** the page's Content Security Policy doesn't allow compiling WebAssembly. **Fix:** add `'wasm-unsafe-eval'` to its `script-src`. Lamina PDF needs nothing else from the policy. See [Browser and bundlers](/quick-start/browser.md). ## `init()` fails in a Vite app **Cause:** Vite pre-bundles dependencies into its own cache, and the `.wasm` files don't move with the JavaScript that loads them. **Fix:** exclude the package from pre-bundling: `optimizeDeps: { exclude: ["@laminapdf/sdk"] }` in `vite.config.ts`. A Web Worker that uses `@laminapdf/sdk/worker` needs the same setting. See [Browser and bundlers](/quick-start/browser.md). ## `init()` fails in a Cloudflare Worker **Cause:** Cloudflare Workers can't compile WebAssembly at run time, so `init()` can't build the modules from bytes or a URL. **Fix:** let Wrangler bundle the package, and call `init()` without a URL or bytes. Wrangler picks the package's entry for Cloudflare Workers, which imports the `.wasm` files as compiled modules. See [Cloudflare Workers](/quick-start/workers.md). ## Opening a file fails with `password-required` or `wrong-password` **Cause:** the file is encrypted. `password-required` means you passed no password, and `wrong-password` means the one you passed matches neither of the file's passwords. **Fix:** ask your user for the password and open the file again with it. Nothing else has changed. See [Open password-protected files](/guides/password-protected-files.md). ## A signed file won't save **Cause:** saving would break a signature, so the save fails with `would-invalidate-signatures`. A signed file saves only incrementally, and only with the edits its signatures allow. **Fix:** keep the default save mode and make only edits the signatures allow. To save anyway, ask your user first, then pass `invalidateSignatures: true`. See [Save a PDF](/guides/save-a-pdf.md) and [`would-invalidate-signatures`](/reference/codes/would-invalidate-signatures.md). ## Memory keeps growing **Cause:** documents and builders live in WebAssembly memory, and the garbage collector frees them late: too late for a long-running page or server. WebAssembly memory also never shrinks, so one large file leaves it large. **Fix:** free each one when you're done: `using doc = …` frees it at the end of the block, and `doc.free()` at once. A call on a freed one throws `disposed`. To give grown memory back, call `reset()` while no document is in use: every module starts afresh, and handles from before throw `instance-reset`. See [You free what you create](/concepts/how-lamina-thinks.md#you-free-what-you-create). ## A call throws `out-of-memory` or `wasm-trap` **Cause:** the WebAssembly module ran out of memory (it may grow to 2 GiB), or it trapped on a bug in Lamina PDF. Either leaves the module's memory half-written, so Lamina PDF replaces the module's instance before the next call. **Fix:** open the document again from its bytes: documents, pages and builders from the old instance throw `instance-reset`. For `out-of-memory`, keep fewer documents open at once and lower the open limits for files you don't trust. Report a `wasm-trap` with the file that causes it. See [`out-of-memory`](/reference/codes/out-of-memory.md) and [`wasm-trap`](/reference/codes/wasm-trap.md). Next: the [error and warning codes](/reference/codes.md), with what each one means and what to do. --- # Rust API (rustdoc) Where to read the API documentation of the Lamina PDF crates. docs.rs builds the API documentation of every Lamina PDF crate on crates.io. Start with [`laminapdf` on docs.rs](https://docs.rs/laminapdf/0.2.0-alpha.2/laminapdf/), the crate to depend on. It wraps the core crates and holds `Document`, `Builder`, `Error`, `Warning`, jobs and `CancelToken`. The other crates are documented the same way: - [`laminapdf-syntax`](https://docs.rs/laminapdf-syntax/0.2.0-alpha.2/laminapdf_syntax/) - [`laminapdf-cos`](https://docs.rs/laminapdf-cos/0.2.0-alpha.2/laminapdf_cos/) - [`laminapdf-model`](https://docs.rs/laminapdf-model/0.2.0-alpha.2/laminapdf_model/) - [`laminapdf-ops`](https://docs.rs/laminapdf-ops/0.2.0-alpha.2/laminapdf_ops/) - [`laminapdf-save`](https://docs.rs/laminapdf-save/0.2.0-alpha.2/laminapdf_save/) - [`laminapdf-content`](https://docs.rs/laminapdf-content/0.2.0-alpha.2/laminapdf_content/) - [`laminapdf-text`](https://docs.rs/laminapdf-text/0.2.0-alpha.2/laminapdf_text/) - [`laminapdf-build`](https://docs.rs/laminapdf-build/0.2.0-alpha.2/laminapdf_build/) - [`laminapdf-render-pdfium`](https://docs.rs/laminapdf-render-pdfium/0.2.0-alpha.2/laminapdf_render_pdfium/) docs.rs couldn't build `laminapdf-source` and `laminapdf-render` at 0.2.0-alpha.2, so their docs come with the next release; until then, build them yourself as below. [Install](/install.md#every-published-package) lists every crate, with its page on crates.io. ## Build the docs yourself To read the docs of a change before it's released, build them from a checkout of Lamina PDF: ```sh cargo doc --workspace --no-deps --all-features --open ``` The command opens the docs in your browser. Every public item of every crate is documented. --- # Error and warning codes Every code Lamina PDF raises, generated from fixtures/codes.json when the site is built. Every error and warning Lamina PDF raises, in Rust, in WebAssembly and in `@laminapdf/sdk`, has one of these codes. The tables below are read from `fixtures/codes.json` when this site is built: the same file `cargo xtask gen codes` generates the Rust enums and the TypeScript unions from. Each code links to its page on this site, which explains when it happens, why, how to fix it and what its `data` holds. See [Errors and warnings](/concepts/errors-and-warnings.md) for how to handle them. ## Warnings | Code | Kind | What happened | What to do | | --- | --- | --- | --- | | [`missing-header`](/reference/codes/missing-header.md) | `input` | no %PDF-x.y header in the first 1,024 bytes; parsing went ahead | nothing to do if the document reads correctly; open in strict mode to refuse such files | | [`xref-repaired`](/reference/codes/xref-repaired.md) | `input` | the stored cross-reference data was unusable and was rebuilt by scanning | check the result; save with a full rewrite, since an incremental update refuses a repaired base | | [`xrefstm-ignored`](/reference/codes/xrefstm-ignored.md) | `input` | a hybrid file's /XRefStm could not be read and was ignored | nothing to do; the classic xref table is used instead | | [`stream-length-recovered`](/reference/codes/stream-length-recovered.md) | `input` | a stream's /Length was wrong, so its end was found by scanning for endstream | nothing to do; a full rewrite writes the correct length | | [`stale-generation`](/reference/codes/stale-generation.md) | `input` | a reference's generation did not match its xref entry, so it resolved to null | nothing to do; the reference is treated as missing | | [`page-tree-node-skipped`](/reference/codes/page-tree-node-skipped.md) | `input` | a page-tree node was missing, malformed or too deep, and was skipped | check the page count: pages under the skipped node are missing | | [`page-count-mismatch`](/reference/codes/page-count-mismatch.md) | `input` | the page tree's /Count differs from the number of pages walked | nothing to do; Lamina PDF uses the walked page count | | [`flate-truncated`](/reference/codes/flate-truncated.md) | `input` | Flate data was truncated or had a bad checksum; the partial output was kept | check the affected content: the end of the stream may be missing | | [`objstm-index-mismatch`](/reference/codes/objstm-index-mismatch.md) | `input` | an object stream's index was stale and the object was found by searching | nothing to do; a full rewrite writes a correct index | | [`object-number-out-of-range`](/reference/codes/object-number-out-of-range.md) | `limit` | an xref entry above max_object_number was dropped | raise max_object_number in Limits only if you trust the input | | [`aes-padding-invalid`](/reference/codes/aes-padding-invalid.md) | `security` | AES-encrypted data had a bad length or padding and was kept as stored | treat the affected strings and streams as suspect: they may have been tampered with | | [`perms-mismatch`](/reference/codes/perms-mismatch.md) | `security` | an R5/R6 document's /Perms does not match its /P and /EncryptMetadata | treat the document's permissions as untrusted | | [`warnings-truncated`](/reference/codes/warnings-truncated.md) | `limit` | more than max_warnings warnings were raised and the rest were dropped | raise max_warnings in Limits if you need the full list | | [`invalid-box`](/reference/codes/invalid-box.md) | `input` | a page boundary box is not four numbers and reads as absent | nothing to do; the page uses the next box in the fallback chain | | [`invalid-rotate`](/reference/codes/invalid-rotate.md) | `input` | a page's /Rotate is not a multiple of 90 and reads as 0 | set the page rotation explicitly if 0 is wrong | | [`name-tree-node-skipped`](/reference/codes/name-tree-node-skipped.md) | `input` | a name- or number-tree node was malformed, repeated or too deep, and was skipped | check the tree's entries: those under the skipped node are missing | | [`outline-cycle`](/reference/codes/outline-cycle.md) | `input` | an outline item was reached twice; that branch of the walk stopped | nothing to do; the outline is read without the loop | | [`outline-item-skipped`](/reference/codes/outline-item-skipped.md) | `input` | an outline item was missing, not a dictionary or too deep, and was skipped with its subtree | check the outline: the skipped item and its children are missing | | [`invalid-date`](/reference/codes/invalid-date.md) | `input` | an Info date is not a valid PDF date; the raw string is kept | read the raw value from the Info view if you need it | | [`invalid-page-label`](/reference/codes/invalid-page-label.md) | `input` | a /PageLabels entry is malformed; the malformed part is dropped | nothing to do; affected pages fall back to decimal labels | | [`invalid-entry`](/reference/codes/invalid-entry.md) | `input` | an entry a view reads is unreadable or of the wrong type and reads as absent | nothing to do; the view uses the entry's default | | [`content-unknown-operator`](/reference/codes/content-unknown-operator.md) | `input` | an operator the spec does not define was skipped outside a BX/EX section | nothing to do; check the page's rendering if the operator mattered | | [`content-bad-operands`](/reference/codes/content-bad-operands.md) | `input` | an operator had too few operands or operands of the wrong type, or extra ones | nothing to do; the operator was skipped or used its last operands | | [`content-unbalanced-q`](/reference/codes/content-unbalanced-q.md) | `input` | a Q without a matching q was ignored, or saved graphics states were left open | nothing to do; the graphics state was restored as the spec intends | | [`content-unbalanced-bt`](/reference/codes/content-unbalanced-bt.md) | `input` | a text object was opened twice, closed outside one, or left open | nothing to do; text operators outside a text object were handled tolerantly | | [`content-unbalanced-marked`](/reference/codes/content-unbalanced-marked.md) | `input` | an EMC without a matching BMC or BDC was ignored, or marked content was left open | check tagged-content results: marked-content sequences were closed for you | | [`content-syntax`](/reference/codes/content-syntax.md) | `input` | content-stream bytes that are not a token were skipped | check the page's content: the bytes around the reported offset were skipped | | [`content-resource-missing`](/reference/codes/content-resource-missing.md) | `input` | an operator named a resource that is missing, of the wrong type or unreadable | check the page's resources: the operator ran without the resource or was skipped | | [`inline-image-unterminated`](/reference/codes/inline-image-unterminated.md) | `input` | no EI ends an inline image; the rest of the content was skipped | check the page's content: everything after the inline image is missing | | [`xobject-cycle`](/reference/codes/xobject-cycle.md) | `input` | a Form XObject draws itself, directly or through other forms | nothing to do; the inner Do was skipped | | [`content-path-unpainted`](/reference/codes/content-path-unpainted.md) | `input` | a content stream ended with a path no painting operator ended | nothing to do; the unpainted path was dropped as the spec intends | | [`font-invalid`](/reference/codes/font-invalid.md) | `input` | a font dictionary has a missing or malformed entry, so its text was decoded with defaults | nothing to do; extracted text or positions for that font may be off | | [`cmap-invalid`](/reference/codes/cmap-invalid.md) | `input` | a font's CMap or ToUnicode CMap is malformed or names a predefined CMap Lamina PDF has no table for | nothing to do; the bad entries were skipped and the rest of the CMap is used | | [`unmapped-glyph`](/reference/codes/unmapped-glyph.md) | `input` | a font shows glyphs that nothing maps to text | nothing to do; those glyphs extract as U+FFFD (the file needs a ToUnicode CMap) | | [`extract-limit-blocks`](/reference/codes/extract-limit-blocks.md) | `limit` | a page had more runs of text than the extraction block limit; the runs past it were left out | raise ExtractLimits::max_blocks_per_page if the page really has that many blocks | | [`extract-reading-order-ambiguous`](/reference/codes/extract-reading-order-ambiguous.md) | `unsupported` | a page mixes horizontal and vertical writing, so the order of its blocks is a guess | nothing to do; check the order of that page's blocks, or ask for content order | | [`extract-table-ambiguous`](/reference/codes/extract-table-ambiguous.md) | `unsupported` | a table's rules marked out merged cells that aren't rectangles, so its cells were kept unmerged | nothing to do; check that table's cells, whose text may be split across neighbours | | [`extract-table-limit`](/reference/codes/extract-table-limit.md) | `limit` | a page's ruling lines would make a table bigger than the extraction's cell limit; those lines gave no table | raise ExtractLimits::max_table_cells if the page really has a table that big | | [`extract-struct-tree-ignored`](/reference/codes/extract-struct-tree-ignored.md) | `input` | a tagged page's structure tree covered too little of its text, or its order jumped back across columns too often, so the layout gave the reading order | nothing to do; ReadingOrder::Tagged uses the tree anyway | | [`extract-untagged-content`](/reference/codes/extract-untagged-content.md) | `input` | a page read in structure-tree order has visible text that is neither in the tree nor an artifact; it follows the tree's blocks, in layout order | nothing to do; tag that text in the file to give it a place in the reading order | ## Errors | Code | Kind | What happened | What to do | | --- | --- | --- | --- | | [`not-pdf`](/reference/codes/not-pdf.md) | `input` | the input does not look like a PDF at all | check that the bytes passed to open are a PDF file | | [`missing-root`](/reference/codes/missing-root.md) | `input` | no document catalog (/Root) was found, even after repair | use another copy of the file: this one has no usable catalog | | [`reference-cycle`](/reference/codes/reference-cycle.md) | `input` | a reference chain or the page tree loops back on itself | use another copy of the file, or repair it with another tool | | [`limit-exceeded`](/reference/codes/limit-exceeded.md) | `limit` | a hard Limits value was exceeded | the input exceeds a safety limit; raise {limit} in Limits only if you trust the input | | [`strict`](/reference/codes/strict.md) | `input` | strict mode refused something the tolerant reader would have repaired | open without strict mode to read the file with repairs, or fix the file | | [`data-pending`](/reference/codes/data-pending.md) | `state` | the ByteSource has not fetched the byte ranges the call needs yet | fetch the listed ranges into the source and retry the call | | [`source`](/reference/codes/source.md) | `io` | the ByteSource failed to read | retry the call, or check the source; the document stays usable | | [`type`](/reference/codes/type.md) | `input` | an object had a different type than the structure requires | use another copy of the file, or repair it with another tool | | [`invalid-structure`](/reference/codes/invalid-structure.md) | `input` | an object stream, xref section or filter parameter was malformed | use another copy of the file, or repair it with another tool | | [`unset-object`](/reference/codes/unset-object.md) | `internal` | a writer object number was reserved but never given a value | this is a bug in Lamina PDF: report it with the steps that led to it | | [`repaired-base`](/reference/codes/repaired-base.md) | `state` | an incremental update was requested for a document whose xref needed repair | save with a full rewrite instead of an incremental update | | [`encrypted-base`](/reference/codes/encrypted-base.md) | `unsupported` | a save of an encrypted document was requested | saving encrypted documents is not supported yet; use another tool to decrypt or re-encrypt | | [`password-required`](/reference/codes/password-required.md) | `security` | the document is encrypted with a user password and none was given | pass the document's password in OpenOptions | | [`wrong-password`](/reference/codes/wrong-password.md) | `security` | the given password is neither the user nor the owner password | ask for the password again and retry with the correct one | | [`unsupported-security-handler`](/reference/codes/unsupported-security-handler.md) | `unsupported` | the document uses a security handler Lamina PDF cannot decrypt | use a tool that supports this encryption to decrypt the file first | | [`unexpected-eof`](/reference/codes/unexpected-eof.md) | `input` | the input ended before a complete token or object was read | use a complete copy of the file: this one looks truncated | | [`syntax`](/reference/codes/syntax.md) | `input` | a token or structure did not match the PDF grammar | use another copy of the file, or repair it with another tool | | [`unsupported-filter`](/reference/codes/unsupported-filter.md) | `unsupported` | a stream uses a filter Lamina PDF does not implement | use a tool that supports the filter to read this stream | | [`decompress`](/reference/codes/decompress.md) | `input` | compressed stream data was corrupt | use another copy of the file: this stream's data is damaged | ## Op errors | Code | Kind | What happened | What to do | | --- | --- | --- | --- | | [`nested-transaction`](/reference/codes/nested-transaction.md) | `state` | a transaction was started, or undo or redo called, while one is open | commit or roll back the open transaction first, or use a savepoint inside it | | [`no-transaction`](/reference/codes/no-transaction.md) | `state` | apply, commit or rollback was called without an open transaction | call begin first, or use transact | | [`unknown-page`](/reference/codes/unknown-page.md) | `usage` | an op names a page the document does not have | use a PageId from this document's pages() | | [`direct-page`](/reference/codes/direct-page.md) | `unsupported` | the page is a direct dictionary, which ops cannot edit yet | rewrite the file so that every page is an indirect object, or wait for page-structure ops | | [`unsupported-version`](/reference/codes/unsupported-version.md) | `unsupported` | an op log has a schema version this reader does not know | upgrade Lamina PDF to a version that reads this op log version | | [`invalid-op-log`](/reference/codes/invalid-op-log.md) | `input` | an op log does not parse: an unknown op or field, a bad value, or not JSON | validate the op log against the op schema; the samples in fixtures/ops show the format | | [`saved-not-rebased`](/reference/codes/saved-not-rebased.md) | `state` | the document was saved to a sink Lamina PDF cannot read back and waits for rebase | call rebase with the saved bytes before applying ops or saving again | | [`no-pending-save`](/reference/codes/no-pending-save.md) | `state` | rebase was called without a save waiting for it | call rebase only after a save that returned SavedNotRebased | ## Save errors | Code | Kind | What happened | What to do | | --- | --- | --- | --- | | [`sink`](/reference/codes/sink.md) | `io` | the sink failed to take the bytes or could not give them back | check the destination and retry the save; the document is unchanged | | [`not-rebased`](/reference/codes/not-rebased.md) | `state` | the output was written, but rebasing the document onto it failed | retry rebase with the saved output carried by the error | | [`sink-not-empty`](/reference/codes/sink-not-empty.md) | `usage` | the sink already held bytes before the save started | pass an empty sink: output offsets count from the sink's first byte | | [`would-invalidate-signatures`](/reference/codes/would-invalidate-signatures.md) | `security` | the save would break one or more signatures | save with SaveMode::Auto, or ask the user and retry with SaveOptions::with_invalidate_signatures | | [`requires-rewrite`](/reference/codes/requires-rewrite.md) | `state` | an incremental save was requested but only a full rewrite is safe | save with SaveMode::Auto or SaveMode::FullRewrite | ## Text errors | Code | Kind | What happened | What to do | | --- | --- | --- | --- | | [`invalid-font`](/reference/codes/invalid-font.md) | `input` | font bytes could not be read as a font | pass the complete bytes of a .ttf, .otf, .ttc or .otc file | | [`collection-index`](/reference/codes/collection-index.md) | `usage` | a font collection member past the end of the collection was asked for | use an index below Font::collection_len | | [`unknown-axis`](/reference/codes/unknown-axis.md) | `usage` | an instance was asked for on a variation axis the font does not have | check the font's axes before choosing coordinates, or use a variable font | | [`font-not-embeddable`](/reference/codes/font-not-embeddable.md) | `usage` | the font's OS/2 fsType forbids embedding | use another font, or register it with EmbedPolicy::Override if your licence allows embedding | | [`missing-glyph`](/reference/codes/missing-glyph.md) | `input` | the font has no glyph for a character of the text | use a font that covers the character, or split the text across fonts | | [`glyph-out-of-range`](/reference/codes/glyph-out-of-range.md) | `usage` | a glyph ID at or past the font's glyph count was used | pass glyph IDs from the font you registered | | [`unknown-font`](/reference/codes/unknown-font.md) | `usage` | a FontKey was used with a registry that did not hand it out | use the key returned by this registry's add | | [`subset-failed`](/reference/codes/subset-failed.md) | `unsupported` | subsetting a font failed | try another version or format of the font, and report the font if it is a common one | | [`invalid-axis-value`](/reference/codes/invalid-axis-value.md) | `usage` | a variation axis value was NaN or infinite | pass finite axis values; values outside the axis range are clamped | | [`wrong-document`](/reference/codes/wrong-document.md) | `usage` | finish_fonts was given a document the registry did not reserve its fonts in | use one FontRegistry per document and finish it with that document | | [`invalid-feature`](/reference/codes/invalid-feature.md) | `usage` | an OpenType feature setting did not parse | write it as liga=0, -liga, smcp or aalt=2 (HarfBuzz syntax without ranges) | | [`invalid-text-range`](/reference/codes/invalid-text-range.md) | `usage` | a byte range was out of bounds or not on character boundaries of the text | take ranges from Lamina PDF's results (line_breaks, itemize, break_lines) and pass the same text | | [`unsupported-layout-option`](/reference/codes/unsupported-layout-option.md) | `unsupported` | a layout option that is not implemented yet was asked for | leave the option at its default; justification comes with a later release | | [`font-mismatch`](/reference/codes/font-mismatch.md) | `usage` | a shaped run was given with a font key for a different font than it was shaped with | pass the key of the font the run was shaped with (the font index of each line run maps to your keys) | | [`encoding-full`](/reference/codes/encoding-full.md) | `limit` | a standard 14 font has no free one-byte code left for another glyph | embed a font with Font::from_bytes, or split the text across two registrations of the standard font | | [`font-not-shapable`](/reference/codes/font-not-shapable.md) | `unsupported` | a standard 14 font was given to shaping or layout, which need a font program | draw standard 14 fonts with FontRegistry::encode, or shape with an embedded font | ## Text warnings | Code | Kind | What happened | What to do | | --- | --- | --- | --- | | [`tounicode-conflict`](/reference/codes/tounicode-conflict.md) | `usage` | one glyph was used for two different texts; ToUnicode keeps the first | mark the second occurrence with /ActualText so extraction gets its text | | [`whole-font-rebuilt`](/reference/codes/whole-font-rebuilt.md) | `usage` | a font that forbids subsetting was rebuilt with every glyph kept | check that the font's licence allows this, or use a static TrueType or CFF version | ## Build errors | Code | Kind | What happened | What to do | | --- | --- | --- | --- | | [`invalid-image`](/reference/codes/invalid-image.md) | `input` | image bytes are not a readable image of the format they were given as | pass the complete bytes of a JPEG or PNG file to Image::jpeg or Image::png | | [`unsupported-image`](/reference/codes/unsupported-image.md) | `unsupported` | the image uses a feature that PDF or Lamina PDF does not support | convert it to a baseline or progressive 8-bit JPEG, or to PNG | | [`image-too-large`](/reference/codes/image-too-large.md) | `limit` | the image needs more memory than the builder allows (a decoded PNG over 256 MiB, or raw dimensions whose size overflows) | scale the image down before adding it | | [`image-data-length`](/reference/codes/image-data-length.md) | `usage` | raw image samples are not width x height x channels bytes long | pass exactly 4 bytes (RGBA) per pixel, row by row | | [`invalid-icc-profile`](/reference/codes/invalid-icc-profile.md) | `input` | the bytes are not a usable ICC profile | pass a complete ICC profile with a Gray, RGB or CMYK data colour space | | [`color-components`](/reference/codes/color-components.md) | `usage` | a colour has the wrong number of components for its colour space | give one component per channel of the colour space (/N of the ICC profile) | | [`invalid-color`](/reference/codes/invalid-color.md) | `usage` | a colour string is not of the form #rgb or #rrggbb | write the colour as # followed by 3 or 6 hexadecimal digits | | [`invalid-geometry`](/reference/codes/invalid-geometry.md) | `usage` | a page size, box, /UserUnit, drawing value (coordinate, matrix, line width, dash, miter limit) or image size is not usable | use finite coordinates, positive page sizes and /UserUnit, non-negative line widths and dashes, a miter limit of at least 1, and images of at least one pixel | | [`text-overflow`](/reference/codes/text-overflow.md) | `usage` | a text box's lines don't all fit its height and its options asked for an error | make the box taller or the text shorter, or use Overflow::Clip and read TextLayout::overflow | | [`page-index-out-of-range`](/reference/codes/page-index-out-of-range.md) | `usage` | a link, outline item or destination names a page index the document doesn't have | use a 0-based index below the document's page count | | [`unknown-destination`](/reference/codes/unknown-destination.md) | `usage` | a link or outline item names a destination that was never defined | define it with Builder::destination before the build finishes, or link to a page | | [`invalid-page-labels`](/reference/codes/invalid-page-labels.md) | `usage` | page label ranges that don't start at page 0, don't increase, start past the last page or number from 0 | give ranges sorted by first page, the first at page 0, numbered from 1 | | [`profile-version-conflict`](/reference/codes/profile-version-conflict.md) | `usage` | the PDF version asked for is one the profile doesn't allow (PDF/UA-1, PDF/A-2 and PDF/A-3 are based on PDF 1.7) | leave the version at 1.7 (the default) or lower; PDF 2.0 needs PDF/A-4 or PDF/UA-2, which Lamina PDF doesn't write yet | | [`missing-title`](/reference/codes/missing-title.md) | `usage` | PDF/UA-1 needs a document title, and the metadata gives none | set a title with Builder::metadata (Metadata::with_title) | | [`missing-lang`](/reference/codes/missing-lang.md) | `usage` | PDF/UA-1 needs the document's natural language, and none was set | set the language with Builder::lang, such as "en-GB" | | [`missing-alt-text`](/reference/codes/missing-alt-text.md) | `usage` | a Figure or Formula has no alternate text (/Alt or /ActualText), which PDF/UA-1 requires | give the figure or formula a description with Tag::with_alt or Surface::figure's alt, or draw decoration as an artifact | | [`untagged-content`](/reference/codes/untagged-content.md) | `usage` | content was drawn outside every structure element and artifact, which PDF/UA-1 forbids | draw it inside Surface::tag (real content) or Surface::artifact (decoration) | | [`heading-level-skipped`](/reference/codes/heading-level-skipped.md) | `usage` | a heading skips a level (the first is not H1, or an H3 follows an H1), which PDF/UA-1 forbids | start with H1 and go down one level at a time | | [`unnumbered-heading-misused`](/reference/codes/unnumbered-heading-misused.md) | `usage` | an unnumbered H heading shares its parent with another H, or the document also uses H1-H6, which PDF/UA-1 forbids | use numbered headings (H1, H2, ...) throughout, or one H per section | | [`invalid-structure-nesting`](/reference/codes/invalid-structure-nesting.md) | `usage` | a table or list element sits in a parent its type doesn't allow (a TD outside a TR, a P directly in a Table) | nest tables as Table > TR > TH/TD and lists as L > LI > Lbl/LBody | | [`link-without-structure`](/reference/codes/link-without-structure.md) | `usage` | a link annotation has no Link structure element (it was drawn inside an artifact), which PDF/UA-1 forbids | draw links outside artifacts, so the builder gives each one a Link element | | [`link-without-contents`](/reference/codes/link-without-contents.md) | `usage` | a link annotation has no description (/Contents), which PDF/UA-1 requires | pass a description as the link's contents | | [`font-not-embedded`](/reference/codes/font-not-embedded.md) | `usage` | a standard 14 font, which is never embedded, was used under a profile that needs every font embedded | load a font file with Builder::font instead of Builder::standard_font | | [`glyph-without-unicode`](/reference/codes/glyph-without-unicode.md) | `usage` | a glyph was drawn without a Unicode value, which PDF/A-2u and PDF/UA-1 forbid | use text whose glyphs each stand for characters, or choose PDF/A-2b, which doesn't need Unicode | | [`device-color-without-output-intent`](/reference/codes/device-color-without-output-intent.md) | `usage` | a DeviceCMYK colour or image was used, but the PDF/A output intent is sRGB | use RGB or gray colours, or a CMYK colour with an ICC profile (Color::icc) | | [`embedded-file-forbidden`](/reference/codes/embedded-file-forbidden.md) | `usage` | PDF/A-2 doesn't allow attached files that aren't PDF/A themselves | choose PDF/A-3b, which allows attached files of any kind | | [`embedded-file-without-mime-type`](/reference/codes/embedded-file-without-mime-type.md) | `usage` | an attached file has no MIME type, which every PDF/A profile requires | pass the file's MIME type, such as text/xml or application/octet-stream | ## Render errors | Code | Kind | What happened | What to do | | --- | --- | --- | --- | | [`render-too-large`](/reference/codes/render-too-large.md) | `limit` | the requested tile is larger than the render limits allow; nothing was allocated | request smaller tiles (256 to 512 pixels at high zoom) or raise RenderLimits if the larger bitmap is intended | | [`render-failed`](/reference/codes/render-failed.md) | `input` | the render engine could not open the document or draw the page | the file may be damaged or use a feature this engine can't draw; try the other backend, or report the file | | [`render-backend-unavailable`](/reference/codes/render-backend-unavailable.md) | `io` | the render engine is not available: its library or module could not be loaded | run cargo xtask fetch-pdfium or set LAMINA_PDF_PDFIUM_DIR to the folder holding the PDFium library | ## Facade errors | Code | Kind | What happened | What to do | | --- | --- | --- | --- | | [`cancelled`](/reference/codes/cancelled.md) | `cancelled` | a CancelToken stopped the work; the job is finished and returned no partial result | nothing to do: this is the outcome the caller asked for; start a new job to try again | | [`job-finished`](/reference/codes/job-finished.md) | `state` | a job was stepped after it had finished, failed or been cancelled | stop stepping a job once a step returned its result or an error; create a new job | | [`document-changed`](/reference/codes/document-changed.md) | `state` | the document a job was created for changed (an edit, undo, redo, save or rebase) or a different document was passed to step | pass the document the job was created from, and don't change it while the job runs; create a new job | ## Binding errors | Code | Kind | What happened | What to do | | --- | --- | --- | --- | | [`builder-finished`](/reference/codes/builder-finished.md) | `state` | a builder or one of its pages was used after it finished | create a new builder or page: a finished one takes no more drawing, pages, fonts or settings | | [`disposed`](/reference/codes/disposed.md) | `state` | a document, builder or other handle was used after free(), or after the using block that held it ended | free a handle only after its last use; to keep working, open the document again or create a new builder | | [`invalid-argument`](/reference/codes/invalid-argument.md) | `usage` | a value passed to a binding does not have the shape its type declares | pass the value the type declarations describe; the data field says what was wrong | | [`source-budget-exceeded`](/reference/codes/source-budget-exceeded.md) | `limit` | a sparse source was given more bytes than its byte budget allows | raise the source's budget, or open the file with fewer bytes fetched (smaller blocks, no readahead) | | [`unknown-option`](/reference/codes/unknown-option.md) | `usage` | an options record passed to a binding has a key Lamina PDF does not know | remove the key or correct its spelling; didYouMean lists the closest known names | ## Package errors | Code | Kind | What happened | What to do | | --- | --- | --- | --- | | [`already-initialized`](/reference/codes/already-initialized.md) | `state` | init() or loadText() named a different source for a module that is already loaded or loading | call init and loadText with a source once, before the first load; later calls need no source | | [`extract-module-not-loaded`](/reference/codes/extract-module-not-loaded.md) | `state` | page.text() was called on a document opened in the base module, which has no text extraction | load the extract module (init with the extract option, or loadExtract) or the text module before opening the document | | [`fetch-failed`](/reference/codes/fetch-failed.md) | `io` | a range source could not fetch input bytes: a network error, an HTTP status other than 200 and 206, a 206 whose Content-Range or length doesn't match the request, or a read that returned the wrong number of bytes | check that the URL is reachable and that the server sends the CORS headers range requests need (Content-Range must be exposed); data.reason and data.status say what failed | | [`not-initialized`](/reference/codes/not-initialized.md) | `state` | an @laminapdf/sdk function was called before init() finished loading the WebAssembly module | await init() once before calling anything else in @laminapdf/sdk | | [`source-changed`](/reference/codes/source-changed.md) | `io` | a remote file changed while it was being read: its ETag or length differs from the first response | open the file again; the document already read parts of the old version and can't mix in the new one | | [`text-module-not-loaded`](/reference/codes/text-module-not-loaded.md) | `state` | a font or text call was made on a builder of the base module, which has no fonts or text | load the text module with init and the text option, or with loadText, before creating the builder that draws text | | [`worker-version-mismatch`](/reference/codes/worker-version-mismatch.md) | `usage` | the worker answered with a different protocol or package version than the client | serve the worker file from the same @laminapdf/sdk build as the page; don't load it from a CDN or an older deploy | | [`worker-crashed`](/reference/codes/worker-crashed.md) | `internal` | the worker running Lamina PDF stopped (an uncaught error, a WebAssembly trap or out of memory); calls in flight were rejected | call client.restart() and open the document again; report the file if it keeps happening | | [`worker-failed`](/reference/codes/worker-failed.md) | `internal` | the worker client failed with an error that isn't Lamina PDF's (a bug in @laminapdf/sdk/worker, or a result it couldn't send) | this is a bug in Lamina PDF: report it with the call and the message; the worker keeps running | | [`wasm-trap`](/reference/codes/wasm-trap.md) | `internal` | the WebAssembly module trapped (a bug in Lamina PDF), so its instance was discarded and the next call gets a fresh one | open the document again and retry; report the file and the call if it traps again | | [`out-of-memory`](/reference/codes/out-of-memory.md) | `limit` | the WebAssembly module ran out of memory (its maximum, or what the engine could give), so its instance was discarded and the next call gets a fresh one | open fewer or smaller documents at once and free each when done; lower the open limits so hostile files fail sooner | | [`instance-reset`](/reference/codes/instance-reset.md) | `state` | a handle was used after its WebAssembly instance was replaced, by reset() or after a trap | open the document again or create a new builder: nothing from the old instance survives | ## Cli errors | Code | Kind | What happened | What to do | | --- | --- | --- | --- | | [`invalid-command-line`](/reference/codes/invalid-command-line.md) | `usage` | the command line doesn't parse: an unknown command or flag, a missing argument, a bad value, or stdin named twice | run the command with --help to see its arguments | | [`invalid-page-range`](/reference/codes/invalid-page-range.md) | `usage` | a page range doesn't fit the grammar, or names a page the document lacks | write pages as 1-5, z for the last page, r2 for the second from the end, or x3 to leave page 3 out; data.position is the character where the range went wrong | | [`binary-output-to-terminal`](/reference/codes/binary-output-to-terminal.md) | `usage` | the command would write a PDF to standard output, and standard output is a terminal | redirect standard output to a file or a pipe, or write to a file with -o | | [`unknown-code`](/reference/codes/unknown-code.md) | `usage` | explain was given a code that isn't a Lamina PDF error or warning code | check the spelling against didYouMean, or list every code at the docs URL | | [`overwrite-refused`](/reference/codes/overwrite-refused.md) | `state` | the output file exists, or the command would modify its input in place, and --force wasn't given | pass --force, or write to a new file with -o | | [`input-unreadable`](/reference/codes/input-unreadable.md) | `io` | an input file, standard input or the password file couldn't be read | check that the file exists and that you can read it; data.input names it | | [`output-unwritable`](/reference/codes/output-unwritable.md) | `io` | the output couldn't be written: its folder is missing or read-only, the disk is full, or standard output closed | check that the output's folder exists, that you can write to it and that the disk has room | | [`path-outside-roots`](/reference/codes/path-outside-roots.md) | `security` | laminapdf mcp was given a path outside its --root folders, or a link that leads out of them | use a path inside one of the server's --root folders, or start the server with another --root | --- # Stability policy What a Lamina PDF version number promises, and what stays the same in every version. Lamina PDF is in alpha, so its API can still change from one release to the next. A few things never change, whatever the version: error and warning codes, the error object's fields, saved edit logs, and identical output from every runtime. This page says what each version number promises, so you know when an upgrade needs a code change. ## One version for every package Every crate and every npm package shares one version, and they are released together, from `laminapdf` and its crates to `@laminapdf/sdk` and the viewer. So `@laminapdf/sdk` at a version always runs the Rust code of `laminapdf` at the same version. [Install](/install.md#every-published-package) lists every package. ## What a version number tells you - **Alphas promise nothing to each other.** Anything can change between `0.2.0-alpha.1` and `0.2.0-alpha.2`. While you try an alpha, pin the exact version: `npm install --save-exact @laminapdf/sdk@0.2.0-alpha.2`, and `laminapdf = "=0.2.0-alpha.2"` in `Cargo.toml`. - **The `alpha` tag names the newest alpha.** `npm install @laminapdf/sdk@alpha` gets it. A plain `npm install @laminapdf/sdk` gets a `0.0.0` placeholder that only reserves the name. - **After the alphas, `0.x` versions follow SemVer as Cargo reads it.** A breaking change raises the minor version (`0.2` to `0.3`), and features and fixes raise the patch (`0.2.0` to `0.2.1`). So an upgrade within one `0.x` line never needs a code change. ## What counts as a breaking change A change breaks your code when it does one of these: - removes or renames a public Rust item or an export of `@laminapdf/sdk` - changes one so that code using it stops compiling or behaves differently from its documentation - raises the minimum Rust version, the minimum Node version or the Cloudflare Workers `compatibility_date` - adds an error kind, since code that matches on the nine kinds has to handle the new one - changes the bytes written for the same input and options Everything else keeps your code working: new functions, types, options and fields; new error and warning codes; documentation; speed; and fixes that make output follow the PDF standard where it didn't. The changelog lists those fixes. ## What never changes Four promises hold across every version, `0.x` and alphas included: - **Error and warning codes are never renamed or reused** for another meaning. A retired code stays in the [code reference](/reference/codes.md) with the version that retired it, and keeps its page. New codes can appear in any release, so handle a code you don't know. - **The error object only grows.** Each error names its schema, `laminapdf.error/1`. Within one schema, a field can be added, but never removed or given another type. - **Saved edit logs keep loading.** A log names its format version (`{ v: 1, ops }`, as [Edit a PDF](/guides/edit-a-pdf.md#replay-edits-stored-as-json) shows). A new op or optional field keeps the version. A changed or removed one raises it, and every older version still loads. - **The same input gives the same bytes everywhere.** With the same input, options and version, Rust and every JavaScript runtime write identical files. ## How the promises are checked Lamina PDF compares every change with the newest published release: the Rust crates with [cargo-semver-checks](https://github.com/obi1kenobi/cargo-semver-checks), and `@laminapdf/sdk` with a report of its exported types. A change that breaks either one has to raise the minor version, and the changelog lists it first. Each release also installs itself twice and runs the quick start: once from the packages before they're published, and once from npm and crates.io after. Both runs cover Node 22, 24 and 26, Deno, Bun, Chromium, Cloudflare Workers and a Rust program, and every run has to write the same `hello.pdf`, byte for byte. ## What isn't promised yet - The low-level file-structure and model types that `laminapdf` re-exports are public, but they can change in any minor release. - Package sizes and speed are measured on every change, not promised. - The minimum Rust version is two releases behind the newest stable Rust (1.97 today). Raising it is a minor-version change, and the changelog says so. ## Before you upgrade The [changelog](/reference/changelog.md) lists every release's changes, breaking ones first. Read the entries between your version and the new one, then move the exact version you pin. --- # Changelog What changed in each Lamina PDF release, newest first. Every Lamina PDF package shares one version, so each entry covers all of them, from the Rust crates to `@laminapdf/sdk` and the viewer. Breaking changes come first, under **Minor Changes**, as the [stability policy](/reference/stability.md) explains. Versions from `0.2.0-alpha.2` on are on npm and crates.io; earlier ones were never published. ## 0.2.0-alpha.2 ### Minor Changes - `Document::save` no longer takes a `CancelToken`. To stop a save from another thread, call `save_with(&options, &cancel)` instead. In TypeScript, `save()`, `saveWithReport()` and `SaveJob.step()` now return `Uint8Array`, which `Response` and `Blob` accept without a cast. Smaller additions: `RectPt::from_points(x, y, width, height)`, `SetInfo::patch(patch)` for an edit that records no `/ModDate`, and `new()` on `BuildOptions`, `OpenOptions`, `PngOptions`, `RetryOptions` and `TextBoxOptions`. You can now ignore the run that `Surface::text` returns: the text is drawn either way. - The WASM module's `beginTag` and `beginArtifact` now take a structure type, table scope and artifact kind by name instead of by index, which breaks code that calls the module directly; `@laminapdf/sdk` already calls them that way. `@laminapdf/sdk` also exports the `LineCap`, `LineJoin` and `TableScope` types that `StrokeStyle` and `TagSpec` use, and `laminapdf-build`'s `FillRule`, `LineCap`, `LineJoin`, `BlendMode`, `ArtifactKind` and `TableScope` gain an `ALL` list of their variants. ### Patch Changes - Text copied from a standard 14 font no longer loses half of its last character when that text runs past 256 UTF-16 units and ends with a character outside the BMP, such as an emoji. Symbol and ZapfDingbats now write a `/CapHeight`, as every other font does, and `laminapdf-syntax` adds `Object::number` and `Object::rounded`. ## 0.2.0-alpha.1 ### Minor Changes - The library is called Lamina PDF, and its public names follow. `@laminapdf/sdk`'s error class is `LaminaPdfError`, checked with `isLaminaPdfError`, and its warnings are `LaminaPdfWarning`. The error object's JSON Schema is `laminapdf.error/1`. `@laminapdf/wasm`'s modules are `lamina_pdf_base`, `lamina_pdf_text`, `lamina_pdf_extract` and `lamina_pdf_render_hayro` (`.js`, `.d.ts` and `.wasm`). - `@laminapdf/viewer` exports only its documented API: `ViewerClient`, `createViewer` and `Viewer`, the types of their options, events and state, the protocol types `client.call` takes, and `MIN_ZOOM`/`MAX_ZOOM`. Its internal helpers (layout, canvas pool, bitmap cache, tiles, zoom maths), its byte-source functions and its protocol constants are no longer exported. Its worker no longer keeps a log of every render, which grew without bound, and no longer serves test-only methods. `@laminapdf/sdk/worker/server`'s `serve()` takes a `saveBudget` (work units per save step), and `workerStats()` reports the handles and calls a worker holds. `@laminapdf/sdk/worker` exports the `Emitter` its client uses. - A render `data-pending` thrown inside `@laminapdf/sdk`'s worker now reaches the page with its byte ranges: `LaminaPdfError.ranges` lists them. Before, they arrived empty. `RenderError` has a new `toDiagnostic()`, which returns the plain error object every Lamina PDF surface uses, and the worker posts that object for any error that has the method. `RenderError`'s `toJSON()`, `reviveRenderError` and `SerializedRenderError` are gone, and `ByteRange` and `RenderErrorKind` name its types. The viewer's worker now throws `RenderError`s for `cancelled` and `disposed` instead of plain objects, so they reach the page with a `docsUrl`. - `@laminapdf/render` has one `WasmSource` type for where an engine's `.wasm` comes from (a URL, a `Response` or a promise of one, the bytes or a compiled module). `CloneableWasmSource` is the subset a Web Worker can be sent. `initPdfium` and `initHayro` both take it: `@laminapdf/render-pdfium`'s own `PdfiumWasm` type is gone, and `initHayro` no longer takes a `Request`, a promise of anything but a `Response`, or a view of a `SharedArrayBuffer`. `RenderBackend` declares `memory()`, which both backends already had; a backend written outside this repository has to add it. The adapters share the page-index check, the read handling and the pending-range merging, so hayro now reports adjacent missing reads as one range, as PDFium did. - The `rotatePage` op now reads a `/Rotate` stored as an integral real, such as `90.0`, as the rotation the page displays; before, it read 0. `PageBuilder.rotate` now refuses angles other than 0, 90, 180 and 270, as its type says. In the Rust crates, `Rotation` from `laminapdf-syntax` replaces `laminapdf-render`'s `ViewRotation` and the degrees in `RenderPage::rotate`, and `laminapdf_cos::Page` drops `media_box`, `crop_box` and `rotate`: read them, and their strict-mode errors, from `laminapdf-model`'s page view. ## 0.2.0-alpha.0 ### Minor Changes - Accessible and archival PDFs. A `profiles` option on the builder (`PDF/UA-1`, `PDF/A-2b`, `PDF/A-2u`, `PDF/A-3b`; `BuildOptions::with_profile` in Rust) checks each machine-checkable rule the document breaks when it finishes, with a coded error per rule: `missing-title`, `missing-lang`, `untagged-content`, `missing-alt-text`, `heading-level-skipped`, `unnumbered-heading-misused`, `invalid-structure-nesting`, `link-without-structure`, `link-without-contents`, `font-not-embedded`, `glyph-without-unicode`, `device-color-without-output-intent`, `embedded-file-forbidden`, `embedded-file-without-mime-type` and `profile-version-conflict`. The builder writes the XMP identification, `/DisplayDocTitle` for PDF/UA-1 and, for PDF/A, an sRGB output intent with a 480-byte embedded ICC profile. `attach(name, bytes, mime, { relationship })` adds associated files for PDF/A-3b, such as an e-invoice's XML. Every example that claims a profile is validated by veraPDF each night. - `@laminapdf/sdk/worker` can be extended: `@laminapdf/sdk/worker/server`'s `serve({ methods })` adds methods to the same worker, called with `client.request(method, params, { priority, key })`. The worker runs higher priorities first and coalesces calls with the same key. `emit()` sends events to `client.on("event")`, and `connect({ onDiscard })` sees late answers (by default it closes their ImageBitmaps). The worker protocol is now version 2. ## 0.1.0-alpha.0 ### Minor Changes - First alpha, `0.1.0-alpha.0`, versioned and tagged but not published (ADR 0017). The `lamina` façade and its core crates at the same version: open, read, edit and save PDFs (incremental and full rewrites, the save planner, undo/redo), generate them (pages, vectors, colour, images, standard 14 and embedded fonts with shaping, bidi and line breaking, links, outlines, metadata, tagged PDF), and extract page text; `@laminapdf/sdk` over the base, text and extract WebAssembly modules in browsers, Node, Deno, Bun and Cloudflare Workers. --- # Glossary The PDF and Lamina PDF terms the docs use, each in a sentence or two. The terms the docs use, in plain words. Each page links a term here the first time it uses one. ## How Lamina PDF works ### Overlay Where Lamina PDF keeps your edits. The bytes you opened never change; the overlay sits on top of them, and saving writes it out. See [how Lamina PDF thinks about a PDF](/concepts/how-lamina-thinks.md). ### Incremental save A save that keeps the original bytes as they are and appends a new revision with your edits after them. Signatures over the original stay valid. ### Full rewrite A save that writes the whole file again. The result is smaller and tidier, but it's no longer the original bytes, so it breaks any signature. ### Façade The `laminapdf` crate: one API in front of Lamina PDF's core crates, which most Rust code depends on alone. ### Glue The JavaScript that sits next to each `.wasm` file. It loads the module and converts values between JavaScript and Rust. ### Export condition A key in a package's `exports` map, such as `node`, `browser`, `deno` or `workerd`, that tells a runtime or bundler which file to load. `@laminapdf/sdk` has one per runtime. ## Inside a PDF file ### COS PDF's object layer: the dictionaries, arrays, numbers, strings, names, streams and references a file is made of. ### Content stream A stream of drawing instructions for a page: where to put text, which lines and shapes to draw, which images to place. ### XObject A reusable piece of content that a content stream draws by name: an image, or a form, which is a group of drawing instructions. ### Cross-reference table The index at the end of a file that gives the byte offset of every object, so a reader can jump straight to one. Newer files store it as a compressed stream instead. ### Xref Short for [cross-reference table](#cross-reference-table). ### Object stream A compressed stream that holds many small objects together, to make the file smaller. Files from PDF 1.5 on can use them. ### Page space Lamina PDF's coordinates: points from the top-left corner of the page as it's displayed, with y going down. See [pages and coordinates](/concepts/pages-and-coordinates.md). ### Crop box The part of a page a viewer shows; anything outside it is cut off. Page space starts at its top-left corner. ### User unit A page entry that makes each user-space unit bigger than 1/72 of an inch, for very large pages. Lamina PDF applies it, so page-space numbers are always points of 1/72 inch. ### User space PDF's own coordinates: points from the bottom-left corner of the page, with y going up, before any rotation. Lamina PDF's API uses page space instead; see [pages and coordinates](/concepts/pages-and-coordinates.md). ## Fonts and text ### Standard 14 fonts The fourteen fonts every PDF reader supplies: Times, Helvetica and Courier, each in four styles, plus Symbol and ZapfDingbats. A PDF can use them without embedding them. ### Type 0 A font type whose character codes can be more than one byte long, so a font can reach any of its glyphs. Lamina PDF embeds every font except the standard 14 as Type 0. ### CMap A table that maps a font's character codes to character IDs (CIDs), which pick its glyphs, or, in a ToUnicode CMap, to Unicode text. ### ToUnicode A CMap a font carries that maps its codes to Unicode, so a reader can copy, search and extract the text. ## Signatures ### DocMDP The permissions of a certifying signature: which changes the signed document still allows, such as none, filling in forms, or comments. DocMDP stands for document modification detection and prevention. ### UR3 A usage-rights signature, which unlocks features such as saving form data in Adobe Reader. An edit it doesn't allow invalidates it. --- # Builder Builds a new document: pages, navigation and metadata; [Builder.finish](/reference/sdk/classes/builder.md#finish) returns a [Document](/reference/sdk/classes/document.md) to save. Created in the text module when it is loaded (or `text: true`), else in the reading module (text, extract, then base; vector and image pages only). `finish()` frees its WebAssembly memory; to drop it unfinished, use `using` or [Builder.free](/reference/sdk/classes/builder.md#free). ## Example ```ts await init({ text: true }); const b = new Builder({ producer: "my app" }); const page = b.page("A4"); page.surface().text("Hello, world!", { font: Font.standard("Helvetica"), size: 24, at: [72, 96] }); page.finish(); const bytes = b.finish().save(); ``` ## Extends - `Handle`\<`BaseGlue`\> ## Constructors ### Constructor > **new Builder**(`options?`): `Builder` #### Parameters ##### options? [`BuilderOptions`](/reference/sdk/interfaces/builderoptions.md) = `{}` version, file ID, producer, creation date, compression, tagging, conformance `profiles`; `text`. #### Returns `Builder` #### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) `not-initialized`, `text-module-not-loaded`; `unknown-option`. #### Overrides `Handle.constructor` ## Accessors ### hasText #### Get Signature > **get** **hasText**(): `boolean` Whether this builder draws in the text module (fonts and text). ##### Returns `boolean` ## Methods ### \[dispose\]() > **\[dispose\]**(): `void` Frees the handle at the end of a `using` block (an alias of `free()`). #### Returns `void` #### Inherited from `Handle.[dispose]` *** ### attach() > **attach**(`name`, `bytes`, `mime`, `options?`): `this` Attaches a file: PDF/A-3b's associated files, such as an e-invoice's XML. The same name again replaces the file. PDF/A-2b and PDF/A-2u refuse attachments at [Builder.finish](/reference/sdk/classes/builder.md#finish), and every PDF/A profile refuses one without a MIME type there. #### Parameters ##### name `string` the file name viewers show. ##### bytes `Uint8Array` the file's contents. ##### mime `string` its MIME type, such as `text/xml`. ##### options? `any` how the file relates to the document (`relationship`, default `unspecified`), its `description` and `modDate`. #### Returns `this` #### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) `invalid-argument` for a `modDate` that isn't a PDF date, `unknown-option` for an unknown key, `builder-finished` after [Builder.finish](/reference/sdk/classes/builder.md#finish). *** ### destination() > **destination**(`name`, `index`, `fit?`): `this` Defines the named destination `name`: page `index`, shown as `fit` (default: the page). #### Parameters ##### name `string` ##### index `number` ##### fit? `any` #### Returns `this` *** ### finish() > **finish**(): [`Document`](/reference/sdk/classes/document.md) Writes fonts, links, the page tree, outline and metadata; the builder (and its unfinished pages) take no more calls afterwards. #### Returns [`Document`](/reference/sdk/classes/document.md) the new document (unsaved; its save is a full rewrite). #### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) `page-index-out-of-range`, `unknown-destination`, `invalid-page-labels`, `invalid-geometry`, `subset-failed`, `builder-finished`; under a profile (`profiles` in the options), the first rule the document breaks, with that rule's code (the error reference lists them, from `profile-version-conflict` on). *** ### font() > **font**(`font`): [`FontInfo`](/reference/sdk/interfaces/fontinfo.md) Registers `font` with this builder (otherwise it is registered at first use). #### Parameters ##### font [`Font`](/reference/sdk/classes/font.md) #### Returns [`FontInfo`](/reference/sdk/interfaces/fontinfo.md) #### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) `text-module-not-loaded`, `invalid-font`, `font-not-embeddable`. *** ### free() > **free**(): `void` Frees the handle's memory now; without it, the garbage collector frees it later. A second call does nothing, and any other call afterwards throws `disposed`. Prefer `using`, which calls this at the end of the block. #### Returns `void` #### Inherited from `Handle.free` *** ### lang() > **lang**(`tag`): `this` The natural language (`/Lang`), a BCP 47 tag. #### Parameters ##### tag `string` #### Returns `this` *** ### metadata() > **metadata**(`metadata`): `this` The metadata, written as the Info dictionary and an XMP packet. #### Parameters ##### metadata `MetadataInit` #### Returns `this` *** ### outline() > **outline**(`entries`): `string`[] Adds outline (bookmark) items with their children. #### Parameters ##### entries readonly `OutlineEntryInit`[] #### Returns `string`[] their IDs, in pre-order. *** ### page() > **page**(`size?`): [`Page`](/reference/sdk/classes/page.md) A new page of a named size or `[width, height]` in points. #### Parameters ##### size? [`PageSizeName`](/reference/sdk/type-aliases/pagesizename.md) \| readonly \[`number`, `number`\] = `"A4"` #### Returns [`Page`](/reference/sdk/classes/page.md) *** ### pageLabels() > **pageLabels**(`ranges`): `this` The page labels (`i, ii, 1, 2…`), ranges by first page; checked at finish. #### Parameters ##### ranges readonly `PageLabelInit`[] #### Returns `this` *** ### viewerPreferences() > **viewerPreferences**(`preferences`): `this` How viewers present the document when it opens. #### Parameters ##### preferences `ViewerPreferencesInit` #### Returns `this` *** ### create() > `static` **create**(`options?`): `Builder` As `new Builder(options)`. #### Parameters ##### options? [`BuilderOptions`](/reference/sdk/interfaces/builderoptions.md) #### Returns `Builder` --- # Document An open PDF: read it, edit it with ops (atomic transactions with undo), save it. It holds WebAssembly memory: free it with `using` or [Document.free](/reference/sdk/classes/document.md#free) (an alias). ## Example ```ts await init(); using doc = Document.open(bytes); doc.transact((tx) => tx.setInfo({ title: "Final" })); const updated = doc.save({ mode: "incremental" }); ``` ## Extends - `Handle`\<`BaseGlue`\> ## Accessors ### canExtractText #### Get Signature > **get** **canExtractText**(): `boolean` Whether `page(i).text()` works on this document: it was opened or built while the extract or the text module was loaded. A document opened before the module loaded stays in the base module; save and reopen it to move it. ##### Returns `boolean` *** ### hasUnsavedChanges #### Get Signature > **get** **hasUnsavedChanges**(): `boolean` Whether there are changes no save has written yet. ##### Returns `boolean` *** ### info #### Get Signature > **get** **info**(): `DocumentInfoJs` The Info dictionary's text entries (document text). ##### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) reading errors. ##### Returns `DocumentInfoJs` *** ### pageCount #### Get Signature > **get** **pageCount**(): `number` The number of pages. ##### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) reading errors. ##### Returns `number` *** ### pages #### Get Signature > **get** **pages**(): readonly `PageInfoJs`[] Every page's facts, in order (one page-tree walk). ##### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) reading errors. ##### Returns readonly `PageInfoJs`[] *** ### warnings #### Get Signature > **get** **warnings**(): readonly [`LaminaPdfWarning`](/reference/sdk/type-aliases/laminapdfwarning.md)[] Every warning so far (build warnings first), each the ADR 0026 §1 object. ##### Returns readonly [`LaminaPdfWarning`](/reference/sdk/type-aliases/laminapdfwarning.md)[] ## Methods ### \[dispose\]() > **\[dispose\]**(): `void` Frees the handle at the end of a `using` block (an alias of `free()`). #### Returns `void` #### Inherited from `Handle.[dispose]` *** ### apply() > **apply**(`ops`): `EditedJs` Applies `ops` in order as one transaction: all of them, or none. #### Parameters ##### ops readonly `OpJs`[] #### Returns `EditedJs` the pages the edit touched. #### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) `unknown-option` / `invalid-argument` for a value that isn't an op; the first failing op's error (`unknown-page`, …), with nothing applied. *** ### applyLog() > **applyLog**(`log`): `EditedJs` Applies an op log (`{ v: 1, ops }`, as JSON text or an object) as one transaction. #### Parameters ##### log `string` \| [`OpLog`](/reference/sdk/interfaces/oplog.md) #### Returns `EditedJs` #### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) `invalid-op-log`, `unsupported-version`; as [Document.apply](/reference/sdk/classes/document.md#apply). *** ### ensure() > **ensure**\<`T`\>(`f`, `options?`): `Promise`\<`T`\> Runs a synchronous call on this document, fetching what it needs first: for a document from [Document.openRange](/reference/sdk/classes/document.md#openrange), a `data-pending` makes it fetch the ranges asked for and call `f` again. For other documents it just returns `f()`. #### Type Parameters ##### T `T` #### Parameters ##### f () => `T` \| `Promise`\<`T`\> the call (it may run several times; keep it free of side effects). An async function works too: its `data-pending` rejections are caught the same way. ##### options? `signal` aborts this call's fetches (the open's signal applies to the open only). ###### signal? `AbortSignal` #### Returns `Promise`\<`T`\> what `f` returns. #### Example ```ts const doc = await Document.openRange(httpRangeSource(url)); const pages = await doc.ensure(() => doc.pages); ``` #### Throws what `f` throws, and as [Document.openRange](/reference/sdk/classes/document.md#openrange). *** ### extract() > **extract**\<`F`\>(`options`): [`ExtractResult`](/reference/sdk/interfaces/extractresult.md)\[`F`\] The document's text for retrieval and LLMs: Markdown or plain text in reading order, the `laminapdf.extract/1` JSON with a box for every block, or chunks sized for retrieval. Tables drawn with lines come out as tables, and text a reader can't see is left out. Needs a document opened while the extract or text module was loaded. #### Type Parameters ##### F `F` *extends* `ExtractFormat` #### Parameters ##### options [`ExtractOptions`](/reference/sdk/interfaces/extractoptions.md)\<`F`\> #### Returns [`ExtractResult`](/reference/sdk/interfaces/extractresult.md)\[`F`\] a string for `"markdown"` and `"text"`, an [Extraction](/reference/sdk/type-aliases/extraction.md) for `"json"`, [Chunk](/reference/sdk/type-aliases/chunk.md)s for `"chunks"`. #### Example ```ts await init({ extract: true }); const doc = Document.open(bytes); const markdown = doc.extract({ format: "markdown" }); const chunks = doc.extract({ format: "chunks", chunk: { maxTokens: 400 } }); ``` #### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) `extract-module-not-loaded` for a document opened in the base module; `page-index-out-of-range`; `unknown-option` (with `didYouMean`) / `invalid-argument` for bad options; reading errors (`data-pending` for a range document: use `ensure`). *** ### free() > **free**(): `void` Frees the handle's memory now; without it, the garbage collector frees it later. A second call does nothing, and any other call afterwards throws `disposed`. Prefer `using`, which calls this at the end of the block. #### Returns `void` #### Inherited from `Handle.free` *** ### page() > **page**(`index`): [`PageRef`](/reference/sdk/classes/pageref.md) A handle on page `index` that follows the page by its ID. #### Parameters ##### index `number` #### Returns [`PageRef`](/reference/sdk/classes/pageref.md) #### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) `page-index-out-of-range`. *** ### pageById() > **pageById**(`id`): [`PageRef`](/reference/sdk/classes/pageref.md) A handle on the page with `id` (`p12.0`). #### Parameters ##### id `string` #### Returns [`PageRef`](/reference/sdk/classes/pageref.md) #### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) `invalid-argument`, `unknown-page`. *** ### pageInfo() > **pageInfo**(`index`): `PageInfoJs` Page `index`'s facts. #### Parameters ##### index `number` #### Returns `PageInfoJs` #### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) `page-index-out-of-range`. *** ### rebase() > **rebase**(`bytes`): `void` Rebases onto `bytes`, the output of a save whose rebase failed (`not-rebased`). #### Parameters ##### bytes `Uint8Array` #### Returns `void` #### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) `no-pending-save`; reading errors in `bytes`. *** ### redo() > **redo**(): `any` Redoes the newest undone transaction. #### Returns `any` how it went, or `undefined` when there is nothing to redo. #### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) as [Document.undo](/reference/sdk/classes/document.md#undo). *** ### save() > **save**(`options?`): `Uint8Array`\<`ArrayBuffer`\> Saves and rebases onto the output (later edits and saves build on it). #### Parameters ##### options? `any` `mode` (`auto`, `incremental`, `fullRewrite`), overrides, output form. #### Returns `Uint8Array`\<`ArrayBuffer`\> the file's bytes. #### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) `would-invalidate-signatures`, `requires-rewrite`, `encrypted-base`, … *** ### saveJob() > **saveJob**(`options?`): [`SaveJob`](/reference/sdk/classes/savejob.md) A save as a job stepped by the caller (to keep a UI responsive). #### Parameters ##### options? `any` #### Returns [`SaveJob`](/reference/sdk/classes/savejob.md) #### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) `unknown-option` / `invalid-argument` for bad options. *** ### saveWithReport() > **saveWithReport**(`options?`): `object` As [Document.save](/reference/sdk/classes/document.md#save), with the save's report (mode used, bytes written, renumbering, …). #### Parameters ##### options? `any` #### Returns `object` ##### bytes > `readonly` **bytes**: `Uint8Array`\<`ArrayBuffer`\> ##### report > `readonly` **report**: `SaveReportJs` #### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) as [Document.save](/reference/sdk/classes/document.md#save). *** ### transact() > **transact**\<`T`\>(`edit`): `T` Runs `edit` with a transaction handle and applies what it collected as one transaction (ADR 0006 §2). When `edit` throws, nothing is applied and the error propagates. The ops are applied when `edit` returns: reads inside `edit` see the document before the transaction. #### Type Parameters ##### T `T` #### Parameters ##### edit (`tx`) => `T` collects ops on `tx`; synchronous only (a callback returning a promise is refused with `invalid-argument` and nothing is applied). #### Returns `T` what `edit` returned. #### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) `nested-transaction` when called (or when `apply`, `undo`, `redo`, `save`… are called) inside a `transact` of the same document; `invalid-argument` for an async callback; as [Document.apply](/reference/sdk/classes/document.md#apply). #### Example ```ts doc.transact((tx) => { tx.setInfo({ title: "Report" }); tx.rotatePage(doc.page(0).id, 90); }); ``` *** ### undo() > **undo**(): `any` Undoes the newest transaction. #### Returns `any` how it went, or `undefined` when there is nothing to undo. #### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) `saved-not-rebased`; reading errors. *** ### open() > `static` **open**(`bytes`, `options?`): `Document` Opens a document from its bytes (the bytes are copied into the WASM memory). The password is tried as given. When that fails with `wrong-password` and its NFKC form differs, the NFKC form is tried too: R5/R6 passwords are prepared with SASLprep, whose normalisation the WASM modules leave out (ADR 0016 §7), while R2–R4 passwords must reach the core unchanged. #### Parameters ##### bytes `ArrayBuffer` \| `Uint8Array`\<`ArrayBufferLike`\> the PDF file. ##### options? [`OpenOptions`](/reference/sdk/interfaces/openoptions.md) password, strict mode, limits. #### Returns `Document` the document. #### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) `not-pdf`, `password-required`, `wrong-password`, `limit-exceeded`, `strict`, …; `unknown-option` / `invalid-argument` for bad options; `not-initialized`. *** ### openRange() > `static` **openRange**(`source`, `options?`): `Promise`\<`Document`\> Opens a document whose bytes are fetched range by range ([httpRangeSource](/reference/sdk/functions/httprangesource.md), [callbackSource](/reference/sdk/functions/callbacksource.md)): only what the open needs is fetched (the end of the file, the xref, the catalog). Calls on the document stay synchronous; one that needs bytes not fetched yet throws `data-pending` with `ranges`, so wrap later reads in [Document.ensure](/reference/sdk/classes/document.md#ensure). #### Parameters ##### source [`RangeSource`](/reference/sdk/interfaces/rangesource.md) \| `Promise`\<[`RangeSource`](/reference/sdk/interfaces/rangesource.md)\> the file (or a promise of it, as `httpRangeSource` returns). ##### options? [`OpenOptions`](/reference/sdk/interfaces/openoptions.md) & [`RangeOptions`](/reference/sdk/interfaces/rangeoptions.md) = `{}` open options (password, strict, limits) and fetch options. #### Returns `Promise`\<`Document`\> the document, once open has everything it needs. #### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) as [Document.open](/reference/sdk/classes/document.md#open); `data-pending` when fetching made no progress or `maxRounds` ran out; `source-budget-exceeded`; the source's own errors (`fetch-failed`, `source-changed`); the signal's reason when aborted. --- # Font A font to draw text with: TrueType or OpenType bytes (subset to the glyphs drawn and embedded) or one of the standard 14. Needs the text module (`init({ text: true })`). ## Example ```ts const inter = Font.fromBytes(await (await fetch("/Inter.ttf")).arrayBuffer()); surface.text("Hello", { font: inter, size: 12, at: [72, 72] }); ``` ## Properties ### bytes > `readonly` **bytes**: `Uint8Array`\<`ArrayBufferLike`\> \| `undefined` The font program, for a font loaded from bytes. *** ### standard > `readonly` **standard**: [`Standard14`](/reference/sdk/type-aliases/standard14.md) \| `undefined` The PostScript name, for a standard 14 font. ## Methods ### fromBytes() > `static` **fromBytes**(`bytes`): `Font` A font from its bytes (TrueType or OpenType/CFF). The bytes are checked when a builder first uses the font (`invalid-font`, `font-not-embeddable`). #### Parameters ##### bytes `ArrayBuffer` \| `Uint8Array`\<`ArrayBufferLike`\> #### Returns `Font` *** ### standard() > `static` **standard**(`name`): `Font` One of the standard 14 fonts. #### Parameters ##### name [`Standard14`](/reference/sdk/type-aliases/standard14.md) #### Returns `Font` --- # Image An image to draw: a JPEG (passed through), a PNG (alpha as a soft mask) or raw RGBA pixels. The bytes are copied when the image is created. It is decoded once per WASM module, the first time a builder of that module draws it (or `width`/`height` is read), so problems with the data (`invalid-image`, `unsupported-image`, …) surface there. Written once per document however often it is drawn. It keeps its bytes, so it outlives `reset()` and decodes again. ## Accessors ### height #### Get Signature > **get** **height**(): `number` Height in pixels (decodes the image if no module has yet). ##### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) as `in`. ##### Returns `number` *** ### width #### Get Signature > **get** **width**(): `number` Width in pixels (decodes the image if no module has yet). ##### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) as `in`. ##### Returns `number` ## Methods ### \[dispose\]() > **\[dispose\]**(): `void` Frees the image at the end of a `using` block (an alias of `free()`). #### Returns `void` *** ### free() > **free**(): `void` Frees the image's WASM memory now; without it, the garbage collector frees it later. A second call does nothing, and any other call afterwards throws `disposed`. Prefer `using`. #### Returns `void` *** ### jpeg() > `static` **jpeg**(`bytes`): `Image` A JPEG. #### Parameters ##### bytes `Uint8Array` #### Returns `Image` *** ### png() > `static` **png**(`bytes`): `Image` A PNG. #### Parameters ##### bytes `Uint8Array` #### Returns `Image` *** ### rgba() > `static` **rgba**(`width`, `height`, `pixels`): `Image` 8-bit RGBA pixels, row by row (`image-data-length` at first use for a wrong length). #### Parameters ##### width `number` ##### height `number` ##### pixels `Uint8Array` #### Returns `Image` --- # LaminaPdfError The one error class of `@laminapdf/sdk` (ADR 0026 §7). `message`, `hint` and `didYouMean` never hold document text (ADR 0026 §4); that goes in `data`, marked untrusted on the code's page at `docsUrl`. ## Example ```ts try { Document.open(bytes, { password: "secret" }); } catch (e) { if (isLaminaPdfError(e, "wrong-password")) askAgain(); else throw e; } ``` ## Extends - `Error` ## Type Parameters ### C `C` *extends* [`ErrorCode`](/reference/sdk/type-aliases/errorcode.md) = [`ErrorCode`](/reference/sdk/type-aliases/errorcode.md) ## Constructors ### Constructor > **new LaminaPdfError**\<`C`\>(`diagnostic`, `options?`): `LaminaPdfError`\<`C`\> #### Parameters ##### diagnostic [`Diagnostic`](/reference/sdk/interfaces/diagnostic.md)\<`C`\> the ADR 0026 §1 object. ##### options? `cause`, when wrapping another error. ###### cause? `unknown` #### Returns `LaminaPdfError`\<`C`\> #### Overrides `Error.constructor` ## Properties ### cause? > `optional` **cause?**: `unknown` #### Inherited from `Error.cause` *** ### code > `readonly` **code**: `C` The stable code (`fixtures/codes.json`). *** ### data? > `readonly` `optional` **data?**: `Readonly`\<`Record`\<`string`, `unknown`\>\> Code-specific fields, documented on the code's page (the only place document text appears). *** ### didYouMean? > `readonly` `optional` **didYouMean?**: readonly `string`[] For `usage` errors: the closest known names (Lamina PDF's own vocabulary). *** ### docsUrl > `readonly` **docsUrl**: `string` The code's page. *** ### hint? > `readonly` `optional` **hint?**: `string` One actionable sentence for this code. *** ### kind > `readonly` **kind**: [`ErrorKind`](/reference/sdk/type-aliases/errorkind.md) What the caller can do about it. *** ### location? > `readonly` `optional` **location?**: [`Location`](/reference/sdk/interfaces/location.md) Where in the input or the document. *** ### message > **message**: `string` #### Inherited from `Error.message` *** ### name > `readonly` **name**: `"LaminaPdfError"` = `"LaminaPdfError"` #### Overrides `Error.name` *** ### stack? > `optional` **stack?**: `string` #### Inherited from `Error.stack` ## Accessors ### ranges #### Get Signature > **get** **ranges**(): readonly `object`[] \| `undefined` For `data-pending`: the byte ranges a range document needs fetched before the call can succeed (`data.ranges`); `undefined` for other codes. [Document.ensure](/reference/sdk/classes/document.md#ensure) fetches them. ##### Returns readonly `object`[] \| `undefined` ## Methods ### toJSON() > **toJSON**(): [`Diagnostic`](/reference/sdk/interfaces/diagnostic.md)\<`C`\> The ADR 0026 §1 object (for logs, workers and `--json` output); optional fields omitted. #### Returns [`Diagnostic`](/reference/sdk/interfaces/diagnostic.md)\<`C`\> --- # Page A page being drawn: its boxes and rotation, its [Surface](/reference/sdk/classes/surface.md), then [Page.finish](/reference/sdk/classes/page.md#finish). Freed with its builder; `using` or [Page.free](/reference/sdk/classes/page.md#free) drops it without writing it. ## Methods ### \[dispose\]() > **\[dispose\]**(): `void` Drops the page at the end of a `using` block (an alias of `free()`). #### Returns `void` *** ### artBox() > **artBox**(`rect`): `this` `/ArtBox`. #### Parameters ##### rect [`Rect`](/reference/sdk/type-aliases/rect.md) #### Returns `this` *** ### bleedBox() > **bleedBox**(`rect`): `this` `/BleedBox`. #### Parameters ##### rect [`Rect`](/reference/sdk/type-aliases/rect.md) #### Returns `this` *** ### cropBox() > **cropBox**(`rect`): `this` `/CropBox` (the visible region), in the media box's page space. #### Parameters ##### rect [`Rect`](/reference/sdk/type-aliases/rect.md) #### Returns `this` *** ### finish() > **finish**(): `string` Writes the page into its builder and frees the page's WASM side; the page takes no more drawing afterwards (`builder-finished`). #### Returns `string` the page's ID. #### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) `invalid-geometry`, `builder-finished`. *** ### free() > **free**(): `void` Drops the page without writing it and frees its WASM side; a second call does nothing, and any other call afterwards throws `disposed`. #### Returns `void` *** ### rotate() > **rotate**(`degrees`): `this` `/Rotate`: 0, 90, 180 or 270 degrees. #### Parameters ##### degrees `0` \| `90` \| `180` \| `270` #### Returns `this` *** ### surface() > **surface**(): [`Surface`](/reference/sdk/classes/surface.md) What the page is drawn with (the same object on every call). #### Returns [`Surface`](/reference/sdk/classes/surface.md) *** ### trimBox() > **trimBox**(`rect`): `this` `/TrimBox`. #### Parameters ##### rect [`Rect`](/reference/sdk/type-aliases/rect.md) #### Returns `this` *** ### userUnit() > **userUnit**(`unit`): `this` `/UserUnit`, for pages larger than 14,400 units. #### Parameters ##### unit `number` #### Returns `this` --- # PageRef A page of a [Document](/reference/sdk/classes/document.md), held by its ID: moving pages doesn't retarget it (ADR 0028 F2). Freed with its document; every call after that throws `disposed`. ## Extends - `Handle`\<`BaseGlue`\> ## Accessors ### id #### Get Signature > **get** **id**(): `string` The page's stable ID (`p12.0`). ##### Returns `string` *** ### index #### Get Signature > **get** **index**(): `number` The page's current 0-based index. ##### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) `unknown-page` when it is gone. ##### Returns `number` *** ### info #### Get Signature > **get** **info**(): `PageInfoJs` The page's facts now. ##### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) `unknown-page` when it is gone. ##### Returns `PageInfoJs` ## Methods ### \[dispose\]() > **\[dispose\]**(): `void` Frees the handle at the end of a `using` block (an alias of `free()`). #### Returns `void` #### Inherited from `Handle.[dispose]` *** ### free() > **free**(): `void` Frees the handle's memory now; without it, the garbage collector frees it later. A second call does nothing, and any other call afterwards throws `disposed`. Prefer `using`, which calls this at the end of the block. #### Returns `void` #### Inherited from `Handle.free` *** ### text() > **text**(`options?`): [`PageText`](/reference/sdk/classes/pagetext.md) The page's text: lines in reading order, runs, and every glyph's quad in page space. Needs a document opened while the extract or text module was loaded. #### Parameters ##### options? [`PageTextOptions`](/reference/sdk/interfaces/pagetextoptions.md) `skipInvisible` leaves out invisible text (OCR layers). #### Returns [`PageText`](/reference/sdk/classes/pagetext.md) #### Example ```ts await init({ extract: true }); const doc = Document.open(bytes); const text = doc.page(0).text().text(); ``` #### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) `extract-module-not-loaded` for a document opened in the base module; `unknown-page`; reading errors (`data-pending` for a range document: use `ensure`). --- # PageText A page's extracted text: lines in reading order, and runs with their glyphs' quads. ## Properties ### glyphCount > `readonly` **glyphCount**: `number` The number of glyphs. ## Accessors ### buffers #### Get Signature > **get** **buffers**(): `object` The arrays as the WASM returned them: `quads` (8 numbers per glyph), `glyphText` and `glyphOffsets` (glyph i is `glyphText.slice(glyphOffsets[i], glyphOffsets[i + 1])`). Post `quads.buffer` and `glyphOffsets.buffer` as transferables to move them to another thread; that detaches them here, and this PageText's `glyph()` and `glyphs()` then throw (`lines` and `runs` keep working). They are this PageText's own arrays, not copies: writing to them changes what `glyph()` returns. ##### Returns `object` ###### glyphOffsets > `readonly` **glyphOffsets**: `Uint32Array` ###### glyphText > `readonly` **glyphText**: `string` ###### quads > `readonly` **quads**: `Float32Array` *** ### lines #### Get Signature > **get** **lines**(): readonly `string`[] The text by line (runs on one baseline joined, trimmed), in content order. ##### Returns readonly `string`[] *** ### runs #### Get Signature > **get** **runs**(): readonly [`PageTextRun`](/reference/sdk/classes/pagetextrun.md)[] The runs, in content order (made on first use). ##### Returns readonly [`PageTextRun`](/reference/sdk/classes/pagetextrun.md)[] *** ### unmapped #### Get Signature > **get** **unmapped**(): `number` Glyphs whose Unicode is unknown (shown as U+FFFD). ##### Returns `number` ## Methods ### glyph() > **glyph**(`i`): [`TextGlyph`](/reference/sdk/interfaces/textglyph.md) Glyph `i`'s text and quad. #### Parameters ##### i `number` #### Returns [`TextGlyph`](/reference/sdk/interfaces/textglyph.md) #### Throws `RangeError` for an index outside `0 … glyphCount - 1`; `TypeError` once [PageText.buffers](/reference/sdk/classes/pagetext.md#buffers)' arrays were transferred to another thread. *** ### text() > **text**(): `string` The page's text: its lines joined with `\n`. #### Returns `string` --- # PageTextRun A run: glyphs of one font, size, direction and marked content on one baseline, in logical order. ## Accessors ### actualText #### Get Signature > **get** **actualText**(): `boolean` The text comes from an `/ActualText` span. ##### Returns `boolean` *** ### bounds #### Get Signature > **get** **bounds**(): `object` The bounds of its glyph quads in page space: `{ x, y, width, height }`. ##### Returns `object` ###### height > `readonly` **height**: `number` ###### width > `readonly` **width**: `number` ###### x > `readonly` **x**: `number` ###### y > `readonly` **y**: `number` *** ### font #### Get Signature > **get** **font**(): `string` The font's `/BaseFont` (document text). ##### Returns `string` *** ### glyphRange #### Get Signature > **get** **glyphRange**(): readonly \[`number`, `number`\] The `[start, end)` range of its glyphs in the page's glyph arrays ([PageText.buffers](/reference/sdk/classes/pagetext.md#buffers)). ##### Returns readonly \[`number`, `number`\] *** ### invisible #### Get Signature > **get** **invisible**(): `boolean` Drawn invisibly (`Tr` 3 or 7: an OCR layer, hidden text). ##### Returns `boolean` *** ### mcid #### Get Signature > **get** **mcid**(): `number` \| `undefined` The marked-content ID the run is in, if any (structure tree links). ##### Returns `number` \| `undefined` *** ### size #### Get Signature > **get** **size**(): `number` Font size, the text matrix applied. ##### Returns `number` *** ### text #### Get Signature > **get** **text**(): `string` The run's text in logical order (document text). ##### Returns `string` *** ### vertical #### Get Signature > **get** **vertical**(): `boolean` Vertical writing. ##### Returns `boolean` ## Methods ### glyphs() > **glyphs**(): [`TextGlyph`](/reference/sdk/interfaces/textglyph.md)[] Its glyphs, with their quads (built on each call). #### Returns [`TextGlyph`](/reference/sdk/interfaces/textglyph.md)[] --- # PendingPath A path waiting to be painted: fill it, stroke it, both, or clip to it. ## Methods ### clip() > **clip**(`rule?`): [`Surface`](/reference/sdk/classes/surface.md) Clips what is drawn next to the path, until the next `restore`. #### Parameters ##### rule? `"nonZero"` \| `"evenOdd"` = `"nonZero"` #### Returns [`Surface`](/reference/sdk/classes/surface.md) the surface. *** ### fill() > **fill**(`rule?`): [`Surface`](/reference/sdk/classes/surface.md) Fills the path with the fill colour. #### Parameters ##### rule? `"nonZero"` \| `"evenOdd"` = `"nonZero"` #### Returns [`Surface`](/reference/sdk/classes/surface.md) the surface. *** ### fillStroke() > **fillStroke**(`rule?`, `style?`): [`Surface`](/reference/sdk/classes/surface.md) Fills, then strokes the path. #### Parameters ##### rule? `"nonZero"` \| `"evenOdd"` = `"nonZero"` ##### style? [`StrokeStyle`](/reference/sdk/interfaces/strokestyle.md) = `{}` #### Returns [`Surface`](/reference/sdk/classes/surface.md) the surface. *** ### stroke() > **stroke**(`style?`): [`Surface`](/reference/sdk/classes/surface.md) Strokes the path with the stroke colour and `style`. #### Parameters ##### style? [`StrokeStyle`](/reference/sdk/interfaces/strokestyle.md) = `{}` #### Returns [`Surface`](/reference/sdk/classes/surface.md) the surface. --- # SaveJob A save in progress, stepped by the caller with a budget of work units (ADR 0028 §3). Freed with its document; every call after that throws `disposed`. ## Extends - `Handle`\<`BaseGlue`\> ## Methods ### \[dispose\]() > **\[dispose\]**(): `void` Frees the handle at the end of a `using` block (an alias of `free()`). #### Returns `void` #### Inherited from `Handle.[dispose]` *** ### cancel() > **cancel**(): `void` Stops the job: its next step fails with `cancelled`. #### Returns `void` *** ### free() > **free**(): `void` Frees the handle's memory now; without it, the garbage collector frees it later. A second call does nothing, and any other call afterwards throws `disposed`. Prefer `using`, which calls this at the end of the block. #### Returns `void` #### Inherited from `Handle.free` *** ### step() > **step**(`budget`): `any` Runs at most `budget` work units. #### Parameters ##### budget `number` #### Returns `any` `{ state: "progress", … }`, or `{ state: "done", bytes, report }` at the end. #### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) `cancelled`, `document-changed`, `job-finished`, or the save's errors. --- # Surface What a page is drawn with. Methods that set state return the surface, so calls chain; invalid geometry is reported by [Page.finish](/reference/sdk/classes/page.md#finish) (`invalid-geometry`). ## Methods ### artifact() > **artifact**(`kind`, `draw`): `this` Draws what `draw` draws as an artifact: decoration, running heads and footers, backgrounds; content outside the structure tree. #### Parameters ##### kind `"layout"` \| `"page"` \| `"pagination"` \| `"background"` ##### draw (`s`) => `void` #### Returns `this` *** ### blendMode() > **blendMode**(`mode`): `this` The blend mode. #### Parameters ##### mode `"normal"` \| `"multiply"` \| `"screen"` \| `"overlay"` \| `"darken"` \| `"lighten"` \| `"colorDodge"` \| `"colorBurn"` \| `"hardLight"` \| `"softLight"` \| `"difference"` \| `"exclusion"` \| `"hue"` \| `"saturation"` \| `"color"` \| `"luminosity"` #### Returns `this` *** ### fillColor() > **fillColor**(`color`): `this` The colour fills (and text) use. #### Parameters ##### color [`Color`](/reference/sdk/type-aliases/color.md) #### Returns `this` *** ### image() > **image**(`image`, `rect`, `alt?`): `this` Draws `image` scaled into `rect`; with `alt`, as a `Figure` structure element with that alternate text. #### Parameters ##### image [`Image`](/reference/sdk/classes/image.md) ##### rect [`Rect`](/reference/sdk/type-aliases/rect.md) ##### alt? `string` #### Returns `this` *** ### link() > **link**(`rect`, `target`, `contents?`): `this` A link annotation over `rect`, with `contents` as its alternate text. #### Parameters ##### rect [`Rect`](/reference/sdk/type-aliases/rect.md) ##### target `LinkTargetInit` ##### contents? `string` #### Returns `this` *** ### opacity() > **opacity**(`fill`, `stroke`): `this` Fill and stroke opacity, 0 to 1. #### Parameters ##### fill `number` ##### stroke `number` #### Returns `this` *** ### path() > **path**(`build`): [`PendingPath`](/reference/sdk/classes/pendingpath.md) A path, built by `build`, to paint. #### Parameters ##### build (`p`) => `void` #### Returns [`PendingPath`](/reference/sdk/classes/pendingpath.md) #### Example ```ts surface.path((p) => p.moveTo(56, 590).lineTo(539, 590)).stroke({ width: 0.5 }); ``` *** ### restore() > **restore**(): `this` Restores the graphics state (`Q`). #### Returns `this` *** ### save() > **save**(): `this` Saves the graphics state (`q`). #### Returns `this` *** ### strokeColor() > **strokeColor**(`color`): `this` The colour strokes use. #### Parameters ##### color [`Color`](/reference/sdk/type-aliases/color.md) #### Returns `this` *** ### tag() > **tag**(`tag`, `draw`): `this` Draws what `draw` draws inside the structure element `tag`: its text, paths and images become the element's content, elements opened inside it its children, links its `Link` children, in reading order. Elements and artifacts `draw` leaves open end with it, also when it throws. A page using an element makes the document tagged. #### Parameters ##### tag [`Tag`](/reference/sdk/type-aliases/tag.md) ##### draw (`s`) => `void` #### Returns `this` #### Example ```ts s.tag("Sect", (s) => { s.text("Results", { font, size: 18, at: [56, 96], tag: "H1" }); s.tag({ type: "P", lang: "en-GB" }, (s) => s.textBox(summary, { font, size: 11, box })); }); ``` #### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) `invalid-argument` for an unknown type without a `role`. *** ### text() > **text**(`text`, `options`): `TextRunJs` Draws `text`: shaped, bidi-resolved, one line per line break (needs the text module). #### Parameters ##### text `string` ##### options [`TextOptions`](/reference/sdk/interfaces/textoptions.md) #### Returns `TextRunJs` what the run measures. #### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) `text-module-not-loaded`, `missing-glyph`, `font-not-shapable`, … *** ### textBox() > **textBox**(`text`, `options`): `TextLayoutJs` Lays `text` out in `options.box`, breaking lines at Unicode line-break opportunities. #### Parameters ##### text `string` ##### options [`TextBoxOptions`](/reference/sdk/interfaces/textboxoptions.md) #### Returns `TextLayoutJs` the lines drawn and where the overflow starts (UTF-16 offsets). #### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) as [Surface.text](/reference/sdk/classes/surface.md#text); `text-overflow` with `overflow: "error"`. *** ### transform() > **transform**(`matrix`): `this` Applies `matrix` (page space) to what is drawn next. See [Matrix](/reference/sdk/variables/matrix.md). #### Parameters ##### matrix [`Transform`](/reference/sdk/type-aliases/transform.md) #### Returns `this` --- # Transaction Collects the ops of one [Document.transact](/reference/sdk/classes/document.md#transact) call. ## Constructors ### Constructor > **new Transaction**(): `Transaction` #### Returns `Transaction` ## Accessors ### ops #### Get Signature > **get** **ops**(): readonly `OpJs`[] The ops collected so far. ##### Returns readonly `OpJs`[] ## Methods ### apply() > **apply**(`op`): `this` Adds `op` to the transaction. #### Parameters ##### op `OpJs` #### Returns `this` #### Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) `no-transaction` after `transact` returned. *** ### rotatePage() > **rotatePage**(`page`, `rotate`): `this` Sets a page's `/Rotate` (`rotatePage`). #### Parameters ##### page `string` the page's ID (`doc.page(0).id`). ##### rotate [`Rotation`](/reference/sdk/type-aliases/rotation.md) 0, 90, 180 or 270 degrees. #### Returns `this` *** ### setInfo() > **setInfo**(`patch`, `at?`): `this` Patches the Info dictionary (`setInfo`). #### Parameters ##### patch [`InfoPatch`](/reference/sdk/type-aliases/infopatch.md) the entries to set (a string) or remove (`null`). ##### at? `string` the modification date to write (`D:20261030120000Z`); omitted, none is written. #### Returns `this` --- # callbackSource > **callbackSource**(`size`, `read`): [`RangeSource`](/reference/sdk/interfaces/rangesource.md) A [RangeSource](/reference/sdk/interfaces/rangesource.md) over a caller's read function (a `Blob.slice`, a file handle, an IPC call). ## Parameters ### size `number` the file's size in bytes. ### read (`offset`, `length`, `signal?`) => `Promise`\<`Uint8Array`\<`ArrayBufferLike`\>\> returns the `length` bytes at `offset`. ## Returns [`RangeSource`](/reference/sdk/interfaces/rangesource.md) --- # httpRangeSource > **httpRangeSource**(`url`, `options?`): `Promise`\<[`RangeSource`](/reference/sdk/interfaces/rangesource.md)\> A [RangeSource](/reference/sdk/interfaces/rangesource.md) over a URL, for `Document.openRange`. The first request asks for the last `tail` bytes, which gives the size and the part of the file a reader needs first (the xref is at the end). Every later request sends `If-Range` with the first strong `ETag` (else its `Last-Modified`); a response whose `ETag`, `Last-Modified` or total size differs fails with `source-changed`. A server that ignores `Range` (it answers `200`) is read with that one full download. The server must allow `Range` (across origins: `Access-Control-Allow-Headers: Range` and `Access-Control-Expose-Headers: Content-Range, Content-Length, ETag`) and must not compress the response. ## Parameters ### url `string` \| `URL` the file. ### options? [`HttpRangeOptions`](/reference/sdk/interfaces/httprangeoptions.md) = `{}` headers, credentials, signal, block size. ## Returns `Promise`\<[`RangeSource`](/reference/sdk/interfaces/rangesource.md)\> the source, once the first request has answered. ## Throws [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) `fetch-failed` (with `data.status`), `source-changed`. --- # init > **init**(`input?`): `Promise`\<`void`\> Loads the base WebAssembly module (fetched next to the package); with `{ text: true }`, the text module too. Idempotent: later calls return at once (or load the text module). Everything else in the package is synchronous and needs this promise to have resolved. ## Parameters ### input? [`WasmSource`](/reference/sdk/type-aliases/wasmsource.md) \| [`InitOptions`](/reference/sdk/interfaces/initoptions.md) where the base module comes from (a URL, `Response`, bytes or `WebAssembly.Module`), or options; omitted, the file next to the package. ## Returns `Promise`\<`void`\> ## Example ```ts await init(); // or: await init({ text: true }) ``` --- # isExtractLoaded > **isExtractLoaded**(): `boolean` Whether documents opened or built from now on can extract text: the extract or the text module is loaded. A document opened earlier stays in the module it was opened in; ask it with [Document.canExtractText](/reference/sdk/classes/document.md#canextracttext). ## Returns `boolean` --- # isInitialized > **isInitialized**(): `boolean` Whether `init()` has finished. ## Returns `boolean` --- # isLaminaPdfError > **isLaminaPdfError**\<`C`\>(`e`, `code?`): `e is LaminaPdfError` Whether `e` is a [LaminaPdfError](/reference/sdk/classes/laminapdferror.md), with `code` when given. ## Type Parameters ### C `C` *extends* [`ErrorCode`](/reference/sdk/type-aliases/errorcode.md) ## Parameters ### e `unknown` anything caught. ### code? `C` the code to match. ## Returns `e is LaminaPdfError` true for a LaminaPdfError (with that code). --- # isTextLoaded > **isTextLoaded**(): `boolean` Whether the text module is loaded (builders created now can draw text). ## Returns `boolean` --- # loadExtract > **loadExtract**(`source?`): `Promise`\<`void`\> Loads the extract module (reading plus text extraction). Documents opened afterwards can extract text with `page.text()`. Idempotent; not needed when the text module is loaded. ## Parameters ### source? [`WasmSource`](/reference/sdk/type-aliases/wasmsource.md) where it comes from; omitted, the file next to the package. ## Returns `Promise`\<`void`\> --- # loadText > **loadText**(`source?`): `Promise`\<`void`\> Loads the text module (fonts, shaping and text layout; about 940 KB gzipped). Builders created afterwards can draw text. Idempotent. ## Parameters ### source? [`WasmSource`](/reference/sdk/type-aliases/wasmsource.md) where it comes from; omitted, the file next to the package. ## Returns `Promise`\<`void`\> --- # onLeak > **onLeak**(`hook?`): `void` Reports every document, builder and image created from now on that the garbage collector reclaims without a `free()` or `using`: a debugging aid for code that should free what it makes. Reclaimed handles are freed anyway, only later. `onLeak()` turns the reports off. ## Parameters ### hook? (`leak`) => `void` called once per leaked handle, some time after the collection. ## Returns `void` --- # reset > **reset**(): `void` Replaces each loaded module's instance with a fresh one, so its memory returns to the size it had after `init()`: WebAssembly memory grows and never shrinks, and this gives it back. Every document, page, builder and save job made before throws `instance-reset` afterwards, so call it while none is in use, such as between jobs on a server. Images and fonts keep working. ## Returns `void` --- # wasmMemory > **wasmMemory**(): [`WasmMemory`](/reference/sdk/interfaces/wasmmemory.md) The size of each loaded module's WebAssembly memory. Memory grows and never shrinks; freed documents and builders leave room that later ones reuse. ## Returns [`WasmMemory`](/reference/sdk/interfaces/wasmmemory.md) the byte length of each loaded module's memory. --- # BuilderOptions Options for a new [Builder](/reference/sdk/classes/builder.md). ## Extends - `Omit`\<[`BuildOptions`](/reference/sdk/type-aliases/buildoptions.md), `"id"`\> ## Indexable > \[`key`: `string`\]: `BuildOptionsInit` > \[`key`: `number`\]: `BuildOptionsInit` > \[`key`: `symbol`\]: `BuildOptionsInit` ## Properties ### id? > `readonly` `optional` **id?**: `string` \| `Uint8Array`\<`ArrayBufferLike`\> `/ID[0]`: 16 bytes, as 32 hex digits or a `Uint8Array` (default: derived from the content). *** ### text? > `readonly` `optional` **text?**: `boolean` Build in the text module (fonts and text). Default: when it is loaded. `true` without the text module loaded throws `text-module-not-loaded`. --- # ByteRange A byte range of the input. ## Properties ### length > `readonly` **length**: `number` Number of bytes. *** ### offset > `readonly` **offset**: `number` First byte. --- # ChunkOptions How the Markdown is cut into chunks. ## Properties ### maxTokens? > `readonly` `optional` **maxTokens?**: `number` A chunk's budget in approximate tokens (characters / 4); 512 by default. Blocks are never cut. *** ### overlap? > `readonly` `optional` **overlap?**: `number` Approximate tokens of whole blocks a chunk repeats from the one before it; 0 by default. *** ### splitOn? > `readonly` `optional` **splitOn?**: `any` Where chunks may start. --- # Diagnostic The ADR 0026 §1 object: what `LaminaPdfError.toJSON()` returns and the WASM throws. ## Type Parameters ### C `C` *extends* `string` = [`ErrorCode`](/reference/sdk/type-aliases/errorcode.md) ## Properties ### code > `readonly` **code**: `C` *** ### data? > `readonly` `optional` **data?**: `Readonly`\<`Record`\<`string`, `unknown`\>\> *** ### didYouMean? > `readonly` `optional` **didYouMean?**: readonly `string`[] *** ### docsUrl > `readonly` **docsUrl**: `string` *** ### hint? > `readonly` `optional` **hint?**: `string` *** ### kind > `readonly` **kind**: [`ErrorKind`](/reference/sdk/type-aliases/errorkind.md) *** ### location? > `readonly` `optional` **location?**: [`Location`](/reference/sdk/interfaces/location.md) *** ### message > `readonly` **message**: `string` --- # ExtractOptions Options for [Document.extract](/reference/sdk/classes/document.md#extract). ## Type Parameters ### F `F` *extends* [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) ## Properties ### chunk? > `readonly` `optional` **chunk?**: [`ChunkOptions`](/reference/sdk/interfaces/chunkoptions.md) How chunks are cut (`"chunks"`, and `"json"` to include them). *** ### dehyphenate? > `readonly` `optional` **dehyphenate?**: `boolean` Join words hyphenated across lines; on by default. *** ### drop? > `readonly` `optional` **drop?**: `object` `keepSpans` keeps each dropped run's text in its `dropped` entry (JSON), for auditing a file. `furniture` (on by default) drops running headers, footers, page numbers and artifact-marked text too. #### furniture? > `readonly` `optional` **furniture?**: `boolean` #### keepSpans? > `readonly` `optional` **keepSpans?**: `boolean` *** ### format > `readonly` **format**: `F` What to return: Markdown or plain text as a string, the JSON as an [Extraction](/reference/sdk/type-aliases/extraction.md), or [Chunk](/reference/sdk/type-aliases/chunk.md)s. *** ### pageMarkers? > `readonly` `optional` **pageMarkers?**: `boolean` Write `` before each page's first block (Markdown, chunks and the JSON's text ranges). *** ### pages? > `readonly` `optional` **pages?**: readonly `number`[] The 0-based page indices to extract, in this order; every page by default. *** ### readingOrder? > `readonly` `optional` **readingOrder?**: `any` Where the reading order comes from; `"auto"` by default. *** ### tables? > `readonly` `optional` **tables?**: `any` Which table detectors run; `"lattice"` by default. --- # ExtractResult The result of [Document.extract](/reference/sdk/classes/document.md#extract) for each format. ## Properties ### chunks > `readonly` **chunks**: [`Chunk`](/reference/sdk/type-aliases/chunk.md)[] *** ### json > `readonly` **json**: [`Extraction`](/reference/sdk/type-aliases/extraction.md) *** ### markdown > `readonly` **markdown**: `string` *** ### text > `readonly` **text**: `string` --- # FontInfo What a builder knows of a font once registered. ## Properties ### family > `readonly` **family**: `string` The family name. *** ### isStandard > `readonly` **isStandard**: `boolean` Whether it is one of the standard 14 (not embedded). *** ### postscriptName > `readonly` **postscriptName**: `string` The PostScript name. --- # HttpRangeOptions Options for [httpRangeSource](/reference/sdk/functions/httprangesource.md). ## Properties ### blockSize? > `readonly` `optional` **blockSize?**: `number` Fetches are aligned to blocks of this many bytes (default 16 384: each object a reader checks costs one block, so larger blocks fetch more of the file). *** ### credentials? > `readonly` `optional` **credentials?**: `RequestCredentials` `fetch`'s credentials mode. *** ### fetch? > `readonly` `optional` **fetch?**: (`input`, `init?`) => `Promise`\<`Response`\> The `fetch` to use (default: the global one). [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/fetch) #### Parameters ##### input `URL` \| `RequestInfo` ##### init? `RequestInit` #### Returns `Promise`\<`Response`\> *** ### headers? > `readonly` `optional` **headers?**: `Readonly`\<`Record`\<`string`, `string`\>\> Extra request headers (authorisation, …). `Range` and `If-Range` are Lamina PDF's. *** ### signal? > `readonly` `optional` **signal?**: `AbortSignal` Aborts the probe; reads take their own signal. *** ### tail? > `readonly` `optional` **tail?**: `number` The first request asks for this many bytes at the end of the file (default 65 536). --- # InitOptions Options for [init](/reference/sdk/functions/init.md). ## Properties ### extract? > `readonly` `optional` **extract?**: `boolean` \| [`WasmSource`](/reference/sdk/type-aliases/wasmsource.md) Also load the extract module (reading plus text extraction, about half the text module's size): `true` for the default file, or where it comes from. Documents opened afterwards can extract text ([PageRef.text](/reference/sdk/classes/pageref.md#text)). Not needed when the text module is loaded. *** ### module? > `readonly` `optional` **module?**: [`WasmSource`](/reference/sdk/type-aliases/wasmsource.md) The base module (default: the `lamina_pdf_base.wasm` next to the package, per export condition). *** ### text? > `readonly` `optional` **text?**: `boolean` \| [`WasmSource`](/reference/sdk/type-aliases/wasmsource.md) Also load the text module (fonts and text): `true` for the default file, or where it comes from. Without it, only builders created after a later [loadText](/reference/sdk/functions/loadtext.md) can draw text. --- # Leak A handle the garbage collector reclaimed without a `free()`. ## Properties ### handle > `readonly` **handle**: `string` The handle's class: `Document`, `Builder` or `Image`. --- # Location Where a problem is (ADR 0026 §1.1); each field has one meaning, all lamina-made values. ## Properties ### id? > `readonly` `optional` **id?**: `string` A LaminaPdfId (`p12.0`). *** ### object? > `readonly` `optional` **object?**: `number` Object number of the indirect object the problem is in or at. *** ### offset? > `readonly` `optional` **offset?**: `number` Byte offset in the input file. *** ### pageIndex? > `readonly` `optional` **pageIndex?**: `number` 0-based page index (ADR 0027 §6). *** ### streamOffset? > `readonly` `optional` **streamOffset?**: `number` Byte offset in the decoded data of `object`. --- # OpenOptions Options for [Document.open](/reference/sdk/classes/document.md#open). ## Properties ### limits? > `readonly` `optional` **limits?**: `any` Resource limits. *** ### password? > `readonly` `optional` **password?**: `string` The user or owner password of an encrypted document (normalised to NFKC, ADR 0016 §7). *** ### strict? > `readonly` `optional` **strict?**: `boolean` Refuse files that need repair (`strict`). --- # OpLog An op log: `{ v: 1, ops }`. ## Properties ### ops > `readonly` **ops**: readonly `OpJs`[] *** ### v > `readonly` **v**: `1` --- # PageTextOptions Options for [PageRef.text](/reference/sdk/classes/pageref.md#text). ## Properties ### skipInvisible? > `readonly` `optional` **skipInvisible?**: `boolean` Leave out text drawn invisibly (rendering modes 3 and 7: OCR layers, hidden text). --- # PathBuilder Builds a path in page space; every method returns the builder, so calls chain. ## Methods ### circle() > **circle**(`center`, `radius`): `PathBuilder` A circle around `center`. #### Parameters ##### center [`Point`](/reference/sdk/type-aliases/point.md) ##### radius `number` #### Returns `PathBuilder` *** ### close() > **close**(): `PathBuilder` Closes the subpath. #### Returns `PathBuilder` *** ### cubicTo() > **cubicTo**(`x1`, `y1`, `x2`, `y2`, `x`, `y`): `PathBuilder` A cubic Bézier curve through two control points to (x, y). #### Parameters ##### x1 `number` ##### y1 `number` ##### x2 `number` ##### y2 `number` ##### x `number` ##### y `number` #### Returns `PathBuilder` *** ### ellipse() > **ellipse**(`rect`): `PathBuilder` The ellipse inscribed in a rectangle. #### Parameters ##### rect [`Rect`](/reference/sdk/type-aliases/rect.md) #### Returns `PathBuilder` *** ### lineTo() > **lineTo**(`x`, `y`): `PathBuilder` A straight line to (x, y). #### Parameters ##### x `number` ##### y `number` #### Returns `PathBuilder` *** ### moveTo() > **moveTo**(`x`, `y`): `PathBuilder` Starts a subpath at (x, y). #### Parameters ##### x `number` ##### y `number` #### Returns `PathBuilder` *** ### quadTo() > **quadTo**(`x1`, `y1`, `x`, `y`): `PathBuilder` A quadratic Bézier curve through one control point to (x, y). #### Parameters ##### x1 `number` ##### y1 `number` ##### x `number` ##### y `number` #### Returns `PathBuilder` *** ### rect() > **rect**(`rect`): `PathBuilder` A rectangle: left, top, width, height. #### Parameters ##### rect [`Rect`](/reference/sdk/type-aliases/rect.md) #### Returns `PathBuilder` --- # QuadPoint A point in page space (ADR 0027: top-left of the displayed CropBox, y down, points). ## Properties ### x > `readonly` **x**: `number` *** ### y > `readonly` **y**: `number` --- # RangeOptions How a range document fetches. ## Properties ### blockSize? > `readonly` `optional` **blockSize?**: `number` Fetches are widened to whole blocks of this many bytes (default: the source's, else 4096). *** ### budget? > `readonly` `optional` **budget?**: `number` The most bytes the document may hold (default: no limit); more is `source-budget-exceeded`. *** ### maxRounds? > `readonly` `optional` **maxRounds?**: `number` Rounds of fetches per call before giving up with the last `data-pending` (default 1024). *** ### readahead? > `readonly` `optional` **readahead?**: `number` Extra bytes fetched after each range (default 0). *** ### signal? > `readonly` `optional` **signal?**: `AbortSignal` Aborts the open's fetches: it rejects with the signal's reason. Later calls take their own signal ([Document.ensure](/reference/sdk/classes/document.md#ensure)). --- # RangeSource Random access to a file whose bytes are fetched on demand (a remote file, a `Blob`, a file handle). `@laminapdf/viewer`'s document worker reads a `Blob` through it too. ## Properties ### blockSize? > `readonly` `optional` **blockSize?**: `number` The block size this source prefers fetches aligned to (HTTP: fewer, larger requests). *** ### size > `readonly` **size**: `number` The file's size in bytes. ## Methods ### read() > **read**(`offset`, `length`, `options?`): `Promise`\<`Uint8Array`\<`ArrayBufferLike`\>\> The `length` bytes at `offset` (exactly that many: the file is at least `offset + length` long). Rejects when `signal` aborts. #### Parameters ##### offset `number` ##### length `number` ##### options? ###### signal? `AbortSignal` #### Returns `Promise`\<`Uint8Array`\<`ArrayBufferLike`\>\> --- # StrokeStyle How strokes are drawn. ## Properties ### cap? > `readonly` `optional` **cap?**: `"butt"` \| `"round"` \| `"square"` Line ends (default `butt`). *** ### dash? > `readonly` `optional` **dash?**: readonly `number`[] Dash pattern in points (default: solid). *** ### dashPhase? > `readonly` `optional` **dashPhase?**: `number` Where the dash pattern starts (default 0). *** ### join? > `readonly` `optional` **join?**: `"round"` \| `"miter"` \| `"bevel"` Corners (default `miter`). *** ### miterLimit? > `readonly` `optional` **miterLimit?**: `number` Miter limit (default 10). *** ### width? > `readonly` `optional` **width?**: `number` Line width in points (default 1). --- # TagSpec A structure element with attributes; `type` may be a custom name when `role` is given. ## Properties ### actualText? > `readonly` `optional` **actualText?**: `string` `/ActualText`: the exact text the content stands for. *** ### alt? > `readonly` `optional` **alt?**: `string` `/Alt`: alternate text (a `Figure` needs it). *** ### expansion? > `readonly` `optional` **expansion?**: `string` `/E`: the expansion of an abbreviation. *** ### lang? > `readonly` `optional` **lang?**: `string` `/Lang`: the element's language (BCP 47). *** ### role? > `readonly` `optional` **role?**: `"Document"` \| `"Part"` \| `"Sect"` \| `"H1"` \| `"H2"` \| `"H3"` \| `"H4"` \| `"H5"` \| `"H6"` \| `"P"` \| `"L"` \| `"LI"` \| `"Lbl"` \| `"LBody"` \| `"Table"` \| `"TR"` \| `"TH"` \| `"TD"` \| `"Figure"` \| `"Caption"` \| `"Link"` \| `"Span"` \| `"Quote"` \| `"Note"` \| `"Code"` The standard type a custom `type` stands for. *** ### scope? > `readonly` `optional` **scope?**: `"row"` \| `"column"` \| `"both"` `/Scope` of a `TH`. *** ### type > `readonly` **type**: `"Document"` \| `"Part"` \| `"Sect"` \| `"H1"` \| `"H2"` \| `"H3"` \| `"H4"` \| `"H5"` \| `"H6"` \| `"P"` \| `"L"` \| `"LI"` \| `"Lbl"` \| `"LBody"` \| `"Table"` \| `"TR"` \| `"TH"` \| `"TD"` \| `"Figure"` \| `"Caption"` \| `"Link"` \| `"Span"` \| `"Quote"` \| `"Note"` \| `"Code"` \| `string` & `object` A standard type, or a custom name written with a `RoleMap` entry to `role`. --- # TextBoxOptions Options of [Surface.textBox](/reference/sdk/classes/surface.md#textbox). ## Properties ### align? > `readonly` `optional` **align?**: `any` Line alignment (default `start`). *** ### box > `readonly` **box**: [`Rect`](/reference/sdk/type-aliases/rect.md) The box the lines are broken into. *** ### direction? > `readonly` `optional` **direction?**: `any` Paragraph direction. *** ### features? > `readonly` `optional` **features?**: readonly `string`[] OpenType features. *** ### font > `readonly` **font**: [`Font`](/reference/sdk/classes/font.md) The font (needs the text module). *** ### language? > `readonly` `optional` **language?**: `string` Language (BCP 47). *** ### lineHeight? > `readonly` `optional` **lineHeight?**: `number` Line height as a factor of the size (default 1.2). *** ### overflow? > `readonly` `optional` **overflow?**: `any` What happens to text that doesn't fit (default `clip`; `error` throws `text-overflow`). *** ### size > `readonly` **size**: `number` Size in points. *** ### tag? > `readonly` `optional` **tag?**: [`Tag`](/reference/sdk/type-aliases/tag.md) The structure element the box is (as drawing it inside [Surface.tag](/reference/sdk/classes/surface.md#tag)). --- # TextGlyph One glyph of a run. ## Properties ### quad > `readonly` **quad**: [`GlyphQuad`](/reference/sdk/type-aliases/glyphquad.md) Its quad in page space. *** ### text > `readonly` **text**: `string` The glyph's text (document text; U+FFFD when its Unicode is unknown). --- # TextOptions Options of [Surface.text](/reference/sdk/classes/surface.md#text). ## Properties ### at > `readonly` **at**: [`Point`](/reference/sdk/type-aliases/point.md) Where the first line starts: `x`, and `y` is the baseline (ADR 0027 §5). *** ### direction? > `readonly` `optional` **direction?**: `any` Paragraph direction (default: from the first strong character). *** ### features? > `readonly` `optional` **features?**: readonly `string`[] OpenType features (`liga=0`, `smcp`). *** ### fill? > `readonly` `optional` **fill?**: [`Color`](/reference/sdk/type-aliases/color.md) Fill colour. *** ### font > `readonly` **font**: [`Font`](/reference/sdk/classes/font.md) The font (needs the text module). *** ### language? > `readonly` `optional` **language?**: `string` Language (BCP 47). *** ### link? > `readonly` `optional` **link?**: `object` A link over the run. #### contents? > `readonly` `optional` **contents?**: `string` #### target > `readonly` **target**: `LinkTargetInit` *** ### renderMode? > `readonly` `optional` **renderMode?**: `any` Rendering mode (`invisible` for an OCR layer). *** ### size > `readonly` **size**: `number` Size in points. *** ### tag? > `readonly` `optional` **tag?**: [`Tag`](/reference/sdk/type-aliases/tag.md) The structure element the run is (as drawing it inside [Surface.tag](/reference/sdk/classes/surface.md#tag)). --- # WasmMemory The size of each loaded module's linear memory, in bytes. ## Properties ### base? > `readonly` `optional` **base?**: `number` The base module's (absent before `init()`). *** ### extract? > `readonly` `optional` **extract?**: `number` The extract module's (absent until it is loaded). *** ### text? > `readonly` `optional` **text?**: `number` The text module's (absent until it is loaded). --- # @laminapdf/sdk ## Classes - [Builder](/reference/sdk/classes/builder.md) - [Document](/reference/sdk/classes/document.md) - [Font](/reference/sdk/classes/font.md) - [Image](/reference/sdk/classes/image.md) - [LaminaPdfError](/reference/sdk/classes/laminapdferror.md) - [Page](/reference/sdk/classes/page.md) - [PageRef](/reference/sdk/classes/pageref.md) - [PageText](/reference/sdk/classes/pagetext.md) - [PageTextRun](/reference/sdk/classes/pagetextrun.md) - [PendingPath](/reference/sdk/classes/pendingpath.md) - [SaveJob](/reference/sdk/classes/savejob.md) - [Surface](/reference/sdk/classes/surface.md) - [Transaction](/reference/sdk/classes/transaction.md) ## Interfaces - [BuilderOptions](/reference/sdk/interfaces/builderoptions.md) - [ByteRange](/reference/sdk/interfaces/byterange.md) - [ChunkOptions](/reference/sdk/interfaces/chunkoptions.md) - [Diagnostic](/reference/sdk/interfaces/diagnostic.md) - [ExtractOptions](/reference/sdk/interfaces/extractoptions.md) - [ExtractResult](/reference/sdk/interfaces/extractresult.md) - [FontInfo](/reference/sdk/interfaces/fontinfo.md) - [HttpRangeOptions](/reference/sdk/interfaces/httprangeoptions.md) - [InitOptions](/reference/sdk/interfaces/initoptions.md) - [Leak](/reference/sdk/interfaces/leak.md) - [Location](/reference/sdk/interfaces/location.md) - [OpenOptions](/reference/sdk/interfaces/openoptions.md) - [OpLog](/reference/sdk/interfaces/oplog.md) - [PageTextOptions](/reference/sdk/interfaces/pagetextoptions.md) - [PathBuilder](/reference/sdk/interfaces/pathbuilder.md) - [QuadPoint](/reference/sdk/interfaces/quadpoint.md) - [RangeOptions](/reference/sdk/interfaces/rangeoptions.md) - [RangeSource](/reference/sdk/interfaces/rangesource.md) - [StrokeStyle](/reference/sdk/interfaces/strokestyle.md) - [TagSpec](/reference/sdk/interfaces/tagspec.md) - [TextBoxOptions](/reference/sdk/interfaces/textboxoptions.md) - [TextGlyph](/reference/sdk/interfaces/textglyph.md) - [TextOptions](/reference/sdk/interfaces/textoptions.md) - [WasmMemory](/reference/sdk/interfaces/wasmmemory.md) ## Type Aliases - [AfRelationship](/reference/sdk/type-aliases/afrelationship.md) - [Align](/reference/sdk/type-aliases/align.md) - [ArtifactKind](/reference/sdk/type-aliases/artifactkind.md) - [AttachmentOptions](/reference/sdk/type-aliases/attachmentoptions.md) - [BlendMode](/reference/sdk/type-aliases/blendmode.md) - [BoxRect](/reference/sdk/type-aliases/boxrect.md) - [BuildOptions](/reference/sdk/type-aliases/buildoptions.md) - [Cell](/reference/sdk/type-aliases/cell.md) - [Chunk](/reference/sdk/type-aliases/chunk.md) - [Color](/reference/sdk/type-aliases/color.md) - [Coordinates](/reference/sdk/type-aliases/coordinates.md) - [Direction](/reference/sdk/type-aliases/direction.md) - [DocumentInfo](/reference/sdk/type-aliases/documentinfo.md) - [Dropped](/reference/sdk/type-aliases/dropped.md) - [DropReason](/reference/sdk/type-aliases/dropreason.md) - [Edited](/reference/sdk/type-aliases/edited.md) - [ErrorCode](/reference/sdk/type-aliases/errorcode.md) - [ErrorKind](/reference/sdk/type-aliases/errorkind.md) - [ExtractedBlock](/reference/sdk/type-aliases/extractedblock.md) - [ExtractedPage](/reference/sdk/type-aliases/extractedpage.md) - [ExtractedSource](/reference/sdk/type-aliases/extractedsource.md) - [ExtractFormat](/reference/sdk/type-aliases/extractformat.md) - [Extraction](/reference/sdk/type-aliases/extraction.md) - [FillRule](/reference/sdk/type-aliases/fillrule.md) - [Fit](/reference/sdk/type-aliases/fit.md) - [Flags](/reference/sdk/type-aliases/flags.md) - [Generator](/reference/sdk/type-aliases/generator.md) - [GlyphQuad](/reference/sdk/type-aliases/glyphquad.md) - [InfoPatch](/reference/sdk/type-aliases/infopatch.md) - [LaminaPdfWarning](/reference/sdk/type-aliases/laminapdfwarning.md) - [LimitName](/reference/sdk/type-aliases/limitname.md) - [Limits](/reference/sdk/type-aliases/limits.md) - [LineBox](/reference/sdk/type-aliases/linebox.md) - [LineCap](/reference/sdk/type-aliases/linecap.md) - [LineJoin](/reference/sdk/type-aliases/linejoin.md) - [LinkTarget](/reference/sdk/type-aliases/linktarget.md) - [Metadata](/reference/sdk/type-aliases/metadata.md) - [Op](/reference/sdk/type-aliases/op.md) - [OutlineEntry](/reference/sdk/type-aliases/outlineentry.md) - [Overflow](/reference/sdk/type-aliases/overflow.md) - [PageInfo](/reference/sdk/type-aliases/pageinfo.md) - [PageLabel](/reference/sdk/type-aliases/pagelabel.md) - [PageSizeName](/reference/sdk/type-aliases/pagesizename.md) - [Point](/reference/sdk/type-aliases/point.md) - [Profile](/reference/sdk/type-aliases/profile.md) - [Progress](/reference/sdk/type-aliases/progress.md) - [Rect](/reference/sdk/type-aliases/rect.md) - [Region](/reference/sdk/type-aliases/region.md) - [RenderMode](/reference/sdk/type-aliases/rendermode.md) - [Rotation](/reference/sdk/type-aliases/rotation.md) - [SaveOptions](/reference/sdk/type-aliases/saveoptions.md) - [SaveReport](/reference/sdk/type-aliases/savereport.md) - [Span](/reference/sdk/type-aliases/span.md) - [Standard14](/reference/sdk/type-aliases/standard14.md) - [StructRef](/reference/sdk/type-aliases/structref.md) - [StructSource](/reference/sdk/type-aliases/structsource.md) - [TableDetector](/reference/sdk/type-aliases/tabledetector.md) - [TableScope](/reference/sdk/type-aliases/tablescope.md) - [Tag](/reference/sdk/type-aliases/tag.md) - [TagName](/reference/sdk/type-aliases/tagname.md) - [TextLayout](/reference/sdk/type-aliases/textlayout.md) - [TextRange](/reference/sdk/type-aliases/textrange.md) - [TextRunInfo](/reference/sdk/type-aliases/textruninfo.md) - [Transform](/reference/sdk/type-aliases/transform.md) - [UndoOutcome](/reference/sdk/type-aliases/undooutcome.md) - [ViewerPreferences](/reference/sdk/type-aliases/viewerpreferences.md) - [WarningCode](/reference/sdk/type-aliases/warningcode.md) - [WasmSource](/reference/sdk/type-aliases/wasmsource.md) ## Variables - [Matrix](/reference/sdk/variables/matrix.md) - [PAGE\_SIZES](/reference/sdk/variables/page_sizes.md) - [TAG\_NAMES](/reference/sdk/variables/tag_names.md) ## Functions - [callbackSource](/reference/sdk/functions/callbacksource.md) - [httpRangeSource](/reference/sdk/functions/httprangesource.md) - [init](/reference/sdk/functions/init.md) - [isExtractLoaded](/reference/sdk/functions/isextractloaded.md) - [isInitialized](/reference/sdk/functions/isinitialized.md) - [isLaminaPdfError](/reference/sdk/functions/islaminapdferror.md) - [isTextLoaded](/reference/sdk/functions/istextloaded.md) - [loadExtract](/reference/sdk/functions/loadextract.md) - [loadText](/reference/sdk/functions/loadtext.md) - [onLeak](/reference/sdk/functions/onleak.md) - [reset](/reference/sdk/functions/reset.md) - [wasmMemory](/reference/sdk/functions/wasmmemory.md) ## References ### ReadingOrder Renames and re-exports [ExtractFormat](/reference/sdk/type-aliases/extractformat.md) *** ### SplitOn Renames and re-exports [ExtractFormat](/reference/sdk/type-aliases/extractformat.md) *** ### TableMode Renames and re-exports [ExtractFormat](/reference/sdk/type-aliases/extractformat.md) --- # AfRelationship > **AfRelationship** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) How an attached file relates to the document (`/AFRelationship`). --- # Align > **Align** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) Text alignment in a text box. --- # ArtifactKind > **ArtifactKind** = *typeof* `ARTIFACT_KINDS`\[`number`\] What kind of artifact (content outside the logical structure). --- # AttachmentOptions > **AttachmentOptions** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) An attached file's relationship, description and modification date. --- # BlendMode > **BlendMode** = *typeof* `BLEND_MODES`\[`number`\] Blend modes (`/BM`), camelCase. --- # BoxRect > **BoxRect** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) A rectangle returned by the builder (page space). --- # BuildOptions > **BuildOptions** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) Options for a new document: version, fixed file ID (32 hex digits), producer, creation date. --- # Cell > **Cell** = `object` One cell of a table block. ## Properties ### bbox > **bbox**: \[`number`, `number`, `number`, `number`\] Its box in page space. *** ### col > **col**: `number` The first column it covers, 0 at the left. *** ### colSpan > **colSpan**: `number` How many columns it covers. *** ### header > **header**: `boolean` Whether it is in a header row. *** ### row > **row**: `number` The first row it covers, 0 at the top. *** ### rowSpan > **rowSpan**: `number` How many rows it covers. *** ### text > **text**: `string` Its visible text, lines joined by a space. --- # Chunk > **Chunk** = `object` One chunk. ## Properties ### blockIds > **blockIds**: `string`[] The blocks it holds, in order. *** ### headingPath > **headingPath**: `string`[] The headings above its first block, outermost first. *** ### id > **id**: `string` `c`, from 0, in order. *** ### pages > **pages**: `number`[] The pages its blocks are on, ascending. *** ### regions > **regions**: [`Region`](/reference/sdk/type-aliases/region.md)[] Its area on each of those pages. *** ### text > **text**: `string` The Markdown from the first block's start to the last block's end. *** ### textRange > **textRange**: [`TextRange`](/reference/sdk/type-aliases/textrange.md) Where `text` sits in the Markdown. *** ### tokensApprox > **tokensApprox**: `number` [`tokens_approx`] of `text`. --- # Color > **Color** = `string` \| readonly \[`number`\] \| readonly \[`number`, `number`, `number`\] \| readonly \[`number`, `number`, `number`, `number`\] A colour: `[gray]`, `[r, g, b]` or `[c, m, y, k]` with components from 0 to 1, or `"#rrggbb"` / `"#rgb"` (DeviceRGB). --- # Coordinates > **Coordinates** = `object` ADR 0027 §4's `coordinates` block: top-left points of the displayed box. ## Properties ### box > **box**: `string` `"CropBox"`, or `"default"` when the page had no usable box and US Letter was used. *** ### origin > **origin**: `string` Always `"top-left"`. *** ### rotate > **rotate**: `number` The `/Rotate` applied, in degrees clockwise. *** ### unit > **unit**: `string` Always `"pt"`. *** ### userUnit > **userUnit**: `number` The `/UserUnit` applied. --- # Direction > **Direction** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) Paragraph direction. --- # DocumentInfo > **DocumentInfo** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) The Info dictionary's text entries (document text). --- # Dropped > **Dropped** = `object` Text left out of the output ([ADR 0030] §4). It records a count, never the text, so a log of the output can't leak what was hidden. [ADR 0030]: https://github.com/laudebugs/laminapdf/blob/main/docs/decisions/0030-extraction-output-and-model-plugins.md ## Properties ### bbox > **bbox**: \[`number`, `number`, `number`, `number`\] Where it was, in page space. *** ### chars > **chars**: `number` How many characters were left out. *** ### page > **page**: `number` The page index. *** ### reason > **reason**: [`DropReason`](/reference/sdk/type-aliases/dropreason.md) Why it was left out (the first reason when there are several). *** ### spans? > `optional` **spans?**: [`Span`](/reference/sdk/type-aliases/span.md)[] The run itself, only when [`DropOptions::keep_spans`](crate::DropOptions::keep_spans) asked for it. --- # DropReason > **DropReason** = `"invisible"` \| `"off-page"` \| `"tiny"` \| `"clipped"` \| `"low-contrast"` \| `"artifact"` \| `"running-header"` \| `"running-footer"` \| `"page-number"` Why text was left out. --- # Edited > **Edited** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) The pages an edit touched. --- # ErrorCode > **ErrorCode** = `"not-pdf"` \| `"missing-root"` \| `"reference-cycle"` \| `"limit-exceeded"` \| `"strict"` \| `"data-pending"` \| `"source"` \| `"type"` \| `"invalid-structure"` \| `"unset-object"` \| `"repaired-base"` \| `"encrypted-base"` \| `"password-required"` \| `"wrong-password"` \| `"unsupported-security-handler"` \| `"unexpected-eof"` \| `"syntax"` \| `"unsupported-filter"` \| `"decompress"` \| `"nested-transaction"` \| `"no-transaction"` \| `"unknown-page"` \| `"direct-page"` \| `"unsupported-version"` \| `"invalid-op-log"` \| `"saved-not-rebased"` \| `"no-pending-save"` \| `"sink"` \| `"not-rebased"` \| `"sink-not-empty"` \| `"would-invalidate-signatures"` \| `"requires-rewrite"` \| `"invalid-font"` \| `"collection-index"` \| `"unknown-axis"` \| `"font-not-embeddable"` \| `"missing-glyph"` \| `"glyph-out-of-range"` \| `"unknown-font"` \| `"subset-failed"` \| `"invalid-axis-value"` \| `"wrong-document"` \| `"invalid-feature"` \| `"invalid-text-range"` \| `"unsupported-layout-option"` \| `"font-mismatch"` \| `"encoding-full"` \| `"font-not-shapable"` \| `"invalid-image"` \| `"unsupported-image"` \| `"image-too-large"` \| `"image-data-length"` \| `"invalid-icc-profile"` \| `"color-components"` \| `"invalid-color"` \| `"invalid-geometry"` \| `"text-overflow"` \| `"page-index-out-of-range"` \| `"unknown-destination"` \| `"invalid-page-labels"` \| `"profile-version-conflict"` \| `"missing-title"` \| `"missing-lang"` \| `"missing-alt-text"` \| `"untagged-content"` \| `"heading-level-skipped"` \| `"unnumbered-heading-misused"` \| `"invalid-structure-nesting"` \| `"link-without-structure"` \| `"link-without-contents"` \| `"font-not-embedded"` \| `"glyph-without-unicode"` \| `"device-color-without-output-intent"` \| `"embedded-file-forbidden"` \| `"embedded-file-without-mime-type"` \| `"render-too-large"` \| `"render-failed"` \| `"render-backend-unavailable"` \| `"cancelled"` \| `"job-finished"` \| `"document-changed"` \| `"builder-finished"` \| `"disposed"` \| `"invalid-argument"` \| `"source-budget-exceeded"` \| `"unknown-option"` \| `"already-initialized"` \| `"extract-module-not-loaded"` \| `"fetch-failed"` \| `"not-initialized"` \| `"source-changed"` \| `"text-module-not-loaded"` \| `"worker-version-mismatch"` \| `"worker-crashed"` \| `"worker-failed"` \| `"wasm-trap"` \| `"out-of-memory"` \| `"instance-reset"` \| `"invalid-command-line"` \| `"invalid-page-range"` \| `"binary-output-to-terminal"` \| `"unknown-code"` \| `"overwrite-refused"` \| `"input-unreadable"` \| `"output-unwritable"` \| `"path-outside-roots"` Every error code (ADR 0026 §3; `fixtures/codes.json`). --- # ErrorKind > **ErrorKind** = `"input"` \| `"io"` \| `"unsupported"` \| `"limit"` \| `"security"` \| `"usage"` \| `"state"` \| `"cancelled"` \| `"internal"` The nine kinds of ADR 0026 §2: what the caller can do about an error. --- # ExtractedBlock > **ExtractedBlock** = `object` & (\{ `level`: `number`; `type`: `"heading"`; \} \| \{ `type`: `"paragraph"`; \} \| \{ `depth`: `number`; `marker`: `string`; `type`: `"list-item"`; \} \| \{ `type`: `"caption"`; \} \| \{ `type`: `"footnote"`; \} \| \{ `type`: `"other"`; \} \| \{ `cols`: `number`; `detector`: [`TableDetector`](/reference/sdk/type-aliases/tabledetector.md); `rows`: `number`; `type`: `"table"`; \}) A block and where its text sits in the Markdown. ## Type Declaration ### bbox > **bbox**: \[`number`, `number`, `number`, `number`\] The block's box in page space. ### bboxNorm? > `optional` **bboxNorm?**: \[`number`, `number`, `number`, `number`\] \| `null` The box divided by the page's width and height, clamped to [0, 1]; `None` when the block lies wholly outside the page. ### cells? > `optional` **cells?**: [`Cell`](/reference/sdk/type-aliases/cell.md)[] A table's cells, row by row; empty for every other block. ### confidence > **confidence**: `number` In [0, 1]: 1 for structure from the tree, lower for structure inferred from layout. ### id > **id**: `string` `p-b`. ### page > **page**: `number` The page index. ### spans > **spans**: [`Span`](/reference/sdk/type-aliases/span.md)[] The runs of text, in reading order. ### structPath > **structPath**: `string`[] The structure types from the root down to the block; empty when untagged. ### structRef? > `optional` **structRef?**: [`StructRef`](/reference/sdk/type-aliases/structref.md) \| `null` The structure element the block comes from, when it comes from one. ### text > **text**: `string` The visible text, lines joined by a space after the text fixes (ADR 0030 §5). ### textRange > **textRange**: [`TextRange`](/reference/sdk/type-aliases/textrange.md) --- # ExtractedPage > **ExtractedPage** = `object` One extracted page ([ADR 0027] §4). [ADR 0027]: https://github.com/laudebugs/laminapdf/blob/main/docs/decisions/0027-coordinates-units-and-page-indexing.md ## Properties ### coordinates > **coordinates**: [`Coordinates`](/reference/sdk/type-aliases/coordinates.md) The frame the boxes are in. *** ### height > **height**: `number` The displayed height in points. *** ### index > **index**: `number` 0-based. *** ### label? > `optional` **label?**: `string` \| `null` The page label (`/PageLabels`), when there is one. *** ### number > **number**: `number` 1-based, as people count. *** ### width > **width**: `number` The displayed width in points (after `/Rotate` and `/UserUnit`). --- # ExtractedSource > **ExtractedSource** = `object` The input, as ADR 0030 §1's `source` describes it. ## Properties ### pageCount > **pageCount**: `number` The document's page count. *** ### sha256? > `optional` **sha256?**: `string` \| `null` The input's SHA-256 in hex: filled by the caller that holds the bytes (the façade), since a windowed source can't be hashed without reading it whole. *** ### structSource > **structSource**: [`StructSource`](/reference/sdk/type-aliases/structsource.md) Where the reading order came from. *** ### tagged > **tagged**: `boolean` Whether the catalog's `/MarkInfo` says `/Marked true`. --- # ExtractFormat > **ExtractFormat** = `any` --- # Extraction > **Extraction** = `Omit`\<`ExtractionJson`, `"warnings"`\> & `object` The `laminapdf.extract/1` document: the schema's type, with the file's warnings as the [LaminaPdfWarning](/reference/sdk/type-aliases/laminapdfwarning.md)s they are (the schema leaves their shape to ADR 0026's). ## Type Declaration ### warnings > **warnings**: [`LaminaPdfWarning`](/reference/sdk/type-aliases/laminapdfwarning.md)[] --- # FillRule > **FillRule** = *typeof* `FILL_RULES`\[`number`\] How a filled or clipped path decides what is inside. --- # Fit > **Fit** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) How a destination shows its page. --- # Flags > **Flags** = [`DropReason`](/reference/sdk/type-aliases/dropreason.md)[] Why a run would be hidden from a reader; empty for visible text. --- # Generator > **Generator** = `object` Who made the extraction (ADR 0030 §1's `generator`). ## Properties ### mode > **mode**: `string` `"rules"`, or `"rules+models"` once model plug-ins exist. *** ### models > **models**: `unknown`[] *** ### name > **name**: `string` *** ### version > **version**: `string` --- # GlyphQuad > **GlyphQuad** = readonly \[[`QuadPoint`](/reference/sdk/interfaces/quadpoint.md), [`QuadPoint`](/reference/sdk/interfaces/quadpoint.md), [`QuadPoint`](/reference/sdk/interfaces/quadpoint.md), [`QuadPoint`](/reference/sdk/interfaces/quadpoint.md)\] A glyph's quad: its four corners, in drawing order (bottom-left, bottom-right, top-right, top-left of the glyph box). --- # InfoPatch > **InfoPatch** = \{ readonly \[K in keyof DocumentInfo\]?: string \| null \} An Info patch: a string sets the entry, `null` removes it, a missing key leaves it alone. --- # LaminaPdfWarning > **LaminaPdfWarning** = `Omit`\<[`Diagnostic`](/reference/sdk/interfaces/diagnostic.md)\<[`WarningCode`](/reference/sdk/type-aliases/warningcode.md)\>, `"didYouMean"`\> A warning: something Lamina PDF repaired or skipped (ADR 0003, ADR 0026 §1). The §1 object without `didYouMean`; document text appears only in `data`. --- # LimitName > **LimitName** = `"max_nesting_depth"` \| `"max_stream_decoded"` \| `"max_decoded_total"` \| `"max_filter_chain"` \| `"max_resolve_depth"` \| `"max_page_tree_depth"` \| `"max_tree_depth"` \| `"max_string_length"` \| `"max_content_operators"` \| `"max_xobject_depth"` \| `"max_image_pixels"` \| `"max_read_window"` The names of the `Limits` (ADR 0003 §3), as `data.limit` of `limit-exceeded`. --- # Limits > **Limits** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) `Limits` (ADR 0003 §3); each one left out keeps its default. --- # LineBox > **LineBox** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) A line of a text box. --- # LineCap > **LineCap** = *typeof* `LINE_CAPS`\[`number`\] How open line ends are drawn. --- # LineJoin > **LineJoin** = *typeof* `LINE_JOINS`\[`number`\] How corners are drawn. --- # LinkTarget > **LinkTarget** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) Where a link or outline item goes. --- # Metadata > **Metadata** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) The document's metadata (Info and XMP). --- # Op > **Op** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) An edit (ADR 0006 §6: `{ op: "setInfo" | "rotatePage", args }`), as in `fixtures/ops/v1`. --- # OutlineEntry > **OutlineEntry** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) An outline (bookmarks) item with its children. --- # Overflow > **Overflow** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) What happens to text that doesn't fit a text box. --- # PageInfo > **PageInfo** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) A page's facts: index, ID, displayed size in points, rotation, label. --- # PageLabel > **PageLabel** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) A page-label range, by its first page. --- # PageSizeName > **PageSizeName** = `"A0"` \| `"A1"` \| `"A2"` \| `"A3"` \| `"A4"` \| `"A5"` \| `"A6"` \| `"Letter"` \| `"Legal"` \| `"Tabloid"` Named page sizes (ISO 216 sizes from millimetres, as the Rust `PageSize` computes them). --- # Point > **Point** = readonly \[`x`: `number`, `y`: `number`\] A point in page space (points from the top-left of the displayed page, y down; ADR 0027). --- # Profile > **Profile** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) A conformance profile: `PDF/UA-1` (accessible), `PDF/A-2b`, `PDF/A-2u` or `PDF/A-3b` (archival). --- # Progress > **Progress** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) A save job's progress. --- # Rect > **Rect** = readonly \[`x`: `number`, `y`: `number`, `width`: `number`, `height`: `number`\] A rectangle in page space: left, top, width, height. --- # Region > **Region** = `object` A chunk's area on one page. ## Properties ### bbox > **bbox**: \[`number`, `number`, `number`, `number`\] The union of the chunk's blocks' boxes on that page, in page space. *** ### page > **page**: `number` The page index. --- # RenderMode > **RenderMode** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) Text rendering mode. --- # Rotation > **Rotation** = `0` \| `90` \| `180` \| `270` The rotations `/Rotate` allows. --- # SaveOptions > **SaveOptions** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) Options for saving. --- # SaveReport > **SaveReport** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) What a save wrote. --- # Span > **Span** = `object` One run of text in a block or a dropped entry. ## Properties ### baseline > **baseline**: `number` The baseline's y in page space (ADR 0027 §5). *** ### bbox > **bbox**: \[`number`, `number`, `number`, `number`\] Its box in page space. *** ### flags > **flags**: [`Flags`](/reference/sdk/type-aliases/flags.md) Why the run would be hidden; empty for visible text. *** ### font > **font**: `string` The font's `/BaseFont`. *** ### size > **size**: `number` The glyph size in points. *** ### text > **text**: `string` The run's text. --- # Standard14 > **Standard14** = `"Times-Roman"` \| `"Times-Bold"` \| `"Times-Italic"` \| `"Times-BoldItalic"` \| `"Helvetica"` \| `"Helvetica-Bold"` \| `"Helvetica-Oblique"` \| `"Helvetica-BoldOblique"` \| `"Courier"` \| `"Courier-Bold"` \| `"Courier-Oblique"` \| `"Courier-BoldOblique"` \| `"Symbol"` \| `"ZapfDingbats"` The standard 14 fonts, by PostScript name: no bytes needed, not embedded, Latin only, unshaped. --- # StructRef > **StructRef** = `object` A structure element ([ADR 0030] §1's `structRef`). [ADR 0030]: https://github.com/laudebugs/laminapdf/blob/main/docs/decisions/0030-extraction-output-and-model-plugins.md ## Properties ### gen > **gen**: `number` Its generation. *** ### mcids > **mcids**: `number`[] The marked-content identifiers it covers on the page. *** ### obj > **obj**: `number` The element's object number. --- # StructSource > **StructSource** = `"tagged"` \| `"xycut"` \| `"content"` Where the reading order came from ([ADR 0030] §2). [ADR 0030]: https://github.com/laudebugs/laminapdf/blob/main/docs/decisions/0030-extraction-output-and-model-plugins.md --- # TableDetector > **TableDetector** = `"lattice"` \| `"stream"` \| `"tagged"` What found a table. --- # TableScope > **TableScope** = *typeof* `TABLE_SCOPES`\[`number`\] Which header cells a `TH` heads (`/Scope`). --- # Tag > **Tag** = [`TagName`](/reference/sdk/type-aliases/tagname.md) \| [`TagSpec`](/reference/sdk/interfaces/tagspec.md) A structure element: a type, or a type with attributes. --- # TagName > **TagName** = *typeof* [`TAG_NAMES`](/reference/sdk/variables/tag_names.md)\[`number`\] A standard structure type (ISO 32000-1 §14.8.4). --- # TextLayout > **TextLayout** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) How a text box laid its text out. --- # TextRange > **TextRange** = `object` Where a piece of text sits in a rendering, in Unicode scalar values (`char`s), `start` inclusive and `end` exclusive. Code points, not bytes or UTF-16 units, so Python slices a rendering with them as they are; [`Rendered::slice`] does it in Rust. ## Properties ### end > **end**: `number` One past the last character. *** ### start > **start**: `number` The first character. --- # TextRunInfo > **TextRunInfo** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) What a text run measures. --- # Transform > **Transform** = readonly \[`a`: `number`, `b`: `number`, `c`: `number`, `d`: `number`, `e`: `number`, `f`: `number`\] A transformation matrix `[a, b, c, d, e, f]` (ISO 32000-2 §8.3.4), in page space. --- # UndoOutcome > **UndoOutcome** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) How an undo or redo went. --- # ViewerPreferences > **ViewerPreferences** = [`ExtractFormat`](/reference/sdk/type-aliases/extractformat.md) How viewers present the document. --- # WarningCode > **WarningCode** = `"missing-header"` \| `"xref-repaired"` \| `"xrefstm-ignored"` \| `"stream-length-recovered"` \| `"stale-generation"` \| `"page-tree-node-skipped"` \| `"page-count-mismatch"` \| `"flate-truncated"` \| `"objstm-index-mismatch"` \| `"object-number-out-of-range"` \| `"aes-padding-invalid"` \| `"perms-mismatch"` \| `"warnings-truncated"` \| `"invalid-box"` \| `"invalid-rotate"` \| `"name-tree-node-skipped"` \| `"outline-cycle"` \| `"outline-item-skipped"` \| `"invalid-date"` \| `"invalid-page-label"` \| `"invalid-entry"` \| `"content-unknown-operator"` \| `"content-bad-operands"` \| `"content-unbalanced-q"` \| `"content-unbalanced-bt"` \| `"content-unbalanced-marked"` \| `"content-syntax"` \| `"content-resource-missing"` \| `"inline-image-unterminated"` \| `"xobject-cycle"` \| `"content-path-unpainted"` \| `"font-invalid"` \| `"cmap-invalid"` \| `"unmapped-glyph"` \| `"extract-limit-blocks"` \| `"extract-reading-order-ambiguous"` \| `"extract-table-ambiguous"` \| `"extract-table-limit"` \| `"extract-struct-tree-ignored"` \| `"extract-untagged-content"` \| `"tounicode-conflict"` \| `"whole-font-rebuilt"` Every warning code (ADR 0026 §3; `fixtures/codes.json`). --- # WasmSource > **WasmSource** = `URL` \| `string` \| `Response` \| `PromiseLike`\<`Response`\> \| `ArrayBuffer` \| `ArrayBufferView` \| `WebAssembly.Module` Where a module comes from: a URL (or URL string) to fetch, a `Response` (or a promise of one), the bytes, or a compiled `WebAssembly.Module` (Cloudflare Workers import one). --- # Matrix > `const` **Matrix**: `object` Building transformation matrices. ## Type Declaration ### identity > `readonly` **identity**: [`Transform`](/reference/sdk/type-aliases/transform.md) The identity. ### after() > `readonly` **after**(`outer`, `inner`): [`Transform`](/reference/sdk/type-aliases/transform.md) `outer` applied after `inner`: maps p to outer(inner(p)). #### Parameters ##### outer [`Transform`](/reference/sdk/type-aliases/transform.md) ##### inner [`Transform`](/reference/sdk/type-aliases/transform.md) #### Returns [`Transform`](/reference/sdk/type-aliases/transform.md) ### rotate() > `readonly` **rotate**(`degrees`): [`Transform`](/reference/sdk/type-aliases/transform.md) A rotation by `degrees` about the origin, clockwise as displayed. #### Parameters ##### degrees `number` #### Returns [`Transform`](/reference/sdk/type-aliases/transform.md) ### scale() > `readonly` **scale**(`sx`, `sy`): [`Transform`](/reference/sdk/type-aliases/transform.md) A scaling by `sx` horizontally and `sy` vertically. #### Parameters ##### sx `number` ##### sy `number` #### Returns [`Transform`](/reference/sdk/type-aliases/transform.md) ### translate() > `readonly` **translate**(`x`, `y`): [`Transform`](/reference/sdk/type-aliases/transform.md) A translation by (x, y) points. #### Parameters ##### x `number` ##### y `number` #### Returns [`Transform`](/reference/sdk/type-aliases/transform.md) --- # PAGE_SIZES > `const` **PAGE\_SIZES**: `Readonly`\<`Record`\<[`PageSizeName`](/reference/sdk/type-aliases/pagesizename.md), readonly \[`width`: `number`, `height`: `number`\]\>\> Each named size in points, width × height. --- # TAG_NAMES > `const` **TAG\_NAMES**: readonly \[`"Document"`, `"Part"`, `"Sect"`, `"H1"`, `"H2"`, `"H3"`, `"H4"`, `"H5"`, `"H6"`, `"P"`, `"L"`, `"LI"`, `"Lbl"`, `"LBody"`, `"Table"`, `"TR"`, `"TH"`, `"TD"`, `"Figure"`, `"Caption"`, `"Link"`, `"Span"`, `"Quote"`, `"Note"`, `"Code"`\] Every TagName, as the WASM module names it. --- # aes-padding-invalid AES-encrypted data had a bad length or padding and was kept as stored - Type: warning - Kind: `security` (encryption, permissions or signatures block the call, or look tampered with) ## What happened AES-encrypted data was not `IV ‖ whole blocks` or had invalid padding; it was kept as stored or unstripped. ## Why Damaged data, or plaintext spliced into an encrypted file (PDFex-style). ## How to fix Treat the affected strings and streams as suspect: they may have been altered or spliced in. Don't use them for anything security-relevant. ## Example ```text (an AESV2 string whose last block does not end in valid PKCS#5 padding) ``` Lamina PDF keeps the data as stored (or unstripped) instead of failing, and raises the warning with the string's or stream's `object`. ## Data fields None. --- # already-initialized init() or loadText() named a different source for a module that is already loaded or loading - Type: error - Kind: `state` (the call isn't valid in the object's current state) ## What happened `init()` or `loadText()` of `@laminapdf/sdk` named a source (a URL, `Response`, bytes or `WebAssembly.Module`) for a module that an earlier call had already loaded, or started loading, from another source (or from the default file). ## Why Each module is instantiated once per JavaScript realm: every document and builder lives in that instance. Loading a second copy from another source would leave two instances whose objects can't be mixed, so the second source is refused rather than silently ignored. ## How to fix Pass the source on the first call only; later calls (from other parts of the app) can call `init()` without one, which waits for the same load. Calling again with the very same source object is allowed. ## Example ```js await init(new URL("/wasm/lamina_pdf_base.wasm", location.href)); await init(); // fine: the module is loaded await init(otherBytes); // throws { code: "already-initialized", … } ``` ## Data fields None. --- # binary-output-to-terminal the command would write a PDF to standard output, and standard output is a terminal - Type: error - Kind: `usage` (the caller misused the API) ## What happened A command that writes a PDF was told to write it to standard output (`-o -`), and standard output is a terminal. ## Why A PDF's bytes would fill the terminal with noise and can leave it in a strange state. The CLI writes binary output only to a file or a pipe. ## How to fix Redirect standard output to a file or a pipe, or write to a file with `-o`. ## Example ```console $ laminapdf pages rotate scan.pdf +90 -o - error[binary-output-to-terminal]: standard output is a terminal = hint: redirect standard output to a file or a pipe, or write to a file with -o $ laminapdf pages rotate scan.pdf +90 -o - > rotated.pdf ``` ## Data fields None. --- # builder-finished a builder or one of its pages was used after it finished - Type: error - Kind: `state` (the call isn't valid in the object's current state) ## What happened A `WasmBuilder` method was called after the builder's `finish`, or a `WasmPageBuilder` was drawn on or finished again after its own `finish` (or after its builder's). ## Why `finish` hands the work over: a builder's becomes a `WasmDocument`, a page's is written into its builder. Neither holds anything to draw on afterwards. ## How to fix Draw everything on a page before finishing it, and finish every page before the builder. To build more, create a new page or builder. ## Example ```js const b = new WasmBuilder(undefined); const page = b.page(595, 842); page.finish(); page.save(); // throws { code: "builder-finished", … } ``` ## Data fields - `argument`: always `builder`. --- # cancelled a CancelToken stopped the work; the job is finished and returned no partial result - Type: error - Kind: `cancelled` (a `CancelToken` stopped the work) ## What happened The `CancelToken` passed to a job, or to a run-to-completion call such as `Document::save`, was cancelled. The call returned at its next check: the start of a step, a budget check, or the next object or page of a long loop. ## Why The caller (or a host on its behalf: an `AbortSignal`, Ctrl-C, a task cancellation) asked for the work to stop. ## How to fix Nothing: this is the outcome that was asked for. The job is finished, a cancelled save left the document as it was (`Dirty`, nothing written), and a cancelled read returned no partial result. Start a new job with a fresh token to try again. ## Example ```rust let cancel = laminapdf::CancelToken::new(); cancel.cancel(); let err = doc.save_with(&laminapdf::SaveOptions::new(), &cancel).unwrap_err(); assert_eq!(err.code(), "cancelled"); ``` ## Data fields None. --- # cmap-invalid a font's CMap or ToUnicode CMap is malformed or names a predefined CMap Lamina PDF has no table for - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened A font's CMap (its Type 0 `/Encoding`) or ToUnicode CMap couldn't be fully used, for one of three reasons: - entries that can't be read, which are skipped; - a predefined CMap Lamina PDF has no table for, read as Identity (Lamina PDF knows only `Identity-H`/`-V` and the `Uni…-UCS2-…`/`Uni…-UTF16-…` ones); - a `/UseCMap` chain deeper than 8 levels, cut there. ## Why A producer bug (bad ranges, a missing `end…` keyword), or a CJK file using one of Adobe's character-collection CMaps without embedding it. ## How to fix Nothing to do. The rest of the CMap is used; affected glyphs may extract as other text or U+FFFD. ## Example ```text 1 beginbfchar <0003> <0020 endbfchar ``` The destination string is not closed; the entry is skipped, and the rest of the ToUnicode CMap is used. ## Data fields None. The `location` is `object`, the font dictionary's object; none for a direct font dictionary. --- # collection-index a font collection member past the end of the collection was asked for - Type: error - Kind: `usage` (the caller misused the API) ## What happened `Font::from_collection` was asked for a member past the end of the font collection. ## Why Members are numbered from 0; a single font counts as a collection of one. ## How to fix Use an index below `Font::collection_len(bytes)`. ## Example ```rust let font = Font::from_collection(ttc_bytes, 4)?; // the collection has 4 members (0..=3): collection-index ``` ## Data fields - `index`: the member that was asked for. - `count`: how many members the collection has. --- # color-components a colour has the wrong number of components for its colour space - Type: error - Kind: `usage` (the caller misused the API) ## What happened `Color::icc` was given a number of components that differs from the profile's channel count. ## Why An `ICCBased` colour has one component per channel of its profile (`/N`: Gray 1, RGB and Lab 3, CMYK 4). ## How to fix Give `IccProfile::components()` values. ## Example ```rust let c = Color::icc(&cmyk_profile, &[0.1, 0.2, 0.3])?; // color-components: 4 expected ``` ## Data fields - `expected`: the colour space's number of components. - `actual`: how many were given. --- # content-bad-operands an operator had too few operands or operands of the wrong type, or extra ones - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened An operator had too few operands or operands of the wrong type (it was skipped), or extra operands (the last ones were used). ## Why A producer bug, or damaged content. ## How to fix Nothing to do. ## Example ```text 72 Td ``` `Td` takes two numbers; with one, the operator is skipped. ## Data fields - `offset`: the operator's byte offset in the decoded content. The `location` is `object` (the page for page content, the Form XObject for a form) with `streamOffset` in its decoded content; none for content interpreted on its own or a direct page. --- # content-path-unpainted a content stream ended with a path no painting operator ended - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened A content stream ended with a path that no painting operator ended; it was dropped. ## Why A producer bug, or truncated content. ## How to fix Nothing to do. ## Example ```text 0 0 m 100 100 l ``` The content stream ends after building a path without `S`, `f` or `n`: the path is dropped, as the spec intends. ## Data fields - `offset`: the byte offset of the path's first segment in the decoded content. The `location` is `object` (the page for page content, the Form XObject for a form) with `streamOffset` in its decoded content; none for content interpreted on its own or a direct page. --- # content-resource-missing an operator named a resource that is missing, of the wrong type or unreadable - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened An operator named a resource (font, XObject, graphics state, colour space, shading, property list) that is missing, of the wrong type or unreadable, or a Form XObject's content could not be decoded; the operator was skipped or ran without it. ## Why Resources lost in editing or merging, or damaged objects. ## How to fix Check the page's `/Resources`. ## Example ```text /Im7 Do % /Resources << /XObject << /Im1 8 0 R >> >> ``` `/Im7` is not in the page's resources, so the `Do` draws nothing. `data.category` is `XObject`; the resource's name, which comes from the file, is never put in the message. ## Data fields - `offset`: the operator's byte offset in the decoded content. - `category`: the resource category, e.g. `Font` (Lamina PDF's own vocabulary). The `location` is `object` (the page for page content, the Form XObject for a form) with `streamOffset` in its decoded content; none for content interpreted on its own or a direct page. --- # content-syntax content-stream bytes that are not a token were skipped - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened A content stream held bytes that are not a token (an unbalanced `)` or `>`, a bad hex digit, an unterminated string or array) or a stray `ID`/`EI`; they were skipped with the operands read so far. ## Why Damaged or truncated content. ## How to fix Check the page's content around the reported offset. ## Example ```text BT (unbalanced Tj ET ``` The string never closes, so the bytes to the end of the token are skipped with the pending operands. ## Data fields - `offset`: the problem's byte offset in the decoded content. The `location` is `object` (the page for page content, the Form XObject for a form) with `streamOffset` in its decoded content; none for content interpreted on its own or a direct page. --- # content-unbalanced-bt a text object was opened twice, closed outside one, or left open - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened A `BT` inside a text object, an `ET` outside one, a text operator outside one, or a content stream that ended inside one. ## Why A producer bug, or damaged content. ## How to fix Nothing to do; text operators were handled tolerantly. ## Example ```text BT /F1 12 Tf (one) Tj BT (two) Tj ET ``` The second `BT` opens a text object inside one; Lamina PDF treats it as a new text object and carries on. ## Data fields - `offset`: the operator's byte offset, or the end of the content. The `location` is `object` (the page for page content, the Form XObject for a form) with `streamOffset` in its decoded content; none for content interpreted on its own or a direct page. --- # content-unbalanced-marked an EMC without a matching BMC or BDC was ignored, or marked content was left open - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened An `EMC` had no matching `BMC`/`BDC` (it was ignored), or a content stream ended with marked-content sequences open (they were closed). ## Why A producer bug, or tagging split across streams. ## How to fix Check tagged-content results; the sequences were closed for you. ## Example ```text /P << /MCID 0 >> BDC (text) Tj ``` The stream ends with the marked-content sequence open; it is closed at the end, so MCID 0 covers the text that follows it. ## Data fields - `offset`: the `EMC`'s byte offset, or the end of the content. The `location` is `object` (the page for page content, the Form XObject for a form) with `streamOffset` in its decoded content; none for content interpreted on its own or a direct page. --- # content-unbalanced-q a Q without a matching q was ignored, or saved graphics states were left open - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened A `Q` had no matching `q` (it was ignored), or a content stream ended with saved graphics states still open (they were restored). ## Why Producers that split `q`/`Q` pairs across streams, or damaged content. ## How to fix Nothing to do; each stream balances on its own. ## Example ```text q 1 0 0 1 72 72 cm 0 0 10 10 re f Q Q ``` The second `Q` has no matching `q` and is ignored. ## Data fields - `offset`: the `Q`'s byte offset, or the end of the content. The `location` is `object` (the page for page content, the Form XObject for a form) with `streamOffset` in its decoded content; none for content interpreted on its own or a direct page. --- # content-unknown-operator an operator the spec does not define was skipped outside a BX/EX section - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened A content stream used an operator ISO 32000-2 Annex A does not define, outside a `BX`/`EX` compatibility section; it was skipped. ## Why A producer wrote a private or misspelled operator. ## How to fix Nothing to do; check the page's rendering if the operator mattered. ## Example ```text BT /F1 12 Tf 72 720 Td (Hi) Tj ET 0 0 1 setcolor ``` `setcolor` is not an Annex A operator and is not inside `BX`/`EX`, so it is skipped with its operands. `data.offset` is its byte offset in the decoded content. ## Data fields - `offset`: the operator's byte offset in the decoded content. The `location` is `object` (the page for page content, the Form XObject for a form) with `streamOffset` in its decoded content; none for content interpreted on its own or a direct page. --- # data-pending the ByteSource has not fetched the byte ranges the call needs yet - Type: error - Kind: `state` (the call isn't valid in the object's current state) ## What happened The `ByteSource` hasn't fetched the listed byte ranges yet. ## Why Range loading: the call needs more of the file. ## How to fix Fetch the ranges and retry the call; the document stays usable. ## Example ```ts const doc = await Document.openRange(httpRangeSource(url)); // pages() needs bytes that haven't arrived: ensure() fetches e.ranges and retries const pages = await doc.ensure(() => doc.pages); ``` ## Data fields `ranges` (offset, length pairs). --- # decompress compressed stream data was corrupt - Type: error - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened Compressed data was corrupt and produced no output. ## Why Damaged streams. ## How to fix Use another copy of the file. ## Example ```text 5 0 obj << /Filter /FlateDecode /Length 40 >> stream (40 bytes that are not zlib data) endstream ``` Inflating fails before producing any output, so the stream has no data. ## Data fields None. --- # device-color-without-output-intent a DeviceCMYK colour or image was used, but the PDF/A output intent is sRGB - Type: error - Kind: `usage` (the caller misused the API) ## What happened Under a PDF/A profile, a page draws with `DeviceCMYK` (CMYK given as plain numbers, with no profile to say which inks they mean): a `Color::cmyk` colour or a CMYK JPEG. ## Why The PDF/A standard for archival PDF (ISO 19005) gives device colour a defined meaning through the document's output intent, which Lamina PDF writes as sRGB. That sRGB intent defines device RGB and gray, but not device CMYK, so ISO 19005-2 §6.2.4.3 forbids CMYK under it. ## How to fix Use `Color::rgb` or `Color::gray`, convert CMYK images to RGB, or give CMYK colours an ICC profile with `Color::icc` (an ICC-based colour carries its own meaning). ## Example ```rust surface.fill_color(Color::cmyk(0.0, 0.5, 1.0, 0.0)); builder.finish()?; // device-color-without-output-intent under Profile::PdfA2b ``` ## Data fields - `page`: the index (0-based) of the first page that uses `DeviceCMYK`. --- # direct-page the page is a direct dictionary, which ops cannot edit yet - Type: error - Kind: `unsupported` (valid input that Lamina PDF doesn't implement) ## What happened The page is a direct dictionary, which ops can't edit yet. ## Why Non-standard files. ## How to fix Rewrite the file so every page is an indirect object (any full rewrite by another tool does), or wait for Lamina PDF's page-structure ops. ## Example ```text 2 0 obj << /Type /Pages /Kids [<< /Type /Page /MediaBox [0 0 612 792] >>] /Count 1 >> ``` The page is written inline in `/Kids`. Reading works (its ID is a path ID such as `p2.0/Kids/0`), but a `rotatePage` op on it fails with `direct-page`. ## Data fields None. --- # disposed a document, builder or other handle was used after free(), or after the using block that held it ended - Type: error - Kind: `state` (the call isn't valid in the object's current state) ## What happened A `Document`, `Builder` or other handle of `@laminapdf/sdk` was used after `free()` released it, or after the `using` block that held it ended (`Symbol.dispose`). A page or save job is freed with its document, and a builder's page with its builder. ## Why Each handle owns memory inside the WebAssembly module. Freeing it returns that memory at once, so the handle has nothing left to work on. ## How to fix Free a handle only after its last use. To keep working with the document, open it again from its bytes, or create a new builder. ## Example ```ts const doc = Document.open(bytes); doc.free(); doc.save(); // throws LaminaPdfError { code: "disposed", … } ``` ## Data fields None. --- # document-changed the document a job was created for changed (an edit, undo, redo, save or rebase) or a different document was passed to step - Type: error - Kind: `state` (the call isn't valid in the object's current state) ## What happened A job's `step` was given a document other than the one the job was created from, or that document changed since: a committed transaction, an undo, a redo, a save or a rebase. Every step checks the document's instance and revision before any work, and the job is now finished. ## Why Jobs don't borrow the document, so other calls can run between two steps. A save or a read that went on after an edit would mix two states of the document, so the job stops instead. ## How to fix Pass the document the job was created from, don't change it while the job runs, and create a new job after a change. A failed save left the document as it was. ## Example ```rust let mut job = doc.save_job(&laminapdf::SaveOptions::new()); doc.apply(vec![laminapdf::Op::from(set_info)])?; // the revision moves on assert_eq!(job.step(&mut doc, laminapdf::Budget::UNLIMITED).unwrap_err().code(), "document-changed"); ``` ## Data fields None. --- # embedded-file-forbidden PDF/A-2 doesn't allow attached files that aren't PDF/A themselves - Type: error - Kind: `usage` (the caller misused the API) ## What happened `Builder::attach` added a file to a document built under PDF/A-2b or PDF/A-2u. ## Why The PDF/A-2 standard for archival PDF (ISO 19005-2 §6.8) allows only attached files that are themselves PDF/A. Lamina PDF can't check what an attachment is, so it rejects every attachment under PDF/A-2 on purpose, PDF/A ones included. The PDF/A-3 standard for archival PDF (ISO 19005-3) allows files of any kind, provided they are associated with the document, which `Builder::attach` does. ## How to fix Choose `Profile::PdfA3b` for documents that carry files, such as an e-invoice's XML, or leave the attachment out. ## Example ```rust let options = BuildOptions::default().with_profile(Profile::PdfA2b); let mut builder = Builder::new(options); builder.attach(Attachment::new("data.csv", csv, "text/csv")); builder.finish()?; // embedded-file-forbidden: use Profile::PdfA3b ``` ## Data fields - `profile`: the profile, such as `PDF/A-2b`. --- # embedded-file-without-mime-type an attached file has no MIME type, which every PDF/A profile requires - Type: error - Kind: `usage` (the caller misused the API) ## What happened Under a PDF/A profile, `Builder::attach` added a file with an empty MIME type. A MIME type names the file's format, such as `text/xml`. ## Why Software reading an archive years later needs to know what each attached file is. The PDF/A-3 standard for archival PDF (ISO 19005-3 §6.8) requires every embedded file to name its MIME type in the `/Subtype` of its stream, and Lamina PDF writes no `/Subtype` for an empty one. ## How to fix Pass the file's MIME type to `Attachment::new`. For bytes of no particular type, use `application/octet-stream`. ## Example ```rust let options = BuildOptions::default().with_profile(Profile::PdfA3b); let mut builder = Builder::new(options); builder.attach(Attachment::new("invoice.xml", xml, "")); builder.finish()?; // embedded-file-without-mime-type: pass "text/xml" ``` ## Data fields - `profile`: the profile, such as `PDF/A-3b`. - `name`: the file's name (untrusted: the caller's text). --- # encoding-full a standard 14 font has no free one-byte code left for another glyph - Type: error - Kind: `limit` (a `Limits` value or another resource bound was hit) ## What happened A standard 14 font had no free one-byte code left for another glyph in this document. ## Why A standard 14 font is a simple font: text is written one byte per character, so one font resource addresses at most 256 glyphs (ISO 32000-2 §9.6.5). Lamina PDF writes the Latin fonts in `WinAnsiEncoding`, and gives each glyph of the standard Latin character set outside it (`Lslash`, `Scedilla`, `minus`, …) one of the 37 codes `WinAnsiEncoding` leaves undefined, through `/Differences`. A document that draws more such glyphs with one font than there are free codes runs out. Assignment is all or nothing: when one `encode` call needs more new codes than are free, none are assigned and nothing of the text is recorded. ## How to fix Embed a font (`Font::from_bytes`), which addresses every glyph through a Type 0 font, or split the text across two registrations of the standard font (each is its own font resource with its own codes). ## Example Drawing more than 37 different non-WinAnsi Latin glyphs (Ā, ă, Ą, …) with `Standard14::Helvetica` in one document: the 38th has no free code in the font's `/Differences`, so the call fails with `encoding-full`. ## Data fields - `font`: the standard font's PostScript name (fixed text: one of the fourteen). - `limit`: how many codes `/Differences` can assign for this font. - `free`: how many of them were still free; the text needed more, so none were assigned. --- # encrypted-base a save of an encrypted document was requested - Type: error - Kind: `unsupported` (valid input that Lamina PDF doesn't implement) ## What happened A save was requested for an encrypted document without `SaveOptions::with_remove_encryption(true)` (every mode: `Auto`, `Incremental` and `FullRewrite`). Nothing was written. `laminapdf_save::SaveError::EncryptedBase` and the writers' `laminapdf_cos::Error::EncryptedBase` share this code. ## Why Lamina PDF can't encrypt on save yet, and it never drops or corrupts encryption silently. ## How to fix Save with `SaveOptions::with_remove_encryption(true)` to write a plaintext full rewrite (every string and stream decrypted, no `/Encrypt`), or wait until Lamina PDF can encrypt on save. A signed and encrypted document also needs `with_invalidate_signatures(true)`. ## Example ```rust let err = doc.save(&SaveOptions::default()).unwrap_err(); // encrypted-base let saved = doc.save(&SaveOptions::default().with_remove_encryption(true))?; ``` ## Data fields None. --- # extract-limit-blocks a page had more runs of text than the extraction block limit; the runs past it were left out - Type: warning - Kind: `limit` (a `Limits` value or another resource bound was hit) ## What happened A page had more runs of text than `ExtractLimits::max_blocks_per_page` allows. Since each run can be a block of its own, extraction kept the first runs the page draws and left out the rest. It cuts them before grouping runs into blocks, so the work stays bounded. ## Why A page of many tiny separate pieces of text, such as a dense table, a map with hundreds of labels, or a file built to slow extraction down. ## How to fix Raise `max_blocks_per_page` if the page really holds that many blocks. ## Example ```rust let mut options = laminapdf_extract::ExtractOptions::default(); options.limits.max_blocks_per_page = 50_000; ``` ## Data fields - `limit`: the limit that was reached. The `location` has the page's `pageIndex`. --- # extract-module-not-loaded page.text() was called on a document opened in the base module, which has no text extraction - Type: error - Kind: `state` (the call isn't valid in the object's current state) ## What happened `page.text()` of `@laminapdf/sdk` was called on a page of a document opened in the base module, which has no text extraction. ## Why `@laminapdf/sdk` ships three WebAssembly modules: base (open, read, edit, save), extract (base plus text extraction) and text (base plus fonts, shaping and extraction). A document lives in the module it was opened in, and opening uses the richest one loaded at that moment: text, then extract, then base. A document opened before the extract or text module was loaded stays in the base module. ## How to fix Load the extract module (about half the size of the text module) before opening the document: `await init({ extract: true })` or `await loadExtract()`. A document already open in the base module can be moved by saving it and opening the bytes again. ## Example ```js await init(); const doc = Document.open(bytes); doc.page(0).text(); // throws { code: "extract-module-not-loaded", … } await loadExtract(); Document.open(bytes).page(0).text().text(); // works ``` ## Data fields None. --- # extract-reading-order-ambiguous a page mixes horizontal and vertical writing, so the order of its blocks is a guess - Type: warning - Kind: `unsupported` (valid input that Lamina PDF doesn't implement) ## What happened A page mixes horizontal and vertical writing. Extraction put its blocks in an order, but no layout rule tells which comes first, so the order is a guess. ## Why Japanese and Chinese pages often set the body vertically and captions, headings or page numbers horizontally. Without a structure tree, nothing in the file says how the two relate. ## How to fix Nothing to do in Lamina PDF; the file is valid. Like every warning, it fails the call in strict mode. Check the order of that page's blocks, or ask for the order the content stream draws (`ReadingOrder::ContentStream`). A tagged file avoids the guess. ## Example A page whose body text is drawn with a vertical font (`Identity-V`) and whose page number is drawn with a horizontal one. ## Data fields None. The `location` has the page's `pageIndex`. --- # extract-struct-tree-ignored a tagged page's structure tree covered too little of its text, or its order jumped back across columns too often, so the layout gave the reading order - Type: warning - Kind: `input` (a problem with the PDF file) ## What happened The page is tagged, but its structure tree didn't pass the checks that decide whether to trust it, so extraction read the page in layout order instead. ## Why A structure tree gives a page's reading order, but trees are often incomplete or wrong. Extraction uses a page's tree only when its elements cover at least 80% of the page's visible text, not counting headers, page numbers and other text the file marks as page furniture. Its order must also jump back up into an earlier column at most once in ten moves from block to block. A tree written by a tool that tags only some of the text, or that tags it in drawing order, fails one of the checks. ## How to fix Nothing to do: the page reads in layout order, as an untagged page would. To use the tree anyway, extract with `ReadingOrder::Tagged` (`readingOrder: "tagged"`). Like every warning, it fails the call in strict mode. ## Example A report whose tool tagged only its headings: the tree covers a tenth of the text, so the reason is `coverage`. ## Data fields - `reason`: `coverage` or `order`, the check that failed. The `location` has the page's `pageIndex`. --- # extract-table-ambiguous a table's rules marked out merged cells that aren't rectangles, so its cells were kept unmerged - Type: warning - Kind: `unsupported` (valid input that Lamina PDF doesn't implement) ## What happened Extraction found a table drawn with lines, but the cells its missing lines would join don't make a rectangle. Extraction kept the table and gave it one cell per square of its grid, so a merged cell's text sits in the first of the squares it covers. ## Why A table's lines tell where cells are merged: a missing line between two squares joins them. Joined squares that form an L or a T shape don't describe a cell, usually because a line is drawn slightly short, drawn twice in different places, or covered by a white rectangle. ## How to fix Nothing to do in Lamina PDF; the file is valid. Like every warning, it fails the call in strict mode. Check that table's cells: the text of a merged cell may be split across its neighbours. ## Example A table whose header spans two columns over the first row only, but whose line under that header stops halfway. ## Data fields None. The `location` has the page's `pageIndex`. --- # extract-table-limit a page's ruling lines would make a table bigger than the extraction's cell limit; those lines gave no table - Type: warning - Kind: `limit` (a `Limits` value or another resource bound was hit) ## What happened A page's ruling lines would have made a table with more cells than `ExtractLimits::max_table_cells` allows, or the page drew so many lines that matching them all would cost too much. Extraction made no table from those lines; their text is still in the page's paragraphs. ## Why A page drawn with thousands of lines, such as a large grid, a chart's gridlines or a file built to slow extraction down. To find a table, extraction compares every horizontal line with every vertical one, so the work grows with the square of the line count. ## How to fix Raise `max_table_cells` if the page really holds a table that big. ## Example ```rust let mut options = laminapdf_extract::ExtractOptions::default(); options.limits.max_table_cells = 50_000; ``` ## Data fields - `limit`: the limit that was reached. The `location` has the page's `pageIndex`. --- # extract-untagged-content a page read in structure-tree order has visible text that is neither in the tree nor an artifact; it follows the tree's blocks, in layout order - Type: warning - Kind: `input` (a problem with the PDF file) ## What happened The page was read in its structure tree's order, but some of its visible text isn't in the tree and isn't marked as an artifact. Extraction kept that text and put it after the tree's blocks, in layout order. ## Why In a tagged file, every piece of visible content should belong to a structure element or be marked as an artifact (ISO 32000-2 §14.8.2.2). Text that is neither has no place in the tree's order. A tool that adds text after tagging a document, such as a stamp or a page number, often leaves text like this. ## How to fix Nothing to do in Lamina PDF. To give that text its place in the reading order, tag it in the file, or mark it as an artifact if it isn't content. Like every warning, it fails the call in strict mode. ## Example A tagged contract with a "DRAFT" stamp drawn after tagging: the stamp's text comes after the contract's blocks. ## Data fields - `chars`: how many characters (spaces left out) lie outside the tree. The `location` has the page's `pageIndex`. --- # fetch-failed a range source could not fetch input bytes: a network error, an HTTP status other than 200 and 206, a 206 whose Content-Range or length doesn't match the request, or a read that returned the wrong number of bytes - Type: error - Kind: `io` (the host failed to supply bytes; often transient) ## What happened A range source of `@laminapdf/sdk` (`httpRangeSource`) couldn't get bytes it asked for. The request failed (a network error, a CORS refusal, DNS), or the server answered with a status other than `200` or `206`, or with a `206` whose `Content-Range` (or length) doesn't match the range asked for. A `Content-Range` that the browser hides, because CORS doesn't expose it, looks the same. It is also raised when a `callbackSource`'s read returns a different number of bytes than it was asked for. ## Why Opening a remote file range by range depends on the server: it must allow the `Range` request (and, across origins, expose `Content-Range`, `Content-Length` and `ETag` through CORS), and must not compress the response (`Content-Encoding` breaks byte ranges). Lamina PDF never retries on its own; the error says the bytes are not available. ## How to fix Check that the URL is reachable from where the code runs and that the server sends `Access-Control-Allow-Origin`, `Access-Control-Allow-Headers: Range` and `Access-Control-Expose-Headers: Content-Range, Content-Length, ETag`. Serve PDFs without `Content-Encoding`. Retry the call yourself if the failure was transient. ## Example ```js const doc = await Document.openRange(httpRangeSource(url)); // throws { code: "fetch-failed", data: { status: 403 } } ``` ## Data fields - `reason`: `network`, `status`, `content-range` or `length` (Lamina PDF's own words). The URL is never included. - `status`: the HTTP status, when there was a response. - `expected`, `got`: for `length`, the bytes asked for and the bytes returned. --- # flate-truncated Flate data was truncated or had a bad checksum; the partial output was kept - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened Flate data was truncated or had a bad checksum; the partial output was kept. ## Why Truncated downloads or damaged streams. ## How to fix Nothing, if the content looks right; part of the stream may be missing. ## Example ```text 5 0 obj << /Filter /FlateDecode /Length 900 >> stream …(the zlib data stops early)… endstream ``` Lamina PDF keeps what inflated before the data ended or the Adler-32 checksum failed; the content may be cut short. The warning carries the stream's `object`. ## Data fields None. --- # font-invalid a font dictionary has a missing or malformed entry, so its text was decoded with defaults - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened A font dictionary read for text extraction has a missing, unreadable or malformed entry (the descendant font, `/Encoding` or its `/Differences`, `/Widths`, `/W`, `/W2`, the font descriptor or its font program). Its text was decoded with defaults: the standard encoding, the standard 14 font's widths or `/MissingWidth`, `/DW`. ## Why A producer bug, a damaged file, or a font dictionary that relies on what a viewer guesses. ## How to fix Nothing to do. Text or positions extracted for that font may be off; regenerating the file with a current producer fixes it. ## Example ```text << /Type /Font /Subtype /Type0 /BaseFont /Foo /Encoding /Identity-H /DescendantFonts 99 0 R >> ``` The descendant font is missing, so text in this font is decoded with defaults: two-byte codes, default widths. The `location` is the font dictionary's `object`. ## Data fields None. The `location` is `object`, the font dictionary's object; none for a direct font dictionary. --- # font-mismatch a shaped run was given with a font key for a different font than it was shaped with - Type: error - Kind: `usage` (the caller misused the API) ## What happened `pdf_run` was given a shaped run and the key of a font other than the one the run was shaped with. Nothing was recorded in the registry. ## Why Glyph IDs, advances and units per em only mean something in the font that produced them; drawing them with another font would show the wrong glyphs and wrong text. ## How to fix Register every font of a layout and pass the key of the run's own font: each `LineRun::font` is an index into the font list given to `break_lines`. ## Example ```rust let run = shaper.shape(¬o, text, &options)?; pdf_run(&run, text, &mut registry, roboto_key)?; // font-mismatch: shaped with Noto ``` ## Data fields - `font`: the registered font's PostScript name (**untrusted**: read from the font). - `shaped_with`: the PostScript name of the font the run was shaped with (**untrusted**: read from the font). --- # font-not-embeddable the font's OS/2 fsType forbids embedding - Type: error - Kind: `usage` (the caller misused the API) ## What happened `FontRegistry::add` refused a font whose `OS/2.fsType` forbids embedding (restricted licence, or bitmap embedding only). ## Why Font vendors mark fonts whose licence does not allow embedding in documents (OpenType `OS/2` table; research 03 §6.3). `fsType` is advisory; the licence is what counts. ## How to fix Use another font, or, if your licence allows embedding, register it with `EmbedPolicy::Override`. ## Example ```rust registry.add(restricted_font, EmbedPolicy::Respect)?; // font-not-embeddable registry.add(restricted_font, EmbedPolicy::Override)?; // only if your licence allows embedding ``` ## Data fields - `font`: the font's PostScript name (**untrusted**: read from the font). - `fs_type`: the `fsType` bits. --- # font-not-embedded a standard 14 font, which is never embedded, was used under a profile that needs every font embedded - Type: error - Kind: `usage` (the caller misused the API) ## What happened Text was drawn with one of the standard 14 fonts (`Builder::standard_font`) under PDF/UA-1 or a PDF/A profile. The standard 14 fonts are never embedded: viewers substitute their own. ## Why The PDF/A standard for archival PDF (ISO 19005-2 §6.2.11.4.1) needs every font embedded, so the document looks the same everywhere in the future. The PDF/UA-1 standard for accessible PDF (ISO 14289-1 §7.21.4.1) needs it too, so viewers can render the text and map it to characters reliably. This is condition 31-009 of the Matterhorn Protocol, the PDF Association's checklist of PDF/UA failures. ## How to fix Load a font file with `Builder::font` (or `Builder::font_bytes` in the façade): Lamina PDF embeds the subset of glyphs you draw. ## Example ```rust let bold = builder.standard_font(Standard14::HelveticaBold)?; surface.text("Invoice", &bold, Pt::new(24.0), at)?; builder.finish()?; // font-not-embedded under Profile::PdfA2b ``` ## Data fields - `profile`: the profile, such as `PDF/A-2b`. - `font`: the font's PostScript name, such as `Helvetica-Bold`. --- # font-not-shapable a standard 14 font was given to shaping or layout, which need a font program - Type: error - Kind: `unsupported` (valid input that Lamina PDF doesn't implement) ## What happened A standard 14 font (`Font::standard`) was given to the shaper, `itemize`, `break_lines` or `pdf_run`. ## Why The standard 14 fonts are referred to by name and never embedded. Lamina PDF has their metrics (Adobe's AFM files) but no font program, so there is nothing for HarfRust to shape with and no `cmap` for itemizing. Nor are there CIDs for a shaped run: a standard 14 font is written as a simple font with one-byte codes. ## How to fix Draw standard 14 text with `FontRegistry::encode` (one glyph per character, kerning not applied), or load a real font with `Font::from_bytes` and shape with it. ## Example ```rust let helvetica = Font::standard(Standard14::Helvetica); shaper.shape(&helvetica, "Hello", &options)?; // font-not-shapable ``` ## Data fields - `font`: the standard font's PostScript name (fixed text: one of the fourteen). --- # glyph-out-of-range a glyph ID at or past the font's glyph count was used - Type: error - Kind: `usage` (the caller misused the API) ## What happened `FontRegistry::use_glyph` was given a glyph ID at or past the font's glyph count. ## Why Glyph IDs must come from the same font (its `cmap` or a shaper run on it). ## How to fix Pass glyph IDs from the font you registered. ## Example ```rust registry.use_glyph(key, GlyphId::new(60_000), "x")?; // the font has 1,200 glyphs ``` ## Data fields - `gid`: the glyph ID. - `count`: the font's glyph count. - `font`: the font's PostScript name (**untrusted**: read from the font). --- # glyph-without-unicode a glyph was drawn without a Unicode value, which PDF/A-2u and PDF/UA-1 forbid - Type: error - Kind: `usage` (the caller misused the API) ## What happened Under PDF/A-2u or PDF/UA-1, a glyph was drawn that stands for no text of its own, or whose text is U+0000, U+FEFF or U+FFFE. Shaping can draw one cluster (a group of characters that display as one unit) with several glyphs. The text then belongs to the first glyph, and the others stand for none. ## Why Software can search, copy or read text aloud only when every glyph maps to a usable Unicode value. The PDF/A-2u archival standard (ISO 19005-2 §6.2.11.7.2) and the PDF/UA-1 standard for accessible PDF (ISO 14289-1 §7.21.7) both need that mapping. These are conditions 31-027 to 31-029 of the Matterhorn Protocol, the PDF Association's checklist of PDF/UA failures. Lamina PDF maps a glyph that stands for no text to an empty value, which these profiles reject. ## How to fix Check the text for control characters and byte-order marks. If shaping draws several glyphs for one cluster, choose a font that draws each cluster as one glyph. An archive that needs no accessibility can use PDF/A-2b instead, which doesn't need Unicode for every glyph. PDF/UA-1 has no such relaxed variant, so a PDF/UA-1 document needs the font change. ## Example ```rust surface.text("\u{FEFF}Report", &font, Pt::new(12.0), at)?; builder.finish()?; // glyph-without-unicode under Profile::PdfA2u ``` ## Data fields - `profile`: the profile, `PDF/A-2u` or `PDF/UA-1`. - `font`: the font's PostScript name (untrusted: read from the font file). - `gid`: the glyph's ID in the font. --- # heading-level-skipped a heading skips a level (the first is not H1, or an H3 follows an H1), which PDF/UA-1 forbids - Type: error - Kind: `usage` (the caller misused the API) ## What happened Under PDF/UA-1, the document's headings skip a level. Numbered headings are `H1`, `H2` and so on, as opposed to the unnumbered `H`. Either the first numbered heading is not `H1`, or a heading is more than one level below the heading before it (an `H3` straight after an `H1`). ## Why Screen-reader users move through a document by its headings, and a skipped level suggests a missing section. The PDF/UA-1 standard for accessible PDF (ISO 14289-1 §7.4.2) requires numbered headings to start at `H1` and to go down one level at a time. These are conditions 14-002 and 14-003 of the Matterhorn Protocol, the PDF Association's checklist of PDF/UA failures. Going back up any number of levels is fine. ## How to fix Use the next level down for a subsection (`H2` under `H1`, `H3` under `H2`). If a heading should only look smaller, change its font size, not its level. ## Example ```rust s.text("Results", &font, Pt::new(18.0), at)?.tag(TagKind::H1); s.text("Method", &font, Pt::new(12.0), at2)?.tag(TagKind::H3); builder.finish()?; // heading-level-skipped: the second heading should be an H2 ``` ## Data fields - `page`: the page's index (0-based). - `level`: the heading's level. - `previous`: the level of the heading before it (0 when there is none). --- # image-data-length raw image samples are not width x height x channels bytes long - Type: error - Kind: `usage` (the caller misused the API) ## What happened `Image::rgba(width, height, bytes)` was given a number of bytes other than `width × height × 4`. ## Why Raw RGBA samples are 4 bytes per pixel, row by row from the top, with no padding. ## How to fix Pass exactly 4 bytes per pixel. ## Example ```rust let img = Image::rgba(2, 2, &[0; 12])?; // image-data-length: 16 bytes expected ``` ## Data fields - `expected`: the length the dimensions call for. - `actual`: the length given. --- # image-too-large the image needs more memory than the builder allows (a decoded PNG over 256 MiB, or raw dimensions whose size overflows) - Type: error - Kind: `limit` (a `Limits` value or another resource bound was hit) ## What happened Decoding a PNG would need more than `MAX_DECODED_IMAGE_BYTES` (256 MiB), or the dimensions given to `Image::rgba` describe more bytes than fit in memory's address space. ## Why The builder decodes PNGs to split colour and alpha; a bound on the decoded size keeps a small file with huge dimensions from exhausting memory. ## How to fix Scale the image down before adding it, or use a JPEG, which is embedded without decoding. ## Example ```rust let img = Image::png(&png_of_30000_by_30000_pixels)?; // image-too-large: over 256 MiB decoded ``` ## Data fields - `limit`: the limit in decoded bytes. --- # inline-image-unterminated no EI ends an inline image; the rest of the content was skipped - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened No `EI` ends an inline image (neither at its declared or computed length nor by searching for it); the rest of the content was skipped. ## Why Damaged or truncated content. ## How to fix Check the page: everything after the inline image is missing. ## Example ```text BI /W 10 /H 10 /CS /G /BPC 8 ID …(100 bytes, then the stream ends)… ``` No `EI` follows the image data, so the rest of the stream is skipped. ## Data fields - `offset`: the `BI`'s byte offset in the decoded content. The `location` is `object` (the page for page content, the Form XObject for a form) with `streamOffset` in its decoded content; none for content interpreted on its own or a direct page. --- # input-unreadable an input file, standard input or the password file couldn't be read - Type: error - Kind: `io` (the host failed to supply or take bytes) ## What happened An input couldn't be read: the PDF named on the command line, standard input, or the file given to `--password-file`. ## Why The file may not exist, may be a folder, or may not be readable by the user running the command. ## How to fix Check that the file exists and that you can read it. `data.input` names the file, and `data.reason` says what the system reported. ## Example ```console $ laminapdf info missing.pdf error[input-unreadable]: couldn't read an input = hint: check that the file exists and that you can read it; data.input names it $ echo $? 1 ``` ## Data fields - `input`: the file that couldn't be read, or `-` for standard input (untrusted: caller text, never in the message). - `reason`: the system's error kind, such as `NotFound` or `PermissionDenied`. --- # instance-reset a handle was used after its WebAssembly instance was replaced, by reset() or after a trap - Type: error - Kind: `state` (the call isn't valid in the object's current state) ## What happened A document, page, builder or save job was used after its WebAssembly instance was replaced. `reset()` replaces every loaded module's instance, and a trap (`wasm-trap` or `out-of-memory`) replaces the instance that trapped. The handle belonged to the old instance, so it has nothing left to work on. ## Why Each handle lives in one instance's memory, and a fresh instance starts empty. Lamina PDF can't move a document across, so every handle from the old instance stops working at once. ## How to fix Open the document again from its bytes, or create a new builder. Unsaved edits are lost with the old instance, so save often in an app that calls `reset()` or may run out of memory. ## Example ```ts const doc = Document.open(bytes); reset(); doc.pageCount; // throws LaminaPdfError { code: "instance-reset", … } ``` ## Data fields None. --- # invalid-argument a value passed to a binding does not have the shape its type declares - Type: error - Kind: `usage` (the caller misused the API) ## What happened A value passed from JavaScript to `@laminapdf/wasm` is not of the type its declaration describes. It might be a record with a field of the wrong type, or a string that names no known value (a blend mode, a fill rule, a standard 14 font, an op). It might also be a flat-encoded path cut short, a colour with the wrong number of components, or an ID that isn't 32 hex digits. ## Why The `.d.ts` types describe each argument, but JavaScript (and code written without the types) can pass anything. The binding checks each value as it converts it. ## How to fix Pass what the declaration says. When the value names an unknown string, `didYouMean` lists the closest allowed ones; `data.detail` says what the conversion found. ## Example ```js page.blendMode("multiplly"); // throws { code: "invalid-argument", didYouMean: ["multiply"], … } ``` ## Data fields - `argument`: the parameter that was wrong (`options`, `path`, `mode`, …). - `detail`: what the conversion said (untrusted: it may quote the value passed). --- # invalid-axis-value a variation axis value was NaN or infinite - Type: error - Kind: `usage` (the caller misused the API) ## What happened `Font::instance` was given a NaN or infinite axis value. ## Why Variation coordinates are user-space numbers such as `wght` 700; NaN and infinities have no position on an axis. ## How to fix Pass finite values; values outside the axis range are clamped, so there is no need for infinities. ## Example ```rust font.instance(&[(OpenTypeTag::WEIGHT, f32::NAN)])?; // invalid-axis-value ``` ## Data fields - `tag`: the axis. --- # invalid-box a page boundary box is not four numbers and reads as absent - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened A page boundary box is not four numbers; it reads as absent. ## Why A writer bug: a box that is not an array of four numbers (too few, or names or strings in it). ## How to fix Nothing; the page uses the next box in the chain. ## Example ```text 3 0 obj << /Type /Page /MediaBox [0 0 612 792] /CropBox [0 0 612] >> ``` The CropBox has three numbers, so it reads as absent and the page uses its MediaBox. `data` is `{ "key": "CropBox" }`. ## Data fields `key` (which box, from Lamina PDF's fixed list). --- # invalid-color a colour string is not of the form #rgb or #rrggbb - Type: error - Kind: `usage` (the caller misused the API) ## What happened `Color::hex` was given a string that is not `#rgb` or `#rrggbb`. ## Why Hex colours are a `#` followed by three or six hexadecimal digits, in either case. ## How to fix Write the colour as `#3366cc` or `#36c`, or use `Color::rgb`. ## Example ```rust let c = Color::hex("#12345")?; // invalid-color: 5 digits ``` ## Data fields - `text`: the string as given (untrusted: caller text, never in the message). --- # invalid-command-line the command line doesn't parse: an unknown command or flag, a missing argument, a bad value, or stdin named twice - Type: error - Kind: `usage` (the caller misused the API) ## What happened The command line didn't parse: a command or flag `laminapdf` doesn't have, a missing argument, a value of the wrong form, or `-` (standard input) named as more than one input. ## Why The CLI checks its whole command line before it reads any file, so a typo never runs half a command. Exit code 2 tells a script that the call itself was wrong, not the document. ## How to fix Run the command with `--help` to see its arguments, and `laminapdf --help` for the commands. Read standard input at most once per command. ## Example ```console $ laminapdf info --pages 1 report.pdf error[invalid-command-line]: unexpected argument '--pages' found Usage: laminapdf info [OPTIONS] For more information, try '--help'. $ echo $? 2 ``` With `--json`, the line on standard error has the message "the command line doesn't parse" and the parser's finding in `data.detail`. ## Data fields Which field is there depends on what was wrong: - `detail`: when the parser refused the line, the kind of problem it found, such as `UnknownArgument` or `MissingRequiredArgument`. - `argument`: when a value parsed but means nothing, the value given: the angle of `pages rotate`, a `help` topic or a `--fields` key (untrusted: caller text, never in the message). --- # invalid-date an Info date is not a valid PDF date; the raw string is kept - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened An Info date is not a valid PDF date (ISO 32000-2 §7.9.4); the raw string is kept. ## Why Writers that write free-form dates. ## How to fix The `Info` view keeps the raw string; read it there if you need it. ## Example ```text << /CreationDate (last Tuesday) >> ``` The Info view's `creation_date` is `None` and its raw string is kept; `data` is `{ "key": "CreationDate" }`. ## Data fields `key` (which entry). --- # invalid-entry an entry a view reads is unreadable or of the wrong type and reads as absent - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened An entry a view reads is unreadable or of the wrong type; it reads as absent or its default. ## Why A writer bug: an entry of the wrong type, or a reference that does not resolve. ## How to fix Nothing to do; the view uses the entry's default. ## Example ```text 1 0 obj << /Type /Catalog /Pages 2 0 R /Outlines (none) >> ``` `/Outlines` should be a dictionary; the outline reads as empty and `data` is `{ "key": "Outlines" }`. ## Data fields `key` (from Lamina PDF's fixed list). --- # invalid-feature an OpenType feature setting did not parse - Type: error - Kind: `usage` (the caller misused the API) ## What happened An OpenType feature setting given to the shaper (`"liga=0".parse::()`) did not parse. ## Why Feature settings use HarfBuzz's syntax without ranges: `tag` or `+tag` (on), `-tag` (off), `tag=N`, `tag=on` or `tag=off`, where the tag is one to four printable ASCII characters. A sign together with a value (`+liga=1`), an empty or longer tag, or a value that is not a non-negative integer is refused. ## How to fix Write the setting as `liga=0`, `-liga`, `smcp` or `aalt=2`, or build it with `Feature::new(OpenTypeTag::new(b"liga"), 0)`. ## Example ```rust let f: Feature = "liga[3:5]=0".parse()?; // invalid-feature: ranges are not supported ``` ## Data fields - `feature`: the setting as given (**untrusted**: caller text). --- # invalid-font font bytes could not be read as a font - Type: error - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened Font bytes passed to `Font::from_bytes` or `Font::from_collection` (`laminapdf-text`) could not be read as a font. ## Why The bytes are not an OpenType/TrueType font or collection (WOFF/WOFF2 must be decompressed first), or a table every font needs (`head`, `hhea`, `hmtx`, `maxp`) is missing or malformed. ## How to fix Pass the bytes of a `.ttf`, `.otf`, `.ttc` or `.otc` file; check that the file is complete. ## Example ```rust let err = laminapdf::Font::from_bytes(std::fs::read("font.woff2")?).unwrap_err(); // invalid-font ``` ## Data fields - `index`: the collection member that was asked for (0 for a single font). --- # invalid-geometry a page size, box, /UserUnit, drawing value (coordinate, matrix, line width, dash, miter limit) or image size is not usable - Type: error - Kind: `usage` (the caller misused the API) ## What happened A page size, page box, `/UserUnit`, drawing value or image size given to the builder is not usable. Drawing values are checked as the `Surface` receives them and the first bad one is reported by `PageBuilder::finish`. ## Why Page sizes and `/UserUnit` must be finite and positive, and page boxes, path coordinates, matrices and image rectangles finite. Line widths and dash lengths must be finite and not negative, and a dash pattern not all zeros (ISO 32000-2 §8.4.3.6). Miter limits must be at least 1, images at least one pixel wide and tall, font sizes and line heights positive, text boxes wider than 0, and destination coordinates finite. ## How to fix Check the values passed to `Builder::page`, `PageBuilder::user_unit`, the box setters, the `Surface` (paths, `transform`, `StrokeStyle`, `image`, `text` and `text_box` sizes, positions, boxes and line heights, `link` rectangles), destinations (`Fit` coordinates) and `Image::rgba`. ## Example ```rust let page = builder.page(PageSize::new(Pt::new(0.0), Pt::new(842.0))); // invalid-geometry at finish ``` ## Data fields - `what`: which value was invalid (fixed text). --- # invalid-icc-profile the bytes are not a usable ICC profile - Type: error - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened `IccProfile::new` (`laminapdf-build`) rejected the bytes. ## Why An ICC profile (ICC.1:2010) starts with a 128-byte header holding its size, the `acsp` signature, its class and its data colour space. PDF's `ICCBased` colour spaces (ISO 32000-2 §8.6.5.5) take Gray, RGB, CMYK and Lab profiles. The builder takes Gray, RGB and CMYK: Lab components fall outside `[0, 1]` and need a `/Range`, which it doesn't support yet. Device-link, abstract and named-colour profiles can't be used. ## How to fix Pass the complete bytes of an input, display, output or colour-space profile with a Gray, RGB or CMYK data colour space. ## Example ```rust let profile = IccProfile::new(b"not a profile".to_vec())?; // invalid-icc-profile ``` ## Data fields - `reason`: what was wrong (fixed text). --- # invalid-image image bytes are not a readable image of the format they were given as - Type: error - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened `Image::jpeg`, `Image::png` or `Image::png_with` (`laminapdf-build`) could not read the bytes as an image of that format. ## Why A JPEG must start with a start-of-image marker and have a frame header (SOF) before its first scan, with every marker segment inside the data; a PNG must decode without errors (signature, chunk CRCs, zlib data, sizes). ## How to fix Pass the complete file. Check that the bytes are the format you called (a PNG passed to `Image::jpeg` fails here). ## Example ```rust let img = Image::jpeg(std::fs::read("photo.png")?)?; // invalid-image: PNG bytes given as JPEG ``` ## Data fields - `format`: `Jpeg`, `Png` or `Rgba`. - `reason`: what was wrong (fixed text). --- # invalid-op-log an op log does not parse: an unknown op or field, a bad value, or not JSON - Type: error - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened An op log does not parse: an unknown op or field, a bad value, or not JSON. ## Why Hand-written or corrupted op logs. ## How to fix Validate the log against the op schema; the samples in `fixtures/ops/v1/` show the format. ## Example ```rust doc.apply_log(r#"{ "v": 1, "ops": [{ "op": "rotate" }] }"#)?; // invalid-op-log: "rotate" is not an op (it is "rotatePage"); data.detail has serde's message ``` ## Data fields `detail` (**untrusted**: may quote the log; the message never includes it). --- # invalid-page-label a /PageLabels entry is malformed; the malformed part is dropped - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened A `/PageLabels` entry is malformed; that part is dropped. ## Why A writer bug: a range that is not a dictionary, an unknown `/S` style, a non-string `/P` prefix or an `/St` below 1. ## How to fix Nothing; affected pages fall back to decimal labels. ## Example ```text /PageLabels << /Nums [0 << /S /Fancy >> 4 << /S /r >>] >> ``` `/Fancy` is not a numbering style, so pages 0–3 get decimal labels; the range starting at 4 is kept. `data` is `{ "start": 0 }`. ## Data fields `start`. --- # invalid-page-labels page label ranges that don't start at page 0, don't increase, start past the last page or number from 0 - Type: error - Kind: `usage` (the caller misused the API) ## What happened The ranges given to `Builder::page_labels` can't be written as a `/PageLabels` number tree. ## Why ISO 32000-2 §7.7.2 and §12.4.2: the tree must have an entry for page index 0, its keys are page indexes in increasing order, and a label range's first number (`/St`) is at least 1. A range starting past the document's last page would label nothing. ## How to fix Give the ranges sorted by first page, the first starting at page 0, each start a page of the document, and numbers from 1 (`PageLabel::with_first`). ## Example ```rust builder.page_labels([PageLabel::new(1, Some(LabelStyle::Decimal))]); // invalid-page-labels at finish: no range starts at page 0 ``` ## Data fields - `what`: what is wrong (fixed text). --- # invalid-page-range a page range doesn't fit the grammar, or names a page the document lacks - Type: error - Kind: `usage` (the caller misused the API) ## What happened A page range doesn't fit the grammar, or it names a page the document doesn't have, such as page 9 of a 5-page file. ## Why Ranges count pages from 1, as people do. A range that can't be read, or that reaches past the last page, fails before anything is written, so a command never works on the wrong pages. ## How to fix Write pages as `1-5`, `z` for the last page, `r2` for the second from the end, `x3` to leave page 3 out, and `:odd` or `:even` at the end for every other page of the result. `data.position` is the character where the range went wrong. ## Example ```console $ laminapdf pages rotate scan.pdf +90:1-9 -o out.pdf error[invalid-page-range]: the document has 5 pages = hint: write pages as 1-5, z for the last page, … $ echo $? 2 ``` ## Data fields - `range`: the range given (untrusted: caller text, never in the message). - `position`: the 1-based character of `range` where it went wrong. --- # invalid-rotate a page's /Rotate is not a multiple of 90 and reads as 0 - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened A page's `/Rotate` is not a multiple of 90; it reads as 0. ## Why A writer bug, or a viewer setting stored as an arbitrary angle. ## How to fix Nothing to do if the page displays upright. Otherwise set the rotation you want with the `rotatePage` op. ## Example ```text 3 0 obj << /Type /Page /Rotate 45 … >> ``` `/Rotate` must be a multiple of 90 (ISO 32000-2 §7.7.3.3); the page reads as unrotated. ## Data fields None. --- # invalid-structure-nesting a table or list element sits in a parent its type doesn't allow (a TD outside a TR, a P directly in a Table) - Type: error - Kind: `usage` (the caller misused the API) ## What happened Under PDF/UA-1, a table or list element sits in a parent its type doesn't allow. A table (`Table`) holds rows (`TR`), each holding header cells (`TH`) and data cells (`TD`); a list (`L`) holds list items (`LI`), each holding a label (`Lbl`, its bullet or number) and a body (`LBody`). Examples of a wrong parent: a `TD` or `TH` outside a `TR`, a `TR` outside a `Table`, an `LI` outside an `L`, or a paragraph directly inside a `Table`, `TR`, `L` or `LI`. ## Why ISO 32000-1 Tables 336 and 337 define how tables and lists are built, and assistive technology relies on that shape to announce rows, columns and list items. The PDF/UA-1 standard for accessible PDF (ISO 14289-1 §7.2) requires tables and lists to have that shape. These are conditions 09-004 and 09-005 of the Matterhorn Protocol, the PDF Association's checklist of PDF/UA failures. ## How to fix Nest tables as `Table` > `TR` > `TH` or `TD` (optionally with `THead`, `TBody` and `TFoot` around the rows, and a `Caption`), and lists as `L` > `LI` > `Lbl` and `LBody`. Put paragraphs and other content inside the cells and list bodies. ## Example ```rust s.tag(TagKind::Table, |s| { s.text("Total", &font, Pt::new(11.0), at)?.tag(TagKind::TD); // a TD straight in the Table Ok(()) })?; builder.finish()?; // invalid-structure-nesting: wrap the cell in a TR ``` ## Data fields - `page`: the page's index (0-based). - `kind`: the element's standard type, such as `TD`. - `parent`: its parent's standard type (`Document` at the top level). --- # invalid-structure an object stream, xref section or filter parameter was malformed - Type: error - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened An object stream, xref section or filter parameter was malformed or out of range. ## Why Malformed input: an object stream, xref section or filter parameter out of range. ## How to fix Use another copy of the file. ## Example ```text 12 0 obj << /Type /ObjStm /N 3 /First -4 >> ``` A negative `/First` makes the object stream unusable; `data.structure` says which structure was malformed. ## Data fields `structure`. --- # invalid-text-range a byte range was out of bounds or not on character boundaries of the text - Type: error - Kind: `usage` (the caller misused the API) ## What happened A byte range of a text given to `Shaper::shape_range`, `BidiParagraph::visual_runs` or `BidiParagraph::line_levels`, or the range of a `ShapedRun` given to `pdf_run`, is past the end of the text or does not start and end on character boundaries. ## Why Ranges are byte offsets into UTF-8 text; a range that cuts a character in two (or reaches past the end) names no text. ## How to fix Take ranges from Lamina PDF's own results (`line_breaks`, `itemize`, `break_lines`, `ShapedRun::text_range`), or check them with `str::get` first, and pass the same text the range was computed on. ## Example ```rust shaper.shape_range(&font, "héllo", 0..2, &options)?; // byte 2 is inside 'é' ``` ## Data fields - `start`, `end`: the range (bytes). - `len`: the text's length (bytes). --- # job-finished a job was stepped after it had finished, failed or been cancelled - Type: error - Kind: `state` (the call isn't valid in the object's current state) ## What happened `step` was called on a job that had already returned its result, failed or been cancelled. ## Why A job ends with its first `Step::Done`, or with its first error: an error ends the job. A step loop that keeps going after either reaches this. ## How to fix Stop stepping once a step returns `Done` or an error. To run the work again, create a new job. ## Example ```rust let mut job = doc.save_job(&laminapdf::SaveOptions::new()); while let laminapdf::Step::Progress { .. } = job.step(&mut doc, laminapdf::Budget::UNLIMITED)? {} assert_eq!(job.step(&mut doc, laminapdf::Budget::UNLIMITED).unwrap_err().code(), "job-finished"); ``` ## Data fields None. --- # limit-exceeded a hard Limits value was exceeded - Type: error - Kind: `limit` (a `Limits` value or another resource bound was hit) ## What happened A hard `Limits` value was exceeded. ## Why Hostile or unusually large input. ## How to fix Raise the named limit only if you trust the input. ## Example ```rust let options = OpenOptions::new().with_limits(Limits::new().with_max_nesting_depth(1)); let doc = Document::open_bytes(bytes, options)?; let err = doc.page_count().unwrap_err(); // { "code": "limit-exceeded", "kind": "limit", "data": { "limit": "max_nesting_depth" }, // "location": { "object": 9, "streamOffset": 89 }, … } ``` ## Data fields `limit` (the limit's name). The `location` tells where the limit was hit. The nesting and string-length limits and the read window give `offset`, a file offset; the same limits inside an object stream give `object` with `streamOffset`. A reference chain gives `object` alone, and decoding budgets give no location. --- # link-without-contents a link annotation has no description (/Contents), which PDF/UA-1 requires - Type: error - Kind: `usage` (the caller misused the API) ## What happened Under PDF/UA-1, a link annotation has no description: `Surface::link` or `TextRun::link` was called with `None` (or an empty string) as its contents. ## Why A screen reader announces a link by its `/Contents`, its alternate description. The PDF/UA-1 standard for accessible PDF (ISO 14289-1 §7.18.5) requires one on every link. This is condition 28-012 of the Matterhorn Protocol, the PDF Association's checklist of PDF/UA failures. ## How to fix Pass a short description of where the link goes as the link's contents. ## Example ```rust surface.link(rect, LinkTarget::Uri("https://example.com/pay".into()), None); builder.finish()?; // link-without-contents: pass Some("Pay the invoice online".into()) ``` ## Data fields - `page`: the page's index (0-based). --- # link-without-structure a link annotation has no Link structure element (it was drawn inside an artifact), which PDF/UA-1 forbids - Type: error - Kind: `usage` (the caller misused the API) ## What happened Under PDF/UA-1, a link annotation has no `Link` structure element. The builder gives every link one automatically, except a link drawn inside an artifact, which stays outside the structure. ## Why A screen reader finds links through the structure tree, so a link inside decoration can't be reached. The PDF/UA-1 standard for accessible PDF (ISO 14289-1 §7.18.5) requires each link annotation to be nested in a `Link` element. This is condition 28-011 of the Matterhorn Protocol, the PDF Association's checklist of PDF/UA failures. ## How to fix Draw the link outside `Surface::artifact`, next to the text it belongs to; the builder then wraps it in a `Link` element. ## Example ```rust surface.artifact(ArtifactKind::Layout, |s| { s.link(rect, LinkTarget::Uri("https://example.com".into()), Some("Example".into())); Ok(()) })?; builder.finish()?; // link-without-structure ``` ## Data fields - `page`: the page's index (0-based). - `count`: how many links on that page lack a `Link` element. --- # missing-alt-text a Figure or Formula has no alternate text (/Alt or /ActualText), which PDF/UA-1 requires - Type: error - Kind: `usage` (the caller misused the API) ## What happened Under PDF/UA-1, a page has a `Figure` or `Formula` element without alternate text: neither `/Alt` (`Tag::with_alt`) nor `/ActualText` (`Tag::with_actual_text`). ## Why A screen reader can't describe a picture or a formula by itself. The PDF/UA-1 standard for accessible PDF (ISO 14289-1 §7.3) requires every `Figure` and every `Formula` to carry a text alternative. These are conditions 13-004 and 17-002 of the Matterhorn Protocol, the PDF Association's checklist of PDF/UA failures. Lamina PDF checks only that a description exists; whether it describes the figure well is for a person to judge. ## How to fix Describe the figure with `Surface::figure(&image, rect, alt)` or `Tag::new(TagKind::Figure).with_alt(…)`. If the picture is decoration that carries no meaning, draw it inside `Surface::artifact` instead. ## Example ```rust surface.tag(TagKind::Figure, |s| { s.image(&chart, rect); Ok(()) })?; builder.finish()?; // missing-alt-text: use Tag::new(TagKind::Figure).with_alt("Sales by month") ``` ## Data fields - `page`: the page's index (0-based). --- # missing-glyph the font has no glyph for a character of the text - Type: error - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened Text contained a character the font has no glyph for. Lamina PDF never draws `.notdef` (an empty box) silently. ## Why The font's `cmap` does not map the character (for example a CJK character with a Latin font). ## How to fix Use a font that covers the character (check with `Font::glyph_for`), or split the text across fonts. ## Example ```rust registry.encode(latin_font, "東京")?; // missing-glyph: the font has no CJK glyphs ``` ## Data fields - `ch`: the character (**untrusted**: caller or document text). - `font`: the font's PostScript name (**untrusted**: read from the font). --- # missing-header no %PDF-x.y header in the first 1,024 bytes; parsing went ahead - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened No `%PDF-x.y` header was found in the first 1,024 bytes; parsing went ahead. ## Why Files are often truncated at the front or prefixed with other data. ## How to fix Nothing, if the document reads correctly. Open in strict mode to refuse such files, or strip whatever precedes `%PDF` before opening. ## Example ```text offset 0 From: mailer@example.org …(1,200 bytes of mail headers and a MIME boundary)… offset 1,212 %PDF-1.7 1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj ``` Lamina PDF looks for `%PDF-` in the first 1,024 bytes only. Here it starts at byte 1,212, so `Document::open_bytes` warns `missing-header` and reads the objects anyway. With `OpenOptions::new().with_strict(true)` the same open fails with [`strict`](/reference/codes/strict.md). ## Data fields None. --- # missing-lang PDF/UA-1 needs the document's natural language, and none was set - Type: error - Kind: `usage` (the caller misused the API) ## What happened A PDF/UA-1 document (`Profile::PdfUa1`) reached `Builder::finish` without a natural language: `Builder::lang` was never called, or was given a blank tag. ## Why A screen reader picks its voice and pronunciation from the document's language (`/Lang` in the catalog, ISO 32000-2 §14.9.2). The PDF/UA-1 standard for accessible PDF (ISO 14289-1 §7.2) requires that the language of all text can be determined. These are conditions 11-001 and 11-006 of the Matterhorn Protocol, the PDF Association's checklist of PDF/UA failures. ## How to fix Set the document's language with a BCP 47 tag, the standard language code of the web, such as `en-GB` or `fr`. Text in another language can carry its own tag with `Tag::with_lang`. ## Example ```rust builder.lang("en-GB"); ``` ## Data fields None. --- # missing-root no document catalog (/Root) was found, even after repair - Type: error - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened No document catalog (`/Root`) was found, even after repair. ## Why The file is damaged beyond repair. ## How to fix Use another copy of the file. ## Example ```text trailer << /Size 3 >> % no /Root, and no object with /Type /Catalog in the file ``` Repair found no catalog to use, so `open` fails. ## Data fields None. --- # missing-title PDF/UA-1 needs a document title, and the metadata gives none - Type: error - Kind: `usage` (the caller misused the API) ## What happened A PDF/UA-1 document (`Profile::PdfUa1`) reached `Builder::finish` without a title: `Builder::metadata` was never called, or its `Metadata` has no title, or the title is blank. ## Why A screen reader announces the title first, so a document without one is hard to tell apart from others. The PDF/UA-1 standard for accessible PDF (ISO 14289-1 §7.1) requires a title in the document's metadata. The title is `dc:title` in the XMP packet, the XML metadata stream that the builder writes from `Metadata`. The standard also asks viewers to show the title in the title bar instead of the file name (`/DisplayDocTitle`, which the builder writes for you). This is condition 06-003 of the Matterhorn Protocol, the PDF Association's checklist of PDF/UA failures. ## How to fix Give the document a title that says what it is. ## Example ```rust builder.metadata(Metadata::default().with_title("Quarterly report, Q3 2026")); ``` ## Data fields None. --- # name-tree-node-skipped a name- or number-tree node was malformed, repeated or too deep, and was skipped - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened A name- or number-tree node was malformed, reached twice or too deep, and was skipped. ## Why Damaged trees: a node that is not a dictionary, a kid reached twice (a cycle), or a tree deeper than `max_tree_depth`. ## How to fix Nothing to do; the entries under the skipped node are missing from the tree. ## Example ```text << /Kids [20 0 R 21 0 R] >> % 21 0 obj is (oops), not a dictionary ``` The walk of the `/Dests` name tree skips node 21 and keeps the entries under node 20. ## Data fields None. --- # nested-transaction a transaction was started, or undo or redo called, while one is open - Type: error - Kind: `state` (the call isn't valid in the object's current state) ## What happened A transaction was started, or undo/redo called, while one is open. ## Why Calling code opened a second transaction. ## How to fix Commit or roll back first, or use a savepoint. ## Example ```ts doc.transact((tx) => { doc.undo(); // nested-transaction: finish the transaction first }); ``` ## Data fields None. --- # no-pending-save rebase was called without a save waiting for it - Type: error - Kind: `state` (the call isn't valid in the current state; it may succeed after a state change) ## What happened `rebase` was called, but no save is waiting for it. ## Why Rebasing onto other bytes would not match the undo history, and unsaved changes would be lost. ## How to fix Call `rebase` only after a save that left the document `SavedNotRebased`. ## Example ```rust doc.rebase(bytes)?; // no-pending-save: no save is waiting ``` ## Data fields None. --- # no-transaction apply, commit or rollback was called without an open transaction - Type: error - Kind: `state` (the call isn't valid in the object's current state) ## What happened `apply`, `commit` or `rollback` was called without an open transaction. ## Why Calling code skipped `begin`. ## How to fix Call `begin` first, or use `transact`. ## Example ```rust // laminapdf_ops::Document, without begin(): ops.commit()?; // no-transaction ``` ## Data fields None. --- # not-initialized an @laminapdf/sdk function was called before init() finished loading the WebAssembly module - Type: error - Kind: `state` (the call isn't valid in the object's current state) ## What happened A function or class of `@laminapdf/sdk` (`Document.open`, `new Builder`, …) was called before `init()` had finished loading and instantiating the WebAssembly module. ## Why `@laminapdf/sdk` is synchronous after `init()`, but fetching and compiling the `.wasm` file is asynchronous, so it happens once, up front, in `init()`. Until that promise resolves there is no module to call. ## How to fix Await `init()` once, before the first call; calling it again is cheap (it is idempotent). In a Cloudflare Worker, pass the imported module: `await init({ module: wasm })`. ## Example ```js import { Document, init } from "@laminapdf/sdk"; Document.open(bytes); // throws { code: "not-initialized", … } await init(); Document.open(bytes); // works ``` ## Data fields None. --- # not-pdf the input does not look like a PDF at all - Type: error - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened The input does not look like a PDF at all. ## Why The bytes are empty, or no `n g obj` header appears anywhere in them: an HTML error page saved as `.pdf`, a different format, or the wrong buffer. ## How to fix Check that you passed the right bytes. ## Example ```rust let err = laminapdf::Document::open_bytes(b"…".to_vec(), Default::default()).unwrap_err(); assert_eq!(err.code(), "not-pdf"); ``` ## Data fields None. --- # not-rebased the output was written, but rebasing the document onto it failed - Type: error - Kind: `state` (the call isn't valid in the current state; it may succeed after a state change) ## What happened The save wrote its complete output, but rebasing the document onto it failed. The document is `SavedNotRebased`. ## Why Reading the output back failed (for example a limit, or a source that can't deliver the bytes yet). ## How to fix Retry `rebase` with the output the error carries (`saved.source()`; in the `laminapdf` façade, `Error::saved_output()` and `Document::rebase`). ## Example ```rust match doc.save_with(&options, &cancel) { Err(e) if e.code() == "not-rebased" => doc.rebase(e.saved_output().unwrap())?, other => { other?; } } ``` ## Data fields - `error`: the rebase's own error (its code, kind and location). --- # object-number-out-of-range an xref entry above max_object_number was dropped - Type: warning - Kind: `limit` (a `Limits` value or another resource bound was hit) ## What happened An xref entry above `Limits::max_object_number` was dropped. ## Why Corrupt or hostile xref data. ## How to fix Raise `max_object_number` only for trusted input. ## Example ```text xref 0 1 0000000000 65535 f 9999999 1 0000000015 00000 n ``` Object 9,999,999 is above `Limits::max_object_number`; its entry is dropped so a hostile xref can't make Lamina PDF allocate for it. References to it resolve to `null`. ## Data fields None. --- # objstm-index-mismatch an object stream's index was stale and the object was found by searching - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened An object stream's index was stale; the object was found by searching. ## Why Object streams rewritten by a tool that did not update the index at their start. ## How to fix Nothing. ## Example ```text 12 0 obj << /Type /ObjStm /N 3 /First 18 >> stream 20 0 21 14 22 31 … ``` The index says object 21 starts at offset 14 of the stream, but it starts elsewhere. Lamina PDF searches the stream for it, uses what it finds and raises the warning. ## Data fields None. --- # out-of-memory the WebAssembly module ran out of memory (its maximum, or what the engine could give), so its instance was discarded and the next call gets a fresh one - Type: error - Kind: `limit` (a `Limits` value or another resource bound was hit) ## What happened The WebAssembly module needed more memory than it could get, so it stopped in the middle of the call. Each module may grow to at most 2 GiB, and a browser tab or server can run out before that. `@laminapdf/sdk` discarded the module's instance, so the next call runs in a fresh one. Every document, page and builder from the old instance now throws `instance-reset`. ## Why A module's memory grows as the module needs more and never shrinks. Many open documents, a huge image, or a file built to exhaust memory can each push it to its limit. The open limits stop most hostile files before memory runs out, and the maximum catches the rest. ## How to fix Open the document again, then keep less in memory at once. Free each document and builder as soon as you're done with it, or use `using`. For files from people you don't trust, lower the open limits, such as `maxStreamDecoded` and `maxImagePixels`, so they fail with `limit-exceeded` first. An app can also open each document in its own Web Worker, with `@laminapdf/sdk/worker`, and end the Web Worker afterwards: that returns all of its memory. ## Example ```ts try { doc = Document.open(bytes, { limits: { maxStreamDecoded: 64_000_000 } }); } catch (e) { if (isLaminaPdfError(e, "out-of-memory")) showError("This file is too large to open here."); else throw e; } ``` ## Data fields - `module`: the module that ran out: `base`, `text` or `extract`. - `memoryBytes`: the size of its memory when it stopped. --- # outline-cycle an outline item was reached twice; that branch of the walk stopped - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened An outline item was reached twice; that branch stopped. ## Why Damaged or hostile outlines. ## How to fix Nothing; the outline is shown without the loop. ## Example ```text 11 0 obj << /Title (Chapter 1) /Parent 10 0 R /Next 11 0 R >> ``` Item 11 is its own `/Next`; the outline walk stops that branch the second time it reaches it. ## Data fields None. --- # outline-item-skipped an outline item was missing, not a dictionary or too deep, and was skipped with its subtree - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened An outline item was missing, not a dictionary or too deep; it and its subtree were skipped. ## Why Damaged outlines. ## How to fix Nothing to do; the skipped item and its children are missing from the outline. ## Example ```text 10 0 obj << /Type /Outlines /First 11 0 R >> % object 11 is missing ``` The first item can't be read, so it and everything under it are left out of `outline()`. ## Data fields None. --- # output-unwritable the output couldn't be written: its folder is missing or read-only, the disk is full, or standard output closed - Type: error - Kind: `io` (the host failed to supply or take bytes) ## What happened The output couldn't be written: its folder is missing or read-only, the disk is full, or standard output was closed before the command finished. ## Why The system refused the write, or standard output closed while the command was writing to it. The CLI writes to a temporary file beside the output and renames it into place, so a failed write leaves any old file as it was. ## How to fix Check that the output's folder exists, that you can write to it and that the disk has room. When piping, make sure the reading command doesn't exit early. ## Example ```console $ laminapdf pages rotate scan.pdf +90 -o /no/such/folder/out.pdf error[output-unwritable]: couldn't write the output = hint: check that the output's folder exists, … $ echo $? 1 ``` ## Data fields - `output`: the file that couldn't be written, or `-` for standard output (untrusted: caller text, never in the message). - `reason`: the system's error kind, such as `NotFound` or `StorageFull`. --- # overwrite-refused the output file exists, or the command would modify its input in place, and --force wasn't given - Type: error - Kind: `state` (the call isn't valid in the object's current state) ## What happened The output file already exists, or the command was asked to change its input in place with `--in-place`, and `--force` wasn't given. ## Why The CLI never replaces a file without being told to, so a mistyped `-o` can't destroy a document. It never prompts either, because scripts and agents can't answer a prompt. Exit code 4 tells a script the command needs `--force`. ## How to fix Pass `--force` to replace the file, or write to a new file with `-o`. The CLI writes to a temporary file and renames it, so a replaced file is never left half written. ## Example ```console $ laminapdf pages rotate scan.pdf 90:z --in-place error[overwrite-refused]: refusing to replace a file without --force = hint: pass --force, or write to a new file with -o $ echo $? 4 ``` ## Data fields - `output`: the file that would have been replaced (untrusted: caller text, never in the message). --- # page-count-mismatch the page tree's /Count differs from the number of pages walked - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened The page tree's `/Count` differs from the number of pages walked; the walk wins. ## Why Writers that don't update `/Count`. ## How to fix Nothing; Lamina PDF uses the walked count. ## Example ```text 2 0 obj << /Type /Pages /Kids [3 0 R 4 0 R] /Count 7 >> ``` The root says 7 pages but two are reachable: `page_count()` is 2, and the warning's `data` is `{ "declared": 7, "walked": 2 }`. ## Data fields `declared`, `walked`. --- # page-index-out-of-range a link, outline item or destination names a page index the document doesn't have - Type: error - Kind: `usage` (the caller misused the API) ## What happened A link (`LinkTarget::Page`), an outline item or a named destination (`Builder::destination`) names a page index the document doesn't have when the build finishes. ## Why Page targets are resolved when `Builder::finish` (or `Appender::finish`) runs, so a link can name a page added after its own; by then every index must be one of the document's pages. Indexes are 0-based. ## How to fix Use an index below the document's page count, or add the page before finishing. ## Example ```rust surface.link(rect, LinkTarget::Page { index: 5, fit: Fit::Page }, None); builder.finish()?; // page-index-out-of-range: the document has 2 pages ``` ## Data fields - `index`: the 0-based index given. - `count`: the number of pages. --- # page-tree-node-skipped a page-tree node was missing, malformed or too deep, and was skipped - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened A page-tree node was missing, unparsable, of the wrong type or too deep, and was skipped. ## Why Damaged page trees: a kid that is missing, unparsable or of the wrong type, a `/Kids` that is not an array, or a tree deeper than `max_page_tree_depth`. ## How to fix Check the page count; pages under the skipped node are missing. ## Example ```text 2 0 obj << /Type /Pages /Kids [3 0 R 9 0 R] /Count 2 >> 9 0 obj (not a page) endobj ``` Kid 9 is a string, not a page or a pages node: it is skipped with this warning, so the document has one page. The `location` is the skipped node's `object` when it has one. ## Data fields None. --- # password-required the document is encrypted with a user password and none was given - Type: error - Kind: `security` (encryption, permissions or signatures block the call, or look tampered with) ## What happened The document is encrypted with a non-empty user password and none was given. ## Why Encrypted input. ## How to fix Ask the user for the password and pass it in `OpenOptions` (`{ password }` in TS). ## Example ```ts try { Document.open(bytes); } catch (e) { if (isLaminaPdfError(e, "password-required")) Document.open(bytes, { password: await ask() }); } ``` ## Data fields None. --- # path-outside-roots laminapdf mcp was given a path outside its --root folders, or a link that leads out of them - Type: error - Kind: `security` (encryption, permissions or signatures block the call) ## What happened A tool of `laminapdf mcp` was given a path the server may not use: one outside every `--root` folder it was started with, one that climbs out with `..`, or a symbolic link inside a root that leads out of it. A missing file outside the roots, and a link that leads nowhere, get this answer too, so a call can't tell whether a file outside the roots exists. ## Why An MCP server acts for a model that may have read instructions planted in a document or a web page. So the server touches only the folders named on its command line by the person who started it. It resolves every path, links included, before it checks the path, so a link can't lead it elsewhere. ## How to fix Use a path inside one of the server's `--root` folders; a relative path is read from the first root. To work on files elsewhere, start the server with another `--root`. ## Example ```json {"schema":"laminapdf.error/1","error":{"code":"path-outside-roots","kind":"security","message":"the path is outside the server's --root folders","data":{"path":"../secret.pdf"},"exitCode":1,…}} ``` ## Data fields - `path`: the path the tool was given (untrusted: caller text, never in the message). --- # perms-mismatch an R5/R6 document's /Perms does not match its /P and /EncryptMetadata - Type: warning - Kind: `security` (encryption, permissions or signatures block the call, or look tampered with) ## What happened An R5/R6 document's `/Perms` does not match its `/P` and `/EncryptMetadata`. ## Why `/P` may have been tampered with (ISO 32000-2 Algorithm 13). ## How to fix Treat the permissions as untrusted. ## Example ```text /Encrypt << /V 5 /R 6 /P -3904 /Perms <…> … >> % /Perms decrypts to a different /P ``` The document opens, but `Document::permissions()` comes from a `/P` that the encrypted `/Perms` contradicts. ## Data fields None. --- # profile-version-conflict the PDF version asked for is one the profile doesn't allow (PDF/UA-1, PDF/A-2 and PDF/A-3 are based on PDF 1.7) - Type: error - Kind: `usage` (the caller misused the API) ## What happened `BuildOptions::with_version(PdfVersion::V2_0)` was combined with a conformance profile (`BuildOptions::with_profile`) that is built on PDF 1.7. `Builder::finish` wrote nothing. ## Why The PDF/UA-1 standard for accessible PDF (ISO 14289-1) and the PDF/A-2 and PDF/A-3 archival standards (ISO 19005-2 and -3) build their rules on ISO 32000-1, which is PDF 1.7. A file that says it is PDF 2.0 can't conform to them. Validators such as veraPDF, the open-source PDF/A and PDF/UA validator, check the header first. ## How to fix Leave the version at its default, 1.7 (or choose a lower one: the builder raises it to what the content needs). PDF 2.0 has its own profiles, PDF/A-4 and PDF/UA-2, which Lamina PDF doesn't write yet. ## Example ```rust let options = BuildOptions::default() .with_version(PdfVersion::V2_0) .with_profile(Profile::PdfA2b); Builder::new(options).finish()?; // profile-version-conflict ``` ## Data fields - `profile`: the profile, such as `PDF/A-2b`. --- # reference-cycle a reference chain or the page tree loops back on itself - Type: error - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened A reference chain or the page tree loops back on itself. ## Why Damaged or hostile files. ## How to fix Use another copy of the file. ## Example ```text 5 0 obj 6 0 R endobj 6 0 obj 5 0 R endobj ``` Resolving object 5 leads back to itself; the call fails instead of looping. A page tree whose `/Kids` lead back to an ancestor fails the same way. ## Data fields None. --- # render-backend-unavailable the render engine is not available: its library or module could not be loaded - Type: error - Kind: `io` (reading or writing outside Lamina PDF failed) ## What happened The PDFium backend (`laminapdf-render-pdfium`) could not load the PDFium library, so nothing can be rendered with it. ## Why PDFium is bound at runtime, never linked at build time: the library has to be present on the machine. Lamina PDF looks for it only in the folder you name. ## How to fix Ship the PDFium library (`libpdfium.dylib`, `libpdfium.so` or `pdfium.dll`) with your app and pass its folder to `PdfiumBackend::bind(dir)`, or set `LAMINA_PDF_PDFIUM_DIR` to that folder for `PdfiumBackend::from_env()`. ## Example ```rust let backend = PdfiumBackend::from_env()?; // render-backend-unavailable when the library isn't there ``` ## Data fields - `backend`: `pdfium`. --- # render-failed the render engine could not open the document or draw the page - Type: error - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened The render engine (PDFium or hayro, `laminapdf-render`) could not open the document, or could not draw the page it was asked for. ## Why The engines have their own parsers. A file that Lamina PDF repairs may still be too damaged for an engine, or use a feature it can't draw. An engine failure (for example a panic inside hayro) is reported this way too, never as a crash. ## How to fix Check the document's warnings: a damaged file is usually reported there too. Try the other backend from the debug menu. If neither can draw the page, report the file with the backend name and version. ## Example ```rust let doc = HayroBackend::new().open(Arc::new(bytes), &RenderOpenOptions::new())?; // render-failed for a file hayro can't parse ``` ## Data fields - `backend`: `pdfium` or `hayro`. - `stage`: `open` or `render`. --- # render-too-large the requested tile is larger than the render limits allow; nothing was allocated - Type: error - Kind: `limit` (a configured limit was exceeded) ## What happened A render backend (`laminapdf-render`) refused a request that is too large. The tile has more than `max_render_pixels` pixels or more than `max_render_side` pixels on a side (the document's `RenderLimits`), or more than 65,535 pixels on a side for hayro. Or, for hayro, which reads the whole file, the input is longer than `max_input_bytes`. ## Why Page sizes and zoom levels come from files and users, so a render could otherwise ask for a bitmap of any size. The limits are checked before anything is allocated. The defaults are 16,777,216 pixels per tile (4096 × 4096, iOS Safari's per-canvas cap), 16,384 pixels per side and 1 GiB of input. ## How to fix At high zoom, render the visible part of the page as tiles of 256 to 512 pixels (`RenderRequest::with_clip`) instead of the whole page. Raise the limits with `RenderOpenOptions::with_limits` only when the larger bitmap is intended. ## Example ```rust let req = RenderRequest::page(0, 1000.0); // a letter page at 1000×: 612,000 × 792,000 pixels doc.render(&req, &mut out, &CancelToken::new())?; // render-too-large ``` ## Data fields - `width`, `height`: the tile's size (tile limits). - `limit`: the limit exceeded. - `unit`: `pixels`, `pixels per side` or `pixels per side (hayro)` (tile limits). - `len`: the input's length in bytes (the input limit, with `limit`). --- # repaired-base an incremental update was requested for a document whose xref needed repair - Type: error - Kind: `state` (the call isn't valid in the object's current state) ## What happened An incremental update was requested for a document whose xref needed repair. ## Why Appending to a repaired file would build on offsets that are wrong. ## How to fix Save with a full rewrite instead. ## Example ```rust let doc = Document::open_bytes(bytes_needing_repair, OpenOptions::new())?; // warns xref-repaired let err = doc.save(&SaveOptions::default().with_mode(SaveMode::Incremental)); // repaired-base; SaveMode::Auto (the default) writes a full rewrite instead ``` ## Data fields None. --- # requires-rewrite an incremental save was requested but only a full rewrite is safe - Type: error (`laminapdf_save::SaveError::RequiresRewrite`) - Kind: `state` (the call isn't valid in the document's current state) ## What happened An incremental save was requested, but only a full rewrite is safe for this document. Nothing was written. ## Why The save planner found one of four reasons the file must be rewritten: - a repaired base, whose cross-reference data an appended revision can't build on; - an encrypted base being saved without its encryption; - a document built from scratch, with no base bytes at all; - a pending op, such as a redaction, that removes content an earlier revision would keep. ## How to fix Save with `SaveMode::Auto` (it resolves to a full rewrite) or `SaveMode::FullRewrite`. If the document is also signed, that needs the explicit override ([`would-invalidate-signatures`](/reference/codes/would-invalidate-signatures.md)). ## Example ```rust let err = doc.save(&mut out, &SaveOptions::new().with_mode(SaveMode::Incremental)).unwrap_err(); assert_eq!(err.code(), "requires-rewrite"); ``` ## Data fields - `reasons`: the rewrite reasons (`RepairedBase`, `EncryptedBase`, `NoBase`, `OpRequiresRewrite`). --- # saved-not-rebased the document was saved to a sink Lamina PDF cannot read back and waits for rebase - Type: error - Kind: `state` (the call isn't valid in the current state; it may succeed after a state change) ## What happened The document was saved to a sink Lamina PDF can't read back, and it waits for `rebase`: ops, undo, redo and saves are refused until then. ## Why `save_full` to a write-only sink leaves the document `SavedNotRebased`: the bytes it would continue from are not available to it. ## How to fix Call `rebase` with the saved bytes, then retry. ## Example ```rust ops.save(&mut write_only_sink, &options)?; // full rewrite; the document waits for rebase ops.apply(op); // saved-not-rebased ops.rebase(saved_bytes)?; // then edits work again ``` ## Data fields None. --- # sink-not-empty the sink already held bytes before the save started - Type: error - Kind: `usage` (the caller misused the API) ## What happened The `WriteSink` already held bytes when the save started, so the writer refused before writing anything. ## Why Output offsets (the xref table, `startxref`) count from the sink's first byte, and a sink only appends. An `AppendedOnly` incremental save also accepts a sink that holds exactly the base. ## How to fix Pass an empty sink. The document is unchanged. ## Example ```rust let mut sink = b"%PDF-".to_vec(); ops.save(&mut sink, &options)?; // sink-not-empty, data.held = 5 ``` ## Data fields - `held`: the bytes the sink held. --- # sink the sink failed to take the bytes or could not give them back - Type: error - Kind: `io` (the host failed to supply or take bytes) ## What happened The `WriteSink` failed to take the bytes, or could not give them back. (A sink that already held bytes is [`sink-not-empty`](/reference/codes/sink-not-empty.md).) ## Why The destination failed: a full disk, a closed upload, a refused read-back. ## How to fix Check the destination and retry the save. The document is unchanged; the sink may hold a prefix of the output. ## Example An upload sink whose connection closed halfway through: the save fails with `sink`, the document is unchanged, and `data.kind` is the I/O error kind (`BrokenPipe`). ## Data fields - `kind`: the I/O error kind, for a writer that failed. - `message`: the sink's or writer's own text (**untrusted**: it comes from the host and may name a path; never in the message). --- # source-budget-exceeded a sparse source was given more bytes than its byte budget allows - Type: error - Kind: `limit` (a `Limits` value or another resource bound was hit) ## What happened A sparse source (`WasmSparseSource` in `@laminapdf/wasm`, or a `@laminapdf/sdk` range document) was handed bytes that would make it hold more than the byte budget it was created with. The bytes were not added. ## Why A range source keeps every byte it has fetched: the document may already have read them, so nothing is evicted. The budget bounds how much of a remote or lazily read file one document may hold in memory. ## How to fix Create the source with a larger budget, or fetch less per round: smaller blocks and no readahead. A file that must be read whole (a full rewrite, or a repaired file with no usable xref) needs a budget of at least its size. ## Example ```js const source = new WasmSparseSource(10_000_000, 1_000_000); source.supply(0, new Uint8Array(2_000_000)); // throws { code: "source-budget-exceeded", … } ``` ## Data fields - `argument`: `bytes`. - `detail`: how many bytes the source would hold and its budget (Lamina PDF's own numbers). --- # source-changed a remote file changed while it was being read: its ETag or length differs from the first response - Type: error - Kind: `io` (the host failed to supply bytes; often transient) ## What happened A remote file changed while `@laminapdf/sdk` was reading it range by range: a response's `ETag` or total length differs from the first one (or the server ignored `If-Range` and sent a different file). ## Why A range document is assembled from many responses. If the file changed between them, the parts no longer belong to one file, and the document would read a mix of two versions. `httpRangeSource` sends `If-Range` with the first `ETag` and compares each response's `ETag` and `Content-Range` length with the first. ## How to fix Open the file again (a new source, a new document). If the file changes often, download it whole with `Document.open(bytes)` instead. ## Example ```js const doc = await Document.openRange(httpRangeSource(url)); await doc.ensure(() => doc.pages); // throws { code: "source-changed", … } if the file was replaced meanwhile ``` ## Data fields None. --- # source the ByteSource failed to read - Type: error - Kind: `io` (the host failed to supply bytes; often transient) ## What happened The `ByteSource` failed to read. ## Why A file-system or network error in the host. ## How to fix Retry, or check the source. ## Example A `ByteSource` reading from a network answered with an error for bytes 4096..+65536; the call fails with `source` and the same call can be retried once the connection is back. ## Data fields None. --- # stale-generation a reference's generation did not match its xref entry, so it resolved to null - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened A reference's generation number did not match its xref entry, so it resolved to `null` (ISO 32000-2 §7.3.10). ## Why An editor deleted and reused an object number (bumping its generation) but left a reference with the old generation behind. ## How to fix Nothing; the reference is treated as missing. ## Example ```text /Contents 7 1 R % but the xref entry for object 7 has generation 0 ``` The reference resolves to `null` (ISO 32000-2 §7.3.10), as if object 7 were missing, and the warning carries `generation` 1. ## Data fields `generation`. --- # stream-length-recovered a stream's /Length was wrong, so its end was found by scanning for endstream - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened A stream's `/Length` was wrong or unresolvable; its end was found by scanning for `endstream`. ## Why Writers that edit streams without updating `/Length`. ## How to fix Nothing; re-saving writes the correct length. ## Example ```text 4 0 obj << /Length 10 >> stream BT /F1 12 Tf 72 720 Td (Hello) Tj ET endstream ``` `/Length` says 10 bytes but the data is longer. Lamina PDF finds `endstream`, uses the real length and raises the warning with the stream's `object`. ## Data fields None. --- # strict strict mode refused something the tolerant reader would have repaired - Type: error - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened Strict mode refused something the tolerant reader would have repaired. ## Why The file needs repair; see the warning code in the data. ## How to fix Open without strict mode, or fix the file. ## Example ```rust let options = OpenOptions::new().with_strict(true); let err = Document::open_bytes(bytes_with_a_broken_xref, options).unwrap_err(); // { "code": "strict", "data": { "warning": "xref-repaired" }, … } ``` ## Data fields `warning` (the warning code). --- # subset-failed subsetting a font failed - Type: error - Kind: `unsupported` (valid input that Lamina PDF doesn't implement) ## What happened Subsetting a font at `FontRegistry::finish_fonts` failed. ## Why The `subsetter` crate could not rebuild the font program: a font feature it does not support, or a table it could not parse. ## How to fix Try another version or format of the font (for example TrueType instead of CFF), and report the font if it is a common one. ## Example A font whose tables `subsetter` can't rebuild makes `FontRegistry::finish_fonts` (and so `Builder::finish`) fail with `subset-failed`; `data.font` is the font's PostScript name. ## Data fields - `font`: the font's PostScript name (**untrusted**: read from the font). - `reason`: the subsetter's failure class (fixed text). --- # syntax a token or structure did not match the PDF grammar - Type: error - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened A token or structure did not match the PDF grammar. ## Why Malformed input. ## How to fix Use another copy of the file. ## Example ```text 1 0 obj << /Type /Catalog /Pages 2 0 R >> >> endobj ``` A stray `>>` where an object should end: parsing that object fails with `syntax` at its offset. ## Data fields None. The `location` is a file `offset`, or `object` with `streamOffset` inside an object stream. --- # text-module-not-loaded a font or text call was made on a builder of the base module, which has no fonts or text - Type: error - Kind: `state` (the call isn't valid in the object's current state) ## What happened A font or text method (`Font` use, `surface.text`, `surface.textBox`, `builder.font`) was called on a `Builder` created while only the base WebAssembly module was loaded, or `new Builder({ text: true })` was called before the text module was loaded. ## Why `@laminapdf/sdk` ships two modules: the base module (open, edit, save, vector and image pages) and the larger text module (all of the base plus fonts, shaping and text layout). A document that draws text is built entirely in the text module, and loading it is asynchronous, so it can't happen inside a synchronous drawing call. A builder created without the text module can't gain it later. ## How to fix Load the text module before creating the builder that draws text: `await init({ text: true })`, or `await loadText()` after `init()`. Builders created after that use the text module. ## Example ```js await init(); const b = new Builder(); b.page("A4").surface().text("Hi", { font: Font.standard("Helvetica"), size: 12, at: [72, 72] }); // throws { code: "text-module-not-loaded", … } await loadText(); // then create a new Builder ``` ## Data fields None. --- # text-overflow a text box's lines don't all fit its height and its options asked for an error - Type: error - Kind: `usage` (the caller misused the API) ## What happened `Surface::text_box` laid its text out and not every line fit the box (its height, or a single grapheme cluster wider than the box), and its `TextBoxOptions` asked for `Overflow::Error`. Nothing was drawn. ## Why A text box draws only lines that fit inside it. With `Overflow::Error` the caller asked to be told instead of having the text cut off. ## How to fix Make the box taller or wider, the font smaller or the text shorter; or use `Overflow::Clip` (the default), which draws the lines that fit and reports where the rest starts in `TextLayout::overflow`. ## Example ```rust let options = TextBoxOptions::default().with_overflow(Overflow::Error); surface.text_box(long_text, &font, Pt::new(12.0), small_rect, &options)?; // text-overflow ``` ## Data fields - `fits`: the number of lines that fit. - `needed`: the number of lines the text needs. --- # tounicode-conflict one glyph was used for two different texts; ToUnicode keeps the first - Type: warning - Kind: `usage` (the caller misused the API) ## What happened One glyph was used for two different texts; the ToUnicode CMap maps it to the first. ## Why A ToUnicode CMap maps each code to one string (ISO 32000-2 §9.10.3), for example when one space glyph stands for both U+0020 and U+00A0. ## How to fix Mark the second occurrence with `/ActualText` (§14.9.4) so extraction gets its text; Lamina PDF's builder does this for you. ## Example A CFF font in which one space glyph is used for both U+0020 and U+00A0: the ToUnicode CMap maps it to U+0020 (the first use), and the warning names the glyph (`gid`) and the second text. ## Data fields - `font`: the font's PostScript name (**untrusted**: read from the font). - `gid`: the glyph, in the original font's numbering. - `kept`, `dropped`: the two texts (**untrusted**: caller text). --- # type an object had a different type than the structure requires - Type: error - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened An object had a different type than the structure requires. ## Why Malformed input: an object of the wrong type where the structure requires a specific one. ## How to fix Use another copy of the file. ## Example ```text 2 0 obj << /Type /Pages /Kids 5 >> ``` `/Kids` must be an array; reading the page tree fails with `type`, and `data` names what was expected and where (`expected`, `context`, from Lamina PDF's own vocabulary). ## Data fields `expected`, `context` (from Lamina PDF's fixed list). --- # unexpected-eof the input ended before a complete token or object was read - Type: error - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened The input ended before a complete token or object was read. ## Why Truncated files. ## How to fix Use a complete copy of the file. ## Example ```text 1 0 obj << /Type /Catalog /Pages 2 0 R ``` The file ends inside a dictionary; the `location` is the file offset where reading stopped. ## Data fields None. The `location` is a file `offset`, or `object` with `streamOffset` inside an object stream. --- # unknown-axis an instance was asked for on a variation axis the font does not have - Type: error - Kind: `usage` (the caller misused the API) ## What happened `Font::instance` was given a variation axis the font does not have. ## Why A static font has no axes; a variable font has the axes listed in `FontInfo::axes` (`fvar`). ## How to fix Check `font.info().axes` before choosing coordinates, or use a variable font. ## Example ```rust let bold = static_font.instance(&[(OpenTypeTag::WEIGHT, 700.0)])?; // unknown-axis: no wght axis ``` ## Data fields - `font`: the font's PostScript name (**untrusted**: read from the font). - `tag`: the axis tag that was asked for. --- # unknown-code explain was given a code that isn't a Lamina PDF error or warning code - Type: error - Kind: `usage` (the caller misused the API) ## What happened `laminapdf explain` was given a code that isn't one of Lamina PDF's error or warning codes. ## Why Every code Lamina PDF reports is listed once, with its own page. A code that isn't listed has no explanation to print. ## How to fix Check the spelling: `didYouMean` lists the known codes closest to the one given. The tables in this folder's README list every code. ## Example ```console $ laminapdf explain xref-repaird error[unknown-code]: not a Lamina PDF code = did you mean: xref-repaired $ echo $? 2 ``` ## Data fields - `code`: the code given (untrusted: caller text, never in the message). --- # unknown-destination a link or outline item names a destination that was never defined - Type: error - Kind: `usage` (the caller misused the API) ## What happened A link or outline item of a new document targets `LinkTarget::Named(name)`, but `Builder::destination` never defined that name. ## Why A named destination is looked up in the document's `/Names /Dests` tree (ISO 32000-2 §12.3.2.4); a name that isn't there goes nowhere in every viewer. ## How to fix Define the name with `Builder::destination(name, page_index, fit)` before `Builder::finish`, or link to the page directly with `LinkTarget::Page`. ## Example ```rust surface.link(rect, LinkTarget::Named("appendix".into()), None); builder.finish()?; // unknown-destination: call builder.destination("appendix", …) first ``` ## Data fields - `name`: the name as given (untrusted: caller text, never in the message). --- # unknown-font a FontKey was used with a registry that did not hand it out - Type: error - Kind: `usage` (the caller misused the API) ## What happened A `FontKey` was used with a `FontRegistry` that did not hand it out. ## Why Keys are indices into one registry; each document has its own. ## How to fix Use the key returned by this registry's `add`. ## Example ```rust let key = registry_a.add(font, EmbedPolicy::Respect)?; registry_b.encode(key, "Hi")?; // unknown-font: key came from registry_a ``` ## Data fields - `key`: the key's index. --- # unknown-option an options record passed to a binding has a key Lamina PDF does not know - Type: error - Kind: `usage` (the caller misused the API) ## What happened An options record passed to `@laminapdf/wasm` (`OpenOptionsInit`, `SaveOptionsInit`, `BuildOptionsInit`, …) has a key Lamina PDF doesn't know. ## Why Records refuse unknown keys instead of ignoring them, so a misspelt option fails loudly rather than silently doing nothing. ## How to fix Remove the key or correct its spelling. `didYouMean` lists the known keys within two edits of it. ## Example ```js WasmDocument.openBytes(bytes, { pasword: "secret" }); // throws { code: "unknown-option", didYouMean: ["password"], … } ``` ## Data fields - `argument`: the parameter whose record had the key (`options`, `metadata`, …). - `option`: the key given (untrusted: caller text, never in the message). --- # unknown-page an op names a page the document does not have - Type: error - Kind: `usage` (the caller misused the API) ## What happened An op names a page the document doesn't have. ## Why The page ID is stale or from another document. ## How to fix Use a `PageId` from this document. ## Example ```rust let other = Document::open_bytes(other_bytes, OpenOptions::new())?; let id = other.page_info(0)?.id; doc.apply(vec![Op::from(RotatePage::new(id, Rotation::Clockwise90))]); // unknown-page ``` ## Data fields None. --- # unmapped-glyph a font shows glyphs that nothing maps to text - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened A font showed glyphs that its ToUnicode CMap, its encoding's glyph names and its font program's `cmap` all leave unmapped. They extract as U+FFFD; `PageText::unmapped` counts them. ## Why The file has no ToUnicode CMap for a subset or symbolic font, uses arbitrary glyph names (`g37`), or obfuscates its encoding (ISO 32000-2 §9.10.2: there is then no way to know what the glyph means). ## How to fix Nothing to do in Lamina PDF. A producer that writes ToUnicode CMaps (Lamina PDF does) avoids it; OCR is the only way to recover such text. ## Example ```text << /Subtype /Type0 /BaseFont /ABCDEF+Subset /Encoding /Identity-H >> % no /ToUnicode ``` A subset font with Identity-H and no ToUnicode: unless its embedded `cmap` maps the glyphs, they extract as U+FFFD and the warning carries the font's `object`. ## Data fields None. The `location` is `object`, the font dictionary's object; none for a direct font dictionary. --- # unnumbered-heading-misused an unnumbered H heading shares its parent with another H, or the document also uses H1-H6, which PDF/UA-1 forbids - Type: error - Kind: `usage` (the caller misused the API) ## What happened Under PDF/UA-1, the document misuses the unnumbered heading `H`, a heading whose level comes from the section it sits in. Numbered headings are `H1`, `H2` and so on. Either two `H` elements share one parent, or the document uses `H` and numbered headings together. ## Why A screen reader works out an `H` heading's level from the sections around it. A section therefore has one `H` at most, and a document that mixes the two kinds gives no consistent outline. The PDF/UA-1 standard for accessible PDF (ISO 14289-1 §7.4.4) requires one `H` per section and only one kind of heading per document. These are conditions 14-006 and 14-007 of the Matterhorn Protocol, the PDF Association's checklist of PDF/UA failures. ## How to fix Use numbered headings (`TagKind::H1` to `TagKind::H6`) throughout; most documents need nothing else. If you use `H` (`Tag::custom("H", …)`), give each section (`Sect`) its own and leave the numbered ones out. ## Example ```rust s.text("Results", &font, Pt::new(18.0), at)?.tag(TagKind::H1); s.text("Method", &font, Pt::new(14.0), at2)?.tag(Tag::custom("H", TagKind::H2)); builder.finish()?; // unnumbered-heading-misused: use TagKind::H2 for the second heading ``` ## Data fields - `page`: the page's index (0-based). --- # unset-object a writer object number was reserved but never given a value - Type: error - Kind: `internal` (a bug in Lamina PDF or a broken invariant) ## What happened A writer object number was reserved but never given a value. ## Why A bug in Lamina PDF. ## How to fix Report it at https://github.com/laudebugs/laminapdf/issues with the steps (and, if you can share it, the file) that led to it. ## Example Raised by a writer when an object number reserved during a save never received its object. No input should cause it. ## Data fields None. --- # unsupported-filter a stream uses a filter Lamina PDF does not implement - Type: error - Kind: `unsupported` (valid input that Lamina PDF doesn't implement) ## What happened A stream uses a filter Lamina PDF doesn't implement; only decoding that stream fails. ## Why Rare or proprietary filters. ## How to fix Use a tool that supports the filter. ## Example ```text 5 0 obj << /Filter /JBIG2Decode /Length 2048 >> stream … ``` Decoding this stream fails; the rest of the document reads normally. `data.filter` is the filter's name as read from the file. ## Data fields `filter` (**untrusted**: read from the file). The message never includes it. --- # unsupported-image the image uses a feature that PDF or Lamina PDF does not support - Type: error - Kind: `unsupported` (valid input that Lamina PDF doesn't implement) ## What happened The image is valid but uses a feature the builder can't embed. ## Why PDF's `DCTDecode` filter (ISO 32000-2 §7.4.8) takes baseline and progressive JPEG with 8-bit samples; lossless, hierarchical and arithmetic-coded JPEG and 12-bit samples are refused, as are JPEGs with 2 or more than 4 components. ## How to fix Re-encode the image as a baseline or progressive 8-bit JPEG, or as PNG. ## Example ```rust let img = Image::jpeg(lossless_jpeg_bytes)?; // unsupported-image: data.feature says which ``` ## Data fields - `format`: the image's format. - `feature`: the feature that is not supported (fixed text). --- # unsupported-layout-option a layout option that is not implemented yet was asked for - Type: error - Kind: `unsupported` (valid input that Lamina PDF doesn't implement) ## What happened `break_lines` was given a layout option Lamina PDF does not implement yet: `LayoutOptions::justify` set to `true`. ## Why Justification (stretching inter-word gaps, kashida) is reserved for a later plan (research 03 §7.6); silently ignoring the option would lay out ragged lines the caller did not ask for. ## How to fix Leave `justify` at `false` and pick an `Alignment` (start, end, left, right or centre). ## Example ```rust let options = LayoutOptions { justify: true, ..LayoutOptions::new(300.0, 12.0) }; break_lines(&shaper, &fonts, text, &options)?; // unsupported-layout-option ``` ## Data fields - `option`: the option's name (fixed text). --- # unsupported-security-handler the document uses a security handler Lamina PDF cannot decrypt - Type: error - Kind: `unsupported` (valid input that Lamina PDF doesn't implement) ## What happened The document uses a security handler Lamina PDF can't decrypt. ## Why Not `/Standard`, an unknown `/V`/`/R`, or an unknown crypt filter method. ## How to fix Use a tool that supports the handler. ## Example ```text trailer << /Encrypt << /Filter /Adobe.PubSec /SubFilter /adbe.pkcs7.s5 … >> >> ``` A public-key (certificate) handler: Lamina PDF implements only `/Standard`, so `open` fails. `data.filter` holds the handler's name as read from the file. ## Data fields `filter` (**untrusted**: read from the file; the message never includes it), `version`, `revision`. --- # unsupported-version an op log has a schema version this reader does not know - Type: error - Kind: `unsupported` (valid input that Lamina PDF doesn't implement) ## What happened An op log has a schema version this reader doesn't know. ## Why A newer Lamina PDF wrote it. ## How to fix Upgrade Lamina PDF. ## Example ```rust doc.apply_log(r#"{ "v": 9, "ops": [] }"#)?; // unsupported-version, data.version = 9 ``` ## Data fields `version`. --- # untagged-content content was drawn outside every structure element and artifact, which PDF/UA-1 forbids - Type: error - Kind: `usage` (the caller misused the API) ## What happened Under PDF/UA-1, something was drawn outside every structure element and artifact: a path, an image or text that is neither part of the document's structure nor marked as decoration. ## Why Assistive technology reads a tagged document through its structure tree, and skips content outside it or reads it out of order. For this reason, the PDF/UA-1 standard for accessible PDF (ISO 14289-1 §7.1) requires every piece of content to be tagged or marked as an artifact. This is condition 01-005 of the Matterhorn Protocol, the PDF Association's checklist of PDF/UA failures. ## How to fix Draw real content inside `Surface::tag` (or give a text run `TextRun::tag`) and decoration, such as rules, backgrounds and page numbers, inside `Surface::artifact`. `Builder::tagging_diagnostics` lists what is still untagged on each page. ## Example ```rust surface.path(|p| { p.rect(rule); }).fill(FillRule::NonZero); builder.finish()?; // untagged-content: wrap it in surface.artifact(ArtifactKind::Layout, …) ``` ## Data fields - `page`: the page's index (0-based). - `content`: what was drawn: `path`, `image` or `text`. - `count`: how many drawings of that kind. --- # warnings-truncated more than max_warnings warnings were raised and the rest were dropped - Type: warning - Kind: `limit` (a `Limits` value or another resource bound was hit) ## What happened More than `Limits::max_warnings` warnings were raised; the rest were dropped. ## Why A badly damaged or hostile file. ## How to fix Raise `max_warnings` if you need the full list. ## Example A damaged file raising 50,000 repairs with the default `max_warnings`: the first warnings are kept, then one `warnings-truncated`, and the rest are dropped. ```rust let limits = laminapdf::Limits::default().with_max_warnings(100_000); let doc = laminapdf::Document::open_bytes(bytes, laminapdf::OpenOptions::new().with_limits(limits))?; ``` ## Data fields None. --- # wasm-trap the WebAssembly module trapped (a bug in Lamina PDF), so its instance was discarded and the next call gets a fresh one - Type: error - Kind: `internal` (a bug in Lamina PDF or a broken invariant) ## What happened The WebAssembly module trapped during the call: Lamina PDF hit a bug and stopped. `@laminapdf/sdk` discarded the module's instance, so the next call runs in a fresh one. Every document, page and builder from the old instance now throws `instance-reset`. ## Why A trap stops the module in the middle of its work, so its memory may hold half-written state. Nothing in that instance can be trusted afterwards. A fresh instance keeps one failed call from breaking every call after it. ## How to fix Open the document again from its bytes and retry. If the same call on the same file traps again, report it with the file: a trap is always a bug in Lamina PDF. ## Example ```ts try { lines = doc.page(0).text().lines; } catch (e) { if (!isLaminaPdfError(e, "wasm-trap")) throw e; doc = Document.open(bytes); // the old document throws instance-reset from now on } ``` ## Data fields - `module`: the module that trapped: `base`, `text` or `extract`. --- # whole-font-rebuilt a font that forbids subsetting was rebuilt with every glyph kept - Type: warning - Kind: `usage` (the caller misused the API) ## What happened A font whose `OS/2.fsType` forbids subsetting was embedded with every glyph, but rebuilt rather than byte for byte. ## Why It is a variable-font instance or has CFF2 outlines; PDF can embed neither as it is, so `subsetter` instanced it (or converted CFF2 to TrueType outlines) while keeping every glyph. Its `cmap` and layout tables are dropped. ## How to fix Check the font's licence allows this, or use a static TrueType/CFF version of the font. ## Example Registering a variable font instance (`font.instance(&[(OpenTypeTag::WEIGHT, 700.0)])`) whose `fsType` forbids subsetting: the font is embedded with every glyph, but instanced, so not byte for byte. ## Data fields - `font`: the font's PostScript name (**untrusted**: read from the font). --- # worker-crashed the worker running Lamina PDF stopped (an uncaught error, a WebAssembly trap or out of memory); calls in flight were rejected - Type: error - Kind: `internal` (a bug or an environment failure, not the caller's input) ## What happened The Web Worker running `@laminapdf/sdk` (from `@laminapdf/sdk/worker`) stopped: an uncaught error, a WebAssembly trap or the browser killing it (out of memory). Every call still in flight was rejected with this code, and the client emitted a `crashed` event. ## Why A long synchronous call can keep the Web Worker busy without answering, so Lamina PDF has no heartbeat and never treats silence as a crash. Only the Web Worker's `error` or `messageerror` event, or a trap it reports, counts as a crash. ## How to fix Call `client.restart()`, then open the document again: handles from the old Web Worker are gone. An encrypted document needs its password again. If one file keeps crashing the Web Worker, report it. ## Example ```ts client.on("crashed", () => client.restart()); await doc.save(); // rejects with LaminaPdfError { code: "worker-crashed", … } if the Web Worker died ``` ## Data fields - `reason`: what ended the Web Worker: `error`, `messageerror` or `trap` (a crash, which emits `crashed`), or `terminated` (`client.terminate()`, `restart()`, or a call made when no Web Worker runs). --- # worker-failed the worker client failed with an error that isn't Lamina PDF's (a bug in @laminapdf/sdk/worker, or a result it couldn't send) - Type: error - Kind: `internal` (a bug or an environment failure, not the caller's input) ## What happened A call through `@laminapdf/sdk/worker` failed inside the Web Worker with an error that isn't a `LaminaPdfError`. The call was rejected with this code and the error's message; the Web Worker and its documents keep working. ## Why The Web Worker passes Lamina PDF's own errors through unchanged and rejects caller mistakes it can detect (an unknown method, a parameter it can't receive) with `invalid-argument`. Anything else is a failure of Lamina PDF's client code, such as a result it couldn't send back or a bug in its code. ## How to fix Report it, with the call you made and the message. The same call through the sync API on the main thread is a workaround while it is fixed. ## Example ```ts await doc.pageCount(); // rejects with LaminaPdfError { code: "worker-failed", kind: "internal", … } on a bug in Lamina PDF's client ``` ## Data fields None. --- # worker-version-mismatch the worker answered with a different protocol or package version than the client - Type: error - Kind: `usage` (the caller did something the API doesn't allow) ## What happened `connect()` from `@laminapdf/sdk/worker` started a Web Worker, and its handshake named a different protocol or package version than the client. The client rejected `connect()` and sent nothing else. ## Why The client and the Web Worker exchange messages whose shape belongs to one build. A Web Worker from another version (an older deploy, a CDN copy, a separately bundled copy) could misread them, so the handshake fails loudly instead. PDF.js learned the same lesson. ## How to fix Let the bundler serve the Web Worker from the same `@laminapdf/sdk` package as the page: `connect()` contains the literal `new Worker(new URL(…, import.meta.url))` that Vite and webpack follow. If you pass `{ worker }` yourself, build it from the same package version. ## Example ```ts const client = await connect({ worker: new Worker("/old/lamina-worker.js", { type: "module" }) }); // rejects: LaminaPdfError { code: "worker-version-mismatch", … } ``` ## Data fields - `expected`: the client's `{ protocol, version }`. - `actual`: what the Web Worker answered. --- # would-invalidate-signatures the save would break one or more signatures - Type: error (`laminapdf_save::SaveError::WouldInvalidateSignatures`) - Kind: `security` (signatures block the call) ## What happened The save planner refused a save because it would break one or more signatures. Either it was a full rewrite of a signed document, or its pending changes or rewrite reasons conflict with the document's signatures: a DocMDP, FieldMDP or usage-rights restriction, or a repaired or encrypted base. Nothing was written. ## Why A signature covers the bytes of the file as it was signed (ISO 32000-2 §12.8). A full rewrite moves those bytes, and a change a certification forbids invalidates it even when appended. Lamina PDF never breaks a signature without an explicit override. ## How to fix Save with `SaveMode::Auto` or `SaveMode::Incremental` when the constraint allows it. Otherwise ask the user, naming the signatures in the error, and save again with `SaveOptions::with_invalidate_signatures(true)`: the invalidated signatures' values and `/Perms` entries are removed and `SaveReport::invalidated` lists them. ## Example ```rust let err = doc.save(&mut out, &SaveOptions::new().with_mode(SaveMode::FullRewrite)).unwrap_err(); assert_eq!(err.code(), "would-invalidate-signatures"); ``` ## Data fields - `signatures`: every signature, as `{ field, kind, value, revision, covers }` (field ID, kind, signature dictionary as `num gen`, revision, covered length). - `rewrite_reasons`: why only a full rewrite is safe, if anything says so. - `violations`: pending changes a signature's restriction forbids, as `{ class, restriction, signature }`. - `signatures_unknown`: part of the form couldn't be followed, so there may be signatures beyond `signatures`. None of them hold document text. --- # wrong-document finish_fonts was given a document the registry did not reserve its fonts in - Type: error - Kind: `usage` (the caller misused the API) ## What happened `FontRegistry::finish_fonts` was given a document in which a font's reserved object is not a free number reserved in that document. ## Why A `FontRegistry` belongs to one document: `reference` reserves numbers in the document it is given, and `finish_fonts` fills those numbers. Finishing into another document would overwrite its objects, so nothing is written. ## How to fix Use one `FontRegistry` per document, and pass `finish_fonts` the document its `reference` calls used. ## Example ```rust let id = registry.reference(key, &doc_a)?; registry.finish_fonts(&mut doc_b.edit())?; // wrong-document: the fonts were reserved in doc_a ``` ## Data fields - `object`: the reserved object number. --- # wrong-password the given password is neither the user nor the owner password - Type: error - Kind: `security` (encryption, permissions or signatures block the call, or look tampered with) ## What happened The given password is neither the user nor the owner password. ## Why The password is neither the user nor the owner password. Lamina PDF tries it as both (owner first for R5/R6), and in TS also its NFKC form. ## How to fix Ask for the password again. Passwords are case-sensitive. ## Example ```ts Document.open(bytes, { password: "nope" }); // LaminaPdfError { code: "wrong-password", kind: "security", // hint: "ask for the password again and retry with the correct one", … } ``` ## Data fields None. --- # xobject-cycle a Form XObject draws itself, directly or through other forms - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened A Form XObject draws itself, directly or through other forms; the inner `Do` was skipped. ## Why A malformed or hostile file: forms that draw each other without end would never finish. ## How to fix Nothing to do. ## Example ```text 8 0 obj << /Subtype /Form /Resources << /XObject << /Me 8 0 R >> >> >> stream /Me Do endstream ``` The form draws itself: the inner `/Me Do` is skipped once Lamina PDF sees form 8 a second time. ## Data fields - `object`: the form reached again. The `location` is `object` (the form reached again). --- # xref-repaired the stored cross-reference data was unusable and was rebuilt by scanning - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened The stored cross-reference data was unusable and was rebuilt by scanning the file. ## Why Edited, truncated or concatenated files often have stale offsets. ## How to fix Nothing, if the result is right. Incremental saves refuse a repaired base (`repaired-base`); save in full instead. ## Example ```text startxref 123456 %%EOF ``` `startxref` points past the end of a file that was truncated and re-padded. Lamina PDF scans the file for `n g obj` headers, rebuilds the table, raises `xref-repaired` and opens normally. `save(SaveMode::Incremental)` is then refused with [`repaired-base`](/reference/codes/repaired-base.md); `SaveMode::Auto` rewrites the file. ## Data fields None. --- # xrefstm-ignored a hybrid file's /XRefStm could not be read and was ignored - Type: warning - Kind: `input` (the input is malformed beyond what tolerance repairs) ## What happened A hybrid file's `/XRefStm` could not be read and was ignored. ## Why Hybrid files carry a classic xref table for old readers and an xref stream for objects in object streams. An editor that moved bytes without updating `/XRefStm`, or a damaged stream, leaves the stream unreadable. ## How to fix Nothing; the classic table is used. ## Example ```text trailer << /Size 40 /Root 1 0 R /XRefStm 99999 >> ``` A hybrid-reference file (ISO 32000-2 §7.5.8.4) points `/XRefStm` at an offset with no stream. Lamina PDF ignores it and reads the classic table; objects only the stream listed may be missing. ## Data fields `offset` (where `/XRefStm` points).