Add potrace vectorizer: PNG to fitted SVG reveal steps
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user