Architecture
One Rust engine, vendored into two bindings, with dispatch and chunking in the engine and almost nothing in the bindings — and how that is enforced.
chunk-engine is one Rust library, rs-chunks, plus two bindings that add as
little as possible on top of it. "Byte-identical across languages" is not a
testing outcome here; it is a consequence of there being exactly one
implementation of every decision that affects output, and of a build that fails
when a copy of that implementation drifts.
This page describes how the pieces actually fit together, and where the honest seams are.
1. Three trees, one engine
| Tree | What it holds | Ships as |
|---|---|---|
rs-chunks/ | The engine. All parsing, all chunking, all metadata. | crates.io rs-chunks (library name chunks_rs) |
py_chunks/ | A PyO3 binding + a small Python layer, over a vendored copy of the engine. | PyPI py-chunks |
js-chunks/ | A wasm-bindgen wrapper crate + a TypeScript layer, over a vendored copy of the engine. | npm js-chunks |
The two bindings each carry a copy of the engine crate rather than a path dependency, because each publishes to its own registry from its own repository and must build from a standalone clone. A copy that nothing checks is a copy that drifts, and drift in this project means the parity claim quietly becomes false — so the copies are managed by a script, not by hand.
sync_engine.sh overwrites both vendored copies from rs-chunks/src with
--write, and with no arguments it diffs them and exits non-zero if either
has moved. It copies the manifest's referenced files too (README.md,
LICENSE), because a manifest pointing at a missing file makes maturin sdist
refuse to build — which is how a release once broke. It also checks that the
website's vendored playground WASM matches the current pkg-web build, so the
playground cannot silently demonstrate an old engine.
A stray .rs file is a build failure
py_chunks/src/extensions/<format>/ is binding glue only: mod.rs and
chunkers.rs, nothing else. sync_engine.sh fails if any other .rs file
appears there, unconditionally. Such a file would shadow the vendored engine
and never be overwritten by --write — which is precisely how three
divergence defects were produced before the fork was removed in August 2026.
All 17 format modules now take their engine from the vendored crate.
2. The life of one call
The layering is the part most often described backwards. Routing by file
extension happens inside the engine, in rs-chunks/src/dispatch.rs — not in
the bindings. What the bindings do is turn whatever the caller passed into
something the engine can take, and turn the result back into a native shape.
- Normalize the source. JavaScript resolves a path (Node only), a
Uint8Array, anArrayBufferor aBlobdown to{ data, filename }and stops there — it performs no routing at all. Python is split: a path goes through a Python-level routing table, and bytes go straight into the engine's dispatch. Rust callers are already holding a path or a slice. - Enter dispatch behind a panic boundary. All six public dispatch
functions wrap the parse in
catch_unwind. A panic anywhere in the engine, or in a third-party parser reached from it, becomesChunkError::Parse("internal parser panic: …")instead of unwinding into the caller's runtime — which for a Python or WASM host is not something the host can be expected to survive. - Route on the extension.
dispatch.rslowercases the extension and matches it to a format module, applying the delimited/spreadsheet argument remapping on the way (see the sentinel note in the API reference). An unknown extension isChunkError::Unsupported. This is why byte input requires afilename: the extension is the only routing signal, and nothing sniffs content. - Parse and chunk in the format module. The format produces
Vec<Chunk>—{ content: String, content_type: String, metadata: Value }. - Shape the result. PyO3 converts to
list[dict]; the WASM wrapper serialises to plain JS objects and renames the single fieldcontent_type→contentType; Rust hands back theVec<Chunk>unchanged.
Correcting an older version of this page
This page used to say that Python writes non-path inputs to a temporary file
with the right extension. That has not been true since 0.6.1. Bytes, file
objects, framework uploads and URL downloads all go straight into the
engine's no-filesystem API (get_chunks_from_bytes and friends) with no disk
round-trip.
One path still materialises a temp file: streaming from bytes
(stream_chunks_from_bytes, and the file-object / upload / URL streaming
helpers that funnel into it). The engine's streaming surface is per-format
and path-based — there is no dispatch::stream and no
stream_*_from_bytes on the bytes binding, except for pdf and xlsx at
the format level — so the Python layer writes a temp file, opens the
iterator, and deletes the file when the iterator is exhausted, closed, or
garbage-collected.
3. The format facade and the shared Markdown pipeline
Every format module exposes the same facade — chunk, chunk_from_bytes,
chunk_with_options, stream, to_markdown, to_markdown_from_bytes, and on
the twelve formats with embedded binary parts, the *_with_images variants.
That uniformity is what lets dispatch.rs be a flat match statement instead of
seventeen special cases.
Behind the facade the formats are not all the same depth of work, and the single most explanatory fact about this codebase is how little of it is per-format:
Seven formats implement only a load step. json, eml, odf, msg,
ipynb, rtf and pdf each parse their own container into one struct:
pub(crate) struct Loaded {
pub markdown: String,
pub images: Vec<(String, Vec<u8>)>,
pub metadata: serde_json::Value, // becomes document_metadata on every chunk
pub records: Option<Vec<usize>>, // record start blocks, for .json/.jsonl/.ndjson
}…and then hand it to formats/pipeline.rs, which runs the Markdown
chunker over loaded.markdown and stamps the results. Every mode, every
boundary decision, every content_type for those seven formats is the .md
implementation. A .msg email and a .ipynb notebook chunk identically to the
Markdown you would get from get_markdown on them, because that is literally
the code path.
The stamping step is where the two format-specific extras get attached:
document_metadata (the whole Loaded.metadata object) on every chunk, and —
only for a format that has records — a record_range naming the records a
chunk was built from. Formats without records gain nothing, which is why adding
record tracking changed no output for .md, .html, .txt or .pdf.
Two more reuse relationships exist alongside it: epub chunks through the
HTML builders, and legacy .ppt runs on the .doc extraction stack. That
leaves eight formats — md, html, txt, docx, pptx, doc, csv,
xlsx — with block models of their own.
4. How each binding is built
Python — PyO3
- One wheel per platform covers CPython 3.9+. The binding builds against
pyo3's
abi3-py39stable ABI, matchingrequires-python = ">=3.9". Before 0.6.1 only 3.13 got a wheel and everyone else fell back to a source build. - The GIL is released around every engine call. There are 23
allow_threadssites — inside the binding macros, inside the sharedrun()helper the hand-written CSV and XLSX functions use, and around each individualnext()pull in the streaming iterators. A long parse does not block other Python threads, and a stream releases the GIL per chunk rather than for the whole iteration. - The pyfunction surface is macro-generated.
bind_format!expands one invocation into eight#[pyfunction]s — six per-mode chunkers,<fmt>_to_markdown, and a streaming constructor — plus aregister()that adds them to the module;bind_images!adds the two image functions;bind_per_mode_format!covers.doc/.ppt/.docx, which historically shipped one function per mode. Onlycsvandxlsxare hand-written, because their argument lists do not fit the macro's shape. A format's binding file is therefore usually a few lines, and the per-format surfaces cannot drift apart by accident. - The package ships
py.typedand a stub for the compiled module, so type checkers see the whole surface.
JavaScript — wasm-bindgen
crates/chunks-wasmis a thin wrapper crate over the vendored engine, compiled to WebAssembly.src/index.tsis the ergonomic layer on top: source normalization, options defaults, overloads, and error wrapping.- Two builds, two init models. On Node the package requires the
pkg-nodeCommonJS build, which instantiates the module synchronously at require time. Everywhere else it dynamically imports thepkg-webbuild and awaits its initialiser. Either way the module is loaded lazily on the first call and cached. Bundled browser apps importjs-chunks/webinstead — the raw positional wasm-bindgen surface — and await the init themselves with an explicit.wasmURL, because a dynamic import of a relative file beside a.wasmdoes not survive bundling. - Two serializer flags are load-bearing for parity. The wrapper serialises
with
serialize_maps_as_objects(true)sometadataarrives as a plain JS object rather than an ESMap, andserialize_missing_as_null(true)so a metadata key whose value is absent arrives asnullrather thanundefined. Without the second flagJSON.stringifywould silently drop keys likesection_headingthat py-chunks and rs-chunks emit asnull, and the JSON of a JavaScript chunk would stop matching the JSON of a Python one for reasons that have nothing to do with chunking. - One deliberate capability divergence: no PDFium in WASM. PDF parsing is
pure Rust and compiles to
wasm32, so JavaScript runs the same PDF parser as the other two SDKs. PDF page rasterisation is behind thepdf-nativeCargo feature (PDFium via theliteparsecrate), and the wasm crate depends on the engine withdefault-features = false. A scanned PDF with no extractable text and no embedded page image therefore reports that it has no text in JavaScript, where the native SDKs return page renders.
5. The error model
The engine has one error type with four variants, and it is
#[non_exhaustive] as of 0.6.1 — downstream matches need a wildcard arm, so
variants can be added without a semver-major bump.
#[non_exhaustive]
pub enum ChunkError {
Unsupported(String), // this extension / capability is not handled
InvalidArg(String), // bad mode, bad window/overlap, bad delimiter
Parse(String), // the document could not be parsed or decoded
Io(std::io::Error), // underlying I/O failure
}Two things about how it reaches the other two languages are worth knowing.
The message text is the bare inner string, not Display. ChunkError's
Display prefixes the variant ("parse error: …"). Both bindings deliberately
bypass it: PyO3 raises the inner String, and the WASM layer builds a
js_sys::Error from the inner String and attaches the variant tag
out-of-band as a kind property, which the TypeScript layer reads back into
ChunkError.kind. That is what makes a JavaScript e.message byte-identical
to the message py-chunks raises for the same input, and it is a fix for a real
divergence: the WASM binding used to use Display, so every JavaScript message
carried a prefix Python's did not.
Which variant a failure carries is decided where the engine constructs it.
impl From<String> for ChunkError lifts an internal Result<_, String> into
ChunkError::Parse, and much of the per-format logic returns exactly that.
This used to leak: md, txt and html lifted their whole builder result
with map_err(ChunkError::Parse), and csv::chunk_from_bytes omitted its
range checks altogether, so overlap >= window_size came back as Parse for
the markdown-family formats but InvalidArg for DOCX/PPTX/XLSX. It has been
fixed: a shared options::validate_mode_args now runs before dispatch in
all three builders, and the missing CSV checks were added, so every
caller-argument mistake is InvalidArg for every format and on both the path
and the bytes route.
The last gap has since closed as well. EPUB does not route through the
markdown pipeline the way its sibling formats do, so it never reached
validate_mode_args at all and bad window arguments produced an empty chunk
list instead of an error — silently, because epub::extract::chunk_package
absorbs a per-spine-document failure as "this chapter produced no chunks". It
now calls its own epub::validate_args at all four entry points, after the
extension check and before the book is loaded, so the bytes route rejects
without parsing too. The kind of mistake now maps predictably onto the
variant for every format — and in Rust, keep the wildcard arm, because
ChunkError is #[non_exhaustive].
The JavaScript wrapper's own pre-engine boundary is fully inside this model:
- Argument validation the wrapper performs itself — a path used off Node, a
byte source with no filename, an unsupported
sourcetype — is thrown asChunkErrorwithkind: "invalid-arg". - The
optsobject is snapshotted once before anything else, by a sharedsnapshotOptsused by all five public entry points. Anullor non-objectopts, and a property getter that throws, both becomeinvalid-argrather than a rawTypeErroror the getter's own exception — and because the snapshot is taken up front, a getter can run at most once and never mid-parse. - Types and ranges are checked host-side because the wasm boundary takes
usizeand would otherwise coerce: a negative, fractional,NaNor non-numeric count and a non-stringfilename/modeare allinvalid-arg. Zero is deliberately left to the engine, whose message names the parameter the target format actually uses. - Reading a path on Node goes through
fs.readFileSyncbefore the engine is reached, and that call is wrapped too: a missing or unreadable file becomes aChunkErrorwithkind: "io", preserving Node's own message. ABlobwhosearrayBuffer()rejects reports the samekind: "io"— it is the same condition, the source's bytes could not be read. There is no input for which the JavaScript API throws a non-ChunkError. See Error Handling.
Python's own layer raises before the engine too — FileNotFoundError,
ValueError, TypeError, NotImplementedError — for the same reason. The
full per-language contract is in the
API reference.
6. Streaming, honestly
Streaming in this project is per-format and path-based. There is no
dispatch-level stream function; formats::<fmt>::stream(path, …) is the whole
surface, and pdf and xlsx add a bytes variant. Every format has a stream
entry point, but that says nothing about whether it saves memory — most of them
chunk the document in full and then hand the vector out one element at a time.
What is genuinely incremental, per runtime:
| Runtime | Genuinely incremental | Everything else |
|---|---|---|
| Rust | pdf in default mode (page-at-a-time, with a 64-deep bounded channel on native builds, so the producer paces itself); xlsx in row/default and sliding_window; csv in the four modes its streaming iterator accepts (row/default, sliding_window, page_aware) | Every other format and mode: full parse, then lazy drain |
| Python | The same pdf and xlsx cases — the binding pulls one item at a time from the engine iterator, releasing the GIL per pull | Everything else — including CSV, which the binding drains through the batch csv::chunk rather than the engine's lazy CSV iterator |
| JavaScript | Nothing | streamChunks awaits getChunks in full and then yields — the entire document and the entire chunk array are in memory before the first yield. Identical results and identical peak memory to getChunks. |
The PDF default case is the one with a measured claim behind it: the first
chunk of a 5,000-page document costs fewer than ten rendered pages. The other
PDF modes rank headings across the whole document before emitting anything, so
the pull-per-item shape is preserved but the cost is paid up front.
Retracting a claim this page used to make
An earlier version of this page said memory is bounded for the truly incremental formats "across all three runtimes". That is not true of JavaScript at all, and it is not true of CSV in Python. Streaming is worth reaching for because it lets you start downstream work before chunking finishes — not because it bounds memory everywhere. See Streaming for the per-format table.
7. How "byte-identical" is enforced
Six harnesses, each closing a gap the others cannot see. The numbers below are the current recorded baselines; a drop from any of them blocks a change.
| Harness | Compares | Baseline |
|---|---|---|
parity_dump.rs → parity_check.py | rs ↔ py, chunks and Markdown, every fixture × every applicable mode | 3,222 identical / 0 different |
golden_snapshot.py check | the whole corpus against a pinned snapshot of {content, content_type, metadata keys} | 3,198 cases, 0 changed |
py_chunks pytest | the Python suite | 4,876 passed |
verify_output.py | actual extracted text against known ground truth, per format family | 201 / 201 |
image_parity.py → image_parity_check.mjs | js ↔ py, image names and image bytes, every image-bearing format | 374 / 375 |
pdf_parity.py → pdf_parity_check.mjs | js ↔ py, PDF Markdown + 3 modes + image bytes | 23 / 24 |
Alongside them: images_dump.rs → images_check.py and md_images_dump.rs →
md_images_check.py extend the rs ↔ py comparison to the _with_images
surfaces, and js-chunks/test/parity_dump.mjs emits the same JSON-lines format
as the Rust dump from the WASM engine so JavaScript output can be fed through
the same checker.
Three details make these numbers mean something:
- 3,222 is a composition, not a round number.
parity_dump.rsemits one Markdown record plus one record per applicable mode for every fixture it can handle: 353 prose files × 7, 18 delimited files × 4, and 97 spreadsheet files × 7 — 2,471 + 72 + 679 = 3,222. Files over 10 MB and unrecognised extensions are skipped. - The golden snapshot is not a parity check. rs and py share source, so a bug in shared code produces identically wrong output on both sides and reads as a pass. The snapshot is a change detector against a pinned baseline — which is how a 24-module refactor could be proven behaviour-identical.
verify_output.pyis not a shape check. Assertions like "non-empty" and "content_typeis a string" cannot see a chunk that lost every&. This harness asserts on real extracted text.
The two known differences, and why they are the same one
Both shortfalls above are the same fixture and the same cause:
pdfjs_issue13520.pdf, the only corpus PDF with neither extractable text nor
an embedded page image. Rust and Python fall back to rasterising the page
with PDFium; JavaScript has no PDFium (see §4) and returns a specific "no
extractable text" error instead. Nothing else differs.
The exception is not a fudge factor: both checkers hold it as a named one-element allowlist and fail if the set of differences stops matching it exactly, so it cannot quietly widen.
8. Design principles
- One implementation of every output decision. Parsing, chunking, metadata
and routing all live in
rs-chunks. The bindings normalize input and shape output; they decide nothing (§2). - A copy that nothing checks is a copy that drifts. Vendoring is the only way to publish three independent packages from one engine, so the drift check is a script that fails the build rather than a rule people remember (§1).
- Uniform facades, shared internals. One facade per format keeps dispatch flat; seven formats behind it are a loader plus the Markdown chunker, and two more reuse a sibling's builders (§3).
- Adversarial input fails cleanly. Every dispatch entry point is
panic-guarded, so a hostile file returns an
Errinstead of taking the host runtime down with it (§2). - Errors carry the same words everywhere. The bindings bypass
Displayso the message text is byte-identical, and carry the variant in whatever form the host language already has for it (§5). - Say what streaming actually does. Per-format, path-based, incremental in a handful of specific cases — and an ergonomic wrapper in JavaScript (§6).
- Publish only what a harness proves. Every parity number on this site has a script that regenerates it, and the two known differences are named, explained, and pinned so they cannot widen (§7).
Related
- Languages & parity — choosing an SDK, and reproducing the parity claim.
- API Reference — the exact surface of each SDK.
- Streaming — the per-format streaming profile.
- Error Handling — what raises, and how to catch it.