test: compare the rendered page against LibreOffice
Everything else in tests/ compares documents at the model level; nothing checked what a page actually looks like. run.mjs renders the same file through soffice and through the real app in Chromium, reads both back as word boxes, and diffs page count, line count, line text and line position. The corpus stays out of the repo: make-fixtures.mjs authors the baseline .docx files, and real-world documents are whatever you drop into the gitignored fixtures/ yourself.
This commit is contained in:
1 parent
09f57c5326
commit
361b14dc17
5 files changed
+657
-1
No files matched your search
@@ -3,3 +3,4 @@ dist
|
||||
.vite
|
||||
debug
|
||||
.DS_Store
|
||||
tests/render-parity/fixtures
|
||||
+3
-1
@@ -17,7 +17,9 @@
|
||||
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:lo": "vitest run tests/lo-roundtrip.test.ts"
|
||||
"test:lo": "vitest run tests/lo-roundtrip.test.ts",
|
||||
"test:parity": "node tests/render-parity/run.mjs",
|
||||
"test:parity:fixtures": "node tests/render-parity/make-fixtures.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# 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 ignored.** A `wp:anchor` carries a y offset from
|
||||
its paragraph; the editor floats it at the anchor itself, so a top-and-bottom wrapped
|
||||
figure sits where its caption paragraph is instead of where Word puts it (the thesis'
|
||||
page 15, 131mm out). The horizontal offset round-trips (`wrapOffset`); the vertical one
|
||||
has no CSS equivalent while the frame stays in flow.
|
||||
- **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.
|
||||
- **Dot leaders.** LibreOffice fills a TOC entry to its page number with dots; the
|
||||
editor's index lays the number out with flex and leaves the gap empty.
|
||||
- **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 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 — the dotted
|
||||
run before a TOC page number. odf-kit can't emit them either.
|
||||
- **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 text is compared — no images, rules, fills or borders.
|
||||
- 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).
|
||||
@@ -0,0 +1,210 @@
|
||||
// Authors the baseline .docx corpus with the `docx` lib directly (not via
|
||||
// src/lib/export/docx.ts, so the fixtures don't test our exporter against itself).
|
||||
// Drop real-world files into fixtures/ alongside them.
|
||||
import { writeFileSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType, LevelFormat,
|
||||
Table, TableRow, TableCell, WidthType, convertMillimetersToTwip,
|
||||
Header, Footer, PageNumber, TabStopType,
|
||||
} from 'docx';
|
||||
|
||||
const OUT = join(dirname(fileURLToPath(import.meta.url)), 'fixtures');
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const LOREM = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.';
|
||||
|
||||
const page = { margin: { top: 1134, bottom: 1134, left: 1134, right: 1134 } }; // 2cm
|
||||
const para = (text, o = {}) => new Paragraph({ children: [new TextRun({ text, ...o.run })], ...o.p });
|
||||
|
||||
// Heading styles declare spacing and font explicitly: LibreOffice fills the gaps of
|
||||
// a built-in style it knows by name from its own defaults, where Word uses only what
|
||||
// the file declares — an omitted property makes the reference disagree with Word.
|
||||
const heading = (id, name, halfPt, before, after) => ({
|
||||
id, name, basedOn: 'Normal', next: 'Normal', quickFormat: true,
|
||||
run: { font: 'Arial', size: halfPt, bold: true },
|
||||
paragraph: { spacing: { before, after } },
|
||||
});
|
||||
|
||||
async function write(name, sections, defaultRun = { font: 'Times New Roman', size: 24 }, extra = {}) {
|
||||
const doc = new Document({
|
||||
styles: {
|
||||
default: { document: { run: defaultRun } },
|
||||
paragraphStyles: [
|
||||
// Word always writes an explicit Normal; the docx lib would emit docDefaults only.
|
||||
{ id: 'Normal', name: 'Normal', run: defaultRun, paragraph: { spacing: { after: 0 } } },
|
||||
heading('Heading1', 'Heading 1', 36, 240, 120),
|
||||
heading('Heading2', 'Heading 2', 32, 200, 100),
|
||||
],
|
||||
},
|
||||
...extra,
|
||||
sections,
|
||||
});
|
||||
writeFileSync(join(OUT, name), await Packer.toBuffer(doc));
|
||||
console.log('wrote', name);
|
||||
}
|
||||
|
||||
// 1. Line breaking + pagination over three pages of plain body text.
|
||||
await write('01-flow.docx', [{
|
||||
properties: { page },
|
||||
children: Array.from({ length: 24 }, (_, i) => para(`${i + 1}. ${LOREM}`)),
|
||||
}]);
|
||||
|
||||
// 2. Headings, alignment, indents, spacing.
|
||||
await write('02-blocks.docx', [{
|
||||
properties: { page },
|
||||
children: [
|
||||
para('Document Title', { p: { heading: HeadingLevel.HEADING_1 } }),
|
||||
para(LOREM),
|
||||
para('Section', { p: { heading: HeadingLevel.HEADING_2 } }),
|
||||
para(LOREM, { p: { alignment: AlignmentType.JUSTIFIED } }),
|
||||
para(LOREM, { p: { indent: { left: convertMillimetersToTwip(20) } } }),
|
||||
para(LOREM, { p: { spacing: { before: 240, after: 240 } } }),
|
||||
para('Centred line', { p: { alignment: AlignmentType.CENTER } }),
|
||||
para('Right line', { p: { alignment: AlignmentType.RIGHT } }),
|
||||
],
|
||||
}]);
|
||||
|
||||
// 3. Mixed fonts and sizes — catches metric/substitution drift first.
|
||||
await write('03-fonts.docx', [{
|
||||
properties: { page },
|
||||
children: [
|
||||
para(LOREM, { run: { font: 'Times New Roman', size: 24 } }),
|
||||
para(LOREM, { run: { font: 'Arial', size: 22 } }),
|
||||
para(LOREM, { run: { font: 'Calibri', size: 22 } }),
|
||||
para(LOREM, { run: { font: 'Courier New', size: 20 } }),
|
||||
para(LOREM, { run: { font: 'Times New Roman', size: 36 } }),
|
||||
para(LOREM, { run: { font: 'Times New Roman', size: 24, bold: true } }),
|
||||
para(LOREM, { run: { font: 'Times New Roman', size: 24, italics: true } }),
|
||||
],
|
||||
}]);
|
||||
|
||||
// 5. Page-break edge cases: a paragraph taller than a page slot (widow-orphan
|
||||
// control is unsatisfiable there), and short paragraphs walking the boundary.
|
||||
await write('05-breaks.docx', [{
|
||||
properties: { page },
|
||||
children: [
|
||||
...Array.from({ length: 12 }, (_, i) => para(`${i + 1}. ${LOREM}`)),
|
||||
para(new Array(14).fill(LOREM).join(' ')), // ~40 lines, taller than one page
|
||||
para('one'), para('two'), para('three'),
|
||||
...Array.from({ length: 6 }, (_, i) => para(`${i + 1}. ${LOREM}`)),
|
||||
],
|
||||
}]);
|
||||
|
||||
// 6. Word's own default body font. Carlito's natural line height is not Liberation
|
||||
// Serif's, so a wrong single-spacing ratio shows up as drift over the page.
|
||||
await write('06-calibri.docx', [{
|
||||
properties: { page },
|
||||
children: Array.from({ length: 20 }, (_, i) =>
|
||||
para(`${i + 1}. ${LOREM}`, { run: { font: 'Calibri', size: 22 } })),
|
||||
}], { font: 'Calibri', size: 22 });
|
||||
|
||||
// 7. The same flow with widow-orphan control off: LibreOffice then fills the page to
|
||||
// the last line, so the editor must stop guarding page breaks too.
|
||||
await write('07-nowidow.docx', [{
|
||||
properties: { page },
|
||||
children: Array.from({ length: 24 }, (_, i) =>
|
||||
new Paragraph({ widowControl: false, children: [new TextRun(`${i + 1}. ${LOREM}`)] })),
|
||||
}]);
|
||||
|
||||
// 8. Lists: three bullet levels, three numbered levels, and numbering continued
|
||||
// across an intervening paragraph. Level indents drive the text column, so they
|
||||
// show up as position differences.
|
||||
const NUM_REF = 'nums';
|
||||
const listLevels = (format) => [0, 1, 2].map((level) => ({
|
||||
level, format, text: format === LevelFormat.BULLET ? '•' : `%${level + 1}.`,
|
||||
alignment: AlignmentType.LEFT,
|
||||
style: { paragraph: { indent: { left: 720 * (level + 1), hanging: 360 } } },
|
||||
}));
|
||||
const li = (text, level, reference) =>
|
||||
new Paragraph({ numbering: { reference, level }, children: [new TextRun(text)] });
|
||||
|
||||
await write('08-lists.docx', [{
|
||||
properties: { page },
|
||||
children: [
|
||||
para('Bullets'),
|
||||
li('first bullet', 0, 'bullets'), li('nested bullet', 1, 'bullets'),
|
||||
li('deeper bullet', 2, 'bullets'), li('back to first', 0, 'bullets'),
|
||||
para('Numbers'),
|
||||
li('one', 0, NUM_REF), li('one point one', 1, NUM_REF),
|
||||
li('one point one point one', 2, NUM_REF), li('two', 0, NUM_REF),
|
||||
para('An interrupting paragraph.'),
|
||||
li('three continues the numbering', 0, NUM_REF),
|
||||
li(`a long item that has to wrap: ${LOREM}`, 0, NUM_REF),
|
||||
],
|
||||
}], undefined, {
|
||||
numbering: { config: [
|
||||
{ reference: 'bullets', levels: listLevels(LevelFormat.BULLET) },
|
||||
{ reference: NUM_REF, levels: listLevels(LevelFormat.DECIMAL) },
|
||||
] },
|
||||
});
|
||||
|
||||
// 9. Header and footer with a page-number field, repeated over three pages. Both
|
||||
// engines place them from the page edge, so a wrong zone distance shifts them.
|
||||
await write('09-headerfooter.docx', [{
|
||||
properties: { page },
|
||||
headers: { default: new Header({ children: [
|
||||
new Paragraph({ alignment: AlignmentType.RIGHT, children: [new TextRun('Parity Report')] }),
|
||||
] }) },
|
||||
footers: { default: new Footer({ children: [
|
||||
new Paragraph({ alignment: AlignmentType.CENTER,
|
||||
children: [new TextRun({ children: ['Page ', PageNumber.CURRENT, ' of ', PageNumber.TOTAL_PAGES] })] }),
|
||||
] }) },
|
||||
children: Array.from({ length: 30 }, (_, i) => para(`${i + 1}. ${LOREM}`)),
|
||||
}]);
|
||||
|
||||
// 10. Tab stops: left, centre, right and decimal, plus a hanging indent. Tabs place
|
||||
// text at absolute positions, so a wrong default tab width is immediately visible.
|
||||
await write('10-tabs.docx', [{
|
||||
properties: { page },
|
||||
children: [
|
||||
para('Default tabs follow:'),
|
||||
new Paragraph({ children: [new TextRun('a\tb\tc\td')] }),
|
||||
new Paragraph({
|
||||
tabStops: [
|
||||
{ type: TabStopType.CENTER, position: convertMillimetersToTwip(60) },
|
||||
{ type: TabStopType.RIGHT, position: convertMillimetersToTwip(120) },
|
||||
{ type: TabStopType.DECIMAL, position: convertMillimetersToTwip(160) },
|
||||
],
|
||||
children: [new TextRun('left\tcentred\tright\t12.34')],
|
||||
}),
|
||||
new Paragraph({
|
||||
indent: { left: convertMillimetersToTwip(25), hanging: convertMillimetersToTwip(25) },
|
||||
children: [new TextRun(`Term\t${LOREM}`)],
|
||||
}),
|
||||
],
|
||||
}]);
|
||||
|
||||
// 4. Table geometry: column widths, spans, a long cell that wraps.
|
||||
const cell = (text, o = {}) => new TableCell({ children: [para(text)], ...o });
|
||||
await write('04-table.docx', [{
|
||||
properties: { page },
|
||||
children: [
|
||||
para('Before the table'),
|
||||
new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
new TableRow({ children: [cell('Header A'), cell('Header B'), cell('Header C')] }),
|
||||
new TableRow({ children: [cell(LOREM), cell('short'), cell('short')] }),
|
||||
new TableRow({ children: [cell('spans two', { columnSpan: 2 }), cell('C3')] }),
|
||||
],
|
||||
}),
|
||||
para('After the table'),
|
||||
],
|
||||
}]);
|
||||
|
||||
// ODT twins, written by LibreOffice itself — the dominant ODT producer, so they carry
|
||||
// its own conventions (percentage font sizes, Text Body, list styles) and exercise the
|
||||
// foreign-document path our own exporter never produces.
|
||||
const docxFiles = readdirSync(OUT).filter((f) => f.endsWith('.docx'));
|
||||
try {
|
||||
execFileSync('soffice', [
|
||||
'--headless', '--norestore', `-env:UserInstallation=file://${OUT}/.loprofile`,
|
||||
'--convert-to', 'odt', '--outdir', OUT, ...docxFiles.map((f) => join(OUT, f)),
|
||||
], { stdio: 'pipe', timeout: 300_000 });
|
||||
console.log(`converted ${docxFiles.length} ODT twins`);
|
||||
} catch {
|
||||
console.log('soffice missing — skipped the ODT twins');
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
// Render-parity harness: opens a document in LibreOffice and in the editor, then
|
||||
// compares the resulting text layout (pages, lines, positions in mm).
|
||||
// Usage and prerequisites — including the mandatory font setup — are in README.md.
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync, mkdtempSync, readdirSync, rmSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, basename, extname, resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { chromium } from 'playwright-core';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(HERE, '../..');
|
||||
const CHROME = join(process.env.HOME, '.cache/ms-playwright/chromium-1228/chrome-linux/chrome');
|
||||
const PORT = 5199;
|
||||
const PAGE_GAP = 20; // pageBreaks.ts
|
||||
const PT_MM = 25.4 / 72;
|
||||
const PX_MM = 25.4 / 96;
|
||||
const LINE_TOL_MM = 1.2; // words within this vertical span are one line
|
||||
const POS_TOL_MM = 1.0; // reported as a position difference beyond this
|
||||
|
||||
// ---------------------------------------------------------------- reference
|
||||
|
||||
function loRender(file, work) {
|
||||
execFileSync('soffice', [
|
||||
'--headless', '--norestore', `-env:UserInstallation=file://${work}/loprofile`,
|
||||
'--convert-to', 'pdf', '--outdir', work, file,
|
||||
], { stdio: 'pipe', timeout: 120_000 });
|
||||
const pdf = join(work, basename(file, extname(file)) + '.pdf');
|
||||
if (!existsSync(pdf)) throw new Error(`LibreOffice produced no PDF for ${file}`);
|
||||
const xml = join(work, 'ref.xml');
|
||||
execFileSync('pdftotext', ['-bbox-layout', pdf, xml], { stdio: 'pipe' });
|
||||
return { pages: parseBbox(readFileSync(xml, 'utf8')), pdf };
|
||||
}
|
||||
|
||||
// pdftotext -bbox-layout emits <page><flow><block><line><word>, coords in pt,
|
||||
// origin top-left of the page.
|
||||
function parseBbox(xml) {
|
||||
const pages = [];
|
||||
for (const [, attrs, body] of xml.matchAll(/<page ([^>]*)>([\s\S]*?)<\/page>/g)) {
|
||||
const words = [];
|
||||
for (const [, wattrs, text] of body.matchAll(/<word ([^>]*)>([\s\S]*?)<\/word>/g)) {
|
||||
const a = num(wattrs);
|
||||
const t = decode(text);
|
||||
if (t) words.push({ text: t, x: a.xMin * PT_MM, y: a.yMin * PT_MM, w: (a.xMax - a.xMin) * PT_MM });
|
||||
}
|
||||
const a = num(attrs);
|
||||
pages.push({ words, width: a.width * PT_MM, height: a.height * PT_MM });
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
const num = (s) => Object.fromEntries([...s.matchAll(/(\w+)="([-\d.]+)"/g)].map(([, k, v]) => [k, +v]));
|
||||
const decode = (s) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
.replace(/'/g, "'").replace(/&/g, '&').trim();
|
||||
|
||||
// ------------------------------------------------------------------- editor
|
||||
|
||||
async function editorRender(browser, file) {
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 1000 } });
|
||||
page.on('dialog', (d) => d.accept());
|
||||
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load' });
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem('odf-editor-zoom', '100'); // no transform scale => rects are doc px
|
||||
});
|
||||
await page.reload({ waitUntil: 'load' });
|
||||
await page.waitForSelector('.tiptap');
|
||||
await page.setInputFiles('input.file-input', file);
|
||||
await settle(page);
|
||||
const result = await page.evaluate(extractLayout);
|
||||
await page.close();
|
||||
return result;
|
||||
}
|
||||
|
||||
// Pagination (and the columns flow) runs over several frames; wait for the page
|
||||
// count and document height to stop moving. An image still decoding has no height
|
||||
// yet and would settle the layout at a page count it leaves again a frame later.
|
||||
async function settle(page) {
|
||||
await page.waitForFunction(() => {
|
||||
const el = document.querySelector('.tiptap');
|
||||
if (!el) return false;
|
||||
const imgs = Array.from(document.querySelectorAll('.paper img'));
|
||||
if (imgs.some((i) => !i.complete)) return false;
|
||||
// The spacers' heights too, not just their count: they go on settling for a while
|
||||
// after the last one is placed, and the page a line lands on moves with them.
|
||||
const spacers = Array.from(document.querySelectorAll('[data-page-break-spacer]'));
|
||||
const key = el.style.minHeight + '|' + spacers.length + '|'
|
||||
+ spacers.reduce((sum, s) => sum + s.offsetHeight, 0);
|
||||
const w = window;
|
||||
if (w.__parityKey !== key) { w.__parityKey = key; w.__paritySince = performance.now(); return false; }
|
||||
return performance.now() - (w.__paritySince ?? 0) > 3000;
|
||||
}, null, { timeout: 180_000 });
|
||||
}
|
||||
|
||||
// Runs in the browser: every rendered word with its page and mm position.
|
||||
function extractLayout() {
|
||||
const PAGE_GAP = 20, PX_MM = 25.4 / 96;
|
||||
const paper = document.querySelector('.paper');
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const pageH = parseFloat(cs.getPropertyValue('--user-page-height'));
|
||||
const pageW = parseFloat(cs.getPropertyValue('--user-page-width'));
|
||||
const cycle = pageH + PAGE_GAP;
|
||||
const origin = paper.getBoundingClientRect();
|
||||
|
||||
const skip = (node) => {
|
||||
for (let e = node.parentElement; e && e !== paper; e = e.parentElement) {
|
||||
if (e.hasAttribute('data-page-break-spacer') || e.classList.contains('band-layer')) return true;
|
||||
const s = getComputedStyle(e);
|
||||
if (s.display === 'none' || s.visibility === 'hidden') return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const words = [];
|
||||
const walker = document.createTreeWalker(paper, NodeFilter.SHOW_TEXT);
|
||||
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
||||
const text = n.nodeValue;
|
||||
if (!text || !text.trim() || skip(n)) continue;
|
||||
for (const m of text.matchAll(/\S+/g)) {
|
||||
const r = document.createRange();
|
||||
r.setStart(n, m.index);
|
||||
r.setEnd(n, m.index + m[0].length);
|
||||
const rect = r.getClientRects()[0];
|
||||
if (!rect || !rect.width) continue;
|
||||
const y = rect.top - origin.top;
|
||||
const page = Math.floor(y / cycle);
|
||||
words.push({
|
||||
text: m[0],
|
||||
page,
|
||||
x: (rect.left - origin.left) * PX_MM,
|
||||
y: (y - page * cycle) * PX_MM,
|
||||
w: rect.width * PX_MM,
|
||||
});
|
||||
}
|
||||
}
|
||||
const numPages = words.length ? Math.max(...words.map((w) => w.page)) + 1 : 1;
|
||||
const pages = Array.from({ length: numPages }, (_, i) => ({
|
||||
words: words.filter((w) => w.page === i).map(({ page, ...r }) => r),
|
||||
width: pageW * PX_MM,
|
||||
height: pageH * PX_MM,
|
||||
}));
|
||||
const mm = (v) => parseFloat(cs.getPropertyValue(v)) * PX_MM;
|
||||
return {
|
||||
pages,
|
||||
margins: { top: mm('--user-margin-top'), bottom: mm('--user-margin-bottom'),
|
||||
left: mm('--user-margin-left'), right: mm('--user-margin-right') },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- compare
|
||||
|
||||
// Both sides are reduced to the same shape before diffing: words sorted into
|
||||
// lines by vertical band, then left to right.
|
||||
function toLines(words) {
|
||||
const sorted = [...words].sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
const lines = [];
|
||||
for (const w of sorted) {
|
||||
const last = lines[lines.length - 1];
|
||||
if (last && Math.abs(w.y - last.y) <= LINE_TOL_MM) {
|
||||
last.words.push(w);
|
||||
last.y = Math.min(last.y, w.y);
|
||||
} else lines.push({ y: w.y, words: [w] });
|
||||
}
|
||||
return lines.map((l) => lineOf(l.y, l.words.sort((a, b) => a.x - b.x)));
|
||||
}
|
||||
|
||||
const round = (n) => Math.round(n * 10) / 10;
|
||||
|
||||
const lineOf = (y, words) => ({
|
||||
y: round(y), x: round(words[0].x), x2: round(Math.max(...words.map((w) => w.x + w.w))),
|
||||
text: words.map((w) => w.text).join(' '), words,
|
||||
});
|
||||
|
||||
// The editor draws list markers with CSS ::marker, which is not a text node and cannot
|
||||
// be measured; LibreOffice's PDF has them as words. Drop a leading reference word only
|
||||
// when that makes the two lines identical, so real content can never be skipped.
|
||||
function withoutMarker(line) {
|
||||
return line.words.length > 1 ? lineOf(line.y, line.words.slice(1)) : null;
|
||||
}
|
||||
|
||||
function compare(ref, ed) {
|
||||
const issues = [];
|
||||
if (ref.pages.length !== ed.pages.length) {
|
||||
issues.push({ kind: 'pageCount', ref: ref.pages.length, editor: ed.pages.length });
|
||||
}
|
||||
const n = Math.min(ref.pages.length, ed.pages.length);
|
||||
for (let p = 0; p < n; p++) {
|
||||
const r = toLines(ref.pages[p].words);
|
||||
const e = toLines(ed.pages[p].words);
|
||||
const rows = Math.max(r.length, e.length);
|
||||
for (let i = 0; i < rows; i++) {
|
||||
let a = r[i];
|
||||
const b = e[i];
|
||||
if (!a || !b) {
|
||||
issues.push({ kind: 'lineCount', page: p + 1, line: i + 1, ref: a?.text ?? null, editor: b?.text ?? null });
|
||||
break; // once the lines slip, every following row is noise
|
||||
}
|
||||
if (norm(a.text) !== norm(b.text)) {
|
||||
const trimmed = withoutMarker(a);
|
||||
if (!trimmed || norm(trimmed.text) !== norm(b.text)) {
|
||||
issues.push({ kind: 'lineBreak', page: p + 1, line: i + 1, ref: a.text, editor: b.text });
|
||||
break;
|
||||
}
|
||||
a = trimmed;
|
||||
}
|
||||
const dx = round(b.x - a.x), dy = round(b.y - a.y);
|
||||
if (Math.abs(dx) > POS_TOL_MM || Math.abs(dy) > POS_TOL_MM) {
|
||||
issues.push({ kind: 'position', page: p + 1, line: i + 1, text: a.text.slice(0, 48), dxMm: dx, dyMm: dy });
|
||||
}
|
||||
// The line's end catches what its start cannot: tab stops, justification and
|
||||
// any per-word drift that leaves the first word in place.
|
||||
const dEnd = round(b.x2 - a.x2);
|
||||
if (Math.abs(dEnd - dx) > POS_TOL_MM) {
|
||||
issues.push({ kind: 'lineEnd', page: p + 1, line: i + 1, text: a.text.slice(0, 48), dxMm: dEnd });
|
||||
}
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
// Whitespace-insensitive: spell decorations split text nodes mid-word and the two
|
||||
// engines needn't agree on word boundaries — only on what sits on a line.
|
||||
const norm = (s) => s.replace(/[\s\u00a0\u00ad]+/g, '');
|
||||
|
||||
// ------------------------------------------------------------------- driver
|
||||
|
||||
async function devServer() {
|
||||
const up = await fetch(`http://localhost:${PORT}/`).then(() => true).catch(() => false);
|
||||
if (up) return null;
|
||||
const proc = spawn('npm', ['run', 'dev', '--', '--port', String(PORT), '--strictPort'],
|
||||
{ cwd: ROOT, stdio: 'ignore', detached: true });
|
||||
for (let i = 0; i < 60; i++) {
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
if (await fetch(`http://localhost:${PORT}/`).then(() => true).catch(() => false)) return proc;
|
||||
}
|
||||
throw new Error('dev server did not start');
|
||||
}
|
||||
|
||||
function corpus(args) {
|
||||
const paths = args.length ? args : [join(HERE, 'fixtures')];
|
||||
const out = [];
|
||||
for (const p of paths) {
|
||||
const abs = resolve(p);
|
||||
const stat = existsSync(abs) && readdirSync(dirname(abs)).length >= 0;
|
||||
if (!stat) continue;
|
||||
try {
|
||||
for (const f of readdirSync(abs)) if (/\.(docx|odt)$/i.test(f)) out.push(join(abs, f));
|
||||
} catch { out.push(abs); }
|
||||
}
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const keep = args.includes('--keep');
|
||||
const jsonAt = args.includes('--json') ? args[args.indexOf('--json') + 1] : null;
|
||||
const files = corpus(args.filter((a) => !a.startsWith('--') && a !== jsonAt));
|
||||
if (!files.length) { console.error('no .docx/.odt files found'); process.exit(2); }
|
||||
|
||||
const server = await devServer();
|
||||
const browser = await chromium.launch({ executablePath: CHROME, args: ['--no-sandbox'] });
|
||||
const work = mkdtempSync(join(tmpdir(), 'parity-'));
|
||||
const report = [];
|
||||
|
||||
for (const file of files) {
|
||||
const name = basename(file);
|
||||
try {
|
||||
const ref = loRender(file, work);
|
||||
const ed = await editorRender(browser, file);
|
||||
const issues = compare(ref, ed);
|
||||
report.push({
|
||||
file: name, refPages: ref.pages.length, editorPages: ed.pages.length, issues,
|
||||
...(jsonAt ? {
|
||||
ref: ref.pages.map((p) => toLines(p.words)),
|
||||
editor: ed.pages.map((p) => toLines(p.words)),
|
||||
margins: ed.margins,
|
||||
} : {}),
|
||||
});
|
||||
console.log(`\n${issues.length ? '✗' : '✓'} ${name} (LO ${ref.pages.length}p / editor ${ed.pages.length}p)`);
|
||||
for (const i of issues.slice(0, 12)) console.log(' ', fmt(i));
|
||||
if (issues.length > 12) console.log(` … ${issues.length - 12} more`);
|
||||
} catch (err) {
|
||||
report.push({ file: name, error: String(err.message ?? err) });
|
||||
console.log(`\n! ${name} ${err.message ?? err}`);
|
||||
}
|
||||
}
|
||||
|
||||
function fmt(i) {
|
||||
if (i.kind === 'pageCount') return `pages: LO ${i.ref}, editor ${i.editor}`;
|
||||
if (i.kind === 'position') return `p${i.page} l${i.line} off by dx ${i.dxMm}mm dy ${i.dyMm}mm — "${i.text}"`;
|
||||
if (i.kind === 'lineEnd') return `p${i.page} l${i.line} ends ${i.dxMm}mm off — "${i.text}"`;
|
||||
return `p${i.page} l${i.line} ${i.kind}\n LO: ${i.ref}\n ed: ${i.editor}`;
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
if (server) process.kill(-server.pid);
|
||||
if (jsonAt) writeFileSync(jsonAt, JSON.stringify(report, null, 2));
|
||||
if (!keep) rmSync(work, { recursive: true, force: true }); else console.log(`\nartifacts: ${work}`);
|
||||
process.exit(report.some((r) => r.error || r.issues?.length) ? 1 : 0);
|
||||
Reference in new issue
Block a user