# 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-memory` write EPUB in pure JS; `@lingo-reader/fb2-parser`/`node-fb2` parse/write FB2. Real coverage, but only for these two formats. - `lingo-reader`/`foliate-js` **read** `mobi`/`azw3` in pure JS. Neither, nor anything else found, **writes** `mobi`/`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): ```js 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: ```js 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: ```js 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`: add `epub: 'application/epub+zip'` (IANA-registered, confirmed) and `fb2`, `mobi`, `azw3`, `lrf`, `pdb`, `rb`, `snb`, `tcr` — none of the latter six have an IANA registration; the exact conventional value for each (vs. a plain `application/octet-stream` fallback) is to be confirmed against `mime-db`/Apache `mime.types` during implementation, not guessed here. - `resolveInputFormat`: verified with the installed `file-type` version that its `supportedExtensions` includes `epub` and `mobi`, and nothing else from `EBOOK_FORMATS`. Two follow-ups, both to be confirmed with real fixture files during implementation rather than assumed: 1. `azw3` is itself a MOBI/PDB-based container — check empirically whether `file-type` reports a real `.azw3` file's `ext` as `mobi` (same class of collision as the dfont/ico bug: declared `azw3`, detected `mobi` → currently rejected as invalid). If confirmed, fix via `normalizeFormat` treating `azw3`/`mobi` as equivalent for the validity check only, same style as the existing `jpg`/`jpeg` and `heif`/`heic` aliasing — not via a bespoke structural probe like dfont's, since there's no library here to build one from. 2. `fb2`, `lrf`, `pdb`, `rb`, `snb`, `tcr` are absent from `file-type`'s supported list entirely (confirmed) — sniffing always returns `null` for them. These follow the existing `UNDETECTABLE_TEXT_FORMATS` fallback 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 (`failed` status, 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`'s `execFile` and assert: `registerEbookConverter()` registers all 90 pairs (10×9) into the registry; a resolved converter calls `execFile` with `CALIBRE_PATH`, `[inputPath, outputPath]`, and `{ timeout: timeoutMs }`; a mocked non-zero-exit/error rejection propagates out of `convert` unchanged (so the existing worker `catch` path 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).