#!/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; 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; darkGray: ReturnType; mediumGray: ReturnType; lightGray?: ReturnType; almostWhite?: ReturnType; 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 { 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 { 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 { // 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 { const idx: Record = {}; 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; darkGray: ReturnType; lightGray: ReturnType; almostWhite: ReturnType; 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 = []; 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; mediumGray: ReturnType; darkGray: ReturnType; almostWhite: ReturnType; lightGray: ReturnType; headerBg: ReturnType; }; fonts: { regular: PDFFont; bold: PDFFont; }; labels: ReturnType; 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 }, 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; darkGray: ReturnType; mediumGray: ReturnType; lightGray: ReturnType; almostWhite: ReturnType; }): 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(); 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(); 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; }): 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 { 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 { 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 { 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 { 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 { 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 };