Skip to content

Commit c43145e

Browse files
authored
Merge pull request #44 from u8array/fix/code128-zpl-escapes
fix(code128): translate ZPL >X escape sequences for canvas render
2 parents 7c3caf8 + eac4d28 commit c43145e

2 files changed

Lines changed: 142 additions & 1 deletion

File tree

‎src/components/Canvas/bwipHelpers.test.ts‎

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
22
import { readFileSync } from "node:fs";
33
import { fileURLToPath } from "node:url";
44
import { dirname, resolve } from "node:path";
5-
import { buildBwipOptions, getDisplaySize, getEanUpcLayout } from "./bwipHelpers";
5+
import { buildBwipOptions, getDisplaySize, getEanUpcLayout, parseZplCode128Escapes } from "./bwipHelpers";
66
import type { LabelObject } from "../../registry";
77

88
describe("getEanUpcLayout", () => {
@@ -217,3 +217,88 @@ describe("getDisplaySize coverage (ZPL-first policy)", () => {
217217
expect(missing, `Missing explicit case for: ${missing.join(", ")}`).toEqual([]);
218218
});
219219
});
220+
221+
describe("parseZplCode128Escapes", () => {
222+
it("returns null for plain ASCII (no escape sequences)", () => {
223+
expect(parseZplCode128Escapes("ABC123")).toBeNull();
224+
expect(parseZplCode128Escapes("")).toBeNull();
225+
});
226+
227+
it("translates >5 to FNC1", () => {
228+
expect(parseZplCode128Escapes("AB>5CD")).toBe("AB^FNC1CD");
229+
});
230+
231+
it("translates >9 to FNC1 only at the start of the field (per ZPL spec)", () => {
232+
expect(parseZplCode128Escapes(">91234")).toBe("^FNC11234");
233+
// Mid-string >9 is just a Code-C invocation; bwip auto-mode picks C
234+
// for the digit run, so we drop the escape entirely.
235+
expect(parseZplCode128Escapes("A>9123")).toBe("A123");
236+
});
237+
238+
it("translates >6/>7/>8 to FNC2/FNC3/FNC4", () => {
239+
expect(parseZplCode128Escapes("A>6B")).toBe("A^FNC2B");
240+
expect(parseZplCode128Escapes("A>7B")).toBe("A^FNC3B");
241+
expect(parseZplCode128Escapes("A>8B")).toBe("A^FNC4B");
242+
});
243+
244+
it("translates >0 to a literal `>`", () => {
245+
expect(parseZplCode128Escapes("A>0B>5")).toBe("A>B^FNC1");
246+
});
247+
248+
it("drops >: and >; (subset switches — bwip auto-mode picks the subset)", () => {
249+
expect(parseZplCode128Escapes("A>:B>;C>5")).toBe("ABC^FNC1");
250+
});
251+
252+
it("doubles literal `^` so bwip parsefnc does not treat it as an escape", () => {
253+
expect(parseZplCode128Escapes("A^B>5")).toBe("A^^B^FNC1");
254+
});
255+
256+
it("leaves trailing `>` and unknown `>X` literal — matches firmware behaviour", () => {
257+
expect(parseZplCode128Escapes("abc>")).toBeNull();
258+
// `>z` is not a defined ZPL escape; Zebra treats it as literal `>z`.
259+
expect(parseZplCode128Escapes("a>z>5")).toBe("a>z^FNC1");
260+
});
261+
262+
it("handles the reported case STRSTR>5… with auto Code-C compaction", () => {
263+
// Without translation: 21 raw Subset-B symbols.
264+
// After translation: bwip sees STRSTR + FNC1 + 16 digits, auto-switches
265+
// to Code C for the digit run → ~15 data symbols, matching firmware.
266+
expect(parseZplCode128Escapes("STRSTR>52316094000242201"))
267+
.toBe("STRSTR^FNC12316094000242201");
268+
});
269+
});
270+
271+
describe("buildBwipOptions code128 escape handling", () => {
272+
const code128 = (content: string): LabelObject =>
273+
({
274+
id: "1",
275+
type: "code128",
276+
x: 0,
277+
y: 0,
278+
rotation: 0,
279+
props: {
280+
content,
281+
height: 100,
282+
moduleWidth: 2,
283+
printInterpretation: false,
284+
checkDigit: false,
285+
rotation: "N",
286+
},
287+
}) as LabelObject;
288+
289+
it("uses raw Subset-B mode for plain ASCII content (existing behaviour)", () => {
290+
const opts = buildBwipOptions(code128("ABC123"), 1, 8);
291+
expect(opts?.raw).toBe(true);
292+
expect(opts?.parsefnc).toBeUndefined();
293+
expect(typeof opts?.text).toBe("string");
294+
expect((opts?.text as string).startsWith("^104")).toBe(true);
295+
});
296+
297+
it("switches to parsefnc auto-mode when ZPL escape sequences are present", () => {
298+
const opts = buildBwipOptions(code128("STRSTR>52316094000242201"), 1, 8);
299+
expect(opts?.parsefnc).toBe(true);
300+
expect(opts?.raw).toBeUndefined();
301+
expect(opts?.text).toBe("STRSTR^FNC12316094000242201");
302+
});
303+
});
304+

