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:
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 });
}