#!/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) const padX = boxed ? 14 : 0; const padY = boxed ? 12 : 0; const xBase = margin + padX; const innerWidth = contentWidth - padX * 2; const colGap = 14; const colW = (innerWidth - colGap) / 2; const rowH = 18; const headerH = boxed ? 18 : 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 - 13, size: 9.5, 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: 1, 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 - 10, size: 8, 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: 10, 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; }; 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, labels, product, locale } = ctx; page.drawLine({ start: { x: margin, y: footerY + 14 }, end: { x: width - margin, y: footerY + 14 }, thickness: 1, 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 } = ctx; const qrSize = 44; const qrGap = 12; const rightReserved = qrImage ? qrSize + qrGap : 0; // Left: logo (preferred) or typographic fallback if (logoImage) { const maxLogoW = 110; const maxLogoH = 28; const scale = Math.min(maxLogoW / logoImage.width, maxLogoH / logoImage.height); const w = logoImage.width * scale; const h = logoImage.height * scale; page.drawImage(logoImage, { x: margin, y: yStart - h + 6, width: w, height: h, }); } else { page.drawText('KLZ', { x: margin, y: yStart, size: 24, font: fonts.bold, color: colors.navy, }); page.drawText('Cables', { x: margin + fonts.bold.widthOfTextAtSize('KLZ', 24) + 4, y: yStart + 2, size: 10, font: fonts.regular, color: colors.mediumGray, }); } // Header divider baseline (shared with footer spacing logic) const dividerY = yStart - 58; ctx.headerDividerY = dividerY; // QR code: place top-right, aligned to the header block (never below the divider) if (qrImage) { const qrX = width - margin - qrSize; const qrY = yStart - qrSize + 6; page.drawImage(qrImage, { x: qrX, y: qrY, width: qrSize, height: qrSize }); } else { // If QR generation failed, keep the URL available as a small header line. const maxW = 220; const urlLines = wrapText(qrUrl, fonts.regular, 8, maxW).slice(0, 2); let urlY = yStart - 12; for (const line of urlLines) { const w = fonts.regular.widthOfTextAtSize(line, 8); page.drawText(line, { x: width - margin - w, y: urlY, size: 8, font: fonts.regular, color: colors.mediumGray, }); urlY -= 10; } } // Header line page.drawLine({ start: { x: margin, y: dividerY }, end: { x: margin + contentWidth, y: dividerY }, thickness: 1, color: colors.lightGray, }); return dividerY - 26; } 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 = 14; const lineGap = 6; const gapY = 8; 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: 10, 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 = 7; const chipFontSize = 7.5; const chipGap = 6; const chipPadTop = 4; 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 = 34; 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: almostWhite, }); 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); return bottomY - 18; } 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 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; // Single-page constraint: keep generous but slightly tighter margins. const margin = 50; const footerY = 50; const contentMinY = footerY + 36; // keep clear of footer const contentWidth = width - 2 * margin; const ctx: SectionDrawContext = { pdfDoc, page, width, height, margin, contentWidth, footerY, contentMinY, headerDividerY: 0, colors: { navy, mediumGray, darkGray, almostWhite, lightGray }, 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; // Page 1 // Page background (STYLEGUIDE.md) page.drawRectangle({ x: 0, y: 0, width, height, color: almostWhite, }); 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 nameLines = wrapText(productName, fontBold, 18, titleW); const shownNameLines = nameLines.slice(0, 2); for (const line of shownNameLines) { if (y - 22 < contentMinY) y = newPage(); page.drawText(line, { x: margin, y, size: 18, font: fontBold, color: navy, maxWidth: titleW, }); y -= 22; } if (cats) { if (y - 18 < contentMinY) y = newPage(); page.drawText(cats, { x: margin, y, size: 9, font, color: mediumGray, maxWidth: titleW, }); y -= 18; } // === HERO IMAGE (full width) === let heroH = 115; const heroGap = 12; if (!hasSpace(heroH + heroGap)) { // Shrink to remaining space (but keep it usable). heroH = Math.max(80, Math.floor(y - contentMinY - heroGap)); } const heroBoxX = margin; const heroBoxY = y - heroH; page.drawRectangle({ x: heroBoxX, y: heroBoxY, width: contentWidth, height: heroH, // Border only (no fill): lets transparent product images blend into the page. borderColor: lightGray, borderWidth: 1, }); if (heroPng) { const pad = 10; 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); const scale = Math.min(boxW / heroImage.width, boxH / heroImage.height); page.drawImage(heroImage, { x: heroBoxX + pad, y: heroBoxY + pad, width: heroImage.width * scale, height: heroImage.height * scale, }); } 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 - 18; // === DESCRIPTION === if ((product.shortDescriptionHtml || product.descriptionHtml) && hasSpace(40)) { page.drawText(labels.description, { x: margin, y: y, size: 10, font: fontBold, color: navy, }); y -= 14; const desc = stripHtml(product.shortDescriptionHtml || product.descriptionHtml); const descLines = wrapText(desc, font, 9, width - 2 * margin); for (const line of descLines.slice(0, 2)) { page.drawText(line, { x: margin, y: y, size: 9, font: font, color: darkGray, }); y -= 12; } y -= 14; } // === 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; // 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 constantItems = 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). // If this product has no processed attributes, show a clear note so it doesn't look broken. if (constantItems.length === 0) { constantItems.push({ 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.', }); } y = drawKeyValueGrid({ title: locale === 'de' ? 'TECHNISCHE DATEN' : 'TECHNICAL DATA', items: constantItems, newPage, getPage: () => page, page, y, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, mediumGray, lightGray, almostWhite, allowNewPage: false, boxed: true, }); // === 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 }> = []; y = drawCrossSectionChipsRow({ title: labels.crossSection, configRows, locale, getPage: () => page, page, y, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, mediumGray, lightGray, almostWhite, }); } else { // If we couldn't detect cross-sections, still show a small note instead of an empty section. if (y - 22 < contentMinY) y = newPage(); page.drawText(labels.crossSection, { x: margin, y, size: 10, font: fontBold, color: navy }); y -= 14; page.drawText(locale === 'de' ? 'Keine Querschnittsdaten verfügbar.' : 'No cross-section data available.', { x: margin, y, size: 9, font, color: mediumGray, maxWidth: contentWidth, }); y -= 16; } // 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; } const enProducts = allProducts.filter(p => p.locale === 'en'); const deProducts = allProducts.filter(p => p.locale === 'de'); console.log(`Found ${enProducts.length} EN + ${deProducts.length} DE products`); const totalChunks = Math.ceil(allProducts.length / CONFIG.chunkSize); for (let i = 0; i < totalChunks; i++) { const chunk = allProducts.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 };