Skip to content
Inkoxide

Search covers every page and heading of the docs. Use ↑ ↓ and ↵.

Architecture

How a document goes from React to a tree, through layout, pagination and paint, to PDF.

Edit this page

Inkoxide is a pure function render(document_tree, options) -> pdf_bytes, with a React front end that builds the document tree. This page describes how the pieces fit together, for contributors.

Repository layout

PathContents
crates/coreThe engine, crate inkoxide. #![forbid(unsafe_code)], no I/O, no napi.
crates/cliinkoxide render / validate / bench, a development tool that does the file I/O.
crates/nodeThe napi-rs binding, published as the npm package inkoxide.
packages/inkoxideThe npm package for the binding: loader, types and tests. The tests are the contract of both bindings.
crates/wasmThe WebAssembly binding, through a small C ABI of its own (see its lib.rs), for the npm package @inkoxide/wasm. Not published yet.
packages/wasmThe npm package for it: loader, types; it runs the tests of packages/inkoxide.
crates/bindingWhat the two bindings share: the render options as JavaScript passes them, their checks, and errors as JSON.
packages/schemaThe document schema (document.v1.json), its TypeScript types and changelog: @inkoxide/schema.
packages/styleThe flat style object, CSS units, and the readers of lengths, keywords, colours, flex, transforms and positions that React and Tailwind share: @inkoxide/style.
packages/tailwindTailwind class strings to engine styles, through the Tailwind v4 compiler: @inkoxide/tailwind, optional.
packages/reactReact components and a reconciler that build document trees: @inkoxide/react.
packages/renderrenderToBuffer, renderToFile, Font.register and the bundled fonts: @inkoxide/render; templates served by id: @inkoxide/render/templates; in a browser, with the engine as WebAssembly in a Web Worker: @inkoxide/render/browser. The shared code reaches the engine and the bundled assets through an EngineHost (host.ts): node-host.ts in Node, browser-host.ts and worker.ts in a browser.
examplesExample documents in React; they produce the screenshots in the READMEs.
benchmarksComparisons with other PDF libraries.
fixturesEngine test documents, fonts, images and reference renders.
fuzzcargo-fuzz targets (nightly, outside the Cargo workspace).

The npm packages depend on each other in this order: @inkoxide/schema → @inkoxide/style → @inkoxide/react → @inkoxide/render, which also depends on inkoxide. @inkoxide/tailwind depends on @inkoxide/style, and @inkoxide/react and @inkoxide/render load it only when it is installed (an optional peer dependency), for className.

From React to a document tree

