Files
edentext/tests/render-parity/run.mjs
T
Steffen Becker d156482a3f test: LibreOffice, the layout lint and a monkey read the editor's work back
lo-fuzz.test.ts hands every fuzz seed to LibreOffice as .odt and .docx and
compares what comes back, listing the differences LibreOffice makes by design.
test:layout opens the corpus, the showcase and fuzz seeds in the browser, holds
the page count against LibreOffice and checks the lines for overlap, margin
escapes and stranded headings. test:monkey types at random on corpus documents
and asserts the schema, undo to the start, redo, and that both saved formats
read back. browser.mjs now carries the dev server and the settle helper.
2026-09-06 19:07:16 +02:00

199 lines
9.3 KiB
JavaScript

// 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 { 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';
import { devServer, settle, extractLayout } from '../browser.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, '../..');
const CHROME = process.env.PARITY_CHROME ?? chromium.executablePath();
const PORT = +(process.env.PARITY_PORT ?? 5199); // reuse a dev server already up
const PAGE_GAP = 20; // pageBreaks.ts
const PT_MM = 25.4 / 72;
const PX_MM = 25.4 / 96;
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
// 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,
// 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, h: (a.yMax - a.yMin) * 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(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"')
.replace(/&apos;/g, "'").replace(/&amp;/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('edentext-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;
}
// ------------------------------------------------------------------- driver
function corpus(args) {
const paths = args.length ? args : [join(HERE, '..', 'corpus'), 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 {
// `~$name` is the lock file a word processor leaves beside a document it has open —
// opening a fixture to compare it by eye would otherwise add it to the corpus.
for (const f of readdirSync(abs)) if (/\.(docx|odt)$/i.test(f) && !f.startsWith('~$')) out.push(join(abs, f));
} catch { out.push(abs); }
}
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 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(PORT);
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, cache);
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,
} : {}),
});
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) {
report.push({ file: name, error: String(err.message ?? err) });
console.log(`\n! ${name} ${err.message ?? err}`);
}
}
// 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 === '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();
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);