Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/core/src/lib/anchorRepin.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/lib/customFonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,9 @@ export function builtinFontFamily(fontId: string | undefined): string | undefine
return fontId ? BUILTIN_FONT_FAMILY[fontId] : undefined;
}

export type DeviceFontLabel = Pick<LabelConfig, "customFonts" | "defaultFontId">;
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
Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/lib/labelGeometry/deviceFonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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<string, "upper" | "stripLower"> = {
Expand Down
6 changes: 2 additions & 4 deletions packages/core/src/lib/labelGeometry/textPositionTransforms.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/lib/labelGeometry/textRenderMetrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -139,5 +142,6 @@ export function getTextRenderMetrics(
yOffsetDots: device?.yOffsetDots,
xOffsetDots: device?.xOffsetDots,
letterSpacingDots: device?.letterSpacingDots,
deviceFontId: deviceId,
};
}
2 changes: 2 additions & 0 deletions packages/core/src/lib/objectBounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { gfaHeaderDims, headerByteSource, type ImageProps } from "../registry/im
import { BARCODE_1D_TYPES, STACKED_2D_TYPES, getEntry } from "../registry";
import { GRAPHIC_ANCHOR_TYPES } from "../registry/zplHelpers";
import type { PageLabel } from "../types/LabelConfig";
import { resolveDeviceFontId } from "./customFonts";
import type { Variable } from "../types/Variable";
import { effectiveDpmm } from "../types/LabelConfig";
import { isAxisSwapped, objectRotation, type ZplRotation } from "../registry/rotation";
Expand Down Expand Up @@ -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);
Expand Down
68 changes: 57 additions & 11 deletions packages/core/src/lib/reverseBacking.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
}

Expand Down Expand Up @@ -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;
}

Expand All @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down
16 changes: 10 additions & 6 deletions packages/core/src/lib/textBlock.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -8,15 +8,19 @@ export const FB_DEFAULTS = {
blockJustify: "L" as const,
} satisfies Partial<TextProps>;

/** 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<TextProps, "blockWidth" | "blockLines">,
fontHeight: number,
fontWidth: number,
deviceFontId?: string,
): Partial<TextProps> {
const hardLines = content.split("\n").length;
const patch: Partial<TextProps> = { content };
Expand All @@ -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;
Expand Down
Loading
Loading