Fixing Unscannable Code128 Badges From Low Print DPI

Symptom Statement Link to this section

The Code128 symbol looks perfect in the PDF preview — sharp bars, clean edges, a readable human string underneath — but the badge that comes off the printer will not decode. A handheld reader at the check-in door beeps red, an attendee waits while a volunteer keys the ID in by hand, and the queue backs up during the opening-morning surge. The tell is that the same payload scans reliably from the screen or from a 600 DPI office laser, yet fails from the on-site direct-thermal badge printer running at 203 DPI. This is the optical failure that barcode threshold tuning exists to catch before a symbol is ever serialized: a 1D barcode whose bar geometry survives on a high-resolution display but collapses when it is quantized onto a coarse printer dot grid. This page addresses that exact symptom — a Code128 badge that renders fine but scans poorly specifically because of low print resolution — and the deterministic ReportLab change that snaps bar widths onto whole dots so the printed symbol matches the design.

Snapping Code128 module width onto the printer dot grid before rasterization A nominal barWidth of about 0.30 millimetres enters and is measured against the printer dot grid, which at 203 DPI is 0.125 millimetres per dot. A gate asks whether the module width divides evenly into whole dots. If it does not, the path drops to a failure box: the rasterizer rounds each bar independently, so a 2.4-dot bar prints as two dots in one place and three in another, producing uneven bars that decode inconsistently and fail at the door. If the width is snapped up to a whole number of dots and clears the 0.254 millimetre X-dimension floor, it passes right through a render-at-device-DPI step with anti-aliasing disabled, and the printed symbol scans reliably. Quantize the module width onto the dot grid, then rasterize Nominal barWidth ~0.30 mm design intent Printer dot grid 203 DPI 0.125 mm / dot GRID GATE module ÷ dot = whole number? Snap to n dots X-dim ≥ 0.254 mm bars land on grid Render at DPI anti-alias off no smoothing Scans at the door PASS FAIL Sub-pixel straddle 2.4-dot bar prints 2 here, 3 there uneven bars → decode fails

Root Cause Analysis Link to this section

Four concrete, physically distinct failures collapse a screen-perfect Code128 into an unscannable print. An incident is usually one of them, occasionally two stacked, so triage means naming which before changing render code.

  • Sub-pixel bar widths. The module width (the X-dimension, the width of the narrowest bar) is not an integer multiple of the printer’s dot pitch. At 203 DPI a dot is 25.4 / 203 ≈ 0.125mm; a nominal 0.30mm bar is 2.4 dots. The rasterizer cannot print 2.4 dots, so it rounds each bar independently — some print at 2 dots, some at 3 — and the resulting bar-to-space ratio drifts outside the decoder’s tolerance.
  • Thermal burn gain. Direct-thermal heads spread heat into the media. A darkness setting tuned too hot fattens every bar by a fraction of a dot (bars gain, spaces shrink); too cold thins them. Either way the printed ratio no longer matches the geometry, and a symbol that was already marginal from sub-pixel rounding tips over the edge.
  • Anti-aliasing. When the symbol is rendered to a bitmap and scaled — or drawn through an image pipeline with smoothing on — the hard black-to-white bar edges are blurred into grey ramps. A scanner thresholds those ramps unpredictably, so edge positions shift and quiet-edge contrast drops.
  • X-dimension too small. Even snapped to whole dots, a 1-dot bar at 203 DPI is 0.125mm — far under the reliable Code128 floor of roughly 0.254mm (10 mil) for mixed handheld hardware. A symbol that thin decodes on a good reader in a lab and fails on a cheap one at the door.

Symptom-to-Resolution Matrix Link to this section

Sub-Pixel Bar Widths From a Non-Integer Module Link to this section

Symptoms

  • The symbol decodes from the screen and from a 600 DPI laser, but not from the 203 or 300 DPI thermal printer.
  • Under a loupe the printed bars are visibly uneven in width where the design shows them uniform.

Root cause. The X-dimension is not a whole number of printer dots, so the rasterizer rounds each bar on its own and the bar-space ratio drifts.

Fix

  1. Read the target device resolution (203 or 300 DPI) as a first-class render parameter, not an afterthought.
  2. Compute the dot pitch in points: dot_pt = 72.0 / dpi.
  3. Snap the module width up to the smallest whole-dot multiple that also clears the readability floor, then pass that exact barWidth to ReportLab.
  4. Assert the chosen barWidth is an integer multiple of dot_pt so a future config change cannot silently reintroduce a sub-pixel width.

