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).
This commit is contained in:
amosblomqvist
2026-10-11 06:52:32 +03:00
parent 951c7704b9
commit e9cf841ce7
5 changed files with 322 additions and 0 deletions
+4
View File
@@ -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
],
};
+228
View File
@@ -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",
+1
View File
@@ -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",
+20
View File
@@ -261,6 +261,26 @@ function crc32(buf: Buffer): number {
}
async function renderSvg(svg: string, opts: RenderSvgOptions = {}): Promise<Buffer> {
// 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(
+69
View File
@@ -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 =
'<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160"><rect width="240" height="160" fill="#3355aa"/></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 =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 200"><circle cx="150" cy="100" r="90" fill="#aa5533"/></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 =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 100"><rect width="400" height="100" fill="#33aa55"/></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 = `<svg xmlns="http://www.w3.org/2000/svg" width="300" height="100">
<rect width="300" height="100" fill="#ffffff"/>
<text x="150" y="60" text-anchor="middle" font-family="Helvetica, Arial, sans-serif" font-size="32" fill="#111111">Harness</text>
</svg>`;
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)`);
});
});