@publiwrite/html-to-epub
Version:
A library to generate EPUB from HTML. Inspired by epub-gen.
847 lines (846 loc) • 35.7 kB
JavaScript
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();
});
}
}