auto-print / files / src / build.ts
build.ts
| 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 | } |