diff --git a/src/components/Canvas/KonvaObject.tsx b/src/components/Canvas/KonvaObject.tsx
index 994b0e5..2c3bdb1 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 3a60a3a..7df8aae 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 c8fdb4d..27de8a0 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 8ef0ffa..05aaafe 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 71d4f5e..77764c0 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 5ee0970..b4443eb 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 }) => {