Files
escProxy/parser.js
2026-07-09 10:57:37 +02:00

359 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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