@@ -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