Files
Steffen Becker 055839655e test: the monkey edits both zones, and the runs compare what is drawn
Header and footer go first, each with its own ops, and a variant flag is
flipped while a zone is open. The round trip compares a list's drawn kind and
marker rather than its node type and style name — a list that loses its name to
a formatted marker carries the style's own char — and skips the marks of an
inline atom, which no file keeps. A caret is snapped to a text position, a
swallowed File-menu click is retried, and WebKit's converging ResizeObserver
round is no page error. A new corpus pair brings all six zones through the
import path; the baseline holds it and a re-recorded showcase entry.
2026-09-06 19:07:23 +02:00

233 lines
14 KiB
JavaScript
Raw Permalink 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[accept*=".odt"]', 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);