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
+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 {