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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,6 @@ CLAUDE.md
# playwright
test-results/
playwright-report/

# Local font build tooling, kept out of the repo on purpose.
scripts/fonts/
12 changes: 6 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@
"@tauri-apps/cli": "^2.11.4",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.3",
"@testing-library/user-event": "^14.6.1",
"@types/babel__core": "^7.20.5",
"@types/node": "^26.2.0",
"@types/node": "^26.1.2",
"@types/papaparse": "^5.5.2",
"@types/pixelmatch": "^5.2.6",
"@types/pngjs": "^6.0.5",
Expand All @@ -70,18 +70,18 @@
"@vitejs/plugin-react": "^6.0.5",
"@vitest/coverage-v8": "^4.1.10",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^10.8.1",
"eslint": "^10.8.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.9.0",
"globals": "^17.8.0",
"jsdom": "^30.0.1",
"pixelmatch": "^7.2.0",
"pngjs": "^7.0.0",
"spdx-expression-parse": "^5.0.0",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.3",
"typescript-eslint": "^8.66.0",
"vite": "^8.2.1",
"typescript-eslint": "^8.65.0",
"vite": "^8.2.0",
"vitest": "^4.1.10"
}
}
20 changes: 20 additions & 0 deletions packages/core/src/lib/customFonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,26 @@ export function builtinFontFamily(fontId: string | undefined): string | undefine
return fontId ? BUILTIN_FONT_FAMILY[fontId] : undefined;
}

export type DeviceFontLabel = Pick<LabelConfig, "customFonts" | "defaultFontId">;

/** Font id whose bitmap cell grid governs a text field, or undefined for a
* scalable face (^A@ TTF, or a ^CW upload aliasing the id). Single resolver
* for render metrics and both anchor boundaries, so they can never disagree
* and anchors round-trip byte-exact. */
export function resolveDeviceFontId(
fieldFontId: string | undefined,
printerFontName: string | undefined,
label: DeviceFontLabel,
): string | undefined {
if (!fieldFontId && printerFontName) return undefined;
const eff = fieldFontId ?? label.defaultFontId;
if (!eff) return undefined;
// Only a usable mapping shadows a built-in: FontManager's manual rows
// start as { alias, path: '' } and the export still emits the built-in
// ^A font for those, so preview and anchor must keep it too.
return resolvePreviewFontName(label, eff) !== undefined ? undefined : eff;
}

