From e3ca32d5469715ce44c0088480c8be05fcceb21b Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 21 May 2026 17:53:51 +0200 Subject: [PATCH 1/9] feat(parser): accept :B64: wrapper on ^GFA/^GFB/^GFC graphic fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enterprise-Systeme emittieren GF-Payloads üblicherweise nicht als raw hex oder raw binary, sondern als :B64::-Wrapper. Bisher landeten ^GFB/^GFC unbedingt im browserLimit und ^GFA mit Wrapper im decompressGFA-Pfad, der dort gar nicht greift. Neue Pipeline: parseGfWrapper sniffe das :B64:/:Z64:-Format, validiere CRC-16/CCITT-FALSE, dekodiere Base64 zu Bytes, normalisiere zu Hex und reiche die in den bestehenden Pixel-Pfad weiter. CRC- Mismatch wird gerendert (Zebra tolerieren das auch) aber als partial gemeldet. :Z64: bleibt erstmal browserLimit, weil sync zlib eine zusätzliche Dep oder einen async-Refactor bräuchte. --- src/lib/zplParser.test.ts | 44 ++++++++++++++++ src/lib/zplParser.ts | 105 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 144 insertions(+), 5 deletions(-) diff --git a/src/lib/zplParser.test.ts b/src/lib/zplParser.test.ts index ecfd281f..bb20d220 100644 --- a/src/lib/zplParser.test.ts +++ b/src/lib/zplParser.test.ts @@ -543,6 +543,50 @@ describe('parseZPL — ^GFA graphic field', () => { expect(props(objects[0])._gfaCache).toContain('^GFA,'); }); + it('imports a :B64:-wrapped ^GFA payload as an image (CRC valid)', () => { + // 8 bytes = [0,0,0,0xFF,0xFF,0,0,0] → base64 "AAAA//8AAAA=" + // CRC-16/CCITT-FALSE over "AAAA//8AAAA=" = 0xDFF8 + const { objects, importReport } = parseZPL( + '^XA^FO0,0^GFA,8,8,1,:B64:AAAA//8AAAA=:DFF8^FS^XZ', + 8, + ); + expect(objects).toHaveLength(1); + expect(objects[0]?.type).toBe('image'); + expect(props(objects[0]).widthDots).toBe(8); + expect(importReport.partial).not.toContain('^GF'); + }); + + it('still renders a :B64: payload with mismatched CRC but flags as partial', () => { + const { objects, importReport } = parseZPL( + '^XA^FO0,0^GFA,8,8,1,:B64:AAAA//8AAAA=:0000^FS^XZ', + 8, + ); + expect(objects).toHaveLength(1); + expect(importReport.partial).toContain('^GF'); + }); + + it('accepts :B64: wrapper on ^GFB and ^GFC (no raw-binary path needed)', () => { + for (const fmt of ['B', 'C'] as const) { + const { objects } = parseZPL( + `^XA^FO0,0^GF${fmt},8,8,1,:B64:AAAA//8AAAA=:DFF8^FS^XZ`, + 8, + ); + expect(objects).toHaveLength(1); + expect(objects[0]?.type).toBe('image'); + } + }); + + it('records :Z64: payload as browserLimit (zlib not yet supported)', () => { + // Arbitrary :Z64: payload — content doesn't matter, only the prefix. + const { objects, importReport } = parseZPL( + '^XA^FO0,0^GFC,8,8,1,:Z64:eJxjYGD4/5+BgQEACP8B/w==:95F4^FS^XZ', + 8, + ); + expect(objects).toHaveLength(0); + expect(importReport.browserLimit.some((s) => s.startsWith('^GF'))).toBe(true); + expect(importReport.partial).not.toContain('^GF'); + }); + it('creates an image object from compressed ^GFA data', () => { // G=1 repeat → "GF" = repeat 'F' once, basically just 'F' // bytesPerRow=1, so we need 2 nibbles per row diff --git a/src/lib/zplParser.ts b/src/lib/zplParser.ts index da815e84..06f3ef09 100644 --- a/src/lib/zplParser.ts +++ b/src/lib/zplParser.ts @@ -169,6 +169,69 @@ function decodeFH( }); } +/** + * CRC-16/CCITT-FALSE (poly 0x1021, init 0x0000) — Zebra's ZB64/ZB16 wrapper + * uses this variant. Computed over the base64 (or hex) payload between the + * `:B64:`/`:Z64:` prefix and the trailing `:CRC` suffix. + */ +function crc16Ccitt(s: string): number { + let crc = 0; + for (const ch of s) { + crc ^= ch.charCodeAt(0) << 8; + for (let j = 0; j < 8; j++) { + crc = (crc & 0x8000) ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff; + } + } + return crc; +} + +/** Convert a Uint8Array to an uppercase hex string. */ +function bytesToHex(bytes: Uint8Array): string { + let out = ""; + for (const b of bytes) { + out += b.toString(16).padStart(2, "0").toUpperCase(); + } + return out; +} + +export type GfWrapperKind = "b64" | "z64"; + +export interface GfWrapperDecoded { + kind: GfWrapperKind; + /** Raw decoded bytes — for `:Z64:` this is still zlib-compressed. */ + bytes: Uint8Array; + /** True if the trailing CRC matches the base64 payload. */ + crcOk: boolean; +} + +/** + * Parse a `:B64::` or `:Z64::` wrapper. Returns + * null if the payload doesn't carry a wrapper. Used inside `^GFA`/`^GFB`/ + * `^GFC` and similar commands where Zebra firmware accepts the same envelope. + * The CRC is computed over the base64 string itself (CRC-16/CCITT-FALSE) and + * surfaced as a flag rather than a hard reject — printers also tolerate + * mismatches, and we'd rather render a slightly-suspect graphic than silently + * drop it. The caller decides whether `:Z64:` (zlib-compressed) is supported. + */ +export function parseGfWrapper(payload: string): GfWrapperDecoded | null { + const m = /^:(B64|Z64):([A-Za-z0-9+/=]+):([0-9A-Fa-f]{4})$/.exec(payload); + if (!m) return null; + const kind = m[1] === "B64" ? "b64" : "z64"; + const b64 = m[2] ?? ""; + const crcStr = m[3] ?? "0000"; + const declaredCrc = parseInt(crcStr, 16); + const actualCrc = crc16Ccitt(b64); + let bytes: Uint8Array; + try { + const bin = atob(b64); + bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + } catch { + bytes = new Uint8Array(0); + } + return { kind, bytes, crcOk: actualCrc === declaredCrc }; +} + /** * Decompress ZPL Alternative Data Compression used in ^GFA fields. * @@ -1100,10 +1163,16 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { ); }, GF(_, rest) { - // ^GFA,{totalBytes},{totalBytes},{bytesPerRow},{compressedOrHexData} + // ^GF{A|B|C},{totalBytes},{totalBytes},{bytesPerRow},{payload} + // + // Payload variants the parser understands: + // - format=A + raw hex (optionally with G-Y/g-z/!/,/: RLE) + // - any format + `:B64::` wrapper (decoded → hex) + // `:Z64:` (zlib-compressed) is recognised but not yet decodable in the + // browser without an async refactor or a sync inflate dep; surfaced as + // `partial` so the import doesn't silently lose the field. const format = rest[0]?.toUpperCase(); - if (format !== "A") { - // Non-A formats (binary, compressed) can't be rendered in the browser + if (format !== "A" && format !== "B" && format !== "C") { skipped.push(`^GF${rest}`); browserLimit.push(`^GF${rest}`); return; @@ -1131,8 +1200,34 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { return; } - // Decompress ZPL Alternative Data Compression for ^GFA - const gfHex = decompressGFA(gfRawData, gfBytesPerRow); + // Normalise payload to hex. Three sources, descending in real-world + // frequency for our parser: existing ^GFA RLE-hex, :B64:-wrapped binary + // (enterprise systems), and raw binary in ^GFB/^GFC (rare in text-paste + // UIs because tokenisation can't survive embedded ^/~/\0). + let gfHex: string; + const wrapper = parseGfWrapper(gfRawData); + if (wrapper) { + if (wrapper.kind === "z64") { + // zlib-compressed: would need sync inflate; bail. No object is + // created, so this is a browser-limit, not a partial import. + browserLimit.push(`^GF${rest.slice(0, 80)}…`); + skipped.push(`^GF${rest.slice(0, 80)}…`); + return; + } + if (!wrapper.crcOk) { + // Render anyway (printers tolerate this), but flag the fidelity loss. + partialCmds.add("^GF"); + } + gfHex = bytesToHex(wrapper.bytes); + } else if (format === "A") { + gfHex = decompressGFA(gfRawData, gfBytesPerRow); + } else { + // Format B/C without :B64: wrapper: raw binary in a text channel — + // can't represent reliably. Surfaced as a limitation. + skipped.push(`^GF${rest.slice(0, 80)}…`); + browserLimit.push(`^GF${rest.slice(0, 80)}…`); + return; + } const gfWidthDots = gfBytesPerRow * 8; const gfTotalBytes = gfHex.length / 2; const gfHeightDots = Math.floor(gfTotalBytes / gfBytesPerRow); From 9ae4d62c51efdc037e7d14f94393f3cb238abc4a Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 21 May 2026 17:58:49 +0200 Subject: [PATCH 2/9] refactor(parser): drop unused GF wrapper exports, DRY truncation token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseGfWrapper und seine Typen waren exportiert, ohne externe Nutzung — zusätzliche API-Surface ohne Grund. Außerdem das viermal duplizierte '^GF${rest.slice(0, 80)}…' in eine lokale Konstante gehoben. --- src/lib/zplParser.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/lib/zplParser.ts b/src/lib/zplParser.ts index 06f3ef09..57d08f12 100644 --- a/src/lib/zplParser.ts +++ b/src/lib/zplParser.ts @@ -194,9 +194,9 @@ function bytesToHex(bytes: Uint8Array): string { return out; } -export type GfWrapperKind = "b64" | "z64"; +type GfWrapperKind = "b64" | "z64"; -export interface GfWrapperDecoded { +interface GfWrapperDecoded { kind: GfWrapperKind; /** Raw decoded bytes — for `:Z64:` this is still zlib-compressed. */ bytes: Uint8Array; @@ -213,7 +213,7 @@ export interface GfWrapperDecoded { * mismatches, and we'd rather render a slightly-suspect graphic than silently * drop it. The caller decides whether `:Z64:` (zlib-compressed) is supported. */ -export function parseGfWrapper(payload: string): GfWrapperDecoded | null { +function parseGfWrapper(payload: string): GfWrapperDecoded | null { const m = /^:(B64|Z64):([A-Za-z0-9+/=]+):([0-9A-Fa-f]{4})$/.exec(payload); if (!m) return null; const kind = m[1] === "B64" ? "b64" : "z64"; @@ -1200,6 +1200,11 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { return; } + // Token used when we have to surface the field rather than render it. + // Truncated because :B64:/:Z64: payloads can be many KB and we don't + // want the import report dominated by one entry. + const gfSummary = `^GF${rest.slice(0, 80)}…`; + // Normalise payload to hex. Three sources, descending in real-world // frequency for our parser: existing ^GFA RLE-hex, :B64:-wrapped binary // (enterprise systems), and raw binary in ^GFB/^GFC (rare in text-paste @@ -1210,8 +1215,8 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { if (wrapper.kind === "z64") { // zlib-compressed: would need sync inflate; bail. No object is // created, so this is a browser-limit, not a partial import. - browserLimit.push(`^GF${rest.slice(0, 80)}…`); - skipped.push(`^GF${rest.slice(0, 80)}…`); + browserLimit.push(gfSummary); + skipped.push(gfSummary); return; } if (!wrapper.crcOk) { @@ -1224,8 +1229,8 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { } else { // Format B/C without :B64: wrapper: raw binary in a text channel — // can't represent reliably. Surfaced as a limitation. - skipped.push(`^GF${rest.slice(0, 80)}…`); - browserLimit.push(`^GF${rest.slice(0, 80)}…`); + skipped.push(gfSummary); + browserLimit.push(gfSummary); return; } const gfWidthDots = gfBytesPerRow * 8; From 15dac31bd0bc171adcef70939e8f6ad9829a65ad Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 21 May 2026 18:11:03 +0200 Subject: [PATCH 3/9] feat(parser): inflate :Z64: graphic payloads via fflate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Z64 ist real-world die Standard-Form für komprimierte ZPL-Grafiken aus Enterprise-Systemen (SAP/Oracle/etc.); B64 alleine reicht für hochauflösende Logos nicht, weil dann doch Base64 von rohen Bitmap-Bytes übrigbleibt. fflate.unzlibSync deckt sync inflate ohne async-Refactor ab (MIT, ~13 KB gzip). Magic numbers an einer Stelle benannt: CRC16_CCITT_POLY, CRC16_MASK, CRC_HEX_DIGITS, IMPORT_FINDING_PAYLOAD_LIMIT. Polynomial und Maskenwerte sind CRC-Standardkonstanten und bleiben hex; nur die Namensvergabe verschiebt die Bedeutung von 'magic' nach 'spec'. --- package.json | 1 + pnpm-lock.yaml | 27 ++++++++++++++++++ src/lib/zplParser.test.ts | 48 ++++++++++++++++++++++++++++--- src/lib/zplParser.ts | 59 ++++++++++++++++++++++++++++----------- 4 files changed, 115 insertions(+), 20 deletions(-) diff --git a/package.json b/package.json index fc560a4b..1cb1f1a6 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@dnd-kit/sortable": "^10.0.0", "@heroicons/react": "^2.2.0", "bwip-js": "^4.10.1", + "fflate": "^0.8.3", "konva": "^10.3.0", "react": "^19.2.6", "react-dom": "^19.2.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a388aa7e..412df701 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: bwip-js: specifier: ^4.10.1 version: 4.10.1 + fflate: + specifier: ^0.8.3 + version: 0.8.3 konva: specifier: ^10.3.0 version: 10.3.0 @@ -329,30 +332,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-arm64-musl@1.0.0': resolution: {integrity: sha512-LTUl9jS8WsLSUGaxQZKQkxfluOJRpgvBuxxdM4pYcjib+di8AU4OzQc6+L6SzGMLcKc9H0RAjojRatBhTMqYdg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@napi-rs/canvas-linux-riscv64-gnu@1.0.0': resolution: {integrity: sha512-Iz931SAZf+WVDzpjk52Q3ffW3zw0YflFwEZMgs036Wfu1kX/LrwT9wGjsuSqyduqefUkl91/vTdAjn8hQu5ezA==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-x64-gnu@1.0.0': resolution: {integrity: sha512-pFEQ5eFK4JusgN1K6KkO9DKP/Hi1WMJOkF8Ch03/khTc4bFbCKkCCsJG4YcOMOW9bI4XbT2/eMAWxhO0xaWgPA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@napi-rs/canvas-linux-x64-musl@1.0.0': resolution: {integrity: sha512-jnvr8NrLHiZ3NCiOKWqDbkI4Ah+QDrqtZ+sddPZBltEb1mQ2coSvCSJYfict+oAwcm0c970oTmVySpjKP/lnaA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@napi-rs/canvas-win32-arm64-msvc@1.0.0': resolution: {integrity: sha512-y2j9/Gfd5joqiqxdP/L1smqjQ+uAx3C4N0EC7bDHrnZEEH8ToM/OC5p3uHvtj4Lq591aHj+ArL01UDLNwT5HgQ==} @@ -414,36 +422,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.1': resolution: {integrity: sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.1': resolution: {integrity: sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.1': resolution: {integrity: sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.1': resolution: {integrity: sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.1': resolution: {integrity: sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.1': resolution: {integrity: sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==} @@ -529,24 +543,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.0': resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} @@ -910,6 +928,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -1050,24 +1071,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -2203,6 +2228,8 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fflate@0.8.3: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 diff --git a/src/lib/zplParser.test.ts b/src/lib/zplParser.test.ts index bb20d220..c4b41ceb 100644 --- a/src/lib/zplParser.test.ts +++ b/src/lib/zplParser.test.ts @@ -1,7 +1,30 @@ import { describe, it, expect, beforeAll } from 'vitest'; +import { zlibSync } from 'fflate'; import { parseZPL } from './zplParser'; import { props } from '../test/helpers'; +/** CRC-16/CCITT-FALSE — same variant used by the parser to validate + * :B64:/:Z64: wrappers. Duplicated here so tests can build valid CRC + * values without exporting the parser's internal helper. */ +function testCrc16(s: string): string { + let crc = 0; + for (const ch of s) { + crc ^= ch.charCodeAt(0) << 8; + for (let j = 0; j < 8; j++) { + crc = (crc & 0x8000) ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff; + } + } + return crc.toString(16).padStart(4, '0').toUpperCase(); +} + +function makeZ64Field(bytes: Uint8Array): string { + const deflated = zlibSync(bytes); + let bin = ''; + for (const b of deflated) bin += String.fromCharCode(b); + const b64 = btoa(bin); + return `:Z64:${b64}:${testCrc16(b64)}`; +} + // ── label config ────────────────────────────────────────────────────────────── describe('parseZPL — label config', () => { @@ -576,15 +599,32 @@ describe('parseZPL — ^GFA graphic field', () => { } }); - it('records :Z64: payload as browserLimit (zlib not yet supported)', () => { - // Arbitrary :Z64: payload — content doesn't matter, only the prefix. + it('imports a :Z64:-wrapped ^GFC payload by inflating zlib data', () => { + // 8 bytes = [0,0,0,0xFF,0xFF,0,0,0] → zlib-compressed → base64 → CRC. + const bytes = new Uint8Array([0, 0, 0, 0xff, 0xff, 0, 0, 0]); + const field = makeZ64Field(bytes); + const { objects, importReport } = parseZPL( + `^XA^FO0,0^GFC,8,8,1,${field}^FS^XZ`, + 8, + ); + expect(objects).toHaveLength(1); + expect(objects[0]?.type).toBe('image'); + expect(props(objects[0]).widthDots).toBe(8); + expect(importReport.partial).not.toContain('^GF'); + }); + + it('records :Z64: with corrupt deflate stream as browserLimit', () => { + // Valid base64 but garbage bytes that fflate will reject as a deflate + // stream. CRC must match so we know the failure is in inflate, not the + // wrapper-shape detection. + const b64 = btoa('not a valid zlib stream'); + const field = `:Z64:${b64}:${testCrc16(b64)}`; const { objects, importReport } = parseZPL( - '^XA^FO0,0^GFC,8,8,1,:Z64:eJxjYGD4/5+BgQEACP8B/w==:95F4^FS^XZ', + `^XA^FO0,0^GFC,8,8,1,${field}^FS^XZ`, 8, ); expect(objects).toHaveLength(0); expect(importReport.browserLimit.some((s) => s.startsWith('^GF'))).toBe(true); - expect(importReport.partial).not.toContain('^GF'); }); it('creates an image object from compressed ^GFA data', () => { diff --git a/src/lib/zplParser.ts b/src/lib/zplParser.ts index 57d08f12..ec12d1bc 100644 --- a/src/lib/zplParser.ts +++ b/src/lib/zplParser.ts @@ -20,6 +20,7 @@ import { isZplRotation, type ZplRotation } from "../registry/rotation"; import type { AztecProps } from "../registry/aztec"; import type { MicroPdf417Props } from "../registry/micropdf417"; import type { CodablockProps } from "../registry/codablock"; +import { unzlibSync } from "fflate"; import { putImage } from "./imageCache"; import { loadFontBytesSync } from "./fontCache"; import { ZPL_BUILTIN_FONT_LETTERS } from "./customFonts"; @@ -170,16 +171,29 @@ function decodeFH( } /** - * CRC-16/CCITT-FALSE (poly 0x1021, init 0x0000) — Zebra's ZB64/ZB16 wrapper - * uses this variant. Computed over the base64 (or hex) payload between the - * `:B64:`/`:Z64:` prefix and the trailing `:CRC` suffix. + * CRC-16/CCITT-FALSE (poly 0x1021, init 0x0000, no reflect, no xorout) — + * Zebra's ZB64/ZB16 wrapper uses this variant. Computed over the base64 + * (or hex) payload between the `:B64:`/`:Z64:` prefix and the trailing + * `:CRC` suffix. */ +/** Characters of a `^GF`/`~DY` payload retained in browserLimit/skipped + * findings; rest is replaced with an ellipsis so a single multi-KB + * base64 blob doesn't drown out the import report. */ +const IMPORT_FINDING_PAYLOAD_LIMIT = 80; + +const CRC16_CCITT_POLY = 0x1021; +const CRC16_MSB_MASK = 0x8000; // 1 << 15 +const CRC16_MASK = 0xffff; +const BITS_PER_BYTE = 8; + function crc16Ccitt(s: string): number { let crc = 0; for (const ch of s) { - crc ^= ch.charCodeAt(0) << 8; - for (let j = 0; j < 8; j++) { - crc = (crc & 0x8000) ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff; + crc ^= ch.charCodeAt(0) << BITS_PER_BYTE; + for (let j = 0; j < BITS_PER_BYTE; j++) { + crc = (crc & CRC16_MSB_MASK) + ? ((crc << 1) ^ CRC16_CCITT_POLY) & CRC16_MASK + : (crc << 1) & CRC16_MASK; } } return crc; @@ -213,8 +227,14 @@ interface GfWrapperDecoded { * mismatches, and we'd rather render a slightly-suspect graphic than silently * drop it. The caller decides whether `:Z64:` (zlib-compressed) is supported. */ +/** CRC-16 emitted as 4 uppercase hex chars in the `:B64:`/`:Z64:` trailer. */ +const CRC_HEX_DIGITS = 4; +const GF_WRAPPER_RE = new RegExp( + `^:(B64|Z64):([A-Za-z0-9+/=]+):([0-9A-Fa-f]{${CRC_HEX_DIGITS}})$`, +); + function parseGfWrapper(payload: string): GfWrapperDecoded | null { - const m = /^:(B64|Z64):([A-Za-z0-9+/=]+):([0-9A-Fa-f]{4})$/.exec(payload); + const m = GF_WRAPPER_RE.exec(payload); if (!m) return null; const kind = m[1] === "B64" ? "b64" : "z64"; const b64 = m[2] ?? ""; @@ -1203,7 +1223,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { // Token used when we have to surface the field rather than render it. // Truncated because :B64:/:Z64: payloads can be many KB and we don't // want the import report dominated by one entry. - const gfSummary = `^GF${rest.slice(0, 80)}…`; + const gfSummary = `^GF${rest.slice(0, IMPORT_FINDING_PAYLOAD_LIMIT)}…`; // Normalise payload to hex. Three sources, descending in real-world // frequency for our parser: existing ^GFA RLE-hex, :B64:-wrapped binary @@ -1212,18 +1232,25 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { let gfHex: string; const wrapper = parseGfWrapper(gfRawData); if (wrapper) { - if (wrapper.kind === "z64") { - // zlib-compressed: would need sync inflate; bail. No object is - // created, so this is a browser-limit, not a partial import. - browserLimit.push(gfSummary); - skipped.push(gfSummary); - return; - } if (!wrapper.crcOk) { // Render anyway (printers tolerate this), but flag the fidelity loss. partialCmds.add("^GF"); } - gfHex = bytesToHex(wrapper.bytes); + let rawBytes = wrapper.bytes; + if (wrapper.kind === "z64") { + // zlib-compressed payload. fflate.unzlibSync handles the zlib + // wrapper (RFC 1950) — Zebra's `:Z64:` is *not* raw deflate. If + // inflate throws (truncation, wrong header), the payload is + // unrecoverable: surface and bail. + try { + rawBytes = unzlibSync(wrapper.bytes); + } catch { + browserLimit.push(gfSummary); + skipped.push(gfSummary); + return; + } + } + gfHex = bytesToHex(rawBytes); } else if (format === "A") { gfHex = decompressGFA(gfRawData, gfBytesPerRow); } else { From f8273cd73c16ca90bc8e13e2aee3dd3c395117de Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 21 May 2026 18:43:19 +0200 Subject: [PATCH 4/9] refactor(parser): extract gfPayloadToHex from GF handler GF-Handler war 100 Zeilen mit vier Abstraction-Levels in einem Block: Command-Tokenparsing, Payload-Normalisierung (B64/Z64/RLE/bail), Pixel-Painting, Cache-Anbindung. gfPayloadToHex isoliert die zweite Stufe als pures Helper-Pair (parseGfWrapper + Inflate) und nimmt aus dem Handler die ganze if-Treppe raus. --- src/lib/zplParser.ts | 91 ++++++++++++++++++++++++++------------------ 1 file changed, 55 insertions(+), 36 deletions(-) diff --git a/src/lib/zplParser.ts b/src/lib/zplParser.ts index ec12d1bc..15e6cd1c 100644 --- a/src/lib/zplParser.ts +++ b/src/lib/zplParser.ts @@ -252,6 +252,51 @@ function parseGfWrapper(payload: string): GfWrapperDecoded | null { return { kind, bytes, crcOk: actualCrc === declaredCrc }; } +/** + * Result of `gfPayloadToHex`: either a usable hex bitmap plus the integrity + * flag for the originating wrapper, or null if the payload can't be decoded. + * `crcOk=false` means the field is rendered with a fidelity caveat (printers + * tolerate this); `null` means surface as a browser limit and skip the + * object. + */ +interface GfPayloadDecoded { + hex: string; + crcOk: boolean; +} + +/** + * Normalise a `^GF{A|B|C}` payload to a hex bitmap. Hides the format / + * wrapper / compression dispatch from the command handler so the latter can + * stay focused on positioning and pixel painting. + * + * - `:B64:`/`:Z64:` wrapper → base64-decode (then zlib-inflate for Z64) + * - `format=A` without wrapper → existing RLE-hex path + * - `format=B`/`C` without wrapper → null (raw binary can't survive the + * text-based ZPL channel and the parser never sees intact bytes anyway) + */ +function gfPayloadToHex( + rawData: string, + format: "A" | "B" | "C", + bytesPerRow: number, +): GfPayloadDecoded | null { + const wrapper = parseGfWrapper(rawData); + if (wrapper) { + let bytes = wrapper.bytes; + if (wrapper.kind === "z64") { + try { + bytes = unzlibSync(wrapper.bytes); + } catch { + return null; + } + } + return { hex: bytesToHex(bytes), crcOk: wrapper.crcOk }; + } + if (format === "A") { + return { hex: decompressGFA(rawData, bytesPerRow), crcOk: true }; + } + return null; +} + /** * Decompress ZPL Alternative Data Compression used in ^GFA fields. * @@ -1220,46 +1265,20 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { return; } - // Token used when we have to surface the field rather than render it. - // Truncated because :B64:/:Z64: payloads can be many KB and we don't - // want the import report dominated by one entry. - const gfSummary = `^GF${rest.slice(0, IMPORT_FINDING_PAYLOAD_LIMIT)}…`; - - // Normalise payload to hex. Three sources, descending in real-world - // frequency for our parser: existing ^GFA RLE-hex, :B64:-wrapped binary - // (enterprise systems), and raw binary in ^GFB/^GFC (rare in text-paste - // UIs because tokenisation can't survive embedded ^/~/\0). - let gfHex: string; - const wrapper = parseGfWrapper(gfRawData); - if (wrapper) { - if (!wrapper.crcOk) { - // Render anyway (printers tolerate this), but flag the fidelity loss. - partialCmds.add("^GF"); - } - let rawBytes = wrapper.bytes; - if (wrapper.kind === "z64") { - // zlib-compressed payload. fflate.unzlibSync handles the zlib - // wrapper (RFC 1950) — Zebra's `:Z64:` is *not* raw deflate. If - // inflate throws (truncation, wrong header), the payload is - // unrecoverable: surface and bail. - try { - rawBytes = unzlibSync(wrapper.bytes); - } catch { - browserLimit.push(gfSummary); - skipped.push(gfSummary); - return; - } - } - gfHex = bytesToHex(rawBytes); - } else if (format === "A") { - gfHex = decompressGFA(gfRawData, gfBytesPerRow); - } else { - // Format B/C without :B64: wrapper: raw binary in a text channel — - // can't represent reliably. Surfaced as a limitation. + const decoded = gfPayloadToHex(gfRawData, format, gfBytesPerRow); + if (!decoded) { + // Truncated summary because :B64:/:Z64: payloads can be many KB + // and we don't want one entry dominating the import report. + const gfSummary = `^GF${rest.slice(0, IMPORT_FINDING_PAYLOAD_LIMIT)}…`; skipped.push(gfSummary); browserLimit.push(gfSummary); return; } + if (!decoded.crcOk) { + // Render anyway (printers tolerate CRC drift) but flag the loss. + partialCmds.add("^GF"); + } + const gfHex = decoded.hex; const gfWidthDots = gfBytesPerRow * 8; const gfTotalBytes = gfHex.length / 2; const gfHeightDots = Math.floor(gfTotalBytes / gfBytesPerRow); From 13abe272b8fded7f2b0c60978396f9f9bd5fadd7 Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 21 May 2026 18:51:37 +0200 Subject: [PATCH 5/9] refactor(parser): immutable bytes assignments in GF helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei mutable Patterns ersetzt: 'let bytes; if (z64) bytes = inflate(…)' und 'let bytes; try { bytes = decodeBase64() }' werden zu 'const bytes =' Ausdrücken über tryInflateZlib bzw. base64ToBytes. Die try/catches sind in die Helpers gewandert, sodass die Aufrufer als reine Datenflüsse lesen. --- src/lib/zplParser.ts | 51 +++++++++++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/src/lib/zplParser.ts b/src/lib/zplParser.ts index 15e6cd1c..68eb7f02 100644 --- a/src/lib/zplParser.ts +++ b/src/lib/zplParser.ts @@ -233,23 +233,30 @@ const GF_WRAPPER_RE = new RegExp( `^:(B64|Z64):([A-Za-z0-9+/=]+):([0-9A-Fa-f]{${CRC_HEX_DIGITS}})$`, ); +/** Decode a base64 string to bytes; empty array on malformed input. */ +function base64ToBytes(b64: string): Uint8Array { + let bin: string; + try { + bin = atob(b64); + } catch { + return new Uint8Array(0); + } + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; +} + function parseGfWrapper(payload: string): GfWrapperDecoded | null { const m = GF_WRAPPER_RE.exec(payload); if (!m) return null; const kind = m[1] === "B64" ? "b64" : "z64"; const b64 = m[2] ?? ""; - const crcStr = m[3] ?? "0000"; - const declaredCrc = parseInt(crcStr, 16); - const actualCrc = crc16Ccitt(b64); - let bytes: Uint8Array; - try { - const bin = atob(b64); - bytes = new Uint8Array(bin.length); - for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); - } catch { - bytes = new Uint8Array(0); - } - return { kind, bytes, crcOk: actualCrc === declaredCrc }; + const declaredCrc = parseInt(m[3] ?? "0", 16); + return { + kind, + bytes: base64ToBytes(b64), + crcOk: crc16Ccitt(b64) === declaredCrc, + }; } /** @@ -274,6 +281,15 @@ interface GfPayloadDecoded { * - `format=B`/`C` without wrapper → null (raw binary can't survive the * text-based ZPL channel and the parser never sees intact bytes anyway) */ +/** Inflate `:Z64:` zlib payload; null on malformed deflate stream. */ +function tryInflateZlib(input: Uint8Array): Uint8Array | null { + try { + return unzlibSync(input); + } catch { + return null; + } +} + function gfPayloadToHex( rawData: string, format: "A" | "B" | "C", @@ -281,14 +297,9 @@ function gfPayloadToHex( ): GfPayloadDecoded | null { const wrapper = parseGfWrapper(rawData); if (wrapper) { - let bytes = wrapper.bytes; - if (wrapper.kind === "z64") { - try { - bytes = unzlibSync(wrapper.bytes); - } catch { - return null; - } - } + const bytes = + wrapper.kind === "z64" ? tryInflateZlib(wrapper.bytes) : wrapper.bytes; + if (!bytes) return null; return { hex: bytesToHex(bytes), crcOk: wrapper.crcOk }; } if (format === "A") { From 919d09b37c4959f2dc756cb0bc86c390ae95f881 Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 21 May 2026 19:04:40 +0200 Subject: [PATCH 6/9] refactor(parser): collapse GF wrapper kind branch to lowercase cast --- src/lib/zplParser.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/lib/zplParser.ts b/src/lib/zplParser.ts index 68eb7f02..3df0200f 100644 --- a/src/lib/zplParser.ts +++ b/src/lib/zplParser.ts @@ -249,11 +249,10 @@ function base64ToBytes(b64: string): Uint8Array { function parseGfWrapper(payload: string): GfWrapperDecoded | null { const m = GF_WRAPPER_RE.exec(payload); if (!m) return null; - const kind = m[1] === "B64" ? "b64" : "z64"; const b64 = m[2] ?? ""; const declaredCrc = parseInt(m[3] ?? "0", 16); return { - kind, + kind: (m[1] ?? "").toLowerCase() as GfWrapperKind, bytes: base64ToBytes(b64), crcOk: crc16Ccitt(b64) === declaredCrc, }; From b205bfc46f7223e787242476390c41012dc41612 Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 21 May 2026 19:32:39 +0200 Subject: [PATCH 7/9] refactor(parser): address Gemini review and round-trip format-letter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parseGfWrapper.trim() vor dem Regex-Match — Tokenizer behält trailing \n auf rest, ohne trim hätte das Pattern jedes real-world Label mit Zeilenumbrüchen verfehlt. Neuer Test deckt das ab. - gfPayloadToHex → gfPayloadToBytes: Uint8Array statt Hex-String durch- reichen spart 30k+ String-Slice + parseInt-Calls in der Paint-Loop bei mittelgroßen Logos. bytesToHex weg; gfaHexToBytes als Brücke für den decompressGFA-Pfad. - _gfaCache erhält den originalen Format-Buchstaben (A/B/C). War hardcoded auf 'A'; mit B/C-Support hätte ein ^GFC,:Z64:… als ^GFA,:Z64:… re-exportiert, was Zebra/Labelary ablehnen. - Veralteten Kommentar zu :Z64:-Support entfernt (ist seit fflate aktiv). --- src/lib/zplParser.test.ts | 12 +++++ src/lib/zplParser.ts | 98 ++++++++++++++++++++------------------- 2 files changed, 63 insertions(+), 47 deletions(-) diff --git a/src/lib/zplParser.test.ts b/src/lib/zplParser.test.ts index c4b41ceb..1be6299a 100644 --- a/src/lib/zplParser.test.ts +++ b/src/lib/zplParser.test.ts @@ -599,6 +599,18 @@ describe('parseZPL — ^GFA graphic field', () => { } }); + it('tolerates trailing whitespace on wrapped GF payloads', () => { + // Real-world ZPL is often line-broken between commands; the tokenizer + // preserves the trailing newline on the field body, so the regex needs + // to accommodate that. + const zplWithNewline = + '^XA\n^FO0,0\n^GFA,8,8,1,:B64:AAAA//8AAAA=:DFF8\n^FS\n^XZ'; + const { objects, importReport } = parseZPL(zplWithNewline, 8); + expect(objects).toHaveLength(1); + expect(objects[0]?.type).toBe('image'); + expect(importReport.browserLimit).toHaveLength(0); + }); + it('imports a :Z64:-wrapped ^GFC payload by inflating zlib data', () => { // 8 bytes = [0,0,0,0xFF,0xFF,0,0,0] → zlib-compressed → base64 → CRC. const bytes = new Uint8Array([0, 0, 0, 0xff, 0xff, 0, 0, 0]); diff --git a/src/lib/zplParser.ts b/src/lib/zplParser.ts index 3df0200f..18fd7576 100644 --- a/src/lib/zplParser.ts +++ b/src/lib/zplParser.ts @@ -199,15 +199,6 @@ function crc16Ccitt(s: string): number { return crc; } -/** Convert a Uint8Array to an uppercase hex string. */ -function bytesToHex(bytes: Uint8Array): string { - let out = ""; - for (const b of bytes) { - out += b.toString(16).padStart(2, "0").toUpperCase(); - } - return out; -} - type GfWrapperKind = "b64" | "z64"; interface GfWrapperDecoded { @@ -218,15 +209,6 @@ interface GfWrapperDecoded { crcOk: boolean; } -/** - * Parse a `:B64::` or `:Z64::` wrapper. Returns - * null if the payload doesn't carry a wrapper. Used inside `^GFA`/`^GFB`/ - * `^GFC` and similar commands where Zebra firmware accepts the same envelope. - * The CRC is computed over the base64 string itself (CRC-16/CCITT-FALSE) and - * surfaced as a flag rather than a hard reject — printers also tolerate - * mismatches, and we'd rather render a slightly-suspect graphic than silently - * drop it. The caller decides whether `:Z64:` (zlib-compressed) is supported. - */ /** CRC-16 emitted as 4 uppercase hex chars in the `:B64:`/`:Z64:` trailer. */ const CRC_HEX_DIGITS = 4; const GF_WRAPPER_RE = new RegExp( @@ -246,8 +228,20 @@ function base64ToBytes(b64: string): Uint8Array { return bytes; } +/** + * Parse a `:B64::` or `:Z64::` wrapper. Returns + * null if the payload doesn't carry a wrapper. Used inside `^GFA`/`^GFB`/ + * `^GFC` where Zebra firmware accepts the same envelope. The CRC is + * computed over the base64 string (CRC-16/CCITT-FALSE) and surfaced as a + * flag rather than a hard reject — printers tolerate mismatches, and + * we'd rather render a slightly-suspect graphic than silently drop it. + * + * `payload.trim()` because real-world ZPL is often line-broken between + * commands; the tokenizer keeps the trailing newline on `rest`, and an + * un-trimmed regex with a `$` anchor would miss every wrapper-in-the-wild. + */ function parseGfWrapper(payload: string): GfWrapperDecoded | null { - const m = GF_WRAPPER_RE.exec(payload); + const m = GF_WRAPPER_RE.exec(payload.trim()); if (!m) return null; const b64 = m[2] ?? ""; const declaredCrc = parseInt(m[3] ?? "0", 16); @@ -259,27 +253,16 @@ function parseGfWrapper(payload: string): GfWrapperDecoded | null { } /** - * Result of `gfPayloadToHex`: either a usable hex bitmap plus the integrity - * flag for the originating wrapper, or null if the payload can't be decoded. - * `crcOk=false` means the field is rendered with a fidelity caveat (printers - * tolerate this); `null` means surface as a browser limit and skip the - * object. + * Result of `gfPayloadToBytes`: the raw bitmap bytes (one row = N bytes, + * each byte = 8 pixels, MSB first) plus the integrity flag for the + * originating wrapper. `crcOk=false` is rendered with a fidelity caveat; + * `null` from `gfPayloadToBytes` means the payload was undecodable. */ interface GfPayloadDecoded { - hex: string; + data: Uint8Array; crcOk: boolean; } -/** - * Normalise a `^GF{A|B|C}` payload to a hex bitmap. Hides the format / - * wrapper / compression dispatch from the command handler so the latter can - * stay focused on positioning and pixel painting. - * - * - `:B64:`/`:Z64:` wrapper → base64-decode (then zlib-inflate for Z64) - * - `format=A` without wrapper → existing RLE-hex path - * - `format=B`/`C` without wrapper → null (raw binary can't survive the - * text-based ZPL channel and the parser never sees intact bytes anyway) - */ /** Inflate `:Z64:` zlib payload; null on malformed deflate stream. */ function tryInflateZlib(input: Uint8Array): Uint8Array | null { try { @@ -289,7 +272,27 @@ function tryInflateZlib(input: Uint8Array): Uint8Array | null { } } -function gfPayloadToHex( +/** Decode the ASCII-hex output of `decompressGFA` into a packed byte array + * so all three GF code paths converge on the same `Uint8Array` shape. */ +function gfaHexToBytes(hex: string): Uint8Array { + const out = new Uint8Array(hex.length >> 1); + for (let i = 0; i < out.length; i++) { + out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return out; +} + +/** + * Normalise a `^GF{A|B|C}` payload to packed bitmap bytes. Hides the + * format / wrapper / compression dispatch from the command handler so the + * latter can stay focused on positioning and pixel painting. + * + * - `:B64:`/`:Z64:` wrapper → base64-decode (then zlib-inflate for Z64) + * - `format=A` without wrapper → existing RLE-hex path → bytes + * - `format=B`/`C` without wrapper → null (raw binary can't survive the + * text-based ZPL channel and the parser never sees intact bytes anyway) + */ +function gfPayloadToBytes( rawData: string, format: "A" | "B" | "C", bytesPerRow: number, @@ -299,10 +302,10 @@ function gfPayloadToHex( const bytes = wrapper.kind === "z64" ? tryInflateZlib(wrapper.bytes) : wrapper.bytes; if (!bytes) return null; - return { hex: bytesToHex(bytes), crcOk: wrapper.crcOk }; + return { data: bytes, crcOk: wrapper.crcOk }; } if (format === "A") { - return { hex: decompressGFA(rawData, bytesPerRow), crcOk: true }; + return { data: gfaHexToBytes(decompressGFA(rawData, bytesPerRow)), crcOk: true }; } return null; } @@ -1275,7 +1278,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { return; } - const decoded = gfPayloadToHex(gfRawData, format, gfBytesPerRow); + const decoded = gfPayloadToBytes(gfRawData, format, gfBytesPerRow); if (!decoded) { // Truncated summary because :B64:/:Z64: payloads can be many KB // and we don't want one entry dominating the import report. @@ -1288,17 +1291,16 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { // Render anyway (printers tolerate CRC drift) but flag the loss. partialCmds.add("^GF"); } - const gfHex = decoded.hex; + const gfBytes = decoded.data; const gfWidthDots = gfBytesPerRow * 8; - const gfTotalBytes = gfHex.length / 2; - const gfHeightDots = Math.floor(gfTotalBytes / gfBytesPerRow); + const gfHeightDots = Math.floor(gfBytes.length / gfBytesPerRow); if (gfHeightDots <= 0) { skipped.push(`^GF${rest}`); return; } - // Convert hex → 1-bit bitmap → canvas → data URL + // Convert 1-bit bitmap → canvas → data URL const canvas = document.createElement("canvas"); canvas.width = gfWidthDots; canvas.height = gfHeightDots; @@ -1309,8 +1311,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { for (let row = 0; row < gfHeightDots; row++) { for (let byteIdx = 0; byteIdx < gfBytesPerRow; byteIdx++) { - const hexOffset = (row * gfBytesPerRow + byteIdx) * 2; - const byte = parseInt(gfHex.slice(hexOffset, hexOffset + 2), 16) || 0; + const byte = gfBytes[row * gfBytesPerRow + byteIdx] ?? 0; for (let bit = 0; bit < 8; bit++) { const px = byteIdx * 8 + bit; const idx = (row * gfWidthDots + px) * 4; @@ -1335,8 +1336,11 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { height: gfHeightDots, }); - // Store original compressed data for lossless re-export - const gfaCache = `^GFA,${Math.floor(gfTotalBytes)},${Math.floor(gfTotalBytes)},${gfBytesPerRow},${gfRawData}`; + // Store original compressed data for lossless re-export. Keep the + // source format letter (A/B/C) because Labelary and Zebra firmware + // reject e.g. `^GFA,…,:Z64:…` — `:Z64:` is canonical for `^GFC` + // only, so a hard-coded `^GFA` on the round-trip would corrupt it. + const gfaCache = `^GF${format},${gfBytes.length},${gfBytes.length},${gfBytesPerRow},${gfRawData}`; const posType: "FT" | "FO" = positionIsFT ? "FT" : "FO"; objects.push( From dbe67d081dac9a989c4aebee72f2812d25536477 Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 21 May 2026 20:02:41 +0200 Subject: [PATCH 8/9] address gemini round 2: name CRC variant correctly, tolerate b64 whitespace, faster hex decode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CRC ist CRC-16/XMODEM (init=0), nicht CCITT-FALSE (init=0xFFFF). Mein vorheriger Kommentar war fehlbenannt — Gemini ist auf die Bezeichnung reingefallen und hat init=0xFFFF empfohlen. Empirisch via Labelary bestätigt: XMODEM-CRC wird akzeptiert, CCITT-FALSE abgelehnt. Umbenannt zu crc16Xmodem. - GF_WRAPPER_RE erlaubt jetzt \s im base64-Anteil; parseGfWrapper stripped Whitespace vor atob/CRC. Real-world ZPL bricht große Payloads auf ~80 Char/Zeile um. Neuer Test deckt das ab. - gfaHexToBytes nutzt jetzt Index-Zugriff + Nibble-Shift statt parseInt über Slices — wegen `noUncheckedIndexedAccess` mit ?? '0'-Fallback. - Veralteten Kommentar zu :Z64:-Limit aus dem GF-Handler entfernt. --- src/lib/zplParser.test.ts | 17 ++++++++++++++--- src/lib/zplParser.ts | 35 +++++++++++++++++++++++------------ 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/src/lib/zplParser.test.ts b/src/lib/zplParser.test.ts index 1be6299a..6e8eee24 100644 --- a/src/lib/zplParser.test.ts +++ b/src/lib/zplParser.test.ts @@ -3,9 +3,10 @@ import { zlibSync } from 'fflate'; import { parseZPL } from './zplParser'; import { props } from '../test/helpers'; -/** CRC-16/CCITT-FALSE — same variant used by the parser to validate - * :B64:/:Z64: wrappers. Duplicated here so tests can build valid CRC - * values without exporting the parser's internal helper. */ +/** CRC-16/XMODEM — same variant used by the parser to validate + * :B64:/:Z64: wrappers (poly 0x1021, init 0x0000). Duplicated here so + * tests can build valid CRC values without exporting the parser's + * internal helper. */ function testCrc16(s: string): string { let crc = 0; for (const ch of s) { @@ -599,6 +600,16 @@ describe('parseZPL — ^GFA graphic field', () => { } }); + it('tolerates embedded whitespace inside a :B64: base64 payload', () => { + // ZPL generators often line-break long base64 blocks every N chars. + // Labelary accepts this; we should too. + const zpl = + '^XA^FO0,0^GFA,8,8,1,:B64:AAAA\n//8AAAA=:DFF8^FS^XZ'; + const { objects, importReport } = parseZPL(zpl, 8); + expect(objects).toHaveLength(1); + expect(importReport.partial).not.toContain('^GF'); + }); + it('tolerates trailing whitespace on wrapped GF payloads', () => { // Real-world ZPL is often line-broken between commands; the tokenizer // preserves the trailing newline on the field body, so the regex needs diff --git a/src/lib/zplParser.ts b/src/lib/zplParser.ts index 18fd7576..16f9531d 100644 --- a/src/lib/zplParser.ts +++ b/src/lib/zplParser.ts @@ -171,10 +171,12 @@ function decodeFH( } /** - * CRC-16/CCITT-FALSE (poly 0x1021, init 0x0000, no reflect, no xorout) — + * CRC-16/XMODEM (poly 0x1021, init 0x0000, no reflect, no xorout) — * Zebra's ZB64/ZB16 wrapper uses this variant. Computed over the base64 * (or hex) payload between the `:B64:`/`:Z64:` prefix and the trailing - * `:CRC` suffix. + * `:CRC` suffix. (Note: this is *not* CRC-16/CCITT-FALSE, which uses + * init=0xFFFF — empirically verified against Labelary: payloads with the + * XMODEM CRC are accepted, CCITT-FALSE CRC is rejected.) */ /** Characters of a `^GF`/`~DY` payload retained in browserLimit/skipped * findings; rest is replaced with an ellipsis so a single multi-KB @@ -186,7 +188,7 @@ const CRC16_MSB_MASK = 0x8000; // 1 << 15 const CRC16_MASK = 0xffff; const BITS_PER_BYTE = 8; -function crc16Ccitt(s: string): number { +function crc16Xmodem(s: string): number { let crc = 0; for (const ch of s) { crc ^= ch.charCodeAt(0) << BITS_PER_BYTE; @@ -211,8 +213,10 @@ interface GfWrapperDecoded { /** CRC-16 emitted as 4 uppercase hex chars in the `:B64:`/`:Z64:` trailer. */ const CRC_HEX_DIGITS = 4; +// \s in the base64 char class tolerates the line-break-every-N-chars +// formatting that some ZPL generators apply to long ^GF payloads. const GF_WRAPPER_RE = new RegExp( - `^:(B64|Z64):([A-Za-z0-9+/=]+):([0-9A-Fa-f]{${CRC_HEX_DIGITS}})$`, + `^:(B64|Z64):([A-Za-z0-9+/=\\s]+):([0-9A-Fa-f]{${CRC_HEX_DIGITS}})$`, ); /** Decode a base64 string to bytes; empty array on malformed input. */ @@ -243,12 +247,15 @@ function base64ToBytes(b64: string): Uint8Array { function parseGfWrapper(payload: string): GfWrapperDecoded | null { const m = GF_WRAPPER_RE.exec(payload.trim()); if (!m) return null; - const b64 = m[2] ?? ""; + // atob and the CRC both fail on embedded whitespace — strip after match + // so the wrapper-form regex above can stay permissive for line-broken + // payloads but the downstream decoders see pure base64. + const b64 = (m[2] ?? "").replace(/\s/g, ""); const declaredCrc = parseInt(m[3] ?? "0", 16); return { kind: (m[1] ?? "").toLowerCase() as GfWrapperKind, bytes: base64ToBytes(b64), - crcOk: crc16Ccitt(b64) === declaredCrc, + crcOk: crc16Xmodem(b64) === declaredCrc, }; } @@ -273,11 +280,15 @@ function tryInflateZlib(input: Uint8Array): Uint8Array | null { } /** Decode the ASCII-hex output of `decompressGFA` into a packed byte array - * so all three GF code paths converge on the same `Uint8Array` shape. */ + * so all three GF code paths converge on the same `Uint8Array` shape. + * Indexed access + nibble shift instead of `parseInt(slice)` because the + * per-byte slice/parseInt pair is the dominant cost on multi-KB bitmaps. */ function gfaHexToBytes(hex: string): Uint8Array { const out = new Uint8Array(hex.length >> 1); for (let i = 0; i < out.length; i++) { - out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + const hi = parseInt(hex[i * 2] ?? "0", 16); + const lo = parseInt(hex[i * 2 + 1] ?? "0", 16); + out[i] = (hi << 4) | lo; } return out; } @@ -1245,10 +1256,10 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { // // Payload variants the parser understands: // - format=A + raw hex (optionally with G-Y/g-z/!/,/: RLE) - // - any format + `:B64::` wrapper (decoded → hex) - // `:Z64:` (zlib-compressed) is recognised but not yet decodable in the - // browser without an async refactor or a sync inflate dep; surfaced as - // `partial` so the import doesn't silently lose the field. + // - any format + `:B64::` wrapper (base64-decoded) + // - any format + `:Z64::` wrapper (zlib-inflated via + // fflate). CRC mismatch → partial finding (printers tolerate), + // inflate failure → browserLimit (payload unrecoverable). const format = rest[0]?.toUpperCase(); if (format !== "A" && format !== "B" && format !== "C") { skipped.push(`^GF${rest}`); From 81c5e3295a473f24644ca06cde0b2946c841de99 Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 21 May 2026 21:13:32 +0200 Subject: [PATCH 9/9] address gemini round 3: preserve original GF byte counts, tidy CRC naming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _gfaCache übernimmt totalBytes/dataBytes aus den Original-Params statt beide auf gfBytes.length zu setzen. Bei :Z64: sind die zwei Werte unterschiedlich (uncompressed vs. on-wire), und die Firmware nutzt den zweiten für die Buffer-Allocation beim Re-Import. - crc16Xmodem-JSDoc neben die Funktion gezogen (saß deplatziert vor einer Konstante). - parseGfWrapper-JSDoc sagte noch 'CRC-16/CCITT-FALSE' — das war genau der Stolperdraht, der Geminis fehlerhafte HIGH-Suggestion ausgelöst hat. Auf XMODEM korrigiert. - CRC16_CCITT_POLY → CRC16_POLY: das Polynom 0x1021 gehört zu mehreren CRC-Varianten; kontextfreier Name verhindert die nächste Verwirrung. Andere Gemini-Findings aus dieser Runde übersprungen: die meisten zitieren altes Code-Snippets (Stale-Cache im Review-Bot), das HIGH-Finding zur CRC-init wurde empirisch via Labelary widerlegt (XMODEM-CRC akzeptiert, CCITT-FALSE abgelehnt), LUT-Optimierungen ohne Messung als Beleg sind spekulativ. --- src/lib/zplParser.ts | 43 +++++++++++++++++++++++++------------------ 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/src/lib/zplParser.ts b/src/lib/zplParser.ts index 16f9531d..5bb875a2 100644 --- a/src/lib/zplParser.ts +++ b/src/lib/zplParser.ts @@ -170,31 +170,31 @@ function decodeFH( }); } -/** - * CRC-16/XMODEM (poly 0x1021, init 0x0000, no reflect, no xorout) — - * Zebra's ZB64/ZB16 wrapper uses this variant. Computed over the base64 - * (or hex) payload between the `:B64:`/`:Z64:` prefix and the trailing - * `:CRC` suffix. (Note: this is *not* CRC-16/CCITT-FALSE, which uses - * init=0xFFFF — empirically verified against Labelary: payloads with the - * XMODEM CRC are accepted, CCITT-FALSE CRC is rejected.) - */ /** Characters of a `^GF`/`~DY` payload retained in browserLimit/skipped * findings; rest is replaced with an ellipsis so a single multi-KB * base64 blob doesn't drown out the import report. */ const IMPORT_FINDING_PAYLOAD_LIMIT = 80; -const CRC16_CCITT_POLY = 0x1021; +const CRC16_POLY = 0x1021; const CRC16_MSB_MASK = 0x8000; // 1 << 15 const CRC16_MASK = 0xffff; const BITS_PER_BYTE = 8; +/** + * CRC-16/XMODEM (poly 0x1021, init 0x0000, no reflect, no xorout) — + * Zebra's ZB64/ZB16 wrapper uses this variant. Computed over the base64 + * (or hex) payload between the `:B64:`/`:Z64:` prefix and the trailing + * `:CRC` suffix. (Note: this is *not* CRC-16/CCITT-FALSE, which uses + * init=0xFFFF — empirically verified against Labelary: payloads with the + * XMODEM CRC are accepted, CCITT-FALSE CRC is rejected.) + */ function crc16Xmodem(s: string): number { let crc = 0; for (const ch of s) { crc ^= ch.charCodeAt(0) << BITS_PER_BYTE; for (let j = 0; j < BITS_PER_BYTE; j++) { crc = (crc & CRC16_MSB_MASK) - ? ((crc << 1) ^ CRC16_CCITT_POLY) & CRC16_MASK + ? ((crc << 1) ^ CRC16_POLY) & CRC16_MASK : (crc << 1) & CRC16_MASK; } } @@ -236,9 +236,9 @@ function base64ToBytes(b64: string): Uint8Array { * Parse a `:B64::` or `:Z64::` wrapper. Returns * null if the payload doesn't carry a wrapper. Used inside `^GFA`/`^GFB`/ * `^GFC` where Zebra firmware accepts the same envelope. The CRC is - * computed over the base64 string (CRC-16/CCITT-FALSE) and surfaced as a - * flag rather than a hard reject — printers tolerate mismatches, and - * we'd rather render a slightly-suspect graphic than silently drop it. + * computed over the base64 string (CRC-16/XMODEM) and surfaced as a flag + * rather than a hard reject — printers tolerate mismatches, and we'd + * rather render a slightly-suspect graphic than silently drop it. * * `payload.trim()` because real-world ZPL is often line-broken between * commands; the tokenizer keeps the trailing newline on `rest`, and an @@ -1347,11 +1347,18 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { height: gfHeightDots, }); - // Store original compressed data for lossless re-export. Keep the - // source format letter (A/B/C) because Labelary and Zebra firmware - // reject e.g. `^GFA,…,:Z64:…` — `:Z64:` is canonical for `^GFC` - // only, so a hard-coded `^GFA` on the round-trip would corrupt it. - const gfaCache = `^GF${format},${gfBytes.length},${gfBytes.length},${gfBytesPerRow},${gfRawData}`; + // Store original compressed data for lossless re-export. Preserve + // the source format letter (A/B/C) *and* the two original byte + // counts: for compressed payloads (^GFC/:Z64:) the 2nd param is the + // uncompressed total and the 3rd is the on-wire ("bytes to follow") + // size; firmware uses the latter for input-buffer allocation, so + // collapsing both to `gfBytes.length` would mis-allocate on + // re-import. Falling back to `gfBytes.length` only if the parser + // didn't see the original (defensive — every well-formed ^GF has + // them). + const gfTotalBytes = gfParams[0] ?? String(gfBytes.length); + const gfDataBytes = gfParams[1] ?? String(gfBytes.length); + const gfaCache = `^GF${format},${gfTotalBytes},${gfDataBytes},${gfBytesPerRow},${gfRawData}`; const posType: "FT" | "FO" = positionIsFT ? "FT" : "FO"; objects.push(