Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit c74737c5e5bba101d7a66a61f522d94dfb6d3f2b
parent 1bbe569d4816f82630fd9876033a071291143cb5
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun,  4 Oct 2026 16:26:24 -0400

report-to-video: shoot-page walks the DOM by hand, keeps marks layout-neutral, isolates the block

With page scripts off the DOM refuses a TreeWalker filter callback, so
the text walk is a plain stack. Marks get vertical padding only and are
positioned earlier-on-top, so an italic overhang is not covered by the
next mark; only the outer ends are rounded. Content outside the shot
block is hidden (--no-isolate keeps it). The browser test covers a <br>
inside a block trimmed to --max-height and the CLI's batch miss path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mumtool/report-to-video/shoot-page.mjs | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mumtool/report-to-video/shoot-page.test.mjs | 33+++++++++++++++++++++++++++++++++
2 files changed, 86 insertions(+), 22 deletions(-)

diff --git a/umtool/report-to-video/shoot-page.mjs b/umtool/report-to-video/shoot-page.mjs @@ -40,6 +40,7 @@ // --max-height <px> A taller block is trimmed to this, centred on the // quote (default: 1200) // --js Run the page's own scripts +// --no-isolate Leave the neighbouring content visible in the padding // --timeout <ms> Page load timeout (default: 20000) import { existsSync, statSync } from "node:fs"; @@ -55,6 +56,7 @@ export const DEFAULTS = Object.freeze({ scale: 2, maxHeight: 1200, js: false, + isolate: true, timeout: 20_000, }); @@ -147,7 +149,7 @@ export function matchInSegments(segments, quote, context) { // --------------------------------------------------------------------------- function browserShoot(args) { - const { quote, context, color, padding, maxHeight } = args; + const { quote, context, color, padding, maxHeight, isolate } = args; const doc = document; const SKIP = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "TITLE", "HEAD", "SVG", "MATH", "IFRAME", "OBJECT", "SELECT", "TEXTAREA"]); const blockCache = new Map(); @@ -173,26 +175,32 @@ function browserShoot(args) { return b; }; + // A hand-rolled walk, not a TreeWalker with a filter: with the page's scripts + // off, the DOM refuses to call back into script ("callback is no longer + // runnable"), and a filter is a callback. const segs = []; let lastBlock = null; - const walker = doc.createTreeWalker(doc.body ?? doc.documentElement, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, { - acceptNode(n) { - if (n.nodeType === 3) return NodeFilter.FILTER_ACCEPT; - if (SKIP.has(n.tagName.toUpperCase())) return NodeFilter.FILTER_REJECT; - const st = getComputedStyle(n); - if (st.display === "none" || st.visibility === "hidden") return NodeFilter.FILTER_REJECT; - return n.tagName === "BR" ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; - }, - }); - for (let n = walker.nextNode(); n; n = walker.nextNode()) { - if (n.nodeType === 1) { + const stack = [doc.body ?? doc.documentElement]; + while (stack.length) { + const n = stack.pop(); + if (n.nodeType === 3) { + const b = blockOf(n); + if (lastBlock && b !== lastBlock) segs.push({ node: null, text: " " }); + lastBlock = b; + segs.push({ node: n, text: n.data }); + continue; + } + if (n.nodeType !== 1 || SKIP.has(n.tagName.toUpperCase())) continue; + const st = getComputedStyle(n); + if (st.display === "none") continue; + if (n.tagName === "BR") { segs.push({ node: null, text: " " }); continue; } - const b = blockOf(n); - if (lastBlock && b !== lastBlock) segs.push({ node: null, text: " " }); - lastBlock = b; - segs.push({ node: n, text: n.data }); + // A hidden element's children may be visible again, so only its own text + // is dropped; children are pushed in reverse to pop in document order. + const kids = [...n.childNodes].filter((k) => k.nodeType === 1 || st.visibility !== "hidden"); + for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]); } const m = matchInSegments(segs.map((s) => s.text), quote, context); @@ -220,15 +228,35 @@ function browserShoot(args) { if (a > 0) node = node.splitText(a); const mark = doc.createElement("mark"); mark.setAttribute("data-shoot-page", ""); - // box-shadow, not padding: the highlight must not reflow the paragraph. - mark.style.cssText = - `background:${color} !important;color:inherit !important;` + - `box-shadow:0 0 0 0.12em ${color};border-radius:0.12em;` + - "-webkit-box-decoration-break:clone;box-decoration-break:clone;"; node.parentNode.insertBefore(mark, node); mark.appendChild(node); marks.push(mark); } + // The highlight must not reflow the paragraph: vertical padding on an inline + // box does not move a line, horizontal padding would, so there is none. + // Each mark is positioned, EARLIER marks on top: inline boxes paint in tree + // order, so otherwise the next mark's background is laid over the previous + // one's glyph overhang — an italic word loses its last letter's tail. Only + // the two outer ends are rounded, or every element boundary shows a notch. + marks.forEach((mark, i) => { + const r = (left, right) => `${left ? "0.15em" : "0"} ${right ? "0.15em" : "0"} ${right ? "0.15em" : "0"} ${left ? "0.15em" : "0"}`; + mark.style.cssText = + `background:${color} !important;color:inherit !important;padding:0.08em 0 !important;margin:0 !important;` + + `position:relative;z-index:${marks.length - i};border-radius:${r(i === 0, i === marks.length - 1)};` + + "-webkit-box-decoration-break:clone;box-decoration-break:clone;"; + }); + + // Everything that is neither the block, inside it, nor around it is hidden + // for the shot, so the padding shows the page's ground and not the bottom of + // the paragraph above. Hidden, not removed: nothing moves. + if (isolate) { + block.setAttribute("data-shoot-page-block", ""); + const style = doc.createElement("style"); + style.textContent = + "body *:not([data-shoot-page-block]):not([data-shoot-page-block] *):not(:has([data-shoot-page-block]))" + + " { visibility: hidden !important; }"; + (doc.head ?? doc.documentElement).appendChild(style); + } const sx = window.scrollX; const sy = window.scrollY; @@ -380,6 +408,7 @@ async function shootOne(ctx, item, o) { color: o.color, padding: o.padding, maxHeight: o.maxHeight, + isolate: o.isolate, }), ); if (!r.ok) return { ...base, ok: false, reason: r.reason, occurrences: r.occurrences, blocked }; @@ -459,6 +488,7 @@ export function parseArgs(argv) { case "--max-height": opts.maxHeight = num(a, next()); break; case "--timeout": opts.timeout = num(a, next()); break; case "--js": opts.js = true; break; + case "--no-isolate": opts.isolate = false; break; default: throw new Error(`unknown argument: ${a}`); } } @@ -473,7 +503,8 @@ export function parseArgs(argv) { const USAGE = "usage: shoot-page.mjs --page <file.html> --quote <text> [--context <text>] --out <shot.png>\n" + " shoot-page.mjs --batch <items.json> --out <dir>\n" + - " [--color <css>] [--padding <px>] [--width <px>] [--scale <n>] [--max-height <px>] [--js] [--timeout <ms>]"; + " [--color <css>] [--padding <px>] [--width <px>] [--scale <n>] [--max-height <px>] [--js] [--no-isolate]\n" + + " [--timeout <ms>]"; async function main() { let args; diff --git a/umtool/report-to-video/shoot-page.test.mjs b/umtool/report-to-video/shoot-page.test.mjs @@ -6,10 +6,12 @@ // // Run with: pnpm test:scripts import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; import { mkdtemp, readFile, rm, writeFile, mkdir } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; +import { fileURLToPath } from "node:url"; import { allowedRequest, matchInSegments, normaliseWithMap, pageExpression, parseArgs, shootPages, validateItems, @@ -176,6 +178,7 @@ const FIXTURE = `<!doctype html> <div style="display:none">a hidden sentence that must not match</div> <img src="https://example.invalid/tracker.png" alt=""> <img src="../outside.png" alt=""> +<div id="long">${Array.from({ length: 80 }, (_, i) => `Line ${i} of the long block.`).join("<br>")}</div> </body></html>`; function pngSize(buf) { @@ -201,6 +204,8 @@ test("a real page: shots, marks, a miss, and nothing fetched from outside", { { id: "curly", page: "saved/article.html", quote: "\"we're not going\"" }, { id: "hidden", page: "saved/article.html", quote: "a hidden sentence" }, { id: "nopage", page: "saved/missing.html", quote: "anything" }, + // Across a <br>, in a block far taller than --max-height. + { id: "long", page: "saved/article.html", quote: "Line 40 of the long block. Line 41" }, ].map((it) => ({ ...it, out: path.join(out, `${it.id}.png`) })); const results = await shootPages(items, { baseDir: dir, padding: 10 }); @@ -231,6 +236,34 @@ test("a real page: shots, marks, a miss, and nothing fetched from outside", { assert.ok(r.blocked.some((u) => u.endsWith("/outside.png")), id); assert.ok(!r.blocked.some((u) => u.endsWith("style.css")), id); } + + // The tall block is trimmed to max-height plus padding, around the quote. + assert.equal(by.long.ok, true); + assert.equal(by.long.block, "#long"); + assert.equal(by.long.trimmed, true); + // (Within a pixel: the crop floors its top edge and ceils its bottom.) + assert.ok(Math.abs(by.long.crop.height - (1200 + 2 * 10)) <= 1, String(by.long.crop.height)); + + // The CLI: a batch with a miss writes what it shot, lists the miss in the + // results, says so on stderr, and exits 1. + await writeFile(path.join(dir, "items.json"), JSON.stringify([ + { id: "ok1", page: "saved/article.html", quote: "would be dropped" }, + { id: "miss1", page: "saved/article.html", quote: "not on this page" }, + ])); + const cli = await new Promise((resolve) => { + execFile(process.execPath, [ + fileURLToPath(new URL("./shoot-page.mjs", import.meta.url)), + "--batch", path.join(dir, "items.json"), "--out", path.join(dir, "cli"), + ], (err, stdout, stderr) => resolve({ code: err ? err.code : 0, stderr })); + }); + assert.equal(cli.code, 1); + assert.match(cli.stderr, /MISSED 1 of 2/); + assert.match(cli.stderr, /miss1: quote not found/); + const report = JSON.parse(await readFile(path.join(dir, "cli", "results.json"), "utf8")); + assert.deepEqual([report.shot, report.missed], [1, 1]); + assert.deepEqual(report.items.map((r) => [r.id, r.ok]), [["ok1", true], ["miss1", false]]); + assert.equal(report.items[0].png, path.join(dir, "cli", "ok1.png")); + pngSize(await readFile(report.items[0].png)); } finally { await rm(dir, { recursive: true, force: true }); }