UPC Label → Poly Bagger Printer Workflow v3

Source: .docx → ZPL → Zebra ZTC 110Xi4-203dpi ZPL (203 DPI thermal transfer)
Last updated: 2026-07-24

CRITICAL LESSONS LEARNED (v2 → v3):
  1. DO NOT use ^PON or ^FWN — on this printer, these commands cause "Zebra is not ready" / printer rejects the job. Instead, rotate the image 180° in Python if the label prints upside down.
  2. Printer goes "not ready" after bad ZPL — reboot the printer to clear the error state. CUPS re-enable won't fix it.
  3. Bit ordering in ^GFA: use byte_val = (byte_val << 1) | bit with bit = 0 if px else 1. First pixel = MSB = leftmost dot. Do NOT reverse this.
  4. "Flip" = 180° rotation (upside down / right-side up). Never mirror (left-right reversal).
  5. Scale=4 is the standard size (772x200 dots = 3.80" x 0.99"). Scale=3 = 579x150, Scale=5 = 965x250.
Verified labels:

One-Step Automated Script

Use print_label.py — it does everything in one command:

python3 print_label.py "UPC Labels/AK47 G2 Ribbed PLUM - PSA RETAIL.docx" --rotate 180

Or for labels that don't need rotation:

python3 print_label.py "UPC Labels/AKV PLUM - PSA RETAIL.docx"
print_label.py
#!/usr/bin/env python3
"""One-step label printer for Zebra ZTC 110Xi4-203dpi ZPL."""
import argparse, os, subprocess, sys, tempfile
from zipfile import ZipFile
from pathlib import Path
from PIL import Image

def extract_image(docx_path):
    with ZipFile(docx_path) as z:
        image_name = next(n for n in z.namelist() if n.startswith("word/media/"))
        with z.open(image_name) as f:
            return Image.open(f).convert("RGB")

def generate_zpl(img, scale=4, rotate=0):
    if rotate:
        img = img.rotate(rotate, expand=True)
    img = img.convert("1")
    w, h = img.size
    img = img.resize((w * scale, h * scale), Image.NEAREST)
    w, h = img.size
    bpr = (w + 7) // 8
    pixels = list(img.getdata())
    rows = []
    for y in range(h):
        row = []
        for bx in range(bpr):
            bv = 0
            for bi in range(8):
                idx = y * w + bx * 8 + bi
                if idx < y * w + w:
                    p = pixels[idx]
                    bv = (bv << 1) | (0 if p else 1)
                else:
                    bv <<= 1
            row.append(bv)
        rows.append("".join(f"{b:02X}" for b in row))
    data = "".join(rows)
    tb = bpr * h
    lp = 80
    sp = 120
    tm = 30
    lm = 60
    return f"^XA\n^LL{h+lp}\n^PW{w+sp}\n^LS0\n^FO{lm},{tm}^GFA,{tb},{tb},{bpr},{data}^FS\n^XZ"

def send_to_printer(zpl):
    with tempfile.NamedTemporaryFile(mode="w", suffix=".zpl", delete=False) as f:
        f.write(zpl)
        tmp = f.name
    try:
        r = subprocess.run(
            ["lp", "-d", "Zebra", "-o", "raw", tmp],
            capture_output=True, text=True, timeout=30
        )
        if r.returncode != 0:
            print(f"ERROR: {r.stderr.strip()}")
            return False
        print(f"Sent: {r.stdout.strip()}")
        return True
    except subprocess.TimeoutExpired:
        print("ERROR: lp command timed out")
        return False
    finally:
        os.unlink(tmp)

def main():
    ap = argparse.ArgumentParser(description="Print a label from a .docx to the Zebra printer")
    ap.add_argument("docx", help="Path to the .docx file")
    ap.add_argument("--scale", type=int, default=4, choices=[3, 4, 5],
                    help="Scale factor (3=2.85\", 4=3.80\", 5=4.75\")")
    ap.add_argument("--rotate", type=int, default=0,
                    help="Rotate image by degrees (180 for upside-down fix)")
    ap.add_argument("--no-print", action="store_true",
                    help="Only generate ZPL, don't send to printer")
    args = ap.parse_args()

    if not os.path.exists(args.docx):
        print(f"File not found: {args.docx}")
        sys.exit(1)

    print(f"Extracting image from {Path(args.docx).name}...")
    img = extract_image(args.docx)
    print(f"  Image size: {img.size[0]}x{img.size[1]} pixels")

    if args.rotate:
        print(f"  Rotating {args.rotate} degrees...")

    print(f"Generating ZPL (scale={args.scale})...")
    zpl = generate_zpl(img, args.scale, args.rotate)

    output_name = Path(args.docx).stem.replace(" ", "_") + ".zpl"
    output_path = Path("UPC Labels") / output_name if Path("UPC Labels").exists() else Path(output_name)
    with open(output_path, "w") as f:
        f.write(zpl)
    print(f"  Saved: {output_path}")

    if not args.no_print:
        print("Sending to printer...")
        if send_to_printer(zpl):
            print("  Done! Check the printout.")
        else:
            print("  FAILED. Try: reboot the printer, then run again.")
    else:
        print("  (--no-print: not sent)")

