Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
2 changes: 1 addition & 1 deletion docs/zpl-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ What's supported, what's next, what's planned.
- [x] `^BR` — GS1 Databar
- [x] `^B5` — Planet Code
- [x] `^BZ` — POSTNET
- [x] `^BS` — UPC/EAN 2- or 5-digit supplement
- [x] `^BQ` — QR Code
- [x] `^BX` — DataMatrix
- [x] `^B7` — PDF417
Expand Down Expand Up @@ -113,7 +114,6 @@ What's supported, what's next, what's planned.

- [ ] `^B4` — Code 49
- [ ] `^BD` — UPS MaxiCode
- [ ] `^BS` — UPC/EAN extensions
- [ ] `^BT` — TLC39

---
Expand Down
174 changes: 86 additions & 88 deletions src/components/Canvas/BarcodeObject.tsx

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/components/Canvas/KonvaObject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ const BARCODE_TYPES = new Set([
"aztec",
"micropdf417",
"codablock",
"upcEanExtension",
]);

export function KonvaObject(props_: Props) {
Expand Down
11 changes: 11 additions & 0 deletions src/components/Canvas/bwipConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,22 @@ export const QR_FT_MODULE_OFFSET = 3;
// (even with printInterpretation=false). Verified at 8 and 12 dpmm: constant 13 dots.
export const EAN_TEXT_ZONE_DOTS = 13;

// ^BS UPC/EAN supplements print the digits ABOVE the bars (Zebra spec), with
// a larger reserved zone than the main EAN/UPC text band. Measured against
// Labelary for FO 50,50 ^BSN,80,Y: bbox top sits 18 dots above the FO anchor
// (bbox height 98 = bar height 80 + 18).
export const UPC_SUPP_TEXT_ZONE_DOTS = 18;

// LOGMARS renders the human-readable line ABOVE the bars (per spec).
// Empirically Labelary leaves ~10 dots between visible text bottom and bar top,
// wider than the standard textGap used for text below other 1D barcodes.
export const LOGMARS_TEXT_ABOVE_GAP_DOTS = 10;

// ^BS UPC/EAN supplement: text sits tight against the bars in Labelary,
// noticeably tighter than logmars and even slightly tighter than the
// standard 5-dot textGap. Empirically ~2 dots.
export const UPC_SUPP_TEXT_ABOVE_GAP_DOTS = 2;

// Total LOGMARS text-zone reserved by firmware (regardless of printInterpretation):
// glyph height + LOGMARS_TEXT_ABOVE_GAP_DOTS. Empirically 20 dots — used as part
// of the ZPL-correct bbox so selection-handles match the printed footprint.
Expand Down
24 changes: 24 additions & 0 deletions src/components/Canvas/bwipHelpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,30 @@ describe("rotation pipeline", () => {
expect(buildBwipOptions(baseCode128("B"), 1, 8)?.rotate).toBe("L");
});

it("resolves UPC/EAN supplement bcid by content length", () => {
const supplement = (content: string): LabelObject =>
({
id: 's',
type: 'upcEanExtension',
x: 0,
y: 0,
rotation: 0,
props: {
content,
height: 80,
moduleWidth: 2,
printInterpretation: true,
checkDigit: false,
rotation: 'N',
},
}) as LabelObject;
// 2-digit content selects the ean2 bcid; everything else (5-digit,
// empty fallback) renders as ean5.
expect(buildBwipOptions(supplement('42'), 1, 8)?.bcid).toBe('ean2');
expect(buildBwipOptions(supplement('51999'), 1, 8)?.bcid).toBe('ean5');
expect(buildBwipOptions(supplement(''), 1, 8)?.bcid).toBe('ean5');
});

it("swaps display W and H for quarter rotations", () => {
// Pretend bwip produced an unrotated 200x100 bitmap.
const fakeCanvas = { width: 200, height: 100 } as HTMLCanvasElement;
Expand Down
153 changes: 129 additions & 24 deletions src/components/Canvas/bwipHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
LOGMARS_TEXT_ZONE_DOTS,
MICROPDF417_QUIET_ZONE_ROWS,
PLESSEY_BWIP_TO_ZEBRA_WIDTH_RATIO,
UPC_SUPP_TEXT_ZONE_DOTS,
} from "./bwipConstants";

/**
Expand Down Expand Up @@ -93,6 +94,9 @@ const BCID: Partial<Record<LabelObject["type"], string>> = {
aztec: "azteccodecompact",
micropdf417: "micropdf417",
codablock: "codablockf",
// Placeholder — actual bcid (ean2 vs ean5) is resolved from the
// content length in the per-type switch in buildBwipOptions.
upcEanExtension: "ean5",
};

export const BWIP_SCALE = 2;
Expand Down Expand Up @@ -207,24 +211,10 @@ function bwipScale1D(
: BWIP_SCALE;
}

export function eanCheckDigit(digits: string, w0: number, w1: number): string {
let sum = 0;
for (let i = 0; i < digits.length; i++)
sum += parseInt(digits[i] ?? "0", 10) * (i % 2 === 0 ? w0 : w1);
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);
}
// Check-digit math now lives in src/lib/barcodeCheckDigits.ts (pure,
// no Canvas deps). Re-export here so existing callers (BarcodeObject)
// keep working without touching every import.
export { eanCheckDigit, upceCheckDigit } from "../../lib/barcodeCheckDigits";

/**
* Encode text as Code 128 subset B using bwip-js raw ^NNN format.
Expand Down Expand Up @@ -327,6 +317,29 @@ export function buildBwipOptions(
opts = { bcid, text, scale, height: 10 };
break;
}
case "upcEanExtension": {
const p = obj.props;
const scale = bwipScale1D(p.moduleWidth, renderScale, renderDpmm);
// ZPL ^BS uses one command for both lengths; bwip splits the
// bcid. Anything that isn't a 2-digit supplement is rendered
// as the 5-digit variant — matches printer behaviour where
// the 5-digit form is the common case (ISBN price, magazine
// sequence) and bwip-js rejects other lengths outright.
// HRI digits sit ABOVE the bars per Zebra firmware. Rendered
// as a separate Konva Text overlay (same pattern as logmars)
// so all four rotations land at the firmware-correct anchor
// via getRotatedTextAnchor; bwip's own includetext would
// bake the text into the bitmap and rotate with it.
const text = p.content || "00000";
const variantBcid = text.length === 2 ? "ean2" : "ean5";
opts = {
bcid: variantBcid,
text,
scale,
height: 10,
};
break;
}
case "code128": {
const p = obj.props;
const scale = bwipScale1D(p.moduleWidth, renderScale, renderDpmm);
Expand Down Expand Up @@ -544,6 +557,60 @@ export interface BarcodeDisplaySize {
bitmapCrop?: { x: number; y: number; width: number; height: number };
}

/** Anchor coordinates for a Konva Text node that is rotated alongside a
* 1D barcode. Exactly one field is meaningful per rotation: `sideX` for
* R / B, `topY` for I. The other is set to 0 and ignored by the caller. */
export interface RotatedTextAnchor {
sideX: number;
topY: number;
}

/**
* Where to place the rotated HRI text node so it sits `textGap` dots away
* from the bars on the firmware-correct side.
*
* Naive sideX = -textGap / w + textGap anchors against the bbox edge,
* which double-counts the firmware text zone (EAN/UPC: 13 dots, logmars:
* 20 dots). Anchoring against the bar sub-rectangle (`barLeftPx`/`bw` for
* R/B, `barTopPx`/`bh` for I) keeps the gap at exactly `textGap`
* regardless of which side the text zone sits on.
*
* Konva rotates around the node origin, so the anchor accounts for the
* text glyph extending in the rotation-opposite direction. For R (CW 90)
* with text on the right, the glyph extends LEFT of `sideX` by
* `textFontSize`, so we offset by +textFontSize. Mirror for B (CCW 90)
* and I (180).
*/
export function getRotatedTextAnchor(
rotation: "R" | "B" | "I",
isTextAbove: boolean,
dim: Pick<BarcodeDisplaySize, "barLeftPx" | "barTopPx" | "barW" | "barH">,
textGap: number,
textFontSize: number,
): RotatedTextAnchor {
const { barLeftPx: btX, barTopPx: btY, barW: bw, barH: bh } = dim;
if (rotation === "R") {
return {
sideX: isTextAbove ? btX + bw + textGap + textFontSize : btX - textGap,
topY: 0,
};
}
if (rotation === "B") {
return {
sideX: isTextAbove ? btX - textGap - textFontSize : btX + bw + textGap,
topY: 0,
};
}
// I (180°): text glyph extends UP from the origin, so a text-below-in-
// upright glyph (now top after flip) anchors at btY - textGap; a
// text-above-in-upright glyph (now bottom) anchors at btY + bh +
// textGap + textFontSize.
return {
sideX: 0,
topY: isTextAbove ? btY + bh + textGap + textFontSize : btY - textGap,
};
}

/** Firmware-reserved text-zone height in dots, keyed by symbology. The
* zone sits below the bars in upright orientation; rotation maps it to
* another side of the bbox in getDisplaySize. Types not listed have no
Expand Down Expand Up @@ -583,8 +650,19 @@ export function getDisplaySize(

// Text-zone reservation in upright orientation, on the "below" side of
// the bars per Labelary's bbox. Zero for symbologies without one.
const textZoneDots = TEXT_ZONE_DOTS_BY_TYPE[obj.type] ?? 0;
// ^BS supplements reserve the zone ABOVE the bars in upright (N);
// bookkeeping reuses the same px value but flips which side gets
// the offset.
// ^BS reserves the text zone only when printInterpretation=Y; with
// f=N the printer prints bars only and bbox = bar height. Other
// EAN/UPC reserve the 13-dot zone unconditionally (Zebra firmware
// ships a fixed text guard even when N).
const textZoneDots =
obj.type === "upcEanExtension"
? obj.props.printInterpretation ? UPC_SUPP_TEXT_ZONE_DOTS : 0
: TEXT_ZONE_DOTS_BY_TYPE[obj.type] ?? 0;
const textZonePx = dotsToPx(textZoneDots, scale, dpmm);
const isTextAbove = obj.type === "upcEanExtension";
Comment thread
u8array marked this conversation as resolved.
Outdated

// Map the upright "below the bars" zone onto the rotated bbox: it travels
// around the rectangle as the symbol rotates.
Expand All @@ -597,11 +675,23 @@ export function getDisplaySize(
let barW = w;
let barH = h;
if (textZonePx > 0) {
switch (rotation) {
case "N": barH = h - textZonePx; break;
case "R": barLeftPx = textZonePx; barW = w - textZonePx; break;
case "I": barTopPx = textZonePx; barH = h - textZonePx; break;
case "B": barW = w - textZonePx; break;
// isTextAbove flips the upright zone from "below the bars" to "above
// the bars" (and the corresponding rotated edges) without duplicating
// the rotation table.
if (!isTextAbove) {
switch (rotation) {
case "N": barH = h - textZonePx; break;
case "R": barLeftPx = textZonePx; barW = w - textZonePx; break;
case "I": barTopPx = textZonePx; barH = h - textZonePx; break;
case "B": barW = w - textZonePx; break;
}
} else {
switch (rotation) {
case "N": barTopPx = textZonePx; barH = h - textZonePx; break;
case "R": barW = w - textZonePx; break;
case "I": barH = h - textZonePx; break;
case "B": barLeftPx = textZonePx; barW = w - textZonePx; break;
}
}
}

Expand Down Expand Up @@ -736,6 +826,21 @@ function getUprightDisplaySize(
const h = dotsToPx(obj.props.height + EAN_TEXT_ZONE_DOTS, scale, dpmm);
return { w, h };
}
case "upcEanExtension": {
// ^BS prints the human-readable digits ABOVE the bars (unlike the
// main EAN/UPC text band below), and Zebra reserves a larger
// vertical zone for it only when printInterpretation=Y. With
// f=N the bbox collapses to bar height (no guard reservation,
// unlike main UPC/EAN which always reserves 13). Measured
// against Labelary at 80-bar height: Y → 98, N → 80.
const modulePx = dotsToPx(obj.props.moduleWidth, scale, dpmm);
const bwipSc = get1DBwipScale(obj.props.moduleWidth, scale, dpmm);
const extraPx = bwipSc === 1 ? 1 : 0;
const w = ((cw - extraPx) / bwipSc) * modulePx;
const zone = obj.props.printInterpretation ? UPC_SUPP_TEXT_ZONE_DOTS : 0;
const h = dotsToPx(obj.props.height + zone, scale, dpmm);
return { w, h };
}
case "logmars": {
// LOGMARS reserves a text zone above the bars (per spec) regardless of
// printInterpretation. Include LOGMARS_TEXT_ZONE_DOTS so the bbox
Expand Down
29 changes: 29 additions & 0 deletions src/lib/barcodeCheckDigits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Pure check-digit math for the EAN/UPC family. No Canvas/React/bwip
* deps so registry leaves and tests can import from here without
* dragging in the canvas renderer.
*/

/**
* Mod-10 check digit with alternating weights. `w0` is applied to the
* 0th, 2nd, … digit; `w1` to the 1st, 3rd, … digit. EAN-13 uses (1, 3)
* scanning left-to-right; EAN-8 and UPC-A use (3, 1).
*/
export function eanCheckDigit(digits: string, w0: number, w1: number): string {
let sum = 0;
for (let i = 0; i < digits.length; i++)
sum += parseInt(digits[i] ?? "0", 10) * (i % 2 === 0 ? w0 : w1);
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);
}
2 changes: 1 addition & 1 deletion src/lib/zplCommandSupport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ export const ZPL_COMMANDS: readonly ZplCommandInfo[] = [
{ cmd: 'BP', status: 'supported', description: 'Plessey barcode' },
{ cmd: 'BQ', status: 'supported', description: 'QR Code' },
{ cmd: 'BR', status: 'supported', description: 'GS1 Databar' },
{ cmd: 'BS', status: 'unsupported', description: 'UPC/EAN extensions' },
{ cmd: 'BS', status: 'supported', description: 'UPC/EAN 2- or 5-digit supplement barcode' },
{ cmd: 'BT', status: 'unsupported', description: 'TLC39 barcode' },
{ cmd: 'BU', status: 'supported', description: 'UPC-A barcode' },
{ cmd: 'BX', status: 'supported', description: 'DataMatrix code' },
Expand Down
28 changes: 28 additions & 0 deletions src/lib/zplGenerator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,6 +673,34 @@ describe('generateZPL — parse/generate roundtrip', () => {
expect(props(barcode).height).toBe(150);
});

it('round-trips a ^BS UPC/EAN extension (5-digit)', () => {
const original = parseZPL('^XA^FO10,10^BSN,80,Y^FD54321^FS^XZ', 8);
const regenerated = generateZPL(BASE_LABEL, original.objects);
const reparsed = parseZPL(regenerated, 8);
const ext = defined(reparsed.objects.find((o) => o.type === 'upcEanExtension'));
expect(props(ext).content).toBe('54321');
expect(props(ext).height).toBe(80);
expect(props(ext).printInterpretation).toBe(true);
});

it('round-trips a ^BS UPC/EAN extension (2-digit)', () => {
const original = parseZPL('^XA^FO10,10^BSN,50,N^FD42^FS^XZ', 8);
const regenerated = generateZPL(BASE_LABEL, original.objects);
const reparsed = parseZPL(regenerated, 8);
const ext = defined(reparsed.objects.find((o) => o.type === 'upcEanExtension'));
expect(props(ext).content).toBe('42');
expect(props(ext).printInterpretation).toBe(false);
});

it('round-trips ^BS rotation and moduleWidth (via ^BY)', () => {
const original = parseZPL('^XA^BY3^FO10,10^BSR,80,Y^FD12345^FS^XZ', 8);
const regenerated = generateZPL(BASE_LABEL, original.objects);
const reparsed = parseZPL(regenerated, 8);
const ext = defined(reparsed.objects.find((o) => o.type === 'upcEanExtension'));
expect(props(ext).rotation).toBe('R');
expect(props(ext).moduleWidth).toBe(3);
});

it('preserves printer params through generate -> parse', () => {
const label: LabelConfig = {
...BASE_LABEL,
Expand Down
2 changes: 2 additions & 0 deletions src/lib/zplParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -845,6 +845,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL {
case "plessey":
case "planet":
case "postal":
case "upcEanExtension":
objects.push(
makeObj(
fieldType,
Expand Down Expand Up @@ -1150,6 +1151,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL {
BP: mkBarcode("plessey", 2, 3, "Y", 1), // ^BPN,c,h,i,N
B5: mkBarcode("planet", 1, 2), // ^B5N,h,i,N
BZ: mkBarcode("postal", 1, 2), // ^BZN,h,i,N
BS: mkBarcode("upcEanExtension", 1, 2), // ^BSo,h,f (UPC/EAN 2- or 5-digit supplement)

// MSI: check logic is "any letter except N" (not simple "Y") — keep inline
// ^BMN,{checkType},{height},{interp},N (checkType: A/B/C/D=enabled, N=none)
Expand Down
8 changes: 8 additions & 0 deletions src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const ar = {
upca: 'UPC-A',
ean8: 'EAN-8',
upce: 'UPC-E',
upcEanExtension: 'امتداد UPC/EAN',
interleaved2of5: 'Interleaved 2 من 5',
code93: 'Code 93',
pdf417: 'PDF417',
Expand Down Expand Up @@ -333,6 +334,13 @@ const ar = {
moduleWidth: 'عرض الوحدة',
placeholder: '6 أرقام',
},
upcEanExtension: {
content: 'المحتوى (2 أو 5 أرقام)',
height: 'الارتفاع (نقاط)',
printInterpretation: 'قابل للقراءة',
moduleWidth: 'عرض الوحدة',
placeholder: '2 أو 5 أرقام',
},
interleaved2of5: {
content: 'المحتوى',
height: 'الارتفاع (نقاط)',
Expand Down
Loading