Files
edentext/tests/render-parity/README.md
T
Steffen Becker f26de200fe test: the parity harness aligns two documents instead of indexing lines
Comparison stopped at the first divergence on a page, so one displaced line
reported every line behind it and a slipped page hid what followed: a
468-page reference reported 4 differences while hiding 465 pages. The line
streams are aligned instead — nearest resync within 60 lines, contiguous
divergences merged, neighbouring lines off by the same amount collapsed to
a count — and it reports 5766. Reference PDFs are cached by file hash
(corpus 9:00 cold against 5:41 cached), each run prints its counts against
the last, and --quick skips what measured over 50 pages.
2026-09-04 19:30:11 +02:00

165 lines
9.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Render parity
Opens the same document in **LibreOffice** and in **the editor**, then compares the
resulting text layout. Everything else in `tests/` compares documents at the model
level (JSON → export → import); this leg is the only one that checks what a page
actually looks like.
```bash
npm run test:parity:fixtures # (re)generate the baseline .docx corpus
npm run test:parity # whole corpus
npm run test:parity -- path/to/file.docx --json report.json
npm run test:parity -- --quick # skip what last measured over 50 pages
```
Exit code 1 when any document differs. `--json` also dumps both sides' lines
(`y`, `x`, `x2` in mm, per page) — that dump is what you diagnose from.
Every run prints each document's issue count against the previous run's and updates
`node_modules/.cache/render-parity/baseline.json`, so an A/B measurement reads
`218 (was 220, −2)` instead of being arithmetic over two reports. A run of one file
leaves the other files' recorded counts alone. `--no-baseline` neither reads nor
writes it — use it when measuring a deliberately broken tree.
**The LibreOffice side is cached** in the same directory, keyed by the file's hash and
the export arguments: the reference never depends on our code, so only the first run of
a document pays for it — the whole corpus measured **9:00 cold against 5:41 cached**,
and one fixture 15s against 10s. `--no-cache` after installing or removing a font,
which does change what LibreOffice renders.
## How it compares
| | reference | editor |
|---|---|---|
| render | `soffice --convert-to pdf` | Playwright Chromium, real app, real file input |
| read | `pdftotext -bbox-layout` (word boxes, pt) | `Range.getClientRects()` (word boxes, px) |
The PDF is exported with `IsSkipEmptyPages=false`: LibreOffice drops its own
auto-inserted blank pages by default — a chapter forced onto a right page — and the
reference then has fewer sheets than the document LibreOffice lays out (the
458-page guide 456 against 468, one fixture 28 against 32).
Both sides are normalized to mm from the top-left of each page and grouped into lines
(`compare.mjs`, the pure half of the harness — `tests/unit/parity-compare.test.ts`
covers it without a browser). Comparison is whitespace-insensitive, because the
spell-check decorations split text nodes mid-word and the two engines needn't agree on
word boundaries — only on what sits on a line; the invisible joiners a note anchor
leaves behind are ignored for the same reason.
The two documents are then **aligned as a whole**, not compared line index against line
index: after a divergence the harness searches up to `SYNC_WINDOW` lines on both sides
for the nearest pair of skips that makes two consecutive lines match again. One dropped
line therefore costs one report, not every line after it, and a page-level slip shows up
as one `pageShift` naming the line it starts at instead of a wall of noise.
Where no resync is in reach it steps both sides by one and keeps comparing. A contents
block whose numbers are all off by one has no two consecutive matching rows anywhere in
it, and declaring the documents parted there hid 465 of the 458-page guide's pages
behind a single report. Contiguous divergent lines are merged into one report carrying
how many lines each side spent, so the region still reads as one difference.
Reported differences are `pageCount`, `pageShift` (from here on the editor runs ±n pages
off), `lineCount`, `lineBreak` (same place, different words) and `position` / `lineEnd`
(same words, off by more than `POS_TOL_MM`). Consecutive lines off by the *same* amount
are one report with a `×n` count, since that is one cause — a strut, a spacing, a band
height — and not n of them. Counts recorded before 2026-09-03 came from the index-wise
comparison, which stopped at the first divergence per page: they are not comparable to
what a run prints now.
## Prerequisites
```bash
apt-get install -y --no-install-recommends libreoffice-writer poppler-utils
npm install --no-save playwright-core && npx playwright-core install chromium
cp src/assets/fonts/*.ttf /usr/local/share/fonts/ && fc-cache -f
```
**The font step is not optional.** LibreOffice renders with system fonts; without
the repo's own TTFs installed it substitutes DejaVu for Liberation Serif and every
line breaks differently for a reason that is not a bug. With them, both engines
resolve Times New Roman → Liberation Serif, Arial → Liberation Sans, Calibri →
Carlito, Cambria → Caladea — the same files the editor bundles.
Where the LibreOffice build ships those families itself (the macOS app bundle does:
`Contents/Resources/fonts/truetype/`), it resolves them without fontconfig and the step
is only about what it does *not* ship — `fc-list` showing neither Liberation nor Carlito
is then not a reason to distrust a run.
**But install only what LibreOffice does not already ship.** A second copy of a
family it bundles makes its render *non-deterministic*: it picks between the two
files per run — measured as `Carlito` against `Carlito-Regular` in the PDF's font
list — and their vertical metrics differ, so a page carrying a large-font heading
lands 1.85mm lower on some runs and a fixture's issue count swings by 40. The
browser side never needs the install: the app `@font-face`s its own copies. On a
macOS install `/Applications/LibreOffice.app/Contents/Resources/fonts/truetype/`
already holds Carlito, Caladea and Liberation, so **skip the `cp` there entirely**;
check any other platform with `fc-list | grep -ci carlito` before copying.
The runner starts `npm run dev` on port 5199 itself if nothing answers there.
## Fixtures
`make-fixtures.mjs` authors the baseline corpus with the `docx` lib **directly**,
not through `src/lib/export/docx.ts` — otherwise the corpus would test our exporter
against itself — and converts each document to its ODT twin with LibreOffice. It
writes them to **`tests/corpus/`, which is committed**: `tests/corpus.test.ts` reads
them at the model level (import → our own export → import) on every `npm test`, so
CI runs against documents this editor did not write. A run with no argument compares
that corpus *and* `fixtures/`, which stays gitignored — keep whatever real-world
files you test against there and local.
## Names stay local
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), or reads
`tests/corpus/`, which is committed. CI greps for the read.
## Probing with a purpose-built document
The fastest probe is an `.odt` of your own run through the harness: both engines lay the
same file out and the report *is* the measurement, in the units the corpus uses. Build it
with `fflate` (`zipSync`), keep it in the scratchpad, and mind two traps — `mimetype` has
to be stored uncompressed (`{ level: 0 }`) or LibreOffice produces no PDF at all, and a
`style:font-name` with **no `<style:font-face>` declaration** is not resolved: LibreOffice
falls back (Calibri → Liberation Sans) where the editor honours the name, which invents a
line-height difference that is not there in any real document.
## Prerequisites, part two: Calibri Light
Word's default theme heading font. LibreOffice's substitution table knows Calibri but
not Calibri Light, so without an alias it falls to fontconfig's generic — DejaVu Serif,
a serif heading nothing else renders. Word substitutes plain Calibri, and so does the
editor (`global.css`), so give LibreOffice the same answer:
```xml
<!-- /etc/fonts/conf.d/30-calibri-light.conf, then fc-cache -f -->
<match target="pattern">
<test name="family"><string>Calibri Light</string></test>
<edit name="family" mode="prepend" binding="strong"><string>Carlito</string></edit>
</match>
```
## What a `lineBreak` run usually is
Both word processors **shrink** the spaces of a justified line to pull one more word
onto it; CSS `text-align: justify` only stretches. Probed on one paragraph: justified,
LibreOffice keeps a word the browser drops, and the same paragraph left-aligned breaks
in both engines at the same word. So a long justified document reports a `lineBreak`
every dozen pages with no defect behind it — the browser simply fits marginally less
per line, worth about a quarter page over fifty. Left-align a passage before believing
a break difference is ours.
## What the harness cannot see
`extractLayout` walks **text nodes**, so anything drawn by CSS is invisible to it: a
list marker and a chapter number both ride `hN::before`, and a numbered heading is
therefore always reported as if it carried no number. Check those against the contents
rows instead, which hold the same label as real text, or against a screenshot
(`docs/headless-testing.md`). A `text-transform` is the case the harness *does* handle
— it reads the case the style paints, not the node's.