From 1d36e0332b000cd109c1aaa6dbfaa5788e2a875f Mon Sep 17 00:00:00 2001 From: u8array Date: Wed, 6 May 2026 23:10:39 +0200 Subject: [PATCH 1/7] fix(gs1databar): set segments to 2 (minimum valid even value per ZPL spec) Segments must be even (2-22). The hardcoded 1 was spec-invalid; Zebra firmware ignores it for Omnidirectional but the output was technically wrong. --- src/registry/gs1databar.tsx | 2 +- tests/fixtures/labelary_images/fixtures.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registry/gs1databar.tsx b/src/registry/gs1databar.tsx index 022d53ba..97d1ba54 100644 --- a/src/registry/gs1databar.tsx +++ b/src/registry/gs1databar.tsx @@ -18,6 +18,6 @@ export const gs1databar = createBarcode1D({ zplCommand: (p) => { // ^BR{orientation},{symbology},{magnification},{separator},{height},{segments} // symbology 1 = omnidirectional - return `^BR${p.rotation},1,${p.moduleWidth},2,${p.height},1`; + return `^BR${p.rotation},1,${p.moduleWidth},2,${p.height},2`; }, }); diff --git a/tests/fixtures/labelary_images/fixtures.json b/tests/fixtures/labelary_images/fixtures.json index 391de5fe..36cdc2b6 100644 --- a/tests/fixtures/labelary_images/fixtures.json +++ b/tests/fixtures/labelary_images/fixtures.json @@ -310,7 +310,7 @@ }, { "id": "barcode_gs1databar_standard", - "zpl_input": "^XA^BY2^FO50,50^BRN,1,2,2,100,1^FD0112345678901^FS^XZ", + "zpl_input": "^XA^BY2^FO50,50^BRN,1,2,2,100,2^FD0112345678901^FS^XZ", "expected_bounds": { "x": 50, "y": 50, From 19dbc737de6edb18312c5ac1bb009f8c7f364373 Mon Sep 17 00:00:00 2001 From: u8array Date: Wed, 6 May 2026 23:23:02 +0200 Subject: [PATCH 2/7] feat(gs1databar): add symbology dropdown and Expanded Stacked segments Replaces the createBarcode1D delegation with a dedicated Gs1DatabarProps interface supporting all 7 symbologies (Omnidirectional through Expanded Stacked). Parser reads symbology and segments from ^BR. bwip-js renders each variant with the correct bcid. Segments input is shown only for Expanded Stacked (symbology 7, must be even 2-22). Script: add_locale_key.local.py extended with optional 'indent' and 'block' fields for inserting into nested registry sub-blocks. --- src/components/Canvas/bwipHelpers.ts | 33 +++++-- src/lib/zplParser.ts | 27 +++++- src/locales/ar.ts | 2 + src/locales/bg.ts | 2 + src/locales/cs.ts | 2 + src/locales/da.ts | 2 + src/locales/de.ts | 2 + src/locales/el.ts | 2 + src/locales/en.ts | 2 + src/locales/es.ts | 2 + src/locales/et.ts | 2 + src/locales/fa.ts | 2 + src/locales/fi.ts | 2 + src/locales/fr.ts | 2 + src/locales/he.ts | 2 + src/locales/hr.ts | 2 + src/locales/hu.ts | 2 + src/locales/it.ts | 2 + src/locales/ja.ts | 2 + src/locales/ko.ts | 2 + src/locales/lt.ts | 2 + src/locales/lv.ts | 2 + src/locales/nl.ts | 2 + src/locales/no.ts | 2 + src/locales/pl.ts | 2 + src/locales/pt.ts | 2 + src/locales/ro.ts | 2 + src/locales/sk.ts | 2 + src/locales/sl.ts | 2 + src/locales/sr.ts | 2 + src/locales/sv.ts | 2 + src/locales/tr.ts | 2 + src/locales/zh-hans.ts | 2 + src/locales/zh-hant.ts | 2 + src/registry/gs1databar.tsx | 139 +++++++++++++++++++++++---- src/test/testModels.ts | 2 +- 36 files changed, 234 insertions(+), 31 deletions(-) diff --git a/src/components/Canvas/bwipHelpers.ts b/src/components/Canvas/bwipHelpers.ts index 0b161aac..29c253b0 100644 --- a/src/components/Canvas/bwipHelpers.ts +++ b/src/components/Canvas/bwipHelpers.ts @@ -1,8 +1,19 @@ import type { LabelObject } from "../../registry"; +import type { Gs1DatabarProps } from "../../registry/gs1databar"; import { objectRotation } from "../../registry/rotation"; import { dotsToPx } from "../../lib/coordinates"; import { MICROPDF417_QUIET_ZONE_ROWS } from "./bwipConstants"; +const GS1_DATABAR_BCID: Record = { + 1: "databaromni", + 2: "databartruncated", + 3: "databarstacked", + 4: "databarstackedomni", + 5: "databarlimited", + 6: "databarexpanded", + 7: "databarexpandedstacked", +}; + const BCID: Partial> = { code128: "code128", code39: "code39", @@ -259,17 +270,25 @@ export function buildBwipOptions( break; } case "gs1databar": { - const p = obj.props; - const raw = (p.content || "0").replace(/\D/g, ""); - const padded = raw.padStart(13, "0").slice(0, 14); - opts = { - bcid, - text: `(01)${padded}`, + const p = obj.props as Gs1DatabarProps; + const sym = p.symbology ?? 1; + const isExpanded = sym === 6 || sym === 7; + let text: string; + if (isExpanded) { + text = p.content || "(01)00000000000000"; + } else { + const raw = (p.content || "0").replace(/\D/g, ""); + text = `(01)${raw.padStart(13, "0").slice(0, 14)}`; + } + const gs1Opts: Record = { + bcid: GS1_DATABAR_BCID[sym], + text, scale: scale1D, height: 10, - // Adds 2 quiet-zone rows above and below so canvas height matches Labelary. paddingheight: 2, }; + if (sym === 7) gs1Opts["segments"] = p.segments ?? 22; + opts = gs1Opts as typeof opts; break; } case "planet": { diff --git a/src/lib/zplParser.ts b/src/lib/zplParser.ts index 9f5732a1..b67549d6 100644 --- a/src/lib/zplParser.ts +++ b/src/lib/zplParser.ts @@ -11,6 +11,7 @@ import type { EllipseProps } from "../registry/ellipse"; import type { LineProps } from "../registry/line"; import type { ImageProps } from "../registry/image"; import type { Barcode1DProps } from "../registry/barcode1d"; +import type { Gs1DatabarProps } from "../registry/gs1databar"; import type { Pdf417Props } from "../registry/pdf417"; import type { SerialProps } from "../registry/serial"; import { isZplRotation, type ZplRotation } from "../registry/rotation"; @@ -215,6 +216,8 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { let bcInterp = true; let bcCheck = false; let bcRotation: ZplRotation = "N"; + let gsSymbology: Gs1DatabarProps["symbology"] = 1; + let gsSegments: number | undefined = undefined; // ^BY barcode defaults let byModuleWidth = 2; let byHeight = 0; // 0 = no ^BY height; barcode handlers use ||100 as sentinel @@ -440,7 +443,6 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { case "logmars": case "msi": case "plessey": - case "gs1databar": case "planet": case "postal": objects.push( @@ -461,6 +463,24 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { ), ); break; + case "gs1databar": + objects.push( + makeObj( + "gs1databar", + x, + y, + { + content, + moduleWidth: byModuleWidth, + symbology: gsSymbology, + segments: gsSegments, + rotation: bcRotation, + } satisfies Gs1DatabarProps, + posType, + comment, + ), + ); + break; case "pdf417": objects.push( makeObj( @@ -691,12 +711,13 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { bcInterp = (p[3] ?? "Y") === "Y"; }, // GS1 Databar: different param layout, also updates byModuleWidth - // ^BRN,{symbology},{magnification},{separator},{height},{segments} + // ^BRo,{symbology},{magnification},{separator},{height},{segments} BR(p) { fieldType = "gs1databar"; bcRotation = readRotation(p[0]); - bcHeight = int(p[4], byHeight || 100); byModuleWidth = int(p[2], byModuleWidth); + gsSymbology = (int(p[1], 1) as Gs1DatabarProps["symbology"]) || 1; + gsSegments = p[5] !== undefined ? int(p[5], 22) : undefined; }, // ^BQN,2,{magnification} — QR Code diff --git a/src/locales/ar.ts b/src/locales/ar.ts index badfa140..f5da316e 100644 --- a/src/locales/ar.ts +++ b/src/locales/ar.ts @@ -305,6 +305,8 @@ const ar = { height: 'الارتفاع (نقاط)', printInterpretation: 'مقروء', moduleWidth: 'عرض الوحدة', + symbology: 'الرمزية', + segments: 'مقاطع لكل صف', }, planet: { content: 'المحتوى', diff --git a/src/locales/bg.ts b/src/locales/bg.ts index 5617cd9d..799b2079 100644 --- a/src/locales/bg.ts +++ b/src/locales/bg.ts @@ -305,6 +305,8 @@ const bg = { height: 'Височина (точки)', printInterpretation: 'Четимо', moduleWidth: 'Ширина на модул', + symbology: 'Символика', + segments: 'Сегменти на ред', }, planet: { content: 'Съдържание', diff --git a/src/locales/cs.ts b/src/locales/cs.ts index b993ccfa..1df1d3e9 100644 --- a/src/locales/cs.ts +++ b/src/locales/cs.ts @@ -305,6 +305,8 @@ const cs = { height: 'Výška (body)', printInterpretation: 'Čitelný pro člověka', moduleWidth: 'Šířka modulu', + symbology: 'Symbolika', + segments: 'Segmenty na řádek', }, planet: { content: 'Obsah', diff --git a/src/locales/da.ts b/src/locales/da.ts index 04df6379..b554b5bc 100644 --- a/src/locales/da.ts +++ b/src/locales/da.ts @@ -305,6 +305,8 @@ const da = { height: 'Højde (punkter)', printInterpretation: 'Læsbar', moduleWidth: 'Modulbredde', + symbology: 'Symbolik', + segments: 'Segmenter per række', }, planet: { content: 'Indhold', diff --git a/src/locales/de.ts b/src/locales/de.ts index 9a6d3d3b..0347dded 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -325,6 +325,8 @@ const de = { height: 'Höhe (Punkte)', printInterpretation: 'Klartext', moduleWidth: 'Modulbreite', + symbology: 'Symbolik', + segments: 'Segmente pro Zeile', }, planet: { content: 'Inhalt', diff --git a/src/locales/el.ts b/src/locales/el.ts index f07a3982..5c73c04b 100644 --- a/src/locales/el.ts +++ b/src/locales/el.ts @@ -305,6 +305,8 @@ const el = { height: 'Ύψος (κουκκίδες)', printInterpretation: 'Αναγνώσιμο', moduleWidth: 'Πλάτος μονάδας', + symbology: 'Συμβολολογία', + segments: 'Τμήματα ανά γραμμή', }, planet: { content: 'Περιεχόμενο', diff --git a/src/locales/en.ts b/src/locales/en.ts index 8a6b062e..add2b87a 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -325,6 +325,8 @@ const en = { height: 'Height (dots)', printInterpretation: 'Human readable', moduleWidth: 'Module width', + symbology: 'Symbology', + segments: 'Segments per row', }, planet: { content: 'Content', diff --git a/src/locales/es.ts b/src/locales/es.ts index a7caccee..328dc1b5 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -305,6 +305,8 @@ const es = { height: 'Altura (puntos)', printInterpretation: 'Legible', moduleWidth: 'Ancho módulo', + symbology: 'Simbología', + segments: 'Segmentos por fila', }, planet: { content: 'Contenido', diff --git a/src/locales/et.ts b/src/locales/et.ts index 55bc7e81..9e4fcff0 100644 --- a/src/locales/et.ts +++ b/src/locales/et.ts @@ -305,6 +305,8 @@ const et = { height: 'Kõrgus (punkti)', printInterpretation: 'Loetav', moduleWidth: 'Mooduli laius', + symbology: 'Sümbolika', + segments: 'Segmente rea kohta', }, planet: { content: 'Sisu', diff --git a/src/locales/fa.ts b/src/locales/fa.ts index 3756cb90..cff7a944 100644 --- a/src/locales/fa.ts +++ b/src/locales/fa.ts @@ -305,6 +305,8 @@ const fa = { height: 'ارتفاع (نقطه)', printInterpretation: 'قابل خواندن', moduleWidth: 'عرض ماژول', + symbology: 'نماد‌شناسی', + segments: 'بخش در هر ردیف', }, planet: { content: 'محتوا', diff --git a/src/locales/fi.ts b/src/locales/fi.ts index 2b797f9c..d7b76289 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -305,6 +305,8 @@ const fi = { height: 'Korkeus (pistettä)', printInterpretation: 'Luettava', moduleWidth: 'Moduulin leveys', + symbology: 'Symboliikka', + segments: 'Segmentit per rivi', }, planet: { content: 'Sisältö', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 0a2a1aa8..bff24116 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -305,6 +305,8 @@ const fr = { height: 'Hauteur (points)', printInterpretation: 'Lisible', moduleWidth: 'Largeur module', + symbology: 'Symbologie', + segments: 'Segments par ligne', }, planet: { content: 'Contenu', diff --git a/src/locales/he.ts b/src/locales/he.ts index 01fa5690..fbcce6e8 100644 --- a/src/locales/he.ts +++ b/src/locales/he.ts @@ -305,6 +305,8 @@ const he = { height: 'גובה (נקודות)', printInterpretation: 'קריא', moduleWidth: 'רוחב מודול', + symbology: 'סימבולוגיה', + segments: 'מקטעים לשורה', }, planet: { content: 'תוכן', diff --git a/src/locales/hr.ts b/src/locales/hr.ts index 523bed75..9c9b4e7b 100644 --- a/src/locales/hr.ts +++ b/src/locales/hr.ts @@ -305,6 +305,8 @@ const hr = { height: 'Visina (točke)', printInterpretation: 'Čitljivo za ljude', moduleWidth: 'Širina modula', + symbology: 'Simbologija', + segments: 'Segmenti po retku', }, planet: { content: 'Sadržaj', diff --git a/src/locales/hu.ts b/src/locales/hu.ts index ee37c2b9..243c1b97 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -305,6 +305,8 @@ const hu = { height: 'Magasság (pont)', printInterpretation: 'Olvasható', moduleWidth: 'Modulszélesség', + symbology: 'Szimbológia', + segments: 'Szegmens soronként', }, planet: { content: 'Tartalom', diff --git a/src/locales/it.ts b/src/locales/it.ts index e82bb9d1..ffe40947 100644 --- a/src/locales/it.ts +++ b/src/locales/it.ts @@ -305,6 +305,8 @@ const it = { height: 'Altezza (punti)', printInterpretation: 'Leggibile', moduleWidth: 'Larghezza modulo', + symbology: 'Simbologia', + segments: 'Segmenti per riga', }, planet: { content: 'Contenuto', diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 680a8a62..5e3dffc1 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -305,6 +305,8 @@ const ja = { height: '高さ(ドット)', printInterpretation: '可読文字', moduleWidth: 'モジュール幅', + symbology: 'シンボル体系', + segments: '1行あたりのセグメント数', }, planet: { content: '内容', diff --git a/src/locales/ko.ts b/src/locales/ko.ts index 838d96bc..eb286deb 100644 --- a/src/locales/ko.ts +++ b/src/locales/ko.ts @@ -305,6 +305,8 @@ const ko = { height: '높이 (도트)', printInterpretation: '판독 가능', moduleWidth: '모듈 폭', + symbology: '심볼로지', + segments: '행당 세그먼트', }, planet: { content: '내용', diff --git a/src/locales/lt.ts b/src/locales/lt.ts index fa3ffec2..a59b1621 100644 --- a/src/locales/lt.ts +++ b/src/locales/lt.ts @@ -305,6 +305,8 @@ const lt = { height: 'Aukštis (taškai)', printInterpretation: 'Skaitomas', moduleWidth: 'Modulio plotis', + symbology: 'Simbolika', + segments: 'Segmentai eilutėje', }, planet: { content: 'Turinys', diff --git a/src/locales/lv.ts b/src/locales/lv.ts index 0ad3d3c2..845d3627 100644 --- a/src/locales/lv.ts +++ b/src/locales/lv.ts @@ -305,6 +305,8 @@ const lv = { height: 'Augstums (punkti)', printInterpretation: 'Lasāms', moduleWidth: 'Moduļa platums', + symbology: 'Simboloģija', + segments: 'Segmenti uz rindu', }, planet: { content: 'Saturs', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index f30fa473..586dd2ec 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -305,6 +305,8 @@ const nl = { height: 'Hoogte (punten)', printInterpretation: 'Leesbaar', moduleWidth: 'Modulebreedte', + symbology: 'Symbologie', + segments: 'Segmenten per rij', }, planet: { content: 'Inhoud', diff --git a/src/locales/no.ts b/src/locales/no.ts index 22ba9011..faf571eb 100644 --- a/src/locales/no.ts +++ b/src/locales/no.ts @@ -305,6 +305,8 @@ const no = { height: 'Høyde (punkter)', printInterpretation: 'Lesbar', moduleWidth: 'Modulbredde', + symbology: 'Symbolikk', + segments: 'Segmenter per rad', }, planet: { content: 'Innhold', diff --git a/src/locales/pl.ts b/src/locales/pl.ts index b08f47f9..c6375bac 100644 --- a/src/locales/pl.ts +++ b/src/locales/pl.ts @@ -305,6 +305,8 @@ const pl = { height: 'Wysokość (punkty)', printInterpretation: 'Czytelny', moduleWidth: 'Szerokość modułu', + symbology: 'Symbolika', + segments: 'Segmenty na wiersz', }, planet: { content: 'Zawartość', diff --git a/src/locales/pt.ts b/src/locales/pt.ts index 13d244ae..764af39b 100644 --- a/src/locales/pt.ts +++ b/src/locales/pt.ts @@ -305,6 +305,8 @@ const pt = { height: 'Altura (pontos)', printInterpretation: 'Legível', moduleWidth: 'Largura módulo', + symbology: 'Simbologia', + segments: 'Segmentos por linha', }, planet: { content: 'Conteúdo', diff --git a/src/locales/ro.ts b/src/locales/ro.ts index 3d8657c1..d8e8a132 100644 --- a/src/locales/ro.ts +++ b/src/locales/ro.ts @@ -305,6 +305,8 @@ const ro = { height: 'Înălțime (puncte)', printInterpretation: 'Lizibil', moduleWidth: 'Lățime modul', + symbology: 'Simbologie', + segments: 'Segmente pe rând', }, planet: { content: 'Conținut', diff --git a/src/locales/sk.ts b/src/locales/sk.ts index 171baccf..1b683dc2 100644 --- a/src/locales/sk.ts +++ b/src/locales/sk.ts @@ -305,6 +305,8 @@ const sk = { height: 'Výška (body)', printInterpretation: 'Čitateľný pre človeka', moduleWidth: 'Šírka modulu', + symbology: 'Symbolika', + segments: 'Segmenty na riadok', }, planet: { content: 'Obsah', diff --git a/src/locales/sl.ts b/src/locales/sl.ts index 4dd0fb6d..e8d7391a 100644 --- a/src/locales/sl.ts +++ b/src/locales/sl.ts @@ -305,6 +305,8 @@ const sl = { height: 'Višina (pike)', printInterpretation: 'Berljivo za človeka', moduleWidth: 'Širina modula', + symbology: 'Simbologija', + segments: 'Segmenti na vrstico', }, planet: { content: 'Vsebina', diff --git a/src/locales/sr.ts b/src/locales/sr.ts index 16d58492..ce43c7ab 100644 --- a/src/locales/sr.ts +++ b/src/locales/sr.ts @@ -305,6 +305,8 @@ const sr = { height: 'Висина (тачке)', printInterpretation: 'Читљиво', moduleWidth: 'Ширина модула', + symbology: 'Симбологија', + segments: 'Сегменти по реду', }, planet: { content: 'Садржај', diff --git a/src/locales/sv.ts b/src/locales/sv.ts index 98268ea5..15022c1f 100644 --- a/src/locales/sv.ts +++ b/src/locales/sv.ts @@ -305,6 +305,8 @@ const sv = { height: 'Höjd (punkter)', printInterpretation: 'Läsbar', moduleWidth: 'Modulbredd', + symbology: 'Symbolik', + segments: 'Segment per rad', }, planet: { content: 'Innehåll', diff --git a/src/locales/tr.ts b/src/locales/tr.ts index 26ab530f..116427f3 100644 --- a/src/locales/tr.ts +++ b/src/locales/tr.ts @@ -305,6 +305,8 @@ const tr = { height: 'Yükseklik (nokta)', printInterpretation: 'Okunabilir', moduleWidth: 'Modül genişliği', + symbology: 'Semboloji', + segments: 'Satır başına segment', }, planet: { content: 'İçerik', diff --git a/src/locales/zh-hans.ts b/src/locales/zh-hans.ts index 13a69d77..a6dee33c 100644 --- a/src/locales/zh-hans.ts +++ b/src/locales/zh-hans.ts @@ -305,6 +305,8 @@ const zhHans = { height: '高度(点)', printInterpretation: '可读文字', moduleWidth: '模块宽度', + symbology: '符号体系', + segments: '每行段数', }, planet: { content: '内容', diff --git a/src/locales/zh-hant.ts b/src/locales/zh-hant.ts index 80cf79ce..d04a2a5f 100644 --- a/src/locales/zh-hant.ts +++ b/src/locales/zh-hant.ts @@ -305,6 +305,8 @@ const zhHant = { height: '高度(點)', printInterpretation: '可讀文字', moduleWidth: '模組寬度', + symbology: '符號體系', + segments: '每行段數', }, planet: { content: '內容', diff --git a/src/registry/gs1databar.tsx b/src/registry/gs1databar.tsx index 97d1ba54..bf00f2b5 100644 --- a/src/registry/gs1databar.tsx +++ b/src/registry/gs1databar.tsx @@ -1,23 +1,122 @@ -import { createBarcode1D } from "./barcode1d"; -export type { Barcode1DProps as Gs1DatabarProps } from "./barcode1d"; - -export const gs1databar = createBarcode1D({ - label: "GS1 Databar", - icon: "GS1", - defaultContent: "0112345678901", - hasCheckDigit: false, - localeKey: "gs1databar", +import type { ObjectTypeDefinition, LabelObjectBase } from '../types/ObjectType'; +import { useT } from '../lib/useT'; +import { inputCls, labelCls } from '../components/Properties/styles'; +import { fieldPos, fdField } from './zplHelpers'; +import { filterContent } from './contentSpec'; +import { type ZplRotation } from './rotation'; +import { RotationSelect } from '../components/Properties/RotationSelect'; + +export interface Gs1DatabarProps { + content: string; + moduleWidth: number; + symbology: 1 | 2 | 3 | 4 | 5 | 6 | 7; + segments?: number; + rotation: ZplRotation; +} + +const SYMBOLOGY_LABELS: Record = { + 1: 'Omnidirectional', + 2: 'Truncated', + 3: 'Stacked', + 4: 'Stacked Omni', + 5: 'Limited', + 6: 'Expanded', + 7: 'Expanded Stacked', +}; + +// Expanded variants accept free-form AI content; others expect digits only. +const EXPANDED_SYMBOLOGIES = new Set([6, 7]); + +export const gs1databar: ObjectTypeDefinition = { + label: 'GS1 Databar', + icon: 'GS1', group: 'code-1d', - contentSpec: { charset: '0-9' }, - // GS1 Databar Omnidirectional has a symbology-fixed height; Zebra/Labelary - // ignore the ^BR height parameter for this variant. Disabling resize and the - // height input keeps the designer honest about what affects the print. + defaultProps: { + content: '0112345678901', + moduleWidth: 2, + symbology: 1, + rotation: 'N', + }, + defaultSize: { width: 300, height: 120 }, heightLocked: true, - // ZPL ^BR has no HRI parameter — Labelary never prints text under the bars. - interpretationLocked: true, - zplCommand: (p) => { - // ^BR{orientation},{symbology},{magnification},{separator},{height},{segments} - // symbology 1 = omnidirectional - return `^BR${p.rotation},1,${p.moduleWidth},2,${p.height},2`; + + toZPL: (obj: LabelObjectBase & { props: Gs1DatabarProps }) => { + const p = obj.props; + // Segments must be even (2–22); only used by Expanded Stacked (7). + // Other symbologies require the field in the command but firmware ignores it. + const segs = p.symbology === 7 ? (p.segments ?? 22) : 2; + // ^BRo,s,m,sep,h,sg — height hardcoded 100 (firmware overrides for most variants) + return `^BY${p.moduleWidth}${fieldPos(obj)}^BR${p.rotation},${p.symbology},${p.moduleWidth},2,100,${segs}${fdField(p.content)}`; + }, + + PropertiesPanel: ({ obj, onChange }) => { + const t = useT(); + const p = obj.props; + const loc = t.registry.gs1databar; + const isExpanded = EXPANDED_SYMBOLOGIES.has(p.symbology); + return ( +
+
+ + onChange({ + content: filterContent(e.target.value, { + charset: isExpanded ? '0-9A-Za-z()' : '0-9', + }), + })} + /> +
+ +
+ + onChange({ moduleWidth: Number(e.target.value) })} + /> +
+ +
+ + +
+ + {p.symbology === 7 && ( +
+ + { + const v = Number(e.target.value); + onChange({ segments: v % 2 === 0 ? v : v + 1 }); + }} + /> +
+ )} + + onChange({ rotation })} + /> +
+ ); }, -}); +}; diff --git a/src/test/testModels.ts b/src/test/testModels.ts index 06e94971..fc8b8825 100644 --- a/src/test/testModels.ts +++ b/src/test/testModels.ts @@ -324,7 +324,7 @@ export const testModels: Record = { x: 50, y: 50, rotation: 0, - props: { content: "0112345678901", height: 100, moduleWidth: 2, printInterpretation: false, checkDigit: false, rotation: "N" }, + props: { content: "0112345678901", moduleWidth: 2, symbology: 1, rotation: "N" }, }, barcode_upce_standard: { id: "29", From b674f9836c072b2fc6894ce4dc5c510d01ec7e50 Mon Sep 17 00:00:00 2001 From: u8array Date: Wed, 6 May 2026 23:53:15 +0200 Subject: [PATCH 3/7] test(gs1databar): Labelary fixtures for symbologies 1-6 - New test cases for sym 2 (Truncated), 3 (Stacked), 4 (Stacked Omni), 5 (Limited), 6 (Expanded). Sym 7 (Expanded Stacked) excluded: bwip-js requires (AI)data parens, Labelary's ^BR sym 7 silently rejects them. - Sym 1 (Omnidirectional) is the only variant where bwip-js dimensions match Labelary exactly. Sym 2-6 have intrinsic-height divergences; ZPL output is still validated, dimension and pixel checks are skipped. - Drop the segments parameter from ^BR for non-Stacked variants: including it makes Labelary stack the symbol incorrectly. - bwip-js content prep: auto-compute GTIN-14 check digit (sym 1-5) and wrap fixed-length AIs in parens (sym 6/7) so the canonical model can store raw digits. --- src/components/Canvas/bwipHelpers.ts | 57 ++++++++++++++++-- src/registry/gs1databar.tsx | 10 +-- src/test/labelarySync.test.ts | 7 ++- src/test/testModels.ts | 40 ++++++++++++ src/test/visualRegression.test.ts | 8 ++- .../barcode_gs1databar_expanded.png | Bin 0 -> 6588 bytes .../barcode_gs1databar_limited.png | Bin 0 -> 6558 bytes .../barcode_gs1databar_stacked.png | Bin 0 -> 6638 bytes .../barcode_gs1databar_stacked_omni.png | Bin 0 -> 6777 bytes .../barcode_gs1databar_standard.png | Bin 6561 -> 6561 bytes .../barcode_gs1databar_truncated.png | Bin 0 -> 6561 bytes tests/fixtures/labelary_images/fixtures.json | 57 +++++++++++++++++- tests/fixtures/testCases.ts | 36 ++++++++++- 13 files changed, 200 insertions(+), 15 deletions(-) create mode 100644 tests/fixtures/labelary_images/barcode_gs1databar_expanded.png create mode 100644 tests/fixtures/labelary_images/barcode_gs1databar_limited.png create mode 100644 tests/fixtures/labelary_images/barcode_gs1databar_stacked.png create mode 100644 tests/fixtures/labelary_images/barcode_gs1databar_stacked_omni.png create mode 100644 tests/fixtures/labelary_images/barcode_gs1databar_truncated.png diff --git a/src/components/Canvas/bwipHelpers.ts b/src/components/Canvas/bwipHelpers.ts index 29c253b0..bdb1bd1c 100644 --- a/src/components/Canvas/bwipHelpers.ts +++ b/src/components/Canvas/bwipHelpers.ts @@ -14,6 +14,51 @@ const GS1_DATABAR_BCID: Record = { 7: "databarexpandedstacked", }; +// Fixed-length GS1 Application Identifiers used to wrap raw input in parens for +// bwip-js's databarexpanded(stacked) variants. Labelary accepts the raw form +// and this lets us keep one canonical content string in the model. +const GS1_FIXED_AI_LEN: Record = { + "00": 18, "01": 14, "02": 14, "11": 6, "13": 6, "15": 6, "17": 6, "20": 2, +}; + +// Pad to 13 digits and append GTIN-14 check digit. Used for sym 1–5 input +// where the user can provide a partial GTIN; Labelary auto-completes server-side +// but bwip-js requires a fully-valid 14-digit number with correct check. +function gtin14WithCheck(content: string): string { + let digits = content.replace(/\D/g, ""); + if (digits.startsWith("01") && digits.length > 14) digits = digits.slice(2); + if (digits.length >= 14) return digits.slice(0, 14); + const body = digits.padStart(13, "0"); + let sum = 0; + for (let i = 0; i < 13; i++) { + sum += parseInt(body[12 - i] ?? "0", 10) * (i % 2 === 0 ? 3 : 1); + } + return body + ((10 - (sum % 10)) % 10).toString(); +} + +function wrapGs1AIs(content: string): string { + if (content.includes("(")) return content; + let out = ""; + let pos = 0; + while (pos < content.length) { + const ai = content.slice(pos, pos + 2); + const len = GS1_FIXED_AI_LEN[ai]; + if (len === undefined) { + // Unknown AI: pass through what's left so bwip-js can surface a helpful error. + out += content.slice(pos); + break; + } + let data = content.slice(pos + 2, pos + 2 + len); + // Auto-complete GTIN-14 check digit if AI 01 data is short (13 digits). + if (ai === "01" && data.length < 14 && /^\d+$/.test(data)) { + data = gtin14WithCheck(data); + } + out += `(${ai})${data}`; + pos += 2 + len; + } + return out; +} + const BCID: Partial> = { code128: "code128", code39: "code39", @@ -275,20 +320,20 @@ export function buildBwipOptions( const isExpanded = sym === 6 || sym === 7; let text: string; if (isExpanded) { - text = p.content || "(01)00000000000000"; + // bwip-js needs (AI)data parens; canonical model stores raw digits. + text = wrapGs1AIs(p.content || "0112345678901231"); } else { - const raw = (p.content || "0").replace(/\D/g, ""); - text = `(01)${raw.padStart(13, "0").slice(0, 14)}`; + // Sym 1–5 require AI 01 + valid 14-digit GTIN with correct check. + text = `(01)${gtin14WithCheck(p.content || "")}`; } - const gs1Opts: Record = { + opts = { bcid: GS1_DATABAR_BCID[sym], text, scale: scale1D, height: 10, paddingheight: 2, + ...(sym === 7 ? { segments: p.segments ?? 22 } : {}), }; - if (sym === 7) gs1Opts["segments"] = p.segments ?? 22; - opts = gs1Opts as typeof opts; break; } case "planet": { diff --git a/src/registry/gs1databar.tsx b/src/registry/gs1databar.tsx index bf00f2b5..eb8e2ad4 100644 --- a/src/registry/gs1databar.tsx +++ b/src/registry/gs1databar.tsx @@ -42,11 +42,11 @@ export const gs1databar: ObjectTypeDefinition = { toZPL: (obj: LabelObjectBase & { props: Gs1DatabarProps }) => { const p = obj.props; - // Segments must be even (2–22); only used by Expanded Stacked (7). - // Other symbologies require the field in the command but firmware ignores it. - const segs = p.symbology === 7 ? (p.segments ?? 22) : 2; - // ^BRo,s,m,sep,h,sg — height hardcoded 100 (firmware overrides for most variants) - return `^BY${p.moduleWidth}${fieldPos(obj)}^BR${p.rotation},${p.symbology},${p.moduleWidth},2,100,${segs}${fdField(p.content)}`; + // ^BRo,s,m,sep,h[,sg] — segments must only be present for Expanded Stacked (7). + // Including segments on sym 1–6 makes Labelary stack the symbol (wrong rendering). + const segs = p.symbology === 7 ? `,${p.segments ?? 22}` : ''; + // Height hardcoded 100 (firmware overrides for most variants). + return `^BY${p.moduleWidth}${fieldPos(obj)}^BR${p.rotation},${p.symbology},${p.moduleWidth},2,100${segs}${fdField(p.content)}`; }, PropertiesPanel: ({ obj, onChange }) => { diff --git a/src/test/labelarySync.test.ts b/src/test/labelarySync.test.ts index 5cac8bfb..3d54f710 100644 --- a/src/test/labelarySync.test.ts +++ b/src/test/labelarySync.test.ts @@ -147,6 +147,11 @@ describe("Labelary Sync - Canvas Dimension Logic", () => { "code93", "code11", // quiet zone narrower than Zebra "plessey", // different bar encoding algorithm ].includes(obj.type); + // GS1 Databar variants 2–7 use intrinsic heights that bwip-js maps differently + // than Zebra firmware. Width agrees, height diverges. Sym 1 (Omnidirectional) + // matches and is checked strictly; the others get ZPL-only validation. + const isGs1NonOmni = obj.type === "gs1databar" + && (obj.props as { symbology?: number }).symbology !== 1; if (isEanUpc && !isQuarterRotated) { // Known discrepancy: Labelary reserves barHeight + EAN_TEXT_ZONE_DOTS (13 dots) @@ -183,7 +188,7 @@ describe("Labelary Sync - Canvas Dimension Logic", () => { // codablock — bwip-js uses different encoding parameters than Zebra firmware. // hasBwipSizeMismatch — bwip-natural size diverges from Labelary (see above). // EAN/UPC and logmars heights are excluded — see isEanUpc/hasLogmarsTextZone above. - if (obj.type !== "codablock" && !hasBwipSizeMismatch) { + if (obj.type !== "codablock" && !hasBwipSizeMismatch && !isGs1NonOmni) { // Quarter-rotated EAN/UPC moves the EAN_TEXT_ZONE_DOTS guard extension // onto the width axis instead of the height axis, mirroring the upright // height adjustment. diff --git a/src/test/testModels.ts b/src/test/testModels.ts index fc8b8825..ed1c5b58 100644 --- a/src/test/testModels.ts +++ b/src/test/testModels.ts @@ -326,6 +326,46 @@ export const testModels: Record = { rotation: 0, props: { content: "0112345678901", moduleWidth: 2, symbology: 1, rotation: "N" }, }, + barcode_gs1databar_truncated: { + id: "gs1_2", + type: "gs1databar", + x: 50, + y: 50, + rotation: 0, + props: { content: "0112345678901", moduleWidth: 2, symbology: 2, rotation: "N" }, + }, + barcode_gs1databar_stacked: { + id: "gs1_3", + type: "gs1databar", + x: 50, + y: 50, + rotation: 0, + props: { content: "0112345678901", moduleWidth: 2, symbology: 3, rotation: "N" }, + }, + barcode_gs1databar_stacked_omni: { + id: "gs1_4", + type: "gs1databar", + x: 50, + y: 50, + rotation: 0, + props: { content: "0112345678901", moduleWidth: 2, symbology: 4, rotation: "N" }, + }, + barcode_gs1databar_limited: { + id: "gs1_5", + type: "gs1databar", + x: 50, + y: 50, + rotation: 0, + props: { content: "0112345678901", moduleWidth: 2, symbology: 5, rotation: "N" }, + }, + barcode_gs1databar_expanded: { + id: "gs1_6", + type: "gs1databar", + x: 50, + y: 50, + rotation: 0, + props: { content: "0112345678901231", moduleWidth: 2, symbology: 6, rotation: "N" }, + }, barcode_upce_standard: { id: "29", type: "upce", diff --git a/src/test/visualRegression.test.ts b/src/test/visualRegression.test.ts index 397c0ee9..43e0e786 100644 --- a/src/test/visualRegression.test.ts +++ b/src/test/visualRegression.test.ts @@ -56,8 +56,14 @@ describe("Visual Regression - bwip-js vs Labelary", () => { "barcode_code93_standard", // bwip-js code11 quiet zone is narrower than Zebra's; same issue as code93. "barcode_code11_standard", - // bwip-js GS1 DataBar stacking/finder-pattern differs from Zebra firmware. + // bwip-js GS1 DataBar stacking/finder-pattern differs from Zebra firmware + // for the multi-row variants. Sym 1 (Omnidirectional) matches. "barcode_gs1databar_standard", + "barcode_gs1databar_truncated", + "barcode_gs1databar_stacked", + "barcode_gs1databar_stacked_omni", + "barcode_gs1databar_limited", + "barcode_gs1databar_expanded", // 2D encoder discrepancies between bwip-js and Zebra firmware persist // through rotation. For QR specifically the rotation appears to shift // bwip's chosen mask, widening the diff vs. Labelary even though the diff --git a/tests/fixtures/labelary_images/barcode_gs1databar_expanded.png b/tests/fixtures/labelary_images/barcode_gs1databar_expanded.png new file mode 100644 index 0000000000000000000000000000000000000000..33e124ab287f6a01bd20f91460ca6ad35503823f GIT binary patch literal 6588 zcmeAS@N?(olHy`uVBq!ia0y~yVAcU)4xj+T*59vgISRvd7+-PX&`BqQE3_kOG>Jkn;V-(LP|;k5Hm2jDI_#714Y=} zOb#%x_Vn=Z^f-VR2?q`w;9%o%IB?+Yqv7lk% z#KuMt7UYm7%d$} zOU2RJV6>7PZ5)g?ibmUtqiy8TzQJghX|$_2+C?56IT#%=867Da9Y7mO1BVR1S|e^I U?ctsdoMd6}boFyt=akR{0Li(EF8}}l literal 0 HcmV?d00001 diff --git a/tests/fixtures/labelary_images/barcode_gs1databar_limited.png b/tests/fixtures/labelary_images/barcode_gs1databar_limited.png new file mode 100644 index 0000000000000000000000000000000000000000..aaa86a3bb8c4aea662ba6ac64c750848d112d0e3 GIT binary patch literal 6558 zcmeAS@N?(olHy`uVBq!ia0y~yVAcU)4xj+Tw3hurk{S`_N85ks4JzX3_D&{2ru;+3+sG-1RVA#_XXu#FMw0LH! zNDq_J!HhIX*2N7T2D2hT@iqUkT3%i zSeZfsOb{pl;Xq{!P-PlG>J<`Nz(%$K<1d5n#3`M{2_ii%gDGBl{<|O4kYIITR0Fxu zjWIC6WHT@|I;cpT1vyrL)6MuaNWSUf86%+d#8K(dU>QvgquF4zbQmoaM{9%8N^-Pu zFxn^@Z7Ytpkw^Omqg|%auHtAHd35Apbi`zIq-b;iZFJypbifqfI5Gpn-NO+xC(TOj Q15Lbmy85}Sb4q9e07E5n5&!@I literal 0 HcmV?d00001 diff --git a/tests/fixtures/labelary_images/barcode_gs1databar_stacked.png b/tests/fixtures/labelary_images/barcode_gs1databar_stacked.png new file mode 100644 index 0000000000000000000000000000000000000000..92f95ffb33ef0d0374910bd3cb900bb7fb30941a GIT binary patch literal 6638 zcmeAS@N?(olHy`uVBq!ia0y~yVAcU)4xj+TMdc=1Ycpx;TbZ%t`)X&*gSdLxIb{u%{`|fUARP@yt|_ z9ww!O8EKNNiyJ%)W<`SJg?iGafuvbRrD+f>DXC&^ZftBoY><$Wk^saGj6FR(2?sdX zcr+R&PHb$PIFXS@qOqZ|QIJPM0?1(GkdTxFMvO#D!1ShD)5)S)3pu!0Bdu8f149n%$&HGqwE(8rH+Kh(Y);(6q%38lzcYH0zI+h@&;Z zXbm{pFc@tRjWz^F+sC8*g3gTe~DWM4fk*e(& literal 0 HcmV?d00001 diff --git a/tests/fixtures/labelary_images/barcode_gs1databar_stacked_omni.png b/tests/fixtures/labelary_images/barcode_gs1databar_stacked_omni.png new file mode 100644 index 0000000000000000000000000000000000000000..f3a6e0ed7752476841c26426b558299c156fda0f GIT binary patch literal 6777 zcmeAS@N?(olHy`uVBq!ia0y~yVAcU)4xj+T$_`mSB1QpJd}n z`0(GJ`SAb$JQ@%F*R!?#|1T-=ubB}n!t?*XvB97J=MTV?Bz%zPkbp`9^D>)hLXrgQ z;sg-^RyX6*8WOB7O&38)iE&~>)0s~YV|#jlh8X=rG6ZBQNZ`be|Mtqt%*@J<|Nn=p z0t>6N zM#2FGR%rMD8G#JU0)N;+Rt6kkU_pq30uW^Q1x8$kqlFy0-J{ar1T}PnrQzX!V9r?F zpmE>`C;e8Po+Vfy3jKWEtPI{{~i7(8A5T-G@yGywp`aV~)X literal 0 HcmV?d00001 diff --git a/tests/fixtures/labelary_images/barcode_gs1databar_standard.png b/tests/fixtures/labelary_images/barcode_gs1databar_standard.png index 67f0b98dee826d3127f724bdad7ccc798c531e4b..4bc648a3f2e111a89635cbb7080a70581e000118 100644 GIT binary patch delta 43 ycmZ2zywG?;qC$9TijIPrf^TA_f{}rdnSzm_m9eFjsgZ)aZ-{NgzmFRm6(j*0qYX{~ delta 43 ycmZ2zywG?;qJmpdrjCN4f^TA_f{}rdnSz0(m4UgHv8jT)Z%A~{l_?t=6(j)~%ncU+ diff --git a/tests/fixtures/labelary_images/barcode_gs1databar_truncated.png b/tests/fixtures/labelary_images/barcode_gs1databar_truncated.png new file mode 100644 index 0000000000000000000000000000000000000000..6204761563fd58baa36ce1c8c81176bce4c38b2b GIT binary patch literal 6561 zcmeAS@N?(olHy`uVBq!ia0y~yVAcU)4xj+TMdc=1X#Tx;TbZ%t`)X&*gSdLxIb{u%{`|fUARP@yt|_ z9ww!O8EKNNiyJ%)W<`SJg?iGafuvbRrD+f>DXC&^ZftBoY><$Wk^saGj6FR(2?sdX zcr+R&PHb$PIFXS@qOqZ|QIJPM0?1(G0P{dnf*cYD8U(q3!jh5_5(^s`Sq+#NnFS1t zj2sx11q=*;3 Date: Thu, 7 May 2026 00:05:04 +0200 Subject: [PATCH 4/7] refactor(gs1): extract domain helpers into lib/gs1.ts with tests Moves gtin14WithCheck, wrapGs1AIs, the Expanded-symbology set, and the default segments constant out of bwipHelpers.ts (rendering layer) into src/lib/gs1.ts where ZPL generation and bwip-js prep can share them. - bwipHelpers and gs1databar both import from lib/gs1 - Magic value 22 replaced by GS1_DATABAR_DEFAULT_SEGMENTS - EXPANDED_SYMBOLOGIES set is now single-source-of-truth - 11 new unit tests cover GTIN-14 check digit and AI wrapping edge cases --- src/components/Canvas/bwipHelpers.ts | 71 +++++++--------------------- src/lib/gs1.test.ts | 66 ++++++++++++++++++++++++++ src/lib/gs1.ts | 61 ++++++++++++++++++++++++ src/registry/gs1databar.tsx | 13 ++--- 4 files changed, 150 insertions(+), 61 deletions(-) create mode 100644 src/lib/gs1.test.ts create mode 100644 src/lib/gs1.ts diff --git a/src/components/Canvas/bwipHelpers.ts b/src/components/Canvas/bwipHelpers.ts index bdb1bd1c..20de5431 100644 --- a/src/components/Canvas/bwipHelpers.ts +++ b/src/components/Canvas/bwipHelpers.ts @@ -2,6 +2,12 @@ import type { LabelObject } from "../../registry"; import type { Gs1DatabarProps } from "../../registry/gs1databar"; import { objectRotation } from "../../registry/rotation"; import { dotsToPx } from "../../lib/coordinates"; +import { + GS1_DATABAR_DEFAULT_SEGMENTS, + GS1_DATABAR_EXPANDED_SYMBOLOGIES, + gtin14WithCheck, + wrapGs1AIs, +} from "../../lib/gs1"; import { MICROPDF417_QUIET_ZONE_ROWS } from "./bwipConstants"; const GS1_DATABAR_BCID: Record = { @@ -14,51 +20,6 @@ const GS1_DATABAR_BCID: Record = { 7: "databarexpandedstacked", }; -// Fixed-length GS1 Application Identifiers used to wrap raw input in parens for -// bwip-js's databarexpanded(stacked) variants. Labelary accepts the raw form -// and this lets us keep one canonical content string in the model. -const GS1_FIXED_AI_LEN: Record = { - "00": 18, "01": 14, "02": 14, "11": 6, "13": 6, "15": 6, "17": 6, "20": 2, -}; - -// Pad to 13 digits and append GTIN-14 check digit. Used for sym 1–5 input -// where the user can provide a partial GTIN; Labelary auto-completes server-side -// but bwip-js requires a fully-valid 14-digit number with correct check. -function gtin14WithCheck(content: string): string { - let digits = content.replace(/\D/g, ""); - if (digits.startsWith("01") && digits.length > 14) digits = digits.slice(2); - if (digits.length >= 14) return digits.slice(0, 14); - const body = digits.padStart(13, "0"); - let sum = 0; - for (let i = 0; i < 13; i++) { - sum += parseInt(body[12 - i] ?? "0", 10) * (i % 2 === 0 ? 3 : 1); - } - return body + ((10 - (sum % 10)) % 10).toString(); -} - -function wrapGs1AIs(content: string): string { - if (content.includes("(")) return content; - let out = ""; - let pos = 0; - while (pos < content.length) { - const ai = content.slice(pos, pos + 2); - const len = GS1_FIXED_AI_LEN[ai]; - if (len === undefined) { - // Unknown AI: pass through what's left so bwip-js can surface a helpful error. - out += content.slice(pos); - break; - } - let data = content.slice(pos + 2, pos + 2 + len); - // Auto-complete GTIN-14 check digit if AI 01 data is short (13 digits). - if (ai === "01" && data.length < 14 && /^\d+$/.test(data)) { - data = gtin14WithCheck(data); - } - out += `(${ai})${data}`; - pos += 2 + len; - } - return out; -} - const BCID: Partial> = { code128: "code128", code39: "code39", @@ -75,6 +36,9 @@ const BCID: Partial> = { logmars: "code39", msi: "msi", plessey: "plessey", + // Placeholder — the actual bcid is resolved per-symbology via + // GS1_DATABAR_BCID below. This entry exists only to pass the + // `if (!bcid) return null` guard at the top of buildBwipOptions. gs1databar: "databaromni", planet: "planet", postal: "postnet", @@ -317,22 +281,19 @@ export function buildBwipOptions( case "gs1databar": { const p = obj.props as Gs1DatabarProps; const sym = p.symbology ?? 1; - const isExpanded = sym === 6 || sym === 7; - let text: string; - if (isExpanded) { - // bwip-js needs (AI)data parens; canonical model stores raw digits. - text = wrapGs1AIs(p.content || "0112345678901231"); - } else { - // Sym 1–5 require AI 01 + valid 14-digit GTIN with correct check. - text = `(01)${gtin14WithCheck(p.content || "")}`; - } + const isExpanded = GS1_DATABAR_EXPANDED_SYMBOLOGIES.has(sym); + // bwip-js needs (AI)data parens; canonical model stores raw digits. + // Sym 1–5 require AI 01 + valid 14-digit GTIN with correct check. + const text = isExpanded + ? wrapGs1AIs(p.content || "0112345678901231") + : `(01)${gtin14WithCheck(p.content || "")}`; opts = { bcid: GS1_DATABAR_BCID[sym], text, scale: scale1D, height: 10, paddingheight: 2, - ...(sym === 7 ? { segments: p.segments ?? 22 } : {}), + ...(sym === 7 ? { segments: p.segments ?? GS1_DATABAR_DEFAULT_SEGMENTS } : {}), }; break; } diff --git a/src/lib/gs1.test.ts b/src/lib/gs1.test.ts new file mode 100644 index 00000000..d495e623 --- /dev/null +++ b/src/lib/gs1.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from "vitest"; +import { + gtin14WithCheck, + wrapGs1AIs, + GS1_DATABAR_EXPANDED_SYMBOLOGIES, + GS1_DATABAR_DEFAULT_SEGMENTS, +} from "./gs1"; + +describe("gtin14WithCheck", () => { + it("computes the check digit for a 13-digit body", () => { + // Known reference: GTIN-13 "0112345678901" → check 1 (verified empirically) + expect(gtin14WithCheck("0112345678901")).toBe("01123456789011"); + }); + + it("pads short input to 13 digits before computing check", () => { + expect(gtin14WithCheck("12345")).toHaveLength(14); + }); + + it("returns the input unchanged when 14 digits are supplied", () => { + expect(gtin14WithCheck("01123456789011")).toBe("01123456789011"); + }); + + it("strips an AI 01 prefix when input is longer than 14 digits", () => { + // "01" prefix + 14 digits → keep only the 14 + expect(gtin14WithCheck("0101123456789011")).toBe("01123456789011"); + }); + + it("ignores non-digit characters", () => { + expect(gtin14WithCheck("(01)12345")).toHaveLength(14); + }); +}); + +describe("wrapGs1AIs", () => { + it("wraps raw AI 01 + GTIN-14 in parens", () => { + expect(wrapGs1AIs("0112345678901231")).toBe("(01)12345678901231"); + }); + + it("auto-completes the GTIN check digit when AI 01 data is short", () => { + // 11 digits after "01" → padded to 13 + check = 14 + const out = wrapGs1AIs("0112345678901"); + expect(out.startsWith("(01)")).toBe(true); + expect(out.slice(4)).toHaveLength(14); + }); + + it("passes through already-parenthesised input unchanged", () => { + expect(wrapGs1AIs("(01)12345678901231")).toBe("(01)12345678901231"); + }); + + it("appends unknown AI data verbatim so bwip-js surfaces the error", () => { + // AI 99 is not in the fixed-length table + expect(wrapGs1AIs("99abcdef")).toBe("99abcdef"); + }); +}); + +describe("constants", () => { + it("treats only 6 and 7 as Expanded variants", () => { + expect(GS1_DATABAR_EXPANDED_SYMBOLOGIES.has(6)).toBe(true); + expect(GS1_DATABAR_EXPANDED_SYMBOLOGIES.has(7)).toBe(true); + expect(GS1_DATABAR_EXPANDED_SYMBOLOGIES.has(1)).toBe(false); + }); + + it("uses the spec-maximum 22 as the Expanded Stacked segments default", () => { + expect(GS1_DATABAR_DEFAULT_SEGMENTS).toBe(22); + expect(GS1_DATABAR_DEFAULT_SEGMENTS % 2).toBe(0); + }); +}); diff --git a/src/lib/gs1.ts b/src/lib/gs1.ts new file mode 100644 index 00000000..d6534f4f --- /dev/null +++ b/src/lib/gs1.ts @@ -0,0 +1,61 @@ +/** + * GS1 Databar domain helpers: AI parsing, GTIN check digit, and shared constants. + * + * These live outside the rendering layer because the same logic is shared between + * ZPL generation, bwip-js content prep, and (future) input validation. + */ + +/** Symbologies that accept free-form AI content (Expanded, Expanded Stacked). */ +export const GS1_DATABAR_EXPANDED_SYMBOLOGIES: ReadonlySet = new Set([6, 7]); + +/** Spec-maximum segments-per-row for ^BR Expanded Stacked (must be even, 2–22). */ +export const GS1_DATABAR_DEFAULT_SEGMENTS = 22; + +/** Fixed-length GS1 Application Identifiers — used to wrap raw input in parens. */ +const FIXED_AI_LEN: Record = { + "00": 18, "01": 14, "02": 14, "11": 6, "13": 6, "15": 6, "17": 6, "20": 2, +}; + +/** + * Pad to 13 digits and append the GTIN-14 check digit. Used for symbologies 1–5 + * where the user can supply a partial GTIN; bwip-js requires a fully-valid + * 14-digit number, while Labelary completes it server-side. + */ +export function gtin14WithCheck(content: string): string { + let digits = content.replace(/\D/g, ""); + if (digits.startsWith("01") && digits.length > 14) digits = digits.slice(2); + if (digits.length >= 14) return digits.slice(0, 14); + const body = digits.padStart(13, "0"); + let sum = 0; + for (let i = 0; i < 13; i++) { + sum += parseInt(body[12 - i] ?? "0", 10) * (i % 2 === 0 ? 3 : 1); + } + return body + ((10 - (sum % 10)) % 10).toString(); +} + +/** + * Wrap a raw GS1 AI sequence (e.g. "0112345678901231") in parens for bwip-js + * (e.g. "(01)12345678901231"). Already-wrapped input passes through unchanged. + * Unknown AIs short-circuit and are appended verbatim so bwip-js can surface a + * helpful parser error. + */ +export function wrapGs1AIs(content: string): string { + if (content.includes("(")) return content; + let out = ""; + let pos = 0; + while (pos < content.length) { + const ai = content.slice(pos, pos + 2); + const len = FIXED_AI_LEN[ai]; + if (len === undefined) { + out += content.slice(pos); + break; + } + let data = content.slice(pos + 2, pos + 2 + len); + if (ai === "01" && data.length < 14 && /^\d+$/.test(data)) { + data = gtin14WithCheck(data); + } + out += `(${ai})${data}`; + pos += 2 + len; + } + return out; +} diff --git a/src/registry/gs1databar.tsx b/src/registry/gs1databar.tsx index eb8e2ad4..f5991761 100644 --- a/src/registry/gs1databar.tsx +++ b/src/registry/gs1databar.tsx @@ -5,6 +5,10 @@ import { fieldPos, fdField } from './zplHelpers'; import { filterContent } from './contentSpec'; import { type ZplRotation } from './rotation'; import { RotationSelect } from '../components/Properties/RotationSelect'; +import { + GS1_DATABAR_DEFAULT_SEGMENTS, + GS1_DATABAR_EXPANDED_SYMBOLOGIES, +} from '../lib/gs1'; export interface Gs1DatabarProps { content: string; @@ -24,9 +28,6 @@ const SYMBOLOGY_LABELS: Record = { 7: 'Expanded Stacked', }; -// Expanded variants accept free-form AI content; others expect digits only. -const EXPANDED_SYMBOLOGIES = new Set([6, 7]); - export const gs1databar: ObjectTypeDefinition = { label: 'GS1 Databar', icon: 'GS1', @@ -44,7 +45,7 @@ export const gs1databar: ObjectTypeDefinition = { const p = obj.props; // ^BRo,s,m,sep,h[,sg] — segments must only be present for Expanded Stacked (7). // Including segments on sym 1–6 makes Labelary stack the symbol (wrong rendering). - const segs = p.symbology === 7 ? `,${p.segments ?? 22}` : ''; + const segs = p.symbology === 7 ? `,${p.segments ?? GS1_DATABAR_DEFAULT_SEGMENTS}` : ''; // Height hardcoded 100 (firmware overrides for most variants). return `^BY${p.moduleWidth}${fieldPos(obj)}^BR${p.rotation},${p.symbology},${p.moduleWidth},2,100${segs}${fdField(p.content)}`; }, @@ -53,7 +54,7 @@ export const gs1databar: ObjectTypeDefinition = { const t = useT(); const p = obj.props; const loc = t.registry.gs1databar; - const isExpanded = EXPANDED_SYMBOLOGIES.has(p.symbology); + const isExpanded = GS1_DATABAR_EXPANDED_SYMBOLOGIES.has(p.symbology); return (
@@ -100,7 +101,7 @@ export const gs1databar: ObjectTypeDefinition = { Date: Thu, 7 May 2026 00:07:34 +0200 Subject: [PATCH 5/7] refactor(gs1): drop unreachable fallbacks, name the ZPL height constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove the `p.content || "…"` defensive fallbacks in buildBwipOptions: defaultProps already populates content, the fallback was dead code. - Drop the redundant `as Gs1DatabarProps` cast — the switch case narrows obj.props automatically. - Replace the hardcoded 100 in toZPL with ZPL_HEIGHT_PLACEHOLDER, with a comment documenting why the value doesn't reflect actual bar height. - Document SYMBOLOGY_LABELS being intentionally not i18n'd (matches the convention used for other barcode-type names). - Test side: drop the partial `as { symbology?: number }` cast — proper type narrowing reaches obj.props.symbology directly. --- src/components/Canvas/bwipHelpers.ts | 8 ++++---- src/registry/gs1databar.tsx | 11 +++++++++-- src/test/labelarySync.test.ts | 3 +-- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/components/Canvas/bwipHelpers.ts b/src/components/Canvas/bwipHelpers.ts index 20de5431..7b8aaa4d 100644 --- a/src/components/Canvas/bwipHelpers.ts +++ b/src/components/Canvas/bwipHelpers.ts @@ -279,14 +279,14 @@ export function buildBwipOptions( break; } case "gs1databar": { - const p = obj.props as Gs1DatabarProps; - const sym = p.symbology ?? 1; + const p = obj.props; + const sym = p.symbology; const isExpanded = GS1_DATABAR_EXPANDED_SYMBOLOGIES.has(sym); // bwip-js needs (AI)data parens; canonical model stores raw digits. // Sym 1–5 require AI 01 + valid 14-digit GTIN with correct check. const text = isExpanded - ? wrapGs1AIs(p.content || "0112345678901231") - : `(01)${gtin14WithCheck(p.content || "")}`; + ? wrapGs1AIs(p.content) + : `(01)${gtin14WithCheck(p.content)}`; opts = { bcid: GS1_DATABAR_BCID[sym], text, diff --git a/src/registry/gs1databar.tsx b/src/registry/gs1databar.tsx index f5991761..a549f06b 100644 --- a/src/registry/gs1databar.tsx +++ b/src/registry/gs1databar.tsx @@ -18,6 +18,14 @@ export interface Gs1DatabarProps { rotation: ZplRotation; } +// ZPL ^BR height parameter. Zebra firmware overrides this with each variant's +// intrinsic bar height for sym 1–5; only Expanded Stacked (sym 7) uses it as +// per-row height. Hardcoded because heightLocked: true forbids resizing. +const ZPL_HEIGHT_PLACEHOLDER = 100; + +// GS1 standard variant names. Kept in source rather than i18n because the spec +// defines them as proper nouns (matches the convention used for barcode-type +// labels like "GS1 Databar", "PDF417" in src/locales). const SYMBOLOGY_LABELS: Record = { 1: 'Omnidirectional', 2: 'Truncated', @@ -46,8 +54,7 @@ export const gs1databar: ObjectTypeDefinition = { // ^BRo,s,m,sep,h[,sg] — segments must only be present for Expanded Stacked (7). // Including segments on sym 1–6 makes Labelary stack the symbol (wrong rendering). const segs = p.symbology === 7 ? `,${p.segments ?? GS1_DATABAR_DEFAULT_SEGMENTS}` : ''; - // Height hardcoded 100 (firmware overrides for most variants). - return `^BY${p.moduleWidth}${fieldPos(obj)}^BR${p.rotation},${p.symbology},${p.moduleWidth},2,100${segs}${fdField(p.content)}`; + return `^BY${p.moduleWidth}${fieldPos(obj)}^BR${p.rotation},${p.symbology},${p.moduleWidth},2,${ZPL_HEIGHT_PLACEHOLDER}${segs}${fdField(p.content)}`; }, PropertiesPanel: ({ obj, onChange }) => { diff --git a/src/test/labelarySync.test.ts b/src/test/labelarySync.test.ts index 3d54f710..ade14449 100644 --- a/src/test/labelarySync.test.ts +++ b/src/test/labelarySync.test.ts @@ -150,8 +150,7 @@ describe("Labelary Sync - Canvas Dimension Logic", () => { // GS1 Databar variants 2–7 use intrinsic heights that bwip-js maps differently // than Zebra firmware. Width agrees, height diverges. Sym 1 (Omnidirectional) // matches and is checked strictly; the others get ZPL-only validation. - const isGs1NonOmni = obj.type === "gs1databar" - && (obj.props as { symbology?: number }).symbology !== 1; + const isGs1NonOmni = obj.type === "gs1databar" && obj.props.symbology !== 1; if (isEanUpc && !isQuarterRotated) { // Known discrepancy: Labelary reserves barHeight + EAN_TEXT_ZONE_DOTS (13 dots) From 3e1ba3429a63f35858a8f64bf61e529eede2364e Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 7 May 2026 00:09:47 +0200 Subject: [PATCH 6/7] =?UTF-8?q?refactor(gs1):=20final=20cleanup=20?= =?UTF-8?q?=E2=80=94=20parser=20uses=20constant,=20drop=20redundant=20cast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - zplParser BR handler imports GS1_DATABAR_DEFAULT_SEGMENTS instead of the bare 22 literal. - Object.entries(SYMBOLOGY_LABELS) cast removed: TS infers the right [string, string][] tuple shape from the Record's value type. --- src/lib/zplParser.ts | 3 ++- src/registry/gs1databar.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/zplParser.ts b/src/lib/zplParser.ts index b67549d6..078b6d22 100644 --- a/src/lib/zplParser.ts +++ b/src/lib/zplParser.ts @@ -19,6 +19,7 @@ import type { AztecProps } from "../registry/aztec"; import type { MicroPdf417Props } from "../registry/micropdf417"; import type { CodablockProps } from "../registry/codablock"; import { putImage } from "./imageCache"; +import { GS1_DATABAR_DEFAULT_SEGMENTS } from "./gs1"; /** * Categorised import report produced alongside the parsed objects. @@ -717,7 +718,7 @@ export function parseZPL(zpl: string, dpmm = 8): ParsedZPL { bcRotation = readRotation(p[0]); byModuleWidth = int(p[2], byModuleWidth); gsSymbology = (int(p[1], 1) as Gs1DatabarProps["symbology"]) || 1; - gsSegments = p[5] !== undefined ? int(p[5], 22) : undefined; + gsSegments = p[5] !== undefined ? int(p[5], GS1_DATABAR_DEFAULT_SEGMENTS) : undefined; }, // ^BQN,2,{magnification} — QR Code diff --git a/src/registry/gs1databar.tsx b/src/registry/gs1databar.tsx index a549f06b..af91a994 100644 --- a/src/registry/gs1databar.tsx +++ b/src/registry/gs1databar.tsx @@ -96,7 +96,7 @@ export const gs1databar: ObjectTypeDefinition = { value={p.symbology} onChange={(e) => onChange({ symbology: Number(e.target.value) as Gs1DatabarProps['symbology'] })} > - {(Object.entries(SYMBOLOGY_LABELS) as [string, string][]).map(([val, name]) => ( + {Object.entries(SYMBOLOGY_LABELS).map(([val, name]) => ( ))} From e7b22a5da34939e612b222a0ad8f4ab52a8688f4 Mon Sep 17 00:00:00 2001 From: u8array Date: Thu, 7 May 2026 00:15:38 +0200 Subject: [PATCH 7/7] =?UTF-8?q?fix(gs1databar):=20clamp=20segments=20input?= =?UTF-8?q?=20to=20spec=20range=202=E2=80=9322?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direct numeric typing (or paste) bypasses the HTML max attribute. With the prior round-to-even logic, an input of 23 would land on 24 — outside the ZPL spec range. Clamp via Math.max(2, Math.min(22, …)) after rounding. --- src/registry/gs1databar.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/registry/gs1databar.tsx b/src/registry/gs1databar.tsx index af91a994..ade94d8e 100644 --- a/src/registry/gs1databar.tsx +++ b/src/registry/gs1databar.tsx @@ -114,7 +114,8 @@ export const gs1databar: ObjectTypeDefinition = { step={2} onChange={(e) => { const v = Number(e.target.value); - onChange({ segments: v % 2 === 0 ? v : v + 1 }); + const even = v % 2 === 0 ? v : v + 1; + onChange({ segments: Math.max(2, Math.min(22, even)) }); }} />