The index draws its leader now, and a frame's vertical offset is a kept value rather than an ignored one — with the measurement that decided it.
156 lines
8.7 KiB
Markdown
156 lines
8.7 KiB
Markdown
# 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
|
|
```
|
|
|
|
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.
|
|
|
|
## 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) |
|
|
|
|
Both sides are normalized to mm from the top-left of each page, grouped into lines,
|
|
and compared as: page count → line count → line text → line position. 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.
|
|
|
|
Reported differences are `pageCount`, `lineCount`, `lineBreak` (same line, different
|
|
words) and `position` (same words, off by more than `POS_TOL_MM`). Comparison of a
|
|
page stops at the first line-level divergence, since everything after it is noise.
|
|
|
|
## 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.
|
|
|
|
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. Real-world `.docx`/`.odt` files dropped into `fixtures/` are picked
|
|
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
|
|
|
|
- **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.
|
|
|
|
## 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>
|
|
```
|
|
|
|
## Open findings
|
|
|
|
What the three real-world fixtures — the contract (an IHK
|
|
contract form), the summary (a student summary: bullets,
|
|
wrapped pictures, one table) and the thesis (a 48-page thesis with charts,
|
|
metafile figures and a German TOC) — still report:
|
|
|
|
- **A frame's vertical anchor offset is kept but not drawn.** Both offsets round-trip
|
|
now (`wrapOffset`/`wrapOffsetY`, images and text boxes alike), but only the horizontal
|
|
one is rendered: a line box avoids a float's whole *margin* box, so applying the
|
|
vertical one as a top margin makes dead space where Word flows text — measured, the
|
|
thesis' figure page went from 131mm off to 187mm and the document grew a page.
|
|
- **One header/footer set for the whole document.** Word gives every *section* its own,
|
|
including its own first page; the contract's second section repeats the letterhead on
|
|
page 10 and the body starts 25mm lower there than here.
|
|
- **A block ending in a hard break renders one line too many.** ProseMirror appends a
|
|
trailing-break `<br>` so the empty last line stays caret-reachable, but under
|
|
`white-space: pre-wrap` that line already exists. Hiding the hack node fixes the height
|
|
and costs the caret that line, so it is hidden only after an inline atom, where there
|
|
is no such line (`editor.css`). Reproduces natively with Shift+Enter, no import needed.
|
|
- **Chromium does not break after a slash**, LibreOffice does — "Sensor/Messinstrument"
|
|
stays one word in a narrow table cell and wraps a line later.
|
|
- **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.
|
|
- **Charts and metafiles are placeholders.** No renderer exists for either, so an
|
|
undrawable frame keeps its box and its label but not its picture (`imageFormats.ts`).
|
|
Exporting writes the placeholder back out — the original is gone from the moment it
|
|
is imported.
|
|
- **Tabs that wrap lose their advance.** Six consecutive tabs at a line end stay on that
|
|
line in Chromium and the continuation starts at the margin; LibreOffice carries the
|
|
tab positions onto it (91mm apart in the contract).
|
|
- **A run of dots is one unbreakable word** to Chromium, while LibreOffice breaks inside
|
|
it — a dotted fill-in line wraps at a different place.
|
|
- **A list marker sits where the browser puts it, not at the hanging indent.** The
|
|
level's `w:ind w:hanging` reaches the text (which lands at Word's 1.27cm) but not
|
|
the bullet: CSS gives `::marker` no position, and Chromium sets it flush against
|
|
the text, ~4mm right of where Word and LibreOffice draw it. Only a `::before`
|
|
marker could place it, which `listMarker.ts` and every ordered format ride on.
|
|
- **A trailing space counts toward a right-aligned line** under `white-space: pre-wrap`,
|
|
pushing the text ~1.2mm left of where LibreOffice puts it.
|
|
- Lines whose natural width lands within ~0.2mm of the right margin may take one word
|
|
more or fewer than LibreOffice. Sub-0.1mm engine rounding, not a layout rule.
|
|
|
|
What tab stops (`tabStops.ts`) deliberately left out, none of it exercised by a fixture:
|
|
|
|
- **Leader characters** (`w:tab w:leader`, `style:leader-text`) are dropped: a tab can't
|
|
fill its gap with dots. odf-kit can't emit them either. The generated index draws its
|
|
own leader (`tableOfContents.ts`), so only a leader on a *tab stop* is missing.
|
|
- **The default tab interval is fixed at 1.25cm**, so a file's own `w:defaultTabStop` /
|
|
`style:tab-stop-distance` is ignored. It is the CSS `tab-size`, which the measuring
|
|
pass leaves to handle every tab past the last custom stop.
|
|
- **Header/footer zones** get no stops: an inactive zone is static HTML from
|
|
`generateHTML`, which no ProseMirror plugin reaches.
|
|
|
|
## Known ceilings
|
|
|
|
- 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).
|