diff --git a/packages/core/src/lib/valueAnchor.ts b/packages/core/src/lib/valueAnchor.ts new file mode 100644 index 00000000..d760b84d --- /dev/null +++ b/packages/core/src/lib/valueAnchor.ts @@ -0,0 +1,12 @@ +/** fieldJustify vocabulary; semantics documented at the schema field. */ +export type FieldJustify = 'L' | 'C' | 'R'; + +/** Origin shift keeping the justified edge fixed for an extent change `delta` + * (before minus after). Centre rounds half away from zero (half-up walks a + * dot per widen/narrow toggle); `ftFlip` inverts for ^FT I/B's bar offset. */ +export function valueAnchorShift(justify: FieldJustify, delta: number, ftFlip: boolean): number { + if (justify === 'L') return ftFlip ? -delta : 0; + if (justify === 'R') return ftFlip ? 0 : delta; + const half = Math.sign(delta) * Math.round(Math.abs(delta) / 2); + return ftFlip ? -half : half; +} diff --git a/packages/core/src/lib/zplImportService.ts b/packages/core/src/lib/zplImportService.ts index 3e1e35ce..b6a85ce2 100644 --- a/packages/core/src/lib/zplImportService.ts +++ b/packages/core/src/lib/zplImportService.ts @@ -7,6 +7,8 @@ import type { CustomFontMapping, LabelConfig } from "../types/LabelConfig"; import type { PrinterProfile } from "../types/PrinterProfile"; import type { LabelObject, Page } from "../types/Group"; import { nextFreeFnNumber, uniqueVariableName, type Variable } from "../types/Variable"; +import { BARCODE_1D_TYPES } from "../registry"; +import { objectRotation } from "../registry/rotation"; export interface ZplImportResult { labelConfig: Partial; @@ -25,7 +27,13 @@ export interface ZplImportResult { mixedPageGeometry: boolean; } -export function importZplText(zpl: string, dpmm: number): ZplImportResult { +export interface ZplImportOptions { + /** Rotated visual footprint (dots), or null when unmeasurable. Enables the + * ^FT+R barcode x-normalisation; headless callers omit it (x uncorrected). */ + measureFootprint?: (obj: LabelObject) => { w: number; h: number } | null; +} + +export function importZplText(zpl: string, dpmm: number, opts?: ZplImportOptions): ZplImportResult { // Single pass: stream-persistent state (^MU, ^CC/^CT/^CD, ^CI, ^CW/uploads, // ^CF/^BY, ^LH/^LT/^LR) carries across ^XA blocks, and the parser owns the // page boundaries (prefix-aware, unlike a literal ^XA split). @@ -89,6 +97,17 @@ export function importZplText(zpl: string, dpmm: number): ZplImportResult { if (nameRemap.size > 0) { rewireBindings(page.objects, nameRemap); } + // ^FT + rotation N only: ^FO-z and the rotation interaction are spec- + // ambiguous, pending printer check (the branch's own FT+I math would even + // demand shift 0 for R). Text unshifted (import measurement fragile). + if (opts?.measureFootprint) { + for (const o of page.objects) { + if (o.fieldJustify !== "R" || o.positionType !== "FT" || !BARCODE_1D_TYPES.has(o.type)) continue; + if (objectRotation((o as { props: object }).props) !== "N") continue; + const fp = opts.measureFootprint(o); + if (fp) o.x -= fp.w; + } + } // A bare page (no ^XA wrapper) usually carries just fonts/profile. But a // wrapper-less paste of real fields also lands here; import those as a page // so they aren't silently dropped (no overlay: the wrapper-less source has diff --git a/packages/core/src/lib/zplParser/context.ts b/packages/core/src/lib/zplParser/context.ts index 8174cc83..e4cd0e6b 100644 --- a/packages/core/src/lib/zplParser/context.ts +++ b/packages/core/src/lib/zplParser/context.ts @@ -102,6 +102,8 @@ export interface DefaultsState { cfWidth: number; cfFontId: string | undefined; fwRotation: TextProps["rotation"]; + /** ^FW z: default justification for fields whose ^FO/^FT omits z. */ + fwJustify: "L" | "R"; fbWidth: number; fbLines: number; fbSpacing: number; @@ -273,6 +275,9 @@ export function resetFormatScopedState(s: ParserState): void { s.comment.fnNumber = null; s.comment.fnComment = undefined; s.field = freshFieldState(); + // ^FW justify persists across ^XA (like fwRotation); a later page's first + // position-less field must inherit it, not the fresh-state 'L'. + s.field.justify = s.defaults.fwJustify; s.format.fhActive = false; resetFieldBlockDefaults(s.defaults); } @@ -318,6 +323,7 @@ export function createParserState(): ParserState { cfWidth: 0, cfFontId: undefined, fwRotation: "N", + fwJustify: "L", fbWidth: 0, fbLines: 1, fbSpacing: 0, diff --git a/packages/core/src/lib/zplParser/flushField.ts b/packages/core/src/lib/zplParser/flushField.ts index b534a665..532927eb 100644 --- a/packages/core/src/lib/zplParser/flushField.ts +++ b/packages/core/src/lib/zplParser/flushField.ts @@ -6,6 +6,7 @@ import { markerOf, type Variable, } from "../../types/Variable"; +import type { LabelObject } from "../../types/Group"; import { embedsToMarkers } from "../fnTemplate"; import { tokensToMarkers } from "../fcTemplate"; import { controlBytesToMarkers } from "../../types/controlKey"; @@ -112,6 +113,12 @@ export function createFlushField( const resetFB = () => resetFieldBlockDefaults(s.defaults); + // ^FO/^FT z rides onto the leaf like the graphics handlers do; L is implicit. + const pushLeaf = (o: LabelObject) => { + if (s.field.justify === "R") o.fieldJustify = "R"; + objects.push(o); + }; + const flushField = () => { if (!s.field.fieldType || s.field.pendingFD === null) { // Bare `^FN^FD^FS`: Variable declaration, not a field. @@ -234,7 +241,7 @@ export function createFlushField( if (s.field.fpCharGap > 0) { textProps.fpCharGap = s.field.fpCharGap; } - objects.push( + pushLeaf( makeObj("text", modelPos.x, modelPos.y, textProps, posType, comment), ); resetFB(); @@ -243,7 +250,7 @@ export function createFlushField( case "code128": { // GS1-128 (^BC…,D): store the canonical compact form, not the parens. const gs1 = s.field.bcGs1; - objects.push( + pushLeaf( makeObj( "code128", s.field.x, @@ -265,7 +272,7 @@ export function createFlushField( break; } case "code39": - objects.push( + pushLeaf( makeObj( "code39", s.field.x, @@ -285,7 +292,7 @@ export function createFlushField( ); break; case "ean13": - objects.push( + pushLeaf( makeObj( "ean13", s.field.x, @@ -308,7 +315,7 @@ export function createFlushField( // content format from toZPL: "{ec}A,{data}" e.g. "QA,https://example.com" const ec = (content[0] ?? "Q") as QrCodeProps["errorCorrection"]; const data = content.slice(3); - objects.push( + pushLeaf( makeObj( "qrcode", s.field.x, @@ -333,7 +340,7 @@ export function createFlushField( // valid only there; otherwise the field data is plain, kept verbatim. const esc = s.field.dmQuality === 200 ? s.field.dmEscape : undefined; const gs1Content = esc ? dataMatrixFdToGs1Content(content, esc) : null; - objects.push( + pushLeaf( makeObj( "datamatrix", s.field.x, @@ -355,7 +362,7 @@ export function createFlushField( break; } case "upce": - objects.push( + pushLeaf( makeObj( "upce", s.field.x, @@ -390,7 +397,7 @@ export function createFlushField( case "planet": case "postal": case "upcEanExtension": - objects.push( + pushLeaf( makeObj( s.field.fieldType, s.field.x, @@ -410,7 +417,7 @@ export function createFlushField( ); break; case "gs1databar": - objects.push( + pushLeaf( makeObj( "gs1databar", s.field.x, @@ -431,7 +438,7 @@ export function createFlushField( ); break; case "pdf417": - objects.push( + pushLeaf( makeObj( "pdf417", s.field.x, @@ -450,7 +457,7 @@ export function createFlushField( ); break; case "code49": - objects.push( + pushLeaf( makeObj( "code49", s.field.x, @@ -469,7 +476,7 @@ export function createFlushField( ); break; case "aztec": - objects.push( + pushLeaf( makeObj( "aztec", s.field.x, @@ -486,7 +493,7 @@ export function createFlushField( ); break; case "maxicode": - objects.push( + pushLeaf( makeObj( "maxicode", s.field.x, @@ -501,7 +508,7 @@ export function createFlushField( ); break; case "micropdf417": - objects.push( + pushLeaf( makeObj( "micropdf417", s.field.x, @@ -519,7 +526,7 @@ export function createFlushField( ); break; case "codablock": - objects.push( + pushLeaf( makeObj( "codablock", s.field.x, @@ -538,7 +545,7 @@ export function createFlushField( ); break; case "tlc39": - objects.push( + pushLeaf( makeObj( "tlc39", s.field.x, @@ -562,7 +569,7 @@ export function createFlushField( // import still produces a sensible visible object. const raw = content.trim().charAt(0).toUpperCase(); const code = (GS_SYMBOL_CODES.has(raw) ? raw : DEFAULT_GS_SYMBOL) as SymbolProps["symbol"]; - objects.push( + pushLeaf( makeObj( "symbol", s.field.x, diff --git a/packages/core/src/lib/zplParser/handlers/fields.ts b/packages/core/src/lib/zplParser/handlers/fields.ts index 24248fd9..7c66513e 100644 --- a/packages/core/src/lib/zplParser/handlers/fields.ts +++ b/packages/core/src/lib/zplParser/handlers/fields.ts @@ -10,7 +10,7 @@ import { resetFieldBlockDefaults, type ParserState, } from "../context"; -import { ciToEncoding, dotsFor, getDecoder, int, readRotation } from "../helpers"; +import { ciToEncoding, dotsFor, getDecoder, int, readJustify, readRotation } from "../helpers"; import type { Handler, Wildcard } from "../types"; /** flushField + appendComment are shared with the orchestrator. */ @@ -78,16 +78,14 @@ export function createFieldHandlers( flushField(); s.field.x = dots(p[0]) + s.label.lhX; s.field.y = dots(p[1]) + s.label.lhY + s.label.ltY; - // 3rd param z = justification (0 left, 1 right, 2 auto). Only graphics - // honour it (right corner); other fields keep the left default for now. - s.field.justify = p[2]?.trim() === "1" ? "R" : "L"; + s.field.justify = readJustify(p[2], s.defaults.fwJustify); s.field.positionIsFT = false; }, FT(p) { flushField(); s.field.x = dots(p[0]) + s.label.lhX; s.field.y = dots(p[1]) + s.label.lhY + s.label.ltY; - s.field.justify = p[2]?.trim() === "1" ? "R" : "L"; + s.field.justify = readJustify(p[2], s.defaults.fwJustify); s.field.positionIsFT = true; }, @@ -122,13 +120,17 @@ export function createFieldHandlers( } }, - // ── Field-wide default rotation ─────────────────────────────────────── - // ^FW{rotation} e.g. ^FWR - FW(_, rest) { + // ── Field-wide defaults ─────────────────────────────────────────────── + // ^FW{rotation},{z} e.g. ^FWR or ^FWN,1 + FW(p, rest) { const fw = (rest[0] ?? "N").toUpperCase(); if (fw === "N" || fw === "R" || fw === "I" || fw === "B") { s.defaults.fwRotation = fw; } + // z sets the default justification for ^FO/^FT that omit their own z; + // 2 (auto) resets to L, same collapse readJustify applies per field. + const z = p[1]?.trim(); + if (z === "0" || z === "1" || z === "2") s.defaults.fwJustify = z === "1" ? "R" : "L"; }, // ── Field block ─────────────────────────────────────────────────────── @@ -185,6 +187,9 @@ export function createFieldHandlers( s.field.fpDirection = "H"; s.field.fpCharGap = 0; s.field.gsMagnification = undefined; + // Spec: an omitted z falls back to ^FW, never to the PRIOR field's z, so + // a position-command-less follow field must not inherit the leaked R. + s.field.justify = s.defaults.fwJustify; resetFieldBlockDefaults(s.defaults); s.comment.fnNumber = null; s.comment.fnComment = undefined; diff --git a/packages/core/src/lib/zplParser/helpers.ts b/packages/core/src/lib/zplParser/helpers.ts index 432b53cf..96d0e81d 100644 --- a/packages/core/src/lib/zplParser/helpers.ts +++ b/packages/core/src/lib/zplParser/helpers.ts @@ -10,6 +10,15 @@ export function readRotation( return raw && isZplRotation(raw) ? raw : fallback; } +/** ^FO/^FT z-justification: explicit 0/1 wins, an omitted z falls back to the + * ^FW default (spec p.201/205); 2 (auto) has no fixed geometry, treated as L. */ +export function readJustify(raw: string | undefined, fallback: "L" | "R"): "L" | "R" { + const z = raw?.trim(); + if (z === "1") return "R"; + if (z === "0" || z === "2") return "L"; + return fallback; +} + /** Validate ^GB/^GC/^GD/^GE colour char; default "B" per spec. */ export function readColor(raw: string | undefined): "B" | "W" { return raw === "W" ? "W" : "B"; diff --git a/packages/core/src/registry/aztec.ts b/packages/core/src/registry/aztec.ts index 18dd3bf4..dcdf796f 100644 --- a/packages/core/src/registry/aztec.ts +++ b/packages/core/src/registry/aztec.ts @@ -1,5 +1,5 @@ import type { ObjectTypeCore } from "../types/ObjectType"; -import { fieldPos, fdFieldFor } from "./zplHelpers"; +import { fieldPosZ, fdFieldFor } from "./zplHelpers"; import { moduleTooSmallPreflight } from "../lib/barcodeScannability"; import { type ZplRotation } from "./rotation"; @@ -47,7 +47,7 @@ export const aztec: ObjectTypeCore = { // menuSymbol, numberOfSymbols, structuredID. ecLevel=0 is Zebra's // documented default for errorControl, so emitting it as-is is valid. return [ - fieldPos(obj), + fieldPosZ(obj), `^B0${p.rotation},${p.magnification},N,${p.ecLevel}`, fdFieldFor(p.content, ctx, undefined, undefined, CONTROL_CHARS), ].join(""); diff --git a/packages/core/src/registry/codablock.ts b/packages/core/src/registry/codablock.ts index 614ebb04..33351f2c 100644 --- a/packages/core/src/registry/codablock.ts +++ b/packages/core/src/registry/codablock.ts @@ -1,5 +1,5 @@ import type { ObjectTypeCore } from "../types/ObjectType"; -import { fieldPos, fdFieldFor } from "./zplHelpers"; +import { fieldPosZ, fdFieldFor } from "./zplHelpers"; import { clamp, commitStacked2DTransform } from "./transformHelpers"; import { moduleTooSmallPreflight } from "../lib/barcodeScannability"; import { type ZplRotation } from "./rotation"; @@ -67,7 +67,7 @@ export const codablock: ObjectTypeCore = { // stack, verified on a ZD230). Mode F (Code 128) matches the codablockf preview. return [ `^BY${p.moduleWidth}`, - fieldPos(obj), + fieldPosZ(obj), `^BB${p.rotation},${p.rowHeight},${p.securityLevel},${clampCodablockColumns(p.columns)},,F`, fdFieldFor(p.content, ctx), ] diff --git a/packages/core/src/registry/datamatrix.ts b/packages/core/src/registry/datamatrix.ts index f0a5da36..65e703bf 100644 --- a/packages/core/src/registry/datamatrix.ts +++ b/packages/core/src/registry/datamatrix.ts @@ -1,5 +1,5 @@ import type { ObjectTypeCore } from '../types/ObjectType'; -import { fieldPos, fdFieldFor } from './zplHelpers'; +import { fieldPosZ, fdFieldFor } from './zplHelpers'; import { DATAMATRIX_FD_ESCAPE, gs1ContentToDataMatrixFd } from '../lib/dataMatrixFd'; import { moduleTooSmallPreflight } from '../lib/barcodeScannability'; import { type ZplRotation } from './rotation'; @@ -89,7 +89,7 @@ export const datamatrix: ObjectTypeCore = { ]; while (params.at(-1) === '') params.pop(); return [ - fieldPos(obj), + fieldPosZ(obj), `^BX${params.join(',')}`, fdFieldFor(p.content, ctx, p.gs1 ? gs1ContentToDataMatrixFd : undefined, undefined, CONTROL_CHARS && !p.gs1), ].join(''); diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 5285c6b8..58ff9100 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -109,6 +109,13 @@ export const STACKED_2D_TYPES: ReadonlySet = new Set( (Object.keys(ObjectRegistry) as LeafType[]).filter((t) => ObjectRegistry[t].barcodeClass === 'stacked2d'), ); +/** True when this type's emitter serializes fieldJustify (graphicAnchor for + * graphics, the z echo in fieldPosZ/textFieldPos for the rest). Only 1D holds + * it un-emitted until the S3 z-emit; dirty tracking reads the same fact. */ +export function emitsFieldJustify(type: string): boolean { + return !BARCODE_1D_TYPES.has(type); +} + /** Dynamic lookup for `LabelObject['type']`; undefined for non-leaf (e.g. `'group'`). */ export function getEntry(type: string): (typeof ObjectRegistry)[LeafType] | undefined { return (ObjectRegistry as Record)[type]; diff --git a/packages/core/src/registry/maxicode.ts b/packages/core/src/registry/maxicode.ts index 98b830ca..0533aa23 100644 --- a/packages/core/src/registry/maxicode.ts +++ b/packages/core/src/registry/maxicode.ts @@ -1,5 +1,5 @@ import type { ObjectTypeCore } from "../types/ObjectType"; -import { fieldPos, fdFieldFor } from "./zplHelpers"; +import { fieldPosZ, fdFieldFor } from "./zplHelpers"; // ISO/IEC 16023 fixed physical size (28.14 x 26.91 mm); no magnification. // 2=US SCM, 3=intl SCM, 4=standard, 5=full EEC, 6=reader programming. @@ -34,7 +34,7 @@ export const maxicode: ObjectTypeCore = { const p = obj.props; // ^BV; structured-append slots fixed at (1,1) since unexposed. return [ - fieldPos(obj), + fieldPosZ(obj), `^BVN,${p.mode},1,1`, fdFieldFor(p.content, ctx), ].join(""); diff --git a/packages/core/src/registry/micropdf417.ts b/packages/core/src/registry/micropdf417.ts index cbca8b4d..0283b0d9 100644 --- a/packages/core/src/registry/micropdf417.ts +++ b/packages/core/src/registry/micropdf417.ts @@ -1,5 +1,5 @@ import type { ObjectTypeCore } from "../types/ObjectType"; -import { fieldPos, fdFieldFor } from "./zplHelpers"; +import { fieldPosZ, fdFieldFor } from "./zplHelpers"; import { commitStacked2DTransform } from "./transformHelpers"; import { moduleTooSmallPreflight } from "../lib/barcodeScannability"; import { type ZplRotation } from "./rotation"; @@ -42,7 +42,7 @@ export const micropdf417: ObjectTypeCore = { // ^BF{orientation},{rowHeight},{mode} return [ `^BY${p.moduleWidth}`, - fieldPos(obj), + fieldPosZ(obj), `^BF${p.rotation},${p.rowHeight},${p.mode}`, fdFieldFor(p.content, ctx, undefined, undefined, CONTROL_CHARS), ] diff --git a/packages/core/src/registry/pdf417.ts b/packages/core/src/registry/pdf417.ts index 3210c557..42596725 100644 --- a/packages/core/src/registry/pdf417.ts +++ b/packages/core/src/registry/pdf417.ts @@ -1,5 +1,5 @@ import type { ObjectTypeCore } from "../types/ObjectType"; -import { fieldPos, fdFieldFor } from "./zplHelpers"; +import { fieldPosZ, fdFieldFor } from "./zplHelpers"; import { commitStacked2DTransform } from "./transformHelpers"; import { moduleTooSmallPreflight } from "../lib/barcodeScannability"; import { type ZplRotation } from "./rotation"; @@ -46,7 +46,7 @@ export const pdf417: ObjectTypeCore = { const p = obj.props; return [ `^BY${p.moduleWidth}`, - fieldPos(obj), + fieldPosZ(obj), `^B7${p.rotation},${p.rowHeight},${p.securityLevel},${p.columns},,,`, fdFieldFor(p.content, ctx, undefined, undefined, CONTROL_CHARS), ] diff --git a/packages/core/src/registry/qrcode.ts b/packages/core/src/registry/qrcode.ts index da2398a4..d0b5b51d 100644 --- a/packages/core/src/registry/qrcode.ts +++ b/packages/core/src/registry/qrcode.ts @@ -1,6 +1,6 @@ import type { ObjectTypeCore } from '../types/ObjectType'; import type { LabelObjectBase } from '../types/LabelObject'; -import { fieldPos, fdFieldFor } from './zplHelpers'; +import { fieldPosZ, fdFieldFor } from './zplHelpers'; import { moduleTooSmallPreflight } from '../lib/barcodeScannability'; import { hasTemplateMarkers } from '../lib/fnTemplate'; import { formatQrSidecarComment, qrRotatedGfaCached } from '../lib/qrGraphic'; @@ -81,14 +81,14 @@ export const qrcode: ObjectTypeCore = { ); const g = resolved ? qrRotatedGfaCached(p, { ...p, content: resolved }) : null; if (g) { - return [formatQrSidecarComment(p), fieldPos(obj), g.gfa, '^FS'].join(''); + return [formatQrSidecarComment(p), fieldPosZ(obj), g.gfa, '^FS'].join(''); } } // Prefix passed as the fdFieldFor transform (not baked into content) so it // composes with the binding: single-bind default / template / CSV override // all get the prefix instead of the payload being emitted raw. return [ - fieldPos(obj), + fieldPosZ(obj), `^BQN,${p.model},${p.magnification}`, fdFieldFor(p.content, ctx, qrFdTransform(obj), undefined, CONTROL_CHARS), ].join(''); diff --git a/packages/core/src/registry/symbol.ts b/packages/core/src/registry/symbol.ts index b7b5cc31..2abd507a 100644 --- a/packages/core/src/registry/symbol.ts +++ b/packages/core/src/registry/symbol.ts @@ -1,5 +1,5 @@ import type { ObjectTypeCore } from '../types/ObjectType'; -import { fieldPos } from './zplHelpers'; +import { fieldPosZ } from './zplHelpers'; import { commitRotatedWidthHeightTransform } from './transformHelpers'; import type { ZplRotation } from './rotation'; @@ -60,7 +60,7 @@ export const symbol: ObjectTypeCore = { toZPL: (obj) => { const p = obj.props; - return `${fieldPos(obj)}^GS${p.rotation},${p.height},${p.width}^FD${p.symbol}^FS`; + return `${fieldPosZ(obj)}^GS${p.rotation},${p.height},${p.width}^FD${p.symbol}^FS`; }, commitTransform: commitRotatedWidthHeightTransform, diff --git a/packages/core/src/registry/tlc39.ts b/packages/core/src/registry/tlc39.ts index af90aa79..f816af0f 100644 --- a/packages/core/src/registry/tlc39.ts +++ b/packages/core/src/registry/tlc39.ts @@ -1,5 +1,5 @@ import type { ObjectTypeCore } from "../types/ObjectType"; -import { fieldPos, fdFieldFor } from "./zplHelpers"; +import { fieldPosZ, fdFieldFor } from "./zplHelpers"; import { commitBarcodeWidthHeightTransform } from "./transformHelpers"; import { limitedSupportPreflight } from "../lib/barcodeScannability"; import { type ZplRotation } from "./rotation"; @@ -47,7 +47,7 @@ export const tlc39: ObjectTypeCore = { const p = obj.props; return [ `^BY${p.moduleWidth}`, - fieldPos(obj), + fieldPosZ(obj), // r1 (wide:narrow ratio) is not exposed as a prop; emit the // canonical "2" so the field round-trips. `^BT${p.rotation},${p.moduleWidth},2,${p.height},${p.microPdfRowHeight},${p.microPdfRows}`, diff --git a/packages/core/src/registry/zplHelpers.ts b/packages/core/src/registry/zplHelpers.ts index 2483dbfe..321dc148 100644 --- a/packages/core/src/registry/zplHelpers.ts +++ b/packages/core/src/registry/zplHelpers.ts @@ -9,12 +9,21 @@ import { getTextRenderMetrics } from "../lib/labelGeometry/textRenderMetrics"; import { blockInterLineExtentDots } from "../lib/zebraTextLayout"; import type { LabelObject } from "../types/Group"; -/** Emit `^FT` or `^FO` depending on how the object was originally positioned. */ +/** Emit `^FT` or `^FO` depending on how the object was originally positioned. + * 1D emitters use this z-less form: their x is import-normalised top-left, + * and z-emit for 1D is the S3 stage. */ export function fieldPos(obj: LabelObjectBase): string { const cmd = obj.positionType === "FT" ? "FT" : "FO"; return `^${cmd}${obj.x},${obj.y}`; } +/** {@link fieldPos} with the z echo: import never normalises these fields, so + * x is still the ZPL anchor and echoing z=1 reproduces the source placement. */ +export function fieldPosZ(obj: LabelObjectBase): string { + const z = obj.fieldJustify === "R" ? ",1" : ""; + return `${fieldPos(obj)}${z}`; +} + /** Graphic leaf types whose ^FO/^FT origin comes from {@link graphicAnchor} * (the footprint top-left under ^FO, a bottom corner under ^FT). For box / * ellipse / image the model x/y already is that top-left; a line's x/y is an @@ -39,7 +48,7 @@ export function graphicAnchorCoords( w: number, h: number, positionType: LabelObjectBase["positionType"], - justify: "L" | "R" | undefined, + justify: LabelObjectBase["fieldJustify"], ): { x: number; y: number } { if (positionType !== "FT") return { x, y }; return justify === "R" ? { x: x + w, y: y + h } : { x, y: y + h }; @@ -53,7 +62,7 @@ export function graphicAnchor( w: number, h: number, positionType: LabelObjectBase["positionType"], - justify: "L" | "R" | undefined, + justify: LabelObjectBase["fieldJustify"], ): string { const a = graphicAnchorCoords(x, y, w, h, positionType, justify); if (positionType !== "FT") return `^FO${a.x},${a.y}`; @@ -147,7 +156,9 @@ export function textZplAnchorCoords(obj: TextLikeObjForFieldPos): { /** Converts EM top-left model coord to ZPL anchor (cap-top/baseline). */ export function textFieldPos(obj: TextLikeObjForFieldPos): string { const a = textZplAnchorCoords(obj); - return `^${a.cmd}${a.x},${a.y}`; + // Same echo contract as fieldPosZ (text is never import-normalised). + const z = obj.fieldJustify === "R" ? ",1" : ""; + return `^${a.cmd}${a.x},${a.y}${z}`; } /** ^FX has no ^FH escape; strip ^/~ to prevent surrounding-command termination. */ diff --git a/packages/core/src/types/LabelObject.ts b/packages/core/src/types/LabelObject.ts index c1096fce..32b75109 100644 --- a/packages/core/src/types/LabelObject.ts +++ b/packages/core/src/types/LabelObject.ts @@ -9,9 +9,10 @@ export const labelObjectBaseSchema = z.object({ rotation: z.number(), /** 'FT' = field typeset (baseline), 'FO' = field origin (top-left). Defaults to 'FO'. */ positionType: z.enum(['FO', 'FT']).optional(), - /** ^FO/^FT z-justification. 'R' (z=1) anchors a graphic at its right corner; - * absent = left (default). Only graphics (^GB) honour it today. */ - fieldJustify: z.enum(['L', 'R']).optional(), + /** ^FO/^FT z-justification (spec p.201/205): 'R' = right edge (z=1), absent + * = left; 'C' is an editor-only centre for 1D barcodes (no ZPL form). + * Graphics emit L/R via graphicAnchor; barcodes carry it un-emitted. */ + fieldJustify: z.enum(['L', 'C', 'R']).optional(), /** Emitted as ^FX before this field in ZPL output. Carries no print output. */ comment: z.string().optional(), /** When true, blocks position/size/prop edits, drag, resize and deletion. diff --git a/packages/mcp-server/src/server.ts b/packages/mcp-server/src/server.ts index de709c8a..34e671f0 100644 --- a/packages/mcp-server/src/server.ts +++ b/packages/mcp-server/src/server.ts @@ -109,7 +109,9 @@ export function buildServer(options: BuildServerOptions = {}): McpServer { description: "Parse a raw ZPL stream into an editable design file (one page per ^XA block; " + "feed it to export_zpl or open_in_app) plus parser findings, per-object bounds " + - "(dots), and bbox overlaps. Size falls back to the caller's hints then 100x50mm.", + "(dots), and bbox overlaps. Size falls back to the caller's hints then 100x50mm. " + + "Right-justified (^FO/^FT z=1) fields keep their ZPL x here (headless, no " + + "measurement); the app import normalises ^FT+R barcodes to top-left.", inputSchema: zplInputShape, }, async ({ zpl, dpmm, widthMm, heightMm }) => json(importZpl(zpl, dpmm, widthMm, heightMm)), diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts index a63c9d57..4c88716a 100644 --- a/packages/mcp-server/src/tools.ts +++ b/packages/mcp-server/src/tools.ts @@ -27,7 +27,7 @@ export const objectInputSchema = z.object({ // Anchor semantics: FO = top-left origin, FT = typeset baseline; fieldJustify // right-aligns. Omitted keeps the model default (FO / left). positionType: z.enum(["FO", "FT"]).optional(), - fieldJustify: z.enum(["L", "R"]).optional(), + fieldJustify: z.enum(["L", "C", "R"]).optional(), props: z.record(z.string(), z.unknown()).optional(), }); export type ObjectInput = z.infer; @@ -129,6 +129,12 @@ function parseEnvelope(designFile: unknown): { ok: true; value: DesignFile } | T * defaults; the schema is tolerant, so createDraft guards it up front. */ function toLabelObject(input: ObjectInput, id: string): LabelObject { const defaults = getEntry(input.type)?.defaultProps ?? {}; + // 'C' has meaning only on 1D barcodes (editor centre); anywhere else it + // would persist as dead metadata, so canonicalize it away here. + const justify = + input.fieldJustify === "C" && getEntry(input.type)?.barcodeClass !== "1d" + ? undefined + : input.fieldJustify; return { id, type: input.type, @@ -136,7 +142,7 @@ function toLabelObject(input: ObjectInput, id: string): LabelObject { y: input.y, rotation: 0, ...(input.positionType !== undefined ? { positionType: input.positionType } : {}), - ...(input.fieldJustify !== undefined ? { fieldJustify: input.fieldJustify } : {}), + ...(justify !== undefined ? { fieldJustify: justify } : {}), props: { ...defaults, ...(input.props ?? {}) }, // Schema is intentionally loose; createDraft rejects unknown types up front. } as LabelObject; @@ -595,7 +601,7 @@ const SCHEMA: SchemaResult = { x: "number, dots from left", y: "number, dots from top", positionType: "optional FO (top-left origin, default) | FT (typeset baseline)", - fieldJustify: "optional L (default) | R (right-align)", + fieldJustify: "optional L (default) | R | C (centre; 1D barcodes only, dropped elsewhere). On non-1D fields R means x IS the ZPL right-edge anchor and re-emits as z=1; on 1D barcodes x stays the top-left and the editor re-pins on value changes", props: "object, merged over defaultProps", }, types: (Object.keys(ObjectRegistry) as (keyof typeof ObjectRegistry)[]).map((type) => { diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index c6b26aa8..f0271cbb 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -30,6 +30,10 @@ import { barcodeEncodeFindings } from "./barcodePreflight"; 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 { objectResolvesCtrl } from "@zplab/core/registry"; +import { measureBarcodeFootprintDots } from "./bwipHelpers"; import { selectTidyTargets } from "../../lib/tidyClassify"; import { safeAreaRectDots } from "../../lib/safeArea"; import { measuredBoundsMap, subscribeMeasuredBounds, getMeasuredSnapshot } from "./measuredBoundsCache"; @@ -451,6 +455,18 @@ 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); + useEffect(() => { + const { variables: vars, active, clock } = previewBinding; + const probe = (o: LabelObject) => { + if (isGroup(o)) return null; + const resolved = applyBindingToObject(o, vars, active, dataRenderMode, clock, objectResolvesCtrl(o)); + return measureBarcodeFootprintDots(resolved as LeafObject, scale, label.dpmm); + }; + registerBarcodeWidthProber(probe); + return () => unregisterBarcodeWidthProber(probe); + }, [scale, label.dpmm, previewBinding, dataRenderMode]); const labelWidthPx = effectiveWidthMm * scale; const physicalWidthPx = label.widthMm * scale; const labelHeightPx = label.heightMm * scale; diff --git a/src/components/Canvas/bwipHelpers.ts b/src/components/Canvas/bwipHelpers.ts index d2e3fa70..d8e05260 100644 --- a/src/components/Canvas/bwipHelpers.ts +++ b/src/components/Canvas/bwipHelpers.ts @@ -10,7 +10,8 @@ import { upceData6FromFd } from "@zplab/core/registry/hriFormatters"; import type { LabelObject } from "@zplab/core/types/Group"; import type { Gs1DatabarProps } from "@zplab/core/registry/gs1databar"; import { isAxisSwapped, objectRotation } from "@zplab/core/registry/rotation"; -import { dotsToPx } from "@zplab/core/lib/coordinates"; +import { dotsToPx, pxToDots } from "@zplab/core/lib/coordinates"; +import { placeholderContentFor, samplePropsFor } from "../../registry/placeholderContent"; import { GS1_DATABAR_DEFAULT_SEGMENTS, GS1_DATABAR_EXPANDED_SYMBOLOGIES, @@ -641,6 +642,29 @@ export function stateFrameProps(ub: BarcodeDisplaySize["upright"], isEanUpc: boo : { width: Math.max(ub.w, 1), height: Math.max(ub.h, 1) }; } +/** Synchronous footprint probe for the store's anchor re-pin. Blank/uncodable + * content falls back to the sample bars, mirroring BarcodeObject, so prop + * edits on a sample-rendered barcode still re-pin against the drawn width. */ +export function measureBarcodeFootprintDots( + obj: LeafObject, + scale: number, + dpmm: number, +): { w: number; h: number } | null { + const blank = (getObjectStringContent(obj) ?? "").trim() === ""; + let target = obj; + let canvas = blank ? null : renderBarcodeCanvas(obj, scale, dpmm).canvas; + if (!canvas) { + const sample = placeholderContentFor(obj.type, obj.props); + if (!sample) return null; + target = { ...obj, props: { ...samplePropsFor(obj.type, obj.props), content: sample } } as LeafObject; + canvas = renderBarcodeCanvas(target, scale, dpmm).canvas; + if (!canvas) return null; + } + const dim = getDisplaySize(target, canvas, scale, dpmm); + if (dim.w <= 0 || dim.h <= 0) return null; + return { w: pxToDots(dim.w, scale, dpmm), h: pxToDots(dim.h, scale, dpmm) }; +} + export function getDisplaySize( obj: LeafObject, canvas: HTMLCanvasElement, diff --git a/src/components/Output/ZplImportModal.tsx b/src/components/Output/ZplImportModal.tsx index 788ae4a8..8135c8bb 100644 --- a/src/components/Output/ZplImportModal.tsx +++ b/src/components/Output/ZplImportModal.tsx @@ -2,6 +2,8 @@ import { useRef, useState } from 'react'; import { XMarkIcon, ClipboardDocumentIcon, CheckIcon, FolderOpenIcon } from '@heroicons/react/16/solid'; import { importZplText, routeSetupCommands, mergeSetupFonts, type ZplImportResult, type SetupCommandChoice } from '@zplab/core/lib/zplImportService'; import { readFileAsText } from '../../lib/readFile'; +import { measureBarcodeFootprintDots } from '../Canvas/bwipHelpers'; +import type { LeafObject } from '@zplab/core/registry'; import { useLabelStore } from '../../store/labelStore'; import type { Page } from '@zplab/core/types/Group'; import type { LabelConfig } from '@zplab/core/types/LabelConfig'; @@ -103,7 +105,10 @@ export function ZplImportModal({ onClose }: Props) { // Shared by both entry points (paste, file picker); they differ only in how // they obtain the text. const processImport = (text: string) => { - const imported = importZplText(text, label.dpmm); + // scale = dpmm renders at 1px per dot: exact, zoom-independent footprints. + const imported = importZplText(text, label.dpmm, { + measureFootprint: (o) => measureBarcodeFootprintDots(o as LeafObject, label.dpmm, label.dpmm), + }); const { labelConfig, printerProfile, pages } = imported; const totalObjects = pages.reduce((s, p) => s + p.objects.length, 0); if ( diff --git a/src/components/Properties/PropertiesPanel.tsx b/src/components/Properties/PropertiesPanel.tsx index ccd4fcba..b9f297c1 100644 --- a/src/components/Properties/PropertiesPanel.tsx +++ b/src/components/Properties/PropertiesPanel.tsx @@ -4,7 +4,7 @@ import { useLabelStore, useCurrentObjects, selectPreviewLocksEditor } from "../. import type { LabelCanvasHandle } from "../Canvas/LabelCanvas"; import type { AlignOp, DistributeAxis, AlignRef } from "../../lib/align"; import type { AlignSelectionRef } from "../../store/slices/uiSlice"; -import { getEntry } from "@zplab/core/registry"; +import { getEntry, BARCODE_1D_TYPES } from "@zplab/core/registry"; import { getPanel } from "../../registry/panels"; import { canGroupSelection, findObjectById, hasLockedAncestor, isGroup } from "@zplab/core/types/Group"; import { symbologyTargets } from "../../lib/symbologySwitch"; @@ -333,6 +333,30 @@ export function PropertiesPanel({ canvasRef }: PropertiesPanelProps) { /> )} + {/* Origin semantics (which edge x pins), hence the position box. */} + {!groupRow && BARCODE_1D_TYPES.has(obj.type) && ( +
+ + {t.properties.valueAnchor} + + { + // L maps back to the implicit default; skip no-op clicks so + // they don't mint phantom undo steps / document diffs. + const fieldJustify = v === "L" ? undefined : v; + if (fieldJustify === obj.fieldJustify) return; + updateObject(obj.id, { fieldJustify }); + }} + options={[ + { value: "L", label: t.registry.text.justifyL }, + { value: "C", label: t.registry.text.justifyC }, + { value: "R", label: t.registry.text.justifyR }, + ]} + aria-label={t.properties.valueAnchor} + /> +
+ )} [2]): LabelObject => { + const r = importZplText(zpl, 8, opts); + const o = r.pages[0]?.objects[0]; + expect(o).toBeDefined(); + return o!; +}; + +describe("^FO/^FT z-justification import", () => { + it("stamps fieldJustify=R from an explicit z=1 on a barcode", () => { + const o = leaf("^XA^FO100,50,1^BCN,100,Y,N,N^FD123^FS^XZ"); + expect(o.fieldJustify).toBe("R"); + // No measurer: x stays at the ZPL (right-edge) value. + expect(o.x).toBe(100); + }); + + it("leaves fieldJustify absent without z (left is implicit)", () => { + const o = leaf("^XA^FO100,50^BCN,100,Y,N,N^FD123^FS^XZ"); + expect(o.fieldJustify).toBeUndefined(); + }); + + it("normalises a right-justified ^FT barcode x via the injected measurer", () => { + const o = leaf("^XA^FT100,150,1^BCN,100,Y,N,N^FD123^FS^XZ", { + measureFootprint: () => ({ w: 40, h: 100 }), + }); + expect(o.x).toBe(60); + expect(o.fieldJustify).toBe("R"); + }); + + it("leaves a ROTATED ^FT z=1 barcode unshifted (rotation interaction unverified)", () => { + const o = leaf("^XA^FT100,150,1^BCR,100,Y,N,N^FD123^FS^XZ", { + measureFootprint: () => ({ w: 40, h: 100 }), + }); + expect(o.x).toBe(100); + expect(o.fieldJustify).toBe("R"); + }); + + it("leaves ^FO z=1 x untouched (z effect on ^FO unverified on firmware)", () => { + const o = leaf("^XA^FO100,50,1^BCN,100,Y,N,N^FD123^FS^XZ", { + measureFootprint: () => ({ w: 40, h: 100 }), + }); + expect(o.x).toBe(100); + expect(o.fieldJustify).toBe("R"); + }); + + it("stamps but never shifts text (import-time text measurement unreliable)", () => { + const o = leaf("^XA^FO100,50,1^A0N,30,30^FDHi^FS^XZ", { + measureFootprint: () => ({ w: 40, h: 30 }), + }); + expect(o.type).toBe("text"); + expect(o.fieldJustify).toBe("R"); + expect(o.x).toBe(100); + }); + + it("^FW z sets the default for fields whose ^FO omits z", () => { + const zpl = "^XA^FWN,1^FO100,50^BCN,100,Y,N,N^FD123^FS^FO300,50,0^BCN,100,Y,N,N^FD456^FS^XZ"; + const r = importZplText(zpl, 8); + const [a, b] = r.pages[0]!.objects; + // Omitted z inherits the ^FW default; explicit z=0 overrides it. + expect(a!.fieldJustify).toBe("R"); + expect(b!.fieldJustify).toBeUndefined(); + }); + + it("^FT carries z the same way", () => { + const o = leaf("^XA^FT100,150,1^BCN,100,Y,N,N^FD123^FS^XZ"); + expect(o.positionType).toBe("FT"); + expect(o.fieldJustify).toBe("R"); + }); + + it("a position-less follow field falls back to the ^FW default, not the prior z", () => { + const r = importZplText("^XA^FO100,50,1^A0N,30,30^FDa^FS^A0N,30,30^FDb^FS^XZ", 8); + expect(r.pages[0]!.objects[1]!.fieldJustify).toBeUndefined(); + }); + + it("^FW z=2 (auto) resets an earlier R default to left", () => { + const zpl = "^XA^FWN,1^FWN,2^FO100,50^BCN,100,Y,N,N^FD123^FS^XZ"; + expect(importZplText(zpl, 8).pages[0]!.objects[0]!.fieldJustify).toBeUndefined(); + }); + + it("a later page's position-less first field inherits the persisted ^FW default", () => { + const zpl = "^XA^FWN,1^A0N,30,30^FDa^FS^XZ^XA^A0N,30,30^FDb^FS^XZ"; + const r = importZplText(zpl, 8); + expect(r.pages[1]!.objects[0]!.fieldJustify).toBe("R"); + }); + + it("echoes z=1 on re-emit for un-normalised text and 2D fields", () => { + // Un-normalised fields keep their anchor x; the echo restores the + // original placement byte-true. + const text = importZplText("^XA^FO100,50,1^A0N,30,30^FDHi^FS^XZ", 8); + const textZpl = generateZPL({ widthMm: 100, heightMm: 50, dpmm: 8 }, text.pages[0]!.objects); + expect(textZpl).toMatch(/\^FO\d+,\d+,1/); + const qr = importZplText("^XA^FT100,150,1^BQN,2,4^FDQA,HELLO^FS^XZ", 8); + const qrZpl = generateZPL({ widthMm: 100, heightMm: 50, dpmm: 8 }, qr.pages[0]!.objects); + expect(qrZpl).toContain("^FT100,150,1"); + }); + + it("S3 tripwire: a right-justified 1D barcode emits ^FO without a z-param", () => { + // When barcode z-emit lands this goes red; the dirty-tracking + // emitsFieldJustify predicate must flip together with the emitter. + const o = { + id: "b1", type: "code128", x: 60, y: 50, rotation: 0, fieldJustify: "R", + props: { content: "123", height: 100, moduleWidth: 2, rotation: "N", + printInterpretation: false, checkDigit: false }, + } as unknown as LabelObject; + const zpl = generateZPL({ widthMm: 100, heightMm: 50, dpmm: 8 }, [o]); + expect(zpl).toContain("^FO60,50^"); + expect(zpl).not.toContain("^FO60,50,1"); + }); +}); diff --git a/src/lib/symbologySwitch.test.ts b/src/lib/symbologySwitch.test.ts index bc270cd3..45064865 100644 --- a/src/lib/symbologySwitch.test.ts +++ b/src/lib/symbologySwitch.test.ts @@ -164,6 +164,13 @@ describe('convertSymbologyMapper', () => { expect(c39.moduleWidth).toBe(2); }); + it('carries fieldJustify within the 1D class, drops it across the boundary', () => { + const src = { ...barcode('code128', { content: 'A' }), fieldJustify: 'R' } as LabelObject; + expect(convertSymbologyMapper('code39' as never)(src).fieldJustify).toBe('R'); + // Crossing the class boundary would break the echo invariant (mapper). + expect(convertSymbologyMapper('qrcode' as never)(src).fieldJustify).toBeUndefined(); + }); + it('is a no-op for same type and non-barcode objects', () => { expect(convertSymbologyMapper('code128' as never)(source)).toBe(source); const text = barcode('text', { content: 'hi' }); diff --git a/src/lib/symbologySwitch.ts b/src/lib/symbologySwitch.ts index 3e1d6e08..04fd56a2 100644 --- a/src/lib/symbologySwitch.ts +++ b/src/lib/symbologySwitch.ts @@ -102,6 +102,14 @@ export function convertSymbologyMapper(targetType: LeafType): (obj: LabelObject) // The serial-off restore snapshot travels with the serial flag. if (typeof p.preSerialContent === "string") props.preSerialContent = p.preSerialContent; } - return { ...obj, type: targetType, props } as unknown as LabelObject; + // x-space differs per class (see fieldPos/fieldPosZ), so the flag must + // not survive a 1D <-> non-1D crossing. + const crossed = (source.barcodeClass === '1d') !== (target.barcodeClass === '1d'); + return { + ...obj, + type: targetType, + props, + ...(crossed ? { fieldJustify: undefined } : {}), + } as unknown as LabelObject; }; } diff --git a/src/locales/ar.ts b/src/locales/ar.ts index 096680a8..971aa486 100644 --- a/src/locales/ar.ts +++ b/src/locales/ar.ts @@ -346,6 +346,8 @@ const ar = { optionsSection: 'خيارات', contentSection: 'المحتوى', settingsSection: 'الإعدادات', + valueAnchor: 'مرساة', + valueAnchorHint: 'الحافة التي تبقى ثابتة عند تغيّر العرض بسبب تغيير القيمة', typographySection: 'الطباعة', name: 'الاسم', x: 'X', diff --git a/src/locales/bg.ts b/src/locales/bg.ts index 258923fb..33d3b6c8 100644 --- a/src/locales/bg.ts +++ b/src/locales/bg.ts @@ -346,6 +346,8 @@ const bg = { optionsSection: 'Опции', contentSection: 'Съдържание', settingsSection: 'Настройки', + valueAnchor: 'Котва', + valueAnchorHint: 'Ръб, който остава фиксиран, когато промяна на стойността променя ширината', typographySection: 'Типография', name: 'Име', x: 'X', diff --git a/src/locales/cs.ts b/src/locales/cs.ts index 82975583..32c4c07c 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -346,6 +346,8 @@ const cs = { optionsSection: 'Možnosti', contentSection: 'Obsah', settingsSection: 'Nastavení', + valueAnchor: 'Kotva', + valueAnchorHint: 'Hrana, která zůstává pevná, když změna hodnoty změní šířku', typographySection: 'Typografie', name: 'Název', x: 'X', diff --git a/src/locales/da.ts b/src/locales/da.ts index 874c3e28..287aecdc 100644 --- a/src/locales/da.ts +++ b/src/locales/da.ts @@ -346,6 +346,8 @@ const da = { optionsSection: 'Valg', contentSection: 'Indhold', settingsSection: 'Indstillinger', + valueAnchor: 'Anker', + valueAnchorHint: 'Kanten der forbliver fast, når en værdiændring ændrer bredden', typographySection: 'Typografi', name: 'Navn', x: 'X', diff --git a/src/locales/de.ts b/src/locales/de.ts index 2611fab6..ebb4fff9 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -346,6 +346,8 @@ const de = { optionsSection: 'Optionen', contentSection: 'Inhalt', settingsSection: 'Einstellungen', + valueAnchor: 'Anker', + valueAnchorHint: 'Kante, die beim Ändern der Breite durch eine Wertänderung fix bleibt', typographySection: 'Typografie', name: 'Name', x: 'X', diff --git a/src/locales/el.ts b/src/locales/el.ts index 7fdfe320..2d252cc8 100644 --- a/src/locales/el.ts +++ b/src/locales/el.ts @@ -346,6 +346,8 @@ const el = { optionsSection: 'Επιλογές', contentSection: 'Περιεχόμενο', settingsSection: 'Ρυθμίσεις', + valueAnchor: 'Άγκυρα', + valueAnchorHint: 'Η άκρη που παραμένει σταθερή όταν μια αλλαγή τιμής μεταβάλλει το πλάτος', typographySection: 'Τυπογραφία', name: 'Όνομα', x: 'X', diff --git a/src/locales/en.ts b/src/locales/en.ts index 4817416c..0e9737d9 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -346,6 +346,8 @@ const en = { optionsSection: 'Options', contentSection: 'Content', settingsSection: 'Settings', + valueAnchor: 'Anchor', + valueAnchorHint: 'Edge that stays fixed when a value change alters the width', typographySection: 'Typography', name: 'Name', x: 'X', diff --git a/src/locales/es.ts b/src/locales/es.ts index 4c4210a6..4be4a6cc 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -346,6 +346,8 @@ const es = { optionsSection: 'Opciones', contentSection: 'Contenido', settingsSection: 'Ajustes', + valueAnchor: 'Anclaje', + valueAnchorHint: 'Borde que permanece fijo cuando un cambio de valor altera el ancho', typographySection: 'Tipografía', name: 'Nombre', x: 'X', diff --git a/src/locales/et.ts b/src/locales/et.ts index f1bf6985..ba960e05 100644 --- a/src/locales/et.ts +++ b/src/locales/et.ts @@ -346,6 +346,8 @@ const et = { optionsSection: 'Valikud', contentSection: 'Sisu', settingsSection: 'Sätted', + valueAnchor: 'Ankur', + valueAnchorHint: 'Serv, mis jääb fikseerituks, kui väärtuse muutus muudab laiust', typographySection: 'Tüpograafia', name: 'Nimi', x: 'X', diff --git a/src/locales/fa.ts b/src/locales/fa.ts index ca0b86d8..eb9b3307 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -346,6 +346,8 @@ const fa = { optionsSection: 'گزینه‌ها', contentSection: 'محتوا', settingsSection: 'تنظیمات', + valueAnchor: 'لنگر', + valueAnchorHint: 'لبه‌ای که هنگام تغییر عرض بر اثر تغییر مقدار ثابت می‌ماند', typographySection: 'تایپوگرافی', name: 'نام', x: 'X', diff --git a/src/locales/fi.ts b/src/locales/fi.ts index 3558e3de..aba248fa 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -346,6 +346,8 @@ const fi = { optionsSection: 'Valinnat', contentSection: 'Sisältö', settingsSection: 'Asetukset', + valueAnchor: 'Ankkuri', + valueAnchorHint: 'Reuna, joka pysyy kiinteänä, kun arvon muutos muuttaa leveyttä', typographySection: 'Typografia', name: 'Nimi', x: 'X', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index b7b81b23..20161a2a 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -346,6 +346,8 @@ const fr = { optionsSection: 'Options', contentSection: 'Contenu', settingsSection: 'Paramètres', + valueAnchor: 'Ancrage', + valueAnchorHint: "Bord qui reste fixe lorsqu'une modification de valeur change la largeur", typographySection: 'Typographie', name: 'Nom', x: 'X', diff --git a/src/locales/he.ts b/src/locales/he.ts index a4360e19..4bc0231c 100644 --- a/src/locales/he.ts +++ b/src/locales/he.ts @@ -346,6 +346,8 @@ const he = { optionsSection: 'אפשרויות', contentSection: 'תוכן', settingsSection: 'הגדרות', + valueAnchor: 'עוגן', + valueAnchorHint: 'הקצה שנשאר קבוע כאשר שינוי בערך משנה את הרוחב', typographySection: 'טיפוגרפיה', name: 'שם', x: 'X', diff --git a/src/locales/hr.ts b/src/locales/hr.ts index 05da05e6..77d7f466 100644 --- a/src/locales/hr.ts +++ b/src/locales/hr.ts @@ -346,6 +346,8 @@ const hr = { optionsSection: 'Opcije', contentSection: 'Sadržaj', settingsSection: 'Postavke', + valueAnchor: 'Sidro', + valueAnchorHint: 'Rub koji ostaje fiksan kada promjena vrijednosti mijenja širinu', typographySection: 'Tipografija', name: 'Naziv', x: 'X', diff --git a/src/locales/hu.ts b/src/locales/hu.ts index f74cd40b..8143764f 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -346,6 +346,8 @@ const hu = { optionsSection: 'Lehetőségek', contentSection: 'Tartalom', settingsSection: 'Beállítások', + valueAnchor: 'Horgony', + valueAnchorHint: 'Az a szél, amely rögzítve marad, ha egy értékváltozás megváltoztatja a szélességet', typographySection: 'Tipográfia', name: 'Név', x: 'X', diff --git a/src/locales/it.ts b/src/locales/it.ts index 3d1877eb..d716e47b 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -346,6 +346,8 @@ const it = { optionsSection: 'Opzioni', contentSection: 'Contenuto', settingsSection: 'Impostazioni', + valueAnchor: 'Ancora', + valueAnchorHint: 'Bordo che rimane fisso quando una modifica del valore altera la larghezza', typographySection: 'Tipografia', name: 'Nome', x: 'X', diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 5ea4922b..88c4eb34 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -346,6 +346,8 @@ const ja = { optionsSection: 'オプション', contentSection: 'コンテンツ', settingsSection: '設定', + valueAnchor: 'アンカー', + valueAnchorHint: '値の変更で幅が変わる際に固定されたままの辺', typographySection: 'タイポグラフィ', name: '名前', x: 'X', diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 295fd8e6..70cf317d 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -346,6 +346,8 @@ const ko = { optionsSection: '옵션', contentSection: '콘텐츠', settingsSection: '설정', + valueAnchor: '앵커', + valueAnchorHint: '값 변경으로 너비가 바뀔 때 고정된 상태로 유지되는 모서리', typographySection: '타이포그래피', name: '이름', x: 'X', diff --git a/src/locales/lt.ts b/src/locales/lt.ts index e42f92b7..d6e54ee8 100644 --- a/src/locales/lt.ts +++ b/src/locales/lt.ts @@ -346,6 +346,8 @@ const lt = { optionsSection: 'Parinktys', contentSection: 'Turinys', settingsSection: 'Nustatymai', + valueAnchor: 'Inkaras', + valueAnchorHint: 'Kraštas, kuris išlieka fiksuotas, kai reikšmės pakeitimas pakeičia plotį', typographySection: 'Tipografija', name: 'Pavadinimas', x: 'X', diff --git a/src/locales/lv.ts b/src/locales/lv.ts index 23d506d4..5d3313e6 100644 --- a/src/locales/lv.ts +++ b/src/locales/lv.ts @@ -346,6 +346,8 @@ const lv = { optionsSection: 'Opcijas', contentSection: 'Saturs', settingsSection: 'Iestatījumi', + valueAnchor: 'Enkurs', + valueAnchorHint: 'Mala, kas paliek fiksēta, kad vērtības izmaiņas maina platumu', typographySection: 'Tipogrāfija', name: 'Nosaukums', x: 'X', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 58b852ef..4ae1e6e7 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -346,6 +346,8 @@ const nl = { optionsSection: 'Opties', contentSection: 'Inhoud', settingsSection: 'Instellingen', + valueAnchor: 'Anker', + valueAnchorHint: 'Rand die vast blijft wanneer een waardewijziging de breedte verandert', typographySection: 'Typografie', name: 'Naam', x: 'X', diff --git a/src/locales/no.ts b/src/locales/no.ts index 2ef2e01c..1a54d90e 100644 --- a/src/locales/no.ts +++ b/src/locales/no.ts @@ -346,6 +346,8 @@ const no = { optionsSection: 'Alternativer', contentSection: 'Innhold', settingsSection: 'Innstillinger', + valueAnchor: 'Anker', + valueAnchorHint: 'Kanten som forblir fast når en verdiendring endrer bredden', typographySection: 'Typografi', name: 'Navn', x: 'X', diff --git a/src/locales/pl.ts b/src/locales/pl.ts index f65f8762..dcbdac53 100644 --- a/src/locales/pl.ts +++ b/src/locales/pl.ts @@ -346,6 +346,8 @@ const pl = { optionsSection: 'Opcje', contentSection: 'Treść', settingsSection: 'Ustawienia', + valueAnchor: 'Kotwica', + valueAnchorHint: 'Krawędź, która pozostaje nieruchoma, gdy zmiana wartości zmienia szerokość', typographySection: 'Typografia', name: 'Nazwa', x: 'X', diff --git a/src/locales/pt.ts b/src/locales/pt.ts index 6d82195a..60b6e3a4 100644 --- a/src/locales/pt.ts +++ b/src/locales/pt.ts @@ -346,6 +346,8 @@ const pt = { optionsSection: 'Opções', contentSection: 'Conteúdo', settingsSection: 'Definições', + valueAnchor: 'Âncora', + valueAnchorHint: 'Borda que permanece fixa quando uma alteração de valor altera a largura', typographySection: 'Tipografia', name: 'Nome', x: 'X', diff --git a/src/locales/ro.ts b/src/locales/ro.ts index 870d0ea2..8b72d93e 100644 --- a/src/locales/ro.ts +++ b/src/locales/ro.ts @@ -346,6 +346,8 @@ const ro = { optionsSection: 'Opțiuni', contentSection: 'Conținut', settingsSection: 'Setări', + valueAnchor: 'Ancoră', + valueAnchorHint: 'Marginea care rămâne fixă atunci când o modificare a valorii schimbă lățimea', typographySection: 'Tipografie', name: 'Nume', x: 'X', diff --git a/src/locales/sk.ts b/src/locales/sk.ts index 0ffb8aca..f5dfe690 100644 --- a/src/locales/sk.ts +++ b/src/locales/sk.ts @@ -346,6 +346,8 @@ const sk = { optionsSection: 'Možnosti', contentSection: 'Obsah', settingsSection: 'Nastavenia', + valueAnchor: 'Kotva', + valueAnchorHint: 'Hrana, ktorá zostáva pevná, keď zmena hodnoty zmení šírku', typographySection: 'Typografia', name: 'Názov', x: 'X', diff --git a/src/locales/sl.ts b/src/locales/sl.ts index cbef41c3..175b62e1 100644 --- a/src/locales/sl.ts +++ b/src/locales/sl.ts @@ -346,6 +346,8 @@ const sl = { optionsSection: 'Možnosti', contentSection: 'Vsebina', settingsSection: 'Nastavitve', + valueAnchor: 'Sidro', + valueAnchorHint: 'Rob, ki ostane fiksen, ko sprememba vrednosti spremeni širino', typographySection: 'Tipografija', name: 'Ime', x: 'X', diff --git a/src/locales/sr.ts b/src/locales/sr.ts index fde3b60c..ee2d686b 100644 --- a/src/locales/sr.ts +++ b/src/locales/sr.ts @@ -346,6 +346,8 @@ const sr = { optionsSection: 'Опције', contentSection: 'Садржај', settingsSection: 'Подешавања', + valueAnchor: 'Сидро', + valueAnchorHint: 'Ивица која остаје фиксна када промена вредности мења ширину', typographySection: 'Типографија', name: 'Naziv', x: 'X', diff --git a/src/locales/sv.ts b/src/locales/sv.ts index 187dc796..d58bd6c1 100644 --- a/src/locales/sv.ts +++ b/src/locales/sv.ts @@ -346,6 +346,8 @@ const sv = { optionsSection: 'Alternativ', contentSection: 'Innehåll', settingsSection: 'Inställningar', + valueAnchor: 'Ankare', + valueAnchorHint: 'Kanten som förblir fast när en värdeändring ändrar bredden', typographySection: 'Typografi', name: 'Namn', x: 'X', diff --git a/src/locales/tr.ts b/src/locales/tr.ts index 2c99b8b8..d8e0a819 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -346,6 +346,8 @@ const tr = { optionsSection: 'Seçenekler', contentSection: 'İçerik', settingsSection: 'Ayarlar', + valueAnchor: 'Çapa', + valueAnchorHint: 'Değer değişikliği genişliği değiştirdiğinde sabit kalan kenar', typographySection: 'Tipografi', name: 'Ad', x: 'X', diff --git a/src/locales/zh-hans.ts b/src/locales/zh-hans.ts index 4b58f09a..34e16928 100644 --- a/src/locales/zh-hans.ts +++ b/src/locales/zh-hans.ts @@ -346,6 +346,8 @@ const zhHans = { optionsSection: '选项', contentSection: '内容', settingsSection: '设置', + valueAnchor: '锚点', + valueAnchorHint: '数值变化导致宽度改变时保持固定的边', typographySection: '排版', name: '名称', x: 'X', diff --git a/src/locales/zh-hant.ts b/src/locales/zh-hant.ts index d8646ebe..a3579a03 100644 --- a/src/locales/zh-hant.ts +++ b/src/locales/zh-hant.ts @@ -346,6 +346,8 @@ const zhHant = { optionsSection: '選項', contentSection: '內容', settingsSection: '設定', + valueAnchor: '錨點', + valueAnchorHint: '數值變化導致寬度改變時保持固定的邊', typographySection: '排版', name: '名稱', x: 'X', diff --git a/src/store/anchorRepin.test.ts b/src/store/anchorRepin.test.ts new file mode 100644 index 00000000..db5bdb29 --- /dev/null +++ b/src/store/anchorRepin.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { applyObjectChanges, NON_EMITTING_PROP_KEYS } 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 type { LabelObject } from "@zplab/core/types/Group"; + +// Fake probe: width tracks content length, axes swap under R rotation. +const probe = (o: LabelObject) => { + const p = (o as { props: { content: string; rotation?: string } }).props; + const w = p.content.length * 10; + const swapped = p.rotation === "R" || p.rotation === "B"; + return swapped ? { w: 30, h: w } : { w, h: 30 }; +}; + +const barcode = (over: object = {}, props: object = {}): LabelObject => + ({ + id: "b1", + type: "code128", + x: 100, + y: 50, + fieldJustify: "R", + props: { content: "AB", height: 100, moduleWidth: 2, rotation: "N", ...props }, + ...over, + }) as unknown as LabelObject; + +describe("anchorRepin", () => { + afterEach(() => registerBarcodeWidthProber(null)); + + it("keeps the right edge fixed when the content widens", () => { + registerBarcodeWidthProber(probe); + const next = applyObjectChanges(barcode(), { props: { content: "ABCD" } }); + // Width 20 -> 40: origin moves left by the full delta. + expect(next.x).toBe(80); + expect(next.y).toBe(50); + }); + + it("splits an odd delta away from zero for a centre justify", () => { + // Odd delta (-7) so a plain Math.round(delta/2) regression turns red. + const oddProbe = (o: LabelObject) => { + const c = (o as { props: { content: string } }).props.content; + return { w: c === "ABCD" ? 27 : 20, h: 30 }; + }; + registerBarcodeWidthProber(oddProbe); + const next = applyObjectChanges(barcode({ fieldJustify: "C" }), { props: { content: "ABCD" } }); + expect(next.x).toBe(96); + }); + + it("does nothing for L/absent justify or a width-neutral edit", () => { + registerBarcodeWidthProber(probe); + expect(applyObjectChanges(barcode({ fieldJustify: "L" }), { props: { content: "ABCD" } }).x).toBe(100); + expect(applyObjectChanges(barcode({ fieldJustify: undefined }), { props: { content: "ABCD" } }).x).toBe(100); + expect(applyObjectChanges(barcode(), { props: { height: 80 } }).x).toBe(100); + }); + + it("skips edits that position the object themselves (transformer commits)", () => { + registerBarcodeWidthProber(probe); + const next = applyObjectChanges(barcode(), { x: 200, props: { content: "ABCD" } }); + expect(next.x).toBe(200); + }); + + it("shifts y instead of x when the rotation swaps the axes", () => { + registerBarcodeWidthProber(probe); + const next = applyObjectChanges(barcode({}, { rotation: "R" }), { props: { content: "ABCD" } }); + expect(next.x).toBe(100); + expect(next.y).toBe(30); + }); + + it("skips rotation changes (footprint axes swap, extents not comparable)", () => { + registerBarcodeWidthProber(probe); + const next = applyObjectChanges(barcode(), { props: { rotation: "R" } }); + expect(next.x).toBe(100); + expect(next.y).toBe(50); + }); + + it("is inert without a registered prober (headless)", () => { + const next = applyObjectChanges(barcode(), { props: { content: "ABCD" } }); + expect(next.x).toBe(100); + }); + + it("ignores non-1D types even with a hand-crafted justify (JSON/MCP)", () => { + registerBarcodeWidthProber(probe); + const qr = barcode({ type: "qrcode" }); + expect(applyObjectChanges(qr, { props: { content: "ABCD" } }).x).toBe(100); + }); + + it("centre widen/narrow toggle returns exactly (no half-up walk)", () => { + // Odd delta (7) exposes Math.round's half-up asymmetry. + const oddProbe = (o: LabelObject) => { + const c = (o as { props: { content: string } }).props.content; + return { w: c === "long" ? 27 : 20, h: 30 }; + }; + registerBarcodeWidthProber(oddProbe); + const widened = applyObjectChanges(barcode({ fieldJustify: "C" }, { content: "shrt" }), { + props: { content: "long" }, + }); + const back = applyObjectChanges(widened, { props: { content: "shrt" } }); + expect(back.x).toBe(100); + }); + + it("treats absent justify as L under ^FT+I (implicit == explicit)", () => { + registerBarcodeWidthProber(probe); + const implicit = barcode({ positionType: "FT", fieldJustify: undefined }, { rotation: "I" }); + // Same compensation as explicit L: holding the left edge needs the delta. + expect(applyObjectChanges(implicit, { props: { content: "ABCD" } }).x).toBe(120); + }); + + it("inverts the shift under ^FT with rotation I (bar-width-dependent origin)", () => { + registerBarcodeWidthProber(probe); + const ftI = barcode({ positionType: "FT" }, { rotation: "I" }); + // FT+I already pins the right edge; correct shift is 0. + expect(applyObjectChanges(ftI, { props: { content: "ABCD" } }).x).toBe(100); + const ftIL = barcode({ positionType: "FT", fieldJustify: "L" }, { rotation: "I" }); + // Holding the LEFT edge under FT+I needs the full delta. + expect(applyObjectChanges(ftIL, { props: { content: "ABCD" } }).x).toBe(120); + }); + + it("applies the same inversion on the swapped axis under ^FT with rotation B", () => { + registerBarcodeWidthProber(probe); + const ftB = barcode({ positionType: "FT" }, { rotation: "B" }); + // FT+B pins the justified edge on the y axis; right stays put. + const r = applyObjectChanges(ftB, { props: { content: "ABCD" } }); + expect(r.x).toBe(100); + expect(r.y).toBe(50); + const ftBL = barcode({ positionType: "FT", fieldJustify: "L" }, { rotation: "B" }); + expect(applyObjectChanges(ftBL, { props: { content: "ABCD" } }).y).toBe(70); + }); +}); + +describe("symbology switch re-pin", () => { + afterEach(() => registerBarcodeWidthProber(null)); + + it("holds the justified edge when a type switch changes the width", () => { + const typedProbe = (o: LabelObject) => + o.type === "code39" ? { w: 15, h: 30 } : { w: 20, h: 30 }; + registerBarcodeWidthProber(typedProbe); + const src = barcode(); + const next = anchorRepin(src, { props: {} }, convertSymbologyMapper("code39" as never)(src)); + // Width 20 -> 15 with anchor R: origin moves right by the delta. + expect(next.x).toBe(105); + }); +}); + +describe("prober registry", () => { + afterEach(() => registerBarcodeWidthProber(null)); + + it("a stale unregister cannot null a successor registration", () => { + const a = () => ({ w: 1, h: 1 }); + const b = () => ({ w: 2, h: 2 }); + registerBarcodeWidthProber(a); + registerBarcodeWidthProber(b); + unregisterBarcodeWidthProber(a); + expect(probeBarcodeFootprint({} as LabelObject)).toEqual({ w: 2, h: 2 }); + }); +}); + +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); + expect(valueAnchorShift("C", -7, false)).toBe(-4); + }); + + it("applies the FT I/B inversion", () => { + expect(valueAnchorShift("R", 10, true)).toBe(0); + expect(valueAnchorShift("L", 10, true)).toBe(-10); + expect(valueAnchorShift("C", 10, true)).toBe(-5); + }); +}); + +describe("dirty semantics of fieldJustify", () => { + const asPage = (leaf: LabelObject) => ({ id: "p1", objects: [leaf] }); + const stamp = (before: LabelObject, after: LabelObject) => + (stampDirtyLeaves( + [asPage(before)] as Parameters[0], + [asPage(after)] as Parameters[1], + )[0]!.objects[0] as { dirty?: boolean }).dirty; + + it("a 1D justify toggle does not stamp dirty (no z emitted yet; overlay survives)", () => { + const leaf = barcode(); + // R, not only C: R is the value a future z-emit would make emit-affecting. + expect(stamp(leaf, { ...leaf, fieldJustify: "C" } as LabelObject)).toBeUndefined(); + const noJustify = barcode({ fieldJustify: undefined }); + expect(stamp(noJustify, { ...noJustify, fieldJustify: "R" } as LabelObject)).toBeUndefined(); + }); + + it("a 2D justify change stamps dirty (fieldPosZ echoes the z)", () => { + const qr = barcode({ type: "qrcode", fieldJustify: undefined }); + expect(stamp(qr, { ...qr, fieldJustify: "R" } as LabelObject)).toBe(true); + }); + + it("a graphic justify change stamps dirty (graphicAnchor emits it)", () => { + const box = barcode({ type: "box", fieldJustify: "L" }); + expect(stamp(box, { ...box, fieldJustify: "R" } as LabelObject)).toBe(true); + }); + + it("a content change still stamps dirty", () => { + const leaf = barcode(); + const changed = { ...leaf, props: { ...(leaf as { props: object }).props, content: "XYZ" } } as LabelObject; + expect(stamp(leaf, changed)).toBe(true); + }); +}); diff --git a/src/store/anchorRepin.ts b/src/store/anchorRepin.ts new file mode 100644 index 00000000..a281ea7c --- /dev/null +++ b/src/store/anchorRepin.ts @@ -0,0 +1,63 @@ +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; +} + +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. */ +let prober: BarcodeWidthProber | null = null; + +export function registerBarcodeWidthProber(p: BarcodeWidthProber | null): void { + prober = p; +} + +/** Clears only if `p` is still active, so a stale unmount cleanup can't null + * a successor registration. */ +export function unregisterBarcodeWidthProber(p: BarcodeWidthProber): void { + if (prober === p) prober = null; +} + +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). */ +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 }; +} diff --git a/src/store/dirtyTracking.ts b/src/store/dirtyTracking.ts index 41059915..b32ff3ab 100644 --- a/src/store/dirtyTracking.ts +++ b/src/store/dirtyTracking.ts @@ -1,6 +1,7 @@ import type { StateCreator, StoreMutatorIdentifier } from 'zustand'; import { isGroup, type LabelObject, type Page } from '@zplab/core/types/Group'; -import { EMIT_AFFECTING_KEYS } from './labelStore.internals'; +import { emitsFieldJustify } from '@zplab/core/registry'; +import { EMIT_AFFECTING_KEYS, NON_EMITTING_PROP_KEYS } from './labelStore.internals'; // Centralizes round-trip dirty-tracking that was otherwise scattered across every // object mutator (the chokepoint plus each wholesale-rewrite bypass). A single @@ -10,14 +11,24 @@ import { EMIT_AFFECTING_KEYS } from './labelStore.internals'; // temporal, so zundo's restore path (which replays via the original setState, // outside this wrapper) does not re-stamp on undo/redo. -/** True when any emit-affecting field differs. Scalars compare by value; `props` - * compares by reference. Given the store's identity-preserving mutators (which - * keep the same `props` object when nothing changed and produce a fresh one on - * an edit), this never UNDER-stamps; it can only over-stamp on a no-op props - * rebuild, which merely regenerates a byte-identical field. Reuses the single - * EMIT_AFFECTING_KEYS source. */ +/** True when any emit-affecting field differs. Scalars compare by value; + * props diffs shallowly (editor-aid keys skipped) and relies on mutators + * replacing, never mutating, nested prop values. */ function emitAffectingChanged(a: LabelObject, b: LabelObject): boolean { for (const k of EMIT_AFFECTING_KEYS) { + const av = (a as Record)[k]; + const bv = (b as Record)[k]; + if (av === bv) continue; + if (k === 'fieldJustify' && !emitsFieldJustify(b.type)) continue; + if (k === 'props' && !propsEmitChanged(av as object, bv as object)) continue; + return true; + } + return false; +} + +function propsEmitChanged(a: object = {}, b: object = {}): boolean { + for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) { + if (NON_EMITTING_PROP_KEYS.has(k)) continue; if ((a as Record)[k] !== (b as Record)[k]) return true; } return false; diff --git a/src/store/labelStore.internals.ts b/src/store/labelStore.internals.ts index b7b6bfa7..0fb776f9 100644 --- a/src/store/labelStore.internals.ts +++ b/src/store/labelStore.internals.ts @@ -4,6 +4,7 @@ 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'; /** Meta fields that remain editable on a locked object so the user can * release the lock or annotate without unlocking first. Everything else @@ -28,6 +29,11 @@ export const EMIT_AFFECTING_KEYS = new Set(['x', 'y', 'rotation', 'positionType' * wrongly added here would silently skip the overlay drop and emit stale config. */ export const NON_EMITTING_CONFIG_KEYS = new Set(['safeAreaMm']); +/** 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']); + /** 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 * config verbatim, so an emit-affecting edit must force full regeneration. */ @@ -144,7 +150,7 @@ export function applyObjectChanges( } as LabelObject; // Dirty-tracking is centralized in the dirtyTracking middleware (a state diff), // so this mutator no longer stamps dirty itself. - return next; + return anchorRepin(obj, normalized, next); } export function detectLocale(): LocaleCode { diff --git a/src/store/slices/objectSlice.ts b/src/store/slices/objectSlice.ts index c2545c0c..28d3fdd5 100644 --- a/src/store/slices/objectSlice.ts +++ b/src/store/slices/objectSlice.ts @@ -13,6 +13,7 @@ import { type Page, } from '@zplab/core/types/Group'; import { getEntry } from '@zplab/core/registry'; +import { anchorRepin } from '../anchorRepin'; import { reorderForZ, type ZOrderDir } from '../../lib/zorder'; import { makeReverseBackingBox, precedingBackingExists, isOwnReverseBacking } from '@zplab/core/lib/reverseBacking'; import { spawnRotationOverride } from '../../lib/spawn'; @@ -204,7 +205,12 @@ export const createObjectSlice: StateCreator = // design; the mapper owns the result's validity (line/box have no hook). // The type/props change is stamped dirty centrally by the dirtyTracking // middleware. - const next = mapObjectById(objs, id, (o) => mapper(o)); + // anchorRepin still runs: a type switch changes the measured width, so + // the justified edge must hold like on any value edit. + const next = mapObjectById(objs, id, (o) => { + const mapped = mapper(o); + return mapped === o ? o : anchorRepin(o, { props: {} }, mapped); + }); // A no-op mapper must not rebuild pages: the fresh array would record a // phantom undo step (temporalEquality is a ref compare). if (next === objs) return {};