ESC/POS bitmap-label wire protocol
This page documents the driver-complete bitmap-label subset of ESC/POS — every directive needed to drive a bitmap-rasterised print job end-to-end on a spec-compliant Epson printer, plus paper handling, plus status. Native text, native barcodes, page-mode buffering, cash-drawer kick, buzzer, kanji, and macros are out of scope by design.
Source
Bytes below are documented from Epson's ESC/POS Application Programming Guide — the canonical ESC/POS reference, published by Seiko Epson Corporation. See INTEROPERABILITY.md for the full statement of intent.
Design rule
Bitmap in, printer code out. The host renders content (text, barcodes, graphics) into a 1-bpp bitmap upstream; this package emits the wire bytes that drive the printer to render that bitmap. Commands that exist solely to make the printer render content the host could have rendered are not in scope.
Print-job structure
A typical job is three parts back-to-back:
1B 40 ESC @ initialise
1D 76 30 00 xL xH yL yH GS v 0 m=0 + 4-byte header raster command
<raster bytes> widthBytes × heightDots 1-bpp packed payloadDrivers needing paper handling (feeds / cuts) or print-area control compose the directives below alongside the raster.
For multiple copies, write the payload N times back-to-back. Pure ESC/POS has no protocol-level multi-copy framing.
Initialisation
| Builder | Bytes | Purpose |
|---|---|---|
buildEscposReset() | 1B 40 | ESC @ — reset to power-on state |
Always the first command in a job.
Bitmap raster
| Builder | Bytes | Purpose |
|---|---|---|
buildGsV0Raster(widthBytes, heightDots) | 1D 76 30 00 xL xH yL yH | Modern raster (GS v 0) — preferred on recent Epson chassis |
buildEscStarRaster(mode, widthDots, heightDots) | 1B 2A m xL xH | Legacy raster (ESC *) — modes 0 / 1 / 32 / 33 for older chassis |
buildEscposDensity(level) | 1B 4E 07 nn | ESC N 7 — set print density |
ESC * modes
0— 8-dot single-density (60 × 90 dpi)1— 8-dot double-density (60 × 180 dpi)32— 24-dot single-density (180 × 90 dpi)33— 24-dot double-density (180 × 180 dpi)
ESC * and GS v 0 overlap; both are provided so this package can drive either chassis family. JSDoc on each builder describes the trade-off.
Bit polarity
Bit 1 in the raster payload denotes a printed dot. This matches the 1 = dark invariant used by LabelBitmap from @mbtech-nl/bitmap — no flip needed.
Width must be a multiple of 8 dots
GS v 0 uses truncating row stride: the firmware reads exactly widthBytes × heightDots bytes, and any pixels past the last 8-dot boundary are silently dropped. Pad upstream:
import { padBitmap } from '@mbtech-nl/bitmap';
const aligned = padBitmap(source, { right: 8 - (source.widthPx % 8) });Paper handling
| Builder | Bytes | Purpose |
|---|---|---|
buildFeedLines(n) | 1B 64 nn | ESC d n — feed n lines at current line spacing |
buildFeedDots(n) | 1B 4A nn | ESC J n — feed n dots forward |
buildReverseFeed(n) | 1B 4B nn | ESC K n — reverse-feed n dots (where supported) |
buildCut(mode, feed?) | 1D 56 … | GS V — full / partial / feed-then-cut |
buildPrintAndFeed(n?) | 1B 64 nn | Convenience alias of buildFeedLines |
GS V cut variants:
1D 56 00 full cut
1D 56 01 partial cut
1D 56 41 nn feed nn dots, then full cut
1D 56 42 nn feed nn dots, then partial cutThe decimal duplicates (0 / 48, 1 / 49, 65 / 97, 66 / 98) are interchangeable per the spec; the builder emits the lower of each pair.
Print-area control
| Builder | Bytes | Purpose |
|---|---|---|
buildLeftMargin(dots) | 1D 4C nL nH | GS L — set left margin in dots (LE u16) |
buildPrintWidth(dots) | 1D 57 nL nH | GS W — set print-area width in dots (LE u16) |
buildPanelButtons(state) | 1B 63 35 nn | ESC c 5 — enable / disable front panel buttons |
buildLineSpacing(dots) | 1B 33 nn | ESC 3 — set line-spacing slot in dots |
buildDefaultLineSpacing() | 1B 32 | ESC 2 — reset line-spacing slot to printer default |
ESC 2 / ESC 3 modulate the dot count consumed by ESC d (line feed) — they affect bitmap-mode paper handling and so are in scope. The text-mode right-spacing setter ESC SP is not in scope.
ESC c 5 accepts any byte; only bit 0 is significant per the spec (0 enabled, 1 disabled). buildPanelButtons accepts a boolean and encodes the spec-canonical values.
Status query
| Builder | Bytes | Purpose |
|---|---|---|
buildRealtimeStatusQuery(kind) | 10 04 nn | DLE EOT n — real-time status (replies interleave with print data) |
buildTransmitStatus(kind) | 1D 72 nn | GS r n — legacy "transmit status" |
buildAutoStatusBack(kind) | 1D 61 nn | GS a n — Automatic Status Back (printer pushes status without query) |
DLE EOT n argument values:
kind | n | Reply |
|---|---|---|
'printer' | 1 | Printer status (online / cover / panel-button / error) |
'offline-cause' | 2 | Cause of an offline condition |
'error-cause' | 3 | Cause of an error condition |
'paper-sensor' | 4 | Paper sensor status |
GS r n argument values:
kind | n | Reply |
|---|---|---|
'paper-sensor' | 1 | Paper sensor status |
'drawer' | 2 | Drawer-kick connector pin-3 status |
GS a n accepts a string from 'off' | 'drawer' | 'online' | 'error' | 'paper' | 'all'; the bitmask is composed spec-canonically.
Realtime-status replies
Reply frames arrive byte-oriented:
[1A]? <opcode> <payload>The 0x1A lead byte ("transmit status") is optional but commonly present.
The Epson realtime-status reply opcodes parsed by this package:
| Opcode | Reply | Payload | Encoding |
|---|---|---|---|
0x10 | printer | 1 byte | bit 3 = online, bit 5 = cover, bit 6 = paper-feed button, bit 7 = error |
0x12 | cover | 1 byte | bit 2 = cover open |
0x14 | drawer | 1 byte | bit 2 = drawer-kick connector pin 3 high |
0x16 | paper | 1 byte | bit 2 = paper near-end, bit 5 = paper-out |
0x18 | autocutter | 1 byte | bit 3 = autocutter error |
0x60 | error-info | 4 bytes | byte 0: bit 2 = paper jam, bit 3 = mechanical, bit 4 = autocutter; byte 1: bit 0 = unrecoverable, bit 6 = temperature |
0x70 | offline-cause | 4 bytes | byte 0: bit 2 = cover, bit 3 = paper-feed button, bit 5 = paper end, bit 6 = error |
0x80 | printer-star | 1 byte | Star-compatible: bit 3 = online, bit 5 = cover, bit 2 = paper out |
Bit numbering
Epson's spec uses 1..8 bit numbering in its reply tables; this package documents zero-indexed bit positions (so the spec's "bit 8" = 0x80 in our notation). Both numbering conventions appear in the parser source comments to keep the mapping unambiguous.
Anything else — including vendor opcode-table extensions — surfaces as { kind: 'unknown', opcode, payload } with payload = remainder-of-buffer. Downstream packages that know their vendor's opcode table consume the unknown reply and decode it themselves.
Parser: parseRealtimeStatus(bytes, offset?).