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