Release Barcode Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 11:08:20 +02:00
parent 81677e1134
commit d94e03b81a
29 changed files with 1266 additions and 100 deletions
+39
View File
@@ -0,0 +1,39 @@
name: Verify
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
CI: "true"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Select declared npm version
run: npm install --global npm@11.17.0
- name: Install dependencies
run: npm ci
- name: Audit runtime dependencies
run: npm audit --omit=dev --audit-level=moderate
- name: Check, test, and build
run: npm run check
- name: Install browser engines
run: npx playwright install --with-deps chromium firefox webkit
- name: Browser tests
run: npm run test:browser
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## 0.2.0 - 2026-09-02
- Added bounded common-GS1 Application Identifier parsing and validation.
- Added physical-size/raster planning, honest quiet-zone guidance and print-sized SVG export.
- Added an explicitly permission-gated native `BarcodeDetector` camera path with local ZXing fallback.
## 0.1.0 - 2026-09-01
- Added the initial local-first Barcode Tools workbench.
+6 -5
View File
@@ -4,15 +4,16 @@ Generate and decode QR codes and barcodes locally in the browser.
Barcode Tools is a standalone local-first application in the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). Inputs are processed in the browser and are not uploaded.
## Version 0.1 scope
## Current scope
- Local SVG generation for QR, Data Matrix, PDF417, Aztec and selected linear symbologies
- Local barcode decoding from a bounded image file or an explicitly enabled camera
- Local barcode decoding from a bounded image file or an explicitly enabled, permission-gated camera; native `BarcodeDetector` is preferred when available and local ZXing is the fallback
- Escaped URL, email, SMS, Wi-Fi, vCard and geo QR payload builders
- Bounded CSV batch generation into a deterministic SVG ZIP
- GTIN check-digit validation plus non-authoritative quiet-zone guidance
- Bounded parsing and validation for common GS1 Application Identifiers in parenthesized HRI and raw element strings, including FNC1/ASCII GS boundaries, dates, check digits and decimal indicators
- GTIN check-digit validation, physical SVG sizing, raster-dimension planning, nominal quiet-zone guidance and print-sized SVG export
Generated SVG is checked for active or linked content before preview/download. Decoded payloads are displayed as text and never opened automatically. Scanner interoperability, GS1 allocation and print quality still require testing against the applicable specifications. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
Generated SVG is checked for active or linked content before preview/download. Decoded payloads are displayed as text and never opened automatically. GS1 allocation/ownership is not queried. bwip-js padding is in points rather than barcode modules, so quiet-zone conformance must be measured on final output; scanner interoperability and print grade still require testing against the intended scanner and applicable specifications. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
## Development
@@ -26,7 +27,7 @@ npm run test:browser
## Release
`npm run release:artifact` creates a deterministic `release/barcode-tools-0.1.0.zip` and checksum sidecar.
`npm run release:artifact` creates a deterministic `release/barcode-tools-0.2.0.zip` and checksum sidecar.
## Licence
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source
The corresponding source for Barcode Tools 0.1.0 is available at:
The corresponding source for Barcode Tools 0.2.0 is available at:
https://git.add-ideas.de/lotobo/barcode-tools/src/tag/v0.1.0
https://git.add-ideas.de/lotobo/barcode-tools/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+3 -3
View File
@@ -4,9 +4,9 @@ Barcode Tools 0.1.0 directly depends on these runtime packages:
| Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `@zxing/browser` | 0.1.5 | MIT |
| `@zxing/library` | 0.21.3 | MIT |
| `bwip-js` | 4.11.4 | MIT |
+5 -3
View File
@@ -1,7 +1,9 @@
# Architecture
Barcode Tools is a static React/Vite application wrapped in the shared Toolbox shell. `barcode/generate.ts` validates options, delegates encoding to the browser build of `bwip-js`, rejects active or linked constructs in the generated SVG string, and previews it through a revocable blob URL. It also parses the bounded two-column batch format and creates deterministic SVG ZIPs with `fflate`.
Barcode Tools is a static React/Vite application wrapped in the shared Toolbox shell. `barcode/generate.ts` validates options, delegates encoding to the browser build of `bwip-js`, rejects active or linked constructs in the generated SVG string, and previews it through a revocable blob URL. It also parses the bounded two-column batch format, creates deterministic SVG ZIPs, computes physical/raster planning dimensions from the generated aspect ratio, and adds millimetre dimensions to a print SVG without rasterizing it. Quiet-zone module values are guidance only because bwip-js padding is measured in points.
`barcode/payloads.ts` escapes structured QR fields and validates GTIN check digits. The decode workspace lazy-loads ZXing only after a file or camera action. Image files are passed through a revocable object URL; camera acquisition is explicit and its controls are stopped on request or unmount. Decoded values are rendered as text and never navigated.
`barcode/payloads.ts` escapes structured QR fields and validates GTIN check digits. `barcode/gs1.ts` recognizes a bounded table of common GS1 AIs, consumes fixed-width fields, requires GS separators for non-final variable fields, validates lengths/data classes/dates/check digits, and stops at an unknown raw AI rather than guessing its boundary.
Version 0.1 does not create an application worker or persist input. The camera requires a secure context but not a top-level browsing context. Relative entry and asset URLs keep the build relocatable below a nested portal path.
The decode workspace lazy-loads ZXing only after a file or camera action. Pressing **Start camera** is the sole path to `getUserMedia`. When the browser exposes `BarcodeDetector`, it processes at most one frame per animation frame; otherwise the local ZXing constraint decoder is used. Image files are passed through a revocable object URL; camera streams/animation/ZXing controls are stopped on request, failure or unmount. Decoded values are rendered as text and never navigated.
The application does not persist input. The camera requires a secure context and may be constrained by the embedding portal's Permissions Policy. Relative entry and asset URLs keep the build relocatable below a nested portal path.
+2 -2
View File
@@ -1,7 +1,7 @@
# Privacy and security
Payloads, images and camera frames are processed in the browser. There is no telemetry, analytics, account, persistence or server upload. Camera access happens only after pressing **Start camera**, is governed by browser permission, requests video without audio, and can be stopped from the workspace.
Payloads, images and camera frames are processed in the browser. There is no telemetry, analytics, account, persistence or server upload. Camera access happens only after pressing **Start camera**, is governed by browser permission/Permissions Policy, requests video without audio, and can be stopped from the workspace. The stream and scan loop are stopped on cancellation, failure and unmount.
Images are limited to 20 MiB, generated payloads to 4,096 characters, and batch input to 256 KiB/100 rows. Structured QR fields are escaped and Wi-Fi security is restricted to WPA, WEP or nopass. Generated SVG is rejected if it contains scripts, foreign objects, event handlers or linked content; previews use local blob URLs.
Decoded payloads are untrusted text and are never opened automatically. A valid check digit does not prove GS1 allocation, and the quiet-zone text is guidance rather than print-quality validation. Interoperability and physical print quality must be checked with the intended scanner and applicable specification.
Decoded payloads are untrusted text and are never opened automatically. A recognized AI or valid check digit does not prove GS1 allocation, ownership or complete carrier conformance. Unknown raw AIs stop parsing to avoid inventing field boundaries. Physical dimensions and raster pixels are planning values: generated padding is expressed in points, not modules, so quiet zones/X-dimension and physical print quality must be measured and tested with the intended scanner and applicable specification.
+22 -23
View File
@@ -1,17 +1,17 @@
{
"name": "barcode-tools",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "barcode-tools",
"version": "0.1.0",
"version": "0.2.0",
"license": "GPL-3.0-or-later",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-helpers": "0.1.0",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.3.0",
"@zxing/browser": "0.1.5",
"@zxing/library": "0.21.3",
"bwip-js": "4.11.4",
@@ -20,7 +20,7 @@
"react-dom": "19.2.8"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
@@ -46,24 +46,24 @@
}
},
"node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3",
"license": "Apache-2.0",
"engines": {
"node": ">=20"
}
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
"integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"license": "Apache-2.0"
},
"node_modules/@add-ideas/toolbox-helpers": {
"version": "0.1.0",
"license": "GPL-3.0-or-later",
"engines": {
"node": ">=22"
}
"version": "0.2.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz",
"integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
"license": "GPL-3.0-or-later"
},
"node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.3",
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3"
"@add-ideas/toolbox-contract": "0.3.0"
},
"peerDependencies": {
"react": ">=18 <20",
@@ -71,17 +71,16 @@
}
},
"node_modules/@add-ideas/toolbox-testkit": {
"version": "0.2.3",
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3"
"@add-ideas/toolbox-contract": "0.3.0"
},
"bin": {
"toolbox-check": "dist/cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@adobe/css-tools": {
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "barcode-tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Generate and decode QR codes and barcodes locally in the browser.",
"license": "GPL-3.0-or-later",
"author": "Albrecht Degering",
@@ -39,9 +39,9 @@
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
},
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-helpers": "0.1.0",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.3.0",
"@zxing/browser": "0.1.5",
"@zxing/library": "0.21.3",
"bwip-js": "4.11.4",
@@ -50,7 +50,7 @@
"react-dom": "19.2.8"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -15,7 +15,25 @@ export default defineConfig({
timeout: 180_000,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{
name: "chromium",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Safari"] },
},
{
name: "mobile-chromium",
testMatch: /responsive\.spec\.ts/,
use: { ...devices["Pixel 5"] },
},
],
});
+6
View File
@@ -1,5 +1,11 @@
# Changelog
## 0.2.0 - 2026-09-02
- Added bounded common-GS1 Application Identifier parsing and validation.
- Added physical-size/raster planning, honest quiet-zone guidance and print-sized SVG export.
- Added an explicitly permission-gated native `BarcodeDetector` camera path with local ZXing fallback.
## 0.1.0 - 2026-09-01
- Added the initial local-first Barcode Tools workbench.
+3 -3
View File
@@ -1,5 +1,5 @@
==============================================================================
@add-ideas/toolbox-contract@0.2.3
@add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
@@ -198,7 +198,7 @@ Declared licence: Apache-2.0
==============================================================================
@add-ideas/toolbox-helpers@0.1.0
@add-ideas/toolbox-helpers@0.2.0
Declared licence: GPL-3.0-or-later
==============================================================================
--- LICENSE ---
@@ -879,7 +879,7 @@ Public License instead of this License. But first, please read
==============================================================================
@add-ideas/toolbox-shell-react@0.2.3
@add-ideas/toolbox-shell-react@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
+6 -5
View File
@@ -4,15 +4,16 @@ Generate and decode QR codes and barcodes locally in the browser.
Barcode Tools is a standalone local-first application in the [add·ideas Toolbox](https://git.add-ideas.de/lotobo/toolbox-portal). Inputs are processed in the browser and are not uploaded.
## Version 0.1 scope
## Current scope
- Local SVG generation for QR, Data Matrix, PDF417, Aztec and selected linear symbologies
- Local barcode decoding from a bounded image file or an explicitly enabled camera
- Local barcode decoding from a bounded image file or an explicitly enabled, permission-gated camera; native `BarcodeDetector` is preferred when available and local ZXing is the fallback
- Escaped URL, email, SMS, Wi-Fi, vCard and geo QR payload builders
- Bounded CSV batch generation into a deterministic SVG ZIP
- GTIN check-digit validation plus non-authoritative quiet-zone guidance
- Bounded parsing and validation for common GS1 Application Identifiers in parenthesized HRI and raw element strings, including FNC1/ASCII GS boundaries, dates, check digits and decimal indicators
- GTIN check-digit validation, physical SVG sizing, raster-dimension planning, nominal quiet-zone guidance and print-sized SVG export
Generated SVG is checked for active or linked content before preview/download. Decoded payloads are displayed as text and never opened automatically. Scanner interoperability, GS1 allocation and print quality still require testing against the applicable specifications. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
Generated SVG is checked for active or linked content before preview/download. Decoded payloads are displayed as text and never opened automatically. GS1 allocation/ownership is not queried. bwip-js padding is in points rather than barcode modules, so quiet-zone conformance must be measured on final output; scanner interoperability and print grade still require testing against the intended scanner and applicable specifications. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
## Development
@@ -26,7 +27,7 @@ npm run test:browser
## Release
`npm run release:artifact` creates a deterministic `release/barcode-tools-0.1.0.zip` and checksum sidecar.
`npm run release:artifact` creates a deterministic `release/barcode-tools-0.2.0.zip` and checksum sidecar.
## Licence
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source
The corresponding source for Barcode Tools 0.1.0 is available at:
The corresponding source for Barcode Tools 0.2.0 is available at:
https://git.add-ideas.de/lotobo/barcode-tools/src/tag/v0.1.0
https://git.add-ideas.de/lotobo/barcode-tools/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+3 -3
View File
@@ -4,9 +4,9 @@ Barcode Tools 0.1.0 directly depends on these runtime packages:
| Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `@zxing/browser` | 0.1.5 | MIT |
| `@zxing/library` | 0.21.3 | MIT |
| `bwip-js` | 4.11.4 | MIT |
+5 -3
View File
@@ -1,7 +1,9 @@
# Architecture
Barcode Tools is a static React/Vite application wrapped in the shared Toolbox shell. `barcode/generate.ts` validates options, delegates encoding to the browser build of `bwip-js`, rejects active or linked constructs in the generated SVG string, and previews it through a revocable blob URL. It also parses the bounded two-column batch format and creates deterministic SVG ZIPs with `fflate`.
Barcode Tools is a static React/Vite application wrapped in the shared Toolbox shell. `barcode/generate.ts` validates options, delegates encoding to the browser build of `bwip-js`, rejects active or linked constructs in the generated SVG string, and previews it through a revocable blob URL. It also parses the bounded two-column batch format, creates deterministic SVG ZIPs, computes physical/raster planning dimensions from the generated aspect ratio, and adds millimetre dimensions to a print SVG without rasterizing it. Quiet-zone module values are guidance only because bwip-js padding is measured in points.
`barcode/payloads.ts` escapes structured QR fields and validates GTIN check digits. The decode workspace lazy-loads ZXing only after a file or camera action. Image files are passed through a revocable object URL; camera acquisition is explicit and its controls are stopped on request or unmount. Decoded values are rendered as text and never navigated.
`barcode/payloads.ts` escapes structured QR fields and validates GTIN check digits. `barcode/gs1.ts` recognizes a bounded table of common GS1 AIs, consumes fixed-width fields, requires GS separators for non-final variable fields, validates lengths/data classes/dates/check digits, and stops at an unknown raw AI rather than guessing its boundary.
Version 0.1 does not create an application worker or persist input. The camera requires a secure context but not a top-level browsing context. Relative entry and asset URLs keep the build relocatable below a nested portal path.
The decode workspace lazy-loads ZXing only after a file or camera action. Pressing **Start camera** is the sole path to `getUserMedia`. When the browser exposes `BarcodeDetector`, it processes at most one frame per animation frame; otherwise the local ZXing constraint decoder is used. Image files are passed through a revocable object URL; camera streams/animation/ZXing controls are stopped on request, failure or unmount. Decoded values are rendered as text and never navigated.
The application does not persist input. The camera requires a secure context and may be constrained by the embedding portal's Permissions Policy. Relative entry and asset URLs keep the build relocatable below a nested portal path.
+2 -2
View File
@@ -1,7 +1,7 @@
# Privacy and security
Payloads, images and camera frames are processed in the browser. There is no telemetry, analytics, account, persistence or server upload. Camera access happens only after pressing **Start camera**, is governed by browser permission, requests video without audio, and can be stopped from the workspace.
Payloads, images and camera frames are processed in the browser. There is no telemetry, analytics, account, persistence or server upload. Camera access happens only after pressing **Start camera**, is governed by browser permission/Permissions Policy, requests video without audio, and can be stopped from the workspace. The stream and scan loop are stopped on cancellation, failure and unmount.
Images are limited to 20 MiB, generated payloads to 4,096 characters, and batch input to 256 KiB/100 rows. Structured QR fields are escaped and Wi-Fi security is restricted to WPA, WEP or nopass. Generated SVG is rejected if it contains scripts, foreign objects, event handlers or linked content; previews use local blob URLs.
Decoded payloads are untrusted text and are never opened automatically. A valid check digit does not prove GS1 allocation, and the quiet-zone text is guidance rather than print-quality validation. Interoperability and physical print quality must be checked with the intended scanner and applicable specification.
Decoded payloads are untrusted text and are never opened automatically. A recognized AI or valid check digit does not prove GS1 allocation, ownership or complete carrier conformance. Unknown raw AIs stop parsing to avoid inventing field boundaries. Physical dimensions and raster pixels are planning values: generated padding is expressed in points, not modules, so quiet zones/X-dimension and physical print quality must be measured and tested with the intended scanner and applicable specification.
+1 -1
View File
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "barcode-tools-shell-";
const CACHE_NAME = CACHE_PREFIX + "0.1.0";
const CACHE_NAME = CACHE_PREFIX + "0.2.0";
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
self.addEventListener("install", (event) => {
event.waitUntil(
+19 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.barcode-tools",
"name": "Barcode Tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Generate and decode QR codes and barcodes locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
@@ -21,6 +21,24 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{
"mediaType": "image/*",
"extensions": [".png", ".jpg", ".jpeg", ".webp"]
},
{ "mediaType": "text/csv", "extensions": [".csv"] }
],
"produces": [
{ "mediaType": "image/svg+xml", "extensions": [".svg"] },
{ "mediaType": "application/zip", "extensions": [".zip"] },
{ "mediaType": "application/json", "extensions": [".json"] }
]
},
"capabilities": {
"required": [],
"optional": ["barcode-detector", "camera"]
},
"privacy": {
"processing": "local",
"fileUploads": true,
+79 -3
View File
@@ -32,6 +32,18 @@ export interface BarcodeSvg {
quietZone: string;
}
export interface PrintGuidance {
widthMm: number;
heightMm: number;
dpi: number;
rasterWidthPixels: number;
rasterHeightPixels: number;
minimumQuietZoneModules: number;
suppliedPaddingPoints: number;
quietZoneAssessment: "manual-measurement-required";
notes: string[];
}
const LINEAR = new Set<BarcodeFormat>([
"code128",
"gs1-128",
@@ -41,6 +53,19 @@ const LINEAR = new Set<BarcodeFormat>([
"code39",
]);
const QUIET_ZONE_MODULES: Record<BarcodeFormat, number> = {
qrcode: 4,
datamatrix: 1,
pdf417: 2,
azteccode: 1,
code128: 10,
"gs1-128": 10,
code39: 10,
ean13: 11,
ean8: 11,
upca: 9,
};
export function renderBarcodeSvg(options: BarcodeOptions): BarcodeSvg {
if (!BARCODE_FORMATS.some(([format]) => format === options.format))
throw new Error("Unsupported barcode format.");
@@ -82,13 +107,64 @@ export function renderBarcodeSvg(options: BarcodeOptions): BarcodeSvg {
height,
quietZone:
options.format === "qrcode"
? "At least 4 modules on every side"
? "Nominal starting guidance: at least 4 modules on every side"
: options.format === "datamatrix"
? "At least 1 module on every side"
: "At least 10 narrow modules at left and right",
? "Nominal starting guidance: at least 1 module on every side"
: "Nominal starting guidance: usually at least 10 narrow modules at left and right; check the specific symbology/application",
};
}
export function barcodePrintGuidance(
barcode: BarcodeSvg,
options: Pick<BarcodeOptions, "format" | "padding">,
widthMm: number,
dpi: number,
): PrintGuidance {
if (!Number.isFinite(widthMm) || widthMm < 5 || widthMm > 2_000)
throw new Error("Print width must be 52,000 mm.");
if (!Number.isInteger(dpi) || dpi < 72 || dpi > 2_400)
throw new Error("Raster target must be an integer from 722,400 DPI.");
if (!(barcode.width > 0) || !(barcode.height > 0))
throw new Error("Barcode vector bounds are unavailable.");
const heightMm = (widthMm * barcode.height) / barcode.width;
const rasterWidthPixels = Math.ceil((widthMm / 25.4) * dpi);
const rasterHeightPixels = Math.ceil((heightMm / 25.4) * dpi);
const minimumQuietZoneModules = QUIET_ZONE_MODULES[options.format];
return {
widthMm,
heightMm,
dpi,
rasterWidthPixels,
rasterHeightPixels,
minimumQuietZoneModules,
suppliedPaddingPoints: options.padding,
quietZoneAssessment: "manual-measurement-required",
notes: [
"Physical width preserves the generated vector aspect ratio; no rasterization is performed by the SVG export.",
"DPI dimensions are planning guidance for a later raster workflow, not a guarantee of printer resolution or barcode grade.",
"bwip-js padding is specified in points and scales with the image; it is not a module count. Measure the final symbol's quiet zone against its actual X-dimension.",
"Verify the required X-dimension, height, quiet zones, colours, substrate and ISO/IEC print grade for the scanning application.",
],
};
}
export function createPrintSizedSvg(
barcode: BarcodeSvg,
widthMm: number,
): string {
if (!Number.isFinite(widthMm) || widthMm < 5 || widthMm > 2_000)
throw new Error("Print width must be 52,000 mm.");
const heightMm = (widthMm * barcode.height) / barcode.width;
if (!Number.isFinite(heightMm) || heightMm <= 0)
throw new Error("Barcode vector bounds are unavailable.");
const width = Number(widthMm.toFixed(4));
const height = Number(heightMm.toFixed(4));
return barcode.svg.replace(
/^<svg\s/u,
`<svg width="${width}mm" height="${height}mm" `,
);
}
export interface BatchRow {
name: string;
value: string;
+489
View File
@@ -0,0 +1,489 @@
import { gtinCheckDigit } from "./payloads";
const MAX_SOURCE = 16 * 1024;
const MAX_ELEMENTS = 128;
const GS = "\u001d";
interface AiDefinition {
pattern: RegExp;
title: string;
fixed?: number;
minimum?: number;
maximum: number;
numeric: boolean;
checkDigit?: boolean;
date?: boolean;
decimalIndicator?: boolean;
}
const DEFINITIONS: AiDefinition[] = [
{
pattern: /^00$/u,
title: "SSCC",
fixed: 18,
maximum: 18,
numeric: true,
checkDigit: true,
},
{
pattern: /^(?:01|02)$/u,
title: "GTIN",
fixed: 14,
maximum: 14,
numeric: true,
checkDigit: true,
},
{ pattern: /^10$/u, title: "Batch / lot", maximum: 20, numeric: false },
{
pattern: /^(?:11|12|13|15|16|17)$/u,
title: "Date (YYMMDD)",
fixed: 6,
maximum: 6,
numeric: true,
date: true,
},
{ pattern: /^20$/u, title: "Variant", fixed: 2, maximum: 2, numeric: true },
{ pattern: /^21$/u, title: "Serial", maximum: 20, numeric: false },
{
pattern: /^22$/u,
title: "Consumer product variant",
maximum: 20,
numeric: false,
},
{ pattern: /^(?:30|37)$/u, title: "Count", maximum: 8, numeric: true },
{
pattern: /^(?:240|241|250|251)$/u,
title: "Additional identifier",
maximum: 30,
numeric: false,
},
{
pattern: /^242$/u,
title: "Made-to-order variation",
maximum: 6,
numeric: true,
},
{ pattern: /^254$/u, title: "GLN extension", maximum: 20, numeric: false },
{
pattern:
/^(?:310|311|312|313|314|315|316|320|321|322|323|324|325|326|327|328|329|330|331|332|333|334|335|336|337)\d$/u,
title: "Decimal measure",
fixed: 6,
maximum: 6,
numeric: true,
decimalIndicator: true,
},
{
pattern: /^392\d$/u,
title: "Price",
maximum: 15,
numeric: true,
decimalIndicator: true,
},
{
pattern: /^393\d$/u,
title: "Price with ISO currency",
minimum: 4,
maximum: 18,
numeric: true,
decimalIndicator: true,
},
{
pattern: /^400$/u,
title: "Customer purchase order",
maximum: 30,
numeric: false,
},
{
pattern: /^401$/u,
title: "Consignment identifier",
maximum: 30,
numeric: false,
},
{
pattern: /^402$/u,
title: "Shipment identifier",
fixed: 17,
maximum: 17,
numeric: true,
checkDigit: true,
},
{ pattern: /^403$/u, title: "Routing code", maximum: 30, numeric: false },
{
pattern: /^4(?:10|11|12|13|14|15|16|17)$/u,
title: "Global Location Number",
fixed: 13,
maximum: 13,
numeric: true,
checkDigit: true,
},
{
pattern: /^420$/u,
title: "Ship-to postal code",
maximum: 20,
numeric: false,
},
{
pattern: /^421$/u,
title: "Ship-to postal code with country",
minimum: 4,
maximum: 12,
numeric: false,
},
{
pattern: /^(?:422|424|425|426)$/u,
title: "ISO country code",
fixed: 3,
maximum: 3,
numeric: true,
},
{
pattern: /^7001$/u,
title: "NATO stock number",
fixed: 13,
maximum: 13,
numeric: true,
},
{ pattern: /^7002$/u, title: "Meat cut", maximum: 30, numeric: false },
{
pattern: /^7003$/u,
title: "Expiration date and time",
fixed: 10,
maximum: 10,
numeric: true,
},
{
pattern: /^8001$/u,
title: "Roll products",
fixed: 14,
maximum: 14,
numeric: true,
},
{
pattern: /^8002$/u,
title: "Cellular mobile identifier",
maximum: 20,
numeric: false,
},
{
pattern: /^8003$/u,
title: "Returnable asset identifier",
minimum: 14,
maximum: 30,
numeric: false,
},
{
pattern: /^8004$/u,
title: "Individual asset identifier",
maximum: 30,
numeric: false,
},
{
pattern: /^8005$/u,
title: "Price per unit",
fixed: 6,
maximum: 6,
numeric: true,
},
{
pattern: /^8006$/u,
title: "Trade item component",
fixed: 18,
maximum: 18,
numeric: true,
},
{ pattern: /^8007$/u, title: "IBAN", maximum: 34, numeric: false },
{
pattern: /^8008$/u,
title: "Production date/time",
maximum: 12,
minimum: 8,
numeric: true,
},
{
pattern: /^8010$/u,
title: "Component / part identifier",
maximum: 30,
numeric: false,
},
{
pattern: /^8011$/u,
title: "Component / part serial",
maximum: 12,
numeric: true,
},
{
pattern: /^8012$/u,
title: "Software version",
maximum: 20,
numeric: false,
},
{
pattern: /^8013$/u,
title: "Global model number",
maximum: 30,
numeric: false,
},
{
pattern: /^(?:8017|8018)$/u,
title: "Service relation number",
fixed: 18,
maximum: 18,
numeric: true,
checkDigit: true,
},
{
pattern: /^8020$/u,
title: "Payment slip reference",
maximum: 25,
numeric: false,
},
{ pattern: /^8110$/u, title: "Coupon code", maximum: 70, numeric: false },
{
pattern: /^8111$/u,
title: "Coupon loyalty points",
fixed: 4,
maximum: 4,
numeric: true,
},
{
pattern: /^8200$/u,
title: "Extended packaging URL",
maximum: 70,
numeric: false,
},
{
pattern: /^(?:90|9[1-9])$/u,
title: "Internal / mutually agreed data",
maximum: 90,
numeric: false,
},
];
export interface Gs1Element {
ai: string;
title: string;
value: string;
fixedLength: boolean;
valid: boolean;
errors: string[];
interpretation?: string;
}
export interface Gs1Inspection {
syntax: "parenthesized-HRI" | "element-string";
symbologyIdentifier?: string;
elements: Gs1Element[];
errors: string[];
warnings: string[];
normalizedHri: string;
normalizedElementString: string;
}
function definition(ai: string): AiDefinition | undefined {
return DEFINITIONS.find((candidate) => candidate.pattern.test(ai));
}
function validateDate(value: string): string | undefined {
const year = Number(value.slice(0, 2));
const month = Number(value.slice(2, 4));
const day = Number(value.slice(4, 6));
if (month < 1 || month > 12) return "Month must be 0112.";
if (day === 0) return undefined;
const date = new Date(Date.UTC(2000 + year, month - 1, day));
return date.getUTCMonth() === month - 1 && date.getUTCDate() === day
? undefined
: "Day is invalid for the encoded month/year.";
}
function element(ai: string, value: string): Gs1Element {
const spec = definition(ai);
if (!spec)
return {
ai,
title: "Unknown or unsupported AI",
value,
fixedLength: false,
valid: false,
errors: [
"This AI is outside the bounded built-in table; its field cannot be validated.",
],
};
const errors: string[] = [];
if (
value.length < (spec.fixed ?? spec.minimum ?? 1) ||
value.length > spec.maximum
)
errors.push(
spec.fixed
? `Expected exactly ${spec.fixed} data characters.`
: `Expected ${spec.minimum ?? 1}${spec.maximum} data characters.`,
);
if (spec.numeric && !/^\d+$/u.test(value))
errors.push("Data must contain decimal digits only.");
if (
[...value].some((character) => {
const code = character.codePointAt(0)!;
return code < 32 || code > 126;
})
)
errors.push(
"Data contains a control or non-ASCII character outside the supported GS1 subset.",
);
if (spec.checkDigit && /^\d+$/u.test(value) && value.length >= 2) {
const expected = String(gtinCheckDigit(value.slice(0, -1)));
if (!value.endsWith(expected))
errors.push(`Check digit should be ${expected}.`);
}
if (spec.date && /^\d{6}$/u.test(value)) {
const dateError = validateDate(value);
if (dateError) errors.push(dateError);
}
let interpretation: string | undefined;
if (spec.decimalIndicator && /^\d+$/u.test(value)) {
const places = Number(ai.at(-1));
const currency = ai.startsWith("393") ? value.slice(0, 3) : undefined;
const numericValue = currency ? value.slice(3) : value;
const integer = BigInt(numericValue);
const digits = integer.toString().padStart(places + 1, "0");
interpretation = places
? `${digits.slice(0, -places)}.${digits.slice(-places)}`
: digits;
if (currency) interpretation = `${currency} ${interpretation}`;
}
return {
ai,
title: spec.title,
value,
fixedLength: spec.fixed !== undefined,
valid: errors.length === 0,
errors,
...(interpretation ? { interpretation } : {}),
};
}
function aiAt(
source: string,
index: number,
): { ai: string; spec: AiDefinition } | undefined {
for (const length of [4, 3, 2]) {
const ai = source.slice(index, index + length);
const spec = definition(ai);
if (spec) return { ai, spec };
}
return undefined;
}
export function inspectGs1(sourceInput: string): Gs1Inspection {
if (sourceInput.length > MAX_SOURCE)
throw new Error("GS1 input exceeds the 16 KiB limit.");
let source = sourceInput
.trim()
.replaceAll("<GS>", GS)
.replaceAll("\\x1D", GS);
if (!source)
throw new Error("Enter a GS1 element string or parenthesized HRI.");
let symbologyIdentifier: string | undefined;
if (/^\][A-Za-z][0-9]/u.test(source)) {
symbologyIdentifier = source.slice(0, 3);
source = source.slice(3);
}
const elements: Gs1Element[] = [];
const errors: string[] = [];
const warnings: string[] = [];
const parenthesized = source.startsWith("(");
if (parenthesized) {
const pattern = /\((\d{2,4})\)/gu;
const matches = [...source.matchAll(pattern)];
if (matches.length === 0 || matches[0]?.index !== 0)
throw new Error(
"Parenthesized HRI must start with a numeric AI in parentheses.",
);
for (const [index, match] of matches.entries()) {
if (elements.length >= MAX_ELEMENTS)
throw new Error(
`More than ${MAX_ELEMENTS} GS1 elements were supplied.`,
);
const start = match.index! + match[0].length;
const end = matches[index + 1]?.index ?? source.length;
const value = source.slice(start, end);
if (value.includes(GS))
warnings.push(
`AI ${match[1]} includes an unnecessary explicit separator in HRI input.`,
);
elements.push(element(match[1]!, value.replaceAll(GS, "")));
}
} else {
let cursor = 0;
while (cursor < source.length) {
if (source[cursor] === GS) {
warnings.push(
`Separator at character ${cursor + 1} does not terminate a preceding variable field.`,
);
cursor += 1;
continue;
}
if (elements.length >= MAX_ELEMENTS)
throw new Error(
`More than ${MAX_ELEMENTS} GS1 elements were supplied.`,
);
const found = aiAt(source, cursor);
if (!found) {
errors.push(
`Unknown AI at character ${cursor + 1}; parsing stopped to avoid guessing field boundaries.`,
);
break;
}
cursor += found.ai.length;
let value: string;
if (found.spec.fixed !== undefined) {
value = source.slice(cursor, cursor + found.spec.fixed);
cursor += value.length;
} else {
const separator = source.indexOf(GS, cursor);
if (separator < 0) {
value = source.slice(cursor);
cursor = source.length;
} else {
value = source.slice(cursor, separator);
cursor = separator + 1;
}
}
elements.push(element(found.ai, value));
}
}
if (elements.length === 0 && errors.length === 0)
errors.push("No GS1 elements were parsed.");
for (const item of elements)
errors.push(...item.errors.map((message) => `AI ${item.ai}: ${message}`));
const normalizedHri = elements
.map((item) => `(${item.ai})${item.value}`)
.join("");
const normalizedElementString = elements
.map((item, index) => {
const last = index === elements.length - 1;
return `${item.ai}${item.value}${!item.fixedLength && !last ? GS : ""}`;
})
.join("");
if (
!parenthesized &&
elements.some(
(item, index) => !item.fixedLength && index < elements.length - 1,
)
) {
// The loop can only find a later field when GS was present, but keep the
// requirement visible in normalized output and reports.
warnings.push(
"Variable-length fields before another element require an FNC1 / ASCII GS separator.",
);
}
return {
syntax: parenthesized ? "parenthesized-HRI" : "element-string",
...(symbologyIdentifier ? { symbologyIdentifier } : {}),
elements,
errors,
warnings,
normalizedHri,
normalizedElementString,
};
}
+302 -26
View File
@@ -3,12 +3,15 @@ import type { IScannerControls } from "@zxing/browser";
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import {
BARCODE_FORMATS,
barcodePrintGuidance,
createPrintSizedSvg,
createBarcodeZip,
parseBatch,
renderBarcodeSvg,
type BarcodeFormat,
type BarcodeSvg,
} from "../barcode/generate";
import { inspectGs1 } from "../barcode/gs1";
import {
buildStructuredPayload,
validateGtin,
@@ -48,22 +51,46 @@ function GenerateWorkspace() {
const [text, setText] = useState("https://toolbox.add-ideas.de/");
const [scale, setScale] = useState(3);
const [padding, setPadding] = useState(8);
const [printWidth, setPrintWidth] = useState(50);
const [printDpi, setPrintDpi] = useState(300);
const [includeText, setIncludeText] = useState(true);
const [barcode, setBarcode] = useState<BarcodeSvg>(() =>
renderBarcodeSvg({ format, text, scale, padding, includeText }),
);
const [renderedOptions, setRenderedOptions] = useState({
format,
scale,
padding,
includeText,
});
const [error, setError] = useState("");
const [payloadKind, setPayloadKind] = useState<PayloadKind>("url");
const [primary, setPrimary] = useState("https://toolbox.add-ideas.de/");
const [secondary, setSecondary] = useState("");
const [tertiary, setTertiary] = useState("WPA");
const [hidden, setHidden] = useState(false);
const print = useMemo(() => {
try {
return {
value: barcodePrintGuidance(
barcode,
renderedOptions,
printWidth,
printDpi,
),
};
} catch (reason) {
return {
error: reason instanceof Error ? reason.message : "Print plan failed.",
};
}
}, [barcode, renderedOptions, printWidth, printDpi]);
const generate = () => {
try {
setBarcode(
renderBarcodeSvg({ format, text, scale, padding, includeText }),
);
const options = { format, text, scale, padding, includeText };
setBarcode(renderBarcodeSvg(options));
setRenderedOptions({ format, scale, padding, includeText });
setError("");
} catch (reason) {
setError(reason instanceof Error ? reason.message : "Generation failed.");
@@ -124,7 +151,7 @@ function GenerateWorkspace() {
/>
</label>
<label className="field">
<span>Padding</span>
<span>Padding (bwip-js points)</span>
<input
type="number"
min="0"
@@ -159,12 +186,27 @@ function GenerateWorkspace() {
onClick={() =>
triggerBlobDownload(
new Blob([barcode.svg], { type: "image/svg+xml" }),
`${format}.svg`,
`${renderedOptions.format}.svg`,
)
}
>
Download SVG
</button>
<button
type="button"
disabled={!print.value}
onClick={() => {
if (!print.value) return;
triggerBlobDownload(
new Blob([createPrintSizedSvg(barcode, print.value.widthMm)], {
type: "image/svg+xml",
}),
`${renderedOptions.format}-${print.value.widthMm}mm.svg`,
);
}}
>
Download print-sized SVG
</button>
</div>
{error && (
<p className="error" role="alert">
@@ -172,6 +214,74 @@ function GenerateWorkspace() {
</p>
)}
<Preview barcode={barcode} />
<details>
<summary>Print size and quiet-zone plan</summary>
<div className="details-body">
<div className="form-grid">
<label className="field">
<span>Physical width (mm)</span>
<input
type="number"
min="5"
max="2000"
step="0.1"
value={printWidth}
onChange={(event) => setPrintWidth(event.target.valueAsNumber)}
/>
</label>
<label className="field">
<span>Raster planning DPI</span>
<input
type="number"
min="72"
max="2400"
value={printDpi}
onChange={(event) => setPrintDpi(event.target.valueAsNumber)}
/>
</label>
</div>
{print.error ? (
<p className="error" role="alert">
{print.error}
</p>
) : (
print.value && (
<>
<dl className="facts">
<div>
<dt>Physical bounds</dt>
<dd>
{print.value.widthMm.toFixed(2)} ×{" "}
{print.value.heightMm.toFixed(2)} mm
</dd>
</div>
<div>
<dt>Raster equivalent</dt>
<dd>
{print.value.rasterWidthPixels} ×{" "}
{print.value.rasterHeightPixels} px @ {print.value.dpi}{" "}
DPI
</dd>
</div>
<div>
<dt>Quiet-zone preflight</dt>
<dd>
Manual measurement required: bwip-js padding is{" "}
{print.value.suppliedPaddingPoints} pt; nominal guidance
is {print.value.minimumQuietZoneModules} module(s)
</dd>
</div>
</dl>
<ul className="compact-list muted">
{print.value.notes.map((note) => (
<li key={note}>{note}</li>
))}
</ul>
</>
)
)}
</div>
</details>
<details>
<summary>Structured QR payload builder</summary>
<div className="details-body">
@@ -273,12 +383,28 @@ function DecodeWorkspace() {
const [decoded, setDecoded] = useState<Decoded>();
const [error, setError] = useState("");
const [camera, setCamera] = useState(false);
const [cameraEngine, setCameraEngine] = useState<"native" | "zxing">();
const video = useRef<HTMLVideoElement>(null);
const controls = useRef<IScannerControls | undefined>(undefined);
useEffect(() => () => controls.current?.stop(), []);
const stream = useRef<MediaStream | undefined>(undefined);
const animationFrame = useRef<number | undefined>(undefined);
const cameraRun = useRef(0);
const fileRun = useRef(0);
useEffect(
() => () => {
cameraRun.current += 1;
fileRun.current += 1;
controls.current?.stop();
stream.current?.getTracks().forEach((track) => track.stop());
if (animationFrame.current !== undefined)
cancelAnimationFrame(animationFrame.current);
},
[],
);
const decodeFile = async (file: File | undefined) => {
if (!file) return;
const run = ++fileRun.current;
if (file.size > 20 * 1024 * 1024) {
setError("Image exceeds the 20 MiB decode limit.");
return;
@@ -290,6 +416,7 @@ function DecodeWorkspace() {
const result = await new BrowserMultiFormatReader().decodeFromImageUrl(
url,
);
if (run !== fileRun.current) return;
setDecoded({
text: result.getText(),
format:
@@ -298,30 +425,83 @@ function DecodeWorkspace() {
});
setError("");
} catch {
setError("No supported barcode could be decoded from this image.");
if (run === fileRun.current)
setError("No supported barcode could be decoded from this image.");
} finally {
URL.revokeObjectURL(url);
}
};
const stopCamera = () => {
cameraRun.current += 1;
controls.current?.stop();
controls.current = undefined;
stream.current?.getTracks().forEach((track) => track.stop());
stream.current = undefined;
if (animationFrame.current !== undefined)
cancelAnimationFrame(animationFrame.current);
animationFrame.current = undefined;
if (video.current) video.current.srcObject = null;
setCameraEngine(undefined);
setCamera(false);
};
const startCamera = async () => {
const run = ++cameraRun.current;
setError("");
setCamera(true);
try {
if (typeof BarcodeDetector !== "undefined") {
const media = await navigator.mediaDevices.getUserMedia({
audio: false,
video: { facingMode: { ideal: "environment" } },
});
if (run !== cameraRun.current) {
media.getTracks().forEach((track) => track.stop());
return;
}
stream.current = media;
await new Promise<void>((resolve) =>
requestAnimationFrame(() => resolve()),
);
const element = video.current;
if (!element)
throw new Error("Camera preview could not be initialized.");
element.srcObject = media;
await element.play();
if (run !== cameraRun.current) return;
setCameraEngine("native");
const detector = new BarcodeDetector();
const scan = async () => {
if (run !== cameraRun.current) return;
try {
const results = await detector.detect(element);
const result = results[0];
if (result)
setDecoded({
text: result.rawValue,
format: result.format || "BarcodeDetector",
});
} catch {
// Individual frames can be undecodable while the camera remains
// healthy. The next animation frame is still bounded to one scan.
}
if (run === cameraRun.current)
animationFrame.current = requestAnimationFrame(() => void scan());
};
animationFrame.current = requestAnimationFrame(() => void scan());
return;
}
const [{ BrowserMultiFormatReader }, { BarcodeFormat }] =
await Promise.all([import("@zxing/browser"), import("@zxing/library")]);
controls.current = await new BrowserMultiFormatReader(undefined, {
if (run !== cameraRun.current) return;
const nextControls = await new BrowserMultiFormatReader(undefined, {
delayBetweenScanAttempts: 150,
delayBetweenScanSuccess: 1_000,
}).decodeFromConstraints(
{ audio: false, video: { facingMode: { ideal: "environment" } } },
video.current ?? undefined,
(result) => {
if (run !== cameraRun.current) return;
if (result)
setDecoded({
text: result.getText(),
@@ -331,8 +511,15 @@ function DecodeWorkspace() {
});
},
);
if (run !== cameraRun.current) {
nextControls.stop();
return;
}
controls.current = nextControls;
setCameraEngine("zxing");
} catch (reason) {
setCamera(false);
if (run !== cameraRun.current) return;
stopCamera();
setError(
reason instanceof Error ? reason.message : "Camera access failed.",
);
@@ -369,13 +556,20 @@ function DecodeWorkspace() {
)}
</div>
{camera && (
<video
className="camera"
ref={video}
muted
playsInline
aria-label="Camera barcode preview"
/>
<>
<video
className="camera"
ref={video}
muted
playsInline
aria-label="Camera barcode preview"
/>
<p className="muted" role="status">
{cameraEngine
? `Camera active via ${cameraEngine === "native" ? "browser BarcodeDetector" : "local ZXing fallback"}.`
: "Waiting for browser camera permission…"}
</p>
</>
)}
{error && (
<p className="error" role="alert">
@@ -482,16 +676,103 @@ function BatchWorkspace() {
function Gs1Workspace() {
const [value, setValue] = useState("4006381333931");
const check = useMemo(() => validateGtin(value.trim()), [value]);
const [elements, setElements] = useState(
"(01)04006381333931(17)271231(10)LOT-42(21)SERIAL-7",
);
const inspection = useMemo(() => {
try {
return { value: inspectGs1(elements) };
} catch (reason) {
return {
error:
reason instanceof Error ? reason.message : "GS1 inspection failed.",
};
}
}, [elements]);
return (
<section className="panel workspace" aria-labelledby="gs1-heading">
<div>
<p className="eyebrow">GS1 helper</p>
<h2 id="gs1-heading">GTIN check digit</h2>
<h2 id="gs1-heading">Application identifiers and check digits</h2>
<p className="muted">
Validate GTIN-8, GTIN-12, GTIN-13, or GTIN-14. Allocation and
ownership are not checked.
Inspect common GS1 Application Identifiers in parenthesized HRI or raw
element-string syntax. Use &lt;GS&gt;, \x1D, or ASCII 29 between
variable fields. Allocation and ownership are not checked.
</p>
</div>
<label className="field">
<span>GS1 elements</span>
<textarea
value={elements}
onChange={(event) => setElements(event.target.value)}
spellCheck={false}
/>
</label>
{inspection.error ? (
<p className="error" role="alert">
{inspection.error}
</p>
) : (
inspection.value && (
<>
{inspection.value.errors.length > 0 && (
<ul className="compact-list error">
{inspection.value.errors.map((message) => (
<li key={message}>{message}</li>
))}
</ul>
)}
{inspection.value.warnings.length > 0 && (
<ul className="compact-list warning">
{inspection.value.warnings.map((message) => (
<li key={message}>{message}</li>
))}
</ul>
)}
<div className="table-scroll" tabIndex={0}>
<table>
<thead>
<tr>
<th>AI</th>
<th>Meaning</th>
<th>Data</th>
<th>Check</th>
</tr>
</thead>
<tbody>
{inspection.value.elements.map((item, index) => (
<tr key={`${item.ai}-${index}`}>
<td>{item.ai}</td>
<td>{item.title}</td>
<td>
{item.value}
{item.interpretation ? ` (${item.interpretation})` : ""}
</td>
<td>{item.valid ? "Valid" : "Review"}</td>
</tr>
))}
</tbody>
</table>
</div>
<dl className="facts">
<div>
<dt>Normalized HRI</dt>
<dd>{inspection.value.normalizedHri || "—"}</dd>
</div>
<div>
<dt>Normalized element string</dt>
<dd>
{inspection.value.normalizedElementString.replaceAll(
"\u001d",
"<GS>",
) || "—"}
</dd>
</div>
</dl>
</>
)
)}
<h3>Standalone GTIN check digit</h3>
<label className="field">
<span>Complete GTIN</span>
<input
@@ -532,11 +813,7 @@ export function Workbench() {
</div>
<span className="privacy-pill">No automatic navigation</span>
</header>
<nav
className="panel workspace-tabs"
role="tablist"
aria-label="Barcode workspaces"
>
<nav className="panel workspace-tabs" aria-label="Barcode workspaces">
{(
[
["generate", "Generate"],
@@ -547,8 +824,7 @@ export function Workbench() {
).map(([value, label]) => (
<button
type="button"
role="tab"
aria-selected={tab === value}
aria-pressed={tab === value}
onClick={() => setTab(value)}
key={value}
>
+29 -1
View File
@@ -161,7 +161,7 @@ textarea {
overflow-x: auto;
padding-bottom: 0.2rem;
}
.workspace-tabs button[aria-selected="true"] {
.workspace-tabs button[aria-pressed="true"] {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent);
color: var(--toolbox-accent-contrast);
@@ -282,6 +282,34 @@ textarea {
.facts dd {
margin: 0.2rem 0 0;
}
.compact-list {
margin: 0;
padding-left: 1.35rem;
line-height: 1.5;
}
.table-scroll {
max-width: 100%;
overflow: auto;
border: 1px solid var(--toolbox-border);
border-radius: 0.65rem;
}
table {
width: 100%;
min-width: 42rem;
border-collapse: collapse;
}
th,
td {
padding: 0.58rem 0.65rem;
border-bottom: 1px solid var(--toolbox-border);
text-align: left;
overflow-wrap: anywhere;
}
th {
color: var(--toolbox-muted);
font-size: 0.72rem;
text-transform: uppercase;
}
.file-button {
position: relative;
overflow: hidden;
+31 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.barcode-tools",
"name": "Barcode Tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Generate and decode QR codes and barcodes locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
@@ -21,6 +21,36 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{
"mediaType": "image/*",
"extensions": [".png", ".jpg", ".jpeg", ".webp"]
},
{
"mediaType": "text/csv",
"extensions": [".csv"]
}
],
"produces": [
{
"mediaType": "image/svg+xml",
"extensions": [".svg"]
},
{
"mediaType": "application/zip",
"extensions": [".zip"]
},
{
"mediaType": "application/json",
"extensions": [".json"]
}
]
},
"capabilities": {
"required": [],
"optional": ["barcode-detector", "camera"]
},
"privacy": {
"processing": "local",
"fileUploads": true,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0";
export const APP_VERSION = "0.2.0";
+11
View File
@@ -1 +1,12 @@
/// <reference types="vite/client" />
interface DetectedBarcode {
rawValue: string;
format: string;
}
declare class BarcodeDetector {
constructor(options?: { formats?: string[] });
static getSupportedFormats?(): Promise<string[]>;
detect(source: CanvasImageSource): Promise<DetectedBarcode[]>;
}
+61
View File
@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import {
barcodePrintGuidance,
createPrintSizedSvg,
createBarcodeZip,
parseBatch,
renderBarcodeSvg,
@@ -9,6 +11,7 @@ import {
gtinCheckDigit,
validateGtin,
} from "../../src/barcode/payloads";
import { inspectGs1 } from "../../src/barcode/gs1";
describe("barcode generation", () => {
it("renders inert scalable QR output", () => {
@@ -40,6 +43,29 @@ describe("barcode generation", () => {
});
expect(first).toEqual(second);
});
it("exports physical SVG dimensions and reports honest quiet-zone guidance", () => {
const barcode = renderBarcodeSvg({
format: "qrcode",
text: "print plan",
scale: 3,
padding: 2,
includeText: false,
});
const plan = barcodePrintGuidance(
barcode,
{ format: "qrcode", padding: 2 },
50,
300,
);
expect(plan.quietZoneAssessment).toBe("manual-measurement-required");
expect(plan.suppliedPaddingPoints).toBe(2);
expect(plan.minimumQuietZoneModules).toBe(4);
expect(plan.rasterWidthPixels).toBe(591);
expect(createPrintSizedSvg(barcode, 50)).toMatch(
/^<svg width="50mm" height="[\d.]+mm" viewBox=/u,
);
});
});
describe("structured payloads", () => {
@@ -68,3 +94,38 @@ describe("structured payloads", () => {
});
});
});
describe("GS1 element inspection", () => {
it("parses parenthesized HRI with checks, dates and variable fields", () => {
const result = inspectGs1(
"(01)04006381333931(17)271231(10)LOT-42(21)SERIAL-7",
);
expect(result.errors).toEqual([]);
expect(result.elements.map((item) => item.ai)).toEqual([
"01",
"17",
"10",
"21",
]);
expect(result.normalizedElementString).toContain("LOT-42\u001d21");
});
it("parses raw fields using GS separators and reports a bad check digit", () => {
const result = inspectGs1("]C10104006381333930\u001d10LOT\u001d21SERIAL");
expect(result.symbologyIdentifier).toBe("]C1");
expect(result.elements[0]?.valid).toBe(false);
expect(result.errors.join(" ")).toMatch(/Check digit should be 1/u);
});
it("interprets decimal indicator AIs without changing the source", () => {
const result = inspectGs1("(3102)001234(3932)978001234");
expect(result.elements[0]?.interpretation).toBe("12.34");
expect(result.elements[1]?.interpretation).toBe("978 12.34");
});
it("stops rather than guessing an unknown raw AI boundary", () => {
const result = inspectGs1("8912345");
expect(result.elements).toEqual([]);
expect(result.errors.join(" ")).toMatch(/Unknown AI/u);
});
});
+88 -3
View File
@@ -34,17 +34,102 @@ test("generates an inert SVG and validates a GTIN", async ({ page }) => {
await page
.getByRole("textbox", { name: "Payload", exact: true })
.fill("browser-local barcode gate");
await page.getByRole("button", { name: "Generate", exact: true }).click();
await page
.getByRole("region", { name: "Scalable barcode" })
.getByRole("button", { name: "Generate", exact: true })
.click();
await expect(
page.getByRole("img", { name: "Generated barcode preview" }),
).toBeVisible();
await expect(page.getByText(/Vector bounds/u)).toBeVisible();
await page.getByRole("tab", { name: "GS1 helper" }).click();
await page.getByText("Print size and quiet-zone plan").click();
await expect(page.getByText(/Manual measurement required/u)).toBeVisible();
await page.getByRole("button", { name: "GS1 helper" }).click();
await expect(page.getByText("Batch / lot", { exact: true })).toBeVisible();
await expect(page.getByText("Serial", { exact: true })).toBeVisible();
await page.getByLabel("Complete GTIN").fill("4006381333931");
await expect(page.getByText("The check digit is valid.")).toBeVisible();
expect(external).toEqual([]);
});
test("requests camera permission only after the explicit start action", async ({
page,
}) => {
await page.addInitScript(() => {
const state = { requests: 0, stopped: false };
Object.defineProperty(window, "__barcodeCameraState", { value: state });
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: {
getUserMedia: async () => {
state.requests += 1;
const stream = document.createElement("canvas").captureStream(1);
for (const track of stream.getTracks()) {
const stop = track.stop.bind(track);
track.stop = () => {
state.stopped = true;
stop();
};
}
return stream;
},
},
});
Object.defineProperty(window, "BarcodeDetector", {
configurable: true,
value: class {
async detect() {
return [{ rawValue: "camera-local", format: "qr_code" }];
}
},
});
HTMLMediaElement.prototype.play = async () => undefined;
});
const external = await localOnly(page);
await page.goto("/deep/nested/barcode/");
await page.getByRole("button", { name: "Decode" }).click();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as unknown as {
__barcodeCameraState: { requests: number };
}
).__barcodeCameraState.requests,
),
)
.toBe(0);
await page.getByRole("button", { name: "Start camera" }).click();
await expect(page.getByText("camera-local", { exact: true })).toBeVisible();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as unknown as {
__barcodeCameraState: { requests: number };
}
).__barcodeCameraState.requests,
),
)
.toBe(1);
await page.getByRole("button", { name: "Stop camera" }).click();
await expect
.poll(() =>
page.evaluate(
() =>
(
window as unknown as {
__barcodeCameraState: { stopped: boolean };
}
).__barcodeCameraState.stopped,
),
)
.toBe(true);
expect(external).toEqual([]);
});
test("serves the release identity and hardened headers", async ({
request,
}) => {
@@ -57,7 +142,7 @@ test("serves the release identity and hardened headers", async ({
const manifest = await request.get("/deep/nested/barcode/toolbox-app.json");
await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.barcode-tools",
version: "0.1.0",
version: "0.2.0",
entry: "./",
});
});
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
test("keeps the primary workspace inside a narrow viewport", async ({
page,
}) => {
await page.goto("/deep/nested/barcode/");
await expect(page.locator("main").first()).toBeVisible();
await expect(
page.locator("main .loading, main .workbench-loading"),
).toHaveCount(0);
const widths = await page.evaluate(() => ({
content: document.documentElement.scrollWidth,
viewport: document.documentElement.clientWidth,
}));
expect(widths.viewport).toBeLessThanOrEqual(430);
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
});