Genesis
This commit is contained in:
358
parser.js
Normal file
358
parser.js
Normal file
@@ -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 <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;
|
||||
}
|
||||
Reference in New Issue
Block a user