‎src/components/Canvas/bwipHelpers.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,53 @@ export function toCode128BRaw(text: string): string | null {
241241
return parts.join("");
242242
}
243243

244+
/**
245+
* Translate ZPL ^BC field-data escape sequences (`>X`) into bwip-js parsefnc
246+
* syntax. Returns null when no recognized escape is present so the caller can
247+
* stay on the existing raw-Subset-B path.
248+
*
249+
* ZPL Code 128 escapes (Zebra ZPL II Programming Guide):
250+
* >0 → literal `>`
251+
* >5 → FNC1
252+
* >6 → FNC2
253+
* >7 → FNC3
254+
* >8 → FNC4
255+
* >9 → invoke Code C; inserts FNC1 only when it is the first character
256+
* of the field (per ZPL II manual). Mid-string `>9` is just a
257+
* subset switch which bwip auto-mode handles for digit runs.
258+
* >: → switch to Subset B (dropped; bwip auto-mode chooses the subset)
259+
* >; → switch to Subset A (dropped; bwip auto-mode chooses the subset)
260+
*
261+
* Without this translation, `STRSTR>52316094000242201` is rendered as 21 raw
262+
* Subset-B symbols, while the firmware reads `>5` as FNC1 and switches to
263+
* Subset C for the 16 trailing digits — yielding ~15 symbols and a much
264+
* narrower bbox. The mismatch is what users observe as "Länge stimmt nicht".
265+
*/
266+
export function parseZplCode128Escapes(text: string): string | null {
267+
if (!/>[05-9:;]/.test(text)) return null;
268+
let out = "";
269+
for (let i = 0; i < text.length; i++) {
270+
const ch = text[i];
271+
// bwip parsefnc treats `^` as escape char; double it for a literal `^`.
272+
if (ch === "^") { out += "^^"; continue; }
273+
if (ch === ">" && i + 1 < text.length) {
274+
const next = text[i + 1];
275+
switch (next) {
276+
case "0": out += ">"; i++; continue;
277+
case "5": out += "^FNC1"; i++; continue;
278+
case "6": out += "^FNC2"; i++; continue;
279+
case "7": out += "^FNC3"; i++; continue;
280+
case "8": out += "^FNC4"; i++; continue;
281+
case "9": if (i === 0) out += "^FNC1"; i++; continue;
282+
case ":":
283+
case ";": i++; continue; // subset switch — bwip auto-mode handles it
284+
}
285+
}
286+
out += ch;
287+
}
288+
return out;
289+
}
290+
244291
export function buildBwipOptions(
245292
obj: LabelObject,
246293
renderScale?: number,
@@ -280,6 +327,15 @@ export function buildBwipOptions(
280327
const text = p.content || "0";
281328
// Note: ZPL ^BC e=Y (checkDigit) only prints the MOD-10 digit in the
282329
// interpretation line — it does NOT append it to the encoded barcode data.
330+
// ZPL escape sequences (e.g. `>5` for FNC1, `>9` for Code-C switch with
331+
// FNC1) require parsefnc auto-mode so bwip emits the same compact symbol
332+
// count Zebra firmware does. Plain ASCII falls through to the raw Code B
333+
// path which keeps the existing module count behaviour unchanged.
334+
const escaped = parseZplCode128Escapes(text);
335+
if (escaped !== null) {
336+
opts = { bcid, text: escaped, parsefnc: true, scale, height: 10 };
337+
break;
338+
}
283339
const rawB = toCode128BRaw(text);
284340
if (rawB) {
285341
opts = { bcid, text: rawB, raw: true, scale, height: 10 };

0 commit comments

Comments
 (0)