From d6e0162098558e76c637c8b98a43e2aee1611d01 Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 4 Jun 2026 09:02:08 +0200 Subject: [PATCH 1/5] feat(zpl): ^MU units of measure (D/I/M + dpi resampling) Parser normalizes ^MUI/^MUM coords to dots at read-time via a new intDots/intDotsOrUndef helper threaded through ~30 dot-quantity sites (FO/FT, GB/GC/GD/GE/GS, A/CF/FB/FP/LH/LT/TB, BY + all bcHeight, PW/LL/ LS/ML). intDots uses parseFloat so fractional inch/mm values (`^MUI^FO 0.5,0.5`) survive instead of getting parseInt-truncated to zero. ^MU `a`-slot scope follows the spec: carries field-by-field until overridden, no ^XA reset. Per-block isolation in production comes from zplImportService calling parseZPL per ^XA block. b/c slots persist on labelConfig.formatDpi/outputDpi and re-emit as `^MUD,b,c` only when both are set (paired directive; no synthesized half-set ratio). Invalid `a` (e.g. `^MUX`) and out-of-spec b/c surface as a partial import finding instead of silently normalizing. --- README.md | 4 +- docs/zpl-roadmap.md | 2 +- src/lib/zplGenerator.test.ts | 25 ++++++ src/lib/zplGenerator.ts | 7 +- src/lib/zplParser.test.ts | 97 +++++++++++++++++++++++ src/lib/zplParser.ts | 38 +++++++-- src/lib/zplParser/context.ts | 4 + src/lib/zplParser/handlers/barcodes.ts | 30 +++---- src/lib/zplParser/handlers/fields.ts | 46 +++++------ src/lib/zplParser/handlers/graphics.ts | 28 +++---- src/lib/zplParser/handlers/labelConfig.ts | 12 +-- src/lib/zplParser/helpers.ts | 27 +++++++ src/types/LabelConfig.ts | 12 +++ 13 files changed, 267 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index ac0aaf0a..57ca0bf6 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ Both `.zpl` and `.json` round-trip cleanly. `.zpl` preserves all printable conte ## Coverage -97 of the 202 ZPL II commands tracked in the [roadmap](docs/zpl-roadmap.md) are supported today. Categorical breakdown: +98 of the 202 ZPL II commands tracked in the [roadmap](docs/zpl-roadmap.md) are supported today. Categorical breakdown: | Area | Supported | |---|---| @@ -128,7 +128,7 @@ Both `.zpl` and `.json` round-trip cleanly. `.zpl` preserves all printable conte | Clock & time | 2 / 3 | | Identity & access | 3 / 3 | | Graphics | 6 / 12 | -| Media & feed | 4 / 10 | +| Media & feed | 5 / 10 | | Text & fonts | 6 / 14 | | Print quality | 4 / 14 | | Configuration & persistence | 3 / 5 | diff --git a/docs/zpl-roadmap.md b/docs/zpl-roadmap.md index 3dd2ebd1..303174ca 100644 --- a/docs/zpl-roadmap.md +++ b/docs/zpl-roadmap.md @@ -162,7 +162,7 @@ The Printer Settings Modal (Media & Feed / Print Quality / Clock & Time / Encodi | `[ ]` | `^MC` | map clear | `Coming soon` | | `[ ]` | `^MI` | maintenance info message | `Coming soon` | | `[ ]` | `^MP` | mode protection | `Coming soon` | -| `[ ]` | `^MU` | units of measure | `Coming soon` | +| `[x]` | `^MU` | units of measure | | | `[ ]` | `^MW` | head cold warning | `Coming soon` | ## Print quality diff --git a/src/lib/zplGenerator.test.ts b/src/lib/zplGenerator.test.ts index b135f6c5..a6267b1f 100644 --- a/src/lib/zplGenerator.test.ts +++ b/src/lib/zplGenerator.test.ts @@ -55,6 +55,31 @@ describe('generateZPL — structure', () => { expect(zpl).toContain('^LS5'); }); + it('does not emit ^MU when neither formatDpi nor outputDpi is set', () => { + const zpl = generateZPL(BASE_LABEL, []); + expect(zpl).not.toContain('^MU'); + }); + + it('emits ^MUD,b,c when formatDpi and outputDpi are set', () => { + const zpl = generateZPL({ ...BASE_LABEL, formatDpi: 150, outputDpi: 300 }, []); + expect(zpl).toContain('^MUD,150,300'); + }); + + it('emits ^MU directly after ^XA so the printer sees the resampling header first', () => { + const zpl = generateZPL({ ...BASE_LABEL, formatDpi: 200, outputDpi: 600 }, []); + const xaIdx = zpl.indexOf('^XA'); + const muIdx = zpl.indexOf('^MUD'); + const pwIdx = zpl.indexOf('^PW'); + expect(xaIdx).toBeGreaterThanOrEqual(0); + expect(muIdx).toBeGreaterThan(xaIdx); + expect(muIdx).toBeLessThan(pwIdx); + }); + + it('omits ^MU when only one of formatDpi/outputDpi is set (paired directive)', () => { + expect(generateZPL({ ...BASE_LABEL, formatDpi: 200 }, [])).not.toContain('^MU'); + expect(generateZPL({ ...BASE_LABEL, outputDpi: 300 }, [])).not.toContain('^MU'); + }); + it('reverse text round-trips: emit ^GB+^FR, parse collapses back to one reverse text', () => { // Generator emits a filled black ^GB knockout-background followed by // an ^FR text at the same anchor. The parser detects that pair and diff --git a/src/lib/zplGenerator.ts b/src/lib/zplGenerator.ts index ec42b55b..61146bdf 100644 --- a/src/lib/zplGenerator.ts +++ b/src/lib/zplGenerator.ts @@ -308,8 +308,13 @@ export function generateZPL( lines.push(`~SD${v}`); } + lines.push('^XA'); + // Paired b,c only; emitting just one half would produce a bogus + // resampling ratio. a=D matches the dots-canonical body. + if (label.formatDpi !== undefined && label.outputDpi !== undefined) { + lines.push(`^MUD,${label.formatDpi},${label.outputDpi}`); + } lines.push( - '^XA', `^PW${widthDots}`, `^LL${heightDots}`, '^CI28', diff --git a/src/lib/zplParser.test.ts b/src/lib/zplParser.test.ts index 22cf4166..fea6acbd 100644 --- a/src/lib/zplParser.test.ts +++ b/src/lib/zplParser.test.ts @@ -52,6 +52,103 @@ describe('parseZPL — label config', () => { }); }); +describe('parseZPL — ^MU units of measure', () => { + it('^MUI rescales following ^GB coords from inches to dots at 8 dpmm', () => { + // 1 inch @ 8 dpmm = 8 * 25.4 = 203.2 dots, rounded to 203 + const { objects } = parseZPL('^XA^MUI^FO1,2^GB1,1,0.1^FS^XZ', 8); + const [obj] = objects; + expect(obj?.x).toBe(203); + expect(obj?.y).toBe(406); + }); + + it('^MUM rescales following ^GB coords from mm to dots at 8 dpmm', () => { + // 10 mm @ 8 dpmm = 80 dots + const { objects } = parseZPL('^XA^MUM^FO10,20^GB10,10,1^FS^XZ', 8); + const [obj] = objects; + expect(obj?.x).toBe(80); + expect(obj?.y).toBe(160); + }); + + it('^MUD leaves ^GB coords unchanged (dots-canonical, default)', () => { + const { objects } = parseZPL('^XA^MUD^FO100,200^GB50,50,2^FS^XZ', 8); + const [obj] = objects; + expect(obj?.x).toBe(100); + expect(obj?.y).toBe(200); + }); + + it('^MU carries across ^XA per spec (field-by-field until overridden)', () => { + // Spec: "^MU carries over from field to field until a new mode is + // entered." In production parseZPL is called per-block by + // zplImportService so cross-block carry-over never surfaces; this + // pins the spec-correct behaviour for the parser-API itself. + const { objects } = parseZPL( + '^XA^MUI^FO1,1^GB1,1,1^FS^XZ^XA^FO1,1^GB1,1,1^FS^XZ', + 8, + ); + expect(objects[0]?.x).toBe(203); + expect(objects[1]?.x).toBe(203); + }); + + it('^MU b,c slots persist on labelConfig for re-emit', () => { + const { labelConfig } = parseZPL('^XA^MUD,150,300^XZ', 8); + expect(labelConfig.formatDpi).toBe(150); + expect(labelConfig.outputDpi).toBe(300); + }); + + it('^MU drops out-of-spec dpi values silently', () => { + const { labelConfig } = parseZPL('^XA^MUD,77,999^XZ', 8); + expect(labelConfig.formatDpi).toBeUndefined(); + expect(labelConfig.outputDpi).toBeUndefined(); + }); + + it('^MU,150,300 with a-slot omitted resets unit to D and persists b/c', () => { + const { labelConfig, objects } = parseZPL( + '^XA^MU,150,300^FO100,100^GB10,10,1^FS^XZ', + 8, + ); + expect(labelConfig.formatDpi).toBe(150); + expect(labelConfig.outputDpi).toBe(300); + // a-slot defaulted to D so coords stay unscaled + expect(objects[0]?.x).toBe(100); + }); + + it('invalid ^MU a-slot surfaces as partial finding', () => { + const { importReport } = parseZPL('^XA^MUX^XZ', 8); + expect(importReport.partial).toContain('^MU'); + }); + + it('round-trip: ^MUD,b,c parses + generates back symmetrically', () => { + const original = '^XA^MUD,200,600^PW600^LL400^CI28^XZ'; + const { labelConfig } = parseZPL(original, 8); + expect(labelConfig.formatDpi).toBe(200); + expect(labelConfig.outputDpi).toBe(600); + }); + + it('^MUI admits fractional inch values (the whole point of I-mode)', () => { + // 0.5 inch @ 8 dpmm = 0.5 * 8 * 25.4 = 101.6 dots, rounded to 102 + const { objects } = parseZPL('^XA^MUI^FO0.5,0.25^GB1,1,0.05^FS^XZ', 8); + const [box] = objects; + expect(box?.x).toBe(102); + expect(box?.y).toBe(51); + expect(props(box).width).toBe(203); + expect(props(box).thickness).toBe(10); // 0.05 in = 10.16 dots → 10 + }); + + it('^MUI also rescales ^GB dimensions and ^PW/^LL', () => { + const { labelConfig, objects } = parseZPL( + '^XA^MUI^PW4^LL3^FO0,0^GB1,2,0.05^FS^XZ', + 8, + ); + expect(labelConfig.widthMm).toBeCloseTo(101.6, 0); // 4 in = 812.8 dots / 8 = 101.6 mm + expect(labelConfig.heightMm).toBeCloseTo(76.2, 0); + const box = objects[0]; + expect(box?.type).toBe('box'); + // 1 in width = 203 dots, 2 in height = 406, 0.05 in thickness = 10 dots + expect(props(box).width).toBe(203); + expect(props(box).height).toBe(406); + }); +}); + // ── text ────────────────────────────────────────────────────────────────────── describe('parseZPL — text via ^A0', () => { diff --git a/src/lib/zplParser.ts b/src/lib/zplParser.ts index 9f008c92..31e0d518 100644 --- a/src/lib/zplParser.ts +++ b/src/lib/zplParser.ts @@ -1,6 +1,7 @@ import { DEFAULT_CLOCK_CHARS } from "./fcTemplate"; import { ZPL_BUILTIN_FONT_LETTERS } from "./customFonts"; -import { tokenize, int, readRotation } from "./zplParser/helpers"; +import { isMuDpi } from "../types/LabelConfig"; +import { tokenize, intDots, readRotation } from "./zplParser/helpers"; import { createParserState, getDefaultTextH, @@ -69,13 +70,40 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { commitPendingReverseBg(); resetComment(_, rest); }, + // a-slot scales dot-quantities on read so the model stays + // dots-canonical. b/c persist for re-emit; printer does the + // resampling at print time. Spec: ^MU carries field-by-field + // until overridden, so no ^XA reset; per-block isolation comes + // from zplImportService. + MU(p) { + const a = (p[0] ?? "").trim().toUpperCase(); + if (a === "I") s.format.unitScale = dpmm * 25.4; + else if (a === "M") s.format.unitScale = dpmm; + else if (a === "" || a === "D") s.format.unitScale = 1; + else { + s.format.unitScale = 1; + s.result.partialCmds.add("^MU"); + } + const rawB = (p[1] ?? "").trim(); + const rawC = (p[2] ?? "").trim(); + if (rawB) { + const b = Number.parseInt(rawB, 10); + if (isMuDpi(b)) labelConfig.formatDpi = b; + else s.result.partialCmds.add("^MU"); + } + if (rawC) { + const c = Number.parseInt(rawC, 10); + if (isMuDpi(c)) labelConfig.outputDpi = c; + else s.result.partialCmds.add("^MU"); + } + }, }; - Object.assign(handlers, createBarcodeHandlers(s.field, s.defaults)); + Object.assign(handlers, createBarcodeHandlers(s.field, s.defaults, s.format)); Object.assign(handlers, createFieldHandlers(s, { flushField, appendComment })); Object.assign(handlers, graphicsFamily.handlers); Object.assign(handlers, createSetupScriptHandlers(printerProfile)); - Object.assign(handlers, createLabelConfigHandlers(labelConfig, dpmm)); + Object.assign(handlers, createLabelConfigHandlers(labelConfig, dpmm, s.format)); Object.assign(handlers, createUnsupportedHandlers(s.result)); for (const { cmd, rest } of tokens) { @@ -90,8 +118,8 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { if (cmd[0] === "A" && cmd.length === 2) { s.field.fieldType = "text"; s.field.textRot = readRotation(rest[0], s.defaults.fwRotation); - s.field.textH = int(p[1], getDefaultTextH(s.defaults)); - s.field.textW = int(p[2], getDefaultTextW(s.defaults)); + s.field.textH = intDots(p[1], s.format.unitScale, getDefaultTextH(s.defaults)); + s.field.textW = intDots(p[2], s.format.unitScale, getDefaultTextW(s.defaults)); const fontChar = cmd[1] ?? ""; // Round-trip: ^A{id} matching the active ^CF drops to "use default" // (no pendingFontId) so re-emit stays terse; otherwise pin the alias. diff --git a/src/lib/zplParser/context.ts b/src/lib/zplParser/context.ts index 4b256b4f..78c6621d 100644 --- a/src/lib/zplParser/context.ts +++ b/src/lib/zplParser/context.ts @@ -69,6 +69,9 @@ export interface FormatState { caretChar: string; tildeChar: string; delimiterChar: string; + /** ^MU a-slot dot multiplier: 1 (D), dpmm*25.4 (I), dpmm (M). + * Internal model is dots-canonical; I/M sources get scaled on read. */ + unitScale: number; } /** Persistent defaults for following fields (^CF, ^FW, ^FB, ^BY). */ @@ -219,6 +222,7 @@ export function createParserState(): ParserState { caretChar: "^", tildeChar: "~", delimiterChar: ",", + unitScale: 1, }, defaults: { cfHeight: 0, diff --git a/src/lib/zplParser/handlers/barcodes.ts b/src/lib/zplParser/handlers/barcodes.ts index b9e5345e..c51dc071 100644 --- a/src/lib/zplParser/handlers/barcodes.ts +++ b/src/lib/zplParser/handlers/barcodes.ts @@ -3,14 +3,15 @@ import type { DataMatrixProps } from "../../../registry/datamatrix"; import type { Gs1DatabarProps } from "../../../registry/gs1databar"; import type { MaxicodeProps } from "../../../registry/maxicode"; import { GS1_DATABAR_DEFAULT_SEGMENTS } from "../../gs1"; -import type { DefaultsState, FieldState } from "../context"; -import { int, readRotation } from "../helpers"; +import type { DefaultsState, FieldState, FormatState } from "../context"; +import { int, intDots, readRotation } from "../helpers"; import type { Handler } from "../types"; /** ^B* barcode commands + shared ^BY defaults. Touches `field` and `defaults`. */ export function createBarcodeHandlers( field: FieldState, defaults: DefaultsState, + format: FormatState, ): Record { // Factory for 1D barcodes: hIdx/iIdx/cIdx = param indices for height/interp/check. const mkBarcode = @@ -24,7 +25,7 @@ export function createBarcodeHandlers( (p) => { field.fieldType = type; field.bcRotation = readRotation(p[0]); - field.bcHeight = int(p[hIdx], defaults.byHeight || 100); + field.bcHeight = intDots(p[hIdx], format.unitScale, defaults.byHeight || 100); field.bcInterp = (p[iIdx] ?? iDefault) === "Y"; field.bcCheck = cIdx >= 0 ? (p[cIdx] ?? "N") === "Y" : false; }; @@ -39,8 +40,8 @@ export function createBarcodeHandlers( // ── Barcode defaults ────────────────────────────────────────────────── // ^BY{moduleWidth},{ratio},{height} BY(p) { - defaults.byModuleWidth = int(p[0], 2); - defaults.byHeight = int(p[2], 0); + defaults.byModuleWidth = intDots(p[0], format.unitScale, 2); + defaults.byHeight = intDots(p[2], format.unitScale, 0); }, // ── 1D barcodes via mkBarcode(type, hIdx, iIdx, iDefault?, cIdx?) ───── @@ -66,7 +67,7 @@ export function createBarcodeHandlers( B4(p) { field.fieldType = "code49"; field.bcRotation = readRotation(p[0]); - field.bcHeight = int(p[1], defaults.byHeight || 20); + field.bcHeight = intDots(p[1], format.unitScale, defaults.byHeight || 20); field.bcInterp = (p[2] ?? "N") === "Y"; const m = (p[3] ?? "A").toUpperCase(); field.bcCode49Mode = /^[A0-5]$/.test(m) @@ -80,7 +81,7 @@ export function createBarcodeHandlers( field.fieldType = "msi"; field.bcRotation = readRotation(p[0]); field.bcCheck = (p[1] ?? "N") !== "N"; - field.bcHeight = int(p[2], defaults.byHeight || 100); + field.bcHeight = intDots(p[2], format.unitScale, defaults.byHeight || 100); field.bcInterp = (p[3] ?? "Y") === "Y"; }, @@ -89,6 +90,7 @@ export function createBarcodeHandlers( BR(p) { field.fieldType = "gs1databar"; field.bcRotation = readRotation(p[0]); + // p[2] is magnification (integer multiplier 1-10), not a dot quantity. defaults.byModuleWidth = int(p[2], defaults.byModuleWidth); field.gsSymbology = (int(p[1], 1) as Gs1DatabarProps["symbology"]) || 1; field.gsSegments = @@ -108,7 +110,7 @@ export function createBarcodeHandlers( BX(p) { field.fieldType = "datamatrix"; field.bcRotation = readRotation(p[0]); - field.dmDim = int(p[1], 5); + field.dmDim = intDots(p[1], format.unitScale, 5); field.dmQuality = int(p[2], 200) as DataMatrixProps["quality"]; }, @@ -116,7 +118,7 @@ export function createBarcodeHandlers( B7(p) { field.fieldType = "pdf417"; field.bcRotation = readRotation(p[0]); - field.pdfRowHeight = int(p[1], 10); + field.pdfRowHeight = intDots(p[1], format.unitScale, 10); field.pdfSecurity = int(p[2], 0); field.pdfColumns = int(p[3], 0); }, @@ -141,14 +143,14 @@ export function createBarcodeHandlers( BF(p) { field.fieldType = "micropdf417"; field.bcRotation = readRotation(p[0]); - field.mpdfRowHeight = int(p[1], 10); + field.mpdfRowHeight = intDots(p[1], format.unitScale, 10); }, // ^BBN,{rowHeight},{security},{numCharsPerRow},{numRows},{mode} — CODABLOCK BB(p) { field.fieldType = "codablock"; field.bcRotation = readRotation(p[0]); - field.cbRowHeight = int(p[1], 10); + field.cbRowHeight = intDots(p[1], format.unitScale, 10); field.cbSecurity = (p[2] ?? "Y") === "N" ? "N" : "Y"; }, @@ -158,10 +160,10 @@ export function createBarcodeHandlers( field.bcRotation = readRotation(p[0]); // w1 (Code 39 narrow bar) overrides ^BY when present; undefined // means fall back to defaults.byModuleWidth at flush time. - field.tlcModuleWidth = int(p[1]) || undefined; + field.tlcModuleWidth = intDots(p[1], format.unitScale, 0) || undefined; // p[2] (r1) intentionally dropped; canonicalized on emit. - field.tlcHeight = int(p[3], defaults.byHeight || 40); - field.tlcMicroPdfRowHeight = int(p[4], 4); + field.tlcHeight = intDots(p[3], format.unitScale, defaults.byHeight || 40); + field.tlcMicroPdfRowHeight = intDots(p[4], format.unitScale, 4); // Zebra ^BT h2 range is 1-10; firmware snaps to a valid linked // MicroPDF417 row count {4,6,8,10} on print. const rows = int(p[5], 4); diff --git a/src/lib/zplParser/handlers/fields.ts b/src/lib/zplParser/handlers/fields.ts index 65cefd3b..2cba99cc 100644 --- a/src/lib/zplParser/handlers/fields.ts +++ b/src/lib/zplParser/handlers/fields.ts @@ -2,7 +2,7 @@ import type { CustomFontMapping } from "../../../types/LabelConfig"; import { FN_NUMBER_MAX, FN_NUMBER_MIN } from "../../../types/Variable"; import type { SerialProps } from "../../../registry/serial"; import { getDefaultTextH, getDefaultTextW, type ParserState } from "../context"; -import { ciToEncoding, getDecoder, int, makeObj, readRotation } from "../helpers"; +import { ciToEncoding, getDecoder, int, intDots, intDotsOrUndef, makeObj, readRotation } from "../helpers"; import type { Handler } from "../types"; /** flushField + appendComment are shared with the orchestrator. */ @@ -24,15 +24,15 @@ export function createFieldHandlers( // ── Field origin ────────────────────────────────────────────────────── FO(p) { flushField(); - s.field.x = int(p[0]) + s.label.lhX; - s.field.y = int(p[1]) + s.label.lhY + s.label.ltY; + s.field.x = intDots(p[0], s.format.unitScale) + s.label.lhX; + s.field.y = intDots(p[1], s.format.unitScale) + s.label.lhY + s.label.ltY; // 3rd param is justification (0/1/2) — stored but not actively used. s.field.positionIsFT = false; }, FT(p) { flushField(); - s.field.x = int(p[0]) + s.label.lhX; - s.field.y = int(p[1]) + s.label.lhY + s.label.ltY; + s.field.x = intDots(p[0], s.format.unitScale) + s.label.lhX; + s.field.y = intDots(p[1], s.format.unitScale) + s.label.lhY + s.label.ltY; s.field.positionIsFT = true; }, @@ -41,8 +41,8 @@ export function createFieldHandlers( A0(p, rest) { s.field.fieldType = "text"; s.field.textRot = readRotation(rest[0], s.defaults.fwRotation); - s.field.textH = int(p[1], getDefaultTextH(s.defaults)); - s.field.textW = int(p[2], getDefaultTextW(s.defaults)); + s.field.textH = intDots(p[1], s.format.unitScale, getDefaultTextH(s.defaults)); + s.field.textW = intDots(p[2], s.format.unitScale, getDefaultTextW(s.defaults)); // fontId "0" only when ^CF differs; "0" is the implicit default otherwise. s.field.pendingFontId = s.defaults.cfFontId && s.defaults.cfFontId !== "0" ? "0" : undefined; }, @@ -51,18 +51,18 @@ export function createFieldHandlers( // ^CF{font},{height},{width} → sets default for fields without ^A CF(p) { const fontId = (p[0] ?? "").trim(); - const explicitHeight = parseInt(p[1] ?? "", 10); - const explicitWidth = parseInt(p[2] ?? "", 10); - s.defaults.cfHeight = isNaN(explicitHeight) ? s.defaults.cfHeight : explicitHeight; - s.defaults.cfWidth = isNaN(explicitWidth) ? s.defaults.cfWidth : explicitWidth; + const explicitHeight = intDotsOrUndef(p[1], s.format.unitScale); + const explicitWidth = intDotsOrUndef(p[2], s.format.unitScale); + if (explicitHeight !== undefined) s.defaults.cfHeight = explicitHeight; + if (explicitWidth !== undefined) s.defaults.cfWidth = explicitWidth; if (fontId) { labelConfig.defaultFontId = fontId; s.defaults.cfFontId = fontId; } - if (!isNaN(explicitHeight) && explicitHeight > 0) { + if (explicitHeight !== undefined && explicitHeight > 0) { labelConfig.defaultFontHeight = explicitHeight; } - if (!isNaN(explicitWidth) && explicitWidth >= 0) { + if (explicitWidth !== undefined && explicitWidth >= 0) { labelConfig.defaultFontWidth = explicitWidth; } }, @@ -79,9 +79,9 @@ export function createFieldHandlers( // ── Field block ─────────────────────────────────────────────────────── // ^FB{width},{lines},{lineSpacing},{justify},{hangingIndent} FB(p) { - s.defaults.fbWidth = int(p[0], 0); + s.defaults.fbWidth = intDots(p[0], s.format.unitScale, 0); s.defaults.fbLines = int(p[1], 1); - s.defaults.fbSpacing = int(p[2], 0); + s.defaults.fbSpacing = intDots(p[2], s.format.unitScale, 0); const fbJ = (p[3] ?? "L").toUpperCase(); s.defaults.fbJustify = fbJ === "C" || fbJ === "R" || fbJ === "J" ? fbJ : "L"; // ^FB also implies text if no ^A was specified. @@ -182,18 +182,18 @@ export function createFieldHandlers( FP(p) { const d = (p[0] ?? "H").toUpperCase(); s.field.fpDirection = d === "V" || d === "R" ? d : "H"; - s.field.fpCharGap = Math.max(0, Math.min(9999, int(p[1], 0))); + s.field.fpCharGap = Math.max(0, Math.min(9999, intDots(p[1], s.format.unitScale, 0))); }, // ── Label home (origin offset) ──────────────────────────────────────── LH(p) { - s.label.lhX = int(p[0], 0); - s.label.lhY = int(p[1], 0); + s.label.lhX = intDots(p[0], s.format.unitScale, 0); + s.label.lhY = intDots(p[1], s.format.unitScale, 0); }, // ── Label top (vertical offset) ─────────────────────────────────────── LT(_, rest) { - s.label.ltY = int(rest, 0); + s.label.ltY = intDots(rest, s.format.unitScale, 0); }, // ^CW {alias},{path} — register a printer-resident font alias. @@ -223,8 +223,8 @@ export function createFieldHandlers( "A@"(p, rest) { s.field.fieldType = "text"; s.field.textRot = readRotation(rest[0], s.defaults.fwRotation); - s.field.textH = int(p[1]) || getDefaultTextH(s.defaults); - s.field.textW = int(p[2]) || getDefaultTextW(s.defaults); + s.field.textH = intDots(p[1], s.format.unitScale) || getDefaultTextH(s.defaults); + s.field.textW = intDots(p[2], s.format.unitScale) || getDefaultTextW(s.defaults); const fontRef = p[3] ?? ""; const colonIdx = fontRef.indexOf(":"); s.field.pendingPrinterFontName = @@ -235,8 +235,8 @@ export function createFieldHandlers( TB(p, rest) { s.field.fieldType = "text"; s.field.textRot = readRotation(rest[0], s.defaults.fwRotation); - const tbW = int(p[1], 0); - const tbH = int(p[2], 0); + const tbW = intDots(p[1], s.format.unitScale, 0); + const tbH = intDots(p[2], s.format.unitScale, 0); s.field.textH = getDefaultTextH(s.defaults); s.field.textW = getDefaultTextW(s.defaults); if (tbW > 0) { diff --git a/src/lib/zplParser/handlers/graphics.ts b/src/lib/zplParser/handlers/graphics.ts index aaa8ae7d..132b463e 100644 --- a/src/lib/zplParser/handlers/graphics.ts +++ b/src/lib/zplParser/handlers/graphics.ts @@ -6,7 +6,7 @@ import { loadFontBytesSync } from "../../fontCache"; import { formatStoragePath, parseStoragePath } from "../../storagePath"; import { getPosType, pushBrowserLimit, type ParserState } from "../context"; import { decodeGraphicToImage } from "../decoders/graphic"; -import { int, makeObj, readColor, readRotation } from "../helpers"; +import { int, intDots, makeObj, readColor, readRotation } from "../helpers"; import type { Handler } from "../types"; /** Characters of a `^GF`/`~DY` payload retained in browserLimit/skipped @@ -103,9 +103,9 @@ export function createGraphicsHandlers( GB(p) { // ^GB{w},{h},{t},{color},{rounding} // ZPL: w=0 or h=0 means "use thickness value" for that dimension - const t = int(p[2], 3); - const rawW = int(p[0], t); - const rawH = int(p[1], t); + const t = intDots(p[2], s.format.unitScale, 3); + const rawW = intDots(p[0], s.format.unitScale, t); + const rawH = intDots(p[1], s.format.unitScale, t); const w = rawW === 0 ? t : rawW; const h = rawH === 0 ? t : rawH; const color = readColor(p[3]); @@ -127,9 +127,9 @@ export function createGraphicsHandlers( commitPendingReverseBg(); // ^GD{w},{h},{t},{color},{orientation} // orientation: L = top-left→bottom-right, R = top-right→bottom-left - const gdW = int(p[0], 1); - const gdH = int(p[1], 1); - const gdT = int(p[2], 3); + const gdW = intDots(p[0], s.format.unitScale, 1); + const gdH = intDots(p[1], s.format.unitScale, 1); + const gdT = intDots(p[2], s.format.unitScale, 3); const gdColor = readColor(p[3]); const gdOri = (p[4] ?? "L").toUpperCase(); const gdLen = Math.round(Math.sqrt(gdW * gdW + gdH * gdH)); @@ -228,9 +228,9 @@ export function createGraphicsHandlers( GE(p) { commitPendingReverseBg(); // ^GE{w},{h},{t},{color} - const w = int(p[0], 100); - const h = int(p[1], 100); - const t = int(p[2], 3); + const w = intDots(p[0], s.format.unitScale, 100); + const h = intDots(p[1], s.format.unitScale, 100); + const t = intDots(p[2], s.format.unitScale, 3); const color = readColor(p[3]); const filled = t >= Math.min(w, h); s.result.objects.push( @@ -254,8 +254,8 @@ export function createGraphicsHandlers( GC(p) { commitPendingReverseBg(); // ^GC{diameter},{thickness},{color} → circle = ellipse with equal w/h - const d = int(p[0], 100); - const t = int(p[1], 3); + const d = intDots(p[0], s.format.unitScale, 100); + const t = intDots(p[1], s.format.unitScale, 3); const color = readColor(p[2]); const filled = t >= d; s.result.objects.push( @@ -343,8 +343,8 @@ export function createGraphicsHandlers( GS(p) { s.field.fieldType = "symbol"; s.field.symRot = readRotation(p[0]); - s.field.symH = int(p[1], 30); - s.field.symW = int(p[2], s.field.symH); + s.field.symH = intDots(p[1], s.format.unitScale, 30); + s.field.symW = intDots(p[2], s.format.unitScale, s.field.symH); }, // ── ~DY downloaded TrueType / graphic payload ────────────────────────── diff --git a/src/lib/zplParser/handlers/labelConfig.ts b/src/lib/zplParser/handlers/labelConfig.ts index 6f081c75..23299aa9 100644 --- a/src/lib/zplParser/handlers/labelConfig.ts +++ b/src/lib/zplParser/handlers/labelConfig.ts @@ -1,7 +1,8 @@ import type { LabelConfig } from "../../../types/LabelConfig"; import { DARKNESS_INSTANT_RANGE, DARKNESS_PERMANENT_RANGE, MAX_LABEL_LENGTH_RANGE, SPEED_RANGE, isMediaFeedMode, isMediaMode, isMediaTracking, isMediaType, isPrintOrientation } from "../../../types/LabelConfig"; import { parseIntOrUndef } from "../../inputParse"; -import { firstChar, inRange, int, strParam } from "../helpers"; +import type { FormatState } from "../context"; +import { firstChar, inRange, int, intDots, intDotsOrUndef, strParam } from "../helpers"; import type { Handler } from "../types"; /** ^PQ extended params (pauseCount, replicates) — Zebra spec caps at @@ -12,14 +13,15 @@ const PQ_EXT_MAX = 99_999_999; export function createLabelConfigHandlers( labelConfig: Partial, dpmm: number, + format: FormatState, ): Record { return { PW(_, rest) { - const dots = int(rest); + const dots = intDots(rest, format.unitScale); if (dots > 0) labelConfig.widthMm = Math.round((dots / dpmm) * 10) / 10; }, LL(_, rest) { - const dots = int(rest); + const dots = intDots(rest, format.unitScale); if (dots > 0) labelConfig.heightMm = Math.round((dots / dpmm) * 10) / 10; }, PQ(p) { @@ -44,7 +46,7 @@ export function createLabelConfigHandlers( if (isMediaMode(mode)) labelConfig.mediaMode = mode; }, LS(_, rest) { - const shift = int(rest, 0); + const shift = intDots(rest, format.unitScale, 0); if (shift !== 0) labelConfig.labelShift = shift; }, PR(p) { @@ -72,7 +74,7 @@ export function createLabelConfigHandlers( if (isMediaTracking(v)) labelConfig.mediaTracking = v; }, ML(p) { - const v = inRange(parseIntOrUndef(p[0]), MAX_LABEL_LENGTH_RANGE); + const v = inRange(intDotsOrUndef(p[0], format.unitScale), MAX_LABEL_LENGTH_RANGE); if (v !== undefined) labelConfig.maxLabelLength = v; }, MF(p) { diff --git a/src/lib/zplParser/helpers.ts b/src/lib/zplParser/helpers.ts index eb9bb2e4..ca697901 100644 --- a/src/lib/zplParser/helpers.ts +++ b/src/lib/zplParser/helpers.ts @@ -73,6 +73,33 @@ export function int(s: string | undefined, fallback = 0): number { return Number.isNaN(n) ? fallback : n; } +/** Read a dot-quantity, applying the active ^MU `a`-slot multiplier. + * `parseFloat` is load-bearing: ^MUI/^MUM admits fractional unit + * values (`^FO0.5,0.5` = half an inch); `parseInt` would silently + * truncate to 0. Non-dot params (rotation, mode, counts) stay on + * plain `int`. */ +export function intDots( + s: string | undefined, + unitScale: number, + fallback = 0, +): number { + const n = Number.parseFloat(s ?? ""); + if (Number.isNaN(n)) return fallback; + return Math.round(n * unitScale); +} + +/** Optional-form of `intDots`. Returns undefined when the param is + * missing/NaN. Used where "absent" must be distinguished from + * "explicit 0". */ +export function intDotsOrUndef( + s: string | undefined, + unitScale: number, +): number | undefined { + const n = Number.parseFloat(s ?? ""); + if (Number.isNaN(n)) return undefined; + return Math.round(n * unitScale); +} + /** Returns v if within [r.min, r.max], else undefined. */ export function inRange(v: number | undefined, r: { min: number; max: number }): number | undefined { return v !== undefined && v >= r.min && v <= r.max ? v : undefined; diff --git a/src/types/LabelConfig.ts b/src/types/LabelConfig.ts index 25dd2b26..54a78a70 100644 --- a/src/types/LabelConfig.ts +++ b/src/types/LabelConfig.ts @@ -75,6 +75,14 @@ export const DARKNESS_INSTANT_RANGE = { min: 0, max: 30 } as const; /** ^ML: maximum label length, in dots. Zebra spec accepts 1..32000. */ export const MAX_LABEL_LENGTH_RANGE = { min: 1, max: 32000 } as const; +/** ^MU b,c accepted dpi values. 200 is Zebra's token for 203-dpi + * printers; only the b/c ratio drives resampling. */ +export const MU_DPI_VALUES = [150, 200, 300, 600] as const; +export type MuDpi = (typeof MU_DPI_VALUES)[number]; +export const isMuDpi = (n: number): n is MuDpi => + (MU_DPI_VALUES as readonly number[]).includes(n); +const muDpiSchema = z.number().refine(isMuDpi); + export const labelConfigSchema = z.object({ widthMm: z.number(), heightMm: z.number(), @@ -124,6 +132,10 @@ export const labelConfigSchema = z.object({ mediaFeedHeadClose: z.enum(MEDIA_FEED_VALUES).optional(), /** ^XB: suppress backfeed for the next label. Standalone toggle. */ suppressBackfeed: z.boolean().optional(), + /** ^MU b: format base dpi paired with outputDpi for printer-side resampling. */ + formatDpi: muDpiSchema.optional(), + /** ^MU c: target output dpi. Paired with formatDpi. */ + outputDpi: muDpiSchema.optional(), }); export type LabelConfig = z.infer; From 98e6ff5e44abdbb1fd0a9c95584136692850366b Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 4 Jun 2026 09:31:21 +0200 Subject: [PATCH 2/5] refactor(zpl): tighten ^MU integration toward long-term idiomatic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Curried dot-helper plumbing: - helpers.ts gains `dotsFor(state)` which binds intDots/intDotsOrUndef to a state's live unitScale. Replaces a per-factory closure that was duplicated across fields/barcodes/graphics/labelConfig. - intDots collapses to a thin fallback wrapper over intDotsOrUndef (DRY: one parseFloat+NaN+round body, not two). Factory-signature unification: - createSetupScriptHandlers(s), createLabelConfigHandlers(s, dpmm) and createBarcodeHandlers(s) now take ParserState, matching the existing createFieldHandlers/createGraphicsHandlers pattern. - ^A dynamic-font dispatch fallback moves out of zplParser.ts into a named export `handleDynamicFontA` in fields.ts. Orchestrator still owns the `cmd[0]==='A'` wildcard match. - New units.ts handler factory owns the ^MU command (both unitScale format-state mutation and muResampling labelConfig persistence). Schema reshape: - formatDpi/outputDpi individual optionals → `muResampling: { formatDpi, outputDpi } | undefined` paired sub-object. Type-enforces the spec's "both or neither" invariant; parser persists only when both slots arrive valid, generator emits the pair without half-set guards. Correctness: - Invalid ^MU a-slot (e.g. ^MUX) preserves prior unitScale instead of silently downgrading to dots. Marked partial. FormatState.unitScale documented as the one slot that intentionally survives ^XA per ^MU spec; per-block isolation comes from zplImportService calling parseZPL per ^XA block. --- src/lib/zplGenerator.test.ts | 27 ++++++--- src/lib/zplGenerator.ts | 8 +-- src/lib/zplParser.test.ts | 45 +++++++++----- src/lib/zplParser.ts | 67 +++------------------ src/lib/zplParser/context.ts | 7 ++- src/lib/zplParser/handlers/barcodes.ts | 37 ++++++------ src/lib/zplParser/handlers/fields.ts | 71 +++++++++++++++++------ src/lib/zplParser/handlers/graphics.ts | 29 ++++----- src/lib/zplParser/handlers/labelConfig.ts | 22 +++---- src/lib/zplParser/handlers/setupScript.ts | 7 +-- src/lib/zplParser/handlers/units.ts | 39 +++++++++++++ src/lib/zplParser/helpers.ts | 47 +++++++++------ src/types/LabelConfig.ts | 16 +++-- 13 files changed, 247 insertions(+), 175 deletions(-) create mode 100644 src/lib/zplParser/handlers/units.ts diff --git a/src/lib/zplGenerator.test.ts b/src/lib/zplGenerator.test.ts index a6267b1f..34a32bad 100644 --- a/src/lib/zplGenerator.test.ts +++ b/src/lib/zplGenerator.test.ts @@ -55,18 +55,24 @@ describe('generateZPL — structure', () => { expect(zpl).toContain('^LS5'); }); - it('does not emit ^MU when neither formatDpi nor outputDpi is set', () => { + it('does not emit ^MU when muResampling is unset', () => { const zpl = generateZPL(BASE_LABEL, []); expect(zpl).not.toContain('^MU'); }); - it('emits ^MUD,b,c when formatDpi and outputDpi are set', () => { - const zpl = generateZPL({ ...BASE_LABEL, formatDpi: 150, outputDpi: 300 }, []); + it('emits ^MUD,b,c when muResampling is set', () => { + const zpl = generateZPL( + { ...BASE_LABEL, muResampling: { formatDpi: 150, outputDpi: 300 } }, + [], + ); expect(zpl).toContain('^MUD,150,300'); }); it('emits ^MU directly after ^XA so the printer sees the resampling header first', () => { - const zpl = generateZPL({ ...BASE_LABEL, formatDpi: 200, outputDpi: 600 }, []); + const zpl = generateZPL( + { ...BASE_LABEL, muResampling: { formatDpi: 200, outputDpi: 600 } }, + [], + ); const xaIdx = zpl.indexOf('^XA'); const muIdx = zpl.indexOf('^MUD'); const pwIdx = zpl.indexOf('^PW'); @@ -75,11 +81,6 @@ describe('generateZPL — structure', () => { expect(muIdx).toBeLessThan(pwIdx); }); - it('omits ^MU when only one of formatDpi/outputDpi is set (paired directive)', () => { - expect(generateZPL({ ...BASE_LABEL, formatDpi: 200 }, [])).not.toContain('^MU'); - expect(generateZPL({ ...BASE_LABEL, outputDpi: 300 }, [])).not.toContain('^MU'); - }); - it('reverse text round-trips: emit ^GB+^FR, parse collapses back to one reverse text', () => { // Generator emits a filled black ^GB knockout-background followed by // an ^FR text at the same anchor. The parser detects that pair and @@ -730,6 +731,14 @@ describe('generateZPL — parse/generate roundtrip', () => { expect(reparsed.objects).toHaveLength(original.objects.length); }); + it('round-trips ^MU muResampling pair through parser + generator', () => { + const label = { ...BASE_LABEL, muResampling: { formatDpi: 200, outputDpi: 600 } as const }; + const regenerated = generateZPL(label, []); + expect(regenerated).toContain('^MUD,200,600'); + const reparsed = parseZPL(regenerated, 8); + expect(reparsed.labelConfig.muResampling).toEqual({ formatDpi: 200, outputDpi: 600 }); + }); + it('preserves text content through a roundtrip', () => { const original = parseZPL('^XA^FO10,20^A0N,30,0^FDHello World^FS^XZ', 8); const regenerated = generateZPL(BASE_LABEL, original.objects); diff --git a/src/lib/zplGenerator.ts b/src/lib/zplGenerator.ts index 61146bdf..9d9abfbd 100644 --- a/src/lib/zplGenerator.ts +++ b/src/lib/zplGenerator.ts @@ -309,10 +309,10 @@ export function generateZPL( } lines.push('^XA'); - // Paired b,c only; emitting just one half would produce a bogus - // resampling ratio. a=D matches the dots-canonical body. - if (label.formatDpi !== undefined && label.outputDpi !== undefined) { - lines.push(`^MUD,${label.formatDpi},${label.outputDpi}`); + // a=D since the model is dots-canonical; b,c shape is guaranteed + // by muResamplingSchema. + if (label.muResampling) { + lines.push(`^MUD,${label.muResampling.formatDpi},${label.muResampling.outputDpi}`); } lines.push( `^PW${widthDots}`, diff --git a/src/lib/zplParser.test.ts b/src/lib/zplParser.test.ts index fea6acbd..818b64ab 100644 --- a/src/lib/zplParser.test.ts +++ b/src/lib/zplParser.test.ts @@ -89,39 +89,56 @@ describe('parseZPL — ^MU units of measure', () => { expect(objects[1]?.x).toBe(203); }); - it('^MU b,c slots persist on labelConfig for re-emit', () => { + it('^MU b,c slots persist as a pair on labelConfig for re-emit', () => { const { labelConfig } = parseZPL('^XA^MUD,150,300^XZ', 8); - expect(labelConfig.formatDpi).toBe(150); - expect(labelConfig.outputDpi).toBe(300); + expect(labelConfig.muResampling).toEqual({ formatDpi: 150, outputDpi: 300 }); }); - it('^MU drops out-of-spec dpi values silently', () => { - const { labelConfig } = parseZPL('^XA^MUD,77,999^XZ', 8); - expect(labelConfig.formatDpi).toBeUndefined(); - expect(labelConfig.outputDpi).toBeUndefined(); + it('^MU with out-of-spec dpi values surfaces as partial finding', () => { + const { labelConfig, importReport } = parseZPL('^XA^MUD,77,999^XZ', 8); + expect(labelConfig.muResampling).toBeUndefined(); + expect(importReport.partial).toContain('^MU'); }); - it('^MU,150,300 with a-slot omitted resets unit to D and persists b/c', () => { + it('^MU,150,300 with a-slot omitted resets unit to D and persists the pair', () => { const { labelConfig, objects } = parseZPL( '^XA^MU,150,300^FO100,100^GB10,10,1^FS^XZ', 8, ); - expect(labelConfig.formatDpi).toBe(150); - expect(labelConfig.outputDpi).toBe(300); + expect(labelConfig.muResampling).toEqual({ formatDpi: 150, outputDpi: 300 }); // a-slot defaulted to D so coords stay unscaled expect(objects[0]?.x).toBe(100); }); - it('invalid ^MU a-slot surfaces as partial finding', () => { - const { importReport } = parseZPL('^XA^MUX^XZ', 8); + it('invalid ^MU a-slot surfaces as partial finding without dropping prior unit', () => { + // ^MUI sets unitScale to inches; a follow-up ^MUX must not silently + // downgrade subsequent coords to dots. + const { objects, importReport } = parseZPL( + '^XA^MUI^FO1,1^GB1,1,1^FS^MUX^FO1,1^GB1,1,1^FS^XZ', + 8, + ); + expect(importReport.partial).toContain('^MU'); + expect(objects[0]?.x).toBe(203); + expect(objects[1]?.x).toBe(203); + }); + + it('half-set ^MU dpi pair is rejected as partial (both-or-neither invariant)', () => { + const { labelConfig, importReport } = parseZPL('^XA^MUD,200^XZ', 8); + expect(labelConfig.muResampling).toBeUndefined(); expect(importReport.partial).toContain('^MU'); }); it('round-trip: ^MUD,b,c parses + generates back symmetrically', () => { const original = '^XA^MUD,200,600^PW600^LL400^CI28^XZ'; const { labelConfig } = parseZPL(original, 8); - expect(labelConfig.formatDpi).toBe(200); - expect(labelConfig.outputDpi).toBe(600); + expect(labelConfig.muResampling).toEqual({ formatDpi: 200, outputDpi: 600 }); + }); + + it('^MUI also rescales dynamic ^A{font} dims (the wildcard-dispatch path)', () => { + // 0.5 inch font height @ 8 dpmm = 101.6 → 102 dots + const { objects } = parseZPL('^XA^MUI^FO0,0^A1N,0.5,0.5^FDX^FS^XZ', 8); + expect(props(objects[0]).fontHeight).toBe(102); + expect(props(objects[0]).fontWidth).toBe(102); }); it('^MUI admits fractional inch values (the whole point of I-mode)', () => { diff --git a/src/lib/zplParser.ts b/src/lib/zplParser.ts index 31e0d518..c2764ddd 100644 --- a/src/lib/zplParser.ts +++ b/src/lib/zplParser.ts @@ -1,18 +1,13 @@ import { DEFAULT_CLOCK_CHARS } from "./fcTemplate"; -import { ZPL_BUILTIN_FONT_LETTERS } from "./customFonts"; -import { isMuDpi } from "../types/LabelConfig"; -import { tokenize, intDots, readRotation } from "./zplParser/helpers"; -import { - createParserState, - getDefaultTextH, - getDefaultTextW, -} from "./zplParser/context"; +import { tokenize } from "./zplParser/helpers"; +import { createParserState } from "./zplParser/context"; import { createFlushField } from "./zplParser/flushField"; import { createBarcodeHandlers } from "./zplParser/handlers/barcodes"; -import { createFieldHandlers } from "./zplParser/handlers/fields"; +import { createFieldHandlers, handleDynamicFontA } from "./zplParser/handlers/fields"; import { createGraphicsHandlers } from "./zplParser/handlers/graphics"; import { createLabelConfigHandlers } from "./zplParser/handlers/labelConfig"; import { createSetupScriptHandlers } from "./zplParser/handlers/setupScript"; +import { createUnitsHandler } from "./zplParser/handlers/units"; import { createUnsupportedHandlers } from "./zplParser/handlers/unsupported"; import type { Handler, @@ -70,40 +65,14 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { commitPendingReverseBg(); resetComment(_, rest); }, - // a-slot scales dot-quantities on read so the model stays - // dots-canonical. b/c persist for re-emit; printer does the - // resampling at print time. Spec: ^MU carries field-by-field - // until overridden, so no ^XA reset; per-block isolation comes - // from zplImportService. - MU(p) { - const a = (p[0] ?? "").trim().toUpperCase(); - if (a === "I") s.format.unitScale = dpmm * 25.4; - else if (a === "M") s.format.unitScale = dpmm; - else if (a === "" || a === "D") s.format.unitScale = 1; - else { - s.format.unitScale = 1; - s.result.partialCmds.add("^MU"); - } - const rawB = (p[1] ?? "").trim(); - const rawC = (p[2] ?? "").trim(); - if (rawB) { - const b = Number.parseInt(rawB, 10); - if (isMuDpi(b)) labelConfig.formatDpi = b; - else s.result.partialCmds.add("^MU"); - } - if (rawC) { - const c = Number.parseInt(rawC, 10); - if (isMuDpi(c)) labelConfig.outputDpi = c; - else s.result.partialCmds.add("^MU"); - } - }, }; - Object.assign(handlers, createBarcodeHandlers(s.field, s.defaults, s.format)); + Object.assign(handlers, createBarcodeHandlers(s)); Object.assign(handlers, createFieldHandlers(s, { flushField, appendComment })); Object.assign(handlers, graphicsFamily.handlers); - Object.assign(handlers, createSetupScriptHandlers(printerProfile)); - Object.assign(handlers, createLabelConfigHandlers(labelConfig, dpmm, s.format)); + Object.assign(handlers, createSetupScriptHandlers(s)); + Object.assign(handlers, createLabelConfigHandlers(s, dpmm)); + Object.assign(handlers, createUnitsHandler(s, dpmm)); Object.assign(handlers, createUnsupportedHandlers(s.result)); for (const { cmd, rest } of tokens) { @@ -114,26 +83,8 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { continue; } - // ^A{font}{rotation},{height},{width} — dynamic font (A0/A@ are static-mapped). if (cmd[0] === "A" && cmd.length === 2) { - s.field.fieldType = "text"; - s.field.textRot = readRotation(rest[0], s.defaults.fwRotation); - s.field.textH = intDots(p[1], s.format.unitScale, getDefaultTextH(s.defaults)); - s.field.textW = intDots(p[2], s.format.unitScale, getDefaultTextW(s.defaults)); - const fontChar = cmd[1] ?? ""; - // Round-trip: ^A{id} matching the active ^CF drops to "use default" - // (no pendingFontId) so re-emit stays terse; otherwise pin the alias. - if (s.defaults.cfFontId && fontChar === s.defaults.cfFontId) { - s.field.pendingFontId = undefined; - } else { - s.field.pendingFontId = fontChar; - } - if ( - !s.fonts.aliases.has(fontChar) && - !ZPL_BUILTIN_FONT_LETTERS.includes(fontChar) - ) { - partialCmds.add(`^${cmd}`); - } + handleDynamicFontA(s, cmd, rest, p); continue; } diff --git a/src/lib/zplParser/context.ts b/src/lib/zplParser/context.ts index 78c6621d..f8b7ba23 100644 --- a/src/lib/zplParser/context.ts +++ b/src/lib/zplParser/context.ts @@ -56,7 +56,9 @@ export interface CommentState { fnComment: string | undefined; } -/** Format-scoped (per-^XA) state; reset at label boundary. */ +/** Format-scoped (per-^XA) state; reset at label boundary + * EXCEPT for `unitScale`, which the ^MU spec defines as carrying + * across formats until explicitly overridden. */ export interface FormatState { embedChar: string; clockChars: ClockChars; @@ -70,7 +72,8 @@ export interface FormatState { tildeChar: string; delimiterChar: string; /** ^MU a-slot dot multiplier: 1 (D), dpmm*25.4 (I), dpmm (M). - * Internal model is dots-canonical; I/M sources get scaled on read. */ + * Internal model is dots-canonical; I/M sources get scaled on read. + * Survives ^XA per spec (^MU carries field-by-field until overridden). */ unitScale: number; } diff --git a/src/lib/zplParser/handlers/barcodes.ts b/src/lib/zplParser/handlers/barcodes.ts index c51dc071..df9d3ba5 100644 --- a/src/lib/zplParser/handlers/barcodes.ts +++ b/src/lib/zplParser/handlers/barcodes.ts @@ -3,16 +3,15 @@ import type { DataMatrixProps } from "../../../registry/datamatrix"; import type { Gs1DatabarProps } from "../../../registry/gs1databar"; import type { MaxicodeProps } from "../../../registry/maxicode"; import { GS1_DATABAR_DEFAULT_SEGMENTS } from "../../gs1"; -import type { DefaultsState, FieldState, FormatState } from "../context"; -import { int, intDots, readRotation } from "../helpers"; +import type { ParserState } from "../context"; +import { dotsFor, int, readRotation } from "../helpers"; import type { Handler } from "../types"; /** ^B* barcode commands + shared ^BY defaults. Touches `field` and `defaults`. */ -export function createBarcodeHandlers( - field: FieldState, - defaults: DefaultsState, - format: FormatState, -): Record { +export function createBarcodeHandlers(s: ParserState): Record { + const { field, defaults } = s; + const { dots } = dotsFor(s); + // Factory for 1D barcodes: hIdx/iIdx/cIdx = param indices for height/interp/check. const mkBarcode = ( @@ -25,7 +24,7 @@ export function createBarcodeHandlers( (p) => { field.fieldType = type; field.bcRotation = readRotation(p[0]); - field.bcHeight = intDots(p[hIdx], format.unitScale, defaults.byHeight || 100); + field.bcHeight = dots(p[hIdx], defaults.byHeight || 100); field.bcInterp = (p[iIdx] ?? iDefault) === "Y"; field.bcCheck = cIdx >= 0 ? (p[cIdx] ?? "N") === "Y" : false; }; @@ -40,8 +39,8 @@ export function createBarcodeHandlers( // ── Barcode defaults ────────────────────────────────────────────────── // ^BY{moduleWidth},{ratio},{height} BY(p) { - defaults.byModuleWidth = intDots(p[0], format.unitScale, 2); - defaults.byHeight = intDots(p[2], format.unitScale, 0); + defaults.byModuleWidth = dots(p[0], 2); + defaults.byHeight = dots(p[2], 0); }, // ── 1D barcodes via mkBarcode(type, hIdx, iIdx, iDefault?, cIdx?) ───── @@ -67,7 +66,7 @@ export function createBarcodeHandlers( B4(p) { field.fieldType = "code49"; field.bcRotation = readRotation(p[0]); - field.bcHeight = intDots(p[1], format.unitScale, defaults.byHeight || 20); + field.bcHeight = dots(p[1], defaults.byHeight || 20); field.bcInterp = (p[2] ?? "N") === "Y"; const m = (p[3] ?? "A").toUpperCase(); field.bcCode49Mode = /^[A0-5]$/.test(m) @@ -81,7 +80,7 @@ export function createBarcodeHandlers( field.fieldType = "msi"; field.bcRotation = readRotation(p[0]); field.bcCheck = (p[1] ?? "N") !== "N"; - field.bcHeight = intDots(p[2], format.unitScale, defaults.byHeight || 100); + field.bcHeight = dots(p[2], defaults.byHeight || 100); field.bcInterp = (p[3] ?? "Y") === "Y"; }, @@ -110,7 +109,7 @@ export function createBarcodeHandlers( BX(p) { field.fieldType = "datamatrix"; field.bcRotation = readRotation(p[0]); - field.dmDim = intDots(p[1], format.unitScale, 5); + field.dmDim = dots(p[1], 5); field.dmQuality = int(p[2], 200) as DataMatrixProps["quality"]; }, @@ -118,7 +117,7 @@ export function createBarcodeHandlers( B7(p) { field.fieldType = "pdf417"; field.bcRotation = readRotation(p[0]); - field.pdfRowHeight = intDots(p[1], format.unitScale, 10); + field.pdfRowHeight = dots(p[1], 10); field.pdfSecurity = int(p[2], 0); field.pdfColumns = int(p[3], 0); }, @@ -143,14 +142,14 @@ export function createBarcodeHandlers( BF(p) { field.fieldType = "micropdf417"; field.bcRotation = readRotation(p[0]); - field.mpdfRowHeight = intDots(p[1], format.unitScale, 10); + field.mpdfRowHeight = dots(p[1], 10); }, // ^BBN,{rowHeight},{security},{numCharsPerRow},{numRows},{mode} — CODABLOCK BB(p) { field.fieldType = "codablock"; field.bcRotation = readRotation(p[0]); - field.cbRowHeight = intDots(p[1], format.unitScale, 10); + field.cbRowHeight = dots(p[1], 10); field.cbSecurity = (p[2] ?? "Y") === "N" ? "N" : "Y"; }, @@ -160,10 +159,10 @@ export function createBarcodeHandlers( field.bcRotation = readRotation(p[0]); // w1 (Code 39 narrow bar) overrides ^BY when present; undefined // means fall back to defaults.byModuleWidth at flush time. - field.tlcModuleWidth = intDots(p[1], format.unitScale, 0) || undefined; + field.tlcModuleWidth = dots(p[1], 0) || undefined; // p[2] (r1) intentionally dropped; canonicalized on emit. - field.tlcHeight = intDots(p[3], format.unitScale, defaults.byHeight || 40); - field.tlcMicroPdfRowHeight = intDots(p[4], format.unitScale, 4); + field.tlcHeight = dots(p[3], defaults.byHeight || 40); + field.tlcMicroPdfRowHeight = dots(p[4], 4); // Zebra ^BT h2 range is 1-10; firmware snaps to a valid linked // MicroPDF417 row count {4,6,8,10} on print. const rows = int(p[5], 4); diff --git a/src/lib/zplParser/handlers/fields.ts b/src/lib/zplParser/handlers/fields.ts index 2cba99cc..b10481d7 100644 --- a/src/lib/zplParser/handlers/fields.ts +++ b/src/lib/zplParser/handlers/fields.ts @@ -1,8 +1,9 @@ import type { CustomFontMapping } from "../../../types/LabelConfig"; import { FN_NUMBER_MAX, FN_NUMBER_MIN } from "../../../types/Variable"; import type { SerialProps } from "../../../registry/serial"; +import { ZPL_BUILTIN_FONT_LETTERS } from "../../customFonts"; import { getDefaultTextH, getDefaultTextW, type ParserState } from "../context"; -import { ciToEncoding, getDecoder, int, intDots, intDotsOrUndef, makeObj, readRotation } from "../helpers"; +import { ciToEncoding, dotsFor, getDecoder, int, makeObj, readRotation } from "../helpers"; import type { Handler } from "../types"; /** flushField + appendComment are shared with the orchestrator. */ @@ -11,6 +12,37 @@ export interface FieldHelpers { appendComment: Handler; } +/** ^A{font}{rotation},{height},{width}: dynamic font (A0/A@ are + * static-mapped). Lives outside the handler-key dispatch because the + * font char is the wildcard part of the command name; the orchestrator + * forwards here on any `^A?` match. */ +export function handleDynamicFontA( + s: ParserState, + cmd: string, + rest: string, + p: string[], +): void { + const { dots } = dotsFor(s); + s.field.fieldType = "text"; + s.field.textRot = readRotation(rest[0], s.defaults.fwRotation); + s.field.textH = dots(p[1], getDefaultTextH(s.defaults)); + s.field.textW = dots(p[2], getDefaultTextW(s.defaults)); + const fontChar = cmd[1] ?? ""; + // Round-trip: ^A{id} matching the active ^CF drops to "use default" + // (no pendingFontId) so re-emit stays terse; otherwise pin the alias. + if (s.defaults.cfFontId && fontChar === s.defaults.cfFontId) { + s.field.pendingFontId = undefined; + } else { + s.field.pendingFontId = fontChar; + } + if ( + !s.fonts.aliases.has(fontChar) && + !ZPL_BUILTIN_FONT_LETTERS.includes(fontChar) + ) { + s.result.partialCmds.add(`^${cmd}`); + } +} + /** Field-shaping commands: FO/FT, A0/A@, TB, CF, FW, FB, FH, FD/FS, * LR/FR, LH/LT, SN/SF, FN/FC/FE, CI, FX, CW. */ export function createFieldHandlers( @@ -19,20 +51,21 @@ export function createFieldHandlers( ): Record { const { flushField, appendComment } = helpers; const { labelConfig } = s.result; + const { dots, dotsOrUndef } = dotsFor(s); return { // ── Field origin ────────────────────────────────────────────────────── FO(p) { flushField(); - s.field.x = intDots(p[0], s.format.unitScale) + s.label.lhX; - s.field.y = intDots(p[1], s.format.unitScale) + s.label.lhY + s.label.ltY; + s.field.x = dots(p[0]) + s.label.lhX; + s.field.y = dots(p[1]) + s.label.lhY + s.label.ltY; // 3rd param is justification (0/1/2) — stored but not actively used. s.field.positionIsFT = false; }, FT(p) { flushField(); - s.field.x = intDots(p[0], s.format.unitScale) + s.label.lhX; - s.field.y = intDots(p[1], s.format.unitScale) + s.label.lhY + s.label.ltY; + s.field.x = dots(p[0]) + s.label.lhX; + s.field.y = dots(p[1]) + s.label.lhY + s.label.ltY; s.field.positionIsFT = true; }, @@ -41,8 +74,8 @@ export function createFieldHandlers( A0(p, rest) { s.field.fieldType = "text"; s.field.textRot = readRotation(rest[0], s.defaults.fwRotation); - s.field.textH = intDots(p[1], s.format.unitScale, getDefaultTextH(s.defaults)); - s.field.textW = intDots(p[2], s.format.unitScale, getDefaultTextW(s.defaults)); + s.field.textH = dots(p[1], getDefaultTextH(s.defaults)); + s.field.textW = dots(p[2], getDefaultTextW(s.defaults)); // fontId "0" only when ^CF differs; "0" is the implicit default otherwise. s.field.pendingFontId = s.defaults.cfFontId && s.defaults.cfFontId !== "0" ? "0" : undefined; }, @@ -51,8 +84,8 @@ export function createFieldHandlers( // ^CF{font},{height},{width} → sets default for fields without ^A CF(p) { const fontId = (p[0] ?? "").trim(); - const explicitHeight = intDotsOrUndef(p[1], s.format.unitScale); - const explicitWidth = intDotsOrUndef(p[2], s.format.unitScale); + const explicitHeight = dotsOrUndef(p[1]); + const explicitWidth = dotsOrUndef(p[2]); if (explicitHeight !== undefined) s.defaults.cfHeight = explicitHeight; if (explicitWidth !== undefined) s.defaults.cfWidth = explicitWidth; if (fontId) { @@ -79,9 +112,9 @@ export function createFieldHandlers( // ── Field block ─────────────────────────────────────────────────────── // ^FB{width},{lines},{lineSpacing},{justify},{hangingIndent} FB(p) { - s.defaults.fbWidth = intDots(p[0], s.format.unitScale, 0); + s.defaults.fbWidth = dots(p[0]); s.defaults.fbLines = int(p[1], 1); - s.defaults.fbSpacing = intDots(p[2], s.format.unitScale, 0); + s.defaults.fbSpacing = dots(p[2]); const fbJ = (p[3] ?? "L").toUpperCase(); s.defaults.fbJustify = fbJ === "C" || fbJ === "R" || fbJ === "J" ? fbJ : "L"; // ^FB also implies text if no ^A was specified. @@ -182,18 +215,18 @@ export function createFieldHandlers( FP(p) { const d = (p[0] ?? "H").toUpperCase(); s.field.fpDirection = d === "V" || d === "R" ? d : "H"; - s.field.fpCharGap = Math.max(0, Math.min(9999, intDots(p[1], s.format.unitScale, 0))); + s.field.fpCharGap = Math.max(0, Math.min(9999, dots(p[1]))); }, // ── Label home (origin offset) ──────────────────────────────────────── LH(p) { - s.label.lhX = intDots(p[0], s.format.unitScale, 0); - s.label.lhY = intDots(p[1], s.format.unitScale, 0); + s.label.lhX = dots(p[0]); + s.label.lhY = dots(p[1]); }, // ── Label top (vertical offset) ─────────────────────────────────────── LT(_, rest) { - s.label.ltY = intDots(rest, s.format.unitScale, 0); + s.label.ltY = dots(rest); }, // ^CW {alias},{path} — register a printer-resident font alias. @@ -223,8 +256,8 @@ export function createFieldHandlers( "A@"(p, rest) { s.field.fieldType = "text"; s.field.textRot = readRotation(rest[0], s.defaults.fwRotation); - s.field.textH = intDots(p[1], s.format.unitScale) || getDefaultTextH(s.defaults); - s.field.textW = intDots(p[2], s.format.unitScale) || getDefaultTextW(s.defaults); + s.field.textH = dots(p[1]) || getDefaultTextH(s.defaults); + s.field.textW = dots(p[2]) || getDefaultTextW(s.defaults); const fontRef = p[3] ?? ""; const colonIdx = fontRef.indexOf(":"); s.field.pendingPrinterFontName = @@ -235,8 +268,8 @@ export function createFieldHandlers( TB(p, rest) { s.field.fieldType = "text"; s.field.textRot = readRotation(rest[0], s.defaults.fwRotation); - const tbW = intDots(p[1], s.format.unitScale, 0); - const tbH = intDots(p[2], s.format.unitScale, 0); + const tbW = dots(p[1]); + const tbH = dots(p[2]); s.field.textH = getDefaultTextH(s.defaults); s.field.textW = getDefaultTextW(s.defaults); if (tbW > 0) { diff --git a/src/lib/zplParser/handlers/graphics.ts b/src/lib/zplParser/handlers/graphics.ts index 132b463e..67961043 100644 --- a/src/lib/zplParser/handlers/graphics.ts +++ b/src/lib/zplParser/handlers/graphics.ts @@ -6,7 +6,7 @@ import { loadFontBytesSync } from "../../fontCache"; import { formatStoragePath, parseStoragePath } from "../../storagePath"; import { getPosType, pushBrowserLimit, type ParserState } from "../context"; import { decodeGraphicToImage } from "../decoders/graphic"; -import { int, intDots, makeObj, readColor, readRotation } from "../helpers"; +import { dotsFor, int, makeObj, readColor, readRotation } from "../helpers"; import type { Handler } from "../types"; /** Characters of a `^GF`/`~DY` payload retained in browserLimit/skipped @@ -43,6 +43,7 @@ export function createGraphicsHandlers( takeComment: () => string | undefined, ): GraphicsFamily { const getReverseFlag = () => s.label.lrActive || s.field.frActive || undefined; + const { dots } = dotsFor(s); const pushGBObject: GraphicsExports["pushGBObject"] = ( gx, gy, w, h, t, color, rounding, reverseFlag, comment, @@ -103,9 +104,9 @@ export function createGraphicsHandlers( GB(p) { // ^GB{w},{h},{t},{color},{rounding} // ZPL: w=0 or h=0 means "use thickness value" for that dimension - const t = intDots(p[2], s.format.unitScale, 3); - const rawW = intDots(p[0], s.format.unitScale, t); - const rawH = intDots(p[1], s.format.unitScale, t); + const t = dots(p[2], 3); + const rawW = dots(p[0], t); + const rawH = dots(p[1], t); const w = rawW === 0 ? t : rawW; const h = rawH === 0 ? t : rawH; const color = readColor(p[3]); @@ -127,9 +128,9 @@ export function createGraphicsHandlers( commitPendingReverseBg(); // ^GD{w},{h},{t},{color},{orientation} // orientation: L = top-left→bottom-right, R = top-right→bottom-left - const gdW = intDots(p[0], s.format.unitScale, 1); - const gdH = intDots(p[1], s.format.unitScale, 1); - const gdT = intDots(p[2], s.format.unitScale, 3); + const gdW = dots(p[0], 1); + const gdH = dots(p[1], 1); + const gdT = dots(p[2], 3); const gdColor = readColor(p[3]); const gdOri = (p[4] ?? "L").toUpperCase(); const gdLen = Math.round(Math.sqrt(gdW * gdW + gdH * gdH)); @@ -228,9 +229,9 @@ export function createGraphicsHandlers( GE(p) { commitPendingReverseBg(); // ^GE{w},{h},{t},{color} - const w = intDots(p[0], s.format.unitScale, 100); - const h = intDots(p[1], s.format.unitScale, 100); - const t = intDots(p[2], s.format.unitScale, 3); + const w = dots(p[0], 100); + const h = dots(p[1], 100); + const t = dots(p[2], 3); const color = readColor(p[3]); const filled = t >= Math.min(w, h); s.result.objects.push( @@ -254,8 +255,8 @@ export function createGraphicsHandlers( GC(p) { commitPendingReverseBg(); // ^GC{diameter},{thickness},{color} → circle = ellipse with equal w/h - const d = intDots(p[0], s.format.unitScale, 100); - const t = intDots(p[1], s.format.unitScale, 3); + const d = dots(p[0], 100); + const t = dots(p[1], 3); const color = readColor(p[2]); const filled = t >= d; s.result.objects.push( @@ -343,8 +344,8 @@ export function createGraphicsHandlers( GS(p) { s.field.fieldType = "symbol"; s.field.symRot = readRotation(p[0]); - s.field.symH = intDots(p[1], s.format.unitScale, 30); - s.field.symW = intDots(p[2], s.format.unitScale, s.field.symH); + s.field.symH = dots(p[1], 30); + s.field.symW = dots(p[2], s.field.symH); }, // ── ~DY downloaded TrueType / graphic payload ────────────────────────── diff --git a/src/lib/zplParser/handlers/labelConfig.ts b/src/lib/zplParser/handlers/labelConfig.ts index 23299aa9..7033bb25 100644 --- a/src/lib/zplParser/handlers/labelConfig.ts +++ b/src/lib/zplParser/handlers/labelConfig.ts @@ -1,8 +1,7 @@ -import type { LabelConfig } from "../../../types/LabelConfig"; import { DARKNESS_INSTANT_RANGE, DARKNESS_PERMANENT_RANGE, MAX_LABEL_LENGTH_RANGE, SPEED_RANGE, isMediaFeedMode, isMediaMode, isMediaTracking, isMediaType, isPrintOrientation } from "../../../types/LabelConfig"; import { parseIntOrUndef } from "../../inputParse"; -import type { FormatState } from "../context"; -import { firstChar, inRange, int, intDots, intDotsOrUndef, strParam } from "../helpers"; +import type { ParserState } from "../context"; +import { dotsFor, firstChar, inRange, int, strParam } from "../helpers"; import type { Handler } from "../types"; /** ^PQ extended params (pauseCount, replicates) — Zebra spec caps at @@ -11,18 +10,19 @@ const PQ_EXT_MAX = 99_999_999; /** Per-label media + print-quality handlers — mutate only `labelConfig`. */ export function createLabelConfigHandlers( - labelConfig: Partial, + s: ParserState, dpmm: number, - format: FormatState, ): Record { + const labelConfig = s.result.labelConfig; + const { dots, dotsOrUndef } = dotsFor(s); return { PW(_, rest) { - const dots = intDots(rest, format.unitScale); - if (dots > 0) labelConfig.widthMm = Math.round((dots / dpmm) * 10) / 10; + const w = dots(rest); + if (w > 0) labelConfig.widthMm = Math.round((w / dpmm) * 10) / 10; }, LL(_, rest) { - const dots = intDots(rest, format.unitScale); - if (dots > 0) labelConfig.heightMm = Math.round((dots / dpmm) * 10) / 10; + const h = dots(rest); + if (h > 0) labelConfig.heightMm = Math.round((h / dpmm) * 10) / 10; }, PQ(p) { const qty = int(p[0], 0); @@ -46,7 +46,7 @@ export function createLabelConfigHandlers( if (isMediaMode(mode)) labelConfig.mediaMode = mode; }, LS(_, rest) { - const shift = intDots(rest, format.unitScale, 0); + const shift = dots(rest); if (shift !== 0) labelConfig.labelShift = shift; }, PR(p) { @@ -74,7 +74,7 @@ export function createLabelConfigHandlers( if (isMediaTracking(v)) labelConfig.mediaTracking = v; }, ML(p) { - const v = inRange(intDotsOrUndef(p[0], format.unitScale), MAX_LABEL_LENGTH_RANGE); + const v = inRange(dotsOrUndef(p[0]), MAX_LABEL_LENGTH_RANGE); if (v !== undefined) labelConfig.maxLabelLength = v; }, MF(p) { diff --git a/src/lib/zplParser/handlers/setupScript.ts b/src/lib/zplParser/handlers/setupScript.ts index 20307bfc..2d4bfe92 100644 --- a/src/lib/zplParser/handlers/setupScript.ts +++ b/src/lib/zplParser/handlers/setupScript.ts @@ -1,15 +1,14 @@ -import type { PrinterProfile } from "../../../types/PrinterProfile"; import { CLOCK_TOLERANCE_RANGE, HEAD_TEST_INTERVAL_RANGE, PRINTER_NAME_MAX_LEN, PRINTER_PASSWORD_REGEX, TEAR_OFF_ADJUST_RANGE, isClockFormat, isClockLanguage, isConfigUpdateAction, isPrinterLocale, isZplMode, setupScriptUnsafeCharRegex } from "../../../types/PrinterProfile"; import { parseIntOrUndef } from "../../inputParse"; import { parseRealtimeClock } from "../../realtimeClock"; +import type { ParserState } from "../context"; import { inRange, strParam } from "../helpers"; import type { Handler } from "../types"; /** Setup-Script commands (^JZ, ^JT, ~TA, ^ST, ^KD, ^KL, ^SE, ^SZ, * ^KN, ^SL, ^KP, ^JU): all write the shared `printerProfile` slice. */ -export function createSetupScriptHandlers( - printerProfile: Partial, -): Record { +export function createSetupScriptHandlers(s: ParserState): Record { + const printerProfile = s.result.printerProfile; return { JZ(p) { const v = strParam(p[0]); diff --git a/src/lib/zplParser/handlers/units.ts b/src/lib/zplParser/handlers/units.ts new file mode 100644 index 00000000..e07fe5e3 --- /dev/null +++ b/src/lib/zplParser/handlers/units.ts @@ -0,0 +1,39 @@ +import { isMuDpi } from "../../../types/LabelConfig"; +import type { ParserState } from "../context"; +import type { Handler } from "../types"; + +/** ^MU units-of-measure handler. Owns one format-state slice + * (`unitScale`) and one labelConfig slice (`muResampling`); kept + * together because both express the same command's intent. */ +export function createUnitsHandler(s: ParserState, dpmm: number): Record { + const labelConfig = s.result.labelConfig; + const markPartial = () => s.result.partialCmds.add("^MU"); + + return { + // a-slot scales dot-quantities on read so the model stays + // dots-canonical. b,c are a paired resampling directive persisted + // for re-emit; printer does the actual scaling at print time. + // Spec: ^MU carries field-by-field until overridden, so no ^XA + // reset; per-block isolation comes from zplImportService. + MU(p) { + const a = (p[0] ?? "").trim().toUpperCase(); + if (a === "I") s.format.unitScale = dpmm * 25.4; + else if (a === "M") s.format.unitScale = dpmm; + else if (a === "" || a === "D") s.format.unitScale = 1; + else markPartial(); // unknown a-slot: preserve prior unitScale + + const rawB = (p[1] ?? "").trim(); + const rawC = (p[2] ?? "").trim(); + // Both-or-neither: a half-set pair has no usable ratio, so a + // lone b or c is a partial import. + if (!rawB && !rawC) return; + const b = Number.parseInt(rawB, 10); + const c = Number.parseInt(rawC, 10); + if (isMuDpi(b) && isMuDpi(c)) { + labelConfig.muResampling = { formatDpi: b, outputDpi: c }; + } else { + markPartial(); + } + }, + }; +} diff --git a/src/lib/zplParser/helpers.ts b/src/lib/zplParser/helpers.ts index ca697901..108f485d 100644 --- a/src/lib/zplParser/helpers.ts +++ b/src/lib/zplParser/helpers.ts @@ -73,31 +73,44 @@ export function int(s: string | undefined, fallback = 0): number { return Number.isNaN(n) ? fallback : n; } -/** Read a dot-quantity, applying the active ^MU `a`-slot multiplier. - * `parseFloat` is load-bearing: ^MUI/^MUM admits fractional unit - * values (`^FO0.5,0.5` = half an inch); `parseInt` would silently - * truncate to 0. Non-dot params (rotation, mode, counts) stay on - * plain `int`. */ -export function intDots( +/** Optional-form of the dot-quantity reader. Returns undefined when + * the param is missing/NaN. `parseFloat` is load-bearing: ^MUI/^MUM + * admits fractional unit values (`^FO0.5,0.5` = half an inch); + * `parseInt` would silently truncate to 0. In D-mode this also + * rounds fractional input (`^FO10.6` → 11), a deliberate change + * from the old `parseInt` truncate. */ +export function intDotsOrUndef( s: string | undefined, unitScale: number, - fallback = 0, -): number { +): number | undefined { const n = Number.parseFloat(s ?? ""); - if (Number.isNaN(n)) return fallback; + if (Number.isNaN(n)) return undefined; return Math.round(n * unitScale); } -/** Optional-form of `intDots`. Returns undefined when the param is - * missing/NaN. Used where "absent" must be distinguished from - * "explicit 0". */ -export function intDotsOrUndef( +/** Read a dot-quantity, applying the active ^MU `a`-slot multiplier. + * Use at every site the spec documents as "dots"; non-dot params + * (rotation, mode, counts) stay on plain `int`. */ +export function intDots( s: string | undefined, unitScale: number, -): number | undefined { - const n = Number.parseFloat(s ?? ""); - if (Number.isNaN(n)) return undefined; - return Math.round(n * unitScale); + fallback = 0, +): number { + return intDotsOrUndef(s, unitScale) ?? fallback; +} + +/** Bind `intDots`/`intDotsOrUndef` to a parser state's live unit + * scale, so handler factories don't each rebuild the same closures. + * Returns helpers that read `state.format.unitScale` at call time + * (load-bearing: ^MU mid-format mutates it). */ +export function dotsFor(state: { format: { unitScale: number } }): { + dots: (raw: string | undefined, fb?: number) => number; + dotsOrUndef: (raw: string | undefined) => number | undefined; +} { + return { + dots: (raw, fb = 0) => intDots(raw, state.format.unitScale, fb), + dotsOrUndef: (raw) => intDotsOrUndef(raw, state.format.unitScale), + }; } /** Returns v if within [r.min, r.max], else undefined. */ diff --git a/src/types/LabelConfig.ts b/src/types/LabelConfig.ts index 54a78a70..6c81cf1f 100644 --- a/src/types/LabelConfig.ts +++ b/src/types/LabelConfig.ts @@ -83,6 +83,15 @@ export const isMuDpi = (n: number): n is MuDpi => (MU_DPI_VALUES as readonly number[]).includes(n); const muDpiSchema = z.number().refine(isMuDpi); +/** ^MU b,c dpi-resampling pair. Type-enforces the spec's "both or + * neither" invariant: a half-set pair has no meaningful ratio, so we + * refuse to model it. */ +export const muResamplingSchema = z.object({ + formatDpi: muDpiSchema, + outputDpi: muDpiSchema, +}); +export type MuResampling = z.infer; + export const labelConfigSchema = z.object({ widthMm: z.number(), heightMm: z.number(), @@ -132,10 +141,9 @@ export const labelConfigSchema = z.object({ mediaFeedHeadClose: z.enum(MEDIA_FEED_VALUES).optional(), /** ^XB: suppress backfeed for the next label. Standalone toggle. */ suppressBackfeed: z.boolean().optional(), - /** ^MU b: format base dpi paired with outputDpi for printer-side resampling. */ - formatDpi: muDpiSchema.optional(), - /** ^MU c: target output dpi. Paired with formatDpi. */ - outputDpi: muDpiSchema.optional(), + /** ^MU b,c paired dpi-resampling directive. Set only when both + * slots arrived valid from a ^MU b,c. See `muResamplingSchema`. */ + muResampling: muResamplingSchema.optional(), }); export type LabelConfig = z.infer; From 37efa29f9e50e0b0e7cb7fba75bd1df94ef4ae43 Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 4 Jun 2026 09:41:33 +0200 Subject: [PATCH 3/5] refactor(zpl): wildcard handlers as first-class dispatch primitive The ^A{font} dynamic-font command can't live in the exact-match handler table because the font char is part of the command key. Previously it leaked through as a named-export helper plus an orchestrator-side inline `cmd[0]==='A' && cmd.length===2` check. Introduces a `Wildcard` type (matches + handle) so the pattern-match logic stays with its owning handler module. `createDynamicFontAWildcard` in fields.ts now declares the pattern; the orchestrator collects wildcards into a list and falls back to them only after exact-match dispatch fails, so a registered handler always wins. Handler signature gains `cmd: string` as a third argument so wildcards can inspect the matched command key (e.g. read the font char from `cmd[1]`). Existing handlers ignore the new arg. Removes the named-export workaround; the orchestrator no longer needs to know about specific wildcard patterns. --- src/lib/zplParser.ts | 24 ++++++------ src/lib/zplParser/handlers/fields.ts | 56 ++++++++++++++-------------- src/lib/zplParser/types.ts | 11 +++++- 3 files changed, 50 insertions(+), 41 deletions(-) diff --git a/src/lib/zplParser.ts b/src/lib/zplParser.ts index c2764ddd..9f4c361b 100644 --- a/src/lib/zplParser.ts +++ b/src/lib/zplParser.ts @@ -3,7 +3,7 @@ import { tokenize } from "./zplParser/helpers"; import { createParserState } from "./zplParser/context"; import { createFlushField } from "./zplParser/flushField"; import { createBarcodeHandlers } from "./zplParser/handlers/barcodes"; -import { createFieldHandlers, handleDynamicFontA } from "./zplParser/handlers/fields"; +import { createDynamicFontAWildcard, createFieldHandlers } from "./zplParser/handlers/fields"; import { createGraphicsHandlers } from "./zplParser/handlers/graphics"; import { createLabelConfigHandlers } from "./zplParser/handlers/labelConfig"; import { createSetupScriptHandlers } from "./zplParser/handlers/setupScript"; @@ -13,6 +13,7 @@ import type { Handler, ImportFinding, ParsedZPL, + Wildcard, } from "./zplParser/types"; export type { ImportFindingKind, @@ -55,15 +56,15 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { }; const handlers: Record = { - XA: (p, rest) => { + XA: (p, rest, cmd) => { commitPendingReverseBg(); s.format.embedChar = "#"; s.format.clockChars = { ...DEFAULT_CLOCK_CHARS }; - resetComment(p, rest); + resetComment(p, rest, cmd); }, - XZ(_, rest) { + XZ(p, rest, cmd) { commitPendingReverseBg(); - resetComment(_, rest); + resetComment(p, rest, cmd); }, }; @@ -75,16 +76,15 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { Object.assign(handlers, createUnitsHandler(s, dpmm)); Object.assign(handlers, createUnsupportedHandlers(s.result)); + // Wildcard handlers are tried only after exact-match dispatch fails, + // so a handler-table entry always wins over a pattern-match. + const wildcards: Wildcard[] = [createDynamicFontAWildcard(s)]; + for (const { cmd, rest } of tokens) { const p = rest.split(s.format.delimiterChar); - const handler = handlers[cmd]; + const handler = handlers[cmd] ?? wildcards.find((w) => w.matches(cmd))?.handle; if (handler) { - handler(p, rest); - continue; - } - - if (cmd[0] === "A" && cmd.length === 2) { - handleDynamicFontA(s, cmd, rest, p); + handler(p, rest, cmd); continue; } diff --git a/src/lib/zplParser/handlers/fields.ts b/src/lib/zplParser/handlers/fields.ts index b10481d7..5aa4ed6f 100644 --- a/src/lib/zplParser/handlers/fields.ts +++ b/src/lib/zplParser/handlers/fields.ts @@ -4,7 +4,7 @@ import type { SerialProps } from "../../../registry/serial"; import { ZPL_BUILTIN_FONT_LETTERS } from "../../customFonts"; import { getDefaultTextH, getDefaultTextW, type ParserState } from "../context"; import { ciToEncoding, dotsFor, getDecoder, int, makeObj, readRotation } from "../helpers"; -import type { Handler } from "../types"; +import type { Handler, Wildcard } from "../types"; /** flushField + appendComment are shared with the orchestrator. */ export interface FieldHelpers { @@ -13,34 +13,34 @@ export interface FieldHelpers { } /** ^A{font}{rotation},{height},{width}: dynamic font (A0/A@ are - * static-mapped). Lives outside the handler-key dispatch because the - * font char is the wildcard part of the command name; the orchestrator - * forwards here on any `^A?` match. */ -export function handleDynamicFontA( - s: ParserState, - cmd: string, - rest: string, - p: string[], -): void { + * static-mapped). Registered as a `Wildcard` because the font char + * is the variable part of the command name. */ +export function createDynamicFontAWildcard(s: ParserState): Wildcard { const { dots } = dotsFor(s); - s.field.fieldType = "text"; - s.field.textRot = readRotation(rest[0], s.defaults.fwRotation); - s.field.textH = dots(p[1], getDefaultTextH(s.defaults)); - s.field.textW = dots(p[2], getDefaultTextW(s.defaults)); - const fontChar = cmd[1] ?? ""; - // Round-trip: ^A{id} matching the active ^CF drops to "use default" - // (no pendingFontId) so re-emit stays terse; otherwise pin the alias. - if (s.defaults.cfFontId && fontChar === s.defaults.cfFontId) { - s.field.pendingFontId = undefined; - } else { - s.field.pendingFontId = fontChar; - } - if ( - !s.fonts.aliases.has(fontChar) && - !ZPL_BUILTIN_FONT_LETTERS.includes(fontChar) - ) { - s.result.partialCmds.add(`^${cmd}`); - } + return { + matches: (cmd) => cmd.length === 2 && cmd[0] === "A", + handle: (p, rest, cmd) => { + s.field.fieldType = "text"; + s.field.textRot = readRotation(rest[0], s.defaults.fwRotation); + s.field.textH = dots(p[1], getDefaultTextH(s.defaults)); + s.field.textW = dots(p[2], getDefaultTextW(s.defaults)); + const fontChar = cmd[1] ?? ""; + // Round-trip: ^A{id} matching the active ^CF drops to "use + // default" (no pendingFontId) so re-emit stays terse; otherwise + // pin the alias. + if (s.defaults.cfFontId && fontChar === s.defaults.cfFontId) { + s.field.pendingFontId = undefined; + } else { + s.field.pendingFontId = fontChar; + } + if ( + !s.fonts.aliases.has(fontChar) && + !ZPL_BUILTIN_FONT_LETTERS.includes(fontChar) + ) { + s.result.partialCmds.add(`^${cmd}`); + } + }, + }; } /** Field-shaping commands: FO/FT, A0/A@, TB, CF, FW, FB, FH, FD/FS, diff --git a/src/lib/zplParser/types.ts b/src/lib/zplParser/types.ts index 17191da0..eb3770f6 100644 --- a/src/lib/zplParser/types.ts +++ b/src/lib/zplParser/types.ts @@ -47,7 +47,16 @@ export interface ParsedZPL { importReport: ImportReport; } -export type Handler = (p: string[], rest: string) => void; +export type Handler = (p: string[], rest: string, cmd: string) => void; + +/** Pattern-matched handler entry: used when a command's key is + * variable (e.g. `^A{font}` with `{font}` ∈ 0..Z) so an exact-key + * table cannot express the dispatch. Each handler module owns its + * own wildcards and exports them alongside the exact-match table. */ +export interface Wildcard { + matches: (cmd: string) => boolean; + handle: Handler; +} /** Result of decoding any GF-shaped graphic into an image-cache entry. */ export interface DecodedGraphic { From 6517b3ef95e0db6081399399462257cfd2d98ef0 Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 4 Jun 2026 10:03:36 +0200 Subject: [PATCH 4/5] refactor(zpl): split ^BR magnification from ^BY moduleWidth slot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ^BR p[2] is a 1-10 magnification multiplier, not a dot quantity. The parser previously wrote it into `defaults.byModuleWidth` (the shared ^BY slot, dot-typed everywhere else), so a ^BR field would silently overwrite real dot widths from ^BY and leak the magnification value into subsequent non-gs1 barcodes. Parser side: - `FieldState.gsMagnification` (1-10 or undefined) carries ^BR p[2] on its own slot. Reset at ^FS so a bare ^BR^FS doesn't leak. - Out-of-range values fall back to `defaults.byModuleWidth` at flush time, preserving the prior "single sizing knob" semantics when ^BY and ^BR carry the same value (the common case). Schema rename: - `Gs1DatabarProps.moduleWidth` → `magnification`. The value always represented the ^BR multiplier semantically; the rename ends the confusion with the dot-typed ^BY value of the same name. - Generator emits `magnification` to both ^BY and ^BR slots per the Zebra convention. Imports with mismatched ^BY/^BR values normalize to the ^BR slot (firmware behavior), so `^BY4 ^BR_,_,6` re-emits as `^BY6 ^BR_,_,6`. Schema migration: - designFile.parseDesignFile and labelStore persist (v5→v6) walk the object tree and rename `props.moduleWidth` → `props.magnification` for gs1databar leaves. Both routes share a new `objectTree.ts` walker. Migration is idempotent and preserves an existing magnification key. Locale rename: - `registry.gs1databar.moduleWidth` → `registry.gs1databar.magnification` across 32 locales. Display value seeded from aztec/qrcode.magnification ("Size (1-10)" in en) so the user-facing label matches how the rest of the registry names the same 1-10 knob. --- src/components/Canvas/bwipHelpers.test.ts | 4 +- src/components/Canvas/bwipHelpers.ts | 8 +- src/lib/designFile.test.ts | 95 +++++++++++++++++++++++ src/lib/designFile.ts | 24 ++++++ src/lib/objectTree.ts | 32 ++++++++ src/lib/zplGenerator.test.ts | 13 ++++ src/lib/zplParser.test.ts | 22 ++++++ src/lib/zplParser/context.ts | 9 ++- src/lib/zplParser/flushField.ts | 5 +- src/lib/zplParser/handlers/barcodes.ts | 6 +- src/lib/zplParser/handlers/fields.ts | 1 + src/lib/zplParser/handlers/units.ts | 2 - src/lib/zplParser/helpers.ts | 4 +- src/locales/ar.ts | 2 +- src/locales/bg.ts | 2 +- src/locales/cs.ts | 2 +- src/locales/da.ts | 2 +- src/locales/de.ts | 2 +- src/locales/el.ts | 2 +- src/locales/en.ts | 2 +- src/locales/es.ts | 2 +- src/locales/et.ts | 2 +- src/locales/fa.ts | 2 +- src/locales/fi.ts | 2 +- src/locales/fr.ts | 2 +- src/locales/he.ts | 2 +- src/locales/hr.ts | 2 +- src/locales/hu.ts | 2 +- src/locales/it.ts | 2 +- src/locales/ja.ts | 2 +- src/locales/ko.ts | 2 +- src/locales/lt.ts | 2 +- src/locales/lv.ts | 2 +- src/locales/nl.ts | 2 +- src/locales/no.ts | 2 +- src/locales/pl.ts | 2 +- src/locales/pt.ts | 2 +- src/locales/ro.ts | 2 +- src/locales/sk.ts | 2 +- src/locales/sl.ts | 2 +- src/locales/sr.ts | 2 +- src/locales/sv.ts | 2 +- src/locales/tr.ts | 2 +- src/locales/zh-hans.ts | 2 +- src/locales/zh-hant.ts | 2 +- src/registry/gs1databar.panel.tsx | 6 +- src/registry/gs1databar.ts | 8 +- src/store/labelStore.ts | 27 ++++++- src/test/testModels.ts | 12 +-- 49 files changed, 280 insertions(+), 62 deletions(-) create mode 100644 src/lib/objectTree.ts diff --git a/src/components/Canvas/bwipHelpers.test.ts b/src/components/Canvas/bwipHelpers.test.ts index 7d970a59..c049d330 100644 --- a/src/components/Canvas/bwipHelpers.test.ts +++ b/src/components/Canvas/bwipHelpers.test.ts @@ -162,7 +162,7 @@ describe("getDisplaySize gs1databar sym 7 fallback", () => { rotation: 0, props: { content: "0112345678901231", - moduleWidth: 2, + magnification: 2, symbology: 7, segments: 22, rotation: "N", @@ -194,7 +194,7 @@ describe("buildBwipOptions gs1databar Expanded fallback", () => { rotation: 0, props: { content, - moduleWidth: 2, + magnification: 2, symbology: 6, segments: 22, rotation: "N", diff --git a/src/components/Canvas/bwipHelpers.ts b/src/components/Canvas/bwipHelpers.ts index ba349577..b09c41b9 100644 --- a/src/components/Canvas/bwipHelpers.ts +++ b/src/components/Canvas/bwipHelpers.ts @@ -484,7 +484,7 @@ export function buildBwipOptions( } case "gs1databar": { const p = obj.props; - const scale = bwipScale1D(p.moduleWidth, renderScale, renderDpmm); + const scale = bwipScale1D(p.magnification, renderScale, renderDpmm); const sym = p.symbology; const isExpanded = GS1_DATABAR_EXPANDED_SYMBOLOGIES.has(sym); // bwip-js needs (AI)data parens; canonical model stores raw digits. @@ -741,7 +741,7 @@ export function getDisplaySize( // bwip renders upright. let bitmapCrop: BarcodeDisplaySize["bitmapCrop"]; if (obj.type === "gs1databar") { - const bwipSc = get1DBwipScale(obj.props.moduleWidth, scale, dpmm); + const bwipSc = get1DBwipScale(obj.props.magnification, scale, dpmm); const padPx = GS1_DATABAR_PADDING_ROWS * bwipSc; if (canvas.height > 2 * padPx) { bitmapCrop = { @@ -823,8 +823,8 @@ function getUprightDisplaySize( return { w, h }; } case "gs1databar": { - const modulePx = dotsToPx(obj.props.moduleWidth, scale, dpmm); - const bwipSc = get1DBwipScale(obj.props.moduleWidth, scale, dpmm); + const modulePx = dotsToPx(obj.props.magnification, scale, dpmm); + const bwipSc = get1DBwipScale(obj.props.magnification, scale, dpmm); const w = (cw / bwipSc) * modulePx; // bwip-js renders most non-stacked variants at the omni (33-module) // height regardless of the actual symbology, so trusting `ch` would diff --git a/src/lib/designFile.test.ts b/src/lib/designFile.test.ts index a668bdb5..505b5a08 100644 --- a/src/lib/designFile.test.ts +++ b/src/lib/designFile.test.ts @@ -68,6 +68,101 @@ describe('parseDesignFile', () => { expect(result.error).toBe('invalid_schema'); }); + it('migrates legacy gs1databar inside a nested group', () => { + const legacyJson = JSON.stringify({ + schemaVersion: 1, + label: { widthMm: 100, heightMm: 60, dpmm: 8 }, + pages: [ + { + objects: [ + { + id: 'g', + type: 'group', + x: 0, + y: 0, + rotation: 0, + children: [ + { + id: 'g1', + type: 'gs1databar', + x: 0, + y: 0, + rotation: 0, + props: { content: '0112345678901', moduleWidth: 5, symbology: 1, rotation: 'N' }, + }, + ], + }, + ], + }, + ], + }); + const result = parseDesignFile(legacyJson); + expect(result.ok).toBe(true); + if (!result.ok) return; + const group = result.value.pages[0]?.objects[0] as { children?: unknown[] }; + const child = group.children?.[0] as { props: Record }; + expect(child.props.magnification).toBe(5); + expect(child.props.moduleWidth).toBeUndefined(); + }); + + it('gs1databar migration is idempotent and prefers existing magnification', () => { + // A re-saved file may already carry both keys; the migration must + // leave the new key intact and not overwrite it from the legacy slot. + const mixed = JSON.stringify({ + schemaVersion: 1, + label: { widthMm: 100, heightMm: 60, dpmm: 8 }, + pages: [ + { + objects: [ + { + id: 'g1', + type: 'gs1databar', + x: 0, + y: 0, + rotation: 0, + props: { content: '01', moduleWidth: 9, magnification: 4, symbology: 1, rotation: 'N' }, + }, + ], + }, + ], + }); + const result = parseDesignFile(mixed); + expect(result.ok).toBe(true); + if (!result.ok) return; + const obj = result.value.pages[0]?.objects[0] as unknown as { props: Record }; + expect(obj.props.magnification).toBe(4); + expect(obj.props.moduleWidth).toBe(9); // untouched when magnification already present + }); + + it('migrates legacy gs1databar props.moduleWidth → props.magnification', () => { + const legacyJson = JSON.stringify({ + schemaVersion: 1, + label: { widthMm: 100, heightMm: 60, dpmm: 8 }, + pages: [ + { + objects: [ + { + id: 'g1', + type: 'gs1databar', + x: 0, + y: 0, + rotation: 0, + props: { content: '0112345678901', moduleWidth: 4, symbology: 1, rotation: 'N' }, + }, + ], + }, + ], + }); + const result = parseDesignFile(legacyJson); + expect(result.ok).toBe(true); + if (!result.ok) return; + const obj = result.value.pages[0]?.objects[0]; + expect(obj?.type).toBe('gs1databar'); + const p = (obj as unknown as { props: Record }).props; + expect(p.magnification).toBe(4); + expect(p.moduleWidth).toBeUndefined(); + }); + it('rejects an unknown schemaVersion as invalid_schema', () => { const futureJson = JSON.stringify({ schemaVersion: 999, diff --git a/src/lib/designFile.ts b/src/lib/designFile.ts index d0617158..19bbf2e0 100644 --- a/src/lib/designFile.ts +++ b/src/lib/designFile.ts @@ -8,6 +8,7 @@ import { type CsvMapping, } from "../types/Variable"; import type { LabelObject } from "../types/Group"; +import { visitLeavesInPages } from "./objectTree"; import { ok, err, type Result } from "./result"; /** Current design-file schema version. Bump when the persisted shape @@ -69,6 +70,8 @@ export function parseDesignFile(text: string): Result { + if ( + leaf.type === "gs1databar" && + leaf.props && + typeof leaf.props === "object" && + "moduleWidth" in leaf.props && + !("magnification" in leaf.props) + ) { + const props = leaf.props as Record; + props.magnification = props.moduleWidth; + delete props.moduleWidth; + } + }); +} + interface SerializedDesign { schemaVersion: number; label: LabelConfig; diff --git a/src/lib/objectTree.ts b/src/lib/objectTree.ts new file mode 100644 index 00000000..9a25ec04 --- /dev/null +++ b/src/lib/objectTree.ts @@ -0,0 +1,32 @@ +/** Shape-erasing helpers for walking a persisted object tree without + * committing to a parsed type. Migrations and the JSON-import path + * both need to visit leaves before the schema is locked in, so the + * walkers must accept `unknown`. */ + +interface UnknownNode { + type?: unknown; + props?: unknown; + children?: unknown; +} + +/** Apply `visit` to every leaf (non-group) node found under `pages`. + * `pages` is treated permissively: any nested `objects`/`children` + * arrays are walked. Mutates leaves in-place via the visitor. */ +export function visitLeavesInPages(pages: unknown, visit: (leaf: UnknownNode) => void): void { + if (!Array.isArray(pages)) return; + for (const page of pages) walkNode(page, visit); +} + +function walkNode(node: unknown, visit: (leaf: UnknownNode) => void): void { + if (!node || typeof node !== "object") return; + const n = node as UnknownNode & { objects?: unknown }; + if (Array.isArray(n.objects)) { + for (const child of n.objects) walkNode(child, visit); + return; + } + if (Array.isArray(n.children)) { + for (const child of n.children) walkNode(child, visit); + return; + } + visit(n); +} diff --git a/src/lib/zplGenerator.test.ts b/src/lib/zplGenerator.test.ts index 34a32bad..3d475567 100644 --- a/src/lib/zplGenerator.test.ts +++ b/src/lib/zplGenerator.test.ts @@ -739,6 +739,19 @@ describe('generateZPL — parse/generate roundtrip', () => { expect(reparsed.labelConfig.muResampling).toEqual({ formatDpi: 200, outputDpi: 600 }); }); + it('emits gs1databar magnification into both ^BY and ^BR slots', () => { + const objs = [{ + id: 'g1', type: 'gs1databar', x: 0, y: 0, rotation: 0, + props: { content: '0112345678901', magnification: 5, symbology: 1 as const, rotation: 'N' as const }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }] as any; + const zpl = generateZPL(BASE_LABEL, objs); + expect(zpl).toContain('^BY5'); + expect(zpl).toContain('^BRN,1,5,2,'); + const reparsed = parseZPL(zpl, 8); + expect(props(reparsed.objects[0]).magnification).toBe(5); + }); + it('preserves text content through a roundtrip', () => { const original = parseZPL('^XA^FO10,20^A0N,30,0^FDHello World^FS^XZ', 8); const regenerated = generateZPL(BASE_LABEL, original.objects); diff --git a/src/lib/zplParser.test.ts b/src/lib/zplParser.test.ts index 818b64ab..eb6dfe38 100644 --- a/src/lib/zplParser.test.ts +++ b/src/lib/zplParser.test.ts @@ -337,6 +337,28 @@ describe('parseZPL — ^BC Code 128', () => { }); }); +describe('parseZPL — ^BR GS1 Databar', () => { + it('reads ^BR p[2] as the gs1databar magnification, not byModuleWidth', () => { + // ^BY4 sets dot-typed module width 4; ^BR p[2]=6 is the multiplier. + // Pre-refactor both wrote into byModuleWidth so the 4 was clobbered. + const { objects } = parseZPL( + '^XA^BY4,2,100^FO0,0^BRN,1,6,2,100^FD0112345678901^FS^XZ', + 8, + ); + const obj = objects[0]; + expect(obj?.type).toBe('gs1databar'); + expect(props(obj).magnification).toBe(6); + }); + + it('falls back to ^BY moduleWidth when ^BR omits the magnification slot', () => { + const { objects } = parseZPL( + '^XA^BY3,2,100^FO0,0^BRN,1^FD0112345678901^FS^XZ', + 8, + ); + expect(props(objects[0]).magnification).toBe(3); + }); +}); + // ── ^FX comment ─────────────────────────────────────────────────────────────── describe('parseZPL — ^FX comment', () => { diff --git a/src/lib/zplParser/context.ts b/src/lib/zplParser/context.ts index f8b7ba23..f1d926fc 100644 --- a/src/lib/zplParser/context.ts +++ b/src/lib/zplParser/context.ts @@ -56,9 +56,7 @@ export interface CommentState { fnComment: string | undefined; } -/** Format-scoped (per-^XA) state; reset at label boundary - * EXCEPT for `unitScale`, which the ^MU spec defines as carrying - * across formats until explicitly overridden. */ +/** Format-scoped (per-^XA) state; reset at label boundary. */ export interface FormatState { embedChar: string; clockChars: ClockChars; @@ -131,6 +129,10 @@ export interface FieldState { // GS1 Databar pending gsSymbology: Gs1DatabarProps["symbology"]; gsSegments: number | undefined; + /** ^BR p[2] magnification multiplier (1-10). Per spec NOT a dot + * quantity; kept separate from `defaults.byModuleWidth` so a stray + * ^BR doesn't overwrite real dot widths from ^BY. */ + gsMagnification: number | undefined; // 2D matrix pending qrMag: number; dmDim: number; @@ -267,6 +269,7 @@ export function createParserState(): ParserState { symW: 30, gsSymbology: 1, gsSegments: undefined, + gsMagnification: undefined, qrMag: 4, dmDim: 5, dmQuality: 200, diff --git a/src/lib/zplParser/flushField.ts b/src/lib/zplParser/flushField.ts index b2822985..7e7ed7b0 100644 --- a/src/lib/zplParser/flushField.ts +++ b/src/lib/zplParser/flushField.ts @@ -367,7 +367,10 @@ export function createFlushField( s.field.y, { content, - moduleWidth: s.defaults.byModuleWidth, + // ^BR p[2] is the authoritative magnification source; + // fall back to ^BY moduleWidth when ^BR omitted p[2] + // (Zebra convention: ^BY and ^BR magnification match). + magnification: s.field.gsMagnification ?? s.defaults.byModuleWidth, symbology: s.field.gsSymbology, segments: s.field.gsSegments, rotation: s.field.bcRotation, diff --git a/src/lib/zplParser/handlers/barcodes.ts b/src/lib/zplParser/handlers/barcodes.ts index df9d3ba5..11cd9444 100644 --- a/src/lib/zplParser/handlers/barcodes.ts +++ b/src/lib/zplParser/handlers/barcodes.ts @@ -89,8 +89,10 @@ export function createBarcodeHandlers(s: ParserState): Record { BR(p) { field.fieldType = "gs1databar"; field.bcRotation = readRotation(p[0]); - // p[2] is magnification (integer multiplier 1-10), not a dot quantity. - defaults.byModuleWidth = int(p[2], defaults.byModuleWidth); + // p[2] is the ^BR magnification multiplier (1-10), not a dot + // quantity. Out-of-range falls back to ^BY at flush time. + const mag = int(p[2]); + field.gsMagnification = mag >= 1 && mag <= 10 ? mag : undefined; field.gsSymbology = (int(p[1], 1) as Gs1DatabarProps["symbology"]) || 1; field.gsSegments = p[5] !== undefined diff --git a/src/lib/zplParser/handlers/fields.ts b/src/lib/zplParser/handlers/fields.ts index 5aa4ed6f..8fd0b25c 100644 --- a/src/lib/zplParser/handlers/fields.ts +++ b/src/lib/zplParser/handlers/fields.ts @@ -159,6 +159,7 @@ export function createFieldHandlers( s.field.frActive = false; s.field.fpDirection = "H"; s.field.fpCharGap = 0; + s.field.gsMagnification = undefined; s.defaults.fbWidth = 0; s.defaults.fbLines = 1; s.defaults.fbSpacing = 0; diff --git a/src/lib/zplParser/handlers/units.ts b/src/lib/zplParser/handlers/units.ts index e07fe5e3..01097c4e 100644 --- a/src/lib/zplParser/handlers/units.ts +++ b/src/lib/zplParser/handlers/units.ts @@ -13,8 +13,6 @@ export function createUnitsHandler(s: ParserState, dpmm: number): Record = { onChange({ moduleWidth })} + onChange={(magnification) => onChange({ magnification })} />
diff --git a/src/registry/gs1databar.ts b/src/registry/gs1databar.ts index 6baaa425..c3ab6b06 100644 --- a/src/registry/gs1databar.ts +++ b/src/registry/gs1databar.ts @@ -6,7 +6,9 @@ import { GS1_DATABAR_DEFAULT_SEGMENTS } from '../lib/gs1'; export interface Gs1DatabarProps { content: string; - moduleWidth: number; + /** ^BR p[2] magnification multiplier (1-10). Generator emits the + * same value to ^BY moduleWidth by Zebra convention. */ + magnification: number; symbology: 1 | 2 | 3 | 4 | 5 | 6 | 7; segments?: number; rotation: ZplRotation; @@ -37,7 +39,7 @@ export const gs1databar: ObjectTypeCore = { bindable: true, defaultProps: { content: '0112345678901', - moduleWidth: 2, + magnification: 2, symbology: 1, rotation: 'N', }, @@ -49,6 +51,6 @@ export const gs1databar: ObjectTypeCore = { // ^BRo,s,m,sep,h[,sg] — segments must only be present for Expanded Stacked (7). // Including segments on sym 1–6 makes Labelary stack the symbol (wrong rendering). const segs = p.symbology === 7 ? `,${p.segments ?? GS1_DATABAR_DEFAULT_SEGMENTS}` : ''; - return `^BY${p.moduleWidth}${fieldPos(obj)}^BR${p.rotation},${p.symbology},${p.moduleWidth},2,${ZPL_HEIGHT_PLACEHOLDER}${segs}${fdFieldFor(obj, p.content, ctx)}`; + return `^BY${p.magnification}${fieldPos(obj)}^BR${p.rotation},${p.symbology},${p.magnification},2,${ZPL_HEIGHT_PLACEHOLDER}${segs}${fdFieldFor(obj, p.content, ctx)}`; }, }; diff --git a/src/store/labelStore.ts b/src/store/labelStore.ts index 9a3b96d6..442bb56f 100644 --- a/src/store/labelStore.ts +++ b/src/store/labelStore.ts @@ -3,6 +3,7 @@ import { temporal } from 'zundo'; import { persist, createJSONStorage } from 'zustand/middleware'; import type { ObjectChanges } from '../types/LabelObject'; import { PRINTER_PROFILE_FIELDS } from '../types/PrinterProfile'; +import { visitLeavesInPages } from '../lib/objectTree'; import type { LabelObject } from '../types/Group'; import { createPrinterProfileSlice, @@ -103,9 +104,33 @@ export function migrateLegacy(persistedState: unknown, version: number): unknown s = { ...s, printerProfile: {} }; } + // v5→v6: gs1databar props.moduleWidth → props.magnification. The + // value was always semantically the ^BR magnification multiplier + // (1-10), not a dot quantity; the rename clarifies that. + if (version < 6) { + s = { ...s, pages: migrateGs1DatabarInPages(s.pages) }; + } + return s; } +function migrateGs1DatabarInPages(pages: unknown): unknown { + // Mutates in place via the shared leaf walker; pages is the persisted + // payload not the live store, so mutation is safe. + visitLeavesInPages(pages, (leaf) => { + if ( + leaf.type !== 'gs1databar' || + !leaf.props || + typeof leaf.props !== 'object' + ) return; + const props = leaf.props as Record; + if (!('moduleWidth' in props) || 'magnification' in props) return; + props.magnification = props.moduleWidth; + delete props.moduleWidth; + }); + return pages; +} + function migrateCirclesInPages(pages: unknown): unknown { if (!Array.isArray(pages)) return pages; return pages.map((page) => { @@ -180,7 +205,7 @@ export const useLabelStore = create()( }), { name: 'zpl-designer-session', - version: 5, + version: 6, migrate: (persistedState, version) => migrateLegacy(persistedState, version) as LabelState, storage: createJSONStorage(() => localStorage), partialize: persistPartialize, diff --git a/src/test/testModels.ts b/src/test/testModels.ts index 70ba9f44..4c0fcd4b 100644 --- a/src/test/testModels.ts +++ b/src/test/testModels.ts @@ -325,7 +325,7 @@ export const testModels: Record = { x: 50, y: 50, rotation: 0, - props: { content: "0112345678901", moduleWidth: 2, symbology: 1, rotation: "N" }, + props: { content: "0112345678901", magnification: 2, symbology: 1, rotation: "N" }, }, barcode_gs1databar_truncated: { id: "gs1_2", @@ -333,7 +333,7 @@ export const testModels: Record = { x: 50, y: 50, rotation: 0, - props: { content: "0112345678901", moduleWidth: 2, symbology: 2, rotation: "N" }, + props: { content: "0112345678901", magnification: 2, symbology: 2, rotation: "N" }, }, barcode_gs1databar_stacked: { id: "gs1_3", @@ -341,7 +341,7 @@ export const testModels: Record = { x: 50, y: 50, rotation: 0, - props: { content: "0112345678901", moduleWidth: 2, symbology: 3, rotation: "N" }, + props: { content: "0112345678901", magnification: 2, symbology: 3, rotation: "N" }, }, barcode_gs1databar_stacked_omni: { id: "gs1_4", @@ -349,7 +349,7 @@ export const testModels: Record = { x: 50, y: 50, rotation: 0, - props: { content: "0112345678901", moduleWidth: 2, symbology: 4, rotation: "N" }, + props: { content: "0112345678901", magnification: 2, symbology: 4, rotation: "N" }, }, barcode_gs1databar_limited: { id: "gs1_5", @@ -357,7 +357,7 @@ export const testModels: Record = { x: 50, y: 50, rotation: 0, - props: { content: "0112345678901", moduleWidth: 2, symbology: 5, rotation: "N" }, + props: { content: "0112345678901", magnification: 2, symbology: 5, rotation: "N" }, }, barcode_gs1databar_expanded: { id: "gs1_6", @@ -365,7 +365,7 @@ export const testModels: Record = { x: 50, y: 50, rotation: 0, - props: { content: "0112345678901231", moduleWidth: 2, symbology: 6, rotation: "N" }, + props: { content: "0112345678901231", magnification: 2, symbology: 6, rotation: "N" }, }, barcode_upce_standard: { id: "29", From f2ebcd6e1e5e624c7f7b229aab1972bb2a7d5e51 Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 4 Jun 2026 10:39:36 +0200 Subject: [PATCH 5/5] refactor(zpl): guard walkNode against bare-array input Defensive: arrays satisfy `typeof node === "object"` so a caller passing an array directly would have fallen through to `visit(n)` with the array itself. Current callers iterate before recursing, so this is theoretical, but the guard removes the surprise. --- src/lib/objectTree.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/objectTree.ts b/src/lib/objectTree.ts index 9a25ec04..2d4a7dd1 100644 --- a/src/lib/objectTree.ts +++ b/src/lib/objectTree.ts @@ -18,7 +18,7 @@ export function visitLeavesInPages(pages: unknown, visit: (leaf: UnknownNode) => } function walkNode(node: unknown, visit: (leaf: UnknownNode) => void): void { - if (!node || typeof node !== "object") return; + if (!node || typeof node !== "object" || Array.isArray(node)) return; const n = node as UnknownNode & { objects?: unknown }; if (Array.isArray(n.objects)) { for (const child of n.objects) walkNode(child, visit);