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

This commit is contained in:
2026-09-02 11:52:44 +02:00
parent 4e3df8d4a4
commit d3e43bea98
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
## 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.
+15 -5
View File
@@ -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
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
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
View File
@@ -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`
+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.
- 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
View File
@@ -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
+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
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.
+20 -20
View File
@@ -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
View File
@@ -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
View File
@@ -15,7 +15,25 @@ export default defineConfig({
timeout: 180_000,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{
name: "chromium",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Safari"] },
},
{
name: "mobile-chromium",
testMatch: /responsive\.spec\.ts/,
use: { ...devices["Pixel 5"] },
},
],
});
+5
View File
@@ -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.
+3 -3
View File
@@ -1,5 +1,5 @@
==============================================================================
@add-ideas/toolbox-contract@0.2.3
@add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
@@ -198,7 +198,7 @@ Declared licence: Apache-2.0
==============================================================================
@add-ideas/toolbox-helpers@0.1.0
@add-ideas/toolbox-helpers@0.2.0
Declared licence: GPL-3.0-or-later
==============================================================================
--- LICENSE ---
@@ -879,7 +879,7 @@ Public License instead of this License. But first, please read
==============================================================================
@add-ideas/toolbox-shell-react@0.2.3
@add-ideas/toolbox-shell-react@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
+15 -5
View File
@@ -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
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
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
View File
@@ -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`
+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.
- 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
View File
@@ -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
+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
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
View File
@@ -1,4 +1,4 @@
const CACHE = "label-tools-v0.1.0";
const CACHE = "label-tools-v0.2.0";
const APP = [
"./",
"./index.html",
+18 -1
View File
@@ -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,
+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>
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>
);
+22 -5
View File
@@ -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
+1 -1
View File
@@ -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
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";
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);
}
+134
View File
@@ -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 {
+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 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 {
+55
View File
@@ -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;
+39 -1
View File
@@ -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
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",
]);
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 },
});
});
+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,
} 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("&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);
});
});