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 00000000..9e6689c1 Binary files /dev/null and b/tests/fixtures/labelary_devicefont_images/fA_fb.png differ 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 00000000..e9a94c83 Binary files /dev/null and b/tests/fixtures/labelary_devicefont_images/fG_fb.png differ diff --git a/tests/fixtures/labelary_devicefont_images/fG_fb_ft.png b/tests/fixtures/labelary_devicefont_images/fG_fb_ft.png new file mode 100644 index 00000000..48520cfe Binary files /dev/null and b/tests/fixtures/labelary_devicefont_images/fG_fb_ft.png differ 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 00000000..dece474e Binary files /dev/null and b/tests/fixtures/labelary_devicefont_images/fG_fb_rotI.png differ 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 00000000..9afdee44 Binary files /dev/null and b/tests/fixtures/labelary_devicefont_images/fG_fb_sp10.png differ 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 00000000..b29a6f80 Binary files /dev/null and b/tests/fixtures/labelary_devicefont_images/fG_tb.png differ diff --git a/tests/fixtures/labelary_devicefont_images/fG_tb_ft.png b/tests/fixtures/labelary_devicefont_images/fG_tb_ft.png new file mode 100644 index 00000000..9bbb8aae Binary files /dev/null and b/tests/fixtures/labelary_devicefont_images/fG_tb_ft.png differ 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 00000000..6475f722 Binary files /dev/null and b/tests/fixtures/labelary_devicefont_images/fG_tb_rotI.png differ diff --git a/tests/fixtures/labelary_text_default_images/h84_tb_ft.png b/tests/fixtures/labelary_text_default_images/h84_tb_ft.png new file mode 100644 index 00000000..9a3df821 Binary files /dev/null and b/tests/fixtures/labelary_text_default_images/h84_tb_ft.png differ diff --git a/tests/fixtures/textBoxMatchCases.ts b/tests/fixtures/textBoxMatchCases.ts index 9fbd842d..3c18574b 100644 --- a/tests/fixtures/textBoxMatchCases.ts +++ b/tests/fixtures/textBoxMatchCases.ts @@ -25,6 +25,9 @@ export interface TextBoxMatchCase { rotation: 'N'; x: number; y: number; + posType?: 'FO' | 'FT'; + /** ^TB block sized so each space-separated word wraps to its own line. */ + block?: { mode: 'tb'; widthDots: number; heightDots: number }; } const SIZES = [20, 30, 50, 80]; @@ -76,8 +79,17 @@ const longTextCases: TextBoxMatchCase[] = [ { id: 'h30_mixed', fontHeight: 30, fontWidth: 0, text: 'Artikel: 4711-XL Gewicht 12,5kg', rotation: 'N', x: 20, y: 200 }, ]; +/** Gates the tbBlockExtentDots formula for the scalable path; device + * fonts have their own block gate. */ +const tbAnchorCases: TextBoxMatchCase[] = [ + { id: 'h84_tb_ft', fontHeight: 84, fontWidth: 0, text: 'MMM MMM MMM', + rotation: 'N', x: 50, y: 700, posType: 'FT', + block: { mode: 'tb', widthDots: 250, heightDots: 400 } }, +]; + export const textBoxMatchCases: TextBoxMatchCase[] = [ ...uniformCases, ...stretchedCases, ...longTextCases, + ...tbAnchorCases, ]; diff --git a/tests/scripts/fetch_labelary_default_text_fixtures.ts b/tests/scripts/fetch_labelary_default_text_fixtures.ts index 55b2941c..550f0e70 100644 --- a/tests/scripts/fetch_labelary_default_text_fixtures.ts +++ b/tests/scripts/fetch_labelary_default_text_fixtures.ts @@ -59,6 +59,18 @@ function fdFor(text: string): { ci: string; fd: string } { return { ci, fd: `^FH^FD${hex}` }; } +function blockCmd( + rotation: string, + block?: + | { mode: 'fb'; widthDots: number; lines: number; spacing: number } + | { mode: 'tb'; widthDots: number; heightDots: number }, +): string { + if (block === undefined) return ''; + return block.mode === 'fb' + ? `^FB${block.widthDots},${block.lines},${block.spacing},L,0` + : `^TB${rotation},${block.widthDots},${block.heightDots}`; +} + async function main(): Promise { 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).