diff --git a/src/components/Canvas/KonvaObject.tsx b/src/components/Canvas/KonvaObject.tsx index 80cd7ceb..0468e9c0 100644 --- a/src/components/Canvas/KonvaObject.tsx +++ b/src/components/Canvas/KonvaObject.tsx @@ -7,6 +7,7 @@ import type Konva from "konva"; import { dotsToPx, pxToDots } from "../../lib/coordinates"; import { outlineInset } from "../../lib/shapeGeometry"; import { useColorScheme } from "../../lib/useColorScheme"; +import { useLabelStore } from "../../store/labelStore"; import { ZPL_FONT_HEIGHT_TO_CSS_RATIO } from "./textPositionTransforms"; import { getTextRenderMetrics } from "./textRenderMetrics"; import { selectionHandlers, type KonvaObjectProps } from "./konvaObjectProps"; @@ -146,13 +147,19 @@ function KonvaObjectInner({ }: Props) { const fontVersion = useFontCacheVersion(); const colors = useColorScheme(); + // Pass the whole label config so the metrics helper can resolve + // either a per-field `fontId` or the label-wide `defaultFontId` to + // an uploaded preview TTF. ZPL emit/parse intentionally call the + // metrics without `label`, so their ink-width stays PrintLab-ZPL + // based and the round-trip is unaffected. + const label = useLabelStore((s) => s.label); // obj.x/y is the Konva render position (top-left of the EM bbox) — // identical to what every other shape stores. The ZPL anchor (^FO // cap-top / ^FT baseline) lives at obj.x/y + zplAnchorDelta and is // applied only at the I/O boundary by zplGenerator / zplParser, so // every in-editor interaction (drag, resize, snap, smart-align) sees // a shape-agnostic single coordinate system. - const baseMetrics = getTextRenderMetrics(obj); + const baseMetrics = getTextRenderMetrics(obj, undefined, label); const textMetrics = baseMetrics && (obj.type === "text" || obj.type === "serial") ? { diff --git a/src/components/Canvas/textRenderMetrics.ts b/src/components/Canvas/textRenderMetrics.ts index be89190a..343d830d 100644 --- a/src/components/Canvas/textRenderMetrics.ts +++ b/src/components/Canvas/textRenderMetrics.ts @@ -1,5 +1,7 @@ +import { resolvePreviewFontName } from "../../lib/customFonts"; import { getFontFamily } from "../../lib/fontCache"; import type { LabelObject } from "../../types/Group"; +import type { LabelConfig } from "../../types/ObjectType"; import { measureInkWidthPx } from "./measureTextDots"; import { ZPL_FONT_HEIGHT_TO_CSS_RATIO } from "./textPositionTransforms"; @@ -24,14 +26,21 @@ export interface TextMetricsInput { fontHeight: number; fontWidth: number; printerFontName?: string; + /** Canvas-only fallback used when `printerFontName` is empty. Lets + * the renderer apply the label-wide default font (resolved from + * ^CW / ^CF) to text fields that did not pick their own printer + * font. Not passed by emit/parser, so the ZPL round-trip stays + * PrintLab-ZPL based. */ + defaultPrinterFontName?: string; } /** Compute metrics from raw text parameters. Pure once * `measureInkWidthPx` and `getFontFamily` are pure. */ export function computeTextRenderMetrics(input: TextMetricsInput): TextRenderMetrics { - const { content, fontHeight, fontWidth, printerFontName } = input; - const fontFamily = printerFontName - ? (getFontFamily(printerFontName) ?? "'PrintLab ZPL', sans-serif") + const { content, fontHeight, fontWidth, printerFontName, defaultPrinterFontName } = input; + const effectiveFontName = printerFontName || defaultPrinterFontName; + const fontFamily = effectiveFontName + ? (getFontFamily(effectiveFontName) ?? "'PrintLab ZPL', sans-serif") : "'PrintLab ZPL', sans-serif"; const fontScaleX = fontWidth > 0 ? fontWidth / fontHeight : 1; const inkWidthDots = @@ -45,17 +54,37 @@ export function computeTextRenderMetrics(input: TextMetricsInput): TextRenderMet /** Object-shaped wrapper used by the renderer and the resize commit * path. `fontHeightOverride` lets the resize commit see the - * to-be-written fontHeight before it lands in obj.props. */ + * to-be-written fontHeight before it lands in obj.props. + * + * `label` is the canvas-only context used to resolve preview fonts. + * Priority order matches the generator's `^A` priority: + * 1. text-level `fontId` → preview TTF for that alias + * 2. text-level `printerFontName` (legacy filename form) + * 3. label `defaultFontId` → preview TTF for the global default + * The emit path (`textFieldPos`) and the parser intentionally call + * this without `label`, so their ink-width measurements stay + * PrintLab-ZPL based and the ZPL round-trip is unaffected. */ export function getTextRenderMetrics( obj: LabelObject, fontHeightOverride?: number, + label?: Pick, ): TextRenderMetrics | null { if (obj.type !== "text" && obj.type !== "serial") return null; const p = obj.props; + const fieldFontId = obj.type === "text" ? obj.props.fontId : undefined; + const fieldPrinterFontName = + obj.type === "text" ? obj.props.printerFontName : undefined; + const printerFontName = label + ? (resolvePreviewFontName(label, fieldFontId) ?? fieldPrinterFontName) + : fieldPrinterFontName; + const defaultPrinterFontName = label + ? resolvePreviewFontName(label, label.defaultFontId) + : undefined; return computeTextRenderMetrics({ content: obj.type === "serial" ? `#${p.content}` : p.content, fontHeight: fontHeightOverride ?? p.fontHeight, fontWidth: p.fontWidth, - printerFontName: obj.type === "text" ? obj.props.printerFontName : undefined, + printerFontName, + defaultPrinterFontName, }); } diff --git a/src/components/Fonts/FontManager.tsx b/src/components/Fonts/FontManager.tsx index 6c0b72a0..8855e2d8 100644 --- a/src/components/Fonts/FontManager.tsx +++ b/src/components/Fonts/FontManager.tsx @@ -6,7 +6,9 @@ import { useLabelStore } from '../../store/labelStore'; import { useT } from '../../lib/useT'; import { DEFAULT_FONT_DRIVE, + ZPL_BUILTIN_FONT_IDS, ZPL_DRIVE_PREFIXES, + isBuiltinFontId, nextFreeAlias, normalizeAlias, uploadedFontPath, @@ -34,19 +36,29 @@ export function FontManager() { const uploadedNames = new Set(fonts.map((f) => f.name)); - // Partition customFonts: mappings whose path resolves to an uploaded - // font are reflected inline on that font row; the rest live in the - // printer-resident sub-section. Aliases are namespaced globally per - // label, so duplicate detection runs across both lists. + // Partition customFonts into three buckets matched to the three UI + // sections. Discriminator is the *presence* of the `path` property + // (m.path === undefined), not its truthiness — a manual mapping + // freshly added carries an empty-string path while the user types, + // and we must keep that row in the manual section instead of + // mis-routing it into the built-in-preview bucket. We also remember + // each manual entry's index in the full `customFonts` array so the + // section's update / remove handlers can target a specific row even + // when two rows transiently share the same (empty) path. const aliasByPath = new Map(); - const manualMappings: CustomFontMapping[] = []; - for (const m of customFonts ?? []) { - aliasByPath.set(m.path, m.alias); + const manualMappings: { entry: CustomFontMapping; index: number }[] = []; + const builtinPreviews: CustomFontMapping[] = []; + (customFonts ?? []).forEach((m, index) => { + if (m.path === undefined) { + builtinPreviews.push(m); + return; + } + if (m.path) aliasByPath.set(m.path, m.alias); const isUploadedPath = m.path.startsWith(DEFAULT_FONT_DRIVE) && uploadedNames.has(m.path.slice(DEFAULT_FONT_DRIVE.length)); - if (!isUploadedPath) manualMappings.push(m); - } + if (!isUploadedPath) manualMappings.push({ entry: m, index }); + }); const aliasCounts = new Map(); for (const m of customFonts ?? []) { @@ -60,17 +72,53 @@ export function FontManager() { }; const setAliasForPath = (path: string, rawAlias: string) => { - replaceList( - upsertCustomFontMapping(customFonts, path, normalizeAlias(rawAlias)), - ); + // For uploaded fonts the entry should also bind the local TTF for + // canvas preview: derive `previewFontName` from the path so the + // generator / parser / renderer all share one source of truth. + // upsertCustomFontMapping already handles the alias upsert; we + // augment the resulting entry with the preview-binding here. + const alias = normalizeAlias(rawAlias); + const next = upsertCustomFontMapping(customFonts, path, alias); + if (alias) { + const entry = next.find((m) => m.path === path); + if (entry && uploadedNames.has(path.slice(DEFAULT_FONT_DRIVE.length))) { + entry.previewFontName = path.slice(DEFAULT_FONT_DRIVE.length); + } + } + replaceList(next); }; - const updateManualAt = (path: string, patch: Partial) => { + const toggleEmbedForPath = (path: string, embed: boolean) => { const list = customFonts ?? []; replaceList( list.map((m) => m.path === path + ? embed + ? { + ...m, + embedInZpl: true, + // ~DY needs the TTF bytes from fontCache; the upload + // row implies the binding, so pin previewFontName too + // (idempotent when already set). + previewFontName: + m.previewFontName ?? path.slice(DEFAULT_FONT_DRIVE.length), + } + : { ...m, embedInZpl: undefined } + : m, + ), + ); + }; + + const updateManualAt = ( + index: number, + patch: Partial, + ) => { + const list = customFonts ?? []; + replaceList( + list.map((m, i) => + i === index ? { + ...m, alias: patch.alias !== undefined ? normalizeAlias(patch.alias) @@ -82,8 +130,8 @@ export function FontManager() { ); }; - const removeByPath = (path: string) => { - replaceList((customFonts ?? []).filter((m) => m.path !== path)); + const removeAt = (index: number) => { + replaceList((customFonts ?? []).filter((_, i) => i !== index)); }; const addManual = () => { @@ -97,6 +145,50 @@ export function FontManager() { ]); }; + const addBuiltinPreview = () => { + // Pick the first built-in id that does not already have a binding + // so the new row lands on a usable default. If every built-in is + // already bound, fall back to "0" — the user can edit it. + const takenAliases = new Set( + (customFonts ?? []) + .filter((m) => m.path === undefined) + .map((m) => m.alias), + ); + const next = + ZPL_BUILTIN_FONT_IDS.find((id) => !takenAliases.has(id)) ?? '0'; + replaceList([...(customFonts ?? []), { alias: next, previewFontName: '' }]); + }; + + // Built-in-preview rows are keyed by alias (one binding per ID). + // Patch is applied via spread so an explicit `undefined` actually + // clears the field — the previous `?? m.previewFontName` fallback + // turned "Pick a font…" into a no-op because Nullish-coalescing + // ignores undefined patches. + const updateBuiltinPreview = ( + currentAlias: string, + patch: Partial, + ) => { + const list = customFonts ?? []; + replaceList( + list.map((m) => { + if (m.alias !== currentAlias || m.path !== undefined) return m; + const next: CustomFontMapping = { ...m, ...patch }; + if (patch.alias !== undefined) { + next.alias = normalizeAlias(patch.alias) || m.alias; + } + return next; + }), + ); + }; + + const removeBuiltinPreview = (alias: string) => { + replaceList( + (customFonts ?? []).filter( + (m) => !(m.alias === alias && m.path === undefined), + ), + ); + }; + const uploadedPaths = fonts.map((f) => uploadedFontPath(f.name)); return ( @@ -113,13 +205,23 @@ export function FontManager() { {fonts.map((font) => { const path = uploadedFontPath(font.name); const alias = aliasByPath.get(path) ?? ''; + const entry = (customFonts ?? []).find((m) => m.path === path); + // Cross-section visibility: list every built-in alias that + // pins this TTF as a preview binding. Lets the user see why + // the file is "important" before they hit delete. + const previewAliases = builtinPreviews + .filter((m) => m.previewFontName === font.name) + .map((m) => m.alias); return ( setAliasForPath(path, v)} + onEmbedChange={(v) => toggleEmbedForPath(path, v)} onRequestDelete={() => setPendingDelete(font.name)} /> ); @@ -127,7 +229,24 @@ export function FontManager() { {adding ? ( - setAdding(false)} /> + { + // Auto-assign the next free alias when the upload succeeds. + // Closes the "what now?" gap between the upload finishing + // and the embed toggle becoming usable: the user lands on + // a row that is already wired through to ^CW + canvas, with + // an editable alias if they want to override the default. + if (uploadedName) { + const path = uploadedFontPath(uploadedName); + const taken = (customFonts ?? []) + .map((m) => m.alias) + .filter(Boolean); + const alias = nextFreeAlias(taken); + if (alias) setAliasForPath(path, alias); + } + setAdding(false); + }} + /> ) : ( +
+
+ + {name} + + onAliasChange(e.target.value)} + /> + + +
+ {overridesBuiltin && ( +

+ {t.fonts.builtinAliasWarning} +

+ )} + {previewAliases.length > 0 && ( +

+ {t.fonts.usedAsPreview}{' '} + {previewAliases.join(', ')} +

+ )}
); } @@ -236,17 +428,20 @@ function FontEntry({ // ── ManualMappingsSection ────────────────────────────────────────────────────── interface ManualMappingsSectionProps { - mappings: CustomFontMapping[]; + rows: { entry: CustomFontMapping; index: number }[]; hint: string; addLabel: string; isDuplicateAlias: (alias: string) => boolean; - onUpdate: (currentPath: string, patch: Partial) => void; - onRemove: (path: string) => void; + /** `index` refers to the row's position in the full `customFonts` + * list (not in this section's subset) so the parent updates the + * correct entry even when two rows transiently share an empty path. */ + onUpdate: (index: number, patch: Partial) => void; + onRemove: (index: number) => void; onAdd: () => void; } function ManualMappingsSection({ - mappings, + rows, hint, addLabel, isDuplicateAlias, @@ -262,13 +457,14 @@ function ManualMappingsSection({ // not count as "leaving". const handleBlur = ( e: FocusEvent, + index: number, path: string, alias: string, ) => { const row = e.currentTarget; requestAnimationFrame(() => { if (!alias && !path && !row.contains(document.activeElement)) { - onRemove(path); + onRemove(index); } }); }; @@ -276,22 +472,18 @@ function ManualMappingsSection({ return (

{hint}

- {mappings.map((m) => { + {rows.map(({ entry: m, index }) => { const dup = isDuplicateAlias(m.alias); - // Key: stable across edits as long as alias and path don't - // change at the same time. For fresh empty rows the auto- - // assigned alias from nextFreeAlias is unique per row, which - // gives a stable identity until the user types a path. - const rowKey = m.path || `__alias__${m.alias}`; + const path = m.path ?? ''; return (
handleBlur(e, m.path, m.alias)} + onBlur={(e) => handleBlur(e, index, path, m.alias)} > onUpdate(m.path, { alias: e.target.value })} + onChange={(e) => onUpdate(index, { alias: e.target.value })} /> onUpdate(m.path, { path: e.target.value })} + value={path} + onChange={(e) => onUpdate(index, { path: e.target.value })} /> +
+ ); + })} + +
+ ); +} + +// ── BuiltinPreviewSection ───────────────────────────────────────────────────── + +interface BuiltinPreviewSectionProps { + mappings: CustomFontMapping[]; + uploadedFonts: string[]; + hint: string; + addLabel: string; + isDuplicateAlias: (alias: string) => boolean; + onUpdate: (currentAlias: string, patch: Partial) => void; + onRemove: (alias: string) => void; + onAdd: () => void; +} + +function BuiltinPreviewSection({ + mappings, + uploadedFonts, + hint, + addLabel, + isDuplicateAlias, + onUpdate, + onRemove, + onAdd, +}: BuiltinPreviewSectionProps) { + const t = useT(); + + return ( +
+

