DEV Community

思源
思源

Posted on

Turning a PNG into a laser-cuttable DXF in the browser (about 150 lines of TypeScript)

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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)