if __name__ == "__main__":
    main()

Manual Steps (if not using the script)

1. Install dependencies

pip3 install Pillow

2. Extract image from .docx

python3 -c "
from zipfile import ZipFile; from PIL import Image
with ZipFile('UPC Labels/your_label.docx') as z:
    img = next(n for n in z.namelist() if n.startswith('word/media/'))
    with z.open(img) as f: Image.open(f).convert('RGB').save('UPC Labels/your_label.png')
print('Done')
"

3. Rotate if needed (180 = fix upside down)

python3 -c "
from PIL import Image
Image.open('UPC Labels/your_label.png').rotate(180, expand=True).save('UPC Labels/your_label_rotated.png')
print('Rotated')
"

4. Generate ZPL

python3 -c "
from PIL import Image; from pathlib import Path
scale = 4
img = Image.open('UPC Labels/your_label.png').convert('1')
w, h = img.size
img = img.resize((w * scale, h * scale), Image.NEAREST)
w, h = img.size
bpr = (w + 7) // 8
pixels = list(img.getdata())
rows = []
for y in range(h):
    row = []
    for bx in range(bpr):
        bv = 0
        for bi in range(8):
            idx = y * w + bx * 8 + bi
            if idx < y * w + w:
                bv = (bv << 1) | (0 if pixels[idx] else 1)
            else:
                bv <<= 1
        row.append(bv)
    rows.append(''.join(f'{b:02X}' for b in row))
data = ''.join(rows)
zpl = f'^XA\n^LL{h+80}\n^PW{w+120}\n^LS0\n^FO60,30^GFA,{bpr*h},{bpr*h},{bpr},{data}^FS\n^XZ'
Path('UPC Labels/your_label.zpl').write_text(zpl)
print(f'Saved ({w}x{h} dots)')
"

5. Send to printer

lp -d Zebra -o raw "UPC Labels/your_label.zpl"

6. Verify

lpq -P Zebra

Setup (one-time)

Connect printer via USB

USB-B to USB-A cable (or USB-C adapter). Port is on the back.

Create print queue (one-time)

lpadmin -p Zebra -E \
  -v "usb://Zebra%20Technologies/ZTC%20110Xi4-203dpi%20ZPL?serial=JJL100932" \
  -o printer-is-shared=false

Verify detection

lpinfo -v | grep -i zebra

ZPL Format (minimal — this works)

^XA
^LL280
^PW892
^LS0
^FO60,30^GFA,19400,19400,97,[HEX_DATA]^FS
^XZ
DO NOT ADD: ^PON, ^FWN, or any other orientation commands — they cause the printer to reject the job on this model.

Label dimensions

ScaleImage dotsPhysical sizeLabel area with padding
3×579×1502.85" × 0.74"699×230
4×772×2003.80" × 0.99"892×280
5×965×2504.75" × 1.23"1085×330

Troubleshooting (v3 updates)

IssueFix
ZPL causes "not ready"Remove ^PON/^FWN. Reboot printer. Resend clean ZPL.
Label upside downRotate image 180° in Python, regenerate ZPL, resend.
Label mirroredUse byte_val = (byte_val << 1) | bit (NOT reversed bit order).
Garbled / unreadableWrong bit ordering or printer in bad state. Reboot printer. Use original << 1 bit shift.
Printer stays "not ready"Reboot the printer (power cycle). CUPS commands won't clear this.
Bottom cut offIncrease bottom_padding (80→120+)
Sides cut offIncrease side_padding (120→160+) or left_margin
Label too large/smallChange scale (3, 4, or 5)
Garbled text (not image)Forgot -o raw. macOS converts ZPL to raster.

DIY Controller (Arduino / ESP32 / Raspberry Pi)

.docx Format (for parsing on-device)

A .docx file is a standard ZIP archive. The label image is always at a fixed path inside:

