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 9071dda5..109d762a 100644 --- a/src/lib/shapeGeometry.ts +++ b/src/lib/shapeGeometry.ts @@ -41,13 +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; + const fillW = promoteFilled ? Math.max(w, t) : w; + const fillH = promoteFilled ? Math.max(h, t) : h; return { offset: renderFilled ? 0 : t / 2, - width: renderFilled ? w : Math.max(0, w - t), - height: renderFilled ? h : 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 62e41d39..36bbfb51 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)) { + // 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) { 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 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..a793ca83 100644 --- a/src/registry/box.tsx +++ b/src/registry/box.tsx @@ -33,7 +33,15 @@ 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 solidThreshold = Math.min(p.width, p.height); + const t = p.filled + ? Math.max(p.thickness, solidThreshold) + : p.thickness; return [ p.reverse ? '^LRY' : '', fieldPos(obj), 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 00000000..e7135514 Binary files /dev/null and b/tests/fixtures/labelary_shape_images/shape_box_thickness_exceeds_both.png differ 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 00000000..17b06d0e Binary files /dev/null and b/tests/fixtures/labelary_shape_images/shape_box_thickness_exceeds_height.png differ 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 00000000..148c4870 Binary files /dev/null and b/tests/fixtures/labelary_shape_images/shape_box_thickness_exceeds_width.png differ diff --git a/tests/fixtures/shapeTestCases.ts b/tests/fixtures/shapeTestCases.ts index 1cb88c2b..0515f784 100644 --- a/tests/fixtures/shapeTestCases.ts +++ b/tests/fixtures/shapeTestCases.ts @@ -288,4 +288,61 @@ export const shapeTestCases: ShapeTestCase[] = [ zpl_input: "^XA^FO100,200^GD200,346,6,B,L^FS^XZ", image_ref: "shape_line_diag_steep.png", }, + + // 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 > 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, + 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", + }, + { + 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", + }, ];