diff --git a/content/products/na2xy.mdx b/content/products/na2xy.mdx index 92a84505..d064f48b 100644 --- a/content/products/na2xy.mdx +++ b/content/products/na2xy.mdx @@ -126,5 +126,5 @@ Der Leiter besteht aus **Aluminium**, verfügbar als rund oder rund mehrdrähtig NA2XY Kabel sind die richtige Wahl für **stabile Energieverteilungen unter mechanisch anspruchsvollen Bedingungen**. Sie bieten eine wirtschaftliche, langlebige Lösung für Netzbetreiber, Industrie und Anlagenbauer, die auf bewährte Technik und hohe Belastbarkeit setzen. - + diff --git a/data/excel/high-voltage.xlsx b/data/excel/high-voltage.xlsx new file mode 100644 index 00000000..cb3edc0c Binary files /dev/null and b/data/excel/high-voltage.xlsx differ diff --git a/data/excel/low-voltage-KM.xlsx b/data/excel/low-voltage-KM.xlsx new file mode 100644 index 00000000..2d7fbeb9 Binary files /dev/null and b/data/excel/low-voltage-KM.xlsx differ diff --git a/data/excel/medium-voltage-KM 170126.xlsx b/data/excel/medium-voltage-KM 170126.xlsx new file mode 100644 index 00000000..53d850a5 Binary files /dev/null and b/data/excel/medium-voltage-KM 170126.xlsx differ diff --git a/data/excel/medium-voltage-KM.xlsx b/data/excel/medium-voltage-KM.xlsx new file mode 100644 index 00000000..427ec552 Binary files /dev/null and b/data/excel/medium-voltage-KM.xlsx differ diff --git a/data/excel/solar-cables.xlsx b/data/excel/solar-cables.xlsx new file mode 100644 index 00000000..b6b432e6 Binary files /dev/null and b/data/excel/solar-cables.xlsx differ diff --git a/package.json b/package.json index 069f69a3..6097800f 100644 --- a/package.json +++ b/package.json @@ -116,7 +116,7 @@ "prepare": "husky", "preinstall": "npx only-allow pnpm" }, - "version": "2.3.35", + "version": "2.3.36", "pnpm": { "onlyBuiltDependencies": [ "@parcel/watcher", diff --git a/public/datasheets/high-voltage/n2xfk2y-de.pdf b/public/datasheets/high-voltage/n2xfk2y-de.pdf index 6db9ba2f..8029335f 100644 Binary files a/public/datasheets/high-voltage/n2xfk2y-de.pdf and b/public/datasheets/high-voltage/n2xfk2y-de.pdf differ diff --git a/public/datasheets/high-voltage/n2xfk2y-en.pdf b/public/datasheets/high-voltage/n2xfk2y-en.pdf index 22df457e..54d2ceda 100644 Binary files a/public/datasheets/high-voltage/n2xfk2y-en.pdf and b/public/datasheets/high-voltage/n2xfk2y-en.pdf differ diff --git a/public/datasheets/high-voltage/n2xfkld2y-de.pdf b/public/datasheets/high-voltage/n2xfkld2y-de.pdf index 06f68580..ccb38549 100644 Binary files a/public/datasheets/high-voltage/n2xfkld2y-de.pdf and b/public/datasheets/high-voltage/n2xfkld2y-de.pdf differ diff --git a/public/datasheets/high-voltage/n2xfkld2y-en.pdf b/public/datasheets/high-voltage/n2xfkld2y-en.pdf index 3f9e7b77..b8bf9ee6 100644 Binary files a/public/datasheets/high-voltage/n2xfkld2y-en.pdf and b/public/datasheets/high-voltage/n2xfkld2y-en.pdf differ diff --git a/public/datasheets/high-voltage/na2xfk2y-de.pdf b/public/datasheets/high-voltage/na2xfk2y-de.pdf index d141c504..43225a32 100644 Binary files a/public/datasheets/high-voltage/na2xfk2y-de.pdf and b/public/datasheets/high-voltage/na2xfk2y-de.pdf differ diff --git a/public/datasheets/high-voltage/na2xfk2y-en.pdf b/public/datasheets/high-voltage/na2xfk2y-en.pdf index 8733e575..6b0ded7e 100644 Binary files a/public/datasheets/high-voltage/na2xfk2y-en.pdf and b/public/datasheets/high-voltage/na2xfk2y-en.pdf differ diff --git a/public/datasheets/high-voltage/na2xfkld2y-de.pdf b/public/datasheets/high-voltage/na2xfkld2y-de.pdf index 044b8d71..b02edcfd 100644 Binary files a/public/datasheets/high-voltage/na2xfkld2y-de.pdf and b/public/datasheets/high-voltage/na2xfkld2y-de.pdf differ diff --git a/public/datasheets/high-voltage/na2xfkld2y-en.pdf b/public/datasheets/high-voltage/na2xfkld2y-en.pdf index 96a122a3..2782a8f0 100644 Binary files a/public/datasheets/high-voltage/na2xfkld2y-en.pdf and b/public/datasheets/high-voltage/na2xfkld2y-en.pdf differ diff --git a/public/datasheets/low-voltage/n2x2y-de.pdf b/public/datasheets/low-voltage/n2x2y-de.pdf index 44f77096..3d7c3012 100644 Binary files a/public/datasheets/low-voltage/n2x2y-de.pdf and b/public/datasheets/low-voltage/n2x2y-de.pdf differ diff --git a/public/datasheets/low-voltage/n2x2y-en.pdf b/public/datasheets/low-voltage/n2x2y-en.pdf index 399ca231..ccb1a297 100644 Binary files a/public/datasheets/low-voltage/n2x2y-en.pdf and b/public/datasheets/low-voltage/n2x2y-en.pdf differ diff --git a/public/datasheets/low-voltage/n2xy-de.pdf b/public/datasheets/low-voltage/n2xy-de.pdf index caa490ba..0a2b2528 100644 Binary files a/public/datasheets/low-voltage/n2xy-de.pdf and b/public/datasheets/low-voltage/n2xy-de.pdf differ diff --git a/public/datasheets/low-voltage/n2xy-en.pdf b/public/datasheets/low-voltage/n2xy-en.pdf index 4de0d0b0..a436d05c 100644 Binary files a/public/datasheets/low-voltage/n2xy-en.pdf and b/public/datasheets/low-voltage/n2xy-en.pdf differ diff --git a/public/datasheets/low-voltage/na2x2y-de.pdf b/public/datasheets/low-voltage/na2x2y-de.pdf index cde0cd65..136e08dc 100644 Binary files a/public/datasheets/low-voltage/na2x2y-de.pdf and b/public/datasheets/low-voltage/na2x2y-de.pdf differ diff --git a/public/datasheets/low-voltage/na2x2y-en.pdf b/public/datasheets/low-voltage/na2x2y-en.pdf index 98db771e..8b1311ab 100644 Binary files a/public/datasheets/low-voltage/na2x2y-en.pdf and b/public/datasheets/low-voltage/na2x2y-en.pdf differ diff --git a/public/datasheets/low-voltage/na2xy-de.pdf b/public/datasheets/low-voltage/na2xy-de.pdf index 8b8e1889..bbb1307c 100644 Binary files a/public/datasheets/low-voltage/na2xy-de.pdf and b/public/datasheets/low-voltage/na2xy-de.pdf differ diff --git a/public/datasheets/low-voltage/na2xy-en.pdf b/public/datasheets/low-voltage/na2xy-en.pdf index 3cf49840..51e0f244 100644 Binary files a/public/datasheets/low-voltage/na2xy-en.pdf and b/public/datasheets/low-voltage/na2xy-en.pdf differ diff --git a/public/datasheets/low-voltage/nay2y-de.pdf b/public/datasheets/low-voltage/nay2y-de.pdf index 80f2afca..df4f368c 100644 Binary files a/public/datasheets/low-voltage/nay2y-de.pdf and b/public/datasheets/low-voltage/nay2y-de.pdf differ diff --git a/public/datasheets/low-voltage/nay2y-en.pdf b/public/datasheets/low-voltage/nay2y-en.pdf index c806c3d0..47f78cbe 100644 Binary files a/public/datasheets/low-voltage/nay2y-en.pdf and b/public/datasheets/low-voltage/nay2y-en.pdf differ diff --git a/public/datasheets/low-voltage/naycwy-de.pdf b/public/datasheets/low-voltage/naycwy-de.pdf index a810c499..3e30a037 100644 Binary files a/public/datasheets/low-voltage/naycwy-de.pdf and b/public/datasheets/low-voltage/naycwy-de.pdf differ diff --git a/public/datasheets/low-voltage/naycwy-en.pdf b/public/datasheets/low-voltage/naycwy-en.pdf index 073790fd..f7e956e8 100644 Binary files a/public/datasheets/low-voltage/naycwy-en.pdf and b/public/datasheets/low-voltage/naycwy-en.pdf differ diff --git a/public/datasheets/low-voltage/nayy-de.pdf b/public/datasheets/low-voltage/nayy-de.pdf index c434a951..e233188f 100644 Binary files a/public/datasheets/low-voltage/nayy-de.pdf and b/public/datasheets/low-voltage/nayy-de.pdf differ diff --git a/public/datasheets/low-voltage/nayy-en.pdf b/public/datasheets/low-voltage/nayy-en.pdf index bff17814..ec76d58c 100644 Binary files a/public/datasheets/low-voltage/nayy-en.pdf and b/public/datasheets/low-voltage/nayy-en.pdf differ diff --git a/public/datasheets/low-voltage/ny2y-de.pdf b/public/datasheets/low-voltage/ny2y-de.pdf index 6917bea5..b7979211 100644 Binary files a/public/datasheets/low-voltage/ny2y-de.pdf and b/public/datasheets/low-voltage/ny2y-de.pdf differ diff --git a/public/datasheets/low-voltage/ny2y-en.pdf b/public/datasheets/low-voltage/ny2y-en.pdf index ab0b6bfc..032b3f51 100644 Binary files a/public/datasheets/low-voltage/ny2y-en.pdf and b/public/datasheets/low-voltage/ny2y-en.pdf differ diff --git a/public/datasheets/low-voltage/nycwy-de.pdf b/public/datasheets/low-voltage/nycwy-de.pdf index 1eeebf5b..f39de879 100644 Binary files a/public/datasheets/low-voltage/nycwy-de.pdf and b/public/datasheets/low-voltage/nycwy-de.pdf differ diff --git a/public/datasheets/low-voltage/nycwy-en.pdf b/public/datasheets/low-voltage/nycwy-en.pdf index 80ecd807..249a3196 100644 Binary files a/public/datasheets/low-voltage/nycwy-en.pdf and b/public/datasheets/low-voltage/nycwy-en.pdf differ diff --git a/public/datasheets/low-voltage/nyy-de.pdf b/public/datasheets/low-voltage/nyy-de.pdf index 4c9a0dda..51b26dfd 100644 Binary files a/public/datasheets/low-voltage/nyy-de.pdf and b/public/datasheets/low-voltage/nyy-de.pdf differ diff --git a/public/datasheets/low-voltage/nyy-en.pdf b/public/datasheets/low-voltage/nyy-en.pdf index 1c7b7253..651c673e 100644 Binary files a/public/datasheets/low-voltage/nyy-en.pdf and b/public/datasheets/low-voltage/nyy-en.pdf differ diff --git a/public/datasheets/medium-voltage/n2xs2y-de.pdf b/public/datasheets/medium-voltage/n2xs2y-de.pdf index 453e0c86..41c326cc 100644 Binary files a/public/datasheets/medium-voltage/n2xs2y-de.pdf and b/public/datasheets/medium-voltage/n2xs2y-de.pdf differ diff --git a/public/datasheets/medium-voltage/n2xs2y-en.pdf b/public/datasheets/medium-voltage/n2xs2y-en.pdf index b6ec3050..ce1d308d 100644 Binary files a/public/datasheets/medium-voltage/n2xs2y-en.pdf and b/public/datasheets/medium-voltage/n2xs2y-en.pdf differ diff --git a/public/datasheets/medium-voltage/n2xsf2y-de.pdf b/public/datasheets/medium-voltage/n2xsf2y-de.pdf index 21800df7..8d60100b 100644 Binary files a/public/datasheets/medium-voltage/n2xsf2y-de.pdf and b/public/datasheets/medium-voltage/n2xsf2y-de.pdf differ diff --git a/public/datasheets/medium-voltage/n2xsf2y-en.pdf b/public/datasheets/medium-voltage/n2xsf2y-en.pdf index 7b3498cd..95d33dc6 100644 Binary files a/public/datasheets/medium-voltage/n2xsf2y-en.pdf and b/public/datasheets/medium-voltage/n2xsf2y-en.pdf differ diff --git a/public/datasheets/medium-voltage/n2xsfl2y-mv-de.pdf b/public/datasheets/medium-voltage/n2xsfl2y-mv-de.pdf index 9ebfb737..ee5862b0 100644 Binary files a/public/datasheets/medium-voltage/n2xsfl2y-mv-de.pdf and b/public/datasheets/medium-voltage/n2xsfl2y-mv-de.pdf differ diff --git a/public/datasheets/medium-voltage/n2xsfl2y-mv-en.pdf b/public/datasheets/medium-voltage/n2xsfl2y-mv-en.pdf index c50d3935..bbddf91c 100644 Binary files a/public/datasheets/medium-voltage/n2xsfl2y-mv-en.pdf and b/public/datasheets/medium-voltage/n2xsfl2y-mv-en.pdf differ diff --git a/public/datasheets/medium-voltage/n2xsy-de.pdf b/public/datasheets/medium-voltage/n2xsy-de.pdf index 42beb951..272f3fb6 100644 Binary files a/public/datasheets/medium-voltage/n2xsy-de.pdf and b/public/datasheets/medium-voltage/n2xsy-de.pdf differ diff --git a/public/datasheets/medium-voltage/n2xsy-en.pdf b/public/datasheets/medium-voltage/n2xsy-en.pdf index a28dde7f..50080db1 100644 Binary files a/public/datasheets/medium-voltage/n2xsy-en.pdf and b/public/datasheets/medium-voltage/n2xsy-en.pdf differ diff --git a/public/datasheets/medium-voltage/na2xs2y-de.pdf b/public/datasheets/medium-voltage/na2xs2y-de.pdf index d93983eb..2212c27c 100644 Binary files a/public/datasheets/medium-voltage/na2xs2y-de.pdf and b/public/datasheets/medium-voltage/na2xs2y-de.pdf differ diff --git a/public/datasheets/medium-voltage/na2xs2y-en.pdf b/public/datasheets/medium-voltage/na2xs2y-en.pdf index 3fd1ad27..c92d4ae2 100644 Binary files a/public/datasheets/medium-voltage/na2xs2y-en.pdf and b/public/datasheets/medium-voltage/na2xs2y-en.pdf differ diff --git a/public/datasheets/medium-voltage/na2xsf2y-de.pdf b/public/datasheets/medium-voltage/na2xsf2y-de.pdf index 29b303c9..f3d54001 100644 Binary files a/public/datasheets/medium-voltage/na2xsf2y-de.pdf and b/public/datasheets/medium-voltage/na2xsf2y-de.pdf differ diff --git a/public/datasheets/medium-voltage/na2xsf2y-en.pdf b/public/datasheets/medium-voltage/na2xsf2y-en.pdf index 29ff6f21..a1b00f45 100644 Binary files a/public/datasheets/medium-voltage/na2xsf2y-en.pdf and b/public/datasheets/medium-voltage/na2xsf2y-en.pdf differ diff --git a/public/datasheets/medium-voltage/na2xsfl2y-mv-de.pdf b/public/datasheets/medium-voltage/na2xsfl2y-mv-de.pdf index 4d949c01..6131a511 100644 Binary files a/public/datasheets/medium-voltage/na2xsfl2y-mv-de.pdf and b/public/datasheets/medium-voltage/na2xsfl2y-mv-de.pdf differ diff --git a/public/datasheets/medium-voltage/na2xsfl2y-mv-en.pdf b/public/datasheets/medium-voltage/na2xsfl2y-mv-en.pdf index 9d0024ed..2d1f72f6 100644 Binary files a/public/datasheets/medium-voltage/na2xsfl2y-mv-en.pdf and b/public/datasheets/medium-voltage/na2xsfl2y-mv-en.pdf differ diff --git a/public/datasheets/medium-voltage/na2xsy-de.pdf b/public/datasheets/medium-voltage/na2xsy-de.pdf index 0c1d2c82..973e939c 100644 Binary files a/public/datasheets/medium-voltage/na2xsy-de.pdf and b/public/datasheets/medium-voltage/na2xsy-de.pdf differ diff --git a/public/datasheets/medium-voltage/na2xsy-en.pdf b/public/datasheets/medium-voltage/na2xsy-en.pdf index 9e9ae55e..8ca86f71 100644 Binary files a/public/datasheets/medium-voltage/na2xsy-en.pdf and b/public/datasheets/medium-voltage/na2xsy-en.pdf differ diff --git a/public/datasheets/solar/h1z2z2-k-de.pdf b/public/datasheets/solar/h1z2z2-k-de.pdf index 406c38a9..cb1c5df7 100644 Binary files a/public/datasheets/solar/h1z2z2-k-de.pdf and b/public/datasheets/solar/h1z2z2-k-de.pdf differ diff --git a/public/datasheets/solar/h1z2z2-k-en.pdf b/public/datasheets/solar/h1z2z2-k-en.pdf index 5461801f..b061c4a0 100644 Binary files a/public/datasheets/solar/h1z2z2-k-en.pdf and b/public/datasheets/solar/h1z2z2-k-en.pdf differ diff --git a/scripts/generate-pdf-datasheets.ts b/scripts/generate-pdf-datasheets.ts new file mode 100644 index 00000000..4c4e08f6 --- /dev/null +++ b/scripts/generate-pdf-datasheets.ts @@ -0,0 +1,396 @@ +#!/usr/bin/env ts-node +/** + * PDF Datasheet Generator (React-PDF) + * + * Renders PDFs via `@react-pdf/renderer`. + * + * Source of truth: + * - All technical data + cross-section tables: Excel files in `data/excel/` + * - Product description text: MDX files in `data/products/{en,de}/*.mdx` + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import * as XLSX from 'xlsx'; +import matter from 'gray-matter'; + +import type { ProductData } from './pdf/model/types'; +import { generateDatasheetPdfBuffer } from './pdf/react-pdf/generate-datasheet-pdf'; +import { generateFileName, normalizeValue, stripHtml } from './pdf/model/utils'; + +const CONFIG = { + outputDir: path.join(process.cwd(), 'public/datasheets'), + chunkSize: 10, +} as const; + +const EXCEL_FILES = [ + { path: path.join(process.cwd(), 'data/excel/high-voltage.xlsx'), voltageType: 'high-voltage' }, + { + path: path.join(process.cwd(), 'data/excel/medium-voltage-KM.xlsx'), + voltageType: 'medium-voltage', + }, + { + path: path.join(process.cwd(), 'data/excel/medium-voltage-KM 170126.xlsx'), + voltageType: 'medium-voltage', + }, + { path: path.join(process.cwd(), 'data/excel/low-voltage-KM.xlsx'), voltageType: 'low-voltage' }, + { path: path.join(process.cwd(), 'data/excel/solar-cables.xlsx'), voltageType: 'solar' }, +] as const; + +type MdxProduct = { + slug: string; + title: string; + sku: string; + categories: string[]; + images: string[]; + descriptionHtml: string; + applicationHtml: string; +}; + +type MdxIndex = Map; // key: normalized designation/title + +function ensureOutputDir(): void { + if (!fs.existsSync(CONFIG.outputDir)) { + fs.mkdirSync(CONFIG.outputDir, { recursive: true }); + } +} + +function normalizeExcelKey(value: string): string { + return String(value || '') + .toUpperCase() + .replace(/-\d+$/g, '') + .replace(/[^A-Z0-9]+/g, ''); +} + +function extractDescriptionFromMdxFrontmatter(data: any): string { + const description = normalizeValue(String(data?.description || '')); + return description; +} + +function parseMdxFileSafely( + raw: string, + fileName: string, +): { + slug: string; + title: string; + sku: string; + categories: string[]; + images: string[]; + descriptionHtml: string; + applicationHtml: string; +} { + let title = ''; + let sku = ''; + let slug = path.basename(fileName, '.mdx'); + let descriptionHtml = ''; + let applicationHtml = ''; + const categories: string[] = []; + const images: string[] = []; + + try { + const parsed = matter(raw); + const data = (parsed.data || {}) as any; + title = normalizeValue(String(data.title || '')); + sku = normalizeValue(String(data.sku || '')); + slug = data.slug || slug; + descriptionHtml = extractDescriptionFromMdxFrontmatter(data); + applicationHtml = normalizeValue(String(data?.application || '')); + if (Array.isArray(data.categories)) { + for (const c of data.categories) { + const val = normalizeValue(typeof c === 'string' ? c : String(c?.category || '')); + if (val) categories.push(val); + } + } + if (Array.isArray(data.images)) { + for (const i of data.images) { + const val = normalizeValue(typeof i === 'string' ? i : String(i?.url || '')); + if (val) images.push(val); + } + } + } catch { + // Fallback regex parsing if YAML has indentation issues + const titleMatch = raw.match(/^title:\s*(.+)$/m); + if (titleMatch) title = normalizeValue(titleMatch[1]); + + const skuMatch = raw.match(/^sku:\s*(.+)$/m); + if (skuMatch) sku = normalizeValue(skuMatch[1]); + + const slugMatch = raw.match(/^slug:\s*(.+)$/m); + if (slugMatch) slug = normalizeValue(slugMatch[1]); + + const descMatch = raw.match( + /^description:\s*(?:>|>-)?\s*\n([\s\S]*?)(?=\n[a-zA-Z0-9_-]+:|\n---)/, + ); + if (descMatch) descriptionHtml = normalizeValue(descMatch[1].replace(/\n\s*/g, ' ').trim()); + + const catMatches = Array.from(raw.matchAll(/category:\s*(.+)$/gm)); + for (const cm of catMatches) { + const val = normalizeValue(cm[1]); + if (val && !categories.includes(val)) categories.push(val); + } + + const urlMatches = Array.from(raw.matchAll(/url:\s*(.+)$/gm)); + for (const um of urlMatches) { + const val = normalizeValue(um[1]); + if (val && val.startsWith('/media/') && !images.includes(val)) images.push(val); + } + } + + if (!title) { + title = path.basename(fileName, '.mdx').toUpperCase(); + } + + return { slug, title, sku, categories, images, descriptionHtml, applicationHtml }; +} + +function buildMdxIndex(locale: 'en' | 'de'): MdxIndex { + const candidateDirs = [ + path.join(process.cwd(), 'content/products'), + path.join(process.cwd(), 'data/products', locale), + path.join(process.cwd(), 'data/products/de'), + ]; + const dir = candidateDirs.find((d) => fs.existsSync(d)); + const idx: MdxIndex = new Map(); + if (!dir) return idx; + + const files = fs + .readdirSync(dir) + .filter((f) => f.endsWith('.mdx')) + .sort(); + + for (const file of files) { + const filePath = path.join(dir, file); + const raw = fs.readFileSync(filePath, 'utf8'); + const parsed = parseMdxFileSafely(raw, file); + if (!parsed.title) continue; + + idx.set(normalizeExcelKey(parsed.title), parsed); + } + + return idx; +} + +function findKeyByHeaderValue(headerRow: Record, pattern: RegExp): string | null { + for (const [k, v] of Object.entries(headerRow || {})) { + const text = normalizeValue(String(v ?? '')); + if (!text) continue; + if (pattern.test(text)) return k; + } + return null; +} + +function readExcelRows(filePath: string): Array> { + if (!fs.existsSync(filePath)) return []; + try { + const fileBuffer = fs.readFileSync(filePath); + const workbook = XLSX.read(fileBuffer, { + type: 'buffer', + cellDates: false, + cellNF: false, + cellText: false, + }); + const sheetName = workbook.SheetNames[0]; + if (!sheetName) return []; + const sheet = workbook.Sheets[sheetName]; + if (!sheet) return []; + + return XLSX.utils.sheet_to_json(sheet, { + defval: '', + raw: false, + blankrows: false, + }) as Array>; + } catch { + return []; + } +} + +function readDesignationsFromExcelFile(filePath: string): Map { + const rows = readExcelRows(filePath); + if (!rows.length) return new Map(); + + // Legacy sheets use "Part Number" as a column key. + // The new MV sheet uses __EMPTY* keys and stores the human headers in row 0 values. + const headerRow = rows[0] || {}; + const partNumberKey = + (Object.prototype.hasOwnProperty.call(headerRow, 'Part Number') ? 'Part Number' : null) || + findKeyByHeaderValue(headerRow, /^part\s*number$/i) || + '__EMPTY'; + + const out = new Map(); + for (const r of rows) { + const pn = normalizeValue(String(r?.[partNumberKey] ?? '')); + if (!pn || pn === 'Units' || pn === 'Part Number') continue; + + const key = normalizeExcelKey(pn); + if (!key) continue; + + // Keep first-seen designation string (stable filenames from MDX slug). + if (!out.has(key)) out.set(key, pn); + } + + return out; +} + +function loadAllExcelDesignations(): Map { + const out = new Map(); + for (const file of EXCEL_FILES) { + const m = readDesignationsFromExcelFile(file.path); + Array.from(m.entries()).forEach(([k, v]) => { + if (!out.has(k)) out.set(k, { designation: v, voltageType: file.voltageType }); + }); + } + return out; +} + +async function loadProductsFromExcelAndMdx(locale: 'en' | 'de'): Promise { + const mdxIndex = buildMdxIndex(locale); + const excelDesignations = loadAllExcelDesignations(); + + const products: ProductData[] = []; + let id = 1; + + Array.from(excelDesignations.entries()).forEach(([key, data]) => { + const mdx = mdxIndex.get(key) || null; + + const title = mdx?.title || data.designation; + const slug = + mdx?.slug || + title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + + // Only the product description comes from MDX. Everything else is Excel-driven + // during model building (technicalItems + voltage tables). + const descriptionHtml = mdx?.descriptionHtml || ''; + + products.push({ + id: id++, + name: title, + shortDescriptionHtml: '', + descriptionHtml, + applicationHtml: mdx?.applicationHtml || '', + images: mdx?.images || [], + featuredImage: (mdx?.images && mdx.images[0]) || null, + sku: mdx?.sku || title, + slug, + translationKey: slug, + locale, + categories: (mdx?.categories || []).map((name) => ({ name })), + attributes: [], + voltageType: (() => { + const cats = (mdx?.categories || []).map((c) => String(c)); + const isMV = cats.some((c) => /medium[-\s]?voltage|mittelspannung/i.test(c)); + if (isMV && data.voltageType === 'high-voltage') return 'medium-voltage'; + return data.voltageType; + })(), + }); + }); + + // Deterministic order: by slug, then name. + products.sort( + (a, b) => (a.slug || '').localeCompare(b.slug || '') || a.name.localeCompare(b.name), + ); + + // Drop products that have no readable name. + return products.filter((p) => stripHtml(p.name)); +} + +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') as 'en' | 'de'; + const buffer = await generateDatasheetPdfBuffer({ product, locale }); + const fileName = generateFileName(product, locale); + + // Determine subfolder based on voltage type + const voltageType = (product as any).voltageType || 'other'; + const subfolder = path.join(CONFIG.outputDir, voltageType); + + // Create subfolder if it doesn't exist + if (!fs.existsSync(subfolder)) { + fs.mkdirSync(subfolder, { recursive: true }); + } + + fs.writeFileSync(path.join(subfolder, fileName), buffer); + console.log(`✓ ${locale.toUpperCase()}: ${voltageType}/${fileName}`); + await new Promise((resolve) => setTimeout(resolve, 25)); + } catch (error) { + console.error(`✗ Failed to process product ${product.id}:`, error); + } + } +} + +async function processProductsInChunks(): Promise { + console.log('Starting PDF generation (React-PDF)'); + ensureOutputDir(); + + const onlyLocale = normalizeValue(String(process.env.PDF_LOCALE || '')).toLowerCase(); + const locales: Array<'en' | 'de'> = + onlyLocale === 'de' || onlyLocale === 'en' ? [onlyLocale] : ['en', 'de']; + + const allProducts: ProductData[] = []; + for (const locale of locales) { + const products = await loadProductsFromExcelAndMdx(locale); + allProducts.push(...products); + } + + if (allProducts.length === 0) { + console.log('No products found'); + return; + } + + // Dev convenience: generate only one product subset. + // IMPORTANT: apply filters BEFORE PDF_LIMIT so the limit works within the filtered set. + let products = allProducts; + + const match = normalizeValue(String(process.env.PDF_MATCH || '')).toLowerCase(); + if (match) { + products = products.filter((p) => { + const hay = [p.slug, p.translationKey, p.sku, p.name].filter(Boolean).join(' ').toLowerCase(); + return hay.includes(match); + }); + } + + 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}`); +} + +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 }; diff --git a/scripts/pdf/model/build-datasheet-model.ts b/scripts/pdf/model/build-datasheet-model.ts new file mode 100644 index 00000000..5df3d205 --- /dev/null +++ b/scripts/pdf/model/build-datasheet-model.ts @@ -0,0 +1,1618 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import type { DatasheetModel, DatasheetVoltageTable, KeyValueItem, ProductData } from './types'; +import type { ExcelMatch, MediumVoltageCrossSectionExcelMatch } from './excel-index'; +import { findExcelForProduct, findMediumVoltageCrossSectionExcelForProduct } from './excel-index'; +import { getLabels, getProductUrl, normalizeValue, stripHtml } from './utils'; + +type ExcelRow = Record; +type VoltageTableModel = { + voltageLabel: string; + metaItems: KeyValueItem[]; + crossSections: string[]; + columns: Array<{ key: string; label: string; get: (rowIndex: number) => string }>; +}; +type BuildExcelModelResult = { + ok: boolean; + technicalItems: KeyValueItem[]; + voltageTables: VoltageTableModel[]; +}; +type AssetMap = Record; + +const ASSET_MAP_FILE = path.join(process.cwd(), 'data/processed/asset-map.json'); + +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(); + +function normalizeUnit(unitRaw: string): string { + const u = normalizeValue(unitRaw); + if (!u) return ''; + if (/^c$/i.test(u) || /^°c$/i.test(u)) return '°C'; + return u.replace(/Ω/gi, 'Ohm').replace(/[\u00B5\u03BC]/g, 'u'); +} + +function formatExcelHeaderLabel(key: string, unit?: string): string { + const k = normalizeValue(key); + if (!k) return ''; + const u = normalizeValue(unit || ''); + const compact = k + .replace(/\s*\(approx\.?\)\s*/gi, ' (approx.) ') + .replace(/\s+/g, ' ') + .trim(); + if (!u) return compact; + if (new RegExp(`\\(${u.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')}\\)`, 'i').test(compact)) + return compact; + return `${compact} (${u})`; +} + +function normalizeVoltageLabel(raw: string): string { + const v = normalizeValue(raw); + if (!v) return ''; + const cleaned = v.replace(/\s+/g, ' '); + if (/\bkv\b/i.test(cleaned)) return cleaned.replace(/\bkv\b/i, 'kV'); + const num = cleaned.match(/\d+(?:[.,]\d+)?(?:\s*\/\s*\d+(?:[.,]\d+)?)?/); + if (!num) return cleaned; + if (/[a-z]/i.test(cleaned)) return cleaned; + return `${cleaned} kV`; +} + +function parseVoltageSortKey(voltageLabel: string): number { + const v = normalizeVoltageLabel(voltageLabel); + 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 compactNumericForLocale(value: string, locale: 'en' | 'de'): string { + const v = normalizeValue(value); + if (!v) return ''; + + // Compact common bending-radius style: "15xD (Single core); 12xD (Multi core)" -> "15/12xD". + // Keep semantics, reduce width. Never truncate with ellipses. + if (/\d+xD/i.test(v)) { + const nums = Array.from(v.matchAll(/(\d+)xD/gi)) + .map((m) => m[1]) + .filter(Boolean); + const unique: string[] = []; + for (const n of nums) { + if (!unique.includes(n)) unique.push(n); + } + if (unique.length) return `${unique.join('/')}xD`; + } + + const hasDigit = /\d/.test(v); + if (!hasDigit) return v; + const trimmed = v.replace(/\s+/g, ' ').trim(); + 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, '.'); + const compact = n + .replace(/\.0+$/, '') + .replace(/(\.\d*?)0+$/, '$1') + .replace(/\.$/, ''); + 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) { + const esc = u.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + v = v.replace(new RegExp(`\\s*${esc}\\b`, 'ig'), '').trim(); + 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(); + } + v = v + .replace(/\s*–\s*/g, '-') + .replace(/\s*-\s*/g, '-') + .replace(/\s*\/\s*/g, '/') + .replace(/\s+/g, ' ') + .trim(); + return compactNumericForLocale(v, locale); +} + +function resolveMediaToLocalPath(urlOrPath: string | null | undefined): string | null { + if (!urlOrPath) return null; + if (urlOrPath.startsWith('/')) return urlOrPath; + if (/^media\//i.test(urlOrPath)) return `/${urlOrPath}`; + 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; + } + return urlOrPath; +} + +function guessColumnKey(row: ExcelRow, patterns: RegExp[]): string | null { + const keys = Object.keys(row || {}); + for (const re of patterns) { + const k = keys.find((x) => { + const key = String(x); + 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 technicalFullLabel(args: { key: string; excelKey: string; locale: 'en' | 'de' }): string { + const key = args.key; + if (args.locale === 'de') { + switch (key) { + case 'cond_mat': + return 'Leitermaterial'; + case 'cond_class': + return 'Leiterklasse'; + case 'core_ins': + return 'Aderisolation'; + case 'field_ctrl': + return 'Feldsteuerung'; + case 'screen': + return 'Schirm'; + case 'long_water': + return 'Längswasserdichtigkeit'; + case 'trans_water': + return 'Querwasserdichtigkeit'; + case 'sheath_mat': + return 'Mantelmaterial'; + case 'sheath_color': + return 'Mantelfarbe'; + case 'flame_ret': + return 'Flammwidrigkeit'; + case 'uv_res': + return 'UV-beständig'; + case 'max_cond_temp': + return 'Max. zulässige Leitertemperatur'; + case 'out_temp_fixed': + return 'Zul. Kabelaußentemperatur, fest verlegt'; + case 'out_temp_motion': + return 'Zul. Kabelaußentemperatur, in Bewegung'; + case 'max_sc_temp_val': + return 'Maximale Kurzschlußtemperatur'; + case 'max_sc_temp': + return 'Maximale Kurzschlußtemperatur'; + case 'max_op_temp': + return 'Max. zulässige Leitertemperatur'; + case 'min_store_temp': + return 'Minimale Lagertemperatur'; + case 'min_lay_temp': + return 'Mindesttemperatur Verlegung'; + case 'min_bend_fixed': + return 'Min. Biegeradius, fest verlegt'; + case 'min_lay_temp_val': + return 'Mindesttemperatur Verlegung'; + case 'meter_mark': + return 'Metermarkierung'; + case 'partial_dis': + return 'Teilentladung'; + case 'cap': + return 'Kapazität'; + case 'X': + return 'Reaktanz'; + case 'test_volt': + return 'Prüfspannung'; + case 'rated_volt': + return 'Nennspannung'; + case 'temp_range': + return 'Temperaturbereich'; + case 'Wm': + return 'Manteldicke'; + case 'Wi': + return 'Isolationsdicke'; + case 'RI': + return 'DC-Leiterwiderstand (20 °C)'; + case 'Ø': + return 'Außen-Ø'; + case 'Rbv': + return 'Biegeradius'; + case 'cpr': + return 'CPR-Klasse'; + case 'flame': + return 'Flammhemmend'; + case 'G': + return 'Gewicht'; + case 'Fzv': + return 'Zugkraft'; + case 'DI': + return 'Durchmesser über Isolation'; + case 'Ik_cond': + return 'Kurzschlussstrom Leiter'; + case 'Ik_screen': + return 'Kurzschlussstrom Schirm'; + case 'D_screen': + return 'Durchmesser über Schirm'; + case 'S_screen': + return 'Metallischer Schirm'; + case 'cross_section': + return 'Querschnitt'; + case 'shape': + return 'Leiterform'; + case 'Ibl': + return 'Strombelastbarkeit (Luft)'; + case 'Ibe': + return 'Strombelastbarkeit (Erde)'; + case 'Cond': + return 'Leitermaterial'; + } + } else { + switch (key) { + case 'cond_mat': + return 'Conductor material'; + case 'cond_class': + return 'Conductor class'; + case 'core_ins': + return 'Core insulation'; + case 'field_ctrl': + return 'Field control'; + case 'screen': + return 'Screen'; + case 'long_water': + return 'Longitudinal water tightness'; + case 'trans_water': + return 'Transverse water tightness'; + case 'sheath_mat': + return 'Sheath material'; + case 'sheath_color': + return 'Sheath color'; + case 'flame_ret': + return 'Flame retardancy'; + case 'uv_res': + return 'UV resistant'; + case 'max_cond_temp': + return 'Max. permissible conductor temperature'; + case 'out_temp_fixed': + return 'Permissible cable outer temperature, fixed'; + case 'out_temp_motion': + return 'Permissible cable outer temperature, in motion'; + case 'max_sc_temp_val': + return 'Maximum short-circuit temperature'; + case 'max_sc_temp': + return 'Maximum short-circuit temperature'; + case 'max_op_temp': + return 'Max. permissible conductor temperature'; + case 'min_store_temp': + return 'Minimum storage temperature'; + case 'min_lay_temp': + return 'Minimum laying temperature'; + case 'min_bend_fixed': + return 'Min. bending radius, fixed'; + case 'min_lay_temp_val': + return 'Minimum laying temperature'; + case 'meter_mark': + return 'Meter marking'; + case 'partial_dis': + return 'Partial discharge'; + case 'cap': + return 'Capacitance'; + case 'X': + return 'Reactance'; + case 'test_volt': + return 'Test voltage'; + case 'rated_volt': + return 'Rated voltage'; + case 'temp_range': + return 'Operating temperature range'; + case 'Wm': + return 'Sheath thickness'; + case 'Wi': + return 'Insulation thickness'; + case 'RI': + return 'DC resistance (20 °C)'; + case 'Ø': + return 'Outer diameter'; + case 'Rbv': + return 'Bending radius'; + case 'cpr': + return 'CPR class'; + case 'flame': + return 'Flame retardant'; + case 'G': + return 'Weight'; + case 'Fzv': + return 'Pulling force'; + case 'DI': + return 'Diameter over insulation'; + case 'Ik_cond': + return 'Short-circuit current conductor'; + case 'Ik_screen': + return 'Short-circuit current screen'; + case 'D_screen': + return 'Diameter over screen'; + case 'S_screen': + return 'Metallic screen'; + case 'cross_section': + return 'Cross-section'; + case 'shape': + return 'Conductor shape'; + case 'Ibl': + return 'Current rating (air)'; + case 'Ibe': + return 'Current rating (ground)'; + case 'Cond': + return 'Conductor material'; + } + } + + // Fallback for unmapped keys (should be rare if columnMapping is comprehensive) + const raw = normalizeValue(args.excelKey); + if (!raw) return ''; + + if (args.locale === 'de') { + return raw + .replace(/\(approx\.?\)/gi, '(ca.)') + .replace(/\bpackaging\b/gi, 'Verpackung') + .replace(/\bce\s*-?conformity\b/gi, 'CE-Konformität'); + } + + return raw + .replace(/\bpackaging\b/gi, 'Packaging') + .replace(/\bce\s*-?conformity\b/gi, 'CE conformity'); +} + +function technicalValueTranslation(args: { + label: string; + value: string; + locale: 'en' | 'de'; +}): string { + const v = normalizeValue(args.value); + if (!v) return ''; + + if (args.locale === 'de') { + if (/^yes$/i.test(v)) return 'ja'; + if (/^no$/i.test(v)) return 'nein'; + if (/^copper$/i.test(v)) return 'Kupfer'; + if (/^aluminum$/i.test(v)) return 'Aluminium'; + if (/^black$/i.test(v)) return 'schwarz'; + if (/^stranded$/i.test(v)) return 'mehrdrähtig'; + if (/^(\d+)xD$/i.test(v)) return v.replace(/^(\d+)xD$/i, '$1 facher Durchmesser'); + if (/^XLPE/i.test(v)) return v.replace(/^XLPE/i, 'VPE'); + if (/^yes, with swelling tape$/i.test(v)) return 'ja, mit Quellvliess'; + if (/^yes, Al-tape$/i.test(v)) return 'ja, Al-Band'; + if (/^Polyethylene/i.test(v)) return v.replace(/^Polyethylene/i, 'Polyethylen'); + if (/^Class 2 stranded$/i.test(v)) return 'Klasse 2 mehrdrähtig'; + if (/^VPE DIX8$/i.test(v)) return 'VPE DIX8'; + if ( + /^inner and outer semiconducting layer made of semiconducting plastic - 3-fold extruded$/i.test( + v, + ) + ) + return 'innere und äußere Leitschicht aus halbleitendem Kunststoff - 3-fach-extrudiert'; + if (/^copper wires \+ transverse conductive helix$/i.test(v)) + return 'Kupferdrähte + Querleitwendel'; + if (/^Polyethylene DMP2$/i.test(v)) return 'Polyethylen DMP2'; + if (/^15 times diameter$/i.test(v)) return '15 facher Durchmesser'; + + // Fallback for partial matches or common terms + return v + .replace(/\bcopper\b/gi, 'Kupfer') + .replace(/\baluminum\b/gi, 'Aluminium') + .replace(/\bblack\b/gi, 'schwarz') + .replace(/\bstranded\b/gi, 'mehrdrähtig') + .replace(/\byes\b/gi, 'ja') + .replace(/\bno\b/gi, 'nein') + .replace(/\bPolyethylene\b/gi, 'Polyethylen') + .replace(/\bXLPE\b/gi, 'VPE'); + } + + if (args.locale === 'en') { + if (/^ja$/i.test(v)) return 'yes'; + if (/^nein$/i.test(v)) return 'no'; + if (/^kupfer$/i.test(v)) return 'Copper'; + if (/^aluminium$/i.test(v)) return 'Aluminum'; + if (/^schwarz$/i.test(v)) return 'black'; + if (/^mehrdrähtig$/i.test(v)) return 'stranded'; + if (/^(\d+)xD$/i.test(v)) return v.replace(/^(\d+)xD$/i, '$1 times diameter'); + if (/^VPE/i.test(v)) return v.replace(/^VPE/i, 'XLPE'); + if (/^ja, mit Quellvliess$/i.test(v)) return 'yes, with swelling tape'; + if (/^ja, Al-Band$/i.test(v)) return 'yes, Al-tape'; + if (/^Polyethylen/i.test(v)) return v.replace(/^Polyethylen/i, 'Polyethylene'); + if (/^Klasse 2 mehrdrähtig$/i.test(v)) return 'Class 2 stranded'; + if (/^innere und äußere Leitschicht aus halbleitendem Kunststoff - 3-fach-extrudiert$/i.test(v)) + return 'inner and outer semiconducting layer made of semiconducting plastic - 3-fold extruded'; + if (/^Kupferdrähte \+ Querleitwendel$/i.test(v)) + return 'copper wires + transverse conductive helix'; + if (/^Polyethylen DMP2$/i.test(v)) return 'Polyethylene DMP2'; + if (/^15 facher Durchmesser$/i.test(v)) return '15 times diameter'; + + // Fallback for partial matches or common terms + return v + .replace(/\bkupfer\b/gi, 'Copper') + .replace(/\baluminium\b/gi, 'Aluminum') + .replace(/\bschwarz\b/gi, 'black') + .replace(/\bmehrdrähtig\b/gi, 'stranded') + .replace(/\bja\b/gi, 'yes') + .replace(/\bnein\b/gi, 'no') + .replace(/\bPolyethylen\b/gi, 'Polyethylene') + .replace(/\bVPE\b/gi, 'XLPE'); + } + + return v; +} + +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'; + case 'DI': + return 'Durchmesser über Isolation'; + case 'RI': + return 'DC-Leiterwiderstand (20 °C)'; + case 'Wi': + return 'Isolationsdicke'; + case 'Wm': + return 'Manteldicke'; + case 'Rbv': + return 'Biegeradius'; + case 'Fzv': + return 'Zugkraft'; + case 'G': + return 'Gewicht'; + case 'Ik_cond': + return 'Kurzschlussstrom Leiter'; + case 'Ik_screen': + return 'Kurzschlussstrom Schirm'; + case 'Ø': + return 'Außen-Ø'; + case 'cap': + return 'Kapazität'; + case 'X': + return 'Reaktanz'; + case 'rated_volt': + return 'Nennspannung'; + case 'D_screen': + return 'Durchmesser über Schirm'; + case 'S_screen': + return 'Metallischer Schirm'; + case 'cross_section': + return 'Querschnitt'; + case 'shape': + return 'Leiterform'; + case 'Ibl': + return 'Strombelastbarkeit (Luft)'; + case 'Ibe': + return 'Strombelastbarkeit (Erde)'; + case 'Cond': + return 'Leitermaterial'; + default: + return formatExcelHeaderLabel(args.excelKey); + } + } + + 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'; + case 'DI': + return 'Diameter over insulation'; + case 'RI': + return 'DC resistance (20 °C)'; + case 'Wi': + return 'Insulation thickness'; + case 'Wm': + return 'Sheath thickness'; + case 'Rbv': + return 'Bending radius'; + case 'Fzv': + return 'Pulling force'; + case 'G': + return 'Weight'; + case 'Ik_cond': + return 'Short-circuit current conductor'; + case 'Ik_screen': + return 'Short-circuit current screen'; + case 'Ø': + return 'Outer diameter'; + case 'cap': + return 'Capacitance'; + case 'X': + return 'Reactance'; + case 'rated_volt': + return 'Rated voltage'; + case 'D_screen': + return 'Diameter over screen'; + case 'S_screen': + return 'Metallic screen'; + case 'cross_section': + return 'Cross-section'; + case 'shape': + return 'Conductor shape'; + case 'Ibl': + return 'Current rating (air)'; + case 'Ibe': + return 'Current rating (ground)'; + case 'Cond': + return 'Conductor material'; + default: + return formatExcelHeaderLabel(args.excelKey); + } +} + +function denseAbbrevLabel(args: { key: string; locale: 'en' | 'de'; unit?: string }): string { + const u = normalizeUnit(args.unit || ''); + const unitSafe = u.replace(/Ω/gi, 'Ohm').replace(/[\u00B5\u03BC]/g, 'u'); + const suffix = 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': + // Capacitance. Use a clear label; lowercase "cap" looks like an internal key. + return `Cap${suffix}`; + case 'X': + return `X${suffix}`; + case 'test_volt': + return `U_test${suffix}`; + case 'rated_volt': + return `U0/U${suffix}`; + 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}`; + case 'cpr': + return `CPR${suffix}`; + case 'flame': + return `FR${suffix}`; + default: + return args.key || ''; + } +} + +function translateAbbreviation(abbrev: string, description: string, locale: 'en' | 'de'): string { + const normalizedDesc = normalizeValue(description); + if (!normalizedDesc) return description; + + // German translations for common abbreviations + if (locale === 'de') { + switch (abbrev) { + case 'DI': + return 'Durchmesser über Isolation'; + case 'RI': + return 'Widerstand Leiter'; + case 'Wi': + return 'Isolationsdicke'; + case 'Ibl': + return 'Strombelastbarkeit Luft'; + case 'Ibe': + return 'Strombelastbarkeit Erde'; + case 'Wm': + return 'Manteldicke'; + case 'Rbv': + return 'Biegeradius'; + case 'Fzv': + return 'Zugkraft'; + case 'G': + return 'Gewicht'; + case 'Ik_cond': + return 'Kurzschlussstrom Leiter'; + case 'Ik_screen': + return 'Kurzschlussstrom Schirm'; + case 'Ø': + return 'Außen-Ø'; + case 'Cond': + return 'Leiter'; + case 'shape': + return 'Leiterform'; + case 'cap': + return 'Kapazität'; + 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'; + default: + return normalizedDesc; + } + } + + // English translations for common abbreviations + switch (abbrev) { + case 'DI': + return 'Diameter over insulation'; + case 'RI': + return 'DC resistance'; + case 'Wi': + return 'Insulation thickness'; + case 'Ibl': + return 'Current rating in air'; + case 'Ibe': + return 'Current rating in ground'; + case 'Wm': + return 'Sheath thickness'; + case 'Rbv': + return 'Bending radius'; + case 'Fzv': + return 'Pulling force'; + case 'G': + return 'Weight'; + case 'Ik_cond': + return 'Short-circuit current conductor'; + case 'Ik_screen': + return 'Short-circuit current screen'; + case 'Ø': + return 'Outer diameter'; + case 'Cond': + return 'Conductor'; + case 'shape': + return 'Shape'; + case 'cap': + return 'Capacitance'; + case 'X': + return 'Reactance'; + case 'test_volt': + return 'Test voltage'; + case 'rated_volt': + return 'Rated voltage'; + case 'temp_range': + return 'Operating temperature range'; + case 'max_op_temp': + return 'Max operating temperature'; + case 'max_sc_temp': + return 'Max short-circuit temperature'; + case 'min_store_temp': + return 'Min storage temperature'; + case 'min_lay_temp': + return 'Min laying temperature'; + case 'cpr': + return 'CPR class'; + case 'flame': + return 'Flame retardant'; + default: + return normalizedDesc; + } +} + +function summarizeOptions(options: string[] | undefined): 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]; + // Never use ellipsis truncation in datasheets. Prefer full value list. + // (Long values should be handled by layout; if needed we can later add wrapping rules.) + return uniq.join(' / '); +} + +function parseNumericOption(value: string): number | null { + const v = normalizeValue(value).replace(/,/g, '.'); + const m = v.match(/-?\d+(?:\.\d+)?/); + if (!m) return null; + const n = Number(m[0]); + return Number.isFinite(n) ? n : null; +} + +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 (uniq.length < 4) return { ok: false, text: '' }; + uniq.sort((a, b) => a - b); + const min = uniq[0]; + const max = uniq[uniq.length - 1]; + const fmt = (n: number) => (Number.isInteger(n) ? String(n) : String(n)).replace(/\.0+$/, ''); + return { ok: true, text: `${fmt(min)}–${fmt(max)}` }; +} + +function summarizeSmartOptions(_label: string, options: string[] | undefined): string { + const range = summarizeNumericRange(options); + if (range.ok) return range.text; + return summarizeOptions(options); +} + +function normalizeDesignation(value: string): string { + return String(value || '') + .toUpperCase() + .replace(/-\d+$/g, '') + .replace(/[^A-Z0-9]+/g, ''); +} + +function buildExcelModel(args: { + product: ProductData; + locale: 'en' | 'de'; +}): BuildExcelModelResult { + const match = findExcelForProduct(args.product) as ExcelMatch | null; + if (!match || match.rows.length === 0) + return { ok: false, technicalItems: [], voltageTables: [] }; + + const units = match.units || {}; + const rows = match.rows; + 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; + } + } + + const columnMapping: Record = { + 'number of cores and cross-section': { + header: 'Cross-section', + unit: '', + key: 'cross_section', + }, + 'ross section conductor': { header: 'Cross-section', unit: '', key: 'cross_section' }, + + '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': { header: 'Fzv', unit: 'N', key: 'Fzv' }, + 'max. pulling force': { header: 'Fzv', unit: 'N', key: 'Fzv' }, + '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' }, + + 'shape of conductor': { header: 'Conductor shape', unit: '', key: 'shape' }, + 'operating temperature range': { + header: 'Operating temp range', + unit: '°C', + key: 'temp_range', + }, + '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', + }, + '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' }, + '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' }, + + 'conductor material': { header: 'Conductor material', unit: '', key: 'cond_mat' }, + 'conductor class': { header: 'Conductor class', unit: '', key: 'cond_class' }, + 'core insulation': { header: 'Core insulation', unit: '', key: 'core_ins' }, + 'field control': { header: 'Field control', unit: '', key: 'field_ctrl' }, + screen: { header: 'Screen', unit: '', key: 'screen' }, + 'longitudinal water tightness': { + header: 'Longitudinal water tightness', + unit: '', + key: 'long_water', + }, + 'transverse water tightness': { + header: 'Transverse water tightness', + unit: '', + key: 'trans_water', + }, + 'sheath material': { header: 'Sheath material', unit: '', key: 'sheath_mat' }, + 'sheath color': { header: 'Sheath color', unit: '', key: 'sheath_color' }, + 'flame retardancy': { header: 'Flame retardancy', unit: '', key: 'flame_ret' }, + 'uv resistant': { header: 'UV resistant', unit: '', key: 'uv_res' }, + 'max. permissible conductor temperature': { + header: 'Max. permissible conductor temperature', + unit: '°C', + key: 'max_cond_temp', + }, + 'permissible cable outer temperature, fixed': { + header: 'Permissible cable outer temperature, fixed', + unit: '°C', + key: 'out_temp_fixed', + }, + 'permissible cable outer temperature, in motion': { + header: 'Permissible cable outer temperature, in motion', + unit: '°C', + key: 'out_temp_motion', + }, + 'maximum short-circuit temperature': { + header: 'Maximum short-circuit temperature', + unit: '°C', + key: 'max_sc_temp_val', + }, + 'min. bending radius, fixed': { + header: 'Min. bending radius, fixed', + unit: '', + key: 'min_bend_fixed', + }, + 'minimum laying temperature': { + header: 'Minimum laying temperature', + unit: '°C', + key: 'min_lay_temp_val', + }, + 'meter marking': { header: 'Meter marking', unit: '', key: 'meter_mark' }, + 'partial discharge': { header: 'Partial discharge', unit: 'pC', key: 'partial_dis' }, + + // High-value electrical/screen columns + 'capacitance (approx.)': { header: 'Capacitance', unit: 'uF/km', key: 'cap' }, + capacitance: { header: 'Capacitance', unit: 'uF/km', key: 'cap' }, + reactance: { header: 'Reactance', unit: 'Ohm/km', key: 'X' }, + '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' }, + }; + + const excelKeys = Object.keys(sample).filter((k) => k && k !== 'Part Number' && k !== 'Units'); + 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; + } + } + } + + 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); + } + } + + const sampleKeys = Object.keys(sample) + .filter((k) => k && k !== 'Part Number' && k !== 'Units') + .sort(); + const shapeKey = + guessColumnKey(sample, [ + /shape of conductor/i, + /leiterform/i, + /leiter.*form/i, + /^shape$/i, + /^form$/i, + ]) || null; + const isAlProduct = + /^NA/i.test(args.product.name || '') || + /^na/i.test(args.product.slug || '') || + /^NA/i.test(args.product.sku || ''); + + const compatibleRows = rows.filter((r) => { + const rKeys = Object.keys(r) + .filter((k) => k && k !== 'Part Number' && k !== 'Units') + .sort(); + if (JSON.stringify(rKeys) !== JSON.stringify(sampleKeys)) return false; + if (isAlProduct && shapeKey) { + const shapeVal = normalizeValue(String(r?.[shapeKey] ?? '')).toLowerCase(); + if (shapeVal === 're' || shapeVal === 'se') { + return false; + } + } + return true; + }); + 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: [] }; + + 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 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); + }); + + const technicalItems: KeyValueItem[] = []; + const globalConstantColumns = new Set(); + + for (const { excelKey, mapping } of deduplicated) { + 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); + const unit = normalizeUnit(units[excelKey] || mapping.unit || ''); + const labelBase = technicalFullLabel({ key: mapping.key, excelKey, locale: args.locale }); + const label = formatExcelHeaderLabel(labelBase, unit); + const rawValue = compactCellForDenseTable(values[0], unit, args.locale); + const value = technicalValueTranslation({ + label: labelBase, + value: rawValue, + locale: args.locale, + }); + if (!technicalItems.find((t) => t.label === label)) + technicalItems.push({ label, value, unit }); + } + } + const TECHNICAL_DATA_ORDER_DE = [ + 'Leitermaterial', + 'Leiterklasse', + 'Aderisolation', + 'Feldsteuerung', + 'Schirm', + 'Längswasserdichtigkeit', + 'Querwasserdichtigkeit', + 'Mantelmaterial', + 'Mantelfarbe', + 'Flammwidrigkeit', + 'UV-beständig', + 'Max. zulässige Leitertemperatur', + 'Zul. Kabelaußentemperatur, fest verlegt', + 'Zul. Kabelaußentemperatur, in Bewegung', + 'Maximale Kurzschlußtemperatur', + 'Min. Biegeradius, fest verlegt', + 'Mindesttemperatur Verlegung', + 'Metermarkierung', + 'Teilentladung', + ]; + + const TECHNICAL_DATA_ORDER_EN = [ + 'Conductor material', + 'Conductor class', + 'Core insulation', + 'Field control', + 'Screen', + 'Longitudinal water tightness', + 'Transverse water tightness', + 'Sheath material', + 'Sheath color', + 'Flame retardancy', + 'UV resistant', + 'Max. permissible conductor temperature', + 'Permissible cable outer temperature, fixed', + 'Permissible cable outer temperature, in motion', + 'Maximum short-circuit temperature', + 'Min. bending radius, fixed', + 'Minimum laying temperature', + 'Meter marking', + 'Partial discharge', + ]; + + const order = args.locale === 'de' ? TECHNICAL_DATA_ORDER_DE : TECHNICAL_DATA_ORDER_EN; + + technicalItems.sort((a, b) => { + const indexA = order.findIndex((label) => a.label.startsWith(label)); + const indexB = order.findIndex((label) => b.label.startsWith(label)); + + if (indexA !== -1 && indexB !== -1) return indexA - indexB; + if (indexA !== -1) return -1; + if (indexB !== -1) return 1; + return a.label.localeCompare(b.label); + }); + + const voltageTables: VoltageTableModel[] = []; + for (const vKey of voltageKeysSorted) { + const indices = byVoltage.get(vKey) || []; + if (!indices.length) continue; + const crossSections = indices.map((idx) => + normalizeValue(String(compatibleRows[idx]?.[csKey] ?? '')), + ); + + 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 || ''), + }); + } + + 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); + + const denseTableKeyOrder = [ + 'Cond', + 'shape', + // Electrical properties (when present) + 'cap', + 'X', + // Dimensions and ratings + 'DI', + 'RI', + 'Wi', + 'Ibl', + 'Ibe', + 'Ik_cond', + 'Wm', + 'Rbv', + 'Ø', + // Screen data (when present) + 'D_screen', + 'S_screen', + 'Fzv', + 'G', + ] as const; + const denseTableKeys = new Set(denseTableKeyOrder); + + const tableColumns: Array<{ + excelKey: string; + mapping: { header: string; unit: string; key: string }; + }> = []; + for (const { excelKey, mapping } of deduplicated) { + if (excelKey === csKey || excelKey === voltageKey) continue; + const values = indices + .map((idx) => normalizeValue(String(compatibleRows[idx]?.[excelKey] ?? ''))) + .filter(Boolean); + if (!values.length) continue; + const unique = Array.from(new Set(values.map((v) => v.toLowerCase()))); + const unit = normalizeUnit(units[excelKey] || mapping.unit || ''); + + if (denseTableKeys.has(mapping.key)) { + tableColumns.push({ excelKey, mapping }); + continue; + } + + if (globalConstantColumns.has(excelKey) && !metaKeyPrioritySet.has(mapping.key)) { + continue; + } + + const value = + unique.length === 1 + ? compactCellForDenseTable(values[0], unit, args.locale) + : summarizeSmartOptions(excelKey, values); + const label = metaFullLabel({ key: mapping.key, excelKey, locale: args.locale }); + metaCandidates.set(mapping.key, { label, value, unit }); + } + + for (const k of metaKeyPriority) { + const item = metaCandidates.get(k); + if (item && item.label && item.value) metaItems.push(item); + } + + const mappedByKey = new Map< + string, + { excelKey: string; mapping: { header: string; unit: string; key: string } } + >(); + for (const c of tableColumns) { + if (!mappedByKey.has(c.mapping.key)) mappedByKey.set(c.mapping.key, c); + } + + // If conductor material is missing in Excel, derive it from designation. + // NA... => Al, N... => Cu (common for this dataset). + if (!mappedByKey.has('Cond')) { + mappedByKey.set('Cond', { + excelKey: '', + mapping: { header: 'Cond.', unit: '', key: 'Cond' }, + }); + } + + const orderedTableColumns = denseTableKeyOrder + .filter((k) => mappedByKey.has(k)) + .map((k) => mappedByKey.get(k)!) + .map(({ excelKey, mapping }) => { + const unit = normalizeUnit((excelKey ? units[excelKey] : '') || mapping.unit || ''); + return { + key: mapping.key, + label: + denseAbbrevLabel({ key: mapping.key, locale: args.locale, unit }) || + formatExcelHeaderLabel(excelKey, unit), + get: (rowIndex: number) => { + const srcRowIndex = indices[rowIndex]; + + if (mapping.key === 'Cond' && !excelKey) { + const pn = normalizeDesignation( + args.product.name || args.product.slug || args.product.sku || '', + ); + if (/^NA/.test(pn)) return 'Al'; + if (/^N/.test(pn)) return 'Cu'; + return ''; + } + + let raw = excelKey + ? normalizeValue(String(compatibleRows[srcRowIndex]?.[excelKey] ?? '')) + : ''; + if (isAlProduct && mapping.key === 'shape') { + raw = raw.replace(/\bRE\b/g, 'RM').replace(/\bSE\b/g, 'SM'); + } + return compactCellForDenseTable(raw, unit, args.locale); + }, + }; + }); + + voltageTables.push({ + voltageLabel: vKey, + metaItems, + crossSections, + columns: orderedTableColumns, + }); + } + + return { ok: true, technicalItems, voltageTables }; +} + +function isMediumVoltageProduct(product: ProductData): boolean { + if (product.voltageType === 'medium-voltage') return true; + const hay = [ + product.slug, + product.path, + product.translationKey, + ...(product.categories || []).map((c) => c.name), + ] + .filter(Boolean) + .join(' '); + return /medium[-\s]?voltage|mittelspannung/i.test(hay); +} + +type AbbrevColumn = { colKey: string; unit: string }; + +function isAbbreviatedHeaderKey(key: string): boolean { + const k = normalizeValue(key); + if (!k) return false; + if (/^__EMPTY/i.test(k)) return false; + + // Examples from the MV sheet: "LD mm", "RI Ohm", "G kg", "SBL 30", "SBE 20", "BK", "BR", "LF". + // Keep this permissive but focused on compact, non-sentence identifiers. + if (k.length > 12) return false; + if (/[a-z]{4,}/.test(k)) return false; + if (!/[A-ZØ]/.test(k)) return false; + return true; +} + +function extractAbbrevColumnsFromMediumVoltageHeader(args: { + headerRow: Record; + units: Record; + partNumberKey: string; + crossSectionKey: string; + ratedVoltageKey: string | null; +}): AbbrevColumn[] { + const out: AbbrevColumn[] = []; + + for (const colKey of Object.keys(args.headerRow || {})) { + if (!colKey) continue; + if (colKey === args.partNumberKey) continue; + if (colKey === args.crossSectionKey) continue; + if (args.ratedVoltageKey && colKey === args.ratedVoltageKey) continue; + + if (!isAbbreviatedHeaderKey(colKey)) continue; + + const unit = normalizeUnit(args.units[colKey] || ''); + out.push({ colKey, unit }); + } + + return out; +} + +function buildMediumVoltageCrossSectionTableFromNewExcel(args: { + product: ProductData; + locale: 'en' | 'de'; +}): BuildExcelModelResult & { legendItems: KeyValueItem[] } { + const mv = findMediumVoltageCrossSectionExcelForProduct( + args.product, + ) as MediumVoltageCrossSectionExcelMatch | null; + if (!mv || !mv.rows.length) + return { ok: false, technicalItems: [], voltageTables: [], legendItems: [] }; + if (!mv.crossSectionKey) + return { ok: false, technicalItems: [], voltageTables: [], legendItems: [] }; + + const abbrevCols = extractAbbrevColumnsFromMediumVoltageHeader({ + headerRow: mv.headerRow, + units: mv.units, + partNumberKey: mv.partNumberKey, + crossSectionKey: mv.crossSectionKey, + ratedVoltageKey: mv.ratedVoltageKey, + }); + if (!abbrevCols.length) + return { ok: false, technicalItems: [], voltageTables: [], legendItems: [] }; + + // Collect legend items: abbreviation -> description from header row + const legendItems: KeyValueItem[] = []; + for (const col of abbrevCols) { + const description = normalizeValue(String(mv.headerRow[col.colKey] || '')); + if (description && description !== col.colKey) { + const translatedDescription = translateAbbreviation(col.colKey, description, args.locale); + legendItems.push({ + label: col.colKey, + value: translatedDescription, + }); + } + } + + const byVoltage = new Map(); + for (let i = 0; i < mv.rows.length; i++) { + const cs = normalizeValue( + String((mv.rows[i] as Record)?.[mv.crossSectionKey] ?? ''), + ); + if (!cs) continue; + + const rawV = mv.ratedVoltageKey + ? normalizeValue(String((mv.rows[i] as Record)?.[mv.ratedVoltageKey] ?? '')) + : ''; + + 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 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); + }); + + const voltageTables: VoltageTableModel[] = []; + for (const vKey of voltageKeysSorted) { + const indices = byVoltage.get(vKey) || []; + if (!indices.length) continue; + + const crossSections = indices.map((idx) => + normalizeValue(String((mv.rows[idx] as Record)?.[mv.crossSectionKey] ?? '')), + ); + + const metaItems: KeyValueItem[] = []; + if (mv.ratedVoltageKey) { + const rawV = normalizeValue( + String((mv.rows[indices[0]] as Record)?.[mv.ratedVoltageKey] ?? ''), + ); + metaItems.push({ + label: args.locale === 'de' ? 'Spannung' : 'Voltage', + value: normalizeVoltageLabel(rawV || ''), + }); + } + + const columns = abbrevCols.map((col) => { + return { + key: col.colKey, + // Use the abbreviated title from the first row as the table header. + label: + denseAbbrevLabel({ key: col.colKey, locale: args.locale, unit: col.unit }) || + normalizeValue(col.colKey), + get: (rowIndex: number) => { + const srcRowIndex = indices[rowIndex]; + const raw = normalizeValue( + String((mv.rows[srcRowIndex] as Record)?.[col.colKey] ?? ''), + ); + return compactCellForDenseTable(raw, col.unit, args.locale); + }, + }; + }); + + voltageTables.push({ voltageLabel: vKey, metaItems, crossSections, columns }); + } + + return { ok: true, technicalItems: [], voltageTables, legendItems }; +} + +export function buildDatasheetModel(args: { + product: ProductData; + locale: 'en' | 'de'; +}): DatasheetModel { + const labels = getLabels(args.locale); + const categoriesLine = (args.product.categories || []).map((c) => stripHtml(c.name)).join(' • '); + const descriptionText = stripHtml(args.product.applicationHtml || ''); + const heroSrc = resolveMediaToLocalPath( + args.product.featuredImage || args.product.images?.[0] || null, + ); + const productUrl = getProductUrl(args.product); + + // Technical data MUST stay sourced from the existing Excel index (legacy sheets). + const excelModel = buildExcelModel({ product: args.product, locale: args.locale }); + + // Cross-section tables: for medium voltage only, prefer the new MV sheet (abbrev columns in header row). + const crossSectionModel = isMediumVoltageProduct(args.product) + ? buildMediumVoltageCrossSectionTableFromNewExcel({ + product: args.product, + locale: args.locale, + }) + : { ok: false, technicalItems: [], voltageTables: [], legendItems: [] }; + + const voltageTablesSrc = crossSectionModel.ok + ? crossSectionModel.voltageTables + : excelModel.ok + ? excelModel.voltageTables + : []; + + const voltageTables: DatasheetVoltageTable[] = voltageTablesSrc.map((t) => { + const columns = t.columns.map((c) => ({ key: c.key, label: c.label })); + const rows = t.crossSections.map((configuration, rowIndex) => ({ + configuration, + cells: t.columns.map((c) => compactNumericForLocale(c.get(rowIndex), args.locale)), + })); + return { + voltageLabel: t.voltageLabel, + metaItems: t.metaItems, + columns, + rows, + }; + }); + + return { + locale: args.locale, + product: { + id: args.product.id, + name: stripHtml(args.product.name), + sku: args.product.sku, + categoriesLine, + descriptionText, + heroSrc, + productUrl, + }, + labels, + technicalItems: (() => { + if (!isMediumVoltageProduct(args.product)) { + return excelModel.ok ? excelModel.technicalItems : []; + } + + const pn = normalizeDesignation(args.product.name || ''); + const isAl = /^NA/.test(pn); + const isFL = pn.includes('FL'); + const isF = !isFL && pn.includes('F'); + + const findExcelVal = (labelPart: string) => { + const found = excelModel.technicalItems.find((it) => + it.label.toLowerCase().includes(labelPart.toLowerCase()), + ); + return found ? found.value : null; + }; + + const items: KeyValueItem[] = []; + if (args.locale === 'de') { + items.push({ label: 'Leitermaterial', value: isAl ? 'Aluminium' : 'Kupfer' }); + items.push({ label: 'Leiterklasse', value: 'Klasse 2 mehrdrähtig' }); + items.push({ label: 'Aderisolation', value: 'VPE DIX8' }); + items.push({ + label: 'Feldsteuerung', + value: 'innere und äußere Leitschicht aus halbleitendem Kunststoff - 3-fach-extrudiert', + }); + items.push({ label: 'Schirm', value: 'Kupferdrähte + Querleitwendel' }); + items.push({ + label: 'Längswasserdichtigkeit', + value: isF || isFL ? 'ja, mit Quellvliess' : 'nein', + }); + items.push({ label: 'Querwasserdichtigkeit', value: isFL ? 'ja, Al-Band' : 'nein' }); + items.push({ label: 'Mantelmaterial', value: 'Polyethylen DMP2' }); + items.push({ label: 'Mantelfarbe', value: 'schwarz' }); + items.push({ label: 'Flammwidrigkeit', value: 'nein' }); + items.push({ label: 'UV-beständig', value: 'ja' }); + items.push({ + label: 'Max. zulässige Leitertemperatur', + value: findExcelVal('Leitertemperatur') || '90°C', + }); + items.push({ + label: 'Zul. Kabelaußentemperatur, fest verlegt', + value: findExcelVal('fest verlegt') || '70°C', + }); + items.push({ + label: 'Zul. Kabelaußentemperatur, in Bewegung', + value: findExcelVal('in Bewegung') || '-20 °C bis +70 °C', + }); + items.push({ + label: 'Maximale Kurzschlußtemperatur', + value: findExcelVal('Kurzschlußtemperatur') || '+250 °C', + }); + items.push({ + label: 'Min. Biegeradius, fest verlegt', + value: findExcelVal('Biegeradius') || '15 facher Durchmesser', + }); + items.push({ + label: 'Mindesttemperatur Verlegung', + value: findExcelVal('Verlegung') || '-5 °C', + }); + items.push({ label: 'Metermarkierung', value: 'ja' }); + items.push({ label: 'Teilentladung', value: findExcelVal('Teilentladung') || '2 pC' }); + items.push({ label: 'Prüfspannung 6/10 kV', value: '21 kV' }); + items.push({ label: 'Prüfspannung 12/20 kV', value: '42 kV' }); + items.push({ label: 'Prüfspannung 18/30 kV', value: '63 kV' }); + } else { + items.push({ label: 'Conductor material', value: isAl ? 'Aluminum' : 'Copper' }); + items.push({ label: 'Conductor class', value: 'Class 2 stranded' }); + items.push({ label: 'Core insulation', value: 'XLPE DIX8' }); + items.push({ + label: 'Field control', + value: + 'inner and outer semiconducting layer made of semiconducting plastic - 3-fold extruded', + }); + items.push({ label: 'Screen', value: 'copper wires + transverse conductive helix' }); + items.push({ + label: 'Longitudinal water tightness', + value: isF || isFL ? 'yes, with swelling tape' : 'no', + }); + items.push({ label: 'Transverse water tightness', value: isFL ? 'yes, Al-tape' : 'no' }); + items.push({ label: 'Sheath material', value: 'Polyethylene DMP2' }); + items.push({ label: 'Sheath color', value: 'black' }); + items.push({ label: 'Flame retardancy', value: 'no' }); + items.push({ label: 'UV resistant', value: 'yes' }); + items.push({ + label: 'Max. permissible conductor temperature', + value: findExcelVal('conductor temperature') || '90°C', + }); + items.push({ + label: 'Permissible cable outer temperature, fixed', + value: findExcelVal('fixed') || '70°C', + }); + items.push({ + label: 'Permissible cable outer temperature, in motion', + value: findExcelVal('in motion') || '-20 °C to +70 °C', + }); + items.push({ + label: 'Maximum short-circuit temperature', + value: findExcelVal('short-circuit temperature') || '+250 °C', + }); + items.push({ + label: 'Min. bending radius, fixed', + value: findExcelVal('bending radius') || '15 times diameter', + }); + items.push({ + label: 'Minimum laying temperature', + value: findExcelVal('laying temperature') || '-5 °C', + }); + items.push({ label: 'Meter marking', value: 'yes' }); + items.push({ + label: 'Partial discharge', + value: findExcelVal('Partial discharge') || '2 pC', + }); + items.push({ label: 'Test voltage 6/10 kV', value: '21 kV' }); + items.push({ label: 'Test voltage 12/20 kV', value: '42 kV' }); + items.push({ label: 'Test voltage 18/30 kV', value: '63 kV' }); + } + return items; + })(), + voltageTables, + legendItems: crossSectionModel.legendItems || [], + }; +} diff --git a/scripts/pdf/model/excel-index.ts b/scripts/pdf/model/excel-index.ts new file mode 100644 index 00000000..c51cab35 --- /dev/null +++ b/scripts/pdf/model/excel-index.ts @@ -0,0 +1,212 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as XLSX from 'xlsx'; + +import type { ProductData } from './types'; +import { normalizeValue } from './utils'; + +type ExcelRow = Record; +export type ExcelMatch = { rows: ExcelRow[]; units: Record }; + +export type MediumVoltageCrossSectionExcelMatch = { + headerRow: ExcelRow; + rows: ExcelRow[]; + units: Record; + partNumberKey: string; + crossSectionKey: string; + ratedVoltageKey: string | null; +}; + +const EXCEL_SOURCE_FILES = [ + path.join(process.cwd(), 'data/excel/high-voltage.xlsx'), + path.join(process.cwd(), 'data/excel/medium-voltage-KM.xlsx'), + path.join(process.cwd(), 'data/excel/low-voltage-KM.xlsx'), + path.join(process.cwd(), 'data/excel/solar-cables.xlsx'), +]; + +// Medium-voltage cross-section table (new format with multi-row header). +// IMPORTANT: this must NOT be used for the technical data table. +const MV_CROSS_SECTION_FILE = path.join(process.cwd(), 'data/excel/medium-voltage-KM 170126.xlsx'); + +type MediumVoltageCrossSectionIndex = { + headerRow: ExcelRow; + units: Record; + partNumberKey: string; + crossSectionKey: string; + ratedVoltageKey: string | null; + rowsByDesignation: Map; +}; + +let EXCEL_INDEX: Map | null = null; +let MV_CROSS_SECTION_INDEX: MediumVoltageCrossSectionIndex | null = null; + +export function normalizeExcelKey(value: string): string { + return String(value || '') + .toUpperCase() + .replace(/-\d+$/g, '') + .replace(/[^A-Z0-9]+/g, ''); +} + +function loadExcelRows(filePath: string): ExcelRow[] { + if (!fs.existsSync(filePath)) return []; + try { + const fileBuffer = fs.readFileSync(filePath); + const workbook = XLSX.read(fileBuffer, { + type: 'buffer', + cellDates: false, + cellNF: false, + cellText: false, + }); + const sheetName = workbook.SheetNames[0]; + if (!sheetName) return []; + const sheet = workbook.Sheets[sheetName]; + if (!sheet) return []; + return XLSX.utils.sheet_to_json(sheet, { + defval: '', + raw: false, + blankrows: false, + }) as ExcelRow[]; + } catch { + return []; + } +} + +function findKeyByHeaderValue(headerRow: ExcelRow, pattern: RegExp): string | null { + for (const [k, v] of Object.entries(headerRow || {})) { + const text = normalizeValue(String(v ?? '')); + if (!text) continue; + if (pattern.test(text)) return k; + } + return null; +} + +function getMediumVoltageCrossSectionIndex(): MediumVoltageCrossSectionIndex { + if (MV_CROSS_SECTION_INDEX) return MV_CROSS_SECTION_INDEX; + + const rows = fs.existsSync(MV_CROSS_SECTION_FILE) ? loadExcelRows(MV_CROSS_SECTION_FILE) : []; + const headerRow = (rows[0] || {}) as ExcelRow; + + const partNumberKey = findKeyByHeaderValue(headerRow, /^part\s*number$/i) || '__EMPTY'; + const crossSectionKey = findKeyByHeaderValue(headerRow, /querschnitt|cross.?section/i) || ''; + const ratedVoltageKey = + findKeyByHeaderValue(headerRow, /rated voltage|voltage rating|nennspannung/i) || null; + + const unitsRow = + rows.find((r) => normalizeValue(String((r as ExcelRow)?.[partNumberKey] ?? '')) === 'Units') || + null; + const units: Record = {}; + if (unitsRow) { + for (const [k, v] of Object.entries(unitsRow)) { + if (k === partNumberKey) continue; + const unit = normalizeValue(String(v ?? '')); + if (unit) units[k] = unit; + } + } + + const rowsByDesignation = new Map(); + for (const r of rows) { + if (r === headerRow) continue; + const pn = normalizeValue(String((r as ExcelRow)?.[partNumberKey] ?? '')); + if (!pn || pn === 'Units' || pn === 'Part Number') continue; + + const key = normalizeExcelKey(pn); + if (!key) continue; + + const cur = rowsByDesignation.get(key) || []; + cur.push(r); + rowsByDesignation.set(key, cur); + } + + MV_CROSS_SECTION_INDEX = { + headerRow, + units, + partNumberKey, + crossSectionKey, + ratedVoltageKey, + rowsByDesignation, + }; + return MV_CROSS_SECTION_INDEX; +} + +export 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); + + 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; +} + +export 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[]; + + for (const c of candidates) { + const key = normalizeExcelKey(c); + const match = idx.get(key); + if (match && match.rows.length) return match; + } + return null; +} + +export function findMediumVoltageCrossSectionExcelForProduct( + product: ProductData, +): MediumVoltageCrossSectionExcelMatch | null { + const idx = getMediumVoltageCrossSectionIndex(); + const candidates = [ + product.name, + product.slug ? product.slug.replace(/-\d+$/g, '') : '', + product.sku, + product.translationKey, + ].filter(Boolean) as string[]; + + for (const c of candidates) { + const key = normalizeExcelKey(c); + const rows = idx.rowsByDesignation.get(key) || []; + if (rows.length) { + return { + headerRow: idx.headerRow, + rows, + units: idx.units, + partNumberKey: idx.partNumberKey, + crossSectionKey: idx.crossSectionKey, + ratedVoltageKey: idx.ratedVoltageKey, + }; + } + } + + return null; +} diff --git a/scripts/pdf/model/types.ts b/scripts/pdf/model/types.ts new file mode 100644 index 00000000..aae446cb --- /dev/null +++ b/scripts/pdf/model/types.ts @@ -0,0 +1,53 @@ +export interface ProductData { + id: number; + name: string; + shortDescriptionHtml: string; + descriptionHtml: string; + applicationHtml: 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[]; + }>; + voltageType?: string; +} + +export type KeyValueItem = { label: string; value: string; unit?: string }; + +export type DatasheetVoltageTable = { + voltageLabel: string; + metaItems: KeyValueItem[]; + columns: Array<{ key: string; label: string }>; + rows: Array<{ configuration: string; cells: string[] }>; +}; + +export type DatasheetModel = { + locale: 'en' | 'de'; + product: { + id: number; + name: string; + sku: string; + categoriesLine: string; + descriptionText: string; + heroSrc: string | null; + productUrl: string; + }; + labels: { + datasheet: string; + description: string; + technicalData: string; + crossSection: string; + sku: string; + noImage: string; + }; + technicalItems: KeyValueItem[]; + voltageTables: DatasheetVoltageTable[]; + legendItems: KeyValueItem[]; +}; diff --git a/scripts/pdf/model/utils.ts b/scripts/pdf/model/utils.ts new file mode 100644 index 00000000..d4444a05 --- /dev/null +++ b/scripts/pdf/model/utils.ts @@ -0,0 +1,74 @@ +import * as path from 'path'; + +import type { ProductData } from './types'; + +export const CONFIG = { + siteUrl: 'https://klz-cables.com', + publicDir: path.join(process.cwd(), 'public'), + assetMapFile: path.join(process.cwd(), 'data/processed/asset-map.json'), +} as const; + +export function stripHtml(html: string): string { + if (!html) return ''; + let text = String(html) + .replace(/<[^>]*>/g, '') + .normalize('NFC'); + text = text + .replace(/[\u00A0\u202F]/g, ' ') + .replace(/[\u2013\u2014]/g, '-') + .replace(/[\u2018\u2019]/g, "'") + .replace(/[\u201C\u201D]/g, '"') + .replace(/\u2026/g, '...') + .replace(/[\u2022]/g, '·') + .replace(/[\u2264]/g, '<=') + .replace(/[\u2265]/g, '>=') + .replace(/[\u2248]/g, '~') + .replace(/[\u03A9\u2126]/g, 'Ohm') + .replace(/[\u00B5\u03BC]/g, 'u') + .replace(/[\u2193]/g, 'v') + .replace(/[\u2191]/g, '^') + .replace(/[\u00B0]/g, '°'); + // eslint-disable-next-line no-control-regex + text = text.replace(/[\u0000-\u001F\u007F]/g, ''); + return text.replace(/\s+/g, ' ').trim(); +} + +export function normalizeValue(value: string): string { + return stripHtml(value).replace(/\s+/g, ' ').trim(); +} + +export function getProductUrl(product: ProductData): string { + if (product.path) return `${CONFIG.siteUrl}${product.path}`; + return CONFIG.siteUrl; +} + +export function 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`; +} + +export function getLabels(locale: 'en' | 'de') { + return { + en: { + datasheet: 'Technical Datasheet', + description: 'APPLICATION', + technicalData: 'TECHNICAL DATA', + crossSection: 'Cross-sections/Voltage', + sku: 'SKU', + noImage: 'No image available', + }, + de: { + datasheet: 'Technisches Datenblatt', + description: 'ANWENDUNG', + technicalData: 'TECHNISCHE DATEN', + crossSection: 'Querschnitte/Spannung', + sku: 'ARTIKELNUMMER', + noImage: 'Kein Bild verfügbar', + }, + }[locale]; +} diff --git a/scripts/pdf/react-pdf/DatasheetDocument.tsx b/scripts/pdf/react-pdf/DatasheetDocument.tsx new file mode 100644 index 00000000..47c5aa98 --- /dev/null +++ b/scripts/pdf/react-pdf/DatasheetDocument.tsx @@ -0,0 +1,111 @@ +import * as React from 'react'; +import { Document, Image, Page, Text, View } from '@react-pdf/renderer'; + +import type { DatasheetModel, DatasheetVoltageTable } from '../model/types'; +import { CONFIG } from '../model/utils'; +import { styles } from './styles'; +import { Header } from './components/Header'; +import { Footer } from './components/Footer'; +import { Section } from './components/Section'; +import { KeyValueGrid } from './components/KeyValueGrid'; +import { DenseTable } from './components/DenseTable'; + +type Assets = { + logoDataUrl: string | null; + heroDataUrl: string | null; + qrDataUrl: string | null; +}; + +export function DatasheetDocument(props: { + model: DatasheetModel; + assets: Assets; +}): React.ReactElement { + const { model, assets } = props; + const headerTitle = model.labels.datasheet; + + // Dense tables require compact headers (no wrapping). Use standard abbreviations. + const firstColLabel = model.locale === 'de' ? 'Adern & Querschnitt' : 'Cores & Cross-section'; + + return ( + + + +
+ + + + + {model.product.categoriesLine ? ( + {model.product.categoriesLine} + ) : null} + {model.product.name} + + + + {assets.heroDataUrl ? ( + + ) : ( + {model.labels.noImage} + )} + + + + +