Files
klz-cables.com/scripts/generate-pdf-datasheets.ts
2026-01-06 23:15:24 +01:00

1616 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env ts-node
/**
* PDF Datasheet Generator - Industrial Engineering Documentation Style
* STYLEGUIDE.md compliant: industrial, technical, restrained
*/
import * as fs from 'fs';
import * as path from 'path';
import { PDFDocument, rgb, StandardFonts, PDFFont, PDFPage, PDFImage } from 'pdf-lib';
let sharpFn: ((input?: any, options?: any) => any) | null = null;
async function getSharp(): Promise<(input?: any, options?: any) => any> {
if (sharpFn) return sharpFn;
// `sharp` is CJS but this script runs as ESM via ts-node.
// Dynamic import gives stable interop.
const mod: any = await import('sharp');
sharpFn = (mod?.default || mod) as (input?: any, options?: any) => any;
return sharpFn;
}
const CONFIG = {
productsFile: path.join(process.cwd(), 'data/processed/products.json'),
outputDir: path.join(process.cwd(), 'public/datasheets'),
chunkSize: 10,
siteUrl: 'https://klz-cables.com',
};
const ASSET_MAP_FILE = path.join(process.cwd(), 'data/processed/asset-map.json');
const PUBLIC_DIR = path.join(process.cwd(), 'public');
type AssetMap = Record<string, string>;
function readAssetMap(): AssetMap {
try {
if (!fs.existsSync(ASSET_MAP_FILE)) return {};
return JSON.parse(fs.readFileSync(ASSET_MAP_FILE, 'utf8')) as AssetMap;
} catch {
return {};
}
}
const ASSET_MAP: AssetMap = readAssetMap();
interface ProductData {
id: number;
name: string;
shortDescriptionHtml: string;
descriptionHtml: string;
images: string[];
featuredImage: string | null;
sku: string;
slug?: string;
path?: string;
translationKey?: string;
locale?: 'en' | 'de';
categories: Array<{ name: string }>;
attributes: Array<{
name: string;
options: string[];
}>;
}
function getProductUrl(product: ProductData): string | null {
if (!product.path) return null;
return `https://klz-cables.com${product.path}`;
}
function drawKeyValueGrid(args: {
title: string;
items: Array<{ label: string; value: string }>;
newPage: () => number;
getPage: () => PDFPage;
page: PDFPage;
y: number;
margin: number;
contentWidth: number;
contentMinY: number;
font: PDFFont;
fontBold: PDFFont;
navy: ReturnType<typeof rgb>;
darkGray: ReturnType<typeof rgb>;
mediumGray: ReturnType<typeof rgb>;
lightGray?: ReturnType<typeof rgb>;
almostWhite?: ReturnType<typeof rgb>;
allowNewPage?: boolean;
boxed?: boolean;
}): number {
let { title, items, newPage, getPage, page, y, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, mediumGray } = args;
const allowNewPage = args.allowNewPage ?? true;
const boxed = args.boxed ?? false;
const lightGray = args.lightGray ?? rgb(0.9020, 0.9137, 0.9294);
const almostWhite = args.almostWhite ?? rgb(0.9725, 0.9765, 0.9804);
// Inner layout (boxed vs. plain)
// Keep a strict spacing system for more professional datasheets.
const padX = boxed ? 16 : 0;
const padY = boxed ? 14 : 0;
const xBase = margin + padX;
const innerWidth = contentWidth - padX * 2;
const colGap = 16;
const colW = (innerWidth - colGap) / 2;
const rowH = 24;
const headerH = boxed ? 22 : 0;
// Draw a strict rectangular section container (no rounding)
if (boxed && items.length) {
const rows = Math.ceil(items.length / 2);
const boxH = padY + headerH + rows * rowH + padY;
const bottomY = y - boxH;
if (bottomY < contentMinY) {
if (!allowNewPage) return contentMinY - 1;
y = newPage();
}
page = getPage();
page.drawRectangle({
x: margin,
y: y - boxH,
width: contentWidth,
height: boxH,
borderColor: lightGray,
borderWidth: 1,
color: rgb(1, 1, 1),
});
// Header band for the title
page.drawRectangle({
x: margin,
y: y - headerH,
width: contentWidth,
height: headerH,
color: almostWhite,
});
}
const drawTitle = () => {
page = getPage();
if (boxed) {
// Align title inside the header band.
page.drawText(title, { x: xBase, y: y - 15, size: 11, font: fontBold, color: navy });
// Divider line below header band
page.drawLine({
start: { x: margin, y: y - headerH },
end: { x: margin + contentWidth, y: y - headerH },
thickness: 0.75,
color: lightGray,
});
y -= headerH + padY;
} else {
page.drawText(title, { x: margin, y, size: 10, font: fontBold, color: navy });
y -= 16;
}
};
if (y - 22 < contentMinY) {
if (!allowNewPage) return contentMinY - 1;
y = newPage();
}
page = getPage();
drawTitle();
let rowY = y;
for (let i = 0; i < items.length; i++) {
const col = i % 2;
const x = xBase + col * (colW + colGap);
const { label, value } = items[i];
if (col === 0 && rowY - rowH < contentMinY) {
if (!allowNewPage) return contentMinY - 1;
y = newPage();
page = getPage();
drawTitle();
rowY = y;
}
page.drawText(label, { x, y: rowY, size: 7.5, font: fontBold, color: mediumGray, maxWidth: colW });
page.drawText(value, { x, y: rowY - 12, size: 9.5, font, color: darkGray, maxWidth: colW });
if (col === 1) rowY -= rowH;
}
return boxed ? rowY - rowH - padY : rowY - rowH;
}
function ensureOutputDir(): void {
if (!fs.existsSync(CONFIG.outputDir)) {
fs.mkdirSync(CONFIG.outputDir, { recursive: true });
}
}
const stripHtml = (html: string): string => {
if (!html) return '';
// IMPORTANT: Keep umlauts and common Latin-1 chars (e.g. ü/ö/ä/ß) for DE PDFs.
// pdf-lib's StandardFonts cover WinAnsi; we only normalize “problematic” typography.
let text = html.replace(/<[^>]*>/g, '').normalize('NFC');
text = text
// whitespace normalization
.replace(/[\u00A0\u202F]/g, ' ') // nbsp / narrow nbsp
// typography normalization
.replace(/[\u2013\u2014]/g, '-') // en/em dash
.replace(/[\u2018\u2019]/g, "'") // curly single quotes
.replace(/[\u201C\u201D]/g, '"') // curly double quotes
.replace(/\u2026/g, '...') // ellipsis
// symbols that can be missing in some encodings
.replace(/[\u2022]/g, '·') // bullet
// math symbols (WinAnsi can't encode these)
.replace(/[\u2264]/g, '<=') // ≤
.replace(/[\u2265]/g, '>=') // ≥
.replace(/[\u2248]/g, '~') // ≈
// electrical symbols (keep meaning; avoid encoding errors)
.replace(/[\u03A9\u2126]/g, 'Ohm') // Ω / Ω
// micro sign / greek mu (WinAnsi can't encode these reliably)
.replace(/[\u00B5\u03BC]/g, 'u'); // µ / μ
// Remove control chars, keep all printable unicode.
text = text.replace(/[\u0000-\u001F\u007F]/g, '');
return text.replace(/\s+/g, ' ').trim();
};
const getLabels = (locale: 'en' | 'de') => ({
en: {
datasheet: 'PRODUCT DATASHEET',
description: 'DESCRIPTION',
specs: 'TECHNICAL SPECIFICATIONS',
crossSection: 'CROSS-SECTION DATA',
categories: 'CATEGORIES',
sku: 'SKU',
},
de: {
datasheet: 'PRODUKTDATENBLATT',
description: 'BESCHREIBUNG',
specs: 'TECHNISCHE SPEZIFIKATIONEN',
crossSection: 'QUERSCHNITTSDATEN',
categories: 'KATEGORIEN',
sku: 'ARTIKELNUMMER',
},
})[locale];
const generateFileName = (product: ProductData, locale: 'en' | 'de'): string => {
const baseName = product.slug || product.translationKey || `product-${product.id}`;
const cleanSlug = baseName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
return `${cleanSlug}-${locale}.pdf`;
};
function wrapText(text: string, font: PDFFont, fontSize: number, maxWidth: number): string[] {
const words = text.split(' ');
const lines: string[] = [];
let currentLine = '';
for (const word of words) {
const testLine = currentLine ? `${currentLine} ${word}` : word;
if (font.widthOfTextAtSize(testLine, fontSize) <= maxWidth) {
currentLine = testLine;
} else {
if (currentLine) lines.push(currentLine);
currentLine = word;
}
}
if (currentLine) lines.push(currentLine);
return lines;
}
function resolveMediaToLocalPath(urlOrPath: string | null | undefined): string | null {
if (!urlOrPath) return null;
// 1) Already public-relative.
if (urlOrPath.startsWith('/')) return urlOrPath;
// 2) Some datasets store "media/..." without leading slash.
if (/^media\//i.test(urlOrPath)) return `/${urlOrPath}`;
// 3) Asset-map can return a few different shapes; normalize them.
const mapped = ASSET_MAP[urlOrPath];
if (mapped) {
if (mapped.startsWith('/')) return mapped;
if (/^public\//i.test(mapped)) return `/${mapped.replace(/^public\//i, '')}`;
if (/^media\//i.test(mapped)) return `/${mapped}`;
return mapped;
}
// 4) Fallback (remote URL or unrecognized local path).
return urlOrPath;
}
async function fetchBytes(url: string): Promise<Uint8Array> {
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
return new Uint8Array(await res.arrayBuffer());
}
async function readBytesFromPublic(localPath: string): Promise<Uint8Array> {
const abs = path.join(PUBLIC_DIR, localPath.replace(/^\//, ''));
return new Uint8Array(fs.readFileSync(abs));
}
function transformLogoSvgToPrintBlack(svg: string): string {
// Our source logo is white-on-transparent (for dark headers). For print (white page), we need dark fills.
// Keep it simple: replace fill white with KLZ navy.
return svg
.replace(/fill\s*:\s*white/gi, 'fill:#0E2A47')
.replace(/fill\s*=\s*"white"/gi, 'fill="#0E2A47"')
.replace(/fill\s*=\s*'white'/gi, "fill='#0E2A47'");
}
async function toPngBytes(inputBytes: Uint8Array, inputHint: string): Promise<Uint8Array> {
// pdf-lib supports PNG/JPG. We normalize everything (webp/svg/jpg/png) to PNG to keep embedding simple.
const ext = (path.extname(inputHint).toLowerCase() || '').replace('.', '');
if (ext === 'png') return inputBytes;
// Special-case the logo SVG to render as dark for print.
if (ext === 'svg' && /\/media\/logo\.svg$/i.test(inputHint)) {
const svg = Buffer.from(inputBytes).toString('utf8');
inputBytes = new Uint8Array(Buffer.from(transformLogoSvgToPrintBlack(svg), 'utf8'));
}
const sharp = await getSharp();
// Preserve alpha where present (some product images are transparent).
return new Uint8Array(await sharp(Buffer.from(inputBytes)).png().toBuffer());
}
type TableColumn = {
label: string;
get: (rowIndex: number) => string;
};
function buildProductAttrIndex(product: ProductData): Record<string, ProductData['attributes'][number]> {
const idx: Record<string, ProductData['attributes'][number]> = {};
for (const a of product.attributes || []) {
idx[normalizeValue(a.name).toLowerCase()] = a;
}
return idx;
}
function getAttrCellValue(attr: ProductData['attributes'][number] | undefined, rowIndex: number, rowCount: number): string {
if (!attr) return '';
if (!attr.options || attr.options.length === 0) return '';
if (rowCount > 0 && attr.options.length === rowCount) return normalizeValue(attr.options[rowIndex]);
if (attr.options.length === 1) return normalizeValue(attr.options[0]);
// Unknown mapping: do NOT guess (this was the main source of "wrong" tables).
return '';
}
function drawTableChunked(args: {
title: string;
configRows: string[];
columns: Array<{ key: string; label: string; get: (rowIndex: number) => string }>;
locale: 'en' | 'de';
newPage: () => number;
getPage: () => PDFPage;
page: PDFPage;
y: number;
margin: number;
contentWidth: number;
contentMinY: number;
font: PDFFont;
fontBold: PDFFont;
navy: ReturnType<typeof rgb>;
darkGray: ReturnType<typeof rgb>;
lightGray: ReturnType<typeof rgb>;
almostWhite: ReturnType<typeof rgb>;
maxDataColsPerTable: number;
}): number {
let {
title,
configRows,
columns,
newPage,
getPage,
page,
y,
margin,
contentWidth,
contentMinY,
font,
fontBold,
navy,
darkGray,
lightGray,
almostWhite,
maxDataColsPerTable,
} = args;
const headerH = 16;
const rowH = 13;
// Always include configuration as first col.
const configCol = {
key: 'configuration',
label: args.locale === 'de' ? 'Konfiguration' : 'Configuration',
get: (i: number) => normalizeValue(configRows[i] || ''),
};
const chunks: Array<typeof columns> = [];
for (let i = 0; i < columns.length; i += maxDataColsPerTable) {
chunks.push(columns.slice(i, i + maxDataColsPerTable));
}
for (let ci = 0; ci < Math.max(1, chunks.length); ci++) {
// Ensure we always draw on the current page reference.
page = getPage();
const chunkCols = chunks.length ? chunks[ci] : [];
const chunkTitle = chunks.length > 1 ? `${title} (${ci + 1}/${chunks.length})` : title;
const tableCols: TableColumn[] = [configCol, ...chunkCols];
// Width distribution (keeps configuration readable)
const configW = 0.32;
const remainingW = 1 - configW;
const perW = remainingW / Math.max(1, tableCols.length - 1);
const widths = tableCols.map((_, idx) => (idx === 0 ? configW : perW));
const ensureSpace = (needed: number) => {
if (y - needed < contentMinY) y = newPage();
page = getPage();
};
ensureSpace(18 + headerH + rowH * 2);
page.drawText(chunkTitle, {
x: margin,
y,
size: 10,
font: fontBold,
color: navy,
});
y -= 16;
// If we are too close to the footer after the title, break before drawing the header.
if (y - headerH - rowH < contentMinY) {
y = newPage();
page = getPage();
page.drawText(chunkTitle, {
x: margin,
y,
size: 10,
font: fontBold,
color: navy,
});
y -= 16;
}
const drawHeader = () => {
page = getPage();
page.drawRectangle({
x: margin,
y: y - headerH,
width: contentWidth,
height: headerH,
color: lightGray,
});
let x = margin;
for (let i = 0; i < tableCols.length; i++) {
page.drawText(tableCols[i].label, {
x: x + 6,
y: y - 11,
size: 8,
font: fontBold,
color: navy,
maxWidth: contentWidth * widths[i] - 12,
});
x += contentWidth * widths[i];
}
y -= headerH;
};
drawHeader();
for (let r = 0; r < configRows.length; r++) {
if (y - rowH < contentMinY) {
y = newPage();
page = getPage();
page.drawText(chunkTitle, {
x: margin,
y,
size: 12,
font: fontBold,
color: navy,
});
y -= 16;
drawHeader();
}
if (r % 2 === 0) {
page.drawRectangle({
x: margin,
y: y - rowH,
width: contentWidth,
height: rowH,
color: almostWhite,
});
}
let x = margin;
for (let c = 0; c < tableCols.length; c++) {
page.drawText(tableCols[c].get(r), {
x: x + 6,
y: y - 10,
size: 8,
font,
color: darkGray,
maxWidth: contentWidth * widths[c] - 12,
});
x += contentWidth * widths[c];
}
y -= rowH;
}
y -= 18;
}
return y;
}
async function loadEmbeddablePng(
src: string | null | undefined,
): Promise<{ pngBytes: Uint8Array; debugLabel: string } | null> {
const resolved = resolveMediaToLocalPath(src);
if (!resolved) return null;
try {
// Prefer local files for stability and speed.
if (resolved.startsWith('/')) {
const bytes = await readBytesFromPublic(resolved);
return { pngBytes: await toPngBytes(bytes, resolved), debugLabel: resolved };
}
// Remote (fallback)
const bytes = await fetchBytes(resolved);
return { pngBytes: await toPngBytes(bytes, resolved), debugLabel: resolved };
} catch {
return null;
}
}
async function loadQrPng(data: string): Promise<{ pngBytes: Uint8Array; debugLabel: string } | null> {
// External QR generator (no extra dependency). This must stay resilient; if it fails, we fall back to URL text.
try {
const safe = encodeURIComponent(data);
const url = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${safe}`;
const bytes = await fetchBytes(url);
// Already PNG but normalize anyway.
return { pngBytes: await toPngBytes(bytes, url), debugLabel: url };
} catch {
return null;
}
}
type SectionDrawContext = {
pdfDoc: PDFDocument;
page: PDFPage;
width: number;
height: number;
margin: number;
contentWidth: number;
footerY: number;
contentMinY: number;
headerDividerY: number;
colors: {
navy: ReturnType<typeof rgb>;
mediumGray: ReturnType<typeof rgb>;
darkGray: ReturnType<typeof rgb>;
almostWhite: ReturnType<typeof rgb>;
lightGray: ReturnType<typeof rgb>;
headerBg: ReturnType<typeof rgb>;
};
fonts: {
regular: PDFFont;
bold: PDFFont;
};
labels: ReturnType<typeof getLabels>;
product: ProductData;
locale: 'en' | 'de';
logoImage: PDFImage | null;
qrImage: PDFImage | null;
qrUrl: string;
};
function drawFooter(ctx: SectionDrawContext): void {
const { page, width, margin, footerY, fonts, colors, locale } = ctx;
page.drawLine({
start: { x: margin, y: footerY + 14 },
end: { x: width - margin, y: footerY + 14 },
thickness: 0.75,
color: colors.lightGray,
});
// Left: site URL (always)
page.drawText(CONFIG.siteUrl, {
x: margin,
y: footerY,
size: 8,
font: fonts.regular,
color: colors.mediumGray,
});
const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
// Right: date + page number (page number filled in after rendering)
const rightText = dateStr;
page.drawText(rightText, {
x: width - margin - fonts.regular.widthOfTextAtSize(rightText, 8),
y: footerY,
size: 8,
font: fonts.regular,
color: colors.mediumGray,
});
}
function stampPageNumbers(pdfDoc: PDFDocument, fonts: { regular: PDFFont }, colors: { mediumGray: ReturnType<typeof rgb> }, margin: number, footerY: number): void {
const pages = pdfDoc.getPages();
const total = pages.length;
for (let i = 0; i < total; i++) {
const page = pages[i];
const { width } = page.getSize();
const text = `${i + 1}/${total}`;
page.drawText(text, {
x: width - margin - fonts.regular.widthOfTextAtSize(text, 8),
y: footerY - 12,
size: 8,
font: fonts.regular,
color: colors.mediumGray,
});
}
}
function drawHeader(ctx: SectionDrawContext, yStart: number): number {
const { page, width, margin, contentWidth, fonts, colors, logoImage, qrImage, qrUrl, labels, product } = ctx;
// Cable-industry look: calm, engineered header with right-aligned meta.
const headerH = 64;
const dividerY = yStart - headerH;
ctx.headerDividerY = dividerY;
page.drawRectangle({
x: 0,
y: dividerY,
width,
height: headerH,
color: colors.headerBg,
});
const qrSize = 44;
const qrGap = 12;
const rightReserved = qrImage ? qrSize + qrGap : 0;
// Left: logo (preferred) or typographic fallback
if (logoImage) {
const maxLogoW = 120;
const maxLogoH = 30;
const scale = Math.min(maxLogoW / logoImage.width, maxLogoH / logoImage.height);
const w = logoImage.width * scale;
const h = logoImage.height * scale;
const logoY = dividerY + Math.round((headerH - h) / 2);
page.drawImage(logoImage, {
x: margin,
y: logoY,
width: w,
height: h,
});
} else {
const baseY = dividerY + 22;
page.drawText('KLZ', {
x: margin,
y: baseY,
size: 22,
font: fonts.bold,
color: colors.navy,
});
page.drawText('Cables', {
x: margin + fonts.bold.widthOfTextAtSize('KLZ', 22) + 4,
y: baseY + 2,
size: 10,
font: fonts.regular,
color: colors.mediumGray,
});
}
// Right: datasheet meta + QR (if available)
const metaRightEdge = width - margin - rightReserved;
const metaTitle = labels.datasheet;
const metaTitleSize = 9;
const metaSkuSize = 8;
const skuText = product.sku ? `${labels.sku}: ${stripHtml(product.sku)}` : '';
const mtW = fonts.bold.widthOfTextAtSize(metaTitle, metaTitleSize);
page.drawText(metaTitle, {
x: metaRightEdge - mtW,
y: dividerY + 38,
size: metaTitleSize,
font: fonts.bold,
color: colors.navy,
});
if (skuText) {
const skuW = fonts.regular.widthOfTextAtSize(skuText, metaSkuSize);
page.drawText(skuText, {
x: metaRightEdge - skuW,
y: dividerY + 24,
size: metaSkuSize,
font: fonts.regular,
color: colors.mediumGray,
});
}
if (qrImage) {
const qrX = width - margin - qrSize;
const qrY = dividerY + Math.round((headerH - qrSize) / 2);
page.drawImage(qrImage, { x: qrX, y: qrY, width: qrSize, height: qrSize });
} else {
// If QR generation failed, keep the URL available as a compact line.
const maxW = 260;
const urlLines = wrapText(qrUrl, fonts.regular, 8, maxW).slice(0, 1);
if (urlLines.length) {
const line = urlLines[0];
const w = fonts.regular.widthOfTextAtSize(line, 8);
page.drawText(line, {
x: width - margin - w,
y: dividerY + 12,
size: 8,
font: fonts.regular,
color: colors.mediumGray,
});
}
}
// Divider line
page.drawLine({
start: { x: margin, y: dividerY },
end: { x: margin + contentWidth, y: dividerY },
thickness: 0.75,
color: colors.lightGray,
});
// Content start: provide real breathing room below the header.
return dividerY - 40;
}
function drawCrossSectionChipsRow(args: {
title: string;
configRows: string[];
locale: 'en' | 'de';
getPage: () => PDFPage;
page: PDFPage;
y: number;
margin: number;
contentWidth: number;
contentMinY: number;
font: PDFFont;
fontBold: PDFFont;
navy: ReturnType<typeof rgb>;
darkGray: ReturnType<typeof rgb>;
mediumGray: ReturnType<typeof rgb>;
lightGray: ReturnType<typeof rgb>;
almostWhite: ReturnType<typeof rgb>;
}): number {
let { title, configRows, locale, getPage, page, y, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, mediumGray, lightGray, almostWhite } = args;
// Single-page rule: if we can't fit the block, stop.
const titleH = 12;
const summaryH = 12;
const chipH = 16;
const lineGap = 8;
const gapY = 10;
const minLines = 2;
const needed = titleH + summaryH + (chipH * minLines) + (lineGap * (minLines - 1)) + gapY;
if (y - needed < contentMinY) return contentMinY - 1;
page = getPage();
// Normalize: keep only cross-section part, de-dupe, sort.
const itemsRaw = configRows
.map(r => splitConfig(r).crossSection)
.map(s => normalizeValue(s))
.filter(Boolean);
const seen = new Set<string>();
const items = itemsRaw.filter(v => (seen.has(v) ? false : (seen.add(v), true)));
items.sort((a, b) => {
const pa = parseCoresAndMm2(a);
const pb = parseCoresAndMm2(b);
if (pa.cores !== null && pb.cores !== null && pa.cores !== pb.cores) return pa.cores - pb.cores;
if (pa.mm2 !== null && pb.mm2 !== null && pa.mm2 !== pb.mm2) return pa.mm2 - pb.mm2;
return a.localeCompare(b);
});
const total = items.length;
const parsed = items.map(parseCoresAndMm2).filter(p => p.cores !== null && p.mm2 !== null) as Array<{ cores: number; mm2: number }>;
const uniqueCores = Array.from(new Set(parsed.map(p => p.cores))).sort((a, b) => a - b);
const mm2Vals = parsed.map(p => p.mm2).sort((a, b) => a - b);
const mm2Min = mm2Vals.length ? mm2Vals[0] : null;
const mm2Max = mm2Vals.length ? mm2Vals[mm2Vals.length - 1] : null;
page.drawText(title, { x: margin, y, size: 11, font: fontBold, color: navy });
y -= titleH;
const summaryParts: string[] = [];
summaryParts.push(locale === 'de' ? `Varianten: ${total}` : `Options: ${total}`);
if (uniqueCores.length) summaryParts.push((locale === 'de' ? 'Adern' : 'Cores') + `: ${uniqueCores.join(', ')}`);
if (mm2Min !== null && mm2Max !== null) summaryParts.push(`mm²: ${mm2Min}${mm2Max !== mm2Min ? `${mm2Max}` : ''}`);
page.drawText(summaryParts.join(' · '), { x: margin, y, size: 8, font, color: mediumGray, maxWidth: contentWidth });
y -= summaryH;
// Tags (wrapping). Rectangular, engineered (no playful rounding).
const padX = 8;
const chipFontSize = 8;
const chipGap = 8;
const chipPadTop = 5;
const startY = y - chipH; // baseline for first chip row
const maxLines = Math.max(1, Math.floor((startY - contentMinY + lineGap) / (chipH + lineGap)));
const chipWidth = (text: string) => font.widthOfTextAtSize(text, chipFontSize) + padX * 2;
type Placement = { text: string; x: number; y: number; w: number; variant: 'normal' | 'more' };
const layout = (texts: string[], includeMoreChip: boolean, moreText: string): { placements: Placement[]; shown: number } => {
const placements: Placement[] = [];
let x = margin;
let line = 0;
let cy = startY;
const advanceLine = () => {
line += 1;
if (line >= maxLines) return false;
x = margin;
cy -= chipH + lineGap;
return true;
};
const tryPlace = (text: string, variant: 'normal' | 'more'): boolean => {
const w = chipWidth(text);
if (w > contentWidth) return false;
if (x + w > margin + contentWidth) {
if (!advanceLine()) return false;
}
placements.push({ text, x, y: cy, w, variant });
x += w + chipGap;
return true;
};
let shown = 0;
for (let i = 0; i < texts.length; i++) {
if (!tryPlace(texts[i], 'normal')) break;
shown++;
}
if (includeMoreChip) {
tryPlace(moreText, 'more');
}
return { placements, shown };
};
// Group by cores: label on the left, mm² tags to the right.
const byCores = new Map<number, number[]>();
const other: string[] = [];
for (const cs of items) {
const p = parseCoresAndMm2(cs);
if (p.cores !== null && p.mm2 !== null) {
const arr = byCores.get(p.cores) ?? [];
arr.push(p.mm2);
byCores.set(p.cores, arr);
} else {
other.push(cs);
}
}
const coreKeys = Array.from(byCores.keys()).sort((a, b) => a - b);
for (const k of coreKeys) {
const uniq = Array.from(new Set(byCores.get(k) ?? [])).sort((a, b) => a - b);
byCores.set(k, uniq);
}
const fmtMm2 = (v: number) => {
const s = Number.isInteger(v) ? String(v) : String(v).replace(/\.0+$/, '');
return s;
};
// Layout engine with group labels.
const labelW = 38;
const placements: Placement[] = [];
let line = 0;
let cy = startY;
let x = margin + labelW;
const canAdvanceLine = () => line + 1 < maxLines;
const advanceLine = () => {
if (!canAdvanceLine()) return false;
line += 1;
cy -= chipH + lineGap;
x = margin + labelW;
return true;
};
const drawGroupLabel = (label: string) => {
// Draw label on each new line for the group (keeps readability when wrapping).
page.drawText(label, {
x: margin,
y: cy + 4,
size: 8,
font: fontBold,
color: mediumGray,
maxWidth: labelW - 4,
});
};
const placeChip = (text: string, variant: 'normal' | 'more') => {
const w = chipWidth(text);
if (w > contentWidth - labelW) return false;
if (x + w > margin + contentWidth) {
if (!advanceLine()) return false;
}
placements.push({ text, x, y: cy, w, variant });
x += w + chipGap;
return true;
};
let truncated = false;
let renderedCount = 0;
const totalChips = coreKeys.reduce((sum, k) => sum + (byCores.get(k)?.length ?? 0), 0) + other.length;
for (const cores of coreKeys) {
const values = byCores.get(cores) ?? [];
const label = `${cores}×`;
// Ensure label is shown at least once per line block.
drawGroupLabel(label);
for (const v of values) {
const ok = placeChip(fmtMm2(v), 'normal');
if (!ok) {
truncated = true;
break;
}
renderedCount++;
}
if (truncated) break;
// Add a tiny gap between core groups (only if we have room on the current line)
x += 4;
if (x > margin + contentWidth - 20) {
if (!advanceLine()) {
// out of vertical space; stop
truncated = true;
break;
}
}
}
if (!truncated && other.length) {
const label = locale === 'de' ? 'Sonst.' : 'Other';
drawGroupLabel(label);
for (const t of other) {
const ok = placeChip(t, 'normal');
if (!ok) {
truncated = true;
break;
}
renderedCount++;
}
}
if (truncated) {
const remaining = Math.max(0, totalChips - renderedCount);
const moreText = locale === 'de' ? `+${remaining} weitere` : `+${remaining} more`;
// Try to place on current line; if not possible, try next line.
if (!placeChip(moreText, 'more')) {
if (advanceLine()) {
placeChip(moreText, 'more');
}
}
}
// Draw placements
for (const p of placements) {
page.drawRectangle({
x: p.x,
y: p.y,
width: p.w,
height: chipH,
borderColor: lightGray,
borderWidth: 1,
color: rgb(1, 1, 1),
});
page.drawText(p.text, {
x: p.x + padX,
y: p.y + chipPadTop,
size: chipFontSize,
font,
color: p.variant === 'more' ? navy : darkGray,
maxWidth: p.w - padX * 2,
});
}
// Return cursor below the last line drawn
const linesUsed = placements.length ? Math.max(...placements.map(p => Math.round((startY - p.y) / (chipH + lineGap)))) + 1 : 1;
const bottomY = startY - (linesUsed - 1) * (chipH + lineGap);
// Consistent section spacing after block.
// IMPORTANT: never return below contentMinY if we actually rendered,
// otherwise callers may think it "didn't fit" and draw a fallback on top (duplicate “Options” lines).
return Math.max(bottomY - 24, contentMinY);
}
function drawCompactList(args: {
items: string[];
x: number;
y: number;
colW: number;
cols: number;
rowH: number;
maxRows: number;
page: PDFPage;
font: PDFFont;
fontSize: number;
color: ReturnType<typeof rgb>;
}): number {
const { items, x, colW, cols, rowH, maxRows, page, font, fontSize, color } = args;
let y = args.y;
const shown = items.slice(0, cols * maxRows);
for (let i = 0; i < shown.length; i++) {
const col = Math.floor(i / maxRows);
const row = i % maxRows;
const ix = x + col * colW;
const iy = y - row * rowH;
page.drawText(shown[i], {
x: ix,
y: iy,
size: fontSize,
font,
color,
maxWidth: colW - 6,
});
}
return y - maxRows * rowH;
}
function findAttr(product: ProductData, includes: RegExp): ProductData['attributes'][number] | undefined {
return product.attributes?.find(a => includes.test(a.name));
}
function normalizeValue(value: string): string {
return stripHtml(value).replace(/\s+/g, ' ').trim();
}
function splitConfig(config: string): { crossSection: string; voltage: string } {
const raw = normalizeValue(config);
const parts = raw.split(/\s*-\s*/);
if (parts.length >= 2) {
return { crossSection: parts[0], voltage: parts.slice(1).join(' - ') };
}
return { crossSection: raw, voltage: '' };
}
function parseCoresAndMm2(crossSection: string): { cores: number | null; mm2: number | null } {
const s = normalizeValue(crossSection)
.replace(/\s+/g, '')
.replace(/×/g, 'x')
.replace(/,/g, '.');
// Typical: 3x1.5, 4x25, 1x70
const m = s.match(/(\d{1,3})x(\d{1,4}(?:\.\d{1,2})?)/i);
if (!m) return { cores: null, mm2: null };
const cores = Number(m[1]);
const mm2 = Number(m[2]);
return {
cores: Number.isFinite(cores) ? cores : null,
mm2: Number.isFinite(mm2) ? mm2 : null,
};
}
async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<Buffer> {
try {
const labels = getLabels(locale);
const pdfDoc = await PDFDocument.create();
const pageSize: [number, number] = [595.28, 841.89]; // A4
let page = pdfDoc.addPage(pageSize);
const { width, height } = page.getSize();
// STYLEGUIDE.md colors
const navy = rgb(0.0549, 0.1647, 0.2784); // #0E2A47
const mediumGray = rgb(0.4196, 0.4471, 0.5020); // #6B7280
const darkGray = rgb(0.1216, 0.1608, 0.2); // #1F2933
const almostWhite = rgb(0.9725, 0.9765, 0.9804); // #F8F9FA
const lightGray = rgb(0.9020, 0.9137, 0.9294); // #E6E9ED
const headerBg = rgb(0.965, 0.972, 0.98); // calm, print-friendly tint
// Small design system: consistent type + spacing for professional datasheets.
const DS = {
space: { xs: 4, sm: 8, md: 12, lg: 16, xl: 24 },
type: { h1: 20, h2: 11, body: 10.5, small: 8 },
rule: { thin: 0.75 },
} as const;
// Line-heights (explicit so vertical rhythm doesn't drift / overlap)
const LH = {
h1: 24,
h2: 16,
body: 14,
small: 10,
} as const;
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
// Assets
// Prefer a raster logo for reliability (sharp SVG support can vary between environments).
const logoPng = (await loadEmbeddablePng('/media/logo.png')) || (await loadEmbeddablePng('/media/logo.svg'));
const logoImage = logoPng ? await pdfDoc.embedPng(logoPng.pngBytes) : null;
// Some products in the processed dataset have no images/attributes.
// Always fall back to a deterministic site hero so the PDF is never "empty".
const fallbackHero = '/media/10648-low-voltage-scaled.webp';
const heroSrc = product.featuredImage || product.images?.[0] || fallbackHero;
const heroPng = await loadEmbeddablePng(heroSrc);
const productUrl = getProductUrl(product) || CONFIG.siteUrl;
const qrPng = await loadQrPng(productUrl);
const qrImage = qrPng ? await pdfDoc.embedPng(qrPng.pngBytes) : null;
// Engineered page frame (A4): slightly narrower margins but consistent rhythm.
const margin = 54;
const footerY = 54;
const contentMinY = footerY + 42; // keep clear of footer + page numbers
const contentWidth = width - 2 * margin;
const ctx: SectionDrawContext = {
pdfDoc,
page,
width,
height,
margin,
contentWidth,
footerY,
contentMinY,
headerDividerY: 0,
colors: { navy, mediumGray, darkGray, almostWhite, lightGray, headerBg },
fonts: { regular: font, bold: fontBold },
labels,
product,
locale,
logoImage,
qrImage,
qrUrl: productUrl,
};
// Hard requirement: one-page PDFs.
// We never create a second page; we truncate sections to fit.
const newPage = (): number => contentMinY - 1;
const hasSpace = (needed: number) => y - needed >= contentMinY;
// ---- Layout helpers (eliminate magic numbers; enforce consistent rhythm) ----
const rule = (gapAbove: number = DS.space.md, gapBelow: number = DS.space.lg) => {
// One-page rule: if we can't fit a divider with its spacing, do nothing.
if (!hasSpace(gapAbove + gapBelow + DS.rule.thin)) return;
y -= gapAbove;
page.drawLine({
start: { x: margin, y },
end: { x: margin + contentWidth, y },
thickness: DS.rule.thin,
color: lightGray,
});
y -= gapBelow;
};
const sectionTitle = (text: string) => {
// One-page rule: if we can't fit the heading + its gap, do nothing.
if (!hasSpace(DS.type.h2 + DS.space.md)) return;
page.drawText(text, {
x: margin,
y,
size: DS.type.h2,
font: fontBold,
color: navy,
});
// Use a real line-height to avoid title/body overlap.
y -= LH.h2;
};
// Page 1
// Page background (print-friendly)
page.drawRectangle({
x: 0,
y: 0,
width,
height,
color: rgb(1, 1, 1),
});
drawFooter(ctx);
let y = drawHeader(ctx, height - margin);
// === PRODUCT HEADER ===
const productName = stripHtml(product.name);
const cats = (product.categories || []).map(c => stripHtml(c.name)).join(' • ');
const titleW = contentWidth;
const titleLineH = LH.h1;
const nameLines = wrapText(productName, fontBold, DS.type.h1, titleW);
const shownNameLines = nameLines.slice(0, 2);
for (const line of shownNameLines) {
if (y - titleLineH < contentMinY) y = newPage();
page.drawText(line, {
x: margin,
y,
size: DS.type.h1,
font: fontBold,
color: navy,
maxWidth: titleW,
});
y -= titleLineH;
}
if (cats) {
if (y - 18 < contentMinY) y = newPage();
page.drawText(cats, {
x: margin,
y,
size: 10.5,
font,
color: mediumGray,
maxWidth: titleW,
});
y -= DS.space.lg;
}
// Separator after product header
rule(DS.space.sm, DS.space.lg);
// === HERO IMAGE (full width) ===
let heroH = 160;
const afterHeroGap = DS.space.xl;
if (!hasSpace(heroH + afterHeroGap)) {
// Shrink to remaining space (but keep it usable).
heroH = Math.max(120, Math.floor(y - contentMinY - afterHeroGap));
}
const heroBoxX = margin;
const heroBoxY = y - heroH;
page.drawRectangle({
x: heroBoxX,
y: heroBoxY,
width: contentWidth,
height: heroH,
// Calm frame; gives images consistent presence even with transparency.
color: almostWhite,
borderColor: lightGray,
borderWidth: 1,
});
if (heroPng) {
const pad = DS.space.md;
const boxW = contentWidth - pad * 2;
const boxH = heroH - pad * 2;
// Pre-crop the image to the target aspect ratio (prevents overflow and removes top/bottom whitespace).
const sharp = await getSharp();
const cropped = await sharp(Buffer.from(heroPng.pngBytes))
.resize({
width: 1200,
height: Math.round((1200 * boxH) / boxW),
fit: 'cover',
position: 'attention',
})
.png()
.toBuffer();
const heroImage = await pdfDoc.embedPng(cropped);
// Exact-fit (we already cropped to this aspect ratio).
page.drawImage(heroImage, {
x: heroBoxX + pad,
y: heroBoxY + pad,
width: boxW,
height: boxH,
});
} else {
page.drawText(locale === 'de' ? 'Kein Bild verfügbar' : 'No image available', {
x: heroBoxX + 12,
y: heroBoxY + heroH / 2,
size: 8,
font,
color: mediumGray,
maxWidth: contentWidth - 24,
});
}
y = heroBoxY - afterHeroGap;
// === DESCRIPTION ===
if (product.shortDescriptionHtml || product.descriptionHtml) {
const desc = stripHtml(product.shortDescriptionHtml || product.descriptionHtml);
const descLineH = 14;
const descMaxLines = 3;
const boxPadX = DS.space.md;
const boxPadY = DS.space.md;
const boxH = boxPadY * 2 + descLineH * descMaxLines;
const descNeeded = DS.type.h2 + DS.space.md + boxH + DS.space.lg + DS.space.xl;
// One-page rule: only render description if we can fit it cleanly.
if (hasSpace(descNeeded)) {
sectionTitle(labels.description);
const boxTop = y + DS.space.xs;
const boxBottom = boxTop - boxH;
page.drawRectangle({
x: margin,
y: boxBottom,
width: contentWidth,
height: boxH,
color: rgb(1, 1, 1),
borderColor: lightGray,
borderWidth: 1,
});
const descLines = wrapText(desc, font, DS.type.body, contentWidth - boxPadX * 2);
let ty = boxTop - boxPadY - DS.type.body;
for (const line of descLines.slice(0, descMaxLines)) {
page.drawText(line, {
x: margin + boxPadX,
y: ty,
size: DS.type.body,
font,
color: darkGray,
maxWidth: contentWidth - boxPadX * 2,
});
ty -= descLineH;
}
y = boxBottom - DS.space.lg;
rule(0, DS.space.xl);
}
}
// === TECHNICAL DATA (shared across all cross-sections) ===
const configAttr = findAttr(product, /configuration|konfiguration|aufbau|bezeichnung/i);
const crossSectionAttr =
configAttr ||
findAttr(product, /number of cores and cross-section|querschnitt|cross.?section|mm²|mm2/i);
const rowCount = crossSectionAttr?.options?.length || 0;
const hasCrossSectionData = Boolean(crossSectionAttr && rowCount > 0);
// Compact mode approach:
// - show constant (non-row) attributes as key/value grid
// - show only a small configuration sample + total count
// - optionally render full tables with PDF_MODE=full
const constantAttrs = (product.attributes || []).filter(a => a.options.length === 1);
const constantItemsAll = constantAttrs
.map(a => ({ label: normalizeValue(a.name), value: normalizeValue(a.options[0]) }))
.filter(i => i.label && i.value)
.slice(0, 12);
// Intentionally do NOT include SKU/categories here (they are already shown in the product header).
// TECH DATA must never crowd out cross-section.
// IMPORTANT: `drawKeyValueGrid()` will return `contentMinY - 1` when it can't fit.
// We must avoid calling it unless we're sure it fits.
const techBox = {
// Keep in sync with `drawKeyValueGrid()` boxed metrics
padY: 14,
headerH: 22,
rowH: 24,
} as const;
// Reserve enough space so cross-sections are actually visible when present.
// Mirror `drawCrossSectionChipsRow()` minimum-needed math (+ a bit of padding).
const minCrossBlockH = 12 /*title*/ + 12 /*summary*/ + (16 * 2) /*chips*/ + 8 /*lineGap*/ + 10 /*gapY*/ + 24 /*after*/;
const reservedForCross = hasCrossSectionData ? minCrossBlockH : 0;
const techTitle = locale === 'de' ? 'TECHNISCHE DATEN' : 'TECHNICAL DATA';
const techBoxHeightFor = (itemsCount: number) => {
const rows = Math.ceil(itemsCount / 2);
return techBox.padY + techBox.headerH + rows * techBox.rowH + techBox.padY;
};
const canFitTechWith = (itemsCount: number) => {
if (itemsCount <= 0) return false;
const techH = techBoxHeightFor(itemsCount);
const afterTechGap = DS.space.lg;
// We need to keep reserved space for cross-section below.
return y - (techH + afterTechGap + reservedForCross) >= contentMinY;
};
// Pick the largest "nice" amount of items that still guarantees cross-section visibility.
const desiredCap = 8;
let chosenCount = 0;
for (let n = Math.min(desiredCap, constantItemsAll.length); n >= 1; n--) {
if (canFitTechWith(n)) {
chosenCount = n;
break;
}
}
if (chosenCount > 0) {
const constantItems = constantItemsAll.slice(0, chosenCount);
y = drawKeyValueGrid({
title: techTitle,
items: constantItems,
newPage,
getPage: () => page,
page,
y,
margin,
contentWidth,
contentMinY,
font,
fontBold,
navy,
darkGray,
mediumGray,
lightGray,
almostWhite,
allowNewPage: false,
boxed: true,
});
} else if (!hasCrossSectionData) {
// If there is no cross-section block, we can afford to show a clear "no data" note.
y = drawKeyValueGrid({
title: techTitle,
items: [
{
label: locale === 'de' ? 'Hinweis' : 'Note',
value:
locale === 'de'
? 'Für dieses Produkt sind derzeit keine technischen Daten hinterlegt.'
: 'No technical data is available for this product yet.',
},
],
newPage,
getPage: () => page,
page,
y,
margin,
contentWidth,
contentMinY,
font,
fontBold,
navy,
darkGray,
mediumGray,
lightGray,
almostWhite,
allowNewPage: false,
boxed: true,
});
}
// Consistent spacing after the technical data block (but never push content below min Y)
if (y - DS.space.lg >= contentMinY) y -= DS.space.lg;
// === CROSS-SECTION TABLE (row-specific data) ===
if (crossSectionAttr && rowCount > 0) {
const configRows = crossSectionAttr.options;
const findRowAttr = (re: RegExp) => {
const a = product.attributes?.find(x => re.test(x.name));
if (!a) return null;
if (!a.options || a.options.length !== rowCount) return null;
return a;
};
const candidateCols: Array<{ key: string; label: string; re: RegExp }> = [
{ key: 'outerDiameter', label: locale === 'de' ? 'Außen-Ø' : 'Outer Ø', re: /outer\s*diameter|außen\s*durchmesser|außen-?ø/i },
{ key: 'weight', label: locale === 'de' ? 'Gewicht' : 'Weight', re: /\bweight\b|gewicht/i },
{ key: 'maxResistance', label: locale === 'de' ? 'Max. Leiterwiderstand' : 'Max. conductor resistance', re: /maximum\s+resistance\s+of\s+conductor|max\.?\s*resistance|leiterwiderstand/i },
{ key: 'current', label: locale === 'de' ? 'Strombelastbarkeit' : 'Current rating', re: /current\s*(rating|carrying)|ampacity|strombelastbarkeit/i },
];
// NOTE: One-page requirement: cross sections render as a dense list only.
// Row-specific values are intentionally omitted to keep the sheet compact.
const columns: Array<{ label: string; get: (rowIndex: number) => string }> = [];
const yAfterCross = drawCrossSectionChipsRow({
title: labels.crossSection,
configRows,
locale,
getPage: () => page,
page,
y,
margin,
contentWidth,
contentMinY,
font,
fontBold,
navy,
darkGray,
mediumGray,
lightGray,
almostWhite,
});
// If the chips block can't fit at all, show a minimal summary line (no chips).
// drawCrossSectionChipsRow returns (contentMinY - 1) in that case.
if (yAfterCross < contentMinY) {
sectionTitle(labels.crossSection);
const total = configRows.length;
const summary = locale === 'de' ? `Varianten: ${total}` : `Options: ${total}`;
page.drawText(summary, {
x: margin,
y,
size: DS.type.body,
font,
color: mediumGray,
maxWidth: contentWidth,
});
y -= LH.body + DS.space.lg;
} else {
y = yAfterCross;
}
} else {
// If there is no cross-section data, do not render the section at all.
}
// Add page numbers after all pages are created.
stampPageNumbers(pdfDoc, { regular: font }, { mediumGray }, margin, footerY);
const pdfBytes = await pdfDoc.save();
return Buffer.from(pdfBytes);
} catch (error: any) {
throw new Error(`Failed to generate PDF for product ${product.id} (${locale}): ${error.message}`);
}
}
async function processChunk(products: ProductData[], chunkIndex: number, totalChunks: number): Promise<void> {
console.log(`\nProcessing chunk ${chunkIndex + 1}/${totalChunks} (${products.length} products)...`);
for (const product of products) {
try {
const locale = product.locale || 'en';
const buffer = await generatePDF(product, locale);
const fileName = generateFileName(product, locale);
fs.writeFileSync(path.join(CONFIG.outputDir, fileName), buffer);
console.log(`${locale.toUpperCase()}: ${fileName}`);
await new Promise(resolve => setTimeout(resolve, 50));
} catch (error) {
console.error(`✗ Failed to process product ${product.id}:`, error);
}
}
}
async function readProductsStream(): Promise<ProductData[]> {
console.log('Reading products.json...');
return new Promise((resolve, reject) => {
const stream = fs.createReadStream(CONFIG.productsFile, { encoding: 'utf8' });
let data = '';
stream.on('data', (chunk) => { data += chunk; });
stream.on('end', () => {
try {
const products = JSON.parse(data);
console.log(`Loaded ${products.length} products`);
resolve(products);
} catch (error) {
reject(new Error(`Failed to parse JSON: ${error}`));
}
});
stream.on('error', (error) => reject(new Error(`Failed to read file: ${error}`)));
});
}
async function processProductsInChunks(): Promise<void> {
console.log('Starting PDF generation - Industrial engineering documentation style');
ensureOutputDir();
try {
const allProducts = await readProductsStream();
if (allProducts.length === 0) {
console.log('No products found');
return;
}
// Optional dev convenience: limit how many PDFs we render (useful for design iteration).
// Default behavior remains unchanged.
const limit = Number(process.env.PDF_LIMIT || '0');
const products = Number.isFinite(limit) && limit > 0 ? allProducts.slice(0, limit) : allProducts;
const enProducts = products.filter(p => p.locale === 'en');
const deProducts = products.filter(p => p.locale === 'de');
console.log(`Found ${enProducts.length} EN + ${deProducts.length} DE products`);
const totalChunks = Math.ceil(products.length / CONFIG.chunkSize);
for (let i = 0; i < totalChunks; i++) {
const chunk = products.slice(i * CONFIG.chunkSize, (i + 1) * CONFIG.chunkSize);
await processChunk(chunk, i, totalChunks);
}
console.log('\n✅ PDF generation completed!');
console.log(`Generated ${enProducts.length} EN + ${deProducts.length} DE PDFs`);
console.log(`Output: ${CONFIG.outputDir}`);
} catch (error) {
console.error('❌ Error:', error);
throw error;
}
}
async function main(): Promise<void> {
const start = Date.now();
try {
await processProductsInChunks();
console.log(`\nTime: ${((Date.now() - start) / 1000).toFixed(2)}s`);
} catch (error) {
console.error('Fatal error:', error);
process.exit(1);
}
}
main().catch(console.error);
export { main as generatePDFDatasheets };