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.
This commit is contained in:
1 parent
7203d716af
commit
f26de200fe
4 files changed
+312
-113
No files matched your search
@@ -9,32 +9,62 @@ actually looks like.
|
||||
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).
|
||||
| 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// Pure comparison half of the harness: two rendered documents in, a list of
|
||||
// differences out. Split from run.mjs so it can be tested without a browser.
|
||||
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
|
||||
const SYNC_WINDOW = 60; // lines searched for a resync after a divergence
|
||||
|
||||
// Both sides are reduced to the same shape before diffing: words sorted into
|
||||
// lines by vertical band, then left to right.
|
||||
export 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];
|
||||
// Tops within a hair, or boxes that overlap by half the shorter one: a chapter
|
||||
// number set far larger than its title shares the title's baseline but starts a
|
||||
// long way above it, and banding by top alone reads that as two lines.
|
||||
const over = last ? Math.min(w.y + (w.h ?? 0), last.y2) - Math.max(w.y, last.y) : 0;
|
||||
if (last && (Math.abs(w.y - last.y) <= LINE_TOL_MM || over >= 0.5 * Math.min(w.h ?? 0, last.y2 - last.y))) {
|
||||
last.words.push(w);
|
||||
last.y = Math.min(last.y, w.y);
|
||||
last.y2 = Math.max(last.y2, w.y + (w.h ?? 0));
|
||||
} else lines.push({ y: w.y, y2: w.y + (w.h ?? 0), 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;
|
||||
}
|
||||
|
||||
// One stream per side, in reading order: the two are aligned against each other as a
|
||||
// whole, so a line the editor drops costs one report instead of every line after it.
|
||||
const stream = (doc) => doc.pages.flatMap((p, i) =>
|
||||
toLines(p.words).map((l, k) => ({ ...l, page: i + 1, line: k + 1, n: norm(l.text) })));
|
||||
|
||||
// The reference line as it has to be read to match `b` — with its list marker dropped
|
||||
// where that is what makes the two identical — or null when they are different lines.
|
||||
function match(a, b) {
|
||||
if (a.n === b.n) return a; // normalized once per line, in `stream`
|
||||
const t = withoutMarker(a);
|
||||
return t && norm(t.text) === b.n ? { ...a, ...t, n: b.n } : null;
|
||||
}
|
||||
|
||||
// The nearest pair of skips that puts the two streams back on the same line. Two
|
||||
// consecutive matches are required: a page number or a lone word matches by accident
|
||||
// often enough to resync on the wrong line and report the whole document as broken.
|
||||
function resync(r, e, i, j) {
|
||||
for (let d = 1; d <= 2 * SYNC_WINDOW; d++) {
|
||||
for (let a = Math.max(0, d - SYNC_WINDOW); a <= Math.min(d, SYNC_WINDOW); a++) {
|
||||
const b = d - a;
|
||||
if (i + a >= r.length || j + b >= e.length || !match(r[i + a], e[j + b])) continue;
|
||||
const next = i + a + 1 >= r.length || j + b + 1 >= e.length || match(r[i + a + 1], e[j + b + 1]);
|
||||
if (next) return [a, b];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// One cause, one report: neighbouring lines off by the same amount on the same page are
|
||||
// one strut or spacing or band height, and a stretch of lines that never match — a
|
||||
// contents block whose numbers are all off by one — is one region, not one report a row.
|
||||
function collapse(issues) {
|
||||
const out = [];
|
||||
for (const i of issues) {
|
||||
const last = out[out.length - 1];
|
||||
const geo = i.kind === 'position' || i.kind === 'lineEnd';
|
||||
if (last && last.kind === i.kind && geo
|
||||
&& last.page === i.page && last.dxMm === i.dxMm && last.dyMm === i.dyMm) last.lines++;
|
||||
// Contiguous in the reference stream, not merely one report after the other:
|
||||
// divergences with clean text between them are separate causes.
|
||||
else if (last && last.kind === i.kind && !geo && last.at + last.refLines === i.at) {
|
||||
last.refLines += i.refLines; last.edLines += i.edLines;
|
||||
} else out.push({ ...i, ...(geo ? { lines: 1 } : {}) });
|
||||
}
|
||||
for (const i of out) delete i.at;
|
||||
return out;
|
||||
}
|
||||
|
||||
export 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 r = stream(ref), e = stream(ed);
|
||||
let i = 0, j = 0, shift = 0;
|
||||
while (i < r.length && j < e.length) {
|
||||
const a = match(r[i], e[j]);
|
||||
if (!a) {
|
||||
// No resync in reach: report this line and step both sides, rather than declaring
|
||||
// the documents parted — a contents block of numbers all off by one has no two
|
||||
// consecutive matching rows, and giving up there hides everything behind it.
|
||||
const [da, db] = resync(r, e, i, j) ?? [1, 1];
|
||||
issues.push({
|
||||
kind: da && db ? 'lineBreak' : 'lineCount', page: r[i].page, line: r[i].line,
|
||||
ref: r[i].text, editor: e[j].text, refLines: da, edLines: db, at: i,
|
||||
});
|
||||
i += da; j += db;
|
||||
continue;
|
||||
}
|
||||
const b = e[j];
|
||||
if (b.page - a.page !== shift) {
|
||||
shift = b.page - a.page;
|
||||
issues.push({ kind: 'pageShift', page: a.page, edPage: b.page, by: shift, text: a.text.slice(0, 48) });
|
||||
}
|
||||
const dx = round(b.x - a.x), dy = round(b.y - a.y);
|
||||
const loc = { page: a.page, line: a.line, ...(shift ? { edPage: b.page } : {}) };
|
||||
if (Math.abs(dx) > POS_TOL_MM || Math.abs(dy) > POS_TOL_MM) {
|
||||
issues.push({ kind: 'position', ...loc, 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', ...loc, text: a.text.slice(0, 48), dxMm: dEnd });
|
||||
}
|
||||
i++; j++;
|
||||
}
|
||||
if (i < r.length || j < e.length) {
|
||||
issues.push({ kind: 'lineCount', page: (r[i] ?? e[j]).page, line: (r[i] ?? e[j]).line, at: i,
|
||||
ref: r[i]?.text ?? null, editor: e[j]?.text ?? null, refLines: r.length - i, edLines: e.length - j });
|
||||
}
|
||||
return collapse(issues);
|
||||
}
|
||||
|
||||
// Whitespace-insensitive, and blind to the invisible joiners a note anchor leaves
|
||||
// behind: the engines needn't agree on word boundaries, only on what sits on a line. A
|
||||
// tab or index leader collapses to one token too — both end the fill at the same stop.
|
||||
const norm = (s) => s.replace(/[\s\u00a0\u00ad\u200b\u2060\ufeff]+/g, '').replace(/([.\u00b7_-])\1{2,}/g, '\u2026');
|
||||
|
||||
+78
-104
@@ -3,11 +3,13 @@
|
||||
// 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 { readFileSync, writeFileSync, copyFileSync, mkdirSync, mkdtempSync, readdirSync, rmSync, existsSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, basename, extname, resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { chromium } from 'playwright-core';
|
||||
import { compare, toLines } from './compare.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(HERE, '../..');
|
||||
@@ -16,25 +18,37 @@ const PORT = +(process.env.PARITY_PORT ?? 5199); // reuse a dev server already
|
||||
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
|
||||
const QUICK_PAGES = 50; // --quick skips what last measured longer than this
|
||||
const CACHE = process.env.PARITY_CACHE ?? join(ROOT, 'node_modules/.cache/render-parity');
|
||||
// Keep the blank pages LibreOffice inserts itself (a chapter forced onto a right page):
|
||||
// its PDF export drops them by default and the reference then loses a sheet the
|
||||
// document really has.
|
||||
const LO_ARGS = ['--convert-to',
|
||||
'pdf:writer_pdf_Export:{"IsSkipEmptyPages":{"type":"boolean","value":"false"}}'];
|
||||
|
||||
// ---------------------------------------------------------------- reference
|
||||
|
||||
function loRender(file, work) {
|
||||
execFileSync('soffice', [
|
||||
'--headless', '--norestore', `-env:UserInstallation=file://${work}/loprofile`,
|
||||
// Keep the blank pages LibreOffice inserts itself (a chapter forced onto a right
|
||||
// page): its PDF export drops them by default and the reference then loses a sheet
|
||||
// the document really has.
|
||||
'--convert-to', 'pdf:writer_pdf_Export:{"IsSkipEmptyPages":{"type":"boolean","value":"false"}}',
|
||||
'--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 };
|
||||
// The reference depends on the file and the export arguments, never on our code, so a
|
||||
// re-run after an editor change reuses it: the corpus measured 9:00 cold, 5:41 cached.
|
||||
// `--no-cache` after installing or removing a font, which does change what LO renders.
|
||||
function loRender(file, work, cache) {
|
||||
const key = createHash('sha1').update(LO_ARGS.join('\0')).update(readFileSync(file)).digest('hex').slice(0, 16);
|
||||
const pdf = join(CACHE, key + '.pdf');
|
||||
const xml = join(CACHE, key + '.xml');
|
||||
const hit = cache && existsSync(xml);
|
||||
if (!hit) {
|
||||
execFileSync('soffice', [
|
||||
'--headless', '--norestore', `-env:UserInstallation=file://${work}/loprofile`,
|
||||
...LO_ARGS, '--outdir', work, file,
|
||||
], { stdio: 'pipe', timeout: 120_000 });
|
||||
const out = join(work, basename(file, extname(file)) + '.pdf');
|
||||
if (!existsSync(out)) throw new Error(`LibreOffice produced no PDF for ${file}`);
|
||||
execFileSync('pdftotext', ['-bbox-layout', out, join(work, 'ref.xml')], { stdio: 'pipe' });
|
||||
mkdirSync(CACHE, { recursive: true });
|
||||
copyFileSync(out, pdf);
|
||||
copyFileSync(join(work, 'ref.xml'), xml);
|
||||
}
|
||||
return { pages: parseBbox(readFileSync(xml, 'utf8')), pdf, cached: hit };
|
||||
}
|
||||
|
||||
// pdftotext -bbox-layout emits <page><flow><block><line><word>, coords in pt,
|
||||
@@ -192,87 +206,6 @@ function extractLayout() {
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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];
|
||||
// Tops within a hair, or boxes that overlap by half the shorter one: a chapter
|
||||
// number set far larger than its title shares the title's baseline but starts a
|
||||
// long way above it, and banding by top alone reads that as two lines.
|
||||
const over = last ? Math.min(w.y + (w.h ?? 0), last.y2) - Math.max(w.y, last.y) : 0;
|
||||
if (last && (Math.abs(w.y - last.y) <= LINE_TOL_MM || over >= 0.5 * Math.min(w.h ?? 0, last.y2 - last.y))) {
|
||||
last.words.push(w);
|
||||
last.y = Math.min(last.y, w.y);
|
||||
last.y2 = Math.max(last.y2, w.y + (w.h ?? 0));
|
||||
} else lines.push({ y: w.y, y2: w.y + (w.h ?? 0), 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 engines
|
||||
// needn't agree on word boundaries, only on what sits on a line. A tab or index leader
|
||||
// collapses to one token too — both engines end the fill at the same stop.
|
||||
const norm = (s) => s.replace(/[\s\u00a0\u00ad]+/g, '').replace(/([.\u00b7_-])\1{2,}/g, '\u2026');
|
||||
|
||||
// ------------------------------------------------------------------- driver
|
||||
|
||||
async function devServer() {
|
||||
@@ -303,10 +236,24 @@ function corpus(args) {
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
// The last run's issue count per file, so a re-run prints what a change moved instead
|
||||
// of leaving the arithmetic to whoever reads two reports side by side.
|
||||
const BASELINE = join(CACHE, 'baseline.json');
|
||||
const readBaseline = () => {
|
||||
try { return JSON.parse(readFileSync(BASELINE, 'utf8')); } catch { return {}; }
|
||||
};
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const keep = args.includes('--keep');
|
||||
const cache = !args.includes('--no-cache');
|
||||
const jsonAt = args.includes('--json') ? args[args.indexOf('--json') + 1] : null;
|
||||
const files = corpus(args.filter((a) => !a.startsWith('--') && a !== jsonAt));
|
||||
const baseline = args.includes('--no-baseline') ? null : readBaseline();
|
||||
let files = corpus(args.filter((a) => !a.startsWith('--') && a !== jsonAt));
|
||||
if (args.includes('--quick')) {
|
||||
const long = files.filter((f) => (baseline?.[basename(f)]?.refPages ?? 0) > QUICK_PAGES);
|
||||
files = files.filter((f) => !long.includes(f));
|
||||
if (long.length) console.log(`--quick: skipping ${long.length} document(s) over ${QUICK_PAGES} pages`);
|
||||
}
|
||||
if (!files.length) { console.error('no .docx/.odt files found'); process.exit(2); }
|
||||
|
||||
const server = await devServer();
|
||||
@@ -317,7 +264,7 @@ const report = [];
|
||||
for (const file of files) {
|
||||
const name = basename(file);
|
||||
try {
|
||||
const ref = loRender(file, work);
|
||||
const ref = loRender(file, work, cache);
|
||||
const ed = await editorRender(browser, file);
|
||||
const issues = compare(ref, ed);
|
||||
report.push({
|
||||
@@ -328,7 +275,9 @@ for (const file of files) {
|
||||
margins: ed.margins,
|
||||
} : {}),
|
||||
});
|
||||
console.log(`\n${issues.length ? '✗' : '✓'} ${name} (LO ${ref.pages.length}p / editor ${ed.pages.length}p)`);
|
||||
const was = baseline?.[name]?.issues;
|
||||
console.log(`\n${issues.length ? '✗' : '✓'} ${name} (LO ${ref.pages.length}p / editor ${ed.pages.length}p)`
|
||||
+ ` ${issues.length} ${delta(issues.length, was)}${ref.cached ? ' [cached]' : ''}`);
|
||||
for (const i of issues.slice(0, 12)) console.log(' ', fmt(i));
|
||||
if (issues.length > 12) console.log(` … ${issues.length - 12} more`);
|
||||
} catch (err) {
|
||||
@@ -337,11 +286,36 @@ for (const file of files) {
|
||||
}
|
||||
}
|
||||
|
||||
// Only what this run measured: a single-fixture run says what that fixture moved and
|
||||
// leaves the other files' recorded counts alone.
|
||||
const done = report.filter((r) => !r.error);
|
||||
const total = done.reduce((n, r) => n + r.issues.length, 0);
|
||||
const seen = done.filter((r) => baseline?.[r.file]);
|
||||
const before = seen.reduce((n, r) => n + baseline[r.file].issues, 0);
|
||||
console.log(`\ntotal ${total} across ${done.length} document(s)`
|
||||
+ (seen.length === done.length ? ` ${delta(total, before)}`
|
||||
: seen.length ? ` (${done.length - seen.length} without a baseline)` : ''));
|
||||
|
||||
if (baseline) {
|
||||
for (const r of done) baseline[r.file] = { issues: r.issues.length, refPages: r.refPages };
|
||||
mkdirSync(CACHE, { recursive: true });
|
||||
writeFileSync(BASELINE, JSON.stringify(baseline, null, 2));
|
||||
}
|
||||
|
||||
function delta(now, was) {
|
||||
if (was == null) return '(new)';
|
||||
return now === was ? '(unchanged)' : `(was ${was}, ${now > was ? '+' : '\u2212'}${Math.abs(now - was)})`;
|
||||
}
|
||||
|
||||
function fmt(i) {
|
||||
const at = `p${i.page}${i.edPage ? `\u2192ed p${i.edPage}` : ''} l${i.line}`;
|
||||
const n = i.lines > 1 ? ` \u00d7${i.lines}` : '';
|
||||
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}`;
|
||||
if (i.kind === 'pageShift') return `p${i.page}: the editor runs ${i.by > 0 ? '+' : ''}${i.by} page(s) from here — "${i.text}"`;
|
||||
if (i.kind === 'position') return `${at}${n} off by dx ${i.dxMm}mm dy ${i.dyMm}mm — "${i.text}"`;
|
||||
if (i.kind === 'lineEnd') return `${at}${n} ends ${i.dxMm}mm off — "${i.text}"`;
|
||||
return `p${i.page} l${i.line} ${i.kind} (LO ${i.refLines} / ed ${i.edLines} line(s))`
|
||||
+ `\n LO: ${i.ref}\n ed: ${i.editor}`;
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
// @ts-expect-error — the parity harness is plain .mjs, deliberately untyped
|
||||
import { compare } from '../render-parity/compare.mjs';
|
||||
|
||||
// The comparison aligns the two documents as a whole, so one dropped or displaced
|
||||
// line costs one report instead of every line after it.
|
||||
const doc = (pages: [string, number][][]) => ({
|
||||
pages: pages.map((lines) => ({
|
||||
words: lines.map(([text, y]) => ({ text, x: 20, y, w: 50, h: 4 })),
|
||||
width: 210, height: 297,
|
||||
})),
|
||||
});
|
||||
|
||||
describe('render-parity comparison', () => {
|
||||
it('reports a line the editor drops once, then resyncs', () => {
|
||||
const ref = doc([[['A', 10], ['B', 20], ['C', 30], ['D', 40]]]);
|
||||
const ed = doc([[['A', 10], ['C', 30], ['D', 40]]]);
|
||||
const issues = compare(ref, ed);
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]).toMatchObject({ kind: 'lineCount', refLines: 1, edLines: 0, ref: 'B' });
|
||||
});
|
||||
|
||||
it('reports a page the content slipped onto once, not once per line', () => {
|
||||
const ref = doc([[['A', 10], ['B', 30]], [['C', 10], ['D', 30]]]);
|
||||
const ed = doc([[['A', 10], ['B', 30]], [], [['C', 10], ['D', 30]]]);
|
||||
const issues = compare(ref, ed);
|
||||
const shifts = issues.filter((i: { kind: string }) => i.kind === 'pageShift');
|
||||
expect(shifts).toHaveLength(1);
|
||||
expect(shifts[0]).toMatchObject({ by: 1, page: 2, edPage: 3, text: 'C' });
|
||||
expect(issues.some((i: { kind: string }) => i.kind.startsWith('line'))).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps going where no resync is in reach, and folds the region into one report', () => {
|
||||
const ref = doc([[['A', 10], ['X1', 20], ['X2', 30]]]);
|
||||
const ed = doc([[['A', 10], ['Y1', 20], ['Y2', 30]]]);
|
||||
const issues = compare(ref, ed);
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]).toMatchObject({ kind: 'lineBreak', refLines: 2, edLines: 2, ref: 'X1' });
|
||||
});
|
||||
|
||||
// Two matching lines, not one: a resync is only trusted where two consecutive lines
|
||||
// match again, so a single agreeing line between two divergences is part of the region.
|
||||
it('leaves two divergences with matching text between them apart', () => {
|
||||
const ref = doc([[['A', 10], ['X', 20], ['B', 30], ['C', 40], ['Y', 50], ['D', 60]]]);
|
||||
const ed = doc([[['A', 10], ['P', 20], ['B', 30], ['C', 40], ['Q', 50], ['D', 60]]]);
|
||||
expect(compare(ref, ed)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('folds a run of lines off by the same amount into one report', () => {
|
||||
const ref = doc([[['A', 10], ['B', 20], ['C', 30]]]);
|
||||
const ed = doc([[['A', 12], ['B', 22], ['C', 32]]]);
|
||||
const issues = compare(ref, ed);
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]).toMatchObject({ kind: 'position', dyMm: 2, lines: 3 });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user