@@ -0,0 +1,556 @@
|
||||
import { useRef, useState, type PointerEvent } from "react";
|
||||
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
|
||||
import { BARCODE_FORMATS } from "../labels/barcode";
|
||||
import { fromMillimetres, toMillimetres } from "../labels/stocks";
|
||||
import {
|
||||
exportTemplate,
|
||||
importTemplate,
|
||||
resizeTemplate,
|
||||
validateTemplate,
|
||||
} from "../labels/template";
|
||||
import type {
|
||||
BarcodeFormat,
|
||||
LabelElement,
|
||||
LabelTemplate,
|
||||
Unit,
|
||||
} from "../labels/types";
|
||||
|
||||
export function DesignerPanel({
|
||||
template,
|
||||
onChange,
|
||||
targetWidthMm,
|
||||
targetHeightMm,
|
||||
unit,
|
||||
}: {
|
||||
template: LabelTemplate;
|
||||
onChange: (template: LabelTemplate) => void;
|
||||
targetWidthMm: number;
|
||||
targetHeightMm: number;
|
||||
unit: Unit;
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState(template.elements[0]?.id ?? "");
|
||||
const [message, setMessage] = useState("");
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const drag = useRef<
|
||||
| {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
}
|
||||
| undefined
|
||||
>(undefined);
|
||||
const selected = template.elements.find(
|
||||
(element) => element.id === selectedId,
|
||||
);
|
||||
const issues = validateTemplate(template);
|
||||
const updateElement = (id: string, patch: Partial<LabelElement>) =>
|
||||
onChange({
|
||||
...template,
|
||||
elements: template.elements.map((element) =>
|
||||
element.id === id
|
||||
? ({ ...element, ...patch } as LabelElement)
|
||||
: element,
|
||||
),
|
||||
});
|
||||
const add = (kind: LabelElement["kind"]) => {
|
||||
const existing = new Set(template.elements.map((element) => element.id));
|
||||
let suffix = 1;
|
||||
while (existing.has(`${kind}-${suffix}`)) suffix += 1;
|
||||
const id = `${kind}-${suffix}`;
|
||||
const geometry = { id, xMm: 2, yMm: 2, widthMm: 24, heightMm: 10 };
|
||||
const element: LabelElement =
|
||||
kind === "text"
|
||||
? {
|
||||
...geometry,
|
||||
kind,
|
||||
content: "{{name}}",
|
||||
fontSizePt: 10,
|
||||
fontWeight: "400",
|
||||
align: "left",
|
||||
color: "#111827",
|
||||
}
|
||||
: kind === "barcode"
|
||||
? {
|
||||
...geometry,
|
||||
kind,
|
||||
content: "{{code}}",
|
||||
format: "qrcode",
|
||||
quietZoneMm: 2,
|
||||
includeText: false,
|
||||
}
|
||||
: kind === "image"
|
||||
? {
|
||||
...geometry,
|
||||
kind,
|
||||
content: "{{image}}",
|
||||
fit: "contain",
|
||||
}
|
||||
: {
|
||||
...geometry,
|
||||
kind,
|
||||
fill: "#ffffff",
|
||||
stroke: "#111827",
|
||||
strokeWidthMm: 0.2,
|
||||
radiusMm: 1,
|
||||
};
|
||||
onChange({ ...template, elements: [...template.elements, element] });
|
||||
setSelectedId(id);
|
||||
};
|
||||
const position = (event: PointerEvent<SVGElement>) => {
|
||||
const box = svgRef.current?.getBoundingClientRect();
|
||||
if (!box) return { x: 0, y: 0 };
|
||||
return {
|
||||
x:
|
||||
((event.clientX - box.left) / box.width) *
|
||||
(template.widthMm + template.bleedMm * 2) -
|
||||
template.bleedMm,
|
||||
y:
|
||||
((event.clientY - box.top) / box.height) *
|
||||
(template.heightMm + template.bleedMm * 2) -
|
||||
template.bleedMm,
|
||||
};
|
||||
};
|
||||
async function open(file: File | undefined) {
|
||||
if (!file) return;
|
||||
try {
|
||||
const next = importTemplate(await file.text());
|
||||
onChange(next);
|
||||
setSelectedId(next.elements[0]?.id ?? "");
|
||||
setMessage(`Opened ${file.name}.`);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section
|
||||
className="panel control-panel designer-panel"
|
||||
aria-labelledby="designer-title"
|
||||
>
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Reusable vector template</p>
|
||||
<h2 id="designer-title">Designer canvas</h2>
|
||||
</div>
|
||||
<span className="count">{template.elements.length} elements</span>
|
||||
</div>
|
||||
<div className="designer-actions">
|
||||
{(["text", "barcode", "image", "shape"] as const).map((kind) => (
|
||||
<button type="button" key={kind} onClick={() => add(kind)}>
|
||||
+ {kind}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange(resizeTemplate(template, targetWidthMm, targetHeightMm))
|
||||
}
|
||||
>
|
||||
Fit selected stock
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
triggerBlobDownload(
|
||||
new Blob([exportTemplate(template)], {
|
||||
type: "application/json",
|
||||
}),
|
||||
"label-template.json",
|
||||
)
|
||||
}
|
||||
>
|
||||
Save template
|
||||
</button>
|
||||
<label className="file-button">
|
||||
Open template
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
onChange={(event) => void open(event.target.files?.[0])}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label>
|
||||
Template name
|
||||
<input
|
||||
value={template.name}
|
||||
maxLength={120}
|
||||
onChange={(event) =>
|
||||
onChange({ ...template, name: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Bleed ({unit})
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max={fromMillimetres(20, unit)}
|
||||
step={unit === "mm" ? 0.1 : 0.01}
|
||||
value={round(fromMillimetres(template.bleedMm, unit))}
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...template,
|
||||
bleedMm: toMillimetres(event.target.valueAsNumber, unit),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="designer-canvas"
|
||||
viewBox={`${-template.bleedMm} ${-template.bleedMm} ${template.widthMm + template.bleedMm * 2} ${template.heightMm + template.bleedMm * 2}`}
|
||||
role="img"
|
||||
aria-label="Draggable label template canvas"
|
||||
onPointerMove={(event) => {
|
||||
const active = drag.current;
|
||||
if (!active) return;
|
||||
const point = position(event);
|
||||
const element = template.elements.find(
|
||||
(item) => item.id === active.id,
|
||||
);
|
||||
if (!element) return;
|
||||
updateElement(active.id, {
|
||||
xMm: clamp(
|
||||
active.x + point.x - active.startX,
|
||||
-template.bleedMm,
|
||||
template.widthMm + template.bleedMm - element.widthMm,
|
||||
),
|
||||
yMm: clamp(
|
||||
active.y + point.y - active.startY,
|
||||
-template.bleedMm,
|
||||
template.heightMm + template.bleedMm - element.heightMm,
|
||||
),
|
||||
});
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
drag.current = undefined;
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
drag.current = undefined;
|
||||
}}
|
||||
>
|
||||
<rect
|
||||
x={-template.bleedMm}
|
||||
y={-template.bleedMm}
|
||||
width={template.widthMm + template.bleedMm * 2}
|
||||
height={template.heightMm + template.bleedMm * 2}
|
||||
className="designer-bleed"
|
||||
/>
|
||||
<rect
|
||||
width={template.widthMm}
|
||||
height={template.heightMm}
|
||||
className="designer-label"
|
||||
/>
|
||||
{template.elements.map((element) => (
|
||||
<g
|
||||
key={element.id}
|
||||
className={
|
||||
selectedId === element.id
|
||||
? "designer-element selected"
|
||||
: "designer-element"
|
||||
}
|
||||
tabIndex={0}
|
||||
aria-label={`${element.kind} element ${element.id}`}
|
||||
onPointerDown={(event) => {
|
||||
setSelectedId(element.id);
|
||||
const point = position(event);
|
||||
drag.current = {
|
||||
id: element.id,
|
||||
x: element.xMm,
|
||||
y: element.yMm,
|
||||
startX: point.x,
|
||||
startY: point.y,
|
||||
};
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
const amount = event.shiftKey ? 1 : 0.1;
|
||||
const delta =
|
||||
event.key === "ArrowLeft"
|
||||
? [-amount, 0]
|
||||
: event.key === "ArrowRight"
|
||||
? [amount, 0]
|
||||
: event.key === "ArrowUp"
|
||||
? [0, -amount]
|
||||
: event.key === "ArrowDown"
|
||||
? [0, amount]
|
||||
: undefined;
|
||||
if (!delta) return;
|
||||
event.preventDefault();
|
||||
updateElement(element.id, {
|
||||
xMm: element.xMm + delta[0]!,
|
||||
yMm: element.yMm + delta[1]!,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<rect
|
||||
x={element.xMm}
|
||||
y={element.yMm}
|
||||
width={element.widthMm}
|
||||
height={element.heightMm}
|
||||
rx="0.6"
|
||||
/>
|
||||
<text x={element.xMm + 1} y={element.yMm + 3}>
|
||||
{element.kind}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
{selected && (
|
||||
<div className="subpanel">
|
||||
<div className="section-heading">
|
||||
<h3>
|
||||
{selected.kind} · {selected.id}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange({
|
||||
...template,
|
||||
elements: template.elements.filter(
|
||||
(item) => item.id !== selected.id,
|
||||
),
|
||||
});
|
||||
setSelectedId("");
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
{(["xMm", "yMm", "widthMm", "heightMm"] as const).map((key) => (
|
||||
<label key={key}>
|
||||
{key.replace("Mm", "")} ({unit})
|
||||
<input
|
||||
type="number"
|
||||
step={unit === "mm" ? 0.1 : 0.01}
|
||||
value={round(fromMillimetres(selected[key], unit))}
|
||||
onChange={(event) =>
|
||||
updateElement(selected.id, {
|
||||
[key]: toMillimetres(event.target.valueAsNumber, unit),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{selected.kind !== "shape" && (
|
||||
<label>
|
||||
Content / merge expression
|
||||
<input
|
||||
value={selected.content}
|
||||
onChange={(event) =>
|
||||
updateElement(selected.id, { content: event.target.value })
|
||||
}
|
||||
placeholder="{{field}}"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{selected.kind === "barcode" && (
|
||||
<>
|
||||
<label>
|
||||
Barcode format
|
||||
<select
|
||||
value={selected.format}
|
||||
onChange={(event) =>
|
||||
updateElement(selected.id, {
|
||||
format: event.target.value as BarcodeFormat,
|
||||
})
|
||||
}
|
||||
>
|
||||
{BARCODE_FORMATS.map(([format, label]) => (
|
||||
<option key={format} value={format}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Quiet zone ({unit})
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={round(fromMillimetres(selected.quietZoneMm, unit))}
|
||||
onChange={(event) =>
|
||||
updateElement(selected.id, {
|
||||
quietZoneMm: toMillimetres(
|
||||
event.target.valueAsNumber,
|
||||
unit,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includeText}
|
||||
onChange={(event) =>
|
||||
updateElement(selected.id, {
|
||||
includeText: event.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
Include human-readable text
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
{selected.kind === "text" && (
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
Font size (pt)
|
||||
<input
|
||||
type="number"
|
||||
min="3"
|
||||
max="144"
|
||||
value={selected.fontSizePt}
|
||||
onChange={(event) =>
|
||||
updateElement(selected.id, {
|
||||
fontSizePt: event.target.valueAsNumber,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Weight
|
||||
<select
|
||||
value={selected.fontWeight}
|
||||
onChange={(event) =>
|
||||
updateElement(selected.id, {
|
||||
fontWeight: event.target.value as "400" | "600" | "700",
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="400">Regular</option>
|
||||
<option value="600">Semibold</option>
|
||||
<option value="700">Bold</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Alignment
|
||||
<select
|
||||
value={selected.align}
|
||||
onChange={(event) =>
|
||||
updateElement(selected.id, {
|
||||
align: event.target.value as "left" | "center" | "right",
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="left">Left</option>
|
||||
<option value="center">Centre</option>
|
||||
<option value="right">Right</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Colour
|
||||
<input
|
||||
type="color"
|
||||
value={selected.color}
|
||||
onChange={(event) =>
|
||||
updateElement(selected.id, { color: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
{selected.kind === "image" && (
|
||||
<label>
|
||||
Image fit
|
||||
<select
|
||||
value={selected.fit}
|
||||
onChange={(event) =>
|
||||
updateElement(selected.id, {
|
||||
fit: event.target.value as "contain" | "cover",
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="contain">Contain</option>
|
||||
<option value="cover">Cover</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
{selected.kind === "shape" && (
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
Fill
|
||||
<input
|
||||
type="color"
|
||||
value={selected.fill}
|
||||
onChange={(event) =>
|
||||
updateElement(selected.id, { fill: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Stroke
|
||||
<input
|
||||
type="color"
|
||||
value={selected.stroke}
|
||||
onChange={(event) =>
|
||||
updateElement(selected.id, { stroke: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Stroke ({unit})
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max={fromMillimetres(5, unit)}
|
||||
step={unit === "mm" ? 0.1 : 0.01}
|
||||
value={round(fromMillimetres(selected.strokeWidthMm, unit))}
|
||||
onChange={(event) =>
|
||||
updateElement(selected.id, {
|
||||
strokeWidthMm: toMillimetres(
|
||||
event.target.valueAsNumber,
|
||||
unit,
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Corner radius ({unit})
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max={fromMillimetres(100, unit)}
|
||||
step={unit === "mm" ? 0.1 : 0.01}
|
||||
value={round(fromMillimetres(selected.radiusMm, unit))}
|
||||
onChange={(event) =>
|
||||
updateElement(selected.id, {
|
||||
radiusMm: toMillimetres(event.target.valueAsNumber, unit),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{issues.length ? (
|
||||
<ul className="designer-issues">
|
||||
{issues.map((issue, index) => (
|
||||
<li key={`${issue.elementId}-${index}`} className={issue.severity}>
|
||||
{issue.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="hint">
|
||||
Template geometry and barcode quiet zones are valid.
|
||||
</p>
|
||||
)}
|
||||
{message && <p role="status">{message}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number) {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
function round(value: number) {
|
||||
return Number(value.toFixed(3));
|
||||
}
|
||||
@@ -43,7 +43,8 @@ export function HelpDialog({
|
||||
<p>
|
||||
Paper feed, printer scaling and installed system fonts vary. Print a
|
||||
calibration sheet on plain paper at 100% / actual size before using
|
||||
label stock. PDF export rasterizes at 144 DPI and does not embed fonts.
|
||||
label stock. PDF export keeps reviewed geometry vector and embeds its
|
||||
standard fonts; SVG continues to use the selected system font stack.
|
||||
</p>
|
||||
</dialog>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { augmentRecords, collectFields, parseMergeData } from "../labels/data";
|
||||
import { createSvgZip, svgFilename } from "../labels/export";
|
||||
import { loadImageAssets } from "../labels/images";
|
||||
import { renderLabelPages } from "../labels/render";
|
||||
import { defaultTemplate } from "../labels/template";
|
||||
import {
|
||||
fromMillimetres,
|
||||
STOCKS,
|
||||
@@ -23,6 +24,7 @@ import type {
|
||||
RenderOptions,
|
||||
Unit,
|
||||
} from "../labels/types";
|
||||
import { DesignerPanel } from "./DesignerPanel";
|
||||
|
||||
const SAMPLE = `name,subtitle,address,code,image
|
||||
Ada Lovelace,Research,12 Analytical Engine Way · London,ASSET-0001,
|
||||
@@ -67,6 +69,9 @@ export function Workbench() {
|
||||
});
|
||||
const [unit, setUnit] = useState<Unit>("mm");
|
||||
const [layout, setLayout] = useState<LayoutKind>("address");
|
||||
const [template, setTemplate] = useState(() =>
|
||||
defaultTemplate(STOCKS[0]!.labelWidthMm, STOCKS[0]!.labelHeightMm),
|
||||
);
|
||||
const [barcodeFormat, setBarcodeFormat] = useState<BarcodeFormat>("qrcode");
|
||||
const [includeBarcodeText, setIncludeBarcodeText] = useState(false);
|
||||
const [fontFamily, setFontFamily] = useState("Arial, Helvetica, sans-serif");
|
||||
@@ -105,6 +110,7 @@ export function Workbench() {
|
||||
cutMarks,
|
||||
registrationMarks,
|
||||
startPosition,
|
||||
template,
|
||||
}),
|
||||
[
|
||||
stock,
|
||||
@@ -120,6 +126,7 @@ export function Workbench() {
|
||||
cutMarks,
|
||||
registrationMarks,
|
||||
startPosition,
|
||||
template,
|
||||
],
|
||||
);
|
||||
const preview = useMemo(() => {
|
||||
@@ -197,7 +204,7 @@ export function Workbench() {
|
||||
}
|
||||
|
||||
async function downloadPdf() {
|
||||
setStatus("Rasterizing pages locally for PDF export…");
|
||||
setStatus("Building vector PDF pages locally…");
|
||||
try {
|
||||
const result = fullRender();
|
||||
const { createPdf } = await import("../labels/pdf");
|
||||
@@ -206,7 +213,7 @@ export function Workbench() {
|
||||
"label-tools-merge.pdf",
|
||||
);
|
||||
setStatus(
|
||||
`Exported ${result.totalPages} PDF page${result.totalPages === 1 ? "" : "s"} locally at 144 DPI.`,
|
||||
`Exported ${result.totalPages} vector PDF page${result.totalPages === 1 ? "" : "s"} locally.`,
|
||||
);
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : String(error));
|
||||
@@ -274,6 +281,15 @@ export function Workbench() {
|
||||
fontFamily={fontFamily}
|
||||
setFontFamily={setFontFamily}
|
||||
/>
|
||||
{layout === "designer" && (
|
||||
<DesignerPanel
|
||||
template={template}
|
||||
onChange={setTemplate}
|
||||
targetWidthMm={stock.labelWidthMm}
|
||||
targetHeightMm={stock.labelHeightMm}
|
||||
unit={unit}
|
||||
/>
|
||||
)}
|
||||
<StockPanel
|
||||
stock={stock}
|
||||
stockId={stockId}
|
||||
@@ -418,7 +434,7 @@ export function Workbench() {
|
||||
onClick={() => void downloadPdf()}
|
||||
disabled={!preview.result}
|
||||
>
|
||||
PDF (144 DPI)
|
||||
Vector PDF
|
||||
</button>
|
||||
<button type="button" onClick={printAll} disabled={!preview.result}>
|
||||
Print all
|
||||
@@ -427,8 +443,8 @@ export function Workbench() {
|
||||
<p className="print-boundary">
|
||||
<strong>Print boundary:</strong> use actual size / 100%, disable
|
||||
browser headers and footers, and verify a plain-paper calibration.
|
||||
SVG stays vector; PDF is rasterized and system fonts are not
|
||||
embedded.
|
||||
SVG and PDF geometry stay vector; local images remain raster and PDF
|
||||
text uses embedded standard fonts.
|
||||
</p>
|
||||
{preview.result && <Warnings warnings={preview.result.warnings} />}
|
||||
</section>
|
||||
@@ -630,6 +646,7 @@ function MappingPanel({
|
||||
<option value="address">Address label</option>
|
||||
<option value="badge">Name badge</option>
|
||||
<option value="asset">Asset tag</option>
|
||||
<option value="designer">Custom designer</option>
|
||||
</select>
|
||||
</label>
|
||||
<FieldSelect
|
||||
|
||||
@@ -14,7 +14,7 @@ export function createSvgZip(result: RenderResult): Blob {
|
||||
files["merge-report.json"] = strToU8(
|
||||
stableStringify(
|
||||
{
|
||||
application: "Label Tools 0.1.0",
|
||||
application: "Label Tools 0.2.0",
|
||||
pages: result.totalPages,
|
||||
labels: result.totalLabels,
|
||||
warnings: result.warnings,
|
||||
|
||||
+214
-43
@@ -1,4 +1,11 @@
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
import {
|
||||
PDFDocument,
|
||||
StandardFonts,
|
||||
rgb,
|
||||
type PDFFont,
|
||||
type PDFPage,
|
||||
type RGB,
|
||||
} from "pdf-lib";
|
||||
import type { LabelStock, RenderResult } from "./types";
|
||||
|
||||
export async function createPdf(
|
||||
@@ -12,19 +19,21 @@ export async function createPdf(
|
||||
const pdf = await PDFDocument.create();
|
||||
const widthPt = (stock.pageWidthMm * 72) / 25.4;
|
||||
const heightPt = (stock.pageHeightMm * 72) / 25.4;
|
||||
const regular = await pdf.embedFont(StandardFonts.Helvetica);
|
||||
const bold = await pdf.embedFont(StandardFonts.HelveticaBold);
|
||||
for (const svg of result.pages) {
|
||||
const png = await rasterizeSvg(svg, stock.pageWidthMm, stock.pageHeightMm);
|
||||
const embedded = await pdf.embedPng(png);
|
||||
const page = pdf.addPage([widthPt, heightPt]);
|
||||
page.drawImage(embedded, {
|
||||
page.drawRectangle({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: widthPt,
|
||||
height: heightPt,
|
||||
color: rgb(1, 1, 1),
|
||||
});
|
||||
await drawVectorSvg(pdf, page, svg, heightPt, regular, bold);
|
||||
}
|
||||
pdf.setTitle("Label Tools merge");
|
||||
pdf.setCreator("Label Tools 0.1.0");
|
||||
pdf.setCreator("Label Tools 0.2.0");
|
||||
pdf.setProducer("Label Tools / pdf-lib");
|
||||
const bytes = await pdf.save({
|
||||
useObjectStreams: false,
|
||||
@@ -35,46 +44,208 @@ export async function createPdf(
|
||||
});
|
||||
}
|
||||
|
||||
async function rasterizeSvg(
|
||||
const MM_PT = 72 / 25.4;
|
||||
interface DrawContext {
|
||||
tx: number;
|
||||
ty: number;
|
||||
scale: number;
|
||||
fill?: string;
|
||||
stroke?: string;
|
||||
strokeWidth: number;
|
||||
}
|
||||
|
||||
async function drawVectorSvg(
|
||||
pdf: PDFDocument,
|
||||
page: PDFPage,
|
||||
svg: string,
|
||||
widthMm: number,
|
||||
heightMm: number,
|
||||
): Promise<Uint8Array> {
|
||||
const dpi = 144;
|
||||
const width = Math.ceil((widthMm / 25.4) * dpi);
|
||||
const height = Math.ceil((heightMm / 25.4) * dpi);
|
||||
if (width * height > 8_000_000)
|
||||
throw new Error(
|
||||
"Rasterized PDF page exceeds the 8 megapixel safety limit.",
|
||||
);
|
||||
const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
|
||||
try {
|
||||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const node = new Image();
|
||||
node.onload = () => resolve(node);
|
||||
node.onerror = () =>
|
||||
reject(
|
||||
new Error("Browser could not rasterize the generated SVG page."),
|
||||
);
|
||||
node.src = url;
|
||||
pageHeight: number,
|
||||
regular: PDFFont,
|
||||
bold: PDFFont,
|
||||
) {
|
||||
const document = new DOMParser().parseFromString(svg, "image/svg+xml");
|
||||
if (document.querySelector("parsererror"))
|
||||
throw new Error("Generated SVG could not be parsed for vector PDF export.");
|
||||
const root = document.documentElement;
|
||||
for (const child of [...root.children])
|
||||
await drawNode(pdf, page, child, pageHeight, regular, bold, {
|
||||
tx: 0,
|
||||
ty: 0,
|
||||
scale: 1,
|
||||
strokeWidth: 0,
|
||||
});
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Canvas rendering is unavailable.");
|
||||
context.fillStyle = "white";
|
||||
context.fillRect(0, 0, width, height);
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
const blob = await new Promise<Blob>((resolve, reject) =>
|
||||
canvas.toBlob(
|
||||
(value) =>
|
||||
value ? resolve(value) : reject(new Error("PNG conversion failed.")),
|
||||
"image/png",
|
||||
),
|
||||
}
|
||||
|
||||
async function drawNode(
|
||||
pdf: PDFDocument,
|
||||
page: PDFPage,
|
||||
node: Element,
|
||||
pageHeight: number,
|
||||
regular: PDFFont,
|
||||
bold: PDFFont,
|
||||
parent: DrawContext,
|
||||
): Promise<void> {
|
||||
if (["defs", "clipPath", "marker"].includes(node.localName)) return;
|
||||
const context = applyContext(parent, node);
|
||||
if (node.localName === "g" || node.localName === "svg") {
|
||||
for (const child of [...node.children])
|
||||
await drawNode(pdf, page, child, pageHeight, regular, bold, context);
|
||||
return;
|
||||
}
|
||||
if (node.localName === "rect") {
|
||||
const x = number(node, "x", 0),
|
||||
y = number(node, "y", 0),
|
||||
width = number(node, "width"),
|
||||
height = number(node, "height");
|
||||
if (
|
||||
![x, y, width, height].every(Number.isFinite) ||
|
||||
width <= 0 ||
|
||||
height <= 0
|
||||
)
|
||||
return;
|
||||
const fill = paint(context.fill),
|
||||
stroke = paint(context.stroke);
|
||||
page.drawRectangle({
|
||||
x: (context.tx + x * context.scale) * MM_PT,
|
||||
y: pageHeight - (context.ty + (y + height) * context.scale) * MM_PT,
|
||||
width: width * context.scale * MM_PT,
|
||||
height: height * context.scale * MM_PT,
|
||||
...(fill ? { color: fill } : {}),
|
||||
...(stroke
|
||||
? {
|
||||
borderColor: stroke,
|
||||
borderWidth: context.strokeWidth * context.scale * MM_PT,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (node.localName === "path") {
|
||||
const path = node.getAttribute("d");
|
||||
if (!path || path.length > 1_000_000) return;
|
||||
const fill = paint(context.fill),
|
||||
stroke = paint(context.stroke);
|
||||
page.drawSvgPath(path, {
|
||||
x: context.tx * MM_PT,
|
||||
y: pageHeight - context.ty * MM_PT,
|
||||
scale: context.scale * MM_PT,
|
||||
...(fill ? { color: fill } : {}),
|
||||
...(stroke
|
||||
? {
|
||||
borderColor: stroke,
|
||||
borderWidth: context.strokeWidth * context.scale * MM_PT,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (node.localName === "text") {
|
||||
const font = /^(?:600|700|bold)$/u.test(
|
||||
node.getAttribute("font-weight") ?? "",
|
||||
)
|
||||
? bold
|
||||
: regular,
|
||||
size = number(node, "font-size", 3) * context.scale * MM_PT,
|
||||
anchor = node.getAttribute("text-anchor") ?? "start";
|
||||
let cursorY = number(node, "y", 0);
|
||||
const spans = [...node.children].filter(
|
||||
(child) => child.localName === "tspan",
|
||||
);
|
||||
return new Uint8Array(await blob.arrayBuffer());
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
const lines = spans.length ? spans : [node];
|
||||
for (const line of lines) {
|
||||
cursorY += number(line, "dy", 0);
|
||||
const value = pdfText(line.textContent ?? ""),
|
||||
lineX = number(line, "x", number(node, "x", 0)),
|
||||
width = font.widthOfTextAtSize(value, size),
|
||||
x =
|
||||
(context.tx + lineX * context.scale) * MM_PT -
|
||||
(anchor === "middle" ? width / 2 : anchor === "end" ? width : 0);
|
||||
page.drawText(value, {
|
||||
x,
|
||||
y: pageHeight - (context.ty + cursorY * context.scale) * MM_PT,
|
||||
size,
|
||||
font,
|
||||
color: paint(context.fill) ?? rgb(0.07, 0.09, 0.15),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (node.localName === "image") {
|
||||
const href = node.getAttribute("href") ?? node.getAttribute("xlink:href");
|
||||
if (!href?.startsWith("data:image/")) return;
|
||||
const comma = href.indexOf(",");
|
||||
if (comma < 0 || !href.slice(0, comma).includes(";base64")) return;
|
||||
const bytes = Uint8Array.from(atob(href.slice(comma + 1)), (character) =>
|
||||
character.charCodeAt(0),
|
||||
);
|
||||
const embedded = href.startsWith("data:image/png")
|
||||
? await pdf.embedPng(bytes)
|
||||
: href.startsWith("data:image/jpeg")
|
||||
? await pdf.embedJpg(bytes)
|
||||
: undefined;
|
||||
if (!embedded) return;
|
||||
const x = number(node, "x", 0),
|
||||
y = number(node, "y", 0),
|
||||
width = number(node, "width"),
|
||||
height = number(node, "height");
|
||||
if (![width, height].every(Number.isFinite)) return;
|
||||
page.drawImage(embedded, {
|
||||
x: (context.tx + x * context.scale) * MM_PT,
|
||||
y: pageHeight - (context.ty + (y + height) * context.scale) * MM_PT,
|
||||
width: width * context.scale * MM_PT,
|
||||
height: height * context.scale * MM_PT,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function applyContext(parent: DrawContext, node: Element): DrawContext {
|
||||
let tx = parent.tx,
|
||||
ty = parent.ty,
|
||||
scale = parent.scale;
|
||||
const transform = node.getAttribute("transform") ?? "";
|
||||
for (const match of transform.matchAll(/(translate|scale)\(([^)]+)\)/gu)) {
|
||||
const values = match[2]!.trim().split(/[ ,]+/u).map(Number);
|
||||
if (values.some((value) => !Number.isFinite(value))) continue;
|
||||
if (match[1] === "translate") {
|
||||
tx += (values[0] ?? 0) * scale;
|
||||
ty += (values[1] ?? 0) * scale;
|
||||
} else if (
|
||||
(values[0] ?? 0) > 0 &&
|
||||
(values[1] === undefined || values[1] === values[0])
|
||||
)
|
||||
scale *= values[0] ?? 1;
|
||||
}
|
||||
return {
|
||||
tx,
|
||||
ty,
|
||||
scale,
|
||||
fill: node.getAttribute("fill") ?? parent.fill,
|
||||
stroke: node.getAttribute("stroke") ?? parent.stroke,
|
||||
strokeWidth: number(node, "stroke-width", parent.strokeWidth),
|
||||
};
|
||||
}
|
||||
|
||||
function paint(value: string | undefined): RGB | undefined {
|
||||
if (!value || value === "none") return undefined;
|
||||
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/iu.exec(value)?.[1];
|
||||
if (!hex) return undefined;
|
||||
const expanded =
|
||||
hex.length === 3 ? [...hex].map((part) => part + part).join("") : hex;
|
||||
return rgb(
|
||||
Number.parseInt(expanded.slice(0, 2), 16) / 255,
|
||||
Number.parseInt(expanded.slice(2, 4), 16) / 255,
|
||||
Number.parseInt(expanded.slice(4, 6), 16) / 255,
|
||||
);
|
||||
}
|
||||
function number(node: Element, name: string, fallback = Number.NaN) {
|
||||
const value = Number(node.getAttribute(name));
|
||||
return Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
function pdfText(value: string) {
|
||||
return [...value]
|
||||
.map((character) => {
|
||||
const point = character.codePointAt(0) ?? 0;
|
||||
return point >= 0x20 && point <= 0xff ? character : "?";
|
||||
})
|
||||
.join("")
|
||||
.slice(0, 10_000);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { barcodeFragment } from "./barcode";
|
||||
import { stockCapacity, validateStock } from "./stocks";
|
||||
import { mergeContent, validateTemplate } from "./template";
|
||||
import type {
|
||||
ImageAsset,
|
||||
MergeRecord,
|
||||
@@ -61,6 +62,7 @@ export function renderLabelPages(
|
||||
width,
|
||||
height,
|
||||
image,
|
||||
imageByName,
|
||||
options,
|
||||
warnings,
|
||||
),
|
||||
@@ -85,9 +87,23 @@ function renderLabel(
|
||||
width: number,
|
||||
height: number,
|
||||
image: ImageAsset | undefined,
|
||||
imageByName: ReadonlyMap<string, ImageAsset>,
|
||||
options: RenderOptions,
|
||||
warnings: RenderWarning[],
|
||||
): string {
|
||||
if (options.layout === "designer" && options.template)
|
||||
return renderDesignerLabel(
|
||||
record,
|
||||
rowIndex,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
imageByName,
|
||||
image,
|
||||
options,
|
||||
warnings,
|
||||
);
|
||||
const id = `clip-${rowIndex}-${Math.round(x * 100)}-${Math.round(y * 100)}`;
|
||||
const padding = Math.max(1.4, Math.min(width, height) * 0.06);
|
||||
const title = fitted(
|
||||
@@ -180,6 +196,122 @@ function renderLabel(
|
||||
return content.join("");
|
||||
}
|
||||
|
||||
function renderDesignerLabel(
|
||||
record: MergeRecord,
|
||||
rowIndex: number,
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
images: ReadonlyMap<string, ImageAsset>,
|
||||
fallbackImage: ImageAsset | undefined,
|
||||
options: RenderOptions,
|
||||
warnings: RenderWarning[],
|
||||
) {
|
||||
const template = options.template!;
|
||||
const issues = validateTemplate(template);
|
||||
const error = issues.find((issue) => issue.severity === "error");
|
||||
if (error) throw new Error(error.message);
|
||||
for (const issue of issues.filter((item) => item.severity === "warning"))
|
||||
warnings.push({
|
||||
row: rowIndex + 1,
|
||||
field: issue.elementId ?? "template",
|
||||
message: issue.message,
|
||||
});
|
||||
const sx = width / template.widthMm,
|
||||
sy = height / template.heightMm,
|
||||
bleedX = template.bleedMm * sx,
|
||||
bleedY = template.bleedMm * sy,
|
||||
clipId = `designer-${rowIndex}-${Math.round(x * 100)}-${Math.round(y * 100)}`;
|
||||
const output = [
|
||||
`<defs><clipPath id="${clipId}"><rect x="${n(x - bleedX)}" y="${n(y - bleedY)}" width="${n(width + bleedX * 2)}" height="${n(height + bleedY * 2)}"/></clipPath></defs>`,
|
||||
`<g clip-path="url(#${clipId})"><rect x="${n(x)}" y="${n(y)}" width="${n(width)}" height="${n(height)}" fill="#fff"/>`,
|
||||
];
|
||||
for (const element of template.elements) {
|
||||
const ex = x + element.xMm * sx,
|
||||
ey = y + element.yMm * sy,
|
||||
ew = element.widthMm * sx,
|
||||
eh = element.heightMm * sy;
|
||||
if (element.kind === "shape") {
|
||||
output.push(
|
||||
`<rect x="${n(ex)}" y="${n(ey)}" width="${n(ew)}" height="${n(eh)}" rx="${n(element.radiusMm * Math.min(sx, sy))}" fill="${xml(element.fill)}" stroke="${xml(element.stroke)}" stroke-width="${n(element.strokeWidthMm * Math.min(sx, sy))}"/>`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const content = mergeContent(element.content, record);
|
||||
if (element.kind === "text") {
|
||||
const fit = fitted(
|
||||
content,
|
||||
ew,
|
||||
eh,
|
||||
element.fontSizePt,
|
||||
rowIndex,
|
||||
element.id,
|
||||
warnings,
|
||||
Math.max(
|
||||
1,
|
||||
Math.floor(eh / Math.max(1.2, element.fontSizePt * 0.44)),
|
||||
),
|
||||
),
|
||||
anchor =
|
||||
element.align === "center"
|
||||
? "middle"
|
||||
: element.align === "right"
|
||||
? "end"
|
||||
: "start",
|
||||
textX =
|
||||
element.align === "center"
|
||||
? ex + ew / 2
|
||||
: element.align === "right"
|
||||
? ex + ew
|
||||
: ex;
|
||||
output.push(
|
||||
`<text x="${n(textX)}" y="${n(ey + fit.size)}" text-anchor="${anchor}" font-family="${xml(options.fontFamily)}" font-size="${n(fit.size)}" font-weight="${element.fontWeight}" fill="${xml(element.color)}">${fit.lines.map((line, index) => `<tspan x="${n(textX)}" dy="${index ? n(fit.size * 1.25) : 0}">${xml(line)}</tspan>`).join("")}</text>`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (element.kind === "image") {
|
||||
const asset = images.get(content) ?? fallbackImage;
|
||||
if (asset)
|
||||
output.push(
|
||||
`<image x="${n(ex)}" y="${n(ey)}" width="${n(ew)}" height="${n(eh)}" href="${xml(asset.dataUrl)}" preserveAspectRatio="xMidYMid ${element.fit === "cover" ? "slice" : "meet"}"/>`,
|
||||
);
|
||||
else if (content)
|
||||
warnings.push({
|
||||
row: rowIndex + 1,
|
||||
field: element.id,
|
||||
message: `Image ${content} is not loaded.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (content) {
|
||||
const quietX = element.quietZoneMm * sx,
|
||||
quietY = element.quietZoneMm * sy;
|
||||
barcodeElement(
|
||||
output,
|
||||
content,
|
||||
{
|
||||
...options,
|
||||
barcodeFormat: element.format,
|
||||
includeBarcodeText: element.includeText,
|
||||
},
|
||||
ex + quietX,
|
||||
ey + quietY,
|
||||
ew - quietX * 2,
|
||||
eh - quietY * 2,
|
||||
rowIndex,
|
||||
warnings,
|
||||
);
|
||||
}
|
||||
}
|
||||
output.push("</g>");
|
||||
if (options.cutMarks)
|
||||
output.push(
|
||||
`<rect x="${n(x)}" y="${n(y)}" width="${n(width)}" height="${n(height)}" fill="none" stroke="#7b8794" stroke-width="0.18" stroke-dasharray="1 1"/>`,
|
||||
);
|
||||
return output.join("");
|
||||
}
|
||||
|
||||
interface ContentContext {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -540,6 +672,8 @@ function validateOptions(options: RenderOptions) {
|
||||
throw new Error("A merge is limited to 2,000 labels.");
|
||||
if (!ALLOWED_FONTS.has(options.fontFamily))
|
||||
throw new Error("Unsupported system font stack.");
|
||||
if (options.layout === "designer" && !options.template)
|
||||
throw new Error("Designer layout requires a validated template.");
|
||||
}
|
||||
|
||||
function xml(value: string): string {
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import { safeJsonParse, stableStringify } from "@add-ideas/toolbox-helpers";
|
||||
import type {
|
||||
BarcodeFormat,
|
||||
LabelTemplate,
|
||||
MergeRecord,
|
||||
TemplateIssue,
|
||||
} from "./types";
|
||||
import { BARCODE_FORMATS } from "./barcode";
|
||||
|
||||
export const TEMPLATE_SCHEMA = "de.add-ideas.label-tools.template.v1" as const;
|
||||
export const MAX_TEMPLATE_BYTES = 256 * 1024;
|
||||
|
||||
export function defaultTemplate(
|
||||
widthMm: number,
|
||||
heightMm: number,
|
||||
): LabelTemplate {
|
||||
const pad = Math.max(1.5, Math.min(widthMm, heightMm) * 0.06);
|
||||
return {
|
||||
schema: TEMPLATE_SCHEMA,
|
||||
id: "custom-label",
|
||||
name: "Custom label",
|
||||
widthMm,
|
||||
heightMm,
|
||||
bleedMm: 0,
|
||||
elements: [
|
||||
{
|
||||
id: "title",
|
||||
kind: "text",
|
||||
xMm: pad,
|
||||
yMm: pad,
|
||||
widthMm: widthMm * 0.58,
|
||||
heightMm: Math.max(5, heightMm * 0.2),
|
||||
content: "{{name}}",
|
||||
fontSizePt: 14,
|
||||
fontWeight: "700",
|
||||
align: "left",
|
||||
color: "#111827",
|
||||
},
|
||||
{
|
||||
id: "body",
|
||||
kind: "text",
|
||||
xMm: pad,
|
||||
yMm: heightMm * 0.32,
|
||||
widthMm: widthMm * 0.58,
|
||||
heightMm: heightMm * 0.42,
|
||||
content: "{{address}}",
|
||||
fontSizePt: 9,
|
||||
fontWeight: "400",
|
||||
align: "left",
|
||||
color: "#374151",
|
||||
},
|
||||
{
|
||||
id: "code",
|
||||
kind: "barcode",
|
||||
xMm: widthMm * 0.67,
|
||||
yMm: heightMm * 0.16,
|
||||
widthMm: widthMm * 0.28,
|
||||
heightMm: heightMm * 0.68,
|
||||
content: "{{code}}",
|
||||
format: "qrcode",
|
||||
quietZoneMm: 2,
|
||||
includeText: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function exportTemplate(template: LabelTemplate): string {
|
||||
const issues = validateTemplate(template);
|
||||
const error = issues.find((issue) => issue.severity === "error");
|
||||
if (error) throw new Error(error.message);
|
||||
return stableStringify(template as unknown as Record<string, unknown>, 2, {
|
||||
maxTextChars: MAX_TEMPLATE_BYTES,
|
||||
maxDepth: 16,
|
||||
maxNodes: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function importTemplate(source: string): LabelTemplate {
|
||||
const value = safeJsonParse(source, {
|
||||
maxTextChars: MAX_TEMPLATE_BYTES,
|
||||
maxDepth: 16,
|
||||
maxNodes: 10_000,
|
||||
});
|
||||
if (!isObject(value) || value.schema !== TEMPLATE_SCHEMA)
|
||||
throw new Error("Not a Label Tools template v1.");
|
||||
const template = value as unknown as LabelTemplate;
|
||||
const issue = validateTemplate(template).find(
|
||||
(candidate) => candidate.severity === "error",
|
||||
);
|
||||
if (issue) throw new Error(issue.message);
|
||||
return structuredClone(template);
|
||||
}
|
||||
|
||||
export function validateTemplate(template: LabelTemplate): TemplateIssue[] {
|
||||
const issues: TemplateIssue[] = [];
|
||||
if (
|
||||
template.schema !== TEMPLATE_SCHEMA ||
|
||||
!finiteRange(template.widthMm, 5, 1_000) ||
|
||||
!finiteRange(template.heightMm, 5, 1_000)
|
||||
)
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: "Template dimensions must be 5–1,000 mm.",
|
||||
});
|
||||
if (
|
||||
typeof template.id !== "string" ||
|
||||
!/^[A-Za-z0-9_-]{1,64}$/u.test(template.id) ||
|
||||
typeof template.name !== "string" ||
|
||||
template.name.trim().length === 0 ||
|
||||
template.name.length > 120
|
||||
)
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: "Template id/name metadata is invalid.",
|
||||
});
|
||||
if (!finiteRange(template.bleedMm, 0, 20))
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: "Bleed must be between 0 and 20 mm.",
|
||||
});
|
||||
if (!Array.isArray(template.elements) || template.elements.length > 100) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: "Templates support at most 100 elements.",
|
||||
});
|
||||
return issues;
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
for (const element of template.elements) {
|
||||
if (
|
||||
!element ||
|
||||
typeof element !== "object" ||
|
||||
!/^[A-Za-z0-9_-]{1,64}$/u.test(element.id)
|
||||
) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: "Every element needs a safe unique id.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!["text", "barcode", "image", "shape"].includes(element.kind)) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
elementId: element.id,
|
||||
message: `${element.id} uses an unsupported element kind.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (ids.has(element.id))
|
||||
issues.push({
|
||||
severity: "error",
|
||||
elementId: element.id,
|
||||
message: `Duplicate element id ${element.id}.`,
|
||||
});
|
||||
ids.add(element.id);
|
||||
const bounds = [
|
||||
element.xMm,
|
||||
element.yMm,
|
||||
element.widthMm,
|
||||
element.heightMm,
|
||||
];
|
||||
if (
|
||||
!bounds.every(Number.isFinite) ||
|
||||
element.widthMm <= 0 ||
|
||||
element.heightMm <= 0
|
||||
)
|
||||
issues.push({
|
||||
severity: "error",
|
||||
elementId: element.id,
|
||||
message: `${element.id} has invalid geometry.`,
|
||||
});
|
||||
const bleed = Number.isFinite(template.bleedMm) ? template.bleedMm : 0;
|
||||
if (
|
||||
element.xMm < -bleed ||
|
||||
element.yMm < -bleed ||
|
||||
element.xMm + element.widthMm > template.widthMm + bleed ||
|
||||
element.yMm + element.heightMm > template.heightMm + bleed
|
||||
)
|
||||
issues.push({
|
||||
severity: "error",
|
||||
elementId: element.id,
|
||||
message: `${element.id} extends beyond the label and bleed.`,
|
||||
});
|
||||
if (element.kind === "barcode") {
|
||||
if (
|
||||
typeof element.content !== "string" ||
|
||||
element.content.length > 4_096 ||
|
||||
typeof element.includeText !== "boolean"
|
||||
)
|
||||
issues.push({
|
||||
severity: "error",
|
||||
elementId: element.id,
|
||||
message: `${element.id} has invalid barcode content settings.`,
|
||||
});
|
||||
if (!BARCODE_FORMATS.some(([format]) => format === element.format))
|
||||
issues.push({
|
||||
severity: "error",
|
||||
elementId: element.id,
|
||||
message: `${element.id} uses an unsupported barcode format.`,
|
||||
});
|
||||
const recommended = quietZoneRecommendation(element.format);
|
||||
if (!finiteRange(element.quietZoneMm, 0, 20))
|
||||
issues.push({
|
||||
severity: "error",
|
||||
elementId: element.id,
|
||||
message: `${element.id} has an invalid quiet zone.`,
|
||||
});
|
||||
else if (element.quietZoneMm < recommended)
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
elementId: element.id,
|
||||
message: `${element.id} quiet zone is below the ${recommended} mm print recommendation.`,
|
||||
});
|
||||
if (
|
||||
element.widthMm - element.quietZoneMm * 2 < 8 ||
|
||||
element.heightMm - element.quietZoneMm * 2 < 8
|
||||
)
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
elementId: element.id,
|
||||
message: `${element.id} may be too small to scan reliably.`,
|
||||
});
|
||||
}
|
||||
if (element.kind === "text") {
|
||||
if (
|
||||
typeof element.content !== "string" ||
|
||||
element.content.length > 10_000 ||
|
||||
!finiteRange(element.fontSizePt, 3, 144) ||
|
||||
!["400", "600", "700"].includes(element.fontWeight) ||
|
||||
!["left", "center", "right"].includes(element.align) ||
|
||||
!safeColour(element.color)
|
||||
)
|
||||
issues.push({
|
||||
severity: "error",
|
||||
elementId: element.id,
|
||||
message: `${element.id} has invalid text settings.`,
|
||||
});
|
||||
} else if (element.kind === "image") {
|
||||
if (
|
||||
typeof element.content !== "string" ||
|
||||
element.content.length > 4_096 ||
|
||||
!["contain", "cover"].includes(element.fit)
|
||||
)
|
||||
issues.push({
|
||||
severity: "error",
|
||||
elementId: element.id,
|
||||
message: `${element.id} has invalid image settings.`,
|
||||
});
|
||||
} else if (element.kind === "shape") {
|
||||
if (
|
||||
!safeColour(element.fill) ||
|
||||
!safeColour(element.stroke) ||
|
||||
!finiteRange(element.strokeWidthMm, 0, 5) ||
|
||||
!finiteRange(element.radiusMm, 0, 100)
|
||||
)
|
||||
issues.push({
|
||||
severity: "error",
|
||||
elementId: element.id,
|
||||
message: `${element.id} has invalid shape settings.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function mergeContent(template: string, record: MergeRecord): string {
|
||||
return template.replace(
|
||||
/\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/gu,
|
||||
(_, key: string) => record[key] ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
export function quietZoneRecommendation(format: BarcodeFormat): number {
|
||||
return format === "qrcode" ||
|
||||
format === "datamatrix" ||
|
||||
format === "azteccode"
|
||||
? 2
|
||||
: format === "pdf417"
|
||||
? 2.5
|
||||
: 3;
|
||||
}
|
||||
|
||||
export function resizeTemplate(
|
||||
template: LabelTemplate,
|
||||
widthMm: number,
|
||||
heightMm: number,
|
||||
): LabelTemplate {
|
||||
const sx = widthMm / template.widthMm,
|
||||
sy = heightMm / template.heightMm;
|
||||
return {
|
||||
...template,
|
||||
widthMm,
|
||||
heightMm,
|
||||
elements: template.elements.map((element) => ({
|
||||
...element,
|
||||
xMm: element.xMm * sx,
|
||||
yMm: element.yMm * sy,
|
||||
widthMm: element.widthMm * sx,
|
||||
heightMm: element.heightMm * sy,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function finiteRange(value: number, minimum: number, maximum: number) {
|
||||
return Number.isFinite(value) && value >= minimum && value <= maximum;
|
||||
}
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
function safeColour(value: unknown): value is string {
|
||||
return typeof value === "string" && /^#[0-9a-f]{6}$/iu.test(value);
|
||||
}
|
||||
+67
-1
@@ -1,5 +1,5 @@
|
||||
export type Unit = "mm" | "in" | "pt";
|
||||
export type LayoutKind = "address" | "badge" | "asset";
|
||||
export type LayoutKind = "address" | "badge" | "asset" | "designer";
|
||||
export type BarcodeFormat =
|
||||
| "qrcode"
|
||||
| "code128"
|
||||
@@ -55,6 +55,71 @@ export interface ImageAsset {
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export type LabelElement =
|
||||
| {
|
||||
id: string;
|
||||
kind: "text";
|
||||
xMm: number;
|
||||
yMm: number;
|
||||
widthMm: number;
|
||||
heightMm: number;
|
||||
content: string;
|
||||
fontSizePt: number;
|
||||
fontWeight: "400" | "600" | "700";
|
||||
align: "left" | "center" | "right";
|
||||
color: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "barcode";
|
||||
xMm: number;
|
||||
yMm: number;
|
||||
widthMm: number;
|
||||
heightMm: number;
|
||||
content: string;
|
||||
format: BarcodeFormat;
|
||||
quietZoneMm: number;
|
||||
includeText: boolean;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "image";
|
||||
xMm: number;
|
||||
yMm: number;
|
||||
widthMm: number;
|
||||
heightMm: number;
|
||||
content: string;
|
||||
fit: "contain" | "cover";
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
kind: "shape";
|
||||
xMm: number;
|
||||
yMm: number;
|
||||
widthMm: number;
|
||||
heightMm: number;
|
||||
fill: string;
|
||||
stroke: string;
|
||||
strokeWidthMm: number;
|
||||
radiusMm: number;
|
||||
};
|
||||
|
||||
export interface LabelTemplate {
|
||||
schema: "de.add-ideas.label-tools.template.v1";
|
||||
id: string;
|
||||
name: string;
|
||||
widthMm: number;
|
||||
heightMm: number;
|
||||
bleedMm: number;
|
||||
elements: LabelElement[];
|
||||
}
|
||||
|
||||
export interface TemplateIssue {
|
||||
severity: "error" | "warning";
|
||||
elementId?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
stock: LabelStock;
|
||||
calibration: Calibration;
|
||||
@@ -69,6 +134,7 @@ export interface RenderOptions {
|
||||
cutMarks: boolean;
|
||||
registrationMarks: boolean;
|
||||
startPosition: number;
|
||||
template?: LabelTemplate;
|
||||
}
|
||||
|
||||
export interface RenderWarning {
|
||||
|
||||
@@ -220,6 +220,61 @@ textarea {
|
||||
height: 1px;
|
||||
clip: rect(0 0 0 0);
|
||||
}
|
||||
.designer-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
.designer-canvas {
|
||||
width: 100%;
|
||||
min-height: 14rem;
|
||||
max-height: 28rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.7rem;
|
||||
background: color-mix(in srgb, CanvasText 6%, Canvas);
|
||||
touch-action: none;
|
||||
}
|
||||
.designer-bleed {
|
||||
fill: color-mix(in srgb, #f59e0b 12%, Canvas);
|
||||
stroke: #b45309;
|
||||
stroke-width: 0.2;
|
||||
stroke-dasharray: 1 1;
|
||||
}
|
||||
.designer-label {
|
||||
fill: #fff;
|
||||
stroke: #64748b;
|
||||
stroke-width: 0.25;
|
||||
}
|
||||
.designer-element {
|
||||
cursor: move;
|
||||
}
|
||||
.designer-element rect {
|
||||
fill: rgb(40 122 71 / 10%);
|
||||
stroke: #287a47;
|
||||
stroke-width: 0.25;
|
||||
}
|
||||
.designer-element.selected rect {
|
||||
fill: rgb(40 122 71 / 22%);
|
||||
stroke-width: 0.55;
|
||||
}
|
||||
.designer-element text {
|
||||
fill: #163b25;
|
||||
font:
|
||||
2.5px system-ui,
|
||||
sans-serif;
|
||||
pointer-events: none;
|
||||
}
|
||||
.designer-issues {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.designer-issues .warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
.designer-issues .error {
|
||||
color: #b42318;
|
||||
}
|
||||
.asset-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.label-tools",
|
||||
"name": "Label Tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Merge and print labels locally.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
@@ -21,6 +21,44 @@
|
||||
"crossOriginIsolated": false,
|
||||
"topLevelContext": false
|
||||
},
|
||||
"io": {
|
||||
"accepts": [
|
||||
{
|
||||
"mediaType": "text/csv",
|
||||
"extensions": [".csv"]
|
||||
},
|
||||
{
|
||||
"mediaType": "application/json",
|
||||
"extensions": [".json"]
|
||||
},
|
||||
{
|
||||
"mediaType": "image/*",
|
||||
"extensions": [".png", ".jpg", ".jpeg", ".webp"]
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
{
|
||||
"mediaType": "application/pdf",
|
||||
"extensions": [".pdf"]
|
||||
},
|
||||
{
|
||||
"mediaType": "image/svg+xml",
|
||||
"extensions": [".svg"]
|
||||
},
|
||||
{
|
||||
"mediaType": "application/zip",
|
||||
"extensions": [".zip"]
|
||||
},
|
||||
{
|
||||
"mediaType": "application/json",
|
||||
"extensions": [".json"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": {
|
||||
"required": [],
|
||||
"optional": ["workers"]
|
||||
},
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": true,
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
export const APP_VERSION = "0.2.0";
|
||||
|
||||
Reference in New Issue
Block a user