diff --git a/packages/core/src/lib/zplImportService.ts b/packages/core/src/lib/zplImportService.ts index 9ac3b57f..3e1e35ce 100644 --- a/packages/core/src/lib/zplImportService.ts +++ b/packages/core/src/lib/zplImportService.ts @@ -1,7 +1,6 @@ import { parseZPL, type ImportFinding, type ImportReport } from "./zplParser"; import { replayRiskFindings, dedupCommandsByKind } from "./importReport"; import { dropPageOverlays } from "./pageOverlay"; -import { pruneUndefined } from "./pruneUndefined"; import { stripDrivePrefix } from "./customFonts"; import { renameTemplateMarkers } from "./fnTemplate"; import type { CustomFontMapping, LabelConfig } from "../types/LabelConfig"; @@ -26,106 +25,35 @@ export interface ZplImportResult { mixedPageGeometry: boolean; } -interface SplitBlocks { - /** Command text before the first `^XA` (e.g. `~DY` font uploads). Empty - * when the head holds no command token, so pasted prose stays discarded. */ - preamble: string; - /** One entry per `^XA...^XZ` document. */ - blocks: string[]; -} - -/** - * Splits a ZPL stream into one block per `^XA...^XZ` document plus the - * preamble that precedes the first `^XA`. ZPL commands are case-insensitive - * per spec. - */ -function splitIntoLabelBlocks(zpl: string): SplitBlocks { - // Capture group preserves the matched delimiter so mixed-case (^xa) survives. - const parts = zpl.split(/(\^XA)/i); - const blocks: string[] = []; - for (let i = 1; i < parts.length; i += 2) { - blocks.push((parts[i] ?? '') + (parts[i + 1] ?? '')); - } - // ~DY uploads emit before the first ^XA; keep the head only when it - // carries a command token (^ or ~). Pure prose is discarded so junk - // imports still yield nothing. - const head = parts[0] ?? ''; - const preamble = /[\^~]/.test(head) ? head : ''; - return { preamble, blocks }; -} - export function importZplText(zpl: string, dpmm: number): ZplImportResult { - const { preamble, blocks } = splitIntoLabelBlocks(zpl); - - if (blocks.length === 0 && !preamble) { - return { - labelConfig: {}, - printerProfile: {}, - pages: [], - variables: [], - report: { findings: [], partial: [], browserLimit: [], unknown: [], replayRisk: [], deviceAction: [] }, - mixedPageGeometry: false, - }; - } + // Single pass: stream-persistent state (^MU, ^CC/^CT/^CD, ^CI, ^CW/uploads, + // ^CF/^BY, ^LH/^LT/^LR) carries across ^XA blocks, and the parser owns the + // page boundaries (prefix-aware, unlike a literal ^XA split). + const r = parseZPL(zpl, dpmm, { captureOverlay: true }); - // Prepend the preamble to block 0 so a font's ~DY and its ^CW alias - // decode in one parser pass. Without ^XA the preamble runs alone. - const hasLabelBlocks = blocks.length > 0; - const parseUnits = hasLabelBlocks - ? blocks.map((b, i) => (i === 0 ? preamble + b : b)) - : [preamble]; - const uploadedFontPaths: string[] = []; - const normPath = (p: string) => p.trim().toUpperCase(); - // Strip only the uploaded side: driveless refs match any drive on the - // printer, but two drived refs on different drives stay distinct. - const strippedNorm = (p: string) => stripDrivePrefix(normPath(p)); - // ^CW aliases + ^A@ direct refs across all blocks; later-block claims - // also exclude a preamble-uploaded font. - const designFontPaths = new Set(); - - let labelConfig: Partial = {}; - // Merge profile fields across blocks: later blocks' values win so a - // multi-block ZPL with re-stated Setup-Script commands resolves to - // the last-seen state (mirrors what the printer would actually end - // up with after executing the stream). - const printerProfile: Partial = {}; const pages: Page[] = []; const findings: ImportFinding[] = []; - // Variables are document-level; blocks merge by source fnNumber only when + // Variables are document-level; pages merge by source fnNumber only when // the ^FD defaults agree (^FN is scoped per ^XA format, so a shared slot // with a different default is a distinct field). A divergent default // becomes a separate Variable on a free fnNumber, keeping fn // document-unique for mapping/batch/header. const variables: Variable[] = []; const variablesBySourceFn = new Map(); - // Parse all blocks up front: renumbering must avoid every source ^FN in - // the document (overlays replay original bytes, so a partial regeneration - // would otherwise collide with a verbatim same-numbered field). - const parsedBlocks = parseUnits.map((block) => parseZPL(block, dpmm, { captureOverlay: true })); - const usedFns = new Set(); - for (const r of parsedBlocks) for (const fn of r.sourceFnNumbers) usedFns.add(fn); + // Renumbering must avoid every source ^FN in the document (overlays replay + // original bytes, so a regenerated field would otherwise collide). + const usedFns = new Set(r.sourceFnNumbers); - // Cross-block customFonts merge by alias. Foreign ZPL can split a font - // upload from its ^CW alias (~DY in preamble, ^CW in a later block), - // and the per-block parser would otherwise lose the entry when block 0's - // labelConfig replaces all later labelConfigs. - const aggregatedCustomFonts = new Map(); - parsedBlocks.forEach((result, i) => { - uploadedFontPaths.push(...result.uploadedFontPaths); - for (const m of result.labelConfig.customFonts ?? []) { - if (m.path) designFontPaths.add(normPath(m.path)); - if (m.alias) aggregatedCustomFonts.set(m.alias, m); - } - for (const p of result.referencedFontPaths) designFontPaths.add(normPath(p)); - // Objects link to their variable by marker NAME. When this block's variable - // merges into an earlier block's (same ^FN number) under a different name, + r.pages.forEach((page, i) => { + // Objects link to their variable by marker NAME. When this page's variable + // merges into an earlier page's (same ^FN number) under a different name, // OR a new fnNumber's name collides and gets disambiguated, its `«name»` // markers must be renamed to the kept variable's name. const nameRemap = new Map(); - for (const v of result.variables) { + for (const v of page.variables) { const slotMates = variablesBySourceFn.get(v.fnNumber) ?? []; // An empty default is a bare slot declaration, not a divergent value - // (mirrors the parser's in-block backfill semantics). + // (mirrors the parser's in-page backfill semantics). const mate = slotMates.find( (m) => m.defaultValue === v.defaultValue || m.defaultValue === "" || v.defaultValue === "", ); @@ -148,8 +76,6 @@ export function importZplText(zpl: string, dpmm: number): ZplImportResult { fnNumber = free; findings.push({ kind: "fnRenumbered", command: `^FN${v.fnNumber} → ^FN${free}`, pageIndex: i }); } - // Disambiguate the name if a prior block took it (e.g. two `field_1` - // from different blocks). const uniqueName = uniqueVariableName(v.name, variables); if (uniqueName !== v.name) nameRemap.set(v.name, uniqueName); const kept: Variable = { ...v, name: uniqueName, fnNumber }; @@ -157,45 +83,67 @@ export function importZplText(zpl: string, dpmm: number): ZplImportResult { usedFns.add(fnNumber); variablesBySourceFn.set(v.fnNumber, [...slotMates, kept]); } - // Rename this block's content markers onto the kept variable names. + // Rename this page's content markers onto the kept variable names. // Defensive walk into groups; the parser does not produce groups, but the // helper is shape-agnostic. if (nameRemap.size > 0) { - rewireBindings(result.objects, nameRemap); + rewireBindings(page.objects, nameRemap); } - // A preamble-only unit (no ^XA) usually carries just fonts/profile. But a + // A bare page (no ^XA wrapper) usually carries just fonts/profile. But a // wrapper-less paste of real fields also lands here; import those as a page // so they aren't silently dropped (no overlay: the wrapper-less source has // nothing to replay byte-for-byte, and re-export adds the ^XA/^XZ wrapper). - if (hasLabelBlocks) { - pages.push({ objects: result.objects, overlay: result.overlay }); - } else if (result.objects.length > 0) { - pages.push({ objects: result.objects }); - } - if (i === 0) { - labelConfig = result.labelConfig; + if (!page.bare) { + pages.push({ objects: page.objects, overlay: page.overlay }); + } else if (page.objects.length > 0) { + pages.push({ objects: page.objects }); } - // Fold cross-block profile fields without leaking present-with- - // undefined keys: an explicit `undefined` from one block would - // otherwise overwrite a real value from a later block and end up - // in the returned ZplImportResult as a misleading "field cleared" - // signal for any consumer that bypasses patchPrinterProfile. - Object.assign(printerProfile, pruneUndefined(result.printerProfile)); - // Per-block findings come from the parser with pageIndex=0; stamp the - // real page index here so the UI can navigate to them. A wrapper-less unit - // is pushed without an overlay, so its lossyEdit caveat (which only matters - // when an overlay would be replayed) is moot; drop it. - for (const f of result.importReport.findings) { - if (!hasLabelBlocks && f.kind === "lossyEdit") continue; - findings.push({ ...f, pageIndex: i }); + for (const f of page.findings) { + // A bare page replays nothing, so its lossyEdit caveat is moot. + if (page.bare && f.kind === "lossyEdit") continue; + findings.push(f); } }); + // Single-label design: keep block 0's config. Per-format fields like ^PQ are + // block-scoped, so the document-accumulated last-write would leak later + // blocks' values. Fonts stay document-wide, and the ZPLLAB sidecar (dpmm, + // which plain ZPL can't carry) is a page-0 preamble that overrides the size. + const labelConfig: Partial = { ...r.pages[0]?.labelConfig }; + if (r.labelConfig.customFonts) labelConfig.customFonts = r.labelConfig.customFonts; + else delete labelConfig.customFonts; + if (r.labelConfig.dpmm !== undefined) { + labelConfig.dpmm = r.labelConfig.dpmm; + labelConfig.widthMm = r.labelConfig.widthMm; + labelConfig.heightMm = r.labelConfig.heightMm; + } + + // Dedup ^CW font entries by alias (a re-stated alias resolves to the last + // definition, as on the printer). + const fontEntries = labelConfig.customFonts ?? []; + const byAlias = new Map(); + for (const m of fontEntries) { + if (m.alias) byAlias.set(m.alias, m); + } + if (byAlias.size > 0) { + labelConfig.customFonts = [...byAlias.values()]; + } + // Uploaded fonts not claimed as design fonts are Setup-Script fonts. // Case-insensitive compare since external streams vary in casing. - const uploadedUnique = [...new Set(uploadedFontPaths)]; + const normPath = (p: string) => p.trim().toUpperCase(); + // Strip only the uploaded side: driveless refs match any drive on the + // printer, but two drived refs on different drives stay distinct. + const strippedNorm = (p: string) => stripDrivePrefix(normPath(p)); + const designFontPaths = new Set(); + for (const m of fontEntries) { + if (m.path) designFontPaths.add(normPath(m.path)); + } + for (const p of r.referencedFontPaths) designFontPaths.add(normPath(p)); + const uploadedUnique = [...new Set(r.uploadedFontPaths)]; const uploadedNormed = new Map(uploadedUnique.map((p) => [normPath(p), p])); const uploadedStripped = new Map(uploadedUnique.map((p) => [strippedNorm(p), p])); + const printerProfile: Partial = { ...r.printerProfile }; const setupFontPaths = uploadedUnique.filter( (p) => !designFontPaths.has(normPath(p)) && !designFontPaths.has(strippedNorm(p)), ); @@ -203,11 +151,10 @@ export function importZplText(zpl: string, dpmm: number): ZplImportResult { printerProfile.setupFonts = setupFontPaths.map((path) => ({ path })); } - // Backfill embedInZpl + previewFontName on aggregated entries whose - // path matches an uploaded font: the parser's ^CW handler can only - // see same-block uploads, so a foreign ZPL with split ~DY/^CW would - // otherwise drop the embed flag on re-export. - for (const m of aggregatedCustomFonts.values()) { + // Backfill embedInZpl + previewFontName on entries whose path matches an + // uploaded font: covers a ^CW that precedes its ~DY upload, which the + // in-pass handler cannot see. + for (const m of labelConfig.customFonts ?? []) { if (!m.path) continue; const upload = uploadedNormed.get(normPath(m.path)) ?? uploadedStripped.get(strippedNorm(m.path)); @@ -217,12 +164,21 @@ export function importZplText(zpl: string, dpmm: number): ZplImportResult { const filename = colon >= 0 ? upload.slice(colon + 1) : upload; if (filename && !m.previewFontName) m.previewFontName = filename; } - if (aggregatedCustomFonts.size > 0) { - labelConfig.customFonts = [...aggregatedCustomFonts.values()]; + + if (r.mixedPageGeometry) { + const sizes = [ + ...new Set( + r.pages + .map((p) => p.labelSize) + .filter((sz) => sz.widthMm !== undefined || sz.heightMm !== undefined) + .map((sz) => `${sz.widthMm ?? ''}x${sz.heightMm ?? ''}`), + ), + ]; + findings.push({ kind: 'mixedPageGeometry', command: sizes.join(', '), pageIndex: 0 }); } // Bucket views deduplicate by command code to match the JSDoc contract on - // ImportReport (see zplParser.ts). The per-occurrence model lives in + // ImportReport (zplParser/types.ts). The per-occurrence model lives in // `findings`; consumers that only need the set of distinct affected commands // read these buckets unchanged. Only command-based kinds get a bucket; a // block-level kind like 'lossyEdit' stays in `findings` by design. @@ -235,24 +191,14 @@ export function importZplText(zpl: string, dpmm: number): ZplImportResult { deviceAction: dedupCommandsByKind(findings, 'deviceAction'), }; - // ^PW/^LL persist across ^XA on a printer, so only an explicit re-statement - // to a different size is a real divergence. - const explicitSizes = new Set( - parsedBlocks - .map((r) => r.labelConfig) - .filter((lc) => lc.widthMm !== undefined || lc.heightMm !== undefined) - .map((lc) => `${lc.widthMm ?? ''}x${lc.heightMm ?? ''}`), - ); - const mixedPageGeometry = explicitSizes.size > 1; - if (mixedPageGeometry) { - report.findings.push({ - kind: 'mixedPageGeometry', - command: [...explicitSizes].join(', '), - pageIndex: 0, - }); - } - - return { labelConfig, printerProfile, pages, variables, report, mixedPageGeometry }; + return { + labelConfig, + printerProfile, + pages, + variables, + report, + mixedPageGeometry: r.mixedPageGeometry, + }; } /** Additive setup-font merge (dedupe by normalized path): a stream lists only diff --git a/packages/core/src/lib/zplParser.ts b/packages/core/src/lib/zplParser.ts index b2be0e06..dc6a500a 100644 --- a/packages/core/src/lib/zplParser.ts +++ b/packages/core/src/lib/zplParser.ts @@ -7,7 +7,7 @@ import { markerOf } from "../types/Variable"; import { getObjectStringContent } from "./variableBinding"; import { parseLabelMetaComment, type LabelMeta } from "./zplLabelMeta"; import { tokenize } from "./zplParser/helpers"; -import { createParserState } from "./zplParser/context"; +import { createParserState, resetFormatScopedState } from "./zplParser/context"; import { createFlushField } from "./zplParser/flushField"; import { createBarcodeHandlers } from "./zplParser/handlers/barcodes"; import { createDynamicFontAWildcard, createFieldHandlers } from "./zplParser/handlers/fields"; @@ -16,10 +16,11 @@ 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 { buildBlockOverlay, type LinkedSpan } from "./zplOverlay/overlay"; +import { buildBlockOverlay, type BlockOverlay, type LinkedSpan } from "./zplOverlay/overlay"; import type { Handler, ImportFinding, + ParsedPage, ParsedZPL, Wildcard, } from "./zplParser/types"; @@ -27,8 +28,36 @@ export type { ImportFindingKind, ImportFinding, ImportReport, + ParsedPage, ParsedZPL, } from "./zplParser/types"; +import type { LabelObject } from "../types/Group"; +import type { Variable } from "../types/Variable"; + +/** Normalize mode-D-exclusive ^FN defaults to model form (inverse of the emit + * escape; mixed slots stay raw, see gs1ModeDExclusiveFns). A lone-marker slot + * holds the whole payload and gets the full decode; an embedded slot is one + * AI's value, where canonicalization could mutate bytes (GTIN check digit), so + * only the >0 escape reverses. Scoped per page: a later page reusing the same + * ^FN number with a plain field must not inherit this page's GS1 decode. */ +function normalizeModeDDefaults(objects: readonly LabelObject[], variables: Variable[]): void { + const modeDFns = gs1ModeDExclusiveFns(objects, variables); + if (modeDFns.size === 0) return; + const fnByVarName = new Map(variables.map((v) => [v.name, v.fnNumber])); + const loneMarkerFns = new Set(); + for (const o of objects) { + const c = getObjectStringContent(o); + if (c === undefined || !isLoneMarker(c)) continue; + const fn = fnByVarName.get(extractTemplateRefs(c)[0] ?? ""); + if (fn !== undefined) loneMarkerFns.add(fn); + } + for (const v of variables) { + if (!modeDFns.has(v.fnNumber)) continue; + v.defaultValue = loneMarkerFns.has(v.fnNumber) + ? (zplFdToModelContent(v.defaultValue) ?? unescapeGs1FdValue(v.defaultValue)) + : unescapeGs1FdValue(v.defaultValue); + } +} /** Barcode object types whose module width comes from ^BY (mirrors the * `s.defaults.byModuleWidth` consumers in flushField). 2D codes that carry @@ -51,9 +80,11 @@ export function parseZPL( ): ParsedZPL { const s = createParserState(); const tokens = tokenize(zpl, s.format); + // partialCmds stays behind s.result: the Set is swapped at each page close + // so per-format repeats of a command are re-reported on their own page. const { objects, labelConfig, printerProfile, variables, - skipped, partialCmds, browserLimit, unknown, replayRisk, deviceAction, + browserLimit, unknown, replayRisk, deviceAction, } = s.result; const takeComment = (): string | undefined => { @@ -73,8 +104,10 @@ export function parseZPL( const resetComment: Handler = (_, rest) => { s.comment.pending = rest.trim() || undefined; }; - // Our geometry sidecar, consumed (not shown as an object comment) from the - // leading slot only, so a stray body ^FX can't rewrite the label settings. + // Our geometry sidecar, consumed (not shown as an object comment) only while + // no design object was seen, so a body ^FX can't rewrite the label settings. + // Deliberately document-wide, not page-0: a verbatim settings-only block + // precedes the regenerated design block (and its sidecar) on re-export. // Holder object so the closure write survives TS flow narrowing. const labelMeta: { value: LabelMeta | null } = { value: null }; // Multi-line ^FX before a field accumulate; XA/XZ reset at label boundaries. @@ -125,34 +158,161 @@ export function parseZPL( // so a handler-table entry always wins over a pattern-match. const wildcards: Wildcard[] = [createDynamicFontAWildcard(s)]; - // Overlay capture: a field runs ^FO/^FT … ^FS. `ovStart` is the field's source - // start (^FO/^FT), `ovBase` the object count when the field opened; a deferred - // reverse-bg that commits mid-field bumps `ovBase` so the field's own object is - // still found at `ovBase`. Linking is intentionally conservative: only clean - // single-object fields and deferred reverse-bg boxes (committed standalone - // before the field's own object) are linked. Any other shape leaves an object - // unlinked, which trips the all-linked gate below and drops the overlay so the - // block regenerates. const overlaySpans: LinkedSpan[] = []; const linkedIds = new Set(); - let ovStart: number | null = null; - let ovBase = 0; const linkObject = (start: number, end: number, objectId: string) => { overlaySpans.push({ start, end, link: { kind: "object", objectId } }); linkedIds.add(objectId); }; - // Running state that would re-interpret a regenerated object's bytes on - // replay (the raw command persists in a raw segment). Verbatim replay is - // unaffected; only the dirty/new regeneration path cares. Tracked once per - // block; drives the overlay's regenSafe flag. - let regenHostileFormat = false; - let sawNonUtf8Ci = false; - let sawBareBarcode = false; - let sawFnDeclaration = false; - // Start of a contiguous leading-comment (^FX) run immediately before a field, - // so the field's span can own its ^FX bytes (uniform verbatim/regen, and a - // comment edit regenerates without leaving a stale ^FX in a raw segment). - let commentStart: number | null = null; + + // Page bookkeeping: one page per ^XA block, closed at the NEXT ^XA (so the + // inter-block separator stays with the preceding page, which the overlay + // replay relies on). Page 0 opens at offset 0 and owns any preamble. + const pages: ParsedPage[] = []; + let mixedPageGeometry = false; + let sawXA = false; + let lastPageW: number | undefined; + let lastPageH: number | undefined; + + /** Page-scoped parse state, replaced wholesale at each page close so a new + * flag cannot miss the boundary reset. */ + const freshPageScope = (start: number) => ({ + // `start` plus the array-length marks delimit this page's slice of the + // shared result arrays. + start, + obj: objects.length, + vari: variables.length, + browser: browserLimit.length, + unknown: unknown.length, + replay: replayRisk.length, + device: deviceAction.length, + span: overlaySpans.length, + // Open field's source start (^FO/^FT or its leading ^FX run) and object + // count at open; a mid-field reverse-bg commit bumps `ovBase`. Only clean + // single-object fields and deferred reverse-bg boxes link; anything else + // trips the all-linked gate and the block regenerates. + ovStart: null as number | null, + ovBase: 0, + // Start of a contiguous ^FX run immediately before a field, so the + // field's span owns its comment bytes. + commentStart: null as number | null, + // Format state that would re-interpret a regenerated object's bytes on + // replay; drives the overlay's regenSafe flag (verbatim replay unaffected). + regenHostileFormat: false, + sawNonUtf8Ci: false, + sawBareBarcode: false, + sawFnDeclaration: false, + }); + let pg = freshPageScope(0); + + const bucketFindings = ( + pageIndex: number, + partial: readonly string[], + browser: readonly string[], + unk: readonly string[], + replay: readonly string[], + device: readonly string[], + ): ImportFinding[] => [ + ...partial.map((command): ImportFinding => ({ kind: "partial", command, pageIndex })), + ...browser.map((command): ImportFinding => ({ kind: "browserLimit", command, pageIndex })), + ...unk.map((command): ImportFinding => ({ kind: "unknown", command, pageIndex })), + ...replay.map((command): ImportFinding => ({ kind: "replayRisk", command, pageIndex })), + ...device.map((command): ImportFinding => ({ kind: "deviceAction", command, pageIndex })), + ]; + + /** Close the current page at source offset `end`: per-format serial-orphan + * sweep, per-page findings/overlay, then reset the format-scoped state so + * the next page starts clean (printer-persistent state carries on). */ + const closePage = (end: number): void => { + // ^SN stripped a single-bind marker: drop this format's variables that no + // marker in THIS page's objects points at (bare ^FN declarations stay). + for (let i = variables.length - 1; i >= pg.vari; i--) { + const v = variables[i]; + if (!v || !s.serialStrippedFns.has(v.fnNumber)) continue; + if (s.bareDeclaredFns.has(v.fnNumber)) continue; + const marker = markerOf(v.name); + let used = false; + for (let j = pg.obj; j < objects.length && !used; j++) { + const o = objects[j]; + used = o !== undefined && (getObjectStringContent(o)?.includes(marker) ?? false); + } + if (!used) variables.splice(i, 1); + } + const pagePartial = [...s.result.partialCmds]; + const pageObjects = objects.slice(pg.obj); + const pageVariables = variables.slice(pg.vari); + normalizeModeDDefaults(pageObjects, pageVariables); + const pageIndex = pages.length; + const findings = bucketFindings( + pageIndex, + pagePartial, + browserLimit.slice(pg.browser), + unknown.slice(pg.unknown), + replayRisk.slice(pg.replay), + deviceAction.slice(pg.device), + ); + const pageRegenSafe = + !pg.regenHostileFormat && !pg.sawNonUtf8Ci && !pg.sawBareBarcode && !pg.sawFnDeclaration; + let pageOverlay: BlockOverlay | undefined; + if (opts.captureOverlay && pageObjects.every((o) => linkedIds.has(o.id))) { + const frame = + s.label.lhX !== 0 || s.label.lhY !== 0 || s.label.ltY !== 0 + ? { homeX: s.label.lhX, homeY: s.label.lhY, top: s.label.ltY } + : undefined; + // buildBlockOverlay throws on a broken span invariant (unreachable + // today); catching drops the overlay instead of crashing import. + try { + pageOverlay = buildBlockOverlay( + zpl.slice(pg.start, end), + overlaySpans + .slice(pg.span) + .map((sp) => ({ ...sp, start: sp.start - pg.start, end: sp.end - pg.start })), + { regenSafe: pageRegenSafe, frame }, + ); + } catch (err) { + console.warn("buildBlockOverlay failed, dropping overlay for this page", err); + pageOverlay = undefined; + } + } + // A non-regenSafe overlay replays verbatim only until the first edit; + // surface that the byte-exact guarantee is conditional for this page. + if (pageOverlay && !pageRegenSafe) { + const reason = pg.sawNonUtf8Ci + ? "a non-UTF-8 ^CI encoding" + : pg.sawBareBarcode + ? "a barcode without an explicit ^BY" + : pg.sawFnDeclaration + ? "a standalone ^FN declaration" + : "a non-default format state (prefix, delimiter, unit, embed char, ^FC, or ^LR)"; + findings.push({ kind: "lossyEdit", command: reason, pageIndex }); + } + const w = labelConfig.widthMm; + const h = labelConfig.heightMm; + const page: ParsedPage = { + objects: pageObjects, + variables: pageVariables, + findings, + labelSize: { widthMm: w, heightMm: h }, + labelConfig: { ...labelConfig }, + }; + if (pageOverlay) page.overlay = pageOverlay; + if (!sawXA) page.bare = true; + pages.push(page); + // ^PW/^LL persist across ^XA, so only a value CHANGE between page closes is + // a real divergence the single-label model cannot represent. + if (w !== undefined || h !== undefined) { + if ( + (lastPageW !== undefined || lastPageH !== undefined) && + (w !== lastPageW || h !== lastPageH) + ) { + mixedPageGeometry = true; + } + lastPageW = w; + lastPageH = h; + } + pg = freshPageScope(end); + resetFormatScopedState(s); + }; for (const { cmd, rest, start } of tokens) { const p = rest.split(s.format.delimiterChar); @@ -179,15 +339,15 @@ export function parseZPL( // field re-derives default chars and would mis-clock under the raw ^FC. !isDefaultClockChars(s.format.clockChars) ) { - regenHostileFormat = true; + pg.regenHostileFormat = true; } // Any non-UTF-8 ^CI makes regen unsafe: a regenerated field emits UTF-8 // bytes that the surviving raw ^CI would mis-decode (the generator emits // ^CI28 only on the full-regen fallback, not in the overlay path). - if (s.format.fhDecoder.encoding !== "utf-8") sawNonUtf8Ci = true; + if (s.format.fhDecoder.encoding !== "utf-8") pg.sawNonUtf8Ci = true; // A bare ^FN declaration (^FN outside an ^FO…^FS field) becomes a raw // segment; the scoped header would re-emit it on regen and duplicate it. - if (cmd === "FN" && ovStart === null) sawFnDeclaration = true; + if (cmd === "FN" && pg.ovStart === null) pg.sawFnDeclaration = true; // Track the leading-comment run; a non-^FX command (other than the field // opener) breaks contiguity so the span won't swallow intervening config. // Known limitation: when a command separates a ^FX from its field, the @@ -195,9 +355,9 @@ export function parseZPL( // producing a duplicate ^FX in the output. Harmless (comments do not // print) and rare, so accepted rather than tracked per-object. if (cmd === "FX") { - if (commentStart === null) commentStart = start; + if (pg.commentStart === null) pg.commentStart = start; } else if (cmd !== "FO" && cmd !== "FT") { - commentStart = null; + pg.commentStart = null; } // A prior stashed reverse-bg committed standalone on this (non-^FS) // token (graphics command, ^XA/^XZ): commitPendingReverseBg pushes the @@ -210,46 +370,54 @@ export function parseZPL( ) { const box = objects[beforeLen]; if (box) linkObject(reverseBefore.span.start, reverseBefore.span.end, box.id); - if (ovStart !== null) ovBase++; + if (pg.ovStart !== null) pg.ovBase++; } if (cmd === "FO" || cmd === "FT") { - ovStart = commentStart ?? start; - ovBase = objects.length; - commentStart = null; - } else if (cmd === "FS" && ovStart !== null) { + pg.ovStart = pg.commentStart ?? start; + pg.ovBase = objects.length; + pg.commentStart = null; + } else if (cmd === "FS" && pg.ovStart !== null) { const fieldEnd = start + 3; // A ^BY-consuming barcode whose ^BY sits outside its own field would // inherit a regenerated neighbour's inline ^BY on replay; mark the // block unsafe. Classify by the parsed object type (not a regex) so // 2D codes that ignore ^BY (QR/DataMatrix/Aztec/MaxiCode) stay // regenSafe. The field's own object is the last one pushed. - const own = objects.length > ovBase ? objects[objects.length - 1] : undefined; + const own = objects.length > pg.ovBase ? objects[objects.length - 1] : undefined; if ( own && BY_CONSUMING_BARCODE_TYPES.has(own.type) && - !/\^BY/i.test(zpl.slice(ovStart, fieldEnd)) + !/\^BY/i.test(zpl.slice(pg.ovStart, fieldEnd)) ) { - sawBareBarcode = true; + pg.sawBareBarcode = true; } - if (s.reverseBg && s.reverseBg.span === undefined && objects.length === ovBase) { + if (s.reverseBg && s.reverseBg.span === undefined && objects.length === pg.ovBase) { // This field stashed a reverse-bg (no object yet); record its span // so the box can be linked when it commits later. - s.reverseBg.span = { start: ovStart, end: fieldEnd }; - } else if (reverseBefore?.span && s.reverseBg === null && objects.length === ovBase + 2) { - // Stashed box committed standalone during this flush (box at ovBase), - // then the field's own object (text/barcode) at ovBase+1. - const box = objects[ovBase]; - const own = objects[ovBase + 1]; + s.reverseBg.span = { start: pg.ovStart, end: fieldEnd }; + } else if (reverseBefore?.span && s.reverseBg === null && objects.length === pg.ovBase + 2) { + // Stashed box committed standalone during this flush (box at pg.ovBase), + // then the field's own object (text/barcode) at pg.ovBase+1. + const box = objects[pg.ovBase]; + const own = objects[pg.ovBase + 1]; if (box) linkObject(reverseBefore.span.start, reverseBefore.span.end, box.id); - if (own) linkObject(ovStart, fieldEnd, own.id); - } else if (objects.length === ovBase + 1) { + if (own) linkObject(pg.ovStart, fieldEnd, own.id); + } else if (objects.length === pg.ovBase + 1) { // Clean single-object field. - const obj = objects[ovBase]; - if (obj) linkObject(ovStart, fieldEnd, obj.id); + const obj = objects[pg.ovBase]; + if (obj) linkObject(pg.ovStart, fieldEnd, obj.id); } - ovStart = null; + pg.ovStart = null; } } + // Page boundary: the FIRST ^XA continues page 0 (which owns any + // preamble); every further ^XA closes the page at its own offset, after + // this token's capture block so a boundary-committed reverse-bg span + // still lands in the closing page. + if (cmd === "XA") { + if (sawXA) closePage(start); + sawXA = true; + } continue; } @@ -258,71 +426,13 @@ export function parseZPL( // trimEnd: `rest` runs to the next command, dragging the line break in // multi-line ZPL into the surfaced token. const token = `${zpl[start] ?? "^"}${cmd}${rest}`.trimEnd(); - skipped.push(token); unknown.push(token); } } - // A post-^FS ^SN stripped a single-bind marker somewhere; only now, with - // every field and embed parsed, is it known whether the shared slot is - // still referenced. Drop variables no marker points at, keeping bare - // ^FN declarations (marker-less by design). In-place: `variables` is the - // returned array. - for (let i = variables.length - 1; i >= 0; i--) { - const v = variables[i]; - if (!v || !s.serialStrippedFns.has(v.fnNumber)) continue; - if (s.bareDeclaredFns.has(v.fnNumber)) continue; - const marker = markerOf(v.name); - const used = objects.some((o) => getObjectStringContent(o)?.includes(marker)); - if (!used) variables.splice(i, 1); - } - - // Normalize mode-D-exclusive ^FN defaults to model form (inverse of the - // emit escape; mixed slots stay raw, see gs1ModeDExclusiveFns). A lone-marker - // slot holds the whole payload and gets the full decode; an embedded slot is - // one AI's value, where canonicalization could mutate bytes (GTIN check - // digit), so only the >0 escape reverses. - const modeDFns = gs1ModeDExclusiveFns(objects, variables); - if (modeDFns.size > 0) { - const fnByVarName = new Map(variables.map((v) => [v.name, v.fnNumber])); - const loneMarkerFns = new Set(); - for (const o of objects) { - const c = getObjectStringContent(o); - if (c === undefined || !isLoneMarker(c)) continue; - const fn = fnByVarName.get(extractTemplateRefs(c)[0] ?? ""); - if (fn !== undefined) loneMarkerFns.add(fn); - } - for (const v of variables) { - if (!modeDFns.has(v.fnNumber)) continue; - v.defaultValue = loneMarkerFns.has(v.fnNumber) - ? (zplFdToModelContent(v.defaultValue) ?? unescapeGs1FdValue(v.defaultValue)) - : unescapeGs1FdValue(v.defaultValue); - } - } - - // Build the overlay only when every parsed object linked to a source span; - // an unlinked object would double-emit (raw replay + model regenerate), so a - // partial capture is unsafe. Falls back to model regeneration for the block. - const allLinked = objects.every((o) => linkedIds.has(o.id)); - const regenSafe = - !regenHostileFormat && !sawNonUtf8Ci && !sawBareBarcode && !sawFnDeclaration; - const frame = - s.label.lhX !== 0 || s.label.lhY !== 0 || s.label.ltY !== 0 - ? { homeX: s.label.lhX, homeY: s.label.lhY, top: s.label.ltY } - : undefined; - // buildBlockOverlay throws on overlapping/out-of-bounds spans (a broken span - // invariant). Today that is unreachable; catching keeps the contract total - // (any capture problem drops the overlay rather than crashing import) and the - // warn surfaces the parser bug should a future change ever make it reachable. - let overlay: ParsedZPL["overlay"]; - if (opts.captureOverlay && allLinked) { - try { - overlay = buildBlockOverlay(zpl, overlaySpans, { regenSafe, frame }); - } catch (err) { - console.warn("buildBlockOverlay failed, dropping overlay for this block", err); - overlay = undefined; - } - } + // Close the last page (also the only one for single-block or bare streams); + // its serial-orphan sweep runs inside. + closePage(zpl.length); // Apply the geometry sidecar last so it wins over ^PW/^LL-derived mm and // restores dpmm, which plain ZPL can't carry. @@ -332,61 +442,13 @@ export function parseZPL( labelConfig.heightMm = labelMeta.value.heightMm; } - // pageIndex stays 0; zplImportService fills it per ^XA…^XZ block. - const findings: ImportFinding[] = [ - ...[...partialCmds].map( - (command): ImportFinding => ({ kind: "partial", command, pageIndex: 0 }), - ), - ...browserLimit.map( - (command): ImportFinding => ({ - kind: "browserLimit", - command, - pageIndex: 0, - }), - ), - ...unknown.map( - (command): ImportFinding => ({ kind: "unknown", command, pageIndex: 0 }), - ), - ...replayRisk.map( - (command): ImportFinding => ({ kind: "replayRisk", command, pageIndex: 0 }), - ), - ...deviceAction.map( - (command): ImportFinding => ({ kind: "deviceAction", command, pageIndex: 0 }), - ), - ]; - - // An overlay that exists but isn't regenSafe replays verbatim only until the - // first edit, then falls back to full regeneration (untouched comments / - // whitespace / unmodeled commands are not preserved). Surface that so the - // user knows the byte-exact guarantee is conditional for this block. - if (overlay && !regenSafe) { - const reason = sawNonUtf8Ci - ? "a non-UTF-8 ^CI encoding" - : sawBareBarcode - ? "a barcode without an explicit ^BY" - : sawFnDeclaration - ? "a standalone ^FN declaration" - : "a non-default format state (prefix, delimiter, unit, embed char, ^FC, or ^LR)"; - findings.push({ kind: "lossyEdit", command: reason, pageIndex: 0 }); - } - return { + pages, + mixedPageGeometry, labelConfig, printerProfile, - objects, - variables, uploadedFontPaths: [...s.fonts.downloadedFontPaths], referencedFontPaths: [...s.fonts.referencedFontPaths], sourceFnNumbers: s.result.sourceFnNumbers, - skipped, - importReport: { - findings, - partial: [...partialCmds], - browserLimit, - unknown, - replayRisk, - deviceAction, - }, - overlay, }; } diff --git a/packages/core/src/lib/zplParser/context.ts b/packages/core/src/lib/zplParser/context.ts index a543bfb8..af9b0f14 100644 --- a/packages/core/src/lib/zplParser/context.ts +++ b/packages/core/src/lib/zplParser/context.ts @@ -36,13 +36,13 @@ export interface PendingReverseBg { span?: { start: number; end: number }; } -/** Public output accumulators handed back via `ParsedZPL`. */ +/** Shared parse accumulators; pages slice the arrays via offset marks, the + * document-wide fields hand back via `ParsedZPL`. */ export interface ParserResult { objects: LabelObject[]; labelConfig: Partial; printerProfile: Partial; variables: Variable[]; - skipped: string[]; partialCmds: Set; browserLimit: string[]; unknown: string[]; @@ -75,7 +75,9 @@ export interface CommentState { fnComment: string | undefined; } -/** Format-scoped (per-^XA) state; reset at label boundary. */ +/** Command-format state, mixed scope: embedChar/clockChars reset at ^XA and + * fhActive at page close; prefix/delimiter chars, unitScale, and fhDecoder + * are printer-persistent and carry across pages. */ export interface FormatState { embedChar: string; clockChars: ClockChars; @@ -214,18 +216,19 @@ export interface ParserState { * design, so the post-^FS serial orphan cleanup must not remove them. */ bareDeclaredFns: Set; /** ^FN slots whose single-bind marker a post-^FS ^SN stripped. Orphan - * cleanup runs at end of parse, not here: the slot is shared, so a later + * cleanup runs at page close, not here: the slot is shared, so a later * field or embed may still reference the variable (spec p.200). */ serialStrippedFns: Set; + /** Index into result.variables where the current ^XA format's slots begin. + * ^FN is per-format scoped, so find-or-reuse must not reach into an earlier + * page's variables; advanced at each page close. */ + varScopeStart: number; } -/** Append to `skipped` and `browserLimit` (invariant: browserLimit ⊆ skipped). - * trimEnd: `token` carries `rest` up to the next command, which in multi-line +/** trimEnd: `token` carries `rest` up to the next command, which in multi-line * ZPL includes the trailing newline (noise in this diagnostic surface). */ export function pushBrowserLimit(result: ParserResult, token: string): void { - const clean = token.trimEnd(); - result.skipped.push(clean); - result.browserLimit.push(clean); + result.browserLimit.push(token.trimEnd()); } /** Default text height: ^CF override, else ZPL baseline 30. */ @@ -243,6 +246,33 @@ export function getPosType(field: FieldState): "FT" | "FO" { return field.positionIsFT ? "FT" : "FO"; } +/** Reset the field-scoped ^FB/^TB block defaults. They bind to a single field + * and clear at its ^FS (and at an ^XA that closes with a field left open). */ +export function resetFieldBlockDefaults(defaults: DefaultsState): void { + defaults.fbWidth = 0; + defaults.fbLines = 1; + defaults.fbSpacing = 0; + defaults.fbJustify = "L"; + defaults.fbHangingIndent = 0; + defaults.tbHeight = 0; +} + +/** Format-scoped reset at an ^XA boundary; printer-persistent state (^MU, + * ^CC/^CT/^CD, ^CI, ^CW/fonts, ^CF/^BY, ^LH/^LT/^LR) carries on. Field state + * resets too: a page closing mid-field must not leak a dangling ^FH/^FB. */ +export function resetFormatScopedState(s: ParserState): void { + s.result.partialCmds = new Set(); + s.varScopeStart = s.result.variables.length; + s.serialStrippedFns.clear(); + s.bareDeclaredFns.clear(); + s.comment.pending = undefined; + s.comment.fnNumber = null; + s.comment.fnComment = undefined; + s.field = freshFieldState(); + s.format.fhActive = false; + resetFieldBlockDefaults(s.defaults); +} + export function createParserState(): ParserState { return { result: { @@ -250,7 +280,6 @@ export function createParserState(): ParserState { labelConfig: {}, printerProfile: {}, variables: [], - skipped: [], partialCmds: new Set(), browserLimit: [], unknown: [], @@ -303,58 +332,65 @@ export function createParserState(): ParserState { reverseBg: null, bareDeclaredFns: new Set(), serialStrippedFns: new Set(), - field: { - x: 0, - y: 0, - positionIsFT: false, - justify: "L", - fieldType: null, - pendingFD: null, - frActive: false, - fpDirection: "H", - fpCharGap: 0, - textRot: "N", - textH: 30, - textW: 0, - bcHeight: 100, - bcInterp: true, - bcInterpAbove: false, - bcCheck: false, - bcRotation: "N", - bcGs1: false, - bcCode49Mode: "A", - symRot: "N", - symH: 30, - symW: 30, - gsSymbology: 1, - gsSegments: undefined, - gsMagnification: undefined, - qrMag: 4, - qrModel: 2, - dmDim: 5, - dmQuality: 200, - dmEscape: undefined, - dmAspect: undefined, - dmCols: undefined, - dmRows: undefined, - pdfRowHeight: 10, - pdfSecurity: 0, - pdfColumns: 0, - aztecMag: 4, - maxicodeMode: 4, - mpdfRowHeight: 10, - cbRowHeight: 10, - cbColumns: CODABLOCK_DEFAULT_COLUMNS, - cbSecurity: "Y", - tlcModuleWidth: undefined, - tlcHeight: 40, - tlcMicroPdfRowHeight: 4, - tlcMicroPdfRows: 4, - pendingPrinterFontName: undefined, - pendingFontId: undefined, - snPending: false, - snIncrement: 1, - snMode: "SN", - }, + varScopeStart: 0, + field: freshFieldState(), + }; +} + +/** Pristine per-field state; also used at ^XA so a field left half-open by a + * missing ^FS cannot leak across the page boundary. */ +export function freshFieldState(): FieldState { + return { + x: 0, + y: 0, + positionIsFT: false, + justify: "L", + fieldType: null, + pendingFD: null, + frActive: false, + fpDirection: "H", + fpCharGap: 0, + textRot: "N", + textH: 30, + textW: 0, + bcHeight: 100, + bcInterp: true, + bcInterpAbove: false, + bcCheck: false, + bcRotation: "N", + bcGs1: false, + bcCode49Mode: "A", + symRot: "N", + symH: 30, + symW: 30, + gsSymbology: 1, + gsSegments: undefined, + gsMagnification: undefined, + qrMag: 4, + qrModel: 2, + dmDim: 5, + dmQuality: 200, + dmEscape: undefined, + dmAspect: undefined, + dmCols: undefined, + dmRows: undefined, + pdfRowHeight: 10, + pdfSecurity: 0, + pdfColumns: 0, + aztecMag: 4, + maxicodeMode: 4, + mpdfRowHeight: 10, + cbRowHeight: 10, + cbColumns: CODABLOCK_DEFAULT_COLUMNS, + cbSecurity: "Y", + tlcModuleWidth: undefined, + tlcHeight: 40, + tlcMicroPdfRowHeight: 4, + tlcMicroPdfRows: 4, + pendingPrinterFontName: undefined, + pendingFontId: undefined, + snPending: false, + snIncrement: 1, + snMode: "SN", }; } diff --git a/packages/core/src/lib/zplParser/flushField.ts b/packages/core/src/lib/zplParser/flushField.ts index 8b80159b..8648d0fb 100644 --- a/packages/core/src/lib/zplParser/flushField.ts +++ b/packages/core/src/lib/zplParser/flushField.ts @@ -40,7 +40,7 @@ import type { CodablockProps } from "../../registry/codablock"; import type { Tlc39Props } from "../../registry/tlc39"; import { upceData6FromFd } from "../../registry/hriFormatters"; import { decodeFH, makeObj, variableNameFromComment } from "./helpers"; -import { getPosType, type ParserState } from "./context"; +import { getPosType, resetFieldBlockDefaults, type ParserState } from "./context"; /** Cross-family deps flushField borrows from graphics (^GB+^FR) and parseZPL (^FX). */ export interface FlushFieldDeps { @@ -57,13 +57,17 @@ export function createFlushField( const { commitPendingReverseBg, getReverseFlag, takeComment } = deps; const { objects, variables } = s.result; - /** Find-or-create Variable for FN slot; silently backfills empty defaultValue. */ + /** Find-or-create Variable for FN slot; silently backfills empty defaultValue. + * Lookup starts at varScopeStart: ^FN is per-^XA-format scoped, so a later + * page's slot must become a new Variable, never reuse an earlier page's. */ const upsertVariable = ( fnNumber: number, defaultValue: string, commentHint?: string, ): Variable => { - const existing = variables.find((v) => v.fnNumber === fnNumber); + const existing = variables + .slice(s.varScopeStart) + .find((v) => v.fnNumber === fnNumber); if (existing) { if (!existing.defaultValue && defaultValue) { existing.defaultValue = defaultValue; @@ -99,18 +103,14 @@ export function createFlushField( } if (seen.size === 0) return payload; for (const n of seen) upsertVariable(n, ""); - const fnToName = new Map(variables.map((v) => [v.fnNumber, v.name])); + // Same per-format scope as the lookup: embeds reference this page's slots. + const fnToName = new Map( + variables.slice(s.varScopeStart).map((v) => [v.fnNumber, v.name]), + ); return embedsToMarkers(payload, s.format.embedChar, fnToName); }; - const resetFB = () => { - s.defaults.fbWidth = 0; - s.defaults.fbLines = 1; - s.defaults.fbSpacing = 0; - s.defaults.fbJustify = "L"; - s.defaults.fbHangingIndent = 0; - s.defaults.tbHeight = 0; - }; + const resetFB = () => resetFieldBlockDefaults(s.defaults); const flushField = () => { if (!s.field.fieldType || s.field.pendingFD === null) { diff --git a/packages/core/src/lib/zplParser/handlers/fields.ts b/packages/core/src/lib/zplParser/handlers/fields.ts index 2195db83..f8d7b9fa 100644 --- a/packages/core/src/lib/zplParser/handlers/fields.ts +++ b/packages/core/src/lib/zplParser/handlers/fields.ts @@ -4,7 +4,12 @@ import { applySerialToLeaf } from "../../../registry/serialField"; import { getEntry } from "../../../registry"; import { classifyField } from "../../variableField"; import { ZPL_BUILTIN_FONT_LETTERS } from "../../customFonts"; -import { getDefaultTextH, getDefaultTextW, type ParserState } from "../context"; +import { + getDefaultTextH, + getDefaultTextW, + resetFieldBlockDefaults, + type ParserState, +} from "../context"; import { ciToEncoding, dotsFor, getDecoder, int, readRotation } from "../helpers"; import type { Handler, Wildcard } from "../types"; @@ -178,12 +183,7 @@ export function createFieldHandlers( s.field.fpDirection = "H"; s.field.fpCharGap = 0; s.field.gsMagnification = undefined; - s.defaults.fbWidth = 0; - s.defaults.fbLines = 1; - s.defaults.fbSpacing = 0; - s.defaults.fbJustify = "L"; - s.defaults.fbHangingIndent = 0; - s.defaults.tbHeight = 0; + resetFieldBlockDefaults(s.defaults); s.comment.fnNumber = null; s.comment.fnComment = undefined; }, diff --git a/packages/core/src/lib/zplParser/handlers/graphics.ts b/packages/core/src/lib/zplParser/handlers/graphics.ts index 13fac9c1..770d3518 100644 --- a/packages/core/src/lib/zplParser/handlers/graphics.ts +++ b/packages/core/src/lib/zplParser/handlers/graphics.ts @@ -11,9 +11,9 @@ import type { QrCodeProps } from "../../../registry/qrcode"; import { dotsFor, ftTopLeft, int, makeObj, readColor, readRotation } from "../helpers"; import type { Handler } from "../types"; -/** Characters of a `^GF`/`~DY` payload retained in browserLimit/skipped - * findings; rest is replaced with an ellipsis so a single multi-KB - * base64 blob doesn't drown out the import report. */ +/** Characters of a `^GF`/`~DY` payload retained in browserLimit findings; + * rest is replaced with an ellipsis so a single multi-KB base64 blob + * doesn't drown out the import report. */ const IMPORT_FINDING_PAYLOAD_LIMIT = 80; /** Helpers re-exported to parseZPL so flushField can commit a stashed reverse-bg diff --git a/packages/core/src/lib/zplParser/types.ts b/packages/core/src/lib/zplParser/types.ts index 13058f9a..ee46401b 100644 --- a/packages/core/src/lib/zplParser/types.ts +++ b/packages/core/src/lib/zplParser/types.ts @@ -17,8 +17,8 @@ export type ImportFindingKind = /** * One import finding. Created per-occurrence so each entry can be navigated - * to its source page in the UI; cross-block dedup happens (if at all) in the - * service layer that merges per-page parser runs. + * to its source page in the UI; cross-page dedup happens (if at all) in the + * import service's report buckets. */ export interface ImportFinding { kind: ImportFindingKind; @@ -26,9 +26,8 @@ export interface ImportFinding { * 'browserLimit' / 'unknown' the full token including parameters * (e.g. "^IM,R:LOGO.GRF"); for 'lossyEdit' a human-readable reason. */ command: string; - /** Page index this finding originated from. The parser doesn't know about - * pages and emits 0; `zplImportService` overwrites it when it merges the - * per-block parser results into a multi-page report. */ + /** Page index (^XA block) this finding originated from, stamped by the + * single-pass parser. */ pageIndex: number; } @@ -49,15 +48,42 @@ export interface ImportReport { deviceAction: string[]; } +/** One ^XA…^XZ format from a single-pass parse. Slices reference the same + * object/variable instances as the flat ParsedZPL arrays. */ +export interface ParsedPage { + objects: LabelObject[]; + /** This page's ^FN slots (per-format scope; cross-page merge is the + * import service's job). */ + variables: Variable[]; + findings: ImportFinding[]; + /** Source-patch overlay for byte-verbatim replay, present only when + * `captureOverlay` is set and every parsed object linked to a source span; + * `undefined` means the block regenerates from the model. */ + overlay?: BlockOverlay; + /** ^PW/^LL-derived size in effect at this page's close (pre-sidecar), so the + * import can keep the first block's geometry as the label size. */ + labelSize: { widthMm?: number; heightMm?: number }; + /** labelConfig accumulated through this page's close (pre-sidecar, never + * reset): page 0 equals block 0's config, which the single-label import + * reads so block-scoped fields like ^PQ can't leak from later blocks. */ + labelConfig: Partial; + /** Page 0 only: stream had no ^XA wrapper (bare field paste). Re-export + * regenerates the wrapper, so the overlay is suppressed by the caller. */ + bare?: boolean; +} + export interface ParsedZPL { + /** One entry per ^XA block (single-pass; stream-persistent state like + * ^MU/^CC/^CW carries across pages). At least one page, possibly empty. */ + pages: ParsedPage[]; + /** ^XA blocks set different explicit ^PW/^LL: a single-label design keeps + * only one size, so callers reject or warn. */ + mixedPageGeometry: boolean; labelConfig: Partial; /** EEPROM-persistent printer-state extracted from any Setup-Script * commands in the stream (^JZ, ^JT, ~TA, ^ST, ^KD, ^SL, ^KL, ^SE, * ^SZ, ^KN). Caller decides whether to merge into the active profile. */ printerProfile: Partial; - objects: LabelObject[]; - /** Template variables reconstructed from `^FN` slots. */ - variables: Variable[]; /** Full device paths of fonts uploaded via `~DY` (TTF/OTF) in this * stream. A path also claimed by a `^CW` alias or referenced by a * `^A@` direct path is a design font (in `labelConfig.customFonts` or @@ -71,16 +97,6 @@ export interface ParsedZPL { /** Every in-range ^FN slot the tokenizer saw, including on passthrough-only * fields; import renumbering must avoid these (overlays replay the bytes). */ sourceFnNumbers: ReadonlySet; - /** Commands not fully imported (browserLimit + unknown). Prefer - * importReport for categorised access. */ - skipped: string[]; - importReport: ImportReport; - /** Source-patch overlay for the parsed block, present only when - * `captureOverlay` is set and every parsed object linked to a source - * span. Lets export replay untouched fields/config/comments/whitespace - * byte-identically. `undefined` when capture was off or a field shape - * couldn't be linked (the block then regenerates from the model). */ - overlay?: BlockOverlay; } export type Handler = (p: string[], rest: string, cmd: string) => void; diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 6429bf07..c6320b25 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -335,7 +335,11 @@ mod tests { #[test] fn status_serializes_running_and_available_flags() { - let json = serde_json::to_string(&McpStatus { running: true, available: false }).unwrap(); + let json = serde_json::to_string(&McpStatus { + running: true, + available: false, + }) + .unwrap(); assert_eq!(json, r#"{"running":true,"available":false}"#); } diff --git a/src/lib/zplGenerator.test.ts b/src/lib/zplGenerator.test.ts index 93e3a69f..19cbe888 100644 --- a/src/lib/zplGenerator.test.ts +++ b/src/lib/zplGenerator.test.ts @@ -4,7 +4,7 @@ import { generateZPL, generateMultiPageZPL, generateBatchZpl } from '@zplab/core import { parseZPL } from '@zplab/core/lib/zplParser'; import type { LabelConfig } from '@zplab/core/types/LabelConfig'; import type { GroupObject, LabelObject } from '@zplab/core/types/Group'; -import { defined, props, serialOf } from '../test/helpers'; +import { defined, parseSingle, props, serialOf } from '../test/helpers'; import { NON_EMITTING_CONFIG_KEYS } from '../store/labelStore.internals'; import { putImage } from '@zplab/core/lib/imageCache'; @@ -112,7 +112,7 @@ describe('generateZPL — structure', () => { expect(zpl).not.toContain('^GB'); expect(zpl).toContain('^FR^FD'); expect(zpl).not.toContain('^LRY'); - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('text'); expect(props(objects[0]).reverse).toBe(true); @@ -128,7 +128,7 @@ describe('generateZPL — structure', () => { props: { content: 'Hi', fontHeight: 30, fontWidth: 0, rotation: rot, reverse: true } }, // eslint-disable-next-line @typescript-eslint/no-explicit-any ] as any; - const { objects } = parseZPL(generateZPL(BASE_LABEL, objs), 8); + const { objects } = parseSingle(generateZPL(BASE_LABEL, objs), 8); expect(objects, `${positionType}/${rot}`).toHaveLength(1); expect(objects[0]?.type).toBe('text'); expect(props(objects[0]).reverse).toBe(true); @@ -140,7 +140,7 @@ describe('generateZPL — structure', () => { // A rotated reverse where the ^GB sits at the same anchor as the ^FR text // genuinely prints beside the text (the box does not overlap rotated text), // so it must NOT collapse into a faked overlap; it stays box + reverse text. - const { objects } = parseZPL( + const { objects } = parseSingle( '^XA^FT100,200^GB30,80,30,B,0^FS^FT100,200^A0R,30,0^FR^FDHi^FS^XZ', 8, ); @@ -160,7 +160,7 @@ describe('generateZPL — structure', () => { const zpl = generateZPL(BASE_LABEL, objs); expect(zpl).toContain('^SN001,5,Y^FS'); expect(zpl).not.toContain('^FD'); // ^SN carries the data itself - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('text'); expect(props(objects[0]).content).toBe('001'); @@ -178,7 +178,7 @@ describe('generateZPL — structure', () => { const zpl = generateZPL(BASE_LABEL, objs); // mask: A,B → 'AA' (upper alpha), 4,2 → 'dd' (decimal); increment 1. expect(zpl).toContain('^FDAB42^SFAAdd,1^FS'); - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(props(objects[0]).content).toBe('AB42'); expect(serialOf(objects[0])?.increment).toBe(1); expect(serialOf(objects[0])?.zplMode).toBe('SF'); @@ -194,7 +194,7 @@ describe('generateZPL — structure', () => { ] as any; const zpl = generateZPL(BASE_LABEL, objs); expect(zpl).toContain('^SN001,5,Y^FS'); - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('code128'); expect(serialOf(objects[0])?.increment).toBe(5); @@ -232,7 +232,7 @@ describe('generateZPL — structure', () => { ] as any; const zpl = generateZPL(BASE_LABEL, objs); expect(zpl).toContain('^SN,1,Y^FS'); - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('text'); expect(serialOf(objects[0])?.zplMode).toBe('SN'); @@ -515,7 +515,7 @@ describe('generateZPL — printer params', () => { }, [], ); - const { labelConfig } = parseZPL(zpl, 8); + const { labelConfig } = parseSingle(zpl, 8); const m = labelConfig.customFonts?.[0]; expect(m?.alias).toBe('M'); expect(m?.path).toBe('E:ROUND.TTF'); @@ -691,7 +691,7 @@ describe('generateZPL — printer params', () => { backfeedSpeed: 4, }; const zpl = generateZPL(original, []); - const { labelConfig } = parseZPL(zpl, 8); + const { labelConfig } = parseSingle(zpl, 8); expect(labelConfig.printSpeed).toBe(6); expect(labelConfig.slewSpeed).toBe(6); expect(labelConfig.backfeedSpeed).toBe(4); @@ -764,7 +764,7 @@ describe('generateZPL — printer params', () => { describe('generateZPL — text object', () => { it('emits ^FO, ^A0 and ^FD for a text object', () => { - const { objects } = parseZPL('^XA^FO10,20^A0N,30,0^FDHello^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO10,20^A0N,30,0^FDHello^FS^XZ', 8); const zpl = generateZPL(BASE_LABEL, objects); expect(zpl).toContain('^FO10,20'); expect(zpl).toContain('^A0N,30,0'); @@ -774,26 +774,26 @@ describe('generateZPL — text object', () => { describe('generateZPL — ^FP field-direction modifier', () => { it('omits ^FP for the default horizontal layout', () => { - const { objects } = parseZPL('^XA^FO10,20^A0N,30,0^FDHello^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO10,20^A0N,30,0^FDHello^FS^XZ', 8); expect(generateZPL(BASE_LABEL, objects)).not.toContain('^FP'); }); it('emits ^FPV,0 for vertical text', () => { - const { objects } = parseZPL('^XA^FO10,20^A0N,30,30^FPV^FDABC^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO10,20^A0N,30,30^FPV^FDABC^FS^XZ', 8); const zpl = generateZPL(BASE_LABEL, objects); expect(zpl).toContain('^FPV,0'); expect(zpl.indexOf('^FPV')).toBeLessThan(zpl.indexOf('^A0')); }); it('emits gap on horizontal layout when only ^FP,g was set', () => { - const { objects } = parseZPL('^XA^FO10,20^A0N,30,30^FP,4^FDABC^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO10,20^A0N,30,30^FP,4^FDABC^FS^XZ', 8); const zpl = generateZPL(BASE_LABEL, objects); expect(zpl).toContain('^FPH,4'); }); it('round-trips ^FPV,2 through parser + generator', () => { const original = '^XA^FO50,50^A0N,30,30^FPV,2^FDABC^FS^XZ'; - const { objects } = parseZPL(original, 8); + const { objects } = parseSingle(original, 8); const zpl = generateZPL(BASE_LABEL, objects); expect(zpl).toContain('^FPV,2'); expect(zpl).toContain('^FDABC'); @@ -802,7 +802,7 @@ describe('generateZPL — ^FP field-direction modifier', () => { describe('generateZPL — ^TB text block', () => { it('emits ^TB{rot},{w},{h} for a text-block object', () => { - const { objects } = parseZPL('^XA^A0N,30^FO0,0^TBN,400,120^FDText block^FS^XZ', 8); + const { objects } = parseSingle('^XA^A0N,30^FO0,0^TBN,400,120^FDText block^FS^XZ', 8); const zpl = generateZPL(BASE_LABEL, objects); expect(zpl).toContain('^TBN,400,120'); expect(zpl).not.toContain('^FB'); @@ -810,9 +810,9 @@ describe('generateZPL — ^TB text block', () => { it('round-trips ^TB (native, no lossy collapse to ^FB)', () => { const original = '^XA^A0N,30^FO0,0^TBN,400,120^FDText block^FS^XZ'; - const { objects } = parseZPL(original, 8); + const { objects } = parseSingle(original, 8); const zpl = generateZPL(BASE_LABEL, objects); - const { objects: reparsed } = parseZPL(zpl, 8); + const { objects: reparsed } = parseSingle(zpl, 8); expect(props(reparsed[0]).textMode).toBe('tb'); expect(props(reparsed[0]).blockWidth).toBe(400); expect(props(reparsed[0]).blockHeight).toBe(120); @@ -821,9 +821,9 @@ describe('generateZPL — ^TB text block', () => { it('round-trips an FT-anchored ^TB position (extent uses blockHeight)', () => { const original = '^XA^A0N,30^FT400,150^TBN,300,90^FDsample^FS^XZ'; - const a = parseZPL(original, 8); + const a = parseSingle(original, 8); const z = generateZPL(BASE_LABEL, a.objects); - const b = parseZPL(z, 8); + const b = parseSingle(z, 8); expect(props(b.objects[0]).blockHeight).toBe(90); // Position must survive the FT extent round-trip unchanged. expect(b.objects[0]?.x).toBe(a.objects[0]?.x); @@ -846,9 +846,9 @@ describe('generateZPL — ^TB text block', () => { it('round-trips ^TBR (R-rotation anchor path)', () => { const original = '^XA^A0R,30^FO100,40^TBR,200,90^FDrotated block sample^FS^XZ'; - const a = parseZPL(original, 8); + const a = parseSingle(original, 8); const z = generateZPL(BASE_LABEL, a.objects); - const b = parseZPL(z, 8); + const b = parseSingle(z, 8); expect(props(b.objects[0]).textMode).toBe('tb'); expect(props(b.objects[0]).blockHeight).toBe(90); expect(props(b.objects[0]).rotation).toBe('R'); @@ -868,7 +868,7 @@ describe('generateZPL — ^TB text block', () => { expect(zpl).not.toContain('^FDa { props: { content: 'rev', fontHeight: 30, fontWidth: 0, rotation: 'N', reverse: true, textMode: 'tb', blockWidth: 200, blockHeight }, }; const zpl = generateZPL(BASE_LABEL, [obj]); - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); // ^FR + ^TB emits no background box, so it re-imports as ONE reverse text. expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('text'); @@ -975,14 +975,14 @@ describe('generateZPL — ^TB text block', () => { }; const zpl = generateZPL(BASE_LABEL, [obj as unknown as LabelObject]); expect(zpl).toContain('^FDA<<>B'); - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(props(objects[0]).content).toBe('A { it('emits ^GB for a box object', () => { - const { objects } = parseZPL('^XA^FO10,20^GB200,100,3,B,0^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO10,20^GB200,100,3,B,0^FS^XZ', 8); const zpl = generateZPL(BASE_LABEL, objects); expect(zpl).toContain('^GB200,100,3,B,0'); }); @@ -990,7 +990,7 @@ describe('generateZPL — box object', () => { describe('generateZPL — line object', () => { it('emits a horizontal ^GB line', () => { - const { objects } = parseZPL('^XA^FO0,0^GB700,3,3^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^GB700,3,3^FS^XZ', 8); const zpl = generateZPL(BASE_LABEL, objects); expect(zpl).toContain('^GB700,3,3'); }); @@ -1002,7 +1002,7 @@ describe('generateZPL — ~DY graphic upload + ^XG recall', () => { const zpl = `~DYR:LOGO,A,G,4,1,${HEX}\n` + `^XA^FO50,80^XGR:LOGO.GRF,1,1^FS^XZ`; - const parsed = parseZPL(zpl, 8); + const parsed = parseSingle(zpl, 8); const out = generateZPL(BASE_LABEL, parsed.objects); // ~DY must precede ^XA so the printer has the file before ^XG references it. const dyAt = out.indexOf('~DYR:LOGO,A,G,4,1,'); @@ -1035,7 +1035,7 @@ describe('generateZPL — ~DY graphic upload + ^XG recall', () => { const zpl = `~DYR:CLOGO,C,G,4,1,:Z64:${b64}:${crc(b64)}\n` + `^XA^FO0,0^XGR:CLOGO.GRF,1,1^FS^XZ`; - const parsed = parseZPL(zpl, 8); + const parsed = parseSingle(zpl, 8); const out = generateZPL(BASE_LABEL, parsed.objects); expect(out).toContain('~DYR:CLOGO,C,G,'); expect(out).not.toContain('~DYR:CLOGO,A,G,'); @@ -1046,7 +1046,7 @@ describe('generateZPL — ~DY graphic upload + ^XG recall', () => { const zpl = `~DYR:LOGO,A,G,4,1,${HEX}\n` + `^XA^FO10,10^XGR:LOGO.GRF,1,1^FS^FO10,200^XGR:LOGO.GRF,1,1^FS^XZ`; - const parsed = parseZPL(zpl, 8); + const parsed = parseSingle(zpl, 8); const out = generateZPL(BASE_LABEL, parsed.objects); const dyMatches = out.match(/~DYR:LOGO,/g) ?? []; const xgMatches = out.match(/\^XGR:LOGO\.GRF/g) ?? []; @@ -1057,7 +1057,7 @@ describe('generateZPL — ~DY graphic upload + ^XG recall', () => { describe('generateZPL — code128 object', () => { it('emits ^BC and ^FD for a Code 128 barcode', () => { - const { objects } = parseZPL('^XA^FO100,50^BCN,200,Y,N,N^FD12345678^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO100,50^BCN,200,Y,N,N^FD12345678^FS^XZ', 8); const zpl = generateZPL(BASE_LABEL, objects); expect(zpl).toContain('^BC'); expect(zpl).toContain('^FD12345678^FS'); @@ -1084,8 +1084,8 @@ describe('generateMultiPageZPL', () => { }); it('preserves per-page objects', () => { - const { objects: page1 } = parseZPL('^XA^FO10,20^A0N,30,0^FDOne^FS^XZ', 8); - const { objects: page2 } = parseZPL('^XA^FO50,60^A0N,30,0^FDTwo^FS^XZ', 8); + const { objects: page1 } = parseSingle('^XA^FO10,20^A0N,30,0^FDOne^FS^XZ', 8); + const { objects: page2 } = parseSingle('^XA^FO50,60^A0N,30,0^FDTwo^FS^XZ', 8); const zpl = generateMultiPageZPL(BASE_LABEL, [{ objects: page1 }, { objects: page2 }]); expect(zpl).toContain('^FDOne^FS'); expect(zpl).toContain('^FDTwo^FS'); @@ -1094,9 +1094,9 @@ describe('generateMultiPageZPL', () => { describe('generateZPL — parse/generate roundtrip', () => { it('preserves object count through a roundtrip', () => { - const original = parseZPL('^XA^FO10,20^A0N,30,0^FDHello^FS^FO10,60^GB200,5,5^FS^XZ', 8); + const original = parseSingle('^XA^FO10,20^A0N,30,0^FDHello^FS^FO10,60^GB200,5,5^FS^XZ', 8); const regenerated = generateZPL(BASE_LABEL, original.objects); - const reparsed = parseZPL(regenerated, 8); + const reparsed = parseSingle(regenerated, 8); expect(reparsed.objects).toHaveLength(original.objects.length); }); @@ -1104,7 +1104,7 @@ describe('generateZPL — parse/generate roundtrip', () => { 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); + const reparsed = parseSingle(regenerated, 8); expect(reparsed.labelConfig.muResampling).toEqual({ formatDpi: 200, outputDpi: 600 }); }); @@ -1117,31 +1117,31 @@ describe('generateZPL — parse/generate roundtrip', () => { const zpl = generateZPL(BASE_LABEL, objs); expect(zpl).toContain('^BY5'); expect(zpl).toContain('^BRN,1,5,2,'); - const reparsed = parseZPL(zpl, 8); + const reparsed = parseSingle(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 original = parseSingle('^XA^FO10,20^A0N,30,0^FDHello World^FS^XZ', 8); const regenerated = generateZPL(BASE_LABEL, original.objects); - const reparsed = parseZPL(regenerated, 8); + const reparsed = parseSingle(regenerated, 8); const textObj = defined(reparsed.objects.find((o) => o.type === 'text')); expect(props(textObj).content).toBe('Hello World'); }); it('preserves barcode content and height through a roundtrip', () => { - const original = parseZPL('^XA^FO50,50^BCN,150,Y,N,N^FD987654^FS^XZ', 8); + const original = parseSingle('^XA^FO50,50^BCN,150,Y,N,N^FD987654^FS^XZ', 8); const regenerated = generateZPL(BASE_LABEL, original.objects); - const reparsed = parseZPL(regenerated, 8); + const reparsed = parseSingle(regenerated, 8); const barcode = defined(reparsed.objects.find((o) => o.type === 'code128')); expect(props(barcode).content).toBe('987654'); expect(props(barcode).height).toBe(150); }); it('round-trips a ^BS UPC/EAN extension (5-digit)', () => { - const original = parseZPL('^XA^FO10,10^BSN,80,Y^FD54321^FS^XZ', 8); + const original = parseSingle('^XA^FO10,10^BSN,80,Y^FD54321^FS^XZ', 8); const regenerated = generateZPL(BASE_LABEL, original.objects); - const reparsed = parseZPL(regenerated, 8); + const reparsed = parseSingle(regenerated, 8); const ext = defined(reparsed.objects.find((o) => o.type === 'upcEanExtension')); expect(props(ext).content).toBe('54321'); expect(props(ext).height).toBe(80); @@ -1149,27 +1149,27 @@ describe('generateZPL — parse/generate roundtrip', () => { }); it('round-trips a ^BS UPC/EAN extension (2-digit)', () => { - const original = parseZPL('^XA^FO10,10^BSN,50,N^FD42^FS^XZ', 8); + const original = parseSingle('^XA^FO10,10^BSN,50,N^FD42^FS^XZ', 8); const regenerated = generateZPL(BASE_LABEL, original.objects); - const reparsed = parseZPL(regenerated, 8); + const reparsed = parseSingle(regenerated, 8); const ext = defined(reparsed.objects.find((o) => o.type === 'upcEanExtension')); expect(props(ext).content).toBe('42'); expect(props(ext).printInterpretation).toBe(false); }); it('round-trips ^BS rotation and moduleWidth (via ^BY)', () => { - const original = parseZPL('^XA^BY3^FO10,10^BSR,80,Y^FD12345^FS^XZ', 8); + const original = parseSingle('^XA^BY3^FO10,10^BSR,80,Y^FD12345^FS^XZ', 8); const regenerated = generateZPL(BASE_LABEL, original.objects); - const reparsed = parseZPL(regenerated, 8); + const reparsed = parseSingle(regenerated, 8); const ext = defined(reparsed.objects.find((o) => o.type === 'upcEanExtension')); expect(props(ext).rotation).toBe('R'); expect(props(ext).moduleWidth).toBe(3); }); it('round-trips a ^B4 Code 49 with default mode A', () => { - const original = parseZPL('^XA^FO10,10^B4N,20,Y,A^FDCODE49^FS^XZ', 8); + const original = parseSingle('^XA^FO10,10^B4N,20,Y,A^FDCODE49^FS^XZ', 8); const regenerated = generateZPL(BASE_LABEL, original.objects); - const reparsed = parseZPL(regenerated, 8); + const reparsed = parseSingle(regenerated, 8); const bc = defined(reparsed.objects.find((o) => o.type === 'code49')); expect(props(bc).content).toBe('CODE49'); expect(props(bc).height).toBe(20); @@ -1178,9 +1178,9 @@ describe('generateZPL — parse/generate roundtrip', () => { }); it('round-trips ^B4 explicit mode + rotation + moduleWidth', () => { - const original = parseZPL('^XA^BY3^FO10,10^B4R,30,N,2^FD12345^FS^XZ', 8); + const original = parseSingle('^XA^BY3^FO10,10^B4R,30,N,2^FD12345^FS^XZ', 8); const regenerated = generateZPL(BASE_LABEL, original.objects); - const reparsed = parseZPL(regenerated, 8); + const reparsed = parseSingle(regenerated, 8); const bc = defined(reparsed.objects.find((o) => o.type === 'code49')); expect(props(bc).rotation).toBe('R'); expect(props(bc).moduleWidth).toBe(3); @@ -1189,7 +1189,7 @@ describe('generateZPL — parse/generate roundtrip', () => { }); it('falls back to mode A when ^B4 receives an unknown mode', () => { - const r = parseZPL('^XA^FO10,10^B4N,20,Y,X^FDCODE49^FS^XZ', 8); + const r = parseSingle('^XA^FO10,10^B4N,20,Y,X^FDCODE49^FS^XZ', 8); const bc = defined(r.objects.find((o) => o.type === 'code49')); expect(props(bc).mode).toBe('A'); }); @@ -1198,7 +1198,7 @@ describe('generateZPL — parse/generate roundtrip', () => { // The templated field references FN2 and FN3 inline; the parser // auto-creates the Variables when they don't already exist (same // bootstrap convention as the single-bind ^FN path). - const r = parseZPL( + const r = parseSingle( '^XA^FO50,50^A0N,30,30^FD#2# and then #3#^FS^XZ', 8, ); @@ -1214,7 +1214,7 @@ describe('generateZPL — parse/generate roundtrip', () => { it('respects a custom ^FE embed character', () => { // ^FE@ redefines the embed delimiter, so `@1@` reads as the FN1 // embed and the literal `#` survives untouched in the output. - const r = parseZPL( + const r = parseSingle( '^XA^FE@^FO50,50^A0N,30,30^FDItem #@1@^FS^XZ', 8, ); @@ -1224,31 +1224,31 @@ describe('generateZPL — parse/generate roundtrip', () => { it('round-trips a label that uses ^FE inline embeds', () => { const src = '^XA^FO50,50^A0N,30,30^FD#1#-#2#^FS^XZ'; - const original = parseZPL(src, 8); + const original = parseSingle(src, 8); const regenerated = generateZPL(BASE_LABEL, original.objects, original.variables); - const reparsed = parseZPL(regenerated, 8); + const reparsed = parseSingle(regenerated, 8); const text = defined(reparsed.objects.find((o) => o.type === 'text')); expect(props(text).content).toBe('«field_1»-«field_2»'); expect(reparsed.variables.map((v) => v.fnNumber).sort()).toEqual([1, 2]); }); it('parses ^FD clock tokens into «clock:T» markers', () => { - const r = parseZPL('^XA^FO50,50^A0N,30,30^FDDate %d/%m/%Y^FS^XZ', 8); + const r = parseSingle('^XA^FO50,50^A0N,30,30^FDDate %d/%m/%Y^FS^XZ', 8); const text = defined(r.objects.find((o) => o.type === 'text')); expect(props(text).content).toBe('Date «clock:d»/«clock:m»/«clock:Y»'); }); it('respects a custom ^FC clock char on parse', () => { - const r = parseZPL('^XA^FC@^FO50,50^A0N,30,30^FDDate @d/@m/@Y^FS^XZ', 8); + const r = parseSingle('^XA^FC@^FO50,50^A0N,30,30^FDDate @d/@m/@Y^FS^XZ', 8); const text = defined(r.objects.find((o) => o.type === 'text')); expect(props(text).content).toBe('Date «clock:d»/«clock:m»/«clock:Y»'); }); it('round-trips a label with clock tokens (default chars)', () => { const src = '^XA^FO50,50^A0N,30,30^FD%d/%m/%Y %H:%M^FS^XZ'; - const original = parseZPL(src, 8); + const original = parseSingle(src, 8); const regenerated = generateZPL(BASE_LABEL, original.objects); - const reparsed = parseZPL(regenerated, 8); + const reparsed = parseSingle(regenerated, 8); const text = defined(reparsed.objects.find((o) => o.type === 'text')); expect(props(text).content).toBe('«clock:d»/«clock:m»/«clock:Y» «clock:H»:«clock:M»'); // ^FC must arm each clock field even for default chars (ZD230: %Y prints @@ -1265,18 +1265,16 @@ describe('generateZPL — parse/generate roundtrip', () => { '^XA^FO50,50^A0N,30,30^FD%d #1#^FS^XZ', 8, ); - // parseZPL flattens pages into one objects array; second label's - // text is the second text object. Its `%d` should still parse as - // a clock token; if the leak existed, the parser would have stuck - // with `@` and left `%d` literal in the content. - const texts = r.objects.filter((o) => o.type === 'text'); + // Second page's `%d` must still parse as a clock token; with the leak the + // parser would have stuck with `@` and left `%d` literal. + const texts = r.pages.flatMap((p) => p.objects).filter((o) => o.type === 'text'); expect(texts.length).toBeGreaterThanOrEqual(2); expect(props(defined(texts[1])).content).toContain('«clock:d»'); }); it('emits ^FC when payload contains a literal %', () => { const src = '^XA^FO50,50^A0N,30,30^FD100% match %Y^FS^XZ'; - const original = parseZPL(src, 8); + const original = parseSingle(src, 8); const regenerated = generateZPL(BASE_LABEL, original.objects); expect(regenerated).toMatch(/\^FC\$/); }); @@ -1291,7 +1289,7 @@ describe('generateZPL — parse/generate roundtrip', () => { const template = { id: 'b', type: 'text', x: 10, y: 50, rotation: 0, props: { content: '«sku» end', fontHeight: 30, fontWidth: 0, rotation: 'N' } } as unknown as LabelObject; const zpl = generateZPL(BASE_LABEL, [literal, template], vars); - const back = parseZPL(zpl, 8); + const back = parseSingle(zpl, 8); expect(props(defined(back.objects[0])).content).toBe('lane #1# here'); }); @@ -1301,13 +1299,13 @@ describe('generateZPL — parse/generate roundtrip', () => { const clock = { id: 'b', type: 'text', x: 10, y: 50, rotation: 0, props: { content: '«clock:Y» 100% done', fontHeight: 30, fontWidth: 0, rotation: 'N' } } as unknown as LabelObject; const zpl = generateZPL(BASE_LABEL, [literal, clock]); - const back = parseZPL(zpl, 8); + const back = parseSingle(zpl, 8); expect(props(defined(back.objects[0])).content).toBe('year %Y now'); }); it('round-trips ^SO2 offsets via labelConfig.secondaryClockOffset', () => { const src = '^XA^SO2,1,0,0,0,0,0^FO10,10^A0N,30,30^FD{Y-{m^FS^XZ'; - const r = parseZPL(src, 8); + const r = parseSingle(src, 8); expect(r.labelConfig.secondaryClockOffset).toEqual({ months: 1 }); const regenerated = generateZPL({ ...BASE_LABEL, ...r.labelConfig }, r.objects); expect(regenerated).toMatch(/\^SO2,1,0,0,0,0,0/); @@ -1316,7 +1314,7 @@ describe('generateZPL — parse/generate roundtrip', () => { it('round-trips ^SO3 offsets independently of ^SO2', () => { const src = '^XA^SO3,0,0,1,0,0,0^FO10,10^A0N,30,30^FD#Y^FS^XZ'; - const r = parseZPL(src, 8); + const r = parseSingle(src, 8); expect(r.labelConfig.tertiaryClockOffset).toEqual({ years: 1 }); expect(r.labelConfig.secondaryClockOffset).toBeUndefined(); const regenerated = generateZPL({ ...BASE_LABEL, ...r.labelConfig }, r.objects); @@ -1333,19 +1331,19 @@ describe('generateZPL — parse/generate roundtrip', () => { }); it('parses ^SO2 with all-zero values as a no-op (no offset stored)', () => { - const r = parseZPL('^XA^SO2,0,0,0,0,0,0^FO10,10^A0N,30,30^FD{Y^FS^XZ', 8); + const r = parseSingle('^XA^SO2,0,0,0,0,0,0^FO10,10^A0N,30,30^FD{Y^FS^XZ', 8); expect(r.labelConfig.secondaryClockOffset).toBeUndefined(); }); it('rejects ^SO with clock# not in {2,3}', () => { - const r = parseZPL('^XA^SO1,1,0,0,0,0,0^FO10,10^A0N,30,30^FDx^FS^XZ', 8); + const r = parseSingle('^XA^SO1,1,0,0,0,0,0^FO10,10^A0N,30,30^FDx^FS^XZ', 8); expect(r.labelConfig.secondaryClockOffset).toBeUndefined(); expect(r.labelConfig.tertiaryClockOffset).toBeUndefined(); }); it('emits ^FE when payload contains a literal #', () => { // Pre-build state via the parser so variable ids are real. - const r = parseZPL('^XA^FN1^FDfoo^FS^XZ', 8); + const r = parseSingle('^XA^FN1^FDfoo^FS^XZ', 8); const v = defined(r.variables[0]); const generated = generateZPL(BASE_LABEL, [ { @@ -1462,7 +1460,7 @@ describe('generateZPL — parse/generate roundtrip', () => { // the mode parameter. The second must default to 'A' even though // the parser variable still holds '3' from the previous handler // run; the handler resets it on each B4 via the `?? "A"` fallback. - const r = parseZPL( + const r = parseSingle( '^XA^FO10,10^B4N,20,Y,3^FDONE^FS^FO10,200^B4N,20,Y^FDTWO^FS^XZ', 8, ); @@ -1483,7 +1481,7 @@ describe('generateZPL — parse/generate roundtrip', () => { defaultFontHeight: 30, }; const regenerated = generateZPL(label, []); - const { labelConfig } = parseZPL(regenerated, BASE_LABEL.dpmm); + const { labelConfig } = parseSingle(regenerated, BASE_LABEL.dpmm); expect(labelConfig.printSpeed).toBe(8); expect(labelConfig.darkness).toBe(0); expect(labelConfig.mediaType).toBe('D'); @@ -1494,14 +1492,14 @@ describe('generateZPL — parse/generate roundtrip', () => { it('preserves partial ^CF (id only) through generate -> parse', () => { const regenerated = generateZPL({ ...BASE_LABEL, defaultFontId: 'A' }, []); - const { labelConfig } = parseZPL(regenerated, BASE_LABEL.dpmm); + const { labelConfig } = parseSingle(regenerated, BASE_LABEL.dpmm); expect(labelConfig.defaultFontId).toBe('A'); expect(labelConfig.defaultFontHeight).toBeUndefined(); }); it('preserves partial ^CF (height only) through generate -> parse', () => { const regenerated = generateZPL({ ...BASE_LABEL, defaultFontHeight: 25 }, []); - const { labelConfig } = parseZPL(regenerated, BASE_LABEL.dpmm); + const { labelConfig } = parseSingle(regenerated, BASE_LABEL.dpmm); expect(labelConfig.defaultFontId).toBeUndefined(); expect(labelConfig.defaultFontHeight).toBe(25); }); @@ -1592,7 +1590,7 @@ describe('generateZPL — variable bindings', () => { it('round-trips a bound text field through parse → variables stay consistent', () => { const out = generateZPL(BASE_LABEL, [textObj], [variable]); - const { variables, objects } = parseZPL(out); + const { variables, objects } = parseSingle(out); expect(variables).toHaveLength(1); expect(variables[0]?.fnNumber).toBe(7); expect(variables[0]?.defaultValue).toBe('ABC-123'); @@ -1602,7 +1600,7 @@ describe('generateZPL — variable bindings', () => { }); it('parses ^FN single-bind to a content marker and re-emits inline byte-stable', () => { - const { objects, variables } = parseZPL('^XA^FO10,20^A0N,30,30^FN5^FDABC^FS^XZ'); + const { objects, variables } = parseSingle('^XA^FO10,20^A0N,30,30^FN5^FDABC^FS^XZ'); expect(props(objects[0]).content).toBe('«field_5»'); expect(variables[0]?.fnNumber).toBe(5); expect(variables[0]?.defaultValue).toBe('ABC'); @@ -1710,7 +1708,7 @@ describe('generateBatchZpl', () => { it('re-escapes raw control bytes from an imported ^FH field on model re-emit', () => { // Text has no control-key chips, so the decoded raw byte stays in content; // regeneration must escape it again or the byte ships raw. - const { objects } = parseZPL('^XA^FH_^FO0,0^A0N,30,0^FDAB_09CD^FS^XZ', 8); + const { objects } = parseSingle('^XA^FH_^FO0,0^A0N,30,0^FDAB_09CD^FS^XZ', 8); expect(props(defined(objects[0])).content).toBe('AB\tCD'); const out = generateZPL(baseLabel, objects); expect(out).toContain('^FH_'); @@ -1719,7 +1717,7 @@ describe('generateBatchZpl', () => { }); it('imports ^FH control bytes as chips on control-capable barcodes and re-emits them', () => { - const { objects } = parseZPL('^XA^FO0,0^BY2^BCN,100,Y,N,N^FH_^FDAB_09CD^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^BY2^BCN,100,Y,N,N^FH_^FDAB_09CD^FS^XZ', 8); expect(props(defined(objects[0])).content).toBe('AB«ctrl:TAB»CD'); const out = generateZPL(baseLabel, objects); expect(out).toContain('^FH_'); @@ -1832,7 +1830,7 @@ describe('generateZPL — ^FT graphic anchors (bottom corner, spec p.205)', () = const zpl = generateZPL(BASE_LABEL, mk('ellipse', { width: 100, height: 80, thickness: 3, filled: false, color: 'B' })); expect(zpl).toContain('^FT50,150^GE100,80,3,B^FS'); // 70 + 80 - expect(parseZPL(zpl, 8).objects[0]?.positionType).toBe('FT'); + expect(parseSingle(zpl, 8).objects[0]?.positionType).toBe('FT'); }); it('ellipse ^GE right-justified: ^FT,1 anchors bottom-right', () => { @@ -1863,7 +1861,7 @@ describe('generateZPL — ^FT graphic anchors (bottom corner, spec p.205)', () = }); it('round-trips a ^FT ellipse byte-stably through parse → emit', () => { - const { objects } = parseZPL('^XA^FT50,150^GE100,80,3,B^FS^XZ', 8); + const { objects } = parseSingle('^XA^FT50,150^GE100,80,3,B^FS^XZ', 8); expect(generateZPL(BASE_LABEL, objects)).toContain('^FT50,150^GE100,80,3,B^FS'); }); @@ -1904,7 +1902,7 @@ describe('generateZPL — ^FT graphic anchors (bottom corner, spec p.205)', () = // so the foreign label round-trips with box, text, and position type intact. const src = '^XA^FT145,172^GB246,44,44,B,0^FS^FT145,172^A0N,44,34^FR^FDPESO LIQUIDO^FS^XZ'; - const { objects } = parseZPL(src, 8); + const { objects } = parseSingle(src, 8); expect(objects).toHaveLength(2); const [bar, text] = objects; // ^GB246,44,44 (thickness == height) is a filled horizontal bar = line. diff --git a/src/lib/zplImportService.test.ts b/src/lib/zplImportService.test.ts index e0ac4528..e76358fd 100644 --- a/src/lib/zplImportService.test.ts +++ b/src/lib/zplImportService.test.ts @@ -7,6 +7,56 @@ import { replayRiskFindings, printerCommandFindings, resolveRoutedReport } from import type { PrinterProfile } from '@zplab/core/types/PrinterProfile'; import type { LabelConfig } from '@zplab/core/types/LabelConfig'; +describe('importZplText - cross-block parser state (parser-owned pages)', () => { + it('carries ^MU units across ^XA blocks (persistent per spec)', () => { + const r = importZplText('^XA^MUI^XZ^XA^FO1,1^A0N,0.5,0.5^FDX^FS^XZ', 8); + const obj = r.pages.at(-1)?.objects[0] as { x: number; props: { fontHeight: number } }; + expect(obj.x).toBe(203); // 1 inch at 203 dpi + expect(obj.props.fontHeight).toBe(102); + }); + + it('resolves a ^CW font alias defined in an earlier block', () => { + const r = importZplText('^XA^CWM,E:ARIAL.TTF^XZ^XA^FO10,10^AMN,30,0^FDHi^FS^XZ', 8); + expect(r.report.partial).not.toContain('^AM'); + }); + + it('splits blocks after a ~CC command-prefix change', () => { + const r = importZplText('~CC!!XA!FO50,50!FDA!FS!XZ!XA!FO60,60!FDB!FS!XZ', 8); + expect(r.pages).toHaveLength(2); + }); + + it('does not leak a dangling ^FH hex-decode into the next block', () => { + const r = importZplText('^XA^FH^XZ^XA^FO10,10^A0N,30,30^FD_41^FS^XZ', 8); + const obj = r.pages.at(-1)?.objects[0] as { props: { content: string } }; + expect(obj.props.content).toBe('_41'); + }); + + it('does not leak dangling ^FB block defaults into the next block', () => { + const r = importZplText('^XA^FB400,2^XZ^XA^FO10,10^A0N,30,30^FDx^FS^XZ', 8); + const obj = r.pages.at(-1)?.objects[0] as { props: { blockWidth?: number } }; + expect(obj.props.blockWidth).toBeUndefined(); + }); + + it('keeps block 0 config; a later block ^PQ does not leak', () => { + const r = importZplText('^XA^FO10,10^A0N,30,30^FDx^FS^XZ^XA^PQ99^XZ', 8); + expect(r.labelConfig.printQuantity).toBeUndefined(); + }); + + it('accepts the ZPLLAB sidecar from a later block while no object was seen (settings-block re-export)', () => { + const sc = '^FXZPLLAB:{"dpmm":12,"wMm":100,"hMm":50}^FS'; + const r = importZplText(`^XA^MMT^XZ^XA${sc}^PW1200^LL600^FO10,10^A0N,30,30^FDx^FS^XZ`, 8); + expect(r.labelConfig.dpmm).toBe(12); + expect(r.labelConfig.widthMm).toBe(100); + expect(r.labelConfig.heightMm).toBe(50); + }); + + it('ignores a ZPLLAB sidecar after the first design object', () => { + const sc = '^FXZPLLAB:{"dpmm":12,"wMm":100,"hMm":50}^FS'; + const r = importZplText(`^XA^FO10,10^A0N,30,30^FDx^FS${sc}^XZ`, 8); + expect(r.labelConfig.dpmm).toBeUndefined(); + }); +}); + describe('importZplText - replay-risk findings', () => { it('flags printer setup commands (run on the printer when exported/printed)', () => { const r = importZplText('^XA^KNFOO^FO10,10^A0N,30,30^FDx^FS^XZ', 8); @@ -169,11 +219,9 @@ describe('importZplText: findings.pageIndex', () => { }); it('folds printerProfile across multiple ^XA blocks (last-write-wins per key, non-overlapping preserved)', () => { - // Block 1 sets reprintAfterError=N + setRealtimeClock. - // Block 2 overrides reprintAfterError to Y. - // Result must carry Y from block 2 AND the clock from block 1: - // a per-block fold mid-pipeline; a refactor that collapsed the - // fold to one end-of-stream pass would lose one of the two. + // Block 1 sets reprintAfterError=N + setRealtimeClock; block 2 overrides + // reprintAfterError. Both must survive: per-key last-write accumulation, + // not last-block-wins. const zpl = [ '^XA^JZN^ST05,20,2026,12,00,00^XZ', '^XA^JZY^XZ', diff --git a/src/lib/zplOverlay/overlayCapture.test.ts b/src/lib/zplOverlay/overlayCapture.test.ts index b2630972..b1d81383 100644 --- a/src/lib/zplOverlay/overlayCapture.test.ts +++ b/src/lib/zplOverlay/overlayCapture.test.ts @@ -1,12 +1,12 @@ import { describe, it, expect } from "vitest"; -import { parseZPL } from "@zplab/core/lib/zplParser"; import { generateZPL } from "@zplab/core/lib/zplGenerator"; import { overlayText, type BlockOverlay } from "@zplab/core/lib/zplOverlay/overlay"; +import { parseSingle } from "../../test/helpers"; /** Parse with overlay capture and assert the load-bearing invariant: * segment texts always concatenate back to the source. */ function captured(zpl: string): BlockOverlay { - const { overlay } = parseZPL(zpl, 8, { captureOverlay: true }); + const { overlay } = parseSingle(zpl, 8, { captureOverlay: true }); expect(overlay, "expected an overlay to be captured").toBeDefined(); expect(overlayText(overlay!)).toBe(zpl); return overlay!; @@ -20,7 +20,7 @@ function objSeg(o: BlockOverlay, objectId: string) { describe("parseZPL overlay capture", () => { it("links a clean single text field, gaps stay raw", () => { const zpl = "^XA\n^FO10,10^A0N,30,30^FDHello^FS\n^XZ"; - const { overlay, objects } = parseZPL(zpl, 8, { captureOverlay: true }); + const { overlay, objects } = parseSingle(zpl, 8, { captureOverlay: true }); expect(overlay).toBeDefined(); expect(overlayText(overlay!)).toBe(zpl); expect(objects).toHaveLength(1); @@ -55,7 +55,7 @@ describe("parseZPL overlay capture", () => { it("links two consecutive fields independently", () => { const zpl = "^XA\n^FO10,10^A0N,30,30^FDa^FS\n^FO10,60^A0N,30,30^FDb^FS\n^XZ"; - const { overlay, objects } = parseZPL(zpl, 8, { captureOverlay: true }); + const { overlay, objects } = parseSingle(zpl, 8, { captureOverlay: true }); expect(overlay).toBeDefined(); expect(overlayText(overlay!)).toBe(zpl); expect(objSeg(overlay!, objects[0]!.id)?.text).toBe("^FO10,10^A0N,30,30^FDa^FS"); @@ -66,7 +66,7 @@ describe("parseZPL overlay capture", () => { // Filled black ^GB with no ^FR is stashed; nothing follows to collapse it, // so it commits as a normal box at ^XZ. Its span must still link. const zpl = "^XA^FO5,5^GB80,80,80,B^FS^XZ"; - const { overlay, objects } = parseZPL(zpl, 8, { captureOverlay: true }); + const { overlay, objects } = parseSingle(zpl, 8, { captureOverlay: true }); expect(overlay).toBeDefined(); expect(overlayText(overlay!)).toBe(zpl); expect(objects).toHaveLength(1); @@ -81,7 +81,7 @@ describe("parseZPL overlay capture", () => { props: { content: "Hi", fontHeight: 30, fontWidth: 0, rotation: "N", reverse: true } }, ] as unknown as Parameters[1]; const zpl = generateZPL({ widthMm: 50, heightMm: 30, dpmm: 8 }, reverseText); - const { overlay, objects } = parseZPL(zpl, 8, { captureOverlay: true }); + const { overlay, objects } = parseSingle(zpl, 8, { captureOverlay: true }); expect(overlay).toBeDefined(); expect(overlayText(overlay!)).toBe(zpl); expect(objects).toHaveLength(1); @@ -95,7 +95,7 @@ describe("parseZPL overlay capture", () => { // standalone during the text flush; both objects must link. const zpl = "^XA^FO5,5^GB200,200,200,B^FS\n^FO300,300^A0N,30,30^FDx^FS^XZ"; - const { overlay, objects } = parseZPL(zpl, 8, { captureOverlay: true }); + const { overlay, objects } = parseSingle(zpl, 8, { captureOverlay: true }); expect(overlay).toBeDefined(); expect(overlayText(overlay!)).toBe(zpl); expect(objects).toHaveLength(2); @@ -105,7 +105,7 @@ describe("parseZPL overlay capture", () => { it("does not link a bare ^FN variable declaration, keeps it raw", () => { const zpl = "^XA^FN1^FDdefault^FS^FO10,10^A0N,30,30^FDx^FS^XZ"; - const { overlay, objects } = parseZPL(zpl, 8, { captureOverlay: true }); + const { overlay, objects } = parseSingle(zpl, 8, { captureOverlay: true }); expect(overlay).toBeDefined(); expect(overlayText(overlay!)).toBe(zpl); // The declaration produces a Variable but no object; the real field links. @@ -178,7 +178,7 @@ describe("parseZPL overlay capture", () => { }); it("is undefined when capture is off", () => { - const { overlay } = parseZPL("^XA^FO0,0^FDx^FS^XZ", 8); + const { overlay } = parseSingle("^XA^FO0,0^FDx^FS^XZ", 8); expect(overlay).toBeUndefined(); }); diff --git a/src/lib/zplParser.test.ts b/src/lib/zplParser.test.ts index 534eb108..4f5f8271 100644 --- a/src/lib/zplParser.test.ts +++ b/src/lib/zplParser.test.ts @@ -3,7 +3,7 @@ import { zlibSync } from 'fflate'; import { parseZPL, BY_CONSUMING_BARCODE_TYPES } from '@zplab/core/lib/zplParser'; import { formatLabelMetaComment } from '@zplab/core/lib/zplLabelMeta'; import { ObjectRegistry } from '@zplab/core/registry'; -import { props, serialOf } from '../test/helpers'; +import { props, serialOf, parseSingle, commandsOf } from '../test/helpers'; // Drift guard for the bare-^BY hazard set. Every 1D and postal barcode emits a // ^BY on regen, so it must be classified ^BY-consuming; a forgotten new one @@ -60,24 +60,24 @@ function makeZ64Field(bytes: Uint8Array): string { describe('parseZPL — label config', () => { it('parses ^PW and ^LL into mm dimensions at 8 dpmm', () => { - const { labelConfig } = parseZPL('^XA^PW800^LL600^XZ', 8); + const { labelConfig } = parseSingle('^XA^PW800^LL600^XZ', 8); expect(labelConfig.widthMm).toBe(100); // 800 dots / 8 dpmm expect(labelConfig.heightMm).toBe(75); // 600 dots / 8 dpmm }); it('ignores ^PW / ^LL with zero value', () => { - const { labelConfig } = parseZPL('^XA^PW0^LL0^XZ', 8); + const { labelConfig } = parseSingle('^XA^PW0^LL0^XZ', 8); expect(labelConfig.widthMm).toBeUndefined(); expect(labelConfig.heightMm).toBeUndefined(); }); it('parses ^PQ print quantity', () => { - const { labelConfig } = parseZPL('^XA^PQ3^XZ', 8); + const { labelConfig } = parseSingle('^XA^PQ3^XZ', 8); expect(labelConfig.printQuantity).toBe(3); }); it('ignores ^PQ with 0', () => { - const { labelConfig } = parseZPL('^XA^PQ0^XZ', 8); + const { labelConfig } = parseSingle('^XA^PQ0^XZ', 8); expect(labelConfig.printQuantity).toBeUndefined(); }); }); @@ -85,7 +85,7 @@ 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 { objects } = parseSingle('^XA^MUI^FO1,2^GB1,1,0.1^FS^XZ', 8); const [obj] = objects; expect(obj?.x).toBe(203); expect(obj?.y).toBe(406); @@ -93,14 +93,14 @@ describe('parseZPL — ^MU units of measure', () => { 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 { objects } = parseSingle('^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 { objects } = parseSingle('^XA^MUD^FO100,200^GB50,50,2^FS^XZ', 8); const [obj] = objects; expect(obj?.x).toBe(100); expect(obj?.y).toBe(200); @@ -108,30 +108,28 @@ describe('parseZPL — ^MU units of measure', () => { 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( + // entered." Single-pass parse carries it across ^XA blocks too. + const r = 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); + expect(r.pages[0]?.objects[0]?.x).toBe(203); + expect(r.pages[1]?.objects[0]?.x).toBe(203); }); it('^MU b,c slots persist as a pair on labelConfig for re-emit', () => { - const { labelConfig } = parseZPL('^XA^MUD,150,300^XZ', 8); + const { labelConfig } = parseSingle('^XA^MUD,150,300^XZ', 8); expect(labelConfig.muResampling).toEqual({ formatDpi: 150, outputDpi: 300 }); }); it('^MU with out-of-spec dpi values surfaces as partial finding', () => { - const { labelConfig, importReport } = parseZPL('^XA^MUD,77,999^XZ', 8); + const { labelConfig, findings } = parseSingle('^XA^MUD,77,999^XZ', 8); expect(labelConfig.muResampling).toBeUndefined(); - expect(importReport.partial).toContain('^MU'); + expect(commandsOf({ findings }, 'partial')).toContain('^MU'); }); it('^MU,150,300 with a-slot omitted resets unit to D and persists the pair', () => { - const { labelConfig, objects } = parseZPL( + const { labelConfig, objects } = parseSingle( '^XA^MU,150,300^FO100,100^GB10,10,1^FS^XZ', 8, ); @@ -143,37 +141,37 @@ describe('parseZPL — ^MU units of measure', () => { 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( + const { objects, findings } = parseSingle( '^XA^MUI^FO1,1^GB1,1,1^FS^MUX^FO1,1^GB1,1,1^FS^XZ', 8, ); - expect(importReport.partial).toContain('^MU'); + expect(commandsOf({ findings }, '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); + const { labelConfig, findings } = parseSingle('^XA^MUD,200^XZ', 8); expect(labelConfig.muResampling).toBeUndefined(); - expect(importReport.partial).toContain('^MU'); + expect(commandsOf({ findings }, '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); + const { labelConfig } = parseSingle(original, 8); 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); + const { objects } = parseSingle('^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)', () => { // 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 { objects } = parseSingle('^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); @@ -182,7 +180,7 @@ describe('parseZPL — ^MU units of measure', () => { }); it('^MUI also rescales ^GB dimensions and ^PW/^LL', () => { - const { labelConfig, objects } = parseZPL( + const { labelConfig, objects } = parseSingle( '^XA^MUI^PW4^LL3^FO0,0^GB1,2,0.05^FS^XZ', 8, ); @@ -200,7 +198,7 @@ describe('parseZPL — ^MU units of measure', () => { describe('parseZPL — text via ^A0', () => { it('creates a text object from an explicit ^A0 command', () => { - const { objects } = parseZPL('^XA^FO10,20^A0N,30,0^FDHello^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO10,20^A0N,30,0^FDHello^FS^XZ', 8); expect(objects).toHaveLength(1); const [obj] = objects; expect(obj?.type).toBe('text'); @@ -215,14 +213,14 @@ describe('parseZPL — text via ^A0', () => { }); it('parses rotation from ^A0', () => { - const { objects } = parseZPL('^XA^FO0,0^A0R,20,0^FDTilt^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^A0R,20,0^FDTilt^FS^XZ', 8); expect(props(objects[0]).rotation).toBe('R'); }); }); describe('parseZPL — text via ^CF (implicit field)', () => { it('creates text from ^CF + ^FD without an explicit ^A', () => { - const { objects } = parseZPL('^XA^CF0,60^FO50,50^FDIntershipping, Inc.^FS^XZ', 8); + const { objects } = parseSingle('^XA^CF0,60^FO50,50^FDIntershipping, Inc.^FS^XZ', 8); expect(objects).toHaveLength(1); const [obj] = objects; expect(obj?.type).toBe('text'); @@ -232,7 +230,7 @@ describe('parseZPL — text via ^CF (implicit field)', () => { it('updates fontHeight when ^CF changes between fields', () => { const zpl = '^XA^CF0,60^FO0,0^FDFirst^FS^CF0,30^FO0,50^FDSecond^FS^XZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects).toHaveLength(2); expect(props(objects[0]).fontHeight).toBe(60); expect(props(objects[1]).fontHeight).toBe(30); @@ -240,7 +238,7 @@ describe('parseZPL — text via ^CF (implicit field)', () => { it('uses ^CFA font command (non-zero font name) to set height', () => { // ^CFA,30 → cmd='CF', rest='A,30' → height=30 - const { objects } = parseZPL('^XA^CFA,30^FO0,0^FDText^FS^XZ', 8); + const { objects } = parseSingle('^XA^CFA,30^FO0,0^FDText^FS^XZ', 8); expect(objects).toHaveLength(1); expect(props(objects[0]).fontHeight).toBe(30); }); @@ -248,12 +246,12 @@ describe('parseZPL — text via ^CF (implicit field)', () => { describe('parseZPL — text field position', () => { it('records positionType FO for ^FO fields', () => { - const { objects } = parseZPL('^XA^FO10,20^A0N,30,0^FDHi^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO10,20^A0N,30,0^FDHi^FS^XZ', 8); expect(objects[0]?.positionType).toBe('FO'); }); it('records positionType FT for ^FT fields', () => { - const { objects } = parseZPL('^XA^FT10,20^A0N,30,0^FDHi^FS^XZ', 8); + const { objects } = parseSingle('^XA^FT10,20^A0N,30,0^FDHi^FS^XZ', 8); expect(objects[0]?.positionType).toBe('FT'); }); }); @@ -262,7 +260,7 @@ describe('parseZPL — ^FT graphic box/line bottom-left anchor', () => { // Spec p.205: a ^FT graphic origin is the bottom-left corner; the model stores // the top-left, so it lifts by the box height. ^FO is the top-left verbatim. it('lifts a ^FT box by its height to the top-left', () => { - const { objects } = parseZPL('^XA^FT100,200^GB50,40,3,B,0^FS^XZ', 8); + const { objects } = parseSingle('^XA^FT100,200^GB50,40,3,B,0^FS^XZ', 8); expect(objects[0]?.type).toBe('box'); expect(objects[0]?.positionType).toBe('FT'); expect(objects[0]?.x).toBe(100); @@ -270,20 +268,20 @@ describe('parseZPL — ^FT graphic box/line bottom-left anchor', () => { }); it('keeps a ^FO box at the top-left', () => { - const { objects } = parseZPL('^XA^FO100,200^GB50,40,3,B,0^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO100,200^GB50,40,3,B,0^FS^XZ', 8); expect(objects[0]?.positionType).toBe('FO'); expect(objects[0]?.y).toBe(200); }); it('lifts a ^FT horizontal line by its thickness', () => { - const { objects } = parseZPL('^XA^FT100,200^GB80,4,4,B,0^FS^XZ', 8); + const { objects } = parseSingle('^XA^FT100,200^GB80,4,4,B,0^FS^XZ', 8); expect(objects[0]?.type).toBe('line'); expect(objects[0]?.positionType).toBe('FT'); expect(objects[0]?.y).toBe(196); // 200 - 4 (thickness) }); it('anchors a right-justified ^FT,1 box at the bottom-right corner', () => { - const { objects } = parseZPL('^XA^FT200,200,1^GB50,40,3,B,0^FS^XZ', 8); + const { objects } = parseSingle('^XA^FT200,200,1^GB50,40,3,B,0^FS^XZ', 8); expect(objects[0]?.fieldJustify).toBe('R'); expect(objects[0]?.x).toBe(150); // 200 - 50 (width) expect(objects[0]?.y).toBe(160); // 200 - 40 (height) @@ -291,7 +289,7 @@ describe('parseZPL — ^FT graphic box/line bottom-left anchor', () => { it('treats z=2 (auto) like left: bottom-left anchor, no R justify', () => { // Only z=1 (right) is modelled; z=2 (auto, bidirectional) narrows to left. - const { objects } = parseZPL('^XA^FT200,200,2^GB50,40,3,B,0^FS^XZ', 8); + const { objects } = parseSingle('^XA^FT200,200,2^GB50,40,3,B,0^FS^XZ', 8); expect(objects[0]?.fieldJustify).toBeUndefined(); expect(objects[0]?.x).toBe(200); // left edge, not shifted by width expect(objects[0]?.y).toBe(160); // 200 - 40 (bottom-left lift) @@ -300,7 +298,7 @@ describe('parseZPL — ^FT graphic box/line bottom-left anchor', () => { describe('parseZPL — ^LH label home offset', () => { it('adds ^LH offset to all field positions', () => { - const { objects } = parseZPL('^XA^LH20,10^FO30,40^A0N,30,0^FDText^FS^XZ', 8); + const { objects } = parseSingle('^XA^LH20,10^FO30,40^A0N,30,0^FDText^FS^XZ', 8); // obj.x = (^FO.x + ^LH.x) - zplAnchorDelta.x; obj.y similar. // For FO/N h=30: dx=0, dy=4.62. expect(objects[0]?.x).toBeCloseTo(50); // 30 + 20 @@ -312,19 +310,19 @@ describe('parseZPL — ^LH label home offset', () => { describe('parseZPL — ^FR field reverse', () => { it('sets reverse on a text field when ^FR precedes ^FD', () => { - const { objects } = parseZPL('^XA^FO0,0^FR^A0N,30,0^FDReversed^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^FR^A0N,30,0^FDReversed^FS^XZ', 8); expect(props(objects[0]).reverse).toBe(true); }); it('does not set reverse without ^FR', () => { - const { objects } = parseZPL('^XA^FO0,0^A0N,30,0^FDNormal^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^A0N,30,0^FDNormal^FS^XZ', 8); expect(props(objects[0]).reverse).toBeFalsy(); }); it('keeps an unrelated filled box + ^FR text at a different anchor as two objects', () => { // Anchor mismatch ⇒ no collapse. Hand-written ZPL where a black box // and an ^FR text happen to coexist must round-trip unchanged. - const { objects } = parseZPL( + const { objects } = parseSingle( '^XA^FO10,10^GB60,30,60,B,0^FS^FO200,200^A0N,30,0^FR^FDHi^FS^XZ', 8, ); @@ -339,7 +337,7 @@ describe('parseZPL — ^FR field reverse', () => { describe('parseZPL — ^GB box', () => { it('creates an unfilled box when thickness < min dimension', () => { - const { objects } = parseZPL('^XA^FO10,20^GB200,100,3,B,0^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO10,20^GB200,100,3,B,0^FS^XZ', 8); expect(objects).toHaveLength(1); const [obj] = objects; expect(obj?.type).toBe('box'); @@ -354,20 +352,20 @@ describe('parseZPL — ^GB box', () => { }); it('creates a filled box when thickness equals the smallest dimension', () => { - const { objects } = parseZPL('^XA^FO0,0^GB100,100,100^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^GB100,100,100^FS^XZ', 8); expect(objects[0]?.type).toBe('box'); expect(props(objects[0]).filled).toBe(true); }); it('creates a box with rounding', () => { - const { objects } = parseZPL('^XA^FO0,0^GB100,50,3,B,5^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^GB100,50,3,B,5^FS^XZ', 8); expect(props(objects[0]).rounding).toBe(5); }); }); describe('parseZPL — ^GB line', () => { it('creates a horizontal line when height equals thickness', () => { - const { objects } = parseZPL('^XA^FO50,100^GB700,3,3^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO50,100^GB700,3,3^FS^XZ', 8); expect(objects).toHaveLength(1); const [obj] = objects; expect(obj?.type).toBe('line'); @@ -377,7 +375,7 @@ describe('parseZPL — ^GB line', () => { }); it('creates a vertical line when width equals thickness', () => { - const { objects } = parseZPL('^XA^FO100,50^GB3,250,3^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO100,50^GB3,250,3^FS^XZ', 8); expect(objects).toHaveLength(1); const [obj] = objects; expect(obj?.type).toBe('line'); @@ -390,7 +388,7 @@ describe('parseZPL — ^GB line', () => { describe('parseZPL — ^BC Code 128', () => { it('creates a code128 object from ^BC^FD', () => { - const { objects } = parseZPL('^XA^FO100,50^BCN,200,Y,N,N^FD12345678^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO100,50^BCN,200,Y,N,N^FD12345678^FS^XZ', 8); expect(objects).toHaveLength(1); const [obj] = objects; expect(obj?.type).toBe('code128'); @@ -401,7 +399,7 @@ describe('parseZPL — ^BC Code 128', () => { }); it('inherits height from ^BY when ^BC has no explicit height', () => { - const { objects } = parseZPL('^XA^BY5,2,270^FO100,50^BC^FD12345678^FS^XZ', 8); + const { objects } = parseSingle('^XA^BY5,2,270^FO100,50^BC^FD12345678^FS^XZ', 8); expect(props(objects[0]).height).toBe(270); expect(props(objects[0]).moduleWidth).toBe(5); }); @@ -411,7 +409,7 @@ 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( + const { objects } = parseSingle( '^XA^BY4,2,100^FO0,0^BRN,1,6,2,100^FD0112345678901^FS^XZ', 8, ); @@ -421,7 +419,7 @@ describe('parseZPL — ^BR GS1 Databar', () => { }); it('falls back to ^BY moduleWidth when ^BR omits the magnification slot', () => { - const { objects } = parseZPL( + const { objects } = parseSingle( '^XA^BY3,2,100^FO0,0^BRN,1^FD0112345678901^FS^XZ', 8, ); @@ -433,16 +431,17 @@ describe('parseZPL — ^BR GS1 Databar', () => { describe('parseZPL — ^FX comment', () => { it('does not produce objects or skips for ^FX lines', () => { - const { objects, skipped } = parseZPL( + const parsed = parseSingle( '^XA^FX This is a comment^FO10,20^A0N,30,0^FDText^FS^XZ', 8, ); - expect(objects).toHaveLength(1); + const skipped = [...commandsOf(parsed, 'browserLimit'), ...commandsOf(parsed, 'unknown')]; + expect(parsed.objects).toHaveLength(1); expect(skipped.some((s) => s.startsWith('^FX'))).toBe(false); }); it('attaches a single ^FX to the next object as comment', () => { - const { objects } = parseZPL( + const { objects } = parseSingle( '^XA^FXTop section^FO10,20^A0N,30,0^FDText^FS^XZ', 8, ); @@ -450,7 +449,7 @@ describe('parseZPL — ^FX comment', () => { }); it('joins consecutive ^FX lines with a newline', () => { - const { objects } = parseZPL( + const { objects } = parseSingle( '^XA^FXLine 1^FXLine 2^FO10,20^A0N,30,0^FDText^FS^XZ', 8, ); @@ -458,15 +457,15 @@ describe('parseZPL — ^FX comment', () => { }); it('does not bleed comments across ^XA boundaries', () => { - const { objects } = parseZPL( + const r = parseZPL( '^XA^FXOnly first^XZ^XA^FO10,20^A0N,30,0^FDText^FS^XZ', 8, ); - expect(objects[0]?.comment).toBeUndefined(); + expect(r.pages[1]?.objects[0]?.comment).toBeUndefined(); }); it('does not reattach a consumed comment to a later object', () => { - const { objects } = parseZPL( + const { objects } = parseSingle( '^XA^FXOnly first^FO10,20^A0N,30,0^FDFirst^FS^FO10,60^A0N,30,0^FDSecond^FS^XZ', 8, ); @@ -486,14 +485,14 @@ describe('parseZPL — barcode rotation', () => { ['^XA^FO0,0^B7I,4,0,0,,,^FDX^FS^XZ', 'I'], ['^XA^FO0,0^B0R,4,N,N,N,N^FDX^FS^XZ', 'R'], ])('reads orientation from %s', (zpl, expected) => { - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect((props(objects[0]) as { rotation?: string }).rotation).toBe(expected); }); // ^BQ's orientation slot is a firmware no-op; the parser pins it to N (a // rotated QR arrives as ^GFA + sidecar instead). it('canonicalizes the decorative ^BQ orientation slot to N', () => { - const { objects } = parseZPL('^XA^FO0,0^BQR,2,4^FDQA,X^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^BQR,2,4^FDQA,X^FS^XZ', 8); const obj = objects[0]; expect((props(obj) as { rotation?: string }).rotation).toBe('N'); }); @@ -506,7 +505,7 @@ describe('parseZPL — barcode rotation', () => { id: 'q', type: 'qrcode', x: 40, y: 60, rotation: 0, props: { content: 'X', magnification: 4, errorCorrection: 'Q', model: 2, rotation: 'R' }, } as never); - const { objects } = parseZPL(`^XA^FXmy note + const { objects } = parseSingle(`^XA^FXmy note ${body}^XZ`, 8); expect(objects[0]?.type).toBe('qrcode'); expect(objects[0]?.comment).toBe('my note'); @@ -520,7 +519,7 @@ ${body}^XZ`, 8); id: 'q', type: 'qrcode', x: 40, y: 160, rotation: 0, positionType: 'FT', props: { content: 'X', magnification: 4, errorCorrection: 'Q', model: 2, rotation: 'R' }, } as never); - const { objects } = parseZPL(`^XA${body}^XZ`, 8); + const { objects } = parseSingle(`^XA${body}^XZ`, 8); expect(objects[0]?.type).toBe('qrcode'); expect(objects[0]?.positionType).toBe('FT'); expect(objects[0]?.x).toBe(40); @@ -533,7 +532,7 @@ ${body}^XZ`, 8); id: 'q', type: 'qrcode', x: 40, y: 60, rotation: 0, props: { content: 'https://x.de', magnification: 5, errorCorrection: 'M', model: 2, rotation: 'B' }, } as never); - const { objects } = parseZPL(`^XA${body}^XZ`, 8); + const { objects } = parseSingle(`^XA${body}^XZ`, 8); expect(objects).toHaveLength(1); const obj = objects[0]; expect(obj?.type).toBe('qrcode'); @@ -545,7 +544,7 @@ ${body}^XZ`, 8); }); it('defaults to N when orientation is missing or unrecognised', () => { - const { objects } = parseZPL('^XA^BY2^FO0,0^BC,100,Y,N,N^FD123^FS^XZ', 8); + const { objects } = parseSingle('^XA^BY2^FO0,0^BC,100,Y,N,N^FD123^FS^XZ', 8); expect((props(objects[0]) as { rotation?: string }).rotation).toBe('N'); }); }); @@ -555,14 +554,14 @@ ${body}^XZ`, 8); describe('parseZPL — ^FH hex escape', () => { it('decodes hex-escaped characters in field data', () => { // _41 = hex 41 = 'A' - const { objects } = parseZPL('^XA^FH_^FO0,0^A0N,30,0^FD_41BC^FS^XZ', 8); + const { objects } = parseSingle('^XA^FH_^FO0,0^A0N,30,0^FD_41BC^FS^XZ', 8); expect(props(objects[0]).content).toBe('ABC'); }); it('keeps a ^FH-encoded GS as the GS1 separator instead of a control chip', () => { // GS1-128 with a hex-escaped separator between variable AIs: the raw GS // must reach the GS1 normalisation, not be chip-tokenised away. - const { objects } = parseZPL( + const { objects } = parseSingle( '^XA^FO0,0^BY2^BCN,100,Y,N,N,D^FH_^FD010401234567890110AB_1D21XY^FS^XZ', 8, ); const content = props(objects[0]).content as string; @@ -571,43 +570,43 @@ describe('parseZPL — ^FH hex escape', () => { }); it('chip-tokenises a ^FH control byte on a non-GS1 code128', () => { - const { objects } = parseZPL('^XA^FO0,0^BY2^BCN,100,Y,N,N^FH_^FDAB_09CD^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^BY2^BCN,100,Y,N,N^FH_^FDAB_09CD^FS^XZ', 8); expect(props(objects[0]).content).toBe('AB«ctrl:TAB»CD'); }); it('decodes UTF-8 multibyte escapes (German umlauts)', () => { // _C3_A4 = ä, _C3_B6 = ö, _C3_BC = ü - const { objects } = parseZPL('^XA^FH_^FO0,0^A0N,30,0^FD_C3_A4_C3_B6_C3_BC^FS^XZ', 8); + const { objects } = parseSingle('^XA^FH_^FO0,0^A0N,30,0^FD_C3_A4_C3_B6_C3_BC^FS^XZ', 8); expect(props(objects[0]).content).toBe('äöü'); }); it('decodes UTF-8 multibyte escapes (Nordic)', () => { // _C3_A6 = æ, _C3_B8 = ø, _C3_A5 = å - const { objects } = parseZPL('^XA^FH_^FO0,0^A0N,30,0^FD_C3_A6_C3_B8_C3_A5^FS^XZ', 8); + const { objects } = parseSingle('^XA^FH_^FO0,0^A0N,30,0^FD_C3_A6_C3_B8_C3_A5^FS^XZ', 8); expect(props(objects[0]).content).toBe('æøå'); }); it('decodes 3-byte UTF-8 escapes (Euro sign)', () => { // _E2_82_AC = € - const { objects } = parseZPL('^XA^FH_^FO0,0^A0N,30,0^FD_E2_82_AC^FS^XZ', 8); + const { objects } = parseSingle('^XA^FH_^FO0,0^A0N,30,0^FD_E2_82_AC^FS^XZ', 8); expect(props(objects[0]).content).toBe('€'); }); it('decodes mixed ASCII and UTF-8 escapes in one field', () => { // _48 = H, _69 = i, then ä - const { objects } = parseZPL('^XA^FH_^FO0,0^A0N,30,0^FD_48_69 _C3_A4^FS^XZ', 8); + const { objects } = parseSingle('^XA^FH_^FO0,0^A0N,30,0^FD_48_69 _C3_A4^FS^XZ', 8); expect(props(objects[0]).content).toBe('Hi ä'); }); it('replaces invalid UTF-8 byte sequences with U+FFFD', () => { // _C3 alone is a truncated 2-byte sequence - const { objects } = parseZPL('^XA^FH_^FO0,0^A0N,30,0^FD_C3^FS^XZ', 8); + const { objects } = parseSingle('^XA^FH_^FO0,0^A0N,30,0^FD_C3^FS^XZ', 8); expect(props(objects[0]).content).toBe('�'); }); it('decodes ^CI27 (Windows-1252) single-byte escapes', () => { // _E4 = 0xE4 = ä in CP1252 (in UTF-8 this would be invalid → U+FFFD) - const { objects } = parseZPL('^XA^CI27^FH_^FO0,0^A0N,30,0^FD_E4_F6_FC^FS^XZ', 8); + const { objects } = parseSingle('^XA^CI27^FH_^FO0,0^A0N,30,0^FD_E4_F6_FC^FS^XZ', 8); expect(props(objects[0]).content).toBe('äöü'); }); @@ -616,15 +615,15 @@ describe('parseZPL — ^FH hex escape', () => { const zpl = '^XA^FH_^FO0,0^A0N,30,0^FD_C3_A4^FS' + '^CI27^FH_^FO0,50^A0N,30,0^FD_E4^FS^XZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(props(objects[0]).content).toBe('ä'); expect(props(objects[1]).content).toBe('ä'); }); it('reports unsupported ^CI N as partial import', () => { // ^CI50 is not a real Zebra encoding; falls back to UTF-8 default - const { importReport } = parseZPL('^XA^CI50^FH_^FO0,0^A0N,30,0^FDx^FS^XZ', 8); - expect(importReport.partial).toContain('^CI50'); + const { findings } = parseSingle('^XA^CI50^FH_^FO0,0^A0N,30,0^FDx^FS^XZ', 8); + expect(commandsOf({ findings }, 'partial')).toContain('^CI50'); }); it('resets decoder to UTF-8 default on unsupported ^CI', () => { @@ -633,7 +632,7 @@ describe('parseZPL — ^FH hex escape', () => { const zpl = '^XA^CI27^FH_^FO0,0^A0N,30,0^FD_E4^FS' + '^CI50^FH_^FO0,50^A0N,30,0^FD_C3_A4^FS^XZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(props(objects[0]).content).toBe('ä'); // CP1252 expect(props(objects[1]).content).toBe('ä'); // UTF-8 (after reset) }); @@ -643,7 +642,7 @@ describe('parseZPL — ^FH hex escape', () => { describe('parseZPL — ^FB field block', () => { it('creates a text object with block properties', () => { - const { objects } = parseZPL( + const { objects } = parseSingle( '^XA^FO10,20^A0N,30,0^FB400,3,5,C,0^FDMulti-line text^FS^XZ', 8, ); @@ -655,7 +654,7 @@ describe('parseZPL — ^FB field block', () => { }); it('reads ^FB slot e as hanging indent', () => { - const { objects } = parseZPL( + const { objects } = parseSingle( '^XA^FO10,20^A0N,30,0^FB400,3,0,L,40^FDMulti-line text^FS^XZ', 8, ); @@ -663,7 +662,7 @@ describe('parseZPL — ^FB field block', () => { }); it('clamps negative ^FB hanging indent to 0 (matches Labelary)', () => { - const { objects } = parseZPL( + const { objects } = parseSingle( '^XA^FO10,20^A0N,30,0^FB400,3,0,L,-40^FDx^FS^XZ', 8, ); @@ -673,14 +672,14 @@ describe('parseZPL — ^FB field block', () => { it('resets ^FB state after use (next text has no block)', () => { const zpl = '^XA^FO0,0^A0N,30,0^FB400,2,0,L,0^FDFirst^FS^FO0,100^A0N,30,0^FDSecond^FS^XZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects).toHaveLength(2); expect(props(objects[0]).blockWidth).toBe(400); expect(props(objects[1]).blockWidth).toBeUndefined(); }); it('^FB without ^A creates text using ^CF defaults', () => { - const { objects } = parseZPL('^XA^CF0,25^FO0,0^FB300,2,0,R,0^FDBlock text^FS^XZ', 8); + const { objects } = parseSingle('^XA^CF0,25^FO0,0^FB300,2,0,R,0^FDBlock text^FS^XZ', 8); expect(objects).toHaveLength(1); expect(props(objects[0]).fontHeight).toBe(25); expect(props(objects[0]).blockWidth).toBe(300); @@ -692,7 +691,7 @@ describe('parseZPL — ^FB field block', () => { describe('parseZPL — ^TB text block', () => { it('creates a native text-block object (width + clip height, no line count)', () => { - const { objects } = parseZPL('^XA^CF0,30^FO0,0^TBN,400,120^FDText block^FS^XZ', 8); + const { objects } = parseSingle('^XA^CF0,30^FO0,0^TBN,400,120^FDText block^FS^XZ', 8); expect(objects).toHaveLength(1); expect(props(objects[0]).content).toBe('Text block'); expect(props(objects[0]).textMode).toBe('tb'); @@ -702,12 +701,12 @@ describe('parseZPL — ^TB text block', () => { }); it('decodes the <<> escape to a literal <', () => { - const { objects } = parseZPL('^XA^A0N,30^FO0,0^TBN,400,60^FDA<<>B^FS^XZ', 8); + const { objects } = parseSingle('^XA^A0N,30^FO0,0^TBN,400,60^FDA<<>B^FS^XZ', 8); expect(props(objects[0]).content).toBe('A { - const { objects } = parseZPL('^XA^A0N,30^FO0,0^TBN^FDx^FS^XZ', 8); + const { objects } = parseSingle('^XA^A0N,30^FO0,0^TBN^FDx^FS^XZ', 8); expect(objects).toHaveLength(1); expect(props(objects[0]).textMode).toBe('tb'); expect(props(objects[0]).blockWidth).toBe(1); @@ -716,7 +715,7 @@ describe('parseZPL — ^TB text block', () => { it('a ^TB+barcode malformed field does not tb-decode the bound default', () => { // ^TB sets fieldType=text, then ^BC flips it to a barcode; the bound // default must stay raw (match the barcode content), not tb-decoded. - const { objects, variables } = parseZPL( + const { objects, variables } = parseSingle( '^XA^FO10,10^TBN,200,50^BCN,100,Y,N^FN1^FD<<>HELLO^FS^XZ', 8, ); @@ -733,32 +732,32 @@ describe('parseZPL — ^FP vertical / reverse text', () => { const baseField = '^XA^FO50,50^A0N,30,30'; it('parses ^FPV as vertical direction', () => { - const { objects } = parseZPL(`${baseField}^FPV^FDABC^FS^XZ`, 8); + const { objects } = parseSingle(`${baseField}^FPV^FDABC^FS^XZ`, 8); expect(objects).toHaveLength(1); expect(props(objects[0]).fpDirection).toBe('V'); expect(props(objects[0]).fpCharGap).toBeUndefined(); }); it('parses ^FPR as reverse direction', () => { - const { objects } = parseZPL(`${baseField}^FPR^FDABC^FS^XZ`, 8); + const { objects } = parseSingle(`${baseField}^FPR^FDABC^FS^XZ`, 8); expect(props(objects[0]).fpDirection).toBe('R'); }); it('parses inter-character gap', () => { - const { objects } = parseZPL(`${baseField}^FPV,5^FDABC^FS^XZ`, 8); + const { objects } = parseSingle(`${baseField}^FPV,5^FDABC^FS^XZ`, 8); expect(props(objects[0]).fpDirection).toBe('V'); expect(props(objects[0]).fpCharGap).toBe(5); }); it('defaults direction to H when only gap is given (^FP,5)', () => { - const { objects } = parseZPL(`${baseField}^FP,5^FDABC^FS^XZ`, 8); + const { objects } = parseSingle(`${baseField}^FP,5^FDABC^FS^XZ`, 8); // H is omitted on emit by leaving fpDirection undefined; gap survives. expect(props(objects[0]).fpDirection).toBeUndefined(); expect(props(objects[0]).fpCharGap).toBe(5); }); it('falls back to H for unknown direction letters', () => { - const { objects } = parseZPL(`${baseField}^FPX^FDABC^FS^XZ`, 8); + const { objects } = parseSingle(`${baseField}^FPX^FDABC^FS^XZ`, 8); expect(props(objects[0]).fpDirection).toBeUndefined(); }); @@ -766,7 +765,7 @@ describe('parseZPL — ^FP vertical / reverse text', () => { const zpl = `${baseField}^FPV,3^FDFirst^FS` + `^FO50,150^A0N,30,30^FDSecond^FS^XZ`; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects).toHaveLength(2); expect(props(objects[0]).fpDirection).toBe('V'); expect(props(objects[0]).fpCharGap).toBe(3); @@ -780,7 +779,7 @@ describe('parseZPL — ^FP vertical / reverse text', () => { describe('parseZPL — ^B3 Code 39', () => { it('creates a code39 object', () => { - const { objects } = parseZPL('^XA^FO0,0^B3N,N,100,Y,N^FDABC^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^B3N,N,100,Y,N^FDABC^FS^XZ', 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('code39'); expect(props(objects[0]).content).toBe('ABC'); @@ -791,7 +790,7 @@ describe('parseZPL — ^B3 Code 39', () => { describe('parseZPL — ^BQ QR Code', () => { it('creates a qrcode object with error correction and content', () => { - const { objects } = parseZPL('^XA^FO0,0^BQN,2,6^FDQA,https://example.com^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^BQN,2,6^FDQA,https://example.com^FS^XZ', 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('qrcode'); expect(props(objects[0]).content).toBe('https://example.com'); @@ -801,19 +800,19 @@ describe('parseZPL — ^BQ QR Code', () => { }); it('preserves Model 1 from ^BQ b (round-trip, no silent change to 2)', () => { - const { objects } = parseZPL('^XA^FO0,0^BQN,1,4^FDQA,X^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^BQN,1,4^FDQA,X^FS^XZ', 8); expect(props(objects[0]).model).toBe(1); }); it('falls back to Model 2 for a missing/invalid ^BQ b', () => { - const { objects } = parseZPL('^XA^FO0,0^BQN,,4^FDQA,X^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^BQN,,4^FDQA,X^FS^XZ', 8); expect(props(objects[0]).model).toBe(2); }); }); describe('parseZPL — ^BX DataMatrix', () => { it('creates a datamatrix object', () => { - const { objects } = parseZPL('^XA^FO0,0^BXN,8,200^FD1234567890^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^BXN,8,200^FD1234567890^FS^XZ', 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('datamatrix'); expect(props(objects[0]).content).toBe('1234567890'); @@ -823,7 +822,7 @@ describe('parseZPL — ^BX DataMatrix', () => { }); it('reads GS1 mode from the escape param and decodes FNC1 separators', () => { - const { objects } = parseZPL( + const { objects } = parseSingle( '^XA^FO0,0^BXN,8,200,,,,_^FD_1010950110153000310ABC123_12112345^FS^XZ', 8, ); @@ -833,36 +832,36 @@ describe('parseZPL — ^BX DataMatrix', () => { }); it('does not treat the escape param as GS1 below quality 200', () => { - const { objects } = parseZPL('^XA^FO0,0^BXN,8,140,,,,_^FD_1010950110153000310^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^BXN,8,140,,,,_^FD_1010950110153000310^FS^XZ', 8); expect(props(objects[0]).gs1).toBe(false); expect(props(objects[0]).content).toBe('_1010950110153000310'); }); it('keeps non-GS1 field data verbatim (no leading FNC1, g set)', () => { - const { objects } = parseZPL('^XA^FO0,0^BXN,8,200,,,,_^FDABC_DEF^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^BXN,8,200,,,,_^FDABC_DEF^FS^XZ', 8); expect(props(objects[0]).gs1).toBe(false); expect(props(objects[0]).content).toBe('ABC_DEF'); }); it('keeps ~dNNN escapes in field data (tilde before a letter that is no immediate command)', () => { - const { objects, importReport } = parseZPL( + const { objects, findings } = parseSingle( '^XA^FO10,10^BXN,8,200,,,,~^FD~1010950110153000310ABC123~d0292112345^FS^XZ', 8, ); - expect(importReport.unknown).toEqual([]); + expect(commandsOf({ findings }, 'unknown')).toEqual([]); expect(props(objects[0]).gs1).toBe(true); expect(props(objects[0]).content).toBe('010950110153000310ABC123\x1d2112345'); }); it('still ends field data at a real immediate command (~JA is intercepted by firmware)', () => { - const { objects } = parseZPL('^XA^FO0,0^BXN,8,200^FDAB~JACD^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^BXN,8,200^FDAB~JACD^FS^XZ', 8); expect(props(objects[0]).content).toBe('AB'); }); it('reads a tilde escape char and the rectangular a param', () => { // Production label: g=~ (the historic firmware default escape char), a=2. // The tilde inside ^FD must survive tokenization, not start a command. - const { objects } = parseZPL( + const { objects } = parseSingle( '^XA^FO50,50^BXN,4,200,,,,~,2^FD~1010426011945468210FM260693^FS^XZ', 8, ); @@ -874,43 +873,43 @@ describe('parseZPL — ^BX DataMatrix', () => { }); it('ignores the a param below quality 200 (firmware prints square)', () => { - const { objects } = parseZPL('^XA^FO0,0^BXN,8,140,,,,,2^FDX^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^BXN,8,140,,,,,2^FDX^FS^XZ', 8); expect(props(objects[0]).aspectRatio).toBeUndefined(); }); it('reads a forced symbol size from the c/r params', () => { - const { objects } = parseZPL('^XA^FO0,0^BXN,8,200,22,22^FDX^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^BXN,8,200,22,22^FDX^FS^XZ', 8); expect(props(objects[0]).columns).toBe(22); expect(props(objects[0]).rows).toBe(22); }); it('drops a forced c/r below quality 200 (invalid there; preview auto-sizes)', () => { - const { objects } = parseZPL('^XA^FO0,0^BXN,8,140,22,22^FDX^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^BXN,8,140,22,22^FDX^FS^XZ', 8); expect(props(objects[0]).columns).toBeUndefined(); expect(props(objects[0]).rows).toBeUndefined(); }); it('derives the rectangular shape from a forced DMRE pair without the a param', () => { - const { objects } = parseZPL('^XA^FO0,0^BXN,8,200,18,8^FDX^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^BXN,8,200,18,8^FDX^FS^XZ', 8); expect(props(objects[0]).columns).toBe(18); expect(props(objects[0]).rows).toBe(8); expect(props(objects[0]).aspectRatio).toBe(2); }); it('a forced square pair overrides a stray a=2', () => { - const { objects } = parseZPL('^XA^FO0,0^BXN,8,200,22,22,,,2^FDX^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^BXN,8,200,22,22,,,2^FDX^FS^XZ', 8); expect(props(objects[0]).aspectRatio).toBeUndefined(); }); it('accepts quality 100 (convolution ECC)', () => { - const { objects } = parseZPL('^XA^FO0,0^BXN,8,100^FDX^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^BXN,8,100^FDX^FS^XZ', 8); expect(props(objects[0]).quality).toBe(100); }); }); describe('parseZPL — ^BU UPC-A', () => { it('creates a upca object', () => { - const { objects } = parseZPL('^XA^FO0,0^BUN,80,Y,N,N^FD01234567890^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^BUN,80,Y,N,N^FD01234567890^FS^XZ', 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('upca'); expect(props(objects[0]).content).toBe('01234567890'); @@ -920,7 +919,7 @@ describe('parseZPL — ^BU UPC-A', () => { describe('parseZPL — ^B8 EAN-8', () => { it('creates an ean8 object', () => { - const { objects } = parseZPL('^XA^FO0,0^B8N,80,Y^FD12345670^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^B8N,80,Y^FD12345670^FS^XZ', 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('ean8'); }); @@ -928,7 +927,7 @@ describe('parseZPL — ^B8 EAN-8', () => { describe('parseZPL — ^B9 UPC-E', () => { it('creates a upce object', () => { - const { objects } = parseZPL('^XA^FO0,0^B9N,80,Y^FD01234565^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^B9N,80,Y^FD01234565^FS^XZ', 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('upce'); }); @@ -936,7 +935,7 @@ describe('parseZPL — ^B9 UPC-E', () => { describe('parseZPL — ^B2 Interleaved 2 of 5', () => { it('creates an interleaved2of5 object', () => { - const { objects } = parseZPL('^XA^FO0,0^B2N,100,Y,N,Y^FD12345678^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^B2N,100,Y,N,Y^FD12345678^FS^XZ', 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('interleaved2of5'); expect(props(objects[0]).checkDigit).toBe(true); @@ -945,7 +944,7 @@ describe('parseZPL — ^B2 Interleaved 2 of 5', () => { describe('parseZPL — ^BA Code 93', () => { it('creates a code93 object', () => { - const { objects } = parseZPL('^XA^FO0,0^BAN,100,Y,N,N^FDABC123^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^BAN,100,Y,N,N^FDABC123^FS^XZ', 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('code93'); }); @@ -953,7 +952,7 @@ describe('parseZPL — ^BA Code 93', () => { describe('parseZPL — ^B7 PDF417', () => { it('creates a pdf417 object', () => { - const { objects } = parseZPL('^XA^FO0,0^B7N,15,3,5,,,^FDTest Data^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^B7N,15,3,5,,,^FDTest Data^FS^XZ', 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('pdf417'); expect(props(objects[0]).content).toBe('Test Data'); @@ -965,7 +964,7 @@ describe('parseZPL — ^B7 PDF417', () => { describe('parseZPL — ^BE EAN-13', () => { it('creates an ean13 object', () => { - const { objects } = parseZPL('^XA^FO0,0^BEN,100,Y^FD5901234123457^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^BEN,100,Y^FD5901234123457^FS^XZ', 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('ean13'); expect(props(objects[0]).content).toBe('5901234123457'); @@ -976,7 +975,7 @@ describe('parseZPL — ^BE EAN-13', () => { describe('parseZPL — ^GE ellipse', () => { it('creates an unfilled ellipse', () => { - const { objects } = parseZPL('^XA^FO0,0^GE200,100,3,B^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^GE200,100,3,B^FS^XZ', 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('ellipse'); expect(props(objects[0]).width).toBe(200); @@ -985,19 +984,19 @@ describe('parseZPL — ^GE ellipse', () => { }); it('detects a filled ellipse when thickness >= min dimension', () => { - const { objects } = parseZPL('^XA^FO0,0^GE100,80,80,B^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^GE100,80,80,B^FS^XZ', 8); expect(props(objects[0]).filled).toBe(true); }); it('preserves the original thickness on filled ^GE (lossless round-trip)', () => { - const { objects } = parseZPL('^XA^FO0,0^GE100,80,80,B^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^GE100,80,80,B^FS^XZ', 8); expect(props(objects[0]).thickness).toBe(80); }); }); describe('parseZPL — ^GS graphic symbol', () => { it('creates a symbol object with code, dims and rotation', () => { - const { objects } = parseZPL('^XA^FO30,40^GSR,50,60^FDC^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO30,40^GSR,50,60^FDC^FS^XZ', 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('symbol'); expect(props(objects[0]).symbol).toBe('C'); @@ -1007,12 +1006,12 @@ describe('parseZPL — ^GS graphic symbol', () => { }); it('falls back to "B" (©) when ^FD payload is not a known code', () => { - const { objects } = parseZPL('^XA^FO0,0^GSN,30,30^FDZ^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^GSN,30,30^FDZ^FS^XZ', 8); expect(props(objects[0]).symbol).toBe('B'); }); it('defaults width to height when ^GS width omitted', () => { - const { objects } = parseZPL('^XA^FO0,0^GSN,40^FDA^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^GSN,40^FDA^FS^XZ', 8); expect(props(objects[0]).height).toBe(40); expect(props(objects[0]).width).toBe(40); }); @@ -1021,7 +1020,7 @@ describe('parseZPL — ^GS graphic symbol', () => { // Bare ^GS without ^FD is malformed but seen in the wild; the // parser must NOT treat the next unrelated ^FD (here: a plain // text field) as the symbol payload. - const { objects } = parseZPL( + const { objects } = parseSingle( '^XA^FO0,0^GSN,40,40^FS^FO100,100^A0N,30,30^FDhello^FS^XZ', 8, ); @@ -1034,7 +1033,7 @@ describe('parseZPL — ^GS graphic symbol', () => { for (const code of ['A','B','C','D','E'] as const) { for (const rot of ['N','R','I','B'] as const) { const zpl = `^XA^FO10,20^GS${rot},40,40^FD${code}^FS^XZ`; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(props(objects[0]).symbol).toBe(code); expect(props(objects[0]).rotation).toBe(rot); } @@ -1044,7 +1043,7 @@ describe('parseZPL — ^GS graphic symbol', () => { describe('parseZPL — ^GC circle', () => { it('creates an ellipse with equal width and height from ^GC', () => { - const { objects } = parseZPL('^XA^FO0,0^GC100,3,B^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^GC100,3,B^FS^XZ', 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('ellipse'); expect(props(objects[0]).width).toBe(100); @@ -1054,19 +1053,19 @@ describe('parseZPL — ^GC circle', () => { }); it('creates a filled circle when thickness >= diameter', () => { - const { objects } = parseZPL('^XA^FO0,0^GC50,50,B^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^GC50,50,B^FS^XZ', 8); expect(props(objects[0]).filled).toBe(true); }); it('preserves the original thickness on filled ^GC (lossless round-trip)', () => { - const { objects } = parseZPL('^XA^FO0,0^GC50,50,B^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^GC50,50,B^FS^XZ', 8); expect(props(objects[0]).thickness).toBe(50); }); }); describe('parseZPL — ^GD diagonal line', () => { it('creates a line object from a diagonal ^GD command', () => { - const { objects } = parseZPL('^XA^FO10,20^GD200,100,3,B,L^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO10,20^GD200,100,3,B,L^FS^XZ', 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('line'); expect(props(objects[0]).thickness).toBe(3); @@ -1084,7 +1083,7 @@ describe('parseZPL — ^GFA graphic field', () => { it('creates an image object from a ^GFA command with uncompressed hex', () => { // 1 byte per row, 2 rows → 2 bytes total, simple hex data const hexData = 'FF00'; - const { objects } = parseZPL(`^XA^FO0,0^GFA,2,2,1,${hexData}^FS^XZ`, 8); + const { objects } = parseSingle(`^XA^FO0,0^GFA,2,2,1,${hexData}^FS^XZ`, 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('image'); expect(props(objects[0]).widthDots).toBe(8); // 1 byte per row × 8 bits @@ -1094,28 +1093,28 @@ describe('parseZPL — ^GFA graphic field', () => { it('imports a :B64:-wrapped ^GFA payload as an image (CRC valid)', () => { // 8 bytes = [0,0,0,0xFF,0xFF,0,0,0] → base64 "AAAA//8AAAA=" // CRC-16/CCITT-FALSE over "AAAA//8AAAA=" = 0xDFF8 - const { objects, importReport } = parseZPL( + const { objects, findings } = parseSingle( '^XA^FO0,0^GFA,8,8,1,:B64:AAAA//8AAAA=:DFF8^FS^XZ', 8, ); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('image'); expect(props(objects[0]).widthDots).toBe(8); - expect(importReport.partial).not.toContain('^GF'); + expect(commandsOf({ findings }, 'partial')).not.toContain('^GF'); }); it('still renders a :B64: payload with mismatched CRC but flags as partial', () => { - const { objects, importReport } = parseZPL( + const { objects, findings } = parseSingle( '^XA^FO0,0^GFA,8,8,1,:B64:AAAA//8AAAA=:0000^FS^XZ', 8, ); expect(objects).toHaveLength(1); - expect(importReport.partial).toContain('^GF'); + expect(commandsOf({ findings }, 'partial')).toContain('^GF'); }); it('accepts :B64: wrapper on ^GFB and ^GFC (no raw-binary path needed)', () => { for (const fmt of ['B', 'C'] as const) { - const { objects } = parseZPL( + const { objects } = parseSingle( `^XA^FO0,0^GF${fmt},8,8,1,:B64:AAAA//8AAAA=:DFF8^FS^XZ`, 8, ); @@ -1129,9 +1128,9 @@ describe('parseZPL — ^GFA graphic field', () => { // Labelary accepts this; we should too. const zpl = '^XA^FO0,0^GFA,8,8,1,:B64:AAAA\n//8AAAA=:DFF8^FS^XZ'; - const { objects, importReport } = parseZPL(zpl, 8); + const { objects, findings } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); - expect(importReport.partial).not.toContain('^GF'); + expect(commandsOf({ findings }, 'partial')).not.toContain('^GF'); }); it('tolerates trailing whitespace on wrapped GF payloads', () => { @@ -1140,24 +1139,24 @@ describe('parseZPL — ^GFA graphic field', () => { // to accommodate that. const zplWithNewline = '^XA\n^FO0,0\n^GFA,8,8,1,:B64:AAAA//8AAAA=:DFF8\n^FS\n^XZ'; - const { objects, importReport } = parseZPL(zplWithNewline, 8); + const { objects, findings } = parseSingle(zplWithNewline, 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('image'); - expect(importReport.browserLimit).toHaveLength(0); + expect(commandsOf({ findings }, 'browserLimit')).toHaveLength(0); }); it('imports a :Z64:-wrapped ^GFC payload by inflating zlib data', () => { // 8 bytes = [0,0,0,0xFF,0xFF,0,0,0] → zlib-compressed → base64 → CRC. const bytes = new Uint8Array([0, 0, 0, 0xff, 0xff, 0, 0, 0]); const field = makeZ64Field(bytes); - const { objects, importReport } = parseZPL( + const { objects, findings } = parseSingle( `^XA^FO0,0^GFC,8,8,1,${field}^FS^XZ`, 8, ); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('image'); expect(props(objects[0]).widthDots).toBe(8); - expect(importReport.partial).not.toContain('^GF'); + expect(commandsOf({ findings }, 'partial')).not.toContain('^GF'); }); it('preserves an undecodable :Z64: ^GF verbatim, sizing height from c not b', () => { @@ -1167,7 +1166,7 @@ describe('parseZPL — ^GFA graphic field', () => { // than c=16 (uncompressed field count); height must come from c/d, not b/d. const b64 = btoa('not a valid zlib stream'); const field = `:Z64:${b64}:${testCrc16(b64)}`; - const { objects, importReport } = parseZPL( + const { objects, findings } = parseSingle( `^XA^FO0,0^GFC,4,16,2,${field}^FS^XZ`, 8, ); @@ -1176,8 +1175,8 @@ describe('parseZPL — ^GFA graphic field', () => { expect(p.widthDots).toBe(16); // d=2 → 2*8 expect(p.heightDots).toBe(8); // c/d = 16/2, not b/d = 4/2 expect(p.rawGf).toBe(`^GFC,4,16,2,${field}`); - expect(importReport.partial).toContain('^GF'); - expect(importReport.browserLimit).toHaveLength(0); + expect(commandsOf({ findings }, 'partial')).toContain('^GF'); + expect(commandsOf({ findings }, 'browserLimit')).toHaveLength(0); }); it('falls back to a square when an opaque ^GF c is under one full row (height would floor to 0)', () => { @@ -1185,7 +1184,7 @@ describe('parseZPL — ^GFA graphic field', () => { // falls back to the square width (d*8) instead of a 0-dot sliver. const b64 = btoa('not a valid zlib stream'); const field = `:Z64:${b64}:${testCrc16(b64)}`; - const { objects } = parseZPL(`^XA^FO0,0^GFC,4,1,2,${field}^FS^XZ`, 8); + const { objects } = parseSingle(`^XA^FO0,0^GFC,4,1,2,${field}^FS^XZ`, 8); expect(objects).toHaveLength(1); const p = props(objects[0]); expect(p.widthDots).toBe(16); @@ -1197,7 +1196,7 @@ describe('parseZPL — ^GFA graphic field', () => { // the preserved header is rebuilt with commas to round-trip a second time. const b64 = btoa('not a real zlib stream'); const field = `:Z64:${b64}:${testCrc16(b64)}`; - const { objects } = parseZPL(`^XA^FO0,0^CD;^GFC;4;16;2;${field}^FS^XZ`, 8); + const { objects } = parseSingle(`^XA^FO0,0^CD;^GFC;4;16;2;${field}^FS^XZ`, 8); expect(objects).toHaveLength(1); expect(props(objects[0]).rawGf).toBe(`^GFC,4,16,2,${field}`); }); @@ -1207,7 +1206,7 @@ describe('parseZPL — ^GFA graphic field', () => { // bytesPerRow=1, so we need 2 nibbles per row // "GF" = 1×F = "F" → only one nibble, padded to "F0" // Two rows: "GF,GF" should give us 2 rows → totalBytes=2 - const { objects } = parseZPL('^XA^FO0,0^GFA,2,2,1,FF,:^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^GFA,2,2,1,FF,:^FS^XZ', 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('image'); }); @@ -1219,7 +1218,7 @@ describe('parseZPL — ^FX label metadata sidecar', () => { it('recovers dpmm/width/height from the leading sidecar, overriding ^PW/^LL', () => { const zpl = `^XA${formatLabelMetaComment({ dpmm: 12, widthMm: 57, heightMm: 32 })}^PW800^LL600^FO0,0^A0N,30,0^FDx^FS^XZ`; // External dpmm 8 is deliberately wrong; the sidecar must win. - const { labelConfig } = parseZPL(zpl, 8); + const { labelConfig } = parseSingle(zpl, 8); expect(labelConfig.dpmm).toBe(12); expect(labelConfig.widthMm).toBe(57); expect(labelConfig.heightMm).toBe(32); @@ -1227,20 +1226,20 @@ describe('parseZPL — ^FX label metadata sidecar', () => { it('does not attach the sidecar as the next object comment', () => { const zpl = `^XA${formatLabelMetaComment({ dpmm: 8, widthMm: 100, heightMm: 60 })}^FO0,0^A0N,30,0^FDx^FS^XZ`; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); expect(objects[0]?.comment).toBeUndefined(); }); it('ignores a sidecar that appears after an object (non-leading slot)', () => { const zpl = `^XA^FO0,0^A0N,30,0^FDx^FS${formatLabelMetaComment({ dpmm: 24, widthMm: 20, heightMm: 20 })}^FO0,50^A0N,30,0^FDy^FS^XZ`; - const { labelConfig, objects } = parseZPL(zpl, 8); + const { labelConfig, objects } = parseSingle(zpl, 8); expect(labelConfig.dpmm).not.toBe(24); expect(props(objects[1]).content).toBe('y'); }); it('keeps a foreign ^FX as a normal object comment', () => { - const { objects } = parseZPL('^XA^FXserial run 42^FO0,0^A0N,30,0^FDx^FS^XZ', 8); + const { objects } = parseSingle('^XA^FXserial run 42^FO0,0^A0N,30,0^FDx^FS^XZ', 8); expect(objects[0]?.comment).toBe('serial run 42'); }); }); @@ -1256,14 +1255,14 @@ describe('parseZPL — ~DY + ^XG graphic upload/recall', () => { const zpl = `~DY${PATH},A,G,4,1,${HEX}\n` + `^XA^FO50,80^XG${PATH}.GRF,1,1^FS^XZ`; - const { objects, importReport } = parseZPL(zpl, 8); + const { objects, findings } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('image'); expect(props(objects[0]).widthDots).toBe(8); expect(props(objects[0]).storedAs).toEqual({ device: 'R', name: 'LOGO', embedInZpl: true }); expect(objects[0]?.x).toBe(50); expect(objects[0]?.y).toBe(80); - expect(importReport.browserLimit).toHaveLength(0); + expect(commandsOf({ findings }, 'browserLimit')).toHaveLength(0); }); it('resolves ^XG even when the .GRF suffix is omitted', () => { @@ -1272,10 +1271,10 @@ describe('parseZPL — ~DY + ^XG graphic upload/recall', () => { const zpl = `~DYR:LOGO,A,G,4,1,00FFFF00\n` + `^XA^FO50,80^XGR:LOGO,1,1^FS^XZ`; - const { objects, importReport } = parseZPL(zpl, 8); + const { objects, findings } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); expect(props(objects[0]).storedAs).toEqual({ device: 'R', name: 'LOGO', embedInZpl: true }); - expect(importReport.browserLimit).toHaveLength(0); + expect(commandsOf({ findings }, 'browserLimit')).toHaveLength(0); }); it('^XG without a preceding ~DY imports as recall-only image', () => { @@ -1284,12 +1283,12 @@ describe('parseZPL — ~DY + ^XG graphic upload/recall', () => { // position/edit it; embedInZpl=false stops the emitter from // re-uploading bytes we never received. const zpl = `^XA^FO0,0^XGR:MISSING.GRF,1,1^FS^XZ`; - const { objects, importReport } = parseZPL(zpl, 8); + const { objects, findings } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); expect(props(objects[0]).storedAs).toEqual({ device: 'R', name: 'MISSING', embedInZpl: false, }); - expect(importReport.partial).toContain('^XG'); + expect(commandsOf({ findings }, 'partial')).toContain('^XG'); }); it('accepts :Z64:-wrapped graphic payloads in ~DY (format C)', () => { @@ -1298,10 +1297,10 @@ describe('parseZPL — ~DY + ^XG graphic upload/recall', () => { const zpl = `~DY${PATH},C,G,4,1,${field}\n` + `^XA^FO0,0^XG${PATH}.GRF,1,1^FS^XZ`; - const { objects, importReport } = parseZPL(zpl, 8); + const { objects, findings } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); expect(props(objects[0]).storedAs).toEqual({ device: 'R', name: 'LOGO', embedInZpl: true }); - expect(importReport.partial).not.toContain('~DY'); + expect(commandsOf({ findings }, 'partial')).not.toContain('~DY'); }); }); @@ -1309,13 +1308,13 @@ describe('parseZPL — ~DY + ^XG graphic upload/recall', () => { describe('parseZPL — ^LR label reverse', () => { it('sets reverse on text when ^LRY is active', () => { - const { objects } = parseZPL('^XA^LRY^FO0,0^A0N,30,0^FDReversed^FS^LRN^XZ', 8); + const { objects } = parseSingle('^XA^LRY^FO0,0^A0N,30,0^FDReversed^FS^LRN^XZ', 8); expect(props(objects[0]).reverse).toBe(true); }); it('disables reverse after ^LRN', () => { const zpl = '^XA^LRY^FO0,0^A0N,30,0^FDFirst^FS^LRN^FO0,50^A0N,30,0^FDSecond^FS^XZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(props(objects[0]).reverse).toBe(true); expect(props(objects[1]).reverse).toBeFalsy(); }); @@ -1325,7 +1324,7 @@ describe('parseZPL — ^LR label reverse', () => { describe('parseZPL — ^FW field default rotation', () => { it('applies default rotation to implicit text fields', () => { - const { objects } = parseZPL('^XA^FWR^CF0,30^FO0,0^FDRotated^FS^XZ', 8); + const { objects } = parseSingle('^XA^FWR^CF0,30^FO0,0^FDRotated^FS^XZ', 8); expect(props(objects[0]).rotation).toBe('R'); }); }); @@ -1334,36 +1333,36 @@ describe('parseZPL — ^FW field default rotation', () => { describe('parseZPL — ^MM and ^LS', () => { it('parses media mode', () => { - const { labelConfig } = parseZPL('^XA^MMT^XZ', 8); + const { labelConfig } = parseSingle('^XA^MMT^XZ', 8); expect(labelConfig.mediaMode).toBe('T'); }); it('parses label shift', () => { - const { labelConfig } = parseZPL('^XA^LS10^XZ', 8); + const { labelConfig } = parseSingle('^XA^LS10^XZ', 8); expect(labelConfig.labelShift).toBe(10); }); }); describe('parseZPL — printer params', () => { it('parses ^PR print speed within range', () => { - const { labelConfig } = parseZPL('^XA^PR6^XZ', 8); + const { labelConfig } = parseSingle('^XA^PR6^XZ', 8); expect(labelConfig.printSpeed).toBe(6); }); it('ignores ^PR with out-of-range value', () => { - const { labelConfig } = parseZPL('^XA^PR1^XZ', 8); + const { labelConfig } = parseSingle('^XA^PR1^XZ', 8); expect(labelConfig.printSpeed).toBeUndefined(); }); it('parses ^PR with slew and backfeed', () => { - const { labelConfig } = parseZPL('^XA^PR6,8,4^XZ', 8); + const { labelConfig } = parseSingle('^XA^PR6,8,4^XZ', 8); expect(labelConfig.printSpeed).toBe(6); expect(labelConfig.slewSpeed).toBe(8); expect(labelConfig.backfeedSpeed).toBe(4); }); it('parses extended ^PQ params', () => { - const { labelConfig } = parseZPL('^XA^PQ5,2,3,Y^XZ', 8); + const { labelConfig } = parseSingle('^XA^PQ5,2,3,Y^XZ', 8); expect(labelConfig.printQuantity).toBe(5); expect(labelConfig.pauseCount).toBe(2); expect(labelConfig.replicates).toBe(3); @@ -1376,7 +1375,7 @@ describe('parseZPL — printer params', () => { }); it('parses ^CF width into defaultFontWidth', () => { - const { labelConfig } = parseZPL('^XA^CFA,30,20^XZ', 8); + const { labelConfig } = parseSingle('^XA^CFA,30,20^XZ', 8); expect(labelConfig.defaultFontId).toBe('A'); expect(labelConfig.defaultFontHeight).toBe(30); expect(labelConfig.defaultFontWidth).toBe(20); @@ -1387,7 +1386,7 @@ describe('parseZPL — printer params', () => { // only carries the alias char so re-emitting produces the same // short ^A{id} form. printerFontName remains undefined; that field // is for the long ^A@,…E:NAME.TTF form, not for alias-based refs. - const { labelConfig, objects } = parseZPL( + const { labelConfig, objects } = parseSingle( '^XA^CWM,E:ARIAL.TTF^FO10,10^AMN,30,0^FDHi^FS^XZ', 8, ); @@ -1403,7 +1402,7 @@ describe('parseZPL — printer params', () => { // ^CFM then ^AMN repeats the default font. The model says // "field uses the label default" by leaving fontId undefined, and // the generator's default-fallback branch restores the ^AM emit. - const { objects } = parseZPL( + const { objects } = parseSingle( '^XA^CFM,30,0^FO10,10^AMN,30,0^FDHi^FS^XZ', 8, ); @@ -1412,7 +1411,7 @@ describe('parseZPL — printer params', () => { }); it('ignores invalid ^CW arguments', () => { - const { labelConfig } = parseZPL('^XA^CW,^XZ', 8); + const { labelConfig } = parseSingle('^XA^CW,^XZ', 8); expect(labelConfig.customFonts).toBeUndefined(); }); @@ -1420,7 +1419,7 @@ describe('parseZPL — printer params', () => { // Two ^CW lines for the same alias: the second should overwrite // the first in customFonts, matching the runtime fontAliases.set // last-wins semantics. - const { labelConfig } = parseZPL( + const { labelConfig } = parseSingle( '^XA^CWM,E:OLD.TTF^CWM,E:NEW.TTF^XZ', 8, ); @@ -1430,7 +1429,7 @@ describe('parseZPL — printer params', () => { }); it('keeps separate ^CW mappings that share a path but use different aliases', () => { - const { labelConfig } = parseZPL( + const { labelConfig } = parseSingle( '^XA^CWM,E:FOO.TTF^CWN,E:FOO.TTF^XZ', 8, ); @@ -1448,9 +1447,9 @@ describe('parseZPL — printer params', () => { it('parses ~JS backfeed sequence; percent forms surface as partial', () => { expect(parseZPL('~JSA^XA^XZ', 8).labelConfig.backfeedSequence).toBe('A'); expect(parseZPL('~JSO^XA^XZ', 8).labelConfig.backfeedSequence).toBe('O'); - const { labelConfig, importReport } = parseZPL('~JS40^XA^XZ', 8); + const { labelConfig, findings } = parseSingle('~JS40^XA^XZ', 8); expect(labelConfig.backfeedSequence).toBeUndefined(); - expect(importReport.partial).toContain('~JS'); + expect(commandsOf({ findings }, 'partial')).toContain('~JS'); }); it('parses ^MD darkness including 0', () => { @@ -1460,7 +1459,7 @@ describe('parseZPL — printer params', () => { }); it('ignores ^MD outside the supported range', () => { - const { labelConfig } = parseZPL('^XA^MD99^XZ', 8); + const { labelConfig } = parseSingle('^XA^MD99^XZ', 8); expect(labelConfig.darkness).toBeUndefined(); }); @@ -1475,7 +1474,7 @@ describe('parseZPL — printer params', () => { }); it('parses ^CF into defaultFontId and defaultFontHeight', () => { - const { labelConfig } = parseZPL('^XA^CF0,40^XZ', 8); + const { labelConfig } = parseSingle('^XA^CF0,40^XZ', 8); expect(labelConfig.defaultFontId).toBe('0'); expect(labelConfig.defaultFontHeight).toBe(40); }); @@ -1485,19 +1484,21 @@ describe('parseZPL — printer params', () => { describe('parseZPL — edge cases', () => { it('returns empty results for empty ZPL', () => { - const { objects, skipped } = parseZPL('', 8); - expect(objects).toHaveLength(0); + const parsed = parseSingle('', 8); + const skipped = [...commandsOf(parsed, 'browserLimit'), ...commandsOf(parsed, 'unknown')]; + expect(parsed.objects).toHaveLength(0); expect(skipped).toHaveLength(0); }); it('handles ^XA^XZ (empty label)', () => { - const { objects, skipped } = parseZPL('^XA^XZ', 8); - expect(objects).toHaveLength(0); + const parsed = parseSingle('^XA^XZ', 8); + const skipped = [...commandsOf(parsed, 'browserLimit'), ...commandsOf(parsed, 'unknown')]; + expect(parsed.objects).toHaveLength(0); expect(skipped).toHaveLength(0); }); it('handles multiple ^FO without ^FD (bare origins are benign)', () => { - const { objects } = parseZPL('^XA^FO10,20^FO30,40^A0N,30,0^FDText^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO10,20^FO30,40^A0N,30,0^FDText^FS^XZ', 8); expect(objects).toHaveLength(1); // FO/N h=30 → obj.y = 40 - 4.62. expect(objects[0]?.x).toBeCloseTo(30); @@ -1505,7 +1506,7 @@ describe('parseZPL — edge cases', () => { }); it('supports different dpmm values (12 dpmm / 300 DPI)', () => { - const { labelConfig } = parseZPL('^XA^PW1200^LL600^XZ', 12); + const { labelConfig } = parseSingle('^XA^PW1200^LL600^XZ', 12); expect(labelConfig.widthMm).toBe(100); expect(labelConfig.heightMm).toBe(50); }); @@ -1558,13 +1559,13 @@ const EXAMPLE_ZPL = ` `.trim(); describe('parseZPL — example shipping label (integration)', () => { - let objects: ReturnType['objects']; - let skipped: ReturnType['skipped']; + let objects: ReturnType['objects']; + let skipped: string[]; beforeAll(() => { - const result = parseZPL(EXAMPLE_ZPL, 8); + const result = parseSingle(EXAMPLE_ZPL, 8); objects = result.objects; - skipped = result.skipped; + skipped = [...commandsOf(result, 'browserLimit'), ...commandsOf(result, 'unknown')]; }); it('produces exactly 23 objects', () => { @@ -1651,7 +1652,7 @@ describe('parseZPL — example shipping label (integration)', () => { describe('parseZPL — ^SN serialization', () => { it('marks a text field serial when ^SN follows ^FD', () => { - const { objects } = parseZPL('^XA^FO10,20^A0N,30,0^FD001^FS\n^SN001,1,Y^XZ', 8); + const { objects } = parseSingle('^XA^FO10,20^A0N,30,0^FD001^FS\n^SN001,1,Y^XZ', 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('text'); expect(props(objects[0]).content).toBe('001'); @@ -1660,13 +1661,13 @@ describe('parseZPL — ^SN serialization', () => { }); it('picks up increment from ^SN parameters', () => { - const { objects } = parseZPL('^XA^FO0,0^A0N,25,0^FD100^FS\n^SN100,5,Y^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^A0N,25,0^FD100^FS\n^SN100,5,Y^XZ', 8); expect(serialOf(objects[0])?.increment).toBe(5); expect(props(objects[0]).fontHeight).toBe(25); }); it('preserves font rotation from ^A0', () => { - const { objects } = parseZPL('^XA^FO0,0^A0R,30,0^FD001^FS\n^SN001,1,Y^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^A0R,30,0^FD001^FS\n^SN001,1,Y^XZ', 8); expect(props(objects[0]).rotation).toBe('R'); }); }); @@ -1675,7 +1676,7 @@ describe('parseZPL — ^SN serialization', () => { describe('parseZPL — ^SF serialization', () => { it('marks a text field serial from ^FD + ^SF', () => { - const { objects } = parseZPL('^XA^FO0,0^A0N,30,0^FD001^SFddd,1^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^A0N,30,0^FD001^SFddd,1^FS^XZ', 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('text'); expect(props(objects[0]).content).toBe('001'); @@ -1684,14 +1685,14 @@ describe('parseZPL — ^SF serialization', () => { }); it('picks up the increment from the ^SF increment string (b param)', () => { - const { objects } = parseZPL('^XA^FO0,0^A0N,30,0^FD100^SFddd,3^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^A0N,30,0^FD100^SFddd,3^FS^XZ', 8); expect(serialOf(objects[0])?.increment).toBe(3); }); it('serializes a barcode field but does not leak to a sibling', () => { const zpl = '^XA^FO10,10^BCN,100,Y,N,N^FD123^SF%%%,1^FS^FO50,50^A0N,30,0^FDtext^FS^XZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects).toHaveLength(2); expect(objects[0]?.type).toBe('code128'); expect(serialOf(objects[0])?.zplMode).toBe('SF'); @@ -1701,14 +1702,14 @@ describe('parseZPL — ^SF serialization', () => { it('does not leak snPending across a bare ^SF^FS', () => { const zpl = '^XA^SF%%%,1^FS^FO10,10^A0N,30,0^FDtext^FS^XZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('text'); }); it('preserves snPending when ^SF appears before ^FO', () => { const zpl = '^XA^SFddd,3^FO10,10^A0N,30,0^FD001^FS^XZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('text'); expect(serialOf(objects[0])?.increment).toBe(3); @@ -1717,7 +1718,7 @@ describe('parseZPL — ^SF serialization', () => { it('does not leak snPending to a sibling field inside the same ^FS block', () => { const zpl = '^XA^SFddd,3^FO10,10^A0N,30,0^FD001^FO20,20^A0N,30,0^FD002^FS^XZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects).toHaveLength(2); expect(serialOf(objects[0])?.increment).toBe(3); expect(serialOf(objects[1])).toBeUndefined(); @@ -1728,7 +1729,7 @@ describe('parseZPL — ^SF serialization', () => { describe('parseZPL — serial wins over a coexisting ^FN binding', () => { it('in-field ^SF after ^FN resolves to serial, not bound', () => { - const { objects } = parseZPL('^XA^FO10,10^A0N,30,0^FN1^FD001^SFddd,1^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO10,10^A0N,30,0^FN1^FD001^SFddd,1^FS^XZ', 8); expect(objects).toHaveLength(1); expect(serialOf(objects[0])?.zplMode).toBe('SF'); // Serial keeps the literal seed, not a variable marker. @@ -1736,13 +1737,13 @@ describe('parseZPL — serial wins over a coexisting ^FN binding', () => { }); it('does not attach serial to a 2D field whose emitter ignores ^SN/^SF', () => { - const { objects } = parseZPL('^XA^FO10,10^BQN,2,5^FDQA,0001^SN0001,1,Y^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO10,10^BQN,2,5^FDQA,0001^SN0001,1,Y^FS^XZ', 8); expect(objects[0]?.type).toBe('qrcode'); expect(serialOf(objects[0])).toBeUndefined(); }); it('post-^FS ^SN on a single-bound 1D field replaces the marker with a literal seed', () => { - const { objects } = parseZPL('^XA^FO10,10^BCN,100,Y,N,N^FN1^FD123^FS\n^SN123,1,Y^XZ', 8); + const { objects } = parseSingle('^XA^FO10,10^BCN,100,Y,N,N^FN1^FD123^FS\n^SN123,1,Y^XZ', 8); expect(objects).toHaveLength(1); expect(serialOf(objects[0])?.zplMode).toBe('SN'); // Not «field_1»: serialFieldData would otherwise filter it to "field1". @@ -1753,14 +1754,14 @@ describe('parseZPL — serial wins over a coexisting ^FN binding', () => { // The ^FD payload (123) differs from the ^SN start (999); ^SN governs, and // the emitter re-emits the seed from content, so a stale 123 would rewrite // the ZPL on export. - const { objects } = parseZPL('^XA^FO10,10^BCN,100,Y,N,N^FD123^FS\n^SN999,1,Y^XZ', 8); + const { objects } = parseSingle('^XA^FO10,10^BCN,100,Y,N,N^FD123^FS\n^SN999,1,Y^XZ', 8); expect(objects).toHaveLength(1); expect(serialOf(objects[0])?.zplMode).toBe('SN'); expect(props(objects[0]).content).toBe('999'); }); it('post-^FS ^SN with an empty start keeps the field ^FD as the seed', () => { - const { objects } = parseZPL('^XA^FO10,10^BCN,100,Y,N,N^FD123^FS\n^SN,1,Y^XZ', 8); + const { objects } = parseSingle('^XA^FO10,10^BCN,100,Y,N,N^FD123^FS\n^SN,1,Y^XZ', 8); expect(objects).toHaveLength(1); expect(serialOf(objects[0])?.zplMode).toBe('SN'); expect(props(objects[0]).content).toBe('123'); @@ -1768,14 +1769,14 @@ describe('parseZPL — serial wins over a coexisting ^FN binding', () => { it('in-field ^SN start value overrides a prior ^FD', () => { // ^FD123^SN999: ^SN's explicit start (999) is the seed, not the ^FD (123). - const { objects } = parseZPL('^XA^FO10,10^A0N,30,0^FD123^SN999,1,Y^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO10,10^A0N,30,0^FD123^SN999,1,Y^FS^XZ', 8); expect(objects).toHaveLength(1); expect(serialOf(objects[0])?.zplMode).toBe('SN'); expect(props(objects[0]).content).toBe('999'); }); it('in-field ^SN with empty start keeps the ^FD as the seed', () => { - const { objects } = parseZPL('^XA^FO10,10^A0N,30,0^FD123^SN,1,Y^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO10,10^A0N,30,0^FD123^SN,1,Y^FS^XZ', 8); expect(objects).toHaveLength(1); expect(serialOf(objects[0])?.zplMode).toBe('SN'); expect(props(objects[0]).content).toBe('123'); @@ -1784,7 +1785,7 @@ describe('parseZPL — serial wins over a coexisting ^FN binding', () => { it('keeps the ^FN binding on a non-serialisable 2D field that also declares ^SN', () => { // QR's emitter ignores ^SN; the variable link must survive instead of the // field collapsing to a literal seed (silent binding loss). - const { objects, variables } = parseZPL('^XA^FO10,10^BQN,2,5^FN1^FDHELLO^SN001,1,Y^FS^XZ', 8); + const { objects, variables } = parseSingle('^XA^FO10,10^BQN,2,5^FN1^FDHELLO^SN001,1,Y^FS^XZ', 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('qrcode'); expect(props(objects[0]).content).toBe('«field_1»'); @@ -1793,19 +1794,19 @@ describe('parseZPL — serial wins over a coexisting ^FN binding', () => { }); it('post-^FS ^SN on a previously ^FN-bound field clears the binding', () => { - const { objects } = parseZPL('^XA^FO10,10^A0N,30,0^FN1^FD001^FS\n^SN001,1,Y^XZ', 8); + const { objects } = parseSingle('^XA^FO10,10^A0N,30,0^FN1^FD001^FS\n^SN001,1,Y^XZ', 8); expect(objects).toHaveLength(1); expect(serialOf(objects[0])?.zplMode).toBe('SN'); expect(props(objects[0]).content).toBe('001'); }); it('in-field serial on an ^FN field creates no orphan variable', () => { - const { variables } = parseZPL('^XA^FO10,10^A0N,30,0^FN1^FD001^SN001,1,Y^FS^XZ', 8); + const { variables } = parseSingle('^XA^FO10,10^A0N,30,0^FN1^FD001^SN001,1,Y^FS^XZ', 8); expect(variables).toHaveLength(0); }); it('a shared slot still gets its variable from the non-serial sibling', () => { - const { objects, variables } = parseZPL( + const { objects, variables } = parseSingle( '^XA^FO10,10^A0N,30,0^FN1^FD001^SN001,1,Y^FS^FO10,60^A0N,30,0^FN1^FDABC^FS^XZ', 8); expect(variables).toHaveLength(1); expect(serialOf(objects[0])?.zplMode).toBe('SN'); @@ -1813,12 +1814,12 @@ describe('parseZPL — serial wins over a coexisting ^FN binding', () => { }); it('post-^FS ^SN removes the variable its stripped binding orphaned', () => { - const { variables } = parseZPL('^XA^FO10,10^A0N,30,0^FN1^FD001^FS\n^SN001,1,Y^XZ', 8); + const { variables } = parseSingle('^XA^FO10,10^A0N,30,0^FN1^FD001^FS\n^SN001,1,Y^XZ', 8); expect(variables).toHaveLength(0); }); it('post-^FS ^SN keeps a variable a sibling field still references', () => { - const { objects, variables } = parseZPL( + const { objects, variables } = parseSingle( '^XA^FO10,10^A0N,30,0^FN1^FD001^FS^FO10,60^A0N,30,0^FN1^FDABC^FS\n^SN001,1,Y^XZ', 8); expect(variables).toHaveLength(1); expect(props(objects[0]).content).toBe('«field_1»'); @@ -1826,7 +1827,7 @@ describe('parseZPL — serial wins over a coexisting ^FN binding', () => { }); it('post-^FS ^SN keeps a bare-declared variable', () => { - const { variables } = parseZPL( + const { variables } = parseSingle( '^XA^FN1^FDdecl^FS^FO10,10^A0N,30,0^FN1^FD001^FS\n^SN001,1,Y^XZ', 8); expect(variables).toHaveLength(1); }); @@ -1834,7 +1835,7 @@ describe('parseZPL — serial wins over a coexisting ^FN binding', () => { it('in-field serial keeps the slot default for a later embed', () => { // Same shared-slot rule as the post-^FS path: the embed must see the // serial seed as default, not a freshly created empty variable. - const { objects, variables } = parseZPL( + const { objects, variables } = parseSingle( '^XA^FO10,10^A0N,30,0^FN1^SN001,1,Y^FS^FO10,60^A0N,30,0^FDlot #1#^FS^XZ', 8); expect(variables).toHaveLength(1); expect(variables[0]?.defaultValue).toBe('001'); @@ -1844,7 +1845,7 @@ describe('parseZPL — serial wins over a coexisting ^FN binding', () => { it('post-^FS ^SN keeps the variable when a later embed references the slot', () => { // The slot is shared (spec p.200): a later #1# must reuse the original // variable and its default, not a freshly created empty one. - const { objects, variables } = parseZPL( + const { objects, variables } = parseSingle( '^XA^FO10,10^A0N,30,0^FN1^FD001^FS\n^SN001,1,Y\n^FO10,60^A0N,30,0^FDlot #1#^FS^XZ', 8); expect(variables).toHaveLength(1); expect(variables[0]?.defaultValue).toBe('001'); @@ -1858,18 +1859,18 @@ describe('parseZPL — prefix char followed by another prefix', () => { it('discards the incomplete first prefix like firmware does', () => { // ZebraDesigner's `CT~~CD,` preamble: the first `~` is immediately // followed by another `~` and must be dropped, not read as command `~C`. - const { objects, importReport } = parseZPL( + const { objects, findings } = parseSingle( 'CT~~CD,~CC^~CT~^XA^FO10,20^A0N,30,0^FDHi^FS^XZ', 8); - expect(importReport.unknown).toEqual([]); + expect(commandsOf({ findings }, 'unknown')).toEqual([]); expect(objects).toHaveLength(1); expect(props(objects[0]).content).toBe('Hi'); }); it('resyncs when a prefix char sits at the second command-name position', () => { // A lone `~` before a newline must not swallow the following ^XA. - const { objects, importReport } = parseZPL( + const { objects, findings } = parseSingle( '~\n^XA^FO10,20^A0N,30,0^FDHi^FS^XZ', 8); - expect(importReport.unknown).toEqual([]); + expect(commandsOf({ findings }, 'unknown')).toEqual([]); expect(objects).toHaveLength(1); expect(props(objects[0]).content).toBe('Hi'); }); @@ -1877,9 +1878,9 @@ describe('parseZPL — prefix char followed by another prefix', () => { it('does not resync on an alphanumeric ^CC prefix that is a valid name char', () => { // With ^CCA, `AA0N` is prefix A + command A0; resyncing there would // shred every command whose name contains the remapped prefix. - const { objects, importReport } = parseZPL( + const { objects, findings } = parseSingle( '^XA^CCAAFO10,20AA0N,30,0AFDHiAFSAXZ', 8); - expect(importReport.unknown).toEqual([]); + expect(commandsOf({ findings }, 'unknown')).toEqual([]); expect(objects).toHaveLength(1); expect(props(objects[0]).content).toBe('Hi'); }); @@ -1889,37 +1890,37 @@ describe('parseZPL — prefix char followed by another prefix', () => { describe('parseZPL — ^FV field variable', () => { it('imports ^FV like ^FD for a plain text field', () => { - const { objects } = parseZPL('^XA^FO10,20^A0N,30,0^FVHello^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO10,20^A0N,30,0^FVHello^FS^XZ', 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('text'); expect(props(objects[0]).content).toBe('Hello'); }); it('binds ^FN + ^FV like ^FN + ^FD', () => { - const { objects, variables } = parseZPL('^XA^FO10,10^A0N,30,0^FN1^FVHELLO^FS^XZ', 8); + const { objects, variables } = parseSingle('^XA^FO10,10^A0N,30,0^FN1^FVHELLO^FS^XZ', 8); expect(objects).toHaveLength(1); expect(props(objects[0]).content).toBe('«field_1»'); expect(variables.find((v) => v.fnNumber === 1)?.defaultValue).toBe('HELLO'); }); it('ignores an empty ^FV per spec', () => { - const { objects } = parseZPL('^XA^FO10,20^A0N,30,0^FV^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO10,20^A0N,30,0^FV^FS^XZ', 8); expect(objects).toHaveLength(0); }); it('feeds a bare ^FN declaration through ^FV', () => { - const { variables } = parseZPL('^XA^FN1^FV001^FS^XZ', 8); + const { variables } = parseSingle('^XA^FN1^FV001^FS^XZ', 8); expect(variables.find((v) => v.fnNumber === 1)?.defaultValue).toBe('001'); }); it('a bare ^FN^FD^FS with empty data still declares the variable', () => { - const { variables } = parseZPL('^XA^FN1^FD^FS^XZ', 8); + const { variables } = parseSingle('^XA^FN1^FD^FS^XZ', 8); expect(variables).toHaveLength(1); expect(variables[0]?.defaultValue).toBe(''); }); it('a bare ^FN^FV^FS with empty data declares nothing', () => { - const { variables } = parseZPL('^XA^FN1^FV^FS^XZ', 8); + const { variables } = parseSingle('^XA^FN1^FV^FS^XZ', 8); expect(variables).toHaveLength(0); }); }); @@ -1930,7 +1931,7 @@ describe('parseZPL — pending field state cleared at ^FS', () => { it('does not leak pendingPrinterFontName from a bare ^A@^FS', () => { const zpl = '^XA^A@N,30,0,E:CUSTOM.FNT^FS^FO10,10^A0N,30,0^FDplain^FS^XZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('text'); expect(props(objects[0]).printerFontName).toBeUndefined(); @@ -1939,7 +1940,7 @@ describe('parseZPL — pending field state cleared at ^FS', () => { it('does not leak pendingFontId from a bare ^A0^FS into a later ^A@ field', () => { const zpl = '^XA^CF1,30,0^A0N,30,0^FS^FO10,10^A@N,30,0,E:CUSTOM.FNT^FDx^FS^XZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('text'); expect(props(objects[0]).fontId).toBeUndefined(); @@ -1948,7 +1949,7 @@ describe('parseZPL — pending field state cleared at ^FS', () => { it('does not leak ^FN slot from a bare ^FN^FS into the next field', () => { const zpl = '^XA^FN1^FS^FO10,10^A0N,30,0^FDhello^FS^XZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); // The bare ^FN1^FS slot must not bleed onto the next field's content. expect(props(objects[0]).content).toBe('hello'); @@ -1957,7 +1958,7 @@ describe('parseZPL — pending field state cleared at ^FS', () => { it('does not leak frActive from a bare ^FR^FS into a fieldless next text', () => { const zpl = '^XA^FO10,10^FR^FS^A0N,30,0^FDplain^FS^XZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('text'); expect(props(objects[0]).reverse).toBeFalsy(); @@ -1966,7 +1967,7 @@ describe('parseZPL — pending field state cleared at ^FS', () => { it('does not leak ^FB defaults from a bare ^FB^FS into the next text field', () => { const zpl = '^XA^FB200^FS^FO10,10^A0N,30,0^FDplain^FS^XZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('text'); expect(props(objects[0]).blockWidth).toBeUndefined(); @@ -1975,7 +1976,7 @@ describe('parseZPL — pending field state cleared at ^FS', () => { it('does not leak bcCheck from a checked barcode into the next non-checked barcode', () => { const zpl = '^XA^FO10,10^BCN,100,Y,N,Y^FD123^FS^FO50,50^BIN,100,Y,N^FD12345^FS^XZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects).toHaveLength(2); expect(objects[0]?.type).toBe('code128'); expect(props(objects[0]).checkDigit).toBe(true); @@ -1985,7 +1986,7 @@ describe('parseZPL — pending field state cleared at ^FS', () => { it('applies ^FR set before ^FO to the next formatted field (spec)', () => { const zpl = '^XA^FR^FO10,10^A0N,30,0^FDreversed^FS^XZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('text'); expect(props(objects[0]).reverse).toBe(true); @@ -1996,19 +1997,21 @@ describe('parseZPL — pending field state cleared at ^FS', () => { describe('parseZPL — ~ tilde commands', () => { it('tokenizes ~DG as a known command (skipped)', () => { - const { skipped } = parseZPL('^XA~DGR:LOGO.GRF,1024,10,FF^XZ', 8); + const parsed = parseSingle('^XA~DGR:LOGO.GRF,1024,10,FF^XZ', 8); + const skipped = [...commandsOf(parsed, 'browserLimit'), ...commandsOf(parsed, 'unknown')]; expect(skipped.some((s) => s.startsWith('~DG'))).toBe(true); }); it('does not create objects for ~DG', () => { - const { objects } = parseZPL('^XA~DGR:LOGO.GRF,1024,10,FF^XZ', 8); + const { objects } = parseSingle('^XA~DGR:LOGO.GRF,1024,10,FF^XZ', 8); expect(objects).toHaveLength(0); }); it('handles mixed ^ and ~ commands', () => { - const { objects, skipped } = parseZPL('^XA~DGR:TEST.GRF,10,1,FF^FO10,20^A0N,30,0^FDHello^FS^XZ', 8); - expect(objects).toHaveLength(1); - expect(objects[0]?.type).toBe('text'); + const parsed = parseSingle('^XA~DGR:TEST.GRF,10,1,FF^FO10,20^A0N,30,0^FDHello^FS^XZ', 8); + const skipped = [...commandsOf(parsed, 'browserLimit'), ...commandsOf(parsed, 'unknown')]; + expect(parsed.objects).toHaveLength(1); + expect(parsed.objects[0]?.type).toBe('text'); expect(skipped.some((s) => s.startsWith('~DG'))).toBe(true); }); }); @@ -2018,7 +2021,7 @@ describe('parseZPL — ~ tilde commands', () => { describe('parseZPL — change caret / tilde / delimiter (^CC ^CT ^CD)', () => { it('^CC switches the command prefix; following commands use the new char', () => { const zpl = '^XA^CCYYFO10,10YA0N,30,0YFDhelloYFSYXZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('text'); expect(props(objects[0]).content).toBe('hello'); @@ -2026,21 +2029,21 @@ describe('parseZPL — change caret / tilde / delimiter (^CC ^CT ^CD)', () => { it('~CC is a synonym of ^CC', () => { const zpl = '^XA~CCYYFO10,10YA0N,30,0YFDhelloYFSYXZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('text'); }); it('^CT switches the tilde prefix without affecting ^ commands', () => { const zpl = '^XA^CT!^FO10,10^A0N,30,0^FDhello^FS!DGR:LOGO.GRF,10,1,FF^XZ'; - const { objects, importReport } = parseZPL(zpl, 8); + const { objects, findings } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); - expect(importReport.browserLimit.some((s) => s.startsWith('~DG'))).toBe(true); + expect(commandsOf({ findings }, 'browserLimit').some((s) => s.startsWith('~DG'))).toBe(true); }); it('^CD switches the parameter delimiter', () => { const zpl = '^XA^CD;^FO10;10^A0N;30;0^FDhello^FS^XZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); expect(objects[0]?.x).toBe(10); expect(props(objects[0]).fontHeight).toBe(30); @@ -2049,14 +2052,14 @@ describe('parseZPL — change caret / tilde / delimiter (^CC ^CT ^CD)', () => { it('~CD is a synonym of ^CD', () => { const zpl = '^XA~CD;^FO10;10^A0N;30;0^FDhello^FS^XZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects[0]?.x).toBe(10); expect(props(objects[0]).fontHeight).toBe(30); }); it('combined ^CC + ^CD: both prefix and delimiter swap', () => { const zpl = '^XA^CD;^CCYYFO10;10YA0N;30;0YFDhelloYFSYXZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); expect(objects[0]?.x).toBe(10); expect(props(objects[0]).fontHeight).toBe(30); @@ -2065,14 +2068,14 @@ describe('parseZPL — change caret / tilde / delimiter (^CC ^CT ^CD)', () => { it('^CC persists across an ^XA boundary within the same parse', () => { const zpl = '^XA^CCYYXZYXAYFO10,10YA0N,30,0YFDhelloYFSYXZ'; - const { objects } = parseZPL(zpl, 8); + const objects = parseZPL(zpl, 8).pages.flatMap((pg) => pg.objects); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('text'); }); it('^CC^ resets the caret prefix back to ^', () => { const zpl = '^XA^CCYYFO10,10YA0N,30,0YFDfirstYFSYCC^^FO50,50^A0N,30,0^FDsecond^FS^XZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects).toHaveLength(2); expect(props(objects[0]).content).toBe('first'); expect(props(objects[1]).content).toBe('second'); @@ -2083,7 +2086,7 @@ describe('parseZPL — change caret / tilde / delimiter (^CC ^CT ^CD)', () => { // ^FO is consumed as the (no-op) argument; the FO that follows is // no longer a command, so its position is lost. const zpl = '^XA^CC^FO10,10^A0N,30,0^FDhello^FS^XZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); expect(props(objects[0]).content).toBe('hello'); expect(objects[0]?.x).toBe(0); @@ -2093,10 +2096,10 @@ describe('parseZPL — change caret / tilde / delimiter (^CC ^CT ^CD)', () => { // ^GFA;total;total;bpr;data uses the mutated delimiter for the four // params; a wrong split would push the command into browserLimit. const zpl = '^XA^CD;^FO10;10^GFA;6;6;3;111111111111^FS^XZ'; - const { objects, importReport } = parseZPL(zpl, 8); + const { objects, findings } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('image'); - expect(importReport.browserLimit.some((s) => s.startsWith('^GF'))).toBe(false); + expect(commandsOf({ findings }, 'browserLimit').some((s) => s.startsWith('^GF'))).toBe(false); }); it('rejects ^CC / ^CT / ^CD args that would collapse role boundaries', () => { @@ -2104,22 +2107,22 @@ describe('parseZPL — change caret / tilde / delimiter (^CC ^CT ^CD)', () => { // ^CD^ would make delimiter == caret (param-split eats command chars). // Invalid args land in partialCmds and the prefix stays unchanged. const zpl = '^XA^CC~^CD^^FO10,10^A0N,30,0^FDhello^FS^XZ'; - const { objects, importReport } = parseZPL(zpl, 8); + const { objects, findings } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); expect(props(objects[0]).content).toBe('hello'); - expect(importReport.partial).toContain('^CC'); - expect(importReport.partial).toContain('^CD'); + expect(commandsOf({ findings }, 'partial')).toContain('^CC'); + expect(commandsOf({ findings }, 'partial')).toContain('^CD'); }); it('rejects ^CC / ^CT / ^CD args that are space or non-printable', () => { // Space/control chars cannot serve as prefixes and would silently // break the subsequent stream; reject them up front. const zpl = '^XA^CC ^CT\t^FO10,10^A0N,30,0^FDhello^FS^XZ'; - const { objects, importReport } = parseZPL(zpl, 8); + const { objects, findings } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); expect(props(objects[0]).content).toBe('hello'); - expect(importReport.partial).toContain('^CC'); - expect(importReport.partial).toContain('^CT'); + expect(commandsOf({ findings }, 'partial')).toContain('^CC'); + expect(commandsOf({ findings }, 'partial')).toContain('^CT'); }); }); @@ -2128,7 +2131,7 @@ describe('parseZPL — change caret / tilde / delimiter (^CC ^CT ^CD)', () => { describe('parseZPL — ^BT TLC39', () => { it('parses ^BT with full param set into a tlc39 object', () => { const zpl = '^XA^FO50,50^BTN,3,3,60,5,3^FD123456,ABC^FS^XZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('tlc39'); const p = props(objects[0]); @@ -2142,34 +2145,34 @@ describe('parseZPL — ^BT TLC39', () => { it('falls back to ^BY moduleWidth when ^BT w1 is empty', () => { const zpl = '^XA^FO0,0^BY5^BTN,,3,40,4,4^FD123456,X^FS^XZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(props(objects[0]).moduleWidth).toBe(5); }); it('preserves microPdfRows mid-range', () => { const zpl = '^XA^FO0,0^BTN,2,3,40,4,6^FD123456,X^FS^XZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(props(objects[0]).microPdfRows).toBe(6); }); it('accepts microPdfRows=1 and =10 (^BT spec bounds)', () => { - const z1 = parseZPL('^XA^FO0,0^BTN,2,3,40,4,1^FD123456,X^FS^XZ', 8).objects; + const z1 = parseSingle('^XA^FO0,0^BTN,2,3,40,4,1^FD123456,X^FS^XZ', 8).objects; expect(props(z1[0]).microPdfRows).toBe(1); - const z10 = parseZPL('^XA^FO0,0^BTN,2,3,40,4,10^FD123456,X^FS^XZ', 8).objects; + const z10 = parseSingle('^XA^FO0,0^BTN,2,3,40,4,10^FD123456,X^FS^XZ', 8).objects; expect(props(z10[0]).microPdfRows).toBe(10); }); it('rejects microPdfRows outside ^BT spec range (-1, 0, 11, 20) and falls back to default 4', () => { for (const v of [-1, 0, 11, 20]) { const zpl = `^XA^FO0,0^BTN,2,3,40,4,${v}^FD123456,X^FS^XZ`; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); expect(props(objects[0]).microPdfRows, `rows=${v}`).toBe(4); } }); it('drops non-canonical r1 on round-trip (re-emits as 2)', async () => { const { ObjectRegistry } = await import('@zplab/core/registry'); - const { objects } = parseZPL('^XA^FO0,0^BTN,2,3,40,4,4^FD123,X^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^BTN,2,3,40,4,4^FD123,X^FS^XZ', 8); const emitted = ObjectRegistry.tlc39.toZPL(objects[0] as never); expect(emitted).toContain('^BTN,2,2,40,4,4'); }); @@ -2177,24 +2180,24 @@ describe('parseZPL — ^BT TLC39', () => { it('round-trips ^BT without serial (no MicroPDF block, no trailing comma)', async () => { const { ObjectRegistry } = await import('@zplab/core/registry'); const original = '^XA^FO10,20^BY2^BTN,2,2,40,4,4^FD123456^FS^XZ'; - const { objects } = parseZPL(original, 8); + const { objects } = parseSingle(original, 8); expect(objects[0]?.type).toBe('tlc39'); expect(props(objects[0]).content).toBe('123456'); const emitted = ObjectRegistry.tlc39.toZPL(objects[0] as never); expect(emitted).toMatch(/\^FD123456\^FS/); - const { objects: round2 } = parseZPL(`^XA${emitted}^XZ`, 8); + const { objects: round2 } = parseSingle(`^XA${emitted}^XZ`, 8); expect(props(round2[0]).content).toBe('123456'); }); it('round-trips ^BT via parse → toZPL → parse', async () => { const { ObjectRegistry } = await import('@zplab/core/registry'); const original = '^XA^FO50,60^BY3^BTN,3,2,80,5,8^FD654321,ABCDEF^FS^XZ'; - const { objects } = parseZPL(original, 8); + const { objects } = parseSingle(original, 8); expect(objects).toHaveLength(1); const emitted = ObjectRegistry.tlc39.toZPL(objects[0] as never); expect(emitted).toContain('^BTN,3,2,80,5,8'); expect(emitted).toContain('^FD654321,ABCDEF'); - const { objects: round2 } = parseZPL(`^XA${emitted}^XZ`, 8); + const { objects: round2 } = parseSingle(`^XA${emitted}^XZ`, 8); expect(round2[0]?.type).toBe('tlc39'); expect(props(round2[0])).toMatchObject(props(objects[0])); }); @@ -2238,8 +2241,9 @@ describe('splitTlc39Content', () => { describe('parseZPL — ^IM image reference', () => { it('adds ^IM to skipped (cannot load printer images)', () => { - const { objects, skipped } = parseZPL('^XA^FO0,0^IMR:LOGO.GRF^FS^XZ', 8); - expect(objects).toHaveLength(0); + const parsed = parseSingle('^XA^FO0,0^IMR:LOGO.GRF^FS^XZ', 8); + const skipped = [...commandsOf(parsed, 'browserLimit'), ...commandsOf(parsed, 'unknown')]; + expect(parsed.objects).toHaveLength(0); expect(skipped.some((s) => s.startsWith('^IM'))).toBe(true); }); }); @@ -2248,13 +2252,13 @@ describe('parseZPL — ^IM image reference', () => { describe('parseZPL — \\& line break in ^FB', () => { it('decodes \\& as newline in field block text', () => { - const { objects } = parseZPL('^XA^FO0,0^A0N,30,0^FB400,3,0,L,0^FDLine 1\\&Line 2\\&Line 3^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^A0N,30,0^FB400,3,0,L,0^FDLine 1\\&Line 2\\&Line 3^FS^XZ', 8); expect(objects).toHaveLength(1); expect(props(objects[0]).content).toBe('Line 1\nLine 2\nLine 3'); }); it('does not decode \\& outside of ^FB blocks', () => { - const { objects } = parseZPL('^XA^FO0,0^A0N,30,0^FDNo\\&Break^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO0,0^A0N,30,0^FDNo\\&Break^FS^XZ', 8); expect(props(objects[0]).content).toBe('No\\&Break'); }); }); @@ -2263,41 +2267,42 @@ describe('parseZPL — \\& line break in ^FB', () => { describe('parseZPL — ^A@ TrueType font fallback', () => { it('imports ^A@ as text with specified height instead of skipping', () => { - const { objects, skipped } = parseZPL('^XA^FO10,20^A@N,40,30,E:ARIAL.TTF^FDTrueType^FS^XZ', 8); - expect(objects).toHaveLength(1); - expect(objects[0]?.type).toBe('text'); - expect(props(objects[0]).content).toBe('TrueType'); - expect(props(objects[0]).fontHeight).toBe(40); + const parsed = parseSingle('^XA^FO10,20^A@N,40,30,E:ARIAL.TTF^FDTrueType^FS^XZ', 8); + const skipped = [...commandsOf(parsed, 'browserLimit'), ...commandsOf(parsed, 'unknown')]; + expect(parsed.objects).toHaveLength(1); + expect(parsed.objects[0]?.type).toBe('text'); + expect(props(parsed.objects[0]).content).toBe('TrueType'); + expect(props(parsed.objects[0]).fontHeight).toBe(40); // Should NOT be in skipped list expect(skipped.some((s) => s.startsWith('^A@'))).toBe(false); }); it('falls back to ^CF defaults when ^A@ has no height', () => { - const { objects } = parseZPL('^XA^CF0,50^FO0,0^A@N,0,0,E:FONT.TTF^FDFallback^FS^XZ', 8); + const { objects } = parseSingle('^XA^CF0,50^FO0,0^A@N,0,0,E:FONT.TTF^FDFallback^FS^XZ', 8); expect(props(objects[0]).fontHeight).toBe(50); }); }); -// ── importReport ───────────────────────────────────────────────────────────── +// ── import findings ────────────────────────────────────────────────────────── -describe('parseZPL — importReport.partial', () => { - it('records ^A@ in importReport.partial (font face not imported)', () => { - const { importReport } = parseZPL('^XA^FO0,0^A@N,30,0,E:ARIAL.TTF^FDText^FS^XZ', 8); - expect(importReport.partial).toContain('^A@'); +describe('parseZPL — partial findings', () => { + it('records ^A@ as partial (font face not imported)', () => { + const { findings } = parseSingle('^XA^FO0,0^A@N,30,0,E:ARIAL.TTF^FDText^FS^XZ', 8); + expect(commandsOf({ findings }, 'partial')).toContain('^A@'); }); it('deduplicates ^A@ entries when used multiple times', () => { const zpl = '^XA^FO0,0^A@N,30,0,E:A.TTF^FDFirst^FS^FO0,50^A@N,30,0,E:B.TTF^FDSecond^FS^XZ'; - const { importReport } = parseZPL(zpl, 8); - expect(importReport.partial.filter((e) => e === '^A@')).toHaveLength(1); + const { findings } = parseSingle(zpl, 8); + expect(commandsOf({ findings }, 'partial').filter((e) => e === '^A@')).toHaveLength(1); }); it('does not flag built-in ^A{letter} fonts (A-H) as partial', () => { // ^AB references the built-in Zebra font B; the parser pins it on // the field as fontId="B" and the generator re-emits the short // form, so the import is lossless and stays out of partial. - const { importReport } = parseZPL('^XA^FO0,0^ABN,30,0^FDText^FS^XZ', 8); - expect(importReport.partial.some((e) => e.startsWith('^A'))).toBe(false); + const { findings } = parseSingle('^XA^FO0,0^ABN,30,0^FDText^FS^XZ', 8); + expect(commandsOf({ findings }, 'partial').some((e) => e.startsWith('^A'))).toBe(false); }); it('flags ^A{alias} as partial when the alias has no ^CW mapping', () => { @@ -2305,100 +2310,96 @@ describe('parseZPL — importReport.partial', () => { // captures fontId="M" so editing stays lossless, but we surface a // partial-import warning because the rendered output will fall // back to font 0 on the printer. - const { importReport } = parseZPL('^XA^FO0,0^AMN,30,0^FDText^FS^XZ', 8); - expect(importReport.partial.some((e) => e.startsWith('^A'))).toBe(true); + const { findings } = parseSingle('^XA^FO0,0^AMN,30,0^FDText^FS^XZ', 8); + expect(commandsOf({ findings }, 'partial').some((e) => e.startsWith('^A'))).toBe(true); }); - it('does not put fully-supported ^A0 in importReport.partial', () => { - const { importReport } = parseZPL('^XA^FO0,0^A0N,30,0^FDText^FS^XZ', 8); - expect(importReport.partial).toHaveLength(0); + it('does not flag fully-supported ^A0 as partial', () => { + const { findings } = parseSingle('^XA^FO0,0^A0N,30,0^FDText^FS^XZ', 8); + expect(commandsOf({ findings }, 'partial')).toHaveLength(0); }); }); -describe('parseZPL — importReport.browserLimit', () => { - it('records ^IM in importReport.browserLimit', () => { - const { importReport } = parseZPL('^XA^FO0,0^IMR:LOGO.GRF^FS^XZ', 8); - expect(importReport.browserLimit.some((s) => s.startsWith('^IM'))).toBe(true); +describe('parseZPL — browserLimit findings', () => { + it('records ^IM as browserLimit', () => { + const { findings } = parseSingle('^XA^FO0,0^IMR:LOGO.GRF^FS^XZ', 8); + expect(commandsOf({ findings }, 'browserLimit').some((s) => s.startsWith('^IM'))).toBe(true); }); - it('records ~DG in importReport.browserLimit', () => { - const { importReport } = parseZPL('^XA~DGR:LOGO.GRF,1024,10,FF^XZ', 8); - expect(importReport.browserLimit.some((s) => s.startsWith('~DG'))).toBe(true); - }); - - it('also keeps ^IM in skipped for backward compatibility', () => { - const { skipped, importReport } = parseZPL('^XA^FO0,0^IMR:LOGO.GRF^FS^XZ', 8); - expect(skipped.some((s) => s.startsWith('^IM'))).toBe(true); - expect(importReport.browserLimit.some((s) => s.startsWith('^IM'))).toBe(true); + it('records ~DG as browserLimit', () => { + const { findings } = parseSingle('^XA~DGR:LOGO.GRF,1024,10,FF^XZ', 8); + expect(commandsOf({ findings }, 'browserLimit').some((s) => s.startsWith('~DG'))).toBe(true); }); }); -describe('parseZPL — importReport.unknown', () => { - it('records unrecognised commands in importReport.unknown', () => { - const { importReport } = parseZPL('^XA^XX99^FO0,0^A0N,30,0^FDText^FS^XZ', 8); - expect(importReport.unknown.some((s) => s.startsWith('^XX'))).toBe(true); +describe('parseZPL — unknown findings', () => { + it('records unrecognised commands as unknown', () => { + const { findings } = parseSingle('^XA^XX99^FO0,0^A0N,30,0^FDText^FS^XZ', 8); + expect(commandsOf({ findings }, 'unknown').some((s) => s.startsWith('^XX'))).toBe(true); }); it('also keeps unknown commands in skipped for backward compatibility', () => { - const { skipped, importReport } = parseZPL('^XA^XX99^FO0,0^A0N,30,0^FDText^FS^XZ', 8); + const parsed = parseSingle('^XA^XX99^FO0,0^A0N,30,0^FDText^FS^XZ', 8); + const skipped = [...commandsOf(parsed, 'browserLimit'), ...commandsOf(parsed, 'unknown')]; expect(skipped.some((s) => s.startsWith('^XX'))).toBe(true); - expect(importReport.unknown.some((s) => s.startsWith('^XX'))).toBe(true); + expect(commandsOf(parsed, 'unknown').some((s) => s.startsWith('^XX'))).toBe(true); }); it('surfaces unknown/browserLimit tokens without a trailing newline', () => { - const { skipped, importReport } = parseZPL('^XA\n^XX99\n^IMR:LOGO.GRF\n^XZ', 8); - expect(importReport.unknown).toContain('^XX99'); - expect(importReport.browserLimit).toContain('^IMR:LOGO.GRF'); - for (const s of [...importReport.unknown, ...importReport.browserLimit, ...skipped]) { + const parsed = parseSingle('^XA\n^XX99\n^IMR:LOGO.GRF\n^XZ', 8); + const skipped = [...commandsOf(parsed, 'browserLimit'), ...commandsOf(parsed, 'unknown')]; + expect(commandsOf(parsed, 'unknown')).toContain('^XX99'); + expect(commandsOf(parsed, 'browserLimit')).toContain('^IMR:LOGO.GRF'); + for (const s of [...commandsOf(parsed, 'unknown'), ...commandsOf(parsed, 'browserLimit'), ...skipped]) { expect(s).toBe(s.trimEnd()); } }); it('keeps the source prefix on unknown tilde commands', () => { - const { importReport } = parseZPL('^XA\n~QQ1,2\n^XZ', 8); - expect(importReport.unknown).toContain('~QQ1,2'); + const { findings } = parseSingle('^XA\n~QQ1,2\n^XZ', 8); + expect(commandsOf({ findings }, 'unknown')).toContain('~QQ1,2'); }); it('does not bake a line break into a truncated ~DY summary token', () => { // Short malformed ~DY: rest ended with \n before the appended ellipsis, // where the push-site trim cannot reach. - const { importReport } = parseZPL('^XA\n~DYR:X,Q,G,10,2,ZZ\n^XZ', 8); - expect(importReport.browserLimit).toContain('~DYR:X,Q,G,10,2,ZZ…'); + const { findings } = parseSingle('^XA\n~DYR:X,Q,G,10,2,ZZ\n^XZ', 8); + expect(commandsOf({ findings }, 'browserLimit')).toContain('~DYR:X,Q,G,10,2,ZZ…'); }); it('preserves an undecodable ^GFB format as an opaque verbatim image', () => { - const { objects, importReport } = parseZPL('^XA^FO0,0^GFB,32,32,4,AABBCCDD^FS^XZ', 8); + const { objects, findings } = parseSingle('^XA^FO0,0^GFB,32,32,4,AABBCCDD^FS^XZ', 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('image'); expect(props(objects[0]).rawGf).toBe('^GFB,32,32,4,AABBCCDD'); - expect(importReport.partial).toContain('^GF'); - expect(importReport.browserLimit.some((s) => s.startsWith('^GF'))).toBe(false); - expect(importReport.unknown.some((s) => s.startsWith('^GF'))).toBe(false); + expect(commandsOf({ findings }, 'partial')).toContain('^GF'); + expect(commandsOf({ findings }, 'browserLimit').some((s) => s.startsWith('^GF'))).toBe(false); + expect(commandsOf({ findings }, 'unknown').some((s) => s.startsWith('^GF'))).toBe(false); }); it('returns empty importReport for a fully supported label', () => { - const { importReport } = parseZPL('^XA^PW800^LL600^FO50,50^A0N,30,0^FDHello^FS^XZ', 8); - expect(importReport.partial).toHaveLength(0); - expect(importReport.browserLimit).toHaveLength(0); - expect(importReport.unknown).toHaveLength(0); - expect(importReport.findings).toHaveLength(0); + const { findings } = parseSingle('^XA^PW800^LL600^FO50,50^A0N,30,0^FDHello^FS^XZ', 8); + expect(commandsOf({ findings }, 'partial')).toHaveLength(0); + expect(commandsOf({ findings }, 'browserLimit')).toHaveLength(0); + expect(commandsOf({ findings }, 'unknown')).toHaveLength(0); + expect(findings).toHaveLength(0); }); }); -describe('parseZPL: importReport.findings', () => { - it('emits one finding per kind with pageIndex=0 (set by service layer later)', () => { +describe('parseZPL: page findings', () => { + it('emits one finding per kind with the page index stamped', () => { const zpl = '^XA^FO0,0^A@N,30,0,E:ARIAL.TTF^FDText^FS^IMR:LOGO.GRF^XX99^XZ'; - const { importReport } = parseZPL(zpl, 8); - const kinds = importReport.findings.map((f) => f.kind); + const { findings } = parseSingle(zpl, 8); + const kinds = findings.map((f) => f.kind); expect(kinds).toContain('partial'); expect(kinds).toContain('browserLimit'); expect(kinds).toContain('unknown'); - expect(importReport.findings.every((f) => f.pageIndex === 0)).toBe(true); + expect(findings.every((f) => f.pageIndex === 0)).toBe(true); // Tripwire: findings emit in source order. A pipeline split that // collects unknowns / browser-limits in a separate pass would // reorder these. Order: ^A@ (L1 partial) → ^IM (L1 browserLimit) → // ^XX (L1 unknown). - expect(importReport.findings.map((f) => f.command)).toEqual([ + expect(findings.map((f) => f.command)).toEqual([ '^A@', '^IMR:LOGO.GRF', '^XX99', ]); }); @@ -2406,8 +2407,8 @@ describe('parseZPL: importReport.findings', () => { it('partial findings are deduplicated by command code', () => { // Two ^A@ uses → one partial finding for "^A@". const zpl = '^XA^FO0,0^A@N,30,0,E:A.TTF^FDFirst^FS^FO0,50^A@N,30,0,E:B.TTF^FDSecond^FS^XZ'; - const { importReport } = parseZPL(zpl, 8); - const partialFindings = importReport.findings.filter((f) => f.kind === 'partial'); + const { findings } = parseSingle(zpl, 8); + const partialFindings = findings.filter((f) => f.kind === 'partial'); expect(partialFindings).toHaveLength(1); expect(partialFindings[0]?.command).toBe('^A@'); }); @@ -2417,7 +2418,7 @@ describe('parseZPL: importReport.findings', () => { describe('parseZPL — ^FO with justification parameter', () => { it('parses ^FO with a 3rd parameter without errors', () => { - const { objects } = parseZPL('^XA^FO100,200,1^A0N,30,0^FDJustified^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO100,200,1^A0N,30,0^FDJustified^FS^XZ', 8); expect(objects).toHaveLength(1); expect(objects[0]?.x).toBeCloseTo(100); expect(objects[0]?.y).toBeCloseTo(200 - 4.62); @@ -2428,7 +2429,7 @@ describe('parseZPL — ^FO with justification parameter', () => { describe('^FN / template variables', () => { it('creates a Variable for each distinct ^FN and binds the field', () => { const zpl = '^XA^FO10,20^A0N,30,30^FN1^FDDefault^FS^XZ'; - const { objects, variables } = parseZPL(zpl); + const { objects, variables } = parseSingle(zpl); expect(variables).toHaveLength(1); expect(variables[0]?.fnNumber).toBe(1); expect(variables[0]?.defaultValue).toBe('Default'); @@ -2439,14 +2440,14 @@ describe('^FN / template variables', () => { it('derives the Variable name from a preceding ^FX comment', () => { const zpl = '^XA^FXField: Customer Name^FO10,20^A0N,30,30^FN1^FDJohn^FS^XZ'; - const { variables } = parseZPL(zpl); + const { variables } = parseSingle(zpl); expect(variables[0]?.name).toBe('Customer_Name'); }); it('falls back to field_ when the ^FX comment is a marker-unsafe name', () => { // `clock:Y` would render as a clock chip; `»` breaks the «name» marker. - expect(parseZPL('^XA^FXField: clock:Y^FO10,20^A0N,30,30^FN1^FDx^FS^XZ').variables[0]?.name).toBe('field_1'); - expect(parseZPL('^XA^FXField: sku»oops^FO10,20^A0N,30,30^FN2^FDx^FS^XZ').variables[0]?.name).toBe('field_2'); + expect(parseSingle('^XA^FXField: clock:Y^FO10,20^A0N,30,30^FN1^FDx^FS^XZ').variables[0]?.name).toBe('field_1'); + expect(parseSingle('^XA^FXField: sku»oops^FO10,20^A0N,30,30^FN2^FDx^FS^XZ').variables[0]?.name).toBe('field_2'); }); it('reuses the same Variable when multiple fields share an fnNumber', () => { @@ -2455,7 +2456,7 @@ describe('^FN / template variables', () => { '^FO10,20^A0N,30,30^FN1^FDA^FS' + '^FO10,60^A0N,30,30^FN1^FDA^FS' + '^XZ'; - const { objects, variables } = parseZPL(zpl); + const { objects, variables } = parseSingle(zpl); expect(variables).toHaveLength(1); const [a, b] = objects; // Both fields reference the shared variable by the same content marker. @@ -2465,11 +2466,11 @@ describe('^FN / template variables', () => { it('ignores out-of-range ^FN numbers and records a partial finding', () => { const zpl = '^XA^FO10,20^A0N,30,30^FN0^FDIgnored^FS^XZ'; - const { variables, objects, importReport } = parseZPL(zpl); + const { variables, objects, findings } = parseSingle(zpl); expect(variables).toHaveLength(0); // Out-of-range ^FN is ignored: no marker, content stays the literal ^FD. expect(props(objects[0]).content).toBe('Ignored'); - expect(importReport.partial).toContain('^FN'); + expect(commandsOf({ findings }, 'partial')).toContain('^FN'); }); }); @@ -2478,16 +2479,16 @@ describe('^FN / template variables', () => { describe('parseZPL — lossyEdit finding for regen-unsafe blocks', () => { it('flags a block with a standalone ^FN declaration', () => { const zpl = '^XA^FN1^FDdefault^FS^FO10,10^A0N,30,0^FDx^FS^XZ'; - const { importReport } = parseZPL(zpl, 8, { captureOverlay: true }); - const f = importReport.findings.find((x) => x.kind === 'lossyEdit'); + const { findings } = parseSingle(zpl, 8, { captureOverlay: true }); + const f = findings.find((x) => x.kind === 'lossyEdit'); expect(f).toBeDefined(); expect(f?.command).toContain('^FN'); }); it('does not flag a clean, regen-safe block', () => { const zpl = '^XA^FO10,10^A0N,30,0^FDx^FS^XZ'; - const { importReport } = parseZPL(zpl, 8, { captureOverlay: true }); - expect(importReport.findings.some((x) => x.kind === 'lossyEdit')).toBe(false); + const { findings } = parseSingle(zpl, 8, { captureOverlay: true }); + expect(findings.some((x) => x.kind === 'lossyEdit')).toBe(false); }); }); @@ -2495,7 +2496,7 @@ describe('parseZPL — lossyEdit finding for regen-unsafe blocks', () => { describe('parseZPL — ^FT graphic anchors lift to model top-left', () => { it('^FT ^GE ellipse: bottom-left lifts by height', () => { - const { objects } = parseZPL('^XA^FT50,150^GE100,80,3,B^FS^XZ', 8); + const { objects } = parseSingle('^XA^FT50,150^GE100,80,3,B^FS^XZ', 8); expect(objects[0]?.type).toBe('ellipse'); expect(objects[0]?.positionType).toBe('FT'); expect(objects[0]?.x).toBe(50); @@ -2504,7 +2505,7 @@ describe('parseZPL — ^FT graphic anchors lift to model top-left', () => { }); it('^FT,1 ^GE ellipse: bottom-right lifts by height and shifts left by width', () => { - const { objects } = parseZPL('^XA^FT150,150,1^GE100,80,3,B^FS^XZ', 8); + const { objects } = parseSingle('^XA^FT150,150,1^GE100,80,3,B^FS^XZ', 8); expect(objects[0]?.positionType).toBe('FT'); expect(objects[0]?.fieldJustify).toBe('R'); expect(objects[0]?.x).toBe(50); // 150 - width 100 @@ -2512,7 +2513,7 @@ describe('parseZPL — ^FT graphic anchors lift to model top-left', () => { }); it('^FT ^GC circle: lifts by the diameter', () => { - const { objects } = parseZPL('^XA^FT60,160^GC100,3,B^FS^XZ', 8); + const { objects } = parseSingle('^XA^FT60,160^GC100,3,B^FS^XZ', 8); expect(objects[0]?.type).toBe('ellipse'); expect(objects[0]?.positionType).toBe('FT'); expect(objects[0]?.x).toBe(60); @@ -2520,7 +2521,7 @@ describe('parseZPL — ^FT graphic anchors lift to model top-left', () => { }); it('^FT ^GD diagonal line: lifts the bounding box, recovers the start point', () => { - const { objects } = parseZPL('^XA^FT10,110^GD80,60,3,B,L^FS^XZ', 8); + const { objects } = parseSingle('^XA^FT10,110^GD80,60,3,B,L^FS^XZ', 8); expect(objects[0]?.type).toBe('line'); expect(objects[0]?.positionType).toBe('FT'); expect(objects[0]?.x).toBe(10); diff --git a/src/lib/zplParser.zebradesigner.test.ts b/src/lib/zplParser.zebradesigner.test.ts index e1b0c950..a1874123 100644 --- a/src/lib/zplParser.zebradesigner.test.ts +++ b/src/lib/zplParser.zebradesigner.test.ts @@ -2,12 +2,14 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { parseZPL } from "@zplab/core/lib/zplParser"; +import { commandsOf } from "../test/helpers"; // Authentic ZDesigner ZD230-203dpi driver output (GDI job captured via // PrintToFile): control-char remap preamble (`CT~~CD,~CC^~CT~`), `##` framing // junk with a raw control byte, a settings-only ^XA block, and a job block // whose page is rasterized into a single Z64 ^GFA. Real-world import shape -// for anything printed through the Windows driver. +// for anything printed through the Windows driver. Two ^XA/^XZ blocks (page 0 +// settings-only, page 1 the rasterized job) so assertions read across pages. const fixture = readFileSync( path.resolve(process.cwd(), "tests/fixtures/zebradesigner_driver_zd230.prn"), "latin1", @@ -17,11 +19,15 @@ const parse = () => parseZPL(fixture, 8); describe("parseZPL — ZebraDesigner driver output", () => { it("recognizes every driver command (no unknowns)", () => { - expect(parse().importReport.unknown).toEqual([]); + const findings = parse().pages.flatMap((p) => p.findings); + expect(commandsOf({ findings }, "unknown")).toEqual([]); }); - it("imports the rasterized page as a single image object", () => { - const { objects } = parse(); + it("imports the rasterized job as a single image on page 1 (page 0 settings-only)", () => { + const { pages } = parse(); + expect(pages).toHaveLength(2); + expect(pages[0]?.objects).toEqual([]); + const objects = pages[1]?.objects ?? []; expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe("image"); expect(objects[0]?.x).toBe(56); @@ -47,6 +53,6 @@ describe("parseZPL — ZebraDesigner driver output", () => { }); it("creates no variables from a fully literal job", () => { - expect(parse().variables).toEqual([]); + expect(parse().pages.flatMap((p) => p.variables)).toEqual([]); }); }); diff --git a/src/registry/codablock.test.ts b/src/registry/codablock.test.ts index fa957b2e..f9d5951f 100644 --- a/src/registry/codablock.test.ts +++ b/src/registry/codablock.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect } from 'vitest'; import { generateZPL } from '@zplab/core/lib/zplGenerator'; -import { parseZPL } from '@zplab/core/lib/zplParser'; import { clampCodablockColumns, type CodablockProps } from '@zplab/core/registry/codablock'; import type { LabelConfig } from '@zplab/core/types/LabelConfig'; import type { LabelObject } from '@zplab/core/types/Group'; +import { parseSingle } from '../test/helpers'; const BASE_LABEL: LabelConfig = { widthMm: 100, heightMm: 50, dpmm: 8 }; @@ -28,7 +28,7 @@ const cbObjects = (overrides: Partial = {}): LabelObject[] => ] as unknown as LabelObject[]; const codablockOf = (zpl: string) => - parseZPL(zpl).objects.find((o) => o.type === 'codablock') as + parseSingle(zpl).objects.find((o) => o.type === 'codablock') as | (LabelObject & { props: CodablockProps }) | undefined; @@ -91,7 +91,7 @@ describe('codablock ^BB parse / round-trip', () => { it('survives a generate → parse → generate round-trip', () => { const first = generateZPL(BASE_LABEL, cbObjects({ columns: 10 })); - const reparsed = parseZPL(first).objects.filter((o) => o.type === 'codablock'); + const reparsed = parseSingle(first).objects.filter((o) => o.type === 'codablock'); const second = generateZPL(BASE_LABEL, reparsed as LabelObject[]); expect(second).toContain(',10,,F'); }); diff --git a/src/registry/code128.gs1.test.ts b/src/registry/code128.gs1.test.ts index acb45017..1af92822 100644 --- a/src/registry/code128.gs1.test.ts +++ b/src/registry/code128.gs1.test.ts @@ -8,6 +8,7 @@ import { PALETTE_PRESET_IDS } from './palettePresets'; import { gs1EnablePatch } from '@zplab/core/registry/gs1FieldSpec'; import type { LabelConfig } from '@zplab/core/types/LabelConfig'; import type { LabelObject } from '@zplab/core/types/Group'; +import { parseSingle } from '../test/helpers'; const LABEL: LabelConfig = { widthMm: 100, heightMm: 50, dpmm: 8 }; @@ -54,21 +55,21 @@ describe('GS1-128 (code128 gs1 mode)', () => { }); it('parses ^BC..,D as gs1 with canonical (unparenthesized) content', () => { - const { objects } = parseZPL('^XA^FO10,10^BCN,100,Y,N,N,D^FD(01)09501101530003^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO10,10^BCN,100,Y,N,N,D^FD(01)09501101530003^FS^XZ', 8); expect((objects[0] as { type: string }).type).toBe('code128'); expect(propsOf(objects[0]).gs1).toBe(true); expect(propsOf(objects[0]).content).toBe(GS1_SAMPLE_CONTENT); }); it('parses a plain ^BC as non-gs1', () => { - const { objects } = parseZPL('^XA^FO10,10^BCN,100,Y,N,N^FD12345678^FS^XZ', 8); + const { objects } = parseSingle('^XA^FO10,10^BCN,100,Y,N,N^FD12345678^FS^XZ', 8); expect(propsOf(objects[0]).gs1).toBeFalsy(); expect(propsOf(objects[0]).content).toBe('12345678'); }); it('round-trips gs1 mode + content through generate->parse', () => { const zpl = generateZPL(LABEL, [mk({ ...baseProps, gs1: true })]); - const { objects } = parseZPL(zpl, LABEL.dpmm); + const { objects } = parseSingle(zpl, LABEL.dpmm); expect(propsOf(objects[0]).gs1).toBe(true); expect(propsOf(objects[0]).content).toBe(GS1_SAMPLE_CONTENT); }); @@ -78,7 +79,7 @@ describe('GS1-128 (code128 gs1 mode)', () => { expect(canonical).not.toBeNull(); const zpl = generateZPL(LABEL, [mk({ ...baseProps, gs1: true, content: canonical! })]); expect(zpl).toContain('^FD(01)09501101530003(10)LOT123>8(21)SER456^FS'); - const { objects } = parseZPL(zpl, LABEL.dpmm); + const { objects } = parseSingle(zpl, LABEL.dpmm); expect(propsOf(objects[0]).gs1).toBe(true); expect(propsOf(objects[0]).content).toBe(canonical); }); @@ -95,7 +96,7 @@ describe('GS1-128 (code128 gs1 mode)', () => { expect(zpl).toContain('^FD(01)#1#(10)#2#^FS'); // And it round-trips back to marker content (the parser mints its own // variable names for the ^FN slots). - const { objects } = parseZPL(zpl, LABEL.dpmm); + const { objects } = parseSingle(zpl, LABEL.dpmm); expect(propsOf(objects[0]).gs1).toBe(true); expect(propsOf(objects[0]).content).toBe('01«field_1»10«field_2»'); }); @@ -124,14 +125,14 @@ describe('GS1-128 FNC1 separators (mode D)', () => { // (10)A>B emits (10)A>0B; parse must unescape so a second export is stable. const zpl1 = generateZPL(LABEL, [mk({ ...baseProps, gs1: true, content: '10A>B' })]); expect(zpl1).toContain('^FD(10)A>0B^FS'); - const { objects } = parseZPL(zpl1, LABEL.dpmm); + const { objects } = parseSingle(zpl1, LABEL.dpmm); expect(propsOf(objects[0]).content).toBe('10A>B'); expect(generateZPL(LABEL, objects as never)).toContain('^FD(10)A>0B^FS'); }); it('round-trips the >8 form back to canonical GS content', () => { const zpl = '^XA^FO10,10^BCN,100,N,N,N,D^FD(01)04012345678901(10)20260707>8(17)261231(30)144^FS^XZ'; - const { objects } = parseZPL(zpl, 8); + const { objects } = parseSingle(zpl, 8); const p = propsOf(objects[0]); expect(p.gs1).toBe(true); expect(p.content).toBe('0104012345678901' + '1020260707' + GS1_GS + '17261231' + '30144'); @@ -153,7 +154,7 @@ describe('GS1-128 mode-D ^FN value symmetry', () => { it('normalizes a raw (non-paren) mode-D field payload to model form', () => { const zpl = '^XA^FO10,10^BCN,100,N,N,N,D^FD010401234567890110LOT>821SER^FS^XZ'; - const { objects } = parseZPL(zpl, LABEL.dpmm); + const { objects } = parseSingle(zpl, LABEL.dpmm); expect(propsOf(objects[0]).content).toBe('010401234567890110LOT' + GS1_GS + '21SER'); }); @@ -161,7 +162,7 @@ describe('GS1-128 mode-D ^FN value symmetry', () => { const zpl = '^XA^FN1^FD010401234567890110LOT>821SER^FS' + '^BY2^FO10,10^BCN,100,N,N,N,D^FE#^FD#1#^FS^XZ'; - const parsed = parseZPL(zpl, LABEL.dpmm); + const parsed = parseSingle(zpl, LABEL.dpmm); expect(parsed.variables.find((v) => v.fnNumber === 1)?.defaultValue) .toBe('010401234567890110LOT' + GS1_GS + '21SER'); }); @@ -172,7 +173,7 @@ describe('GS1-128 mode-D ^FN value symmetry', () => { const zpl = '^XA^FN1^FD0112345678901231^FS' + '^BY2^FO10,10^BCN,100,N,N,N,D^FE#^FD(01)04012345678901(10)#1#^FS^XZ'; - const parsed = parseZPL(zpl, LABEL.dpmm); + const parsed = parseSingle(zpl, LABEL.dpmm); expect(parsed.variables.find((v) => v.fnNumber === 1)?.defaultValue).toBe('0112345678901231'); }); @@ -180,7 +181,7 @@ describe('GS1-128 mode-D ^FN value symmetry', () => { const zpl = '^XA^FN1^FD>;>80100003486^FS' + '^BY2^FO10,10^BCN,100,N,N,N,D^FE#^FD(01)04012345678901(10)#1#^FS^XZ'; - const parsed = parseZPL(zpl, LABEL.dpmm); + const parsed = parseSingle(zpl, LABEL.dpmm); expect(parsed.variables.find((v) => v.fnNumber === 1)?.defaultValue).toBe('>;>80100003486'); }); @@ -197,7 +198,7 @@ describe('GS1-128 mode-D ^FN value symmetry', () => { it('round-trips the escaped default back to the raw > (no compounding)', () => { const zpl = generateZPL(LABEL, [gs1Tpl()], vars); - const parsed = parseZPL(zpl, LABEL.dpmm); + const parsed = parseSingle(zpl, LABEL.dpmm); expect(parsed.variables.find((v) => v.fnNumber === 1)?.defaultValue).toBe('A>B'); const zpl2 = generateZPL(LABEL, parsed.objects as never, parsed.variables); expect(zpl2).toContain('^FN1^FDA>0B^FS'); @@ -219,9 +220,19 @@ describe('GS1-128 mode-D ^FN value symmetry', () => { const single = mk({ ...baseProps, gs1: true, content: '«' + 'lot' + '»' }); const singleVars: Variable[] = [{ id: 'w', name: 'lot', fnNumber: 2, defaultValue: content }]; const zpl1 = generateZPL(LABEL, [single], singleVars); - const parsed = parseZPL(zpl1, LABEL.dpmm); + const parsed = parseSingle(zpl1, LABEL.dpmm); expect(parsed.variables.find((v) => v.fnNumber === 2)?.defaultValue).toBe(content); const zpl2 = generateZPL(LABEL, parsed.objects as never, parsed.variables); expect(zpl2).toBe(zpl1); }); + + it('scopes mode-D normalization per page: a later block reusing the ^FN number does not suppress it', () => { + const zpl = + '^XA^FN1^FD010401234567890110LOT>821SER^FS' + + '^BY2^FO10,10^BCN,100,N,N,N,D^FE#^FD#1#^FS^XZ' + + '^XA^FO10,10^A0N,30,30^FN1^FDplain^FS^XZ'; + const parsed = parseZPL(zpl, LABEL.dpmm); + const modeDVar = parsed.pages[0]?.variables.find((v) => v.fnNumber === 1); + expect(modeDVar?.defaultValue).toBe('010401234567890110LOT' + GS1_GS + '21SER'); + }); }); diff --git a/src/registry/maxicode.test.ts b/src/registry/maxicode.test.ts index 2f6665e8..84c5eadd 100644 --- a/src/registry/maxicode.test.ts +++ b/src/registry/maxicode.test.ts @@ -1,10 +1,9 @@ import { describe, it, expect } from "vitest"; import { ObjectRegistry } from "@zplab/core/registry"; -import { parseZPL } from "@zplab/core/lib/zplParser"; import { validateMaxicodeBwip } from "../components/Canvas/bwipHelpers"; import type { LabelObjectBase } from "@zplab/core/types/LabelObject"; import type { MaxicodeProps } from "@zplab/core/registry/maxicode"; -import { defined } from "../test/helpers"; +import { defined, parseSingle } from "../test/helpers"; function makeObj(props: MaxicodeProps, overrides?: Partial): LabelObjectBase & { props: MaxicodeProps } { return { @@ -59,7 +58,7 @@ describe("validateMaxicodeBwip", () => { describe("maxicode parser roundtrip", () => { it("parses ^BV back to a maxicode object; the no-op orientation slot is canonicalized away", () => { const src = "^XA^PW400^LL400^FO50,50^BVR,3,1,1^FDPAYLOAD^FS^XZ"; - const { objects } = parseZPL(src); + const { objects } = parseSingle(src); const obj = objects[0]; expect(obj?.type).toBe("maxicode"); if (obj?.type !== "maxicode") return; @@ -72,7 +71,7 @@ describe("maxicode parser roundtrip", () => { it("defaults mode to 4 when an out-of-range value is given", () => { // mode 9 doesn't exist; parser clamps to the safe standalone default. const src = "^XA^FO0,0^BVN,9,1,1^FDX^FS^XZ"; - const { objects } = parseZPL(src); + const { objects } = parseSingle(src); const obj = objects[0]; if (obj?.type !== "maxicode") throw new Error("expected maxicode"); expect(obj.props.mode).toBe(4); @@ -85,7 +84,7 @@ describe("maxicode parser roundtrip", () => { mode: 5, })); const src = `^XA${body}^XZ`; - const { objects } = parseZPL(src); + const { objects } = parseSingle(src); const obj = objects[0]; if (obj?.type !== "maxicode") throw new Error("expected maxicode"); expect(obj.props.content).toBe("HELLO123"); diff --git a/src/test/helpers.ts b/src/test/helpers.ts index 1b69d4fa..c2cf235c 100644 --- a/src/test/helpers.ts +++ b/src/test/helpers.ts @@ -1,5 +1,22 @@ import { expect } from 'vitest'; import type { SerialMode } from '@zplab/core/registry/serialField'; +import { parseZPL, type ImportFindingKind } from '@zplab/core/lib/zplParser'; + +/** Parse a single-label stream: the sole page merged with the document-wide + * fields. Asserts exactly one page; multi-page tests read `pages` directly. */ +export function parseSingle(zpl: string, dpmm = 8, opts?: { captureOverlay?: boolean }) { + const r = parseZPL(zpl, dpmm, opts); + expect(r.pages).toHaveLength(1); + // Keep the document labelConfig: the page snapshot predates the sidecar. + return { ...r, ...defined(r.pages[0]), labelConfig: r.labelConfig }; +} + +/** Commands of one finding kind, in occurrence order (per-page findings + * replaced the old document-wide report buckets). */ +export const commandsOf = ( + parsed: { findings: { kind: ImportFindingKind; command: string }[] }, + kind: ImportFindingKind, +): string[] => parsed.findings.filter((f) => f.kind === kind).map((f) => f.command); /** Read the serial-mode prop off a parsed leaf for assertions. */ export const serialOf = ( diff --git a/src/test/zplRoundtrip.test.ts b/src/test/zplRoundtrip.test.ts index 7f5a6512..891025b0 100644 --- a/src/test/zplRoundtrip.test.ts +++ b/src/test/zplRoundtrip.test.ts @@ -7,19 +7,18 @@ * full round-trip without structural loss. */ import { describe, it, expect } from 'vitest'; -import { parseZPL } from '@zplab/core/lib/zplParser'; import { generateZPL } from '@zplab/core/lib/zplGenerator'; import type { LabelConfig } from '@zplab/core/types/LabelConfig'; -import { props, defined } from './helpers'; +import { props, defined, parseSingle, commandsOf } from './helpers'; const BASE: LabelConfig = { widthMm: 100, heightMm: 60, dpmm: 8 }; function roundtrip(zpl: string, dpmm = 8) { - const first = parseZPL(zpl, dpmm); + const first = parseSingle(zpl, dpmm); // BASE provides fallbacks for labels that omit ^PW/^LL; dpmm always wins over any parsed value const label: LabelConfig = { ...BASE, ...first.labelConfig, dpmm }; const regenerated = generateZPL(label, first.objects); - const second = parseZPL(regenerated, dpmm); + const second = parseSingle(regenerated, dpmm); return { first, second, label, regenerated }; } @@ -304,7 +303,7 @@ describe('round-trip — field block text', () => { // across rotation, justify, hanging indent, line spacing and FT vs FO anchoring // without ever growing a ^GB. const reverseBlockIsIdempotent = (blockZpl: string) => { - const base = parseZPL(blockZpl, 8); + const base = parseSingle(blockZpl, 8); const obj = base.objects.find((o) => o.type === 'text'); expect(obj).toBeDefined(); const reversed = { @@ -313,7 +312,7 @@ describe('round-trip — field block text', () => { } as (typeof base.objects)[number]; const label: LabelConfig = { ...BASE, ...base.labelConfig, dpmm: 8 }; const emit1 = generateZPL(label, [reversed]); - const parsed = parseZPL(emit1, 8); + const parsed = parseSingle(emit1, 8); expect(parsed.objects).toHaveLength(1); const out = defined(parsed.objects[0]); expect(out.type).toBe('text'); @@ -627,7 +626,7 @@ describe('round-trip — text rotation × positionType preservation', () => { describe('parseZPL — real-world structural commands are silently ignored', () => { // Labels produced by Zebra Designer / ZPL II tools commonly begin with a // block of printer configuration commands that carry no canvas-design info. - // They must NOT pollute importReport.unknown. + // They must NOT pollute the unknown findings. const ZEBRA_HEADER_ZPL = [ '^XA', '^CI28', // UTF-8 encoding @@ -643,24 +642,24 @@ describe('parseZPL — real-world structural commands are silently ignored', () '^XZ', ].join(''); - it('does not add ^CI, ^PR, ^MN, ^JM, ^MT to importReport.unknown', () => { - const { importReport } = parseZPL(ZEBRA_HEADER_ZPL, 8); - const noiseInUnknown = importReport.unknown.filter( + it('does not flag ^CI, ^PR, ^MN, ^JM, ^MT as unknown', () => { + const parsed = parseSingle(ZEBRA_HEADER_ZPL, 8); + const noiseInUnknown = commandsOf(parsed, 'unknown').filter( (s) => /^\^(CI|PR|MN|JM|MT|JA)/.test(s), ); expect(noiseInUnknown).toHaveLength(0); }); it('still parses design objects correctly after the header block', () => { - const { objects } = parseZPL(ZEBRA_HEADER_ZPL, 8); + const { objects } = parseSingle(ZEBRA_HEADER_ZPL, 8); expect(objects).toHaveLength(1); expect(objects[0]?.type).toBe('text'); expect(props(objects[0]).content).toBe('Real Label'); }); - it('genuinely unknown commands still appear in importReport.unknown', () => { - const { importReport } = parseZPL(ZEBRA_HEADER_ZPL, 8); - expect(importReport.unknown.some((s) => s.startsWith('^XF'))).toBe(true); + it('genuinely unknown commands still surface as unknown findings', () => { + const parsed = parseSingle(ZEBRA_HEADER_ZPL, 8); + expect(commandsOf(parsed, 'unknown').some((s) => s.startsWith('^XF'))).toBe(true); }); }); @@ -685,7 +684,7 @@ describe('round-trip — label geometry sidecar', () => { expect(zpl).toContain('^FXZPLLAB:'); // Re-parse at the wrong external dpmm: ^PW/^LL alone would give 85.5×48mm // at 8dpmm, but the sidecar restores the authored geometry exactly. - const parsed = parseZPL(zpl, 8); + const parsed = parseSingle(zpl, 8); expect(parsed.labelConfig.dpmm).toBe(12); expect(parsed.labelConfig.widthMm).toBe(57); expect(parsed.labelConfig.heightMm).toBe(32);