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 ecfd281f..6e8eee24 100644 --- a/src/lib/zplParser.test.ts +++ b/src/lib/zplParser.test.ts @@ -1,7 +1,31 @@ import { describe, it, expect, beforeAll } from 'vitest'; +import { zlibSync } from 'fflate'; import { parseZPL } from './zplParser'; import { props } from '../test/helpers'; +/** 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) { + 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', () => { @@ -543,6 +567,89 @@ 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('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 + // 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]); + 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,${field}^FS^XZ`, + 8, + ); + expect(objects).toHaveLength(0); + expect(importReport.browserLimit.some((s) => s.startsWith('^GF'))).toBe(true); + }); + 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..5bb875a2 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"; @@ -169,6 +170,157 @@ function decodeFH( }); } +/** 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_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_POLY) & CRC16_MASK + : (crc << 1) & CRC16_MASK; + } + } + return crc; +} + +type GfWrapperKind = "b64" | "z64"; + +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; +} + +/** 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+/=\\s]+):([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; +} + +/** + * 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/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 + * 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.trim()); + if (!m) return null; + // 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: crc16Xmodem(b64) === declaredCrc, + }; +} + +/** + * 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 { + data: Uint8Array; + crcOk: boolean; +} + +/** Inflate `:Z64:` zlib payload; null on malformed deflate stream. */ +function tryInflateZlib(input: Uint8Array): Uint8Array | null { + try { + return unzlibSync(input); + } catch { + return 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. + * 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++) { + const hi = parseInt(hex[i * 2] ?? "0", 16); + const lo = parseInt(hex[i * 2 + 1] ?? "0", 16); + out[i] = (hi << 4) | lo; + } + 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, +): GfPayloadDecoded | null { + const wrapper = parseGfWrapper(rawData); + if (wrapper) { + const bytes = + wrapper.kind === "z64" ? tryInflateZlib(wrapper.bytes) : wrapper.bytes; + if (!bytes) return null; + return { data: bytes, crcOk: wrapper.crcOk }; + } + if (format === "A") { + return { data: gfaHexToBytes(decompressGFA(rawData, bytesPerRow)), crcOk: true }; + } + return null; +} + /** * Decompress ZPL Alternative Data Compression used in ^GFA fields. * @@ -1100,10 +1252,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 (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") { - // 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,18 +1289,29 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { return; } - // Decompress ZPL Alternative Data Compression for ^GFA - const gfHex = decompressGFA(gfRawData, 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. + 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 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; @@ -1153,8 +1322,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; @@ -1179,8 +1347,18 @@ 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. 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(