auto-print / files / src / build.ts
build.ts
a6d0aed2 2026-10-24 Pavel Safronov | 1 | // Builds the output files of one book, by running pandoc, a browser and Kindle Previewer. |
| 2 | ||
| 3 | import { spawn } from "node:child_process"; | |
| 4 | import fs from "node:fs"; | |
| 5 | import os from "node:os"; | |
| 6 | import path from "node:path"; | |
| 7 | import { fileURLToPath, pathToFileURL } from "node:url"; | |
| 8 | import type { Book } from "./config.ts"; | |
| 9 | import { assemble, pandocFormat, readExtraPages, readStory, type Target } from "./manuscript.ts"; | |
| 10 | import { displayPath, fail, run, sleep } from "./util.ts"; | |
| 11 | ||
| 12 | /** Stylesheets and the docx reference doc. The folder sits next to src/ and dist/. */ | |
| 13 | export const assetsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "assets"); | |
| 14 | ||
| 15 | const browserCandidates = [ | |
| 16 | "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", | |
| 17 | "/Applications/Chromium.app/Contents/MacOS/Chromium", | |
| 18 | "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", | |
| 19 | "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser", | |
| 20 | "/usr/bin/google-chrome", | |
| 21 | "/usr/bin/chromium", | |
| 22 | "/usr/bin/chromium-browser", | |
| 23 | ]; | |
| 24 | ||
| 25 | const kindlePreviewerCandidates = [ | |
| 26 | "/Applications/Kindle Previewer 4.app/Contents/MacOS/KindlePreviewer4CLI", | |
| 27 | ]; | |
| 28 | ||
| 29 | function findBrowser(): string { | |
| 30 | const fromEnv = process.env.CHROME_PATH; | |
| 31 | if (fromEnv) return fromEnv; | |
| 32 | const found = browserCandidates.find((candidate) => fs.existsSync(candidate)); | |
| 33 | if (!found) { | |
| 34 | fail("No Chromium-based browser found (looked for Chrome, Chromium, Edge, Brave). Install one, or set CHROME_PATH."); | |
| 35 | } | |
| 36 | return found; | |
| 37 | } | |
| 38 | ||
| 39 | /** | |
| 40 | * Runs the browser headless against a local HTML file, with a throwaway profile. | |
| 41 | * | |
| 42 | * Headless Chrome often keeps running after it has written its output file. | |
| 43 | * So this does not wait for the browser to exit. It waits until the output | |
| 44 | * file exists and has stopped growing, and then stops the browser itself. | |
| 45 | */ | |
| 46 | async function runBrowser(tempDir: string, htmlPath: string, browserArgs: string[], expectedOutput: string): Promise<void> { | |
| 47 | const browser = findBrowser(); | |
| 48 | const child = spawn(browser, [ | |
| 49 | "--headless", | |
| 50 | "--disable-gpu", | |
| 51 | "--no-first-run", | |
| 52 | "--no-default-browser-check", | |
| 53 | "--disable-breakpad", | |
| 54 | "--disable-background-networking", | |
| 55 | "--disable-component-update", | |
| 56 | `--user-data-dir=${path.join(tempDir, "browser-profile")}`, | |
| 57 | ...browserArgs, | |
| 58 | pathToFileURL(htmlPath).href, | |
| 59 | ], { stdio: "ignore", detached: true }); | |
| 60 | ||
| 61 | let exited = false; | |
| 62 | let spawnError: Error | null = null; | |
| 63 | child.on("exit", () => { exited = true; }); | |
| 64 | child.on("error", (error) => { spawnError = error; exited = true; }); | |
| 65 | ||
| 66 | const deadline = Date.now() + 180_000; | |
| 67 | let lastSize = -1; | |
| 68 | let stableChecks = 0; | |
| 69 | while (!exited && Date.now() < deadline) { | |
| 70 | await sleep(250); | |
| 71 | const size = fs.existsSync(expectedOutput) ? fs.statSync(expectedOutput).size : 0; | |
| 72 | stableChecks = size > 0 && size === lastSize ? stableChecks + 1 : 0; | |
| 73 | lastSize = size; | |
| 74 | if (stableChecks >= 4) break; | |
| 75 | } | |
| 76 | if (!exited) { | |
| 77 | // The browser runs in its own process group, so this also stops its helper processes. | |
| 78 | try { | |
| 79 | process.kill(-child.pid!, "SIGKILL"); | |
| 80 | } catch { | |
| 81 | child.kill("SIGKILL"); | |
| 82 | } | |
| 83 | while (!exited) await sleep(50); | |
| 84 | // Give helper processes a moment to disappear, so the temp folder can be removed cleanly. | |
| 85 | await sleep(300); | |
| 86 | } | |
| 87 | ||
| 88 | if (spawnError) fail(`${path.basename(browser)} could not be started: ${(spawnError as Error).message}`); | |
| 89 | if (!fs.existsSync(expectedOutput) || fs.statSync(expectedOutput).size === 0) { | |
| 90 | fail(`${path.basename(browser)} did not produce ${path.basename(expectedOutput)}.`); | |
| 91 | } | |
| 92 | } | |
| 93 | ||
| 94 | function escapeHtml(text: string): string { | |
| 95 | return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); | |
| 96 | } | |
| 97 | ||
| 98 | /** Makes a plain text cover: "Title / by / Author", in large bold letters on white. */ | |
| 99 | async function generateCover(tempDir: string, title: string, author: string): Promise<string> { | |
| 100 | const width = 1200; | |
| 101 | const height = 1700; | |
| 102 | const htmlPath = path.join(tempDir, "cover.html"); | |
| 103 | const pngPath = path.join(tempDir, "cover.png"); | |
| 104 | const html = `<!doctype html> | |
| 105 | <html><head><meta charset="utf-8"><style> | |
| 106 | html, body { margin: 0; width: ${width}px; height: ${height}px; background: white; overflow: hidden; } | |
| 107 | body { display: flex; align-items: center; justify-content: center; } | |
| 108 | #text { font-family: Helvetica, Arial, sans-serif; font-weight: bold; color: #1a1a1a; | |
| 109 | text-align: center; line-height: 1.15; font-size: 240px; } | |
| 110 | </style></head><body> | |
| 111 | <div id="text">${escapeHtml(title)}<br>by<br>${escapeHtml(author)}</div> | |
| 112 | <script> | |
| 113 | // Shrink the text until it fits inside the cover with a margin. | |
| 114 | const text = document.getElementById("text"); | |
| 115 | let size = 240; | |
| 116 | while (size > 40 && (text.scrollWidth > ${width - 160} || text.scrollHeight > ${height - 200})) { | |
| 117 | size -= 4; | |
| 118 | text.style.fontSize = size + "px"; | |
| 119 | } | |
| 120 | </script> | |
| 121 | </body></html>`; | |
| 122 | fs.writeFileSync(htmlPath, html); | |
| 123 | await runBrowser(tempDir, htmlPath, [`--screenshot=${pngPath}`, `--window-size=${width},${height}`, "--hide-scrollbars"], pngPath); | |
| 124 | return pngPath; | |
| 125 | } | |
| 126 | ||
| 127 | function findKindlePreviewer(): string { | |
| 128 | const fromEnv = process.env.KINDLE_PREVIEWER_PATH; | |
| 129 | if (fromEnv) return fromEnv; | |
| 130 | const found = kindlePreviewerCandidates.find((candidate) => fs.existsSync(candidate)); | |
| 131 | if (!found) { | |
| 132 | fail("Kindle Previewer 4 is not installed. On macOS, install it with: brew install --cask kindle-previewer"); | |
| 133 | } | |
| 134 | return found; | |
| 135 | } | |
| 136 | ||
| 137 | /** | |
| 138 | * Converts an epub into a kpf, the file format of Amazon's Kindle tools. | |
| 139 | * | |
| 140 | * Kindle Previewer names the output after the input file and puts it in a | |
| 141 | * "KPF" subfolder, next to its log files. It also exits with code 0 when the | |
| 142 | * conversion fails. So this runs it in the temp folder, looks for the kpf | |
| 143 | * file, and copies only that file to its final place. | |
| 144 | */ | |
| 145 | function convertEpubToKpf(tempDir: string, epubPath: string, kpfPath: string): void { | |
| 146 | const previewer = findKindlePreviewer(); | |
| 147 | const workDir = path.join(tempDir, "kindle"); | |
| 148 | fs.mkdirSync(workDir, { recursive: true }); | |
| 149 | const output = run(previewer, [epubPath, "--convert", "--output", workDir], undefined, 600_000); | |
| 150 | const kpfDir = path.join(workDir, "KPF"); | |
| 151 | const produced = fs.existsSync(kpfDir) ? fs.readdirSync(kpfDir).filter((name) => name.endsWith(".kpf")) : []; | |
| 152 | if (produced.length === 0) { | |
| 153 | const lastLines = output.trim().split("\n").slice(-4).join("\n"); | |
| 154 | fail(`Kindle Previewer did not produce a kpf file. Its last messages were:\n${lastLines}`); | |
| 155 | } | |
| 156 | fs.copyFileSync(path.join(kpfDir, produced[0]!), kpfPath); | |
| 157 | } | |
| 158 | ||
| 159 | /** pandoc's own epub stylesheet, followed by auto-print's additions from assets/epub.css. */ | |
| 160 | function writeEpubStylesheet(tempDir: string): string { | |
| 161 | const cssPath = path.join(tempDir, "epub.css"); | |
| 162 | const defaults = run("pandoc", ["--print-default-data-file", "epub.css"]); | |
| 163 | fs.writeFileSync(cssPath, `${defaults}\n${fs.readFileSync(path.join(assetsDir, "epub.css"), "utf8")}`); | |
| 164 | return cssPath; | |
| 165 | } | |
| 166 | ||
| 167 | /** | |
| 168 | * Builds every format of one book into `outDir/<slug>/`. | |
| 169 | * A format that fails is reported, and the other formats are still built. | |
| 170 | * Returns the number of formats that failed. | |
| 171 | */ | |
| 172 | export async function buildBook(book: Book, outDir: string): Promise<number> { | |
| 173 | if (!fs.existsSync(book.source)) fail(`Source file not found: ${book.source}`); | |
| 174 | const { story, files } = readStory(book.source, book.section); | |
| 175 | const front = readExtraPages(book.source, book.front, "front"); | |
| 176 | const back = readExtraPages(book.source, book.back, "back"); | |
| 177 | const markdownFor = (target: Target) => assemble(story, front, back, target, book.chapterBreaks, book.title); | |
| 178 | ||
| 179 | const bookDir = path.join(outDir, book.slug); | |
| 180 | fs.mkdirSync(bookDir, { recursive: true }); | |
| 181 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `auto-print-${book.slug}-`)); | |
| 182 | let failures = 0; | |
| 183 | ||
| 184 | // gfm matches how GitHub, GitLab and the VS Code preview show markdown files. | |
| 185 | // "smart" turns straight quotes and dashes into typographic ones. | |
| 186 | // The other extensions let assemble() mark the extra pages and their page breaks. | |
| 187 | const commonArgs = [ | |
| 188 | `--from=${pandocFormat}`, | |
| 189 | `--metadata=title:${book.title}`, | |
| 190 | `--metadata=author:${book.author}`, | |
| 191 | `--metadata=lang:${book.language}`, | |
| 192 | // Lets images in the story be found relative to the files they are written in. | |
| 193 | `--resource-path=${[...new Set(files.map((file) => path.dirname(file)))].join(path.delimiter)}`, | |
| 194 | ]; | |
| 195 | ||
| 196 | const buildEpub = async (epubPath: string): Promise<void> => { | |
| 197 | let coverPath: string; | |
| 198 | if (book.cover) { | |
| 199 | coverPath = book.cover; | |
| 200 | if (!fs.existsSync(coverPath)) fail(`Cover image not found: ${coverPath}`); | |
| 201 | } else { | |
| 202 | coverPath = await generateCover(tempDir, book.title, book.author); | |
| 203 | } | |
| 204 | run("pandoc", [ | |
| 205 | ...commonArgs, | |
| 206 | "--to=epub3", | |
| 207 | // pandoc starts a new file, and so a new page, at every level 1 heading. | |
| 208 | // assemble() decides which headings are at level 1. See chapterBreaks. | |
| 209 | "--split-level=1", | |
| 210 | `--css=${writeEpubStylesheet(tempDir)}`, | |
| 211 | `--metadata=identifier:${book.slug}`, | |
| 212 | `--metadata=date:${new Date().toISOString().slice(0, 10)}`, | |
| 213 | `--epub-cover-image=${coverPath}`, | |
| 214 | `--output=${epubPath}`, | |
| 215 | ], markdownFor("epub")); | |
| 216 | }; | |
| 217 | ||
| 218 | // The kpf is made from an epub. This epub lives in the temp folder, so the | |
| 219 | // kpf can be built even when "epub" itself is not in the list of formats. | |
| 220 | let epubForKindle: string | null = null; | |
| 221 | ||
| 222 | try { | |
| 223 | for (const format of book.formats) { | |
| 224 | const outPath = path.join(bookDir, `${book.slug}.${format}`); | |
| 225 | try { | |
| 226 | if (format === "epub") { | |
| 227 | await buildEpub(outPath); | |
| 228 | } else if (format === "docx") { | |
| 229 | run("pandoc", [ | |
| 230 | ...commonArgs, | |
| 231 | "--to=docx", | |
| 232 | // pandoc's default Word styles, plus a style for the dedication. | |
| 233 | `--reference-doc=${path.join(assetsDir, "reference.docx")}`, | |
| 234 | `--output=${outPath}`, | |
| 235 | ], markdownFor("docx")); | |
| 236 | } else if (format === "pdf") { | |
| 237 | const htmlPath = path.join(tempDir, `${book.slug}.html`); | |
| 238 | run("pandoc", [ | |
| 239 | ...commonArgs, | |
| 240 | "--to=html5", | |
| 241 | "--standalone", | |
| 242 | "--embed-resources", | |
| 243 | `--css=${path.join(assetsDir, "print.css")}`, | |
| 244 | `--output=${htmlPath}`, | |
| 245 | ], markdownFor("html")); | |
| 246 | fs.rmSync(outPath, { force: true }); | |
| 247 | await runBrowser(tempDir, htmlPath, ["--no-pdf-header-footer", `--print-to-pdf=${outPath}`], outPath); | |
| 248 | } else { | |
| 249 | findKindlePreviewer(); // Fail early, before spending time on the epub. | |
| 250 | if (epubForKindle === null) { | |
| 251 | epubForKindle = path.join(tempDir, `${book.slug}.epub`); | |
| 252 | await buildEpub(epubForKindle); | |
| 253 | } | |
| 254 | convertEpubToKpf(tempDir, epubForKindle, outPath); | |
| 255 | } | |
| 256 | const kilobytes = Math.round(fs.statSync(outPath).size / 1024); | |
| 257 | console.log(` wrote ${displayPath(outPath)} (${kilobytes} KB)`); | |
| 258 | } catch (error) { | |
| 259 | failures += 1; | |
| 260 | console.error(` FAILED ${format}: ${(error as Error).message}`); | |
| 261 | } | |
| 262 | } | |
| 263 | } finally { | |
| 264 | fs.rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); | |
| 265 | } | |
| 266 | return failures; | |
| 267 | } |