render logs into images

This commit is contained in:
seb
2026-07-11 05:41:17 +02:00
parent 9466d295a4
commit a46e2705a4
12 changed files with 1366 additions and 249 deletions

23
.env.example Normal file
View File

@@ -0,0 +1,23 @@
# ESC/POS print proxy configuration
# Host/port the proxy listens on for incoming print jobs
LISTEN_HOST=0.0.0.0
LISTEN_PORT=9100
# Target printer the raw bytes are forwarded to
TARGET_HOST=10.10.10.24
TARGET_PORT=9100
# Directory where job logs are written
LOG_DIR=./logs
# ESC/POS cut command prefix (hex, space-separated). Default GS V = 1d 56
# A job is delimited by this prefix followed by one mode byte.
CUT_PREFIX=1d 56
# Keep bytes arriving after a cut as the start of the next job (true/false)
KEEP_AFTER_CUT=true
# Forward received bytes to the target printer (true/false).
# Set to false to only receive + log jobs without sending them anywhere.
FORWARD=true

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
logs/
*.log
.env

View File

@@ -2,15 +2,18 @@
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.
It listens for incoming ESC/POS print jobs on a TCP port, **forwards the raw bytes straight to a target printer**, and **automatically parses and renders every job** to disk as both a structured `.log` file and a high-fidelity `.png` receipt image.
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.
## Features
- **Transparent Proxying**: Forwards bytes losslessly to a physical ESC/POS printer.
- **Advanced Parsing**: Decomposes the raw byte stream into ESC/POS commands, tracking formatting states (bold, underline, double-height, alignment).
- **Native PNG Rendering**: Automatically generates a 1-bit style grayscale PNG image of the exact receipt that was printed.
- **Embedded Graphics**: Supports rendering custom embedded `IMAGE` payloads and generates actual QR codes from `ESC Z` (2D symbol setup) commands.
## Usage
@@ -18,6 +21,7 @@ Configuration lives in `.env` (copy `.env.example` to `.env` and edit). It is lo
```bash
cp .env.example .env # first time only
npm install
npm start
```
@@ -35,18 +39,16 @@ npm start
Real environment variables always take precedence over `.env`.
## Logs
## Logs and Output
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).
- `job-<timestamp>-<seq>.log`The parsed, human-readable token stream containing command opcodes, parameters, and decoded text.
- `job-<timestamp>-<seq>.png`A rendered visual receipt using the exact fonts (Font A / Font B) and layout applied by the software.
## 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:
The `test-send.mjs` script sends a sample ESC/POS receipt (with a `GS V` cut) to the listener so you can verify logging, forwarding, and PNG generation:
```bash
# terminal 1
@@ -56,47 +58,10 @@ npm start
npm run test:send
```
## Parsing the logs
## Architecture
`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
```
The source code resides in the `src/` directory:
- `server.js` — The core TCP proxy server and chunk delimiter.
- `parser.js` — The ESC/POS grammar parser.
- `renderer.js` — The PNG generator leveraging the `sharp` and `qrcode` libraries to draw the parsed receipt instructions to an image buffer.
- `font-12x24.js` / `font-9x16.js` — Exported native JS arrays containing the dot-matrix character maps used by the renderer.

945
package-lock.json generated
View File

