Skip to content

Feat/zpl cw custom fonts#78

Merged
u8array merged 22 commits into
mainfrom
feat/zpl-cw-custom-fonts
May 20, 2026
Merged

Feat/zpl cw custom fonts#78
u8array merged 22 commits into
mainfrom
feat/zpl-cw-custom-fonts

Conversation

@u8array
Copy link
Copy Markdown
Owner

@u8array u8array commented May 20, 2026

No description provided.

u8array added 21 commits May 20, 2026 19:09
When label.defaultFontId resolves to a ^CW alias whose mapping points
at an uploaded TTF, text and serial fields without their own
printerFontName now render in that font. Pure canvas concern: the ZPL
emit/parse paths stay PrintLab-ZPL based so the round-trip is unaffected.
…pings

customFontMappingSchema now accepts optional previewFontName (canvas-only
TTF binding) and embedInZpl (~DY emit flag); path becomes optional so
built-in font IDs (0, A-H) can carry a preview binding without inventing
a fake printer path. TextProps gains fontId for the short ^A{id} form
alongside the existing printerFontName for ^A@ filename references.

New helpers isBuiltinFontId, resolvePreviewFontName, getAvailableFontIds
back the upcoming UI dropdowns. resolveDefaultPrinterFontName is now a
thin wrapper around resolvePreviewFontName so the canvas default-font
fallback shares the resolution path with per-field lookups.

No behaviour change yet: generator, parser and existing UI still operate
on path-based mappings.
resolveFontCmd in registry/zplHelpers picks the ^A form per text-like
field: explicit fontId / printerFontName / label-wide defaultFontId /
^A0 fallback. text and serial both go through this helper so the global
default applies consistently across every text-like type.

ZPL has no "use ^CF font" form for per-field ^A, so we splice the
defaultFontId at emit time. Without this, every field's hard-coded ^A0
silently overrode ^CF on the printer.

toZPL gains an optional ctx parameter (ZplEmitContext { label }) so
leaf emitters can reach label-wide state. Direct test callers can omit
ctx and get the no-default branch.
Dynamic ^A handler now records the font character (e.g. M, B) on the
text/serial field via pendingFontId. The ^A0 static handler does the
same for the legacy form. Round-trip stability is preserved by
suppressing the assignment whenever the font character matches the
active ^CF: the field's ^A repeats the label default, so the model
keeps fontId undefined and the generator's default-fallback branch
restores the same emit.

Previously ^A{X} for a ^CW-mapped alias copied the resolved filename
onto printerFontName; now the field carries the alias char and the
^CW mapping lives once in labelConfig.customFonts. importReport.partial
no longer flags built-in letters (A-H) because they round-trip cleanly;
it still flags aliases with no ^CW mapping so dangling references
surface in the import diff.
getTextRenderMetrics now takes the label config and walks the same
priority order as the generator: text-level fontId → field-level
printerFontName → label-wide defaultFontId. Each resolves through the
shared resolvePreviewFontName helper so a single source of truth feeds
canvas, generator, and parser. KonvaObject passes the full label
instead of a pre-resolved string; the metrics module owns the
resolution detail.

textFieldPos and zplParser keep calling getTextRenderMetrics without a
label argument, so their ink-width measurements remain PrintLab-ZPL
based and the ZPL round-trip is unchanged.
Text Properties Panel surfaces a single Font dropdown built from
getAvailableFontIds(label): "(use label default)" + the nine built-in
Zebra IDs (0, A-H) + every ^CW alias the user has registered. Selecting
an entry pins it as fontId on the field, emitting the short ^A{id}
form. The legacy filename input (printerFontName / ^A@) now lives
behind a collapsible Advanced reveal so round-trip-imported labels
still surface the path but new designs default to the alias-based
workflow.

Adds four locale keys across all 32 locales: useLabelDefault,
builtinSuffix, fontAdvanced, fontFilenameLabel.
Pulls the duplicated /^[A-Z]:/ replace into stripDrivePrefix in
customFonts.ts; PropertiesPanel and text picker now share the helper
along with resolvePreviewFontName. Exports ZPL_BUILTIN_FONT_LETTERS so
the parser's ^A handler can branch on the shared constant instead of
re-typing '0ABCDEFGH'.

