From 31acdd06ba73686b523a4d3f4a8695062195a1cf Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Wed, 4 Nov 2026 04:15:26 +0000 Subject: [PATCH 1/4] Spec 001 T030, from #162's review: long tokens' breaks, the lows A token no longer ends at a no-break space, U+202F or U+FEFF, which Chromium does not break at (the spaces it does break at are measured and listed in the next commit). A summation's markup is read whole, so a word runs through inline tags and is counted whole; tags are never counted or broken into. A token over 40 breaks into equal pieces of at most 20. Unit tests for text() and markupBreaks, each shown failing with its mutant. The docs say what can still overflow (re-measured in the next commit). Co-Authored-By: Claude Opus 5.5 --- specs/001-pathway-pdfs/contracts/cli.md | 8 +- specs/001-pathway-pdfs/research.md | 5 +- specs/001-pathway-pdfs/tasks.md | 1 + src/layout/html.ts | 104 ++++++++++++++++++------ src/layout/section.ts | 3 +- test/layout/html.test.ts | 87 ++++++++++++++++++++ 6 files changed, 179 insertions(+), 29 deletions(-) create mode 100644 test/layout/html.test.ts diff --git a/specs/001-pathway-pdfs/contracts/cli.md b/specs/001-pathway-pdfs/contracts/cli.md index 6bae651..fb99292 100644 --- a/specs/001-pathway-pdfs/contracts/cli.md +++ b/specs/001-pathway-pdfs/contracts/cli.md @@ -203,9 +203,11 @@ FR-011's check, alone. Also run by `book`. figure's link (a diagram's labels). Measured against the section, not in points, so a part Chromium printed scaled down still reads its names, its scale not a fault: the layout keeps realistic content inside the text column (long tokens may break, a table's - columns narrow to their longest words), but a table of very many columns can still be - wider; `no-stable-id`: its text holds no `Stable identifier` followed by its stable - identifier; + columns narrow to their longest words), but a table whose longest words, side by side, + are wider than the column can still be wider: a token of up to 40 characters does not + break, so three columns of 40-character words are wider, four of 30 and five of 21 (a + longer token breaks into pieces of 14 to 20, and takes six columns or more); + `no-stable-id`: its text holds no `Stable identifier` followed by its stable identifier; - `missing-figure`: a pathway's or reaction's section with a figure by the Figure rules, whose source is in `--files` (its diagram's two files, its EHLD; a reaction's is the graph), has no link to that figure's interactive form below its heading. The figure is diff --git a/specs/001-pathway-pdfs/research.md b/specs/001-pathway-pdfs/research.md index 19a5894..ae4d540 100644 --- a/specs/001-pathway-pdfs/research.md +++ b/specs/001-pathway-pdfs/research.md @@ -954,7 +954,10 @@ embedded. 28 human events have a `` in their summation, so a table wider than the text column, which Chromium printed by scaling its whole part down below the theme's sizes, would have reached the real book: the layout now keeps realistic content inside the - column (pages.css; a table of very many columns can still be wider), and the check + column (pages.css; a table whose longest words, side by side, are wider than the + column can still be wider: measured, three columns of unbroken 40-character words, + four of 30 or five of 21; a longer token, broken into pieces of 14 to 20, takes six + columns or more), and the check reads headings against the section's own body text, so a part printed scaled down is not a fault. No human summation holds a `
` block, and no human event's name holds a soft hyphen, a zero-width or
diff --git a/specs/001-pathway-pdfs/tasks.md b/specs/001-pathway-pdfs/tasks.md
index 8909ecd..7221579 100644
--- a/specs/001-pathway-pdfs/tasks.md
+++ b/specs/001-pathway-pdfs/tasks.md
@@ -536,6 +536,7 @@ passes.
       - **`overflow-wrap: anywhere` broke short columns' words (high).** In a table it let every column narrow to a character, so a table wider than the column broke its short columns' words (`Da`/`te`). `body` now has `overflow-wrap: break-word`, which keeps each column at its longest word, and the layout gives a token longer than 40 characters a `` every 20 (`text()` in `src/layout/html.ts`, grapheme by grapheme, for every text it writes; `markupBreaks` for a summation's text runs, a character reference one unit; an attribute's value and the page's `` never, through `plainText`), which adds no character to what is read. An edit's date is kept whole (`white-space: nowrap`), as a column narrowed to its longest word broke `2026-01-04` at its hyphens. Test: an edit's names, 30 of them, and a summation table of `Phosphofructokinase` beside a column of prose: exit 0, `Date` and `Action` whole and no fragment of them, every date whole, `Phosphofructokinase` whole four times; on #160's CSS it read `Da`, `te`, `Ac`. The overflow test is kept: its table is now four columns of long words, as wide as release 98's are (six columns of 28-letter words are wider than the column whatever breaks, and a part holding one is printed scaled down, which the check tolerates), and its 140-character token also sits in a table cell, where only its `<wbr>`s let the column narrow.
       - **Lows.** The docs no longer say nothing overflows: the layout keeps realistic content inside the column, a table of very many columns can still be wider, and the check's measure against the section tolerates a part printed scaled down (contracts/cli.md, research R8). `folded()` builds its pattern from FR-017's `NEVER_DRAWN` (`src/layout/characters.ts`), not a copy. `identifies()` reads a candidate's section only to the next place a section may start (an event's first link to its page, or an outline entry with an event's name). Recorded: the failed-removal test (`test/print/temporary.test.ts`) relies on `chmod` keeping a folder from being emptied, so it does not hold when run as root.
       - **Tests:** 439 before, 441 after (the labels and the short-columns tests). Each of these mutants made one fail: the page's commonest size with the figures' labels counted, as before (the labels test); `overflow-wrap: anywhere` again, and the dates allowed to break (the short-columns test, each); no `<wbr>` at all, and none in summations (the overflow test, each). Survived: the label's size alone, and the page's size without the figures' labels alone (each covers the other on the fixture; both kept, the second as the fallback for a section with no label); `identifies()` unbounded (no fixture section lacks its label, the only case it changes); no `overflow-wrap` and the tables' `max-width: 100%` (with the `<wbr>`s, nothing tested is wider than a line; both kept, as the review asked); the facts' grid at `minmax(0, 1fr)` and the contents' links at `min-width: 0`, taken out again.
+    - **Follow-up (#PR):** #162's review's five lows. A token ends only at a space Chromium breaks a line at (space, tab, line feed, carriage return): a no-break space, U+202F or U+FEFF no longer ends one. A summation's markup is read whole (`markupBreaks`, which `markupText` no longer feeds run by run), its tags never counted or broken into, and a word counted whole through the inline tags (`a`, `b`, `em`, `i`, `span`, `strong`, `sub`, `sup`, `u`), any other tag ending it. A token over 40 breaks into equal pieces of at most 20 (41 is 14, 14 and 13, not 20, 20 and 1). The docs (contracts/cli.md, research R8) say what can still overflow, measured on a page with the book's styles at the text column's width: unbroken tokens side by side, three columns of 40 characters, four of 30, five of 21; a longer token, broken into pieces of 14 to 20, takes six columns or more. Tests: `test/layout/html.test.ts` (7) for `text()` and `markupBreaks`, 441 before, 448 after. Each of these mutants made one fail: the old `/^\s$/u` space (the spaces test, and the markup one through a no-break space); a carriage return and line feed not taken as a space (the spaces test); character references not split out (`&` broken into, the references test); `text()` escaping before it breaks (the escaped test); tags taken as text (the attribute and inline-tags tests); every tag ending a word, and none (the inline-tags test, each); tags counted in a word's length (the same); a `<` before no tag name taken as a tag (the markup test); a break every 20 again (five tests).
     - **Known gaps:** the rules are measured on the fixture alone, whose names are short and whose figures small: T031 runs the check on release 98's book, and its time against R7's 9 minutes. A figure is known by its link: a drawing lost with its link kept is not seen. A heading that lost its link and every entry naming it is found nowhere, and is then three faults. A link landing on a page but not on its target is not a fault (R8: every link landing on a page). Between `bookCommand` returning and the process ending there are no handlers, so a signal then is the default action, exit 128 and its number, the book and its report already in place.
 
 - [ ] T031 [US1] The book on a private copy of release 98's graph.
diff --git a/src/layout/html.ts b/src/layout/html.ts
index d6f1a36..a89efd5 100644
--- a/src/layout/html.ts
+++ b/src/layout/html.ts
@@ -12,34 +12,69 @@ export function plainText(value: string): string {
 }
 
 /**
- * A token longer than this, with no space in it, may break every BREAK_EVERY characters:
- * the pages break words only where they may (`overflow-wrap: break-word`, pages.css), so
- * that a table's short columns keep their words whole, and a token longer than the text
- * column (a run of letters, a long address) would otherwise overflow it, and Chromium then
- * prints its whole part scaled down.
+ * A token longer than this, with no space in it, is given places to break, in equal pieces
+ * of at most BREAK_EVERY characters: the pages break words only where they may
+ * (`overflow-wrap: break-word`, pages.css), so that a table's short columns keep their
+ * words whole, and a token longer than the text column (a run of letters, a long address)
+ * would otherwise overflow it, and Chromium then prints its whole part scaled down.
  */
 export const LONG_TOKEN = 40;
 export const BREAK_EVERY = 20;
 
 /**
- * Units of text (characters as a reader sees them, and a markup run's character references)
- * with `<wbr>` between every BREAK_EVERY of a token longer than LONG_TOKEN: a break
+ * The spaces a token ends at: those Chromium breaks a line at. A no-break space, U+202F or
+ * U+FEFF joins its neighbours, so a token runs through it; a space it could break at but
+ * that is not listed (an en space, say) only adds a `<wbr>` where a break already was.
+ * A carriage return and line feed are one grapheme.
+ */
+const BREAKABLE_SPACE = /^[ \t\n\r]+$/;
+
+/**
+ * A unit of text: a character as a reader sees it, a character reference, or a tag, which
+ * is no character, and which ends the token it is in unless it is inline (`joins`).
+ */
+interface Unit {
+  readonly text: string;
+  readonly tag?: 'joins' | 'ends';
+}
+
+/**
+ * Units with `<wbr>` between the pieces of a token longer than LONG_TOKEN, each piece of
+ * at most BREAK_EVERY units and none more than one shorter than another: a break
  * opportunity, which adds no character to the printed text.
  */
-function breakTokens(units: readonly string[], escape: (unit: string) => string): string {
+function breakTokens(units: readonly Unit[], escape: (unit: string) => string): string {
   let out = '';
-  let token: string[] = [];
+  let token: Unit[] = [];
   const flush = () => {
-    for (let i = 0; i < token.length; i++) {
-      if (token.length > LONG_TOKEN && i > 0 && i % BREAK_EVERY === 0) out += '<wbr>';
-      out += escape(token[i] ?? '');
+    const length = token.filter((unit) => unit.tag === undefined).length;
+    const pieces = length > LONG_TOKEN ? Math.ceil(length / BREAK_EVERY) : 1;
+    // the k-th piece ends at ceil(k * length / pieces) units, so the pieces differ by one at most
+    let next = 1;
+    let counted = 0;
+    for (const unit of token) {
+      if (unit.tag !== undefined) {
+        out += unit.text;
+        continue;
+      }
+      if (next < pieces && counted === Math.ceil((next * length) / pieces)) {
+        out += '<wbr>';
+        next++;
+      }
+      out += escape(unit.text);
+      counted++;
     }
     token = [];
   };
   for (const unit of units) {
-    if (/^\s$/u.test(unit)) {
+    if (unit.tag === 'joins') {
+      token.push(unit);
+    } else if (unit.tag === 'ends') {
+      flush();
+      out += unit.text;
+    } else if (BREAKABLE_SPACE.test(unit.text)) {
       flush();
-      out += escape(unit);
+      out += escape(unit.text);
     } else {
       token.push(unit);
     }
@@ -50,8 +85,8 @@ function breakTokens(units: readonly string[], escape: (unit: string) => string)
 
 /** Characters as a reader sees them: a break never parts a mark from its letter. */
 const GRAPHEMES = new Intl.Segmenter('en', { granularity: 'grapheme' });
-const graphemes = (value: string): string[] =>
-  Array.from(GRAPHEMES.segment(value), (part) => part.segment);
+const graphemes = (value: string): Unit[] =>
+  Array.from(GRAPHEMES.segment(value), (part) => ({ text: part.segment }));
 
 /** Text for an element's content, escaped, a long token given places to break. */
 export function text(value: string): string {
@@ -59,14 +94,37 @@ export function text(value: string): string {
 }
 
 /**
- * A run of markup's text (raw: its character references as they are) given places to break
- * in a long token, as `text` gives plain text: for `markupText`'s runs of a summation.
+ * The inline tags of a summation (the sanitiser's, rules.ts): a word may run through them,
+ * as `Na<sub>2</sub>` or a word half in italics, and is counted whole. Any other tag, a
+ * paragraph's or a cell's, ends the word.
  */
-export function markupBreaks(run: string): string {
-  // each character reference one unit, every other text by its characters
-  const units = run
-    .split(/(&(?:#[0-9]+|#x[0-9a-f]+|[a-z][a-z0-9]*);)/giu)
-    .flatMap((part, i) => (i % 2 === 1 ? [part] : graphemes(part)));
+const INLINE_TAGS = new Set(['a', 'b', 'em', 'i', 'span', 'strong', 'sub', 'sup', 'u']);
+
+/** A tag (its attributes' quoted values may hold `>`), or a comment; a `<` before neither is text. */
+const TAG = /(<!--[\s\S]*?(?:-->|$)|<\/?([a-z][a-z0-9]*)(?:[^>"']|"[^"]*"|'[^']*')*>?)/giu;
+const REFERENCE = /(&(?:#[0-9]+|#x[0-9a-f]+|[a-z][a-z0-9]*);)/giu;
+
+/**
+ * A summation's sanitised markup, a long token in its text given places to break as `text`
+ * gives plain text: each character reference one unit, a tag never broken into and never
+ * counted, and a word counted whole through inline tags.
+ */
+export function markupBreaks(markup: string): string {
+  const units: Unit[] = [];
+  const parts = markup.split(TAG);
+  // split gives text, then each tag with its name (or undefined), then text again
+  for (let i = 0; i < parts.length; i += 3) {
+    const between = parts[i] ?? '';
+    between.split(REFERENCE).forEach((part, j) => {
+      if (j % 2 === 1) units.push({ text: part });
+      else units.push(...graphemes(part));
+    });
+    const tag = parts[i + 1];
+    if (tag === undefined) continue;
+    const name = parts[i + 2]?.toLowerCase();
+    const joins = tag.startsWith('<!--') || (name !== undefined && INLINE_TAGS.has(name));
+    units.push({ text: tag, tag: joins ? 'joins' : 'ends' });
+  }
   return breakTokens(units, (unit) => unit);
 }
 
diff --git a/src/layout/section.ts b/src/layout/section.ts
index 34bfe1a..4bf9337 100644
--- a/src/layout/section.ts
+++ b/src/layout/section.ts
@@ -6,7 +6,6 @@ import type { Reference } from '../graph/queries.ts';
 import type { Found, FoundRow, Place, Section } from '../model/types.ts';
 import { figureHtml, figureKey, type FigureSvgs, type Placement } from './figure.ts';
 import { anchorOf } from '../model/build.ts';
-import { markupText } from './characters.ts';
 import { Links, attribute, markupBreaks, text } from './html.ts';
 
 /** What laying out sections reports, for the run report and the service's log. */
@@ -95,7 +94,7 @@ export function sectionHtml(section: Section, input: SectionInput): string {
   if (section.summations.length > 0) {
     // sanitised by the model (rules.ts) to the tags the website's pages allow
     // a long token in its text given places to break, as every other text is (html.ts)
-    const broken = section.summations.map((summation) => markupText(summation, markupBreaks));
+    const broken = section.summations.map(markupBreaks);
     parts.push(`<div class="summation">${broken.join('\n')}</div>`);
   }
 
diff --git a/test/layout/html.test.ts b/test/layout/html.test.ts
new file mode 100644
index 0000000..8242506
--- /dev/null
+++ b/test/layout/html.test.ts
@@ -0,0 +1,87 @@
+// A long token's places to break (spec 001 T030): `<wbr>` in a token longer than 40
+// characters, in equal pieces of at most 20, never inside a character reference or a tag.
+import assert from 'node:assert/strict';
+import { describe, test } from 'node:test';
+
+import { BREAK_EVERY, LONG_TOKEN, markupBreaks, text } from '../../src/layout/html.ts';
+
+/** Each piece's length in characters, a character reference counted as one. */
+const pieces = (out: string): number[] =>
+  out.split('<wbr>').map((piece) => piece.replace(/&[^;]*;/g, '_').length);
+
+describe("text()'s breaks", () => {
+  test('a token of 40 characters is kept whole; longer, it breaks in equal pieces', () => {
+    assert.equal(text('a'.repeat(LONG_TOKEN)), 'a'.repeat(LONG_TOKEN));
+    assert.deepEqual(pieces(text('a'.repeat(41))), [14, 14, 13]);
+    for (let n = LONG_TOKEN + 1; n <= 200; n++) {
+      const lengths = pieces(text('a'.repeat(n)));
+      assert.equal(lengths.length, Math.ceil(n / BREAK_EVERY), `${String(n)}: pieces`);
+      assert.ok(Math.max(...lengths) <= BREAK_EVERY, `${String(n)}: at most 20`);
+      assert.ok(Math.max(...lengths) - Math.min(...lengths) <= 1, `${String(n)}: equal`);
+    }
+  });
+
+  test('a token ends at a space Chromium breaks at, and runs through one it does not', () => {
+    const half = 'a'.repeat(30);
+    for (const space of [' ', '\t', '\n', '\r', '\r\n'])
+      assert.equal(text(`${half}${space}${half}`), `${half}${space}${half}`, JSON.stringify(space));
+    for (const joiner of ['\u00a0', '\u202f', '\ufeff']) {
+      const out = text(`${half}${joiner}${half}`);
+      assert.deepEqual(pieces(out), [16, 15, 15, 15], JSON.stringify(joiner));
+      assert.equal(out.replaceAll('<wbr>', ''), `${half}${joiner}${half}`);
+    }
+  });
+
+  test('a character escaped is one unit, never broken into', () => {
+    const out = text('&'.repeat(41));
+    assert.equal(out, ['&'.repeat(14), '&'.repeat(14), '&'.repeat(13)].join('<wbr>'));
+    assert.doesNotMatch(text(`${'<'.repeat(30)}>${'"'.repeat(30)}`), /&[a-z]*<wbr>/);
+  });
+});
+
+describe("markupBreaks()'s breaks", () => {
+  test('a character reference is one unit, never broken into', () => {
+    for (const reference of ['&', 'α', 'α']) {
+      const out = markupBreaks(reference.repeat(41));
+      assert.equal(
+        out,
+        [reference.repeat(14), reference.repeat(14), reference.repeat(13)].join('<wbr>')
+      );
+    }
+  });
+
+  test("a tag's attribute is never broken, nor counted in the word beside it", () => {
+    const href = `https://site.example/${'p'.repeat(60)}`;
+    const link = `<a href="${href}">link</a>`;
+    assert.equal(markupBreaks(`<p>${link}</p>`), `<p>${link}</p>`);
+    assert.equal(
+      markupBreaks(`<p>${'w'.repeat(30)}${link}</p>`),
+      `<p>${'w'.repeat(30)}${link}</p>`
+    );
+  });
+
+  test('a word runs through inline tags, and ends at any other', () => {
+    const x = 'x'.repeat(25);
+    const y = 'y'.repeat(25);
+    assert.equal(
+      markupBreaks(`<p>${x}<i>${y}</i></p>`),
+      `<p>${'x'.repeat(17)}<wbr>${'x'.repeat(8)}<i>${'y'.repeat(9)}<wbr>${'y'.repeat(16)}</i></p>`
+    );
+    assert.match(markupBreaks(`${x}<sub>2</sub>${y}`), /<wbr>/);
+    const cells = `<tr><td>${x}</td><td>${y}</td></tr>`;
+    assert.equal(markupBreaks(cells), cells);
+    assert.equal(markupBreaks(`${x}<br>${y}`), `${x}<br>${y}`);
+  });
+
+  test('its text breaks as text() does, a `<` before no tag name being text', () => {
+    const plain = `${'a'.repeat(45)} ${'b'.repeat(10)}`;
+    assert.equal(markupBreaks(plain), text(plain));
+    const lessThan = `${'a'.repeat(30)}<=${'b'.repeat(30)}`;
+    assert.equal(markupBreaks(lessThan).replaceAll('<wbr>', ''), lessThan);
+    assert.equal(markupBreaks(lessThan).split('<wbr>').length, 4);
+    assert.equal(
+      markupBreaks(`<p>${'a'.repeat(30)}\u00a0${'a'.repeat(30)}</p>`).split('<wbr>').length,
+      4
+    );
+  });
+});

