Skip to content

Commit b7d1026

Browse files
authored
Merge pull request #28 from u8array/fix/utf8-fh-decoding
Fix/utf8 fh decoding
2 parents 3bdf433 + cbf06a9 commit b7d1026

2 files changed

Lines changed: 120 additions & 7 deletions

File tree

‎src/lib/zplParser.test.ts‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,69 @@ describe('parseZPL — ^FH hex escape', () => {
257257
const { objects } = parseZPL('^XA^FH_^FO0,0^A0N,30,0^FD_41BC^FS^XZ', 8);
258258
expect(props(objects[0]).content).toBe('ABC');
259259
});
260+
261+
it('decodes UTF-8 multibyte escapes (German umlauts)', () => {
262+
// _C3_A4 = ä, _C3_B6 = ö, _C3_BC = ü
263+
const { objects } = parseZPL('^XA^FH_^FO0,0^A0N,30,0^FD_C3_A4_C3_B6_C3_BC^FS^XZ', 8);
264+
expect(props(objects[0]).content).toBe('äöü');
265+
});
266+
267+
it('decodes UTF-8 multibyte escapes (Nordic)', () => {
268+
// _C3_A6 = æ, _C3_B8 = ø, _C3_A5 = å
269+
const { objects } = parseZPL('^XA^FH_^FO0,0^A0N,30,0^FD_C3_A6_C3_B8_C3_A5^FS^XZ', 8);
270+
expect(props(objects[0]).content).toBe('æøå');
271+
});
272+
273+
it('decodes 3-byte UTF-8 escapes (Euro sign)', () => {
274+
// _E2_82_AC = €
275+
const { objects } = parseZPL('^XA^FH_^FO0,0^A0N,30,0^FD_E2_82_AC^FS^XZ', 8);
276+
expect(props(objects[0]).content).toBe('€');
277+
});
278+
279+
it('decodes mixed ASCII and UTF-8 escapes in one field', () => {
280+
// _48 = H, _69 = i, then ä
281+
const { objects } = parseZPL('^XA^FH_^FO0,0^A0N,30,0^FD_48_69 _C3_A4^FS^XZ', 8);
282+
expect(props(objects[0]).content).toBe('Hi ä');
283+
});
284+
285+
it('replaces invalid UTF-8 byte sequences with U+FFFD', () => {
286+
// _C3 alone is a truncated 2-byte sequence
287+
const { objects } = parseZPL('^XA^FH_^FO0,0^A0N,30,0^FD_C3^FS^XZ', 8);
288+
expect(props(objects[0]).content).toBe('�');
289+
});
290+
291+
it('decodes ^CI27 (Windows-1252) single-byte escapes', () => {
292+
// _E4 = 0xE4 = ä in CP1252 (in UTF-8 this would be invalid → U+FFFD)
293+
const { objects } = parseZPL('^XA^CI27^FH_^FO0,0^A0N,30,0^FD_E4_F6_FC^FS^XZ', 8);
294+
expect(props(objects[0]).content).toBe('äöü');
295+
});
296+
297+
it('switches encoding mid-label on ^CI', () => {
298+
// first field UTF-8 (default), second field CP1252
299+
const zpl =
300+
'^XA^FH_^FO0,0^A0N,30,0^FD_C3_A4^FS' +
301+
'^CI27^FH_^FO0,50^A0N,30,0^FD_E4^FS^XZ';
302+
const { objects } = parseZPL(zpl, 8);
303+
expect(props(objects[0]).content).toBe('ä');
304+
expect(props(objects[1]).content).toBe('ä');
305+
});
306+
307+
it('reports unsupported ^CI N as partial import', () => {
308+
// ^CI50 is not a real Zebra encoding — falls back to UTF-8 default
309+
const { importReport } = parseZPL('^XA^CI50^FH_^FO0,0^A0N,30,0^FDx^FS^XZ', 8);
310+
expect(importReport.partial).toContain('^CI50');
311+
});
312+
313+
it('resets decoder to UTF-8 default on unsupported ^CI', () => {
314+
// After ^CI27 sets CP1252, an unknown ^CI50 must fall back to UTF-8
315+
// (not keep CP1252) so behaviour is predictable.
316+
const zpl =
317+
'^XA^CI27^FH_^FO0,0^A0N,30,0^FD_E4^FS' +
318+
'^CI50^FH_^FO0,50^A0N,30,0^FD_C3_A4^FS^XZ';
319+
const { objects } = parseZPL(zpl, 8);
320+
expect(props(objects[0]).content).toBe('ä'); // CP1252
321+
expect(props(objects[1]).content).toBe('ä'); // UTF-8 (after reset)
322+
});
260323
});
261324

262325
// ── ^FB field block ───────────────────────────────────────────────────────────

‎src/lib/zplParser.ts‎

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,49 @@ function makeObj(
8989
} as unknown as LabelObject;
9090
}
9191

