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
2 changes: 1 addition & 1 deletion packages/core/src/lib/emittedAnchor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function emittedAnchorDots(
box?: BoundingBoxDots,
): { x: number; y: number } {
if (obj.type === "text") {
const { x, y } = textZplAnchorCoords(obj as Parameters<typeof textZplAnchorCoords>[0], ctx.label);
const { x, y } = textZplAnchorCoords(obj as Parameters<typeof textZplAnchorCoords>[0], ctx.label, ctx.variables);
return { x, y };
}
if (GRAPHIC_ANCHOR_TYPES.has(obj.type)) {
Expand Down
52 changes: 40 additions & 12 deletions packages/core/src/lib/labelGeometry/deviceFonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,17 +67,50 @@ export const DEVICE_FONT_CELLS: Readonly<
]),
);

/** Snapped magnifications for a bitmap font, or null for Font 0 / unknown
* ids / non-positive heights (magnify would otherwise clamp a negative to
* mag 1 or propagate NaN). Width 0 derives from the height mag. */
function deviceFontMags(
fontId: string | undefined,
heightDots: number,
widthDots: number,
): { spec: DeviceFontSpec; magH: number; magW: number } | null {
if (!fontId) return null;
const spec = DEVICE_FONTS[fontId];
if (!spec || !(heightDots > 0)) return null;
const magH = magnify(heightDots, spec.magStep);
const magW = widthDots > 0 ? magnify(widthDots, spec.magWidthStep) : magH;
return { spec, magH, magW };
}

/** Deterministic field extent in dots for the ^FO anchor of rotated (I/B)
* bitmap fields: cells are monospaced, and Labelary measures the printed
* extent at n*advance - gap/2 within 2 dots across fonts. Null for
* Font 0 / unknown ids (measured PrintLab width applies there). */
export function deviceFontInkWidthDots(
fontId: string | undefined,
heightDots: number,
widthDots: number,
content: string,
): number | null {
const mags = deviceFontMags(fontId, heightDots, widthDots);
if (!mags) return null;
const { spec, magW } = mags;
const n = applyDeviceFontCase(fontId, content).length;
if (n === 0) return 0;
const gap = spec.advancePerMag - spec.magWidthStep;
return magW * (n * spec.advancePerMag - gap / 2);
}

/** Requested ^A height snapped to the cell grid, or null for Font 0 /
* unknown ids / non-positive heights. The firmware anchors a bitmap field
* by its snapped cell, so the anchor transform needs this too. */
export function deviceFontSnappedHeightDots(
fontId: string | undefined,
heightDots: number,
): number | null {
if (!fontId) return null;
const spec = DEVICE_FONTS[fontId];
if (!spec || !(heightDots > 0)) return null;
return magnify(heightDots, spec.magStep) * spec.magStep;
const mags = deviceFontMags(fontId, heightDots, 0);
return mags ? mags.magH * mags.spec.magStep : null;
}

// Zebra fonts B and H (OCR-A) have no lowercase glyphs: B prints uppercase,
Expand Down Expand Up @@ -119,14 +152,9 @@ export function deviceFontMetrics(
heightDots: number,
widthDots: number,
): DeviceFontMetrics | null {
if (!fontId) return null;
const spec = DEVICE_FONTS[fontId];
if (!spec) return null;
// Guard NaN / non-positive height: magnify would otherwise clamp a negative
// to mag 1 or propagate NaN through fontSizeDots into every offset.
if (!(heightDots > 0)) return null;
const magH = magnify(heightDots, spec.magStep);
const magW = widthDots > 0 ? magnify(widthDots, spec.magWidthStep) : magH;
const mags = deviceFontMags(fontId, heightDots, widthDots);
if (!mags) return null;
const { spec, magH, magW } = mags;
const fontSizeDots = (magH * spec.capInkPerMag) / spec.capPerEm;
const scaleX =
((magW * spec.advancePerMag) / (fontSizeDots * spec.advPerEm)) *
Expand Down
28 changes: 27 additions & 1 deletion packages/core/src/lib/labelGeometry/textRenderMetrics.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { builtinFontFamily, resolveDeviceFontId, resolvePreviewFontName } from "../customFonts";
import { applyDeviceFontCase, deviceFontMetrics } from "./deviceFonts";
import { applyDeviceFontCase, deviceFontInkWidthDots, deviceFontMetrics } from "./deviceFonts";
import { getFontFamily } from "../fontCache";
import type { LabelObject } from "../../types/Group";
import type { LabelConfig } from "../../types/LabelConfig";
Expand Down Expand Up @@ -71,6 +71,32 @@ export function computeTextRenderMetrics(input: TextMetricsInput): TextRenderMet
return { content, fontFamily, fontScaleX, inkWidthDots, fontSizeDots: input.fontSizeDots };
}

