Genesis
This commit is contained in:
102
README.md
Normal file
102
README.md
Normal file
@@ -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-<timestamp>-<seq>.bin` — the exact raw bytes (reprintable).
|
||||||
|
- `job-<timestamp>-<seq>.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
|
||||||
|
```
|
||||||
169
index.js
Normal file
169
index.js
Normal file
@@ -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}`
|
||||||
|
);
|
||||||
|
});
|
||||||
41
package-lock.json
generated
Normal file
41
package-lock.json
generated
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
package.json
Normal file
17
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
126
parse-log.mjs
Normal file
126
parse-log.mjs
Normal file
@@ -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 <file-or-dir> [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 <file-or-dir> [--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();
|
||||||
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;
|
||||||
|
}
|
||||||
42
test-send.mjs
Normal file
42
test-send.mjs
Normal file
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user