Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions packages/core/src/lib/code128Subset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/lib/code128Subset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

/**
Expand Down
49 changes: 37 additions & 12 deletions packages/core/src/lib/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, string[]>();
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<number>,
leafPred: (leaf: LeafObject) => boolean,
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
45 changes: 24 additions & 21 deletions packages/core/src/lib/zplParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>; templateFns: Set<number> } {
const fnByVarName = new Map(variables.map((v) => [v.name, v.fnNumber]));
const loneFns = new Set<number>();
const templateFns = new Set<number>();
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
Expand All @@ -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<number>();
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);
}
Expand All @@ -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<number>();
const templateFns = new Set<number>();
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;
Expand Down
51 changes: 28 additions & 23 deletions packages/core/src/lib/zplParser/flushField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
31 changes: 13 additions & 18 deletions packages/core/src/registry/barcode1d.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))) {
Expand All @@ -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).
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/registry/hriFormatters.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -74,9 +75,10 @@ export function formatUpcEanExtensionHri(content: string): string {
/** Firmware prints no glyph for a control byte in the interpretation line
* (ZD230-verified: `AB<HT>CD…` 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,
Expand Down
10 changes: 8 additions & 2 deletions packages/core/src/types/controlKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,19 @@ const byteToKey = new Map<string, ControlKeyName>(
// 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
Expand Down
Loading
Loading