Path inside .docxContent
word/media/image1.pngThe label image (PNG format)
word/document.xmlWord document XML (not needed — just extract the PNG)
[Content_Types].xmlContent type manifest (not needed)
# Pseudocode: extract PNG from .docx
Open file as ZIP
Find first entry starting with "word/media/"
Extract raw bytes → save as PNG
Close ZIP

The label images are 193×50 pixel RGB PNGs. Different labels may have different dimensions — the script auto-detects.

RGB → 1-bit Conversion

# Pseudocode: convert RGB pixel to black/white
For each pixel (R, G, B):
    luminance = (R + G + B) / 3   # simple average
    if luminance < 128:
        output = 0   # black
    else:
        output = 255 # white

ZPL ^GFA Encoding (exact byte format)

# Pseudocode: encode 1-bit image as ^GFA hex
Input: width W, height H, 1-bit pixel data (0=black, 255=white)
Scale: multiply W and H by scale_factor (4 is standard)

bpr = (W + 7) / 8         # bytes per row (rounded up)
hex_string = ""

For each row y from 0 to H-1:
    For each byte bx from 0 to bpr-1:
        byte_val = 0
        For each bit bi from 0 to 7:
            pixel_idx = y * W + bx * 8 + bi
            if pixel_idx < y * W + W:
                if pixel[pixel_idx] == black:
                    bit = 1   # 1 = black dot (print)
                else:
                    bit = 0   # 0 = white dot (skip)
                byte_val = (byte_val << 1) | bit
            else:
                byte_val <<= 1  # pad remaining bits with 0
        hex_string += format(byte_val, "02X")
    # No newline between rows — continuous hex string

total_bytes = bpr * H
padding_bottom = 80
padding_side = 120
margin_top = 30
margin_left = 60

label_h = H + padding_bottom
label_w = W + padding_side

ZPL = "^XA\n" +
      "^LL" + label_h + "\n" +
      "^PW" + label_w + "\n" +
      "^LS0\n" +
      "^FO" + margin_left + "," + margin_top +
      "^GFA," + total_bytes + "," + total_bytes + "," + bpr + "," + hex_string + "^FS\n" +
      "^XZ"

Communication Protocols

TCP (Ethernet/WiFi)

Zebra printers listen on TCP port 9100 by default. Send raw ZPL bytes, then close the connection.

# Pseudocode: TCP
connect to printer_ip on port 9100
send ZPL bytes
close connection

# Verification
netcat (nc) also works: nc printer-ip 9100 < label.zpl

Serial (RS-232 / USB-Serial)

Settings: 9600 baud, 8 data bits, no parity, 1 stop bit (9600 8N1)
No flow control

# Pseudocode: Serial (Arduino / ESP32)
Serial.begin(9600, SERIAL_8N1);
Serial.write(zpl_bytes, zpl_length);
Serial.flush();

USB (USB-B on printer)

USB on this printer uses the printer's USB CDC or vendor-specific class. On a Pi, it shows up as a serial device or CUPS queue. On ESP32/Arduino, USB host mode would be needed — TCP or serial is far simpler.

Recommendation by Platform

PlatformBest protocolNotes
Raspberry PiTCP (port 9100)Can run Python/Pillow, full .docx parsing. Same as macOS workflow.
ESP32TCP (WiFi) port 9100Needs PNG decoder library (e.g., pngle or lodepng). Embed the label image as raw bytes or host a web UI for upload.
Arduino (Uno/Mega)Serial 9600 8N1Too little RAM for PNG decode. Pre-convert PNG → hex on PC, upload to SD, send from SD via serial.
Arduino + WiFi shieldTCP port 9100Same RAM limits. Pre-encoded ZPL stored on SD card.

Windows / Alternative Methods

No Admin Rights (COM port)

mode COM4:9600,n,8,1
copy /b label.zpl COM4:

Network (if printer has IP)

nc printer-ip 9100 < label.zpl

LabelView (T-1000 Bagger)

Windows-only licensed app from TEKLYNX. Recreate the label as .nlbl on the bagger. Cannot convert ZPL→.nlbl.


Print Queue Reference

# Check status
lpstat -p Zebra
# Check queue
lpq -P Zebra
# Cancel all jobs
cancel -a Zebra
# Re-enable
cupsenable Zebra
# Delete and recreate queue
lpadmin -x Zebra
lpadmin -p Zebra -E -v "usb://Zebra%20Technologies/ZTC%20110Xi4-203dpi%20ZPL?serial=JJL100932" -o printer-is-shared=false