#!/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 { execSync } from 'child_process'; 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'); const EXCEL_SOURCE_FILES = [ path.join(process.cwd(), 'data/source/high-voltage.xlsx'), path.join(process.cwd(), 'data/source/medium-voltage-KM.xlsx'), path.join(process.cwd(), 'data/source/low-voltage-KM.xlsx'), path.join(process.cwd(), 'data/source/solar-cables.xlsx'), ]; 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[]; }>; } type ExcelRow = Record; type ExcelMatch = { rows: ExcelRow[]; units: Record }; let EXCEL_INDEX: Map | null = null; function normalizeExcelKey(value: string): string { // Match product names/slugs and Excel "Part Number" robustly. // Examples: // - "NA2XS(FL)2Y" -> "NA2XSFL2Y" // - "na2xsfl2y-3" -> "NA2XSFL2Y" return String(value || '') .toUpperCase() .replace(/-\d+$/g, '') .replace(/[^A-Z0-9]+/g, ''); } function loadExcelRows(filePath: string): ExcelRow[] { // We intentionally avoid adding a heavy xlsx parser dependency. // Instead, we use `xlsx-cli` via npx, which is already available at runtime. // NOTE: `xlsx-cli -j` prints the sheet name on the first line, then JSON. const out = execSync(`npx -y xlsx-cli -j "${filePath}"`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); const trimmed = out.trim(); const jsonStart = trimmed.indexOf('['); if (jsonStart < 0) return []; const jsonText = trimmed.slice(jsonStart); try { return JSON.parse(jsonText) as ExcelRow[]; } catch { return []; } } function getExcelIndex(): Map { if (EXCEL_INDEX) return EXCEL_INDEX; const idx = new Map(); for (const file of EXCEL_SOURCE_FILES) { if (!fs.existsSync(file)) continue; const rows = loadExcelRows(file); if (process.env.PDF_DEBUG_EXCEL === '1') { console.log(`[excel] loaded ${rows.length} rows from ${path.relative(process.cwd(), file)}`); } const unitsRow = rows.find(r => r && r['Part Number'] === 'Units') || null; const units: Record = {}; if (unitsRow) { for (const [k, v] of Object.entries(unitsRow)) { if (k === 'Part Number') continue; const unit = normalizeValue(String(v ?? '')); if (unit) units[k] = unit; } } for (const r of rows) { const pn = r?.['Part Number']; if (!pn || pn === 'Units') continue; const key = normalizeExcelKey(String(pn)); if (!key) continue; const cur = idx.get(key); if (!cur) { idx.set(key, { rows: [r], units }); } else { cur.rows.push(r); if (Object.keys(cur.units).length < Object.keys(units).length) cur.units = units; } } } EXCEL_INDEX = idx; return idx; } function findExcelForProduct(product: ProductData): ExcelMatch | null { const idx = getExcelIndex(); const candidates = [ product.name, product.slug ? product.slug.replace(/-\d+$/g, '') : '', product.sku, product.translationKey, ].filter(Boolean) as string[]; if (process.env.PDF_DEBUG_EXCEL === '1') { const keys = candidates.map(c => normalizeExcelKey(c)); console.log(`[excel] lookup product=${product.id} ${product.locale ?? ''} slug=${product.slug ?? ''} name=${stripHtml(product.name)} keys=${keys.join(',')}`); } for (const c of candidates) { const key = normalizeExcelKey(c); const match = idx.get(key); if (match && match.rows.length) return match; } return null; } function findExcelRowsForProduct(product: ProductData): ExcelRow[] { const match = findExcelForProduct(product); return match?.rows || []; } function guessColumnKey(row: ExcelRow, patterns: RegExp[]): string | null { const keys = Object.keys(row || {}); // Try pattern-based matching first for (const re of patterns) { const k = keys.find(x => { const key = String(x); // Specific exclusions to prevent wrong matches if (re.test('conductor') && /ross section conductor/i.test(key)) return false; if (re.test('insulation thickness') && /Diameter over insulation/i.test(key)) return false; if (re.test('conductor') && !/^conductor$/i.test(key)) return false; if (re.test('insulation') && !/^insulation$/i.test(key)) return false; if (re.test('sheath') && !/^sheath$/i.test(key)) return false; if (re.test('norm') && !/^norm$/i.test(key)) return false; return re.test(key); }); if (k) return k; } return null; } function hasAttr(product: ProductData, nameRe: RegExp, expectedLen?: number): boolean { const a = product.attributes?.find(x => nameRe.test(x.name)); if (!a) return false; if (typeof expectedLen === 'number') return (a.options || []).length === expectedLen; return (a.options || []).length > 0; } function pushRowAttrIfMissing(args: { product: ProductData; name: string; options: string[]; expectedLen: number; existsRe: RegExp; }): void { const { product, name, options, expectedLen, existsRe } = args; if (!options.filter(Boolean).length) return; if (hasAttr(product, existsRe, expectedLen)) return; product.attributes = product.attributes || []; product.attributes.push({ name, options }); } function pushAttrIfMissing(args: { product: ProductData; name: string; options: string[]; existsRe: RegExp }): void { const { product, name, options, existsRe } = args; if (!options.filter(Boolean).length) return; if (hasAttr(product, existsRe)) return; product.attributes = product.attributes || []; product.attributes.push({ name, options }); } function getUniqueNonEmpty(options: string[]): string[] { const uniq: string[] = []; const seen = new Set(); for (const v of options.map(normalizeValue).filter(Boolean)) { const k = v.toLowerCase(); if (seen.has(k)) continue; seen.add(k); uniq.push(v); } return uniq; } function ensureExcelCrossSectionAttributes(product: ProductData, locale: 'en' | 'de'): void { const hasCross = (product.attributes || []).some(a => /configuration|konfiguration|aufbau|bezeichnung|number of cores and cross-section|querschnitt|cross.?section|mm²|mm2/i.test(a.name) && (a.options?.length || 0) > 0); if (hasCross) return; const rows = findExcelRowsForProduct(product); if (!rows.length) { if (process.env.PDF_DEBUG_EXCEL === '1') { console.log(`[excel] no rows found for product ${product.id} (${product.slug ?? stripHtml(product.name)})`); } return; } // Find the cross-section column. const csKey = guessColumnKey(rows[0], [ /number of cores and cross-section/i, /cross.?section/i, /ross section conductor/i, ]) || null; if (!csKey) { if (process.env.PDF_DEBUG_EXCEL === '1') { console.log(`[excel] rows found but no cross-section column for product ${product.id}; available keys: ${Object.keys(rows[0] || {}).slice(0, 30).join(', ')}`); } return; } // Get all technical column keys using improved detection const voltageKey = guessColumnKey(rows[0], [/rated voltage/i, /voltage rating/i, /spannungs/i, /nennspannung/i]); const outerKey = guessColumnKey(rows[0], [/outer diameter\b/i, /outer diameter.*approx/i, /outer diameter of cable/i, /außen/i]); const weightKey = guessColumnKey(rows[0], [/weight\b/i, /gewicht/i, /cable weight/i]); const dcResKey = guessColumnKey(rows[0], [/dc resistance/i, /resistance conductor/i, /leiterwiderstand/i]); // Additional technical columns const ratedVoltKey = voltageKey; // Already found above const testVoltKey = guessColumnKey(rows[0], [/test voltage/i, /prüfspannung/i]); const tempRangeKey = guessColumnKey(rows[0], [/operating temperature range/i, /temperature range/i, /temperaturbereich/i]); const minLayKey = guessColumnKey(rows[0], [/minimal temperature for laying/i]); const minStoreKey = guessColumnKey(rows[0], [/minimal storage temperature/i]); const maxOpKey = guessColumnKey(rows[0], [/maximal operating conductor temperature/i, /max\. operating/i]); const maxScKey = guessColumnKey(rows[0], [/maximal short-circuit temperature/i, /short\s*circuit\s*temperature/i]); const insThkKey = guessColumnKey(rows[0], [/nominal insulation thickness/i, /insulation thickness/i]); const sheathThkKey = guessColumnKey(rows[0], [/nominal sheath thickness/i, /minimum sheath thickness/i]); const maxResKey = guessColumnKey(rows[0], [/maximum resistance of conductor/i]); // Material and specification columns const conductorKey = guessColumnKey(rows[0], [/^conductor$/i]); const insulationKey = guessColumnKey(rows[0], [/^insulation$/i]); const sheathKey = guessColumnKey(rows[0], [/^sheath$/i]); const normKey = guessColumnKey(rows[0], [/^norm$/i, /^standard$/i]); const cprKey = guessColumnKey(rows[0], [/cpr class/i]); const rohsKey = guessColumnKey(rows[0], [/^rohs$/i]); const reachKey = guessColumnKey(rows[0], [/^reach$/i]); const packagingKey = guessColumnKey(rows[0], [/^packaging$/i]); const shapeKey = guessColumnKey(rows[0], [/shape of conductor/i]); const flameKey = guessColumnKey(rows[0], [/flame retardant/i]); const diamCondKey = guessColumnKey(rows[0], [/diameter conductor/i]); const diamInsKey = guessColumnKey(rows[0], [/diameter over insulation/i]); const diamScreenKey = guessColumnKey(rows[0], [/diameter over screen/i]); const metalScreenKey = guessColumnKey(rows[0], [/metallic screen/i]); const capacitanceKey = guessColumnKey(rows[0], [/capacitance/i]); const reactanceKey = guessColumnKey(rows[0], [/reactance/i]); const electricalStressKey = guessColumnKey(rows[0], [/electrical stress/i]); const pullingForceKey = guessColumnKey(rows[0], [/max\. pulling force/i, /pulling force/i]); const heatingTrefoilKey = guessColumnKey(rows[0], [/heating time constant.*trefoil/i]); const heatingFlatKey = guessColumnKey(rows[0], [/heating time constant.*flat/i]); const currentAirTrefoilKey = guessColumnKey(rows[0], [/current ratings in air.*trefoil/i]); const currentAirFlatKey = guessColumnKey(rows[0], [/current ratings in air.*flat/i]); const currentGroundTrefoilKey = guessColumnKey(rows[0], [/current ratings in ground.*trefoil/i]); const currentGroundFlatKey = guessColumnKey(rows[0], [/current ratings in ground.*flat/i]); const scCurrentCondKey = guessColumnKey(rows[0], [/conductor shortcircuit current/i]); const scCurrentScreenKey = guessColumnKey(rows[0], [/screen shortcircuit current/i]); const cfgName = locale === 'de' ? 'Anzahl der Adern und Querschnitt' : 'Number of cores and cross-section'; const cfgOptions = rows .map(r => { const cs = normalizeValue(String(r?.[csKey] ?? '')); const v = voltageKey ? normalizeValue(String(r?.[voltageKey] ?? '')) : ''; if (!cs) return ''; if (!v) return cs; // Keep the existing config separator used by splitConfig(): "cross - voltage". // Add unit only if not already present. const vHasUnit = /\bkv\b/i.test(v); const vText = vHasUnit ? v : `${v} kV`; return `${cs} - ${vText}`; }) .filter(Boolean); if (!cfgOptions.length) return; const attrs = product.attributes || []; attrs.push({ name: cfgName, options: cfgOptions }); const pushRowAttr = (name: string, key: string | null, unit?: string) => { if (!key) return; const options = rows .map(r => normalizeValue(String(r?.[key] ?? ''))) .map(v => (unit && v && looksNumeric(v) ? `${v} ${unit}` : v)); if (options.filter(Boolean).length === 0) return; attrs.push({ name, options }); }; // These names are chosen so existing PDF regexes can detect them. pushRowAttr(locale === 'de' ? 'Außen-Ø' : 'Outer diameter', outerKey, 'mm'); pushRowAttr(locale === 'de' ? 'Gewicht' : 'Weight', weightKey, 'kg/km'); pushRowAttr(locale === 'de' ? 'DC-Leiterwiderstand (20°C)' : 'DC resistance at 20 °C', dcResKey, 'Ω/km'); const colValues = (key: string | null) => rows.map(r => normalizeValue(String(r?.[key ?? ''] ?? ''))); const addConstOrSmallList = (args: { name: string; existsRe: RegExp; key: string | null }) => { if (!args.key) return; const uniq = getUniqueNonEmpty(colValues(args.key)); if (!uniq.length) return; // If all rows share the same value, store as single option. if (uniq.length === 1) { pushAttrIfMissing({ product, name: args.name, options: [uniq[0]], existsRe: args.existsRe }); return; } // Otherwise store the unique set (TECHNICAL DATA will compact it). pushAttrIfMissing({ product, name: args.name, options: uniq, existsRe: args.existsRe }); }; addConstOrSmallList({ name: locale === 'de' ? 'Nennspannung' : 'Rated voltage', existsRe: /rated\s*voltage|voltage\s*rating|nennspannung|spannungsbereich/i, key: ratedVoltKey, }); addConstOrSmallList({ name: locale === 'de' ? 'Prüfspannung' : 'Test voltage', existsRe: /test\s*voltage|prüfspannung/i, key: testVoltKey, }); addConstOrSmallList({ name: locale === 'de' ? 'Temperaturbereich' : 'Operating temperature range', existsRe: /operating\s*temperature\s*range|temperature\s*range|temperaturbereich/i, key: tempRangeKey, }); addConstOrSmallList({ name: locale === 'de' ? 'Min. Verlegetemperatur' : 'Minimal temperature for laying', existsRe: /minimal\s*temperature\s*for\s*laying/i, key: minLayKey, }); addConstOrSmallList({ name: locale === 'de' ? 'Min. Lagertemperatur' : 'Minimal storage temperature', existsRe: /minimal\s*storage\s*temperature/i, key: minStoreKey, }); addConstOrSmallList({ name: locale === 'de' ? 'Max. Betriebstemperatur' : 'Maximal operating conductor temperature', existsRe: /maximal\s*operating\s*conductor\s*temperature|max\.?\s*operating/i, key: maxOpKey, }); addConstOrSmallList({ name: locale === 'de' ? 'Kurzschlusstemperatur (max.)' : 'Maximal short-circuit temperature', existsRe: /maximal\s*short-?circuit\s*temperature|short\s*circuit\s*temperature|kurzschlusstemperatur/i, key: maxScKey, }); addConstOrSmallList({ name: locale === 'de' ? 'Isolationsdicke (nom.)' : 'Nominal insulation thickness', existsRe: /nominal\s*insulation\s*thickness|insulation\s*thickness/i, key: insThkKey, }); addConstOrSmallList({ name: locale === 'de' ? 'Manteldicke (nom.)' : 'Nominal sheath thickness', existsRe: /nominal\s*sheath\s*thickness|minimum\s*sheath\s*thickness|manteldicke/i, key: sheathThkKey, }); addConstOrSmallList({ name: locale === 'de' ? 'Max. Leiterwiderstand' : 'Maximum resistance of conductor', existsRe: /maximum\s*resistance\s*of\s*conductor|max\.?\s*resistance|leiterwiderstand/i, key: maxResKey, }); // Add additional technical data from Excel files addConstOrSmallList({ name: locale === 'de' ? 'Leiter' : 'Conductor', existsRe: /conductor/i, key: conductorKey, }); addConstOrSmallList({ name: locale === 'de' ? 'Isolierung' : 'Insulation', existsRe: /insulation/i, key: insulationKey, }); addConstOrSmallList({ name: locale === 'de' ? 'Mantel' : 'Sheath', existsRe: /sheath/i, key: sheathKey, }); addConstOrSmallList({ name: locale === 'de' ? 'Norm' : 'Standard', existsRe: /norm|standard|iec|vde/i, key: normKey, }); addConstOrSmallList({ name: locale === 'de' ? 'Leiterdurchmesser' : 'Conductor diameter', existsRe: /diameter conductor|conductor diameter/i, key: diamCondKey, }); addConstOrSmallList({ name: locale === 'de' ? 'Isolierungsdurchmesser' : 'Insulation diameter', existsRe: /diameter over insulation|diameter insulation/i, key: diamInsKey, }); addConstOrSmallList({ name: locale === 'de' ? 'Schirmdurchmesser' : 'Screen diameter', existsRe: /diameter over screen|diameter screen/i, key: diamScreenKey, }); addConstOrSmallList({ name: locale === 'de' ? 'Metallischer Schirm' : 'Metallic screen', existsRe: /metallic screen/i, key: metalScreenKey, }); addConstOrSmallList({ name: locale === 'de' ? 'Max. Zugkraft' : 'Max. pulling force', existsRe: /max.*pulling force|pulling force/i, key: pullingForceKey, }); addConstOrSmallList({ name: locale === 'de' ? 'Elektrische Spannung Leiter' : 'Electrical stress conductor', existsRe: /electrical stress conductor/i, key: electricalStressKey, }); addConstOrSmallList({ name: locale === 'de' ? 'Elektrische Spannung Isolierung' : 'Electrical stress insulation', existsRe: /electrical stress insulation/i, key: electricalStressKey, }); addConstOrSmallList({ name: locale === 'de' ? 'Reaktanz' : 'Reactance', existsRe: /reactance/i, key: reactanceKey, }); addConstOrSmallList({ name: locale === 'de' ? 'Heizzeitkonstante trefoil' : 'Heating time constant trefoil', existsRe: /heating time constant.*trefoil/i, key: heatingTrefoilKey, }); addConstOrSmallList({ name: locale === 'de' ? 'Heizzeitkonstante flach' : 'Heating time constant flat', existsRe: /heating time constant.*flat/i, key: heatingFlatKey, }); addConstOrSmallList({ name: locale === 'de' ? 'Flammhemmend' : 'Flame retardant', existsRe: /flame retardant/i, key: flameKey, }); addConstOrSmallList({ name: locale === 'de' ? 'CPR-Klasse' : 'CPR class', existsRe: /cpr class/i, key: cprKey, }); addConstOrSmallList({ name: locale === 'de' ? 'Verpackung' : 'Packaging', existsRe: /packaging/i, key: packagingKey, }); addConstOrSmallList({ name: locale === 'de' ? 'Biegeradius' : 'Bending radius', existsRe: /bending radius/i, key: null, // Will be found in row-specific attributes }); addConstOrSmallList({ name: locale === 'de' ? 'Leiterform' : 'Shape of conductor', existsRe: /shape of conductor/i, key: shapeKey, }); addConstOrSmallList({ name: locale === 'de' ? 'Isolierungsfarbe' : 'Colour of insulation', existsRe: /colour of insulation/i, key: null, // Will be found in row-specific attributes }); addConstOrSmallList({ name: locale === 'de' ? 'Mantelfarbe' : 'Colour of sheath', existsRe: /colour of sheath/i, key: null, // Will be found in row-specific attributes }); addConstOrSmallList({ name: locale === 'de' ? 'RoHS/REACH' : 'RoHS/REACH', existsRe: /rohs.*reach/i, key: null, // Will be found in row-specific attributes }); product.attributes = attrs; if (process.env.PDF_DEBUG_EXCEL === '1') { console.log(`[excel] enriched product ${product.id} (${product.slug ?? stripHtml(product.name)}) with ${cfgOptions.length} configurations from excel`); } } function ensureExcelRowSpecificAttributes(product: ProductData, locale: 'en' | 'de'): void { const rows = findExcelRowsForProduct(product); if (!rows.length) return; const crossSectionAttr = findAttr(product, /configuration|konfiguration|aufbau|bezeichnung/i) || findAttr(product, /number of cores and cross-section|querschnitt|cross.?section|mm²|mm2/i); if (!crossSectionAttr || !crossSectionAttr.options?.length) return; const rowCount = crossSectionAttr.options.length; // Only enrich row-specific columns when row counts match (avoid wrong mapping). if (rows.length !== rowCount) return; const sample = rows[0] || {}; const keyOuter = guessColumnKey(sample, [/outer diameter \(approx\.?\)/i, /outer diameter of cable/i, /outer diameter\b/i, /diameter over screen/i]); const keyWeight = guessColumnKey(sample, [/weight \(approx\.?\)/i, /cable weight/i, /\bweight\b/i]); const keyDcRes = guessColumnKey(sample, [/dc resistance at 20/i, /maximum resistance of conductor/i, /resistance conductor/i]); const keyCap = guessColumnKey(sample, [/capacitance/i]); const keyIndTrefoil = guessColumnKey(sample, [/inductance,?\s*trefoil/i]); const keyIndAirFlat = guessColumnKey(sample, [/inductance in air,?\s*flat/i]); const keyIndGroundFlat = guessColumnKey(sample, [/inductance in ground,?\s*flat/i]); const keyIairTrefoil = guessColumnKey(sample, [/current ratings in air,?\s*trefoil/i]); const keyIairFlat = guessColumnKey(sample, [/current ratings in air,?\s*flat/i]); const keyIgroundTrefoil = guessColumnKey(sample, [/current ratings in ground,?\s*trefoil/i]); const keyIgroundFlat = guessColumnKey(sample, [/current ratings in ground,?\s*flat/i]); const keyScCond = guessColumnKey(sample, [/conductor shortcircuit current/i]); const keyScScreen = guessColumnKey(sample, [/screen shortcircuit current/i]); const keyBend = guessColumnKey(sample, [/bending radius/i, /min\. bending radius/i]); // Additional row-specific technical data const keyConductorDiameter = guessColumnKey(sample, [/conductor diameter/i, /diameter conductor/i]); const keyInsulationThickness = guessColumnKey(sample, [/nominal insulation thickness/i, /insulation thickness/i]); const keySheathThickness = guessColumnKey(sample, [/nominal sheath thickness/i, /minimum sheath thickness/i, /sheath thickness/i]); const keyCapacitance = guessColumnKey(sample, [/capacitance/i]); const keyInductanceTrefoil = guessColumnKey(sample, [/inductance.*trefoil/i]); const keyInductanceAirFlat = guessColumnKey(sample, [/inductance.*air.*flat/i]); const keyInductanceGroundFlat = guessColumnKey(sample, [/inductance.*ground.*flat/i]); const keyCurrentAirTrefoil = guessColumnKey(sample, [/current.*air.*trefoil/i]); const keyCurrentAirFlat = guessColumnKey(sample, [/current.*air.*flat/i]); const keyCurrentGroundTrefoil = guessColumnKey(sample, [/current.*ground.*trefoil/i]); const keyCurrentGroundFlat = guessColumnKey(sample, [/current.*ground.*flat/i]); const keyHeatingTimeTrefoil = guessColumnKey(sample, [/heating.*time.*trefoil/i]); const keyHeatingTimeFlat = guessColumnKey(sample, [/heating.*time.*flat/i]); const get = (k: string | null) => rows.map(r => normalizeValue(String(r?.[k ?? ''] ?? ''))); const withUnit = (vals: string[], unit: string) => vals.map(v => (v && looksNumeric(v) ? `${v} ${unit}` : v)); // Use labels that are already recognized by the existing PDF regexes. pushRowAttrIfMissing({ product, name: locale === 'de' ? 'Außen-Ø' : 'Outer diameter', options: withUnit(get(keyOuter), 'mm'), expectedLen: rowCount, existsRe: /outer\s*diameter|außen\s*durchmesser|außen-?ø/i, }); pushRowAttrIfMissing({ product, name: locale === 'de' ? 'Gewicht' : 'Weight', options: withUnit(get(keyWeight), 'kg/km'), expectedLen: rowCount, existsRe: /\bweight\b|gewicht/i, }); pushRowAttrIfMissing({ product, name: locale === 'de' ? 'DC-Leiterwiderstand (20°C)' : 'DC resistance at 20 °C', options: withUnit(get(keyDcRes), 'Ω/km'), expectedLen: rowCount, existsRe: /dc\s*resistance|max(?:imum)?\s*resistance|resistance\s+conductor|leiterwiderstand/i, }); pushRowAttrIfMissing({ product, name: locale === 'de' ? 'Kapazität (ca.)' : 'Capacitance (approx.)', options: withUnit(get(keyCap), 'μF/km'), expectedLen: rowCount, existsRe: /capacitance|kapazit/i, }); pushRowAttrIfMissing({ product, name: locale === 'de' ? 'Induktivität, trefoil (ca.)' : 'Inductance, trefoil (approx.)', options: withUnit(get(keyIndTrefoil), 'mH/km'), expectedLen: rowCount, existsRe: /inductance,?\s*trefoil/i, }); pushRowAttrIfMissing({ product, name: locale === 'de' ? 'Induktivität in Luft, flach (ca.)' : 'Inductance in air, flat (approx.)', options: withUnit(get(keyIndAirFlat), 'mH/km'), expectedLen: rowCount, existsRe: /inductance\s+in\s+air,?\s*flat/i, }); pushRowAttrIfMissing({ product, name: locale === 'de' ? 'Induktivität im Erdreich, flach (ca.)' : 'Inductance in ground, flat (approx.)', options: withUnit(get(keyIndGroundFlat), 'mH/km'), expectedLen: rowCount, existsRe: /inductance\s+in\s+ground,?\s*flat/i, }); pushRowAttrIfMissing({ product, name: locale === 'de' ? 'Strombelastbarkeit in Luft, trefoil' : 'Current ratings in air, trefoil', options: withUnit(get(keyIairTrefoil), 'A'), expectedLen: rowCount, existsRe: /current\s+ratings\s+in\s+air,?\s*trefoil/i, }); pushRowAttrIfMissing({ product, name: locale === 'de' ? 'Strombelastbarkeit in Luft, flach' : 'Current ratings in air, flat', options: withUnit(get(keyIairFlat), 'A'), expectedLen: rowCount, existsRe: /current\s+ratings\s+in\s+air,?\s*flat/i, }); pushRowAttrIfMissing({ product, name: locale === 'de' ? 'Strombelastbarkeit im Erdreich, trefoil' : 'Current ratings in ground, trefoil', options: withUnit(get(keyIgroundTrefoil), 'A'), expectedLen: rowCount, existsRe: /current\s+ratings\s+in\s+ground,?\s*trefoil/i, }); pushRowAttrIfMissing({ product, name: locale === 'de' ? 'Strombelastbarkeit im Erdreich, flach' : 'Current ratings in ground, flat', options: withUnit(get(keyIgroundFlat), 'A'), expectedLen: rowCount, existsRe: /current\s+ratings\s+in\s+ground,?\s*flat/i, }); pushRowAttrIfMissing({ product, name: locale === 'de' ? 'Kurzschlussstrom Leiter' : 'Conductor shortcircuit current', options: withUnit(get(keyScCond), 'kA'), expectedLen: rowCount, existsRe: /conductor\s+shortcircuit\s+current/i, }); pushRowAttrIfMissing({ product, name: locale === 'de' ? 'Kurzschlussstrom Schirm' : 'Screen shortcircuit current', options: withUnit(get(keyScScreen), 'kA'), expectedLen: rowCount, existsRe: /screen\s+shortcircuit\s+current/i, }); pushRowAttrIfMissing({ product, name: locale === 'de' ? 'Biegeradius (min.)' : 'Bending radius (min.)', options: withUnit(get(keyBend), 'mm'), expectedLen: rowCount, existsRe: /bending\s*radius|biegeradius/i, }); // Additional row-specific technical data pushRowAttrIfMissing({ product, name: locale === 'de' ? 'Leiterdurchmesser' : 'Conductor diameter', options: withUnit(get(keyConductorDiameter), 'mm'), expectedLen: rowCount, existsRe: /conductor diameter|diameter conductor/i, }); pushRowAttrIfMissing({ product, name: locale === 'de' ? 'Isolationsdicke' : 'Insulation thickness', options: withUnit(get(keyInsulationThickness), 'mm'), expectedLen: rowCount, existsRe: /insulation thickness|nominal insulation thickness/i, }); pushRowAttrIfMissing({ product, name: locale === 'de' ? 'Manteldicke' : 'Sheath thickness', options: withUnit(get(keySheathThickness), 'mm'), expectedLen: rowCount, existsRe: /sheath thickness|nominal sheath thickness/i, }); pushRowAttrIfMissing({ product, name: locale === 'de' ? 'Kapazität' : 'Capacitance', options: withUnit(get(keyCapacitance), 'μF/km'), expectedLen: rowCount, existsRe: /capacitance/i, }); pushRowAttrIfMissing({ product, name: locale === 'de' ? 'Induktivität trefoil' : 'Inductance trefoil', options: withUnit(get(keyInductanceTrefoil), 'mH/km'), expectedLen: rowCount, existsRe: /inductance.*trefoil/i, }); pushRowAttrIfMissing({ product, name: locale === 'de' ? 'Induktivität Luft flach' : 'Inductance air flat', options: withUnit(get(keyInductanceAirFlat), 'mH/km'), expectedLen: rowCount, existsRe: /inductance.*air.*flat/i, }); pushRowAttrIfMissing({ product, name: locale === 'de' ? 'Induktivität Erdreich flach' : 'Inductance ground flat', options: withUnit(get(keyInductanceGroundFlat), 'mH/km'), expectedLen: rowCount, existsRe: /inductance.*ground.*flat/i, }); pushRowAttrIfMissing({ product, name: locale === 'de' ? 'Strombelastbarkeit Luft trefoil' : 'Current rating air trefoil', options: withUnit(get(keyCurrentAirTrefoil), 'A'), expectedLen: rowCount, existsRe: /current.*air.*trefoil/i, }); pushRowAttrIfMissing({ product, name: locale === 'de' ? 'Strombelastbarkeit Luft flach' : 'Current rating air flat', options: withUnit(get(keyCurrentAirFlat), 'A'), expectedLen: rowCount, existsRe: /current.*air.*flat/i, }); pushRowAttrIfMissing({ product, name: locale === 'de' ? 'Strombelastbarkeit Erdreich trefoil' : 'Current rating ground trefoil', options: withUnit(get(keyCurrentGroundTrefoil), 'A'), expectedLen: rowCount, existsRe: /current.*ground.*trefoil/i, }); pushRowAttrIfMissing({ product, name: locale === 'de' ? 'Strombelastbarkeit Erdreich flach' : 'Current rating ground flat', options: withUnit(get(keyCurrentGroundFlat), 'A'), expectedLen: rowCount, existsRe: /current.*ground.*flat/i, }); pushRowAttrIfMissing({ product, name: locale === 'de' ? 'Heizzeitkonstante trefoil' : 'Heating time constant trefoil', options: withUnit(get(keyHeatingTimeTrefoil), 's'), expectedLen: rowCount, existsRe: /heating.*time.*trefoil/i, }); pushRowAttrIfMissing({ product, name: locale === 'de' ? 'Heizzeitkonstante flach' : 'Heating time constant flat', options: withUnit(get(keyHeatingTimeFlat), 's'), expectedLen: rowCount, existsRe: /heating.*time.*flat/i, }); } 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 = ''; 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 { 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 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; 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 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(); 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 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(' / '); // UX: avoid showing internal counts like "+8" in customer-facing PDFs. // Indicate truncation with an ellipsis. return `${uniq.slice(0, maxItems).join(' / ')} / ...`; } 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 there are only a few distinct values, listing is clearer than a range. if (uniq.length < 4) return { ok: false, text: '' }; uniq.sort((a, b) => a - b); const min = uniq[0]; const max = uniq[uniq.length - 1]; // UX: don't show internal counts like "n=…" in customer-facing datasheets. return { ok: true, text: `${formatNumber(min)}–${formatNumber(max)}` }; } 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; darkGray: ReturnType; lightGray: ReturnType; almostWhite: ReturnType; }): 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 { 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); } } // === EXCEL SOURCE ENRICHMENT (cross-section + key row-specific attrs) === // Some products have cross-section data on the website but not in the WP export. // When missing, we enrich from the source Excel sheets under `data/source/*`. ensureExcelCrossSectionAttributes(product, locale); // Even when cross-sections exist, the WP export can miss row-specific technical columns. // We add a best-effort set of numeric per-row attributes from Excel (only when row counts match). ensureExcelRowSpecificAttributes(product, locale); if (process.env.PDF_DEBUG_EXCEL === '1') { const hasAnyCfg = (product.attributes || []).some(a => /configuration|konfiguration|aufbau|bezeichnung|number of cores and cross-section|querschnitt|cross.?section|mm²|mm2/i.test(a.name), ); console.log(`[excel] after enrichment: product ${product.id} cfgAttrPresent=${hasAnyCfg}`); } // === 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); if (process.env.PDF_DEBUG_EXCEL === '1') { console.log( `[excel] crossSectionAttr=${crossSectionAttr ? normalizeValue(crossSectionAttr.name) : 'none'} rows=${rowCount} hasCross=${hasCrossSectionData}`, ); } // 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 pdfMode = process.env.PDF_MODE || 'compact'; // 'compact' or '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' }, { re: /conductor/i, fallbackLabel: locale === 'de' ? 'Leiter' : 'Conductor' }, { re: /insulation/i, fallbackLabel: locale === 'de' ? 'Isolierung' : 'Insulation' }, { re: /sheath/i, fallbackLabel: locale === 'de' ? 'Mantel' : 'Sheath' }, { re: /flame retardant|flammhemmend/i, fallbackLabel: locale === 'de' ? 'Flammhemmend' : 'Flame retardant' }, { re: /packaging|verpackung/i, fallbackLabel: locale === 'de' ? 'Verpackung' : 'Packaging' }, { re: /rohs.*reach/i, fallbackLabel: locale === 'de' ? 'RoHS/REACH' : 'RoHS/REACH' }, ]; const picked = new Set(); 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 (min–max 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 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 rowDcRes = findRowAttr(/dc resistance|leiterwiderstand/i); const rowCap = findRowAttr(/capacitance|kapazit/i); const rowCurrentAir = findRowAttr(/current.*air/i); const rowCurrentGround = findRowAttr(/current.*ground/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). // Build columns dynamically based on what's available const availableColumns: Array<{ key: string; label: string; attr: ProductData['attributes'][number] | null; unit: string }> = []; if (rowOuter) availableColumns.push({ key: 'outer', label: locale === 'de' ? 'Außen-Ø' : 'Outer Ø', attr: rowOuter, unit: 'mm' }); if (rowWeight) availableColumns.push({ key: 'weight', label: locale === 'de' ? 'Gewicht' : 'Weight', attr: rowWeight, unit: 'kg/km' }); if (rowDcRes) availableColumns.push({ key: 'dcres', label: locale === 'de' ? 'Widerstand' : 'Resistance', attr: rowDcRes, unit: 'Ω/km' }); if (rowCap) availableColumns.push({ key: 'cap', label: locale === 'de' ? 'Kapazität' : 'Capacitance', attr: rowCap, unit: 'μF/km' }); if (rowCurrentAir) availableColumns.push({ key: 'curair', label: locale === 'de' ? 'Strom Luft' : 'Current Air', attr: rowCurrentAir, unit: 'A' }); if (rowCurrentGround) availableColumns.push({ key: 'curground', label: locale === 'de' ? 'Strom Erdreich' : 'Current Ground', attr: rowCurrentGround, unit: 'A' }); if (availableColumns.length >= 2) { // Use first two available columns for the preview table const col1 = availableColumns[0]; const col2 = availableColumns[1]; const previewRows = configRows.map((cfg, i) => ({ config: normalizeValue(cfg), col1: formatMaybeWithUnit(getAttrCellValue(col1.attr ?? undefined, i, rowCount), col1.unit), col2: formatMaybeWithUnit(getAttrCellValue(col2.attr ?? undefined, i, rowCount), col2.unit), })); const previewTitle = locale === 'de' ? 'Konfigurationswerte (Auszug)' : 'Configuration values (excerpt)'; const yAfterPreview = drawRowPreviewTable({ title: previewTitle, rows: previewRows, headers: { config: locale === 'de' ? 'Konfiguration' : 'Configuration', col1: col1.label, col2: col2.label, }, getPage: () => page, page, y, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, lightGray, almostWhite, }); if (yAfterPreview >= contentMinY) y = yAfterPreview; } // Full table mode: show more technical columns if space allows and mode is enabled if (pdfMode === 'full' && availableColumns.length > 2) { // Try to show additional columns in a chunked table const additionalColumns = availableColumns.slice(2).map(col => ({ key: col.key, label: col.label, get: (i: number) => formatMaybeWithUnit(getAttrCellValue(col.attr ?? undefined, i, rowCount), col.unit), })); if (additionalColumns.length > 0 && y - 100 >= contentMinY) { y = drawTableChunked({ title: locale === 'de' ? 'Technische Daten (alle)' : 'Technical data (all)', configRows, columns: additionalColumns, locale, newPage, getPage: () => page, page, y, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, lightGray, almostWhite, maxDataColsPerTable: 3, }); } } } 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 };