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
+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";