Skip to content
Inkoxide

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

API reference

The Rust and Node APIs, render options and error codes.

Edit this page

Inkoxide has two public surfaces: the Rust crate inkoxide (crates/core) and the Node package inkoxide (packages/inkoxide, built from crates/node). They expose the same engine with the same options and error codes. The document format itself is described in Schema reference and Example documents.

The API follows semantic versioning. Changes are additive: new options, new error codes and new functions are minor versions; removing or renaming any of them is a major version.

Side by side

ConceptRust (inkoxide)Node (inkoxide)
Register a font (TrueType, CFF OpenType, WOFF or WOFF2; not a .ttc collection)FontStore::register(id, bytes) -> Result<(), InkoxideError>registerFont(id, data): void (process-wide)
Remove a fontFontStore::unregister(id) -> boolunregisterFont(id): boolean
ImagesImageStore::insert(id, bytes), passed to each calloptions.images: Record<string, Uint8Array> per render
Hyphenation patternsHyphenationStore::register(lang, tex), via RenderOptions::with_hyphenationregisterHyphenationPatterns(lang, data) (process-wide), unregisterHyphenationPatterns(lang)
Validatevalidate(doc, format, &fonts, &images) -> Vec<ValidationError>validate(doc, format?, images?): ValidationError[]
Validate without the imagesvalidate_without_images(doc, format, &fonts) -> Vec<ValidationError>validate(doc, format?), with no images
Renderrender(doc, format, &options, &fonts, &images) -> Result<Vec<u8>, InkoxideError>render(doc, options?): Promise<Uint8Array> (libuv pool)
Render, blocking(same render)renderSync(doc, options?): Uint8Array
Input formatInputFormat::Json (default), InputFormat::MessagePack (cargo feature msgpack, on by default; without it, PARSE_ERROR)options.format / format: 'json' (default) or 'msgpack'
Empty the caches renders share (decoded images, font subsets, SVG with its text)clear_caches()(not available)
VersionENGINE_VERSION, SCHEMA_VERSIONversion(): { engine, schema, gitSha }
ErrorInkoxideError (.code()), ValidationError { code, path, message }InkoxideError extends Error { code, path, message }

doc is bytes in Rust; in Node it is a Uint8Array (or Buffer) or a string, which is read as UTF-8 JSON.

WebAssembly (@inkoxide/wasm)

@inkoxide/wasm is the engine built for wasm32-unknown-unknown, with the functions and types of the Node package, and two more:

