From 7b4b4d39eab513c6b99b8aab906bbc5b4fc70734 Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 16 May 2026 10:32:48 +0200 Subject: [PATCH 1/5] test(shape): add regression for ^GB thickness > height dimension promotion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User report: filled box `^GB101,92,101` shows as 101×92 in the editor but Labelary renders it 101×101 (Zebra firmware promotes the rendered height up to `thickness` when `t > h`, per the "horizontal line" rule in the ^GB docs). This test fails on main with ~900 pixel diff along the bottom edge of the box, exactly the dimension gap (`thickness - height = 9 dots`) multiplied by the rect width. Fix lands in the follow-up commit. --- .../shape_box_thickness_exceeds_height.png | Bin 0 -> 6465 bytes tests/fixtures/shapeTestCases.ts | 32 ++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 tests/fixtures/labelary_shape_images/shape_box_thickness_exceeds_height.png diff --git a/tests/fixtures/labelary_shape_images/shape_box_thickness_exceeds_height.png b/tests/fixtures/labelary_shape_images/shape_box_thickness_exceeds_height.png new file mode 100644 index 0000000000000000000000000000000000000000..17b06d0ec7fcef1e51db54df329dbfec9d448f15 GIT binary patch literal 6465 zcmeAS@N?(olHy`uVBq!ia0y~yVAcU)4xj+TEan9E;q)Bqtc_nGMtkGQ`>)FvhQJ9 z#2^gH7mFJ-MyvPHX3c18Z?u&=+PWX@^o(})MmxEqegDxhkI}K7(IK$Wp})}~&(WdY g(V^Y}9_nRac-|DT$MVAk4ba?(r>mdKI;Vst0GU0L<^TWy literal 0 HcmV?d00001 diff --git a/tests/fixtures/shapeTestCases.ts b/tests/fixtures/shapeTestCases.ts index 1cb88c2b..e2c27c00 100644 --- a/tests/fixtures/shapeTestCases.ts +++ b/tests/fixtures/shapeTestCases.ts @@ -288,4 +288,36 @@ export const shapeTestCases: ShapeTestCase[] = [ zpl_input: "^XA^FO100,200^GD200,346,6,B,L^FS^XZ", image_ref: "shape_line_diag_steep.png", }, + + // Reverse-print (^LRY) cases — Zebra firmware inverts each ink pixel + // produced by the wrapped field against whatever was already on the + // label. For a filled black ^GB on a white background that means the + // rect's interior turns into a white knockout. renderShape has to + // honour this or the editor will keep showing the inverse-marked field + // as plain black while Labelary prints it as a knockout. + // + // The 101×92 dimensions reproduce a user-reported case where the + // filled box's bottom edge aligned with a separate thin line in the + // editor but Labelary rendered the rect a couple of dots taller — + // exposing an off-by-one in the inset / height math. + { + id: "shape_box_thickness_exceeds_height", + obj: { + id: "20", + type: "box", + // ^GB101,92,101: thickness (101) > height (92). Zebra firmware + // promotes the rendered dimensions to max(w,t) × max(h,t), so the + // visible field is 101×101 rather than the literal 101×92. Our + // renderShape used to clamp to "filled w×h" without the height + // promotion, leaving a ~10-dot strip missing along the bottom + // edge. Reverse=true mirrors the user-reported ZPL but doesn't + // affect geometry (^LRY only inverts ink colour). + x: 144, + y: 160, + rotation: 0, + props: { width: 101, height: 92, thickness: 101, filled: false, color: "B", rounding: 0, reverse: true }, + }, + zpl_input: "^XA^LRY^FO144,160^GB101,92,101,B,0^FS^LRN^XZ", + image_ref: "shape_box_thickness_exceeds_height.png", + }, ]; From d8a778331c728e925b6b216a73090bbf90302042 Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 16 May 2026 10:34:44 +0200 Subject: [PATCH 2/5] fix(shape): promote ^GB filled dimensions to max(w,t) / max(h,t) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zebra ^GB documents a "horizontal line" as `h <= t, w > t` rendered at `w × t` (not `w × h`), and the symmetric rule for vertical lines and filled rects whose thickness exceeds the smaller axis. The renderers were honouring `width × height` literally, so a user-typed `^GB101,92,101` showed up 9 dots short of what Labelary prints. Fold the promotion into `outlineInset` so both the Konva editor render (KonvaObject) and the @napi-rs/canvas test renderer (shapeRender) read their `renderFilled` width/height from one place. The shape regression test added in the previous commit now passes; obj.props stay untouched because ZPL is the source of truth — the canvas only visualises what the firmware would print. --- src/lib/shapeGeometry.ts | 9 +++++++-- src/lib/shapeRender.ts | 19 ++++++++----------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/lib/shapeGeometry.ts b/src/lib/shapeGeometry.ts index 9071dda5..0bd643e6 100644 --- a/src/lib/shapeGeometry.ts +++ b/src/lib/shapeGeometry.ts @@ -44,10 +44,15 @@ export function outlineInset( ): OutlineInset { const clampsToFilled = !filled && t * 2 >= Math.min(w, h); const renderFilled = filled || clampsToFilled; + // Zebra ^GB documents a "horizontal line" as `h <= t, w > t` rendered + // at full thickness (w × t), and symmetrically for vertical. Apply the + // same per-axis promotion here so a filled rect whose declared height + // is below thickness ends up at max(h, t) — Labelary does this and + // the editor must follow or thick boxes look short. return { offset: renderFilled ? 0 : t / 2, - width: renderFilled ? w : Math.max(0, w - t), - height: renderFilled ? h : Math.max(0, h - t), + width: renderFilled ? Math.max(w, t) : Math.max(0, w - t), + height: renderFilled ? Math.max(h, t) : Math.max(0, h - t), renderFilled, }; } diff --git a/src/lib/shapeRender.ts b/src/lib/shapeRender.ts index 62e41d39..5fc70de4 100644 --- a/src/lib/shapeRender.ts +++ b/src/lib/shapeRender.ts @@ -1,5 +1,5 @@ import type { LabelObject } from "../types/Group"; -import { diagonalPolygonPoints } from "./shapeGeometry"; +import { diagonalPolygonPoints, outlineInset } from "./shapeGeometry"; /** Inward-extruded ^GE / ^GC ring or solid disc, shared by ellipse and * circle. Extracted so the two registry types — which carry different @@ -74,18 +74,15 @@ export function renderShape( // evenodd fill once we have a Labelary fixture with rounding>0 // to validate against; the current fixtures all use rounding=0 // so the four-band approach below is exact. - if (p.filled) { - ctx.fillStyle = color; - ctx.fillRect(obj.x, obj.y, p.width, p.height); - return; - } const t = Math.max(1, p.thickness); - // Outline that extrudes inward — clamps to filled rect when the - // outline would meet itself in the middle (Zebra firmware does the - // same: ^GB with thickness >= min(w, h)/2 renders solid). - if (t * 2 >= Math.min(p.width, p.height)) { + const geom = outlineInset(p.width, p.height, t, p.filled); + if (geom.renderFilled) { + // `geom.width` / `geom.height` already account for the Zebra + // `^GB w,h,t` per-axis dimension promotion (max(w,t), max(h,t)) + // so a thickness exceeding height or width extrudes the rendered + // field accordingly, matching Labelary. ctx.fillStyle = color; - ctx.fillRect(obj.x, obj.y, p.width, p.height); + ctx.fillRect(obj.x, obj.y, geom.width, geom.height); return; } // Four filled bands (top, bottom, left, right) avoid the From dd9e3e3aac5138f1d423a0f82060108d961a6f71 Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 16 May 2026 10:40:14 +0200 Subject: [PATCH 3/5] fix(box): preserve thickness verbatim through parse / toZPL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The renderer-side dimension promotion is moot if the parser hands it a clamped thickness. Two changes restore round-trip fidelity for ^GB fields where thickness exceeds the smaller axis: - zplParser.GB: store the original `t` instead of resetting to 3 when `t >= min(w,h)`. The `filled` flag is still derived for the UI, but thickness now carries the firmware's truth. - box.toZPL: emit `t` directly, only floor up to `min(w,h)` when the user toggled `filled` and the stored thickness is below the solid threshold (preserves the "make this solid" intent for UI-driven edits). Together with the renderer fix in the previous commit, the `^GB101,92,101` regression now renders 101 × 101 in the editor and round-trips to `^GB101,92,101` instead of the lossy `^GB101,92,92`. --- src/lib/zplParser.ts | 6 +++++- src/registry/box.tsx | 9 ++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/lib/zplParser.ts b/src/lib/zplParser.ts index 3c5e6b04..313f26e7 100644 --- a/src/lib/zplParser.ts +++ b/src/lib/zplParser.ts @@ -1012,7 +1012,11 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { { width: w, height: h, - thickness: filled ? 3 : t, + // 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, diff --git a/src/registry/box.tsx b/src/registry/box.tsx index 0dd71414..89210215 100644 --- a/src/registry/box.tsx +++ b/src/registry/box.tsx @@ -33,7 +33,14 @@ export const box: ObjectTypeDefinition = { toZPL: (obj) => { const p = obj.props; - const t = p.filled ? Math.min(p.width, p.height) : p.thickness; + // Emit `thickness` verbatim so a ZPL round-trip is lossless. Only + // floor it up to `min(w,h)` when the user toggled `filled` but the + // stored thickness is below the firmware's solid threshold; this + // keeps a user-driven "make this solid" intent in the printed + // output even if they never bumped the thickness slider. + const t = p.filled + ? Math.max(p.thickness, Math.min(p.width, p.height)) + : p.thickness; return [ p.reverse ? '^LRY' : '', fieldPos(obj), From cc5ab71ee4d4a8c0608374c6f5770c8d4b716a83 Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 16 May 2026 11:29:20 +0200 Subject: [PATCH 4/5] refactor(shape): clean up audit findings on the ^GB promotion fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - shapeGeometry.outlineInset: tighten the JSDoc; the prior wording hinged on the "horizontal line" wording from the ^GB docs, but the parser routes literal horizontal / vertical lines into `type: 'line'` before they reach this helper. The actual scope is filled rects whose thickness exceeds one of the two axes. - box.toZPL: lift the `min(w, h)` solid threshold into a named local so the nested-conditional reads top-to-bottom. - shapeTestCases: add the symmetric `thickness > width` case and the "exceeds both axes → square" case. Without them a regression that only broke one axis or only handled the non-square promotion would slip past the height-only fixture. --- src/lib/shapeGeometry.ts | 11 ++-- src/registry/box.tsx | 3 +- .../shape_box_thickness_exceeds_both.png | Bin 0 -> 6464 bytes .../shape_box_thickness_exceeds_width.png | Bin 0 -> 6464 bytes tests/fixtures/shapeTestCases.ts | 61 ++++++++++++------ 5 files changed, 51 insertions(+), 24 deletions(-) create mode 100644 tests/fixtures/labelary_shape_images/shape_box_thickness_exceeds_both.png create mode 100644 tests/fixtures/labelary_shape_images/shape_box_thickness_exceeds_width.png diff --git a/src/lib/shapeGeometry.ts b/src/lib/shapeGeometry.ts index 0bd643e6..70910a92 100644 --- a/src/lib/shapeGeometry.ts +++ b/src/lib/shapeGeometry.ts @@ -44,11 +44,12 @@ export function outlineInset( ): OutlineInset { const clampsToFilled = !filled && t * 2 >= Math.min(w, h); const renderFilled = filled || clampsToFilled; - // Zebra ^GB documents a "horizontal line" as `h <= t, w > t` rendered - // at full thickness (w × t), and symmetrically for vertical. Apply the - // same per-axis promotion here so a filled rect whose declared height - // is below thickness ends up at max(h, t) — Labelary does this and - // the editor must follow or thick boxes look short. + // Per-axis dimension promotion for solid rects: when the user-typed + // thickness exceeds an axis the printed field grows to thickness on + // that axis (Zebra firmware extrudes the outline outward in that + // case rather than clipping it to the declared bbox). Pure single- + // axis lines hit a different parser branch and are stored as `line` + // objects, so this only fires for ^GB rects where t > w or t > h. return { offset: renderFilled ? 0 : t / 2, width: renderFilled ? Math.max(w, t) : Math.max(0, w - t), diff --git a/src/registry/box.tsx b/src/registry/box.tsx index 89210215..a793ca83 100644 --- a/src/registry/box.tsx +++ b/src/registry/box.tsx @@ -38,8 +38,9 @@ export const box: ObjectTypeDefinition = { // stored thickness is below the firmware's solid threshold; this // keeps a user-driven "make this solid" intent in the printed // output even if they never bumped the thickness slider. + const solidThreshold = Math.min(p.width, p.height); const t = p.filled - ? Math.max(p.thickness, Math.min(p.width, p.height)) + ? Math.max(p.thickness, solidThreshold) : p.thickness; return [ p.reverse ? '^LRY' : '', diff --git a/tests/fixtures/labelary_shape_images/shape_box_thickness_exceeds_both.png b/tests/fixtures/labelary_shape_images/shape_box_thickness_exceeds_both.png new file mode 100644 index 0000000000000000000000000000000000000000..e7135514f92b5499e043b8b55c749b9ae913b527 GIT binary patch literal 6464 zcmeAS@N?(olHy`uVBq!ia0y~yVAcU)4xj+T)|M&?$gCJOGpAyLC7*a7O`G-B1+d&NlE(61!ra%L(4yMI3 zQ$>21ln!R3NwO|(@GzJa36dA;Nt*_eW*LW@F~MXruwZvkkvOXZlIL_Y z1|@Bmrcvq9U>QvgquF4zbQmoaM{9%8N^-PuFxn^@Z7Ytpkw^Omqg|%auHtAHd35Ap hbi`!9M~avjKA(=*xTjh!7C6zt;OXk;vd$@?2>=61Xx{(; literal 0 HcmV?d00001 diff --git a/tests/fixtures/labelary_shape_images/shape_box_thickness_exceeds_width.png b/tests/fixtures/labelary_shape_images/shape_box_thickness_exceeds_width.png new file mode 100644 index 0000000000000000000000000000000000000000..148c48709253f0ea5cf4148bf72e44ea10339477 GIT binary patch literal 6464 zcmeAS@N?(olHy`uVBq!ia0y~yVAcU)4xj+T)|M&?$g#tQDfAuqnGU14C5Smx>C7*a7O`G-B1+d&NlE(61!ra%L(4yMI3 zQ$>21ln!R3NwO|(@GzJa36dA;Nt*_eW*LlAK#9GL zS>mkD;sg-^PB-Jz8WOB7O&8A?ak(*0OfcCD%q$Knqtc_nGMXGlvjHtihlJ6Jd9=kb z+7KIUfQ~l2NBbkA{jkyg=V-TlbU0#k7-n?%X>|B$I1WFJ4zrF9FOQD8W6U-%FVdQ&MBb@0D(r300000 literal 0 HcmV?d00001 diff --git a/tests/fixtures/shapeTestCases.ts b/tests/fixtures/shapeTestCases.ts index e2c27c00..0515f784 100644 --- a/tests/fixtures/shapeTestCases.ts +++ b/tests/fixtures/shapeTestCases.ts @@ -289,29 +289,21 @@ export const shapeTestCases: ShapeTestCase[] = [ image_ref: "shape_line_diag_steep.png", }, - // Reverse-print (^LRY) cases — Zebra firmware inverts each ink pixel - // produced by the wrapped field against whatever was already on the - // label. For a filled black ^GB on a white background that means the - // rect's interior turns into a white knockout. renderShape has to - // honour this or the editor will keep showing the inverse-marked field - // as plain black while Labelary prints it as a knockout. - // - // The 101×92 dimensions reproduce a user-reported case where the - // filled box's bottom edge aligned with a separate thin line in the - // editor but Labelary rendered the rect a couple of dots taller — - // exposing an off-by-one in the inset / height math. + // Dimension-promotion cases for ^GB rects where thickness exceeds an + // axis. Zebra firmware extrudes solid fields out to `max(w, t)` / + // `max(h, t)`; renderShape used to draw the literal `w × h` and miss + // the strip along the affected edge. Each case picks a different + // axis so a regression touching only one branch is caught. { id: "shape_box_thickness_exceeds_height", obj: { id: "20", type: "box", - // ^GB101,92,101: thickness (101) > height (92). Zebra firmware - // promotes the rendered dimensions to max(w,t) × max(h,t), so the - // visible field is 101×101 rather than the literal 101×92. Our - // renderShape used to clamp to "filled w×h" without the height - // promotion, leaving a ~10-dot strip missing along the bottom - // edge. Reverse=true mirrors the user-reported ZPL but doesn't - // affect geometry (^LRY only inverts ink colour). + // ^GB101,92,101: thickness > height → rect grows downward by + // (t - h) = 9 dots. Reproduces the user-reported case where the + // editor's box bottom was 9 dots above Labelary's. Reverse=true + // mirrors the original ZPL; ^LRY only inverts ink colour and + // does not affect geometry. x: 144, y: 160, rotation: 0, @@ -320,4 +312,37 @@ export const shapeTestCases: ShapeTestCase[] = [ zpl_input: "^XA^LRY^FO144,160^GB101,92,101,B,0^FS^LRN^XZ", image_ref: "shape_box_thickness_exceeds_height.png", }, + { + id: "shape_box_thickness_exceeds_width", + obj: { + id: "21", + type: "box", + // ^GB80,150,120: thickness > width → rect grows rightward by + // (t - w) = 40 dots. Symmetric of the above; catches a fix that + // only handles the height axis. + x: 100, + y: 100, + rotation: 0, + props: { width: 80, height: 150, thickness: 120, filled: false, color: "B", rounding: 0 }, + }, + zpl_input: "^XA^FO100,100^GB80,150,120,B,0^FS^XZ", + image_ref: "shape_box_thickness_exceeds_width.png", + }, + { + id: "shape_box_thickness_exceeds_both", + obj: { + id: "22", + type: "box", + // ^GB60,40,90: thickness exceeds both axes → 90×90 square. + // Square is the firmware's documented "create a square" form + // (w, h, t all equal); the promotion path must collapse to that + // shape when t pulls both axes up to the same value. + x: 100, + y: 100, + rotation: 0, + props: { width: 60, height: 40, thickness: 90, filled: false, color: "B", rounding: 0 }, + }, + zpl_input: "^XA^FO100,100^GB60,40,90,B,0^FS^XZ", + image_ref: "shape_box_thickness_exceeds_both.png", + }, ]; From 9ba36c77ecababafa0d0d4be471215bc56d4664a Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 16 May 2026 11:56:44 +0200 Subject: [PATCH 5/5] refactor(shape): opt in to ^GB dimension promotion explicitly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini review pointed out that ^GE / ^GC follow a different rule than ^GB: per the Zebra docs they collapse to a solid shape at their declared bbox when thickness exceeds the radius / axes, with no per-axis promotion. Applying `max(w,t) × max(h,t)` unconditionally inside `outlineInset` would silently change ellipse / circle behaviour for very thick outlines. Add a `promoteFilled` flag (defaults to false) and switch only the two ^GB call sites (renderShape + KonvaObject) on. Current ellipse and circle tests still pass; future thick-outline ellipse cases stay on the correct firmware-matching branch. --- src/components/Canvas/KonvaObject.tsx | 6 +++++- src/lib/shapeGeometry.ts | 19 +++++++++++-------- src/lib/shapeRender.ts | 10 +++++----- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/components/Canvas/KonvaObject.tsx b/src/components/Canvas/KonvaObject.tsx index ca24fce9..f6b95df6 100644 --- a/src/components/Canvas/KonvaObject.tsx +++ b/src/components/Canvas/KonvaObject.tsx @@ -271,7 +271,11 @@ function KonvaObjectInner({ // stroke on the inset rect places the band exactly inside the // declared bbox; the firmware's clamp-to-solid rule is handled by // `renderFilled`. - const insetGeom = outlineInset(w, h, strokeWidth, p.filled); + // promoteFilled=true: see note in shapeRender.ts — ^GB rects extrude + // their solid fill to max(w,t) × max(h,t) per Zebra firmware. The + // ellipse / circle branches below leave this off because ^GE / ^GC + // collapse to solid at their declared bbox without promotion. + const insetGeom = outlineInset(w, h, strokeWidth, p.filled, true); const renderFilled = insetGeom.renderFilled; const insetCornerRadius = renderFilled ? cornerRadius diff --git a/src/lib/shapeGeometry.ts b/src/lib/shapeGeometry.ts index 70910a92..109d762a 100644 --- a/src/lib/shapeGeometry.ts +++ b/src/lib/shapeGeometry.ts @@ -41,19 +41,22 @@ export function outlineInset( h: number, t: number, filled: boolean, + /** When true, a solid-rendered field extends to `max(w, t) × max(h, t)`. + * This per-axis promotion is documented for ^GB rects only ("horizontal + * line" rule and its vertical mirror); ^GE / ^GC just collapse to solid + * at their declared bbox dimensions, so callers from those code paths + * leave this off. Pure single-axis lines hit a different parser branch + * and never reach this helper. */ + promoteFilled = false, ): OutlineInset { const clampsToFilled = !filled && t * 2 >= Math.min(w, h); const renderFilled = filled || clampsToFilled; - // Per-axis dimension promotion for solid rects: when the user-typed - // thickness exceeds an axis the printed field grows to thickness on - // that axis (Zebra firmware extrudes the outline outward in that - // case rather than clipping it to the declared bbox). Pure single- - // axis lines hit a different parser branch and are stored as `line` - // objects, so this only fires for ^GB rects where t > w or t > h. + const fillW = promoteFilled ? Math.max(w, t) : w; + const fillH = promoteFilled ? Math.max(h, t) : h; return { offset: renderFilled ? 0 : t / 2, - width: renderFilled ? Math.max(w, t) : Math.max(0, w - t), - height: renderFilled ? Math.max(h, t) : Math.max(0, h - t), + width: renderFilled ? fillW : Math.max(0, w - t), + height: renderFilled ? fillH : Math.max(0, h - t), renderFilled, }; } diff --git a/src/lib/shapeRender.ts b/src/lib/shapeRender.ts index 5fc70de4..36bbfb51 100644 --- a/src/lib/shapeRender.ts +++ b/src/lib/shapeRender.ts @@ -75,12 +75,12 @@ export function renderShape( // to validate against; the current fixtures all use rounding=0 // so the four-band approach below is exact. const t = Math.max(1, p.thickness); - const geom = outlineInset(p.width, p.height, t, p.filled); + // promoteFilled=true: ^GB rects extrude solid fields to + // max(w,t) × max(h,t) (Zebra "horizontal line" rule). Without it, + // a 101×92 rect declared with thickness 101 would be drawn 9 dots + // short along its bottom edge compared to Labelary. + const geom = outlineInset(p.width, p.height, t, p.filled, true); if (geom.renderFilled) { - // `geom.width` / `geom.height` already account for the Zebra - // `^GB w,h,t` per-axis dimension promotion (max(w,t), max(h,t)) - // so a thickness exceeding height or width extrudes the rendered - // field accordingly, matching Labelary. ctx.fillStyle = color; ctx.fillRect(obj.x, obj.y, geom.width, geom.height); return;