From 04e8a1c6cd16f9eadbf54cdb46c68517a5f4853d Mon Sep 17 00:00:00 2001
From: Adam Wright <adam.j.wright82@gmail.com>
Date: Wed, 4 Nov 2026 03:54:23 +0000
Subject: [PATCH 2/4] Spec 001 T030 follow-up: the PR's number

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
 specs/001-pathway-pdfs/tasks.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/specs/001-pathway-pdfs/tasks.md b/specs/001-pathway-pdfs/tasks.md
index 7221579..d8e3ccd 100644
--- a/specs/001-pathway-pdfs/tasks.md
+++ b/specs/001-pathway-pdfs/tasks.md
@@ -536,7 +536,7 @@ passes.
       - **`overflow-wrap: anywhere` broke short columns' words (high).** In a table it let every column narrow to a character, so a table wider than the column broke its short columns' words (`Da`/`te`). `body` now has `overflow-wrap: break-word`, which keeps each column at its longest word, and the layout gives a token longer than 40 characters a `<wbr>` every 20 (`text()` in `src/layout/html.ts`, grapheme by grapheme, for every text it writes; `markupBreaks` for a summation's text runs, a character reference one unit; an attribute's value and the page's `<title>` never, through `plainText`), which adds no character to what is read. An edit's date is kept whole (`white-space: nowrap`), as a column narrowed to its longest word broke `2026-01-04` at its hyphens. Test: an edit's names, 30 of them, and a summation table of `Phosphofructokinase` beside a column of prose: exit 0, `Date` and `Action` whole and no fragment of them, every date whole, `Phosphofructokinase` whole four times; on #160's CSS it read `Da`, `te`, `Ac`. The overflow test is kept: its table is now four columns of long words, as wide as release 98's are (six columns of 28-letter words are wider than the column whatever breaks, and a part holding one is printed scaled down, which the check tolerates), and its 140-character token also sits in a table cell, where only its `<wbr>`s let the column narrow.
       - **Lows.** The docs no longer say nothing overflows: the layout keeps realistic content inside the column, a table of very many columns can still be wider, and the check's measure against the section tolerates a part printed scaled down (contracts/cli.md, research R8). `folded()` builds its pattern from FR-017's `NEVER_DRAWN` (`src/layout/characters.ts`), not a copy. `identifies()` reads a candidate's section only to the next place a section may start (an event's first link to its page, or an outline entry with an event's name). Recorded: the failed-removal test (`test/print/temporary.test.ts`) relies on `chmod` keeping a folder from being emptied, so it does not hold when run as root.
       - **Tests:** 439 before, 441 after (the labels and the short-columns tests). Each of these mutants made one fail: the page's commonest size with the figures' labels counted, as before (the labels test); `overflow-wrap: anywhere` again, and the dates allowed to break (the short-columns test, each); no `<wbr>` at all, and none in summations (the overflow test, each). Survived: the label's size alone, and the page's size without the figures' labels alone (each covers the other on the fixture; both kept, the second as the fallback for a section with no label); `identifies()` unbounded (no fixture section lacks its label, the only case it changes); no `overflow-wrap` and the tables' `max-width: 100%` (with the `<wbr>`s, nothing tested is wider than a line; both kept, as the review asked); the facts' grid at `minmax(0, 1fr)` and the contents' links at `min-width: 0`, taken out again.
-    - **Follow-up (#PR):** #162's review's five lows. A token ends only at a space Chromium breaks a line at (space, tab, line feed, carriage return): a no-break space, U+202F or U+FEFF no longer ends one. A summation's markup is read whole (`markupBreaks`, which `markupText` no longer feeds run by run), its tags never counted or broken into, and a word counted whole through the inline tags (`a`, `b`, `em`, `i`, `span`, `strong`, `sub`, `sup`, `u`), any other tag ending it. A token over 40 breaks into equal pieces of at most 20 (41 is 14, 14 and 13, not 20, 20 and 1). The docs (contracts/cli.md, research R8) say what can still overflow, measured on a page with the book's styles at the text column's width: unbroken tokens side by side, three columns of 40 characters, four of 30, five of 21; a longer token, broken into pieces of 14 to 20, takes six columns or more. Tests: `test/layout/html.test.ts` (7) for `text()` and `markupBreaks`, 441 before, 448 after. Each of these mutants made one fail: the old `/^\s$/u` space (the spaces test, and the markup one through a no-break space); a carriage return and line feed not taken as a space (the spaces test); character references not split out (`&` broken into, the references test); `text()` escaping before it breaks (the escaped test); tags taken as text (the attribute and inline-tags tests); every tag ending a word, and none (the inline-tags test, each); tags counted in a word's length (the same); a `<` before no tag name taken as a tag (the markup test); a break every 20 again (five tests).
+    - **Follow-up (#163):** #162's review's five lows. A token ends only at a space Chromium breaks a line at (space, tab, line feed, carriage return): a no-break space, U+202F or U+FEFF no longer ends one. A summation's markup is read whole (`markupBreaks`, which `markupText` no longer feeds run by run), its tags never counted or broken into, and a word counted whole through the inline tags (`a`, `b`, `em`, `i`, `span`, `strong`, `sub`, `sup`, `u`), any other tag ending it. A token over 40 breaks into equal pieces of at most 20 (41 is 14, 14 and 13, not 20, 20 and 1). The docs (contracts/cli.md, research R8) say what can still overflow, measured on a page with the book's styles at the text column's width: unbroken tokens side by side, three columns of 40 characters, four of 30, five of 21; a longer token, broken into pieces of 14 to 20, takes six columns or more. Tests: `test/layout/html.test.ts` (7) for `text()` and `markupBreaks`, 441 before, 448 after. Each of these mutants made one fail: the old `/^\s$/u` space (the spaces test, and the markup one through a no-break space); a carriage return and line feed not taken as a space (the spaces test); character references not split out (`&` broken into, the references test); `text()` escaping before it breaks (the escaped test); tags taken as text (the attribute and inline-tags tests); every tag ending a word, and none (the inline-tags test, each); tags counted in a word's length (the same); a `<` before no tag name taken as a tag (the markup test); a break every 20 again (five tests).
     - **Known gaps:** the rules are measured on the fixture alone, whose names are short and whose figures small: T031 runs the check on release 98's book, and its time against R7's 9 minutes. A figure is known by its link: a drawing lost with its link kept is not seen. A heading that lost its link and every entry naming it is found nowhere, and is then three faults. A link landing on a page but not on its target is not a fault (R8: every link landing on a page). Between `bookCommand` returning and the process ending there are no handlers, so a signal then is the default action, exit 128 and its number, the book and its report already in place.
 
 - [ ] T031 [US1] The book on a private copy of release 98's graph.

From 3e4a6b88a898cf526ce28033f5186dabe0f3dd41 Mon Sep 17 00:00:00 2001
From: Adam Wright <adam.j.wright82@gmail.com>
Date: Wed, 4 Nov 2026 04:15:14 +0000
Subject: [PATCH 3/4] Spec 001 T030, from #163's review: the spaces Chromium
 breaks at, measured

A token now ends at every space Chromium was measured to break a line at:
tab, line feed, carriage return, space, U+1680, U+2000 to U+2006, U+2008
to U+200B, U+2028, U+2029, U+205F and U+3000, and a numeric reference to
one. #163 ended tokens at space, tab, line feed and carriage return alone,
so two words beside a thin space counted as one token and a long pair
broke mid-word. A no-break space, U+2007, U+202F, U+FEFF, U+000B and
U+000C still join their neighbours.

ci/measure/breaks.ts measures both what Chromium breaks at (exit 1 where
BREAKABLE_SPACE disagrees) and how many columns of tokens of each length
are wider than the text column; its output is recorded, and the docs give
its figures as approximate, the letters and the method stated.

markupBreaks says it relies on sanitiseSummation; tests cover the tag
reader's quoted attributes, comments, a and b, upper case, raw markup
through the sanitiser, and every built summation sanitised.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
 ci/measure/breaks.ts                          | 123 ++++++++++++++++++
 .../results/2026-11-04/breaks/output.txt      |  53 ++++++++
 specs/001-pathway-pdfs/contracts/cli.md       |   9 +-
 specs/001-pathway-pdfs/research.md            |  11 +-
 specs/001-pathway-pdfs/tasks.md               |   6 +-
 src/layout/html.ts                            |  46 +++++--
 test/layout/html.test.ts                      |  61 ++++++++-
 test/model/build.test.ts                      |   7 +
 8 files changed, 296 insertions(+), 20 deletions(-)
 create mode 100644 ci/measure/breaks.ts
 create mode 100644 ci/measure/results/2026-11-04/breaks/output.txt

diff --git a/ci/measure/breaks.ts b/ci/measure/breaks.ts
new file mode 100644
index 0000000..4dd27c0
--- /dev/null
+++ b/ci/measure/breaks.ts
@@ -0,0 +1,123 @@
+// T030 (research R8): where Chromium breaks a line, and what can still be wider than the
+// text column once long tokens are given places to break (src/layout/html.ts).
+//
+//   node ci/measure/breaks.ts
+//
+// 1. Spaces: each character JavaScript's `\s` matches, and U+180E, U+200B and U+2060, set
+//    between two runs of eight letters in a box too narrow for both: whether Chromium puts
+//    them on two lines. Each is compared with `BREAKABLE_SPACE`; any disagreement is exit 1.
+// 2. Tables: a summation's table of two rows, every cell one token, laid out by Chromium
+//    with the book's stylesheets and print media, in a `.summation` as wide as the text
+//    column (A4 less two 20 mm margins, 170 mm); the token given its breaks by
+//    `markupBreaks`, as the layout does. For each token length, the fewest columns whose
+//    table is wider than the column. The letters are given: a widest case of `m` alone,
+//    and lowercase text cut from biochemical words, as a summation's long words are. The
+//    figures depend on the letters, so they are approximate for any other text.
+//
+// The output is numbers only.
+import { chromium } from 'playwright-core';
+
+import { pageStyles } from '../../src/layout/document.ts';
+import { BREAKABLE_SPACE, markupBreaks } from '../../src/layout/html.ts';
+
+const hex = (code: number) => `U+${code.toString(16).toUpperCase().padStart(4, '0')}`;
+
+const browser = await chromium.launch();
+let disagree = 0;
+try {
+  const page = await browser.newPage();
+
+  // 1. spaces
+  const codes: number[] = [];
+  for (let code = 0; code <= 0xffff; code++)
+    if (/\s/u.test(String.fromCharCode(code))) codes.push(code);
+  codes.push(0x180e, 0x200b, 0x2060);
+  await page.setContent(
+    '<div id="box" style="width: 60px; font: 16px monospace; white-space: normal"></div>'
+  );
+  // run in the page, as a string: this project's types have no DOM
+  const broken = await page.evaluate<boolean[]>(
+    `(() => {
+      const box = document.getElementById('box');
+      box.textContent = 'a';
+      const one = box.getBoundingClientRect().height;
+      return ${JSON.stringify(codes)}.map((code) => {
+        box.textContent = 'aaaaaaaa' + String.fromCharCode(code) + 'bbbbbbbb';
+        return box.getBoundingClientRect().height > one * 1.5;
+      });
+    })()`
+  );
+  console.log(
+    'Spaces: whether Chromium breaks a line there (DISAGREE: BREAKABLE_SPACE says otherwise)'
+  );
+  codes.forEach((code, i) => {
+    const chromiumBreaks = broken[i] === true;
+    const ends = BREAKABLE_SPACE.test(String.fromCharCode(code));
+    if (chromiumBreaks !== ends) disagree++;
+    const mark = chromiumBreaks === ends ? '' : '  DISAGREE';
+    console.log(`  ${hex(code)}  ${chromiumBreaks ? 'breaks' : 'joins '}  ${mark}`);
+  });
+
+  // 2. tables
+  const css = pageStyles();
+  const words = [
+    'phosphatidylinositol',
+    'glycosyltransferase',
+    'ubiquitin',
+    'mitochondrial',
+    'dehydrogenase',
+    'acetylglucosamine',
+    'carboxykinase',
+    'phosphofructokinase',
+    'transcription',
+    'cytochrome',
+    'oxidoreductase',
+    'aminoacyl',
+    'synthetase',
+    'glutathione',
+    'peroxidase',
+  ].join('');
+  const letters: Record<string, (length: number, column: number) => string> = {
+    'm alone': (length) => 'm'.repeat(length),
+    'biochemical words': (length, column) =>
+      (words + words).slice((column * 37) % words.length).slice(0, length),
+  };
+  const lengths = [20, 21, 25, 30, 35, 40, 41, 50, 60, 100];
+  console.log('\nTables: the fewest columns wider than the text column, by token length');
+  for (const [name, token] of Object.entries(letters)) {
+    console.log(`  ${name}`);
+    for (const length of lengths) {
+      let found = 'none of 1 to 12';
+      for (let columns = 1; columns <= 12; columns++) {
+        const cells = Array.from(
+          { length: columns },
+          (_, c) => `<td>${token(length, c)}</td>`
+        ).join('');
+        const table = markupBreaks(`<table><tr>${cells}</tr><tr>${cells}</tr></table>`);
+        await page.setContent(
+          `<!doctype html><html><head><style>${css.fonts}</style><style>${css.theme}</style>` +
+            `<style>${css.pages}</style></head><body class="book">` +
+            `<div class="summation" style="width: 170mm">${table}</div></body></html>`
+        );
+        await page.emulateMedia({ media: 'print' });
+        const [column, wide] = await page.evaluate<[number, number]>(
+          `document.fonts.ready.then(() => [
+            document.querySelector('.summation').getBoundingClientRect().width,
+            document.querySelector('table').scrollWidth,
+          ])`
+        );
+        if (wide > column + 0.5) {
+          found = `${String(columns)} (${wide.toFixed(0)} px > ${column.toFixed(0)} px)`;
+          break;
+        }
+      }
+      console.log(`    ${String(length).padStart(3)} characters: ${found}`);
+    }
+  }
+} finally {
+  await browser.close();
+}
+if (disagree > 0) {
+  console.log(`\n${String(disagree)} spaces disagree`);
+  process.exitCode = 1;
+}
diff --git a/ci/measure/results/2026-11-04/breaks/output.txt b/ci/measure/results/2026-11-04/breaks/output.txt
new file mode 100644
index 0000000..13b8d9f
--- /dev/null
+++ b/ci/measure/results/2026-11-04/breaks/output.txt
@@ -0,0 +1,53 @@
+Spaces: whether Chromium breaks a line there (DISAGREE: BREAKABLE_SPACE says otherwise)
+  U+0009  breaks  
+  U+000A  breaks  
+  U+000B  joins   
+  U+000C  joins   
+  U+000D  breaks  
+  U+0020  breaks  
+  U+00A0  joins   
+  U+1680  breaks  
+  U+2000  breaks  
+  U+2001  breaks  
+  U+2002  breaks  
+  U+2003  breaks  
+  U+2004  breaks  
+  U+2005  breaks  
+  U+2006  breaks  
+  U+2007  joins   
+  U+2008  breaks  
+  U+2009  breaks  
+  U+200A  breaks  
+  U+2028  breaks  
+  U+2029  breaks  
+  U+202F  joins   
+  U+205F  breaks  
+  U+3000  breaks  
+  U+FEFF  joins   
+  U+180E  joins   
+  U+200B  breaks  
+  U+2060  joins   
+
+Tables: the fewest columns wider than the text column, by token length
+  m alone
+     20 characters: 3 (685 px > 643 px)
+     21 characters: 3 (718 px > 643 px)
+     25 characters: 3 (849 px > 643 px)
+     30 characters: 2 (675 px > 643 px)
+     35 characters: 2 (784 px > 643 px)
+     40 characters: 2 (893 px > 643 px)
+     41 characters: 4 (653 px > 643 px)
+     50 characters: 4 (783 px > 643 px)
+     60 characters: 3 (685 px > 643 px)
+    100 characters: 3 (685 px > 643 px)
+  biochemical words
+     20 characters: 5 (647 px > 643 px)
+     21 characters: 5 (677 px > 643 px)
+     25 characters: 5 (795 px > 643 px)
+     30 characters: 4 (748 px > 643 px)
+     35 characters: 3 (663 px > 643 px)
+     40 characters: 3 (754 px > 643 px)
+     41 characters: 7 (674 px > 643 px)
+     50 characters: 6 (691 px > 643 px)
+     60 characters: 5 (675 px > 643 px)
+    100 characters: 5 (685 px > 643 px)
diff --git a/specs/001-pathway-pdfs/contracts/cli.md b/specs/001-pathway-pdfs/contracts/cli.md
index fb99292..bdfc2fa 100644
--- a/specs/001-pathway-pdfs/contracts/cli.md
+++ b/specs/001-pathway-pdfs/contracts/cli.md
@@ -204,9 +204,12 @@ FR-011's check, alone. Also run by `book`.
     part Chromium printed scaled down still reads its names, its scale not a fault: the
     layout keeps realistic content inside the text column (long tokens may break, a table's
     columns narrow to their longest words), but a table whose longest words, side by side,
-    are wider than the column can still be wider: a token of up to 40 characters does not
-    break, so three columns of 40-character words are wider, four of 30 and five of 21 (a
-    longer token breaks into pieces of 14 to 20, and takes six columns or more);
+    are wider than the column can still be wider. A token of up to 40 characters does not
+    break, and a longer one breaks into pieces of 13 to 20; how many columns are too many
+    depends on the letters. Measured (`ci/measure/breaks.ts`, lowercase text cut from
+    biochemical words), roughly: five columns of 20- to 25-character words, four of 30,
+    three of 35 to 40, and five to seven of longer tokens; of wider letters (`m` alone),
+    two to four;
     `no-stable-id`: its text holds no `Stable identifier` followed by its stable identifier;
   - `missing-figure`: a pathway's or reaction's section with a figure by the Figure rules,
     whose source is in `--files` (its diagram's two files, its EHLD; a reaction's is the
diff --git a/specs/001-pathway-pdfs/research.md b/specs/001-pathway-pdfs/research.md
index ae4d540..76b7aa8 100644
--- a/specs/001-pathway-pdfs/research.md
+++ b/specs/001-pathway-pdfs/research.md
@@ -955,9 +955,14 @@ embedded.
     column, which Chromium printed by scaling its whole part down below the theme's sizes,
     would have reached the real book: the layout now keeps realistic content inside the
     column (pages.css; a table whose longest words, side by side, are wider than the
-    column can still be wider: measured, three columns of unbroken 40-character words,
-    four of 30 or five of 21; a longer token, broken into pieces of 14 to 20, takes six
-    columns or more), and the check
+    column can still be wider: a token of up to 40 characters does not break, a longer one
+    breaks into pieces of 13 to 20. Measured by `ci/measure/breaks.ts`, a table of two
+    rows laid out by Chromium with the book's stylesheets and print media in a
+    `.summation` 170 mm wide, the text column; its output in
+    `ci/measure/results/2026-11-04/breaks/`. With lowercase text cut from biochemical
+    words, roughly five columns of 20- to 25-character words are wider, four of 30,
+    three of 35 to 40, and five to seven of longer tokens; with `m` alone, two to four.
+    The figures depend on the letters, so they are approximate), and the check
     reads headings against the section's own body text, so a part printed scaled down is
     not a fault. No human summation holds a
     `<pre>` block, and no human event's name holds a soft hyphen, a zero-width or
diff --git a/specs/001-pathway-pdfs/tasks.md b/specs/001-pathway-pdfs/tasks.md
index d8e3ccd..0c9e1e7 100644
--- a/specs/001-pathway-pdfs/tasks.md
+++ b/specs/001-pathway-pdfs/tasks.md
@@ -536,7 +536,11 @@ passes.
       - **`overflow-wrap: anywhere` broke short columns' words (high).** In a table it let every column narrow to a character, so a table wider than the column broke its short columns' words (`Da`/`te`). `body` now has `overflow-wrap: break-word`, which keeps each column at its longest word, and the layout gives a token longer than 40 characters a `<wbr>` every 20 (`text()` in `src/layout/html.ts`, grapheme by grapheme, for every text it writes; `markupBreaks` for a summation's text runs, a character reference one unit; an attribute's value and the page's `<title>` never, through `plainText`), which adds no character to what is read. An edit's date is kept whole (`white-space: nowrap`), as a column narrowed to its longest word broke `2026-01-04` at its hyphens. Test: an edit's names, 30 of them, and a summation table of `Phosphofructokinase` beside a column of prose: exit 0, `Date` and `Action` whole and no fragment of them, every date whole, `Phosphofructokinase` whole four times; on #160's CSS it read `Da`, `te`, `Ac`. The overflow test is kept: its table is now four columns of long words, as wide as release 98's are (six columns of 28-letter words are wider than the column whatever breaks, and a part holding one is printed scaled down, which the check tolerates), and its 140-character token also sits in a table cell, where only its `<wbr>`s let the column narrow.
       - **Lows.** The docs no longer say nothing overflows: the layout keeps realistic content inside the column, a table of very many columns can still be wider, and the check's measure against the section tolerates a part printed scaled down (contracts/cli.md, research R8). `folded()` builds its pattern from FR-017's `NEVER_DRAWN` (`src/layout/characters.ts`), not a copy. `identifies()` reads a candidate's section only to the next place a section may start (an event's first link to its page, or an outline entry with an event's name). Recorded: the failed-removal test (`test/print/temporary.test.ts`) relies on `chmod` keeping a folder from being emptied, so it does not hold when run as root.
       - **Tests:** 439 before, 441 after (the labels and the short-columns tests). Each of these mutants made one fail: the page's commonest size with the figures' labels counted, as before (the labels test); `overflow-wrap: anywhere` again, and the dates allowed to break (the short-columns test, each); no `<wbr>` at all, and none in summations (the overflow test, each). Survived: the label's size alone, and the page's size without the figures' labels alone (each covers the other on the fixture; both kept, the second as the fallback for a section with no label); `identifies()` unbounded (no fixture section lacks its label, the only case it changes); no `overflow-wrap` and the tables' `max-width: 100%` (with the `<wbr>`s, nothing tested is wider than a line; both kept, as the review asked); the facts' grid at `minmax(0, 1fr)` and the contents' links at `min-width: 0`, taken out again.
-    - **Follow-up (#163):** #162's review's five lows. A token ends only at a space Chromium breaks a line at (space, tab, line feed, carriage return): a no-break space, U+202F or U+FEFF no longer ends one. A summation's markup is read whole (`markupBreaks`, which `markupText` no longer feeds run by run), its tags never counted or broken into, and a word counted whole through the inline tags (`a`, `b`, `em`, `i`, `span`, `strong`, `sub`, `sup`, `u`), any other tag ending it. A token over 40 breaks into equal pieces of at most 20 (41 is 14, 14 and 13, not 20, 20 and 1). The docs (contracts/cli.md, research R8) say what can still overflow, measured on a page with the book's styles at the text column's width: unbroken tokens side by side, three columns of 40 characters, four of 30, five of 21; a longer token, broken into pieces of 14 to 20, takes six columns or more. Tests: `test/layout/html.test.ts` (7) for `text()` and `markupBreaks`, 441 before, 448 after. Each of these mutants made one fail: the old `/^\s$/u` space (the spaces test, and the markup one through a no-break space); a carriage return and line feed not taken as a space (the spaces test); character references not split out (`&` broken into, the references test); `text()` escaping before it breaks (the escaped test); tags taken as text (the attribute and inline-tags tests); every tag ending a word, and none (the inline-tags test, each); tags counted in a word's length (the same); a `<` before no tag name taken as a tag (the markup test); a break every 20 again (five tests).
+    - **Follow-up (#PR, replacing #163 after its review):** #162's review's five lows. A token ends only at a space Chromium breaks a line at, as measured (`ci/measure/breaks.ts`): tab, line feed, carriage return, space, U+1680, U+2000 to U+2006, U+2008 to U+200B, U+2028, U+2029, U+205F and U+3000, and a numeric reference to one; a no-break space, U+2007, U+202F, U+FEFF, U+000B and U+000C no longer end one. A summation's markup is read whole (`markupBreaks`, which `markupText` no longer feeds run by run), its tags never counted or broken into, and a word counted whole through the inline tags (`a`, `b`, `em`, `i`, `span`, `strong`, `sub`, `sup`, `u`) and comments, any other tag ending it; it relies on `sanitiseSummation`, which the model applies to every summation. A token over 40 breaks into equal pieces of at most 20 (41 is 14, 14 and 13, not 20, 20 and 1). The docs (contracts/cli.md, research R8) say what can still overflow, measured by `ci/measure/breaks.ts` (output in `ci/measure/results/2026-11-04/breaks/`) and approximate, as it depends on the letters: with lowercase text cut from biochemical words, five columns of 20- to 25-character words, four of 30, three of 35 to 40, five to seven of longer tokens (broken into pieces of 13 to 20); with `m` alone, two to four. Tests: `test/layout/html.test.ts` (11) for `text()` and `markupBreaks`, and in `test/model/build.test.ts` every built summation sanitised; 441 before, 452 after. Each of these mutants made one fail: the old `/^\s$/u` space (the spaces test, and the markup one through a no-break space); a carriage return and line feed not taken as a space (the spaces test); character references not split out (`&` broken into, the references test); `text()` escaping before it breaks (the escaped test); tags taken as text (the attribute and inline-tags tests); every tag ending a word, and none (the inline-tags test, each); tags counted in a word's length (the same); a `<` before no tag name taken as a tag (the markup test); a break every 20 again (five tests).
+      - **From #163's review:** two mediums, each reproduced, and lows.
+        - **Breaking spaces dropped (medium, a regression).** #163 ended a token only at a space, tab, line feed or carriage return, so two words beside a thin space (or an en, em or ideographic space, or U+200B, which main also missed) counted as one token, and a long pair broke mid-word; 9 of release 98's human summations hold such spaces (measured on the private copy). `BREAKABLE_SPACE` is now the set Chromium was measured to break at, which `ci/measure/breaks.ts` checks (exit 1 on a disagreement); the review's rule, `\s` without U+00A0, U+202F and U+FEFF and with U+200B, would also have ended tokens at U+2007, U+000B and U+000C, at which Chromium does not break. Tests: two 28- and 17-letter words beside a thin space kept whole; every measured space, breaking and joining. Each of these mutants made one fail: #163's set (the thin-space, spaces and reference tests); the set without U+2000 to U+200B (the same); the review's rule, and U+2007 taken as a space (the spaces test, each); numeric references never taken as spaces (the reference test).
+        - **The overflow figures (medium).** #163's figures, measured with the one word `phosphatidylinositoltrisphosphatekinases`, did not hold for other letters, and said pieces of 14 to 20 where 41 gives 13. Re-measured by a script that can be re-run (`ci/measure/breaks.ts`), the letters and the method stated, the figures given as approximate (above).
+        - **Lows.** The tag reader's branches are tested: a quoted `>` in an attribute, a comment within a word, `a` and `b` inline, and a tag in capitals; each of these mutants made the test fail: quotes not read in a tag, comments ending a word, `a` and `b` not inline, tag names not lowercased. `markupBreaks`'s doc comment says it relies on `sanitiseSummation`, and that raw markup (CDATA, a doctype, a processing instruction, a script) is not read rightly; a test gives such raw markup through the sanitiser and finds no `<wbr>` inside a tag, and the model's test checks every summation it builds is the sanitiser's fixed point (with the model not sanitising, it fails, as the existing check for scripts does).
     - **Known gaps:** the rules are measured on the fixture alone, whose names are short and whose figures small: T031 runs the check on release 98's book, and its time against R7's 9 minutes. A figure is known by its link: a drawing lost with its link kept is not seen. A heading that lost its link and every entry naming it is found nowhere, and is then three faults. A link landing on a page but not on its target is not a fault (R8: every link landing on a page). Between `bookCommand` returning and the process ending there are no handlers, so a signal then is the default action, exit 128 and its number, the book and its report already in place.
 
 - [ ] T031 [US1] The book on a private copy of release 98's graph.
diff --git a/src/layout/html.ts b/src/layout/html.ts
index a89efd5..e865ee7 100644
--- a/src/layout/html.ts
+++ b/src/layout/html.ts
@@ -22,12 +22,16 @@ export const LONG_TOKEN = 40;
 export const BREAK_EVERY = 20;
 
 /**
- * The spaces a token ends at: those Chromium breaks a line at. A no-break space, U+202F or
- * U+FEFF joins its neighbours, so a token runs through it; a space it could break at but
- * that is not listed (an en space, say) only adds a `<wbr>` where a break already was.
- * A carriage return and line feed are one grapheme.
+ * The spaces a token ends at: those Chromium breaks a line at, measured
+ * (`ci/measure/breaks.ts`): tab, line feed, carriage return (a carriage return and line
+ * feed are one grapheme), space, U+1680, U+2000 to U+2006, U+2008 to U+200A (the en, em,
+ * thin and hair spaces among them), U+200B (zero width), U+2028, U+2029, U+205F and U+3000.
+ * The others `\s` holds join their neighbours, and a token runs through them: U+000B,
+ * U+000C, U+00A0 (no-break), U+2007 (figure), U+202F and U+FEFF. A space left out of this
+ * list would join two words into one token, and a long one would break mid-word.
  */
-const BREAKABLE_SPACE = /^[ \t\n\r]+$/;
+export const BREAKABLE_SPACE =
+  /^[\t\n\r \u1680\u2000-\u2006\u2008-\u200b\u2028\u2029\u205f\u3000]+$/u;
 
 /**
  * A unit of text: a character as a reader sees it, a character reference, or a tag, which
@@ -36,6 +40,8 @@ const BREAKABLE_SPACE = /^[ \t\n\r]+$/;
 interface Unit {
   readonly text: string;
   readonly tag?: 'joins' | 'ends';
+  /** A character reference to a space a line breaks at. */
+  readonly space?: boolean;
 }
 
 /**
@@ -72,7 +78,7 @@ function breakTokens(units: readonly Unit[], escape: (unit: string) => string):
     } else if (unit.tag === 'ends') {
       flush();
       out += unit.text;
-    } else if (BREAKABLE_SPACE.test(unit.text)) {
+    } else if (unit.space === true || BREAKABLE_SPACE.test(unit.text)) {
       flush();
       out += escape(unit.text);
     } else {
@@ -100,14 +106,32 @@ export function text(value: string): string {
  */
 const INLINE_TAGS = new Set(['a', 'b', 'em', 'i', 'span', 'strong', 'sub', 'sup', 'u']);
 
-/** A tag (its attributes' quoted values may hold `>`), or a comment; a `<` before neither is text. */
+/**
+ * A tag (its attributes' quoted values may hold `>`), or a comment; a `<` before neither is
+ * text. Enough for the sanitiser's output, which holds no CDATA, doctype, processing
+ * instruction or script or style element: raw markup is not read rightly.
+ */
 const TAG = /(<!--[\s\S]*?(?:-->|$)|<\/?([a-z][a-z0-9]*)(?:[^>"']|"[^"]*"|'[^']*')*>?)/giu;
 const REFERENCE = /(&(?:#[0-9]+|#x[0-9a-f]+|[a-z][a-z0-9]*);)/giu;
 
 /**
- * A summation's sanitised markup, a long token in its text given places to break as `text`
- * gives plain text: each character reference one unit, a tag never broken into and never
- * counted, and a word counted whole through inline tags.
+ * The character a numeric reference stands for; a named one is left as it is, as the
+ * sanitiser writes every character but `&`, `<`, `>` and `"` as itself.
+ */
+function referenced(reference: string): string {
+  const hex = /^&#x([0-9a-f]+);$/iu.exec(reference)?.[1];
+  const decimal = /^&#([0-9]+);$/u.exec(reference)?.[1];
+  const code =
+    hex !== undefined ? parseInt(hex, 16) : decimal !== undefined ? Number(decimal) : NaN;
+  return code <= 0x10ffff ? String.fromCodePoint(code) : reference;
+}
+
+/**
+ * A summation's markup, sanitised by `sanitiseSummation` (src/model/rules.ts), which the
+ * model applies to every summation it builds, a long token in its text given places to
+ * break as `text` gives plain text: each character reference one unit, a tag never broken
+ * into and never counted, and a word counted whole through inline tags. It relies on the
+ * sanitiser: on raw markup (CDATA, a doctype, a script) it may break where it should not.
  */
 export function markupBreaks(markup: string): string {
   const units: Unit[] = [];
@@ -116,7 +140,7 @@ export function markupBreaks(markup: string): string {
   for (let i = 0; i < parts.length; i += 3) {
     const between = parts[i] ?? '';
     between.split(REFERENCE).forEach((part, j) => {
-      if (j % 2 === 1) units.push({ text: part });
+      if (j % 2 === 1) units.push({ text: part, space: BREAKABLE_SPACE.test(referenced(part)) });
       else units.push(...graphemes(part));
     });
     const tag = parts[i + 1];
diff --git a/test/layout/html.test.ts b/test/layout/html.test.ts
index 8242506..7c715f6 100644
--- a/test/layout/html.test.ts
+++ b/test/layout/html.test.ts
@@ -4,6 +4,7 @@ import assert from 'node:assert/strict';
 import { describe, test } from 'node:test';
 
 import { BREAK_EVERY, LONG_TOKEN, markupBreaks, text } from '../../src/layout/html.ts';
+import { sanitiseSummation } from '../../src/model/rules.ts';
 
 /** Each piece's length in characters, a character reference counted as one. */
 const pieces = (out: string): number[] =>
@@ -22,16 +23,37 @@ describe("text()'s breaks", () => {
   });
 
   test('a token ends at a space Chromium breaks at, and runs through one it does not', () => {
+    // as measured by ci/measure/breaks.ts
     const half = 'a'.repeat(30);
-    for (const space of [' ', '\t', '\n', '\r', '\r\n'])
+    const breaking = [' ', '\t', '\n', '\r', '\r\n', '\u1680', '\u200b', '\u2028', '\u2029'];
+    for (let code = 0x2000; code <= 0x200a; code++)
+      if (code !== 0x2007) breaking.push(String.fromCharCode(code));
+    breaking.push('\u205f', '\u3000');
+    for (const space of breaking)
       assert.equal(text(`${half}${space}${half}`), `${half}${space}${half}`, JSON.stringify(space));
-    for (const joiner of ['\u00a0', '\u202f', '\ufeff']) {
+    const joining = [
+      '\u000b',
+      '\u000c',
+      '\u00a0',
+      '\u2007',
+      '\u202f',
+      '\ufeff',
+      '\u180e',
+      '\u2060',
+    ];
+    for (const joiner of joining) {
       const out = text(`${half}${joiner}${half}`);
       assert.deepEqual(pieces(out), [16, 15, 15, 15], JSON.stringify(joiner));
       assert.equal(out.replaceAll('<wbr>', ''), `${half}${joiner}${half}`);
     }
   });
 
+  test('two words beside a thin space are two tokens, each kept whole', () => {
+    // 46 characters together: before, they were one token, broken mid-word
+    const name = 'glycosylphosphatidylinositol\u2009phosphodiesterase isoform';
+    assert.equal(text(name), name);
+  });
+
   test('a character escaped is one unit, never broken into', () => {
     const out = text('&'.repeat(41));
     assert.equal(out, ['&'.repeat(14), '&'.repeat(14), '&'.repeat(13)].join('<wbr>'));
@@ -84,4 +106,39 @@ describe("markupBreaks()'s breaks", () => {
       4
     );
   });
+  test('a space written as a numeric reference ends a token as the space does', () => {
+    const x = 'x'.repeat(30);
+    for (const space of [' ', ' ', ' '])
+      assert.equal(markupBreaks(`${x}${space}${x}`), `${x}${space}${x}`, space);
+    assert.equal(markupBreaks(`${x} ${x}`).split('<wbr>').length, 4);
+    assert.equal(markupBreaks(`${x} ${x}`).split('<wbr>').length, 4);
+  });
+
+  test("a tag's quoted `>`, a comment, upper case: read as the tags they are", () => {
+    const x = 'x'.repeat(25);
+    const y = 'y'.repeat(25);
+    // a quoted `>` does not end the tag, so nothing in it is counted or broken
+    const link = `<a href="https://site.example/?q=a>${'b'.repeat(40)}">`;
+    assert.equal(markupBreaks(`<p>${link}w</a></p>`), `<p>${link}w</a></p>`);
+    // a word runs through a comment, `a`, `b` and an inline tag in capitals
+    for (const [open, close] of [
+      ['<!-- note -->', ''],
+      ['<a href="https://site.example/">', '</a>'],
+      ['<b>', '</b>'],
+      ['<I>', '</I>'],
+    ] as const) {
+      const out = markupBreaks(`${x}${open}${y}${close}`);
+      assert.equal(out.split('<wbr>').length, 3, open);
+      assert.equal(out.replaceAll('<wbr>', ''), `${x}${open}${y}${close}`, open);
+    }
+  });
+
+  test("it is given the sanitiser's markup, and breaks nothing inside a tag of it", () => {
+    const raw =
+      `<!DOCTYPE html><?pi ${'p'.repeat(50)}?><![CDATA[${'c'.repeat(50)}]]>` +
+      `<script>${'s'.repeat(50)}</script><p title="${'t'.repeat(50)}">${'w'.repeat(50)}</p>`;
+    const out = markupBreaks(sanitiseSummation(raw));
+    assert.doesNotMatch(out, /<[^>]*<wbr>/);
+    assert.match(out, /^<p>w+<wbr>w+<wbr>w+<\/p>$/);
+  });
 });
diff --git a/test/model/build.test.ts b/test/model/build.test.ts
index ca58255..e197270 100644
--- a/test/model/build.test.ts
+++ b/test/model/build.test.ts
@@ -6,6 +6,7 @@ import { after, before, describe, test } from 'node:test';
 import { graphSettings, openGraph, type Graph } from '../../src/graph/connection.ts';
 import { eventByIdentifier } from '../../src/graph/queries.ts';
 import { buildBook, buildSingle } from '../../src/model/build.ts';
+import { sanitiseSummation } from '../../src/model/rules.ts';
 import type { Document, Place } from '../../src/model/types.ts';
 
 const skip = process.env['NEO4J_URI'] ? false : 'no fixture graph: set NEO4J_URI';
@@ -205,6 +206,12 @@ describe('the model', { skip }, () => {
     ]);
     const alpha = fullOf(pdfs, 'R-HSA-90000200');
     assert.doesNotMatch(alpha.summations.join(''), /script|onerror|javascript/);
+    // every summation sanitised, as the layout's breaks (markupBreaks) rely on it
+    for (const doc of pdfs)
+      for (const p of doc.places)
+        if (p.full)
+          for (const summation of p.section.summations)
+            assert.equal(sanitiseSummation(summation), summation, p.section.event.stId);
     assert.deepEqual(
       fullOf(pdfs, 'R-HSA-90000120').inferredFrom.map((s) => s.speciesName),
       ['Mus musculus', 'Rattus norvegicus']

From c9c3f533dcb664ee9e81b800d0734d793ac54920 Mon Sep 17 00:00:00 2001
From: Adam Wright <adam.j.wright82@gmail.com>
Date: Wed, 4 Nov 2026 04:16:05 +0000
Subject: [PATCH 4/4] Spec 001 T030 follow-up: the PR's number

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
 specs/001-pathway-pdfs/tasks.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/specs/001-pathway-pdfs/tasks.md b/specs/001-pathway-pdfs/tasks.md
index 0c9e1e7..359a779 100644
--- a/specs/001-pathway-pdfs/tasks.md
+++ b/specs/001-pathway-pdfs/tasks.md
@@ -536,7 +536,7 @@ passes.
       - **`overflow-wrap: anywhere` broke short columns' words (high).** In a table it let every column narrow to a character, so a table wider than the column broke its short columns' words (`Da`/`te`). `body` now has `overflow-wrap: break-word`, which keeps each column at its longest word, and the layout gives a token longer than 40 characters a `<wbr>` every 20 (`text()` in `src/layout/html.ts`, grapheme by grapheme, for every text it writes; `markupBreaks` for a summation's text runs, a character reference one unit; an attribute's value and the page's `<title>` never, through `plainText`), which adds no character to what is read. An edit's date is kept whole (`white-space: nowrap`), as a column narrowed to its longest word broke `2026-01-04` at its hyphens. Test: an edit's names, 30 of them, and a summation table of `Phosphofructokinase` beside a column of prose: exit 0, `Date` and `Action` whole and no fragment of them, every date whole, `Phosphofructokinase` whole four times; on #160's CSS it read `Da`, `te`, `Ac`. The overflow test is kept: its table is now four columns of long words, as wide as release 98's are (six columns of 28-letter words are wider than the column whatever breaks, and a part holding one is printed scaled down, which the check tolerates), and its 140-character token also sits in a table cell, where only its `<wbr>`s let the column narrow.
       - **Lows.** The docs no longer say nothing overflows: the layout keeps realistic content inside the column, a table of very many columns can still be wider, and the check's measure against the section tolerates a part printed scaled down (contracts/cli.md, research R8). `folded()` builds its pattern from FR-017's `NEVER_DRAWN` (`src/layout/characters.ts`), not a copy. `identifies()` reads a candidate's section only to the next place a section may start (an event's first link to its page, or an outline entry with an event's name). Recorded: the failed-removal test (`test/print/temporary.test.ts`) relies on `chmod` keeping a folder from being emptied, so it does not hold when run as root.
       - **Tests:** 439 before, 441 after (the labels and the short-columns tests). Each of these mutants made one fail: the page's commonest size with the figures' labels counted, as before (the labels test); `overflow-wrap: anywhere` again, and the dates allowed to break (the short-columns test, each); no `<wbr>` at all, and none in summations (the overflow test, each). Survived: the label's size alone, and the page's size without the figures' labels alone (each covers the other on the fixture; both kept, the second as the fallback for a section with no label); `identifies()` unbounded (no fixture section lacks its label, the only case it changes); no `overflow-wrap` and the tables' `max-width: 100%` (with the `<wbr>`s, nothing tested is wider than a line; both kept, as the review asked); the facts' grid at `minmax(0, 1fr)` and the contents' links at `min-width: 0`, taken out again.
-    - **Follow-up (#PR, replacing #163 after its review):** #162's review's five lows. A token ends only at a space Chromium breaks a line at, as measured (`ci/measure/breaks.ts`): tab, line feed, carriage return, space, U+1680, U+2000 to U+2006, U+2008 to U+200B, U+2028, U+2029, U+205F and U+3000, and a numeric reference to one; a no-break space, U+2007, U+202F, U+FEFF, U+000B and U+000C no longer end one. A summation's markup is read whole (`markupBreaks`, which `markupText` no longer feeds run by run), its tags never counted or broken into, and a word counted whole through the inline tags (`a`, `b`, `em`, `i`, `span`, `strong`, `sub`, `sup`, `u`) and comments, any other tag ending it; it relies on `sanitiseSummation`, which the model applies to every summation. A token over 40 breaks into equal pieces of at most 20 (41 is 14, 14 and 13, not 20, 20 and 1). The docs (contracts/cli.md, research R8) say what can still overflow, measured by `ci/measure/breaks.ts` (output in `ci/measure/results/2026-11-04/breaks/`) and approximate, as it depends on the letters: with lowercase text cut from biochemical words, five columns of 20- to 25-character words, four of 30, three of 35 to 40, five to seven of longer tokens (broken into pieces of 13 to 20); with `m` alone, two to four. Tests: `test/layout/html.test.ts` (11) for `text()` and `markupBreaks`, and in `test/model/build.test.ts` every built summation sanitised; 441 before, 452 after. Each of these mutants made one fail: the old `/^\s$/u` space (the spaces test, and the markup one through a no-break space); a carriage return and line feed not taken as a space (the spaces test); character references not split out (`&` broken into, the references test); `text()` escaping before it breaks (the escaped test); tags taken as text (the attribute and inline-tags tests); every tag ending a word, and none (the inline-tags test, each); tags counted in a word's length (the same); a `<` before no tag name taken as a tag (the markup test); a break every 20 again (five tests).
+    - **Follow-up (#164, replacing #163 after its review):** #162's review's five lows. A token ends only at a space Chromium breaks a line at, as measured (`ci/measure/breaks.ts`): tab, line feed, carriage return, space, U+1680, U+2000 to U+2006, U+2008 to U+200B, U+2028, U+2029, U+205F and U+3000, and a numeric reference to one; a no-break space, U+2007, U+202F, U+FEFF, U+000B and U+000C no longer end one. A summation's markup is read whole (`markupBreaks`, which `markupText` no longer feeds run by run), its tags never counted or broken into, and a word counted whole through the inline tags (`a`, `b`, `em`, `i`, `span`, `strong`, `sub`, `sup`, `u`) and comments, any other tag ending it; it relies on `sanitiseSummation`, which the model applies to every summation. A token over 40 breaks into equal pieces of at most 20 (41 is 14, 14 and 13, not 20, 20 and 1). The docs (contracts/cli.md, research R8) say what can still overflow, measured by `ci/measure/breaks.ts` (output in `ci/measure/results/2026-11-04/breaks/`) and approximate, as it depends on the letters: with lowercase text cut from biochemical words, five columns of 20- to 25-character words, four of 30, three of 35 to 40, five to seven of longer tokens (broken into pieces of 13 to 20); with `m` alone, two to four. Tests: `test/layout/html.test.ts` (11) for `text()` and `markupBreaks`, and in `test/model/build.test.ts` every built summation sanitised; 441 before, 452 after. Each of these mutants made one fail: the old `/^\s$/u` space (the spaces test, and the markup one through a no-break space); a carriage return and line feed not taken as a space (the spaces test); character references not split out (`&` broken into, the references test); `text()` escaping before it breaks (the escaped test); tags taken as text (the attribute and inline-tags tests); every tag ending a word, and none (the inline-tags test, each); tags counted in a word's length (the same); a `<` before no tag name taken as a tag (the markup test); a break every 20 again (five tests).
       - **From #163's review:** two mediums, each reproduced, and lows.
         - **Breaking spaces dropped (medium, a regression).** #163 ended a token only at a space, tab, line feed or carriage return, so two words beside a thin space (or an en, em or ideographic space, or U+200B, which main also missed) counted as one token, and a long pair broke mid-word; 9 of release 98's human summations hold such spaces (measured on the private copy). `BREAKABLE_SPACE` is now the set Chromium was measured to break at, which `ci/measure/breaks.ts` checks (exit 1 on a disagreement); the review's rule, `\s` without U+00A0, U+202F and U+FEFF and with U+200B, would also have ended tokens at U+2007, U+000B and U+000C, at which Chromium does not break. Tests: two 28- and 17-letter words beside a thin space kept whole; every measured space, breaking and joining. Each of these mutants made one fail: #163's set (the thin-space, spaces and reference tests); the set without U+2000 to U+200B (the same); the review's rule, and U+2007 taken as a space (the spaces test, each); numeric references never taken as spaces (the reference test).
         - **The overflow figures (medium).** #163's figures, measured with the one word `phosphatidylinositoltrisphosphatekinases`, did not hold for other letters, and said pieces of 14 to 20 where 41 gives 13. Re-measured by a script that can be re-run (`ci/measure/breaks.ts`), the letters and the method stated, the figures given as approximate (above).