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);