Files
convert/docs/superpowers/specs/2026-07-31-ebook-converter-design.md

8.6 KiB
Raw Permalink Blame History

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):

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: 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).