@@ -9,22 +9,655 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"chalk": "^5.6.2",
"dotenv": "^17.4.2"
"dotenv": "^17.4.2",
"qrcode": "^1.5.4",
"sharp": "^0.35.3"
}
},
"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==",
"node_modules/@emnapi/runtime": {
"version": "1.11.2",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz",
"integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz",
"integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.2"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz",
"integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz",
"integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz",
"integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz",
"integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz",
"integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz",
"integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz",
"integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz",
"integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz",
"integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz",
"integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz",
"integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz",
"integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.2"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz",
"integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz",
"integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz",
"integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.2"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz",
"integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.2"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz",
"integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz",
"integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz",
"integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.2"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz",
"integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.1"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz",
"integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz",
"integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz",
"integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz",
"integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
@@ -36,6 +669,302 @@
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/sharp": {
"version": "0.35.3",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz",
"integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==",
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
"semver": "^7.8.5"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.3",
"@img/sharp-darwin-x64": "0.35.3",
"@img/sharp-freebsd-wasm32": "0.35.3",
"@img/sharp-libvips-darwin-arm64": "1.3.2",
"@img/sharp-libvips-darwin-x64": "1.3.2",
"@img/sharp-libvips-linux-arm": "1.3.2",
"@img/sharp-libvips-linux-arm64": "1.3.2",
"@img/sharp-libvips-linux-ppc64": "1.3.2",
"@img/sharp-libvips-linux-riscv64": "1.3.2",
"@img/sharp-libvips-linux-s390x": "1.3.2",
"@img/sharp-libvips-linux-x64": "1.3.2",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.2",
"@img/sharp-libvips-linuxmusl-x64": "1.3.2",
"@img/sharp-linux-arm": "0.35.3",
"@img/sharp-linux-arm64": "0.35.3",
"@img/sharp-linux-ppc64": "0.35.3",
"@img/sharp-linux-riscv64": "0.35.3",
"@img/sharp-linux-s390x": "0.35.3",
"@img/sharp-linux-x64": "0.35.3",
"@img/sharp-linuxmusl-arm64": "0.35.3",
"@img/sharp-linuxmusl-x64": "0.35.3",
"@img/sharp-webcontainers-wasm32": "0.35.3",
"@img/sharp-win32-arm64": "0.35.3",
"@img/sharp-win32-ia32": "0.35.3",
"@img/sharp-win32-x64": "0.35.3"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
}
}
}

View File

@@ -3,15 +3,15 @@
"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",
"main": "src/server.js",
"scripts": {
"start": "node index.js",
"test:send": "node test-send.mjs",
"parse": "node parse-log.mjs"
"start": "node src/server.js",
"test:send": "node test-send.mjs"
},
"license": "MIT",
"dependencies": {
"chalk": "^5.6.2",
"dotenv": "^17.4.2"
"dotenv": "^17.4.2",
"qrcode": "^1.5.4",
"sharp": "^0.35.3"
}
}

View File

@@ -1,126 +0,0 @@
// 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();

3
src/font-12x24.js Normal file

File diff suppressed because one or more lines are too long

3
src/font-9x16.js Normal file

File diff suppressed because one or more lines are too long

View File

