fuzzyworld

auto-print / files / src / build.ts

build.ts

Blame: the commit that last changed each line · file · history

a6d0aed2 2026-10-24 Pavel Safronov1// Builds the output files of one book, by running pandoc, a browser and Kindle Previewer.
2
3import { spawn } from "node:child_process";
4import fs from "node:fs";
5import os from "node:os";
6import path from "node:path";
7import { fileURLToPath, pathToFileURL } from "node:url";
8import type { Book } from "./config.ts";
9import { assemble, pandocFormat, readExtraPages, readStory, type Target } from "./manuscript.ts";
10import { displayPath, fail, run, sleep } from "./util.ts";
11
12/** Stylesheets and the docx reference doc. The folder sits next to src/ and dist/. */
13export const assetsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "assets");
14
15const 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
25const kindlePreviewerCandidates = [
26 "/Applications/Kindle Previewer 4.app/Contents/MacOS/KindlePreviewer4CLI",
27];
28
29function 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 */
46async 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
94function escapeHtml(text: string): string {
95 return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
96}
97
98/** Makes a plain text cover: "Title / by / Author", in large bold letters on white. */
99async 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
127function 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 */
145function 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. */
160function 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 */
172export 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}