Files
edentext/tests/layout/run.mjs
T
Steffen Becker c4239a6e72 test: the layout run holds a baseline, and the monkey edits frames and columns
tests/layout/baseline.json records where each page starts and what the load cost, per
engine and platform; a page starting elsewhere is a layout change the run prints page by
page, a load over three times its recorded time a regression, and LAYOUT_UPDATE=1 records
it again. The monkey draws frame sizes, wraps, alignment and column counts too, holds the
history against the edits rather than the caches the editor recomputes, and leaves it
unchecked where the columns flow rewrote the document outside it. The differences both
importers make by design are named in the comparison; unhoist moved to normalize.ts.
2026-09-06 19:07:17 +02:00

233 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Layout run: corpus, showcase and fuzz seeds opened in the editor, the page count held
// against LibreOffice's PDF, the lines checked for overlap, margin escapes and stranded
// headings, and the page starts and load time held against tests/layout/baseline.json.
import { execFileSync, execSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, copyFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, basename, extname, relative } from 'node:path';
import { ROOT, checker, devServer, openApp, settle } from '../browser.mjs';
// `[regex]` limits the run by file name; LAYOUT_UPDATE=1 records the baseline for this
// engine and platform instead of holding the run against it.
const PORT = +(process.env.LAYOUT_PORT ?? 4188);
const SEEDS = +(process.env.LAYOUT_SEEDS ?? 10);
const UPDATE = process.env.LAYOUT_UPDATE === '1';
const only = process.argv[2] ? new RegExp(process.argv[2]) : null;
const has = (tool) => { try { execSync(`command -v ${tool}`, { stdio: 'ignore' }); return true; } catch { return false; } };
const LO = has('soffice') && has('pdfinfo');
const { check, failures } = checker();
// Line breaking is the engine's and the platform's, so each keeps its own baseline.
const BASE = join(ROOT, 'tests/layout/baseline.json');
const KEY = `${process.env.BROWSER ?? 'chromium'}/${process.platform}`;
const baseline = existsSync(BASE) ? JSON.parse(readFileSync(BASE, 'utf8')) : {};
const known = baseline[KEY] ?? {};
const fresh = {};
const files = (dir) => (existsSync(dir)
? readdirSync(dir).filter((f) => /\.(odt|docx)$/.test(f) && !f.startsWith('~$')).sort().map((f) => join(dir, f))
: []);
const docs = [...files(join(ROOT, 'tests/corpus')), ...files(join(ROOT, 'tests/corpus/word')), ...files(join(ROOT, 'docs/showcase'))];
const work = mkdtempSync(join(tmpdir(), 'layout-'));
const server = await devServer(PORT);
const { browser, page, pageErrors } = await openApp(PORT);
if (!LO) console.log('soffice/pdfinfo missing: page counts are not held against LibreOffice');
// LibreOffice's page count from its PDF, the blank pages it inserts itself kept as the
// editor keeps them; one soffice call for the whole batch, under unique names.
function loPages(paths) {
const inDir = join(work, 'in'), out = join(work, 'pdf');
mkdirSync(inDir, { recursive: true }); mkdirSync(out, { recursive: true });
const named = paths.map((p, i) => { const f = join(inDir, `${i}-${basename(p)}`); copyFileSync(p, f); return f; });
execFileSync('soffice', ['--headless', '--norestore', `-env:UserInstallation=file://${work}/profile`, '--convert-to',
'pdf:writer_pdf_Export:{"IsSkipEmptyPages":{"type":"boolean","value":"false"}}', '--outdir', out, ...named],
{ stdio: 'pipe', timeout: 900_000 });
const pages = new Map();
named.forEach((f, i) => {
const pdf = join(out, basename(f, extname(f)) + '.pdf');
const m = existsSync(pdf) && /Pages:\s+(\d+)/.exec(execFileSync('pdfinfo', [pdf], { encoding: 'utf8' }));
if (m) pages.set(paths[i], +m[1]);
});
return pages;
}
// Runs in the browser: the rendered lines against the page sheets and the document's
// margins (single-section documents only: a later section may have margins of its own).
function lintLayout() {
const paper = document.querySelector('.paper');
const origin = paper.getBoundingClientRect();
const rel = (r) => ({ top: r.top - origin.top, bottom: r.bottom - origin.top, left: r.left - origin.left, right: r.right - origin.left });
const sheets = [...document.querySelectorAll('.page-sheet')].map((s) => rel(s.getBoundingClientRect()));
const pageOf = (y) => sheets.findIndex((s) => y >= s.top - 1 && y <= s.bottom + 1);
const cs = getComputedStyle(document.documentElement);
const px = (v) => parseFloat(cs.getPropertyValue(v));
const margins = { top: px('--user-margin-top'), bottom: px('--user-margin-bottom'), left: px('--user-margin-left'), right: px('--user-margin-right') };
const oneSection = !cs.getPropertyValue('--pb-section-page').includes(',');
const mirrored = /[1-9]/.test(cs.getPropertyValue('--pb-section-mirror'));
const side = mirrored ? Math.min(margins.left, margins.right) : null;
// Where a text node is painted: a frame may sit on the text by design, a formula
// stacks its own boxes, and a hidden or spacer node is not painted at all.
const originOf = (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 null;
if (e.classList.contains('page-decor-layer') || e.classList.contains('line-number-layer')) return null;
if (e.classList.contains('hf-layer')) return 'hf';
if (e.classList.contains('textbox-node') || e.classList.contains('image-node')) return 'frame';
if (e.classList.contains('formula')) return 'formula';
if (e.classList.contains('note')) return 'note';
const s = getComputedStyle(e);
if (s.display === 'none' || s.visibility === 'hidden') return null;
}
return 'body';
};
const lines = [];
const walker = document.createTreeWalker(paper, NodeFilter.SHOW_TEXT);
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
if (!n.nodeValue.trim()) continue;
const kind = originOf(n);
if (!kind) continue;
// Between the tabs: a tab's own box runs to its stop, past a cell's edge if need be.
for (const m of n.nodeValue.matchAll(/[^\t]+/g)) {
const range = document.createRange();
range.setStart(n, m.index); range.setEnd(n, m.index + m[0].length);
for (const r of range.getClientRects()) {
if (r.width < 0.5 || r.height < 0.5) continue;
const b = rel(r);
lines.push({ ...b, kind, page: pageOf((b.top + b.bottom) / 2), text: m[0].trim().slice(0, 40) });
}
}
}
const issues = [];
const box = (b) => [b.left, b.top, b.right, b.bottom].map(Math.round).join(',');
const issue = (kind, l, extra = {}) => { if (issues.length < 60) issues.push({ kind, page: l.page + 1, text: l.text, at: l.left != null ? box(l) : undefined, ...extra }); };
// Two painted lines on one page may not share their box (a frame may, a formula's
// pieces may). ponytail: a sorted sweep, quadratic only across lines that share a band.
lines.sort((a, b) => a.top - b.top);
for (let i = 0; i < lines.length; i++) {
const a = lines[i];
if (a.kind === 'frame' || a.kind === 'formula') continue;
for (let j = i + 1; j < lines.length && lines[j].top < a.bottom; j++) {
const b = lines[j];
if (b.kind === 'frame' || b.kind === 'formula' || b.page !== a.page) continue;
const dy = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
const dx = Math.min(a.right, b.right) - Math.max(a.left, b.left);
if (dy > 0.5 * Math.min(a.bottom - a.top, b.bottom - b.top) && dx > 2) issue('overlap', a, { other: b.text, otherKind: b.kind, otherAt: box(b) });
}
}
const starts = sheets.map((_, i) => lines.find((l) => l.page === i && (l.kind === 'body' || l.kind === 'note'))?.text ?? '');
// Body text stays on a sheet and, in a single-section document, inside the margins.
for (const l of lines) {
if (l.kind !== 'body' && l.kind !== 'note') continue;
if (l.page < 0) { issue('in the page gap', l); continue; }
const s = sheets[l.page];
if (l.left < s.left - 1 || l.right > s.right + 1) issue('off the page', l, { sheet: box(s) });
if (!oneSection) continue;
const [ml, mr] = side == null ? [margins.left, margins.right] : [side, side];
const body = { left: s.left + ml, top: s.top + margins.top, right: s.right - mr, bottom: s.bottom - margins.bottom };
if (l.top < body.top - 3) issue('above the top margin', l, { body: box(body) });
if (l.bottom > body.bottom + 3) issue('below the bottom margin', l, { body: box(body) });
// ponytail: 6px of slack for a justified line's overhang and the sheet's own border.
if (l.left < body.left - 6 || l.right > body.right + 6) issue('beyond the side margin', l, { body: box(body) });
}
// A heading stays with the block it introduces, across a page and a column alike,
// unless that block asks for the break itself.
const blocks = (parent) => [...parent.children].filter((e) => !e.hasAttribute('data-page-break-spacer'));
for (const h of paper.querySelectorAll('.tiptap :is(h1, h2, h3, h4, h5, h6)')) {
if (h.closest('td, th, li, .textbox-node, .hf-layer')) continue;
const sib = blocks(h.parentElement);
const next = sib[sib.indexOf(h) + 1];
if (!next || next.classList.contains('note') || next.hasAttribute('data-page-break-before')) continue;
const hr = rel(h.getBoundingClientRect()), nr = rel(next.getBoundingClientRect());
if (hr.bottom - hr.top < 1 || nr.bottom - nr.top < 1) continue;
const l = { page: pageOf(hr.bottom - 1), text: h.textContent.trim().slice(0, 40) };
if (pageOf(nr.top + 1) !== l.page) issue('heading alone at the page end', l);
else if (h.closest('.columns-node') && nr.left > hr.left + 5) issue('heading alone at the column end', l);
}
const tiptap = document.querySelector('.tiptap');
if (tiptap.scrollWidth > tiptap.clientWidth + 1) issues.push({ kind: 'content wider than the page', page: 0, text: `${tiptap.scrollWidth}px in ${tiptap.clientWidth}px` });
return { pages: sheets.length, lines: lines.length, starts, issues };
}
const b64 = (u8) => { let s = ''; for (let i = 0; i < u8.length; i += 0x8000) s += String.fromCharCode(...u8.subarray(i, i + 0x8000)); return btoa(s); };
try {
await page.goto(`http://localhost:${PORT}/`, { waitUntil: 'load' });
await page.waitForSelector('.tiptap', { timeout: 30_000 });
// The fuzz seeds, written by the app's own exporter in the page.
mkdirSync(join(work, 'fuzz'), { recursive: true });
for (let seed = 1; seed <= SEEDS; seed++) {
const odt = await page.evaluate(async ({ seed, b64 }) => {
const [{ buildOdt }, { mulberry32 }, { genCase, exportArgs }] = await Promise.all([
import('/src/lib/export/odt.ts'), import('/tests/fuzzDoc.ts'), import('/tests/fuzzOptions.ts')]);
const { doc, opts } = genCase(mulberry32(seed));
return new Function('return ' + b64)()(await buildOdt(doc, ...exportArgs(opts)));
}, { seed, b64: b64.toString() });
const f = join(work, 'fuzz', `seed-${seed}.odt`);
writeFileSync(f, Buffer.from(odt, 'base64'));
docs.push(f);
}
const todo = docs.filter((d) => !only || only.test(d));
const retried = new Set();
const lo = LO ? loPages(todo) : new Map();
for (const file of todo) {
const name = file.startsWith(ROOT) ? relative(ROOT, file) : `fuzz/${basename(file)}`;
try {
// A fresh editor for each document, so its settle cannot be the previous one's.
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'load' });
await page.waitForSelector('.tiptap', { timeout: 30_000 });
await page.evaluate(() => { window.__loadAt = performance.now(); });
await page.setInputFiles('input.file-input', file);
// The document's name says the file is in; a document may hold nothing to wait for.
await page.waitForFunction((n) => document.querySelector('.doc-name-input')?.value.startsWith(n), basename(file, extname(file)), { timeout: 60_000 });
await settle(page, true);
// The layout's own time: from the file going in to the last change settle saw, so the
// quiet period it waits out afterwards is not part of the measurement.
const ms = await page.evaluate(() => Math.round((window.__paritySince - window.__loadAt) / 100) * 100);
const r = await page.evaluate(lintLayout);
const ref = lo.get(file);
const off = ref == null ? 0 : r.pages - ref;
// A page or a tenth either way is the slack two layout engines take.
const pagesOk = Math.abs(off) <= Math.max(1, Math.round((ref ?? 0) / 10));
// What the baseline holds: where each page starts, and what the load cost.
const prev = known[name];
fresh[name] = { pages: r.pages, lines: r.lines, ms, starts: r.starts };
const moved = prev ? r.starts.map((t, i) => [i + 1, t, prev.starts[i] ?? '—']).filter(([, a, b]) => a !== b) : [];
// ponytail: wall clock read through a 500ms poll, so only three times the recorded
// load plus a second of slack counts as a regression.
const slow = prev && ms > prev.ms * 3 + 1000 ? `${ms}ms against ${prev.ms}ms` : null;
const label = `${name} editor ${r.pages}p${ref != null ? ` / LO ${ref}p` : ''}${off && pagesOk ? ' (~)' : ''} ${r.issues.length} issue(s)${prev ? '' : ' (new)'}`;
check(pagesOk && !r.issues.length && (UPDATE || (!moved.length && !slow)), label);
if (slow) console.log(` slower than the baseline: ${slow}`);
for (const [pg, now, was] of moved.slice(0, 5)) console.log(` p${pg} starts "${now}", the baseline has "${was}"`);
if (moved.length > 5) console.log(` … ${moved.length - 5} page start(s) more`);
for (const i of r.issues.slice(0, 8)) {
const where = i.other ? ` × ${i.otherKind} "${i.other}" @${i.otherAt}` : i.body ? ` (body ${i.body})` : i.sheet ? ` (sheet ${i.sheet})` : '';
console.log(` p${i.page} ${i.kind}: "${i.text}"${i.at ? ` @${i.at}` : ''}${where}`);
}
if (r.issues.length > 8) console.log(` … ${r.issues.length - 8} more`);
} catch (err) {
// A layout that never settles is a finding of its own, not the end of the run; a
// page torn down under the probe (the app's own reload) gets one more go.
if (/Execution context was destroyed/.test(err.message) && !retried.has(file)) { retried.add(file); todo.push(file); continue; }
check(false, `${name}: ${err.message.split('\n')[0]}`);
}
}
if (UPDATE) {
const merged = Object.fromEntries(Object.entries({ ...known, ...fresh }).sort());
writeFileSync(BASE, JSON.stringify({ ...baseline, [KEY]: merged }, null, 1) + '\n');
console.log(`baseline for ${KEY}: ${Object.keys(fresh).length} document(s) recorded`);
} else if (!Object.keys(known).length) {
console.log(`no baseline for ${KEY} — record one with LAYOUT_UPDATE=1`);
}
} catch (err) {
check(false, `layout run threw: ${err.stack ?? err}`);
} finally {
check(pageErrors.length === 0, pageErrors.length ? `no uncaught page errors — got: ${pageErrors.join(' | ')}` : 'no uncaught page errors');
await browser.close();
if (server) process.kill(-server.pid);
}
process.exit(failures.length ? 1 : 0);