diff --git a/packages/core/src/lib/barcodeDims.ts b/packages/core/src/lib/barcodeDims.ts index ec76fcba..1ef978b9 100644 --- a/packages/core/src/lib/barcodeDims.ts +++ b/packages/core/src/lib/barcodeDims.ts @@ -18,7 +18,8 @@ import { gtin14WithCheck, } from "./gs1"; import { planGs1Fd } from "./gs1Plan"; -import { code128ControlBwipRaw, code128FdToSymbols, code128PlainFd, code128SymbolsToBwipRaw } from "./code128Subset"; +import { code128FdToSymbols, code128SymbolsToBwipRaw } from "./code128Subset"; +import { planCode128Fd } from "./code128Plan"; import { isRectangular, dmVersionString, type DataMatrixProps } from "../registry/datamatrix"; import { MAXICODE_WIDTH_MM, MAXICODE_HEIGHT_MM } from "../registry/maxicode"; import { micropdf417ModeDims, micropdf417ModeFits } from "../registry/micropdf417"; @@ -249,19 +250,6 @@ export function getEanUpcHriFragmentsWith( return txt.map((t) => ({ char: t[0], xModule: t[1] })); } -// Forcing Code B keeps module count in sync with Labelary (^BC default). -// Returns null for chars outside ASCII 32..126. -function toCode128BRaw(text: string): string | null { - if (!text) return null; - const parts = ["^104"]; // Start B - for (const ch of text) { - const code = ch.charCodeAt(0); - if (code < 32 || code > 126) return null; - parts.push(`^${String(code - 32).padStart(3, "0")}`); - } - return parts.join(""); -} - export function buildBwipOptions( obj: LeafObject, renderScale?: number, @@ -340,31 +328,12 @@ export function buildBwipOptions( break; } const text = p.content || "0"; - // Control bytes render from the same symbol plan the emitter writes as - // subset invocations, so canvas width equals printed width by - // construction. This is planCode128Fd's whole mode in bwip symbol - // values; keep the two branches in lockstep with code128Plan.ts. - const ctrlRaw = code128ControlBwipRaw(text); - if (ctrlRaw) { - opts = { bcid, text: ctrlRaw, raw: true, scale, height: 10 }; - break; - } - // Interpret the EMITTED payload: the printer only reads the - // fdPlainEscape form, and a bare `>` diverges from the model content. - const fdText = code128PlainFd(text); - if (/>[0-9:;<=]/.test(fdText)) { - const symbols = code128FdToSymbols(fdText); - if (symbols) { - opts = { bcid, text: code128SymbolsToBwipRaw(symbols), raw: true, scale, height: 10 }; - break; - } - } - const rawB = toCode128BRaw(text); - if (rawB) { - opts = { bcid, text: rawB, raw: true, scale, height: 10 }; - } else { - opts = { bcid, text, scale, height: 10 }; - } + // The printer reads the emitted ^FD, not the model text, so the canvas + // draws its Table-2 read; a byte no subset carries is left to bwip. + const symbols = code128FdToSymbols(planCode128Fd(text, "whole").fd); + opts = symbols + ? { bcid, text: code128SymbolsToBwipRaw(symbols), raw: true, scale, height: 10 } + : { bcid, text, scale, height: 10 }; break; } case "code39": diff --git a/packages/core/src/lib/code128Subset.test.ts b/packages/core/src/lib/code128Subset.test.ts index 17f954ab..49e10a16 100644 --- a/packages/core/src/lib/code128Subset.test.ts +++ b/packages/core/src/lib/code128Subset.test.ts @@ -1,21 +1,43 @@ import { describe, expect, it } from "vitest"; import bwipjs from "bwip-js/generic"; import { - code128ControlBwipRaw, code128ControlFd, code128FdToBytes, code128FdToDisplayText, code128FdToSymbols, code128PlainFd, + code128SymbolsToBwipRaw, code128SymbolsToFd, + hasControlBytes, planCode128Symbols, } from "./code128Subset"; -import { measureBarcodeFootprintDotsWith, resolveForMeasure, type BwipEngine } from "./barcodeDims"; +import { buildBwipOptions, measureBarcodeFootprintDotsWith, resolveForMeasure, type BwipEngine } from "./barcodeDims"; import type { LeafObject } from "../registry"; import type { LabelObject } from "../types/Group"; const engine = bwipjs as unknown as BwipEngine; +const code128 = (content: string): LeafObject => + ({ + id: "b", type: "code128", x: 0, y: 0, rotation: 0, positionType: "FO", + props: { + content, height: 100, moduleWidth: 2, printInterpretation: false, + printInterpretationAbove: false, checkDigit: false, rotation: "N", + }, + }) as LabelObject as LeafObject; + +/** Deterministic LCG corpus so a failure reproduces. */ +function seededCorpus(seed: number, atoms: readonly string[], count: number, maxLen: number): string[] { + let state = seed; + const rnd = () => (state = (state * 48271) % 0x7fffffff) / 0x7fffffff; + return Array.from({ length: count }, () => { + const len = 1 + Math.floor(rnd() * maxLen); + let text = ""; + for (let j = 0; j < len; j++) text += atoms[Math.floor(rnd() * atoms.length)]; + return text; + }); +} + const HT = "\x09"; const LF = "\x0A"; const CR = "\x0D"; @@ -48,7 +70,6 @@ describe("planCode128Symbols", () => { it("returns null without a control byte (the plain path stays untouched)", () => { expect(code128ControlFd("ABC123")).toBeNull(); - expect(code128ControlBwipRaw("ABC123")).toBeNull(); }); }); @@ -156,16 +177,10 @@ 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)]; + // 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. + for (const text of seededCorpus(0x2f6e2b1, [..."AZaz09 >^~\t\r\n\x1D\x1C\x01\x7F"], 2000, 12)) { const plan = planCode128Symbols(text); if (!plan) continue; const fd = code128SymbolsToFd(plan); @@ -217,15 +232,6 @@ describe("code128PlainFd", () => { }); describe("printed vs rendered module count", () => { - const code128 = (content: string): LeafObject => - ({ - id: "b", type: "code128", x: 0, y: 0, rotation: 0, positionType: "FO", - props: { - content, height: 100, moduleWidth: 2, printInterpretation: false, - printInterpretationAbove: false, checkDigit: false, rotation: "N", - }, - }) as LabelObject as LeafObject; - /** Symbols = plan + check + stop; stop carries 2 modules more than the rest. */ const plannedModules = (text: string) => 11 * (planCode128Symbols(text)!.length + 1) + 13; @@ -270,3 +276,49 @@ describe("printed vs rendered module count", () => { expect(dots?.w).toBe(plainModules(3) * 2); }); }); + +describe("canvas read of the emitted FD", () => { + const START_B = 104; + + /** Printed symbols per payload class: control bytes via the invocation + * plan, invocation sequences via the Table-2 read, plain ASCII as one + * Subset-B symbol per character, a byte no subset carries left to bwip. */ + const printed = (text: string): { text: string; raw: boolean } => { + if (hasControlBytes(text)) { + const values = planCode128Symbols(text); + if (values) return { text: code128SymbolsToBwipRaw(values), raw: true }; + } + const fd = code128PlainFd(text); + if (/>[0-9:;<=]/.test(fd)) { + const symbols = code128FdToSymbols(fd); + if (symbols) return { text: code128SymbolsToBwipRaw(symbols), raw: true }; + } + const values = [START_B]; + for (const ch of text) { + const code = ch.charCodeAt(0); + if (code < 32 || code > 126) return { text, raw: false }; + values.push(code - 32); + } + return { text: code128SymbolsToBwipRaw(values), raw: true }; + }; + + it("renders every payload class as the printer reads it", () => { + const atoms = [ + ..."AZaz09 >^~\t\r\n\x1D\x1C\x01\x7Fä€", + ...[..."0123456789:;<="].map((c) => `>${c}`), + ]; + for (const text of ["", ...seededCorpus(0x5bd1e99, atoms, 3000, 8)]) { + const expected = printed(text || "0"); + const opts = buildBwipOptions(code128(text), 1, 8); + expect(opts?.text, JSON.stringify(text)).toBe(expected.text); + expect(opts?.raw ?? false, JSON.stringify(text)).toBe(expected.raw); + } + }); + + it("resolves a control chip like the whole-field emit does", () => { + // Callers without a binding chain (MCP footprint) hand chips through + // unresolved; the whole-mode plan still encodes the byte the printer gets. + expect(buildBwipOptions(code128("A«ctrl:TAB»B"), 1, 8)?.text) + .toBe(buildBwipOptions(code128(`A${HT}B`), 1, 8)?.text); + }); +}); diff --git a/packages/core/src/lib/code128Subset.ts b/packages/core/src/lib/code128Subset.ts index 052d75e3..70225cdc 100644 --- a/packages/core/src/lib/code128Subset.ts +++ b/packages/core/src/lib/code128Subset.ts @@ -1,9 +1,10 @@ /** Code 128 subset-invocation coding for ^BC field data (spec p.95-99). * * The firmware drops ^FH-escaped C0 bytes from the symbol (ZD230-verified); - * only the invocation form encodes them. Emit and canvas share the planned - * symbol values, so printed module count equals rendered by construction - * (shared ^FN slots excepted: they emit raw and preflight warns). */ + * only the invocation form encodes them. The canvas reads the emitted ^FD + * back through the same Table-2 walk, so printed module count equals + * rendered by construction (shared ^FN slots excepted: they emit raw and + * preflight warns). */ import { mapLiteralSpans } from "./fnTemplate"; import { C0_OR_DEL_RE } from "../types/controlKey"; @@ -144,13 +145,6 @@ export function code128ControlFd(text: string): string | null { return values ? code128SymbolsToFd(values) : null; } -/** {@link code128ControlFd}'s canvas twin: the same plan as bwip raw data. */ -export function code128ControlBwipRaw(text: string): string | null { - if (!hasControlBytes(text)) return null; - const values = planCode128Symbols(text); - return values ? code128SymbolsToBwipRaw(values) : null; -} - /** Shared Table-2 walk over a ^BC ^FD payload: symbol values plus the data * characters the interpretation line shows. `ok` is false when a byte fits no * subset (the only unreadable case under the lenient rules). */