{hint}

+ {mappings.map((m) => { + const dup = isDuplicateAlias(m.alias); + return ( +
+ + + + {showAdvanced && ( +
+ { - const file = e.target.files?.[0]; - if (file) void handleFontUpload(file); - e.target.value = ""; - }} + type="text" + className={inputCls} + placeholder="ARIAL.TTF" + value={p.printerFontName ?? ""} + onChange={(e) => + onChange({ + printerFontName: e.target.value || undefined, + fontId: e.target.value ? undefined : p.fontId, + }) + } /> - - + {fontLoaded && ( + + {t.registry.text.fontLoaded} + + )} + {fontAssignedButMissing && ( + <> + + {t.registry.text.fontMissing} + + { + const file = e.target.files?.[0]; + if (file) void handleFontUpload(file); + e.target.value = ""; + }} + /> + + + )} +
)}
diff --git a/src/registry/zplHelpers.ts b/src/registry/zplHelpers.ts index 9c0e3957..4aeff298 100644 --- a/src/registry/zplHelpers.ts +++ b/src/registry/zplHelpers.ts @@ -1,4 +1,4 @@ -import type { LabelObjectBase } from "../types/ObjectType"; +import type { LabelObjectBase, ZplEmitContext } from "../types/ObjectType"; import { modelToZplAnchor } from "../components/Canvas/textPositionTransforms"; import { getTextRenderMetrics } from "../components/Canvas/textRenderMetrics"; import type { LabelObject } from "../types/Group"; @@ -13,6 +13,38 @@ interface TextLikeObjForFieldPos extends LabelObjectBase { props: { fontHeight: number; rotation: "N" | "R" | "I" | "B" }; } +/** Build the `^A…` font command for a text-like field. Priority order: + * explicit `fontId` (short `^A{id}` form) → explicit `printerFontName` + * (long `^A@,…E:NAME.TTF` form) → label-wide `defaultFontId` from + * `ctx.label` → `^A0` (the historical baseline). The default-fallback + * branch is what gives `^CF` user-visible effect: ZPL has no "use the + * ^CF font" syntax for per-field ^A, so we splice the default ID in at + * emit time. Without `ctx`, falls straight through to `^A0`, which + * matches the behaviour direct test callers have always seen. */ +export function resolveFontCmd( + props: { + rotation: "N" | "R" | "I" | "B"; + fontHeight: number; + fontWidth: number; + fontId?: string; + printerFontName?: string; + }, + ctx?: ZplEmitContext, +): string { + const { rotation, fontHeight, fontWidth, fontId, printerFontName } = props; + if (fontId) { + return `^A${fontId}${rotation},${fontHeight},${fontWidth}`; + } + if (printerFontName) { + return `^A@${rotation},${fontHeight},${fontWidth},E:${printerFontName}`; + } + const defaultId = ctx?.label.defaultFontId; + if (defaultId) { + return `^A${defaultId}${rotation},${fontHeight},${fontWidth}`; + } + return `^A0${rotation},${fontHeight},${fontWidth}`; +} + /** Emit `^FT` or `^FO` for text/serial objects. obj.x/y is stored as the * Konva render position (EM top-left); the ZPL anchor (cap-top for ^FO, * baseline for ^FT) sits a rotation- and positionType-dependent offset diff --git a/src/types/ObjectType.ts b/src/types/ObjectType.ts index 35adfc5d..f969d3e6 100644 --- a/src/types/ObjectType.ts +++ b/src/types/ObjectType.ts @@ -1,12 +1,42 @@ import type React from 'react'; import { z } from 'zod'; -/** A single ^CW mapping: a 1-character alias [A-Z0-9] paired with a font - * path on the printer's storage (e.g. "E:ARIAL.TTF"). */ -export const customFontMappingSchema = z.object({ - alias: z.string().regex(/^[A-Z0-9]$/), - path: z.string().min(1), -}); +/** A single font mapping. Three row shapes are supported so the editor + * can stay 1:1 with what the printer renders: + * + * 1. **Printer-resident custom font** — `path` set, optional + * `previewFontName`. Emits `^CW{alias},{path}` so the printer + * resolves `^A{alias}` against the path. With `previewFontName` + * also set the canvas renders that TTF; with `embedInZpl` true the + * TTF bytes ship in the ZPL stream via `~DY`. + * 2. **Built-in font preview binding** — alias is one of `0` / `A-H` + * (the fonts every Zebra printer ships with), `path` left empty, + * `previewFontName` points at an uploaded TTF. No `^CW` is emitted; + * the binding is cosmetic so the canvas can show what the built-in + * glyphs actually look like. + * 3. **Manual printer-resident font** — `path` set, no upload. User + * declares "this alias maps to a file already on the printer"; + * canvas falls back to PrintLab ZPL because it has no bytes. + * + * Both `path` and `previewFontName` allow empty strings: while the user + * is editing a fresh row the value may transiently be blank, and we + * want that state to survive a persist/rehydrate round-trip so the + * reload lands on the same row instead of dropping it. Completeness + * ("at least one of the two is non-empty") is enforced at emit time + * via the existing `if (m.alias && m.path)` guards in zplGenerator — + * not as a schema-level refine, because the schema fronts the + * persisted store and the store has to allow in-progress edits. */ +export const customFontMappingSchema = z + .object({ + alias: z.string().regex(/^[A-Z0-9]$/), + path: z.string().optional(), + previewFontName: z.string().optional(), + embedInZpl: z.boolean().optional(), + }) + .refine((m) => !m.embedInZpl || (!!m.path && !!m.previewFontName), { + message: + "embedInZpl requires both a printer path (~DY target) and a preview TTF (~DY bytes)", + }); export type CustomFontMapping = z.infer; export const labelConfigSchema = z.object({ @@ -103,6 +133,15 @@ export interface TransformContext { anchor: { nodeHeight: number; rowHeight: number } | null; } +/** Context passed to `toZPL` so leaf emit functions can reach + * label-wide state (default font ID, ^CW alias map, etc.). Optional — + * most types ignore it; only text/serial currently care about the + * default font fallback. Tests calling `toZPL` directly can omit + * `ctx` and get the no-default-context branch. */ +export interface ZplEmitContext { + label: LabelConfig; +} + export interface ObjectTypeDefinition

{ label: string; icon: string; @@ -132,7 +171,7 @@ export interface ObjectTypeDefinition

{ * instance of the type (e.g. QR / DataMatrix). */ uniformScale?: boolean | ((props: P) => boolean); - toZPL: (obj: LabelObjectBase & { props: P }) => string; + toZPL: (obj: LabelObjectBase & { props: P }, ctx?: ZplEmitContext) => string; /** * Optional hook to enforce type-specific invariants on incoming changes * (e.g. clamp out-of-range coordinates). Called before changes are merged