diff --git a/packages/core/src/lib/anchorRepin.ts b/packages/core/src/lib/anchorRepin.ts new file mode 100644 index 00000000..e0047788 --- /dev/null +++ b/packages/core/src/lib/anchorRepin.ts @@ -0,0 +1,69 @@ +import type { LabelObject } from "../types/Group"; +import type { ObjectChanges } from "../types/LabelObject"; +import { BARCODE_1D_TYPES, getEntry } from "../registry"; +import { isAxisSwapped, objectRotation } from "../registry/rotation"; +import { valueAnchorShift } from "./valueAnchor"; +import type { Footprint as BarcodeFootprint } from "./footprintProber"; + +export type { BarcodeFootprint }; + +/** ^FT+I/B inverts the anchor math (see valueAnchorShift). */ +function hasFtFlip(o: LabelObject): boolean { + const rot = objectRotation((o as { props: object }).props); + return (o as { positionType?: string }).positionType === "FT" && (rot === "I" || rot === "B"); +} + +/** Re-pins justified 1D barcodes so a width-changing edit keeps the justified edge fixed. */ +export function anchorRepin( + obj: LabelObject, + changes: ObjectChanges, + next: LabelObject, + probe: (o: LabelObject) => BarcodeFootprint | null, +): LabelObject { + // 1D-only: the ftFlip math matches barcodeFtAnchorOffset only there (QR + // graphics use an "N" offset + module shift the re-pin doesn't model); + // graphics have static extents, so fieldJustify never re-pins them. + if (!BARCODE_1D_TYPES.has(next.type)) return next; + // Absent means L (schema contract), and L participates under the FT flip. + const justify = next.fieldJustify ?? "L"; + const rot = objectRotation((next as { props: object }).props); + const ftFlip = hasFtFlip(next); + if (justify === "L" && !ftFlip) return next; + // `in`, not value-check: an explicit x/y key marks a positioning edit, and + // x: undefined is already illegal (the merge spread would clobber obj.x). + if (!changes.props || "x" in changes || "y" in changes) return next; + if ("rotation" in changes.props) return next; + // Re-pinning presumes the anchored edge was already in force: an op that + // introduces the justify/flip itself has no pinned edge to keep, so shifting + // by the width delta would move it off the x the caller just set. + if ((obj.fieldJustify ?? "L") !== justify || hasFtFlip(obj) !== ftFlip) return next; + // Both widths from the same synchronous probe: width-neutral edit = exact no-op. + const before = probe(obj); + const after = probe(next); + if (!before || !after) return next; + const swapped = isAxisSwapped(rot); + const delta = swapped ? before.h - after.h : before.w - after.w; + const shift = valueAnchorShift(justify, delta, ftFlip); + if (shift === 0) return next; + return swapped ? { ...next, y: next.y + shift } : { ...next, x: next.x + shift }; +} + +/** Shared leaf edit pipeline: normalize, replace, merge props, re-pin, in that order. */ +export function applyChanges( + obj: LabelObject, + changes: ObjectChanges, + probe: (o: LabelObject) => BarcodeFootprint | null, +): LabelObject { + const normalize = getEntry(obj.type)?.normalizeChanges; + const normalized = normalize ? normalize(obj as never, changes as never) : changes; + const current = (obj as { props?: object }).props ?? {}; + const next = { + ...obj, + ...normalized, + // Always written, never conditionally spread: `normalized` may carry an + // explicit `props: undefined`, which the spread above would leave in place + // and hand every renderer and emitter a propless object. + props: normalized.props ? { ...current, ...normalized.props } : (obj as { props?: object }).props, + } as LabelObject; + return anchorRepin(obj, normalized as ObjectChanges, next, probe); +} diff --git a/packages/core/src/lib/barcodeDims.ts b/packages/core/src/lib/barcodeDims.ts index 6a4a5bd8..8c070e46 100644 --- a/packages/core/src/lib/barcodeDims.ts +++ b/packages/core/src/lib/barcodeDims.ts @@ -5,6 +5,7 @@ import type { LeafObject } from "../registry"; import type { LabelObject } from "../types/Group"; +import { errorMessage } from "./errorMessage"; import { clampCodablockColumns, CODABLOCK_PREVIEW_COLUMNS_MIN } from "../registry/codablock"; import { EC_PERCENT_MIN, EC_PERCENT_MAX } from "../registry/aztec"; import { upceData6FromFd } from "../registry/hriFormatters"; @@ -690,7 +691,8 @@ function getUprightDisplaySize( const modulePx = dotsToPx(obj.props.moduleWidth, scale, dpmm); const bwipSc = get1DBwipScale(obj.props.moduleWidth, scale, dpmm); const w = (cw / bwipSc) * modulePx; - const h = dotsToPx(obj.props.height, scale, dpmm); + const zone = barcodeTextZoneDots(obj); + const h = dotsToPx(obj.props.height + zone, scale, dpmm); return { w, h }; } case "ean13": @@ -751,7 +753,7 @@ function getUprightDisplaySize( const bwipSc = get1DBwipScale(obj.props.moduleWidth, scale, dpmm); const extraPx = bwipSc === 1 ? 1 : 0; const w = ((cw - extraPx) / bwipSc) * modulePx; - const h = dotsToPx(obj.props.height, scale, dpmm); + const h = dotsToPx(obj.props.height + barcodeTextZoneDots(obj), scale, dpmm); return { w, h }; } case "pdf417": { @@ -1139,6 +1141,57 @@ function measureDisplayWith( return dim.w > 0 && dim.h > 0 ? dim : null; } +/** Why the leaf's OWN content does not encode, or null when it does. + * measureDisplayWith falls back to sample content, so a measured footprint + * proves nothing. Blank is not a failure: emptyContent owns that signal. */ +export function barcodeEncodeIssueWith( + bwip: BwipEngine, + obj: LeafObject, + dpmm: number, +): string | null { + if ((getObjectStringContent(obj) ?? "").trim() === "") return null; + try { + if (barcodeDimsPx(bwip, obj, dpmm, dpmm) !== null) return null; + } catch { + // The diagnostic run below reports the throw instead of hiding it. + } + return encodeFailureReason(bwip, obj, dpmm); +} + +/** Re-run the encoder with its errors uncaught, so the caller can say WHAT is + * wrong. The dims path deliberately swallows these to keep measuring. */ +function encodeFailureReason(bwip: BwipEngine, obj: LeafObject, dpmm: number): string { + try { + if (EAN_UPC_TYPES.has(obj.type)) { + const text = getObjectStringContent(obj) ?? ""; + const encoded = obj.type === "upce" ? `0${upceData6FromFd(text)}` : text; + bwip.raw({ bcid: obj.type, text: encoded, includetext: true }); + return "the encoder produced no symbol for this payload"; + } + // barcodeDimsPx routes these past buildBwipOptions, so their missing BCID + // entry says nothing about them: re-run their own encoder for the reason. + if (ZEBRA_WIDTH_BAR_TYPES.has(obj.type)) { + const t = obj.type as ZebraWidthBarType; + const text = zebraWidthBarText(t, getObjectStringContent(obj) ?? ""); + bwip.raw({ bcid: ZEBRA_WIDTH_BCID[t], text }); + return "the encoder produced no symbol for this payload"; + } + if (obj.type === "tlc39") return "the encoder produced no symbol for this payload"; + const opts = buildBwipOptions(obj, dpmm, dpmm); + // Only ^BF refuses on capacity; every other null is a type with no encoder + // path, which must not be reported to the caller as a payload problem. + if (!opts) { + return obj.type === "micropdf417" + ? "the payload exceeds what this symbology can carry" + : "this symbology has no encoder"; + } + bwip.render(opts, dimsDrawing()); + return "the encoder produced no symbol for this payload"; + } catch (e) { + return errorMessage(e); + } +} + export function measureBarcodeFootprintDotsWith( bwip: BwipEngine, obj: LeafObject, diff --git a/packages/core/src/lib/barcodeEncodePreflight.ts b/packages/core/src/lib/barcodeEncodePreflight.ts new file mode 100644 index 00000000..cd006c02 --- /dev/null +++ b/packages/core/src/lib/barcodeEncodePreflight.ts @@ -0,0 +1,88 @@ +// Single decision tree for encode findings, shared by editor and MCP sidecar so the two reports cannot drift. + +import { ctrlParityFor, gs1StaticUnparsed, type LeafObject } from "../registry"; +import { maxicodeScmOwnedByPreflight, type MaxicodeProps } from "../registry/maxicode"; +import { isBarcode } from "./objectBounds"; +import { PREFLIGHT_SEVERITY, type PreflightFinding } from "./preflight"; +import type { Variable } from "../types/Variable"; +import { + applyBindingToObject, + getObjectStringContent, + type ActiveRow, + type ClockResolveCtx, +} from "./variableBinding"; + +/** Binding context so the check encodes what PRINTS: `«marker»` content is + * resolved exactly like the caller's render. */ +export interface EncodeEnv { + variables: readonly Variable[]; + active: ActiveRow | null; + clock?: ClockResolveCtx; +} + +export interface EncodeVerdict { + error: string | null; + approximated: boolean; +} + +/** Preview-resolved leaf for the encoder (identity-preserving when unbound). */ +export function resolveForEncode(leaf: LeafObject, env: EncodeEnv): LeafObject { + return applyBindingToObject( + leaf, + env.variables, + env.active, + // Always the resolved values: a schema render substitutes placeholders, + // and encoding those would clear a barcode whose real payload cannot code. + "preview", + env.clock, + ctrlParityFor(leaf), + ); +} + +/** Encode check over ALL exportable leaves, not just rendered ones, so a + * hidden-but-exported barcode with an uncodable payload still reports. + * `encode` is the caller's encoder seam (canvas or headless bwip). */ +export function barcodeEncodeFindingsCore( + leaves: readonly LeafObject[], + env: EncodeEnv, + encode: (leaf: LeafObject, resolved: LeafObject) => EncodeVerdict, +): PreflightFinding[] { + const findings: PreflightFinding[] = []; + for (const leaf of leaves) { + // Barcode-only producer: text and shapes never encode, and a bound TEXT + // field resolving empty stays quiet (configured field, and the canvas + // shows an honest empty box there, unlike the barcode's sample bars). + if (!isBarcode(leaf)) continue; + const resolved = resolveForEncode(leaf, env); + if ((getObjectStringContent(resolved) ?? "").trim() === "") { + // A literal-blank field is already owned by computePreflight's + // emptyContent (raw content ""); a BARCODE whose marker resolves empty + // is raw-nonempty there, yet renders as sample bars, so surface it here. + if ((getObjectStringContent(leaf) ?? "").trim() !== "") { + findings.push({ objectId: leaf.id, kind: "emptyContent", severity: PREFLIGHT_SEVERITY.emptyContent }); + } + continue; + } + // A literal mode 2/3 MaxiCode without a carrier message is owned by + // maxicodeModeMissingScm (computePreflight); skip renderFailed to avoid a + // double report. Marker content isn't skipped: the producer guards it out. + if ( + resolved.type === "maxicode" && + maxicodeScmOwnedByPreflight(getObjectStringContent(leaf) ?? "", resolved.props as MaxicodeProps) + ) { + continue; + } + // Static unparsed GS1 is owned by gs1ContentUnparsed (see + // gs1StaticUnparsed); a second renderFailed would contradict it. + if (gs1StaticUnparsed(leaf.type, leaf.props, getObjectStringContent(leaf) ?? "")) { + continue; + } + const verdict = encode(leaf, resolved); + if (verdict.error) { + findings.push({ objectId: leaf.id, kind: "renderFailed", severity: PREFLIGHT_SEVERITY.renderFailed, detail: verdict.error }); + } else if (verdict.approximated) { + findings.push({ objectId: leaf.id, kind: "previewApproximate", severity: PREFLIGHT_SEVERITY.previewApproximate }); + } + } + return findings; +} diff --git a/packages/core/src/lib/barcodeHri.test.ts b/packages/core/src/lib/barcodeHri.test.ts new file mode 100644 index 00000000..6759d058 --- /dev/null +++ b/packages/core/src/lib/barcodeHri.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from "vitest"; + +import { BARCODE_1D_TYPES, ObjectRegistry, type LeafObject } from "../registry"; +import { barcodeTextZoneDots, hriZoneDots } from "./barcodeHri"; + +describe("hriZoneDots", () => { + // Labelary, 6 and 8 dpmm, ^BC and ^B3: total ink height minus the bar height. + it("matches the measured line height per module width", () => { + expect([1, 2, 3, 4, 5].map(hriZoneDots)).toEqual([14, 21, 28, 35, 42]); + }); + + it("treats a fractional module like the dot grid does", () => { + expect(hriZoneDots(2.4)).toBe(21); + expect(hriZoneDots(0)).toBe(14); + }); +}); + +/** 1D symbologies whose firmware prints an interpretation line that no zone is + * reserved for yet. Shrinking this set needs measurements, never a copied + * formula: a guessed band moves every box by an invented number. */ +const UNMEASURED_HRI_ZONE: ReadonlySet = new Set([ + "plessey", + "planet", + "postal", + "code49", + "gs1databar", +]); + +describe("HRI zone coverage", () => { + // The zone tests iterate HRI_LINE_TYPES itself, so only an outside-in sweep + // catches a symbology that was never added to it. + it("classifies every 1D symbology, or names it as unmeasured", () => { + for (const type of BARCODE_1D_TYPES) { + const entry = ObjectRegistry[type as keyof typeof ObjectRegistry]; + if (!entry) continue; + const leaf = { + id: type, + type, + x: 0, + y: 0, + props: { ...(entry.defaultProps as object), printInterpretation: true, moduleWidth: 2 }, + } as LeafObject; + const zone = barcodeTextZoneDots(leaf); + if (UNMEASURED_HRI_ZONE.has(type)) { + expect(zone, `${type} is listed as unmeasured but now reserves a zone`).toBe(0); + } else { + expect(zone, `${type} prints an HRI line with no zone reserved`).toBeGreaterThan(0); + } + } + }); +}); + +describe("a GS1-128's interpretation band", () => { + const leaf = (gs1: boolean, moduleWidth: number, content = "(01)09501101530003") => + ({ + id: "b", type: "code128", x: 0, y: 0, rotation: 0, + props: { content, height: 60, moduleWidth, printInterpretation: true, gs1 }, + }) as never; + + it("is taller than the plain one, because the HRI font is scaled up", () => { + // The renderer draws GS1 HRI at up to GS1_HRI_FONT_SCALE of the plain em, + // so reserving the plain band let the line run outside the published bbox. + for (const mw of [2, 5]) { + expect(barcodeTextZoneDots(leaf(true, mw))).toBeGreaterThan(barcodeTextZoneDots(leaf(false, mw))); + } + }); + + it("covers the band Labelary prints, at every measured module width", () => { + // Measured at 8 dpmm as ink below the bars: 21 / 34 / 52 / 66 / 82. + // The reserved band must never read short, or the HRI runs off the media + // while the report calls the field clean. + const measured: Record = { 1: 21, 2: 34, 3: 52, 4: 66, 5: 82 }; + for (const [mw, dots] of Object.entries(measured)) { + expect(barcodeTextZoneDots(leaf(true, Number(mw))), `mw ${mw}`).toBeGreaterThanOrEqual(dots); + } + }); + + it("does not read the content", () => { + // Reading it meant measuring it, and the measure falls back to a per-glyph + // estimate without a canvas, so headless and browser reserved differently. + const long = leaf(true, 3, "(01)09501101020917(10)ABC123(21)SERIAL987654(11)260101"); + expect(barcodeTextZoneDots(long)).toBe(barcodeTextZoneDots(leaf(true, 3))); + }); + + it("leaves a non-GS1 code128 exactly where it was", () => { + expect(barcodeTextZoneDots(leaf(false, 3))).toBe(hriZoneDots(3)); + }); +}); diff --git a/packages/core/src/lib/barcodeHri.ts b/packages/core/src/lib/barcodeHri.ts index b30a41ed..820fb84c 100644 --- a/packages/core/src/lib/barcodeHri.ts +++ b/packages/core/src/lib/barcodeHri.ts @@ -1,7 +1,7 @@ // Pure HRI text-zone resolution shared by the barcode renderer (getDisplaySize) // and the group-rotation bbox probe, so zone height and side never drift apart. -import { ObjectRegistry, type LeafObject } from "../registry"; +import { isGs1Active, ObjectRegistry, type LeafObject } from "../registry"; import { EAN_TEXT_ZONE_DOTS, LOGMARS_TEXT_ZONE_DOTS, @@ -15,7 +15,9 @@ import { measureInkWidthPx } from "./labelGeometry/measureTextDots"; /** GS1-128 HRI font em (dots): the scaled-up base, shrunk to fit the bar width * by measured advance so it matches the print whatever face we use. Falls back - * to the un-shrunk size when bars aren't measured yet (`barWidthDots <= 0`). */ + * to the un-shrunk size when bars aren't measured yet (`barWidthDots <= 0`). + * CANVAS ONLY: measureInkWidthPx substitutes a per-glyph estimate without a + * DOM, so the headless kernel must never size a reservation by this. */ export function gs1HriFontDots( content: string, baseFontDots: number, @@ -37,14 +39,50 @@ const TEXT_ZONE_DOTS_BY_TYPE: Partial> = { logmars: LOGMARS_TEXT_ZONE_DOTS, }; +/** Types whose interpretation line adds a module-scaled band, rather than the + * fixed zone EAN/UPC and logmars reserve. Pinned per type by barcodeHriZone. */ +export const HRI_LINE_TYPES: ReadonlySet = new Set([ + "code128", + "code39", + "code93", + "code11", + "interleaved2of5", + "msi", + "codabar", + "industrial2of5", + "standard2of5", +]); + +/** HRI line height in dots, Labelary-measured at 6 and 8 dpmm over module + * widths 1-5: 7 per module plus 7, whatever font class the modulus selects + * (spec p.142). ZD230 verification still open. */ +export function hriZoneDots(moduleWidth: number): number { + return 7 * (Math.max(1, Math.round(moduleWidth)) + 1); +} + /** Firmware-reserved HRI text-zone height in dots. ^BS reserves it only when - * printInterpretation is on; other EAN/UPC reserve the fixed guard zone always. */ + * printInterpretation is on; other EAN/UPC reserve the fixed guard zone always; + * the rest reserve a module-scaled line, but only with the line turned on. */ export function barcodeTextZoneDots(obj: LeafObject): number { + const p = obj.props as { printInterpretation?: boolean; moduleWidth?: number }; if (obj.type === "upcEanExtension") { - const p = obj.props as { printInterpretation?: boolean; moduleWidth?: number }; return p.printInterpretation ? upcSuppTextZoneDots(p.moduleWidth ?? 2) : 0; } - return TEXT_ZONE_DOTS_BY_TYPE[obj.type] ?? 0; + const fixed = TEXT_ZONE_DOTS_BY_TYPE[obj.type]; + if (fixed !== undefined) return fixed; + const printsHri = HRI_LINE_TYPES.has(obj.type) && p.printInterpretation === true; + if (!printsHri) return 0; + const moduleWidth = p.moduleWidth ?? 2; + // The registry predicate, not a raw props read: a type that cannot carry GS1 + // must not claim the taller band off a stray flag. + return isGs1Active(ObjectRegistry[obj.type], obj.props) + ? gs1HriZoneDots(moduleWidth) + : hriZoneDots(moduleWidth); +} + +/** GS1-128 HRI band, Labelary-measured at 8dpmm over module widths 1-5, fitted to never read short. */ +function gs1HriZoneDots(moduleWidth: number): number { + return 15 * Math.max(1, Math.round(moduleWidth)) + 7; } /** HRI sits above the bars when the per-object toggle is set or the symbology diff --git a/packages/core/src/lib/dataMatrixFd.ts b/packages/core/src/lib/dataMatrixFd.ts index 7052103b..6e3254f7 100644 --- a/packages/core/src/lib/dataMatrixFd.ts +++ b/packages/core/src/lib/dataMatrixFd.ts @@ -2,7 +2,7 @@ // as `_1`, a literal `_` doubled, non-printable bytes as `_dNNN`. Non-GS1 field // data is arbitrary bytes and never routed here. Pure, no UI. -import { GS1_GS } from "./gs1"; +import { aiSpec, GS1_GS, isVariableKind, typedGs1Parts, typedSegmentValue } from "./gs1"; /** Escape-sequence control character we emit (^BX g param). Kept outside * `^`/`~` so it never collides with fdField's ^FH escaping. */ @@ -74,3 +74,31 @@ export function dataMatrixFdToGs1Content(fd: string, escape: string): string | n if (!fd.startsWith(fnc1)) return null; return decodeEscapes(fd, escape, fnc1.length); } + +/** Typed `(AI)value…` content into the ^BX payload while the values are still + * opaque: the AI codes are literal, so parentheses and FNC1 placement are + * already decidable. Null when the content is not in the typed form. */ +export function typedGs1ToDataMatrixFd(content: string): string | null { + const runs = typedGs1DataRuns(content); + if (!runs) return null; + const fnc1 = ESC + "1"; + return fnc1 + runs.map(escapeRun).join(fnc1); +} + +/** The FNC1-separated data runs of a typed `(AI)value…` content: parens out, + * a separator only after a variable-length AI. The one structure the ^FD codec + * and the canvas both encode from, so preview and print cannot size a symbol + * from different data. Null when the content is not in the typed form. */ +export function typedGs1DataRuns(content: string): string[] | null { + const parts = typedGs1Parts(content); + if (!parts) return null; + const runs: string[] = [""]; + for (const [index, part] of parts.entries()) { + // Same completion the literal path applies, or the bound form would carry + // a different AI-01 payload than the same content written out. + runs[runs.length - 1] += `${part.ai}${typedSegmentValue(part.ai, part.value)}`; + const spec = aiSpec(part.ai); + if (spec && isVariableKind(spec.kind) && index < parts.length - 1) runs.push(""); + } + return runs; +} diff --git a/packages/core/src/lib/errorMessage.ts b/packages/core/src/lib/errorMessage.ts new file mode 100644 index 00000000..b0df35f2 --- /dev/null +++ b/packages/core/src/lib/errorMessage.ts @@ -0,0 +1,5 @@ +/** Centralises the `e instanceof Error ? ... : String(e)` coercion every + * async/catch site would otherwise repeat. */ +export function errorMessage(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} diff --git a/packages/core/src/lib/footprintProber.ts b/packages/core/src/lib/footprintProber.ts index 073a2219..2dbdf818 100644 --- a/packages/core/src/lib/footprintProber.ts +++ b/packages/core/src/lib/footprintProber.ts @@ -1,7 +1,7 @@ import type { LabelObject } from "../types/Group"; /** Rotated visual footprint in exact dots (raw object, scale = dpmm). */ -interface Footprint { +export interface Footprint { w: number; h: number; } @@ -30,6 +30,13 @@ export function unregisterFootprintMeasurer(m: FootprintMeasurer): void { if (measurer === m) measurer = null; } +/** Drop the memo. The cache keys on the props reference, not the resolution + * binding, so a caller that re-measures the same props under a new binding + * (withFootprintBinding) must reset it or read a stale width. */ +export function resetFootprintCache(): void { + cache = new WeakMap(); +} + export function measureFootprintDots(obj: LabelObject, dpmm?: number): Footprint | null { if (!measurer) return null; const key = (obj as { props?: object }).props; diff --git a/packages/core/src/lib/gfaDecode.labelary.test.ts b/packages/core/src/lib/gfaDecode.labelary.test.ts new file mode 100644 index 00000000..b20b0396 --- /dev/null +++ b/packages/core/src/lib/gfaDecode.labelary.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { rasterFromGfa } from "./gfaDecode"; + +/** Row-major ink rows as '#'/'.' strings, the shape the Labelary raster was + * read in (see the vectors below). */ +const rows = (gfa: string, width: number): string[] => { + const r = rasterFromGfa(gfa); + if (!r) return []; + const out: string[] = []; + for (let y = 0; y < r.heightDots; y++) { + let s = ""; + for (let x = 0; x < width; x++) { + const byte = r.bytes[y * r.bytesPerRow + (x >> 3)] ?? 0; + s += (byte & (0x80 >> (x & 7))) !== 0 ? "#" : "."; + } + out.push(s); + } + return out; +}; + +// Labelary-verified: comma/bang/colon always emit a row, even right after one the data already filled. +describe("^GFA fill semantics, against the Labelary raster", () => { + it("puts a blank row after a comma that follows a full row", () => { + expect(rows("^GFA,16,16,2,FFFF,8001,!!!!!!", 16)).toEqual([ + "################", + "................", + "#..............#", + "................", + "################", + "################", + "################", + "################", + ]); + }); + + it("pads a partial row on a comma and opens a new one on a bang", () => { + expect(rows("^GFA,16,16,2,FF,!,,,,,,", 16)).toEqual([ + "########........", + "################", + "................", + "................", + "................", + "................", + "................", + "................", + ]); + }); + + it("repeats the previous row on a colon, blank included", () => { + expect(rows("^GFA,16,16,2,FFFF,:,8001,!!!!", 16)).toEqual([ + "################", + "................", + "................", + "................", + "#..............#", + "................", + "################", + "################", + ]); + }); +}); diff --git a/packages/core/src/lib/gfaDecode.test.ts b/packages/core/src/lib/gfaDecode.test.ts new file mode 100644 index 00000000..41414017 --- /dev/null +++ b/packages/core/src/lib/gfaDecode.test.ts @@ -0,0 +1,174 @@ +import { describe, it, expect } from "vitest"; +import { rasterFromGfa } from "./gfaDecode"; +import { GF_MAX_DECODED_BYTES, gfPayloadToBytes } from "./zplParser/decoders/gfa"; +import { gfaFromRaster, type MonoRaster } from "./imageToZpl"; + +const raster = (bytes: number[], bytesPerRow: number): MonoRaster => ({ + bytes: new Uint8Array(bytes), + bytesPerRow, + paddedWidth: bytesPerRow * 8, + widthDots: bytesPerRow * 8, + heightDots: bytes.length / bytesPerRow, +}); + +const hex = (r: { bytes: Uint8Array }) => + [...r.bytes].map((b) => b.toString(16).toUpperCase().padStart(2, "0")).join(""); + +describe("rasterFromGfa", () => { + it("round-trips our own encoder", () => { + const source = raster([0xff, 0x00, 0x81, 0x18, 0x3c, 0x7e], 2); + const back = rasterFromGfa(gfaFromRaster(source)); + expect(back?.bytes).toEqual(source.bytes); + expect(back?.heightDots).toBe(3); + expect(back?.bytesPerRow).toBe(2); + }); + + it("expands the repeat counts from the spec's own examples", () => { + // p.1759: M6 is seven hex 6s, hB is 40 hex Bs, and counts combine (vMB). + expect(hex(rasterFromGfa("^GFA,4,4,4,M60")!)).toBe("66666660"); + expect(hex(rasterFromGfa("^GFA,20,20,20,hB")!)).toBe("B".repeat(40)); + expect(rasterFromGfa("^GFA,164,164,164,vMB")!.bytes.slice(0, 163).every((b) => b === 0xbb)).toBe(true); + }); + + it("counts g as twenty, not forty", () => { + // hB is 40 per the spec, so g must be 20; an off-by-one-step table doubles + // the ink and only hides behind a row that truncates it. + expect(hex(rasterFromGfa("^GFA,30,30,30,gF")!)).toBe("F".repeat(20) + "0".repeat(40)); + }); + + it("carries a run across the row boundary instead of truncating it", () => { + // Header says 4 bytes over 2 per row: two rows, the second one half-filled. + expect(hex(rasterFromGfa("^GFA,4,4,2,MF")!)).toBe("FFFFFFF0"); + }); + + it("decodes a wrapped payload as base64, never as hex", () => { + // Every raw-binary import stores its cache as ^GFA,…,:B64:…. Read as hex + // the same string yields plausible noise, so assert the actual bytes: + // "AP//AA==" is 00 FF FF 00. + expect(hex(rasterFromGfa("^GFA,4,4,2,:B64:AP//AA==:1234")!)).toBe("00FFFF00"); + }); + + it("fills a line with zeros on a comma and with ones on a bang", () => { + expect(hex(rasterFromGfa("^GFA,4,4,2,FF,!")!)).toBe("FF00FFFF"); + }); + + it("repeats the previous line on a colon", () => { + expect(hex(rasterFromGfa("^GFA,4,4,2,A1B2:")!)).toBe("A1B2A1B2"); + }); + + it("reads a leading colon as an empty previous line", () => { + // A bare leading colon has no row to repeat and is invalid input (p.1601 + // reserves it as the lead-in for :B64:/:Z64:), so it yields a blank row. + // c=4 over 2 bytes per row declares the two rows this payload produces. + expect(hex(rasterFromGfa("^GFA,4,4,2,:C3D4")!)).toBe("0000C3D4"); + }); + + it("pads a short final row instead of dropping it", () => { + expect(hex(rasterFromGfa("^GFA,4,4,2,FFFFAB")!)).toBe("FFFFAB00"); + }); + + it("refuses a header it cannot use", () => { + expect(rasterFromGfa("^GFB,8,8,1,binary")).toBeNull(); + expect(rasterFromGfa("^GFA,8,8,0,FF")).toBeNull(); + expect(rasterFromGfa("not a graphic")).toBeNull(); + }); + + it("keeps the visible width inside the byte-padded one", () => { + const r = rasterFromGfa("^GFA,2,2,2,FFFF", 12); + expect(r?.paddedWidth).toBe(16); + expect(r?.widthDots).toBe(12); + }); +}); + +describe("a header without its format letter", () => { + it("is refused, like the parser and the emitter refuse it", () => { + // Spec p.215 defaults `a` to A, but nothing else in this codebase accepts + // the short form, and a preview must not show what the print drops. + expect(rasterFromGfa("^GF,4,4,2,FF00FF00")).toBeNull(); + expect(rasterFromGfa("^GFA,4,4,2,FF00FF00")).not.toBeNull(); + }); +}); + +describe("a hostile RLE payload", () => { + // 100k input chars declare ~32M output nibbles; before the decode cap this + // expanded quadratically (seconds of CPU, hundreds of MB) inside the render. + it("is refused rather than expanded past the decode budget", () => { + // Refused, not truncated: handing back the rows that fit would store a + // silently cropped graphic and re-export it at the crop height. + const payload = "zzzzF".repeat(20_000); + expect(rasterFromGfa(`^GFA,4,4,1,${payload}`)).toBeNull(); + expect(gfPayloadToBytes(payload, "A", 1, Number.NaN)).toBeNull(); + }); + + it("still decodes a payload that fits the budget", () => { + const decoded = gfPayloadToBytes("zzzzF", "A", 1, Number.NaN); + expect(decoded!.data.length).toBeLessThanOrEqual(GF_MAX_DECODED_BYTES); + }); +}); + +describe("the header count, not the stream", () => { + it("keeps the rows the header declares when the payload runs long", () => { + // Spec p.215: c is the size of the image, not necessarily of the data. + expect(rasterFromGfa("^GFA,2,2,2,C3D4FFFF")?.heightDots).toBe(1); + }); + + it("refuses a header without the count, which prints nothing", () => { + // Labelary: omitting b renders identically, omitting c produces no label + // at all, because the firmware never learns where the graphic ends. + expect(rasterFromGfa("^GFA,,,2,C3D4FFFF")).toBeNull(); + expect(rasterFromGfa("^GFA,,4,2,C3D4FFFF")?.heightDots).toBe(2); + }); + + it("refuses a fractional row count instead of flooring past what emit uses", () => { + // 5 bytes over 2 per row = 2.5 rows: gfaHeaderDims returns null and emit + // falls back to props dims, so the canvas must not draw a floored 2 rows. + expect(rasterFromGfa("^GFA,5,5,2,C3D4FF")).toBeNull(); + }); +}); + +describe("a :Z64: zip bomb", () => { + it("declines to inflate past the decode budget instead of OOMing", async () => { + const { zlibSync } = await import("fflate"); + const packed = zlibSync(new Uint8Array(GF_MAX_DECODED_BYTES * 4)); + const b64 = Buffer.from(packed).toString("base64"); + expect(rasterFromGfa(`^GFA,4,4,2,:Z64:${b64}:0000`)).toBeNull(); + }); +}); + +describe("a single unbounded repeat run", () => { + // The row cap is only tested between steps, so one run's `repeat(count)` has + // to clamp itself: 40k compress chars declare ~16M nibbles in ONE allocation. + it("clamps the run to the decode budget instead of allocating it whole", () => { + const payload = "z".repeat(40_000) + "F"; + expect(rasterFromGfa(`^GFA,4,4,1,${payload}`)).toBeNull(); + expect(gfPayloadToBytes(payload, "A", 1, Number.NaN)).toBeNull(); + }); +}); + +describe("a binary header that declares only the format count", () => { + it("decodes via the c fallback like the boundary reads it", () => { + // b omitted, c=4 (spec p.215: b == c uncompressed). A bare parseInt("") + // made the raw branch compare against NaN and refuse a graphic that prints. + const bytes = "\x01\x02\x03\x04"; + expect(rasterFromGfa(`^GFB,,4,2,${bytes}`)).not.toBeNull(); + }); +}); + +describe("a payload shorter than its declared count", () => { + it("draws the declared height with blank rows, the size bounds and emit use", () => { + // gfaHeaderDims reports 4 rows for this header; shrinking to the one row + // that decoded made the canvas, the report and the print disagree. + const r = rasterFromGfa("^GFA,8,8,2,FFFF"); + expect(r?.heightDots).toBe(4); + expect(hex(r!)).toBe("FFFF000000000000"); + }); +}); + +describe("comma and bang as fills", () => { + // What they are FOR: letting a row omit its trailing bytes. The interaction + // after an already-full row is pinned against the printer's own raster in + // gfaDecode.labelary.test.ts, not asserted from the prose here. + it("fills a short row with zeros", () => { + expect(hex(rasterFromGfa("^GFA,4,4,2,FF,AB")!)).toBe("FF00AB00"); + }); +}); diff --git a/packages/core/src/lib/gfaDecode.ts b/packages/core/src/lib/gfaDecode.ts new file mode 100644 index 00000000..5b826982 --- /dev/null +++ b/packages/core/src/lib/gfaDecode.ts @@ -0,0 +1,60 @@ +// A ^GF command back into the packed raster the preview draws, for designs +// that own only the encoded bytes. The payload decoding stays the parser's. + +import type { MonoRaster } from "./imageToZpl"; +import { GF_MAX_BYTES_PER_ROW, GF_MAX_ROWS, parseGfHeader } from "../registry/image"; +import { GF_MAX_DECODED_BYTES, gfPayloadToBytes } from "./zplParser/decoders/gfa"; + +/** And the two together: the caps multiply out to 163 Mpx, which the preview + * canvas would back with hundreds of megabytes. Derived from the decoder's + * own ceiling so the two cannot drift. */ +const MAX_DOTS = GF_MAX_DECODED_BYTES * 8; + +/** Null when the header is unusable or the payload does not decode; callers + * fall back to their placeholder rather than drawing noise. */ +export function rasterFromGfa(gfa: string, visibleWidthDots?: number): MonoRaster | null { + const head = parseGfHeader(gfa.trim()); + // Same empty-payload guard gfaHeaderDims applies: a bare header decodes to a + // blank raster, which would hide the missing-graphic placeholder and publish + // a measured footprint for a field that prints nothing. + if (!head || head.payload.trim() === "") return null; + const { format, bytesPerRow } = head; + // Bounded before the decoder runs: it pads each row to the declared width, so + // a header claiming 2^28 bytes throws RangeError out of the render body. + if (!Number.isInteger(bytesPerRow) || bytesPerRow > GF_MAX_BYTES_PER_ROW) { + return null; + } + // b, or c when b is omitted (spec p.215: b == c uncompressed), same fallback the boundary applies. + // A bare parseInt("") is NaN, and the raw-binary branch then length-compares against it and refuses a graphic that prints. + const countStr = head.totalBytes !== "" ? head.totalBytes : head.dataBytes; + const decoded = gfPayloadToBytes( + head.payload, + format, + bytesPerRow, + countStr === "" ? Number.NaN : Number.parseInt(countStr, 10), + ); + if (!decoded) return null; + // Row count comes from the header (p.215 c/d); a fractional or missing count falls back to the placeholder. + if (head.dataBytes === "") return null; + const declaredRows = Number.parseInt(head.dataBytes, 10) / bytesPerRow; + if (!Number.isInteger(declaredRows) || declaredRows <= 0) return null; + // A short stream pads to the declared row count instead of shrinking the graphic; past the caps nothing draws. + const heightDots = declaredRows; + if (heightDots <= 0 || heightDots > GF_MAX_ROWS) return null; + if (heightDots * bytesPerRow * 8 > MAX_DOTS) return null; + const needed = heightDots * bytesPerRow; + let bytes = decoded.data.subarray(0, needed); + if (bytes.length < needed) { + const padded = new Uint8Array(needed); + padded.set(bytes); + bytes = padded; + } + const paddedWidth = bytesPerRow * 8; + return { + bytes, + bytesPerRow, + paddedWidth, + widthDots: Math.min(visibleWidthDots ?? paddedWidth, paddedWidth), + heightDots, + }; +} diff --git a/packages/core/src/lib/gs1.ts b/packages/core/src/lib/gs1.ts index df9f1f9c..0598c83c 100644 --- a/packages/core/src/lib/gs1.ts +++ b/packages/core/src/lib/gs1.ts @@ -379,9 +379,45 @@ export function unescapeGs1FdValue(value: string): string { return value.replaceAll(">0", ">"); } -/** Segment value as emitted: GTIN completed to 14 digits, others verbatim. */ +/** Typed `(AI)value…` parts, or null when the content is not entirely in that + * shape. The AIs are not checked against the catalog: a carrier that only + * needs the parentheses gone can work without it. */ +export function typedGs1Shape(content: string): { ai: string; value: string }[] | null { + const parts = [...content.matchAll(/\(([0-9]{2,4})\)([^(]*)/g)]; + if (parts.length === 0) return null; + const covered = parts.reduce((n, m) => n + m[0].length, 0); + return covered === content.length + ? parts.map((m) => ({ ai: m[1] ?? "", value: m[2] ?? "" })) + : null; +} + +/** Typed parts whose AIs the catalog all carries, which is what deciding FNC1 + * placement needs. */ +export function typedGs1Parts(content: string): { ai: string; value: string }[] | null { + const parts = typedGs1Shape(content); + return parts?.every((p) => aiSpec(p.ai) !== undefined) ? parts : null; +} + +/** Emits a typed part's value, completing a literal GTIN so binding another part does not ship a bare AI-01. */ +export function typedSegmentValue(ai: string, value: string): string { + if (aiSpec(ai)?.kind !== "gtin") return value; + return /^[0-9]+$/.test(value) ? gtin14WithCheck(value) : value; +} + +/** Typed content with its literal GTINs completed, for the carriers that ship + * the `(AI)value` form as written (^BC mode D). Null when the content is not + * in the typed form. */ +export function completeTypedGtins(content: string): string | null { + const parts = typedGs1Parts(content); + if (!parts) return null; + return parts.map((p) => `(${p.ai})${typedSegmentValue(p.ai, p.value)}`).join(""); +} + +/** Segment value as emitted, on the same rule the typed path uses: a GTIN gets + * completed only when it IS digits, so a value the parser could not structure + * (trailing text, a marker) reaches the symbol instead of being stripped. */ function segmentValue(s: Gs1Segment): string { - return AI_BY_CODE.get(s.ai)?.kind === "gtin" ? gtin14WithCheck(s.value) : s.value; + return typedSegmentValue(s.ai, s.value); } /** A variable-length AI that is not the last segment needs a trailing FNC1 diff --git a/packages/core/src/lib/gs1Plan.test.ts b/packages/core/src/lib/gs1Plan.test.ts index 3a7cd2c6..da16276e 100644 --- a/packages/core/src/lib/gs1Plan.test.ts +++ b/packages/core/src/lib/gs1Plan.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { planGs1Fd } from "./gs1Plan"; +import { generateMultiPageZPL } from "./zplGenerator"; import { GS1_GS } from "./gs1"; import { getEntry, type LeafObject } from "../registry"; @@ -74,3 +75,185 @@ describe("planGs1Fd", () => { expect(planGs1Fd("0112345678901231", "code128").bwipParsefncText).toBeNull(); }); }); + +describe("GS1 DataMatrix element string", () => { + it("never ships the human-readable parentheses inside the symbol", () => { + // ^BX encodes ^FD verbatim; only ^BC mode D strips parens (spec p.95). + expect(planGs1Fd("(00)340123450000000017", "datamatrix").fd).toBe("_100340123450000000017"); + }); + + it("leaves canonical content exactly as it was", () => { + expect(planGs1Fd("00340123450000000017", "datamatrix").fd).toBe("_100340123450000000017"); + }); + + it("chains a fixed-length AI without a separator", () => { + expect(planGs1Fd("(01)04012345123456(10)L42", "datamatrix").fd).toBe("_1010401234512345610L42"); + }); + + it("separates after a variable-length AI, where the decoder needs it", () => { + expect(planGs1Fd("(10)L42(17)261231", "datamatrix").fd).toBe("_110L42_117261231"); + }); +}); + +describe("a variable inside a GS1 field", () => { + const emit = (type: "code128" | "datamatrix") => + generateMultiPageZPL( + { widthMm: 70, heightMm: 40, dpmm: 8 }, + [{ + objects: [{ + id: "s", type, x: 10, y: 10, rotation: 0, + props: { + content: "(01)«GTIN»(10)«LOT»", + gs1: true, height: 60, moduleWidth: 2, + dimension: 6, quality: 200, rotation: "N", + }, + } as never], + }], + [ + { id: "v1", name: "GTIN", fnNumber: 1, defaultValue: "04150123456782" }, + { id: "v2", name: "LOT", fnNumber: 2, defaultValue: "L42" }, + ], + ); + + it("emits the slot, never a value computed from the slot number", () => { + for (const type of ["code128", "datamatrix"] as const) { + const zpl = emit(type); + expect(zpl, type).toContain("#1#"); + expect(zpl, type).not.toContain("00000000000"); + } + }); + + it("still canonicalises content that carries no marker", () => { + expect(planGs1Fd("(01)04150123456782", "datamatrix").fd).toBe("_10104150123456782"); + }); +}); + +describe("values that only look like a slot reference", () => { + it("keeps the separator on a hyphenated lot", () => { + // (10) is variable length, so the next AI needs FNC1 after it. + expect(planGs1Fd("(10)-123-(17)261231", "code128").fd).toBe("(10)-123->8(17)261231"); + }); + + it("canonicalises that same content for DataMatrix", () => { + expect(planGs1Fd("(10)-123-(17)261231", "datamatrix").fd).toBe("_110-123-_117261231"); + }); + + it("still recognises a real embed", () => { + expect(planGs1Fd("(10)#2#(17)261231", "code128").fd).toContain("#2#"); + }); +}); + +describe("values that use the same characters an embed does", () => { + const literal = (content: string, carrier: "code128" | "datamatrix") => + planGs1Fd(content, carrier).fd; + + it("treats percent and ampersand values as data, not as slot references", () => { + // Both are legal in the GS1 82-character set and both are ^FE candidates. + expect(literal("(10)%123%(17)261231", "code128")).toBe("(10)%123%>8(17)261231"); + expect(literal("(10)&42&(17)261231", "datamatrix")).toBe("_110&42&_117261231"); + }); + + it("keeps separating a hyphenated lot", () => { + expect(literal("(10)-123-(17)261231", "code128")).toBe("(10)-123->8(17)261231"); + }); +}); + +describe("a GS1 DataMatrix whose values are still variable", () => { + const emit = (content: string) => + generateMultiPageZPL( + { widthMm: 70, heightMm: 40, dpmm: 8 }, + [{ + objects: [{ + id: "s", type: "datamatrix", x: 10, y: 10, rotation: 0, + props: { content, gs1: true, dimension: 6, quality: 200, rotation: "N" }, + } as never], + }], + [ + { id: "v1", name: "GTIN", fnNumber: 1, defaultValue: "04150123456782" }, + { id: "v2", name: "LOT", fnNumber: 2, defaultValue: "L42" }, + ], + ); + + it("drops the human-readable parentheses from the symbol data", () => { + const zpl = emit("(01)«GTIN»(10)«LOT»"); + expect(zpl).toContain("#1#"); + expect(zpl).not.toContain("^FD_1(01)"); + expect(zpl).not.toContain("(10)"); + }); + + it("keeps the separator after the variable-length AI when one follows", () => { + // (10) is variable, so a following (17) needs FNC1; (01) is fixed and does not. + const zpl = emit("(01)«GTIN»(10)«LOT»(17)261231"); + expect(zpl).toMatch(/\^FD_101#1#10#2#_117261231\^FS/); + }); +}); + +describe("an AI the catalog does not know", () => { + const emit = (content: string) => + generateMultiPageZPL( + { widthMm: 70, heightMm: 40, dpmm: 8 }, + [{ + objects: [{ + id: "s", type: "datamatrix", x: 10, y: 10, rotation: 0, + props: { content, gs1: true, dimension: 6, quality: 200, rotation: "N" }, + } as never], + }], + [{ id: "v1", name: "LOT", fnNumber: 1, defaultValue: "L42" }], + ); + + it("is judged the same with or without an unresolved marker", () => { + // (99) is company-internal and the catalog carries no spec for it, so the + // template path must not canonicalise what the literal path refuses. + expect(emit("(9999)«LOT»")).toContain("(9999)"); + expect(planGs1Fd("(9999)ABC", "datamatrix").fd).toContain("(9999)"); + }); +}); + +describe("a GS1 content whose GTIN is literal but another value is bound", () => { + const emit = (content: string, type: string) => + generateMultiPageZPL( + { widthMm: 70, heightMm: 40, dpmm: 8 }, + [{ + objects: [{ + id: "s", type, x: 10, y: 10, rotation: 0, + props: { content, gs1: true, dimension: 6, quality: 200, rotation: "N", height: 60, moduleWidth: 2 }, + } as never], + }], + [{ id: "v2", name: "LOT", fnNumber: 2, defaultValue: "L42" }], + ); + + // The literal path completes AI 01 to 14 digits with its check digit, so a + // bound sibling must not change the number the scanner reads. + it("still completes the GTIN on both carriers", () => { + expect(emit("(01)5901234123457(10)«LOT»", "datamatrix")).toContain("_10159012341234576"); + expect(emit("(01)5901234123457(10)«LOT»", "code128")).toContain("(01)59012341234576"); + }); +}); + +describe("GS1 content the catalog can only partly segment", () => { + // parseGs1ToSegments returns what it could read; the rest is still the user's + // data and must reach the symbol (roundtrip rule). + it("carries the unsegmented tail into the ^BX payload", () => { + const plan = planGs1Fd("(01)09501101530003TRAILING", "datamatrix"); + expect(plan.fd).toContain("TRAILING"); + }); +}); + +describe("a marked GS1 DataMatrix the canvas has to measure", () => { + // The canvas encodes bwipParsefncText while ^BX ships fd. Reading the raw + // content for the preview kept the parens and one leading FNC1, so the two + // sized the symbol from different data (DM steps up in discrete sizes). + it("previews the runs the ^FD ships, not the parenthesized content", () => { + const plan = planGs1Fd("(01)«GTIN»(10)ABC", "datamatrix"); + expect(plan.bwipParsefncText).toBe("^FNC101«GTIN»10ABC"); + expect(plan.fd).not.toContain("("); + }); + + it("separates the preview runs wherever the ^FD separates them", () => { + // AI 10 is variable-length, so a following AI needs its own FNC1 in both. + const plan = planGs1Fd("(10)«LOT»(11)260809", "datamatrix"); + expect(plan.bwipParsefncText).toBe("^FNC110«LOT»^FNC111260809"); + // The marker's guillemets are non-printable bytes, hence the _dNNN escapes. + expect(plan.fd).toBe("_110_d171LOT_d187_111260809"); + }); +}); diff --git a/packages/core/src/lib/gs1Plan.ts b/packages/core/src/lib/gs1Plan.ts index a92651cc..6bd30490 100644 --- a/packages/core/src/lib/gs1Plan.ts +++ b/packages/core/src/lib/gs1Plan.ts @@ -1,10 +1,17 @@ import { + completeTypedGtins, GS1_GS, parseGs1ToSegments, segmentsToElementString, segmentsToZplFd, + segmentsToContent, } from "./gs1"; -import { gs1ContentToDataMatrixFd } from "./dataMatrixFd"; +import { + gs1ContentToDataMatrixFd, + typedGs1DataRuns, + typedGs1ToDataMatrixFd, +} from "./dataMatrixFd"; +import { hasTemplateMarkers } from "./fnTemplate"; /** GS1 carriers with distinct ^FD grammars: ^BC mode D (parenthesized + >8), * ^BX quality 200 (`_1` escapes), ^BR (raw content; separator grammar is @@ -50,6 +57,28 @@ export function planGs1Fd(content: string, carrier: Gs1Carrier): Gs1FdPlan { losses: [], }; } + // The catalog must not segment around a marker (it would read the marker's + // own characters as the field); post-substitution emitters pass the resolved + // form themselves. + if (hasTemplateMarkers(content)) { + // Feeds preview only; emit resolves markers separately and never routes template content through .fd. + const typed = carrier === "code128" ? completeTypedGtins(content) : null; + // The canvas encodes the same runs the ^FD does, or it would size the + // symbol from the parens and single FNC1 that never reach the wire. + const dmRuns = carrier === "datamatrix" ? typedGs1DataRuns(content) : null; + return { + // ^BX takes the structural form (parens out, FNC1 by AI). + fd: + carrier === "datamatrix" + ? (typedGs1ToDataMatrixFd(content) ?? gs1ContentToDataMatrixFd(content)) + : (typed ?? content), + bwipText: typed ?? content, + bwipParsefncText: dmRuns + ? parsefncRuns(dmRuns.join(GS1_GS), "^FNC1") + : parsefncRuns(typed ?? content, carrier === "datamatrix" ? "^FNC1" : ""), + losses: [], + }; + } const segs = parseGs1ToSegments(content); if (!segs || segs.length === 0) { const fd = carrier === "datamatrix" ? gs1ContentToDataMatrixFd(content) : content; @@ -66,7 +95,10 @@ export function planGs1Fd(content: string, carrier: Gs1Carrier): Gs1FdPlan { case "code128": return parsed(segmentsToZplFd(segs)); case "datamatrix": - return parsed(gs1ContentToDataMatrixFd(content)); + // From the segments: ^BX encodes verbatim, so a typed "(01)" would ship + // its parens (only ^BC mode D strips them, spec p.95). Unstructured + // remainders stay inside their segment's value. + return parsed(gs1ContentToDataMatrixFd(segmentsToContent(segs))); case "databar": return parsed(content); } diff --git a/packages/core/src/lib/imageToZpl.ts b/packages/core/src/lib/imageToZpl.ts index fcbb8e41..a6d097f3 100644 --- a/packages/core/src/lib/imageToZpl.ts +++ b/packages/core/src/lib/imageToZpl.ts @@ -157,7 +157,14 @@ export function monoPreviewCanvas( threshold: number, ): HTMLCanvasElement | null { const raster = rasterizeMono(img, widthDots, threshold); - if (!raster) return null; + return raster ? rasterPreviewCanvas(raster) : null; +} + +/** The packed raster as a canvas, shared by the source-image path and the + * decoded-^GF one so both previews are drawn identically. */ +export function rasterPreviewCanvas(raster: MonoRaster): HTMLCanvasElement | null { + // A degenerate raster would make ImageData throw (same guard as rasterizeMono). + if (raster.widthDots <= 0 || raster.heightDots <= 0) return null; const canvas = document.createElement("canvas"); canvas.width = raster.widthDots; canvas.height = raster.heightDots; @@ -183,7 +190,23 @@ export async function imageToGFA( threshold = 128, rotation: ZplRotation = 'N', ): Promise { - const img = await loadImage(dataUrl, 'Failed to load image for GFA conversion'); + return gfaFromImage( + await loadImage(dataUrl, 'Failed to load image for GFA conversion'), + widthDots, + threshold, + rotation, + ); +} + +/** Same encode from an already-decoded image, for callers that had to decode + * first to check it: a second decode of the same source costs its own timeout + * budget. */ +export function gfaFromImage( + img: HTMLImageElement, + widthDots: number, + threshold = 128, + rotation: ZplRotation = 'N', +): GfaResult { const raster = rasterizeMono(img, widthDots, threshold, rotation); if (!raster) throw new Error("Could not rasterize image"); return { diff --git a/packages/core/src/lib/loadImage.ts b/packages/core/src/lib/loadImage.ts index 24a9eb68..463cccd6 100644 --- a/packages/core/src/lib/loadImage.ts +++ b/packages/core/src/lib/loadImage.ts @@ -1,10 +1,21 @@ +/** A decode that neither loads nor errors would park its caller forever; the + * agent-supplied graphics reaching this are answered on a timeout upstream. */ +export const DECODE_TIMEOUT_MS = 15_000; + /** Load an image from a URL or data-URL. Rejects with `message` on failure. * Centralises the new Image() + onload/onerror decode boilerplate. */ export function loadImage(src: string, message = 'Failed to load image'): Promise { return new Promise((resolve, reject) => { const img = new Image(); - img.onload = () => resolve(img); - img.onerror = () => reject(new Error(message)); + const timer = setTimeout(() => reject(new Error(message)), DECODE_TIMEOUT_MS); + img.onload = () => { + clearTimeout(timer); + resolve(img); + }; + img.onerror = () => { + clearTimeout(timer); + reject(new Error(message)); + }; img.src = src; }); } diff --git a/packages/core/src/lib/objectBounds.rightAnchor.test.ts b/packages/core/src/lib/objectBounds.rightAnchor.test.ts new file mode 100644 index 00000000..53679b51 --- /dev/null +++ b/packages/core/src/lib/objectBounds.rightAnchor.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from "vitest"; +import { objectBoundsDots, rightAnchorShiftDots } from "./objectBounds"; +import { computePreflight } from "./preflight"; +import type { LabelObject } from "../types/Group"; +import type { PageLabel } from "../types/LabelConfig"; + +const label = { widthMm: 100, heightMm: 50, dpmm: 8 } as PageLabel; +const ctx = { label }; + +const text = (extra: Record = {}, props: Record = {}): LabelObject => + ({ + id: "t", type: "text", x: 776, y: 25, rotation: 0, + props: { content: "ROESTEREI SEIT 1998", fontHeight: 20, fontWidth: 0, rotation: "N", ...props }, + ...extra, + }) as LabelObject; + +describe("a right-justified field's x is the printed right edge", () => { + it("puts the box left of the anchor, where the print lands", () => { + const plain = objectBoundsDots(text(), ctx); + const right = objectBoundsDots(text({ fieldJustify: "R" }), ctx); + expect(right.width).toBeCloseTo(plain.width, 5); + expect(right.x + right.width).toBeCloseTo(776, 5); + }); + + it("leaves a left-justified field alone", () => { + expect(objectBoundsDots(text(), ctx).x).toBe(776); + }); + + it("keeps 1D barcodes and graphics on their left edge, which their emit converts", () => { + const bc = { id: "b", type: "code128", x: 100, y: 10, rotation: 0, fieldJustify: "R", + props: { content: "123", height: 50, moduleWidth: 2, rotation: "N" } } as unknown as LabelObject; + expect(rightAnchorShiftDots(bc, 200)).toBe(0); + const box = { id: "g", type: "box", x: 100, y: 10, rotation: 0, fieldJustify: "R", + props: { width: 200, height: 50 } } as unknown as LabelObject; + expect(rightAnchorShiftDots(box, 200)).toBe(0); + }); + + it("shifts a ^GS symbol too, which emits through the same anchor echo", () => { + const symbol = { id: "s", type: "symbol", x: 300, y: 10, rotation: 0, fieldJustify: "R", + props: { symbol: "A", width: 30, height: 30, rotation: "N" } } as unknown as LabelObject; + expect(rightAnchorShiftDots(symbol, 30)).toBe(30); + expect(objectBoundsDots(symbol, ctx).x).toBe(270); + }); + + it("leaves a ^FB block alone, where the block width owns the justification", () => { + expect(rightAnchorShiftDots(text({ fieldJustify: "R" }, { blockWidth: 300 }), 200)).toBe(0); + }); +}); + +describe("a serial field whose block props lie dormant", () => { + it("anchors from the right, like the single line it emits as", () => { + // Serial mode resolves to "normal"; blockWidth stays behind but unused, so + // the field is not a block and its x still means the right edge. + const serial = text({ fieldJustify: "R" }, { serial: { start: "1", step: 1 }, blockWidth: 300 }); + const box = objectBoundsDots(serial, ctx); + expect(box.x + box.width).toBeCloseTo(776, 5); + }); +}); + +describe("a right-justified field hanging off the home edge", () => { + // Its ink runs left of the anchor, so an anchor that is on the label says + // nothing: without a box test the whole field reported clean. + it("is reported off-label, exactly as the left-justified twin is", () => { + const small = { widthMm: 37.5, heightMm: 25, dpmm: 8 } as never; + const text = (justify: "L" | "R") => + ({ + id: "t", type: "text", x: 100, y: 20, rotation: 0, fieldJustify: justify, + props: { content: "HELLO WORLD LONG", fontHeight: 40, fontWidth: 0, rotation: "N" }, + }) as never; + const kinds = (justify: "L" | "R") => + computePreflight([text(justify)], { label: small }, "mm").map((f) => f.kind); + expect(kinds("L")).toContain("offLabelClipped"); + expect(kinds("R")).toContain("offLabelClipped"); + }); +}); + +describe("a right-justified field off the bottom edge", () => { + // The home-edge shortcut must not downgrade a field that is also fully below + // the label to "clipped"; nothing of it prints. + it("is outside, not clipped, when it also hangs off the home edge", () => { + const small = { widthMm: 50, heightMm: 30, dpmm: 8 } as never; // 400x240 dots + const text = { + id: "t", type: "text", x: 5, y: 500, rotation: 0, fieldJustify: "R", + props: { content: "HELLO", fontHeight: 30, fontWidth: 0, rotation: "N" }, + } as never; + expect(computePreflight([text], { label: small }, "mm").map((f) => f.kind)) + .toContain("offLabelOutside"); + }); +}); + +describe("a right-justified ^FT graphic hanging off the home edge", () => { + // Graphics keep model x on the LEFT and convert on emit (^FT x+w,y+h,1), so + // their ink runs left of the anchor just like a right-justified text field's. + // The anchor test alone reported the whole box clean. + it("is reported off-label, like the left-justified twin", () => { + const label = { widthMm: 100, heightMm: 50, dpmm: 8 } as never; + const box = (justify?: "R") => + ({ + id: "b", type: "box", x: -50, y: 20, rotation: 0, + positionType: "FT", ...(justify ? { fieldJustify: justify } : {}), + props: { width: 100, height: 50, thickness: 2, color: "B", rounding: 0 }, + }) as never; + const kinds = (justify?: "R") => + computePreflight([box(justify)], { label }, "mm").map((f) => f.kind); + // Left-justified anchors at the negative x itself (nothing prints); + // right-justified anchors at x+w, which is ON the label, so only the box + // test catches the half that hangs off. Both must be flagged. + expect(kinds()).toContain("offLabelOutside"); + expect(kinds("R")).toContain("offLabelClipped"); + }); +}); + +describe("a runaway ^GF bytes-per-row", () => { + it("does not report an 8-million-dot box", () => { + const label = { widthMm: 60, heightMm: 40, dpmm: 8 } as never; + const img = { + id: "g", type: "image", x: 0, y: 0, rotation: 0, + props: { imageId: "", widthDots: 8, rawGf: "^GFA,,,1000000," }, + } as never; + const box = objectBoundsDots(img, { label }); + expect(box.width).toBeLessThan(100000); + }); +}); diff --git a/packages/core/src/lib/objectBounds.ts b/packages/core/src/lib/objectBounds.ts index aaac621e..0823a698 100644 --- a/packages/core/src/lib/objectBounds.ts +++ b/packages/core/src/lib/objectBounds.ts @@ -13,9 +13,9 @@ import type { LabelObject } from "../types/Group"; import { getAllLeaves, isGroup } from "../types/Group"; import type { LeafObject } from "../registry"; -import { gfaHeaderDims, type ImageProps } from "../registry/image"; -import { getImage } from "./imageCache"; +import { gfaHeaderDims, headerByteSource, type ImageProps } from "../registry/image"; import { BARCODE_1D_TYPES, STACKED_2D_TYPES, getEntry } from "../registry"; +import { GRAPHIC_ANCHOR_TYPES } from "../registry/zplHelpers"; import type { PageLabel } from "../types/LabelConfig"; import { effectiveDpmm } from "../types/LabelConfig"; import { isAxisSwapped, objectRotation, type ZplRotation } from "../registry/rotation"; @@ -55,7 +55,7 @@ export interface ObjectBoundsCtx { /** Swap width/height for the quarter-turn rotations. Mirrors how every * rotation-aware renderer derives its rotated footprint from the upright one. */ -function rotatedFootprint( +export function rotatedFootprint( width: number, height: number, rotation: ZplRotation, @@ -178,11 +178,9 @@ export function isBarcode(obj: { type: string }): boolean { return BARCODE_TYPES.has(obj.type); } -/** Store-less ^GFA header dims (the byte truth objectBoundsDots sizes by); - * null whenever the image resolves through any other source. */ +/** Store-less ^GFA header dims, the byte truth objectBoundsDots sizes by (recall fields included). */ function imageHeaderBounds(p: ImageProps): { width: number; height: number | null } | null { - if (p.storedAs || p.rawGf || getImage(p.imageId) || objectRotation(p) !== "N") return null; - return gfaHeaderDims(p._gfaCache); + return gfaHeaderDims(headerByteSource(p)); } /** True when objectBoundsDots estimates this leaf headlessly: barcode registry @@ -206,6 +204,60 @@ export function boundsAreApprox( /** Axis-aligned model-space bbox (dots) for one object. Always the VISUAL * top-left regardless of FO/FT, so align/distribute can use min/max edges. */ export function objectBoundsDots(obj: LabelObject, ctx: ObjectBoundsCtx): BoundingBoxDots { + const box = objectBoxDots(obj, ctx); + const shift = rightAnchorShiftDots(obj, box.width); + return shift === 0 ? box : { ...box, x: box.x - shift }; +} + +/** Rotated box width for a right-anchored field; null (unmeasurable) is distinct from a real zero shift. */ +export function rightAnchorBoxWidthDots( + obj: LeafObject, + measuredBoxWidthDots?: number, +): number | null { + if (measuredBoxWidthDots !== undefined && measuredBoxWidthDots > 0) return measuredBoxWidthDots; + if (obj.type === "symbol") return (obj.props as { width: number }).width; + if (obj.type === "text") { + const p = obj.props as { content: string; fontHeight: number; rotation: ZplRotation }; + if (isBlankText(p.content)) { + return rotatedFootprint(p.fontHeight * EMPTY_TEXT_PLACEHOLDER_GLYPHS, p.fontHeight, p.rotation).width; + } + } + return null; +} + +/** Whether the printed box sits left of obj.x; 1D barcodes/graphics convert on emit, text/2D symbols do not. */ +export function isRightAnchoredField(obj: LabelObject): boolean { + if (isGroup(obj) || obj.fieldJustify !== "R") return false; + if (BARCODE_1D_TYPES.has(obj.type) || GRAPHIC_ANCHOR_TYPES.has(obj.type)) return false; + // A block carries its own width and the firmware justifies inside it; that is + // a different question from the field anchor. Same mode decision the bounds + // use, so a serial field with dormant block props still counts as single line. + if (obj.type === "text") { + const p = obj.props; + if (resolveTextMode(p) !== "normal" && (p.blockWidth ?? 0) > 0) return false; + } + return true; +} + +/** How far left of `obj.x` the ink starts (see isRightAnchoredField). + * ZD230-measured (^IS preview, all four rotations): the firmware shifts along + * x by the ROTATED box width, never along the field direction. */ +export function rightAnchorShiftDots(obj: LabelObject, widthDots: number): number { + return isRightAnchoredField(obj) ? widthDots : 0; +} + +/** True when ink runs left of the emitted anchor (right-justified text/symbol/2D, or right-justified ^FT graphic). */ +export function inkRunsLeftOfAnchor(obj: LabelObject): boolean { + if (isRightAnchoredField(obj)) return true; + return ( + !isGroup(obj) && + GRAPHIC_ANCHOR_TYPES.has(obj.type) && + obj.positionType === "FT" && + obj.fieldJustify === "R" + ); +} + +function objectBoxDots(obj: LabelObject, ctx: ObjectBoundsCtx): BoundingBoxDots { if (isGroup(obj)) return groupBounds(obj, ctx); switch (obj.type) { @@ -390,12 +442,23 @@ export function offLabelPlacement( anchor: { x: number; y: number }, box: BoundingBoxDots, label: PageLabel, + /** Ink runs LEFT of the anchor then, so the anchor tests say nothing about + * it and a field hanging off the home edge would report clean. */ + rightAnchored = false, ): OffLabel | null { const r = printableRectDots(label); if (anchor.x < r.x - EDGE_EPS || anchor.y < r.y - EDGE_EPS) return "outside"; + // The home edge is a far edge for a right-anchored field (its ink runs left), + // tested alongside right/bottom so a field off the bottom is not downgraded. + const overLeft = rightAnchored && box.x < r.x - EDGE_EPS; const overRight = box.x + box.width > r.x + r.width + EDGE_EPS; const overBottom = box.y + box.height > r.y + r.height + EDGE_EPS; - if (!overRight && !overBottom) return null; - const onLabel = box.x < r.x + r.width - EDGE_EPS && box.y < r.y + r.height - EDGE_EPS; + if (!overLeft && !overRight && !overBottom) return null; + // Real overlap in both axes = part still prints (clipped); no overlap = gone. + const onLabel = + box.x + box.width > r.x + EDGE_EPS && + box.x < r.x + r.width - EDGE_EPS && + box.y + box.height > r.y + EDGE_EPS && + box.y < r.y + r.height - EDGE_EPS; return onLabel ? "clipped" : "outside"; } diff --git a/packages/core/src/lib/objectOverlap.ts b/packages/core/src/lib/objectOverlap.ts index 4c8bf156..ee4c0ca2 100644 --- a/packages/core/src/lib/objectOverlap.ts +++ b/packages/core/src/lib/objectOverlap.ts @@ -56,10 +56,13 @@ function intersect(a: BoundingBoxDots, b: BoundingBoxDots): BoundingBoxDots | nu * stop and let the caller flag truncation. */ export const MAX_OVERLAPS = 500; -/** Index loop (no per-row slice allocation) with an early exit at `cap`. */ +/** Index loop (no per-row slice allocation) with an early exit at `cap`. + * `keep` rejects a pair before it counts against the cap: a caller that + * discards pairs afterwards would let them crowd out real collisions. */ export function computeOverlaps( boxes: readonly LeafBoxDots[], cap: number = MAX_OVERLAPS, + keep?: (a: LeafBoxDots, b: LeafBoxDots) => boolean, ): OverlapDots[] { const out: OverlapDots[] = []; for (let i = 0; i < boxes.length && out.length < cap; i++) { @@ -69,7 +72,8 @@ export function computeOverlaps( const bj = boxes[j]; if (!bj) continue; const rect = intersect(bi.box, bj.box); - if (rect) out.push({ a: bi.id, b: bj.id, ...rect, approx: bi.approx || bj.approx }); + if (!rect || (keep && !keep(bi, bj))) continue; + out.push({ a: bi.id, b: bj.id, ...rect, approx: bi.approx || bj.approx }); } } return out; diff --git a/packages/core/src/lib/preflight.ts b/packages/core/src/lib/preflight.ts index ef7bcf1b..01ba6194 100644 --- a/packages/core/src/lib/preflight.ts +++ b/packages/core/src/lib/preflight.ts @@ -1,9 +1,15 @@ import { getEntry, gs1StaticUnparsed, isGs1Active, usesPlainCode128Escape, type LeafObject } from "../registry"; -import { objectBoundsDots, offLabelPlacement, type ObjectBoundsCtx } from "./objectBounds"; +import { + inkRunsLeftOfAnchor, + objectBoundsDots, + offLabelPlacement, + type ObjectBoundsCtx, +} from "./objectBounds"; import { emittedAnchorDots } from "./emittedAnchor"; import { suspiciousCharDetail } from "./suspiciousChars"; -import { GS1_GS, parseGs1ToSegments, validateGs1Segment, validateGs1SegmentResolved } from "./gs1"; -import { DATAMATRIX_FD_ESCAPE } from "./dataMatrixFd"; +import { GS1_GS, parseGs1ToSegments, typedGs1Parts, typedGs1Shape, validateGs1Segment, validateGs1SegmentResolved } from "./gs1"; +import { DATAMATRIX_FD_ESCAPE, typedGs1ToDataMatrixFd } from "./dataMatrixFd"; +import { gs1CarrierFor, planGs1Fd } from "./gs1Plan"; import { extractTemplateRefs, hasTemplateMarkers, pickEmbedChar } from "./fnTemplate"; import { hasClockMarkers, pickClockChars } from "./fcTemplate"; import { planCode128Fd, planHasLoss } from "./code128Plan"; @@ -346,7 +352,12 @@ export function computePreflight( // below owns the blank-field signal. const blankText = leaf.type === "text" && content !== undefined && isBlankText(content); if (!blankText) { - const placement = offLabelPlacement(emittedAnchorDots(leaf, ctx, box), box, ctx.label); + const placement = offLabelPlacement( + emittedAnchorDots(leaf, ctx, box), + box, + ctx.label, + inkRunsLeftOfAnchor(leaf), + ); const kind = placement === "outside" ? "offLabelOutside" : placement === "clipped" ? "offLabelClipped" : null; if (kind) findings.push({ objectId: leaf.id, kind, severity: PREFLIGHT_SEVERITY[kind] }); @@ -421,3 +432,90 @@ export function computePreflight( } return findings; } + +/** Data characters only: the AI catalog's canonical form differs from the + * caller's in punctuation, never in payload. */ +const gs1DataChars = (s: string): string => + s.replaceAll("(", "").replaceAll(")", "").replaceAll(GS1_GS, ""); + +/** The catalog silently normalizes what it can parse: a 13-digit GTIN grows a + * computed check digit, stray characters vanish. Rewriting caller data without + * a word is worse than refusing it, so the difference is reported. */ +export function gs1NormalizationFindings(leaves: readonly LeafObject[]): PreflightFinding[] { + const out: PreflightFinding[] = []; + for (const leaf of leaves) { + const carrier = gs1CarrierFor(leaf.type); + if (!carrier || (leaf.props as { gs1?: boolean }).gs1 === false) continue; + if (leaf.type !== "gs1databar" && !(leaf.props as { gs1?: boolean }).gs1) continue; + const content = getObjectStringContent(leaf) ?? ""; + if (content === "") continue; + // ^BR ships the content verbatim on every path (no fd transform), so + // neither a rewrite nor a derivability demand ever applies to it; a + // canvas-vs-wire GTIN divergence is mirror-drift work. + if (carrier === "databar") continue; + if (hasTemplateMarkers(content)) { + // A whole-field binding is canonical: the row supplies the entire element + // string, so there is no structure to derive. + if (!isLoneMarker(content)) { + const shape = typedGs1Shape(content); + // ^BX has to remove the parentheses itself and place every FNC1, so it + // needs the catalog; ^BC mode D strips them in firmware and needs it + // only to separate one AI from the next. + const derivable = + carrier === "datamatrix" + ? typedGs1ToDataMatrixFd(content) !== null + : shape !== null && (shape.length === 1 || typedGs1Parts(content) !== null); + if (!derivable) { + out.push({ + objectId: leaf.id, + kind: "gs1ValueInvalid", + severity: PREFLIGHT_SEVERITY.gs1ValueInvalid, + detail: + shape === null + ? "GS1 content with a variable must be written as (AI)value" + : "an AI here is not in the catalog, so the separator after it cannot be placed", + }); + } + } + continue; + } + const canonical = planGs1Fd(content, carrier).bwipText; + if (gs1DataChars(canonical) === gs1DataChars(content)) continue; + out.push({ + objectId: leaf.id, + kind: "gs1ValueInvalid", + severity: PREFLIGHT_SEVERITY.gs1ValueInvalid, + detail: `the payload was rewritten to ${canonical}`, + }); + } + return out; +} + +/** Printers that resolve ^FE, per the ZPL guide (p. 192). A field mixing text + * with markers has no other wire form, so the caller has to know before it + * treats the export as print-ready. */ +const FE_PRINTERS = "ZD421C/D, ZD621D/T, ZT411/421, ZT510, ZT610/620"; + +/** Fields that mix literal text with variable slots emit ^FE, which most + * firmware ignores; a whole-field binding emits plain ^FN and is unaffected. */ +export function templateFieldFindings( + leaves: readonly LeafObject[], + variables: readonly Variable[], +): PreflightFinding[] { + const out: PreflightFinding[] = []; + for (const leaf of leaves) { + const content = getObjectStringContent(leaf); + if (content === undefined) continue; + const field = classifyField(content, variables); + // refs empty means no marker names a variable (clock token, control chip, + // orphan): markersToEmbeds arms no ^FE for any of those. + if (field.kind !== "template" || field.refs.length === 0) continue; + out.push({ + objectId: leaf.id, + kind: "printerSupportLimited", + severity: PREFLIGHT_SEVERITY.printerSupportLimited, + detail: `mixed text and variables emit ^FE (${FE_PRINTERS} only)`, + }); + } + return out; +} diff --git a/packages/core/src/lib/templateObjects.ts b/packages/core/src/lib/templateObjects.ts new file mode 100644 index 00000000..eab8674b --- /dev/null +++ b/packages/core/src/lib/templateObjects.ts @@ -0,0 +1,61 @@ +// Subtree-wide template-marker rewrites: the object-graph twin of fnTemplate's +// per-string helpers. Shared so the editor's variable rename/delete and the MCP +// server's patch ops cannot drift apart. + +import { isGroup, type LabelObject } from '../types/Group'; +import { renameTemplateMarkers, substituteTemplateMarker } from './fnTemplate'; +import { getObjectStringContent } from './variableBinding'; + +/** Apply `fn` to every leaf's `content` in a subtree. Identity-preserving on + * no change, so downstream memoisation survives an edit that touched nothing. */ +function mapLeafContent( + objects: LabelObject[], + fn: (content: string) => string, +): LabelObject[] { + let changed = false; + const next = objects.map((obj) => { + if (isGroup(obj)) { + const nextChildren = mapLeafContent(obj.children, fn); + if (nextChildren === obj.children) return obj; + changed = true; + return { ...obj, children: nextChildren }; + } + const content = getObjectStringContent(obj); + if (content === undefined) return obj; + const mapped = fn(content); + if (mapped === content) return obj; + changed = true; + const props = (obj as { props: object }).props; + return { ...obj, props: { ...props, content: mapped } } as LabelObject; + }); + return changed ? next : objects; +} + +/** Rename one marker across a subtree (see rewriteTemplateMarkersMap). */ +export function rewriteTemplateMarkers( + objects: LabelObject[], + oldName: string, + newName: string, +): LabelObject[] { + return rewriteTemplateMarkersMap(objects, new Map([[oldName, newName]])); +} + +/** Rename many names in ONE pass per leaf, each looked up against the original + * name: order-independent and collision-safe (swaps/chains can't cascade). */ +export function rewriteTemplateMarkersMap( + objects: LabelObject[], + renames: ReadonlyMap, +): LabelObject[] { + if (renames.size === 0) return objects; + return mapLeafContent(objects, (content) => renameTemplateMarkers(content, renames)); +} + +/** Replace every `«name»` marker with `replacement` across a subtree's leaf + * `content`. Used on variable deletion. */ +export function substituteTemplateMarkers( + objects: LabelObject[], + name: string, + replacement: string, +): LabelObject[] { + return mapLeafContent(objects, (content) => substituteTemplateMarker(content, name, replacement)); +} diff --git a/packages/core/src/lib/zplGenerator.ts b/packages/core/src/lib/zplGenerator.ts index 7faf23d0..5ad13698 100644 --- a/packages/core/src/lib/zplGenerator.ts +++ b/packages/core/src/lib/zplGenerator.ts @@ -26,7 +26,7 @@ import { isOverlayConsistent, MIN_JM_SPAN, type FormatHead, type JmSpan } from ' import { reconstructBlockHead } from './zplHeadScan'; import { objectBoundsDots, type ObjectBoundsCtx } from './objectBounds'; import { formatFontDownloadFromPath } from './customFonts'; -import { inlineGfaFor, imageEmitDims, type ImageProps } from '../registry/image'; +import { imageEmitDims, imageEmitRotation, parseGfHeader, shippableGfa, type ImageProps } from '../registry/image'; import { formatStoragePath } from './storagePath'; function formatDownloadObject(m: CustomFontMapping): string | undefined { @@ -173,16 +173,12 @@ function formatSetOffset( /** ~DY for a graphic upload. Format letter is preserved so :Z64: stays paired with C. */ function formatGraphicUpload(p: ImageProps): string | undefined { if (!p.storedAs) return undefined; - const cache = p._gfaCache ?? inlineGfaFor(p); - if (!cache) return undefined; - // Byte-count headers are optional in ^GF, hence \d* not \d+. - const m = /^\^GF([ABC]),(\d*),(\d*),(\d+),([\s\S]*)$/.exec(cache); - if (!m) return undefined; - const format = m[1]; - const total = m[2]; - const bpr = m[4]; - const data = m[5]; - return `~DY${formatStoragePath(p.storedAs, false)},${format},G,${total},${bpr},${data}`; + // Same resolver toZPL uses, so an unshippable cache falls back to a fresh + // encode here too instead of dropping the upload the ^XG depends on. + const cache = shippableGfa(p, imageEmitRotation(p)); + const h = cache ? parseGfHeader(cache) : null; + if (!h) return undefined; + return `~DY${formatStoragePath(p.storedAs, false)},${h.format},G,${h.totalBytes},${h.bytesPerRow},${h.payload}`; } /** Head-less replay block, once a density decision is due: self-declares @@ -713,9 +709,13 @@ function generateZplBlock( if (p.storedAs.embedInZpl === false) continue; const key = formatStoragePath(p.storedAs, false); if (seenGraphics.has(key)) continue; - seenGraphics.add(key); const dy = formatGraphicUpload(p); - if (dy) lines.push(dy); + if (!dy) continue; + // Claimed only once an upload actually exists: reserving the path first + // meant one object whose bytes cannot be written silenced every later + // object sharing it, so each emitted its ^XG against a file nobody sent. + seenGraphics.add(key); + lines.push(dy); } // ~SD is immediate (not EEPROM), emit before ^XA so it applies to this label. diff --git a/packages/core/src/lib/zplParser.multiline.test.ts b/packages/core/src/lib/zplParser.multiline.test.ts new file mode 100644 index 00000000..fdf3dda9 --- /dev/null +++ b/packages/core/src/lib/zplParser.multiline.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest"; +import { importZplText } from "./zplImportService"; + +const single = "^XA^FO10,10^A0N,20,20^FDx^FS^PQ2,0,0,N^XZ"; +const lines = "^XA\n^FO10,10^A0N,20,20^FDx^FS\n^PQ2,0,0,N\n^XZ"; + +describe("line-oriented ZPL", () => { + it("reads the last parameter the same with or without line breaks", () => { + expect(importZplText(lines, 8).labelConfig.overridePauseCount).toBe( + importZplText(single, 8).labelConfig.overridePauseCount, + ); + expect(importZplText(lines, 8).labelConfig.overridePauseCount).toBe("N"); + }); + + it("leaves field data verbatim, unlike the parameter list", () => { + const r = importZplText("^XA\n^FO10,10^A0N,20,20^FDkeep me \n^FS\n^XZ", 8); + const text = r.pages[0]?.objects[0] as { props: { content: string } }; + expect(text.props.content).toBe("keep me \n"); + }); +}); + +describe("a parameter whose value is a space", () => { + it("survives, because ^FE takes any character", () => { + // Spec p.191: the embed delimiter is any character but ^ and ~. + const r = importZplText("^XA\n^FN1^FDA^FS\n^FE ^FO10,10^A0N,20,20^FD 1 ^FS\n^XZ", 8); + const text = r.pages[0]?.objects.find((o) => o.type === "text") as { props: { content: string } }; + expect(text.props.content).toContain("«"); + }); +}); + +describe("indented ZPL", () => { + // The common hand-authored shape: the break is followed by the next line's + // indent, so an end-anchored newline strip never fires. + it("keeps an enum parameter intact when the next line is indented", () => { + for (const zpl of [ + "^XA\n ^FO10,10^A0N,20,20^FDx^FS\n ^PQ2,0,0,N\n ^XZ", + "^XA\r\n\t^PQ2,0,0,N \r\n\t^XZ", + ]) { + expect(importZplText(zpl, 8).labelConfig.overridePauseCount, zpl).toBe("N"); + } + }); +}); + +describe("a whitespace parameter after a non-blank one", () => { + // ^FC's tertiary indicator IS the space here; the strip may only take + // whitespace hanging after real content, not a whitespace-valued slot. + it("keeps the space tertiary clock indicator, with and without line breaks", () => { + for (const zpl of [ + "^XA\n^FC%,{, \n^FO10,10^A0N,20,20^FD H^FS\n^XZ", + "^XA^FC%,{, ^FO10,10^A0N,20,20^FD H^FS^XZ", + ]) { + const text = importZplText(zpl, 8).pages[0]?.objects[0] as { props: { content: string } }; + expect(text.props.content, zpl).toContain("«clock3:H»"); + } + }); +}); + +describe("a trailing space with no line break", () => { + // Single-line ZPL has no wrap indentation to strip, so a space after the last + // parameter is real data (a ^SN seed here) and must survive; the strip is + // line-break-only. A regression of the strip shortened the seed to "AB". + it("keeps a space the last parameter ends with", () => { + const r = importZplText("^XA^FO10,10^A0N,20,20^SNAB ^FS^XZ", 8); + const text = r.pages[0]?.objects.find((o) => o.type === "text") as { props: { content: string } }; + expect(text.props.content).toBe("AB "); + }); +}); + +describe("a whitespace character parameter at line end", () => { + // ^FE's parameter IS a space here; only the break and indent may go. + it("keeps the space delimiter that the line break follows", () => { + const zpl = "^XA\n^FN1^FDA^FS\n^FE \n^FO10,10^A0N,20,20^FD 1 ^FS\n^XZ"; + const objects = importZplText(zpl, 8).pages[0]?.objects ?? []; + const contents = objects.map((o) => (o as { props?: { content?: string } }).props?.content); + expect(contents).toContain("«field_1»"); + }); +}); + +describe("a command whose parameters carry a long run of line breaks", () => { + // Regression pin: the old strip regex backtracked cubically (114s at 8000 breaks) on raw-binary ^GF payloads. + it("parses in linear time instead of backtracking", () => { + const zpl = `^XA^FO10,10^A0N,20,20^FD${"\n".repeat(8000)}x^FS^XZ`; + const started = Date.now(); + importZplText(zpl, 8); + expect(Date.now() - started).toBeLessThan(2000); + }, 10_000); +}); diff --git a/packages/core/src/lib/zplParser.ts b/packages/core/src/lib/zplParser.ts index f479a741..198bec0a 100644 --- a/packages/core/src/lib/zplParser.ts +++ b/packages/core/src/lib/zplParser.ts @@ -9,7 +9,7 @@ import { isLoneMarker } from "./variableField"; import { markerOf } from "../types/Variable"; import { getObjectStringContent } from "./variableBinding"; import { parseLabelMetaComment, type LabelMeta } from "./zplLabelMeta"; -import { tokenize } from "./zplParser/helpers"; +import { stripLineWrap, stripTrailingSpaces, tokenize } from "./zplParser/helpers"; import { lookaheadJmDensity, scanBareStream } from "./zplHeadScan"; import { createParserState, deriveUnitScale, resetFormatScopedState, type FnDefaultCandidate } from "./zplParser/context"; import { createFlushField } from "./zplParser/flushField"; @@ -243,6 +243,8 @@ export function parseZPL( // split happens at dispatch via the token's source char. ~JM is not a real // command (only caret ^JM sets density), so it routes here as a noop too. const tildeDeviceCodes = new Set(["PH", "PP", "JM"]); + // Commands whose last param is literal data, where a trailing space is real (^SN, ^SF, ^A@), not line-wrap noise. + const LITERAL_TAIL_CMDS = new Set(["SN", "SF", "A@"]); Object.assign(handlers, setupScriptHandlers); Object.assign(handlers, createLabelConfigHandlers(s, dpmm)); Object.assign(handlers, createUnitsHandler(s, dpmm)); @@ -432,7 +434,12 @@ export function parseZPL( }; for (const { cmd, rest, start } of tokens) { - const p = rest.split(s.format.delimiterChar); + // Strips trailing break plus indent from the last literal param; LITERAL_TAIL_CMDS keep real trailing spaces. + const p = stripLineWrap(rest).split(s.format.delimiterChar); + const last = p[p.length - 1]; + if (!LITERAL_TAIL_CMDS.has(cmd) && last !== undefined && /\S/.test(last)) { + p[p.length - 1] = stripTrailingSpaces(last); + } // Flag printer-config commands: lossless replay re-emits them, so they run // on the user's printer at print/export. Recorded by code (deduped later). // ~PH/~PP: flagged as device actions AND skipped entirely, or they would diff --git a/packages/core/src/lib/zplParser/decoders/gfa.ts b/packages/core/src/lib/zplParser/decoders/gfa.ts index 541d6bd6..4ec1ea57 100644 --- a/packages/core/src/lib/zplParser/decoders/gfa.ts +++ b/packages/core/src/lib/zplParser/decoders/gfa.ts @@ -1,4 +1,4 @@ -import { unzlibSync } from "fflate"; +import { Unzlib } from "fflate"; import { parseGfWrapper, wrapGfB64 } from "./crc"; import { latin1ToBytes, NON_LATIN1_RE } from "../../binaryText"; import type { UnsafeRawFieldSpan } from "../helpers"; @@ -24,10 +24,38 @@ export function rewriteRawFieldSpans( return out + text.slice(cursor); } -/** Inflate `:Z64:` zlib payload; null on malformed deflate stream. */ +/** Inflates a :Z64: zlib payload, streamed so a zip bomb aborts at the cap instead of OOMing the webview. */ function tryInflateZlib(input: Uint8Array): Uint8Array | null { + // unzlibSync threw on empty input; the streamed loop simply never runs, so + // without this a 0-byte payload decoded "successfully" to nothing and the + // canvas painted a transparent graphic over the missing-graphic placeholder. + if (input.length === 0) return null; try { - return unzlibSync(input); + const chunks: Uint8Array[] = []; + let total = 0; + let overflow = false; + const inflate = new Unzlib((chunk) => { + total += chunk.length; + if (total > GF_MAX_DECODED_BYTES) { + overflow = true; + throw new Error("gf inflate exceeds the decode budget"); + } + chunks.push(chunk); + }); + // Fed in slices so a runaway ratio is caught after the first over-cap chunk, + // before the full output is ever allocated. + const STEP = 16_384; + for (let i = 0; i < input.length && !overflow; i += STEP) { + inflate.push(input.subarray(i, i + STEP), i + STEP >= input.length); + } + if (overflow) return null; + const out = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + out.set(c, offset); + offset += c.length; + } + return out; } catch { return null; } @@ -86,7 +114,10 @@ export function gfPayloadToBytes( } if (format === "C") return null; if (format === "A") { - return { data: gfaHexToBytes(decompressGFA(rawData, bytesPerRow)), crcOk: true }; + const expanded = decompressGFA(rawData, bytesPerRow); + // Null past the budget rather than partial rows, which would re-export a silently cropped image. + if (expanded === null) return null; + return { data: gfaHexToBytes(expanded), crcOk: true }; } if (rawData.length === byteCount && !NON_LATIN1_RE.test(rawData)) { return { data: latin1ToBytes(rawData), crcOk: true, raw: true }; @@ -94,6 +125,11 @@ export function gfPayloadToBytes( return null; } +/** Decode ceiling shared with the preview budget (gfaDecode's 16 Mdot cap): + * RLE lets a few input chars declare unbounded output, so the decoder stops + * at the size no consumer would accept anyway. */ +export const GF_MAX_DECODED_BYTES = 2_000_000; + const HEX_RE = /[0-9A-Fa-f]/; const isHex = (ch: string) => HEX_RE.test(ch); const isCompressChar = (ch: string) => @@ -106,24 +142,35 @@ const repeatCount = (ch: string): number => { // ^GFA ZPL Alt Data Compression: G-Y x1-19, g-z x20-400 (mult 20), combinable. // , = pad row with 0; ! = pad with F; : = repeat previous row. -function decompressGFA(data: string, bytesPerRow: number): string { +function decompressGFA(data: string, bytesPerRow: number): string | null { const nibblesPerRow = bytesPerRow * 2; + const maxRows = Math.ceil((GF_MAX_DECODED_BYTES * 2) / nibblesPerRow); const rows: string[] = []; let currentRow = ""; let i = 0; + /** Set when a repeat run had to be cut short: the output is then a partial + * graphic, never an honest one. */ + let clamped = false; + + /** Nibbles still inside the decode budget, so no single step can exceed it. */ + const remainingNibbles = () => + Math.max(0, (maxRows - rows.length) * nibblesPerRow - currentRow.length); + const pushRow = () => { rows.push(currentRow.slice(0, nibblesPerRow).padEnd(nibblesPerRow, "0")); currentRow = ""; }; - while (i < data.length) { + while (i < data.length && rows.length < maxRows) { const ch = data[i] ?? ""; if (ch === ",") { + // Labelary: always emits a row, even after one the data filled exactly. pushRow(); i++; } else if (ch === "!") { + // Same with ones (p.1759), and likewise unconditional. currentRow = currentRow.padEnd(nibblesPerRow, "F"); rows.push(currentRow.slice(0, nibblesPerRow)); currentRow = ""; @@ -144,7 +191,10 @@ function decompressGFA(data: string, bytesPerRow: number): string { } const nextCh = data[i] ?? ""; if (i < data.length && isHex(nextCh)) { - currentRow += nextCh.repeat(count); + // Clamped before the allocation, not by the row cap below: one repeat count can over-allocate on its own. + const room = remainingNibbles(); + if (count > room) clamped = true; + currentRow += nextCh.repeat(Math.min(count, room)); i++; } } else if (isHex(ch)) { @@ -154,12 +204,19 @@ function decompressGFA(data: string, bytesPerRow: number): string { i++; } - if (currentRow.length >= nibblesPerRow) { + // Drained fully: one long repeat run can span many rows, and a lone `if` + // lets currentRow grow (and re-slice) quadratically. + while (currentRow.length >= nibblesPerRow) { rows.push(currentRow.slice(0, nibblesPerRow)); currentRow = currentRow.slice(nibblesPerRow); } } + // Stopped on the cap rather than on the input, or cut a run short: either way + // the rest of the graphic was never expanded, so there is no honest partial + // answer to hand back. + if (i < data.length || clamped) return null; + if (currentRow.length > 0) { pushRow(); } diff --git a/packages/core/src/lib/zplParser/helpers.ts b/packages/core/src/lib/zplParser/helpers.ts index d14c97c8..9539bef6 100644 --- a/packages/core/src/lib/zplParser/helpers.ts +++ b/packages/core/src/lib/zplParser/helpers.ts @@ -371,3 +371,19 @@ export function decodeFH( return decoder.decode(bytes); }); } + +/** Scanned, not regex-matched: the old pattern backtracked cubically on long break runs. */ +export function stripLineWrap(rest: string): string { + const kept = rest.trimEnd().length; + const nl = rest.slice(kept).search(/[\r\n]/); + return nl === -1 ? rest : rest.slice(0, kept + nl); +} + +/** Trailing horizontal whitespace of the last parameter, same reasoning. */ +export function stripTrailingSpaces(value: string): string { + let end = value.length; + while (end > 0 && WS_NOT_BREAK_RE.test(value[end - 1] ?? "")) end--; + return end === value.length ? value : value.slice(0, end); +} + +const WS_NOT_BREAK_RE = /[^\S\r\n]/; diff --git a/packages/core/src/registry/datamatrix.ts b/packages/core/src/registry/datamatrix.ts index 0fd83137..687bd715 100644 --- a/packages/core/src/registry/datamatrix.ts +++ b/packages/core/src/registry/datamatrix.ts @@ -1,6 +1,12 @@ import type { ObjectTypeCore } from '../types/ObjectType'; import { fieldPosZ, fdFieldFor } from './zplHelpers'; -import { DATAMATRIX_FD_ESCAPE } from '../lib/dataMatrixFd'; +import { + DATAMATRIX_FD_ESCAPE, + gs1ContentToDataMatrixFd, + typedGs1ToDataMatrixFd, +} from '../lib/dataMatrixFd'; +import { hasTemplateMarkers } from '../lib/fnTemplate'; +import { isLoneMarker } from '../lib/variableField'; import { planGs1Fd } from '../lib/gs1Plan'; import { moduleTooSmallPreflight } from '../lib/barcodeScannability'; import { type ZplRotation } from './rotation'; @@ -22,6 +28,14 @@ export const DM_RECT_SIZES = [ const dmGs1Fd = (s: string): string => planGs1Fd(s, 'datamatrix').fd; +/** Markers reach the transform as ^FE embeds the AI catalog cannot read as + * values; typedGs1ToDataMatrixFd owns what stays derivable from them. */ +const dmGs1TemplateFd = (s: string): string => + typedGs1ToDataMatrixFd(s) ?? gs1ContentToDataMatrixFd(s); + +const dmGs1Transform = (content: string): ((s: string) => string) => + hasTemplateMarkers(content) && !isLoneMarker(content) ? dmGs1TemplateFd : dmGs1Fd; + export interface DataMatrixProps { content: string; dimension: number; // module size in dots @@ -73,7 +87,7 @@ export const datamatrix: ObjectTypeCore = { // GS1 mode FNC1-escapes the payload; shared with the CSV batch override. // Non-GS1 content is arbitrary bytes, emitted verbatim (the printer owns any // ^BX escape sequences it contains). - fdTransform: (obj) => (obj.props.gs1 ? dmGs1Fd : undefined), + fdTransform: (obj) => (obj.props.gs1 ? dmGs1Transform(obj.props.content) : undefined), toZPL: (obj, ctx) => { const p = obj.props; @@ -94,7 +108,13 @@ export const datamatrix: ObjectTypeCore = { return [ fieldPosZ(obj), `^BX${params.join(',')}`, - fdFieldFor(p.content, ctx, p.gs1 ? dmGs1Fd : undefined, undefined, CONTROL_CHARS && !p.gs1), + fdFieldFor( + p.content, + ctx, + p.gs1 ? dmGs1Transform(p.content) : undefined, + undefined, + CONTROL_CHARS && !p.gs1, + ), ].join(''); }, }; diff --git a/packages/core/src/registry/image.gfaOnly.test.ts b/packages/core/src/registry/image.gfaOnly.test.ts new file mode 100644 index 00000000..68fe51a4 --- /dev/null +++ b/packages/core/src/registry/image.gfaOnly.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { ObjectRegistry } from "./index"; +import type { LabelObject } from "../types/Group"; + +const gfaOnly = (): LabelObject => + ({ + id: "img", type: "image", x: 0, y: 0, rotation: 0, + props: { imageId: "", widthDots: 16, heightDots: 2, threshold: 128, rotation: "N", _gfaCache: "^GFA,4,4,2,FF00FF00" }, + }) as LabelObject; + +describe("resizing a graphic that only exists as bytes", () => { + const entry = ObjectRegistry.image!; + + it("keeps the box, because the bytes cannot be re-encoded", () => { + const changes = entry.commitTransform?.(gfaOnly() as never, { sx: 2, sy: 2, snap: (d: number) => d } as never); + expect(changes).toEqual({}); + }); + + it("still emits the graphic afterwards", () => { + expect(entry.toZPL?.(gfaOnly() as never, {} as never)).toContain("^GFA,4,4,2,FF00FF00"); + }); + + it("keeps the bytes at a rotated orientation too", () => { + // Rotation R renders as a placeholder (emit is upright-only), but the + // cache is still the only copy: a resize commit must not clear it, or + // rotating back to N could never restore the graphic. + const rotated = gfaOnly(); + (rotated as { props: { rotation: string } }).props.rotation = "R"; + const changes = entry.commitTransform?.(rotated as never, { sx: 2, sy: 2, snap: (d: number) => d } as never); + expect(changes).toEqual({}); + }); +}); diff --git a/packages/core/src/registry/image.rotation.test.ts b/packages/core/src/registry/image.rotation.test.ts new file mode 100644 index 00000000..f0eedf54 --- /dev/null +++ b/packages/core/src/registry/image.rotation.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { headerByteSource, imageEmitRotation, shippableGfa, type ImageProps } from "./image"; + +const GFA = "^GFA,4,4,2,FF00FF00"; + +// A field switched to ^XG keeps whatever rotation it had inline, but ^XG always +// recalls upright. Gating byte resolution on the raw prop dropped the ~DY while +// the ^XG that depends on it still shipped, and blanked the canvas preview. +describe("a recall field carrying a rotation from its inline past", () => { + const recall = (rotation: string) => + ({ + imageId: "gone", + widthDots: 8, + threshold: 128, + rotation, + _gfaCache: GFA, + storedAs: { device: "R", name: "IMG.GRF" }, + }) as unknown as ImageProps; + + it("resolves its bytes upright at any stored rotation", () => { + for (const r of ["N", "R", "I", "B"]) { + expect(imageEmitRotation(recall(r))).toBe("N"); + expect(headerByteSource(recall(r))).toBe(GFA); + expect(shippableGfa(recall(r), imageEmitRotation(recall(r)))).toBe(GFA); + } + }); +}); + +// The other reason rotation cannot be honoured: no source image to re-raster. +// That one must NOT collapse to upright, or the field prints an orientation the +// user did not ask for instead of saying it cannot. +describe("byte-only inline bytes carrying a rotation nothing can apply", () => { + it("keeps the rotation so the refusal stays loud", () => { + const p = { + imageId: "gone", widthDots: 8, threshold: 128, rotation: "R", _gfaCache: GFA, + } as unknown as ImageProps; + expect(imageEmitRotation(p)).toBe("R"); + expect(headerByteSource(p)).toBeUndefined(); + }); +}); diff --git a/packages/core/src/registry/image.shipGuard.test.ts b/packages/core/src/registry/image.shipGuard.test.ts new file mode 100644 index 00000000..f5bc9477 --- /dev/null +++ b/packages/core/src/registry/image.shipGuard.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from "vitest"; +import { gfShipsSafely } from "./image"; + +// Corpus of every payload a review found reaching the wire, so a guard rewrite cannot quietly reopen one. +describe("payloads that must never ship verbatim", () => { + const REFUSED: [string, string][] = [ + ["no byte count at all", "^GFB,,,1,^XZ^XA^JUS^XZ"], + ["format A carries a caret", "^GFA,1,1,1,00^XZ^XA^JUF"], + ["commands past the declared count", "^GFB,4,4,2,AAAA^XZ^XA~JB"], + // c is the DECOMPRESSED size for format C, so it can never bound the wire. + ["compressed, c standing in for b", `^GFC,,4096,80,${"A".repeat(40)}^XZ${"B".repeat(36)}`], + ["header carrying no data", "^GFB,8,8,1,"], + // Labelary: omitting c produces no label at all, the firmware eats the rest + // of the stream looking for an end it was never told. + ["no graphic-field count", "^GFB,,,2,ABCD"], + // c bounds the data, not b: cutting at b left the ^XZ past c unscanned. + ["commands past c while b over-declares", "^GFB,9999,2,2,AB^XZ"], + ["bare header, no payload at all", "^GFA,8,8,1"], + ["not a ^GF command", "^XZ"], + ["a bare device command", "~JB"], + ["unreadable header with a caret", "^GFA, 8, 8, 1, FF^XZ"], + ]; + + for (const [name, payload] of REFUSED) { + it(`refuses: ${name}`, () => { + expect(gfShipsSafely(payload)).toBe(false); + }); + } +}); + +describe("payloads the importer preserves and that must keep shipping", () => { + const ACCEPTED: [string, string][] = [ + ["plain hex", "^GFA,4,4,2,FF00FF00"], + ["the wrapper the parser writes", "^GFB,4,4,2,:B64:AAAA:9c02"], + ["control bytes inside the declared count", "^GFB,4,4,2,A^B~"], + ["under-read payload, count over-declared", "^GFB,9999,9999,10,AB"], + ["b omitted, which Labelary prints identically", "^GFB,,4,2,ABCD"], + // Looks like a hole and is not: the firmware still owes itself 1e20 bytes, + // so it eats everything following AS DATA and the ^XZ never executes. A + // broken label, which is what the source stream already said, not a command. + ["a count no payload could ever satisfy", "^GFB,99999999999999999999,8,1,AB^XZ"], + // What our own encoder writes for a 4x6in label at 8 dpmm. A spec-range + // gate on b/c silently emptied exactly this. + ["a full-size graphic our encoder emits", `^GFA,124236,124236,102,${"F".repeat(20)}`], + ]; + + for (const [name, payload] of ACCEPTED) { + it(`ships: ${name}`, () => { + expect(gfShipsSafely(payload)).toBe(true); + }); + } +}); diff --git a/packages/core/src/registry/image.ts b/packages/core/src/registry/image.ts index d535022d..a176859b 100644 --- a/packages/core/src/registry/image.ts +++ b/packages/core/src/registry/image.ts @@ -33,6 +33,16 @@ export function isImageRotatable(p: ImageProps): boolean { return !!getImage(p.imageId) && !p.storedAs && !p.rawGf; } +/** The rotation the bytes are resolved at. ^XG recall and opaque rawGf print + * upright by construction, so a rotation left over from an inline past is + * meaningless there, not merely unachievable: gating on it dropped the ~DY + * while the ^XG depending on it still shipped. A cache with no source image + * keeps its rotation, so an impossible re-raster still refuses out loud + * instead of printing the wrong orientation. */ +export function imageEmitRotation(p: ImageProps): ZplRotation { + return p.storedAs || p.rawGf ? 'N' : objectRotation(p); +} + /** Emitted (byte-padded) footprint of the image field, axes swapped on a baked * R/B rotation. Shared by toZPL and the generator's home-shift drop check so * the two can't disagree on the anchor footprint. */ @@ -40,11 +50,9 @@ export function imageEmitDims(p: ImageProps): { width: number; height: number } if (isImageRotatable(p) && isAxisSwapped(objectRotation(p))) { return { width: gfByteWidth(imageEmitHeight(p)), height: p.widthDots }; } - if (!getImage(p.imageId) && objectRotation(p) === 'N') { - const header = gfaHeaderDims(p._gfaCache); - if (header) { - return { width: header.width, height: header.height ?? (p.heightDots ?? p.widthDots) }; - } + const header = gfaHeaderDims(headerByteSource(p)); + if (header) { + return { width: header.width, height: header.height ?? (p.heightDots ?? p.widthDots) }; } return { width: gfByteWidth(p.widthDots), height: imageEmitHeight(p) }; } @@ -105,36 +113,152 @@ function gfaSync(dataUrl: string, widthDots: number, threshold: number, rotation return raster ? gfaFromRaster(raster) : ''; } + +export interface GfHeader { + format: "A" | "B" | "C"; + /** b / c params as written; byte-count headers are optional (empty string). */ + totalBytes: string; + dataBytes: string; + bytesPerRow: number; + payload: string; +} + +/** The one ^GF header grammar. Every consumer (dims, preview decode, ~DY + * upload, the MCP boundary) reads it through here, so they cannot drift on + * what counts as a header. */ +export function parseGfHeader(value: string | undefined): GfHeader | null { + // The comma after d is required whenever a payload follows: without it the + // firmware reads "2FF00" as d and drops the graphic. + const m = value ? /^\^GF([ABC]),(\d*),(\d*),(\d+)(?:,|$)/.exec(value) : null; + if (!m) return null; + // b and c stay ungated: the spec's 1..99999 (p.215) is a doc limit, not a + // wire limit, and our own encoder emits past it. + const bytesPerRow = Number(m[4]); + if (bytesPerRow <= 0) return null; + return { + format: m[1] as GfHeader["format"], + totalBytes: m[2] ?? "", + dataBytes: m[3] ?? "", + bytesPerRow, + payload: value !== undefined ? value.slice(m[0].length) : "", + }; +} + +/** 8192 dots a row, far past any real label at 24 dpmm. Shared cap so bounds, + * emit and the preview decoder reject the same runaway header. */ +export const GF_MAX_BYTES_PER_ROW = 1024; + +/** Rows a header may declare. Past this it is not a label graphic, and the + * derived height would reach the emitted ^FT and the off-label check. */ +export const GF_MAX_ROWS = 20_000; + +/** Whether this ^GF can ship verbatim: format A and the base64 wrappers ban ^/~ anywhere, raw binary only past its declared count. */ +export function gfShipsSafely(value: string): boolean { + const head = parseGfHeader(value); + // The shared runaway cap, applied here too: without it a wide graphic shipped + // at full width while gfaHeaderDims returned null and the footprint fell back + // to the props width, so emit and bounds described different ink. + if (head && head.bytesPerRow > GF_MAX_BYTES_PER_ROW) return false; + // A header we cannot read carries no count to bound its data by, so nothing + // here can tell data from an appended command. + if (!head) return false; + // A bare header declares bytes it never sends, so the firmware reads the rest + // of the stream as graphic data (p.215) and the block never terminates. + if (head.payload.trim() === "") return false; + // Same outcome without c: Labelary produces no label for a header missing it, + // because nothing tells the firmware where the graphic ends. b may be omitted + // freely, which renders identically. + if (head.dataBytes === "") return false; + const trimmed = head.payload.replace(/^\s+/, ""); + const wrapped = trimmed.startsWith(":B64:") || trimmed.startsWith(":Z64:"); + // p.215, ASCII hex: "~DN or any caret or tilde character prematurely aborts + // the download"; format A and the base64 wrappers ban either character anywhere. + if (head.format === "A" || wrapped) return !/[\^~]/.test(head.payload); + // Boundary is c (bitmap size), not b (host bytes), per spec p.215; without c nothing bounds the data. + const countStr = head.format === "C" ? "" : head.dataBytes; + if (countStr === "") return !/[\^~]/.test(head.payload); + const byteCount = Number(countStr); + if (!Number.isInteger(byteCount) || byteCount < 0) return false; + // Wire bytes, not string indices: the generator emits ^CI28, so one payload char can be several bytes. + const wire = new TextEncoder().encode(head.payload); + return !wire.subarray(byteCount).some((b) => b === 0x5e || b === 0x7e); +} + /** Printed size from a ^GF header (spec p.215: width = bytes per row x 8, - * lines = count / bytes per row); the header, not the props, is the byte - * truth for store-less emit and bounds (uploads never persist heightDots). - * Empty count slot (preserved foreign header): height null, callers fall - * back to model dims. Null on unparsable or non-positive/fractional rows. */ + * lines = count / bytes per row); the header, not the props, is the byte truth + * for store-less emit and bounds. Empty count slot: height null, callers fall + * back to model dims; null on fractional rows or a runaway width. */ export function gfaHeaderDims( cache: string | undefined, ): { width: number; height: number | null } | null { - const m = cache ? /^\^GF[ABC],\d*,(\d*),(\d+),/.exec(cache) : null; - if (!m) return null; - const bytesPerRow = Number(m[2]); - if (bytesPerRow <= 0) return null; - const width = bytesPerRow * 8; - if (m[1] === "") return { width, height: null }; - const height = Number(m[1]) / bytesPerRow; - return Number.isInteger(height) && height > 0 ? { width, height } : null; + const h = parseGfHeader(cache); + // A payload-less header (^GFA,8,8,1 with no data) is not a usable graphic: + // emit would ship the bare header and firmware would read past it into ^FS. + if (!h || h.bytesPerRow > GF_MAX_BYTES_PER_ROW || h.payload.trim() === "") return null; + // c is required: Labelary renders a header missing b identically to a full + // one, but a header missing c produces no label at all, because the firmware + // never learns where the graphic ends and eats the rest of the stream. + if (h.dataBytes === "") return null; + const width = h.bytesPerRow * 8; + const height = Number(h.dataBytes) / h.bytesPerRow; + // Rows bounded like the width: an unbounded c drove a 20-million-dot field + // into the ^FT anchor and the off-label check, and past 1e21 the number + // formats as "1e+21", which no firmware parses. + if (!Number.isInteger(height) || height <= 0 || height > GF_MAX_ROWS) return null; + return { width, height }; } -/** Store-less byte source: unrotated (a re-raster needs the source image) - * with a parsable header, which then also provides the emit dimensions. */ +/** Store-less byte source: unrotated (a re-raster needs the source image) with a + * parsable header that also provides the emit dimensions, and bytes the stream + * can carry as data rather than as commands. */ function gfaCacheUsable(p: ImageProps): boolean { - return !!p._gfaCache && objectRotation(p) === 'N' && gfaHeaderDims(p._gfaCache) !== null; + return ( + !!p._gfaCache && + imageEmitRotation(p) === 'N' && + gfaHeaderDims(p._gfaCache) !== null && + gfShipsSafely(p._gfaCache) + ); +} + +/** Bytes with no source image are the graphic's only copy: no edit may clear or re-encode them, at any rotation. */ +export function gfaCacheIsOnlyCopy(p: ImageProps): boolean { + return !!p._gfaCache && !getImage(p.imageId); +} + +/** The graphic whose header describes the printed ink, for the sites that size + * the field. rawGf counts at any rotation because toZPL ships it verbatim; a + * cache only upright, where the emit uses it too. */ +export function headerByteSource(p: ImageProps): string | undefined { + if (getImage(p.imageId)) return undefined; + // Only bytes emit will actually ship; memoised per props object because this is scanned several times per frame. + const hit = SHIP_SOURCE_CACHE.get(p); + if (hit !== undefined) return hit.value; + const source = p.rawGf + ? gfShipsSafely(p.rawGf) + ? p.rawGf + : undefined + : imageEmitRotation(p) === 'N' && p._gfaCache && gfShipsSafely(p._gfaCache) + ? p._gfaCache + : undefined; + SHIP_SOURCE_CACHE.set(p, { value: source }); + return source; } +/** Boxed so a cached `undefined` is still a hit. */ +const SHIP_SOURCE_CACHE = new WeakMap(); + /** Fresh upright ^GFA from the image store, for emit sites that need bytes * after a cache-clearing edit (canvas resize regens only via the panel). */ -export function inlineGfaFor(p: ImageProps): string | undefined { +export function inlineGfaFor(p: ImageProps, rotation: ZplRotation = 'N'): string | undefined { const img = getImage(p.imageId); if (!img) return undefined; - return gfaSync(img.dataUrl, p.widthDots, p.threshold, 'N') || undefined; + return gfaSync(img.dataUrl, p.widthDots, p.threshold, rotation) || undefined; +} + +/** The ^GF bytes to ship, cache or fresh encode; undefined means nothing prints. Both stream sites read this. */ +export function shippableGfa(p: ImageProps, rotation: ZplRotation = 'N'): string | undefined { + if (rotation === 'N' && p._gfaCache && gfShipsSafely(p._gfaCache)) return p._gfaCache; + return inlineGfaFor(p, rotation); } export const image: ObjectTypeCore = { @@ -152,11 +276,14 @@ export const image: ObjectTypeCore = { // A width/threshold change without a fresh cache in the same change // invalidates the bytes, or emit/preflight would use the stale raster. - normalizeChanges: (_obj, changes) => { + normalizeChanges: (obj, changes) => { const next = changes.props as Partial | undefined; if (!next || !('widthDots' in next || 'threshold' in next) || '_gfaCache' in next) { return changes; } + // Only when a source image can re-encode them: otherwise the cache is the + // graphic's only copy and clearing it prints nothing. + if (gfaCacheIsOnlyCopy(obj.props)) return changes; return { ...changes, props: { ...next, _gfaCache: undefined } }; }, @@ -165,6 +292,20 @@ export const image: ObjectTypeCore = { // also covers exportable-but-hidden images the canvas never renders. preflight: (obj) => { const p = obj.props; + // Named separately from "no bytes at all": these bytes exist but carry a ^/~ + // the firmware would read as a command, so emit drops them (see toZPL) and + // the user/agent has to hear why rather than seeing a blank field. + if (p.rawGf && !gfShipsSafely(p.rawGf)) { + return [{ kind: 'imageMissing', detail: 'the stored ^GF bytes carry ^ or ~ outside their declared byte count, so they cannot be printed' }]; + } + // A recall field whose ~DY got dropped still keeps its ^XG, so storedAs alone cannot mark it resolvable. + if (p.storedAs && p.storedAs.embedInZpl !== false) { + // Asked, not rasterised: this runs on every findings recompute, so it must not force a full re-encode. + const canUpload = p._gfaCache ? gfShipsSafely(p._gfaCache) : !!getImage(p.imageId); + if (!canUpload) { + return [{ kind: 'imageMissing', detail: 'this field recalls a stored graphic whose upload cannot be written, so the printer has nothing to recall' }]; + } + } const resolvable = !!p.rawGf || !!p.storedAs || !!getImage(p.imageId) || gfaCacheUsable(p); return resolvable ? [] : [{ kind: 'imageMissing' }]; @@ -192,6 +333,8 @@ export const image: ObjectTypeCore = { const dominant = Math.abs(sx - 1) >= Math.abs(sy - 1) ? sx : sy; return { widthDots: widthDots(dominant), _gfaCache: undefined }; } + // Bytes with no source image cannot be re-encoded at a new size, so the box is kept rather than cleared. + if (gfaCacheIsOnlyCopy(obj.props)) return {}; // First-resize fallback for heightDots: use the current widthDots so // the implicit default (square placeholder) matches what the canvas // renders before the user has dragged. Drifting from that (e.g. a @@ -215,7 +358,12 @@ export const image: ObjectTypeCore = { const anchor = graphicFieldPos(obj, d.width, d.height); // Opaque graphic: re-emit the original ^GF verbatim at the (possibly moved) // field position. The bytes were never decoded, so there's nothing to regen. - if (p.rawGf) return `${anchor}${p.rawGf}^FS`; + // Guarded here rather than at the input boundary: this is the one place that + // turns them into a stream, and it needs no guess about where they came from + // (preflight reports the same refusal, so the drop is never silent). + if (p.rawGf) { + return gfShipsSafely(p.rawGf) ? `${anchor}${p.rawGf}^FS` : `${anchor}^FD^FS`; + } // Recall path: upload happened in the preamble; here we just reference // it via ^XG. The `.GRF` extension is implicit on `~DY{path},A,G,…`; // Zebra firmware persists the file as `path.GRF` and `^XG` resolves @@ -231,10 +379,6 @@ export const image: ObjectTypeCore = { } // _gfaCache holds the upright bytes, so a rotated field regenerates fresh // (rasterizeMono bakes the rotation in). - const rot = objectRotation(p); - const gfa = rot === 'N' - ? (p._gfaCache || gfaSync(cached.dataUrl, p.widthDots, p.threshold, 'N')) - : gfaSync(cached.dataUrl, p.widthDots, p.threshold, rot); - return `${anchor}${gfa}^FS`; + return `${anchor}${shippableGfa(p, imageEmitRotation(p)) ?? ''}^FS`; }, }; diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 60a8a9e5..841e82bb 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -120,8 +120,9 @@ export function emitsFieldJustify(type: string, emit1dZJustify = false): boolean return emit1dZJustify || !BARCODE_1D_TYPES.has(type); } -/** Dynamic lookup for `LabelObject['type']`; undefined for non-leaf (e.g. `'group'`). */ +/** Dynamic LabelObject['type'] lookup, hasOwn-gated so "constructor"/"toString" cannot masquerade as a type. */ export function getEntry(type: string): (typeof ObjectRegistry)[LeafType] | undefined { + if (!Object.hasOwn(ObjectRegistry, type)) return undefined; return (ObjectRegistry as Record)[type]; } diff --git a/packages/core/src/types/LabelObject.test.ts b/packages/core/src/types/LabelObject.test.ts new file mode 100644 index 00000000..df183e80 --- /dev/null +++ b/packages/core/src/types/LabelObject.test.ts @@ -0,0 +1,8 @@ +import { describe, it, expect } from "vitest"; +import { NON_EMITTING_PROP_KEYS } from "./LabelObject"; + +describe("NON_EMITTING_PROP_KEYS", () => { + it("membership lock: exactly the editor-only, never-emitted prop keys", () => { + expect([...NON_EMITTING_PROP_KEYS].sort()).toEqual(["preSerialContent"]); + }); +}); diff --git a/packages/core/src/types/LabelObject.ts b/packages/core/src/types/LabelObject.ts index 32b75109..3b1af4db 100644 --- a/packages/core/src/types/LabelObject.ts +++ b/packages/core/src/types/LabelObject.ts @@ -45,6 +45,9 @@ export type LabelObjectBase = z.infer; export type ObjectChanges = Partial> & { props?: object }; +/** Prop keys that are design-time only and never emitted; drives dirty-tracking and the boundary's control-char check. */ +export const NON_EMITTING_PROP_KEYS: ReadonlySet = new Set(['preSerialContent']); + /** Palette DISPLAY grouping only, not a barcode's dimension: `legacy` collects * deprecated/rarely-supported symbologies (obsolete linear + deprecated postal * like PLANET/POSTNET). The 1D/2D truth lives in BARCODE_1D_TYPES / diff --git a/packages/mcp-server/src/tools.test.ts b/packages/mcp-server/src/tools.test.ts index cad51e19..d8417efc 100644 --- a/packages/mcp-server/src/tools.test.ts +++ b/packages/mcp-server/src/tools.test.ts @@ -203,7 +203,8 @@ describe("mcp-server tools", () => { expect(box).toMatchObject({ x: 10, y: 20, width: 200, height: 100, approx: false }); const bc = created.bounds.find((b) => b.objectId === "c"); expect(bc?.approx).toBe(false); - expect(bc!.height).toBe(80); + // 80 bars + the 21-dot HRI line at module width 2 (Labelary-measured). + expect(bc!.height).toBe(101); }); it("reports probed barcode footprints with the full bar-rect entry", () => { @@ -274,9 +275,8 @@ describe("mcp-server tools", () => { .toMatchObject({ width: 8, height: 4, approx: false }); }); - it("keeps a preserved foreign header with an empty count slot exportable", () => { - // The parser preserves such headers verbatim and always sets heightDots; - // the empty count must not read as unusable (silent drop again). + it("reports a preserved foreign header with an empty count slot", () => { + // Labelary: a ^GF missing c eats the rest of the stream (^XZ included), so the field must drop loudly. const gfa = "^GFA,4,,1,00FF00FF"; const design = { schemaVersion: 5, @@ -286,8 +286,11 @@ describe("mcp-server tools", () => { props: { imageId: "gone", widthDots: 8, heightDots: 4, threshold: 128, rotation: "N", _gfaCache: gfa }, }] }], }; - expect(ok(exportZpl(design)).zpl).toContain(gfa); - expect(ok(validateDraft(design)).bounds.find((b) => b.objectId === "img")) + expect(ok(exportZpl(design)).zpl).not.toContain(gfa); + const report = ok(validateDraft(design)); + expect(report.warnings.some((w) => w.kind === "imageMissing")).toBe(true); + // Bounds still describe the field, from props, so placement stays editable. + expect(report.bounds.find((b) => b.objectId === "img")) .toMatchObject({ width: 8, height: 4 }); // Fractional or zero rows stay unusable (malformed header). const bad = { ...design, pages: [{ objects: [{ ...design.pages[0]!.objects[0]!, @@ -307,23 +310,6 @@ describe("mcp-server tools", () => { expect(nearEdge.warnings.some((w) => w.objectId === "q" && w.kind.startsWith("offLabel"))).toBe(true); }); - it("clears a stale ^GFA cache when width or threshold change without fresh bytes", () => { - // A prop change on a machine without the source image must invalidate - // the cache, not print stale bytes at a new anchor width. - const entry = ObjectRegistry.image; - const obj = { - id: "i", type: "image", x: 0, y: 0, rotation: 0, - props: { imageId: "gone", widthDots: 64, threshold: 128, rotation: "N", _gfaCache: "^GFA,8,8,1,00FF00FF00FF00FF" }, - } as never; - const widthOnly = entry.normalizeChanges!(obj, { props: { widthDots: 80 } }); - expect((widthOnly.props as { _gfaCache?: string })._gfaCache).toBeUndefined(); - expect("_gfaCache" in (widthOnly.props as object)).toBe(true); - const withFresh = entry.normalizeChanges!(obj, { props: { widthDots: 80, _gfaCache: "^GFA,1,1,1,00" } }); - expect((withFresh.props as { _gfaCache?: string })._gfaCache).toBe("^GFA,1,1,1,00"); - const unrelated = entry.normalizeChanges!(obj, { props: { rotation: "R" } }); - expect("_gfaCache" in (unrelated.props as object)).toBe(false); - }); - it("validate_zpl reports the intersection rect of two overlapping boxes", () => { const v = ok(validateZpl("^XA^FO0,0^GB100,100,3^FS^FO60,60^GB100,100,3^FS^XZ")); expect(v.overlaps).toHaveLength(1); diff --git a/src/components/Canvas/BarcodeObject.tsx b/src/components/Canvas/BarcodeObject.tsx index b00809e1..24a29edd 100644 --- a/src/components/Canvas/BarcodeObject.tsx +++ b/src/components/Canvas/BarcodeObject.tsx @@ -3,7 +3,7 @@ import { Image as KImage, Group, Rect, Shape, Text } from "react-konva"; import type Konva from "konva"; import { BARCODE_1D_TYPES, ObjectRegistry, objectResolvesCtrl } from "@zplab/core/registry"; import { dotsToPx, mmToDots, pxToDots } from "@zplab/core/lib/coordinates"; -import { barcodeFtAnchorOffset, qrPrintsAsGraphic } from "@zplab/core/lib/objectBounds"; +import { barcodeFtAnchorOffset, qrPrintsAsGraphic, rightAnchorShiftDots } from "@zplab/core/lib/objectBounds"; import { useColorScheme, CANVAS_WARNING } from "../../hooks/useColorScheme"; import { useFontCacheVersion } from "../../hooks/useFontCacheVersion"; import { selectionHandlers, useBlankFieldWarns, PLACEHOLDER_DASH, PLACEHOLDER_STROKE_PX, type KonvaObjectProps } from "./konvaObjectProps"; @@ -261,7 +261,10 @@ export function BarcodeObject({ // when the text zone extends LEFT/ABOVE the bars (rotated EAN/UPC, // inverted EAN/UPC/LOGMARS). The Konva Group is positioned at bbox // top-left and KImage offsets back to land bars at FO. - const x = offsetX + dotsToPx(displayX, scale, dpmm) - dim.barLeftPx; + const x = + offsetX + + dotsToPx(displayX - rightAnchorShiftDots(obj, pxToDots(dim.w, scale, dpmm)), scale, dpmm) - + dim.barLeftPx; const y = offsetY + dotsToPx(displayY, scale, dpmm) - dim.barTopPx; // Dotted frame over the sample bars: orange for a blank (unconfigured) field, diff --git a/src/components/Canvas/ImageObject.gfaFallback.test.tsx b/src/components/Canvas/ImageObject.gfaFallback.test.tsx new file mode 100644 index 00000000..fbfd5341 --- /dev/null +++ b/src/components/Canvas/ImageObject.gfaFallback.test.tsx @@ -0,0 +1,92 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeAll, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import { Stage, Layer } from "react-konva"; +import type Konva from "konva"; +import { ImageObject } from "./ImageObject"; +import type { LabelObject } from "@zplab/core/types/Group"; + +beforeAll(() => { + const noop = () => undefined; + // jsdom ships no ImageData; the preview builds one from the decoded raster. + globalThis.ImageData = class { + data: Uint8ClampedArray; + width: number; + height: number; + constructor(data: Uint8ClampedArray, width: number, height: number) { + this.data = data; + this.width = width; + this.height = height; + } + } as unknown as typeof globalThis.ImageData; + HTMLCanvasElement.prototype.getContext = (() => + new Proxy( + { + getImageData: () => ({ data: new Uint8ClampedArray(4) }), + measureText: () => ({ width: 0 }), + putImageData: noop, + }, + { get: (target, prop) => (prop in target ? target[prop as keyof typeof target] : noop) }, + )) as unknown as typeof HTMLCanvasElement.prototype.getContext; +}); + +afterEach(cleanup); + +/** 16x2 checkerboard: no store entry, only the encoded bytes an agent or an + * import produced. */ +const gfaOnlyImage = (gfa: string | undefined): LabelObject => + ({ + id: "logo", + type: "image", + x: 0, + y: 0, + rotation: 0, + props: { imageId: "", widthDots: 16, heightDots: 2, threshold: 128, rotation: "N", ...(gfa ? { _gfaCache: gfa } : {}) }, + }) as LabelObject; + +function renderedImage(obj: LabelObject): Konva.Image | undefined { + let stage: Konva.Stage | null = null; + render( + { stage = n; }}> + + undefined} + onChange={() => undefined} + snap={(d) => d} + /> + + , + ); + return (stage as unknown as Konva.Stage | null)?.find("Image")[0] as Konva.Image | undefined; +} + +describe("image without a store entry", () => { + it("draws the encoded bytes instead of an empty placeholder", () => { + const node = renderedImage(gfaOnlyImage("^GFA,4,4,2,FF00FF00")); + expect(node).toBeDefined(); + const drawn = node?.image() as HTMLCanvasElement | undefined; + expect(drawn?.width).toBe(16); + expect(drawn?.height).toBe(2); + }); + + it("falls back to the placeholder when there are no bytes either", () => { + expect(renderedImage(gfaOnlyImage(undefined))?.image()).toBeUndefined(); + }); + + it("draws an imported graphic held verbatim as rawGf", () => { + const obj = gfaOnlyImage(undefined) as unknown as { props: Record }; + obj.props.rawGf = "^GFA,4,4,2,FF00FF00"; + const node = renderedImage(obj as never); + expect((node?.image() as HTMLCanvasElement | undefined)?.width).toBe(16); + }); + + it("does not draw a payload it cannot decode", () => { + expect(renderedImage(gfaOnlyImage("^GFB,4,4,2,binary"))?.image()).toBeUndefined(); + }); +}); diff --git a/src/components/Canvas/ImageObject.tsx b/src/components/Canvas/ImageObject.tsx index 3f8e96ac..e0223d69 100644 --- a/src/components/Canvas/ImageObject.tsx +++ b/src/components/Canvas/ImageObject.tsx @@ -3,8 +3,10 @@ import { Group, Image as KImage, Path, Rect } from "react-konva"; import type { LabelObject } from "@zplab/core/types/Group"; import { dotsToPx, pxToDots } from "@zplab/core/lib/coordinates"; import { getImage } from "@zplab/core/lib/imageCache"; +import { rasterFromGfa } from "@zplab/core/lib/gfaDecode"; +import { headerByteSource, imageEmitRotation, isImageRotatable } from "@zplab/core/registry/image"; import { loadImage } from "@zplab/core/lib/loadImage"; -import { monoPreviewCanvas } from "@zplab/core/lib/imageToZpl"; +import { monoPreviewCanvas, rasterPreviewCanvas } from "@zplab/core/lib/imageToZpl"; import { useColorScheme } from "../../hooks/useColorScheme"; import { selectionHandlers, type KonvaObjectProps } from "./konvaObjectProps"; import { setMeasuredBounds, clearMeasuredBounds } from "./measuredBoundsCache"; @@ -14,10 +16,31 @@ import { isAxisSwapped, objectRotation } from "@zplab/core/registry/rotation"; type ImageLabelObject = Extract; type Props = Omit & { obj: ImageLabelObject }; -/** Image renderer. Hosted as its own component so hooks (useState/ - * useEffect for async image loading) can run without violating - * rules-of-hooks. The dispatcher in KonvaObject narrows `obj` - * before passing; no runtime cast needed here. */ +/** Keyed on the props object (like footprintProber's caches): entries die with + * their design instead of pinning megabyte payload strings for the process + * lifetime, and the canvas on screen can never be evicted from under Konva. */ +const gfaPreviewCache = new WeakMap(); + +/** Decode-and-draw for an image the store cannot supply. Memoised per props + * identity (an edit swaps the props object): a re-render must not re-decode, + * and Konva needs a stable image identity. */ +function gfaPreviewCanvas( + props: object, + gfa: string | undefined, + rotation: string, +): HTMLCanvasElement | null { + if (!gfa || rotation !== "N") return null; + const hit = gfaPreviewCache.get(props); + if (hit !== undefined) return hit; + // No visible width: the header is what these bytes print at (headerByteSource). + const raster = rasterFromGfa(gfa); + const canvas = raster ? rasterPreviewCanvas(raster) : null; + gfaPreviewCache.set(props, canvas); + return canvas; +} + +/** Own component so the async-decode hooks stay out of KonvaObject's + * dispatcher, which narrows `obj` before passing. */ export function ImageObject({ obj, scale, @@ -58,19 +81,21 @@ export function ImageObject({ }; }, [cached]); - // Rotatable only for an inline cached bitmap (see isImageRotatable); reuse - // the `cached` lookup already made above. - const rotatable = !!cached && !p.storedAs && !p.rawGf; - const rotation = rotatable ? objectRotation(p) : "N"; + // Whether this instance turns, which is not the same question as the rotation + // its bytes resolve at (imageEmitRotation). + const rotation = isImageRotatable(p) ? objectRotation(p) : "N"; const swap = isAxisSwapped(rotation); - const w = dotsToPx(p.widthDots, scale, dpmm); // WYSIWYG mono preview (see monoPreviewCanvas), handed to Konva to nearest- // neighbour upscale (imageSmoothingEnabled=false, as BarcodeObject). Upright; // the inner Group turns it. The colored source is never shown on the label. const preview = htmlImg && cached ? monoPreviewCanvas(htmlImg, p.widthDots, p.threshold) - : null; + // Byte-only graphic: headerByteSource is the emit-side precedence, so the + // canvas cannot show bytes the print would not use. + : gfaPreviewCanvas(p, headerByteSource(p), imageEmitRotation(p)); + const widthDots = !cached && preview ? preview.width : p.widthDots; + const w = dotsToPx(widthDots, scale, dpmm); // Height from the raster is dot-quantised, so the box matches the emitted // ^GF height exactly. Pre-load, aspect-lock off the cached dimensions // (guarding 0-width malformed files: NaN-sized nodes otherwise); recall-only @@ -107,7 +132,7 @@ export function ImageObject({ // Gate on `preview`, not `htmlImg`: an image that loaded but can't rasterize // (dimensionless SVG, naturalWidth 0) emits a blank ^GF, so showing the color // source would lie. Fall through to the placeholder in that case. - if (preview && cached) { + if (preview) { // bwip-style rotation: the upright preview draws inside an inner Group whose // rotatedGroupTransform places it for R/I/B; the outer Group keeps the // object's x/y and interaction (matches BarcodeObject). diff --git a/src/components/Canvas/KonvaObject.tsx b/src/components/Canvas/KonvaObject.tsx index dcde1fdb..097daebb 100644 --- a/src/components/Canvas/KonvaObject.tsx +++ b/src/components/Canvas/KonvaObject.tsx @@ -7,6 +7,8 @@ import { BarcodeObject } from "./BarcodeObject"; import { LineObject } from "./LineObject"; import { ImageObject } from "./ImageObject"; import { dotsToPx, pxToDots } from "@zplab/core/lib/coordinates"; +import { rotatedFootprint } from "@zplab/core/lib/objectBounds"; +import { rightAnchorShift } from "./transformPosition"; import { measureInkWidthPx } from "@zplab/core/lib/labelGeometry/measureTextDots"; import { outlineInset } from "../../lib/shapeGeometry"; import { reverseShapeStyle } from "./reverseShapeStyle"; @@ -539,7 +541,6 @@ function KonvaObjectInner({ } : null; - const x = offsetX + dotsToPx(obj.x, scale, dpmm); const y = offsetY + dotsToPx(obj.y, scale, dpmm); // Only single-line text needs a measured footprint; block (^FB/^TB) text @@ -554,6 +555,16 @@ function KonvaObjectInner({ const blankSingleLine = isSingleLineText && isBlankText(textMetrics?.content ?? ""); const rotation = obj.type === "text" ? obj.props.rotation : "N"; const isQuarterTurn = isAxisSwapped(rotation); + // A right-justified field's x is the ZPL right edge (see rightAnchorShiftDots), + // so the ink starts one rendered box width to the left of it; core resolves + // the unmeasured cases (symbol props, blank placeholder). + const measuredBoxW = + obj.type === "text" && !blankSingleLine + ? rotatedFootprint(inkWidthDots, fontHeightDots, rotation).width + : undefined; + const x = + offsetX + + dotsToPx(obj.x - rightAnchorShift(obj, measuredBoxW), scale, dpmm); useEffect(() => { if (!isSingleLineText) return; // Blank (empty or whitespace) or zero-height: drop the measured entry so diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index 02b17d6c..c8648889 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -32,8 +32,7 @@ import { usePreviewBinding } from "../../store/usePreviewBinding"; import { useContextMenu } from "../../hooks/useContextMenu"; import { rotateSelectionChanges } from "../../lib/groupRotation"; import { registerBarcodeWidthProber, unregisterBarcodeWidthProber } from "../../store/anchorRepin"; -import { applyBindingToObject } from "@zplab/core/lib/variableBinding"; -import { ctrlParityFor } from "@zplab/core/registry"; +import { resolveForMeasure } from "@zplab/core/lib/barcodeDims"; import { measureBarcodeFootprintDots } from "./bwipHelpers"; import { copyText } from "../../lib/clipboard"; import { selectTidyTargets } from "../../lib/tidyClassify"; @@ -484,18 +483,17 @@ export const LabelCanvas = forwardRef(function LabelCa } = useCanvasPanZoom({ zoom, onZoomChange, fitZoom, containerRef }); const scale = SCREEN_PX_PER_MM * zoom; - // The probe measures the same binding-resolved content KonvaObject draws. - const dataRenderMode = useLabelStore((s) => s.canvasSettings.dataRenderMode); + // Probes variable DEFAULTS, not the previewed row, so a preview toggle cannot move the persisted anchor x. useEffect(() => { - const { variables: vars, active, clock } = previewBinding; + const { variables: vars, clock } = previewBinding; const probe = (o: LabelObject) => { if (isGroup(o)) return null; - const resolved = applyBindingToObject(o, vars, active, dataRenderMode, clock, ctrlParityFor(o)); + const resolved = resolveForMeasure(o, vars, clock); return measureBarcodeFootprintDots(resolved as LeafObject, scale, effDpmm); }; registerBarcodeWidthProber(probe); return () => unregisterBarcodeWidthProber(probe); - }, [scale, effDpmm, previewBinding, dataRenderMode]); + }, [scale, effDpmm, previewBinding]); const labelWidthPx = effectiveWidthMm * scale; const physicalWidthPx = label.widthMm * scale; const labelHeightPx = label.heightMm * scale; diff --git a/src/components/Canvas/barcodePreflight.ts b/src/components/Canvas/barcodePreflight.ts index ee91ef10..42c4a6ae 100644 --- a/src/components/Canvas/barcodePreflight.ts +++ b/src/components/Canvas/barcodePreflight.ts @@ -1,35 +1,21 @@ -import { ctrlParityFor, gs1StaticUnparsed, type LeafObject } from "@zplab/core/registry"; -import { maxicodeScmOwnedByPreflight, type MaxicodeProps } from "@zplab/core/registry/maxicode"; -import { isBarcode } from "@zplab/core/lib/objectBounds"; -import { PREFLIGHT_SEVERITY, type PreflightFinding } from "@zplab/core/lib/preflight"; -import type { Variable } from "@zplab/core/types/Variable"; +import type { LeafObject } from "@zplab/core/registry"; import { - applyBindingToObject, - getObjectStringContent, - type ActiveRow, - type ClockResolveCtx, -} from "@zplab/core/lib/variableBinding"; + barcodeEncodeFindingsCore, + resolveForEncode, + type EncodeEnv, + type EncodeVerdict, +} from "@zplab/core/lib/barcodeEncodePreflight"; +import type { PreflightFinding } from "@zplab/core/lib/preflight"; +import { getObjectStringContent } from "@zplab/core/lib/variableBinding"; import { renderBarcodeCanvas } from "./bwipHelpers"; -/** Binding context so the check encodes what PRINTS: `«marker»` content is - * resolved exactly like the canvas preview. Encoding the raw marker text - * would flag valid payloads (e.g. a GS1 fixed AI filled by a variable) as - * too long. */ -export interface EncodeEnv { - variables: readonly Variable[]; - active: ActiveRow | null; - clock?: ClockResolveCtx; -} +export type { EncodeEnv, EncodeVerdict }; +export { resolveForEncode }; // Cache encode verdicts per object identity (the store is identity- // preserving). The RESOLVED content string is the binding-sensitive key: a // marker-free barcode stays stable across unrelated variable/CSV/clock edits, // a marker barcode re-encodes exactly when its substituted payload changes. -export interface EncodeVerdict { - error: string | null; - approximated: boolean; -} - const encodeCache = new WeakMap< LeafObject, { scale: number; dpmm: number; content: string } & EncodeVerdict @@ -52,15 +38,9 @@ function cachedEncode( return verdict; } -/** Preview-resolved leaf for the encoder (identity-preserving when unbound). */ -export function resolveForEncode(leaf: LeafObject, env: EncodeEnv): LeafObject { - return applyBindingToObject(leaf, env.variables, env.active, "preview", env.clock, ctrlParityFor(leaf)); -} - -/** Encode check over ALL exportable leaves, not just rendered ones, so a - * hidden-but-exported barcode with an uncodable payload (QR overflow, invalid - * EAN, ...) still badges. Lives at the canvas layer because the encoder does. - * `encodeError` is injectable so the mapping is testable without the encoder. */ +/** The shared decision tree bound to the canvas encoder. Lives at the canvas + * layer because the encoder does; `encodeError` stays injectable so the + * mapping is testable without it. */ export function barcodeEncodeFindings( leaves: readonly LeafObject[], scale: number, @@ -68,46 +48,8 @@ export function barcodeEncodeFindings( env: EncodeEnv, encodeError?: (leaf: LeafObject, resolved: LeafObject) => string | null | EncodeVerdict, ): PreflightFinding[] { - const findings: PreflightFinding[] = []; - for (const leaf of leaves) { - // Barcode-only producer: text and shapes never encode, and a bound TEXT - // field resolving empty stays quiet (configured field, and the canvas - // shows an honest empty box there, unlike the barcode's sample bars). - if (!isBarcode(leaf)) continue; - const resolved = resolveForEncode(leaf, env); - if ((getObjectStringContent(resolved) ?? "").trim() === "") { - // A blank payload has nothing to encode, so never a renderFailed error. - // A literal-blank field is already owned by computePreflight's - // emptyContent (raw content ""); a BARCODE whose marker resolves empty - // (empty variable default / empty CSV cell) is raw-nonempty there, yet - // renders as sample bars, so surface its emptiness here. - if ((getObjectStringContent(leaf) ?? "").trim() !== "") { - findings.push({ objectId: leaf.id, kind: "emptyContent", severity: PREFLIGHT_SEVERITY.emptyContent }); - } - continue; - } - // A literal mode 2/3 MaxiCode without a carrier message is owned by - // maxicodeModeMissingScm (computePreflight); skip renderFailed to avoid a - // double report. Marker content isn't skipped: the producer guards it out. - if ( - resolved.type === "maxicode" && - maxicodeScmOwnedByPreflight(getObjectStringContent(leaf) ?? "", resolved.props as MaxicodeProps) - ) { - continue; - } - // Static unparsed GS1 is owned by gs1ContentUnparsed (see - // gs1StaticUnparsed); a second renderFailed would contradict it. - if (gs1StaticUnparsed(leaf.type, leaf.props, getObjectStringContent(leaf) ?? "")) { - continue; - } + return barcodeEncodeFindingsCore(leaves, env, (leaf, resolved) => { const raw = encodeError ? encodeError(leaf, resolved) : cachedEncode(leaf, resolved, scale, dpmm); - const verdict: EncodeVerdict = - raw === null || typeof raw === "string" ? { error: raw, approximated: false } : raw; - if (verdict.error) { - findings.push({ objectId: leaf.id, kind: "renderFailed", severity: PREFLIGHT_SEVERITY.renderFailed, detail: verdict.error }); - } else if (verdict.approximated) { - findings.push({ objectId: leaf.id, kind: "previewApproximate", severity: PREFLIGHT_SEVERITY.previewApproximate }); - } - } - return findings; + return raw === null || typeof raw === "string" ? { error: raw, approximated: false } : raw; + }); } diff --git a/src/components/Canvas/hooks/useKonvaTransformer.ts b/src/components/Canvas/hooks/useKonvaTransformer.ts index 9a7a1ccd..bc360a9a 100644 --- a/src/components/Canvas/hooks/useKonvaTransformer.ts +++ b/src/components/Canvas/hooks/useKonvaTransformer.ts @@ -40,8 +40,8 @@ import { modelPositionFromRenderedTopLeft, renderedTopLeftFromModel, } from "../transformPosition"; -import { isBarcode, type BoundingBoxDots } from "@zplab/core/lib/objectBounds"; -import { projectMultiResize } from "../../../lib/multiResize"; +import { isBarcode, isRightAnchoredField, rotatedFootprint, type BoundingBoxDots } from "@zplab/core/lib/objectBounds"; +import { projectedAnchorXDots, projectMultiResize } from "../../../lib/multiResize"; import { lineHandlesNodeId, lineRootNodeId } from "../konvaObjectProps"; import { isAxisSwapped, objectRotation } from "@zplab/core/registry/rotation"; import { getMeasuredSnapshot } from "../measuredBoundsCache"; @@ -313,6 +313,7 @@ export function useKonvaTransformer({ anchorPxY: number; scales: boolean; uniform: boolean; + frozenX: boolean; hide?: Konva.Node; }[]; ids: string[]; @@ -564,15 +565,20 @@ export function useKonvaTransformer({ // Grips would deform under the group scale; hide for the gesture. const hide = leaf.type === "line" ? stage.findOne(`#${lineHandlesNodeId(id)}`) : null; hide?.visible(false); + const projX = projectedAnchorXDots(leaf, getMeasuredSnapshot().get(id)?.width); return [ { node, startX: node.x(), startY: node.y(), - // Commit and live both project the MODEL anchor; render-offset - // nodes (^FT bar base, ^BQ shift) would jump by off*(f-1) else. - anchorPxX: objectsOffsetX + dotsToPx(leaf.x, scale, dpmm), + // The quantity the commit projects (projectedAnchorXDots), not the + // model anchor: a right-anchored field projects its ink edge, so + // projecting x here jumped it by shift*(1-f) on release. Render + // offsets (^FT bar base, ^BQ shift) ride along unscaled below. + anchorPxX: objectsOffsetX + dotsToPx(projX ?? leaf.x, scale, dpmm), anchorPxY: labelOffsetY + dotsToPx(leaf.y, scale, dpmm), + // Unmeasured right-anchored leaf: the commit holds x, so must live. + frozenX: projX === null, scales: SHAPE_PRIMITIVE_TYPES.has(leaf.type), // lockAspect commits min(fx, fy); live must match or it snaps back. uniform: @@ -845,7 +851,7 @@ export function useKonvaTransformer({ } else { // Anchor projects, the render offset rides along unscaled. n.node.position({ - x: px + (n.anchorPxX - mr.start.x) * fx + (n.startX - n.anchorPxX), + x: n.frozenX ? n.startX : px + (n.anchorPxX - mr.start.x) * fx + (n.startX - n.anchorPxX), y: py + (n.anchorPxY - mr.start.y) * fy + (n.startY - n.anchorPxY), }); } @@ -1354,7 +1360,16 @@ export function useKonvaTransformer({ y: mr.bboxDots.y + pxToDots(endY - mr.start.y, scale, dpmm), }; // Drop no-op entries so a sub-dot jiggle records no undo step. - const changes = projectMultiResize(leafs, mr.bboxDots, origin, fx, fy, snap).filter( + const measured = getMeasuredSnapshot(); + const changes = projectMultiResize( + leafs, + mr.bboxDots, + origin, + fx, + fy, + snap, + (id) => measured.get(id)?.width, + ).filter( (c) => { const l = leafById.get(c.id); if (!l) return false; @@ -1435,6 +1450,18 @@ export function useKonvaTransformer({ ); committedW = dims.w; committedH = dims.h; + } else if (!(obj.positionType === "FT" && isBarcode(obj)) && isRightAnchoredField(obj)) { + // Right-anchored fields: the inverse adds back the committed width, since the Group node's width()/height() are always 0. + const m = getMeasuredSnapshot().get(singleId); + if (m && m.width > 0) { + const up = rotatedFootprint(m.width * sx, m.height * sy, objectRotation(obj.props)); + committedW = up.width; + committedH = up.height; + } else if (obj.type === "symbol") { + const sp = obj.props as { width: number; height: number }; + committedW = sp.width * sx; + committedH = sp.height * sy; + } } // Invert per-type render offsets (QR's +10 Y, the rotation-aware FT bar // anchor) so the stored model matches the render path. Text renders at diff --git a/src/components/Canvas/transformPosition.hriZone.test.ts b/src/components/Canvas/transformPosition.hriZone.test.ts new file mode 100644 index 00000000..bf2842c8 --- /dev/null +++ b/src/components/Canvas/transformPosition.hriZone.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { modelPositionFromRenderedTopLeft, renderedTopLeftFromModel } from "./transformPosition"; +import { setMeasuredBounds, clearMeasuredBounds } from "./measuredBoundsCache"; +import type { LeafObject } from "@zplab/core/registry"; + +const code128 = (rotation: string): LeafObject => + ({ + id: "bc", + type: "code128", + x: 40, + y: 100, + rotation: 0, + positionType: "FO", + props: { content: "12345", height: 100, moduleWidth: 2, printInterpretation: true, rotation }, + }) as unknown as LeafObject; + +afterEach(() => clearMeasuredBounds("bc")); + +describe("^FO barcode with an HRI zone", () => { + it("round-trips model -> rendered -> model", () => { + // Inverted: the zone sits above the bars, so the render draws 21 dots up. + setMeasuredBounds("bc", { + width: 246, height: 121, barHeightDots: 100, + barLeftDots: 0, barTopDots: 21, uprightBarWDots: 246, uprightBarHDots: 100, + }); + const obj = code128("I"); + const rendered = renderedTopLeftFromModel(obj); + expect(rendered.y).toBe(79); + expect(modelPositionFromRenderedTopLeft(obj, rendered.x, rendered.y)).toEqual({ x: 40, y: 100 }); + }); + + it("round-trips on the x axis when the symbol is rotated", () => { + setMeasuredBounds("bc", { + width: 121, height: 246, barHeightDots: 100, + barLeftDots: 21, barTopDots: 0, uprightBarWDots: 246, uprightBarHDots: 100, + }); + const obj = code128("R"); + const rendered = renderedTopLeftFromModel(obj); + expect(rendered.x).toBe(19); + expect(modelPositionFromRenderedTopLeft(obj, rendered.x, rendered.y)).toEqual({ x: 40, y: 100 }); + }); + + it("leaves a barcode without a zone where it is", () => { + setMeasuredBounds("bc", { + width: 246, height: 100, barHeightDots: 100, + barLeftDots: 0, barTopDots: 0, uprightBarWDots: 246, uprightBarHDots: 100, + }); + const obj = code128("N"); + expect(renderedTopLeftFromModel(obj)).toEqual({ x: 40, y: 100 }); + }); +}); + +describe("a right-justified field", () => { + const rightText = (): LeafObject => + ({ + id: "bc", type: "text", x: 400, y: 50, rotation: 0, positionType: "FO", + fieldJustify: "R", + props: { content: "rechts", fontHeight: 30, fontWidth: 0, rotation: "N" }, + }) as unknown as LeafObject; + + it("round-trips its anchor through a resize commit", () => { + setMeasuredBounds("bc", { width: 120, height: 30 }); + const obj = rightText(); + const rendered = renderedTopLeftFromModel(obj); + // Drawn one width left of the anchor, like the renderer and the bounds. + expect(rendered.x).toBe(280); + expect(modelPositionFromRenderedTopLeft(obj, rendered.x, rendered.y)).toEqual({ x: 400, y: 50 }); + }); +}); + +describe("a right-justified symbol", () => { + const symbol = (width: number, height: number, rotation = "N"): LeafObject => + ({ + id: "sym", type: "symbol", x: 400, y: 50, rotation: 0, positionType: "FO", + fieldJustify: "R", + props: { symbol: "A", width, height, rotation }, + }) as unknown as LeafObject; + + it("round-trips its anchor without a measured footprint", () => { + const obj = symbol(40, 40); + const rendered = renderedTopLeftFromModel(obj); + expect(rendered.x).toBe(360); + expect(modelPositionFromRenderedTopLeft(obj, rendered.x, rendered.y)).toEqual({ x: 400, y: 50 }); + }); + + it("uses its width on a quarter turn too, because the ^GS box does not turn", () => { + const obj = symbol(60, 20, "R"); + expect(renderedTopLeftFromModel(obj).x).toBe(340); + expect(modelPositionFromRenderedTopLeft(obj, 340, 50)).toEqual({ x: 400, y: 50 }); + }); +}); + +describe("a right-justified field with nothing in it", () => { + const blank = (): LeafObject => + ({ + id: "empty", type: "text", x: 300, y: 40, rotation: 0, positionType: "FO", + fieldJustify: "R", + props: { content: "", fontHeight: 30, fontWidth: 0, rotation: "N" }, + }) as unknown as LeafObject; + + it("keeps its anchor across a commit, though nothing was measured", () => { + const obj = blank(); + const rendered = renderedTopLeftFromModel(obj); + expect(rendered.x).toBeLessThan(300); + expect(modelPositionFromRenderedTopLeft(obj, rendered.x, rendered.y)).toEqual({ x: 300, y: 40 }); + }); +}); + +describe("branches that return before the fall-through", () => { + const rightJustified = (type: string, positionType: "FO" | "FT"): LeafObject => + ({ + id: "code", type, x: 400, y: 50, rotation: 0, positionType, fieldJustify: "R", + props: { content: "HELLO", magnification: 5, dimension: 6, quality: 200, rotation: "N", height: 60, moduleWidth: 2 }, + }) as unknown as LeafObject; + + it("shifts an ^FO qrcode and a ^FT 2D code like every other anchored field", () => { + for (const [type, pos] of [["qrcode", "FO"], ["qrcode", "FT"], ["datamatrix", "FT"]] as const) { + const obj = rightJustified(type, pos); + setMeasuredBounds("code", { + width: 200, height: 200, barHeightDots: 200, + barLeftDots: 0, barTopDots: 0, uprightBarWDots: 200, uprightBarHDots: 200, + }); + const rendered = renderedTopLeftFromModel(obj); + expect(modelPositionFromRenderedTopLeft(obj, rendered.x, rendered.y).x, `${type} ${pos}`).toBe(400); + clearMeasuredBounds("code"); + } + }); + + it("anchors a quarter-turned ^FT symbol by the width its box actually has", () => { + // The committed pair is upright; on a turn the box is 120 wide, not 400, + // and objectBounds shifts by the box. + setMeasuredBounds("code", { + width: 120, height: 400, barHeightDots: 400, + barLeftDots: 0, barTopDots: 0, uprightBarWDots: 400, uprightBarHDots: 120, + }); + const obj = { + id: "code", type: "pdf417", x: 500, y: 300, rotation: 0, positionType: "FT", fieldJustify: "R", + props: { content: "HELLO", rotation: "R", height: 120, moduleWidth: 2, rowHeight: 4 }, + } as unknown as LeafObject; + const rendered = renderedTopLeftFromModel(obj); + expect(modelPositionFromRenderedTopLeft(obj, rendered.x, rendered.y, 400, 120).x).toBe(500); + clearMeasuredBounds("code"); + }); + + it("inverts a resize with the width being committed, not the stale one", () => { + setMeasuredBounds("bc", { width: 120, height: 30 }); + const obj = { + id: "bc", type: "text", x: 400, y: 50, rotation: 0, positionType: "FO", fieldJustify: "R", + props: { content: "rechts", fontHeight: 30, fontWidth: 0, rotation: "N" }, + } as unknown as LeafObject; + // Grown to 180: the commit has to add back the new width, not the cached one. + expect(modelPositionFromRenderedTopLeft(obj, 220, 50, 180).x).toBe(400); + }); +}); diff --git a/src/components/Canvas/transformPosition.ts b/src/components/Canvas/transformPosition.ts index d91c0c2f..dfc42eec 100644 --- a/src/components/Canvas/transformPosition.ts +++ b/src/components/Canvas/transformPosition.ts @@ -1,6 +1,13 @@ import type { LeafObject } from "@zplab/core/registry"; import { QR_FO_Y_OFFSET_DOTS, QR_FT_MODULE_OFFSET } from "@zplab/core/lib/bwipConstants"; -import { barcodeFtAnchorOffset, isBarcode, qrPrintsAsGraphic } from "@zplab/core/lib/objectBounds"; +import { + barcodeFtAnchorOffset, + isBarcode, + qrPrintsAsGraphic, + rightAnchorBoxWidthDots, + rightAnchorShiftDots, + rotatedFootprint, +} from "@zplab/core/lib/objectBounds"; import { isAxisSwapped, objectRotation, type ZplRotation } from "@zplab/core/registry/rotation"; import { getMeasuredSnapshot } from "./measuredBoundsCache"; @@ -72,15 +79,31 @@ export function modelPositionFromRenderedTopLeft( committedUprightH?: number, committedMagnification?: number, ): { x: number; y: number } { + // Shifts by the upright BOX width, which on a quarter turn is the upright height; the committed pair is always upright. + const committedBoxW = + committedUprightW !== undefined && committedUprightH !== undefined && obj.type !== "symbol" + ? rotatedFootprint(committedUprightW, committedUprightH, objectRotation(obj.props)).width + : committedUprightW; + const anchor = rightAnchorShift(obj, committedBoxW); if (obj.type === "qrcode" && obj.positionType !== "FT" && !qrPrintsAsGraphic(obj)) { - return { x: renderedXDots, y: renderedYDots - QR_FO_Y_OFFSET_DOTS }; + return { x: renderedXDots + anchor, y: renderedYDots - QR_FO_Y_OFFSET_DOTS }; } if (isFtBarcode(obj)) { const c = cacheBar(obj); - const d = ftBarcodeRenderDelta(obj, committedUprightW ?? c.w, committedUprightH ?? c.h, committedMagnification); - return { x: renderedXDots - d.x, y: renderedYDots - d.y }; + const w = committedUprightW ?? c.w; + const h = committedUprightH ?? c.h; + const d = ftBarcodeRenderDelta(obj, w, h, committedMagnification); + const barAnchor = rightAnchorShiftDots(obj, rotatedFootprint(w, h, objectRotation(obj.props)).width); + return { x: renderedXDots - d.x + barAnchor, y: renderedYDots - d.y }; } - return { x: renderedXDots, y: renderedYDots }; + // ^FO barcodes: the render shifts by the HRI zone (objectBounds.barcodeTopLeft + // subtracts it), so the inverse must add it back or a resize commits a + // position one zone off from where the object was drawn. + const zone = foBarcodeZone(obj); + return { + x: renderedXDots + zone.barLeft + anchor, + y: renderedYDots + zone.barTop, + }; } /** Inverse of `modelPositionFromRenderedTopLeft` at the current size. */ @@ -89,12 +112,34 @@ export function renderedTopLeftFromModel(obj: LeafObject): { y: number; } { if (obj.type === "qrcode" && obj.positionType !== "FT" && !qrPrintsAsGraphic(obj)) { - return { x: obj.x, y: obj.y + QR_FO_Y_OFFSET_DOTS }; + return renderedWithAnchor(obj, obj.x, obj.y + QR_FO_Y_OFFSET_DOTS); } if (isFtBarcode(obj)) { const c = cacheBar(obj); const d = ftBarcodeRenderDelta(obj, c.w, c.h); - return { x: obj.x + d.x, y: obj.y + d.y }; + return renderedWithAnchor(obj, obj.x + d.x, obj.y + d.y); } - return { x: obj.x, y: obj.y }; + const zone = foBarcodeZone(obj); + return { x: obj.x - zone.barLeft - rightAnchorShift(obj), y: obj.y - zone.barTop }; +} + +/** Same shift for the branches that return before the fall-through. */ +function renderedWithAnchor(obj: LeafObject, x: number, y: number): { x: number; y: number } { + return { x: x - rightAnchorShift(obj), y }; +} + +/** How far left of its model x the canvas draws a right-justified field; unmeasurable draws unshifted, + * and the renderer, this transform and its inverse all read it here so they stay each other's inverse. */ +export function rightAnchorShift(obj: LeafObject, committedWidth?: number): number { + // A resize passes the width it is committing; otherwise the measured footprint. + const width = rightAnchorBoxWidthDots(obj, committedWidth ?? getMeasuredSnapshot().get(obj.id)?.width); + return width === null ? 0 : rightAnchorShiftDots(obj, width); +} + +/** The HRI-zone offset an ^FO barcode's render applies, zero for everything + * else. Mirrors objectBounds.barcodeTopLeft's plain-^FO return. */ +function foBarcodeZone(obj: LeafObject): { barLeft: number; barTop: number } { + if (!isBarcode(obj)) return { barLeft: 0, barTop: 0 }; + const c = cacheBar(obj); + return { barLeft: c.barLeft, barTop: c.barTop }; } diff --git a/src/lib/densityRescale.test.ts b/src/lib/densityRescale.test.ts index 6e5f6cd0..6dd6e65a 100644 --- a/src/lib/densityRescale.test.ts +++ b/src/lib/densityRescale.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, beforeEach } from "vitest"; import { CALIBRATION_CLAMP, LAYOUT_LABEL_FIELDS, rescaleDesign, rescaleParamsFor, rescaleWouldChange } from "./densityRescale"; import { useLabelStore } from "../store/labelStore"; +import { putImage, removeImage } from "@zplab/core/lib/imageCache"; import type { LabelObject, Page } from "@zplab/core/types/Group"; import type { LeafObject } from "@zplab/core/registry"; import type { LabelConfig } from "@zplab/core/types/LabelConfig"; @@ -91,11 +92,21 @@ describe("rescaleDesign", () => { }); it("drops the stale GFA cache when an editable image is rescaled", () => { + putImage({ id: "a", name: "a.png", dataUrl: "data:image/png;base64,AA", width: 8, height: 8 }); const img = leaf("i", "image", 0, 0, { imageId: "a", widthDots: 100, threshold: 128, _gfaCache: "^GFA,old" } as never); const r = rescaleDesign(page(img), label, 8, 16, { dpmm: 16 }); // factor 2 const out = r.pages[0]!.objects[0] as typeof img; expect((out.props as { widthDots: number }).widthDots).toBe(200); expect((out.props as { _gfaCache?: string })._gfaCache).toBeUndefined(); + removeImage("a"); + }); + + it("locks a cache that has no source image, instead of deleting the only copy", () => { + const img = leaf("i", "image", 0, 0, { imageId: "", widthDots: 100, threshold: 128, _gfaCache: "^GFA,8,8,1,00" } as never); + const r = rescaleDesign(page(img), label, 8, 16, { dpmm: 16 }); + const out = r.pages[0]!.objects[0] as typeof img; + expect((out.props as { _gfaCache?: string })._gfaCache).toBe("^GFA,8,8,1,00"); + expect(r.warnings.some((w) => w.reason === "imageFixed")).toBe(true); }); it("locks the footprint of a verbatim (rawGf) graphic and warns it cannot rescale", () => { diff --git a/src/lib/densityRescale.ts b/src/lib/densityRescale.ts index e96e2896..fdb555a8 100644 --- a/src/lib/densityRescale.ts +++ b/src/lib/densityRescale.ts @@ -1,5 +1,6 @@ import { isGroup, type LabelObject, type LeafObject, type Page } from "@zplab/core/types/Group"; import { getEntry } from "@zplab/core/registry"; +import { gfaCacheIsOnlyCopy, type ImageProps } from "@zplab/core/registry/image"; import { effectiveDpmm, labelConfigSpec, scaledLabelConfigFields, type JmDensity, type LabelConfig } from "@zplab/core/types/LabelConfig"; /** Pending density change: a new head dpmm or a new ^JM mode, both reinterpreting @@ -120,7 +121,10 @@ function rescaleLeaf(leaf: LeafObject, factor: number, warnings: RescaleWarning[ // carry fixed-resolution bytes: their footprint is locked (mirrors // image.commitTransform), only position scales, and we warn it cannot rescale. if (leaf.type === "image") { - if (props.rawGf != null || props.storedAs != null) { + // A cache with no source image behind it is the graphic's only copy (what + // raster_image hands over), so it counts as fixed bytes too: re-scaling + // would clear it with nothing left to re-encode from. + if (props.rawGf != null || props.storedAs != null || gfaCacheIsOnlyCopy(props as unknown as ImageProps)) { warn("widthDots", "imageFixed"); } else { for (const k of ["widthDots", "heightDots"] as const) { diff --git a/src/lib/errorMessage.ts b/src/lib/errorMessage.ts index aa998404..5f8b73d9 100644 --- a/src/lib/errorMessage.ts +++ b/src/lib/errorMessage.ts @@ -1,5 +1 @@ -/** Centralises the `e instanceof Error ? ... : String(e)` coercion every - * Tauri/async call site would otherwise repeat. */ -export function errorMessage(e: unknown): string { - return e instanceof Error ? e.message : String(e); -} +export { errorMessage } from "@zplab/core/lib/errorMessage"; diff --git a/src/lib/groupRotation.test.ts b/src/lib/groupRotation.test.ts index 48448ef6..13732acb 100644 --- a/src/lib/groupRotation.test.ts +++ b/src/lib/groupRotation.test.ts @@ -103,6 +103,18 @@ describe("rotateSelectionChanges", () => { expect(c).toEqual({ x: 10, y: 10, props: { rotation: "R" } }); }); + it("keeps a right-justified symbol on its anchor across a turn", () => { + // objectBounds puts the box one width left of x, so writing the rotated + // left edge straight back would move the symbol by its own width. + const s = leaf("symbol", 100, 10, { symbol: "A", width: 30, height: 30, rotation: "N" }); + (s as { fieldJustify?: string }).fieldJustify = "R"; + const c = rotateSelectionChanges([s], [s.id], ctx(), 1).get(s.id) as { x: number; y: number }; + // A single object turns about its own centre: the box, and with it the + // anchor, stays where it was. + expect(c.x).toBe(100); + expect(c.y).toBe(10); + }); + it("no drift over four turns with a non-integer union centre", () => { // union (0,0)-(61,20), centre x=30.5 -> a float pivot would round each step // and the two boxes would drift apart, only re-aligning after 360deg. diff --git a/src/lib/groupRotation.ts b/src/lib/groupRotation.ts index 8d5b087c..26e42e8c 100644 --- a/src/lib/groupRotation.ts +++ b/src/lib/groupRotation.ts @@ -5,7 +5,7 @@ import type { LabelObject, LeafObject } from "@zplab/core/types/Group"; import { isGroup } from "@zplab/core/types/Group"; -import { objectBoundsDots, selectionUnionDots, type BoundingBoxDots, type ObjectBoundsCtx } from "@zplab/core/lib/objectBounds"; +import { rightAnchorShiftDots, objectBoundsDots, selectionUnionDots, type BoundingBoxDots, type ObjectBoundsCtx } from "@zplab/core/lib/objectBounds"; import { ZPL_ROTATIONS, isZplRotation, type ZplRotation } from "@zplab/core/registry/rotation"; import { barSubRect } from "@zplab/core/lib/bwipConstants"; import { barcodeTextZoneDots, barcodeZoneAbove } from "@zplab/core/lib/barcodeHri"; @@ -101,7 +101,9 @@ function leafChanges( if (leaf.type === "symbol" || leaf.type === "image") { const b = objectBoundsDots(leaf, ctx); const centre = rotateAbout({ x: b.x + b.width / 2, y: b.y + b.height / 2 }, pivot, steps); - const x = Math.round(centre.x - b.width / 2); + // The box of a right-justified field sits one width left of its model x, so + // the new left edge has to be carried back to the anchor before storing it. + const x = Math.round(centre.x - b.width / 2) + rightAnchorShiftDots(leaf, b.width); const y = Math.round(centre.y - b.height / 2); if (leaf.type === "symbol") { const r = advanceRotation((leaf.props as { rotation: string }).rotation, steps); diff --git a/src/lib/multiResize.test.ts b/src/lib/multiResize.test.ts index 9699f912..f20d2255 100644 --- a/src/lib/multiResize.test.ts +++ b/src/lib/multiResize.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { projectMultiResize } from "./multiResize"; +import { projectedAnchorXDots, projectMultiResize } from "./multiResize"; import type { LeafObject } from "@zplab/core/registry"; const ident = (v: number) => v; @@ -127,3 +127,75 @@ describe("projectMultiResize", () => { }); }); + +describe("a right-justified member of the selection", () => { + // Its model x IS the printed right edge while the union bbox is ink space, so + // projecting the raw x walked the field right by its own box width and out of + // the selection frame. + it("keeps its ink edge flush with a left-anchored twin", () => { + const symbol = leaf("s", "symbol", 400, 100, { width: 120, height: 40, symbol: "A", rotation: "N" }); + (symbol as unknown as { fieldJustify: string }).fieldJustify = "R"; + const box = leaf("b", "box", 280, 100, { width: 200, height: 40, thickness: 2, filled: false, color: "B", rounding: 0 }); + // Both ink left edges sit at 280, so the union starts there; pin that edge. + const union = { x: 280, y: 100, width: 200, height: 40 }; + const changes = projectMultiResize([symbol, box], union, { x: union.x, y: union.y }, 2, 1, ident); + // The box stays at 280, and the symbol's ink left edge (x - width) must stay + // 280 too, i.e. its model x stays 400. + expect(changes.find((c) => c.id === "b")?.x).toBe(280); + expect((changes.find((c) => c.id === "s")?.x ?? 0) - 120).toBe(280); + }); +}); + +describe("a right-justified member whose width was never measured", () => { + // Its ink edge is unknown, so projecting its model x would move it in the + // wrong space. Keeping x loses the resize for that member; guessing lost its + // position by a full ink width and persisted that. + it("keeps its x rather than projecting the wrong space", () => { + const qr = leaf("q", "qrcode", 500, 100, { content: "X", magnification: 5, errorCorrection: "M", model: 2, rotation: "N" }); + (qr as unknown as { fieldJustify: string }).fieldJustify = "R"; + const [c] = projectMultiResize([qr], bbox, { x: 0, y: bbox.y }, 2, 1, ident); + expect(c?.x).toBe(500); + }); + + it("still projects it once a measurement exists", () => { + const qr = leaf("q", "qrcode", 500, 100, { content: "X", magnification: 5, errorCorrection: "M", model: 2, rotation: "N" }); + (qr as unknown as { fieldJustify: string }).fieldJustify = "R"; + const [c] = projectMultiResize([qr], bbox, { x: 0, y: bbox.y }, 2, 1, ident, () => 200); + // Ink edge 500-200=300 projects to (300-100)*2 = 400, anchor back to 600. + expect(c?.x).toBe(600); + }); +}); + +// The transformer's live preview projects projectedAnchorXDots and lets the +// render offset ride along unscaled; the commit projects the same quantity and +// carries the anchor back. Pinning the shared rule keeps the drag from showing +// one position and the release committing another. +describe("the quantity a resize projects", () => { + const rightSymbol = () => { + const s = leaf("s", "symbol", 400, 100, { width: 120, height: 40, symbol: "A", rotation: "N" }); + (s as unknown as { fieldJustify: string }).fieldJustify = "R"; + return s; + }; + + it("is the ink edge for a right-anchored field and the model x otherwise", () => { + expect(projectedAnchorXDots(rightSymbol())).toBe(280); + expect(projectedAnchorXDots(leaf("t", "text", 400, 100, { content: "x" }))).toBe(400); + }); + + it("is null exactly when the commit holds the leaf still", () => { + const qr = leaf("q", "qrcode", 500, 100, { content: "X", magnification: 5, errorCorrection: "M", model: 2, rotation: "N" }); + (qr as unknown as { fieldJustify: string }).fieldJustify = "R"; + expect(projectedAnchorXDots(qr)).toBeNull(); + expect(projectMultiResize([qr], bbox, { x: 0, y: bbox.y }, 2, 1, ident)[0]?.x).toBe(500); + }); + + it("lands the live preview and the commit on the same rendered x", () => { + const s = rightSymbol(); + const union = { x: 280, y: 100, width: 200, height: 40 }; + const fx = 2; + const proj = projectedAnchorXDots(s)!; + const live = union.x + (proj - union.x) * fx + (s.x - 120 - proj); + const committed = projectMultiResize([s], union, { x: union.x, y: union.y }, fx, 1, ident)[0]!.x; + expect(live).toBe(committed - 120); + }); +}); diff --git a/src/lib/multiResize.ts b/src/lib/multiResize.ts index 407a984c..4fc80769 100644 --- a/src/lib/multiResize.ts +++ b/src/lib/multiResize.ts @@ -1,6 +1,11 @@ import type { LeafObject } from "@zplab/core/registry"; import { getEntry, SHAPE_PRIMITIVE_TYPES } from "@zplab/core/registry"; -import type { BoundingBoxDots } from "@zplab/core/lib/objectBounds"; +import { + isRightAnchoredField, + rightAnchorBoxWidthDots, + rightAnchorShiftDots, + type BoundingBoxDots, +} from "@zplab/core/lib/objectBounds"; import { makeFree } from "./lineConstrain"; export interface MultiResizeChange { @@ -10,6 +15,18 @@ export interface MultiResizeChange { props?: Record; } +/** The x a resize projects for this leaf: the ink edge for a right-anchored + * field (the union bbox is ink space while `leaf.x` is the model anchor), else + * its model x. Null when a right-anchored width is unmeasured and the leaf has + * to hold still: projecting its model x would move it in the wrong space and + * persist that. The live preview reads it too, or the two project in different + * spaces and the object jumps by one shift on release. */ +export function projectedAnchorXDots(leaf: LeafObject, measuredWidth?: number): number | null { + const boxWidth = rightAnchorBoxWidthDots(leaf, measuredWidth); + if (boxWidth === null) return isRightAnchoredField(leaf) ? null : leaf.x; + return leaf.x - rightAnchorShiftDots(leaf, boxWidth); +} + /** Linear reprojection to a resized union: x' = origin.x + (x - bbox.x) * fx. * Shapes also scale (box/ellipse via commitTransform, line via endpoint), * stroke thickness never. `origin` is the POST-gesture bbox origin: left/top @@ -21,12 +38,25 @@ export function projectMultiResize( fx: number, fy: number, snap: (v: number) => number, + /** Rendered box width (dots) by id, for the right-anchor carry-back below; + * the canvas's measured snapshot, the source the single-resize inverse uses. + * Omitted, only props-derivable widths (symbol, blank text) carry back. */ + measuredWidthDots?: (id: string) => number | undefined, ): MultiResizeChange[] { const projectX = (x: number) => origin.x + (x - bbox.x) * fx; const projectY = (y: number) => origin.y + (y - bbox.y) * fy; const changes: MultiResizeChange[] = []; for (const leaf of leafs) { - const x = Math.round(projectX(leaf.x)); + const projX = projectedAnchorXDots(leaf, measuredWidthDots?.(leaf.id)); + if (projX === null) { + changes.push({ id: leaf.id, x: leaf.x, y: Math.round(projectY(leaf.y)) }); + continue; + } + const shift = leaf.x - projX; + // Rounded once, around the whole expression: re-adding a fractional + // measured width after rounding left a non-integer x, and a vertical-only + // drag then recorded an undo step for a sub-dot horizontal nudge. + const x = Math.round(projectX(projX) + shift); const y = Math.round(projectY(leaf.y)); if (!SHAPE_PRIMITIVE_TYPES.has(leaf.type)) { changes.push({ id: leaf.id, x, y }); diff --git a/src/lib/zplGenerator.test.ts b/src/lib/zplGenerator.test.ts index c2fbd886..3667121b 100644 --- a/src/lib/zplGenerator.test.ts +++ b/src/lib/zplGenerator.test.ts @@ -370,7 +370,7 @@ describe('generateZPL — printer params', () => { putImage({ id: 'imgC', name: 'c', dataUrl: 'data:,', width: 100, height: 200 }); const ftImage: LabelObject = { id: 'imc', type: 'image', x: 0, y: 10, rotation: 0, positionType: 'FT', - props: { imageId: 'imgC', widthDots: 120, heightDots: 10, threshold: 128, _gfaCache: '^GFA1,1,1,00' }, + props: { imageId: 'imgC', widthDots: 120, heightDots: 10, threshold: 128, _gfaCache: '^GFA,1,1,1,00' }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any; // aspect height = round(120 * 200/100) = 240; anchor y = 10 + 240 - home 200 = 50 diff --git a/src/locales/loadLocale.test.ts b/src/locales/loadLocale.test.ts index 5f0bd50e..878e4cc6 100644 --- a/src/locales/loadLocale.test.ts +++ b/src/locales/loadLocale.test.ts @@ -29,7 +29,9 @@ describe("locale registry", () => { expect(isLocaleCode("de")).toBe(true); }); - it("every locale matches the en key structure exactly", async () => { + // Own timeout: 33 dynamic locale imports are transform-bound, and the default + // 5s is a coin flip once the rest of the suite runs alongside them. + it("every locale matches the en key structure exactly", { timeout: 30_000 }, async () => { // Replaces the compile-time guarantee the old eager map gave implicitly; // deep both-direction parity so a missing OR extra key fails per locale. const enKeys = keyPaths(en as unknown as Record).sort(); diff --git a/src/store/anchorRepin.test.ts b/src/store/anchorRepin.test.ts index 53f4df4e..12155c07 100644 --- a/src/store/anchorRepin.test.ts +++ b/src/store/anchorRepin.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect, afterEach } from "vitest"; -import { applyObjectChanges, NON_EMITTING_PROP_KEYS } from "./labelStore.internals"; +import { applyObjectChanges } from "./labelStore.internals"; import { registerBarcodeWidthProber, unregisterBarcodeWidthProber, probeBarcodeFootprint, anchorRepin } from "./anchorRepin"; import { stampDirtyLeaves } from "./dirtyTracking"; import { convertSymbologyMapper } from "../lib/symbologySwitch"; import { valueAnchorShift } from "@zplab/core/lib/valueAnchor"; +import { applyChanges } from "@zplab/core/lib/anchorRepin"; import type { LabelObject } from "@zplab/core/types/Group"; // Fake probe: width tracks content length, axes swap under R rotation. @@ -74,6 +75,23 @@ describe("anchorRepin", () => { expect(next.y).toBe(50); }); + it("skips the op that introduces the justify itself (no pinned edge yet)", () => { + registerBarcodeWidthProber(probe); + const src = barcode({ fieldJustify: undefined }); + const next = applyObjectChanges(src, { fieldJustify: "R", props: { content: "ABCD" } }); + // The right edge was never in force; shifting would move the object off + // the position the caller just set (a patch sends both together). + expect(next.fieldJustify).toBe("R"); + expect(next.x).toBe(100); + }); + + it("skips the op that first flips the FT anchor", () => { + registerBarcodeWidthProber(probe); + const src = barcode({ fieldJustify: "L" }, { rotation: "I" }); + const next = applyObjectChanges(src, { positionType: "FT", props: { content: "ABCD" } }); + expect(next.x).toBe(100); + }); + it("is inert without a registered prober (headless)", () => { const next = applyObjectChanges(barcode(), { props: { content: "ABCD" } }); expect(next.x).toBe(100); @@ -155,12 +173,6 @@ describe("prober registry", () => { }); }); -describe("NON_EMITTING_PROP_KEYS", () => { - it("membership lock: exactly the editor-only, never-emitted prop keys", () => { - expect([...NON_EMITTING_PROP_KEYS].sort()).toEqual(["preSerialContent"]); - }); -}); - describe("valueAnchorShift", () => { it("is symmetric for centre (away-from-zero halves)", () => { expect(valueAnchorShift("C", 7, false)).toBe(4); @@ -216,3 +228,14 @@ describe("dirty semantics of fieldJustify", () => { expect(stamp(leaf, changed)).toBe(true); }); }); + +describe("applyChanges with an explicit props: undefined", () => { + it("keeps the object's props instead of wiping them", () => { + // ObjectChanges declares props?: object, and a conditional spread left the + // undefined the outer spread had already copied on, handing every renderer + // and emitter a propless object. + const src = barcode(); + const next = applyChanges(src, { x: 20, props: undefined } as never, () => null); + expect((next as { props?: object }).props).toEqual((src as { props: object }).props); + }); +}); diff --git a/src/store/anchorRepin.ts b/src/store/anchorRepin.ts index a281ea7c..ea17b40e 100644 --- a/src/store/anchorRepin.ts +++ b/src/store/anchorRepin.ts @@ -1,20 +1,11 @@ import type { LabelObject } from "@zplab/core/types/Group"; import type { ObjectChanges } from "@zplab/core/types/LabelObject"; -import { BARCODE_1D_TYPES } from "@zplab/core/registry"; -import { isAxisSwapped, objectRotation } from "@zplab/core/registry/rotation"; -import { valueAnchorShift } from "@zplab/core/lib/valueAnchor"; - -/** Rotated visual footprint in dots, as the canvas measures it. */ -interface BarcodeFootprint { - w: number; - h: number; -} +import { anchorRepin as coreAnchorRepin, type BarcodeFootprint } from "@zplab/core/lib/anchorRepin"; type BarcodeWidthProber = (obj: LabelObject) => BarcodeFootprint | null; -/** Barcode width is not computable headlessly (bwip must encode), so the - * canvas registers a synchronous prober at runtime; in node tests it stays - * null and anchor re-pinning is simply off. */ +/** Probe resolving variable DEFAULTS, the same source the sidecar uses, so a preview toggle cannot move the persisted x. + * Null in node tests, where re-pinning is simply off. */ let prober: BarcodeWidthProber | null = null; export function registerBarcodeWidthProber(p: BarcodeWidthProber | null): void { @@ -31,33 +22,8 @@ export function probeBarcodeFootprint(obj: LabelObject): BarcodeFootprint | null return prober ? prober(obj) : null; } -/** Justified barcodes: shift the origin so a width-changing props edit keeps - * the justified edge fixed. Skipped when the edit positions the object itself - * (transformer commits carry x/y) and on rotation changes (axes swap). */ +/** Store-side repin: the shared rule bound to the canvas prober (preview + * binding), see the core function for the contract. */ export function anchorRepin(obj: LabelObject, changes: ObjectChanges, next: LabelObject): LabelObject { - // 1D-only: the ftFlip math matches barcodeFtAnchorOffset only there (QR - // graphics use an "N" offset + module shift the re-pin doesn't model); - // graphics have static extents, so fieldJustify never re-pins them. - if (!BARCODE_1D_TYPES.has(next.type)) return next; - // Absent means L (schema contract), and L participates under the FT flip. - const justify = next.fieldJustify ?? 'L'; - const props = (next as { props: object }).props; - const rot = objectRotation(props); - // ^FT+I/B inverts the anchor math (see valueAnchorShift). - const ftFlip = - (next as { positionType?: string }).positionType === 'FT' && (rot === 'I' || rot === 'B'); - if (justify === 'L' && !ftFlip) return next; - // `in`, not value-check: an explicit x/y key marks a positioning edit, and - // x: undefined is already illegal (the merge spread would clobber obj.x). - if (!changes.props || 'x' in changes || 'y' in changes) return next; - if ('rotation' in changes.props) return next; - // Both widths from the same synchronous probe: width-neutral edit = exact no-op. - const before = probeBarcodeFootprint(obj); - const after = probeBarcodeFootprint(next); - if (!before || !after) return next; - const swapped = isAxisSwapped(rot); - const delta = swapped ? before.h - after.h : before.w - after.w; - const shift = valueAnchorShift(justify, delta, ftFlip); - if (shift === 0) return next; - return swapped ? { ...next, y: next.y + shift } : { ...next, x: next.x + shift }; + return coreAnchorRepin(obj, changes, next, probeBarcodeFootprint); } diff --git a/src/store/imageCacheInvalidation.test.ts b/src/store/imageCacheInvalidation.test.ts index c0f4e0c6..3df2506d 100644 --- a/src/store/imageCacheInvalidation.test.ts +++ b/src/store/imageCacheInvalidation.test.ts @@ -1,10 +1,12 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, afterEach } from "vitest"; import { applyObjectChanges } from "./labelStore.internals"; import { ObjectRegistry } from "@zplab/core/registry"; +import { putImage, removeImage } from "@zplab/core/lib/imageCache"; import type { LabelObject } from "@zplab/core/types/Group"; // The wiring seam between normalizeChanges and the emit fallback: a width -// change without fresh bytes must reach toZPL as an invalidated cache. +// change without fresh bytes must reach toZPL as an invalidated cache, but +// only where the source image can re-encode them. describe("image cache invalidation through applyObjectChanges", () => { const gfa = "^GFA,8,8,1,00FF00FF00FF00FF"; const img = { @@ -16,11 +18,20 @@ describe("image cache invalidation through applyObjectChanges", () => { props: { imageId: "gone", widthDots: 8, threshold: 128, rotation: "N", _gfaCache: gfa }, } as LabelObject; + afterEach(() => removeImage("src")); + it("a width-only change ends in an empty ^FD emit, not stale bytes", () => { + putImage({ id: "src", name: "s.png", dataUrl: "data:image/png;base64,AA", width: 8, height: 8 }); + const withSource = { ...img, props: { ...(img as { props: object }).props, imageId: "src" } } as LabelObject; + const changed = applyObjectChanges(withSource, { props: { widthDots: 80 } }); + expect((changed as { props: { _gfaCache?: string } }).props._gfaCache).toBeUndefined(); + }); + + it("keeps the bytes when they are the graphic's only copy", () => { expect(ObjectRegistry.image.toZPL(img as never)).toContain(gfa); const changed = applyObjectChanges(img, { props: { widthDots: 80 } }); - const zpl = ObjectRegistry.image.toZPL(changed as never); - expect(zpl).not.toContain(gfa); - expect(zpl).toContain("^FD^FS"); + // The header is the printed size (imageEmitDims), so widthDots never made + // these bytes stale; clearing them would leave nothing to print. + expect(ObjectRegistry.image.toZPL(changed as never)).toContain(gfa); }); }); diff --git a/src/store/labelStore.internals.ts b/src/store/labelStore.internals.ts index d23f54ea..61e6ca0e 100644 --- a/src/store/labelStore.internals.ts +++ b/src/store/labelStore.internals.ts @@ -2,10 +2,13 @@ import { isGroup, type LabelObject, type Page } from '@zplab/core/types/Group'; import type { ObjectChanges } from '@zplab/core/types/LabelObject'; import { NON_EMITTING_CONFIG_FIELDS } from '@zplab/core/types/LabelConfig'; import { isLocaleCode, type LocaleCode } from '../locales'; -import { renameTemplateMarkers, substituteTemplateMarker } from '@zplab/core/lib/fnTemplate'; -import { getObjectStringContent } from '@zplab/core/lib/variableBinding'; -import { getEntry } from '@zplab/core/registry'; -import { anchorRepin } from './anchorRepin'; +export { + rewriteTemplateMarkers, + rewriteTemplateMarkersMap, + substituteTemplateMarkers, +} from '@zplab/core/lib/templateObjects'; +import { applyChanges } from '@zplab/core/lib/anchorRepin'; +import { probeBarcodeFootprint } from './anchorRepin'; import { newId } from "@zplab/core/lib/ids"; /** Meta fields that remain editable on a locked object so the user can @@ -33,10 +36,8 @@ export const NON_EMITTING_CONFIG_KEYS: ReadonlySet = new Set( NON_EMITTING_CONFIG_FIELDS, ); -/** Prop keys that never reach emitted ZPL: a props diff touching only these - * must not stamp dirty and drop the verbatim overlay. Classifies globally by - * key name (membership locked in anchorRepin.test.ts). */ -export const NON_EMITTING_PROP_KEYS = new Set(['preSerialContent']); +// Shared with the MCP boundary, so it lives in core. +export { NON_EMITTING_PROP_KEYS } from '@zplab/core/types/LabelObject'; /** True when a config patch changes a field that reaches emitted ZPL. Used to * drop page overlays: until config-segment linkage lands, an overlay replays @@ -59,73 +60,6 @@ function dropProvenance(node: T): T { return next; } -/** Apply `renameTemplateMarker` to every leaf's `content` in a subtree. - * Identity-preserving: returns the same array (and same node refs) - * when no markers needed rewriting, so React memoisation downstream - * stays effective for the common case where the rename touched no - * templates. */ -export function rewriteTemplateMarkers( - objects: LabelObject[], - oldName: string, - newName: string, -): LabelObject[] { - return rewriteTemplateMarkersMap(objects, new Map([[oldName, newName]])); -} - -/** Like `rewriteTemplateMarkers` but renames many names in ONE pass per leaf, - * looking each marker up against the original name. Order-independent and - * collision-safe (swaps/chains can't cascade). Identity-preserving. */ -export function rewriteTemplateMarkersMap( - objects: LabelObject[], - renames: ReadonlyMap, -): LabelObject[] { - if (renames.size === 0) return objects; - let changed = false; - const next = objects.map((obj) => { - if (isGroup(obj)) { - const nextChildren = rewriteTemplateMarkersMap(obj.children, renames); - if (nextChildren === obj.children) return obj; - changed = true; - return { ...obj, children: nextChildren }; - } - const content = getObjectStringContent(obj); - if (content === undefined) return obj; - const renamed = renameTemplateMarkers(content, renames); - if (renamed === content) return obj; - changed = true; - const props = (obj as { props: object }).props; - return { ...obj, props: { ...props, content: renamed } } as LabelObject; - }); - return changed ? next : objects; -} - -/** Replace every `«name»` marker with `replacement` across a subtree's leaf - * `content`. Identity-preserving when nothing matched (see - * {@link rewriteTemplateMarkers}). Used on variable deletion. */ -export function substituteTemplateMarkers( - objects: LabelObject[], - name: string, - replacement: string, -): LabelObject[] { - let changed = false; - const next = objects.map((obj) => { - if (isGroup(obj)) { - const nextChildren = substituteTemplateMarkers(obj.children, name, replacement); - if (nextChildren === obj.children) return obj; - changed = true; - return { ...obj, children: nextChildren }; - } - const content = getObjectStringContent(obj); - if (content === undefined) return obj; - const substituted = substituteTemplateMarker(content, name, replacement); - if (substituted === content) return obj; - changed = true; - const props = (obj as { props: object }).props; - return { ...obj, props: { ...props, content: substituted } } as LabelObject; - }); - return changed ? next : objects; -} - export function applyObjectChanges( obj: LabelObject, changes: ObjectChanges, @@ -145,16 +79,9 @@ export function applyObjectChanges( // tree updates reach them through their own mapObjectById call. return { ...obj, ...changes } as LabelObject; } - const normalize = getEntry(obj.type)?.normalizeChanges; - const normalized = normalize ? normalize(obj, changes) : changes; - const next = { - ...obj, - ...normalized, - props: normalized.props ? Object.assign({}, obj.props, normalized.props) : obj.props, - } as LabelObject; // Dirty-tracking is centralized in the dirtyTracking middleware (a state diff), // so this mutator no longer stamps dirty itself. - return anchorRepin(obj, normalized, next); + return applyChanges(obj, changes, probeBarcodeFootprint); } export function detectLocale(): LocaleCode { diff --git a/src/store/slices/labelConfigSlice.ts b/src/store/slices/labelConfigSlice.ts index 123c6aa4..369d3ecc 100644 --- a/src/store/slices/labelConfigSlice.ts +++ b/src/store/slices/labelConfigSlice.ts @@ -37,9 +37,10 @@ export interface LabelConfigSlice { columnMapping?: ColumnMapping | null, dataSource?: DbSourceRef | null, ) => void; - /** Parse serialized design-file text and load it, routing a parse failure - * to userError; every text source (file open, MCP push) shares this path. */ - loadDesignText: (text: string) => void; + /** Parse serialized design-file text and load it, routing a parse failure to + * userError; every text source (file open, MCP push) shares this path. False + * on a text that is no design file, so the MCP bridge can report it back. */ + loadDesignText: (text: string) => boolean; /** Append pages to the current design without touching label config. * Switches focus to the first appended page. */ appendPages: (pages: Page[]) => void; @@ -114,7 +115,7 @@ export const createLabelConfigSlice: StateCreator