#!/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; type KeyValueItem = { label: string; value: string; unit?: string }; type VoltageTableModel = { voltageLabel: string; metaItems: KeyValueItem[]; crossSections: string[]; columns: Array<{ key: string; label: string; get: (rowIndex: number) => string }>; }; function estimateDenseMetaGridHeight(itemsCount: number): number { // Must stay in sync with the layout constants in `drawDenseMetaGrid()`. const cols = itemsCount >= 7 ? 3 : 2; const cellH = 34; const titleH = 18; const headerPadY = 10; const rows = Math.ceil(Math.max(0, itemsCount) / cols); const boxH = headerPadY + titleH + rows * cellH + headerPadY; // `drawDenseMetaGrid()` returns the cursor below the box with additional spacing. return boxH + 18; } function normalizeUnit(unitRaw: string): string { const u = normalizeValue(unitRaw); if (!u) return ''; // Temperature units: show °C (not plain C). if (/^c$/i.test(u) || /^°c$/i.test(u)) return '°C'; // Common WinAnsi-safe normalizations. return u .replace(/Ω/gi, 'Ohm') .replace(/[\u00B5\u03BC]/g, 'u'); } function denseAbbrevLabel(args: { key: string; locale: 'en' | 'de'; unit?: string; withUnit?: boolean; }): string { const u = normalizeUnit(args.unit || ''); const withUnit = args.withUnit ?? true; const unitSafe = u .replace(/Ω/gi, 'Ohm') .replace(/[\u00B5\u03BC]/g, 'u'); const suffix = withUnit && unitSafe ? ` [${unitSafe}]` : ''; switch (args.key) { case 'DI': case 'RI': case 'Wi': case 'Ibl': case 'Ibe': case 'Wm': case 'Rbv': case 'Fzv': case 'G': return `${args.key}${suffix}`; case 'Ik_cond': return `Ik${suffix}`; case 'Ik_screen': return `Ik_s${suffix}`; case 'Ø': return `Ø${suffix}`; case 'Cond': return args.locale === 'de' ? 'Leiter' : 'Cond.'; case 'shape': return args.locale === 'de' ? 'Form' : 'Shape'; // Electrical case 'cap': return `C${suffix}`; case 'X': return `X${suffix}`; // Temperatures case 'temp_range': return `T${suffix}`; case 'max_op_temp': return `T_op${suffix}`; case 'max_sc_temp': return `T_sc${suffix}`; case 'min_store_temp': return `T_st${suffix}`; case 'min_lay_temp': return `T_lay${suffix}`; // Compliance case 'cpr': return `CPR${suffix}`; case 'flame': return `FR${suffix}`; // Voltages case 'test_volt': return `U_test${suffix}`; case 'rated_volt': return `U0/U${suffix}`; default: return args.key || ''; } } function metaFullLabel(args: { key: string; excelKey: string; locale: 'en' | 'de' }): string { const key = normalizeValue(args.key); if (args.locale === 'de') { switch (key) { case 'test_volt': return 'Prüfspannung'; case 'temp_range': return 'Temperaturbereich'; case 'max_op_temp': return 'Leitertemperatur (max.)'; case 'max_sc_temp': return 'Kurzschlusstemperatur (max.)'; case 'min_lay_temp': return 'Minimale Verlegetemperatur'; case 'min_store_temp': return 'Minimale Lagertemperatur'; case 'cpr': return 'CPR-Klasse'; case 'flame': return 'Flammhemmend'; default: return formatExcelHeaderLabel(args.excelKey); } } // EN switch (key) { case 'test_volt': return 'Test voltage'; case 'temp_range': return 'Operating temperature range'; case 'max_op_temp': return 'Conductor temperature (max.)'; case 'max_sc_temp': return 'Short-circuit temperature (max.)'; case 'min_lay_temp': return 'Minimum laying temperature'; case 'min_store_temp': return 'Minimum storage temperature'; case 'cpr': return 'CPR class'; case 'flame': return 'Flame retardant'; default: return formatExcelHeaderLabel(args.excelKey); } } function expandMetaLabel(label: string, locale: 'en' | 'de'): string { const l = normalizeValue(label); if (!l) return ''; // Safety net: the voltage-group meta grid must never show abbreviated labels. // (Even if upstream mapping changes, we keep customer-facing readability.) const mapDe: Record = { U_test: 'Prüfspannung', 'U0/U': 'Nennspannung', 'U_0/U': 'Nennspannung', T: 'Temperaturbereich', T_op: 'Leitertemperatur (max.)', T_sc: 'Kurzschlusstemperatur (max.)', T_lay: 'Minimale Verlegetemperatur', T_st: 'Minimale Lagertemperatur', CPR: 'CPR-Klasse', FR: 'Flammhemmend', }; const mapEn: Record = { U_test: 'Test voltage', 'U0/U': 'Rated voltage', 'U_0/U': 'Rated voltage', T: 'Operating temperature range', T_op: 'Conductor temperature (max.)', T_sc: 'Short-circuit temperature (max.)', T_lay: 'Minimum laying temperature', T_st: 'Minimum storage temperature', CPR: 'CPR class', FR: 'Flame retardant', }; const mapped = (locale === 'de' ? mapDe[l] : mapEn[l]) || ''; return mapped || label; } function technicalFullLabel(args: { key: string; excelKey: string; locale: 'en' | 'de' }): string { const k = normalizeValue(args.key); if (args.locale === 'de') { // Prefer stable internal keys (from columnMapping.key) to translate Technical Data labels. switch (k) { case 'DI': return 'Durchmesser über Isolierung'; case 'RI': return 'DC-Leiterwiderstand (20 °C)'; case 'Wi': return 'Isolationsdicke'; case 'Ibl': return 'Strombelastbarkeit in Luft (trefoil)'; case 'Ibe': return 'Strombelastbarkeit im Erdreich (trefoil)'; case 'Ik_cond': return 'Kurzschlussstrom Leiter'; case 'Ik_screen': return 'Kurzschlussstrom Schirm'; case 'Wm': return 'Manteldicke'; case 'Rbv': return 'Biegeradius (min.)'; case 'Ø': return 'Außen-Ø'; case 'Fzv': return 'Zugkraft (max.)'; case 'G': return 'Gewicht'; case 'Cond': case 'conductor': return 'Leiter'; case 'shape': return 'Leiterform'; case 'insulation': return 'Isolierung'; case 'sheath': return 'Mantel'; case 'cap': return 'Kapazität'; case 'ind_trefoil': return 'Induktivität (trefoil)'; case 'ind_air_flat': return 'Induktivität (Luft, flach)'; case 'ind_ground_flat': return 'Induktivität (Erdreich, flach)'; case 'X': return 'Reaktanz'; case 'test_volt': return 'Prüfspannung'; case 'rated_volt': return 'Nennspannung'; case 'temp_range': return 'Temperaturbereich'; case 'max_op_temp': return 'Leitertemperatur (max.)'; case 'max_sc_temp': return 'Kurzschlusstemperatur (max.)'; case 'min_store_temp': return 'Minimale Lagertemperatur'; case 'min_lay_temp': return 'Minimale Verlegetemperatur'; case 'cpr': return 'CPR-Klasse'; case 'flame': return 'Flammhemmend'; case 'packaging': return 'Verpackung'; case 'ce': return 'CE-Konformität'; case 'norm': return 'Norm'; case 'standard': return 'Standard'; case 'D_screen': return 'Durchmesser über Schirm'; case 'S_screen': return 'Metallischer Schirm'; default: break; } // Fallback: best-effort translation from the raw Excel header (prevents English in DE PDFs). const raw = normalizeValue(args.excelKey); if (!raw) return ''; return raw .replace(/\(approx\.?\)/gi, '(ca.)') .replace(/\bcapacitance\b/gi, 'Kapazität') .replace(/\binductance\b/gi, 'Induktivität') .replace(/\breactance\b/gi, 'Reaktanz') .replace(/\btest voltage\b/gi, 'Prüfspannung') .replace(/\brated voltage\b/gi, 'Nennspannung') .replace(/\boperating temperature range\b/gi, 'Temperaturbereich') .replace(/\bminimum sheath thickness\b/gi, 'Manteldicke (min.)') .replace(/\bsheath thickness\b/gi, 'Manteldicke') .replace(/\bnominal insulation thickness\b/gi, 'Isolationsdicke (nom.)') .replace(/\binsulation thickness\b/gi, 'Isolationsdicke') .replace(/\bdc resistance at 20\s*°?c\b/gi, 'DC-Leiterwiderstand (20 °C)') .replace(/\bouter diameter(?: of cable)?\b/gi, 'Außen-Ø') .replace(/\bbending radius\b/gi, 'Biegeradius') .replace(/\bpackaging\b/gi, 'Verpackung') .replace(/\bce\s*-?conformity\b/gi, 'CE-Konformität'); } // EN: keep as-is (Excel headers are already English). return normalizeValue(args.excelKey); } function compactNumericForLocale(value: string, locale: 'en' | 'de'): string { const v = normalizeValue(value); if (!v) return ''; // Handle special text patterns like "15xD (Single core); 12xD (Multi core)" // Compact to "15/12xD" or "10xD" for single values if (/\d+xD/.test(v)) { // Extract all number+xD patterns const numbers = []; const matches = Array.from(v.matchAll(/(\d+)xD/g)); for (let i = 0; i < matches.length; i++) numbers.push(matches[i][1]); if (numbers.length > 0) { // Remove duplicates while preserving order const unique: string[] = []; for (const num of numbers) { if (!unique.includes(num)) { unique.push(num); } } return unique.join('/') + 'xD'; } } // Normalize decimals for the target locale if it looks numeric-ish. const hasDigit = /\d/.test(v); if (!hasDigit) return v; const trimmed = v.replace(/\s+/g, ' ').trim(); // Keep ranges like "1.2–3.4" or "1.2-3.4". const parts = trimmed.split(/(–|-)/); const out = parts.map(p => { if (p === '–' || p === '-') return p; const s = p.trim(); if (!/^-?\d+(?:[\.,]\d+)?$/.test(s)) return p; const n = s.replace(/,/g, '.'); // Datasheets: do NOT use k/M suffixes (can be misleading for engineering values). // Only trim trailing zeros for compactness. const compact = n .replace(/\.0+$/, '') .replace(/(\.\d*?)0+$/, '$1') .replace(/\.$/, ''); // Preserve leading "+" when present (typical for temperature cells like "+90"). const hadPlus = /^\+/.test(s); const withPlus = hadPlus && !/^\+/.test(compact) ? `+${compact}` : compact; return locale === 'de' ? withPlus.replace(/\./g, ',') : withPlus; }); return out.join(''); } function compactCellForDenseTable(value: string, unit: string | undefined, locale: 'en' | 'de'): string { let v = normalizeValue(value); if (!v) return ''; const u = normalizeValue(unit || ''); if (u) { // Remove unit occurrences from the cell value (unit is already in the header). const esc = u.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); v = v.replace(new RegExp(`\\s*${esc}\\b`, 'ig'), '').trim(); // Common composite units appear in the data cells too; strip them. v = v .replace(/\bkg\s*\/\s*km\b/gi, '') .replace(/\bohm\s*\/\s*km\b/gi, '') .replace(/\bΩ\s*\/\s*km\b/gi, '') .replace(/\bu\s*f\s*\/\s*km\b/gi, '') .replace(/\bmh\s*\/\s*km\b/gi, '') .replace(/\bkA\b/gi, '') .replace(/\bmm\b/gi, '') .replace(/\bkv\b/gi, '') .replace(/\b°?c\b/gi, '') .replace(/\s+/g, ' ') .trim(); } // Normalize common separators to compact but readable tokens. // Example: "-35 - +90" -> "-35-+90", "-40°C / +90°C" -> "-40/+90". v = v .replace(/\s*–\s*/g, '-') .replace(/\s*-\s*/g, '-') .replace(/\s*\/\s*/g, '/') .replace(/\s+/g, ' ') .trim(); return compactNumericForLocale(v, locale); } function normalizeVoltageLabel(raw: string): string { const v = normalizeValue(raw); if (!v) return ''; // Keep common MV/HV notation like "6/10" or "12/20". const cleaned = v.replace(/\s+/g, ' '); if (/\bkv\b/i.test(cleaned)) return cleaned.replace(/\bkv\b/i, 'kV'); // If purely numeric-ish (e.g. "20"), add unit. const num = cleaned.match(/\d+(?:[\.,]\d+)?(?:\s*\/\s*\d+(?:[\.,]\d+)?)?/); if (!num) return cleaned; // If the string already contains other words, keep as-is. if (/[a-z]/i.test(cleaned)) return cleaned; return `${cleaned} kV`; } function parseVoltageSortKey(voltageLabel: string): number { const v = normalizeVoltageLabel(voltageLabel); // Sort by the last number (e.g. 6/10 -> 10, 12/20 -> 20) const nums = v .replace(/,/g, '.') .match(/\d+(?:\.\d+)?/g) ?.map(n => Number(n)) .filter(n => Number.isFinite(n)); if (!nums || nums.length === 0) return Number.POSITIVE_INFINITY; return nums[nums.length - 1]; } function formatExcelHeaderLabel(key: string, unit?: string): string { const k = normalizeValue(key); if (!k) return ''; const u = normalizeValue(unit || ''); // Prefer compact but clear labels. const compact = k .replace(/\s*\(approx\.?\)\s*/gi, ' (approx.) ') .replace(/\s+/g, ' ') .trim(); if (!u) return compact; // Avoid double units like "(mm) mm". if (new RegExp(`\\(${u.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')}\\)`, 'i').test(compact)) return compact; return `${compact} (${u})`; } function formatExcelCellValue(value: string, unit?: string, opts?: { appendUnit?: boolean }): string { const v = normalizeValue(value); if (!v) return ''; const u = normalizeValue(unit || ''); if (!u) return v; const appendUnit = opts?.appendUnit ?? true; // Only auto-append unit for pure numbers; many Excel cells already contain units. if (!appendUnit) return v; return looksNumeric(v) ? `${v} ${u}` : v; } function buildExcelModel(args: { product: ProductData; locale: 'en' | 'de'; }): { ok: boolean; technicalItems: KeyValueItem[]; voltageTables: VoltageTableModel[] } { const match = findExcelForProduct(args.product); if (!match || match.rows.length === 0) return { ok: false, technicalItems: [], voltageTables: [] }; const units = match.units || {}; // Filter rows to only include compatible column structures // This handles products that exist in multiple Excel files with different column structures const rows = match.rows; // Find the row with most columns as sample let sample = rows.find(r => r && Object.keys(r).length > 0) || {}; let maxColumns = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units').length; for (const r of rows) { const cols = Object.keys(r).filter(k => k && k !== 'Part Number' && k !== 'Units').length; if (cols > maxColumns) { sample = r; maxColumns = cols; } } // Map Excel column names to our compact/standardized keys. // This mapping covers all common Excel column names found in the source files // IMPORTANT: More specific patterns must come before generic ones to avoid false matches const columnMapping: Record = { // Cross-section and voltage (these are used for grouping, not shown in table) - MUST COME FIRST 'number of cores and cross-section': { header: 'Cross-section', unit: '', key: 'cross_section' }, 'ross section conductor': { header: 'Cross-section', unit: '', key: 'cross_section' }, // The 13 required headers (exact order as specified) 'diameter over insulation': { header: 'DI', unit: 'mm', key: 'DI' }, 'diameter over insulation (approx.)': { header: 'DI', unit: 'mm', key: 'DI' }, 'dc resistance at 20 °C': { header: 'RI', unit: 'Ohm/km', key: 'RI' }, 'dc resistance at 20°C': { header: 'RI', unit: 'Ohm/km', key: 'RI' }, 'resistance conductor': { header: 'RI', unit: 'Ohm/km', key: 'RI' }, 'maximum resistance of conductor': { header: 'RI', unit: 'Ohm/km', key: 'RI' }, 'insulation thickness': { header: 'Wi', unit: 'mm', key: 'Wi' }, 'nominal insulation thickness': { header: 'Wi', unit: 'mm', key: 'Wi' }, 'current ratings in air, trefoil': { header: 'Ibl', unit: 'A', key: 'Ibl' }, 'current ratings in air, trefoil*': { header: 'Ibl', unit: 'A', key: 'Ibl' }, 'current ratings in ground, trefoil': { header: 'Ibe', unit: 'A', key: 'Ibe' }, 'current ratings in ground, trefoil*': { header: 'Ibe', unit: 'A', key: 'Ibe' }, 'conductor shortcircuit current': { header: 'Ik', unit: 'kA', key: 'Ik_cond' }, 'screen shortcircuit current': { header: 'Ik', unit: 'kA', key: 'Ik_screen' }, 'sheath thickness': { header: 'Wm', unit: 'mm', key: 'Wm' }, 'minimum sheath thickness': { header: 'Wm', unit: 'mm', key: 'Wm' }, 'nominal sheath thickness': { header: 'Wm', unit: 'mm', key: 'Wm' }, 'bending radius': { header: 'Rbv', unit: 'mm', key: 'Rbv' }, 'bending radius (min.)': { header: 'Rbv', unit: 'mm', key: 'Rbv' }, 'outer diameter': { header: 'Ø', unit: 'mm', key: 'Ø' }, 'outer diameter (approx.)': { header: 'Ø', unit: 'mm', key: 'Ø' }, 'outer diameter of cable': { header: 'Ø', unit: 'mm', key: 'Ø' }, // Pulling force is typically expressed in N in datasheets. // Some sources appear to store values in kN (e.g. 4.5), we normalize in the cell getter. 'pulling force': { header: 'Fzv', unit: 'N', key: 'Fzv' }, 'max. pulling force': { header: 'Fzv', unit: 'N', key: 'Fzv' }, // Conductor material (we render this as a single column) 'conductor aluminum': { header: 'Cond.', unit: '', key: 'Cond' }, 'conductor copper': { header: 'Cond.', unit: '', key: 'Cond' }, 'weight': { header: 'G', unit: 'kg/km', key: 'G' }, 'weight (approx.)': { header: 'G', unit: 'kg/km', key: 'G' }, 'cable weight': { header: 'G', unit: 'kg/km', key: 'G' }, // Additional technical columns (to include ALL Excel data) // Specific material/property columns must come before generic 'conductor' pattern 'conductor diameter (approx.)': { header: 'Conductor diameter', unit: 'mm', key: 'cond_diam' }, 'conductor diameter': { header: 'Conductor diameter', unit: 'mm', key: 'cond_diam' }, 'diameter conductor': { header: 'Conductor diameter', unit: 'mm', key: 'cond_diam' }, 'diameter over screen': { header: 'Diameter over screen', unit: 'mm', key: 'D_screen' }, 'metallic screen mm2': { header: 'Metallic screen', unit: 'mm2', key: 'S_screen' }, 'metallic screen': { header: 'Metallic screen', unit: 'mm2', key: 'S_screen' }, 'reactance': { header: 'Reactance', unit: 'Ohm/km', key: 'X' }, 'capacitance (approx.)': { header: 'Capacitance', unit: 'uF/km', key: 'cap' }, 'capacitance': { header: 'Capacitance', unit: 'uF/km', key: 'cap' }, 'inductance, trefoil (approx.)': { header: 'Inductance trefoil', unit: 'mH/km', key: 'ind_trefoil' }, 'inductance, trefoil': { header: 'Inductance trefoil', unit: 'mH/km', key: 'ind_trefoil' }, 'inductance in air, flat (approx.)': { header: 'Inductance air flat', unit: 'mH/km', key: 'ind_air_flat' }, 'inductance in air, flat': { header: 'Inductance air flat', unit: 'mH/km', key: 'ind_air_flat' }, 'inductance in ground, flat (approx.)': { header: 'Inductance ground flat', unit: 'mH/km', key: 'ind_ground_flat' }, 'inductance in ground, flat': { header: 'Inductance ground flat', unit: 'mH/km', key: 'ind_ground_flat' }, 'current ratings in air, flat': { header: 'Current air flat', unit: 'A', key: 'cur_air_flat' }, 'current ratings in air, flat*': { header: 'Current air flat', unit: 'A', key: 'cur_air_flat' }, 'current ratings in ground, flat': { header: 'Current ground flat', unit: 'A', key: 'cur_ground_flat' }, 'current ratings in ground, flat*': { header: 'Current ground flat', unit: 'A', key: 'cur_ground_flat' }, 'heating time constant, trefoil*': { header: 'Heating time trefoil', unit: 's', key: 'heat_trefoil' }, 'heating time constant, trefoil': { header: 'Heating time trefoil', unit: 's', key: 'heat_trefoil' }, 'heating time constant, flat*': { header: 'Heating time flat', unit: 's', key: 'heat_flat' }, 'heating time constant, flat': { header: 'Heating time flat', unit: 's', key: 'heat_flat' }, // Temperature and other technical data 'maximal operating conductor temperature': { header: 'Max operating temp', unit: '°C', key: 'max_op_temp' }, 'maximal short-circuit temperature': { header: 'Max short-circuit temp', unit: '°C', key: 'max_sc_temp' }, 'operating temperature range': { header: 'Operating temp range', unit: '°C', key: 'temp_range' }, 'minimal storage temperature': { header: 'Min storage temp', unit: '°C', key: 'min_store_temp' }, 'minimal temperature for laying': { header: 'Min laying temp', unit: '°C', key: 'min_lay_temp' }, 'test voltage': { header: 'Test voltage', unit: 'kV', key: 'test_volt' }, 'rated voltage': { header: 'Rated voltage', unit: 'kV', key: 'rated_volt' }, // Material and specification data // Note: More specific patterns must come before generic ones to avoid conflicts // Conductor description/type (keep as technical/meta data, not as material column) 'conductor': { header: 'Conductor', unit: '', key: 'conductor' }, // Screen and tape columns 'copper wire screen and tape': { header: 'Copper screen', unit: '', key: 'copper_screen' }, 'CUScreen': { header: 'Copper screen', unit: '', key: 'copper_screen' }, 'conductive tape below screen': { header: 'Conductive tape below', unit: '', key: 'tape_below' }, 'non conducting tape above screen': { header: 'Non-conductive tape above', unit: '', key: 'tape_above' }, 'al foil': { header: 'Al foil', unit: '', key: 'al_foil' }, // Material properties 'shape of conductor': { header: 'Conductor shape', unit: '', key: 'shape' }, 'colour of insulation': { header: 'Insulation color', unit: '', key: 'color_ins' }, 'colour of sheath': { header: 'Sheath color', unit: '', key: 'color_sheath' }, 'insulation': { header: 'Insulation', unit: '', key: 'insulation' }, 'sheath': { header: 'Sheath', unit: '', key: 'sheath' }, 'norm': { header: 'Norm', unit: '', key: 'norm' }, 'standard': { header: 'Standard', unit: '', key: 'standard' }, 'cpr class': { header: 'CPR class', unit: '', key: 'cpr' }, 'flame retardant': { header: 'Flame retardant', unit: '', key: 'flame' }, 'self-extinguishing of single cable': { header: 'Flame retardant', unit: '', key: 'flame' }, 'packaging': { header: 'Packaging', unit: '', key: 'packaging' }, 'ce-conformity': { header: 'CE conformity', unit: '', key: 'ce' }, 'rohs/reach': { header: 'RoHS/REACH', unit: '', key: 'rohs_reach' }, }; // Get all Excel keys from sample const excelKeys = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units'); // Find which Excel keys match our mapping const matchedColumns: Array<{ excelKey: string; mapping: { header: string; unit: string; key: string } }> = []; for (const excelKey of excelKeys) { const normalized = normalizeValue(excelKey).toLowerCase(); for (const [pattern, mapping] of Object.entries(columnMapping)) { if (normalized === pattern.toLowerCase() || new RegExp(pattern, 'i').test(normalized)) { matchedColumns.push({ excelKey, mapping }); break; } } } // Deduplicate by mapping.key to avoid duplicate columns const seenKeys = new Set(); const deduplicated: typeof matchedColumns = []; for (const item of matchedColumns) { if (!seenKeys.has(item.mapping.key)) { seenKeys.add(item.mapping.key); deduplicated.push(item); } } matchedColumns.length = 0; matchedColumns.push(...deduplicated); // Separate into 13 required headers vs additional columns const requiredHeaderKeys = ['DI', 'RI', 'Wi', 'Ibl', 'Ibe', 'Ik_cond', 'Wm', 'Rbv', 'Ø', 'Fzv', 'Cond', 'G']; const isRequiredHeader = (key: string) => requiredHeaderKeys.includes(key); // Filter rows to only include those with the same column structure as sample const sampleKeys = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units').sort(); const compatibleRows = rows.filter(r => { const rKeys = Object.keys(r).filter(k => k && k !== 'Part Number' && k !== 'Units').sort(); return JSON.stringify(rKeys) === JSON.stringify(sampleKeys); }); if (compatibleRows.length === 0) return { ok: false, technicalItems: [], voltageTables: [] }; const csKey = guessColumnKey(sample, [/number of cores and cross-section/i, /cross.?section/i, /ross section conductor/i]) || null; const voltageKey = guessColumnKey(sample, [/rated voltage/i, /voltage rating/i, /nennspannung/i, /spannungs/i]) || null; if (!csKey) return { ok: false, technicalItems: [], voltageTables: [] }; // Group rows by voltage rating const byVoltage = new Map(); for (let i = 0; i < compatibleRows.length; i++) { const cs = normalizeValue(String(compatibleRows[i]?.[csKey] ?? '')); if (!cs) continue; const rawV = voltageKey ? normalizeValue(String(compatibleRows[i]?.[voltageKey] ?? '')) : ''; const voltageLabel = normalizeVoltageLabel(rawV || ''); const key = voltageLabel || (args.locale === 'de' ? 'Spannung unbekannt' : 'Voltage unknown'); const arr = byVoltage.get(key) ?? []; arr.push(i); byVoltage.set(key, arr); } const voltageTables: VoltageTableModel[] = []; const technicalItems: KeyValueItem[] = []; const voltageKeysSorted = Array.from(byVoltage.keys()).sort((a, b) => { const na = parseVoltageSortKey(a); const nb = parseVoltageSortKey(b); if (na !== nb) return na - nb; return a.localeCompare(b); }); // Track which columns are constant across ALL voltage groups (global constants) const globalConstantColumns = new Set(); // First pass: identify columns that are constant across all rows for (const { excelKey, mapping } of matchedColumns) { const values = compatibleRows.map(r => normalizeValue(String(r?.[excelKey] ?? ''))).filter(Boolean); const unique = Array.from(new Set(values.map(v => v.toLowerCase()))); if (unique.length === 1 && values.length > 0) { globalConstantColumns.add(excelKey); // Global constants belong to TECHNICAL DATA (shown once). const unit = normalizeUnit(units[excelKey] || mapping.unit || ''); const labelBase = technicalFullLabel({ key: mapping.key, excelKey, locale: args.locale }); const label = formatExcelHeaderLabel(labelBase, unit); const value = compactCellForDenseTable(values[0], unit, args.locale); const existing = technicalItems.find(t => t.label === label); if (!existing) technicalItems.push({ label, value, unit }); } } // Second pass: for each voltage group, separate constant vs variable columns for (const vKey of voltageKeysSorted) { const indices = byVoltage.get(vKey) || []; if (!indices.length) continue; const crossSections = indices.map(idx => normalizeValue(String(compatibleRows[idx]?.[csKey] ?? ''))); // Meta items: keep a consistent, compact set across products. // This is the "voltage-group meta header" (parameter block) above each table. const metaItems: KeyValueItem[] = []; const metaCandidates = new Map(); if (voltageKey) { const rawV = normalizeValue(String(compatibleRows[indices[0]]?.[voltageKey] ?? '')); metaItems.push({ label: args.locale === 'de' ? 'Spannung' : 'Voltage', value: normalizeVoltageLabel(rawV || ''), }); } // Which non-table fields we want to show consistently per voltage group. const metaKeyPriority = [ 'test_volt', 'temp_range', 'max_op_temp', 'max_sc_temp', 'min_lay_temp', 'min_store_temp', 'cpr', 'flame', ]; const metaKeyPrioritySet = new Set(metaKeyPriority); // Cross-section table is limited to the core industry columns (compact for A4). // To avoid “too little data” we always render the full set of core columns in the table, // even when a value is constant across the voltage group. const tableColumns: Array<{ excelKey: string; mapping: { header: string; unit: string; key: string } }> = []; const denseTableKeyOrder = [ // Conductor should be the first technical column (after designation). // This improves scanability in the cable industry (material/type is a primary discriminator). 'Cond', // Next highest priority for LV/MV: conductor shape/type (RE/RM/RMV/...). 'shape', // Electrical properties (when available) – high value for MV/HV engineering. 'cap', 'X', 'DI', 'RI', 'Wi', 'Ibl', 'Ibe', 'Ik_cond', 'Wm', 'Rbv', 'Ø', // Extra high-value dimensions/metal screen info (when available): common in MV/HV. // Keep close to Ø to support engineers comparing layer builds. 'D_screen', 'S_screen', 'Fzv', 'G', ] as const; const denseTableKeys = new Set(denseTableKeyOrder); // Extra data handling: // - global constants => TECHNICAL DATA // - voltage-group constants => metaItems // - voltage-group varying (non-core) => metaItems as ranges/lists (keeps A4 compact) // Pre-scan: detect if bending radius is expressed as xD (common in LV/Solar sheets) // so we can label the unit correctly (Rbv [xD] instead of Rbv [mm]). // Detect bending radius representation (mm vs xD) from the matched Excel column. const bendingRadiusKey = matchedColumns.find(c => c.mapping.key === 'Rbv')?.excelKey || null; let bendUnitOverride = ''; if (bendingRadiusKey) { const bendVals = indices .map(idx => normalizeValue(String(compatibleRows[idx]?.[bendingRadiusKey] ?? ''))) .filter(Boolean); if (bendVals.some(v => /\bxD\b/i.test(v))) bendUnitOverride = 'xD'; } // 1) Collect mapped columns. for (const { excelKey, mapping } of matchedColumns) { // Skip cross-section and voltage keys (these are used for grouping) if (excelKey === csKey || excelKey === voltageKey) continue; // Get values for this voltage group const values = indices.map(idx => normalizeValue(String(compatibleRows[idx]?.[excelKey] ?? ''))).filter(Boolean); if (values.length > 0) { const unique = Array.from(new Set(values.map(v => v.toLowerCase()))); let unit = normalizeUnit(units[excelKey] || mapping.unit || ''); if (mapping.key === 'Rbv' && bendUnitOverride) unit = bendUnitOverride; // Always keep the core 13 columns in the table (even if constant). if (denseTableKeys.has(mapping.key)) { tableColumns.push({ excelKey, mapping }); continue; } // For meta header: collect candidates, but only *display* a consistent subset. // Global constants are normally in TECHNICAL DATA, but we still allow them // into the voltage meta block if they are part of the priority set. if (globalConstantColumns.has(excelKey) && !metaKeyPrioritySet.has(mapping.key)) { continue; } const value = unique.length === 1 ? compactCellForDenseTable(values[0], unit, args.locale) : summarizeSmartOptions(excelKey, values); // Meta header: keep labels fully readable (no abbreviations). // Units are shown separately by the meta grid. const label = metaFullLabel({ key: mapping.key, excelKey, locale: args.locale }); metaCandidates.set(mapping.key, { label, value, unit }); } } // 1b) Materialize meta items in a stable order. // This keeps LV/MV/HV tables visually consistent (no "MV has much more in front"). for (const k of metaKeyPriority) { const item = metaCandidates.get(k); if (item && item.label && item.value) metaItems.push(item); } // 2) Build the compact cross-section table. // If a column is not available in the Excel source and we cannot derive it safely, // we omit it (empty columns waste A4 width and reduce readability). const mappedByKey = new Map(); for (const c of tableColumns) { if (!mappedByKey.has(c.mapping.key)) mappedByKey.set(c.mapping.key, c); } // Helper keys for derived values. // We derive DI (diameter over insulation) from Ø and Wm when DI is missing. const outerDiameterKey = (mappedByKey.get('Ø')?.excelKey || '') || null; const sheathThicknessKey = (mappedByKey.get('Wm')?.excelKey || '') || null; const canDeriveDenseKey = (k: (typeof denseTableKeyOrder)[number]): boolean => { if (k === 'DI') return Boolean(outerDiameterKey && sheathThicknessKey); if (k === 'Cond') return true; // derived from product designation when missing return false; }; const orderedTableColumns = denseTableKeyOrder .filter(k => mappedByKey.has(k) || canDeriveDenseKey(k)) .map(k => { const existing = mappedByKey.get(k); if (existing) return existing; return { excelKey: '', mapping: { header: k, unit: '', key: k }, }; }); // Debug: Check for duplicate keys if (process.env.PDF_DEBUG_EXCEL === '1') { const keys = tableColumns.map(c => c.mapping.key); const duplicates = keys.filter((k, i) => keys.indexOf(k) !== i); if (duplicates.length > 0) { console.log(`[debug] Duplicate keys found: ${duplicates.join(', ')}`); console.log(`[debug] All columns: ${tableColumns.map(c => c.mapping.key + '(' + c.mapping.header + ')').join(', ')}`); } } const columns = orderedTableColumns.map(({ excelKey, mapping }) => { // Default units for the compact column set (used when an Excel unit is missing). // We keep Excel units when available. const defaultUnitByKey: Record = { DI: 'mm', RI: 'Ohm/km', Wi: 'mm', Ibl: 'A', Ibe: 'A', Ik_cond: 'kA', Wm: 'mm', Rbv: 'mm', 'Ø': 'mm', Fzv: 'N', G: 'kg/km', }; let unit = normalizeUnit((excelKey ? units[excelKey] : '') || mapping.unit || defaultUnitByKey[mapping.key] || ''); if (mapping.key === 'Rbv' && bendUnitOverride) unit = bendUnitOverride; return { key: mapping.key, // Keep labels compact for dense tables; headerLabelFor() will use these. label: denseAbbrevLabel({ key: mapping.key, locale: args.locale, unit, withUnit: true }) || formatExcelHeaderLabel(excelKey, unit), get: (rowIndex: number) => { const srcRowIndex = indices[rowIndex]; const raw = excelKey ? normalizeValue(String(compatibleRows[srcRowIndex]?.[excelKey] ?? '')) : ''; const unitLocal = unit; // LV sheets (e.g. NA2XY): current ratings (Ibl/Ibe) and short-circuit current (Ik) // are typically not part of the source Excel. Keep cells empty (don’t guess). // However, for DI we can derive a usable engineering approximation. // Derived values (only when the source column is missing/empty). // DI (diameter over insulation): approx. from Ø and Wm when available. if (mapping.key === 'DI' && !raw && outerDiameterKey && sheathThicknessKey) { const odRaw = normalizeValue(String(compatibleRows[srcRowIndex]?.[outerDiameterKey] ?? '')); const wmRaw = normalizeValue(String(compatibleRows[srcRowIndex]?.[sheathThicknessKey] ?? '')); const od = parseNumericOption(odRaw); const wm = parseNumericOption(wmRaw); if (od !== null && wm !== null) { const di = od - 2 * wm; if (Number.isFinite(di) && di > 0) return `~${compactNumericForLocale(String(di), args.locale)}`; } } // Conductor material: if not present in Excel, derive from part number prefix. // NA… => Al, N… => Cu (common cable designation pattern in this dataset). if (mapping.key === 'Cond' && !raw) { const pn = normalizeExcelKey(args.product.name || args.product.slug || args.product.sku || ''); if (/^NA/.test(pn)) return 'Al'; if (/^N/.test(pn)) return 'Cu'; } // If bending radius is given as xD, keep it as-is (unit label reflects xD). if (mapping.key === 'Rbv' && /\bxD\b/i.test(raw)) return compactNumericForLocale(raw, args.locale); // HV source: "Min. bending radius" appears to be stored in meters (e.g. 1.70). // Convert to mm when we label it as mm. if (mapping.key === 'Rbv' && unitLocal.toLowerCase() === 'mm') { const n = parseNumericOption(raw); const looksLikeMeters = n !== null && n > 0 && n < 50 && /[\.,]\d{1,3}/.test(raw) && !/\dxD/i.test(raw); if (looksLikeMeters) return compactNumericForLocale(String(Math.round(n * 1000)), args.locale); } // Pulling force: some sources appear to store kN values (e.g. 4.5) without unit. // When header/unit is N and the value is small, normalize to N. if (mapping.key === 'Fzv' && unitLocal.toLowerCase() === 'n') { const n = parseNumericOption(raw); const looksLikeKN = n !== null && n > 0 && n < 100 && !/\bN\b/i.test(raw) && !/\bkN\b/i.test(raw); if (looksLikeKN) return compactNumericForLocale(String(Math.round(n * 1000)), args.locale); } return compactCellForDenseTable(raw, unitLocal, args.locale); }, }; }); voltageTables.push({ voltageLabel: vKey, metaItems, crossSections, columns }); // Debug: Show columns for this voltage if (process.env.PDF_DEBUG_EXCEL === '1') { console.log(`[debug] Voltage ${vKey}: ${columns.length} columns`); console.log(`[debug] Columns: ${columns.map(c => c.key).join(', ')}`); } } technicalItems.sort((a, b) => a.label.localeCompare(b.label)); // Debug: Show technical items if (process.env.PDF_DEBUG_EXCEL === '1') { console.log(`[debug] Technical items: ${technicalItems.map(t => t.label).join(', ')}`); } return { ok: true, technicalItems, voltageTables }; } // Unified key-value grid renderer for both metagrid and technical data function drawKeyValueTable(args: { title: string; items: Array<{ label: string; value: string; unit?: 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; mediumGray: ReturnType; expandLabels?: boolean; // true for metagrid, false for technical data }): number { let { page, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, mediumGray } = args; let y = args.y; const items = (args.items || []).filter(i => i.label && i.value); if (!items.length) return y; const lightGray = rgb(0.9020, 0.9137, 0.9294); const almostWhite = rgb(0.9725, 0.9765, 0.9804); const expandLabels = args.expandLabels ?? false; // Auto-determine columns based on item count const cols = items.length >= 7 ? 3 : 2; const colWidth = contentWidth / cols; const cellH = 34; const padX = 10; const titleH = 18; const headerPadY = 10; const labelSize = 7.25; const valueSize = 8.75; const labelYOff = 12; const valueYOff = 28; const rows = Math.ceil(items.length / cols); const boxH = headerPadY + titleH + rows * cellH + headerPadY; const needed = boxH + 10; if (y - needed < contentMinY) y = args.newPage(); page = args.getPage(); const boxTopY = y; const boxBottomY = boxTopY - boxH; // Outer frame page.drawRectangle({ x: margin, y: boxBottomY, width: contentWidth, height: boxH, borderColor: lightGray, borderWidth: 1, color: rgb(1, 1, 1), }); // Title band page.drawRectangle({ x: margin, y: boxTopY - (headerPadY + titleH), width: contentWidth, height: headerPadY + titleH, color: almostWhite, }); if (args.title) { page.drawText(args.title, { x: margin + padX, y: boxTopY - (headerPadY + 12), size: 10, font: fontBold, color: navy, maxWidth: contentWidth - padX * 2, }); } // Separator below title const gridTopY = boxTopY - (headerPadY + titleH); page.drawLine({ start: { x: margin, y: gridTopY }, end: { x: margin + contentWidth, y: gridTopY }, thickness: 0.75, color: lightGray, }); // Render grid cells for (let r = 0; r < rows; r++) { const rowTopY = gridTopY - r * cellH; const rowBottomY = rowTopY - cellH; // Zebra striping if (r % 2 === 0) { page.drawRectangle({ x: margin, y: rowBottomY, width: contentWidth, height: cellH, color: rgb(0.99, 0.992, 0.995), }); } // Row separator (except last) if (r !== rows - 1) { page.drawLine({ start: { x: margin, y: rowBottomY }, end: { x: margin + contentWidth, y: rowBottomY }, thickness: 0.5, color: lightGray, }); } for (let c = 0; c < cols; c++) { const idx = r * cols + c; const item = items[idx]; const x0 = margin + c * colWidth; // Column separator (except last) - draw from grid top to bottom for full height if (c !== cols - 1) { page.drawLine({ start: { x: x0 + colWidth, y: gridTopY }, end: { x: x0 + colWidth, y: boxBottomY + headerPadY }, thickness: 0.5, color: lightGray, }); } if (!item) continue; const labelText = expandLabels ? expandMetaLabel(item.label, args.locale) : item.label; const valueText = item.unit ? `${item.value} ${item.unit}` : item.value; const maxW = colWidth - padX * 2 - 2; const labelOneLine = ellipsizeToWidth(labelText, fontBold, labelSize, maxW); const valueOneLine = ellipsizeToWidth(valueText, font, valueSize, maxW); page.drawText(labelOneLine, { x: x0 + padX, y: rowTopY - labelYOff, size: labelSize, font: fontBold, color: mediumGray, }); page.drawText(valueOneLine, { x: x0 + padX, y: rowTopY - valueYOff, size: valueSize, font, color: darkGray, }); } } return Math.max(contentMinY, boxBottomY - 18); } // Backward compatibility wrapper for metagrid function drawDenseMetaGrid(args: { title: string; items: Array<{ label: string; value: string; unit?: 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; mediumGray: ReturnType; }): number { return drawKeyValueTable({ ...args, expandLabels: true }); } function prioritizeColumnsForDenseTable(args: { columns: VoltageTableModel['columns']; }): VoltageTableModel['columns'] { // Priority order: compact cross-section table columns first, then all additional technical data const priorityOrder = [ // Compact cross-section table columns (industry standard) // NOTE: The designation/configuration is the *first* table column already. // We put conductor material/type first among the technical columns for better scanability. // Note: Ik is represented by Ik_cond (conductor shortcircuit current) 'Cond', 'shape', 'cap', 'X', 'DI', 'RI', 'Wi', 'Ibl', 'Ibe', 'Ik_cond', 'Wm', 'Rbv', 'Ø', // Extra high-value dimensions/metal screen info (when present) 'D_screen', 'S_screen', 'Fzv', 'G', // Additional technical columns (in logical groups) // Dimensions and materials 'cond_diam', 'shape', 'conductor', 'insulation', 'sheath', // Temperatures 'max_op_temp', 'max_sc_temp', 'temp_range', 'min_store_temp', 'min_lay_temp', // Electrical properties 'cap', 'ind_trefoil', 'ind_air_flat', 'ind_ground_flat', // Current ratings (flat) 'cur_air_flat', 'cur_ground_flat', // Heating time constants 'heat_trefoil', 'heat_flat', // Voltage ratings 'test_volt', 'rated_volt', // Colors 'color_ins', 'color_sheath', // Materials and specs 'norm', 'standard', 'cpr', 'flame', 'packaging', 'ce', // Screen/tape layers 'tape_below', 'copper_screen', 'tape_above', 'al_foil', // Additional shortcircuit current 'Ik_screen' ]; return [...args.columns].sort((a, b) => { const ia = priorityOrder.indexOf(a.key); const ib = priorityOrder.indexOf(b.key); if (ia !== -1 && ib !== -1) return ia - ib; if (ia !== -1) return -1; if (ib !== -1) return 1; return a.key.localeCompare(b.key); }); } 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); if (!match?.rows || match.rows.length === 0) return []; const rows = match.rows; // Find the row with most columns as sample let sample = rows.find(r => r && Object.keys(r).length > 0) || {}; let maxColumns = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units').length; for (const r of rows) { const cols = Object.keys(r).filter(k => k && k !== 'Part Number' && k !== 'Units').length; if (cols > maxColumns) { sample = r; maxColumns = cols; } } // Filter to only rows with the same column structure as sample const sampleKeys = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units').sort(); const compatibleRows = rows.filter(r => { const rKeys = Object.keys(r).filter(k => k && k !== 'Part Number' && k !== 'Units').sort(); return JSON.stringify(rKeys) === JSON.stringify(sampleKeys); }); return compatibleRows; } 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 (20C)' : 'DC resistance at 20 C', dcResKey, 'Ohm/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 (20C)' : 'DC resistance at 20 C', options: withUnit(get(keyDcRes), 'Ohm/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), 'uF/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), 'uF/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; const drawBoxFrame = (boxTopY: number, rowsCount: number) => { const boxH = padY + headerH + rowsCount * rowH + padY; page = getPage(); page.drawRectangle({ x: margin, y: boxTopY - boxH, width: contentWidth, height: boxH, borderColor: lightGray, borderWidth: 1, color: rgb(1, 1, 1), }); // Header band for the title page.drawRectangle({ x: margin, y: boxTopY - headerH, width: contentWidth, height: headerH, color: almostWhite, }); return boxH; }; 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(); // Boxed + multi-page: render separate boxes per page segment. if (boxed && allowNewPage) { let i = 0; while (i < items.length) { // Compute how many rows fit in the remaining space. const available = y - contentMinY; const maxRows = Math.max(1, Math.floor((available - (headerH + padY * 2)) / rowH)); const maxItems = Math.max(2, maxRows * 2); const slice = items.slice(i, i + maxItems); const rowsCount = Math.ceil(slice.length / 2); const neededH = padY + headerH + rowsCount * rowH + padY; if (y - neededH < contentMinY) y = newPage(); drawBoxFrame(y, rowsCount); drawTitle(); let rowY = y; for (let j = 0; j < slice.length; j++) { const col = j % 2; const x = xBase + col * (colW + colGap); const { label, value } = slice[j]; 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; } y = rowY - rowH - padY; i += slice.length; if (i < items.length) y = newPage(); } return y; } // Boxed single-segment (current page) or plain continuation. if (boxed && items.length) { const rows = Math.ceil(items.length / 2); const neededH = padY + headerH + rows * rowH + padY; if (y - neededH < contentMinY) { if (!allowNewPage) return contentMinY - 1; y = newPage(); } drawBoxFrame(y, rows); } 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(); if (!boxed) drawTitle(); rowY = y; } // Don't truncate - allow text to fit naturally page.drawText(label, { x, y: rowY, size: 7.5, font: fontBold, color: mediumGray }); page.drawText(value, { x, y: rowY - 12, size: 9.5, font, color: darkGray }); 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') // µ / μ // directional arrows (WinAnsi can't encode these) .replace(/[\u2193]/g, 'v') // ↓ .replace(/[\u2191]/g, '^') // ↑ // degree symbol (WinAnsi-safe; keep for engineering units like °C) .replace(/[\u00B0]/g, '°'); // 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 ellipsizeToWidth(text: string, font: PDFFont, fontSize: number, maxWidth: number): string { // WinAnsi-safe ellipsis ("...") const t = normalizeValue(text); if (!t) return ''; if (font.widthOfTextAtSize(t, fontSize) <= maxWidth) return t; const ellipsis = '...'; const eW = font.widthOfTextAtSize(ellipsis, fontSize); if (eW >= maxWidth) return ''; // Binary search for max prefix that fits. let lo = 0; let hi = t.length; while (lo < hi) { const mid = Math.ceil((lo + hi) / 2); const s = t.slice(0, mid); if (font.widthOfTextAtSize(s, fontSize) + eW <= maxWidth) lo = mid; else hi = mid - 1; } const cut = Math.max(0, lo); return `${t.slice(0, cut)}${ellipsis}`; } 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 = { key?: string; 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 }>; firstColLabel?: string; dense?: boolean; onePage?: boolean; cellFormatter?: (value: string, columnKey: string) => 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 dense = args.dense ?? false; const onePage = args.onePage ?? false; const formatCell = args.cellFormatter ?? ((v: string) => v); // pdf-lib will wrap text when `maxWidth` is set. // For dense technical tables we want *no wrapping* (clip instead), so we replace spaces with NBSP. const noWrap = (s: string) => (dense ? String(s).replace(/ /g, '\u00A0') : String(s)); // pdf-lib does not clip text to maxWidth; it only uses it for line breaking. // To prevent header/body text from overlapping into neighboring columns, we manually truncate. const truncateToWidth = (text: string, f: PDFFont, size: number, maxW: number): string => { // IMPORTANT: do NOT call normalizeValue() here. // We may have inserted NBSP to force no-wrapping; normalizeValue() would convert it back to spaces. const t = String(text) .replace(/[\r\n\t]+/g, ' ') .replace(/\s+/g, ' ') .trim(); if (!t) return ''; if (maxW <= 4) return ''; if (f.widthOfTextAtSize(t, size) <= maxW) return t; const ellipsis = '…'; const eW = f.widthOfTextAtSize(ellipsis, size); if (eW >= maxW) return ''; // Binary search for max prefix that fits. let lo = 0; let hi = t.length; while (lo < hi) { const mid = Math.ceil((lo + hi) / 2); const s = t.slice(0, mid); if (f.widthOfTextAtSize(s, size) + eW <= maxW) lo = mid; else hi = mid - 1; } const cut = Math.max(0, lo); return `${t.slice(0, cut)}${ellipsis}`; }; // Dense table style (more columns, compact typography) // IMPORTANT: header labels must stay on a single line (no wrapped/stacked headers). const headerH = dense ? 16 : 16; let rowH = dense ? 16 : 16; // Increased to prevent row overlap let bodyFontSize = dense ? 6.5 : 8; let headerFontSize = dense ? 6.5 : 8; const headerFill = dense ? rgb(0.42, 0.44, 0.45) : lightGray; const headerText = dense ? rgb(1, 1, 1) : navy; let cellPadX = dense ? 4 : 6; const headerLabelFor = (col: { label: string; key?: string }): string => { // Dense tables: use compact cable industry abbreviations const raw = normalizeValue(col.label); if (!dense) return raw; const b = raw.toLowerCase(); const key = col.key || ''; const hasUnit = /\(|\[/.test(raw); const labelWithUnit = (abbr: string, defaultUnit: string): string => { // Prefer the actual unit we already have in the incoming label (from Excel units row). // Example: raw = "DC resistance at 20 °C (Ω/km)" => keep Ω/km. const m = raw.match(/[\[(]\s*([^\])]+?)\s*[\])]/); const unit = normalizeValue(m?.[1] || '') || defaultUnit; // WinAnsi safe: Ω => Ohm, µ => u (handled by stripHtml/normalizeValue) const unitSafe = unit.replace(/Ω/gi, 'Ohm').replace(/[\u00B5\u03BC]/g, 'u'); return `${abbr} [${unitSafe}]`; }; // Cable industry standard abbreviations (compact, WinAnsi-safe) // Column set: // - Bezeichnung (first column) // - DI, RI, Wi, Ibl, Ibe, Ik, Wm, Rbv, Ø, Fzv, Al, Cu, G if (key === 'configuration') return args.locale === 'de' ? 'Bezeichnung' : 'Designation'; if (key === 'DI' || /diameter\s+over\s+insulation/i.test(b)) return labelWithUnit('DI', 'mm'); if (key === 'RI' || /dc\s*resistance/i.test(b) || /resistance\s+conductor/i.test(b)) return labelWithUnit('RI', 'Ohm/km'); if (key === 'Wi' || /insulation\s+thickness/i.test(b) || /nominal\s+insulation\s+thickness/i.test(b)) return labelWithUnit('Wi', 'mm'); if (key === 'Ibl' || /current\s+ratings\s+in\s+air.*trefoil/i.test(b)) return labelWithUnit('Ibl', 'A'); if (key === 'Ibe' || /current\s+ratings\s+in\s+ground.*trefoil/i.test(b)) return labelWithUnit('Ibe', 'A'); if (key === 'Ik_cond' || /conductor.*shortcircuit/i.test(b)) return labelWithUnit('Ik', 'kA'); if (key === 'Wm' || /sheath\s+thickness/i.test(b) || /minimum\s+sheath\s+thickness/i.test(b)) return labelWithUnit('Wm', 'mm'); // Rbv can be given in mm or as xD (LV/solar). We keep the unit from the label. if (key === 'Rbv' || /bending\s+radius/i.test(b)) return labelWithUnit('Rbv', 'mm'); if (key === 'Ø' || /outer\s+diameter/i.test(b) || /outer\s+diameter\s+of\s+cable/i.test(b)) return labelWithUnit('Ø', 'mm'); if (key === 'Fzv' || /pulling\s+force/i.test(b)) return labelWithUnit('Fzv', 'N'); if (key === 'Al') return 'Al'; if (key === 'Cu') return 'Cu'; if (key === 'G' || /\bweight\b/i.test(b) || /cable\s+weight/i.test(b)) return labelWithUnit('G', 'kg/km'); // Cross-section (always needed as first column) if (/number of cores and cross-section/i.test(b) || /querschnitt/i.test(b)) { return args.locale === 'de' ? 'QS' : 'CS'; } // Additional technical columns (WinAnsi-compatible abbreviations) if (key === 'cond_diam') return labelWithUnit('D_cond', 'mm'); if (key === 'D_screen') return labelWithUnit('D_scr', 'mm'); if (key === 'S_screen') return labelWithUnit('A_scr', 'mm2'); if (key === 'cap') return labelWithUnit('C', 'uF/km'); if (key === 'X') return labelWithUnit('X', 'Ohm/km'); if (key === 'ind_trefoil') return labelWithUnit('L_t', 'mH/km'); if (key === 'ind_air_flat') return labelWithUnit('L_af', 'mH/km'); if (key === 'ind_ground_flat') return labelWithUnit('L_gf', 'mH/km'); if (key === 'cur_air_flat') return labelWithUnit('I_af', 'A'); if (key === 'cur_ground_flat') return labelWithUnit('I_gf', 'A'); if (key === 'heat_trefoil') return labelWithUnit('t_th_t', 's'); if (key === 'heat_flat') return labelWithUnit('t_th_f', 's'); if (key === 'max_op_temp') return labelWithUnit('T_op', '°C'); if (key === 'max_sc_temp') return labelWithUnit('T_sc', '°C'); if (key === 'temp_range') return labelWithUnit('T', '°C'); if (key === 'min_store_temp') return labelWithUnit('T_st', '°C'); if (key === 'min_lay_temp') return labelWithUnit('T_lay', '°C'); if (key === 'test_volt') return labelWithUnit('U_test', 'kV'); if (key === 'rated_volt') return labelWithUnit('U_0/U', 'kV'); if (key === 'conductor') return 'Cond'; if (key === 'insulation') return 'Iso'; if (key === 'sheath') return 'Sh'; if (key === 'norm') return 'Norm'; if (key === 'standard') return 'Std'; if (key === 'cpr') return 'CPR'; if (key === 'flame') return 'FR'; if (key === 'packaging') return 'Pack'; if (key === 'ce') return 'CE'; if (key === 'shape') return 'Shape'; if (key === 'color_ins') return 'C_iso'; if (key === 'color_sheath') return 'C_sh'; if (key === 'tape_below') return 'Tape v'; if (key === 'copper_screen') return 'Cu Scr'; if (key === 'tape_above') return 'Tape ^'; if (key === 'al_foil') return 'Al Foil'; // Fallback: keep Excel label (will be truncated) return raw; }; // Always include a first column (configuration / cross-section). const configCol = { key: 'configuration', label: args.firstColLabel || (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] : []; // UX: never show chunk fractions like "(1/2)". // If we need multiple chunks, we keep the same title and paginate naturally. const chunkTitle = title; const tableCols: TableColumn[] = [configCol, ...chunkCols]; // Header labels (may be simplified for dense tables). const headerLabels = tableCols.map(c => headerLabelFor({ label: c.label, key: c.key })); // Auto-fit column widths to content // Calculate required width for each column based on header and sample data // For dense tables with many columns, use more generous minimums const isDenseManyColumns = dense && tableCols.length >= 12; // When rendering many columns, auto-compact typography BEFORE measuring widths. // This prevents overflow where the later columns end up drawn off-page. if (isDenseManyColumns) { bodyFontSize = Math.max(5.3, Math.min(bodyFontSize, 5.8)); headerFontSize = Math.max(5.1, Math.min(headerFontSize, 5.5)); rowH = Math.max(9, Math.min(rowH, 10)); cellPadX = 3; } const minColWidth = isDenseManyColumns ? 24 : 20; // Minimum width in points const maxColWidth = isDenseManyColumns ? 110 : 120; // Cap widths for dense tables const colGap = isDenseManyColumns ? 1 : 2; // Gap between columns (smaller for dense) // Calculate required width for each column const requiredWidths = tableCols.map((col, i) => { const key = col.key || ''; const headerLabel = headerLabels[i] || headerLabelFor({ label: col.label, key: col.key }); // Prefer key-aware constraints so the table is readable on A4. // - configuration needs space (long designations) // - numeric columns should stay compact const isCfg = key === 'configuration'; // NOTE: keep configuration column capped; otherwise it starves the numeric columns. const cfgMaxAbs = dense ? 150 : 180; const cfgMaxRel = Math.floor(contentWidth * (dense ? 0.26 : 0.30)); const cfgMax = Math.max(120, Math.min(cfgMaxAbs, cfgMaxRel)); const cfgMin = dense ? 90 : 120; const minW = isCfg ? cfgMin : minColWidth; const maxW = isCfg ? cfgMax : (isDenseManyColumns ? 72 : maxColWidth); // Measure header width const headerWidth = fontBold.widthOfTextAtSize(headerLabel, headerFontSize); // Measure sample data widths using the *rendered* value (apply cell formatter). // Using only raw values can under-estimate (e.g. locale decimal, derived values). let maxDataWidth = 0; const sampleRows = Math.min(10, configRows.length); for (let r = 0; r < sampleRows; r++) { const rawVal = col.get(r); const rendered = formatCell(rawVal, key); const dataWidth = font.widthOfTextAtSize(String(rendered), bodyFontSize); maxDataWidth = Math.max(maxDataWidth, dataWidth); } // Take the maximum of header and data const padding = isDenseManyColumns ? cellPadX * 1.5 : cellPadX * 2; const contentWidthNeeded = Math.max(headerWidth, maxDataWidth) + padding; // Clamp to min/max return Math.max(minW, Math.min(maxW, contentWidthNeeded)); }); // Calculate total required width (including gaps) const baseTotal = requiredWidths.reduce((sum, w) => sum + w, 0) + (tableCols.length - 1) * colGap; // If we have extra space, give it primarily to the configuration column // (best readability gain) and then distribute any remainder proportionally. const widthsWithExtra = [...requiredWidths]; if (baseTotal < contentWidth && tableCols.length > 0) { let remaining = contentWidth - baseTotal; const cfgIndex = tableCols.findIndex(c => (c.key || '') === 'configuration'); if (cfgIndex >= 0 && remaining > 0) { // Only give the configuration column a controlled share of the remaining space. // This keeps numeric columns readable and consistent across tables. const cfgMaxAbs = dense ? 150 : 180; const cfgMaxRel = Math.floor(contentWidth * (dense ? 0.26 : 0.30)); const cfgMax = Math.max(120, Math.min(cfgMaxAbs, cfgMaxRel)); const cfgRemainingCap = Math.min(remaining, Math.floor(remaining * (dense ? 0.35 : 0.45))); const add = Math.max(0, Math.min(cfgMax - widthsWithExtra[cfgIndex], cfgRemainingCap)); widthsWithExtra[cfgIndex] += add; remaining -= add; } if (remaining > 0) { const sum = widthsWithExtra.reduce((a, b) => a + b, 0) || 1; for (let i = 0; i < widthsWithExtra.length; i++) { if (remaining <= 0) break; const share = widthsWithExtra[i] / sum; const add = Math.min(remaining, remaining * share); widthsWithExtra[i] += add; remaining -= add; } } } const totalRequiredWidth = widthsWithExtra.reduce((sum, w) => sum + w, 0) + (tableCols.length - 1) * colGap; // Scale to fit available content width if needed // Dense tables MUST fit on the page; allow stronger scaling when needed. let scaleFactor = totalRequiredWidth > contentWidth ? contentWidth / totalRequiredWidth : 1; if (!isDenseManyColumns) { // Keep regular tables from becoming too small. scaleFactor = Math.max(scaleFactor, 0.9); } // Scale widths (gaps are also scaled) const widthsPt = widthsWithExtra.map(w => w * scaleFactor); const scaledGap = colGap * scaleFactor; const ensureSpace = (needed: number) => { if (y - needed < contentMinY) y = newPage(); page = getPage(); }; // One-page mode: adapt row height so the full voltage table can fit on the page. if (onePage) { const rows = configRows.length; const available = y - contentMinY - 10 - 12 /*title*/; const maxRowH = Math.floor((available - headerH) / Math.max(1, rows)); rowH = Math.max(8, Math.min(rowH, maxRowH)); bodyFontSize = Math.max(6, Math.min(bodyFontSize, rowH - 3)); headerFontSize = Math.max(6, Math.min(headerFontSize, rowH - 3)); } ensureSpace(14 + headerH + rowH * 2); if (chunkTitle) { page.drawText(chunkTitle, { x: margin, y, size: 10, font: fontBold, color: navy, }); y -= dense ? 14 : 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(); if (chunkTitle) { 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: headerFill, }); const headerTextY = y - headerH + Math.floor((headerH - headerFontSize) / 2) + 1; let x = margin; for (let i = 0; i < tableCols.length; i++) { const hl = headerLabels[i] || headerLabelFor({ label: tableCols[i].label, key: tableCols[i].key }); const colWidth = widthsPt[i]; const colMaxW = colWidth - cellPadX * 2; // Overlap guard: always truncate to column width (pdf-lib doesn't clip by itself). // Don't truncate headers - use auto-fit widths page.drawText(noWrap(String(hl)), { x: x + cellPadX, y: headerTextY, size: headerFontSize, font: fontBold, color: headerText, // DO NOT set maxWidth in dense mode (it triggers wrapping instead of clipping). ...(dense ? {} : { maxWidth: colMaxW }), }); x += colWidth + scaledGap; } y -= headerH; }; drawHeader(); for (let r = 0; r < configRows.length; r++) { if (!onePage && y - rowH < contentMinY) { y = newPage(); page = getPage(); if (chunkTitle) { page.drawText(chunkTitle, { x: margin, y, size: 12, font: fontBold, color: navy, }); y -= 16; } drawHeader(); } if (onePage && y - rowH < contentMinY) { // In one-page mode we must not paginate. Clip remaining rows. break; } 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++) { const raw = tableCols[c].get(r); const txt = formatCell(raw, tableCols[c].key || ''); const colWidth = widthsPt[c]; const colMaxW = colWidth - cellPadX * 2; // Don't truncate - use auto-fit widths to ensure everything fits page.drawText(noWrap(txt), { x: x + cellPadX, y: y - (rowH - 5), // Adjusted for new rowH of 16 size: bodyFontSize, font, color: darkGray, ...(dense ? {} : { maxWidth: colMaxW }), }); x += colWidth + scaledGap; } y -= rowH; } y -= dense ? 20 : 24; } 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. // Keep header compact to free vertical space for technical tables. const headerH = 52; const dividerY = yStart - headerH; ctx.headerDividerY = dividerY; page.drawRectangle({ x: 0, y: dividerY, width, height: headerH, color: colors.headerBg, }); const qrSize = 36; const qrGap = 12; const rightReserved = qrImage ? qrSize + qrGap : 0; // Left: logo (preferred) or typographic fallback if (logoImage) { const maxLogoW = 120; const maxLogoH = 24; 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: keep breathing room below the header, but use page height efficiently. return dividerY - 22; } 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 pageSizePortrait: [number, number] = [595.28, 841.89]; // DIN A4 portrait const pageSizeLandscape: [number, number] = [841.89, 595.28]; // DIN A4 landscape let page = pdfDoc.addPage(pageSizePortrait); 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, }; const syncCtxForPage = (p: PDFPage) => { const sz = p.getSize(); ctx.page = p; ctx.width = sz.width; ctx.height = sz.height; ctx.contentWidth = sz.width - 2 * ctx.margin; }; const drawPageBackground = (p: PDFPage) => { const { width: w, height: h } = p.getSize(); p.drawRectangle({ x: 0, y: 0, width: w, height: h, color: rgb(1, 1, 1), }); }; const drawProductNameOnPage = (p: PDFPage, yStart: number): number => { const name = stripHtml(product.name); const maxW = ctx.contentWidth; const line = wrapText(name, fontBold, 12, maxW).slice(0, 1)[0] || name; p.drawText(line, { x: margin, y: yStart, size: 12, font: fontBold, color: navy, maxWidth: maxW, }); return yStart - 18; }; // Real multi-page support. // Each new page repeats header + footer for print-friendly, consistent scanning. const newPage = (opts?: { includeProductName?: boolean; landscape?: boolean }): number => { page = pdfDoc.addPage(opts?.landscape ? pageSizeLandscape : pageSizePortrait); syncCtxForPage(page); drawPageBackground(page); drawFooter(ctx); let yStart = drawHeader(ctx, ctx.height - ctx.margin); if (opts?.includeProductName) yStart = drawProductNameOnPage(page, yStart); return yStart; }; 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 syncCtxForPage(page); drawPageBackground(page); drawFooter(ctx); let y = drawHeader(ctx, ctx.height - ctx.margin); // === PRODUCT HEADER === const productName = stripHtml(product.name); const cats = (product.categories || []).map(c => stripHtml(c.name)).join(' • '); // First page: keep the header compact so technical tables start earlier. const titleW = contentWidth; const titleSize = 18; const titleLineH = 21; const nameLines = wrapText(productName, fontBold, titleSize, titleW); const shownNameLines = nameLines.slice(0, 1); for (const line of shownNameLines) { if (y - titleLineH < contentMinY) y = newPage(); page.drawText(line, { x: margin, y, size: titleSize, 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.md; } // Separator after product header // No dividing lines on first page (cleaner, more space-efficient). // rule(DS.space.xs, DS.space.md); // === 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 ? 96 : 120; const afterHeroGap = DS.space.lg; if (!hasSpace(heroH + afterHeroGap)) { // Shrink to remaining space (but keep it usable). heroH = Math.max(84, 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; // Fit image into the box without cutting it off (contain). // Technical images often must remain fully visible. const sharp = await getSharp(); const contained = await sharp(Buffer.from(heroPng.pngBytes)) .resize({ width: 1200, height: Math.round((1200 * boxH) / boxW), fit: 'contain', background: { r: 248, g: 249, b: 250, alpha: 1 }, }) .png() .toBuffer(); const heroImage = await pdfDoc.embedPng(contained); 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 (optional) === if (product.shortDescriptionHtml || product.descriptionHtml) { const desc = stripHtml(product.shortDescriptionHtml || product.descriptionHtml); const descLineH = 14; // Keep full length: render all lines, paginate if needed. const boxPadX = DS.space.md; const boxPadY = DS.space.md; sectionTitle(labels.description); const maxTextW = contentWidth - boxPadX * 2; const descLines = wrapText(desc, font, DS.type.body, maxTextW); // Draw as boxed paragraphs, paginating as needed. // We keep a single consistent box per page segment. let i = 0; while (i < descLines.length) { // Ensure we have enough space for at least 2 lines. const minLines = 2; const minBoxH = boxPadY * 2 + descLineH * minLines; if (!hasSpace(minBoxH + DS.space.md)) { y = newPage({ includeProductName: true }); } // Compute how many lines we can fit on the current page. const availableH = y - contentMinY - DS.space.md; const maxLinesThisPage = Math.max(minLines, Math.floor((availableH - boxPadY * 2) / descLineH)); const slice = descLines.slice(i, i + maxLinesThisPage); const boxH = boxPadY * 2 + descLineH * slice.length; 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, }); let ty = boxTop - boxPadY - DS.type.body; for (const line of slice) { page.drawText(line, { x: margin + boxPadX, y: ty, size: DS.type.body, font, color: darkGray, }); ty -= descLineH; } y = boxBottom - DS.space.md; i += slice.length; // If there is more text, continue on a new page segment (keeps layout stable). if (i < descLines.length) { y = newPage({ includeProductName: true }); sectionTitle(labels.description); } } rule(0, DS.space.lg); } // === EXCEL MODEL === // Priority: render ALL Excel data (technical + per-voltage tables). const excelModel = buildExcelModel({ product, locale }); // Keep the old enrichment as a fallback path only. // (Some products may not match Excel keying; then we still have a usable PDF.) ensureExcelCrossSectionAttributes(product, locale); ensureExcelRowSpecificAttributes(product, locale); if (excelModel.ok) { const tables = excelModel.voltageTables; const hasMultipleVoltages = tables.length > 1; // TECHNICAL DATA (shared across all cross-sections) const techTitle = locale === 'de' ? 'TECHNISCHE DATEN' : 'TECHNICAL DATA'; const techItems = excelModel.technicalItems; // Track if we've rendered any content before the tables let hasRenderedContent = false; if (techItems.length) { y = drawKeyValueGrid({ title: techTitle, items: techItems, newPage: () => newPage({ includeProductName: true }), getPage: () => page, page, y, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, mediumGray, lightGray, almostWhite, allowNewPage: true, boxed: true, }); hasRenderedContent = true; // Add spacing after technical data section before first voltage table if (y - 20 >= contentMinY) y -= 20; } // CROSS-SECTION DATA: one table per voltage rating const firstColLabel = locale === 'de' ? 'Adern & Querschnitt' : 'Cores & cross-section'; for (const t of tables) { // Maintain a minimum space between tables (even when staying on the same page). // This avoids visual collisions between the previous table and the next meta header. if (hasRenderedContent && y - 20 >= contentMinY) y -= 20; // Check if we need a new page for this voltage table // Estimate: meta block (if shown) + table header + at least 3 data rows const estimateMetaH = (itemsCount: number) => { // Always render a voltage-group header (even for single-voltage products) // so all datasheets look consistent. const titleH = 14; const rowH = 14; const cols = 3; const rows = Math.max(1, Math.ceil(Math.max(0, itemsCount) / cols)); return titleH + rows * rowH + 8; }; const minTableH = 16 /*header*/ + 9 * 3 /*3 rows*/ + 10 /*pad*/; const minNeeded = estimateMetaH((t.metaItems || []).length) + minTableH; if (y - minNeeded < contentMinY) { y = newPage({ includeProductName: true, landscape: false }); } // Top meta block: always render per voltage group. // This ensures a consistent structure across products (LV/MV/HV) and makes the // voltage group visible in the heading. y = drawDenseMetaGrid({ title: `${labels.crossSection} — ${t.voltageLabel}`, items: t.metaItems, locale, newPage: () => newPage({ includeProductName: true, landscape: false }), getPage: () => page, page, y, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, mediumGray, }); // Breathing room before the dense table y -= 14; // Cross-section table: exactly 13 columns as specified // Order: DI, RI, Wi, Ibl, Ibe, Ik, Wm, Rbv, Ø, Fzv, Al, Cu, G const tableColumns = prioritizeColumnsForDenseTable({ columns: t.columns }); // Format dense table cells: compact decimals, no units in cells const cellFormatter = (value: string, columnKey: string) => { return compactNumericForLocale(value, locale); }; // Table title: keep empty because the voltage-group title is already shown in the meta block. const tableTitle = ''; y = drawTableChunked({ title: tableTitle, configRows: t.crossSections, columns: tableColumns, firstColLabel, dense: true, onePage: false, // Allow multiple pages for large tables cellFormatter, locale, newPage: () => newPage({ includeProductName: true, landscape: false }), getPage: () => page, page, y, margin, contentWidth: ctx.contentWidth, contentMinY, font, fontBold, navy, darkGray, lightGray, almostWhite, maxDataColsPerTable: 10_000, // All columns in one table }); hasRenderedContent = true; } } else { // Fallback (non-Excel products): keep existing behavior minimal const note = locale === 'de' ? 'Hinweis: Für dieses Produkt liegen derzeit keine Excel-Daten vor.' : 'Note: No Excel data is available for this product yet.'; y = drawKeyValueGrid({ title: locale === 'de' ? 'TECHNISCHE DATEN' : 'TECHNICAL DATA', items: [{ label: locale === 'de' ? 'Quelle' : 'Source', value: note }], newPage: () => newPage({ includeProductName: true }), getPage: () => page, page, y, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, mediumGray, lightGray, almostWhite, allowNewPage: true, boxed: true, }); } // 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; } // Dev convenience: generate only one locale / one product subset. // IMPORTANT: apply filters BEFORE PDF_LIMIT so the limit works within the filtered set. let products = allProducts; const onlyLocale = normalizeValue(String(process.env.PDF_LOCALE || '')).toLowerCase(); if (onlyLocale === 'de' || onlyLocale === 'en') { products = products.filter(p => (p.locale || 'en') === onlyLocale); } const match = normalizeValue(String(process.env.PDF_MATCH || '')).toLowerCase(); if (match) { products = products.filter(p => { const hay = [p.slug, p.translationKey, p.sku, stripHtml(p.name)] .filter(Boolean) .join(' ') .toLowerCase(); return hay.includes(match); }); } // 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'); products = Number.isFinite(limit) && limit > 0 ? products.slice(0, limit) : products; const enProducts = products.filter(p => (p.locale || 'en') === 'en'); const deProducts = products.filter(p => (p.locale || 'en') === '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 };