diff --git a/packages/core/src/lib/emittedAnchor.ts b/packages/core/src/lib/emittedAnchor.ts index 2b821dc0..417468ea 100644 --- a/packages/core/src/lib/emittedAnchor.ts +++ b/packages/core/src/lib/emittedAnchor.ts @@ -18,7 +18,7 @@ export function emittedAnchorDots( box?: BoundingBoxDots, ): { x: number; y: number } { if (obj.type === "text") { - const { x, y } = textZplAnchorCoords(obj as Parameters[0], ctx.label); + const { x, y } = textZplAnchorCoords(obj as Parameters[0], ctx.label, ctx.variables); return { x, y }; } if (GRAPHIC_ANCHOR_TYPES.has(obj.type)) { diff --git a/packages/core/src/lib/labelGeometry/deviceFonts.ts b/packages/core/src/lib/labelGeometry/deviceFonts.ts index b5236654..99d717b4 100644 --- a/packages/core/src/lib/labelGeometry/deviceFonts.ts +++ b/packages/core/src/lib/labelGeometry/deviceFonts.ts @@ -67,6 +67,41 @@ 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. */ @@ -74,10 +109,8 @@ 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, @@ -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)) * diff --git a/packages/core/src/lib/labelGeometry/textRenderMetrics.ts b/packages/core/src/lib/labelGeometry/textRenderMetrics.ts index 9402d30d..3d64ce68 100644 --- a/packages/core/src/lib/labelGeometry/textRenderMetrics.ts +++ b/packages/core/src/lib/labelGeometry/textRenderMetrics.ts @@ -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"; @@ -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( diff --git a/packages/core/src/lib/objectBounds.ts b/packages/core/src/lib/objectBounds.ts index 0823a698..91455704 100644 --- a/packages/core/src/lib/objectBounds.ts +++ b/packages/core/src/lib/objectBounds.ts @@ -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"; @@ -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. */ diff --git a/packages/core/src/lib/zplParser/flushField.ts b/packages/core/src/lib/zplParser/flushField.ts index a9389bdc..9a4543fe 100644 --- a/packages/core/src/lib/zplParser/flushField.ts +++ b/packages/core/src/lib/zplParser/flushField.ts @@ -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"; @@ -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 @@ -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, diff --git a/packages/core/src/registry/text.ts b/packages/core/src/registry/text.ts index 2d080390..9525fed1 100644 --- a/packages/core/src/registry/text.ts +++ b/packages/core/src/registry/text.ts @@ -207,7 +207,7 @@ export const text: ObjectTypeCore = { // 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); @@ -233,7 +233,7 @@ export const text: ObjectTypeCore = { 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 diff --git a/packages/core/src/registry/zplHelpers.ts b/packages/core/src/registry/zplHelpers.ts index f154e8c4..601c2fd3 100644 --- a/packages/core/src/registry/zplHelpers.ts +++ b/packages/core/src/registry/zplHelpers.ts @@ -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"; @@ -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; @@ -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, ); @@ -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}`; diff --git a/packages/mcp-server/src/tools.test.ts b/packages/mcp-server/src/tools.test.ts index d8417efc..530c503e 100644 --- a/packages/mcp-server/src/tools.test.ts +++ b/packages/mcp-server/src/tools.test.ts @@ -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); diff --git a/packages/mcp-server/src/tools.ts b/packages/mcp-server/src/tools.ts index 025e5eb7..24b299c6 100644 --- a/packages/mcp-server/src/tools.ts +++ b/packages/mcp-server/src/tools.ts @@ -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, @@ -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), }; }); diff --git a/src/components/Canvas/LabelCanvas.tsx b/src/components/Canvas/LabelCanvas.tsx index c8648889..57e55181 100644 --- a/src/components/Canvas/LabelCanvas.tsx +++ b/src/components/Canvas/LabelCanvas.tsx @@ -613,7 +613,7 @@ export const LabelCanvas = forwardRef(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 diff --git a/src/lib/labelGeometry/deviceFonts.test.ts b/src/lib/labelGeometry/deviceFonts.test.ts index 601a6f75..a52e67ec 100644 --- a/src/lib/labelGeometry/deviceFonts.test.ts +++ b/src/lib/labelGeometry/deviceFonts.test.ts @@ -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", () => { @@ -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(); + }); +}); diff --git a/src/lib/zplParser.test.ts b/src/lib/zplParser.test.ts index f83acbc1..338dea87 100644 --- a/src/lib/zplParser.test.ts +++ b/src/lib/zplParser.test.ts @@ -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); diff --git a/src/test/deviceFontBoxMatch.test.ts b/src/test/deviceFontBoxMatch.test.ts index d2595853..221294fa 100644 --- a/src/test/deviceFontBoxMatch.test.ts +++ b/src/test/deviceFontBoxMatch.test.ts @@ -3,7 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { GlobalFonts } from '@napi-rs/canvas'; import { PNG } from 'pngjs'; -import { deviceFontMetrics } from '@zplab/core/lib/labelGeometry/deviceFonts'; +import { deviceFontMetrics, deviceFontInkWidthDots } from '@zplab/core/lib/labelGeometry/deviceFonts'; import { zplAnchorToModel } from '@zplab/core/lib/labelGeometry/textPositionTransforms'; import { builtinFontFamily } from '@zplab/core/lib/customFonts'; import { deviceFontBoxMatchCases } from '../../tests/fixtures/deviceFontBoxMatchCases'; @@ -74,25 +74,33 @@ describe('Device font box-match: substitutes vs. Labelary bitmap fonts', () => { if (!metrics) return; const face = faceFor(tc.fontId); + const inkWidth = + deviceFontInkWidthDots(tc.fontId, tc.fontHeight, tc.fontWidth, tc.text) ?? 0; const model = zplAnchorToModel( tc.x, tc.y, { fontHeight: tc.fontHeight, rotation: tc.rotation, fontId: tc.fontId }, 'FO', + inkWidth, ); - const drawX = model.x + metrics.xOffsetDots; - const drawY = model.y + metrics.yOffsetDots; const { canvas, ctx } = inkCanvas(); + // Konva rotates the node about its position; the device nudges are + // node-local, so they apply inside the rotated frame. + const deg = { N: 0, I: 180, B: 270 }[tc.rotation]; + ctx.save(); + ctx.translate(model.x, model.y); + ctx.rotate((deg * Math.PI) / 180); drawKonvaText(ctx, { text: tc.text, - x: drawX, - y: drawY, + x: metrics.xOffsetDots, + y: metrics.yOffsetDots, fontSizePx: metrics.fontSizeDots, fontFamily: face.family, scaleX: metrics.scaleX, letterSpacingPx: metrics.letterSpacingDots, }); + ctx.restore(); const localPng = PNG.sync.read(canvas.toBuffer('image/png')); const labelaryPng = PNG.sync.read( diff --git a/tests/fixtures/deviceFontBoxMatchCases.ts b/tests/fixtures/deviceFontBoxMatchCases.ts index de0fece7..adc05895 100644 --- a/tests/fixtures/deviceFontBoxMatchCases.ts +++ b/tests/fixtures/deviceFontBoxMatchCases.ts @@ -11,7 +11,7 @@ export interface DeviceFontBoxMatchCase { /** Width parameter of `^A{id},h,w`. 0 = derive from height. */ fontWidth: number; text: string; - rotation: 'N'; + rotation: 'N' | 'I' | 'B'; x: number; y: number; } @@ -89,8 +89,29 @@ const sweepCases: DeviceFontBoxMatchCase[] = FONT_PLANS.flatMap((plan) => })), ); +/** Rotated fields anchor at the cell edge and extend along the reading + * direction, so their position exercises the deterministic cell-grid + * extent (deviceFontInkWidthDots) end to end. */ +const rotatedCases: DeviceFontBoxMatchCase[] = ['A', 'E', 'G'].flatMap((fontId) => { + const plan = FONT_PLANS.find((f) => f.fontId === fontId); + if (!plan) throw new Error(`no plan for ${fontId}`); + return (['I', 'B'] as const).flatMap((rotation) => + [1, 2].map((mag) => ({ + id: `f${fontId}_rot${rotation}_m${mag}`, + fontId, + fontHeight: plan.magStep * mag, + fontWidth: 0, + text: 'M5i', + rotation, + x: 300, + y: 150, + })), + ); +}); + export const deviceFontBoxMatchCases: DeviceFontBoxMatchCase[] = [ ...magCases, ...snapCases, ...sweepCases, + ...rotatedCases, ]; diff --git a/tests/fixtures/labelary_devicefont_images/fA_rotB_m1.png b/tests/fixtures/labelary_devicefont_images/fA_rotB_m1.png new file mode 100644 index 00000000..1eb750b8 Binary files /dev/null and b/tests/fixtures/labelary_devicefont_images/fA_rotB_m1.png differ diff --git a/tests/fixtures/labelary_devicefont_images/fA_rotB_m2.png b/tests/fixtures/labelary_devicefont_images/fA_rotB_m2.png new file mode 100644 index 00000000..94176c4d Binary files /dev/null and b/tests/fixtures/labelary_devicefont_images/fA_rotB_m2.png differ diff --git a/tests/fixtures/labelary_devicefont_images/fA_rotI_m1.png b/tests/fixtures/labelary_devicefont_images/fA_rotI_m1.png new file mode 100644 index 00000000..2a98de24 Binary files /dev/null and b/tests/fixtures/labelary_devicefont_images/fA_rotI_m1.png differ diff --git a/tests/fixtures/labelary_devicefont_images/fA_rotI_m2.png b/tests/fixtures/labelary_devicefont_images/fA_rotI_m2.png new file mode 100644 index 00000000..f74e0b6f Binary files /dev/null and b/tests/fixtures/labelary_devicefont_images/fA_rotI_m2.png differ diff --git a/tests/fixtures/labelary_devicefont_images/fE_rotB_m1.png b/tests/fixtures/labelary_devicefont_images/fE_rotB_m1.png new file mode 100644 index 00000000..55e52403 Binary files /dev/null and b/tests/fixtures/labelary_devicefont_images/fE_rotB_m1.png differ diff --git a/tests/fixtures/labelary_devicefont_images/fE_rotB_m2.png b/tests/fixtures/labelary_devicefont_images/fE_rotB_m2.png new file mode 100644 index 00000000..346078d6 Binary files /dev/null and b/tests/fixtures/labelary_devicefont_images/fE_rotB_m2.png differ diff --git a/tests/fixtures/labelary_devicefont_images/fE_rotI_m1.png b/tests/fixtures/labelary_devicefont_images/fE_rotI_m1.png new file mode 100644 index 00000000..cee3f103 Binary files /dev/null and b/tests/fixtures/labelary_devicefont_images/fE_rotI_m1.png differ diff --git a/tests/fixtures/labelary_devicefont_images/fE_rotI_m2.png b/tests/fixtures/labelary_devicefont_images/fE_rotI_m2.png new file mode 100644 index 00000000..3208ea7b Binary files /dev/null and b/tests/fixtures/labelary_devicefont_images/fE_rotI_m2.png differ diff --git a/tests/fixtures/labelary_devicefont_images/fG_rotB_m1.png b/tests/fixtures/labelary_devicefont_images/fG_rotB_m1.png new file mode 100644 index 00000000..76fede75 Binary files /dev/null and b/tests/fixtures/labelary_devicefont_images/fG_rotB_m1.png differ diff --git a/tests/fixtures/labelary_devicefont_images/fG_rotB_m2.png b/tests/fixtures/labelary_devicefont_images/fG_rotB_m2.png new file mode 100644 index 00000000..41647708 Binary files /dev/null and b/tests/fixtures/labelary_devicefont_images/fG_rotB_m2.png differ diff --git a/tests/fixtures/labelary_devicefont_images/fG_rotI_m1.png b/tests/fixtures/labelary_devicefont_images/fG_rotI_m1.png new file mode 100644 index 00000000..95bec246 Binary files /dev/null and b/tests/fixtures/labelary_devicefont_images/fG_rotI_m1.png differ diff --git a/tests/fixtures/labelary_devicefont_images/fG_rotI_m2.png b/tests/fixtures/labelary_devicefont_images/fG_rotI_m2.png new file mode 100644 index 00000000..4936cfc8 Binary files /dev/null and b/tests/fixtures/labelary_devicefont_images/fG_rotI_m2.png differ