/** Ink extent feeding the FO/I and FO/B anchor shift: cell grid for bitmap
* device fonts, measured PrintLab width otherwise. The single derivation
* shared by emit and parse; one-sided drift breaks byte-exact round-trips
* for rotated fields. */
export function anchorInkWidthDots(input: {
fontId: string | undefined;
content: string;
fontHeight: number;
fontWidth: number;
printerFontName?: string;
}): number {
const cellGrid = deviceFontInkWidthDots(
input.fontId,
input.fontHeight,
input.fontWidth,
input.content,
);
if (cellGrid !== null) return cellGrid;
return computeTextRenderMetrics({
content: input.content,
fontHeight: input.fontHeight,
fontWidth: input.fontWidth,
printerFontName: input.printerFontName,
}).inkWidthDots;
}

/** Canvas-only preview font priority: fontId -> printerFontName -> defaultFontId.
* Emit/parse omit `label` so round-trip stays PrintLab-based. */
export function getTextRenderMetrics(
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/lib/objectBounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { gfaHeaderDims, headerByteSource, type ImageProps } from "../registry/im
import { BARCODE_1D_TYPES, STACKED_2D_TYPES, getEntry } from "../registry";
import { GRAPHIC_ANCHOR_TYPES } from "../registry/zplHelpers";
import type { PageLabel } from "../types/LabelConfig";
import type { Variable } from "../types/Variable";
import { effectiveDpmm } from "../types/LabelConfig";
import { isAxisSwapped, objectRotation, type ZplRotation } from "../registry/rotation";
import { resolveTextMode } from "../registry/text";
Expand All @@ -34,6 +35,9 @@ export interface BoundingBoxDots {

export interface ObjectBoundsCtx {
label: PageLabel;
/** Lets the emitted-anchor preflight resolve a single-bind marker the way
* emission does; absent, the marker text itself is measured. */
variables?: readonly Variable[];
/** Measured footprints (dots) published by the render layer for types whose
* size isn't purely computable (barcodes, single-line text). Keyed by obj.id.
* The FT anchor uses uprightBar*Dots; barHeightDots is the legacy fallback. */
Expand Down
23 changes: 11 additions & 12 deletions packages/core/src/lib/zplParser/flushField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { dataMatrixFdToGs1Content } from "../dataMatrixFd";
import { zplAnchorToModel } from "../labelGeometry/textPositionTransforms";
import { resolveDeviceFontId } from "../customFonts";
import { blockInterLineExtentDots } from "../zebraTextLayout";
import { computeTextRenderMetrics } from "../labelGeometry/textRenderMetrics";
import { anchorInkWidthDots } from "../labelGeometry/textRenderMetrics";
import type { TextProps } from "../../registry/text";
import type { Code128Props } from "../../registry/code128";
import type { Code39Props } from "../../registry/code39";
Expand Down Expand Up @@ -242,17 +242,6 @@ export function createFlushField(
if (s.field.fieldType !== "text") commitPendingReverseBg();
switch (s.field.fieldType) {
case "text": {
// ZPL anchors ^FO at cap-top and ^FT at baseline; our internal
// model stores the Konva render position (EM-top-left) so editor
// interactions stay shift-free. The FO/I and FO/B shifts also
// need the rendered ink width; measure it the same way the
// renderer does so the round-trip stays exact.
const { inkWidthDots } = computeTextRenderMetrics({
content: decoded,
fontHeight: s.field.textH,
fontWidth: s.field.textW,
printerFontName: s.field.pendingPrinterFontName,
});
// FT pins the last baseline, so the EM-top sits one block-extent above;
// FO R/I stack the same way. ^TB is a fixed clip height, ^FB stacks
// lines. Must match the generator's blockExtentFor for byte-exact
Expand All @@ -275,6 +264,16 @@ export function createFlushField(
defaultFontId: s.defaults.cfFontId,
},
);
// ZPL anchors ^FO at cap-top and ^FT at baseline; our internal
// model stores the Konva render position (EM-top-left) so editor
// interactions stay shift-free.
const inkWidthDots = anchorInkWidthDots({
fontId: anchorFontId,
content: decoded,
fontHeight: s.field.textH,
fontWidth: s.field.textW,
printerFontName: s.field.pendingPrinterFontName,
});
const modelPos = zplAnchorToModel(
s.field.x,
s.field.y,
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/registry/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ export const text: ObjectTypeCore<TextProps> = {
// Serial mode: plain ^A field whose ^FD is wrapped by ^SN/^SF. No block,
// reverse, ^FP or variable binding (those are cleared on switch).
if (p.serial) {
return `${textFieldPos(obj, ctx?.label)}${resolveFontCmd(p, ctx)}${serialFieldData(p.content, p.serial)}`;
return `${textFieldPos(obj, ctx?.label, ctx?.variables)}${resolveFontCmd(p, ctx)}${serialFieldData(p.content, p.serial)}`;
}
const mode = resolveTextMode(p);
const fontCmd = resolveFontCmd(p, ctx);
Expand All @@ -233,7 +233,7 @@ export const text: ObjectTypeCore<TextProps> = {
const fpDir = p.fpDirection ?? "H";
const fpGap = p.fpCharGap ?? 0;
const fpCmd = fpDir !== "H" || fpGap > 0 ? `^FP${fpDir},${fpGap}` : "";
const anchor = textFieldPos(obj, ctx?.label);
const anchor = textFieldPos(obj, ctx?.label, ctx?.variables);
const fd = fdFieldFor(content, ctx, undefined, encodeDefault);
// ^FR is the spec-true reverse: it knocks the glyph ink out of whatever is
// already drawn (e.g. a black ^GB placed behind the text), so we emit it as
Expand Down
24 changes: 19 additions & 5 deletions packages/core/src/registry/zplHelpers.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import type { LabelObjectBase } from "../types/LabelObject";
import { effectiveDpmm, type JmDensity } from "../types/LabelConfig";
import type { ZplEmitContext } from "../types/ZplEmit";
import type { Variable } from "../types/Variable";
import { hasTemplateMarkers, markersToEmbeds } from "../lib/fnTemplate";
import { hasClockMarkers, markersToTokens } from "../lib/fcTemplate";
import { hasControlMarkers, resolveControlMarkers } from "../types/controlKey";
import { classifyField } from "../lib/variableField";
import { modelToZplAnchor } from "../lib/labelGeometry/textPositionTransforms";
import { resolveDeviceFontId, type DeviceFontLabel } from "../lib/customFonts";
import { getTextRenderMetrics } from "../lib/labelGeometry/textRenderMetrics";
import { anchorInkWidthDots } from "../lib/labelGeometry/textRenderMetrics";
import { blockInterLineExtentDots } from "../lib/zebraTextLayout";
import type { LabelObject } from "../types/Group";
import { objectRotation } from "./rotation";
Expand Down Expand Up @@ -117,6 +118,8 @@ export function wrapReverse(reverse: boolean | undefined, body: string): string
interface TextLikeObjForFieldPos extends LabelObjectBase {
props: {
fontHeight: number;
fontWidth: number;
content: string;
rotation: "N" | "R" | "I" | "B";
fontId?: string;
printerFontName?: string;
Expand Down Expand Up @@ -169,26 +172,36 @@ export function resolveFontCmd(

/** Numeric ZPL anchor (cap-top/baseline) for a text-like field. `label`
* resolves the effective device font (^CF default, ^CW alias override)
* for the anchor snap. */
* for the anchor snap; `variables` resolve a single-bind marker to the
* default the wire actually prints (^FN{n}^FD{default}). */
export function textZplAnchorCoords(
obj: TextLikeObjForFieldPos,
label?: DeviceFontLabel,
variables?: readonly Variable[],
): {
cmd: "FO" | "FT";
x: number;
y: number;
} {
const cmd = obj.positionType === "FT" ? "FT" : "FO";
const metrics = getTextRenderMetrics(obj as unknown as LabelObject);
const p = obj.props;
const blockExtentDots = blockExtentFor(p);
const anchorFontId = resolveDeviceFontId(p.fontId, p.printerFontName, label ?? {});
const cls = variables ? classifyField(p.content, variables) : undefined;
const anchorContent = cls?.kind === "single" ? cls.variable.defaultValue : p.content;
const inkWidthDots = anchorInkWidthDots({
fontId: anchorFontId,
content: anchorContent,
fontHeight: p.fontHeight,
fontWidth: p.fontWidth,
printerFontName: p.printerFontName,
});
const a = modelToZplAnchor(
obj.x,
obj.y,
{ ...p, fontId: anchorFontId },
obj.positionType,
metrics?.inkWidthDots ?? 0,
inkWidthDots,
blockExtentDots,
p.blockWidth ?? 0,
);
Expand All @@ -200,8 +213,9 @@ export function textZplAnchorCoords(
export function textFieldPos(
obj: TextLikeObjForFieldPos,
label?: DeviceFontLabel,
variables?: readonly Variable[],
): string {
const a = textZplAnchorCoords(obj, label);
const a = textZplAnchorCoords(obj, label, variables);
// Same echo contract as fieldPosZ (text is never import-normalised).
const z = obj.fieldJustify === "R" ? ",1" : "";
return `^${a.cmd}${a.x},${a.y}${z}`;
Expand Down
5 changes: 5 additions & 0 deletions packages/mcp-server/src/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,11 @@ describe("mcp-server tools", () => {
expect(nearEdge.warnings.some((w) => w.objectId === "q" && w.kind.startsWith("offLabel"))).toBe(true);
});

it("judges off-label for a bound rotated field by its printed default, not its marker", () => {
const v = ok(validateZpl("^XA^PW800^LL400^FO10,150^AGI,60,40^FN1^FDM5i^FS^XZ"));
expect(v.warnings.filter((w) => w.kind.startsWith("offLabel"))).toEqual([]);
});

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);
Expand Down
5 changes: 3 additions & 2 deletions packages/mcp-server/src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,10 @@ function preflightOf(
objects: LabelObject[],
label: PageLabel,
pageIndex: number,
variables: readonly Variable[],
measured?: ObjectBoundsCtx["measured"],
): PreflightWarning[] {
return computePreflight(exportableLeaves(objects), { label, measured }, "mm").map((f) => ({
return computePreflight(exportableLeaves(objects), { label, measured, variables }, "mm").map((f) => ({
pageIndex,
objectId: f.objectId,
kind: f.kind,
Expand Down Expand Up @@ -364,7 +365,7 @@ function boundReport(
const probed = measuredBarcodes(pages, label, measured);
return {
warnings: perPage(pages, label, (objects, pageLabel, i) =>
preflightOf(objects, pageLabel, i, probed)),
preflightOf(objects, pageLabel, i, variables, probed)),
...geometryFor(pages, label, probed),
};
});
Expand Down
2 changes: 1 addition & 1 deletion src/components/Canvas/LabelCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -613,7 +613,7 @@ export const LabelCanvas = forwardRef<LabelCanvasHandle, Props>(function LabelCa
const isMultiSelection = selectedIds.length > 1;
// Snapshot (not the live map) so the frame derivations recompute when a
// settled footprint changes; the changing snapshot reference is the signal.
const frameCtx = { label, measured: measuredSnapshot };
const frameCtx = { label, measured: measuredSnapshot, variables };
// Hidden leaves never render, so the old client-rect path ignored them; keep
// them out of the model-based bounds too.
// visibleLeaves carries the cascaded (effective) lock; read it, not the raw
Expand Down
19 changes: 18 additions & 1 deletion src/lib/labelGeometry/deviceFonts.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { applyDeviceFontCase, deviceFontMetrics } from "@zplab/core/lib/labelGeometry/deviceFonts";
import { applyDeviceFontCase, deviceFontInkWidthDots, deviceFontMetrics } from "@zplab/core/lib/labelGeometry/deviceFonts";
import { ZPL_BUILTIN_FONT_LETTERS, builtinFontFamily } from "@zplab/core/lib/customFonts";

describe("device-font table parity", () => {
Expand Down Expand Up @@ -109,3 +109,20 @@ describe("deviceFontMetrics", () => {
expect(wide.fontSizeDots).toBe(narrow.fontSizeDots); // height unchanged
});
});

describe("deviceFontInkWidthDots", () => {
it("uses the cell grid: n*advance - gap/2", () => {
// Font G: advance 48, cell 40 -> gap 8; Labelary-measured extent.
expect(deviceFontInkWidthDots("G", 60, 0, "M5i")).toBe(3 * 48 - 4);
expect(deviceFontInkWidthDots("G", 120, 0, "M")).toBe(2 * (48 - 4));
});
it("counts the case-folded visible content", () => {
// H drops lowercase entirely, so "M5i" prints as two glyphs.
expect(deviceFontInkWidthDots("H", 21, 0, "M5i")).toBe(2 * 19 - 3);
expect(deviceFontInkWidthDots("H", 21, 0, "iii")).toBe(0);
});
it("returns null for scalable ids", () => {
expect(deviceFontInkWidthDots("0", 50, 0, "M")).toBeNull();
expect(deviceFontInkWidthDots(undefined, 50, 0, "M")).toBeNull();
});
});
18 changes: 18 additions & 0 deletions src/lib/zplParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,24 @@ describe('parseZPL — ^MU units of measure', () => {
expect(commandsOf({ findings }, 'partial')).toContain('^MU');
});

it('anchors a rotated device-font field by the cell-grid extent', () => {
// I anchors at the field's cell edge: the em-box lands one cell-grid
// extent (3*48 - gap/2 = 140 for ^AG) past ^FO.
const r = parseSingle('^XA^FO300,150^AGI,60,40^FDM5i^FS^XZ', 8);
expect(defined(r.objects[0]).x).toBe(300 + 140);
const out = generateZPL({ widthMm: 100, heightMm: 50, dpmm: 8 }, r.objects, r.variables);
expect(out).toContain('^FO300,150');
});

it('keeps the ^FO anchor stable for an ^FN-bound rotated device-font field', () => {
// The wire prints the variable default, so the anchor extent must be
// measured from it, not from the «marker» the content holds in-model.
const r = parseSingle('^XA^FO300,150^AGI,60,40^FN1^FDM5i^FS^XZ', 8);
expect(defined(r.objects[0]).x).toBe(300 + 140);
const out = generateZPL({ widthMm: 100, heightMm: 50, dpmm: 8 }, r.objects, r.variables);
expect(out).toContain('^FO300,150');
});

it('round-trip: ^MUD,b,c parses + generates back symmetrically', () => {
const original = '^XA^MUD,200,600^PW600^LL400^CI28^XZ';
const { labelConfig } = parseSingle(original, 8);
Expand Down
Loading
Loading