diff --git a/packages/core/src/lib/code128Subset.test.ts b/packages/core/src/lib/code128Subset.test.ts index d4e0b844..17f954ab 100644 --- a/packages/core/src/lib/code128Subset.test.ts +++ b/packages/core/src/lib/code128Subset.test.ts @@ -154,6 +154,35 @@ describe("code128FdToSymbols", () => { }); }); +describe("strict/lenient reader drift", () => { + it("the lenient reader reproduces every emitted invocation plan", () => { + // Deterministic LCG so failures reproduce; the two Table-2 readers are + // separate by design (adoption gate vs firmware emulation), this pins + // that they agree on everything the emitter can produce. + let seed = 0x2f6e2b1; + const rnd = () => (seed = (seed * 48271) % 0x7fffffff) / 0x7fffffff; + const alphabet = "AZaz09 >^~\t\r\n\x1D\x1C\x01\x7F"; + for (let i = 0; i < 2000; i++) { + const len = 1 + Math.floor(rnd() * 12); + let text = ""; + for (let j = 0; j < len; j++) text += alphabet[Math.floor(rnd() * alphabet.length)]; + const plan = planCode128Symbols(text); + if (!plan) continue; + const fd = code128SymbolsToFd(plan); + expect(code128FdToSymbols(fd), JSON.stringify(text)).toEqual(plan); + // The strict reader recovers the exact bytes for every emitted form. + expect(code128FdToBytes(fd), JSON.stringify(text)).toBe(text); + } + }); +}); + +describe("DEL (0x7F)", () => { + it("takes the invocation path instead of shipping a raw byte", () => { + expect(code128ControlFd("A\x7FB")).toBe(">:A>1B"); + expect(code128FdToBytes(">:A>1B")).toBe("A\x7FB"); + }); +}); + describe("code128FdToDisplayText", () => { it("decodes an escape stream to the interpretation-line data (spec p.98 Fig 3/4)", () => { expect(code128FdToDisplayText(">:CODE128")).toBe("CODE128"); diff --git a/packages/core/src/lib/code128Subset.ts b/packages/core/src/lib/code128Subset.ts index 1349a996..052d75e3 100644 --- a/packages/core/src/lib/code128Subset.ts +++ b/packages/core/src/lib/code128Subset.ts @@ -6,6 +6,7 @@ * (shared ^FN slots excepted: they emit raw and preflight warns). */ import { mapLiteralSpans } from "./fnTemplate"; +import { C0_OR_DEL_RE } from "../types/controlKey"; const START_A = 103; const START_B = 104; @@ -34,11 +35,10 @@ function charFromB(value: number): string { return String.fromCharCode(value === 95 ? 127 : value + 32); } -// eslint-disable-next-line no-control-regex -const C0_BYTE = /[\x00-\x1F]/; - +/** DEL included: Subset B carries it as value 95 (`>1`), so it takes the + * invocation path like the C0 range instead of shipping as a raw byte. */ export function hasControlBytes(text: string): boolean { - return C0_BYTE.test(text); + return C0_OR_DEL_RE.test(text); } /** diff --git a/packages/core/src/lib/preflight.ts b/packages/core/src/lib/preflight.ts index e9463e65..1dd44926 100644 --- a/packages/core/src/lib/preflight.ts +++ b/packages/core/src/lib/preflight.ts @@ -6,8 +6,8 @@ import { GS1_GS, parseGs1ToSegments, validateGs1Segment, validateGs1SegmentResol import { DATAMATRIX_FD_ESCAPE } from "./dataMatrixFd"; import { extractTemplateRefs, hasTemplateMarkers, pickEmbedChar } from "./fnTemplate"; import { hasClockMarkers, pickClockChars } from "./fcTemplate"; -import { code128EscapeLiterals } from "./code128Subset"; -import { hasControlMarkers, resolveControlMarkers } from "../types/controlKey"; +import { code128ControlFd, code128EscapeLiterals, hasControlBytes } from "./code128Subset"; +import { C0_RE, resolveControlMarkers } from "../types/controlKey"; import { classifyField, isLoneMarker } from "./variableField"; import { parseContent, typedContentIncompleteRows, typedContentMarkerFindings } from "./typedContent"; import { getObjectStringContent, resolveForRow, variableSubstitutions } from "./variableBinding"; @@ -196,6 +196,18 @@ export function markerValueFindings( // shared slots emit raw; exclusive plain-^BC slots still leak `>` before an // invocation char, which the escape leaves verbatim by design. const byName = new Map(deps.variables.map((v) => [v.name, v])); + // One CSV row-walk per variable per run, not per warning channel. Values + // are chip-resolved: the emit resolves them too, so the dirty predicates + // must see the byte, not the marker text. + const subsCache = new Map(); + const substitutionsOf = (v: Variable): string[] => { + let subs = subsCache.get(v.id); + if (!subs) { + subs = variableSubstitutions(v, deps.dataset, deps.columnMapping).map(resolveControlMarkers); + subsCache.set(v.id, subs); + } + return subs; + }; const slotValueWarnings = ( slots: Set, leafPred: (leaf: LeafObject) => boolean, @@ -211,7 +223,7 @@ export function markerValueFindings( for (const name of new Set(extractTemplateRefs(content))) { const v = byName.get(name); if (!v || !slots.has(v.fnNumber)) continue; - if (variableSubstitutions(v, deps.dataset, deps.columnMapping).some(dirtyPred)) { + if (substitutionsOf(v).some(dirtyPred)) { dirty.push(v.name); } } @@ -243,18 +255,25 @@ export function markerValueFindings( (val) => />[0-9:;<=]/.test(val), (names) => `">" before an invocation character in ${names} prints as a barcode invocation, not text`, ); - // eslint-disable-next-line no-control-regex - const hasC0 = (val: string) => /[\x00-\x1F]/.test(val); + const hasC0 = (val: string) => C0_RE.test(val); const c0Message = (names: string) => `control bytes in ${names} are dropped from the printed symbol (^FH path)`; - // Exclusive slots: only a lone bind encodes control bytes losslessly - // (invocation form); a template keeps ^FH where the firmware drops them. + // Exclusive slots: a lone bind encodes control bytes losslessly (invocation + // form), a template keeps ^FH where the firmware drops them. slotValueWarnings( buckets.plainExclusive, (leaf) => plainLeaf(leaf) && !isLoneMarker(getObjectStringContent(leaf) ?? ""), hasC0, c0Message, ); + // Lone binds still lose when the value defeats the invocation plan (a byte + // no subset carries): the emit then falls back to ^FH. + slotValueWarnings( + buckets.plainExclusive, + (leaf) => plainLeaf(leaf) && isLoneMarker(getObjectStringContent(leaf) ?? ""), + (val) => hasC0(val) && code128ControlFd(resolveControlMarkers(val)) === null, + c0Message, + ); // Shared slots emit raw/^FH even for a lone bind, so no exemption there. slotValueWarnings( buckets.plainShared, @@ -351,11 +370,17 @@ export function computePreflight( const inv = `${[...new Set(invocations)].map((s) => `"${s}"`).join(", ")} read as barcode invocation codes, not text`; detail = detail ? `${detail}; ${inv}` : inv; } - // Chips alongside other markers keep the lossy ^FH path (emitter - // gate), where the firmware drops the bytes from the symbol. - if (hasControlMarkers(content) && hasTemplateMarkers(resolveControlMarkers(content))) { - const chips = "control chips in a template field are dropped from the printed symbol"; - detail = detail ? `${detail}; ${chips}` : chips; + // Control bytes (chips or raw) beside other markers (^FH path) or + // beside a byte no subset carries (invocation plan bails) both drop + // from the symbol. + const resolved = resolveControlMarkers(content); + if (hasControlBytes(resolved)) { + const drop = hasTemplateMarkers(resolved) + ? "control bytes in a template field are dropped from the printed symbol" + : code128ControlFd(resolved) === null + ? "control bytes are dropped from the printed symbol (payload has a character Code 128 cannot encode)" + : null; + if (drop) detail = detail ? `${detail}; ${drop}` : drop; } } if (detail) { diff --git a/packages/core/src/lib/zplParser.ts b/packages/core/src/lib/zplParser.ts index 6eb9cf28..d1f24cc0 100644 --- a/packages/core/src/lib/zplParser.ts +++ b/packages/core/src/lib/zplParser.ts @@ -37,6 +37,27 @@ export type { import type { LabelObject } from "../types/Group"; import type { Variable } from "../types/Variable"; +/** Lone-marker vs. template consumers per ^FN slot; shared by both default + * normalizers so their whole-payload criterion cannot drift. */ +function fnConsumerShapes( + objects: readonly LabelObject[], + variables: readonly Variable[], +): { loneFns: Set; templateFns: Set } { + const fnByVarName = new Map(variables.map((v) => [v.name, v.fnNumber])); + const loneFns = new Set(); + const templateFns = new Set(); + for (const o of objects) { + const c = getObjectStringContent(o); + if (c === undefined || !hasTemplateMarkers(c)) continue; + const target = isLoneMarker(c) ? loneFns : templateFns; + for (const name of extractTemplateRefs(c)) { + const fn = fnByVarName.get(name); + if (fn !== undefined) target.add(fn); + } + } + return { loneFns, templateFns }; +} + /** 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 @@ -46,17 +67,10 @@ import type { Variable } from "../types/Variable"; 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); - } + const { loneFns } = fnConsumerShapes(objects, variables); for (const v of variables) { if (!modeDFns.has(v.fnNumber)) continue; - v.defaultValue = loneMarkerFns.has(v.fnNumber) + v.defaultValue = loneFns.has(v.fnNumber) ? (zplFdToModelContent(v.defaultValue) ?? unescapeGs1FdValue(v.defaultValue)) : unescapeGs1FdValue(v.defaultValue); } @@ -69,18 +83,7 @@ function normalizeModeDDefaults(objects: readonly LabelObject[], variables: Vari function normalizeCode128PlainDefaults(objects: readonly LabelObject[], variables: Variable[]): boolean { const plainFns = code128PlainExclusiveFns(objects, variables); if (plainFns.size === 0) return false; - const fnByVarName = new Map(variables.map((v) => [v.name, v.fnNumber])); - const loneFns = new Set(); - const templateFns = new Set(); - for (const o of objects) { - const c = getObjectStringContent(o); - if (c === undefined || !hasTemplateMarkers(c)) continue; - const target = isLoneMarker(c) ? loneFns : templateFns; - for (const name of extractTemplateRefs(c)) { - const fn = fnByVarName.get(name); - if (fn !== undefined) target.add(fn); - } - } + const { loneFns, templateFns } = fnConsumerShapes(objects, variables); let regenLossy = false; for (const v of variables) { if (!plainFns.has(v.fnNumber)) continue; diff --git a/packages/core/src/lib/zplParser/flushField.ts b/packages/core/src/lib/zplParser/flushField.ts index b2955cfd..0cdc2205 100644 --- a/packages/core/src/lib/zplParser/flushField.ts +++ b/packages/core/src/lib/zplParser/flushField.ts @@ -59,6 +59,33 @@ export interface FlushFieldDeps { takeComment: () => string | undefined; } +/** Adopt a plain-^BC ^FD into model bytes only when the emit re-encodes it + * byte-identically (an uncatalogued C0 stays a raw byte; a compacted + * Subset-C or FNC stream stays verbatim and re-exports unchanged). Payloads + * a regen would rewrite flag `bcFdRegenLossy` instead. */ +function adoptCode128Fd(content: string, s: ParserState): string { + const bytes = hasTemplateMarkers(content) ? null : code128FdToBytes(content); + const unescaped = hasTemplateMarkers(content) ? code128DecodeLiterals(content) : null; + if (bytes !== null + && (hasControlBytes(bytes) + ? code128ControlFd(bytes) === content + : code128PlainFd(bytes) === content)) { + return bytes; + } + if (unescaped !== null && code128EscapeLiterals(unescaped) === content) { + // Marker-bearing payload: the emit escaped the literal spans before + // tokenization, so reverse that (same byte-identity gate). + return unescaped; + } + if (code128PlainFd(content) !== content || code128ControlFd(content) !== null) { + // Regen rewrites this field (bare `>` re-escaped, ^FH-imported control + // bytes become invocations; unencodable bytes keep the identical ^FH + // path), so byte exactness only holds through the page's overlay. + s.bcFdRegenLossy = true; + } + return content; +} + /** Field-emit closure: turns cached s.field into a pushed LabelObject at ^FS. */ export function createFlushField( s: ParserState, @@ -161,29 +188,7 @@ export function createFlushField( // ^FN defaults are excluded: their escape depends on slot exclusivity, // which only the page-close pass knows (normalizeCode128PlainDefaults). if (!gs1Field && s.field.fieldType === "code128" && s.comment.fnNumber === null) { - // Adopt the decode only when the emit re-escapes it byte-identically, - // so adoption never changes the ZPL or the symbol (a compacted Subset-C - // stream, an FNC stream etc. stay verbatim and re-export unchanged). - // Byte-identical re-emit is the whole criterion: an uncatalogued C0 - // stays a raw byte in the model (no chip), which the emit re-encodes - // the same way. - const bytes = hasTemplateMarkers(content) ? null : code128FdToBytes(content); - const unescaped = hasTemplateMarkers(content) ? code128DecodeLiterals(content) : null; - if (bytes !== null - && (hasControlBytes(bytes) - ? code128ControlFd(bytes) === content - : code128PlainFd(bytes) === content)) { - content = bytes; - } else if (unescaped !== null && code128EscapeLiterals(unescaped) === content) { - // Marker-bearing payload: the emit escaped the literal spans before - // tokenization, so reverse that (same byte-identity gate). - content = unescaped; - } else if (code128PlainFd(content) !== content || code128ControlFd(content) !== null) { - // Regen rewrites this field (bare `>` re-escaped, ^FH-imported control - // bytes become invocations; unencodable bytes keep the identical ^FH - // path), so byte exactness only holds through the page's overlay. - s.bcFdRegenLossy = true; - } + content = adoptCode128Fd(content, s); } if (!gs1Field && s.field.fieldType && getEntry(s.field.fieldType)?.controlChars) { content = controlBytesToMarkers(content); diff --git a/packages/core/src/registry/barcode1d.ts b/packages/core/src/registry/barcode1d.ts index 26cfe142..b13e0ec6 100644 --- a/packages/core/src/registry/barcode1d.ts +++ b/packages/core/src/registry/barcode1d.ts @@ -1,7 +1,7 @@ import type { LabelObjectBase, ObjectGroup } from '../types/LabelObject'; import type { ObjectTypeCore } from '../types/ObjectType'; import type { HriBehavior } from '../types/ZplEmit'; -import { fieldPos1d, fdField, fdFieldFor } from './zplHelpers'; +import { fieldPos1d, fdFieldFor } from './zplHelpers'; import { serialFieldData, type SerialMode } from './serialField'; import { commitBarcodeWidthHeightTransform } from './transformHelpers'; import { hasTemplateMarkers } from '../lib/fnTemplate'; @@ -96,10 +96,14 @@ export function createBarcode1DCore(config: Barcode1DCoreConfig): ObjectTypeCore // GS1 mode brings its own escaping. const escape = obj.props.gs1 ? undefined : config.fdPlainEscape; // A lone marker (single-bind) still transforms its default/CSV value; - // only a real template is skipped. The plain escape applies either - // way: it touches literal text, never an embed reference. + // only a real template is skipped. Ctrl-capable types resolve chips + // first: chips-only is non-template there (whole-field ctrlEncode). const isTemplate = - hasTemplateMarkers(obj.props.content) && !isLoneMarker(obj.props.content); + hasTemplateMarkers( + config.controlChars === true + ? resolveControlMarkers(obj.props.content) + : obj.props.content, + ) && !isLoneMarker(obj.props.content); const base = isTemplate ? undefined : config.gs1Capable && obj.props.gs1 @@ -201,7 +205,7 @@ export function createBarcode1DCore(config: Barcode1DCoreConfig): ObjectTypeCore // Template payload: escape literal spans BEFORE ^FE/^FC tokenization. A // clock char may be '=' or '<', so a post-token escape would read a // literal `>` next to a token as an invocation and skip the `>0`. - // Chips-only payloads stay raw for the ctrlFd invocation plan below; + // Chips-only payloads stay raw for fdTransformFor's invocation plan; // marker BODIES stay raw too (names may carry >/^/~). if (!p.gs1 && config.fdPlainEscape && !isLoneMarker(content) && hasTemplateMarkers(resolveControlMarkers(content))) { @@ -216,19 +220,10 @@ export function createBarcode1DCore(config: Barcode1DCoreConfig): ObjectTypeCore fdTransformOnce = undefined; } } - // Literal control bytes take the symbology's own escape (^FH hex is - // dropped by the firmware), gated on the RESOLVED bytes so an imported - // raw byte matches the canvas plan; templates keep ^FH (^FE tokens - // cannot survive inside the escape form). - let ctrlFd: string | null = null; - if (config.ctrlFdEncode && config.controlChars === true && !p.gs1 - && !obj.props.serial) { - const resolved = resolveControlMarkers(content); - if (!hasTemplateMarkers(resolved)) ctrlFd = config.ctrlFdEncode(resolved); - } - const fieldData = ctrlFd !== null - ? fdField(ctrlFd) - : obj.props.serial + // 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 // Serial seeds skip fdPlainEscape: ^SN data is filtered alphanumeric, // and an injected `>0` would leave a stray 0 in the ^SF mask. A // template seed skips the base transform too (pre-escape behaviour). diff --git a/packages/core/src/registry/hriFormatters.ts b/packages/core/src/registry/hriFormatters.ts index 1649366e..38af3927 100644 --- a/packages/core/src/registry/hriFormatters.ts +++ b/packages/core/src/registry/hriFormatters.ts @@ -1,5 +1,6 @@ import { code11CheckDigits, eanCheckDigit, upceCheckDigit } from '../lib/barcodeCheckDigits'; import { code128FdToDisplayText, code128PlainFd } from '../lib/code128Subset'; +import { C0_OR_DEL_RE } from '../types/controlKey'; /** * HRI text formatters per 1D symbology. Each takes the user-provided @@ -74,9 +75,10 @@ export function formatUpcEanExtensionHri(content: string): string { /** Firmware prints no glyph for a control byte in the interpretation line * (ZD230-verified: `ABCD…` prints `ABCDEFGH`), so strip them from every * 1D HRI line before layout, which is length-based. */ +const C0_OR_DEL_RE_G = new RegExp(C0_OR_DEL_RE.source, 'g'); + export function stripHriControlBytes(text: string): string { - // eslint-disable-next-line no-control-regex - return text.replace(/[\x00-\x1F\x7F]/g, ''); + return text.replace(C0_OR_DEL_RE_G, ''); } /** Plain ^BC HRI shows the DECODED data, never invocation codes (spec p.98, diff --git a/packages/core/src/types/controlKey.ts b/packages/core/src/types/controlKey.ts index a8d2f580..28fd7486 100644 --- a/packages/core/src/types/controlKey.ts +++ b/packages/core/src/types/controlKey.ts @@ -55,13 +55,19 @@ const byteToKey = new Map( // Derived from the catalogue so a new key cannot desync the import side. const CTRL_BYTE_RE = new RegExp(`[${Object.values(CONTROL_KEYS).join("")}]`, "g"); +/** Single source for the two control-byte classes: C0 proper, and C0 plus + * DEL (Code 128 Subset B carries DEL as value 95). */ // eslint-disable-next-line no-control-regex -const C0_RE = /[\x00-\x1F]/g; +export const C0_RE = /[\x00-\x1F]/; +// eslint-disable-next-line no-control-regex +export const C0_OR_DEL_RE = /[\x00-\x1F\x7F]/; + +const C0_RE_G = new RegExp(C0_RE.source, "g"); /** Drop every control byte, mirroring what a symbology that cannot encode them * on the ^FH path actually prints (Code 128 inside a template payload). */ export function stripControlBytes(content: string): string { - return content.replace(C0_RE, ""); + return content.replace(C0_RE_G, ""); } /** Raw control byte -> `«ctrl:…»` chip, the import symmetry of diff --git a/src/lib/preflight.test.ts b/src/lib/preflight.test.ts index 524e1672..74e469af 100644 --- a/src/lib/preflight.test.ts +++ b/src/lib/preflight.test.ts @@ -392,13 +392,22 @@ describe("computePreflight (suspicious-chars producer)", () => { expect(computePreflight([bc("d", "A>5B", { serial: { start: 1 } })], ctx, "mm") .some((x) => x.kind === "suspiciousChars")).toBe(false); - // Chips alongside another marker keep the lossy ^FH path (emitter gate), - // so the dropped bytes must badge; a chips-only field encodes fine. + // Control bytes (chips or raw) alongside another marker keep the lossy + // ^FH path (emitter gate), so the drop must badge; an encodable + // chips-only or raw-byte field stays quiet. const chipTemplate = computePreflight([bc("e", "A«ctrl:TAB»B«sku»")], ctx, "mm") .find((x) => x.kind === "suspiciousChars"); - expect(chipTemplate?.detail).toContain("control chips"); + expect(chipTemplate?.detail).toContain("control bytes"); expect(computePreflight([bc("f", "A«ctrl:TAB»B")], ctx, "mm") .some((x) => x.kind === "suspiciousChars")).toBe(false); + // A byte beside a character no subset carries loses the invocation plan + // and falls to ^FH: badge that too, chip or raw alike. + const chipUnencodable = computePreflight([bc("g", "Ä«ctrl:TAB»B")], ctx, "mm") + .find((x) => x.kind === "suspiciousChars"); + expect(chipUnencodable?.detail).toContain("cannot encode"); + const rawUnencodable = computePreflight([bc("h", "Ä\tB")], ctx, "mm") + .find((x) => x.kind === "suspiciousChars"); + expect(rawUnencodable?.detail).toContain("cannot encode"); }); it("still flags a control char in a non-GS1 field", () => { @@ -640,6 +649,16 @@ describe("markerValueFindings (mode-D shared ^FN slot)", () => { const shared = markerValueFindings( [code128("c", "«batch»", false), text("t", "L «batch» R")], deps); expect(shared.some((f) => f.detail?.includes("control bytes"))).toBe(true); + // Exclusive lone bind loses too when the value defeats the invocation + // plan (a byte no subset carries forces the ^FH fallback), whether the + // byte arrives raw or as a chip marker in the default. + for (const dv of ["ä\tB", "Ä«ctrl:TAB»B"]) { + const unencodable = [{ id: "v", name: "batch", fnNumber: 1, defaultValue: dv }]; + const out2 = markerValueFindings( + [code128("c", "«batch»", false)], + { variables: unencodable, dataset: null, columnMapping: null }); + expect(out2.some((f) => f.detail?.includes("control bytes")), JSON.stringify(dv)).toBe(true); + } }); it("warns when the payloads exhaust every ^FC candidate (markers print literally)", () => {