92-
/** Decode ^FH hex escapes: replaces {delimiter}XX with the character for hex XX */
93-
function decodeFH(text: string, delimiter: string): string {
92+
/**
93+
* Map a ^CI N parameter to a TextDecoder label. Most labels printed by this
94+
* app use ^CI28 (UTF-8); ^CI27 is Windows-1252 (Zebra default for many EU
95+
* setups); legacy ^CI0..13 are 7-bit-ASCII-compatible code-page variants for
96+
* which Windows-1252 is a safe superset for the purposes of `^FH` decoding.
97+
* Unsupported encodings (multi-byte UTF-16/32 variants, code page 850, …)
98+
* fall back to UTF-8 with the command surfaced via importReport.partial.
99+
*/
100+
function ciToEncoding(n: number): { label: string; supported: boolean } {
101+
if (n === 28) return { label: "utf-8", supported: true };
102+
if (n === 27) return { label: "windows-1252", supported: true };
103+
if (n >= 0 && n <= 13) return { label: "windows-1252", supported: true };
104+
return { label: "utf-8", supported: false };
105+
}
106+
107+
const decoderCache = new Map<string, TextDecoder>();
108+
function getDecoder(label: string): TextDecoder {
109+
let dec = decoderCache.get(label);
110+
if (!dec) {
111+
dec = new TextDecoder(label);
112+
decoderCache.set(label, dec);
113+
}
114+
return dec;
115+
}
116+
117+
/**
118+
* Decode ^FH hex escapes: replaces runs of {delimiter}XX with the string for
119+
* the byte sequence XX XX … under the active ^CI encoding. A single non-ASCII
120+
* glyph may span multiple escape pairs (e.g. `_C3_A4` → `ä` under UTF-8), so
121+
* we collect contiguous pairs into a Uint8Array and run one TextDecoder pass
122+
* per run. Invalid byte sequences become U+FFFD (decoder default).
123+
*/
124+
function decodeFH(text: string, delimiter: string, decoder: TextDecoder): string {
94125
const escaped = delimiter.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
95-
return text.replace(new RegExp(`${escaped}([0-9A-Fa-f]{2})`, "g"), (_, hex) =>
96-
String.fromCharCode(parseInt(hex, 16)),
97-
);
126+
const runRe = new RegExp(`(?:${escaped}[0-9A-Fa-f]{2})+`, "g");
127+
const stride = delimiter.length + 2;
128+
return text.replace(runRe, (run) => {
129+
const bytes = new Uint8Array(run.length / stride);
130+
for (let i = 0, b = 0; i < run.length; i += stride, b++) {
131+
bytes[b] = parseInt(run.slice(i + delimiter.length, i + stride), 16);
132+
}
133+
return decoder.decode(bytes);
134+
});
98135
}
99136

100137
/**
@@ -242,6 +279,11 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL {
242279
let fhActive = false;
243280
let fhDelimiter = "_";
244281

282+
// ^CI state (character set / encoding for ^FH byte decoding). Default UTF-8
283+
// matches our generator output; legacy ZPL using ^CI27 / ^CI0..13 sets a
284+
// single-byte decoder before ^FH escapes are processed.
285+
let fhDecoder = getDecoder("utf-8");
286+
245287
// ^FT vs ^FO: store position type so we can reproduce exactly in re-export.
246288
let positionIsFT = false;
247289

@@ -285,7 +327,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL {
285327

286328
const flushField = () => {
287329
if (!fieldType || pendingFD === null) return;
288-
const content = fhActive ? decodeFH(pendingFD, fhDelimiter) : pendingFD;
330+
const content = fhActive ? decodeFH(pendingFD, fhDelimiter, fhDecoder) : pendingFD;
289331
const posType: "FT" | "FO" = positionIsFT ? "FT" : "FO";
290332
const comment = takeComment();
291333

@@ -1170,9 +1212,17 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL {
11701212
// assembled text reaches the next field object as one multi-line comment.
11711213
FX: appendComment,
11721214

1215+
// ^CI N: character set / encoding for ^FH byte decoding. Mapped to a
1216+
// TextDecoder; unsupported variants (UTF-16/32, code page 850) keep the
1217+
// current decoder and surface as a partial import.
1218+
CI: (p) => {
1219+
const enc = ciToEncoding(int(p[0]));
1220+
fhDecoder = getDecoder(enc.label);
1221+
if (!enc.supported) partialCmds.add(`^CI${int(p[0])}`);
1222+
},
1223+
11731224
// These commands carry no canvas-design information and are silently
11741225
// discarded so they do not pollute importReport.unknown.
1175-
CI: noop, // character set encoding (^CI28 = UTF-8 is the browser default)
11761226
FN: noop, // field number — variable data placeholder (template feature)
11771227
FV: noop, // field variable — supplies data for ^FN at print time
11781228
FC: noop, // field clock — inserts date/time (requires printer RTC)

0 commit comments

Comments
 (0)