caj2pdf-rust
v0.8.1
Published
CAJ, KDH, HN/C8 and PDF to PDF conversion for browsers and Node.js through WebAssembly in a Worker
Readme
caj2pdf JavaScript package
Streaming CAJ-family to PDF conversion for browsers and Node.js 22+. The package runs the same synchronous Rust core as the native crate in a Worker, through a raw, dependency-free WebAssembly ABI. It has no npm dependencies, and all project-owned JavaScript and TypeScript declarations are MIT-licensed.
Native C8/HN-B text uses bundled fonts and measured parameters automatically; the default examples below need no font options.
Install
npm install caj2pdf-rustThe published package includes the tested WASM module; consumers do not need a Rust toolchain. Version 0.5.0 changes the Worker API; see the migration guide.
Usage
// Node.js
import { open } from "node:fs/promises";
import { finished } from "node:stream/promises";
import { convert, loadModule, nodeWritableSink } from "caj2pdf-rust";
const module = await loadModule();
let output;
try {
output = (await open("paper.pdf", "wx")).createWriteStream();
// A path, file: URL, descriptor or Blob; the Worker reads a path itself.
const report = await convert(module, "paper.caj", nodeWritableSink(output));
output.end();
await finished(output);
console.log(report.format, report.pagesConverted);
} finally {
output?.destroy();
if (output) await finished(output).catch(() => {});
}// Browser: `file` is a File from an <input type="file">.
import { convert, loadModule, webWritableSink } from "caj2pdf-rust";
const module = await loadModule();
const writer = (await (await showSaveFilePicker()).createWritable()).getWriter();
await convert(module, file, webWritableSink(writer));
await writer.close();The . export resolves to node.mjs under the node condition and to
browser.mjs elsewhere; ./node and ./browser select one explicitly. Both
re-export the platform-neutral API in io.mjs. Type declarations are in
*.d.mts. Runnable examples are examples/node.mjs
(node js/examples/node.mjs INPUT|- OUTPUT.pdf [--no-bookmarks]) and
examples/browser.html (serve the repository root
over HTTP and open /js/examples/browser.html). The Node example also
removes its newly created output on failure; the short snippet above leaves
partial-output disposal to the caller.
The browser example runs one conversion at a time. Failed or cancelled conversions remove their private OPFS output. When using its download fallback, save the download and click Discard download to revoke the URL and remove the private output; starting another conversion also removes the previous download. Discard before closing the tab: browser shutdown cannot reliably await storage cleanup. The save-picker path writes directly to the file you choose.
Supported inputs
| Input | Conversion |
| --- | --- |
| PDF (%PDF-) | Validated, repaired where the core supports it, and copied. |
| CAJ (CAJ) | Reconstructed PDF with CAJ outline bookmarks. |
| KDH (KDH) | Decoded PDF, then the PDF path. |
| HN, C8 | Experimental complete-page conversion with built-in standard codec tables; native C8/HN-B text uses bundled fonts automatically (below). inspect reads page counts and validated HN-A bookmark counts. |
| CAA (v0.6.0) | inspect returns format: "caa" and null counts; convert rejects with UnsupportedFormatError and asks for the referenced document. |
| TEB | Recognized; rejected with UnsupportedFormatError. |
| Anything else | Rejected with UnsupportedFormatError (format: null). |
The measured .nh sample contains HN-A bytes and uses the HN reader. The
legacy forced format "nh" remains unsupported; it does not mean the .nh
extension. Detection uses bytes, not names. CAA recognition is limited to
the complete observed descriptor fields within the first 1,024 bytes; it
never decodes or fetches their opaque targets. CAS has no verified sample.
v0.6.0 breaking inspection type change: DocumentInfo.pageCount is now
number | null, because CAA descriptors have no known page count. Callers
should handle info.pageCount === null before using it as a number; the
Format union also includes "caa". Existing successful document inspections
still return numeric page counts.
TTKN response input
For the measured TTKN server profile, pass
{ ttknResponse: response } to convert. It must be exactly 32 hexadecimal
ASCII characters with its original case. Node and browsers use the same
option; no embedded authentication URL is contacted. inspect does not
accept this option. Other encrypted profiles remain unsupported.
API
convert(wasm, source, sink, options)detects the format from at most five leading bytes, or 1,024 when looking for a displaced%PDF-header (header offset rule), or usesoptions.format, and resolves with{ format, inputBytesRead, outputBytesWritten, pagesConverted, substitutedGlyphs, bookmarksWritten, omittedPages, outlineWarnings, outlineOmitted }.inspect(wasm, source, options)resolves with{ format, pageCount, bookmarkCount, outlineWarnings, applicationInfo, inputBytesRead }without output.bookmarkCountis validated/countable for CAJ and HN-A; it isnullfor PDF, KDH, C8 and HN-B (unknown, not zero). HN-A validation streams one outline record at a time and reads no image payloads.outlineWarningscounts HN-A bookmark defects. An entry with an invalid title, page or zero level is skipped and its children are re-parented; a level that skips a parent is clamped. The rest of the outline and every page are still written, andbookmarkCountis what conversion writes. The count is0for other formats. Located per-entry reasons are listed by the CLI (see docs/cli.md); earlier releases rejected these documents.applicationInfois{ doi, url, noteCount }from a C8 application-info package, the same values the CLI'sinspectprints and conversion writes to the PDF/Info. It isnullwhen there is no package, when the package is defective (conversion ignores it too), and for other formats. A missing field isnull. The DOI is a CNKI identifier, not a verified DOI, and the URL is never followed. Detecting the package reads only the last 32 bytes of the source.wasmis theWebAssembly.ModulefromloadModule(). Each call starts its own Worker and instance, so calls may run concurrently.- Options:
format("auto"by default),limits,chunkSize(bytes per request, default 256 KiB, at most 1 MiB),signal,progress(called with the fraction of the input read, 0 to 1, never decreasing), and forconvertincludeBookmarks(defaulttrue),allowDamagedandhnc8. limits:maxInputBytes(8 GiB),maxOutputBytes(16 GiB),maxAllocationBytes(64 MiB; at most 256 MiB and at leastchunkSize; a single allocation limit, not a total memory budget),maxPagesandmaxBookmarks(100,000). JavaScript validates them and the Rust engine enforces them.- Errors are
Caj2PdfErrorwith a stablecode(for exampleMALFORMED_CAJ,PDF_LIMIT_EXCEEDED,TRUNCATED_INPUT) and the core's located message. The code follows the core error's kind and format context:IOandCANCELLEDfor any format;HNC8for any other HN/C8 or JBIG2 failure;TRUNCATED_INPUT; the PDF codes (MALFORMED_PDF,ENCRYPTED_PDF,UNSUPPORTED_PDF_FEATURE,AMBIGUOUS_PDF_REPAIR,PDF_LIMIT_EXCEEDED),CAJ_LIMIT_EXCEEDED,MALFORMED_CAJandMALFORMED_KDHfor those formats; thenLIMIT_EXCEEDED,UNSUPPORTED_FORMATandINVALID_INPUT.UnsupportedFormatErroraddsformat. Sink errors, abort reasons and errors from inputs read on the calling thread (Node Blobs) propagate unchanged; a read that fails inside the Worker rejects with anErrorof the samenameandmessage.
Inputs and sinks
| Entry | Accepted inputs (documents and fonts) | How they are read |
| --- | --- | --- |
| Browser | Blob or File; OPFS FileSystemFileHandle | In the Worker: FileReaderSync over one slice() per request; a synchronous access handle the Worker opens and closes. |
| Node.js | File path (string or file: URL); open file descriptor (for a FileHandle, its fd); Blob | In the Worker with fs.readSync (a path is opened and closed by the Worker; a descriptor stays open); a Blob is sliced on the calling thread and handed over through shared memory. |
Inputs are caller-owned and must stay unchanged until the operation settles.
A size is taken once, before the Worker starts; a file that shrinks later
fails with TRUNCATED_INPUT. A path or descriptor must name a regular file.
interface SequentialSink {
writeChunk(bytes: Uint8Array, signal?: AbortSignal): Promise<number>; // bytes accepted
flush(signal?: AbortSignal): Promise<void>;
}The sink runs on the calling thread. Each bytes is a fresh copy from the
Worker, so a sink may keep it. writeChunk may accept a prefix; the rest is
offered again.
| Adapter | Entry | Behavior |
| --- | --- | --- |
| webWritableSink(writer) | both | Awaits each writer.write(); flush awaits writer.ready. Never closes the writer. |
| nodeWritableSink(writable) | Node | Awaits each write callback, so at most one chunk is queued. Never ends the stream. |
| convertReadable(wasm, stream, sink, options) / spoolToTempFile | Node | Spools a Node Readable, Web ReadableStream, or async iterable to a private file (mode 0600) in a fresh mkdtemp directory under os.tmpdir() (or options.tempDirectory); the spool's source is its path. |
| convertReadableStream(wasm, stream, sink, options) / spoolToOpfs | Browser | Spools a ReadableStream to a uniquely named Origin Private File System file; the spool's source is its file handle. |
A plain stream has no size or random access, so it is never converted
directly and never buffered whole in memory. The spool accepts at most
maxSpoolBytes (default limits.maxInputBytes, 8 GiB) and rejects with
LIMIT_EXCEEDED as soon as more arrives. convertReadable and
convertReadableStream remove the spool after success, failure, sink error,
or abort; the lower-level spool functions return dispose() for the caller.
The stream pump releases its owned Web reader on EOF or failure. On failure it
initiates cancellation before release; stalled or rejected producer cancellation
cannot delay or replace the primary error. Node spool writes validate positive,
in-range progress and check cancellation around each awaited partial write.
Browser storage support
The browser spool needs navigator.storage.getDirectory() and
FileSystemFileHandle.createWritable() in a secure context (HTTPS or
localhost). Chromium-based browsers and Firefox provide both on the main
thread; Safari's support for createWritable() depends on its version. The
automated tests confirm the spool, its bound, and its cleanup against the
real OPFS of headless Chromium on the main thread and in a Dedicated Worker;
Firefox and Safari are not tested automatically.
When either API is missing, the spool
rejects with RANDOM_ACCESS_REQUIRED instead of falling back to memory; pass
a Blob/File (which browsers keep disk-backed). OPFS writes count against
the origin's storage quota.
Bilevel output compression
Bilevel image streams use lossless Flate compression. JPEG streams are copied
unchanged. PDF byte snapshots/hashes change even when pixels and layout do not.
For bilevel conversion, limits.maxAllocationBytes must be at least 512n *
1024n for the fixed compressor reservation (the default 64 MiB already covers
this). This is a per-allocation requirement, not a whole-process memory budget.
See the compression measurements.
Workers and cross-origin isolation
Every convert and inspect call runs in a fresh Worker: a module
Worker in browsers (the package's internal/worker.mjs, so serve the
package directory as is) and node:worker_threads in Node.js. The calling
thread only awaits the sink and reports progress, so a page stays responsive.
The API also works from a caller's own Dedicated Worker, which then starts a
nested Worker.
Bundlers may emit the internal Worker as a classic script. Its module graph
has no top-level await, so Vite's default worker.format: "iife" works;
no global Worker-format override is needed.
A shared control block (SharedArrayBuffer) carries cancellation and sink
acknowledgements: the Worker pauses after eight chunks the sink has not yet
taken, and an abort stops it at its next checkpoint, after which it closes
its inputs (an OPFS file is free again when the promise rejects). Node.js
always has shared memory. Browsers provide it only to cross-origin isolated
pages; serve the page with
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corpWithout isolation, conversion still works, but output is not throttled by the sink (the Worker may run ahead and queue chunks in memory) and an abort terminates the Worker at once. Node Blob inputs always use shared memory.
The browser example calls convert from the page; the HTML file needs no
Worker of its own. HN/C8 rendering remains experimental with the documented
layout limitations. C8/HN-B outline layouts are unverified: with bookmarks
requested they convert with no outline and the report's outlineOmitted is
true. Pass includeBookmarks: false (--no-bookmarks in the Node example)
to skip it.
Experimental HN/C8 conversion
convert uses the built-in standard QM/MQ states and needs no HN/C8 options
for image pages. Type-0 (JBIG1) rows stream straight to the PDF; type-3
(JBIG2) symbol and text bitmaps are held in WASM memory, each allocation
capped by limits.maxAllocationBytes. No temporary storage is created.
// No external state-table files or scratch storage are needed.
const result = await convert(wasm, source, sink, {
includeBookmarks: false, // C8/HN-B outlines are unverified and would be omitted anyway.
});HN-A/C8 type-1 and type-2 JPEG images share the bounded validation/emission path. The HN/C8 adapter also selects the documented unused-refinement-template interoperability policy; generic JBIG2 parsing remains strict. See the type-1 profile and checks.
HN-A outlines are supported. C8/HN-B currently require explicit
includeBookmarks: false. Admitted native C8/HN-B text pages use the
automatic font preset below;
unverified content is rejected rather than silently omitted. The shared HN/C8
route admits only the documented unused-refinement-template anomaly. Other
malformed JBIG2 headers remain errors. Image-only pages receive no OCR text
layer; Unicode, whitespace and reading-order limits
remain separate from native glyph rendering. HN/C8 inspection validates metadata without implying
that the document can be converted. Located conversion and metadata failures use error code
HNC8; an I/O failure or a cancellation during an HN/C8 operation reports IO or
CANCELLED like any other. Standard numeric state adoption is recorded in #189.
v0.x migration
HN-A/C8 output now follows declared page/image extents and omits DIB padding. PDF page sizes, image widths and hashes change; regenerate affected snapshots. Zero extents are rejected. The physical unit remains empirical; see the geometry correction.
HN/C8 conversion no longer always throws UnsupportedFormatError: callers must
handle HNC8 and invalid configuration errors. Existing
PDF/CAJ/KDH calls do not need hnc8. Rust users match HN/C8 failures by
their Context::Hnc8 error context.
HN/C8 inspection now succeeds for valid metadata; malformed headers/outlines
use HNC8 instead of a blanket unsupported-format error. C8/HN-B unknown
bookmark counts remain null, while validated empty HN-A outlines return zero.
Migration to the Worker API (#355)
convert/inspecttake theWebAssembly.ModulefromloadModule()and an input from the table above instead of a{ size, readAt }source. ReplaceblobSource(file)withfile; in Node.js replacefileHandleSource(handle)withhandle.fdor the path.withHnc8Scratch,fileHandleScratch,syncAccessHandleScratch, thehnc8.scratchoption andcheckRangeare removed; drop them.- Spooled
sources are now a file path (Node.js) or an OPFS file handle (browser); pass them toconvertunchanged. - A browser page that needs prompt cancellation or sink backpressure must be
cross-origin isolated (above). Custom JavaScript
readAtsources are no longer accepted; wrap such data in aBlobor spool it. - The raw WASM ABI changed completely: see the I/O architecture.
Bounded memory and I/O
Rust requests one range or writes one chunk at a time, at most chunkSize
bytes. A read lands directly in Rust memory; a write is copied once by the
Worker and transferred to the calling thread. No API accepts or returns a
whole-document byte array. Format indexes (page tables, bookmarks, object
offsets) and HN/C8 bitmaps are held in WASM memory under limits.
Converting a 25,186,757-byte one-page PDF with the default 256 KiB chunk grew WASM memory from 1,245,184 bytes after instantiation to a peak of 1,769,472 bytes, independent of the document length; the largest read and write were each 262,144 bytes. Current browser/Node measurements, temporary-storage results, and reproduction commands are in JavaScript validation.
Cancellation
The core checks for cancellation between rows, pages and I/O chunks. On abort,
the promise rejects with signal.reason (an AbortError by default). With
shared memory the Worker stops at its next checkpoint and closes its inputs
first (a Worker that has not stopped after 10 seconds is terminated);
without it the Worker is terminated at once. A sink write already in progress
is not awaited. Bytes a sink already accepted are not withdrawn: callers own
their output and should delete or discard it after a rejection (the examples
do).
Build and test
cargo build --locked --release --all-features -p caj2pdf-wasm --target wasm32-unknown-unknown
node js/scripts/copy-wasm.mjs
node --test js/test/*.test.mjsnpm run build:wasm (run inside js/) builds the module and copies it next
to the entry points as caj2pdf_wasm.wasm (scripts/copy-wasm.mjs, mode
0644), which is where loadModule() looks by default. That copy is
gitignored and is never committed. Examples use this same packaged module path,
including when Cargo builds into a custom target directory. The files list puts it in the tarball,
and prepack refuses to pack without a WebAssembly module there. The release process (release policy) tests the
actual tarball on Node.js and Chromium before publishing it.
Tests
js/test/*.test.mjs run with node --test against the real WASM build:
convert.test.mjsconverts synthetic CAJ, KDH, and PDF inputs from Blobs into Web sinks and from paths,file:URLs and descriptors into Node sinks, asserting every write is within the chunk size and validating each output withqpdf --checkandqpdf --show-npageswhenqpdfis installed (otherwise a diagnostic reports the skip; the CI WASM job installs it). It also coversinspect, TEB rejection, typed errors, limits, cancellation, progress, sink and source errors.hnc8-convert.test.mjsandc8-fonts.test.mjscover HN/C8 images, inspection and native C8/HN-B fonts from Blobs, paths and spools.spool.test.mjscovers Node temp-file spooling from Node and Web streams, the spool bound, cleanup after success, failure, and abort, and the OPFS spool against an in-memory OPFS test double.browser.test.mjsrunsbrowser.mjsin headless Chromium (below).browser-example.test.mjsdrives the actual HTML example in Chromium, checks failed/cancelled output cleanup, validates a downloaded PDF, and verifies replacement/discard removes OPFS files and revokes download URLs.package.test.mjsrunsnpm packon a temporary copy of the package with the WASM build and asserts the tarball holds exactly the entry points, declarations,caj2pdf_wasm.wasm,package.json,LICENSE, andREADME.md. It extracts the actual tarball into a fresh consumer, checks Node package exports and default WASM loading, and converts CAJ through the packed browser entry in Chromium. Packing without a valid WASM build must fail.examples.test.mjsruns the Node example as a subprocess for CAJ/KDH/PDF/HN files and stdin, validates output PDFs, and checks missing/malformed inputs, existing-output preservation and usage errors.adapters.test.mjscovers input validation and the sinks;wasm.test.mjscalls the raw ABI directly (short I/O, typed host failures, cancellation, re-entry and configuration checks).corpus.test.mjsruns the optional corpus runner (below) against a synthetic corpus and matrix built at test time.
The other tests run the browser adapters on Node's Blob, ReadableStream,
and WritableStream. The inputs are synthetic MIT fixtures from
tests/fixtures or built at test time; no external corpus test runs here.
Optional external corpus
scripts/corpus.mjs runs the external
CAJSamples documents inventoried in
tests/conformance/matrix.json through
this package. It never fetches the corpus, and no corpus file is committed:
cargo build --locked --release --all-features -p caj2pdf-wasm --target wasm32-unknown-unknown
CAJ2PDF_CORPUS_DIR=/path/to/CAJSamples node js/scripts/corpus.mjs \
[--matrix PATH] [--wasm PATH] [--qpdf PATH]The repository records no per-sample Rust outcome, so each entry's
expectation comes from the API's format contract and the matrix's
expected_outcome, classified as
scripts/conformance.py does:
| Entry | expectation | Requirement | Outcome when met |
| --- | --- | --- | --- |
| HN/C8 in the corpus runner | not_run | Runner has no font configuration; conversion is not attempted | NOT_RUN |
| TEB | unsupported | UnsupportedFormatError | unsupported |
| CAJ, KDH, or PDF; reference success | convert | Output validated with the reference output page count | passed |
| CAJ, KDH, or PDF; reference error or unsupported | excluded | None recorded; conversion runs and is reported as observed | excluded |
| CAJ, KDH, or PDF; reference unknown | not_run | None recorded; conversion runs and is reported as observed | not_run |
For each matrix entry, in order, it:
- Checks every path component with
lstatand refuses a symbolic link as the file or any parent directory (matrix paths have no..components), opens the file without following links, and requires the inode it checked. It then streams the file once through SHA-256 and the Git blob SHA-1 with one 1 MiB buffer and compares both and the size with the matrix. - Converts the same handle, by descriptor, into
nodeWritableSinkover a private file (mode0600) in a freshmkdtempdirectory, which is removed after success, failure, timeout, or interruption. Each conversion has a 10-minuteAbortSignal.timeoutand a 4 GiB output limit. - When a conversion succeeds, requires the detected format to match,
qpdf --checkto exit 0 with noWARNING:line, andqpdf --show-npagesto equalpagesConverted. Aconvertentry also requires the matrix page count (expected_pdf.page_count, elsepage_count). Each qpdf call has the same timeout and 1 MiB of captured output.
A failed requirement is failed. For excluded and not_run entries, a
typed input rejection (Caj2PdfError other than CANCELLED, IO,
LIMIT_EXCEEDED, or UNKNOWN) or a validated output is recorded in
observed; a timeout, I/O error, WASM trap, or invalid output still fails.
A qpdf warning fails, as in check_qpdf_log in
scripts/jbig2_oracle.py and the Rust KDH
corpus test. After all conversions it re-verifies every source by path.
Progress goes to stderr and a JSON report to stdout:
{ status, reason, sample_count, qpdf, checked, passed, failed, unsupported, excluded, not_run, failures, results }.
Each results row has id, format, reference (expected_outcome),
expectation, outcome, stage, reason, and observed.
| Situation | status | Exit |
| --- | --- | --- |
| CAJ2PDF_CORPUS_DIR unset or empty | NOT_RUN, all counts zero | 0 |
| Corpus missing, or any failed entry | FAIL | 1 |
| No failure, but a not_run entry (including outputs not validated because qpdf is missing) or no passed entry | NOT_RUN | 0 |
| No failure or not_run entry, at least one passed | PASS | 0 |
| Invalid matrix, arguments, or WASM module | setup error | 2 |
| SIGINT or SIGTERM | interrupted after cleanup; no report | 130 |
unsupported and excluded are never passes and, as in conformance.py,
do not block PASS. Page counts and qpdf --check do not compare page
order, rendering, or outlines with the reference PDFs, so the known
reference differences (issue-40/issue-44 page order and
issue-49/issue-73 outlines; see CAJ format notes)
are outside this check. A timeout aborts at the next I/O call; a WASM loop
that never returns to I/O is not interrupted. The CI WASM job runs the
script with an empty CAJ2PDF_CORPUS_DIR and asserts NOT_RUN with zero
counts.
Real-browser tests
browser.test.mjs needs no npm packages. It serves the js/ directory
read-only (GET and HEAD, no paths outside it, including through symbolic
links) on an ephemeral http://127.0.0.1 port with node:http (a secure
context) with cross-origin isolation headers, starts
headless Chromium with a throwaway profile and --remote-debugging-port=0,
and drives it over the Chrome DevTools Protocol with Node's global
WebSocket (browser-harness.mjs). The page
imports browser.mjs, loads the freshly built WASM from its default URL,
and runs
browser-cases.mjs:
Filesources to realWritableStreamsinks for synthetic CAJ, KDH, and PDF inputs, with every write at most the 4 KiB chunk size and progress reaching 1, and an OPFS file handle as the source. The outputs return to Node (base64 plus a SHA-256 computed withcrypto.subtle) forqpdf --checkand page counts.- HN/C8 conversion from the page's own Dedicated Worker, with fonts from OPFS file handles and Blobs.
- HN/C8 inspection validates source metadata; conversion uses the experimental image-page path.
AbortSignalcancellation while aWritableStreamwrite is stalled.convertReadableStreamthrough the real OPFS: onecaj2pdf-spool-*file exists during conversion and none after success, themaxSpoolBytesbound, an unsupported input, or an abort.
Chromium is found through CAJ2PDF_CHROME, then /usr/bin/google-chrome,
/usr/bin/chromium, and Playwright's /opt/pw-browsers directory. Without
one the tests are skipped locally and fail when CI is set:
CAJ2PDF_CHROME=/path/to/chrome node --test js/test/browser.test.mjsChromium is started with --no-sandbox, --no-proxy-server, background
networking disabled, and host resolution restricted to 127.0.0.1. Every
DevTools command has a 30-second timeout. After the run, or when the test
process exits early or receives SIGINT or SIGTERM, the Chromium process
group is killed and its throwaway profile removed. The CI WASM job runs
these tests with the runner's preinstalled Google Chrome on Node 22 and 24.
Firefox and Safari are not covered, and native file-picker/save dialogs are not automated. The example
page and the packed-package check run without cross-origin isolation, so
both Worker modes are exercised. The example page's OPFS fallback is tested
through actual Chromium File and storage APIs.
An OPFS spool failure normally preserves the original error. Cleanup retries
only a transient NoModificationAllowedError (at most three attempts, with
10 ms and 50 ms delays). If the failed spool still cannot be removed, rejection
is an AggregateError: cause and errors[0] hold the original failure,
errors[1] holds the removal failure. The temporary file may then remain.
convertReadableStream, convertReadable and convertSpooled also expose
this combined failure when conversion fails and its already-created spool
cannot be removed. Callers that inspect
error.name or error.code should first inspect error.cause when catching an
AggregateError, and also report its cleanup error. A successfully cleaned
conversion still rejects with its original error object.
Fonts for native C8 and HN-B pages
By default convert() automatically loads the bundled fonts and selects all
mode-specific parameters. Node reads the package resource; the browser streams
it into an OPFS cache on first native-text conversion and verifies it on reuse.
Serve caj2pdf-fonts-v1.bin alongside the package assets (bundlers discover its
new URL() reference). Secure-context OPFS and Web Locks are required.
Inspection and image-only conversions do not load fonts. The pack is about
58.6 MiB before compression, kept outside the WASM heap; full embedded programs
can increase output PDF size. See bundled fonts.
Advanced font overrides
To override the entire preset, the admitted native C8 and HN-B text/vector
profiles accept caller-owned fonts, given as inputs of the same kinds as the document. Only cjk and
latin are required:
await convert(wasm, documentSource, outputSink, {
includeBookmarks: false, // C8/HN-B bookmarks are not supported yet.
hnc8: {
fonts: {
cjk: cjkFontSource,
cjkSans: sansFontSource, // Optional native CJK Hei/sans styles.
cjkKai: kaiFontSource, // Optional native CJK Kai styles.
cjkSystem: systemFontSource, // Explicit font for measured system-font styles.
latin: latinFontSource,
// Optional roles; absent ones use the fallback rule below.
alternateLatin: alternateFontSource, // 801d/4 Latin resource.
decoration: { source: decorationFontSource, character: "►" },
symbols: symbolFontSource, // Semantic HN-B mode-0 symbols/space.
// Explicit symbols glyphs by raw HN-B mode-0 code; text is unchanged.
symbolGlyphs: [{ code: 0xa1af, glyph: "\ue000" }],
// Optional: refuse any symbols font other than the measured one.
symbolFontIdentity: { postscriptName: "HGFX_CNKI", checksumAdjustment: 0x9a2b73d3 },
latinState3: state3FontSource, // HN-B/C8 801d/3 Latin resource.
latinState28: state28FontSource, // C8 801d/28 resource.
latinState31: state31FontSource, // C8 801d/31 resource.
},
},
});To preserve original TrueType programs, set hnc8.fonts.embedding: "full".
The default is "subset". Standalone programs keep their exact bytes;
collections preserve the selected face's original glyph IDs and tables in
a rebuilt standalone font, without DSIG. Used CFF resources reject "full";
unused resources remain subsets. Complete programs use bounded ranged reads
but can substantially increase output size.
hnc8.fonts.glyphAliases accepts an iterable of
{ role, character, glyph }, for example:
const fonts = {
cjk: cjkFontSource,
latin: originalLatinFontSource,
embedding: "full",
glyphAliases: [{ role: "latin", character: "1", glyph: "\u6571" }],
};This illustrates one measured resource mapping, not a complete font recipe.
Both characters must be BMP scalars. role names a supplied font property
(such as latinState3); its original cmap selects glyph, while extracted
text retains character. Source programs are never edited. Duplicate source/
character keys, including aliases through shared roles, and missing glyphs
reject conversion. At most eleven BMP maps are accepted, with Rust-side
allocation limits. Aliases also work with subsets. Explicit symbol-code maps
and nonsemantic decorations bypass ordinary aliases. Font inputs, transport
and source deduplication retain the same rules in Node and browsers.
symbolGlyphs selects, by its BMP character-map entry, the symbols glyph
drawn for each listed raw HN-B mode-0 symbol code. Extracted text keeps the
decoded character, so codes that decode alike can keep distinct source
shapes. It requires symbols. A non-symbol or repeated code, a glyph the
font lacks, or a native document other than HN-B mode-0 rejects the
conversion; listed codes never fall back. No mapping is built in (#518).
symbolFontIdentity (a PostScript name and the head table
checkSumAdjustment) binds the map to the font it was measured on: any other
symbols font rejects the conversion. It requires symbolGlyphs.
cjkSans, cjkKai and cjkSystem select the measured CJK style resources
in C8 and HN-B native modes 0/2; the original style is used before geometry
normalization. Explicit 801c/2, /3, /4 override the family with sans,
Kai and base CJK; the next 8002 resets it. They use the same ranged font inputs and face selection as
cjk. Optional roles can share sources. The maximum is eleven distinct
font sources. These roles select Han and, in C8, fullwidth Latin letters,
ideographic space and fullwidth ampersand. Ordinary Latin and the raw low-byte
006c/0070 letters keep their existing resource rules. These options do not
perform system font discovery. See the
CLI style table and
Wine measurements.
Fallback rule (implemented in the core, so CLI, Node and browser
behave the same): each glyph uses the font of the role the source selects.
If that role is absent, or its font does not map the character, CJK-coded
characters (CJK blocks and halfwidth/fullwidth forms) use cjk and every
other character, including the decoration alias ►, uses latin. A glyph
missing from that font still fails with its page and source byte. There
is no system-font lookup. The CLI's --fonts DIR lookup is not part of
the JavaScript API: custom font files are passed as sources.
Set hnc8: { fonts: false } to disable automatic fonts entirely.
Fonts may be supplied for any HN/C8 input. The core inspects the header and
the first page with text: only a C8/HN-B document in a native rendering mode
whose text holds native records uses the fonts. HN-A and other image documents are converted as images,
byte-identical to a conversion without hnc8.fonts, and the font sources
are not read. The CLI applies the same rule.
Optional free-font override for Node. These alternative fonts are installed separately. See docs/cli.md for the installation command and the supported font formats:
import { convert } from "caj2pdf-rust";
await convert(module, "paper.c8", outputSink, {
includeBookmarks: false,
hnc8: {
fonts: {
cjk: "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf",
latin: "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
},
},
});Fonts may have TrueType or CFF outlines. A role may instead be
{ source, face } to use face face of a collection (.ttc); a plain input
is face 0. Reuse the same input (the same string, number or object) across
roles to embed it once; two different inputs are two resources even when
they hold the same bytes. Character coverage alone does not guarantee compatible widths or
bearings, or prevent overlap at fixed source positions. See the
same-resource controls.
The optional decoration character is a nonsemantic BMP alias, not document
text. Roles exist because source role selection differs from Unicode/script
selection.
These roles choose caller resources, not complete source typography. The Wine investigation retains ordinary native glyph mappings, CJK style selection and same-outline positioning gaps. Font-name equality and embedded-font coverage are insufficient; the HN-B mode-0 symbol map is not a general C8 ordinary-character map. Preserve semantic Unicode separately from any measured font lookup alias.
In C8 CJK character mode (80ce/0), raw a0 digits and Latin letters select
the measured raw-GBK alias in the active CJK font. ActualText keeps their
source ASCII characters, and substitutedGlyphs stays unchanged. An absent
optional style role uses cjk; a selected font missing its alias is an error.
There is no ASCII-glyph or different-resource fallback for this case.
80ce/1 restores ordinary Latin selection. Extractors must honor ActualText
to return the semantic character instead of the display alias.
The default ► is a visible substitute. A caller-supplied, hash-verified
HGBZ resource can select all four measured ornament patterns in one document:
const decorationOptions = {
decoration: { source: decorationFontSource, character: "\u5d41" },
decorationGlyphs: [
{ value: 1, glyph: "\u5d04" }, { value: 2, glyph: "\u5d05" },
{ value: 46, glyph: "\u5d41" }, { value: 117, glyph: "\u5d95" },
],
decorationFontIdentity: { postscriptName: "HGBZ_CNKI", checksumAdjustment: 2632420189 },
};
// Spread decorationOptions into hnc8.fonts alongside cjk and latin.decorationGlyphs consumes at most five iterable entries before rejecting a
map longer than four. Values must be unique and glyphs must be BMP scalars.
An explicit decoration role is required; missing mapped glyphs fail instead of
falling back. The optional identity binds the map to the measured font and
requires a nonempty map. Only native mode 2 accepts the map; admitted record
states are unchanged. Unlisted values retain decoration.character and the
legacy fallback. These aliases draw nonsemantic artifacts with empty ActualText.
See the measured resource identities. This does not
establish ordinary-text or pixel fidelity.
Keep fonts unchanged until conversion settles; the converter does not close
caller-owned descriptors. Forward-only fonts can use the existing bounded
spooling helpers; dispose the spool in finally. The caller must discard
partial output after failure, including a final flush failure.
This enables only the independently admitted native profiles. HN-B mixed images and unverified glyph/style combinations remain explicit errors; source-font identity is not inferred. Use the matching JavaScript and WASM builds; older WASM binaries cannot accept font registration. Existing image-only conversions require no font options.
Damaged CAJ inputs
Pass { allowDamaged: true } to explicitly permit blank substitutes for
unrecoverable CAJ pages. Always inspect report.omittedPages: each entry has a
zero-based pageIndex and an absolute source offset (bigint). Conversion
resolves with the report when it produces a partial PDF; I/O, cancellation,
resource-limit, unsupported-feature, and unavailable-geometry errors still
reject. Shared damaged resources may affect multiple pages. The default is
strict conversion. This option applies to CAJ, not every recognized format.
Private-use glyph substitutions
For the measured HN-B A661 profile, report.substitutedGlyphs counts glyphs
rendered with a visual approximation because the supplied font lacks U+E6C7.
The PDF retains U+E6C7 in ActualText, without guessing a standard Unicode
identity. Supply an appropriate U+E6C7 glyph to avoid the substitute; missing
both that glyph and its documented display alias remains an error. Extractors
that ignore ActualText can return the display alias. Use the JavaScript package
with its matching WASM build. See the
conformance limits.
