Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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 -- <chapter> "<phrase>"` 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 -- <name> "<phrase>"` renders the page carrying a phrase, and
`npm run preview -- <name> --chapter <file name or title>` renders every page of one chapter, as
`build/<name>-p<page>.png`. Both read the digital edition unless `--pdf <file>` 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
Expand Down
12 changes: 7 additions & 5 deletions PRINT-SPECIFICATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>` as options; the output defaults to `build/cover-kdp-<pages>pp-<paper>.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

Expand All @@ -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 -- <name> "<phrase>" # rasterize one page of the digital edition to check it
npm run preview -- <name> "<phrase>" # rasterize the page of the digital edition carrying the phrase
npm run preview -- <name> --chapter escrow # rasterize every page of one chapter, matched by file name or title
npm run preview -- <name> --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, <reviewer>)` 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, <reviewer>)` 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 `<name>-p<page>.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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<name>"` 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 "<name>"` 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

Expand Down
30 changes: 26 additions & 4 deletions scripts/build-pdf.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, <name>.pages.json, giving each chapter's first and last page, which
// npm run preview -- --chapter reads.
//
// Usage: npm run build-pdf-draft -- --reviewer "<name>"
// npm run build-pdf-paperback
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.`);
Expand Down
Loading
Loading