Thermal Burn Gain Widening the Bars Link to this section

Symptoms

  • Bars look bold and slightly bled; spaces between them look pinched.
  • Lowering the printer’s darkness/heat setting improves the scan rate.

Root cause. The direct-thermal darkness is tuned too hot, so heat spreads into the media and every bar gains width at the expense of its neighbouring space.

Fix

  1. Calibrate darkness to the media: print a test symbol at descending heat and scan each until the first reliable pass.
  2. Prefer a slightly wider snapped X-dimension so a small, consistent burn gain stays inside tolerance rather than eating a thin bar entirely.
  3. Pin the media type in the print profile so a stock change does not silently shift the burn characteristics.

Anti-Aliasing Blurring the Bar Edges Link to this section

Symptoms

  • The symbol was rasterized to PNG or JPEG and dropped into the layout, and scan rates fell versus a vector draw.
  • Zooming the printed bars shows grey feathering at every edge.

Root cause. An image pipeline with smoothing enabled turned hard bar edges into grey ramps, so the scanner’s threshold lands in different places on different bars.

Fix

  1. Draw the barcode as vector geometry directly onto the PDF via ReportLab’s drawOn, never as a downscaled raster.
  2. If a bitmap is unavoidable, render it at the exact device DPI with nearest-neighbour scaling and anti-aliasing disabled.
  3. Never JPEG-compress a barcode; the block artifacts destroy edge contrast.

X-Dimension Below the Scanner Floor Link to this section

Symptoms

  • The symbol scans on one reader model and fails on another with identical print output.
  • The barcode was shrunk to fit a crowded template.

Root cause. The X-dimension is under the reliable Code128 minimum, so only the best optics in the room can resolve it.

Fix

  1. Enforce a hard MIN_X_DIM_MM floor (0.254mm) in the render path; refuse to emit anything thinner.
  2. If the symbol no longer fits, shorten the payload or reclaim space rather than thinning bars — layout trade-offs belong in badge layout architecture.
  3. Keep the quiet zones intact while you resize; a thicker bar with a crowded margin still fails, which is the sibling failure covered in tuning barcode quiet zones for handheld scanners.

Minimal Working Implementation Link to this section

A single self-contained module: compute the dot pitch for the target printer, snap the module width onto whole dots while enforcing the X-dimension floor, build the Code128 symbol in ReportLab, and prove with an assert block that the emitted barWidth lands exactly on the device grid.

PYTHON
import math

from reportlab.graphics.barcode import code128
from reportlab.lib.units import mm
from reportlab.pdfgen import canvas

# --- Printer + symbology constants ---------------------------------------
PT_PER_INCH = 72.0
MIN_X_DIM_MM = 0.254        # ~10 mil: reliable Code128 floor for handhelds
QUIET_ZONE_MODULES = 10     # Code128 needs >= 10X of clear space each side


def dot_pitch_pt(dpi: int) -> float:
    """Width of a single printer dot, in PDF points."""
    return PT_PER_INCH / dpi


def dpi_safe_bar_width(dpi: int, nominal_mm: float = 0.30) -> float:
    """A barWidth (points) that is a whole number of printer dots AND at least
    MIN_X_DIM_MM wide.

    A module width that is not an integer multiple of the dot pitch forces the
    rasterizer to round each bar on its own, so a nominal 2.4-dot bar prints as
    2 dots here and 3 dots there. Snapping to whole dots removes that jitter.
    """
    dot = dot_pitch_pt(dpi)
    min_x_pt = (MIN_X_DIM_MM / 25.4) * PT_PER_INCH
    nominal_pt = (nominal_mm / 25.4) * PT_PER_INCH
    target_pt = max(nominal_pt, min_x_pt)          # honour both target and floor
    dots = max(1, math.ceil(target_pt / dot))      # snap UP to whole dots
    return dots * dot


def build_code128(value: str, dpi: int) -> code128.Code128:
    bar_width = dpi_safe_bar_width(dpi)
    min_x_pt = (MIN_X_DIM_MM / 25.4) * PT_PER_INCH
    assert bar_width >= min_x_pt, (
        f"X-dimension {bar_width:.4f}pt below {min_x_pt:.4f}pt floor at {dpi} DPI"
    )
    quiet = QUIET_ZONE_MODULES * bar_width
    return code128.Code128(
        value,
        barWidth=bar_width,
        barHeight=18 * mm,
        humanReadable=True,     # keyable fallback if a reader still misses
        quiet=True,
        lquiet=quiet,
        rquiet=quiet,
    )


