Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
5 changes: 5 additions & 0 deletions src/components/Canvas/BarcodeObject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
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,7 +152,11 @@ 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.
// Manual HRI overlays only run for upright barcodes; when rotated, bwip-js
// bakes the text into the bitmap (see bwipHelpers).
const isUpright = objectRotation(obj.props) === "N";
const printInterp =
isUpright &&
!ObjectRegistry[obj.type]?.interpretationLocked &&
!!(obj.props as { printInterpretation?: boolean }).printInterpretation;
const moduleWidth =
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);
});
});
73 changes: 55 additions & 18 deletions src/components/Canvas/bwipHelpers.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -164,6 +165,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<string, unknown> | null = null;

switch (obj.type) {
Expand Down Expand Up @@ -336,6 +343,18 @@ 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;
// When the symbol is rotated we delegate HRI text to bwip-js (so it gets
// rotated alongside the bars). The manual text overlays in BarcodeObject
// assume an upright bitmap and do not rotate; gating them out is paired
// with this flag. For 'N' we keep the previous behaviour: manual overlays
// give pixel-perfect EAN/UPC labels and the LOGMARS-above placement.
const printInterp = !!(obj.props as { printInterpretation?: boolean }).printInterpretation;
if (printInterp) opts.includetext = true;
}
return opts;
}

Expand All @@ -347,6 +366,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) {
Expand All @@ -356,7 +393,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 };
}
Expand All @@ -365,7 +402,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 };
}
Expand All @@ -375,7 +412,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);
Expand All @@ -384,17 +421,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 };
}
Expand All @@ -405,7 +442,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 };
}
Expand All @@ -419,14 +456,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
Expand All @@ -444,28 +481,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 };
}
Expand All @@ -476,14 +513,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 };
}
}
}
2 changes: 1 addition & 1 deletion src/components/Canvas/transformPosition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
26 changes: 26 additions & 0 deletions src/components/Properties/RotationSelect.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex flex-col gap-1">
<label className={labelCls}>{t.registry.text.rotation}</label>
<select
className={inputCls}
value={value}
onChange={(e) => isZplRotation(e.target.value) && onChange(e.target.value)}
>
{ZPL_ROTATIONS.map((r) => (
<option key={r} value={r}>{t.registry.text[`rotation${r}`]}</option>
))}
</select>
</div>
);
}
22 changes: 22 additions & 0 deletions src/lib/zplParser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading