Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
160 changes: 135 additions & 25 deletions src/components/Canvas/bwipHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* bwipHelpers.test.ts ensures every BCID-registered type has a case.
*/

import type { LeafObject } from "../../registry";
import { ObjectRegistry, type LeafObject } from "../../registry";
import type { LabelObject } from "../../types/Group";
import type { Gs1DatabarProps } from "../../registry/gs1databar";
import { objectRotation } from "../../registry/rotation";
Expand All @@ -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,24 @@ 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);
// Source of truth for textAbove is the registry's HriBehavior — same
// field BarcodeObject consumes for its overlay positioning. Without
// this the bbox places bars at the top and reserves the zone at the
// bottom, but the renderer draws the text above the bars at negative
// y → text leaks out of the bbox. Bug spotted by gemini on PR #90.
const isTextAbove = ObjectRegistry[obj.type]?.hri?.textAbove ?? false;

// Map the upright "below the bars" zone onto the rotated bbox: it travels
// around the rectangle as the symbol rotates.
Expand All @@ -597,11 +680,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 +831,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
Loading