diff --git a/packages/core/src/lib/boundControlChips.test.ts b/packages/core/src/lib/boundControlChips.test.ts new file mode 100644 index 00000000..b7e4d807 --- /dev/null +++ b/packages/core/src/lib/boundControlChips.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { ObjectRegistry, ctrlParityFor, type LeafObject, type LeafType } from "../registry"; +import type { CtrlParity } from "./markerResolve"; +import { generateBatchZpl } from "./zplGenerator"; +import { importZplText } from "./zplImportService"; +import { resolveForMeasure } from "./barcodeDims"; +import { slotEncodingConflicts } from "./slotConflicts"; +import { pageLabelConfig } from "../types/Group"; + +type Leaf = LeafObject & { props: { content: string } }; + +const label = { widthMm: 70, heightMm: 40, dpmm: 8 }; +const variables = [{ id: "v", name: "v", fnNumber: 1, defaultValue: "A«ctrl:TAB»B" }]; +const leafOf = (type: LeafType, content: string, extra: object = {}): Leaf => + ({ id: `${type}:${content}`, type, x: 0, y: 0, rotation: 0, positionType: "FO", props: { ...ObjectRegistry[type].defaultProps, content, ...extra } }) as Leaf; +const bound = (type: LeafType): Leaf => leafOf(type, "«v»"); +const template = (type: LeafType): Leaf => leafOf(type, "x«v»y"); +const text = bound("text"); +const ctrlTypes = (Object.keys(ObjectRegistry) as LeafType[]).filter((t) => ObjectRegistry[t].controlChars === true); +const gs1CtrlTypes = ctrlTypes.filter((t) => ObjectRegistry[t].gs1Capable === true); + +// The roster test fails on a controlChars type without a row here. +const EXPECTED = { + aztec: { boundField: "^FN1^FH_^FDA_09B^FS", parity: "byte", templateConflict: false }, + code128: { boundField: "^FN1^FD>9337334^FS", parity: "byteOutsideTemplate", templateConflict: false }, + datamatrix: { boundField: "^FN1^FH_^FDA_09B^FS", parity: "byte", templateConflict: false }, + micropdf417: { boundField: "^FN1^FH_^FDA_09B^FS", parity: "byte", templateConflict: false }, + pdf417: { boundField: "^FN1^FH_^FDA_09B^FS", parity: "byte", templateConflict: false }, + // A QR lone bind writes its EC prefix into the slot, which a template of its kind prints as payload. + qrcode: { boundField: "^FN1^FH_^FDQA,A_09B^FS", parity: "byte", templateConflict: true }, +} as const satisfies Partial>; +const expected = (type: LeafType) => EXPECTED[type as keyof typeof EXPECTED]; + +describe("a bound control chip on every controlChars type", () => { + it("the controlChars roster is the five 2D symbologies and Code 128, two of them GS1 capable", () => { + expect([...ctrlTypes].sort()).toEqual(Object.keys(EXPECTED).sort()); + expect([...gs1CtrlTypes].sort()).toEqual(["code128", "datamatrix"]); + }); + + it.each(ctrlTypes)("%s emits and recalls the chip as its byte, the canvas resolves it and the import restores the chip", (type) => { + const leaf = bound(type); + const { boundField } = expected(type); + const zpl = ObjectRegistry[type].toZPL(leaf, { label, variables }); + expect(zpl).toContain(boundField); + const batch = generateBatchZpl(pageLabelConfig(label, {}), [leaf], variables, { headers: ["v"], rows: [["A«ctrl:TAB»B"]] }, { bindings: { v: "v" } }); + expect(batch).toContain(boundField); + expect(resolveForMeasure(leaf, variables).props.content).toBe("A\tB"); + expect(importZplText(`^XA${zpl}^XZ`, 8).variables[0]?.defaultValue).toBe("A«ctrl:TAB»B"); + }); + + it.each(ctrlTypes)("%s reports the parity its table row names", (type) => { + expect(ctrlParityFor(bound(type))).toBe(expected(type).parity); + }); + + it.each(gs1CtrlTypes)("%s keeps the chip literal under GS1, in its parity, its emit, the canvas and an import", (type) => { + const leaf = leafOf(type, "«v»", { gs1: true }); + expect(ctrlParityFor(leaf)).toBe("literal"); + const zpl = ObjectRegistry[type].toZPL(leaf, { label, variables }); + expect(zpl).toContain("ctrl:TAB"); + expect(resolveForMeasure(leaf, variables).props.content).toBe("A«ctrl:TAB»B"); + expect(importZplText(`^XA${zpl}^XZ`, 8).variables[0]?.defaultValue).toBe("A«ctrl:TAB»B"); + }); + + it.each(ctrlTypes)("%s conflicts over a slot with a text field, not with a lone bind of its kind, and with a template of its kind per its table row", (type) => { + expect([...slotEncodingConflicts([bound(type), text], variables).fns]).toEqual([1]); + expect([...slotEncodingConflicts([bound(type), { ...bound(type), id: "second" }], variables).fns]).toEqual([]); + expect([...slotEncodingConflicts([bound(type), template(type)], variables).fns]).toEqual(expected(type).templateConflict ? [1] : []); + }); + + it.each(ctrlTypes)("%s as a template agrees with a text template and with a block-text lone bind, which both leave the slot raw", (type) => { + const fbText = leafOf("text", "«v»", { textMode: "fb", blockWidth: 200 }); + expect([...slotEncodingConflicts([template(type), template("text")], variables).fns]).toEqual([]); + expect([...slotEncodingConflicts([fbText, template(type)], variables).fns]).toEqual([]); + }); + + it("a Code 128 m=N template drops the byte an Aztec lone bind writes, so the slot conflicts", () => { + expect([...slotEncodingConflicts([bound("aztec"), template("code128")], variables).fns]).toEqual([1]); + expect([...slotEncodingConflicts([bound("aztec"), leafOf("code128", "x«v»y", { mode: "A" })], variables).fns]).toEqual([]); + }); +}); diff --git a/packages/core/src/lib/code128Auto.test.ts b/packages/core/src/lib/code128Auto.test.ts index 4f3766e2..438fce27 100644 --- a/packages/core/src/lib/code128Auto.test.ts +++ b/packages/core/src/lib/code128Auto.test.ts @@ -8,7 +8,6 @@ import { dropPageOverlays } from "./pageOverlay"; import { pageLabelConfig, type LabelObject, type Page } from "../types/Group"; import { slotEncodingConflicts } from "./slotConflicts"; import { code128 } from "../registry/code128"; -import { aztec, type AztecProps } from "../registry/aztec"; import { ctrlParityFor } from "../registry"; import { parseZPL } from "./zplParser"; import type { LeafObject } from "../registry"; @@ -158,7 +157,7 @@ describe("one ^BC m for emit, canvas and parser", () => { it("turns a bound control chip into its byte under m=A, on the emit, the batch recall, the canvas and the import", () => { const variables = [{ id: "v", name: "v", fnNumber: 1, defaultValue: "A«ctrl:TAB»B" }]; const bound = leaf("«v»", { mode: "A" }); - expect(ctrlParityFor(bound)).toBe("byteWhole"); + expect(ctrlParityFor(bound)).toBe("byte"); expect(code128.toZPL(bound, { label, variables })).toContain("^FN1^FH_^FDA_09B^FS"); const batch = generateBatchZpl(pageLabelConfig(label, {}), [bound], variables, { headers: ["v"], rows: [["A«ctrl:TAB»B"]] }, { bindings: { v: "v" } }); expect(batch).toContain("^FN1^FH_^FDA_09B^FS"); @@ -166,14 +165,6 @@ describe("one ^BC m for emit, canvas and parser", () => { expect(importZplText("^XA^FO10,10^BY2^BCN,80,Y,N,N,A^FN1^FH_^FDA_09B^FS^XZ", 8).variables[0]?.defaultValue).toBe("A«ctrl:TAB»B"); }); - it("keeps a bound chip literal on a 2D type, whose emit leaves it in the value", () => { - const variables = [{ id: "v", name: "v", fnNumber: 1, defaultValue: "A«ctrl:TAB»B" }]; - const bound = { id: "z", type: "aztec", x: 0, y: 0, rotation: 0, positionType: "FO", props: { ...aztec.defaultProps, content: "«v»" } } as LeafObject & { props: AztecProps }; - expect(ctrlParityFor(bound)).toBe("byte"); - expect(aztec.toZPL(bound, { label, variables })).toContain("^FN1^FDA«ctrl:TAB»B^FS"); - expect((resolveForMeasure(bound, variables) as { props: { content: string } }).props.content).toBe("A«ctrl:TAB»B"); - }); - it("reports a slot an m=A field shares with a text field, which prints the chip as text", () => { const variables = [{ id: "v", name: "v", fnNumber: 1, defaultValue: "A«ctrl:TAB»B" }]; const text = { id: "t", type: "text", x: 0, y: 0, rotation: 0, positionType: "FO", props: { content: "«v»", font: "0", fontSize: 30 } } as unknown as LabelObject; diff --git a/packages/core/src/lib/fnTemplate.ts b/packages/core/src/lib/fnTemplate.ts index 7b56edf3..800e6cb2 100644 --- a/packages/core/src/lib/fnTemplate.ts +++ b/packages/core/src/lib/fnTemplate.ts @@ -1,5 +1,5 @@ -import { markerOf, markerRe, type Variable } from "../types/Variable"; -import { isControlBody } from "../types/controlKey"; +import { isLoneMarker, markerOf, markerRe, type Variable } from "../types/Variable"; +import { isControlBody, resolveControlMarkers } from "../types/controlKey"; import { clockBodyLength } from "./fcTemplate"; import { escapeRegExp } from "./escapeRegExp"; @@ -68,13 +68,19 @@ export function hasTemplateMarkers(content: string): boolean { * and an unknown marker counts as its literal characters (matching emit, * where unresolved markers stay literal). A control-key chip counts one byte * only when `ctrlAsByte` (the field's emitter resolves chips); elsewhere it - * stays literal at emit and counts its full marker text. */ + * stays literal at emit and counts its full marker text. A bound value's + * chips count as bytes only on a lone bind. */ export function resolvedContentLength( content: string, variables: readonly Variable[], ctrlAsByte = false, ): number { - const byName = new Map(variables.map((v) => [v.name, v.defaultValue.length])); + const loneBind = ctrlAsByte && isLoneMarker(content); + const byName = new Map(variables.map((v) => [v.name, v.defaultValue])); + const boundLength = (body: string): number | undefined => { + const value = byName.get(body); + return value === undefined ? undefined : (loneBind ? resolveControlMarkers(value) : value).length; + }; let len = 0; let last = 0; for (const m of content.matchAll(markerRe())) { @@ -82,7 +88,7 @@ export function resolvedContentLength( const body = m[1] ?? ""; len += clockBodyLength(body) ?? (ctrlAsByte && isControlBody(body) ? 1 : undefined) - ?? byName.get(body) + ?? boundLength(body) ?? m[0].length; last = (m.index ?? 0) + m[0].length; } diff --git a/packages/core/src/lib/markerResolve.ts b/packages/core/src/lib/markerResolve.ts index fad30b04..024b6737 100644 --- a/packages/core/src/lib/markerResolve.ts +++ b/packages/core/src/lib/markerResolve.ts @@ -1,7 +1,5 @@ import { hasTemplateMarkers, resolveTemplateMarkers } from "./fnTemplate"; -// SINGLE_MARKER_RE direct: variableField's isLoneMarker would cycle back into -// this leaf module via variableBinding. -import { SINGLE_MARKER_RE } from "../types/Variable"; +import { isLoneMarker } from "../types/Variable"; import { code128EscapeLiterals, code128PlainFd } from "./code128Subset"; import { channelDatesFrom, hasClockMarkers, resolveClockMarkers, type ChannelDates } from "./fcTemplate"; import { hasControlMarkers, resolveControlMarkers, stripControlBytes } from "../types/controlKey"; @@ -39,14 +37,14 @@ export function clockDatesThunk(clock?: ClockResolveCtx): () => ChannelDates { ); } -/** Where the emitter turns a chip into a byte, so preview resolution mirrors it. - * `byte` in the field's own content, `byteWhole` in a bound whole value too. - * `byteOutsideTemplate` as byteWhole, minus ^FE templates, where ^FH drops the bytes. */ -export type CtrlParity = "literal" | "byte" | "byteWhole" | "byteOutsideTemplate"; +/** Where the emitter turns a chip into a byte, so preview resolution mirrors it. `byte` resolves + * in the field's own content and in a bound whole value. `byteOutsideTemplate` does the same but + * drops the byte in ^FE templates, where ^FH is lossy. `literal` resolves nowhere. */ +export type CtrlParity = "literal" | "byte" | "byteOutsideTemplate"; -/** Shared control -> clock -> variable order: field-own display markers first - * (a chip in substituted data stays literal, matching export; lone binds are - * the exception below). `ctrl` mirrors the emitter's capability gate. */ +/** Shared control -> clock -> variable order: field-own display markers first. A chip in + * substituted data stays literal, since a template consumer prints the slot verbatim. Lone + * binds are the exception below, and `ctrl` mirrors the emitter's capability gate. */ export function resolveMarkerChain( content: string, resolveVar: (name: string) => string | undefined, @@ -65,7 +63,7 @@ export function resolveMarkerChain( // Emitter gate, evaluated pre-^FC there too: clock markers keep the lossy // ^FH path; a lone bind is a whole ^FD and takes the field's whole-value // transform. Chips-only payloads are bytes by now and match neither gate. - loneBind = (ctrl === "byteWhole" || ctrl === "byteOutsideTemplate") && SINGLE_MARKER_RE.test(next); + loneBind = ctrl !== "literal" && isLoneMarker(next); templateFd = ctrl === "byteOutsideTemplate" && hasTemplateMarkers(next) && !loneBind; if (templateFd) next = code128EscapeLiterals(next); if (hasClockMarkers(next)) next = resolveClockMarkers(next, getDates()); @@ -76,8 +74,7 @@ export function resolveMarkerChain( return value === undefined ? value : code128PlainFd(value); }); } - // The emitter resolves chips inside a lone-bind value too (fdTransformFor - // runs resolveControlMarkers on the whole-field default/CSV cell). + // The emitter resolves chips inside a lone-bind value too, see withChipPass. if (loneBind && hasControlMarkers(next)) next = resolveControlMarkers(next); // After substitution, so a control byte from the bound value drops too. return templateFd ? stripControlBytes(next) : next; diff --git a/packages/core/src/lib/preflight.ts b/packages/core/src/lib/preflight.ts index d6f78566..31356567 100644 --- a/packages/core/src/lib/preflight.ts +++ b/packages/core/src/lib/preflight.ts @@ -15,7 +15,8 @@ import { extractTemplateRefs, hasTemplateMarkers, pickEmbedChar } from "./fnTemp import { hasClockMarkers, pickClockChars } from "./fcTemplate"; import { planCode128Fd, planHasLoss } from "./code128Plan"; import { resolveControlMarkers } from "../types/controlKey"; -import { classifyField, isLoneMarker } from "./variableField"; +import { classifyField } from "./variableField"; +import { isLoneMarker } from "../types/Variable"; import { parseContent, typedContentIncompleteRows, typedContentMarkerFindings } from "./typedContent"; import { getObjectStringContent, resolveForRow, variableSubstitutions } from "./variableBinding"; import { fnConsumerBuckets, isModeDLeaf } from "./gs1ModeDFns"; diff --git a/packages/core/src/lib/slotConflicts.ts b/packages/core/src/lib/slotConflicts.ts index e065b384..b2a7fb85 100644 --- a/packages/core/src/lib/slotConflicts.ts +++ b/packages/core/src/lib/slotConflicts.ts @@ -3,13 +3,18 @@ import { classifyField } from "./variableField"; import { qrPrintsAsGraphic } from "./objectBounds"; import { resolveContentPreview } from "./markerResolve"; import { planCode128Fd } from "./code128Plan"; -import { getEntry, isGs1Active, usesPlainCode128Escape } from "../registry"; +import { ctrlParityFor, getEntry, isGs1Active, loneBindFdTransform, usesPlainCode128Escape } from "../registry"; +import { resolveControlMarkers } from "../types/controlKey"; import { extractTemplateRefs } from "./fnTemplate"; import { getObjectStringContent } from "./variableBinding"; import { fnConsumerBuckets, type FnConsumerBuckets } from "./gs1ModeDFns"; import type { Variable } from "../types/Variable"; -type SlotEncoder = ((s: string) => string) | undefined; +/** The slot forms a consumer accepts, derived from the raw value. */ +type SlotAccepts = (raw: string) => readonly string[]; + +const RAW: SlotAccepts = (raw) => [raw]; +const RAW_OR_BYTES: SlotAccepts = (raw) => [raw, resolveControlMarkers(raw)]; export interface SlotConflicts { fns: Set; @@ -17,10 +22,9 @@ export interface SlotConflicts { consumerIds: Set; } -/** Slots whose consumers would put DIFFERENT bytes in the one substituted - * value: the emitting field wins, every consumer prints its encoding. - * Values must be RAW (chips unresolved): fdFieldFor transforms the - * unresolved default; the code128 plans resolve chips internally. */ +/** Slots with no form every consumer accepts: the emitting field wins and every + * other consumer prints that encoding. Values must be RAW with chips unresolved, + * since the consumers resolve them. */ export function slotEncodingConflicts( objects: readonly LabelObject[], variables: readonly Variable[], @@ -30,13 +34,13 @@ export function slotEncodingConflicts( const byName = new Map(variables.map((v) => [v.name, v])); const perSlot = new Map< number, - { encoders: SlotEncoder[]; vars: Set; leafIds: string[] } + { consumers: SlotAccepts[]; vars: Set; leafIds: string[] } >(); - const note = (v: Variable, encode: SlotEncoder, leafId: string) => { + const note = (v: Variable, accepts: SlotAccepts, leafId: string) => { const slot = perSlot.get(v.fnNumber) ?? - { encoders: [], vars: new Set(), leafIds: [] }; - slot.encoders.push(encode); + { consumers: [], vars: new Set(), leafIds: [] }; + slot.consumers.push(accepts); slot.vars.add(v); slot.leafIds.push(leafId); perSlot.set(v.fnNumber, slot); @@ -57,34 +61,34 @@ export function slotEncodingConflicts( if (cls.kind === "single") { // fdFieldFor encodes the single-bind value; a plain-^BC lone bind on a // shared slot is degraded to sharedRaw by the emit (barcode1d, rawFdFns). - note( - cls.variable, + const encode = usesPlainCode128Escape(entry, props) && buckets.plainShared.has(cls.variable.fnNumber) ? (s: string) => planCode128Fd(s, "sharedRaw").fd - : entry.fdTransform?.(leaf), - leaf.id, - ); + : loneBindFdTransform(leaf); + note(cls.variable, encode ? (raw) => [encode(raw)] : RAW, leaf.id); continue; } - // Every other shape embeds the slot inside its own payload and reads it raw. + // A type whose template keeps the byte also accepts it, since a lone bind of its kind writes bytes into the slot. + const accepts = ctrlParityFor(leaf) === "byte" ? RAW_OR_BYTES : RAW; for (const name of extractTemplateRefs(c)) { const v = byName.get(name); - if (v) note(v, undefined, leaf.id); + if (v) note(v, accepts, leaf.id); } } const fns = new Set(); const consumerIds = new Set(); - for (const [fn, { encoders, vars, leafIds }] of perSlot) { - if (encoders.length < 2) continue; + for (const [fn, { consumers, vars, leafIds }] of perSlot) { + if (consumers.length < 2) continue; // Exclusive code128-family slots are emit-coordinated: all consumers // share the ^BC grammar, so byte differences (>0) decode identically. if (buckets.modeDExclusive.has(fn) || buckets.plainExclusive.has(fn)) continue; - // All-raw readers cannot diverge; skip the row walk. - if (encoders.every((e) => e === undefined)) continue; + // All-raw readers cannot diverge, so skip the row walk. RAW is the one shared constant. + if (consumers.every((accepts) => accepts === RAW)) continue; const diverges = [...vars].some((v) => - substitutionsOf(v).some( - (val) => new Set(encoders.map((e) => (e ? e(val) : val))).size > 1, - ), + substitutionsOf(v).some((val) => { + const forms = consumers.map((accepts) => accepts(val)); + return !forms.flat().some((form) => forms.every((f) => f.includes(form))); + }), ); if (diverges) { fns.add(fn); diff --git a/packages/core/src/lib/variableField.ts b/packages/core/src/lib/variableField.ts index e5017bdd..e1da4a08 100644 --- a/packages/core/src/lib/variableField.ts +++ b/packages/core/src/lib/variableField.ts @@ -11,12 +11,6 @@ import { extractClockTokens } from "./fcTemplate"; export type BindableLeaf = LabelObjectBase & { props: { content: string } }; export const asLabelObject = (obj: BindableLeaf): LabelObject => obj as unknown as LabelObject; -/** True when content is exactly one `«marker»` and nothing else: the shape that - * emits inline (single-bind) rather than expanding to `^FE` embeds. */ -export function isLoneMarker(content: string): boolean { - return SINGLE_MARKER_RE.test(content); -} - /** The single-bound Variable for a field (content === exactly one known * marker), or undefined for template/literal/empty content. Drives the layer * badge and the canvas fallback tint. */ diff --git a/packages/core/src/lib/zplGenerator.ts b/packages/core/src/lib/zplGenerator.ts index 8e8653f0..0c132665 100644 --- a/packages/core/src/lib/zplGenerator.ts +++ b/packages/core/src/lib/zplGenerator.ts @@ -2,7 +2,7 @@ import { mmToDots } from './coordinates'; import { CONTROL_BYTES_RE } from './binaryText'; import { unsafeRawFieldSpans } from './zplParser/helpers'; import { rewriteRawFieldSpans } from './zplParser/decoders/gfa'; -import { getEntry, usesPlainCode128Escape, BARCODE_1D_TYPES } from '../registry'; +import { getEntry, loneBindFdTransform, usesPlainCode128Escape, BARCODE_1D_TYPES } from '../registry'; import { fdField, stripZplCommandChars, GRAPHIC_ANCHOR_TYPES, printerAnchoredX } from '../registry/zplHelpers'; import { stripZplParamChars } from './zplParams'; import { isRecallableFormatPath, storageKey, storageRefMatchesPath } from './storagePath'; @@ -866,7 +866,7 @@ function planRecalls( && plainSharedFns.has(v.fnNumber); const transform = boundPlainShared ? (s: string) => planCode128Fd(s, 'sharedRaw').fd - : (bound && !isGroup(bound) ? boundEntry?.fdTransform?.(bound) : undefined) ?? + : (bound && !isGroup(bound) ? loneBindFdTransform(bound) : undefined) ?? (modeDFns.has(v.fnNumber) ? escapeGs1FdValue : plain128Fns.has(v.fnNumber) diff --git a/packages/core/src/lib/zplParser.ts b/packages/core/src/lib/zplParser.ts index cc1cfb3e..2808c56f 100644 --- a/packages/core/src/lib/zplParser.ts +++ b/packages/core/src/lib/zplParser.ts @@ -6,7 +6,7 @@ import { code128FdToBytes, hasControlBytes } from "./code128Subset"; import { planCode128Fd } from "./code128Plan"; import { extractTemplateRefs, hasTemplateMarkers } from "./fnTemplate"; import { controlBytesToMarkers, resolveControlMarkers } from "../types/controlKey"; -import { isLoneMarker } from "./variableField"; +import { isLoneMarker } from "../types/Variable"; import { markerOf } from "../types/Variable"; import { getObjectStringContent } from "./variableBinding"; import { parseLabelMetaComment, type LabelMeta } from "./zplLabelMeta"; diff --git a/packages/core/src/registry/barcode1d.ts b/packages/core/src/registry/barcode1d.ts index ec6a7879..131c65b7 100644 --- a/packages/core/src/registry/barcode1d.ts +++ b/packages/core/src/registry/barcode1d.ts @@ -7,7 +7,8 @@ import { serialFieldData, type SerialMode } from './serialField'; import { commitBarcodeWidthHeightTransform, moduleWidthSpec } from './transformHelpers'; import { hasTemplateMarkers } from '../lib/fnTemplate'; import { moduleTooSmallPreflight } from '../lib/barcodeScannability'; -import { classifyField, isLoneMarker } from '../lib/variableField'; +import { classifyField } from '../lib/variableField'; +import { isLoneMarker } from '../types/Variable'; import { code128Mode, planCode128Fd } from '../lib/code128Plan'; import { resolveControlMarkers } from '../types/controlKey'; import { parseGs1ToSegments, segmentsToZplFd } from '../lib/gs1'; @@ -129,7 +130,7 @@ export function createBarcode1DCore(config: Barcode1DCoreConfig): ObjectTypeCore const escape = code128Mode(obj.props) === 'N' ? config.fdPlainEscape : undefined; // A lone marker (single-bind) still transforms its default/CSV value; // only a real template is skipped. Ctrl-capable types resolve chips - // first: chips-only is non-template there (whole-field ctrlEncode). + // first: chips-only is non-template there. const isTemplate = hasTemplateMarkers( config.controlChars === true @@ -137,14 +138,12 @@ export function createBarcode1DCore(config: Barcode1DCoreConfig): ObjectTypeCore : obj.props.content, ) && !isLoneMarker(obj.props.content); const base = isTemplate ? undefined : gs1 ? gs1ModeDFd : config.fdContent; - // The plain grammar's own encoder plans the bytes, otherwise fdField's ^FH pass carries them. - const chips = !isTemplate && config.controlChars === true && !gs1; - const whole = chips && escape && config.ctrlFdEncode + // The plain grammar encodes a control byte in its invocation form. Any other m leaves it to ^FH, + // and so does a template, as ^FE tokens cannot survive inside the invocation form. + const wire = !isTemplate && config.controlChars === true && !gs1 && escape && config.ctrlFdEncode ? (s: string) => planCode128Fd(s, 'whole').fd - : chips && escape - ? (s: string) => escape(resolveControlMarkers(s)) - : chips ? resolveControlMarkers : escape; - return !base ? whole : !whole ? base : (s: string) => whole(base(s)); + : escape; + return !base ? wire : !wire ? base : (s: string) => wire(base(s)); } : undefined; @@ -175,7 +174,6 @@ export function createBarcode1DCore(config: Barcode1DCoreConfig): ObjectTypeCore gs1Capable: config.gs1Capable, controlChars: config.controlChars, ctrlNeedsOwnEscape: config.ctrlFdEncode !== undefined, - ctrlResolvesBound: config.controlChars === true, preflight: moduleTooSmallPreflight('moduleWidth'), @@ -245,9 +243,6 @@ export function createBarcode1DCore(config: Barcode1DCoreConfig): ObjectTypeCore fdTransformOnce = undefined; } } - // Control bytes on non-template payloads take the symbology's own - // escape via the fdTransformFor ctrlEncode wrap; templates keep ^FH - // (^FE tokens cannot survive inside the escape form). const fieldData = obj.props.serial && !p.gs1 // GS1 excluded (legacy models may carry both flags): the alnum seed // filter would eat the >8 FNC1 and merge AI values. Serial seeds skip diff --git a/packages/core/src/registry/datamatrix.ts b/packages/core/src/registry/datamatrix.ts index 70b853d5..aa8debbf 100644 --- a/packages/core/src/registry/datamatrix.ts +++ b/packages/core/src/registry/datamatrix.ts @@ -7,7 +7,7 @@ import { typedGs1ToDataMatrixFd, } from '../lib/dataMatrixFd'; import { hasTemplateMarkers } from '../lib/fnTemplate'; -import { isLoneMarker } from '../lib/variableField'; +import { isLoneMarker } from '../types/Variable'; import { planGs1Fd } from '../lib/gs1Plan'; import { moduleTooSmallPreflight } from '../lib/barcodeScannability'; import { type ZplRotation, ROTATION_SPEC } from './rotation'; diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 2cdf6071..f6131af0 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -2,9 +2,10 @@ import { code128Mode } from '../lib/code128Plan'; import type { ObjectTypeCore } from '../types/ObjectType'; import { hasTemplateMarkers } from '../lib/fnTemplate'; import { gs1CarrierFor, planGs1Fd } from '../lib/gs1Plan'; -import type { LeafType, ObjectRegistryMap } from './leafObject'; +import type { LeafObject, LeafType, ObjectRegistryMap } from './leafObject'; import { resolveContentSpec, type ContentSpec } from './contentSpec'; import type { CtrlParity } from '../lib/markerResolve'; +import { withChipPass } from './zplHelpers'; export type { LeafObject, LeafType } from './leafObject'; @@ -169,13 +170,18 @@ export function objectResolvesCtrl(obj: { type: string; props?: object }): boole return entry?.controlChars === true && !isGs1Active(entry, obj.props); } +/** fdFieldFor's single-bind transform for a leaf's own entry, so recall and emit agree. + * The parser keeps reading `fdTransform` raw, since its question is whether an encoder exists. */ +export function loneBindFdTransform(obj: LeafObject): ((payload: string) => string) | undefined { + const wire = getEntry(obj.type)?.fdTransform?.(obj); + return objectResolvesCtrl(obj) ? withChipPass(wire) : wire; +} + /** {@link objectResolvesCtrl} refined by how the byte survives to the symbol, * so a preview renders what the firmware prints (see {@link CtrlParity}). */ export function ctrlParityFor(obj: { type: string; props?: object }): CtrlParity { if (!objectResolvesCtrl(obj)) return "literal"; - const entry = getEntry(obj.type); - if (!entry?.ctrlResolvesBound) return "byte"; - return usesPlainCode128Escape(entry, obj.props) ? "byteOutsideTemplate" : "byteWhole"; + return usesPlainCode128Escape(getEntry(obj.type), obj.props) ? "byteOutsideTemplate" : "byte"; } /** The object's effective content spec: its entry's rule resolved against props. */ diff --git a/packages/core/src/registry/zplHelpers.ts b/packages/core/src/registry/zplHelpers.ts index 61cc9e16..2bd76be7 100644 --- a/packages/core/src/registry/zplHelpers.ts +++ b/packages/core/src/registry/zplHelpers.ts @@ -268,6 +268,11 @@ export function fdField(payload: string, arm = ''): string { return `^FH${FH_DELIM}${arm}^FD${escaped}^FS`; } +/** The chip pass runs first so the wire form escapes the byte, not the marker text. */ +export function withChipPass(wire?: (payload: string) => string): (payload: string) => string { + return wire ? (s) => wire(resolveControlMarkers(s)) : resolveControlMarkers; +} + /** Single-bind content (`«name»`) emits `^FN{n}` + default; a template expands * to `^FE` embeds, everything else stays literal. `transform` (e.g. GS1 FNC1 * escaping) is applied to the final field-data payload so it composes with @@ -293,7 +298,8 @@ export function fdFieldFor( if (cls.kind === "single") { const v = cls.variable; if (ctx.bareFnSlots?.has(v.fnNumber)) return `^FN${v.fnNumber}^FS`; - return `^FN${v.fnNumber}${fdField(transform(encodeDefault(v.defaultValue)))}`; + const whole = resolveCtrl ? withChipPass(transform) : transform; + return `^FN${v.fnNumber}${fdField(whole(encodeDefault(v.defaultValue)))}`; } } // Template path: arm ^FE/^FC on this field (absent ctx delim = templates not @@ -314,7 +320,7 @@ export function fdFieldFor( payload = markersToTokens(payload, ctx.clockChars); arm = `^FC${ctx.clockChars.date},${ctx.clockChars.time},${ctx.clockChars.tertiary}${arm}`; } - // Control-key chips become their raw byte; fdField hex-escapes it via ^FH. + // Byte before transform, as at withChipPass. if (resolveCtrl && hasControlMarkers(payload)) payload = resolveControlMarkers(payload); return fdField(transform(payload), arm); } diff --git a/packages/core/src/types/ObjectType.ts b/packages/core/src/types/ObjectType.ts index 04831225..6100575c 100644 --- a/packages/core/src/types/ObjectType.ts +++ b/packages/core/src/types/ObjectType.ts @@ -81,14 +81,12 @@ export interface ObjectTypeCore

