Add potrace vectorizer: PNG to fitted SVG reveal steps

This commit is contained in:
Dan
2026-06-29 14:10:51 +00:00
parent 99af05b8d0
commit ddb5362050
+76
View File
@@ -0,0 +1,76 @@
import "server-only";
import { trace } from "potrace";
/**
* Turn a raster coloring-book PNG (black lines on white) into SVG line art fitted to the lessons'
* 0 0 400 300 viewBox. potrace traces the dark pixels into filled paths; we recolor them with our ink
* and wrap with a transform so they sit in the standard lesson coordinate space.
*
* - `pngToTemplateSvg` returns one fitted <path> (holes preserved via fill-rule) — used as the Color
* phase `baseSvg` and the final overlay.
* - `svgToRevealSteps` splits that path into chunks for the Trace phase's stroke-by-stroke reveal.
*/
const STROKE = "#2b2440";
const VW = 400;
const VH = 300;
function traceToSvg(png: Buffer): Promise<string> {
return new Promise((resolve, reject) => {
trace(
png,
{ color: STROKE, background: "transparent", threshold: 170, turdSize: 80, optTolerance: 0.4 },
(err: Error | null, svg: string) => (err ? reject(err) : resolve(svg)),
);
});
}
function round(n: number): number {
return Math.round(n * 100) / 100;
}
export async function pngToTemplateSvg(png: Buffer): Promise<string> {
const svg = await traceToSvg(png);
// Source dimensions (potrace emits width/height + viewBox on the <svg>).
const wM = svg.match(/width="(\d+(?:\.\d+)?)"/);
const hM = svg.match(/height="(\d+(?:\.\d+)?)"/);
const srcW = wM ? parseFloat(wM[1]) : VW;
const srcH = hM ? parseFloat(hM[1]) : VH;
// All path data, combined into one path (keeps fill-rule holes correct).
const ds = [...svg.matchAll(/\bd="([^"]+)"/g)].map((m) => m[1]);
if (ds.length === 0) throw new Error("Vectorize produced no paths (image may be blank).");
const combined = ds.join(" ");
// Fit srcW×srcH into 400×300, centered.
const s = Math.min(VW / srcW, VH / srcH);
const tx = round((VW - srcW * s) / 2);
const ty = round((VH - srcH * s) / 2);
const transform = `translate(${tx} ${ty}) scale(${round(s)})`;
return `<path d="${combined}" transform="${transform}" fill="${STROKE}" fill-rule="evenodd"/>`;
}
/** Split the template path into up to `maxSteps` chunks of subpaths for the trace reveal. */
export function svgToRevealSteps(templateSvg: string, maxSteps = 6): string[][] {
const dM = templateSvg.match(/\bd="([^"]+)"/);
const tM = templateSvg.match(/transform="([^"]+)"/);
if (!dM) return [[templateSvg]];
const transform = tM ? ` transform="${tM[1]}"` : "";
// Split into subpaths at each move command.
const subs = dM[1]
.split(/(?=[Mm])/)
.map((s) => s.trim())
.filter(Boolean);
const elements = subs.map((d) => `<path d="${d}"${transform} fill="${STROKE}" fill-rule="evenodd"/>`);
if (elements.length === 0) return [[templateSvg]];
// Chunk into steps so the reveal has a few stages.
const steps = Math.min(maxSteps, elements.length);
const per = Math.ceil(elements.length / steps);
const out: string[][] = [];
for (let i = 0; i < elements.length; i += per) out.push(elements.slice(i, i + per));
return out;
}