Printing shipping and product labels from a web app (ZPL and TSPL, no PDF)
If your web app ships parcels, prices products or tracks stock, sooner or later someone asks: "Can it print the label straight to the Zebra?" The usual first attempt is to generate a PDF and call window.print(). That wo
If your web app ships parcels, prices products or tracks stock, sooner or later someone asks: "Can it print the label straight to the Zebra?"
The usual first attempt is to generate a PDF and call window.print(). That works badly. The label comes out scaled, shifted or split across two labels, and every PC needs its own driver settings. Label printers have their own languages, and sending those directly is simpler and much more reliable.
This post covers the two languages you'll meet most (ZPL and TSPL), the units and sizes that trip people up, and how to get the bytes from a browser to the printer.
Two languages cover most printers
| Language | Printers |
|---|---|
| ZPL | Zebra, and most printers sold as "Zebra-compatible" |
| TSPL | TSC, 4BARCODE, many Xprinter and generic 4" label printers |
| EPL | older Zebra/Eltron models (avoid for new work) |
Both are plain text. You build a string, send it to the printer as-is (raw), and the printer draws the label itself. No driver rendering, no PDF, no scaling.
Units: everything is in dots
This is where most first labels go wrong. Coordinates are in printer dots, not millimetres or pixels:
- 203 dpi printers (the common ones): 8 dots per mm
- 300 dpi printers: about 11.8 dots per mm
So a 4 Γ 6 inch shipping label is 812 Γ 1218 dots at 203 dpi, and 1200 Γ 1800 at 300 dpi. If your label prints at two-thirds size, you wrote it for 203 dpi and sent it to a 300 dpi printer (or the other way round). Keep the dpi in your printer settings and multiply.
ZPL: a shipping label
^XA
^PW812
^LL1218
^CI28
^CF0,40
^FO40,40^FDShip to: Jane Doe^FS
^CF0,30
^FO40,90^FD221B Baker Street, London^FS
^FO40,150^BY3^BCN,120,Y,N,N^FD1Z999AA10123456784^FS
^FO40,320^BQN,2,6^FDQA,https://example.com/track/1Z999AA10123456784^FS
^XZ
What the commands do:
-
^XAβ¦^XZ: start and end of a label -
^PW812/^LL1218: print width and label length in dots (4 Γ 6" at 203 dpi) -
^CI28: treat field data as UTF-8, soΓ©,ΓΌandΒ£work with the built-in scalable font -
^CF0,40: default font 0 (scalable), 40 dots high -
^FOx,y: field origin (top-left of the next element) -
^FDβ¦^FS: field data, then field separator -
^BY3^BCN,120,Y,N,N: Code 128 barcode, module width 3, 120 dots high, human-readable text underneath -
^BQN,2,6+^FDQA,β¦: QR code, magnification 6. TheQA,prefix means error-correction level Q and automatic input mode. It isn't part of your data.
Tip: paste ZPL into the free Labelary viewer to see the label before you print a single one. It saves a lot of paper.
TSPL: a product label
SIZE 50 mm, 30 mm
GAP 2 mm, 0 mm
DIRECTION 1
CLS
TEXT 20,20,"3",0,1,1,"Organic Honey 500g"
TEXT 20,60,"2",0,1,1,"Price: 9.99"
BARCODE 20,100,"128",70,1,0,2,2,"SKU-000123"
PRINT 1,1
-
SIZE/GAP: label size and the gap between labels. TSPL lets you use mm here, butTEXTandBARCODEpositions are still in dots. -
CLS: clear the image buffer. Forget it and you'll print the previous label on top of the new one. TEXT x,y,"font",rotation,x-mul,y-mul,"content"BARCODE x,y,"128",height,readable,rotation,narrow,wide,"content"-
QRCODE x,y,M,4,A,0,"https://β¦"adds a QR code (ECC level M, cell width 4). -
PRINT 1,1: print one copy of one set
End each line with \r\n. Many printers accept \n, but some older firmware doesn't.
Common gotchas
-
Wrong gap/label length = blank labels or a label skipped every time. Calibrate the media sensor once. On Zebra, hold the feed button or send
~JC. On TSC, use the printer's utility or theAUTODETECTcommand. Then make sure^LL/SIZEmatch the real stock. -
Built-in fonts are limited. ZPL's font 0 plus
^CI28handles most Latin text. TSPL's bitmap fonts are often ASCII-only. For Arabic, Cyrillic or CJK, either install a font on the printer or draw the text as an image in the browser (a<canvas>), then send it with ZPL^GFAor TSPLBITMAP. Treat the image as 1-bit and threshold it, because label printers don't do greys. -
Don't send ZPL through a "normal" print job. On Windows, a job submitted through the driver with datatype
RAW(OpenPrinterβStartDocPrinterwith"RAW"βWritePrinter) passes your ZPL through untouched. Printing a text file through the driver instead prints the ZPL source code as text on the label. On macOS/Linux,lp -d <printer> -o raw label.zpldoes the right thing. -
Network label printers listen on TCP port 9100.
nc 192.168.1.60 9100 < label.zplis the fastest way to test one. Browsers can't open raw TCP sockets, though, which brings us toβ¦
Getting the bytes from the browser to the printer
Option 1: the vendor's agent
Zebra ships a free local agent called Browser Print, with a JS library. If every printer you'll ever support is a Zebra, use it. It doesn't help with TSC and the many generic TSPL printers, and you'll need a separate solution for receipt printers.
Option 2: WebUSB
Chromium-only, needs a user gesture to pair, and on Windows the installed driver usually holds the USB interface, so the browser can't claim it. It's fine for a kiosk where you control the machine, and painful anywhere else.
Option 3: your own small local agent
A program on the PC listens on 127.0.0.1, your page POSTs the ZPL/TSPL string to it, and it writes the bytes to the OS print queue as RAW (or to TCP 9100 for network printers). It's about a hundred lines of code, plus the parts people skip:
- answer Chrome's Local Network Access preflight with
Access-Control-Allow-Private-Network: true. If you passtargetAddressSpacetofetch, use'loopback'for 127.0.0.1. In Chrome 155,'local'fails. - bind to
127.0.0.1only, check theOriginheader against an allowlist, and reject non-loopbackHostheaders (DNS rebinding) - lock per printer so two tabs don't interleave two labels
I wrote up the receipt-printer side of this (ESC/POS, printer widths, raster images) in a previous post.
If you'd rather not build the agent
I packaged that agent as Thermalink: a single ~7 MB binary for Windows/macOS/Linux plus a JS SDK. For label printers it's a pure raw passthrough: you write the ZPL/TSPL, and it gets the bytes to the right printer (USB via the OS queue, or network via 9100). The same agent also does receipt printers, where it renders receipts as images.
const tl = new Thermalink();
await tl.printRaw(zpl, { printer: 'labels' }); // 'labels' = alias for the Zebra/TSC queue or tcp://ip:9100
To be upfront: I've tested it on a real BIXOLON receipt printer, and the raw label path is covered by automated tests, but I haven't tested it on a physical label printer yet. If you try it on one, I'd like to hear how it went. It's a paid one-time download with full source: https://7207381545467.gumroad.com/l/xwyogp. Disclosure: I built it.
Everything above works without it too. If you're stuck on a specific label printer, drop the model and your ZPL/TSPL in the comments and I'll take a look.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.