Release Label Tools 0.1.0

This commit is contained in:
2026-09-01 13:05:57 +02:00
commit 4e3df8d4a4
64 changed files with 10953 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
import { toSVG } from "bwip-js/browser";
import type { BarcodeFormat } from "./types";
export const BARCODE_FORMATS: readonly [BarcodeFormat, string][] = [
["qrcode", "QR Code"],
["code128", "Code 128"],
["gs1-128", "GS1-128"],
["ean13", "EAN-13"],
["ean8", "EAN-8"],
["upca", "UPC-A"],
["code39", "Code 39"],
["datamatrix", "Data Matrix"],
["pdf417", "PDF417"],
["azteccode", "Aztec Code"],
];
const LINEAR = new Set<BarcodeFormat>([
"code128",
"gs1-128",
"ean13",
"ean8",
"upca",
"code39",
]);
export function barcodeFragment(
format: BarcodeFormat,
text: string,
includeText: boolean,
) {
if (!BARCODE_FORMATS.some(([candidate]) => candidate === format))
throw new Error("Unsupported barcode format.");
if (!text || text.length > 4_096)
throw new Error("Barcode payload must contain 14,096 characters.");
const svg = toSVG({
bcid: format,
text,
scale: 2,
padding: format === "qrcode" ? 8 : 4,
...(LINEAR.has(format) ? { height: 12, includetext: includeText } : {}),
backgroundcolor: "FFFFFF",
barcolor: "111111",
textcolor: "111111",
});
if (/<(?:script|foreignObject)|\bon\w+\s*=|\b(?:href|src)\s*=/iu.test(svg))
throw new Error("Generated barcode failed the inert SVG policy.");
const root = /^<svg\b([^>]*)>([\s\S]*)<\/svg>$/u.exec(svg.trim());
if (!root) throw new Error("Barcode encoder returned malformed SVG.");
const viewBox = /\bviewBox="([^"]+)"/u
.exec(root[1] ?? "")?.[1]
?.trim()
.split(/\s+/u)
.map(Number);
if (
!viewBox ||
viewBox.length !== 4 ||
viewBox.some((value) => !Number.isFinite(value))
)
throw new Error("Barcode SVG has no valid viewBox.");
return {
body: root[2] ?? "",
viewBox: viewBox as [number, number, number, number],
};
}
+164
View File
@@ -0,0 +1,164 @@
import {
parseCsv,
safeJsonParse,
secureRandomBytes,
type JsonValue,
} from "@add-ideas/toolbox-helpers";
import type { GeneratorOptions, MergeRecord } from "./types";
export const DATA_LIMITS = Object.freeze({
maxCharacters: 2 * 1024 * 1024,
maxRows: 2_000,
maxFields: 100,
maxFieldCharacters: 10_000,
});
export function parseMergeData(
source: string,
format: "auto" | "csv" | "json" = "auto",
): MergeRecord[] {
if (source.length > DATA_LIMITS.maxCharacters)
throw new Error("Merge data exceeds the 2 MiB input limit.");
const resolved =
format === "auto"
? source.trimStart().startsWith("[") || source.trimStart().startsWith("{")
? "json"
: "csv"
: format;
return resolved === "json"
? parseJsonRecords(source)
: parseCsvRecords(source);
}
function parseCsvRecords(source: string): MergeRecord[] {
const rows = parseCsv(source, {
maxRows: DATA_LIMITS.maxRows + 1,
maxColumns: DATA_LIMITS.maxFields,
maxFieldChars: DATA_LIMITS.maxFieldCharacters,
});
if (!rows.length) return [];
const headers = rows[0]!.map((header, index) =>
normalizeHeader(header, index),
);
if (new Set(headers).size !== headers.length)
throw new Error("CSV headers must be unique.");
return rows
.slice(1)
.filter((row) => row.some(Boolean))
.map((row) =>
Object.fromEntries(
headers.map((header, index) => [header, row[index] ?? ""]),
),
);
}
function parseJsonRecords(source: string): MergeRecord[] {
const value = safeJsonParse(source, {
maxTextChars: DATA_LIMITS.maxCharacters,
maxDepth: 16,
maxNodes: 250_000,
});
const records = Array.isArray(value)
? value
: isObject(value) && Array.isArray(value.records)
? value.records
: [value];
if (records.length > DATA_LIMITS.maxRows)
throw new Error("JSON contains more than 2,000 records.");
return records.map((record, index) => {
if (!isObject(record))
throw new Error(`JSON record ${index + 1} must be an object.`);
const flattened: MergeRecord = {};
flattenRecord(record, flattened, "", 0);
if (Object.keys(flattened).length > DATA_LIMITS.maxFields)
throw new Error(
`JSON record ${index + 1} has more than 100 flattened fields.`,
);
return flattened;
});
}
function flattenRecord(
value: Record<string, JsonValue>,
output: MergeRecord,
prefix: string,
depth: number,
) {
if (depth > 4)
throw new Error("JSON record nesting exceeds four merge levels.");
for (const [key, child] of Object.entries(value)) {
const path = prefix ? `${prefix}.${key}` : key;
if (isObject(child)) flattenRecord(child, output, path, depth + 1);
else {
const rendered = Array.isArray(child)
? child.map((item) => scalar(item)).join(", ")
: scalar(child);
if (rendered.length > DATA_LIMITS.maxFieldCharacters)
throw new Error(`Field ${path} exceeds 10,000 characters.`);
output[path] = rendered;
}
}
}
function scalar(value: JsonValue): string {
if (value === null) return "";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
function normalizeHeader(value: string, index: number): string {
const normalized = value.replace(/^\uFEFF/u, "").trim();
return normalized || `column_${index + 1}`;
}
function isObject(value: JsonValue): value is Record<string, JsonValue> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
export function augmentRecords(
input: MergeRecord[],
options: GeneratorOptions,
randomProvider: (length: number) => Uint8Array = secureRandomBytes,
): MergeRecord[] {
if (
!Number.isInteger(options.count) ||
options.count < 0 ||
options.count > DATA_LIMITS.maxRows
)
throw new Error("Generated row count must be between 0 and 2,000.");
if (!Number.isSafeInteger(options.serialStart))
throw new Error("Serial start must be a safe integer.");
if (
!Number.isInteger(options.serialPadding) ||
options.serialPadding < 0 ||
options.serialPadding > 20
)
throw new Error("Serial padding must be between 0 and 20.");
if (
!Number.isInteger(options.randomLength) ||
options.randomLength < 4 ||
options.randomLength > 64
)
throw new Error("Random length must be between 4 and 64.");
const count = Math.max(input.length, options.count);
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
return Array.from({ length: count }, (_, index) => {
const bytes = randomProvider(options.randomLength);
const random = [...bytes]
.map((byte) => alphabet[byte % alphabet.length])
.join("");
const serialNumber = options.serialStart + index;
return {
...(input[index] ?? {}),
__row: String(index + 1),
__serial: `${options.serialPrefix}${String(serialNumber).padStart(options.serialPadding, "0")}`,
__random: random,
};
});
}
export function collectFields(records: MergeRecord[]): string[] {
return [...new Set(records.flatMap((record) => Object.keys(record)))].sort(
(a, b) => a.localeCompare(b),
);
}
+38
View File
@@ -0,0 +1,38 @@
import {
sanitizeDownloadFilename,
stableStringify,
} from "@add-ideas/toolbox-helpers";
import { strToU8, zipSync } from "fflate";
import type { RenderResult } from "./types";
export function createSvgZip(result: RenderResult): Blob {
const files: Record<string, Uint8Array> = {};
result.pages.forEach((svg, index) => {
files[`label-sheet-${String(index + 1).padStart(3, "0")}.svg`] =
strToU8(svg);
});
files["merge-report.json"] = strToU8(
stableStringify(
{
application: "Label Tools 0.1.0",
pages: result.totalPages,
labels: result.totalLabels,
warnings: result.warnings,
boundary:
"System fonts and printer scaling are not embedded or controlled; print a calibration sheet first.",
},
2,
),
);
const bytes = zipSync(files, {
level: 6,
mtime: new Date("1980-01-01T00:00:00.000Z"),
});
return new Blob([new Uint8Array(bytes).buffer], { type: "application/zip" });
}
export function svgFilename(index: number): string {
return sanitizeDownloadFilename(
`label-sheet-${String(index + 1).padStart(3, "0")}.svg`,
);
}
+73
View File
@@ -0,0 +1,73 @@
import type { ImageAsset } from "./types";
const ALLOWED = new Set(["image/png", "image/jpeg", "image/webp"]);
const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
const MAX_TOTAL_BYTES = 32 * 1024 * 1024;
const MAX_PIXELS = 40_000_000;
export async function loadImageAssets(
files: FileList | File[],
): Promise<ImageAsset[]> {
const selected = [...files];
if (selected.length > 50)
throw new Error("A project is limited to 50 images.");
if (selected.reduce((sum, file) => sum + file.size, 0) > MAX_TOTAL_BYTES)
throw new Error("Image assets exceed the 32 MiB project limit.");
const output: ImageAsset[] = [];
for (const file of selected) {
if (!ALLOWED.has(file.type))
throw new Error(
`${file.name}: only PNG, JPEG and WebP images are accepted.`,
);
if (file.size > MAX_IMAGE_BYTES)
throw new Error(`${file.name}: image exceeds 8 MiB.`);
const dimensions = await imageDimensions(file);
if (dimensions.width * dimensions.height > MAX_PIXELS)
throw new Error(`${file.name}: decoded image exceeds 40 megapixels.`);
output.push({
name: file.name,
mimeType: file.type,
dataUrl: await readDataUrl(file),
width: dimensions.width,
height: dimensions.height,
bytes: file.size,
});
}
return output;
}
async function imageDimensions(
file: File,
): Promise<{ width: number; height: number }> {
if ("createImageBitmap" in globalThis) {
const bitmap = await createImageBitmap(file);
try {
return { width: bitmap.width, height: bitmap.height };
} finally {
bitmap.close();
}
}
const url = URL.createObjectURL(file);
try {
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const node = new Image();
node.onload = () => resolve(node);
node.onerror = () =>
reject(new Error(`${file.name}: browser could not decode the image.`));
node.src = url;
});
return { width: image.naturalWidth, height: image.naturalHeight };
} finally {
URL.revokeObjectURL(url);
}
}
function readDataUrl(file: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () =>
reject(reader.error ?? new Error("Image read failed."));
reader.onload = () => resolve(String(reader.result));
reader.readAsDataURL(file);
});
}
+80
View File
@@ -0,0 +1,80 @@
import { PDFDocument } from "pdf-lib";
import type { LabelStock, RenderResult } from "./types";
export async function createPdf(
result: RenderResult,
stock: LabelStock,
): Promise<Blob> {
if (result.pages.length > 100)
throw new Error(
"PDF export is limited to 100 pages; use SVG ZIP for larger runs.",
);
const pdf = await PDFDocument.create();
const widthPt = (stock.pageWidthMm * 72) / 25.4;
const heightPt = (stock.pageHeightMm * 72) / 25.4;
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, {
x: 0,
y: 0,
width: widthPt,
height: heightPt,
});
}
pdf.setTitle("Label Tools merge");
pdf.setCreator("Label Tools 0.1.0");
pdf.setProducer("Label Tools / pdf-lib");
const bytes = await pdf.save({
useObjectStreams: false,
addDefaultPage: false,
});
return new Blob([new Uint8Array(bytes).buffer], {
type: "application/pdf",
});
}
async function rasterizeSvg(
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;
});
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",
),
);
return new Uint8Array(await blob.arrayBuffer());
} finally {
URL.revokeObjectURL(url);
}
}
+556
View File
@@ -0,0 +1,556 @@
import { barcodeFragment } from "./barcode";
import { stockCapacity, validateStock } from "./stocks";
import type {
ImageAsset,
MergeRecord,
RenderOptions,
RenderResult,
RenderWarning,
} from "./types";
const ALLOWED_FONTS = new Set([
"Arial, Helvetica, sans-serif",
"Georgia, 'Times New Roman', serif",
"'Courier New', Courier, monospace",
"system-ui, sans-serif",
]);
export function renderLabelPages(
options: RenderOptions,
maxPages = Number.POSITIVE_INFINITY,
): RenderResult {
const stock = validateStock(options.stock);
validateOptions(options);
const capacity = stockCapacity(stock);
const totalSlots = options.startPosition + options.records.length;
const totalPages = Math.max(1, Math.ceil(totalSlots / capacity));
const pageCount = Math.min(totalPages, Math.max(0, Math.floor(maxPages)));
const warnings: RenderWarning[] = [];
const imageByName = new Map(
options.images.map((asset) => [asset.name, asset]),
);
const defaultImage = imageByName.get(options.defaultImage);
const pages = Array.from({ length: pageCount }, (_, pageIndex) => {
const labels: string[] = [];
for (let slot = 0; slot < capacity; slot += 1) {
const globalSlot = pageIndex * capacity + slot;
const rowIndex = globalSlot - options.startPosition;
if (rowIndex < 0 || rowIndex >= options.records.length) continue;
const row = options.records[rowIndex]!;
const column = slot % stock.columns;
const rowPosition = Math.floor(slot / stock.columns);
const x =
options.calibration.offsetXmm +
(stock.marginLeftMm + column * (stock.labelWidthMm + stock.gapXmm)) *
options.calibration.scaleX;
const y =
options.calibration.offsetYmm +
(stock.marginTopMm +
rowPosition * (stock.labelHeightMm + stock.gapYmm)) *
options.calibration.scaleY;
const width = stock.labelWidthMm * options.calibration.scaleX;
const height = stock.labelHeightMm * options.calibration.scaleY;
const imageName = field(row, options.mapping.image);
const image = imageByName.get(imageName) ?? defaultImage;
labels.push(
renderLabel(
row,
rowIndex,
x,
y,
width,
height,
image,
options,
warnings,
),
);
}
return pageSvg(
stock.pageWidthMm,
stock.pageHeightMm,
labels.join(""),
options,
pageIndex,
);
});
return { pages, totalPages, totalLabels: options.records.length, warnings };
}
function renderLabel(
record: MergeRecord,
rowIndex: number,
x: number,
y: number,
width: number,
height: number,
image: ImageAsset | undefined,
options: RenderOptions,
warnings: RenderWarning[],
): string {
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(
field(record, options.mapping.title),
width * 0.62,
height * 0.18,
16,
rowIndex,
"title",
warnings,
);
const subtitle = fitted(
field(record, options.mapping.subtitle),
width * 0.62,
height * 0.12,
10,
rowIndex,
"subtitle",
warnings,
);
const body = fitted(
field(record, options.mapping.body),
width * 0.85,
height * 0.24,
9,
rowIndex,
"body",
warnings,
3,
);
const barcode = field(record, options.mapping.barcode);
const content: string[] = [
`<defs><clipPath id="${id}"><rect x="${n(x)}" y="${n(y)}" width="${n(width)}" height="${n(height)}" rx="${n(Math.min(2, height * 0.05))}"/></clipPath></defs>`,
`<g clip-path="url(#${id})">`,
`<rect x="${n(x)}" y="${n(y)}" width="${n(width)}" height="${n(height)}" fill="#fff"/>`,
];
if (options.layout === "badge")
renderBadge(content, {
x,
y,
width,
height,
padding,
title,
subtitle,
body,
barcode,
image,
options,
rowIndex,
warnings,
});
else if (options.layout === "asset")
renderAsset(content, {
x,
y,
width,
height,
padding,
title,
subtitle,
body,
barcode,
image,
options,
rowIndex,
warnings,
});
else
renderAddress(content, {
x,
y,
width,
height,
padding,
title,
subtitle,
body,
barcode,
image,
options,
rowIndex,
warnings,
});
content.push("</g>");
if (options.cutMarks)
content.push(
`<rect x="${n(x)}" y="${n(y)}" width="${n(width)}" height="${n(height)}" rx="0.8" fill="none" stroke="#7b8794" stroke-width="0.18" stroke-dasharray="1 1"/>`,
);
return content.join("");
}
interface ContentContext {
x: number;
y: number;
width: number;
height: number;
padding: number;
title: FitText;
subtitle: FitText;
body: FitText;
barcode: string;
image?: ImageAsset;
options: RenderOptions;
rowIndex: number;
warnings: RenderWarning[];
}
function renderAddress(out: string[], context: ContentContext) {
const {
x,
y,
width,
height,
padding,
title,
subtitle,
body,
barcode,
image,
options,
rowIndex,
warnings,
} = context;
const imageWidth = image ? Math.min(width * 0.2, height * 0.32) : 0;
if (image)
out.push(
imageElement(image, x + padding, y + padding, imageWidth, imageWidth),
);
const textX = x + padding + (image ? imageWidth + padding : 0);
text(out, title, textX, y + padding + title.size, "700", options.fontFamily);
text(
out,
subtitle,
textX,
y + padding + title.size + subtitle.size * 1.35,
"600",
options.fontFamily,
);
multiline(out, body, x + padding, y + height * 0.49, options.fontFamily);
if (barcode)
barcodeElement(
out,
barcode,
options,
x + width * 0.63,
y + height * 0.56,
width * 0.33,
height * 0.35,
rowIndex,
warnings,
);
}
function renderBadge(out: string[], context: ContentContext) {
const {
x,
y,
width,
height,
padding,
title,
subtitle,
body,
barcode,
image,
options,
rowIndex,
warnings,
} = context;
const imageWidth = image ? Math.min(width * 0.28, height - padding * 2) : 0;
if (image)
out.push(
imageElement(
image,
x + padding,
y + padding,
imageWidth,
height - padding * 2,
),
);
const textX = x + padding + (image ? imageWidth + padding : 0);
text(out, title, textX, y + height * 0.34, "700", options.fontFamily);
text(out, subtitle, textX, y + height * 0.48, "600", options.fontFamily);
multiline(out, body, textX, y + height * 0.62, options.fontFamily);
if (barcode)
barcodeElement(
out,
barcode,
options,
x + width * 0.68,
y + height * 0.62,
width * 0.27,
height * 0.3,
rowIndex,
warnings,
);
}
function renderAsset(out: string[], context: ContentContext) {
const {
x,
y,
width,
height,
padding,
title,
subtitle,
body,
barcode,
image,
options,
rowIndex,
warnings,
} = context;
text(
out,
title,
x + padding,
y + padding + title.size,
"700",
options.fontFamily,
);
text(
out,
subtitle,
x + padding,
y + padding + title.size + subtitle.size * 1.45,
"600",
options.fontFamily,
);
multiline(out, body, x + padding, y + height * 0.46, options.fontFamily);
if (image)
out.push(
imageElement(
image,
x + width * 0.76,
y + padding,
width * 0.18,
height * 0.28,
),
);
if (barcode)
barcodeElement(
out,
barcode,
options,
x + padding,
y + height * 0.58,
width - padding * 2,
height * 0.34,
rowIndex,
warnings,
);
}
function barcodeElement(
output: string[],
value: string,
options: RenderOptions,
x: number,
y: number,
width: number,
height: number,
rowIndex: number,
warnings: RenderWarning[],
) {
try {
const barcode = barcodeFragment(
options.barcodeFormat,
value,
options.includeBarcodeText,
);
const [vx, vy, vw, vh] = barcode.viewBox;
const scale = Math.min(width / vw, height / vh);
const tx = x + (width - vw * scale) / 2 - vx * scale;
const ty = y + (height - vh * scale) / 2 - vy * scale;
output.push(
`<g transform="translate(${n(tx)} ${n(ty)}) scale(${n(scale)})">${barcode.body}</g>`,
);
} catch (error) {
warnings.push({
row: rowIndex + 1,
field: "barcode",
message: error instanceof Error ? error.message : String(error),
});
output.push(
`<rect x="${n(x)}" y="${n(y)}" width="${n(width)}" height="${n(height)}" fill="#fee2e2" stroke="#b42318" stroke-width="0.3"/><text x="${n(x + 1)}" y="${n(y + Math.min(4, height / 2))}" font-size="2.4" fill="#b42318">Barcode error</text>`,
);
}
}
function imageElement(
asset: ImageAsset,
x: number,
y: number,
width: number,
height: number,
): string {
return `<image x="${n(x)}" y="${n(y)}" width="${n(width)}" height="${n(height)}" href="${xml(asset.dataUrl)}" preserveAspectRatio="xMidYMid meet"/>`;
}
interface FitText {
lines: string[];
size: number;
truncated: boolean;
}
function fitted(
value: string,
width: number,
height: number,
preferredPt: number,
row: number,
fieldName: string,
warnings: RenderWarning[],
maxLines = 1,
): FitText {
const clean = [...value]
.map((character) => {
const code = character.codePointAt(0) ?? 0;
return code <= 0x1f || code === 0x7f ? " " : character;
})
.join("")
.replace(/\s+/gu, " ")
.trim();
if (!clean)
return { lines: [], size: preferredPt * 0.352778, truncated: false };
const preferred = preferredPt * 0.352778;
const size = Math.max(
1.35,
Math.min(preferred, (height / Math.max(1, maxLines)) * 0.78),
);
const characters = Math.max(1, Math.floor(width / (size * 0.56)));
const lines = wrap(clean, characters, maxLines);
const consumed = lines.join(" ").length;
const truncated = consumed < clean.length;
if (truncated)
warnings.push({
row: row + 1,
field: fieldName,
message: "Text was truncated to fit the selected layout.",
});
return { lines, size, truncated };
}
function wrap(value: string, characters: number, maxLines: number): string[] {
const words = value.split(" ");
const lines: string[] = [];
let line = "";
for (const word of words) {
const candidate = line ? `${line} ${word}` : word;
if (candidate.length <= characters) line = candidate;
else {
if (line) lines.push(line);
line = word.length > characters ? word.slice(0, characters) : word;
if (lines.length >= maxLines) break;
}
}
if (line && lines.length < maxLines) lines.push(line);
if (lines.length === maxLines && lines.join(" ").length < value.length)
lines[lines.length - 1] =
`${lines.at(-1)!.slice(0, Math.max(1, characters - 1))}`;
return lines;
}
function text(
output: string[],
content: FitText,
x: number,
y: number,
weight: string,
font: string,
) {
if (!content.lines[0]) return;
output.push(
`<text x="${n(x)}" y="${n(y)}" font-family="${xml(font)}" font-size="${n(content.size)}" font-weight="${weight}" fill="#111827">${xml(content.lines[0])}</text>`,
);
}
function multiline(
output: string[],
content: FitText,
x: number,
y: number,
font: string,
) {
if (!content.lines.length) return;
output.push(
`<text x="${n(x)}" y="${n(y)}" font-family="${xml(font)}" font-size="${n(content.size)}" fill="#374151">${content.lines.map((line, index) => `<tspan x="${n(x)}" dy="${index ? n(content.size * 1.25) : "0"}">${xml(line)}</tspan>`).join("")}</text>`,
);
}
function pageSvg(
width: number,
height: number,
labels: string,
options: RenderOptions,
page: number,
): string {
const marks = options.registrationMarks
? registrationMarks(width, height)
: "";
return `<svg xmlns="http://www.w3.org/2000/svg" width="${n(width)}mm" height="${n(height)}mm" viewBox="0 0 ${n(width)} ${n(height)}" role="img" aria-label="Label sheet page ${page + 1}"><rect width="100%" height="100%" fill="#fff"/>${marks}${labels}</svg>`;
}
function registrationMarks(width: number, height: number): string {
const points = [
[4, 4],
[width - 4, 4],
[4, height - 4],
[width - 4, height - 4],
];
return `<g stroke="#111" stroke-width="0.2">${points.map(([x, y]) => `<path d="M${n(x! - 2)} ${n(y!)}h4M${n(x!)} ${n(y! - 2)}v4"/>`).join("")}</g>`;
}
function field(record: MergeRecord, key: string): string {
return key ? (record[key] ?? "") : "";
}
function validateOptions(options: RenderOptions) {
const { calibration } = options;
if (
![
calibration.offsetXmm,
calibration.offsetYmm,
calibration.scaleX,
calibration.scaleY,
].every(Number.isFinite)
)
throw new Error("Calibration values must be finite.");
if (
Math.abs(calibration.offsetXmm) > 25 ||
Math.abs(calibration.offsetYmm) > 25 ||
calibration.scaleX < 0.8 ||
calibration.scaleX > 1.2 ||
calibration.scaleY < 0.8 ||
calibration.scaleY > 1.2
)
throw new Error(
"Calibration is outside the bounded ±25 mm / 80120% range.",
);
if (
!Number.isInteger(options.startPosition) ||
options.startPosition < 0 ||
options.startPosition >= stockCapacity(options.stock)
)
throw new Error("Start position is outside the selected stock.");
if (options.records.length > 2_000)
throw new Error("A merge is limited to 2,000 labels.");
if (!ALLOWED_FONTS.has(options.fontFamily))
throw new Error("Unsupported system font stack.");
}
function xml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
function n(value: number): string {
return Number(value.toFixed(4)).toString();
}
+156
View File
@@ -0,0 +1,156 @@
import type { LabelStock, Unit } from "./types";
export const MM_PER_INCH = 25.4;
export const PT_PER_INCH = 72;
export const STOCKS: readonly LabelStock[] = [
{
id: "a4-3x8",
name: "A4 · 3 × 8 general labels",
pageName: "A4",
pageWidthMm: 210,
pageHeightMm: 297,
columns: 3,
rows: 8,
labelWidthMm: 63.5,
labelHeightMm: 33.9,
marginLeftMm: 7.25,
marginTopMm: 12.9,
gapXmm: 2.5,
gapYmm: 0,
note: "Common 24-up A4 geometry; verify against the stock vendor template.",
},
{
id: "a4-l7160",
name: "A4 · 3 × 7 (63.5 × 38.1 mm)",
pageName: "A4",
pageWidthMm: 210,
pageHeightMm: 297,
columns: 3,
rows: 7,
labelWidthMm: 63.5,
labelHeightMm: 38.1,
marginLeftMm: 7.25,
marginTopMm: 15.15,
gapXmm: 2.5,
gapYmm: 0,
note: "Geometry commonly sold as L7160-compatible; verify your exact sheet.",
},
{
id: "a4-l7163",
name: "A4 · 2 × 7 (99.1 × 38.1 mm)",
pageName: "A4",
pageWidthMm: 210,
pageHeightMm: 297,
columns: 2,
rows: 7,
labelWidthMm: 99.1,
labelHeightMm: 38.1,
marginLeftMm: 4.65,
marginTopMm: 15.15,
gapXmm: 2.5,
gapYmm: 0,
note: "Geometry commonly sold as L7163-compatible; verify your exact sheet.",
},
{
id: "letter-5160",
name: "Letter · 3 × 10 (2.625 × 1 in)",
pageName: "US Letter",
pageWidthMm: 8.5 * MM_PER_INCH,
pageHeightMm: 11 * MM_PER_INCH,
columns: 3,
rows: 10,
labelWidthMm: 2.625 * MM_PER_INCH,
labelHeightMm: MM_PER_INCH,
marginLeftMm: 0.1875 * MM_PER_INCH,
marginTopMm: 0.5 * MM_PER_INCH,
gapXmm: 0.125 * MM_PER_INCH,
gapYmm: 0,
note: "Common 5160-compatible geometry; verify printer and stock measurements.",
},
{
id: "letter-5163",
name: "Letter · 2 × 5 (4 × 2 in)",
pageName: "US Letter",
pageWidthMm: 8.5 * MM_PER_INCH,
pageHeightMm: 11 * MM_PER_INCH,
columns: 2,
rows: 5,
labelWidthMm: 4 * MM_PER_INCH,
labelHeightMm: 2 * MM_PER_INCH,
marginLeftMm: 0.15625 * MM_PER_INCH,
marginTopMm: 0.5 * MM_PER_INCH,
gapXmm: 0.1875 * MM_PER_INCH,
gapYmm: 0,
note: "Common 5163-compatible geometry; verify printer and stock measurements.",
},
{
id: "letter-badge",
name: "Letter · 2 × 4 badge cards (4 × 2.5 in)",
pageName: "US Letter",
pageWidthMm: 8.5 * MM_PER_INCH,
pageHeightMm: 11 * MM_PER_INCH,
columns: 2,
rows: 4,
labelWidthMm: 4 * MM_PER_INCH,
labelHeightMm: 2.5 * MM_PER_INCH,
marginLeftMm: 0.15625 * MM_PER_INCH,
marginTopMm: 0.5 * MM_PER_INCH,
gapXmm: 0.1875 * MM_PER_INCH,
gapYmm: 0.3333 * MM_PER_INCH,
note: "Generic badge-card geometry, not a vendor guarantee.",
},
] as const;
export function toMillimetres(value: number, unit: Unit): number {
if (!Number.isFinite(value)) throw new Error("Dimension must be finite.");
if (unit === "in") return value * MM_PER_INCH;
if (unit === "pt") return (value * MM_PER_INCH) / PT_PER_INCH;
return value;
}
export function fromMillimetres(value: number, unit: Unit): number {
if (unit === "in") return value / MM_PER_INCH;
if (unit === "pt") return (value * PT_PER_INCH) / MM_PER_INCH;
return value;
}
export function validateStock(stock: LabelStock): LabelStock {
const finitePositive: (keyof LabelStock)[] = [
"pageWidthMm",
"pageHeightMm",
"labelWidthMm",
"labelHeightMm",
"columns",
"rows",
];
for (const key of finitePositive) {
const value = stock[key];
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
throw new Error(`${key} must be positive.`);
}
if (
!Number.isInteger(stock.columns) ||
!Number.isInteger(stock.rows) ||
stock.columns > 20 ||
stock.rows > 50
)
throw new Error("Grid rows and columns must be bounded integers.");
if (stock.pageWidthMm > 1_000 || stock.pageHeightMm > 1_000)
throw new Error("Page dimensions cannot exceed 1,000 mm.");
const right =
stock.marginLeftMm +
stock.columns * stock.labelWidthMm +
(stock.columns - 1) * stock.gapXmm;
const bottom =
stock.marginTopMm +
stock.rows * stock.labelHeightMm +
(stock.rows - 1) * stock.gapYmm;
if (right > stock.pageWidthMm + 0.01 || bottom > stock.pageHeightMm + 0.01)
throw new Error("The label grid does not fit on the page.");
return stock;
}
export function stockCapacity(stock: LabelStock): number {
return stock.columns * stock.rows;
}
+93
View File
@@ -0,0 +1,93 @@
export type Unit = "mm" | "in" | "pt";
export type LayoutKind = "address" | "badge" | "asset";
export type BarcodeFormat =
| "qrcode"
| "code128"
| "gs1-128"
| "ean13"
| "ean8"
| "upca"
| "code39"
| "datamatrix"
| "pdf417"
| "azteccode";
export type MergeRecord = Record<string, string>;
export interface LabelStock {
id: string;
name: string;
pageName: string;
pageWidthMm: number;
pageHeightMm: number;
columns: number;
rows: number;
labelWidthMm: number;
labelHeightMm: number;
marginLeftMm: number;
marginTopMm: number;
gapXmm: number;
gapYmm: number;
note: string;
}
export interface Calibration {
offsetXmm: number;
offsetYmm: number;
scaleX: number;
scaleY: number;
}
export interface FieldMapping {
title: string;
subtitle: string;
body: string;
barcode: string;
image: string;
}
export interface ImageAsset {
name: string;
mimeType: string;
dataUrl: string;
width: number;
height: number;
bytes: number;
}
export interface RenderOptions {
stock: LabelStock;
calibration: Calibration;
layout: LayoutKind;
mapping: FieldMapping;
barcodeFormat: BarcodeFormat;
includeBarcodeText: boolean;
fontFamily: string;
records: MergeRecord[];
images: ImageAsset[];
defaultImage: string;
cutMarks: boolean;
registrationMarks: boolean;
startPosition: number;
}
export interface RenderWarning {
row: number;
field: string;
message: string;
}
export interface RenderResult {
pages: string[];
totalPages: number;
totalLabels: number;
warnings: RenderWarning[];
}
export interface GeneratorOptions {
count: number;
serialPrefix: string;
serialStart: number;
serialPadding: number;
randomLength: number;
}