Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/dev/verify_typstwatch.mjs

68.1 KiB, 1 run

created by r2519314175:777, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1// verify_typstwatch.mjs — the watched live document: it follows the file, it keeps
2// the reader's place, it never blanks, and it refuses rather than bricking.
3//
4// The author's ask was `typst watch` plus a document viewer, inside Daimond: he
5// edits a `.typ` — or a daimon does — and the pages he is looking at become the new
6// ones, in place. What that turns into is a loop, and a loop has failure modes a
7// button does not:
8//
9// 1. IT MUST FOLLOW BOTH ACTORS. His editor and a daimon's `file_write` both
10// count as "the source changed". Asserted by the WORDS ON THE PAGE, read back
11// out of the rendered document, not by a counter that could tick for any
12// reason.
13//
14// 2. IT MUST NOT MOVE HIM. A rebuild puts the new pages up at the page and the
15// offset he was reading, not at the top and not at "the same fraction of a
16// longer book".
17//
18// 3. IT MUST NEVER BLANK. Not for one frame. This is the check the whole design
19// rests on, so it is measured on EVERY ANIMATION FRAME across a rebuild
20// rather than sampled before and after — a swap that empties the host for two
21// frames is invisible to a check that looks at either end, and is exactly what
22// a reader notices. The frame count is asserted too: a sampler that saw three
23// frames did not watch a rebuild and proves nothing.
24//
25// 4. A BROKEN SOURCE MUST COST HIM NOTHING BUT THE ERROR. The last good pages
26// stay, with typst's own words — file and line — under them. That is the whole
27// reason `typst watch` is usable, and it is asserted against the compiler's
28// real diagnostic, not against a string this file made up.
29//
30// 5. IT MUST REFUSE RATHER THAN BRICK. Measured in dev/TYPST_WATCH.md: one
31// document too big for the wasm heap traps the compiler, and EVERY LATER
32// COMPILE then fails with `recursive use of an object detected`, for the life
33// of the page. A one-shot button hides that; a loop finds it. So the loop
34// budgets the heap and stops before the wall, and that is checked at the
35// limit rather than assumed from the code.
36//
37// 6. IT MUST LOOK LIKE A DOCUMENT. A stack of sheets with space between them, not
38// one continuous page — which is what the author saw and said so about. Checked
39// as geometry AND as pixels: separate elements with a gap prove nothing on their
40// own if what fills the gap is more paper.
41//
42// 7. AND IT MUST OFFER WHAT A DOCUMENT VIEWER OFFERS. A rail of the document's own
43// sections, hyperlinked. The entries come from the compiler; the page each one
44// is on cannot (measured — `query('heading', 'location')` answers `[]`), so it
45// comes from the laid-out pages. The check is against a fixture whose pages are
46// decided by `#pagebreak()`, and which carries a CONTENTS: every heading's words
47// appear on page one, so a rail that matched on words alone would put the whole
48// document there.
49//
50// 8. AND NONE OF IT MAY REACH THE COMPILER. The rail, the zoom, the fit and the
51// paper are all VIEW: the layout does not depend on any of them. A control that
52// compiled is how the loop that filled 2427 MB in front of the author gets built
53// again.
54//
55// AND THE CLAIM THE WHOLE RENDERING RESTS ON, CHECKED AGAINST AN OUTSIDE TOOL.
56// The live view is NOT a PDF — writing the PDF costs about eight times what laying
57// the pages out does, and Chrome's PDF viewer will not say where the reader is. The
58// user's condition was "as long as it looks precisely the same". So one page is
59// compiled both ways from one source, the PDF is rasterised by POPPLER — which has
60// nothing to do with typst, and is the only thing here that is not our own code
61// agreeing with itself — the live view is photographed at the same size, and the
62// ink is compared. Anti-aliasing differs. Nothing structural may.
63//
64// EACH CHECK IS PROVED AGAINST BROKEN CODE FIRST. `--break <name>` serves a
65// deliberately damaged copy of a real source file to the real page, and the run is
66// expected to FAIL. A break whose anchor does not appear exactly once aborts rather
67// than passing quietly.
68//
69// node dev/verify_typstwatch.mjs --break nonudge # 1 fails: nothing follows the file
70// node dev/verify_typstwatch.mjs --break resetscroll # 2 fails: back to the top
71// node dev/verify_typstwatch.mjs --break blank # 3 fails: a frame with no ink
72// node dev/verify_typstwatch.mjs --break dropgood # 4 fails: the good pages go
73// node dev/verify_typstwatch.mjs --break stickyerror # 5 fails: the error never clears
74// node dev/verify_typstwatch.mjs --break nodebounce # 6 fails: a burst is a burst
75// node dev/verify_typstwatch.mjs --break nostop # 7 fails: it compiles after closing
76// node dev/verify_typstwatch.mjs --break noguard # 8 fails: it walks into the wall
77// node dev/verify_typstwatch.mjs --break skewpages # 9 fails: the pages are not the PDF
78// node dev/verify_typstwatch.mjs --break tallsvg # 10 fails: a page deep in the book
79// # is a pale wash
80// node dev/verify_typstwatch.mjs --break versionskew # 11 fails: the two wasms disagree
81// node dev/verify_typstwatch.mjs --break viewcompiles# 12 fails: a control reaches the compiler
82// node dev/verify_typstwatch.mjs --break runtogether # 13 fails: the pages run together
83// node dev/verify_typstwatch.mjs --break railwrongpage # 14 fails: a section names the
84// # wrong page
85// node dev/verify_typstwatch.mjs --break oldwords # 15 fails: the bar's old words
86// node dev/verify_typstwatch.mjs --break pagebox # 16 fails: the page number is
87// # clipped out of its own box
88// node dev/verify_typstwatch.mjs --break driftpages # 17 fails: the ink slides down
89// # its own paper, a little
90// # further on every page
91// node dev/verify_typstwatch.mjs # and then, clean
92//
93// eval "$(bash dev/world.sh 13 --up)"
94// node dev/verify_typstwatch.mjs
95//
96// Needs dev/serve.mjs and `pdftoppm` (poppler-utils). No gateway, no model: every
97// compile here is the real vendored typst, and the only network is localhost.
98import fs from 'node:fs';
99import path from 'node:path';
100import { execFileSync } from 'node:child_process';
101import { fileURLToPath } from 'node:url';
102import { open, shot, scratch, errors } from './harness.mjs';
103
104const HERE = path.dirname(fileURLToPath(import.meta.url));
105const WWW = path.join(HERE, '..', 'www');
106
107const BREAK = (() => {
108 const i = process.argv.indexOf('--break');
109 return i > 0 ? String(process.argv[i + 1] || '') : '';
110})();
111
112const PROFILE = scratch('pw', 'typstwatch' + (BREAK ? '-' + BREAK : ''));
113fs.rmSync(PROFILE, { recursive: true, force: true });
114const WORK = scratch('typstwatch' + (BREAK ? '-' + BREAK : ''), 'ink');
115fs.rmSync(WORK, { recursive: true, force: true });
116fs.mkdirSync(WORK, { recursive: true });
117
118const ok = [], bad = [];
119const check = (name, pass, detail) => {
120 (pass ? ok : bad).push(name);
121 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
122};
123
124// ── The breaks ───────────────────────────────────────────────────────
125// Each is a real edit to a real file, served in place of it.
126const BREAKS = {
127 // Neither actor reaches the loop: the poll finds nothing and a write says
128 // nothing. The view then only ever changes when a person presses Compile,
129 // which is the state this feature exists to leave behind.
130 nonudge: [{
131 file: 'js/typstwatch.js',
132 find: 'function nudge(cause, detail) {\n\tif (S.mode !== \'live\') return;',
133 with: 'function nudge(cause, detail) {\n\tif (S.mode !== \'live\' || true) return;',
134 }],
135 // The rebuild lands at the top of the document, throwing away the reader's
136 // place. This is what a naive swap does, and it is why `where`/`goTo` exist.
137 resetscroll: [{
138 file: 'js/typstwatch.js',
139 find: '\tif (was) goTo(was); else if (sc) sc.scrollTop = 0;',
140 with: '\tif (sc) sc.scrollTop = 0;',
141 }],
142 // The host is emptied before the new pages are ready — one or two frames of
143 // nothing, which is invisible to any check that looks only before and after.
144 blank: [{
145 file: 'js/typstwatch.js',
146 find: '\tconst pages = host.querySelector(\'.tl-pages\');\n'
147 + '\t// The geometry has to be in place before a band can be asked for, since',
148 with: '\tconst pages = host.querySelector(\'.tl-pages\');\n'
149 + '\tpages.shadowRoot.replaceChildren();\n'
150 + '\tawait new Promise(function (res) { requestAnimationFrame(function () { '
151 + 'requestAnimationFrame(res); }); });\n'
152 + '\t// The geometry has to be in place before a band can be asked for, since',
153 }],
154 // A failed build takes the document with it. The reader loses the page they were
155 // reading every time a brace is mistyped.
156 //
157 // It throws away the KEPT BYTES as well as the marks, and that is not belt and
158 // braces — it is what "the last good view is not kept" means. Emptying only the
159 // DOM does not go red, because showing the error grows the error strip, the
160 // scroller resizes, and the repaint that follows draws the pages again out of the
161 // bytes. Worth knowing: the view heals itself from a stray blanking. It cannot
162 // heal from having nothing to draw.
163 dropgood: [{
164 file: 'js/typstwatch.js',
165 find: '\t\tS.failed++;\n\t\tconst why = (out && out.error) ? String(out.error) : tOr(\'typst.watch.nothing\',',
166 with: '\t\tS.failed++;\n\t\tvec = null;\n'
167 + '\t\thost.querySelector(\'.tl-pages\').shadowRoot.replaceChildren();\n'
168 + '\t\tconst why = (out && out.error) ? String(out.error) : tOr(\'typst.watch.nothing\',',
169 }],
170 // The error outlives the fault: the source is right again and the screen still
171 // says it is wrong, which teaches a reader to stop believing the message.
172 stickyerror: [{
173 file: 'js/typstwatch.js',
174 find: '\t\t\tshowError(\'\');\n'
175 + '\t\t\t// A HELD LOOP IS NOT UNHELD BY A BUILD SUCCEEDING.',
176 with: '\t\t\t// A HELD LOOP IS NOT UNHELD BY A BUILD SUCCEEDING.',
177 }],
178 // Every write is its own rebuild. A fast typist queues twenty compiles and the
179 // preview runs minutes behind the text.
180 nodebounce: [{
181 file: 'js/typstwatch.js',
182 find: '\tif (timer) clearTimeout(timer);\n\ttimer = setTimeout(function () { timer = null; build(false); }, S.debounce);',
183 with: '\tbuild(false);',
184 }],
185 // Closing the document does not end the watch, so the compiler keeps working on
186 // a file nobody is looking at.
187 nostop: [{
188 file: 'js/typstwatch.js',
189 find: '\tif (S.seen && !shown) {\n\t\tstop();\n\t\treturn;\n\t}',
190 with: '\tif (false) { stop(); return; }',
191 }],
192 // No budget at all: the loop starts a rebuild whatever the heap says, which is
193 // how it eventually finds the wall and dies there.
194 noguard: [{
195 file: 'js/typstwatch.js',
196 find: '\tif (heap + S.headroom <= S.budget) return \'\';',
197 with: '\tif (true) return \'\';',
198 }],
199 // The pages are drawn at the wrong scale. Nothing reflows and every glyph is
200 // still there, so a check counting elements passes — only the INK says so.
201 //
202 // BY A CONSTANT, AND THAT IS THE HALF IT PROVES. Fourteen points off every page is
203 // fourteen points off PAGE ONE, which is the page the ink comparison photographs.
204 // An error that starts at nothing and grows walks straight past it — see
205 // `driftpages`, which is the same line moved by an amount that depends on the page.
206 skewpages: [{
207 file: 'js/typstwatch.js',
208 find: '\t\tsvg.setAttribute(\'viewBox\', \'0 \' + y + \' \' + S.docW + \' \' + S.heights[i]);',
209 with: '\t\tsvg.setAttribute(\'viewBox\', \'0 \' + (y - 14) + \' \' + S.docW + \' \' + S.heights[i]);',
210 }],
211 // THE DEFECT AS IT SHIPPED, in the shape it actually had. The crop was taken at the
212 // EXACT running sum of the page heights while the renderer places each page at a
213 // running sum of WHOLE-POINT heights, so the two separated by 0.46 pt a page on the
214 // 453.543 pt fixture and never met again: 11 pt by page 26, 45 pt by page 100,
215 // 105 pt by page 230. The paper stayed exactly where it belonged and the INK inside
216 // it slid down — a white band opening at the head of every page and the last lines
217 // pushed off the foot — which the author reported as a gradual violation of the
218 // margins, and which every one of the checks in this file was blind to.
219 //
220 // IT MUST BE AN ACCUMULATING ERROR AND NOT A CONSTANT ONE. `skewpages` above is
221 // caught by the ink comparison because a constant reaches page one; this reaches
222 // page one by nothing at all, and only a check that looks at a page deep in the book
223 // AND at where its ink sits — not how much of it there is — goes red.
224 driftpages: [{
225 file: 'js/typstwatch.js',
226 find: '\t\tsvg.setAttribute(\'viewBox\', \'0 \' + y + \' \' + S.docW + \' \' + S.heights[i]);',
227 with: '\t\tsvg.setAttribute(\'viewBox\', \'0 \' + (y - 0.46 * i) + \' \' + S.docW + \' \' + S.heights[i]);',
228 }],
229 // The SVG is made as tall as the whole book again, with only the visible band
230 // drawn in it — which is the obvious way to do this and the way that fails. Every
231 // mark is still in the DOM and the page count is still right; the pixels are what
232 // go wrong, and only past about thirty thousand of them down.
233 //
234 // The element the browser is asked to rasterise is the height of the WHOLE BOOK
235 // again, with the page pulled into place inside it — which is the obvious way to
236 // draw this and the way that failed. The sheets stay where they are and the reader
237 // still sees the right page, so this goes red for the SIZE OF THE ELEMENT and for
238 // nothing else.
239 //
240 // WHAT IT NO LONGER PROVES, AND THIS IS WORTH KNOWING. The pale wash itself did
241 // not come back: on a 251-page document, an SVG 64,000 px tall inside a sheet that
242 // CLIPS IT TO ONE PAGE rasterised perfectly all the way down. The clip is what
243 // saves it. So the pixel check below stands as the guarantee, and the assertion
244 // this break reddens is the structural one — nothing drawn is taller than a page —
245 // which is the mechanism the pixels were a symptom of.
246 tallsvg: [{
247 file: 'js/typstwatch.js',
248 find: '\t\tsvg.setAttribute(\'viewBox\', \'0 \' + y + \' \' + S.docW + \' \' + S.heights[i]);\n'
249 + '\t\tsvg.setAttribute(\'width\', String(S.docW * scale));\n'
250 + '\t\tsvg.setAttribute(\'height\', String(S.heights[i] * scale));',
251 with: '\t\tsvg.setAttribute(\'viewBox\', \'0 0 \' + S.docW + \' \' + S.docH);\n'
252 + '\t\tsvg.setAttribute(\'width\', String(S.docW * scale));\n'
253 + '\t\tsvg.setAttribute(\'height\', String(S.docH * scale));\n'
254 + '\t\tsvg.style.position = \'absolute\';\n'
255 + '\t\tsvg.style.top = (-y * scale) + \'px\';',
256 }],
257 // A VIEW CHANGE STARTS A COMPILE. The rail is a control like the zoom and the
258 // paper: it shows what the last build already answered. A control that reached
259 // for the compiler is how the loop that filled 2427 MB of heap in front of the
260 // author gets built a second time.
261 viewcompiles: [{
262 file: 'js/typstwatch.js',
263 find: '\tif (S.rail) locate();',
264 with: '\tif (S.rail) { locate(); build(false); }',
265 }],
266 // The sheets touch again, so the document reads as one continuous page — which is
267 // what the author saw and said so about.
268 runtogether: [{
269 file: 'js/typstwatch.js',
270 find: '\t\tacc += S.heights[i] + PAGE_GAP;',
271 with: '\t\tacc += S.heights[i];',
272 }],
273 // A rail entry points at the wrong page: the FIRST place the words appear wins
274 // rather than the largest setting of them, so every entry in a document with a
275 // contents points at the contents. Each entry is still on a real page, and they
276 // are still in order — which is exactly why "each entry is valid" and "the
277 // entries are in order" are not between them enough to catch it.
278 railwrongpage: [{
279 file: 'js/typstwatch.js',
280 find: '\t\t\tif (!best || c.size > best.size + 0.05) best = c;',
281 with: '\t\t\tif (!best) best = c;',
282 }],
283 // The page box loses the rule that keeps it a toolbar control, and the skin's
284 // generous `input[type="text"]` padding takes it back: a box whose content is
285 // nought pixels wide, with the page number clipped away inside it. This is the
286 // break for a defect that was FOUND rather than invented — the box had been empty
287 // since the day it shipped and nothing said so, because a check on the VALUE
288 // passes on a number the reader cannot see.
289 pagebox: [{
290 file: 'css/viewer.css',
291 find: '#typst-live .tl-page {\n\tpadding: 1px 4px;\n\theight: auto;\n'
292 + '\tmin-height: 0;\n\tborder-radius: var(--radius-sm);\n}',
293 with: '#typst-live .tl-page-disabled-for-this-run { padding: 1px 4px; }',
294 }],
295 // The words the author asked to have changed, back as they were.
296 oldwords: [{
297 file: 'js/typstwatch.js',
298 find: '\t\t\t: tOr(\'typst.watch.live_preview\', \'Live preview\'), S.error ? \'stale\' : \'live\');',
299 with: '\t\t\t: tOr(\'typst.watch.live\', \'Live\'), S.error ? \'stale\' : \'live\');',
300 }, {
301 file: 'js/typstwatch.js',
302 find: '\tb.textContent = S.dark ? tOr(\'typst.watch.paper_light\', \'Light\')\n'
303 + '\t\t: tOr(\'typst.watch.paper_dark\', \'Dark\');',
304 with: '\tb.textContent = S.dark ? tOr(\'typst.watch.day\', \'Light paper\')\n'
305 + '\t\t: tOr(\'typst.watch.night\', \'Dark paper\');',
306 }],
307 // Not a page break at all: the version check is asked about the wrong pair of
308 // files, which is the shape of somebody upgrading one wasm and not the other.
309 versionskew: [],
310};
311
312if (BREAK && !BREAKS[BREAK]) {
313 console.error(`unknown break '${BREAK}'; one of: ${Object.keys(BREAKS).join(', ')}`);
314 process.exit(2);
315}
316
317/// `src` with `spec` applied, or a hard stop. Nothing is served that was not verified
318/// to differ from what it was given.
319function damaged(src, spec) {
320 const n = src.split(spec.find).length - 1;
321 if (n !== 1) {
322 console.error(`break '${BREAK}': the anchor appears ${n} times in ${spec.file}, `
323 + 'so nothing was broken and the run below would prove nothing.');
324 process.exit(2);
325 }
326 return src.replace(spec.find, spec.with);
327}
328
329/// Serve every break's damage, ONE BODY PER FILE.
330///
331/// A break with two edits to one file used to register two routes for the same URL,
332/// each built from the file ON DISK — and Playwright serves the LAST route registered,
333/// so only the second edit ever reached the browser. `tallsvg` and `oldwords` are both
334/// two-edit breaks, and both were half-served: `oldwords` proved the paper button and
335/// silently said nothing about the bar. A break that is only half applied is a break
336/// that proves half of what the summary claims.
337async function routes(page) {
338 if (!BREAK) return;
339 const byFile = new Map();
340 for (const spec of (BREAKS[BREAK] || [])) {
341 const src = byFile.has(spec.file) ? byFile.get(spec.file)
342 : fs.readFileSync(path.join(WWW, spec.file), 'utf8');
343 byFile.set(spec.file, damaged(src, spec));
344 }
345 for (const [file, body] of byFile) {
346 // The type from the NAME, because a stylesheet served as JavaScript is not
347 // applied at all and the break would look like the check being wrong.
348 const type = /\.css$/.test(file) ? 'text/css' : 'application/javascript';
349 await page.route('**/' + file, r => r.fulfill({
350 status: 200, contentType: type, body,
351 }));
352 }
353}
354
355// ── The two wasm modules are the same typst ──────────────────────────
356//
357// The renderer was vendored so the live view could draw the compiler's own layout.
358// If the two are ever built from different typst source, the preview stops being a
359// preview and starts being a plausible drawing of a different book — the same class
360// of silent wrongness as a substituted font. Version NUMBERS would not settle it, so
361// this reads the typst git checkout out of each wasm's own bytes.
362
363/// Every distinct match of `re` in a file read as latin1.
364function scan(file, re) {
365 const text = fs.readFileSync(file).toString('latin1');
366 const out = new Set();
367 const g = new RegExp(re.source, 'g');
368 let m;
369 while ((m = g.exec(text)) !== null) out.add(m[0]);
370 return [...out];
371}
372
373const V = path.join(WWW, 'vendor', 'typst');
374const CHECKOUT = /checkouts\/typst-[0-9a-f]+\/[0-9a-f]{7}/;
375{
376 const compiler = path.join(V, 'typst_ts_web_compiler_bg.wasm');
377 // Under `versionskew` the check is asked about the app's OWN wasm, which is not
378 // typst at all — the shape of somebody upgrading one half of the pair.
379 const rend = BREAK === 'versionskew'
380 ? path.join(WWW, 'pkg', 'oxedyne_daimond_bg.wasm')
381 : path.join(V, 'typst_ts_renderer_bg.wasm');
382 const a = scan(compiler, CHECKOUT);
383 const b = scan(rend, CHECKOUT);
384 check('the renderer is built from the compiler\'s own typst checkout',
385 a.length === 1 && b.length === 1 && a[0] === b[0],
386 `compiler ${JSON.stringify(a)} vs renderer ${JSON.stringify(b)}`);
387 const ver = scan(compiler, /Typst \d+\.\d+\.\d+/);
388 const assets = scan(compiler, /typst-assets-\d+\.\d+\.\d+/);
389 check('and that checkout is the 0.14.2 the design note recorded',
390 ver.includes('Typst 0.14.2') && assets.includes('typst-assets-0.14.2'),
391 `${ver.join(', ')} / ${assets.join(', ')}`);
392}
393
394// The outside tool. Without it the ink comparison cannot be made at all, and a
395// check that quietly skips is worse than one that fails.
396let POPPLER = true;
397try { execFileSync('pdftoppm', ['-v'], { stdio: 'pipe' }); }
398catch (e) { POPPLER = false; }
399
400// ── The fixture ──────────────────────────────────────────────────────
401//
402// A small book: a main file and two chapters, so an edit can be made to a chapter
403// the reader is NOT looking at, and so the document is several pages long and a
404// scroll position means something.
405
406const MAIN = `#import "one.typ": one
407#import "two.typ": two
408#set page(width: 120mm, height: 160mm, margin: 12mm)
409#set text(size: 10pt)
410#set par(justify: true)
411= The book
412#one
413#pagebreak()
414#two
415`;
416const ONE = (w) => `#let one = [
417== Chapter one
418${w} lorem ipsum dolor sit amet, consectetur adipiscing elit.
419
420#lorem(90)
421]
422`;
423const TWO = (w) => `#let two = [
424== Chapter two
425${w} consectetur adipiscing elit, sed do eiusmod tempor.
426
427#lorem(120)
428]
429`;
430
431const s = await open({ name: 'typstwatch', profile: PROFILE, route: routes });
432const { page } = s;
433await page.waitForTimeout(1200);
434
435/// Write one of the fixture's files through the app's own byte door, which is the
436/// same door a daimon's `file_write` goes through.
437const put = (p, text) => page.evaluate(async ({ p, text }) => {
438 const m = await import('/pkg/oxedyne_daimond.js');
439 await m.write_file(p, text);
440}, { p, text });
441
442const st = () => page.evaluate(() => window.DaimondTypstWatch.state());
443const sleep = (ms) => new Promise(r => setTimeout(r, ms));
444
445/// The words actually on the page, read out of the rendered document.
446///
447/// typst.ts lays a transparent text layer over the glyph outlines so a reader can
448/// select what they see, and that layer IS what is on screen. Asserting on it is
449/// asserting on the document; asserting on a build counter is asserting that
450/// something happened.
451const words = () => page.evaluate(() => {
452 const h = document.querySelector('#typst-live .tl-pages');
453 if (!h || !h.shadowRoot) return '';
454 return [...h.shadowRoot.querySelectorAll('.tsel')].map(e => e.textContent).join(' ');
455});
456
457/// How many marks — glyph outlines and shapes — are on screen right now.
458const marks = () => page.evaluate(() => {
459 const h = document.querySelector('#typst-live .tl-pages');
460 return (h && h.shadowRoot) ? h.shadowRoot.querySelectorAll('use, path').length : -1;
461});
462
463try {
464 await put('proj/main.typ', MAIN);
465 await put('proj/one.typ', ONE('Alpha'));
466 await put('proj/two.typ', TWO('Zulu'));
467
468 // Wide enough for the ink comparison to mean something, and for the document to
469 // be read the way it would be.
470 await page.setViewportSize({ width: 1900, height: 1000 });
471
472 // ── Opened and compiled the way a person does it ─────────────
473 // Through the panel's own opener and the panel's own button. The watch is armed
474 // by `src/wasm/typst.rs` inside that button's call; nothing here starts it.
475 await page.evaluate(() => window.DaimondDoc.show('proj/main.typ'));
476 await page.waitForTimeout(1200);
477 const hasBtn = await page.$('[data-act="compile"]');
478 check('the Doc panel offers Compile on a .typ file', !!hasBtn);
479 await page.click('[data-act="compile"]', { force: true });
480 // The cold compile builds the 27 MB compiler and the 1 MB renderer.
481 for (let i = 0; i < 60 && !(await st()).drawn; i++) await sleep(500);
482
483 const s0 = await st();
484 check('COMPILING STARTS THE WATCH — no toggle was touched',
485 s0.mode === 'live' && s0.path === 'proj/main.typ' && s0.drawn === 1,
486 JSON.stringify({ mode: s0.mode, path: s0.path, drawn: s0.drawn }));
487 check('and the pages on screen are the pages of this document',
488 /Chapter one/.test(await words()) && s0.pages >= 2,
489 `${s0.pages} page(s)`);
490 check('and the watch is following every file the compile read',
491 s0.files >= 3, `${s0.files} file(s)`);
492
493 // ── The words on the bar ─────────────────────────────────────
494 // Asked for by the author in as many words: "Live" was the state of a loop and
495 // not the name of the thing he was looking at, and "Dark paper" was two words
496 // where the button sits beside a page and nothing else in the view is light or
497 // dark. Asserted as EXACT text: "Live" is a substring of "Live preview", so
498 // anything looser passes on the very wording it is meant to catch.
499 const bar0 = await page.$eval('#typst-live .tl-says', e => e.textContent.trim());
500 check('THE BAR NAMES THE THING, NOT THE LOOP — "Live preview"',
501 bar0 === 'Live preview', JSON.stringify(bar0));
502 const paper0 = await page.$eval('#typst-live .tl-night', e => e.textContent.trim());
503 await page.evaluate(() => window.DaimondTypstWatch.dark(true));
504 const paper1 = await page.$eval('#typst-live .tl-night', e => e.textContent.trim());
505 await page.evaluate(() => window.DaimondTypstWatch.dark(false));
506 check('and the paper is one word each way — "Dark", then "Light"',
507 paper0 === 'Dark' && paper1 === 'Light',
508 `${JSON.stringify(paper0)} then ${JSON.stringify(paper1)}`);
509
510 // AND THE PAGE NUMBER IS IN THE BOX, WHERE IT CAN BE READ. Asserting the VALUE
511 // alone is what let this go: the box said `1` to `page.$eval` and showed nothing
512 // at all, because the warm skin's `input[type="text"]` padding left it no content
513 // width and `overflow: clip` did the rest. So the width the digit has to live in
514 // is measured, and against the type it is set in rather than against a number
515 // chosen here.
516 const box = await page.evaluate(() => {
517 const i = document.querySelector('#typst-live .tl-page');
518 const c = getComputedStyle(i);
519 const r = i.getBoundingClientRect();
520 const inner = i.clientWidth - (parseFloat(c.paddingLeft) || 0) - (parseFloat(c.paddingRight) || 0);
521 return { value: i.value, inner, h: r.h || r.height, fs: parseFloat(c.fontSize) || 0,
522 pad: c.paddingLeft + ' ' + c.paddingTop };
523 });
524 check('AND THE PAGE NUMBER IS IN THE BOX — room for the digits, not only a value',
525 box.value === '1' && box.inner >= box.fs * 1.5 && box.h < box.fs * 2.6,
526 `value ${JSON.stringify(box.value)}, ${box.inner.toFixed(1)}px of room for `
527 + `${box.fs}px type in a box ${box.h.toFixed(1)}px tall, padding ${box.pad}`);
528
529 // ── 1. It follows the file, without anybody acting ───────────
530 // A daimon's write. The panel is not touched, no button is pressed, and the
531 // assertion is on the WORDS, so a counter that ticked for another reason
532 // cannot pass it.
533 const before1 = (await st()).drawn;
534 await put('proj/one.typ', ONE('Bravo'));
535 for (let i = 0; i < 24 && (await st()).drawn === before1; i++) await sleep(250);
536 const after1 = await words();
537 check('AN EDIT TO A CHAPTER REFRESHES THE VIEW WITH NOBODY ACTING',
538 /Bravo/.test(after1) && !/Alpha/.test(after1),
539 (await st()).drawn === before1 ? 'nothing rebuilt' : after1.slice(0, 90));
540
541 // ── 2. The reader's place survives the swap ──────────────────
542 // Scrolled well into the book, then a LATER chapter is edited so the document
543 // changes under the reader without the page they are on moving. A fraction of
544 // the scroll height would pass this by accident when nothing resizes, so the
545 // assertion is the page and the offset within it.
546 await page.evaluate(() => {
547 const sc = document.querySelector('#typst-live .tl-scroll');
548 sc.scrollTop = Math.round(sc.scrollHeight * 0.55);
549 });
550 await sleep(300);
551 const place = await st();
552 const before2 = place.drawn;
553 await put('proj/two.typ', TWO('Yankee'));
554 for (let i = 0; i < 24 && (await st()).drawn === before2; i++) await sleep(250);
555 const kept = await st();
556 check('THE READER\'S PLACE SURVIVES THE SWAP',
557 kept.drawn > before2
558 && kept.at && place.at
559 && kept.at.page === place.at.page
560 && Math.abs(kept.at.into - place.at.into) < 2,
561 `was page ${place.at && place.at.page} +${place.at && place.at.into.toFixed(1)}pt, `
562 + `now page ${kept.at && kept.at.page} +${kept.at && kept.at.into.toFixed(1)}pt`);
563 check('and it did not simply refuse to move, either — the page really changed',
564 /Yankee/.test(await words()), (await words()).slice(0, 90));
565
566 // ── 3. Nothing blanks, on any frame ──────────────────────────
567 // Sampled every animation frame across a whole rebuild. The frame COUNT is
568 // asserted as well: a sampler that saw four frames did not watch a rebuild.
569 const steady = await marks();
570 await page.evaluate(() => {
571 window.__frames = [];
572 const tick = () => {
573 const h = document.querySelector('#typst-live .tl-pages');
574 window.__frames.push((h && h.shadowRoot)
575 ? h.shadowRoot.querySelectorAll('use, path').length : -1);
576 window.__raf = requestAnimationFrame(tick);
577 };
578 tick();
579 });
580 const before3 = (await st()).drawn;
581 await put('proj/one.typ', ONE('Charlie'));
582 for (let i = 0; i < 24 && (await st()).drawn === before3; i++) await sleep(250);
583 await sleep(400);
584 const frames = await page.evaluate(() => {
585 cancelAnimationFrame(window.__raf);
586 return window.__frames;
587 });
588 const low = frames.length ? Math.min(...frames) : -1;
589 check('the frame sampler actually watched a rebuild',
590 frames.length > 20 && (await st()).drawn > before3,
591 `${frames.length} frame(s)`);
592 check('NOTHING BLANKS — every frame across the rebuild carried the document',
593 low > 0 && low >= steady * 0.9,
594 `lowest frame held ${low} marks, steady state ${steady}`);
595 // (The pixels themselves are counted below, where the page is cropped and
596 // compared against poppler's rendering of the same page. A photograph of the
597 // WHOLE scroller would be four-fifths panel background, and a "40% ink" reading
598 // off that would pass for a drawn page while measuring the dark behind it.)
599
600 // ── 4. A broken source costs the error and nothing else ──────
601 const good = await marks();
602 const goodWords = await words();
603 const before4 = (await st()).builds;
604 await put('proj/one.typ', '#let one = [ unbalanced\n');
605 for (let i = 0; i < 24 && (await st()).builds === before4; i++) await sleep(250);
606 await sleep(600);
607 const broken = await st();
608 check('A BROKEN SOURCE LEAVES THE LAST GOOD VIEW UP',
609 (await marks()) === good && (await words()) === goodWords,
610 `${await marks()} marks, was ${good}`);
611 check('and it shows the compiler\'s own words, with the file and the line',
612 /one\.typ:\d+/.test(broken.error) && /unclosed delimiter/i.test(broken.error),
613 JSON.stringify(broken.error).slice(0, 200));
614 check('and the error is not a Rust dump wearing a message',
615 !/LocalErr\{|\x1b\[/.test(broken.error), JSON.stringify(broken.error).slice(0, 120));
616 const bar = await page.$eval('#typst-live .tl-says', e => e.textContent);
617 check('and the bar says the pages are the last ones that built',
618 /last build/i.test(bar), bar);
619
620 // ── 5. A good build clears it, without a word ────────────────
621 const before5 = (await st()).drawn;
622 await put('proj/one.typ', ONE('Delta'));
623 for (let i = 0; i < 24 && (await st()).drawn === before5; i++) await sleep(250);
624 await sleep(400);
625 const fixed = await st();
626 const errShown = await page.$eval('#typst-live .tl-err',
627 e => ({ text: e.textContent, shown: e.style.display !== 'none' }));
628 check('A GOOD BUILD AFTER A BAD ONE CLEARS THE ERROR, SILENTLY',
629 fixed.error === '' && !errShown.shown && /Delta/.test(await words())
630 && fixed.mode === 'live',
631 JSON.stringify({ error: fixed.error.slice(0, 80), shown: errShown.shown, mode: fixed.mode }));
632
633 // ── 6. A burst of writes is one rebuild ──────────────────────
634 // Six writes inside the debounce window. One rebuild, not six: the count is of
635 // builds STARTED, so a loop that ran them back to back cannot pass by finishing
636 // quickly.
637 const before6 = (await st()).builds;
638 for (let i = 0; i < 6; i++) { await put('proj/two.typ', TWO('Burst' + i)); await sleep(60); }
639 await sleep(3000);
640 const burst = await st();
641 check('THE DEBOUNCE COALESCES A BURST OF WRITES INTO ONE REBUILD',
642 burst.builds - before6 === 1,
643 `${burst.builds - before6} rebuild(s) for six writes, debounce ${burst.debounce} ms`);
644 check('and the one it ran was of the LAST write, not the first',
645 /Burst5/.test(await words()), (await words()).slice(0, 90));
646
647 // ── 9. The pages are the PDF's pages ─────────────────────────
648 // Done here, while the view is live and known good, and before the heap and
649 // closing checks put the loop into states it cannot draw from.
650 if (!POPPLER) {
651 check('the vector pages are the same ink as the PDF', false,
652 'pdftoppm is not installed, so the only outside oracle here could not be asked');
653 } else {
654 const r = await inkCompare(page, 'proj/main.typ');
655 check('the page photographed is a page of text on paper, not a dark box',
656 r.paper > 0.6 && r.ink > 0.005 && r.ink < 0.2,
657 `${(r.paper * 100).toFixed(1)}% paper, ${(r.ink * 100).toFixed(2)}% ink`);
658 check('THE LIVE PAGE HAS EXACTLY THE LINES THE PDF PAGE HAS',
659 r.lines.pdf >= 8 && r.lines.live === r.lines.pdf,
660 `PDF ${r.lines.pdf} line(s), live ${r.lines.live}`);
661 check('AND THEY SIT WHERE THE PDF PUTS THEM — nothing reflowed, nothing moved',
662 r.raw < 3 && r.rows.worst < 1.5 && Math.abs(r.rows.a - 1) < 0.01,
663 `worst line ${r.raw.toFixed(2)}px from the PDF's; after one scale and offset `
664 + `${r.rows.worst.toFixed(2)}px (rms ${r.rows.rms.toFixed(2)}px), scale `
665 + `${r.rows.a.toFixed(5)}`);
666 check('and the text is set to the same measure, to the pixel',
667 Math.abs(r.measure.live[0] - r.measure.pdf[0]) <= 2
668 && Math.abs(r.measure.live[1] - r.measure.pdf[1]) <= 2,
669 `PDF ${r.measure.pdf.join('..')}, live ${r.measure.live.join('..')} of ${r.w}px`);
670 // Reported rather than asserted, and the difference is the point: poppler
671 // hints a glyph and Chrome fills its outline, so the same stroke carries
672 // different weight. That is the rasteriser, not the book, and a check that
673 // demanded equality here would be asserting that two rasterisers agree.
674 console.log(` (ink weight live/PDF ${r.ratio.toFixed(3)} and `
675 + `${(r.diff * 100).toFixed(1)}% of pixels differ by more than 8/255 — anti-aliasing `
676 + `along every glyph edge, which is why the LINES are what is asserted. The `
677 + `difference image is ${r.png})`);
678 }
679
680 // ── 8. The heap guard refuses rather than bricking ───────────
681 // Driven at the limit, by moving the limit: the ceiling is a real setting, and
682 // the compiler's heap is monotonic, so a budget below where it already stands is
683 // exactly the state a long session reaches. Nothing here allocates gigabytes to
684 // find out — the measurement that did is in dev/TYPST_WATCH.md, and it left that
685 // page's compiler dead.
686 const heapNow = (await st()).heap;
687 await page.evaluate((mb) => window.DaimondTypstWatch.budgetMB(mb), Math.max(1, heapNow / 2));
688 const before8 = (await st()).builds;
689 await put('proj/one.typ', ONE('Echo'));
690 await sleep(3000);
691 const held = await st();
692 const rebuildBtn = await page.$eval('#typst-live .tl-rebuild',
693 e => ({ shown: e.style.display !== 'none', label: e.textContent }));
694 check('THE HEAP GUARD REFUSES TO START A REBUILD THAT MIGHT NOT FIT',
695 held.builds === before8 && held.mode === 'held',
696 `${held.builds - before8} rebuild(s) started, mode ${held.mode}`);
697 check('and it says why, in MB, rather than merely stopping',
698 /memory|MB/i.test(held.reason) && /Rebuild|reload/i.test(held.reason),
699 held.reason.slice(0, 160));
700 check('and it falls back to a Rebuild button the user presses knowingly',
701 rebuildBtn.shown, JSON.stringify(rebuildBtn));
702 check('and the pages are still on screen while it is held',
703 (await marks()) > 0, `${await marks()} marks`);
704 // Pressing it is the user's own choice, and it works.
705 // Clicked through the DOM rather than through the locator: under `--break
706 // noguard` the button is never offered, and a run that dies on a missing button
707 // says nothing about the twenty checks after it. A break must reach the summary.
708 const drawn8 = (await st()).drawn;
709 await page.evaluate(() => {
710 const b = document.querySelector('#typst-live .tl-rebuild');
711 if (b) b.click();
712 });
713 for (let i = 0; i < 24 && (await st()).drawn === drawn8; i++) await sleep(250);
714 const forced = await st();
715 check('and pressing Rebuild does rebuild — the guard stopped the LOOP, not the user',
716 forced.builds > before8 && /Echo/.test(await words()),
717 `${forced.builds - before8} rebuild(s), ${forced.drawn - drawn8} drawn`);
718 await page.evaluate(() => window.DaimondTypstWatch.budgetMB(2500));
719
720 // ── 7. Closing the view ends the watch ───────────────────────
721 // Last, because it takes the loop down. Proved by the absence of a compile after
722 // a real edit — and the same edit is shown to have caused one a moment earlier,
723 // so the silence is the closing and not the fixture.
724 await sleep(1500);
725 const before7 = (await st()).builds;
726 await put('proj/two.typ', TWO('Foxtrot'));
727 for (let i = 0; i < 24 && (await st()).builds === before7; i++) await sleep(250);
728 check('the same edit rebuilds while the view is open (so the silence below is the closing)',
729 (await st()).builds > before7, `${(await st()).builds - before7} rebuild(s)`);
730
731 await shot(s, 'typstwatch' + (BREAK ? '-' + BREAK : ''));
732
733 // The PREVIEW panel, which is where the live pages live. It used to be the Doc
734 // panel, because the source and its pages shared one — and the watch stopping
735 // when the source was closed was the very compromise the split removed. Closing
736 // the document now leaves the pages up, on purpose; closing the PAGES is what
737 // says nobody is reading them.
738 await page.click('#panel-preview [data-close="preview"]', { force: true });
739 await sleep(2000);
740 const closed = await st();
741 const before9 = closed.builds;
742 await put('proj/one.typ', ONE('Golf'));
743 await sleep(4000);
744 const after9 = await st();
745 check('CLOSING THE VIEW ENDS THE WATCH — nothing compiles after it',
746 after9.builds === before9 && after9.mode === 'idle' && after9.path === '',
747 `${after9.builds - before9} rebuild(s) after closing, mode ${after9.mode}`);
748 check('and the live view is gone from the panel, leaving it as it was',
749 !(await page.$('#typst-live')),
750 'the live view is still in the panel');
751 // The preview panel's own two renderings get their inline `display` back,
752 // exactly as they had it. A live view that stood in for them and then left them
753 // hidden would close into an empty panel.
754 const back = await page.evaluate(() => ({
755 view: document.getElementById('pv-view').style.display,
756 embed: document.getElementById('doc-embed').style.display,
757 }));
758 check('and the PDF the Compile button wrote is showing again, exactly as it was',
759 back.embed === '' && back.view === 'none', JSON.stringify(back));
760
761 // ── 11. Sheets, and a rail of the document's own sections ────
762 //
763 // A second fixture, because these answers need a shape the first one does not
764 // have. It carries a CONTENTS, which prints every heading's words on page one and
765 // is precisely what a naive "which page is this heading on" gets wrong; and a
766 // `#pagebreak()` before every chapter, so WHICH PAGE EACH HEADING IS ON IS DECIDED
767 // BY THE FIXTURE rather than read back out of the thing under test.
768 const RAIL = `#set page(width: 120mm, height: 160mm, margin: 12mm)
769#set text(size: 10pt)
770#set par(justify: true)
771#outline()
772#pagebreak()
773= Alpha chapter
774#lorem(60)
775#pagebreak()
776= Bravo chapter
777== Bravo first part
778#lorem(60)
779#pagebreak()
780= Charlie chapter
781#lorem(60)
782`;
783 // What the fixture says, page by page. `Contents` is typst's own title for
784 // `#outline()`, and it is a heading like any other, so the rail lists it.
785 const WANT = [
786 { text: 'Contents', level: 1, page: 1 },
787 { text: 'Alpha chapter', level: 1, page: 2 },
788 { text: 'Bravo chapter', level: 1, page: 3 },
789 { text: 'Bravo first part', level: 2, page: 3 },
790 { text: 'Charlie chapter', level: 1, page: 4 },
791 ];
792 await put('rail/main.typ', RAIL);
793 await page.evaluate(() => window.DaimondDoc.show('rail/main.typ'));
794 await sleep(1200);
795 await page.click('[data-act="compile"]', { force: true });
796 for (let i = 0; i < 120; i++) {
797 const w = await st();
798 if (w.path === 'rail/main.typ' && w.drawn && w.pages >= 4) break;
799 await sleep(500);
800 }
801 const r0 = await st();
802 check('the contents fixture laid out as it was designed to',
803 r0.pages === 4, `${r0.pages} page(s)`);
804
805 // A DOCUMENT IS A STACK OF SHEETS. Reported as "shows as a continuous page, not
806 // distinct pages", and answered in two parts: the pages are separate elements
807 // with space between them, and what shows in that space is the panel and not more
808 // paper. The second half is what makes it visible; the first alone would pass on a
809 // gap nobody could see.
810 const sheets = await page.evaluate(() => {
811 const h = document.querySelector('#typst-live .tl-pages');
812 document.querySelector('#typst-live .tl-scroll').scrollTop = 0;
813 return [...h.shadowRoot.querySelectorAll('.tl-sheet')].map((e) => {
814 const r = e.getBoundingClientRect();
815 return { page: Number(e.getAttribute('data-page')), x: r.left, y: r.top,
816 w: r.width, h: r.height, marks: e.querySelectorAll('use, path').length };
817 });
818 });
819 const inOrder = sheets.every((s, i) => s.page === sheets[0].page + i);
820 const allDrawn = sheets.every(s => s.marks > 20);
821 check('EVERY PAGE IN VIEW IS ITS OWN SHEET, and every sheet has its own page on it',
822 sheets.length >= 2 && inOrder && allDrawn,
823 sheets.map(s => `p${s.page}:${s.marks}`).join(' '));
824 const gapPx = sheets.length >= 2
825 ? Math.round(sheets[1].y - (sheets[0].y + sheets[0].h)) : 0;
826 check('AND THE SHEETS DO NOT TOUCH — there is a gap between two pages',
827 gapPx >= 3, `${gapPx}px between page ${sheets[0] && sheets[0].page} and the next`);
828 if (POPPLER && sheets.length >= 2) {
829 // A strip across the seam: six rows of paper, the gap, six rows of paper. If
830 // the pages run together the whole strip is paper and the middle is as light
831 // as the ends.
832 const seam = path.join(WORK, 'seam.png');
833 const y0 = Math.floor(sheets[0].y + sheets[0].h) - 6;
834 const y1 = Math.ceil(sheets[1].y) + 6;
835 await page.screenshot({ path: seam, clip: {
836 x: Math.floor(sheets[0].x) + 4, y: y0,
837 width: Math.max(8, Math.floor(sheets[0].w) - 8), height: Math.max(4, y1 - y0),
838 } });
839 const g = inkOf(seam);
840 const rowMean = (y) => {
841 let s = 0;
842 for (let x = 0; x < g.w; x++) s += g.gray[y * g.w + x];
843 return s / g.w;
844 };
845 const paper = (rowMean(0) + rowMean(g.h - 1)) / 2;
846 let darkest = 255;
847 for (let y = 0; y < g.h; y++) darkest = Math.min(darkest, rowMean(y));
848 check('and what shows between them is the panel, not more paper',
849 paper - darkest > 30,
850 `paper ${paper.toFixed(0)}/255 at the edges of the seam, `
851 + `${darkest.toFixed(0)} at its darkest (${seam})`);
852 } else {
853 check('and what shows between them is the panel, not more paper', false,
854 'the imaging tools this needs are not installed');
855 }
856
857 // A VIEW CHANGE NEVER STARTS A COMPILE. The page box, the zoom and the paper were
858 // built this way on purpose and the rail is no different: the layout is the
859 // compiler's and does not depend on any of them. The loop that cycled `Live` /
860 // `Rebuilding` in front of the author is what a control reaching for the compiler
861 // builds a second time.
862 const beforeV = await st();
863 await page.evaluate(() => {
864 const w = window.DaimondTypstWatch;
865 w.rail(true);
866 w.zoom(1.5);
867 w.fitPage(true);
868 w.dark(true);
869 w.dark(false);
870 w.rail(false);
871 w.rail(true);
872 w.fitPage(false);
873 });
874 await sleep(2500);
875 const afterV = await st();
876 check('A VIEW CHANGE NEVER STARTS A COMPILE — rail, zoom, fit and paper are all view',
877 afterV.builds === beforeV.builds && afterV.drawn === beforeV.drawn,
878 `${afterV.builds - beforeV.builds} rebuild(s), `
879 + `${afterV.drawn - beforeV.drawn} redraw(s) for eight view changes`);
880 check('and the rail is open, showing something, after all that',
881 afterV.rail && afterV.toc.length === WANT.length,
882 `rail ${afterV.rail}, ${afterV.toc.length} section(s)`);
883
884 for (let i = 0; i < 40 && !(await st()).located; i++) await sleep(250);
885 const toc = await page.evaluate(() => window.DaimondTypstWatch.sections());
886 check('THE RAIL IS THE COMPILED DOCUMENT\'S OWN HEADINGS, in order, with their levels',
887 JSON.stringify(toc.map(e => [e.text, e.level]))
888 === JSON.stringify(WANT.map(e => [e.text, e.level])),
889 JSON.stringify(toc.map(e => e.text + '/' + e.level)));
890 // EXACT PAGES, AND THAT IS THE POINT. "Every entry is on a page that really
891 // exists" and "the entries are in non-decreasing order" are each true and, taken
892 // together, still let the whole rail sit on the contents page — which is the one
893 // wrong answer this document was built to produce. The fixture decides the pages;
894 // the rail has to agree with it.
895 check('AND EACH ONE NAMES THE PAGE THE DOCUMENT PUTS IT ON, not the contents page',
896 toc.length === WANT.length && toc.every((e, i) => e.page === WANT[i].page),
897 toc.map(e => `${e.text}→p${e.page}`).join(', '));
898 // And it is wired to the pages: the entry is a control, not a caption.
899 //
900 // Zoomed in first, and NOT the last entry, and both for the same reason: a
901 // scroller cannot put its final page at its own top, so a jump to the end of a
902 // short document lands wherever the scroll runs out and proves nothing either way.
903 // At 200% the stack is twice the panel and `Bravo chapter` is a real journey.
904 await page.evaluate(() => {
905 window.DaimondTypstWatch.zoom(3);
906 document.querySelector('#typst-live .tl-scroll').scrollTop = 0;
907 });
908 await sleep(500);
909 const K = 2; // `Bravo chapter`, page 3
910 const beforeJ = await st();
911 await page.evaluate((k) => {
912 document.querySelectorAll('#typst-live .tl-toclist .tl-tocgo')[k].click();
913 }, K);
914 await sleep(600);
915 const jumped = await st();
916 const jgeom = await page.evaluate((n) => {
917 const sc = document.querySelector('#typst-live .tl-scroll');
918 const b = window.DaimondTypstWatch.pageBox(n);
919 return { at: sc.scrollTop, max: sc.scrollHeight - sc.clientHeight, want: b.top };
920 }, WANT[K].page);
921 // `want <= max` is asserted too, and on purpose: if the page could not be reached
922 // anyway the rest of this check would pass on a scroller that ran out rather than
923 // on a jump that worked.
924 check('and clicking a section goes to it',
925 beforeJ.at && beforeJ.at.page === 0
926 && jgeom.want <= jgeom.max
927 && Math.abs(jgeom.at - jgeom.want) < 2
928 && jumped.at && jumped.at.page === WANT[K].page - 1,
929 `from page ${beforeJ.at ? beforeJ.at.page + 1 : '?'} to page `
930 + `${jumped.at ? jumped.at.page + 1 : '?'} (wanted ${WANT[K].page}); scrolled to `
931 + `${Math.round(jgeom.at)} of a possible ${Math.round(jgeom.max)}, page top `
932 + `${Math.round(jgeom.want)}`);
933 await page.evaluate(() => window.DaimondTypstWatch.zoom(1));
934 await shot(s, 'typstwatch-rail' + (BREAK ? '-' + BREAK : ''));
935 // Closed again, so the long book below is not scanned for sections it is not
936 // being asked about.
937 await page.evaluate(() => window.DaimondTypstWatch.rail(false));
938
939 // ── 10. A long book is drawn all the way down ────────────────
940 //
941 // The check that would have caught the defect this design was rewritten around,
942 // and nothing else here would have. Drawn the obvious way — one SVG as tall as
943 // the book, only the visible band filled in — a 251-page document rasterises at a
944 // REDUCED RESOLUTION far from its origin: page 1 came out 5.6% dark and page 40
945 // came out 0.00%, the words still there in outline, the page a pale wash. Every
946 // mark was in the DOM, every element existed, the page count was right, and the
947 // document was unreadable.
948 //
949 // So it is asserted in PIXELS, on a page deep enough to be past where Chrome
950 // stops drawing properly, against page one of the same document.
951 // LONG ENOUGH TO BE PAST WHERE CHROME STOPS DRAWING PROPERLY, which is a property
952 // of how tall the ELEMENT is and not of how far down the page is. The defect was
953 // found on a 251-page document — an SVG 192,000 px tall — so the fixture is that
954 // long, and it is why this block is the slow one.
955 await put('deep/main.typ',
956 '#set page(width: 120mm, height: 160mm, margin: 12mm)\n'
957 + '#set text(size: 10pt)\n#set par(justify: true)\n#lorem(70000)\n');
958 await page.evaluate(() => window.DaimondDoc.show('deep/main.typ'));
959 await sleep(1200);
960 await page.click('[data-act="compile"]', { force: true });
961 // Waited on THIS document being the one on screen, not on a counter: `stop()`
962 // leaves the tally of the watch that just ended, so `drawn > 0` was true before
963 // the button was even pressed and the wait fell straight through.
964 for (let i = 0; i < 120; i++) {
965 const w = await st();
966 if (w.path === 'deep/main.typ' && w.drawn && w.pages > 2) break;
967 await sleep(500);
968 }
969 const deep = await st();
970 // A missing view is a FAILED CHECK, not an exception: a break that stops the loop
971 // working at all must still reach the summary, or the run says nothing about the
972 // checks it did get through.
973 const shotPage = async (n, name) => {
974 const g = await page.evaluate((k) => {
975 if (!document.querySelector('#typst-live .tl-pages')) return null;
976 window.DaimondTypstWatch.goToPage(k);
977 const b = window.DaimondTypstWatch.pageBox(k);
978 const sc = document.querySelector('#typst-live .tl-scroll');
979 const h = document.querySelector('#typst-live .tl-pages');
980 const hr = h.getBoundingClientRect(), sr = sc.getBoundingClientRect();
981 return { top: hr.top + b.top, left: hr.left, w: hr.width, h: b.height,
982 sy: sr.y, sh: sr.height };
983 }, n);
984 if (!g) return { ratio: 0, missing: true };
985 await sleep(1200); // Chrome rasterises a jump of sixty thousand pixels
986 const y = Math.max(g.top, g.sy);
987 const p = path.join(WORK, name);
988 await page.screenshot({ path: p, clip: {
989 x: g.left, y, width: Math.floor(g.w),
990 height: Math.floor(Math.min(g.h, g.sy + g.sh - y)),
991 } });
992 return inkOf(p);
993 };
994 check('a long document is many pages, and deep enough to be worth asking about',
995 deep.pages > 200, `${deep.pages} page(s)`);
996 const first = await shotPage(1, 'deep-1.png');
997 const DEEP = Math.min(230, deep.pages);
998 const far = await shotPage(DEEP, 'deep-far.png');
999 // AND THE MECHANISM, NOT ONLY THE SYMPTOM. The pixels above are the guarantee; this
1000 // is the thing they are a symptom of, and it is the half that can still be proved
1001 // against broken code. On a 251-page document nothing the browser is asked to
1002 // rasterise may be taller than one page — the stack is, and it must not be an
1003 // element.
1004 const tall = await page.evaluate(() => {
1005 const h = document.querySelector('#typst-live .tl-pages');
1006 if (!h || !h.shadowRoot) return null;
1007 let worst = 0;
1008 for (const svg of h.shadowRoot.querySelectorAll('svg')) {
1009 worst = Math.max(worst, svg.getBoundingClientRect().height);
1010 }
1011 const sheet = h.shadowRoot.querySelector('.tl-sheet');
1012 return { worst, sheet: sheet ? sheet.getBoundingClientRect().height : 0,
1013 stack: h.getBoundingClientRect().height };
1014 });
1015 check('NOTHING DRAWN IS TALLER THAN A PAGE — the stack is tall, the elements are not',
1016 !!tall && tall.sheet > 0 && tall.worst > 0
1017 && tall.worst <= tall.sheet + 2 && tall.stack > tall.sheet * 50,
1018 tall ? `tallest element ${Math.round(tall.worst)}px, one page ${Math.round(tall.sheet)}px, `
1019 + `the whole stack ${Math.round(tall.stack)}px` : 'there was no live view to measure');
1020 check('A PAGE DEEP IN A LONG BOOK IS DRAWN AS DARKLY AS THE FIRST ONE',
1021 !first.missing && !far.missing && first.ratio > 0.005
1022 && far.ratio > first.ratio * 0.75,
1023 first.missing || far.missing ? 'there was no live view to photograph'
1024 : `page 1 ${(first.ratio * 100).toFixed(2)}% ink, page `
1025 + `${DEEP} ${(far.ratio * 100).toFixed(2)}% ink`);
1026
1027 // AND ITS INK IS ON ITS OWN PAPER, WHICH IS THE PROPERTY NOTHING HERE ASSERTED.
1028 //
1029 // THE GAP BETWEEN TWO SOUND CHECKS, and a shipped defect lived in it for the life of
1030 // this file. The ink comparison against poppler is the strictest check here — it
1031 // puts every line within a pixel of where the PDF puts it — and it photographs THE
1032 // FIRST SHEET and rasterises PDF PAGE ONE, where the error this misses is zero by
1033 // construction. The only check that looks this far down is the one directly above,
1034 // and it asserts HOW MUCH ink page 230 carries, to a quarter, and never WHERE it is:
1035 // it passed with room to spare on a page whose type block had slid 37 mm down its
1036 // own paper. Between them: every page after the first, in position.
1037 //
1038 // The sheet's `viewBox` says where the page is cropped from; the group inside it
1039 // carries the renderer's own `translate(x, y)` for that same page. If they are not
1040 // the same number the crop is not over the ink — the paper is still exactly where it
1041 // belongs, so a white band opens at the head of the page and the last lines are
1042 // pushed off the foot, which is what the author saw and called a gradual violation
1043 // of the margins. The slack is `PAGE_INSET`, the twentieth of a point the window
1044 // itself is asked with; under the defect it is 0 pt on page 1 and about 105 pt here.
1045 const CROP_SLACK = 0.05;
1046 const crops = await page.evaluate(() => {
1047 const h = document.querySelector('#typst-live .tl-pages');
1048 if (!h || !h.shadowRoot) return null;
1049 const out = [];
1050 for (const sh of h.shadowRoot.querySelectorAll('.tl-sheet')) {
1051 const svg = sh.querySelector('svg');
1052 // A DIRECT CHILD: the shared glyph outlines ride in the first sheet made, and
1053 // the page group is the one the sheet itself holds.
1054 const g = svg && svg.querySelector(':scope > g[class*="typst-page"]');
1055 if (!svg || !g) continue;
1056 const vb = (svg.getAttribute('viewBox') || '').trim().split(/\s+/).map(Number);
1057 const m = /translate\(\s*[-\d.]+\s*,\s*([-\d.]+)/
1058 .exec(g.getAttribute('transform') || '');
1059 out.push({ page: Number(sh.getAttribute('data-page')),
1060 crop: vb[1], ink: m ? parseFloat(m[1]) : NaN });
1061 }
1062 return out;
1063 });
1064 let worstCrop = null;
1065 for (const c of (crops || [])) {
1066 const off = Math.abs(c.crop - c.ink);
1067 if (!worstCrop || !(off <= worstCrop.off)) {
1068 worstCrop = { page: c.page, off, crop: c.crop, ink: c.ink };
1069 }
1070 }
1071 check('EVERY SHEET IS CROPPED WHERE ITS OWN PAGE\'S INK IS, this far down as on page one',
1072 !!crops && crops.length >= 2 && !!worstCrop && worstCrop.off <= CROP_SLACK,
1073 worstCrop ? `${crops.length} sheet(s); worst is page ${worstCrop.page}, cropped at `
1074 + `${worstCrop.crop.toFixed(2)}pt with its ink at ${worstCrop.ink.toFixed(2)}pt — `
1075 + `${worstCrop.off.toFixed(2)}pt apart`
1076 : 'there was no live view with sheets in it to measure');
1077
1078 // AND THE ROUNDING BEHIND ALL OF THAT IS A MEASURED NUMBER RATHER THAN A SURPRISE.
1079 // Two routes to the same quantity, and they are independent. `drift` is the
1080 // renderer's own total height less the exact sum of the page heights, taken in
1081 // `draw` for the cost of two subtractions and available before a single page is
1082 // drawn; `rdrift` is how far the origin the renderer GAVE the last page has walked
1083 // from the exact sum above it, read out of a group in a band. Where the renderer's
1084 // total is the rounded accumulation the two can differ only by the last page's own
1085 // rounding, which is under a point. Where it is the exact sum, `drift` is zero and
1086 // there is nothing to hold `rdrift` against — that case is named here rather than
1087 // asserted, and both numbers are printed either way.
1088 const dr = await st();
1089 check('THE RENDERER\'S ROUNDING IS MEASURED TWICE, BEFORE AND AFTER THE DRAW, AND AGREES',
1090 Number.isFinite(dr.drift) && Number.isFinite(dr.rdrift)
1091 && (Math.abs(dr.drift) < 0.5 || Math.abs(dr.drift - dr.rdrift) <= 1),
1092 `the renderer's own total less the exact sum ${Number(dr.drift).toFixed(2)}pt; `
1093 + `the last page's own origin less its exact top ${Number(dr.rdrift).toFixed(2)}pt`
1094 + (Math.abs(dr.drift) < 0.5
1095 ? ' — the total IS the exact sum here, so this one is a report, not a proof'
1096 : ''));
1097 check('and no band was quietly given up on along the way',
1098 !dr.bandErr, dr.bandErr);
1099
1100 const errs = errors(s).filter(e =>
1101 !/Failed to load resource/.test(e) && !/502/.test(e) && !/Bad Gateway/.test(e));
1102 check('nothing was refused by way of an unhandled error', errs.length === 0,
1103 errs.slice(0, 3).join(' | '));
1104} finally {
1105 await s.close();
1106}
1107
1108// ── The ink ──────────────────────────────────────────────────────────
1109
1110/// The ink in a PNG: `{ w, h, gray, total, ratio }`, greyscale, 0 = white.
1111function inkOf(png) {
1112 const raw = png.replace(/\.png$/, '.gray');
1113 const dims = execFileSync('identify', ['-format', '%wx%h', png]).toString();
1114 const [w, h] = dims.split('x').map(Number);
1115 execFileSync('convert', [png, '-colorspace', 'gray', '-depth', '8', raw]);
1116 const g = fs.readFileSync(raw);
1117 let total = 0;
1118 for (let i = 0; i < w * h; i++) total += 255 - g[i];
1119 return { w, h, gray: g, total, ratio: total / (255 * w * h) };
1120}
1121
1122// ── What "the same page" is measured as, and why not pixels ──────────
1123//
1124// Comparing the two rasters pixel for pixel does not answer the question. Poppler
1125// and Chrome disagree about the WEIGHT of a glyph — poppler hints and Chrome fills
1126// an outline, so at 120 dpi Chrome lays down about a third more ink along the same
1127// strokes — and they round the page box differently, so one raster is a fraction of
1128// a percent taller than the other and by the foot of the page the lines are two
1129// pixels apart. Both of those are the rasterisers, and neither is the book.
1130//
1131// What IS the book is where the lines sit and how long they are. So the ink is
1132// reduced to LINE BANDS — the runs of rows that carry ink — and the two sets are
1133// fitted to each other with one scale and one offset. If nothing reflowed there are
1134// the same number of bands and they land on each other to within a pixel. If a
1135// paragraph re-broke, a line appears, disappears or moves relative to its
1136// neighbours, and no single scale and offset can put them back together.
1137//
1138// This is the metric that would catch a font substituted silently, which is the
1139// failure `typst.js` refuses to compile through. A page typeset in the wrong face
1140// has the same words and different line breaks, and only the geometry says so.
1141
1142/// The runs of rows (or columns) carrying ink, as `[start, end]` pairs.
1143///
1144/// Three things make this stable, and each was arrived at by watching it wobble.
1145/// The threshold is a fraction of the BUSIEST row rather than an absolute, so it
1146/// does not depend on how dark either rasteriser draws. The profile is SMOOTHED
1147/// over five samples first, so an ascender or a superscript poking above a line does
1148/// not become a band of its own in one raster and not the other. And bands closer
1149/// together than `GAP` are MERGED, because two halves of one line of type are one
1150/// line of type.
1151///
1152/// The recipe was checked for stability rather than tuned to pass: on the fixture,
1153/// thresholds from 6% to 20% all report the same thirteen lines in both rasters,
1154/// agreeing to within one pixel. A metric that only works at one threshold is a
1155/// coincidence.
1156function bands(profile) {
1157 const SM = 2, THR = 0.15, GAP = 3;
1158 const p = new Float64Array(profile.length);
1159 for (let i = 0; i < profile.length; i++) {
1160 let sum = 0, n = 0;
1161 for (let j = -SM; j <= SM; j++) {
1162 const q = i + j;
1163 if (q >= 0 && q < profile.length) { sum += profile[q]; n++; }
1164 }
1165 p[i] = sum / n;
1166 }
1167 let max = 0;
1168 for (const v of p) if (v > max) max = v;
1169 const thr = max * THR;
1170 const raw = [];
1171 let start = -1;
1172 for (let i = 0; i < p.length; i++) {
1173 if (p[i] > thr) { if (start < 0) start = i; }
1174 else if (start >= 0) { raw.push([start, i - 1]); start = -1; }
1175 }
1176 if (start >= 0) raw.push([start, p.length - 1]);
1177 const out = [];
1178 for (const b of raw) {
1179 if (out.length && b[0] - out[out.length - 1][1] <= GAP) out[out.length - 1][1] = b[1];
1180 else out.push([b[0], b[1]]);
1181 }
1182 return out;
1183}
1184
1185/// Fit `ys ≈ a·xs + c` and report the fit and how far off it is.
1186///
1187/// `a` near 1 and a residual under a pixel is two rasterisations of one layout. A
1188/// residual of several pixels is a layout that moved.
1189function fit(xs, ys) {
1190 const n = xs.length;
1191 if (!n || n !== ys.length) return { a: 0, c: 0, rms: Infinity, worst: Infinity };
1192 let mx = 0, my = 0;
1193 for (let i = 0; i < n; i++) { mx += xs[i]; my += ys[i]; }
1194 mx /= n; my /= n;
1195 let sxx = 0, sxy = 0;
1196 for (let i = 0; i < n; i++) { sxx += (xs[i] - mx) ** 2; sxy += (xs[i] - mx) * (ys[i] - my); }
1197 const a = sxx ? sxy / sxx : 1;
1198 const c = my - a * mx;
1199 let sum = 0, worst = 0;
1200 for (let i = 0; i < n; i++) {
1201 const d = Math.abs(ys[i] - (a * xs[i] + c));
1202 sum += d * d;
1203 if (d > worst) worst = d;
1204 }
1205 return { a, c, rms: Math.sqrt(sum / n), worst };
1206}
1207
1208/// Compile `main` to a PDF, photograph the live view, and compare the ink.
1209///
1210/// The PDF is rasterised by POPPLER and the pages by CHROME, from one compile of one
1211/// source. Neither raster is ours, and poppler has no typst in it, so an agreement
1212/// here is not this project agreeing with itself.
1213///
1214/// Alignment is measured rather than assumed: two rasterisers round a page box
1215/// differently, and a whole-pixel offset is not a layout difference. The best shift
1216/// is reported and bounded — a page that really had reflowed would not come back
1217/// into correlation at ANY shift.
1218async function inkCompare(page, main) {
1219 // THE PDF OF THE SOURCE AS IT STANDS NOW, and this is the trap the first version
1220 // of this check fell into: the PDF the Compile button wrote was of the source as
1221 // it was when the button was pressed, and by this point the loop has rebuilt from
1222 // four edits. The two pages differed in one word, the correlation collapsed, and
1223 // the check reported a rendering fault that was entirely its own.
1224 //
1225 // Through `typst_compile_project` — the page's own Compile door, the same one the
1226 // button uses — so the PDF and the live view come from one gatherer and one
1227 // compiler and differ only in what they were asked to produce.
1228 const bytes = await page.evaluate(async (p) => {
1229 const m = await import('/pkg/oxedyne_daimond.js');
1230 const out = await m.typst_compile_project(p);
1231 return out.pdf ? Array.from(out.pdf) : [];
1232 }, main);
1233 const pdf = path.join(WORK, 'a.pdf');
1234 fs.writeFileSync(pdf, Buffer.from(bytes));
1235
1236 // The live view's first page — PHOTOGRAPHED WHERE IT IS ON SCREEN, clipped to the
1237 // page's own rectangle in the viewport.
1238 //
1239 // Not an element screenshot of the page host: that host is two pages tall and
1240 // taller than the viewport, and what came back had eight rows of the panel's dark
1241 // chrome across the top and the scroller's background down both edges. It read as
1242 // a third more ink than the PDF and as a page whose text block was fourteen pixels
1243 // wider, neither of which had anything to do with the document. A viewport clip is
1244 // literally what the reader sees.
1245 const live = path.join(WORK, 'live1.png');
1246 const box = await page.evaluate(() => {
1247 const h = document.querySelector('#typst-live .tl-pages');
1248 document.querySelector('#typst-live .tl-scroll').scrollTop = 0;
1249 // THE FIRST SHEET, which is exactly one page: the pages are separate elements
1250 // now, so the first page's rectangle is an element's rectangle and no longer
1251 // has to be worked out by dividing the stack. The SHEET and not the svg inside
1252 // it, because the sheet is the page — an svg that had grown taller than its
1253 // page would make this photograph the whole book and fail the ink comparison
1254 // for a reason that has nothing to do with the ink.
1255 const r = h.shadowRoot.querySelector('.tl-sheet').getBoundingClientRect();
1256 return { x: r.left, y: r.top, w: r.width, h: r.height };
1257 });
1258 await page.screenshot({
1259 path: live,
1260 clip: { x: box.x, y: box.y, width: Math.floor(box.w), height: Math.floor(box.h) },
1261 });
1262
1263 execFileSync('pdftoppm', ['-r', '150', '-gray', '-png', '-f', '1', '-l', '1',
1264 '-scale-to-x', String(Math.floor(box.w)), '-scale-to-y', '-1', pdf,
1265 path.join(WORK, 'pdf')]);
1266 const pdfPng = path.join(WORK,
1267 fs.readdirSync(WORK).find(f => /^pdf-?0*1\.png$/.test(f)));
1268
1269 const A = inkOf(pdfPng), B = inkOf(live);
1270 // Two pixels off every edge of both. A clipped screenshot lands on a fractional
1271 // device pixel and blends the edge with whatever is behind the page; two pixels of
1272 // a 576-pixel page is nothing, and it is the difference between measuring a
1273 // document and measuring the panel it sits in.
1274 const IN = 2;
1275 const w = Math.min(A.w, B.w) - 2 * IN, h = Math.min(A.h, B.h) - 2 * IN;
1276 const rowA = new Float64Array(h), rowB = new Float64Array(h);
1277 const colA = new Float64Array(w), colB = new Float64Array(w);
1278 let diff = 0, inkA = 0, inkB = 0;
1279 for (let y = 0; y < h; y++) {
1280 for (let x = 0; x < w; x++) {
1281 const a = 255 - A.gray[(y + IN) * A.w + (x + IN)];
1282 const b = 255 - B.gray[(y + IN) * B.w + (x + IN)];
1283 inkA += a; inkB += b;
1284 rowA[y] += a; rowB[y] += b;
1285 colA[x] += a; colB[x] += b;
1286 if (Math.abs(a - b) > 8) diff++;
1287 }
1288 }
1289 // The two rasterisers disagree about how heavily a glyph is drawn — poppler hints,
1290 // Chrome fills an outline — so the profiles are levelled by TOTAL INK before the
1291 // lines are picked out of them. Otherwise the heavier of the two never dips below
1292 // a threshold between two lines and two lines read as one, which is a rasteriser
1293 // difference reported as a reflow.
1294 if (inkB) {
1295 const k = inkA / inkB;
1296 for (let i = 0; i < h; i++) rowB[i] *= k;
1297 for (let i = 0; i < w; i++) colB[i] *= k;
1298 }
1299 // The lines of the page, and the columns the text is set between.
1300 const lineA = bands(rowA), lineB = bands(rowB);
1301 const midA = lineA.map(([a, b]) => (a + b) / 2), midB = lineB.map(([a, b]) => (a + b) / 2);
1302 const rows = (midA.length === midB.length) ? fit(midA, midB)
1303 : { a: 0, c: 0, rms: Infinity, worst: Infinity };
1304 // And the same lines WITHOUT a fit. The fit says "nothing reflowed relative to
1305 // everything else", which a page drawn at the wrong scale or in the wrong place
1306 // would satisfy perfectly; this says the lines are where the PDF puts them, full
1307 // stop, and the only slack is the pixel the two rasterisers round differently.
1308 let raw = Infinity;
1309 if (midA.length === midB.length && midA.length) {
1310 raw = 0;
1311 for (let i = 0; i < midA.length; i++) raw = Math.max(raw, Math.abs(midB[i] - midA[i]));
1312 }
1313 const colBandA = bands(colA), colBandB = bands(colB);
1314 // The measure — where the text block starts and ends across the page. A
1315 // re-justified paragraph moves it; a rasteriser does not.
1316 const edgeA = colBandA.length ? [colBandA[0][0], colBandA[colBandA.length - 1][1]] : [0, 0];
1317 const edgeB = colBandB.length ? [colBandB[0][0], colBandB[colBandB.length - 1][1]] : [0, 0];
1318 // How much of the photograph is paper and how much is ink, so "the page is
1319 // drawn" cannot be satisfied by a dark rectangle where the page should be.
1320 let paper = 0, dark = 0;
1321 for (let y = 0; y < h; y++) {
1322 for (let x = 0; x < w; x++) {
1323 const v = B.gray[(y + IN) * B.w + (x + IN)];
1324 if (v > 240) paper++; else if (v < 128) dark++;
1325 }
1326 }
1327 const png = path.join(WORK, 'difference.png');
1328 try {
1329 execFileSync('convert', [pdfPng, live, '-compose', 'difference', '-composite',
1330 '-negate', png]);
1331 } catch (e) { /* the difference image is evidence, not a check */ }
1332 return {
1333 ratio: inkA ? inkB / inkA : 0,
1334 lines: { pdf: midA.length, live: midB.length },
1335 rows, raw,
1336 measure: { pdf: edgeA, live: edgeB },
1337 diff: diff / (w * h), paper: paper / (w * h), ink: dark / (w * h), w, h, png,
1338 };
1339}
1340
1341console.log(`\nchecks: ${ok.length} ok, ${bad.length} failed`);
1342if (BREAK) {
1343 console.log(`\nbreak '${BREAK}': ${bad.length} check(s) failed`
1344 + (bad.length ? ' — ' + bad.join('; ') : ' — NOTHING FAILED, so the checks above prove nothing'));
1345 process.exit(bad.length ? 0 : 1); // a break MUST fail something
1346}
1347console.log(bad.length === 0 ? '\nall checks passed' : `\n${bad.length} check(s) FAILED`);
1348process.exit(bad.length === 0 ? 0 : 1);