Also collapses the font-dropdown labelText into a single template
expression (id + suffix) — the previous nested ternary repeated the
preview-name format across both built-in and custom branches.
Generator emits ~DY{path},A,T,{size},,{hex} before ^XA for every
customFonts mapping that has embedInZpl=true and matching cached bytes.
ASCII-hex format is chosen over Z64 for simplicity (no CRC) and broad
firmware/Labelary support; the payload size doubles but stays well
inside the existing per-font 4 MB cap.

Parser DY handler reverses the encoding: decodes hex back to bytes,
registers the font in the cache via loadFontBytesSync (sync wrapper so
the parser stays non-async), and records the path. A later ^CW for the
same path picks up embedInZpl=true and previewFontName so the
round-trip preserves the user's "ship the bytes" intent.

fontCache gains getFontBytes / loadFontBytes / loadFontBytesSync via a
shared registerBytes core. Only TTF/OTF / ASCII-hex ~DY are imported;
non-supported payloads (Z64, compressed, non-font extensions) fall
through to the existing browser-limit findings.
FontEntry rows for uploaded fonts gain a checkbox column that toggles
embedInZpl on the matching ^CW mapping. Setting the alias on an
uploaded font now also pins previewFontName to the cached TTF — both
fields stayed loosely coupled before, but they describe the same
binding (canvas + on-printer); pinning both gives the generator a
single source of truth and lets the embed toggle skip the additional
patching.

The checkbox is disabled while no alias is assigned: ~DY without a
matching ^CW would push bytes the printer can't reference. Tooltip
spells out the ~DY effect so the user can decide whether to ship the
font with every label or rely on the device-resident copy.
Adds a third collapsible section to the Fonts tab where users bind a
local TTF to one of the nine built-in Zebra font IDs (0, A-H) for
editor preview only. Each row carries no path and emits no ^CW — the
binding lives purely in customFonts so the canvas resolver can show
what the user's printer renders for the built-in glyph, while the ZPL
output stays clean.

Rows are keyed by alias (one binding per ID) with a dedicated update
helper instead of repurposing the path-based upsert. The font dropdown
falls back to the previously-saved name when the upload was removed,
so the binding stays visible and re-bindable instead of vanishing.
Surfaces an amber inline warning on the uploaded-font row when the
user assigns a built-in letter (0, A-H) as the alias — that emits a
^CW which overrides the printer's factory font, almost never the
user's intent. Points them at the "Built-in font previews" section
for an editor-only binding.

Label-properties default font datalist now goes through the same
getAvailableFontIds helper the per-text dropdown uses, so a built-in
preview binding surfaces in the global selector with the same
filename suffix the user sees in text fields.
Three small UX nudges so the built-in preview workflow stops hiding:
- Section auto-opens whenever the user already has at least one
  binding, so reloads land on the bindings the user just made instead
  of a collapsed surface.
- Empty-state teaser sits between the upload list and the printer-
  resident section when fonts exist but no built-in binding does. One
  line, only shown when relevant.
- Uploaded-font rows show "preview for {ids}" when a built-in
  binding references that TTF. Makes the cross-section dependency
  visible before the user accidentally deletes a referenced upload.
updateManualAt and updateBuiltinPreview rebuilt the mapping object
from scratch on every edit, dropping previewFontName or embedInZpl if
they happened to be set. The UI doesn't construct such hybrid entries
today, but a round-trip-imported label or future composition can —
spreading the source object first keeps untouched fields intact and
makes the partial-patch contract honest.
…ng border

isBuiltinFontId("") returned true because String.includes("") is
always true — every FontEntry with a blank alias surfaced the
"overrides built-in" warning. Guards with an explicit length check
and a regression test.

The amber/red warning borders on alias inputs were drawn the same dark
gray as the unmarked state because Tailwind's CSS cascade put
border-border (from inputCls) after border-amber-500 / border-red-500
in source order. Prefixing the conditional classes with ! (important)
makes the warning state visible regardless of utility ordering.
Freshly-uploaded fonts land in the list with an alias already filled
in (next free letter from the I-Z 1-9 range), so the ^CW mapping +
canvas preview light up immediately and the embed toggle is usable on
first render. The user can still type a different letter; this just
removes the half-set-up state the old flow forced through.

AddFontForm's onDone gains an optional uploadedName so cancel and
upload-failed paths still close the form without producing a row.
Three polish passes from the user-perspective walkthrough:

