Back to Blog
ARTICLE

zpl-renderer — turning printer code into pixels, with no printer

August 21, 2026
Milad Nalbandi
4 min read
Java

If you've ever written the backend for anything that ships a physical parcel, you've met ZPL — Zebra Programming Language. It's the language thermal label printers speak, and it looks like this:

^XA
^CF0,60
^FO50,50^FDHello, ZPL!^FS
^FO50,150^BY3,2,100^BC^FD123456789^FS
^XZ

That's a 4×6 shipping label: some text, a Code 128 barcode. Your carrier integration spits out a few hundred lines of it per order, and it works — right up until the moment you need to look at one.

Because ZPL has a peculiar property: it is write-only in practice. It's not a document format, it's a stream of printer commands. There is no viewer built into anything. So when a label comes out wrong — barcode clipped, address overflowing its field, logo bitmap mangled — your options have historically been:

  1. Print it. Walk to the warehouse, print, squint, walk back, change one coordinate, repeat. The feedback loop is measured in minutes and footsteps.
  2. Send it to Labelary. A genuinely excellent free web API that renders ZPL to PNG. It also means POSTing your labels — with names, addresses, order numbers, tracking codes on them — to a third-party server. Every time. In your test suite too, if you're not careful.

Neither of those is a good answer for a build pipeline. So I wrote a third one.

zpl-renderer

zpl-renderer is a pure-JVM ZPL II renderer. ZPL string in, BufferedImage out. No Zebra hardware, no API call, no native dependency, no network access of any kind.

val image = ZplRenderer.render(zpl, dpi = 300, widthInch = 4.0, heightInch = 6.0)
ImageIO.write(image, "png", File("label.png"))

That's the whole API surface for the common case. There's a Java-friendly overload too — every public method carries @JvmStatic / @JvmOverloads, because a library in this space that's awkward to call from Java is a library half the ecosystem can't use.

BufferedImage image = ZplRenderer.render("^XA^CF0,60^FO50,50^FDHello^FS^XZ", 300, 4.0, 6.0, false, true);

What it actually understands

Not "parses ZPL" in the hand-wave sense — here's the real surface:

  • Text^FO/^FT positioning, ^A font and orientation, ^CF defaults, ^FB word-wrapped field blocks, ^FR/^FI reverse video, ^FH hex escapes.
  • Barcodes — Code 128 (^BC/^B3), QR (^BQ), EAN-8 (^B8), EAN-13 (^BE), UPC-A (^BU), Data Matrix (^BX), generated through ZXing.
  • Shapes^GB boxes, ^GE ellipses, ^GD diagonals.
  • Bitmaps^GFA/^GFB graphic fields, including nibble-based run-length encoding, :Z64: (zlib + Base64) and :B64:.
  • Templates^DF/^XF store-and-recall with ^FN field numbers, plus ~DG/^XG graphic store, so labels built as a template plus variable data render the way the printer would resolve them.
  • Structure^XA/^XZ, ^LH home offset, ^FW default orientation, ^BY barcode defaults, ^FX comments.

Output is DPI-aware (200 and 300 dpi, the two that matter), with configurable label dimensions and an in-memory cache keyed on the ZPL plus every render parameter.

The design that made it tractable

ZPL is a long tail. There are hundreds of commands, most of which nobody uses, and a handful that every label depends on. A monolithic parser would have collapsed under its own when block by the third feature request.

So the engine is a chain of responsibility:

ZplRenderer  (public API)
    │
    └─ ZplEngine  (dispatcher)
           ├─ ControlHandler     ^XA ^XZ ^LH ^CF ^FW ^FX …
           ├─ TextHandler        ^FO ^FT ^A ^FB ^FD ^FS ^FR …
           ├─ RectangleHandler   ^GB ^GE ^GD
           ├─ BitmapHandler      ^GFA ^GFB
           └─ BarcodeHandler     ^BY ^BC ^BQ ^B8 ^BE ^BU ^BX

Each handler declares which commands it claims and gets handed the ones it wants. Adding support for a new command means adding a case to one handler, or writing a new handler — never touching the dispatcher.

Two decisions did most of the work:

ZplEngine is an instance, not a singleton. It started life as a global object, which is the obvious thing to write and the wrong thing to have. Making it instantiable means you can construct an engine with custom handlers, and — more importantly — means two concurrent renders can't scribble over each other's state.

All mutable state lives in RenderContext, reset on every ^XA. ZPL is deeply stateful: ^CF changes the default font for everything after it, ^BY sets barcode dimensions for subsequent barcodes, ^LH shifts the origin for the rest of the label. Cramming that into handler fields would make renders order-dependent across calls in a way that's miserable to debug. One context object, created fresh per label, keeps every render hermetic.

The ^GFA decoder deserves a mention as the least glamorous, most satisfying part. Compressed ZPL bitmaps use a nibble-based RLE scheme with repeat characters, line-repeat semantics, and two Base64 container formats layered on top. It's the kind of format you implement by reading the spec and then discovering what real printers actually accept. When a mangled logo suddenly renders correctly, that's the code that fixed it.

Using it

Kotlin 2.0 on the JVM, JDK 17+, Apache 2.0, distributed through JitPack:

repositories { maven("https://jitpack.io") }
dependencies { implementation("com.github.MiladNalbandi:zpl-renderer:v1.1.9") }

The natural fits are the ones I built it for: rendering label previews in an admin panel without a round trip, snapshot-testing your label templates in CI so a coordinate change can't silently break a barcode, and generating PNGs for customer-facing "here's your label" screens without handing addresses to a third party.

It ships in something real

zpl-renderer wasn't written as a library first. It was extracted from ZPL Label Viewer, an IntelliJ plugin that previews labels inside your IDE — separate post on that one. Pulling the engine out into its own repo was the best structural decision in either project: the plugin got smaller and the renderer got a test suite, a changelog, and a life outside a single IDE.

Source, issues and full command reference: github.com/MiladNalbandi/zpl-renderer. If your labels use a command I haven't handled yet, open an issue with the ZPL — that's how most of the current coverage got written.

Milad Nalbandi
Software Engineer & Writer