1 Commits
Author SHA1 Message Date
zemion d3e43bea98 Release Label Tools 0.2.0
Verify / verify (push) Canceled after 0s
2026-09-02 11:52:44 +02:00
37 changed files with 1733 additions and 119 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
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## 0.2.0 - 2026-09-02
- Add a visual label designer, richer vector templates, deterministic
multi-page SVG/ZIP output and vector PDF export with calibrated geometry.
## 0.1.0 - 2026-09-01 ## 0.1.0 - 2026-09-01
- Add bounded CSV/JSON data merge, flattening and field mapping. - Add bounded CSV/JSON data merge, flattening and field mapping.
+15 -5
View File
@@ -1,9 +1,17 @@
# Label Tools # Label Tools
Label Tools combines bounded CSV/JSON data merge with calibrated label stocks
and a reusable visual template designer. Text, image, barcode and shape
elements can be positioned by drag, keyboard or exact mm/in/pt fields; template
JSON round-trips locally. Bleed, sheet bounds, barcode quiet zones and likely
scan-size problems are checked before print. Physical-size SVG and vector PDF
exports retain paths and text, while local raster images remain embedded image
objects.
Label Tools is a production-oriented, local-first browser studio for merging Label Tools is a production-oriented, local-first browser studio for merging
CSV/JSON records into printable label, badge and asset-tag sheets. CSV/JSON records into printable label, badge and asset-tag sheets.
Version 0.1.0 includes: Version 0.2.0 includes:
- bounded CSV and JSON parsing with nested JSON field flattening; - bounded CSV and JSON parsing with nested JSON field flattening;
- explicit field mapping plus local serial and cryptographically random values; - explicit field mapping plus local serial and cryptographically random values;
@@ -16,7 +24,7 @@ Version 0.1.0 includes:
- start-slot selection, X/Y offset and independent scale calibration; - start-slot selection, X/Y offset and independent scale calibration;
- optional cut guides and registration marks; - optional cut guides and registration marks;
- paginated print preview and explicit print calibration guidance; - paginated print preview and explicit print calibration guidance;
- vector SVG, deterministic multi-page SVG/report ZIP, and 144 DPI PDF export. - vector SVG, deterministic multi-page SVG/report ZIP, and vector PDF export.
Data and assets remain in browser memory. There is no telemetry, remote API, Data and assets remain in browser memory. There is no telemetry, remote API,
font download, CDN asset or automatic request. font download, CDN asset or automatic request.
@@ -42,9 +50,11 @@ the actual sheet, print the registration/cut marks on plain paper, choose
stock. Printer feed and unprintable margins are outside browser control. stock. Printer feed and unprintable margins are outside browser control.
SVG uses one of four system font stacks and does not embed fonts. Results can SVG uses one of four system font stacks and does not embed fonts. Results can
therefore vary by operating system. PDF export intentionally rasterizes each therefore vary by operating system. PDF export maps application-owned SVG
generated SVG page at 144 DPI, preserving the preview appearance on the same geometry into pdf-lib vector paths, rectangles and embedded Helvetica text.
device but not creating an archival or press-quality font-embedded PDF. Characters outside the built-in WinAnsi repertoire are replaced visibly, and
unsupported WebP image objects remain available in SVG but are omitted from
PDF. The result is print-accurate in physical dimensions, not an archival PDF.
## Limits ## Limits
+2 -1
View File
@@ -7,7 +7,8 @@ Label Tools keeps data in browser memory and accepts only bounded CSV/JSON and
PNG/JPEG/WebP assets. Merge text and SVG attributes are escaped. Generated PNG/JPEG/WebP assets. Merge text and SVG attributes are escaped. Generated
barcode markup comes from a pinned local encoder and is checked for active or barcode markup comes from a pinned local encoder and is checked for active or
linked elements. Remote image URLs, user-provided SVG and arbitrary templates linked elements. Remote image URLs, user-provided SVG and arbitrary templates
are not accepted in v0.1. containing markup or code are not accepted in v0.2. Imported template JSON is
restricted to the bounded declarative v1 element schema.
Generated labels can contain sensitive or guessable identifiers. Review the Generated labels can contain sensitive or guessable identifiers. Review the
preview and warnings before export, and do not treat serial/random values as an preview and warnings before export, and do not treat serial/random values as an
+1 -1
View File
@@ -1,7 +1,7 @@
# Source identity # Source identity
- Project: Label Tools - Project: Label Tools
- Version: 0.1.0 - Version: 0.2.0
- Canonical repository: https://git.add-ideas.de/lotobo/label-tools - Canonical repository: https://git.add-ideas.de/lotobo/label-tools
- Licence: GPL-3.0-or-later - Licence: GPL-3.0-or-later
- Toolbox app id: `de.add-ideas.label-tools` - Toolbox app id: `de.add-ideas.label-tools`
+2 -1
View File
@@ -5,7 +5,8 @@ never loaded from a CDN. Their licence texts are copied into the production
artifact at build time. artifact at build time.
- React and React DOM — MIT - React and React DOM — MIT
- add·ideas Toolbox Contract, Shell React and Helpers — GPL-3.0-or-later - add·ideas Toolbox Contract and Shell React 0.3.0 — Apache-2.0
- add·ideas Toolbox Helpers 0.2.0 — GPL-3.0-or-later
- bwip-js — MIT - bwip-js — MIT
- fflate — MIT - fflate — MIT
- pdf-lib and its runtime dependencies — MIT - pdf-lib and its runtime dependencies — MIT
+17 -3
View File
@@ -1,15 +1,29 @@
# Architecture # Architecture
The sheet engine keeps all physical geometry in millimetres and converts only
at the editor boundary. Alongside the fixed address, badge and asset layouts,
`labels/template.ts` validates a reusable v1 element model for draggable text,
image, barcode and shape elements. Merge expressions are fixed `{{field}}`
lookups; they never evaluate code. Bounds include an explicit bleed allowance,
and barcode elements report format-specific quiet-zone and minimum-size risks.
SVG pages use physical dimensions and application-owned escaped markup. PDF
export walks that generated markup into `pdf-lib` vector paths, rectangles and
embedded standard-font text; local PNG/JPEG assets remain image objects. No
page-wide rasterization is used. Unsupported WebP embedding remains visible in
SVG and is omitted from PDF rather than fetched or executed.
Label Tools separates untrusted input from rendering: Label Tools separates untrusted input from rendering:
1. `labels/data.ts` uses toolbox helpers for bounded CSV/JSON parsing and the 1. `labels/data.ts` uses toolbox helpers for bounded CSV/JSON parsing and the
browser cryptographic RNG for optional virtual fields. browser cryptographic RNG for optional virtual fields.
2. `labels/stocks.ts` stores canonical millimetre geometry and validates every 2. `labels/stocks.ts` stores canonical millimetre geometry and validates every
preset/custom grid before rendering. preset/custom grid before rendering.
3. `labels/render.ts` maps scalar fields into three fixed layouts, escapes XML, 3. `labels/render.ts` maps scalar fields into three fixed layouts or the
embeds validated local raster data, and places reviewed bwip-js output. validated designer model, escapes XML, embeds validated local raster data,
and places reviewed bwip-js output.
4. `labels/export.ts` exports exact SVG pages, deterministic fflate ZIPs, or 4. `labels/export.ts` exports exact SVG pages, deterministic fflate ZIPs, or
locally rasterized pdf-lib documents. vector pdf-lib documents assembled from application-owned SVG markup.
The React workbench retains the last valid data model after parse errors and The React workbench retains the last valid data model after parse errors and
limits interactive preview to the first 12 pages. Complete SVG export remains limits interactive preview to the first 12 pages. Complete SVG export remains
+6 -4
View File
@@ -7,10 +7,12 @@ inside the symbol; they are never requested.
CSV/JSON structure, field lengths, record counts, image bytes/pixels, grid CSV/JSON structure, field lengths, record counts, image bytes/pixels, grid
dimensions, calibration and output pages are bounded. Object URLs are revoked. dimensions, calibration and output pages are bounded. Object URLs are revoked.
User strings are normalized for label display and XML-escaped. Only built-in User strings are normalized for label display and XML-escaped. Imported
templates are supported, and only locally decoded PNG/JPEG/WebP is embedded. templates use a bounded, declarative v1 schema with no script or remote-resource
field, and only locally decoded PNG/JPEG/WebP is embedded.
Printing crosses a physical boundary the browser cannot verify: installed font Printing crosses a physical boundary the browser cannot verify: installed font
metrics, device drivers, page scaling, feed skew and stock tolerances. Use the metrics, device drivers, page scaling, feed skew and stock tolerances. Use the
calibration controls and test on plain paper. PDF is a 144 DPI raster export and calibration controls and test on plain paper. PDF uses vector geometry and
does not embed or preserve font semantics. embedded standard fonts, but it does not preserve the selected system-font
metrics or provide archival guarantees.
+20 -20
View File
@@ -1,17 +1,17 @@
{ {
"name": "label-tools", "name": "label-tools",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "label-tools", "name": "label-tools",
"version": "0.1.0", "version": "0.2.0",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"bwip-js": "4.11.4", "bwip-js": "4.11.4",
"fflate": "0.8.3", "fflate": "0.8.3",
"pdf-lib": "1.17.1", "pdf-lib": "1.17.1",
@@ -19,7 +19,7 @@
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
@@ -45,24 +45,24 @@
} }
}, },
"node_modules/@add-ideas/toolbox-contract": { "node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz", "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-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==", "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@add-ideas/toolbox-helpers": { "node_modules/@add-ideas/toolbox-helpers": {
"version": "0.1.0", "version": "0.2.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.1.0/toolbox-helpers-0.1.0.tgz", "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-UKl1Oxekedf8D2df86VrnVA53AcMhrnh6iUPXY+k8frirBXotb0yd8SGT+IF/3hcqYwcYe/v9WVFuSgKtIYVnw==", "integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
"license": "GPL-3.0-or-later" "license": "GPL-3.0-or-later"
}, },
"node_modules/@add-ideas/toolbox-shell-react": { "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.2.3/toolbox-shell-react-0.2.3.tgz", "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-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==", "integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=18 <20", "react": ">=18 <20",
@@ -70,13 +70,13 @@
} }
}, },
"node_modules/@add-ideas/toolbox-testkit": { "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.2.3/toolbox-testkit-0.2.3.tgz", "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-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==", "integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"bin": { "bin": {
"toolbox-check": "dist/cli.js" "toolbox-check": "dist/cli.js"
+5 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "label-tools", "name": "label-tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Merge local data into printable labels and badges in the browser.", "description": "Merge local data into printable labels and badges in the browser.",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"author": "Albrecht Degering", "author": "Albrecht Degering",
@@ -39,9 +39,9 @@
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force" "release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
}, },
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"bwip-js": "4.11.4", "bwip-js": "4.11.4",
"fflate": "0.8.3", "fflate": "0.8.3",
"pdf-lib": "1.17.1", "pdf-lib": "1.17.1",
@@ -49,7 +49,7 @@
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -15,7 +15,25 @@ export default defineConfig({
timeout: 180_000, timeout: 180_000,
}, },
projects: [ 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"] },
},
], ],
}); });
+5
View File
@@ -1,5 +1,10 @@
# Changelog # Changelog
## 0.2.0 - 2026-09-02
- Add a visual label designer, richer vector templates, deterministic
multi-page SVG/ZIP output and vector PDF export with calibrated geometry.
## 0.1.0 - 2026-09-01 ## 0.1.0 - 2026-09-01
- Add bounded CSV/JSON data merge, flattening and field mapping. - Add bounded CSV/JSON data merge, flattening and field mapping.
+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 Declared licence: Apache-2.0
============================================================================== ==============================================================================
--- LICENSE --- --- 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 Declared licence: GPL-3.0-or-later
============================================================================== ==============================================================================
--- LICENSE --- --- 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 Declared licence: Apache-2.0
============================================================================== ==============================================================================
--- LICENSE --- --- LICENSE ---
+15 -5
View File
@@ -1,9 +1,17 @@
# Label Tools # Label Tools
Label Tools combines bounded CSV/JSON data merge with calibrated label stocks
and a reusable visual template designer. Text, image, barcode and shape
elements can be positioned by drag, keyboard or exact mm/in/pt fields; template
JSON round-trips locally. Bleed, sheet bounds, barcode quiet zones and likely
scan-size problems are checked before print. Physical-size SVG and vector PDF
exports retain paths and text, while local raster images remain embedded image
objects.
Label Tools is a production-oriented, local-first browser studio for merging Label Tools is a production-oriented, local-first browser studio for merging
CSV/JSON records into printable label, badge and asset-tag sheets. CSV/JSON records into printable label, badge and asset-tag sheets.
Version 0.1.0 includes: Version 0.2.0 includes:
- bounded CSV and JSON parsing with nested JSON field flattening; - bounded CSV and JSON parsing with nested JSON field flattening;
- explicit field mapping plus local serial and cryptographically random values; - explicit field mapping plus local serial and cryptographically random values;
@@ -16,7 +24,7 @@ Version 0.1.0 includes:
- start-slot selection, X/Y offset and independent scale calibration; - start-slot selection, X/Y offset and independent scale calibration;
- optional cut guides and registration marks; - optional cut guides and registration marks;
- paginated print preview and explicit print calibration guidance; - paginated print preview and explicit print calibration guidance;
- vector SVG, deterministic multi-page SVG/report ZIP, and 144 DPI PDF export. - vector SVG, deterministic multi-page SVG/report ZIP, and vector PDF export.
Data and assets remain in browser memory. There is no telemetry, remote API, Data and assets remain in browser memory. There is no telemetry, remote API,
font download, CDN asset or automatic request. font download, CDN asset or automatic request.
@@ -42,9 +50,11 @@ the actual sheet, print the registration/cut marks on plain paper, choose
stock. Printer feed and unprintable margins are outside browser control. stock. Printer feed and unprintable margins are outside browser control.
SVG uses one of four system font stacks and does not embed fonts. Results can SVG uses one of four system font stacks and does not embed fonts. Results can
therefore vary by operating system. PDF export intentionally rasterizes each therefore vary by operating system. PDF export maps application-owned SVG
generated SVG page at 144 DPI, preserving the preview appearance on the same geometry into pdf-lib vector paths, rectangles and embedded Helvetica text.
device but not creating an archival or press-quality font-embedded PDF. Characters outside the built-in WinAnsi repertoire are replaced visibly, and
unsupported WebP image objects remain available in SVG but are omitted from
PDF. The result is print-accurate in physical dimensions, not an archival PDF.
## Limits ## Limits
+2 -1
View File
@@ -7,7 +7,8 @@ Label Tools keeps data in browser memory and accepts only bounded CSV/JSON and
PNG/JPEG/WebP assets. Merge text and SVG attributes are escaped. Generated PNG/JPEG/WebP assets. Merge text and SVG attributes are escaped. Generated
barcode markup comes from a pinned local encoder and is checked for active or barcode markup comes from a pinned local encoder and is checked for active or
linked elements. Remote image URLs, user-provided SVG and arbitrary templates linked elements. Remote image URLs, user-provided SVG and arbitrary templates
are not accepted in v0.1. containing markup or code are not accepted in v0.2. Imported template JSON is
restricted to the bounded declarative v1 element schema.
Generated labels can contain sensitive or guessable identifiers. Review the Generated labels can contain sensitive or guessable identifiers. Review the
preview and warnings before export, and do not treat serial/random values as an preview and warnings before export, and do not treat serial/random values as an
+1 -1
View File
@@ -1,7 +1,7 @@
# Source identity # Source identity
- Project: Label Tools - Project: Label Tools
- Version: 0.1.0 - Version: 0.2.0
- Canonical repository: https://git.add-ideas.de/lotobo/label-tools - Canonical repository: https://git.add-ideas.de/lotobo/label-tools
- Licence: GPL-3.0-or-later - Licence: GPL-3.0-or-later
- Toolbox app id: `de.add-ideas.label-tools` - Toolbox app id: `de.add-ideas.label-tools`
+2 -1
View File
@@ -5,7 +5,8 @@ never loaded from a CDN. Their licence texts are copied into the production
artifact at build time. artifact at build time.
- React and React DOM — MIT - React and React DOM — MIT
- add·ideas Toolbox Contract, Shell React and Helpers — GPL-3.0-or-later - add·ideas Toolbox Contract and Shell React 0.3.0 — Apache-2.0
- add·ideas Toolbox Helpers 0.2.0 — GPL-3.0-or-later
- bwip-js — MIT - bwip-js — MIT
- fflate — MIT - fflate — MIT
- pdf-lib and its runtime dependencies — MIT - pdf-lib and its runtime dependencies — MIT
+17 -3
View File
@@ -1,15 +1,29 @@
# Architecture # Architecture
The sheet engine keeps all physical geometry in millimetres and converts only
at the editor boundary. Alongside the fixed address, badge and asset layouts,
`labels/template.ts` validates a reusable v1 element model for draggable text,
image, barcode and shape elements. Merge expressions are fixed `{{field}}`
lookups; they never evaluate code. Bounds include an explicit bleed allowance,
and barcode elements report format-specific quiet-zone and minimum-size risks.
SVG pages use physical dimensions and application-owned escaped markup. PDF
export walks that generated markup into `pdf-lib` vector paths, rectangles and
embedded standard-font text; local PNG/JPEG assets remain image objects. No
page-wide rasterization is used. Unsupported WebP embedding remains visible in
SVG and is omitted from PDF rather than fetched or executed.
Label Tools separates untrusted input from rendering: Label Tools separates untrusted input from rendering:
1. `labels/data.ts` uses toolbox helpers for bounded CSV/JSON parsing and the 1. `labels/data.ts` uses toolbox helpers for bounded CSV/JSON parsing and the
browser cryptographic RNG for optional virtual fields. browser cryptographic RNG for optional virtual fields.
2. `labels/stocks.ts` stores canonical millimetre geometry and validates every 2. `labels/stocks.ts` stores canonical millimetre geometry and validates every
preset/custom grid before rendering. preset/custom grid before rendering.
3. `labels/render.ts` maps scalar fields into three fixed layouts, escapes XML, 3. `labels/render.ts` maps scalar fields into three fixed layouts or the
embeds validated local raster data, and places reviewed bwip-js output. validated designer model, escapes XML, embeds validated local raster data,
and places reviewed bwip-js output.
4. `labels/export.ts` exports exact SVG pages, deterministic fflate ZIPs, or 4. `labels/export.ts` exports exact SVG pages, deterministic fflate ZIPs, or
locally rasterized pdf-lib documents. vector pdf-lib documents assembled from application-owned SVG markup.
The React workbench retains the last valid data model after parse errors and The React workbench retains the last valid data model after parse errors and
limits interactive preview to the first 12 pages. Complete SVG export remains limits interactive preview to the first 12 pages. Complete SVG export remains
+6 -4
View File
@@ -7,10 +7,12 @@ inside the symbol; they are never requested.
CSV/JSON structure, field lengths, record counts, image bytes/pixels, grid CSV/JSON structure, field lengths, record counts, image bytes/pixels, grid
dimensions, calibration and output pages are bounded. Object URLs are revoked. dimensions, calibration and output pages are bounded. Object URLs are revoked.
User strings are normalized for label display and XML-escaped. Only built-in User strings are normalized for label display and XML-escaped. Imported
templates are supported, and only locally decoded PNG/JPEG/WebP is embedded. templates use a bounded, declarative v1 schema with no script or remote-resource
field, and only locally decoded PNG/JPEG/WebP is embedded.
Printing crosses a physical boundary the browser cannot verify: installed font Printing crosses a physical boundary the browser cannot verify: installed font
metrics, device drivers, page scaling, feed skew and stock tolerances. Use the metrics, device drivers, page scaling, feed skew and stock tolerances. Use the
calibration controls and test on plain paper. PDF is a 144 DPI raster export and calibration controls and test on plain paper. PDF uses vector geometry and
does not embed or preserve font semantics. embedded standard fonts, but it does not preserve the selected system-font
metrics or provide archival guarantees.
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = "label-tools-v0.1.0"; const CACHE = "label-tools-v0.2.0";
const APP = [ const APP = [
"./", "./",
"./index.html", "./index.html",
+18 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.label-tools", "id": "de.add-ideas.label-tools",
"name": "Label Tools", "name": "Label Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Merge and print labels locally.", "description": "Merge and print labels locally.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
@@ -21,6 +21,23 @@
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": false "topLevelContext": false
}, },
"io": {
"accepts": [
{ "mediaType": "text/csv", "extensions": [".csv"] },
{ "mediaType": "application/json", "extensions": [".json"] },
{
"mediaType": "image/*",
"extensions": [".png", ".jpg", ".jpeg", ".webp"]
}
],
"produces": [
{ "mediaType": "application/pdf", "extensions": [".pdf"] },
{ "mediaType": "image/svg+xml", "extensions": [".svg"] },
{ "mediaType": "application/zip", "extensions": [".zip"] },
{ "mediaType": "application/json", "extensions": [".json"] }
]
},
"capabilities": { "required": [], "optional": ["workers"] },
"privacy": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": true, "fileUploads": true,
+556
View File
@@ -0,0 +1,556 @@
import { useRef, useState, type PointerEvent } from "react";
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import { BARCODE_FORMATS } from "../labels/barcode";
import { fromMillimetres, toMillimetres } from "../labels/stocks";
import {
exportTemplate,
importTemplate,
resizeTemplate,
validateTemplate,
} from "../labels/template";
import type {
BarcodeFormat,
LabelElement,
LabelTemplate,
Unit,
} from "../labels/types";
export function DesignerPanel({
template,
onChange,
targetWidthMm,
targetHeightMm,
unit,
}: {
template: LabelTemplate;
onChange: (template: LabelTemplate) => void;
targetWidthMm: number;
targetHeightMm: number;
unit: Unit;
}) {
const [selectedId, setSelectedId] = useState(template.elements[0]?.id ?? "");
const [message, setMessage] = useState("");
const svgRef = useRef<SVGSVGElement>(null);
const drag = useRef<
| {
id: string;
x: number;
y: number;
startX: number;
startY: number;
}
| undefined
>(undefined);
const selected = template.elements.find(
(element) => element.id === selectedId,
);
const issues = validateTemplate(template);
const updateElement = (id: string, patch: Partial<LabelElement>) =>
onChange({
...template,
elements: template.elements.map((element) =>
element.id === id
? ({ ...element, ...patch } as LabelElement)
: element,
),
});
const add = (kind: LabelElement["kind"]) => {
const existing = new Set(template.elements.map((element) => element.id));
let suffix = 1;
while (existing.has(`${kind}-${suffix}`)) suffix += 1;
const id = `${kind}-${suffix}`;
const geometry = { id, xMm: 2, yMm: 2, widthMm: 24, heightMm: 10 };
const element: LabelElement =
kind === "text"
? {
...geometry,
kind,
content: "{{name}}",
fontSizePt: 10,
fontWeight: "400",
align: "left",
color: "#111827",
}
: kind === "barcode"
? {
...geometry,
kind,
content: "{{code}}",
format: "qrcode",
quietZoneMm: 2,
includeText: false,
}
: kind === "image"
? {
...geometry,
kind,
content: "{{image}}",
fit: "contain",
}
: {
...geometry,
kind,
fill: "#ffffff",
stroke: "#111827",
strokeWidthMm: 0.2,
radiusMm: 1,
};
onChange({ ...template, elements: [...template.elements, element] });
setSelectedId(id);
};
const position = (event: PointerEvent<SVGElement>) => {
const box = svgRef.current?.getBoundingClientRect();
if (!box) return { x: 0, y: 0 };
return {
x:
((event.clientX - box.left) / box.width) *
(template.widthMm + template.bleedMm * 2) -
template.bleedMm,
y:
((event.clientY - box.top) / box.height) *
(template.heightMm + template.bleedMm * 2) -
template.bleedMm,
};
};
async function open(file: File | undefined) {
if (!file) return;
try {
const next = importTemplate(await file.text());
onChange(next);
setSelectedId(next.elements[0]?.id ?? "");
setMessage(`Opened ${file.name}.`);
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
}
}
return (
<section
className="panel control-panel designer-panel"
aria-labelledby="designer-title"
>
<div className="section-heading">
<div>
<p className="eyebrow">Reusable vector template</p>
<h2 id="designer-title">Designer canvas</h2>
</div>
<span className="count">{template.elements.length} elements</span>
</div>
<div className="designer-actions">
{(["text", "barcode", "image", "shape"] as const).map((kind) => (
<button type="button" key={kind} onClick={() => add(kind)}>
+ {kind}
</button>
))}
<button
type="button"
onClick={() =>
onChange(resizeTemplate(template, targetWidthMm, targetHeightMm))
}
>
Fit selected stock
</button>
<button
type="button"
onClick={() =>
triggerBlobDownload(
new Blob([exportTemplate(template)], {
type: "application/json",
}),
"label-template.json",
)
}
>
Save template
</button>
<label className="file-button">
Open template
<input
type="file"
accept=".json,application/json"
onChange={(event) => void open(event.target.files?.[0])}
/>
</label>
</div>
<label>
Template name
<input
value={template.name}
maxLength={120}
onChange={(event) =>
onChange({ ...template, name: event.target.value })
}
/>
</label>
<label>
Bleed ({unit})
<input
type="number"
min="0"
max={fromMillimetres(20, unit)}
step={unit === "mm" ? 0.1 : 0.01}
value={round(fromMillimetres(template.bleedMm, unit))}
onChange={(event) =>
onChange({
...template,
bleedMm: toMillimetres(event.target.valueAsNumber, unit),
})
}
/>
</label>
<svg
ref={svgRef}
className="designer-canvas"
viewBox={`${-template.bleedMm} ${-template.bleedMm} ${template.widthMm + template.bleedMm * 2} ${template.heightMm + template.bleedMm * 2}`}
role="img"
aria-label="Draggable label template canvas"
onPointerMove={(event) => {
const active = drag.current;
if (!active) return;
const point = position(event);
const element = template.elements.find(
(item) => item.id === active.id,
);
if (!element) return;
updateElement(active.id, {
xMm: clamp(
active.x + point.x - active.startX,
-template.bleedMm,
template.widthMm + template.bleedMm - element.widthMm,
),
yMm: clamp(
active.y + point.y - active.startY,
-template.bleedMm,
template.heightMm + template.bleedMm - element.heightMm,
),
});
}}
onPointerUp={() => {
drag.current = undefined;
}}
onPointerCancel={() => {
drag.current = undefined;
}}
>
<rect
x={-template.bleedMm}
y={-template.bleedMm}
width={template.widthMm + template.bleedMm * 2}
height={template.heightMm + template.bleedMm * 2}
className="designer-bleed"
/>
<rect
width={template.widthMm}
height={template.heightMm}
className="designer-label"
/>
{template.elements.map((element) => (
<g
key={element.id}
className={
selectedId === element.id
? "designer-element selected"
: "designer-element"
}
tabIndex={0}
aria-label={`${element.kind} element ${element.id}`}
onPointerDown={(event) => {
setSelectedId(element.id);
const point = position(event);
drag.current = {
id: element.id,
x: element.xMm,
y: element.yMm,
startX: point.x,
startY: point.y,
};
event.currentTarget.setPointerCapture(event.pointerId);
}}
onKeyDown={(event) => {
const amount = event.shiftKey ? 1 : 0.1;
const delta =
event.key === "ArrowLeft"
? [-amount, 0]
: event.key === "ArrowRight"
? [amount, 0]
: event.key === "ArrowUp"
? [0, -amount]
: event.key === "ArrowDown"
? [0, amount]
: undefined;
if (!delta) return;
event.preventDefault();
updateElement(element.id, {
xMm: element.xMm + delta[0]!,
yMm: element.yMm + delta[1]!,
});
}}
>
<rect
x={element.xMm}
y={element.yMm}
width={element.widthMm}
height={element.heightMm}
rx="0.6"
/>
<text x={element.xMm + 1} y={element.yMm + 3}>
{element.kind}
</text>
</g>
))}
</svg>
{selected && (
<div className="subpanel">
<div className="section-heading">
<h3>
{selected.kind} · {selected.id}
</h3>
<button
type="button"
onClick={() => {
onChange({
...template,
elements: template.elements.filter(
(item) => item.id !== selected.id,
),
});
setSelectedId("");
}}
>
Delete
</button>
</div>
<div className="form-grid">
{(["xMm", "yMm", "widthMm", "heightMm"] as const).map((key) => (
<label key={key}>
{key.replace("Mm", "")} ({unit})
<input
type="number"
step={unit === "mm" ? 0.1 : 0.01}
value={round(fromMillimetres(selected[key], unit))}
onChange={(event) =>
updateElement(selected.id, {
[key]: toMillimetres(event.target.valueAsNumber, unit),
})
}
/>
</label>
))}
</div>
{selected.kind !== "shape" && (
<label>
Content / merge expression
<input
value={selected.content}
onChange={(event) =>
updateElement(selected.id, { content: event.target.value })
}
placeholder="{{field}}"
/>
</label>
)}
{selected.kind === "barcode" && (
<>
<label>
Barcode format
<select
value={selected.format}
onChange={(event) =>
updateElement(selected.id, {
format: event.target.value as BarcodeFormat,
})
}
>
{BARCODE_FORMATS.map(([format, label]) => (
<option key={format} value={format}>
{label}
</option>
))}
</select>
</label>
<label>
Quiet zone ({unit})
<input
type="number"
min="0"
value={round(fromMillimetres(selected.quietZoneMm, unit))}
onChange={(event) =>
updateElement(selected.id, {
quietZoneMm: toMillimetres(
event.target.valueAsNumber,
unit,
),
})
}
/>
</label>
<label className="check-row">
<input
type="checkbox"
checked={selected.includeText}
onChange={(event) =>
updateElement(selected.id, {
includeText: event.target.checked,
})
}
/>
Include human-readable text
</label>
</>
)}
{selected.kind === "text" && (
<div className="form-grid">
<label>
Font size (pt)
<input
type="number"
min="3"
max="144"
value={selected.fontSizePt}
onChange={(event) =>
updateElement(selected.id, {
fontSizePt: event.target.valueAsNumber,
})
}
/>
</label>
<label>
Weight
<select
value={selected.fontWeight}
onChange={(event) =>
updateElement(selected.id, {
fontWeight: event.target.value as "400" | "600" | "700",
})
}
>
<option value="400">Regular</option>
<option value="600">Semibold</option>
<option value="700">Bold</option>
</select>
</label>
<label>
Alignment
<select
value={selected.align}
onChange={(event) =>
updateElement(selected.id, {
align: event.target.value as "left" | "center" | "right",
})
}
>
<option value="left">Left</option>
<option value="center">Centre</option>
<option value="right">Right</option>
</select>
</label>
<label>
Colour
<input
type="color"
value={selected.color}
onChange={(event) =>
updateElement(selected.id, { color: event.target.value })
}
/>
</label>
</div>
)}
{selected.kind === "image" && (
<label>
Image fit
<select
value={selected.fit}
onChange={(event) =>
updateElement(selected.id, {
fit: event.target.value as "contain" | "cover",
})
}
>
<option value="contain">Contain</option>
<option value="cover">Cover</option>
</select>
</label>
)}
{selected.kind === "shape" && (
<div className="form-grid">
<label>
Fill
<input
type="color"
value={selected.fill}
onChange={(event) =>
updateElement(selected.id, { fill: event.target.value })
}
/>
</label>
<label>
Stroke
<input
type="color"
value={selected.stroke}
onChange={(event) =>
updateElement(selected.id, { stroke: event.target.value })
}
/>
</label>
<label>
Stroke ({unit})
<input
type="number"
min="0"
max={fromMillimetres(5, unit)}
step={unit === "mm" ? 0.1 : 0.01}
value={round(fromMillimetres(selected.strokeWidthMm, unit))}
onChange={(event) =>
updateElement(selected.id, {
strokeWidthMm: toMillimetres(
event.target.valueAsNumber,
unit,
),
})
}
/>
</label>
<label>
Corner radius ({unit})
<input
type="number"
min="0"
max={fromMillimetres(100, unit)}
step={unit === "mm" ? 0.1 : 0.01}
value={round(fromMillimetres(selected.radiusMm, unit))}
onChange={(event) =>
updateElement(selected.id, {
radiusMm: toMillimetres(event.target.valueAsNumber, unit),
})
}
/>
</label>
</div>
)}
</div>
)}
{issues.length ? (
<ul className="designer-issues">
{issues.map((issue, index) => (
<li key={`${issue.elementId}-${index}`} className={issue.severity}>
{issue.message}
</li>
))}
</ul>
) : (
<p className="hint">
Template geometry and barcode quiet zones are valid.
</p>
)}
{message && <p role="status">{message}</p>}
</section>
);
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.max(minimum, Math.min(maximum, value));
}
function round(value: number) {
return Number(value.toFixed(3));
}
+2 -1
View File
@@ -43,7 +43,8 @@ export function HelpDialog({
<p> <p>
Paper feed, printer scaling and installed system fonts vary. Print a Paper feed, printer scaling and installed system fonts vary. Print a
calibration sheet on plain paper at 100% / actual size before using calibration sheet on plain paper at 100% / actual size before using
label stock. PDF export rasterizes at 144 DPI and does not embed fonts. label stock. PDF export keeps reviewed geometry vector and embeds its
standard fonts; SVG continues to use the selected system font stack.
</p> </p>
</dialog> </dialog>
); );
+22 -5
View File
@@ -5,6 +5,7 @@ import { augmentRecords, collectFields, parseMergeData } from "../labels/data";
import { createSvgZip, svgFilename } from "../labels/export"; import { createSvgZip, svgFilename } from "../labels/export";
import { loadImageAssets } from "../labels/images"; import { loadImageAssets } from "../labels/images";
import { renderLabelPages } from "../labels/render"; import { renderLabelPages } from "../labels/render";
import { defaultTemplate } from "../labels/template";
import { import {
fromMillimetres, fromMillimetres,
STOCKS, STOCKS,
@@ -23,6 +24,7 @@ import type {
RenderOptions, RenderOptions,
Unit, Unit,
} from "../labels/types"; } from "../labels/types";
import { DesignerPanel } from "./DesignerPanel";
const SAMPLE = `name,subtitle,address,code,image const SAMPLE = `name,subtitle,address,code,image
Ada Lovelace,Research,12 Analytical Engine Way · London,ASSET-0001, Ada Lovelace,Research,12 Analytical Engine Way · London,ASSET-0001,
@@ -67,6 +69,9 @@ export function Workbench() {
}); });
const [unit, setUnit] = useState<Unit>("mm"); const [unit, setUnit] = useState<Unit>("mm");
const [layout, setLayout] = useState<LayoutKind>("address"); const [layout, setLayout] = useState<LayoutKind>("address");
const [template, setTemplate] = useState(() =>
defaultTemplate(STOCKS[0]!.labelWidthMm, STOCKS[0]!.labelHeightMm),
);
const [barcodeFormat, setBarcodeFormat] = useState<BarcodeFormat>("qrcode"); const [barcodeFormat, setBarcodeFormat] = useState<BarcodeFormat>("qrcode");
const [includeBarcodeText, setIncludeBarcodeText] = useState(false); const [includeBarcodeText, setIncludeBarcodeText] = useState(false);
const [fontFamily, setFontFamily] = useState("Arial, Helvetica, sans-serif"); const [fontFamily, setFontFamily] = useState("Arial, Helvetica, sans-serif");
@@ -105,6 +110,7 @@ export function Workbench() {
cutMarks, cutMarks,
registrationMarks, registrationMarks,
startPosition, startPosition,
template,
}), }),
[ [
stock, stock,
@@ -120,6 +126,7 @@ export function Workbench() {
cutMarks, cutMarks,
registrationMarks, registrationMarks,
startPosition, startPosition,
template,
], ],
); );
const preview = useMemo(() => { const preview = useMemo(() => {
@@ -197,7 +204,7 @@ export function Workbench() {
} }
async function downloadPdf() { async function downloadPdf() {
setStatus("Rasterizing pages locally for PDF export…"); setStatus("Building vector PDF pages locally…");
try { try {
const result = fullRender(); const result = fullRender();
const { createPdf } = await import("../labels/pdf"); const { createPdf } = await import("../labels/pdf");
@@ -206,7 +213,7 @@ export function Workbench() {
"label-tools-merge.pdf", "label-tools-merge.pdf",
); );
setStatus( setStatus(
`Exported ${result.totalPages} PDF page${result.totalPages === 1 ? "" : "s"} locally at 144 DPI.`, `Exported ${result.totalPages} vector PDF page${result.totalPages === 1 ? "" : "s"} locally.`,
); );
} catch (error) { } catch (error) {
setStatus(error instanceof Error ? error.message : String(error)); setStatus(error instanceof Error ? error.message : String(error));
@@ -274,6 +281,15 @@ export function Workbench() {
fontFamily={fontFamily} fontFamily={fontFamily}
setFontFamily={setFontFamily} setFontFamily={setFontFamily}
/> />
{layout === "designer" && (
<DesignerPanel
template={template}
onChange={setTemplate}
targetWidthMm={stock.labelWidthMm}
targetHeightMm={stock.labelHeightMm}
unit={unit}
/>
)}
<StockPanel <StockPanel
stock={stock} stock={stock}
stockId={stockId} stockId={stockId}
@@ -418,7 +434,7 @@ export function Workbench() {
onClick={() => void downloadPdf()} onClick={() => void downloadPdf()}
disabled={!preview.result} disabled={!preview.result}
> >
PDF (144 DPI) Vector PDF
</button> </button>
<button type="button" onClick={printAll} disabled={!preview.result}> <button type="button" onClick={printAll} disabled={!preview.result}>
Print all Print all
@@ -427,8 +443,8 @@ export function Workbench() {
<p className="print-boundary"> <p className="print-boundary">
<strong>Print boundary:</strong> use actual size / 100%, disable <strong>Print boundary:</strong> use actual size / 100%, disable
browser headers and footers, and verify a plain-paper calibration. browser headers and footers, and verify a plain-paper calibration.
SVG stays vector; PDF is rasterized and system fonts are not SVG and PDF geometry stay vector; local images remain raster and PDF
embedded. text uses embedded standard fonts.
</p> </p>
{preview.result && <Warnings warnings={preview.result.warnings} />} {preview.result && <Warnings warnings={preview.result.warnings} />}
</section> </section>
@@ -630,6 +646,7 @@ function MappingPanel({
<option value="address">Address label</option> <option value="address">Address label</option>
<option value="badge">Name badge</option> <option value="badge">Name badge</option>
<option value="asset">Asset tag</option> <option value="asset">Asset tag</option>
<option value="designer">Custom designer</option>
</select> </select>
</label> </label>
<FieldSelect <FieldSelect
+1 -1
View File
@@ -14,7 +14,7 @@ export function createSvgZip(result: RenderResult): Blob {
files["merge-report.json"] = strToU8( files["merge-report.json"] = strToU8(
stableStringify( stableStringify(
{ {
application: "Label Tools 0.1.0", application: "Label Tools 0.2.0",
pages: result.totalPages, pages: result.totalPages,
labels: result.totalLabels, labels: result.totalLabels,
warnings: result.warnings, warnings: result.warnings,
+214 -43
View File
@@ -1,4 +1,11 @@
import { PDFDocument } from "pdf-lib"; import {
PDFDocument,
StandardFonts,
rgb,
type PDFFont,
type PDFPage,
type RGB,
} from "pdf-lib";
import type { LabelStock, RenderResult } from "./types"; import type { LabelStock, RenderResult } from "./types";
export async function createPdf( export async function createPdf(
@@ -12,19 +19,21 @@ export async function createPdf(
const pdf = await PDFDocument.create(); const pdf = await PDFDocument.create();
const widthPt = (stock.pageWidthMm * 72) / 25.4; const widthPt = (stock.pageWidthMm * 72) / 25.4;
const heightPt = (stock.pageHeightMm * 72) / 25.4; const heightPt = (stock.pageHeightMm * 72) / 25.4;
const regular = await pdf.embedFont(StandardFonts.Helvetica);
const bold = await pdf.embedFont(StandardFonts.HelveticaBold);
for (const svg of result.pages) { for (const svg of result.pages) {
const png = await rasterizeSvg(svg, stock.pageWidthMm, stock.pageHeightMm);
const embedded = await pdf.embedPng(png);
const page = pdf.addPage([widthPt, heightPt]); const page = pdf.addPage([widthPt, heightPt]);
page.drawImage(embedded, { page.drawRectangle({
x: 0, x: 0,
y: 0, y: 0,
width: widthPt, width: widthPt,
height: heightPt, height: heightPt,
color: rgb(1, 1, 1),
}); });
await drawVectorSvg(pdf, page, svg, heightPt, regular, bold);
} }
pdf.setTitle("Label Tools merge"); pdf.setTitle("Label Tools merge");
pdf.setCreator("Label Tools 0.1.0"); pdf.setCreator("Label Tools 0.2.0");
pdf.setProducer("Label Tools / pdf-lib"); pdf.setProducer("Label Tools / pdf-lib");
const bytes = await pdf.save({ const bytes = await pdf.save({
useObjectStreams: false, useObjectStreams: false,
@@ -35,46 +44,208 @@ export async function createPdf(
}); });
} }
async function rasterizeSvg( const MM_PT = 72 / 25.4;
interface DrawContext {
tx: number;
ty: number;
scale: number;
fill?: string;
stroke?: string;
strokeWidth: number;
}
async function drawVectorSvg(
pdf: PDFDocument,
page: PDFPage,
svg: string, svg: string,
widthMm: number, pageHeight: number,
heightMm: number, regular: PDFFont,
): Promise<Uint8Array> { bold: PDFFont,
const dpi = 144; ) {
const width = Math.ceil((widthMm / 25.4) * dpi); const document = new DOMParser().parseFromString(svg, "image/svg+xml");
const height = Math.ceil((heightMm / 25.4) * dpi); if (document.querySelector("parsererror"))
if (width * height > 8_000_000) throw new Error("Generated SVG could not be parsed for vector PDF export.");
throw new Error( const root = document.documentElement;
"Rasterized PDF page exceeds the 8 megapixel safety limit.", for (const child of [...root.children])
); await drawNode(pdf, page, child, pageHeight, regular, bold, {
const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" })); tx: 0,
try { ty: 0,
const image = await new Promise<HTMLImageElement>((resolve, reject) => { scale: 1,
const node = new Image(); strokeWidth: 0,
node.onload = () => resolve(node);
node.onerror = () =>
reject(
new Error("Browser could not rasterize the generated SVG page."),
);
node.src = url;
}); });
const canvas = document.createElement("canvas"); }
canvas.width = width;
canvas.height = height; async function drawNode(
const context = canvas.getContext("2d"); pdf: PDFDocument,
if (!context) throw new Error("Canvas rendering is unavailable."); page: PDFPage,
context.fillStyle = "white"; node: Element,
context.fillRect(0, 0, width, height); pageHeight: number,
context.drawImage(image, 0, 0, width, height); regular: PDFFont,
const blob = await new Promise<Blob>((resolve, reject) => bold: PDFFont,
canvas.toBlob( parent: DrawContext,
(value) => ): Promise<void> {
value ? resolve(value) : reject(new Error("PNG conversion failed.")), if (["defs", "clipPath", "marker"].includes(node.localName)) return;
"image/png", const context = applyContext(parent, node);
), if (node.localName === "g" || node.localName === "svg") {
for (const child of [...node.children])
await drawNode(pdf, page, child, pageHeight, regular, bold, context);
return;
}
if (node.localName === "rect") {
const x = number(node, "x", 0),
y = number(node, "y", 0),
width = number(node, "width"),
height = number(node, "height");
if (
![x, y, width, height].every(Number.isFinite) ||
width <= 0 ||
height <= 0
)
return;
const fill = paint(context.fill),
stroke = paint(context.stroke);
page.drawRectangle({
x: (context.tx + x * context.scale) * MM_PT,
y: pageHeight - (context.ty + (y + height) * context.scale) * MM_PT,
width: width * context.scale * MM_PT,
height: height * context.scale * MM_PT,
...(fill ? { color: fill } : {}),
...(stroke
? {
borderColor: stroke,
borderWidth: context.strokeWidth * context.scale * MM_PT,
}
: {}),
});
return;
}
if (node.localName === "path") {
const path = node.getAttribute("d");
if (!path || path.length > 1_000_000) return;
const fill = paint(context.fill),
stroke = paint(context.stroke);
page.drawSvgPath(path, {
x: context.tx * MM_PT,
y: pageHeight - context.ty * MM_PT,
scale: context.scale * MM_PT,
...(fill ? { color: fill } : {}),
...(stroke
? {
borderColor: stroke,
borderWidth: context.strokeWidth * context.scale * MM_PT,
}
: {}),
});
return;
}
if (node.localName === "text") {
const font = /^(?:600|700|bold)$/u.test(
node.getAttribute("font-weight") ?? "",
)
? bold
: regular,
size = number(node, "font-size", 3) * context.scale * MM_PT,
anchor = node.getAttribute("text-anchor") ?? "start";
let cursorY = number(node, "y", 0);
const spans = [...node.children].filter(
(child) => child.localName === "tspan",
); );
return new Uint8Array(await blob.arrayBuffer()); const lines = spans.length ? spans : [node];
} finally { for (const line of lines) {
URL.revokeObjectURL(url); cursorY += number(line, "dy", 0);
const value = pdfText(line.textContent ?? ""),
lineX = number(line, "x", number(node, "x", 0)),
width = font.widthOfTextAtSize(value, size),
x =
(context.tx + lineX * context.scale) * MM_PT -
(anchor === "middle" ? width / 2 : anchor === "end" ? width : 0);
page.drawText(value, {
x,
y: pageHeight - (context.ty + cursorY * context.scale) * MM_PT,
size,
font,
color: paint(context.fill) ?? rgb(0.07, 0.09, 0.15),
});
}
return;
}
if (node.localName === "image") {
const href = node.getAttribute("href") ?? node.getAttribute("xlink:href");
if (!href?.startsWith("data:image/")) return;
const comma = href.indexOf(",");
if (comma < 0 || !href.slice(0, comma).includes(";base64")) return;
const bytes = Uint8Array.from(atob(href.slice(comma + 1)), (character) =>
character.charCodeAt(0),
);
const embedded = href.startsWith("data:image/png")
? await pdf.embedPng(bytes)
: href.startsWith("data:image/jpeg")
? await pdf.embedJpg(bytes)
: undefined;
if (!embedded) return;
const x = number(node, "x", 0),
y = number(node, "y", 0),
width = number(node, "width"),
height = number(node, "height");
if (![width, height].every(Number.isFinite)) return;
page.drawImage(embedded, {
x: (context.tx + x * context.scale) * MM_PT,
y: pageHeight - (context.ty + (y + height) * context.scale) * MM_PT,
width: width * context.scale * MM_PT,
height: height * context.scale * MM_PT,
});
} }
} }
function applyContext(parent: DrawContext, node: Element): DrawContext {
let tx = parent.tx,
ty = parent.ty,
scale = parent.scale;
const transform = node.getAttribute("transform") ?? "";
for (const match of transform.matchAll(/(translate|scale)\(([^)]+)\)/gu)) {
const values = match[2]!.trim().split(/[ ,]+/u).map(Number);
if (values.some((value) => !Number.isFinite(value))) continue;
if (match[1] === "translate") {
tx += (values[0] ?? 0) * scale;
ty += (values[1] ?? 0) * scale;
} else if (
(values[0] ?? 0) > 0 &&
(values[1] === undefined || values[1] === values[0])
)
scale *= values[0] ?? 1;
}
return {
tx,
ty,
scale,
fill: node.getAttribute("fill") ?? parent.fill,
stroke: node.getAttribute("stroke") ?? parent.stroke,
strokeWidth: number(node, "stroke-width", parent.strokeWidth),
};
}
function paint(value: string | undefined): RGB | undefined {
if (!value || value === "none") return undefined;
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/iu.exec(value)?.[1];
if (!hex) return undefined;
const expanded =
hex.length === 3 ? [...hex].map((part) => part + part).join("") : hex;
return rgb(
Number.parseInt(expanded.slice(0, 2), 16) / 255,
Number.parseInt(expanded.slice(2, 4), 16) / 255,
Number.parseInt(expanded.slice(4, 6), 16) / 255,
);
}
function number(node: Element, name: string, fallback = Number.NaN) {
const value = Number(node.getAttribute(name));
return Number.isFinite(value) ? value : fallback;
}
function pdfText(value: string) {
return [...value]
.map((character) => {
const point = character.codePointAt(0) ?? 0;
return point >= 0x20 && point <= 0xff ? character : "?";
})
.join("")
.slice(0, 10_000);
}
+134
View File
@@ -1,5 +1,6 @@
import { barcodeFragment } from "./barcode"; import { barcodeFragment } from "./barcode";
import { stockCapacity, validateStock } from "./stocks"; import { stockCapacity, validateStock } from "./stocks";
import { mergeContent, validateTemplate } from "./template";
import type { import type {
ImageAsset, ImageAsset,
MergeRecord, MergeRecord,
@@ -61,6 +62,7 @@ export function renderLabelPages(
width, width,
height, height,
image, image,
imageByName,
options, options,
warnings, warnings,
), ),
@@ -85,9 +87,23 @@ function renderLabel(
width: number, width: number,
height: number, height: number,
image: ImageAsset | undefined, image: ImageAsset | undefined,
imageByName: ReadonlyMap<string, ImageAsset>,
options: RenderOptions, options: RenderOptions,
warnings: RenderWarning[], warnings: RenderWarning[],
): string { ): string {
if (options.layout === "designer" && options.template)
return renderDesignerLabel(
record,
rowIndex,
x,
y,
width,
height,
imageByName,
image,
options,
warnings,
);
const id = `clip-${rowIndex}-${Math.round(x * 100)}-${Math.round(y * 100)}`; const id = `clip-${rowIndex}-${Math.round(x * 100)}-${Math.round(y * 100)}`;
const padding = Math.max(1.4, Math.min(width, height) * 0.06); const padding = Math.max(1.4, Math.min(width, height) * 0.06);
const title = fitted( const title = fitted(
@@ -180,6 +196,122 @@ function renderLabel(
return content.join(""); return content.join("");
} }
function renderDesignerLabel(
record: MergeRecord,
rowIndex: number,
x: number,
y: number,
width: number,
height: number,
images: ReadonlyMap<string, ImageAsset>,
fallbackImage: ImageAsset | undefined,
options: RenderOptions,
warnings: RenderWarning[],
) {
const template = options.template!;
const issues = validateTemplate(template);
const error = issues.find((issue) => issue.severity === "error");
if (error) throw new Error(error.message);
for (const issue of issues.filter((item) => item.severity === "warning"))
warnings.push({
row: rowIndex + 1,
field: issue.elementId ?? "template",
message: issue.message,
});
const sx = width / template.widthMm,
sy = height / template.heightMm,
bleedX = template.bleedMm * sx,
bleedY = template.bleedMm * sy,
clipId = `designer-${rowIndex}-${Math.round(x * 100)}-${Math.round(y * 100)}`;
const output = [
`<defs><clipPath id="${clipId}"><rect x="${n(x - bleedX)}" y="${n(y - bleedY)}" width="${n(width + bleedX * 2)}" height="${n(height + bleedY * 2)}"/></clipPath></defs>`,
`<g clip-path="url(#${clipId})"><rect x="${n(x)}" y="${n(y)}" width="${n(width)}" height="${n(height)}" fill="#fff"/>`,
];
for (const element of template.elements) {
const ex = x + element.xMm * sx,
ey = y + element.yMm * sy,
ew = element.widthMm * sx,
eh = element.heightMm * sy;
if (element.kind === "shape") {
output.push(
`<rect x="${n(ex)}" y="${n(ey)}" width="${n(ew)}" height="${n(eh)}" rx="${n(element.radiusMm * Math.min(sx, sy))}" fill="${xml(element.fill)}" stroke="${xml(element.stroke)}" stroke-width="${n(element.strokeWidthMm * Math.min(sx, sy))}"/>`,
);
continue;
}
const content = mergeContent(element.content, record);
if (element.kind === "text") {
const fit = fitted(
content,
ew,
eh,
element.fontSizePt,
rowIndex,
element.id,
warnings,
Math.max(
1,
Math.floor(eh / Math.max(1.2, element.fontSizePt * 0.44)),
),
),
anchor =
element.align === "center"
? "middle"
: element.align === "right"
? "end"
: "start",
textX =
element.align === "center"
? ex + ew / 2
: element.align === "right"
? ex + ew
: ex;
output.push(
`<text x="${n(textX)}" y="${n(ey + fit.size)}" text-anchor="${anchor}" font-family="${xml(options.fontFamily)}" font-size="${n(fit.size)}" font-weight="${element.fontWeight}" fill="${xml(element.color)}">${fit.lines.map((line, index) => `<tspan x="${n(textX)}" dy="${index ? n(fit.size * 1.25) : 0}">${xml(line)}</tspan>`).join("")}</text>`,
);
continue;
}
if (element.kind === "image") {
const asset = images.get(content) ?? fallbackImage;
if (asset)
output.push(
`<image x="${n(ex)}" y="${n(ey)}" width="${n(ew)}" height="${n(eh)}" href="${xml(asset.dataUrl)}" preserveAspectRatio="xMidYMid ${element.fit === "cover" ? "slice" : "meet"}"/>`,
);
else if (content)
warnings.push({
row: rowIndex + 1,
field: element.id,
message: `Image ${content} is not loaded.`,
});
continue;
}
if (content) {
const quietX = element.quietZoneMm * sx,
quietY = element.quietZoneMm * sy;
barcodeElement(
output,
content,
{
...options,
barcodeFormat: element.format,
includeBarcodeText: element.includeText,
},
ex + quietX,
ey + quietY,
ew - quietX * 2,
eh - quietY * 2,
rowIndex,
warnings,
);
}
}
output.push("</g>");
if (options.cutMarks)
output.push(
`<rect x="${n(x)}" y="${n(y)}" width="${n(width)}" height="${n(height)}" fill="none" stroke="#7b8794" stroke-width="0.18" stroke-dasharray="1 1"/>`,
);
return output.join("");
}
interface ContentContext { interface ContentContext {
x: number; x: number;
y: number; y: number;
@@ -540,6 +672,8 @@ function validateOptions(options: RenderOptions) {
throw new Error("A merge is limited to 2,000 labels."); throw new Error("A merge is limited to 2,000 labels.");
if (!ALLOWED_FONTS.has(options.fontFamily)) if (!ALLOWED_FONTS.has(options.fontFamily))
throw new Error("Unsupported system font stack."); throw new Error("Unsupported system font stack.");
if (options.layout === "designer" && !options.template)
throw new Error("Designer layout requires a validated template.");
} }
function xml(value: string): string { function xml(value: string): string {
+313
View File
@@ -0,0 +1,313 @@
import { safeJsonParse, stableStringify } from "@add-ideas/toolbox-helpers";
import type {
BarcodeFormat,
LabelTemplate,
MergeRecord,
TemplateIssue,
} from "./types";
import { BARCODE_FORMATS } from "./barcode";
export const TEMPLATE_SCHEMA = "de.add-ideas.label-tools.template.v1" as const;
export const MAX_TEMPLATE_BYTES = 256 * 1024;
export function defaultTemplate(
widthMm: number,
heightMm: number,
): LabelTemplate {
const pad = Math.max(1.5, Math.min(widthMm, heightMm) * 0.06);
return {
schema: TEMPLATE_SCHEMA,
id: "custom-label",
name: "Custom label",
widthMm,
heightMm,
bleedMm: 0,
elements: [
{
id: "title",
kind: "text",
xMm: pad,
yMm: pad,
widthMm: widthMm * 0.58,
heightMm: Math.max(5, heightMm * 0.2),
content: "{{name}}",
fontSizePt: 14,
fontWeight: "700",
align: "left",
color: "#111827",
},
{
id: "body",
kind: "text",
xMm: pad,
yMm: heightMm * 0.32,
widthMm: widthMm * 0.58,
heightMm: heightMm * 0.42,
content: "{{address}}",
fontSizePt: 9,
fontWeight: "400",
align: "left",
color: "#374151",
},
{
id: "code",
kind: "barcode",
xMm: widthMm * 0.67,
yMm: heightMm * 0.16,
widthMm: widthMm * 0.28,
heightMm: heightMm * 0.68,
content: "{{code}}",
format: "qrcode",
quietZoneMm: 2,
includeText: false,
},
],
};
}
export function exportTemplate(template: LabelTemplate): string {
const issues = validateTemplate(template);
const error = issues.find((issue) => issue.severity === "error");
if (error) throw new Error(error.message);
return stableStringify(template as unknown as Record<string, unknown>, 2, {
maxTextChars: MAX_TEMPLATE_BYTES,
maxDepth: 16,
maxNodes: 10_000,
});
}
export function importTemplate(source: string): LabelTemplate {
const value = safeJsonParse(source, {
maxTextChars: MAX_TEMPLATE_BYTES,
maxDepth: 16,
maxNodes: 10_000,
});
if (!isObject(value) || value.schema !== TEMPLATE_SCHEMA)
throw new Error("Not a Label Tools template v1.");
const template = value as unknown as LabelTemplate;
const issue = validateTemplate(template).find(
(candidate) => candidate.severity === "error",
);
if (issue) throw new Error(issue.message);
return structuredClone(template);
}
export function validateTemplate(template: LabelTemplate): TemplateIssue[] {
const issues: TemplateIssue[] = [];
if (
template.schema !== TEMPLATE_SCHEMA ||
!finiteRange(template.widthMm, 5, 1_000) ||
!finiteRange(template.heightMm, 5, 1_000)
)
issues.push({
severity: "error",
message: "Template dimensions must be 51,000 mm.",
});
if (
typeof template.id !== "string" ||
!/^[A-Za-z0-9_-]{1,64}$/u.test(template.id) ||
typeof template.name !== "string" ||
template.name.trim().length === 0 ||
template.name.length > 120
)
issues.push({
severity: "error",
message: "Template id/name metadata is invalid.",
});
if (!finiteRange(template.bleedMm, 0, 20))
issues.push({
severity: "error",
message: "Bleed must be between 0 and 20 mm.",
});
if (!Array.isArray(template.elements) || template.elements.length > 100) {
issues.push({
severity: "error",
message: "Templates support at most 100 elements.",
});
return issues;
}
const ids = new Set<string>();
for (const element of template.elements) {
if (
!element ||
typeof element !== "object" ||
!/^[A-Za-z0-9_-]{1,64}$/u.test(element.id)
) {
issues.push({
severity: "error",
message: "Every element needs a safe unique id.",
});
continue;
}
if (!["text", "barcode", "image", "shape"].includes(element.kind)) {
issues.push({
severity: "error",
elementId: element.id,
message: `${element.id} uses an unsupported element kind.`,
});
continue;
}
if (ids.has(element.id))
issues.push({
severity: "error",
elementId: element.id,
message: `Duplicate element id ${element.id}.`,
});
ids.add(element.id);
const bounds = [
element.xMm,
element.yMm,
element.widthMm,
element.heightMm,
];
if (
!bounds.every(Number.isFinite) ||
element.widthMm <= 0 ||
element.heightMm <= 0
)
issues.push({
severity: "error",
elementId: element.id,
message: `${element.id} has invalid geometry.`,
});
const bleed = Number.isFinite(template.bleedMm) ? template.bleedMm : 0;
if (
element.xMm < -bleed ||
element.yMm < -bleed ||
element.xMm + element.widthMm > template.widthMm + bleed ||
element.yMm + element.heightMm > template.heightMm + bleed
)
issues.push({
severity: "error",
elementId: element.id,
message: `${element.id} extends beyond the label and bleed.`,
});
if (element.kind === "barcode") {
if (
typeof element.content !== "string" ||
element.content.length > 4_096 ||
typeof element.includeText !== "boolean"
)
issues.push({
severity: "error",
elementId: element.id,
message: `${element.id} has invalid barcode content settings.`,
});
if (!BARCODE_FORMATS.some(([format]) => format === element.format))
issues.push({
severity: "error",
elementId: element.id,
message: `${element.id} uses an unsupported barcode format.`,
});
const recommended = quietZoneRecommendation(element.format);
if (!finiteRange(element.quietZoneMm, 0, 20))
issues.push({
severity: "error",
elementId: element.id,
message: `${element.id} has an invalid quiet zone.`,
});
else if (element.quietZoneMm < recommended)
issues.push({
severity: "warning",
elementId: element.id,
message: `${element.id} quiet zone is below the ${recommended} mm print recommendation.`,
});
if (
element.widthMm - element.quietZoneMm * 2 < 8 ||
element.heightMm - element.quietZoneMm * 2 < 8
)
issues.push({
severity: "warning",
elementId: element.id,
message: `${element.id} may be too small to scan reliably.`,
});
}
if (element.kind === "text") {
if (
typeof element.content !== "string" ||
element.content.length > 10_000 ||
!finiteRange(element.fontSizePt, 3, 144) ||
!["400", "600", "700"].includes(element.fontWeight) ||
!["left", "center", "right"].includes(element.align) ||
!safeColour(element.color)
)
issues.push({
severity: "error",
elementId: element.id,
message: `${element.id} has invalid text settings.`,
});
} else if (element.kind === "image") {
if (
typeof element.content !== "string" ||
element.content.length > 4_096 ||
!["contain", "cover"].includes(element.fit)
)
issues.push({
severity: "error",
elementId: element.id,
message: `${element.id} has invalid image settings.`,
});
} else if (element.kind === "shape") {
if (
!safeColour(element.fill) ||
!safeColour(element.stroke) ||
!finiteRange(element.strokeWidthMm, 0, 5) ||
!finiteRange(element.radiusMm, 0, 100)
)
issues.push({
severity: "error",
elementId: element.id,
message: `${element.id} has invalid shape settings.`,
});
}
}
return issues;
}
export function mergeContent(template: string, record: MergeRecord): string {
return template.replace(
/\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/gu,
(_, key: string) => record[key] ?? "",
);
}
export function quietZoneRecommendation(format: BarcodeFormat): number {
return format === "qrcode" ||
format === "datamatrix" ||
format === "azteccode"
? 2
: format === "pdf417"
? 2.5
: 3;
}
export function resizeTemplate(
template: LabelTemplate,
widthMm: number,
heightMm: number,
): LabelTemplate {
const sx = widthMm / template.widthMm,
sy = heightMm / template.heightMm;
return {
...template,
widthMm,
heightMm,
elements: template.elements.map((element) => ({
...element,
xMm: element.xMm * sx,
yMm: element.yMm * sy,
widthMm: element.widthMm * sx,
heightMm: element.heightMm * sy,
})),
};
}
function finiteRange(value: number, minimum: number, maximum: number) {
return Number.isFinite(value) && value >= minimum && value <= maximum;
}
function isObject(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function safeColour(value: unknown): value is string {
return typeof value === "string" && /^#[0-9a-f]{6}$/iu.test(value);
}
+67 -1
View File
@@ -1,5 +1,5 @@
export type Unit = "mm" | "in" | "pt"; export type Unit = "mm" | "in" | "pt";
export type LayoutKind = "address" | "badge" | "asset"; export type LayoutKind = "address" | "badge" | "asset" | "designer";
export type BarcodeFormat = export type BarcodeFormat =
| "qrcode" | "qrcode"
| "code128" | "code128"
@@ -55,6 +55,71 @@ export interface ImageAsset {
bytes: number; bytes: number;
} }
export type LabelElement =
| {
id: string;
kind: "text";
xMm: number;
yMm: number;
widthMm: number;
heightMm: number;
content: string;
fontSizePt: number;
fontWeight: "400" | "600" | "700";
align: "left" | "center" | "right";
color: string;
}
| {
id: string;
kind: "barcode";
xMm: number;
yMm: number;
widthMm: number;
heightMm: number;
content: string;
format: BarcodeFormat;
quietZoneMm: number;
includeText: boolean;
}
| {
id: string;
kind: "image";
xMm: number;
yMm: number;
widthMm: number;
heightMm: number;
content: string;
fit: "contain" | "cover";
}
| {
id: string;
kind: "shape";
xMm: number;
yMm: number;
widthMm: number;
heightMm: number;
fill: string;
stroke: string;
strokeWidthMm: number;
radiusMm: number;
};
export interface LabelTemplate {
schema: "de.add-ideas.label-tools.template.v1";
id: string;
name: string;
widthMm: number;
heightMm: number;
bleedMm: number;
elements: LabelElement[];
}
export interface TemplateIssue {
severity: "error" | "warning";
elementId?: string;
message: string;
}
export interface RenderOptions { export interface RenderOptions {
stock: LabelStock; stock: LabelStock;
calibration: Calibration; calibration: Calibration;
@@ -69,6 +134,7 @@ export interface RenderOptions {
cutMarks: boolean; cutMarks: boolean;
registrationMarks: boolean; registrationMarks: boolean;
startPosition: number; startPosition: number;
template?: LabelTemplate;
} }
export interface RenderWarning { export interface RenderWarning {
+55
View File
@@ -220,6 +220,61 @@ textarea {
height: 1px; height: 1px;
clip: rect(0 0 0 0); clip: rect(0 0 0 0);
} }
.designer-actions {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.designer-canvas {
width: 100%;
min-height: 14rem;
max-height: 28rem;
border: 1px solid var(--line);
border-radius: 0.7rem;
background: color-mix(in srgb, CanvasText 6%, Canvas);
touch-action: none;
}
.designer-bleed {
fill: color-mix(in srgb, #f59e0b 12%, Canvas);
stroke: #b45309;
stroke-width: 0.2;
stroke-dasharray: 1 1;
}
.designer-label {
fill: #fff;
stroke: #64748b;
stroke-width: 0.25;
}
.designer-element {
cursor: move;
}
.designer-element rect {
fill: rgb(40 122 71 / 10%);
stroke: #287a47;
stroke-width: 0.25;
}
.designer-element.selected rect {
fill: rgb(40 122 71 / 22%);
stroke-width: 0.55;
}
.designer-element text {
fill: #163b25;
font:
2.5px system-ui,
sans-serif;
pointer-events: none;
}
.designer-issues {
margin: 0;
padding-left: 1.2rem;
font-size: 0.82rem;
}
.designer-issues .warning {
color: var(--warning);
}
.designer-issues .error {
color: #b42318;
}
.asset-list { .asset-list {
list-style: none; list-style: none;
padding: 0; padding: 0;
+39 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.label-tools", "id": "de.add-ideas.label-tools",
"name": "Label Tools", "name": "Label Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Merge and print labels locally.", "description": "Merge and print labels locally.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
@@ -21,6 +21,44 @@
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": false "topLevelContext": false
}, },
"io": {
"accepts": [
{
"mediaType": "text/csv",
"extensions": [".csv"]
},
{
"mediaType": "application/json",
"extensions": [".json"]
},
{
"mediaType": "image/*",
"extensions": [".png", ".jpg", ".jpeg", ".webp"]
}
],
"produces": [
{
"mediaType": "application/pdf",
"extensions": [".pdf"]
},
{
"mediaType": "image/svg+xml",
"extensions": [".svg"]
},
{
"mediaType": "application/zip",
"extensions": [".zip"]
},
{
"mediaType": "application/json",
"extensions": [".json"]
}
]
},
"capabilities": {
"required": [],
"optional": ["workers"]
},
"privacy": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": true, "fileUploads": true,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0"; export const APP_VERSION = "0.2.0";
+4 -4
View File
@@ -60,17 +60,17 @@ test("merges inert CSV and exports vector SVG and deterministic ZIP", async ({
"merge-report.json", "merge-report.json",
]); ]);
expect(JSON.parse(strFromU8(files["merge-report.json"]!))).toMatchObject({ expect(JSON.parse(strFromU8(files["merge-report.json"]!))).toMatchObject({
application: "Label Tools 0.1.0", application: "Label Tools 0.2.0",
labels: 2, labels: 2,
}); });
}); });
test("exports a local PDF with an explicit raster boundary", async ({ test("exports a local vector PDF with an explicit font boundary", async ({
page, page,
}) => { }) => {
await page.goto("/deep/nested/label/"); await page.goto("/deep/nested/label/");
const pending = page.waitForEvent("download", { timeout: 120_000 }); const pending = page.waitForEvent("download", { timeout: 120_000 });
await page.getByRole("button", { name: "PDF (144 DPI)" }).click(); await page.getByRole("button", { name: "Vector PDF" }).click();
const download = await pending; const download = await pending;
expect(download.suggestedFilename()).toBe("label-tools-merge.pdf"); expect(download.suggestedFilename()).toBe("label-tools-merge.pdf");
const bytes = await readFile((await download.path())!); const bytes = await readFile((await download.path())!);
@@ -107,7 +107,7 @@ test("integrates help, dark theme, PWA identity and hardened headers", async ({
const manifest = await request.get("/deep/nested/label/toolbox-app.json"); const manifest = await request.get("/deep/nested/label/toolbox-app.json");
await expect(manifest.json()).resolves.toMatchObject({ await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.label-tools", id: "de.add-ideas.label-tools",
version: "0.1.0", version: "0.2.0",
privacy: { processing: "local", telemetry: false }, privacy: { processing: "local", telemetry: false },
}); });
}); });
+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/label/");
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);
});
+52
View File
@@ -0,0 +1,52 @@
import { PDFDocument } from "pdf-lib";
import { describe, expect, it } from "vitest";
import { createPdf } from "../../src/labels/pdf";
import { renderLabelPages } from "../../src/labels/render";
import { STOCKS } from "../../src/labels/stocks";
import type { RenderOptions } from "../../src/labels/types";
describe("vector PDF export", () => {
it("keeps the physical page geometry and emits vector page operators", async () => {
const stock = STOCKS[0]!;
const options: RenderOptions = {
stock,
calibration: { offsetXmm: 0, offsetYmm: 0, scaleX: 1, scaleY: 1 },
layout: "address",
mapping: {
title: "name",
subtitle: "subtitle",
body: "body",
barcode: "code",
image: "image",
},
barcodeFormat: "qrcode",
includeBarcodeText: false,
fontFamily: "Arial, Helvetica, sans-serif",
records: [
{
name: "Vector label",
subtitle: "Local",
body: "Physical units",
code: "SAFE-1",
},
],
images: [],
defaultImage: "",
cutMarks: true,
registrationMarks: true,
startPosition: 0,
};
const blob = await createPdf(renderLabelPages(options), stock);
expect(blob.type).toBe("application/pdf");
const document = await PDFDocument.load(await blob.arrayBuffer());
expect(document.getPageCount()).toBe(1);
expect(document.getPage(0).getWidth()).toBeCloseTo(
(stock.pageWidthMm * 72) / 25.4,
4,
);
expect(document.getPage(0).getHeight()).toBeCloseTo(
(stock.pageHeightMm * 72) / 25.4,
4,
);
});
});
+14
View File
@@ -6,6 +6,7 @@ import {
toMillimetres, toMillimetres,
} from "../../src/labels/stocks"; } from "../../src/labels/stocks";
import type { RenderOptions } from "../../src/labels/types"; import type { RenderOptions } from "../../src/labels/types";
import { defaultTemplate } from "../../src/labels/template";
function options(): RenderOptions { function options(): RenderOptions {
return { return {
@@ -67,4 +68,17 @@ describe("label SVG renderer", () => {
expect(fromMillimetres(25.4, "pt")).toBeCloseTo(72); expect(fromMillimetres(25.4, "pt")).toBeCloseTo(72);
expect(toMillimetres(fromMillimetres(42, "in"), "in")).toBeCloseTo(42); expect(toMillimetres(fromMillimetres(42, "in"), "in")).toBeCloseTo(42);
}); });
it("renders merged designer elements and preserves barcode quiet space", () => {
const input = options();
input.layout = "designer";
input.template = defaultTemplate(
input.stock.labelWidthMm,
input.stock.labelHeightMm,
);
const page = renderLabelPages(input).pages[0]!;
expect(page).toContain("&lt;script&gt;aler");
expect(page).toContain("designer-0-");
expect(page).not.toContain("<script>");
});
}); });
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import {
defaultTemplate,
exportTemplate,
importTemplate,
mergeContent,
validateTemplate,
} from "../../src/labels/template";
describe("label template model", () => {
it("round-trips a bounded reusable template", () => {
const template = defaultTemplate(63.5, 33.9);
expect(importTemplate(exportTemplate(template))).toEqual(template);
expect(mergeContent("{{name}} / {{missing}}", { name: "Ada" })).toBe(
"Ada / ",
);
});
it("reports quiet zones and rejects out-of-bounds elements", () => {
const template = defaultTemplate(40, 20);
const barcode = template.elements.find(
(element) => element.kind === "barcode",
)!;
if (barcode.kind !== "barcode") throw new Error("fixture mismatch");
barcode.quietZoneMm = 0;
barcode.xMm = 100;
const issues = validateTemplate(template);
expect(issues.some((issue) => issue.severity === "warning")).toBe(true);
expect(issues.some((issue) => issue.severity === "error")).toBe(true);
});
it("rejects malformed imported element collections without iterating them", () => {
const malformed = JSON.stringify({
...defaultTemplate(40, 20),
elements: { unexpected: true },
});
expect(() => importTemplate(malformed)).toThrow(/at most 100 elements/iu);
});
});