If you own a laser cutter or a CNC router, you have probably done this: someone sends you a logo as a PNG, your machine software wants vector outlines, and you spend twenty minutes redrawing it by hand or uploading it to a converter that adds a watermark.
I wanted a converter that runs entirely in the browser (no upload, no signup), so I built one. Here is how the pipeline works. It turned out to be four small steps.
1. Threshold the image to pure black and white
Laser software only cares about where the shape is. So the first step throws away colour: convert every pixel to luminance and compare it against a threshold.
export function binarize(data: Uint8ClampedArray, threshold: number, invert: boolean) {
const out = new Uint8ClampedArray(data.length);
for (let i = 0; i < data.length; i += 4) {
const a = data[i + 3] / 255;
// treat transparent pixels as white background
const r = data[i] * a + 255 * (1 - a);
const g = data[i + 1] * a + 255 * (1 - a);
const b = data[i + 2] * a + 255 * (1 - a);
const lum = 0.299 * r + 0.587 * g + 0.114 * b;
const dark = invert ? lum >= threshold : lum < threshold;
const v = dark ? 0 : 255;
out[i] = out[i + 1] = out[i + 2] = v;
out[i + 3] = 255;
}
return out;
}
Two details matter here:
-
Transparency. Many logos are PNGs with an alpha channel. If you ignore alpha, transparent pixels (often stored as
0,0,0,0) count as black and you get a solid rectangle. Compositing onto white first fixes it. - A live threshold slider. No single value works for every image, so the UI re-traces as you drag. That is the reason the image is downscaled to at most 1600px before tracing.
2. Trace the black regions into outlines
I didn't write a tracer. imagetracerjs is public domain (Unlicense), has no dependencies, and works on a plain ImageData. With a fixed two-colour palette, layer 0 holds the black paths:
const td = ImageTracer.imagedataToTracedata(
{ data: bw, width, height },
{
pal: [{ r: 0, g: 0, b: 0, a: 255 }, { r: 255, g: 255, b: 255, a: 255 }],
colorquantcycles: 1,
ltres: 0.5 + smooth * 0.25, // line error tolerance
qtres: 0.5 + smooth * 0.25, // curve error tolerance
pathomit: 4 + smooth * 2, // drop specks smaller than this
rightangleenhance: true,
blurradius: 0,
},
);
const paths = td.layers[0]; // black = shapes to cut
A single "smooth" slider maps onto three tracer options. A higher value means fewer points, rounder curves, and more noise thrown away.
The important part for cutting: holes come out as their own closed paths in the same layer. The inside of an "O" or an "A" is a separate outline, so the cutter actually cuts it instead of leaving a solid blob.
3. Flatten the curves and convert pixels to millimetres
The tracer outputs straight segments (L) and quadratic Béziers (Q). Old-style DXF polylines only hold straight segments, so every curve becomes a few line segments:
function flattenQ(p0: Pt, c: Pt, p1: Pt, steps: number): Pt[] {
const pts: Pt[] = [];
for (let k = 1; k <= steps; k++) {
const t = k / steps, u = 1 - t;
pts.push([
u * u * p0[0] + 2 * u * t * c[0] + t * t * p1[0],
u * u * p0[1] + 2 * u * t * c[1] + t * t * p1[1],
]);
}
return pts;
}
Then scale to the real-world width the user typed in, and flip Y. Image coordinates grow downward and CAD coordinates grow upward. If you forget this, every design comes out upside down, and you only notice after the cut.
const k = widthMm / imageWidth;
const mm = poly.map(([x, y]) => [x * k, (imageHeight - y) * k]);
I also drop zero-area paths. On a white-background image, the tracer sometimes emits a degenerate frame along the image border.
4. Write DXF R12 by hand
DXF looks scary but is just alternating lines of group code and value. I picked the oldest widely supported flavour, R12 (AC1009), with closed POLYLINE entities. LightBurn, xTool Creative Space, Fusion 360, Inkscape and AutoCAD all open it.
g(0, "SECTION"); g(2, "HEADER");
g(9, "$ACADVER"); g(1, "AC1009");
g(9, "$INSUNITS"); g(70, 4); // 4 = millimetres
g(0, "ENDSEC");
g(0, "SECTION"); g(2, "ENTITIES");
for (const p of polys) {
g(0, "POLYLINE"); g(8, "0"); g(66, 1); g(70, 1); // 70=1 → closed
for (const [x, y] of p) { g(0, "VERTEX"); g(8, "0"); g(10, x); g(20, y); }
g(0, "SEQEND");
}
g(0, "ENDSEC"); g(0, "EOF");
Lessons from testing in real software:
-
Close the polylines (
70 = 1). Open paths make some laser software refuse to fill or offset them. -
Units are a hint, not a rule. R12 predates
$INSUNITS. Newer apps read it, older ones assume whatever you set as default. That is why the tool asks for a width in mm and bakes the scale into the coordinates. - An SVG export is basically free once you have the polylines, and some people prefer it.
The result
Everything above runs client-side, so the image never leaves the browser and there is nothing to rate-limit. You can try it here: PNG to DXF converter. Drop a logo, drag the threshold until the preview looks right, set the width, download.
Things I'd still like to improve: centreline tracing for engraving single-stroke text, and smarter speck removal on photos. If you cut things for a living, I'd love to hear which file it fails on.
Top comments (0)