Something went wrong. Try again.
Something went wrong. Try again.
8.5 kB · 217 lines
JavaScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218// Port of convertEpubFile, FilesPage.html lines 4899-5246.//// Kept verbatim: the `renamed` map build (4911-4916), the mimetype-first STORE// write (4926-4929), the 4-pass structure, the per-entry compression choices// (images STORE, xhtml/opf/rest DEFLATE level 8) and pass 1 capturing the LAST// `.opf` it sees (5015-5017).//// Removed as dead code for this CLI: `splitImages` and every branch that read// it (the multi-part write at 4988-5012 and the split-matching XHTML block at// 5064-5163), `operationCancelled` checks, `getImageState` (always Normal), and// the batch/export-log plumbing. Logging calls become reporter hooks.
import JSZip from 'jszip';import { DEFENSIVE_STYLE } from './constants.js';import { safeReadText, decodeHref, resolvePath } from './util.js';import { parseXhtml, safeSerialize } from './xml.js';import { extractIdentifier, syncNCXIdentifier, fixOPF, fixSvgCover, fixSvgWrappedImages,} from './epub-fixes.js';import { processImage } from './image.js';
const TEXT_COMPRESSION = { compression: 'DEFLATE', compressionOptions: { level: 8 }, createFolders: false };const STORE = { compression: 'STORE', createFolders: false };
const NOOP_REPORTER = { image() {}, fix() {}, warn() {} };
/** * @param {Buffer} inputBuffer raw .epub bytes * @param {{maxWidth:number,maxHeight:number,quality:number,autoCrop:boolean}} opts * @param {{image?:Function,fix?:Function,warn?:Function}} [reporter] * @returns {Promise<{buffer:Buffer, stats:object}>} */export async function convertEpub(inputBuffer, opts, reporter = NOOP_REPORTER) { const report = { ...NOOP_REPORTER, ...reporter }; const startTime = Date.now(); const originalSize = inputBuffer.length;
const stats = { images: 0, fixes: 0, errors: 0, warnings: 0, originalSize, newSize: 0, elapsedMs: 0 };
const zip = await JSZip.loadAsync(inputBuffer); // NOTE: `.jpg` is deliberately absent from this pattern. JPEGs are still // re-encoded below, they just keep their filename, so no reference rewriting // is needed for them. (4911-4916) const renamed = {}; zip.forEach(p => { const l = p.toLowerCase(); if (l.match(/\.(png|gif|webp|bmp|jpeg)$/)) { renamed[p] = p.replace(/\.(png|gif|webp|bmp|jpeg)$/i, '.jpg'); } });
const out = new JSZip(); const entries = Object.entries(zip.files); const xhtmlFiles = {}; let opfPath = null, opfContent = null; let mainIdentifier = null;
// Write mimetype FIRST per EPUB OCF spec. jszip preserves insertion order, so // nothing downstream may sort or re-add entries. (4926-4929) if (zip.files['mimetype']) { const mimetypeData = await zip.files['mimetype'].async('nodebuffer'); out.file('mimetype', mimetypeData, STORE); }
// ---- First pass: process images ----------------------------------------- for (let i = 0; i < entries.length; i++) { const [path, fileObj] = entries[i]; if (fileObj.dir || path === 'mimetype') continue; const low = path.toLowerCase();
if (low.match(/\.(png|gif|webp|bmp|jpg|jpeg)$/)) { const data = await fileObj.async('nodebuffer');
let result = null; try { result = await processImage(data, path, opts); } catch (imageError) { // DELIBERATE DEVIATION from source lines 4945-4972. // // The page's fallback wrote the ORIGINAL bytes under the RENAMED `.jpg` // path. That is safe in a browser, where every format the manifest can // name decodes. sharp has no BMP decoder, so under Node every BMP fails // here — and the page's behaviour would emit a file called `x.jpg` that // actually holds BMP bytes, which readers reject. // // Instead: write the ORIGINAL bytes at the ORIGINAL path and drop the // entry from `renamed`, so the XHTML/OPF/CSS/NCX rewrites in passes 2-4 // leave their references pointing at the file that really exists. out.file(path, data, STORE); delete renamed[path]; stats.errors++; report.warn(`Failed to process ${path.split('/').pop()}, using original (${imageError.message})`); continue; }
stats.images++; report.image(path, result.meta);
const newPath = renamed[path] || path.replace(/\.[^.]+$/, '.jpg'); out.file(newPath, result.data, STORE); } else if (low.match(/\.(xhtml|html|htm)$/)) { xhtmlFiles[path] = await safeReadText(fileObj); } else if (low.endsWith('.opf')) { // Last .opf wins, as in the source. (5015-5017) opfPath = path; opfContent = await safeReadText(fileObj); } }
// ---- Second pass: update XHTML ------------------------------------------ for (const [xhtmlPath, content] of Object.entries(xhtmlFiles)) { let t = content; const r = fixSvgCover(t); if (r.fixed) { t = r.c; stats.fixes++; report.fix('SVG cover', xhtmlPath.split('/').pop()); }
const r2 = fixSvgWrappedImages(t); if (r2.fixed) { t = r2.c; stats.fixes++; report.fix(`SVG images (${r2.count})`, xhtmlPath.split('/').pop()); }
try { const { doc, ok } = parseXhtml(t);
if (ok) { let modified = false;
// Remove width/height attributes from ALL img tags (dimensions may have changed). // This prevents CrossPoint and other readers from using wrong dimensions. const allImgElements = doc.querySelectorAll('img'); for (const img of allImgElements) { if (img.hasAttribute('width')) { img.removeAttribute('width'); modified = true; } if (img.hasAttribute('height')) { img.removeAttribute('height'); modified = true; }
const src = img.getAttribute('src'); if (src) { const decodedSrc = decodeHref(src); const resolvedSrc = resolvePath(xhtmlPath, decodedSrc);
const match = Object.entries(renamed).find(([oldPath]) => resolvedSrc === oldPath);
if (match) { const [oldPath, newPath] = match; img.setAttribute('src', decodedSrc.replace(oldPath.split('/').pop(), newPath.split('/').pop())); modified = true; } } }
// Only serialize if we made changes if (modified) { t = safeSerialize(doc, content); } } } catch (e) { report.warn(`XHTML parse error for ${xhtmlPath}: ${e.message}`); stats.warnings++; }
// Inject universal image constraint — prevents overflow on e-ink displays if (t.includes('</head>')) { t = t.replace('</head>', DEFENSIVE_STYLE + '</head>'); }
out.file(xhtmlPath, t, TEXT_COMPRESSION); }
// Extract main identifier from OPF (parser with regex fallback) if (opfContent) { mainIdentifier = extractIdentifier(opfContent); }
// ---- Third pass: update OPF --------------------------------------------- if (opfContent) { let t = opfContent; for (const [o, n] of Object.entries(renamed)) { t = t.split(o.split('/').pop()).join(n.split('/').pop()); } t = fixOPF(t, opfContent); if (t !== opfContent) { stats.fixes++; report.fix('OPF', 'manifest updated'); } out.file(opfPath, t, TEXT_COMPRESSION); }
// ---- Fourth pass: copy remaining files ---------------------------------- for (const [path, fileObj] of entries) { if (fileObj.dir || path === 'mimetype') continue; const low = path.toLowerCase(); if (low.match(/\.(png|gif|webp|bmp|jpg|jpeg)$/) || low.match(/\.(xhtml|html|htm)$/) || low.endsWith('.opf')) continue;
let data = await fileObj.async('nodebuffer'); if (low.endsWith('.css')) { let t = await safeReadText(fileObj); for (const [o, n] of Object.entries(renamed)) { t = t.split(o.split('/').pop()).join(n.split('/').pop()); } data = new TextEncoder().encode(t); } else if (low.endsWith('.ncx')) { let t = await safeReadText(fileObj); for (const [o, n] of Object.entries(renamed)) { t = t.split(o.split('/').pop()).join(n.split('/').pop()); } const oldT = t; t = syncNCXIdentifier(t, mainIdentifier); if (t !== oldT) { stats.fixes++; report.fix('NCX identifier', 'Synced with OPF'); } data = new TextEncoder().encode(t); } out.file(path, data, TEXT_COMPRESSION); }
const buffer = await out.generateAsync({ type: 'nodebuffer', mimeType: 'application/epub+zip' });
stats.newSize = buffer.length; stats.elapsedMs = Date.now() - startTime;
return { buffer, stats };}