From e9cf841ce7bac96fc6b30a7639693eea57e35dd4 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Sun, 11 Oct 2026 06:52:32 +0300 Subject: [PATCH] wave E stage 2: renderSvg goes native-first - resvg in-process Visual-maker SVGs never execute script, so a browser added nothing but a process. resvg rasterizes in-process now (white background and fitTo width mirror the old Chrome-page semantics; svgViewport unchanged, quirks included for parity). The old Chrome path stays verbatim as the fallback for exotic-SVG parse failures - it was the only path before, so nothing regresses. The resvg binary (4MB per platform, one per build) rides the standalone via the same tracing pattern. Gates 1519/1519; native path pinned against real SVGs (intrinsic size, viewBox-only 900 default, opts override, system-font text). --- next.config.ts | 4 + package-lock.json | 228 ++++++++++++++++++++++++++++++++ package.json | 1 + src/agent/subagents/headless.ts | 20 +++ tests/svg-native-render.test.ts | 69 ++++++++++ 5 files changed, 322 insertions(+) create mode 100644 tests/svg-native-render.test.ts diff --git a/next.config.ts b/next.config.ts index 1ed6289..e9e1390 100644 --- a/next.config.ts +++ b/next.config.ts @@ -61,6 +61,9 @@ const nextConfig: NextConfig = { // npm installs only the current platform's binary, so this glob // resolves to exactly one Skia binary per build. "node_modules/@napi-rs/canvas-*/**/*", + // Wave E stage 2: resvg rasterizes visual-maker SVGs natively; same + // per-platform-binary story as the canvas above (one per build). + "node_modules/@resvg/resvg-js-*/**/*", "node_modules/@earendil-works/pi-ai/dist/providers/data/**/*", "node_modules/@earendil-works/pi-agent-core/dist/**/*.json", ], @@ -73,6 +76,7 @@ const nextConfig: NextConfig = { "@earendil-works/pi-agent-core", "pdfjs-dist", // sources v0: bundling breaks its node fake-worker path "@napi-rs/canvas", // native binding — bundling breaks the .node require + "@resvg/resvg-js", // native binding — same ], }; diff --git a/package-lock.json b/package-lock.json index 179d16f..2abc297 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@earendil-works/pi-ai": "1.0.2", "@mozilla/readability": "^0.6.0", "@radix-ui/react-use-controllable-state": "^1.2.6", + "@resvg/resvg-js": "^2.6.2", "@rive-app/react-webgl2": "^4.32.1", "@streamdown/cjk": "^1.0.3", "@streamdown/code": "^1.1.1", @@ -4454,6 +4455,233 @@ "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", "license": "MIT" }, + "node_modules/@resvg/resvg-js": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", + "integrity": "sha512-xBaJish5OeGmniDj9cW5PRa/PtmuVU3ziqrbr5xJj901ZDN4TosrVaNZpEiLZAxdfnhAe7uQ7QFWfjPe9d9K2Q==", + "license": "MPL-2.0", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@resvg/resvg-js-android-arm-eabi": "2.6.2", + "@resvg/resvg-js-android-arm64": "2.6.2", + "@resvg/resvg-js-darwin-arm64": "2.6.2", + "@resvg/resvg-js-darwin-x64": "2.6.2", + "@resvg/resvg-js-linux-arm-gnueabihf": "2.6.2", + "@resvg/resvg-js-linux-arm64-gnu": "2.6.2", + "@resvg/resvg-js-linux-arm64-musl": "2.6.2", + "@resvg/resvg-js-linux-x64-gnu": "2.6.2", + "@resvg/resvg-js-linux-x64-musl": "2.6.2", + "@resvg/resvg-js-win32-arm64-msvc": "2.6.2", + "@resvg/resvg-js-win32-ia32-msvc": "2.6.2", + "@resvg/resvg-js-win32-x64-msvc": "2.6.2" + } + }, + "node_modules/@resvg/resvg-js-android-arm-eabi": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm-eabi/-/resvg-js-android-arm-eabi-2.6.2.tgz", + "integrity": "sha512-FrJibrAk6v29eabIPgcTUMPXiEz8ssrAk7TXxsiZzww9UTQ1Z5KAbFJs+Z0Ez+VZTYgnE5IQJqBcoSiMebtPHA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-android-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-android-arm64/-/resvg-js-android-arm64-2.6.2.tgz", + "integrity": "sha512-VcOKezEhm2VqzXpcIJoITuvUS/fcjIw5NA/w3tjzWyzmvoCdd+QXIqy3FBGulWdClvp4g+IfUemigrkLThSjAQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-arm64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-arm64/-/resvg-js-darwin-arm64-2.6.2.tgz", + "integrity": "sha512-nmok2LnAd6nLUKI16aEB9ydMC6Lidiiq2m1nEBDR1LaaP7FGs4AJ90qDraxX+CWlVuRlvNjyYJTNv8qFjtL9+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-darwin-x64": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-darwin-x64/-/resvg-js-darwin-x64-2.6.2.tgz", + "integrity": "sha512-GInyZLjgWDfsVT6+SHxQVRwNzV0AuA1uqGsOAW+0th56J7Nh6bHHKXHBWzUrihxMetcFDmQMAX1tZ1fZDYSRsw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm-gnueabihf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm-gnueabihf/-/resvg-js-linux-arm-gnueabihf-2.6.2.tgz", + "integrity": "sha512-YIV3u/R9zJbpqTTNwTZM5/ocWetDKGsro0SWp70eGEM9eV2MerWyBRZnQIgzU3YBnSBQ1RcxRZvY/UxwESfZIw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-gnu/-/resvg-js-linux-arm64-gnu-2.6.2.tgz", + "integrity": "sha512-zc2BlJSim7YR4FZDQ8OUoJg5holYzdiYMeobb9pJuGDidGL9KZUv7SbiD4E8oZogtYY42UZEap7dqkkYuA91pg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-arm64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-arm64-musl/-/resvg-js-linux-arm64-musl-2.6.2.tgz", + "integrity": "sha512-3h3dLPWNgSsD4lQBJPb4f+kvdOSJHa5PjTYVsWHxLUzH4IFTJUAnmuWpw4KqyQ3NA5QCyhw4TWgxk3jRkQxEKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-gnu": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-gnu/-/resvg-js-linux-x64-gnu-2.6.2.tgz", + "integrity": "sha512-IVUe+ckIerA7xMZ50duAZzwf1U7khQe2E0QpUxu5MBJNao5RqC0zwV/Zm965vw6D3gGFUl7j4m+oJjubBVoftw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-linux-x64-musl": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-linux-x64-musl/-/resvg-js-linux-x64-musl-2.6.2.tgz", + "integrity": "sha512-UOf83vqTzoYQO9SZ0fPl2ZIFtNIz/Rr/y+7X8XRX1ZnBYsQ/tTb+cj9TE+KHOdmlTFBxhYzVkP2lRByCzqi4jQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-arm64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-arm64-msvc/-/resvg-js-win32-arm64-msvc-2.6.2.tgz", + "integrity": "sha512-7C/RSgCa+7vqZ7qAbItfiaAWhyRSoD4l4BQAbVDqRRsRgY+S+hgS3in0Rxr7IorKUpGE69X48q6/nOAuTJQxeQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-ia32-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-ia32-msvc/-/resvg-js-win32-ia32-msvc-2.6.2.tgz", + "integrity": "sha512-har4aPAlvjnLcil40AC77YDIk6loMawuJwFINEM7n0pZviwMkMvjb2W5ZirsNOZY4aDbo5tLx0wNMREp5Brk+w==", + "cpu": [ + "ia32" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@resvg/resvg-js-win32-x64-msvc": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/@resvg/resvg-js-win32-x64-msvc/-/resvg-js-win32-x64-msvc-2.6.2.tgz", + "integrity": "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@rive-app/react-webgl2": { "version": "4.32.1", "resolved": "https://registry.npmjs.org/@rive-app/react-webgl2/-/react-webgl2-4.32.1.tgz", diff --git a/package.json b/package.json index 20808a0..3d58ebd 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@earendil-works/pi-ai": "1.0.2", "@mozilla/readability": "^0.6.0", "@radix-ui/react-use-controllable-state": "^1.2.6", + "@resvg/resvg-js": "^2.6.2", "@rive-app/react-webgl2": "^4.32.1", "@streamdown/cjk": "^1.0.3", "@streamdown/code": "^1.1.1", diff --git a/src/agent/subagents/headless.ts b/src/agent/subagents/headless.ts index 46a6316..0b8c93d 100644 --- a/src/agent/subagents/headless.ts +++ b/src/agent/subagents/headless.ts @@ -261,6 +261,26 @@ function crc32(buf: Buffer): number { } async function renderSvg(svg: string, opts: RenderSvgOptions = {}): Promise { + // NATIVE FIRST (Wave E stage 2, owner 2026-10-10): resvg rasterizes + // static SVG in-process — visual-maker SVGs never execute script, so a + // browser adds nothing but a process. White background mirrors the old + // Chrome page (html,body{background:#fff}); fitTo width mirrors + // svgViewport semantics (attrs win, viewBox-only gets the 900 default, + // opts override). The browser path below stays as the fallback for + // exotic-SVG features resvg cannot parse — failures there used to be + // the ONLY path, so nothing regresses. + const { width } = svgViewport(svg, opts); + try { + const { Resvg } = await import("@resvg/resvg-js"); + const resvg = new Resvg(svg, { + fitTo: { mode: "width", value: width }, + font: { loadSystemFonts: true }, + background: "#ffffff", + }); + return providerSafePng(resvg.render().asPng()); + } catch { + /* fall through to the browser path */ + } const dir = tempDir("lh-visual-"); const file = path.join(dir, "figure.html"); fs.writeFileSync( diff --git a/tests/svg-native-render.test.ts b/tests/svg-native-render.test.ts new file mode 100644 index 0000000..b6d40c3 --- /dev/null +++ b/tests/svg-native-render.test.ts @@ -0,0 +1,69 @@ +/** + * Wave E stage 2 (owner 2026-10-10): renderSvg is NATIVE FIRST — resvg + * rasterizes in-process; the old Chrome path stays as fallback for + * exotic-SVG parse failures. These tests pin the native path against real + * SVGs: attribute-sized, viewBox-only (the 900px default), text-bearing + * (system fonts), and the canonical single-IDAT PNG form. + */ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { realHeadless } from "../src/agent/subagents/headless.ts"; + +function pngFacts(buf: Buffer): { w: number; h: number; idats: number } { + assert.equal(buf.subarray(0, 8).toString("hex"), "89504e470d0a1a0a", "PNG signature"); + const w = buf.readUInt32BE(16); + const h = buf.readUInt32BE(20); + let off = 8; + let idats = 0; + while (off < buf.length) { + const len = buf.readUInt32BE(off); + const type = buf.subarray(off + 4, off + 8).toString("ascii"); + if (type === "IDAT") idats++; + off += 12 + len; + } + return { w, h, idats }; +} + +describe("native renderSvg (resvg in-process, browser fallback)", () => { + it("renders an attribute-sized SVG at its intrinsic size", async () => { + const svg = + ''; + const png = await realHeadless.renderSvg(svg); + const f = pngFacts(png); + assert.equal(f.w, 240); + assert.equal(f.h, 160); + assert.equal(f.idats, 1, "single-IDAT canonical PNG"); + }); + + it("viewBox-only SVG gets the 900px-wide default", async () => { + // No width attr anywhere (svgViewport grabs the FIRST width= in the + // document — children included — a pre-existing quirk kept for + // parity; use a circle so the default branch is exercised). + const svg = + ''; + const png = await realHeadless.renderSvg(svg); + const f = pngFacts(png); + assert.equal(f.w, 900, "svgViewport default width"); + assert.equal(f.h, 600, "aspect preserved from viewBox"); + }); + + it("width/height opts override for viewBox-only SVGs", async () => { + const svg = + ''; + const png = await realHeadless.renderSvg(svg, { width: 500, height: 125 }); + const f = pngFacts(png); + assert.equal(f.w, 500); + }); + + it("text-bearing SVG renders with system fonts (non-trivial output)", async () => { + const svg = ` + + Harness +`; + const png = await realHeadless.renderSvg(svg); + const f = pngFacts(png); + assert.equal(f.w, 300); + // A blank 300x100 white PNG is ~300 bytes; glyphs push it well past. + assert.ok(png.length > 1000, `text rendered (png ${png.length} bytes > 1000)`); + }); +});