Release Label Tools 0.1.0
This commit is contained in:
@@ -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 1–4,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],
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 / 80–120% 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function n(value: number): string {
|
||||
return Number(value.toFixed(4)).toString();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user