- Embed checkbox label drops the ZPL-jargon: "Send med ZPL" becomes
  "Send with print job" with the ~DY mechanic kept in the tooltip so
  new users see a plain-language label first. Hint rephrased so the
  printer-side use case leads, Labelary stays implicit.
- The built-in-previews teaser now lives inside CollapsibleSection
  itself and only renders while collapsed. Avoids the previous
  Title/teaser duplication once the section is open. teaser slot is
  generic so other sections can adopt the same pattern.
- FontEntry delete control matches the other two sections: always-
  visible TrashIcon instead of the hover-only × glyph. One visual
  pattern across uploaded / manual / built-in rows, more discoverable
  for keyboard / touch users.
The bucket discriminator switched from truthiness to property presence.
Click "Add printer font", path field starts as empty string — the
old !m.path check pushed that row into the built-in-previews bucket,
so the new entry surfaced inside the lower (often collapsed) section
instead of the manual one the user was actually adding to. Routing
now keys off m.path === undefined; an empty-string path stays in the
manual section while the user types.

The matching updateBuiltinPreview / removeBuiltinPreview / addBuiltin
guards switch to the same check so the partitions and the mutators
agree on what "built-in only" means.
Mirrors the built-in-previews auto-open: a manual mapping is invisible
under a collapsed section, and the surrounding flow already paid the
"open the section to add a row" cost. After a reload the user lands
on the rows they last touched instead of a closed surface.
… paths

ManualMappingsSection used the row's path as the delete / update key.
Two fresh rows both carry an empty-string path while the user is
typing them, so clicking the trash on one row removed every empty-
path row at once. The section now hands the row's index in the full
customFonts list down with each entry, and the update / remove
handlers target by index. Uploaded-font rows still key off their
stable E:NAME.TTF path; only the manual section needed the change.
The teaser asked "Want to see what built-in fonts look like? See
section below" but after the prior commit that nested it inside the
CollapsibleSection the text sat on top of the section it referenced,
not above it. More importantly the section title itself already
answers the question — the teaser was doubling that signal.

Removes the now-empty teaser slot from CollapsibleSection and the
locale key from all 32 files.
Two related correctness fixes flagged in the final smell sweep:

- The schema rejected the rows the UI produced while a user was
  typing. addManual creates {alias, path: ''} and addBuiltinPreview
  creates {alias, previewFontName: ''} so the new row renders before
  the user supplies a value; the old .min(1) on each field plus the
  "at least one" refine would have wiped both on rehydrate. Drops
  the per-field min and the at-least-one refine, keeping only the
  embedInZpl refine ("~DY needs both sides"). The emit guards in
  zplGenerator already skip empty-alias / empty-path rows, so loosening
  the schema does not produce invalid ZPL.
- updateBuiltinPreview merged the patch with ,
  which made the "Pick a font…" option a no-op because Nullish
  coalescing ignores undefined patches. Switched to a spread so an
  explicit  actually clears the binding.
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces comprehensive support for custom font management, including the ability to embed font bytes directly into ZPL streams using the ~DY command. Key updates include a redesigned font management UI, improved font resolution logic that prioritizes specific identifiers (fontId, printerFontName, or label defaults), and enhanced ZPL parsing to handle downloaded font payloads. The changes also include extensive localization updates and unit tests for the new font utilities and ZPL generation/parsing logic. Reviewer feedback focuses on performance optimizations for hex encoding and replacing legacy string methods with modern alternatives.

Comment thread src/lib/zplGenerator.ts Outdated
Comment thread src/lib/zplParser.ts Outdated
- Hex-encode the ~DY payload via Array.from + map + join. The previous
  string-concat loop is fine functionally but allocates a fresh string
  on every byte; the declarative form scales better for the larger
  fonts (the cap is 4 MB, ~8 MB hex).
- Swap data.substr() for data.slice(i*2, i*2+2) in the parser's ~DY
  decoder. substr is legacy and discouraged in modern lint configs.

Both are stylistic — no behaviour change, generator output and parser
acceptance are byte-identical to the previous form.
@u8array u8array merged commit 2183e56 into main May 20, 2026
2 checks passed
@u8array u8array deleted the feat/zpl-cw-custom-fonts branch May 20, 2026 21:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant