From 687fd77291d6706cc7baf05d29ee778f864ed387 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 24 May 2026 22:24:14 +0200 Subject: [PATCH] fix(zpl): reverse text uses ^GB+^FR knockout pattern instead of ^LR ^LRY/^LRN wraps a single field with label-reverse, which Zebra firmware (and Labelary) treat as a global bitmap flip rather than per-field inversion. The result: our reverse-text emit produced no visible inversion on print, only the canvas faked the effect. Switch the text emitter to the standard ZPL pattern for white-on- black: a filled black ^GB at the field anchor (sized to the rendered ink bbox) followed by ^FR^FD so the printer knocks the glyphs out of the black. Parser stashes filled-black ^GB candidates and collapses them with a following ^FR text at the same anchor with matching bbox back into a single reverse-text object, so the round-trip stays one-to-one. Hand- written ZPL with unrelated ^GB+^FR pairs at mismatched anchors or sizes round-trips unchanged via the existing box path. Scope limited to text per the open follow-up ticket; box / ellipse / line still emit ^LR via wrapReverse and remain broken on Labelary (rarely used; addressed separately). --- src/components/Canvas/KonvaObject.tsx | 10 +- src/lib/zplGenerator.test.ts | 21 +++ src/lib/zplParser.test.ts | 13 ++ src/lib/zplParser.ts | 239 +++++++++++++++++++------- src/registry/registry.test.ts | 12 +- src/registry/text.tsx | 46 ++++- 6 files changed, 268 insertions(+), 73 deletions(-) diff --git a/src/components/Canvas/KonvaObject.tsx b/src/components/Canvas/KonvaObject.tsx index 994b0e59..2c3bdb1d 100644 --- a/src/components/Canvas/KonvaObject.tsx +++ b/src/components/Canvas/KonvaObject.tsx @@ -277,8 +277,14 @@ function KonvaObjectInner({ // Use the measured ink width (already in dot space, mirrored to // CSS px via dotsToPx) so the inverted background bbox tracks the // actual rendered text rather than a length-based guess. + // Match the printer's ^GB knockout-background exactly: width = + // measured ink width, height = font height in CSS px. Earlier + // versions padded the height to 1.3× for visual breathing room + // but that hid what the printer actually emits — the canvas now + // mirrors the same `^GB inkW, fontHeight` shape the generator + // produces, so the preview matches Labelary and the real device. const approxW = dotsToPx(textMetrics.inkWidthDots, scale, dpmm); - const approxH = fontSizePx * 1.3; + const approxH = fontSizePx; return ( ); diff --git a/src/lib/zplGenerator.test.ts b/src/lib/zplGenerator.test.ts index 3a60a3af..7df8aae3 100644 --- a/src/lib/zplGenerator.test.ts +++ b/src/lib/zplGenerator.test.ts @@ -55,6 +55,27 @@ describe('generateZPL — structure', () => { expect(zpl).toContain('^LS5'); }); + it('reverse text round-trips: emit ^GB+^FR, parse collapses back to one reverse text', () => { + // Generator emits a filled black ^GB knockout-background followed by + // an ^FR text at the same anchor. The parser detects that pair and + // collapses it back into a single text object with reverse:true — + // so the editor never sees two objects after a save/load cycle. + const objs = [ + { id: 'r', type: 'text', x: 50, y: 50, rotation: 0, + props: { content: 'Hi', fontHeight: 30, fontWidth: 0, rotation: 'N', reverse: true } }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any; + const zpl = generateZPL(BASE_LABEL, objs); + expect(zpl).toContain('^GB'); + expect(zpl).toContain('^FR^FD'); + expect(zpl).not.toContain('^LRY'); + const { objects } = parseZPL(zpl, 8); + expect(objects).toHaveLength(1); + expect(objects[0]?.type).toBe('text'); + expect(props(objects[0]).reverse).toBe(true); + expect(props(objects[0]).content).toBe('Hi'); + }); + it('omits objects with includeInExport=false', () => { const objs = [ { id: 'a', type: 'text', x: 10, y: 10, rotation: 0, props: { content: 'KEEP', fontHeight: 30, fontWidth: 0, rotation: 'N', reverse: false } }, diff --git a/src/lib/zplParser.test.ts b/src/lib/zplParser.test.ts index c8fdb4db..27de8a0e 100644 --- a/src/lib/zplParser.test.ts +++ b/src/lib/zplParser.test.ts @@ -136,6 +136,19 @@ describe('parseZPL — ^FR field reverse', () => { const { objects } = parseZPL('^XA^FO0,0^A0N,30,0^FDNormal^FS^XZ', 8); expect(props(objects[0]).reverse).toBeFalsy(); }); + + it('keeps an unrelated filled box + ^FR text at a different anchor as two objects', () => { + // Anchor mismatch ⇒ no collapse. Hand-written ZPL where a black box + // and an ^FR text happen to coexist must round-trip unchanged. + const { objects } = parseZPL( + '^XA^FO10,10^GB60,30,60,B,0^FS^FO200,200^A0N,30,0^FR^FDHi^FS^XZ', + 8, + ); + expect(objects).toHaveLength(2); + expect(objects[0]?.type).toBe('box'); + expect(objects[1]?.type).toBe('text'); + expect(props(objects[1]).reverse).toBe(true); + }); }); // ── shapes ──────────────────────────────────────────────────────────────────── diff --git a/src/lib/zplParser.ts b/src/lib/zplParser.ts index 8ef0ffac..05aaafe4 100644 --- a/src/lib/zplParser.ts +++ b/src/lib/zplParser.ts @@ -589,6 +589,37 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { // ^FR field reverse (single-field reverse, reset on ^FS / new ^FO / ^FT) let frActive = false; + // Pending knockout-background ^GB. Our generator emits reverse text as + // a filled black ^GB followed by an ^FR text at the same anchor — + // standard ZPL pattern for white-on-black. On import we stash a + // candidate ^GB here and, if the next text field comes with ^FR at + // the same anchor and matching bbox, collapse the pair into a single + // text object with `reverse: true`. If the stash never matches it + // gets committed as a regular filled box, so non-pair ^GB+^FR + // sequences in hand-written ZPL round-trip unchanged. + /** Stashed ^GB params. Stores the full GB shape so the commit path + * can replay through the standard line-vs-box detection (a 200×30 + * filled-black GB at t=30 could be either a fat horizontal line or + * our reverse-text background — only the following ^FR text + * disambiguates). */ + let pendingReverseBg: { + x: number; + y: number; + w: number; + h: number; + t: number; + color: "B" | "W"; + rounding: number; + reverseFlag: boolean | undefined; + comment?: string; + } | null = null; + /** Bbox tolerance for collapsing a stashed ^GB with a following ^FR + * text. Emit-time inkWidth and parse-time inkWidth can drift by a + * dot or two depending on whether the PrintLab font is registered + * on both sides; a small tolerance lets the legitimate pair + * collapse without over-collapsing unrelated coincidental layouts. */ + const REVERSE_BBOX_TOLERANCE_DOTS = 2; + // ^LH label home (origin offset applied to all field positions) let lhX = 0; let lhY = 0; @@ -767,6 +798,10 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { // shared helper so parser and generator stay symmetric. const decoded = fbWidth > 0 ? decodeFbContent(content) : content; + // Non-text fields can never be the second half of a reverse-text + // pair, so flush the stashed bg as a regular box before pushing. + // Text handles its own collapse-or-commit inline below. + if (fieldType !== "text") commitPendingReverseBg(); switch (fieldType) { case "text": { // ZPL anchors ^FO at cap-top and ^FT at baseline; our internal @@ -787,8 +822,12 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { posType, inkWidthDots, ); - // If ^SF was pending, create a serial object instead of text + // If ^SF was pending, create a serial object instead of text. + // Serial fields can't be the second half of a reverse-text pair + // (no reverse-serial use case in our model), so flush any + // pending bg as a regular box before pushing the serial. if (snPending) { + commitPendingReverseBg(); objects.push( makeObj( "serial", @@ -812,12 +851,46 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { resetFB(); break; } + // Reverse-text collapse: if the previous field was a filled-black + // ^GB at this same anchor with a matching bbox, and this text is + // ^FR-flagged, the pair is our white-on-black emit. Drop the + // stashed bg and surface a single reverse-text object instead of + // a box + reverse-text. Dim match uses a small dot-tolerance so + // rounding in the emitter and parser can't unpair a legitimate + // pair. Anything that doesn't match flushes the stash as a + // regular box so hand-written ZPL with unrelated ^GB+^FR + // sequences round-trips unchanged. ^FB block-text isn't part of + // the reverse-text emit so collapsing is skipped there too. + const vertical = textRot === "R" || textRot === "B"; + const expectedW = vertical ? textH : Math.max(1, Math.round(inkWidthDots)); + const expectedH = vertical ? Math.max(1, Math.round(inkWidthDots)) : textH; + const collapse = + pendingReverseBg !== null && + frActive && + fbWidth === 0 && + pendingReverseBg.x === x && + pendingReverseBg.y === y && + Math.abs(pendingReverseBg.w - expectedW) <= REVERSE_BBOX_TOLERANCE_DOTS && + Math.abs(pendingReverseBg.h - expectedH) <= REVERSE_BBOX_TOLERANCE_DOTS; + // Preserve any comment that was attached to the stashed ^GB + // (e.g. a `^FX banner` before the bg). Merged with the text's + // own comment so no import metadata is silently dropped. + let mergedComment = comment; + if (collapse) { + const bgComment = pendingReverseBg?.comment; + if (bgComment) { + mergedComment = mergedComment ? `${bgComment}\n${mergedComment}` : bgComment; + } + pendingReverseBg = null; + } else { + commitPendingReverseBg(); + } const textProps: TextProps = { content: decoded, fontHeight: textH, fontWidth: textW, rotation: textRot, - reverse: getReverseFlag(), + reverse: collapse ? true : getReverseFlag(), printerFontName: pendingPrinterFontName, fontId: pendingFontId, }; @@ -830,7 +903,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { textProps.blockJustify = fbJustify; } objects.push( - makeObj("text", modelPos.x, modelPos.y, textProps, posType, comment), + makeObj("text", modelPos.x, modelPos.y, textProps, posType, mergedComment), ); resetFB(); break; @@ -1143,6 +1216,76 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { const getReverseFlag = () => lrActive || frActive || undefined; + /** Push a ^GB-derived object using the standard line-vs-box detection. + * Shared between the GB handler's direct-push path and the + * reverse-bg commit path so a stashed GB that didn't pair with a + * reverse-text gets the same line/box classification it would have + * gotten on a direct parse. */ + const pushGBObject = ( + gx: number, + gy: number, + w: number, + h: number, + t: number, + color: "B" | "W", + rounding: number, + reverseFlag: boolean | undefined, + comment: string | undefined, + ) => { + if (h === t && w > t) { + objects.push( + makeObj( + "line", + gx, + gy, + { angle: 0, length: w, thickness: t, color, reverse: reverseFlag } satisfies LineProps, + undefined, + comment, + ), + ); + } else if (w === t && h > t) { + objects.push( + makeObj( + "line", + gx, + gy, + { angle: 90, length: h, thickness: t, color, reverse: reverseFlag } satisfies LineProps, + undefined, + comment, + ), + ); + } else { + const filled = t >= Math.min(w, h); + objects.push( + makeObj( + "box", + gx, + gy, + { + width: w, + height: h, + thickness: t, + filled, + color, + rounding, + reverse: reverseFlag, + } satisfies BoxProps, + undefined, + comment, + ), + ); + } + }; + + /** Push the stashed reverse-bg as the GB shape it actually was. Called + * when the stash didn't pair with a reverse-text on the next field. */ + const commitPendingReverseBg = () => { + if (!pendingReverseBg) return; + const bg = pendingReverseBg; + pendingReverseBg = null; + pushGBObject(bg.x, bg.y, bg.w, bg.h, bg.t, bg.color, bg.rounding, bg.reverseFlag, bg.comment); + }; + const handlers: Record = { // ── Label dimensions ──────────────────────────────────────────────────── PW(_, rest) { @@ -1426,68 +1569,26 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { const rounding = int(p[4], 0); const gbComment = takeComment(); - // Distinguish line from box: a line has one dimension equal to thickness - if (h === t && w > t) { - objects.push( - makeObj( - "line", - x, - y, - { - angle: 0, - length: w, - thickness: t, - color, - reverse: getReverseFlag(), - } satisfies LineProps, - undefined, - gbComment, - ), - ); - } else if (w === t && h > t) { - objects.push( - makeObj( - "line", - x, - y, - { - angle: 90, - length: h, - thickness: t, - color, - reverse: getReverseFlag(), - } satisfies LineProps, - undefined, - gbComment, - ), - ); - } else { - const filled = t >= Math.min(w, h); - objects.push( - makeObj( - "box", - x, - y, - { - width: w, - height: h, - // Preserve the original thickness so a ZPL round-trip is - // lossless and the renderer can apply Zebra's dimension - // promotion (`max(w,t) × max(h,t)`) for fields where - // thickness exceeds the smaller axis. - thickness: t, - filled, - color, - rounding, - reverse: getReverseFlag(), - } satisfies BoxProps, - undefined, - gbComment, - ), - ); + // Filled-black non-rounded ^GBs (no active ^LR/^FR) are candidate + // reverse-text backgrounds — stash them and let flushField + // collapse the pair when the next field is an ^FR text at the + // same anchor with matching bbox. Stash is opaque: it stores the + // raw GB params so the commit path replays through the same + // line-vs-box detection a direct parse would use (a fat + // horizontal line and a reverse-bg banner share the same GB + // shape; only the following ^FR text disambiguates). + const filled = t >= Math.min(w, h); + const reverseFlag = getReverseFlag(); + if (filled && color === "B" && rounding === 0 && !reverseFlag) { + commitPendingReverseBg(); + pendingReverseBg = { x, y, w, h, t, color, rounding, reverseFlag, comment: gbComment }; + return; } + commitPendingReverseBg(); + pushGBObject(x, y, w, h, t, color, rounding, reverseFlag, gbComment); }, GD(p) { + commitPendingReverseBg(); // ^GD{w},{h},{t},{color},{orientation} // orientation: L = top-left→bottom-right, R = top-right→bottom-left const gdW = int(p[0], 1); @@ -1523,6 +1624,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { ); }, GF(_, rest) { + commitPendingReverseBg(); // ^GF{A|B|C},{totalBytes},{totalBytes},{bytesPerRow},{payload} // // Payload variants the parser understands: @@ -1595,6 +1697,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { ); }, GE(p) { + commitPendingReverseBg(); // ^GE{w},{h},{t},{color} const w = int(p[0], 100); const h = int(p[1], 100); @@ -1623,6 +1726,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { ); }, GC(p) { + commitPendingReverseBg(); // ^GC{diameter},{thickness},{color} → circle = ellipse with equal w/h const d = int(p[0], 100); const t = int(p[1], 3); @@ -1650,6 +1754,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { // ── Recall stored graphic ────────────────────────────────────────────── XG(_, rest) { + commitPendingReverseBg(); // ^XGd:f.x,mx,my — references a graphic uploaded earlier via ~DY. // Two valid imports: // - With preceding ~DY in the stream: full image (bytes + storedAs @@ -1965,11 +2070,19 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { // ^XA…^XZ block into the next one — without this `^FC@^...^XZ^XA` // would parse later default-char tokens differently. XA: (p, rest) => { + // Defensive: flush any stash that survived a malformed prior + // label (missing ^XZ) so it doesn't bleed into the new block. + commitPendingReverseBg(); embedChar = "#"; clockChars = { ...DEFAULT_CLOCK_CHARS }; resetComment(p, rest); }, - XZ: resetComment, + XZ(_, rest) { + // Flush any orphan reverse-bg before the label boundary so it + // doesn't leak across labels in a multi-label stream. + commitPendingReverseBg(); + resetComment(_, rest); + }, // ^FX: comment field — accumulate across consecutive ^FX lines so the // assembled text reaches the next field object as one multi-line comment. FX: appendComment, diff --git a/src/registry/registry.test.ts b/src/registry/registry.test.ts index 71d4f5e4..77764c08 100644 --- a/src/registry/registry.test.ts +++ b/src/registry/registry.test.ts @@ -41,12 +41,18 @@ describe('text.toZPL', () => { expect(zpl).toContain('^FT100,218'); }); - it('emits ^LRY / ^LRN when reverse is true', () => { + it('emits ^GB knockout-background + ^FR for reverse text', () => { const zpl = def.toZPL(makeObj('text', { content: 'Rev', fontHeight: 30, fontWidth: 0, rotation: 'N', reverse: true, })); - expect(zpl).toContain('^LRY'); - expect(zpl).toContain('^LRN'); + // Filled black ^GB at the field anchor (height = fontHeight, width + // from ink metrics), then ^FR before ^FD so the printer knocks the + // glyphs out of the black. Replaces the legacy ^LRY/^LRN wrap + // which Zebra firmware treated as a global label flip rather than + // per-field inversion. + expect(zpl).toMatch(/\^GB\d+,30,\d+,B,0\^FS/); + expect(zpl).toContain('^FR^FD'); + expect(zpl).not.toContain('^LRY'); }); it('emits ^FB for field block properties', () => { diff --git a/src/registry/text.tsx b/src/registry/text.tsx index 5ee09702..b4443eb4 100644 --- a/src/registry/text.tsx +++ b/src/registry/text.tsx @@ -2,7 +2,9 @@ import { useRef, useState, useCallback } from "react"; import type { ObjectTypeDefinition } from "../types/ObjectType"; import { useT } from "../lib/useT"; import { buttonCls, inputCls, labelCls } from "../components/Properties/styles"; -import { textFieldPos, fdFieldFor, resolveFontCmd, wrapReverse } from "./zplHelpers"; +import { textFieldPos, fdFieldFor, resolveFontCmd } from "./zplHelpers"; +import { getTextRenderMetrics } from "../components/Canvas/textRenderMetrics"; +import type { LabelObject } from "../types/Group"; import { effectiveScale } from "./transformHelpers"; import { getFont, loadFontFile } from "../lib/fontCache"; import { getAvailableFontIds, stripDrivePrefix } from "../lib/customFonts"; @@ -79,10 +81,44 @@ export const text: ObjectTypeDefinition = { // printer ignores embedded newlines anyway, so encoding only // happens when blockWidth is set. const content = p.blockWidth ? encodeFbContent(p.content) : p.content; - const body = [textFieldPos(obj), fontCmd, fbCmd, fdFieldFor(obj, content, ctx)] - .filter(Boolean) - .join(""); - return wrapReverse(p.reverse, body); + const anchor = textFieldPos(obj); + const fd = fdFieldFor(obj, content, ctx); + if (!p.reverse) { + return [anchor, fontCmd, fbCmd, fd].filter(Boolean).join(""); + } + // Reverse text = white-on-black knockout. Standard ZPL pattern: + // a filled black ^GB at the field anchor, then the text with ^FR + // (Field Reverse) which inverts the ink within the field bounds, + // knocking the glyphs out of the black. ^GB and the text share + // the same ^FO so the box top aligns with the text cap-top. + // Box dimensions match the rendered ink: width from measured + // metrics, height from fontHeight. For R/B rotations the visible + // bbox is fontHeight wide by inkWidth tall, so the dimensions + // swap. + const metrics = getTextRenderMetrics(obj as unknown as LabelObject); + const fallback = p.fontWidth || p.fontHeight; + const inkW = Math.max(1, Math.round(metrics?.inkWidthDots ?? fallback)); + const vertical = p.rotation === "R" || p.rotation === "B"; + // ^FB block-text wraps to blockWidth across up to blockLines rows, + // so the bg has to cover the block area instead of the single-line + // ink bbox. blockLineSpacing is added per row above the first to + // mirror Zebra's row advance. The parser skips collapse for + // fbWidth>0 so this branch produces a box + reverse-text pair on + // round-trip — accepted trade-off until block-text collapse lands. + const block = p.blockWidth ?? 0; + const lines = p.blockLines ?? 1; + const blockH = p.fontHeight * lines + (p.blockLineSpacing ?? 0) * Math.max(0, lines - 1); + const baseW = block > 0 ? block : inkW; + const baseH = block > 0 ? blockH : p.fontHeight; + const gbW = vertical ? baseH : baseW; + const gbH = vertical ? baseW : baseH; + // Thickness = min(w,h) keeps the box filled (Zebra requires t >= + // min(w,h) for a solid fill) without triggering the dimension + // promotion. ZPL promotes the box to `max(w,t) × max(h,t)`; using + // max here would inflate a 200×30 banner into a 200×200 square. + const gbThickness = Math.min(gbW, gbH); + const gb = `${anchor}^GB${gbW},${gbH},${gbThickness},B,0^FS`; + return [gb, anchor, fontCmd, fbCmd, "^FR", fd].filter(Boolean).join(""); }, PropertiesPanel: ({ obj, onChange }) => {