Something went wrong. Try again.
Something went wrong. Try again.
6.1 kB · 183 lines
JavaScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184#!/usr/bin/env node// epub-optimize — batch EPUB optimizer for xteink e-ink readers.//// Sequencing mirrors the page's uploadFile (5406-5688): read metadata name// first, reserve a collision-free name second, convert third.
import { parseArgs } from 'node:util';import { readFileSync, writeFileSync, readdirSync, mkdirSync, statSync, existsSync } from 'node:fs';import { resolve, join, basename, dirname } from 'node:path';import JSZip from 'jszip';
import { DEVICE_PROFILES, DEFAULT_DEVICE, DEFAULT_JPEG_QUALITY } from './constants.js';import { convertEpub } from './convert.js';import { getMetadataFilename, reserveAvailableFilename } from './rename.js';import { Reporter } from './report.js';
const USAGE = `Usage: epub-optimize <input> [options]
<input> a directory of .epub files (top-level scan) or a single .epub file
Options: --out DIR output directory (default: <input>/optimized) --device X4|X3 target screen: X4 = 480x800 (default), X3 = 528x792 --quality N JPEG quality, 1-100 (default: ${DEFAULT_JPEG_QUALITY}) --auto-crop trim uniform margins before resizing (off by default) --rename name output files "Title - Author.epub" from EPUB metadata -h, --help show this message
Images are always converted to grayscale baseline JPEG. Originals are nevermodified; output always lands in a separate directory.`.trim();
class UsageError extends Error {}
function parseCliArgs(argv) { let parsed; try { parsed = parseArgs({ args: argv, options: { out: { type: 'string' }, device: { type: 'string' }, quality: { type: 'string' }, 'auto-crop': { type: 'boolean', default: false }, rename: { type: 'boolean', default: false }, help: { type: 'boolean', short: 'h', default: false }, }, allowPositionals: true, }); } catch (e) { throw new UsageError(e.message); }
const { values, positionals } = parsed;
if (values.help) return { help: true };
if (positionals.length === 0) throw new UsageError('missing <input>'); if (positionals.length > 1) throw new UsageError(`unexpected extra argument "${positionals[1]}"`);
const device = String(values.device ?? DEFAULT_DEVICE).toUpperCase(); if (!DEVICE_PROFILES[device]) { throw new UsageError(`--device must be one of ${Object.keys(DEVICE_PROFILES).join(', ')} (got "${values.device}")`); }
const quality = values.quality === undefined ? DEFAULT_JPEG_QUALITY : Number(values.quality); if (!Number.isInteger(quality) || quality < 1 || quality > 100) { throw new UsageError(`--quality must be an integer from 1 to 100 (got "${values.quality}")`); }
return { help: false, input: positionals[0], outArg: values.out, device, quality, autoCrop: values['auto-crop'], rename: values.rename, };}
/** Resolve <input> to a list of .epub paths, plus the count of skipped entries. */function discoverInputs(inputPath) { if (!existsSync(inputPath)) throw new UsageError(`input not found: ${inputPath}`);
const st = statSync(inputPath); if (st.isFile()) { if (!inputPath.toLowerCase().endsWith('.epub')) { throw new UsageError(`not an .epub file: ${inputPath}`); } return { epubs: [inputPath], skipped: [], baseDir: dirname(inputPath) }; }
const epubs = []; const skipped = []; for (const name of readdirSync(inputPath)) { const full = join(inputPath, name); let entry; try { entry = statSync(full); } catch (e) { continue; } if (!entry.isFile()) continue; if (name.toLowerCase().endsWith('.epub')) epubs.push(full); else skipped.push(name); } epubs.sort(); return { epubs, skipped, baseDir: inputPath };}
async function main(argv) { const args = parseCliArgs(argv); if (args.help) { console.log(USAGE); return 0; }
const inputPath = resolve(args.input); const { epubs, skipped, baseDir } = discoverInputs(inputPath);
const outDir = resolve(args.outArg ?? join(baseDir, 'optimized')); if (outDir === inputPath) throw new UsageError('--out must differ from <input>'); mkdirSync(outDir, { recursive: true });
const profile = DEVICE_PROFILES[args.device]; const opts = Object.freeze({ maxWidth: profile.width, maxHeight: profile.height, quality: args.quality, autoCrop: args.autoCrop, });
const reporter = new Reporter(); for (const name of skipped) reporter.skipFile(name);
console.log( `Device ${profile.label} (${profile.width}x${profile.height}) | quality ${opts.quality} | ` + `auto-crop ${opts.autoCrop ? 'on' : 'off'} | rename ${args.rename ? 'on' : 'off'}` ); console.log(`Output: ${outDir}`); console.log('');
// Seeded from what is already on disk, then carried across the whole batch so // same-run collisions also pick up " (2)" suffixes. const usedNames = new Set(readdirSync(outDir).map(n => n.toLowerCase()));
for (const epubPath of epubs) { const sourceName = basename(epubPath); try { const inputBuffer = readFileSync(epubPath);
let desiredName = sourceName; if (args.rename) { const zip = await JSZip.loadAsync(inputBuffer); const metadataName = await getMetadataFilename(zip); if (metadataName) desiredName = metadataName; }
const outName = reserveAvailableFilename(desiredName, usedNames);
const hooks = reporter.beginFile(); const { buffer, stats } = await convertEpub(inputBuffer, opts, hooks); writeFileSync(join(outDir, outName), buffer);
reporter.endFile(outName === sourceName ? outName : `${sourceName} → ${outName}`, stats); } catch (e) { reporter.failFile(sourceName, e); } }
return reporter.finish() > 0 ? 1 : 0;}
main(process.argv.slice(2)) .then(code => { process.exitCode = code; }) .catch(e => { if (e instanceof UsageError) { console.error(`error: ${e.message}`); console.error(''); console.error(USAGE); } else { console.error(`error: ${e && e.message ? e.message : e}`); } process.exitCode = 1; });