# --- Verification: the emitted geometry lands exactly on the device grid ---
if __name__ == "__main__":
    dpi = 203                                   # common direct-thermal head
    dot = dot_pitch_pt(dpi)
    bw = dpi_safe_bar_width(dpi)

    # bar width is a whole number of dots (no sub-pixel straddle)
    assert abs((bw / dot) - round(bw / dot)) < 1e-9, "module straddles the grid"
    # and clears the readability floor
    assert bw >= (MIN_X_DIM_MM / 25.4) * PT_PER_INCH, "X-dimension too thin"

    bc = build_code128("ATND-000481", dpi)
    c = canvas.Canvas("/tmp/badge_barcode.pdf")
    bc.drawOn(c, 20 * mm, 20 * mm)              # vector draw — never a raster
    c.showPage()
    c.save()
    print(f"OK dpi={dpi} X={bw:.4f}pt = {round(bw / dot)} dots "
          f"({bw / PT_PER_INCH * 25.4:.3f}mm)")

The assert block is the fix’s own regression test: it proves the emitted barWidth is an exact integer multiple of the dot pitch (so no bar can straddle a sub-pixel boundary) and that it never drops below the X-dimension floor, whatever nominal size the template requests.

Memory & Performance Constraints Link to this section

Barcode synthesis is CPU-light per badge, but at event scale the render loop runs thousands of times, so the constraints are about not re-doing work and not silently rasterizing.

Component Constraint Mitigation
Dot-grid computation dpi_safe_bar_width recomputed per badge wastes cycles in a tight loop Memoize by dpi; the snapped width is identical for every badge on one printer
Vector vs raster draw A rasterized symbol inflates the PDF and invites anti-aliasing Use drawOn vector geometry; keep the symbol out of any image pipeline
Font / human-readable text Embedding a full font for the fallback string bloats every file Subset the glyphs used, as covered by font & glyph embedding
Batch render throughput Serial rendering stalls the queue during an opening surge Render badges as an async batch, offloaded per async batch processing

Incident Triage & Rollback Link to this section

Fast path when the door reports a wave of unscannable badges — target under 15 minutes to containment. Every step is non-destructive until the final rollback.

  1. Isolate print versus design. Scan the on-screen PDF and a freshly printed badge with the same reader. If the screen scans and the print does not, the fault is in rasterization or the printer, not the payload.
  2. Read the effective X-dimension. Log the computed barWidth and DPI for the current batch: grep 'X=' render.log | tail. A width that is not a clean multiple of 72/dpi confirms a sub-pixel straddle.
  3. Check the printer profile. Confirm the darkness/heat setting and media type match the calibrated profile; a hot darkness setting fattens bars independently of the render.
  4. Deploy the snapped width. Ship the dpi_safe_bar_width change, re-render one badge, and scan it five times from two reader models before releasing the batch.

Rollback. Set BARCODE_RENDER_DPI back to the previous value and redeploy: git revert HEAD~1 --no-edit && docker compose up -d --build. Because rendering is pure — the same payload and DPI always produce the same geometry — reverting and re-rendering is always safe and never changes a badge’s identity.

Post-rollback validation. Re-render a known sample and confirm the geometry is grid-aligned before reopening the batch:

BASH
python -m badge.barcode  # runs the __main__ assert block; expect "OK dpi=203 ..."

Frequently Asked Questions Link to this section

Why does the barcode scan on screen but not once it is printed? Because the screen has far more pixels per millimetre than a 203 DPI thermal head. On screen a 0.30mm bar is many pixels wide and its edges are exact; on the printer that same bar is 2.4 dots and must be rounded to a whole number, so the printed bar-to-space ratio drifts out of tolerance. Snapping the module width onto whole printer dots makes the print match the design.

Should I just increase the barcode size until it scans? Only up to the point where the X-dimension is a whole number of dots and clears the 0.254mm floor — beyond that you are wasting badge space. Size alone does not fix a sub-pixel straddle; a large barcode whose module is still 2.4 dots prints with the same uneven bars. Snap first, then size to fit.

Does thermal darkness matter if the geometry is already correct? Yes. A darkness setting tuned too hot spreads heat into the media and fattens every bar by a fraction of a dot, shifting the ratio even when the vector geometry is perfect. Calibrate darkness to the media and leave a little headroom in the snapped X-dimension so a small, consistent burn gain stays inside tolerance.