Skip to content

Commit ef8cf0c

Browse files
authored
Merge pull request #19 from u8array/feat/barcode-rotation
Feat/barcode rotation
2 parents 9f3cdba + 700efaa commit ef8cf0c

51 files changed

Lines changed: 949 additions & 124 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎src/components/Canvas/BarcodeObject.tsx‎

Lines changed: 153 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ import {
1010
buildBwipOptions,
1111
getDisplaySize,
1212
eanCheckDigit,
13+
upceCheckDigit,
1314
get1DBwipScale,
1415
getEanUpcLayout,
1516
type EanUpcType,
1617
} from "./bwipHelpers";
18+
import { objectRotation } from "../../registry/rotation";
1719
import {
1820
QR_FO_Y_OFFSET_DOTS,
1921
QR_FT_MODULE_OFFSET,
@@ -151,9 +153,12 @@ export function BarcodeObject({
151153
// Force-off when the symbology has no HRI in ZPL (e.g. GS1 Databar) — the
152154
// canvas must match the print output even if a legacy saved object still
153155
// carries printInterpretation: true.
154-
const printInterp =
156+
const rotation = objectRotation(obj.props);
157+
const isUpright = rotation === "N";
158+
const printInterpEnabled =
155159
!ObjectRegistry[obj.type]?.interpretationLocked &&
156160
!!(obj.props as { printInterpretation?: boolean }).printInterpretation;
161+
const printInterp = isUpright && printInterpEnabled;
157162
const moduleWidth =
158163
(obj.props as { moduleWidth?: number }).moduleWidth ?? 2;
159164
const textFontSize = Math.max(dotsToPx(moduleWidth * 10, scale, dpmm), 6);
@@ -320,44 +325,14 @@ export function BarcodeObject({
320325
fill="#000000"
321326
listening={false}
322327
/>,
323-
// check digit — outside-right (like leading digit on left)
324-
<Text
325-
key="dc"
326-
x={w + 2}
327-
y={textY}
328-
width={ldW}
329-
text={allDigits[11]}
330-
fontSize={textFontSize}
331-
fontFamily="'Courier New', monospace"
332-
align="left"
333-
wrap="none"
334-
fill="#000000"
335-
listening={false}
336-
/>,
337328
];
338329
} else if (obj.type === "upce") {
339330
const digits6 = rawContent
340331
.replace(/\D/g, "")
341332
.slice(0, 6)
342333
.padEnd(6, "0");
343334

344-
// Expand UPC-E to 11-digit UPC-A to compute check digit
345-
const vA = digits6[0] ?? "0",
346-
vB = digits6[1] ?? "0",
347-
vC = digits6[2] ?? "0";
348-
const vD = digits6[3] ?? "0",
349-
vE = digits6[4] ?? "0",
350-
vF = digits6[5] ?? "0";
351-
const fi = parseInt(vF, 10);
352-
let expanded11: string;
353-
if (fi <= 2) expanded11 = `0${vA}${vB}${vF}0000${vC}${vD}${vE}`;
354-
else if (fi === 3) expanded11 = `0${vA}${vB}${vC}00000${vD}${vE}`;
355-
else if (fi === 4) expanded11 = `0${vA}${vB}${vC}${vD}00000${vE}`;
356-
else expanded11 = `0${vA}${vB}${vC}${vD}${vE}${vF}0000`;
357-
let ckSum = 0;
358-
for (let i = 0; i < 11; i++)
359-
ckSum += parseInt(expanded11[i] ?? "0", 10) * (i % 2 === 0 ? 3 : 1);
360-
const checkDigit = String((10 - (ckSum % 10)) % 10);
335+
const checkDigit = upceCheckDigit(digits6);
361336

362337
// UPC-E: 6 digits centered over the data area (modules 3–44 of 51)
363338
const { xLeft: xMid, halfWidth: midW } = layout;
@@ -444,6 +419,11 @@ export function BarcodeObject({
444419

445420
// ── Other 1D: separate Konva Text below bars ──────────────────────────
446421
const showText = BARCODE_1D_TYPES.has(obj.type) && printInterp;
422+
// Rotated 1D: text overlay rotated to match the barcode orientation.
423+
const showRotatedText =
424+
!isUpright &&
425+
printInterpEnabled &&
426+
BARCODE_1D_TYPES.has(obj.type);
447427

448428
let displayText = rawContent;
449429
if (obj.type === "code39") {
@@ -543,6 +523,147 @@ export function BarcodeObject({
543523
);
544524
}
545525

526+
// ── Rotated 1D: text overlay rotated alongside the bars ──────────────
527+
if (showRotatedText) {
528+
// Rotation math (Konva y-down, CW positive):
529+
// R (rot=90): local-x→screen-down, local-y→screen-left
530+
// B (rot=-90): local-x→screen-up, local-y→screen-right
531+
// I (rot=180): local-x→screen-left, local-y→screen-up
532+
//
533+
// Text "side" for 90°/270°: standard 1D text is below bars in upright,
534+
// so after 90°CW it's on the LEFT; after 270°CW on the RIGHT.
535+
// LOGMARS is mirrored (text above in upright → right for 90°, left for 270°).
536+
const isTextAbove = obj.type === "logmars";
537+
// x-anchor for R/B (shared by all text nodes for a given rotation)
538+
const sideX =
539+
rotation === "R"
540+
? isTextAbove ? w + textGap + textFontSize : -textGap
541+
: isTextAbove ? -(textGap + textFontSize) : w + textGap;
542+
const tRot = rotation === "R" ? 90 : rotation === "I" ? 180 : -90;
543+
544+
// ── EAN/UPC: reproduce upright digit layout along the rotated axis ──
545+
let textElements: React.ReactNode;
546+
if (EAN_UPC_TYPES.has(obj.type)) {
547+
const bwipSc = get1DBwipScale(moduleWidth, scale, dpmm);
548+
// For I: encoding runs horizontally (canvas.width); for R/B: vertically (canvas.height)
549+
const encDisplay = rotation === "I" ? w : h;
550+
const encCanvas = rotation === "I" ? barcodeCanvas.width : barcodeCanvas.height;
551+
const layout = getEanUpcLayout(obj.type as EanUpcType, encDisplay, encCanvas, bwipSc);
552+
const { xLeft, xRight, halfWidth: halfW } = layout;
553+
const ldW = textFontSize * 1.2;
554+
555+
const tStyle = {
556+
fontSize: textFontSize,
557+
fontFamily: "'Courier New', monospace" as const,
558+
wrap: "none" as const,
559+
fill: "#000000",
560+
listening: false,
561+
};
562+
563+
// Position a text node at `encPos` from barcode start, spanning `size`.
564+
// For R: encPos → screen-y downward from top (start=top).
565+
// For B: encPos → screen-y upward from bottom (start=bottom), anchor = h - encPos.
566+
// For I: encPos → screen-x leftward from right (start=right), anchor-x = w - encPos.
567+
const node = (key: string, encPos: number, size: number, text: string) => {
568+
const tx = rotation === "I" ? w - encPos : sideX;
569+
const ty = rotation === "R" ? encPos : rotation === "B" ? h - encPos : -textGap;
570+
return <Text key={key} x={tx} y={ty} rotation={tRot} width={Math.max(size, 1)} text={text} align="center" {...tStyle} />;
571+
};
572+
573+
// Single digit floated BEFORE barcode start (outside the quiet zone).
574+
const sysNode = (key: string, text: string) => {
575+
// R: above top (y=-ldW); B: below bottom (y=h+ldW); I: right of barcode (x=w+ldW).
576+
const tx = rotation === "I" ? w + ldW : sideX;
577+
const ty = rotation === "R" ? -ldW : rotation === "B" ? h + ldW : -textGap;
578+
return <Text key={key} x={tx} y={ty} rotation={tRot} width={Math.max(ldW, 1)} text={text} align="center" {...tStyle} />;
579+
};
580+
581+
// Single digit floated AFTER barcode end (UPC-A/UPC-E check digit).
582+
const trailNode = (key: string, text: string) => {
583+
// R: below bottom (y≈encDisplay); B: above top (y≈h-encDisplay); I: left of x=0.
584+
const tx = rotation === "I" ? -ldW : sideX;
585+
const ty = rotation === "R" ? encDisplay : rotation === "B" ? h - encDisplay : -textGap;
586+
return <Text key={key} x={tx} y={ty} rotation={tRot} width={Math.max(ldW, 1)} text={text} align="left" {...tStyle} />;
587+
};
588+
589+
if (obj.type === "ean13") {
590+
const d12 = rawContent.replace(/\D/g, "").slice(0, 12).padEnd(12, "0");
591+
const all13 = d12 + eanCheckDigit(d12, 1, 3);
592+
textElements = [
593+
sysNode("sys", all13[0] ?? ""),
594+
node("left", xLeft, halfW, all13.slice(1, 7)),
595+
node("right", xRight, halfW, all13.slice(7, 13)),
596+
];
597+
} else if (obj.type === "ean8") {
598+
const d7 = rawContent.replace(/\D/g, "").slice(0, 7).padEnd(7, "0");
599+
const all8 = d7 + eanCheckDigit(d7, 3, 1);
600+
textElements = [
601+
node("left", xLeft, halfW, all8.slice(0, 4)),
602+
node("right", xRight, halfW, all8.slice(4, 8)),
603+
];
604+
} else if (obj.type === "upca") {
605+
const d11 = rawContent.replace(/\D/g, "").slice(0, 11).padEnd(11, "0");
606+
const all12 = d11 + eanCheckDigit(d11, 3, 1);
607+
textElements = [
608+
sysNode("sys", all12[0] ?? ""),
609+
node("left", xLeft, halfW, all12.slice(1, 6)),
610+
node("right", xRight, halfW, all12.slice(6, 11)),
611+
];
612+
} else if (obj.type === "upce") {
613+
const d6 = rawContent.replace(/\D/g, "").slice(0, 6).padEnd(6, "0");
614+
const ck = upceCheckDigit(d6);
615+
textElements = [
616+
sysNode("sys", "0"),
617+
node("mid", xLeft, halfW, d6),
618+
trailNode("trail", ck),
619+
];
620+
}
621+
} else {
622+
// ── Other 1D: single centered text string ──────────────────────────
623+
let txtX: number;
624+
let txtY: number;
625+
let txtWidth: number;
626+
627+
if (rotation === "R") {
628+
txtX = sideX; txtY = 0; txtWidth = h;
629+
} else if (rotation === "I") {
630+
txtX = w;
631+
txtY = isTextAbove ? h + textGap + textFontSize : -textGap;
632+
txtWidth = w;
633+
} else {
634+
txtX = sideX; txtY = h; txtWidth = h;
635+
}
636+
637+
textElements = (
638+
<Text
639+
x={txtX} y={txtY} rotation={tRot} width={Math.max(txtWidth, 1)}
640+
text={displayText} fontSize={textFontSize}
641+
fontFamily="'Courier New', monospace"
642+
align="center" wrap="none" fill="#000000" listening={false}
643+
/>
644+
);
645+
}
646+
647+
return (
648+
<Group
649+
id={obj.id} x={x} y={y} draggable
650+
onClick={(e) => onSelect(e.evt.shiftKey || e.evt.ctrlKey || e.evt.metaKey)}
651+
onTap={() => onSelect(false)}
652+
onDragMove={(e) => e.target.position(snapPos(e.target.x(), e.target.y()))}
653+
onDragEnd={handleDragEnd}
654+
>
655+
<KImage x={0} y={0} image={barcodeCanvas}
656+
width={Math.max(w, 1)} height={Math.max(h, 1)}
657+
imageSmoothingEnabled={false}
658+
stroke={isSelected ? "#6366f1" : undefined}
659+
strokeWidth={isSelected ? 2 : 0}
660+
strokeScaleEnabled={false}
661+
/>
662+
{textElements}
663+
</Group>
664+
);
665+
}
666+
546667
return (
547668
<KImage
548669
id={obj.id}

‎src/components/Canvas/bwipHelpers.test.ts‎

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect } from "vitest";
2-
import { getEanUpcLayout } from "./bwipHelpers";
2+
import { buildBwipOptions, getDisplaySize, getEanUpcLayout } from "./bwipHelpers";
3+
import type { LabelObject } from "../../registry";
34

45
describe("getEanUpcLayout", () => {
56
// bwip-js native canvas widths (no quiet zones, scale=2):
@@ -67,3 +68,60 @@ describe("getEanUpcLayout", () => {
6768
});
6869
});
6970
});
71+
72+
describe("rotation pipeline", () => {
73+
// Minimal code128 fixture; only the props used by buildBwipOptions/
74+
// getDisplaySize matter for these checks.
75+
const baseCode128 = (rotation: "N" | "R" | "I" | "B"): LabelObject =>
76+
({
77+
id: "1",
78+
type: "code128",
79+
x: 0,
80+
y: 0,
81+
rotation: 0,
82+
props: {
83+
content: "ABC",
84+
height: 100,
85+
moduleWidth: 2,
86+
printInterpretation: false,
87+
checkDigit: false,
88+
rotation,
89+
},
90+
}) as LabelObject;
91+
92+
it("does not set rotate for N", () => {
93+
const opts = buildBwipOptions(baseCode128("N"), 1, 8);
94+
expect(opts?.rotate).toBeUndefined();
95+
});
96+
97+
it("forwards R and I unchanged to bwip-js", () => {
98+
expect(buildBwipOptions(baseCode128("R"), 1, 8)?.rotate).toBe("R");
99+
expect(buildBwipOptions(baseCode128("I"), 1, 8)?.rotate).toBe("I");
100+
});
101+
102+
it("translates ZPL B to bwip L (270° CW)", () => {
103+
expect(buildBwipOptions(baseCode128("B"), 1, 8)?.rotate).toBe("L");
104+
});
105+
106+
it("swaps display W and H for quarter rotations", () => {
107+
// Pretend bwip produced an unrotated 200x100 bitmap.
108+
const fakeCanvas = { width: 200, height: 100 } as HTMLCanvasElement;
109+
const upright = getDisplaySize(baseCode128("N"), fakeCanvas, 1, 8);
110+
// For R/B, bwip's bitmap is post-rotation (100x200). Pass that and check
111+
// the upright dimensions are recovered then re-swapped to visible.
112+
const rotatedCanvas = { width: 100, height: 200 } as HTMLCanvasElement;
113+
const rotR = getDisplaySize(baseCode128("R"), rotatedCanvas, 1, 8);
114+
const rotB = getDisplaySize(baseCode128("B"), rotatedCanvas, 1, 8);
115+
expect(rotR.w).toBe(upright.h);
116+
expect(rotR.h).toBe(upright.w);
117+
expect(rotB.w).toBe(upright.h);
118+
expect(rotB.h).toBe(upright.w);
119+
});
120+
121+
it("leaves dimensions untouched for I (180°)", () => {
122+
const fakeCanvas = { width: 200, height: 100 } as HTMLCanvasElement;
123+
const upright = getDisplaySize(baseCode128("N"), fakeCanvas, 1, 8);
124+
const inverted = getDisplaySize(baseCode128("I"), fakeCanvas, 1, 8);
125+
expect(inverted).toEqual(upright);
126+
});
127+
});

0 commit comments

Comments
 (0)