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

1847 lines
58 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 = '';
const isOrphanWord = (w: string) => {
// Avoid ugly single short words on their own line in DE/EN (e.g. “im”, “in”, “to”).
// This is a typography/UX improvement for datasheets.
const s = w.trim();
return s.length > 0 && s.length <= 2;
};
for (let i = 0; i < words.length; i++) {
const word = words[i];
const next = i + 1 < words.length ? words[i + 1] : '';
const testLine = currentLine ? `${currentLine} ${word}` : word;
if (font.widthOfTextAtSize(testLine, fontSize) <= maxWidth) {
// Orphan control: if adding the *next* word would overflow, don't end the line with a tiny orphan.
// Example: "... mechanischen im" + "Belastungen" should become "... mechanischen" / "im Belastungen ...".
if (currentLine && next && isOrphanWord(word)) {
const testWithNext = `${testLine} ${next}`;
if (font.widthOfTextAtSize(testWithNext, fontSize) > maxWidth) {
lines.push(currentLine);
currentLine = word;
continue;
}
}
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 mtW = fonts.bold.widthOfTextAtSize(metaTitle, metaTitleSize);
// With SKU removed, vertically center the title within the header block.
const metaY = dividerY + Math.round(headerH / 2 - metaTitleSize / 2);
page.drawText(metaTitle, {
x: metaRightEdge - mtW,
y: metaY,
size: metaTitleSize,
font: fonts.bold,
color: colors.navy,
});
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';
maxLinesCap?: 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>;
}): 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 maxLinesAvailable = Math.max(1, Math.floor((startY - contentMinY + lineGap) / (chipH + lineGap)));
// UX/Content priority: don't let cross-section tags consume the whole sheet.
// When technical data is dense, we cap this to keep specs visible.
const maxLines = Math.min(args.maxLinesCap ?? 2, maxLinesAvailable);
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 summarizeOptions(options: string[] | undefined, maxItems: number = 3): string {
const vals = (options || []).map(normalizeValue).filter(Boolean);
if (vals.length === 0) return '';
const uniq = Array.from(new Set(vals));
if (uniq.length === 1) return uniq[0];
if (uniq.length <= maxItems) return uniq.join(' / ');
return `${uniq.slice(0, maxItems).join(' / ')} (+${uniq.length - maxItems})`;
}
function parseNumericOption(value: string): number | null {
const v = normalizeValue(value).replace(/,/g, '.');
// First numeric token (works for "12.3", "12.3 mm", "-35", "26.5 kg/km").
const m = v.match(/-?\d+(?:\.\d+)?/);
if (!m) return null;
const n = Number(m[0]);
return Number.isFinite(n) ? n : null;
}
function formatNumber(n: number): string {
const s = Number.isInteger(n) ? String(n) : String(n);
return s.replace(/\.0+$/, '');
}
function summarizeNumericRange(options: string[] | undefined): { ok: boolean; text: string } {
const vals = (options || []).map(parseNumericOption).filter((n): n is number => n !== null);
if (vals.length < 3) return { ok: false, text: '' };
const uniq = Array.from(new Set(vals));
if (uniq.length < 2) return { ok: false, text: '' };
uniq.sort((a, b) => a - b);
const min = uniq[0];
const max = uniq[uniq.length - 1];
return { ok: true, text: `${formatNumber(min)}${formatNumber(max)} (n=${uniq.length})` };
}
function summarizeSmartOptions(label: string, options: string[] | undefined): string {
// Prefer numeric ranges when an attribute has many numeric-ish entries (typical for row-specific data).
const range = summarizeNumericRange(options);
if (range.ok) return range.text;
return summarizeOptions(options, 3);
}
function looksNumeric(value: string): boolean {
const v = normalizeValue(value).replace(/,/g, '.');
return /^-?\d+(?:\.\d+)?$/.test(v);
}
function formatMaybeWithUnit(value: string, unit: string): string {
const v = normalizeValue(value);
if (!v) return '';
return looksNumeric(v) ? `${v} ${unit}` : v;
}
function drawRowPreviewTable(args: {
title: string;
rows: Array<{ config: string; col1: string; col2: string }>;
headers: { config: string; col1: string; col2: string };
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>;
}): number {
let { title, rows, headers, getPage, page, y, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, lightGray, almostWhite } = args;
const titleH = 16;
const headerH = 16;
const rowH = 13;
const padAfter = 18;
// One-page rule: require at least 2 data rows.
const minNeeded = titleH + headerH + rowH * 2 + padAfter;
if (y - minNeeded < contentMinY) return contentMinY - 1;
page = getPage();
page.drawText(title, { x: margin, y, size: 10, font: fontBold, color: navy });
y -= titleH;
// How many rows fit?
const availableForRows = y - contentMinY - padAfter - headerH;
const maxRows = Math.max(2, Math.floor(availableForRows / rowH));
const shown = rows.slice(0, Math.max(0, maxRows));
const hasCol2 = shown.some(r => Boolean(r.col2));
// Widths: favor configuration readability.
const wCfg = 0.46;
const w1 = hasCol2 ? 0.27 : 0.54;
const w2 = hasCol2 ? 0.27 : 0;
const x0 = margin;
const x1 = margin + contentWidth * wCfg;
const x2 = margin + contentWidth * (wCfg + w1);
const drawHeader = () => {
page = getPage();
page.drawRectangle({ x: margin, y: y - headerH, width: contentWidth, height: headerH, color: lightGray });
page.drawText(headers.config, { x: x0 + 6, y: y - 11, size: 8, font: fontBold, color: navy, maxWidth: contentWidth * wCfg - 12 });
page.drawText(headers.col1, { x: x1 + 6, y: y - 11, size: 8, font: fontBold, color: navy, maxWidth: contentWidth * w1 - 12 });
if (hasCol2) {
page.drawText(headers.col2, { x: x2 + 6, y: y - 11, size: 8, font: fontBold, color: navy, maxWidth: contentWidth * w2 - 12 });
}
y -= headerH;
};
drawHeader();
for (let i = 0; i < shown.length; i++) {
if (y - rowH < contentMinY) return contentMinY - 1;
page = getPage();
if (i % 2 === 0) {
page.drawRectangle({ x: margin, y: y - rowH, width: contentWidth, height: rowH, color: almostWhite });
}
page.drawText(shown[i].config, { x: x0 + 6, y: y - 10, size: 8, font, color: darkGray, maxWidth: contentWidth * wCfg - 12 });
page.drawText(shown[i].col1, { x: x1 + 6, y: y - 10, size: 8, font, color: darkGray, maxWidth: contentWidth * w1 - 12 });
if (hasCol2) {
page.drawText(shown[i].col2, { x: x2 + 6, y: y - 10, size: 8, font, color: darkGray, maxWidth: contentWidth * w2 - 12 });
}
y -= rowH;
}
y -= padAfter;
return y;
}
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 have no product-specific images.
// Do NOT fall back to a generic/category hero (misleading in datasheets).
// If missing, we render a neutral placeholder box.
const heroSrc = product.featuredImage || product.images?.[0] || null;
const heroPng = heroSrc ? await loadEmbeddablePng(heroSrc) : null;
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) ===
// Dense technical products need more room for specs; prioritize content over imagery.
const hasLotsOfTech = (product.attributes?.length || 0) >= 18;
let heroH = hasLotsOfTech ? 120 : 160;
const afterHeroGap = DS.space.xl;
if (!hasSpace(heroH + afterHeroGap)) {
// Shrink to remaining space (but keep it usable).
heroH = Math.max(96, 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
// Prefer a curated list that matches website expectations.
// IMPORTANT: for row-specific arrays we don't attempt per-row mapping here; we summarize as ranges.
const preferredTechAttrs: Array<{ re: RegExp; fallbackLabel: string }> = [
{ re: /standard|norm|vde|iec/i, fallbackLabel: locale === 'de' ? 'Norm' : 'Standard' },
{ re: /rated\s*voltage|voltage\s*rating|nennspannung/i, fallbackLabel: locale === 'de' ? 'Nennspannung' : 'Rated voltage' },
{ re: /test\s*voltage|pr\u00fcfspannung/i, fallbackLabel: locale === 'de' ? 'Pr\u00fcfspannung' : 'Test voltage' },
{ re: /temperature\s*range|operating\s*temperature|betriebstemperatur/i, fallbackLabel: locale === 'de' ? 'Temperaturbereich' : 'Temperature range' },
{ re: /bending\s*radius|biegeradius/i, fallbackLabel: locale === 'de' ? 'Biegeradius' : 'Bending radius' },
{ re: /cpr\s*class/i, fallbackLabel: locale === 'de' ? 'CPR-Klasse' : 'CPR class' },
];
const picked = new Set<string>();
const techItemsPreferred = preferredTechAttrs
.map(({ re, fallbackLabel }) => {
const a = findAttr(product, re);
if (!a) return null;
const label = normalizeValue(a.name) || fallbackLabel;
const value = summarizeSmartOptions(label, a.options);
if (!label || !value) return null;
picked.add(label.toLowerCase());
return { label, value };
})
.filter(Boolean) as Array<{ label: string; value: string }>;
const isConfigLikeAttr = (name: string) =>
/configuration|konfiguration|aufbau|bezeichnung|number of cores and cross-section|querschnitt|cross.?section|mm²|mm2/i.test(name);
const isClearlyMetaAttr = (name: string) => /\bsku\b|artikelnummer|\bid\b|product\s*id/i.test(name);
// Provide additional technical attributes as compact summaries.
// - numeric-heavy arrays become ranges (minmax with count)
// - non-numeric arrays become short lists
// This is what fills the “missing important technical data” without breaking 1-page.
const techItemsMore = (product.attributes || [])
.filter(a => (a.options?.length || 0) > 1)
.filter(a => !isConfigLikeAttr(a.name))
.filter(a => !isClearlyMetaAttr(a.name))
.map(a => {
const label = normalizeValue(a.name);
if (!label) return null;
if (picked.has(label.toLowerCase())) return null;
const value = summarizeSmartOptions(label, a.options);
if (!value) return null;
return { label, value };
})
.filter(Boolean) as Array<{ label: string; value: string }>;
const constantAttrs = (product.attributes || []).filter(a => a.options.length === 1);
const techItemsFill = constantAttrs
.map(a => ({ label: normalizeValue(a.name), value: normalizeValue(a.options[0]) }))
.filter(i => i.label && i.value)
.filter(i => !picked.has(i.label.toLowerCase()));
const constantItemsAll = [...techItemsPreferred, ...techItemsMore, ...techItemsFill].slice(0, 20);
// 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).
// We cap the chips block to keep room for technical data.
const crossMaxLinesCap = 2;
const minCrossBlockH =
12 /*title*/ +
12 /*summary*/ +
(16 * crossMaxLinesCap) /*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 = 12;
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;
};
// Pull the two most important row-specific columns and show a small excerpt table.
const rowOuter = findRowAttr(/outer\s*diameter|außen\s*durchmesser|außen-?ø/i);
const rowWeight = findRowAttr(/\bweight\b|gewicht/i);
const yAfterCross = drawCrossSectionChipsRow({
title: labels.crossSection,
configRows,
locale,
// keep chips as a fallback, but prefer the dense list section below
maxLinesCap: 2,
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;
}
// Compact per-configuration excerpt (only if it fits).
if (rowOuter && rowWeight) {
const previewRows = configRows.map((cfg, i) => ({
config: normalizeValue(cfg),
col1: formatMaybeWithUnit(getAttrCellValue(rowOuter ?? undefined, i, rowCount), 'mm'),
col2: formatMaybeWithUnit(getAttrCellValue(rowWeight ?? undefined, i, rowCount), 'kg/km'),
}));
const previewTitle = locale === 'de' ? 'Konfigurationswerte (Auszug)' : 'Configuration values (excerpt)';
const yAfterPreview = drawRowPreviewTable({
title: previewTitle,
rows: previewRows,
headers: {
config: locale === 'de' ? 'Konfiguration' : 'Configuration',
col1: locale === 'de' ? 'Außen-Ø' : 'Outer Ø',
col2: locale === 'de' ? 'Gewicht' : 'Weight',
},
getPage: () => page,
page,
y,
margin,
contentWidth,
contentMinY,
font,
fontBold,
navy,
darkGray,
lightGray,
almostWhite,
});
if (yAfterPreview >= contentMinY) y = yAfterPreview;
}
} 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 };