Most of the time, “add a barcode to this” means reaching for a library. This project went the other way the encoders, the rasteriser, and the decoder are all hand-written, a few hundred lines of pure JavaScript driven by the ISO/IEC pattern tables. This post is about why that was the right call, and mostly about the most surprising part how little of a barcode is graphics and how much of it is arithmetic.
If you take one idea away, let it be this:
A 1D barcode is not a picture. It is a number, expanded into a run of bars and spaces, then snapped onto a printer’s dot grid.
Everything below is the math of that expansion and that snapping.
Why build it instead of installing it
The data being encoded is dynamic. It is assembled from free text and fields resolved at generation time, formatted differently depending on where the barcode lands, and reused across several parts of the product. The requirement was a single place where a user could configure a barcode (type, size, DPI, label styling) and have it behave identically everywhere it appeared the live editor, the on-screen preview, and the final printed document.
Off-the-shelf libraries (JsBarcode, bwip-js) are great at drawing a barcode. What they don’t provide is control over two things this project couldn’t compromise on:
- The print math. They emit pixels or SVG sized for a screen, not snapped to a target printer’s dot grid. A bar that works out to “2.7 dots wide” rounds unpredictably, and a scanner reading a threshold sees the wrong edge. The single most common failure mode for a printed barcode is this it looks perfect in the browser and won’t decode on paper.
- Scannability as a first-class result. A library will happily render a symbol whose bars are below the minimum readable width. It looks like a barcode. It just doesn’t scan. The engine needed to report whether a given configuration will actually read, before anything is printed.
Building it in-house also collapsed the problem into a single pure function, one deterministic calculation shared by preview, validation, and print. No drift between what the user sees and what comes out of the printer, because there is only one implementation of the math. That reusability was as valuable as the correctness.
Barcodes are numbers module
The atomic unit of a 1D barcode is the module, the width of the narrowest bar or space. Every bar and space is an integer number of modules wide. That integer constraint is the whole ballgame: it’s what allows a barcode to be reasoned about as a list of runs rather than a bitmap.
So the pipeline for any symbology is the same:
text → encode → run list [{ bar, widthInModules }, …] → geometry → ink
The encode step is where the two symbologies differ. Everything after it (page mapping, dot snapping, rendering, decoding) is shared.
Code 39: three of nine
Code 39 is the simpler of the two, and its name is a spoiler for the math. Each character is drawn with 9 elements (5 bars and 4 spaces, alternating, always starting with a bar). Of those 9 elements, exactly 3 are wide, hence “3 of 9.”
- A narrow element is 1 module.
- A wide element is 3 modules.
So each character’s pattern can be represented as a 9-bit string, where 1 = wide and 0 = narrow, walked as bar/space/bar/space. That’s the entire encoding table: 43 characters (0-9, A-Z, and - . $ / + % and space), each a fixed 9-bit pattern from ISO/IEC 16388.
A few consequences fall straight out of the rules:
- Character width is constant: 3 wide × 3 modules + 6 narrow × 1 module = 15 modules, plus a 1-module inter-character gap. Every character costs the same width. That makes Code 39 predictable but not dense.
- It’s self-checking. Because of the fixed 3-of-9 structure, a single misread element produces an invalid pattern rather than a wrong-but-valid character. The optional MOD-43 check digit is not added here, since the structure already catches the failure.
- Start and stop are the
*guard, added by the encoder and never part of the user’s data. - Quiet zones matter. The encoder bakes a 10-module blank run onto each side rather than leaving it to CSS margins, because the quiet zone is scan-critical and layout padding is not.
- Uppercase only. The character set is 43 symbols; lowercase is simply not encodable. (More on why that turned into a safety feature later.)
Here is the encoder producing the run list, the whole idea in one function:
// text → run list [{ bar, w(modules) }, …] with 10-module quiet zones
export function encode39(text) {
const framed = ["*", ...text.split(""), "*"]; // guard chars
const runs = [{ bar: false, w: 10 }]; // leading quiet zone
framed.forEach((ch, idx) => {
const pat = CODE39_PATTERNS[ch]; // 9-bit ISO pattern
for (let i = 0; i < 9; i++)
runs.push({ bar: i % 2 === 0, w: pat[i] === "1" ? 3 : 1 });
if (idx < framed.length - 1) runs.push({ bar: false, w: 1 }); // gap
});
runs.push({ bar: false, w: 10 }); // trailing quiet zone
return { runs, modules: runs.reduce((s, r) => s + r.w, 0) };
}
Note that the encoder doesn’t know anything about pixels, DPI, or the page. It returns runs and a total module count. That separation is deliberate and it’s what makes the next stage reusable.
Code 128:
Code 128 is where the math gets interesting. Every symbol is exactly 11 modules wide, made of 6 elements (3 bars, 3 spaces) whose widths sum to 11. There are 107 patterns, numbered 0 to 106, taken from ISO/IEC 15417.
The “128” refers to the full ASCII range it can encode, which it does through three code sets:
- Set A: uppercase, digits, punctuation, and control characters.
- Set B: uppercase, lowercase, digits, punctuation.
- Set C: digit pairs. Each symbol encodes two digits (00 to 99), so numeric data packs at twice the density.
A Code 128 barcode is structured as:
[start code] [data symbols…] [checksum symbol] [stop pattern]
Two pieces of math define it.
The MOD-103 checksum. This is a position-weighted sum, not a simple total. The start code counts once, then each data symbol is multiplied by its position:
let sum = startVal;
data.forEach((v, i) => (sum += v * (i + 1))); // position-weighted
const check = sum % 103; // the checksum symbol
The weighting is what makes it robust: transposing two symbols changes the sum, where a plain checksum wouldn’t notice. The decoder recomputes this independently, so the encoder and decoder cross-check each other.
Automatic segmentation. The genuinely tricky part is deciding which code set to use, and switching between them mid-string to keep the barcode short. The heuristic a run of 6 or more digits (or 4 or more if it sits at the start or end) goes to Set C as digit pairs; everything else goes to Set B. Adjacent runs in the same set get merged, and set switches are emitted as “latch” codes in the symbol stream.
// Split into Set B / Set C runs: Set C for long digit runs
if (run >= 6 || (run >= 4 && (i === 0 || i + run === text.length))) {
const even = run % 2 ? run - 1 : run; // Set C needs digit pairs
segs.push({ set: "C", t: text.slice(i, i + even) });
} else {
segs.push({ set: "B", t: /* the non-C remainder */ });
}
The payoff is real. Encode ORDER0000123456 and the numeric tail collapses into Set C two digits at a time, roughly halving the width of that portion. For typical label sizes, that density difference is often what makes a barcode fit at all.
From symbols to ink the print math:
This is the part libraries hide, and the part that actually determines whether a barcode scans. Once a run list exists, “N modules” has to become a physical width, and printers can only place ink on their own dot grid.
The calculation walks through units carefully:
- Pixels to inches. The element has a size in logical pixels; divide by pixels-per-inch to get inches.
- Inches to a whole-dot budget. Multiply by DPI and floor it. This is the total number of printer dots available for the whole symbol.
- The X-dimension. Divide the dot budget by the total module count and floor again. This is
xDots, the width of one module in whole printer dots.
const budget = Math.floor(innerIn * dpi); // inches → whole dots
const xDots = Math.floor(budget / enc.modules); // largest whole-dot module
const pxPerMod = (xDots / dpi) * PPI; // dots → px, integer-exact
Those two Math.floor calls are the entire trick. By forcing every module to an integer number of dots before any drawing happens, the bar-to-space ratios stay exact on paper. There is no fractional dot to round unpredictably. A “2.7-dot bar” never exists.
Snapping to device pixels
The same discipline applies again at the rasteriser. When the bars are drawn, fractional pixel coordinates get anti-aliased. A bar from x=10.4 to x=12.7 bleeds grey into its neighbouring space, and a scanner’s threshold lands in the wrong place. Bars merge; spacing looks uneven.
The fix is to round each edge independently to a device pixel and derive the width from the difference, never rounding the width itself, which would let error accumulate across the symbol:
const left = Math.round(bx + padL + rect.x * scale);
const right = Math.round(bx + padL + (rect.x + rect.w) * scale);
ctx.fillRect(left, barTop, Math.max(1, right - left), barHeight);
The Math.max(1, …) guarantees a bar never disappears entirely at small scales. This single rounding rule is why the printed output matches the preview.
Scan reliability the minimum X-dimension:
A barcode can be geometrically drawable and still be too small to scan. The readable minimum is governed by two floors that used to disagree a dot floor (at least a couple of dots per module) and a physical floor (the narrowest bar must be at least about 0.19 mm regardless of DPI). The engine folds them into one number:
function minXForDpi(dpi) {
const minDotsForMm = Math.ceil((0.19 / 25.4) * dpi);
return Math.max(dpi >= 600 ? 3 : 2, minDotsForMm);
}
| DPI | minimum X | narrowest bar |
|---|---|---|
| 96 | 2 dots | 0.53 mm |
| 203 | 2 dots | 0.25 mm |
| 300 | 3 dots | 0.25 mm |
| 600 | 5 dots | 0.21 mm |
A barcode is only marked scannable if xDots >= minX. That single comparison turns “will this read?” from a guess into a computed boolean, which the UI surfaces to the user before they print.
One engine, two renderers
The engine is a pure function. It takes configuration and returns geometry a list of bar rectangles, a bar height, the scannable verdict, and a human-readable reason if it fails. Nothing in it draws anything. That lets two very different renderers consume the exact same math:
- The browser preview draws the rectangles onto a high-resolution
<canvas>(the backing store is oversampled, then CSS-sized back down). SVG is deliberately avoided here, because inside a zoomable preview an SVG gets rasterised once and then upscaled, which looks blurry. Drawing straight onto a high-res canvas sidesteps that. - The server render runs the identical calculation in Node using
node-canvas, draws the pages, and stitches them into a PDF withpdf-lib. No headless browser is involved. It’s the same arithmetic and the same pixel-snapping rule, just on the server’s canvas instead of the browser’s.
Because both call one function, “what you see is what prints” isn’t a slogan to be tested for. It’s a structural guarantee.
Proving it the decode round-trip:
This is the most interesting check of all, because it runs in production, not just in tests. Every time a barcode is rendered, the engine:
- expands the run list into a literal per-dot array (exactly what will be printed),
- collapses that array back into runs,
- decodes it with the in-repo decoder,
- compares the result to the original input.
const decoded = decodeFor(type, dotsToRuns(rasterSnapped(enc.runs, xDots)));
result.decoded = decoded.ok && decoded.text === text;
If an encoder bug or a degenerate size produced a symbol that wouldn’t read, this catches it, because it tests the geometry actually being drawn, not the abstract run list. It is a cheap, strong correctness proof, and the closest thing to a physical scan test that software can provide.
Fail loud, never wrong
In any domain where a barcode carries an identifier, the worst outcome is not a barcode that fails to render. It’s a barcode that renders cleanly and encodes the wrong data. Two cases drove this design:
- Lowercase Code 39. The live-typing filter upper-cases as you type (good UX). But that meant
abcsilently became a barcode readingABC, and an over-length value became a barcode of its first N characters. Both scan perfectly, both carry the wrong payload. The fix was a strict validation gate that runs before the filter and rejects rather than repairs. Lowercase now fails loudly with a red status instead of being quietly “rescued.” - Partially-resolved dynamic fields. When barcode content mixes literal text with fields resolved at generation time, an unresolved field used to contribute an empty string, so
ORDER <field>could become a scannable barcode readingORDER. Now the resolver reports whether every field resolved, and all three renderers refuse to draw a partial payload. A barcode is generated only when it is 100% resolved.
The principle: filters may quietly drop or truncate for display, but that must never rescue a value into a barcode. An out-of-charset or over-limit value has to fail visibly.
The API shape
The component is a plain React component where we can pass the props. A trimmed view of the shape:
<Barcode
type="code128" // "code39" | "code128"
codeSet="Automatic" // Code 128 only: Automatic | A | B | C
value="ORDER0000123"
width={240} height={90}
dpi={203} // target printer resolution, drives all validation
showLabel
onStatus={(result) => { /* full computeBarcode result */ }}
/>
Under it sits the function that does the work, computeBarcode(props), returning a rich result:
{ rects, barH, // geometry to draw
renderable, scannable, decoded, // the three verdicts
reason, fix, badFields, // actionable failure info
xDots, minX, budget, /* … */ } // the computed print math
renderable and scannable are deliberately separate (bars can be drawable but still below the readable minimum), and every failure carries a reason (what’s wrong), a fix (what to change), and badFields (which inputs to highlight). “Won’t scan” is never the whole message.
The supporting functions available directly: encode39() and encode128() (text to runs), the matching decoders for the round-trip, rasterSnapped() and dotsToRuns() (runs to dots and back), and minXForDpi() (the readability floor).
The takeaway
Barcodes look like a rendering problem. They are a units problem. Pixels to inches to whole dots to modules get the flooring right at each boundary and the graphics take care of themselves; get it wrong and no amount of high-resolution rendering will make the thing scan.
And the from-scratch decision paid off in an unexpected way. It wasn’t really about avoiding a dependency. It was that owning the math made scannability a computed result and correctness a round-trip proof, both impossible when the encoding lives inside a black box. Sometimes writing the boring lookup table by hand is what buys the interesting guarantees.
Thanks for reading. Questions or corrections welcome in the comments. If anything here is unclear, or you’ve solved the same print-fidelity problems a different way, drop it in the comments.