Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3e43bea98 |
@@ -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
|
||||
@@ -1,5 +1,10 @@
|
||||
# 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
|
||||
|
||||
- Add bounded CSV/JSON data merge, flattening and field mapping.
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
# 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
|
||||
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;
|
||||
- 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;
|
||||
- optional cut guides and registration marks;
|
||||
- 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,
|
||||
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.
|
||||
|
||||
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
|
||||
generated SVG page at 144 DPI, preserving the preview appearance on the same
|
||||
device but not creating an archival or press-quality font-embedded PDF.
|
||||
therefore vary by operating system. PDF export maps application-owned SVG
|
||||
geometry into pdf-lib vector paths, rectangles and embedded Helvetica text.
|
||||
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
|
||||
|
||||
|
||||
+2
-1
@@ -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
|
||||
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
|
||||
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
|
||||
preview and warnings before export, and do not treat serial/random values as an
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Source identity
|
||||
|
||||
- Project: Label Tools
|
||||
- Version: 0.1.0
|
||||
- Version: 0.2.0
|
||||
- Canonical repository: https://git.add-ideas.de/lotobo/label-tools
|
||||
- Licence: GPL-3.0-or-later
|
||||
- Toolbox app id: `de.add-ideas.label-tools`
|
||||
|
||||
@@ -5,7 +5,8 @@ never loaded from a CDN. Their licence texts are copied into the production
|
||||
artifact at build time.
|
||||
|
||||
- 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
|
||||
- fflate — MIT
|
||||
- pdf-lib and its runtime dependencies — MIT
|
||||
|
||||
+17
-3
@@ -1,15 +1,29 @@
|
||||
# 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:
|
||||
|
||||
1. `labels/data.ts` uses toolbox helpers for bounded CSV/JSON parsing and the
|
||||
browser cryptographic RNG for optional virtual fields.
|
||||
2. `labels/stocks.ts` stores canonical millimetre geometry and validates every
|
||||
preset/custom grid before rendering.
|
||||
3. `labels/render.ts` maps scalar fields into three fixed layouts, escapes XML,
|
||||
embeds validated local raster data, and places reviewed bwip-js output.
|
||||
3. `labels/render.ts` maps scalar fields into three fixed layouts or the
|
||||
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
|
||||
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
|
||||
limits interactive preview to the first 12 pages. Complete SVG export remains
|
||||
|
||||
@@ -7,10 +7,12 @@ inside the symbol; they are never requested.
|
||||
|
||||
CSV/JSON structure, field lengths, record counts, image bytes/pixels, grid
|
||||
dimensions, calibration and output pages are bounded. Object URLs are revoked.
|
||||
User strings are normalized for label display and XML-escaped. Only built-in
|
||||
templates are supported, and only locally decoded PNG/JPEG/WebP is embedded.
|
||||
User strings are normalized for label display and XML-escaped. Imported
|
||||
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
|
||||
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
|
||||
does not embed or preserve font semantics.
|
||||
calibration controls and test on plain paper. PDF uses vector geometry and
|
||||
embedded standard fonts, but it does not preserve the selected system-font
|
||||
metrics or provide archival guarantees.
|
||||
|
||||
Generated
+20
-20
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"name": "label-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "label-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",
|
||||
"bwip-js": "4.11.4",
|
||||
"fflate": "0.8.3",
|
||||
"pdf-lib": "1.17.1",
|
||||
@@ -19,7 +19,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",
|
||||
@@ -45,24 +45,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-contract": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz",
|
||||
"integrity": "sha512-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==",
|
||||
"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",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.1.0/toolbox-helpers-0.1.0.tgz",
|
||||
"integrity": "sha512-UKl1Oxekedf8D2df86VrnVA53AcMhrnh6iUPXY+k8frirBXotb0yd8SGT+IF/3hcqYwcYe/v9WVFuSgKtIYVnw==",
|
||||
"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",
|
||||
"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",
|
||||
"integrity": "sha512-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==",
|
||||
"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",
|
||||
@@ -70,13 +70,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-testkit": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz",
|
||||
"integrity": "sha512-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==",
|
||||
"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"
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "label-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Merge local data into printable labels and badges 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",
|
||||
"bwip-js": "4.11.4",
|
||||
"fflate": "0.8.3",
|
||||
"pdf-lib": "1.17.1",
|
||||
@@ -49,7 +49,7 @@
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
||||
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
|
||||
+20
-2
@@ -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"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 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
|
||||
|
||||
- Add bounded CSV/JSON data merge, flattening and field mapping.
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
+15
-5
@@ -1,9 +1,17 @@
|
||||
# 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
|
||||
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;
|
||||
- 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;
|
||||
- optional cut guides and registration marks;
|
||||
- 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,
|
||||
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.
|
||||
|
||||
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
|
||||
generated SVG page at 144 DPI, preserving the preview appearance on the same
|
||||
device but not creating an archival or press-quality font-embedded PDF.
|
||||
therefore vary by operating system. PDF export maps application-owned SVG
|
||||
geometry into pdf-lib vector paths, rectangles and embedded Helvetica text.
|
||||
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
|
||||
|
||||
|
||||
+2
-1
@@ -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
|
||||
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
|
||||
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
|
||||
preview and warnings before export, and do not treat serial/random values as an
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# Source identity
|
||||
|
||||
- Project: Label Tools
|
||||
- Version: 0.1.0
|
||||
- Version: 0.2.0
|
||||
- Canonical repository: https://git.add-ideas.de/lotobo/label-tools
|
||||
- Licence: GPL-3.0-or-later
|
||||
- Toolbox app id: `de.add-ideas.label-tools`
|
||||
|
||||
@@ -5,7 +5,8 @@ never loaded from a CDN. Their licence texts are copied into the production
|
||||
artifact at build time.
|
||||
|
||||
- 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
|
||||
- fflate — MIT
|
||||
- pdf-lib and its runtime dependencies — MIT
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
# 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:
|
||||
|
||||
1. `labels/data.ts` uses toolbox helpers for bounded CSV/JSON parsing and the
|
||||
browser cryptographic RNG for optional virtual fields.
|
||||
2. `labels/stocks.ts` stores canonical millimetre geometry and validates every
|
||||
preset/custom grid before rendering.
|
||||
3. `labels/render.ts` maps scalar fields into three fixed layouts, escapes XML,
|
||||
embeds validated local raster data, and places reviewed bwip-js output.
|
||||
3. `labels/render.ts` maps scalar fields into three fixed layouts or the
|
||||
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
|
||||
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
|
||||
limits interactive preview to the first 12 pages. Complete SVG export remains
|
||||
|
||||
@@ -7,10 +7,12 @@ inside the symbol; they are never requested.
|
||||
|
||||
CSV/JSON structure, field lengths, record counts, image bytes/pixels, grid
|
||||
dimensions, calibration and output pages are bounded. Object URLs are revoked.
|
||||
User strings are normalized for label display and XML-escaped. Only built-in
|
||||
templates are supported, and only locally decoded PNG/JPEG/WebP is embedded.
|
||||
User strings are normalized for label display and XML-escaped. Imported
|
||||
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
|
||||
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
|
||||
does not embed or preserve font semantics.
|
||||
calibration controls and test on plain paper. PDF uses vector geometry and
|
||||
embedded standard fonts, but it does not preserve the selected system-font
|
||||
metrics or provide archival guarantees.
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = "label-tools-v0.1.0";
|
||||
const CACHE = "label-tools-v0.2.0";
|
||||
const APP = [
|
||||
"./",
|
||||
"./index.html",
|
||||
|
||||
+18
-1
@@ -3,7 +3,7 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.label-tools",
|
||||
"name": "Label Tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Merge and print labels locally.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
@@ -21,6 +21,23 @@
|
||||
"crossOriginIsolated": 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": {
|
||||
"processing": "local",
|
||||
"fileUploads": true,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -43,7 +43,8 @@ export function HelpDialog({
|
||||
<p>
|
||||
Paper feed, printer scaling and installed system fonts vary. Print a
|
||||
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>
|
||||
</dialog>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { augmentRecords, collectFields, parseMergeData } from "../labels/data";
|
||||
import { createSvgZip, svgFilename } from "../labels/export";
|
||||
import { loadImageAssets } from "../labels/images";
|
||||
import { renderLabelPages } from "../labels/render";
|
||||
import { defaultTemplate } from "../labels/template";
|
||||
import {
|
||||
fromMillimetres,
|
||||
STOCKS,
|
||||
@@ -23,6 +24,7 @@ import type {
|
||||
RenderOptions,
|
||||
Unit,
|
||||
} from "../labels/types";
|
||||
import { DesignerPanel } from "./DesignerPanel";
|
||||
|
||||
const SAMPLE = `name,subtitle,address,code,image
|
||||
Ada Lovelace,Research,12 Analytical Engine Way · London,ASSET-0001,
|
||||
@@ -67,6 +69,9 @@ export function Workbench() {
|
||||
});
|
||||
const [unit, setUnit] = useState<Unit>("mm");
|
||||
const [layout, setLayout] = useState<LayoutKind>("address");
|
||||
const [template, setTemplate] = useState(() =>
|
||||
defaultTemplate(STOCKS[0]!.labelWidthMm, STOCKS[0]!.labelHeightMm),
|
||||
);
|
||||
const [barcodeFormat, setBarcodeFormat] = useState<BarcodeFormat>("qrcode");
|
||||
const [includeBarcodeText, setIncludeBarcodeText] = useState(false);
|
||||
const [fontFamily, setFontFamily] = useState("Arial, Helvetica, sans-serif");
|
||||
@@ -105,6 +110,7 @@ export function Workbench() {
|
||||
cutMarks,
|
||||
registrationMarks,
|
||||
startPosition,
|
||||
template,
|
||||
}),
|
||||
[
|
||||
stock,
|
||||
@@ -120,6 +126,7 @@ export function Workbench() {
|
||||
cutMarks,
|
||||
registrationMarks,
|
||||
startPosition,
|
||||
template,
|
||||
],
|
||||
);
|
||||
const preview = useMemo(() => {
|
||||
@@ -197,7 +204,7 @@ export function Workbench() {
|
||||
}
|
||||
|
||||
async function downloadPdf() {
|
||||
setStatus("Rasterizing pages locally for PDF export…");
|
||||
setStatus("Building vector PDF pages locally…");
|
||||
try {
|
||||
const result = fullRender();
|
||||
const { createPdf } = await import("../labels/pdf");
|
||||
@@ -206,7 +213,7 @@ export function Workbench() {
|
||||
"label-tools-merge.pdf",
|
||||
);
|
||||
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) {
|
||||
setStatus(error instanceof Error ? error.message : String(error));
|
||||
@@ -274,6 +281,15 @@ export function Workbench() {
|
||||
fontFamily={fontFamily}
|
||||
setFontFamily={setFontFamily}
|
||||
/>
|
||||
{layout === "designer" && (
|
||||
<DesignerPanel
|
||||
template={template}
|
||||
onChange={setTemplate}
|
||||
targetWidthMm={stock.labelWidthMm}
|
||||
targetHeightMm={stock.labelHeightMm}
|
||||
unit={unit}
|
||||
/>
|
||||
)}
|
||||
<StockPanel
|
||||
stock={stock}
|
||||
stockId={stockId}
|
||||
@@ -418,7 +434,7 @@ export function Workbench() {
|
||||
onClick={() => void downloadPdf()}
|
||||
disabled={!preview.result}
|
||||
>
|
||||
PDF (144 DPI)
|
||||
Vector PDF
|
||||
</button>
|
||||
<button type="button" onClick={printAll} disabled={!preview.result}>
|
||||
Print all
|
||||
@@ -427,8 +443,8 @@ export function Workbench() {
|
||||
<p className="print-boundary">
|
||||
<strong>Print boundary:</strong> use actual size / 100%, disable
|
||||
browser headers and footers, and verify a plain-paper calibration.
|
||||
SVG stays vector; PDF is rasterized and system fonts are not
|
||||
embedded.
|
||||
SVG and PDF geometry stay vector; local images remain raster and PDF
|
||||
text uses embedded standard fonts.
|
||||
</p>
|
||||
{preview.result && <Warnings warnings={preview.result.warnings} />}
|
||||
</section>
|
||||
@@ -630,6 +646,7 @@ function MappingPanel({
|
||||
<option value="address">Address label</option>
|
||||
<option value="badge">Name badge</option>
|
||||
<option value="asset">Asset tag</option>
|
||||
<option value="designer">Custom designer</option>
|
||||
</select>
|
||||
</label>
|
||||
<FieldSelect
|
||||
|
||||
@@ -14,7 +14,7 @@ export function createSvgZip(result: RenderResult): Blob {
|
||||
files["merge-report.json"] = strToU8(
|
||||
stableStringify(
|
||||
{
|
||||
application: "Label Tools 0.1.0",
|
||||
application: "Label Tools 0.2.0",
|
||||
pages: result.totalPages,
|
||||
labels: result.totalLabels,
|
||||
warnings: result.warnings,
|
||||
|
||||
+214
-43
@@ -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";
|
||||
|
||||
export async function createPdf(
|
||||
@@ -12,19 +19,21 @@ export async function createPdf(
|
||||
const pdf = await PDFDocument.create();
|
||||
const widthPt = (stock.pageWidthMm * 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) {
|
||||
const png = await rasterizeSvg(svg, stock.pageWidthMm, stock.pageHeightMm);
|
||||
const embedded = await pdf.embedPng(png);
|
||||
const page = pdf.addPage([widthPt, heightPt]);
|
||||
page.drawImage(embedded, {
|
||||
page.drawRectangle({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: widthPt,
|
||||
height: heightPt,
|
||||
color: rgb(1, 1, 1),
|
||||
});
|
||||
await drawVectorSvg(pdf, page, svg, heightPt, regular, bold);
|
||||
}
|
||||
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");
|
||||
const bytes = await pdf.save({
|
||||
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,
|
||||
widthMm: number,
|
||||
heightMm: number,
|
||||
): Promise<Uint8Array> {
|
||||
const dpi = 144;
|
||||
const width = Math.ceil((widthMm / 25.4) * dpi);
|
||||
const height = Math.ceil((heightMm / 25.4) * dpi);
|
||||
if (width * height > 8_000_000)
|
||||
throw new Error(
|
||||
"Rasterized PDF page exceeds the 8 megapixel safety limit.",
|
||||
);
|
||||
const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
|
||||
try {
|
||||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const node = new Image();
|
||||
node.onload = () => resolve(node);
|
||||
node.onerror = () =>
|
||||
reject(
|
||||
new Error("Browser could not rasterize the generated SVG page."),
|
||||
);
|
||||
node.src = url;
|
||||
pageHeight: number,
|
||||
regular: PDFFont,
|
||||
bold: PDFFont,
|
||||
) {
|
||||
const document = new DOMParser().parseFromString(svg, "image/svg+xml");
|
||||
if (document.querySelector("parsererror"))
|
||||
throw new Error("Generated SVG could not be parsed for vector PDF export.");
|
||||
const root = document.documentElement;
|
||||
for (const child of [...root.children])
|
||||
await drawNode(pdf, page, child, pageHeight, regular, bold, {
|
||||
tx: 0,
|
||||
ty: 0,
|
||||
scale: 1,
|
||||
strokeWidth: 0,
|
||||
});
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Canvas rendering is unavailable.");
|
||||
context.fillStyle = "white";
|
||||
context.fillRect(0, 0, width, height);
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
const blob = await new Promise<Blob>((resolve, reject) =>
|
||||
canvas.toBlob(
|
||||
(value) =>
|
||||
value ? resolve(value) : reject(new Error("PNG conversion failed.")),
|
||||
"image/png",
|
||||
),
|
||||
}
|
||||
|
||||
async function drawNode(
|
||||
pdf: PDFDocument,
|
||||
page: PDFPage,
|
||||
node: Element,
|
||||
pageHeight: number,
|
||||
regular: PDFFont,
|
||||
bold: PDFFont,
|
||||
parent: DrawContext,
|
||||
): Promise<void> {
|
||||
if (["defs", "clipPath", "marker"].includes(node.localName)) return;
|
||||
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());
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
const lines = spans.length ? spans : [node];
|
||||
for (const line of lines) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { barcodeFragment } from "./barcode";
|
||||
import { stockCapacity, validateStock } from "./stocks";
|
||||
import { mergeContent, validateTemplate } from "./template";
|
||||
import type {
|
||||
ImageAsset,
|
||||
MergeRecord,
|
||||
@@ -61,6 +62,7 @@ export function renderLabelPages(
|
||||
width,
|
||||
height,
|
||||
image,
|
||||
imageByName,
|
||||
options,
|
||||
warnings,
|
||||
),
|
||||
@@ -85,9 +87,23 @@ function renderLabel(
|
||||
width: number,
|
||||
height: number,
|
||||
image: ImageAsset | undefined,
|
||||
imageByName: ReadonlyMap<string, ImageAsset>,
|
||||
options: RenderOptions,
|
||||
warnings: RenderWarning[],
|
||||
): 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 padding = Math.max(1.4, Math.min(width, height) * 0.06);
|
||||
const title = fitted(
|
||||
@@ -180,6 +196,122 @@ function renderLabel(
|
||||
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 {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -540,6 +672,8 @@ function validateOptions(options: RenderOptions) {
|
||||
throw new Error("A merge is limited to 2,000 labels.");
|
||||
if (!ALLOWED_FONTS.has(options.fontFamily))
|
||||
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 {
|
||||
|
||||
@@ -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 5–1,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
@@ -1,5 +1,5 @@
|
||||
export type Unit = "mm" | "in" | "pt";
|
||||
export type LayoutKind = "address" | "badge" | "asset";
|
||||
export type LayoutKind = "address" | "badge" | "asset" | "designer";
|
||||
export type BarcodeFormat =
|
||||
| "qrcode"
|
||||
| "code128"
|
||||
@@ -55,6 +55,71 @@ export interface ImageAsset {
|
||||
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 {
|
||||
stock: LabelStock;
|
||||
calibration: Calibration;
|
||||
@@ -69,6 +134,7 @@ export interface RenderOptions {
|
||||
cutMarks: boolean;
|
||||
registrationMarks: boolean;
|
||||
startPosition: number;
|
||||
template?: LabelTemplate;
|
||||
}
|
||||
|
||||
export interface RenderWarning {
|
||||
|
||||
@@ -220,6 +220,61 @@ textarea {
|
||||
height: 1px;
|
||||
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 {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.label-tools",
|
||||
"name": "Label Tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Merge and print labels locally.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
@@ -21,6 +21,44 @@
|
||||
"crossOriginIsolated": 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": {
|
||||
"processing": "local",
|
||||
"fileUploads": true,
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
export const APP_VERSION = "0.2.0";
|
||||
|
||||
@@ -60,17 +60,17 @@ test("merges inert CSV and exports vector SVG and deterministic ZIP", async ({
|
||||
"merge-report.json",
|
||||
]);
|
||||
expect(JSON.parse(strFromU8(files["merge-report.json"]!))).toMatchObject({
|
||||
application: "Label Tools 0.1.0",
|
||||
application: "Label Tools 0.2.0",
|
||||
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,
|
||||
}) => {
|
||||
await page.goto("/deep/nested/label/");
|
||||
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;
|
||||
expect(download.suggestedFilename()).toBe("label-tools-merge.pdf");
|
||||
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");
|
||||
await expect(manifest.json()).resolves.toMatchObject({
|
||||
id: "de.add-ideas.label-tools",
|
||||
version: "0.1.0",
|
||||
version: "0.2.0",
|
||||
privacy: { processing: "local", telemetry: false },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
toMillimetres,
|
||||
} from "../../src/labels/stocks";
|
||||
import type { RenderOptions } from "../../src/labels/types";
|
||||
import { defaultTemplate } from "../../src/labels/template";
|
||||
|
||||
function options(): RenderOptions {
|
||||
return {
|
||||
@@ -67,4 +68,17 @@ describe("label SVG renderer", () => {
|
||||
expect(fromMillimetres(25.4, "pt")).toBeCloseTo(72);
|
||||
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("<script>aler");
|
||||
expect(page).toContain("designer-0-");
|
||||
expect(page).not.toContain("<script>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user