Receipt printers
Receipts for ESC/POS thermal printers: aligned and bold lines, two-column rows, QR codes and barcodes, the cutter and the cash drawer, as base64, a file, or sent to port 9100.
Version 1.0.0 · 4 functions · 1 crate
Functions
What use escpos.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use escpos_receipt, escpos_preview from escpos (Quillo 0.6).
escpos_receipt(lines: list, options: any) → stror an errorA receipt as ESC/POS bytes, in base64. Each line is text, or a map: { text, align: "center", bold, underline, size: 2 (or [w, h], 1 to 8), feed: 2 }, { columns: ["Coffee", "€3.20"] } (left and right on one line), { divider: "-" }, { qr: "https://...", qr_size: 6 }, { barcode: "A-1042" } (CODE128). options are { width: 42, cut: true, drawer: false }: characters per line (32 for 58 mm paper, 42 or 48 for 80 mm), cut the paper at the end, open the cash drawer. Text prints in code page 858 (with €).
escpos_preview(lines: list, options: any) → stror an errorWhat the receipt will look like, as plain text: the same lines and options, aligned in width columns, QR codes and barcodes as [QR ...]. For a preview on screen and for tests.
escpos_write(lines: list, options: any, path: str) → intor an errorWrites the receipt's bytes to path: a USB printer's device (/dev/usb/lp0 on Linux), a shared printer's port, or a file (folders made as needed) to send later. Gives the number of bytes.
escpos_send(lines: list, options: any, host: str, port: int, timeout_ms: int) → intor an errorSends the receipt to a network printer (raw printing, port 9100 on almost all of them), waiting at most timeout_ms (100 to 30,000) to connect. Gives the number of bytes sent.
Get it
$ quillo install escpos # escpos.qwer into this folder$ quillo install escpos --example # and its example, escpos.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/escpos.qwer$ curl -O https://quillolang.org/plugins/files/escpos.qweThen, in your program, all of it or only what you call:
use escpos.qweruse escpos_receipt, escpos_preview from escpos // Quillo 0.6: only theseIt uses this crate, which cargo fetches the first time the plugin is built:
base64 = "0.22"Example
// A café's till: the receipt for an order, previewed on screen, saved for the USB printer, and// sent to the network printer when PRINTER_HOST is set.//// quillo run escpos.qwe then open http://localhost:8080/preview// PRINTER_HOST=192.168.1.50 quillo run escpos.qwe and http://localhost:8080/printuse "escpos.qwer"let ORDER = { id: "A-1042", table: 4, lines: [{ item: "Flat white", qty: 2, price: 3.4 }, { item: "Croissant", qty: 1, price: 2.5 }, { item: "Crème brûlée", qty: 1, price: 6.0 }] }fn euros(x) = `€${format_number(x, "en", 2)}`fn receipt(order) { let total = sum(order.lines | map((l) => l.qty * l.price)) [ { text: "Café Quillo", align: "center", bold: true, size: 2 }, { text: "1 Market Street", align: "center" }, { divider: "=" }, `Order ${order.id}, table ${order.table}`, { divider: "-" }, ] + (order.lines | map((l) => { columns: [`${l.qty} × ${l.item}`, euros(l.qty * l.price)] })) + [ { divider: "-" }, { columns: ["TOTAL", euros(total)], bold: true }, { qr: `https://cafe.example/receipt/${order.id}`, align: "center", feed: 1 }, { text: "Thank you!", align: "center", feed: 3 }, ]}let OPTIONS = { width: 42, cut: true, drawer: false }route GET /preview = respond(200, escpos_preview(receipt(ORDER), OPTIONS)?, { "content-type": "text/plain; charset=utf-8" })route GET /bytes { let b64 = escpos_receipt(receipt(ORDER), OPTIONS)? { base64_start: slice(b64, 0, 8), saved: escpos_write(receipt(ORDER), OPTIONS, "data/receipt-A-1042.bin")? }}route GET /print { let host = env("PRINTER_HOST", null) ?? reply 503 "set PRINTER_HOST to the printer's address" { sent: escpos_send(receipt(ORDER), merge(OPTIONS, { drawer: true }), host, 9100, 3000)? }}$ quillo run escpos.qwe$ curl 'localhost:8080/preview'$ curl 'localhost:8080/bytes'Source
//! Receipts for ESC/POS printers (Epson TM, Star, Xprinter and most thermal printers): lines with alignment, bold, sizes, two-column rows, QR codes, barcodes, the cutter and the cash drawer, as bytes in base64, written to a file or USB device, or sent to a network printer on port 9100.//! version: 1.0.0//!//! [dependencies]//! base64 = "0.22"use base64::Engine;use serde_json::{json, Value};use std::io::Write;const ESC: u8 = 0x1b;const GS: u8 = 0x1d;/// Code page 858 (850 with the euro sign), which most ESC/POS printers have as table 19; characters it lacks print as `?`.fn cp858(c: char) -> u8 { if (c as u32) < 0x80 { return c as u8; } const HIGH: &str = "ÇüéâäàåçêëèïîìÄÅÉæÆôöòûùÿÖÜø£Ø×ƒáíóúñѪº¿®¬½¼¡«»"; if let Some(i) = HIGH.chars().position(|h| h == c) { return 0x80 + i as u8; } match c { 'Á' => 0xb5, 'Â' => 0xb6, 'À' => 0xb7, '©' => 0xb8, '¢' => 0xbd, '¥' => 0xbe, 'ã' => 0xc6, 'Ã' => 0xc7, '¤' => 0xcf, 'ð' => 0xd0, 'Ð' => 0xd1, 'Ê' => 0xd2, 'Ë' => 0xd3, 'È' => 0xd4, '€' => 0xd5, 'Í' => 0xd6, 'Î' => 0xd7, 'Ï' => 0xd8, 'Ì' => 0xde, 'Ó' => 0xe0, 'ß' => 0xe1, 'Ô' => 0xe2, 'Ò' => 0xe3, 'õ' => 0xe4, 'Õ' => 0xe5, 'µ' => 0xe6, 'Ú' => 0xe9, 'Û' => 0xea, 'Ù' => 0xeb, 'ý' => 0xec, 'Ý' => 0xed, '±' => 0xf1, '¾' => 0xf3, '§' => 0xf5, '÷' => 0xf6, '°' => 0xf8, '·' => 0xfa, '–' | '—' => b'-', '‘' | '’' => b'\'', '“' | '”' => b'"', '…' => b'.', _ => b'?', }}fn text(out: &mut Vec<u8>, s: &str) { out.extend(s.chars().filter(|c| *c != '\r' && *c != '\n').map(cp858));}fn width_of(options: &Value) -> usize { options["width"].as_u64().unwrap_or(42).clamp(16, 96) as usize}/// Left text and right text on one line, the gap filled with spaces (or the left cut short).fn columns(left: &str, right: &str, width: usize) -> String { let (l, r) = (left.chars().count(), right.chars().count()); if l + r + 1 > width { let keep = width.saturating_sub(r + 1); format!("{} {}", left.chars().take(keep).collect::<String>(), right) } else { format!("{}{}{}", left, " ".repeat(width - l - r), right) }}/// The printer's bytes, and a plain-text picture of the receipt.fn build(lines: &[Value], options: &Value) -> Result<(Vec<u8>, String), String> { if lines.len() > 5000 { return Err("at most 5,000 lines".into()); } let width = width_of(options); let mut out = vec![ESC, b'@', ESC, b't', 19]; let mut pic = String::new(); for line in lines { let l = match line { Value::String(s) => json!({ "text": s }), Value::Object(_) => line.clone(), other => return Err(format!("a line is text or a map, not {}", other)), }; let align = match l["align"].as_str().unwrap_or("left") { "center" | "centre" => 1, "right" => 2, _ => 0, }; out.extend([ESC, b'a', align]); let (w, h) = match l["size"].as_array() { Some(s) if s.len() == 2 => (s[0].as_u64().unwrap_or(1).clamp(1, 8) as u8, s[1].as_u64().unwrap_or(1).clamp(1, 8) as u8), _ => match l["size"].as_u64() { Some(n) => (n.clamp(1, 8) as u8, n.clamp(1, 8) as u8), None => (1, 1), }, }; let bold = l["bold"] == true; let underline = l["underline"] == true; out.extend([ESC, b'E', bold as u8, ESC, b'-', underline as u8, GS, b'!', ((w - 1) << 4) | (h - 1)]); let place = |s: &str, cols: usize| -> String { let n = s.chars().count(); match align { 1 if n < cols => format!("{}{}", " ".repeat((cols - n) / 2), s), 2 if n < cols => format!("{}{}", " ".repeat(cols - n), s), _ => s.to_string(), } }; let cols = width / w as usize; if let Some(cs) = l["columns"].as_array() { let s = columns(cs.first().and_then(Value::as_str).unwrap_or(""), cs.get(1).and_then(Value::as_str).unwrap_or(""), cols); text(&mut out, &s); out.push(b'\n'); pic.push_str(&s); pic.push('\n'); } else if let Some(d) = l.get("divider") { let ch = d.as_str().and_then(|s| s.chars().next()).unwrap_or('-'); let s: String = std::iter::repeat(ch).take(cols).collect(); text(&mut out, &s); out.push(b'\n'); pic.push_str(&s); pic.push('\n'); } else if let Some(q) = l["qr"].as_str() { let data = q.as_bytes(); if data.len() > 7000 { return Err("a QR code holds at most 7,000 bytes".into()); } let module = l["qr_size"].as_u64().unwrap_or(6).clamp(1, 16) as u8; let n = data.len() + 3; out.extend([GS, b'(', b'k', 4, 0, 49, 65, 50, 0]); out.extend([GS, b'(', b'k', 3, 0, 49, 67, module]); out.extend([GS, b'(', b'k', 3, 0, 49, 69, 49]); out.extend([GS, b'(', b'k', (n % 256) as u8, (n / 256) as u8, 49, 80, 48]); out.extend_from_slice(data); out.extend([GS, b'(', b'k', 3, 0, 49, 81, 48, b'\n']); pic.push_str(&place(&format!("[QR {}]", q), width)); pic.push('\n'); } else if let Some(b) = l["barcode"].as_str() { if b.is_empty() || b.len() > 250 || !b.chars().all(|c| (' '..='~').contains(&c)) { return Err("a barcode is 1 to 250 printable ASCII characters (CODE128)".into()); } out.extend([GS, b'h', 80, GS, b'w', 2, GS, b'H', 2, GS, b'k', 73, (b.len() + 2) as u8, b'{', b'B']); out.extend_from_slice(b.as_bytes()); out.push(b'\n'); pic.push_str(&place(&format!("[BARCODE {}]", b), width)); pic.push('\n'); } else { let s = l["text"].as_str().map(str::to_string).unwrap_or_else(|| if l["text"].is_null() { String::new() } else { l["text"].to_string() }); text(&mut out, &s); out.push(b'\n'); // Wide text is placed as the printer places it, each character taking `w` columns. let shown = s; let n = shown.chars().count() * w as usize; let pad = match align { 1 if n < width => (width - n) / 2, 2 if n < width => width - n, _ => 0, }; pic.push_str(&format!("{}{}", " ".repeat(pad), shown)); pic.push('\n'); } if let Some(f) = l["feed"].as_u64() { out.extend([ESC, b'd', f.min(255) as u8]); pic.push_str(&"\n".repeat(f.min(20) as usize)); } } out.extend([ESC, b'E', 0, ESC, b'-', 0, GS, b'!', 0, ESC, b'a', 0]); if options["drawer"] == true { out.extend([ESC, b'p', 0, 25, 250]); } if options["cut"] != false { out.extend([GS, b'V', 66, 3]); pic.push_str(&"✂".repeat(width.min(8))); pic.push('\n'); } Ok((out, pic))}/// A receipt as ESC/POS bytes, in base64. Each line is text, or a map: `{ text, align: "center", bold, underline, size: 2 (or [w, h], 1 to 8), feed: 2 }`,/// `{ columns: ["Coffee", "€3.20"] }` (left and right on one line), `{ divider: "-" }`, `{ qr: "https://...", qr_size: 6 }`, `{ barcode: "A-1042" }` (CODE128)./// `options` are `{ width: 42, cut: true, drawer: false }`: characters per line (32 for 58 mm paper, 42 or 48 for 80 mm), cut the paper at the end, open the cash drawer. Text prints in code page 858 (with €).pub fn escpos_receipt(lines: Vec<serde_json::Value>, options: serde_json::Value) -> Result<String, String> { Ok(base64::engine::general_purpose::STANDARD.encode(build(&lines, &options)?.0))}/// What the receipt will look like, as plain text: the same lines and options, aligned in `width` columns, QR codes and barcodes as `[QR ...]`. For a preview on screen and for tests.pub fn escpos_preview(lines: Vec<serde_json::Value>, options: serde_json::Value) -> Result<String, String> { Ok(build(&lines, &options)?.1)}/// Writes the receipt's bytes to `path`: a USB printer's device (`/dev/usb/lp0` on Linux), a shared printer's port, or a file (folders made as needed) to send later. Gives the number of bytes.pub fn escpos_write(lines: Vec<serde_json::Value>, options: serde_json::Value, path: &str) -> Result<i64, String> { let (bytes, _) = build(&lines, &options)?; let p = std::path::Path::new(path); let is_device = path.starts_with("/dev/"); if !is_device { if let Some(dir) = p.parent() { if !dir.as_os_str().is_empty() { std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; } } } let mut f = std::fs::OpenOptions::new().write(true).create(!is_device).truncate(!is_device).open(p).map_err(|e| format!("{}: {}", path, e))?; f.write_all(&bytes).map_err(|e| format!("{}: {}", path, e))?; Ok(bytes.len() as i64)}/// Sends the receipt to a network printer (raw printing, `port` 9100 on almost all of them), waiting at most `timeout_ms` (100 to 30,000) to connect. Gives the number of bytes sent.pub fn escpos_send(lines: Vec<serde_json::Value>, options: serde_json::Value, host: &str, port: i64, timeout_ms: i64) -> Result<i64, String> { use std::net::ToSocketAddrs; let (bytes, _) = build(&lines, &options)?; let t = std::time::Duration::from_millis(timeout_ms.clamp(100, 30_000) as u64); let port = u16::try_from(port).map_err(|_| format!("{} is not a port", port))?; let addr = (host, port).to_socket_addrs().map_err(|e| format!("{}: {}", host, e))?.next().ok_or(format!("{} has no address", host))?; let mut s = std::net::TcpStream::connect_timeout(&addr, t).map_err(|e| format!("cannot reach the printer at {}:{}: {}", host, port, e))?; s.set_write_timeout(Some(t)).map_err(|e| e.to_string())?; s.write_all(&bytes).map_err(|e| e.to_string())?; s.flush().map_err(|e| e.to_string())?; Ok(bytes.len() as i64)}Related
dither.qwer · 1.0.0Pictures dithered to a few colours: black and white for thermal receipt and label printers, the 3 and 7 colours of e-ink, or any palette, by Floyd–Steinberg, Atkinson, Stucki, Bayer or threshold; and 1-bit rows packed for printers.7 views · 9 downloadsG-code for 3D printers gcode.qwer · 1.0.0G-code from 3D printers and CNC machines read: print time with acceleration, filament length, weight and cost, the print's size, layers and their heights, temperatures and the slicer's notes, and a layer drawn as SVG.6 views · 7 downloadsModbus frames modbus.qwer · 1.0.0Modbus RTU and TCP frames built and read without a device: register and coil requests, answers and exceptions explained, CRC-16 and MBAP headers, registers turned into integers, floats and text in any word order and back, and a TCP client for a real PLC or meter.8 views · 10 downloadsNMEA GPS nmea.qwer · 1.0.0NMEA 0183 sentences from GPS receivers read: position, time, speed, course, altitude, satellites and fix quality, with checksums checked.7 views · 8 downloadsOBD-II obd2.qwer · 1.0.0OBD-II answers from an ELM327 adapter decoded: speed, RPM, temperatures, load, fuel level and voltage, trouble codes such as P0133, and the PIDs a car supports.8 views · 8 downloadsRFID EPC tags epc.qwer · 1.0.0RFID tags' EPC numbers by GS1's Tag Data Standard: SGTIN-96 from a GTIN and serial, SSCC-96, SGLN-96, GRAI-96 and GIAI-96 encoded to the hex a tag holds, decoded back with their URIs and the GTIN, SSCC or GLN inside, and a reader's inventory.6 views · 8 downloadsThread 0 posts
On Receipt printers: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.