/** Canvas-only preview TTF name; emit/parse ignore this resolver. */
export function resolvePreviewFontName(
label: Pick<LabelConfig, "customFonts">,
Expand Down
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]);
const { x, y } = textZplAnchorCoords(obj as Parameters<typeof textZplAnchorCoords>[0], ctx.label);
return { x, y };
}
if (GRAPHIC_ANCHOR_TYPES.has(obj.type)) {
Expand Down
44 changes: 35 additions & 9 deletions packages/core/src/lib/labelGeometry/deviceFonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@ interface DeviceFontSpec {
capInkPerMag: number; // visible cap-ink height per magnification (Labelary)
capPerEm: number; // substitute font cap-ink per em
advPerEm: number; // substitute font advance per em
// Canvas position trims vs Labelary, as fraction of fontSizeDots. The
// Font-0 em-anchor lands the substitute's cap-top slightly off; these
// nudge it back (both positive = down / right). Labelary-calibrated per font.
// Labelary-calibrated canvas trims: the Font-0 em-anchor lands the
// substitute's cap-top slightly off; these nudge it back (positive =
// down / right). yOffEm is a fraction of fontSizeDots; xOffEm also scales
// with scaleX because it cancels the substitute glyph's left side bearing,
// which is horizontal geometry.
yOffEm?: number;
xOffEm?: number;
// Glyph-shape width multiplier on scaleX: matches a single glyph's width to
Expand All @@ -41,19 +43,43 @@ interface DeviceFontSpec {
const MAX_MAG = 10;

const DEVICE_FONTS: Record<string, DeviceFontSpec> = {
A: { magStep: 9, magWidthStep: 5, advancePerMag: 6, capInkPerMag: 7.125, capPerEm: 0.757, advPerEm: 0.602, yOffEm: 0.096, xOffEm: -0.032, widthCorr: 0.964, letterSpacingEm: 0.021 },
A: { magStep: 9, magWidthStep: 5, advancePerMag: 6, capInkPerMag: 7.125, capPerEm: 0.757, advPerEm: 0.602, yOffEm: 0.036, xOffEm: -0.032, widthCorr: 0.964, letterSpacingEm: 0.021 },
B: { magStep: 11, magWidthStep: 7, advancePerMag: 9, capInkPerMag: 11.5, capPerEm: 0.762, advPerEm: 0.602, xOffEm: -0.040 },
C: { magStep: 18, magWidthStep: 10, advancePerMag: 12, capInkPerMag: 14.75, capPerEm: 0.757, advPerEm: 0.602, yOffEm: 0.026, xOffEm: -0.043 },
D: { magStep: 18, magWidthStep: 10, advancePerMag: 12, capInkPerMag: 14.75, capPerEm: 0.757, advPerEm: 0.602, yOffEm: 0.026, xOffEm: -0.043 },
E: { magStep: 28, magWidthStep: 15, advancePerMag: 20, capInkPerMag: 21.875, capPerEm: 0.787, advPerEm: 0.723, yOffEm: 0.198, xOffEm: -0.189 },
F: { magStep: 26, magWidthStep: 13, advancePerMag: 16, capInkPerMag: 22, capPerEm: 0.757, advPerEm: 0.602, yOffEm: 0.034, xOffEm: -0.052 },
G: { magStep: 60, magWidthStep: 40, advancePerMag: 48, capInkPerMag: 48, capPerEm: 0.757, advPerEm: 0.602, yOffEm: 0.055, widthCorr: 0.817, letterSpacingEm: 0.142 },
H: { magStep: 21, magWidthStep: 13, advancePerMag: 19, capInkPerMag: 21, capPerEm: 0.780, advPerEm: 0.723, yOffEm: -0.030, xOffEm: -0.193 },
E: { magStep: 28, magWidthStep: 15, advancePerMag: 20, capInkPerMag: 21.875, capPerEm: 0.787, advPerEm: 0.723, yOffEm: 0.198, xOffEm: -0.114 },
F: { magStep: 26, magWidthStep: 13, advancePerMag: 16, capInkPerMag: 22, capPerEm: 0.757, advPerEm: 0.602, yOffEm: 0.034, xOffEm: -0.060 },
G: { magStep: 60, magWidthStep: 40, advancePerMag: 48, capInkPerMag: 49, capPerEm: 0.757, advPerEm: 0.602, yOffEm: 0.025, widthCorr: 0.817, letterSpacingEm: 0.142 },
H: { magStep: 21, magWidthStep: 13, advancePerMag: 19, capInkPerMag: 21, capPerEm: 0.780, advPerEm: 0.723, yOffEm: -0.030, xOffEm: -0.117 },
};

const magnify = (requested: number, step: number) =>
Math.min(MAX_MAG, Math.max(1, Math.round(requested / step)));

/** Cell grid per device font. Exported so fixtures/tests derive their ^A
* heights from the same table the snap uses instead of copying it. */
export const DEVICE_FONT_CELLS: Readonly<
Record<string, { magStep: number; magWidthStep: number }>
> = Object.fromEntries(
Object.entries(DEVICE_FONTS).map(([id, s]) => [
id,
{ magStep: s.magStep, magWidthStep: s.magWidthStep },
]),
);

/** 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;
}

// Zebra fonts B and H (OCR-A) have no lowercase glyphs: B prints uppercase,
// H drops lowercase entirely (so "Text" -> "T"). Mirror that on the canvas.
const DEVICE_FONT_CASE: Record<string, "upper" | "stripLower"> = {
Expand Down Expand Up @@ -109,7 +135,7 @@ export function deviceFontMetrics(
fontSizeDots,
scaleX,
yOffsetDots: (spec.yOffEm ?? 0) * fontSizeDots,
xOffsetDots: (spec.xOffEm ?? 0) * fontSizeDots,
xOffsetDots: (spec.xOffEm ?? 0) * fontSizeDots * scaleX,
letterSpacingDots: (spec.letterSpacingEm ?? 0) * fontSizeDots,
};
}
12 changes: 9 additions & 3 deletions packages/core/src/lib/labelGeometry/textPositionTransforms.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
// EM-top-left <-> ZPL anchor (^FO cap-top / ^FT baseline). Applied only
// at the zplGenerator/zplParser boundary; editor interactions skip it.
import { deviceFontSnappedHeightDots } from "./deviceFonts";

interface TextLikeProps {
fontHeight: number;
rotation: "N" | "R" | "I" | "B";
/** Device font id; the pad terms use its snapped cell height. */
fontId?: string;
}

export const ZPL_FONT_HEIGHT_TO_CSS_RATIO = 1.0;
Expand All @@ -21,9 +24,12 @@ function zplAnchorDelta(
blockExtentDots = 0,
blockReadingWidthDots = 0,
): { dx: number; dy: number } {
const h = props.fontHeight / ZPL_FONT_HEIGHT_TO_CSS_RATIO;
const pad = props.fontHeight * EM_TOP_ABOVE_CAP;
const bias = props.fontHeight * RENDER_Y_BIAS;
const effHeight =
deviceFontSnappedHeightDots(props.fontId, props.fontHeight) ??
props.fontHeight;
const h = effHeight / ZPL_FONT_HEIGHT_TO_CSS_RATIO;
const pad = effHeight * EM_TOP_ABOVE_CAP;
const bias = effHeight * RENDER_Y_BIAS;
const isFT = positionType === "FT";
// FO I/B anchor at the far reading end of the field: a single line ends at
// anchor+inkWidth, but an ^FB block ends at anchor+blockWidth (the frame).
Expand Down
15 changes: 5 additions & 10 deletions packages/core/src/lib/labelGeometry/textRenderMetrics.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { builtinFontFamily, resolvePreviewFontName } from "../customFonts";
import { builtinFontFamily, resolveDeviceFontId, resolvePreviewFontName } from "../customFonts";
import { applyDeviceFontCase, deviceFontMetrics } from "./deviceFonts";
import { getFontFamily } from "../fontCache";
import type { LabelObject } from "../../types/Group";
Expand Down Expand Up @@ -89,16 +89,11 @@ export function getTextRenderMetrics(
const defaultPrinterFontName = label
? resolvePreviewFontName(label, label.defaultFontId)
: undefined;
// Device fonts (A-H) preview as their substitute face at the snapped
// magnification; a custom upload aliased to the same id (the field's, or the
// default when the field has none) wins. Canvas-only: gated on `label` so the
// emit/parse path keeps PrintLab metrics and the round-trip inkWidth matches.
const effectiveFontId = fieldFontId ?? label?.defaultFontId;
const effectiveCustomName = label
? resolvePreviewFontName(label, effectiveFontId)
// Canvas-only: gated on `label` so the emit/parse path keeps PrintLab
// metrics and the round-trip inkWidth matches.
const deviceId = label
? resolveDeviceFontId(fieldFontId, fieldPrinterFontName, label)
: undefined;
const deviceId =
label && !effectiveCustomName ? effectiveFontId : undefined;
const fontFamilyOverride = deviceId ? builtinFontFamily(deviceId) : undefined;
const fontHeight = fontHeightOverride ?? p.fontHeight;
const device = deviceFontMetrics(deviceId, fontHeight, p.fontWidth);
Expand Down
16 changes: 15 additions & 1 deletion packages/core/src/lib/zplParser/flushField.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { decodeTbContent } from "../tbContent";
import { zplFdToModelContent } from "../gs1";
import { dataMatrixFdToGs1Content } from "../dataMatrixFd";
import { zplAnchorToModel } from "../labelGeometry/textPositionTransforms";
import { resolveDeviceFontId } from "../customFonts";
import { blockInterLineExtentDots } from "../zebraTextLayout";
import { computeTextRenderMetrics } from "../labelGeometry/textRenderMetrics";
import type { TextProps } from "../../registry/text";
Expand Down Expand Up @@ -265,10 +266,23 @@ export function createFlushField(
blockLineSpacing: s.defaults.fbSpacing,
fontHeight: s.field.textH,
});
// Same resolution as the generator so anchors round-trip.
const anchorFontId = resolveDeviceFontId(
s.field.pendingFontId,
s.field.pendingPrinterFontName,
{
customFonts: s.result.labelConfig.customFonts,
defaultFontId: s.defaults.cfFontId,
},
);
const modelPos = zplAnchorToModel(
s.field.x,
s.field.y,
{ fontHeight: s.field.textH, rotation: s.field.textRot },
{
fontHeight: s.field.textH,
rotation: s.field.textRot,
fontId: anchorFontId,
},
posType,
inkWidthDots,
blockExtentDots,
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)}${resolveFontCmd(p, ctx)}${serialFieldData(p.content, p.serial)}`;
return `${textFieldPos(obj, ctx?.label)}${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);
const anchor = textFieldPos(obj, ctx?.label);
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
22 changes: 17 additions & 5 deletions packages/core/src/registry/zplHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ 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 { blockInterLineExtentDots } from "../lib/zebraTextLayout";
import type { LabelObject } from "../types/Group";
Expand Down Expand Up @@ -117,6 +118,8 @@ interface TextLikeObjForFieldPos extends LabelObjectBase {
props: {
fontHeight: number;
rotation: "N" | "R" | "I" | "B";
fontId?: string;
printerFontName?: string;
blockWidth?: number;
blockLines?: number;
blockLineSpacing?: number;
Expand Down Expand Up @@ -164,8 +167,13 @@ export function resolveFontCmd(
return `^A0${rotation},${fontHeight},${fontWidth}`;
}

/** Numeric ZPL anchor (cap-top/baseline) for a text-like field. */
export function textZplAnchorCoords(obj: TextLikeObjForFieldPos): {
/** 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. */
export function textZplAnchorCoords(
obj: TextLikeObjForFieldPos,
label?: DeviceFontLabel,
): {
cmd: "FO" | "FT";
x: number;
y: number;
Expand All @@ -174,10 +182,11 @@ export function textZplAnchorCoords(obj: TextLikeObjForFieldPos): {
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 a = modelToZplAnchor(
obj.x,
obj.y,
obj.props,
{ ...p, fontId: anchorFontId },
obj.positionType,
metrics?.inkWidthDots ?? 0,
blockExtentDots,
Expand All @@ -188,8 +197,11 @@ 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);
export function textFieldPos(
obj: TextLikeObjForFieldPos,
label?: DeviceFontLabel,
): string {
const a = textZplAnchorCoords(obj, label);
// 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
2 changes: 1 addition & 1 deletion packages/mcp-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@
"devDependencies": {
"esbuild": "^0.28.1",
"postject": "^1.0.0-alpha.6",
"tsx": "^4.23.11"
"tsx": "^4.19.2"
}
}
Loading
Loading