8.6 KiB
Ebook conversion (epub/fb2/lrf/mobi/pdb/rb/snb/tcr/azw3/pdf via Calibre)
Date: 2026-07-31
Goal
Add the ebook family, requested formats: epub, fb2, lrf, mobi, pdb, rb, snb, tcr, azw3, pdf. All-pairs bidirectional (source ≠ target), same shape as font/dfont.
Scope decision
This list is effectively Calibre's own supported-format set. Verified before committing to an approach:
epub-gen/epub-gen-memorywrite EPUB in pure JS;@lingo-reader/fb2-parser/node-fb2parse/write FB2. Real coverage, but only for these two formats.lingo-reader/foliate-jsreadmobi/azw3in pure JS. Neither, nor anything else found, writesmobi/azw3— the only tool that ever did (kindlegen) is discontinued by Amazon and isn't npm-installable as a maintained precompiled binary.lrf,pdb,rb,snb,tcr: no JS library of any kind (read or write) was found. These are formats for long-discontinued readers (Sony BBeB, RocketBook, Bambook, generic Palm/PDA text compression).
Reimplementing 10 formats' worth of binary containers from scratch for formats with ~zero active readership is not a good effort/value trade — and would still leave mobi/azw3 write-only broken. Decided instead (user-approved): wrap Calibre's ebook-convert CLI as an external subprocess. It is the one tool that actually handles all 10 formats both ways, with real fidelity. This is the first converter in the project that shells out to a manually-installed binary rather than a pure-JS or npm-auto-downloaded one (sharp/puppeteer remain the only prior binary deps, both npm-managed) — accepted per the project's own documented exception: "l'upload manuel d'un binaire statique via SSH si nécessaire (non utilisé en v1)". Calibre publishes a self-contained Linux tarball (bundled Qt/Python, no compilation, no root) — that build is what gets deployed to o2switch via SSH, outside npm install.
Backend flow
src/converters/ebook.js (new):
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { register } from './registry.js';
const execFileAsync = promisify(execFile);
const EBOOK_FORMATS = ['epub', 'fb2', 'lrf', 'mobi', 'pdb', 'rb', 'snb', 'tcr', 'azw3', 'pdf'];
const CALIBRE_PATH = process.env.CALIBRE_PATH || 'ebook-convert';
export function registerEbookConverter() {
for (const sourceFormat of EBOOK_FORMATS) {
for (const targetFormat of EBOOK_FORMATS) {
if (sourceFormat === targetFormat) continue;
register({
family: 'ebook',
sourceFormat,
targetFormat,
convert: async (inputPath, outputPath, options = {}) => {
await execFileAsync(CALIBRE_PATH, [inputPath, outputPath], {
timeout: options.timeoutMs,
});
},
});
}
}
}
execFile (never exec) so paths are passed as an argv array, not interpolated into a shell string. timeout/killSignal are native execFile options — Node kills the Calibre subprocess itself when it fires, rather than leaving it running in the background after the outer worker race gives up (see below). No stdout/stderr handling needed beyond the default: a non-zero exit throws, and the thrown error's .stack/message (including captured stderr, which execFile attaches to the error) flows into the existing generic catch in worker.js's processJob unchanged.
src/config.js: add one field, same pattern as the other worker knobs:
ebookJobTimeoutMs: Number(process.env.EBOOK_JOB_TIMEOUT_MS ?? 180000),
CALIBRE_PATH itself is not added to config.js — it's read directly in ebook.js at module scope, the same way font.js/dfont.js don't thread configuration through the registry; only the timeout needs to reach worker.js's scheduling logic, so only it goes through config.
src/worker.js: replace the single hardcoded JOB_TIMEOUT_MS use in processJob with a per-family choice, and forward it into convert's options:
const JOB_TIMEOUT_MS = 60000; // unchanged, default for every family except ebook
// inside processJob, after resolving `entry`:
const timeoutMs = entry.family === 'ebook' ? config.ebookJobTimeoutMs : JOB_TIMEOUT_MS;
await withTimeout(
entry.convert(inputFilePath, outputFilePath, { quality: job.quality, iconSize: job.iconSize, timeoutMs }),
timeoutMs
);
Also add registerEbookConverter() next to the other seven register* calls in main(). src/app.js gets the same import + call in registerAllConverters() (needed there too, since that's where GET /api/formats reads the registry to answer the frontend — confirmed by reading app.js, same as every prior family).
src/mime.js:
OUTPUT_MIME_TYPES: addepub: 'application/epub+zip'(IANA-registered, confirmed) andfb2,mobi,azw3,lrf,pdb,rb,snb,tcr— none of the latter six have an IANA registration; the exact conventional value for each (vs. a plainapplication/octet-streamfallback) is to be confirmed againstmime-db/Apachemime.typesduring implementation, not guessed here.resolveInputFormat: verified with the installedfile-typeversion that itssupportedExtensionsincludesepubandmobi, and nothing else fromEBOOK_FORMATS. Two follow-ups, both to be confirmed with real fixture files during implementation rather than assumed:azw3is itself a MOBI/PDB-based container — check empirically whetherfile-typereports a real.azw3file'sextasmobi(same class of collision as the dfont/ico bug: declaredazw3, detectedmobi→ currently rejected as invalid). If confirmed, fix vianormalizeFormattreatingazw3/mobias equivalent for the validity check only, same style as the existingjpg/jpegandheif/heicaliasing — not via a bespoke structural probe like dfont's, since there's no library here to build one from.fb2,lrf,pdb,rb,snb,tcrare absent fromfile-type's supported list entirely (confirmed) — sniffing always returnsnullfor them. These follow the existingUNDETECTABLE_TEXT_FORMATSfallback shape: trust the declared format when sniffing draws a blank. This is a real (accepted) weakening of the validation guarantee versus sniffable formats — a mislabeled file still reaches Calibre, which will simply fail the job cleanly (failedstatus, generic message) rather than silently producing garbage, so it is not a security gap, only a lower-confidence input check for these specific formats since no sniffing library for them exists.
Database / frontend: no changes, same reasoning as the font design — family is a free-text column, and App.jsx's format controls are entirely driven by GET /api/formats, which reflects the registry automatically.
Operational note (o2switch)
Calibre conversions are subprocess-based and heavier than every other converter in the project (a full, if headless, Calibre invocation vs. an in-process JS call) — worth keeping in mind against the documented 20-process/12GB budget if ebook jobs turn out to dominate traffic, though no separate concurrency limit is being introduced now (WORKER_CONCURRENCY stays global across all families, YAGNI until real usage says otherwise).
Testing
Calibre is not installed on the Windows dev machine (ebook-convert/calibre confirmed absent from PATH) and won't be for this task (decided) — so unlike every prior converter test (font.test.js, dfont.test.js, etc.), which run real conversions against fixture files, test/converters/ebook.test.js cannot exercise a real Calibre binary locally. Instead:
- Mock
node:child_process'sexecFileand assert:registerEbookConverter()registers all 90 pairs (10×9) into the registry; a resolved converter callsexecFilewithCALIBRE_PATH,[inputPath, outputPath], and{ timeout: timeoutMs }; a mocked non-zero-exit/error rejection propagates out ofconvertunchanged (so the existing workercatchpath is exercised, not re-implemented here). - Real end-to-end Calibre conversion is verified only after o2switch deployment (or if Calibre for Windows is installed locally later — explicitly deferred, not part of this task).
test/mime.test.js: add cases for the azw3/mobi collision check (once confirmed empirically) and for the fb2/lrf/pdb/rb/snb/tcr undetectable-format fallback.
test/worker.test.js: add a case confirming a job whose resolved converter has family: 'ebook' uses config.ebookJobTimeoutMs rather than the default JOB_TIMEOUT_MS.
Frontend: manual browser check only, once a real Calibre binary is available somewhere to convert against (no frontend changes expected regardless, per the "Database / frontend" section above).