diff --git a/src/lib/vectorize.ts b/src/lib/vectorize.ts new file mode 100644 index 0000000..5fce46b --- /dev/null +++ b/src/lib/vectorize.ts @@ -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 (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 { + 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 { + const svg = await traceToSvg(png); + + // Source dimensions (potrace emits width/height + viewBox on the ). + 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 ``; +} + +/** 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) => ``); + 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; +}