From 9466d295a4b04e23168d550cb083fdb3c17c3fd2 Mon Sep 17 00:00:00 2001 From: seb Date: Thu, 9 Jul 2026 10:57:37 +0200 Subject: [PATCH] Genesis --- README.md | 102 +++++++++++++ index.js | 169 ++++++++++++++++++++++ package-lock.json | 41 ++++++ package.json | 17 +++ parse-log.mjs | 126 ++++++++++++++++ parser.js | 358 ++++++++++++++++++++++++++++++++++++++++++++++ test-send.mjs | 42 ++++++ 7 files changed, 855 insertions(+) create mode 100644 README.md create mode 100644 index.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 parse-log.mjs create mode 100644 parser.js create mode 100644 test-send.mjs diff --git a/README.md b/README.md new file mode 100644 index 0000000..3b708be --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# escpos-proxy + +Minimal ESC/POS print proxy written as a Node.js ESM app. + +It listens for incoming ESC/POS print jobs on a TCP port, **forwards the raw +bytes straight to a target printer**, and **logs every job to disk** as both a +raw `.bin` file and a decoded `.txt` sidecar. + +A *job* is delimited by either: +- a **cut command** ("bladecut") in the stream — ESC/POS `GS V` = `0x1D 0x56`, or +- a **connection drop** (the client closes / resets the connection). + +No database, websocket, or receipt re-rendering is involved. + +## Usage + +Configuration lives in `.env` (copy `.env.example` to `.env` and edit). It is loaded via [dotenv](https://www.npmjs.com/package/dotenv). + +```bash +cp .env.example .env # first time only +npm start +``` + +### Environment variables (`.env`) + +| Variable | Default | Description | +|------------------|------------------|--------------------------------------| +| `LISTEN_HOST` | `0.0.0.0` | Host the proxy listens on | +| `LISTEN_PORT` | `9100` | Port the proxy listens on | +| `TARGET_HOST` | `10.10.10.24` | Printer to forward raw bytes to | +| `TARGET_PORT` | `9100` | Printer port | +| `LOG_DIR` | `./logs` | Directory for job logs | +| `CUT_PREFIX` | `1d 56` | ESC/POS cut command prefix, hex space-separated (`GS V`) | +| `KEEP_AFTER_CUT` | `true` | Treat bytes after a cut as the next job | + +Real environment variables always take precedence over `.env`. + +## Logs + +For each completed job, two files are written into `LOG_DIR`: + +- `job--.bin` — the exact raw bytes (reprintable). +- `job--.txt` — the same bytes decoded as `latin1` (ASCII + + control characters visible, ESC/POS faithful). + +## Testing + +`test-send.mjs` sends a sample ESC/POS receipt (with a `GS V` cut) to the +listener so you can verify logging and forwarding: + +```bash +# terminal 1 +npm start + +# terminal 2 +npm run test:send +``` + +## Parsing the logs + +`parser.js` is a **lossless** ESC/POS parser. It decomposes the raw byte stream +into the ESC/POS command grammar: each control sequence is recognized by its +opcode with its parsed parameters, and text is kept as raw byte spans (decoded +via the selected code page). Nothing is *interpreted* into higher-level meaning +(no "this is bold", no line/style inference) — it is a faithful tokenization. +Every byte is preserved, and the original stream can be rebuilt via +`serialize(parse(buf))`. + +Token types: +- `{ type: "text", bytes, text }` — raw bytes + code-page decode +- `{ type: "command", bytes, group, name, params }` — control sequence + params +- `{ type: "image", bytes, width, height, data }` — raster bit image (`GS v 0`) + +`parse-log.mjs` is a CLI wrapper. By default it prints the parsed token stream +(opcode + command name + parameters, text spans, images) and verifies the +round-trip `serialize(parse(buf)) === buf` (no bytes lost). + +```bash +# parsed token stream (default) — a faithful decomposition +npm run parse -- logs/job-2026-07-08T21-08-02-523Z-0003.bin + +# parse every .bin in a directory +node parse-log.mjs logs --all + +# also show the raw bytes of each token +node parse-log.mjs logs/job-...bin --hex + +# convenience: decoded receipt text only (drops structure — not lossless) +node parse-log.mjs logs/job-...bin --text + +# also render raster bit images (e.g. QR codes) as ASCII art +node parse-log.mjs logs/job-...bin --image --max-width 60 +``` + +As a module: + +```js +import { parse, serialize, renderText } from "./parser.js"; +const buf = fs.readFileSync("logs/job-xxx.bin"); +const events = parse(buf); // lossless token stream +console.log(serialize(events).equals(buf)); // true — nothing lost +``` \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..5783c3b --- /dev/null +++ b/index.js @@ -0,0 +1,169 @@ +import net from "node:net"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import dotenv from "dotenv"; +import { parse, renderTokens, serialize } from "./parser.js"; + +dotenv.config(); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const num = (v, d) => (v === undefined || v === "" ? d : Number(v)); +const str = (v, d) => (v === undefined || v === "" ? d : v); +const bool = (v, d) => + v === undefined || v === "" ? d : v.toLowerCase() === "true" || v === "1"; + +// Parse CUT_PREFIX (hex, space-separated) into a byte array. +function parseCutPrefix(v, d) { + if (v === undefined || v === "") return d; + return v + .trim() + .split(/\s+/) + .map((h) => parseInt(h, 16)); +} + +const config = { + listenHost: str(process.env.LISTEN_HOST, "0.0.0.0"), + listenPort: num(process.env.LISTEN_PORT, 9100), + targetHost: str(process.env.TARGET_HOST, "10.10.10.24"), + targetPort: num(process.env.TARGET_PORT, 9100), + logDir: str(process.env.LOG_DIR, "./logs"), + forward: bool(process.env.FORWARD, true), + cutPrefix: parseCutPrefix(process.env.CUT_PREFIX, [0x1d, 0x56]), + keepAfterCut: bool(process.env.KEEP_AFTER_CUT, true), +}; + +const logDir = path.resolve(__dirname, config.logDir); +fs.mkdirSync(logDir, { recursive: true }); + +let seq = 0; + +function timestamp() { + // ISO timestamp without colons (filesystem friendly), with ms. + return new Date().toISOString().replace(/[:.]/g, "-"); +} + +function logJob(buffer) { + if (!buffer || buffer.length === 0) return; + const id = `${timestamp()}-${String(++seq).padStart(4, "0")}`; + const parsedPath = path.join(logDir, `job-${id}.log`); + + // Only the parsed, lossless token stream is logged. + let parsedLines = ""; + try { + const events = parse(buffer); + parsedLines = renderTokens(events); + // Sanity: the parser must be lossless (rebuild == original). + const rebuilt = serialize(events); + if (!rebuilt.equals(buffer)) { + throw new Error("parser round-trip mismatch"); + } + } catch (err) { + parsedLines = `[parse error] ${err.message}\n${buffer.toString("latin1")}`; + } + fs.writeFileSync(parsedPath, parsedLines); + + console.log(`[log] job ${id} (${buffer.length} bytes) -> ${parsedPath}`); +} + +// Find the end offset (exclusive) of the first complete cut command in `buf`. +// Returns -1 if no complete cut sequence is present yet. +// A cut is: cutPrefix (0x1D 0x56) followed by 1 mode byte. +function findCutEnd(buf, cutPrefix) { + const n = cutPrefix.length; + let i = 0; + while (i <= buf.length - (n + 1)) { + let match = true; + for (let k = 0; k < n; k++) { + if (buf[i + k] !== cutPrefix[k]) { + match = false; + break; + } + } + if (match) { + // cut + mode byte => end is i + n + 1 + return i + n + 1; + } + i++; + } + return -1; +} + +// Streams received bytes to the target printer (unless forwarding is disabled). +function connectTarget() { + if (!config.forward) return null; + const target = net.createConnection( + { host: config.targetHost, port: config.targetPort }, + () => console.log(`[target] connected to ${config.targetHost}:${config.targetPort}`) + ); + target.on("error", (err) => { + console.error(`[target] error: ${err.message}`); + target.destroy(); + }); + target.on("close", () => console.log("[target] connection closed")); + return target; +} + +const server = net.createServer((clientSocket) => { + const clientAddr = `${clientSocket.remoteAddress}:${clientSocket.remotePort}`; + console.log(`[client] connected ${clientAddr}`); + + let buffer = Buffer.alloc(0); + const target = connectTarget(); + + clientSocket.on("data", (chunk) => { + // Forward raw bytes straight to the printer (if forwarding is enabled). + if (target && target.writable) target.write(chunk); + + buffer = Buffer.concat([buffer, chunk]); + + // Finalize any complete jobs delimited by a cut command. + let cutEnd = findCutEnd(buffer, config.cutPrefix); + while (cutEnd !== -1) { + const job = buffer.subarray(0, cutEnd); + logJob(job); + if (config.keepAfterCut) { + buffer = buffer.subarray(cutEnd); + cutEnd = findCutEnd(buffer, config.cutPrefix); + } else { + buffer = Buffer.alloc(0); + break; + } + } + }); + + const finalize = () => { + // Connection drop: any remaining bytes form the last job. + if (buffer.length > 0) { + logJob(buffer); + buffer = Buffer.alloc(0); + } + if (target) target.end(); + }; + + clientSocket.on("end", () => { + console.log(`[client] end ${clientAddr}`); + finalize(); + }); + + clientSocket.on("close", () => { + console.log(`[client] closed ${clientAddr}`); + finalize(); + }); + + clientSocket.on("error", (err) => { + console.error(`[client] error ${clientAddr}: ${err.message}`); + finalize(); + }); +}); + +server.listen(config.listenPort, config.listenHost, () => { + console.log( + `[server] listening on ${config.listenHost}:${config.listenPort}, ` + + (config.forward + ? `forwarding to ${config.targetHost}:${config.targetPort}, ` + : `forwarding DISABLED (receive + log only), `) + + `logging to ${logDir}` + ); +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f6e94b4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,41 @@ +{ + "name": "escpos-proxy", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "escpos-proxy", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "dotenv": "^17.4.2" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cdbeb7e --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "escpos-proxy", + "version": "1.0.0", + "description": "Minimal ESC/POS print proxy: forwards raw jobs to a target printer and logs each job to disk (cut command or connection drop delimits a job).", + "type": "module", + "main": "index.js", + "scripts": { + "start": "node index.js", + "test:send": "node test-send.mjs", + "parse": "node parse-log.mjs" + }, + "license": "MIT", + "dependencies": { + "chalk": "^5.6.2", + "dotenv": "^17.4.2" + } +} diff --git a/parse-log.mjs b/parse-log.mjs new file mode 100644 index 0000000..c6c8685 --- /dev/null +++ b/parse-log.mjs @@ -0,0 +1,126 @@ +// CLI: parse ESC/POS .bin print jobs logged by the proxy. +// +// This is a *parser*: it decomposes the raw byte stream into the ESC/POS +// command grammar — each control sequence recognized by its opcode with its +// parsed parameters, and text as raw byte spans (decoded via the selected code +// page). Nothing is interpreted into higher-level meaning (no "this is bold", +// no line/style inference). Every byte is preserved and the stream can be +// rebuilt via `serialize()` (verified by default). +// +// Usage: +// node parse-log.mjs [options] +// +// Options: +// --all treat each argument as a directory and parse all *.bin in it +// --hex also print the raw bytes of every token +// --text print only the decoded text spans (convenience view) +// --image render raster bit images as ASCII art (downsampled) +// --max-width n ASCII image width (default 80) +// --no-verify skip the losslessness round-trip check + +import fs from "node:fs"; +import path from "node:path"; +import { parse, serialize, renderText, renderImage, renderTokens, summarize } from "./parser.js"; + +function parseArgs(argv) { + const opts = { files: [], mode: "tokens", hex: false, image: false, maxWidth: 80, all: false, verify: true }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--text") opts.mode = "text"; + else if (a === "--hex") opts.hex = true; + else if (a === "--image") opts.image = true; + else if (a === "--all") opts.all = true; + else if (a === "--no-verify") opts.verify = false; + else if (a === "--max-width") opts.maxWidth = Number(argv[++i]) || 80; + else if (a.startsWith("--")) console.error(`[warn] unknown option ${a}`); + else opts.files.push(a); + } + return opts; +} + +function expandTargets(targets, all) { + const out = []; + for (const t of targets) { + const stat = fs.statSync(t); + if (stat.isDirectory()) { + out.push( + ...fs + .readdirSync(t) + .filter((f) => f.endsWith(".bin")) + .map((f) => path.join(t, f)) + .sort() + ); + } else out.push(t); + } + return out; +} + +function main() { + const opts = parseArgs(process.argv.slice(2)); + if (opts.files.length === 0) { + console.error( + "usage: node parse-log.mjs [--hex|--text] [--image] [--all] [--no-verify]" + ); + process.exit(1); + } + const files = expandTargets(opts.files, opts.all); + let hadError = false; + let allOk = true; + + for (const f of files) { + let buf; + try { + buf = fs.readFileSync(f); + } catch (err) { + console.error(`[error] cannot read ${f}: ${err.message}`); + hadError = true; + continue; + } + const events = parse(buf); + const s = summarize(events); + const name = path.basename(f); + + let ok = true; + if (opts.verify) { + const out = serialize(events); + ok = out.length === buf.length && out.equals(buf); + if (!ok) { + allOk = false; + console.error(`[FAIL] ${name}: round-trip mismatch (${out.length} vs ${buf.length} bytes)`); + } + } + const status = opts.verify ? (ok ? "OK" : "LOSSY") : "unverified"; + + if (opts.mode === "text") { + console.log(`\n===== ${name} (${buf.length} bytes) =====\n`); + console.log(renderText(events)); + continue; + } + + console.log(`\n===== ${name} (${buf.length} bytes) — round-trip: ${status} =====\n`); + + console.log(renderTokens(events, { hex: opts.hex })); + + if (opts.image) { + for (const e of events) { + if (e.type === "image") { + console.log(`\n-- raster image ${e.width}x${e.height} --`); + console.log(renderImage(e, opts.maxWidth)); + } + } + } + + console.log( + `\n[summary] ${s.commands} commands, ${s.images} image(s), ${s.cuts} cut(s), ` + + `${s.text} text runs (${s.textBytes} bytes)` + ); + } + + if (opts.verify && !allOk) { + console.error("\n[error] at least one file was not losslessly reconstructed"); + process.exit(1); + } + process.exit(hadError ? 1 : 0); +} + +main(); diff --git a/parser.js b/parser.js new file mode 100644 index 0000000..e3d4d84 --- /dev/null +++ b/parser.js @@ -0,0 +1,358 @@ +// ESC/POS parser for Epson TM-m30III print jobs. +// +// Design goal: **lossless**. Every input byte is accounted for by exactly one +// token. Each token records its *exact* raw bytes, so the original buffer can +// always be reconstructed byte-for-byte via `serialize()`. No command, control +// byte, or image pixel is ever dropped — only interpreted. +// +// Token types: +// { type: "text", bytes: number[], text: string } +// { type: "command", bytes: number[], group, name, params } +// { type: "image", bytes: number[], widthBytes, height, width, data } +// +// `text` is a *convenience decode* of `bytes` (CP437); the raw `bytes` are the +// source of truth and always preserved. + +const ESC = 0x1b; +const GS = 0x1d; +const FS = 0x1c; + +// Code page 437 (PC437) — the table selected by `ESC t 0` in these jobs. +const CP437_UPPER = [ + "Ç", "ü", "é", "â", "ä", "à", "å", "ç", "ê", "ë", "è", "ï", "î", "ì", "Ä", "Å", + "É", "æ", "Æ", "ô", "ö", "ò", "û", "ù", "ÿ", "Ö", "Ü", "ø", "£", "Ø", "×", "ƒ", + "á", "í", "ó", "ú", "ñ", "Ñ", "ª", "º", "¿", "⌐", "¬", "½", "¼", "¡", "«", "»", + "░", "▒", "▓", "│", "┤", "╡", "╢", "╖", "╕", "╣", "║", "╗", "╝", "╜", "╛", "┐", + "└", "┴", "┬", "├", "─", "┼", "╞", "╟", "╚", "╔", "╩", "╦", "╠", "═", "╬", "╧", + "╨", "╤", "╥", "╙", "╒", "╓", "╫", "╪", "┘", "┌", "┐", "█", "▄", "▌", "▐", "▀", + "α", "ß", "Γ", "π", "Σ", "σ", "µ", "τ", "Φ", "Θ", "Ω", "δ", "∞", "φ", "ε", "∩", + "≡", "±", "≥", "≤", "⌠", "⌡", "÷", "≈", "°", "∙", "·", "√", "ⁿ", "²", "■", " ", +]; + +function cp437Char(b) { + if (b === 0x0a) return "\n"; // LF + if (b === 0x09) return "\t"; // tab + if (b === 0x0d) return "\r"; // CR + if (b < 0x20) return `\\x${b.toString(16).padStart(2, "0")}`; + if (b < 0x7f) return String.fromCharCode(b); + if (b === 0x7f) return "\\x7f"; + return CP437_UPPER[b - 0x80]; +} + +// Map of ESC t (select character code table) values to a decoder. +const CODE_TABLES = { + 0: "PC437", 1: "Katakana", 2: "PC850", 3: "PC860 (Portuguese)", + 4: "PC863 (Canadian)", 5: "PC865 (Nordic)", 16: "WPC1252", + 17: "PC866 (Cyrillic)", 18: "PC852 (Latin2)", 19: "PC858", + 255: "raw", +}; + +function decodeText(bytes, tableNo) { + const name = CODE_TABLES[tableNo] || CODE_TABLES[0]; + let out = ""; + for (const b of bytes) out += cp437Char(b); + return { table: name, text: out }; +} + +// Parameter-byte counts for single-shot ESC/GS/FS commands that have a fixed +// length. Used as a fallback so unknown-but-length-known commands are consumed +// exactly. Anything not listed is treated as a 2-byte header (ESC/GS/FS + fn). +const PARAM_LEN = { + [`${ESC}`]: { + 0x40: 0, 0x74: 1, 0x33: 1, 0x32: 0, 0x61: 1, 0x21: 1, 0x45: 1, 0x2d: 1, + 0x4d: 1, 0x52: 1, 0x64: 1, 0x4a: 1, 0x24: 2, 0x5c: 2, 0x70: 3, 0x69: 0, + 0x4c: 2, 0x57: 4, + }, + [`${GS}`]: { 0x56: 1, 0x21: 1, 0x4c: 2, 0x57: 4, 0x49: 2, 0x4a: 2 }, + [`${FS}`]: { 0x2e: 0, 0x21: 1 }, +}; + +function printMode(n) { + const bits = []; + if (n & 0x01) bits.push("fontB"); + if (n & 0x02) bits.push("bold"); + if (n & 0x04) bits.push("doubleHeight"); + if (n & 0x08) bits.push("doubleWidth"); + if (n & 0x10) bits.push("underline"); + if (n & 0x20) bits.push("white/black"); + if (n & 0x40) bits.push("rotate"); + if (n & 0x80) bits.push("upsideDown"); + return bits.length ? bits.join("+") : "normal"; +} + +const JUSTIFY = { 0: "left", 1: "center", 2: "right" }; +const UNDERLINE = { 0: "off", 1: "single", 2: "double" }; + +// Main entry: parse a Buffer into a lossless token stream. +export function parse(buffer) { + const events = []; + let textRun = []; + let codeTable = 0; + + const flushText = () => { + if (textRun.length === 0) return; + const { table, text } = decodeText(textRun, codeTable); + events.push({ + type: "text", + bytes: [...textRun], + text, + table, + }); + textRun = []; + }; + + const buf = buffer; + let i = 0; + const len = buf.length; + + while (i < len) { + const start = i; + const b = buf[i]; + + if (b === ESC || b === GS || b === FS) { + flushText(); + const group = b === ESC ? "ESC" : b === GS ? "GS" : "FS"; + const fn = buf[i + 1]; + const res = parseCommand(group, fn, buf, i); + if (res) { + const { next, event } = res; + event.bytes = Array.from(buf.subarray(start, next)); + if (event.type === "command" && group === "ESC" && fn === 0x74) + codeTable = event.params.n; + events.push(event); + i = next; + continue; + } + // Unknown control function: emit exactly its 2 header bytes, then let the + // stream continue. (No bytes are dropped — they reappear as later tokens.) + const bytes = [b, fn]; + const total = 2 + (PARAM_LEN[`${b}`]?.[fn] ?? 0); + for (let k = 2; k < Math.min(total, len - i); k++) bytes.push(buf[i + k]); + events.push({ + type: "command", + group, + name: `(unknown 0x${fn.toString(16).padStart(2, "0")})`, + bytes, + params: { consumed: bytes.length }, + }); + i += bytes.length; + continue; + } + + // printable / whitespace / control text byte — all kept. + textRun.push(b); + i++; + } + flushText(); + return events; +} + +// Parse a single command at `pos` (pointing at ESC/GS/FS). Returns +// { next, event } or null if it must fall back to generic handling. +function parseCommand(group, fn, buf, pos) { + const at = (off) => buf[pos + off]; + + // ---- ESC commands ---- + if (group === "ESC") { + switch (fn) { + case 0x40: + return ev(pos, 2, { type: "command", group, name: "Initialize", params: {} }); + case 0x74: + return ev(pos, 3, { type: "command", group, name: "Select code table", params: { n: at(2), table: CODE_TABLES[at(2)] || "?" } }); + case 0x33: + return ev(pos, 3, { type: "command", group, name: "Line spacing", params: { dots: at(2), mm: +(at(2) / 180).toFixed(3) } }); + case 0x32: + return ev(pos, 2, { type: "command", group, name: "Default line spacing", params: {} }); + case 0x61: + return ev(pos, 3, { type: "command", group, name: "Justification", params: { n: at(2), align: JUSTIFY[at(2)] || at(2) } }); + case 0x21: + return ev(pos, 3, { type: "command", group, name: "Print mode", params: { n: at(2), mode: printMode(at(2)) } }); + case 0x45: + return ev(pos, 3, { type: "command", group, name: "Bold", params: { on: at(2) === 1 } }); + case 0x2d: + return ev(pos, 3, { type: "command", group, name: "Underline", params: { n: at(2), mode: UNDERLINE[at(2)] || at(2) } }); + case 0x4d: + return ev(pos, 3, { type: "command", group, name: "Font", params: { n: at(2), font: at(2) === 1 ? "B" : "A" } }); + case 0x52: + return ev(pos, 3, { type: "command", group, name: "Intl character set", params: { n: at(2) } }); + case 0x64: + return ev(pos, 3, { type: "command", group, name: "Print n lines", params: { n: at(2) } }); + case 0x4a: + return ev(pos, 3, { type: "command", group, name: "Feed n dots", params: { n: at(2) } }); + case 0x24: + return ev(pos, 4, { type: "command", group, name: "Absolute position", params: { pos: at(2) + at(3) * 256 } }); + case 0x5c: + return ev(pos, 4, { type: "command", group, name: "Relative position", params: { offset: at(2) + at(3) * 256 } }); + case 0x70: + return ev(pos, 5, { type: "command", group, name: "Pulse", params: { n: at(2), m: at(3), t: at(4) } }); + case 0x69: + return ev(pos, 2, { type: "command", group, name: "Full cut", params: {} }); + case 0x5a: + // ESC Z n1..n5 — 2D symbol store (observed: 01 03 08 0c 01) + return ev(pos, 7, { type: "command", group, name: "2D symbol setup", params: { params: [at(2), at(3), at(4), at(5), at(6)] } }); + default: + return null; + } + } + + // ---- GS commands ---- + if (group === "GS") { + switch (fn) { + case 0x56: + return ev(pos, 3, { type: "command", group, name: "Cut", params: { mode: at(2), kind: cutKind(at(2)) } }); + case 0x76: { + if (at(2) !== 0x30) return null; + const m = at(3); + const w = at(4) + at(5) * 256; // bytes per row + const h = at(6) + at(7) * 256; // rows + const dataLen = w * h; + const data = buf.subarray(pos + 8, pos + 8 + dataLen); + const event = { + type: "image", + widthBytes: w, + height: h, + width: w * 8, + data, + m, + }; + return { next: pos + 8 + dataLen, event }; + } + case 0x21: + return ev(pos, 3, { type: "command", group, name: "Char size", params: { width: (at(2) & 0x0f) + 1, height: ((at(2) >> 4) & 0x0f) + 1 } }); + case 0x28: { + // GS ( k fn ... — variable length container + const pL = at(3), pH = at(4); + const total = 2 + pL + pH * 256; + const fn2 = at(5); + return ev(pos, 2 + total, { type: "command", group, name: "Symbol data (GS ( k)", params: { fn: fn2, len: total } }); + } + case 0x4c: + return ev(pos, 5, { type: "command", group, name: "Left margin", params: { dots: at(3) + at(4) * 256 } }); + case 0x57: + return ev(pos, 7, { type: "command", group, name: "Print area width", params: { width: at(5) + at(6) * 256 } }); + default: + return null; + } + } + + // ---- FS commands ---- + if (group === "FS") { + switch (fn) { + case 0x2e: + return ev(pos, 2, { type: "command", group, name: "Select page mode", params: {} }); + case 0x21: + return ev(pos, 3, { type: "command", group, name: "Char size", params: { n: at(2) } }); + default: + return null; + } + } + + return null; +} + +function cutKind(n) { + return { + 0: "full (feed)", 1: "partial (feed)", 65: "partial (no feed)", + 66: "full (no feed)", + }[n] || `0x${n.toString(16)}`; +} + +// Build a {next, event} from start offset, total consumed length, and event. +function ev(start, total, event) { + return { next: start + total, event }; +} + +// Reconstruct the exact original byte stream from a token list. +// Losslessness check: serialize(parse(buf)) should equal buf. +export function serialize(events) { + const chunks = events.map((e) => Buffer.from(e.bytes)); + return Buffer.concat(chunks); +} + +export function renderText(events) { + return events + .filter((e) => e.type === "text") + .map((e) => e.text) + .join(""); +} + +export function summarize(events) { + const s = { text: 0, textBytes: 0, commands: 0, images: 0, cuts: 0 }; + for (const e of events) { + if (e.type === "text") { s.text++; s.textBytes += e.bytes.length; } + else if (e.type === "image") s.images++; + else if (e.type === "command") { + s.commands++; + if (e.name === "Cut") s.cuts++; + } + } + return s; +} + +// Render the parsed token stream as a readable, lossless listing. Each token is +// one line: offset, opcode, recognized command name, parsed parameters (text +// spans are quoted). `hex` appends the raw bytes. +export function renderTokens(events, { hex: showHex = false } = {}) { + const out = []; + let off = 0; + for (const e of events) { + let line = fmtToken(e, off); + if (showHex) line += ` [${fmtHex(e.bytes).slice(0, 80)}]`; + out.push(line); + off += e.bytes.length; + } + return out.join("\n"); +} + +function fmtHex(bs) { + return bs.map((b) => b.toString(16).padStart(2, "0")).join(" "); +} + +function fmtParams(e) { + const p = e.params || {}; + return Object.entries(p) + .map(([k, v]) => + `${k}=${typeof v === "number" ? (Number.isInteger(v) ? "0x" + v.toString(16) : v) : v}` + ) + .join(" "); +} + +function fmtToken(e, off) { + const offs = off.toString(16).padStart(4, "0"); + if (e.type === "text") { + const t = e.text + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\n/g, "\\n") + .replace(/\t/g, "\\t") + .replace(/\r/g, "\\r") + .slice(0, 200); + return `${offs} TEXT "${t}" (${e.bytes.length}B)`; + } + if (e.type === "image") { + return `${offs} IMAGE ${e.width}x${e.height} ${e.data.length}B`; + } + const fn = e.bytes[1].toString(16).padStart(2, "0"); + const params = fmtParams(e); + return `${offs} ${e.group} ${fn} ${e.name}${params ? " " + params : ""}`.trimEnd(); +} + +// Render a raster bit image as ASCII art (downsampled to fit `maxWidth`). +export function renderImage(image, maxWidth = 80) { + const { widthBytes, height, data } = image; + const w = widthBytes * 8; + const colStep = Math.max(1, Math.round(w / maxWidth)); + const rowStep = colStep; + let out = ""; + for (let y = 0; y < height; y += rowStep) { + let line = ""; + for (let x = 0; x < w; x += colStep) { + const byteIndex = y * widthBytes + (x >> 3); + const bit = 7 - (x & 7); + const on = (data[byteIndex] >> bit) & 1; + line += on ? "#" : " "; + } + out += line + "\n"; + } + return out; +} diff --git a/test-send.mjs b/test-send.mjs new file mode 100644 index 0000000..6e23aa5 --- /dev/null +++ b/test-send.mjs @@ -0,0 +1,42 @@ +// Sends a sample ESC/POS receipt (with a GS V cut) to the proxy listener. +// Useful to verify logging + forwarding. +import net from "node:net"; + +const host = process.env.LISTEN_HOST || "127.0.0.1"; +const port = Number(process.env.LISTEN_PORT || 9100); + +const ESC = 0x1b; +const GS = 0x1d; + +const lines = [ + Buffer.from([ESC, 0x40]), // init + Buffer.from([ESC, 0x61, 0x01]), // center + Buffer.from("KASSENBON\n", "latin1"), + Buffer.from([ESC, 0x61, 0x00]), // left + Buffer.from("Artikel 1 3.50 EUR\n", "latin1"), + Buffer.from("Artikel 2 1.20 EUR\n", "latin1"), + Buffer.from("--------------------------------\n", "latin1"), + Buffer.from("Summe 4.70 EUR\n", "latin1"), + Buffer.from([GS, 0x56, 0x00]), // cut (bladecut) -> job delimiter + // second job in same connection: + Buffer.from([ESC, 0x40]), + Buffer.from("TAGESABSCHLUSS\n", "latin1"), + Buffer.from([GS, 0x56, 0x01]), // another cut +]; + +const client = net.createConnection({ host, port }, () => { + console.log(`[test] connected to ${host}:${port}`); + for (const l of lines) client.write(l); + console.log("[test] sent sample jobs"); + client.end(); +}); + +client.on("error", (err) => { + console.error("[test] error:", err.message); + process.exit(1); +}); + +client.on("close", () => { + console.log("[test] done"); + process.exit(0); +}); \ No newline at end of file