From a804712b9f6aa126ceff6d72c93770115fd77768 Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Mon, 5 Oct 2026 19:19:31 +0000 Subject: [PATCH] Preview a whole chapter, from any build npm run preview -- --chapter renders every page of one chapter, and --pdf reads a build other than the digital edition. The build writes a page map beside each PDF giving each chapter's first and last page, so a chapter preview shows the book's own pages, broken where the build breaks them. Docs brought in line with the code: CLAUDE.md described preview's first argument as a chapter and said the build printed only the output path; the cover example in PRINT-SPECIFICATION.md used an old page count; and resize-cover's --out and the macOS Chromium fallback were undocumented. The preview now falls back to Homebrew's Chromium as the build does. Claude-Session: https://claude.ai/code/session_01KtTPqhYezQFvpkDqQFUUVT --- CLAUDE.md | 9 ++- PRINT-SPECIFICATION.md | 12 ++-- README.md | 2 +- scripts/build-pdf.mjs | 30 +++++++-- scripts/preview-page.mjs | 138 +++++++++++++++++++++++++++++---------- 5 files changed, 145 insertions(+), 46 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0b7e6581..c7e190c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ Three commands, all of which must pass: ``` npm run lint # banned phrases, heading rules, figure and chapter-reference rules node scripts/check-figure-geometry.mjs # renders every SVG in Chromium: text overlap, arrowheads, coin marks -npm run build-pdf-digital # the whole book; prints only the output path on success +npm run build-pdf-digital # the whole book; on success prints only the output path and the trim crop ``` The first two also run in GitHub Actions on every pull request and every push to `main` @@ -16,8 +16,11 @@ does not run there, so run it yourself. The build reads the finished PDF back an quote printed outside the code face, which is the one check on the typographic quotes that does not trust the build's own conversion. -`npm run preview -- ""` renders the page of the digital edition carrying a phrase, -which is faster than paging through the book when checking one edit. +To look at a page or a chapter, build the book and render it to PNG rather than paging through the PDF: +`npm run preview -- ""` renders the page carrying a phrase, and +`npm run preview -- --chapter ` renders every page of one chapter, as +`build/-p.png`. Both read the digital edition unless `--pdf ` names another build, and +the pages are the book's own, broken where the build breaks them. `node scripts/check-figure-bands.mjs` reports horizontal dead space in the figures — the hole a deletion leaves when the columns around it stay put. It is advisory, not a gate: it over-reports on diff --git a/PRINT-SPECIFICATION.md b/PRINT-SPECIFICATION.md index 1b4b5ecb..986b1b74 100644 --- a/PRINT-SPECIFICATION.md +++ b/PRINT-SPECIFICATION.md @@ -90,9 +90,9 @@ Its art is 744 x 969, an aspect of 0.7678, which is the page's exactly, so nothi The drawing is vector throughout and its type is outlined, so the file embeds no fonts and nothing in it depends on a face being found at build time. The 300dpi rasterization is the only thing that limits it. An export from Figma carrying a layer blur, a non-normal blend mode, or a placed photograph would not have that property, because Figma flattens those regions to bitmap at frame resolution and its PDF export has no scale multiplier; such an export needs its frame scaled to 2232 x 2907 first, so the flattened parts land at 300dpi. -**The press cover is a separate export, sized by `npm run resize-cover`.** A file going to KDP wraps the whole book: 0.125in bleed + 7.44in back cover + spine + 7.44in front cover + 0.125in bleed wide, and 9.69 + 0.25in tall. The spine is the interior page count times KDP's per-page thickness (0.002252in for white paper, 0.0025in for cream), so at 308 interior pages on white (309 with the cover) the target is 15.824 x 9.94in with a 0.694in spine. None of that belongs in `assets/cover.pdf`, which is the front cover at trim and nothing else. +**The press cover is a separate export, sized by `npm run resize-cover`.** A file going to KDP wraps the whole book: 0.125in bleed + 7.44in back cover + spine + 7.44in front cover + 0.125in bleed wide, and 9.69 + 0.25in tall. The spine is the interior page count times KDP's per-page thickness (0.002252in for white paper, 0.0025in for cream), so at 307 interior pages on white (the digital edition's 309 less the cover and its blank reverse) the target is 15.821 x 9.94in with a 0.691in spine. None of that belongs in `assets/cover.pdf`, which is the front cover at trim and nothing else. -`npm run resize-cover` rescales the Figma export of the full wrap, kept at `assets/cover-wrap.pdf` (another path can be passed as an argument, `--pages N` and `--paper white|cream` as options), to that exact size, filling the target and cropping overflow symmetrically into the bleed. Page count is read from the paperback interior, the file actually uploaded, unless `--pages` is passed. `npm run build-pdf-paperback` runs it straight after building that interior, so the two always agree; run it alone to pass `--paper cream` or `--pages`. The script also measures the spine the art was drawn for, by aligning trim heights and taking the width left over after two covers and two bleeds, and warns when that disagrees with the real spine: the art then bleeds onto or short of the true spine edges by half the difference per side, and spine text rides off-center by the same amount. A warning means the rescale is good enough for a proof and the production cover should be re-exported from a Figma frame drawn for the frozen page count (1 px = 1 pt: 15.824 x 9.94in is a 1139 x 716 px frame). +`npm run resize-cover` rescales the Figma export of the full wrap, kept at `assets/cover-wrap.pdf` (another path can be passed as an argument, `--pages N`, `--paper white|cream` and `--out ` as options; the output defaults to `build/cover-kdp-pp-.pdf`), to that exact size, filling the target and cropping overflow symmetrically into the bleed. Page count is read from the paperback interior, the file actually uploaded, unless `--pages` is passed. `npm run build-pdf-paperback` runs it straight after building that interior, so the two always agree; run it alone to pass `--paper cream` or `--pages`. The script also measures the spine the art was drawn for, by aligning trim heights and taking the width left over after two covers and two bleeds, and warns when that disagrees with the real spine: the art then bleeds onto or short of the true spine edges by half the difference per side, and spine text rides off-center by the same amount. A warning means the rescale is good enough for a proof and the production cover should be re-exported from a Figma frame drawn for the frozen page count (1 px = 1 pt: 15.821 x 9.94in is a 1139 x 716 px frame). ## US Letter @@ -110,15 +110,17 @@ npm run build-pdf-draft -- --reviewer "..." # advance copy: name and commit ha npm run build-pdf-draft -- --reviewer "..." --size letter # the same at US Letter npm run build-pdf-digital -- out.pdf # explicit output path npm run build-pdf-digital -- --help # the switches, from the script itself -npm run preview -- "" # rasterize one page of the digital edition to check it +npm run preview -- "" # rasterize the page of the digital edition carrying the phrase +npm run preview -- --chapter escrow # rasterize every page of one chapter, matched by file name or title +npm run preview -- --chapter escrow --pdf "build/Building Financial Software on Solana (paperback interior).pdf" # the same from another build ``` An unrecognized switch, a `--size` or `--reviewer` with no value or an unknown size, and more than one output path all print the usage and exit nonzero, rather than taking the stray argument for a filename. So does a draft with no `--reviewer`, since a review copy nobody can trace is the one the stamp exists to prevent, and a final build with one, since a review copy is not final. -Each build writes its own file in `build/`: the digital edition is `Building Financial Software on Solana.pdf`, and every other build adds a label, `(paperback interior)`, `(draft, )` or `(letter)`, so one build never overwrites another. The three scripts set `FINAL=1`, `NO_COVER=1` and `NO_LINKS=1` for `scripts/build-pdf.mjs`, and those still compose by hand for an unusual combination: `FINAL=1 NO_COVER=1 node scripts/build-pdf.mjs --size letter` is a cover-less final Letter build. +Each build writes its own file in `build/`: the digital edition is `Building Financial Software on Solana.pdf`, and every other build adds a label, `(paperback interior)`, `(draft, )` or `(letter)`, so one build never overwrites another. Beside each PDF goes a page map of the same name ending `.pages.json`, listing each chapter's file, title, and first and last page; `npm run preview -- --chapter` reads it, so a chapter preview breaks pages exactly where that build does. Previews land in `build/` as `-p.png`. The three scripts set `FINAL=1`, `NO_COVER=1` and `NO_LINKS=1` for `scripts/build-pdf.mjs`, and those still compose by hand for an unusual combination: `FINAL=1 NO_COVER=1 node scripts/build-pdf.mjs --size letter` is a cover-less final Letter build. **The interior uploaded to KDP carries no links.** A link in a PDF is an annotation laid over the page rather than ink on it, and KDP removes every one it finds, then reports each page it removed one from as "non-printable markup" to be checked: every table of contents page, and every page with a cross-reference or an address. Nothing printed changes when they go, because every address is printed in full after its link text, but the report cannot say so. `NO_LINKS=1` builds the file without them, and without the named destinations the internal links pointed at, so KDP has nothing to remove. Every other build keeps them, for reading on screen. -The build needs a Chromium binary, found at `/opt/pw-browsers/chromium` or wherever `CHROMIUM_PATH` points. +The build and the preview need a Chromium binary, found at `CHROMIUM_PATH` if set, otherwise at `/opt/pw-browsers/chromium`, otherwise at `/opt/homebrew/bin/chromium` (`brew install chromium` on a Mac). At the time of writing the manuscript runs to **309 pages** with the cover, which puts it in KDP's 301 to 500 page band for gutter purposes, whose 0.625in minimum the 1in inside margin clears. Every figure is drawn, so that number will move with editing rather than with artwork. diff --git a/README.md b/README.md index 060b6ef6..7b07e990 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ The book's home is [solanabook.org](https://solanabook.org), which hosts the pub ## Building the PDF -`npm install` then `npm run build-pdf-digital` renders the whole manuscript, in table-of-contents order, as a single-column textbook PDF (serif type, running headers, page numbers, a contents page with page numbers) into `build/`. The page is 7.44 x 9.69in, the KDP standard trim closest in proportion to the 7 x 9.19in size technical books are usually printed at. Three builds share it: `build-pdf-digital` is the finished book for reading on screen, `build-pdf-paperback` the interior uploaded to KDP, and `build-pdf-draft -- --reviewer ""` an advance copy stamped for one reviewer, which takes `--size letter` for a reviewer printing on an office printer. `npm run resize-cover` sizes the full-wrap cover export (`assets/cover-wrap.pdf`) to KDP's exact requirement, spine width included. [PRINT-SPECIFICATION.md](PRINT-SPECIFICATION.md) records the trim, margins, type, cover math, and the rest of the physical book. Requires a Chromium binary; the script uses `/opt/pw-browsers/chromium` or the `CHROMIUM_PATH` environment variable. +`npm install` then `npm run build-pdf-digital` renders the whole manuscript, in table-of-contents order, as a single-column textbook PDF (serif type, running headers, page numbers, a contents page with page numbers) into `build/`. The page is 7.44 x 9.69in, the KDP standard trim closest in proportion to the 7 x 9.19in size technical books are usually printed at. Three builds share it: `build-pdf-digital` is the finished book for reading on screen, `build-pdf-paperback` the interior uploaded to KDP, and `build-pdf-draft -- --reviewer ""` an advance copy stamped for one reviewer, which takes `--size letter` for a reviewer printing on an office printer. `npm run resize-cover` sizes the full-wrap cover export (`assets/cover-wrap.pdf`) to KDP's exact requirement, spine width included. [PRINT-SPECIFICATION.md](PRINT-SPECIFICATION.md) records the trim, margins, type, cover math, and the rest of the physical book. Requires a Chromium binary: `CHROMIUM_PATH` if set, otherwise `/opt/pw-browsers/chromium`, otherwise `/opt/homebrew/bin/chromium` (`brew install chromium` on a Mac). `npm run preview` renders pages of a built PDF to PNG, either the page carrying a phrase or a whole chapter; [PRINT-SPECIFICATION.md](PRINT-SPECIFICATION.md) lists its options. ## Preparing for press diff --git a/scripts/build-pdf.mjs b/scripts/build-pdf.mjs index 428a302e..3f91b099 100644 --- a/scripts/build-pdf.mjs +++ b/scripts/build-pdf.mjs @@ -2,10 +2,12 @@ // Build a technical-book style PDF of the manuscript. // // Reads TABLE-OF-CONTENTS.md to get the chapters in order, converts each -// markdown file to HTML, lays the result out as book pages at the print trim (serif -// type, running headers, page numbers, chapters on fresh pages, a table of -// contents with page numbers) using Paged.js, and prints to PDF with the -// system Chromium via playwright-core. +// markdown file to HTML, lays the result out as book pages at the trim, or at +// --size letter (serif type, running headers, page numbers, chapters on fresh +// pages, a table of contents with page numbers) using Paged.js, and prints to +// PDF with the system Chromium via playwright-core. Beside the PDF it writes a +// page map, .pages.json, giving each chapter's first and last page, which +// npm run preview -- --chapter reads. // // Usage: npm run build-pdf-draft -- --reviewer "" // npm run build-pdf-paperback @@ -964,6 +966,22 @@ const openingPages = await page.evaluate(() => { return [...opening.values()].sort((a, b) => a.number - b.number); }); +// The pages each chapter spans, written beside the PDF so npm run preview can +// render a whole chapter by name. Read from the laid-out book rather than +// rebuilt per chapter, so the preview breaks pages exactly where the book does. +const chapterSpans = await page.evaluate(() => { + // Paged.js gives every fragment of a split section the same data-ref, but + // only one of them keeps the section's id, which is what names the chapter. + const spans = new Map(); + for (const section of document.querySelectorAll("section.chapter")) { + const number = Number(section.closest(".pagedjs_page")?.getAttribute("data-page-number")); + const key = section.dataset.ref ?? section.id; + const span = spans.get(key) ?? { id: "", first: number, last: number }; + spans.set(key, { id: span.id || section.id, first: Math.min(span.first, number), last: Math.max(span.last, number) }); + } + return Object.fromEntries([...spans.values()].filter(({ id }) => id).map(({ id, first, last }) => [id, { first, last }])); +}); + // Nothing printed may fall outside its boundary. Paged.js clips every page to // its content area, and a figure is clipped to its own box, so a line that // lands past either edge is not a line in the margin but a line cut off: the @@ -1108,6 +1126,10 @@ if (stripLinks) { pdf.catalog.delete(PDFName.of("Dests")); } writeFileSync(outputPath, await pdf.save()); +const pageMap = sections + .filter((section) => chapterSpans[section.id]) + .map((section) => ({ path: section.path, title: section.title, ...chapterSpans[section.id] })); +writeFileSync(outputPath.replace(/\.pdf$/i, "") + ".pages.json", JSON.stringify(pageMap, null, 2) + "\n"); console.log(`Wrote "${outputPath}"`); if (croppedPages > 0) console.log(`Cropped ${croppedPages} page(s) to the ${PAGE_WIDTH_IN} x ${PAGE_HEIGHT_IN}in trim.`); diff --git a/scripts/preview-page.mjs b/scripts/preview-page.mjs index 443ab155..ddff00e3 100644 --- a/scripts/preview-page.mjs +++ b/scripts/preview-page.mjs @@ -1,31 +1,97 @@ -// Rasterize a page of the digital edition (npm run build-pdf-digital) to PNG, for eyeballing typography and -// figure placement. Finds the page by a phrase on it, so page-number drift -// between builds never previews the wrong page. +// Rasterize pages of a built PDF to PNG, for eyeballing typography and figure +// placement: the page carrying a phrase, or every page of one chapter. Pages +// are found by content rather than number, so page-number drift between builds +// never previews the wrong page. +// // Usage: npm run preview -- "" -import { readFileSync, writeFileSync, mkdtempSync } from "node:fs"; -import { join, resolve, dirname } from "node:path"; +// npm run preview -- --chapter +// add --pdf to read a build other than the digital edition +// +// is part of a chapter's file name or title, e.g. "escrow". Its +// pages come from the page map the build writes beside each PDF, so they are +// the pages the chapter occupies in the book, broken exactly where the book +// breaks them. PNGs land in build/ as -p.png. +import { readFileSync, writeFileSync, mkdtempSync, existsSync } from "node:fs"; +import { basename, join, resolve, dirname } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath, pathToFileURL } from "node:url"; import { chromium } from "playwright-core"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const [outPrefix, phrase] = process.argv.slice(2); -const pdfPath = join(repoRoot, "build", "Building Financial Software on Solana.pdf"); const outDir = join(repoRoot, "build"); -// Find the page carrying the phrase, so page-number drift between builds -// never silently previews the wrong page. +const USAGE = `Usage: npm run preview -- "" + npm run preview -- --chapter + --pdf Read this build instead of the digital edition + (npm run build-pdf-digital).`; +const die = (message) => { + console.error(`${message}\n\n${USAGE}`); + process.exit(1); +}; + +const argv = process.argv.slice(2).filter((arg) => arg !== "--"); +let pdfPath = join(outDir, "Building Financial Software on Solana.pdf"); +let chapterQuery = null; +const positional = []; +for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--help" || arg === "-h") { + console.log(USAGE); + process.exit(0); + } else if (arg === "--pdf" || arg === "--chapter") { + if (i + 1 >= argv.length) die(`${arg} needs a value.`); + if (arg === "--pdf") pdfPath = resolve(argv[++i]); + else chapterQuery = argv[++i]; + } else if (arg.startsWith("-")) { + die(`Unknown option ${JSON.stringify(arg)}.`); + } else { + positional.push(arg); + } +} +const [outPrefix, phrase] = positional; +if (!outPrefix) die("Name the preview: it prefixes the PNG filenames."); +if (chapterQuery === null && !phrase) die("Give a phrase on the page, or --chapter."); +if (chapterQuery !== null && phrase) die("Give a phrase or --chapter, not both."); +if (positional.length > 2) die(`Unexpected argument ${JSON.stringify(positional[2])}.`); +if (!existsSync(pdfPath)) die(`No PDF at ${pdfPath}; build it first.`); + const pdfjsNode = await import("pdfjs-dist/legacy/build/pdf.mjs"); -const doc = await pdfjsNode.getDocument({ data: new Uint8Array(readFileSync(pdfPath)), useSystemFonts: true }).promise; -let target = null; -for (let p = 1; p <= doc.numPages && !target; p++) { - const text = (await (await doc.getPage(p)).getTextContent()).items.map((i) => i.str).join(" "); - if (text.replace(/\s+/g, " ").includes(phrase)) target = p; +const pdfBytes = readFileSync(pdfPath); + +let pageNumbers; +if (chapterQuery !== null) { + const mapPath = pdfPath.replace(/\.pdf$/i, "") + ".pages.json"; + if (!existsSync(mapPath)) die(`No page map at ${mapPath}; rebuild the PDF to write one.`); + const chapters = JSON.parse(readFileSync(mapPath, "utf8")); + const query = chapterQuery.toLowerCase(); + // The file name rather than the whole path, since every chapter in a part + // shares its folder: "amm" would otherwise match "programming". + const fileName = (path) => basename(path, ".md").toLowerCase(); + const matches = chapters.filter(({ path, title }) => fileName(path).includes(query) || title.toLowerCase().includes(query)); + // A query that is one chapter's whole file name wins over others that merely + // contain it, so "intro" picks intro.md beside introduction.md. + const exact = matches.filter(({ path }) => fileName(path) === query); + const list = (entries) => entries.map(({ path, title }) => ` ${path} (${title})`).join("\n"); + const chosen = exact.length === 1 ? exact : matches; + if (chosen.length === 0) die(`No chapter matches ${JSON.stringify(chapterQuery)}. Chapters:\n${list(chapters)}`); + if (chosen.length > 1) die(`${JSON.stringify(chapterQuery)} matches ${chosen.length} chapters:\n${list(chosen)}`); + const { first, last } = chosen[0]; + pageNumbers = Array.from({ length: last - first + 1 }, (_, index) => first + index); +} else { + // Find the page carrying the phrase, so page-number drift between builds + // never silently previews the wrong page. + const doc = await pdfjsNode.getDocument({ data: new Uint8Array(pdfBytes), useSystemFonts: true }).promise; + for (let p = 1; p <= doc.numPages && !pageNumbers; p++) { + const text = (await (await doc.getPage(p)).getTextContent()).items.map((i) => i.str).join(" "); + if (text.replace(/\s+/g, " ").includes(phrase)) pageNumbers = [p]; + } + if (!pageNumbers) die(`Phrase not found in ${pdfPath}: ${phrase}`); } -if (!target) throw new Error(`phrase not found: ${phrase}`); const browser = await chromium.launch({ - executablePath: process.env.CHROMIUM_PATH ?? "/opt/pw-browsers/chromium", + executablePath: + process.env.CHROMIUM_PATH ?? + (existsSync("/opt/pw-browsers/chromium") ? "/opt/pw-browsers/chromium" : "/opt/homebrew/bin/chromium"), args: ["--allow-file-access-from-files"], }); const workDir = mkdtempSync(join(tmpdir(), "preview-")); @@ -34,33 +100,39 @@ writeFileSync(stage, "preview"); const page = await browser.newPage(); await page.goto(pathToFileURL(stage).href); -const dataUrl = await page.evaluate( - async ({ pdfjsUrl, workerUrl, pdfB64, pageNumber, targetWidth }) => { +const dataUrls = await page.evaluate( + async ({ pdfjsUrl, workerUrl, pdfB64, pageNumbers, targetWidth }) => { const pdfjs = await import(pdfjsUrl); pdfjs.GlobalWorkerOptions.workerSrc = workerUrl; const bytes = Uint8Array.from(atob(pdfB64), (c) => c.charCodeAt(0)); const d = await pdfjs.getDocument({ data: bytes }).promise; - const pg = await d.getPage(pageNumber); - const viewport = pg.getViewport({ scale: targetWidth / pg.getViewport({ scale: 1 }).width }); - const canvas = document.createElement("canvas"); - canvas.width = Math.round(viewport.width); - canvas.height = Math.round(viewport.height); - const ctx = canvas.getContext("2d"); - ctx.fillStyle = "#fff"; - ctx.fillRect(0, 0, canvas.width, canvas.height); - await pg.render({ canvasContext: ctx, viewport }).promise; - return canvas.toDataURL("image/png"); + const urls = []; + for (const pageNumber of pageNumbers) { + const pg = await d.getPage(pageNumber); + const viewport = pg.getViewport({ scale: targetWidth / pg.getViewport({ scale: 1 }).width }); + const canvas = document.createElement("canvas"); + canvas.width = Math.round(viewport.width); + canvas.height = Math.round(viewport.height); + const ctx = canvas.getContext("2d"); + ctx.fillStyle = "#fff"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + await pg.render({ canvasContext: ctx, viewport }).promise; + urls.push(canvas.toDataURL("image/png")); + } + return urls; }, { pdfjsUrl: pathToFileURL(join(repoRoot, "node_modules", "pdfjs-dist", "legacy", "build", "pdf.mjs")).href, workerUrl: pathToFileURL(join(repoRoot, "node_modules", "pdfjs-dist", "legacy", "build", "pdf.worker.mjs")).href, - pdfB64: readFileSync(pdfPath).toString("base64"), - pageNumber: target, + pdfB64: pdfBytes.toString("base64"), + pageNumbers, targetWidth: 1700, }, ); -const out = join(outDir, `${outPrefix}-p${target}.png`); -writeFileSync(out, Buffer.from(dataUrl.split(",")[1], "base64")); -console.log(out); +pageNumbers.forEach((pageNumber, index) => { + const out = join(outDir, `${outPrefix}-p${pageNumber}.png`); + writeFileSync(out, Buffer.from(dataUrls[index].split(",")[1], "base64")); + console.log(out); +}); await browser.close();