Skip to content

Commit 7710e2e

Browse files
authored
Merge pull request #96 from u8array/refactor/barcode-group-rotation
Refactor/barcode group rotation
2 parents d3fd583 + 6524f03 commit 7710e2e

13 files changed

Lines changed: 422 additions & 618 deletions

‎src/components/Canvas/BarcodeObject.tsx‎

Lines changed: 129 additions & 481 deletions
Large diffs are not rendered by default.

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

Lines changed: 13 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -93,18 +93,13 @@ describe("rotation pipeline", () => {
9393
},
9494
}) as LabelObject;
9595

96-
it("does not set rotate for N", () => {
97-
const opts = buildBwipOptions(baseCode128("N"), 1, 8);
98-
expect(opts?.rotate).toBeUndefined();
99-
});
100-
101-
it("forwards R and I unchanged to bwip-js", () => {
102-
expect(buildBwipOptions(baseCode128("R"), 1, 8)?.rotate).toBe("R");
103-
expect(buildBwipOptions(baseCode128("I"), 1, 8)?.rotate).toBe("I");
104-
});
105-
106-
it("translates ZPL B to bwip L (270° CW)", () => {
107-
expect(buildBwipOptions(baseCode128("B"), 1, 8)?.rotate).toBe("L");
96+
it("never sets a bwip rotate option (Konva handles visual rotation)", () => {
97+
// bwip-js always renders upright now; the renderer wraps the
98+
// bitmap in an inner rotated Group via rotatedGroupTransform. A
99+
// rotate option in opts would double-rotate the result.
100+
for (const rot of ["N", "R", "I", "B"] as const) {
101+
expect(buildBwipOptions(baseCode128(rot), 1, 8)?.rotate).toBeUndefined();
102+
}
108103
});
109104

110105
it("resolves UPC/EAN supplement bcid by content length", () => {
@@ -132,14 +127,12 @@ describe("rotation pipeline", () => {
132127
});
133128

134129
it("swaps display W and H for quarter rotations", () => {
135-
// Pretend bwip produced an unrotated 200x100 bitmap.
136-
const fakeCanvas = { width: 200, height: 100 } as HTMLCanvasElement;
137-
const upright = getDisplaySize(baseCode128("N"), fakeCanvas, 1, 8);
138-
// For R/B, bwip's bitmap is post-rotation (100x200). Pass that and check
139-
// the upright dimensions are recovered then re-swapped to visible.
140-
const rotatedCanvas = { width: 100, height: 200 } as HTMLCanvasElement;
141-
const rotR = getDisplaySize(baseCode128("R"), rotatedCanvas, 1, 8);
142-
const rotB = getDisplaySize(baseCode128("B"), rotatedCanvas, 1, 8);
130+
// bwip-js always produces an upright bitmap now; getDisplaySize
131+
// swaps W/H itself for R/B to report the rotated screen footprint.
132+
const uprightCanvas = { width: 200, height: 100 } as HTMLCanvasElement;
133+
const upright = getDisplaySize(baseCode128("N"), uprightCanvas, 1, 8);
134+
const rotR = getDisplaySize(baseCode128("R"), uprightCanvas, 1, 8);
135+
const rotB = getDisplaySize(baseCode128("B"), uprightCanvas, 1, 8);
143136
expect(rotR.w).toBe(upright.h);
144137
expect(rotR.h).toBe(upright.w);
145138
expect(rotB.w).toBe(upright.h);

‎src/components/Canvas/bwipHelpers.ts‎

Lines changed: 55 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -289,12 +289,8 @@ export function buildBwipOptions(
289289
const bcid = BCID[obj.type];
290290
if (!bcid) return null;
291291

292-
// bwip-js takes the same N/R/I/B letters ZPL does for symbol orientation;
293-
// emitting it post-build means the produced bitmap is already rotated and
294-
// its dimensions are the post-rotation extents — no Konva-side rotation math
295-
// needed.
296-
const rotation = objectRotation(obj.props);
297-
292+
// bwip always renders upright; visual rotation is handled in the
293+
// Konva renderer via an inner rotated Group (rotatedGroupTransform).
298294
// Declared without an initializer because every reachable case
299295
// assigns before `break` and the `default` arm returns early — the
300296
// previous `= null` initializer is what ESLint 10's
@@ -352,8 +348,9 @@ export function buildBwipOptions(
352348
// HRI digits sit ABOVE the bars per Zebra firmware. Rendered
353349
// as a separate Konva Text overlay (same pattern as logmars)
354350
// so all four rotations land at the firmware-correct anchor
355-
// via getRotatedTextAnchor; bwip's own includetext would
356-
// bake the text into the bitmap and rotate with it.
351+
// via the inner rotated Group in BarcodeObject; bwip's own
352+
// includetext would bake the text into the bitmap and rotate
353+
// with it.
357354
const text = p.content || "00000";
358355
const variantBcid = text.length === 2 ? "ean2" : "ean5";
359356
opts = {
@@ -539,16 +536,10 @@ export function buildBwipOptions(
539536
return null;
540537
}
541538

542-
if (rotation !== "N") {
543-
// ZPL uses N/R/I/B (B = 270° CW). bwip-js uses N/R/I/L (L = 90° CCW =
544-
// 270° CW). The other three letters mean the same thing in both.
545-
opts.rotate = rotation === "B" ? "L" : rotation;
546-
// HRI text is handled as a Konva overlay in BarcodeObject (same as for
547-
// upright barcodes). Using bwip's includetext would embed text into the
548-
// bitmap at bwip's internal scale, making the bitmap taller/wider than the
549-
// bar-only dimensions that getDisplaySize computes — causing the KImage to
550-
// stretch the bitmap incorrectly and appear blurry/distorted.
551-
}
539+
// Visual rotation is Konva's job (wraps the KImage in a rotated Group
540+
// via rotatedGroupTransform). Asking bwip to rotate would produce
541+
// bitmaps in mismatched orientations across types — keep them all
542+
// upright so the renderer can rotate them uniformly.
552543
return opts;
553544
}
554545

@@ -573,6 +564,18 @@ export interface BarcodeDisplaySize {
573564
barH: number;
574565
barLeftPx: number;
575566
barTopPx: number;
567+
/** Upright (rotation=N) view of the same layout. Renderers that draw
568+
* inside an inner rotated Group consume this so their geometry stays
569+
* rotation-independent — the inner group's transform handles the
570+
* visual rotation. Always present alongside the rotated fields. */
571+
upright: {
572+
w: number;
573+
h: number;
574+
barW: number;
575+
barH: number;
576+
barLeftPx: number;
577+
barTopPx: number;
578+
};
576579
/** Sub-rect of the bwip-js canvas to render (in source pixel coords).
577580
* Lets the renderer skip bwip's internal padding, e.g. the
578581
* paddingheight pad on GS1 DataBar that would otherwise leave the
@@ -581,60 +584,6 @@ export interface BarcodeDisplaySize {
581584
bitmapCrop?: { x: number; y: number; width: number; height: number };
582585
}
583586

584-
/** Anchor coordinates for a Konva Text node that is rotated alongside a
585-
* 1D barcode. Exactly one field is meaningful per rotation: `sideX` for
586-
* R / B, `topY` for I. The other is set to 0 and ignored by the caller. */
587-
export interface RotatedTextAnchor {
588-
sideX: number;
589-
topY: number;
590-
}
591-
592-
/**
593-
* Where to place the rotated HRI text node so it sits `textGap` dots away
594-
* from the bars on the firmware-correct side.
595-
*
596-
* Naive sideX = -textGap / w + textGap anchors against the bbox edge,
597-
* which double-counts the firmware text zone (EAN/UPC: 13 dots, logmars:
598-
* 20 dots). Anchoring against the bar sub-rectangle (`barLeftPx`/`bw` for
599-
* R/B, `barTopPx`/`bh` for I) keeps the gap at exactly `textGap`
600-
* regardless of which side the text zone sits on.
601-
*
602-
* Konva rotates around the node origin, so the anchor accounts for the
603-
* text glyph extending in the rotation-opposite direction. For R (CW 90)
604-
* with text on the right, the glyph extends LEFT of `sideX` by
605-
* `textFontSize`, so we offset by +textFontSize. Mirror for B (CCW 90)
606-
* and I (180).
607-
*/
608-
export function getRotatedTextAnchor(
609-
rotation: "R" | "B" | "I",
610-
isTextAbove: boolean,
611-
dim: Pick<BarcodeDisplaySize, "barLeftPx" | "barTopPx" | "barW" | "barH">,
612-
textGap: number,
613-
textFontSize: number,
614-
): RotatedTextAnchor {
615-
const { barLeftPx: btX, barTopPx: btY, barW: bw, barH: bh } = dim;
616-
if (rotation === "R") {
617-
return {
618-
sideX: isTextAbove ? btX + bw + textGap + textFontSize : btX - textGap,
619-
topY: 0,
620-
};
621-
}
622-
if (rotation === "B") {
623-
return {
624-
sideX: isTextAbove ? btX - textGap - textFontSize : btX + bw + textGap,
625-
topY: 0,
626-
};
627-
}
628-
// I (180°): text glyph extends UP from the origin, so a text-below-in-
629-
// upright glyph (now top after flip) anchors at btY - textGap; a
630-
// text-above-in-upright glyph (now bottom) anchors at btY + bh +
631-
// textGap + textFontSize.
632-
return {
633-
sideX: 0,
634-
topY: isTextAbove ? btY + bh + textGap + textFontSize : btY - textGap,
635-
};
636-
}
637-
638587
/** Firmware-reserved text-zone height in dots, keyed by symbology. The
639588
* zone sits below the bars in upright orientation; rotation maps it to
640589
* another side of the bbox in getDisplaySize. Types not listed have no
@@ -656,19 +605,18 @@ export function getDisplaySize(
656605
if (!canvas) {
657606
return {
658607
w: 0, h: 0, barW: 0, barH: 0, barLeftPx: 0, barTopPx: 0,
608+
upright: { w: 0, h: 0, barW: 0, barH: 0, barLeftPx: 0, barTopPx: 0 },
659609
};
660610
}
661611

662-
// For 90°/270° rotations, bwip-js produces a bitmap whose width and height
663-
// are swapped relative to the upright form. Compute size as if upright (the
664-
// existing per-symbology formulas all assume that), then swap at the end.
612+
// bwip-js now always renders upright (the BarcodeObject renderer
613+
// handles visual rotation via a rotated Konva Group), so the canvas
614+
// dimensions are the upright dimensions directly.
665615
const rotation = objectRotation(obj.props);
666616
const isQuarter = rotation === "R" || rotation === "B";
667-
const cw = isQuarter ? canvas.height : canvas.width;
668-
const ch = isQuarter ? canvas.width : canvas.height;
669-
const upright = getUprightDisplaySize(obj, cw, ch, scale, dpmm);
617+
const upright = getUprightDisplaySize(obj, canvas.width, canvas.height, scale, dpmm);
670618

671-
// Bbox after rotation.
619+
// Bbox after rotation: R/B swap upright w/h; N/I keep them.
672620
const w = isQuarter ? upright.h : upright.w;
673621
const h = isQuarter ? upright.w : upright.h;
674622

@@ -725,39 +673,41 @@ export function getDisplaySize(
725673
}
726674

727675
// GS1 DataBar opts include `paddingheight: N`, which adds whitespace
728-
// rows on top and bottom of the bwip canvas. Without cropping them out,
729-
// the bitmap drawn at displayH leaves the bars proportionally shorter
730-
// than the spec-correct height. Zebra firmware fills the full reserved
731-
// height with bars; mirror that by cropping the source bitmap to the
732-
// bar-only rows.
733-
//
734-
// Rotation flips which axis the padding sits on. For N / I the padding
735-
// is on top/bottom of the bitmap as bwip produced it; for R / B (bwip
736-
// rotated 90° CW / CCW respectively) the same rows end up on the
737-
// left/right edges, so the crop must run along the x-axis instead.
676+
// rows on top and bottom of the upright bwip canvas. Without cropping
677+
// them out, the bitmap drawn at displayH leaves the bars
678+
// proportionally shorter than the spec-correct height. Zebra firmware
679+
// fills the full reserved height with bars; mirror that by cropping
680+
// the source bitmap to the bar-only rows. Always y-axis now because
681+
// bwip renders upright.
738682
let bitmapCrop: BarcodeDisplaySize["bitmapCrop"];
739683
if (obj.type === "gs1databar") {
740684
const bwipSc = get1DBwipScale(obj.props.moduleWidth, scale, dpmm);
741685
const padPx = GS1_DATABAR_PADDING_ROWS * bwipSc;
742-
const axisDim = isQuarter ? canvas.width : canvas.height;
743-
if (axisDim > 2 * padPx) {
744-
bitmapCrop = isQuarter
745-
? {
746-
x: padPx,
747-
y: 0,
748-
width: canvas.width - 2 * padPx,
749-
height: canvas.height,
750-
}
751-
: {
752-
x: 0,
753-
y: padPx,
754-
width: canvas.width,
755-
height: canvas.height - 2 * padPx,
756-
};
686+
if (canvas.height > 2 * padPx) {
687+
bitmapCrop = {
688+
x: 0,
689+
y: padPx,
690+
width: canvas.width,
691+
height: canvas.height - 2 * padPx,
692+
};
757693
}
758694
}
759695

760-
return { w, h, barW, barH, barLeftPx, barTopPx, bitmapCrop };
696+
// Upright view (rotation=N) of the same layout. Text zone sits above
697+
// the bars when isTextAbove (logmars), otherwise implicitly below
698+
// (barTopPx=0, barH = h - textZonePx). Bars span the full width.
699+
// The inner-rotated-Group renderer pattern consumes this so its
700+
// geometry stays rotation-independent.
701+
const uprightView = {
702+
w: upright.w,
703+
h: upright.h,
704+
barLeftPx: 0,
705+
barTopPx: isTextAbove && textZonePx > 0 ? textZonePx : 0,
706+
barW: upright.w,
707+
barH: textZonePx > 0 ? upright.h - textZonePx : upright.h,
708+
};
709+
710+
return { w, h, barW, barH, barLeftPx, barTopPx, upright: uprightView, bitmapCrop };
761711
}
762712

763713
function getUprightDisplaySize(
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { Text } from "react-konva";
2+
import type { ReactNode } from "react";
3+
import type { EanUpcType } from "./bwipHelpers";
4+
5+
/** Visual gap in display px between the UPC-E bar end and the floated
6+
* check digit. Chosen by eye to match the Labelary render — the digit
7+
* sits just past the quiet-zone boundary, not flush against the bars. */
8+
const UPCE_TRAIL_DIGIT_GAP_PX = 2;
9+
10+
/** Result of building the EAN/UPC digit overlay: the Konva text nodes
11+
* plus the clip-expansion needed on the parent Group so floated
12+
* sys/trail digits stay visible. `clipLeft`/`clipRight` are dots
13+
* beyond the bar's upright x-range (0..barW) the parent must reserve;
14+
* consumers without clipping can ignore them. */
15+
export interface EanUpcDigitOverlay {
16+
nodes: ReactNode[];
17+
clipLeft: number;
18+
clipRight: number;
19+
}
20+
21+
interface BuildArgs {
22+
type: EanUpcType;
23+
displayText: string;
24+
layout: { xLeft: number; xRight: number; halfWidth: number };
25+
/** Upright bar width in display px — used as the right anchor for
26+
* the UPC-E trail digit (sits 2px past the bar end). */
27+
uprightBarW: number;
28+
/** Upright bar height in display px — text Y = uprightBarH + textGap. */
29+
uprightBarH: number;
30+
textGap: number;
31+
textFontSize: number;
32+
}
33+
34+
/** Build the digit-by-digit HRI overlay for EAN-13 / EAN-8 / UPC-A /
35+
* UPC-E in upright coords. Single source of truth shared by the
36+
* upright printInterp branch and the rotated inner-Group branch in
37+
* BarcodeObject — both produce identical digit positions, only the
38+
* parent wrapper differs (upright clips the bbox; rotated wraps in a
39+
* rotatedGroupTransform Group). */
40+
export function buildEanUpcDigitOverlay(args: BuildArgs): EanUpcDigitOverlay {
41+
const { type, displayText, layout, uprightBarW, uprightBarH, textGap, textFontSize } = args;
42+
const ldW = textFontSize * 1.2;
43+
const textY = Math.max(uprightBarH, 1) + textGap;
44+
const { xLeft, xRight, halfWidth: halfW } = layout;
45+
46+
const tStyle = {
47+
y: textY,
48+
fontSize: textFontSize,
49+
fontFamily: "'Courier New', monospace" as const,
50+
fontStyle: "bold" as const,
51+
wrap: "none" as const,
52+
fill: "#000000",
53+
listening: false,
54+
};
55+
56+
const main = (key: string, x: number, width: number, text: string) =>
57+
<Text key={key} x={x} width={Math.max(width, 1)} text={text} align="center" {...tStyle} />;
58+
const sys = (text: string) =>
59+
<Text key="sys" x={-ldW} width={ldW} text={text} align="center" {...tStyle} />;
60+
const trail = (text: string) =>
61+
<Text key="trail" x={uprightBarW + UPCE_TRAIL_DIGIT_GAP_PX} width={ldW} text={text} align="left" {...tStyle} />;
62+
63+
switch (type) {
64+
case "ean13":
65+
return {
66+
clipLeft: ldW,
67+
clipRight: 0,
68+
nodes: [
69+
sys(displayText[0] ?? ""),
70+
main("left", xLeft, halfW, displayText.slice(1, 7)),
71+
main("right", xRight, halfW, displayText.slice(7, 13)),
72+
],
73+
};
74+
case "ean8":
75+
return {
76+
clipLeft: 0,
77+
clipRight: 0,
78+
nodes: [
79+
main("left", xLeft, halfW, displayText.slice(0, 4)),
80+
main("right", xRight, halfW, displayText.slice(4, 8)),
81+
],
82+
};
83+
case "upca":
84+
// UPC-A HRI: number system (1) | 5 manuf | 5 product | check (1).
85+
// System digit floats left of the bars, check digit floats right —
86+
// matches Zebra's ^BU default (checkDigit=Y) which the generator
87+
// now emits.
88+
return {
89+
clipLeft: ldW,
90+
clipRight: ldW,
91+
nodes: [
92+
sys(displayText[0] ?? ""),
93+
main("left", xLeft, halfW, displayText.slice(1, 6)),
94+
main("right", xRight, halfW, displayText.slice(6, 11)),
95+
trail(displayText[11] ?? ""),
96+
],
97+
};
98+
case "upce":
99+
return {
100+
clipLeft: ldW,
101+
clipRight: ldW,
102+
nodes: [
103+
sys(displayText[0] ?? "0"),
104+
main("mid", xLeft, halfW, displayText.slice(1, 7)),
105+
trail(displayText[7] ?? ""),
106+
],
107+
};
108+
}
109+
}

0 commit comments

Comments
 (0)