{ /** Symbology can encode raw control bytes (TAB/CR/...): gates the content * editor's control-key chips and the import's byte-to-chip tokenising. * False/absent for fixed-charset symbologies (EAN/UPC, Code 39) and text. - * Each registry feeds the same constant to its emitter, so flag and chips never desync. */ + * Each registry feeds the same constant to its emitter and clears it under GS1, so flag and chips never desync. */ controlChars?: boolean; /** At ^BC m=N, control bytes reach this symbology's symbol only through its * own escape form, since the firmware drops ^FH hex there. Set by the * emitters that carry such an encoder. */ ctrlNeedsOwnEscape?: boolean; - /** The type's `fdTransform` resolves control chips in a whole bound value. */ - ctrlResolvesBound?: boolean; /** 1:1 aspect-locked free resize (ellipse with lockAspect). For * integer-module 2D symbologies use uniformScaleProp instead. */ uniformScale?: boolean | ((props: P) => boolean); @@ -107,7 +105,8 @@ export interface ObjectTypeCore

{ toZPL: (obj: LabelObjectBase & { props: P }, ctx?: ZplEmitContext) => string; /** Optional ^FD payload transform (QR `{ec}A,` prefix, UPC-E compaction, GS1 * FNC1 escaping). Returned per object so the single emit AND the CSV batch - * ^FN override apply the same transform; undefined when none applies. */ + * ^FN override apply the same transform through loneBindFdTransform. Undefined + * when none applies. */ fdTransform?: ( obj: LabelObjectBase & { props: P }, ) => ((payload: string) => string) | undefined; diff --git a/packages/core/src/types/Variable.ts b/packages/core/src/types/Variable.ts index 80cfe6de..177d715f 100644 --- a/packages/core/src/types/Variable.ts +++ b/packages/core/src/types/Variable.ts @@ -69,6 +69,10 @@ export const markerRe = (): RegExp => /«([^»]+)»/g; /** Whole string is exactly one marker: the single-bind shape. */ export const SINGLE_MARKER_RE = /^«([^»]+)»$/; +export function isLoneMarker(content: string): boolean { + return SINGLE_MARKER_RE.test(content); +} + /** Strip variable-marker delimiters from a literal value. A default/fallback * can't carry a `«…»` marker: preview would resolve it while single-bind export * emits it verbatim (a phantom re-bind / silent drift). Single source for that diff --git a/src/components/Barcode/ContentBuilderModal.tsx b/src/components/Barcode/ContentBuilderModal.tsx index 3e103186..c561a98c 100644 --- a/src/components/Barcode/ContentBuilderModal.tsx +++ b/src/components/Barcode/ContentBuilderModal.tsx @@ -135,7 +135,8 @@ function ContentBuilder({ objectId }: { objectId: string }) { const columnMapping = useLabelStore((s) => s.columnMapping); const markerErrors = typedContentMarkerFindings(type, fields, variables, dataset, columnMapping); const valid = isContentComplete(type, validationFields, fields) && Object.keys(markerErrors).length === 0; - const ec = recommendedEc(resolveDefaults(content)); + const resolveCtrl = target ? objectResolvesCtrl(target) : false; + const ec = recommendedEc(resolveDefaults(content, { resolveCtrl })); // EC recommendation is QR-only; DataMatrix uses fixed ECC200. const isQr = target?.type === "qrcode"; const currentEc = @@ -252,12 +253,10 @@ function ContentBuilder({ objectId }: { objectId: string }) {

{tc.preview} - {tc.charsFmt.replace("{n}", String(resolvedContentLength(content, variables, target ? objectResolvesCtrl(target) : false)))} + {tc.charsFmt.replace("{n}", String(resolvedContentLength(content, variables, resolveCtrl)))}
- {/* Print preview: markers resolved in the assembled payload, - matching the GS1 modal's semantics. */} - {resolveDefaults(content)} + {resolveDefaults(content, { resolveCtrl })} {isQr && (
diff --git a/src/lib/fnTemplate.test.ts b/src/lib/fnTemplate.test.ts index 167aff9e..c7762ac2 100644 --- a/src/lib/fnTemplate.test.ts +++ b/src/lib/fnTemplate.test.ts @@ -169,6 +169,12 @@ describe("resolvedContentLength", () => { expect(resolvedContentLength("A«ctrl:TAB»B", [], true)).toBe(3); expect(resolvedContentLength("A«ctrl:TAB»B", [])).toBe("A«ctrl:TAB»B".length); }); + it("counts a bound value's chips as bytes only on a lone bind", () => { + const bound = [{ id: "v", name: "v", fnNumber: 1, defaultValue: "A«ctrl:TAB»B" }]; + expect(resolvedContentLength("«v»", bound, true)).toBe(3); + expect(resolvedContentLength("«v»", bound)).toBe("A«ctrl:TAB»B".length); + expect(resolvedContentLength("x«v»y", bound, true)).toBe("xA«ctrl:TAB»By".length); + }); it("handles empty defaults and plain literals", () => { expect(resolvedContentLength("abc", VARS)).toBe(3); expect(resolvedContentLength("«e»", [{ id: "v", name: "e", fnNumber: 3, defaultValue: "" }])).toBe(0); diff --git a/src/lib/variableBinding.test.ts b/src/lib/variableBinding.test.ts index 1063c24a..97802223 100644 --- a/src/lib/variableBinding.test.ts +++ b/src/lib/variableBinding.test.ts @@ -243,7 +243,7 @@ describe('applyBindingToObject', () => { const vars = [{ id: 'v1', name: 'sku', fnNumber: 1, defaultValue: 'A\tB' }]; const out = applyBindingToObject(o, vars, null, 'preview', undefined, 'byteOutsideTemplate'); expect((out as unknown as { props: { content: string } }).props.content).toBe('A\tB'); - // A chip inside the lone-bind default resolves too, like fdTransformFor. + // A chip inside the lone-bind default resolves too, like withChipPass. const chipVars = [{ id: 'v1', name: 'sku', fnNumber: 1, defaultValue: 'A«ctrl:TAB»B' }]; const outChip = applyBindingToObject(o, chipVars, null, 'preview', undefined, 'byteOutsideTemplate'); expect((outChip as unknown as { props: { content: string } }).props.content).toBe('A\tB');