Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/core/src/lib/valueAnchor.ts
Original file line number Diff line number Diff line change
@@ -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;
}
21 changes: 20 additions & 1 deletion packages/core/src/lib/zplImportService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<LabelConfig>;
Expand All @@ -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).
Expand Down Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/lib/zplParser/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -318,6 +323,7 @@ export function createParserState(): ParserState {
cfWidth: 0,
cfFontId: undefined,
fwRotation: "N",
fwJustify: "L",
fbWidth: 0,
fbLines: 1,
fbSpacing: 0,
Expand Down
41 changes: 24 additions & 17 deletions packages/core/src/lib/zplParser/flushField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<n>^FD<default>^FS`: Variable declaration, not a field.
Expand Down Expand Up @@ -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();
Expand All @@ -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,
Expand All @@ -265,7 +272,7 @@ export function createFlushField(
break;
}
case "code39":
objects.push(
pushLeaf(
makeObj(
"code39",
s.field.x,
Expand All @@ -285,7 +292,7 @@ export function createFlushField(
);
break;
case "ean13":
objects.push(
pushLeaf(
makeObj(
"ean13",
s.field.x,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -355,7 +362,7 @@ export function createFlushField(
break;
}
case "upce":
objects.push(
pushLeaf(
makeObj(
"upce",
s.field.x,
Expand Down Expand Up @@ -390,7 +397,7 @@ export function createFlushField(
case "planet":
case "postal":
case "upcEanExtension":
objects.push(
pushLeaf(
makeObj(
s.field.fieldType,
s.field.x,
Expand All @@ -410,7 +417,7 @@ export function createFlushField(
);
break;
case "gs1databar":
objects.push(
pushLeaf(
makeObj(
"gs1databar",
s.field.x,
Expand All @@ -431,7 +438,7 @@ export function createFlushField(
);
break;
case "pdf417":
objects.push(
pushLeaf(
makeObj(
"pdf417",
s.field.x,
Expand All @@ -450,7 +457,7 @@ export function createFlushField(
);
break;
case "code49":
objects.push(
pushLeaf(
makeObj(
"code49",
s.field.x,
Expand All @@ -469,7 +476,7 @@ export function createFlushField(
);
break;
case "aztec":
objects.push(
pushLeaf(
makeObj(
"aztec",
s.field.x,
Expand All @@ -486,7 +493,7 @@ export function createFlushField(
);
break;
case "maxicode":
objects.push(
pushLeaf(
makeObj(
"maxicode",
s.field.x,
Expand All @@ -501,7 +508,7 @@ export function createFlushField(
);
break;
case "micropdf417":
objects.push(
pushLeaf(
makeObj(
"micropdf417",
s.field.x,
Expand All @@ -519,7 +526,7 @@ export function createFlushField(
);
break;
case "codablock":
objects.push(
pushLeaf(
makeObj(
"codablock",
s.field.x,
Expand All @@ -538,7 +545,7 @@ export function createFlushField(
);
break;
case "tlc39":
objects.push(
pushLeaf(
makeObj(
"tlc39",
s.field.x,
Expand All @@ -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,
Expand Down
21 changes: 13 additions & 8 deletions packages/core/src/lib/zplParser/handlers/fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
},

Expand Down Expand Up @@ -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 ───────────────────────────────────────────────────────
Expand Down Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/lib/zplParser/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/registry/aztec.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -47,7 +47,7 @@ export const aztec: ObjectTypeCore<AztecProps> = {
// 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("");
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/registry/codablock.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -67,7 +67,7 @@ export const codablock: ObjectTypeCore<CodablockProps> = {
// 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),
]
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/registry/datamatrix.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -89,7 +89,7 @@ export const datamatrix: ObjectTypeCore<DataMatrixProps> = {
];
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('');
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/registry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,13 @@ export const STACKED_2D_TYPES: ReadonlySet<string> = 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<string, (typeof ObjectRegistry)[LeafType] | undefined>)[type];
Expand Down
Loading