diff --git a/src/components/Canvas/bwipHelpers.test.ts b/src/components/Canvas/bwipHelpers.test.ts index c75fe741..4f28a702 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,88 @@ 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 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", () => { + 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("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 + // 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 c2361400..69da3391 100644 --- a/src/components/Canvas/bwipHelpers.ts +++ b/src/components/Canvas/bwipHelpers.ts @@ -241,6 +241,53 @@ 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; 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) + * + * 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": 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 + } + } + out += ch; + } + return out; +} + export function buildBwipOptions( obj: LabelObject, renderScale?: number, @@ -280,6 +327,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 };