@inkoxide/react is a React reconciler whose host components (Document, Page, View, Text, Image, Table, …) build an in-memory tree. After React commits, the tree is converted into a schema v1 document: className strings go through @inkoxide/tailwind when it is installed (the real Tailwind compiler turns classes into CSS, and the CSS declarations are mapped to engine styles), style objects are read with the same readers of lengths, keywords, colours, flex, transforms and positions from @inkoxide/style (em, vw and vh resolve against the element's font size and the page in both), and fonts are resolved against the font registry with CSS font matching. In development the tree is validated against the JSON Schema, and errors carry the component stack.

@inkoxide/render registers the bundled fonts with the engine once per process, renders the tree, and, when the document uses render props that need the page count, renders again with the page count until it settles (at most four renders).

Pipeline

bytes ──serde──▶ schema::Document ──validate──▶ (arena tree) ──layout──▶ boxes
      ──paginate──▶ pages ──paint──▶ display lists ──pdf──▶ bytes
StageModuleWhat it does
Parsevalidate (parse), schemaJSON or MessagePack into the typed structs of schema (hand-written deserialisers in schema/de.rs); serde errors become validation errors with their path and code
Validatevalidate (check)Semantic checks: font ids, image ids (images must decode), anchors, value ranges
Fontsfonts, pdf/font.rsRegistry and metrics, WOFF/WOFF2 decoding, subsetting, CFF programs (fonts/cff.rs); Type0/CID embedding of TrueType and CFF, ToUnicode
Texttextrustybuzz shaping, UAX #14 line breaking, hyphenation, font fallback, bidi, justification
LayoutlayoutAn arena tree laid out by Taffy (flexbox and grid), with a fast path for simple table cells
Tableslayout/table.rsColumn widths, spans, collapsed borders; splitting with repeated header rows
Floatslayout/flow.rs, layout/float.rsFloat containers (the root, a column box or a cell with floated children), Taffy leaves like tables: children stacked as in a column, floats placed, text wrapped beside them, boxes narrowed
Paginationpaginate, pipelineBreaks flows into pages: orphans and widows, keep-together, forced breaks
PaintpaintDisplay lists: backgrounds, borders, radii, opacity, transforms, clipping
PDFpdfPages and content streams (encoded in parallel), links, notes, outline, forms, PDF/A-1b, 2b and 3b, encryption. Every string and stream goes into the file through pdf/out.rs, which encrypts it when the file is encrypted; crates/core/clippy.toml forbids pdf-writer's TextStr, TextStrWithLang, Date, the other writers that make strings of their own, and its stream writers anywhere else. Two exceptions, both in pdf/crypt.rs: Dict::write writes the strings of the Encrypt dictionary as they are, since the standard leaves them unencrypted, and splice_encrypt adds the /Encrypt entry to the trailer pdf-writer finished, which has no setter for it
ImagesimagesJPEG passthrough, PNG with SMask, GIF and WebP, SVG as vector form XObjects, SVG text read and replaced with probes (images/svg_text)

Public API (core)

pub fn validate(doc: &[u8], format: InputFormat, fonts: &FontStore, images: &ImageStore) -> Vec<ValidationError>;
pub fn validate_without_images(doc: &[u8], format: InputFormat, fonts: &FontStore) -> Vec<ValidationError>;
pub fn render(doc: &[u8], format: InputFormat, options: &RenderOptions, fonts: &FontStore, images: &ImageStore)
    -> Result<Vec<u8>, InkoxideError>;
pub fn clear_caches();

Besides these, the crate exports only their inputs and outputs (RenderOptions, PdfALevel, Encryption, Permissions, Printing, InputFormat, FontStore, ImageStore, HyphenationStore, InkoxideError, ValidationError, ValidationCode) and the constants ENGINE_VERSION and SCHEMA_VERSION. Every module is private, so nothing else is reachable from outside the crate.

Layout data model

layout::Tree is an arena (Vec<LNode>) in breadth-first order, so each node's children are a contiguous index range. Index 0 is a synthetic root: the page content box (column flex, fixed width, unbounded height). After LayoutEngine::compute every node holds absolute Geometry (border box, plus resolved border and padding) in flow coordinates; paint adds the page margin offset.

Text pipeline

runs ─▶ prepare (whitespace, soft hyphens, font per char, rustybuzz) ─▶ Prepared   (cached per node)
Prepared + LineBoxes ─▶ break_lines (per chunk: greedy or optimal, metrics, maxLines/ellipsis) ─▶ LineLayout (cached per boxes)
LineLayout ─▶ TextPainter (align, justify, placeholders, decorations) ─▶ Op::Text
Op::Text in a colour font ─▶ ColorGlyphs ─▶ Op::Span + Op::Image + Op::InvisibleText

break_lines breaks each chunk between forced breaks on its own, first fit (lines.rs) or, for textWrap: pretty, with Knuth and Plass's total fit (optimal.rs), which falls back to first fit past its caps. Lines are laid in LineBoxes (layout/float.rs): the content width, and bands where floats take room on a side, where each line finds its box at its own top. A float container's layout records the line boxes of the text beside its floats (Tree.wraps); pagination, page numbers and the painter look the lines up in them.

A run's fontFeatures go to rustybuzz with its words; shaped words and shape plans are cached per feature set too. A run's verticalAlign moves its baseline (text::baseline_shift, from the font's OS/2 superscript and subscript metrics): line metrics count the moved glyph boxes, and the painter draws the run, its decorations and its debug box there.

An image run is an inline object (text/objects.rs): one glyph-like cluster in a segment of its own (U+FFFC in the text, so UAX #14 breaks around it), as wide as the image, so line breaking, textWrap: pretty and ellipses need no special case. Line metrics add its extent where its verticalAlign puts it (top and bottom after the rest of the line), and the painter draws its image there, in a span with its alt.

Itemisation picks a font per character, except in emoji sequences (text/emoji.rs, UTS #51: flags, keycaps, skin tones, tag and ZWJ sequences), which take one font each so the font's ligature can form; a sequence with emoji presentation tries the colour fallback fonts first. After shaping, the glyphs text uses from colour fonts become images once per render (text/color.rs): fonts/color.rs writes COLR v0 and v1 glyphs as SVG, under a work budget, and takes PNG bitmaps from CBDT and sbix; they decode through images like document images. A COLR glyph with layers in the foreground colour becomes one image per run colour it is drawn in. The painter then replaces each colour glyph with its image, in a /Span whose /ActualText is the glyph's text, with an invisible space for text extraction. A font with colour bitmaps and no outlines is never embedded.

Two third-party crates stay behind one module each, so replacing them touches only that module: ttf-parser (font parsing) is used only in fonts, which gives the rest of the engine metrics, glyph ids and advances; rustybuzz (shaping) only in text/prepare.rs, which parses its shaping faces from the font bytes once per render and shares them between threads. pdf depends on fonts and images, never the reverse: fonts subsets font programs and caches the compressed whole program, and the PDF writer supplies the compression; the writer and images both compress through flate.rs.

SVG text goes through the same shaper. images/svg_text reads each <text> of an SVG once, when the SVG is decoded: the cascade of its text properties (simplecss and svgtypes are used only in its extract.rs, at the versions usvg uses), white space and character positions. Each render lays the text out with its fonts before layout (text/svg.rs, one TextShaper::prepare call per chunk, so fallback fonts, bidi, the glyph limit and ToUnicode apply), replaces each <text> with a "probe" path over its ink, and converts the SVG again (cached by the rewritten source). usvg resolves the probes' paint; the PDF writer draws each probe's text in its place as PDF text (pdf/svg.rs). See ADR 0001.

TextCache is the layout engine's LeafMeasure for text nodes: Taffy asks for min-content / max-content / definite widths and gets the paragraph's size. Glyph → Unicode mappings are collected while shaping and become the ToUnicode CMaps, so text is selectable and searchable; colour glyphs carry theirs as ActualText.

Pagination and paint

pipeline::run lays out every page template (flow, header, footer, overlays), paginates each flow, then paints page by page: background → overlays with a negative zIndex → flow (clipped to the content band, only nodes and lines on the page) → fixed nodes → the other overlays → header → footer. An overlay is a page child that is fixed and absolutely positioned: it is laid out on its own against the page content box, like a band, instead of in the flow. Overlays are painted by zIndex, then in document order. FlowPart tells the box painter which part of a flow to paint; leaf painters get a ContentCtx with the node's shift, per-line shifts and the page window. Elements with debug have their boxes recorded as they are painted (paint/debug.rs), a paragraph's with the lines and runs the text painter placed (ContentPainter::paint_outlined); the outlines are drawn last, over everything, the flow's clipped to its content band.

Page-number placeholders are laid out as 999 until the page count is known. Then flow text that shows them gets the numbers of the page its first number lands on (TextCache::set_numbers, through fill_numbers), and the flows with such text are laid out and paginated again, until no number changes (at most three times; pipeline::number_flows). Text in a fixed node or a table's header rows is painted on several pages and keeps the stand-ins' width. The painter always draws the page's own digits. A header, footer or overlay that shows page numbers is laid out again on each page, in the page's paint job: text::PageText puts the page's digits in place of the stand-ins and breaks those paragraphs' lines again, and reads every other paragraph from the shared cache.

Testing

See Testing: unit and fixture tests (byte-determinism, JSON/MessagePack parity, qpdf --check), schema parity, visual regression, the Node suite, benchmarks and fuzzing.

Synced from docs/ARCHITECTURE.md in the inkoxide repository.