From b80e0c2c38b4543008fee5c057cdd7bed348f740 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 10 May 2026 09:06:49 +0200 Subject: [PATCH 1/2] fix(code128): translate ZPL >X escape sequences for canvas render Without translation, '>5' was encoded as two literal Subset-B symbols, so content like 'STRSTR>52316094000242201' rendered ~21 symbols on canvas while Zebra firmware reads '>5' as FNC1, switches to Code C for the trailing digit run and prints ~15 symbols. Result: canvas bbox markedly wider than the printed barcode. parseZplCode128Escapes converts >0/>5-8/>9 to bwip-js parsefnc syntax and drops >: />; subset switches (bwip auto-mode picks the subset). Plain ASCII content stays on the existing toCode128BRaw raw path. --- src/components/Canvas/bwipHelpers.test.ts | 78 ++++++++++++++++++++++- src/components/Canvas/bwipHelpers.ts | 55 ++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/src/components/Canvas/bwipHelpers.test.ts b/src/components/Canvas/bwipHelpers.test.ts index c75fe74..b3d5808 100644 --- a/src/components/Canvas/bwipHelpers.test.ts +++ b/src/components/Canvas/bwipHelpers.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; -import { buildBwipOptions, getDisplaySize, getEanUpcLayout } from "./bwipHelpers"; +import { buildBwipOptions, getDisplaySize, getEanUpcLayout, parseZplCode128Escapes } from "./bwipHelpers"; import type { LabelObject } from "../../registry"; describe("getEanUpcLayout", () => { @@ -217,3 +217,79 @@ describe("getDisplaySize coverage (ZPL-first policy)", () => { expect(missing, `Missing explicit case for: ${missing.join(", ")}`).toEqual([]); }); }); + +describe("parseZplCode128Escapes", () => { + it("returns null for plain ASCII (no escape sequences)", () => { + expect(parseZplCode128Escapes("ABC123")).toBeNull(); + expect(parseZplCode128Escapes("")).toBeNull(); + }); + + it("translates >5 to FNC1", () => { + expect(parseZplCode128Escapes("AB>5CD")).toBe("AB^FNC1CD"); + }); + + it("translates >9 (Code C + FNC1) to FNC1 — bwip auto-mode handles the C switch", () => { + expect(parseZplCode128Escapes(">91234")).toBe("^FNC11234"); + }); + + it("translates >6/>7/>8 to FNC2/FNC3/FNC4", () => { + expect(parseZplCode128Escapes("A>6B")).toBe("A^FNC2B"); + expect(parseZplCode128Escapes("A>7B")).toBe("A^FNC3B"); + expect(parseZplCode128Escapes("A>8B")).toBe("A^FNC4B"); + }); + + it("translates >0 to a literal `>`", () => { + expect(parseZplCode128Escapes("A>0B>5")).toBe("A>B^FNC1"); + }); + + it("drops >: and >; (subset switches — bwip auto-mode picks the subset)", () => { + expect(parseZplCode128Escapes("A>:B>;C>5")).toBe("ABC^FNC1"); + }); + + it("doubles literal `^` so bwip parsefnc does not treat it as an escape", () => { + expect(parseZplCode128Escapes("A^B>5")).toBe("A^^B^FNC1"); + }); + + it("handles the reported case STRSTR>5… with auto Code-C compaction", () => { + // Without translation: 21 raw Subset-B symbols. + // After translation: bwip sees STRSTR + FNC1 + 16 digits, auto-switches + // to Code C for the digit run → ~15 data symbols, matching firmware. + expect(parseZplCode128Escapes("STRSTR>52316094000242201")) + .toBe("STRSTR^FNC12316094000242201"); + }); +}); + +describe("buildBwipOptions code128 escape handling", () => { + const code128 = (content: string): LabelObject => + ({ + id: "1", + type: "code128", + x: 0, + y: 0, + rotation: 0, + props: { + content, + height: 100, + moduleWidth: 2, + printInterpretation: false, + checkDigit: false, + rotation: "N", + }, + }) as LabelObject; + + it("uses raw Subset-B mode for plain ASCII content (existing behaviour)", () => { + const opts = buildBwipOptions(code128("ABC123"), 1, 8); + expect(opts?.raw).toBe(true); + expect(opts?.parsefnc).toBeUndefined(); + expect(typeof opts?.text).toBe("string"); + expect((opts?.text as string).startsWith("^104")).toBe(true); + }); + + it("switches to parsefnc auto-mode when ZPL escape sequences are present", () => { + const opts = buildBwipOptions(code128("STRSTR>52316094000242201"), 1, 8); + expect(opts?.parsefnc).toBe(true); + expect(opts?.raw).toBeUndefined(); + expect(opts?.text).toBe("STRSTR^FNC12316094000242201"); + }); +}); + diff --git a/src/components/Canvas/bwipHelpers.ts b/src/components/Canvas/bwipHelpers.ts index c236140..3e2c9ac 100644 --- a/src/components/Canvas/bwipHelpers.ts +++ b/src/components/Canvas/bwipHelpers.ts @@ -241,6 +241,52 @@ export function toCode128BRaw(text: string): string | null { return parts.join(""); } +/** + * Translate ZPL ^BC field-data escape sequences (`>X`) into bwip-js parsefnc + * syntax. Returns null when no recognized escape is present so the caller can + * stay on the existing raw-Subset-B path. + * + * ZPL Code 128 escapes (Zebra ZPL II Programming Guide): + * >0 → literal `>` + * >5 → FNC1 + * >6 → FNC2 + * >7 → FNC3 + * >8 → FNC4 + * >9 → invoke Code C with FNC1 (bwip auto-mode switches to C for digit runs, + * so FNC1 alone is a sufficient translation) + * >: → switch to Subset B (dropped; bwip auto-mode chooses the subset) + * >; → switch to Subset A (dropped; bwip auto-mode chooses the subset) + * + * Without this translation, `STRSTR>52316094000242201` is rendered as 21 raw + * Subset-B symbols, while the firmware reads `>5` as FNC1 and switches to + * Subset C for the 16 trailing digits — yielding ~15 symbols and a much + * narrower bbox. The mismatch is what users observe as "Länge stimmt nicht". + */ +export function parseZplCode128Escapes(text: string): string | null { + if (!/>[05-9:;]/.test(text)) return null; + let out = ""; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + // bwip parsefnc treats `^` as escape char; double it for a literal `^`. + if (ch === "^") { out += "^^"; continue; } + if (ch === ">" && i + 1 < text.length) { + const next = text[i + 1]; + switch (next) { + case "0": out += ">"; i++; continue; + case "5": + case "9": out += "^FNC1"; i++; continue; + case "6": out += "^FNC2"; i++; continue; + case "7": out += "^FNC3"; i++; continue; + case "8": out += "^FNC4"; i++; continue; + case ":": + case ";": i++; continue; // subset switch — bwip auto-mode handles it + } + } + out += ch; + } + return out; +} + export function buildBwipOptions( obj: LabelObject, renderScale?: number, @@ -280,6 +326,15 @@ export function buildBwipOptions( const text = p.content || "0"; // Note: ZPL ^BC e=Y (checkDigit) only prints the MOD-10 digit in the // interpretation line — it does NOT append it to the encoded barcode data. + // ZPL escape sequences (e.g. `>5` for FNC1, `>9` for Code-C switch with + // FNC1) require parsefnc auto-mode so bwip emits the same compact symbol + // count Zebra firmware does. Plain ASCII falls through to the raw Code B + // path which keeps the existing module count behaviour unchanged. + const escaped = parseZplCode128Escapes(text); + if (escaped !== null) { + opts = { bcid, text: escaped, parsefnc: true, scale, height: 10 }; + break; + } const rawB = toCode128BRaw(text); if (rawB) { opts = { bcid, text: rawB, raw: true, scale, height: 10 }; From eac4d28edb1dd64cd7d60d11424194ae1c29dd08 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 10 May 2026 09:22:55 +0200 Subject: [PATCH 2/2] fix(code128): emit FNC1 for >9 only at field start Per ZPL II manual, the >9 invocation char inserts FNC1 only when it is the first character of the field data. Mid-string >9 is just a Subset C invocation, which bwip-js auto-mode handles for digit runs without an explicit FNC1. Reviewer-spotted (gemini-code-assist on PR #44). --- src/components/Canvas/bwipHelpers.test.ts | 11 ++++++++++- src/components/Canvas/bwipHelpers.ts | 9 +++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/components/Canvas/bwipHelpers.test.ts b/src/components/Canvas/bwipHelpers.test.ts index b3d5808..4f28a70 100644 --- a/src/components/Canvas/bwipHelpers.test.ts +++ b/src/components/Canvas/bwipHelpers.test.ts @@ -228,8 +228,11 @@ describe("parseZplCode128Escapes", () => { expect(parseZplCode128Escapes("AB>5CD")).toBe("AB^FNC1CD"); }); - it("translates >9 (Code C + FNC1) to FNC1 — bwip auto-mode handles the C switch", () => { + it("translates >9 to FNC1 only at the start of the field (per ZPL spec)", () => { expect(parseZplCode128Escapes(">91234")).toBe("^FNC11234"); + // Mid-string >9 is just a Code-C invocation; bwip auto-mode picks C + // for the digit run, so we drop the escape entirely. + expect(parseZplCode128Escapes("A>9123")).toBe("A123"); }); it("translates >6/>7/>8 to FNC2/FNC3/FNC4", () => { @@ -250,6 +253,12 @@ describe("parseZplCode128Escapes", () => { expect(parseZplCode128Escapes("A^B>5")).toBe("A^^B^FNC1"); }); + it("leaves trailing `>` and unknown `>X` literal — matches firmware behaviour", () => { + expect(parseZplCode128Escapes("abc>")).toBeNull(); + // `>z` is not a defined ZPL escape; Zebra treats it as literal `>z`. + expect(parseZplCode128Escapes("a>z>5")).toBe("a>z^FNC1"); + }); + it("handles the reported case STRSTR>5… with auto Code-C compaction", () => { // Without translation: 21 raw Subset-B symbols. // After translation: bwip sees STRSTR + FNC1 + 16 digits, auto-switches diff --git a/src/components/Canvas/bwipHelpers.ts b/src/components/Canvas/bwipHelpers.ts index 3e2c9ac..69da339 100644 --- a/src/components/Canvas/bwipHelpers.ts +++ b/src/components/Canvas/bwipHelpers.ts @@ -252,8 +252,9 @@ export function toCode128BRaw(text: string): string | null { * >6 → FNC2 * >7 → FNC3 * >8 → FNC4 - * >9 → invoke Code C with FNC1 (bwip auto-mode switches to C for digit runs, - * so FNC1 alone is a sufficient translation) + * >9 → invoke Code C; inserts FNC1 only when it is the first character + * of the field (per ZPL II manual). Mid-string `>9` is just a + * subset switch which bwip auto-mode handles for digit runs. * >: → switch to Subset B (dropped; bwip auto-mode chooses the subset) * >; → switch to Subset A (dropped; bwip auto-mode chooses the subset) * @@ -273,11 +274,11 @@ export function parseZplCode128Escapes(text: string): string | null { const next = text[i + 1]; switch (next) { case "0": out += ">"; i++; continue; - case "5": - case "9": out += "^FNC1"; i++; continue; + case "5": out += "^FNC1"; i++; continue; case "6": out += "^FNC2"; i++; continue; case "7": out += "^FNC3"; i++; continue; case "8": out += "^FNC4"; i++; continue; + case "9": if (i === 0) out += "^FNC1"; i++; continue; case ":": case ";": i++; continue; // subset switch — bwip auto-mode handles it }