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.
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 nominal0.30mmbar is2.4dots. The rasterizer cannot print2.4dots, 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 roughly0.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
- Read the target device resolution (
203or300DPI) as a first-class render parameter, not an afterthought. - Compute the dot pitch in points:
dot_pt = 72.0 / dpi. - Snap the module width up to the smallest whole-dot multiple that also clears the readability floor, then pass that exact
barWidthto ReportLab. - Assert the chosen
barWidthis an integer multiple ofdot_ptso 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
- Calibrate darkness to the media: print a test symbol at descending heat and scan each until the first reliable pass.
- Prefer a slightly wider snapped X-dimension so a small, consistent burn gain stays inside tolerance rather than eating a thin bar entirely.
- 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
- Draw the barcode as vector geometry directly onto the PDF via ReportLab’s
drawOn, never as a downscaled raster. - If a bitmap is unavoidable, render it at the exact device DPI with nearest-neighbour scaling and anti-aliasing disabled.
- 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
- Enforce a hard
MIN_X_DIM_MMfloor (0.254mm) in the render path; refuse to emit anything thinner. - If the symbol no longer fits, shorten the payload or reclaim space rather than thinning bars — layout trade-offs belong in badge layout architecture.
- 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.
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.
- 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.
- Read the effective X-dimension. Log the computed
barWidthand DPI for the current batch:grep 'X=' render.log | tail. A width that is not a clean multiple of72/dpiconfirms a sub-pixel straddle. - 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.
- Deploy the snapped width. Ship the
dpi_safe_bar_widthchange, 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:
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.
Related Link to this section
- Barcode Threshold Tuning — the parent stage that defines the optical-readability gate and the pass matrix this DPI-safe geometry has to clear.
- Tuning Barcode Quiet Zones for Handheld Scanners — the sibling failure where a correctly-sized symbol still fails because its clear margin was crowded or trimmed away.
- Badge Layout Architecture — where to reclaim space when a floor-compliant symbol no longer fits the template.