FunctionMeaning
init(source?, { hyphenation? }): Promise<void>Compiles and instantiates the module once: from a URL (default: inkoxide.wasm next to the package), a Response, its bytes or a compiled WebAssembly.Module. Every other function throws until it resolves. hyphenation is the folder of the built-in hyphenation patterns (default: the package's hyphenation/)
loadHyphenation(langs): Promise<void>Fetches the built-in hyphenation patterns of BCP-47 tags, for renderSync
clearCaches(): voidRust clear_caches(): empties the caches renders share (decoded images, font subsets, SVG with its text). The memory stays with the instance.

How it differs from the Node package:

  • One thread. Every call runs on the calling thread, render included; threads may only be 1, and another number is INVALID_ARGUMENT at options.threads. In a page, call it from a worker, as @inkoxide/render/browser does.
  • JSON only. It is built without the msgpack feature: format: 'msgpack' is a PARSE_ERROR.
  • Downloads on demand. It is built without the embedded-hyphenation feature: render fetches the patterns of a language from hyphenation/<file>.bin the first time a document hyphenates text in it, and the fonts of the deferredFonts option (id → URL) a render needs. renderSync cannot fetch: it fails with NOT_LOADED instead (load the patterns first with loadHyphenation).
  • A bug in the engine (a panic in a dependency) ends the instance, since WebAssembly cannot unwind: the call fails with INTERNAL, and the next one runs in a new instance with the fonts and hyphenation patterns registered so far. The engine's own limits return their errors, as in Node.
  • Memory grows to at most 4 GiB and is not given back; the process-wide caches keep a quarter of their Node budgets.
  • Output. A render has the bytes Node renders, except that numbers computed with trigonometry in a dependency (the angle of an SVG marker) can differ in the last digits. scripts/wasm-parity.mjs checks every fixture.

Render options

Rust field / setterNode optionDefaultMeaning
compress / with_compresscompresstrueFlate-compress page content streams
compression_level / with_compression_levelcompressionLevel6Flate level 0–9 (Rust: a larger level is 9; Node: other values, fractions included, are INVALID_ARGUMENT)
creation_date / with_creation_datecreationDatenoneRFC 3339 timestamp; overrides meta.creationDate. With neither, no date is written. Another string fails the render: INVALID_VALUE at options.creationDate in Rust, INVALID_ARGUMENT in Node
threads / with_threadsthreadsall coresWorker threads for this render. 1 runs it on the calling thread (in Node, its libuv thread) with no parallelism inside, so concurrent renders do not wait for each other; a larger number uses a pool of that size, shared by renders that ask for the same number and kept for the life of the process. A pool has at most 256 threads. Rust: 0 is 1, and a number above 256 uses the 256-thread pool; Node: an integer from 1 to 256, other values are INVALID_ARGUMENT
subset_fonts / with_subset_fontssubsetFontstrueEmbed only the glyphs used
pdf_a / with_pdf_apdfAfalseWrite PDF/A, at pdf_a_level (Node: true is PDF/A-2b). PDF/A forbids scripts, so text fields with a format keep their formatted text and value but not the form scripts that format what the user types
pdf_a_level / with_pdf_a_level (also sets pdf_a)pdfA: '1b' | '2b' | '3b'PdfALevel::A2bThe PDF/A level: 1b, 2b or 3b (see PDF/A). Node: another string is INVALID_ARGUMENT
hyphenation / with_hyphenation(registerHyphenationPatterns)noneRuntime TeX patterns; and, for a build without the embedded-hyphenation feature, built-in patterns given with register_builtin
deferred_fonts / with_deferred_fonts(@inkoxide/wasm: deferredFonts, id → URL)noneIds of declared fonts that may be missing: a render that does not draw with one, or try it for a character, renders as if it were registered; one that does fails with NOT_LOADED naming it
encryption / with_encryption (Encryption)encryption: { userPassword?, ownerPassword?, permissions? }noneEncrypt the PDF with passwords and permissions (see Encryption). Not with PDF/A
(argument format)format'json'Input encoding
(argument &ImageStore)images{}Image bytes by id: JPEG, PNG, GIF (first frame), WebP or SVG

In Rust, RenderOptions is #[non_exhaustive]. Build it with the setters; the fields stay public for reading and assignment:

use inkoxide::{FontStore, ImageStore, InputFormat, RenderOptions};

let mut fonts = FontStore::new();
fonts.register("Inter-Regular", std::fs::read("Inter-Regular.ttf")?)?;
let options = RenderOptions::default()
    .with_threads(1)
    .with_creation_date("2026-01-01T00:00:00Z");
let pdf = inkoxide::render(&doc, InputFormat::Json, &options, &fonts, &ImageStore::new())?;
import { readFileSync } from 'node:fs';
import { registerFont, render } from 'inkoxide';

registerFont('Inter-Regular', readFileSync('Inter-Regular.ttf'));
const pdf = await render(JSON.stringify(doc), { threads: 1, creationDate: '2026-01-01T00:00:00Z' });

PDF/A

With pdfA (Rust: pdf_a), the file is PDF/A, for archiving: XMP metadata that mirrors the document information, an sRGB output intent and a file identifier. CMYK JPEGs are converted to RGB, and .notdef glyphs are left out. Every level is "b": the pages look the same in every reader.

LevelNodeRustNotes
PDF/A-2btrue or '2b'PdfALevel::A2b (default)Based on PDF 1.7
PDF/A-3b'3b'PdfALevel::A3bPDF/A-2b; the level allows embedded files, but Inkoxide cannot attach any yet
PDF/A-1b'1b'PdfALevel::A1bBased on PDF 1.4, for archives that require it. Font subsets list their glyphs (CIDSet)

Every level takes page sides of 3 points or more: a render of a smaller page as PDF/A fails with PDFA_CONFLICT at the page's size.

PDF/A-1b has no transparency. A render at 1b fails with PDFA_CONFLICT at the path of the first thing that needs it: a colour with alpha below 1 (a backgroundColor of alpha 0 draws nothing and is accepted), an opacity between 0 and 1, debug, an image, inline image, emoji image or background image with an alpha channel, an SVG drawn with opacity, a mask, a blend mode, an isolated group or a clip path other than one path, or a colour glyph that needs transparency (a bitmap with an alpha channel or a layer with an opacity, at the path of its run). validate does not know the level, so it does not report these. A PDF/A-1b file also keeps within the limits of PDF/A-1: real numbers within ±32767, strings of at most 65535 bytes, names of at most 127 bytes, arrays of at most 8191 entries, dictionaries of at most 4095, and graphics states nested at most 28 deep. A document that would pass one fails with PDF_ERROR.

The a and u levels need tagged PDF, which Inkoxide does not write yet.

In the CLI, --pdf-a alone writes PDF/A-2b, and a level follows an equals sign:

inkoxide render doc.json --fonts fonts/ --pdf-a=1b

Encryption

With encryption, the file is encrypted with AES-256 (PDF 2.0, revision 6 of the standard security handler; Acrobat X, pdf.js, PDFium, Preview, Poppler and qpdf open it). The user password opens the file; the owner password lifts the permissions.

Rust (Encryption, Permissions)NodeDefault
user_passworduserPasswordnone: anyone opens the file
owner_passwordownerPasswordthe user password
permissions.printing: Option<Printing>permissions.printing: 'lowResolution' | 'highResolution'not allowed
permissions.modifying, copying, annotating, filling_forms, document_assemblymodifying, copying, annotating, fillingForms, documentAssemblyfalse
permissions.content_accessibilitycontentAccessibilitytrue
  • At least one password is needed. Passwords are printable ASCII, at most 127 bytes.
  • permissions need an owner password other than the user password: without one, or with the same one, the user password lifts them. Readers, not the file, enforce the permissions.
  • With pdfA, encryption is INVALID_VALUE at options.encryption in Rust and INVALID_ARGUMENT in Node (PDF/A forbids it).
  • A bad value is INVALID_VALUE at its path (options.encryption.userPassword, …) in Rust and INVALID_ARGUMENT in Node. No error and no Debug output shows a password: Rust's Encryption prints them as <redacted>.
  • Every string and stream is encrypted. The object structure is not: page count, page sizes, font names and widths, link rectangles, stream lengths and the other dictionary entries stay readable. Font subset tags (ABCDEF+ in a font name) are keyed by the file's keys, and a font's widths are listed for all its glyphs, not only those used, so neither tells which glyphs the file uses. Listing them all adds up to about 5 bytes a glyph to each font (some 15 KB for a Latin font of 3,000 glyphs). Radio buttons, whose states are otherwise named after their values, are named by their position in the group instead, with the values in /Opt, so the values are encrypted too.
  • Form field state that PDF writes as names and numbers stays readable: whether a checkbox is checked (/V and /AS), which radio button of a group is on (its position, as above), and the positions of a list box's selected options (/I) and of its first visible one (/TI). Text field values, select and list values and every option are strings, and are encrypted.
use inkoxide::{Encryption, Permissions, Printing, RenderOptions};

let options = RenderOptions::default().with_encryption(
    Encryption::new()
        .with_user_password("open")
        .with_owner_password("owner")
        .with_permissions(Permissions::new().with_printing(Printing::HighResolution)),
);

Hyphenation patterns

A text node with hyphenate: true is hyphenated in its lang (or meta.lang). With neither, nothing is hyphenated, and the render logs a tracing warning that names the node's id. A run's own hyphenate overrides the node's for that run's words, in the node's language. The patterns are chosen in this order:

  1. patterns registered for the tag, then for each less specific tag (en-GB, then en);
  2. the built-in patterns for the language (48 languages, English included: hypher's, in crates/core/hyphenation; builtin_hyphenation_file names the file of a tag);
  3. none.

Soft hyphens (U+00AD) in the text are always honoured.

A word wider than its line is broken between any two characters, hyphenated or not, as CSS does with overflow-wrap: anywhere; a grapheme cluster is never split. Text with whiteSpace nowrap or pre is not broken: a long word overflows the line.

Patterns are registered for a BCP-47 tag with a two- or three-letter language (en, en-GB, haw), in any case and with - or _. Patterns for en-GB and for en-US coexist; patterns for en serve every English tag without patterns of its own.

Patterns are TeX files in the hyph-utf8 format: \patterns{…}, with an optional \hyphenation{…} exception list, in UTF-8. English is built in; to hyphenate it with another pattern set (or to add a language that is not built in), register a file:

  1. Download a pattern file from hyph-utf8, for example hyph-en-us.tex from https://github.com/hyphenation/tex-hyphen/blob/master/hyph-utf8/tex/generic/hyph-utf8/patterns/tex/hyph-en-us.tex.
  2. Check that its licence suits you. The inkoxide package ships no pattern files. @inkoxide/render ships hyph-en-us.tex.
  3. Register it:
registerHyphenationPatterns('en', fs.readFileSync('hyph-en-us.tex')); // once, at startup; 'en' covers every English lang
inkoxide render doc.json --fonts fonts/ --hyphenation en=hyph-en-us.tex
let mut patterns = inkoxide::HyphenationStore::new();
patterns.register("en-US", &std::fs::read("hyph-en-us.tex")?)?;
let options = RenderOptions::default().with_hyphenation(patterns);

Line breaking

A text node breaks its lines first fit by default (textWrap: "wrap"). With textWrap: "pretty", each stretch of a paragraph between forced breaks is set as a whole (Knuth and Plass's total fit), for even lines. It is bounded per paragraph, so its cost and its output do not depend on the rest of the document or on threads; past a bound, the stretch is set first fit:

  • at most 20 000 break opportunities in a stretch;
  • at most 512 open breakpoints at a time;
  • at most 2 097 152 lines tried in a paragraph.

Font features

A run's fontFeatures set OpenType features by tag, as CSS font-feature-settings: 1 turns a feature on, 0 off, and a larger value picks an alternate. A feature does something only when the run's font has it. The features are bounded:

  • a tag is four printable ASCII characters, set once per run (INVALID_VALUE);
  • a value is at most 65535 (OUT_OF_RANGE);
  • a run takes at most 32 features (OUT_OF_RANGE);
  • a document uses at most 64 different sets of features, as each set is shaped with plans and words of its own (OUT_OF_RANGE).

@inkoxide/render reads fontFeatureSettings and fontVariantNumeric, and their Tailwind classes, into a run's fontFeatures, within the same bounds.

Determinism

For a fixed creationDate (or meta.creationDate), output bytes depend only on the document, the font, image and hyphenation pattern bytes, and the options. They do not depend on the thread count or on JSON versus MessagePack input. Without any creation date, no date is written, so output is still deterministic.

Encrypted output is deterministic too: the keys, salts and IVs that revision 6 makes random are derived from the passwords and the whole file as it would be written unencrypted, so identical input gives identical bytes, and documents whose files differ anywhere (text, images, links, form fields, notes, bookmarks, metadata) get different keys. The same input with the same passwords gives the same bytes, so equal files can be told apart from different ones. The derivation is not the standard's random one; it has been reviewed internally but has not had an independent cryptography review yet. What encryption does and does not protect is in Security.

Errors

Every failure has a stable string code. In Rust it is ValidationCode::as_str() or InkoxideError::code(). In Node it is InkoxideError.code, typed as the ErrorCode union in index.d.ts. Handle unknown codes, because new ones may be added in minor versions.

CodeRustNodeWhen
PARSE_ERROR … UNKNOWN_ANCHORValidationCode (in ValidationError or InkoxideError::Validation)ValidationError.code, or InkoxideError.code from renderThe document is invalid. See the code table in Schema reference. In Rust, also an invalid creation_date option (INVALID_VALUE at options.creationDate)
PDFA_CONFLICTValidationCode::PdfaConflictInkoxideError.code from renderThe document uses something its PDF/A level does not allow: a page side under 3 points, at any level, or transparency under PDF/A-1b. The path names the first such use. Only render reports it: validate does not know the level
FONT_ERRORInkoxideError::FontsameFont bytes cannot be parsed or embedded, or the shaper panicked on the font, or the font shaped the text to far more glyphs than it has bytes (the message names the font)
IMAGE_ERRORInkoxideError::ImagesameAn image cannot be embedded. Images that do not decode are INVALID_IMAGE at their path, found by validation first
LAYOUT_ERRORInkoxideError::LayoutsameLayout failed, for example the page limit (100,000 pages) or the limit on content repeated on every page was exceeded
PDF_ERRORInkoxideError::PdfsameWriting the PDF failed, or a PDF/A-1b file would pass one of the limits of PDF/A-1
HYPHENATION_ERRORInkoxideError::HyphenationsameHyphenation patterns cannot be parsed
NOT_LOADEDInkoxideError::NotLoaded (NotLoaded { fonts, hyphenation })@inkoxide/wasm only: from renderSync, and from render when it cannot fetch what the document needsThe render needs a font of deferred_fonts, or built-in hyphenation patterns that a build without the embedded-hyphenation feature has not been given: register them and render again (@inkoxide/wasm's render fetches them and does). @inkoxide/render/browser reports a file it could not fetch as INTERNAL
INTERNALInkoxideError::Internal, also for a panic in rendersame, plus caught panics in any callA bug in the engine; the process keeps running
INVALID_ARGUMENTn/a (types are checked at compile time)Node onlyA JS argument has the wrong type or is out of range

The full list of validation codes is ValidationCode in crates/core/src/error.rs and the ValidationCode union in packages/inkoxide/index.d.ts.

The fields of a Node error are:

  • code: as above.
  • path: the document location, like pages[0].children[3].runs[1].font. It is empty when the error is not tied to a location.
  • message: the engine's description, without the code or the path.
  • name: always 'InkoxideError'.

render reports only the first validation problem; call validate to get the others. validate returns at most 100 problems. It decodes the images the document uses, so an image that would fail the render is INVALID_IMAGE at its path (the decode is cached for the render). A JPEG is embedded without decoding: its structure is checked (it must be complete, and baseline or progressive), but corrupt compressed data inside a complete JPEG is not found. Without images (Node validate with no images, Rust validate_without_images), image ids are not looked up and everything else is checked. An empty result means the document is valid; render can still fail on an option, on the page limit or the limit on content repeated on every page (LAYOUT_ERROR), on a font that cannot be shaped or embedded (FONT_ERROR), on something its PDF/A level does not allow (PDFA_CONFLICT, which validate cannot know), or on a failure in writing the PDF (PDF_ERROR). validate reads the document's structure first, and a problem found there (input that does not parse, an unknown or missing property, a wrong type, a value the reader rejects) is returned alone, as the rest cannot be checked.

Some rules are checked by the engine but not by the JSON Schema, so a document that passes the schema can still fail validation. The schema's descriptions and Schema reference state them. Among them:

  • break with a value other than its defaults, on a header or footer, on a node in a table cell, or on any node inside these, is INVALID_VALUE; a pageBreak there is INVALID_NODE.
  • Page margins that leave no room for content (left + right at least the page width, or top + bottom at least its height) are OUT_OF_RANGE at margin.
  • DUPLICATE_ID is a font id declared twice, or a form field name used twice (the buttons of one radio group share theirs) or used both for a field and for a group of fields (a and a.b).
  • Fonts, images and #id links must resolve: UNKNOWN_FONT, FONT_NOT_REGISTERED, UNKNOWN_IMAGE, INVALID_IMAGE, IMAGE_TOO_LARGE and UNKNOWN_ANCHOR. A #id link must name a node in the page body (the flow or an overlay) that is displayed: an id in a header or footer, or under display: none, is not a destination.
  • A radio button needs a non-empty value (MISSING_FIELD), and a text field's value is at most maxLength characters (OUT_OF_RANGE). The value of a select or list field is one of its options; an array value needs a list with multiSelect and names each option at most once (INVALID_VALUE).
  • A format applies to single-line text fields that are not passwords, with the keys of its type; a date or time format needs a pattern (MISSING_FIELD) made of the documented letters and .,/:-, and the field's value must fit the format (INVALID_VALUE).
  • gridArea cannot be combined with gridRow or gridColumn (INVALID_VALUE), and gridTemplateAreas has at most 10000 cells (OUT_OF_RANGE).
  • float applies to the children of a column box, not with position: absolute nor with a forced break (INVALID_VALUE), and not to table cells (STYLE_NOT_APPLICABLE, as clear). Inside a float, a forced break is INVALID_VALUE and a pageBreak INVALID_NODE. A box holds at most 1000 floated children (OUT_OF_RANGE). A box with floats takes no justifyContent or alignContent, and its children no flexGrow or flexBasis (STYLE_NOT_APPLICABLE).
  • transform, transformOrigin and zIndex do not apply to table cells (STYLE_NOT_APPLICABLE).
  • The tables of a document have at most 1000000 grid slots in all, a slot being one row of one column, however the cells span them (OUT_OF_RANGE at the table's rows).

The other way, the engine reads a number as a 32-bit float: one past a bound by less than a float's precision there is read as the bound and accepted (flexGrow: 1000000.01). Integers (also written as 400.0), percentages and fr sizes are bounded as written.

Not part of the API

  • InkoxideError.__testPanic in the Node binding is a non-enumerable test hook, not a module export.
  • Nothing else in the Rust crate is public: its modules are private.

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