diff --git a/src/components/Canvas/BarcodeObject.tsx b/src/components/Canvas/BarcodeObject.tsx index d4af854e..172059ec 100644 --- a/src/components/Canvas/BarcodeObject.tsx +++ b/src/components/Canvas/BarcodeObject.tsx @@ -10,10 +10,12 @@ import { buildBwipOptions, getDisplaySize, eanCheckDigit, + upceCheckDigit, get1DBwipScale, getEanUpcLayout, type EanUpcType, } from "./bwipHelpers"; +import { objectRotation } from "../../registry/rotation"; import { QR_FO_Y_OFFSET_DOTS, QR_FT_MODULE_OFFSET, @@ -151,9 +153,12 @@ export function BarcodeObject({ // Force-off when the symbology has no HRI in ZPL (e.g. GS1 Databar) — the // canvas must match the print output even if a legacy saved object still // carries printInterpretation: true. - const printInterp = + const rotation = objectRotation(obj.props); + const isUpright = rotation === "N"; + const printInterpEnabled = !ObjectRegistry[obj.type]?.interpretationLocked && !!(obj.props as { printInterpretation?: boolean }).printInterpretation; + const printInterp = isUpright && printInterpEnabled; const moduleWidth = (obj.props as { moduleWidth?: number }).moduleWidth ?? 2; const textFontSize = Math.max(dotsToPx(moduleWidth * 10, scale, dpmm), 6); @@ -320,20 +325,6 @@ export function BarcodeObject({ fill="#000000" listening={false} />, - // check digit — outside-right (like leading digit on left) - , ]; } else if (obj.type === "upce") { const digits6 = rawContent @@ -341,23 +332,7 @@ export function BarcodeObject({ .slice(0, 6) .padEnd(6, "0"); - // Expand UPC-E to 11-digit UPC-A to compute check digit - const vA = digits6[0] ?? "0", - vB = digits6[1] ?? "0", - vC = digits6[2] ?? "0"; - const vD = digits6[3] ?? "0", - vE = digits6[4] ?? "0", - vF = digits6[5] ?? "0"; - const fi = parseInt(vF, 10); - let expanded11: string; - if (fi <= 2) expanded11 = `0${vA}${vB}${vF}0000${vC}${vD}${vE}`; - else if (fi === 3) expanded11 = `0${vA}${vB}${vC}00000${vD}${vE}`; - else if (fi === 4) expanded11 = `0${vA}${vB}${vC}${vD}00000${vE}`; - else expanded11 = `0${vA}${vB}${vC}${vD}${vE}${vF}0000`; - let ckSum = 0; - for (let i = 0; i < 11; i++) - ckSum += parseInt(expanded11[i] ?? "0", 10) * (i % 2 === 0 ? 3 : 1); - const checkDigit = String((10 - (ckSum % 10)) % 10); + const checkDigit = upceCheckDigit(digits6); // UPC-E: 6 digits centered over the data area (modules 3–44 of 51) const { xLeft: xMid, halfWidth: midW } = layout; @@ -444,6 +419,11 @@ export function BarcodeObject({ // ── Other 1D: separate Konva Text below bars ────────────────────────── const showText = BARCODE_1D_TYPES.has(obj.type) && printInterp; + // Rotated 1D: text overlay rotated to match the barcode orientation. + const showRotatedText = + !isUpright && + printInterpEnabled && + BARCODE_1D_TYPES.has(obj.type); let displayText = rawContent; if (obj.type === "code39") { @@ -543,6 +523,147 @@ export function BarcodeObject({ ); } + // ── Rotated 1D: text overlay rotated alongside the bars ────────────── + if (showRotatedText) { + // Rotation math (Konva y-down, CW positive): + // R (rot=90): local-x→screen-down, local-y→screen-left + // B (rot=-90): local-x→screen-up, local-y→screen-right + // I (rot=180): local-x→screen-left, local-y→screen-up + // + // Text "side" for 90°/270°: standard 1D text is below bars in upright, + // so after 90°CW it's on the LEFT; after 270°CW on the RIGHT. + // LOGMARS is mirrored (text above in upright → right for 90°, left for 270°). + const isTextAbove = obj.type === "logmars"; + // x-anchor for R/B (shared by all text nodes for a given rotation) + const sideX = + rotation === "R" + ? isTextAbove ? w + textGap + textFontSize : -textGap + : isTextAbove ? -(textGap + textFontSize) : w + textGap; + const tRot = rotation === "R" ? 90 : rotation === "I" ? 180 : -90; + + // ── EAN/UPC: reproduce upright digit layout along the rotated axis ── + let textElements: React.ReactNode; + if (EAN_UPC_TYPES.has(obj.type)) { + const bwipSc = get1DBwipScale(moduleWidth, scale, dpmm); + // For I: encoding runs horizontally (canvas.width); for R/B: vertically (canvas.height) + const encDisplay = rotation === "I" ? w : h; + const encCanvas = rotation === "I" ? barcodeCanvas.width : barcodeCanvas.height; + const layout = getEanUpcLayout(obj.type as EanUpcType, encDisplay, encCanvas, bwipSc); + const { xLeft, xRight, halfWidth: halfW } = layout; + const ldW = textFontSize * 1.2; + + const tStyle = { + fontSize: textFontSize, + fontFamily: "'Courier New', monospace" as const, + wrap: "none" as const, + fill: "#000000", + listening: false, + }; + + // Position a text node at `encPos` from barcode start, spanning `size`. + // For R: encPos → screen-y downward from top (start=top). + // For B: encPos → screen-y upward from bottom (start=bottom), anchor = h - encPos. + // For I: encPos → screen-x leftward from right (start=right), anchor-x = w - encPos. + const node = (key: string, encPos: number, size: number, text: string) => { + const tx = rotation === "I" ? w - encPos : sideX; + const ty = rotation === "R" ? encPos : rotation === "B" ? h - encPos : -textGap; + return ; + }; + + // Single digit floated BEFORE barcode start (outside the quiet zone). + const sysNode = (key: string, text: string) => { + // R: above top (y=-ldW); B: below bottom (y=h+ldW); I: right of barcode (x=w+ldW). + const tx = rotation === "I" ? w + ldW : sideX; + const ty = rotation === "R" ? -ldW : rotation === "B" ? h + ldW : -textGap; + return ; + }; + + // Single digit floated AFTER barcode end (UPC-A/UPC-E check digit). + const trailNode = (key: string, text: string) => { + // R: below bottom (y≈encDisplay); B: above top (y≈h-encDisplay); I: left of x=0. + const tx = rotation === "I" ? -ldW : sideX; + const ty = rotation === "R" ? encDisplay : rotation === "B" ? h - encDisplay : -textGap; + return ; + }; + + if (obj.type === "ean13") { + const d12 = rawContent.replace(/\D/g, "").slice(0, 12).padEnd(12, "0"); + const all13 = d12 + eanCheckDigit(d12, 1, 3); + textElements = [ + sysNode("sys", all13[0] ?? ""), + node("left", xLeft, halfW, all13.slice(1, 7)), + node("right", xRight, halfW, all13.slice(7, 13)), + ]; + } else if (obj.type === "ean8") { + const d7 = rawContent.replace(/\D/g, "").slice(0, 7).padEnd(7, "0"); + const all8 = d7 + eanCheckDigit(d7, 3, 1); + textElements = [ + node("left", xLeft, halfW, all8.slice(0, 4)), + node("right", xRight, halfW, all8.slice(4, 8)), + ]; + } else if (obj.type === "upca") { + const d11 = rawContent.replace(/\D/g, "").slice(0, 11).padEnd(11, "0"); + const all12 = d11 + eanCheckDigit(d11, 3, 1); + textElements = [ + sysNode("sys", all12[0] ?? ""), + node("left", xLeft, halfW, all12.slice(1, 6)), + node("right", xRight, halfW, all12.slice(6, 11)), + ]; + } else if (obj.type === "upce") { + const d6 = rawContent.replace(/\D/g, "").slice(0, 6).padEnd(6, "0"); + const ck = upceCheckDigit(d6); + textElements = [ + sysNode("sys", "0"), + node("mid", xLeft, halfW, d6), + trailNode("trail", ck), + ]; + } + } else { + // ── Other 1D: single centered text string ────────────────────────── + let txtX: number; + let txtY: number; + let txtWidth: number; + + if (rotation === "R") { + txtX = sideX; txtY = 0; txtWidth = h; + } else if (rotation === "I") { + txtX = w; + txtY = isTextAbove ? h + textGap + textFontSize : -textGap; + txtWidth = w; + } else { + txtX = sideX; txtY = h; txtWidth = h; + } + + textElements = ( + + ); + } + + return ( + onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey)} + onTap={() => onSelect(false)} + onDragMove={(e) => e.target.position(snapPos(e.target.x(), e.target.y()))} + onDragEnd={handleDragEnd} + > + + {textElements} + + ); + } + return ( { // bwip-js native canvas widths (no quiet zones, scale=2): @@ -67,3 +68,60 @@ describe("getEanUpcLayout", () => { }); }); }); + +describe("rotation pipeline", () => { + // Minimal code128 fixture; only the props used by buildBwipOptions/ + // getDisplaySize matter for these checks. + const baseCode128 = (rotation: "N" | "R" | "I" | "B"): LabelObject => + ({ + id: "1", + type: "code128", + x: 0, + y: 0, + rotation: 0, + props: { + content: "ABC", + height: 100, + moduleWidth: 2, + printInterpretation: false, + checkDigit: false, + rotation, + }, + }) as LabelObject; + + it("does not set rotate for N", () => { + const opts = buildBwipOptions(baseCode128("N"), 1, 8); + expect(opts?.rotate).toBeUndefined(); + }); + + it("forwards R and I unchanged to bwip-js", () => { + expect(buildBwipOptions(baseCode128("R"), 1, 8)?.rotate).toBe("R"); + expect(buildBwipOptions(baseCode128("I"), 1, 8)?.rotate).toBe("I"); + }); + + it("translates ZPL B to bwip L (270° CW)", () => { + expect(buildBwipOptions(baseCode128("B"), 1, 8)?.rotate).toBe("L"); + }); + + it("swaps display W and H for quarter rotations", () => { + // Pretend bwip produced an unrotated 200x100 bitmap. + const fakeCanvas = { width: 200, height: 100 } as HTMLCanvasElement; + const upright = getDisplaySize(baseCode128("N"), fakeCanvas, 1, 8); + // For R/B, bwip's bitmap is post-rotation (100x200). Pass that and check + // the upright dimensions are recovered then re-swapped to visible. + const rotatedCanvas = { width: 100, height: 200 } as HTMLCanvasElement; + const rotR = getDisplaySize(baseCode128("R"), rotatedCanvas, 1, 8); + const rotB = getDisplaySize(baseCode128("B"), rotatedCanvas, 1, 8); + expect(rotR.w).toBe(upright.h); + expect(rotR.h).toBe(upright.w); + expect(rotB.w).toBe(upright.h); + expect(rotB.h).toBe(upright.w); + }); + + it("leaves dimensions untouched for I (180°)", () => { + const fakeCanvas = { width: 200, height: 100 } as HTMLCanvasElement; + const upright = getDisplaySize(baseCode128("N"), fakeCanvas, 1, 8); + const inverted = getDisplaySize(baseCode128("I"), fakeCanvas, 1, 8); + expect(inverted).toEqual(upright); + }); +}); diff --git a/src/components/Canvas/bwipHelpers.ts b/src/components/Canvas/bwipHelpers.ts index aee1ab7e..0b161aac 100644 --- a/src/components/Canvas/bwipHelpers.ts +++ b/src/components/Canvas/bwipHelpers.ts @@ -1,4 +1,5 @@ import type { LabelObject } from "../../registry"; +import { objectRotation } from "../../registry/rotation"; import { dotsToPx } from "../../lib/coordinates"; import { MICROPDF417_QUIET_ZONE_ROWS } from "./bwipConstants"; @@ -131,6 +132,18 @@ export function eanCheckDigit(digits: string, w0: number, w1: number): string { return String((10 - (sum % 10)) % 10); } +/** Compute the UPC-E check digit from the 6 compressed data digits. */ +export function upceCheckDigit(digits6: string): string { + const [vA, vB, vC, vD, vE, vF] = digits6.padEnd(6, "0").split(""); + const fi = parseInt(vF ?? "0", 10); + let exp: string; + if (fi <= 2) exp = `0${vA}${vB}${vF}0000${vC}${vD}${vE}`; + else if (fi === 3) exp = `0${vA}${vB}${vC}00000${vD}${vE}`; + else if (fi === 4) exp = `0${vA}${vB}${vC}${vD}00000${vE}`; + else exp = `0${vA}${vB}${vC}${vD}${vE}${vF}0000`; + return eanCheckDigit(exp, 3, 1); +} + /** * Encode text as Code 128 subset B using bwip-js raw ^NNN format. * ZPL's ^BC defaults to subset B for printable ASCII content, so using raw @@ -164,6 +177,12 @@ export function buildBwipOptions( ? get1DBwipScale(mw, renderScale, renderDpmm) : BWIP_SCALE; + // bwip-js takes the same N/R/I/B letters ZPL does for symbol orientation; + // emitting it post-build means the produced bitmap is already rotated and + // its dimensions are the post-rotation extents — no Konva-side rotation math + // needed. + const rotation = objectRotation(obj.props); + let opts: Record | null = null; switch (obj.type) { @@ -336,6 +355,16 @@ export function buildBwipOptions( return null; } + if (opts && rotation !== "N") { + // ZPL uses N/R/I/B (B = 270° CW). bwip-js uses N/R/I/L (L = 90° CCW = + // 270° CW). The other three letters mean the same thing in both. + opts.rotate = rotation === "B" ? "L" : rotation; + // HRI text is handled as a Konva overlay in BarcodeObject (same as for + // upright barcodes). Using bwip's includetext would embed text into the + // bitmap at bwip's internal scale, making the bitmap taller/wider than the + // bar-only dimensions that getDisplaySize computes — causing the KImage to + // stretch the bitmap incorrectly and appear blurry/distorted. + } return opts; } @@ -347,6 +376,24 @@ export function getDisplaySize( ): { w: number; h: number } { if (!canvas) return { w: 0, h: 0 }; + // For 90°/270° rotations, bwip-js produces a bitmap whose width and height + // are swapped relative to the upright form. Compute size as if upright (the + // existing per-symbology formulas all assume that), then swap at the end. + const rotation = objectRotation(obj.props); + const isQuarter = rotation === "R" || rotation === "B"; + const cw = isQuarter ? canvas.height : canvas.width; + const ch = isQuarter ? canvas.width : canvas.height; + const upright = getUprightDisplaySize(obj, cw, ch, scale, dpmm); + return isQuarter ? { w: upright.h, h: upright.w } : upright; +} + +function getUprightDisplaySize( + obj: LabelObject, + cw: number, + ch: number, + scale: number, + dpmm: number, +): { w: number; h: number } { // bwip-js at bwipSc=1 renders 1 extra pixel; at bwipSc>=2 it renders the exact module // count. The extraPx term corrects for this so formulas stay consistent across scales. switch (obj.type) { @@ -356,7 +403,7 @@ export function getDisplaySize( // Correcting to the Labelary width would stretch bars; return the bwip-natural size. const modulePx = dotsToPx(obj.props.moduleWidth, scale, dpmm); const bwipSc = get1DBwipScale(obj.props.moduleWidth, scale, dpmm); - const w = (canvas.width / bwipSc) * modulePx; + const w = (cw / bwipSc) * modulePx; const h = dotsToPx(obj.props.height, scale, dpmm); return { w, h }; } @@ -365,7 +412,7 @@ export function getDisplaySize( // Width is approximate; the visual regression is skipped for this type. const modulePx = dotsToPx(obj.props.moduleWidth, scale, dpmm); const bwipSc = get1DBwipScale(obj.props.moduleWidth, scale, dpmm); - const w = (canvas.width / bwipSc) * modulePx; + const w = (cw / bwipSc) * modulePx; const h = dotsToPx(obj.props.height, scale, dpmm); return { w, h }; } @@ -375,7 +422,7 @@ export function getDisplaySize( // match with Labelary fixtures regardless of bwip canvas pixel rounding. const modulePx = dotsToPx(obj.props.moduleWidth, scale, dpmm); const bwipSc = get1DBwipScale(obj.props.moduleWidth, scale, dpmm); - const rawPx = (canvas.width / bwipSc) * modulePx * POSTNET_PLANET_WIDTH_RATIO; + const rawPx = (cw / bwipSc) * modulePx * POSTNET_PLANET_WIDTH_RATIO; const wDots = Math.round((rawPx / scale) * dpmm); const w = dotsToPx(wDots, scale, dpmm); const h = dotsToPx(obj.props.height, scale, dpmm); @@ -384,17 +431,17 @@ export function getDisplaySize( case "gs1databar": { const modulePx = dotsToPx(obj.props.moduleWidth, scale, dpmm); const bwipSc = get1DBwipScale(obj.props.moduleWidth, scale, dpmm); - const w = (canvas.width / bwipSc) * modulePx; + const w = (cw / bwipSc) * modulePx; // Height is symbol-standard fixed (not the ZPL height param). // paddingheight:2 in buildBwipOptions adds the quiet-zone rows so - // canvas.height already reflects the correct total height. - const h = (canvas.height / bwipSc) * modulePx; + // ch already reflects the correct total height. + const h = (ch / bwipSc) * modulePx; return { w, h }; } case "code128": { const modulePx = dotsToPx(obj.props.moduleWidth, scale, dpmm); const bwipSc = get1DBwipScale(obj.props.moduleWidth, scale, dpmm); - const w = (canvas.width / bwipSc) * modulePx; + const w = (cw / bwipSc) * modulePx; const h = dotsToPx(obj.props.height, scale, dpmm); return { w, h }; } @@ -405,7 +452,7 @@ export function getDisplaySize( const modulePx = dotsToPx(obj.props.moduleWidth, scale, dpmm); const bwipSc = get1DBwipScale(obj.props.moduleWidth, scale, dpmm); const extraPx = bwipSc === 1 ? 1 : 0; - const w = ((canvas.width - extraPx) / bwipSc) * modulePx; + const w = ((cw - extraPx) / bwipSc) * modulePx; const h = dotsToPx(obj.props.height, scale, dpmm); return { w, h }; } @@ -419,14 +466,14 @@ export function getDisplaySize( const modulePx = dotsToPx(obj.props.moduleWidth, scale, dpmm); const bwipSc = get1DBwipScale(obj.props.moduleWidth, scale, dpmm); const extraPx = bwipSc === 1 ? 1 : 0; - const w = ((canvas.width - extraPx) / bwipSc) * modulePx; + const w = ((cw - extraPx) / bwipSc) * modulePx; const h = dotsToPx(obj.props.height, scale, dpmm); return { w, h }; } case "pdf417": { const p = obj.props; // bwip-js uses a fixed internal row height of 3 for pdf417 - const numRows = canvas.height / (BWIP_PDF417_MIN_ROWHEIGHT * BWIP_SCALE); + const numRows = ch / (BWIP_PDF417_MIN_ROWHEIGHT * BWIP_SCALE); // Width check: bwip-js sometimes adds unexpected padding or uses // different column logic. We force the display width based on the @@ -444,28 +491,28 @@ export function getDisplaySize( case "qrcode": { const modulePx = dotsToPx(obj.props.magnification, scale, dpmm); const size = - (canvas.width / (BWIP_SCALE * BWIP_2D_INTERNAL_SCALE)) * modulePx; + (cw / (BWIP_SCALE * BWIP_2D_INTERNAL_SCALE)) * modulePx; return { w: size, h: size }; } case "datamatrix": { const modulePx = dotsToPx(obj.props.dimension, scale, dpmm); const size = - (canvas.width / (BWIP_SCALE * BWIP_2D_INTERNAL_SCALE)) * modulePx; + (cw / (BWIP_SCALE * BWIP_2D_INTERNAL_SCALE)) * modulePx; return { w: size, h: size }; } case "aztec": { const modulePx = dotsToPx(obj.props.magnification, scale, dpmm); const size = - (canvas.width / (BWIP_SCALE * BWIP_2D_INTERNAL_SCALE)) * modulePx; + (cw / (BWIP_SCALE * BWIP_2D_INTERNAL_SCALE)) * modulePx; return { w: size, h: size }; } case "micropdf417": { const p = obj.props; // bwip-js ignores rowheight for micropdf417 and always uses 2 internal pixels per row. // It also adds MICROPDF417_QUIET_ZONE_ROWS quiet-zone rows (top+bottom) to the canvas. - const numRows = Math.max(0, canvas.height / (BWIP_SCALE * 2) - MICROPDF417_QUIET_ZONE_ROWS); + const numRows = Math.max(0, ch / (BWIP_SCALE * 2) - MICROPDF417_QUIET_ZONE_ROWS); const w = - (canvas.width / BWIP_SCALE) * dotsToPx(p.moduleWidth, scale, dpmm); + (cw / BWIP_SCALE) * dotsToPx(p.moduleWidth, scale, dpmm); const h = numRows * dotsToPx(p.rowHeight, scale, dpmm); return { w, h }; } @@ -476,14 +523,14 @@ export function getDisplaySize( Math.round(p.rowHeight / Math.max(p.moduleWidth, 1)), ); const w = - (canvas.width / BWIP_SCALE) * dotsToPx(p.moduleWidth, scale, dpmm); + (cw / BWIP_SCALE) * dotsToPx(p.moduleWidth, scale, dpmm); const h = - (canvas.height / BWIP_SCALE) * + (ch / BWIP_SCALE) * (dotsToPx(p.rowHeight, scale, dpmm) / specRowheight); return { w, h }; } default: { - return { w: canvas.width, h: canvas.height }; + return { w: cw, h: ch }; } } } diff --git a/src/components/Canvas/transformPosition.test.ts b/src/components/Canvas/transformPosition.test.ts index bae49176..cfb78fd7 100644 --- a/src/components/Canvas/transformPosition.test.ts +++ b/src/components/Canvas/transformPosition.test.ts @@ -10,7 +10,7 @@ const qrFo: LabelObject = { y: 0, rotation: 0, positionType: "FO", - props: { content: "x", magnification: 4, errorCorrection: "Q" }, + props: { content: "x", magnification: 4, errorCorrection: "Q", rotation: "N" }, }; const ellipse: LabelObject = { diff --git a/src/components/Properties/RotationSelect.tsx b/src/components/Properties/RotationSelect.tsx new file mode 100644 index 00000000..478dc9ea --- /dev/null +++ b/src/components/Properties/RotationSelect.tsx @@ -0,0 +1,26 @@ +import { ZPL_ROTATIONS, isZplRotation, type ZplRotation } from '../../registry/rotation'; +import { useT } from '../../lib/useT'; +import { inputCls, labelCls } from './styles'; + +interface Props { + value: ZplRotation; + onChange: (next: ZplRotation) => void; +} + +export function RotationSelect({ value, onChange }: Props) { + const t = useT(); + return ( +
+ + +
+ ); +} diff --git a/src/lib/zplParser.test.ts b/src/lib/zplParser.test.ts index a72bb701..3bd09f60 100644 --- a/src/lib/zplParser.test.ts +++ b/src/lib/zplParser.test.ts @@ -227,6 +227,28 @@ describe('parseZPL — ^FX comment', () => { }); }); +// ── barcode rotation ────────────────────────────────────────────────────────── + +describe('parseZPL — barcode rotation', () => { + it.each([ + ['^XA^BY2^FO0,0^BCR,100,Y,N,N^FD123^FS^XZ', 'R'], + ['^XA^BY2^FO0,0^BCI,100,Y,N,N^FD123^FS^XZ', 'I'], + ['^XA^BY2^FO0,0^BCB,100,Y,N,N^FD123^FS^XZ', 'B'], + ['^XA^FO0,0^BQR,2,4^FDQA,X^FS^XZ', 'R'], + ['^XA^FO0,0^BXB,5,200^FDX^FS^XZ', 'B'], + ['^XA^FO0,0^B7I,4,0,0,,,^FDX^FS^XZ', 'I'], + ['^XA^FO0,0^B0R,4,N,N,N,N^FDX^FS^XZ', 'R'], + ])('reads orientation from %s', (zpl, expected) => { + const { objects } = parseZPL(zpl, 8); + expect((objects[0]?.props as { rotation?: string }).rotation).toBe(expected); + }); + + it('defaults to N when orientation is missing or unrecognised', () => { + const { objects } = parseZPL('^XA^BY2^FO0,0^BC,100,Y,N,N^FD123^FS^XZ', 8); + expect((objects[0]?.props as { rotation?: string }).rotation).toBe('N'); + }); +}); + // ── ^FH hex encoding ────────────────────────────────────────────────────────── describe('parseZPL — ^FH hex escape', () => { diff --git a/src/lib/zplParser.ts b/src/lib/zplParser.ts index df5ba71d..9f5732a1 100644 --- a/src/lib/zplParser.ts +++ b/src/lib/zplParser.ts @@ -13,6 +13,7 @@ import type { ImageProps } from "../registry/image"; import type { Barcode1DProps } from "../registry/barcode1d"; import type { Pdf417Props } from "../registry/pdf417"; import type { SerialProps } from "../registry/serial"; +import { isZplRotation, type ZplRotation } from "../registry/rotation"; import type { AztecProps } from "../registry/aztec"; import type { MicroPdf417Props } from "../registry/micropdf417"; import type { CodablockProps } from "../registry/codablock"; @@ -213,6 +214,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { let bcHeight = 100; let bcInterp = true; let bcCheck = false; + let bcRotation: ZplRotation = "N"; // ^BY barcode defaults let byModuleWidth = 2; let byHeight = 0; // 0 = no ^BY height; barcode handlers use ||100 as sentinel @@ -344,6 +346,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { moduleWidth: byModuleWidth, printInterpretation: bcInterp, checkDigit: bcCheck, + rotation: bcRotation, } satisfies Code128Props, posType, comment, @@ -362,6 +365,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { moduleWidth: byModuleWidth, printInterpretation: bcInterp, checkDigit: bcCheck, + rotation: bcRotation, } satisfies Code39Props, posType, comment, @@ -379,6 +383,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { height: bcHeight, moduleWidth: byModuleWidth, printInterpretation: bcInterp, + rotation: bcRotation, } satisfies Ean13Props, posType, comment, @@ -398,6 +403,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { content: data, magnification: qrMag, errorCorrection: ec, + rotation: bcRotation, } satisfies QrCodeProps, posType, comment, @@ -415,6 +421,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { content, dimension: dmDim, quality: dmQuality, + rotation: bcRotation, } satisfies DataMatrixProps, posType, comment, @@ -447,6 +454,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { moduleWidth: byModuleWidth, printInterpretation: bcInterp, checkDigit: bcCheck, + rotation: bcRotation, } satisfies Barcode1DProps, posType, comment, @@ -465,6 +473,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { securityLevel: pdfSecurity, columns: pdfColumns, moduleWidth: byModuleWidth, + rotation: bcRotation, } satisfies Pdf417Props, posType, comment, @@ -481,6 +490,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { content, magnification: aztecMag, ecLevel: 0, + rotation: bcRotation, } satisfies AztecProps, posType, comment, @@ -498,6 +508,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { moduleWidth: byModuleWidth, rowHeight: mpdfRowHeight, mode: 0, + rotation: bcRotation, } satisfies MicroPdf417Props, posType, comment, @@ -515,6 +526,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { moduleWidth: byModuleWidth, rowHeight: cbRowHeight, securityLevel: cbSecurity, + rotation: bcRotation, } satisfies CodablockProps, posType, comment, @@ -545,7 +557,14 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { browserLimit.push(tok); }; - const handleAztec: Handler = (p) => { fieldType = "aztec"; aztecMag = int(p[1], 4); }; + const readRotation = (raw: string | undefined): ZplRotation => + raw && isZplRotation(raw) ? raw : "N"; + + const handleAztec: Handler = (p) => { + fieldType = "aztec"; + bcRotation = readRotation(p[0]); + aztecMag = int(p[1], 4); + }; // Factory for standard 1D barcode commands that share the same state variables. // hIdx/iIdx/cIdx are the comma-split parameter indices for height/interp/check. @@ -557,6 +576,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { cIdx = -1, ): Handler => (p) => { fieldType = type; + bcRotation = readRotation(p[0]); bcHeight = int(p[hIdx], byHeight || 100); bcInterp = (p[iIdx] ?? iDefault) === "Y"; if (cIdx >= 0) bcCheck = (p[cIdx] ?? "N") === "Y"; @@ -665,6 +685,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { // ^BMN,{checkType},{height},{interp},N (checkType: A/B/C/D=enabled, N=none) BM(p) { fieldType = "msi"; + bcRotation = readRotation(p[0]); bcCheck = (p[1] ?? "N") !== "N"; bcHeight = int(p[2], byHeight || 100); bcInterp = (p[3] ?? "Y") === "Y"; @@ -673,6 +694,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { // ^BRN,{symbology},{magnification},{separator},{height},{segments} BR(p) { fieldType = "gs1databar"; + bcRotation = readRotation(p[0]); bcHeight = int(p[4], byHeight || 100); byModuleWidth = int(p[2], byModuleWidth); }, @@ -680,17 +702,20 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { // ^BQN,2,{magnification} — QR Code BQ(p) { fieldType = "qrcode"; + bcRotation = readRotation(p[0]); qrMag = int(p[2], 4); }, // ^BXN,{dimension},{quality} — DataMatrix BX(p) { fieldType = "datamatrix"; + bcRotation = readRotation(p[0]); dmDim = int(p[1], 5); dmQuality = int(p[2], 200) as DataMatrixProps["quality"]; }, // ^B7N,{rowHeight},{securityLevel},{columns},,, — PDF417 B7(p) { fieldType = "pdf417"; + bcRotation = readRotation(p[0]); pdfRowHeight = int(p[1], 10); pdfSecurity = int(p[2], 0); pdfColumns = int(p[3], 0); @@ -701,11 +726,13 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { // ^BFN,{rowHeight} — MicroPDF417 BF(p) { fieldType = "micropdf417"; + bcRotation = readRotation(p[0]); mpdfRowHeight = int(p[1], 10); }, // ^BBN,{rowHeight},{security},{numCharsPerRow},{numRows},{mode} — CODABLOCK BB(p) { fieldType = "codablock"; + bcRotation = readRotation(p[0]); cbRowHeight = int(p[1], 10); cbSecurity = (p[2] ?? "Y") === "N" ? "N" : "Y"; }, diff --git a/src/registry/aztec.tsx b/src/registry/aztec.tsx index b759d4c7..e5751847 100644 --- a/src/registry/aztec.tsx +++ b/src/registry/aztec.tsx @@ -2,11 +2,14 @@ import type { ObjectTypeDefinition } from "../types/ObjectType"; import { useT } from "../lib/useT"; import { inputCls, labelCls } from "../components/Properties/styles"; import { fieldPos, fdField } from "./zplHelpers"; +import { type ZplRotation } from "./rotation"; +import { RotationSelect } from "../components/Properties/RotationSelect"; export interface AztecProps { content: string; magnification: number; // 1–10, module size in dots ecLevel: number; // 0 = auto, 1–99 error correction percentage, 201–232 for layers + rotation: ZplRotation; } export const aztec: ObjectTypeDefinition = { @@ -17,6 +20,7 @@ export const aztec: ObjectTypeDefinition = { content: "1234567890", magnification: 4, ecLevel: 0, + rotation: 'N', }, defaultSize: { width: 200, height: 200 }, @@ -26,7 +30,7 @@ export const aztec: ObjectTypeDefinition = { // Also ^BO (alternate) — we use ^B0 as canonical return [ fieldPos(obj), - `^B0N,${p.magnification},N,N,N,N`, + `^B0${p.rotation},${p.magnification},N,N,N,N`, fdField(p.content), ].join(""); }, @@ -71,6 +75,8 @@ export const aztec: ObjectTypeDefinition = { onChange={(e) => onChange({ ecLevel: Number(e.target.value) })} /> + + onChange({ rotation })} /> ); }, diff --git a/src/registry/barcode1d.tsx b/src/registry/barcode1d.tsx index 7c09932f..7d290913 100644 --- a/src/registry/barcode1d.tsx +++ b/src/registry/barcode1d.tsx @@ -4,6 +4,8 @@ import { inputCls, labelCls } from '../components/Properties/styles'; import { fieldPos, fdField } from './zplHelpers'; import { commitHeightTransform } from './transformHelpers'; import { filterContent, type ContentSpec } from './contentSpec'; +import { type ZplRotation } from './rotation'; +import { RotationSelect } from '../components/Properties/RotationSelect'; export interface Barcode1DProps { content: string; @@ -11,6 +13,7 @@ export interface Barcode1DProps { moduleWidth: number; printInterpretation: boolean; checkDigit: boolean; + rotation: ZplRotation; } interface Barcode1DConfig { @@ -58,6 +61,7 @@ export function createBarcode1D(config: Barcode1DConfig): ObjectTypeDefinition{loc.checkDigit} )} + + onChange({ rotation })} + /> ); }, diff --git a/src/registry/codabar.tsx b/src/registry/codabar.tsx index 7a3efefb..66e89674 100644 --- a/src/registry/codabar.tsx +++ b/src/registry/codabar.tsx @@ -12,6 +12,6 @@ export const codabar = createBarcode1D({ zplCommand: (p) => { const interp = p.printInterpretation ? "Y" : "N"; const check = p.checkDigit ? "Y" : "N"; - return `^BKN,${check},${p.height},${interp},N`; + return `^BK${p.rotation},${check},${p.height},${interp},N`; }, }); diff --git a/src/registry/codablock.tsx b/src/registry/codablock.tsx index 70af76dd..ec36ba2b 100644 --- a/src/registry/codablock.tsx +++ b/src/registry/codablock.tsx @@ -3,12 +3,15 @@ import { useT } from "../lib/useT"; import { inputCls, labelCls } from "../components/Properties/styles"; import { fieldPos, fdField } from "./zplHelpers"; import { commitStacked2DTransform } from "./transformHelpers"; +import { type ZplRotation } from "./rotation"; +import { RotationSelect } from "../components/Properties/RotationSelect"; export interface CodablockProps { content: string; moduleWidth: number; // bar width in dots rowHeight: number; // row height in dots securityLevel: "Y" | "N"; // security check + rotation: ZplRotation; } export const codablock: ObjectTypeDefinition = { @@ -20,6 +23,7 @@ export const codablock: ObjectTypeDefinition = { moduleWidth: 2, rowHeight: 2, securityLevel: "Y", + rotation: 'N', }, defaultSize: { width: 250, height: 120 }, @@ -31,7 +35,7 @@ export const codablock: ObjectTypeDefinition = { return [ `^BY${p.moduleWidth}`, fieldPos(obj), - `^BBN,${p.rowHeight},${p.securityLevel}`, + `^BB${p.rotation},${p.rowHeight},${p.securityLevel}`, fdField(p.content), ] .filter(Boolean) @@ -90,6 +94,8 @@ export const codablock: ObjectTypeDefinition = { /> {loc.security} + + onChange({ rotation })} /> ); }, diff --git a/src/registry/code11.tsx b/src/registry/code11.tsx index 70d02f6c..218a816b 100644 --- a/src/registry/code11.tsx +++ b/src/registry/code11.tsx @@ -12,6 +12,6 @@ export const code11 = createBarcode1D({ zplCommand: (p) => { const interp = p.printInterpretation ? "Y" : "N"; const check = p.checkDigit ? "Y" : "N"; - return `^B1N,${check},${p.height},${interp},N`; + return `^B1${p.rotation},${check},${p.height},${interp},N`; }, }); diff --git a/src/registry/code128.tsx b/src/registry/code128.tsx index 4391724c..3225bfbe 100644 --- a/src/registry/code128.tsx +++ b/src/registry/code128.tsx @@ -3,6 +3,8 @@ import { useT } from '../lib/useT'; import { inputCls, labelCls } from '../components/Properties/styles'; import { fieldPos, fdField } from './zplHelpers'; import { commitHeightTransform } from './transformHelpers'; +import { type ZplRotation } from './rotation'; +import { RotationSelect } from '../components/Properties/RotationSelect'; export interface Code128Props { content: string; @@ -10,6 +12,7 @@ export interface Code128Props { moduleWidth: number; printInterpretation: boolean; checkDigit: boolean; + rotation: ZplRotation; } export const code128: ObjectTypeDefinition = { @@ -22,6 +25,7 @@ export const code128: ObjectTypeDefinition = { moduleWidth: 2, printInterpretation: true, checkDigit: false, + rotation: 'N', }, defaultSize: { width: 300, height: 120 }, @@ -34,7 +38,7 @@ export const code128: ObjectTypeDefinition = { return [ `^BY${p.moduleWidth}`, fieldPos(obj), - `^BCN,${p.height},${interp},N,${check}`, + `^BC${p.rotation},${p.height},${interp},N,${check}`, fdField(p.content), ].filter(Boolean).join(''); }, @@ -96,6 +100,8 @@ export const code128: ObjectTypeDefinition = { {t.registry.code128.checkDigit} + + onChange({ rotation })} /> ); }, diff --git a/src/registry/code39.tsx b/src/registry/code39.tsx index 114a5143..a57e0167 100644 --- a/src/registry/code39.tsx +++ b/src/registry/code39.tsx @@ -4,6 +4,8 @@ import { inputCls, labelCls } from '../components/Properties/styles'; import { fieldPos, fdField } from './zplHelpers'; import { commitHeightTransform } from './transformHelpers'; import { filterContent, type ContentSpec } from './contentSpec'; +import { type ZplRotation } from './rotation'; +import { RotationSelect } from '../components/Properties/RotationSelect'; const code39Spec: ContentSpec = { charset: '0-9A-Za-z\\-. $/+%' }; @@ -13,6 +15,7 @@ export interface Code39Props { moduleWidth: number; printInterpretation: boolean; checkDigit: boolean; + rotation: ZplRotation; } export const code39: ObjectTypeDefinition = { @@ -25,6 +28,7 @@ export const code39: ObjectTypeDefinition = { moduleWidth: 2, printInterpretation: true, checkDigit: false, + rotation: 'N', }, defaultSize: { width: 300, height: 120 }, @@ -37,7 +41,7 @@ export const code39: ObjectTypeDefinition = { return [ `^BY${p.moduleWidth}`, fieldPos(obj), - `^B3N,${check},${p.height},${interp},N`, + `^B3${p.rotation},${check},${p.height},${interp},N`, fdField(p.content), ].filter(Boolean).join(''); }, @@ -99,6 +103,8 @@ export const code39: ObjectTypeDefinition = { {t.registry.code39.checkDigit} + + onChange({ rotation })} /> ); }, diff --git a/src/registry/code93.tsx b/src/registry/code93.tsx index 35c76b27..d815f1fa 100644 --- a/src/registry/code93.tsx +++ b/src/registry/code93.tsx @@ -11,6 +11,6 @@ export const code93 = createBarcode1D({ zplCommand: (p) => { const interp = p.printInterpretation ? 'Y' : 'N'; const check = p.checkDigit ? 'Y' : 'N'; - return `^BAN,${p.height},${interp},N,${check}`; + return `^BA${p.rotation},${p.height},${interp},N,${check}`; }, }); diff --git a/src/registry/datamatrix.tsx b/src/registry/datamatrix.tsx index 3eb583f0..50232712 100644 --- a/src/registry/datamatrix.tsx +++ b/src/registry/datamatrix.tsx @@ -3,11 +3,14 @@ import { useT } from '../lib/useT'; import { inputCls, labelCls } from '../components/Properties/styles'; import { fieldPos, fdField } from './zplHelpers'; import { clamp } from './transformHelpers'; +import { type ZplRotation } from './rotation'; +import { RotationSelect } from '../components/Properties/RotationSelect'; export interface DataMatrixProps { content: string; dimension: number; // module size in dots (1–12) quality: 0 | 50 | 80 | 140 | 200; // 0 = auto + rotation: ZplRotation; } export const datamatrix: ObjectTypeDefinition = { @@ -18,6 +21,7 @@ export const datamatrix: ObjectTypeDefinition = { content: '1234567890', dimension: 5, quality: 200, + rotation: 'N', }, defaultSize: { width: 150, height: 150 }, @@ -29,7 +33,7 @@ export const datamatrix: ObjectTypeDefinition = { const p = obj.props; return [ fieldPos(obj), - `^BXN,${p.dimension},${p.quality}`, + `^BX${p.rotation},${p.dimension},${p.quality}`, fdField(p.content), ].join(''); }, @@ -74,6 +78,8 @@ export const datamatrix: ObjectTypeDefinition = { + + onChange({ rotation })} /> ); }, diff --git a/src/registry/ean13.tsx b/src/registry/ean13.tsx index e9244a8f..ea6894c8 100644 --- a/src/registry/ean13.tsx +++ b/src/registry/ean13.tsx @@ -4,6 +4,8 @@ import { inputCls, labelCls } from '../components/Properties/styles'; import { fieldPos, fdField } from './zplHelpers'; import { commitHeightTransform } from './transformHelpers'; import { filterContent, type ContentSpec } from './contentSpec'; +import { type ZplRotation } from './rotation'; +import { RotationSelect } from '../components/Properties/RotationSelect'; const ean13Spec: ContentSpec = { charset: '0-9', maxLength: 12 }; @@ -12,6 +14,7 @@ export interface Ean13Props { height: number; moduleWidth: number; printInterpretation: boolean; + rotation: ZplRotation; } export const ean13: ObjectTypeDefinition = { @@ -23,6 +26,7 @@ export const ean13: ObjectTypeDefinition = { height: 100, moduleWidth: 2, printInterpretation: true, + rotation: 'N', }, defaultSize: { width: 300, height: 120 }, @@ -34,7 +38,7 @@ export const ean13: ObjectTypeDefinition = { return [ `^BY${p.moduleWidth}`, fieldPos(obj), - `^BEN,${p.height},${interp},N`, + `^BE${p.rotation},${p.height},${interp},N`, fdField(p.content), ].filter(Boolean).join(''); }, @@ -87,6 +91,8 @@ export const ean13: ObjectTypeDefinition = { /> {t.registry.ean13.printInterpretation} + + onChange({ rotation })} /> ); }, diff --git a/src/registry/ean8.tsx b/src/registry/ean8.tsx index 64a6b26e..91e51874 100644 --- a/src/registry/ean8.tsx +++ b/src/registry/ean8.tsx @@ -11,6 +11,6 @@ export const ean8 = createBarcode1D({ contentSpec: { charset: '0-9', maxLength: 7 }, zplCommand: (p) => { const interp = p.printInterpretation ? 'Y' : 'N'; - return `^B8N,${p.height},${interp},N`; + return `^B8${p.rotation},${p.height},${interp},N`; }, }); diff --git a/src/registry/gs1databar.tsx b/src/registry/gs1databar.tsx index 80a02376..022d53ba 100644 --- a/src/registry/gs1databar.tsx +++ b/src/registry/gs1databar.tsx @@ -18,6 +18,6 @@ export const gs1databar = createBarcode1D({ zplCommand: (p) => { // ^BR{orientation},{symbology},{magnification},{separator},{height},{segments} // symbology 1 = omnidirectional - return `^BRN,1,${p.moduleWidth},2,${p.height},1`; + return `^BR${p.rotation},1,${p.moduleWidth},2,${p.height},1`; }, }); diff --git a/src/registry/industrial2of5.tsx b/src/registry/industrial2of5.tsx index 43647221..6fac1cda 100644 --- a/src/registry/industrial2of5.tsx +++ b/src/registry/industrial2of5.tsx @@ -11,6 +11,6 @@ export const industrial2of5 = createBarcode1D({ contentSpec: { charset: '0-9' }, zplCommand: (p) => { const interp = p.printInterpretation ? "Y" : "N"; - return `^BIN,${p.height},${interp},N`; + return `^BI${p.rotation},${p.height},${interp},N`; }, }); diff --git a/src/registry/interleaved2of5.tsx b/src/registry/interleaved2of5.tsx index 8006a911..6310ebc3 100644 --- a/src/registry/interleaved2of5.tsx +++ b/src/registry/interleaved2of5.tsx @@ -12,6 +12,6 @@ export const interleaved2of5 = createBarcode1D({ zplCommand: (p) => { const interp = p.printInterpretation ? 'Y' : 'N'; const check = p.checkDigit ? 'Y' : 'N'; - return `^B2N,${p.height},${interp},N,${check}`; + return `^B2${p.rotation},${p.height},${interp},N,${check}`; }, }); diff --git a/src/registry/logmars.tsx b/src/registry/logmars.tsx index 583ccef3..cb94e958 100644 --- a/src/registry/logmars.tsx +++ b/src/registry/logmars.tsx @@ -11,6 +11,6 @@ export const logmars = createBarcode1D({ contentSpec: { charset: '0-9A-Za-z\\-. $/+%' }, zplCommand: (p) => { const interp = p.printInterpretation ? "Y" : "N"; - return `^BLN,${p.height},${interp}`; + return `^BL${p.rotation},${p.height},${interp}`; }, }); diff --git a/src/registry/micropdf417.tsx b/src/registry/micropdf417.tsx index c44214d2..9659a5dc 100644 --- a/src/registry/micropdf417.tsx +++ b/src/registry/micropdf417.tsx @@ -3,12 +3,15 @@ import { useT } from "../lib/useT"; import { inputCls, labelCls } from "../components/Properties/styles"; import { fieldPos, fdField } from "./zplHelpers"; import { commitStacked2DTransform } from "./transformHelpers"; +import { type ZplRotation } from "./rotation"; +import { RotationSelect } from "../components/Properties/RotationSelect"; export interface MicroPdf417Props { content: string; moduleWidth: number; // bar width in dots rowHeight: number; // row height in dots mode: number; + rotation: ZplRotation; } export const micropdf417: ObjectTypeDefinition = { @@ -20,6 +23,7 @@ export const micropdf417: ObjectTypeDefinition = { moduleWidth: 2, rowHeight: 2, mode: 0, + rotation: 'N', }, defaultSize: { width: 200, height: 100 }, @@ -31,7 +35,7 @@ export const micropdf417: ObjectTypeDefinition = { return [ `^BY${p.moduleWidth}`, fieldPos(obj), - `^BFN,${p.rowHeight},${p.mode}`, + `^BF${p.rotation},${p.rowHeight},${p.mode}`, fdField(p.content), ] .filter(Boolean) @@ -90,6 +94,8 @@ export const micropdf417: ObjectTypeDefinition = { onChange={(e) => onChange({ mode: Number(e.target.value) })} /> + + onChange({ rotation })} /> ); }, diff --git a/src/registry/msi.tsx b/src/registry/msi.tsx index f3f1f0e1..99876554 100644 --- a/src/registry/msi.tsx +++ b/src/registry/msi.tsx @@ -18,6 +18,6 @@ export const msi = createBarcode1D({ // ^BM format: ^BM[o,e,h,f,g] — check digit (e) comes before height (h) // A=Mod10, B=Mod11, C=Mod10+Mod10, D=Mod11+Mod10, N=none const checkType = p.checkDigit ? "A" : "N"; - return `^BMN,${checkType},${p.height},${interp},N`; + return `^BM${p.rotation},${checkType},${p.height},${interp},N`; }, }); diff --git a/src/registry/pdf417.tsx b/src/registry/pdf417.tsx index 9a22d7ef..cab3fad9 100644 --- a/src/registry/pdf417.tsx +++ b/src/registry/pdf417.tsx @@ -3,6 +3,8 @@ import { useT } from "../lib/useT"; import { inputCls, labelCls } from "../components/Properties/styles"; import { fieldPos, fdField } from "./zplHelpers"; import { commitStacked2DTransform } from "./transformHelpers"; +import { type ZplRotation } from "./rotation"; +import { RotationSelect } from "../components/Properties/RotationSelect"; export interface Pdf417Props { content: string; @@ -10,6 +12,7 @@ export interface Pdf417Props { securityLevel: number; // 0–8 columns: number; // 1–30, 0 = auto moduleWidth: number; + rotation: ZplRotation; } export const pdf417: ObjectTypeDefinition = { @@ -22,6 +25,7 @@ export const pdf417: ObjectTypeDefinition = { securityLevel: 0, columns: 0, moduleWidth: 2, + rotation: 'N', }, defaultSize: { width: 300, height: 150 }, @@ -32,7 +36,7 @@ export const pdf417: ObjectTypeDefinition = { return [ `^BY${p.moduleWidth}`, fieldPos(obj), - `^B7N,${p.rowHeight},${p.securityLevel},${p.columns},,,`, + `^B7${p.rotation},${p.rowHeight},${p.securityLevel},${p.columns},,,`, fdField(p.content), ] .filter(Boolean) @@ -106,6 +110,8 @@ export const pdf417: ObjectTypeDefinition = { /> + + onChange({ rotation })} /> ); }, diff --git a/src/registry/planet.tsx b/src/registry/planet.tsx index f1c58520..01c4d57c 100644 --- a/src/registry/planet.tsx +++ b/src/registry/planet.tsx @@ -11,6 +11,6 @@ export const planet = createBarcode1D({ contentSpec: { charset: '0-9' }, zplCommand: (p) => { const interp = p.printInterpretation ? "Y" : "N"; - return `^B5N,${p.height},${interp},N`; + return `^B5${p.rotation},${p.height},${interp},N`; }, }); diff --git a/src/registry/plessey.tsx b/src/registry/plessey.tsx index e592f98e..02659c79 100644 --- a/src/registry/plessey.tsx +++ b/src/registry/plessey.tsx @@ -14,6 +14,6 @@ export const plessey = createBarcode1D({ zplCommand: (p) => { const interp = p.printInterpretation ? "Y" : "N"; const check = p.checkDigit ? "Y" : "N"; - return `^BPN,${check},${p.height},${interp},N`; + return `^BP${p.rotation},${check},${p.height},${interp},N`; }, }); diff --git a/src/registry/postal.tsx b/src/registry/postal.tsx index 0179d435..b795a558 100644 --- a/src/registry/postal.tsx +++ b/src/registry/postal.tsx @@ -12,6 +12,6 @@ export const postal = createBarcode1D({ zplCommand: (p) => { const interp = p.printInterpretation ? "Y" : "N"; // ^BZ{orientation},{height},{interp},{startStop} - return `^BZN,${p.height},${interp},N`; + return `^BZ${p.rotation},${p.height},${interp},N`; }, }); diff --git a/src/registry/qrcode.tsx b/src/registry/qrcode.tsx index 022ede6d..4d2fb81b 100644 --- a/src/registry/qrcode.tsx +++ b/src/registry/qrcode.tsx @@ -3,11 +3,14 @@ import { useT } from '../lib/useT'; import { inputCls, labelCls } from '../components/Properties/styles'; import { fieldPos, fdField } from './zplHelpers'; import { clamp } from './transformHelpers'; +import { type ZplRotation } from './rotation'; +import { RotationSelect } from '../components/Properties/RotationSelect'; export interface QrCodeProps { content: string; magnification: number; // 1–10, dot size per module errorCorrection: 'H' | 'Q' | 'M' | 'L'; + rotation: ZplRotation; } export const qrcode: ObjectTypeDefinition = { @@ -18,6 +21,7 @@ export const qrcode: ObjectTypeDefinition = { content: 'https://example.com', magnification: 4, errorCorrection: 'Q', + rotation: 'N', }, defaultSize: { width: 200, height: 200 }, @@ -29,7 +33,7 @@ export const qrcode: ObjectTypeDefinition = { const p = obj.props; return [ fieldPos(obj), - `^BQN,2,${p.magnification}`, + `^BQ${p.rotation},2,${p.magnification}`, fdField(`${p.errorCorrection}A,${p.content}`), ].join(''); }, @@ -84,6 +88,8 @@ export const qrcode: ObjectTypeDefinition = { + + onChange({ rotation })} /> ); }, diff --git a/src/registry/registry.test.ts b/src/registry/registry.test.ts index 6e693ed9..0574a576 100644 --- a/src/registry/registry.test.ts +++ b/src/registry/registry.test.ts @@ -178,7 +178,7 @@ describe('code128.toZPL', () => { it('emits ^BC and ^FD', () => { const zpl = def.toZPL(makeObj('code128', { content: 'ABCDEF', height: 100, moduleWidth: 2, - printInterpretation: true, checkDigit: false, + printInterpretation: true, checkDigit: false, rotation: 'N', })); expect(zpl).toContain('^BCN,100,Y,N,N'); expect(zpl).toContain('^FDABCDEF^FS'); @@ -187,7 +187,7 @@ describe('code128.toZPL', () => { it('emits ^BY when moduleWidth is not 2', () => { const zpl = def.toZPL(makeObj('code128', { content: '123', height: 100, moduleWidth: 5, - printInterpretation: true, checkDigit: false, + printInterpretation: true, checkDigit: false, rotation: 'N', })); expect(zpl).toContain('^BY5'); }); @@ -195,12 +195,33 @@ describe('code128.toZPL', () => { it('always emits ^BY to prevent ZPL state leaking to subsequent barcodes', () => { const zpl = def.toZPL(makeObj('code128', { content: '123', height: 100, moduleWidth: 2, - printInterpretation: true, checkDigit: false, + printInterpretation: true, checkDigit: false, rotation: 'N', })); expect(zpl).toContain('^BY2'); }); }); +// ── rotation ────────────────────────────────────────────────────────────────── + +describe('barcode rotation in ZPL output', () => { + type Rot = 'N' | 'R' | 'I' | 'B'; + it.each<[string, string, Rot, Record]>([ + ['code128', '^BCR,', 'R', { height: 100, moduleWidth: 2, printInterpretation: true, checkDigit: false }], + ['code39', '^B3I,', 'I', { height: 100, moduleWidth: 2, printInterpretation: true, checkDigit: false }], + ['ean13', '^BEB,', 'B', { height: 100, moduleWidth: 2, printInterpretation: true }], + ['qrcode', '^BQR,', 'R', { magnification: 4, errorCorrection: 'Q' }], + ['datamatrix', '^BXI,', 'I', { dimension: 5, quality: 200 }], + ['pdf417', '^B7B,', 'B', { rowHeight: 4, securityLevel: 0, columns: 0, moduleWidth: 2 }], + ['aztec', '^B0R,', 'R', { magnification: 4, ecLevel: 0 }], + ['codabar', '^BKR,', 'R', { height: 100, moduleWidth: 2, printInterpretation: true, checkDigit: false }], + ])('%s emits orientation in command param', (type, expected, rotation, baseProps) => { + const def = defined(ObjectRegistry[type]); + const content = type === 'ean13' ? '590123412345' : 'X'; + const zpl = def.toZPL(makeObj(type, { content, ...baseProps, rotation })); + expect(zpl).toContain(expected); + }); +}); + // ── code39 ──────────────────────────────────────────────────────────────────── describe('code39.toZPL', () => { @@ -209,7 +230,7 @@ describe('code39.toZPL', () => { it('emits ^B3 barcode command', () => { const zpl = def.toZPL(makeObj('code39', { content: 'ABC', height: 100, moduleWidth: 2, - printInterpretation: true, checkDigit: false, + printInterpretation: true, checkDigit: false, rotation: 'N', })); expect(zpl).toContain('^B3'); expect(zpl).toContain('^FDABC^FS'); @@ -223,7 +244,7 @@ describe('qrcode.toZPL', () => { it('emits ^BQ with magnification and ^FD with error correction prefix', () => { const zpl = def.toZPL(makeObj('qrcode', { - content: 'https://example.com', magnification: 6, errorCorrection: 'Q', + content: 'https://example.com', magnification: 6, errorCorrection: 'Q', rotation: 'N', })); expect(zpl).toContain('^BQN,2,6'); expect(zpl).toContain('^FDQA,https://example.com^FS'); @@ -234,7 +255,7 @@ describe('qrcode.normalizeChanges', () => { const def = defined(ObjectRegistry['qrcode']); const normalize = defined(def.normalizeChanges); const baseObj = makeObj('qrcode', { - content: 'x', magnification: 4, errorCorrection: 'Q', + content: 'x', magnification: 4, errorCorrection: 'Q', rotation: 'N', }); it('clamps negative y to 0 for ^FO', () => { @@ -275,7 +296,7 @@ describe('datamatrix.toZPL', () => { it('emits ^BX with dimension and quality', () => { const zpl = def.toZPL(makeObj('datamatrix', { - content: 'DM123', dimension: 8, quality: 200, + content: 'DM123', dimension: 8, quality: 200, rotation: 'N', })); expect(zpl).toContain('^BXN,8,200'); expect(zpl).toContain('^FDDM123^FS'); @@ -289,7 +310,7 @@ describe('pdf417.toZPL', () => { it('emits ^B7 with row height, security, and columns', () => { const zpl = def.toZPL(makeObj('pdf417', { - content: 'PDF', rowHeight: 10, securityLevel: 2, columns: 4, moduleWidth: 2, + content: 'PDF', rowHeight: 10, securityLevel: 2, columns: 4, moduleWidth: 2, rotation: 'N', })); expect(zpl).toContain('^B7N,10,2,4,,,'); expect(zpl).toContain('^FDPDF^FS'); @@ -297,7 +318,7 @@ describe('pdf417.toZPL', () => { it('emits ^BY when moduleWidth is not 2', () => { const zpl = def.toZPL(makeObj('pdf417', { - content: 'X', rowHeight: 10, securityLevel: 0, columns: 0, moduleWidth: 3, + content: 'X', rowHeight: 10, securityLevel: 0, columns: 0, moduleWidth: 3, rotation: 'N', })); expect(zpl).toContain('^BY3'); }); diff --git a/src/registry/rotation.test.ts b/src/registry/rotation.test.ts new file mode 100644 index 00000000..ad5ad977 --- /dev/null +++ b/src/registry/rotation.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { isZplRotation, objectRotation, ZPL_ROTATIONS } from "./rotation"; + +describe("isZplRotation", () => { + it("accepts the four ZPL letters", () => { + for (const r of ZPL_ROTATIONS) { + expect(isZplRotation(r)).toBe(true); + } + }); + + it("rejects bwip-js's L (not a ZPL letter) and other strings", () => { + expect(isZplRotation("L")).toBe(false); + expect(isZplRotation("n")).toBe(false); + expect(isZplRotation("")).toBe(false); + expect(isZplRotation("RR")).toBe(false); + }); +}); + +describe("objectRotation", () => { + it("returns the rotation when valid", () => { + expect(objectRotation({ rotation: "R" })).toBe("R"); + expect(objectRotation({ rotation: "B" })).toBe("B"); + }); + + it("falls back to N when missing or invalid", () => { + expect(objectRotation({})).toBe("N"); + expect(objectRotation({ rotation: undefined })).toBe("N"); + expect(objectRotation({ rotation: "L" })).toBe("N"); + expect(objectRotation({ rotation: "garbage" })).toBe("N"); + }); +}); diff --git a/src/registry/rotation.ts b/src/registry/rotation.ts new file mode 100644 index 00000000..095bf396 --- /dev/null +++ b/src/registry/rotation.ts @@ -0,0 +1,22 @@ +/** + * ZPL field orientation. The single letter that follows a barcode/text + * command in ZPL: N (normal, 0°), R (rotated 90° CW), I (inverted 180°), + * B (bottom-up 270°). + */ +export type ZplRotation = 'N' | 'R' | 'I' | 'B'; + +export const ZPL_ROTATIONS: readonly ZplRotation[] = ['N', 'R', 'I', 'B'] as const; + +export function isZplRotation(value: string): value is ZplRotation { + return value === 'N' || value === 'R' || value === 'I' || value === 'B'; +} + +/** + * Extract `rotation` from an object's props, falling back to `'N'`. Centralises + * the default so consumers in different layers (bwip-js opts, canvas overlay + * gating) cannot drift apart. + */ +export function objectRotation(props: object): ZplRotation { + const r = (props as { rotation?: string }).rotation; + return r !== undefined && isZplRotation(r) ? r : 'N'; +} diff --git a/src/registry/standard2of5.tsx b/src/registry/standard2of5.tsx index 67b95c12..2ed46b7b 100644 --- a/src/registry/standard2of5.tsx +++ b/src/registry/standard2of5.tsx @@ -11,6 +11,6 @@ export const standard2of5 = createBarcode1D({ contentSpec: { charset: '0-9' }, zplCommand: (p) => { const interp = p.printInterpretation ? "Y" : "N"; - return `^BJN,${p.height},${interp},N`; + return `^BJ${p.rotation},${p.height},${interp},N`; }, }); diff --git a/src/registry/upca.tsx b/src/registry/upca.tsx index fd6c7433..6b9f7674 100644 --- a/src/registry/upca.tsx +++ b/src/registry/upca.tsx @@ -11,6 +11,6 @@ export const upca = createBarcode1D({ contentSpec: { charset: '0-9', maxLength: 11 }, zplCommand: (p) => { const interp = p.printInterpretation ? 'Y' : 'N'; - return `^BUN,${p.height},${interp},N,N`; + return `^BU${p.rotation},${p.height},${interp},N,N`; }, }); diff --git a/src/registry/upce.tsx b/src/registry/upce.tsx index f1ea270b..2ec63868 100644 --- a/src/registry/upce.tsx +++ b/src/registry/upce.tsx @@ -11,6 +11,6 @@ export const upce = createBarcode1D({ contentSpec: { charset: '0-9', maxLength: 6 }, zplCommand: (p) => { const interp = p.printInterpretation ? 'Y' : 'N'; - return `^B9N,${p.height},${interp},N`; + return `^B9${p.rotation},${p.height},${interp},N`; }, }); diff --git a/src/test/labelarySync.test.ts b/src/test/labelarySync.test.ts index 4a9e03d9..5cac8bfb 100644 --- a/src/test/labelarySync.test.ts +++ b/src/test/labelarySync.test.ts @@ -8,6 +8,7 @@ import { } from "../components/Canvas/bwipHelpers"; import { EAN_TEXT_ZONE_DOTS } from "../components/Canvas/bwipConstants"; import { ObjectRegistry } from "../registry"; +import { objectRotation } from "../registry/rotation"; import { defined } from "./helpers"; import { testModels } from "./testModels"; @@ -86,6 +87,12 @@ describe("Labelary Sync - Canvas Dimension Logic", () => { console.log(`[DEBUG] expectedBounds:`, tc.expected_bounds); } + // For 90°/270° rotated symbols the visible W and H are swapped, so the + // upright-shape assertions on bar height / module direction stop applying. + const rotation = objectRotation(obj.props); + const isQuarterRotated = rotation === "R" || rotation === "B"; + const isEanUpc = ["ean13", "ean8", "upca", "upce"].includes(obj.type); + // Verify visual position (top-left of the rendered bounding box in dots). // This mimics the positioning logic in BarcodeObject.tsx. const visualX = obj.x; @@ -106,13 +113,18 @@ describe("Labelary Sync - Canvas Dimension Logic", () => { } } - expect(visualX).toBe(tc.expected_bounds.x); + // EAN/UPC have extended guard bars whose visible extent rotates with the + // symbol. Under R rotation those guards sit LEFT of the FO anchor, so + // the bbox.x is below obj.x. The model still holds obj.x as FO, so the + // strict x-equality check is dropped for rotated EAN/UPC. + if (!(isEanUpc && isQuarterRotated)) { + expect(visualX).toBe(tc.expected_bounds.x); + } expect(visualY).toBeCloseTo(tc.expected_bounds.y, 0); expect(displaySize.w).toBeGreaterThan(0); expect(displaySize.h).toBeGreaterThan(0); - const isEanUpc = ["ean13", "ean8", "upca", "upce"].includes(obj.type); const is1DCode = [ "code128", "code39", @@ -136,16 +148,18 @@ describe("Labelary Sync - Canvas Dimension Logic", () => { "plessey", // different bar encoding algorithm ].includes(obj.type); - if (isEanUpc) { + if (isEanUpc && !isQuarterRotated) { // Known discrepancy: Labelary reserves barHeight + EAN_TEXT_ZONE_DOTS (13 dots) // even with printInterpretation=N. getDisplaySize intentionally returns only the // bar height because the text zone is blank whitespace — bwip does not render it. // expected_bounds.height in fixtures reflects the true Labelary value (barHeight+13). + // Under quarter rotation the text zone rotates onto the horizontal axis, so the + // bbox height already equals the bar length; the subtraction would be wrong. expect(displaySize.h * 8).toBeCloseTo( tc.expected_bounds.height - EAN_TEXT_ZONE_DOTS, 1, ); - } else if (is1DCode) { + } else if (is1DCode && !isQuarterRotated) { expect(displaySize.h).toBe( (obj.props as { height: number }).height / 8, ); @@ -170,7 +184,14 @@ describe("Labelary Sync - Canvas Dimension Logic", () => { // hasBwipSizeMismatch — bwip-natural size diverges from Labelary (see above). // EAN/UPC and logmars heights are excluded — see isEanUpc/hasLogmarsTextZone above. if (obj.type !== "codablock" && !hasBwipSizeMismatch) { - expect(displaySize.w * 8).toBeCloseTo(tc.expected_bounds.width, 1); + // Quarter-rotated EAN/UPC moves the EAN_TEXT_ZONE_DOTS guard extension + // onto the width axis instead of the height axis, mirroring the upright + // height adjustment. + const widthAdjust = isEanUpc && isQuarterRotated ? EAN_TEXT_ZONE_DOTS : 0; + expect(displaySize.w * 8).toBeCloseTo( + tc.expected_bounds.width - widthAdjust, + 1, + ); if (!isEanUpc && !hasLogmarsTextZone) { expect(displaySize.h * 8).toBeCloseTo(tc.expected_bounds.height, 1); } diff --git a/src/test/testModels.ts b/src/test/testModels.ts index 47542581..06e94971 100644 --- a/src/test/testModels.ts +++ b/src/test/testModels.ts @@ -13,6 +13,7 @@ export const testModels: Record = { moduleWidth: 2, printInterpretation: false, checkDigit: false, + rotation: "N", }, }, barcode_code128_small_no_text: { @@ -27,6 +28,7 @@ export const testModels: Record = { moduleWidth: 1, printInterpretation: false, checkDigit: false, + rotation: "N", }, }, barcode_code128_large_check_digit: { @@ -41,6 +43,7 @@ export const testModels: Record = { moduleWidth: 3, printInterpretation: false, checkDigit: true, + rotation: "N", }, }, barcode_qr_standard: { @@ -49,7 +52,7 @@ export const testModels: Record = { x: 50, y: 50, rotation: 0, - props: { content: "Hello World", magnification: 4, errorCorrection: "Q" }, + props: { content: "Hello World", magnification: 4, errorCorrection: "Q", rotation: "N" }, }, barcode_qr_large_high_ec: { id: "5", @@ -61,6 +64,7 @@ export const testModels: Record = { content: "Zebra Print Lab QR Code Testing", magnification: 8, errorCorrection: "H", + rotation: "N", }, }, barcode_ean13_standard: { @@ -74,6 +78,7 @@ export const testModels: Record = { height: 100, moduleWidth: 2, printInterpretation: false, + rotation: "N", }, }, barcode_datamatrix_standard: { @@ -82,7 +87,7 @@ export const testModels: Record = { x: 50, y: 50, rotation: 0, - props: { content: "DataMatrixTest", dimension: 5, quality: 200 }, + props: { content: "DataMatrixTest", dimension: 5, quality: 200, rotation: "N" }, }, barcode_code39_standard: { id: "8", @@ -96,6 +101,7 @@ export const testModels: Record = { moduleWidth: 2, printInterpretation: false, checkDigit: false, + rotation: "N", }, }, barcode_pdf417_standard: { @@ -110,6 +116,7 @@ export const testModels: Record = { securityLevel: 1, columns: 4, moduleWidth: 2, + rotation: "N", }, }, barcode_upca_standard: { @@ -124,6 +131,7 @@ export const testModels: Record = { moduleWidth: 2, printInterpretation: false, checkDigit: false, + rotation: "N", }, }, barcode_ean8_standard: { @@ -138,6 +146,7 @@ export const testModels: Record = { moduleWidth: 2, printInterpretation: false, checkDigit: false, + rotation: "N", }, }, barcode_aztec_standard: { @@ -146,7 +155,7 @@ export const testModels: Record = { x: 50, y: 50, rotation: 0, - props: { content: "Aztec123", magnification: 4, ecLevel: 0 }, + props: { content: "Aztec123", magnification: 4, ecLevel: 0, rotation: "N" }, }, barcode_interleaved2of5_standard: { id: "13", @@ -160,6 +169,7 @@ export const testModels: Record = { moduleWidth: 2, printInterpretation: false, checkDigit: false, + rotation: "N", }, }, barcode_micropdf417_standard: { @@ -173,6 +183,7 @@ export const testModels: Record = { moduleWidth: 2, rowHeight: 2, mode: 0, + rotation: "N", }, }, barcode_codablock_standard: { @@ -186,6 +197,7 @@ export const testModels: Record = { moduleWidth: 2, rowHeight: 2, securityLevel: "Y", + rotation: "N", }, }, barcode_pdf417_auto: { @@ -200,6 +212,7 @@ export const testModels: Record = { securityLevel: 1, columns: 0, moduleWidth: 2, + rotation: "N", }, }, barcode_pdf417_auto_ecc: { @@ -214,6 +227,7 @@ export const testModels: Record = { securityLevel: 0, columns: 0, moduleWidth: 2, + rotation: "N", }, }, barcode_code93_standard: { @@ -222,7 +236,7 @@ export const testModels: Record = { x: 50, y: 50, rotation: 0, - props: { content: "CODE93", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false }, + props: { content: "CODE93", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false, rotation: "N" }, }, barcode_code11_standard: { id: "19", @@ -230,7 +244,7 @@ export const testModels: Record = { x: 50, y: 50, rotation: 0, - props: { content: "12345", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false }, + props: { content: "12345", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false, rotation: "N" }, }, barcode_industrial2of5_standard: { id: "20", @@ -238,7 +252,7 @@ export const testModels: Record = { x: 50, y: 50, rotation: 0, - props: { content: "12345678", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false }, + props: { content: "12345678", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false, rotation: "N" }, }, barcode_standard2of5_standard: { id: "21", @@ -246,7 +260,7 @@ export const testModels: Record = { x: 50, y: 50, rotation: 0, - props: { content: "12345678", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false }, + props: { content: "12345678", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false, rotation: "N" }, }, barcode_codabar_standard: { id: "22", @@ -254,7 +268,7 @@ export const testModels: Record = { x: 50, y: 50, rotation: 0, - props: { content: "A12345A", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false }, + props: { content: "A12345A", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false, rotation: "N" }, }, barcode_logmars_standard: { id: "23", @@ -262,7 +276,7 @@ export const testModels: Record = { x: 50, y: 50, rotation: 0, - props: { content: "LOGMARS1", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false }, + props: { content: "LOGMARS1", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false, rotation: "N" }, }, barcode_logmars_with_text: { id: "23b", @@ -270,7 +284,7 @@ export const testModels: Record = { x: 50, y: 50, rotation: 0, - props: { content: "LOGMARS1", height: 100, moduleWidth: 2, printInterpretation: true, checkDigit: false }, + props: { content: "LOGMARS1", height: 100, moduleWidth: 2, printInterpretation: true, checkDigit: false, rotation: "N" }, }, barcode_msi_standard: { id: "24", @@ -278,7 +292,7 @@ export const testModels: Record = { x: 50, y: 50, rotation: 0, - props: { content: "12345678", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false }, + props: { content: "12345678", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false, rotation: "N" }, }, barcode_plessey_standard: { id: "25", @@ -286,7 +300,7 @@ export const testModels: Record = { x: 50, y: 50, rotation: 0, - props: { content: "12345678", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false }, + props: { content: "12345678", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false, rotation: "N" }, }, barcode_planet_standard: { id: "26", @@ -294,7 +308,7 @@ export const testModels: Record = { x: 50, y: 50, rotation: 0, - props: { content: "12345678901", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false }, + props: { content: "12345678901", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false, rotation: "N" }, }, barcode_postal_standard: { id: "27", @@ -302,7 +316,7 @@ export const testModels: Record = { x: 50, y: 50, rotation: 0, - props: { content: "12345", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false }, + props: { content: "12345", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false, rotation: "N" }, }, barcode_gs1databar_standard: { id: "28", @@ -310,7 +324,7 @@ export const testModels: Record = { x: 50, y: 50, rotation: 0, - props: { content: "0112345678901", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false }, + props: { content: "0112345678901", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false, rotation: "N" }, }, barcode_upce_standard: { id: "29", @@ -318,6 +332,80 @@ export const testModels: Record = { x: 50, y: 50, rotation: 0, - props: { content: "012345", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false }, + props: { content: "012345", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false, rotation: "N" }, + }, + + // ── Rotation coverage ─────────────────────────────────────────────────── + barcode_code128_rot_R: { + id: "rot1", + type: "code128", + x: 100, + y: 100, + rotation: 0, + props: { content: "123456", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false, rotation: "R" }, + }, + barcode_code128_rot_I: { + id: "rot2", + type: "code128", + x: 100, + y: 100, + rotation: 0, + props: { content: "123456", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false, rotation: "I" }, + }, + barcode_code128_rot_B: { + id: "rot3", + type: "code128", + x: 100, + y: 100, + rotation: 0, + props: { content: "123456", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false, rotation: "B" }, + }, + barcode_qr_rot_R: { + id: "rot4", + type: "qrcode", + x: 100, + y: 100, + rotation: 0, + props: { content: "Hello World", magnification: 4, errorCorrection: "Q", rotation: "R" }, + }, + barcode_datamatrix_rot_R: { + id: "rot5", + type: "datamatrix", + x: 100, + y: 100, + rotation: 0, + props: { content: "DataMatrixTest", dimension: 5, quality: 200, rotation: "R" }, + }, + barcode_code39_rot_R: { + id: "rot6", + type: "code39", + x: 100, + y: 100, + rotation: 0, + props: { content: "CODE39", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false, rotation: "R" }, + }, + barcode_code39_rot_B: { + id: "rot7", + type: "code39", + x: 100, + y: 100, + rotation: 0, + props: { content: "CODE39", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false, rotation: "B" }, + }, + barcode_ean13_rot_R: { + id: "rot8", + type: "ean13", + x: 100, + y: 100, + rotation: 0, + props: { content: "123456789012", height: 100, moduleWidth: 2, printInterpretation: false, rotation: "R" }, + }, + barcode_ean13_rot_B: { + id: "rot9", + type: "ean13", + x: 100, + y: 100, + rotation: 0, + props: { content: "123456789012", height: 100, moduleWidth: 2, printInterpretation: false, rotation: "B" }, }, }; diff --git a/src/test/visualRegression.test.ts b/src/test/visualRegression.test.ts index 6a93853d..397c0ee9 100644 --- a/src/test/visualRegression.test.ts +++ b/src/test/visualRegression.test.ts @@ -58,6 +58,13 @@ describe("Visual Regression - bwip-js vs Labelary", () => { "barcode_code11_standard", // bwip-js GS1 DataBar stacking/finder-pattern differs from Zebra firmware. "barcode_gs1databar_standard", + // 2D encoder discrepancies between bwip-js and Zebra firmware persist + // through rotation. For QR specifically the rotation appears to shift + // bwip's chosen mask, widening the diff vs. Labelary even though the + // upright QR matches. DataMatrix is the same root cause as the unrotated + // case above. + "barcode_qr_rot_R", + "barcode_datamatrix_rot_R", ]; const testFn = failingTests.includes(tc.id) ? it.skip : it; diff --git a/tests/fixtures/labelary_images/barcode_code128_rot_B.png b/tests/fixtures/labelary_images/barcode_code128_rot_B.png new file mode 100644 index 00000000..69de3bef Binary files /dev/null and b/tests/fixtures/labelary_images/barcode_code128_rot_B.png differ diff --git a/tests/fixtures/labelary_images/barcode_code128_rot_I.png b/tests/fixtures/labelary_images/barcode_code128_rot_I.png new file mode 100644 index 00000000..5d908f30 Binary files /dev/null and b/tests/fixtures/labelary_images/barcode_code128_rot_I.png differ diff --git a/tests/fixtures/labelary_images/barcode_code128_rot_R.png b/tests/fixtures/labelary_images/barcode_code128_rot_R.png new file mode 100644 index 00000000..3242a9ae Binary files /dev/null and b/tests/fixtures/labelary_images/barcode_code128_rot_R.png differ diff --git a/tests/fixtures/labelary_images/barcode_code39_rot_B.png b/tests/fixtures/labelary_images/barcode_code39_rot_B.png new file mode 100644 index 00000000..9f1f80e2 Binary files /dev/null and b/tests/fixtures/labelary_images/barcode_code39_rot_B.png differ diff --git a/tests/fixtures/labelary_images/barcode_code39_rot_R.png b/tests/fixtures/labelary_images/barcode_code39_rot_R.png new file mode 100644 index 00000000..facfef2c Binary files /dev/null and b/tests/fixtures/labelary_images/barcode_code39_rot_R.png differ diff --git a/tests/fixtures/labelary_images/barcode_datamatrix_rot_R.png b/tests/fixtures/labelary_images/barcode_datamatrix_rot_R.png new file mode 100644 index 00000000..4fbeca1a Binary files /dev/null and b/tests/fixtures/labelary_images/barcode_datamatrix_rot_R.png differ diff --git a/tests/fixtures/labelary_images/barcode_ean13_rot_B.png b/tests/fixtures/labelary_images/barcode_ean13_rot_B.png new file mode 100644 index 00000000..477dd1d9 Binary files /dev/null and b/tests/fixtures/labelary_images/barcode_ean13_rot_B.png differ diff --git a/tests/fixtures/labelary_images/barcode_ean13_rot_R.png b/tests/fixtures/labelary_images/barcode_ean13_rot_R.png new file mode 100644 index 00000000..c4018732 Binary files /dev/null and b/tests/fixtures/labelary_images/barcode_ean13_rot_R.png differ diff --git a/tests/fixtures/labelary_images/barcode_qr_rot_R.png b/tests/fixtures/labelary_images/barcode_qr_rot_R.png new file mode 100644 index 00000000..7518e6da Binary files /dev/null and b/tests/fixtures/labelary_images/barcode_qr_rot_R.png differ diff --git a/tests/fixtures/labelary_images/fixtures.json b/tests/fixtures/labelary_images/fixtures.json index 775d5441..391de5fe 100644 --- a/tests/fixtures/labelary_images/fixtures.json +++ b/tests/fixtures/labelary_images/fixtures.json @@ -190,80 +190,244 @@ { "id": "barcode_code93_standard", "zpl_input": "^XA^BY2^FO50,50^BAN,100,N,N,N^FDCODE93^FS^XZ", - "expected_bounds": { "x": 50, "y": 50, "width": 182, "height": 100 }, + "expected_bounds": { + "x": 50, + "y": 50, + "width": 182, + "height": 100 + }, "image_ref": "barcode_code93_standard.png" }, { "id": "barcode_code11_standard", "zpl_input": "^XA^BY2^FO50,50^B1N,N,100,N,N^FD12345^FS^XZ", - "expected_bounds": { "x": 50, "y": 50, "width": 178, "height": 100 }, + "expected_bounds": { + "x": 50, + "y": 50, + "width": 178, + "height": 100 + }, "image_ref": "barcode_code11_standard.png" }, { "id": "barcode_industrial2of5_standard", "zpl_input": "^XA^BY2^FO50,50^BIN,100,N,N^FD12345678^FS^XZ", - "expected_bounds": { "x": 50, "y": 50, "width": 262, "height": 100 }, + "expected_bounds": { + "x": 50, + "y": 50, + "width": 262, + "height": 100 + }, "image_ref": "barcode_industrial2of5_standard.png" }, { "id": "barcode_standard2of5_standard", "zpl_input": "^XA^BY2^FO50,50^BJN,100,N,N^FD12345678^FS^XZ", - "expected_bounds": { "x": 50, "y": 50, "width": 242, "height": 100 }, + "expected_bounds": { + "x": 50, + "y": 50, + "width": 242, + "height": 100 + }, "image_ref": "barcode_standard2of5_standard.png" }, { "id": "barcode_codabar_standard", "zpl_input": "^XA^BY2^FO50,50^BKN,N,100,N,N^FDA12345A^FS^XZ", - "expected_bounds": { "x": 50, "y": 50, "width": 174, "height": 100 }, + "expected_bounds": { + "x": 50, + "y": 50, + "width": 174, + "height": 100 + }, "image_ref": "barcode_codabar_standard.png" }, { "id": "barcode_logmars_standard", "zpl_input": "^XA^BY2^FO50,50^BLN,100,N^FDLOGMARS1^FS^XZ", - "expected_bounds": { "x": 50, "y": 50, "width": 350, "height": 120 }, + "expected_bounds": { + "x": 50, + "y": 50, + "width": 350, + "height": 120 + }, "image_ref": "barcode_logmars_standard.png" }, { "id": "barcode_logmars_with_text", "zpl_input": "^XA^BY2^FO50,50^BLN,100,Y^FDLOGMARS1^FS^XZ", - "expected_bounds": { "x": 50, "y": 50, "width": 350, "height": 120 }, + "expected_bounds": { + "x": 50, + "y": 50, + "width": 350, + "height": 120 + }, "image_ref": "barcode_logmars_with_text.png" }, { "id": "barcode_msi_standard", "zpl_input": "^XA^BY2,2^FO50,50^BMN,N,100,N,N^FD12345678^FS^XZ", - "expected_bounds": { "x": 50, "y": 50, "width": 230, "height": 100 }, + "expected_bounds": { + "x": 50, + "y": 50, + "width": 230, + "height": 100 + }, "image_ref": "barcode_msi_standard.png" }, { "id": "barcode_plessey_standard", "zpl_input": "^XA^BY2,2^FO50,50^BPN,N,100,N,N^FD12345678^FS^XZ", - "expected_bounds": { "x": 50, "y": 50, "width": 294, "height": 100 }, + "expected_bounds": { + "x": 50, + "y": 50, + "width": 294, + "height": 100 + }, "image_ref": "barcode_plessey_standard.png" }, { "id": "barcode_planet_standard", "zpl_input": "^XA^BY2^FO50,50^B5N,100,N,N^FD12345678901^FS^XZ", - "expected_bounds": { "x": 50, "y": 50, "width": 307, "height": 100 }, + "expected_bounds": { + "x": 50, + "y": 50, + "width": 307, + "height": 100 + }, "image_ref": "barcode_planet_standard.png" }, { "id": "barcode_postal_standard", "zpl_input": "^XA^BY2^FO50,50^BZN,100,N,N^FD12345^FS^XZ", - "expected_bounds": { "x": 50, "y": 50, "width": 157, "height": 100 }, + "expected_bounds": { + "x": 50, + "y": 50, + "width": 157, + "height": 100 + }, "image_ref": "barcode_postal_standard.png" }, { "id": "barcode_gs1databar_standard", "zpl_input": "^XA^BY2^FO50,50^BRN,1,2,2,100,1^FD0112345678901^FS^XZ", - "expected_bounds": { "x": 50, "y": 50, "width": 192, "height": 66 }, + "expected_bounds": { + "x": 50, + "y": 50, + "width": 192, + "height": 66 + }, "image_ref": "barcode_gs1databar_standard.png" }, { "id": "barcode_upce_standard", "zpl_input": "^XA^BY2^FO50,50^B9N,100,N,N^FD012345^FS^XZ", - "expected_bounds": { "x": 50, "y": 50, "width": 102, "height": 113 }, + "expected_bounds": { + "x": 50, + "y": 50, + "width": 102, + "height": 113 + }, "image_ref": "barcode_upce_standard.png" + }, + { + "id": "barcode_code128_rot_R", + "zpl_input": "^XA^BY2^FO100,100^BCR,100,N,N,N^FD123456^FS^XZ", + "expected_bounds": { + "x": 100, + "y": 100, + "width": 100, + "height": 202 + }, + "image_ref": "barcode_code128_rot_R.png" + }, + { + "id": "barcode_code128_rot_I", + "zpl_input": "^XA^BY2^FO100,100^BCI,100,N,N,N^FD123456^FS^XZ", + "expected_bounds": { + "x": 100, + "y": 100, + "width": 202, + "height": 100 + }, + "image_ref": "barcode_code128_rot_I.png" + }, + { + "id": "barcode_code128_rot_B", + "zpl_input": "^XA^BY2^FO100,100^BCB,100,N,N,N^FD123456^FS^XZ", + "expected_bounds": { + "x": 100, + "y": 100, + "width": 100, + "height": 202 + }, + "image_ref": "barcode_code128_rot_B.png" + }, + { + "id": "barcode_qr_rot_R", + "zpl_input": "^XA^FO100,100^BQR,2,4^FDQA,Hello World^FS^XZ", + "expected_bounds": { + "x": 100, + "y": 110, + "width": 84, + "height": 84 + }, + "image_ref": "barcode_qr_rot_R.png" + }, + { + "id": "barcode_datamatrix_rot_R", + "zpl_input": "^XA^FO100,100^BXR,5,200^FDDataMatrixTest^FS^XZ", + "expected_bounds": { + "x": 100, + "y": 100, + "width": 90, + "height": 90 + }, + "image_ref": "barcode_datamatrix_rot_R.png" + }, + { + "id": "barcode_code39_rot_R", + "zpl_input": "^XA^BY2^FO100,100^B3R,N,100,N,N^FDCODE39^FS^XZ", + "expected_bounds": { + "x": 100, + "y": 100, + "width": 100, + "height": 254 + }, + "image_ref": "barcode_code39_rot_R.png" + }, + { + "id": "barcode_code39_rot_B", + "zpl_input": "^XA^BY2^FO100,100^B3B,N,100,N,N^FDCODE39^FS^XZ", + "expected_bounds": { + "x": 100, + "y": 100, + "width": 100, + "height": 254 + }, + "image_ref": "barcode_code39_rot_B.png" + }, + { + "id": "barcode_ean13_rot_R", + "zpl_input": "^XA^BY2^FO100,100^BER,100,N,N^FD123456789012^FS^XZ", + "expected_bounds": { + "x": 87, + "y": 100, + "width": 113, + "height": 190 + }, + "image_ref": "barcode_ean13_rot_R.png" + }, + { + "id": "barcode_ean13_rot_B", + "zpl_input": "^XA^BY2^FO100,100^BEB,100,N,N^FD123456789012^FS^XZ", + "expected_bounds": { + "x": 100, + "y": 100, + "width": 113, + "height": 190 + }, + "image_ref": "barcode_ean13_rot_B.png" } ] } \ No newline at end of file diff --git a/tests/fixtures/testCases.ts b/tests/fixtures/testCases.ts index 03f4963b..a88e99b2 100644 --- a/tests/fixtures/testCases.ts +++ b/tests/fixtures/testCases.ts @@ -194,4 +194,69 @@ export const testCases: TestCase[] = [ expected_bounds: { x: 50, y: 50, width: 102, height: 113 }, image_ref: "barcode_upce_standard.png", }, + + // ── Rotation coverage ───────────────────────────────────────────────────── + // Bounds measured from the Labelary PNG via tests/scripts/measure_bbox.mjs. + // R/B swap width and height of the unrotated symbol; the QR +10 dot Y offset + // applies to rotated QR codes too. + { + id: "barcode_code128_rot_R", + zpl_input: "^XA^BY2^FO100,100^BCR,100,N,N,N^FD123456^FS^XZ", + expected_bounds: { x: 100, y: 100, width: 100, height: 202 }, + image_ref: "barcode_code128_rot_R.png", + }, + { + id: "barcode_code128_rot_I", + zpl_input: "^XA^BY2^FO100,100^BCI,100,N,N,N^FD123456^FS^XZ", + expected_bounds: { x: 100, y: 100, width: 202, height: 100 }, + image_ref: "barcode_code128_rot_I.png", + }, + { + id: "barcode_code128_rot_B", + zpl_input: "^XA^BY2^FO100,100^BCB,100,N,N,N^FD123456^FS^XZ", + expected_bounds: { x: 100, y: 100, width: 100, height: 202 }, + image_ref: "barcode_code128_rot_B.png", + }, + { + id: "barcode_qr_rot_R", + zpl_input: "^XA^FO100,100^BQR,2,4^FDQA,Hello World^FS^XZ", + expected_bounds: { x: 100, y: 110, width: 84, height: 84 }, + image_ref: "barcode_qr_rot_R.png", + }, + { + id: "barcode_datamatrix_rot_R", + zpl_input: "^XA^FO100,100^BXR,5,200^FDDataMatrixTest^FS^XZ", + expected_bounds: { x: 100, y: 100, width: 90, height: 90 }, + image_ref: "barcode_datamatrix_rot_R.png", + }, + // Code39 (^B3) and EAN13 (^BE) use different param orders than Code128's + // ^BC, so cover them too. Bounds populated via measure_bbox.mjs. + { + id: "barcode_code39_rot_R", + zpl_input: "^XA^BY2^FO100,100^B3R,N,100,N,N^FDCODE39^FS^XZ", + expected_bounds: { x: 100, y: 100, width: 100, height: 254 }, + image_ref: "barcode_code39_rot_R.png", + }, + { + id: "barcode_code39_rot_B", + zpl_input: "^XA^BY2^FO100,100^B3B,N,100,N,N^FDCODE39^FS^XZ", + expected_bounds: { x: 100, y: 100, width: 100, height: 254 }, + image_ref: "barcode_code39_rot_B.png", + }, + // EAN13 has extended guard bars that extend past the bar-height baseline. + // After R rotation those guards sit LEFT of the FO anchor (ink at x=87 with + // FO=100), so the bbox starts to the left of obj.x. The B rotation keeps the + // ink within the FO-anchored corner. + { + id: "barcode_ean13_rot_R", + zpl_input: "^XA^BY2^FO100,100^BER,100,N,N^FD123456789012^FS^XZ", + expected_bounds: { x: 87, y: 100, width: 113, height: 190 }, + image_ref: "barcode_ean13_rot_R.png", + }, + { + id: "barcode_ean13_rot_B", + zpl_input: "^XA^BY2^FO100,100^BEB,100,N,N^FD123456789012^FS^XZ", + expected_bounds: { x: 100, y: 100, width: 113, height: 190 }, + image_ref: "barcode_ean13_rot_B.png", + }, ]; diff --git a/tests/scripts/fetch_labelary_fixtures.ts b/tests/scripts/fetch_labelary_fixtures.ts index c3bfbf0f..8b8bcb13 100644 --- a/tests/scripts/fetch_labelary_fixtures.ts +++ b/tests/scripts/fetch_labelary_fixtures.ts @@ -7,6 +7,10 @@ const FIXTURES_DIR = path.resolve( "tests/fixtures/labelary_images", ); +interface FixtureMapping { + test_cases: typeof testCases; +} + async function fetchLabelaryImage(zpl: string): Promise { // Use 8dpmm (203 dpi) and 4x4 inches as standard canvas dimensions const url = "http://api.labelary.com/v1/printers/8dpmm/labels/4x4/0/"; @@ -35,10 +39,24 @@ async function main() { fs.mkdirSync(FIXTURES_DIR, { recursive: true }); const mappingFile = path.join(FIXTURES_DIR, "fixtures.json"); - const mappingData = { test_cases: testCases }; - - console.log("Writing mapping JSON (fixtures.json)..."); - fs.writeFileSync(mappingFile, JSON.stringify(mappingData, null, 2), "utf8"); + // fixtures.json is the source of truth for Labelary-measured bounds. Only + // append entries for new test cases — never overwrite existing ones, since + // testCases.ts may carry rounded/placeholder bounds that have been refined + // by hand or via tests/scripts/measure_bbox.mjs. + const existing: FixtureMapping = fs.existsSync(mappingFile) + ? JSON.parse(fs.readFileSync(mappingFile, "utf8")) + : { test_cases: [] }; + const knownIds = new Set(existing.test_cases.map((c) => c.id)); + const additions = testCases.filter((c) => !knownIds.has(c.id)); + if (additions.length > 0) { + const merged = { test_cases: [...existing.test_cases, ...additions] }; + console.log( + `Adding ${additions.length} new entr${additions.length === 1 ? "y" : "ies"} to fixtures.json...`, + ); + fs.writeFileSync(mappingFile, JSON.stringify(merged, null, 2), "utf8"); + } else { + console.log("fixtures.json already covers every test case."); + } console.log("Fetching images from Labelary API..."); for (const tc of testCases) { diff --git a/tests/scripts/measure_bbox.mjs b/tests/scripts/measure_bbox.mjs new file mode 100644 index 00000000..ab78fb46 --- /dev/null +++ b/tests/scripts/measure_bbox.mjs @@ -0,0 +1,24 @@ +import * as fs from 'fs'; +import { PNG } from 'pngjs'; +import * as path from 'path'; + +const FIXTURES_DIR = 'tests/fixtures/labelary_images'; +const ids = process.argv.slice(2); + +for (const id of ids) { + const png = PNG.sync.read(fs.readFileSync(path.join(FIXTURES_DIR, `${id}.png`))); + let minX = png.width, minY = png.height, maxX = 0, maxY = 0; + for (let y = 0; y < png.height; y++) { + for (let x = 0; x < png.width; x++) { + const i = (png.width * y + x) << 2; + const r = png.data[i], g = png.data[i+1], b = png.data[i+2]; + if ((r + g + b) / 3 < 128) { + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + } + } + } + console.log(JSON.stringify({ id, x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 })); +}