feat(font): PrintLab ZPL Bold + ZPL-correct text positioning#67
Conversation
Add a PrintLab ZPL Bold font (Roboto Condensed derivative, Apache-2.0) with per-glyph advance widths and vertical scale fitted to Zebra's CG Triumvirate so editor layout matches the printed label. Family is renamed to avoid collision with upstream Roboto. License and NOTICE included in src/assets/fonts/. Rebuild text positioning to bridge two anchor semantics: Konva anchors at the EM-top-left and rotates the whole node around that point, while ZPL anchors at the cap-top (^FO) or baseline (^FT) and keeps the cell extending right-down regardless of rotation. The new shift table in textPositionTransforms maps every rotation x positionType to the screen offset needed so the rendered bbox lands where Labelary draws it. I and B FO branches additionally consume the measured ink width because Konva's rotation pivots at the anchor. Add textBoxMatch (our font vs Labelary default) and textVisualRegression (same font on both sides) suites with Labelary-generated fixtures.
There was a problem hiding this comment.
Code Review
This pull request introduces a custom "PrintLab ZPL" font and a refined text positioning system to improve rendering accuracy compared to Zebra's native output. Key changes include a new "measureInkWidthPx" utility for precise text measurement and an updated "anchorDelta" transform that handles "^FO" and "^FT" anchors across all rotations. The PR also adds comprehensive visual and bounding-box regression tests. Feedback suggests consolidating duplicated font property calculations and replacing a hardcoded width heuristic in reversed text rendering with the new measurement utility.
Pre-branch, obj.x/y stored the ZPL anchor (^FO cap-top or ^FT baseline) and the renderer applied a rotation- and positionType-dependent shift so the Konva text-bbox landed where Labelary draws. That shift hung off every interaction path (drag, resize, snap, smart-align), and on rotated text it interacted poorly with Konva's transformer math — corner-drags produced 1e15-class scale values, smart-align translated instead of resizing, and pinInactiveEdges restored the wrong edges. Move the shift to the I/O boundary. obj.x/y now stores the Konva render position (= EM top-left of the rendered text) — the same coordinate system every other shape uses. zplGenerator's textFieldPos converts to the ZPL anchor on emit, zplParser converts back on import. Editor-side interactions stay shape-agnostic. Concrete fixes that fall out: - Wrapper Group around the rotated Text so the transformer-attached node is axis-aligned (Konva's rotated-node bbox math is the source of the rotated-text-resize drift). - Rectangle resize on text + serial: corner-drag updates fontHeight via sy and fontWidth via sx independently, mirroring box/ellipse. - Sanity-clamp on transformer scale so a Konva NaN/Infinity can't blow obj coordinates to 1e15. - useFontCacheVersion bumps on document.fonts loadingdone so Konva's internal text-width cache is invalidated when @font-face finishes loading; key={fontVersion} on the Text re-mounts it so the cap-top position stops drifting between mounts. - Gemini-PR feedback: shared text metrics (getTextRenderMetrics) used by both the renderer and the resize commit; reverse text background uses measureInkWidthPx instead of the 0.62 heuristic. Test suite extended with 32 byte-exact round-trip tests covering ^FO/^FT x N/R/I/B x {h=20,30,50,87}: parse(zpl) -> generate must yield the input ZPL. Plus a two-pass test that catches drift across multiple import/export cycles.
The previous FO shift table only applied the small `pad - bias` ascender
correction for R/I/B rotations, which is correct for FT (baseline) but
wrong for FO. ZPL's ^FO is documented as the top-left of the character
field regardless of rotation — but Konva's rotation pivot lands at a
different corner of the rotated bbox per rotation:
R: ^FO is top-right of the visible field, Konva pivot is bottom-left
of the rotated bbox → need to shift by -h
I: ^FO is bottom-right, need shifts by -w and -h
B: ^FO is bottom-left of visible field, Konva pivot is bottom-right
of rotated bbox → need to shift by -w
zplAnchorDelta now takes inkWidthDots and adds those h/w jumps for FO
R/I/B. zplHelpers.textFieldPos (emit) and zplParser (parse) both feed
the measured ink width through so the conversion sees the same value.
Side effects:
- text.tsx / serial.tsx commitTransform swaps sx/sy for R/B rotations
so the user's vertical mouse drag still controls fontHeight regardless
of which axis Konva considers "the height" post-rotation.
- measureTextDots gains a typeof-guard for environments where
CanvasRenderingContext2D has no measureText (unit tests in jsdom).
Verified visually against Labelary for h=441 R, h=275 I, h=275 B —
editor and preview now land at identical dot positions. All 881 tests
green, including the rotation×positionType byte-exact round-trips.
Match the codebase's single-quote / single-line-args style (zplGenerator, zplParser, lib helpers etc), and add explicit assertions for the two FO rotations that previously only had round-trip coverage: FO/I subtracts the measured ink width on X and FO/B subtracts it on Y, both on top of the (h ± pad ∓ bias) jump. Without these, a sign error on the inkWidth term in FO/I or FO/B would slip through — the round-trip loop pairs the same expression on both sides and would not catch a consistently-wrong direction.
…able types The text/serial fix for "vertical mouse drag controls fontHeight even when rotation R/B swaps the axes in screen space" applies the same way to every other rotatable shape whose commit math distinguishes the two scale axes: 1D barcodes (height vs. moduleWidth) and stacked 2D codes (rowHeight vs. moduleWidth). The 2D codes that use `commitUniformScaleTransform` are symmetric in sx/sy and stay untouched. Extract the swap into `effectiveScale(rotation, ctx)` in transformHelpers and route all four commit paths through it: - text.tsx, serial.tsx (already had inline swaps — collapsed to shared) - commitBarcodeWidthHeightTransform - commitStacked2DTransform Also de-duplicate the text-metrics derivation: zplParser was recomputing fontFamily / fontScaleX / inkWidthDots inline with the same formula `getTextRenderMetrics` uses. Both now route through a new `computeTextRenderMetrics` primitive that takes raw text parameters, so emit and parse measure the same way by construction. Drive-by: drop the stale `objectToDisplay` / `displayToObject` mentions in `textRenderMetrics.ts` jsdoc.
The clamp on `node.scaleX/scaleY()` to (0.01, 100) was defensive against the Konva transformer producing 1e15-class scale values when applied to a *rotated* node (its bbox math hits a near-zero divisor on certain corner drags). With every rotatable type now wrapped in an unrotated outer Group — text/serial via KonvaObject, barcodes already did so — the transformer never sees a rotated node directly, so the pathological condition the clamp guarded against doesn't arise. Tests can't catch a Konva-internal NaN since it only happens during live drag interaction; the empirical signal is that the resize-induced 1e15 coordinates in the bug reports stopped reproducing once the wrapper Group landed.
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces the 'PrintLab ZPL Bold' font and refactors the text and serial object coordinate systems to use the visual top-left for internal storage, applying ZPL anchor shifts only during import and export. Key changes include new text measurement utilities, rotation-aware scaling logic, and updated Konva rendering to prevent drift during resizing. Feedback identifies a potential issue in transformPosition.ts where text rotation offsets might still cause jumps if not explicitly handled, and a discrepancy in useKonvaTransformer.ts between the implementation and comments regarding visible-glyph snapping.
- useKonvaTransformer.ts: stale block describing a visible-glyph bbox-conversion that was prototyped, reverted, and accidentally left documented. Code went back to passing the EM bbox directly to the snap helpers, so the comment is gone now. - transformPosition.ts: the "pass-through for text" line was accurate but didn't say *why* it's safe. Spelled out that obj.x/y is the wrapper Group's position and Konva pins the Group's axis-aligned clientRect, so the model stays in sync with the visible pinned corner without an inversion step here.
No description provided.