From 64cc010f01e43ef0dd58d680cc2541c8536f21aa Mon Sep 17 00:00:00 2001 From: Steffen Becker Date: Tue, 11 Aug 2026 21:58:11 +0000 Subject: [PATCH] docs: a real-world document's name stays out of the repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/ci.yml | 4 + .gitignore | 1 + CHANGELOG.md | 2 + CLAUDE.md | 4 + tests/render-parity/README.md | 162 ++-------------------------------- 5 files changed, 20 insertions(+), 153 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9760ddf..a11d471 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index d845c42..849129a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ dist debug .DS_Store tests/render-parity/fixtures +tests/render-parity/FINDINGS.md docs/others diff --git a/CHANGELOG.md b/CHANGELOG.md index 10f2fec..bc89cb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index f53b03e..0679c2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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** diff --git a/tests/render-parity/README.md b/tests/render-parity/README.md index 284c08a..85849f8 100644 --- a/tests/render-parity/README.md +++ b/tests/render-parity/README.md @@ -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: ``` -## 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 `
`: 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).