From 7f9712988473ef38e43c31332c6cb916f5341994 Mon Sep 17 00:00:00 2001 From: u8array Date: Mon, 17 Aug 2026 22:26:18 +0200 Subject: [PATCH] fix(zpl): stack device-font blocks by the snapped cell pitch Labelary-measured: ^FB/^TB line pitch uses the snapped cell height, and a ^TB block pins at the anchor with the first line laid out like ^FO (Font 0 included). --- packages/core/src/lib/anchorRepin.ts | 4 +- packages/core/src/lib/customFonts.ts | 4 +- .../core/src/lib/labelGeometry/deviceFonts.ts | 22 +++ .../labelGeometry/textPositionTransforms.ts | 6 +- .../lib/labelGeometry/textRenderMetrics.ts | 4 + packages/core/src/lib/objectBounds.ts | 2 + packages/core/src/lib/reverseBacking.ts | 68 ++++++-- packages/core/src/lib/textBlock.ts | 16 +- packages/core/src/lib/zebraTextLayout.ts | 151 ++++++++++++++++-- packages/core/src/lib/zplParser/flushField.ts | 19 ++- .../core/src/lib/zplParser/handlers/fields.ts | 15 +- packages/core/src/registry/text.ts | 46 +++--- packages/core/src/registry/textMode.ts | 21 +++ packages/core/src/registry/zplHelpers.ts | 32 ++-- packages/core/src/types/LabelConfig.ts | 4 + packages/core/src/types/ObjectType.ts | 9 ++ src/components/Canvas/KonvaObject.tsx | 38 +++-- .../Canvas/hooks/useKonvaTransformer.ts | 12 ++ .../Properties/BlockTextSettings.tsx | 14 +- src/components/Properties/TextModeSection.tsx | 21 ++- src/lib/preflight.test.ts | 16 ++ src/lib/reverseBacking.test.ts | 69 ++++++++ src/lib/textBlock.test.ts | 33 ++++ src/lib/zebraTextLayout.test.ts | 63 +++++++- src/lib/zplGenerator.test.ts | 46 ++++++ src/lib/zplParser.test.ts | 27 ++++ src/store/labelStore.internals.ts | 4 +- src/store/pageLabelSeam.test.ts | 3 + src/store/slices/objectSlice.ts | 4 +- src/test/deviceFontBoxMatch.test.ts | 50 ++++-- src/test/textBoxMatch.test.ts | 45 ++++-- tests/fixtures/deviceFontBoxMatchCases.ts | 29 ++++ .../labelary_devicefont_images/fA_fb.png | Bin 0 -> 7306 bytes .../labelary_devicefont_images/fG_fb.png | Bin 0 -> 10640 bytes .../labelary_devicefont_images/fG_fb_ft.png | Bin 0 -> 10640 bytes .../labelary_devicefont_images/fG_fb_rotI.png | Bin 0 -> 9171 bytes .../labelary_devicefont_images/fG_fb_sp10.png | Bin 0 -> 10640 bytes .../labelary_devicefont_images/fG_tb.png | Bin 0 -> 10640 bytes .../labelary_devicefont_images/fG_tb_ft.png | Bin 0 -> 10640 bytes .../labelary_devicefont_images/fG_tb_rotI.png | Bin 0 -> 6457 bytes .../h84_tb_ft.png | Bin 0 -> 12898 bytes tests/fixtures/textBoxMatchCases.ts | 12 ++ .../fetch_labelary_default_text_fixtures.ts | 38 +++-- 43 files changed, 803 insertions(+), 144 deletions(-) create mode 100644 packages/core/src/registry/textMode.ts create mode 100644 tests/fixtures/labelary_devicefont_images/fA_fb.png create mode 100644 tests/fixtures/labelary_devicefont_images/fG_fb.png create mode 100644 tests/fixtures/labelary_devicefont_images/fG_fb_ft.png create mode 100644 tests/fixtures/labelary_devicefont_images/fG_fb_rotI.png create mode 100644 tests/fixtures/labelary_devicefont_images/fG_fb_sp10.png create mode 100644 tests/fixtures/labelary_devicefont_images/fG_tb.png create mode 100644 tests/fixtures/labelary_devicefont_images/fG_tb_ft.png create mode 100644 tests/fixtures/labelary_devicefont_images/fG_tb_rotI.png create mode 100644 tests/fixtures/labelary_text_default_images/h84_tb_ft.png diff --git a/packages/core/src/lib/anchorRepin.ts b/packages/core/src/lib/anchorRepin.ts index e0047788..225d62dc 100644 --- a/packages/core/src/lib/anchorRepin.ts +++ b/packages/core/src/lib/anchorRepin.ts @@ -1,6 +1,7 @@ import type { LabelObject } from "../types/Group"; import type { ObjectChanges } from "../types/LabelObject"; import { BARCODE_1D_TYPES, getEntry } from "../registry"; +import type { NormalizeChangesCtx } from "../types/ObjectType"; import { isAxisSwapped, objectRotation } from "../registry/rotation"; import { valueAnchorShift } from "./valueAnchor"; import type { Footprint as BarcodeFootprint } from "./footprintProber"; @@ -53,9 +54,10 @@ export function applyChanges( obj: LabelObject, changes: ObjectChanges, probe: (o: LabelObject) => BarcodeFootprint | null, + ctx?: NormalizeChangesCtx, ): LabelObject { const normalize = getEntry(obj.type)?.normalizeChanges; - const normalized = normalize ? normalize(obj as never, changes as never) : changes; + const normalized = normalize ? normalize(obj as never, changes as never, ctx) : changes; const current = (obj as { props?: object }).props ?? {}; const next = { ...obj, diff --git a/packages/core/src/lib/customFonts.ts b/packages/core/src/lib/customFonts.ts index 29911dba..b1608081 100644 --- a/packages/core/src/lib/customFonts.ts +++ b/packages/core/src/lib/customFonts.ts @@ -113,7 +113,9 @@ export function builtinFontFamily(fontId: string | undefined): string | undefine return fontId ? BUILTIN_FONT_FAMILY[fontId] : undefined; } -export type DeviceFontLabel = Pick; +import type { DeviceFontLabel } from "../types/LabelConfig"; + +export type { DeviceFontLabel }; /** 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 diff --git a/packages/core/src/lib/labelGeometry/deviceFonts.ts b/packages/core/src/lib/labelGeometry/deviceFonts.ts index 99d717b4..d9aa3933 100644 --- a/packages/core/src/lib/labelGeometry/deviceFonts.ts +++ b/packages/core/src/lib/labelGeometry/deviceFonts.ts @@ -102,6 +102,17 @@ export function deviceFontInkWidthDots( return magW * (n * spec.advancePerMag - gap / 2); } +/** Snapped character-cell width (the effective ^A font width), or null + * for Font 0 / unknown ids. */ +export function deviceFontSnappedWidthDots( + fontId: string | undefined, + heightDots: number, + widthDots: number, +): number | null { + const mags = deviceFontMags(fontId, heightDots, widthDots); + return mags ? mags.magW * mags.spec.magWidthStep : null; +} + /** 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. */ @@ -113,6 +124,17 @@ export function deviceFontSnappedHeightDots( return mags ? mags.magH * mags.spec.magStep : null; } +/** The height the firmware actually uses for a text field: bitmap device + * fonts snap to their cell grid, everything else keeps the requested + * height. Feeds the anchor shift and the block line pitch, which Labelary + * both measures at the snapped cell. */ +export function effectiveFontHeightDots( + fontId: string | undefined, + heightDots: number, +): number { + return deviceFontSnappedHeightDots(fontId, heightDots) ?? heightDots; +} + // 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 = { diff --git a/packages/core/src/lib/labelGeometry/textPositionTransforms.ts b/packages/core/src/lib/labelGeometry/textPositionTransforms.ts index 5d617323..a6af83db 100644 --- a/packages/core/src/lib/labelGeometry/textPositionTransforms.ts +++ b/packages/core/src/lib/labelGeometry/textPositionTransforms.ts @@ -1,6 +1,6 @@ // 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"; +import { effectiveFontHeightDots } from "./deviceFonts"; interface TextLikeProps { fontHeight: number; @@ -24,9 +24,7 @@ function zplAnchorDelta( blockExtentDots = 0, blockReadingWidthDots = 0, ): { dx: number; dy: number } { - const effHeight = - deviceFontSnappedHeightDots(props.fontId, props.fontHeight) ?? - props.fontHeight; + const effHeight = effectiveFontHeightDots(props.fontId, props.fontHeight); const h = effHeight / ZPL_FONT_HEIGHT_TO_CSS_RATIO; const pad = effHeight * EM_TOP_ABOVE_CAP; const bias = effHeight * RENDER_Y_BIAS; diff --git a/packages/core/src/lib/labelGeometry/textRenderMetrics.ts b/packages/core/src/lib/labelGeometry/textRenderMetrics.ts index 3d64ce68..9657f51c 100644 --- a/packages/core/src/lib/labelGeometry/textRenderMetrics.ts +++ b/packages/core/src/lib/labelGeometry/textRenderMetrics.ts @@ -21,6 +21,9 @@ export interface TextRenderMetrics { xOffsetDots?: number; /** Canvas-only device-font inter-char spacing in dots (positive loosens). */ letterSpacingDots?: number; + /** Canvas-only resolved bitmap device font (A-H); single source for + * consumers that need the id beside the metrics (line pitch, bounds). */ + deviceFontId?: string; } /** Parser feeds these (no obj at parse time). */ @@ -139,5 +142,6 @@ export function getTextRenderMetrics( yOffsetDots: device?.yOffsetDots, xOffsetDots: device?.xOffsetDots, letterSpacingDots: device?.letterSpacingDots, + deviceFontId: deviceId, }; } diff --git a/packages/core/src/lib/objectBounds.ts b/packages/core/src/lib/objectBounds.ts index 91455704..46e63966 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 { resolveDeviceFontId } from "./customFonts"; import type { Variable } from "../types/Variable"; import { effectiveDpmm } from "../types/LabelConfig"; import { isAxisSwapped, objectRotation, type ZplRotation } from "../registry/rotation"; @@ -315,6 +316,7 @@ function objectBoxDots(obj: LabelObject, ctx: ObjectBoundsCtx): BoundingBoxDots blockLines: p.blockLines ?? 1, blockLineSpacing: p.blockLineSpacing ?? 0, fontHeight: p.fontHeight, + deviceFontId: resolveDeviceFontId(p.fontId, p.printerFontName, ctx.label), rotation: p.rotation, }); // bounds are field-anchor-relative (can be negative for R/I/B); diff --git a/packages/core/src/lib/reverseBacking.ts b/packages/core/src/lib/reverseBacking.ts index 85e1c673..59a1119f 100644 --- a/packages/core/src/lib/reverseBacking.ts +++ b/packages/core/src/lib/reverseBacking.ts @@ -1,5 +1,5 @@ import { getTextRenderMetrics, computeTextRenderMetrics } from "./labelGeometry/textRenderMetrics"; -import { rotatedLineOffset } from "./zebraTextLayout"; +import { blockStackHeightDots, rotatedLineOffset } from "./zebraTextLayout"; import { isAxisSwapped } from "../registry/rotation"; import { resolveTextMode, type TextProps } from "../registry/text"; import type { BoxProps } from "../registry/box"; @@ -51,6 +51,8 @@ function finiteOrUndefined(v: unknown): number | undefined { export function reverseBackingBoxGeometry( text: TextLike, label?: FontLabel, + /** rawBlockPitch reproduces the pre-snap ^FB stack (legacy backings). */ + opts?: { rawBlockPitch?: boolean }, ): { x: number; y: number; props: BoxProps } { const p = text.props; const mode = resolveTextMode(p); @@ -59,9 +61,11 @@ export function reverseBackingBoxGeometry( // would make font resolution throw; fall back to a label-independent measure // so migration degrades to best-effort instead of crashing. let inkWidthDots: number; + let deviceFontId: string | undefined; try { - inkWidthDots = - getTextRenderMetrics(text as unknown as LabelObject, undefined, label)?.inkWidthDots ?? 0; + const metrics = getTextRenderMetrics(text as unknown as LabelObject, undefined, label); + inkWidthDots = metrics?.inkWidthDots ?? 0; + deviceFontId = metrics?.deviceFontId; if (!inkWidthDots) { inkWidthDots = computeTextRenderMetrics({ content: p.content, @@ -89,7 +93,13 @@ export function reverseBackingBoxGeometry( } else if (mode === "fb") { const lines = blockLines ?? 1; baseW = blockWidth ?? inkW; - baseH = p.fontHeight * lines + blockSpacing * Math.max(0, lines - 1); + // Snapped pitch for device fonts; identical to h*lines + gaps for Font 0. + baseH = blockStackHeightDots( + lines, + p.fontHeight, + blockSpacing, + opts?.rawBlockPitch ? undefined : deviceFontId, + ); } else { baseW = inkW; baseH = p.fontHeight; @@ -153,8 +163,22 @@ interface Rect { } /** Axis-aligned footprint a backing would need to cover for this text. */ -function expectedBackingFootprint(text: TextLike, label?: FontLabel): Rect { - const geo = reverseBackingBoxGeometry(text, label); +/** Current geometry first, then the pre-snap raw pitch. Ownership and + * removal only: coverage checks stay on the current geometry so the + * migration can resize a stale backing. */ +function expectedBackingFootprints(text: TextLike, label?: FontLabel): Rect[] { + return [ + expectedBackingFootprint(text, label), + expectedBackingFootprint(text, label, { rawBlockPitch: true }), + ]; +} + +function expectedBackingFootprint( + text: TextLike, + label?: FontLabel, + opts?: { rawBlockPitch?: boolean }, +): Rect { + const geo = reverseBackingBoxGeometry(text, label, opts); return { x: geo.x, y: geo.y, width: geo.props.width, height: geo.props.height }; } @@ -205,6 +229,8 @@ export function isReverseBackingFor( if (!candidate) return false; const cf = candidateBackingFootprint(candidate); if (!cf) return false; + // Current geometry only: a raw-pitch legacy backing under-covers the + // snapped print, so the migration resizes it instead of counting it. return coverage(expectedBackingFootprint(text, label), cf) >= BACKING_COVERAGE_MIN; } @@ -220,14 +246,13 @@ export function isOwnReverseBacking( if (!candidate) return false; const cf = candidateBackingFootprint(candidate); if (!cf) return false; - const ef = expectedBackingFootprint(text, label); const near = (a: number, b: number) => Math.abs(a - b) <= Math.max(4, b * 0.05); - return ( + const matches = (ef: Rect) => Math.abs(cf.x - ef.x) <= 4 && Math.abs(cf.y - ef.y) <= 4 && near(cf.width, ef.width) && - near(cf.height, ef.height) - ); + near(cf.height, ef.height); + return expectedBackingFootprints(text, label).some(matches); } /** True when any sibling before `index` is a black shape covering the text's @@ -298,7 +323,28 @@ export function insertReverseBackingBoxes( canBuildBackingBox(props) && !precedingBackingExists(objects, i, o as unknown as TextLike, label) ) { - out.push(makeReverseBackingBox(o as unknown as TextLike, label)); + // The feature's own backing at a stale (pre-snap) geometry migrates in + // place; rebuilt from scratch because an owned legacy backing may be + // a black line, not a box. + const ownIdx = out.findLastIndex((prev) => + isOwnReverseBacking(prev, o as unknown as TextLike, label), + ); + if (ownIdx >= 0) { + const own = out[ownIdx] as LabelObject; + out[ownIdx] = { + ...makeReverseBackingBox(o as unknown as TextLike, label), + id: own.id, + ...(own.name !== undefined ? { name: own.name } : {}), + ...(own.comment !== undefined ? { comment: own.comment } : {}), + ...(own.locked !== undefined ? { locked: own.locked } : {}), + ...(own.visible !== undefined ? { visible: own.visible } : {}), + ...(own.includeInExport !== undefined + ? { includeInExport: own.includeInExport } + : {}), + }; + } else { + out.push(makeReverseBackingBox(o as unknown as TextLike, label)); + } } out.push(o); } diff --git a/packages/core/src/lib/textBlock.ts b/packages/core/src/lib/textBlock.ts index 84eca0b0..dcd3ed4e 100644 --- a/packages/core/src/lib/textBlock.ts +++ b/packages/core/src/lib/textBlock.ts @@ -1,5 +1,5 @@ import type { TextProps } from "../registry/text"; -import { wrapBlockLines, zebraLineWidthDots } from "./zebraTextLayout"; +import { blockLineWidthDots, wrapBlockLines } from "./zebraTextLayout"; /** Shared between manual Tekstblok checkbox and auto-activation. */ export const FB_DEFAULTS = { @@ -8,15 +8,19 @@ export const FB_DEFAULTS = { blockJustify: "L" as const, } satisfies Partial; -/** Activate ^FB with defaults on first newline. blockLines is the maxLines - * cap (the box height), so once a block exists it is NOT resynced to the - * content on every edit: the canvas wraps to width and soft-wrap overflow is - * surfaced as a warning instead. Explicit hard breaks may only grow the cap. */ +/** ^FB cap upkeep on content edits. blockLines is the maxLines cap (the + * box height), so once a block exists it is NOT resynced to the content + * on every edit: the canvas wraps to width and soft-wrap overflow is + * surfaced as a warning instead. Explicit hard breaks may only grow the + * cap. The no-blockWidth branch backfills defaults for imported designs + * carrying a bare textMode 'fb'; user edits never reach it (single-line + * editors reject newlines for plain text). */ export function deriveBlockTextPatch( content: string, prev: Pick, fontHeight: number, fontWidth: number, + deviceFontId?: string, ): Partial { const hardLines = content.split("\n").length; const patch: Partial = { content }; @@ -28,7 +32,7 @@ export function deriveBlockTextPatch( patch.blockLines = wrapBlockLines( content, FB_DEFAULTS.blockWidth, - (line) => zebraLineWidthDots(line, fontHeight, fontWidth), + (line) => blockLineWidthDots(line, fontHeight, fontWidth, deviceFontId), ).length; patch.blockLineSpacing = FB_DEFAULTS.blockLineSpacing; patch.blockJustify = FB_DEFAULTS.blockJustify; diff --git a/packages/core/src/lib/zebraTextLayout.ts b/packages/core/src/lib/zebraTextLayout.ts index d5fa0c80..29b5a650 100644 --- a/packages/core/src/lib/zebraTextLayout.ts +++ b/packages/core/src/lib/zebraTextLayout.ts @@ -2,6 +2,8 @@ // compute against fixed advance to match Labelary. import { dotsToPx, pxToDots } from "./coordinates"; +import { deviceFontInkWidthDots, deviceFontSnappedHeightDots, deviceFontSnappedWidthDots, effectiveFontHeightDots } from "./labelGeometry/deviceFonts"; +import { EM_TOP_ABOVE_CAP } from "./labelGeometry/textPositionTransforms"; import { isAxisSwapped, type ZplRotation } from "../registry/rotation"; export type BlockJustify = "L" | "C" | "R" | "J"; @@ -40,14 +42,48 @@ export function zebraGlyphAdvanceDots(fontHeight: number, fontWidth: number): nu return fontWidth > 0 ? fontWidth : fontHeight * A0_DEFAULT_ASPECT; } -/** ^FB slot a: spec skips print when block is narrower than one glyph - * cell (explicit `fontWidth` or `h * 5/9` for A0 default). */ +/** Line width in the basis the firmware advances: device cells for A-H, + * the Font-0 table otherwise. Shared by preflight and the block panel so + * their wrap estimate cannot diverge. */ +export function blockLineWidthDots( + line: string, + fontHeight: number, + fontWidth: number, + deviceFontId?: string, +): number { + return ( + deviceFontInkWidthDots(deviceFontId, fontHeight, fontWidth, line) ?? + zebraLineWidthDots(line, fontHeight, fontWidth) + ); +} + +/** ^FB slot a: spec (p.186) says text below the font width does not + * print; Labelary in practice wraps per char and still prints, so this + * is the shared warning threshold (preflight, panel, canvas), not a + * print guarantee. */ export function isBlockTooNarrow( blockWidthDots: number, fontHeight: number, fontWidth: number, + deviceFontId?: string, ): boolean { - return blockWidthDots > 0 && blockWidthDots < zebraGlyphAdvanceDots(fontHeight, fontWidth); + return ( + blockWidthDots > 0 && + blockWidthDots < blockGlyphCellDots(fontHeight, fontWidth, deviceFontId) + ); +} + +/** The effective font width (spec's ^FB slot-a threshold): the snapped + * cell width for device fonts, else the Font-0 advance. */ +export function blockGlyphCellDots( + fontHeight: number, + fontWidth: number, + deviceFontId?: string, +): number { + return ( + deviceFontSnappedWidthDots(deviceFontId, fontHeight, fontWidth) ?? + zebraGlyphAdvanceDots(fontHeight, fontWidth) + ); } /** Display-space positions of each word inside one justify=J line. Caller @@ -92,9 +128,45 @@ export function blockInterLineExtentDots(args: { blockLines: number; blockLineSpacing: number; fontHeight: number; + deviceFontId?: string; }): number { if (args.blockWidthDots <= 0) return 0; - return Math.max(0, (args.blockLines - 1) * blockLineStepDots(args.fontHeight, args.blockLineSpacing)); + return Math.max( + 0, + (args.blockLines - 1) * blockLineStepDots(args.fontHeight, args.blockLineSpacing, args.deviceFontId), + ); +} + +/** Anchor extent of a ^TB field beyond its first line. The firmware pins + * the block box at the anchor (bottom edge under FT) and lays the first + * line out at the block top exactly like an ^FO field, for Font 0 and + * the bitmap fonts alike (Labelary-measured). */ +export function tbBlockExtentDots( + blockHeightDots: number, + fontHeight: number, + deviceFontId?: string, +): number { + const effH = effectiveFontHeightDots(deviceFontId, fontHeight); + return Math.max(0, blockHeightDots - effH * (1 - EM_TOP_ABOVE_CAP)); +} + +/** Anchor extent of a block beyond its first line, ^TB or ^FB. The single + * tb-vs-fb dispatch shared by generator, parser and the box-match gate; + * a one-sided change here shifts anchors silently. tbHeightDots > 0 + * selects ^TB (the parser's native signal: ^TB is stream state, not a + * stored mode). */ +export function blockAnchorExtentDots(args: { + tbHeightDots: number; + blockWidthDots: number; + blockLines: number; + blockLineSpacing: number; + fontHeight: number; + deviceFontId?: string; +}): number { + if (args.tbHeightDots > 0) { + return tbBlockExtentDots(args.tbHeightDots, args.fontHeight, args.deviceFontId); + } + return blockInterLineExtentDots(args); } // ZplRotation's single source is registry/rotation; re-exported here for the @@ -252,8 +324,58 @@ export function zebraJustifyGapDots( return extra > 0 ? extra / wordGapCount : 0; } -export function blockLineStepDots(fontHeight: number, blockLineSpacing: number): number { - return fontHeight + blockLineSpacing; +/** (n-1) steps plus the last line's own cell: the trailing inter-line + * gap doesn't render. */ +const stackDots = (lines: number, stepDots: number, lastLineDots: number) => + Math.max(0, lines - 1) * stepDots + lastLineDots; + +/** Vertical span of n stacked ^FB lines. */ +export function blockStackHeightDots( + lines: number, + fontHeight: number, + blockLineSpacing: number, + deviceFontId?: string, +): number { + return stackDots( + lines, + blockLineStepDots(fontHeight, blockLineSpacing, deviceFontId), + effectiveFontHeightDots(deviceFontId, fontHeight), + ); +} + +/** Vertical span of n wrapped ^TB lines; ^TB steps by its own pitch. */ +export function tbStackHeightDots( + lines: number, + fontHeight: number, + deviceFontId?: string, +): number { + return stackDots( + lines, + tbLineStepDots(fontHeight, deviceFontId), + effectiveFontHeightDots(deviceFontId, fontHeight), + ); +} + +/** Inverse of {@link blockStackHeightDots}: how many lines a given stack + * height holds. */ +export function blockLinesForHeightDots( + heightDots: number, + fontHeight: number, + blockLineSpacing: number, + deviceFontId?: string, +): number { + const step = blockLineStepDots(fontHeight, blockLineSpacing, deviceFontId); + return Math.max(1, Math.round((heightDots + blockLineSpacing) / step)); +} + +/** Firmware line pitch: device fonts stack by the snapped cell height + * (Labelary-measured), Font 0 by the requested height. */ +export function blockLineStepDots( + fontHeight: number, + blockLineSpacing: number, + deviceFontId?: string, +): number { + return effectiveFontHeightDots(deviceFontId, fontHeight) + blockLineSpacing; } /** Width of the empty single-line text placeholder, in fontHeight multiples. @@ -272,8 +394,12 @@ export function isBlankText(content: string): boolean { * ~1.25x fontHeight (calibrated against Labelary for the default font). */ export const TB_LINE_HEIGHT_RATIO = 1.25; -export function tbLineStepDots(fontHeight: number): number { - return fontHeight * TB_LINE_HEIGHT_RATIO; +/** Device fonts wrap ^TB lines at exactly the snapped cell height + * (Labelary-measured); the ratio is the Font-0 path. */ +export function tbLineStepDots(fontHeight: number, deviceFontId?: string): number { + return ( + deviceFontSnappedHeightDots(deviceFontId, fontHeight) ?? fontHeight * TB_LINE_HEIGHT_RATIO + ); } /** FB block bbox in Group-local display coords. Rotates with the @@ -285,10 +411,13 @@ export function blockBoundsDots(args: { blockLineSpacing: number; fontHeight: number; rotation?: ZplRotation; + deviceFontId?: string; }): { x: number; y: number; width: number; height: number } { - const lineStep = blockLineStepDots(args.fontHeight, args.blockLineSpacing); const blockWidth = args.blockWidthDots; - const linesExtent = args.blockLines > 0 ? (args.blockLines - 1) * lineStep + args.fontHeight : 0; + const linesExtent = + args.blockLines > 0 + ? blockStackHeightDots(args.blockLines, args.fontHeight, args.blockLineSpacing, args.deviceFontId) + : 0; switch (args.rotation ?? "N") { case "N": return { x: 0, y: 0, width: blockWidth, height: linesExtent }; case "R": return { x: -linesExtent, y: 0, width: linesExtent, height: blockWidth }; @@ -328,6 +457,7 @@ export function blockReflowGeometry(args: { blockLines: number; blockLineSpacing: number; fontHeight: number; + deviceFontId?: string; /** Which screen edge the user is dragging; the opposite edge is pinned. */ activeLeft: boolean; activeTop: boolean; @@ -359,6 +489,7 @@ export function blockReflowGeometry(args: { blockLines, blockLineSpacing: args.blockLineSpacing, fontHeight: args.fontHeight, + deviceFontId: args.deviceFontId, rotation: args.rotation, }); const bxPx = dotsToPx(b.x, args.scale, args.dpmm); diff --git a/packages/core/src/lib/zplParser/flushField.ts b/packages/core/src/lib/zplParser/flushField.ts index 9a4543fe..bf370e5e 100644 --- a/packages/core/src/lib/zplParser/flushField.ts +++ b/packages/core/src/lib/zplParser/flushField.ts @@ -19,8 +19,8 @@ import { decodeTbContent } from "../tbContent"; import { zplFdToModelContent } from "../gs1"; import { dataMatrixFdToGs1Content } from "../dataMatrixFd"; import { zplAnchorToModel } from "../labelGeometry/textPositionTransforms"; +import { blockAnchorExtentDots } from "../zebraTextLayout"; import { resolveDeviceFontId } from "../customFonts"; -import { blockInterLineExtentDots } from "../zebraTextLayout"; import { anchorInkWidthDots } from "../labelGeometry/textRenderMetrics"; import type { TextProps } from "../../registry/text"; import type { Code128Props } from "../../registry/code128"; @@ -246,15 +246,6 @@ export function createFlushField( // 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 // round-trips and matching WYSIWYG. - const blockExtentDots = - s.defaults.tbHeight > 0 - ? Math.max(0, s.defaults.tbHeight - s.field.textH) - : blockInterLineExtentDots({ - blockWidthDots: s.defaults.fbWidth, - blockLines: s.defaults.fbLines, - blockLineSpacing: s.defaults.fbSpacing, - fontHeight: s.field.textH, - }); // Same resolution as the generator so anchors round-trip. const anchorFontId = resolveDeviceFontId( s.field.pendingFontId, @@ -264,6 +255,14 @@ export function createFlushField( defaultFontId: s.defaults.cfFontId, }, ); + const blockExtentDots = blockAnchorExtentDots({ + tbHeightDots: s.defaults.tbHeight, + blockWidthDots: s.defaults.fbWidth, + blockLines: s.defaults.fbLines, + blockLineSpacing: s.defaults.fbSpacing, + fontHeight: s.field.textH, + deviceFontId: anchorFontId, + }); // 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. diff --git a/packages/core/src/lib/zplParser/handlers/fields.ts b/packages/core/src/lib/zplParser/handlers/fields.ts index e44ad141..595203a6 100644 --- a/packages/core/src/lib/zplParser/handlers/fields.ts +++ b/packages/core/src/lib/zplParser/handlers/fields.ts @@ -334,12 +334,21 @@ export function createFieldHandlers( // clip height) so it round-trips as ^TB, unlike the prior lossy collapse // to ^FB lines. Justify/spacing/indent are not ^TB params. TB(p, rest) { + // ^TB always redefines the field as text, but has no font params: a + // preceding ^A donates height/width/rotation (spec p.356); anything + // else falls back to the ^CF/^FW defaults. + const hadTextFont = s.field.fieldType === "text"; s.field.fieldType = "text"; - s.field.textRot = readRotation(rest[0], s.defaults.fwRotation); + if (!hadTextFont) { + s.field.textH = getDefaultTextH(s.defaults); + s.field.textW = getDefaultTextW(s.defaults); + } + s.field.textRot = readRotation( + rest[0], + hadTextFont ? s.field.textRot : s.defaults.fwRotation, + ); const tbW = dots(p[1]); const tbH = dots(p[2]); - s.field.textH = getDefaultTextH(s.defaults); - s.field.textW = getDefaultTextW(s.defaults); // Spec defaults width/height to 1 dot; clamp (never drop) so even a bare // ^TB round-trips as ^TB instead of silently degrading to plain text. s.defaults.fbWidth = tbW > 0 ? tbW : 1; diff --git a/packages/core/src/registry/text.ts b/packages/core/src/registry/text.ts index 9525fed1..0ba3f406 100644 --- a/packages/core/src/registry/text.ts +++ b/packages/core/src/registry/text.ts @@ -6,27 +6,17 @@ import { encodeTbContent } from "../lib/tbContent"; import { serialFieldData, type SerialMode } from "./serialField"; import { deriveBlockTextPatch } from "../lib/textBlock"; import { + blockLineWidthDots, isBlockTooNarrow, wrapBlockLines, - zebraLineWidthDots, - tbLineStepDots, + tbStackHeightDots, type ZplRotation, } from "../lib/zebraTextLayout"; -/** Text layout mode. 'normal' = plain ^A (no wrap), 'fb' = ^FB field - * block (max-lines cap, justify, hanging indent), 'tb' = ^TB text block - * (word-wrap clipped at a pixel height). Only 'tb' is stored explicitly; - * 'normal' vs 'fb' is inferred from blockWidth presence so legacy designs - * and ^FB imports keep working. Read it through `resolveTextMode`. */ -export type TextMode = "normal" | "fb" | "tb"; +import { resolveTextMode, type TextMode } from "./textMode"; +import { resolveDeviceFontId } from "../lib/customFonts"; -export function resolveTextMode(p: Pick): TextMode { - // Serial is a plain single-line counter (^A + ^SN/^SF); block props lie - // dormant while it is active, so the mode resolves to 'normal' regardless. - if (p.serial) return "normal"; - if (p.textMode) return p.textMode; - return p.blockWidth ? "fb" : "normal"; -} +export { resolveTextMode, type TextMode }; /** Whether the field's content may contain line breaks. Only text in a block * mode (^FB/^TB) wraps to multiple lines; plain text (^A) and every barcode @@ -118,23 +108,24 @@ export const text: ObjectTypeCore = { // glyph cell prints nothing (error), and content that wraps past the line cap // (^FB) or block height (^TB) is clipped (overset warning). Mirrors the wrap // the panel/canvas already use (Font-0 metrics), so the badge matches them. - preflight: (obj) => { + preflight: (obj, ctx) => { const p = obj.props; const mode = resolveTextMode(p); if (mode === "normal") return []; - if (isBlockTooNarrow(p.blockWidth ?? 0, p.fontHeight, p.fontWidth ?? 0)) { + const deviceFontId = resolveDeviceFontId(p.fontId, p.printerFontName, ctx.label); + const lineWidth = (line: string) => + blockLineWidthDots(line, p.fontHeight, p.fontWidth ?? 0, deviceFontId); + if (isBlockTooNarrow(p.blockWidth ?? 0, p.fontHeight, p.fontWidth ?? 0, deviceFontId)) { return [{ kind: "blockTooNarrow" }]; } // ^FB honours newlines as hard breaks; ^TB collapses them to spaces (mirrors // encodeTbContent), so wrap the collapsed form there to avoid false overset. const content = mode === "tb" ? (p.content ?? "").replace(/\n/g, " ") : (p.content ?? ""); - const lines = wrapBlockLines(content, p.blockWidth ?? 0, (line) => - zebraLineWidthDots(line, p.fontHeight, p.fontWidth ?? 0), - ); + const lines = wrapBlockLines(content, p.blockWidth ?? 0, lineWidth); // ^TB clips at blockHeight; N lines occupy (N-1) steps plus the last line's // own height, not N steps (the trailing 0.25 line-gap doesn't render). const tbHeight = (p.blockHeight ?? 0) > 0 - ? (lines.length - 1) * tbLineStepDots(p.fontHeight) + p.fontHeight + ? tbStackHeightDots(lines.length, p.fontHeight, deviceFontId) : 0; const overset = mode === "fb" @@ -150,7 +141,7 @@ export const text: ObjectTypeCore = { // rotations so the user's screen-vertical drag stays attached to // fontHeight regardless of how Konva orients the glyphs. // Canonical un-emit shape so round-trips stay diff-free. - normalizeChanges: (_obj, changes) => { + normalizeChanges: (_obj, changes, ctx) => { const nextProps = changes.props as Partial | undefined; if (!nextProps) return changes; let patched = nextProps; @@ -167,9 +158,16 @@ export const text: ObjectTypeCore = { { blockWidth: merged.blockWidth, blockLines: merged.blockLines }, merged.fontHeight, merged.fontWidth, + resolveDeviceFontId(merged.fontId, merged.printerFontName, ctx?.label ?? {}), + ); + // Take every field the patch changes (the import-shape activation + // backfills width/spacing/justify too, not just the line cap). + const { content: _grownContent, ...grownRest } = grown; + const changedEntries = Object.entries(grownRest).filter( + ([k, v]) => (merged as Record)[k] !== v, ); - if (grown.blockLines !== undefined && grown.blockLines !== merged.blockLines) { - patched = { ...patched, blockLines: grown.blockLines }; + if (changedEntries.length > 0) { + patched = { ...patched, ...Object.fromEntries(changedEntries) }; } } } diff --git a/packages/core/src/registry/textMode.ts b/packages/core/src/registry/textMode.ts new file mode 100644 index 00000000..bfe9cad2 --- /dev/null +++ b/packages/core/src/registry/textMode.ts @@ -0,0 +1,21 @@ +/** Text layout mode. 'normal' = plain ^A (no wrap), 'fb' = ^FB field + * block (max-lines cap, justify, hanging indent), 'tb' = ^TB text block + * (word-wrap clipped at a pixel height). Only 'tb' is stored explicitly; + * 'normal' vs 'fb' is inferred from blockWidth presence so legacy designs + * and ^FB imports keep working. Read it through `resolveTextMode`. + * + * Own leaf module (not text.ts) so emit helpers can consult the mode + * without a registry cycle. */ +export type TextMode = "normal" | "fb" | "tb"; + +export function resolveTextMode(p: { + textMode?: TextMode; + blockWidth?: number; + serial?: object; +}): TextMode { + // Serial is a plain single-line counter (^A + ^SN/^SF); block props lie + // dormant while it is active, so the mode resolves to 'normal' regardless. + if (p.serial) return "normal"; + if (p.textMode) return p.textMode; + return p.blockWidth ? "fb" : "normal"; +} diff --git a/packages/core/src/registry/zplHelpers.ts b/packages/core/src/registry/zplHelpers.ts index 601c2fd3..7cd34051 100644 --- a/packages/core/src/registry/zplHelpers.ts +++ b/packages/core/src/registry/zplHelpers.ts @@ -7,11 +7,12 @@ import { hasClockMarkers, markersToTokens } from "../lib/fcTemplate"; import { hasControlMarkers, resolveControlMarkers } from "../types/controlKey"; import { classifyField } from "../lib/variableField"; import { modelToZplAnchor } from "../lib/labelGeometry/textPositionTransforms"; +import { blockAnchorExtentDots } from "../lib/zebraTextLayout"; import { resolveDeviceFontId, type DeviceFontLabel } from "../lib/customFonts"; import { anchorInkWidthDots } from "../lib/labelGeometry/textRenderMetrics"; -import { blockInterLineExtentDots } from "../lib/zebraTextLayout"; import type { LabelObject } from "../types/Group"; import { objectRotation } from "./rotation"; +import { resolveTextMode, type TextMode } from "./textMode"; import { measureFootprintDots } from "../lib/footprintProber"; /** Emit `^FT` or `^FO` depending on how the object was originally positioned. @@ -126,22 +127,31 @@ interface TextLikeObjForFieldPos extends LabelObjectBase { blockWidth?: number; blockLines?: number; blockLineSpacing?: number; - textMode?: "normal" | "fb" | "tb"; + textMode?: TextMode; blockHeight?: number; + serial?: object; }; } /** Vertical extent of a block beyond its first line, in dots. Shifts the * FT baseline / FO-R/I anchor. ^TB is a fixed clip height; ^FB stacks lines. */ -function blockExtentFor(p: TextLikeObjForFieldPos["props"]): number { - if (p.textMode === "tb") { - return Math.max(0, (p.blockHeight ?? p.fontHeight) - p.fontHeight); - } - return blockInterLineExtentDots({ +function blockExtentFor( + p: TextLikeObjForFieldPos["props"], + mode: TextMode, + deviceFontId?: string, +): number { + if (mode === "normal") return 0; + // blockHeight 0 falls back to the font height, mirroring the parser's + // ^TB clamp. + const tbHeightDots = + mode === "tb" ? (p.blockHeight && p.blockHeight > 0 ? p.blockHeight : p.fontHeight) : 0; + return blockAnchorExtentDots({ + tbHeightDots, blockWidthDots: p.blockWidth ?? 0, blockLines: p.blockLines ?? 1, blockLineSpacing: p.blockLineSpacing ?? 0, fontHeight: p.fontHeight, + deviceFontId, }); } @@ -185,8 +195,12 @@ export function textZplAnchorCoords( } { const cmd = obj.positionType === "FT" ? "FT" : "FO"; const p = obj.props; - const blockExtentDots = blockExtentFor(p); const anchorFontId = resolveDeviceFontId(p.fontId, p.printerFontName, label ?? {}); + // Serial suppresses the block on emit, so dormant block props must not + // shift the anchor either (extent nor reading width). + const mode = resolveTextMode(p); + const blockExtentDots = blockExtentFor(p, mode, anchorFontId); + const blockReadingWidthDots = mode === "normal" ? 0 : (p.blockWidth ?? 0); const cls = variables ? classifyField(p.content, variables) : undefined; const anchorContent = cls?.kind === "single" ? cls.variable.defaultValue : p.content; const inkWidthDots = anchorInkWidthDots({ @@ -203,7 +217,7 @@ export function textZplAnchorCoords( obj.positionType, inkWidthDots, blockExtentDots, - p.blockWidth ?? 0, + blockReadingWidthDots, ); // ^FO/^FT take integers; firmware would truncate fractional residue anyway. return { cmd, x: Math.round(a.x), y: Math.round(a.y) }; diff --git a/packages/core/src/types/LabelConfig.ts b/packages/core/src/types/LabelConfig.ts index ec11f47c..35eef2aa 100644 --- a/packages/core/src/types/LabelConfig.ts +++ b/packages/core/src/types/LabelConfig.ts @@ -320,6 +320,10 @@ declare const pageResolved: unique symbol; * still accepts a design label unchecked. */ export type PageLabel = LabelConfig & { readonly [pageResolved]: true }; +/** The label slice device-font resolution reads (^CF default + ^CW-style + * custom aliases); design-scoped, so a design label is always valid here. */ +export type DeviceFontLabel = Pick; + /** Assert a label is already page-resolved, for the two spots the type cannot * prove it: an absent override (design label == page label) and a public entry * whose caller owns the page scope. Every use must say which at the call site. */ diff --git a/packages/core/src/types/ObjectType.ts b/packages/core/src/types/ObjectType.ts index b4177046..50e3a0a7 100644 --- a/packages/core/src/types/ObjectType.ts +++ b/packages/core/src/types/ObjectType.ts @@ -2,6 +2,14 @@ import type { LabelObjectBase, ObjectChanges, ObjectGroup } from './LabelObject' import type { ContentSpec } from './contentSpec'; import type { HriBehavior, TransformContext, ZplEmitContext } from './ZplEmit'; import type { PreflightCtx, PreflightProducerResult } from './preflight'; +import type { DeviceFontLabel } from './LabelConfig'; + + +/** Same label slice preflight gets; lets normalizeChanges resolve the + * effective device font (^CF default) where it needs one. */ +export interface NormalizeChangesCtx { + label?: DeviceFontLabel; +} /** Domain half of a registry entry: emits ZPL, no React deps. */ export interface ObjectTypeCore

{ @@ -97,6 +105,7 @@ export interface ObjectTypeCore

{ normalizeChanges?: ( obj: LabelObjectBase & { props: P }, changes: ObjectChanges, + ctx?: NormalizeChangesCtx, ) => ObjectChanges; /** Pure hook: maps Konva Transformer scale to prop changes on transform end. */ commitTransform?: ( diff --git a/src/components/Canvas/KonvaObject.tsx b/src/components/Canvas/KonvaObject.tsx index 097daebb..96a5f468 100644 --- a/src/components/Canvas/KonvaObject.tsx +++ b/src/components/Canvas/KonvaObject.tsx @@ -156,8 +156,8 @@ function TextFieldContent({ scale, dpmm, fontVersion, - placeholderColor, emptyColor, + deviceFontId, isSelected = false, }: { obj: TextFieldObj; @@ -166,7 +166,8 @@ function TextFieldContent({ scale: number; dpmm: number; fontVersion: number; - placeholderColor: string; + /** Resolved bitmap device font (A-H); drives the snapped line pitch. */ + deviceFontId?: string; /** Empty-field placeholder stroke: warning orange once the untouched grace * ended, the affordance accent while pristine or on the drop ghost. */ emptyColor: string; @@ -202,19 +203,18 @@ function TextFieldContent({ // that table, so measure the rendered glyphs instead. const isDefaultFont0 = fontWidth === 0 && Math.abs((base.scaleX ?? 1) - 1) < 1e-3; + // Mirrors computeTextRenderMetrics: letter spacing is part of the rendered + // advance, so wrap/align/overset must measure it too (font G carries a + // large calibrated spacing). const measureLinePx = (s: string) => isDefaultFont0 ? dotsToPx(zebraLineWidthDots(s, fontHeight, fontWidth), scale, dpmm) - : measureInkWidthPx(s, base.fontSize, base.fontFamily, base.fontStyle) * + : (measureInkWidthPx(s, base.fontSize, base.fontFamily, base.fontStyle) + + (base.letterSpacing ?? 0) * Math.max(0, s.length - 1)) * (base.scaleX ?? 1); - // Skip the field only when not even one rendered char fits (Labelary wraps to - // a single char). Soft hyphens are dropped by the wrap, so never gate on one. - const firstChar = content.replace(/\u00AD/g, "").trim()[0] ?? ""; - const tooNarrow = - firstChar !== "" && - dotsToPx(blockWidth, scale, dpmm) < measureLinePx(firstChar); - const emptyContent = isBlankText(content); - if (tooNarrow || emptyContent) { + // Labelary keeps printing below the too-narrow threshold (per-char wrap), + // so a narrow block renders like it prints; the warning is preflight's job. + if (isBlankText(content)) { const bounds = mode === "tb" ? tbBoundsDots(blockWidth, blockHeight ?? fontHeight, obj.props.rotation) @@ -223,6 +223,7 @@ function TextFieldContent({ blockLines: blockLines ?? 1, blockLineSpacing: blockLineSpacing ?? 0, fontHeight, + deviceFontId: deviceFontId, rotation: obj.props.rotation, }); return ( @@ -233,8 +234,8 @@ function TextFieldContent({ width={dotsToPx(bounds.width, scale, dpmm)} height={dotsToPx(bounds.height, scale, dpmm)} // Unconfigured (empty) reads as the warning family, matching the - // blank-barcode frame; too-narrow keeps the design-affordance accent. - color={emptyContent ? emptyColor : placeholderColor} + // blank-barcode frame. + color={emptyColor} /> ); } @@ -246,7 +247,7 @@ function TextFieldContent({ // must too or it would show line breaks that won't print. const tbText = content.replace(/\n/g, " "); const tbLines = wrapBlockLines(tbText, dotsToPx(blockWidth, scale, dpmm), measureLinePx); - const tbStep = tbLineStepDots(fontHeight); + const tbStep = tbLineStepDots(fontHeight, deviceFontId); const clip = tbBoundsDots(blockWidth, blockHeight ?? fontHeight, obj.props.rotation); // Lines sit exactly like ^FB (first line at the block top with Konva's cap // pad); matches the Labelary preview so toggling it doesn't shift the text. @@ -289,7 +290,7 @@ function TextFieldContent({ } const justify = blockJustify ?? "L"; const indent = blockHangingIndent ?? 0; - const lineStepDots = blockLineStepDots(fontHeight, blockLineSpacing ?? 0); + const lineStepDots = blockLineStepDots(fontHeight, blockLineSpacing ?? 0, deviceFontId); // Wrap to the rendered block width; ^FB slot b: text exceeding blockLines // overprints onto the last line (matches Labelary), so pin overflow rows to // the last index rather than dropping them. @@ -363,6 +364,7 @@ function BlockWrapGuide({ blockLineSpacing, blockHeightDots, fontHeight, + fontId, rotation, scale, dpmm, @@ -375,6 +377,7 @@ function BlockWrapGuide({ /** Set for ^TB: the frame is the width x clip-height rect, not line-stacked. */ blockHeightDots?: number; fontHeight: number; + fontId?: string; rotation: ZplRotation; scale: number; dpmm: number; @@ -384,7 +387,7 @@ function BlockWrapGuide({ const bounds = blockHeightDots != null ? tbBoundsDots(blockWidthDots, blockHeightDots, rotation) - : blockBoundsDots({ blockWidthDots, blockLines, blockLineSpacing, fontHeight, rotation }); + : blockBoundsDots({ blockWidthDots, blockLines, blockLineSpacing, fontHeight, deviceFontId: fontId, rotation }); const bx = dotsToPx(bounds.x, scale, dpmm); const by = dotsToPx(bounds.y, scale, dpmm); const bw = dotsToPx(bounds.width, scale, dpmm); @@ -654,8 +657,8 @@ function KonvaObjectInner({ s.label); + const deviceFontId = resolveDeviceFontId(p.fontId, p.printerFontName, label); const contentLines = wrapBlockLines( p.content, p.blockWidth ?? 0, - (line) => zebraLineWidthDots(line, p.fontHeight, p.fontWidth), + (line) => blockLineWidthDots(line, p.fontHeight, p.fontWidth, deviceFontId), ).length; const maxLines = p.blockLines ?? 1; const truncates = contentLines > maxLines; - const advance = Math.ceil(zebraGlyphAdvanceDots(p.fontHeight, p.fontWidth)); - const tooNarrow = isBlockTooNarrow(p.blockWidth ?? 0, p.fontHeight, p.fontWidth); + const advance = Math.ceil(blockGlyphCellDots(p.fontHeight, p.fontWidth, deviceFontId)); + const tooNarrow = isBlockTooNarrow(p.blockWidth ?? 0, p.fontHeight, p.fontWidth, deviceFontId); return (

diff --git a/src/components/Properties/TextModeSection.tsx b/src/components/Properties/TextModeSection.tsx index 065be539..3109bc6d 100644 --- a/src/components/Properties/TextModeSection.tsx +++ b/src/components/Properties/TextModeSection.tsx @@ -9,6 +9,9 @@ import { Tooltip } from "../ui/Tooltip"; import { fieldGridCols, fieldGridCell } from "../ui/formStyles"; import { FB_DEFAULTS } from "@zplab/core/lib/textBlock"; import { resolveTextMode, type TextMode, type TextProps } from "@zplab/core/registry/text"; +import { resolveDeviceFontId } from "@zplab/core/lib/customFonts"; +import { blockLinesForHeightDots, blockStackHeightDots } from "@zplab/core/lib/zebraTextLayout"; +import { useLabelStore } from "../../store/labelStore"; type IconType = typeof MinusIcon; @@ -30,8 +33,9 @@ export function TextModeSection({ onChange: (patch: Partial) => void; }) { const t = useT(); + const label = useLabelStore((s) => s.label); const mode = resolveTextMode(p); - const fontH = Math.max(1, p.fontHeight); + const deviceFontId = resolveDeviceFontId(p.fontId, p.printerFontName, label); const setMode = (next: TextMode) => { if (next === mode) return; @@ -46,7 +50,11 @@ export function TextModeSection({ blockHeight: undefined, }); } else if (next === "fb") { - const lines = p.blockHeight ? Math.max(1, Math.round(p.blockHeight / fontH)) : p.blockLines ?? 3; + const spacing = p.blockLineSpacing ?? FB_DEFAULTS.blockLineSpacing; + // Same pitch the runtime stacks with, so switching preserves the size. + const lines = p.blockHeight + ? blockLinesForHeightDots(p.blockHeight, p.fontHeight, spacing, deviceFontId) + : p.blockLines ?? 3; onChange({ textMode: undefined, blockWidth: p.blockWidth ?? FB_DEFAULTS.blockWidth, @@ -57,7 +65,14 @@ export function TextModeSection({ blockHeight: undefined, }); } else { - const height = p.blockHeight ?? (p.blockLines ?? 3) * fontH; + const height = + p.blockHeight ?? + blockStackHeightDots( + p.blockLines ?? 3, + p.fontHeight, + p.blockLineSpacing ?? FB_DEFAULTS.blockLineSpacing, + deviceFontId, + ); onChange({ textMode: "tb", blockWidth: p.blockWidth ?? FB_DEFAULTS.blockWidth, diff --git a/src/lib/preflight.test.ts b/src/lib/preflight.test.ts index f5f4933c..cf76073c 100644 --- a/src/lib/preflight.test.ts +++ b/src/lib/preflight.test.ts @@ -307,6 +307,22 @@ describe("per-type producers (registry preflight capability)", () => { expect(getEntry("text")!.preflight!(tb, pctx)).toEqual([{ kind: "textOverset" }]); }); + it("text: ^TB overset judges device fonts by the snapped pitch", () => { + // Font G h=84 snaps to 60: three 'MMM' lines stack 180 <= 200, so no + // overset; the raw pitch (2*105+84 = 294) would falsely warn. + const tb = textLeaf({ content: "MMM MMM MMM", fontHeight: 84, fontWidth: 0, + blockWidth: 250, blockHeight: 200, textMode: "tb", fontId: "G" }); + expect(getEntry("text")!.preflight!(tb, pctx)).toEqual([]); + }); + + it("text: ^FB wrap judges device fonts by cell advances, not Font-0 widths", () => { + // Font A h=20 (mag 2): 6 cells of 12 dots fit blockWidth 80 on one + // line; the Font-0 table (~90.6 dots) would falsely wrap and warn. + const fb = textLeaf({ content: "MMMMMM", fontHeight: 20, fontWidth: 0, + blockWidth: 80, blockLines: 1, fontId: "A" }); + expect(getEntry("text")!.preflight!(fb, pctx)).toEqual([]); + }); + it("text: a block with room to spare flags nothing", () => { const ok = textLeaf({ content: "AAA", fontHeight: 30, fontWidth: 0, blockWidth: 400, blockLines: 5 }); expect(getEntry("text")!.preflight!(ok, pctx)).toEqual([]); diff --git a/src/lib/reverseBacking.test.ts b/src/lib/reverseBacking.test.ts index 39d22345..22f9f9c1 100644 --- a/src/lib/reverseBacking.test.ts +++ b/src/lib/reverseBacking.test.ts @@ -44,6 +44,65 @@ describe("reverseBackingBoxGeometry", () => { expect(geo.props.color).toBe("B"); }); + it("sizes an ^FB device-font backing by the snapped pitch", () => { + // ^AG h=84 snaps to 60: 3 lines back 2*60+60 = 180 dots, not 252. + const geo = reverseBackingBoxGeometry( + text({ reverse: true, fontId: "G", fontHeight: 84, blockWidth: 700, blockLines: 3 }) as never, + { customFonts: [], defaultFontId: undefined }, + ); + expect(geo.props.height).toBe(180); + }); + + it("still recognizes a backing persisted at the pre-snap raw pitch", () => { + const rev = text({ reverse: true, fontId: "G", fontHeight: 84, + blockWidth: 700, blockLines: 3 }); + const legacy = reverseBackingBoxGeometry(rev as never, + { customFonts: [], defaultFontId: undefined }, { rawBlockPitch: true }); + expect(legacy.props.height).toBe(252); + const box = { id: "bg", type: "box", x: legacy.x, y: legacy.y, rotation: 0, + props: { ...legacy.props } } as unknown as LabelObject; + expect(insertReverseBackingBoxes([box, rev], + { customFonts: [], defaultFontId: undefined })).toHaveLength(2); + }); + + it("migrates a raw-pitch legacy backing to the snapped geometry on snap-up", () => { + // Font G h=40 snaps UP to 60: the legacy 120-dot backing under-covers + // the 180-dot snapped stack. It is the feature's own box, so migration + // resizes it in place instead of stacking a second one. + const label = { customFonts: [], defaultFontId: undefined }; + const rev = text({ reverse: true, fontId: "G", fontHeight: 40, + blockWidth: 700, blockLines: 3 }); + const legacy = reverseBackingBoxGeometry(rev as never, label, { rawBlockPitch: true }); + expect(legacy.props.height).toBe(120); + const box = { id: "bg", type: "box", x: legacy.x, y: legacy.y, rotation: 0, + props: { ...legacy.props } } as unknown as LabelObject; + const out = insertReverseBackingBoxes([box, rev], label); + expect(out).toHaveLength(2); + // Canonical form for 700x180 is a horizontal line (height == thickness). + const migrated = out[0] as { id: string; type: string; props: { thickness: number } }; + expect(migrated.id).toBe("bg"); + expect(migrated.type).toBe("line"); + expect(migrated.props.thickness).toBe(180); + }); + + it("re-canonicalizes a line-shaped owned legacy backing on migration", () => { + // A feature-era backing persisted as a black line matches ownership by + // footprint; migration must rebuild the canonical shape instead of + // merging box props into the line. + const label = { customFonts: [], defaultFontId: undefined }; + const rev = text({ reverse: true, fontId: "G", fontHeight: 40, + blockWidth: 700, blockLines: 3 }); + const legacy = reverseBackingBoxGeometry(rev as never, label, { rawBlockPitch: true }); + const line = { id: "bg", type: "line", x: legacy.x, y: legacy.y, rotation: 0, + props: { length: legacy.props.width, thickness: legacy.props.height, + angle: 0, color: "B" } } as unknown as LabelObject; + const out = insertReverseBackingBoxes([line, rev], label); + expect(out).toHaveLength(2); + const migrated = out[0] as { id: string; type: string; props: { thickness?: number } }; + expect(migrated.id).toBe("bg"); + expect(migrated.props.thickness).toBe(180); + }); + it("swaps width/height for vertical rotations", () => { const n = reverseBackingBoxGeometry(text({ reverse: true, rotation: "N" }) as never); const r = reverseBackingBoxGeometry(text({ reverse: true, rotation: "R" }) as never); @@ -145,6 +204,16 @@ describe("insertReverseBackingBoxes", () => { expect(insertReverseBackingBoxes([rev], badLabel)).toHaveLength(2); }); + it("does not crash on a malformed label for an ^FB device-font text", () => { + const rev = { + ...(text({ reverse: true }) as unknown as Record), + props: { content: "Hi", fontHeight: 84, fontWidth: 0, rotation: "N", + reverse: true, fontId: "G", blockWidth: 700, blockLines: 3 }, + } as unknown as LabelObject; + const badLabel = { customFonts: "oops", defaultFontId: "A" } as never; + expect(() => insertReverseBackingBoxes([rev], badLabel)).not.toThrow(); + }); + it("recognizes a covering line whose angle is a string (unvalidated json)", () => { const rev = text({ reverse: true }); const lineStrAngle = { diff --git a/src/lib/textBlock.test.ts b/src/lib/textBlock.test.ts index bdbe5074..f513976e 100644 --- a/src/lib/textBlock.test.ts +++ b/src/lib/textBlock.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from "vitest"; import { deriveBlockTextPatch, FB_DEFAULTS } from "@zplab/core/lib/textBlock"; +import { getEntry } from "@zplab/core/registry"; +import type { TextProps } from "@zplab/core/registry/text"; const H = 30; const W = 0; @@ -19,6 +21,37 @@ describe("deriveBlockTextPatch", () => { }); }); + it("activates with device-font cell widths, not the Font-0 table", () => { + // Font A h=20 (mag 2, 12-dot advance): 30 M's are 359 dots and fit the + // 400-dot default width; the Font-0 table (~453) would wrap and cap + // the new block one line short. + const long = "M".repeat(30); + const patch = deriveBlockTextPatch(`${long}\nM`, {}, 20, 0, "A"); + expect(patch.blockLines).toBe(2); + }); + + it("resolves the ^CF-default device font for the first-newline cap", () => { + // Same discriminating case, but the device font comes from the label + // default instead of the field's fontId. + const long = "M".repeat(30); + // Reachable via imported designs: explicit textMode 'fb' without a + // blockWidth is the one shape that routes normalizeChanges into the + // activation branch. + const obj = { id: "t", type: "text", x: 0, y: 0, rotation: 0, + props: { content: "M", fontHeight: 20, fontWidth: 0, rotation: "N", textMode: "fb" } }; + const changes = getEntry("text")!.normalizeChanges!( + obj as never, + { props: { content: `${long}\nM` } }, + { label: { defaultFontId: "A" } }, + ); + const props = changes.props as Partial; + expect(props.blockLines).toBe(2); + // The backfill must be complete, or the block emits ^FB0 (no width). + expect(props.blockWidth).toBe(FB_DEFAULTS.blockWidth); + expect(props.blockLineSpacing).toBe(FB_DEFAULTS.blockLineSpacing); + expect(props.blockJustify).toBe(FB_DEFAULTS.blockJustify); + }); + it("grows the line cap when explicit hard breaks exceed it", () => { expect( deriveBlockTextPatch("A\nB\nC", { blockWidth: 400, blockLines: 2 }, H, W), diff --git a/src/lib/zebraTextLayout.test.ts b/src/lib/zebraTextLayout.test.ts index 1ab204c9..8e2f1843 100644 --- a/src/lib/zebraTextLayout.test.ts +++ b/src/lib/zebraTextLayout.test.ts @@ -6,10 +6,13 @@ import { zebraHangingIndentOffsetDots, zebraJustifyGapDots, blockJustifyWordPositions, + blockGlyphCellDots, + blockLinesForHeightDots, isBlockTooNarrow, blockBoundsDots, blockLineStartDots, blockLineStepDots, + blockStackHeightDots, blockWordAdvanceDots, blockReflowGeometry, blockGlyphAnchorPoint, @@ -24,6 +27,41 @@ describe("tbLineStepDots", () => { expect(tbLineStepDots(40)).toBe(50); expect(tbLineStepDots(80)).toBe(100); }); + it("stacks device fonts by the snapped cell height, no ratio", () => { + // Labelary: ^AG h=84 wraps ^TB lines 60 apart (mag 1), not 84*1.25. + expect(tbLineStepDots(84, "G")).toBe(60); + }); +}); + +describe("blockBoundsDots for device fonts", () => { + it("stacks the bbox by the snapped pitch and last-line cell", () => { + // ^AG h=84 snaps to 60: 3 lines span 2*60+60, not 2*84+84. + const b = blockBoundsDots({ + blockWidthDots: 700, blockLines: 3, blockLineSpacing: 0, fontHeight: 84, deviceFontId: "G", + }); + expect(b.height).toBe(180); + expect(blockBoundsDots({ + blockWidthDots: 700, blockLines: 3, blockLineSpacing: 0, fontHeight: 84, + }).height).toBe(252); + }); +}); + +describe("blockLinesForHeightDots", () => { + it("inverts blockStackHeightDots for the fb<->tb mode switch", () => { + expect(blockLinesForHeightDots(blockStackHeightDots(3, 84, 10, "G"), 84, 10, "G")).toBe(3); + expect(blockLinesForHeightDots(252, 84, 0)).toBe(3); + }); +}); + +describe("blockLineStepDots for device fonts", () => { + it("stacks by the snapped cell height plus spacing", () => { + // Labelary: ^AG h=84 -> pitch 60; h=100 -> 120; ^AA h=20 -> 18. + expect(blockLineStepDots(84, 0, "G")).toBe(60); + expect(blockLineStepDots(84, 10, "G")).toBe(70); + expect(blockLineStepDots(100, 0, "G")).toBe(120); + expect(blockLineStepDots(20, 0, "A")).toBe(18); + expect(blockLineStepDots(84, 0, "0")).toBe(84); + }); }); describe("tbBoundsDots", () => { @@ -178,18 +216,27 @@ describe("zebraHangingIndentOffsetDots", () => { }); describe("isBlockTooNarrow", () => { - it("returns true when block is below explicit fontWidth (Labelary fixture 02)", () => { - expect(isBlockTooNarrow(20, 30, 30)).toBe(true); + it("gates on the shared cell, fractional widths included", () => { + // Font-0 cell at h=30 is 16.67: a width inside the fractional gap must + // judge identically everywhere (the panel only ceils for display). + expect(isBlockTooNarrow(16.5, 30, 0)).toBe(true); + expect(isBlockTooNarrow(17, 30, 0)).toBe(false); + expect(isBlockTooNarrow(9, 20, 0, "A")).toBe(true); + expect(isBlockTooNarrow(10, 20, 0, "A")).toBe(false); + expect(isBlockTooNarrow(0, 30, 0)).toBe(false); }); - it("returns false when block matches explicit fontWidth", () => { - expect(isBlockTooNarrow(30, 30, 30)).toBe(false); +}); + +describe("blockGlyphCellDots", () => { + it("uses the explicit fontWidth as the Font-0 cell (Labelary fixture 02)", () => { + expect(blockGlyphCellDots(30, 30)).toBe(30); }); it("uses A0 5/9 aspect for fontWidth=0 (~17 dots for h=30)", () => { - expect(isBlockTooNarrow(15, 30, 0)).toBe(true); - expect(isBlockTooNarrow(20, 30, 0)).toBe(false); + expect(blockGlyphCellDots(30, 0)).toBeCloseTo(30 * (5 / 9), 6); }); - it("returns false when blockWidth is 0 or absent (no block configured)", () => { - expect(isBlockTooNarrow(0, 30, 30)).toBe(false); + it("uses the snapped cell width for device fonts", () => { + // Font A mag 2: cell width 5 per mag (spec font width, not advance). + expect(blockGlyphCellDots(20, 0, "A")).toBe(2 * 5); }); }); diff --git a/src/lib/zplGenerator.test.ts b/src/lib/zplGenerator.test.ts index 3667121b..5f54d478 100644 --- a/src/lib/zplGenerator.test.ts +++ b/src/lib/zplGenerator.test.ts @@ -825,6 +825,52 @@ describe('generateZPL — ^TB text block', () => { expect(props(reparsed[0]).content).toBe('Text block'); }); + it('ignores dormant ^TB props for the anchor of a serial field', () => { + // Serial suppresses the block (no ^TB in the stream), so the anchor + // must not carry a block extent the reparse cannot see. + const mk = (extra: object) => [ + { id: 's', type: 'text', x: 100, y: 137, positionType: 'FT', rotation: 0, + props: { content: '001', fontHeight: 40, fontWidth: 0, rotation: 'N', + serial: { increment: 1, zplMode: 'SN' }, ...extra } }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any; + const plain = generateZPL(BASE_LABEL, mk({})); + const dormant = generateZPL(BASE_LABEL, mk({ textMode: 'tb', blockWidth: 200, blockHeight: 40 })); + expect(dormant).not.toContain('^TB'); + expect(dormant).toBe(plain); + }); + + it('ignores dormant blockWidth for the FO/I anchor of a serial field', () => { + // FO I anchors at the far reading end; a dormant ^FB width must not + // replace the ink extent when serial suppresses the block. + const mk = (extra: object) => [ + { id: 's', type: 'text', x: 300, y: 150, rotation: 0, + props: { content: '001', fontHeight: 40, fontWidth: 0, rotation: 'I', + serial: { increment: 1, zplMode: 'SN' }, ...extra } }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any; + const plain = generateZPL(BASE_LABEL, mk({})); + const dormant = generateZPL(BASE_LABEL, mk({ blockWidth: 400 })); + expect(dormant).not.toContain('^FB'); + expect(dormant).toBe(plain); + }); + + it('anchors blockHeight 0 like the parser (falls back to fontHeight)', () => { + // The parser maps ^TB height 0 to the font height; the emitted anchor + // must mirror that or the round-trip shifts by the extent delta. + const mk = (blockHeight: number) => [ + { id: 't', type: 'text', x: 30, y: 100, positionType: 'FT', rotation: 0, + props: { content: 'x', fontHeight: 30, fontWidth: 0, rotation: 'N', + textMode: 'tb', blockWidth: 200, blockHeight } }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any; + const ftY = (zpl: string) => /\^FT\d+,(\d+)/.exec(zpl)?.[1]; + const zero = generateZPL(BASE_LABEL, mk(0)); + const fontHigh = generateZPL(BASE_LABEL, mk(30)); + expect(ftY(zero)).toBeDefined(); + expect(ftY(zero)).toBe(ftY(fontHigh)); + }); + it('round-trips an FT-anchored ^TB position (extent uses blockHeight)', () => { const original = '^XA^A0N,30^FT400,150^TBN,300,90^FDsample^FS^XZ'; const a = parseSingle(original, 8); diff --git a/src/lib/zplParser.test.ts b/src/lib/zplParser.test.ts index 338dea87..89205fd8 100644 --- a/src/lib/zplParser.test.ts +++ b/src/lib/zplParser.test.ts @@ -167,6 +167,33 @@ describe('parseZPL — ^MU units of measure', () => { expect(out).toContain('^FO300,150'); }); + it('inherits the last ^A rotation for a bare ^TB orientation (spec p.356)', () => { + const r = parseSingle('^XA^FO50,50^A0R,30,30^TB,300,200^FDsample^FS^XZ', 8); + expect(props(r.objects[0]).rotation).toBe('R'); + }); + + it('lets ^TB reclaim the field as text after a barcode command', () => { + const r = parseSingle('^XA^FO10,10^BCN,100,Y,N,N^TBN,300,200^FDdata^FS^XZ', 8); + expect(defined(r.objects[0]).type).toBe('text'); + }); + + it('keeps the ^A height for a ^TB field (spec: ^TB renders in the last ^A font)', () => { + const r = parseSingle('^XA^FT400,300^A0N,80,80^TBN,300,200^FDsample^FS^XZ', 8); + expect(props(r.objects[0]).fontHeight).toBe(80); + const out = generateZPL({ widthMm: 100, heightMm: 50, dpmm: 8 }, r.objects, r.variables); + expect(out).toContain('^FT400,300'); + }); + + it('stacks a device-font ^FB block by the snapped cell pitch', () => { + // Labelary: ^AG h=84 prints lines 60 apart (snapped cell), not 84. + // FT pins the last baseline, so 2 extra lines lift the EM-top by 2*60. + const single = parseSingle('^XA^FT50,700^AGN,84,^FDMMM^FS^XZ', 8); + const block = parseSingle('^XA^FT50,700^AGN,84,^FB700,3,0,L,0^FDMMM\\&MMM\\&MMM^FS^XZ', 8); + expect(defined(single.objects[0]).y - defined(block.objects[0]).y).toBeCloseTo(120, 6); + const out = generateZPL({ widthMm: 100, heightMm: 50, dpmm: 8 }, block.objects, block.variables); + expect(out).toContain('^FT50,700'); + }); + 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. diff --git a/src/store/labelStore.internals.ts b/src/store/labelStore.internals.ts index 61e6ca0e..9b560abd 100644 --- a/src/store/labelStore.internals.ts +++ b/src/store/labelStore.internals.ts @@ -8,6 +8,7 @@ export { substituteTemplateMarkers, } from '@zplab/core/lib/templateObjects'; import { applyChanges } from '@zplab/core/lib/anchorRepin'; +import type { DeviceFontLabel } from '@zplab/core/lib/customFonts'; import { probeBarcodeFootprint } from './anchorRepin'; import { newId } from "@zplab/core/lib/ids"; @@ -64,6 +65,7 @@ export function applyObjectChanges( obj: LabelObject, changes: ObjectChanges, ancestorLocked = false, + label?: DeviceFontLabel, ): LabelObject { // Lock cascades from any ancestor group: a leaf inside a locked group // accepts only bypass keys (locked / visible / includeInExport / @@ -81,7 +83,7 @@ export function applyObjectChanges( } // Dirty-tracking is centralized in the dirtyTracking middleware (a state diff), // so this mutator no longer stamps dirty itself. - return applyChanges(obj, changes, probeBarcodeFootprint); + return applyChanges(obj, changes, probeBarcodeFootprint, { label }); } export function detectLocale(): LocaleCode { diff --git a/src/store/pageLabelSeam.test.ts b/src/store/pageLabelSeam.test.ts index f08b9164..66249919 100644 --- a/src/store/pageLabelSeam.test.ts +++ b/src/store/pageLabelSeam.test.ts @@ -46,6 +46,9 @@ const ALLOWED = [ // Clock offsets are design-wide and carry no density. ".label.secondaryClockOffset", ".label.tertiaryClockOffset", + // normalizeChanges ctx reads customFonts/defaultFontId, both design-scoped. + "applyObjectChanges(obj, changes, ancestorLocked, state.label)", + "applyObjectChanges(n, changes, inheritedLocked, state.label)", ]; // No /g flag: a global regex keeps lastIndex across .test() calls and would diff --git a/src/store/slices/objectSlice.ts b/src/store/slices/objectSlice.ts index dbf11d59..2402c3ec 100644 --- a/src/store/slices/objectSlice.ts +++ b/src/store/slices/objectSlice.ts @@ -190,7 +190,7 @@ export const createObjectSlice: StateCreator = const ancestorLocked = hasLockedAncestor(objs, id); return updateCurrentObjects(state, (curr) => mapObjectById(curr, id, (obj) => - applyObjectChanges(obj, changes, ancestorLocked), + applyObjectChanges(obj, changes, ancestorLocked, state.label), ), ); }), @@ -233,7 +233,7 @@ export const createObjectSlice: StateCreator = const next = nodes.map((n) => { const changes = updateMap.get(n.id); let updated = changes - ? applyObjectChanges(n, changes, inheritedLocked) + ? applyObjectChanges(n, changes, inheritedLocked, state.label) : n; if (isGroup(updated)) { const childLocked = inheritedLocked || !!updated.locked; diff --git a/src/test/deviceFontBoxMatch.test.ts b/src/test/deviceFontBoxMatch.test.ts index 221294fa..690fef9f 100644 --- a/src/test/deviceFontBoxMatch.test.ts +++ b/src/test/deviceFontBoxMatch.test.ts @@ -5,6 +5,7 @@ import { GlobalFonts } from '@napi-rs/canvas'; import { PNG } from 'pngjs'; import { deviceFontMetrics, deviceFontInkWidthDots } from '@zplab/core/lib/labelGeometry/deviceFonts'; import { zplAnchorToModel } from '@zplab/core/lib/labelGeometry/textPositionTransforms'; +import { blockAnchorExtentDots, blockLineStepDots, tbLineStepDots } from '@zplab/core/lib/zebraTextLayout'; import { builtinFontFamily } from '@zplab/core/lib/customFonts'; import { deviceFontBoxMatchCases } from '../../tests/fixtures/deviceFontBoxMatchCases'; import { darkBBox } from '../../tests/lib/darkBBox'; @@ -76,13 +77,40 @@ describe('Device font box-match: substitutes vs. Labelary bitmap fonts', () => { const inkWidth = deviceFontInkWidthDots(tc.fontId, tc.fontHeight, tc.fontWidth, tc.text) ?? 0; + // The gate consumes the production extent, so a parser/generator + // divergence fails here instead of shifting user labels silently. + const blockExtent = + tc.block === undefined + ? 0 + : blockAnchorExtentDots({ + tbHeightDots: tc.block.mode === 'tb' ? tc.block.heightDots : 0, + blockWidthDots: tc.block.widthDots, + blockLines: tc.block.mode === 'fb' ? tc.block.lines : 1, + blockLineSpacing: tc.block.mode === 'fb' ? tc.block.spacing : 0, + fontHeight: tc.fontHeight, + deviceFontId: tc.fontId, + }); const model = zplAnchorToModel( tc.x, tc.y, { fontHeight: tc.fontHeight, rotation: tc.rotation, fontId: tc.fontId }, - 'FO', + tc.posType ?? 'FO', inkWidth, + blockExtent, + tc.block?.mode === 'fb' ? tc.block.widthDots : 0, ); + // ^FB breaks at \&, the ^TB case is sized to wrap each word; the + // wrap algorithm itself is out of scope here. + const lines = + tc.block === undefined + ? [tc.text] + : tc.block.mode === 'fb' + ? tc.text.split('\\&') + : tc.text.split(' '); + const step = + tc.block?.mode === 'tb' + ? tbLineStepDots(tc.fontHeight, tc.fontId) + : blockLineStepDots(tc.fontHeight, tc.block?.spacing ?? 0, tc.fontId); const { canvas, ctx } = inkCanvas(); // Konva rotates the node about its position; the device nudges are @@ -91,15 +119,17 @@ describe('Device font box-match: substitutes vs. Labelary bitmap fonts', () => { ctx.save(); ctx.translate(model.x, model.y); ctx.rotate((deg * Math.PI) / 180); - drawKonvaText(ctx, { - text: tc.text, - x: metrics.xOffsetDots, - y: metrics.yOffsetDots, - fontSizePx: metrics.fontSizeDots, - fontFamily: face.family, - scaleX: metrics.scaleX, - letterSpacingPx: metrics.letterSpacingDots, - }); + for (const [i, line] of lines.entries()) { + drawKonvaText(ctx, { + text: line, + x: metrics.xOffsetDots, + y: metrics.yOffsetDots + i * step, + fontSizePx: metrics.fontSizeDots, + fontFamily: face.family, + scaleX: metrics.scaleX, + letterSpacingPx: metrics.letterSpacingDots, + }); + } ctx.restore(); const localPng = PNG.sync.read(canvas.toBuffer('image/png')); diff --git a/src/test/textBoxMatch.test.ts b/src/test/textBoxMatch.test.ts index 191c7ec3..87a6932f 100644 --- a/src/test/textBoxMatch.test.ts +++ b/src/test/textBoxMatch.test.ts @@ -3,6 +3,8 @@ import * as fs from 'fs'; import * as path from 'path'; import { PNG } from 'pngjs'; +import { zplAnchorToModel } from '@zplab/core/lib/labelGeometry/textPositionTransforms'; +import { tbBlockExtentDots, tbLineStepDots } from '@zplab/core/lib/zebraTextLayout'; import { textBoxMatchCases } from '../../tests/fixtures/textBoxMatchCases'; import { darkBBox } from '../../tests/lib/darkBBox'; import { drawKonvaText } from '../../tests/lib/drawKonvaText'; @@ -63,15 +65,33 @@ describe('Text Box-Match — PrintLab ZPL vs. Labelary default font', () => { // ZPL `^A0,h,w` with w != h stretches each glyph; w = 0 means // "match h". const { canvas, ctx } = inkCanvas(); - drawKonvaText(ctx, { - text: tc.text, - x: tc.x, - y: tc.y, - fontSizePx: tc.fontHeight, - fontFamily: PRINTLAB_FONT_FAMILY, - fontStyle: 'bold', - scaleX: tc.fontWidth > 0 ? tc.fontWidth / tc.fontHeight : 1, - }); + // Block cases run through the production anchor transform; the ^TB + // width is sized so each word wraps to its own line, keeping the + // wrap algorithm itself out of scope. + const model = tc.block + ? zplAnchorToModel( + tc.x, + tc.y, + { fontHeight: tc.fontHeight, rotation: tc.rotation }, + tc.posType ?? 'FO', + 0, + tbBlockExtentDots(tc.block.heightDots, tc.fontHeight), + 0, + ) + : { x: tc.x, y: tc.y }; + const lines = tc.block ? tc.text.split(' ') : [tc.text]; + const step = tbLineStepDots(tc.fontHeight); + for (const [i, line] of lines.entries()) { + drawKonvaText(ctx, { + text: line, + x: model.x, + y: model.y + i * step, + fontSizePx: tc.fontHeight, + fontFamily: PRINTLAB_FONT_FAMILY, + fontStyle: 'bold', + scaleX: tc.fontWidth > 0 ? tc.fontWidth / tc.fontHeight : 1, + }); + } const localPng = PNG.sync.read(canvas.toBuffer('image/png')); const labelaryPng = PNG.sync.read( @@ -93,6 +113,13 @@ describe('Text Box-Match — PrintLab ZPL vs. Labelary default font', () => { expect(localBox.height).toBeLessThanOrEqual( labelaryBox.height + BBOX_TOLERANCE_DOTS, ); + if (tc.block) { + // Anchored blocks gate position too, not just the footprint. + expect(Math.abs(localBox.y - labelaryBox.y), `y drift for ${tc.id}`) + .toBeLessThanOrEqual(BBOX_TOLERANCE_DOTS); + expect(Math.abs(localBox.x - labelaryBox.x), `x drift for ${tc.id}`) + .toBeLessThanOrEqual(BBOX_TOLERANCE_DOTS); + } }); }); }); diff --git a/tests/fixtures/deviceFontBoxMatchCases.ts b/tests/fixtures/deviceFontBoxMatchCases.ts index adc05895..a6680468 100644 --- a/tests/fixtures/deviceFontBoxMatchCases.ts +++ b/tests/fixtures/deviceFontBoxMatchCases.ts @@ -14,6 +14,11 @@ export interface DeviceFontBoxMatchCase { rotation: 'N' | 'I' | 'B'; x: number; y: number; + posType?: 'FO' | 'FT'; + /** ^FB stacks explicit \& lines; ^TB wraps into a fixed clip box. */ + block?: + | { mode: 'fb'; widthDots: number; lines: number; spacing: number } + | { mode: 'tb'; widthDots: number; heightDots: number }; } interface FontPlan { @@ -109,9 +114,33 @@ const rotatedCases: DeviceFontBoxMatchCase[] = ['A', 'E', 'G'].flatMap((fontId) ); }); +/** Multi-line blocks: the firmware stacks device-font lines by the + * snapped cell height (+ ^FB spacing), not the requested height; FT + * pins the block bottom. h=84 snaps to 60, so a wrong pitch basis is + * a 24-dot-per-line error the gate cannot miss. */ +const blockCases: DeviceFontBoxMatchCase[] = [ + { id: 'fG_fb', fontId: 'G', fontHeight: 84, fontWidth: 0, text: 'MMM\\&MMM\\&MMM', + rotation: 'N', x: 50, y: 50, block: { mode: 'fb', widthDots: 700, lines: 3, spacing: 0 } }, + { id: 'fG_fb_sp10', fontId: 'G', fontHeight: 84, fontWidth: 0, text: 'MMM\\&MMM\\&MMM', + rotation: 'N', x: 50, y: 50, block: { mode: 'fb', widthDots: 700, lines: 3, spacing: 10 } }, + { id: 'fG_fb_ft', fontId: 'G', fontHeight: 84, fontWidth: 0, text: 'MMM\\&MMM\\&MMM', + rotation: 'N', x: 50, y: 700, posType: 'FT', block: { mode: 'fb', widthDots: 700, lines: 3, spacing: 0 } }, + { id: 'fA_fb', fontId: 'A', fontHeight: 20, fontWidth: 0, text: 'MMM\\&MMM\\&MMM', + rotation: 'N', x: 50, y: 50, block: { mode: 'fb', widthDots: 700, lines: 3, spacing: 0 } }, + { id: 'fG_tb', fontId: 'G', fontHeight: 84, fontWidth: 0, text: 'MMM MMM MMM', + rotation: 'N', x: 50, y: 50, block: { mode: 'tb', widthDots: 250, heightDots: 400 } }, + { id: 'fG_tb_ft', fontId: 'G', fontHeight: 84, fontWidth: 0, text: 'MMM MMM MMM', + rotation: 'N', x: 50, y: 700, posType: 'FT', block: { mode: 'tb', widthDots: 250, heightDots: 400 } }, + { id: 'fG_tb_rotI', fontId: 'G', fontHeight: 84, fontWidth: 0, text: 'MMM MMM MMM', + rotation: 'I', x: 600, y: 600, block: { mode: 'tb', widthDots: 250, heightDots: 400 } }, + { id: 'fG_fb_rotI', fontId: 'G', fontHeight: 84, fontWidth: 0, text: 'MMM\\&MMM\\&MMM', + rotation: 'I', x: 600, y: 600, block: { mode: 'fb', widthDots: 250, lines: 3, spacing: 0 } }, +]; + export const deviceFontBoxMatchCases: DeviceFontBoxMatchCase[] = [ ...magCases, ...snapCases, ...sweepCases, ...rotatedCases, + ...blockCases, ]; diff --git a/tests/fixtures/labelary_devicefont_images/fA_fb.png b/tests/fixtures/labelary_devicefont_images/fA_fb.png new file mode 100644 index 0000000000000000000000000000000000000000..9e6689c11c2df63c07face3eb0128851446abecf GIT binary patch literal 7306 zcmeHLZA=qq9KY+8Ybgg7?C@YtP7r0I=(QcQBU@50{&v?G?wAglaRPKYox< zlj>LaO^RBTqE05Vwig!fm@bE)=clsM^@TO(N@r4Knb#8t8L4tu3rJa`sLN?qI7G46 z4>3_eUF=r!6C)Upa4>CPSS$?x2e6n-k>z#>C8VeG8EU*uNl!op?|eg^Sec zv9Zl(-%yhS2cG^Gk9d{~JPCM^N8a3G#IFqABFUccaT*dOT}sBVqvC3u)#a3hBA#gY z1sl((EsogWBI|ohlp%c34T*EtCPv{!;!7k1@A7U#ZO`eISQn+hvUGV2Elqv=V5pNs z$-ss`Q#_IAGUW6Y$N$Pf=&haId+b`0t?mE*_ua8!{v0{FB8J8e^s5`4Fovh?>BUMI z2pJyVomkf%#j6Z&V|#pV`H9&Y98sK#NnJAx1x?;)YVb?*HQ3@D+F$JZ6qfn!4LykJ z;6Okt9M;KT@0b$%A%;|O+_WSYBP-|Nm3|CXHam@4Ek424)xlwW1I%%O_4(Ai=DkRO zl)0&se6j+af+_)UE960$nlT>9bnAEE1h|ED_|7MVbC8&h7{#!kBVbG?U*GBDIu<(t zP-(hM#_|{QCO7UG&*Lv!ax>PTjfT!8?fAnTMob1RID=uJ*C_f70k`zZqzM9Z~oL zP+I_j^^=d0^(g-J6V?u+7gOhvt`VsAy*-*Q?xgyX?dW zH}%%s*)tfnY_LEoJsz>o!}VqA8er+OdTx?0@5;wE~`V*W**+5 zvHYc;0wS0a-!(_RX;Hm{CXi=Vxel@-5G)c%?mx*5Ldd_C0vPPjG0fxy#Uf#0sCHt4 z0&}$U)B%sgtq6ENz@Ddkamj=WSUZ55chv%a0j3J1R~Yu!8UdDhw+nI*$o5Nv TwE59h4C0sUjJ))VTPW+_t7Wpn literal 0 HcmV?d00001 diff --git a/tests/fixtures/labelary_devicefont_images/fG_fb.png b/tests/fixtures/labelary_devicefont_images/fG_fb.png new file mode 100644 index 0000000000000000000000000000000000000000..e9a94c83db5e027a40c096900b3b379a94f4e800 GIT binary patch literal 10640 zcmeI2e{>U77RP5Y$uy(`4A37Fz%mqAVu>(G5Tg9(6iQou2w{cV?(x8+O#`_6sQ9ZW z(wVe$OR3T#6bdXv>aL!Jm13M!Xf=f zcmE0J0GXNh-rRTJefM*}FWKanpWWXu)IbnK|Jga2za|LEjQ;B32JyoAbs6wq%Hy;0 z9)EDn6Ke~Lhv-FE+j!v~!BTLq-R5#%dUyR#&~hAVIdg51^YImHR`ZKi ztSTZNShL#9TPE`vPb}lDf^`!7bHCMcztzfTKd|V+n!BGR2-B6>nHh^p_7rxd8?t;R z-XK`2JqraxrKX|2D4|-D>RawM>Kf!6VOKtSKDgRlhaPKVkAFtD)NIf1E>%4HnB{?M z$@;DinTd8O7_!K}k@@xKM@~pMEOcIW$erryrKf6y;n`8Z_UkD9Q_D zazK)#u6L@)eg|EW6mW6621z1x^|Q`aDvC3a(yag3ud0C)a(v}5iy#~<5al(yjT|@C zSWG@T3I<=y=!2_l6je4BTTYqU&5V;_n3un?sJ<@_!6zqU&)_Jk^{Z!?;>N4vC~780 z&1a9PYVnUL)bN$v9jeM6Lz7a8Io7#$ial5$mnA~SmQLsy{Tg|b@s4gX?Zn2(%7Q5z z$9eL&VvWv*trLPP;^38RJT>1d!Arr3^z@4`X?=k+G}$rr8%OQ|uUub6o6ZEmKO?Kj zf%;{VWG~%K8YW#wZ3|VZ1!mp(say8+Pls2uaIjZ7UR>zzR|t;FJ3;8i!R^euj6qq* zf;Vqx9E~1D$@qIM3>MKO+_m;K)b%Jqese_-1g*^$Agy&U@dF$m_*V)HBDv|Q;xSk$ zSP5gB`ILO-d&)^k6s1N$+gJp$ubzkH;*Y%Ub+X2OQYG6bv5A8otscZDw=IYNfpH~ zv9EU+W$DAE@JXOGJo5gJMKquH!w%$qg+SgvyrG`d=l)cLypI-2B{tTN*;$W$!5)M~ zsd?SW6P|kfX2NHH_`Z7>3>MKO+;x^8;@t^zYyJFluyN!(Ml0{6;1V`Q#qr}X%8Z?y zp0LEh<#uSWWef6GC|65zi@yc?_S#gcwW9|fI~gs6Ce?cJJ8)VLb}g$t2M^vlZ4(Y& zKYb&n$vL*U5PZlN6)32!xzMw(8z`)YdnY#O--ft2=sNsZ9r$Hit-`ZUI#I9{e`$sV zH;tk)_zNv)9nW>(0&=~J@CfvN5jilM=JkcV;l^z| zgTaoy`Pc8`RPnuSiYai~N6=M&WHQOp0kq(lScI08C(qY!{KpF<)529!&3hS%Jthth z{NN(W1Uy0li~yTd<*St(_838f6X2CXD3nAp(Ykp;GR3~aS=fH-|H{eY(MlyJaPlaj zP0PZzn!GdI=bqn6dot+)@~LeT+0j-u{&2%f3XgNEqFV%cu4x~Ukx0EKT^Yi^Qos-O zZ_sELJfG?LMILkATS5iKJY-C@ zX21HgR2X8e`)ZHQu)*q8zFEo##!=R;D3^!^-_x$5y5BWB0xhzkyZzf4vOrl&+T(TO znO6c^hELGyZG71+kHejk?DikGk-7`(TIJ;7&vwX~E6q+dK8)3HBD!39FJ;K{$s;0J zinb32j77Mj(IMb29-UvQYO&dmuZt+P+K0Ja?XI@c=Vw7xJK28(DC?m8=yj&fXk9(W9D2>GdJzs|P}|5^UI2T2qIaK`DF3A)D;m^(lOEG9@^xM};cluSj-S=bDCqFXqrf-U(IZ zou>h|R#b&R4nk>k@D&&=qDi>x=14k{6KdW7(m^etnJ^H{*V-%r-PSi@m;;;`IG+LL zOTWSqX>inqlKZPm-GpaxDfp>{)8hmm6{@`zp}P)h3ginBS0^1609!W%BfMFQ3YeEx z!e9|i!d;VYgCk263;-L^#z+|f%+@;#c%p4MiW->2)ag);uq|z9)~H|&6)e$+DYA$I zXQ!W58!xA$k_InSoavMqf)DwELqXl1XJNH{-9TYoEWhthy%mtOM?A9;1zYRyn_xsB zlU8X&c%D-+Wf@FkhS7+sGrPFL?Zj0`>C-xjEw`}b9f68%)SEiZw?L95HD{0?{tPuz z&E#dgM%##j<2NW))^y?;4zRoV7D#3iqDE3==MW;wavNQx) zp=nP^o2-4a)Ws9?rvfBDPPei|7^^6Z1Tn*X4)XdzAYj?a1t@*V#HW)Wca-35BFG-tCJ_=-9?~~d(WH=`EVmK9G}@l$gWY<; z4?Uw^BX1({x;c`L&2s zDOAzlEHMMRKB0CzirBk4#ti|>O>0;Q~Pp4yI-a+{o-^G{q0 z5Xz1?FyB*;Rl7;d?@jJENlczV2|g-R@j@|K2K~A@0+4!*GZ`XxJM&a0co)dTYFozRb-c6($@L<9iWDIJ(0j1bD>X&!oq>vM zMDp+!NJ0k*?Vi8D0Z7ItdlWsZq63^q&;dpBeK)Xs3nW=mdlqtwdnt?!wk|Cr6OiK$BK%DZBSSm~sgrYpAP}=ek!V1{#vM_1WATEyzKFZ3{ zne<^PRVY?yg@s7nl_MOfc30SA7bj_fQbG}TJHUG6X>N&8xrB)@TX>_ zJ@x3?r`Kg>p?S`XtX0l+8wC3}`#A9d(VFpKg5Bl5_Ws7(&~hwpIdff>^Qo0<*9ePN zuFgV_tX&f?SSJaIPp=ScqHQAldB|pc$TmSpd1O)M_zNE+WVtarDREKG?#!0yrevQ* zFp1U@&qC2uq^qpRiZ0QO@ICG}8!DAl@s)IZJ*vdL17FsMFNfU{4u|kI2xo_IRt(c1 z49ReD5H5X8I(NLkIY6zBBz;xDU&F#p>NTb&CF-T$h06PsxyU=cn5^G-kL}P_N!= zh8l(!r9^j=EXytL6;pi=x@0-%(hLba&$k|e=3~t~pJ2|W`ZG*n8;q>YVHjq9AuKn; zIJTwNy#NiDSOx9yX_jU8ut$_5ku+T!$LL$$sZdqlOVnU)Qid$29aGS!6YZjyW5bq< ziYMUbj%!->55wtUt6Cd0jXSOk%(sf-?xQDApIz_SL^0nQ)H`9+*T1%EzRQQ;laow} z^^U5}wbm_M=z4&cf|nA(eEB}6M*3if zsybt6W6hL$SnxodGC1`k80KIGQg`+N^F0+jj!27??sghi$nud*6_jzw?JSsLrhu^b z-(2oSp2fNF$FQcH3LS;KRO< zNl<4Vlepeakg%ra*tb+g!1B#0e0T*+dxqI;)Hw%}V0*R^ZzG$H)BDGfb-cD4`!dVL z8!F`>`cp+gEDqP!FJo8NTLbs5J_M7)rcn9|;9>W29Jg66 zL1#PyBL$|U12TmXVBJmFfg4@OBQpD>uG4r10(Gh4*xo#fjef*j zq-!0<%HGEtr0C`^WJJ$;lq_kKV^4a{RI?_Os0rFvClTXh>!@W57}1!CN04BIg4hy@ z_47qAOBtLn%9x4r+CwSY5nJ7|&WhCHNV-(pxrH(=DuH%Km$H&KqYm4u=;B<1u31(J zsV-qqcFJrzaRmLA^6;D?#we0o)1-ocU%c6uTV&<>xu;W}IA)*k${5LUjI5pR2^W60 zoUG?h$AgEtzRC7eu=A2UX9G41uS&(Dk*UCw@So>Nw<~K$ z+j4Jk3_YZ!)u20S6Y_dG#PeG#EpczCk1&j5_XoEQ8?QIog}mo$96`TgYOVifh9c6o zoVzY}c2e^y7nmK|o=I$u@9gUU$}>Zmnog{CC8D;+>|P~?ZSjS$lq?`HHUegi(^Jr!ER82d%<3q ziZdM#?2QcJV^DW788*xTo(rCLL;hO8ER`-4k@Th8cs`(M!ILC-kjWMS>4dKR@;NrU z>ie-Ixyh;{77j{wJ9 zonIMfDaUCdLs1tz44d7PM$$Sj&2CTY3Awu{)5KfgA7iFQ_X<_Ak&TYBbU%;Nx=kW! zePIf~wxks9JerQiX`Or81%ut)q^oN;I_4-PkfvbejX8&(hpb*+K@HgU7AEpBM16e& z*8+Asc9o>;gHi>j?C&3<`Gen|##vn%+&ffp%8d5LSLfTXFI*_^Wmwgy8*EOZWgt%1 zjEDguIM2XfAx%2FcB5mnZuW0TEg&>s7hu7~f%GzA)sftP z$YO-F?{?x^L`A(rwY?&Ay8F7_#20#8oxHXRU@Jo~LKPyB0;Zu51`BD@*)=j0jx;4) z)^ODTJLH=uVHEB@MkfVeo50%9wV!`tL`9ifvSsi-D*+AqgFn7r+l6;<9?fdS--#hJs7p zz_D`uau;^*f~2J9vH`p0e<&b^s74_O*cES@ARsIwyRb_tk$cSwpwEKix4D!u0_JqM zKq05e4D@9y2Icl=M8||I!PK%|RMdU<(+jl@s^TS0l{UDWzJvG`nTzPq7@CLLpietf zNrh^F5uow~q6$&@Rt20;DiEUL&JFMhDvo5Cih&GGm?=#tzpXdE!51;rdr@Kx=^e4Z6hDIEBY#m= z{>0$A!Zz5dCAxix6oA0epXWts%J0zc<|<6V@rd=_H+m|p#F}PYmc%sM3r`S zGW36OT9!mT_lX!Zr0lhMm8om_W=|(pI}=fd=nVD=VJTSx26O`8iil3V7Q)hY$eQe@ zHm9(u^34=R7pxlzki0#Trm{P*O2UYab`82)WHL*YUYJVa1K2w&gpW!@(NF>)Nm;uK zm@x$e?2{u&`Z6}JwnJ_hLoBB)_3==E-z_l{AcA{=YCC`D*ybjHqR3G4Vqge=Bbxwe zsi$M?O)Or!(J@0*kRb`^s%!`VLB(H&|14v7g;60}m! zP{mtBkQTc8_9i;J>(b7yrJ-~TQnnp1@dLaN{CGH+4}h2=9fu?jLQ7wBz83OkAb?$k zsA4EpaOr-B!~`kXD^zjHbihN7sNO_pcU{`qbwnr~wTMxxaT#G%v{QLtzHc6zqrUb4 z7#GKIIUBY?PA%U|QlqXct3A2jAu&NpV#U-WRPjP-sGwqWZ=$oiF750(s~a5kN;wTS z0wxMioB;c0l=DVp?E{Ax6xe$EV7lk;e zZA(~vVs9sKSY!Pf0!UTho&k}Ub~5{mctR#RQMiKfyuGP09qe`_lj^L$fJJ=$E>go`?2fn-l60h`VeH5KvdjW0sw9qKbYqB^H#3!#=r53 zbgW2C4R%l!d*9-n;cj|yJL5@WGN@sHw1@cI=g0D+}i_^V||x#9QD!tdMtjPKha^tn8) UQdzj?j|hHd&zhg~<_y{MU!V(Q8~^|S literal 0 HcmV?d00001 diff --git a/tests/fixtures/labelary_devicefont_images/fG_fb_rotI.png b/tests/fixtures/labelary_devicefont_images/fG_fb_rotI.png new file mode 100644 index 0000000000000000000000000000000000000000..dece474e012ee98778c665bede2750b428d904be GIT binary patch literal 9171 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/labelary_devicefont_images/fG_fb_sp10.png b/tests/fixtures/labelary_devicefont_images/fG_fb_sp10.png new file mode 100644 index 0000000000000000000000000000000000000000..9afdee44c17fcccfe54961b03e4253d39e8db63f GIT binary patch literal 10640 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/labelary_devicefont_images/fG_tb.png b/tests/fixtures/labelary_devicefont_images/fG_tb.png new file mode 100644 index 0000000000000000000000000000000000000000..b29a6f804342ff8eb3591f373da391735cf65bc0 GIT binary patch literal 10640 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 TnqPbZ!JpZ)<|n-~L-za^_Jw1) literal 0 HcmV?d00001 diff --git a/tests/fixtures/labelary_devicefont_images/fG_tb_rotI.png b/tests/fixtures/labelary_devicefont_images/fG_tb_rotI.png new file mode 100644 index 0000000000000000000000000000000000000000..6475f722313c17a57e9cc6243477ee0703da956a GIT binary patch literal 6457 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 V>66v?uOPyslV?u))5E^9{{hT { fs.mkdirSync(FIXTURES_DIR, { recursive: true }); fs.mkdirSync(DEVICE_FIXTURES_DIR, { recursive: true }); @@ -66,25 +78,31 @@ async function main(): Promise { const jobs: FetchJob[] = [ ...[...textBoxMatchCases, ...font0GlyphCoverageCases].map((tc) => { const { ci, fd } = fdFor(tc.text); + const blk = 'block' in tc ? blockCmd(tc.rotation, tc.block) : ''; + const posType = 'posType' in tc ? (tc.posType ?? 'FO') : 'FO'; return { dir: FIXTURES_DIR, id: tc.id, zpl: - `^XA${ci}^FO${tc.x},${tc.y}` + + `^XA${ci}^${posType}${tc.x},${tc.y}` + `^A0${tc.rotation},${tc.fontHeight},${tc.fontWidth || tc.fontHeight}` + - `${fd}^FS^XZ`, + `${blk}${fd}^FS^XZ`, }; }), // Device fonts: width stays 0 so the firmware derives it from the // cell matrix, matching what deviceFontMetrics does locally. - ...deviceFontBoxMatchCases.map((tc) => ({ - dir: DEVICE_FIXTURES_DIR, - id: tc.id, - zpl: - `^XA^FO${tc.x},${tc.y}` + - `^A${tc.fontId}${tc.rotation},${tc.fontHeight},${tc.fontWidth || ''}` + - `^FD${tc.text}^FS^XZ`, - })), + ...deviceFontBoxMatchCases.map((tc) => { + const pos = `^${tc.posType ?? 'FO'}${tc.x},${tc.y}`; + const blk = blockCmd(tc.rotation, tc.block); + return { + dir: DEVICE_FIXTURES_DIR, + id: tc.id, + zpl: + `^XA${pos}` + + `^A${tc.fontId}${tc.rotation},${tc.fontHeight},${tc.fontWidth || ''}` + + `${blk}^FD${tc.text}^FS^XZ`, + }; + }), ]; // The case lists share ids on purpose (see fixtureIdContract).