UNPKG

@publiwrite/html-to-epub

Version:

A library to generate EPUB from HTML. Inspired by epub-gen.

847 lines (846 loc) 35.7 kB
import archiver from "archiver"; import axios from "axios"; import { renderFile } from "ejs"; import { encodeXML } from "entities"; import { createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, rmSync, unlinkSync, } from "fs"; import { copyFile as copyFileAsync, readFile as readFileAsync, writeFile as writeFileAsync, } from "fs/promises"; import { Agent as HttpsAgent } from "https"; import { imageSize } from "image-size"; import mime from "mime"; import pLimit from "p-limit"; import { basename, dirname, resolve } from "path"; import rehypeParse from "rehype-parse"; import rehypeStringify from "rehype-stringify"; import slugify from "slugify"; import { pipeline } from "stream/promises"; import { unified } from "unified"; import { visit } from "unist-util-visit"; import { fileURLToPath } from "url"; import { v4 as uuidv4 } from "uuid"; // Max length of the slugified chapter title in a generated filename // (`${index}_${slug}.xhtml`). Filesystem/zip entry names cap at 255 // bytes; slugify(strict) emits ASCII (1 byte/char), so 120 leaves ample // room for the index prefix and `.xhtml` suffix while staying readable. const MAX_SLUG_LENGTH = 120; // Tags that ship interpretable / scriptable content; stripped from // every chapter regardless of EPUB version. The library used to allow // these via `allowedXhtml11Tags` (epub2 path) and via no filter at all // (epub3 path), so an inline `<script>`, `<iframe>`, `<object>`, // `<embed>` or `<applet>` from user HTML would survive into the // produced EPUB. Most modern readers sandbox JS, but the safer posture // is to refuse to ship them in the first place. const FORBIDDEN_TAGS = new Set([ "script", "iframe", "object", "embed", "applet", ]); // Allowed HTML attributes & tags. Looked up per-attribute on every // element of every chapter; using a Set turns the O(N) .includes() // hot-path scan into O(1). const allowedAttributes = [ "content", "alt", "id", "title", "src", "href", "about", "accesskey", "aria-activedescendant", "aria-atomic", "aria-autocomplete", "aria-busy", "aria-checked", "aria-controls", "aria-describedat", "aria-describedby", "aria-disabled", "aria-dropeffect", "aria-expanded", "aria-flowto", "aria-grabbed", "aria-haspopup", "aria-hidden", "aria-invalid", "aria-label", "aria-labelledby", "aria-level", "aria-live", "aria-multiline", "aria-multiselectable", "aria-orientation", "aria-owns", "aria-posinset", "aria-pressed", "aria-readonly", "aria-relevant", "aria-required", "aria-selected", "aria-setsize", "aria-sort", "aria-valuemax", "aria-valuemin", "aria-valuenow", "aria-valuetext", "className", "content", "contenteditable", "contextmenu", "datatype", "dir", "draggable", "dropzone", "hidden", "hreflang", "id", "inlist", "itemid", "itemref", "itemscope", "itemtype", "lang", "media", "ns1:type", "ns2:alphabet", "ns2:ph", "prefix", "property", "rel", "resource", "rev", "role", "spellcheck", "style", "tabindex", "target", "title", "type", "typeof", "vocab", "xml:base", "xml:lang", "xml:space", // rehype camelCases the `colspan`/`rowspan` HTML attributes into the hast // property keys `colSpan`/`rowSpan` — the sanitiser compares against those // keys, so the canonical entries are camelCased. The lowercase forms are // kept alongside as a defensive net: a future tree built by hand, a // different parser, or any caller that sets `node.properties.colspan` // directly will still survive the sanitiser. Either form serialises back // to the correct lowercase HTML attribute. "colSpan", "rowSpan", "colspan", "rowspan", "epub:type", "epub:prefix", "start", "checked", "readonly", // `data-empty` survives the sanitiser. rehype camelCases `data-*` // attributes, so the property key is `dataEmpty`. Editor-rendered // chapter HTML marks blank-line paragraphs with `data-empty`, and the // CSS book-indent rule (`p:not([data-empty]) + p`) depends on it being // present in the EPUB output the same way it is in the PDF output. "dataEmpty", ]; const allowedXhtml11Tags = [ "div", "p", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "dl", "dt", "dd", "address", "hr", "pre", "blockquote", "center", "ins", "del", "a", "span", "bdo", "br", "em", "strong", "dfn", "code", "samp", "kbd", "bar", "cite", "abbr", "acronym", "q", "sub", "sup", "tt", "i", "b", "big", "small", "u", "s", "strike", "basefont", "font", // `object`, `embed`, `applet`, `iframe`, `script` deliberately // omitted — they're in FORBIDDEN_TAGS and replaced with span at // the top of validateElements, before this list is consulted. "param", "img", "table", "caption", "colgroup", "col", "thead", "tfoot", "tbody", "tr", "th", "td", "input", "map", "noscript", "ns:svg", "var", ]; const allowedAttributesSet = new Set(allowedAttributes); // Current directory const __dirname = fileURLToPath(new URL(".", import.meta.url)); export class EPub { uuid; title; description; cover; coverMediaType; coverExtension; coverDimensions = { width: 0, height: 0, }; publisher; author; tocTitle; appendChapterTitles; includeToc; date; lang; css; fonts; content; images; customOpfTemplatePath; customNcxTocTemplatePath; customHtmlCoverTemplatePath; customHtmlContentTemplatePath; customHtmlTocTemplatePath; version; userAgent; verbose; tempDir; tempEpubDir; output; zipCompressionLevel; imageDownloadConcurrency; assetFailureMode; allowFileUrls; /** * Populated during `render()` with one entry per inline-image or * cover-image fetch that failed in `"warn"` mode. Empty in `"throw"` * mode (the first failure short-circuits the render). Returned on the * `render()` result so callers can decide whether to ship the EPUB. */ warnings = []; // Lookup index for processImgTags: O(1) by url instead of O(n) // .find() in the constructor's per-element callback. The `images` // array stays the source of truth (order matters for manifest IDs). imagesByUrl = new Map(); // Shared axios instance with TLS keep-alive so the N image fetches // and the cover fetch reuse sockets across the parallel batches // instead of paying a fresh DNS + TCP + TLS handshake per request. // Big win when fetching N images from the same CDN/S3 origin. // // `timeout` and `maxContentLength` are non-default safety caps: // without them a hanging or huge image server can hold the caller // (e.g. a Lambda) until its own function timeout, then waste the // entire ephemeral storage budget on a single bloated download. // 30s + 100 MB is the sweet spot for legitimate book illustrations // while bounding the worst case. httpClient = axios.create({ httpsAgent: new HttpsAgent({ keepAlive: true, maxSockets: 16 }), timeout: 30_000, maxContentLength: 100 * 1024 * 1024, }); constructor(options, output) { // File ID this.uuid = uuidv4(); // Required options this.title = options.title; this.description = options.description; this.output = output; // Options with defaults this.cover = options.cover ?? null; this.publisher = options.publisher ?? "anonymous"; this.author = options.author ? typeof options.author === "string" ? [options.author] : options.author : ["anonymous"]; if (this.author.length === 0) { this.author = ["anonymous"]; } this.tocTitle = options.tocTitle ?? "Table Of Contents"; this.appendChapterTitles = options.appendChapterTitles ?? true; this.includeToc = options.includeToc ?? true; this.date = options.date ?? new Date().toISOString(); this.lang = options.lang ?? "en"; this.css = options.css ?? null; this.fonts = options.fonts ?? []; this.customOpfTemplatePath = options.customOpfTemplatePath ?? null; this.customNcxTocTemplatePath = options.customNcxTocTemplatePath ?? null; this.customHtmlTocTemplatePath = options.customHtmlTocTemplatePath ?? null; this.customHtmlCoverTemplatePath = options.customHtmlCoverTemplatePath ?? null; this.customHtmlContentTemplatePath = options.customHtmlContentTemplatePath ?? null; this.version = options.version ?? 3; this.userAgent = options.userAgent ?? "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.105 Safari/537.36"; this.verbose = options.verbose ?? false; this.zipCompressionLevel = options.zipCompressionLevel ?? 6; this.imageDownloadConcurrency = options.imageDownloadConcurrency ?? 8; this.assetFailureMode = options.assetFailureMode ?? "warn"; this.allowFileUrls = options.allowFileUrls ?? false; // Temporary folder for work this.tempDir = options.tempDir ?? resolve(__dirname, "../tempDir/"); this.tempEpubDir = resolve(this.tempDir, this.uuid); // Check the cover image if (this.cover !== null) { this.coverMediaType = mime.getType(this.cover); if (this.coverMediaType === null) { throw new Error(`The cover image can't be processed : ${this.cover}`); } this.coverExtension = mime.getExtension(this.coverMediaType); if (this.coverExtension === null) { throw new Error(`The cover image can't be processed : ${this.cover}`); } } else { this.coverMediaType = null; this.coverExtension = null; } const loadHtml = (content, plugins) => { const html = unified() .use(rehypeParse, { fragment: true }) .use(plugins) // Voids: [] is required for epub generation, and causes little/no harm for non-epub usage .use(rehypeStringify, { allowDangerousHtml: true, voids: [] }) .processSync(content) .toString(); // EPUB content files are XHTML (XML). rehype-stringify uses hast-util-to-html which // unconditionally serializes known HTML boolean attributes (checked, readonly, etc.) // as bare attributes (e.g. `checked`), which is invalid XML. Post-process to // produce the XHTML-compliant form (`checked="checked"`) instead. return html.replace(/ (checked|readonly|disabled|multiple|selected)(?=[ />])/g, ' $1="$1"'); }; this.images = []; this.content = []; // Insert cover in content if (this.cover) { const templatePath = this.customHtmlCoverTemplatePath || resolve(__dirname, "../templates/cover.xhtml.ejs"); if (!existsSync(templatePath)) { throw new Error("Could not resolve path to cover template HTML."); } this.content.push({ id: `item_${this.content.length}`, href: "cover.xhtml", title: "cover", data: "", url: null, author: [], filePath: resolve(this.tempEpubDir, `./OEBPS/cover.xhtml`), templatePath, excludeFromToc: true, beforeToc: true, ornamentalBreakElement: null, isCover: true, }); } // Parse contents & save images const contentTemplatePath = this.customHtmlContentTemplatePath || resolve(__dirname, "../templates/content.xhtml.ejs"); const contentOffset = this.content.length; this.content.push(...options.content.map((content, i) => { const index = contentOffset + i; // Get the content URL & path let href, filePath; if (content.filename === undefined) { // slugify (with strict + lower) handles diacritics // transliteration internally, so the old uslug(diacritics(...)) // pipeline collapses into one call. "Café Résumé" still // produces "cafe-resume" — that contract is locked down by // the chapter-file-naming characterization test. // Cap the slug so `${index}_${slug}.xhtml` stays under the // 255-byte filename limit (ext4/tmpfs/zip entry). A chapter // whose title is a whole paragraph would otherwise blow the // limit and crash rendering with ENAMETOOLONG. The `${index}_` // prefix keeps filenames unique regardless of truncation. const titleSlug = slugify(content.title || "no title", { lower: true, strict: true, }).slice(0, MAX_SLUG_LENGTH); href = `${index}_${titleSlug}.xhtml`; filePath = resolve(this.tempEpubDir, `./OEBPS/${index}_${titleSlug}.xhtml`); } else { href = content.filename.match(/\.xhtml$/) ? content.filename : `${content.filename}.xhtml`; if (content.filename.match(/\.xhtml$/)) { filePath = resolve(this.tempEpubDir, `./OEBPS/${content.filename}`); } else { filePath = resolve(this.tempEpubDir, `./OEBPS/${content.filename}.xhtml`); } } const dir = dirname(filePath); const processImgTags = (node) => { if (!["img", "input"].includes(node.tagName)) { return; } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const url = node.properties.src; if (url === undefined || url === null) { return; } let extension, id; const image = this.imagesByUrl.get(url); if (image) { id = image.id; extension = image.extension; } else { id = uuidv4(); const mediaType = mime.getType(url.replace(/\?.*/, "")); if (mediaType === null) { if (this.verbose) { console.error("[Image Error]", `The image can't be processed : ${url}`); } return; } extension = mime.getExtension(mediaType); if (extension === null) { if (this.verbose) { console.error("[Image Error]", `The image can't be processed : ${url}`); } return; } const epubImage = { id, url, dir, mediaType, extension }; this.imagesByUrl.set(url, epubImage); this.images.push(epubImage); } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion node.properties.src = `images/${id}.${extension}`; }; const validateElements = (node) => { // Strip forbidden tags FIRST, before attribute filtering, so // their children (e.g. inline JS bytes inside <script>) are // also dropped. Replacing with <span> + empty children leaves // the surrounding flow intact and is valid in both XHTML 1.1 // (epub2) and epub3. if (FORBIDDEN_TAGS.has(node.tagName)) { node.tagName = "span"; node.children = []; node.properties = {}; return; } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const attrs = node.properties; if (["img", "br", "hr"].includes(node.tagName)) { if (node.tagName === "img") { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion node.properties.alt = node.properties?.alt || "image-placeholder"; } } for (const k of Object.keys(attrs)) { if (allowedAttributesSet.has(k)) { if (k === "type" && node.tagName === "script") { // Strip type from <script> elements to defang inline JS. // Keep type on other elements (e.g. type="checkbox" on <input>). // eslint-disable-next-line @typescript-eslint/no-non-null-assertion delete node.properties[k]; } } else { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion delete node.properties[k]; } } if (this.version === 2) { if (!allowedXhtml11Tags.includes(node.tagName)) { if (this.verbose) { console.log("Warning (content[" + index + "]):", node.tagName, "tag isn't allowed on EPUB 2/XHTML 1.1 DTD."); } node.tagName = "div"; } } }; // Content ID & directory const id = `item_${index}`; // Parse the content const html = loadHtml(content.data, [ () => (tree) => { visit(tree, "element", validateElements); }, () => (tree) => { visit(tree, "element", processImgTags); }, ]); // Return the EpubContent return { id, href, title: content.title, data: html, url: content.url ?? null, author: content.author ? (typeof content.author === "string" ? [content.author] : content.author) : [], filePath, templatePath: contentTemplatePath, excludeFromToc: content.excludeFromToc === true, // Default to false beforeToc: content.beforeToc === true, // Default to false ornamentalBreakElement: content.ornamentalBreakElement ?? null, }; })); } async render() { // Reset per-call so a reused EPub instance doesn't accumulate // warnings across renders (defensive; the common Lambda case is // one EPub per invocation). this.warnings = []; // Create directories if (!existsSync(this.tempDir)) { mkdirSync(this.tempDir); } mkdirSync(this.tempEpubDir); mkdirSync(resolve(this.tempEpubDir, "./OEBPS")); // downloadAllImage and makeCover are independent network operations // against the same CDN. Run them in parallel so they share the // keep-alive socket pool instead of serialising. if (this.verbose) { console.log("Downloading Images and Cover..."); } await Promise.all([this.downloadAllImage(this.images), this.makeCover()]); if (this.verbose) { console.log("Generating Template Files....."); } await this.generateTempFile(this.content); if (this.verbose) { console.log("Generating Epub Files..."); } await this.generate(); if (this.verbose) { if (this.warnings.length > 0) { console.log(`Done with ${this.warnings.length} warning(s).`); } else { console.log("Done."); } } return { result: "ok", warnings: this.warnings }; } async generateTempFile(contents) { // Create the document's Header const docHeader = this.version === 2 ? `<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd"> <html xmlns="http://www.w3.org/1999/xhtml" lang="${this.lang}"> ` : `<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops" lang="${this.lang}"> `; // Copy the CSS style if (!this.css) { this.css = readFileSync(resolve(__dirname, "../templates/template.css"), { encoding: "utf8" }); } await writeFileAsync(resolve(this.tempEpubDir, "./OEBPS/style.css"), this.css); // Copy fonts in parallel. Each font is a self-contained TTF, no // ordering dependency. Mutating `this.fonts` in-place would race // under Promise.all, so build the new filename array atomically. if (this.fonts.length) { mkdirSync(resolve(this.tempEpubDir, "./OEBPS/fonts")); this.fonts = await Promise.all(this.fonts.map(async (font) => { if (!existsSync(font)) { throw new Error(`Custom font not found at ${font}.`); } const filename = basename(font); await copyFileAsync(font, resolve(this.tempEpubDir, `./OEBPS/fonts/${filename}`)); return filename; })); } // Render + write chapter files in parallel. Each chapter is a // standalone XHTML file with no dependency on sibling renders. The // previous serial loop waited synchronously for the EJS renderFile // and the writeFileSync of each chapter; on a 30-chapter book at // ~30-50ms each, that was 1-1.5s of pure serial work. // // `chapterIndex` is the entry's position in `contents` (cover, if // any, is index 0). Surfaced to templates so an empty chapter // title can fall back to "Chapter N" without the template having // to walk the array. Same pattern the TOC templates already use. await Promise.all(contents.map(async (content, chapterIndex) => { const result = await renderFile(content.templatePath, { ...this, ...content, bookTitle: this.title, chapterIndex, encodeXML, docHeader, }, { escape: (markup) => markup, }); await writeFileAsync(content.filePath, result); })); // META-INF + OPF + NCX (+ optional XHTML TOC) — independent files // with no ordering dependency. The previous serial sequence ran // four awaited renderFile() + writeFileSync calls back-to-back; // parallelising shaves ~50-150ms on slow filesystems. mkdirSync(this.tempEpubDir + "/META-INF"); const opfPath = this.customOpfTemplatePath || resolve(__dirname, `../templates/epub${this.version}/content.opf.ejs`); if (!existsSync(opfPath)) { throw new Error("Custom file to OPF template not found."); } const ncxTocPath = this.customNcxTocTemplatePath || resolve(__dirname, "../templates/toc.ncx.ejs"); if (!existsSync(ncxTocPath)) { throw new Error("Custom file the NCX toc template not found."); } const htmlTocPath = this.includeToc ? this.customHtmlTocTemplatePath || resolve(__dirname, `../templates/epub${this.version}/toc.xhtml.ejs`) : null; if (htmlTocPath && !existsSync(htmlTocPath)) { throw new Error("Custom file to HTML toc template not found."); } const writeJobs = [ writeFileAsync(`${this.tempEpubDir}/META-INF/container.xml`, '<?xml version="1.0" encoding="UTF-8" ?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>'), (async () => writeFileAsync(resolve(this.tempEpubDir, "./OEBPS/content.opf"), await renderFile(opfPath, this)))(), (async () => writeFileAsync(resolve(this.tempEpubDir, "./OEBPS/toc.ncx"), await renderFile(ncxTocPath, this)))(), ]; if (this.version === 2) { // write meta-inf/com.apple.ibooks.display-options.xml [from pedrosanta:xhtml#6] writeJobs.push(writeFileAsync(`${this.tempEpubDir}/META-INF/com.apple.ibooks.display-options.xml`, ` <?xml version="1.0" encoding="UTF-8" standalone="yes"?> <display_options> <platform name="*"> <option name="specified-fonts">true</option> </platform> </display_options> `)); } if (htmlTocPath) { writeJobs.push((async () => writeFileAsync(resolve(this.tempEpubDir, "./OEBPS/toc.xhtml"), await renderFile(htmlTocPath, this)))()); } await Promise.all(writeJobs); } async makeCover() { if (this.cover === null) { return; } const destPath = resolve(this.tempEpubDir, `./OEBPS/cover.${this.coverExtension}`); // pipeline() resolves only when the destination's `finish` event // fires (i.e. all bytes are flushed to disk), unlike the old // pipe()+readable-`end` approach which could resolve before the // write actually completed. This matters because imageSize() runs // immediately afterwards and reading a partially-written file // either fails or returns wrong dimensions. try { if (this.cover.slice(0, 7) === "file://") { // See downloadImage: file:// is rejected by default. if (!this.allowFileUrls) { throw new Error("file:// URLs are not permitted (set allowFileUrls: true to opt in)"); } await pipeline(createReadStream(fileURLToPath(this.cover)), createWriteStream(destPath)); } else if (this.cover.slice(0, 4) === "http" || this.cover.slice(0, 2) === "//") { const httpRequest = await this.httpClient.get(this.cover, { responseType: "stream", headers: { "User-Agent": this.userAgent }, }); await pipeline(httpRequest.data, createWriteStream(destPath)); } else { await pipeline(createReadStream(this.cover), createWriteStream(destPath)); } } catch (err) { if (this.verbose) { console.error(`The cover image can't be processed : ${this.cover}, ${err}`); } // Make sure no partial file is left behind to confuse later // imageSize / cover-asset embedding. try { unlinkSync(destPath); } catch { /* file may not exist; ignore */ } const msg = `cover fetch failed: ${this.cover}: ${err.message}`; if (this.assetFailureMode === "throw") { throw new Error(msg); } this.warnings.push(msg); // Fully unwire the cover so the produced EPUB doesn't ship a // dangling reference. With the URL fetched but no bytes on disk, // the OPF used to keep declaring `<item id="image_cover" href="cover.png"/>` // and the spine kept `<itemref idref="cover"/>`, plus the cover.xhtml // got rendered with `<image href="cover.png"/>` — all of which trip // RSC-001 in epubcheck ("file could not be found"). Nulling the // cover URL drops every conditional `locals.cover` block, and // filtering the cover entry out of `this.content` prevents // generateTempFile from writing cover.xhtml at all. this.cover = null; this.coverMediaType = null; this.coverExtension = null; this.content = this.content.filter((c) => !c.isCover); return; } if (this.verbose) { console.log("[Success] cover image downloaded successfully!"); } // image-size v2 dropped the file-path + callback overloads and // now takes a Buffer/Uint8Array synchronously. Read the cover // we just wrote and pass the bytes in. The read also eliminates // the previous library-internal disk re-open, so the cover bytes // are touched exactly twice on the hot path (download + size). const coverBytes = await readFileAsync(destPath); const result = imageSize(coverBytes); if (!result || !result.width || !result.height) { throw new Error(`Failed to retrieve cover image dimensions for "${destPath}"`); } this.coverDimensions.width = result.width; this.coverDimensions.height = result.height; if (this.verbose) { console.log(`cover image dimensions: ${this.coverDimensions.width} x ${this.coverDimensions.height}`); } } async downloadImage(image) { const filename = resolve(this.tempEpubDir, `./OEBPS/images/${image.id}.${image.extension}`); // Uniform try/catch for all three branches (file://, http(s)://, // local relative path). Previous code only caught HTTP errors and // silently `return`-ed on failure; sync file:// throws blew up the // whole render. assetFailureMode now decides the policy. try { if (image.url.indexOf("file://") === 0) { // file:// URLs in user-controlled HTML are an arbitrary-file- // read vector: a Plate `<img src="file:///etc/passwd">` would // happily get embedded in the produced EPUB. Reject by default; // opt back in via `allowFileUrls: true` for trusted callers // (tests, offline tooling). if (!this.allowFileUrls) { throw new Error("file:// URLs are not permitted (set allowFileUrls: true to opt in)"); } // fileURLToPath handles authority, percent-encoding and Windows // paths correctly; the old `substr(7)` broke on file:///C:/... // and on the deprecated String.substr API itself. await copyFileAsync(fileURLToPath(image.url), filename); return; } if (image.url.indexOf("http") === 0 || image.url.indexOf("//") === 0) { const httpRequest = await this.httpClient.get(image.url, { responseType: "stream", headers: { "User-Agent": this.userAgent }, }); await pipeline(httpRequest.data, createWriteStream(filename)); if (this.verbose) { console.log("[Download Success]", image.url); } return; } // Local relative path (image.dir + image.url) await pipeline(createReadStream(resolve(image.dir, image.url)), createWriteStream(filename)); if (this.verbose) { console.log("[Download Success]", image.url); } } catch (err) { // Best-effort cleanup of any partial write. try { unlinkSync(filename); } catch { /* file may not exist */ } if (this.verbose) { console.error("[Download Error]", image.url, err); } const msg = `image fetch failed: ${image.url}: ${err.message}`; if (this.assetFailureMode === "throw") { throw new Error(msg); } this.warnings.push(msg); } } async downloadAllImage(images) { if (images.length === 0) { return; } mkdirSync(resolve(this.tempEpubDir, "./OEBPS/images")); // True semaphore via p-limit: a slot opens as soon as ANY download // finishes, not when the whole batch finishes. The previous batch // loop (Promise.all over slices) had head-of-line blocking; one // slow image in a batch idled up to N-1 sockets until it returned. // For 30 images with one 3-second outlier that wasted ~3s per // chunk; the semaphore amortises that cost. const limit = pLimit(Math.max(1, this.imageDownloadConcurrency)); await Promise.all(images.map((img) => limit(() => this.downloadImage(img)))); } generate() { // Thanks to Paul Bradley // http://www.bradleymedia.org/gzip-markdown-epub/ (404 as of 28.07.2016) // Web Archive URL: // http://web.archive.org/web/20150521053611/http://www.bradleymedia.org/gzip-markdown-epub // or Gist: // https://gist.github.com/cyrilis/8d48eef37fbc108869ac32eb3ef97bca const cwd = this.tempEpubDir; return new Promise((resolve, reject) => { // Default level 6 (was 9). Level 9 is ~10x slower than 6 for only // ~2-5% smaller output on EPUB content (mostly fonts and JPEG/PNG // which are already compressed). Configurable via constructor for // callers that genuinely want max compression. const archive = archiver("zip", { zlib: { level: this.zipCompressionLevel } }); const output = createWriteStream(this.output); if (this.verbose) { console.log("Zipping temp dir to", this.output); } archive.append("application/epub+zip", { store: true, name: "mimetype" }); archive.directory(cwd + "/META-INF", "META-INF"); archive.directory(cwd + "/OEBPS", "OEBPS"); archive.pipe(output); // Resolve on the writable's 'close' event, not the archiver's // 'end'. archiver fires 'end' when its readable side has emitted // its last chunk; the destination WriteStream may still be // buffering bytes to disk. Resolving early lets the caller read // a partially-written .epub (AdmZip throws "No END header" on // such files). 'close' on the WriteStream is the canonical // "fully flushed and closed" signal. output.on("close", () => { if (this.verbose) { console.log("Done zipping, clearing temp dir..."); } rmSync(cwd, { recursive: true, force: true }); resolve(); }); output.on("error", (err) => reject(err)); // eslint-disable-next-line @typescript-eslint/no-explicit-any archive.on("error", (err) => reject(err)); archive.finalize(); }); } }