Free, open-source alternative to QZ Tray. Send raw ZPL and ESC/POS commands to thermal printers from any web application — no Java, no browser plugins.
EDPrintTool runs a lightweight local service (port 8189) that accepts print jobs via REST API or WebSocket from your web app and forwards raw bytes to Zebra label printers, receipt printers (Star, Epson, Bixolon, Citizen), and other thermal printers.
- ZPL label printing — Zebra printers with configurable label size, darkness, speed, orientation, and media type
- ESC/POS receipt printing — structured command API with text formatting, code pages, auto-cut
- Network & USB — print over TCP (port 9100) or through the OS spooler (Windows/macOS/Linux)
- REST + WebSocket — two ways to integrate, with a drop-in JavaScript client library
- Web dashboard — configure printers, adjust settings, test print from the browser
- No drivers needed — sends raw bytes directly, bypassing print drivers entirely
- Printer profiles — capability presets for Epson, Star, Bixolon, Citizen, and generic printers
- 12 label presets — common Zebra label sizes from 4x8 down to 1x0.5 (jewelry)
- Barcodes & QR codes — CODE128, EAN13, UPC-A, QR codes, PDF417 via ESC/POS
- 1-bit image printing — raster images via ESC/POS
GS v 0 - PDF document printing — print PDFs through the OS spooler (USB printers)
- Cloud relay — print remotely over the internet via a self-hosted relay server
npm install
npm startOpen http://localhost:8189 to access the dashboard. Add a printer, configure its settings, and print a test page.
Local mode (ws://localhost:8189) works great for same-machine printing, but modern browsers (Chrome, Edge) will show a "connect to devices on your local network" permission prompt when your HTTPS web app connects to localhost. This is a Chrome Private Network Access security feature that cannot be bypassed — even QZ Tray faces the same limitation.
Relay mode eliminates this entirely. Your web app talks to the relay over HTTPS (a public domain), so no local network access is needed — no browser prompts, no firewall issues, and it works from anywhere. See Cloud Relay below.
Include the client library from the running service:
<script src="http://localhost:8189/edprint.js"></script>
<script>
const ep = new EDPrint();
await ep.connect();
// List configured printers
const printers = await ep.listPrinters();
// Print a ZPL label
await ep.print('my-printer-id', '^XA^FO50,50^ADN,36,20^FDHello^FS^XZ');
// Print multiple copies
await ep.print('my-printer-id', zpl, { copies: 3 });
// Print without applying saved settings (raw passthrough)
await ep.print('my-printer-id', zpl, { applySettings: false });
// Quick print to IP without saving a printer
await ep.printRaw('192.168.1.100', zpl);
</script>No client library required — just fetch:
// Print ZPL
fetch('http://localhost:8189/api/print/my-printer-id', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
zpl: '^XA^FO50,50^ADN,36,20^FDHello^FS^XZ',
copies: 1
})
});
// Print ESC/POS receipt
fetch('http://localhost:8189/api/print-escpos/my-printer-id', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
commands: [
["initialize"],
["align", "center"],
["bold", true],
["textSize", 1, 2],
["line", "MI TIENDA"],
["textSize", 1, 1],
["bold", false],
["line", "RFC: XAXX010101000"],
["rule", "="],
["align", "left"],
["columns", ["Widget A", "2", "$9.99"]],
["columns", ["Widget B", "1", "$4.50"]],
["rule"],
["pair", "Subtotal", "$14.49"],
["pair", "IVA 16%", "$2.32"],
["bold", true],
["pair", "TOTAL", "$16.81"],
["bold", false],
["cut", "partial", 4]
]
})
});
// Print a PDF document (USB/spooler printers only)
const pdfBase64 = btoa(/* ... raw PDF bytes ... */); // or use FileReader
fetch('http://localhost:8189/api/print-document/my-printer-id', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file: pdfBase64, copies: 1 })
});
// Quick print — send raw data to an IP:port without saving a printer
fetch('http://localhost:8189/api/print-raw', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
host: '192.168.1.100',
port: 9100,
zpl: '^XA^FO50,50^ADN,36,20^FDHello^FS^XZ'
})
});| Method | Endpoint | Description |
|---|---|---|
GET |
/api/status |
Server status and printer count |
GET |
/api/printers |
List configured printers |
GET |
/api/printers/discover |
Discover system printers |
GET |
/api/label-presets |
Available ZPL label sizes |
GET |
/api/printer-profiles |
ESC/POS printer capability profiles |
GET |
/api/printers/:id/debug |
Debug info + hex dump of test payload |
POST |
/api/printers |
Add a printer |
PATCH |
/api/printers/:id/settings |
Update printer settings |
DELETE |
/api/printers/:id |
Remove a printer |
POST |
/api/print/:id |
Print ZPL to a printer |
POST |
/api/print-escpos/:id |
Print ESC/POS commands to a printer |
POST |
/api/print-document/:id |
Print PDF through OS spooler (USB only) |
POST |
/api/print-raw |
Quick print to IP:port (no saved printer) |
Connect to ws://localhost:8189 and send JSON messages:
{ "action": "listPrinters", "requestId": "1" }
{ "action": "print", "requestId": "2", "printerId": "abc", "zpl": "^XA...^XZ" }
{ "action": "printRaw", "requestId": "3", "host": "192.168.1.100", "zpl": "^XA...^XZ" }Responses include the requestId for correlation:
{ "requestId": "1", "success": true, "data": [...] }| Setting | Default | Description |
|---|---|---|
labelPreset |
4x6 |
Label size preset |
widthDots |
812 |
Label width in dots |
heightDots |
1218 |
Label height in dots |
dpi |
203 |
Printer DPI (203 or 304) |
darkness |
15 |
Print darkness (0–30) |
speed |
4 |
Print speed in inches/sec (2–14) |
orientation |
N |
N=normal, R=rotated, I=inverted, B=bottom-up |
mediaType |
T |
T=thermal transfer, D=direct thermal |
printMode |
T |
T=tear-off, P=peel-off, C=cutter |
| Setting | Default | Description |
|---|---|---|
paperWidth |
80mm |
Paper width (80mm, 72mm, or 58mm) |
autoCut |
true |
Send cut command after print |
cutType |
partial |
partial or full cut |
feedLines |
4 |
Lines to feed before cutting |
codepage |
cp1252 |
Code page: cp437, cp850, cp858, cp860, cp863, cp865, cp1252 |
printerProfile |
generic |
Printer capability profile |
ESC/POS printers use single-byte codepages, not UTF-8. EDPrintTool defaults to cp1252 (Windows Latin 1) which supports all Western European and Spanish characters: á é í ó ú ñ ü ¡ ¿.
You can change the codepage per-printer in settings, or per-job with the codepage command. The encoder automatically converts text to the correct single-byte encoding.
| Codepage | ESC/POS ID | Coverage |
|---|---|---|
cp1252 |
16 | Default. Western European, Spanish, Portuguese, French, German |
cp437 |
0 | US ASCII + box-drawing characters |
cp850 |
2 | Multilingual Latin I |
cp858 |
19 | Latin I + Euro sign (€) |
cp860 |
3 | Portuguese |
cp863 |
4 | Canadian French |
cp865 |
5 | Nordic |
The ESC/POS endpoint accepts an array of commands, each as [method, ...args]:
| Command | Args | Description |
|---|---|---|
initialize |
— | Reset printer |
codepage |
name |
Set code page (e.g. "cp1252") |
line |
text |
Print text + line feed |
text |
text |
Print text without line feed |
raw |
text |
Print pre-formatted text |
newline |
— | Print empty line |
empty |
— | Print empty line (alias) |
bold |
true/false |
Toggle bold |
underline |
0/1/2 |
Underline off / thin / thick |
align |
left/center/right |
Set alignment |
font |
0/1 |
Select font (0=Font A 12x24, 1=Font B 9x17) |
textSize |
width, height |
Text size multiplier (1–8) |
invert |
true/false |
Reverse (white on black) |
rule |
[char] |
Print horizontal rule (default "-") |
columns |
[col1, col2, ...] |
Print columns across page width |
pair |
left, right, [fill] |
Key-value with dot fill (e.g. Subtotal......$9.99) |
feed |
[lines] |
Feed paper |
cut |
[type, feedLines] |
Cut paper ("partial" or "full") |
openCashDrawer |
[pin] |
Open cash drawer (0=pin 2, 1=pin 5) |
image |
data, {options} |
Print 1-bit raster image (see below) |
barcode |
data, {options} |
Print 1D barcode (see below) |
qrcode |
data, {options} |
Print QR code (see below) |
pdf417 |
data, {options} |
Print PDF417 barcode (see below) |
["image", "<base64 1-bit pixel data>", { "width": 384 }]Prints a 1-bit monochrome raster image using GS v 0. The data must be raw pixel bytes — not a PNG, BMP, or JPEG file. Each bit represents one pixel (1=black, 0=white), MSB first, packed left-to-right, top-to-bottom.
| Option | Default | Description |
|---|---|---|
width |
(required) | Image width in pixels (must be a multiple of 8) |
height |
(auto) | Image height in pixels. If omitted, calculated from data length |
mode |
0 |
0=normal, 1=double width, 2=double height, 3=both |
Preparing image data:
- Convert your image to 1-bit monochrome (black & white, no grayscale)
- Each row =
width / 8bytes. Total data =(width / 8) * heightbytes - Base64-encode the raw pixel bytes
Typical print widths (203 dpi print heads):
| Paper | Max width |
|---|---|
| 80mm | 576 px |
| 72mm | 512 px |
| 58mm | 384 px |
Validation errors returned if:
widthis missing or not a multiple of 8- Data is empty or not valid base64
- Data length doesn't match
width × height - Width or height exceeds 4096 pixels
["barcode", "12345678", { "type": "CODE128", "height": 80, "width": 2, "hri": "below" }]| Option | Default | Description |
|---|---|---|
type |
CODE128 |
UPC-A, UPC-E, EAN13, EAN8, CODE39, ITF, CODABAR, CODE93, CODE128 |
height |
80 |
Barcode height in dots (1–255) |
width |
2 |
Bar width multiplier (2–6) |
hri |
below |
Human-readable text: none, above, below, both |
["qrcode", "https://example.com", { "size": 6, "errorCorrection": "M" }]| Option | Default | Description |
|---|---|---|
size |
6 |
Module size in dots (1–16) |
errorCorrection |
M |
Error correction level: L (7%), M (15%), Q (25%), H (30%) |
["pdf417", "Invoice data here", { "columns": 0, "width": 3, "errorCorrection": 1 }]| Option | Default | Description |
|---|---|---|
columns |
0 |
Number of columns (0=auto, 1–30) |
rows |
0 |
Number of rows (0=auto, 3–90) |
width |
3 |
Module width (2–8) |
height |
3 |
Row height (2–8) |
errorCorrection |
1 |
Error correction level (0–8) |
EDPrintTool can print over the internet via a self-hosted cloud relay. The relay routes print jobs from your web app to EDPrintTool instances running at remote locations.
[Web App] → HTTPS → [Cloud Relay] ← WSS ← [EDPrintTool @ Store A]
← WSS ← [EDPrintTool @ Store B]
EDPrintTool connects outbound to the relay — no port forwarding or firewall changes needed.
The relay server lives in relay/. Deploy to any Node.js host (Railway, Render, VPS):
cd relay
npm install
RELAY_ADMIN_KEY=your-secret npm startOr deploy from GitHub — set root directory to relay and add RELAY_ADMIN_KEY as an env var.
curl -X POST https://your-relay.example.com/api/locations \
-H "Content-Type: application/json" \
-H "X-Admin-Key: your-secret" \
-d '{"name": "Store A", "locationId": "store-a"}'Returns { "locationId": "store-a", "apiKey": "generated-key" }.
To persist locations across redeployments (ephemeral filesystems), set the RELAY_LOCATIONS env var:
RELAY_LOCATIONS=store-a:apikey123:Store A,store-b:apikey456:Store B
Create relay.json in the config directory:
- Windows:
%APPDATA%\EDPrintTool\relay.json - macOS:
~/Library/Application Support/EDPrintTool/relay.json - Linux:
~/.edprinttool/relay.json
{
"enabled": true,
"relayUrl": "wss://your-relay.example.com/ws/connect",
"locationId": "store-a",
"apiKey": "generated-key"
}Start EDPrintTool normally (Node.js or Windows exe). It connects to the relay while still serving localhost:8189.
// JavaScript client library (relay mode)
const ep = new EDPrint({
mode: 'relay',
relayUrl: 'https://your-relay.example.com',
locationId: 'store-a',
apiKey: 'generated-key',
});
await ep.print('my-printer', '^XA^FO50,50^ADN,36,20^FDHello^FS^XZ');
// Or plain fetch
fetch('https://your-relay.example.com/api/locations/store-a/print/my-printer', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': 'generated-key' },
body: JSON.stringify({ zpl: '^XA...^XZ', copies: 1 })
});Admin endpoints (require X-Admin-Key header):
| Method | Path | Description |
|---|---|---|
GET |
/api/status |
Relay status, location count, connected count |
POST |
/api/locations |
Register a location |
GET |
/api/locations |
List all locations with online status |
DELETE |
/api/locations/:id |
Remove a location |
Print endpoints (require X-API-Key header):
| Method | Path | Description |
|---|---|---|
GET |
/api/locations/:id/status |
EDPrintTool status at location |
GET |
/api/locations/:id/printers |
List printers at location |
POST |
/api/locations/:id/print/:printerId |
Print ZPL |
POST |
/api/locations/:id/print-escpos/:printerId |
Print ESC/POS |
POST |
/api/locations/:id/print-document/:printerId |
Print PDF |
POST |
/api/locations/:id/print-raw |
Quick print to IP:port |
See relay/README.md for full deployment and configuration docs.
A native Windows app (src-native/) wraps the same functionality in a WinForms desktop application with system tray integration. It uses .NET 8, HttpListener for the HTTP server, and P/Invoke to winspool.drv for direct USB printing.
Build with:
dotnet publish src-native/EDPrintTool/EDPrintTool.csproj -c Release -r win-x64 --self-contained -p:PublishSingleFile=trueMIT