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..d8e3ccd 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 ``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 `` 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 ``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).
- **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 `` 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 `` 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 `` 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 += '';
- 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 += '';
+ 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 `Na2` 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 = /(|$)|<\/?([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('