DEV Community

Cover image for I built a Perler bead pattern generator that exports your work as a single HTML file
PHT
PHT

Posted on Originally published at perler.phtbyte.com

I built a Perler bead pattern generator that exports your work as a single HTML file

If you've never heard of Perler beads (fuse beads): you place tiny plastic beads on a pegged board, iron them, and they melt into pixel art you can keep. It's physical pixel art — and people make genuinely impressive stuff with it.

Planning a big project requires a pattern: a grid telling you which bead color goes in which cell. Existing online generators do the quantization for you, but I wanted one with a specific superpower: the whole tool, plus your current work, exportable as ONE self-contained HTML file you can keep, email, or open years later.

So I built Perler Bead Blueprint Maker — 100% client-side, no account, no backend, images never leave your browser. Source is on GitHub (MIT).

This post is about how it works and the one bug that taught me to never regex-replace HTML again.

TL;DR — what it does

  • Upload an image → quantized to real bead palettes (Perler, MARD, ARTKAL, YANT — including their actual color codes and names)
  • Preset sizes (29/38/49/69/104…) or any custom N×N
  • Color budget: k-means clustering in Lab space to hit "≤ 25 colors" without visual mush
  • Interactive 2D grid (bead codes printed in cells, highlight tracking, "done" marks that persist)
  • 3D board view with real perler beads (Three.js InstancedMesh) and a depth-layering mode that stacks the board into relief layers
  • Editing tools: brush / fill / eraser / eyedropper with undo history
  • Export: poster PNG/PDF, multi-size PDF collection + CSV shopping list, and — the reason this project exists — a single HTML file that IS the app WITH your work inside

The quantization: Lab space + seeded k-means

Naive RGB distance quantizes badly (blues and purples especially). Everything runs in CIELAB and uses ΔE76 for matching:

// rgb → Lab, then nearest palette color by ΔE76
function nearest(hex: string, palette: Color[]): Color {
  const [L, a, b] = rgb2lab(hex);
  let best = palette[0], bestD = Infinity;
  for (const c of palette) {
    const d = deltaE76(L, a, b, c.lab);
    if (d < bestD) { bestD = d; best = c; }
  }
  return best;
}
Enter fullscreen mode Exit fullscreen mode

For the color budget, a palette of 221 colors reduced to "25 colors max" is a clustering problem. k-means++ in Lab space works well, with one addition: a fixed seed, so the same image always quantizes to the same pattern. Sounds obvious, but most generators re-roll the dice on every page load — you refresh and your carefully-tuned 20-color selection becomes 23 different colors.

The 3D board

Beads are InstancedMesh cylinders — one draw call for thousands of beads — with a canvas-generated texture that fakes the hole. The pegboard underneath has bumps. Nothing fancy, but it sells the illusion:

const geo = new THREE.CylinderGeometry(0.42, 0.42, 0.32, 24);
const mesh = new THREE.InstancedMesh(geo, mat, count);
// one setMatrixAt per bead, y offset = board layer
Enter fullscreen mode Exit fullscreen mode

The depth-layering mode is my favorite: a heuristic (brightness + center weighting + smoothing, then equal-frequency bucketing into 7 layers) turns a flat pattern into stacked relief layers — for making actual 3D bead sculptures, shakelight-style.

The single-file export (and the bug that broke it)

The exported file is the entire app bundle + your current state (blueprint, progress, source image as a compressed data URL) embedded in a <script type="application/json"> tag. On open, the app checks that tag, restores state into localStorage, and you continue where you left off. No server, no database — the file is the database.

My first implementation did the injection with a regex:

// ❌ DO NOT DO THIS
html = html.replace(
  /<script id="perlerSaved"[^>]*>[\s\S]*?<\/script>/,
  `<script id="perlerSaved">${json}</script>`
);
Enter fullscreen mode Exit fullscreen mode

It worked… until it didn't. The check I skipped: the marker string perlerSaved also exists inside the bundled JS — in the very restore code that reads the tag, and inside minified variable-containing chunks. The regex matched there first, and replaced ~84KB of application code with a JSON blob. The exported file opened to a white screen.

Two lessons, now burned into the project's docs:

// ✅ DOM-based injection: the parser finds THE element, not any text occurrence
const tpl = document.createElement('div');
tpl.innerHTML = html;
const tag = tpl.querySelector('#perlerSaved');
tag.textContent = json;          // textContent never executes, never mis-matches
html = '<!DOCTYPE html>' + tpl.innerHTML;
Enter fullscreen mode Exit fullscreen mode
  1. Never regex-replace structured markup. Parse, query, mutate, serialize. The regex version produced a file that worked exactly once — when I tested it before the bundle contained the string, and never again.
  2. Test the export against the real production bundle, not a dev build. Dev bundles don't contain minified code that collides with your markers.

Bonus property of this design: the exported file strips external <script src> tags, so your exported copy has no analytics, no external requests — it works offline forever.

Small things that mattered more than expected

  • Printing bead codes inside grid cells came from a single user comment ("no way to know which color goes where without cross-checking the list"). Cells auto-hide codes below ~13px, and text color flips black/white by cell luminance with a contrast outline.
  • Language toggle (中文/EN) — first-use detection from navigator.language, persisted choice. A pixel-art tool is visual, but "MARD 221" labels and toasts still need to be readable.
  • Pointer Events everywhere — one code path for mouse and touch, pinch-zoom included. Writing mouse + touch handlers separately in 2026 is self-harm.
  • Privacy-friendly analytics: GoatCounter, no cookies, event names like e/upload/104/mard48/zh/m (event/size/palette/language/device). One gotcha I hit the hard way: GoatCounter silently classifies traffic from cloud-provider IP ranges as bots — if you test your own site through a VPN/proxy, your dashboard stays at zero and nothing tells you why.

Try it

Everything runs locally; upload an image, hit the 3D view, and export a single file as a souvenir.

If you build something with a pattern from it (or find a quantization case where it picks a clearly wrong color), I'd genuinely like to hear about it in the comments.

Top comments (0)