Skill Tracer
Skills › Anatomy › anatomy › print.mjs

print.mjs

260 lines · scripts/print.mjs at 1482e84

1import { spawnSync } from "node:child_process";
2import { closeSync, existsSync, mkdtempSync, openSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
3import { homedir, tmpdir } from "node:os";
4import { dirname, join, resolve } from "node:path";
5import { fileURLToPath, pathToFileURL } from "node:url";
6import { FORMATS, PAPER, pagesOf, sheetHtml } from "./print-sheet.mjs";
7
8const HERE = dirname(fileURLToPath(import.meta.url));
9const DRIVE = join(HERE, "drive.mjs");
11function usage() {
12 console.log(`Print a figure as an explained drawing on one A4 sheet: an HTML page and a PDF.
14 node print.mjs <sheet.mjs> [--format poster|drawing|detailed|all] [--letter] [--png] [--out name]
16 sheet.mjs a module whose default export names the built page, the poses to capture, the parts and the text
17 (see references/print.md and examples/raptor-engine/sheet.mjs)
18 --format f poster (one page, big title, numbers and a legend; the default), drawing (one page, parts list,
19 specification and a strip of steps), detailed (two pages: the figure with every part, then the steps,
20 close-ups and the why), or all
21 --letter US Letter instead of A4
22 --png also write a preview PNG of the sheet
23 --out name output base name (default: the spec's out, else the page name)
25Writes <name>-<format>-a4.html and .pdf next to the spec.`);
28export function chromePath() {
29 const candidates = [
30 process.env.CHROME_PATH,
31 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
32 "/Applications/Chromium.app/Contents/MacOS/Chromium",
33 "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
34 "/usr/bin/google-chrome",
35 "/usr/bin/google-chrome-stable",
36 "/usr/bin/chromium",
37 "/usr/bin/chromium-browser",
38 "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
39 "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
40 ...(process.env.LOCALAPPDATA ? [join(process.env.LOCALAPPDATA, "Google\\Chrome\\Application\\chrome.exe")] : []),
41 "C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
42 "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
43 ].filter(Boolean);
44 const playwright = join(homedir(), "Library/Caches/ms-playwright");
45 if (existsSync(playwright)) {
46 for (const dir of readdirSync(playwright).sort().reverse()) {
47 for (const sub of ["chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing", "chrome-mac/Chromium.app/Contents/MacOS/Chromium", "chrome-linux/chrome"]) {
48 candidates.push(join(playwright, dir, sub));
49 }
50 }
51 }
52 const linux = join(homedir(), ".cache/ms-playwright");
53 if (existsSync(linux)) for (const dir of readdirSync(linux).sort().reverse()) for (const sub of ["chrome-linux64/chrome", "chrome-linux/chrome"]) candidates.push(join(linux, dir, sub));
54 return candidates.find((path) => existsSync(path));
57export function captureScript(spec) {
58 const config = {
59 layers: spec.layers ?? ".iso-stage svg.iso-svg, .iso-plate svg.iso-svg",
60 strip: spec.strip ?? [],
61 parts: (spec.parts ?? []).filter((part) => part.select).map(({ id, select }) => ({ id, select })),
62 frame: spec.rasterSelector ?? ".iso-stage",
63 };
64 return `(() => {
65 const C = ${JSON.stringify(config)};
66 const TONES = ["hi", "mid", "lo", "dot"];
67 const layers = [...document.querySelectorAll(C.layers)].filter((svg) => svg.viewBox && svg.viewBox.baseVal && svg.viewBox.baseVal.width);
68 if (!layers.length) return { __print: true, error: "no layers match " + C.layers };
69 const base = layers[0];
70 const vbox = base.viewBox.baseVal;
71 const rect = base.getBoundingClientRect();
72 const k = vbox.width / rect.width;
73 const toVb = (b) => [vbox.x + (b.left - rect.left) * k, vbox.y + (b.top - rect.top) * k, vbox.x + (b.right - rect.left) * k, vbox.y + (b.bottom - rect.top) * k];
74 const sets = C.parts.map((part) => ({ id: part.id, els: layers.map((svg) => [...svg.querySelectorAll(part.select)]) }));
75 const boxes = {};
76 const anchors = {};
77 for (const set of sets) {
78 let box = null;
79 let best = null;
80 for (const el of set.els.flat()) {
81 const b = el.getBoundingClientRect();
82 if (!b.width && !b.height) continue;
83 const v = toVb(b);
84 box = box ? [Math.min(box[0], v[0]), Math.min(box[1], v[1]), Math.max(box[2], v[2]), Math.max(box[3], v[3])] : v;
85 for (const shape of el.matches("path,circle,ellipse,rect,polygon") ? [el] : el.querySelectorAll("path,circle,ellipse,rect,polygon")) {
86 if (shape.closest(".iso-halo, [class*=halo]")) continue;
87 const s = shape.getBoundingClientRect();
88 const area = s.width * s.height;
89 if (!best || area > best.area) best = { area, at: [(toVb(s)[0] + toVb(s)[2]) / 2, (toVb(s)[1] + toVb(s)[3]) / 2] };
90 }
91 }
92 if (box) boxes[set.id] = box.map((n) => Math.round(n * 10) / 10);
93 if (best) anchors[set.id] = best.at.map((n) => Math.round(n * 10) / 10);
94 }
95 const html = layers.map((svg, index) => {
96 const live = new Map();
97 for (const set of sets) for (const el of set.els[index]) live.set(el, set.id);
98 const clone = svg.cloneNode(true);
99 const walkLive = [...svg.querySelectorAll("*")];
100 const walkClone = [...clone.querySelectorAll("*")];
101 walkLive.forEach((el, at) => {
102 const id = live.get(el);
103 if (!id) return;
104 const cs = getComputedStyle(el);
105 const tokens = TONES.map((tone) => "--anatomy-" + tone + ":var(--on-" + id + "-" + tone + ",var(--part-" + tone + "," + (cs.getPropertyValue("--anatomy-" + tone).trim() || "currentColor") + "))").join(";");
106 const target = walkClone[at];
107 const own = target.getAttribute("style");
108 target.setAttribute("style", (own ? own.replace(/;?\\s*$/, ";") : "") + tokens + ";display:var(--show-" + id + "," + (cs.display === "none" ? "none" : "inline") + ")");
109 target.setAttribute("data-print-part", id);
110 });
111 for (const sel of C.strip) clone.querySelectorAll(sel).forEach((el) => el.remove());
112 clone.querySelectorAll("title,desc").forEach((el) => el.remove());
113 clone.querySelectorAll("[id]").forEach((el) => el.setAttribute("id", "ps" + index + "-" + el.id));
114 let inner = clone.innerHTML;
115 clone.querySelectorAll("[id]").forEach((el) => {
116 const old = el.id.slice(("ps" + index + "-").length);
117 inner = inner.split("url(#" + old + ")").join("url(#" + el.id + ")").split('href="#' + old + '"').join('href="#' + el.id + '"');
118 });
119 return inner;
120 });
121 let content = null;
122 for (const svg of layers) for (const el of svg.children) {
123 if (el.tagName === "defs" || el.tagName === "title" || el.tagName === "desc") continue;
124 const b = el.getBoundingClientRect();
125 if (!b.width || !b.height) continue;
126 const v = toVb(b);
127 content = content ? [Math.min(content[0], v[0]), Math.min(content[1], v[1]), Math.max(content[2], v[2]), Math.max(content[3], v[3])] : v;
128 }
129 const css = [...document.querySelectorAll("style")].map((s) => s.textContent).join("\\n");
130 const r = (document.querySelector(C.frame) || base.parentElement).getBoundingClientRect();
131 return { __print: true, vb: [vbox.x, vbox.y, vbox.width, vbox.height], content: content && [content[0], content[1], content[2] - content[0], content[3] - content[1]].map((n) => Math.round(n * 10) / 10), k, offset: [(rect.left - r.left), (rect.top - r.top)], layers: html, boxes, anchors, css, readout: (document.querySelector("[data-readout]") || {}).textContent || "" };
132})()`;
135function drive(page, actions, { reduced, width, height }) {
136 const profile = mkdtempSync(join(tmpdir(), "iso-print-"));
137 const file = join(profile, "actions.json");
138 writeFileSync(file, JSON.stringify(actions));
139 const args = [DRIVE, page, "--actions", file, "--width", String(width), "--height", String(height)];
140 if (reduced) args.push("--reduced");
141 const log = join(profile, "out.txt");
142 const fd = openSync(log, "w");
143 const run = spawnSync(process.execPath, args, { stdio: ["ignore", fd, "pipe"], encoding: "utf8", maxBuffer: 1 << 26 });
144 closeSync(fd);
145 const stdout = readFileSync(log, "utf8");
146 rmSync(profile, { recursive: true, force: true });
147 if (run.status !== 0) throw new Error(`drive.mjs failed:\n${(run.stderr || stdout || "").slice(-2000)}`);
148 return stdout
149 .split("\n")
150 .filter((line) => line.startsWith("eval: "))
151 .map((line) => JSON.parse(line.slice(6)));
154export async function capture(spec, pagePath) {
155 const options = { reduced: spec.reduced ?? true, width: spec.width ?? 1200, height: spec.height ?? 1200 };
156 const views = Object.entries(spec.views ?? { rest: {} });
157 const script = captureScript(spec);
158 const actions = [["wait", spec.settle ?? 1200]];
159 for (const [, view] of views) actions.push(...(view.actions ?? []), ["eval", script], ...(view.reset ?? []));
160 const results = drive(pagePath, actions, options).filter((result) => result && result.__print);
161 if (results.length !== views.length) throw new Error(`expected ${views.length} captures, got ${results.length}`);
162 const out = { views: {}, css: "" };
163 results.forEach((result, index) => {
164 if (result.error) throw new Error(result.error);
165 out.css = result.css;
166 const [name, view] = views[index];
167 out.views[name] = { vb: result.vb, content: result.content, layers: result.layers, boxes: result.boxes, anchors: result.anchors, k: result.k, offset: result.offset, readout: result.readout, raster: view.raster ? true : undefined };
168 });
169 const rasters = views.filter(([, view]) => view.raster);
170 if (rasters.length) {
171 const dir = mkdtempSync(join(tmpdir(), "iso-print-shots-"));
172 const shots = [["wait", spec.settle ?? 1200]];
173 for (const [name, view] of views) {
174 shots.push(...(view.actions ?? []));
175 if (view.raster) {
176 const captured = out.views[name];
177 const region = view.raster.region ?? captured.vb;
178 const [vx, vy] = captured.vb;
179 const box = [(region[0] - vx) / captured.k + captured.offset[0], (region[1] - vy) / captured.k + captured.offset[1], region[2] / captured.k, region[3] / captured.k].map((n) => Math.round(n));
180 shots.push(["shot", join(dir, `${name}.png`), view.raster.selector ?? spec.rasterSelector ?? ".iso-stage", view.raster.scale ?? 2, box]);
181 out.views[name].raster = { region, file: join(dir, `${name}.png`) };
182 }
183 shots.push(...(view.reset ?? []));
184 }
185 drive(pagePath, shots, options);
186 for (const [name] of rasters) {
187 const raster = out.views[name].raster;
188 if (!existsSync(raster.file)) throw new Error(`raster shot for ${name} was not written`);
189 raster.href = `data:image/png;base64,${readFileSync(raster.file).toString("base64")}`;
190 delete raster.file;
191 }
192 rmSync(dir, { recursive: true, force: true });
193 }
194 return out;
197export function printPdf(htmlFile, pdfFile, { png, paper, pages: expected = 1 } = {}) {
198 const binary = chromePath();
199 if (!binary) throw new Error("No Chrome found. Set CHROME_PATH to a Chrome or Chromium binary.");
200 const profile = mkdtempSync(join(tmpdir(), "iso-print-pdf-"));
201 const common = ["--headless=new", ...(process.platform === "linux" ? ["--no-sandbox"] : []), "--disable-gpu", "--hide-scrollbars", "--no-first-run", "--no-default-browser-check", "--mute-audio", "--allow-file-access-from-files", `--user-data-dir=${profile}`];
202 const url = pathToFileURL(htmlFile).href;
203 if (existsSync(pdfFile)) rmSync(pdfFile);
204 spawnSync(binary, [...common, "--no-pdf-header-footer", `--print-to-pdf=${pdfFile}`, url], { stdio: "ignore", timeout: 120000 });
205 if (png) spawnSync(binary, [...common, "--force-device-scale-factor=2", `--window-size=${paper.px[0]},${paper.px[1] * expected}`, `--screenshot=${png}`, `${url}?print`], { stdio: "ignore", timeout: 120000 });
206 rmSync(profile, { recursive: true, force: true });
207 if (!existsSync(pdfFile) || !statSync(pdfFile).size) throw new Error(`Chrome did not write ${pdfFile}`);
208 const pages = (readFileSync(pdfFile, "latin1").match(/\/Type\s*\/Page(?![s\w])/g) ?? []).length;
209 return { pages, bytes: statSync(pdfFile).size };
212async function main() {
213 const argv = process.argv.slice(2);
214 if (!argv.length || argv.includes("--help") || argv.includes("-h")) {
215 usage();
216 process.exit(argv.length ? 0 : 1);
217 }
218 const rest = [];
219 const options = { letter: false, png: false, out: null, format: null };
220 for (let index = 0; index < argv.length; index++) {
221 const arg = argv[index];
222 if (arg === "--letter") options.letter = true;
223 else if (arg === "--png") options.png = true;
224 else if (arg === "--out") options.out = argv[++index];
225 else if (arg === "--format") options.format = argv[++index];
226 else rest.push(arg);
227 }
228 const specFile = resolve(rest[0]);
229 const spec = (await import(pathToFileURL(specFile).href)).default;
230 const dir = dirname(specFile);
231 const pagePath = resolve(dir, spec.page);
232 if (!existsSync(pagePath)) throw new Error(`no built page at ${pagePath}; build the figure with --light first`);
233 const paper = options.letter ? PAPER.letter : PAPER.a4;
234 const formats = options.format === "all" ? FORMATS : [options.format ?? spec.format ?? "poster"];
235 for (const format of formats) if (!FORMATS.includes(format)) throw new Error(`unknown format "${format}"; use one of ${FORMATS.join(", ")} or all`);
236 const base = options.out ?? spec.out ?? spec.page.replace(/(-light)?\.html$/, "");
237 const captured = await capture(spec, pagePath);
238 for (const part of spec.parts ?? []) {
239 if (part.select && !captured.views[spec.main.view].boxes[part.id]) console.warn(`warning: part ${part.id} matched nothing in view ${spec.main.view}`);
240 }
241 let failed = false;
242 for (const format of formats) {
243 const name = `${base}-${format}-${options.letter ? "letter" : "a4"}`;
244 const html = sheetHtml(captured, spec, paper, format);
245 const htmlFile = join(dir, `${name}.html`);
246 writeFileSync(htmlFile, html);
247 const pdfFile = join(dir, `${name}.pdf`);
248 const png = options.png ? join(dir, `${name}.png`) : null;
249 const expected = pagesOf(format, spec);
250 const { pages, bytes } = printPdf(htmlFile, pdfFile, { png, paper, pages: expected });
251 console.log(`wrote ${name}.html (${(html.length / 1024).toFixed(0)} KB) and ${name}.pdf (${(bytes / 1024).toFixed(0)} KB, ${pages} page${pages === 1 ? "" : "s"})${png ? ` and ${name}.png` : ""}`);
252 if (pages !== expected) {
253 console.error(`${format} should print on ${expected} page${expected === 1 ? "" : "s"} but runs to ${pages}; shorten the text or the steps until it fits`);
254 failed = true;
255 }
256 }
257 if (failed) process.exit(1);
260if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();