From e4e4ecc12768b16bb82be9a923ebc967ee0487f8 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 9 Aug 2026 11:51:56 +0200 Subject: [PATCH 01/15] fix: editor and core corrections, split out of the MCP app-channel branch Right-anchor handling through resize and rotation, the ^GFA decoder (fill semantics pinned against the printer raster, bounded decode), HRI zones, GS1 and parser fixes, plus the shared edit pipeline in core. --- packages/core/src/lib/anchorRepin.ts | 76 +++++++ packages/core/src/lib/barcodeDims.ts | 65 +++++- .../core/src/lib/barcodeEncodePreflight.ts | 92 +++++++++ packages/core/src/lib/barcodeHri.test.ts | 78 +++++++ packages/core/src/lib/barcodeHri.ts | 61 +++++- packages/core/src/lib/dataMatrixFd.ts | 20 +- packages/core/src/lib/errorMessage.ts | 5 + packages/core/src/lib/footprintProber.ts | 9 +- .../core/src/lib/gfaDecode.labelary.test.ts | 66 ++++++ packages/core/src/lib/gfaDecode.test.ts | 171 +++++++++++++++ packages/core/src/lib/gfaDecode.ts | 72 +++++++ packages/core/src/lib/gs1.ts | 43 +++- packages/core/src/lib/gs1Plan.test.ts | 164 +++++++++++++++ packages/core/src/lib/gs1Plan.ts | 30 ++- packages/core/src/lib/imageToZpl.ts | 27 ++- packages/core/src/lib/loadImage.ts | 15 +- .../src/lib/objectBounds.rightAnchor.test.ts | 123 +++++++++++ packages/core/src/lib/objectBounds.ts | 93 ++++++++- packages/core/src/lib/objectOverlap.ts | 8 +- packages/core/src/lib/preflight.ts | 106 +++++++++- packages/core/src/lib/templateObjects.ts | 61 ++++++ packages/core/src/lib/zplGenerator.ts | 16 +- .../core/src/lib/zplParser.multiline.test.ts | 77 +++++++ packages/core/src/lib/zplParser.ts | 18 +- .../core/src/lib/zplParser/decoders/gfa.ts | 85 +++++++- packages/core/src/registry/datamatrix.ts | 26 ++- .../core/src/registry/image.gfaOnly.test.ts | 32 +++ packages/core/src/registry/image.ts | 194 +++++++++++++++--- packages/core/src/registry/index.ts | 6 +- packages/core/src/types/LabelObject.test.ts | 8 + packages/core/src/types/LabelObject.ts | 6 + packages/mcp-server/src/tools.test.ts | 20 +- src/components/Canvas/BarcodeObject.tsx | 7 +- .../Canvas/ImageObject.gfaFallback.test.tsx | 92 +++++++++ src/components/Canvas/ImageObject.tsx | 48 ++++- src/components/Canvas/KonvaObject.tsx | 12 +- src/components/Canvas/LabelCanvas.tsx | 15 +- src/components/Canvas/barcodePreflight.ts | 90 ++------ .../Canvas/hooks/useKonvaTransformer.ts | 29 ++- .../Canvas/transformPosition.hriZone.test.ts | 154 ++++++++++++++ src/components/Canvas/transformPosition.ts | 66 +++++- src/lib/densityRescale.test.ts | 11 + src/lib/densityRescale.ts | 6 +- src/lib/errorMessage.ts | 6 +- src/lib/groupRotation.test.ts | 12 ++ src/lib/groupRotation.ts | 11 +- src/lib/multiResize.test.ts | 18 ++ src/lib/multiResize.ts | 21 +- src/lib/zplGenerator.test.ts | 2 +- src/locales/loadLocale.test.ts | 4 +- src/store/anchorRepin.test.ts | 37 +++- src/store/anchorRepin.ts | 48 +---- src/store/imageCacheInvalidation.test.ts | 21 +- src/store/labelStore.internals.ts | 93 +-------- src/store/slices/labelConfigSlice.ts | 10 +- 55 files changed, 2336 insertions(+), 350 deletions(-) create mode 100644 packages/core/src/lib/anchorRepin.ts create mode 100644 packages/core/src/lib/barcodeEncodePreflight.ts create mode 100644 packages/core/src/lib/barcodeHri.test.ts create mode 100644 packages/core/src/lib/errorMessage.ts create mode 100644 packages/core/src/lib/gfaDecode.labelary.test.ts create mode 100644 packages/core/src/lib/gfaDecode.test.ts create mode 100644 packages/core/src/lib/gfaDecode.ts create mode 100644 packages/core/src/lib/objectBounds.rightAnchor.test.ts create mode 100644 packages/core/src/lib/templateObjects.ts create mode 100644 packages/core/src/lib/zplParser.multiline.test.ts create mode 100644 packages/core/src/registry/image.gfaOnly.test.ts create mode 100644 packages/core/src/types/LabelObject.test.ts create mode 100644 src/components/Canvas/ImageObject.gfaFallback.test.tsx create mode 100644 src/components/Canvas/transformPosition.hriZone.test.ts diff --git a/packages/core/src/lib/anchorRepin.ts b/packages/core/src/lib/anchorRepin.ts new file mode 100644 index 00000000..6da96276 --- /dev/null +++ b/packages/core/src/lib/anchorRepin.ts @@ -0,0 +1,76 @@ +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"); +} + +/** Justified 1D barcodes: shift the origin so a width-changing props edit + * keeps the justified edge fixed. `probe` is the caller's width source, so + * editor and patch_design re-pin the same way. Skipped on positioning edits + * (x/y present), rotation changes (axes swap), and the op that introduces + * the justify/flip itself (no pinned edge existed yet). */ +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 }; +} + +/** The leaf edit pipeline shared by the editor and patch_design: registry + * normalize, top-level replace, props merge, anchor re-pin. The hook ORDER is + * the domain rule, so it lives once; callers add only their own gates (the + * editor's lock bypass, patch_design's loud lock refusal) and their probe. */ +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..03316332 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"; @@ -596,7 +597,13 @@ export function getDisplaySize( const w = isQuarter ? upright.h : upright.w; const h = isQuarter ? upright.w : upright.h; - const textZonePx = dotsToPx(barcodeTextZoneDots(obj), scale, dpmm); + // Upright bar width feeds the GS1 band's shrink-to-fit; without it that band + // would be reserved un-shrunk. + const textZonePx = dotsToPx( + barcodeTextZoneDots(obj, pxToDots(upright.w, scale, dpmm)), + scale, + dpmm, + ); const zoneAbove = barcodeZoneAbove(obj); // Map the upright "below the bars" zone onto the rotated bbox: it travels @@ -690,7 +697,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, pxToDots(w, scale, dpmm)); + const h = dotsToPx(obj.props.height + zone, scale, dpmm); return { w, h }; } case "ean13": @@ -751,7 +759,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 +1147,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..6d5d7f7f --- /dev/null +++ b/packages/core/src/lib/barcodeEncodePreflight.ts @@ -0,0 +1,92 @@ +// The one decision tree for barcode encode findings: which leaf gets +// emptyContent, renderFailed or previewApproximate, and which is owned by +// another producer. The editor and the MCP sidecar both feed it their own +// encoder seam, so the two reports cannot drift (they did, three times). + +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, + type RenderMode, +} 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; + /** How markers resolve; the canvas passes its user toggle. */ + mode?: RenderMode; +} + +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, + env.mode ?? "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..9cf683bf --- /dev/null +++ b/packages/core/src/lib/barcodeHri.test.ts @@ -0,0 +1,78 @@ +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) => + ({ + id: "b", type: "code128", x: 0, y: 0, rotation: 0, + props: { content: "(01)09501101530003", 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("shrinks back toward the plain band once the bars constrain the font", () => { + // Same shrink-to-fit the renderer applies: a narrow symbol cannot show the + // full-size font, so reserving it un-shrunk would over-report. + const unshrunk = barcodeTextZoneDots(leaf(true, 2)); + expect(barcodeTextZoneDots(leaf(true, 2), 40)).toBeLessThan(unshrunk); + }); + + 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..aa989386 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, @@ -10,6 +10,7 @@ import { GS1_HRI_FONT_SCALE, GS1_HRI_WIDTH_RATIO, HRI_FONT_0, + VERA_MONO_HRI_EM_PER_MODULE, } from "./bwipConstants"; import { measureInkWidthPx } from "./labelGeometry/measureTextDots"; @@ -37,14 +38,64 @@ 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. */ -export function barcodeTextZoneDots(obj: LeafObject): number { + * 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. + * `barWidthDots` is the measured bar width, which only the GS1 band below + * needs; omitting it over-reserves that band rather than under-reporting it. */ +export function barcodeTextZoneDots(obj: LeafObject, barWidthDots = 0): 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; + return hriZoneDots(moduleWidth) * gs1ZoneScale(obj, moduleWidth, barWidthDots); +} + +/** GS1-128 draws its interpretation line at gs1HriFontDots, up to + * GS1_HRI_FONT_SCALE of the plain em, so the plain band hriZoneDots measures is + * too short and the HRI runs outside the published bbox (the off-label bottom + * test and the overlap scan then under-report it). The band scales with the em, + * so scale it by the same ratio the renderer applies. Estimated, not measured: + * like hriZoneDots' own fit this still wants Labelary/ZD230 confirmation, and + * it deliberately errs long (a too-tall band over-reports, a too-short one + * hides ink running off the media). */ +function gs1ZoneScale(obj: LeafObject, moduleWidth: number, barWidthDots: number): number { + // The registry predicate, not a raw props read: a type that cannot carry GS1 + // must not scale its band off a stray flag. + if (!isGs1Active(ObjectRegistry[obj.type], obj.props)) return 1; + const hri = ObjectRegistry[obj.type]?.hri; + const baseFontDots = hri?.fontDots + ? hri.fontDots(moduleWidth) + : moduleWidth * VERA_MONO_HRI_EM_PER_MODULE; + if (baseFontDots <= 0) return 1; + const content = (obj.props as { content?: string }).content ?? ""; + return gs1HriFontDots(content, baseFontDots, barWidthDots) / baseFontDots; } /** 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..7bb0f3fb 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,21 @@ 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 parts = typedGs1Parts(content); + if (!parts) return null; + const fnc1 = ESC + "1"; + let out = fnc1; + 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. + out += escapeRun(`${part.ai}${typedSegmentValue(part.ai, part.value)}`); + const spec = aiSpec(part.ai); + if (spec && isVariableKind(spec.kind) && index < parts.length - 1) out += fnc1; + } + return out; +} 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..4911adcd --- /dev/null +++ b/packages/core/src/lib/gfaDecode.labelary.test.ts @@ -0,0 +1,66 @@ +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; +}; + +// Rendered on Labelary (8dpmm, 2x1) and read back pixel by pixel, because the +// spec (p.1759) defines what a comma, a bang and a colon each do but not what +// they do after a line the data already filled exactly. The answer is that all +// three ALWAYS produce a row: a comma following a full row fills a fresh line +// with zeros, it is not a no-op. Anything that makes them conditional turns +// every comma-separated ^GFA into half its rows. +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..4e4f4cc0 --- /dev/null +++ b/packages/core/src/lib/gfaDecode.test.ts @@ -0,0 +1,171 @@ +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("refuses a wrapped payload rather than reading it as hex", () => { + // Every raw-binary import stores its cache as ^GFA,…,:B64:…; decoding that + // as hex would draw plausible noise. + const wrapped = rasterFromGfa("^GFA,4,4,2,:B64:AP//AA==:1234"); + expect(wrapped === null || hex(wrapped)).not.toBe("B64AAA"); + }); + + 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("falls back to the stream when the count slot is empty", () => { + expect(rasterFromGfa("^GFA,,,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..63ba8151 --- /dev/null +++ b/packages/core/src/lib/gfaDecode.ts @@ -0,0 +1,72 @@ +// 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, parseGfHeader } from "../registry/image"; +import { GF_MAX_DECODED_BYTES, gfPayloadToBytes } from "./zplParser/decoders/gfa"; + +/** Rows a preview will draw; past this the raster is not a label graphic. */ +const MAX_ROWS = 20_000; + +/** 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) — the 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; + // Rows come from the header count (spec p.215: c / d), which is the number + // bounds and emit use; the stream may carry more or fewer than it declares. + // A present-but-fractional count is malformed: fall back to the placeholder + // like gfaHeaderDims/emit do, or the canvas would draw a floored row count the + // emitter rejects and the two would disagree on an ^FT image's position. + const declaredRows = head.dataBytes === "" ? 0 : Number.parseInt(head.dataBytes, 10) / bytesPerRow; + if (head.dataBytes !== "" && (!Number.isInteger(declaredRows) || declaredRows <= 0)) return null; + const streamRows = Math.floor(decoded.data.length / bytesPerRow); + // The DECLARED count wins, never the shorter stream: it is the height bounds + // and emit size the field by, and the firmware prints the rows the payload + // omits as blank. Shrinking to what decoded would draw a smaller graphic than + // the one that prints. Past the caps nothing is drawn at all (placeholder), + // rather than silently under-drawing a graphic we cannot hold. + const heightDots = declaredRows > 0 ? declaredRows : streamRows; + if (heightDots <= 0 || heightDots > 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..fe0e6b71 100644 --- a/packages/core/src/lib/gs1.ts +++ b/packages/core/src/lib/gs1.ts @@ -379,9 +379,48 @@ 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; +} + +/** A typed part's value as emitted, completing a literal GTIN exactly as + * segmentValue does for parsed segments: without it, binding any OTHER part + * to a variable would silently ship a 13-digit AI-01. A value carrying a + * marker passes through, since its check digit belongs to the supplied row. */ +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..2e0cbb91 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,166 @@ 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"); + }); +}); diff --git a/packages/core/src/lib/gs1Plan.ts b/packages/core/src/lib/gs1Plan.ts index a92651cc..4cd9d99b 100644 --- a/packages/core/src/lib/gs1Plan.ts +++ b/packages/core/src/lib/gs1Plan.ts @@ -1,10 +1,13 @@ import { + completeTypedGtins, GS1_GS, parseGs1ToSegments, segmentsToElementString, segmentsToZplFd, + segmentsToContent, } from "./gs1"; -import { gs1ContentToDataMatrixFd } from "./dataMatrixFd"; +import { gs1ContentToDataMatrixFd, 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 +53,26 @@ 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)) { + // Only bwipText/parsefnc consumers reach this (canvas bars, dims); the emit + // resolves markers first and never routes template content through .fd. So + // this feeds a preview: completeTypedGtins fills a TYPED "(01)…" GTIN (MCP + // input) for the sample, and returns null for the app's raw model content. + const typed = carrier === "code128" ? completeTypedGtins(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: 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 +89,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..4414ab25 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,13 @@ 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). + * Exactly what imageEmitDims consults, `storedAs` included: a recall field + * prints the stored graphic at its header size, so excluding it here sized the + * box (and the off-label check) off props while the generator anchored off the + * header. Recall-only fields carry no bytes, so they still fall back to props. */ 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 +208,68 @@ 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 }; +} + +/** The rotated box width a right-anchored field shifts by. A caller with a + * measured or committed box passes it; without one, a symbol's own props are + * the truth (its box never turns) and blank text draws the placeholder. A 2D + * barcode's box comes from its encoding, so it has no props-only width: its + * renderer (BarcodeObject) always publishes a measured box before this runs. */ +export function rightAnchorBoxWidthDots(obj: LeafObject, measuredBoxWidthDots?: number): number { + 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 0; +} + +/** Whether this field's printed box sits left of `obj.x` (z=1 anchor). Text + * and the 2D symbologies emit the anchor as-is, so their right-justified x IS + * the printed right edge; 1D barcodes and graphics convert on emit and keep x + * on the left. */ +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 the ink runs LEFT of the EMITTED anchor, so the anchor's own + * near-edge test says nothing about the home edge. Two shapes reach it: a + * right-justified text/symbol/2D field (model x already IS the right edge), + * and a right-justified ^FT graphic, whose model x is the left edge but whose + * emitted anchor is x+w (graphicAnchorCoords). ^FO graphics ignore justify. */ +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 +454,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..2b4eda31 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 { inlineGfaFor, imageEmitDims, gfShipsSafely, parseGfHeader, type ImageProps } from '../registry/image'; import { formatStoragePath } from './storagePath'; function formatDownloadObject(m: CustomFontMapping): string | undefined { @@ -175,14 +175,12 @@ 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}`; + const h = parseGfHeader(cache); + // The ~DY preamble is a second site that turns these bytes into a stream, so + // it runs the same ship guard as toZPL: a payload carrying ^/~ past its count + // would execute here exactly as it would in the field. + if (!h || !gfShipsSafely(cache)) 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 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..8714a903 --- /dev/null +++ b/packages/core/src/lib/zplParser.multiline.test.ts @@ -0,0 +1,77 @@ +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»"); + }); +}); diff --git a/packages/core/src/lib/zplParser.ts b/packages/core/src/lib/zplParser.ts index f479a741..0b8ae6a6 100644 --- a/packages/core/src/lib/zplParser.ts +++ b/packages/core/src/lib/zplParser.ts @@ -243,6 +243,11 @@ 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 parameter is literal user data, where a trailing space + // is a character and not line-wrap whitespace: ^SN's serial seed, ^SF's mask, + // ^A@'s font path. Everything else ends in an enum/number, where a hanging + // space before a line break is only formatting. + 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 +437,18 @@ export function parseZPL( }; for (const { cmd, rest, start } of tokens) { - const p = rest.split(s.format.delimiterChar); + // Strip the trailing break plus the next line's indent, then spaces left + // hanging after the LAST PARAMETER's real content ("N \n" -> "N"). A break + // and a real trailing space are indistinguishable syntactically, so the + // exemption is by command: LITERAL_TAIL_CMDS end in user data where a space + // counts. A whitespace-VALUED parameter survives either way (`^FE `, + // `^FC%,{, ` keep their space); the delimiter is never whitespace + // (acceptsPrefixRemap), so this cannot eat one. ^FD keeps `rest` verbatim. + const p = rest.replace(/[\r\n]+\s*$/, "").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] = last.replace(/[^\S\r\n]+$/, ""); + } // 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..ec8bc167 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,41 @@ export function rewriteRawFieldSpans( return out + text.slice(cursor); } -/** Inflate `:Z64:` zlib payload; null on malformed deflate stream. */ +/** Inflate `:Z64:` zlib payload; null on a malformed stream or one that + * decompresses past the decode budget. Streamed so a zip-bomb cache (a few KB + * inflating to hundreds of MB) aborts after the cap instead of OOMing the + * webview the moment the render path decodes it. */ 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 +117,13 @@ export function gfPayloadToBytes( } if (format === "C") return null; if (format === "A") { - return { data: gfaHexToBytes(decompressGFA(rawData, bytesPerRow)), crcOk: true }; + const expanded = decompressGFA(rawData, bytesPerRow); + // Past the budget the decoder stops mid-graphic; returning the partial rows + // would store a silently cropped image and re-export it at the crop height. + // Null instead, matching the :Z64: path, so callers keep the payload as + // undecodable rather than as a smaller graphic than the one that prints. + 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 +131,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 +148,38 @@ 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 === ",") { + // Always produces a row, even right after one the data filled exactly: + // Labelary-verified (gfaDecode.labelary.test.ts), where `FFFF,8001,` on a + // 2-byte row renders FFFF, blank, 8001, blank. Its purpose is letting a + // row omit its trailing zeros, not terminating a row that is already full. 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 +200,13 @@ 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: consecutive + // compress chars accumulate an unbounded count, and `repeat` would + // allocate all of it (or throw RangeError) in one step. A clamp means + // the graphic did not fit, which the caller has to hear about. + const room = remainingNibbles(); + if (count > room) clamped = true; + currentRow += nextCh.repeat(Math.min(count, room)); i++; } } else if (isHex(ch)) { @@ -154,12 +216,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/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.ts b/packages/core/src/registry/image.ts index d535022d..4a7a382a 100644 --- a/packages/core/src/registry/image.ts +++ b/packages/core/src/registry/image.ts @@ -40,11 +40,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,28 +103,146 @@ function gfaSync(dataUrl: string, widthDots: number, threshold: number, rotation return raster ? gfaFromRaster(raster) : ''; } +/** Spec p.215 range for ^GF's byte counts and bytes-per-row. */ +const inGfRange = (n: number): boolean => Number.isInteger(n) && n >= 1 && n <= 99999; + +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; + const bytesPerRow = Number(m[4]); + // Spec p.215: b, c and d are each "Values: 1 to 99999". Enforced in the one + // place that owns the grammar, so no consumer has to re-derive it — without + // it a c of 4000000 drove the emitted ^FT anchor and a b of 1e20 sailed past + // Number.isInteger into the overhang scan. Empty b/c stay legal (the byte + // counts are optional, and the parser preserves headers that omit them). + if (!inGfRange(bytesPerRow)) return null; + if ((m[2] !== "" && !inGfRange(Number(m[2]))) || (m[3] !== "" && !inGfRange(Number(m[3])))) { + 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; + +/** Can this ^GF string be shipped verbatim without the firmware reading part of + * it as a command? Format A and the :B64:/:Z64: wrappers are ASCII alphabets, + * so a ^/~ anywhere in them is an appended command. Raw binary B/C carries + * those bytes as data INSIDE its declared count (spec p.215) and the firmware + * resumes parsing past it, so only the overhang matters — and with no count + * declared nothing bounds the data, so the whole payload has to be clean. + * Sliced in string units: ^ and ~ are single-byte ASCII wherever they sit. + * Lives here because emit is what turns these bytes into a stream; the MCP + * boundary reuses it for caller-supplied props. */ +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; + 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" — so in format A (and the base64 wrappers, whose alphabet has + // neither) one anywhere ends the graphic, wherever the count says it stops. + if (head.format === "A" || wrapped) return !/[\^~]/.test(head.payload); + // p.215, binary: "All control prefixes are ignored until the total number of + // bytes needed for the graphic format is sent" — so b (or c when b is + // omitted) IS the boundary, and only bytes past it are read as commands. + const countStr = head.totalBytes !== "" ? head.totalBytes : head.dataBytes; + // Nothing declares where the data ends, so the whole payload must be clean. + if (countStr === "") return !/[\^~]/.test(head.payload); + // WIRE bytes, not string indices: the generator emits ^CI28, so one payload + // char can be several bytes and a JS slice would cut in the wrong place. + // parseGfHeader has already held the count to the spec's 1..99999, so it can + // no longer exceed any real payload by enough to make this scan vacuous. + const wire = new TextEncoder().encode(head.payload); + return !wire.subarray(Number(countStr)).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; + const width = h.bytesPerRow * 8; + if (h.dataBytes === "") return { width, height: null }; + 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 && + objectRotation(p) === 'N' && + gfaHeaderDims(p._gfaCache) !== null && + gfShipsSafely(p._gfaCache) + ); +} + +/** Bytes with no source image behind them are the graphic's only copy: no edit + * may clear them, nothing could re-encode them. The one predicate behind + * commitTransform, normalizeChanges and densityRescale, at ANY rotation: + * a rotated cache is only latently blank (rotating back restores it). */ +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: without this the bounds, the ^FT anchor + // and the canvas all described ink that toZPL replaces with an empty field. + if (p.rawGf) return gfShipsSafely(p.rawGf) ? p.rawGf : undefined; + return objectRotation(p) === 'N' && p._gfaCache && gfShipsSafely(p._gfaCache) + ? p._gfaCache + : undefined; } /** Fresh upright ^GFA from the image store, for emit sites that need bytes @@ -152,11 +268,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 +284,22 @@ 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 that means to ship its bytes: formatGraphicUpload drops the + // ~DY when it cannot, but the ^XG stays, so the field recalls a file that + // was never uploaded. `storedAs` alone made this count as resolvable below, + // which is why it printed nothing without a word. + if (p.storedAs && p.storedAs.embedInZpl !== false) { + const upload = p._gfaCache ?? inlineGfaFor(p); + if (!upload || !gfShipsSafely(upload)) { + 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 +327,11 @@ 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 theirs to keep: clearing the cache would trade a visible graphic + // for an empty field, and nothing could bring it back. Rotation-agnostic + // like the rawGf guard above; the upright-only rule is emit's, not ours. + 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 +355,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 @@ -232,8 +377,11 @@ export const image: ObjectTypeCore = { // _gfaCache holds the upright bytes, so a rotated field regenerates fresh // (rasterizeMono bakes the rotation in). const rot = objectRotation(p); + // The cache goes through the same ship guard as every other verbatim path; + // with a source image behind it we can simply re-encode instead of dropping. + const usableCache = p._gfaCache && gfShipsSafely(p._gfaCache) ? p._gfaCache : ''; const gfa = rot === 'N' - ? (p._gfaCache || gfaSync(cached.dataUrl, p.widthDots, p.threshold, 'N')) + ? (usableCache || gfaSync(cached.dataUrl, p.widthDots, p.threshold, 'N')) : gfaSync(cached.dataUrl, p.widthDots, p.threshold, rot); return `${anchor}${gfa}^FS`; }, diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 60a8a9e5..be051cb7 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -120,8 +120,12 @@ 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 lookup for `LabelObject['type']`; undefined for non-leaf (e.g. `'group'`). + * hasOwn-gated: a bare bracket read resolves Object.prototype members, so a + * type of "constructor" or "toString" answered as a registered entry and every + * caller's `getEntry(t) === undefined` guard passed it through. */ 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..470f6aa5 100644 --- a/packages/core/src/types/LabelObject.ts +++ b/packages/core/src/types/LabelObject.ts @@ -45,6 +45,12 @@ export type LabelObjectBase = z.infer; export type ObjectChanges = Partial> & { props?: object }; +/** Prop keys that never reach emitted ZPL (design-time state only), classified + * globally by key name. Consumers: the dirty-tracking overlay invalidation and + * the MCP boundary's control-character check (verbatim user text is legal + * here). Membership locked in LabelObject.test.ts. */ +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..a1fa9cb9 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", () => { @@ -307,23 +308,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..9c36fe96 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 } 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, @@ -61,16 +84,25 @@ export function ImageObject({ // 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"; 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 (rawGf at + // any rotation, _gfaCache only upright, nothing while a store image exists), + // so the canvas can't show bytes the print would not use. + : gfaPreviewCanvas( + p, + headerByteSource(p), + p.rawGf ? "N" : objectRotation(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 +139,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..d0841ff3 100644 --- a/src/components/Canvas/KonvaObject.tsx +++ b/src/components/Canvas/KonvaObject.tsx @@ -7,6 +7,7 @@ import { BarcodeObject } from "./BarcodeObject"; import { LineObject } from "./LineObject"; import { ImageObject } from "./ImageObject"; import { dotsToPx, pxToDots } from "@zplab/core/lib/coordinates"; +import { rightAnchorShiftDots, rightAnchorBoxWidthDots, rotatedFootprint } from "@zplab/core/lib/objectBounds"; import { measureInkWidthPx } from "@zplab/core/lib/labelGeometry/measureTextDots"; import { outlineInset } from "../../lib/shapeGeometry"; import { reverseShapeStyle } from "./reverseShapeStyle"; @@ -539,7 +540,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 +554,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 - rightAnchorShiftDots(obj, rightAnchorBoxWidthDots(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..ffd8248a 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,20 @@ 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); + // Variable DEFAULTS, not the previewed row or render mode (resolveForMeasure): + // this probe only feeds the anchor re-pin, which writes a persisted x, so a + // preview toggle must not move what prints — and the sidecar, which has no + // row, has to arrive at the same number for the same edit. 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..1d3c6f33 100644 --- a/src/components/Canvas/hooks/useKonvaTransformer.ts +++ b/src/components/Canvas/hooks/useKonvaTransformer.ts @@ -40,7 +40,7 @@ import { modelPositionFromRenderedTopLeft, renderedTopLeftFromModel, } from "../transformPosition"; -import { isBarcode, type BoundingBoxDots } from "@zplab/core/lib/objectBounds"; +import { isBarcode, isRightAnchoredField, rotatedFootprint, type BoundingBoxDots } from "@zplab/core/lib/objectBounds"; import { projectMultiResize } from "../../../lib/multiResize"; import { lineHandlesNodeId, lineRootNodeId } from "../konvaObjectProps"; import { isAxisSwapped, objectRotation } from "@zplab/core/registry/rotation"; @@ -1354,7 +1354,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 +1444,22 @@ export function useKonvaTransformer({ ); committedW = dims.w; committedH = dims.h; + } else if (!(obj.positionType === "FT" && isBarcode(obj)) && isRightAnchoredField(obj)) { + // Right-anchored text/symbol/FO-2D: the inverse must add back the width + // being committed. The measured snapshot holds the pre-resize box, the + // drag scale is what the release multiplied it by; the id-bearing node is + // a Konva Group, whose own width()/height() are always 0. Symbols never + // publish a snapshot: their box is their props (and never turns). + 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..efcaca59 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,34 @@ export function modelPositionFromRenderedTopLeft( committedUprightH?: number, committedMagnification?: number, ): { x: number; y: number } { + // Every branch shifts: the renderers and objectBounds do it unconditionally, + // and objectBounds shifts by the BOX width, which on a quarter turn is the + // upright height (the committed pair is always upright; a symbol's box never + // turns, see rightAnchorBoxWidthDots). + 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 +115,36 @@ 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 }; +} + +/** The width a right-justified field's render shifts left by, from the same + * measured footprint the renderer and objectBounds use. Without it a resize + * would commit the visual left edge into a model x that means the right one. */ +function rightAnchorShift(obj: LeafObject, committedWidth?: number): number { + // A resize passes the width it is committing; otherwise the measured + // footprint, with core resolving the unmeasured cases. + const width = rightAnchorBoxWidthDots(obj, committedWidth ?? getMeasuredSnapshot().get(obj.id)?.width); + return 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..a2cdd102 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); @@ -148,7 +150,10 @@ function rotateMeasured( const width = odd ? m.height : m.width; const height = odd ? m.width : m.height; const next = { ...m, width, height }; - const tz = barcodeTextZoneDots(leaf); + // The measured upright bar width, so the GS1 band shrinks to fit exactly as + // it did in the measurement this entry came from; omitting it would reserve + // the un-shrunk band and re-anchor the rotated bars off the canvas. + const tz = barcodeTextZoneDots(leaf, m.uprightBarWDots ?? 0); if (tz > 0) { // Same placement the renderer uses; objectBounds only needs the bar's top, // left and height for the FT anchor, so barW is dropped here. diff --git a/src/lib/multiResize.test.ts b/src/lib/multiResize.test.ts index 9699f912..161b38b2 100644 --- a/src/lib/multiResize.test.ts +++ b/src/lib/multiResize.test.ts @@ -127,3 +127,21 @@ 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); + }); +}); diff --git a/src/lib/multiResize.ts b/src/lib/multiResize.ts index 407a984c..d16a1c95 100644 --- a/src/lib/multiResize.ts +++ b/src/lib/multiResize.ts @@ -1,6 +1,10 @@ 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 { + rightAnchorBoxWidthDots, + rightAnchorShiftDots, + type BoundingBoxDots, +} from "@zplab/core/lib/objectBounds"; import { makeFree } from "./lineConstrain"; export interface MultiResizeChange { @@ -21,12 +25,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)); + // The union bbox is ink space while leaf.x is the model anchor, and for a + // right-justified field those differ by one box width: project the ink edge + // and carry the anchor back (same rule as groupRotation's leafChanges). + // Zero for shapes, which are never right-anchored (GRAPHIC_ANCHOR_TYPES), + // and their width is the one this gesture changes. + const shift = rightAnchorShiftDots( + leaf, + rightAnchorBoxWidthDots(leaf, measuredWidthDots?.(leaf.id)), + ); + const x = Math.round(projectX(leaf.x - shift)) + 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..76ee2e08 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 (patch_design 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..948a2323 100644 --- a/src/store/anchorRepin.ts +++ b/src/store/anchorRepin.ts @@ -1,20 +1,13 @@ 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. */ +/** The canvas registers a probe that resolves variable DEFAULTS, the same + * source the sidecar's measurer uses (resolveForMeasure): the re-pin writes a + * persisted x, so neither the previewed row nor a render-mode toggle may reach + * it. Null in node tests, where re-pinning is simply off. */ let prober: BarcodeWidthProber | null = null; export function registerBarcodeWidthProber(p: BarcodeWidthProber | null): void { @@ -31,33 +24,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 From a977d0c5cf754ee41a2cbd13049debc2f1b20ddd Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 9 Aug 2026 13:49:24 +0200 Subject: [PATCH 02/15] fix: stop the spec range gate from dropping real graphics, cut format C at b Our own encoder writes counts past the documented 1..99999, so gating the grammar on it silently emptied every full-size graphic; the render budgets stay where they are spent. And c only stands in for b uncompressed, never for the compressed format, where it outruns the payload and voids the overhang scan. --- packages/core/src/registry/image.ts | 39 ++++++++++++++++------------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/packages/core/src/registry/image.ts b/packages/core/src/registry/image.ts index 4a7a382a..90f6c82b 100644 --- a/packages/core/src/registry/image.ts +++ b/packages/core/src/registry/image.ts @@ -103,8 +103,6 @@ function gfaSync(dataUrl: string, widthDots: number, threshold: number, rotation return raster ? gfaFromRaster(raster) : ''; } -/** Spec p.215 range for ^GF's byte counts and bytes-per-row. */ -const inGfRange = (n: number): boolean => Number.isInteger(n) && n >= 1 && n <= 99999; export interface GfHeader { format: "A" | "B" | "C"; @@ -124,15 +122,13 @@ export function parseGfHeader(value: string | undefined): GfHeader | null { const m = value ? /^\^GF([ABC]),(\d*),(\d*),(\d+)(?:,|$)/.exec(value) : null; if (!m) return null; const bytesPerRow = Number(m[4]); - // Spec p.215: b, c and d are each "Values: 1 to 99999". Enforced in the one - // place that owns the grammar, so no consumer has to re-derive it — without - // it a c of 4000000 drove the emitted ^FT anchor and a b of 1e20 sailed past - // Number.isInteger into the overhang scan. Empty b/c stay legal (the byte - // counts are optional, and the parser preserves headers that omit them). - if (!inGfRange(bytesPerRow)) return null; - if ((m[2] !== "" && !inGfRange(Number(m[2]))) || (m[3] !== "" && !inGfRange(Number(m[3])))) { - return null; - } + // No range gate on b/c. The spec documents them as 1..99999 (p.215), but that + // is a documentation limit, not a wire limit: our own encoder writes + // ^GFA,124236,124236,102 for a 4x6in graphic at 8 dpmm, and rejecting it here + // made every consumer read the header as unusable and drop the graphic. + // The render budgets that DO bind live where they are spent (gfaHeaderDims, + // the decode caps), not in the grammar. + if (bytesPerRow <= 0) return null; return { format: m[1] as GfHeader["format"], totalBytes: m[2] ?? "", @@ -178,17 +174,24 @@ export function gfShipsSafely(value: string): boolean { // neither) one anywhere ends the graphic, wherever the count says it stops. if (head.format === "A" || wrapped) return !/[\^~]/.test(head.payload); // p.215, binary: "All control prefixes are ignored until the total number of - // bytes needed for the graphic format is sent" — so b (or c when b is - // omitted) IS the boundary, and only bytes past it are read as commands. - const countStr = head.totalBytes !== "" ? head.totalBytes : head.dataBytes; + // bytes needed for the graphic format is sent", so b is the boundary and only + // bytes past it are read as commands. c may stand in for b only where the two + // are equal, which is the UNCOMPRESSED case: for format C, c is the size of + // the decompressed image and always outruns the wire payload, so taking it + // would put the cut past the end and scan nothing at all. + const countStr = + head.totalBytes !== "" ? head.totalBytes : head.format === "C" ? "" : head.dataBytes; // Nothing declares where the data ends, so the whole payload must be clean. 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 and a JS slice would cut in the wrong place. - // parseGfHeader has already held the count to the spec's 1..99999, so it can - // no longer exceed any real payload by enough to make this scan vacuous. + // char can be several bytes and a JS slice would cut in the wrong place. A + // count at or past the payload leaves no overhang, which is the honest answer: + // the firmware then keeps consuming the following stream AS DATA, so the label + // breaks but no command of ours executes. const wire = new TextEncoder().encode(head.payload); - return !wire.subarray(Number(countStr)).some((b) => b === 0x5e || b === 0x7e); + 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, From bd81c376014f37ea0de8b5c34abf0037c2eac30e Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 9 Aug 2026 14:09:48 +0200 Subject: [PATCH 03/15] fix: measure the GS1 band instead of the DOM, and stop the parser strip backtracking The band is now module-width only (Labelary-measured), so the headless kernel and the canvas cannot answer differently; the line-wrap strip is a linear scan in the parser helpers, where the regex it replaces cost 114s at 8000 breaks. --- packages/core/src/lib/barcodeDims.ts | 10 +---- packages/core/src/lib/barcodeHri.test.ts | 24 +++++++---- packages/core/src/lib/barcodeHri.ts | 42 +++++++------------ .../core/src/lib/zplParser.multiline.test.ts | 13 ++++++ packages/core/src/lib/zplParser.ts | 6 +-- packages/core/src/lib/zplParser/helpers.ts | 20 +++++++++ src/lib/groupRotation.ts | 5 +-- 7 files changed, 72 insertions(+), 48 deletions(-) diff --git a/packages/core/src/lib/barcodeDims.ts b/packages/core/src/lib/barcodeDims.ts index 03316332..8c070e46 100644 --- a/packages/core/src/lib/barcodeDims.ts +++ b/packages/core/src/lib/barcodeDims.ts @@ -597,13 +597,7 @@ export function getDisplaySize( const w = isQuarter ? upright.h : upright.w; const h = isQuarter ? upright.w : upright.h; - // Upright bar width feeds the GS1 band's shrink-to-fit; without it that band - // would be reserved un-shrunk. - const textZonePx = dotsToPx( - barcodeTextZoneDots(obj, pxToDots(upright.w, scale, dpmm)), - scale, - dpmm, - ); + const textZonePx = dotsToPx(barcodeTextZoneDots(obj), scale, dpmm); const zoneAbove = barcodeZoneAbove(obj); // Map the upright "below the bars" zone onto the rotated bbox: it travels @@ -697,7 +691,7 @@ function getUprightDisplaySize( const modulePx = dotsToPx(obj.props.moduleWidth, scale, dpmm); const bwipSc = get1DBwipScale(obj.props.moduleWidth, scale, dpmm); const w = (cw / bwipSc) * modulePx; - const zone = barcodeTextZoneDots(obj, pxToDots(w, scale, dpmm)); + const zone = barcodeTextZoneDots(obj); const h = dotsToPx(obj.props.height + zone, scale, dpmm); return { w, h }; } diff --git a/packages/core/src/lib/barcodeHri.test.ts b/packages/core/src/lib/barcodeHri.test.ts index 9cf683bf..6759d058 100644 --- a/packages/core/src/lib/barcodeHri.test.ts +++ b/packages/core/src/lib/barcodeHri.test.ts @@ -51,10 +51,10 @@ describe("HRI zone coverage", () => { }); describe("a GS1-128's interpretation band", () => { - const leaf = (gs1: boolean, moduleWidth: number) => + const leaf = (gs1: boolean, moduleWidth: number, content = "(01)09501101530003") => ({ id: "b", type: "code128", x: 0, y: 0, rotation: 0, - props: { content: "(01)09501101530003", height: 60, moduleWidth, printInterpretation: true, gs1 }, + props: { content, height: 60, moduleWidth, printInterpretation: true, gs1 }, }) as never; it("is taller than the plain one, because the HRI font is scaled up", () => { @@ -65,11 +65,21 @@ describe("a GS1-128's interpretation band", () => { } }); - it("shrinks back toward the plain band once the bars constrain the font", () => { - // Same shrink-to-fit the renderer applies: a narrow symbol cannot show the - // full-size font, so reserving it un-shrunk would over-report. - const unshrunk = barcodeTextZoneDots(leaf(true, 2)); - expect(barcodeTextZoneDots(leaf(true, 2), 40)).toBeLessThan(unshrunk); + 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", () => { diff --git a/packages/core/src/lib/barcodeHri.ts b/packages/core/src/lib/barcodeHri.ts index aa989386..e6010403 100644 --- a/packages/core/src/lib/barcodeHri.ts +++ b/packages/core/src/lib/barcodeHri.ts @@ -10,13 +10,14 @@ import { GS1_HRI_FONT_SCALE, GS1_HRI_WIDTH_RATIO, HRI_FONT_0, - VERA_MONO_HRI_EM_PER_MODULE, } from "./bwipConstants"; 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, @@ -61,10 +62,8 @@ export function hriZoneDots(moduleWidth: number): number { /** 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; - * the rest reserve a module-scaled line, but only with the line turned on. - * `barWidthDots` is the measured bar width, which only the GS1 band below - * needs; omitting it over-reserves that band rather than under-reporting it. */ -export function barcodeTextZoneDots(obj: LeafObject, barWidthDots = 0): number { + * 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") { return p.printInterpretation ? upcSuppTextZoneDots(p.moduleWidth ?? 2) : 0; @@ -74,28 +73,19 @@ export function barcodeTextZoneDots(obj: LeafObject, barWidthDots = 0): number { const printsHri = HRI_LINE_TYPES.has(obj.type) && p.printInterpretation === true; if (!printsHri) return 0; const moduleWidth = p.moduleWidth ?? 2; - return hriZoneDots(moduleWidth) * gs1ZoneScale(obj, moduleWidth, barWidthDots); + // 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 draws its interpretation line at gs1HriFontDots, up to - * GS1_HRI_FONT_SCALE of the plain em, so the plain band hriZoneDots measures is - * too short and the HRI runs outside the published bbox (the off-label bottom - * test and the overlap scan then under-report it). The band scales with the em, - * so scale it by the same ratio the renderer applies. Estimated, not measured: - * like hriZoneDots' own fit this still wants Labelary/ZD230 confirmation, and - * it deliberately errs long (a too-tall band over-reports, a too-short one - * hides ink running off the media). */ -function gs1ZoneScale(obj: LeafObject, moduleWidth: number, barWidthDots: number): number { - // The registry predicate, not a raw props read: a type that cannot carry GS1 - // must not scale its band off a stray flag. - if (!isGs1Active(ObjectRegistry[obj.type], obj.props)) return 1; - const hri = ObjectRegistry[obj.type]?.hri; - const baseFontDots = hri?.fontDots - ? hri.fontDots(moduleWidth) - : moduleWidth * VERA_MONO_HRI_EM_PER_MODULE; - if (baseFontDots <= 0) return 1; - const content = (obj.props as { content?: string }).content ?? ""; - return gs1HriFontDots(content, baseFontDots, barWidthDots) / baseFontDots; +/** GS1-128 band, Labelary-measured at 8 dpmm over module widths 1-5 + * (21/34/52/66/82 dots) and fitted to never read short: a short band hides HRI + * running off the media. Module width only, so the headless kernel and the + * canvas cannot answer differently for one object. */ +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/zplParser.multiline.test.ts b/packages/core/src/lib/zplParser.multiline.test.ts index 8714a903..1a9acd09 100644 --- a/packages/core/src/lib/zplParser.multiline.test.ts +++ b/packages/core/src/lib/zplParser.multiline.test.ts @@ -75,3 +75,16 @@ describe("a whitespace character parameter at line end", () => { expect(contents).toContain("«field_1»"); }); }); + +describe("a command whose parameters carry a long run of line breaks", () => { + // The line-wrap strip used to be `/[\r\n]+\s*$/`, whose overlapping classes + // backtrack cubically: 114 s at 8000 breaks, and a raw-binary ^GF payload + // carries exactly such runs. Sub-millisecond now; the budget is generous so + // the pin is about the complexity class, not the machine. + 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 0b8ae6a6..3b4281a3 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"; @@ -444,10 +444,10 @@ export function parseZPL( // counts. A whitespace-VALUED parameter survives either way (`^FE `, // `^FC%,{, ` keep their space); the delimiter is never whitespace // (acceptsPrefixRemap), so this cannot eat one. ^FD keeps `rest` verbatim. - const p = rest.replace(/[\r\n]+\s*$/, "").split(s.format.delimiterChar); + 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] = last.replace(/[^\S\r\n]+$/, ""); + 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). diff --git a/packages/core/src/lib/zplParser/helpers.ts b/packages/core/src/lib/zplParser/helpers.ts index d14c97c8..30369d64 100644 --- a/packages/core/src/lib/zplParser/helpers.ts +++ b/packages/core/src/lib/zplParser/helpers.ts @@ -371,3 +371,23 @@ export function decodeFH( return decoder.decode(bytes); }); } + +/** Cut the trailing line break and the next line's indent off a command's + * parameter text. Scanned, not matched: `/[\r\n]+\s*$/` has overlapping + * classes, so a long run of breaks followed by anything else backtracks + * cubically (114 s at 8000 breaks), and a raw-binary ^GF payload carries + * exactly such 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/src/lib/groupRotation.ts b/src/lib/groupRotation.ts index a2cdd102..26e42e8c 100644 --- a/src/lib/groupRotation.ts +++ b/src/lib/groupRotation.ts @@ -150,10 +150,7 @@ function rotateMeasured( const width = odd ? m.height : m.width; const height = odd ? m.width : m.height; const next = { ...m, width, height }; - // The measured upright bar width, so the GS1 band shrinks to fit exactly as - // it did in the measurement this entry came from; omitting it would reserve - // the un-shrunk band and re-anchor the rotated bars off the canvas. - const tz = barcodeTextZoneDots(leaf, m.uprightBarWDots ?? 0); + const tz = barcodeTextZoneDots(leaf); if (tz > 0) { // Same placement the renderer uses; objectBounds only needs the bar's top, // left and height for the FT anchor, so barW is dropped here. From 6e8e013af215afdec43b74c78132a05c8403728b Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 9 Aug 2026 14:30:51 +0200 Subject: [PATCH 04/15] test: pin every ^GF payload a review round found reaching the wire The guard has been rebuilt several times and each rebuild closed one shape while opening another; one corpus makes that visible in a single run. --- .../core/src/registry/image.shipGuard.test.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 packages/core/src/registry/image.shipGuard.test.ts 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..63062ed2 --- /dev/null +++ b/packages/core/src/registry/image.shipGuard.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from "vitest"; +import { gfShipsSafely } from "./image"; + +// Every payload a review round found reaching the wire, kept together so a +// later change to the guard cannot quietly reopen one of them. The guard has +// been rebuilt several times (byte counts, a whitelist, wire-byte offsets) and +// each rebuild closed one shape while opening another; this is the corpus that +// makes that visible in one run. +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,"], + ["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"], + // 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"], + ["no count, clean payload", "^GFB,,,2,ABCD"], + // 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); + }); + } +}); From fff433e90e2dc76fb7bd7eff556ca4c8f17fd656 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 9 Aug 2026 15:26:10 +0200 Subject: [PATCH 05/15] fix: claim a storage path only once its upload exists, resolve a widthless block as plain A graphic whose bytes cannot be written no longer silences every later object sharing its path. And a declared block with no width resolves to normal in the one mode source, so the renderer, the bounds and the anchor stop disagreeing. --- .../src/lib/objectBounds.rightAnchor.test.ts | 21 ++++++++++++++++++- packages/core/src/lib/zplGenerator.ts | 8 +++++-- packages/core/src/registry/text.ts | 6 +++++- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/core/src/lib/objectBounds.rightAnchor.test.ts b/packages/core/src/lib/objectBounds.rightAnchor.test.ts index 53679b51..1c94e3b0 100644 --- a/packages/core/src/lib/objectBounds.rightAnchor.test.ts +++ b/packages/core/src/lib/objectBounds.rightAnchor.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { objectBoundsDots, rightAnchorShiftDots } from "./objectBounds"; +import { isRightAnchoredField, objectBoundsDots, rightAnchorShiftDots } from "./objectBounds"; import { computePreflight } from "./preflight"; import type { LabelObject } from "../types/Group"; import type { PageLabel } from "../types/LabelConfig"; @@ -121,3 +121,22 @@ describe("a runaway ^GF bytes-per-row", () => { expect(box.width).toBeLessThan(100000); }); }); + +describe("a right-justified text whose block has no width", () => { + // The renderer drew it as plain text (shifting the ink left) while the bounds + // still called it a block (no shift), so a resize committed the visual left + // edge as the model x and walked the field left by its own ink width. + it("is treated as plain text by every consumer, so the anchor agrees", () => { + const leaf = { + id: "t", type: "text", x: 400, y: 50, rotation: 0, fieldJustify: "R", + props: { content: "HELLO", fontHeight: 30, fontWidth: 0, rotation: "N", textMode: "tb", blockWidth: 0 }, + } as never; + expect(isRightAnchoredField(leaf)).toBe(true); + // With a width the block owns its justification, so the field anchor drops. + const withWidth = { + ...(leaf as object), + props: { ...(leaf as { props: object }).props, blockWidth: 200 }, + } as never; + expect(isRightAnchoredField(withWidth)).toBe(false); + }); +}); diff --git a/packages/core/src/lib/zplGenerator.ts b/packages/core/src/lib/zplGenerator.ts index 2b4eda31..870aa26f 100644 --- a/packages/core/src/lib/zplGenerator.ts +++ b/packages/core/src/lib/zplGenerator.ts @@ -711,9 +711,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/registry/text.ts b/packages/core/src/registry/text.ts index 65cb4fea..d59de00f 100644 --- a/packages/core/src/registry/text.ts +++ b/packages/core/src/registry/text.ts @@ -24,7 +24,11 @@ export function resolveTextMode(p: Pick Date: Sun, 9 Aug 2026 17:46:17 +0200 Subject: [PATCH 06/15] perf: memoise the ship guard off the geometry path, drop the preflight raster The guard scans a whole payload and objectBoundsDots reaches it several times per leaf per frame; the answer is now cached on the props object, which is replaced exactly when it can change. Preflight asks whether an upload can be written instead of rasterising one to find out. Plus a decode assertion that can actually fail. --- packages/core/src/lib/gfaDecode.test.ts | 10 ++++----- packages/core/src/registry/image.ts | 30 ++++++++++++++++++++----- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/packages/core/src/lib/gfaDecode.test.ts b/packages/core/src/lib/gfaDecode.test.ts index 4e4f4cc0..58ec1dfa 100644 --- a/packages/core/src/lib/gfaDecode.test.ts +++ b/packages/core/src/lib/gfaDecode.test.ts @@ -41,11 +41,11 @@ describe("rasterFromGfa", () => { expect(hex(rasterFromGfa("^GFA,4,4,2,MF")!)).toBe("FFFFFFF0"); }); - it("refuses a wrapped payload rather than reading it as hex", () => { - // Every raw-binary import stores its cache as ^GFA,…,:B64:…; decoding that - // as hex would draw plausible noise. - const wrapped = rasterFromGfa("^GFA,4,4,2,:B64:AP//AA==:1234"); - expect(wrapped === null || hex(wrapped)).not.toBe("B64AAA"); + 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", () => { diff --git a/packages/core/src/registry/image.ts b/packages/core/src/registry/image.ts index 90f6c82b..505f9bf0 100644 --- a/packages/core/src/registry/image.ts +++ b/packages/core/src/registry/image.ts @@ -242,12 +242,26 @@ export function headerByteSource(p: ImageProps): string | undefined { if (getImage(p.imageId)) return undefined; // Only bytes emit will actually ship: without this the bounds, the ^FT anchor // and the canvas all described ink that toZPL replaces with an empty field. - if (p.rawGf) return gfShipsSafely(p.rawGf) ? p.rawGf : undefined; - return objectRotation(p) === 'N' && p._gfaCache && gfShipsSafely(p._gfaCache) - ? p._gfaCache - : undefined; + // Memoised per props object, because gfShipsSafely scans the whole payload + // and objectBoundsDots reaches this several times per leaf per frame (bounds, + // approx, selection union, preflight). The props object is replaced on every + // edit (applyChanges), which is exactly when the answer can change. + const hit = SHIP_SOURCE_CACHE.get(p); + if (hit !== undefined) return hit.value; + const source = p.rawGf + ? gfShipsSafely(p.rawGf) + ? p.rawGf + : undefined + : objectRotation(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 { @@ -298,8 +312,12 @@ export const image: ObjectTypeCore = { // was never uploaded. `storedAs` alone made this count as resolvable below, // which is why it printed nothing without a word. if (p.storedAs && p.storedAs.embedInZpl !== false) { - const upload = p._gfaCache ?? inlineGfaFor(p); - if (!upload || !gfShipsSafely(upload)) { + // Asked, not rasterised: inlineGfaFor decodes the source and runs a full + // rasterizeMono, and this hook runs from the canvas render body on every + // findings recompute. A store image means emit can re-encode; without one + // the cache is the only upload source there is. + 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' }]; } } From f09665c3d92ae8d690b6a15ee95ce0c67db7720b Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 9 Aug 2026 19:14:16 +0200 Subject: [PATCH 07/15] revert: resolve a widthless block as plain text It dropped the ^TB and blockHeight from the emit while blockExtentFor, which reads props.textMode raw, still shifted the anchor by the block extent: silent ZPL loss for a divergence whose root is rightAnchorBoxWidthDots returning 0 for "width unknown". --- .../src/lib/objectBounds.rightAnchor.test.ts | 21 +------------------ packages/core/src/registry/text.ts | 6 +----- 2 files changed, 2 insertions(+), 25 deletions(-) diff --git a/packages/core/src/lib/objectBounds.rightAnchor.test.ts b/packages/core/src/lib/objectBounds.rightAnchor.test.ts index 1c94e3b0..53679b51 100644 --- a/packages/core/src/lib/objectBounds.rightAnchor.test.ts +++ b/packages/core/src/lib/objectBounds.rightAnchor.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { isRightAnchoredField, objectBoundsDots, rightAnchorShiftDots } from "./objectBounds"; +import { objectBoundsDots, rightAnchorShiftDots } from "./objectBounds"; import { computePreflight } from "./preflight"; import type { LabelObject } from "../types/Group"; import type { PageLabel } from "../types/LabelConfig"; @@ -121,22 +121,3 @@ describe("a runaway ^GF bytes-per-row", () => { expect(box.width).toBeLessThan(100000); }); }); - -describe("a right-justified text whose block has no width", () => { - // The renderer drew it as plain text (shifting the ink left) while the bounds - // still called it a block (no shift), so a resize committed the visual left - // edge as the model x and walked the field left by its own ink width. - it("is treated as plain text by every consumer, so the anchor agrees", () => { - const leaf = { - id: "t", type: "text", x: 400, y: 50, rotation: 0, fieldJustify: "R", - props: { content: "HELLO", fontHeight: 30, fontWidth: 0, rotation: "N", textMode: "tb", blockWidth: 0 }, - } as never; - expect(isRightAnchoredField(leaf)).toBe(true); - // With a width the block owns its justification, so the field anchor drops. - const withWidth = { - ...(leaf as object), - props: { ...(leaf as { props: object }).props, blockWidth: 200 }, - } as never; - expect(isRightAnchoredField(withWidth)).toBe(false); - }); -}); diff --git a/packages/core/src/registry/text.ts b/packages/core/src/registry/text.ts index d59de00f..65cb4fea 100644 --- a/packages/core/src/registry/text.ts +++ b/packages/core/src/registry/text.ts @@ -24,11 +24,7 @@ export function resolveTextMode(p: Pick Date: Sun, 9 Aug 2026 19:44:23 +0200 Subject: [PATCH 08/15] refactor: one resolver for the bytes an emit site ships toZPL fell back to a fresh encode when the cache could not ship while the ~DY preamble just dropped the upload, leaving its ^XG recalling a file nobody sent. Both now ask shippableGfa. --- packages/core/src/lib/zplGenerator.ts | 14 ++++++-------- packages/core/src/registry/image.ts | 24 ++++++++++++++---------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/packages/core/src/lib/zplGenerator.ts b/packages/core/src/lib/zplGenerator.ts index 870aa26f..a5dfa060 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, gfShipsSafely, parseGfHeader, type ImageProps } from '../registry/image'; +import { imageEmitDims, parseGfHeader, shippableGfa, type ImageProps } from '../registry/image'; import { formatStoragePath } from './storagePath'; function formatDownloadObject(m: CustomFontMapping): string | undefined { @@ -173,13 +173,11 @@ 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; - const h = parseGfHeader(cache); - // The ~DY preamble is a second site that turns these bytes into a stream, so - // it runs the same ship guard as toZPL: a payload carrying ^/~ past its count - // would execute here exactly as it would in the field. - if (!h || !gfShipsSafely(cache)) return undefined; + // 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); + 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}`; } diff --git a/packages/core/src/registry/image.ts b/packages/core/src/registry/image.ts index 505f9bf0..0791f5fa 100644 --- a/packages/core/src/registry/image.ts +++ b/packages/core/src/registry/image.ts @@ -264,10 +264,21 @@ 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 an emit site should ship: a cache the stream can carry, else a + * fresh encode from the source image. Undefined when neither exists, which is + * the field that prints nothing. Both sites that turn these bytes into a + * stream read it (toZPL's inline field and the ~DY preamble), so they cannot + * disagree on whether a graphic is shippable — the ~DY used to drop while the + * ^XG it belongs to still shipped, recalling a file nobody uploaded. */ +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 = { @@ -397,13 +408,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); - // The cache goes through the same ship guard as every other verbatim path; - // with a source image behind it we can simply re-encode instead of dropping. - const usableCache = p._gfaCache && gfShipsSafely(p._gfaCache) ? p._gfaCache : ''; - const gfa = rot === 'N' - ? (usableCache || gfaSync(cached.dataUrl, p.widthDots, p.threshold, 'N')) - : gfaSync(cached.dataUrl, p.widthDots, p.threshold, rot); - return `${anchor}${gfa}^FS`; + return `${anchor}${shippableGfa(p, objectRotation(p)) ?? ''}^FS`; }, }; From 646debc51c4711fe3391b1f174f44a0833eccc44 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 9 Aug 2026 23:03:12 +0200 Subject: [PATCH 09/15] fix: make an unknown right-anchor width unrepresentable as zero A zero shift states something about the field, an absent width states something about the caller, and one value meant both: a multi-resize projected an unmeasured right-justified leaf in the wrong space and persisted it. The type now forces each caller to say which it means. --- packages/core/src/lib/objectBounds.ts | 19 ++++++++++++------- src/components/Canvas/KonvaObject.tsx | 2 +- src/components/Canvas/transformPosition.ts | 6 +++++- src/lib/multiResize.test.ts | 20 ++++++++++++++++++++ src/lib/multiResize.ts | 19 ++++++++++++++----- 5 files changed, 52 insertions(+), 14 deletions(-) diff --git a/packages/core/src/lib/objectBounds.ts b/packages/core/src/lib/objectBounds.ts index 4414ab25..b2d50214 100644 --- a/packages/core/src/lib/objectBounds.ts +++ b/packages/core/src/lib/objectBounds.ts @@ -213,12 +213,17 @@ export function objectBoundsDots(obj: LabelObject, ctx: ObjectBoundsCtx): Boundi return shift === 0 ? box : { ...box, x: box.x - shift }; } -/** The rotated box width a right-anchored field shifts by. A caller with a - * measured or committed box passes it; without one, a symbol's own props are - * the truth (its box never turns) and blank text draws the placeholder. A 2D - * barcode's box comes from its encoding, so it has no props-only width: its - * renderer (BarcodeObject) always publishes a measured box before this runs. */ -export function rightAnchorBoxWidthDots(obj: LeafObject, measuredBoxWidthDots?: number): number { +/** The rotated box width a right-anchored field shifts by, or null when this + * object's width cannot be known without measuring it. Null is NOT zero: a + * zero shift is a statement about the field, an absent width is a statement + * about the caller, and conflating them let a resize commit the visual left + * edge as a model x that means the right one. A caller with a measured or + * committed box passes it; without one only a symbol (its box is its props and + * never turns) and blank text (it draws the placeholder) can be answered. */ +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") { @@ -227,7 +232,7 @@ export function rightAnchorBoxWidthDots(obj: LeafObject, measuredBoxWidthDots?: return rotatedFootprint(p.fontHeight * EMPTY_TEXT_PLACEHOLDER_GLYPHS, p.fontHeight, p.rotation).width; } } - return 0; + return null; } /** Whether this field's printed box sits left of `obj.x` (z=1 anchor). Text diff --git a/src/components/Canvas/KonvaObject.tsx b/src/components/Canvas/KonvaObject.tsx index d0841ff3..d407991b 100644 --- a/src/components/Canvas/KonvaObject.tsx +++ b/src/components/Canvas/KonvaObject.tsx @@ -563,7 +563,7 @@ function KonvaObjectInner({ : undefined; const x = offsetX + - dotsToPx(obj.x - rightAnchorShiftDots(obj, rightAnchorBoxWidthDots(obj, measuredBoxW)), scale, dpmm); + dotsToPx(obj.x - rightAnchorShiftDots(obj, rightAnchorBoxWidthDots(obj, measuredBoxW) ?? 0), scale, dpmm); useEffect(() => { if (!isSingleLineText) return; // Blank (empty or whitespace) or zero-height: drop the measured entry so diff --git a/src/components/Canvas/transformPosition.ts b/src/components/Canvas/transformPosition.ts index efcaca59..124e771a 100644 --- a/src/components/Canvas/transformPosition.ts +++ b/src/components/Canvas/transformPosition.ts @@ -137,8 +137,12 @@ function renderedWithAnchor(obj: LeafObject, x: number, y: number): { x: number; function rightAnchorShift(obj: LeafObject, committedWidth?: number): number { // A resize passes the width it is committing; otherwise the measured // footprint, with core resolving the unmeasured cases. + // Null (unmeasured, non-symbol, non-blank) draws unshifted in KonvaObject + // too, so forward and inverse stay each other's inverse. It does mean + // objectBoundsDots, which sizes from its own estimate, describes a box this + // pair does not use until the leaf has been rendered once. const width = rightAnchorBoxWidthDots(obj, committedWidth ?? getMeasuredSnapshot().get(obj.id)?.width); - return rightAnchorShiftDots(obj, width); + return rightAnchorShiftDots(obj, width ?? 0); } /** The HRI-zone offset an ^FO barcode's render applies, zero for everything diff --git a/src/lib/multiResize.test.ts b/src/lib/multiResize.test.ts index 161b38b2..74fa4e41 100644 --- a/src/lib/multiResize.test.ts +++ b/src/lib/multiResize.test.ts @@ -145,3 +145,23 @@ describe("a right-justified member of the selection", () => { 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); + }); +}); diff --git a/src/lib/multiResize.ts b/src/lib/multiResize.ts index d16a1c95..39504aaf 100644 --- a/src/lib/multiResize.ts +++ b/src/lib/multiResize.ts @@ -1,6 +1,7 @@ import type { LeafObject } from "@zplab/core/registry"; import { getEntry, SHAPE_PRIMITIVE_TYPES } from "@zplab/core/registry"; import { + isRightAnchoredField, rightAnchorBoxWidthDots, rightAnchorShiftDots, type BoundingBoxDots, @@ -39,11 +40,19 @@ export function projectMultiResize( // and carry the anchor back (same rule as groupRotation's leafChanges). // Zero for shapes, which are never right-anchored (GRAPHIC_ANCHOR_TYPES), // and their width is the one this gesture changes. - const shift = rightAnchorShiftDots( - leaf, - rightAnchorBoxWidthDots(leaf, measuredWidthDots?.(leaf.id)), - ); - const x = Math.round(projectX(leaf.x - shift)) + shift; + const boxWidth = rightAnchorBoxWidthDots(leaf, measuredWidthDots?.(leaf.id)); + // Unmeasured right-anchored leaf: its ink edge is unknown, so projecting + // its model x would move it in the wrong space and persist that. Leaving it + // where it is loses the resize for one member; guessing loses its position. + if (boxWidth === null && isRightAnchoredField(leaf)) { + changes.push({ id: leaf.id, x: leaf.x, y: Math.round(projectY(leaf.y)) }); + continue; + } + const shift = rightAnchorShiftDots(leaf, boxWidth ?? 0); + // 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(leaf.x - shift) + shift); const y = Math.round(projectY(leaf.y)); if (!SHAPE_PRIMITIVE_TYPES.has(leaf.type)) { changes.push({ id: leaf.id, x, y }); From b5f1d94cb9d7c85b4ffc7f733a60eb4b22cd5ea5 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 9 Aug 2026 23:15:57 +0200 Subject: [PATCH 10/15] refactor: name the canvas right-anchor policy once Two callers discharged the unknown width with ?? 0, which shows the conflation without resolving it. The renderer, the forward transform and its inverse now read one helper that says an unmeasurable width draws unshifted. --- src/components/Canvas/KonvaObject.tsx | 5 +++-- src/components/Canvas/transformPosition.ts | 21 ++++++++++----------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/components/Canvas/KonvaObject.tsx b/src/components/Canvas/KonvaObject.tsx index d407991b..097daebb 100644 --- a/src/components/Canvas/KonvaObject.tsx +++ b/src/components/Canvas/KonvaObject.tsx @@ -7,7 +7,8 @@ import { BarcodeObject } from "./BarcodeObject"; import { LineObject } from "./LineObject"; import { ImageObject } from "./ImageObject"; import { dotsToPx, pxToDots } from "@zplab/core/lib/coordinates"; -import { rightAnchorShiftDots, rightAnchorBoxWidthDots, rotatedFootprint } from "@zplab/core/lib/objectBounds"; +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"; @@ -563,7 +564,7 @@ function KonvaObjectInner({ : undefined; const x = offsetX + - dotsToPx(obj.x - rightAnchorShiftDots(obj, rightAnchorBoxWidthDots(obj, measuredBoxW) ?? 0), scale, dpmm); + 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/transformPosition.ts b/src/components/Canvas/transformPosition.ts index 124e771a..34c6434c 100644 --- a/src/components/Canvas/transformPosition.ts +++ b/src/components/Canvas/transformPosition.ts @@ -131,18 +131,17 @@ function renderedWithAnchor(obj: LeafObject, x: number, y: number): { x: number; return { x: x - rightAnchorShift(obj), y }; } -/** The width a right-justified field's render shifts left by, from the same - * measured footprint the renderer and objectBounds use. Without it a resize - * would commit the visual left edge into a model x that means the right one. */ -function rightAnchorShift(obj: LeafObject, committedWidth?: number): number { - // A resize passes the width it is committing; otherwise the measured - // footprint, with core resolving the unmeasured cases. - // Null (unmeasured, non-symbol, non-blank) draws unshifted in KonvaObject - // too, so forward and inverse stay each other's inverse. It does mean - // objectBoundsDots, which sizes from its own estimate, describes a box this - // pair does not use until the leaf has been rendered once. +/** How far left of its model x the CANVAS draws a right-justified field. An + * unmeasurable width draws unshifted, which is the whole policy: the renderer, + * this forward transform and its inverse all read it here, so they stay each + * other's inverse and a resize cannot commit the visual left edge as a model x + * that means the right one. objectBoundsDots sizes from its own estimate + * instead, so it still describes a box this trio does not use until the leaf + * has been rendered once. */ +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 rightAnchorShiftDots(obj, width ?? 0); + return width === null ? 0 : rightAnchorShiftDots(obj, width); } /** The HRI-zone offset an ^FO barcode's render applies, zero for everything From 172c8d4053e3182631bdf379f5e4528e85c0c06e Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 9 Aug 2026 23:22:32 +0200 Subject: [PATCH 11/15] fix: treat c as the required graphic count, and as the data boundary Labelary: omitting b renders identically, omitting c produces no label at all because the firmware never learns where the graphic ends. So a c-less header is unusable everywhere instead of each consumer guessing, and the binary scan cuts at c (the graphic size the spec names) rather than at b. --- packages/core/src/lib/gfaDecode.test.ts | 7 ++++-- packages/core/src/lib/gfaDecode.ts | 20 ++++++++-------- .../core/src/registry/image.shipGuard.test.ts | 7 +++++- packages/core/src/registry/image.ts | 23 +++++++++++-------- packages/mcp-server/src/tools.test.ts | 15 ++++++++---- 5 files changed, 46 insertions(+), 26 deletions(-) diff --git a/packages/core/src/lib/gfaDecode.test.ts b/packages/core/src/lib/gfaDecode.test.ts index 58ec1dfa..41414017 100644 --- a/packages/core/src/lib/gfaDecode.test.ts +++ b/packages/core/src/lib/gfaDecode.test.ts @@ -112,8 +112,11 @@ describe("the header count, not the stream", () => { expect(rasterFromGfa("^GFA,2,2,2,C3D4FFFF")?.heightDots).toBe(1); }); - it("falls back to the stream when the count slot is empty", () => { - expect(rasterFromGfa("^GFA,,,2,C3D4FFFF")?.heightDots).toBe(2); + 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", () => { diff --git a/packages/core/src/lib/gfaDecode.ts b/packages/core/src/lib/gfaDecode.ts index 63ba8151..f1aeb594 100644 --- a/packages/core/src/lib/gfaDecode.ts +++ b/packages/core/src/lib/gfaDecode.ts @@ -43,15 +43,17 @@ export function rasterFromGfa(gfa: string, visibleWidthDots?: number): MonoRaste // A present-but-fractional count is malformed: fall back to the placeholder // like gfaHeaderDims/emit do, or the canvas would draw a floored row count the // emitter rejects and the two would disagree on an ^FT image's position. - const declaredRows = head.dataBytes === "" ? 0 : Number.parseInt(head.dataBytes, 10) / bytesPerRow; - if (head.dataBytes !== "" && (!Number.isInteger(declaredRows) || declaredRows <= 0)) return null; - const streamRows = Math.floor(decoded.data.length / bytesPerRow); - // The DECLARED count wins, never the shorter stream: it is the height bounds - // and emit size the field by, and the firmware prints the rows the payload - // omits as blank. Shrinking to what decoded would draw a smaller graphic than - // the one that prints. Past the caps nothing is drawn at all (placeholder), - // rather than silently under-drawing a graphic we cannot hold. - const heightDots = declaredRows > 0 ? declaredRows : streamRows; + // c is required, so there is no stream fallback: a header without it prints + // nothing (it consumes the rest of the stream instead), and drawing the rows + // that happened to decode would show ink the printer never produces. + if (head.dataBytes === "") return null; + const declaredRows = Number.parseInt(head.dataBytes, 10) / bytesPerRow; + if (!Number.isInteger(declaredRows) || declaredRows <= 0) return null; + // The declared count is the height bounds and emit size the field by, and the + // firmware prints the rows the payload omits as blank, so a short stream is + // padded rather than shrinking the graphic. Past the caps nothing is drawn at + // all (placeholder), rather than under-drawing one we cannot hold. + const heightDots = declaredRows; if (heightDots <= 0 || heightDots > MAX_ROWS) return null; if (heightDots * bytesPerRow * 8 > MAX_DOTS) return null; const needed = heightDots * bytesPerRow; diff --git a/packages/core/src/registry/image.shipGuard.test.ts b/packages/core/src/registry/image.shipGuard.test.ts index 63062ed2..3443fe64 100644 --- a/packages/core/src/registry/image.shipGuard.test.ts +++ b/packages/core/src/registry/image.shipGuard.test.ts @@ -14,6 +14,11 @@ describe("payloads that must never ship verbatim", () => { // 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"], @@ -33,11 +38,11 @@ describe("payloads the importer preserves and that must keep shipping", () => { ["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"], - ["no count, clean payload", "^GFB,,,2,ABCD"], // 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)}`], diff --git a/packages/core/src/registry/image.ts b/packages/core/src/registry/image.ts index 0791f5fa..ff6e830a 100644 --- a/packages/core/src/registry/image.ts +++ b/packages/core/src/registry/image.ts @@ -167,6 +167,10 @@ export function gfShipsSafely(value: string): boolean { // 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 @@ -174,14 +178,12 @@ export function gfShipsSafely(value: string): boolean { // neither) one anywhere ends the graphic, wherever the count says it stops. if (head.format === "A" || wrapped) return !/[\^~]/.test(head.payload); // p.215, binary: "All control prefixes are ignored until the total number of - // bytes needed for the graphic format is sent", so b is the boundary and only - // bytes past it are read as commands. c may stand in for b only where the two - // are equal, which is the UNCOMPRESSED case: for format C, c is the size of - // the decompressed image and always outruns the wire payload, so taking it - // would put the cut past the end and scan nothing at all. - const countStr = - head.totalBytes !== "" ? head.totalBytes : head.format === "C" ? "" : head.dataBytes; - // Nothing declares where the data ends, so the whole payload must be clean. + // bytes needed for THE GRAPHIC FORMAT is sent", so the boundary is c (the + // bitmap size), not b (what the host transmits). Labelary-confirmed: omitting + // b prints identically, omitting c consumes the rest of the stream. For + // format C the wire carries less than c, so no wire boundary is expressible + // and the payload has to be clean throughout. + 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; @@ -205,8 +207,11 @@ export function gfaHeaderDims( // 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; - if (h.dataBytes === "") return { width, height: null }; 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 diff --git a/packages/mcp-server/src/tools.test.ts b/packages/mcp-server/src/tools.test.ts index a1fa9cb9..731174c3 100644 --- a/packages/mcp-server/src/tools.test.ts +++ b/packages/mcp-server/src/tools.test.ts @@ -275,9 +275,11 @@ 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 produces NO label, because nothing tells the + // firmware where the graphic ends and it eats the rest of the stream, + // ^XZ included. Shipping it verbatim would take the whole print job down, + // so the field drops, but loudly, which is what the roundtrip rule asks. const gfa = "^GFA,4,,1,00FF00FF"; const design = { schemaVersion: 5, @@ -287,8 +289,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]!, From 9915550cd938223ddddfdfb912a9e5fd4ea5bedb Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 9 Aug 2026 23:39:59 +0200 Subject: [PATCH 12/15] chore: comment audit over the branch diff Cuts 39 over-long blocks to the 1-3 line cap and the em dashes. Also drops the duplicated row cap and EncodeEnv.mode, which no caller ever set. --- packages/core/src/lib/anchorRepin.ts | 11 +-- .../core/src/lib/barcodeEncodePreflight.ts | 12 ++-- packages/core/src/lib/barcodeHri.ts | 5 +- .../core/src/lib/gfaDecode.labelary.test.ts | 7 +- packages/core/src/lib/gfaDecode.ts | 26 ++----- packages/core/src/lib/gs1.ts | 5 +- packages/core/src/lib/gs1Plan.ts | 5 +- packages/core/src/lib/objectBounds.ts | 25 ++----- .../core/src/lib/zplParser.multiline.test.ts | 5 +- packages/core/src/lib/zplParser.ts | 13 +--- .../core/src/lib/zplParser/decoders/gfa.ts | 20 ++---- packages/core/src/lib/zplParser/helpers.ts | 6 +- .../core/src/registry/image.shipGuard.test.ts | 6 +- packages/core/src/registry/image.ts | 68 ++++--------------- packages/core/src/registry/index.ts | 5 +- packages/core/src/types/LabelObject.ts | 5 +- packages/mcp-server/src/tools.test.ts | 5 +- src/components/Canvas/LabelCanvas.tsx | 5 +- .../Canvas/hooks/useKonvaTransformer.ts | 6 +- src/components/Canvas/transformPosition.ts | 14 +--- src/lib/multiResize.ts | 6 +- src/store/anchorRepin.test.ts | 4 +- src/store/anchorRepin.ts | 6 +- 23 files changed, 54 insertions(+), 216 deletions(-) diff --git a/packages/core/src/lib/anchorRepin.ts b/packages/core/src/lib/anchorRepin.ts index 6da96276..e0047788 100644 --- a/packages/core/src/lib/anchorRepin.ts +++ b/packages/core/src/lib/anchorRepin.ts @@ -13,11 +13,7 @@ function hasFtFlip(o: LabelObject): boolean { return (o as { positionType?: string }).positionType === "FT" && (rot === "I" || rot === "B"); } -/** Justified 1D barcodes: shift the origin so a width-changing props edit - * keeps the justified edge fixed. `probe` is the caller's width source, so - * editor and patch_design re-pin the same way. Skipped on positioning edits - * (x/y present), rotation changes (axes swap), and the op that introduces - * the justify/flip itself (no pinned edge existed yet). */ +/** Re-pins justified 1D barcodes so a width-changing edit keeps the justified edge fixed. */ export function anchorRepin( obj: LabelObject, changes: ObjectChanges, @@ -52,10 +48,7 @@ export function anchorRepin( return swapped ? { ...next, y: next.y + shift } : { ...next, x: next.x + shift }; } -/** The leaf edit pipeline shared by the editor and patch_design: registry - * normalize, top-level replace, props merge, anchor re-pin. The hook ORDER is - * the domain rule, so it lives once; callers add only their own gates (the - * editor's lock bypass, patch_design's loud lock refusal) and their probe. */ +/** Shared leaf edit pipeline: normalize, replace, merge props, re-pin, in that order. */ export function applyChanges( obj: LabelObject, changes: ObjectChanges, diff --git a/packages/core/src/lib/barcodeEncodePreflight.ts b/packages/core/src/lib/barcodeEncodePreflight.ts index 6d5d7f7f..cd006c02 100644 --- a/packages/core/src/lib/barcodeEncodePreflight.ts +++ b/packages/core/src/lib/barcodeEncodePreflight.ts @@ -1,7 +1,4 @@ -// The one decision tree for barcode encode findings: which leaf gets -// emptyContent, renderFailed or previewApproximate, and which is owned by -// another producer. The editor and the MCP sidecar both feed it their own -// encoder seam, so the two reports cannot drift (they did, three times). +// 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"; @@ -13,7 +10,6 @@ import { getObjectStringContent, type ActiveRow, type ClockResolveCtx, - type RenderMode, } from "./variableBinding"; /** Binding context so the check encodes what PRINTS: `«marker»` content is @@ -22,8 +18,6 @@ export interface EncodeEnv { variables: readonly Variable[]; active: ActiveRow | null; clock?: ClockResolveCtx; - /** How markers resolve; the canvas passes its user toggle. */ - mode?: RenderMode; } export interface EncodeVerdict { @@ -37,7 +31,9 @@ export function resolveForEncode(leaf: LeafObject, env: EncodeEnv): LeafObject { leaf, env.variables, env.active, - env.mode ?? "preview", + // 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), ); diff --git a/packages/core/src/lib/barcodeHri.ts b/packages/core/src/lib/barcodeHri.ts index e6010403..820fb84c 100644 --- a/packages/core/src/lib/barcodeHri.ts +++ b/packages/core/src/lib/barcodeHri.ts @@ -80,10 +80,7 @@ export function barcodeTextZoneDots(obj: LeafObject): number { : hriZoneDots(moduleWidth); } -/** GS1-128 band, Labelary-measured at 8 dpmm over module widths 1-5 - * (21/34/52/66/82 dots) and fitted to never read short: a short band hides HRI - * running off the media. Module width only, so the headless kernel and the - * canvas cannot answer differently for one object. */ +/** 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; } diff --git a/packages/core/src/lib/gfaDecode.labelary.test.ts b/packages/core/src/lib/gfaDecode.labelary.test.ts index 4911adcd..b20b0396 100644 --- a/packages/core/src/lib/gfaDecode.labelary.test.ts +++ b/packages/core/src/lib/gfaDecode.labelary.test.ts @@ -18,12 +18,7 @@ const rows = (gfa: string, width: number): string[] => { return out; }; -// Rendered on Labelary (8dpmm, 2x1) and read back pixel by pixel, because the -// spec (p.1759) defines what a comma, a bang and a colon each do but not what -// they do after a line the data already filled exactly. The answer is that all -// three ALWAYS produce a row: a comma following a full row fills a fresh line -// with zeros, it is not a no-op. Anything that makes them conditional turns -// every comma-separated ^GFA into half its rows. +// 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([ diff --git a/packages/core/src/lib/gfaDecode.ts b/packages/core/src/lib/gfaDecode.ts index f1aeb594..5b826982 100644 --- a/packages/core/src/lib/gfaDecode.ts +++ b/packages/core/src/lib/gfaDecode.ts @@ -2,12 +2,9 @@ // that own only the encoded bytes. The payload decoding stays the parser's. import type { MonoRaster } from "./imageToZpl"; -import { GF_MAX_BYTES_PER_ROW, parseGfHeader } from "../registry/image"; +import { GF_MAX_BYTES_PER_ROW, GF_MAX_ROWS, parseGfHeader } from "../registry/image"; import { GF_MAX_DECODED_BYTES, gfPayloadToBytes } from "./zplParser/decoders/gfa"; -/** Rows a preview will draw; past this the raster is not a label graphic. */ -const MAX_ROWS = 20_000; - /** 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. */ @@ -27,9 +24,8 @@ export function rasterFromGfa(gfa: string, visibleWidthDots?: number): MonoRaste 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) — the 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. + // 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, @@ -38,23 +34,13 @@ export function rasterFromGfa(gfa: string, visibleWidthDots?: number): MonoRaste countStr === "" ? Number.NaN : Number.parseInt(countStr, 10), ); if (!decoded) return null; - // Rows come from the header count (spec p.215: c / d), which is the number - // bounds and emit use; the stream may carry more or fewer than it declares. - // A present-but-fractional count is malformed: fall back to the placeholder - // like gfaHeaderDims/emit do, or the canvas would draw a floored row count the - // emitter rejects and the two would disagree on an ^FT image's position. - // c is required, so there is no stream fallback: a header without it prints - // nothing (it consumes the rest of the stream instead), and drawing the rows - // that happened to decode would show ink the printer never produces. + // 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; - // The declared count is the height bounds and emit size the field by, and the - // firmware prints the rows the payload omits as blank, so a short stream is - // padded rather than shrinking the graphic. Past the caps nothing is drawn at - // all (placeholder), rather than under-drawing one we cannot hold. + // 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 > MAX_ROWS) return null; + 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); diff --git a/packages/core/src/lib/gs1.ts b/packages/core/src/lib/gs1.ts index fe0e6b71..0598c83c 100644 --- a/packages/core/src/lib/gs1.ts +++ b/packages/core/src/lib/gs1.ts @@ -398,10 +398,7 @@ export function typedGs1Parts(content: string): { ai: string; value: string }[] return parts?.every((p) => aiSpec(p.ai) !== undefined) ? parts : null; } -/** A typed part's value as emitted, completing a literal GTIN exactly as - * segmentValue does for parsed segments: without it, binding any OTHER part - * to a variable would silently ship a 13-digit AI-01. A value carrying a - * marker passes through, since its check digit belongs to the supplied row. */ +/** 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; diff --git a/packages/core/src/lib/gs1Plan.ts b/packages/core/src/lib/gs1Plan.ts index 4cd9d99b..e70974a3 100644 --- a/packages/core/src/lib/gs1Plan.ts +++ b/packages/core/src/lib/gs1Plan.ts @@ -57,10 +57,7 @@ export function planGs1Fd(content: string, carrier: Gs1Carrier): Gs1FdPlan { // own characters as the field); post-substitution emitters pass the resolved // form themselves. if (hasTemplateMarkers(content)) { - // Only bwipText/parsefnc consumers reach this (canvas bars, dims); the emit - // resolves markers first and never routes template content through .fd. So - // this feeds a preview: completeTypedGtins fills a TYPED "(01)…" GTIN (MCP - // input) for the sample, and returns null for the app's raw model content. + // Feeds preview only; emit resolves markers separately and never routes template content through .fd. const typed = carrier === "code128" ? completeTypedGtins(content) : null; return { // ^BX takes the structural form (parens out, FNC1 by AI). diff --git a/packages/core/src/lib/objectBounds.ts b/packages/core/src/lib/objectBounds.ts index b2d50214..0823a698 100644 --- a/packages/core/src/lib/objectBounds.ts +++ b/packages/core/src/lib/objectBounds.ts @@ -178,11 +178,7 @@ export function isBarcode(obj: { type: string }): boolean { return BARCODE_TYPES.has(obj.type); } -/** Store-less ^GFA header dims (the byte truth objectBoundsDots sizes by). - * Exactly what imageEmitDims consults, `storedAs` included: a recall field - * prints the stored graphic at its header size, so excluding it here sized the - * box (and the off-label check) off props while the generator anchored off the - * header. Recall-only fields carry no bytes, so they still fall back to props. */ +/** Store-less ^GFA header dims, the byte truth objectBoundsDots sizes by (recall fields included). */ function imageHeaderBounds(p: ImageProps): { width: number; height: number | null } | null { return gfaHeaderDims(headerByteSource(p)); } @@ -213,13 +209,7 @@ export function objectBoundsDots(obj: LabelObject, ctx: ObjectBoundsCtx): Boundi return shift === 0 ? box : { ...box, x: box.x - shift }; } -/** The rotated box width a right-anchored field shifts by, or null when this - * object's width cannot be known without measuring it. Null is NOT zero: a - * zero shift is a statement about the field, an absent width is a statement - * about the caller, and conflating them let a resize commit the visual left - * edge as a model x that means the right one. A caller with a measured or - * committed box passes it; without one only a symbol (its box is its props and - * never turns) and blank text (it draws the placeholder) can be answered. */ +/** Rotated box width for a right-anchored field; null (unmeasurable) is distinct from a real zero shift. */ export function rightAnchorBoxWidthDots( obj: LeafObject, measuredBoxWidthDots?: number, @@ -235,10 +225,7 @@ export function rightAnchorBoxWidthDots( return null; } -/** Whether this field's printed box sits left of `obj.x` (z=1 anchor). Text - * and the 2D symbologies emit the anchor as-is, so their right-justified x IS - * the printed right edge; 1D barcodes and graphics convert on emit and keep x - * on the left. */ +/** 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; @@ -259,11 +246,7 @@ export function rightAnchorShiftDots(obj: LabelObject, widthDots: number): numbe return isRightAnchoredField(obj) ? widthDots : 0; } -/** True when the ink runs LEFT of the EMITTED anchor, so the anchor's own - * near-edge test says nothing about the home edge. Two shapes reach it: a - * right-justified text/symbol/2D field (model x already IS the right edge), - * and a right-justified ^FT graphic, whose model x is the left edge but whose - * emitted anchor is x+w (graphicAnchorCoords). ^FO graphics ignore justify. */ +/** 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 ( diff --git a/packages/core/src/lib/zplParser.multiline.test.ts b/packages/core/src/lib/zplParser.multiline.test.ts index 1a9acd09..fdf3dda9 100644 --- a/packages/core/src/lib/zplParser.multiline.test.ts +++ b/packages/core/src/lib/zplParser.multiline.test.ts @@ -77,10 +77,7 @@ describe("a whitespace character parameter at line end", () => { }); describe("a command whose parameters carry a long run of line breaks", () => { - // The line-wrap strip used to be `/[\r\n]+\s*$/`, whose overlapping classes - // backtrack cubically: 114 s at 8000 breaks, and a raw-binary ^GF payload - // carries exactly such runs. Sub-millisecond now; the budget is generous so - // the pin is about the complexity class, not the machine. + // 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(); diff --git a/packages/core/src/lib/zplParser.ts b/packages/core/src/lib/zplParser.ts index 3b4281a3..198bec0a 100644 --- a/packages/core/src/lib/zplParser.ts +++ b/packages/core/src/lib/zplParser.ts @@ -243,10 +243,7 @@ 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 parameter is literal user data, where a trailing space - // is a character and not line-wrap whitespace: ^SN's serial seed, ^SF's mask, - // ^A@'s font path. Everything else ends in an enum/number, where a hanging - // space before a line break is only formatting. + // 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)); @@ -437,13 +434,7 @@ export function parseZPL( }; for (const { cmd, rest, start } of tokens) { - // Strip the trailing break plus the next line's indent, then spaces left - // hanging after the LAST PARAMETER's real content ("N \n" -> "N"). A break - // and a real trailing space are indistinguishable syntactically, so the - // exemption is by command: LITERAL_TAIL_CMDS end in user data where a space - // counts. A whitespace-VALUED parameter survives either way (`^FE `, - // `^FC%,{, ` keep their space); the delimiter is never whitespace - // (acceptsPrefixRemap), so this cannot eat one. ^FD keeps `rest` verbatim. + // 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)) { diff --git a/packages/core/src/lib/zplParser/decoders/gfa.ts b/packages/core/src/lib/zplParser/decoders/gfa.ts index ec8bc167..4ec1ea57 100644 --- a/packages/core/src/lib/zplParser/decoders/gfa.ts +++ b/packages/core/src/lib/zplParser/decoders/gfa.ts @@ -24,10 +24,7 @@ export function rewriteRawFieldSpans( return out + text.slice(cursor); } -/** Inflate `:Z64:` zlib payload; null on a malformed stream or one that - * decompresses past the decode budget. Streamed so a zip-bomb cache (a few KB - * inflating to hundreds of MB) aborts after the cap instead of OOMing the - * webview the moment the render path decodes it. */ +/** 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 @@ -118,10 +115,7 @@ export function gfPayloadToBytes( if (format === "C") return null; if (format === "A") { const expanded = decompressGFA(rawData, bytesPerRow); - // Past the budget the decoder stops mid-graphic; returning the partial rows - // would store a silently cropped image and re-export it at the crop height. - // Null instead, matching the :Z64: path, so callers keep the payload as - // undecodable rather than as a smaller graphic than the one that prints. + // 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 }; } @@ -172,10 +166,7 @@ function decompressGFA(data: string, bytesPerRow: number): string | null { const ch = data[i] ?? ""; if (ch === ",") { - // Always produces a row, even right after one the data filled exactly: - // Labelary-verified (gfaDecode.labelary.test.ts), where `FFFF,8001,` on a - // 2-byte row renders FFFF, blank, 8001, blank. Its purpose is letting a - // row omit its trailing zeros, not terminating a row that is already full. + // Labelary: always emits a row, even after one the data filled exactly. pushRow(); i++; } else if (ch === "!") { @@ -200,10 +191,7 @@ function decompressGFA(data: string, bytesPerRow: number): string | null { } const nextCh = data[i] ?? ""; if (i < data.length && isHex(nextCh)) { - // Clamped before the allocation, not by the row cap below: consecutive - // compress chars accumulate an unbounded count, and `repeat` would - // allocate all of it (or throw RangeError) in one step. A clamp means - // the graphic did not fit, which the caller has to hear about. + // 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)); diff --git a/packages/core/src/lib/zplParser/helpers.ts b/packages/core/src/lib/zplParser/helpers.ts index 30369d64..9539bef6 100644 --- a/packages/core/src/lib/zplParser/helpers.ts +++ b/packages/core/src/lib/zplParser/helpers.ts @@ -372,11 +372,7 @@ export function decodeFH( }); } -/** Cut the trailing line break and the next line's indent off a command's - * parameter text. Scanned, not matched: `/[\r\n]+\s*$/` has overlapping - * classes, so a long run of breaks followed by anything else backtracks - * cubically (114 s at 8000 breaks), and a raw-binary ^GF payload carries - * exactly such runs. */ +/** 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]/); diff --git a/packages/core/src/registry/image.shipGuard.test.ts b/packages/core/src/registry/image.shipGuard.test.ts index 3443fe64..f5bc9477 100644 --- a/packages/core/src/registry/image.shipGuard.test.ts +++ b/packages/core/src/registry/image.shipGuard.test.ts @@ -1,11 +1,7 @@ import { describe, it, expect } from "vitest"; import { gfShipsSafely } from "./image"; -// Every payload a review round found reaching the wire, kept together so a -// later change to the guard cannot quietly reopen one of them. The guard has -// been rebuilt several times (byte counts, a whitelist, wire-byte offsets) and -// each rebuild closed one shape while opening another; this is the corpus that -// makes that visible in one run. +// 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"], diff --git a/packages/core/src/registry/image.ts b/packages/core/src/registry/image.ts index ff6e830a..58bb2ff8 100644 --- a/packages/core/src/registry/image.ts +++ b/packages/core/src/registry/image.ts @@ -121,13 +121,9 @@ export function parseGfHeader(value: string | undefined): GfHeader | null { // 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]); - // No range gate on b/c. The spec documents them as 1..99999 (p.215), but that - // is a documentation limit, not a wire limit: our own encoder writes - // ^GFA,124236,124236,102 for a 4x6in graphic at 8 dpmm, and rejecting it here - // made every consumer read the header as unusable and drop the graphic. - // The render budgets that DO bind live where they are spent (gfaHeaderDims, - // the decode caps), not in the grammar. if (bytesPerRow <= 0) return null; return { format: m[1] as GfHeader["format"], @@ -146,15 +142,7 @@ export const GF_MAX_BYTES_PER_ROW = 1024; * derived height would reach the emitted ^FT and the off-label check. */ export const GF_MAX_ROWS = 20_000; -/** Can this ^GF string be shipped verbatim without the firmware reading part of - * it as a command? Format A and the :B64:/:Z64: wrappers are ASCII alphabets, - * so a ^/~ anywhere in them is an appended command. Raw binary B/C carries - * those bytes as data INSIDE its declared count (spec p.215) and the firmware - * resumes parsing past it, so only the overhang matters — and with no count - * declared nothing bounds the data, so the whole payload has to be clean. - * Sliced in string units: ^ and ~ are single-byte ASCII wherever they sit. - * Lives here because emit is what turns these bytes into a stream; the MCP - * boundary reuses it for caller-supplied props. */ +/** 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 @@ -174,24 +162,14 @@ export function gfShipsSafely(value: string): boolean { 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" — so in format A (and the base64 wrappers, whose alphabet has - // neither) one anywhere ends the graphic, wherever the count says it stops. + // the download"; format A and the base64 wrappers ban either character anywhere. if (head.format === "A" || wrapped) return !/[\^~]/.test(head.payload); - // p.215, binary: "All control prefixes are ignored until the total number of - // bytes needed for THE GRAPHIC FORMAT is sent", so the boundary is c (the - // bitmap size), not b (what the host transmits). Labelary-confirmed: omitting - // b prints identically, omitting c consumes the rest of the stream. For - // format C the wire carries less than c, so no wire boundary is expressible - // and the payload has to be clean throughout. + // 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 and a JS slice would cut in the wrong place. A - // count at or past the payload leaves no overhang, which is the honest answer: - // the firmware then keeps consuming the following stream AS DATA, so the label - // breaks but no command of ours executes. + // 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); } @@ -232,10 +210,7 @@ function gfaCacheUsable(p: ImageProps): boolean { ); } -/** Bytes with no source image behind them are the graphic's only copy: no edit - * may clear them, nothing could re-encode them. The one predicate behind - * commitTransform, normalizeChanges and densityRescale, at ANY rotation: - * a rotated cache is only latently blank (rotating back restores it). */ +/** 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); } @@ -245,12 +220,7 @@ export function gfaCacheIsOnlyCopy(p: ImageProps): boolean { * 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: without this the bounds, the ^FT anchor - // and the canvas all described ink that toZPL replaces with an empty field. - // Memoised per props object, because gfShipsSafely scans the whole payload - // and objectBoundsDots reaches this several times per leaf per frame (bounds, - // approx, selection union, preflight). The props object is replaced on every - // edit (applyChanges), which is exactly when the answer can change. + // 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 @@ -275,12 +245,7 @@ export function inlineGfaFor(p: ImageProps, rotation: ZplRotation = 'N'): string return gfaSync(img.dataUrl, p.widthDots, p.threshold, rotation) || undefined; } -/** The ^GF bytes an emit site should ship: a cache the stream can carry, else a - * fresh encode from the source image. Undefined when neither exists, which is - * the field that prints nothing. Both sites that turn these bytes into a - * stream read it (toZPL's inline field and the ~DY preamble), so they cannot - * disagree on whether a graphic is shippable — the ~DY used to drop while the - * ^XG it belongs to still shipped, recalling a file nobody uploaded. */ +/** 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); @@ -323,15 +288,9 @@ export const image: ObjectTypeCore = { 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 that means to ship its bytes: formatGraphicUpload drops the - // ~DY when it cannot, but the ^XG stays, so the field recalls a file that - // was never uploaded. `storedAs` alone made this count as resolvable below, - // which is why it printed nothing without a word. + // 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: inlineGfaFor decodes the source and runs a full - // rasterizeMono, and this hook runs from the canvas render body on every - // findings recompute. A store image means emit can re-encode; without one - // the cache is the only upload source there is. + // 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' }]; @@ -364,10 +323,7 @@ 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 theirs to keep: clearing the cache would trade a visible graphic - // for an empty field, and nothing could bring it back. Rotation-agnostic - // like the rawGf guard above; the upright-only rule is emit's, not ours. + // 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 diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index be051cb7..841e82bb 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -120,10 +120,7 @@ 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'`). - * hasOwn-gated: a bare bracket read resolves Object.prototype members, so a - * type of "constructor" or "toString" answered as a registered entry and every - * caller's `getEntry(t) === undefined` guard passed it through. */ +/** 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.ts b/packages/core/src/types/LabelObject.ts index 470f6aa5..3b1af4db 100644 --- a/packages/core/src/types/LabelObject.ts +++ b/packages/core/src/types/LabelObject.ts @@ -45,10 +45,7 @@ export type LabelObjectBase = z.infer; export type ObjectChanges = Partial> & { props?: object }; -/** Prop keys that never reach emitted ZPL (design-time state only), classified - * globally by key name. Consumers: the dirty-tracking overlay invalidation and - * the MCP boundary's control-character check (verbatim user text is legal - * here). Membership locked in LabelObject.test.ts. */ +/** 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 diff --git a/packages/mcp-server/src/tools.test.ts b/packages/mcp-server/src/tools.test.ts index 731174c3..d8417efc 100644 --- a/packages/mcp-server/src/tools.test.ts +++ b/packages/mcp-server/src/tools.test.ts @@ -276,10 +276,7 @@ describe("mcp-server tools", () => { }); it("reports a preserved foreign header with an empty count slot", () => { - // Labelary: a ^GF missing c produces NO label, because nothing tells the - // firmware where the graphic ends and it eats the rest of the stream, - // ^XZ included. Shipping it verbatim would take the whole print job down, - // so the field drops, but loudly, which is what the roundtrip rule asks. + // 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, diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index ffd8248a..c8648889 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -483,10 +483,7 @@ export const LabelCanvas = forwardRef(function LabelCa } = useCanvasPanZoom({ zoom, onZoomChange, fitZoom, containerRef }); const scale = SCREEN_PX_PER_MM * zoom; - // Variable DEFAULTS, not the previewed row or render mode (resolveForMeasure): - // this probe only feeds the anchor re-pin, which writes a persisted x, so a - // preview toggle must not move what prints — and the sidecar, which has no - // row, has to arrive at the same number for the same edit. + // Probes variable DEFAULTS, not the previewed row, so a preview toggle cannot move the persisted anchor x. useEffect(() => { const { variables: vars, clock } = previewBinding; const probe = (o: LabelObject) => { diff --git a/src/components/Canvas/hooks/useKonvaTransformer.ts b/src/components/Canvas/hooks/useKonvaTransformer.ts index 1d3c6f33..4beaf888 100644 --- a/src/components/Canvas/hooks/useKonvaTransformer.ts +++ b/src/components/Canvas/hooks/useKonvaTransformer.ts @@ -1445,11 +1445,7 @@ export function useKonvaTransformer({ committedW = dims.w; committedH = dims.h; } else if (!(obj.positionType === "FT" && isBarcode(obj)) && isRightAnchoredField(obj)) { - // Right-anchored text/symbol/FO-2D: the inverse must add back the width - // being committed. The measured snapshot holds the pre-resize box, the - // drag scale is what the release multiplied it by; the id-bearing node is - // a Konva Group, whose own width()/height() are always 0. Symbols never - // publish a snapshot: their box is their props (and never turns). + // 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)); diff --git a/src/components/Canvas/transformPosition.ts b/src/components/Canvas/transformPosition.ts index 34c6434c..dfc42eec 100644 --- a/src/components/Canvas/transformPosition.ts +++ b/src/components/Canvas/transformPosition.ts @@ -79,10 +79,7 @@ export function modelPositionFromRenderedTopLeft( committedUprightH?: number, committedMagnification?: number, ): { x: number; y: number } { - // Every branch shifts: the renderers and objectBounds do it unconditionally, - // and objectBounds shifts by the BOX width, which on a quarter turn is the - // upright height (the committed pair is always upright; a symbol's box never - // turns, see rightAnchorBoxWidthDots). + // 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 @@ -131,13 +128,8 @@ function renderedWithAnchor(obj: LeafObject, x: number, y: number): { x: number; return { x: x - rightAnchorShift(obj), y }; } -/** How far left of its model x the CANVAS draws a right-justified field. An - * unmeasurable width draws unshifted, which is the whole policy: the renderer, - * this forward transform and its inverse all read it here, so they stay each - * other's inverse and a resize cannot commit the visual left edge as a model x - * that means the right one. objectBoundsDots sizes from its own estimate - * instead, so it still describes a box this trio does not use until the leaf - * has been rendered once. */ +/** 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); diff --git a/src/lib/multiResize.ts b/src/lib/multiResize.ts index 39504aaf..be6f7ae8 100644 --- a/src/lib/multiResize.ts +++ b/src/lib/multiResize.ts @@ -35,11 +35,7 @@ export function projectMultiResize( const projectY = (y: number) => origin.y + (y - bbox.y) * fy; const changes: MultiResizeChange[] = []; for (const leaf of leafs) { - // The union bbox is ink space while leaf.x is the model anchor, and for a - // right-justified field those differ by one box width: project the ink edge - // and carry the anchor back (same rule as groupRotation's leafChanges). - // Zero for shapes, which are never right-anchored (GRAPHIC_ANCHOR_TYPES), - // and their width is the one this gesture changes. + // Union bbox is ink space but leaf.x is the model anchor: project the ink edge, then carry the anchor back by one box width. const boxWidth = rightAnchorBoxWidthDots(leaf, measuredWidthDots?.(leaf.id)); // Unmeasured right-anchored leaf: its ink edge is unknown, so projecting // its model x would move it in the wrong space and persist that. Leaving it diff --git a/src/store/anchorRepin.test.ts b/src/store/anchorRepin.test.ts index 76ee2e08..12155c07 100644 --- a/src/store/anchorRepin.test.ts +++ b/src/store/anchorRepin.test.ts @@ -80,7 +80,7 @@ describe("anchorRepin", () => { 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 (patch_design sends both together). + // the position the caller just set (a patch sends both together). expect(next.fieldJustify).toBe("R"); expect(next.x).toBe(100); }); @@ -232,7 +232,7 @@ describe("dirty semantics of fieldJustify", () => { 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 + // 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); diff --git a/src/store/anchorRepin.ts b/src/store/anchorRepin.ts index 948a2323..ea17b40e 100644 --- a/src/store/anchorRepin.ts +++ b/src/store/anchorRepin.ts @@ -4,10 +4,8 @@ import { anchorRepin as coreAnchorRepin, type BarcodeFootprint } from "@zplab/co type BarcodeWidthProber = (obj: LabelObject) => BarcodeFootprint | null; -/** The canvas registers a probe that resolves variable DEFAULTS, the same - * source the sidecar's measurer uses (resolveForMeasure): the re-pin writes a - * persisted x, so neither the previewed row nor a render-mode toggle may reach - * it. Null in node tests, where 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 { From 2caefabbb81f5bf8afe517b3d4364376174ed8d4 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 9 Aug 2026 23:47:02 +0200 Subject: [PATCH 13/15] fix: size a marked GS1 DataMatrix preview from the runs ^FD ships The canvas encoded the raw (AI)value content, so parens and a single FNC1 that never reach the wire could push the symbol to the next size. --- packages/core/src/lib/dataMatrixFd.ts | 20 +++++++++++++++----- packages/core/src/lib/gs1Plan.test.ts | 19 +++++++++++++++++++ packages/core/src/lib/gs1Plan.ts | 13 +++++++++++-- 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/packages/core/src/lib/dataMatrixFd.ts b/packages/core/src/lib/dataMatrixFd.ts index 7bb0f3fb..6e3254f7 100644 --- a/packages/core/src/lib/dataMatrixFd.ts +++ b/packages/core/src/lib/dataMatrixFd.ts @@ -79,16 +79,26 @@ export function dataMatrixFdToGs1Content(fd: string, escape: string): string | n * 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 fnc1 = ESC + "1"; - let out = fnc1; + 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. - out += escapeRun(`${part.ai}${typedSegmentValue(part.ai, part.value)}`); + 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) out += fnc1; + if (spec && isVariableKind(spec.kind) && index < parts.length - 1) runs.push(""); } - return out; + return runs; } diff --git a/packages/core/src/lib/gs1Plan.test.ts b/packages/core/src/lib/gs1Plan.test.ts index 2e0cbb91..da16276e 100644 --- a/packages/core/src/lib/gs1Plan.test.ts +++ b/packages/core/src/lib/gs1Plan.test.ts @@ -238,3 +238,22 @@ describe("GS1 content the catalog can only partly segment", () => { 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 e70974a3..6bd30490 100644 --- a/packages/core/src/lib/gs1Plan.ts +++ b/packages/core/src/lib/gs1Plan.ts @@ -6,7 +6,11 @@ import { segmentsToZplFd, segmentsToContent, } from "./gs1"; -import { gs1ContentToDataMatrixFd, typedGs1ToDataMatrixFd } from "./dataMatrixFd"; +import { + gs1ContentToDataMatrixFd, + typedGs1DataRuns, + typedGs1ToDataMatrixFd, +} from "./dataMatrixFd"; import { hasTemplateMarkers } from "./fnTemplate"; /** GS1 carriers with distinct ^FD grammars: ^BC mode D (parenthesized + >8), @@ -59,6 +63,9 @@ export function planGs1Fd(content: string, carrier: Gs1Carrier): Gs1FdPlan { 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: @@ -66,7 +73,9 @@ export function planGs1Fd(content: string, carrier: Gs1Carrier): Gs1FdPlan { ? (typedGs1ToDataMatrixFd(content) ?? gs1ContentToDataMatrixFd(content)) : (typed ?? content), bwipText: typed ?? content, - bwipParsefncText: parsefncRuns(typed ?? content, carrier === "datamatrix" ? "^FNC1" : ""), + bwipParsefncText: dmRuns + ? parsefncRuns(dmRuns.join(GS1_GS), "^FNC1") + : parsefncRuns(typed ?? content, carrier === "datamatrix" ? "^FNC1" : ""), losses: [], }; } From d187d4965e4a0ce4580d315db5518b37187642de Mon Sep 17 00:00:00 2001 From: u8array Date: Mon, 10 Aug 2026 00:00:30 +0200 Subject: [PATCH 14/15] fix: resolve recall-field bytes upright, whatever rotation they carry A ^XG field kept its inline rotation, which gated the byte resolution: the ~DY upload dropped while the ^XG depending on it still shipped, silently. --- packages/core/src/lib/zplGenerator.ts | 4 +- .../core/src/registry/image.rotation.test.ts | 40 +++++++++++++++++++ packages/core/src/registry/image.ts | 16 ++++++-- src/components/Canvas/ImageObject.tsx | 21 ++++------ 4 files changed, 62 insertions(+), 19 deletions(-) create mode 100644 packages/core/src/registry/image.rotation.test.ts diff --git a/packages/core/src/lib/zplGenerator.ts b/packages/core/src/lib/zplGenerator.ts index a5dfa060..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 { imageEmitDims, parseGfHeader, shippableGfa, 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 { @@ -175,7 +175,7 @@ function formatGraphicUpload(p: ImageProps): string | undefined { if (!p.storedAs) return undefined; // 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); + 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}`; 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.ts b/packages/core/src/registry/image.ts index 58bb2ff8..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. */ @@ -204,7 +214,7 @@ export function gfaHeaderDims( function gfaCacheUsable(p: ImageProps): boolean { return ( !!p._gfaCache && - objectRotation(p) === 'N' && + imageEmitRotation(p) === 'N' && gfaHeaderDims(p._gfaCache) !== null && gfShipsSafely(p._gfaCache) ); @@ -227,7 +237,7 @@ export function headerByteSource(p: ImageProps): string | undefined { ? gfShipsSafely(p.rawGf) ? p.rawGf : undefined - : objectRotation(p) === 'N' && p._gfaCache && gfShipsSafely(p._gfaCache) + : imageEmitRotation(p) === 'N' && p._gfaCache && gfShipsSafely(p._gfaCache) ? p._gfaCache : undefined; SHIP_SOURCE_CACHE.set(p, { value: source }); @@ -369,6 +379,6 @@ export const image: ObjectTypeCore = { } // _gfaCache holds the upright bytes, so a rotated field regenerates fresh // (rasterizeMono bakes the rotation in). - return `${anchor}${shippableGfa(p, objectRotation(p)) ?? ''}^FS`; + return `${anchor}${shippableGfa(p, imageEmitRotation(p)) ?? ''}^FS`; }, }; diff --git a/src/components/Canvas/ImageObject.tsx b/src/components/Canvas/ImageObject.tsx index 9c36fe96..e0223d69 100644 --- a/src/components/Canvas/ImageObject.tsx +++ b/src/components/Canvas/ImageObject.tsx @@ -4,7 +4,7 @@ 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 } from "@zplab/core/registry/image"; +import { headerByteSource, imageEmitRotation, isImageRotatable } from "@zplab/core/registry/image"; import { loadImage } from "@zplab/core/lib/loadImage"; import { monoPreviewCanvas, rasterPreviewCanvas } from "@zplab/core/lib/imageToZpl"; import { useColorScheme } from "../../hooks/useColorScheme"; @@ -81,11 +81,9 @@ 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); // WYSIWYG mono preview (see monoPreviewCanvas), handed to Konva to nearest- @@ -93,14 +91,9 @@ export function ImageObject({ // the inner Group turns it. The colored source is never shown on the label. const preview = htmlImg && cached ? monoPreviewCanvas(htmlImg, p.widthDots, p.threshold) - // Byte-only graphic: headerByteSource is the emit-side precedence (rawGf at - // any rotation, _gfaCache only upright, nothing while a store image exists), - // so the canvas can't show bytes the print would not use. - : gfaPreviewCanvas( - p, - headerByteSource(p), - p.rawGf ? "N" : objectRotation(p), - ); + // 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 From 971b06d283e684c8ff15a4f4f5c9361315588379 Mon Sep 17 00:00:00 2001 From: u8array Date: Mon, 10 Aug 2026 00:08:19 +0200 Subject: [PATCH 15/15] fix: project the same x live and on commit in a multi-resize The commit projects a right-anchored field's ink edge while the live preview projected its model anchor, so the object jumped by shift*(1-f) on release. --- .../Canvas/hooks/useKonvaTransformer.ts | 16 ++++++--- src/lib/multiResize.test.ts | 36 ++++++++++++++++++- src/lib/multiResize.ts | 24 ++++++++----- 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/src/components/Canvas/hooks/useKonvaTransformer.ts b/src/components/Canvas/hooks/useKonvaTransformer.ts index 4beaf888..bc360a9a 100644 --- a/src/components/Canvas/hooks/useKonvaTransformer.ts +++ b/src/components/Canvas/hooks/useKonvaTransformer.ts @@ -41,7 +41,7 @@ import { renderedTopLeftFromModel, } from "../transformPosition"; import { isBarcode, isRightAnchoredField, rotatedFootprint, type BoundingBoxDots } from "@zplab/core/lib/objectBounds"; -import { projectMultiResize } from "../../../lib/multiResize"; +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), }); } diff --git a/src/lib/multiResize.test.ts b/src/lib/multiResize.test.ts index 74fa4e41..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; @@ -165,3 +165,37 @@ describe("a right-justified member whose width was never measured", () => { 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 be6f7ae8..4fc80769 100644 --- a/src/lib/multiResize.ts +++ b/src/lib/multiResize.ts @@ -15,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 @@ -35,20 +47,16 @@ export function projectMultiResize( const projectY = (y: number) => origin.y + (y - bbox.y) * fy; const changes: MultiResizeChange[] = []; for (const leaf of leafs) { - // Union bbox is ink space but leaf.x is the model anchor: project the ink edge, then carry the anchor back by one box width. - const boxWidth = rightAnchorBoxWidthDots(leaf, measuredWidthDots?.(leaf.id)); - // Unmeasured right-anchored leaf: its ink edge is unknown, so projecting - // its model x would move it in the wrong space and persist that. Leaving it - // where it is loses the resize for one member; guessing loses its position. - if (boxWidth === null && isRightAnchoredField(leaf)) { + 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 = rightAnchorShiftDots(leaf, boxWidth ?? 0); + 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(leaf.x - shift) + shift); + 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 });