DEV Community

toolzip
toolzip

Posted on

Building a Barcode Generator That Actually Works — The bwip-js EAN-13 Deep Dive

If you've ever tried to render an EAN-13 barcode with an add-on code in the browser, you know it's more complicated than it looks. This is the story of how I solved it for ToolZip.


The Problem

EAN-13 is the standard barcode on most retail products worldwide. It's 13 digits, with distinctive "guard bars" — the longer bars at the left edge, center, and right edge that scanners use to orient themselves.

Many ISBN books also use an EAN-5 add-on: a separate 5-digit barcode attached to the right side of the main barcode, typically encoding the price.

I was using bwip-js, the most complete barcode library available for JavaScript. It works great — until you need both EAN-13 and EAN-5 together with text labels.


The bwip-js includetext Bug

bwip-js has an option called includetext that renders the human-readable digits below the bars. This is what you see on every product barcode — the numbers printed under the bars.

Here's the problem: when includetext: true is set with an EAN-5 add-on, the digit glyphs overlap with the bars.

My first instinct: set includetext: false and add the text manually.

// ❌ This breaks the guard bars
bwipjs.toSVG({
  bcid: 'ean13',
  text: '9791190333146 07810',
  includetext: false, // Guard bars disappear!
});
Enter fullscreen mode Exit fullscreen mode

This removed the guard bars entirely. The guard bars are part of the includetext rendering path in bwip-js — they don't render without it.


Why Guard Bars Matter

Guard bars aren't decorative. They're functionally required:

Left guard:   Tells the scanner where the barcode starts
Center guard: Divides the left and right digit groups
Right guard:  Tells the scanner where it ends
Enter fullscreen mode Exit fullscreen mode

A barcode without guard bars won't scan. Setting includetext: false was not an option.


The Solution: Keep includetext, Remove the Glyphs

The key insight: includetext: true renders both the guard bars and the digit glyphs as SVG path elements. I can keep the guard bars by keeping includetext: true, then surgically remove just the digit glyph paths and replace them with <text> SVG elements positioned correctly.

Step 1: Generate with includetext: true

// Server-side (Next.js API route)
const rawSvg = bwipjs.toSVG({
  bcid: 'ean13',
  text: '9791190333146 07810',
  scale: 2,
  height: 10,
  includetext: true,  // ← Must stay true
  textxalign: 'center',
  guardwhitespace: true,
});
Enter fullscreen mode Exit fullscreen mode

Step 2: Identify the SVG structure

The generated SVG contains:

  • <path stroke=...> elements — the bars (keep these)
  • <path fill="#000000"> elements — the digit glyphs (remove these)
  • The LMI > symbol — a small chevron bwip-js adds after EAN-5 (remove this)
const fills = [...rawSvg.matchAll(
  /<path d="([^"]+)" fill="#000000" \/>/g
)];
// fills[0] = all digit glyphs (EAN-13 + EAN-5)
// fills[1] = LMI chevron symbol
Enter fullscreen mode Exit fullscreen mode

Step 3: Calculate text positions from bar positions

The bars themselves tell us where to position the text. I parse the stroke path M/L coordinates to find:

const segs = [...rawSvg.matchAll(/M(\d+) (\d+)L(\d+) (\d+)/g)];
const allX = [...new Set(segs.map(m => +m[1]))].sort((a,b) => a-b);

// Find the gap between EAN-13 bars and EAN-5 bars
let splitIdx = 0;
for (let i = 1; i < allX.length; i++) {
  if (allX[i] - allX[i-1] > 20) { splitIdx = i; break; }
}

const ean13Xs = allX.slice(0, splitIdx);
const ean5Xs  = allX.slice(splitIdx);
Enter fullscreen mode Exit fullscreen mode

Then find the center guard bar position:

let midGapIdx = 0, maxGap = 0;
for (let i = 1; i < ean13Xs.length; i++) {
  const gap = ean13Xs[i] - ean13Xs[i-1];
  if (gap > maxGap) { maxGap = gap; midGapIdx = i; }
}

const leftBarEndX    = ean13Xs[midGapIdx - 1];
const rightBarStartX = ean13Xs[midGapIdx];
const leftGuardX     = ean13Xs[0];
const rightGuardX    = ean13Xs[ean13Xs.length - 1];
Enter fullscreen mode Exit fullscreen mode

Step 4: Place text elements

const LEFT_PAD = 26;

const texts = [
  // "9" — left of first guard bar
  `<text
    x="${LEFT_PAD + leftGuardX - 4}"
    y="${ean13TextY}"
    font-family="monospace"
    font-size="18"
    text-anchor="end">
    ${value[0]}
  </text>`,

  // "791190" — centered between left guard and center guard
  `<text
    x="${LEFT_PAD + (leftGuardX + leftBarEndX) / 2}"
    y="${ean13TextY}"
    font-family="monospace"
    font-size="18"
    text-anchor="middle">
    ${value.slice(1, 7)}
  </text>`,

  // "333146" — centered between center guard and right guard
  `<text
    x="${LEFT_PAD + (rightBarStartX + rightGuardX) / 2}"
    y="${ean13TextY}"
    font-family="monospace"
    font-size="18"
    text-anchor="middle">
    ${value.slice(7, 13)}
  </text>`,

  // EAN-5 — centered over EAN-5 bars, ABOVE the barcode
  `<text
    x="${LEFT_PAD + (ean5Xs[0] + ean5Xs[ean5Xs.length-1]) / 2}"
    y="${ean5TextY}"
    font-family="monospace"
    font-size="16"
    text-anchor="middle">
    ${addon}
  </text>`,
].join('\n');
Enter fullscreen mode Exit fullscreen mode

The Leading Zero Problem

One more gotcha: EAN-5 add-on codes are always 5 digits. A price of 7,810 Korean Won encodes as 07810.

If you render this as a JavaScript number, 07810 becomes 7810. The leading zero disappears.

Always pass the add-on as a string:

// ❌ Wrong
const addon = 07810; // → 7810

// ✅ Correct
const addon = "07810"; // → "07810"

// In SVG text element:
`${addon}` // renders as "07810" correctly
Enter fullscreen mode Exit fullscreen mode

Why Server-Side?

I generate barcodes in a Next.js API route, not in the browser.

The reason: bwip-js renders differently in Node.js vs browser environments. The browser version uses a canvas-based renderer that produces slightly different SVG output, making the glyph extraction logic unreliable.

Running it server-side gives consistent, predictable output every time:

// app/api/barcode/route.ts
export async function GET(req: NextRequest) {
  const bwipjs = (await import("bwip-js")).default;
  const rawSvg = bwipjs.toSVG({ ... });
  // post-process SVG
  return new NextResponse(processedSvg, {
    headers: { "Content-Type": "image/svg+xml" },
  });
}
Enter fullscreen mode Exit fullscreen mode

The Result

The final barcode renders correctly:

        07810          ← EAN-5 above, centered
    ▌▌▌▌▌▌▌▌▌▌▌▌▌▌    ← EAN-13 bars with guard bars
9  791190   333146     ← 1 + 6 + 6 digit layout
Enter fullscreen mode Exit fullscreen mode

Scannable, standard-compliant, and rendered entirely as SVG — no raster images, infinitely scalable for print.


Try It

The barcode generator is live at toolzip.app/tools/qr-barcode. It supports ISBN, EAN-13, EAN-8, UPC-A, Code 128, Code 39, and ISSN.

If you've run into similar bwip-js quirks or have questions about the SVG post-processing approach, drop a comment — happy to discuss.

Top comments (0)