diff --git a/.gitignore b/.gitignore
index 5cefa986..37613dc5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,6 +25,7 @@ coverage/
# Test output
tests/fixtures/__diffs__/
+tests/fixtures/__shape_diffs__/
# Editor directories and files
.vscode/*
@@ -51,4 +52,5 @@ tests/fixtures/__diffs__/
*.instructions.*
!tests/fixtures/labelary_images/*.png
+!tests/fixtures/labelary_shape_images/*.png
CLAUDE.md
diff --git a/src/components/Canvas/KonvaObject.tsx b/src/components/Canvas/KonvaObject.tsx
index 578746e8..6b62d461 100644
--- a/src/components/Canvas/KonvaObject.tsx
+++ b/src/components/Canvas/KonvaObject.tsx
@@ -6,6 +6,7 @@ import { LineObject } from "./LineObject";
import { ImageObject } from "./ImageObject";
import type Konva from "konva";
import { dotsToPx, pxToDots } from "../../lib/coordinates";
+import { outlineInset } from "../../lib/shapeGeometry";
import { useColorScheme } from "../../lib/useColorScheme";
import {
objectToDisplay,
@@ -277,6 +278,17 @@ function KonvaObjectInner({
const strokeWidth = Math.max(dotsToPx(p.thickness, scale, dpmm), 0.5);
const cornerRadius =
p.rounding * dotsToPx(Math.min(p.width, p.height) / 8, scale, dpmm);
+ // Option-A geometry (delegated to lib/shapeGeometry.ts so the Konva
+ // canvas, the @napi-rs pixel-regression renderer, and any future
+ // consumer share one definition of ZPL ^GB extrusion). Centred
+ // 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);
+ const renderFilled = insetGeom.renderFilled;
+ const insetCornerRadius = renderFilled
+ ? cornerRadius
+ : Math.max(0, cornerRadius - strokeWidth / 2);
// Inverted (^LRY) regions print as a knockout. The difference-blend
// body renders print-correctly: on the white label it produces black
@@ -299,16 +311,19 @@ function KonvaObjectInner({
// and outlined indistinguishable on canvas.
const isReverse = !!p.reverse;
const shapeColor = p.color === "B" ? "#000000" : "#cccccc";
+ // `renderFilled` includes the firmware clamp-to-solid case, so a
+ // very-thick outline picks the filled fill/stroke pair instead of
+ // collapsing into a degenerate inset rect.
const stroke = isReverse
- ? p.filled
+ ? renderFilled
? "transparent"
: "#ffffff"
: shapeColor;
const fill = isReverse
- ? p.filled
+ ? renderFilled
? "#ffffff"
: "transparent"
- : p.filled
+ : renderFilled
? shapeColor
: "transparent";
// Wrap body + selection overlay in a draggable Group so both move
@@ -333,22 +348,25 @@ function KonvaObjectInner({
onDragEnd={handleDragEnd}
>
{isSelected && (
@@ -363,7 +381,14 @@ function KonvaObjectInner({
const ry = dotsToPx(p.height, scale, dpmm) / 2;
const stroke = p.color === "B" ? "#000000" : "#cccccc";
const strokeWidth = Math.max(dotsToPx(p.thickness, scale, dpmm), 0.5);
- const fill = p.filled
+ // Option-A geometry — same outlineInset() definition as the box
+ // path so the firmware's clamp-to-solid rule stays consistent
+ // across shapes; only the centred-stroke placement differs.
+ const insetGeom = outlineInset(rx * 2, ry * 2, strokeWidth, p.filled);
+ const renderFilled = insetGeom.renderFilled;
+ const insetRx = insetGeom.width / 2;
+ const insetRy = insetGeom.height / 2;
+ const fill = renderFilled
? p.color === "B"
? "#000000"
: "#ffffff"
@@ -373,10 +398,16 @@ function KonvaObjectInner({
id={obj.id}
x={x + rx}
y={y + ry}
- radiusX={rx}
- radiusY={ry}
+ radiusX={insetRx}
+ radiusY={insetRy}
stroke={isSelected ? colors.selection : stroke}
- strokeWidth={isSelected ? Math.max(strokeWidth, 1.5) : strokeWidth}
+ strokeWidth={
+ isSelected
+ ? Math.max(strokeWidth, 1.5)
+ : renderFilled
+ ? 0
+ : strokeWidth
+ }
strokeScaleEnabled={false}
fill={fill}
draggable
@@ -401,7 +432,11 @@ function KonvaObjectInner({
const r = dotsToPx(p.diameter, scale, dpmm) / 2;
const stroke = p.color === "B" ? "#000000" : "#cccccc";
const strokeWidth = Math.max(dotsToPx(p.thickness, scale, dpmm), 0.5);
- const fill = p.filled
+ // Option-A geometry — same outlineInset() definition as box/ellipse.
+ const insetGeom = outlineInset(r * 2, r * 2, strokeWidth, p.filled);
+ const renderFilled = insetGeom.renderFilled;
+ const insetR = insetGeom.width / 2;
+ const fill = renderFilled
? p.color === "B"
? "#000000"
: "#ffffff"
@@ -411,9 +446,15 @@ function KonvaObjectInner({
id={obj.id}
x={x + r}
y={y + r}
- radius={r}
+ radius={insetR}
stroke={isSelected ? colors.selection : stroke}
- strokeWidth={isSelected ? Math.max(strokeWidth, 1.5) : strokeWidth}
+ strokeWidth={
+ isSelected
+ ? Math.max(strokeWidth, 1.5)
+ : renderFilled
+ ? 0
+ : strokeWidth
+ }
strokeScaleEnabled={false}
fill={fill}
draggable
diff --git a/src/components/Canvas/LineObject.tsx b/src/components/Canvas/LineObject.tsx
index 040b98da..6f81d4ec 100644
--- a/src/components/Canvas/LineObject.tsx
+++ b/src/components/Canvas/LineObject.tsx
@@ -6,6 +6,7 @@ import { dotsToPx, pxToDots } from "../../lib/coordinates";
import { constrainLine, type ConstrainMode } from "../../lib/lineConstrain";
import { useColorScheme } from "../../lib/useColorScheme";
import { computePointSnap, type SnapRect } from "../../lib/snapGuides";
+import { diagonalPolygonPoints } from "../../lib/shapeGeometry";
import { selectionHandlers, type KonvaObjectProps } from "./konvaObjectProps";
/** Endpoint-handle visuals — small white square with a thin selection
@@ -107,7 +108,31 @@ export function LineObject({
: p.color === "B"
? "#000000"
: "#cccccc";
- const lineStrokeWidth = Math.max(dotsToPx(p.thickness, scale, dpmm), 1);
+ // Live thickness while the side handle is being dragged. Falls back to
+ // the stored prop when no drag is in flight; commits to props on
+ // dragEnd. Wrapping the rendering width in this state means the band,
+ // selection outline and handle anchors all track the cursor in real
+ // time without any one-frame delay on release.
+ const [liveThicknessDots, setLiveThicknessDots] = useState(null);
+ const effectiveThicknessDots = liveThicknessDots ?? p.thickness;
+ const lineStrokeWidth = Math.max(dotsToPx(effectiveThicknessDots, scale, dpmm), 1);
+
+ // Option-A geometry (mirrors src/lib/shapeRender.ts):
+ // - Axis-aligned lines map to ^GB and extrude thickness downward
+ // (horizontal) or rightward (vertical) from (obj.x, obj.y) — the
+ // visible body is shifted by t/2 along that axis so the band fills
+ // y..y+t / x..x+t exactly. Handles stay at the band's start corner.
+ // - Diagonal lines map to ^GD: the conceptual line is the left long
+ // edge of a parallelogram and thickness extrudes purely in +x. The
+ // diagonalPolygonPoints helper builds the four vertices.
+ //
+ // The axis-aligned / diagonal pick is derived from the *live* display
+ // endpoints rather than `p.angle` (which only updates on dragEnd).
+ // Otherwise dragging a near-horizontal endpoint shows the body locked
+ // to the horizontal band until release, then snaps to the parallelo-
+ // gram — a visible jump the user noticed.
+ const halfStrokePx = lineStrokeWidth / 2;
+
// Live positions while handles are being dragged (snapped preview)
const [livePt1, setLivePt1] = useState<{ x: number; y: number } | null>(null);
@@ -127,6 +152,18 @@ export function LineObject({
const dispX2 = livePt2?.x ?? x2 + dx;
const dispY2 = livePt2?.y ?? y2 + dy;
+ // Half-pixel epsilon: constrainLine's auto-snap commits 45°-step
+ // positions where ddx/ddy land exactly on axis-aligned values, but
+ // float math can leave a tiny residue. <0.5 px collapses to "the
+ // pixel grid sees this as axis-aligned" without false-positives.
+ const ddxDisp = dispX2 - dispX1;
+ const ddyDisp = dispY2 - dispY1;
+ const isHorizontal = Math.abs(ddyDisp) < 0.5;
+ const isVertical = Math.abs(ddxDisp) < 0.5;
+ const isAxisAligned = isHorizontal || isVertical;
+ const visualShiftX = isVertical ? halfStrokePx : 0;
+ const visualShiftY = isHorizontal ? halfStrokePx : 0;
+
// Shift forces the user-explicit 45°-step constraint; otherwise we use
// Figma-style auto-snap (±5° tolerance to the nearest 45° step).
const resolveMode = (shift: boolean): ConstrainMode =>
@@ -250,6 +287,25 @@ export function LineObject({
};
}
+ // Diagonal-only: the parallelogram vertex list is reused by the body
+ // (filled) and the selection outline (stroke), so compute it once.
+ // Returns garbage for axis-aligned input — but the diagonal branch is
+ // gated on !isAxisAligned, so it's only consumed when valid.
+ const diagPoints = diagonalPolygonPoints(
+ dispX1, dispY1, dispX2, dispY2, lineStrokeWidth,
+ );
+
+ // Thickness handle anchor — sits on the far long edge of the band:
+ // bottom edge for horizontal lines, right edge otherwise. The handle's
+ // perpendicular drag direction is then y for horizontal and x for
+ // anything else, matching ZPL's ^GB / ^GD extrusion conventions.
+ const lineCenterX = (dispX1 + dispX2) / 2;
+ const lineCenterY = (dispY1 + dispY2) / 2;
+ const thickHandleX =
+ lineCenterX + (isHorizontal ? 0 : lineStrokeWidth);
+ const thickHandleY =
+ lineCenterY + (isHorizontal ? lineStrokeWidth : 0);
+
return (
{/* Visible line — tracks both whole-drag and handle-drag live.
@@ -257,30 +313,71 @@ export function LineObject({
white label it renders black, over darker shapes it inverts
those pixels. Stays in reverse mode even when selected so the
inversion visualisation isn't masked. */}
-
- {isSelected && (
-
+ {isAxisAligned ? (
+ <>
+
+ {isSelected && (
+
+ )}
+ >
+ ) : (
+ <>
+ {/* Diagonal ^GD body — closed filled parallelogram rather than
+ a centred stroke so the canvas matches Labelary's flat-top /
+ pointy-side geometry. Reverse uses the same difference blend
+ as the stroked case. */}
+
+ {isSelected && (
+
+ )}
+ >
)}
{/* Wide transparent hit area — handles click-to-select and whole-line drag.
id is here (not on the Group) so the Stage snap handler can find this node
- via e.target.id() and apply object-snap correctly. */}
+ via e.target.id() and apply object-snap correctly. The hit area is
+ shifted along with the visible body so clicks register where the
+ user sees the line. */}
+ {/* Thickness handle — drags perpendicular to the extrusion
+ axis (y for horizontal, x for everything else). Clamps to
+ the 1-dot minimum; flip-on-overshoot is deferred. */}
+ {
+ const cursorX = e.target.x() + HANDLE_HIT_SIZE / 2;
+ const cursorY = e.target.y() + HANDLE_HIT_SIZE / 2;
+ const extPx = isHorizontal
+ ? cursorY - lineCenterY
+ : cursorX - lineCenterX;
+ const newT = Math.max(
+ 1,
+ Math.round(pxToDots(extPx, scale, dpmm)),
+ );
+ setLiveThicknessDots(newT);
+ // Pin the Rect to the (possibly-clamped) anchor so
+ // dragging past the minimum doesn't decouple the handle
+ // from the band edge.
+ const newStroke = Math.max(dotsToPx(newT, scale, dpmm), 1);
+ e.target.position({
+ x:
+ lineCenterX +
+ (isHorizontal ? 0 : newStroke) -
+ HANDLE_HIT_SIZE / 2,
+ y:
+ lineCenterY +
+ (isHorizontal ? newStroke : 0) -
+ HANDLE_HIT_SIZE / 2,
+ });
+ }}
+ onDragEnd={() => {
+ const committed = liveThicknessDots;
+ setLiveThicknessDots(null);
+ if (committed !== null && committed !== p.thickness) {
+ onChange({ props: { thickness: committed } });
+ }
+ }}
+ />
+
>
)}
diff --git a/src/components/Canvas/textPositionTransforms.test.ts b/src/components/Canvas/textPositionTransforms.test.ts
index 3e5c1602..7d7f39f1 100644
--- a/src/components/Canvas/textPositionTransforms.test.ts
+++ b/src/components/Canvas/textPositionTransforms.test.ts
@@ -11,10 +11,9 @@ describe('text position transforms', () => {
expect(r).toEqual({ x: 100, y: 170 });
});
- it('applies only the rotation offset under FO', () => {
- // FO + I → no FT correction, rotation offset dy = -15.
+ it('returns the input verbatim under FO', () => {
const r = objectToDisplay(100, 200, { fontHeight: 30, rotation: 'I' }, 'FO');
- expect(r).toEqual({ x: 100, y: 185 });
+ expect(r).toEqual({ x: 100, y: 200 });
});
it('treats undefined positionType like FO', () => {
@@ -22,11 +21,10 @@ describe('text position transforms', () => {
expect(r).toEqual({ x: 100, y: 200 });
});
- it('combines FT correction and rotation offset for I', () => {
- // FT I: dy = renderedH (30/1.3 ≈ 23.077). Rotation offset I: dy -15.
+ it('applies the FT correction for I (renderedH = fontHeight / ratio)', () => {
const r = objectToDisplay(100, 200, { fontHeight: 30, rotation: 'I' }, 'FT');
expect(r.x).toBeCloseTo(100);
- expect(r.y).toBeCloseTo(200 + 30 / 1.3 - 15);
+ expect(r.y).toBeCloseTo(200 + 30 / 1.3);
});
});
diff --git a/src/components/Canvas/textPositionTransforms.ts b/src/components/Canvas/textPositionTransforms.ts
index 03c02fc6..f6133d82 100644
--- a/src/components/Canvas/textPositionTransforms.ts
+++ b/src/components/Canvas/textPositionTransforms.ts
@@ -1,29 +1,20 @@
/** Pure transforms between the text/serial object's saved coordinate
* (what ZPL persists) and the Konva-anchor coordinate (what we paint).
*
- * Two corrections stack:
- * 1. ^FT baseline correction (only when positionType === "FT"):
- * ^FT places the origin at the baseline of the first character;
- * Konva's Text anchor sits at a different corner depending on
- * rotation. Shift accordingly so the painted text matches the
- * baseline the ZPL describes.
- * 2. Rotation alignment (always for text/serial):
- * Konva rotates around the top-left corner; ZPL ^FO does not
- * behave the same way. 15 dots is an empirically determined
- * offset that lines the canvas back up with what the printer
- * (and Labelary) renders.
+ * The single correction is the ^FT baseline shift: ^FT places the
+ * origin at the baseline of the first character while Konva's Text
+ * anchor sits at a different corner depending on rotation. ^FO needs
+ * no correction, so the transforms are the identity in that case.
*
- * `displayToObject` is the exact inverse so a drag-end can recover
- * the saved coordinate from the dragged Konva position. */
+ * `displayToObject` is the exact inverse of `objectToDisplay` so a
+ * drag-end can recover the saved coordinate from the dragged Konva
+ * position. */
interface TextLikeProps {
fontHeight: number;
- rotation: 'N' | 'R' | 'I' | 'B';
+ rotation: "N" | "R" | "I" | "B";
}
-/** 15 dots empirical canvas/ZPL alignment offset for rotated text. */
-const ROTATION_OFFSET_DOTS = 15;
-
/** Ratio between ZPL fontHeight (cap-height) and CSS/Konva fontSize
* (em-height) for Roboto Condensed Bold. Empirical: divide ZPL
* fontHeight by this to get the Konva-rendered height in dots, or to
@@ -37,19 +28,14 @@ function ftBaselineDelta(props: TextLikeProps): { dx: number; dy: number } {
// at the top, so we shift up by the full ZPL fontHeight.
const renderedH = props.fontHeight / ZPL_FONT_HEIGHT_TO_CSS_RATIO;
switch (props.rotation) {
- case 'N': return { dx: 0, dy: -props.fontHeight };
- case 'R': return { dx: renderedH, dy: 0 };
- case 'I': return { dx: 0, dy: renderedH };
- case 'B': return { dx: -renderedH, dy: 0 };
- }
-}
-
-function rotationOffsetDelta(props: TextLikeProps): { dx: number; dy: number } {
- switch (props.rotation) {
- case 'N': return { dx: 0, dy: 0 };
- case 'I': return { dx: 0, dy: -ROTATION_OFFSET_DOTS };
- case 'R': return { dx: -ROTATION_OFFSET_DOTS, dy: 0 };
- case 'B': return { dx: ROTATION_OFFSET_DOTS, dy: 0 };
+ case "N":
+ return { dx: 0, dy: -props.fontHeight };
+ case "R":
+ return { dx: renderedH, dy: 0 };
+ case "I":
+ return { dx: 0, dy: renderedH };
+ case "B":
+ return { dx: -renderedH, dy: 0 };
}
}
@@ -58,19 +44,11 @@ export function objectToDisplay(
objectX: number,
objectY: number,
props: TextLikeProps,
- positionType: 'FO' | 'FT' | undefined,
+ positionType: "FO" | "FT" | undefined,
): { x: number; y: number } {
- let x = objectX;
- let y = objectY;
- if (positionType === 'FT') {
- const ft = ftBaselineDelta(props);
- x += ft.dx;
- y += ft.dy;
- }
- const rot = rotationOffsetDelta(props);
- x += rot.dx;
- y += rot.dy;
- return { x, y };
+ if (positionType !== "FT") return { x: objectX, y: objectY };
+ const ft = ftBaselineDelta(props);
+ return { x: objectX + ft.dx, y: objectY + ft.dy };
}
/** Inverse of objectToDisplay — recovers the saved coordinate from a
@@ -79,17 +57,9 @@ export function displayToObject(
displayX: number,
displayY: number,
props: TextLikeProps,
- positionType: 'FO' | 'FT' | undefined,
+ positionType: "FO" | "FT" | undefined,
): { x: number; y: number } {
- let x = displayX;
- let y = displayY;
- const rot = rotationOffsetDelta(props);
- x -= rot.dx;
- y -= rot.dy;
- if (positionType === 'FT') {
- const ft = ftBaselineDelta(props);
- x -= ft.dx;
- y -= ft.dy;
- }
- return { x, y };
+ if (positionType !== "FT") return { x: displayX, y: displayY };
+ const ft = ftBaselineDelta(props);
+ return { x: displayX - ft.dx, y: displayY - ft.dy };
}
diff --git a/src/lib/shapeGeometry.test.ts b/src/lib/shapeGeometry.test.ts
new file mode 100644
index 00000000..bcfb12e4
--- /dev/null
+++ b/src/lib/shapeGeometry.test.ts
@@ -0,0 +1,94 @@
+import { describe, it, expect } from "vitest";
+import { outlineInset, diagonalPolygonPoints } from "./shapeGeometry";
+
+describe("outlineInset", () => {
+ it("returns the unmodified bbox for a filled shape", () => {
+ expect(outlineInset(100, 60, 5, true)).toEqual({
+ offset: 0,
+ width: 100,
+ height: 60,
+ renderFilled: true,
+ });
+ });
+
+ it("insets by t/2 on every side for a typical outline", () => {
+ expect(outlineInset(100, 60, 6, false)).toEqual({
+ offset: 3,
+ width: 94,
+ height: 54,
+ renderFilled: false,
+ });
+ });
+
+ it("clamps to solid when 2t reaches min(w, h) (firmware behaviour)", () => {
+ // min(20, 100) = 20, 2*10 = 20 → clamp triggers.
+ expect(outlineInset(100, 20, 10, false)).toEqual({
+ offset: 0,
+ width: 100,
+ height: 20,
+ renderFilled: true,
+ });
+ });
+
+ it("does not clamp one dot below the threshold", () => {
+ // min(20, 100) = 20, 2*9 = 18 → outline still renders.
+ expect(outlineInset(100, 20, 9, false)).toMatchObject({
+ renderFilled: false,
+ });
+ });
+
+ it("clamps zero-or-negative inset dimensions to 0", () => {
+ // Pathological case: thickness larger than the bbox triggers clamp
+ // first, so we get the filled values, never negative width/height.
+ const result = outlineInset(10, 10, 50, false);
+ expect(result.width).toBeGreaterThanOrEqual(0);
+ expect(result.height).toBeGreaterThanOrEqual(0);
+ expect(result.renderFilled).toBe(true);
+ });
+});
+
+describe("diagonalPolygonPoints", () => {
+ it("places the conceptual line endpoints on the same long edge (L orientation)", () => {
+ // Line top-left → bottom-right, slope +. Both endpoints should appear
+ // verbatim among the four polygon vertices and sit on the *left*
+ // long edge (smaller x at each y).
+ const pts = diagonalPolygonPoints(100, 100, 200, 200, 10);
+ expect(pts).toEqual([
+ 100, 100,
+ 110, 100,
+ 210, 200,
+ 200, 200,
+ ]);
+ });
+
+ it("uses the +x-shifted parallel edge for R orientation (slash)", () => {
+ // Line top-right → bottom-left, slope −. The line endpoints
+ // (200, 100) and (100, 200) lie on the same long edge of the
+ // returned parallelogram.
+ const pts = diagonalPolygonPoints(200, 100, 100, 200, 10);
+ expect(pts).toEqual([
+ 200, 100,
+ 210, 100,
+ 110, 200,
+ 100, 200,
+ ]);
+ });
+
+ it("normalises arbitrary endpoint order to a canonical bbox", () => {
+ // (300, 300) → (100, 100) is the same diagonal as (100, 100) →
+ // (300, 300); helper should produce the same set of vertices.
+ const forward = diagonalPolygonPoints(100, 100, 300, 300, 6);
+ const reverse = diagonalPolygonPoints(300, 300, 100, 100, 6);
+ // Sort vertex pairs lexicographically so order-insensitive compare.
+ const pairs = (flat: number[]) => {
+ const out: [number, number][] = [];
+ for (let i = 0; i < flat.length; i += 2) out.push([flat[i]!, flat[i + 1]!]);
+ return out.sort(([ax, ay], [bx, by]) => ax - bx || ay - by);
+ };
+ expect(pairs(forward)).toEqual(pairs(reverse));
+ });
+
+ it("returns 8 numbers (4 vertices × 2 coords)", () => {
+ expect(diagonalPolygonPoints(0, 0, 50, 50, 3)).toHaveLength(8);
+ });
+});
diff --git a/src/lib/shapeGeometry.ts b/src/lib/shapeGeometry.ts
new file mode 100644
index 00000000..9071dda5
--- /dev/null
+++ b/src/lib/shapeGeometry.ts
@@ -0,0 +1,101 @@
+/**
+ * Pure geometric helpers for ZPL shape primitives (^GB / ^GE / ^GC / ^GD).
+ *
+ * Mirrors Zebra firmware's rendering semantics so that the on-screen
+ * Konva canvas, the @napi-rs/canvas pixel-regression renderer, and the
+ * ZPL output all describe the same shape:
+ * - Outlines (box / ellipse / circle) extrude thickness *inward* from
+ * the declared bbox; thickness ≥ min(w, h)/2 collapses to solid.
+ * - Diagonal lines (^GD) place the conceptual line on the *left long
+ * edge* of a parallelogram and extrude thickness in +x only — both
+ * endpoints sit on the same side, never the centreline.
+ *
+ * Keeping the geometry in one pure module prevents drift between the
+ * rendering pathways (tests cover the @napi-rs path against Labelary,
+ * which transitively validates anything that consumes these helpers).
+ */
+
+/**
+ * Inset values for an outline rectangle / ellipse / circle whose
+ * declared bbox is (0, 0, w, h) with stroke thickness t. The caller
+ * uses these to position a *centred-stroke* primitive whose outer
+ * edge lands on the declared bbox.
+ *
+ * When 2t ≥ min(w, h) the outline would meet itself in the middle and
+ * Zebra firmware renders solid; `renderFilled` signals that case so
+ * callers can drop the stroke and fill (0, 0, w, h) directly.
+ */
+export interface OutlineInset {
+ /** Top-left offset for the inset primitive (= t/2 unless filled). */
+ offset: number;
+ /** Width of the inset primitive (= w − t unless filled). */
+ width: number;
+ /** Height of the inset primitive (= h − t unless filled). */
+ height: number;
+ /** Whether the firmware clamps this outline to a solid shape. */
+ renderFilled: boolean;
+}
+
+export function outlineInset(
+ w: number,
+ h: number,
+ t: number,
+ filled: boolean,
+): OutlineInset {
+ const clampsToFilled = !filled && t * 2 >= Math.min(w, h);
+ const renderFilled = filled || clampsToFilled;
+ return {
+ offset: renderFilled ? 0 : t / 2,
+ width: renderFilled ? w : Math.max(0, w - t),
+ height: renderFilled ? h : Math.max(0, h - t),
+ renderFilled,
+ };
+}
+
+/** Four (x, y) vertices in the flat order Konva.Line and 2D canvas
+ * paths both consume. Tuple-typed so callers can destructure without
+ * any `as`-cast or non-null-assertion noise. */
+export type ParallelogramPoints = [
+ number, number,
+ number, number,
+ number, number,
+ number, number,
+];
+
+/**
+ * Four parallelogram vertices for a ^GD diagonal line spanning the bbox
+ * from (ax, ay) to (bx, by) with thickness t.
+ *
+ * The conceptual line runs along the polygon's *left long edge*; the
+ * other long edge is offset by +t in x. This is the same convention as
+ * Zebra firmware (verified pixel-by-pixel against Labelary fixtures).
+ */
+export function diagonalPolygonPoints(
+ ax: number,
+ ay: number,
+ bx: number,
+ by: number,
+ t: number,
+): ParallelogramPoints {
+ const ddx = bx - ax;
+ const ddy = by - ay;
+ const w = Math.abs(ddx);
+ const h = Math.abs(ddy);
+ const orientation: "L" | "R" = ddx * ddy >= 0 ? "L" : "R";
+ const boxX = ddx < 0 ? ax + ddx : ax;
+ const boxY = ddy < 0 ? ay + ddy : ay;
+ if (orientation === "L") {
+ return [
+ boxX, boxY,
+ boxX + t, boxY,
+ boxX + w + t, boxY + h,
+ boxX + w, boxY + h,
+ ];
+ }
+ return [
+ boxX + w, boxY,
+ boxX + w + t, boxY,
+ boxX + t, boxY + h,
+ boxX, boxY + h,
+ ];
+}
diff --git a/src/lib/shapeRender.ts b/src/lib/shapeRender.ts
new file mode 100644
index 00000000..a73bf5dc
--- /dev/null
+++ b/src/lib/shapeRender.ts
@@ -0,0 +1,177 @@
+import type { LabelObject } from "../registry";
+import { diagonalPolygonPoints } from "./shapeGeometry";
+
+/** Inward-extruded ^GE / ^GC ring or solid disc, shared by ellipse and
+ * circle. Extracted so the two registry types — which carry different
+ * prop shapes — can each pass their normalised width / height in
+ * without the call-site needing a union-narrowing ternary. */
+function drawEllipticalOutline(
+ ctx: CanvasRenderingContext2D,
+ x: number, y: number,
+ w: number, h: number,
+ thickness: number,
+ filled: boolean,
+ zplColor: "B" | "W",
+): void {
+ const color = zplColor === "B" ? "#000000" : "#ffffff";
+ const cx = x + w / 2;
+ const cy = y + h / 2;
+
+ if (filled) {
+ ctx.fillStyle = color;
+ ctx.beginPath();
+ ctx.ellipse(cx, cy, w / 2, h / 2, 0, 0, Math.PI * 2);
+ ctx.fill();
+ return;
+ }
+
+ // Even-odd fill of outer ellipse minus inner ellipse — gives a true
+ // inward-extruded ring (canvas stroke would be centred on the path
+ // and overflow the declared bbox).
+ const t = Math.max(1, thickness);
+ ctx.fillStyle = color;
+ ctx.beginPath();
+ ctx.ellipse(cx, cy, w / 2, h / 2, 0, 0, Math.PI * 2);
+ ctx.ellipse(
+ cx, cy,
+ Math.max(0, w / 2 - t),
+ Math.max(0, h / 2 - t),
+ 0, 0, Math.PI * 2,
+ );
+ ctx.fill("evenodd");
+}
+
+/**
+ * 2D-canvas shape primitive (^GB / ^GE / ^GC / line-as-^GB) renderer.
+ *
+ * Test-only: the Konva canvas does not call this function. Both code
+ * paths share the same geometric definitions via `lib/shapeGeometry.ts`
+ * (outlineInset, diagonalPolygonPoints), and the pixel-regression
+ * suite uses this 2D-canvas renderer to compare against Labelary.
+ *
+ * Geometry follows ZPL semantics (Option A from the design discussion):
+ * outline thickness extrudes *inward* from the declared bounding box for
+ * `^GB`/`^GE`/`^GC`, and *downward / rightward* from `(x, y)` for axis-
+ * aligned lines. This is the print-truth geometry — what Labelary renders
+ * from the same ZPL — so the canvas matches the printer 1:1.
+ *
+ * The caller supplies a 2D context whose units already equal ZPL dots
+ * (i.e. 1 unit = 1 dot). At 8dpmm this is the same as 1 px in the
+ * Labelary reference images, which is what the regression suite assumes.
+ */
+export function renderShape(
+ ctx: CanvasRenderingContext2D,
+ obj: LabelObject,
+): void {
+ switch (obj.type) {
+ case "box": {
+ const p = obj.props;
+ const color = p.color === "B" ? "#000000" : "#ffffff";
+ // TODO: rounding support. ^GB w,h,t,c,r accepts a 0..8 rounding
+ // index that maps to corner radius (r * Math.min(w, h) / 8 in
+ // dots — the formula already in KonvaObject for canvas display).
+ // Implement via ctx.roundRect for outer + inner rect with
+ // 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)) {
+ ctx.fillStyle = color;
+ ctx.fillRect(obj.x, obj.y, p.width, p.height);
+ return;
+ }
+ // Four filled bands (top, bottom, left, right) avoid the
+ // centred-stroke half-pixel artefacts an ellipse-style outline
+ // would have for axis-aligned rects.
+ ctx.fillStyle = color;
+ ctx.fillRect(obj.x, obj.y, p.width, t); // top
+ ctx.fillRect(obj.x, obj.y + p.height - t, p.width, t); // bottom
+ ctx.fillRect(obj.x, obj.y + t, t, p.height - t * 2); // left
+ ctx.fillRect(obj.x + p.width - t, obj.y + t, t, p.height - t * 2); // right
+ return;
+ }
+
+ case "ellipse": {
+ drawEllipticalOutline(
+ ctx,
+ obj.x, obj.y,
+ obj.props.width, obj.props.height,
+ obj.props.thickness, obj.props.filled, obj.props.color,
+ );
+ return;
+ }
+
+ case "circle": {
+ drawEllipticalOutline(
+ ctx,
+ obj.x, obj.y,
+ obj.props.diameter, obj.props.diameter,
+ obj.props.thickness, obj.props.filled, obj.props.color,
+ );
+ return;
+ }
+
+ case "line": {
+ const p = obj.props;
+ const color = p.color === "B" ? "#000000" : "#ffffff";
+ const a = ((p.angle % 360) + 360) % 360;
+ const t = Math.max(1, p.thickness);
+
+ // Axis-aligned lines map directly to ^GB rectangles. ZPL extrudes
+ // thickness downward (horizontal) or rightward (vertical) from
+ // (obj.x, obj.y); angle 180 / 270 mean the line *starts* at (x,y)
+ // and extends in the opposite axis direction.
+ ctx.fillStyle = color;
+ if (a === 0) {
+ ctx.fillRect(obj.x, obj.y, p.length, t);
+ } else if (a === 180) {
+ ctx.fillRect(obj.x - p.length, obj.y, p.length, t);
+ } else if (a === 90) {
+ ctx.fillRect(obj.x, obj.y, t, p.length);
+ } else if (a === 270) {
+ ctx.fillRect(obj.x, obj.y - p.length, t, p.length);
+ } else {
+ // Diagonal ^GD: derive the polygon vertices from the integer-
+ // rounded line endpoints (matching line.toZPL's rounding), then
+ // delegate the parallelogram geometry to shapeGeometry. The
+ // Konva canvas calls the same helper, so the two render paths
+ // cannot drift.
+ const rad = (a * Math.PI) / 180;
+ const dx = p.length * Math.cos(rad);
+ const dy = p.length * Math.sin(rad);
+ const ddx = Math.sign(dx) * Math.max(1, Math.abs(Math.round(dx)));
+ const ddy = Math.sign(dy) * Math.max(1, Math.abs(Math.round(dy)));
+ const [v0x, v0y, v1x, v1y, v2x, v2y, v3x, v3y] = diagonalPolygonPoints(
+ obj.x, obj.y,
+ obj.x + ddx, obj.y + ddy,
+ t,
+ );
+ ctx.beginPath();
+ ctx.moveTo(v0x, v0y);
+ ctx.lineTo(v1x, v1y);
+ ctx.lineTo(v2x, v2y);
+ ctx.lineTo(v3x, v3y);
+ ctx.closePath();
+ ctx.fill();
+ }
+ return;
+ }
+
+ default:
+ // Non-shape objects (text, barcodes, images, serial) are out of
+ // scope for this renderer — the barcode regression suite covers
+ // bwip-js outputs separately. Test infrastructure only, so a
+ // loud throw is intentional: any pixel-regression case that
+ // smuggles a non-shape object through here is a test-author bug,
+ // not a runtime condition the UI needs to survive.
+ throw new Error(`renderShape: unsupported type "${(obj as { type: string }).type}"`);
+ }
+}
diff --git a/src/test/shapeRegression.test.ts b/src/test/shapeRegression.test.ts
new file mode 100644
index 00000000..56d7473c
--- /dev/null
+++ b/src/test/shapeRegression.test.ts
@@ -0,0 +1,96 @@
+import { describe, it, expect } from "vitest";
+import * as fs from "fs";
+import * as path from "path";
+import pixelmatch from "pixelmatch";
+import { PNG } from "pngjs";
+import { createCanvas } from "@napi-rs/canvas";
+import { shapeTestCases } from "../../tests/fixtures/shapeTestCases";
+import { renderShape } from "../lib/shapeRender";
+
+/**
+ * Pixel regression for shape primitives (box / ellipse / circle / line),
+ * the geometric counterpart of `visualRegression.test.ts` (which covers
+ * barcodes via bwip-js). Each test:
+ *
+ * 1. Renders the `LabelObject` via `renderShape` onto a blank 812×812
+ * canvas (matches Labelary 8dpmm × 4 inches).
+ * 2. Loads the Labelary reference PNG for the same ZPL.
+ * 3. Diffs them with pixelmatch and asserts the diff stays under a
+ * tight tolerance.
+ *
+ * Fetch the references first via
+ * pnpm tsx tests/scripts/fetch_labelary_shape_fixtures.ts
+ */
+
+const FIXTURES_DIR = path.resolve(
+ process.cwd(),
+ "tests/fixtures/labelary_shape_images",
+);
+const DIFF_DIR = path.resolve(process.cwd(), "tests/fixtures/__shape_diffs__");
+
+if (!fs.existsSync(DIFF_DIR)) {
+ fs.mkdirSync(DIFF_DIR, { recursive: true });
+}
+
+const CANVAS_W = 812;
+const CANVAS_H = 812;
+// Per-test diff budget. Pure black-on-white shapes finish at <100 px
+// diff in practice; 200 leaves headroom for rasterisation rounding
+// while still catching any 1-px geometry shift.
+const ALLOWED_TOLERANCE = 200;
+// pixelmatch threshold (per-pixel YIQ distance, 0..1). 0.1 is tight
+// enough to flag geometry off-by-ones without snagging on subpixel AA.
+const PIXELMATCH_THRESHOLD = 0.1;
+
+describe("Visual Regression - shape primitives vs Labelary", () => {
+ it("loads shape test cases", () => {
+ expect(shapeTestCases.length).toBeGreaterThan(0);
+ });
+
+ describe.each(shapeTestCases)("Shape: $id", (tc) => {
+ it("matches the Labelary reference pixel-for-pixel", async () => {
+ const fixturePath = path.join(FIXTURES_DIR, tc.image_ref);
+ if (!fs.existsSync(fixturePath)) {
+ throw new Error(
+ `Fixture not found: ${fixturePath}. ` +
+ `Run: pnpm tsx tests/scripts/fetch_labelary_shape_fixtures.ts`,
+ );
+ }
+
+ const canvas = createCanvas(CANVAS_W, CANVAS_H);
+ const ctx = canvas.getContext("2d");
+ ctx.fillStyle = "white";
+ ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
+ renderShape(ctx as unknown as CanvasRenderingContext2D, tc.obj);
+
+ const labelaryRef = PNG.sync.read(fs.readFileSync(fixturePath));
+ const localPng = PNG.sync.read(canvas.toBuffer("image/png"));
+
+ expect(labelaryRef.width).toBe(CANVAS_W);
+ expect(labelaryRef.height).toBe(CANVAS_H);
+
+ const diff = new PNG({ width: CANVAS_W, height: CANVAS_H });
+ const numDiffPixels = pixelmatch(
+ labelaryRef.data,
+ localPng.data,
+ diff.data,
+ CANVAS_W,
+ CANVAS_H,
+ { threshold: PIXELMATCH_THRESHOLD },
+ );
+
+ if (numDiffPixels > ALLOWED_TOLERANCE) {
+ fs.writeFileSync(
+ path.join(DIFF_DIR, `${tc.id}_diff.png`),
+ PNG.sync.write(diff),
+ );
+ fs.writeFileSync(
+ path.join(DIFF_DIR, `${tc.id}_local.png`),
+ canvas.toBuffer("image/png"),
+ );
+ }
+
+ expect(numDiffPixels).toBeLessThanOrEqual(ALLOWED_TOLERANCE);
+ });
+ });
+});
diff --git a/tests/fixtures/labelary_shape_images/shape_box_filled.png b/tests/fixtures/labelary_shape_images/shape_box_filled.png
new file mode 100644
index 00000000..1b89bfe7
Binary files /dev/null and b/tests/fixtures/labelary_shape_images/shape_box_filled.png differ
diff --git a/tests/fixtures/labelary_shape_images/shape_box_outline_near_filled.png b/tests/fixtures/labelary_shape_images/shape_box_outline_near_filled.png
new file mode 100644
index 00000000..9b869167
Binary files /dev/null and b/tests/fixtures/labelary_shape_images/shape_box_outline_near_filled.png differ
diff --git a/tests/fixtures/labelary_shape_images/shape_box_outline_t20.png b/tests/fixtures/labelary_shape_images/shape_box_outline_t20.png
new file mode 100644
index 00000000..11211587
Binary files /dev/null and b/tests/fixtures/labelary_shape_images/shape_box_outline_t20.png differ
diff --git a/tests/fixtures/labelary_shape_images/shape_box_outline_t3.png b/tests/fixtures/labelary_shape_images/shape_box_outline_t3.png
new file mode 100644
index 00000000..4e0e9b8a
Binary files /dev/null and b/tests/fixtures/labelary_shape_images/shape_box_outline_t3.png differ
diff --git a/tests/fixtures/labelary_shape_images/shape_box_outline_thick.png b/tests/fixtures/labelary_shape_images/shape_box_outline_thick.png
new file mode 100644
index 00000000..14ad5b0d
Binary files /dev/null and b/tests/fixtures/labelary_shape_images/shape_box_outline_thick.png differ
diff --git a/tests/fixtures/labelary_shape_images/shape_box_outline_thin.png b/tests/fixtures/labelary_shape_images/shape_box_outline_thin.png
new file mode 100644
index 00000000..4f504205
Binary files /dev/null and b/tests/fixtures/labelary_shape_images/shape_box_outline_thin.png differ
diff --git a/tests/fixtures/labelary_shape_images/shape_circle_outline.png b/tests/fixtures/labelary_shape_images/shape_circle_outline.png
new file mode 100644
index 00000000..9491de33
Binary files /dev/null and b/tests/fixtures/labelary_shape_images/shape_circle_outline.png differ
diff --git a/tests/fixtures/labelary_shape_images/shape_ellipse_outline.png b/tests/fixtures/labelary_shape_images/shape_ellipse_outline.png
new file mode 100644
index 00000000..a9f34193
Binary files /dev/null and b/tests/fixtures/labelary_shape_images/shape_ellipse_outline.png differ
diff --git a/tests/fixtures/labelary_shape_images/shape_line_diag_backslash_45.png b/tests/fixtures/labelary_shape_images/shape_line_diag_backslash_45.png
new file mode 100644
index 00000000..8bfd4514
Binary files /dev/null and b/tests/fixtures/labelary_shape_images/shape_line_diag_backslash_45.png differ
diff --git a/tests/fixtures/labelary_shape_images/shape_line_diag_shallow.png b/tests/fixtures/labelary_shape_images/shape_line_diag_shallow.png
new file mode 100644
index 00000000..7c57cd0b
Binary files /dev/null and b/tests/fixtures/labelary_shape_images/shape_line_diag_shallow.png differ
diff --git a/tests/fixtures/labelary_shape_images/shape_line_diag_slash_45.png b/tests/fixtures/labelary_shape_images/shape_line_diag_slash_45.png
new file mode 100644
index 00000000..82535af9
Binary files /dev/null and b/tests/fixtures/labelary_shape_images/shape_line_diag_slash_45.png differ
diff --git a/tests/fixtures/labelary_shape_images/shape_line_diag_steep.png b/tests/fixtures/labelary_shape_images/shape_line_diag_steep.png
new file mode 100644
index 00000000..0bd3571b
Binary files /dev/null and b/tests/fixtures/labelary_shape_images/shape_line_diag_steep.png differ
diff --git a/tests/fixtures/labelary_shape_images/shape_line_horizontal_left.png b/tests/fixtures/labelary_shape_images/shape_line_horizontal_left.png
new file mode 100644
index 00000000..e08e61b7
Binary files /dev/null and b/tests/fixtures/labelary_shape_images/shape_line_horizontal_left.png differ
diff --git a/tests/fixtures/labelary_shape_images/shape_line_horizontal_t1.png b/tests/fixtures/labelary_shape_images/shape_line_horizontal_t1.png
new file mode 100644
index 00000000..344a3a63
Binary files /dev/null and b/tests/fixtures/labelary_shape_images/shape_line_horizontal_t1.png differ
diff --git a/tests/fixtures/labelary_shape_images/shape_line_horizontal_t3.png b/tests/fixtures/labelary_shape_images/shape_line_horizontal_t3.png
new file mode 100644
index 00000000..ecdcb786
Binary files /dev/null and b/tests/fixtures/labelary_shape_images/shape_line_horizontal_t3.png differ
diff --git a/tests/fixtures/labelary_shape_images/shape_line_horizontal_thick.png b/tests/fixtures/labelary_shape_images/shape_line_horizontal_thick.png
new file mode 100644
index 00000000..11b47ee9
Binary files /dev/null and b/tests/fixtures/labelary_shape_images/shape_line_horizontal_thick.png differ
diff --git a/tests/fixtures/labelary_shape_images/shape_line_vertical_thick.png b/tests/fixtures/labelary_shape_images/shape_line_vertical_thick.png
new file mode 100644
index 00000000..e563e573
Binary files /dev/null and b/tests/fixtures/labelary_shape_images/shape_line_vertical_thick.png differ
diff --git a/tests/fixtures/labelary_shape_images/shape_line_vertical_up.png b/tests/fixtures/labelary_shape_images/shape_line_vertical_up.png
new file mode 100644
index 00000000..17752ed3
Binary files /dev/null and b/tests/fixtures/labelary_shape_images/shape_line_vertical_up.png differ
diff --git a/tests/fixtures/shapeTestCases.ts b/tests/fixtures/shapeTestCases.ts
new file mode 100644
index 00000000..da714cbc
--- /dev/null
+++ b/tests/fixtures/shapeTestCases.ts
@@ -0,0 +1,291 @@
+import type { LabelObject } from "../../src/registry";
+
+/**
+ * Pixel-regression cases for the geometric primitives (box, line, ellipse,
+ * circle) — analogous to `testCases.ts` for barcodes. Each entry pairs a
+ * canonical `LabelObject` (used by `renderShape` to produce the local
+ * canvas) with the ZPL Labelary should render as the reference.
+ *
+ * ZPL is stored verbatim rather than re-derived from `obj` via the registry,
+ * mirroring the barcode-fixtures pattern. The registry's runtime entry
+ * (`src/registry/index.ts`) transitively imports the React components and
+ * the zustand store, both of which crash under plain Node — keeping the
+ * ZPL inline lets the fetch script run without a DOM polyfill. The trade-
+ * off is that the strings need a manual update if a shape's `toZPL`
+ * changes; cross-check via `zplGenerator.test.ts`.
+ *
+ * Initial set deliberately covers the geometry-asymmetry cases:
+ * - thick outline boxes (^GB thickness extrudes inward)
+ * - horizontal / vertical lines of varying thickness
+ * - ellipse + circle outline (^GE thickness behaviour)
+ * Anti-aliasing-only cases (thickness 1, filled solid) are kept too as a
+ * baseline that should match trivially.
+ *
+ * Diagonal lines (`^GD`) are intentionally absent until `renderShape`
+ * covers the Zebra quadrilateral geometry.
+ */
+export interface ShapeTestCase {
+ id: string;
+ obj: LabelObject;
+ zpl_input: string;
+ image_ref: string;
+}
+
+export const shapeTestCases: ShapeTestCase[] = [
+ {
+ id: "shape_box_outline_thin",
+ obj: {
+ id: "1",
+ type: "box",
+ x: 100,
+ y: 100,
+ rotation: 0,
+ props: { width: 300, height: 200, thickness: 1, filled: false, color: "B", rounding: 0 },
+ },
+ zpl_input: "^XA^FO100,100^GB300,200,1,B,0^FS^XZ",
+ image_ref: "shape_box_outline_thin.png",
+ },
+ {
+ id: "shape_box_outline_thick",
+ obj: {
+ id: "2",
+ type: "box",
+ x: 100,
+ y: 100,
+ rotation: 0,
+ props: { width: 300, height: 200, thickness: 12, filled: false, color: "B", rounding: 0 },
+ },
+ zpl_input: "^XA^FO100,100^GB300,200,12,B,0^FS^XZ",
+ image_ref: "shape_box_outline_thick.png",
+ },
+ {
+ // Filled box: box.toZPL substitutes thickness with min(w, h) — the
+ // ZPL string below mirrors that exactly so Labelary renders a solid
+ // rect. Keep in sync if `box.toZPL` changes.
+ id: "shape_box_filled",
+ obj: {
+ id: "3",
+ type: "box",
+ x: 100,
+ y: 100,
+ rotation: 0,
+ props: { width: 300, height: 200, thickness: 1, filled: true, color: "B", rounding: 0 },
+ },
+ zpl_input: "^XA^FO100,100^GB300,200,200,B,0^FS^XZ",
+ image_ref: "shape_box_filled.png",
+ },
+ {
+ id: "shape_line_horizontal_thick",
+ obj: {
+ id: "4",
+ type: "line",
+ x: 100,
+ y: 200,
+ rotation: 0,
+ props: { angle: 0, length: 400, thickness: 10, color: "B" },
+ },
+ zpl_input: "^XA^FO100,200^GB400,10,10,B,0^FS^XZ",
+ image_ref: "shape_line_horizontal_thick.png",
+ },
+ {
+ id: "shape_line_vertical_thick",
+ obj: {
+ id: "5",
+ type: "line",
+ x: 200,
+ y: 100,
+ rotation: 0,
+ props: { angle: 90, length: 400, thickness: 10, color: "B" },
+ },
+ zpl_input: "^XA^FO200,100^GB10,400,10,B,0^FS^XZ",
+ image_ref: "shape_line_vertical_thick.png",
+ },
+ {
+ id: "shape_ellipse_outline",
+ obj: {
+ id: "7",
+ type: "ellipse",
+ x: 100,
+ y: 100,
+ rotation: 0,
+ props: { width: 300, height: 200, thickness: 8, filled: false, color: "B" },
+ },
+ zpl_input: "^XA^FO100,100^GE300,200,8,B^FS^XZ",
+ image_ref: "shape_ellipse_outline.png",
+ },
+ {
+ id: "shape_circle_outline",
+ obj: {
+ id: "8",
+ type: "circle",
+ x: 100,
+ y: 100,
+ rotation: 0,
+ props: { diameter: 200, thickness: 8, filled: false, color: "B" },
+ },
+ zpl_input: "^XA^FO100,100^GE200,200,8,B^FS^XZ",
+ image_ref: "shape_circle_outline.png",
+ },
+
+ // Reverse-direction lines — angle 180 / 270 extend the body backward
+ // from (x, y). The renderer maps this to ^GB at (x - length, y) /
+ // (x, y - length); the ZPL strings below precompute that shift so
+ // Labelary positions the same band.
+ {
+ id: "shape_line_horizontal_left",
+ obj: {
+ id: "9",
+ type: "line",
+ x: 500,
+ y: 200,
+ rotation: 0,
+ props: { angle: 180, length: 400, thickness: 10, color: "B" },
+ },
+ zpl_input: "^XA^FO100,200^GB400,10,10,B,0^FS^XZ",
+ image_ref: "shape_line_horizontal_left.png",
+ },
+ {
+ id: "shape_line_vertical_up",
+ obj: {
+ id: "10",
+ type: "line",
+ x: 200,
+ y: 500,
+ rotation: 0,
+ props: { angle: 270, length: 400, thickness: 10, color: "B" },
+ },
+ zpl_input: "^XA^FO200,100^GB10,400,10,B,0^FS^XZ",
+ image_ref: "shape_line_vertical_up.png",
+ },
+
+ // Thickness sweep — odd, larger, and right at the filled-clamp edge
+ // (Zebra renders ^GB with `2 * thickness >= min(w, h)` as solid; for
+ // 300×200 the threshold is t = 100, so t=99 is the densest still-
+ // outline case and proves the clamp boundary).
+ {
+ id: "shape_box_outline_t3",
+ obj: {
+ id: "11",
+ type: "box",
+ x: 100,
+ y: 100,
+ rotation: 0,
+ props: { width: 300, height: 200, thickness: 3, filled: false, color: "B", rounding: 0 },
+ },
+ zpl_input: "^XA^FO100,100^GB300,200,3,B,0^FS^XZ",
+ image_ref: "shape_box_outline_t3.png",
+ },
+ {
+ id: "shape_box_outline_t20",
+ obj: {
+ id: "12",
+ type: "box",
+ x: 100,
+ y: 100,
+ rotation: 0,
+ props: { width: 300, height: 200, thickness: 20, filled: false, color: "B", rounding: 0 },
+ },
+ zpl_input: "^XA^FO100,100^GB300,200,20,B,0^FS^XZ",
+ image_ref: "shape_box_outline_t20.png",
+ },
+ {
+ id: "shape_box_outline_near_filled",
+ obj: {
+ id: "13",
+ type: "box",
+ x: 100,
+ y: 100,
+ rotation: 0,
+ props: { width: 300, height: 200, thickness: 99, filled: false, color: "B", rounding: 0 },
+ },
+ zpl_input: "^XA^FO100,100^GB300,200,99,B,0^FS^XZ",
+ image_ref: "shape_box_outline_near_filled.png",
+ },
+ {
+ id: "shape_line_horizontal_t1",
+ obj: {
+ id: "14",
+ type: "line",
+ x: 100,
+ y: 300,
+ rotation: 0,
+ props: { angle: 0, length: 400, thickness: 1, color: "B" },
+ },
+ zpl_input: "^XA^FO100,300^GB400,1,1,B,0^FS^XZ",
+ image_ref: "shape_line_horizontal_t1.png",
+ },
+ {
+ id: "shape_line_horizontal_t3",
+ obj: {
+ id: "15",
+ type: "line",
+ x: 100,
+ y: 350,
+ rotation: 0,
+ props: { angle: 0, length: 400, thickness: 3, color: "B" },
+ },
+ zpl_input: "^XA^FO100,350^GB400,3,3,B,0^FS^XZ",
+ image_ref: "shape_line_horizontal_t3.png",
+ },
+
+ // Diagonal lines (^GD) — Labelary fixtures fetched up front so the
+ // renderer implementation in Phase 2 can iterate offline. Tests for
+ // these IDs are skipped until renderShape supports ^GD geometry; the
+ // skip predicate lives in shapeRegression.test.ts.
+ //
+ // ZPL strings were derived from line.toZPL's diagonal branch
+ // (Math.cos/sin → dx/dy → w/h/orientation/boxX/boxY).
+ {
+ id: "shape_line_diag_slash_45",
+ obj: {
+ id: "16",
+ type: "line",
+ x: 100,
+ y: 500,
+ rotation: 0,
+ props: { angle: -45, length: 400, thickness: 6, color: "B" },
+ },
+ // angle 315°: dx=+283, dy=-283 → boxY shifts up by 283
+ zpl_input: "^XA^FO100,217^GD283,283,6,B,R^FS^XZ",
+ image_ref: "shape_line_diag_slash_45.png",
+ },
+ {
+ id: "shape_line_diag_backslash_45",
+ obj: {
+ id: "17",
+ type: "line",
+ x: 100,
+ y: 100,
+ rotation: 0,
+ props: { angle: 45, length: 400, thickness: 6, color: "B" },
+ },
+ zpl_input: "^XA^FO100,100^GD283,283,6,B,L^FS^XZ",
+ image_ref: "shape_line_diag_backslash_45.png",
+ },
+ {
+ id: "shape_line_diag_shallow",
+ obj: {
+ id: "18",
+ type: "line",
+ x: 100,
+ y: 200,
+ rotation: 0,
+ props: { angle: 30, length: 400, thickness: 6, color: "B" },
+ },
+ zpl_input: "^XA^FO100,200^GD346,200,6,B,L^FS^XZ",
+ image_ref: "shape_line_diag_shallow.png",
+ },
+ {
+ id: "shape_line_diag_steep",
+ obj: {
+ id: "19",
+ type: "line",
+ x: 100,
+ y: 200,
+ rotation: 0,
+ props: { angle: 60, length: 400, thickness: 6, color: "B" },
+ },
+ zpl_input: "^XA^FO100,200^GD200,346,6,B,L^FS^XZ",
+ image_ref: "shape_line_diag_steep.png",
+ },
+];
diff --git a/tests/scripts/fetch_labelary_shape_fixtures.ts b/tests/scripts/fetch_labelary_shape_fixtures.ts
new file mode 100644
index 00000000..eea1dd93
--- /dev/null
+++ b/tests/scripts/fetch_labelary_shape_fixtures.ts
@@ -0,0 +1,63 @@
+import * as fs from "fs";
+import * as path from "path";
+import { shapeTestCases } from "../fixtures/shapeTestCases";
+
+const FIXTURES_DIR = path.resolve(
+ process.cwd(),
+ "tests/fixtures/labelary_shape_images",
+);
+
+async function fetchLabelaryImage(zpl: string): Promise {
+ // 8dpmm + 4×4 inches mirrors the barcode fixture infrastructure, so the
+ // resulting 812×812 PNGs slot into the same comparison shape.
+ const url = "http://api.labelary.com/v1/printers/8dpmm/labels/4x4/0/";
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ Accept: "image/png",
+ "Content-Type": "application/x-www-form-urlencoded",
+ },
+ body: zpl,
+ });
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(
+ `Labelary API error: ${response.status} ${response.statusText} - ${errorText}`,
+ );
+ }
+
+ const arrayBuffer = await response.arrayBuffer();
+ return Buffer.from(arrayBuffer);
+}
+
+async function main() {
+ fs.mkdirSync(FIXTURES_DIR, { recursive: true });
+
+ console.log("Fetching Labelary shape fixtures...");
+ for (const tc of shapeTestCases) {
+ const imagePath = path.join(FIXTURES_DIR, tc.image_ref);
+
+ if (fs.existsSync(imagePath)) {
+ console.log(`⏩ Skipping ${tc.id} - image already exists.`);
+ continue;
+ }
+
+ console.log(`Fetching ${tc.id}…`);
+ console.log(` ZPL: ${tc.zpl_input}`);
+ try {
+ const buf = await fetchLabelaryImage(tc.zpl_input);
+ fs.writeFileSync(imagePath, buf);
+ console.log(`✅ Saved ${tc.image_ref}`);
+ } catch (e) {
+ console.error(`❌ Failed ${tc.id}:`, e);
+ }
+
+ // Labelary throttles around 5 rps; stay well below.
+ await new Promise((r) => setTimeout(r, 500));
+ }
+
+ console.log("🎉 Done.");
+}
+
+main().catch(console.error);