@@ -70,13 +70,10 @@ const PARAM_LEN = {
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");
if (n & 0x08) bits.push("bold");
if (n & 0x10) bits.push("doubleHeight");
if (n & 0x20) bits.push("doubleWidth");
if (n & 0x80) bits.push("underline");
return bits.length ? bits.join("+") : "normal";
}
@@ -224,7 +221,8 @@ function parseCommand(group, fn, buf, pos) {
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 } });
const payload = buf.subarray(pos + 6, pos + 2 + total);
return ev(pos, 2 + total, { type: "command", group, name: "Symbol data (GS ( k)", params: { fn: fn2, len: total, data: Buffer.from(payload).toString('base64') } });
}
case 0x4c:
return ev(pos, 5, { type: "command", group, name: "Left margin", params: { dots: at(3) + at(4) * 256 } });
@@ -297,7 +295,7 @@ export function renderTokens(events, { hex: showHex = false } = {}) {
let off = 0;
for (const e of events) {
let line = fmtToken(e, off);
if (showHex) line += ` [${fmtHex(e.bytes).slice(0, 80)}]`;
if (showHex) line += ` [${fmtHex(e.bytes)}]`;
out.push(line);
off += e.bytes.length;
}
@@ -325,12 +323,11 @@ function fmtToken(e, off) {
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n")
.replace(/\t/g, "\\t")
.replace(/\r/g, "\\r")
.slice(0, 200);
.replace(/\r/g, "\\r");
return `${offs} TEXT "${t}" (${e.bytes.length}B)`;
}
if (e.type === "image") {
return `${offs} IMAGE ${e.width}x${e.height} ${e.data.length}B`;
return `${offs} IMAGE ${e.width}x${e.height} ${e.data.length}B data=${Buffer.from(e.data).toString('base64')}`;
}
const fn = e.bytes[1].toString(16).padStart(2, "0");
const params = fmtParams(e);

353
src/renderer.js Normal file
View File

@@ -0,0 +1,353 @@
import fs from 'fs';
import sharp from 'sharp';
import { fileURLToPath } from 'url';
import path from 'path';
import QRCode from 'qrcode';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
import { fontAData } from './font-12x24.js';
import { fontBData } from './font-9x16.js';
export function renderReceipt(inputFile, outputFile) {
const text = fs.readFileSync(inputFile, 'utf-8');
const logLines = text.split('\n');
const MAX_DOTS = 576; // Standard 80mm printer width
const PADDING = 20;
let align = 'left';
let bold = false;
let underline = false;
let font = 'A'; // 'A' or 'B'
let doubleHeight = false;
let doubleWidth = false;
// Parse text into a structured list of lines
let allLines = [];
let currentLineSegments = [];
let expectingQRCode = false;
let qrScale = 8;
for (const line of logLines) {
let willChangeAlign = null;
let willChangeFont = null;
let willChangeDoubleHeight = null;
let willChangeDoubleWidth = null;
if (line.includes('align=')) {
if (line.includes('align=center') && align !== 'center') willChangeAlign = 'center';
else if (line.includes('align=left') && align !== 'left') willChangeAlign = 'left';
else if (line.includes('align=right') && align !== 'right') willChangeAlign = 'right';
}
else if (line.includes('Print mode')) {
const modeIndex = line.indexOf('mode=');
if (modeIndex !== -1) {
const modeStr = line.substring(modeIndex + 5).trim();
// ESC ! bitmask only turns off its own internal bits, it doesn't override ESC E or ESC -
const escBangBold = modeStr.includes('bold');
const escBangUnderline = modeStr.includes('underline');
// We assume explicit 'Bold on=true' and 'Underline mode=single' handled elsewhere
// but we must check if the combined state changes.
const newDoubleHeight = modeStr.includes('doubleHeight');
const newDoubleWidth = modeStr.includes('doubleWidth');
const newFont = modeStr.includes('fontB') ? 'B' : 'A';
if (newFont !== font) willChangeFont = newFont;
if (newDoubleHeight !== doubleHeight) willChangeDoubleHeight = newDoubleHeight;
if (newDoubleWidth !== doubleWidth) willChangeDoubleWidth = newDoubleWidth;
// If ESC ! specifically turns ON bold/underline, we enable it.
// If it turns it off, we only turn it off if ESC E / ESC - isn't forcing it on.
// However, without full tracking of both states, the simplest fix is to only
// allow Print Mode to ENABLE bold/underline, or to just OR them.
// Wait, if Print mode is normal, it might mean they want to clear it?
// Actually, let's just track the 'Print mode' specific state if needed.
// For now, if the printer sets Bold explicitly via ESC E, we should NEVER clear it via ESC !
if (escBangBold) bold = true;
if (escBangUnderline) underline = true;
// If mode is 'normal', it shouldn't clear bold if ESC E is true.
// So we do not set bold=false here. It relies on ESC E to turn it off.
}
}
else if (line.includes('Underline')) {
const modeIndex = line.indexOf('mode=');
if (modeIndex !== -1) underline = !line.includes('mode=off');
}
else if (line.includes('Bold on=')) {
bold = line.includes('on=true');
}
else if (line.includes('Font n=0x1 font=B') && font !== 'B') willChangeFont = 'B';
else if (line.includes('Font n=0x0 font=A') && font !== 'A') willChangeFont = 'A';
const shouldFlush = (willChangeAlign !== null) || (willChangeFont !== null) || (willChangeDoubleHeight !== null) || (willChangeDoubleWidth !== null);
if (shouldFlush && currentLineSegments.length > 0) {
allLines.push({ segments: currentLineSegments, align, emptyFont: font, emptyDoubleHeight: doubleHeight });
currentLineSegments = [];
}
if (willChangeAlign !== null) align = willChangeAlign;
if (willChangeFont !== null) font = willChangeFont;
if (willChangeDoubleHeight !== null) doubleHeight = willChangeDoubleHeight;
if (willChangeDoubleWidth !== null) doubleWidth = willChangeDoubleWidth;
if (line.includes('2D symbol setup')) {
expectingQRCode = true;
const paramsMatch = line.match(/params=\d+,\d+,(\d+)/);
if (paramsMatch && paramsMatch[1]) {
const rawScale = parseInt(paramsMatch[1], 10);
qrScale = Math.max(1, Math.floor(rawScale / 2)); // Halved to fix sizing
}
}
if (line.includes('IMAGE')) {
const match = line.match(/IMAGE\s+(\d+)x(\d+)\s+\d+B\s+data=(.*)/);
if (match) {
if (currentLineSegments.length > 0) {
allLines.push({ segments: currentLineSegments, align, emptyFont: font, emptyDoubleHeight: doubleHeight });
currentLineSegments = [];
}
const width = parseInt(match[1], 10);
const height = parseInt(match[2], 10);
const base64Data = match[3];
const buffer = Buffer.from(base64Data, 'base64');
allLines.push({ type: 'image', width, height, data: buffer, align });
}
}
if (line.includes('TEXT')) {
const firstQuote = line.indexOf('"');
const lastQuote = line.lastIndexOf('"');
if (firstQuote !== -1 && lastQuote !== -1 && lastQuote > firstQuote) {
let str = line.substring(firstQuote + 1, lastQuote);
str = str.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
if (expectingQRCode) {
allLines.push({ type: 'qrcode', data: str, align, scale: qrScale });
expectingQRCode = false;
continue;
}
let currentText = '';
for (let i = 0; i < str.length; i++) {
if (str[i] === '\n') {
if (currentText.length > 0) {
currentLineSegments.push({ text: currentText, bold, underline, font, doubleHeight, doubleWidth });
currentText = '';
}
allLines.push({ segments: currentLineSegments, align, emptyFont: font, emptyDoubleHeight: doubleHeight });
currentLineSegments = [];
} else {
currentText += str[i];
}
}
if (currentText.length > 0) {
currentLineSegments.push({ text: currentText, bold, underline, font, doubleHeight, doubleWidth });
}
}
}
}
if (currentLineSegments.length > 0) {
allLines.push({ segments: currentLineSegments, align, emptyFont: font, emptyDoubleHeight: doubleHeight });
}
// Compute total height by calculating height per line
let totalHeight = PADDING * 2;
for (const line of allLines) {
if (line.type === 'qrcode') {
try {
const qr = QRCode.create(line.data, { errorCorrectionLevel: 'M' });
line.qrSize = qr.modules.size;
line.qrPixels = qr.modules.data;
line.height = line.qrSize * line.scale + 40; // 40px top margin
} catch (err) {
console.error("Failed to generate QR code:", err.message);
line.height = 0;
}
totalHeight += line.height;
continue;
} else if (line.type === 'image') {
totalHeight += line.height;
continue;
}
let maxH = 0;
if (line.segments.length === 0) {
maxH = (line.emptyFont === 'B' ? 16 : 24) * (line.emptyDoubleHeight ? 2 : 1);
} else {
for (const seg of line.segments) {
const h = (seg.font === 'B' ? 16 : 24) * (seg.doubleHeight ? 2 : 1);
if (h > maxH) maxH = h;
}
}
line.height = maxH;
totalHeight += maxH;
}
// Create image buffer
const width = MAX_DOTS + PADDING * 2;
const height = totalHeight;
const buffer = Buffer.alloc(width * height, 255); // 1 channel grayscale, white
function setPixel(x, y, color) {
if (x < 0 || x >= width || y < 0 || y >= height) return;
buffer[y * width + x] = color;
}
let cursorY = PADDING;
for (const line of allLines) {
if (line.type === 'qrcode') {
if (!line.qrPixels) {
cursorY += line.height;
continue;
}
const qrWidthDots = line.qrSize * line.scale;
let startX = PADDING;
if (line.align === 'center') {
startX = PADDING + Math.floor((MAX_DOTS - qrWidthDots) / 2);
} else if (line.align === 'right') {
startX = PADDING + MAX_DOTS - qrWidthDots;
}
const startY = cursorY + 40;
for (let r = 0; r < line.qrSize; r++) {
for (let c = 0; c < line.qrSize; c++) {
const isDark = line.qrPixels[r * line.qrSize + c];
if (isDark) {
for (let dy = 0; dy < line.scale; dy++) {
for (let dx = 0; dx < line.scale; dx++) {
setPixel(startX + c * line.scale + dx, startY + r * line.scale + dy, 0);
}
}
}
}
}
cursorY += line.height;
continue;
} else if (line.type === 'image') {
let startX = PADDING;
if (line.align === 'center') {
startX = PADDING + Math.floor((MAX_DOTS - line.width) / 2);
} else if (line.align === 'right') {
startX = PADDING + MAX_DOTS - line.width;
}
const widthBytes = Math.floor(line.width / 8);
for (let y = 0; y < line.height; y++) {
for (let x = 0; x < line.width; x++) {
const byteIndex = y * widthBytes + (x >> 3);
const bit = 7 - (x & 7);
const isDark = (line.data[byteIndex] >> bit) & 1;
if (isDark) {
setPixel(startX + x, cursorY + y, 0);
}
}
}
cursorY += line.height;
continue;
}
let lineWidth = 0;
for (const seg of line.segments) {
const baseCharWidth = seg.font === 'B' ? 9 : 12;
const charWidth = seg.doubleWidth ? baseCharWidth * 2 : baseCharWidth;
lineWidth += seg.text.length * charWidth;
}
let startX = PADDING;
if (line.align === 'center') {
startX = PADDING + Math.floor((MAX_DOTS - lineWidth) / 2);
} else if (line.align === 'right') {
startX = PADDING + MAX_DOTS - lineWidth;
}
// Font B lines often visually misalign on the left edge due to glyph differences
if (line.align === 'left' && line.segments.length > 0 && line.segments[0].font === 'B') {
startX += 2;
}
let x = startX;
for (const seg of line.segments) {
const isFontB = seg.font === 'B';
const baseCharWidth = isFontB ? 9 : 12;
const baseCharHeight = isFontB ? 16 : 24;
const charWidth = seg.doubleWidth ? baseCharWidth * 2 : baseCharWidth;
const charHeight = seg.doubleHeight ? baseCharHeight * 2 : baseCharHeight;
const fontData = isFontB ? fontBData : fontAData;
const bytesPerChar = isFontB ? 32 : 48; // 16 rows * 2 vs 24 rows * 2
// Baseline align text to the bottom of the line height
const yOffset = line.height - charHeight;
for (let i = 0; i < seg.text.length; i++) {
const charCode = seg.text.charCodeAt(i);
let targetCode = charCode;
if (targetCode > 255) targetCode = 63; // Map unsupported to '?'
const charOffset = targetCode * bytesPerChar;
if (charOffset + (bytesPerChar - 1) < fontData.length) {
for (let r = 0; r < baseCharHeight; r++) {
const rowByte1 = fontData[charOffset + r * 2];
const rowByte2 = fontData[charOffset + r * 2 + 1];
const row16 = (rowByte1 << 8) | rowByte2;
for (let c = 0; c < baseCharWidth; c++) {
const bit = (row16 >> (15 - c)) & 1;
if (bit) {
const px = x + (seg.doubleWidth ? c * 2 : c);
const py = cursorY + yOffset + (seg.doubleHeight ? r * 2 : r);
setPixel(px, py, 0);
if (seg.bold) setPixel(px + 1, py, 0);
if (seg.doubleWidth) {
setPixel(px + 1, py, 0);
if (seg.bold) setPixel(px + 2, py, 0);
}
if (seg.doubleHeight) {
setPixel(px, py + 1, 0);
if (seg.bold) setPixel(px + 1, py + 1, 0);
if (seg.doubleWidth) {
setPixel(px + 1, py + 1, 0);
if (seg.bold) setPixel(px + 2, py + 1, 0);
}
}
}
}
}
}
if (seg.underline) {
// Underline at the bottom of the character
for (let c = 0; c < charWidth; c++) {
setPixel(x + c, cursorY + line.height - 1, 0);
setPixel(x + c, cursorY + line.height - 2, 0); // double thickness looks better
}
}
x += charWidth;
}
}
cursorY += line.height;
}
sharp(buffer, {
raw: {
width,
height,
channels: 1
}
})
.png()
.toFile(outputFile)
.catch(err => console.error("Sharp error:", err));
}

View File

@@ -4,6 +4,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import dotenv from "dotenv";
import { parse, renderTokens, serialize } from "./parser.js";
import { renderReceipt } from "./renderer.js";
dotenv.config();
@@ -34,7 +35,7 @@ const config = {
keepAfterCut: bool(process.env.KEEP_AFTER_CUT, true),
};
const logDir = path.resolve(__dirname, config.logDir);
const logDir = path.resolve(__dirname, '..', config.logDir);
fs.mkdirSync(logDir, { recursive: true });
let seq = 0;
@@ -64,7 +65,14 @@ function logJob(buffer) {
}
fs.writeFileSync(parsedPath, parsedLines);
console.log(`[log] job ${id} (${buffer.length} bytes) -> ${parsedPath}`);
const pngPath = path.join(logDir, `job-${id}.png`);
try {
renderReceipt(parsedPath, pngPath);
console.log(`[log] job ${id} (${buffer.length} bytes) -> ${parsedPath} & ${pngPath}`);
} catch (err) {
console.error(`[error] failed to render PNG for job ${id}: ${err.message}`);
console.log(`[log] job ${id} (${buffer.length} bytes) -> ${parsedPath}`);
}
}
// Find the end offset (exclusive) of the first complete cut command in `buf`.

View File

@@ -1,42 +0,0 @@
// 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);
});