Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
db49d84
feat(registry): add rotation prop to all barcodes (1D + 2D)
u8array May 6, 2026
95f3ada
feat(parser): read rotation from imported barcode commands
u8array May 6, 2026
c960994
feat(canvas): rotate barcodes via bwip-js, gate manual HRI overlays
u8array May 6, 2026
2b3d39b
refactor(rotation): centralise prop access in objectRotation helper
u8array May 6, 2026
9638871
fix(canvas): map ZPL B to bwip L and swap display size for quarter ro…
u8array May 6, 2026
0c5c606
test(rotation): pixel-perfect Labelary fixtures for 1D, skip 2D encod…
u8array May 6, 2026
a548987
refactor(canvas): pass upright dimensions as numbers, reuse objectRot…
u8array May 6, 2026
8934a37
fix(test): merge instead of overwrite fixtures.json in fetch script
u8array May 6, 2026
a4a0351
test(rotation): cover code39 and ean13 rotations against Labelary
u8array May 6, 2026
6b212ad
refactor(test): dedupe EAN/UPC type check, hoist FixtureMapping inter…
u8array May 6, 2026
2170986
test(rotation): unit tests for helpers and bwip option translation
u8array May 6, 2026
1f21d20
fix(canvas): rotated barcode HRI text overlay instead of bwip include…
u8array May 6, 2026
d66a2ac
fix(canvas): correct rotated HRI text anchor math and include EAN/UPC
u8array May 6, 2026
1d5d154
fix(canvas): correct rotated HRI text side and EAN/UPC display string
u8array May 6, 2026
e8af764
fix(canvas): format EAN/UPC rotated HRI with digit grouping and check…
u8array May 6, 2026
a8ab0b3
fix(canvas): position rotated EAN/UPC HRI digits at 1/4 and 3/4 using…
u8array May 6, 2026
0a68115
refactor(barcode): extract upceCheckDigit helper, remove dead EAN/UPC…
u8array May 6, 2026
86d9b39
refactor(barcode): unify Group/KImage wrapper in showRotatedText block
u8array May 6, 2026
8d46ffa
fix(barcode): include UPC-A check digit in right block, not separate …
u8array May 6, 2026
700efaa
fix(barcode): UPC-A HRI shows 5+5 digits, check digit not rendered
u8array May 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 153 additions & 32 deletions src/components/Canvas/BarcodeObject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -320,44 +325,14 @@ export function BarcodeObject({
fill="#000000"
listening={false}
/>,
// check digit — outside-right (like leading digit on left)
<Text
key="dc"
x={w + 2}
y={textY}
width={ldW}
text={allDigits[11]}
fontSize={textFontSize}
fontFamily="'Courier New', monospace"
align="left"
wrap="none"
fill="#000000"
listening={false}
/>,
];
} else if (obj.type === "upce") {
const digits6 = rawContent
.replace(/\D/g, "")
.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;
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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 <Text key={key} x={tx} y={ty} rotation={tRot} width={Math.max(size, 1)} text={text} align="center" {...tStyle} />;
};

// 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 <Text key={key} x={tx} y={ty} rotation={tRot} width={Math.max(ldW, 1)} text={text} align="center" {...tStyle} />;
};

// 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 <Text key={key} x={tx} y={ty} rotation={tRot} width={Math.max(ldW, 1)} text={text} align="left" {...tStyle} />;
};

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 = (
<Text
x={txtX} y={txtY} rotation={tRot} width={Math.max(txtWidth, 1)}
text={displayText} fontSize={textFontSize}
fontFamily="'Courier New', monospace"
align="center" wrap="none" fill="#000000" listening={false}
/>
);
}

return (
<Group
id={obj.id} x={x} y={y} draggable
onClick={(e) => 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}
>
<KImage x={0} y={0} image={barcodeCanvas}
width={Math.max(w, 1)} height={Math.max(h, 1)}
imageSmoothingEnabled={false}
stroke={isSelected ? "#6366f1" : undefined}
strokeWidth={isSelected ? 2 : 0}
strokeScaleEnabled={false}
/>
{textElements}
</Group>
);
}
Comment thread
u8array marked this conversation as resolved.

return (
<KImage
id={obj.id}
Expand Down
60 changes: 59 additions & 1 deletion src/components/Canvas/bwipHelpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import { getEanUpcLayout } from "./bwipHelpers";
import { buildBwipOptions, getDisplaySize, getEanUpcLayout } from "./bwipHelpers";
import type { LabelObject } from "../../registry";

describe("getEanUpcLayout", () => {
// bwip-js native canvas widths (no quiet zones, scale=2):
Expand Down Expand Up @@ -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);
});
});
Loading