docs: a real-world document's name stays out of the repo

The parity findings named the six files the corpus is built from, so every
measurement carried a document title into the history. They move to a
gitignored FINDINGS.md beside the corpus itself; the README keeps the harness.

A test that needs a document builds one, so nothing under src/ or tests/
reads the gitignored corpus — CI greps for exactly that.
This commit is contained in:
Steffen Becker committed 2026-08-11 22:25:33 +00:00
1 parent fe55aec95f
commit 64cc010f01
5 files changed
+20 -153

No files matched your search

+4
View File
@@ -12,6 +12,10 @@ jobs:
- uses: actions/checkout@v4
# CLAUDE.md is read at every session start; detail belongs in the nested files.
- run: test "$(wc -l < CLAUDE.md)" -le 120 || { echo "CLAUDE.md over its 120-line budget"; exit 1; }
# The corpus is gitignored, so a test reading it would name a document and fail here.
- run: |
! git grep -l "render-parity/fixtures" -- src tests ':!tests/render-parity' \
|| { echo "a test reads the gitignored parity corpus"; exit 1; }
- uses: actions/setup-node@v4
with:
node-version: 20
+1
View File
@@ -4,4 +4,5 @@ dist
debug
.DS_Store
tests/render-parity/fixtures
tests/render-parity/FINDINGS.md
docs/others
+2
View File
@@ -139,6 +139,8 @@ The gap against Word/LibreOffice, most valuable first. Reviewed 2026-08-08.
- Password-protected ODT/DOCX; digital signatures
- Navigator / outline view, split view
- PWA / offline support
- A committed corpus of documents we author ourselves, so CI can test against real files
(`.gitignore` excludes all of `tests/render-parity/fixtures/` today)
**Out of scope for now**
- Mail merge, data sources, form fields
+4
View File
@@ -37,6 +37,10 @@ Tests live in `tests/` (outside `src/`, so `svelte-check` ignores them), jsdom v
lines**, however large the change. Probed behaviour, measurements and rationale lists belong
in `docs/architecture/` or the nearest `CLAUDE.md`, not repeated in the history.
**IMPORTANT: no real-world document's name anywhere in the repo, and a commit message
names no document at all** — not even "the thesis": describe the fix and its measurement,
never the file it helped. Detail in `tests/render-parity/README.md`.
**IMPORTANT: never introduce a default only this editor has.** Both importers suppress values equal
to the defaults, so an editor-only default is indistinguishable from a failed style resolution and
silently lands in every imported document as direct formatting. The defaults follow **LibreOffice**
+9 -153
View File
@@ -55,17 +55,16 @@ against itself. Real-world `.docx`/`.odt` files dropped into `fixtures/` are pic
up too. The directory is gitignored: run the generator once, and keep whatever
real-world files you test against local.
## Differences that are not editor bugs
## Names stay local
- **Justified text.** LibreOffice *compresses* inter-word spaces (measured: 0.864mm
against a natural 1.058mm) to pull one more word onto a line. CSS justification
only stretches, and Chromium exposes no minimum-word-spacing control, so a
justified line may end one word short. `02-blocks` reports this permanently.
- **Omitted properties of built-in styles.** For a style it recognizes by name,
LibreOffice fills what the file leaves out from its own defaults (a Heading with
no spacing gets 0.42cm before). Word uses only what the file declares, and so do
we — so fixtures must declare heading spacing and font explicitly, or the
reference disagrees with Word rather than with us.
A real-world document's name goes in no file, and a commit message names no document at
all — write the rule that changed and what it measured. `FINDINGS.md` is gitignored for
that reason.
Only this harness reads `fixtures/`. The directory is gitignored, so a `tests/unit/` test
reading it fails with `ENOENT` on a fresh clone; it zips its own document instead
(`docx-onoff.test.ts` — both importers take a `Uint8Array`, not a path). CI greps for the
read.
## Prerequisites, part two: Calibri Light
@@ -82,146 +81,3 @@ editor (`global.css`), so give LibreOffice the same answer:
</match>
```
## Open findings
What the real-world fixtures — the contract (an IHK contract
form), the summary (a student summary: bullets, wrapped
pictures, one table), the thesis (a 48-page thesis with charts, metafile figures and
a German TOC) the Math Guide (LibreOffice's own 63-page Math Guide: a title page,
a three-page index, per-section masters, ~35 figure frames and 400 formulas) and
the Writer Guide (its 458-page Writer Guide) — still report. The two guides and
the thesis run off LibreOffice on page count (thesis 48 against 49, Math Guide 64 against
63, Writer Guide 477 against 458), in each case downstream of a line the rules below cost
or gained:
- **A page-anchored frame's coordinates are its page's, but a frame in a *header* keeps
the ones its zone gives it.** Both are drawn out of flow; the body one (`anchorPage`,
`image.ts`) sits behind the text at `--page-cycle` × (page − 1) + its `svg:y`.
- **An index entry keeps its heading's hard break.** A book's chapter heading is its
number and its title on two lines, and both producers copy that break into the entry —
the Writer Guide's contents is 18 rows shorter without it. The leader and the page
number ride the entry's last line (`align-items: last baseline`).
- **The 458-page Writer Guide is a frontier of its own**: 19 pages over LibreOffice, with
the drift accumulating (its index is one page short at "Autoformatting", two long by
"Index"). Nothing in it has been diagnosed past the contents.
- **The index is its own pagination.** A generated TOC is a block atom, so pagination has
no inner position to put a spacer at; the node view breaks it between entries itself
(`tableOfContents.ts`) and asks for one recompute when that changes its height.
- **A hoisted text box loses the vertical offset it was anchored by.** A frame's own
offsets are drawn now (`docs/architecture/frames.md`), and a lifted box takes its anchor
paragraph's spacing (`docs/architecture/frames.md`), but a text box anchored inside a
paragraph is a block node here, so the importer lifts it out and it simply follows that
paragraph. On the thesis' figure page that leaves its caption 4.7mm high — LibreOffice
places the box by its own 19.52cm, we place it after the picture.
Attempted and **reverted**: a top margin of the offset minus the previous block's
measured height overshot by 4.5mm (231.2 against 227.7, from 223.0) and improved
nothing in the corpus. The anchor paragraph's box is not the right basis — the frame
it holds is a float, so its height is the text's alone.
- **A page's last line is where the footer band ends.** The body margin a file's header or
footer reconstructs is the page margin plus the zone's declared height; the zone's own gap
to the body sits *inside* that height (probed — `styleResolver.ts`). The height is the
zone's *own* font: it is one paragraph here, so its mark struts every line of the band
(`blockFontSize.ts` is in the zone schema, `hfReachPx` measures the same value). Getting
that wrong costs a line on every page, which is worth more than any single block's
spacing.
- **Chromium floors every painted border to one pixel**, so a table's rows come out
~0.25mm too tall each unless the excess is taken off the cell padding
(`docs/architecture/tables.md`).
- **A bulleted line is 0.12mm shorter here than in LibreOffice.** The numbering font joins
the line it labels there; our marker is out of flow and CSS gives an outside `::marker`
no metric influence either (probed: its `font-family` changes nothing, its `font-size`
grows the line above the baseline where LibreOffice grows it below). The summary
fixture's bullet pages drift ~1mm by their last line — that is all its `position`
reports are.
- **A formula page cannot be compared.** LibreOffice draws each formula from its own OLE
frame; we typeset MathML, so `pdftotext` and `getClientRects` measure two different
typesettings and the harness splits a row whose glyph boxes disagree. Honouring the
frame's `style:vertical-pos` on a formula was tried and **reverted** — every inline
formula sank ~1mm and the Math Guide went from 100 to 125 issues (CHANGELOG).
- **Page geometry is document-wide.** Headers and footers are per section (each is
editable in place), but margins, orientation and format are not: they come from the
master page governing the most body blocks (`import/odt.ts`), so a title page with its
own margins is laid out with the body's. The Math Guide's title sits 9.8mm low and
3.3mm left for exactly that reason.
- **Line height follows the paragraph, not the line.** The block's CSS strut applies to
every line; a word processor takes each line's own runs and the paragraph mark only on
the last. The mark's font rides the block (`blockFontSize.ts`), and a paragraph whose
runs all agree takes theirs — which covers everything the corpus has. A paragraph of
*mixed* sizes still struts at the block's.
Attempted and **reverted**: `line-height: 0` on the block with the content's line-height
restored by an inline decoration does drop the strut, and measured right on a purpose-built
fixture (a 24pt run over 10pt text came to 62.1pt against LibreOffice's 61.8, from 69.0).
It cost the contract 22 issues all the same. Two things the strut was quietly holding up:
a line of no runs at all — two breaks in a row — collapses to nothing, and the reserved
paragraph mark is an `::after` on the block, so the same rule takes the *last* line's
height away with it. An empty widget span at the block's end gets that line back but not
to the same value. Whatever replaces the strut has to carry both.
- **Metafiles are placeholders, and a re-export keeps the placeholder.** EMF/WMF/SVM have
no JS decoder, so such a frame keeps its box and its label but not its picture
(`imageFormats.ts`) — and what is exported is that placeholder. **Charts** are drawn
now (`import/chart.ts`), but as a *picture*: a re-export carries the drawing, not a
chart object, since the editor has none.
- **A list level's own hanging indent is not read**, only the 0.635cm both exports
write (`LIST_HANGING_CM`). A **left**-set label wider than that overflows into the text,
where Word moves the text to the next list tab — reading `w:lvl/w:pPr/w:ind w:hanging`
would settle it. Deliberate for now: in flow, a wide marker pushed the text and cost
the thesis fixture a page. (The level's *left* indent is read at every depth, and a
**right**-set label — `markerAlign`, which is what the built-in Roman numberings use —
grows into the margin, so the case that actually collided is gone.)
What a probe of the ODF side turned up, before anything is built on it: LibreOffice
honours a nested level's `fo:text-indent` linearly (−0.300 → label at 2.243cm, −1.000
→ 1.543, −1.200 → 1.344, against `fo:margin-left="2.540cm"`) — **except** at −1.270,
the value odf-kit writes for level 2, where it draws its own flat 0.635cm hanging
(1.909cm) instead. So reading the attribute back naively moves the markers of our own
exports. Whatever rule that is has to be understood before the value is trusted.
- Lines take one word more or fewer than LibreOffice, which is most of the thesis'
remaining `lineBreak` reports. Two causes, both measured on that fixture:
- **LibreOffice compresses inter-word spaces to fit a line**, CSS justification only
expands. Of 349 full-width justified lines, **91** are narrower in LibreOffice than
their natural width here — up to **0.83px per space** at 12pt (p99 0.68px).
Attempted and **reverted**: `word-spacing: -0.05em` on justified blocks. It does fix
the break decision (the thesis' page 6 paragraph goes 7 lines → LibreOffice's 6), but
the corpus got *worse* — `lineBreak` 45 → 43 while `lineEnd` went 2 → 16, because the
tighter spacing also shortens every paragraph's unjustified last line by 1–3mm.
- Sub-0.1mm engine rounding, which **accumulates**: matching word widths (11.86mm for
`haben,` on both sides) had drifted 0.45mm apart by the 150th mm of the line.
LibreOffice quantizes each glyph advance where Chromium keeps it fractional, and
nothing in CSS exposes that.
## Findings that did not survive measurement
Kept here so they are not re-derived. Each was checked against `soffice` on a purpose-built
one-page fixture.
- **A trailing space on a right-aligned line.** LibreOffice counts it exactly as we do
(its last glyph lands at 189.0mm, ours at 188.9mm). Hanging it — which is what Word
does — moved us 1.0mm *away* from the reference, so it was reverted.
- **Breaking after a slash, and inside a run of dots.** Neither engine does either:
`Sensor/Messinstrument` and a 50-dot leader stay one word in both. What LibreOffice
really does that we did not is break a word *wider than its line*; that is fixed
(`overflow-wrap: break-word`).
- **A block ending in a hard break.** Real — the next paragraph sat 4.7mm too low — but
the cause was not the trailing-break `<br>`: under `pre-wrap` that adds no line. It was
the always-reserved paragraph mark, which is inline content *past* the break and so
opened a line of its own. Fixed in `editor.css`.
## Known ceilings
- **A stop's leader is drawn, but the harness can't see it.** The fill is CSS generated
content, so `Range.getClientRects()` reads the gap as empty while LibreOffice's PDF has
the dots in its text. The contract's `Zwischen ......` line reports a `lineBreak` for
that reason alone — compare it by eye, not by this harness. An **index** leader is real
text on both sides, so it is compared: as one token, since how many dots fill the gap is
the fill's business (`norm`). A TOC whose file declares *no* leader still gets ours.
- Lines are grouped by vertical band, so **multi-column** text on one page merges
columns into single lines. Columns fixtures need per-block grouping first.
- **Only where text sits is compared** — not what it looks like. A dropped colour,
highlight, fill, border or image changes nothing this harness can see, so a green
run does not mean the page matches. Word's highlighter pen went unread through
several rounds of green reports. Check that by eye: `pdftoppm -r 96 -png` the
LibreOffice PDF beside a Playwright screenshot of the same page. Two traps there —
the app's toolbar and ruler cover the top ~85px of a page in the viewport, and
LibreOffice caches font matching per user profile, so a reused profile keeps
substituting the font it resolved before `30-calibri-light.conf` existed.
- LibreOffice is the reference, not Word. They agree on the layout rules exercised
here, but not on everything (e.g. Word's own line-height rounding).