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}
+ )}
+
+
+
+
+
+
+
+ {model.product.descriptionText ? (
+
+ {model.product.descriptionText}
+
+ ) : null}
+
+ {model.technicalItems.length ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+ {model.voltageTables.map((t: DatasheetVoltageTable) => (
+
+ {`${model.labels.crossSection} — ${t.voltageLabel}`}
+
+
+
+ ))}
+
+ {model.legendItems.length ? (
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/scripts/pdf/react-pdf/assets.ts b/scripts/pdf/react-pdf/assets.ts
new file mode 100644
index 00000000..7546008a
--- /dev/null
+++ b/scripts/pdf/react-pdf/assets.ts
@@ -0,0 +1,93 @@
+import * as fs from 'fs';
+import * as path from 'path';
+
+type SharpLike = (
+ input?: unknown,
+ options?: unknown,
+) => { png: () => { toBuffer: () => Promise } };
+
+let sharpFn: SharpLike | null = null;
+async function getSharp(): Promise {
+ if (sharpFn) return sharpFn;
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const mod: any = await import('sharp');
+ sharpFn = (mod?.default || mod) as SharpLike;
+ return sharpFn;
+}
+
+const PUBLIC_DIR = path.join(process.cwd(), 'public');
+
+async function fetchBytes(url: string): Promise {
+ const res = await fetch(url);
+ if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
+ return new Uint8Array(await res.arrayBuffer());
+}
+
+async function readBytesFromPublic(localPath: string): Promise {
+ const abs = path.join(PUBLIC_DIR, localPath.replace(/^\//, ''));
+ return new Uint8Array(fs.readFileSync(abs));
+}
+
+function transformLogoSvgToPrintBlack(svg: string): string {
+ return svg
+ .replace(/fill\s*:\s*white/gi, 'fill:#000000')
+ .replace(/fill\s*=\s*"white"/gi, 'fill="#000000"')
+ .replace(/fill\s*=\s*'white'/gi, "fill='#000000'")
+ .replace(/fill\s*:\s*#[0-9a-fA-F]{6}/gi, 'fill:#000000')
+ .replace(/fill\s*=\s*"#[0-9a-fA-F]{6}"/gi, 'fill="#000000"')
+ .replace(/fill\s*=\s*'#[0-9a-fA-F]{6}'/gi, "fill='#000000'");
+}
+
+async function toPngBytes(inputBytes: Uint8Array, inputHint: string): Promise {
+ const ext = (path.extname(inputHint).toLowerCase() || '').replace('.', '');
+ if (ext === 'png') return inputBytes;
+
+ if (
+ ext === 'svg' &&
+ (/\/media\/logo\.svg$/i.test(inputHint) || /\/logo-blue\.svg$/i.test(inputHint))
+ ) {
+ const svg = Buffer.from(inputBytes).toString('utf8');
+ inputBytes = new Uint8Array(Buffer.from(transformLogoSvgToPrintBlack(svg), 'utf8'));
+ }
+
+ const sharp = await getSharp();
+ return new Uint8Array(await sharp(Buffer.from(inputBytes)).png().toBuffer());
+}
+
+function toDataUrlPng(bytes: Uint8Array): string {
+ return `data:image/png;base64,${Buffer.from(bytes).toString('base64')}`;
+}
+
+export async function loadImageAsPngDataUrl(src: string | null): Promise {
+ if (!src) return null;
+ try {
+ if (src.startsWith('/')) {
+ const bytes = await readBytesFromPublic(src);
+ const png = await toPngBytes(bytes, src);
+ return toDataUrlPng(png);
+ }
+ const bytes = await fetchBytes(src);
+ const png = await toPngBytes(bytes, src);
+ return toDataUrlPng(png);
+ } catch {
+ return null;
+ }
+}
+
+export async function loadQrAsPngDataUrl(data: string): Promise {
+ try {
+ const QRCode = (await import('qrcode')).default || (await import('qrcode'));
+ const buffer = await QRCode.toBuffer(data, { width: 180, margin: 1, type: 'png' });
+ return `data:image/png;base64,${buffer.toString('base64')}`;
+ } catch {
+ try {
+ const safe = encodeURIComponent(data);
+ const url = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${safe}`;
+ const bytes = await fetchBytes(url);
+ const png = await toPngBytes(bytes, url);
+ return toDataUrlPng(png);
+ } catch {
+ return null;
+ }
+ }
+}
diff --git a/scripts/pdf/react-pdf/components/DenseTable.tsx b/scripts/pdf/react-pdf/components/DenseTable.tsx
new file mode 100644
index 00000000..6f31b2d9
--- /dev/null
+++ b/scripts/pdf/react-pdf/components/DenseTable.tsx
@@ -0,0 +1,231 @@
+import * as React from 'react';
+import { Text, View } from '@react-pdf/renderer';
+
+import type { DatasheetVoltageTable } from '../../model/types';
+import { styles } from '../styles';
+
+function clamp(n: number, min: number, max: number): number {
+ return Math.max(min, Math.min(max, n));
+}
+
+function normTextForMeasure(v: unknown): string {
+ return String(v ?? '')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+function textLen(v: unknown): number {
+ return normTextForMeasure(v).length;
+}
+
+function distributeWithMinMax(
+ weights: number[],
+ total: number,
+ minEach: number,
+ maxEach: number,
+): number[] {
+ const n = weights.length;
+ if (!n) return [];
+
+ const mins = Array.from({ length: n }, () => minEach);
+ const maxs = Array.from({ length: n }, () => maxEach);
+
+ // If mins don't fit, scale them down proportionally.
+ const minSum = mins.reduce((a, b) => a + b, 0);
+ if (minSum > total) {
+ const k = total / minSum;
+ return mins.map((m) => m * k);
+ }
+
+ const result = mins.slice();
+ let remaining = total - minSum;
+ let remainingIdx = Array.from({ length: n }, (_, i) => i);
+
+ // Distribute remaining proportionally, respecting max constraints.
+ // Loop is guaranteed to terminate because each iteration either:
+ // - removes at least one index due to hitting max, or
+ // - exhausts `remaining`.
+ while (remaining > 1e-9 && remainingIdx.length) {
+ const wSum = remainingIdx.reduce((acc, i) => acc + Math.max(0, weights[i] || 0), 0);
+ if (wSum <= 1e-9) {
+ // No meaningful weights: distribute evenly.
+ const even = remaining / remainingIdx.length;
+ for (const i of remainingIdx) result[i] += even;
+ remaining = 0;
+ break;
+ }
+
+ const nextIdx: number[] = [];
+ for (const i of remainingIdx) {
+ const w = Math.max(0, weights[i] || 0);
+ const add = (w / wSum) * remaining;
+ const capped = Math.min(result[i] + add, maxs[i]);
+ const used = capped - result[i];
+ result[i] = capped;
+ remaining -= used;
+ if (result[i] + 1e-9 < maxs[i]) nextIdx.push(i);
+ }
+ remainingIdx = nextIdx;
+ }
+
+ // Numerical guard: force exact sum by adjusting the last column.
+ const sum = result.reduce((a, b) => a + b, 0);
+ const drift = total - sum;
+ if (Math.abs(drift) > 1e-9) result[result.length - 1] += drift;
+
+ return result;
+}
+
+export function DenseTable(props: {
+ table: Pick;
+ firstColLabel: string;
+}): React.ReactElement {
+ const cols = props.table.columns;
+ const rows = props.table.rows;
+
+ const headerText = (label: string): string => {
+ // Table headers must NEVER wrap into a second line.
+ // react-pdf can wrap on spaces, so we replace whitespace with NBSP.
+ return String(label || '')
+ .replace(/\s+/g, '\u00A0')
+ .trim();
+ };
+
+ // Column widths: use explicit percentages (no rounding gaps) so the table always
+ // consumes the full content width.
+ // Goal:
+ // - keep the designation column *not too wide*
+ // - distribute data columns by estimated content width (header + cells)
+ // so columns better fit their data
+ // Make first column denser so numeric columns get more room.
+ // (Long designations can still wrap in body if needed, but table scanability
+ // benefits more from wider data columns.)
+ const cfgMin = 0.14;
+ const cfgMax = 0.23;
+
+ // A content-based heuristic.
+ // React-PDF doesn't expose a reliable text-measurement API at render time,
+ // so we approximate width by string length (compressed via sqrt to reduce outliers).
+ const cfgContentLen = Math.max(
+ textLen(props.firstColLabel),
+ ...rows.map((r) => textLen(r.configuration)),
+ 8,
+ );
+ const dataContentLens = cols.map((c, ci) => {
+ const headerL = textLen(c.label);
+ let cellMax = 0;
+ for (const r of rows) cellMax = Math.max(cellMax, textLen(r.cells[ci]));
+ // Slightly prioritize the header (scanability) over a single long cell.
+ return Math.max(headerL * 1.15, cellMax, 3);
+ });
+
+ // Use mostly-linear weights so long headers get noticeably more space.
+ const cfgWeight = cfgContentLen * 1.05;
+ const dataWeights = dataContentLens.map((l) => l);
+ const dataWeightSum = dataWeights.reduce((a, b) => a + b, 0);
+ const rawCfgPct = dataWeightSum > 0 ? cfgWeight / (cfgWeight + dataWeightSum) : 0.28;
+ let cfgPct = clamp(rawCfgPct, cfgMin, cfgMax);
+
+ // Ensure a minimum per-data-column width; if needed, shrink cfgPct.
+ // These floors are intentionally generous. Too-narrow columns are worse than a
+ // slightly narrower first column for scanability.
+ const minDataPct =
+ cols.length >= 14 ? 0.045 : cols.length >= 12 ? 0.05 : cols.length >= 10 ? 0.055 : 0.06;
+ const cfgPctMaxForMinData = 1 - cols.length * minDataPct;
+ if (Number.isFinite(cfgPctMaxForMinData)) cfgPct = Math.min(cfgPct, cfgPctMaxForMinData);
+ cfgPct = clamp(cfgPct, cfgMin, cfgMax);
+
+ const dataTotal = Math.max(0, 1 - cfgPct);
+ const maxDataPct = Math.min(0.24, Math.max(minDataPct * 2.8, dataTotal * 0.55));
+ const dataPcts = distributeWithMinMax(dataWeights, dataTotal, minDataPct, maxDataPct);
+
+ const cfgW = `${(cfgPct * 100).toFixed(4)}%`;
+ const dataWs = dataPcts.map((p, idx) => {
+ // Keep the last column as the remainder so percentages sum to exactly 100%.
+ if (idx === dataPcts.length - 1) {
+ const used = dataPcts.slice(0, -1).reduce((a, b) => a + b, 0);
+ const remainder = Math.max(0, dataTotal - used);
+ return `${(remainder * 100).toFixed(4)}%`;
+ }
+ return `${(p * 100).toFixed(4)}%`;
+ });
+
+ const headerFontSize =
+ cols.length >= 14 ? 5.7 : cols.length >= 12 ? 5.9 : cols.length >= 10 ? 6.2 : 6.6;
+
+ return (
+
+
+
+
+ {headerText(props.firstColLabel)}
+
+
+ {cols.map((c, idx) => {
+ const isLast = idx === cols.length - 1;
+ return (
+
+
+ {headerText(c.label)}
+
+
+ );
+ })}
+
+
+ {rows.map((r, ri) => (
+
+
+
+ {r.configuration}
+
+
+ {r.cells.map((cell, ci) => {
+ const isLast = ci === r.cells.length - 1;
+ return (
+
+
+ {cell}
+
+
+ );
+ })}
+
+ ))}
+
+ );
+}
diff --git a/scripts/pdf/react-pdf/components/Footer.tsx b/scripts/pdf/react-pdf/components/Footer.tsx
new file mode 100644
index 00000000..990b468b
--- /dev/null
+++ b/scripts/pdf/react-pdf/components/Footer.tsx
@@ -0,0 +1,25 @@
+import * as React from 'react';
+import { Text, View } from '@react-pdf/renderer';
+
+import { styles } from '../styles';
+
+export function Footer(props: { locale: 'en' | 'de'; siteUrl?: string }): React.ReactElement {
+ const date = new Date().toLocaleDateString(props.locale === 'en' ? 'en-US' : 'de-DE', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ });
+
+ const siteUrl = props.siteUrl || 'https://klz-cables.com';
+
+ return (
+
+ KLZ CABLES
+ {date}
+ `${pageNumber} / ${totalPages}`}
+ />
+
+ );
+}
diff --git a/scripts/pdf/react-pdf/components/Header.tsx b/scripts/pdf/react-pdf/components/Header.tsx
new file mode 100644
index 00000000..234d1ca1
--- /dev/null
+++ b/scripts/pdf/react-pdf/components/Header.tsx
@@ -0,0 +1,44 @@
+import * as React from 'react';
+import { Image, Text, View } from '@react-pdf/renderer';
+
+import { styles } from '../styles';
+
+export function Header(props: {
+ title: string;
+ logoDataUrl?: string | null;
+ qrDataUrl?: string | null;
+ isHero?: boolean;
+}): React.ReactElement {
+ const { isHero = false } = props;
+
+ return (
+
+
+ {props.logoDataUrl ? (
+
+ ) : (
+ KLZ
+ )}
+
+
+ {props.title}
+ {props.qrDataUrl ? : null}
+
+
+ );
+}
diff --git a/scripts/pdf/react-pdf/components/KeyValueGrid.tsx b/scripts/pdf/react-pdf/components/KeyValueGrid.tsx
new file mode 100644
index 00000000..9164d0f7
--- /dev/null
+++ b/scripts/pdf/react-pdf/components/KeyValueGrid.tsx
@@ -0,0 +1,40 @@
+import * as React from 'react';
+import { Text, View } from '@react-pdf/renderer';
+
+import type { KeyValueItem } from '../../model/types';
+import { styles } from '../styles';
+
+export function KeyValueGrid(props: { items: KeyValueItem[] }): React.ReactElement | null {
+ const items = (props.items || []).filter((i) => i.label && i.value);
+ if (!items.length) return null;
+
+ // 2-column layout: (label, value)
+ return (
+
+ {items.map((item, rowIndex) => {
+ const isLast = rowIndex === items.length - 1;
+ const value = item.unit ? `${item.value} ${item.unit}` : item.value;
+
+ return (
+
+
+ {item.label}
+
+
+ {value}
+
+
+ );
+ })}
+
+ );
+}
diff --git a/scripts/pdf/react-pdf/components/Section.tsx b/scripts/pdf/react-pdf/components/Section.tsx
new file mode 100644
index 00000000..70a8bf2e
--- /dev/null
+++ b/scripts/pdf/react-pdf/components/Section.tsx
@@ -0,0 +1,20 @@
+import * as React from 'react';
+import { Text, View } from '@react-pdf/renderer';
+
+import { styles } from '../styles';
+
+export function Section(props: {
+ title: string;
+ children: React.ReactNode;
+ boxed?: boolean;
+ minPresenceAhead?: number;
+}): React.ReactElement {
+ const boxed = props.boxed ?? true;
+ return (
+
+ {props.title}
+
+ {props.children}
+
+ );
+}
diff --git a/scripts/pdf/react-pdf/generate-datasheet-pdf.tsx b/scripts/pdf/react-pdf/generate-datasheet-pdf.tsx
new file mode 100644
index 00000000..2014d7df
--- /dev/null
+++ b/scripts/pdf/react-pdf/generate-datasheet-pdf.tsx
@@ -0,0 +1,27 @@
+import * as React from 'react';
+import { renderToBuffer } from '@react-pdf/renderer';
+
+import type { ProductData } from '../model/types';
+import { buildDatasheetModel } from '../model/build-datasheet-model';
+import { loadImageAsPngDataUrl, loadQrAsPngDataUrl } from './assets';
+import { DatasheetDocument } from './DatasheetDocument';
+
+export async function generateDatasheetPdfBuffer(args: {
+ product: ProductData;
+ locale: 'en' | 'de';
+}): Promise {
+ const model = buildDatasheetModel({ product: args.product, locale: args.locale });
+
+ const logoDataUrl =
+ (await loadImageAsPngDataUrl('/logo-blue.svg')) ||
+ (await loadImageAsPngDataUrl('/logo-white.svg')) ||
+ null;
+
+ const heroDataUrl = await loadImageAsPngDataUrl(model.product.heroSrc);
+ const qrDataUrl = await loadQrAsPngDataUrl(model.product.productUrl);
+
+ const element = (
+
+ );
+ return await renderToBuffer(element);
+}
diff --git a/scripts/pdf/react-pdf/styles.ts b/scripts/pdf/react-pdf/styles.ts
new file mode 100644
index 00000000..d4a139f0
--- /dev/null
+++ b/scripts/pdf/react-pdf/styles.ts
@@ -0,0 +1,270 @@
+import { Font, StyleSheet } from '@react-pdf/renderer';
+
+// Prevent automatic word hyphenation, which can create multi-line table headers
+// even when we try to keep them single-line.
+Font.registerHyphenationCallback((word) => [word]);
+
+export const COLORS = {
+ primary: '#001a4d',
+ primaryDark: '#000d26',
+ accent: '#82ed20',
+ textPrimary: '#111827',
+ textSecondary: '#4b5563',
+ textLight: '#9ca3af',
+ neutral: '#f8f9fa',
+ border: '#e5e7eb',
+} as const;
+
+export const styles = StyleSheet.create({
+ page: {
+ paddingTop: 0,
+ paddingLeft: 30,
+ paddingRight: 30,
+ paddingBottom: 60,
+ fontFamily: 'Helvetica',
+ fontSize: 10,
+ color: COLORS.textPrimary,
+ backgroundColor: '#FFFFFF',
+ },
+
+ // Hero-style header
+ hero: {
+ backgroundColor: '#FFFFFF',
+ paddingTop: 30,
+ paddingBottom: 0,
+ paddingHorizontal: 0,
+ marginBottom: 20,
+ position: 'relative',
+ borderBottomWidth: 0,
+ borderBottomColor: COLORS.border,
+ },
+ header: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: 24,
+ paddingHorizontal: 0,
+ },
+ headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 },
+ logo: { width: 100, height: 22, objectFit: 'contain' },
+ brandFallback: {
+ fontSize: 20,
+ fontWeight: 700,
+ color: COLORS.primaryDark,
+ letterSpacing: 1,
+ textTransform: 'uppercase',
+ },
+ headerRight: { flexDirection: 'row', alignItems: 'center', gap: 10 },
+ headerTitle: {
+ fontSize: 9,
+ fontWeight: 700,
+ color: COLORS.primary,
+ letterSpacing: 1.5,
+ textTransform: 'uppercase',
+ },
+ qr: { width: 30, height: 30, objectFit: 'contain' },
+
+ productRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 20,
+ },
+ productInfoCol: {
+ flex: 1,
+ justifyContent: 'center',
+ },
+ productImageCol: {
+ flex: 1,
+ height: 120,
+ justifyContent: 'center',
+ alignItems: 'center',
+ borderRadius: 8,
+ borderWidth: 1,
+ borderColor: COLORS.border,
+ backgroundColor: '#FFFFFF',
+ overflow: 'hidden',
+ },
+
+ productHero: {
+ marginTop: 0,
+ paddingHorizontal: 0,
+ },
+ productName: {
+ fontSize: 24,
+ fontWeight: 700,
+ color: COLORS.primaryDark,
+ marginBottom: 0,
+ textTransform: 'uppercase',
+ letterSpacing: -0.5,
+ },
+ productMeta: {
+ fontSize: 9,
+ color: COLORS.textSecondary,
+ fontWeight: 700,
+ textTransform: 'uppercase',
+ letterSpacing: 1,
+ marginBottom: 4,
+ },
+
+ content: {
+ paddingHorizontal: 0,
+ },
+
+ footer: {
+ position: 'absolute',
+ left: 30,
+ right: 30,
+ bottom: 30,
+ paddingTop: 16,
+ borderTopWidth: 1,
+ borderTopColor: COLORS.border,
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ },
+ footerBrand: {
+ fontSize: 9,
+ fontWeight: 700,
+ color: COLORS.primaryDark,
+ textTransform: 'uppercase',
+ letterSpacing: 1,
+ },
+ footerText: {
+ fontSize: 8,
+ color: COLORS.textLight,
+ fontWeight: 500,
+ textTransform: 'uppercase',
+ letterSpacing: 0.5,
+ },
+
+ h1: {
+ fontSize: 22,
+ fontWeight: 700,
+ color: COLORS.primaryDark,
+ marginBottom: 8,
+ textTransform: 'uppercase',
+ },
+ subhead: {
+ fontSize: 10,
+ fontWeight: 700,
+ color: COLORS.textSecondary,
+ marginBottom: 16,
+ textTransform: 'uppercase',
+ letterSpacing: 0.5,
+ },
+
+ heroBox: {
+ height: 180,
+ borderRadius: 12,
+ borderWidth: 1,
+ borderColor: COLORS.border,
+ backgroundColor: '#FFFFFF',
+ marginBottom: 24,
+ justifyContent: 'center',
+ overflow: 'hidden',
+ padding: 0,
+ },
+ heroImage: { width: '100%', height: '100%', objectFit: 'contain' },
+ noImage: { fontSize: 8, color: COLORS.textLight, textAlign: 'center' },
+
+ section: {
+ marginBottom: 10,
+ },
+ sectionTitle: {
+ fontSize: 14,
+ fontWeight: 700,
+ color: COLORS.primaryDark,
+ marginBottom: 8,
+ textTransform: 'uppercase',
+ letterSpacing: -0.2,
+ },
+ sectionAccent: {
+ width: 30,
+ height: 3,
+ backgroundColor: COLORS.accent,
+ marginBottom: 8,
+ borderRadius: 1.5,
+ },
+ body: { fontSize: 10, lineHeight: 1.6, color: COLORS.textSecondary },
+
+ kvGrid: {
+ width: '100%',
+ borderWidth: 1,
+ borderColor: COLORS.border,
+ borderRadius: 8,
+ overflow: 'hidden',
+ },
+ kvRow: {
+ flexDirection: 'row',
+ borderBottomWidth: 1,
+ borderBottomColor: COLORS.border,
+ },
+ kvRowAlt: { backgroundColor: COLORS.neutral },
+ kvRowLast: { borderBottomWidth: 0 },
+ kvCell: { paddingVertical: 3, paddingHorizontal: 12 },
+ kvMidDivider: {
+ borderRightWidth: 1,
+ borderRightColor: COLORS.border,
+ },
+ kvLabelText: {
+ fontSize: 8,
+ fontWeight: 700,
+ color: COLORS.primaryDark,
+ textTransform: 'uppercase',
+ letterSpacing: 0.3,
+ },
+ kvValueText: { fontSize: 9, color: COLORS.textPrimary, fontWeight: 500 },
+
+ tableWrap: {
+ width: '100%',
+ borderWidth: 1,
+ borderColor: COLORS.border,
+ borderRadius: 8,
+ overflow: 'hidden',
+ marginBottom: 16,
+ },
+ tableHeader: {
+ width: '100%',
+ flexDirection: 'row',
+ backgroundColor: COLORS.neutral,
+ borderBottomWidth: 1,
+ borderBottomColor: COLORS.border,
+ },
+ tableHeaderCell: {
+ paddingVertical: 8,
+ paddingHorizontal: 6,
+ fontSize: 7,
+ fontWeight: 700,
+ color: COLORS.primaryDark,
+ textTransform: 'uppercase',
+ letterSpacing: 0.2,
+ },
+ tableHeaderCellCfg: {
+ paddingHorizontal: 8,
+ },
+ tableHeaderCellDivider: {
+ borderRightWidth: 1,
+ borderRightColor: COLORS.border,
+ },
+ tableRow: {
+ width: '100%',
+ flexDirection: 'row',
+ borderBottomWidth: 1,
+ borderBottomColor: COLORS.border,
+ },
+ tableRowAlt: { backgroundColor: '#FFFFFF' },
+ tableCell: {
+ paddingVertical: 6,
+ paddingHorizontal: 6,
+ fontSize: 7,
+ color: COLORS.textSecondary,
+ fontWeight: 500,
+ },
+ tableCellCfg: {
+ paddingHorizontal: 8,
+ },
+ tableCellDivider: {
+ borderRightWidth: 1,
+ borderRightColor: COLORS.border,
+ },
+});