From 8d16edaf6cf0da94c09d46f0a97fc738d8649155 Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 23 May 2026 17:53:26 +0200 Subject: [PATCH 1/4] feat(barcode): ^BS UPC/EAN supplement barcode (2- or 5-digit) New standalone object type for the ^BS supplement printed alongside UPC-A / EAN-13. Common uses: 5-digit ISBN price code on books, 2-digit issue number on magazines. The user positions it manually next to the main barcode (separate object), mirroring how the rest of the registry works (one type per ZPL command, no cross-cutting props on existing types). ZPL: single ^BS{o},{h},{f} command for both lengths. Canvas: bwip-js bcid resolved from content.length (ean2 vs ean5) since bwip splits what ZPL unifies. bwip needs explicit includetext + textyalign='above' to match Zebra firmware, which prints the human-readable digits above the bars (unlike main EAN/UPC where they sit below). Bbox math matches Labelary: - printInterpretation=Y: bbox extends UPC_SUPP_TEXT_ZONE_DOTS (18) above the FO anchor; bars sit at obj.y, text above - printInterpretation=N: bbox = bar height (no guard, unlike main EAN/UPC which always reserves 13) The canvas y-shift handles the above-anchor placement in BarcodeObject; tests special-case visualY for the supplement. Validation: ContentSpec gained `validLengths` for soft-warning when the field isn't a valid length set. UPC/EAN supplement spec uses [2, 5]; the PropertiesPanel surfaces an inline amber hint when the length is wrong (no input blocking, since the user has to pass through invalid lengths to reach 5). Parser, generator, locale entries for all 32 locales (best-effort quality tier), support matrix update (BS unsupported -> supported), public roadmap entry moved from Coming soon to Supported. Coverage: round-trip tests for both lengths and rotation; bcid-resolution test in bwipHelpers; contentSpec validation tests; Labelary visual-regression and bbox-sync fixtures for both supps (bars-only N variant so the bwip-vs-Zebra font difference doesn't break the strict pixel diff). Known limitation: bwip-js ean2 renders 19 modules where Zebra prints 20, a 2-dot width delta documented at hasBwipSizeMismatch in labelarySync.test.ts. --- docs/zpl-roadmap.md | 2 +- src/components/Canvas/BarcodeObject.tsx | 23 +++-- src/components/Canvas/KonvaObject.tsx | 1 + src/components/Canvas/bwipConstants.ts | 6 ++ src/components/Canvas/bwipHelpers.test.ts | 24 ++++++ src/components/Canvas/bwipHelpers.ts | 81 ++++++++++++++++-- src/lib/zplCommandSupport.ts | 2 +- src/lib/zplGenerator.test.ts | 28 ++++++ src/lib/zplParser.ts | 2 + src/locales/ar.ts | 8 ++ src/locales/bg.ts | 8 ++ src/locales/cs.ts | 8 ++ src/locales/da.ts | 8 ++ src/locales/de.ts | 8 ++ src/locales/el.ts | 8 ++ src/locales/en.ts | 8 ++ src/locales/es.ts | 8 ++ src/locales/et.ts | 8 ++ src/locales/fa.ts | 8 ++ src/locales/fi.ts | 8 ++ src/locales/fr.ts | 8 ++ src/locales/he.ts | 8 ++ src/locales/hr.ts | 8 ++ src/locales/hu.ts | 8 ++ src/locales/it.ts | 8 ++ src/locales/ja.ts | 8 ++ src/locales/ko.ts | 8 ++ src/locales/lt.ts | 8 ++ src/locales/lv.ts | 8 ++ src/locales/nl.ts | 8 ++ src/locales/no.ts | 8 ++ src/locales/pl.ts | 8 ++ src/locales/pt.ts | 8 ++ src/locales/ro.ts | 8 ++ src/locales/sk.ts | 8 ++ src/locales/sl.ts | 8 ++ src/locales/sr.ts | 8 ++ src/locales/sv.ts | 8 ++ src/locales/tr.ts | 8 ++ src/locales/zh-hans.ts | 8 ++ src/locales/zh-hant.ts | 8 ++ src/registry/barcode1d.tsx | 5 +- src/registry/contentSpec.test.ts | 45 ++++++++++ src/registry/contentSpec.ts | 16 ++++ src/registry/index.ts | 8 +- src/registry/upcEanExtension.tsx | 27 ++++++ src/test/labelarySync.test.ts | 17 +++- src/test/testModels.ts | 16 ++++ src/test/visualRegression.test.ts | 13 ++- .../barcode_upcean_supp2_standard.png | Bin 0 -> 6490 bytes .../barcode_upcean_supp5_standard.png | Bin 0 -> 6529 bytes tests/fixtures/labelary_images/fixtures.json | 22 +++++ tests/fixtures/testCases.ts | 21 +++++ 53 files changed, 595 insertions(+), 20 deletions(-) create mode 100644 src/registry/contentSpec.test.ts create mode 100644 src/registry/upcEanExtension.tsx create mode 100644 tests/fixtures/labelary_images/barcode_upcean_supp2_standard.png create mode 100644 tests/fixtures/labelary_images/barcode_upcean_supp5_standard.png diff --git a/docs/zpl-roadmap.md b/docs/zpl-roadmap.md index 8e62edcb..7213a9da 100644 --- a/docs/zpl-roadmap.md +++ b/docs/zpl-roadmap.md @@ -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 @@ -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 --- diff --git a/src/components/Canvas/BarcodeObject.tsx b/src/components/Canvas/BarcodeObject.tsx index 50918c7f..485aeeda 100644 --- a/src/components/Canvas/BarcodeObject.tsx +++ b/src/components/Canvas/BarcodeObject.tsx @@ -171,10 +171,17 @@ export function BarcodeObject({ // Bitmap is drawn at the bar sub-rectangle of the bbox so the bars // render at their true height. The text-zone padding (which side // depends on rotation) stays empty inside the bbox. - const bw = Math.max(dim.barW, 1); - const bh = Math.max(dim.barH, 1); - const btX = dim.barLeftPx; - const btY = dim.barTopPx; + // ^BS supplements: bwip-js draws the digits INSIDE the bitmap + // (textyalign:'above'), so the bitmap already spans the full bbox + // including the text zone. Drawing it into the bar sub-rect would + // squash both the bars and the text into the bar-height. Render the + // bitmap at full bbox extents instead; the Group is still anchored + // at bbox top-left via dim.barTopPx, so bars land at the FO row. + const fillFullBbox = obj.type === "upcEanExtension"; + const bw = fillFullBbox ? Math.max(dim.w, 1) : Math.max(dim.barW, 1); + const bh = fillFullBbox ? Math.max(dim.h, 1) : Math.max(dim.barH, 1); + const btX = fillFullBbox ? 0 : dim.barLeftPx; + const btY = fillFullBbox ? 0 : dim.barTopPx; // Konva crop prop is undefined when no cropping is needed; passing it // selectively skips bwip's internal padding (e.g. GS1 DataBar's // paddingheight rows) so bars fill the bbox at firmware-correct height. @@ -452,7 +459,13 @@ export function BarcodeObject({ } // ── Other 1D: separate Konva Text below bars ────────────────────────── - const showText = BARCODE_1D_TYPES.has(obj.type) && printInterp; + // upcEanExtension lets bwip-js render the HRI digits above the bars + // (via textyalign:'above'), so there's no Konva text overlay; the + // default render path below covers the bitmap placement. + const showText = + BARCODE_1D_TYPES.has(obj.type) && + printInterp && + obj.type !== "upcEanExtension"; // Rotated 1D: text overlay rotated to match the barcode orientation. const showRotatedText = !isUpright && diff --git a/src/components/Canvas/KonvaObject.tsx b/src/components/Canvas/KonvaObject.tsx index a3b14f92..2f726be0 100644 --- a/src/components/Canvas/KonvaObject.tsx +++ b/src/components/Canvas/KonvaObject.tsx @@ -105,6 +105,7 @@ const BARCODE_TYPES = new Set([ "aztec", "micropdf417", "codablock", + "upcEanExtension", ]); export function KonvaObject(props_: Props) { diff --git a/src/components/Canvas/bwipConstants.ts b/src/components/Canvas/bwipConstants.ts index 6f854032..8b387ebc 100644 --- a/src/components/Canvas/bwipConstants.ts +++ b/src/components/Canvas/bwipConstants.ts @@ -5,6 +5,12 @@ 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. diff --git a/src/components/Canvas/bwipHelpers.test.ts b/src/components/Canvas/bwipHelpers.test.ts index 99f507d3..b0ad6c3f 100644 --- a/src/components/Canvas/bwipHelpers.test.ts +++ b/src/components/Canvas/bwipHelpers.test.ts @@ -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; diff --git a/src/components/Canvas/bwipHelpers.ts b/src/components/Canvas/bwipHelpers.ts index 1185a781..ab681e1b 100644 --- a/src/components/Canvas/bwipHelpers.ts +++ b/src/components/Canvas/bwipHelpers.ts @@ -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"; /** @@ -93,6 +94,9 @@ const BCID: Partial> = { 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; @@ -327,6 +331,33 @@ 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. + // `textyalign: above` flips the human-readable line to sit + // above the bars, matching Zebra firmware (and Labelary) for + // UPC/EAN supplements — bwip-js's default puts it below. + const text = p.content || "00000"; + const variantBcid = text.length === 2 ? "ean2" : "ean5"; + opts = { + bcid: variantBcid, + text, + scale, + height: 10, + // bwip's ean2/ean5 default is bars-only. Zebra firmware + // prints the digits when ^BSf=Y and places them above the + // bars (unlike main EAN/UPC where they sit below). Forward + // the prop so f=N renders bars-only, matching Labelary. + includetext: p.printInterpretation, + textyalign: 'above', + }; + break; + } case "code128": { const p = obj.props; const scale = bwipScale1D(p.moduleWidth, renderScale, renderDpmm); @@ -583,8 +614,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"; // Map the upright "below the bars" zone onto the rotated bbox: it travels // around the rectangle as the symbol rotates. @@ -597,11 +639,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; + } } } @@ -736,6 +790,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 diff --git a/src/lib/zplCommandSupport.ts b/src/lib/zplCommandSupport.ts index 31d921fa..30f7c813 100644 --- a/src/lib/zplCommandSupport.ts +++ b/src/lib/zplCommandSupport.ts @@ -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' }, diff --git a/src/lib/zplGenerator.test.ts b/src/lib/zplGenerator.test.ts index 337955e1..27c743e3 100644 --- a/src/lib/zplGenerator.test.ts +++ b/src/lib/zplGenerator.test.ts @@ -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, diff --git a/src/lib/zplParser.ts b/src/lib/zplParser.ts index 324b53b5..1bce600e 100644 --- a/src/lib/zplParser.ts +++ b/src/lib/zplParser.ts @@ -845,6 +845,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { case "plessey": case "planet": case "postal": + case "upcEanExtension": objects.push( makeObj( fieldType, @@ -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) diff --git a/src/locales/ar.ts b/src/locales/ar.ts index 0f2a9c2e..bbec5b26 100644 --- a/src/locales/ar.ts +++ b/src/locales/ar.ts @@ -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', @@ -333,6 +334,13 @@ const ar = { moduleWidth: 'عرض الوحدة', placeholder: '6 أرقام', }, + upcEanExtension: { + content: 'المحتوى (2 أو 5 أرقام)', + height: 'الارتفاع (نقاط)', + printInterpretation: 'قابل للقراءة', + moduleWidth: 'عرض الوحدة', + placeholder: '2 أو 5 أرقام', + }, interleaved2of5: { content: 'المحتوى', height: 'الارتفاع (نقاط)', diff --git a/src/locales/bg.ts b/src/locales/bg.ts index ed4239cd..c37444ea 100644 --- a/src/locales/bg.ts +++ b/src/locales/bg.ts @@ -25,6 +25,7 @@ const bg = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'Разширение UPC/EAN', interleaved2of5: 'Interleaved 2 от 5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const bg = { moduleWidth: 'Ширина на модул', placeholder: '6 цифри', }, + upcEanExtension: { + content: 'Съдържание (2 или 5 цифри)', + height: 'Височина (точки)', + printInterpretation: 'Четим', + moduleWidth: 'Ширина на модула', + placeholder: '2 или 5 цифри', + }, interleaved2of5: { content: 'Съдържание', height: 'Височина (точки)', diff --git a/src/locales/cs.ts b/src/locales/cs.ts index 05bda2f9..8b0c80f2 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -25,6 +25,7 @@ const cs = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'Rozšíření UPC/EAN', interleaved2of5: 'Interleaved 2 z 5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const cs = { moduleWidth: 'Šířka modulu', placeholder: '6 číslic', }, + upcEanExtension: { + content: 'Obsah (2 nebo 5 číslic)', + height: 'Výška (body)', + printInterpretation: 'Čitelné', + moduleWidth: 'Šířka modulu', + placeholder: '2 nebo 5 číslic', + }, interleaved2of5: { content: 'Obsah', height: 'Výška (body)', diff --git a/src/locales/da.ts b/src/locales/da.ts index 19c651a7..a3c3af47 100644 --- a/src/locales/da.ts +++ b/src/locales/da.ts @@ -25,6 +25,7 @@ const da = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'UPC/EAN-tillæg', interleaved2of5: 'Interleaved 2 af 5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const da = { moduleWidth: 'Modulbredde', placeholder: '6 cifre', }, + upcEanExtension: { + content: 'Indhold (2 eller 5 cifre)', + height: 'Højde (punkter)', + printInterpretation: 'Læsbar', + moduleWidth: 'Modulbredde', + placeholder: '2 eller 5 cifre', + }, interleaved2of5: { content: 'Indhold', height: 'Højde (punkter)', diff --git a/src/locales/de.ts b/src/locales/de.ts index 77a15d09..067833f7 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -25,6 +25,7 @@ const de = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'UPC/EAN-Erweiterung', interleaved2of5: 'Interleaved 2 of 5', code93: 'Code 93', pdf417: 'PDF417', @@ -354,6 +355,13 @@ const de = { moduleWidth: 'Modulbreite', placeholder: '6 Ziffern', }, + upcEanExtension: { + content: 'Inhalt (2 oder 5 Ziffern)', + height: 'Höhe (Punkte)', + printInterpretation: 'Klartext', + moduleWidth: 'Modulbreite', + placeholder: '2 oder 5 Ziffern', + }, interleaved2of5: { content: 'Inhalt', height: 'Höhe (Punkte)', diff --git a/src/locales/el.ts b/src/locales/el.ts index f4522fd2..4dab1001 100644 --- a/src/locales/el.ts +++ b/src/locales/el.ts @@ -25,6 +25,7 @@ const el = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'Επέκταση UPC/EAN', interleaved2of5: 'Interleaved 2 από 5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const el = { moduleWidth: 'Πλάτος μονάδας', placeholder: '6 ψηφία', }, + upcEanExtension: { + content: 'Περιεχόμενο (2 ή 5 ψηφία)', + height: 'Ύψος (κουκκίδες)', + printInterpretation: 'Αναγνώσιμο', + moduleWidth: 'Πλάτος μονάδας', + placeholder: '2 ή 5 ψηφία', + }, interleaved2of5: { content: 'Περιεχόμενο', height: 'Ύψος (κουκκίδες)', diff --git a/src/locales/en.ts b/src/locales/en.ts index 96c56ff0..e5a78e36 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -25,6 +25,7 @@ const en = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'UPC/EAN extension', interleaved2of5: 'Interleaved 2 of 5', code93: 'Code 93', pdf417: 'PDF417', @@ -354,6 +355,13 @@ const en = { moduleWidth: 'Module width', placeholder: '6 digits', }, + upcEanExtension: { + content: 'Content (2 or 5 digits)', + height: 'Height (dots)', + printInterpretation: 'Human readable', + moduleWidth: 'Module width', + placeholder: '2 or 5 digits', + }, interleaved2of5: { content: 'Content', height: 'Height (dots)', diff --git a/src/locales/es.ts b/src/locales/es.ts index 06dcbb1f..1935149c 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -25,6 +25,7 @@ const es = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'Extensión UPC/EAN', interleaved2of5: 'Intercalado 2 de 5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const es = { moduleWidth: 'Ancho módulo', placeholder: '6 dígitos', }, + upcEanExtension: { + content: 'Contenido (2 o 5 dígitos)', + height: 'Altura (puntos)', + printInterpretation: 'Legible', + moduleWidth: 'Ancho de módulo', + placeholder: '2 o 5 dígitos', + }, interleaved2of5: { content: 'Contenido', height: 'Altura (puntos)', diff --git a/src/locales/et.ts b/src/locales/et.ts index c203c02d..3d8cba7f 100644 --- a/src/locales/et.ts +++ b/src/locales/et.ts @@ -25,6 +25,7 @@ const et = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'UPC/EAN laiendus', interleaved2of5: 'Interleaved 2/5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const et = { moduleWidth: 'Mooduli laius', placeholder: '6 numbrit', }, + upcEanExtension: { + content: 'Sisu (2 või 5 numbrit)', + height: 'Kõrgus (punktid)', + printInterpretation: 'Loetav', + moduleWidth: 'Mooduli laius', + placeholder: '2 või 5 numbrit', + }, interleaved2of5: { content: 'Sisu', height: 'Kõrgus (punkti)', diff --git a/src/locales/fa.ts b/src/locales/fa.ts index 4c13ce56..16d3c89b 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -25,6 +25,7 @@ const fa = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'افزونه UPC/EAN', interleaved2of5: 'Interleaved 2 از 5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const fa = { moduleWidth: 'عرض ماژول', placeholder: '6 رقم', }, + upcEanExtension: { + content: 'محتوا (۲ یا ۵ رقم)', + height: 'ارتفاع (نقطه)', + printInterpretation: 'خوانا', + moduleWidth: 'عرض ماژول', + placeholder: '۲ یا ۵ رقم', + }, interleaved2of5: { content: 'محتوا', height: 'ارتفاع (نقطه)', diff --git a/src/locales/fi.ts b/src/locales/fi.ts index 37119724..e11524cb 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -25,6 +25,7 @@ const fi = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'UPC/EAN-lisäys', interleaved2of5: 'Interleaved 2/5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const fi = { moduleWidth: 'Moduulin leveys', placeholder: '6 numeroa', }, + upcEanExtension: { + content: 'Sisältö (2 tai 5 numeroa)', + height: 'Korkeus (pisteet)', + printInterpretation: 'Luettava', + moduleWidth: 'Moduulin leveys', + placeholder: '2 tai 5 numeroa', + }, interleaved2of5: { content: 'Sisältö', height: 'Korkeus (pistettä)', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 8302b13f..3d0abb35 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -25,6 +25,7 @@ const fr = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'Extension UPC/EAN', interleaved2of5: 'Entrelacé 2 parmi 5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const fr = { moduleWidth: 'Largeur module', placeholder: '6 chiffres', }, + upcEanExtension: { + content: 'Contenu (2 ou 5 chiffres)', + height: 'Hauteur (points)', + printInterpretation: 'Lisible', + moduleWidth: 'Largeur de module', + placeholder: '2 ou 5 chiffres', + }, interleaved2of5: { content: 'Contenu', height: 'Hauteur (points)', diff --git a/src/locales/he.ts b/src/locales/he.ts index 6582ae68..5f8005a2 100644 --- a/src/locales/he.ts +++ b/src/locales/he.ts @@ -25,6 +25,7 @@ const he = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'הרחבת UPC/EAN', interleaved2of5: 'Interleaved 2 מ-5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const he = { moduleWidth: 'רוחב מודול', placeholder: '6 ספרות', }, + upcEanExtension: { + content: 'תוכן (2 או 5 ספרות)', + height: 'גובה (נקודות)', + printInterpretation: 'קריא', + moduleWidth: 'רוחב מודול', + placeholder: '2 או 5 ספרות', + }, interleaved2of5: { content: 'תוכן', height: 'גובה (נקודות)', diff --git a/src/locales/hr.ts b/src/locales/hr.ts index e499bd92..60a09782 100644 --- a/src/locales/hr.ts +++ b/src/locales/hr.ts @@ -25,6 +25,7 @@ const hr = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'UPC/EAN proširenje', interleaved2of5: 'Interleaved 2 od 5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const hr = { moduleWidth: 'Širina modula', placeholder: '6 znamenki', }, + upcEanExtension: { + content: 'Sadržaj (2 ili 5 znamenki)', + height: 'Visina (točke)', + printInterpretation: 'Čitljivo', + moduleWidth: 'Širina modula', + placeholder: '2 ili 5 znamenki', + }, interleaved2of5: { content: 'Sadržaj', height: 'Visina (točke)', diff --git a/src/locales/hu.ts b/src/locales/hu.ts index b27dfb52..1fda5dd1 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -25,6 +25,7 @@ const hu = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'UPC/EAN kiegészítés', interleaved2of5: 'Interleaved 2/5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const hu = { moduleWidth: 'Modulszélesség', placeholder: '6 számjegy', }, + upcEanExtension: { + content: 'Tartalom (2 vagy 5 számjegy)', + height: 'Magasság (pont)', + printInterpretation: 'Olvasható', + moduleWidth: 'Modulszélesség', + placeholder: '2 vagy 5 számjegy', + }, interleaved2of5: { content: 'Tartalom', height: 'Magasság (pont)', diff --git a/src/locales/it.ts b/src/locales/it.ts index e6f6d0e4..eaf35f54 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -25,6 +25,7 @@ const it = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'Estensione UPC/EAN', interleaved2of5: 'Interleaved 2 di 5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const it = { moduleWidth: 'Larghezza modulo', placeholder: '6 cifre', }, + upcEanExtension: { + content: 'Contenuto (2 o 5 cifre)', + height: 'Altezza (punti)', + printInterpretation: 'Leggibile', + moduleWidth: 'Larghezza modulo', + placeholder: '2 o 5 cifre', + }, interleaved2of5: { content: 'Contenuto', height: 'Altezza (punti)', diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 1b5e0d64..d03729d9 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -25,6 +25,7 @@ const ja = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'UPC/EAN 拡張', interleaved2of5: 'インターリーブド 2/5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const ja = { moduleWidth: 'モジュール幅', placeholder: '6桁', }, + upcEanExtension: { + content: 'コンテンツ (2 桁または 5 桁)', + height: '高さ (ドット)', + printInterpretation: '人間可読', + moduleWidth: 'モジュール幅', + placeholder: '2 桁または 5 桁', + }, interleaved2of5: { content: '内容', height: '高さ(ドット)', diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 842a9ca2..a908b821 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -25,6 +25,7 @@ const ko = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'UPC/EAN 확장', interleaved2of5: '인터리브드 2/5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const ko = { moduleWidth: '모듈 폭', placeholder: '6자리', }, + upcEanExtension: { + content: '내용 (2자리 또는 5자리)', + height: '높이 (도트)', + printInterpretation: '판독 가능', + moduleWidth: '모듈 너비', + placeholder: '2자리 또는 5자리', + }, interleaved2of5: { content: '내용', height: '높이 (도트)', diff --git a/src/locales/lt.ts b/src/locales/lt.ts index 31fb7e1b..399f34af 100644 --- a/src/locales/lt.ts +++ b/src/locales/lt.ts @@ -25,6 +25,7 @@ const lt = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'UPC/EAN papildymas', interleaved2of5: 'Interleaved 2 iš 5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const lt = { moduleWidth: 'Modulio plotis', placeholder: '6 skaitmenys', }, + upcEanExtension: { + content: 'Turinys (2 arba 5 skaitmenys)', + height: 'Aukštis (taškai)', + printInterpretation: 'Skaitomas', + moduleWidth: 'Modulio plotis', + placeholder: '2 arba 5 skaitmenys', + }, interleaved2of5: { content: 'Turinys', height: 'Aukštis (taškai)', diff --git a/src/locales/lv.ts b/src/locales/lv.ts index 7f5af647..120668ee 100644 --- a/src/locales/lv.ts +++ b/src/locales/lv.ts @@ -25,6 +25,7 @@ const lv = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'UPC/EAN paplašinājums', interleaved2of5: 'Interleaved 2 no 5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const lv = { moduleWidth: 'Moduļa platums', placeholder: '6 cipari', }, + upcEanExtension: { + content: 'Saturs (2 vai 5 cipari)', + height: 'Augstums (punkti)', + printInterpretation: 'Lasāms', + moduleWidth: 'Moduļa platums', + placeholder: '2 vai 5 cipari', + }, interleaved2of5: { content: 'Saturs', height: 'Augstums (punkti)', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index d74df55d..418bd71e 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -25,6 +25,7 @@ const nl = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'UPC/EAN-uitbreiding', interleaved2of5: 'Interleaved 2 van 5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const nl = { moduleWidth: 'Modulebreedte', placeholder: '6 cijfers', }, + upcEanExtension: { + content: 'Inhoud (2 of 5 cijfers)', + height: 'Hoogte (punten)', + printInterpretation: 'Leesbaar', + moduleWidth: 'Modulebreedte', + placeholder: '2 of 5 cijfers', + }, interleaved2of5: { content: 'Inhoud', height: 'Hoogte (punten)', diff --git a/src/locales/no.ts b/src/locales/no.ts index 765af1fe..cc629784 100644 --- a/src/locales/no.ts +++ b/src/locales/no.ts @@ -25,6 +25,7 @@ const no = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'UPC/EAN-tillegg', interleaved2of5: 'Interleaved 2 av 5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const no = { moduleWidth: 'Modulbredde', placeholder: '6 siffer', }, + upcEanExtension: { + content: 'Innhold (2 eller 5 sifre)', + height: 'Høyde (punkter)', + printInterpretation: 'Lesbar', + moduleWidth: 'Modulbredde', + placeholder: '2 eller 5 sifre', + }, interleaved2of5: { content: 'Innhold', height: 'Høyde (punkter)', diff --git a/src/locales/pl.ts b/src/locales/pl.ts index 1a4254e6..a6bbc3db 100644 --- a/src/locales/pl.ts +++ b/src/locales/pl.ts @@ -25,6 +25,7 @@ const pl = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'Rozszerzenie UPC/EAN', interleaved2of5: 'Interleaved 2 z 5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const pl = { moduleWidth: 'Szerokość modułu', placeholder: '6 cyfr', }, + upcEanExtension: { + content: 'Treść (2 lub 5 cyfr)', + height: 'Wysokość (punkty)', + printInterpretation: 'Czytelne', + moduleWidth: 'Szerokość modułu', + placeholder: '2 lub 5 cyfr', + }, interleaved2of5: { content: 'Zawartość', height: 'Wysokość (punkty)', diff --git a/src/locales/pt.ts b/src/locales/pt.ts index e71486cc..989485a7 100644 --- a/src/locales/pt.ts +++ b/src/locales/pt.ts @@ -25,6 +25,7 @@ const pt = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'Extensão UPC/EAN', interleaved2of5: 'Intercalado 2 de 5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const pt = { moduleWidth: 'Largura módulo', placeholder: '6 dígitos', }, + upcEanExtension: { + content: 'Conteúdo (2 ou 5 dígitos)', + height: 'Altura (pontos)', + printInterpretation: 'Legível', + moduleWidth: 'Largura do módulo', + placeholder: '2 ou 5 dígitos', + }, interleaved2of5: { content: 'Conteúdo', height: 'Altura (pontos)', diff --git a/src/locales/ro.ts b/src/locales/ro.ts index 4daca2b7..8f5c8770 100644 --- a/src/locales/ro.ts +++ b/src/locales/ro.ts @@ -25,6 +25,7 @@ const ro = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'Extensie UPC/EAN', interleaved2of5: 'Interleaved 2 din 5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const ro = { moduleWidth: 'Lățime modul', placeholder: '6 cifre', }, + upcEanExtension: { + content: 'Conținut (2 sau 5 cifre)', + height: 'Înălțime (puncte)', + printInterpretation: 'Lizibil', + moduleWidth: 'Lățime modul', + placeholder: '2 sau 5 cifre', + }, interleaved2of5: { content: 'Conținut', height: 'Înălțime (puncte)', diff --git a/src/locales/sk.ts b/src/locales/sk.ts index 37cb4e78..3999aa46 100644 --- a/src/locales/sk.ts +++ b/src/locales/sk.ts @@ -25,6 +25,7 @@ const sk = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'Rozšírenie UPC/EAN', interleaved2of5: 'Interleaved 2 z 5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const sk = { moduleWidth: 'Šírka modulu', placeholder: '6 číslic', }, + upcEanExtension: { + content: 'Obsah (2 alebo 5 číslic)', + height: 'Výška (body)', + printInterpretation: 'Čitateľné', + moduleWidth: 'Šírka modulu', + placeholder: '2 alebo 5 číslic', + }, interleaved2of5: { content: 'Obsah', height: 'Výška (body)', diff --git a/src/locales/sl.ts b/src/locales/sl.ts index f19b1eff..07e2eb61 100644 --- a/src/locales/sl.ts +++ b/src/locales/sl.ts @@ -25,6 +25,7 @@ const sl = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'UPC/EAN razširitev', interleaved2of5: 'Interleaved 2 od 5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const sl = { moduleWidth: 'Širina modula', placeholder: '6 števk', }, + upcEanExtension: { + content: 'Vsebina (2 ali 5 števk)', + height: 'Višina (točke)', + printInterpretation: 'Berljivo', + moduleWidth: 'Širina modula', + placeholder: '2 ali 5 števk', + }, interleaved2of5: { content: 'Vsebina', height: 'Višina (pike)', diff --git a/src/locales/sr.ts b/src/locales/sr.ts index 72dceb00..ba2830f4 100644 --- a/src/locales/sr.ts +++ b/src/locales/sr.ts @@ -25,6 +25,7 @@ const sr = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'UPC/EAN proširenje', interleaved2of5: 'Interleaved 2 од 5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const sr = { moduleWidth: 'Ширина модула', placeholder: '6 цифара', }, + upcEanExtension: { + content: 'Sadržaj (2 ili 5 cifara)', + height: 'Visina (tačke)', + printInterpretation: 'Čitljivo', + moduleWidth: 'Širina modula', + placeholder: '2 ili 5 cifara', + }, interleaved2of5: { content: 'Садржај', height: 'Висина (тачке)', diff --git a/src/locales/sv.ts b/src/locales/sv.ts index a06ce796..ed8cff45 100644 --- a/src/locales/sv.ts +++ b/src/locales/sv.ts @@ -25,6 +25,7 @@ const sv = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'UPC/EAN-tillägg', interleaved2of5: 'Interleaved 2 av 5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const sv = { moduleWidth: 'Modulbredd', placeholder: '6 siffror', }, + upcEanExtension: { + content: 'Innehåll (2 eller 5 siffror)', + height: 'Höjd (punkter)', + printInterpretation: 'Läsbar', + moduleWidth: 'Modulbredd', + placeholder: '2 eller 5 siffror', + }, interleaved2of5: { content: 'Innehåll', height: 'Höjd (punkter)', diff --git a/src/locales/tr.ts b/src/locales/tr.ts index 913815f7..300a46e2 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -25,6 +25,7 @@ const tr = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'UPC/EAN eki', interleaved2of5: 'Interleaved 2/5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const tr = { moduleWidth: 'Modül genişliği', placeholder: '6 rakam', }, + upcEanExtension: { + content: 'İçerik (2 veya 5 hane)', + height: 'Yükseklik (nokta)', + printInterpretation: 'Okunabilir', + moduleWidth: 'Modül genişliği', + placeholder: '2 veya 5 hane', + }, interleaved2of5: { content: 'İçerik', height: 'Yükseklik (nokta)', diff --git a/src/locales/zh-hans.ts b/src/locales/zh-hans.ts index 93726888..19086530 100644 --- a/src/locales/zh-hans.ts +++ b/src/locales/zh-hans.ts @@ -25,6 +25,7 @@ const zhHans = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'UPC/EAN 扩展', interleaved2of5: '交叉 2/5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const zhHans = { moduleWidth: '模块宽度', placeholder: '6 位数字', }, + upcEanExtension: { + content: '内容 (2 位或 5 位数字)', + height: '高度 (点)', + printInterpretation: '可读', + moduleWidth: '模块宽度', + placeholder: '2 位或 5 位数字', + }, interleaved2of5: { content: '内容', height: '高度(点)', diff --git a/src/locales/zh-hant.ts b/src/locales/zh-hant.ts index 49101773..736e9e62 100644 --- a/src/locales/zh-hant.ts +++ b/src/locales/zh-hant.ts @@ -25,6 +25,7 @@ const zhHant = { upca: 'UPC-A', ean8: 'EAN-8', upce: 'UPC-E', + upcEanExtension: 'UPC/EAN 擴充', interleaved2of5: '交叉 2/5', code93: 'Code 93', pdf417: 'PDF417', @@ -333,6 +334,13 @@ const zhHant = { moduleWidth: '模組寬度', placeholder: '6 位數字', }, + upcEanExtension: { + content: '內容 (2 位或 5 位數字)', + height: '高度 (點)', + printInterpretation: '可讀', + moduleWidth: '模組寬度', + placeholder: '2 位或 5 位數字', + }, interleaved2of5: { content: '內容', height: '高度(點)', diff --git a/src/registry/barcode1d.tsx b/src/registry/barcode1d.tsx index aeaae6e4..7e13b950 100644 --- a/src/registry/barcode1d.tsx +++ b/src/registry/barcode1d.tsx @@ -4,7 +4,7 @@ import type { Translations } from '../locales'; import { inputCls, labelCls } from '../components/Properties/styles'; import { fieldPos, fdFieldFor } from './zplHelpers'; import { commitBarcodeWidthHeightTransform } from './transformHelpers'; -import { filterContent, type ContentSpec } from './contentSpec'; +import { filterContent, hasValidLength, type ContentSpec } from './contentSpec'; import { type ZplRotation } from './rotation'; import { RotationSelect } from '../components/Properties/RotationSelect'; import { NumberInput } from '../components/Properties/NumberInput'; @@ -113,6 +113,9 @@ export function createBarcode1D(config: Barcode1DConfig): ObjectTypeDefinition onChange({ content: filterContent(e.target.value, config.contentSpec) })} /> + {!hasValidLength(p.content, config.contentSpec) && loc.placeholder && ( +

{loc.placeholder}

+ )} { + it('strips characters outside the charset', () => { + const spec: ContentSpec = { charset: '0-9' }; + expect(filterContent('A1B2C3', spec)).toBe('123'); + }); + + it('truncates to maxLength', () => { + const spec: ContentSpec = { charset: '0-9', maxLength: 3 }; + expect(filterContent('123456789', spec)).toBe('123'); + }); + + it('returns raw when no spec', () => { + expect(filterContent('anything')).toBe('anything'); + }); +}); + +describe('hasValidLength', () => { + it('returns true when no spec is provided', () => { + expect(hasValidLength('anything')).toBe(true); + }); + + it('returns true when spec has no validLengths constraint', () => { + expect(hasValidLength('whatever', { charset: '0-9' })).toBe(true); + }); + + it('returns true for empty content (not yet typed)', () => { + expect(hasValidLength('', { charset: '0-9', validLengths: [2, 5] })).toBe(true); + }); + + it('returns true when content length matches an allowed value', () => { + const spec: ContentSpec = { charset: '0-9', validLengths: [2, 5] }; + expect(hasValidLength('42', spec)).toBe(true); + expect(hasValidLength('12345', spec)).toBe(true); + }); + + it('returns false for in-between lengths (UPC/EAN supplement 1/3/4)', () => { + const spec: ContentSpec = { charset: '0-9', validLengths: [2, 5] }; + expect(hasValidLength('1', spec)).toBe(false); + expect(hasValidLength('123', spec)).toBe(false); + expect(hasValidLength('1234', spec)).toBe(false); + }); +}); diff --git a/src/registry/contentSpec.ts b/src/registry/contentSpec.ts index f33940bb..c2118a36 100644 --- a/src/registry/contentSpec.ts +++ b/src/registry/contentSpec.ts @@ -10,6 +10,13 @@ export interface ContentSpec { /** Character-class body (no surrounding `[]`), e.g. `0-9` or `0-9A-Z\\-. $/+%`. */ charset: string; maxLength?: number; + /** Set of exact lengths the symbology accepts (e.g. [2, 5] for + * UPC/EAN supplements). Used for soft validation in the + * PropertiesPanel — typed input is not blocked (the user has to + * pass through 1/3/4 chars to reach 5), but lengths outside the + * set surface an inline warning so the user notices before the + * printer rejects the field. */ + validLengths?: readonly number[]; } const rejectCache = new WeakMap(); @@ -28,3 +35,12 @@ export function filterContent(raw: string, spec?: ContentSpec): string { const filtered = raw.replace(rejectPattern(spec), ''); return spec.maxLength ? filtered.slice(0, spec.maxLength) : filtered; } + +/** True when `content`'s length matches one of `spec.validLengths`, + * or when `validLengths` is unset (no length constraint). Empty + * string is treated as "not yet committed" and returns true so the + * Properties panel doesn't warn on a fresh field. */ +export function hasValidLength(content: string, spec?: ContentSpec): boolean { + if (!spec?.validLengths || content === '') return true; + return spec.validLengths.includes(content.length); +} diff --git a/src/registry/index.ts b/src/registry/index.ts index a5854aa8..ffb5dbd0 100644 --- a/src/registry/index.ts +++ b/src/registry/index.ts @@ -59,6 +59,8 @@ import { micropdf417 } from './micropdf417.tsx'; import type { MicroPdf417Props } from './micropdf417.tsx'; import { codablock } from './codablock.tsx'; import type { CodablockProps } from './codablock.tsx'; +import { upcEanExtension } from './upcEanExtension.tsx'; +import type { UpcEanExtensionProps } from './upcEanExtension.tsx'; /** Single-branch shape for one registry type: the common base plus a * literal `type` discriminator and that type's props. Used to compose @@ -99,12 +101,13 @@ export type LeafObject = | Leaf<'postal', PostalProps> | Leaf<'aztec', AztecProps> | Leaf<'micropdf417', MicroPdf417Props> - | Leaf<'codablock', CodablockProps>; + | Leaf<'codablock', CodablockProps> + | Leaf<'upcEanExtension', UpcEanExtensionProps>; export const BARCODE_1D_TYPES = new Set([ 'code128', 'code39', 'ean13', 'ean8', 'upca', 'upce', 'interleaved2of5', 'code93', 'code11', 'industrial2of5', 'standard2of5', 'codabar', 'logmars', 'msi', 'plessey', - 'gs1databar', 'planet', 'postal', + 'gs1databar', 'planet', 'postal', 'upcEanExtension', ]); export const STACKED_2D_TYPES = new Set(['pdf417', 'micropdf417', 'codablock']); @@ -126,6 +129,7 @@ export const ObjectRegistry: Record> = { gs1databar, ean8, upce, + upcEanExtension, logmars, code93, codabar, diff --git a/src/registry/upcEanExtension.tsx b/src/registry/upcEanExtension.tsx new file mode 100644 index 00000000..610f3aaa --- /dev/null +++ b/src/registry/upcEanExtension.tsx @@ -0,0 +1,27 @@ +import { createBarcode1D } from './barcode1d'; +export type { Barcode1DProps as UpcEanExtensionProps } from './barcode1d'; + +/** UPC/EAN extension barcode (^BS) — the 2- or 5-digit supplement + * printed alongside a UPC-A / EAN-13. Common uses: 5-digit ISBN + * price code on books, 2-digit issue number on magazines. + * + * Standalone object by design: the user positions it manually next + * to the main barcode. Same `^BSo,h,f` ZPL syntax for both lengths; + * the canvas renderer picks `ean2` vs `ean5` from `content.length`. + * validLengths surfaces an inline warning when the field isn't 2 + * or 5 digits (bwip-js / the printer reject other lengths). + * Default '51999' = $19.99 in the ISBN price-code form, the + * dominant use case. */ +export const upcEanExtension = createBarcode1D({ + label: 'UPC/EAN extension', + icon: 'EXT', + defaultContent: '51999', + hasCheckDigit: false, + locale: (t) => t.registry.upcEanExtension, + group: 'code-1d', + contentSpec: { charset: '0-9', maxLength: 5, validLengths: [2, 5] }, + zplCommand: (p) => { + const interp = p.printInterpretation ? 'Y' : 'N'; + return `^BS${p.rotation},${p.height},${interp}`; + }, +}); diff --git a/src/test/labelarySync.test.ts b/src/test/labelarySync.test.ts index 536b70c4..616d3d9b 100644 --- a/src/test/labelarySync.test.ts +++ b/src/test/labelarySync.test.ts @@ -6,6 +6,7 @@ import { buildBwipOptions, getDisplaySize, } from "../components/Canvas/bwipHelpers"; +import { UPC_SUPP_TEXT_ZONE_DOTS } from "../components/Canvas/bwipConstants"; import { ObjectRegistry } from "../registry"; import { objectRotation } from "../registry/rotation"; import { defined } from "./helpers"; @@ -110,6 +111,12 @@ describe("Labelary Sync - Canvas Dimension Logic", () => { if (obj.type === "qrcode") { visualY += 10; } + if (obj.type === "upcEanExtension" && obj.props.printInterpretation) { + // ^BS bbox top sits above the FO anchor by the supplement + // text zone when printInterpretation=Y; the bars themselves + // still start at obj.y. With f=N the bbox starts at FO. + visualY -= UPC_SUPP_TEXT_ZONE_DOTS; + } } // EAN/UPC have extended guard bars whose visible extent rotates with the @@ -140,7 +147,15 @@ describe("Labelary Sync - Canvas Dimension Logic", () => { // plessey applies an empirical width ratio. The bitmap inside still // looks visually distorted (kept as a known limitation in // visualRegression.test.ts), but the bbox dimensions now match. - const hasBwipSizeMismatch = false; + // bwip-js renders the 2-digit ^BS supplement (ean2) at 19 modules, + // while Zebra firmware reserves 20 modules. The 2-dot delta is a + // fixed encoder difference; per-module post-stretching would distort + // the bar pattern more than the 2 dots are worth in a layout tool. + // Document the divergence and skip the strict width check for this + // single fixture. + const hasBwipSizeMismatch = + obj.type === "upcEanExtension" && + ((obj.props as { content?: string }).content ?? "").length === 2; // GS1 Databar variant 7 (Expanded Stacked) is segments-dependent; bwip-natural // height differs from spec and we don't yet have a per-segment formula. const isGs1Sym7 = obj.type === "gs1databar" && obj.props.symbology === 7; diff --git a/src/test/testModels.ts b/src/test/testModels.ts index bfc57aef..70ba9f44 100644 --- a/src/test/testModels.ts +++ b/src/test/testModels.ts @@ -449,4 +449,20 @@ export const testModels: Record = { rotation: 0, props: { content: "123456789012", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false, rotation: "B" }, }, + barcode_upcean_supp5_standard: { + id: "supp1", + type: "upcEanExtension", + x: 50, + y: 50, + rotation: 0, + props: { content: "51999", height: 80, moduleWidth: 2, printInterpretation: false, checkDigit: false, rotation: "N" }, + }, + barcode_upcean_supp2_standard: { + id: "supp2", + type: "upcEanExtension", + x: 50, + y: 50, + rotation: 0, + props: { content: "42", height: 80, moduleWidth: 2, printInterpretation: false, checkDigit: false, rotation: "N" }, + }, }; diff --git a/src/test/visualRegression.test.ts b/src/test/visualRegression.test.ts index dc7e55f9..a2c60896 100644 --- a/src/test/visualRegression.test.ts +++ b/src/test/visualRegression.test.ts @@ -12,7 +12,7 @@ import { buildBwipOptions, getDisplaySize, } from "../components/Canvas/bwipHelpers"; -import { QR_FO_Y_OFFSET_DOTS } from "../components/Canvas/bwipConstants"; +import { QR_FO_Y_OFFSET_DOTS, UPC_SUPP_TEXT_ZONE_DOTS } from "../components/Canvas/bwipConstants"; const FIXTURES_DIR = path.resolve( process.cwd(), @@ -120,8 +120,15 @@ describe("Visual Regression - bwip-js vs Labelary", () => { ); // Zebra firmware renders ^FO-positioned QR codes with a +10 dot Y offset. - // Match production BarcodeObject.tsx behaviour. - const drawY = obj.type === "qrcode" ? obj.y + QR_FO_Y_OFFSET_DOTS : obj.y; + // Match production BarcodeObject.tsx behaviour. UPC/EAN supplements + // render the human-readable digits ABOVE the bars, so the bitmap's + // top edge sits text-zone above the FO anchor. + const drawY = + obj.type === "qrcode" + ? obj.y + QR_FO_Y_OFFSET_DOTS + : obj.type === "upcEanExtension" && obj.props.printInterpretation + ? obj.y - UPC_SUPP_TEXT_ZONE_DOTS + : obj.y; // Bars draw at FO; bbox extends in the text-zone direction without // shifting the bar pattern. barLeftPx/barTopPx describe where the // bars sit inside the bbox, but the bitmap itself anchors at obj.x/y. diff --git a/tests/fixtures/labelary_images/barcode_upcean_supp2_standard.png b/tests/fixtures/labelary_images/barcode_upcean_supp2_standard.png new file mode 100644 index 0000000000000000000000000000000000000000..0e091f4ec4ac8cb5e7aaef307d88a67461c9723a GIT binary patch literal 6490 zcmeAS@N?(olHy`uVBq!ia0y~yVAcU)4xj+TVV`q-HcCzq?<;iM}uWFIgDn5(b8eGR2;1hMk~qD#=&T#Xtb?3+D0Dj z8;o|DM!SlmUF6Y`gV7O_(UGFj0W|6k95OM~%!kpdxWr2PDtwW_%hX-83pa8Z4v9VKf_zmJXw(;%IF! zT1k#J4n`YAqiw~}Hu7lSV6@9L+EpCwB9D$7jE Date: Sun, 24 May 2026 01:07:14 +0200 Subject: [PATCH 2/4] refactor(barcode): unify rotated HRI anchor, render ^BS text via overlay Three changes that all centre on the rotated 1D HRI overlay: 1. Extract getRotatedTextAnchor in bwipHelpers. The previous per-rotation sideX math (-textGap / w+textGap) anchored against the bbox edge, which silently double-counted the firmware text zone (EAN/UPC: 13 dots, logmars: 20 dots). The helper anchors against the bar sub-rectangle so the gap is exactly textGap regardless of which side the zone sits on. Fixes a pre-existing spacing bug on rotated logmars + ean13/upca/ean8/upce. 2. Switch ^BS (upcEanExtension) HRI from bwip-embedded text to a Konva overlay, matching the logmars pattern. bwip's includetext bakes the text into the bitmap, which then rotates as a unit and lands on the wrong side for R/B/I; the overlay positions correctly via the helper. Drops the fillFullBbox special case and the upcEanExtension exclusion from showText. 3. Per-type above-bars gap. logmars keeps its wider 10-dot air gap (LOGMARS_TEXT_ABOVE_GAP_DOTS); ^BS sits very tight to the bars (UPC_SUPP_TEXT_ABOVE_GAP_DOTS = 2) per Labelary. Also anchor the upright text-above against btY so the firmware-reserved text zone isn't added on top of the visual gap. HRI text now renders bold across all 1D types, slightly closer to the Zebra HRI weight than plain Courier New. --- src/components/Canvas/BarcodeObject.tsx | 119 ++++++++++++++---------- src/components/Canvas/bwipConstants.ts | 5 + src/components/Canvas/bwipHelpers.ts | 68 ++++++++++++-- 3 files changed, 135 insertions(+), 57 deletions(-) diff --git a/src/components/Canvas/BarcodeObject.tsx b/src/components/Canvas/BarcodeObject.tsx index 485aeeda..69ad8352 100644 --- a/src/components/Canvas/BarcodeObject.tsx +++ b/src/components/Canvas/BarcodeObject.tsx @@ -9,6 +9,7 @@ import { selectionHandlers, type KonvaObjectProps } from "./konvaObjectProps"; import { buildBwipOptions, getDisplaySize, + getRotatedTextAnchor, eanCheckDigit, upceCheckDigit, get1DBwipScale, @@ -21,6 +22,7 @@ import { QR_FO_Y_OFFSET_DOTS, QR_FT_MODULE_OFFSET, LOGMARS_TEXT_ABOVE_GAP_DOTS, + UPC_SUPP_TEXT_ABOVE_GAP_DOTS, EAN_UPC_TYPES, } from "./bwipConstants"; @@ -171,17 +173,10 @@ export function BarcodeObject({ // Bitmap is drawn at the bar sub-rectangle of the bbox so the bars // render at their true height. The text-zone padding (which side // depends on rotation) stays empty inside the bbox. - // ^BS supplements: bwip-js draws the digits INSIDE the bitmap - // (textyalign:'above'), so the bitmap already spans the full bbox - // including the text zone. Drawing it into the bar sub-rect would - // squash both the bars and the text into the bar-height. Render the - // bitmap at full bbox extents instead; the Group is still anchored - // at bbox top-left via dim.barTopPx, so bars land at the FO row. - const fillFullBbox = obj.type === "upcEanExtension"; - const bw = fillFullBbox ? Math.max(dim.w, 1) : Math.max(dim.barW, 1); - const bh = fillFullBbox ? Math.max(dim.h, 1) : Math.max(dim.barH, 1); - const btX = fillFullBbox ? 0 : dim.barLeftPx; - const btY = fillFullBbox ? 0 : dim.barTopPx; + const bw = Math.max(dim.barW, 1); + const bh = Math.max(dim.barH, 1); + const btX = dim.barLeftPx; + const btY = dim.barTopPx; // Konva crop prop is undefined when no cropping is needed; passing it // selectively skips bwip's internal padding (e.g. GS1 DataBar's // paddingheight rows) so bars fill the bbox at firmware-correct height. @@ -242,7 +237,7 @@ export function BarcodeObject({ width={ldW} text={allDigits[0]} fontSize={textFontSize} - fontFamily="'Courier New', monospace" + fontFamily="'Courier New', monospace" fontStyle="bold" align="center" wrap="none" fill="#000000" @@ -255,7 +250,7 @@ export function BarcodeObject({ width={halfW13} text={allDigits.slice(1, 7)} fontSize={textFontSize} - fontFamily="'Courier New', monospace" + fontFamily="'Courier New', monospace" fontStyle="bold" align="center" wrap="none" fill="#000000" @@ -268,7 +263,7 @@ export function BarcodeObject({ width={halfW13} text={allDigits.slice(7, 13)} fontSize={textFontSize} - fontFamily="'Courier New', monospace" + fontFamily="'Courier New', monospace" fontStyle="bold" align="center" wrap="none" fill="#000000" @@ -293,7 +288,7 @@ export function BarcodeObject({ width={halfW8} text={allDigits.slice(0, 4)} fontSize={textFontSize} - fontFamily="'Courier New', monospace" + fontFamily="'Courier New', monospace" fontStyle="bold" align="center" wrap="none" fill="#000000" @@ -306,7 +301,7 @@ export function BarcodeObject({ width={halfW8} text={allDigits.slice(4, 8)} fontSize={textFontSize} - fontFamily="'Courier New', monospace" + fontFamily="'Courier New', monospace" fontStyle="bold" align="center" wrap="none" fill="#000000" @@ -334,7 +329,7 @@ export function BarcodeObject({ width={ldW} text={allDigits[0]} fontSize={textFontSize} - fontFamily="'Courier New', monospace" + fontFamily="'Courier New', monospace" fontStyle="bold" align="center" wrap="none" fill="#000000" @@ -348,7 +343,7 @@ export function BarcodeObject({ width={halfUpca} text={allDigits.slice(1, 6)} fontSize={textFontSize} - fontFamily="'Courier New', monospace" + fontFamily="'Courier New', monospace" fontStyle="bold" align="center" wrap="none" fill="#000000" @@ -362,7 +357,7 @@ export function BarcodeObject({ width={halfUpca} text={allDigits.slice(6, 11)} fontSize={textFontSize} - fontFamily="'Courier New', monospace" + fontFamily="'Courier New', monospace" fontStyle="bold" align="center" wrap="none" fill="#000000" @@ -390,7 +385,7 @@ export function BarcodeObject({ width={ldW} text="0" fontSize={textFontSize} - fontFamily="'Courier New', monospace" + fontFamily="'Courier New', monospace" fontStyle="bold" align="center" wrap="none" fill="#000000" @@ -403,7 +398,7 @@ export function BarcodeObject({ width={midW} text={digits6} fontSize={textFontSize} - fontFamily="'Courier New', monospace" + fontFamily="'Courier New', monospace" fontStyle="bold" align="center" wrap="none" fill="#000000" @@ -416,7 +411,7 @@ export function BarcodeObject({ width={ldW} text={checkDigit} fontSize={textFontSize} - fontFamily="'Courier New', monospace" + fontFamily="'Courier New', monospace" fontStyle="bold" align="left" wrap="none" fill="#000000" @@ -458,14 +453,10 @@ export function BarcodeObject({ ); } - // ── Other 1D: separate Konva Text below bars ────────────────────────── - // upcEanExtension lets bwip-js render the HRI digits above the bars - // (via textyalign:'above'), so there's no Konva text overlay; the - // default render path below covers the bitmap placement. + // ── Other 1D: separate Konva Text below (or above) the bars ────────── const showText = BARCODE_1D_TYPES.has(obj.type) && - printInterp && - obj.type !== "upcEanExtension"; + printInterp; // Rotated 1D: text overlay rotated to match the barcode orientation. const showRotatedText = !isUpright && @@ -473,7 +464,12 @@ export function BarcodeObject({ BARCODE_1D_TYPES.has(obj.type); let displayText = rawContent; - if (obj.type === "code39") { + if (obj.type === "upcEanExtension") { + // ^BS supplements show the data as a single 2- or 5-digit string, + // padded to whichever variant bwip ended up rendering. + const t = rawContent.replace(/\D/g, ""); + displayText = (t.length === 2 ? t : t.slice(0, 5).padEnd(5, "0")); + } else if (obj.type === "code39") { displayText = `*${rawContent}*`; } else if (obj.type === "logmars") { const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-. $/+%"; @@ -489,15 +485,26 @@ export function BarcodeObject({ // LOGMARS renders the human-readable line above the bars (per spec). // ^FO Y refers to the bar top, so text is drawn at negative y to extend // above the group origin into the visual zone above the bars. - const isTextAbove = obj.type === "logmars"; - const aboveGap = isTextAbove - ? Math.max(dotsToPx(LOGMARS_TEXT_ABOVE_GAP_DOTS, scale, dpmm), 3) - : textGap; + const isTextAbove = obj.type === "logmars" || obj.type === "upcEanExtension"; + // Per-type above-bars gap. logmars spec leaves a noticeable air + // gap (10 dots); ^BS sits very tight to the bars (~2 dots, + // matching Labelary). Below-bars uses the universal textGap. + const aboveGap = + obj.type === "logmars" + ? Math.max(dotsToPx(LOGMARS_TEXT_ABOVE_GAP_DOTS, scale, dpmm), 3) + : obj.type === "upcEanExtension" + ? Math.max(dotsToPx(UPC_SUPP_TEXT_ABOVE_GAP_DOTS, scale, dpmm), 1) + : textGap; // Local y for the HRI text. The /sy form keeps a constant *visual* offset // when the group is being scaled (sy = 1 at rest, ≠ 1 during a drag). + // Anchor against the BAR top (btY) for text-above, not the group + // origin: when the firmware reserves a text zone above the bars + // (logmars: 20 dots, ^BS f=Y: 18 dots) the group origin sits + // text-zone above the bar top, and ignoring btY pushes the text + // a full text-zone above where it should sit. const textLocalY = (sy: number) => isTextAbove - ? -(textFontSize + aboveGap) / sy + ? btY - (textFontSize + aboveGap) / sy : Math.max(bh, 1) + textGap / sy; const txtY = textLocalY(1); @@ -563,7 +570,7 @@ export function BarcodeObject({ width={Math.max(w, 1)} text={displayText} fontSize={textFontSize} - fontFamily="'Courier New', monospace" + fontFamily="'Courier New', monospace" fontStyle="bold" align="center" wrap="none" fill="#000000" @@ -583,12 +590,29 @@ export function BarcodeObject({ // Text "side" for 90°/270°: standard 1D text is below bars in upright, // so after 90°CW it's on the LEFT; after 270°CW on the RIGHT. // LOGMARS is mirrored (text above in upright → right for 90°, left for 270°). - const isTextAbove = obj.type === "logmars"; - // x-anchor for R/B (shared by all text nodes for a given rotation) - const sideX = - rotation === "R" - ? isTextAbove ? w + textGap + textFontSize : -textGap - : isTextAbove ? -(textGap + textFontSize) : w + textGap; + const isTextAbove = obj.type === "logmars" || obj.type === "upcEanExtension"; + // Match the upright gap so rotated and N stay visually consistent + // per type: logmars wide (10 dots), ^BS very tight (2 dots), + // everything else uses textGap. + const rotGap = + obj.type === "logmars" + ? Math.max(dotsToPx(LOGMARS_TEXT_ABOVE_GAP_DOTS, scale, dpmm), 3) + : obj.type === "upcEanExtension" + ? Math.max(dotsToPx(UPC_SUPP_TEXT_ABOVE_GAP_DOTS, scale, dpmm), 1) + : textGap; + // x/y anchor for the rotated text. Helper anchors against the bar + // sub-rectangle, not the bbox edge — without that the firmware + // text-zone (EAN/UPC: 13 dots, logmars: 20 dots) is added to the + // gap and the text drifts that many dots away from the bars. + // sideX is the R/B x-anchor (and the I tx for sysNode/trailNode); + // topY is the I y-anchor (replaces -textGap for I). + const { sideX, topY } = getRotatedTextAnchor( + rotation, + isTextAbove, + dim, + rotGap, + textFontSize, + ); const tRot = rotation === "R" ? 90 : rotation === "I" ? 180 : -90; // ── EAN/UPC: reproduce upright digit layout along the rotated axis ── @@ -605,6 +629,7 @@ export function BarcodeObject({ const tStyle = { fontSize: textFontSize, fontFamily: "'Courier New', monospace" as const, + fontStyle: "bold" as const, wrap: "none" as const, fill: "#000000", listening: false, @@ -616,7 +641,7 @@ export function BarcodeObject({ // For I: encPos → screen-x leftward from right (start=right), anchor-x = w - encPos. const node = (key: string, encPos: number, size: number, text: string) => { const tx = rotation === "I" ? w - encPos : sideX; - const ty = rotation === "R" ? encPos : rotation === "B" ? h - encPos : -textGap; + const ty = rotation === "R" ? encPos : rotation === "B" ? h - encPos : topY; return ; }; @@ -624,7 +649,7 @@ export function BarcodeObject({ const sysNode = (key: string, text: string) => { // R: above top (y=-ldW); B: below bottom (y=h+ldW); I: right of barcode (x=w+ldW). const tx = rotation === "I" ? w + ldW : sideX; - const ty = rotation === "R" ? -ldW : rotation === "B" ? h + ldW : -textGap; + const ty = rotation === "R" ? -ldW : rotation === "B" ? h + ldW : topY; return ; }; @@ -632,7 +657,7 @@ export function BarcodeObject({ const trailNode = (key: string, text: string) => { // R: below bottom (y≈encDisplay); B: above top (y≈h-encDisplay); I: left of x=0. const tx = rotation === "I" ? -ldW : sideX; - const ty = rotation === "R" ? encDisplay : rotation === "B" ? h - encDisplay : -textGap; + const ty = rotation === "R" ? encDisplay : rotation === "B" ? h - encDisplay : topY; return ; }; @@ -677,9 +702,7 @@ export function BarcodeObject({ if (rotation === "R") { txtX = sideX; txtY = 0; txtWidth = h; } else if (rotation === "I") { - txtX = w; - txtY = isTextAbove ? bh + textGap + textFontSize : -textGap; - txtWidth = w; + txtX = w; txtY = topY; txtWidth = w; } else { txtX = sideX; txtY = h; txtWidth = h; } @@ -688,7 +711,7 @@ export function BarcodeObject({ ); diff --git a/src/components/Canvas/bwipConstants.ts b/src/components/Canvas/bwipConstants.ts index 8b387ebc..9e4bf3a5 100644 --- a/src/components/Canvas/bwipConstants.ts +++ b/src/components/Canvas/bwipConstants.ts @@ -16,6 +16,11 @@ export const UPC_SUPP_TEXT_ZONE_DOTS = 18; // 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. diff --git a/src/components/Canvas/bwipHelpers.ts b/src/components/Canvas/bwipHelpers.ts index ab681e1b..ceac77ab 100644 --- a/src/components/Canvas/bwipHelpers.ts +++ b/src/components/Canvas/bwipHelpers.ts @@ -339,9 +339,11 @@ export function buildBwipOptions( // 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. - // `textyalign: above` flips the human-readable line to sit - // above the bars, matching Zebra firmware (and Labelary) for - // UPC/EAN supplements — bwip-js's default puts it below. + // 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 = { @@ -349,12 +351,6 @@ export function buildBwipOptions( text, scale, height: 10, - // bwip's ean2/ean5 default is bars-only. Zebra firmware - // prints the digits when ^BSf=Y and places them above the - // bars (unlike main EAN/UPC where they sit below). Forward - // the prop so f=N renders bars-only, matching Labelary. - includetext: p.printInterpretation, - textyalign: 'above', }; break; } @@ -575,6 +571,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, + 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 From 99b088cb3c48b4d3bf8a18882ce56bde9162b694 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 24 May 2026 08:56:43 +0200 Subject: [PATCH 3/4] refactor(registry): extract HRI behaviour into ObjectTypeDefinition Replace the obj.type-string chains in BarcodeObject (isTextAbove, aboveGap, displayText) with an `hri?: HriBehavior` field on each 1D leaf. New `hri` fields wired for ean13/ean8/upca/upce/code39/logmars/ upcEanExtension; defaults preserve behaviour for the remaining 1D types (raw content, below bars, textGap). Per-type formatters live in registry/hriFormatters.ts. Check-digit math (eanCheckDigit / upceCheckDigit) moved from Canvas/bwipHelpers to lib/barcodeCheckDigits.ts so the registry can import without crossing into the Canvas layer; bwipHelpers re-exports for backwards compat. The EAN/UPC multi-Text digit-split branches consume displayText directly instead of recomputing the formatted string inline, so the registry's formatHri is the single source of truth for HRI content. Net effect: adding a new 1D symbology no longer touches BarcodeObject to wire up its HRI behaviour; everything lives in the leaf. --- src/components/Canvas/BarcodeObject.tsx | 126 +++++++++--------------- src/components/Canvas/bwipHelpers.ts | 22 +---- src/lib/barcodeCheckDigits.ts | 29 ++++++ src/registry/barcode1d.tsx | 5 +- src/registry/code39.tsx | 2 + src/registry/ean13.tsx | 2 + src/registry/ean8.tsx | 2 + src/registry/hriFormatters.ts | 55 +++++++++++ src/registry/logmars.tsx | 7 ++ src/registry/upcEanExtension.tsx | 7 ++ src/registry/upca.tsx | 2 + src/registry/upce.tsx | 2 + src/types/ObjectType.ts | 26 +++++ 13 files changed, 186 insertions(+), 101 deletions(-) create mode 100644 src/lib/barcodeCheckDigits.ts create mode 100644 src/registry/hriFormatters.ts diff --git a/src/components/Canvas/BarcodeObject.tsx b/src/components/Canvas/BarcodeObject.tsx index 69ad8352..04d773d6 100644 --- a/src/components/Canvas/BarcodeObject.tsx +++ b/src/components/Canvas/BarcodeObject.tsx @@ -10,8 +10,6 @@ import { buildBwipOptions, getDisplaySize, getRotatedTextAnchor, - eanCheckDigit, - upceCheckDigit, get1DBwipScale, getEanUpcLayout, type BarcodeDisplaySize, @@ -21,8 +19,6 @@ import { objectRotation } from "../../registry/rotation"; import { QR_FO_Y_OFFSET_DOTS, QR_FT_MODULE_OFFSET, - LOGMARS_TEXT_ABOVE_GAP_DOTS, - UPC_SUPP_TEXT_ABOVE_GAP_DOTS, EAN_UPC_TYPES, } from "./bwipConstants"; @@ -203,6 +199,20 @@ export function BarcodeObject({ const textGap = Math.max(dotsToPx(5, scale, dpmm), 3); const rawContent = (obj.props as { content?: string }).content ?? ""; + // HRI behaviour comes from the registry — per-type formatHri / + // textAbove / aboveGapDots. Defaults: raw content, below bars, + // textGap. Keeps BarcodeObject type-agnostic for the generic 1D + // HRI path; EAN/UPC multi-digit-split branches below consume the + // same formatHri output (displayText) as the source string. + const hri = ObjectRegistry[obj.type]?.hri; + const displayText = hri?.formatHri?.(rawContent) ?? rawContent; + const isTextAbove = hri?.textAbove ?? false; + // 3px floor matches textGap so HRI stays legible at very small + // scales regardless of which dots value the spec calls for. + const aboveGapPx = hri?.aboveGapDots !== undefined + ? Math.max(dotsToPx(hri.aboveGapDots, scale, dpmm), 3) + : textGap; + // ── EAN/UPC: manually-positioned digit labels ───────────────────────── if (EAN_UPC_TYPES.has(obj.type) && printInterp) { const bwipSc = get1DBwipScale(moduleWidth, scale, dpmm); @@ -219,11 +229,8 @@ export function BarcodeObject({ let clipRight = 0; if (obj.type === "ean13") { - const digits12 = rawContent - .replace(/\D/g, "") - .slice(0, 12) - .padEnd(12, "0"); - const allDigits = digits12 + eanCheckDigit(digits12, 1, 3); // 13 digits + // 13-digit string formatted by registry's formatEan13Hri (includes check digit). + const allDigits = displayText; const { xLeft: xLeft13, xRight: xRight13, halfWidth: halfW13 } = layout; @@ -271,11 +278,8 @@ export function BarcodeObject({ />, ]; } else if (obj.type === "ean8") { - const digits7 = rawContent - .replace(/\D/g, "") - .slice(0, 7) - .padEnd(7, "0"); - const allDigits = digits7 + eanCheckDigit(digits7, 3, 1); // 8 digits + // 8-digit string formatted by registry's formatEan8Hri. + const allDigits = displayText; const { xLeft: xLeft8, xRight: xRight8, halfWidth: halfW8 } = layout; @@ -309,11 +313,8 @@ export function BarcodeObject({ />, ]; } else if (obj.type === "upca") { - const digits11 = rawContent - .replace(/\D/g, "") - .slice(0, 11) - .padEnd(11, "0"); - const allDigits = digits11 + eanCheckDigit(digits11, 3, 1); // 12 digits + // 12-digit string formatted by registry's formatUpcaHri. + const allDigits = displayText; const { xLeft: xLeftUpca, xRight: xRightUpca, halfWidth: halfUpca } = layout; @@ -365,12 +366,10 @@ export function BarcodeObject({ />, ]; } else if (obj.type === "upce") { - const digits6 = rawContent - .replace(/\D/g, "") - .slice(0, 6) - .padEnd(6, "0"); - - const checkDigit = upceCheckDigit(digits6); + // displayText = "0" + 6 data digits + check digit (8 chars total), + // formatted by registry's formatUpceHri. + const digits6 = displayText.slice(1, 7); + const checkDigit = displayText[7] ?? ""; // UPC-E: 6 digits centered over the data area (modules 3–44 of 51) const { xLeft: xMid, halfWidth: midW } = layout; @@ -463,38 +462,11 @@ export function BarcodeObject({ printInterpEnabled && BARCODE_1D_TYPES.has(obj.type); - let displayText = rawContent; - if (obj.type === "upcEanExtension") { - // ^BS supplements show the data as a single 2- or 5-digit string, - // padded to whichever variant bwip ended up rendering. - const t = rawContent.replace(/\D/g, ""); - displayText = (t.length === 2 ? t : t.slice(0, 5).padEnd(5, "0")); - } else if (obj.type === "code39") { - displayText = `*${rawContent}*`; - } else if (obj.type === "logmars") { - const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-. $/+%"; - let sum = 0; - for (const c of rawContent) { - const idx = chars.indexOf(c.toUpperCase()); - if (idx >= 0) sum += idx; - } - displayText = `${rawContent}${chars[sum % 43] ?? ""}`; - } - if (showText) { // LOGMARS renders the human-readable line above the bars (per spec). // ^FO Y refers to the bar top, so text is drawn at negative y to extend // above the group origin into the visual zone above the bars. - const isTextAbove = obj.type === "logmars" || obj.type === "upcEanExtension"; - // Per-type above-bars gap. logmars spec leaves a noticeable air - // gap (10 dots); ^BS sits very tight to the bars (~2 dots, - // matching Labelary). Below-bars uses the universal textGap. - const aboveGap = - obj.type === "logmars" - ? Math.max(dotsToPx(LOGMARS_TEXT_ABOVE_GAP_DOTS, scale, dpmm), 3) - : obj.type === "upcEanExtension" - ? Math.max(dotsToPx(UPC_SUPP_TEXT_ABOVE_GAP_DOTS, scale, dpmm), 1) - : textGap; + const aboveGap = aboveGapPx; // Local y for the HRI text. The /sy form keeps a constant *visual* offset // when the group is being scaled (sy = 1 at rest, ≠ 1 during a drag). // Anchor against the BAR top (btY) for text-above, not the group @@ -590,16 +562,10 @@ export function BarcodeObject({ // Text "side" for 90°/270°: standard 1D text is below bars in upright, // so after 90°CW it's on the LEFT; after 270°CW on the RIGHT. // LOGMARS is mirrored (text above in upright → right for 90°, left for 270°). - const isTextAbove = obj.type === "logmars" || obj.type === "upcEanExtension"; - // Match the upright gap so rotated and N stay visually consistent - // per type: logmars wide (10 dots), ^BS very tight (2 dots), - // everything else uses textGap. - const rotGap = - obj.type === "logmars" - ? Math.max(dotsToPx(LOGMARS_TEXT_ABOVE_GAP_DOTS, scale, dpmm), 3) - : obj.type === "upcEanExtension" - ? Math.max(dotsToPx(UPC_SUPP_TEXT_ABOVE_GAP_DOTS, scale, dpmm), 1) - : textGap; + // isTextAbove and rotGap come from the registry (same source as + // upright above) — keeps rotated and N visually consistent per + // type without duplicating the per-type chain. + const rotGap = aboveGapPx; // x/y anchor for the rotated text. Helper anchors against the bar // sub-rectangle, not the bbox edge — without that the firmware // text-zone (EAN/UPC: 13 dots, logmars: 20 dots) is added to the @@ -661,36 +627,32 @@ export function BarcodeObject({ return ; }; + // All EAN/UPC HRI strings are formatted by the registry's + // formatHri (displayText). The split positions differ per type + // but the source string is the same as the upright branch. if (obj.type === "ean13") { - const d12 = rawContent.replace(/\D/g, "").slice(0, 12).padEnd(12, "0"); - const all13 = d12 + eanCheckDigit(d12, 1, 3); textElements = [ - sysNode("sys", all13[0] ?? ""), - node("left", xLeft, halfW, all13.slice(1, 7)), - node("right", xRight, halfW, all13.slice(7, 13)), + sysNode("sys", displayText[0] ?? ""), + node("left", xLeft, halfW, displayText.slice(1, 7)), + node("right", xRight, halfW, displayText.slice(7, 13)), ]; } else if (obj.type === "ean8") { - const d7 = rawContent.replace(/\D/g, "").slice(0, 7).padEnd(7, "0"); - const all8 = d7 + eanCheckDigit(d7, 3, 1); textElements = [ - node("left", xLeft, halfW, all8.slice(0, 4)), - node("right", xRight, halfW, all8.slice(4, 8)), + node("left", xLeft, halfW, displayText.slice(0, 4)), + node("right", xRight, halfW, displayText.slice(4, 8)), ]; } else if (obj.type === "upca") { - const d11 = rawContent.replace(/\D/g, "").slice(0, 11).padEnd(11, "0"); - const all12 = d11 + eanCheckDigit(d11, 3, 1); textElements = [ - sysNode("sys", all12[0] ?? ""), - node("left", xLeft, halfW, all12.slice(1, 6)), - node("right", xRight, halfW, all12.slice(6, 11)), + sysNode("sys", displayText[0] ?? ""), + node("left", xLeft, halfW, displayText.slice(1, 6)), + node("right", xRight, halfW, displayText.slice(6, 11)), ]; } else if (obj.type === "upce") { - const d6 = rawContent.replace(/\D/g, "").slice(0, 6).padEnd(6, "0"); - const ck = upceCheckDigit(d6); + // displayText = "0" + 6 data digits + check digit (8 chars). textElements = [ - sysNode("sys", "0"), - node("mid", xLeft, halfW, d6), - trailNode("trail", ck), + sysNode("sys", displayText[0] ?? "0"), + node("mid", xLeft, halfW, displayText.slice(1, 7)), + trailNode("trail", displayText[7] ?? ""), ]; } } else { diff --git a/src/components/Canvas/bwipHelpers.ts b/src/components/Canvas/bwipHelpers.ts index ceac77ab..b17e3b42 100644 --- a/src/components/Canvas/bwipHelpers.ts +++ b/src/components/Canvas/bwipHelpers.ts @@ -211,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. diff --git a/src/lib/barcodeCheckDigits.ts b/src/lib/barcodeCheckDigits.ts new file mode 100644 index 00000000..724ab23c --- /dev/null +++ b/src/lib/barcodeCheckDigits.ts @@ -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); +} diff --git a/src/registry/barcode1d.tsx b/src/registry/barcode1d.tsx index 7e13b950..ee097a5d 100644 --- a/src/registry/barcode1d.tsx +++ b/src/registry/barcode1d.tsx @@ -1,4 +1,4 @@ -import type { ObjectTypeDefinition, ObjectGroup, LabelObjectBase } from '../types/ObjectType'; +import type { ObjectTypeDefinition, ObjectGroup, LabelObjectBase, HriBehavior } from '../types/ObjectType'; import { useT } from '../lib/useT'; import type { Translations } from '../locales'; import { inputCls, labelCls } from '../components/Properties/styles'; @@ -43,6 +43,8 @@ interface Barcode1DConfig { interpretationLocked?: boolean; /** Restrict allowed input characters; see {@link ContentSpec}. */ contentSpec?: ContentSpec; + /** See {@link HriBehavior}. */ + hri?: HriBehavior; } interface BarcodeLocale { @@ -71,6 +73,7 @@ export function createBarcode1D(config: Barcode1DConfig): ObjectTypeDefinition= 0) sum += idx; + } + return `${content}${LOGMARS_CHARSET[sum % 43] ?? ''}`; +} + +/** ^BS shows the supplement as a single 2- or 5-digit string, padded + * to whichever variant bwip actually rendered (anything other than 2 + * becomes the 5-digit form). */ +export function formatUpcEanExtensionHri(content: string): string { + const t = content.replace(/\D/g, ''); + return t.length === 2 ? t : t.slice(0, 5).padEnd(5, '0'); +} diff --git a/src/registry/logmars.tsx b/src/registry/logmars.tsx index 020be80d..03cec2f7 100644 --- a/src/registry/logmars.tsx +++ b/src/registry/logmars.tsx @@ -1,4 +1,6 @@ import { createBarcode1D } from "./barcode1d"; +import { formatLogmarsHri } from "./hriFormatters"; +import { LOGMARS_TEXT_ABOVE_GAP_DOTS } from "../components/Canvas/bwipConstants"; export type { Barcode1DProps as LogmarsProps } from "./barcode1d"; export const logmars = createBarcode1D({ @@ -13,4 +15,9 @@ export const logmars = createBarcode1D({ const interp = p.printInterpretation ? "Y" : "N"; return `^BL${p.rotation},${p.height},${interp}`; }, + hri: { + textAbove: true, + aboveGapDots: LOGMARS_TEXT_ABOVE_GAP_DOTS, + formatHri: formatLogmarsHri, + }, }); diff --git a/src/registry/upcEanExtension.tsx b/src/registry/upcEanExtension.tsx index 610f3aaa..48902e6a 100644 --- a/src/registry/upcEanExtension.tsx +++ b/src/registry/upcEanExtension.tsx @@ -1,4 +1,6 @@ import { createBarcode1D } from './barcode1d'; +import { formatUpcEanExtensionHri } from './hriFormatters'; +import { UPC_SUPP_TEXT_ABOVE_GAP_DOTS } from '../components/Canvas/bwipConstants'; export type { Barcode1DProps as UpcEanExtensionProps } from './barcode1d'; /** UPC/EAN extension barcode (^BS) — the 2- or 5-digit supplement @@ -24,4 +26,9 @@ export const upcEanExtension = createBarcode1D({ const interp = p.printInterpretation ? 'Y' : 'N'; return `^BS${p.rotation},${p.height},${interp}`; }, + hri: { + textAbove: true, + aboveGapDots: UPC_SUPP_TEXT_ABOVE_GAP_DOTS, + formatHri: formatUpcEanExtensionHri, + }, }); diff --git a/src/registry/upca.tsx b/src/registry/upca.tsx index 76860861..1fc3bd34 100644 --- a/src/registry/upca.tsx +++ b/src/registry/upca.tsx @@ -1,4 +1,5 @@ import { createBarcode1D } from './barcode1d'; +import { formatUpcaHri } from './hriFormatters'; export type { Barcode1DProps as UpcAProps } from './barcode1d'; export const upca = createBarcode1D({ @@ -13,4 +14,5 @@ export const upca = createBarcode1D({ const interp = p.printInterpretation ? 'Y' : 'N'; return `^BU${p.rotation},${p.height},${interp},N,N`; }, + hri: { formatHri: formatUpcaHri }, }); diff --git a/src/registry/upce.tsx b/src/registry/upce.tsx index 8e52d308..3568864e 100644 --- a/src/registry/upce.tsx +++ b/src/registry/upce.tsx @@ -1,4 +1,5 @@ import { createBarcode1D } from './barcode1d'; +import { formatUpceHri } from './hriFormatters'; export type { Barcode1DProps as UpcEProps } from './barcode1d'; export const upce = createBarcode1D({ @@ -13,4 +14,5 @@ export const upce = createBarcode1D({ const interp = p.printInterpretation ? 'Y' : 'N'; return `^B9${p.rotation},${p.height},${interp},N`; }, + hri: { formatHri: formatUpceHri }, }); diff --git a/src/types/ObjectType.ts b/src/types/ObjectType.ts index 88124ddc..60568f44 100644 --- a/src/types/ObjectType.ts +++ b/src/types/ObjectType.ts @@ -154,6 +154,28 @@ export interface ZplEmitContext { variables?: readonly Variable[]; } +/** + * Per-type HRI (human-readable interpretation) rendering behaviour. All + * fields are optional with sensible defaults: text is rendered below the + * bars in raw form with the standard textGap. Each leaf overrides only + * what differs from the baseline, keeping BarcodeObject type-agnostic + * for the generic HRI path. + */ +export interface HriBehavior { + /** True when the HRI text sits above the bars (logmars spec, ^BS). + * Default: false. */ + textAbove?: boolean; + /** Gap in dots between the bar edge and the text glyph. Applies to + * both the upright above-bars gap AND the side gap on rotated + * R/B/I, so a tighter ^BS (2) stays tight after rotation while + * logmars (10) keeps its wider air gap. Below-bars upright always + * uses the global textGap regardless of this value. */ + aboveGapDots?: number; + /** Transform raw content into the displayed HRI string (add check + * digit, wrap with start/stop chars, pad, …). Default: identity. */ + formatHri?: (content: string) => string; +} + export interface ObjectTypeDefinition

{ label: string; icon: string; @@ -211,6 +233,10 @@ export interface ObjectTypeDefinition

{ obj: LabelObjectBase & { props: P }, ctx: TransformContext, ) => Partial

; + /** See {@link HriBehavior}. Only meaningful for 1D barcode types + * that render an HRI text overlay; other types should leave this + * undefined. */ + hri?: HriBehavior; PropertiesPanel: React.ComponentType<{ obj: LabelObjectBase & { props: P }; onChange: (props: Partial

) => void; From 554ce2430d5d72e556bae350f89f7e57221ea6b1 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 24 May 2026 09:07:09 +0200 Subject: [PATCH 4/4] fix(barcode): read isTextAbove from registry in getDisplaySize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bwipHelpers.getDisplaySize was hardcoding `isTextAbove` to upcEanExtension only, leaving logmars treated as text-below. The bbox reserved the firmware text zone at the bottom while BarcodeObject drew the HRI overlay above the bars at negative y — text leaked outside the selection bbox. Reading hri.textAbove from the registry fixes logmars and matches the source of truth BarcodeObject already consumes. Bug spotted by gemini review on PR #90. Also pick up the remaining nits from the prior round: - JSDoc on HriBehavior points at logmars / upcEanExtension as canonical examples for new contributors - Unit tests for hriFormatters cover happy path + edge cases (invalid chars in LOGMARS charset, short inputs, length-driven routing in ^BS) — 15 new tests --- src/components/Canvas/bwipHelpers.ts | 9 ++- src/registry/hriFormatters.test.ts | 87 ++++++++++++++++++++++++++++ src/types/ObjectType.ts | 4 ++ 3 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 src/registry/hriFormatters.test.ts diff --git a/src/components/Canvas/bwipHelpers.ts b/src/components/Canvas/bwipHelpers.ts index b17e3b42..847800b7 100644 --- a/src/components/Canvas/bwipHelpers.ts +++ b/src/components/Canvas/bwipHelpers.ts @@ -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"; @@ -662,7 +662,12 @@ export function getDisplaySize( ? 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"; + // 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. diff --git a/src/registry/hriFormatters.test.ts b/src/registry/hriFormatters.test.ts new file mode 100644 index 00000000..d3d573a8 --- /dev/null +++ b/src/registry/hriFormatters.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { + formatEan13Hri, + formatEan8Hri, + formatUpcaHri, + formatUpceHri, + formatCode39Hri, + formatLogmarsHri, + formatUpcEanExtensionHri, +} from './hriFormatters'; + +describe('HRI formatters', () => { + describe('formatEan13Hri', () => { + it('appends check digit to 12-digit input', () => { + expect(formatEan13Hri('590123412345')).toBe('5901234123457'); + }); + it('pads short input with zeros before computing check digit', () => { + // "123" padded to "123000000000" → sum=1*1+2*3+3*1=10 → check=0 + expect(formatEan13Hri('123')).toBe('1230000000000'); + }); + it('strips non-digits', () => { + expect(formatEan13Hri('5-9-0-1-2-3-4-1-2-3-4-5')).toBe('5901234123457'); + }); + }); + + describe('formatEan8Hri', () => { + it('appends check digit to 7-digit input', () => { + expect(formatEan8Hri('1234567')).toBe('12345670'); + }); + }); + + describe('formatUpcaHri', () => { + it('appends check digit to 11-digit input', () => { + expect(formatUpcaHri('01234567890')).toBe('012345678905'); + }); + }); + + describe('formatUpceHri', () => { + it('produces 8-char string: 0 + 6 data + check', () => { + const r = formatUpceHri('012345'); + expect(r).toHaveLength(8); + expect(r[0]).toBe('0'); + expect(r.slice(1, 7)).toBe('012345'); + }); + }); + + describe('formatCode39Hri', () => { + it('wraps content with start/stop asterisks', () => { + expect(formatCode39Hri('CODE39')).toBe('*CODE39*'); + }); + }); + + describe('formatLogmarsHri', () => { + it('appends mod-43 check char for plain text', () => { + // sum("LOGMARS1") in the charset: L=21,O=24,G=16,M=22,A=10,R=27,S=28,1=1 + // = 21+24+16+22+10+27+28+1 = 149 → 149 % 43 = 20 → "K" + expect(formatLogmarsHri('LOGMARS1')).toBe('LOGMARS1K'); + }); + it('treats lowercase as uppercase (charset is upper-only)', () => { + expect(formatLogmarsHri('logmars1')).toBe('logmars1K'); + }); + it('ignores characters not in the LOGMARS charset', () => { + // The single valid char 'A' (index 10) sums to 10 → check = "A". + // The '@' contributes nothing. + expect(formatLogmarsHri('A@')).toBe('A@A'); + }); + it('returns empty-content + first-charset-char on empty input', () => { + expect(formatLogmarsHri('')).toBe('0'); + }); + }); + + describe('formatUpcEanExtensionHri', () => { + it('keeps 2-digit content as-is', () => { + expect(formatUpcEanExtensionHri('12')).toBe('12'); + }); + it('pads short 5-digit form with zeros', () => { + expect(formatUpcEanExtensionHri('51')).toBe('51'); + expect(formatUpcEanExtensionHri('519')).toBe('51900'); + }); + it('preserves 5-digit content', () => { + expect(formatUpcEanExtensionHri('51999')).toBe('51999'); + }); + it('strips non-digits then routes by length', () => { + expect(formatUpcEanExtensionHri('5-1-9-9-9')).toBe('51999'); + }); + }); +}); diff --git a/src/types/ObjectType.ts b/src/types/ObjectType.ts index 60568f44..e8180614 100644 --- a/src/types/ObjectType.ts +++ b/src/types/ObjectType.ts @@ -160,6 +160,10 @@ export interface ZplEmitContext { * bars in raw form with the standard textGap. Each leaf overrides only * what differs from the baseline, keeping BarcodeObject type-agnostic * for the generic HRI path. + * + * @example See registry/logmars.tsx (text above + wider gap + check digit + * formatter) and registry/upcEanExtension.tsx (text above + very tight gap) + * for the canonical patterns. */ export interface HriBehavior { /** True when the HRI text sits above the bars (logmars spec, ^BS).