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.
233 lines
14 KiB
JavaScript
233 lines
14 KiB
JavaScript
// 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);
|