Feat/zpl cw custom fonts#78
Merged
Merged
Conversation
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.
There was a problem hiding this comment.
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.
- 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.