// 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; }