@razmans/thermal-printer-encoder
v1.0.0
Published
Turn a declarative receipt document (text, styling, images) into an ESC/POS byte stream ready to send over any serial or Bluetooth transport.
Maintainers
Readme
thermal-printer-encoder
Turn a plain, declarative description of a receipt — text, styling, images, cuts — into an ESC/POS byte stream that thermal receipt printers understand.
This library does not talk to any printer. It has no dependency on serial
ports, Bluetooth, USB, or Node's net/serialport modules, so it works the same
in Node, a browser, or React Native. You get a Uint8Array back; how you send it
(Web Bluetooth, serialport, a native Bluetooth SPP socket, a network print
server, lp, whatever) is entirely up to you.
Install
npm install thermal-printer-encoderQuick start
import { encodeReceipt } from "thermal-printer-encoder";
const bytes = encodeReceipt({
nodes: [
{ type: "text", value: "MY STORE", style: { align: "center", bold: true, width: 2, height: 2 } },
{ type: "text", value: "123 Main St", style: { align: "center" } },
{ type: "rule" },
{ type: "text", value: "Coffee $3.50" },
{ type: "text", value: "Bagel $2.25" },
{ type: "rule" },
{ type: "text", value: "TOTAL $5.75", style: { bold: true } },
{ type: "feed", lines: 2 },
{ type: "cut" },
],
});
// `bytes` is a Uint8Array of raw ESC/POS commands — send it over whatever
// transport you're using, e.g.:
await bluetoothCharacteristic.writeValue(bytes);
// or
serialPort.write(Buffer.from(bytes));Document model
A receipt is just { nodes: ReceiptNode[] }. Nodes are processed top to bottom.
text
{
type: "text",
value: "Hello world",
style: {
align: "left" | "center" | "right",
bold: boolean,
underline: boolean,
invert: boolean, // white-on-black, if the printer supports it
width: 1-8, // horizontal size multiplier
height: 1-8, // vertical size multiplier
},
}Each text node ends with a line feed and resets style back to normal for the next node, so you never have to manually turn formatting off.
image
{
type: "image",
source: pngOrJpegBuffer | { width, height, data: rgbaBytes },
align: "left" | "center" | "right",
width: 384, // resize to this many dots (aspect-ratio preserved)
dither: "floyd-steinberg" | "threshold", // default: "floyd-steinberg"
threshold: 128, // only used when dither: "threshold"
}source accepts either:
- Already-decoded RGBA pixel data (e.g.
ImageDatafrom a<canvas>):{ width, height, data }— works everywhere (Node, browser, React Native), no extra setup, or - A raw PNG or JPEG file buffer (
Uint8Array/ArrayBuffer) — the core package has no bundled decoder for this (see below), so it only works if you pass adecodeImagefunction inencodeReceipt's options.
Decoding PNG/JPEG buffers
The main thermal-printer-encoder entry point has zero Node-specific
dependencies, so it can be bundled for a browser or React Native without
pulling in a PNG/JPEG decoder you'll never use there. If you want to hand
encodeReceipt a raw file buffer instead of pre-decoded pixels:
- In Node, pass the bundled decoder (uses
pngjs/jpeg-js) via the/nodesubpath:import { encodeReceipt } from "thermal-printer-encoder"; import { decodeImageBuffer } from "thermal-printer-encoder/node"; const bytes = encodeReceipt(document, { decodeImage: decodeImageBuffer }); - In a browser, decode via
<canvas>yourself and pass the resultingRasterImagedirectly as theimagenode'ssource— nodecodeImageoption needed:async function loadRasterImage(url: string): Promise<RasterImage> { const img = await new Promise<HTMLImageElement>((resolve, reject) => { const el = new Image(); el.onload = () => resolve(el); el.onerror = reject; el.src = url; }); const canvas = document.createElement("canvas"); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; const ctx = canvas.getContext("2d")!; ctx.drawImage(img, 0, 0); const { data, width, height } = ctx.getImageData(0, 0, canvas.width, canvas.height); return { width, height, data: new Uint8Array(data.buffer) }; }
Images are converted to 1-bit monochrome with Floyd–Steinberg error diffusion
by default, which reproduces photos and logos far better than a flat
black/white threshold on thermal printers' limited resolution. Pass
dither: "threshold" if you'd rather have hard edges (e.g. for line-art logos
that are already black and white).
Most 58mm printers print at 384 dots wide; most 80mm printers print at 576.
Set width on the image node (or paperWidthDots in encodeReceipt's
options) to match your printer, or the image will be clipped to the paper
width.
barcode
{
type: "barcode",
data: "012345678905",
barcodeType: "upc-a" | "upc-e" | "ean13" | "ean8" | "code39" | "itf" | "codabar" | "code93" | "code128",
align: "left" | "center" | "right",
height: 80, // bar height in dots
width: 3, // bar module width in dots, 2-6
textPosition: "below", // "none" | "above" | "below" | "both"
}data must satisfy the chosen symbology's character set and length rules
(e.g. ean13 needs 12-13 digits, code128 accepts arbitrary ASCII) — this
library does not validate or checksum the payload, it passes it straight to
the printer's barcode engine.
qrcode
{
type: "qrcode",
data: "https://example.com",
align: "left" | "center" | "right",
size: 6, // module (dot) size, 1-16
errorCorrection: "M", // "L" | "M" | "Q" | "H" — higher = more damage-tolerant, less capacity
}feed, rule, cut
{ type: "feed", lines: 3 }
{ type: "rule", char: "-", length: 32 } // draws a divider line of repeated characters
{ type: "cut", mode: "full" | "partial" } // default: "full"Raw ESC/POS commands
These are the raw byte sequences the encoder emits internally
(src/escpos/commands.ts). Useful as a reference if you need to append your
own bytes to the Uint8Array returned by encodeReceipt (e.g. to kick a cash
drawer, which has no dedicated document node yet).
| Command | Bytes | What it does |
| --- | --- | --- |
| INIT | 1B 40 | Resets the printer to its default state. |
| ALIGN_LEFT | 1B 61 00 | Left-aligns subsequent content. |
| ALIGN_CENTER | 1B 61 01 | Center-aligns subsequent content. |
| ALIGN_RIGHT | 1B 61 02 | Right-aligns subsequent content. |
| BOLD_ON / BOLD_OFF | 1B 45 01 / 1B 45 00 | Toggles bold text. |
| UNDERLINE_ON / UNDERLINE_OFF | 1B 2D 01 / 1B 2D 00 | Toggles underlined text. |
| INVERT_ON / INVERT_OFF | 1D 42 01 / 1D 42 00 | Toggles white-on-black (reverse) printing. |
| LF | 0A | Line feed — advances one line. |
| CUT_FULL | 1D 56 00 | Fully cuts the paper. |
| CUT_PARTIAL | 1D 56 01 | Partially cuts the paper (leaves a tab). |
| DRAWER_KICK | 1D 70 00 19 FA | Pulses cash drawer pin 2 (25ms on / 250ms off) to pop it open. |
Options
encodeReceipt(document, {
paperWidthDots: 576, // 384 (58mm) or 576 (80mm); also caps image width
charsPerLine: 48, // used to size `rule` nodes when `length` isn't given
decodeImage: undefined, // decodes an `image` node's PNG/JPEG buffer `source` — see "Decoding PNG/JPEG buffers" above
});Why declarative instead of a fluent builder?
Receipts are naturally data: they're built from database rows, templates, or serialized and replayed later. A plain object is easy to construct programmatically, validate, log, snapshot-test, and send over the wire (e.g. render on a server, print on a client) without coupling to a builder API.
What this library intentionally does not do
- Open, list, or write to serial/Bluetooth/USB ports.
- Manage printer connection state, retries, or reconnection.
- Support every ESC/POS extension ever shipped by every vendor — it covers the
common subset (text styling, raster images, barcodes, QR codes, feed, cut)
that works across the vast majority of receipt printers. More obscure
vendor-specific escape sequences (cash drawer kick, NV logo storage, etc.)
can be appended manually by concatenating your own bytes with the
Uint8Arraythis library returns.
License
MIT
