@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user