diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml new file mode 100644 index 0000000..b84a7cb --- /dev/null +++ b/.gitea/workflows/verify.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index c69e535..5862e44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index fb45ba2..d0e4339 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SOURCE.md b/SOURCE.md index 00ca701..3fc4138 100644 --- a/SOURCE.md +++ b/SOURCE.md @@ -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`. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 06b3972..4dacbd3 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -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 | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9bdcb5b..f3d43d8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/PRIVACY-SECURITY.md b/docs/PRIVACY-SECURITY.md index 85a3fd7..fd7d0b9 100644 --- a/docs/PRIVACY-SECURITY.md +++ b/docs/PRIVACY-SECURITY.md @@ -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. diff --git a/package-lock.json b/package-lock.json index ee84b51..e925a2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 4d4c594..af803ab 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts index 731da62..2ab6a22 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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"] }, + }, ], }); diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md index c69e535..5862e44 100644 --- a/public/CHANGELOG.md +++ b/public/CHANGELOG.md @@ -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. diff --git a/public/LICENSES/npm-runtime-licenses.txt b/public/LICENSES/npm-runtime-licenses.txt index eff8ec1..4be386e 100644 --- a/public/LICENSES/npm-runtime-licenses.txt +++ b/public/LICENSES/npm-runtime-licenses.txt @@ -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 --- diff --git a/public/README.md b/public/README.md index fb45ba2..d0e4339 100644 --- a/public/README.md +++ b/public/README.md @@ -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 diff --git a/public/SOURCE.md b/public/SOURCE.md index 00ca701..3fc4138 100644 --- a/public/SOURCE.md +++ b/public/SOURCE.md @@ -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`. diff --git a/public/THIRD_PARTY_NOTICES.md b/public/THIRD_PARTY_NOTICES.md index 06b3972..4dacbd3 100644 --- a/public/THIRD_PARTY_NOTICES.md +++ b/public/THIRD_PARTY_NOTICES.md @@ -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 | diff --git a/public/docs/ARCHITECTURE.md b/public/docs/ARCHITECTURE.md index 9bdcb5b..f3d43d8 100644 --- a/public/docs/ARCHITECTURE.md +++ b/public/docs/ARCHITECTURE.md @@ -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. diff --git a/public/docs/PRIVACY-SECURITY.md b/public/docs/PRIVACY-SECURITY.md index 85a3fd7..fd7d0b9 100644 --- a/public/docs/PRIVACY-SECURITY.md +++ b/public/docs/PRIVACY-SECURITY.md @@ -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. diff --git a/public/sw.js b/public/sw.js index 421b944..40cb03b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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( diff --git a/public/toolbox-app.json b/public/toolbox-app.json index 1aa370e..32fbce0 100644 --- a/public/toolbox-app.json +++ b/public/toolbox-app.json @@ -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, diff --git a/src/barcode/generate.ts b/src/barcode/generate.ts index f2d4ace..bd82b3a 100644 --- a/src/barcode/generate.ts +++ b/src/barcode/generate.ts @@ -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([ "code128", "gs1-128", @@ -41,6 +53,19 @@ const LINEAR = new Set([ "code39", ]); +const QUIET_ZONE_MODULES: Record = { + 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, + widthMm: number, + dpi: number, +): PrintGuidance { + if (!Number.isFinite(widthMm) || widthMm < 5 || widthMm > 2_000) + throw new Error("Print width must be 5–2,000 mm."); + if (!Number.isInteger(dpi) || dpi < 72 || dpi > 2_400) + throw new Error("Raster target must be an integer from 72–2,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 5–2,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( + /^ 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 01–12."; + 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) + .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, + }; +} diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx index 639fbdd..f84371a 100644 --- a/src/components/Workbench.tsx +++ b/src/components/Workbench.tsx @@ -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(() => renderBarcodeSvg({ format, text, scale, padding, includeText }), ); + const [renderedOptions, setRenderedOptions] = useState({ + format, + scale, + padding, + includeText, + }); const [error, setError] = useState(""); const [payloadKind, setPayloadKind] = useState("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() { />