diff --git a/book/lib/paged.browser.js b/book/lib/paged.browser.js index f437ba646..42b13e1be 100644 --- a/book/lib/paged.browser.js +++ b/book/lib/paged.browser.js @@ -1870,6 +1870,24 @@ } + // [PATCH: avoid-when-it-fits] Gives `break-inside: auto` to the first + // element (in document order) with `break-inside: avoid` that lies + // wholly past the page, and says whether there was one. + relaxAvoid(rendered, bounds) { + let end = Math.round(bounds.right + this.gap); + let vEnd = Math.round(bounds.bottom); + for (let el of rendered.querySelectorAll("*")) { + if (el.style.breakInside) continue; + if (window.getComputedStyle(el).breakInside !== "avoid") continue; + let r = el.getBoundingClientRect(); + if (Math.round(r.left) >= end || Math.round(r.top) >= vEnd) { + el.style.breakInside = "auto"; + return true; + } + } + return false; + } + findBreakToken(rendered, source, bounds = this.bounds, prevBreakToken, extract = true) { let overflow = this.findOverflow(rendered, bounds); let breakToken, breakLetter; @@ -1893,6 +1911,19 @@ // Stop removal if we are in a loop if (breakToken && breakToken.equals(prevBreakToken)) { + // [PATCH: avoid-when-it-fits] The break is where the page + // began: nothing fitted. When an element with `break-inside: + // avoid` lies past the page, Chromium pushed it there whole, + // and it will on every page: paged.js lays a page out a part + // at a time, so it sees only the part of a long callout that + // fits in a column, and a heading's `break-after: avoid` took + // the break back to before the heading. An element that fits + // on no page cannot avoid a break, as CSS has it: let it + // break where it is, and look again. Nothing has been removed + // from the page yet. + if (this.relaxAvoid(rendered, bounds)) { + return this.findBreakToken(rendered, source, bounds, prevBreakToken, extract); + } return breakToken; } @@ -3213,12 +3244,35 @@ let newToken = breakToken.toJSON(true); if (tokens.has(newToken)) { // loop - let err = new OverflowContentError("Layout repeated", [breakToken.node]); console.error("Layout repeated at: ", breakToken.node); - return err; - } else { - tokens.add(newToken); + // [PATCH: layout-repeated-skip] Upstream returns the error + // here, which ends the flow as if the content were done: + // one element no page can hold cut the book short at it. + // Skip the rest of the block that holds the node instead + // (the node after the node itself is not enough: a page + // laid out from inside a
gives the same break again)
+ // and go on with what follows it. A block whose next node
+ // was a break already is not progress: climb to the block
+ // around it. The report above names the node;
+ // render-book.mjs passes it on as a warning.
+ const blocks = "pre, table, ul, ol, dl, blockquote, figure, p, li, dt, dd, div, section, h1, h2, h3, h4, h5, h6";
+ let el = breakToken.node && (breakToken.node.nodeType === 1 ? breakToken.node : breakToken.node.parentElement);
+ let block = el && (el.closest(blocks) || el);
+ let after;
+ while (block) {
+ after = nodeAfter(block);
+ if (!after) break;
+ newToken = new BreakToken(after).toJSON(true);
+ if (!tokens.has(newToken)) break;
+ after = undefined;
+ block = block.parentElement && block.parentElement.closest(blocks);
+ }
+ if (!after) {
+ return new OverflowContentError("Layout repeated", [breakToken.node]);
+ }
+ breakToken = new BreakToken(after);
}
+ tokens.add(newToken);
}
_assertSync(this.hooks.afterPageLayout.trigger(page.element, page, breakToken, this), "afterPageLayout");
@@ -33113,7 +33167,20 @@
// the call is also sync, so the entire script execution runs
// inside one EvaluateScript frame instead of being scheduled
// across multiple microtask continuations.
+ // [PATCH: fonts-before-flow] preview() in two halves. polisher.add()
+ // replaces the document's stylesheets with processed copies, and every
+ // @font-face in them becomes a new font face that has to load again.
+ // Upstream's async preview awaited loadFonts() in between; the sync
+ // chain does not, so the whole flow ran on fallback fonts and the
+ // pages were printed in the real ones: every break was chosen for text
+ // of the wrong width, and text near a page's end moved off it.
+ // render-book.mjs calls prepare(), awaits document.fonts, then flow().
preview(content, stylesheets, renderTo) {
+ this.prepare(content, stylesheets, renderTo);
+ return this.flow();
+ }
+
+ prepare(content, stylesheets, renderTo) {
_assertSync(this.hooks.beforePreview.trigger(content, renderTo), "beforePreview");
@@ -33131,6 +33198,13 @@
this.polisher.add(...stylesheets);
+ this._prepared = { content, renderTo };
+ }
+
+ flow() {
+ let { content, renderTo } = this._prepared;
+ this._prepared = undefined;
+
let startTime = performance.now();
// Render flow
diff --git a/book/render-book.mjs b/book/render-book.mjs
index 19750929c..de24f6bfd 100644
--- a/book/render-book.mjs
+++ b/book/render-book.mjs
@@ -19,9 +19,10 @@
// Matches the pagedjs-cli CLI surface that book.bat used:
// --outline-tags : headings to include in the PDF outline.
// Defaults to h1,h2,h3,h4 if omitted.
-// -t / --timeout : per-operation puppeteer timeout in ms. 0
-// disables. Default 0 (we have no untrusted
-// input; the 1638-page book takes ~100 s).
+// -t / --timeout : per-operation puppeteer timeout in ms, each
+// CDP call included (page.pdf() of the 3,700-page
+// book takes about 3 minutes). 0 disables. Default 0
+// (we have no untrusted input).
// --additional-script
// : extra in-page script to inject after the
// paged.js bundle. Repeatable. Used by book.bat
@@ -203,15 +204,148 @@ Renders an HTML book to a PDF with paged.js and headless Chromium.
the book to render
-o, --output the PDF to write
--outline-tags headings to put in the PDF outline (default h1,h2,h3,h4)
- -t, --timeout per-operation timeout in milliseconds; 0 disables (default 0)
+ -t, --timeout per-operation timeout in milliseconds, each DevTools call
+ included; 0 disables (default 0)
--additional-script a script to inject after paged.js; repeatable
-h, --help print this text and exit
+An element that paged.js cannot place on any page is skipped, from the point
+where it stops fitting, and the render goes on; each one is reported as a warning
+naming it, on stderr and, under GitHub Actions, as an annotation. So is each code
+block that lacks lines on the pages.
+
Exit codes:
0 the PDF was written
2 a refused command line, an input or script that does not exist, a render that
failed, or a crash`;
+// The two reports with which paged.js gives up on an element: "Layout repeated"
+// from the chunker's loop guard, "Unable to layout item" from Layout.renderTo. The
+// forked bundle skips the rest of the element and goes on (renderTo upstream, the
+// chunker by [PATCH: layout-repeated-skip]); these reports are all that says so.
+const LAYOUT_FAILURE_RE = /^(Layout repeated|Unable to layout item)\b/;
+const layoutFailures = [];
+
+// Names the element a report is about: its tag, the start of its text, and the
+// nearest heading before it, so that the page it is on can be found.
+async function describeLayoutFailure(text, handle) {
+ const what = text.match(LAYOUT_FAILURE_RE)[1];
+ if (!handle) return what;
+ try {
+ const where = await handle.evaluate((node) => {
+ const el = node.nodeType === 1 ? node : node.parentElement;
+ const snippet = (node.textContent || "").replace(/\s+/g, " ").trim().slice(0, 60);
+ let heading = "";
+ for (let cur = el; cur && !heading; cur = cur.parentElement) {
+ for (let sib = cur.previousElementSibling; sib && !heading; sib = sib.previousElementSibling) {
+ const h = sib.matches("h1, h2, h3, h4") ? sib : [...sib.querySelectorAll("h1, h2, h3, h4")].pop();
+ if (h) heading = h.textContent.replace(/\s+/g, " ").trim();
+ }
+ }
+ const tag = el ? `<${el.tagName.toLowerCase()}>` : "a node";
+ return `${tag} "${snippet}"${heading ? `, after the heading "${heading}"` : ""}`;
+ });
+ return `${what} at ${where}`;
+ } catch {
+ return what;
+ }
+}
+
+// paged.js can lose lines where it splits a code block across pages, and says
+// nothing (Fixes-PagedJS.md, "Code split across pages"). Before the render,
+// markCodeBlocks numbers each in an attribute, which paged.js copies to
+// every piece of a block it splits, and returns each block's text and the
+// heading before it; after it, collectRenderedCode puts each block's pieces
+// together again. Both run in the page.
+function markCodeBlocks() {
+ const headingBefore = (el) => {
+ for (let cur = el; cur; cur = cur.parentElement) {
+ for (let sib = cur.previousElementSibling; sib; sib = sib.previousElementSibling) {
+ const h = sib.matches("h1, h2, h3, h4") ? sib : [...sib.querySelectorAll("h1, h2, h3, h4")].pop();
+ if (h) return h.textContent.replace(/\s+/g, " ").trim();
+ }
+ }
+ return "";
+ };
+ return [...document.querySelectorAll("pre")].map((pre, i) => {
+ pre.setAttribute("data-code-check", String(i));
+ return { text: pre.textContent, heading: headingBefore(pre) };
+ });
+}
+
+// A line that paged.js leaves on a page outside its content box (in the
+// overflow column to its right, or below it) is in the page but not in the
+// PDF: only the lines that start inside the box count. The book build puts
+// each line in a span.code-line (codeLines in builder/book.mjs); a block
+// without them counts whole, by where it starts.
+function collectRenderedCode() {
+ const pieces = {};
+ for (const el of document.querySelectorAll(".pagedjs_pages pre[data-code-check]")) {
+ const box = el.closest(".pagedjs_page_content")?.getBoundingClientRect();
+ const shown = (node) => {
+ if (!box) return true;
+ const r = node.getBoundingClientRect();
+ return r.left < box.right - 1 && r.top < box.bottom - 1;
+ };
+ const lines = el.querySelectorAll(".code-line");
+ let text = "";
+ if (lines.length) {
+ for (const line of lines) if (shown(line)) text += line.textContent;
+ } else if (shown(el)) {
+ text = el.textContent;
+ }
+ const i = el.getAttribute("data-code-check");
+ pieces[i] = (pieces[i] ?? "") + text;
+ }
+ return pieces;
+}
+
+// One report for each block whose rendered pieces lack lines of its source,
+// lines compared without their whitespace. The lines kept are a longest
+// common subsequence of the two, so a missing line that also occurs later in
+// the block (an `End Sub`) is not matched there, taking the lines between
+// with it.
+function missingCodeLines(blocks, pieces) {
+ const keys = (text) =>
+ text
+ .split("\n")
+ .map((l) => l.replace(/\s+/g, ""))
+ .filter(Boolean);
+ const reports = [];
+ blocks.forEach((block, i) => {
+ const src = keys(block.text);
+ const got = keys(pieces[i] ?? "");
+ const n = src.length;
+ const m = got.length;
+ const lcs = Array.from({ length: n + 1 }, () => new Int32Array(m + 1));
+ for (let a = n - 1; a >= 0; a--) {
+ for (let b = m - 1; b >= 0; b--) {
+ lcs[a][b] = src[a] === got[b] ? lcs[a + 1][b + 1] + 1 : Math.max(lcs[a + 1][b], lcs[a][b + 1]);
+ }
+ }
+ const missing = [];
+ for (let a = 0, b = 0; a < n; ) {
+ if (b < m && src[a] === got[b]) {
+ a++;
+ b++;
+ } else if (b < m && lcs[a][b + 1] >= lcs[a + 1][b]) {
+ b++;
+ } else {
+ missing.push(a);
+ a++;
+ }
+ }
+ if (!missing.length) return;
+ const first = block.text
+ .split("\n")
+ .filter((l) => l.trim())
+ [missing[0]].trim();
+ const where = block.heading ? ` after the heading "${block.heading}"` : "";
+ reports.push(`${missing.length} of ${n} lines missing from the code block${where}, first "${first.slice(0, 60)}"`);
+ });
+ return reports;
+}
+
const { values, positionals, timeoutMs } = withUsageError(() => {
const cli = parseCli(process.argv.slice(2), {
options: {
@@ -304,6 +438,11 @@ const browser = await puppeteer.launch({
"--disable-gpu",
"--disable-software-rasterizer",
],
+ // Every CDP call, not only the page's own operations, waits as long as -t
+ // says: puppeteer's own limit of 180 s ended page.pdf() on a CI runner once
+ // the book was laid out whole, its 3,700 pages taking about that long to
+ // generate. 0, the default, is no limit; CI's step timeout is the backstop.
+ protocolTimeout: timeoutMs,
});
let exitCode = 0;
@@ -330,9 +469,19 @@ try {
};
// Render phase: progress-handler.js (loaded via addScriptTag below)
// emits `[render-progress] page=N elapsed=Ns` from afterPageLayout.
+ // paged.js reports an element it cannot place on any page only on the
+ // console, and then ends the flow as if the book were done; each such
+ // report is kept, and fails the render below.
page.on("console", (msg) => {
const t = msg.text();
- if (!t.startsWith("[render-progress]")) return;
+ if (LAYOUT_FAILURE_RE.test(t)) {
+ layoutFailures.push(describeLayoutFailure(t, msg.args()[1]));
+ return;
+ }
+ if (!t.startsWith("[render-progress]")) {
+ if (msg.type() === "error") console.error("[page console]", t);
+ return;
+ }
const m = t.match(/page=(\d+)\s+elapsed=([\d.]+)/);
if (!m) return;
const line = `rendering: ${m[1]} pages (${m[2]}s)`;
@@ -358,35 +507,63 @@ try {
}
// Render -- paged.js per-page layout.
- // PagedPolyfill.preview() is fully synchronous in our forked bundle
- // (the entire chain preview -> chunker.flow -> render -> *layout is
- // now sync; loadFonts is a sync assertion that page.goto's
- // waitUntil:'load' already satisfied; stylesheets are loaded via
- // synchronous XHR). Inner IIFE is a plain sync arrow; outer await
- // is just the CDP round-trip puppeteer needs to ferry the result.
+ // The chain under PagedPolyfill.flow() is fully synchronous in our
+ // forked bundle (chunker.flow -> render -> *layout; stylesheets are
+ // loaded via synchronous XHR). It runs in two calls with a wait between:
+ // prepare() replaces the stylesheets with paged.js's processed copies,
+ // which makes every @font-face a font face that loads again, and flow()
+ // must not lay out a page before they have [PATCH: fonts-before-flow].
+ // Each inner arrow is plain and sync; the outer await is the CDP
+ // round-trip puppeteer needs to ferry the result.
+ const codeBlocks = await page.evaluate(markCodeBlocks);
const tRender = Date.now();
- await page.evaluate(() => {
- if (!window.PagedPolyfill) {
- throw new Error("paged.js bundle did not expose window.PagedPolyfill");
- }
- try {
- window.PagedPolyfill.preview();
- } catch (err) {
- // Unwrap the undecorated ProgressEvent paged.js throws on fetch
- // failures so the message includes the offending URL.
- const e =
- err && err.target
- ? new Error(
- `${err.type || "event"} on ${err.target.tagName || "?"}: ${err.target.src || err.target.href || ""}`,
- )
- : err;
- throw e;
- }
+ const pagedStep = (step) =>
+ page.evaluate((step) => {
+ if (!window.PagedPolyfill) {
+ throw new Error("paged.js bundle did not expose window.PagedPolyfill");
+ }
+ try {
+ window.PagedPolyfill[step]();
+ } catch (err) {
+ // Unwrap the undecorated ProgressEvent paged.js throws on fetch
+ // failures so the message includes the offending URL.
+ const e =
+ err && err.target
+ ? new Error(
+ `${err.type || "event"} on ${err.target.tagName || "?"}: ${err.target.src || err.target.href || ""}`,
+ )
+ : err;
+ throw e;
+ }
+ }, step);
+ await pagedStep("prepare");
+ const unloaded = await page.evaluate(async () => {
+ await Promise.all([...document.fonts].map((f) => f.load().catch(() => {})));
+ await document.fonts.ready;
+ return [...document.fonts].filter((f) => f.status !== "loaded").map((f) => `${f.family} ${f.style} ${f.weight}`);
});
+ if (unloaded.length) console.error(`[fonts] not loaded, so laid out in a fallback font: ${unloaded.join(", ")}`);
+ await pagedStep("flow");
await page.waitForSelector(".pagedjs_pages");
const pageCount = await page.evaluate(() => document.querySelectorAll(".pagedjs_pages > .pagedjs_page").length);
clearProgress();
console.log(`render: ${fmtMs(Date.now() - tRender)} (${pageCount} pages)`);
+ for (const report of await Promise.all(layoutFailures)) {
+ console.error(`[layout] ${report}: skipped from where it stops fitting`);
+ if (process.env.GITHUB_ACTIONS === "true") console.log(`::warning title=Book layout::${report}`);
+ }
+ // GitHub shows the first ten annotations of a kind on a run's page; the
+ // count says how many there are.
+ const codeReports = missingCodeLines(codeBlocks, await page.evaluate(collectRenderedCode));
+ codeReports.forEach((report, i) => {
+ console.error(`[code] ${report}`);
+ if (process.env.GITHUB_ACTIONS === "true" && i < 10) console.log(`::warning title=Book code::${report}`);
+ });
+ if (codeReports.length) {
+ const summary = `${codeReports.length} of ${codeBlocks.length} code blocks have lines missing from the book`;
+ console.error(`[code] ${summary}`);
+ if (process.env.GITHUB_ACTIONS === "true") console.log(`::warning title=Book code::${summary}`);
+ }
// Generate -- meta extraction, outline walk, then Chromium DOM->PDF.
//
diff --git a/builder/book.mjs b/builder/book.mjs
index 18bfd01b9..837f2cdb6 100644
--- a/builder/book.mjs
+++ b/builder/book.mjs
@@ -298,7 +298,9 @@ const SUMMARY_OPEN_RE = /]*>/gi;
const HEADING_SHIFT_RE = /<(\/?)h([1-6])\b/g;
// PLAN-8 §6.3 / book-chapter-transform.rb#book_chapter_transform. Five
-// logical passes, after step 0 reduces a picture in two themes to its light one:
+// logical passes, after step 0 reduces a picture in two themes to its light one,
+// and a sixth of tbdocs's own, which puts each line of a code block in a block
+// element of its own (codeLines, below):
// 1. strip `src="/` prefix (no-op when baseurl is empty)
// 2. unwrap / tags (FAQ-style collapsibles flatten
// for print); summary id= attributes are preserved as inline spans
@@ -371,9 +373,66 @@ export function bookChapterTransform(body, baseurl, headingShiftN, chapterAnchor
result = replaceOutsideCode(result, /href="#([^"]+)"/g, (_, frag) => `href="#${prefix}${frag}"`);
}
+ // Step 6: each line of a code block in a block element of its own.
+ result = result.replace(CODE_BLOCK_RE, (_, open, body, close) => open + codeLines(body) + close);
+
return result;
}
+// paged.js cannot be trusted to split a text node that wraps over many
+// lines: in a code block, one text node often holds most of a listing, and
+// where paged.js picks the same break in it twice it gives up on the rest
+// (Fixes-PagedJS.md, "An element no page can hold"). With each line in an
+// inline span of its own, every line starts a node, and paged.js breaks code
+// between two nodes. The spans are inline and keep their newlines, so the
+// block lays out exactly as before: line boxes, and the orphans and widows
+// that decide where a page breaks it, are untouched. (Block spans broke
+// code as well, but a block of one line has no orphans or widows to keep.)
+const CODE_BLOCK_RE = /(]*>\s*]*>)([\s\S]*?)(<\/code>\s*<\/pre>)/g;
+const CODE_TOKEN_RE = /(<\/?[a-zA-Z][^>]*>|\n)/;
+const VOID_ELEMENTS = new Set(["br", "wbr", "img", "hr", "input"]);
+
+// The body of a as one `` per line, each
+// ending in its newline. A tag still open at a line end is closed before the
+// newline and opened again on the next line, so every line is well formed
+// and the text is unchanged. The newline is in a `` of its
+// own, as step 3 puts the whitespace between tokens: after a page break
+// inside a line, paged.js goes on with nodeAfter(), which passes over a text
+// node that is all whitespace, and a bare newline there was lost, joining
+// the line to the next one.
+function codeLines(body) {
+ const lines = [];
+ const open = []; // [tag name, opening tag] of each element open here
+ let line = "";
+ const endLine = (newline) => {
+ const closing = open
+ .map(([name]) => `${name}>`)
+ .reverse()
+ .join("");
+ const end = newline ? `${newline}` : "";
+ lines.push(`${line}${closing}${end}`);
+ line = open.map(([, tag]) => tag).join("");
+ };
+ const trailing = body.endsWith("\n");
+ for (const token of (trailing ? body.slice(0, -1) : body).split(CODE_TOKEN_RE)) {
+ if (!token) continue;
+ if (token === "\n") {
+ endLine("\n");
+ } else if (token.startsWith("")) {
+ open.pop();
+ line += token;
+ } else if (token.startsWith("<")) {
+ const name = token.slice(1).match(/^[a-zA-Z]+/)[0];
+ if (!token.endsWith("/>") && !VOID_ELEMENTS.has(name.toLowerCase())) open.push([name, token]);
+ line += token;
+ } else {
+ line += token;
+ }
+ }
+ endLine(trailing ? "\n" : "");
+ return lines.join("");
+}
+
// ---------------------------------------------------------------------------
// §D Article wrapper assembly (port of book-chapter-body.html)
// ---------------------------------------------------------------------------
diff --git a/docs/Documentation/Fixes-PagedJS.md b/docs/Documentation/Fixes-PagedJS.md
index def13de94..2eff26d79 100644
--- a/docs/Documentation/Fixes-PagedJS.md
+++ b/docs/Documentation/Fixes-PagedJS.md
@@ -32,7 +32,7 @@ In headless Chromium nothing else uses the event loop, so yielding gains nothing
| `flow()` | All five `await` sites removed; `beforeParsed`/`afterParsed`/`afterRendered` hooks are called synchronously and guarded by `_assertSync`. |
| `renderOnIdle()` / `renderAsync()` | Removed; both wrapped `renderer.next()` in unnecessary async machinery. |
| `clonePage()` | `async` removed; only reachable via the `Footnotes` handler, which disables itself when the document has no footnotes. |
-| `loadFonts()` | Rewritten as a synchronous assertion. `waitUntil: "load"` in `render-book.mjs` guarantees every `FontFace` the book uses has settled before paged.js runs. The assertion rejects `loading` and `error`, not `unloaded`: a CSS-connected face is fetched only when the layout needs it, so a face `print.css` declares but this render never uses stays `unloaded`. |
+| `loadFonts()` | Rewritten as a synchronous assertion. `waitUntil: "load"` in `render-book.mjs` settles the font faces of the stylesheets the book loads. The assertion rejects `loading` and `error`, not `unloaded`: a CSS-connected face is fetched only when the layout needs it, so a face `print.css` declares but this render never uses stays `unloaded`. `Polisher.add()` then replaces those stylesheets, and their faces load again: [Fonts before the flow](#fonts-before-the-flow) waits for them. |
| `parse()` | `async` removed; no registered handler in this pipeline is async for the hooks it fires. |
| `request()` | Replaced with synchronous XHR (`XMLHttpRequest` with `async=false`), returning `responseText` directly. |
| `add()` | `async` removed; all inputs are inline `{url: text}` objects that need no fetch. |
@@ -116,6 +116,30 @@ Each call site that receives the sync sentinel also calls `_assertSync(result, h
**Fix.** `[PATCH: tokens-set]` The Array is replaced with a `Set`, so each lookup is O(1) instead of O(n).
+### Fonts before the flow
+
+**Problem.** `Polisher.add()` replaces the document's stylesheets with paged.js's processed copies, and every `@font-face` in them becomes a font face that has to load again. Upstream's async `preview()` waited for it; the synchronous chain went straight on to `chunker.flow()`, so the whole book was laid out while every face was still `loading`, in fallback fonts, and then printed in the real ones. Every page break was chosen for text of the wrong width. Where the real text took more room, as Cascadia Mono does against the 6.6 % narrower fallback on Windows, the lines that no longer fitted moved into the overflow column to the right of the page and were never printed: a render of 13 October 2026 left 476 code lines off its pages this way, two of them in the middle of the Interfaces and CoClasses example, and none once the fonts were loaded first. On Linux the fallbacks differ, and so did the faults.
+
+**Fix.** `[PATCH: fonts-before-flow]` `preview()` is split into `prepare()`, everything up to and including `Polisher.add()`, and `flow()`, the layout. `render-book.mjs` calls `prepare()`, loads every face in `document.fonts` and waits for `document.fonts.ready`, then calls `flow()`; a face that still has not loaded is reported as `[fonts]`. `preview()` remains, as the two halves in one call.
+
+### A break that cannot be avoided
+
+**Problem.** `print.css` gives callouts `break-inside: avoid`, and CSS breaks such an element anyway when it fits on no page. paged.js lays a page out a part at a time, so Chromium saw only the part of a long callout that fitted in a column, honoured `avoid` and pushed it whole past the page; on the next page the same happened, and with a heading's `break-after: avoid` before the callout, the break went back to before the heading every time. paged.js gave up ("Unable to layout item"), and the first page's part of the callout was left in the overflow column: tsJson's "Known problems" warning, three pages long, lost its first page this way.
+
+**Fix.** `[PATCH: avoid-when-it-fits]` Where `findBreakToken` finds a break at the point where the page began, before it removes anything, `relaxAvoid()` gives `break-inside: auto` to the first element with `break-inside: avoid` that lies wholly past the page, and the break is looked for again. An element that fits on a page is still moved whole to the next one: Chromium places it there, and the break is then not where the page began.
+
+### An element no page can hold
+
+**Problem.** When the chunker laid out a page and got back a break token it had seen before, it logged `Layout repeated at:` and *returned* an error rather than throwing it. That ended the flow as if the content were done: the book stopped at that element. `render-book.mjs` relayed only the progress lines from the page's console, so it saved the pages laid out so far and exited 0. From 12 October 2026 the JSON listings on the IDE's Panel Layouts page, whose deepest lines are indented by twelve tabs, did this on the Linux runner, and the published book stopped at page 461.
+
+**Fix.** `[PATCH: layout-repeated-skip]` The chunker skips the rest of the block that holds the node --- the nearest `pre`, `table`, list, `p`, `div` or similar around it --- and goes on with the node after that block. The node after the node itself is not enough: a page laid out from inside a `` gives the same break again, and the render adds pages without end. A block whose next node has already been a break is not progress either, so the chunker climbs to the block around it. It still returns the error when nothing follows. `Layout.renderTo` upstream already skips to the node after one it reports as `Unable to layout item`. `render-book.mjs` relays both reports as warnings that name the element ([PDF Generation](../PDF-Generation#stalled-or-still-working)).
+
+### Code split across pages
+
+**Problem.** In a code block one text node often holds most of a listing, and paged.js does not split a text node that wraps over many lines reliably: on a page narrow enough, it picks the same break twice, which is the Panel Layouts case above, or a break further on than the content it moves off the page, and the lines between are lost without a report. (Most of the code the book of 12 October 2026 lacked, 490 of its 8,862 code lines of 25 characters or more, among them the end of the Call statement's page, went the way [Fonts before the flow](#fonts-before-the-flow) describes.)
+
+**Fix.** The book build puts each line of a code block, with its newline, in an inline `` of its own (`codeLines` in `builder/book.mjs`): every line starts a node, and paged.js breaks code between two nodes. The spans are inline, so the block lays out exactly as before, page breaks included; block spans would break code as well, but a block of one line has no orphans or widows to keep. Each line's newline is in a `` of its own: after a page break inside a line, paged.js goes on with `nodeAfter()`, which passes over a text node that is all whitespace, and a bare newline there was lost, joining the line to the next one. `render-book.mjs` checks every code block: it numbers each `` before the render, puts the pieces paged.js split together again after it, and reports as `[code]` each block that lacks a line, counting only the lines that start inside a page's content box. `print.css` also sets `tab-size: 3` on code blocks, which keeps the Panel Layouts listings within the column.
+
## Margin group rendering
**Problem.** The `finalizePage` hook computed `grid-template-columns` and `grid-template-rows` for each page's margin groups by reading `getComputedStyle()` at layout time. This forced a layout flush on every page.
diff --git a/docs/Documentation/PDF-Generation.md b/docs/Documentation/PDF-Generation.md
index 14fbbd31b..9b213d53e 100644
--- a/docs/Documentation/PDF-Generation.md
+++ b/docs/Documentation/PDF-Generation.md
@@ -17,7 +17,7 @@ Internals of the two-stage PDF pipeline: `tbdocs` Phase 8 assembles a sparse `_s

-The book runs to about 2,250 pages. `tbdocs` builds `_site-pdf/` as part of its normal run; `render-book.mjs` runs only when `book.bat` calls it, which keeps `puppeteer` and `pdf-lib` out of the site generator's dependency tree.
+The book runs to about 3,700 pages. `tbdocs` builds `_site-pdf/` as part of its normal run; `render-book.mjs` runs only when `book.bat` calls it, which keeps `puppeteer` and `pdf-lib` out of the site generator's dependency tree.
## Running the renderer
@@ -33,7 +33,7 @@ node book/render-book.mjs -o
| `` | required | Path to the assembled HTML file (usually `_site-pdf/book.html`). |
| `-o` / `--output` | required | Destination PDF path. |
| `--outline-tags` | `h1,h2,h3,h4` | Comma-separated heading tags to include in the PDF bookmark tree. |
-| `-t` / `--timeout` | `0` (disabled) | Per-operation puppeteer timeout in milliseconds, a whole number from 0 to 2147483647. |
+| `-t` / `--timeout` | `0` (disabled) | Per-operation puppeteer timeout in milliseconds, a whole number from 0 to 2147483647: the page's own operations and each DevTools (CDP) call, `page.pdf()` included. |
| `--additional-script` | --- | Inject an extra in-page script after the paged.js bundle. Repeatable. |
`book.bat` runs the standard production invocation:
@@ -82,10 +82,14 @@ Live progress separates a stall from slowness. It differs per phase, and by whet
| Generate | `generating: s`, a heartbeat every 500 ms, **on a terminal only** | Nothing is wrong. `page.pdf()` returns only at the end, so a piped run prints nothing meanwhile. |
| Process | none | Nothing is wrong. The phase reports only when it finishes. |
-The renderer relays three kinds of in-browser fault, each with its own prefix:
+The renderer relays these in-browser faults, each with its own prefix:
- **`[request failed] `** --- Chromium could not load a resource. A file missing from `_site-pdf/` appears as `net::ERR_FILE_NOT_FOUND` with its path, which points to an incomplete Phase 8 tree.
- **`[page error] `** --- an uncaught exception inside the page.
+- **`[layout] at : skipped from where it stops fitting`** --- paged.js could not place an element on any page, and reported `Layout repeated` or `Unable to layout item`. The element is named by its tag, the start of its text and the heading before it. The render skips the rest of it and goes on, so the book is complete apart from that part of the element ([Paged.js Patches](Fixes/PagedJS#an-element-no-page-can-hold)); the exit code stays 0. Under GitHub Actions each report is also a `::warning` annotation on the run.
+- **`[code] of lines missing from the code block after the heading "", first ""`** --- a code block whose pieces on the pages lack lines of its source; a line paged.js left outside a page's content box counts as missing, since it is not printed. A last `[code]` line counts the blocks. The exit code stays 0, and under GitHub Actions the first ten and the count are `::warning` annotations.
+- **`[fonts] not loaded, so laid out in a fallback font: `** --- a font face that had not loaded when the layout began.
+- **`[page console] `** --- any other error the page writes to its console.
- **`[render-book] error: `** --- the top-level catch. It closes the browser and sets the exit code to 2.
A paged.js stylesheet fetch that fails arrives as `error on LINK: `.
@@ -181,7 +185,7 @@ Code 1 is unambiguous, because the renderer has no 1. For code 2, stderr tells t
### Phase 1: Render
-Opens a headless Chromium instance, loads `book.html` under `file://`, and calls `PagedPolyfill.preview()` to run the CSS Paged Media layout engine. When it returns, the DOM contains one `.pagedjs_page` element per output PDF page.
+Opens a headless Chromium instance, loads `book.html` under `file://`, and runs the CSS Paged Media layout engine in two calls: `PagedPolyfill.prepare()`, then, once every font face has loaded, `PagedPolyfill.flow()`. When it returns, the DOM contains one `.pagedjs_page` element per output PDF page.
**Chromium launch flags:**
@@ -202,7 +206,7 @@ This stops paged.js running automatically when its bundle loads. The driver then
2. `lib/progress-handler.js` --- logs `[render-progress] page=N elapsed=Xs` to the browser console after each page is laid out.
3. Any `--additional-script` paths (production adds `perf/detach-pages.js`).
-`PagedPolyfill.preview()` is then called via `page.evaluate()`. The call is synchronous; the `await` is only the CDP round-trip.
+`PagedPolyfill.prepare()` is then called via `page.evaluate()`. It replaces the stylesheets with paged.js's processed copies, which makes every `@font-face` a font face that loads again, so the driver loads every face in `document.fonts` and waits for `document.fonts.ready` before it calls `PagedPolyfill.flow()`, which lays out the pages ([Fonts before the flow](Fixes/PagedJS#fonts-before-the-flow)); a face that has not loaded by then is reported as `[fonts]`. Both calls are synchronous; each `await` is only the CDP round-trip.
`perf/detach-pages.js` removes each finalised page from the DOM right after layout and restores all pages in order at `afterRendered`. This keeps `getBoundingClientRect` (which paged.js calls per page) at ~0.7 ms/page instead of climbing towards ~8 ms/page by the last chapters. CSS counters break across detached pages, so `print.css` uses `var(--page-num)` (a custom property paged.js writes per page) instead of `counter(page)` for running page numbers.
@@ -370,7 +374,9 @@ Two globals control the polyfill:
| Member | Description |
|---|---|
-| `PagedPolyfill.preview()` | Runs the full layout pipeline. In the vendored bundle this is fully synchronous. |
+| `PagedPolyfill.preview()` | Runs the full layout pipeline: `prepare()` and `flow()` in one call. In the vendored bundle this is fully synchronous. |
+| `PagedPolyfill.prepare()` | The first half: wraps the content and replaces the stylesheets with processed copies. Added by the fork. |
+| `PagedPolyfill.flow()` | The second half: lays out the pages. Added by the fork; `render-book.mjs` calls it once the fonts have loaded. |
### Handler system
diff --git a/docs/Documentation/Pipeline-Stages.md b/docs/Documentation/Pipeline-Stages.md
index a3d1c83d9..5eb2c8441 100644
--- a/docs/Documentation/Pipeline-Stages.md
+++ b/docs/Documentation/Pipeline-Stages.md
@@ -663,7 +663,7 @@ Both HTML escapers convert a value that is not a string with `String` first.
| `resolveBookChapters` | `(bookData, pages) → void` | Resolves `_book.yml` chapter selectors to `Page[]` references; sets `_chapters` / `_landing` / `_foreword` in place. |
| `sortByNavOrder` | `(input) → Page[]` | Group-by-owning-index sort: index pages first, then by `nav_order` ascending with title tie-breaker. |
| `chapterAnchorFromUrl` | `(url, fallbackTitle?) → string` | Page URL → `ch-…` anchor slug. |
-| `bookChapterTransform` | `(body, baseurl, headingShiftN, chapterAnchor) → string` | Five per-chapter body transforms: baseurl-strip, `` unwrap, whitespace-`` wrap for pagedjs, heading shift, chapter-anchor prefixing. |
+| `bookChapterTransform` | `(body, baseurl, headingShiftN, chapterAnchor) → string` | The per-chapter body transforms: a picture in two themes reduced to its light one, baseurl-strip, `` unwrap, table-wrapper strip, whitespace-`` wrap for pagedjs, heading shift, chapter-anchor prefixing, and each line of a code block put in a `` of its own, so that paged.js breaks code between lines. |
| `assembleBook` | `(site, pages) → string` | Phase 8 entry. Returns the assembled `book.html` string. |
| `rewriteBookHrefs` | `(html, site, pages) → string` | Rewrites a link to a page in the book to that page's `href="#ch-X"` anchor, and a link to any other site page to its absolute URL under `site.config.url` (a site path is dead in a PDF). |
| `bookCoverage` | `(bookData, pages) → { unlisted, both, emptyEntries, emptyLeftOut, missingUrls }` | Runs after `resolveBookChapters`. Reports pages in no book entry and no `left_out:` entry, pages in both, entries that match no page, and landing or foreword URLs that name no page. Every list is empty on a consistent manifest. |
diff --git a/docs/assets/css/print.css b/docs/assets/css/print.css
index 90ae01c10..23c9a3b5b 100644
--- a/docs/assets/css/print.css
+++ b/docs/assets/css/print.css
@@ -638,6 +638,12 @@ div.highlighter-rouge {
word-wrap: break-word;
overflow-wrap: break-word;
break-inside: auto;
+ /* At the default tab-size of 8, the twelve tabs that indent the IDE's
+ panel-layout JSON (Panel Layouts page) are 96 columns, wider than the
+ ~88 the column holds: Chromium on Linux then lays the block out so that
+ paged.js finds no break in it, logs "Layout repeated" and ends the book
+ there. At 3 the deepest line is 58 columns. */
+ tab-size: 3;
}
div.highlighter-rouge pre,