diff --git a/data/source/high-voltage.xlsx b/data/source/high-voltage.xlsx new file mode 100644 index 00000000..cb3edc0c Binary files /dev/null and b/data/source/high-voltage.xlsx differ diff --git a/data/source/low-voltage-KM.xlsx b/data/source/low-voltage-KM.xlsx new file mode 100644 index 00000000..2d7fbeb9 Binary files /dev/null and b/data/source/low-voltage-KM.xlsx differ diff --git a/data/source/medium-voltage-KM.xlsx b/data/source/medium-voltage-KM.xlsx new file mode 100644 index 00000000..427ec552 Binary files /dev/null and b/data/source/medium-voltage-KM.xlsx differ diff --git a/data/source/solar-cables.xlsx b/data/source/solar-cables.xlsx new file mode 100644 index 00000000..b6b432e6 Binary files /dev/null and b/data/source/solar-cables.xlsx differ diff --git a/public/datasheets/h1z2z2-k-de.pdf b/public/datasheets/h1z2z2-k-de.pdf index 2e29cdf1..b8fceb40 100644 Binary files a/public/datasheets/h1z2z2-k-de.pdf and b/public/datasheets/h1z2z2-k-de.pdf differ diff --git a/public/datasheets/h1z2z2-k-en.pdf b/public/datasheets/h1z2z2-k-en.pdf index 07a02c18..48e72b76 100644 Binary files a/public/datasheets/h1z2z2-k-en.pdf and b/public/datasheets/h1z2z2-k-en.pdf differ diff --git a/public/datasheets/n2x2y-2-de.pdf b/public/datasheets/n2x2y-2-de.pdf index eff5688d..f58b511f 100644 Binary files a/public/datasheets/n2x2y-2-de.pdf and b/public/datasheets/n2x2y-2-de.pdf differ diff --git a/public/datasheets/n2x2y-en.pdf b/public/datasheets/n2x2y-en.pdf index 3ad96bc5..ed456cd9 100644 Binary files a/public/datasheets/n2x2y-en.pdf and b/public/datasheets/n2x2y-en.pdf differ diff --git a/public/datasheets/n2xfk2y-de.pdf b/public/datasheets/n2xfk2y-de.pdf index d02e2676..8aeec4cc 100644 Binary files a/public/datasheets/n2xfk2y-de.pdf and b/public/datasheets/n2xfk2y-de.pdf differ diff --git a/public/datasheets/n2xfk2y-en.pdf b/public/datasheets/n2xfk2y-en.pdf index b2f42a06..0febfd3c 100644 Binary files a/public/datasheets/n2xfk2y-en.pdf and b/public/datasheets/n2xfk2y-en.pdf differ diff --git a/public/datasheets/n2xfkld2y-de.pdf b/public/datasheets/n2xfkld2y-de.pdf index 4ded6191..2045899e 100644 Binary files a/public/datasheets/n2xfkld2y-de.pdf and b/public/datasheets/n2xfkld2y-de.pdf differ diff --git a/public/datasheets/n2xfkld2y-en.pdf b/public/datasheets/n2xfkld2y-en.pdf index 5e4f2562..22fe0b79 100644 Binary files a/public/datasheets/n2xfkld2y-en.pdf and b/public/datasheets/n2xfkld2y-en.pdf differ diff --git a/public/datasheets/n2xs2y-2-de.pdf b/public/datasheets/n2xs2y-2-de.pdf index a87c9401..a1423373 100644 Binary files a/public/datasheets/n2xs2y-2-de.pdf and b/public/datasheets/n2xs2y-2-de.pdf differ diff --git a/public/datasheets/n2xs2y-en.pdf b/public/datasheets/n2xs2y-en.pdf index 356d6583..4a699881 100644 Binary files a/public/datasheets/n2xs2y-en.pdf and b/public/datasheets/n2xs2y-en.pdf differ diff --git a/public/datasheets/n2xsf2y-2-de.pdf b/public/datasheets/n2xsf2y-2-de.pdf index ebd90387..e2d170b9 100644 Binary files a/public/datasheets/n2xsf2y-2-de.pdf and b/public/datasheets/n2xsf2y-2-de.pdf differ diff --git a/public/datasheets/n2xsf2y-en.pdf b/public/datasheets/n2xsf2y-en.pdf index 7120f57b..6794ab32 100644 Binary files a/public/datasheets/n2xsf2y-en.pdf and b/public/datasheets/n2xsf2y-en.pdf differ diff --git a/public/datasheets/n2xsfl2y-2-de.pdf b/public/datasheets/n2xsfl2y-2-de.pdf index 53d0aff9..cf08ceb1 100644 Binary files a/public/datasheets/n2xsfl2y-2-de.pdf and b/public/datasheets/n2xsfl2y-2-de.pdf differ diff --git a/public/datasheets/n2xsfl2y-3-en.pdf b/public/datasheets/n2xsfl2y-3-en.pdf index d6d325b0..af00fca4 100644 Binary files a/public/datasheets/n2xsfl2y-3-en.pdf and b/public/datasheets/n2xsfl2y-3-en.pdf differ diff --git a/public/datasheets/n2xsfl2y-de.pdf b/public/datasheets/n2xsfl2y-de.pdf index b48c7c76..6c78a38b 100644 Binary files a/public/datasheets/n2xsfl2y-de.pdf and b/public/datasheets/n2xsfl2y-de.pdf differ diff --git a/public/datasheets/n2xsfl2y-en.pdf b/public/datasheets/n2xsfl2y-en.pdf index 0fc8b98a..35225403 100644 Binary files a/public/datasheets/n2xsfl2y-en.pdf and b/public/datasheets/n2xsfl2y-en.pdf differ diff --git a/public/datasheets/n2xsy-2-de.pdf b/public/datasheets/n2xsy-2-de.pdf index f478547c..92effe2b 100644 Binary files a/public/datasheets/n2xsy-2-de.pdf and b/public/datasheets/n2xsy-2-de.pdf differ diff --git a/public/datasheets/n2xsy-en.pdf b/public/datasheets/n2xsy-en.pdf index db13bb4d..fcf21312 100644 Binary files a/public/datasheets/n2xsy-en.pdf and b/public/datasheets/n2xsy-en.pdf differ diff --git a/public/datasheets/n2xy-2-de.pdf b/public/datasheets/n2xy-2-de.pdf index cdaa1701..f5c7061f 100644 Binary files a/public/datasheets/n2xy-2-de.pdf and b/public/datasheets/n2xy-2-de.pdf differ diff --git a/public/datasheets/n2xy-en.pdf b/public/datasheets/n2xy-en.pdf index 3ba6f8cd..578807b2 100644 Binary files a/public/datasheets/n2xy-en.pdf and b/public/datasheets/n2xy-en.pdf differ diff --git a/public/datasheets/na2x2y-2-de.pdf b/public/datasheets/na2x2y-2-de.pdf index 98bda1d1..4a389157 100644 Binary files a/public/datasheets/na2x2y-2-de.pdf and b/public/datasheets/na2x2y-2-de.pdf differ diff --git a/public/datasheets/na2x2y-en.pdf b/public/datasheets/na2x2y-en.pdf index 2fb60b93..574f0e3e 100644 Binary files a/public/datasheets/na2x2y-en.pdf and b/public/datasheets/na2x2y-en.pdf differ diff --git a/public/datasheets/na2xfk2y-de.pdf b/public/datasheets/na2xfk2y-de.pdf index 363d3db5..22ae93ba 100644 Binary files a/public/datasheets/na2xfk2y-de.pdf and b/public/datasheets/na2xfk2y-de.pdf differ diff --git a/public/datasheets/na2xfk2y-en.pdf b/public/datasheets/na2xfk2y-en.pdf index 732bcb88..a80cdb47 100644 Binary files a/public/datasheets/na2xfk2y-en.pdf and b/public/datasheets/na2xfk2y-en.pdf differ diff --git a/public/datasheets/na2xfkld2y-de.pdf b/public/datasheets/na2xfkld2y-de.pdf index b6d4c17c..c0edfb26 100644 Binary files a/public/datasheets/na2xfkld2y-de.pdf and b/public/datasheets/na2xfkld2y-de.pdf differ diff --git a/public/datasheets/na2xfkld2y-en.pdf b/public/datasheets/na2xfkld2y-en.pdf index 120efd69..a6105f8e 100644 Binary files a/public/datasheets/na2xfkld2y-en.pdf and b/public/datasheets/na2xfkld2y-en.pdf differ diff --git a/public/datasheets/na2xs2y-2-de.pdf b/public/datasheets/na2xs2y-2-de.pdf index 138c3bb2..f6e95b5b 100644 Binary files a/public/datasheets/na2xs2y-2-de.pdf and b/public/datasheets/na2xs2y-2-de.pdf differ diff --git a/public/datasheets/na2xs2y-en.pdf b/public/datasheets/na2xs2y-en.pdf index 8453e3c8..3ee5127a 100644 Binary files a/public/datasheets/na2xs2y-en.pdf and b/public/datasheets/na2xs2y-en.pdf differ diff --git a/public/datasheets/na2xsf2y-2-de.pdf b/public/datasheets/na2xsf2y-2-de.pdf index e26debdf..d800de9a 100644 Binary files a/public/datasheets/na2xsf2y-2-de.pdf and b/public/datasheets/na2xsf2y-2-de.pdf differ diff --git a/public/datasheets/na2xsf2y-en.pdf b/public/datasheets/na2xsf2y-en.pdf index 73a09084..49c2edd5 100644 Binary files a/public/datasheets/na2xsf2y-en.pdf and b/public/datasheets/na2xsf2y-en.pdf differ diff --git a/public/datasheets/na2xsfl2y-2-de.pdf b/public/datasheets/na2xsfl2y-2-de.pdf index 5d271107..de555fd2 100644 Binary files a/public/datasheets/na2xsfl2y-2-de.pdf and b/public/datasheets/na2xsfl2y-2-de.pdf differ diff --git a/public/datasheets/na2xsfl2y-3-en.pdf b/public/datasheets/na2xsfl2y-3-en.pdf index 4e74daf3..dd45f2fc 100644 Binary files a/public/datasheets/na2xsfl2y-3-en.pdf and b/public/datasheets/na2xsfl2y-3-en.pdf differ diff --git a/public/datasheets/na2xsfl2y-de.pdf b/public/datasheets/na2xsfl2y-de.pdf index d9a9d8de..6fb9a05c 100644 Binary files a/public/datasheets/na2xsfl2y-de.pdf and b/public/datasheets/na2xsfl2y-de.pdf differ diff --git a/public/datasheets/na2xsfl2y-en.pdf b/public/datasheets/na2xsfl2y-en.pdf index 432e8013..555d4bf1 100644 Binary files a/public/datasheets/na2xsfl2y-en.pdf and b/public/datasheets/na2xsfl2y-en.pdf differ diff --git a/public/datasheets/na2xsy-2-de.pdf b/public/datasheets/na2xsy-2-de.pdf index b72cbea9..4ba3dc8a 100644 Binary files a/public/datasheets/na2xsy-2-de.pdf and b/public/datasheets/na2xsy-2-de.pdf differ diff --git a/public/datasheets/na2xsy-en.pdf b/public/datasheets/na2xsy-en.pdf index 68688a73..de75e9ec 100644 Binary files a/public/datasheets/na2xsy-en.pdf and b/public/datasheets/na2xsy-en.pdf differ diff --git a/public/datasheets/na2xy-2-de.pdf b/public/datasheets/na2xy-2-de.pdf index 4d7476e0..6eddbdfd 100644 Binary files a/public/datasheets/na2xy-2-de.pdf and b/public/datasheets/na2xy-2-de.pdf differ diff --git a/public/datasheets/na2xy-en.pdf b/public/datasheets/na2xy-en.pdf index ea436402..6ddb73c0 100644 Binary files a/public/datasheets/na2xy-en.pdf and b/public/datasheets/na2xy-en.pdf differ diff --git a/public/datasheets/nay2y-2-de.pdf b/public/datasheets/nay2y-2-de.pdf index c259f073..33b10f16 100644 Binary files a/public/datasheets/nay2y-2-de.pdf and b/public/datasheets/nay2y-2-de.pdf differ diff --git a/public/datasheets/nay2y-en.pdf b/public/datasheets/nay2y-en.pdf index e481a258..3eb48603 100644 Binary files a/public/datasheets/nay2y-en.pdf and b/public/datasheets/nay2y-en.pdf differ diff --git a/public/datasheets/naycwy-2-de.pdf b/public/datasheets/naycwy-2-de.pdf index 4229c824..96c452e0 100644 Binary files a/public/datasheets/naycwy-2-de.pdf and b/public/datasheets/naycwy-2-de.pdf differ diff --git a/public/datasheets/naycwy-en.pdf b/public/datasheets/naycwy-en.pdf index 55402ad0..718f9d03 100644 Binary files a/public/datasheets/naycwy-en.pdf and b/public/datasheets/naycwy-en.pdf differ diff --git a/public/datasheets/nayy-2-de.pdf b/public/datasheets/nayy-2-de.pdf index 4c192844..36b5ed3a 100644 Binary files a/public/datasheets/nayy-2-de.pdf and b/public/datasheets/nayy-2-de.pdf differ diff --git a/public/datasheets/nayy-en.pdf b/public/datasheets/nayy-en.pdf index c82dcd97..1c067e47 100644 Binary files a/public/datasheets/nayy-en.pdf and b/public/datasheets/nayy-en.pdf differ diff --git a/public/datasheets/ny2y-2-de.pdf b/public/datasheets/ny2y-2-de.pdf index c5a98e4e..cc3e4084 100644 Binary files a/public/datasheets/ny2y-2-de.pdf and b/public/datasheets/ny2y-2-de.pdf differ diff --git a/public/datasheets/ny2y-en.pdf b/public/datasheets/ny2y-en.pdf index eb17a660..30ca7fc4 100644 Binary files a/public/datasheets/ny2y-en.pdf and b/public/datasheets/ny2y-en.pdf differ diff --git a/public/datasheets/nycwy-2-de.pdf b/public/datasheets/nycwy-2-de.pdf index dca418da..e60e4ab2 100644 Binary files a/public/datasheets/nycwy-2-de.pdf and b/public/datasheets/nycwy-2-de.pdf differ diff --git a/public/datasheets/nycwy-en.pdf b/public/datasheets/nycwy-en.pdf index 06913f37..ff2c1cc0 100644 Binary files a/public/datasheets/nycwy-en.pdf and b/public/datasheets/nycwy-en.pdf differ diff --git a/public/datasheets/nyy-2-de.pdf b/public/datasheets/nyy-2-de.pdf index a9fb66d1..b0b1fd95 100644 Binary files a/public/datasheets/nyy-2-de.pdf and b/public/datasheets/nyy-2-de.pdf differ diff --git a/public/datasheets/nyy-en.pdf b/public/datasheets/nyy-en.pdf index 1c7b8fa4..88614917 100644 Binary files a/public/datasheets/nyy-en.pdf and b/public/datasheets/nyy-en.pdf differ diff --git a/scripts/generate-pdf-datasheets.ts b/scripts/generate-pdf-datasheets.ts index da0f12e4..d7cdde04 100644 --- a/scripts/generate-pdf-datasheets.ts +++ b/scripts/generate-pdf-datasheets.ts @@ -6,6 +6,7 @@ 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; @@ -28,6 +29,13 @@ const CONFIG = { 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 { @@ -60,6 +68,451 @@ interface ProductData { }>; } +type ExcelRow = Record; +type ExcelMatch = { rows: ExcelRow[]; units: Record }; +let EXCEL_INDEX: Map | null = null; + +function normalizeExcelKey(value: string): string { + // Match product names/slugs and Excel "Part Number" robustly. + // Examples: + // - "NA2XS(FL)2Y" -> "NA2XSFL2Y" + // - "na2xsfl2y-3" -> "NA2XSFL2Y" + return String(value || '') + .toUpperCase() + .replace(/-\d+$/g, '') + .replace(/[^A-Z0-9]+/g, ''); +} + +function loadExcelRows(filePath: string): ExcelRow[] { + // We intentionally avoid adding a heavy xlsx parser dependency. + // Instead, we use `xlsx-cli` via npx, which is already available at runtime. + // NOTE: `xlsx-cli -j` prints the sheet name on the first line, then JSON. + const out = execSync(`npx -y xlsx-cli -j "${filePath}"`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); + const trimmed = out.trim(); + const jsonStart = trimmed.indexOf('['); + if (jsonStart < 0) return []; + const jsonText = trimmed.slice(jsonStart); + try { + return JSON.parse(jsonText) as ExcelRow[]; + } catch { + return []; + } +} + +function getExcelIndex(): Map { + if (EXCEL_INDEX) return EXCEL_INDEX; + const idx = new Map(); + for (const file of EXCEL_SOURCE_FILES) { + if (!fs.existsSync(file)) continue; + const rows = loadExcelRows(file); + if (process.env.PDF_DEBUG_EXCEL === '1') { + console.log(`[excel] loaded ${rows.length} rows from ${path.relative(process.cwd(), file)}`); + } + + const unitsRow = rows.find(r => r && r['Part Number'] === 'Units') || null; + const units: Record = {}; + if (unitsRow) { + for (const [k, v] of Object.entries(unitsRow)) { + if (k === 'Part Number') continue; + const unit = normalizeValue(String(v ?? '')); + if (unit) units[k] = unit; + } + } + + for (const r of rows) { + const pn = r?.['Part Number']; + if (!pn || pn === 'Units') continue; + const key = normalizeExcelKey(String(pn)); + if (!key) continue; + const cur = idx.get(key); + if (!cur) { + idx.set(key, { rows: [r], units }); + } else { + cur.rows.push(r); + if (Object.keys(cur.units).length < Object.keys(units).length) cur.units = units; + } + } + } + EXCEL_INDEX = idx; + return idx; +} + +function findExcelForProduct(product: ProductData): ExcelMatch | null { + const idx = getExcelIndex(); + const candidates = [ + product.name, + product.slug ? product.slug.replace(/-\d+$/g, '') : '', + product.sku, + product.translationKey, + ].filter(Boolean) as string[]; + + if (process.env.PDF_DEBUG_EXCEL === '1') { + const keys = candidates.map(c => normalizeExcelKey(c)); + console.log(`[excel] lookup product=${product.id} ${product.locale ?? ''} slug=${product.slug ?? ''} name=${stripHtml(product.name)} keys=${keys.join(',')}`); + } + + for (const c of candidates) { + const key = normalizeExcelKey(c); + const match = idx.get(key); + if (match && match.rows.length) return match; + } + return null; +} + +function findExcelRowsForProduct(product: ProductData): ExcelRow[] { + const match = findExcelForProduct(product); + return match?.rows || []; +} + +function guessColumnKey(row: ExcelRow, patterns: RegExp[]): string | null { + const keys = Object.keys(row || {}); + for (const re of patterns) { + const k = keys.find(x => re.test(String(x))); + 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; + } + + 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 that are often missing from WP exports. + // We add them as either constant attributes (if identical across all rows) + // or as small multi-value arrays (if they vary), so TECHNICAL DATA can render them. + const ratedVoltKey = guessColumnKey(rows[0], [/rated voltage/i, /voltage rating/i, /spannungs/i, /nennspannung/i]); + 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]); + + const cfgName = locale === 'de' ? 'Anzahl der Adern und Querschnitt' : 'Number of cores and cross-section'; + const cfgOptions = rows + .map(r => { + const cs = normalizeValue(String(r?.[csKey] ?? '')); + const v = voltageKey ? normalizeValue(String(r?.[voltageKey] ?? '')) : ''; + if (!cs) return ''; + if (!v) return cs; + // Keep the existing config separator used by splitConfig(): "cross - voltage". + // Add unit only if not already present. + const vHasUnit = /\bkv\b/i.test(v); + const vText = vHasUnit ? v : `${v} kV`; + return `${cs} - ${vText}`; + }) + .filter(Boolean); + + if (!cfgOptions.length) return; + + const attrs = product.attributes || []; + attrs.push({ name: cfgName, options: cfgOptions }); + + const pushRowAttr = (name: string, key: string | null, unit?: string) => { + if (!key) return; + const options = rows + .map(r => normalizeValue(String(r?.[key] ?? ''))) + .map(v => (unit && v && looksNumeric(v) ? `${v} ${unit}` : v)); + if (options.filter(Boolean).length === 0) return; + attrs.push({ name, options }); + }; + + // These names are chosen so existing PDF regexes can detect them. + pushRowAttr(locale === 'de' ? 'Außen-Ø' : 'Outer diameter', outerKey, 'mm'); + pushRowAttr(locale === 'de' ? 'Gewicht' : 'Weight', weightKey, 'kg/km'); + pushRowAttr(locale === 'de' ? 'DC-Leiterwiderstand (20°C)' : 'DC resistance at 20 °C', dcResKey, 'Ω/km'); + + const colValues = (key: string | null) => rows.map(r => normalizeValue(String(r?.[key ?? ''] ?? ''))); + + const addConstOrSmallList = (args: { name: string; existsRe: RegExp; key: string | null }) => { + if (!args.key) return; + const uniq = getUniqueNonEmpty(colValues(args.key)); + if (!uniq.length) return; + // If all rows share the same value, store as single option. + if (uniq.length === 1) { + pushAttrIfMissing({ product, name: args.name, options: [uniq[0]], existsRe: args.existsRe }); + return; + } + // Otherwise store the unique set (TECHNICAL DATA will compact it). + pushAttrIfMissing({ product, name: args.name, options: uniq, existsRe: args.existsRe }); + }; + + addConstOrSmallList({ + name: locale === 'de' ? 'Nennspannung' : 'Rated voltage', + existsRe: /rated\s*voltage|voltage\s*rating|nennspannung|spannungsbereich/i, + key: ratedVoltKey, + }); + addConstOrSmallList({ + name: locale === 'de' ? 'Prüfspannung' : 'Test voltage', + existsRe: /test\s*voltage|prüfspannung/i, + key: testVoltKey, + }); + addConstOrSmallList({ + name: locale === 'de' ? 'Temperaturbereich' : 'Operating temperature range', + existsRe: /operating\s*temperature\s*range|temperature\s*range|temperaturbereich/i, + key: tempRangeKey, + }); + addConstOrSmallList({ + name: locale === 'de' ? 'Min. Verlegetemperatur' : 'Minimal temperature for laying', + existsRe: /minimal\s*temperature\s*for\s*laying/i, + key: minLayKey, + }); + addConstOrSmallList({ + name: locale === 'de' ? 'Min. Lagertemperatur' : 'Minimal storage temperature', + existsRe: /minimal\s*storage\s*temperature/i, + key: minStoreKey, + }); + addConstOrSmallList({ + name: locale === 'de' ? 'Max. Betriebstemperatur' : 'Maximal operating conductor temperature', + existsRe: /maximal\s*operating\s*conductor\s*temperature|max\.?\s*operating/i, + key: maxOpKey, + }); + addConstOrSmallList({ + name: locale === 'de' ? 'Kurzschlusstemperatur (max.)' : 'Maximal short-circuit temperature', + existsRe: /maximal\s*short-?circuit\s*temperature|short\s*circuit\s*temperature|kurzschlusstemperatur/i, + key: maxScKey, + }); + addConstOrSmallList({ + name: locale === 'de' ? 'Isolationsdicke (nom.)' : 'Nominal insulation thickness', + existsRe: /nominal\s*insulation\s*thickness|insulation\s*thickness/i, + key: insThkKey, + }); + addConstOrSmallList({ + name: locale === 'de' ? 'Manteldicke (nom.)' : 'Nominal sheath thickness', + existsRe: /nominal\s*sheath\s*thickness|minimum\s*sheath\s*thickness|manteldicke/i, + key: sheathThkKey, + }); + addConstOrSmallList({ + name: locale === 'de' ? 'Max. Leiterwiderstand' : 'Maximum resistance of conductor', + existsRe: /maximum\s*resistance\s*of\s*conductor|max\.?\s*resistance|leiterwiderstand/i, + key: maxResKey, + }); + + 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]); + + const get = (k: string | null) => rows.map(r => normalizeValue(String(r?.[k ?? ''] ?? ''))); + const withUnit = (vals: string[], unit: string) => vals.map(v => (v && looksNumeric(v) ? `${v} ${unit}` : v)); + + // Use labels that are already recognized by the existing PDF regexes. + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Außen-Ø' : 'Outer diameter', + options: withUnit(get(keyOuter), 'mm'), + expectedLen: rowCount, + existsRe: /outer\s*diameter|außen\s*durchmesser|außen-?ø/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Gewicht' : 'Weight', + options: withUnit(get(keyWeight), 'kg/km'), + expectedLen: rowCount, + existsRe: /\bweight\b|gewicht/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'DC-Leiterwiderstand (20°C)' : 'DC resistance at 20 °C', + options: withUnit(get(keyDcRes), 'Ω/km'), + expectedLen: rowCount, + existsRe: /dc\s*resistance|max(?:imum)?\s*resistance|resistance\s+conductor|leiterwiderstand/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Kapazität (ca.)' : 'Capacitance (approx.)', + options: withUnit(get(keyCap), 'μF/km'), + expectedLen: rowCount, + existsRe: /capacitance|kapazit/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Induktivität, trefoil (ca.)' : 'Inductance, trefoil (approx.)', + options: withUnit(get(keyIndTrefoil), 'mH/km'), + expectedLen: rowCount, + existsRe: /inductance,?\s*trefoil/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Induktivität in Luft, flach (ca.)' : 'Inductance in air, flat (approx.)', + options: withUnit(get(keyIndAirFlat), 'mH/km'), + expectedLen: rowCount, + existsRe: /inductance\s+in\s+air,?\s*flat/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Induktivität im Erdreich, flach (ca.)' : 'Inductance in ground, flat (approx.)', + options: withUnit(get(keyIndGroundFlat), 'mH/km'), + expectedLen: rowCount, + existsRe: /inductance\s+in\s+ground,?\s*flat/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Strombelastbarkeit in Luft, trefoil' : 'Current ratings in air, trefoil', + options: withUnit(get(keyIairTrefoil), 'A'), + expectedLen: rowCount, + existsRe: /current\s+ratings\s+in\s+air,?\s*trefoil/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Strombelastbarkeit in Luft, flach' : 'Current ratings in air, flat', + options: withUnit(get(keyIairFlat), 'A'), + expectedLen: rowCount, + existsRe: /current\s+ratings\s+in\s+air,?\s*flat/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Strombelastbarkeit im Erdreich, trefoil' : 'Current ratings in ground, trefoil', + options: withUnit(get(keyIgroundTrefoil), 'A'), + expectedLen: rowCount, + existsRe: /current\s+ratings\s+in\s+ground,?\s*trefoil/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Strombelastbarkeit im Erdreich, flach' : 'Current ratings in ground, flat', + options: withUnit(get(keyIgroundFlat), 'A'), + expectedLen: rowCount, + existsRe: /current\s+ratings\s+in\s+ground,?\s*flat/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Kurzschlussstrom Leiter' : 'Conductor shortcircuit current', + options: withUnit(get(keyScCond), 'kA'), + expectedLen: rowCount, + existsRe: /conductor\s+shortcircuit\s+current/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Kurzschlussstrom Schirm' : 'Screen shortcircuit current', + options: withUnit(get(keyScScreen), 'kA'), + expectedLen: rowCount, + existsRe: /screen\s+shortcircuit\s+current/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Biegeradius (min.)' : 'Bending radius (min.)', + options: withUnit(get(keyBend), 'mm'), + expectedLen: rowCount, + existsRe: /bending\s*radius|biegeradius/i, + }); +} + function getProductUrl(product: ProductData): string | null { if (!product.path) return null; return `https://klz-cables.com${product.path}`; @@ -1065,7 +1518,9 @@ function summarizeOptions(options: string[] | undefined, maxItems: number = 3): const uniq = Array.from(new Set(vals)); if (uniq.length === 1) return uniq[0]; if (uniq.length <= maxItems) return uniq.join(' / '); - return `${uniq.slice(0, maxItems).join(' / ')} (+${uniq.length - maxItems})`; + // 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 { @@ -1086,11 +1541,13 @@ function summarizeNumericRange(options: string[] | undefined): { ok: boolean; te const vals = (options || []).map(parseNumericOption).filter((n): n is number => n !== null); if (vals.length < 3) return { ok: false, text: '' }; const uniq = Array.from(new Set(vals)); - if (uniq.length < 2) return { ok: false, text: '' }; + // 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]; - return { ok: true, text: `${formatNumber(min)}–${formatNumber(max)} (n=${uniq.length})` }; + // 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 { @@ -1482,6 +1939,21 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise + /configuration|konfiguration|aufbau|bezeichnung|number of cores and cross-section|querschnitt|cross.?section|mm²|mm2/i.test(a.name), + ); + console.log(`[excel] after enrichment: product ${product.id} cfgAttrPresent=${hasAnyCfg}`); + } + // === TECHNICAL DATA (shared across all cross-sections) === const configAttr = findAttr(product, /configuration|konfiguration|aufbau|bezeichnung/i); const crossSectionAttr = @@ -1490,6 +1962,12 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise 0); + if (process.env.PDF_DEBUG_EXCEL === '1') { + console.log( + `[excel] crossSectionAttr=${crossSectionAttr ? normalizeValue(crossSectionAttr.name) : 'none'} rows=${rowCount} hasCross=${hasCrossSectionData}`, + ); + } + // Compact mode approach: // - show constant (non-row) attributes as key/value grid // - show only a small configuration sample + total count