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
80 changes: 80 additions & 0 deletions packages/core/src/lib/boundControlChips.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<LeafType, { boundField: string; parity: CtrlParity; templateConflict: boolean }>>;
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([]);
});
});
11 changes: 1 addition & 10 deletions packages/core/src/lib/code128Auto.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -158,22 +157,14 @@ 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");
expect((resolveForMeasure(bound as LabelObject, variables) as typeof bound).props.content).toBe("A\tB");
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;
Expand Down
16 changes: 11 additions & 5 deletions packages/core/src/lib/fnTemplate.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -68,21 +68,27 @@ 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())) {
len += (m.index ?? 0) - last;
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;
}
Expand Down
23 changes: 10 additions & 13 deletions packages/core/src/lib/markerResolve.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -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());
Expand All @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/lib/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
52 changes: 28 additions & 24 deletions packages/core/src/lib/slotConflicts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,28 @@ 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<number>;
/** Leaves that consume a conflicted slot; findings must not blame others. */
consumerIds: Set<string>;
}

/** 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[],
Expand All @@ -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<Variable>; leafIds: string[] }
{ consumers: SlotAccepts[]; vars: Set<Variable>; 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<Variable>(), leafIds: [] };
slot.encoders.push(encode);
{ consumers: [], vars: new Set<Variable>(), leafIds: [] };
slot.consumers.push(accepts);
slot.vars.add(v);
slot.leafIds.push(leafId);
perSlot.set(v.fnNumber, slot);
Expand All @@ -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<number>();
const consumerIds = new Set<string>();
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);
Expand Down
6 changes: 0 additions & 6 deletions packages/core/src/lib/variableField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/lib/zplGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading