UNPKG

@publiwrite/html-to-epub

Version:

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

1,055 lines (992 loc) 36.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 { Element } from "hast"; 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 { Plugin, 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: ReadonlyArray<string> = [ "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<string>(allowedAttributes); // Current directory const __dirname = fileURLToPath(new URL(".", import.meta.url)); export interface EpubContentOptions { title: string; data: string; url?: string; author?: Array<string> | string; filename?: string; excludeFromToc?: boolean; beforeToc?: boolean; ornamentalBreakElement?: string; } export interface EpubOptions { title: string; description: string; cover?: string; publisher?: string; author?: Array<string> | string; tocTitle?: string; appendChapterTitles?: boolean; includeToc?: boolean; date?: string; lang?: string; css?: string; fonts?: string[]; content: EpubContentOptions[]; customOpfTemplatePath?: string; customNcxTocTemplatePath?: string; customHtmlTocTemplatePath?: string; customHtmlCoverTemplatePath?: string; customHtmlContentTemplatePath?: string; version?: number; userAgent?: string; verbose?: boolean; tempDir?: string; /** * gzip compression level for the final EPUB ZIP (0 = store-only, * 9 = max). The previous default was 9, which is ~10x slower than * 6 for ~2-5% smaller output; EPUB payloads are mostly fonts and * already-compressed images, so the marginal saving is tiny. * Default 6. */ zipCompressionLevel?: number; /** * Max concurrent image downloads. Inline images used to be fetched * one-at-a-time in a serial loop. For a 30-image book over 100-200ms * RTT to S3/CDN this added 3-6s of pure waiting. Now bounded-parallel * via a true semaphore (p-limit) so a slot opens as soon as any one * download finishes, not when the whole batch finishes. * Default 8. */ imageDownloadConcurrency?: number; /** * What to do when an inline image or cover image fails to fetch. * * "warn" (default): log if verbose, push a message to `warnings`, * continue producing the EPUB. Backwards-compatible with * pre-0.1.0 silent-skip behaviour. * "throw": reject `render()` on the first failure. Use this when * a missing image means the EPUB is unfit to ship (which * is most production cases). The previous default silently * shipped broken EPUBs when the network was unreachable. */ assetFailureMode?: "warn" | "throw"; /** * Whether to honour `file://` URLs in inline images and cover. The * library used to accept them unconditionally, which meant any * Plate-rendered `<img src="file:///etc/passwd">` would be embedded * verbatim in the generated EPUB. Default is now `false` (reject). * Set `true` only if you control the rendered HTML and need local * file embedding (e.g. tests or offline tooling). */ allowFileUrls?: boolean; } interface EpubContent { id: string; href: string; title: string; data: string; url: string | null; author: Array<string>; filePath: string; templatePath: string; excludeFromToc: boolean; beforeToc: boolean; ornamentalBreakElement: string | null; // Set on the cover.xhtml entry only. The OPF manifest already // declares cover.xhtml via the conditional <item id="cover"/> block, // so the per-content forEach loop must skip this entry to avoid // OPF-074 ("declared in several manifest item"). Set by the library // when it pushes the cover into `this.content`; never set by callers. isCover?: boolean; } interface EpubImage { id: string; url: string; dir: string; mediaType: string; extension: string; } export class EPub { uuid: string; title: string; description: string; cover: string | null; coverMediaType: string | null; coverExtension: string | null; coverDimensions = { width: 0, height: 0, }; publisher: string; author: Array<string>; tocTitle: string; appendChapterTitles: boolean; includeToc: boolean; date: string; lang: string; css: string | null; fonts: Array<string>; content: Array<EpubContent>; images: Array<EpubImage>; customOpfTemplatePath: string | null; customNcxTocTemplatePath: string | null; customHtmlCoverTemplatePath: string | null; customHtmlContentTemplatePath: string | null; customHtmlTocTemplatePath: string | null; version: number; userAgent: string; verbose: boolean; tempDir: string; tempEpubDir: string; output: string; zipCompressionLevel: number; imageDownloadConcurrency: number; assetFailureMode: "warn" | "throw"; allowFileUrls: boolean; /** * 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: string[] = []; // 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). private imagesByUrl = new Map<string, EpubImage>(); // 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. private httpClient = axios.create({ httpsAgent: new HttpsAgent({ keepAlive: true, maxSockets: 16 }), timeout: 30_000, maxContentLength: 100 * 1024 * 1024, }); constructor(options: EpubOptions, output: string) { // 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: string, plugins: Plugin[]) => { 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<EpubContent>((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: Element) => { if (!["img", "input"].includes(node.tagName)) { return; } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const url = node.properties!.src as string | null | undefined; 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: 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: Element) => { // 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(): Promise<{ result: string; warnings: string[] }> { // 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 }; } private async generateTempFile(contents: EpubContent[]) { // 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: Promise<unknown>[] = [ 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); } private async makeCover(): Promise<void> { 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 as Error).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}`); } } private async downloadImage(image: EpubImage): Promise<void> { 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 as Error).message}`; if (this.assetFailureMode === "throw") { throw new Error(msg); } this.warnings.push(msg); } } private async downloadAllImage(images: Array<EpubImage>): Promise<void> { 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)))); } private generate(): Promise<void> { // 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: unknown) => reject(err as Error)); // eslint-disable-next-line @typescript-eslint/no-explicit-any archive.on("error", (err: any) => reject(err)); archive.finalize(); }); } }