diff --git a/specs/001-pathway-pdfs/contracts/cli.md b/specs/001-pathway-pdfs/contracts/cli.md index 6ce917b..03ed58f 100644 --- a/specs/001-pathway-pdfs/contracts/cli.md +++ b/specs/001-pathway-pdfs/contracts/cli.md @@ -99,10 +99,13 @@ the figures already drawn by `figures`. here can come after earlier parts or PDFs are printed; the step still ends with exit 1 and no book written, the folder being moved into place only at the end. - **Exit 0:** the book is written, and `check-book` passed on it. The folder is written - beside its name and moved into place, so a failed run never leaves a partial book. A - run is whole: a failure in any PDF writes none, and the step is run again. -- **Exit 1:** a fault from the preflight or FR-011. The report names each one by stable - identifier or file name, never a person. + beside its name, checked there (`check-book`'s checks, on the documents the preflight + built, the printer closed first), and moved into place only once they pass, so a failed + run never leaves a partial book or one that fails its check. A run is whole: a failure in + any PDF writes none, and the step is run again. +- **Exit 1:** a fault from the preflight or FR-011, each printed as `check-book` prints it. + The report names each one by stable identifier or file name, never a person, and gives + each FR-011 check with its count of faults. - **Exit 2:** bad arguments; a `--report` that exists already (left untouched, and no report written), whose folder cannot be made, or that is inside `--out`; an `--out` that is not absent or empty, as for `figures` (an old book is never replaced in place), that @@ -118,12 +121,16 @@ the figures already drawn by `figures`. 128 and the signal's number. It exits rather than raising the signal again, as the exit handlers that end the browser and remove its folders run only on an exit. A signal while the browser is starting is acted on once it has started, and a second - signal then stops the run at once. + signal then stops the run at once. Once the book is checked and moved into place, a + signal stops nothing that is not done: the book is left in place, the report says it + was written (outcome `written`) and names the signal, and the exit is 0. - **A private temporary folder:** the run makes one in TMPDIR at its start and points everything it starts at it (the printer's pages, the browser launcher's folders and the browser itself, by TMPDIR), and removes it, whole, as the process exits, after the - launcher has ended the browser; so a signal at any moment leaves nothing in TMPDIR. The - browser launcher's own signal handling is off, as in `serve`. + launcher has ended the browser; so SIGINT, SIGTERM or SIGHUP at any moment leaves nothing + in TMPDIR (SIGKILL, or the memory cap's kill, leaves it). One is live in a process at a + time, as TMPDIR is the process's: a second is refused. The browser launcher's own signal + handling is off, as in `serve`. ## `pdf-export preflight` @@ -153,12 +160,15 @@ as in `--files` (`diagram/R-HSA-….json`), a figure's as in `--figures` (`R-HSA FR-011's check, alone. Also run by `book`. -| Flag | Meaning | -| ---------------------- | ---------------------------------------------------------------------------------------- | -| `--graph`, `--species` | the graph the book was made from, for the list of top-level pathways and events expected | -| `--files` | the release's folder, for which events have a figure's source (FR-011) | +| Flag | Meaning | +| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | +| `--graph`, `--database`, `--species` | as for `book`: the graph the book was made from, for the list of top-level pathways and events expected | +| `--files` | the release's folder: its EHLD summary, for the Figure rules, as `book` reads it, and which events have a figure's source (FR-011); required | - **Exit 0:** every check passed. +- **Exit 2:** bad arguments (one folder, `--files`, and a graph are needed); a folder that is + not one; no EHLD summary in `--files`; the graph cannot be reached at the start; or any + other error (a PDF that cannot be read, a graph that fails mid-run), on stderr. - **Exit 1:** at least one fault, one line each: - `fault missing-pathway `: a top-level pathway with no PDF; - `fault extra-file ""`: a PDF in the folder that is no top-level pathway's; @@ -168,8 +178,38 @@ FR-011's check, alone. Also run by `book`. `no-stable-id`, `missing-figure` and `broken-link`. A file is matched to its top-level pathway by name: the names expected are made from - the graph as `book` makes them. The PDFs are untagged, so sections are found by the - outline and the text at each entry's destination (research R8). + the graph as `book` makes them, and any other file or folder in the folder is an + `extra-file`. The PDFs are untagged, so they are read with pdf.js for their outline, links + and text (research R8), each event's printed texts after FR-017's substitutions: + - names are compared as read back: compatibility forms folded (NFKC), white space and the + characters never drawn (FR-017's controls and default-ignorables: a soft hyphen, a + joiner, a variation selector) left out; + - a **section** is found at its heading's link to the event's page on the website (the + first link there; a reaction's figure links there too, below it), where an outline or + contents entry with its name lands too; or else where such an entry lands whose section + gives the event's stable identifier first (two events of one name), or the first such + entry; or else at that link. It runs to the next section found; + - the **contents list's entries** are the links before the first section (where the first + outline entry with an event's name lands), each one annotation, its rectangle around + every line of an entry that wraps; + - `not-in-contents`: no contents entry lands on the section and reads its name; + `not-in-outline`: no outline entry, at any depth, does both; an event whose section is + not found at all is these two faults and `no-name`; + - `no-name`: the section's heading does not read its name. The heading is its leading + text set larger than its page's body text (the size most of the page's characters are + set at) by at least 15:14, the ratio of 11.25 pt to the body's 10.5 (the smallest + heading is 12 pt): measured against the page, not in points, so a page Chromium printed + scaled down (which the layout should never make, as nothing in it is wider than the text + column) still reads its names, its own scale not a fault; `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 + known by its link: a drawing lost with its link kept is not seen; + - `broken-link`: a link that lands on no page of its PDF, is still a placeholder or probe + address (T026), or whose address to an event's page cannot be decoded, named by the + section it is in, or by the PDF's pathway for one in its front parts, one line a + section. ## `pdf-export compare ` @@ -201,4 +241,6 @@ The single-event service (contracts/service.md). SIGHUP once it serves closes it and exits 0; one before it serves, or another while it closes, exits at once with 128 and the signal's number. Like `book`, it makes a private temporary folder at its start for the printer and the browsers it starts, removed as - the process exits, so it leaves nothing in TMPDIR however it is stopped. + the process exits, so it leaves nothing in TMPDIR when it is stopped by SIGINT, SIGTERM + or SIGHUP. A process killed outright (SIGKILL, the memory cap's kill) or by a signal it + does not handle leaves the folder, as no exit handler runs. diff --git a/specs/001-pathway-pdfs/data-model.md b/specs/001-pathway-pdfs/data-model.md index 1369a38..bfa37a1 100644 --- a/specs/001-pathway-pdfs/data-model.md +++ b/specs/001-pathway-pdfs/data-model.md @@ -176,6 +176,9 @@ cause (`unanswered`, `failed`, `refused`, `invalid` or `label`, contracts/figure time where they overlap (T029). - The join's settings: `--joins`, `--parallel` and whether object streams are on (research R4). -- Each FR-011 check, and its result (none until `check-book`, T030). +- Each FR-011 check (`check-book`, T030), with its count of faults, none a pass; none + when the run stopped before the check. Its faults are among the book's faults. +- The signal that stopped the run, if one did; once the book is in place, the outcome is + still the book written. - The book's faults, each as `preflight` prints it, and the error that stopped it, if any. - No names (FR-013). diff --git a/specs/001-pathway-pdfs/research.md b/specs/001-pathway-pdfs/research.md index cac9644..97493ca 100644 --- a/specs/001-pathway-pdfs/research.md +++ b/specs/001-pathway-pdfs/research.md @@ -939,13 +939,24 @@ embedded. **Decision.** - **FR-011's check** (`check-book`) reads the book with pdfjs. The book is untagged - (R4), so it finds the sections by the outline pdf-export wrote and the text at each - outline entry's destination, not by a structure tree. It checks every top-level + (R4), so it finds each section, not by a structure tree, but at its heading's link to + the event's page on the website where an outline or contents entry with its name lands + too (or else where such an entry lands, T030, contracts/cli.md), and reads the text + there: its heading, the leading text set larger than its page's body text, and its + stable identifier. It checks every top-level pathway present, every event in the contents and the bookmarks, every section named and every section with its stable identifier (two faults), every figure present where its source exists, and every link landing on a page. Each fault is seen to fail once (SC-007). The release's files are checked before printing, by `book`'s preflight (contracts/cli.md). + - **Measured 4 Nov on a private copy of release 98's graph** (T030, from #160's review): + 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 never overflows (pages.css), and the + check reads headings against their page's body text. No human summation holds a + `
` block, and no human event's name holds a soft hyphen, a zero-width or
+    variation-selector character or a control: the check's leaving those out of the names
+    it compares is defensive.
 - **SC-008's figure comparison** draws both by the same means, as SC-008 asks: the
   render service's SVG is printed alone by the same Chromium to a one-page PDF at the
   figure's size on the page, on a page with the same `@font-face` rules as the book's
diff --git a/specs/001-pathway-pdfs/tasks.md b/specs/001-pathway-pdfs/tasks.md
index 461b9aa..ca3ec39 100644
--- a/specs/001-pathway-pdfs/tasks.md
+++ b/specs/001-pathway-pdfs/tasks.md
@@ -507,7 +507,7 @@ passes.
       - **Recorded, not changed:** the `--report`-inside-`--out` check is lexical (resolved paths, no symbolic links followed); the tests' read of a process's children (`/proc//task//children`) needs a kernel with CONFIG_PROC_CHILDREN, without which those moments are never reached and their tests fail by their deadline; a test file killed mid-run (by the memory cap, say) may still leave its own folders, as its `after` never runs.
     - **Known gaps:** a file's label check names the first event with that figure; a refused PDF's error does not say which PDF unless its message does (stamping's names the name); a run killed outright (SIGKILL) leaves `.-XXXXXX` beside `--out`, which a later run ignores and does not remove.
 
-- [ ] T030 [US1] `check-book`, `src/check/book.ts` (FR-011, contracts/cli.md).
+- [x] T030 [US1] `check-book`, `src/check/book.ts` (FR-011, contracts/cli.md).
   - **Flags:** `--graph`, `--species` and `--files`.
   - **Output:** one line per fault, in contracts/cli.md's three forms: `fault missing-pathway `, `fault extra-file ""`, and `fault  "" ` for the rest.
   - **Method:** it reads the graph, and the book's outline and text, since the book is untagged.
@@ -517,6 +517,22 @@ passes.
 
   `book` runs it on the folder, and exits 0 only when it passes. Tests: a fixture book with each fault planted one at a time fails with that fault's line (SC-007).
 
+  - **Done (#161, replacing #160 after its review):** `checkBook(folder, docs, files)` (`src/check/book.ts`) checks a book's folder against the documents it was made from: `book` gives the preflight's, `pdf-export check-book ` (`checkBookCommand`, wired into `src/cli.ts`) builds them from the graph as `book` does, with `--graph`, `--database` (added beside the contract's two, as `preflight` has it), `--species` and `--files` (required: its EHLD summary decides the Figure rules, its files which figures' sources exist). Exit 0 with no fault, 1 with any (`fault `, one line each, in the contract's three forms), 2 on bad arguments, a folder that is not one, no EHLD summary, a graph that cannot be reached at the start (its own driver retrying briefly, as `preflight`'s) or any other error. **What it reads:** each PDF with pdf.js, page by page: its text in the text area (the stamps in the margins left out), its links (an address, or where an internal one lands: a page of the PDF and a height, or nowhere) and its outline at every depth; each event's expected texts are as printed, after FR-017's substitutions (`printedDocument`). **The faults:** `missing-pathway ` for a pathway whose file (named from the graph as `book` names it) is absent; `extra-file ""` for any other file or folder in the folder; then, PDF by PDF in the book's order and event by event in document order, `not-in-contents`, `not-in-outline`, `no-name`, `no-stable-id` and `missing-figure`, then `broken-link`. **How a section is found** (the book is untagged, research R4, R8): at its heading's link to the event's page on the website, the first link there in reading order, where an outline or contents entry with its name lands too; else where such an entry lands whose section gives its stable identifier first, or the first (a heading that lost its link, whose first link is then a reaction's figure's; since #160's review); else at that link; it runs to the next section found. An entry is the event's when it lands within 3 pt of the section's start (a destination is the top of the heading's line, its link the top of its text, 0.5 to 1.5 pt lower on the fixture) and reads its name: the contents list's entries are the links before the first section (since #160's review, where the first outline entry with an event's name lands), each one annotation, its rectangle around every line of an entry that wraps. The heading is the section's leading text set larger than its page's body text by 15:14 (since #160's review; 11.25 pt to the body's 10.5, the smallest heading 12); the stable identifier a piece after `Stable identifier`, or in one piece with it; the figure a link to the figure's interactive form (`Links.figure`, matched by its path, as `check-book` has no `--site`) below the heading's last line, so a reaction's heading link to the same page is not taken for it, checked only where the source exists (`neededFiles` all in `--files`; a reaction's is the graph). A link is broken when it lands on no page of its PDF or is still a placeholder or probe address (T026), one line a section, named by the section it is in, or the PDF's pathway in the front parts. An event whose section is found nowhere is `not-in-contents`, `not-in-outline` and `no-name`. **In `book`:** the printer is closed once the PDFs are written (its browser not held through the check), the check runs on the folder beside `--out`, its faults are exit 1 (printed, in the report's faults, the folder removed, no book), and only then is the folder renamed into place. The run report's `checks` is every check with its count of faults (`null` if the run stopped before it), its `check-book` stage timed, and the PDFs, figures, characters and stages are noted before the check, so a report written at any later moment holds them.
+    - **Tests:** `test/check/book.test.ts` (10): the fixture's book printed once by `book` (exit 0, its TMPDIR left empty), and each fault planted, one at a time, in a copy of its folder or of `beta pathway.pdf` with pdf-lib, never in the shared fixture; each fails with exactly its line. The book as written passes, and passes saved again unchanged; Gamma's PDF removed (`missing-pathway R-HSA-90000300`); a PDF copied as `Delta pathway.pdf` (`extra-file`); Reaction eight's (R-HSA-90000132, at one place in beta) contents link removed, and its contents text erased with its link kept (`not-in-contents`); its outline entry taken out, and retitled `Reaction nine` (`not-in-outline`); its heading's text erased (`no-name`); its stable identifier's text erased (`no-stable-id`); its figure's link removed, and beta sub with diagram's (R-HSA-90000130) diagram's link, but not with that diagram's file taken from a copy of `--files` (`missing-figure`); its first internal link landing on the catalog, not a page, and turned back into a placeholder (`broken-link`). Text is erased by taking the `BT … ET` runs that read it, through their fonts' ToUnicode maps, out of the page's content (Chromium writes one a run, its glyphs as two-byte codes). The command: exit 0 and nothing printed on the book; exit 1 with the two lines of two plants; exit 2 on no folder, two, no `--files`, an unknown flag, a flag with no value, no graph, a file for the folder, no EHLD summary and an address that cannot be read. `test/book/book.test.ts`: the exit-0 test now holds every check passed and timed, and 3 tests are added: a printer that drops Reaction eight's name from its heading (as a layout might) is exit 1 with `fault no-name "beta pathway.pdf" R-HSA-90000132` alone, nothing left, the report's `checks` saying so; and two from #159's review, below. 418 tests before, 433 after (with `test/print/print.test.ts`'s and `test/print/temporary.test.ts`'s, below). Each of these mutants made one fail: each check's faults left out, one at a time (its plant's test; `missing-pathway` and `not-in-outline` also the command's); the contents entry's text not read, and the outline entry's title not read (the erased-text and retitled plants); the figure's link taken above the heading too (the reaction's plant, its heading's link counted); every figure's source taken to exist (the no-source case); placeholders not broken, and links landing nowhere not broken (each its plant); a broken link named by the PDF's pathway (the broken-link test); the heading taken as all the section's text (every test: the book fails its own check); the stable identifier's label taken without its value (the no-stable-id test); a section always at its first link to its page (the book's dropped-name test: its first link is then its figure's, and it is five faults where it is one); in `book`, the check not run (the exit-0 and dropped-name tests), and its faults ignored (the dropped-name test).
+    - **From #159's review:** five lows.
+      - **Printer leaks:** the book's tests read TMPDIR after the run's private folder, which holds the printer's, is removed, so a printer leaving its own `pdf-export-*` passed them. `test/print/print.test.ts` adds a test, with or without the graph: a printer started with TMPDIR a folder of its own makes its folder there, prints, closes, and leaves the folder empty; with `close` not removing its folder, it fails.
+      - **One live private folder a process:** `privateTemporary` throws while one is live (TMPDIR is the process's; a second left it naming a removed folder, and the two exit handlers could move each other last for ever), and `remove` restores TMPDIR and lets its handlers go before removing the folder, so a removal that fails leaves the process as it was. `test/print/temporary.test.ts`: a second refused while the first is live, changing nothing, and allowed once it is removed; without the check, the two exit handlers moved each other last for 32 s, until a `RangeError: Map maximum size exceeded` failed the file.
+      - **Cleanup on an exception:** everything in `bookCommand` after the folder is made is in a `try`, whose `finally` lets the signal handlers go and removes the folder, saying on stderr if that fails. `end` no longer removes it, so the report is written first. A test opens the graph with a `close` that rejects and an `err` that throws on being told so, which escapes between the start and `end`: the run rejects, and TMPDIR is restored and left empty, and no SIGTERM handler is left. With the removal back in `end` alone, as before, it fails.
+      - **The serve contract:** it now says `serve` leaves nothing in TMPDIR when stopped by SIGINT, SIGTERM or SIGHUP, and that SIGKILL, the memory cap's kill or an unhandled signal leave the folder; `book`'s bullet likewise.
+      - **A signal after the final rename (T029's L3, changed):** decided, once the book is checked and in place a signal stops nothing that is not done: the folder variable is cleared after the rename and the run is marked placed, and a signal then writes the report with the outcome `written`, the signal in a new `signal` field (`error` null), and exits 0 (2 if the report cannot be written); before, it exits 128 and the signal's number with `outcome: 'error'` as it did, `signal` set too. contracts/cli.md and data-model.md say so. The test runs `book` in-process with an `exit` it can catch (`BookIo.exit`, `process.exit` by default) and sends SIGTERM as the run says the book is written (after the rename): exit 0, the book in place, the report written with the PDFs and every check passed, and the handlers gone. Each of these mutants made it fail: the signal handled as before the rename, and the run never marked placed.
+    - **From #160's review:** a high, a medium and lows, each reproduced first.
+      - **A part printed scaled down (high).** Anything wider than the text column (a summation's table of long words, a 140-character token in a summation or a name) made Chromium print its whole part scaled by about 2/3, body text 10.5 to 7 pt and headings 12 to 8, all below the check's absolute 11.25 pt: every section there read as `no-name`, and `book` refused a good book. Both sides fixed. The layout: `body` has `overflow-wrap: anywhere` (unlike `break-word`, it lets tables, the facts' grid and the contents' flex items size below a long word), and the summations', edits' and found tables `max-width: 100%`; summations hold no `
` or image (the sanitiser's tags; and release 98 has no `
` in a human summation, below). The check: decided, a heading is judged against its page's body text (the size most of the page's characters are set at), 15:14 larger, not an absolute size, so a part printed scaled down is not a fault of its own and reads its names; contracts/cli.md says so. Tests (`test/book/book.test.ts`): a book whose Reaction one summation holds a table of two rows of six long words and a 140-character token, and whose Reaction eight's name holds that token, printed with the threshold at 1 page, is exit 0, every `Stable identifier` at 10.5 pt and the headings at 12, 16.5 and 18; before the fix it was exit 1, `no-name` for every section in the parts that overflowed and a `not-in-contents` (the contents entry overflowed too). And a printer that plants a block 800 px wide in every part: exit 0, every `Stable identifier` below 10 pt (the parts scaled down). The single PDF's tests pass unchanged.
+      - **Characters never drawn (medium).** `folded()` leaves out controls and default-ignorables (FR-017's `NEVER_DRAWN`), as the printed text holds none. A test plants U+00AD, U+200D, U+FE0F and U+0007 in Reaction eight's name: exit 0; before, a false `no-name` and `not-in-contents`.
+      - **Lows.** Two sections of one name and an earlier link to the event's page: the section is placed where an entry with its name lands whose section gives its stable identifier first, then the first; and the contents list ends where the first outline entry with an event's name lands, not at the first link to an event's page (a link on the cover ended it before it began: every event `not-in-contents`). Tested: Reaction eight printed as `Shared reaction`, the name of another section of beta's, with the cover's first link pointing at its page: no fault. The outline's and the contents' landings are indexed by folded name once, not folded again for each event. An address to an event's page that cannot be decoded is a `broken-link`, not a stop (tested). The contents' line grouping is gone: Chromium writes one annotation for a link that wraps (the overflow test's contents entry for Reaction eight wraps, and reads whole). `between`'s 1 pt above a start is gone, as no baseline is above it. Pinned: the landing tolerance (an outline entry moved 10 pt below its heading is `not-in-outline`), placement by the outline alone (Reaction eight's heading link and contents link removed: `not-in-contents` alone, placed by its outline entry). `remove()` that fails to remove the folder sets its exit handler again, quiet, to try once more on exit (tested with a folder that cannot be emptied). contracts/cli.md (the faults of a section found nowhere are two and `no-name`) and research.md R8 (how sections are found) corrected.
+      - **Release 98, measured 4 Nov on the private copy:** 28 human events have a `
` in their summation, so the high would have reached the real book; no human summation holds `
`; no human event's name holds a soft hyphen, a zero-width or variation-selector character or a control, so the medium's fix is defensive (research R8).
+      - **Tests:** 433 before, 439 after: in `test/book/book.test.ts` the overflow, scaled-down and never-drawn tests; in `test/check/book.test.ts` the outline-placement and duplicate-name tests, and the moved outline entry and undecodable address in existing ones; in `test/print/temporary.test.ts` the failed removal. Each of these mutants made one fail: no `overflow-wrap` (the overflow test); the heading at an absolute 11.25 pt (the scaled-down test); never-drawn characters kept in `folded()` (the never-drawn test); the first named spot taken (the duplicate-name test); the contents list ended at the first link to an event's page (the same); an undecodable address not caught, and not broken (the broken-link test, each); no placement by the outline (the outline-placement test); the landing tolerance 0, and 30 (the not-in-outline test, each); `remove()` keeping no handler (its test); a section always at its first link (the dropped-name and outline-placement tests); the contents text and the outline title not read (their plants). Survived, and so taken out: the facts' grid at `minmax(0, 1fr)` and the contents' links at `min-width: 0` (`anywhere` already lets both shrink). Survived and kept: the tables' `max-width: 100%` (the same, but said where a table is set), and the margin filter on the stamps (no section's reading meets them today: a stamp is above a section's start or past its stable identifier).
+    - **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.
   - Run `figures` first and time it for SC-010; then time each of `book`'s stages against research R7's budget, check memory, and run `check-book`. Record the results in research R7.
   - T029's memory measurement, left here: the join's memory on the real book, object streams on and off, beside printing; raise `--joins` to 2 only if two real joins fit, choose object streams from it (off until then), and do T012 first if one real join does not fit.
diff --git a/src/book/command.ts b/src/book/command.ts
index b090dcb..8bd5a3f 100644
--- a/src/book/command.ts
+++ b/src/book/command.ts
@@ -7,9 +7,11 @@
 // 2 on bad arguments, a `--report` that exists or cannot be written, an `--out` that is not
 // absent or empty, in a folder the run cannot write, or a mount point, a graph that cannot
 // be read at the start, or any other error, after printing the faults found before it; a
-// signal is 128 and its number. The run report (data-model.md) is written to `--report`
-// however the run ends once its arguments and report are accepted, a signal's included.
-// FR-011's check (`check-book`, T030) is not yet run: exit 0 is provisional.
+// signal is 128 and its number, but once the book is in place, when it is 0. FR-011's check
+// (`check-book`, T030) runs on the book's folder before it is moved into place: its faults
+// are exit 1, and no book is written. The run report (data-model.md) is written to
+// `--report` however the run ends once its arguments and report are accepted, a signal's
+// included.
 import {
   accessSync,
   constants as fsConstants,
@@ -28,6 +30,7 @@ import { basename, dirname, join, resolve, sep } from 'node:path';
 
 import { graphSettings, openGraph, type Graph, type GraphSettings } from '../graph/connection.ts';
 import { releaseInfo, type ReleaseInfo } from '../graph/queries.ts';
+import { checkBook, type CheckResult } from '../check/book.ts';
 import { BookCharacters } from '../layout/characters.ts';
 import { codePointName } from '../layout/fonts.ts';
 import type { Figure } from '../model/types.ts';
@@ -211,6 +214,11 @@ export interface RunReport {
   faults: string[];
   /** The error that stopped the run, if one did. */
   error: string | null;
+  /**
+   * The signal that stopped the run, if one did: with the outcome `written` when it came once
+   * the book was in place, and `error` before.
+   */
+  signal: NodeJS.Signals | null;
   seconds: number;
   stages: StageTime[];
   pdfs: PdfWritten[];
@@ -234,8 +242,8 @@ export interface RunReport {
     /** Characters no font draws, each as its fault: code point and stable identifier. */
     uncovered: string[];
   };
-  /** FR-011's checks, each and its result: `check-book` (T030), not yet run. */
-  checks: null;
+  /** FR-011's checks, each with its count of faults (none a pass); null if not run. */
+  checks: CheckResult[] | null;
 }
 
 /** A printer that can be closed: the Printer (src/print/print.ts). */
@@ -252,6 +260,8 @@ export interface BookIo {
   threshold?: number;
   /** The covers' date. */
   date?: Date;
+  /** How a signal ends the process (a test's stands in for `process.exit`). */
+  exit?: (code: number) => never;
 }
 
 /** `pdf-export book`: its exit code; faults to `out`, errors to `err`. */
@@ -285,9 +295,10 @@ export async function bookCommand(
       return false;
     }
   };
+  // the report is written before the private folder is removed (in the `finally` below), so
+  // a removal that fails cannot keep it from being written
   const end = (code: number, error: unknown = null): number => {
     for (const signal of SIGNALS) process.off(signal, onSignal);
-    temporary.remove();
     for (const fault of report.faults) out(`fault ${fault}\n`);
     if (error !== null) {
       report.error = message(error);
@@ -302,8 +313,13 @@ export async function bookCommand(
   // the exit handlers run: the printer's, which removes its pages' folder
   // (src/print/print.ts), and the browser launcher's, which ends the browser and removes its
   // profile. A signal while the browser is launching is held until the launch ends, as the
-  // launcher registers its folders for removal only once the browser has started.
+  // launcher registers its folders for removal only once the browser has started. Once the
+  // book is in place (checked, and renamed), a signal stops nothing: the report says the
+  // book was written, naming the signal, and the exit is 0.
+  const exit = io.exit ?? ((code: number) => process.exit(code));
+  /** The book's folder while it is written, null before and once it is in place. */
   let folder: string | null = null;
+  let placed = false;
   // set by the handler, read after the launch: an object, as a closure sets it
   const signals: { held: boolean; signalled: NodeJS.Signals | null } = {
     held: false,
@@ -312,12 +328,18 @@ export async function bookCommand(
   const stop = (signal: NodeJS.Signals): void => {
     // the run's private temporary folder is removed by its exit handler, after the browser
     // launcher's has ended the browser (src/print/temporary.ts)
+    report.signal = signal;
+    if (placed) {
+      report.outcome = 'written';
+      err(`The book was written; then stopped by ${signal}\n`);
+      exit(writeReport() ? 0 : 2);
+    }
     if (folder !== null) rmSync(folder, { recursive: true, force: true });
     report.outcome = 'error';
     report.error = `stopped by ${signal}`;
     err(`The book was not written: ${report.error}\n`);
     writeReport();
-    process.exit(128 + osConstants.signals[signal]);
+    exit(128 + osConstants.signals[signal]);
   };
   const onSignal = (signal: NodeJS.Signals): void => {
     // a second signal stops the run at once, held or not: a launch that hangs would
@@ -328,117 +350,173 @@ export async function bookCommand(
     }
     signals.signalled = signal;
   };
-  for (const signal of SIGNALS) process.on(signal, onSignal);
+  // everything from here in a `try`: an exception escaping the run still lets the handlers
+  // go and removes the private folder, after the report is written
+  try {
+    for (const signal of SIGNALS) process.on(signal, onSignal);
 
-  // an earlier run's report is never replaced, and no report is written for the refusal;
-  // checked, and its folder made, once the handlers are set, so a signal from the moment
-  // its folder exists finds them
-  const unreportable =
-    settings.report === null ? null : reportProblem(settings.report, settings.out);
-  if (unreportable) {
-    for (const signal of SIGNALS) process.off(signal, onSignal);
-    temporary.remove();
-    err(`${unreportable}\n`);
-    return 2;
-  }
+    // an earlier run's report is never replaced, and no report is written for the refusal;
+    // checked, and its folder made, once the handlers are set, so a signal from the moment
+    // its folder exists finds them
+    const unreportable =
+      settings.report === null ? null : reportProblem(settings.report, settings.out);
+    if (unreportable) {
+      err(`${unreportable}\n`);
+      return 2;
+    }
 
-  const problem = outProblem(settings.out);
-  if (problem) return end(2, problem);
+    const problem = outProblem(settings.out);
+    if (problem) return end(2, problem);
 
-  // the start's check, its own driver retrying briefly, as `preflight`'s (T024)
-  let start: Graph | undefined;
-  let release: ReleaseInfo;
-  try {
-    start = open(settings.graph, { connectionTimeoutMs: 5000, retryTimeMs: 0 });
-    release = await releaseInfo(start);
-  } catch (error) {
+    // the start's check, its own driver retrying briefly, as `preflight`'s (T024)
+    let start: Graph | undefined;
+    let release: ReleaseInfo;
+    try {
+      start = open(settings.graph, { connectionTimeoutMs: 5000, retryTimeMs: 0 });
+      release = await releaseInfo(start);
+    } catch (error) {
+      await closing(start, err);
+      return end(2, `the graph cannot be read: ${message(error)}`);
+    }
     await closing(start, err);
-    return end(2, `the graph cannot be read: ${message(error)}`);
-  }
-  await closing(start, err);
-  report.release = release.release;
-  report.checksum = release.checksum;
-
-  // the preflight, with the driver's own retries; its faults kept check by check
-  const preflightStarted = performance.now();
-  const faults: string[] = [];
-  let docs;
-  let stopped: { error: unknown } | null = null;
-  let graph: Graph | undefined;
-  try {
-    graph = open(settings.graph);
-    docs = (await preflight(graph, release, settings, faults)).docs;
-  } catch (error) {
-    stopped = { error };
-  } finally {
-    await closing(graph, err);
-  }
-  report.stages.push({
-    stage: 'preflight',
-    seconds: seconds(performance.now() - preflightStarted),
-    budget: 4 * 60,
-    measured: 'wall',
-  });
-  noteFaults(report, faults);
-  if (stopped) return end(2, `the preflight stopped: ${message(stopped.error)}`);
-  if (faults.length > 0 || !docs) return end(1);
-  for (const record of expectedFigures(docs).values()) report.figures.byKind[record.kind] += 1;
+    report.release = release.release;
+    report.checksum = release.checksum;
 
-  // the book, printed into a folder beside its name and moved into place at the end
-  const target = resolve(settings.out);
-  let printer: BookPrinter | undefined;
-  let written: Written | undefined;
-  let failure: unknown = null;
-  const characters = new BookCharacters();
-  try {
-    folder = mkdtempSync(join(dirname(target), `.${basename(target)}-`));
-    signals.held = true;
+    // the preflight, with the driver's own retries; its faults kept check by check
+    const preflightStarted = performance.now();
+    const faults: string[] = [];
+    let docs;
+    let stopped: { error: unknown } | null = null;
+    let graph: Graph | undefined;
     try {
-      printer = await (io.startPrinter ?? startPrinter)();
+      graph = open(settings.graph);
+      docs = (await preflight(graph, release, settings, faults)).docs;
+    } catch (error) {
+      stopped = { error };
     } finally {
-      // a signal held through the launch acts now, whether it began the browser or not
-      signals.held = false;
-      if (signals.signalled !== null) stop(signals.signalled);
+      await closing(graph, err);
     }
-    written = await writeBook(docs, characters, {
-      figures: settings.figures,
-      site: settings.site,
-      folder,
-      parts: {
-        threshold: io.threshold ?? PARTS.threshold,
-        parallel: settings.parallel,
-        joins: settings.joins,
-      },
-      printer,
-      date: io.date ?? new Date(),
+    report.stages.push({
+      stage: 'preflight',
+      seconds: seconds(performance.now() - preflightStarted),
+      budget: 4 * 60,
+      measured: 'wall',
     });
-    renameSync(folder, target);
-  } catch (error) {
-    failure = error;
+    noteFaults(report, faults);
+    if (stopped) return end(2, `the preflight stopped: ${message(stopped.error)}`);
+    if (faults.length > 0 || !docs) return end(1);
+    for (const record of expectedFigures(docs).values()) report.figures.byKind[record.kind] += 1;
+
+    // the book, printed into a folder beside its name, checked (FR-011), and moved into place
+    const target = resolve(settings.out);
+    let printer: BookPrinter | undefined;
+    let written: Written | undefined;
+    let failure: unknown = null;
+    const characters = new BookCharacters();
+    // closed once the book is printed, so its browser is not held through the check
+    const closePrinter = async () => {
+      const open = printer;
+      printer = undefined;
+      try {
+        await open?.close();
+      } catch (error) {
+        err(`The printer did not close: ${message(error)}\n`);
+      }
+    };
+    try {
+      folder = mkdtempSync(join(dirname(target), `.${basename(target)}-`));
+      signals.held = true;
+      try {
+        printer = await (io.startPrinter ?? startPrinter)();
+      } finally {
+        // a signal held through the launch acts now, whether it began the browser or not
+        signals.held = false;
+        if (signals.signalled !== null) stop(signals.signalled);
+      }
+      written = await writeBook(docs, characters, {
+        figures: settings.figures,
+        site: settings.site,
+        folder,
+        parts: {
+          threshold: io.threshold ?? PARTS.threshold,
+          parallel: settings.parallel,
+          joins: settings.joins,
+        },
+        printer,
+        date: io.date ?? new Date(),
+      });
+      await closePrinter();
+      noteWritten(report, written);
+      // FR-011's check, on the folder before it is moved into place: a fault is exit 1, and
+      // the folder is removed with it
+      const checkStarted = performance.now();
+      const checked = await checkBook(folder, docs, settings.files);
+      report.checks = checked.checks;
+      report.stages.push({
+        stage: 'check-book',
+        seconds: seconds(performance.now() - checkStarted),
+        budget: 9 * 60,
+        measured: 'wall',
+      });
+      if (checked.faults.length > 0) throw new BookFaults(checked.faults);
+      renameSync(folder, target);
+      // in place: a signal from now on reports the book written (`stop`, above)
+      folder = null;
+      placed = true;
+    } catch (error) {
+      failure = error;
+    } finally {
+      await closePrinter();
+      if (failure !== null && folder !== null) rmSync(folder, { recursive: true, force: true });
+    }
+    report.characters.replaced = counts(written?.replaced ?? characters.replaced);
+    if (failure !== null) {
+      // faults found while printing or checking are exit 1, any other error exit 2, the
+      // faults printed first
+      const failures =
+        failure instanceof AggregateError ? (failure.errors as unknown[]) : [failure];
+      const others = failures.filter((f) => !(f instanceof BookFaults));
+      for (const f of failures) if (f instanceof BookFaults) noteFaults(report, [...f.faults]);
+      if (others.length > 0) return end(2, others.map(message).join('; '));
+      return end(1);
+    }
+    err(
+      `The book: ${String(report.total.pdfs)} PDFs, ${String(report.total.pages)} pages, in ${settings.out}\n`
+    );
+    return end(0);
   } finally {
+    for (const signal of SIGNALS) process.off(signal, onSignal);
     try {
-      await printer?.close();
+      temporary.remove();
     } catch (error) {
-      err(`The printer did not close: ${message(error)}\n`);
+      err(`The run's temporary folder was not removed: ${message(error)}\n`);
     }
-    if (failure !== null && folder !== null) rmSync(folder, { recursive: true, force: true });
-  }
-  report.characters.replaced = counts(written?.replaced ?? characters.replaced);
-  if (failure !== null) {
-    // faults found while printing are exit 1, any other error exit 2, the faults printed first
-    const failures = failure instanceof AggregateError ? (failure.errors as unknown[]) : [failure];
-    const others = failures.filter((f) => !(f instanceof BookFaults));
-    for (const f of failures) if (f instanceof BookFaults) noteFaults(report, [...f.faults]);
-    if (others.length > 0) return end(2, others.map(message).join('; '));
-    return end(1);
   }
-  if (!written) return end(2, 'nothing was written');
+}
+
+/** The signals a run is stopped by, its folder removed and its report written first. */
+const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const;
+
+const startPrinter = async (): Promise => {
+  // loaded only here: it starts a browser
+  const { Printer } = await import('../print/print.ts');
+  return Printer.start();
+};
+
+const seconds = (ms: number): number => Math.round(ms) / 1000;
+
+/**
+ * What the book's printing gives the report: each PDF (printed and checked; on a fault of the
+ * check, printed and then discarded), the figures, the characters replaced, and the stages.
+ */
+function noteWritten(report: RunReport, written: Written): void {
   report.pdfs = written.pdfs;
   report.total = {
     pdfs: written.pdfs.length,
     pages: written.pdfs.reduce((n, pdf) => n + pdf.pages, 0),
     bytes: written.pdfs.reduce((n, pdf) => n + pdf.bytes, 0),
   };
+  report.characters.replaced = counts(written.replaced);
   report.figures.belowFloor = written.layout.belowFloor;
   report.figures.unmeasured = written.layout.unmeasured;
   report.figures.missing = [...new Set([...report.figures.missing, ...written.layout.missing])];
@@ -448,26 +526,10 @@ export async function bookCommand(
     { stage: 'print', seconds: seconds(ms.print), budget: 13 * 60, measured: 'summed' },
     { stage: 'join', seconds: seconds(ms.join), budget: 6 * 60, measured: 'summed' },
     { stage: 'contents', seconds: seconds(ms.contents), budget: 2 * 60, measured: 'summed' },
-    { stage: 'parts', seconds: seconds(ms.parts), budget: null, measured: 'wall' },
-    { stage: 'check-book', seconds: null, budget: 9 * 60, measured: 'wall' }
-  );
-  err(
-    `The book: ${String(report.total.pdfs)} PDFs, ${String(report.total.pages)} pages, in ${settings.out}\n`
+    { stage: 'parts', seconds: seconds(ms.parts), budget: null, measured: 'wall' }
   );
-  return end(0);
 }
 
-/** The signals a run is stopped by, its folder removed and its report written first. */
-const SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP'] as const;
-
-const startPrinter = async (): Promise => {
-  // loaded only here: it starts a browser
-  const { Printer } = await import('../print/print.ts');
-  return Printer.start();
-};
-
-const seconds = (ms: number): number => Math.round(ms) / 1000;
-
 function emptyReport(settings: BookSettings): RunReport {
   return {
     release: null,
@@ -475,6 +537,7 @@ function emptyReport(settings: BookSettings): RunReport {
     outcome: 'error',
     faults: [],
     error: null,
+    signal: null,
     seconds: 0,
     stages: [],
     pdfs: [],
diff --git a/src/check/book.ts b/src/check/book.ts
new file mode 100644
index 0000000..c06527e
--- /dev/null
+++ b/src/check/book.ts
@@ -0,0 +1,615 @@
+// FR-011's check of a written book (spec 001 T030, contracts/cli.md `check-book`, research
+// R8): the folder holds one PDF per top-level pathway and nothing else, and in each PDF every
+// event of its pathway has its section, named and with its stable identifier, its entry in
+// the contents list and in the outline, and its figure where the figure's source exists; and
+// no internal link is broken. The book is untagged (research R4), so each PDF is read with
+// pdf.js for its outline, its links and its text, and compared with the documents the book
+// was made from: `book` gives its own, `check-book` builds them from the graph as `book`
+// does. Each fault is a line naming its PDF and a stable identifier, never a text or a person.
+//
+// A section is found by its heading's link to the event's page on the website, the first
+// link there in reading order, where an outline or contents entry with its name lands too;
+// failing that, where such an entry lands whose section gives its stable identifier first
+// (two events of one name), or the first such entry (a heading that lost its link, whose
+// first link is then a reaction's figure's); failing both, at that first link. It runs to
+// the next section found. Its name is the text of its heading, the leading text there set
+// larger than its page's body text (so a page printed scaled down still reads it); its
+// stable identifier the text after `Stable identifier` in it; its figure the link to the
+// figure's interactive form below its heading. An entry of the outline or the contents list
+// is the event's when it lands on its section's heading and reads its name, compared
+// without white space or the characters never drawn; the contents list's entries are the
+// links before the first section. A link is broken when it lands on no page of its PDF, is
+// still a placeholder or a probe (src/layout/html.ts), or its address to an event's page
+// cannot be decoded, named by the section it is in, or by the PDF's pathway in the front.
+import { readdirSync, readFileSync, statSync } from 'node:fs';
+import { join } from 'node:path';
+
+import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs';
+
+import { printedDocument } from '../book/book.ts';
+import {
+  graphSettings,
+  openGraph,
+  type Graph,
+  type GraphOptions,
+  type GraphSettings,
+} from '../graph/connection.ts';
+import { releaseInfo } from '../graph/queries.ts';
+import { BookCharacters } from '../layout/characters.ts';
+import { PAGE } from '../layout/figure.ts';
+import { Links, PLACEHOLDER, PROBE } from '../layout/html.ts';
+import { buildBook } from '../model/build.ts';
+import type { Document, Figure } from '../model/types.ts';
+import { closing, message, SettingsError } from '../preflight/command.ts';
+import { checkFiles, neededFiles, readSummary, SUMMARY } from '../preflight/files.ts';
+import { figureRecord } from '../preflight/folder.ts';
+import { fileName } from '../preflight/names.ts';
+import { bookPathways } from '../preflight/preflight.ts';
+
+/** FR-011's checks, in the order their faults are listed (contracts/cli.md). */
+export const CHECKS = [
+  'missing-pathway',
+  'extra-file',
+  'not-in-contents',
+  'not-in-outline',
+  'no-name',
+  'no-stable-id',
+  'missing-figure',
+  'broken-link',
+] as const;
+
+export type CheckKind = (typeof CHECKS)[number];
+
+/** A check and how many faults it found: none is a pass. */
+export interface CheckResult {
+  check: CheckKind;
+  faults: number;
+}
+
+export interface BookCheck {
+  /** Each fault, as `check-book` prints it after `fault `. */
+  faults: string[];
+  /** Every check, each with its count of faults, in CHECKS' order. */
+  checks: CheckResult[];
+}
+
+/**
+ * A heading is set at least this much larger than its page's body text: between the body's
+ * 10.5 pt and the smallest heading's 12 pt (src/layout/pages.css: body-medium, and
+ * title-medium for h3 to h6), as a ratio, so a page Chromium printed scaled down (anything
+ * wider than the text column, which the layout should never print) still reads its headings.
+ */
+const HEADING_RATIO = 11.25 / 10.5;
+
+/**
+ * How far a landing may be from a heading's link and still be on it: a destination is the
+ * top of the heading's line, its link's rectangle the top of its text, 0.5 to 1.5 pt lower.
+ */
+const ON_HEADING_PT = 3;
+
+/** A place in a PDF: its page, counted from 0, and a height on it in points from its foot. */
+interface Spot {
+  page: number;
+  y: number;
+}
+
+/** Whether `a` comes after `b` in reading order: a later page, or lower on the same one. */
+const later = (a: Spot, b: Spot): boolean => a.page > b.page || (a.page === b.page && a.y < b.y);
+
+const near = (a: Spot, b: Spot): boolean =>
+  a.page === b.page && Math.abs(a.y - b.y) <= ON_HEADING_PT;
+
+/** A piece of text in the page's text area (not the stamps in its margins). */
+interface Piece extends Spot {
+  x: number;
+  size: number;
+  str: string;
+}
+
+interface Link extends Spot {
+  /** Its rectangle: left, bottom, right, top; `y` is its top. */
+  rect: [number, number, number, number];
+  uri: string | null;
+  /** Where an internal link lands, or null for one that lands nowhere. */
+  landing: Spot | null;
+  internal: boolean;
+}
+
+interface OutlineItem {
+  title: string;
+  landing: Spot | null;
+}
+
+/** What is read from one PDF. */
+interface Read {
+  /** Every page's pieces of text, in reading order. */
+  pieces: Piece[];
+  /** Each page's body size: the size most of its text's characters are set at. */
+  body: number[];
+  /** Every link, in reading order. */
+  links: Link[];
+  /** Every outline entry, at any depth. */
+  outline: OutlineItem[];
+}
+
+/**
+ * Text compared as read back: compatibility forms folded, white space ignored, and the
+ * characters never drawn (FR-017's NEVER_DRAWN: controls and default-ignorables, a soft
+ * hyphen, a joiner, a variation selector) left out, as the printed text has none of them.
+ */
+const folded = (text: string): string =>
+  text.normalize('NFKC').replace(/[\s\p{Cc}\p{Default_Ignorable_Code_Point}]+/gu, '');
+
+/** The label a section's stable identifier follows, as read. */
+const LABEL = folded('Stable identifier');
+
+const byReading = (a: Spot & { x?: number }, b: Spot & { x?: number }): number =>
+  a.page - b.page || b.y - a.y || (a.x ?? 0) - (b.x ?? 0);
+
+type PdfJs = Awaited['promise']>;
+
+/** Where a destination lands, or null where it names no page of the PDF. */
+async function land(doc: PdfJs, dest: unknown): Promise {
+  const explicit: unknown = typeof dest === 'string' ? await doc.getDestination(dest) : dest;
+  if (!Array.isArray(explicit)) return null;
+  const [ref, kind, left, top] = explicit as unknown[];
+  let page: number;
+  try {
+    if (typeof ref === 'number') {
+      if (!Number.isInteger(ref) || ref < 0 || ref >= doc.numPages) return null;
+      page = ref;
+    } else if (ref !== null && typeof ref === 'object' && 'num' in ref && 'gen' in ref) {
+      page = await doc.getPageIndex(ref as { num: number; gen: number });
+    } else {
+      return null;
+    }
+  } catch {
+    return null;
+  }
+  // the height a destination names: `/XYZ left top zoom`, `/FitH top`, `/FitBH top`
+  const name = kind !== null && typeof kind === 'object' && 'name' in kind ? kind.name : null;
+  const height = name === 'XYZ' ? top : name === 'FitH' || name === 'FitBH' ? left : null;
+  if (typeof height === 'number') return { page, y: height };
+  const [, , , pageTop = 0] = (await doc.getPage(page + 1)).view;
+  return { page, y: pageTop };
+}
+
+const isRecord = (value: unknown): value is Record =>
+  value !== null && typeof value === 'object';
+
+/** A PDF's text, links and outline, read with pdf.js. */
+async function readPdf(path: string): Promise {
+  const task = getDocument({ data: new Uint8Array(readFileSync(path)) });
+  try {
+    const doc = await task.promise;
+    const pieces: Piece[] = [];
+    const links: Link[] = [];
+    const body: number[] = [];
+    for (let i = 0; i < doc.numPages; i++) {
+      const page = await doc.getPage(i + 1);
+      const [, , , height = 0] = page.view;
+      const bySize = new Map();
+      for (const item of (await page.getTextContent()).items) {
+        if (!('str' in item) || item.str.trim() === '') continue;
+        const [a = 0, b = 0, c = 0, d = 0, x = 0, y = 0] = item.transform as number[];
+        // the stamped headers and numbers are in the margins
+        if (y <= PAGE.margin || y >= height - PAGE.margin) continue;
+        const size = Math.max(Math.hypot(a, b), Math.hypot(c, d));
+        pieces.push({ page: i, x, y, size, str: item.str });
+        const rounded = Math.round(size * 100) / 100;
+        bySize.set(rounded, (bySize.get(rounded) ?? 0) + item.str.length);
+      }
+      body.push([...bySize].sort(([, m], [, n]) => n - m)[0]?.[0] ?? 0);
+      for (const annot of (await page.getAnnotations()) as unknown[]) {
+        if (!isRecord(annot) || annot['subtype'] !== 'Link') continue;
+        const [x1 = 0, y1 = 0, x2 = 0, y2 = 0] = (annot['rect'] as number[] | undefined) ?? [];
+        const rect: Link['rect'] = [
+          Math.min(x1, x2),
+          Math.min(y1, y2),
+          Math.max(x1, x2),
+          Math.max(y1, y2),
+        ];
+        const address = annot['unsafeUrl'] ?? annot['url'];
+        const uri = typeof address === 'string' ? address : null;
+        const dest = annot['dest'];
+        const internal = uri === null && dest !== undefined && dest !== null;
+        links.push({
+          page: i,
+          y: rect[3],
+          rect,
+          uri,
+          internal,
+          landing: internal ? await land(doc, dest) : null,
+        });
+      }
+      page.cleanup();
+    }
+    const outline: OutlineItem[] = [];
+    const walk = async (items: readonly { title: string; dest: unknown; items: unknown[] }[]) => {
+      for (const item of items) {
+        outline.push({ title: item.title, landing: await land(doc, item.dest) });
+        await walk(item.items as typeof items);
+      }
+    };
+    // null, not an empty list, for a PDF with no outline, whatever its type says
+    const top = (await doc.getOutline()) as Parameters[0] | null;
+    await walk(top ?? []);
+    return { pieces: pieces.sort(byReading), body, links: links.sort(byReading), outline };
+  } finally {
+    await task.destroy();
+  }
+}
+
+/** The text of a link: the pieces on its page whose start and baseline are in its box. */
+function linkText(link: Link, pieces: readonly Piece[]): string {
+  const [x1, y1, x2, y2] = link.rect;
+  return between(pieces, { page: link.page, y: y2 }, { page: link.page, y: y1 - 1 })
+    .filter((p) => p.x >= x1 - 1 && p.x <= x2 + 1)
+    .map((p) => p.str)
+    .join('');
+}
+
+/** An event with a full section in a PDF, as the book prints it. */
+interface Expected {
+  stId: string;
+  name: string;
+  figure: Figure | null;
+}
+
+/** The items of a list in reading order from `start` (at or below it) to `next` (above it). */
+function between(sorted: readonly T[], start: Spot, next: Spot | undefined): T[] {
+  const first = (before: (item: T) => boolean) => {
+    let lo = 0;
+    let hi = sorted.length;
+    while (lo < hi) {
+      const mid = (lo + hi) >>> 1;
+      const item = sorted[mid];
+      if (item && before(item)) lo = mid + 1;
+      else hi = mid;
+    }
+    return lo;
+  };
+  const from = first(
+    (item) => item.page < start.page || (item.page === start.page && item.y > start.y)
+  );
+  const to = next
+    ? first((item) => item.page < next.page || (item.page === next.page && item.y > next.y))
+    : sorted.length;
+  return sorted.slice(from, Math.max(from, to));
+}
+
+/** One PDF's faults, in its events' order, then its broken links. */
+function checkPdf(
+  file: string,
+  pathway: string,
+  events: readonly Expected[],
+  { pieces, body, links, outline }: Read,
+  sourced: (stId: string, figure: Figure) => boolean
+): string[] {
+  const site = new Links(true, '', new Set());
+  const fault = (kind: CheckKind, stId: string) => `${kind} ${JSON.stringify(file)} ${stId}`;
+
+  // each event's first link to its page on the website: its heading's, unless that is lost;
+  // a link whose address cannot be decoded is broken, never a stop
+  const pagePath = site.event('');
+  const firstLinks = new Map();
+  const undecodable = new Set();
+  for (const link of links) {
+    const at = link.uri?.lastIndexOf(pagePath) ?? -1;
+    if (link.uri === null || at < 0) continue;
+    let stId: string;
+    try {
+      stId = decodeURIComponent(link.uri.slice(at + pagePath.length));
+    } catch {
+      undecodable.add(link);
+      continue;
+    }
+    if (!firstLinks.has(stId)) firstLinks.set(stId, link);
+  }
+  // the contents list's entries: the links before the first section, which is where the
+  // first outline entry with an event's name lands, or else at the first event's first link
+  // to its page (a link to an event's page on the cover would otherwise end the list before
+  // it begins). Chromium writes one annotation for a link, its rectangle around every line of
+  // an entry that wraps, so an entry's text is all that rectangle holds
+  const names = new Set(events.map((event) => folded(event.name)));
+  const first =
+    outline
+      .flatMap((item) => (item.landing && names.has(folded(item.title)) ? [item.landing] : []))
+      .sort(byReading)[0] ??
+    events
+      .flatMap((event) => {
+        const link = firstLinks.get(event.stId);
+        return link ? [link] : [];
+      })
+      .sort(byReading)[0];
+  const entries: { landing: Spot; text: string }[] = [];
+  for (const link of links) {
+    if (first && !later(first, link)) break;
+    if (link.landing) entries.push({ landing: link.landing, text: folded(linkText(link, pieces)) });
+  }
+  // where the outline's entries and the contents' land, by the name each reads, once
+  const byName = new Map();
+  const named = (name: string) => {
+    let spots = byName.get(name);
+    if (!spots) {
+      spots = { outline: [], contents: [] };
+      byName.set(name, spots);
+    }
+    return spots;
+  };
+  for (const item of outline)
+    if (item.landing) named(folded(item.title)).outline.push(item.landing);
+  for (const entry of entries) named(entry.text).contents.push(entry.landing);
+  /** Whether the section starting at `spot` gives `stId` as its stable identifier: its first. */
+  const identifies = (spot: Spot, stId: string): boolean => {
+    const after = between(pieces, spot, undefined);
+    const at = after.findIndex((piece) => folded(piece.str).startsWith(LABEL));
+    const read = folded(after[at]?.str ?? '');
+    return read === LABEL + stId || (read === LABEL && folded(after[at + 1]?.str ?? '') === stId);
+  };
+  // each section: at its first link to its page where an outline or contents entry with its
+  // name lands there too; or else where such an entry lands whose section gives its stable
+  // identifier (two events of one name), or the first; or else at that link
+  const located = new Map();
+  for (const event of events) {
+    const spots = byName.get(folded(event.name));
+    const candidates = spots ? [...spots.outline, ...spots.contents] : [];
+    const link = firstLinks.get(event.stId);
+    const stId = folded(event.stId);
+    const found =
+      link && candidates.some((spot) => near(spot, link))
+        ? link
+        : (candidates.find((spot) => identifies(spot, stId)) ?? candidates[0] ?? link);
+    if (found) located.set(event.stId, found);
+  }
+  // each section runs from its start to the next section's
+  const starts = [...located.values()].sort(byReading);
+  const sections = new Map();
+  for (const [stId, start] of located) {
+    const next = starts.find((other) => later(other, start) && !near(other, start));
+    sections.set(stId, { start, next });
+  }
+
+  const faults: string[] = [];
+  for (const event of events) {
+    const section = sections.get(event.stId);
+    const name = folded(event.name);
+    if (!section) {
+      faults.push(fault('not-in-contents', event.stId), fault('not-in-outline', event.stId));
+      faults.push(fault('no-name', event.stId));
+      continue;
+    }
+    const { start, next } = section;
+    const spots = byName.get(name);
+    if (!spots?.contents.some((spot) => near(spot, start))) {
+      faults.push(fault('not-in-contents', event.stId));
+    }
+    if (!spots?.outline.some((spot) => near(spot, start))) {
+      faults.push(fault('not-in-outline', event.stId));
+    }
+    const text = between(pieces, start, next);
+    const heading: Piece[] = [];
+    for (const piece of text) {
+      if (piece.size < (body[piece.page] ?? 0) * HEADING_RATIO) break;
+      heading.push(piece);
+    }
+    if (folded(heading.map((p) => p.str).join('')) !== name) {
+      faults.push(fault('no-name', event.stId));
+    }
+    // its own piece after the label's, or in one piece with it
+    const stId = folded(event.stId);
+    const identified = text.some((piece, i) => {
+      const read = folded(piece.str);
+      return read === LABEL + stId || (read === LABEL && folded(text[i + 1]?.str ?? '') === stId);
+    });
+    if (!identified) {
+      faults.push(fault('no-stable-id', event.stId));
+    }
+    if (event.figure && sourced(event.stId, event.figure)) {
+      // below its heading, so not a reaction's heading's link to the same page
+      const below = heading.at(-1) ?? text[0] ?? start;
+      const address = site.figure(event.figure, event.stId);
+      const drawn = between(links, start, next).some(
+        (link) => link.uri?.endsWith(address) && later(link, below)
+      );
+      if (!drawn) faults.push(fault('missing-figure', event.stId));
+    }
+  }
+  // a link not rewritten, one landing on no page of the PDF, or one whose address cannot be
+  // decoded, named by the section it is in, or by the PDF's pathway in the front parts
+  const broken = new Set();
+  for (const link of links) {
+    const placeholder = link.uri?.startsWith(PLACEHOLDER) || link.uri?.startsWith(PROBE);
+    const nowhere = link.internal && link.landing === null;
+    if (!placeholder && !nowhere && !undecodable.has(link)) continue;
+    let holder = pathway;
+    for (const [stId, { start, next }] of sections) {
+      if (between([link], start, next).length > 0) holder = stId;
+    }
+    broken.add(fault('broken-link', holder));
+  }
+  faults.push(...broken);
+  return faults;
+}
+
+/**
+ * FR-011's check of a book's folder, against the documents it was made from (as the
+ * preflight builds them) and the release's folder, which says which figures' sources exist.
+ */
+export async function checkBook(
+  folder: string,
+  docs: readonly Document[],
+  files: string
+): Promise {
+  const characters = new BookCharacters();
+  const expected = docs.flatMap((doc) => {
+    const [pathway] = bookPathways([doc]);
+    if (!pathway) return [];
+    const printed = printedDocument(doc, characters);
+    const events = printed.places.flatMap((place) =>
+      place.full
+        ? [
+            {
+              stId: place.section.event.stId,
+              name: place.section.event.name,
+              figure: place.section.figure,
+            },
+          ]
+        : []
+    );
+    // named as `book` names it: the pathway's name as the graph has it
+    return [{ file: fileName(pathway.name), stId: pathway.stId, events }];
+  });
+  const present = new Set(readdirSync(folder));
+  const faults: string[] = [];
+  for (const pdf of expected)
+    if (!present.has(pdf.file)) faults.push(`missing-pathway ${pdf.stId}`);
+  const names = new Set(expected.map((pdf) => pdf.file));
+  const extra = [...present].filter((name) => !names.has(name)).sort();
+  for (const name of extra) faults.push(`extra-file ${JSON.stringify(name)}`);
+
+  const sources = new Map();
+  const sourced = (stId: string, figure: Figure): boolean => {
+    let known = sources.get(stId);
+    if (known === undefined) {
+      known = checkFiles(files, neededFiles([figureRecord(stId, figure)])).length === 0;
+      sources.set(stId, known);
+    }
+    return known;
+  };
+  for (const pdf of expected) {
+    if (!present.has(pdf.file)) continue;
+    const read = await readPdf(join(folder, pdf.file));
+    faults.push(...checkPdf(pdf.file, pdf.stId, pdf.events, read, sourced));
+  }
+  return {
+    faults,
+    checks: CHECKS.map((check) => ({
+      check,
+      faults: faults.filter((f) => f.startsWith(`${check} `)).length,
+    })),
+  };
+}
+
+export interface CheckBookSettings {
+  folder: string;
+  graph: GraphSettings;
+  species: string;
+  files: string;
+}
+
+const FLAGS = ['--graph', '--database', '--species', '--files'];
+
+/** The settings from `check-book`'s arguments and the environment (contracts/cli.md). */
+export function checkBookSettings(
+  args: readonly string[],
+  env: Readonly>
+): CheckBookSettings {
+  const flags = new Map();
+  const folders: string[] = [];
+  for (let i = 0; i < args.length; i++) {
+    const arg = args[i] ?? '';
+    if (!arg.startsWith('--')) {
+      folders.push(arg);
+      continue;
+    }
+    const [name = '', inline] = arg.split(/=(.*)/s, 2);
+    if (!FLAGS.includes(name)) throw new SettingsError(`Unknown argument: ${name}`);
+    const value = inline ?? args[++i];
+    if (value === undefined || value === '') throw new SettingsError(`${name} needs a value`);
+    if (flags.has(name)) throw new SettingsError(`${name} is given twice`);
+    flags.set(name, value);
+  }
+  const [folder] = folders;
+  if (folder === undefined || folder === '' || folders.length > 1) {
+    throw new SettingsError('One folder is needed: pdf-export check-book ');
+  }
+  const files = flags.get('--files');
+  if (files === undefined) throw new SettingsError('--files is needed');
+  const graphEnv = { ...env };
+  const uri = flags.get('--graph');
+  if (uri !== undefined) graphEnv['NEO4J_URI'] = uri;
+  const database = flags.get('--database');
+  if (database !== undefined) graphEnv['NEO4J_DATABASE'] = database;
+  let graph: GraphSettings;
+  try {
+    graph = graphSettings(graphEnv);
+  } catch {
+    throw new SettingsError('--graph (or NEO4J_URI) is needed');
+  }
+  return { folder, graph, species: flags.get('--species') ?? 'Homo sapiens', files };
+}
+
+/**
+ * `pdf-export check-book `: exit 0 when every check passes, 1 with a fault (one line
+ * each, `fault `), 2 on bad arguments, a folder that is not one, no EHLD summary in
+ * `--files` (without it the Figure rules, and so the book, cannot be known), a graph that
+ * cannot be reached at the start, or any other error.
+ */
+export async function checkBookCommand(
+  args: readonly string[],
+  env: Readonly>,
+  {
+    out,
+    err,
+    open = openGraph,
+  }: {
+    out: (text: string) => void;
+    err: (text: string) => void;
+    open?: (settings: GraphSettings, options?: GraphOptions) => Graph;
+  }
+): Promise {
+  let settings: CheckBookSettings;
+  try {
+    settings = checkBookSettings(args, env);
+  } catch (error) {
+    if (!(error instanceof SettingsError)) throw error;
+    err(`${error.message}\n`);
+    return 2;
+  }
+  let folder = false;
+  try {
+    folder = statSync(settings.folder).isDirectory();
+  } catch {
+    // not there: said below
+  }
+  if (!folder) {
+    err(`Not a folder: ${settings.folder}\n`);
+    return 2;
+  }
+  const summary = readSummary(settings.files);
+  if (!summary) {
+    err(`The release's folder has no ${SUMMARY}: the book's figures cannot be known\n`);
+    return 2;
+  }
+  // the start's read on a driver of its own, retrying briefly, as `preflight`'s (T024)
+  let start: Graph | undefined;
+  try {
+    start = open(settings.graph, { connectionTimeoutMs: 5000, retryTimeMs: 0 });
+    await releaseInfo(start);
+  } catch (error) {
+    err(`The graph cannot be read: ${message(error)}\n`);
+    return 2;
+  } finally {
+    await closing(start, err);
+  }
+  let graph: Graph | undefined;
+  let checked: BookCheck;
+  try {
+    graph = open(settings.graph);
+    const docs = await buildBook(graph, {
+      species: settings.species,
+      illustrated: summary.illustrated,
+    });
+    await closing(graph, err);
+    graph = undefined;
+    checked = await checkBook(settings.folder, docs, settings.files);
+  } catch (error) {
+    err(`The check stopped: ${message(error)}\n`);
+    return 2;
+  } finally {
+    await closing(graph, err);
+  }
+  for (const fault of checked.faults) out(`fault ${fault}\n`);
+  return checked.faults.length > 0 ? 1 : 0;
+}
diff --git a/src/cli.ts b/src/cli.ts
index 0d6aeaf..6df0dae 100755
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -2,7 +2,7 @@
 // pdf-export's command line (contracts/cli.md). Its commands come with spec 001's tasks:
 // serve (T022) runs the single-event service; preflight (T024) runs the book's preflight
 // alone; figures (T046) draws the book's figures into a folder; book (T029) writes the
-// book; check-book and compare are to come.
+// book; check-book (T030) checks a written book for FR-011's faults; compare is to come.
 import { constants as osConstants } from 'node:os';
 
 import { privateTemporary } from './print/temporary.ts';
@@ -87,6 +87,12 @@ if (import.meta.main) {
       out: (text) => process.stdout.write(text),
       err: (text) => process.stderr.write(text),
     });
+  } else if (args[0] === 'check-book') {
+    const { checkBookCommand } = await import('./check/book.ts');
+    process.exitCode = await checkBookCommand(args.slice(1), process.env, {
+      out: (text) => process.stdout.write(text),
+      err: (text) => process.stderr.write(text),
+    });
   } else if (args[0] === 'figures') {
     const { figuresCommand } = await import('./figures/step.ts');
     process.exitCode = await figuresCommand(args.slice(1), process.env, {
diff --git a/src/layout/pages.css b/src/layout/pages.css
index da46a12..7c49905 100644
--- a/src/layout/pages.css
+++ b/src/layout/pages.css
@@ -29,6 +29,13 @@ body {
   font-size: var(--mat-sys-body-medium-size);
   line-height: var(--mat-sys-body-medium-line-height);
   font-weight: var(--mat-sys-body-medium-weight);
+  /*
+   * Nothing may be wider than the text column: Chromium prints a page holding anything wider
+   * scaled down whole, its text below the theme's sizes (T030's review). A word too long for
+   * its line (a token of 140 characters, a table of long words) is broken anywhere, and
+   * `anywhere`, unlike `break-word`, lets tables and grids size their columns below it.
+   */
+  overflow-wrap: anywhere;
 }
 a {
   color: var(--mat-sys-primary);
@@ -202,6 +209,11 @@ table.edits,
 table.found {
   border-collapse: collapse;
   margin: 4pt 0 8pt;
+  /*
+   * never wider than the column, its cells wrapping: the body's `overflow-wrap: anywhere`
+   * already lets them, and this says so where a table is set
+   */
+  max-width: 100%;
 }
 th {
   text-align: left;
diff --git a/src/print/temporary.ts b/src/print/temporary.ts
index 5049903..d26726a 100644
--- a/src/print/temporary.ts
+++ b/src/print/temporary.ts
@@ -7,7 +7,10 @@
 // that lets the exit handlers run (the commands' handlers exit, never die by the signal)
 // leaves nothing, whichever of those folders had been made by then. Its exit handler is kept
 // the last: the launcher's own, added when a browser is spawned, kills the browser first,
-// as one still running could write into its profile again after the folder had gone.
+// as one still running could write into its profile again after the folder had gone. One
+// is live in a process at a time: TMPDIR is the process's, and a second would leave it
+// naming a folder the first had removed, and the two exit handlers each moving itself last
+// on the other's every move.
 import { mkdtempSync, rmSync } from 'node:fs';
 import { tmpdir } from 'node:os';
 import { join } from 'node:path';
@@ -15,16 +18,27 @@ import { join } from 'node:path';
 export interface PrivateTemporary {
   /** The folder, which TMPDIR names until `remove`. */
   root: string;
-  /** The folder removed, TMPDIR as it was, and the exit handler let go; done once. */
+  /**
+   * The folder removed, TMPDIR as it was, and the exit handler let go; done once. A removal
+   * that fails throws, the handler set again to try once more as the process exits.
+   */
   remove(): void;
 }
 
+/** The live folder's path, or null. */
+let live: string | null = null;
+
 /**
  * A private temporary folder in TMPDIR, named for `purpose`, TMPDIR set to it, and an exit
- * handler removing it, registered before anything else is started.
+ * handler removing it, registered before anything else is started. Throws while another is
+ * live in the process.
  */
 export function privateTemporary(purpose: string): PrivateTemporary {
+  if (live !== null) {
+    throw new Error(`A private temporary folder is live in this process already: ${live}`);
+  }
   const root = mkdtempSync(join(tmpdir(), `pdf-export.${purpose}-`));
+  live = root;
   const was = process.env['TMPDIR'];
   const onExit = () => {
     rmSync(root, { recursive: true, force: true });
@@ -51,11 +65,25 @@ export function privateTemporary(purpose: string): PrivateTemporary {
     remove() {
       if (removed) return;
       removed = true;
+      live = null;
       process.off('newListener', keepLast);
       process.off('exit', onExit);
-      onExit();
       if (was === undefined) delete process.env['TMPDIR'];
       else process.env['TMPDIR'] = was;
+      // last, so a removal that fails leaves the process as it was before, but for the
+      // folder's exit handler, set again: the folder is still there, and is tried again on exit
+      try {
+        onExit();
+      } catch (error) {
+        process.on('exit', () => {
+          try {
+            onExit();
+          } catch {
+            // the folder could not be removed now either: nothing more can be done
+          }
+        });
+        throw error;
+      }
     },
   };
 }
diff --git a/test/book/book.test.ts b/test/book/book.test.ts
index eff989a..5491b0e 100644
--- a/test/book/book.test.ts
+++ b/test/book/book.test.ts
@@ -41,6 +41,7 @@ import {
   type GraphOptions,
   type GraphSettings,
 } from '../../src/graph/connection.ts';
+import { CHECKS } from '../../src/check/book.ts';
 import { releaseInfo } from '../../src/graph/queries.ts';
 import { PAGE } from '../../src/layout/figure.ts';
 import { PLACEHOLDER, PROBE } from '../../src/layout/html.ts';
@@ -283,7 +284,6 @@ describe('pdf-export book', { skip }, () => {
   });
 
   test('exit 0 on the fixture: one PDF per top-level pathway and no other, finished, with no render service asked', async () => {
-    // provisional until check-book (T030) checks the book
     const { parent, out, report, args } = place();
     // two at once, not the default four, to spare the memory of a test run's other prints
     const run = await book([...args, '--parallel', '2', '--joins', '2']);
@@ -317,6 +317,13 @@ describe('pdf-export book', { skip }, () => {
       written.stages.map((stage) => stage.stage),
       ['preflight', 'figures', 'print', 'join', 'contents', 'parts', 'check-book']
     );
+    // FR-011's check run, every check passed (T030)
+    assert.deepEqual(
+      written.checks,
+      CHECKS.map((check) => ({ check, faults: 0 }))
+    );
+    assert.ok((written.stages.at(-1)?.seconds ?? 0) > 0, 'check-book not timed');
+    assert.equal(written.signal, null);
     for (const pdf of written.pdfs) {
       const path = join(out, pdf.file);
       const bytes = readFileSync(path);
@@ -879,4 +886,193 @@ describe('pdf-export book', { skip }, () => {
     assert.deepEqual(readdirSync(parent), []);
     assert.equal(readReport(report).outcome, 'faults');
   });
+  test("a fault FR-011's check finds: exit 1, its line, and no book", async () => {
+    // a printer that drops Reaction eight's name from its heading, as a layout might
+    const { parent, report, args } = place();
+    const printed: string[] = [];
+    const run = await book([...args, '--parallel', '2'], {
+      startPrinter: async () => {
+        const printer = await counting(printed)();
+        return {
+          part: (html) => printer.part(html.replace('>Reaction eight printer.close(),
+        };
+      },
+    });
+    assert.deepEqual(run.left, [], 'left in its temporary folder');
+    assert.equal(run.code, 1, run.err);
+    assert.equal(run.out, 'fault no-name "beta pathway.pdf" R-HSA-90000132\n');
+    assert.deepEqual(readdirSync(parent), []);
+    const written = readReport(report);
+    assert.equal(written.outcome, 'faults');
+    assert.deepEqual(written.faults, ['no-name "beta pathway.pdf" R-HSA-90000132']);
+    assert.deepEqual(
+      written.checks,
+      CHECKS.map((check) => ({ check, faults: check === 'no-name' ? 1 : 0 }))
+    );
+  });
+
+  test('a signal once the book is in place: the book written, exit 0, the signal in the report', async () => {
+    const { parent, out, report, args } = place();
+    class Exited extends Error {
+      readonly code: number;
+      constructor(code: number) {
+        super(`exit ${String(code)}`);
+        this.code = code;
+      }
+    }
+    const before = process.listenerCount('SIGTERM');
+    // sent as the run says the book is written, after it is moved into place
+    const run = book(args, {
+      err: (text) => {
+        if (text.startsWith('The book: ')) process.emit('SIGTERM', 'SIGTERM');
+      },
+      exit: (code) => {
+        throw new Exited(code);
+      },
+    });
+    const exited = await run.then(
+      () => null,
+      (error: unknown) => error
+    );
+    assert.ok(exited instanceof Exited, `not stopped by the signal: ${String(exited)}`);
+    assert.equal(exited.code, 0);
+    assert.equal(process.listenerCount('SIGTERM'), before, 'its handlers left set');
+    assert.deepEqual(readdirSync(parent), ['TheReactomeBook']);
+    assert.deepEqual(readdirSync(out).sort(), BOOK);
+    const written = readReport(report);
+    assert.equal(written.outcome, 'written');
+    assert.equal(written.signal, 'SIGTERM');
+    assert.equal(written.error, null);
+    assert.equal(written.pdfs.length, 3);
+    assert.deepEqual(
+      written.checks,
+      CHECKS.map((check) => ({ check, faults: 0 }))
+    );
+  });
+
+  test('an exception escaping the run still removes its temporary folder and handlers', async () => {
+    // a graph whose close fails, and an `err` that throws on being told so: the exception
+    // escapes between the run's start and its end
+    const { args } = place();
+    const temporary = scratch();
+    const was = process.env['TMPDIR'];
+    process.env['TMPDIR'] = temporary;
+    const before = process.listenerCount('SIGTERM');
+    try {
+      await assert.rejects(
+        bookCommand(
+          args,
+          { NEO4J_URI: process.env['NEO4J_URI'] },
+          {
+            out: () => undefined,
+            err: (text) => {
+              if (text.includes('did not close')) throw new Error('err failed');
+            },
+            open: (settings, options) => {
+              const graph = openGraph(settings, options);
+              return {
+                read: (query, params, readOptions) => graph.read(query, params, readOptions),
+                close: async () => {
+                  await graph.close();
+                  throw new Error('close failed');
+                },
+              };
+            },
+          }
+        ),
+        /err failed/
+      );
+      assert.equal(process.env['TMPDIR'], temporary, 'TMPDIR not restored');
+    } finally {
+      if (was === undefined) delete process.env['TMPDIR'];
+      else process.env['TMPDIR'] = was;
+    }
+    assert.deepEqual(readdirSync(temporary), [], 'left in its temporary folder');
+    assert.equal(process.listenerCount('SIGTERM'), before, 'its handlers left set');
+  });
+  test('nothing overflows the text column: a wide table and a long unbroken token print every part at full size', async () => {
+    // release 98 has 28 human summations holding a table; a table of several columns of long
+    // words, and a token of 140 characters unbroken, in a summation and in a name, are wider
+    // than the text column, and Chromium shrank the whole part to fit them (#160's review)
+    const word = (n: number) => `Longwordcolumn${String(n)}withoutspaces`;
+    const row = (cells: string[]) => `
${cells.map((c) => ``).join('')}`; + const table = `
${c}
${row([1, 2, 3, 4, 5, 6].map(word))}${row([7, 8, 9, 10, 11, 12].map(word))}
`; + const token = 'X'.repeat(140); + const plants: [string, string][] = [ + ['A third placeholder summation, first by order.', `

${token}

${table}`], + ['Reaction eight', `Reaction eight ${token}`], + ]; + const { out, args } = place(); + const run = await book([...args, '--parallel', '1'], { open: planting(plants), threshold: 1 }); + assert.deepEqual(run.left, [], 'left in its temporary folder'); + assert.equal(run.code, 0, run.err + run.out); + // every section's body text at 10.5 pt, and its headings at 12, 16.5 and 18 + const sizes = { label: new Set(), headings: new Set() }; + const headings = new Set(['Reaction one', 'Shared reaction', 'beta sub sub', 'Alpha pathway']); + for (const file of readdirSync(out)) { + await reading(join(out, file), async (doc) => { + for (let i = 1; i <= doc.numPages; i++) { + for (const item of (await (await doc.getPage(i)).getTextContent()).items) { + if (!('str' in item)) continue; + const t = item.transform as number[]; + const size = Math.round(Math.hypot(t[2] ?? 0, t[3] ?? 0) * 100) / 100; + if (item.str === 'Stable identifier') sizes.label.add(size); + // a heading: at the text column's left edge, past the cover, set above the body + // (not the same name in a contents entry, a location or a figure's label) + const left = Math.abs((t[4] ?? 0) - PAGE.margin) < 2; + if (i > 1 && left && headings.has(item.str) && size > 10.5) sizes.headings.add(size); + } + } + }); + } + assert.deepEqual([...sizes.label], [10.5]); + assert.deepEqual([...sizes.headings].sort(), [12, 16.5, 18]); + }); + test("a part Chromium printed scaled down still reads its sections' names", async () => { + // a block wider than the text column planted in every part printed, as the layout's + // overflow once did: Chromium prints each part scaled down whole, its text below the + // theme's sizes, and the check reads each heading against its page's body text + const { out, args } = place(); + const run = await book([...args, '--parallel', '2'], { + startPrinter: async () => { + const printer = await counting([])(); + return { + part: (html) => + printer.part( + html.replace('', '
') + ), + close: () => printer.close(), + }; + }, + }); + assert.deepEqual(run.left, [], 'left in its temporary folder'); + assert.equal(run.code, 0, run.err + run.out); + // and it was scaled down: the label every section prints is below the body's 10.5 pt + const labels = new Set(); + for (const file of readdirSync(out)) { + await reading(join(out, file), async (doc) => { + for (let i = 1; i <= doc.numPages; i++) { + for (const item of (await (await doc.getPage(i)).getTextContent()).items) { + if (!('str' in item) || item.str !== 'Stable identifier') continue; + const t = item.transform as number[]; + labels.add(Math.round(Math.hypot(t[2] ?? 0, t[3] ?? 0) * 100) / 100); + } + } + }); + } + assert.ok(labels.size > 0 && [...labels].every((size) => size < 10), [...labels].join(' ')); + }); + + test('a name holding characters never drawn reads as printed', async () => { + // a soft hyphen, a joiner, a variation selector and a control: the preflight lets them + // through (FR-017's NEVER_DRAWN), the book prints none of them, and the check reads the + // name without them + const plants: [string, string][] = [ + ['Reaction eight', 'Reac\u00ADtion\u200D eight\uFE0F\u0007'], + ]; + const run = await book([...place().args, '--parallel', '2'], { open: planting(plants) }); + assert.deepEqual(run.left, [], 'left in its temporary folder'); + assert.equal(run.code, 0, run.err + run.out); + }); }); diff --git a/test/check/book.test.ts b/test/check/book.test.ts new file mode 100644 index 0000000..2c2d37e --- /dev/null +++ b/test/check/book.test.ts @@ -0,0 +1,601 @@ +// FR-011's check, `check-book` (spec 001 T030, contracts/cli.md, SC-007): the fixture's book, +// printed once by `book`, passes; and a copy of it with one fault planted, by editing its +// folder or a copy of one of its PDFs (never the shared fixture), fails with that fault's +// line and no other. The faults are planted in `beta pathway.pdf`, at Reaction eight +// (R-HSA-90000132, at one place there, so its one contents entry and one outline entry are +// its own), and at beta sub with diagram (R-HSA-90000130) for a diagram's figure. +import assert from 'node:assert/strict'; +import { + copyFileSync, + cpSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { after, before, describe, test } from 'node:test'; + +import { + decodePDFRawStream, + PDFArray, + PDFDict, + PDFDocument, + PDFHexString, + PDFName, + PDFNumber, + PDFRawStream, + PDFRef, + PDFString, +} from '@cantoo/pdf-lib'; + +import { bookCommand } from '../../src/book/command.ts'; +import { CHECKS, checkBook, checkBookCommand } from '../../src/check/book.ts'; +import { graphSettings, openGraph, type Graph } from '../../src/graph/connection.ts'; +import { releaseInfo } from '../../src/graph/queries.ts'; +import { PLACEHOLDER } from '../../src/layout/html.ts'; +import { buildBook } from '../../src/model/build.ts'; +import type { Document } from '../../src/model/types.ts'; +import { readSummary } from '../../src/preflight/files.ts'; +import { expectedFigures, manifestLine, MANIFEST } from '../../src/preflight/folder.ts'; + +const skip = process.env['NEO4J_URI'] ? false : 'no fixture graph: set NEO4J_URI'; +const FILES = fileURLToPath(new URL('../../ci/fixture/files/', import.meta.url)); +const FIGURES = fileURLToPath(new URL('../../ci/fixture/figures/', import.meta.url)); +const BETA = 'beta pathway.pdf'; +const EIGHT = 'R-HSA-90000132'; +const SUB = 'R-HSA-90000130'; + +/** A PDF in a folder changed and saved again, as the book saves it (no object streams). */ +async function editPdf(path: string, edit: (pdf: PDFDocument) => void): Promise { + const pdf = await PDFDocument.load(readFileSync(path)); + edit(pdf); + writeFileSync(path, await pdf.save({ useObjectStreams: false })); +} + +interface Entry { + ref: PDFRef; + dict: PDFDict; + parent: PDFDict; + title: string; +} + +/** Every outline entry, at any depth, in order. */ +function outlineEntries(pdf: PDFDocument): Entry[] { + const entries: Entry[] = []; + const walk = (parent: PDFDict) => { + let ref = parent.get(PDFName.of('First')); + while (ref instanceof PDFRef) { + const dict = pdf.context.lookup(ref, PDFDict); + const titled = dict.lookup(PDFName.of('Title')); + if (!(titled instanceof PDFHexString || titled instanceof PDFString)) + throw new Error('untitled'); + const title = titled.decodeText(); + entries.push({ ref, dict, parent, title }); + walk(dict); + ref = dict.get(PDFName.of('Next')); + } + }; + walk(pdf.catalog.lookup(PDFName.of('Outlines'), PDFDict)); + return entries; +} + +/** The one outline entry with a title. */ +function entryOf(pdf: PDFDocument, title: string): Entry { + const found = outlineEntries(pdf).filter((entry) => entry.title === title); + assert.equal(found.length, 1, `outline entries titled ${title}`); + const [entry] = found; + if (!entry) throw new Error('unreachable'); + return entry; +} + +/** Where an outline entry lands: its page's index and ref, and its height. */ +function landingOf(pdf: PDFDocument, title: string): { index: number; page: PDFRef; y: number } { + const dest = entryOf(pdf, title).dict.lookup(PDFName.of('Dest'), PDFArray); + const page = dest.get(0); + if (!(page instanceof PDFRef)) throw new Error('a destination with no page'); + const index = pdf.getPages().findIndex((p) => p.ref === page); + return { index, page, y: dest.lookup(3, PDFNumber).asNumber() }; +} + +/** A leaf of the outline taken out of it, its neighbours and parent linked past it. */ +function dropOutline(pdf: PDFDocument, title: string): void { + const { ref, dict, parent } = entryOf(pdf, title); + assert.ok(!dict.has(PDFName.of('First')), `${title} is not a leaf`); + const prev = dict.get(PDFName.of('Prev')); + const next = dict.get(PDFName.of('Next')); + const relink = (holder: PDFDict, key: string, to: unknown) => { + if (to instanceof PDFRef) holder.set(PDFName.of(key), to); + else holder.delete(PDFName.of(key)); + }; + if (prev instanceof PDFRef) relink(pdf.context.lookup(prev, PDFDict), 'Next', next); + if (next instanceof PDFRef) relink(pdf.context.lookup(next, PDFDict), 'Prev', prev); + if (parent.get(PDFName.of('First')) === ref) relink(parent, 'First', next); + if (parent.get(PDFName.of('Last')) === ref) relink(parent, 'Last', prev); + if (!parent.has(PDFName.of('First'))) parent.delete(PDFName.of('Count')); + pdf.context.delete(ref); +} + +interface Annotation { + ref: PDFRef; + dict: PDFDict; + uri: string | null; + dest: PDFArray | undefined; + top: number; +} + +/** A page's link annotations, as listed. */ +function annotations(pdf: PDFDocument, index: number): Annotation[] { + const annots = pdf.getPages()[index]?.node.Annots(); + const found: Annotation[] = []; + for (let i = 0; i < (annots?.size() ?? 0); i++) { + const ref = annots?.get(i); + if (!(ref instanceof PDFRef)) continue; + const dict = pdf.context.lookup(ref, PDFDict); + const action = dict.lookupMaybe(PDFName.of('A'), PDFDict); + const uri = action?.lookupMaybe(PDFName.of('URI'), PDFString, PDFHexString)?.decodeText(); + const rect = dict.lookup(PDFName.of('Rect'), PDFArray); + const top = Math.max( + rect.lookup(1, PDFNumber).asNumber(), + rect.lookup(3, PDFNumber).asNumber() + ); + found.push({ + ref, + dict, + uri: uri ?? null, + dest: dict.lookupMaybe(PDFName.of('Dest'), PDFArray), + top, + }); + } + return found; +} + +/** An annotation taken off its page and out of the file. */ +function removeAnnotation(pdf: PDFDocument, index: number, ref: PDFRef): void { + const page = pdf.getPages()[index]; + const annots = page?.node.Annots(); + if (!page || !annots) throw new Error('a page with no annotations'); + const at = annots.indexOf(ref); + assert.ok(at !== undefined && at >= 0, 'the annotation is not on its page'); + annots.remove(at); + pdf.context.delete(ref); +} + +/** A font's character codes and what each reads as, from its ToUnicode map. */ +function readsAs(font: PDFDict): Map { + const map = new Map(); + const stream = font.lookup(PDFName.of('ToUnicode')); + if (!(stream instanceof PDFRawStream)) return map; + const cmap = Buffer.from(decodePDFRawStream(stream).decode()).toString('latin1'); + const utf16 = (hex: string) => Buffer.from(hex, 'hex').swap16().toString('utf16le'); + for (const [, block = ''] of cmap.matchAll(/beginbfchar([\s\S]*?)endbfchar/g)) { + for (const [, code = '', to = ''] of block.matchAll(/<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>/g)) { + map.set(parseInt(code, 16), utf16(to)); + } + } + for (const [, block = ''] of cmap.matchAll(/beginbfrange([\s\S]*?)endbfrange/g)) { + const ranges = block.matchAll( + /<([0-9A-Fa-f]+)>\s*<([0-9A-Fa-f]+)>\s*(?:<([0-9A-Fa-f]+)>|\[([^\]]*)\])/g + ); + for (const [, lo = '', hi = '', base, list] of ranges) { + const from = parseInt(lo, 16); + const to = parseInt(hi, 16); + const listed = list === undefined ? [] : [...list.matchAll(/<([0-9A-Fa-f]+)>/g)]; + for (let code = from; code <= to; code++) { + if (base !== undefined) + map.set(code, String.fromCodePoint(parseInt(base, 16) + code - from)); + else map.set(code, utf16(listed[code - from]?.[1] ?? '')); + } + } + } + return map; +} + +/** + * Every run of text on a page that reads `text` taken out of its content: each `BT ... ET` + * block (Chromium writes one a run, its glyphs as two-byte hex codes) read through its + * fonts' ToUnicode maps. Returns how many were taken out. + */ +function eraseText(pdf: PDFDocument, index: number, text: string): number { + const page = pdf.getPages()[index]; + if (!page) throw new Error('no such page'); + const fonts = page.node.Resources()?.lookupMaybe(PDFName.of('Font'), PDFDict); + const maps = new Map>(); + const mapOf = (name: string) => { + let map = maps.get(name); + if (!map) { + const font = fonts?.lookupMaybe(PDFName.of(name), PDFDict); + map = font ? readsAs(font) : new Map(); + maps.set(name, map); + } + return map; + }; + const contents = page.node.get(PDFName.of('Contents')); + const refs = contents instanceof PDFArray ? contents.asArray() : [contents]; + let erased = 0; + for (const ref of refs) { + if (!(ref instanceof PDFRef)) continue; + const stream = pdf.context.lookup(ref); + if (!(stream instanceof PDFRawStream)) continue; + const before = Buffer.from(decodePDFRawStream(stream).decode()).toString('latin1'); + let found = 0; + const after = before.replace(/\bBT\b[\s\S]*?\bET\b/g, (block) => { + let map = new Map(); + let read = ''; + for (const [, font, hex] of block.matchAll( + /\/([^\s/<>[\]()]+)\s+[-\d.]+\s+Tf|<([0-9A-Fa-f\s]*)>/g + )) { + if (font !== undefined) map = mapOf(font); + else + for (const code of (hex ?? '').replace(/\s/g, '').match(/.{4}/g) ?? []) { + read += map.get(parseInt(code, 16)) ?? ''; + } + } + if (read !== text) return block; + found += 1; + return ''; + }); + if (found === 0) continue; + erased += found; + pdf.context.assign(ref, pdf.context.flateStream(Buffer.from(after, 'latin1'))); + } + return erased; +} + +describe('pdf-export check-book', { skip }, () => { + let root: string | undefined; + const scratch = (): string => { + root ??= mkdtempSync(join(tmpdir(), 'pdfx-check-')); + return mkdtempSync(join(root, 'test-')); + }; + /** The fixture's book, as `book` wrote it, and the documents it was made from. */ + let book: string; + let docs: Document[]; + let figures: string; + let summary: NonNullable>; + const env = { NEO4J_URI: process.env['NEO4J_URI'] }; + + before(async () => { + const graph = openGraph(graphSettings(process.env)); + const read = readSummary(FILES); + if (!read) throw new Error('the fixture has no EHLD summary'); + summary = read; + let print; + try { + const release = await releaseInfo(graph); + docs = await buildBook(graph, { illustrated: summary.illustrated }); + print = { + release: release.release, + checksum: release.checksum ?? '', + summary: summary.hash, + content: release.release, + build: 'test-build', + }; + } finally { + await graph.close(); + } + // a figures' folder as `figures` would leave it, as the book's tests write one + figures = scratch(); + const lines = [manifestLine({ type: 'fingerprint', ...print })]; + for (const record of expectedFigures(docs).values()) { + const file = `${record.source}${record.kind === 'reaction' ? '.reaction' : ''}.svg`; + copyFileSync(join(FIGURES, file), join(figures, `${record.stId}.svg`)); + lines.push(manifestLine({ type: 'figure', ...record })); + } + lines.push(manifestLine({ type: 'complete', ...print })); + writeFileSync(join(figures, MANIFEST), lines.join('')); + book = join(scratch(), 'TheReactomeBook'); + const temporary = scratch(); + const was = process.env['TMPDIR']; + process.env['TMPDIR'] = temporary; + const err: string[] = []; + let code: number; + try { + code = await bookCommand( + ['--files', FILES, '--figures', figures, '--site', 'https://site.example', '--out', book], + env, + { out: () => undefined, err: (text) => err.push(text), date: new Date('2026-11-04') } + ); + } finally { + if (was === undefined) delete process.env['TMPDIR']; + else process.env['TMPDIR'] = was; + } + assert.equal(code, 0, err.join('')); + assert.deepEqual(readdirSync(temporary), [], 'left in its temporary folder'); + }); + after(() => { + if (root) rmSync(root, { recursive: true, force: true }); + }); + + /** The faults in a copy of the book, `plant` given its folder first. */ + const planted = async (plant: (folder: string) => void | Promise, files = FILES) => { + const folder = join(scratch(), 'TheReactomeBook'); + cpSync(book, folder, { recursive: true }); + await plant(folder); + return (await checkBook(folder, docs, files)).faults; + }; + + /** A plant in `beta pathway.pdf`. */ + const inBeta = (edit: (pdf: PDFDocument) => void) => (folder: string) => + editPdf(join(folder, BETA), edit); + + const line = (kind: string, stId: string, file = BETA) => + `${kind} ${JSON.stringify(file)} ${stId}`; + + test('the book as `book` wrote it passes every check', async () => { + assert.deepEqual(readdirSync(book).sort(), ['Alpha pathway.pdf', 'Gamma pathway.pdf', BETA]); + const checked = await checkBook(book, docs, FILES); + assert.deepEqual(checked.faults, []); + assert.deepEqual( + checked.checks, + CHECKS.map((check) => ({ check, faults: 0 })) + ); + // and so it does saved again unchanged, as the plants are + assert.deepEqual(await planted(inBeta(() => undefined)), []); + }); + + test('a top-level pathway with no PDF: missing-pathway', async () => { + const faults = await planted((folder) => { + unlinkSync(join(folder, 'Gamma pathway.pdf')); + }); + assert.deepEqual(faults, ['missing-pathway R-HSA-90000300']); + }); + + test("a file that is no top-level pathway's: extra-file", async () => { + const faults = await planted((folder) => { + copyFileSync(join(folder, BETA), join(folder, 'Delta pathway.pdf')); + }); + assert.deepEqual(faults, ['extra-file "Delta pathway.pdf"']); + }); + + test("an event's contents entry gone: not-in-contents", async () => { + const faults = await planted( + inBeta((pdf) => { + const target = landingOf(pdf, 'Reaction eight'); + const contents = landingOf(pdf, 'Contents').index; + const links = annotations(pdf, contents).filter( + (a) => + a.dest?.get(0) === target.page && a.dest.lookup(3, PDFNumber).asNumber() === target.y + ); + assert.equal(links.length, 1); + for (const link of links) removeAnnotation(pdf, contents, link.ref); + }) + ); + assert.deepEqual(faults, [line('not-in-contents', EIGHT)]); + // its link kept, its text gone + const unread = await planted( + inBeta((pdf) => { + assert.equal(eraseText(pdf, landingOf(pdf, 'Contents').index, 'Reaction eight'), 1); + }) + ); + assert.deepEqual(unread, [line('not-in-contents', EIGHT)]); + }); + + test("an event's outline entry gone: not-in-outline", async () => { + const faults = await planted( + inBeta((pdf) => { + dropOutline(pdf, 'Reaction eight'); + }) + ); + assert.deepEqual(faults, [line('not-in-outline', EIGHT)]); + // there, landing on its section, but titled another's name + const retitled = await planted( + inBeta((pdf) => { + entryOf(pdf, 'Reaction eight').dict.set( + PDFName.of('Title'), + PDFHexString.fromText('Reaction nine') + ); + }) + ); + assert.deepEqual(retitled, [line('not-in-outline', EIGHT)]); + // titled its name, but landing 10 pt below its heading, past what a landing may be off + const moved = await planted( + inBeta((pdf) => { + const dest = entryOf(pdf, 'Reaction eight').dict.lookup(PDFName.of('Dest'), PDFArray); + dest.set(3, PDFNumber.of(dest.lookup(3, PDFNumber).asNumber() - 10)); + }) + ); + assert.deepEqual(moved, [line('not-in-outline', EIGHT)]); + }); + + test("a section's name not printed: no-name", async () => { + const faults = await planted( + inBeta((pdf) => { + const { index } = landingOf(pdf, 'Reaction eight'); + assert.ok(eraseText(pdf, index, 'Reaction eight') > 0, 'no name erased'); + }) + ); + assert.deepEqual(faults, [line('no-name', EIGHT)]); + }); + + test("a section's stable identifier not printed: no-stable-id", async () => { + const faults = await planted( + inBeta((pdf) => { + const { index } = landingOf(pdf, 'Reaction eight'); + assert.equal(eraseText(pdf, index, EIGHT), 1); + }) + ); + assert.deepEqual(faults, [line('no-stable-id', EIGHT)]); + }); + + /** The figure's link in a section: the lowest of its page's links to `address`. */ + const dropFigure = (title: string, address: string) => + inBeta((pdf) => { + const { index } = landingOf(pdf, title); + const links = annotations(pdf, index).filter((a) => a.uri?.endsWith(address)); + const lowest = links.sort((a, b) => a.top - b.top)[0]; + if (!lowest) throw new Error(`no link to ${address}`); + removeAnnotation(pdf, index, lowest.ref); + }); + + test("a section's figure gone where its source exists: missing-figure", async () => { + // a reaction's, below its heading's link to the same page, and a pathway's diagram + assert.deepEqual(await planted(dropFigure('Reaction eight', `/content/detail/${EIGHT}`)), [ + line('missing-figure', EIGHT), + ]); + const diagram = dropFigure('beta sub with diagram', `/PathwayBrowser/#/${SUB}`); + assert.deepEqual(await planted(diagram), [line('missing-figure', SUB)]); + // with its diagram's file not in the release's folder, its figure is not looked for + const files = scratch(); + cpSync(FILES, files, { recursive: true }); + unlinkSync(join(files, 'diagram', `${SUB}.json`)); + assert.deepEqual(await planted(diagram, files), []); + }); + + test('a link landing on no page, or not rewritten: broken-link', async () => { + /** Reaction eight's first internal link: its location's first pathway. */ + const breaking = (edit: (pdf: PDFDocument, link: Annotation) => void) => + inBeta((pdf) => { + const { index, y } = landingOf(pdf, 'Reaction eight'); + const link = annotations(pdf, index).find((a) => a.dest !== undefined && a.top < y); + if (!link) throw new Error('no internal link in its section'); + edit(pdf, link); + }); + const nowhere = breaking((pdf, link) => { + // its page a dictionary that is not a page: the catalog + const root = pdf.context.trailerInfo.Root; + if (!(root instanceof PDFRef) || !link.dest) throw new Error('no catalog'); + link.dest.set(0, root); + }); + assert.deepEqual(await planted(nowhere), [line('broken-link', EIGHT)]); + const placeholder = breaking((pdf, link) => { + link.dict.delete(PDFName.of('Dest')); + link.dict.set( + PDFName.of('A'), + pdf.context.obj({ S: 'URI', URI: PDFString.of(`${PLACEHOLDER}s-R-HSA-90000100`) }) + ); + }); + assert.deepEqual(await planted(placeholder), [line('broken-link', EIGHT)]); + // an address to an event's page that cannot be decoded: broken, not a stop + const undecodable = breaking((pdf, link) => { + link.dict.delete(PDFName.of('Dest')); + const uri = PDFString.of('https://site.example/content/detail/%E0%A4%A'); + link.dict.set(PDFName.of('A'), pdf.context.obj({ S: 'URI', URI: uri })); + }); + assert.deepEqual(await planted(undecodable), [line('broken-link', EIGHT)]); + }); + + test('a section whose heading lost its link and its contents entry is found by the outline', async () => { + // its first link to its page is then its figure's: the section is not placed there + const faults = await planted( + inBeta((pdf) => { + const target = landingOf(pdf, 'Reaction eight'); + const heading = annotations(pdf, target.index) + .filter((a) => a.uri?.endsWith(`/content/detail/${EIGHT}`)) + .sort((a, b) => b.top - a.top)[0]; + if (!heading) throw new Error('no heading link'); + removeAnnotation(pdf, target.index, heading.ref); + const contents = landingOf(pdf, 'Contents').index; + for (const link of annotations(pdf, contents)) { + if ( + link.dest?.get(0) === target.page && + link.dest.lookup(3, PDFNumber).asNumber() === target.y + ) { + removeAnnotation(pdf, contents, link.ref); + } + } + }) + ); + assert.deepEqual(faults, [line('not-in-contents', EIGHT)]); + }); + + test('two sections of one name, and an earlier link to the second: each found at its own', async () => { + // Reaction eight printed as `Shared reaction`, the name of another section of beta's + // (R-HSA-90000220), and a link on the cover to its page, before both + const renamed = (graph: Graph): Graph => ({ + read: async (query: string, params?: Record) => { + const rows = await graph.read(query, params); + return JSON.parse( + JSON.stringify(rows).split('"Reaction eight"').join('"Shared reaction"') + ) as T[]; + }, + close: () => graph.close(), + }); + const graph = renamed(openGraph(graphSettings(process.env))); + let twice: Document[]; + try { + twice = await buildBook(graph, { illustrated: summary.illustrated }); + } finally { + await graph.close(); + } + const folder = join(scratch(), 'TheReactomeBook'); + const temporary = scratch(); + const was = process.env['TMPDIR']; + process.env['TMPDIR'] = temporary; + let code: number; + try { + code = await bookCommand( + ['--files', FILES, '--figures', figures, '--site', 'https://site.example', '--out', folder], + env, + { + out: () => undefined, + err: () => undefined, + open: (settings, options) => renamed(openGraph(settings, options)), + } + ); + } finally { + if (was === undefined) delete process.env['TMPDIR']; + else process.env['TMPDIR'] = was; + } + assert.equal(code, 0); + const before = await checkBook(folder, twice, FILES); + assert.deepEqual(before.faults, []); + await editPdf(join(folder, BETA), (pdf) => { + const [link] = annotations(pdf, 0).filter((a) => a.uri !== null); + if (!link) throw new Error('no link on the cover'); + const uri = PDFString.of(`https://site.example/content/detail/${EIGHT}`); + link.dict.set(PDFName.of('A'), pdf.context.obj({ S: 'URI', URI: uri })); + }); + assert.deepEqual((await checkBook(folder, twice, FILES)).faults, []); + }); + + test('pdf-export check-book: its lines and exit codes', async () => { + const run = async (args: string[], environment: Record = env) => { + const out: string[] = []; + const err: string[] = []; + const code = await checkBookCommand(args, environment, { + out: (text) => out.push(text), + err: (text) => err.push(text), + }); + return { code, out: out.join(''), err: err.join('') }; + }; + const passed = await run([book, '--files', FILES]); + assert.deepEqual(passed, { code: 0, out: '', err: '' }); + const folder = join(scratch(), 'TheReactomeBook'); + cpSync(book, folder, { recursive: true }); + await inBeta((pdf) => { + dropOutline(pdf, 'Reaction eight'); + })(folder); + unlinkSync(join(folder, 'Gamma pathway.pdf')); + const failed = await run(['--files', FILES, folder, '--species', 'Homo sapiens']); + assert.equal(failed.code, 1, failed.err); + assert.equal( + failed.out, + `fault missing-pathway R-HSA-90000300\nfault ${line('not-in-outline', EIGHT)}\n` + ); + // bad arguments, a folder that is not one, no EHLD summary, no graph + for (const args of [ + ['--files', FILES], + [book, book, '--files', FILES], + [book], + [book, '--files', FILES, '--render', 'x'], + [book, '--files', FILES, '--species'], + ]) { + assert.equal((await run(args)).code, 2, args.join(' ')); + } + assert.equal((await run([book, '--files', FILES], {})).code, 2); + const notFolder = await run([join(book, BETA), '--files', FILES]); + assert.equal(notFolder.code, 2); + assert.match(notFolder.err, /Not a folder/); + const noSummary = await run([book, '--files', scratch()]); + assert.equal(noSummary.code, 2); + assert.match(noSummary.err, /svgsummary/); + const unreachable = await run([book, '--files', FILES, '--graph', 'bolt://']); + assert.equal(unreachable.code, 2); + assert.match(unreachable.err, /cannot be read/); + }); +}); diff --git a/test/print/print.test.ts b/test/print/print.test.ts index f4d3e40..81ba790 100644 --- a/test/print/print.test.ts +++ b/test/print/print.test.ts @@ -2,6 +2,9 @@ // embedded and no other, the outline nested, internal links landing on their targets, and // every contents number its target's page. import assert from 'node:assert/strict'; +import { mkdtempSync, readdirSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { after, before, describe, test } from 'node:test'; import { PDFDict, PDFDocument, PDFName } from '@cantoo/pdf-lib'; @@ -326,3 +329,29 @@ test('either a new page count or a moved target is a moved print', () => { '5 pages, then 5; moved: b' ); }); + +// with or without the fixture graph: a printer leaves nothing in TMPDIR once closed, its pages' +// folder among it (the book's tests read TMPDIR only after the run's private folder, which +// holds the printer's, is gone, so they cannot see a printer that leaves its own) +test("a printer's close removes its pages' folder", async () => { + const temporary = mkdtempSync(join(tmpdir(), 'pdfx-printer-')); + const was = process.env['TMPDIR']; + process.env['TMPDIR'] = temporary; + try { + const printer = await Printer.start(); + try { + assert.ok( + readdirSync(temporary).some((name) => name.startsWith('pdf-export-')), + 'no folder of its own' + ); + await printer.part('

A page

'); + } finally { + await printer.close(); + } + assert.deepEqual(readdirSync(temporary), []); + } finally { + if (was === undefined) delete process.env['TMPDIR']; + else process.env['TMPDIR'] = was; + rmSync(temporary, { recursive: true, force: true }); + } +}); diff --git a/test/print/temporary.test.ts b/test/print/temporary.test.ts new file mode 100644 index 0000000..aa47fd8 --- /dev/null +++ b/test/print/temporary.test.ts @@ -0,0 +1,62 @@ +// A process's private temporary folder (spec 001 T029, T030 from #159's review): one live at +// a time in a process, TMPDIR pointing at it until it is removed, then as it was. +import assert from 'node:assert/strict'; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { privateTemporary } from '../../src/print/temporary.ts'; + +test('one private temporary folder is live in a process at a time', () => { + const parent = mkdtempSync(join(tmpdir(), 'pdfx-temporary-')); + const was = process.env['TMPDIR']; + process.env['TMPDIR'] = parent; + try { + const first = privateTemporary('test'); + assert.equal(process.env['TMPDIR'], first.root); + assert.throws(() => privateTemporary('again'), /live in this process already/); + // the refusal changed nothing + assert.equal(process.env['TMPDIR'], first.root); + assert.ok(existsSync(first.root)); + first.remove(); + assert.equal(process.env['TMPDIR'], parent); + assert.ok(!existsSync(first.root)); + // once it is removed, another may be made + const second = privateTemporary('test'); + assert.equal(process.env['TMPDIR'], second.root); + second.remove(); + assert.equal(process.env['TMPDIR'], parent); + } finally { + if (was === undefined) delete process.env['TMPDIR']; + else process.env['TMPDIR'] = was; + rmSync(parent, { recursive: true, force: true }); + } +}); + +test('a removal that fails keeps an exit handler, to try again as the process exits', () => { + const parent = mkdtempSync(join(tmpdir(), 'pdfx-temporary-')); + const was = process.env['TMPDIR']; + process.env['TMPDIR'] = parent; + const handlers = process.listenerCount('exit'); + let locked: string | undefined; + try { + const temporary = privateTemporary('test'); + // a folder inside it that cannot be emptied: its file cannot be unlinked + locked = join(temporary.root, 'locked'); + mkdirSync(locked); + writeFileSync(join(locked, 'file'), ''); + chmodSync(locked, 0o500); + assert.throws(() => { + temporary.remove(); + }); + assert.ok(existsSync(temporary.root)); + assert.equal(process.listenerCount('exit'), handlers + 1, 'no exit handler kept'); + assert.equal(process.env['TMPDIR'], parent); + } finally { + if (locked) chmodSync(locked, 0o700); + if (was === undefined) delete process.env['TMPDIR']; + else process.env['TMPDIR'] = was; + rmSync(parent, { recursive: true, force: true }); + } +});