Skip to content

Commit 9924d97

Browse files
committed
fix(barcode): HRI start/stop glyphs and UPC-E zero normalization
Code 11/93/39 HRI render their start/stop markers (triangle/square/asterisk) and Code 11 check digits like Labelary; UPC-E content normalization is idempotent.
1 parent 664ea5e commit 9924d97

13 files changed

Lines changed: 198 additions & 27 deletions

‎src/components/Canvas/BarcodeObject.tsx‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
import { objectRotation } from "../../registry/rotation";
2222
import { rotatedGroupTransform } from "./rotatedGroupTransform";
2323
import { buildEanUpcDigitOverlay } from "./eanUpcDigitNodes";
24+
import { buildCode1dStartStopGlyphs } from "./code1dHriOverlay";
2425
import {
2526
VERA_MONO_HRI_EM_PER_MODULE,
2627
VERA_MONO_HRI_CAP_TOP_PAD,
@@ -248,7 +249,8 @@ export function BarcodeObject({
248249
// Generic 1D subtracts this below the bars so the visible bar-to-cap gap
249250
// equals textGap and stays module-width-independent (see the constant).
250251
const glyphTopPad = textFontSize * VERA_MONO_HRI_CAP_TOP_PAD;
251-
const displayText = hri?.formatHri?.(rawContent) ?? rawContent;
252+
const checkDigit = (obj.props as { checkDigit?: boolean }).checkDigit;
253+
const displayText = hri?.formatHri?.(rawContent, checkDigit) ?? rawContent;
252254
const isTextAbove = hri?.textAbove ?? false;
253255
// 3px floor matches textGap so HRI stays legible at very small
254256
// scales regardless of which dots value the spec calls for.
@@ -318,6 +320,8 @@ export function BarcodeObject({
318320
// ref stays undefined and the bars+text just scale together
319321
// during a rotated resize drag, a minor visual nit with no broken
320322
// print output.
323+
const fontFamily =
324+
resolveMwValue(hri?.fontFamily, moduleWidth) ?? HRI_FONT_A;
321325
const textY = isTextAbove
322326
? ub.barTopPx - textFontSize - aboveGapPx
323327
: ub.barTopPx + ub.barH + textGap - glyphTopPad;
@@ -326,10 +330,21 @@ export function BarcodeObject({
326330
const bottomAlign = hri?.fontDots
327331
? { height: textFontSize, verticalAlign: "bottom" as const }
328332
: {};
329-
const fontFamily =
330-
resolveMwValue(hri?.fontFamily, moduleWidth) ?? HRI_FONT_A;
331-
overlayContent = (
333+
// Code 11/93: keep the centered text as-is and only add the shape
334+
// start/stop glyphs flanking it (Code 11 triangle, Code 93 square).
335+
const startStopGlyphs = hri?.startStopGlyph
336+
? buildCode1dStartStopGlyphs({
337+
text: displayText, fontFamily, fontSize: textFontSize,
338+
barLeftPx: ub.barLeftPx, barW: ub.barW, textY,
339+
glyph: hri.startStopGlyph,
340+
})
341+
: null;
342+
// Keyed array (like the EAN path), not a fragment: a keyless data
343+
// Text adjacent to keyed start/stop Text nodes of the same type makes
344+
// react-konva mis-reconcile and drop the flanking ones (Code 39 *).
345+
const dataText = (
332346
<Text
347+
key="hri"
333348
ref={isUpright ? setTextRef : undefined}
334349
x={ub.barLeftPx} y={textY} width={Math.max(ub.barW, 1)}
335350
text={displayText} fontSize={textFontSize}
@@ -338,6 +353,7 @@ export function BarcodeObject({
338353
{...bottomAlign}
339354
/>
340355
);
356+
overlayContent = startStopGlyphs ? [dataText, ...startStopGlyphs] : dataText;
341357
}
342358

343359
// Counter-scale only applies to upright Other 1D. textLocalY here

‎src/components/Canvas/bwipHelpers.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import bwipjs from "bwip-js/browser";
55
import { ObjectRegistry, type LeafObject } from "../../registry";
6+
import { upceData6FromFd } from "../../registry/hriFormatters";
67
import type { LabelObject } from "../../types/Group";
78
import type { Gs1DatabarProps } from "../../registry/gs1databar";
89
import { objectRotation } from "../../registry/rotation";
@@ -179,7 +180,9 @@ export interface EanUpcHriFragment {
179180
* number-system digit as the firmware does. Shared by the bars and HRI
180181
* paths so both encode the identical symbol. */
181182
function rawEanUpc(type: EanUpcType, text: string): BwipRawLinear | null {
182-
const encoded = type === "upce" && text.length === 6 ? `0${text}` : text;
183+
// UPC-E: always feed bwip the canonical 7-digit form (NS 0 + 6 data),
184+
// NS-aware so a content that already carries the prefix is not doubled.
185+
const encoded = type === "upce" ? `0${upceData6FromFd(text)}` : text;
183186
try {
184187
const stack = bwipjs.raw({ bcid: type, text: encoded, includetext: true } as never) as BwipRawLinear[];
185188
return stack?.[0] ?? null;
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { Line, Rect, Text } from "react-konva";
2+
import type { ReactNode } from "react";
3+
import { VERA_MONO_HRI_CAP_TOP_PAD } from "./bwipConstants";
4+
5+
// Monospace advance (cell width / em), used to estimate the centered HRI
6+
// text's width so the glyphs flank it (assumes a monospace fontFamily, which
7+
// the Vera/Font-A HRI is); the text itself is rendered unchanged.
8+
const MONO_ADVANCE = 0.6;
9+
// Cap band within the em box; matches the HRI text's glyphTopPad.
10+
const CAP_HEIGHT = 1 - 2 * VERA_MONO_HRI_CAP_TOP_PAD;
11+
// Triangle apex slightly obtuse (Labelary Code 11): width ~= height (~51deg).
12+
const TRI_W_RATIO = 0.95;
13+
const TRI_Y_SHIFT = 0.12;
14+
// Triangle height per cap, asymmetric: small start, larger stop.
15+
const TRI_H_START = 0.5;
16+
const TRI_H_STOP = 0.75;
17+
// Extra horizontal gap (per cell) so the triangle does not crowd the text.
18+
const TRI_GAP = 0.2;
19+
// Code 93 oblong: 0.5:1 aspect scaled to ~0.8, bottom-anchored to the baseline.
20+
const SQ_W = 0.4;
21+
const SQ_H = 0.8;
22+
// The font's asterisk sits high; push it down to center it on the digit row.
23+
const ASTERISK_Y_SHIFT = 0.2;
24+
// Stroke width per em for the drawn triangle/square outlines.
25+
const STROKE_RATIO = 0.06;
26+
27+
type Glyph = "triangle" | "square" | "asterisk";
28+
29+
interface BuildArgs {
30+
/** The HRI text already rendered (centered) by BarcodeObject. */
31+
text: string;
32+
fontFamily: string;
33+
fontSize: number;
34+
/** Upright bar left edge and width in display px. */
35+
barLeftPx: number;
36+
barW: number;
37+
/** Top y of the HRI text box. */
38+
textY: number;
39+
glyph: Glyph;
40+
}
41+
42+
/** Start/stop markers flanking the centered HRI text: Code 11 triangle
43+
* (asymmetric, small start / large stop), Code 93 oblong square, Code 39
44+
* asterisk (a real glyph, but lowered to sit centered like Labelary). The
45+
* text stays rendered as-is; markers sit one cell-width past its edges. */
46+
export function buildCode1dStartStopGlyphs(a: BuildArgs): ReactNode[] {
47+
const { text, fontFamily, fontSize, barLeftPx, barW, textY, glyph } = a;
48+
const cellW = fontSize * MONO_ADVANCE;
49+
const capTop = textY + fontSize * VERA_MONO_HRI_CAP_TOP_PAD;
50+
const capH = fontSize * CAP_HEIGHT;
51+
const stroke = Math.max(fontSize * STROKE_RATIO, 1);
52+
const center = barLeftPx + barW / 2;
53+
const textHalf = (text.length * cellW) / 2;
54+
const triGap = glyph === "triangle" ? cellW * TRI_GAP : 0;
55+
const startCx = center - textHalf - cellW / 2 - triGap;
56+
const stopCx = center + textHalf + cellW / 2 + triGap;
57+
58+
const make = (key: string, cx: number, isStop: boolean): ReactNode => {
59+
if (glyph === "asterisk") {
60+
// Box must exceed the glyph's measured width or Konva drops the line
61+
// (empty textArr -> 0x0, invisible); cellW alone is too tight.
62+
return (
63+
<Text key={key} x={cx - fontSize / 2} width={fontSize} y={textY + capH * ASTERISK_Y_SHIFT}
64+
text="*" fontSize={fontSize} fontFamily={fontFamily}
65+
align="center" wrap="none" fill="#000000" listening={false} />
66+
);
67+
}
68+
if (glyph === "square") {
69+
const w = capH * SQ_W, h = capH * SQ_H;
70+
const cy = capTop + capH - h / 2; // bottom-anchored to the baseline
71+
return (
72+
<Rect key={key} x={cx - w / 2} y={cy - h / 2} width={w} height={h}
73+
stroke="#000000" strokeWidth={stroke} listening={false} />
74+
);
75+
}
76+
const h = capH * (isStop ? TRI_H_STOP : TRI_H_START), w = h * TRI_W_RATIO;
77+
const cy = capTop + capH / 2 + capH * TRI_Y_SHIFT;
78+
return (
79+
<Line key={key} closed listening={false} stroke="#000000" strokeWidth={stroke}
80+
points={[cx, cy - h / 2, cx - w / 2, cy + h / 2, cx + w / 2, cy + h / 2]} />
81+
);
82+
};
83+
84+
return [make("start", startCx, false), make("stop", stopCx, true)];
85+
}

‎src/components/Properties/LayerRow.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ export function LayerRow({
192192
) : (
193193
<span className="w-4 h-4 shrink-0" />
194194
)}
195-
<span className="font-mono text-xs text-accent shrink-0 w-4 text-center">
195+
<span className="font-mono text-xs text-accent shrink-0 w-4 text-center whitespace-nowrap">
196196
{groupRow ? '⊞' : def?.icon}
197197
</span>
198198
{boundVariable && (

‎src/lib/barcodeCheckDigits.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { code11CheckDigits } from './barcodeCheckDigits';
3+
4+
// Verified against Labelary (^B1 ^FD..., HRI shows data + check digit(s)).
5+
describe('code11CheckDigits', () => {
6+
it('computes C+K for "12345" (two=true): C=2, K=8', () => {
7+
expect(code11CheckDigits('12345', true)).toBe('28');
8+
});
9+
it('computes only C for "12345" (two=false): C=2', () => {
10+
expect(code11CheckDigits('12345', false)).toBe('2');
11+
});
12+
it('renders a value-10 check digit as the dash symbol', () => {
13+
// "123": C = 3*1+2*2+1*3 = 10 -> "-"; K over "123-" = 4.
14+
expect(code11CheckDigits('123', true)).toBe('-4');
15+
});
16+
it('cycles weights past length 10', () => {
17+
// "12345678901" (e=N): C=4, K=10 -> "-".
18+
expect(code11CheckDigits('12345678901', true)).toBe('4-');
19+
});
20+
it('skips non Code 11 characters instead of producing NaN', () => {
21+
expect(code11CheckDigits('12A', true)).not.toMatch(/NaN/);
22+
});
23+
});

‎src/lib/barcodeCheckDigits.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,27 @@ export function eanCheckDigit(digits: string, w0: number, w1: number): string {
1616
return String((10 - (sum % 10)) % 10);
1717
}
1818

19+
/** Code 11 weighted mod-11 over `s` right-to-left; weights cycle 1..maxWeight.
20+
* A result of 10 renders as the dash symbol, Code 11's value-10 character. */
21+
function code11Weighted(s: string, maxWeight: number): string {
22+
let sum = 0;
23+
for (let i = 0; i < s.length; i++) {
24+
const ch = s[s.length - 1 - i] ?? "0";
25+
const val = ch === "-" ? 10 : parseInt(ch, 10);
26+
if (Number.isNaN(val)) continue; // skip non Code 11 chars instead of NaN
27+
sum += val * ((i % maxWeight) + 1);
28+
}
29+
const r = sum % 11;
30+
return r === 10 ? "-" : String(r);
31+
}
32+
33+
/** Code 11 check digit(s): the C digit, plus the K digit when `two` (the
34+
* ^B1 e=N case). Verified: "12345" -> C=2, K=8. */
35+
export function code11CheckDigits(data: string, two: boolean): string {
36+
const c = code11Weighted(data, 10);
37+
return two ? c + code11Weighted(data + c, 9) : c;
38+
}
39+
1940
/** Compute the UPC-E check digit from the 6 compressed data digits. */
2041
export function upceCheckDigit(digits6: string): string {
2142
const [vA, vB, vC, vD, vE, vF] = digits6.padEnd(6, "0").split("");

‎src/registry/code11.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createBarcode1DCore, type Barcode1DCoreConfig } from './barcode1d';
2+
import { formatCode11Hri } from './hriFormatters';
23
export type { Barcode1DProps as Code11Props } from "./barcode1d";
34

45
export const code11CoreConfig: Barcode1DCoreConfig = {
@@ -11,6 +12,7 @@ export const code11CoreConfig: Barcode1DCoreConfig = {
1112
const check = p.checkDigit ? "Y" : "N";
1213
return `^B1${p.rotation},${check},${p.height},${interp},N`;
1314
},
15+
hri: { formatHri: formatCode11Hri, startStopGlyph: 'triangle' },
1416
};
1517

1618
export const code11 = createBarcode1DCore(code11CoreConfig);

‎src/registry/code39.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { createBarcode1DCore, type Barcode1DCoreConfig } from './barcode1d';
2-
import { formatCode39Hri } from './hriFormatters';
32
export type { Barcode1DProps as Code39Props } from './barcode1d';
43

54
export const code39CoreConfig: Barcode1DCoreConfig = {
@@ -12,7 +11,9 @@ export const code39CoreConfig: Barcode1DCoreConfig = {
1211
const check = p.checkDigit ? 'Y' : 'N';
1312
return `^B3${p.rotation},${check},${p.height},${interp},N`;
1413
},
15-
hri: { formatHri: formatCode39Hri },
14+
// Start/stop is the asterisk; rendered as flanking glyphs (lowered to sit
15+
// centered like Labelary), so the data text carries no `*`.
16+
hri: { startStopGlyph: 'asterisk' },
1617
};
1718

1819
export const code39 = createBarcode1DCore(code39CoreConfig);

‎src/registry/code93.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ export const code93CoreConfig: Barcode1DCoreConfig = {
1111
const check = p.checkDigit ? 'Y' : 'N';
1212
return `^BA${p.rotation},${p.height},${interp},N,${check}`;
1313
},
14+
// Labelary draws Code 93's start/stop as a square; data shows without
15+
// the (non-displayed) check chars, so no formatHri.
16+
hri: { startStopGlyph: 'square' },
1417
};
1518

1619
export const code93 = createBarcode1DCore(code93CoreConfig);

‎src/registry/hriFormatters.test.ts‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
formatUpcaHri,
66
formatUpceHri,
77
upceData6FromFd,
8-
formatCode39Hri,
8+
formatCode11Hri,
99
formatLogmarsHri,
1010
formatUpcEanExtensionHri,
1111
} from './hriFormatters';
@@ -43,6 +43,21 @@ describe('HRI formatters', () => {
4343
expect(r[0]).toBe('0');
4444
expect(r.slice(1, 7)).toBe('012345');
4545
});
46+
it('is idempotent: NS-prefixed content does not accumulate zeros', () => {
47+
expect(formatUpceHri('0012345')).toBe(formatUpceHri('012345'));
48+
});
49+
});
50+
51+
describe('formatCode11Hri', () => {
52+
it('appends C+K check digits when checkDigit is off (e=N, two checks)', () => {
53+
expect(formatCode11Hri('12345', false)).toBe('1234528');
54+
});
55+
it('appends only the C check digit when checkDigit is on (e=Y, one check)', () => {
56+
expect(formatCode11Hri('12345', true)).toBe('123452');
57+
});
58+
it('defaults to two check digits', () => {
59+
expect(formatCode11Hri('12345')).toBe('1234528');
60+
});
4661
});
4762

4863
describe('upceData6FromFd', () => {
@@ -57,12 +72,6 @@ describe('HRI formatters', () => {
5772
});
5873
});
5974

60-
describe('formatCode39Hri', () => {
61-
it('wraps content with start/stop asterisks', () => {
62-
expect(formatCode39Hri('CODE39')).toBe('*CODE39*');
63-
});
64-
});
65-
6675
describe('formatLogmarsHri', () => {
6776
it('appends mod-43 check char for plain text', () => {
6877
// sum("LOGMARS1") in the charset: L=21,O=24,G=16,M=22,A=10,R=27,S=28,1=1

0 commit comments

Comments
 (0)