Skip to content
7 min read · 1,440 words

The Bundled Fleet

Every row in this table has a downside, and the downside is in the table. If a column reads like marketing, tell us — that's a bug. (The capability shapes these rows declare are The Capability Model; how dispatch chooses among them is Engine Dispatch; what EMPTY_MIME generation means is Generating Media.)

SubpathFactoryEnvironmentDeclaresTrade-offs, stated plainly
engines/jimpjimpEngine()Cross-env (pure JS)mutate over png/jpg/bmp/gif/tiff; generate (EMPTY_MIME → blank canvas, any supported encoding)No webp/avif. Slower than sharp. Re-encoding strips EXIF inherently. Blank canvas is fixed 1024×1024 white — resize in the same statement.
engines/sharpsharpEngine(), fromSharp(sharp)Node only (native bindings)mutate over image/*, encodes incl. webp/avif; generate (EMPTY_MIME → blank canvas)Full format set, full fit modes, native speed. Canvas is 3-channel so jpeg never trips on alpha.
engines/sheetjssheetjsEngine()Cross-env (pure JS)convert: the FULL spreadsheet matrix (xlsx/xlsm/xlsb/xls/ods/fods/csv/numbers/sylk/dif/dbf in; those plus txt/html/rtf/json out); generate (EMPTY_MIME → any write target); edit: every sheet.* op over the whole read matrixSheetJS. Install from the SheetJS CDN, not the npm registrypnpm add xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz (the registry copy is frozen at 0.18.5 with two CVEs; the engine floors at >=0.20.2; SheetJS recommends vendoring the tarball). CE strips styling — fonts, fills, comments do not survive its edits or round-trips (styling is a SheetJS Pro feature). NUMBERS output lazy-loads a ~110 KB ZAHL payload.
engines/exceljsexceljsEngine()Cross-env (pure JS)edit: every sheet.* op over xlsx, styling preserved; generate (EMPTY_MIME → xlsx)xlsx only — it silently reads a real ODS as zero worksheets and throws on legacy XLS, which is why it lost the conversion job to sheetjs. What it keeps is fidelity: bold, fills, comments, formulas survive an edit untouched.
engines/datadataEngine()Cross-env (pure JS)convert: json ⇄ yaml, json ⇄ csv, json → txt; generate (EMPTY_MIME → txt/md/json/yaml/csv/html)The smallest engine in the fleet, deliberately: text-family generation is a literal and text-family conversion is a parse and a re-serialize. csv edges lazy-load the papaparse peer; yaml rides the library's own js-yaml.
engines/tesseract_jstesseractJsEngine({ languages })Cross-env (WASM)convert image/* → txt/hocr/jsonlanguages is required — packs download on first use (point langPath/cachePath somewhere deliberate). Slower than the native CLI. Worker per call: leak-free, not pooled.
engines/audio_decodeaudioDecodeEngine()Cross-env (JS/WASM codecs)convert mp3/aac/ogg/opus/flac/wav → pcm; generate (EMPTY_MIME → wav)No ffmpeg anywhere. PCM output carries the source rate in meta.sampleRate; the transcribe step resamples to 16 kHz. The generated wav is one second of 16-bit mono silence at 44100 Hz — a canonical RIFF envelope written by a dependency-free helper (generation never loads the decode peer).
engines/transformers_asrtransformersAsrEngine({ model })Cross-env (ONNX / WASM / WebGPU)convert pcm → txt/srt/vtt/jsonmodel is required — Whisper models are multi-hundred-MB downloads and we never trigger one silently. srt/vtt assembled from chunk timestamps.
engines/sofficesofficeEngine(opts)Binary-backedconvert: the office matrix (each silo to pdf/html + within-silo; pdf → html/txt/docx/odt; ODS/xls → xlsx rides the matrix — there is no separate "normalize" engine anymore); generate (EMPTY_MIME → its whole matrix)LibreOffice. Document conversion has no mature cross-environment equivalent — the candidates are a 200 MB browser-only beta and an AGPL license you'd have to explain to counsel. This is the honest exception, and it's why the runtime contracts exist.
engines/execa_executorexecaExecutor()Node— (a BinaryExecutor, not an engine)The local-process executor (execa under the hood, via resolver).
engines/fs_workspacefsScratchWorkspace({ root })Node— (a ScratchWorkspace, not an engine)root is required — there is no os.tmpdir() silent default.

Catch missing peers at lint time, not at runtime

Every engine in this table that has an optional peer will fail with E_INVALID_MEDIA_PIPELINE_CONFIG the first time it runs if that peer isn't installed — not at pipeline construction, not at import, at the first op. The adk-media/require-engine-peers ESLint rule catches this at lint time instead: it checks that every peer for every engine you import resolves from your file's location, and reports the exact pnpm add command to fix it (including the CDN URL for xlsx). Enable it via the media battery's ESLint plugin — see Developer Tools → ESLint Rules.

Install xlsx from the SheetJS CDN — npm i xlsx is a trap

pnpm add xlsx@https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz. Not pnpm add xlsx. Here is the situation, stated plainly because someone is going to skim this and type the wrong command: the version of xlsx on the public npm registry is 0.18.5, last published in 2023, and it carries two known CVEs (CVE-2023-30533 prototype pollution, CVE-2024-22363 ReDoS). SheetJS stopped publishing to npm and ships current builds — the ones with the fixes and the formats this engine needs — only from cdn.sheetjs.com. So the single most obvious thing a developer does to install a dependency installs a stale, vulnerable artifact, and the registry will hand it over with a straight face and no warning. We did not create this; we are not happy about it; and the only thing worse than tolerating it would be picking a worse-aligned library to avoid typing a URL. The engine floors at >=0.20.2 so a 0.18.5 install fails fast at construction rather than mysteriously at runtime, and SheetJS recommends vendoring the tarball into your repo so you're not load-bearing on a CDN. Read their installation page; it says the same thing in calmer words.

Field note: the library that lost and the registry that lies

ExcelJS was the incumbent candidate for spreadsheet conversion (already the sheet.* workhorse) until empirical testing showed its real conversion matrix is xlsx/csv only — xlsx.load() on a real ODS silently returns a zero-worksheet workbook (it's a zip, so nothing throws; silent garbage, worse than an error), and legacy XLS throws Can't find end of central directory. SheetJS reads ODS, every XLS BIFF era, NUMBERS, SYLK, DIF, DBF. So the loser of "best conversion matrix" became the winner of "fidelity-preserving editor" and they both stayed — each doing the one job it's actually good at, neither pretending to do the other.

Two spreadsheet engines: the consumer picks the trade-off

The obvious question — "doesn't SheetJS just replace ExcelJS?" — got an empirical answer during development. Round-trip a styled workbook (bold red header, yellow fill, a comment, a formula) through ExcelJS: everything survives. Round-trip the same bytes through SheetJS CE with cellStyles: true: bold: null, fill: null, comment gutted — styling is a SheetJS Pro feature, and CE silently drops what it can't model. An edit that strips a user's formatting as a side effect is corruption with good intentions — but whether that matters is a property of YOUR workload, not ours, so both engines declare the edit capability and array order picks:

You edit…ComposeWhy
Human-authored workbooks (formatting matters)exceljsEngine() first (or alone)Edits preserve the styling they don't touch.
Data workbooks, CSV-land pipelinessheetjsEngine() aloneThe full read matrix in-process (ODS, XLS, NUMBERS, …), no second dependency, and there's no styling to lose.
Both kindsexceljsEngine(), sheetjsEngine()exceljs takes xlsx edits (fidelity); sheetjs takes everything exceljs can't read, plus all conversions.

This is the edit capability's fidelity-arbitration design doing exactly what it was built for: two engines truthfully declaring the same ops, supply order picking the winner, and the trade-off visible in your engines array instead of buried in a library's release notes.

Worth saying out loud: SheetJS's stated philosophy (the "Zen of SheetJS") is the same bet this battery makes — data processing should fit in any workflow (no imposed lifecycle, plain objects) is BYO-everything; file formats are implementation details (HTML-saved-as-XLS parses like real XLS) is exactly the registry's MIME-edges-not-format-special-cases model; data processing should be confidential (everything local, zero telemetry) is the battery's in-process posture. The CDN-install friction is worth tolerating for a library that aligned this well before we ever met it.