diff --git a/eslint.config.js b/eslint.config.js index b83d2252..8f07a1cb 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,6 +14,9 @@ export default [ 'data/**', // Scripts + config files are not part of the frontend lint target. 'scripts/**', + // …except the PDF generator which is maintained runtime code. + '!scripts/pdf/**', + '!scripts/generate-pdf-datasheets.ts', 'next.config.*', 'postcss.config.js', 'tailwind.config.*', @@ -21,6 +24,47 @@ export default [ ], }, js.configs.recommended, + { + // Allow linting the React-PDF generator despite global `scripts/**` ignore. + // These files are runtime code (not throwaway scripts) and should stay clean. + files: ['scripts/pdf/**/*.{ts,tsx}', 'scripts/generate-pdf-datasheets.ts'], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + ecmaFeatures: { jsx: true }, + }, + globals: { + console: 'readonly', + process: 'readonly', + Buffer: 'readonly', + __dirname: 'readonly', + __filename: 'readonly', + require: 'readonly', + module: 'readonly', + fetch: 'readonly', + React: 'readonly', + JSX: 'readonly', + }, + }, + plugins: { + '@typescript-eslint': tsPlugin, + react, + }, + rules: { + ...tsPlugin.configs.recommended.rules, + ...react.configs.recommended.rules, + 'no-undef': 'error', + 'react/react-in-jsx-scope': 'off', + 'react/prop-types': 'off', + '@typescript-eslint/no-unused-vars': 'warn', + '@typescript-eslint/no-explicit-any': 'warn', + }, + settings: { + react: { version: 'detect' }, + }, + }, { // Only lint the actual source files, not build output or scripts files: ['app/**/*.{ts,tsx}', 'components/**/*.{ts,tsx}', 'lib/**/*.{ts,tsx}'], diff --git a/package-lock.json b/package-lock.json index f97a5f59..1e09118f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "postcss": "^8.5.6", "sass": "^1.97.1", "tailwindcss": "^4.1.18", + "tsx": "^4.21.0", "typescript": "^5.7.2", "vitest": "^4.0.16" } @@ -9540,6 +9541,26 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 3ba54a07..9d6e3ce1 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,10 @@ "build": "next build", "start": "next start", "export": "next export", + "lint": "eslint .", + "lint:pdf": "eslint \"scripts/pdf/**/*.{ts,tsx}\" scripts/generate-pdf-datasheets.ts --no-ignore --max-warnings=0", + "pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.ts", + "pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts", "data:export": "node scripts/wordpress-export.js", "data:process": "node scripts/process-data.js", "data:analyze": "node scripts/analyze-export.js", @@ -51,6 +55,7 @@ "postcss": "^8.5.6", "sass": "^1.97.1", "tailwindcss": "^4.1.18", + "tsx": "^4.21.0", "typescript": "^5.7.2", "vitest": "^4.0.16" } diff --git a/public/datasheets/h1z2z2-k-de.pdf b/public/datasheets/h1z2z2-k-de.pdf index e1bac0f9..1a144567 100644 Binary files a/public/datasheets/h1z2z2-k-de.pdf and b/public/datasheets/h1z2z2-k-de.pdf differ diff --git a/public/datasheets/h1z2z2-k-en.pdf b/public/datasheets/h1z2z2-k-en.pdf index 22397a91..cf2d47cc 100644 Binary files a/public/datasheets/h1z2z2-k-en.pdf and b/public/datasheets/h1z2z2-k-en.pdf differ diff --git a/public/datasheets/n2x2y-2-de.pdf b/public/datasheets/n2x2y-2-de.pdf index 8ea4e8b9..f1eed8e8 100644 Binary files a/public/datasheets/n2x2y-2-de.pdf and b/public/datasheets/n2x2y-2-de.pdf differ diff --git a/public/datasheets/n2x2y-en.pdf b/public/datasheets/n2x2y-en.pdf index 4ab2d02f..87a23f27 100644 Binary files a/public/datasheets/n2x2y-en.pdf and b/public/datasheets/n2x2y-en.pdf differ diff --git a/public/datasheets/n2xfk2y-de.pdf b/public/datasheets/n2xfk2y-de.pdf index 9edeb1c8..a4c4e38d 100644 Binary files a/public/datasheets/n2xfk2y-de.pdf and b/public/datasheets/n2xfk2y-de.pdf differ diff --git a/public/datasheets/n2xfk2y-en.pdf b/public/datasheets/n2xfk2y-en.pdf index cb977e53..cf746489 100644 Binary files a/public/datasheets/n2xfk2y-en.pdf and b/public/datasheets/n2xfk2y-en.pdf differ diff --git a/public/datasheets/n2xfkld2y-de.pdf b/public/datasheets/n2xfkld2y-de.pdf index 26ae6c38..a1cf72b1 100644 Binary files a/public/datasheets/n2xfkld2y-de.pdf and b/public/datasheets/n2xfkld2y-de.pdf differ diff --git a/public/datasheets/n2xfkld2y-en.pdf b/public/datasheets/n2xfkld2y-en.pdf index b3519a82..2e141f1a 100644 Binary files a/public/datasheets/n2xfkld2y-en.pdf and b/public/datasheets/n2xfkld2y-en.pdf differ diff --git a/public/datasheets/n2xs2y-2-de.pdf b/public/datasheets/n2xs2y-2-de.pdf index b725144d..fb4cc23a 100644 Binary files a/public/datasheets/n2xs2y-2-de.pdf and b/public/datasheets/n2xs2y-2-de.pdf differ diff --git a/public/datasheets/n2xs2y-en.pdf b/public/datasheets/n2xs2y-en.pdf index 8be303bd..a55a6c4f 100644 Binary files a/public/datasheets/n2xs2y-en.pdf and b/public/datasheets/n2xs2y-en.pdf differ diff --git a/public/datasheets/n2xsf2y-2-de.pdf b/public/datasheets/n2xsf2y-2-de.pdf index bc6db37a..d1567b48 100644 Binary files a/public/datasheets/n2xsf2y-2-de.pdf and b/public/datasheets/n2xsf2y-2-de.pdf differ diff --git a/public/datasheets/n2xsf2y-en.pdf b/public/datasheets/n2xsf2y-en.pdf index 4e17c52b..b883781a 100644 Binary files a/public/datasheets/n2xsf2y-en.pdf and b/public/datasheets/n2xsf2y-en.pdf differ diff --git a/public/datasheets/n2xsfl2y-2-de.pdf b/public/datasheets/n2xsfl2y-2-de.pdf index fbf4aaec..b04a7b31 100644 Binary files a/public/datasheets/n2xsfl2y-2-de.pdf and b/public/datasheets/n2xsfl2y-2-de.pdf differ diff --git a/public/datasheets/n2xsfl2y-3-en.pdf b/public/datasheets/n2xsfl2y-3-en.pdf index 9aec4a8b..937c95bc 100644 Binary files a/public/datasheets/n2xsfl2y-3-en.pdf and b/public/datasheets/n2xsfl2y-3-en.pdf differ diff --git a/public/datasheets/n2xsfl2y-de.pdf b/public/datasheets/n2xsfl2y-de.pdf index 612463aa..c7ce0cce 100644 Binary files a/public/datasheets/n2xsfl2y-de.pdf and b/public/datasheets/n2xsfl2y-de.pdf differ diff --git a/public/datasheets/n2xsfl2y-en.pdf b/public/datasheets/n2xsfl2y-en.pdf index e4df5099..088dc213 100644 Binary files a/public/datasheets/n2xsfl2y-en.pdf and b/public/datasheets/n2xsfl2y-en.pdf differ diff --git a/public/datasheets/n2xsy-2-de.pdf b/public/datasheets/n2xsy-2-de.pdf index 0233ecc2..c2ceb5df 100644 Binary files a/public/datasheets/n2xsy-2-de.pdf and b/public/datasheets/n2xsy-2-de.pdf differ diff --git a/public/datasheets/n2xsy-en.pdf b/public/datasheets/n2xsy-en.pdf index 71a24e54..15c3fe0b 100644 Binary files a/public/datasheets/n2xsy-en.pdf and b/public/datasheets/n2xsy-en.pdf differ diff --git a/public/datasheets/n2xy-2-de.pdf b/public/datasheets/n2xy-2-de.pdf index 2239be9f..562fdcb6 100644 Binary files a/public/datasheets/n2xy-2-de.pdf and b/public/datasheets/n2xy-2-de.pdf differ diff --git a/public/datasheets/n2xy-en.pdf b/public/datasheets/n2xy-en.pdf index 8eaa7e7a..00d81f25 100644 Binary files a/public/datasheets/n2xy-en.pdf and b/public/datasheets/n2xy-en.pdf differ diff --git a/public/datasheets/na2x2y-2-de.pdf b/public/datasheets/na2x2y-2-de.pdf index c966f133..56b30c6b 100644 Binary files a/public/datasheets/na2x2y-2-de.pdf and b/public/datasheets/na2x2y-2-de.pdf differ diff --git a/public/datasheets/na2x2y-en.pdf b/public/datasheets/na2x2y-en.pdf index f7a94fb3..7659c7ae 100644 Binary files a/public/datasheets/na2x2y-en.pdf and b/public/datasheets/na2x2y-en.pdf differ diff --git a/public/datasheets/na2xfk2y-de.pdf b/public/datasheets/na2xfk2y-de.pdf index 30b61479..99786ee2 100644 Binary files a/public/datasheets/na2xfk2y-de.pdf and b/public/datasheets/na2xfk2y-de.pdf differ diff --git a/public/datasheets/na2xfk2y-en.pdf b/public/datasheets/na2xfk2y-en.pdf index ded17263..d67f9392 100644 Binary files a/public/datasheets/na2xfk2y-en.pdf and b/public/datasheets/na2xfk2y-en.pdf differ diff --git a/public/datasheets/na2xfkld2y-de.pdf b/public/datasheets/na2xfkld2y-de.pdf index fef36349..7d3768df 100644 Binary files a/public/datasheets/na2xfkld2y-de.pdf and b/public/datasheets/na2xfkld2y-de.pdf differ diff --git a/public/datasheets/na2xfkld2y-en.pdf b/public/datasheets/na2xfkld2y-en.pdf index c1dbfda2..2a6e05f4 100644 Binary files a/public/datasheets/na2xfkld2y-en.pdf and b/public/datasheets/na2xfkld2y-en.pdf differ diff --git a/public/datasheets/na2xs2y-2-de.pdf b/public/datasheets/na2xs2y-2-de.pdf index 2c78d3d5..55851b34 100644 Binary files a/public/datasheets/na2xs2y-2-de.pdf and b/public/datasheets/na2xs2y-2-de.pdf differ diff --git a/public/datasheets/na2xs2y-en.pdf b/public/datasheets/na2xs2y-en.pdf index c45416d5..9baf088d 100644 Binary files a/public/datasheets/na2xs2y-en.pdf and b/public/datasheets/na2xs2y-en.pdf differ diff --git a/public/datasheets/na2xsf2y-2-de.pdf b/public/datasheets/na2xsf2y-2-de.pdf index b5229d7e..d30a1375 100644 Binary files a/public/datasheets/na2xsf2y-2-de.pdf and b/public/datasheets/na2xsf2y-2-de.pdf differ diff --git a/public/datasheets/na2xsf2y-en.pdf b/public/datasheets/na2xsf2y-en.pdf index 3a65e03e..7dd37bac 100644 Binary files a/public/datasheets/na2xsf2y-en.pdf and b/public/datasheets/na2xsf2y-en.pdf differ diff --git a/public/datasheets/na2xsfl2y-2-de.pdf b/public/datasheets/na2xsfl2y-2-de.pdf index 0ec5cca3..aaf96df5 100644 Binary files a/public/datasheets/na2xsfl2y-2-de.pdf and b/public/datasheets/na2xsfl2y-2-de.pdf differ diff --git a/public/datasheets/na2xsfl2y-3-en.pdf b/public/datasheets/na2xsfl2y-3-en.pdf index 30b4e5b8..1c3aa8eb 100644 Binary files a/public/datasheets/na2xsfl2y-3-en.pdf and b/public/datasheets/na2xsfl2y-3-en.pdf differ diff --git a/public/datasheets/na2xsfl2y-de.pdf b/public/datasheets/na2xsfl2y-de.pdf index 70bf6225..697caa53 100644 Binary files a/public/datasheets/na2xsfl2y-de.pdf and b/public/datasheets/na2xsfl2y-de.pdf differ diff --git a/public/datasheets/na2xsfl2y-en.pdf b/public/datasheets/na2xsfl2y-en.pdf index dae3f291..9fcdd55f 100644 Binary files a/public/datasheets/na2xsfl2y-en.pdf and b/public/datasheets/na2xsfl2y-en.pdf differ diff --git a/public/datasheets/na2xsy-2-de.pdf b/public/datasheets/na2xsy-2-de.pdf index 9c621e50..32e14bfd 100644 Binary files a/public/datasheets/na2xsy-2-de.pdf and b/public/datasheets/na2xsy-2-de.pdf differ diff --git a/public/datasheets/na2xsy-en.pdf b/public/datasheets/na2xsy-en.pdf index a6b91faf..565cb36e 100644 Binary files a/public/datasheets/na2xsy-en.pdf and b/public/datasheets/na2xsy-en.pdf differ diff --git a/public/datasheets/na2xy-2-de.pdf b/public/datasheets/na2xy-2-de.pdf index 5a0e2686..7c6114e9 100644 Binary files a/public/datasheets/na2xy-2-de.pdf and b/public/datasheets/na2xy-2-de.pdf differ diff --git a/public/datasheets/na2xy-en.pdf b/public/datasheets/na2xy-en.pdf index 17d45dd1..1efb71fc 100644 Binary files a/public/datasheets/na2xy-en.pdf and b/public/datasheets/na2xy-en.pdf differ diff --git a/public/datasheets/nay2y-2-de.pdf b/public/datasheets/nay2y-2-de.pdf index 8a3221c9..cf3d9fa8 100644 Binary files a/public/datasheets/nay2y-2-de.pdf and b/public/datasheets/nay2y-2-de.pdf differ diff --git a/public/datasheets/nay2y-en.pdf b/public/datasheets/nay2y-en.pdf index 12e0897d..1aa300ec 100644 Binary files a/public/datasheets/nay2y-en.pdf and b/public/datasheets/nay2y-en.pdf differ diff --git a/public/datasheets/naycwy-2-de.pdf b/public/datasheets/naycwy-2-de.pdf index 73d43fa2..83f97239 100644 Binary files a/public/datasheets/naycwy-2-de.pdf and b/public/datasheets/naycwy-2-de.pdf differ diff --git a/public/datasheets/naycwy-en.pdf b/public/datasheets/naycwy-en.pdf index 09f48573..62db1a47 100644 Binary files a/public/datasheets/naycwy-en.pdf and b/public/datasheets/naycwy-en.pdf differ diff --git a/public/datasheets/nayy-2-de.pdf b/public/datasheets/nayy-2-de.pdf index 5fdc27e1..22382737 100644 Binary files a/public/datasheets/nayy-2-de.pdf and b/public/datasheets/nayy-2-de.pdf differ diff --git a/public/datasheets/nayy-en.pdf b/public/datasheets/nayy-en.pdf index 0fdfbd88..5f37cb79 100644 Binary files a/public/datasheets/nayy-en.pdf and b/public/datasheets/nayy-en.pdf differ diff --git a/public/datasheets/ny2y-2-de.pdf b/public/datasheets/ny2y-2-de.pdf index 65ce9362..f73a5627 100644 Binary files a/public/datasheets/ny2y-2-de.pdf and b/public/datasheets/ny2y-2-de.pdf differ diff --git a/public/datasheets/ny2y-en.pdf b/public/datasheets/ny2y-en.pdf index e6154ee7..61e4fdf4 100644 Binary files a/public/datasheets/ny2y-en.pdf and b/public/datasheets/ny2y-en.pdf differ diff --git a/public/datasheets/nycwy-2-de.pdf b/public/datasheets/nycwy-2-de.pdf index ec215f68..04220cdb 100644 Binary files a/public/datasheets/nycwy-2-de.pdf and b/public/datasheets/nycwy-2-de.pdf differ diff --git a/public/datasheets/nycwy-en.pdf b/public/datasheets/nycwy-en.pdf index d73d4e20..22f00853 100644 Binary files a/public/datasheets/nycwy-en.pdf and b/public/datasheets/nycwy-en.pdf differ diff --git a/public/datasheets/nyy-2-de.pdf b/public/datasheets/nyy-2-de.pdf index 22baac9e..8c1dea5f 100644 Binary files a/public/datasheets/nyy-2-de.pdf and b/public/datasheets/nyy-2-de.pdf differ diff --git a/public/datasheets/nyy-en.pdf b/public/datasheets/nyy-en.pdf index 9d15bfb3..55bda7d9 100644 Binary files a/public/datasheets/nyy-en.pdf and b/public/datasheets/nyy-en.pdf differ diff --git a/scripts/generate-pdf-datasheets-pdf-lib.ts b/scripts/generate-pdf-datasheets-pdf-lib.ts new file mode 100644 index 00000000..08ecd7c5 --- /dev/null +++ b/scripts/generate-pdf-datasheets-pdf-lib.ts @@ -0,0 +1,4106 @@ +#!/usr/bin/env ts-node +/** + * PDF Datasheet Generator - Industrial Engineering Documentation Style + * STYLEGUIDE.md compliant: industrial, technical, restrained + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { execSync } from 'child_process'; +import { PDFDocument, rgb, StandardFonts, PDFFont, PDFPage, PDFImage } from 'pdf-lib'; + +let sharpFn: ((input?: any, options?: any) => any) | null = null; +async function getSharp(): Promise<(input?: any, options?: any) => any> { + if (sharpFn) return sharpFn; + // `sharp` is CJS but this script runs as ESM via ts-node. + // Dynamic import gives stable interop. + const mod: any = await import('sharp'); + sharpFn = (mod?.default || mod) as (input?: any, options?: any) => any; + return sharpFn; +} + +const CONFIG = { + productsFile: path.join(process.cwd(), 'data/processed/products.json'), + outputDir: path.join(process.cwd(), 'public/datasheets'), + chunkSize: 10, + siteUrl: 'https://klz-cables.com', +}; + +const ASSET_MAP_FILE = path.join(process.cwd(), 'data/processed/asset-map.json'); +const PUBLIC_DIR = path.join(process.cwd(), 'public'); + +const EXCEL_SOURCE_FILES = [ + path.join(process.cwd(), 'data/source/high-voltage.xlsx'), + path.join(process.cwd(), 'data/source/medium-voltage-KM.xlsx'), + path.join(process.cwd(), 'data/source/low-voltage-KM.xlsx'), + path.join(process.cwd(), 'data/source/solar-cables.xlsx'), +]; + +type AssetMap = Record; + +function readAssetMap(): AssetMap { + try { + if (!fs.existsSync(ASSET_MAP_FILE)) return {}; + return JSON.parse(fs.readFileSync(ASSET_MAP_FILE, 'utf8')) as AssetMap; + } catch { + return {}; + } +} + +const ASSET_MAP: AssetMap = readAssetMap(); + +interface ProductData { + id: number; + name: string; + shortDescriptionHtml: string; + descriptionHtml: string; + images: string[]; + featuredImage: string | null; + sku: string; + slug?: string; + path?: string; + translationKey?: string; + locale?: 'en' | 'de'; + categories: Array<{ name: string }>; + attributes: Array<{ + name: string; + options: string[]; + }>; +} + +type ExcelRow = Record; +type ExcelMatch = { rows: ExcelRow[]; units: Record }; +let EXCEL_INDEX: Map | null = null; + +type KeyValueItem = { label: string; value: string; unit?: string }; +type VoltageTableModel = { + voltageLabel: string; + metaItems: KeyValueItem[]; + crossSections: string[]; + columns: Array<{ key: string; label: string; get: (rowIndex: number) => string }>; +}; + +function estimateDenseMetaGridHeight(itemsCount: number): number { + // Must stay in sync with the layout constants in `drawDenseMetaGrid()`. + const cols = itemsCount >= 7 ? 3 : 2; + const cellH = 34; + const titleH = 18; + const headerPadY = 10; + const rows = Math.ceil(Math.max(0, itemsCount) / cols); + const boxH = headerPadY + titleH + rows * cellH + headerPadY; + // `drawDenseMetaGrid()` returns the cursor below the box with additional spacing. + return boxH + 18; +} + +function normalizeUnit(unitRaw: string): string { + const u = normalizeValue(unitRaw); + if (!u) return ''; + // Temperature units: show °C (not plain C). + if (/^c$/i.test(u) || /^°c$/i.test(u)) return '°C'; + // Common WinAnsi-safe normalizations. + return u + .replace(/Ω/gi, 'Ohm') + .replace(/[\u00B5\u03BC]/g, 'u'); +} + +function denseAbbrevLabel(args: { + key: string; + locale: 'en' | 'de'; + unit?: string; + withUnit?: boolean; +}): string { + const u = normalizeUnit(args.unit || ''); + const withUnit = args.withUnit ?? true; + const unitSafe = u + .replace(/Ω/gi, 'Ohm') + .replace(/[\u00B5\u03BC]/g, 'u'); + const suffix = withUnit && unitSafe ? ` [${unitSafe}]` : ''; + + switch (args.key) { + case 'DI': + case 'RI': + case 'Wi': + case 'Ibl': + case 'Ibe': + case 'Wm': + case 'Rbv': + case 'Fzv': + case 'G': + return `${args.key}${suffix}`; + case 'Ik_cond': + return `Ik${suffix}`; + case 'Ik_screen': + return `Ik_s${suffix}`; + case 'Ø': + return `Ø${suffix}`; + case 'Cond': + return args.locale === 'de' ? 'Leiter' : 'Cond.'; + case 'shape': + return args.locale === 'de' ? 'Form' : 'Shape'; + // Electrical + case 'cap': + return `C${suffix}`; + case 'X': + return `X${suffix}`; + // Temperatures + case 'temp_range': + return `T${suffix}`; + case 'max_op_temp': + return `T_op${suffix}`; + case 'max_sc_temp': + return `T_sc${suffix}`; + case 'min_store_temp': + return `T_st${suffix}`; + case 'min_lay_temp': + return `T_lay${suffix}`; + // Compliance + case 'cpr': + return `CPR${suffix}`; + case 'flame': + return `FR${suffix}`; + // Voltages + case 'test_volt': + return `U_test${suffix}`; + case 'rated_volt': + return `U0/U${suffix}`; + default: + return args.key || ''; + } +} + +function metaFullLabel(args: { key: string; excelKey: string; locale: 'en' | 'de' }): string { + const key = normalizeValue(args.key); + if (args.locale === 'de') { + switch (key) { + case 'test_volt': + return 'Prüfspannung'; + case 'temp_range': + return 'Temperaturbereich'; + case 'max_op_temp': + return 'Leitertemperatur (max.)'; + case 'max_sc_temp': + return 'Kurzschlusstemperatur (max.)'; + case 'min_lay_temp': + return 'Minimale Verlegetemperatur'; + case 'min_store_temp': + return 'Minimale Lagertemperatur'; + case 'cpr': + return 'CPR-Klasse'; + case 'flame': + return 'Flammhemmend'; + default: + return formatExcelHeaderLabel(args.excelKey); + } + } + + // EN + switch (key) { + case 'test_volt': + return 'Test voltage'; + case 'temp_range': + return 'Operating temperature range'; + case 'max_op_temp': + return 'Conductor temperature (max.)'; + case 'max_sc_temp': + return 'Short-circuit temperature (max.)'; + case 'min_lay_temp': + return 'Minimum laying temperature'; + case 'min_store_temp': + return 'Minimum storage temperature'; + case 'cpr': + return 'CPR class'; + case 'flame': + return 'Flame retardant'; + default: + return formatExcelHeaderLabel(args.excelKey); + } +} + +function expandMetaLabel(label: string, locale: 'en' | 'de'): string { + const l = normalizeValue(label); + if (!l) return ''; + + // Safety net: the voltage-group meta grid must never show abbreviated labels. + // (Even if upstream mapping changes, we keep customer-facing readability.) + const mapDe: Record = { + U_test: 'Prüfspannung', + 'U0/U': 'Nennspannung', + 'U_0/U': 'Nennspannung', + T: 'Temperaturbereich', + T_op: 'Leitertemperatur (max.)', + T_sc: 'Kurzschlusstemperatur (max.)', + T_lay: 'Minimale Verlegetemperatur', + T_st: 'Minimale Lagertemperatur', + CPR: 'CPR-Klasse', + FR: 'Flammhemmend', + }; + + const mapEn: Record = { + U_test: 'Test voltage', + 'U0/U': 'Rated voltage', + 'U_0/U': 'Rated voltage', + T: 'Operating temperature range', + T_op: 'Conductor temperature (max.)', + T_sc: 'Short-circuit temperature (max.)', + T_lay: 'Minimum laying temperature', + T_st: 'Minimum storage temperature', + CPR: 'CPR class', + FR: 'Flame retardant', + }; + + const mapped = (locale === 'de' ? mapDe[l] : mapEn[l]) || ''; + return mapped || label; +} + +function technicalFullLabel(args: { key: string; excelKey: string; locale: 'en' | 'de' }): string { + const k = normalizeValue(args.key); + + if (args.locale === 'de') { + // Prefer stable internal keys (from columnMapping.key) to translate Technical Data labels. + switch (k) { + case 'DI': + return 'Durchmesser über Isolierung'; + case 'RI': + return 'DC-Leiterwiderstand (20 °C)'; + case 'Wi': + return 'Isolationsdicke'; + case 'Ibl': + return 'Strombelastbarkeit in Luft (trefoil)'; + case 'Ibe': + return 'Strombelastbarkeit im Erdreich (trefoil)'; + case 'Ik_cond': + return 'Kurzschlussstrom Leiter'; + case 'Ik_screen': + return 'Kurzschlussstrom Schirm'; + case 'Wm': + return 'Manteldicke'; + case 'Rbv': + return 'Biegeradius (min.)'; + case 'Ø': + return 'Außen-Ø'; + case 'Fzv': + return 'Zugkraft (max.)'; + case 'G': + return 'Gewicht'; + case 'Cond': + case 'conductor': + return 'Leiter'; + case 'shape': + return 'Leiterform'; + case 'insulation': + return 'Isolierung'; + case 'sheath': + return 'Mantel'; + case 'cap': + return 'Kapazität'; + case 'ind_trefoil': + return 'Induktivität (trefoil)'; + case 'ind_air_flat': + return 'Induktivität (Luft, flach)'; + case 'ind_ground_flat': + return 'Induktivität (Erdreich, flach)'; + case 'X': + return 'Reaktanz'; + case 'test_volt': + return 'Prüfspannung'; + case 'rated_volt': + return 'Nennspannung'; + case 'temp_range': + return 'Temperaturbereich'; + case 'max_op_temp': + return 'Leitertemperatur (max.)'; + case 'max_sc_temp': + return 'Kurzschlusstemperatur (max.)'; + case 'min_store_temp': + return 'Minimale Lagertemperatur'; + case 'min_lay_temp': + return 'Minimale Verlegetemperatur'; + case 'cpr': + return 'CPR-Klasse'; + case 'flame': + return 'Flammhemmend'; + case 'packaging': + return 'Verpackung'; + case 'ce': + return 'CE-Konformität'; + case 'norm': + return 'Norm'; + case 'standard': + return 'Standard'; + case 'D_screen': + return 'Durchmesser über Schirm'; + case 'S_screen': + return 'Metallischer Schirm'; + default: + break; + } + + // Fallback: best-effort translation from the raw Excel header (prevents English in DE PDFs). + const raw = normalizeValue(args.excelKey); + if (!raw) return ''; + return raw + .replace(/\(approx\.?\)/gi, '(ca.)') + .replace(/\bcapacitance\b/gi, 'Kapazität') + .replace(/\binductance\b/gi, 'Induktivität') + .replace(/\breactance\b/gi, 'Reaktanz') + .replace(/\btest voltage\b/gi, 'Prüfspannung') + .replace(/\brated voltage\b/gi, 'Nennspannung') + .replace(/\boperating temperature range\b/gi, 'Temperaturbereich') + .replace(/\bminimum sheath thickness\b/gi, 'Manteldicke (min.)') + .replace(/\bsheath thickness\b/gi, 'Manteldicke') + .replace(/\bnominal insulation thickness\b/gi, 'Isolationsdicke (nom.)') + .replace(/\binsulation thickness\b/gi, 'Isolationsdicke') + .replace(/\bdc resistance at 20\s*°?c\b/gi, 'DC-Leiterwiderstand (20 °C)') + .replace(/\bouter diameter(?: of cable)?\b/gi, 'Außen-Ø') + .replace(/\bbending radius\b/gi, 'Biegeradius') + .replace(/\bpackaging\b/gi, 'Verpackung') + .replace(/\bce\s*-?conformity\b/gi, 'CE-Konformität'); + } + + // EN: keep as-is (Excel headers are already English). + return normalizeValue(args.excelKey); +} + +function compactNumericForLocale(value: string, locale: 'en' | 'de'): string { + const v = normalizeValue(value); + if (!v) return ''; + + // Handle special text patterns like "15xD (Single core); 12xD (Multi core)" + // Compact to "15/12xD" or "10xD" for single values + if (/\d+xD/.test(v)) { + // Extract all number+xD patterns + const numbers = []; + const matches = Array.from(v.matchAll(/(\d+)xD/g)); + for (let i = 0; i < matches.length; i++) numbers.push(matches[i][1]); + if (numbers.length > 0) { + // Remove duplicates while preserving order + const unique: string[] = []; + for (const num of numbers) { + if (!unique.includes(num)) { + unique.push(num); + } + } + return unique.join('/') + 'xD'; + } + } + + // Normalize decimals for the target locale if it looks numeric-ish. + const hasDigit = /\d/.test(v); + if (!hasDigit) return v; + const trimmed = v.replace(/\s+/g, ' ').trim(); + // Keep ranges like "1.2–3.4" or "1.2-3.4". + const parts = trimmed.split(/(–|-)/); + const out = parts.map(p => { + if (p === '–' || p === '-') return p; + const s = p.trim(); + if (!/^-?\d+(?:[\.,]\d+)?$/.test(s)) return p; + const n = s.replace(/,/g, '.'); + + // Datasheets: do NOT use k/M suffixes (can be misleading for engineering values). + // Only trim trailing zeros for compactness. + const compact = n + .replace(/\.0+$/, '') + .replace(/(\.\d*?)0+$/, '$1') + .replace(/\.$/, ''); + + // Preserve leading "+" when present (typical for temperature cells like "+90"). + const hadPlus = /^\+/.test(s); + const withPlus = hadPlus && !/^\+/.test(compact) ? `+${compact}` : compact; + return locale === 'de' ? withPlus.replace(/\./g, ',') : withPlus; + }); + return out.join(''); +} + +function compactCellForDenseTable(value: string, unit: string | undefined, locale: 'en' | 'de'): string { + let v = normalizeValue(value); + if (!v) return ''; + const u = normalizeValue(unit || ''); + if (u) { + // Remove unit occurrences from the cell value (unit is already in the header). + const esc = u.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + v = v.replace(new RegExp(`\\s*${esc}\\b`, 'ig'), '').trim(); + // Common composite units appear in the data cells too; strip them. + v = v + .replace(/\bkg\s*\/\s*km\b/gi, '') + .replace(/\bohm\s*\/\s*km\b/gi, '') + .replace(/\bΩ\s*\/\s*km\b/gi, '') + .replace(/\bu\s*f\s*\/\s*km\b/gi, '') + .replace(/\bmh\s*\/\s*km\b/gi, '') + .replace(/\bkA\b/gi, '') + .replace(/\bmm\b/gi, '') + .replace(/\bkv\b/gi, '') + .replace(/\b°?c\b/gi, '') + .replace(/\s+/g, ' ') + .trim(); + } + + // Normalize common separators to compact but readable tokens. + // Example: "-35 - +90" -> "-35-+90", "-40°C / +90°C" -> "-40/+90". + v = v + .replace(/\s*–\s*/g, '-') + .replace(/\s*-\s*/g, '-') + .replace(/\s*\/\s*/g, '/') + .replace(/\s+/g, ' ') + .trim(); + + return compactNumericForLocale(v, locale); +} + +function normalizeVoltageLabel(raw: string): string { + const v = normalizeValue(raw); + if (!v) return ''; + // Keep common MV/HV notation like "6/10" or "12/20". + const cleaned = v.replace(/\s+/g, ' '); + if (/\bkv\b/i.test(cleaned)) return cleaned.replace(/\bkv\b/i, 'kV'); + // If purely numeric-ish (e.g. "20"), add unit. + const num = cleaned.match(/\d+(?:[\.,]\d+)?(?:\s*\/\s*\d+(?:[\.,]\d+)?)?/); + if (!num) return cleaned; + // If the string already contains other words, keep as-is. + if (/[a-z]/i.test(cleaned)) return cleaned; + return `${cleaned} kV`; +} + +function parseVoltageSortKey(voltageLabel: string): number { + const v = normalizeVoltageLabel(voltageLabel); + // Sort by the last number (e.g. 6/10 -> 10, 12/20 -> 20) + const nums = v + .replace(/,/g, '.') + .match(/\d+(?:\.\d+)?/g) + ?.map(n => Number(n)) + .filter(n => Number.isFinite(n)); + if (!nums || nums.length === 0) return Number.POSITIVE_INFINITY; + return nums[nums.length - 1]; +} + +function formatExcelHeaderLabel(key: string, unit?: string): string { + const k = normalizeValue(key); + if (!k) return ''; + const u = normalizeValue(unit || ''); + + // Prefer compact but clear labels. + const compact = k + .replace(/\s*\(approx\.?\)\s*/gi, ' (approx.) ') + .replace(/\s+/g, ' ') + .trim(); + + if (!u) return compact; + // Avoid double units like "(mm) mm". + if (new RegExp(`\\(${u.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')}\\)`, 'i').test(compact)) return compact; + return `${compact} (${u})`; +} + +function formatExcelCellValue(value: string, unit?: string, opts?: { appendUnit?: boolean }): string { + const v = normalizeValue(value); + if (!v) return ''; + const u = normalizeValue(unit || ''); + if (!u) return v; + const appendUnit = opts?.appendUnit ?? true; + // Only auto-append unit for pure numbers; many Excel cells already contain units. + if (!appendUnit) return v; + return looksNumeric(v) ? `${v} ${u}` : v; +} + +function buildExcelModel(args: { + product: ProductData; + locale: 'en' | 'de'; +}): { ok: boolean; technicalItems: KeyValueItem[]; voltageTables: VoltageTableModel[] } { + const match = findExcelForProduct(args.product); + if (!match || match.rows.length === 0) return { ok: false, technicalItems: [], voltageTables: [] }; + + const units = match.units || {}; + + // Filter rows to only include compatible column structures + // This handles products that exist in multiple Excel files with different column structures + const rows = match.rows; + + // Find the row with most columns as sample + let sample = rows.find(r => r && Object.keys(r).length > 0) || {}; + let maxColumns = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units').length; + + for (const r of rows) { + const cols = Object.keys(r).filter(k => k && k !== 'Part Number' && k !== 'Units').length; + if (cols > maxColumns) { + sample = r; + maxColumns = cols; + } + } + + // Map Excel column names to our compact/standardized keys. + // This mapping covers all common Excel column names found in the source files + // IMPORTANT: More specific patterns must come before generic ones to avoid false matches + const columnMapping: Record = { + // Cross-section and voltage (these are used for grouping, not shown in table) - MUST COME FIRST + 'number of cores and cross-section': { header: 'Cross-section', unit: '', key: 'cross_section' }, + 'ross section conductor': { header: 'Cross-section', unit: '', key: 'cross_section' }, + + // The 13 required headers (exact order as specified) + 'diameter over insulation': { header: 'DI', unit: 'mm', key: 'DI' }, + 'diameter over insulation (approx.)': { header: 'DI', unit: 'mm', key: 'DI' }, + 'dc resistance at 20 °C': { header: 'RI', unit: 'Ohm/km', key: 'RI' }, + 'dc resistance at 20°C': { header: 'RI', unit: 'Ohm/km', key: 'RI' }, + 'resistance conductor': { header: 'RI', unit: 'Ohm/km', key: 'RI' }, + 'maximum resistance of conductor': { header: 'RI', unit: 'Ohm/km', key: 'RI' }, + 'insulation thickness': { header: 'Wi', unit: 'mm', key: 'Wi' }, + 'nominal insulation thickness': { header: 'Wi', unit: 'mm', key: 'Wi' }, + 'current ratings in air, trefoil': { header: 'Ibl', unit: 'A', key: 'Ibl' }, + 'current ratings in air, trefoil*': { header: 'Ibl', unit: 'A', key: 'Ibl' }, + 'current ratings in ground, trefoil': { header: 'Ibe', unit: 'A', key: 'Ibe' }, + 'current ratings in ground, trefoil*': { header: 'Ibe', unit: 'A', key: 'Ibe' }, + 'conductor shortcircuit current': { header: 'Ik', unit: 'kA', key: 'Ik_cond' }, + 'screen shortcircuit current': { header: 'Ik', unit: 'kA', key: 'Ik_screen' }, + 'sheath thickness': { header: 'Wm', unit: 'mm', key: 'Wm' }, + 'minimum sheath thickness': { header: 'Wm', unit: 'mm', key: 'Wm' }, + 'nominal sheath thickness': { header: 'Wm', unit: 'mm', key: 'Wm' }, + 'bending radius': { header: 'Rbv', unit: 'mm', key: 'Rbv' }, + 'bending radius (min.)': { header: 'Rbv', unit: 'mm', key: 'Rbv' }, + 'outer diameter': { header: 'Ø', unit: 'mm', key: 'Ø' }, + 'outer diameter (approx.)': { header: 'Ø', unit: 'mm', key: 'Ø' }, + 'outer diameter of cable': { header: 'Ø', unit: 'mm', key: 'Ø' }, + // Pulling force is typically expressed in N in datasheets. + // Some sources appear to store values in kN (e.g. 4.5), we normalize in the cell getter. + 'pulling force': { header: 'Fzv', unit: 'N', key: 'Fzv' }, + 'max. pulling force': { header: 'Fzv', unit: 'N', key: 'Fzv' }, + // Conductor material (we render this as a single column) + 'conductor aluminum': { header: 'Cond.', unit: '', key: 'Cond' }, + 'conductor copper': { header: 'Cond.', unit: '', key: 'Cond' }, + 'weight': { header: 'G', unit: 'kg/km', key: 'G' }, + 'weight (approx.)': { header: 'G', unit: 'kg/km', key: 'G' }, + 'cable weight': { header: 'G', unit: 'kg/km', key: 'G' }, + + // Additional technical columns (to include ALL Excel data) + // Specific material/property columns must come before generic 'conductor' pattern + 'conductor diameter (approx.)': { header: 'Conductor diameter', unit: 'mm', key: 'cond_diam' }, + 'conductor diameter': { header: 'Conductor diameter', unit: 'mm', key: 'cond_diam' }, + 'diameter conductor': { header: 'Conductor diameter', unit: 'mm', key: 'cond_diam' }, + 'diameter over screen': { header: 'Diameter over screen', unit: 'mm', key: 'D_screen' }, + 'metallic screen mm2': { header: 'Metallic screen', unit: 'mm2', key: 'S_screen' }, + 'metallic screen': { header: 'Metallic screen', unit: 'mm2', key: 'S_screen' }, + 'reactance': { header: 'Reactance', unit: 'Ohm/km', key: 'X' }, + 'capacitance (approx.)': { header: 'Capacitance', unit: 'uF/km', key: 'cap' }, + 'capacitance': { header: 'Capacitance', unit: 'uF/km', key: 'cap' }, + 'inductance, trefoil (approx.)': { header: 'Inductance trefoil', unit: 'mH/km', key: 'ind_trefoil' }, + 'inductance, trefoil': { header: 'Inductance trefoil', unit: 'mH/km', key: 'ind_trefoil' }, + 'inductance in air, flat (approx.)': { header: 'Inductance air flat', unit: 'mH/km', key: 'ind_air_flat' }, + 'inductance in air, flat': { header: 'Inductance air flat', unit: 'mH/km', key: 'ind_air_flat' }, + 'inductance in ground, flat (approx.)': { header: 'Inductance ground flat', unit: 'mH/km', key: 'ind_ground_flat' }, + 'inductance in ground, flat': { header: 'Inductance ground flat', unit: 'mH/km', key: 'ind_ground_flat' }, + 'current ratings in air, flat': { header: 'Current air flat', unit: 'A', key: 'cur_air_flat' }, + 'current ratings in air, flat*': { header: 'Current air flat', unit: 'A', key: 'cur_air_flat' }, + 'current ratings in ground, flat': { header: 'Current ground flat', unit: 'A', key: 'cur_ground_flat' }, + 'current ratings in ground, flat*': { header: 'Current ground flat', unit: 'A', key: 'cur_ground_flat' }, + 'heating time constant, trefoil*': { header: 'Heating time trefoil', unit: 's', key: 'heat_trefoil' }, + 'heating time constant, trefoil': { header: 'Heating time trefoil', unit: 's', key: 'heat_trefoil' }, + 'heating time constant, flat*': { header: 'Heating time flat', unit: 's', key: 'heat_flat' }, + 'heating time constant, flat': { header: 'Heating time flat', unit: 's', key: 'heat_flat' }, + + // Temperature and other technical data + 'maximal operating conductor temperature': { header: 'Max operating temp', unit: '°C', key: 'max_op_temp' }, + 'maximal short-circuit temperature': { header: 'Max short-circuit temp', unit: '°C', key: 'max_sc_temp' }, + 'operating temperature range': { header: 'Operating temp range', unit: '°C', key: 'temp_range' }, + 'minimal storage temperature': { header: 'Min storage temp', unit: '°C', key: 'min_store_temp' }, + 'minimal temperature for laying': { header: 'Min laying temp', unit: '°C', key: 'min_lay_temp' }, + 'test voltage': { header: 'Test voltage', unit: 'kV', key: 'test_volt' }, + 'rated voltage': { header: 'Rated voltage', unit: 'kV', key: 'rated_volt' }, + + // Material and specification data + // Note: More specific patterns must come before generic ones to avoid conflicts + + // Conductor description/type (keep as technical/meta data, not as material column) + 'conductor': { header: 'Conductor', unit: '', key: 'conductor' }, + + // Screen and tape columns + 'copper wire screen and tape': { header: 'Copper screen', unit: '', key: 'copper_screen' }, + 'CUScreen': { header: 'Copper screen', unit: '', key: 'copper_screen' }, + 'conductive tape below screen': { header: 'Conductive tape below', unit: '', key: 'tape_below' }, + 'non conducting tape above screen': { header: 'Non-conductive tape above', unit: '', key: 'tape_above' }, + 'al foil': { header: 'Al foil', unit: '', key: 'al_foil' }, + + // Material properties + 'shape of conductor': { header: 'Conductor shape', unit: '', key: 'shape' }, + 'colour of insulation': { header: 'Insulation color', unit: '', key: 'color_ins' }, + 'colour of sheath': { header: 'Sheath color', unit: '', key: 'color_sheath' }, + 'insulation': { header: 'Insulation', unit: '', key: 'insulation' }, + 'sheath': { header: 'Sheath', unit: '', key: 'sheath' }, + 'norm': { header: 'Norm', unit: '', key: 'norm' }, + 'standard': { header: 'Standard', unit: '', key: 'standard' }, + 'cpr class': { header: 'CPR class', unit: '', key: 'cpr' }, + 'flame retardant': { header: 'Flame retardant', unit: '', key: 'flame' }, + 'self-extinguishing of single cable': { header: 'Flame retardant', unit: '', key: 'flame' }, + 'packaging': { header: 'Packaging', unit: '', key: 'packaging' }, + 'ce-conformity': { header: 'CE conformity', unit: '', key: 'ce' }, + 'rohs/reach': { header: 'RoHS/REACH', unit: '', key: 'rohs_reach' }, + }; + + // Get all Excel keys from sample + const excelKeys = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units'); + + // Find which Excel keys match our mapping + const matchedColumns: Array<{ excelKey: string; mapping: { header: string; unit: string; key: string } }> = []; + for (const excelKey of excelKeys) { + const normalized = normalizeValue(excelKey).toLowerCase(); + for (const [pattern, mapping] of Object.entries(columnMapping)) { + if (normalized === pattern.toLowerCase() || new RegExp(pattern, 'i').test(normalized)) { + matchedColumns.push({ excelKey, mapping }); + break; + } + } + } + + // Deduplicate by mapping.key to avoid duplicate columns + const seenKeys = new Set(); + const deduplicated: typeof matchedColumns = []; + for (const item of matchedColumns) { + if (!seenKeys.has(item.mapping.key)) { + seenKeys.add(item.mapping.key); + deduplicated.push(item); + } + } + matchedColumns.length = 0; + matchedColumns.push(...deduplicated); + + // Separate into 13 required headers vs additional columns + const requiredHeaderKeys = ['DI', 'RI', 'Wi', 'Ibl', 'Ibe', 'Ik_cond', 'Wm', 'Rbv', 'Ø', 'Fzv', 'Cond', 'G']; + const isRequiredHeader = (key: string) => requiredHeaderKeys.includes(key); + + // Filter rows to only include those with the same column structure as sample + const sampleKeys = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units').sort(); + const compatibleRows = rows.filter(r => { + const rKeys = Object.keys(r).filter(k => k && k !== 'Part Number' && k !== 'Units').sort(); + return JSON.stringify(rKeys) === JSON.stringify(sampleKeys); + }); + + if (compatibleRows.length === 0) return { ok: false, technicalItems: [], voltageTables: [] }; + + const csKey = + guessColumnKey(sample, [/number of cores and cross-section/i, /cross.?section/i, /ross section conductor/i]) || null; + const voltageKey = + guessColumnKey(sample, [/rated voltage/i, /voltage rating/i, /nennspannung/i, /spannungs/i]) || null; + + if (!csKey) return { ok: false, technicalItems: [], voltageTables: [] }; + + // Group rows by voltage rating + const byVoltage = new Map(); + for (let i = 0; i < compatibleRows.length; i++) { + const cs = normalizeValue(String(compatibleRows[i]?.[csKey] ?? '')); + if (!cs) continue; + const rawV = voltageKey ? normalizeValue(String(compatibleRows[i]?.[voltageKey] ?? '')) : ''; + const voltageLabel = normalizeVoltageLabel(rawV || ''); + const key = voltageLabel || (args.locale === 'de' ? 'Spannung unbekannt' : 'Voltage unknown'); + const arr = byVoltage.get(key) ?? []; + arr.push(i); + byVoltage.set(key, arr); + } + + const voltageTables: VoltageTableModel[] = []; + const technicalItems: KeyValueItem[] = []; + + const voltageKeysSorted = Array.from(byVoltage.keys()).sort((a, b) => { + const na = parseVoltageSortKey(a); + const nb = parseVoltageSortKey(b); + if (na !== nb) return na - nb; + return a.localeCompare(b); + }); + + // Track which columns are constant across ALL voltage groups (global constants) + const globalConstantColumns = new Set(); + + // First pass: identify columns that are constant across all rows + for (const { excelKey, mapping } of matchedColumns) { + const values = compatibleRows.map(r => normalizeValue(String(r?.[excelKey] ?? ''))).filter(Boolean); + const unique = Array.from(new Set(values.map(v => v.toLowerCase()))); + + if (unique.length === 1 && values.length > 0) { + globalConstantColumns.add(excelKey); + + // Global constants belong to TECHNICAL DATA (shown once). + const unit = normalizeUnit(units[excelKey] || mapping.unit || ''); + const labelBase = technicalFullLabel({ key: mapping.key, excelKey, locale: args.locale }); + const label = formatExcelHeaderLabel(labelBase, unit); + const value = compactCellForDenseTable(values[0], unit, args.locale); + const existing = technicalItems.find(t => t.label === label); + if (!existing) technicalItems.push({ label, value, unit }); + } + } + + // Second pass: for each voltage group, separate constant vs variable columns + for (const vKey of voltageKeysSorted) { + const indices = byVoltage.get(vKey) || []; + if (!indices.length) continue; + + const crossSections = indices.map(idx => normalizeValue(String(compatibleRows[idx]?.[csKey] ?? ''))); + + // Meta items: keep a consistent, compact set across products. + // This is the "voltage-group meta header" (parameter block) above each table. + const metaItems: KeyValueItem[] = []; + const metaCandidates = new Map(); + if (voltageKey) { + const rawV = normalizeValue(String(compatibleRows[indices[0]]?.[voltageKey] ?? '')); + metaItems.push({ + label: args.locale === 'de' ? 'Spannung' : 'Voltage', + value: normalizeVoltageLabel(rawV || ''), + }); + } + + // Which non-table fields we want to show consistently per voltage group. + const metaKeyPriority = [ + 'test_volt', + 'temp_range', + 'max_op_temp', + 'max_sc_temp', + 'min_lay_temp', + 'min_store_temp', + 'cpr', + 'flame', + ]; + const metaKeyPrioritySet = new Set(metaKeyPriority); + + // Cross-section table is limited to the core industry columns (compact for A4). + // To avoid “too little data” we always render the full set of core columns in the table, + // even when a value is constant across the voltage group. + const tableColumns: Array<{ excelKey: string; mapping: { header: string; unit: string; key: string } }> = []; + + const denseTableKeyOrder = [ + // Conductor should be the first technical column (after designation). + // This improves scanability in the cable industry (material/type is a primary discriminator). + 'Cond', + // Next highest priority for LV/MV: conductor shape/type (RE/RM/RMV/...). + 'shape', + + // Electrical properties (when available) – high value for MV/HV engineering. + 'cap', + 'X', + + 'DI', + 'RI', + 'Wi', + 'Ibl', + 'Ibe', + 'Ik_cond', + 'Wm', + 'Rbv', + 'Ø', + + // Extra high-value dimensions/metal screen info (when available): common in MV/HV. + // Keep close to Ø to support engineers comparing layer builds. + 'D_screen', + 'S_screen', + + 'Fzv', + 'G', + ] as const; + const denseTableKeys = new Set(denseTableKeyOrder); + + // Extra data handling: + // - global constants => TECHNICAL DATA + // - voltage-group constants => metaItems + // - voltage-group varying (non-core) => metaItems as ranges/lists (keeps A4 compact) + + // Pre-scan: detect if bending radius is expressed as xD (common in LV/Solar sheets) + // so we can label the unit correctly (Rbv [xD] instead of Rbv [mm]). + // Detect bending radius representation (mm vs xD) from the matched Excel column. + const bendingRadiusKey = matchedColumns.find(c => c.mapping.key === 'Rbv')?.excelKey || null; + let bendUnitOverride = ''; + if (bendingRadiusKey) { + const bendVals = indices + .map(idx => normalizeValue(String(compatibleRows[idx]?.[bendingRadiusKey] ?? ''))) + .filter(Boolean); + if (bendVals.some(v => /\bxD\b/i.test(v))) bendUnitOverride = 'xD'; + } + + // 1) Collect mapped columns. + for (const { excelKey, mapping } of matchedColumns) { + // Skip cross-section and voltage keys (these are used for grouping) + if (excelKey === csKey || excelKey === voltageKey) continue; + + // Get values for this voltage group + const values = indices.map(idx => normalizeValue(String(compatibleRows[idx]?.[excelKey] ?? ''))).filter(Boolean); + + if (values.length > 0) { + const unique = Array.from(new Set(values.map(v => v.toLowerCase()))); + let unit = normalizeUnit(units[excelKey] || mapping.unit || ''); + if (mapping.key === 'Rbv' && bendUnitOverride) unit = bendUnitOverride; + + // Always keep the core 13 columns in the table (even if constant). + if (denseTableKeys.has(mapping.key)) { + tableColumns.push({ excelKey, mapping }); + continue; + } + + // For meta header: collect candidates, but only *display* a consistent subset. + // Global constants are normally in TECHNICAL DATA, but we still allow them + // into the voltage meta block if they are part of the priority set. + if (globalConstantColumns.has(excelKey) && !metaKeyPrioritySet.has(mapping.key)) { + continue; + } + + const value = + unique.length === 1 + ? compactCellForDenseTable(values[0], unit, args.locale) + : summarizeSmartOptions(excelKey, values); + + // Meta header: keep labels fully readable (no abbreviations). + // Units are shown separately by the meta grid. + const label = metaFullLabel({ key: mapping.key, excelKey, locale: args.locale }); + + metaCandidates.set(mapping.key, { label, value, unit }); + } + } + + // 1b) Materialize meta items in a stable order. + // This keeps LV/MV/HV tables visually consistent (no "MV has much more in front"). + for (const k of metaKeyPriority) { + const item = metaCandidates.get(k); + if (item && item.label && item.value) metaItems.push(item); + } + + // 2) Build the compact cross-section table. + // If a column is not available in the Excel source and we cannot derive it safely, + // we omit it (empty columns waste A4 width and reduce readability). + const mappedByKey = new Map(); + for (const c of tableColumns) { + if (!mappedByKey.has(c.mapping.key)) mappedByKey.set(c.mapping.key, c); + } + + // Helper keys for derived values. + // We derive DI (diameter over insulation) from Ø and Wm when DI is missing. + const outerDiameterKey = (mappedByKey.get('Ø')?.excelKey || '') || null; + const sheathThicknessKey = (mappedByKey.get('Wm')?.excelKey || '') || null; + + const canDeriveDenseKey = (k: (typeof denseTableKeyOrder)[number]): boolean => { + if (k === 'DI') return Boolean(outerDiameterKey && sheathThicknessKey); + if (k === 'Cond') return true; // derived from product designation when missing + return false; + }; + + const orderedTableColumns = denseTableKeyOrder + .filter(k => mappedByKey.has(k) || canDeriveDenseKey(k)) + .map(k => { + const existing = mappedByKey.get(k); + if (existing) return existing; + return { + excelKey: '', + mapping: { header: k, unit: '', key: k }, + }; + }); + + // Debug: Check for duplicate keys + if (process.env.PDF_DEBUG_EXCEL === '1') { + const keys = tableColumns.map(c => c.mapping.key); + const duplicates = keys.filter((k, i) => keys.indexOf(k) !== i); + if (duplicates.length > 0) { + console.log(`[debug] Duplicate keys found: ${duplicates.join(', ')}`); + console.log(`[debug] All columns: ${tableColumns.map(c => c.mapping.key + '(' + c.mapping.header + ')').join(', ')}`); + } + } + + const columns = orderedTableColumns.map(({ excelKey, mapping }) => { + // Default units for the compact column set (used when an Excel unit is missing). + // We keep Excel units when available. + const defaultUnitByKey: Record = { + DI: 'mm', + RI: 'Ohm/km', + Wi: 'mm', + Ibl: 'A', + Ibe: 'A', + Ik_cond: 'kA', + Wm: 'mm', + Rbv: 'mm', + 'Ø': 'mm', + Fzv: 'N', + G: 'kg/km', + }; + + let unit = normalizeUnit((excelKey ? units[excelKey] : '') || mapping.unit || defaultUnitByKey[mapping.key] || ''); + if (mapping.key === 'Rbv' && bendUnitOverride) unit = bendUnitOverride; + + return { + key: mapping.key, + // Keep labels compact for dense tables; headerLabelFor() will use these. + label: denseAbbrevLabel({ key: mapping.key, locale: args.locale, unit, withUnit: true }) || formatExcelHeaderLabel(excelKey, unit), + get: (rowIndex: number) => { + const srcRowIndex = indices[rowIndex]; + const raw = excelKey ? normalizeValue(String(compatibleRows[srcRowIndex]?.[excelKey] ?? '')) : ''; + const unitLocal = unit; + + // LV sheets (e.g. NA2XY): current ratings (Ibl/Ibe) and short-circuit current (Ik) + // are typically not part of the source Excel. Keep cells empty (don’t guess). + // However, for DI we can derive a usable engineering approximation. + + // Derived values (only when the source column is missing/empty). + // DI (diameter over insulation): approx. from Ø and Wm when available. + if (mapping.key === 'DI' && !raw && outerDiameterKey && sheathThicknessKey) { + const odRaw = normalizeValue(String(compatibleRows[srcRowIndex]?.[outerDiameterKey] ?? '')); + const wmRaw = normalizeValue(String(compatibleRows[srcRowIndex]?.[sheathThicknessKey] ?? '')); + const od = parseNumericOption(odRaw); + const wm = parseNumericOption(wmRaw); + if (od !== null && wm !== null) { + const di = od - 2 * wm; + if (Number.isFinite(di) && di > 0) return `~${compactNumericForLocale(String(di), args.locale)}`; + } + } + + // Conductor material: if not present in Excel, derive from part number prefix. + // NA… => Al, N… => Cu (common cable designation pattern in this dataset). + if (mapping.key === 'Cond' && !raw) { + const pn = normalizeExcelKey(args.product.name || args.product.slug || args.product.sku || ''); + if (/^NA/.test(pn)) return 'Al'; + if (/^N/.test(pn)) return 'Cu'; + } + + // If bending radius is given as xD, keep it as-is (unit label reflects xD). + if (mapping.key === 'Rbv' && /\bxD\b/i.test(raw)) return compactNumericForLocale(raw, args.locale); + + // HV source: "Min. bending radius" appears to be stored in meters (e.g. 1.70). + // Convert to mm when we label it as mm. + if (mapping.key === 'Rbv' && unitLocal.toLowerCase() === 'mm') { + const n = parseNumericOption(raw); + const looksLikeMeters = n !== null && n > 0 && n < 50 && /[\.,]\d{1,3}/.test(raw) && !/\dxD/i.test(raw); + if (looksLikeMeters) return compactNumericForLocale(String(Math.round(n * 1000)), args.locale); + } + + // Pulling force: some sources appear to store kN values (e.g. 4.5) without unit. + // When header/unit is N and the value is small, normalize to N. + if (mapping.key === 'Fzv' && unitLocal.toLowerCase() === 'n') { + const n = parseNumericOption(raw); + const looksLikeKN = n !== null && n > 0 && n < 100 && !/\bN\b/i.test(raw) && !/\bkN\b/i.test(raw); + if (looksLikeKN) return compactNumericForLocale(String(Math.round(n * 1000)), args.locale); + } + + return compactCellForDenseTable(raw, unitLocal, args.locale); + }, + }; + }); + + voltageTables.push({ voltageLabel: vKey, metaItems, crossSections, columns }); + + // Debug: Show columns for this voltage + if (process.env.PDF_DEBUG_EXCEL === '1') { + console.log(`[debug] Voltage ${vKey}: ${columns.length} columns`); + console.log(`[debug] Columns: ${columns.map(c => c.key).join(', ')}`); + } + } + + technicalItems.sort((a, b) => a.label.localeCompare(b.label)); + + // Debug: Show technical items + if (process.env.PDF_DEBUG_EXCEL === '1') { + console.log(`[debug] Technical items: ${technicalItems.map(t => t.label).join(', ')}`); + } + + return { ok: true, technicalItems, voltageTables }; +} + +// Unified key-value grid renderer for both metagrid and technical data +function drawKeyValueTable(args: { + title: string; + items: Array<{ label: string; value: string; unit?: string }>; + locale: 'en' | 'de'; + newPage: () => number; + getPage: () => PDFPage; + page: PDFPage; + y: number; + margin: number; + contentWidth: number; + contentMinY: number; + font: PDFFont; + fontBold: PDFFont; + navy: ReturnType; + darkGray: ReturnType; + mediumGray: ReturnType; + expandLabels?: boolean; // true for metagrid, false for technical data +}): number { + let { page, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, mediumGray } = args; + let y = args.y; + const items = (args.items || []).filter(i => i.label && i.value); + if (!items.length) return y; + + const lightGray = rgb(0.9020, 0.9137, 0.9294); + const almostWhite = rgb(0.9725, 0.9765, 0.9804); + const expandLabels = args.expandLabels ?? false; + + // Auto-determine columns based on item count + const cols = items.length >= 7 ? 3 : 2; + const colWidth = contentWidth / cols; + const cellH = 34; + const padX = 10; + const titleH = 18; + const headerPadY = 10; + + const labelSize = 7.25; + const valueSize = 8.75; + const labelYOff = 12; + const valueYOff = 28; + + const rows = Math.ceil(items.length / cols); + const boxH = headerPadY + titleH + rows * cellH + headerPadY; + const needed = boxH + 10; + + if (y - needed < contentMinY) y = args.newPage(); + page = args.getPage(); + + const boxTopY = y; + const boxBottomY = boxTopY - boxH; + + // Outer frame + page.drawRectangle({ + x: margin, + y: boxBottomY, + width: contentWidth, + height: boxH, + borderColor: lightGray, + borderWidth: 1, + color: rgb(1, 1, 1), + }); + + // Title band + page.drawRectangle({ + x: margin, + y: boxTopY - (headerPadY + titleH), + width: contentWidth, + height: headerPadY + titleH, + color: almostWhite, + }); + + if (args.title) { + page.drawText(args.title, { + x: margin + padX, + y: boxTopY - (headerPadY + 12), + size: 10, + font: fontBold, + color: navy, + maxWidth: contentWidth - padX * 2, + }); + } + + // Separator below title + const gridTopY = boxTopY - (headerPadY + titleH); + page.drawLine({ + start: { x: margin, y: gridTopY }, + end: { x: margin + contentWidth, y: gridTopY }, + thickness: 0.75, + color: lightGray, + }); + + // Render grid cells + for (let r = 0; r < rows; r++) { + const rowTopY = gridTopY - r * cellH; + const rowBottomY = rowTopY - cellH; + + // Zebra striping + if (r % 2 === 0) { + page.drawRectangle({ + x: margin, + y: rowBottomY, + width: contentWidth, + height: cellH, + color: rgb(0.99, 0.992, 0.995), + }); + } + + // Row separator (except last) + if (r !== rows - 1) { + page.drawLine({ + start: { x: margin, y: rowBottomY }, + end: { x: margin + contentWidth, y: rowBottomY }, + thickness: 0.5, + color: lightGray, + }); + } + + for (let c = 0; c < cols; c++) { + const idx = r * cols + c; + const item = items[idx]; + const x0 = margin + c * colWidth; + + // Column separator (except last) - draw from grid top to bottom for full height + if (c !== cols - 1) { + page.drawLine({ + start: { x: x0 + colWidth, y: gridTopY }, + end: { x: x0 + colWidth, y: boxBottomY + headerPadY }, + thickness: 0.5, + color: lightGray, + }); + } + + if (!item) continue; + + const labelText = expandLabels ? expandMetaLabel(item.label, args.locale) : item.label; + const valueText = item.unit ? `${item.value} ${item.unit}` : item.value; + + const maxW = colWidth - padX * 2 - 2; + const labelOneLine = ellipsizeToWidth(labelText, fontBold, labelSize, maxW); + const valueOneLine = ellipsizeToWidth(valueText, font, valueSize, maxW); + + page.drawText(labelOneLine, { + x: x0 + padX, + y: rowTopY - labelYOff, + size: labelSize, + font: fontBold, + color: mediumGray, + }); + page.drawText(valueOneLine, { + x: x0 + padX, + y: rowTopY - valueYOff, + size: valueSize, + font, + color: darkGray, + }); + } + } + + return Math.max(contentMinY, boxBottomY - 18); +} + +// Backward compatibility wrapper for metagrid +function drawDenseMetaGrid(args: { + title: string; + items: Array<{ label: string; value: string; unit?: string }>; + locale: 'en' | 'de'; + newPage: () => number; + getPage: () => PDFPage; + page: PDFPage; + y: number; + margin: number; + contentWidth: number; + contentMinY: number; + font: PDFFont; + fontBold: PDFFont; + navy: ReturnType; + darkGray: ReturnType; + mediumGray: ReturnType; +}): number { + return drawKeyValueTable({ ...args, expandLabels: true }); +} + +function prioritizeColumnsForDenseTable(args: { + columns: VoltageTableModel['columns']; +}): VoltageTableModel['columns'] { + // Priority order: compact cross-section table columns first, then all additional technical data + const priorityOrder = [ + // Compact cross-section table columns (industry standard) + // NOTE: The designation/configuration is the *first* table column already. + // We put conductor material/type first among the technical columns for better scanability. + // Note: Ik is represented by Ik_cond (conductor shortcircuit current) + 'Cond', + 'shape', + 'cap', + 'X', + 'DI', + 'RI', + 'Wi', + 'Ibl', + 'Ibe', + 'Ik_cond', + 'Wm', + 'Rbv', + 'Ø', + // Extra high-value dimensions/metal screen info (when present) + 'D_screen', + 'S_screen', + 'Fzv', + 'G', + + // Additional technical columns (in logical groups) + // Dimensions and materials + 'cond_diam', 'shape', 'conductor', 'insulation', 'sheath', + // Temperatures + 'max_op_temp', 'max_sc_temp', 'temp_range', 'min_store_temp', 'min_lay_temp', + // Electrical properties + 'cap', 'ind_trefoil', 'ind_air_flat', 'ind_ground_flat', + // Current ratings (flat) + 'cur_air_flat', 'cur_ground_flat', + // Heating time constants + 'heat_trefoil', 'heat_flat', + // Voltage ratings + 'test_volt', 'rated_volt', + // Colors + 'color_ins', 'color_sheath', + // Materials and specs + 'norm', 'standard', 'cpr', 'flame', 'packaging', 'ce', + // Screen/tape layers + 'tape_below', 'copper_screen', 'tape_above', 'al_foil', + // Additional shortcircuit current + 'Ik_screen' + ]; + + return [...args.columns].sort((a, b) => { + const ia = priorityOrder.indexOf(a.key); + const ib = priorityOrder.indexOf(b.key); + if (ia !== -1 && ib !== -1) return ia - ib; + if (ia !== -1) return -1; + if (ib !== -1) return 1; + return a.key.localeCompare(b.key); + }); +} + +function normalizeExcelKey(value: string): string { + // Match product names/slugs and Excel "Part Number" robustly. + // Examples: + // - "NA2XS(FL)2Y" -> "NA2XSFL2Y" + // - "na2xsfl2y-3" -> "NA2XSFL2Y" + return String(value || '') + .toUpperCase() + .replace(/-\d+$/g, '') + .replace(/[^A-Z0-9]+/g, ''); +} + +function loadExcelRows(filePath: string): ExcelRow[] { + // We intentionally avoid adding a heavy xlsx parser dependency. + // Instead, we use `xlsx-cli` via npx, which is already available at runtime. + // NOTE: `xlsx-cli -j` prints the sheet name on the first line, then JSON. + const out = execSync(`npx -y xlsx-cli -j "${filePath}"`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); + const trimmed = out.trim(); + const jsonStart = trimmed.indexOf('['); + if (jsonStart < 0) return []; + const jsonText = trimmed.slice(jsonStart); + try { + return JSON.parse(jsonText) as ExcelRow[]; + } catch { + return []; + } +} + +function getExcelIndex(): Map { + if (EXCEL_INDEX) return EXCEL_INDEX; + const idx = new Map(); + for (const file of EXCEL_SOURCE_FILES) { + if (!fs.existsSync(file)) continue; + const rows = loadExcelRows(file); + if (process.env.PDF_DEBUG_EXCEL === '1') { + console.log(`[excel] loaded ${rows.length} rows from ${path.relative(process.cwd(), file)}`); + } + + const unitsRow = rows.find(r => r && r['Part Number'] === 'Units') || null; + const units: Record = {}; + if (unitsRow) { + for (const [k, v] of Object.entries(unitsRow)) { + if (k === 'Part Number') continue; + const unit = normalizeValue(String(v ?? '')); + if (unit) units[k] = unit; + } + } + + for (const r of rows) { + const pn = r?.['Part Number']; + if (!pn || pn === 'Units') continue; + const key = normalizeExcelKey(String(pn)); + if (!key) continue; + const cur = idx.get(key); + if (!cur) { + idx.set(key, { rows: [r], units }); + } else { + cur.rows.push(r); + if (Object.keys(cur.units).length < Object.keys(units).length) cur.units = units; + } + } + } + EXCEL_INDEX = idx; + return idx; +} + +function findExcelForProduct(product: ProductData): ExcelMatch | null { + const idx = getExcelIndex(); + const candidates = [ + product.name, + product.slug ? product.slug.replace(/-\d+$/g, '') : '', + product.sku, + product.translationKey, + ].filter(Boolean) as string[]; + + if (process.env.PDF_DEBUG_EXCEL === '1') { + const keys = candidates.map(c => normalizeExcelKey(c)); + console.log(`[excel] lookup product=${product.id} ${product.locale ?? ''} slug=${product.slug ?? ''} name=${stripHtml(product.name)} keys=${keys.join(',')}`); + } + + for (const c of candidates) { + const key = normalizeExcelKey(c); + const match = idx.get(key); + if (match && match.rows.length) return match; + } + return null; +} + +function findExcelRowsForProduct(product: ProductData): ExcelRow[] { + const match = findExcelForProduct(product); + if (!match?.rows || match.rows.length === 0) return []; + + const rows = match.rows; + + // Find the row with most columns as sample + let sample = rows.find(r => r && Object.keys(r).length > 0) || {}; + let maxColumns = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units').length; + + for (const r of rows) { + const cols = Object.keys(r).filter(k => k && k !== 'Part Number' && k !== 'Units').length; + if (cols > maxColumns) { + sample = r; + maxColumns = cols; + } + } + + // Filter to only rows with the same column structure as sample + const sampleKeys = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units').sort(); + const compatibleRows = rows.filter(r => { + const rKeys = Object.keys(r).filter(k => k && k !== 'Part Number' && k !== 'Units').sort(); + return JSON.stringify(rKeys) === JSON.stringify(sampleKeys); + }); + + return compatibleRows; +} + +function guessColumnKey(row: ExcelRow, patterns: RegExp[]): string | null { + const keys = Object.keys(row || {}); + + // Try pattern-based matching first + for (const re of patterns) { + const k = keys.find(x => { + const key = String(x); + + // Specific exclusions to prevent wrong matches + if (re.test('conductor') && /ross section conductor/i.test(key)) return false; + if (re.test('insulation thickness') && /Diameter over insulation/i.test(key)) return false; + if (re.test('conductor') && !/^conductor$/i.test(key)) return false; + if (re.test('insulation') && !/^insulation$/i.test(key)) return false; + if (re.test('sheath') && !/^sheath$/i.test(key)) return false; + if (re.test('norm') && !/^norm$/i.test(key)) return false; + + return re.test(key); + }); + if (k) return k; + } + return null; +} + +function hasAttr(product: ProductData, nameRe: RegExp, expectedLen?: number): boolean { + const a = product.attributes?.find(x => nameRe.test(x.name)); + if (!a) return false; + if (typeof expectedLen === 'number') return (a.options || []).length === expectedLen; + return (a.options || []).length > 0; +} + +function pushRowAttrIfMissing(args: { + product: ProductData; + name: string; + options: string[]; + expectedLen: number; + existsRe: RegExp; +}): void { + const { product, name, options, expectedLen, existsRe } = args; + if (!options.filter(Boolean).length) return; + if (hasAttr(product, existsRe, expectedLen)) return; + product.attributes = product.attributes || []; + product.attributes.push({ name, options }); +} + +function pushAttrIfMissing(args: { product: ProductData; name: string; options: string[]; existsRe: RegExp }): void { + const { product, name, options, existsRe } = args; + if (!options.filter(Boolean).length) return; + if (hasAttr(product, existsRe)) return; + product.attributes = product.attributes || []; + product.attributes.push({ name, options }); +} + +function getUniqueNonEmpty(options: string[]): string[] { + const uniq: string[] = []; + const seen = new Set(); + for (const v of options.map(normalizeValue).filter(Boolean)) { + const k = v.toLowerCase(); + if (seen.has(k)) continue; + seen.add(k); + uniq.push(v); + } + return uniq; +} + +function ensureExcelCrossSectionAttributes(product: ProductData, locale: 'en' | 'de'): void { + const hasCross = (product.attributes || []).some(a => /configuration|konfiguration|aufbau|bezeichnung|number of cores and cross-section|querschnitt|cross.?section|mm²|mm2/i.test(a.name) && (a.options?.length || 0) > 0); + if (hasCross) return; + + const rows = findExcelRowsForProduct(product); + if (!rows.length) { + if (process.env.PDF_DEBUG_EXCEL === '1') { + console.log(`[excel] no rows found for product ${product.id} (${product.slug ?? stripHtml(product.name)})`); + } + return; + } + + // Find the cross-section column. + const csKey = + guessColumnKey(rows[0], [ + /number of cores and cross-section/i, + /cross.?section/i, + /ross section conductor/i, + ]) || null; + if (!csKey) { + if (process.env.PDF_DEBUG_EXCEL === '1') { + console.log(`[excel] rows found but no cross-section column for product ${product.id}; available keys: ${Object.keys(rows[0] || {}).slice(0, 30).join(', ')}`); + } + return; + } + + // Get all technical column keys using improved detection + const voltageKey = guessColumnKey(rows[0], [/rated voltage/i, /voltage rating/i, /spannungs/i, /nennspannung/i]); + const outerKey = guessColumnKey(rows[0], [/outer diameter\b/i, /outer diameter.*approx/i, /outer diameter of cable/i, /außen/i]); + const weightKey = guessColumnKey(rows[0], [/weight\b/i, /gewicht/i, /cable weight/i]); + const dcResKey = guessColumnKey(rows[0], [/dc resistance/i, /resistance conductor/i, /leiterwiderstand/i]); + + // Additional technical columns + const ratedVoltKey = voltageKey; // Already found above + const testVoltKey = guessColumnKey(rows[0], [/test voltage/i, /prüfspannung/i]); + const tempRangeKey = guessColumnKey(rows[0], [/operating temperature range/i, /temperature range/i, /temperaturbereich/i]); + const minLayKey = guessColumnKey(rows[0], [/minimal temperature for laying/i]); + const minStoreKey = guessColumnKey(rows[0], [/minimal storage temperature/i]); + const maxOpKey = guessColumnKey(rows[0], [/maximal operating conductor temperature/i, /max\. operating/i]); + const maxScKey = guessColumnKey(rows[0], [/maximal short-circuit temperature/i, /short\s*circuit\s*temperature/i]); + const insThkKey = guessColumnKey(rows[0], [/nominal insulation thickness/i, /insulation thickness/i]); + const sheathThkKey = guessColumnKey(rows[0], [/nominal sheath thickness/i, /minimum sheath thickness/i]); + const maxResKey = guessColumnKey(rows[0], [/maximum resistance of conductor/i]); + + // Material and specification columns + const conductorKey = guessColumnKey(rows[0], [/^conductor$/i]); + const insulationKey = guessColumnKey(rows[0], [/^insulation$/i]); + const sheathKey = guessColumnKey(rows[0], [/^sheath$/i]); + const normKey = guessColumnKey(rows[0], [/^norm$/i, /^standard$/i]); + const cprKey = guessColumnKey(rows[0], [/cpr class/i]); + const rohsKey = guessColumnKey(rows[0], [/^rohs$/i]); + const reachKey = guessColumnKey(rows[0], [/^reach$/i]); + const packagingKey = guessColumnKey(rows[0], [/^packaging$/i]); + const shapeKey = guessColumnKey(rows[0], [/shape of conductor/i]); + const flameKey = guessColumnKey(rows[0], [/flame retardant/i]); + const diamCondKey = guessColumnKey(rows[0], [/diameter conductor/i]); + const diamInsKey = guessColumnKey(rows[0], [/diameter over insulation/i]); + const diamScreenKey = guessColumnKey(rows[0], [/diameter over screen/i]); + const metalScreenKey = guessColumnKey(rows[0], [/metallic screen/i]); + const capacitanceKey = guessColumnKey(rows[0], [/capacitance/i]); + const reactanceKey = guessColumnKey(rows[0], [/reactance/i]); + const electricalStressKey = guessColumnKey(rows[0], [/electrical stress/i]); + const pullingForceKey = guessColumnKey(rows[0], [/max\. pulling force/i, /pulling force/i]); + const heatingTrefoilKey = guessColumnKey(rows[0], [/heating time constant.*trefoil/i]); + const heatingFlatKey = guessColumnKey(rows[0], [/heating time constant.*flat/i]); + const currentAirTrefoilKey = guessColumnKey(rows[0], [/current ratings in air.*trefoil/i]); + const currentAirFlatKey = guessColumnKey(rows[0], [/current ratings in air.*flat/i]); + const currentGroundTrefoilKey = guessColumnKey(rows[0], [/current ratings in ground.*trefoil/i]); + const currentGroundFlatKey = guessColumnKey(rows[0], [/current ratings in ground.*flat/i]); + const scCurrentCondKey = guessColumnKey(rows[0], [/conductor shortcircuit current/i]); + const scCurrentScreenKey = guessColumnKey(rows[0], [/screen shortcircuit current/i]); + + const cfgName = locale === 'de' ? 'Anzahl der Adern und Querschnitt' : 'Number of cores and cross-section'; + const cfgOptions = rows + .map(r => { + const cs = normalizeValue(String(r?.[csKey] ?? '')); + const v = voltageKey ? normalizeValue(String(r?.[voltageKey] ?? '')) : ''; + if (!cs) return ''; + if (!v) return cs; + // Keep the existing config separator used by splitConfig(): "cross - voltage". + // Add unit only if not already present. + const vHasUnit = /\bkv\b/i.test(v); + const vText = vHasUnit ? v : `${v} kV`; + return `${cs} - ${vText}`; + }) + .filter(Boolean); + + if (!cfgOptions.length) return; + + const attrs = product.attributes || []; + attrs.push({ name: cfgName, options: cfgOptions }); + + const pushRowAttr = (name: string, key: string | null, unit?: string) => { + if (!key) return; + const options = rows + .map(r => normalizeValue(String(r?.[key] ?? ''))) + .map(v => (unit && v && looksNumeric(v) ? `${v} ${unit}` : v)); + if (options.filter(Boolean).length === 0) return; + attrs.push({ name, options }); + }; + + // These names are chosen so existing PDF regexes can detect them. + pushRowAttr(locale === 'de' ? 'Außen-Ø' : 'Outer diameter', outerKey, 'mm'); + pushRowAttr(locale === 'de' ? 'Gewicht' : 'Weight', weightKey, 'kg/km'); + pushRowAttr(locale === 'de' ? 'DC-Leiterwiderstand (20C)' : 'DC resistance at 20 C', dcResKey, 'Ohm/km'); + + const colValues = (key: string | null) => rows.map(r => normalizeValue(String(r?.[key ?? ''] ?? ''))); + + const addConstOrSmallList = (args: { name: string; existsRe: RegExp; key: string | null }) => { + if (!args.key) return; + const uniq = getUniqueNonEmpty(colValues(args.key)); + if (!uniq.length) return; + // If all rows share the same value, store as single option. + if (uniq.length === 1) { + pushAttrIfMissing({ product, name: args.name, options: [uniq[0]], existsRe: args.existsRe }); + return; + } + // Otherwise store the unique set (TECHNICAL DATA will compact it). + pushAttrIfMissing({ product, name: args.name, options: uniq, existsRe: args.existsRe }); + }; + + addConstOrSmallList({ + name: locale === 'de' ? 'Nennspannung' : 'Rated voltage', + existsRe: /rated\s*voltage|voltage\s*rating|nennspannung|spannungsbereich/i, + key: ratedVoltKey, + }); + addConstOrSmallList({ + name: locale === 'de' ? 'Prüfspannung' : 'Test voltage', + existsRe: /test\s*voltage|prüfspannung/i, + key: testVoltKey, + }); + addConstOrSmallList({ + name: locale === 'de' ? 'Temperaturbereich' : 'Operating temperature range', + existsRe: /operating\s*temperature\s*range|temperature\s*range|temperaturbereich/i, + key: tempRangeKey, + }); + addConstOrSmallList({ + name: locale === 'de' ? 'Min. Verlegetemperatur' : 'Minimal temperature for laying', + existsRe: /minimal\s*temperature\s*for\s*laying/i, + key: minLayKey, + }); + addConstOrSmallList({ + name: locale === 'de' ? 'Min. Lagertemperatur' : 'Minimal storage temperature', + existsRe: /minimal\s*storage\s*temperature/i, + key: minStoreKey, + }); + addConstOrSmallList({ + name: locale === 'de' ? 'Max. Betriebstemperatur' : 'Maximal operating conductor temperature', + existsRe: /maximal\s*operating\s*conductor\s*temperature|max\.?\s*operating/i, + key: maxOpKey, + }); + addConstOrSmallList({ + name: locale === 'de' ? 'Kurzschlusstemperatur (max.)' : 'Maximal short-circuit temperature', + existsRe: /maximal\s*short-?circuit\s*temperature|short\s*circuit\s*temperature|kurzschlusstemperatur/i, + key: maxScKey, + }); + addConstOrSmallList({ + name: locale === 'de' ? 'Isolationsdicke (nom.)' : 'Nominal insulation thickness', + existsRe: /nominal\s*insulation\s*thickness|insulation\s*thickness/i, + key: insThkKey, + }); + addConstOrSmallList({ + name: locale === 'de' ? 'Manteldicke (nom.)' : 'Nominal sheath thickness', + existsRe: /nominal\s*sheath\s*thickness|minimum\s*sheath\s*thickness|manteldicke/i, + key: sheathThkKey, + }); + addConstOrSmallList({ + name: locale === 'de' ? 'Max. Leiterwiderstand' : 'Maximum resistance of conductor', + existsRe: /maximum\s*resistance\s*of\s*conductor|max\.?\s*resistance|leiterwiderstand/i, + key: maxResKey, + }); + + // Add additional technical data from Excel files + addConstOrSmallList({ + name: locale === 'de' ? 'Leiter' : 'Conductor', + existsRe: /conductor/i, + key: conductorKey, + }); + + addConstOrSmallList({ + name: locale === 'de' ? 'Isolierung' : 'Insulation', + existsRe: /insulation/i, + key: insulationKey, + }); + + addConstOrSmallList({ + name: locale === 'de' ? 'Mantel' : 'Sheath', + existsRe: /sheath/i, + key: sheathKey, + }); + + addConstOrSmallList({ + name: locale === 'de' ? 'Norm' : 'Standard', + existsRe: /norm|standard|iec|vde/i, + key: normKey, + }); + + addConstOrSmallList({ + name: locale === 'de' ? 'Leiterdurchmesser' : 'Conductor diameter', + existsRe: /diameter conductor|conductor diameter/i, + key: diamCondKey, + }); + + addConstOrSmallList({ + name: locale === 'de' ? 'Isolierungsdurchmesser' : 'Insulation diameter', + existsRe: /diameter over insulation|diameter insulation/i, + key: diamInsKey, + }); + + addConstOrSmallList({ + name: locale === 'de' ? 'Schirmdurchmesser' : 'Screen diameter', + existsRe: /diameter over screen|diameter screen/i, + key: diamScreenKey, + }); + + addConstOrSmallList({ + name: locale === 'de' ? 'Metallischer Schirm' : 'Metallic screen', + existsRe: /metallic screen/i, + key: metalScreenKey, + }); + + addConstOrSmallList({ + name: locale === 'de' ? 'Max. Zugkraft' : 'Max. pulling force', + existsRe: /max.*pulling force|pulling force/i, + key: pullingForceKey, + }); + + addConstOrSmallList({ + name: locale === 'de' ? 'Elektrische Spannung Leiter' : 'Electrical stress conductor', + existsRe: /electrical stress conductor/i, + key: electricalStressKey, + }); + + addConstOrSmallList({ + name: locale === 'de' ? 'Elektrische Spannung Isolierung' : 'Electrical stress insulation', + existsRe: /electrical stress insulation/i, + key: electricalStressKey, + }); + + addConstOrSmallList({ + name: locale === 'de' ? 'Reaktanz' : 'Reactance', + existsRe: /reactance/i, + key: reactanceKey, + }); + + addConstOrSmallList({ + name: locale === 'de' ? 'Heizzeitkonstante trefoil' : 'Heating time constant trefoil', + existsRe: /heating time constant.*trefoil/i, + key: heatingTrefoilKey, + }); + + addConstOrSmallList({ + name: locale === 'de' ? 'Heizzeitkonstante flach' : 'Heating time constant flat', + existsRe: /heating time constant.*flat/i, + key: heatingFlatKey, + }); + + addConstOrSmallList({ + name: locale === 'de' ? 'Flammhemmend' : 'Flame retardant', + existsRe: /flame retardant/i, + key: flameKey, + }); + + addConstOrSmallList({ + name: locale === 'de' ? 'CPR-Klasse' : 'CPR class', + existsRe: /cpr class/i, + key: cprKey, + }); + + addConstOrSmallList({ + name: locale === 'de' ? 'Verpackung' : 'Packaging', + existsRe: /packaging/i, + key: packagingKey, + }); + + addConstOrSmallList({ + name: locale === 'de' ? 'Biegeradius' : 'Bending radius', + existsRe: /bending radius/i, + key: null, // Will be found in row-specific attributes + }); + + addConstOrSmallList({ + name: locale === 'de' ? 'Leiterform' : 'Shape of conductor', + existsRe: /shape of conductor/i, + key: shapeKey, + }); + + addConstOrSmallList({ + name: locale === 'de' ? 'Isolierungsfarbe' : 'Colour of insulation', + existsRe: /colour of insulation/i, + key: null, // Will be found in row-specific attributes + }); + + addConstOrSmallList({ + name: locale === 'de' ? 'Mantelfarbe' : 'Colour of sheath', + existsRe: /colour of sheath/i, + key: null, // Will be found in row-specific attributes + }); + + addConstOrSmallList({ + name: locale === 'de' ? 'RoHS/REACH' : 'RoHS/REACH', + existsRe: /rohs.*reach/i, + key: null, // Will be found in row-specific attributes + }); + + product.attributes = attrs; + + if (process.env.PDF_DEBUG_EXCEL === '1') { + console.log(`[excel] enriched product ${product.id} (${product.slug ?? stripHtml(product.name)}) with ${cfgOptions.length} configurations from excel`); + } +} + +function ensureExcelRowSpecificAttributes(product: ProductData, locale: 'en' | 'de'): void { + const rows = findExcelRowsForProduct(product); + if (!rows.length) return; + + const crossSectionAttr = + findAttr(product, /configuration|konfiguration|aufbau|bezeichnung/i) || + findAttr(product, /number of cores and cross-section|querschnitt|cross.?section|mm²|mm2/i); + if (!crossSectionAttr || !crossSectionAttr.options?.length) return; + + const rowCount = crossSectionAttr.options.length; + // Only enrich row-specific columns when row counts match (avoid wrong mapping). + if (rows.length !== rowCount) return; + + const sample = rows[0] || {}; + + const keyOuter = guessColumnKey(sample, [/outer diameter \(approx\.?\)/i, /outer diameter of cable/i, /outer diameter\b/i, /diameter over screen/i]); + const keyWeight = guessColumnKey(sample, [/weight \(approx\.?\)/i, /cable weight/i, /\bweight\b/i]); + const keyDcRes = guessColumnKey(sample, [/dc resistance at 20/i, /maximum resistance of conductor/i, /resistance conductor/i]); + const keyCap = guessColumnKey(sample, [/capacitance/i]); + const keyIndTrefoil = guessColumnKey(sample, [/inductance,?\s*trefoil/i]); + const keyIndAirFlat = guessColumnKey(sample, [/inductance in air,?\s*flat/i]); + const keyIndGroundFlat = guessColumnKey(sample, [/inductance in ground,?\s*flat/i]); + const keyIairTrefoil = guessColumnKey(sample, [/current ratings in air,?\s*trefoil/i]); + const keyIairFlat = guessColumnKey(sample, [/current ratings in air,?\s*flat/i]); + const keyIgroundTrefoil = guessColumnKey(sample, [/current ratings in ground,?\s*trefoil/i]); + const keyIgroundFlat = guessColumnKey(sample, [/current ratings in ground,?\s*flat/i]); + const keyScCond = guessColumnKey(sample, [/conductor shortcircuit current/i]); + const keyScScreen = guessColumnKey(sample, [/screen shortcircuit current/i]); + const keyBend = guessColumnKey(sample, [/bending radius/i, /min\. bending radius/i]); + + // Additional row-specific technical data + const keyConductorDiameter = guessColumnKey(sample, [/conductor diameter/i, /diameter conductor/i]); + const keyInsulationThickness = guessColumnKey(sample, [/nominal insulation thickness/i, /insulation thickness/i]); + const keySheathThickness = guessColumnKey(sample, [/nominal sheath thickness/i, /minimum sheath thickness/i, /sheath thickness/i]); + const keyCapacitance = guessColumnKey(sample, [/capacitance/i]); + const keyInductanceTrefoil = guessColumnKey(sample, [/inductance.*trefoil/i]); + const keyInductanceAirFlat = guessColumnKey(sample, [/inductance.*air.*flat/i]); + const keyInductanceGroundFlat = guessColumnKey(sample, [/inductance.*ground.*flat/i]); + const keyCurrentAirTrefoil = guessColumnKey(sample, [/current.*air.*trefoil/i]); + const keyCurrentAirFlat = guessColumnKey(sample, [/current.*air.*flat/i]); + const keyCurrentGroundTrefoil = guessColumnKey(sample, [/current.*ground.*trefoil/i]); + const keyCurrentGroundFlat = guessColumnKey(sample, [/current.*ground.*flat/i]); + const keyHeatingTimeTrefoil = guessColumnKey(sample, [/heating.*time.*trefoil/i]); + const keyHeatingTimeFlat = guessColumnKey(sample, [/heating.*time.*flat/i]); + + const get = (k: string | null) => rows.map(r => normalizeValue(String(r?.[k ?? ''] ?? ''))); + const withUnit = (vals: string[], unit: string) => vals.map(v => (v && looksNumeric(v) ? `${v} ${unit}` : v)); + + // Use labels that are already recognized by the existing PDF regexes. + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Außen-Ø' : 'Outer diameter', + options: withUnit(get(keyOuter), 'mm'), + expectedLen: rowCount, + existsRe: /outer\s*diameter|außen\s*durchmesser|außen-?ø/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Gewicht' : 'Weight', + options: withUnit(get(keyWeight), 'kg/km'), + expectedLen: rowCount, + existsRe: /\bweight\b|gewicht/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'DC-Leiterwiderstand (20C)' : 'DC resistance at 20 C', + options: withUnit(get(keyDcRes), 'Ohm/km'), + expectedLen: rowCount, + existsRe: /dc\s*resistance|max(?:imum)?\s*resistance|resistance\s+conductor|leiterwiderstand/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Kapazität (ca.)' : 'Capacitance (approx.)', + options: withUnit(get(keyCap), 'uF/km'), + expectedLen: rowCount, + existsRe: /capacitance|kapazit/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Induktivität, trefoil (ca.)' : 'Inductance, trefoil (approx.)', + options: withUnit(get(keyIndTrefoil), 'mH/km'), + expectedLen: rowCount, + existsRe: /inductance,?\s*trefoil/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Induktivität in Luft, flach (ca.)' : 'Inductance in air, flat (approx.)', + options: withUnit(get(keyIndAirFlat), 'mH/km'), + expectedLen: rowCount, + existsRe: /inductance\s+in\s+air,?\s*flat/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Induktivität im Erdreich, flach (ca.)' : 'Inductance in ground, flat (approx.)', + options: withUnit(get(keyIndGroundFlat), 'mH/km'), + expectedLen: rowCount, + existsRe: /inductance\s+in\s+ground,?\s*flat/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Strombelastbarkeit in Luft, trefoil' : 'Current ratings in air, trefoil', + options: withUnit(get(keyIairTrefoil), 'A'), + expectedLen: rowCount, + existsRe: /current\s+ratings\s+in\s+air,?\s*trefoil/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Strombelastbarkeit in Luft, flach' : 'Current ratings in air, flat', + options: withUnit(get(keyIairFlat), 'A'), + expectedLen: rowCount, + existsRe: /current\s+ratings\s+in\s+air,?\s*flat/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Strombelastbarkeit im Erdreich, trefoil' : 'Current ratings in ground, trefoil', + options: withUnit(get(keyIgroundTrefoil), 'A'), + expectedLen: rowCount, + existsRe: /current\s+ratings\s+in\s+ground,?\s*trefoil/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Strombelastbarkeit im Erdreich, flach' : 'Current ratings in ground, flat', + options: withUnit(get(keyIgroundFlat), 'A'), + expectedLen: rowCount, + existsRe: /current\s+ratings\s+in\s+ground,?\s*flat/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Kurzschlussstrom Leiter' : 'Conductor shortcircuit current', + options: withUnit(get(keyScCond), 'kA'), + expectedLen: rowCount, + existsRe: /conductor\s+shortcircuit\s+current/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Kurzschlussstrom Schirm' : 'Screen shortcircuit current', + options: withUnit(get(keyScScreen), 'kA'), + expectedLen: rowCount, + existsRe: /screen\s+shortcircuit\s+current/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Biegeradius (min.)' : 'Bending radius (min.)', + options: withUnit(get(keyBend), 'mm'), + expectedLen: rowCount, + existsRe: /bending\s*radius|biegeradius/i, + }); + + // Additional row-specific technical data + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Leiterdurchmesser' : 'Conductor diameter', + options: withUnit(get(keyConductorDiameter), 'mm'), + expectedLen: rowCount, + existsRe: /conductor diameter|diameter conductor/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Isolationsdicke' : 'Insulation thickness', + options: withUnit(get(keyInsulationThickness), 'mm'), + expectedLen: rowCount, + existsRe: /insulation thickness|nominal insulation thickness/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Manteldicke' : 'Sheath thickness', + options: withUnit(get(keySheathThickness), 'mm'), + expectedLen: rowCount, + existsRe: /sheath thickness|nominal sheath thickness/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Kapazität' : 'Capacitance', + options: withUnit(get(keyCapacitance), 'uF/km'), + expectedLen: rowCount, + existsRe: /capacitance/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Induktivität trefoil' : 'Inductance trefoil', + options: withUnit(get(keyInductanceTrefoil), 'mH/km'), + expectedLen: rowCount, + existsRe: /inductance.*trefoil/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Induktivität Luft flach' : 'Inductance air flat', + options: withUnit(get(keyInductanceAirFlat), 'mH/km'), + expectedLen: rowCount, + existsRe: /inductance.*air.*flat/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Induktivität Erdreich flach' : 'Inductance ground flat', + options: withUnit(get(keyInductanceGroundFlat), 'mH/km'), + expectedLen: rowCount, + existsRe: /inductance.*ground.*flat/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Strombelastbarkeit Luft trefoil' : 'Current rating air trefoil', + options: withUnit(get(keyCurrentAirTrefoil), 'A'), + expectedLen: rowCount, + existsRe: /current.*air.*trefoil/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Strombelastbarkeit Luft flach' : 'Current rating air flat', + options: withUnit(get(keyCurrentAirFlat), 'A'), + expectedLen: rowCount, + existsRe: /current.*air.*flat/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Strombelastbarkeit Erdreich trefoil' : 'Current rating ground trefoil', + options: withUnit(get(keyCurrentGroundTrefoil), 'A'), + expectedLen: rowCount, + existsRe: /current.*ground.*trefoil/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Strombelastbarkeit Erdreich flach' : 'Current rating ground flat', + options: withUnit(get(keyCurrentGroundFlat), 'A'), + expectedLen: rowCount, + existsRe: /current.*ground.*flat/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Heizzeitkonstante trefoil' : 'Heating time constant trefoil', + options: withUnit(get(keyHeatingTimeTrefoil), 's'), + expectedLen: rowCount, + existsRe: /heating.*time.*trefoil/i, + }); + + pushRowAttrIfMissing({ + product, + name: locale === 'de' ? 'Heizzeitkonstante flach' : 'Heating time constant flat', + options: withUnit(get(keyHeatingTimeFlat), 's'), + expectedLen: rowCount, + existsRe: /heating.*time.*flat/i, + }); +} + +function getProductUrl(product: ProductData): string | null { + if (!product.path) return null; + return `https://klz-cables.com${product.path}`; +} + +function drawKeyValueGrid(args: { + title: string; + items: Array<{ label: string; value: string }>; + newPage: () => number; + getPage: () => PDFPage; + page: PDFPage; + y: number; + margin: number; + contentWidth: number; + contentMinY: number; + font: PDFFont; + fontBold: PDFFont; + navy: ReturnType; + darkGray: ReturnType; + mediumGray: ReturnType; + lightGray?: ReturnType; + almostWhite?: ReturnType; + allowNewPage?: boolean; + boxed?: boolean; +}): number { + let { title, items, newPage, getPage, page, y, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, mediumGray } = args; + const allowNewPage = args.allowNewPage ?? true; + const boxed = args.boxed ?? false; + + const lightGray = args.lightGray ?? rgb(0.9020, 0.9137, 0.9294); + const almostWhite = args.almostWhite ?? rgb(0.9725, 0.9765, 0.9804); + + // Inner layout (boxed vs. plain) + // Keep a strict spacing system for more professional datasheets. + const padX = boxed ? 16 : 0; + const padY = boxed ? 14 : 0; + const xBase = margin + padX; + const innerWidth = contentWidth - padX * 2; + const colGap = 16; + const colW = (innerWidth - colGap) / 2; + const rowH = 24; + const headerH = boxed ? 22 : 0; + + const drawBoxFrame = (boxTopY: number, rowsCount: number) => { + const boxH = padY + headerH + rowsCount * rowH + padY; + page = getPage(); + page.drawRectangle({ + x: margin, + y: boxTopY - boxH, + width: contentWidth, + height: boxH, + borderColor: lightGray, + borderWidth: 1, + color: rgb(1, 1, 1), + }); + // Header band for the title + page.drawRectangle({ + x: margin, + y: boxTopY - headerH, + width: contentWidth, + height: headerH, + color: almostWhite, + }); + return boxH; + }; + + const drawTitle = () => { + page = getPage(); + if (boxed) { + // Align title inside the header band. + page.drawText(title, { x: xBase, y: y - 15, size: 11, font: fontBold, color: navy }); + // Divider line below header band + page.drawLine({ + start: { x: margin, y: y - headerH }, + end: { x: margin + contentWidth, y: y - headerH }, + thickness: 0.75, + color: lightGray, + }); + y -= headerH + padY; + } else { + page.drawText(title, { x: margin, y, size: 10, font: fontBold, color: navy }); + y -= 16; + } + }; + + if (y - 22 < contentMinY) { + if (!allowNewPage) return contentMinY - 1; + y = newPage(); + } + page = getPage(); + + // Boxed + multi-page: render separate boxes per page segment. + if (boxed && allowNewPage) { + let i = 0; + while (i < items.length) { + // Compute how many rows fit in the remaining space. + const available = y - contentMinY; + const maxRows = Math.max(1, Math.floor((available - (headerH + padY * 2)) / rowH)); + const maxItems = Math.max(2, maxRows * 2); + const slice = items.slice(i, i + maxItems); + const rowsCount = Math.ceil(slice.length / 2); + const neededH = padY + headerH + rowsCount * rowH + padY; + + if (y - neededH < contentMinY) y = newPage(); + drawBoxFrame(y, rowsCount); + + drawTitle(); + let rowY = y; + for (let j = 0; j < slice.length; j++) { + const col = j % 2; + const x = xBase + col * (colW + colGap); + const { label, value } = slice[j]; + page.drawText(label, { x, y: rowY, size: 7.5, font: fontBold, color: mediumGray, maxWidth: colW }); + page.drawText(value, { x, y: rowY - 12, size: 9.5, font, color: darkGray, maxWidth: colW }); + if (col === 1) rowY -= rowH; + } + + y = rowY - rowH - padY; + i += slice.length; + if (i < items.length) y = newPage(); + } + return y; + } + + // Boxed single-segment (current page) or plain continuation. + if (boxed && items.length) { + const rows = Math.ceil(items.length / 2); + const neededH = padY + headerH + rows * rowH + padY; + if (y - neededH < contentMinY) { + if (!allowNewPage) return contentMinY - 1; + y = newPage(); + } + drawBoxFrame(y, rows); + } + + drawTitle(); + + let rowY = y; + for (let i = 0; i < items.length; i++) { + const col = i % 2; + const x = xBase + col * (colW + colGap); + const { label, value } = items[i]; + + if (col === 0 && rowY - rowH < contentMinY) { + if (!allowNewPage) return contentMinY - 1; + y = newPage(); + page = getPage(); + if (!boxed) drawTitle(); + rowY = y; + } + + // Don't truncate - allow text to fit naturally + page.drawText(label, { x, y: rowY, size: 7.5, font: fontBold, color: mediumGray }); + page.drawText(value, { x, y: rowY - 12, size: 9.5, font, color: darkGray }); + + if (col === 1) rowY -= rowH; + } + + return boxed ? rowY - rowH - padY : rowY - rowH; +} + +function ensureOutputDir(): void { + if (!fs.existsSync(CONFIG.outputDir)) { + fs.mkdirSync(CONFIG.outputDir, { recursive: true }); + } +} + +const stripHtml = (html: string): string => { + if (!html) return ''; + // IMPORTANT: Keep umlauts and common Latin-1 chars (e.g. ü/ö/ä/ß) for DE PDFs. + // pdf-lib's StandardFonts cover WinAnsi; we only normalize “problematic” typography. + let text = html.replace(/<[^>]*>/g, '').normalize('NFC'); + text = text + // whitespace normalization + .replace(/[\u00A0\u202F]/g, ' ') // nbsp / narrow nbsp + // typography normalization + .replace(/[\u2013\u2014]/g, '-') // en/em dash + .replace(/[\u2018\u2019]/g, "'") // curly single quotes + .replace(/[\u201C\u201D]/g, '"') // curly double quotes + .replace(/\u2026/g, '...') // ellipsis + // symbols that can be missing in some encodings + .replace(/[\u2022]/g, '·') // bullet + // math symbols (WinAnsi can't encode these) + .replace(/[\u2264]/g, '<=') // ≤ + .replace(/[\u2265]/g, '>=') // ≥ + .replace(/[\u2248]/g, '~') // ≈ + // electrical symbols (keep meaning; avoid encoding errors) + .replace(/[\u03A9\u2126]/g, 'Ohm') // Ω / Ω + // micro sign / greek mu (WinAnsi can't encode these reliably) + .replace(/[\u00B5\u03BC]/g, 'u') // µ / μ + // directional arrows (WinAnsi can't encode these) + .replace(/[\u2193]/g, 'v') // ↓ + .replace(/[\u2191]/g, '^') // ↑ + // degree symbol (WinAnsi-safe; keep for engineering units like °C) + .replace(/[\u00B0]/g, '°'); + + // Remove control chars, keep all printable unicode. + text = text.replace(/[\u0000-\u001F\u007F]/g, ''); + return text.replace(/\s+/g, ' ').trim(); +}; + +const getLabels = (locale: 'en' | 'de') => ({ + en: { + datasheet: 'PRODUCT DATASHEET', + description: 'DESCRIPTION', + specs: 'TECHNICAL SPECIFICATIONS', + crossSection: 'CROSS-SECTION DATA', + categories: 'CATEGORIES', + sku: 'SKU', + }, + de: { + datasheet: 'PRODUKTDATENBLATT', + description: 'BESCHREIBUNG', + specs: 'TECHNISCHE SPEZIFIKATIONEN', + crossSection: 'QUERSCHNITTSDATEN', + categories: 'KATEGORIEN', + sku: 'ARTIKELNUMMER', + }, +})[locale]; + +const generateFileName = (product: ProductData, locale: 'en' | 'de'): string => { + const baseName = product.slug || product.translationKey || `product-${product.id}`; + const cleanSlug = baseName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); + return `${cleanSlug}-${locale}.pdf`; +}; + +function wrapText(text: string, font: PDFFont, fontSize: number, maxWidth: number): string[] { + const words = text.split(' '); + const lines: string[] = []; + let currentLine = ''; + + const isOrphanWord = (w: string) => { + // Avoid ugly single short words on their own line in DE/EN (e.g. “im”, “in”, “to”). + // This is a typography/UX improvement for datasheets. + const s = w.trim(); + return s.length > 0 && s.length <= 2; + }; + + for (let i = 0; i < words.length; i++) { + const word = words[i]; + const next = i + 1 < words.length ? words[i + 1] : ''; + const testLine = currentLine ? `${currentLine} ${word}` : word; + + if (font.widthOfTextAtSize(testLine, fontSize) <= maxWidth) { + // Orphan control: if adding the *next* word would overflow, don't end the line with a tiny orphan. + // Example: "... mechanischen im" + "Belastungen" should become "... mechanischen" / "im Belastungen ...". + if (currentLine && next && isOrphanWord(word)) { + const testWithNext = `${testLine} ${next}`; + if (font.widthOfTextAtSize(testWithNext, fontSize) > maxWidth) { + lines.push(currentLine); + currentLine = word; + continue; + } + } + + currentLine = testLine; + } else { + if (currentLine) lines.push(currentLine); + currentLine = word; + } + } + if (currentLine) lines.push(currentLine); + return lines; +} + +function ellipsizeToWidth(text: string, font: PDFFont, fontSize: number, maxWidth: number): string { + // WinAnsi-safe ellipsis ("...") + const t = normalizeValue(text); + if (!t) return ''; + if (font.widthOfTextAtSize(t, fontSize) <= maxWidth) return t; + + const ellipsis = '...'; + const eW = font.widthOfTextAtSize(ellipsis, fontSize); + if (eW >= maxWidth) return ''; + + // Binary search for max prefix that fits. + let lo = 0; + let hi = t.length; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + const s = t.slice(0, mid); + if (font.widthOfTextAtSize(s, fontSize) + eW <= maxWidth) lo = mid; + else hi = mid - 1; + } + const cut = Math.max(0, lo); + return `${t.slice(0, cut)}${ellipsis}`; +} + +function resolveMediaToLocalPath(urlOrPath: string | null | undefined): string | null { + if (!urlOrPath) return null; + + // 1) Already public-relative. + if (urlOrPath.startsWith('/')) return urlOrPath; + + // 2) Some datasets store "media/..." without leading slash. + if (/^media\//i.test(urlOrPath)) return `/${urlOrPath}`; + + // 3) Asset-map can return a few different shapes; normalize them. + const mapped = ASSET_MAP[urlOrPath]; + if (mapped) { + if (mapped.startsWith('/')) return mapped; + if (/^public\//i.test(mapped)) return `/${mapped.replace(/^public\//i, '')}`; + if (/^media\//i.test(mapped)) return `/${mapped}`; + return mapped; + } + + // 4) Fallback (remote URL or unrecognized local path). + return urlOrPath; +} + +async function fetchBytes(url: string): Promise { + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`); + return new Uint8Array(await res.arrayBuffer()); +} + +async function readBytesFromPublic(localPath: string): Promise { + const abs = path.join(PUBLIC_DIR, localPath.replace(/^\//, '')); + return new Uint8Array(fs.readFileSync(abs)); +} + +function transformLogoSvgToPrintBlack(svg: string): string { + // Our source logo is white-on-transparent (for dark headers). For print (white page), we need dark fills. + // Keep it simple: replace fill white with KLZ navy. + return svg + .replace(/fill\s*:\s*white/gi, 'fill:#0E2A47') + .replace(/fill\s*=\s*"white"/gi, 'fill="#0E2A47"') + .replace(/fill\s*=\s*'white'/gi, "fill='#0E2A47'"); +} + +async function toPngBytes(inputBytes: Uint8Array, inputHint: string): Promise { + // pdf-lib supports PNG/JPG. We normalize everything (webp/svg/jpg/png) to PNG to keep embedding simple. + const ext = (path.extname(inputHint).toLowerCase() || '').replace('.', ''); + if (ext === 'png') return inputBytes; + + // Special-case the logo SVG to render as dark for print. + if (ext === 'svg' && /\/media\/logo\.svg$/i.test(inputHint)) { + const svg = Buffer.from(inputBytes).toString('utf8'); + inputBytes = new Uint8Array(Buffer.from(transformLogoSvgToPrintBlack(svg), 'utf8')); + } + + const sharp = await getSharp(); + // Preserve alpha where present (some product images are transparent). + return new Uint8Array(await sharp(Buffer.from(inputBytes)).png().toBuffer()); +} + +type TableColumn = { + key?: string; + label: string; + get: (rowIndex: number) => string; +}; + +function buildProductAttrIndex(product: ProductData): Record { + const idx: Record = {}; + for (const a of product.attributes || []) { + idx[normalizeValue(a.name).toLowerCase()] = a; + } + return idx; +} + +function getAttrCellValue(attr: ProductData['attributes'][number] | undefined, rowIndex: number, rowCount: number): string { + if (!attr) return ''; + if (!attr.options || attr.options.length === 0) return ''; + if (rowCount > 0 && attr.options.length === rowCount) return normalizeValue(attr.options[rowIndex]); + if (attr.options.length === 1) return normalizeValue(attr.options[0]); + // Unknown mapping: do NOT guess (this was the main source of "wrong" tables). + return ''; +} + +function drawTableChunked(args: { + title: string; + configRows: string[]; + columns: Array<{ key: string; label: string; get: (rowIndex: number) => string }>; + firstColLabel?: string; + dense?: boolean; + onePage?: boolean; + cellFormatter?: (value: string, columnKey: string) => string; + locale: 'en' | 'de'; + newPage: () => number; + getPage: () => PDFPage; + page: PDFPage; + y: number; + margin: number; + contentWidth: number; + contentMinY: number; + font: PDFFont; + fontBold: PDFFont; + navy: ReturnType; + darkGray: ReturnType; + lightGray: ReturnType; + almostWhite: ReturnType; + maxDataColsPerTable: number; +}): number { + let { + title, + configRows, + columns, + newPage, + getPage, + page, + y, + margin, + contentWidth, + contentMinY, + font, + fontBold, + navy, + darkGray, + lightGray, + almostWhite, + maxDataColsPerTable, + } = args; + + const dense = args.dense ?? false; + const onePage = args.onePage ?? false; + const formatCell = args.cellFormatter ?? ((v: string) => v); + + // pdf-lib will wrap text when `maxWidth` is set. + // For dense technical tables we want *no wrapping* (clip instead), so we replace spaces with NBSP. + const noWrap = (s: string) => (dense ? String(s).replace(/ /g, '\u00A0') : String(s)); + + // pdf-lib does not clip text to maxWidth; it only uses it for line breaking. + // To prevent header/body text from overlapping into neighboring columns, we manually truncate. + const truncateToWidth = (text: string, f: PDFFont, size: number, maxW: number): string => { + // IMPORTANT: do NOT call normalizeValue() here. + // We may have inserted NBSP to force no-wrapping; normalizeValue() would convert it back to spaces. + const t = String(text) + .replace(/[\r\n\t]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + if (!t) return ''; + if (maxW <= 4) return ''; + if (f.widthOfTextAtSize(t, size) <= maxW) return t; + const ellipsis = '…'; + const eW = f.widthOfTextAtSize(ellipsis, size); + if (eW >= maxW) return ''; + + // Binary search for max prefix that fits. + let lo = 0; + let hi = t.length; + while (lo < hi) { + const mid = Math.ceil((lo + hi) / 2); + const s = t.slice(0, mid); + if (f.widthOfTextAtSize(s, size) + eW <= maxW) lo = mid; + else hi = mid - 1; + } + const cut = Math.max(0, lo); + return `${t.slice(0, cut)}${ellipsis}`; + }; + + // Dense table style (more columns, compact typography) + // IMPORTANT: header labels must stay on a single line (no wrapped/stacked headers). + const headerH = dense ? 16 : 16; + let rowH = dense ? 16 : 16; // Increased to prevent row overlap + let bodyFontSize = dense ? 6.5 : 8; + let headerFontSize = dense ? 6.5 : 8; + + const headerFill = dense ? rgb(0.42, 0.44, 0.45) : lightGray; + const headerText = dense ? rgb(1, 1, 1) : navy; + let cellPadX = dense ? 4 : 6; + + const headerLabelFor = (col: { label: string; key?: string }): string => { + // Dense tables: use compact cable industry abbreviations + const raw = normalizeValue(col.label); + if (!dense) return raw; + + const b = raw.toLowerCase(); + const key = col.key || ''; + const hasUnit = /\(|\[/.test(raw); + + const labelWithUnit = (abbr: string, defaultUnit: string): string => { + // Prefer the actual unit we already have in the incoming label (from Excel units row). + // Example: raw = "DC resistance at 20 °C (Ω/km)" => keep Ω/km. + const m = raw.match(/[\[(]\s*([^\])]+?)\s*[\])]/); + const unit = normalizeValue(m?.[1] || '') || defaultUnit; + // WinAnsi safe: Ω => Ohm, µ => u (handled by stripHtml/normalizeValue) + const unitSafe = unit.replace(/Ω/gi, 'Ohm').replace(/[\u00B5\u03BC]/g, 'u'); + return `${abbr} [${unitSafe}]`; + }; + + // Cable industry standard abbreviations (compact, WinAnsi-safe) + // Column set: + // - Bezeichnung (first column) + // - DI, RI, Wi, Ibl, Ibe, Ik, Wm, Rbv, Ø, Fzv, Al, Cu, G + if (key === 'configuration') return args.locale === 'de' ? 'Bezeichnung' : 'Designation'; + if (key === 'DI' || /diameter\s+over\s+insulation/i.test(b)) return labelWithUnit('DI', 'mm'); + if (key === 'RI' || /dc\s*resistance/i.test(b) || /resistance\s+conductor/i.test(b)) return labelWithUnit('RI', 'Ohm/km'); + if (key === 'Wi' || /insulation\s+thickness/i.test(b) || /nominal\s+insulation\s+thickness/i.test(b)) return labelWithUnit('Wi', 'mm'); + if (key === 'Ibl' || /current\s+ratings\s+in\s+air.*trefoil/i.test(b)) return labelWithUnit('Ibl', 'A'); + if (key === 'Ibe' || /current\s+ratings\s+in\s+ground.*trefoil/i.test(b)) return labelWithUnit('Ibe', 'A'); + if (key === 'Ik_cond' || /conductor.*shortcircuit/i.test(b)) return labelWithUnit('Ik', 'kA'); + if (key === 'Wm' || /sheath\s+thickness/i.test(b) || /minimum\s+sheath\s+thickness/i.test(b)) return labelWithUnit('Wm', 'mm'); + // Rbv can be given in mm or as xD (LV/solar). We keep the unit from the label. + if (key === 'Rbv' || /bending\s+radius/i.test(b)) return labelWithUnit('Rbv', 'mm'); + if (key === 'Ø' || /outer\s+diameter/i.test(b) || /outer\s+diameter\s+of\s+cable/i.test(b)) return labelWithUnit('Ø', 'mm'); + if (key === 'Fzv' || /pulling\s+force/i.test(b)) return labelWithUnit('Fzv', 'N'); + if (key === 'Al') return 'Al'; + if (key === 'Cu') return 'Cu'; + if (key === 'G' || /\bweight\b/i.test(b) || /cable\s+weight/i.test(b)) return labelWithUnit('G', 'kg/km'); + + // Cross-section (always needed as first column) + if (/number of cores and cross-section/i.test(b) || /querschnitt/i.test(b)) { + return args.locale === 'de' ? 'QS' : 'CS'; + } + + // Additional technical columns (WinAnsi-compatible abbreviations) + if (key === 'cond_diam') return labelWithUnit('D_cond', 'mm'); + if (key === 'D_screen') return labelWithUnit('D_scr', 'mm'); + if (key === 'S_screen') return labelWithUnit('A_scr', 'mm2'); + if (key === 'cap') return labelWithUnit('C', 'uF/km'); + if (key === 'X') return labelWithUnit('X', 'Ohm/km'); + if (key === 'ind_trefoil') return labelWithUnit('L_t', 'mH/km'); + if (key === 'ind_air_flat') return labelWithUnit('L_af', 'mH/km'); + if (key === 'ind_ground_flat') return labelWithUnit('L_gf', 'mH/km'); + if (key === 'cur_air_flat') return labelWithUnit('I_af', 'A'); + if (key === 'cur_ground_flat') return labelWithUnit('I_gf', 'A'); + if (key === 'heat_trefoil') return labelWithUnit('t_th_t', 's'); + if (key === 'heat_flat') return labelWithUnit('t_th_f', 's'); + if (key === 'max_op_temp') return labelWithUnit('T_op', '°C'); + if (key === 'max_sc_temp') return labelWithUnit('T_sc', '°C'); + if (key === 'temp_range') return labelWithUnit('T', '°C'); + if (key === 'min_store_temp') return labelWithUnit('T_st', '°C'); + if (key === 'min_lay_temp') return labelWithUnit('T_lay', '°C'); + if (key === 'test_volt') return labelWithUnit('U_test', 'kV'); + if (key === 'rated_volt') return labelWithUnit('U_0/U', 'kV'); + if (key === 'conductor') return 'Cond'; + if (key === 'insulation') return 'Iso'; + if (key === 'sheath') return 'Sh'; + if (key === 'norm') return 'Norm'; + if (key === 'standard') return 'Std'; + if (key === 'cpr') return 'CPR'; + if (key === 'flame') return 'FR'; + if (key === 'packaging') return 'Pack'; + if (key === 'ce') return 'CE'; + if (key === 'shape') return 'Shape'; + if (key === 'color_ins') return 'C_iso'; + if (key === 'color_sheath') return 'C_sh'; + if (key === 'tape_below') return 'Tape v'; + if (key === 'copper_screen') return 'Cu Scr'; + if (key === 'tape_above') return 'Tape ^'; + if (key === 'al_foil') return 'Al Foil'; + + // Fallback: keep Excel label (will be truncated) + return raw; + }; + + // Always include a first column (configuration / cross-section). + const configCol = { + key: 'configuration', + label: args.firstColLabel || (args.locale === 'de' ? 'Konfiguration' : 'Configuration'), + get: (i: number) => normalizeValue(configRows[i] || ''), + }; + + const chunks: Array = []; + for (let i = 0; i < columns.length; i += maxDataColsPerTable) { + chunks.push(columns.slice(i, i + maxDataColsPerTable)); + } + + for (let ci = 0; ci < Math.max(1, chunks.length); ci++) { + // Ensure we always draw on the current page reference. + page = getPage(); + + const chunkCols = chunks.length ? chunks[ci] : []; + // UX: never show chunk fractions like "(1/2)". + // If we need multiple chunks, we keep the same title and paginate naturally. + const chunkTitle = title; + const tableCols: TableColumn[] = [configCol, ...chunkCols]; + + // Header labels (may be simplified for dense tables). + const headerLabels = tableCols.map(c => headerLabelFor({ label: c.label, key: c.key })); + + // Auto-fit column widths to content + // Calculate required width for each column based on header and sample data + // For dense tables with many columns, use more generous minimums + const isDenseManyColumns = dense && tableCols.length >= 12; + + // When rendering many columns, auto-compact typography BEFORE measuring widths. + // This prevents overflow where the later columns end up drawn off-page. + if (isDenseManyColumns) { + bodyFontSize = Math.max(5.3, Math.min(bodyFontSize, 5.8)); + headerFontSize = Math.max(5.1, Math.min(headerFontSize, 5.5)); + rowH = Math.max(9, Math.min(rowH, 10)); + cellPadX = 3; + } + + const minColWidth = isDenseManyColumns ? 24 : 20; // Minimum width in points + const maxColWidth = isDenseManyColumns ? 110 : 120; // Cap widths for dense tables + const colGap = isDenseManyColumns ? 1 : 2; // Gap between columns (smaller for dense) + + // Calculate required width for each column + const requiredWidths = tableCols.map((col, i) => { + const key = col.key || ''; + const headerLabel = headerLabels[i] || headerLabelFor({ label: col.label, key: col.key }); + + // Prefer key-aware constraints so the table is readable on A4. + // - configuration needs space (long designations) + // - numeric columns should stay compact + const isCfg = key === 'configuration'; + // NOTE: keep configuration column capped; otherwise it starves the numeric columns. + const cfgMaxAbs = dense ? 150 : 180; + const cfgMaxRel = Math.floor(contentWidth * (dense ? 0.26 : 0.30)); + const cfgMax = Math.max(120, Math.min(cfgMaxAbs, cfgMaxRel)); + const cfgMin = dense ? 90 : 120; + + const minW = isCfg ? cfgMin : minColWidth; + const maxW = isCfg ? cfgMax : (isDenseManyColumns ? 72 : maxColWidth); + + // Measure header width + const headerWidth = fontBold.widthOfTextAtSize(headerLabel, headerFontSize); + + // Measure sample data widths using the *rendered* value (apply cell formatter). + // Using only raw values can under-estimate (e.g. locale decimal, derived values). + let maxDataWidth = 0; + const sampleRows = Math.min(10, configRows.length); + for (let r = 0; r < sampleRows; r++) { + const rawVal = col.get(r); + const rendered = formatCell(rawVal, key); + const dataWidth = font.widthOfTextAtSize(String(rendered), bodyFontSize); + maxDataWidth = Math.max(maxDataWidth, dataWidth); + } + + // Take the maximum of header and data + const padding = isDenseManyColumns ? cellPadX * 1.5 : cellPadX * 2; + const contentWidthNeeded = Math.max(headerWidth, maxDataWidth) + padding; + + // Clamp to min/max + return Math.max(minW, Math.min(maxW, contentWidthNeeded)); + }); + + // Calculate total required width (including gaps) + const baseTotal = requiredWidths.reduce((sum, w) => sum + w, 0) + (tableCols.length - 1) * colGap; + + // If we have extra space, give it primarily to the configuration column + // (best readability gain) and then distribute any remainder proportionally. + const widthsWithExtra = [...requiredWidths]; + if (baseTotal < contentWidth && tableCols.length > 0) { + let remaining = contentWidth - baseTotal; + + const cfgIndex = tableCols.findIndex(c => (c.key || '') === 'configuration'); + if (cfgIndex >= 0 && remaining > 0) { + // Only give the configuration column a controlled share of the remaining space. + // This keeps numeric columns readable and consistent across tables. + const cfgMaxAbs = dense ? 150 : 180; + const cfgMaxRel = Math.floor(contentWidth * (dense ? 0.26 : 0.30)); + const cfgMax = Math.max(120, Math.min(cfgMaxAbs, cfgMaxRel)); + + const cfgRemainingCap = Math.min(remaining, Math.floor(remaining * (dense ? 0.35 : 0.45))); + const add = Math.max(0, Math.min(cfgMax - widthsWithExtra[cfgIndex], cfgRemainingCap)); + widthsWithExtra[cfgIndex] += add; + remaining -= add; + } + + if (remaining > 0) { + const sum = widthsWithExtra.reduce((a, b) => a + b, 0) || 1; + for (let i = 0; i < widthsWithExtra.length; i++) { + if (remaining <= 0) break; + const share = widthsWithExtra[i] / sum; + const add = Math.min(remaining, remaining * share); + widthsWithExtra[i] += add; + remaining -= add; + } + } + } + + const totalRequiredWidth = widthsWithExtra.reduce((sum, w) => sum + w, 0) + (tableCols.length - 1) * colGap; + + // Scale to fit available content width if needed + // Dense tables MUST fit on the page; allow stronger scaling when needed. + let scaleFactor = totalRequiredWidth > contentWidth ? contentWidth / totalRequiredWidth : 1; + if (!isDenseManyColumns) { + // Keep regular tables from becoming too small. + scaleFactor = Math.max(scaleFactor, 0.9); + } + + // Scale widths (gaps are also scaled) + const widthsPt = widthsWithExtra.map(w => w * scaleFactor); + const scaledGap = colGap * scaleFactor; + + const ensureSpace = (needed: number) => { + if (y - needed < contentMinY) y = newPage(); + page = getPage(); + }; + + // One-page mode: adapt row height so the full voltage table can fit on the page. + if (onePage) { + const rows = configRows.length; + const available = y - contentMinY - 10 - 12 /*title*/; + const maxRowH = Math.floor((available - headerH) / Math.max(1, rows)); + rowH = Math.max(8, Math.min(rowH, maxRowH)); + bodyFontSize = Math.max(6, Math.min(bodyFontSize, rowH - 3)); + headerFontSize = Math.max(6, Math.min(headerFontSize, rowH - 3)); + } + + ensureSpace(14 + headerH + rowH * 2); + if (chunkTitle) { + page.drawText(chunkTitle, { + x: margin, + y, + size: 10, + font: fontBold, + color: navy, + }); + y -= dense ? 14 : 16; + } + + // If we are too close to the footer after the title, break before drawing the header. + if (y - headerH - rowH < contentMinY) { + y = newPage(); + page = getPage(); + if (chunkTitle) { + page.drawText(chunkTitle, { + x: margin, + y, + size: 10, + font: fontBold, + color: navy, + }); + y -= 16; + } + } + + const drawHeader = () => { + page = getPage(); + page.drawRectangle({ + x: margin, + y: y - headerH, + width: contentWidth, + height: headerH, + color: headerFill, + }); + + const headerTextY = y - headerH + Math.floor((headerH - headerFontSize) / 2) + 1; + let x = margin; + for (let i = 0; i < tableCols.length; i++) { + const hl = headerLabels[i] || headerLabelFor({ label: tableCols[i].label, key: tableCols[i].key }); + const colWidth = widthsPt[i]; + const colMaxW = colWidth - cellPadX * 2; + + // Overlap guard: always truncate to column width (pdf-lib doesn't clip by itself). + // Don't truncate headers - use auto-fit widths + page.drawText(noWrap(String(hl)), { + x: x + cellPadX, + y: headerTextY, + size: headerFontSize, + font: fontBold, + color: headerText, + // DO NOT set maxWidth in dense mode (it triggers wrapping instead of clipping). + ...(dense ? {} : { maxWidth: colMaxW }), + }); + x += colWidth + scaledGap; + } + y -= headerH; + }; + + drawHeader(); + + for (let r = 0; r < configRows.length; r++) { + if (!onePage && y - rowH < contentMinY) { + y = newPage(); + page = getPage(); + if (chunkTitle) { + page.drawText(chunkTitle, { + x: margin, + y, + size: 12, + font: fontBold, + color: navy, + }); + y -= 16; + } + drawHeader(); + } + + if (onePage && y - rowH < contentMinY) { + // In one-page mode we must not paginate. Clip remaining rows. + break; + } + + if (r % 2 === 0) { + page.drawRectangle({ + x: margin, + y: y - rowH, + width: contentWidth, + height: rowH, + color: almostWhite, + }); + } + + let x = margin; + for (let c = 0; c < tableCols.length; c++) { + const raw = tableCols[c].get(r); + const txt = formatCell(raw, tableCols[c].key || ''); + const colWidth = widthsPt[c]; + const colMaxW = colWidth - cellPadX * 2; + // Don't truncate - use auto-fit widths to ensure everything fits + page.drawText(noWrap(txt), { + x: x + cellPadX, + y: y - (rowH - 5), // Adjusted for new rowH of 16 + size: bodyFontSize, + font, + color: darkGray, + ...(dense ? {} : { maxWidth: colMaxW }), + }); + x += colWidth + scaledGap; + } + + y -= rowH; + } + + y -= dense ? 20 : 24; + } + + return y; +} + +async function loadEmbeddablePng( + src: string | null | undefined, +): Promise<{ pngBytes: Uint8Array; debugLabel: string } | null> { + const resolved = resolveMediaToLocalPath(src); + if (!resolved) return null; + + try { + // Prefer local files for stability and speed. + if (resolved.startsWith('/')) { + const bytes = await readBytesFromPublic(resolved); + return { pngBytes: await toPngBytes(bytes, resolved), debugLabel: resolved }; + } + + // Remote (fallback) + const bytes = await fetchBytes(resolved); + return { pngBytes: await toPngBytes(bytes, resolved), debugLabel: resolved }; + } catch { + return null; + } +} + +async function loadQrPng(data: string): Promise<{ pngBytes: Uint8Array; debugLabel: string } | null> { + // External QR generator (no extra dependency). This must stay resilient; if it fails, we fall back to URL text. + try { + const safe = encodeURIComponent(data); + const url = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${safe}`; + const bytes = await fetchBytes(url); + // Already PNG but normalize anyway. + return { pngBytes: await toPngBytes(bytes, url), debugLabel: url }; + } catch { + return null; + } +} + +type SectionDrawContext = { + pdfDoc: PDFDocument; + page: PDFPage; + width: number; + height: number; + margin: number; + contentWidth: number; + footerY: number; + contentMinY: number; + headerDividerY: number; + colors: { + navy: ReturnType; + mediumGray: ReturnType; + darkGray: ReturnType; + almostWhite: ReturnType; + lightGray: ReturnType; + headerBg: ReturnType; + }; + fonts: { + regular: PDFFont; + bold: PDFFont; + }; + labels: ReturnType; + product: ProductData; + locale: 'en' | 'de'; + logoImage: PDFImage | null; + qrImage: PDFImage | null; + qrUrl: string; +}; + +function drawFooter(ctx: SectionDrawContext): void { + const { page, width, margin, footerY, fonts, colors, locale } = ctx; + + page.drawLine({ + start: { x: margin, y: footerY + 14 }, + end: { x: width - margin, y: footerY + 14 }, + thickness: 0.75, + color: colors.lightGray, + }); + + // Left: site URL (always) + page.drawText(CONFIG.siteUrl, { + x: margin, + y: footerY, + size: 8, + font: fonts.regular, + color: colors.mediumGray, + }); + + const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + // Right: date + page number (page number filled in after rendering) + const rightText = dateStr; + page.drawText(rightText, { + x: width - margin - fonts.regular.widthOfTextAtSize(rightText, 8), + y: footerY, + size: 8, + font: fonts.regular, + color: colors.mediumGray, + }); +} + +function stampPageNumbers(pdfDoc: PDFDocument, fonts: { regular: PDFFont }, colors: { mediumGray: ReturnType }, margin: number, footerY: number): void { + const pages = pdfDoc.getPages(); + const total = pages.length; + for (let i = 0; i < total; i++) { + const page = pages[i]; + const { width } = page.getSize(); + const text = `${i + 1}/${total}`; + page.drawText(text, { + x: width - margin - fonts.regular.widthOfTextAtSize(text, 8), + y: footerY - 12, + size: 8, + font: fonts.regular, + color: colors.mediumGray, + }); + } +} + +function drawHeader(ctx: SectionDrawContext, yStart: number): number { + const { page, width, margin, contentWidth, fonts, colors, logoImage, qrImage, qrUrl, labels, product } = ctx; + + // Cable-industry look: calm, engineered header with right-aligned meta. + // Keep header compact to free vertical space for technical tables. + const headerH = 52; + const dividerY = yStart - headerH; + ctx.headerDividerY = dividerY; + + page.drawRectangle({ + x: 0, + y: dividerY, + width, + height: headerH, + color: colors.headerBg, + }); + + const qrSize = 36; + const qrGap = 12; + const rightReserved = qrImage ? qrSize + qrGap : 0; + + // Left: logo (preferred) or typographic fallback + if (logoImage) { + const maxLogoW = 120; + const maxLogoH = 24; + const scale = Math.min(maxLogoW / logoImage.width, maxLogoH / logoImage.height); + const w = logoImage.width * scale; + const h = logoImage.height * scale; + const logoY = dividerY + Math.round((headerH - h) / 2); + page.drawImage(logoImage, { + x: margin, + y: logoY, + width: w, + height: h, + }); + } else { + const baseY = dividerY + 22; + page.drawText('KLZ', { + x: margin, + y: baseY, + size: 22, + font: fonts.bold, + color: colors.navy, + }); + page.drawText('Cables', { + x: margin + fonts.bold.widthOfTextAtSize('KLZ', 22) + 4, + y: baseY + 2, + size: 10, + font: fonts.regular, + color: colors.mediumGray, + }); + } + + // Right: datasheet meta + QR (if available) + const metaRightEdge = width - margin - rightReserved; + const metaTitle = labels.datasheet; + const metaTitleSize = 9; + + const mtW = fonts.bold.widthOfTextAtSize(metaTitle, metaTitleSize); + // With SKU removed, vertically center the title within the header block. + const metaY = dividerY + Math.round(headerH / 2 - metaTitleSize / 2); + page.drawText(metaTitle, { + x: metaRightEdge - mtW, + y: metaY, + size: metaTitleSize, + font: fonts.bold, + color: colors.navy, + }); + + if (qrImage) { + const qrX = width - margin - qrSize; + const qrY = dividerY + Math.round((headerH - qrSize) / 2); + page.drawImage(qrImage, { x: qrX, y: qrY, width: qrSize, height: qrSize }); + } else { + // If QR generation failed, keep the URL available as a compact line. + const maxW = 260; + const urlLines = wrapText(qrUrl, fonts.regular, 8, maxW).slice(0, 1); + if (urlLines.length) { + const line = urlLines[0]; + const w = fonts.regular.widthOfTextAtSize(line, 8); + page.drawText(line, { + x: width - margin - w, + y: dividerY + 12, + size: 8, + font: fonts.regular, + color: colors.mediumGray, + }); + } + } + + // Divider line + page.drawLine({ + start: { x: margin, y: dividerY }, + end: { x: margin + contentWidth, y: dividerY }, + thickness: 0.75, + color: colors.lightGray, + }); + + // Content start: keep breathing room below the header, but use page height efficiently. + return dividerY - 22; +} + +function drawCrossSectionChipsRow(args: { + title: string; + configRows: string[]; + locale: 'en' | 'de'; + maxLinesCap?: number; + getPage: () => PDFPage; + page: PDFPage; + y: number; + margin: number; + contentWidth: number; + contentMinY: number; + font: PDFFont; + fontBold: PDFFont; + navy: ReturnType; + darkGray: ReturnType; + mediumGray: ReturnType; + lightGray: ReturnType; + almostWhite: ReturnType; +}): number { + let { title, configRows, locale, getPage, page, y, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, mediumGray, lightGray, almostWhite } = args; + + // Single-page rule: if we can't fit the block, stop. + const titleH = 12; + const summaryH = 12; + const chipH = 16; + const lineGap = 8; + const gapY = 10; + const minLines = 2; + const needed = titleH + summaryH + (chipH * minLines) + (lineGap * (minLines - 1)) + gapY; + if (y - needed < contentMinY) return contentMinY - 1; + + page = getPage(); + + // Normalize: keep only cross-section part, de-dupe, sort. + const itemsRaw = configRows + .map(r => splitConfig(r).crossSection) + .map(s => normalizeValue(s)) + .filter(Boolean); + + const seen = new Set(); + const items = itemsRaw.filter(v => (seen.has(v) ? false : (seen.add(v), true))); + + items.sort((a, b) => { + const pa = parseCoresAndMm2(a); + const pb = parseCoresAndMm2(b); + if (pa.cores !== null && pb.cores !== null && pa.cores !== pb.cores) return pa.cores - pb.cores; + if (pa.mm2 !== null && pb.mm2 !== null && pa.mm2 !== pb.mm2) return pa.mm2 - pb.mm2; + return a.localeCompare(b); + }); + + const total = items.length; + const parsed = items.map(parseCoresAndMm2).filter(p => p.cores !== null && p.mm2 !== null) as Array<{ cores: number; mm2: number }>; + const uniqueCores = Array.from(new Set(parsed.map(p => p.cores))).sort((a, b) => a - b); + const mm2Vals = parsed.map(p => p.mm2).sort((a, b) => a - b); + const mm2Min = mm2Vals.length ? mm2Vals[0] : null; + const mm2Max = mm2Vals.length ? mm2Vals[mm2Vals.length - 1] : null; + + page.drawText(title, { x: margin, y, size: 11, font: fontBold, color: navy }); + y -= titleH; + + const summaryParts: string[] = []; + summaryParts.push(locale === 'de' ? `Varianten: ${total}` : `Options: ${total}`); + if (uniqueCores.length) summaryParts.push((locale === 'de' ? 'Adern' : 'Cores') + `: ${uniqueCores.join(', ')}`); + if (mm2Min !== null && mm2Max !== null) summaryParts.push(`mm²: ${mm2Min}${mm2Max !== mm2Min ? `–${mm2Max}` : ''}`); + page.drawText(summaryParts.join(' · '), { x: margin, y, size: 8, font, color: mediumGray, maxWidth: contentWidth }); + y -= summaryH; + + // Tags (wrapping). Rectangular, engineered (no playful rounding). + const padX = 8; + const chipFontSize = 8; + const chipGap = 8; + const chipPadTop = 5; + + const startY = y - chipH; // baseline for first chip row + const maxLinesAvailable = Math.max(1, Math.floor((startY - contentMinY + lineGap) / (chipH + lineGap))); + // UX/Content priority: don't let cross-section tags consume the whole sheet. + // When technical data is dense, we cap this to keep specs visible. + const maxLines = Math.min(args.maxLinesCap ?? 2, maxLinesAvailable); + + const chipWidth = (text: string) => font.widthOfTextAtSize(text, chipFontSize) + padX * 2; + + type Placement = { text: string; x: number; y: number; w: number; variant: 'normal' | 'more' }; + + const layout = (texts: string[], includeMoreChip: boolean, moreText: string): { placements: Placement[]; shown: number } => { + const placements: Placement[] = []; + let x = margin; + let line = 0; + let cy = startY; + + const advanceLine = () => { + line += 1; + if (line >= maxLines) return false; + x = margin; + cy -= chipH + lineGap; + return true; + }; + + const tryPlace = (text: string, variant: 'normal' | 'more'): boolean => { + const w = chipWidth(text); + if (w > contentWidth) return false; + if (x + w > margin + contentWidth) { + if (!advanceLine()) return false; + } + placements.push({ text, x, y: cy, w, variant }); + x += w + chipGap; + return true; + }; + + let shown = 0; + for (let i = 0; i < texts.length; i++) { + if (!tryPlace(texts[i], 'normal')) break; + shown++; + } + + if (includeMoreChip) { + tryPlace(moreText, 'more'); + } + return { placements, shown }; + }; + + // Group by cores: label on the left, mm² tags to the right. + const byCores = new Map(); + const other: string[] = []; + for (const cs of items) { + const p = parseCoresAndMm2(cs); + if (p.cores !== null && p.mm2 !== null) { + const arr = byCores.get(p.cores) ?? []; + arr.push(p.mm2); + byCores.set(p.cores, arr); + } else { + other.push(cs); + } + } + + const coreKeys = Array.from(byCores.keys()).sort((a, b) => a - b); + for (const k of coreKeys) { + const uniq = Array.from(new Set(byCores.get(k) ?? [])).sort((a, b) => a - b); + byCores.set(k, uniq); + } + + const fmtMm2 = (v: number) => { + const s = Number.isInteger(v) ? String(v) : String(v).replace(/\.0+$/, ''); + return s; + }; + + // Layout engine with group labels. + const labelW = 38; + const placements: Placement[] = []; + let line = 0; + let cy = startY; + let x = margin + labelW; + + const canAdvanceLine = () => line + 1 < maxLines; + const advanceLine = () => { + if (!canAdvanceLine()) return false; + line += 1; + cy -= chipH + lineGap; + x = margin + labelW; + return true; + }; + + const drawGroupLabel = (label: string) => { + // Draw label on each new line for the group (keeps readability when wrapping). + page.drawText(label, { + x: margin, + y: cy + 4, + size: 8, + font: fontBold, + color: mediumGray, + maxWidth: labelW - 4, + }); + }; + + const placeChip = (text: string, variant: 'normal' | 'more') => { + const w = chipWidth(text); + if (w > contentWidth - labelW) return false; + if (x + w > margin + contentWidth) { + if (!advanceLine()) return false; + } + placements.push({ text, x, y: cy, w, variant }); + x += w + chipGap; + return true; + }; + + let truncated = false; + let renderedCount = 0; + const totalChips = coreKeys.reduce((sum, k) => sum + (byCores.get(k)?.length ?? 0), 0) + other.length; + + for (const cores of coreKeys) { + const values = byCores.get(cores) ?? []; + const label = `${cores}×`; + // Ensure label is shown at least once per line block. + drawGroupLabel(label); + for (const v of values) { + const ok = placeChip(fmtMm2(v), 'normal'); + if (!ok) { + truncated = true; + break; + } + renderedCount++; + } + if (truncated) break; + // Add a tiny gap between core groups (only if we have room on the current line) + x += 4; + if (x > margin + contentWidth - 20) { + if (!advanceLine()) { + // out of vertical space; stop + truncated = true; + break; + } + } + } + + if (!truncated && other.length) { + const label = locale === 'de' ? 'Sonst.' : 'Other'; + drawGroupLabel(label); + for (const t of other) { + const ok = placeChip(t, 'normal'); + if (!ok) { + truncated = true; + break; + } + renderedCount++; + } + } + + if (truncated) { + const remaining = Math.max(0, totalChips - renderedCount); + const moreText = locale === 'de' ? `+${remaining} weitere` : `+${remaining} more`; + // Try to place on current line; if not possible, try next line. + if (!placeChip(moreText, 'more')) { + if (advanceLine()) { + placeChip(moreText, 'more'); + } + } + } + + // Draw placements + for (const p of placements) { + page.drawRectangle({ + x: p.x, + y: p.y, + width: p.w, + height: chipH, + borderColor: lightGray, + borderWidth: 1, + color: rgb(1, 1, 1), + }); + page.drawText(p.text, { + x: p.x + padX, + y: p.y + chipPadTop, + size: chipFontSize, + font, + color: p.variant === 'more' ? navy : darkGray, + maxWidth: p.w - padX * 2, + }); + } + + // Return cursor below the last line drawn + const linesUsed = placements.length ? Math.max(...placements.map(p => Math.round((startY - p.y) / (chipH + lineGap)))) + 1 : 1; + const bottomY = startY - (linesUsed - 1) * (chipH + lineGap); + // Consistent section spacing after block. + // IMPORTANT: never return below contentMinY if we actually rendered, + // otherwise callers may think it "didn't fit" and draw a fallback on top (duplicate “Options” lines). + return Math.max(bottomY - 24, contentMinY); +} + +function drawCompactList(args: { + items: string[]; + x: number; + y: number; + colW: number; + cols: number; + rowH: number; + maxRows: number; + page: PDFPage; + font: PDFFont; + fontSize: number; + color: ReturnType; +}): number { + const { items, x, colW, cols, rowH, maxRows, page, font, fontSize, color } = args; + let y = args.y; + const shown = items.slice(0, cols * maxRows); + for (let i = 0; i < shown.length; i++) { + const col = Math.floor(i / maxRows); + const row = i % maxRows; + const ix = x + col * colW; + const iy = y - row * rowH; + page.drawText(shown[i], { + x: ix, + y: iy, + size: fontSize, + font, + color, + maxWidth: colW - 6, + }); + } + return y - maxRows * rowH; +} + +function findAttr(product: ProductData, includes: RegExp): ProductData['attributes'][number] | undefined { + return product.attributes?.find(a => includes.test(a.name)); +} + +function normalizeValue(value: string): string { + return stripHtml(value).replace(/\s+/g, ' ').trim(); +} + +function summarizeOptions(options: string[] | undefined, maxItems: number = 3): string { + const vals = (options || []).map(normalizeValue).filter(Boolean); + if (vals.length === 0) return ''; + const uniq = Array.from(new Set(vals)); + if (uniq.length === 1) return uniq[0]; + if (uniq.length <= maxItems) return uniq.join(' / '); + // UX: avoid showing internal counts like "+8" in customer-facing PDFs. + // Indicate truncation with an ellipsis. + return `${uniq.slice(0, maxItems).join(' / ')} / ...`; +} + +function parseNumericOption(value: string): number | null { + const v = normalizeValue(value).replace(/,/g, '.'); + // First numeric token (works for "12.3", "12.3 mm", "-35", "26.5 kg/km"). + const m = v.match(/-?\d+(?:\.\d+)?/); + if (!m) return null; + const n = Number(m[0]); + return Number.isFinite(n) ? n : null; +} + +function formatNumber(n: number): string { + const s = Number.isInteger(n) ? String(n) : String(n); + return s.replace(/\.0+$/, ''); +} + +function summarizeNumericRange(options: string[] | undefined): { ok: boolean; text: string } { + const vals = (options || []).map(parseNumericOption).filter((n): n is number => n !== null); + if (vals.length < 3) return { ok: false, text: '' }; + const uniq = Array.from(new Set(vals)); + // If there are only a few distinct values, listing is clearer than a range. + if (uniq.length < 4) return { ok: false, text: '' }; + uniq.sort((a, b) => a - b); + const min = uniq[0]; + const max = uniq[uniq.length - 1]; + // UX: don't show internal counts like "n=…" in customer-facing datasheets. + return { ok: true, text: `${formatNumber(min)}–${formatNumber(max)}` }; +} + +function summarizeSmartOptions(label: string, options: string[] | undefined): string { + // Prefer numeric ranges when an attribute has many numeric-ish entries (typical for row-specific data). + const range = summarizeNumericRange(options); + if (range.ok) return range.text; + return summarizeOptions(options, 3); +} + +function looksNumeric(value: string): boolean { + const v = normalizeValue(value).replace(/,/g, '.'); + return /^-?\d+(?:\.\d+)?$/.test(v); +} + +function formatMaybeWithUnit(value: string, unit: string): string { + const v = normalizeValue(value); + if (!v) return ''; + return looksNumeric(v) ? `${v} ${unit}` : v; +} + +function drawRowPreviewTable(args: { + title: string; + rows: Array<{ config: string; col1: string; col2: string }>; + headers: { config: string; col1: string; col2: string }; + getPage: () => PDFPage; + page: PDFPage; + y: number; + margin: number; + contentWidth: number; + contentMinY: number; + font: PDFFont; + fontBold: PDFFont; + navy: ReturnType; + darkGray: ReturnType; + lightGray: ReturnType; + almostWhite: ReturnType; +}): number { + let { title, rows, headers, getPage, page, y, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, lightGray, almostWhite } = args; + + const titleH = 16; + const headerH = 16; + const rowH = 13; + const padAfter = 18; + + // One-page rule: require at least 2 data rows. + const minNeeded = titleH + headerH + rowH * 2 + padAfter; + if (y - minNeeded < contentMinY) return contentMinY - 1; + + page = getPage(); + page.drawText(title, { x: margin, y, size: 10, font: fontBold, color: navy }); + y -= titleH; + + // How many rows fit? + const availableForRows = y - contentMinY - padAfter - headerH; + const maxRows = Math.max(2, Math.floor(availableForRows / rowH)); + const shown = rows.slice(0, Math.max(0, maxRows)); + + const hasCol2 = shown.some(r => Boolean(r.col2)); + + // Widths: favor configuration readability. + const wCfg = 0.46; + const w1 = hasCol2 ? 0.27 : 0.54; + const w2 = hasCol2 ? 0.27 : 0; + + const x0 = margin; + const x1 = margin + contentWidth * wCfg; + const x2 = margin + contentWidth * (wCfg + w1); + + const drawHeader = () => { + page = getPage(); + page.drawRectangle({ x: margin, y: y - headerH, width: contentWidth, height: headerH, color: lightGray }); + page.drawText(headers.config, { x: x0 + 6, y: y - 11, size: 8, font: fontBold, color: navy, maxWidth: contentWidth * wCfg - 12 }); + page.drawText(headers.col1, { x: x1 + 6, y: y - 11, size: 8, font: fontBold, color: navy, maxWidth: contentWidth * w1 - 12 }); + if (hasCol2) { + page.drawText(headers.col2, { x: x2 + 6, y: y - 11, size: 8, font: fontBold, color: navy, maxWidth: contentWidth * w2 - 12 }); + } + y -= headerH; + }; + + drawHeader(); + + for (let i = 0; i < shown.length; i++) { + if (y - rowH < contentMinY) return contentMinY - 1; + page = getPage(); + if (i % 2 === 0) { + page.drawRectangle({ x: margin, y: y - rowH, width: contentWidth, height: rowH, color: almostWhite }); + } + page.drawText(shown[i].config, { x: x0 + 6, y: y - 10, size: 8, font, color: darkGray, maxWidth: contentWidth * wCfg - 12 }); + page.drawText(shown[i].col1, { x: x1 + 6, y: y - 10, size: 8, font, color: darkGray, maxWidth: contentWidth * w1 - 12 }); + if (hasCol2) { + page.drawText(shown[i].col2, { x: x2 + 6, y: y - 10, size: 8, font, color: darkGray, maxWidth: contentWidth * w2 - 12 }); + } + y -= rowH; + } + + y -= padAfter; + return y; +} + +function splitConfig(config: string): { crossSection: string; voltage: string } { + const raw = normalizeValue(config); + const parts = raw.split(/\s*-\s*/); + if (parts.length >= 2) { + return { crossSection: parts[0], voltage: parts.slice(1).join(' - ') }; + } + return { crossSection: raw, voltage: '' }; +} + +function parseCoresAndMm2(crossSection: string): { cores: number | null; mm2: number | null } { + const s = normalizeValue(crossSection) + .replace(/\s+/g, '') + .replace(/×/g, 'x') + .replace(/,/g, '.'); + + // Typical: 3x1.5, 4x25, 1x70 + const m = s.match(/(\d{1,3})x(\d{1,4}(?:\.\d{1,2})?)/i); + if (!m) return { cores: null, mm2: null }; + const cores = Number(m[1]); + const mm2 = Number(m[2]); + return { + cores: Number.isFinite(cores) ? cores : null, + mm2: Number.isFinite(mm2) ? mm2 : null, + }; +} + +async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise { + try { + const labels = getLabels(locale); + const pdfDoc = await PDFDocument.create(); + const pageSizePortrait: [number, number] = [595.28, 841.89]; // DIN A4 portrait + const pageSizeLandscape: [number, number] = [841.89, 595.28]; // DIN A4 landscape + let page = pdfDoc.addPage(pageSizePortrait); + const { width, height } = page.getSize(); + + // STYLEGUIDE.md colors + const navy = rgb(0.0549, 0.1647, 0.2784); // #0E2A47 + const mediumGray = rgb(0.4196, 0.4471, 0.5020); // #6B7280 + const darkGray = rgb(0.1216, 0.1608, 0.2); // #1F2933 + const almostWhite = rgb(0.9725, 0.9765, 0.9804); // #F8F9FA + const lightGray = rgb(0.9020, 0.9137, 0.9294); // #E6E9ED + const headerBg = rgb(0.965, 0.972, 0.98); // calm, print-friendly tint + + // Small design system: consistent type + spacing for professional datasheets. + const DS = { + space: { xs: 4, sm: 8, md: 12, lg: 16, xl: 24 }, + type: { h1: 20, h2: 11, body: 10.5, small: 8 }, + rule: { thin: 0.75 }, + } as const; + + // Line-heights (explicit so vertical rhythm doesn't drift / overlap) + const LH = { + h1: 24, + h2: 16, + body: 14, + small: 10, + } as const; + + const font = await pdfDoc.embedFont(StandardFonts.Helvetica); + const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + + // Assets + // Prefer a raster logo for reliability (sharp SVG support can vary between environments). + const logoPng = (await loadEmbeddablePng('/media/logo.png')) || (await loadEmbeddablePng('/media/logo.svg')); + const logoImage = logoPng ? await pdfDoc.embedPng(logoPng.pngBytes) : null; + + // Some products have no product-specific images. + // Do NOT fall back to a generic/category hero (misleading in datasheets). + // If missing, we render a neutral placeholder box. + const heroSrc = product.featuredImage || product.images?.[0] || null; + const heroPng = heroSrc ? await loadEmbeddablePng(heroSrc) : null; + + const productUrl = getProductUrl(product) || CONFIG.siteUrl; + const qrPng = await loadQrPng(productUrl); + const qrImage = qrPng ? await pdfDoc.embedPng(qrPng.pngBytes) : null; + + // Engineered page frame (A4): slightly narrower margins but consistent rhythm. + const margin = 54; + const footerY = 54; + const contentMinY = footerY + 42; // keep clear of footer + page numbers + const contentWidth = width - 2 * margin; + + const ctx: SectionDrawContext = { + pdfDoc, + page, + width, + height, + margin, + contentWidth, + footerY, + contentMinY, + headerDividerY: 0, + colors: { navy, mediumGray, darkGray, almostWhite, lightGray, headerBg }, + fonts: { regular: font, bold: fontBold }, + labels, + product, + locale, + logoImage, + qrImage, + qrUrl: productUrl, + }; + + const syncCtxForPage = (p: PDFPage) => { + const sz = p.getSize(); + ctx.page = p; + ctx.width = sz.width; + ctx.height = sz.height; + ctx.contentWidth = sz.width - 2 * ctx.margin; + }; + + const drawPageBackground = (p: PDFPage) => { + const { width: w, height: h } = p.getSize(); + p.drawRectangle({ + x: 0, + y: 0, + width: w, + height: h, + color: rgb(1, 1, 1), + }); + }; + + const drawProductNameOnPage = (p: PDFPage, yStart: number): number => { + const name = stripHtml(product.name); + const maxW = ctx.contentWidth; + const line = wrapText(name, fontBold, 12, maxW).slice(0, 1)[0] || name; + p.drawText(line, { + x: margin, + y: yStart, + size: 12, + font: fontBold, + color: navy, + maxWidth: maxW, + }); + return yStart - 18; + }; + + // Real multi-page support. + // Each new page repeats header + footer for print-friendly, consistent scanning. + const newPage = (opts?: { includeProductName?: boolean; landscape?: boolean }): number => { + page = pdfDoc.addPage(opts?.landscape ? pageSizeLandscape : pageSizePortrait); + syncCtxForPage(page); + drawPageBackground(page); + drawFooter(ctx); + let yStart = drawHeader(ctx, ctx.height - ctx.margin); + if (opts?.includeProductName) yStart = drawProductNameOnPage(page, yStart); + return yStart; + }; + + const hasSpace = (needed: number) => y - needed >= contentMinY; + + // ---- Layout helpers (eliminate magic numbers; enforce consistent rhythm) ---- + const rule = (gapAbove: number = DS.space.md, gapBelow: number = DS.space.lg) => { + // One-page rule: if we can't fit a divider with its spacing, do nothing. + if (!hasSpace(gapAbove + gapBelow + DS.rule.thin)) return; + + y -= gapAbove; + page.drawLine({ + start: { x: margin, y }, + end: { x: margin + contentWidth, y }, + thickness: DS.rule.thin, + color: lightGray, + }); + y -= gapBelow; + }; + + const sectionTitle = (text: string) => { + // One-page rule: if we can't fit the heading + its gap, do nothing. + if (!hasSpace(DS.type.h2 + DS.space.md)) return; + + page.drawText(text, { + x: margin, + y, + size: DS.type.h2, + font: fontBold, + color: navy, + }); + // Use a real line-height to avoid title/body overlap. + y -= LH.h2; + }; + + // Page 1 + syncCtxForPage(page); + drawPageBackground(page); + drawFooter(ctx); + let y = drawHeader(ctx, ctx.height - ctx.margin); + + // === PRODUCT HEADER === + const productName = stripHtml(product.name); + const cats = (product.categories || []).map(c => stripHtml(c.name)).join(' • '); + + // First page: keep the header compact so technical tables start earlier. + const titleW = contentWidth; + const titleSize = 18; + const titleLineH = 21; + const nameLines = wrapText(productName, fontBold, titleSize, titleW); + const shownNameLines = nameLines.slice(0, 1); + for (const line of shownNameLines) { + if (y - titleLineH < contentMinY) y = newPage(); + page.drawText(line, { + x: margin, + y, + size: titleSize, + font: fontBold, + color: navy, + maxWidth: titleW, + }); + y -= titleLineH; + } + + if (cats) { + if (y - 18 < contentMinY) y = newPage(); + page.drawText(cats, { + x: margin, + y, + size: 10.5, + font, + color: mediumGray, + maxWidth: titleW, + }); + y -= DS.space.md; + } + + // Separator after product header + // No dividing lines on first page (cleaner, more space-efficient). + // rule(DS.space.xs, DS.space.md); + + // === HERO IMAGE (full width) === + // Dense technical products need more room for specs; prioritize content over imagery. + const hasLotsOfTech = (product.attributes?.length || 0) >= 18; + let heroH = hasLotsOfTech ? 96 : 120; + const afterHeroGap = DS.space.lg; + if (!hasSpace(heroH + afterHeroGap)) { + // Shrink to remaining space (but keep it usable). + heroH = Math.max(84, Math.floor(y - contentMinY - afterHeroGap)); + } + + const heroBoxX = margin; + const heroBoxY = y - heroH; + page.drawRectangle({ + x: heroBoxX, + y: heroBoxY, + width: contentWidth, + height: heroH, + // Calm frame; gives images consistent presence even with transparency. + color: almostWhite, + borderColor: lightGray, + borderWidth: 1, + }); + + if (heroPng) { + const pad = DS.space.md; + const boxW = contentWidth - pad * 2; + const boxH = heroH - pad * 2; + + // Fit image into the box without cutting it off (contain). + // Technical images often must remain fully visible. + const sharp = await getSharp(); + const contained = await sharp(Buffer.from(heroPng.pngBytes)) + .resize({ + width: 1200, + height: Math.round((1200 * boxH) / boxW), + fit: 'contain', + background: { r: 248, g: 249, b: 250, alpha: 1 }, + }) + .png() + .toBuffer(); + const heroImage = await pdfDoc.embedPng(contained); + + page.drawImage(heroImage, { + x: heroBoxX + pad, + y: heroBoxY + pad, + width: boxW, + height: boxH, + }); + } else { + page.drawText(locale === 'de' ? 'Kein Bild verfügbar' : 'No image available', { + x: heroBoxX + 12, + y: heroBoxY + heroH / 2, + size: 8, + font, + color: mediumGray, + maxWidth: contentWidth - 24, + }); + } + + y = heroBoxY - afterHeroGap; + + // === DESCRIPTION (optional) === + if (product.shortDescriptionHtml || product.descriptionHtml) { + const desc = stripHtml(product.shortDescriptionHtml || product.descriptionHtml); + const descLineH = 14; + // Keep full length: render all lines, paginate if needed. + const boxPadX = DS.space.md; + const boxPadY = DS.space.md; + + sectionTitle(labels.description); + + const maxTextW = contentWidth - boxPadX * 2; + const descLines = wrapText(desc, font, DS.type.body, maxTextW); + + // Draw as boxed paragraphs, paginating as needed. + // We keep a single consistent box per page segment. + let i = 0; + while (i < descLines.length) { + // Ensure we have enough space for at least 2 lines. + const minLines = 2; + const minBoxH = boxPadY * 2 + descLineH * minLines; + if (!hasSpace(minBoxH + DS.space.md)) { + y = newPage({ includeProductName: true }); + } + + // Compute how many lines we can fit on the current page. + const availableH = y - contentMinY - DS.space.md; + const maxLinesThisPage = Math.max(minLines, Math.floor((availableH - boxPadY * 2) / descLineH)); + const slice = descLines.slice(i, i + maxLinesThisPage); + + const boxH = boxPadY * 2 + descLineH * slice.length; + const boxTop = y + DS.space.xs; + const boxBottom = boxTop - boxH; + + page.drawRectangle({ + x: margin, + y: boxBottom, + width: contentWidth, + height: boxH, + color: rgb(1, 1, 1), + borderColor: lightGray, + borderWidth: 1, + }); + + let ty = boxTop - boxPadY - DS.type.body; + for (const line of slice) { + page.drawText(line, { + x: margin + boxPadX, + y: ty, + size: DS.type.body, + font, + color: darkGray, + }); + ty -= descLineH; + } + + y = boxBottom - DS.space.md; + i += slice.length; + + // If there is more text, continue on a new page segment (keeps layout stable). + if (i < descLines.length) { + y = newPage({ includeProductName: true }); + sectionTitle(labels.description); + } + } + + rule(0, DS.space.lg); + } + + // === EXCEL MODEL === + // Priority: render ALL Excel data (technical + per-voltage tables). + const excelModel = buildExcelModel({ product, locale }); + + // Keep the old enrichment as a fallback path only. + // (Some products may not match Excel keying; then we still have a usable PDF.) + ensureExcelCrossSectionAttributes(product, locale); + ensureExcelRowSpecificAttributes(product, locale); + + if (excelModel.ok) { + const tables = excelModel.voltageTables; + const hasMultipleVoltages = tables.length > 1; + + // TECHNICAL DATA (shared across all cross-sections) + const techTitle = locale === 'de' ? 'TECHNISCHE DATEN' : 'TECHNICAL DATA'; + const techItems = excelModel.technicalItems; + + // Track if we've rendered any content before the tables + let hasRenderedContent = false; + + if (techItems.length) { + y = drawKeyValueGrid({ + title: techTitle, + items: techItems, + newPage: () => newPage({ includeProductName: true }), + getPage: () => page, + page, + y, + margin, + contentWidth, + contentMinY, + font, + fontBold, + navy, + darkGray, + mediumGray, + lightGray, + almostWhite, + allowNewPage: true, + boxed: true, + }); + + hasRenderedContent = true; + + // Add spacing after technical data section before first voltage table + if (y - 20 >= contentMinY) y -= 20; + } + + // CROSS-SECTION DATA: one table per voltage rating + const firstColLabel = + locale === 'de' ? 'Adern & Querschnitt' : 'Cores & cross-section'; + + for (const t of tables) { + // Maintain a minimum space between tables (even when staying on the same page). + // This avoids visual collisions between the previous table and the next meta header. + if (hasRenderedContent && y - 20 >= contentMinY) y -= 20; + + // Check if we need a new page for this voltage table + // Estimate: meta block (if shown) + table header + at least 3 data rows + const estimateMetaH = (itemsCount: number) => { + // Always render a voltage-group header (even for single-voltage products) + // so all datasheets look consistent. + const titleH = 14; + const rowH = 14; + const cols = 3; + const rows = Math.max(1, Math.ceil(Math.max(0, itemsCount) / cols)); + return titleH + rows * rowH + 8; + }; + const minTableH = 16 /*header*/ + 9 * 3 /*3 rows*/ + 10 /*pad*/; + const minNeeded = estimateMetaH((t.metaItems || []).length) + minTableH; + if (y - minNeeded < contentMinY) { + y = newPage({ includeProductName: true, landscape: false }); + } + + // Top meta block: always render per voltage group. + // This ensures a consistent structure across products (LV/MV/HV) and makes the + // voltage group visible in the heading. + y = drawDenseMetaGrid({ + title: `${labels.crossSection} — ${t.voltageLabel}`, + items: t.metaItems, + locale, + newPage: () => newPage({ includeProductName: true, landscape: false }), + getPage: () => page, + page, + y, + margin, + contentWidth, + contentMinY, + font, + fontBold, + navy, + darkGray, + mediumGray, + }); + + // Breathing room before the dense table + y -= 14; + + // Cross-section table: exactly 13 columns as specified + // Order: DI, RI, Wi, Ibl, Ibe, Ik, Wm, Rbv, Ø, Fzv, Al, Cu, G + const tableColumns = prioritizeColumnsForDenseTable({ columns: t.columns }); + + // Format dense table cells: compact decimals, no units in cells + const cellFormatter = (value: string, columnKey: string) => { + return compactNumericForLocale(value, locale); + }; + + // Table title: keep empty because the voltage-group title is already shown in the meta block. + const tableTitle = ''; + + y = drawTableChunked({ + title: tableTitle, + configRows: t.crossSections, + columns: tableColumns, + firstColLabel, + dense: true, + onePage: false, // Allow multiple pages for large tables + cellFormatter, + locale, + newPage: () => newPage({ includeProductName: true, landscape: false }), + getPage: () => page, + page, + y, + margin, + contentWidth: ctx.contentWidth, + contentMinY, + font, + fontBold, + navy, + darkGray, + lightGray, + almostWhite, + maxDataColsPerTable: 10_000, // All columns in one table + }); + + hasRenderedContent = true; + } + } else { + // Fallback (non-Excel products): keep existing behavior minimal + const note = locale === 'de' + ? 'Hinweis: Für dieses Produkt liegen derzeit keine Excel-Daten vor.' + : 'Note: No Excel data is available for this product yet.'; + y = drawKeyValueGrid({ + title: locale === 'de' ? 'TECHNISCHE DATEN' : 'TECHNICAL DATA', + items: [{ label: locale === 'de' ? 'Quelle' : 'Source', value: note }], + newPage: () => newPage({ includeProductName: true }), + getPage: () => page, + page, + y, + margin, + contentWidth, + contentMinY, + font, + fontBold, + navy, + darkGray, + mediumGray, + lightGray, + almostWhite, + allowNewPage: true, + boxed: true, + }); + } + + // Add page numbers after all pages are created. + stampPageNumbers(pdfDoc, { regular: font }, { mediumGray }, margin, footerY); + + const pdfBytes = await pdfDoc.save(); + return Buffer.from(pdfBytes); + + } catch (error: any) { + throw new Error(`Failed to generate PDF for product ${product.id} (${locale}): ${error.message}`); + } +} + +async function processChunk(products: ProductData[], chunkIndex: number, totalChunks: number): Promise { + console.log(`\nProcessing chunk ${chunkIndex + 1}/${totalChunks} (${products.length} products)...`); + + for (const product of products) { + try { + const locale = product.locale || 'en'; + const buffer = await generatePDF(product, locale); + const fileName = generateFileName(product, locale); + fs.writeFileSync(path.join(CONFIG.outputDir, fileName), buffer); + console.log(`✓ ${locale.toUpperCase()}: ${fileName}`); + await new Promise(resolve => setTimeout(resolve, 50)); + } catch (error) { + console.error(`✗ Failed to process product ${product.id}:`, error); + } + } +} + +async function readProductsStream(): Promise { + console.log('Reading products.json...'); + return new Promise((resolve, reject) => { + const stream = fs.createReadStream(CONFIG.productsFile, { encoding: 'utf8' }); + let data = ''; + stream.on('data', (chunk) => { data += chunk; }); + stream.on('end', () => { + try { + const products = JSON.parse(data); + console.log(`Loaded ${products.length} products`); + resolve(products); + } catch (error) { + reject(new Error(`Failed to parse JSON: ${error}`)); + } + }); + stream.on('error', (error) => reject(new Error(`Failed to read file: ${error}`))); + }); +} + +async function processProductsInChunks(): Promise { + console.log('Starting PDF generation - Industrial engineering documentation style'); + ensureOutputDir(); + + try { + const allProducts = await readProductsStream(); + if (allProducts.length === 0) { + console.log('No products found'); + return; + } + + // Dev convenience: generate only one locale / one product subset. + // IMPORTANT: apply filters BEFORE PDF_LIMIT so the limit works within the filtered set. + let products = allProducts; + + const onlyLocale = normalizeValue(String(process.env.PDF_LOCALE || '')).toLowerCase(); + if (onlyLocale === 'de' || onlyLocale === 'en') { + products = products.filter(p => (p.locale || 'en') === onlyLocale); + } + + const match = normalizeValue(String(process.env.PDF_MATCH || '')).toLowerCase(); + if (match) { + products = products.filter(p => { + const hay = [p.slug, p.translationKey, p.sku, stripHtml(p.name)] + .filter(Boolean) + .join(' ') + .toLowerCase(); + return hay.includes(match); + }); + } + + // Optional dev convenience: limit how many PDFs we render (useful for design iteration). + // Default behavior remains unchanged. + const limit = Number(process.env.PDF_LIMIT || '0'); + products = Number.isFinite(limit) && limit > 0 ? products.slice(0, limit) : products; + + const enProducts = products.filter(p => (p.locale || 'en') === 'en'); + const deProducts = products.filter(p => (p.locale || 'en') === 'de'); + console.log(`Found ${enProducts.length} EN + ${deProducts.length} DE products`); + + const totalChunks = Math.ceil(products.length / CONFIG.chunkSize); + for (let i = 0; i < totalChunks; i++) { + const chunk = products.slice(i * CONFIG.chunkSize, (i + 1) * CONFIG.chunkSize); + await processChunk(chunk, i, totalChunks); + } + + console.log('\n✅ PDF generation completed!'); + console.log(`Generated ${enProducts.length} EN + ${deProducts.length} DE PDFs`); + console.log(`Output: ${CONFIG.outputDir}`); + + } catch (error) { + console.error('❌ Error:', error); + throw error; + } +} + +async function main(): Promise { + const start = Date.now(); + try { + await processProductsInChunks(); + console.log(`\nTime: ${((Date.now() - start) / 1000).toFixed(2)}s`); + } catch (error) { + console.error('Fatal error:', error); + process.exit(1); + } +} + +main().catch(console.error); + +export { main as generatePDFDatasheets }; diff --git a/scripts/generate-pdf-datasheets.ts b/scripts/generate-pdf-datasheets.ts index 617c979c..0a3b6a3a 100644 --- a/scripts/generate-pdf-datasheets.ts +++ b/scripts/generate-pdf-datasheets.ts @@ -1,2137 +1,23 @@ #!/usr/bin/env ts-node /** - * PDF Datasheet Generator - Industrial Engineering Documentation Style - * STYLEGUIDE.md compliant: industrial, technical, restrained + * PDF Datasheet Generator (React-PDF) + * + * Uses the same Excel-driven data model as the legacy generator, but renders + * PDFs via `@react-pdf/renderer` for maintainable layout and pagination. */ import * as fs from 'fs'; import * as path from 'path'; -import { execSync } from 'child_process'; -import { PDFDocument, rgb, StandardFonts, PDFFont, PDFPage, PDFImage } from 'pdf-lib'; -let sharpFn: ((input?: any, options?: any) => any) | null = null; -async function getSharp(): Promise<(input?: any, options?: any) => any> { - if (sharpFn) return sharpFn; - // `sharp` is CJS but this script runs as ESM via ts-node. - // Dynamic import gives stable interop. - const mod: any = await import('sharp'); - sharpFn = (mod?.default || mod) as (input?: any, options?: any) => any; - return sharpFn; -} +import type { ProductData } from './pdf/model/types'; +import { generateDatasheetPdfBuffer } from './pdf/react-pdf/generate-datasheet-pdf'; +import { generateFileName, normalizeValue } from './pdf/model/utils'; const CONFIG = { productsFile: path.join(process.cwd(), 'data/processed/products.json'), outputDir: path.join(process.cwd(), 'public/datasheets'), chunkSize: 10, - siteUrl: 'https://klz-cables.com', -}; - -const ASSET_MAP_FILE = path.join(process.cwd(), 'data/processed/asset-map.json'); -const PUBLIC_DIR = path.join(process.cwd(), 'public'); - -const EXCEL_SOURCE_FILES = [ - path.join(process.cwd(), 'data/source/high-voltage.xlsx'), - path.join(process.cwd(), 'data/source/medium-voltage-KM.xlsx'), - path.join(process.cwd(), 'data/source/low-voltage-KM.xlsx'), - path.join(process.cwd(), 'data/source/solar-cables.xlsx'), -]; - -type AssetMap = Record; - -function readAssetMap(): AssetMap { - try { - if (!fs.existsSync(ASSET_MAP_FILE)) return {}; - return JSON.parse(fs.readFileSync(ASSET_MAP_FILE, 'utf8')) as AssetMap; - } catch { - return {}; - } -} - -const ASSET_MAP: AssetMap = readAssetMap(); - -interface ProductData { - id: number; - name: string; - shortDescriptionHtml: string; - descriptionHtml: string; - images: string[]; - featuredImage: string | null; - sku: string; - slug?: string; - path?: string; - translationKey?: string; - locale?: 'en' | 'de'; - categories: Array<{ name: string }>; - attributes: Array<{ - name: string; - options: string[]; - }>; -} - -type ExcelRow = Record; -type ExcelMatch = { rows: ExcelRow[]; units: Record }; -let EXCEL_INDEX: Map | null = null; - -type KeyValueItem = { label: string; value: string; unit?: string }; -type VoltageTableModel = { - voltageLabel: string; - metaItems: KeyValueItem[]; - crossSections: string[]; - columns: Array<{ key: string; label: string; get: (rowIndex: number) => string }>; -}; - -function estimateDenseMetaGridHeight(itemsCount: number): number { - // Must stay in sync with the layout constants in `drawDenseMetaGrid()`. - const cols = itemsCount >= 7 ? 3 : 2; - const cellH = 34; - const titleH = 18; - const headerPadY = 10; - const rows = Math.ceil(Math.max(0, itemsCount) / cols); - const boxH = headerPadY + titleH + rows * cellH + headerPadY; - // `drawDenseMetaGrid()` returns the cursor below the box with additional spacing. - return boxH + 18; -} - -function normalizeUnit(unitRaw: string): string { - const u = normalizeValue(unitRaw); - if (!u) return ''; - // Temperature units: show °C (not plain C). - if (/^c$/i.test(u) || /^°c$/i.test(u)) return '°C'; - // Common WinAnsi-safe normalizations. - return u - .replace(/Ω/gi, 'Ohm') - .replace(/[\u00B5\u03BC]/g, 'u'); -} - -function denseAbbrevLabel(args: { - key: string; - locale: 'en' | 'de'; - unit?: string; - withUnit?: boolean; -}): string { - const u = normalizeUnit(args.unit || ''); - const withUnit = args.withUnit ?? true; - const unitSafe = u - .replace(/Ω/gi, 'Ohm') - .replace(/[\u00B5\u03BC]/g, 'u'); - const suffix = withUnit && unitSafe ? ` [${unitSafe}]` : ''; - - switch (args.key) { - case 'DI': - case 'RI': - case 'Wi': - case 'Ibl': - case 'Ibe': - case 'Wm': - case 'Rbv': - case 'Fzv': - case 'G': - return `${args.key}${suffix}`; - case 'Ik_cond': - return `Ik${suffix}`; - case 'Ik_screen': - return `Ik_s${suffix}`; - case 'Ø': - return `Ø${suffix}`; - case 'Cond': - return args.locale === 'de' ? 'Leiter' : 'Cond.'; - case 'shape': - return args.locale === 'de' ? 'Form' : 'Shape'; - // Electrical - case 'cap': - return `C${suffix}`; - case 'X': - return `X${suffix}`; - // Temperatures - case 'temp_range': - return `T${suffix}`; - case 'max_op_temp': - return `T_op${suffix}`; - case 'max_sc_temp': - return `T_sc${suffix}`; - case 'min_store_temp': - return `T_st${suffix}`; - case 'min_lay_temp': - return `T_lay${suffix}`; - // Compliance - case 'cpr': - return `CPR${suffix}`; - case 'flame': - return `FR${suffix}`; - // Voltages - case 'test_volt': - return `U_test${suffix}`; - case 'rated_volt': - return `U0/U${suffix}`; - default: - return args.key || ''; - } -} - -function metaFullLabel(args: { key: string; excelKey: string; locale: 'en' | 'de' }): string { - const key = normalizeValue(args.key); - if (args.locale === 'de') { - switch (key) { - case 'test_volt': - return 'Prüfspannung'; - case 'temp_range': - return 'Temperaturbereich'; - case 'max_op_temp': - return 'Leitertemperatur (max.)'; - case 'max_sc_temp': - return 'Kurzschlusstemperatur (max.)'; - case 'min_lay_temp': - return 'Minimale Verlegetemperatur'; - case 'min_store_temp': - return 'Minimale Lagertemperatur'; - case 'cpr': - return 'CPR-Klasse'; - case 'flame': - return 'Flammhemmend'; - default: - return formatExcelHeaderLabel(args.excelKey); - } - } - - // EN - switch (key) { - case 'test_volt': - return 'Test voltage'; - case 'temp_range': - return 'Operating temperature range'; - case 'max_op_temp': - return 'Conductor temperature (max.)'; - case 'max_sc_temp': - return 'Short-circuit temperature (max.)'; - case 'min_lay_temp': - return 'Minimum laying temperature'; - case 'min_store_temp': - return 'Minimum storage temperature'; - case 'cpr': - return 'CPR class'; - case 'flame': - return 'Flame retardant'; - default: - return formatExcelHeaderLabel(args.excelKey); - } -} - -function expandMetaLabel(label: string, locale: 'en' | 'de'): string { - const l = normalizeValue(label); - if (!l) return ''; - - // Safety net: the voltage-group meta grid must never show abbreviated labels. - // (Even if upstream mapping changes, we keep customer-facing readability.) - const mapDe: Record = { - U_test: 'Prüfspannung', - 'U0/U': 'Nennspannung', - 'U_0/U': 'Nennspannung', - T: 'Temperaturbereich', - T_op: 'Leitertemperatur (max.)', - T_sc: 'Kurzschlusstemperatur (max.)', - T_lay: 'Minimale Verlegetemperatur', - T_st: 'Minimale Lagertemperatur', - CPR: 'CPR-Klasse', - FR: 'Flammhemmend', - }; - - const mapEn: Record = { - U_test: 'Test voltage', - 'U0/U': 'Rated voltage', - 'U_0/U': 'Rated voltage', - T: 'Operating temperature range', - T_op: 'Conductor temperature (max.)', - T_sc: 'Short-circuit temperature (max.)', - T_lay: 'Minimum laying temperature', - T_st: 'Minimum storage temperature', - CPR: 'CPR class', - FR: 'Flame retardant', - }; - - const mapped = (locale === 'de' ? mapDe[l] : mapEn[l]) || ''; - return mapped || label; -} - -function technicalFullLabel(args: { key: string; excelKey: string; locale: 'en' | 'de' }): string { - const k = normalizeValue(args.key); - - if (args.locale === 'de') { - // Prefer stable internal keys (from columnMapping.key) to translate Technical Data labels. - switch (k) { - case 'DI': - return 'Durchmesser über Isolierung'; - case 'RI': - return 'DC-Leiterwiderstand (20 °C)'; - case 'Wi': - return 'Isolationsdicke'; - case 'Ibl': - return 'Strombelastbarkeit in Luft (trefoil)'; - case 'Ibe': - return 'Strombelastbarkeit im Erdreich (trefoil)'; - case 'Ik_cond': - return 'Kurzschlussstrom Leiter'; - case 'Ik_screen': - return 'Kurzschlussstrom Schirm'; - case 'Wm': - return 'Manteldicke'; - case 'Rbv': - return 'Biegeradius (min.)'; - case 'Ø': - return 'Außen-Ø'; - case 'Fzv': - return 'Zugkraft (max.)'; - case 'G': - return 'Gewicht'; - case 'Cond': - case 'conductor': - return 'Leiter'; - case 'shape': - return 'Leiterform'; - case 'insulation': - return 'Isolierung'; - case 'sheath': - return 'Mantel'; - case 'cap': - return 'Kapazität'; - case 'ind_trefoil': - return 'Induktivität (trefoil)'; - case 'ind_air_flat': - return 'Induktivität (Luft, flach)'; - case 'ind_ground_flat': - return 'Induktivität (Erdreich, flach)'; - case 'X': - return 'Reaktanz'; - case 'test_volt': - return 'Prüfspannung'; - case 'rated_volt': - return 'Nennspannung'; - case 'temp_range': - return 'Temperaturbereich'; - case 'max_op_temp': - return 'Leitertemperatur (max.)'; - case 'max_sc_temp': - return 'Kurzschlusstemperatur (max.)'; - case 'min_store_temp': - return 'Minimale Lagertemperatur'; - case 'min_lay_temp': - return 'Minimale Verlegetemperatur'; - case 'cpr': - return 'CPR-Klasse'; - case 'flame': - return 'Flammhemmend'; - case 'packaging': - return 'Verpackung'; - case 'ce': - return 'CE-Konformität'; - case 'norm': - return 'Norm'; - case 'standard': - return 'Standard'; - case 'D_screen': - return 'Durchmesser über Schirm'; - case 'S_screen': - return 'Metallischer Schirm'; - default: - break; - } - - // Fallback: best-effort translation from the raw Excel header (prevents English in DE PDFs). - const raw = normalizeValue(args.excelKey); - if (!raw) return ''; - return raw - .replace(/\(approx\.?\)/gi, '(ca.)') - .replace(/\bcapacitance\b/gi, 'Kapazität') - .replace(/\binductance\b/gi, 'Induktivität') - .replace(/\breactance\b/gi, 'Reaktanz') - .replace(/\btest voltage\b/gi, 'Prüfspannung') - .replace(/\brated voltage\b/gi, 'Nennspannung') - .replace(/\boperating temperature range\b/gi, 'Temperaturbereich') - .replace(/\bminimum sheath thickness\b/gi, 'Manteldicke (min.)') - .replace(/\bsheath thickness\b/gi, 'Manteldicke') - .replace(/\bnominal insulation thickness\b/gi, 'Isolationsdicke (nom.)') - .replace(/\binsulation thickness\b/gi, 'Isolationsdicke') - .replace(/\bdc resistance at 20\s*°?c\b/gi, 'DC-Leiterwiderstand (20 °C)') - .replace(/\bouter diameter(?: of cable)?\b/gi, 'Außen-Ø') - .replace(/\bbending radius\b/gi, 'Biegeradius') - .replace(/\bpackaging\b/gi, 'Verpackung') - .replace(/\bce\s*-?conformity\b/gi, 'CE-Konformität'); - } - - // EN: keep as-is (Excel headers are already English). - return normalizeValue(args.excelKey); -} - -function compactNumericForLocale(value: string, locale: 'en' | 'de'): string { - const v = normalizeValue(value); - if (!v) return ''; - - // Handle special text patterns like "15xD (Single core); 12xD (Multi core)" - // Compact to "15/12xD" or "10xD" for single values - if (/\d+xD/.test(v)) { - // Extract all number+xD patterns - const numbers = []; - const matches = Array.from(v.matchAll(/(\d+)xD/g)); - for (let i = 0; i < matches.length; i++) numbers.push(matches[i][1]); - if (numbers.length > 0) { - // Remove duplicates while preserving order - const unique: string[] = []; - for (const num of numbers) { - if (!unique.includes(num)) { - unique.push(num); - } - } - return unique.join('/') + 'xD'; - } - } - - // Normalize decimals for the target locale if it looks numeric-ish. - const hasDigit = /\d/.test(v); - if (!hasDigit) return v; - const trimmed = v.replace(/\s+/g, ' ').trim(); - // Keep ranges like "1.2–3.4" or "1.2-3.4". - const parts = trimmed.split(/(–|-)/); - const out = parts.map(p => { - if (p === '–' || p === '-') return p; - const s = p.trim(); - if (!/^-?\d+(?:[\.,]\d+)?$/.test(s)) return p; - const n = s.replace(/,/g, '.'); - - // Datasheets: do NOT use k/M suffixes (can be misleading for engineering values). - // Only trim trailing zeros for compactness. - const compact = n - .replace(/\.0+$/, '') - .replace(/(\.\d*?)0+$/, '$1') - .replace(/\.$/, ''); - - // Preserve leading "+" when present (typical for temperature cells like "+90"). - const hadPlus = /^\+/.test(s); - const withPlus = hadPlus && !/^\+/.test(compact) ? `+${compact}` : compact; - return locale === 'de' ? withPlus.replace(/\./g, ',') : withPlus; - }); - return out.join(''); -} - -function compactCellForDenseTable(value: string, unit: string | undefined, locale: 'en' | 'de'): string { - let v = normalizeValue(value); - if (!v) return ''; - const u = normalizeValue(unit || ''); - if (u) { - // Remove unit occurrences from the cell value (unit is already in the header). - const esc = u.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - v = v.replace(new RegExp(`\\s*${esc}\\b`, 'ig'), '').trim(); - // Common composite units appear in the data cells too; strip them. - v = v - .replace(/\bkg\s*\/\s*km\b/gi, '') - .replace(/\bohm\s*\/\s*km\b/gi, '') - .replace(/\bΩ\s*\/\s*km\b/gi, '') - .replace(/\bu\s*f\s*\/\s*km\b/gi, '') - .replace(/\bmh\s*\/\s*km\b/gi, '') - .replace(/\bkA\b/gi, '') - .replace(/\bmm\b/gi, '') - .replace(/\bkv\b/gi, '') - .replace(/\b°?c\b/gi, '') - .replace(/\s+/g, ' ') - .trim(); - } - - // Normalize common separators to compact but readable tokens. - // Example: "-35 - +90" -> "-35-+90", "-40°C / +90°C" -> "-40/+90". - v = v - .replace(/\s*–\s*/g, '-') - .replace(/\s*-\s*/g, '-') - .replace(/\s*\/\s*/g, '/') - .replace(/\s+/g, ' ') - .trim(); - - return compactNumericForLocale(v, locale); -} - -function normalizeVoltageLabel(raw: string): string { - const v = normalizeValue(raw); - if (!v) return ''; - // Keep common MV/HV notation like "6/10" or "12/20". - const cleaned = v.replace(/\s+/g, ' '); - if (/\bkv\b/i.test(cleaned)) return cleaned.replace(/\bkv\b/i, 'kV'); - // If purely numeric-ish (e.g. "20"), add unit. - const num = cleaned.match(/\d+(?:[\.,]\d+)?(?:\s*\/\s*\d+(?:[\.,]\d+)?)?/); - if (!num) return cleaned; - // If the string already contains other words, keep as-is. - if (/[a-z]/i.test(cleaned)) return cleaned; - return `${cleaned} kV`; -} - -function parseVoltageSortKey(voltageLabel: string): number { - const v = normalizeVoltageLabel(voltageLabel); - // Sort by the last number (e.g. 6/10 -> 10, 12/20 -> 20) - const nums = v - .replace(/,/g, '.') - .match(/\d+(?:\.\d+)?/g) - ?.map(n => Number(n)) - .filter(n => Number.isFinite(n)); - if (!nums || nums.length === 0) return Number.POSITIVE_INFINITY; - return nums[nums.length - 1]; -} - -function formatExcelHeaderLabel(key: string, unit?: string): string { - const k = normalizeValue(key); - if (!k) return ''; - const u = normalizeValue(unit || ''); - - // Prefer compact but clear labels. - const compact = k - .replace(/\s*\(approx\.?\)\s*/gi, ' (approx.) ') - .replace(/\s+/g, ' ') - .trim(); - - if (!u) return compact; - // Avoid double units like "(mm) mm". - if (new RegExp(`\\(${u.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')}\\)`, 'i').test(compact)) return compact; - return `${compact} (${u})`; -} - -function formatExcelCellValue(value: string, unit?: string, opts?: { appendUnit?: boolean }): string { - const v = normalizeValue(value); - if (!v) return ''; - const u = normalizeValue(unit || ''); - if (!u) return v; - const appendUnit = opts?.appendUnit ?? true; - // Only auto-append unit for pure numbers; many Excel cells already contain units. - if (!appendUnit) return v; - return looksNumeric(v) ? `${v} ${u}` : v; -} - -function buildExcelModel(args: { - product: ProductData; - locale: 'en' | 'de'; -}): { ok: boolean; technicalItems: KeyValueItem[]; voltageTables: VoltageTableModel[] } { - const match = findExcelForProduct(args.product); - if (!match || match.rows.length === 0) return { ok: false, technicalItems: [], voltageTables: [] }; - - const units = match.units || {}; - - // Filter rows to only include compatible column structures - // This handles products that exist in multiple Excel files with different column structures - const rows = match.rows; - - // Find the row with most columns as sample - let sample = rows.find(r => r && Object.keys(r).length > 0) || {}; - let maxColumns = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units').length; - - for (const r of rows) { - const cols = Object.keys(r).filter(k => k && k !== 'Part Number' && k !== 'Units').length; - if (cols > maxColumns) { - sample = r; - maxColumns = cols; - } - } - - // Map Excel column names to our compact/standardized keys. - // This mapping covers all common Excel column names found in the source files - // IMPORTANT: More specific patterns must come before generic ones to avoid false matches - const columnMapping: Record = { - // Cross-section and voltage (these are used for grouping, not shown in table) - MUST COME FIRST - 'number of cores and cross-section': { header: 'Cross-section', unit: '', key: 'cross_section' }, - 'ross section conductor': { header: 'Cross-section', unit: '', key: 'cross_section' }, - - // The 13 required headers (exact order as specified) - 'diameter over insulation': { header: 'DI', unit: 'mm', key: 'DI' }, - 'diameter over insulation (approx.)': { header: 'DI', unit: 'mm', key: 'DI' }, - 'dc resistance at 20 °C': { header: 'RI', unit: 'Ohm/km', key: 'RI' }, - 'dc resistance at 20°C': { header: 'RI', unit: 'Ohm/km', key: 'RI' }, - 'resistance conductor': { header: 'RI', unit: 'Ohm/km', key: 'RI' }, - 'maximum resistance of conductor': { header: 'RI', unit: 'Ohm/km', key: 'RI' }, - 'insulation thickness': { header: 'Wi', unit: 'mm', key: 'Wi' }, - 'nominal insulation thickness': { header: 'Wi', unit: 'mm', key: 'Wi' }, - 'current ratings in air, trefoil': { header: 'Ibl', unit: 'A', key: 'Ibl' }, - 'current ratings in air, trefoil*': { header: 'Ibl', unit: 'A', key: 'Ibl' }, - 'current ratings in ground, trefoil': { header: 'Ibe', unit: 'A', key: 'Ibe' }, - 'current ratings in ground, trefoil*': { header: 'Ibe', unit: 'A', key: 'Ibe' }, - 'conductor shortcircuit current': { header: 'Ik', unit: 'kA', key: 'Ik_cond' }, - 'screen shortcircuit current': { header: 'Ik', unit: 'kA', key: 'Ik_screen' }, - 'sheath thickness': { header: 'Wm', unit: 'mm', key: 'Wm' }, - 'minimum sheath thickness': { header: 'Wm', unit: 'mm', key: 'Wm' }, - 'nominal sheath thickness': { header: 'Wm', unit: 'mm', key: 'Wm' }, - 'bending radius': { header: 'Rbv', unit: 'mm', key: 'Rbv' }, - 'bending radius (min.)': { header: 'Rbv', unit: 'mm', key: 'Rbv' }, - 'outer diameter': { header: 'Ø', unit: 'mm', key: 'Ø' }, - 'outer diameter (approx.)': { header: 'Ø', unit: 'mm', key: 'Ø' }, - 'outer diameter of cable': { header: 'Ø', unit: 'mm', key: 'Ø' }, - // Pulling force is typically expressed in N in datasheets. - // Some sources appear to store values in kN (e.g. 4.5), we normalize in the cell getter. - 'pulling force': { header: 'Fzv', unit: 'N', key: 'Fzv' }, - 'max. pulling force': { header: 'Fzv', unit: 'N', key: 'Fzv' }, - // Conductor material (we render this as a single column) - 'conductor aluminum': { header: 'Cond.', unit: '', key: 'Cond' }, - 'conductor copper': { header: 'Cond.', unit: '', key: 'Cond' }, - 'weight': { header: 'G', unit: 'kg/km', key: 'G' }, - 'weight (approx.)': { header: 'G', unit: 'kg/km', key: 'G' }, - 'cable weight': { header: 'G', unit: 'kg/km', key: 'G' }, - - // Additional technical columns (to include ALL Excel data) - // Specific material/property columns must come before generic 'conductor' pattern - 'conductor diameter (approx.)': { header: 'Conductor diameter', unit: 'mm', key: 'cond_diam' }, - 'conductor diameter': { header: 'Conductor diameter', unit: 'mm', key: 'cond_diam' }, - 'diameter conductor': { header: 'Conductor diameter', unit: 'mm', key: 'cond_diam' }, - 'diameter over screen': { header: 'Diameter over screen', unit: 'mm', key: 'D_screen' }, - 'metallic screen mm2': { header: 'Metallic screen', unit: 'mm2', key: 'S_screen' }, - 'metallic screen': { header: 'Metallic screen', unit: 'mm2', key: 'S_screen' }, - 'reactance': { header: 'Reactance', unit: 'Ohm/km', key: 'X' }, - 'capacitance (approx.)': { header: 'Capacitance', unit: 'uF/km', key: 'cap' }, - 'capacitance': { header: 'Capacitance', unit: 'uF/km', key: 'cap' }, - 'inductance, trefoil (approx.)': { header: 'Inductance trefoil', unit: 'mH/km', key: 'ind_trefoil' }, - 'inductance, trefoil': { header: 'Inductance trefoil', unit: 'mH/km', key: 'ind_trefoil' }, - 'inductance in air, flat (approx.)': { header: 'Inductance air flat', unit: 'mH/km', key: 'ind_air_flat' }, - 'inductance in air, flat': { header: 'Inductance air flat', unit: 'mH/km', key: 'ind_air_flat' }, - 'inductance in ground, flat (approx.)': { header: 'Inductance ground flat', unit: 'mH/km', key: 'ind_ground_flat' }, - 'inductance in ground, flat': { header: 'Inductance ground flat', unit: 'mH/km', key: 'ind_ground_flat' }, - 'current ratings in air, flat': { header: 'Current air flat', unit: 'A', key: 'cur_air_flat' }, - 'current ratings in air, flat*': { header: 'Current air flat', unit: 'A', key: 'cur_air_flat' }, - 'current ratings in ground, flat': { header: 'Current ground flat', unit: 'A', key: 'cur_ground_flat' }, - 'current ratings in ground, flat*': { header: 'Current ground flat', unit: 'A', key: 'cur_ground_flat' }, - 'heating time constant, trefoil*': { header: 'Heating time trefoil', unit: 's', key: 'heat_trefoil' }, - 'heating time constant, trefoil': { header: 'Heating time trefoil', unit: 's', key: 'heat_trefoil' }, - 'heating time constant, flat*': { header: 'Heating time flat', unit: 's', key: 'heat_flat' }, - 'heating time constant, flat': { header: 'Heating time flat', unit: 's', key: 'heat_flat' }, - - // Temperature and other technical data - 'maximal operating conductor temperature': { header: 'Max operating temp', unit: '°C', key: 'max_op_temp' }, - 'maximal short-circuit temperature': { header: 'Max short-circuit temp', unit: '°C', key: 'max_sc_temp' }, - 'operating temperature range': { header: 'Operating temp range', unit: '°C', key: 'temp_range' }, - 'minimal storage temperature': { header: 'Min storage temp', unit: '°C', key: 'min_store_temp' }, - 'minimal temperature for laying': { header: 'Min laying temp', unit: '°C', key: 'min_lay_temp' }, - 'test voltage': { header: 'Test voltage', unit: 'kV', key: 'test_volt' }, - 'rated voltage': { header: 'Rated voltage', unit: 'kV', key: 'rated_volt' }, - - // Material and specification data - // Note: More specific patterns must come before generic ones to avoid conflicts - - // Conductor description/type (keep as technical/meta data, not as material column) - 'conductor': { header: 'Conductor', unit: '', key: 'conductor' }, - - // Screen and tape columns - 'copper wire screen and tape': { header: 'Copper screen', unit: '', key: 'copper_screen' }, - 'CUScreen': { header: 'Copper screen', unit: '', key: 'copper_screen' }, - 'conductive tape below screen': { header: 'Conductive tape below', unit: '', key: 'tape_below' }, - 'non conducting tape above screen': { header: 'Non-conductive tape above', unit: '', key: 'tape_above' }, - 'al foil': { header: 'Al foil', unit: '', key: 'al_foil' }, - - // Material properties - 'shape of conductor': { header: 'Conductor shape', unit: '', key: 'shape' }, - 'colour of insulation': { header: 'Insulation color', unit: '', key: 'color_ins' }, - 'colour of sheath': { header: 'Sheath color', unit: '', key: 'color_sheath' }, - 'insulation': { header: 'Insulation', unit: '', key: 'insulation' }, - 'sheath': { header: 'Sheath', unit: '', key: 'sheath' }, - 'norm': { header: 'Norm', unit: '', key: 'norm' }, - 'standard': { header: 'Standard', unit: '', key: 'standard' }, - 'cpr class': { header: 'CPR class', unit: '', key: 'cpr' }, - 'flame retardant': { header: 'Flame retardant', unit: '', key: 'flame' }, - 'self-extinguishing of single cable': { header: 'Flame retardant', unit: '', key: 'flame' }, - 'packaging': { header: 'Packaging', unit: '', key: 'packaging' }, - 'ce-conformity': { header: 'CE conformity', unit: '', key: 'ce' }, - 'rohs/reach': { header: 'RoHS/REACH', unit: '', key: 'rohs_reach' }, - }; - - // Get all Excel keys from sample - const excelKeys = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units'); - - // Find which Excel keys match our mapping - const matchedColumns: Array<{ excelKey: string; mapping: { header: string; unit: string; key: string } }> = []; - for (const excelKey of excelKeys) { - const normalized = normalizeValue(excelKey).toLowerCase(); - for (const [pattern, mapping] of Object.entries(columnMapping)) { - if (normalized === pattern.toLowerCase() || new RegExp(pattern, 'i').test(normalized)) { - matchedColumns.push({ excelKey, mapping }); - break; - } - } - } - - // Deduplicate by mapping.key to avoid duplicate columns - const seenKeys = new Set(); - const deduplicated: typeof matchedColumns = []; - for (const item of matchedColumns) { - if (!seenKeys.has(item.mapping.key)) { - seenKeys.add(item.mapping.key); - deduplicated.push(item); - } - } - matchedColumns.length = 0; - matchedColumns.push(...deduplicated); - - // Separate into 13 required headers vs additional columns - const requiredHeaderKeys = ['DI', 'RI', 'Wi', 'Ibl', 'Ibe', 'Ik_cond', 'Wm', 'Rbv', 'Ø', 'Fzv', 'Cond', 'G']; - const isRequiredHeader = (key: string) => requiredHeaderKeys.includes(key); - - // Filter rows to only include those with the same column structure as sample - const sampleKeys = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units').sort(); - const compatibleRows = rows.filter(r => { - const rKeys = Object.keys(r).filter(k => k && k !== 'Part Number' && k !== 'Units').sort(); - return JSON.stringify(rKeys) === JSON.stringify(sampleKeys); - }); - - if (compatibleRows.length === 0) return { ok: false, technicalItems: [], voltageTables: [] }; - - const csKey = - guessColumnKey(sample, [/number of cores and cross-section/i, /cross.?section/i, /ross section conductor/i]) || null; - const voltageKey = - guessColumnKey(sample, [/rated voltage/i, /voltage rating/i, /nennspannung/i, /spannungs/i]) || null; - - if (!csKey) return { ok: false, technicalItems: [], voltageTables: [] }; - - // Group rows by voltage rating - const byVoltage = new Map(); - for (let i = 0; i < compatibleRows.length; i++) { - const cs = normalizeValue(String(compatibleRows[i]?.[csKey] ?? '')); - if (!cs) continue; - const rawV = voltageKey ? normalizeValue(String(compatibleRows[i]?.[voltageKey] ?? '')) : ''; - const voltageLabel = normalizeVoltageLabel(rawV || ''); - const key = voltageLabel || (args.locale === 'de' ? 'Spannung unbekannt' : 'Voltage unknown'); - const arr = byVoltage.get(key) ?? []; - arr.push(i); - byVoltage.set(key, arr); - } - - const voltageTables: VoltageTableModel[] = []; - const technicalItems: KeyValueItem[] = []; - - const voltageKeysSorted = Array.from(byVoltage.keys()).sort((a, b) => { - const na = parseVoltageSortKey(a); - const nb = parseVoltageSortKey(b); - if (na !== nb) return na - nb; - return a.localeCompare(b); - }); - - // Track which columns are constant across ALL voltage groups (global constants) - const globalConstantColumns = new Set(); - - // First pass: identify columns that are constant across all rows - for (const { excelKey, mapping } of matchedColumns) { - const values = compatibleRows.map(r => normalizeValue(String(r?.[excelKey] ?? ''))).filter(Boolean); - const unique = Array.from(new Set(values.map(v => v.toLowerCase()))); - - if (unique.length === 1 && values.length > 0) { - globalConstantColumns.add(excelKey); - - // Global constants belong to TECHNICAL DATA (shown once). - const unit = normalizeUnit(units[excelKey] || mapping.unit || ''); - const labelBase = technicalFullLabel({ key: mapping.key, excelKey, locale: args.locale }); - const label = formatExcelHeaderLabel(labelBase, unit); - const value = compactCellForDenseTable(values[0], unit, args.locale); - const existing = technicalItems.find(t => t.label === label); - if (!existing) technicalItems.push({ label, value, unit }); - } - } - - // Second pass: for each voltage group, separate constant vs variable columns - for (const vKey of voltageKeysSorted) { - const indices = byVoltage.get(vKey) || []; - if (!indices.length) continue; - - const crossSections = indices.map(idx => normalizeValue(String(compatibleRows[idx]?.[csKey] ?? ''))); - - // Meta items: keep a consistent, compact set across products. - // This is the "voltage-group meta header" (parameter block) above each table. - const metaItems: KeyValueItem[] = []; - const metaCandidates = new Map(); - if (voltageKey) { - const rawV = normalizeValue(String(compatibleRows[indices[0]]?.[voltageKey] ?? '')); - metaItems.push({ - label: args.locale === 'de' ? 'Spannung' : 'Voltage', - value: normalizeVoltageLabel(rawV || ''), - }); - } - - // Which non-table fields we want to show consistently per voltage group. - const metaKeyPriority = [ - 'test_volt', - 'temp_range', - 'max_op_temp', - 'max_sc_temp', - 'min_lay_temp', - 'min_store_temp', - 'cpr', - 'flame', - ]; - const metaKeyPrioritySet = new Set(metaKeyPriority); - - // Cross-section table is limited to the core industry columns (compact for A4). - // To avoid “too little data” we always render the full set of core columns in the table, - // even when a value is constant across the voltage group. - const tableColumns: Array<{ excelKey: string; mapping: { header: string; unit: string; key: string } }> = []; - - const denseTableKeyOrder = [ - // Conductor should be the first technical column (after designation). - // This improves scanability in the cable industry (material/type is a primary discriminator). - 'Cond', - // Next highest priority for LV/MV: conductor shape/type (RE/RM/RMV/...). - 'shape', - - // Electrical properties (when available) – high value for MV/HV engineering. - 'cap', - 'X', - - 'DI', - 'RI', - 'Wi', - 'Ibl', - 'Ibe', - 'Ik_cond', - 'Wm', - 'Rbv', - 'Ø', - - // Extra high-value dimensions/metal screen info (when available): common in MV/HV. - // Keep close to Ø to support engineers comparing layer builds. - 'D_screen', - 'S_screen', - - 'Fzv', - 'G', - ] as const; - const denseTableKeys = new Set(denseTableKeyOrder); - - // Extra data handling: - // - global constants => TECHNICAL DATA - // - voltage-group constants => metaItems - // - voltage-group varying (non-core) => metaItems as ranges/lists (keeps A4 compact) - - // Pre-scan: detect if bending radius is expressed as xD (common in LV/Solar sheets) - // so we can label the unit correctly (Rbv [xD] instead of Rbv [mm]). - // Detect bending radius representation (mm vs xD) from the matched Excel column. - const bendingRadiusKey = matchedColumns.find(c => c.mapping.key === 'Rbv')?.excelKey || null; - let bendUnitOverride = ''; - if (bendingRadiusKey) { - const bendVals = indices - .map(idx => normalizeValue(String(compatibleRows[idx]?.[bendingRadiusKey] ?? ''))) - .filter(Boolean); - if (bendVals.some(v => /\bxD\b/i.test(v))) bendUnitOverride = 'xD'; - } - - // 1) Collect mapped columns. - for (const { excelKey, mapping } of matchedColumns) { - // Skip cross-section and voltage keys (these are used for grouping) - if (excelKey === csKey || excelKey === voltageKey) continue; - - // Get values for this voltage group - const values = indices.map(idx => normalizeValue(String(compatibleRows[idx]?.[excelKey] ?? ''))).filter(Boolean); - - if (values.length > 0) { - const unique = Array.from(new Set(values.map(v => v.toLowerCase()))); - let unit = normalizeUnit(units[excelKey] || mapping.unit || ''); - if (mapping.key === 'Rbv' && bendUnitOverride) unit = bendUnitOverride; - - // Always keep the core 13 columns in the table (even if constant). - if (denseTableKeys.has(mapping.key)) { - tableColumns.push({ excelKey, mapping }); - continue; - } - - // For meta header: collect candidates, but only *display* a consistent subset. - // Global constants are normally in TECHNICAL DATA, but we still allow them - // into the voltage meta block if they are part of the priority set. - if (globalConstantColumns.has(excelKey) && !metaKeyPrioritySet.has(mapping.key)) { - continue; - } - - const value = - unique.length === 1 - ? compactCellForDenseTable(values[0], unit, args.locale) - : summarizeSmartOptions(excelKey, values); - - // Meta header: keep labels fully readable (no abbreviations). - // Units are shown separately by the meta grid. - const label = metaFullLabel({ key: mapping.key, excelKey, locale: args.locale }); - - metaCandidates.set(mapping.key, { label, value, unit }); - } - } - - // 1b) Materialize meta items in a stable order. - // This keeps LV/MV/HV tables visually consistent (no "MV has much more in front"). - for (const k of metaKeyPriority) { - const item = metaCandidates.get(k); - if (item && item.label && item.value) metaItems.push(item); - } - - // 2) Build the compact cross-section table. - // If a column is not available in the Excel source and we cannot derive it safely, - // we omit it (empty columns waste A4 width and reduce readability). - const mappedByKey = new Map(); - for (const c of tableColumns) { - if (!mappedByKey.has(c.mapping.key)) mappedByKey.set(c.mapping.key, c); - } - - // Helper keys for derived values. - // We derive DI (diameter over insulation) from Ø and Wm when DI is missing. - const outerDiameterKey = (mappedByKey.get('Ø')?.excelKey || '') || null; - const sheathThicknessKey = (mappedByKey.get('Wm')?.excelKey || '') || null; - - const canDeriveDenseKey = (k: (typeof denseTableKeyOrder)[number]): boolean => { - if (k === 'DI') return Boolean(outerDiameterKey && sheathThicknessKey); - if (k === 'Cond') return true; // derived from product designation when missing - return false; - }; - - const orderedTableColumns = denseTableKeyOrder - .filter(k => mappedByKey.has(k) || canDeriveDenseKey(k)) - .map(k => { - const existing = mappedByKey.get(k); - if (existing) return existing; - return { - excelKey: '', - mapping: { header: k, unit: '', key: k }, - }; - }); - - // Debug: Check for duplicate keys - if (process.env.PDF_DEBUG_EXCEL === '1') { - const keys = tableColumns.map(c => c.mapping.key); - const duplicates = keys.filter((k, i) => keys.indexOf(k) !== i); - if (duplicates.length > 0) { - console.log(`[debug] Duplicate keys found: ${duplicates.join(', ')}`); - console.log(`[debug] All columns: ${tableColumns.map(c => c.mapping.key + '(' + c.mapping.header + ')').join(', ')}`); - } - } - - const columns = orderedTableColumns.map(({ excelKey, mapping }) => { - // Default units for the compact column set (used when an Excel unit is missing). - // We keep Excel units when available. - const defaultUnitByKey: Record = { - DI: 'mm', - RI: 'Ohm/km', - Wi: 'mm', - Ibl: 'A', - Ibe: 'A', - Ik_cond: 'kA', - Wm: 'mm', - Rbv: 'mm', - 'Ø': 'mm', - Fzv: 'N', - G: 'kg/km', - }; - - let unit = normalizeUnit((excelKey ? units[excelKey] : '') || mapping.unit || defaultUnitByKey[mapping.key] || ''); - if (mapping.key === 'Rbv' && bendUnitOverride) unit = bendUnitOverride; - - return { - key: mapping.key, - // Keep labels compact for dense tables; headerLabelFor() will use these. - label: denseAbbrevLabel({ key: mapping.key, locale: args.locale, unit, withUnit: true }) || formatExcelHeaderLabel(excelKey, unit), - get: (rowIndex: number) => { - const srcRowIndex = indices[rowIndex]; - const raw = excelKey ? normalizeValue(String(compatibleRows[srcRowIndex]?.[excelKey] ?? '')) : ''; - const unitLocal = unit; - - // LV sheets (e.g. NA2XY): current ratings (Ibl/Ibe) and short-circuit current (Ik) - // are typically not part of the source Excel. Keep cells empty (don’t guess). - // However, for DI we can derive a usable engineering approximation. - - // Derived values (only when the source column is missing/empty). - // DI (diameter over insulation): approx. from Ø and Wm when available. - if (mapping.key === 'DI' && !raw && outerDiameterKey && sheathThicknessKey) { - const odRaw = normalizeValue(String(compatibleRows[srcRowIndex]?.[outerDiameterKey] ?? '')); - const wmRaw = normalizeValue(String(compatibleRows[srcRowIndex]?.[sheathThicknessKey] ?? '')); - const od = parseNumericOption(odRaw); - const wm = parseNumericOption(wmRaw); - if (od !== null && wm !== null) { - const di = od - 2 * wm; - if (Number.isFinite(di) && di > 0) return `~${compactNumericForLocale(String(di), args.locale)}`; - } - } - - // Conductor material: if not present in Excel, derive from part number prefix. - // NA… => Al, N… => Cu (common cable designation pattern in this dataset). - if (mapping.key === 'Cond' && !raw) { - const pn = normalizeExcelKey(args.product.name || args.product.slug || args.product.sku || ''); - if (/^NA/.test(pn)) return 'Al'; - if (/^N/.test(pn)) return 'Cu'; - } - - // If bending radius is given as xD, keep it as-is (unit label reflects xD). - if (mapping.key === 'Rbv' && /\bxD\b/i.test(raw)) return compactNumericForLocale(raw, args.locale); - - // HV source: "Min. bending radius" appears to be stored in meters (e.g. 1.70). - // Convert to mm when we label it as mm. - if (mapping.key === 'Rbv' && unitLocal.toLowerCase() === 'mm') { - const n = parseNumericOption(raw); - const looksLikeMeters = n !== null && n > 0 && n < 50 && /[\.,]\d{1,3}/.test(raw) && !/\dxD/i.test(raw); - if (looksLikeMeters) return compactNumericForLocale(String(Math.round(n * 1000)), args.locale); - } - - // Pulling force: some sources appear to store kN values (e.g. 4.5) without unit. - // When header/unit is N and the value is small, normalize to N. - if (mapping.key === 'Fzv' && unitLocal.toLowerCase() === 'n') { - const n = parseNumericOption(raw); - const looksLikeKN = n !== null && n > 0 && n < 100 && !/\bN\b/i.test(raw) && !/\bkN\b/i.test(raw); - if (looksLikeKN) return compactNumericForLocale(String(Math.round(n * 1000)), args.locale); - } - - return compactCellForDenseTable(raw, unitLocal, args.locale); - }, - }; - }); - - voltageTables.push({ voltageLabel: vKey, metaItems, crossSections, columns }); - - // Debug: Show columns for this voltage - if (process.env.PDF_DEBUG_EXCEL === '1') { - console.log(`[debug] Voltage ${vKey}: ${columns.length} columns`); - console.log(`[debug] Columns: ${columns.map(c => c.key).join(', ')}`); - } - } - - technicalItems.sort((a, b) => a.label.localeCompare(b.label)); - - // Debug: Show technical items - if (process.env.PDF_DEBUG_EXCEL === '1') { - console.log(`[debug] Technical items: ${technicalItems.map(t => t.label).join(', ')}`); - } - - return { ok: true, technicalItems, voltageTables }; -} - -function drawDenseMetaGrid(args: { - title: string; - items: Array<{ label: string; value: string; unit?: string }>; - locale: 'en' | 'de'; - newPage: () => number; - getPage: () => PDFPage; - page: PDFPage; - y: number; - margin: number; - contentWidth: number; - contentMinY: number; - font: PDFFont; - fontBold: PDFFont; - navy: ReturnType; - darkGray: ReturnType; - mediumGray: ReturnType; -}): number { - let { page, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, mediumGray } = args; - let y = args.y; - const items = (args.items || []).filter(i => i.label && i.value); - if (!items.length) return y; - - const lightGray = rgb(0.9020, 0.9137, 0.9294); - const almostWhite = rgb(0.9725, 0.9765, 0.9804); - - // Redesign: boxed meta panel with clear alignment + separators. - // Goal: compact, but “datasheet-grade” readability. - const cols = items.length >= 7 ? 3 : 2; - const colGap = 0; // we use separators instead of gaps - const colWidth = (contentWidth - colGap * (cols - 1)) / cols; - // Keep enough vertical space so label + value never overlap (even with different font metrics). - const cellH = 34; - const padX = 10; - const titleH = 18; - const headerPadY = 10; - - const labelSize = 7.25; - const valueSize = 8.75; - const labelYOff = 12; - const valueYOff = 28; - - const rows = Math.ceil(items.length / cols); - const boxH = headerPadY + titleH + rows * cellH + headerPadY; - const needed = boxH + 10; - - if (y - needed < contentMinY) y = args.newPage(); - page = args.getPage(); - - const boxTopY = y; - const boxBottomY = boxTopY - boxH; - - // Outer frame - page.drawRectangle({ - x: margin, - y: boxBottomY, - width: contentWidth, - height: boxH, - borderColor: lightGray, - borderWidth: 1, - color: rgb(1, 1, 1), - }); - - // Title band - page.drawRectangle({ - x: margin, - y: boxTopY - (headerPadY + titleH), - width: contentWidth, - height: headerPadY + titleH, - color: almostWhite, - }); - - if (args.title) { - page.drawText(args.title, { - x: margin + padX, - y: boxTopY - (headerPadY + 12), - size: 10, - font: fontBold, - color: navy, - maxWidth: contentWidth - padX * 2, - }); - } - - // Separator below title band - const gridTopY = boxTopY - (headerPadY + titleH); - page.drawLine({ - start: { x: margin, y: gridTopY }, - end: { x: margin + contentWidth, y: gridTopY }, - thickness: 0.75, - color: lightGray, - }); - - // Grid cells - for (let r = 0; r < rows; r++) { - const rowTopY = gridTopY - r * cellH; - const rowBottomY = rowTopY - cellH; - - // Subtle zebra striping for scanability - if (r % 2 === 0) { - page.drawRectangle({ - x: margin, - y: rowBottomY, - width: contentWidth, - height: cellH, - color: rgb(0.99, 0.992, 0.995), - }); - } - - // Row separator (except last row) - if (r !== rows - 1) { - page.drawLine({ - start: { x: margin, y: rowBottomY }, - end: { x: margin + contentWidth, y: rowBottomY }, - thickness: 0.5, - color: lightGray, - }); - } - - for (let c = 0; c < cols; c++) { - const idx = r * cols + c; - const item = items[idx]; - const x0 = margin + c * (colWidth + colGap); - - // Vertical separators - if (c !== cols - 1) { - page.drawLine({ - start: { x: x0 + colWidth, y: rowBottomY }, - end: { x: x0 + colWidth, y: rowTopY }, - thickness: 0.5, - color: lightGray, - }); - } - - if (!item) continue; - - const labelText = expandMetaLabel(item.label, args.locale); - const valueText = item.unit ? `${item.value} ${item.unit}` : item.value; - - // Safety margin because pdf-lib does not clip text and font metrics can be slightly optimistic. - const maxW = colWidth - padX * 2 - 2; - const labelOneLine = ellipsizeToWidth(labelText, fontBold, labelSize, maxW); - const valueOneLine = ellipsizeToWidth(valueText, font, valueSize, maxW); - - page.drawText(labelOneLine, { - x: x0 + padX, - y: rowTopY - labelYOff, - size: labelSize, - font: fontBold, - color: mediumGray, - }); - page.drawText(valueOneLine, { - x: x0 + padX, - y: rowTopY - valueYOff, - size: valueSize, - font, - color: darkGray, - }); - } - } - - // Cursor below panel - return Math.max(contentMinY, boxBottomY - 18); -} - -function prioritizeColumnsForDenseTable(args: { - columns: VoltageTableModel['columns']; -}): VoltageTableModel['columns'] { - // Priority order: compact cross-section table columns first, then all additional technical data - const priorityOrder = [ - // Compact cross-section table columns (industry standard) - // NOTE: The designation/configuration is the *first* table column already. - // We put conductor material/type first among the technical columns for better scanability. - // Note: Ik is represented by Ik_cond (conductor shortcircuit current) - 'Cond', - 'shape', - 'cap', - 'X', - 'DI', - 'RI', - 'Wi', - 'Ibl', - 'Ibe', - 'Ik_cond', - 'Wm', - 'Rbv', - 'Ø', - // Extra high-value dimensions/metal screen info (when present) - 'D_screen', - 'S_screen', - 'Fzv', - 'G', - - // Additional technical columns (in logical groups) - // Dimensions and materials - 'cond_diam', 'shape', 'conductor', 'insulation', 'sheath', - // Temperatures - 'max_op_temp', 'max_sc_temp', 'temp_range', 'min_store_temp', 'min_lay_temp', - // Electrical properties - 'cap', 'ind_trefoil', 'ind_air_flat', 'ind_ground_flat', - // Current ratings (flat) - 'cur_air_flat', 'cur_ground_flat', - // Heating time constants - 'heat_trefoil', 'heat_flat', - // Voltage ratings - 'test_volt', 'rated_volt', - // Colors - 'color_ins', 'color_sheath', - // Materials and specs - 'norm', 'standard', 'cpr', 'flame', 'packaging', 'ce', - // Screen/tape layers - 'tape_below', 'copper_screen', 'tape_above', 'al_foil', - // Additional shortcircuit current - 'Ik_screen' - ]; - - return [...args.columns].sort((a, b) => { - const ia = priorityOrder.indexOf(a.key); - const ib = priorityOrder.indexOf(b.key); - if (ia !== -1 && ib !== -1) return ia - ib; - if (ia !== -1) return -1; - if (ib !== -1) return 1; - return a.key.localeCompare(b.key); - }); -} - -function normalizeExcelKey(value: string): string { - // Match product names/slugs and Excel "Part Number" robustly. - // Examples: - // - "NA2XS(FL)2Y" -> "NA2XSFL2Y" - // - "na2xsfl2y-3" -> "NA2XSFL2Y" - return String(value || '') - .toUpperCase() - .replace(/-\d+$/g, '') - .replace(/[^A-Z0-9]+/g, ''); -} - -function loadExcelRows(filePath: string): ExcelRow[] { - // We intentionally avoid adding a heavy xlsx parser dependency. - // Instead, we use `xlsx-cli` via npx, which is already available at runtime. - // NOTE: `xlsx-cli -j` prints the sheet name on the first line, then JSON. - const out = execSync(`npx -y xlsx-cli -j "${filePath}"`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); - const trimmed = out.trim(); - const jsonStart = trimmed.indexOf('['); - if (jsonStart < 0) return []; - const jsonText = trimmed.slice(jsonStart); - try { - return JSON.parse(jsonText) as ExcelRow[]; - } catch { - return []; - } -} - -function getExcelIndex(): Map { - if (EXCEL_INDEX) return EXCEL_INDEX; - const idx = new Map(); - for (const file of EXCEL_SOURCE_FILES) { - if (!fs.existsSync(file)) continue; - const rows = loadExcelRows(file); - if (process.env.PDF_DEBUG_EXCEL === '1') { - console.log(`[excel] loaded ${rows.length} rows from ${path.relative(process.cwd(), file)}`); - } - - const unitsRow = rows.find(r => r && r['Part Number'] === 'Units') || null; - const units: Record = {}; - if (unitsRow) { - for (const [k, v] of Object.entries(unitsRow)) { - if (k === 'Part Number') continue; - const unit = normalizeValue(String(v ?? '')); - if (unit) units[k] = unit; - } - } - - for (const r of rows) { - const pn = r?.['Part Number']; - if (!pn || pn === 'Units') continue; - const key = normalizeExcelKey(String(pn)); - if (!key) continue; - const cur = idx.get(key); - if (!cur) { - idx.set(key, { rows: [r], units }); - } else { - cur.rows.push(r); - if (Object.keys(cur.units).length < Object.keys(units).length) cur.units = units; - } - } - } - EXCEL_INDEX = idx; - return idx; -} - -function findExcelForProduct(product: ProductData): ExcelMatch | null { - const idx = getExcelIndex(); - const candidates = [ - product.name, - product.slug ? product.slug.replace(/-\d+$/g, '') : '', - product.sku, - product.translationKey, - ].filter(Boolean) as string[]; - - if (process.env.PDF_DEBUG_EXCEL === '1') { - const keys = candidates.map(c => normalizeExcelKey(c)); - console.log(`[excel] lookup product=${product.id} ${product.locale ?? ''} slug=${product.slug ?? ''} name=${stripHtml(product.name)} keys=${keys.join(',')}`); - } - - for (const c of candidates) { - const key = normalizeExcelKey(c); - const match = idx.get(key); - if (match && match.rows.length) return match; - } - return null; -} - -function findExcelRowsForProduct(product: ProductData): ExcelRow[] { - const match = findExcelForProduct(product); - if (!match?.rows || match.rows.length === 0) return []; - - const rows = match.rows; - - // Find the row with most columns as sample - let sample = rows.find(r => r && Object.keys(r).length > 0) || {}; - let maxColumns = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units').length; - - for (const r of rows) { - const cols = Object.keys(r).filter(k => k && k !== 'Part Number' && k !== 'Units').length; - if (cols > maxColumns) { - sample = r; - maxColumns = cols; - } - } - - // Filter to only rows with the same column structure as sample - const sampleKeys = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units').sort(); - const compatibleRows = rows.filter(r => { - const rKeys = Object.keys(r).filter(k => k && k !== 'Part Number' && k !== 'Units').sort(); - return JSON.stringify(rKeys) === JSON.stringify(sampleKeys); - }); - - return compatibleRows; -} - -function guessColumnKey(row: ExcelRow, patterns: RegExp[]): string | null { - const keys = Object.keys(row || {}); - - // Try pattern-based matching first - for (const re of patterns) { - const k = keys.find(x => { - const key = String(x); - - // Specific exclusions to prevent wrong matches - if (re.test('conductor') && /ross section conductor/i.test(key)) return false; - if (re.test('insulation thickness') && /Diameter over insulation/i.test(key)) return false; - if (re.test('conductor') && !/^conductor$/i.test(key)) return false; - if (re.test('insulation') && !/^insulation$/i.test(key)) return false; - if (re.test('sheath') && !/^sheath$/i.test(key)) return false; - if (re.test('norm') && !/^norm$/i.test(key)) return false; - - return re.test(key); - }); - if (k) return k; - } - return null; -} - -function hasAttr(product: ProductData, nameRe: RegExp, expectedLen?: number): boolean { - const a = product.attributes?.find(x => nameRe.test(x.name)); - if (!a) return false; - if (typeof expectedLen === 'number') return (a.options || []).length === expectedLen; - return (a.options || []).length > 0; -} - -function pushRowAttrIfMissing(args: { - product: ProductData; - name: string; - options: string[]; - expectedLen: number; - existsRe: RegExp; -}): void { - const { product, name, options, expectedLen, existsRe } = args; - if (!options.filter(Boolean).length) return; - if (hasAttr(product, existsRe, expectedLen)) return; - product.attributes = product.attributes || []; - product.attributes.push({ name, options }); -} - -function pushAttrIfMissing(args: { product: ProductData; name: string; options: string[]; existsRe: RegExp }): void { - const { product, name, options, existsRe } = args; - if (!options.filter(Boolean).length) return; - if (hasAttr(product, existsRe)) return; - product.attributes = product.attributes || []; - product.attributes.push({ name, options }); -} - -function getUniqueNonEmpty(options: string[]): string[] { - const uniq: string[] = []; - const seen = new Set(); - for (const v of options.map(normalizeValue).filter(Boolean)) { - const k = v.toLowerCase(); - if (seen.has(k)) continue; - seen.add(k); - uniq.push(v); - } - return uniq; -} - -function ensureExcelCrossSectionAttributes(product: ProductData, locale: 'en' | 'de'): void { - const hasCross = (product.attributes || []).some(a => /configuration|konfiguration|aufbau|bezeichnung|number of cores and cross-section|querschnitt|cross.?section|mm²|mm2/i.test(a.name) && (a.options?.length || 0) > 0); - if (hasCross) return; - - const rows = findExcelRowsForProduct(product); - if (!rows.length) { - if (process.env.PDF_DEBUG_EXCEL === '1') { - console.log(`[excel] no rows found for product ${product.id} (${product.slug ?? stripHtml(product.name)})`); - } - return; - } - - // Find the cross-section column. - const csKey = - guessColumnKey(rows[0], [ - /number of cores and cross-section/i, - /cross.?section/i, - /ross section conductor/i, - ]) || null; - if (!csKey) { - if (process.env.PDF_DEBUG_EXCEL === '1') { - console.log(`[excel] rows found but no cross-section column for product ${product.id}; available keys: ${Object.keys(rows[0] || {}).slice(0, 30).join(', ')}`); - } - return; - } - - // Get all technical column keys using improved detection - const voltageKey = guessColumnKey(rows[0], [/rated voltage/i, /voltage rating/i, /spannungs/i, /nennspannung/i]); - const outerKey = guessColumnKey(rows[0], [/outer diameter\b/i, /outer diameter.*approx/i, /outer diameter of cable/i, /außen/i]); - const weightKey = guessColumnKey(rows[0], [/weight\b/i, /gewicht/i, /cable weight/i]); - const dcResKey = guessColumnKey(rows[0], [/dc resistance/i, /resistance conductor/i, /leiterwiderstand/i]); - - // Additional technical columns - const ratedVoltKey = voltageKey; // Already found above - const testVoltKey = guessColumnKey(rows[0], [/test voltage/i, /prüfspannung/i]); - const tempRangeKey = guessColumnKey(rows[0], [/operating temperature range/i, /temperature range/i, /temperaturbereich/i]); - const minLayKey = guessColumnKey(rows[0], [/minimal temperature for laying/i]); - const minStoreKey = guessColumnKey(rows[0], [/minimal storage temperature/i]); - const maxOpKey = guessColumnKey(rows[0], [/maximal operating conductor temperature/i, /max\. operating/i]); - const maxScKey = guessColumnKey(rows[0], [/maximal short-circuit temperature/i, /short\s*circuit\s*temperature/i]); - const insThkKey = guessColumnKey(rows[0], [/nominal insulation thickness/i, /insulation thickness/i]); - const sheathThkKey = guessColumnKey(rows[0], [/nominal sheath thickness/i, /minimum sheath thickness/i]); - const maxResKey = guessColumnKey(rows[0], [/maximum resistance of conductor/i]); - - // Material and specification columns - const conductorKey = guessColumnKey(rows[0], [/^conductor$/i]); - const insulationKey = guessColumnKey(rows[0], [/^insulation$/i]); - const sheathKey = guessColumnKey(rows[0], [/^sheath$/i]); - const normKey = guessColumnKey(rows[0], [/^norm$/i, /^standard$/i]); - const cprKey = guessColumnKey(rows[0], [/cpr class/i]); - const rohsKey = guessColumnKey(rows[0], [/^rohs$/i]); - const reachKey = guessColumnKey(rows[0], [/^reach$/i]); - const packagingKey = guessColumnKey(rows[0], [/^packaging$/i]); - const shapeKey = guessColumnKey(rows[0], [/shape of conductor/i]); - const flameKey = guessColumnKey(rows[0], [/flame retardant/i]); - const diamCondKey = guessColumnKey(rows[0], [/diameter conductor/i]); - const diamInsKey = guessColumnKey(rows[0], [/diameter over insulation/i]); - const diamScreenKey = guessColumnKey(rows[0], [/diameter over screen/i]); - const metalScreenKey = guessColumnKey(rows[0], [/metallic screen/i]); - const capacitanceKey = guessColumnKey(rows[0], [/capacitance/i]); - const reactanceKey = guessColumnKey(rows[0], [/reactance/i]); - const electricalStressKey = guessColumnKey(rows[0], [/electrical stress/i]); - const pullingForceKey = guessColumnKey(rows[0], [/max\. pulling force/i, /pulling force/i]); - const heatingTrefoilKey = guessColumnKey(rows[0], [/heating time constant.*trefoil/i]); - const heatingFlatKey = guessColumnKey(rows[0], [/heating time constant.*flat/i]); - const currentAirTrefoilKey = guessColumnKey(rows[0], [/current ratings in air.*trefoil/i]); - const currentAirFlatKey = guessColumnKey(rows[0], [/current ratings in air.*flat/i]); - const currentGroundTrefoilKey = guessColumnKey(rows[0], [/current ratings in ground.*trefoil/i]); - const currentGroundFlatKey = guessColumnKey(rows[0], [/current ratings in ground.*flat/i]); - const scCurrentCondKey = guessColumnKey(rows[0], [/conductor shortcircuit current/i]); - const scCurrentScreenKey = guessColumnKey(rows[0], [/screen shortcircuit current/i]); - - const cfgName = locale === 'de' ? 'Anzahl der Adern und Querschnitt' : 'Number of cores and cross-section'; - const cfgOptions = rows - .map(r => { - const cs = normalizeValue(String(r?.[csKey] ?? '')); - const v = voltageKey ? normalizeValue(String(r?.[voltageKey] ?? '')) : ''; - if (!cs) return ''; - if (!v) return cs; - // Keep the existing config separator used by splitConfig(): "cross - voltage". - // Add unit only if not already present. - const vHasUnit = /\bkv\b/i.test(v); - const vText = vHasUnit ? v : `${v} kV`; - return `${cs} - ${vText}`; - }) - .filter(Boolean); - - if (!cfgOptions.length) return; - - const attrs = product.attributes || []; - attrs.push({ name: cfgName, options: cfgOptions }); - - const pushRowAttr = (name: string, key: string | null, unit?: string) => { - if (!key) return; - const options = rows - .map(r => normalizeValue(String(r?.[key] ?? ''))) - .map(v => (unit && v && looksNumeric(v) ? `${v} ${unit}` : v)); - if (options.filter(Boolean).length === 0) return; - attrs.push({ name, options }); - }; - - // These names are chosen so existing PDF regexes can detect them. - pushRowAttr(locale === 'de' ? 'Außen-Ø' : 'Outer diameter', outerKey, 'mm'); - pushRowAttr(locale === 'de' ? 'Gewicht' : 'Weight', weightKey, 'kg/km'); - pushRowAttr(locale === 'de' ? 'DC-Leiterwiderstand (20C)' : 'DC resistance at 20 C', dcResKey, 'Ohm/km'); - - const colValues = (key: string | null) => rows.map(r => normalizeValue(String(r?.[key ?? ''] ?? ''))); - - const addConstOrSmallList = (args: { name: string; existsRe: RegExp; key: string | null }) => { - if (!args.key) return; - const uniq = getUniqueNonEmpty(colValues(args.key)); - if (!uniq.length) return; - // If all rows share the same value, store as single option. - if (uniq.length === 1) { - pushAttrIfMissing({ product, name: args.name, options: [uniq[0]], existsRe: args.existsRe }); - return; - } - // Otherwise store the unique set (TECHNICAL DATA will compact it). - pushAttrIfMissing({ product, name: args.name, options: uniq, existsRe: args.existsRe }); - }; - - addConstOrSmallList({ - name: locale === 'de' ? 'Nennspannung' : 'Rated voltage', - existsRe: /rated\s*voltage|voltage\s*rating|nennspannung|spannungsbereich/i, - key: ratedVoltKey, - }); - addConstOrSmallList({ - name: locale === 'de' ? 'Prüfspannung' : 'Test voltage', - existsRe: /test\s*voltage|prüfspannung/i, - key: testVoltKey, - }); - addConstOrSmallList({ - name: locale === 'de' ? 'Temperaturbereich' : 'Operating temperature range', - existsRe: /operating\s*temperature\s*range|temperature\s*range|temperaturbereich/i, - key: tempRangeKey, - }); - addConstOrSmallList({ - name: locale === 'de' ? 'Min. Verlegetemperatur' : 'Minimal temperature for laying', - existsRe: /minimal\s*temperature\s*for\s*laying/i, - key: minLayKey, - }); - addConstOrSmallList({ - name: locale === 'de' ? 'Min. Lagertemperatur' : 'Minimal storage temperature', - existsRe: /minimal\s*storage\s*temperature/i, - key: minStoreKey, - }); - addConstOrSmallList({ - name: locale === 'de' ? 'Max. Betriebstemperatur' : 'Maximal operating conductor temperature', - existsRe: /maximal\s*operating\s*conductor\s*temperature|max\.?\s*operating/i, - key: maxOpKey, - }); - addConstOrSmallList({ - name: locale === 'de' ? 'Kurzschlusstemperatur (max.)' : 'Maximal short-circuit temperature', - existsRe: /maximal\s*short-?circuit\s*temperature|short\s*circuit\s*temperature|kurzschlusstemperatur/i, - key: maxScKey, - }); - addConstOrSmallList({ - name: locale === 'de' ? 'Isolationsdicke (nom.)' : 'Nominal insulation thickness', - existsRe: /nominal\s*insulation\s*thickness|insulation\s*thickness/i, - key: insThkKey, - }); - addConstOrSmallList({ - name: locale === 'de' ? 'Manteldicke (nom.)' : 'Nominal sheath thickness', - existsRe: /nominal\s*sheath\s*thickness|minimum\s*sheath\s*thickness|manteldicke/i, - key: sheathThkKey, - }); - addConstOrSmallList({ - name: locale === 'de' ? 'Max. Leiterwiderstand' : 'Maximum resistance of conductor', - existsRe: /maximum\s*resistance\s*of\s*conductor|max\.?\s*resistance|leiterwiderstand/i, - key: maxResKey, - }); - - // Add additional technical data from Excel files - addConstOrSmallList({ - name: locale === 'de' ? 'Leiter' : 'Conductor', - existsRe: /conductor/i, - key: conductorKey, - }); - - addConstOrSmallList({ - name: locale === 'de' ? 'Isolierung' : 'Insulation', - existsRe: /insulation/i, - key: insulationKey, - }); - - addConstOrSmallList({ - name: locale === 'de' ? 'Mantel' : 'Sheath', - existsRe: /sheath/i, - key: sheathKey, - }); - - addConstOrSmallList({ - name: locale === 'de' ? 'Norm' : 'Standard', - existsRe: /norm|standard|iec|vde/i, - key: normKey, - }); - - addConstOrSmallList({ - name: locale === 'de' ? 'Leiterdurchmesser' : 'Conductor diameter', - existsRe: /diameter conductor|conductor diameter/i, - key: diamCondKey, - }); - - addConstOrSmallList({ - name: locale === 'de' ? 'Isolierungsdurchmesser' : 'Insulation diameter', - existsRe: /diameter over insulation|diameter insulation/i, - key: diamInsKey, - }); - - addConstOrSmallList({ - name: locale === 'de' ? 'Schirmdurchmesser' : 'Screen diameter', - existsRe: /diameter over screen|diameter screen/i, - key: diamScreenKey, - }); - - addConstOrSmallList({ - name: locale === 'de' ? 'Metallischer Schirm' : 'Metallic screen', - existsRe: /metallic screen/i, - key: metalScreenKey, - }); - - addConstOrSmallList({ - name: locale === 'de' ? 'Max. Zugkraft' : 'Max. pulling force', - existsRe: /max.*pulling force|pulling force/i, - key: pullingForceKey, - }); - - addConstOrSmallList({ - name: locale === 'de' ? 'Elektrische Spannung Leiter' : 'Electrical stress conductor', - existsRe: /electrical stress conductor/i, - key: electricalStressKey, - }); - - addConstOrSmallList({ - name: locale === 'de' ? 'Elektrische Spannung Isolierung' : 'Electrical stress insulation', - existsRe: /electrical stress insulation/i, - key: electricalStressKey, - }); - - addConstOrSmallList({ - name: locale === 'de' ? 'Reaktanz' : 'Reactance', - existsRe: /reactance/i, - key: reactanceKey, - }); - - addConstOrSmallList({ - name: locale === 'de' ? 'Heizzeitkonstante trefoil' : 'Heating time constant trefoil', - existsRe: /heating time constant.*trefoil/i, - key: heatingTrefoilKey, - }); - - addConstOrSmallList({ - name: locale === 'de' ? 'Heizzeitkonstante flach' : 'Heating time constant flat', - existsRe: /heating time constant.*flat/i, - key: heatingFlatKey, - }); - - addConstOrSmallList({ - name: locale === 'de' ? 'Flammhemmend' : 'Flame retardant', - existsRe: /flame retardant/i, - key: flameKey, - }); - - addConstOrSmallList({ - name: locale === 'de' ? 'CPR-Klasse' : 'CPR class', - existsRe: /cpr class/i, - key: cprKey, - }); - - addConstOrSmallList({ - name: locale === 'de' ? 'Verpackung' : 'Packaging', - existsRe: /packaging/i, - key: packagingKey, - }); - - addConstOrSmallList({ - name: locale === 'de' ? 'Biegeradius' : 'Bending radius', - existsRe: /bending radius/i, - key: null, // Will be found in row-specific attributes - }); - - addConstOrSmallList({ - name: locale === 'de' ? 'Leiterform' : 'Shape of conductor', - existsRe: /shape of conductor/i, - key: shapeKey, - }); - - addConstOrSmallList({ - name: locale === 'de' ? 'Isolierungsfarbe' : 'Colour of insulation', - existsRe: /colour of insulation/i, - key: null, // Will be found in row-specific attributes - }); - - addConstOrSmallList({ - name: locale === 'de' ? 'Mantelfarbe' : 'Colour of sheath', - existsRe: /colour of sheath/i, - key: null, // Will be found in row-specific attributes - }); - - addConstOrSmallList({ - name: locale === 'de' ? 'RoHS/REACH' : 'RoHS/REACH', - existsRe: /rohs.*reach/i, - key: null, // Will be found in row-specific attributes - }); - - product.attributes = attrs; - - if (process.env.PDF_DEBUG_EXCEL === '1') { - console.log(`[excel] enriched product ${product.id} (${product.slug ?? stripHtml(product.name)}) with ${cfgOptions.length} configurations from excel`); - } -} - -function ensureExcelRowSpecificAttributes(product: ProductData, locale: 'en' | 'de'): void { - const rows = findExcelRowsForProduct(product); - if (!rows.length) return; - - const crossSectionAttr = - findAttr(product, /configuration|konfiguration|aufbau|bezeichnung/i) || - findAttr(product, /number of cores and cross-section|querschnitt|cross.?section|mm²|mm2/i); - if (!crossSectionAttr || !crossSectionAttr.options?.length) return; - - const rowCount = crossSectionAttr.options.length; - // Only enrich row-specific columns when row counts match (avoid wrong mapping). - if (rows.length !== rowCount) return; - - const sample = rows[0] || {}; - - const keyOuter = guessColumnKey(sample, [/outer diameter \(approx\.?\)/i, /outer diameter of cable/i, /outer diameter\b/i, /diameter over screen/i]); - const keyWeight = guessColumnKey(sample, [/weight \(approx\.?\)/i, /cable weight/i, /\bweight\b/i]); - const keyDcRes = guessColumnKey(sample, [/dc resistance at 20/i, /maximum resistance of conductor/i, /resistance conductor/i]); - const keyCap = guessColumnKey(sample, [/capacitance/i]); - const keyIndTrefoil = guessColumnKey(sample, [/inductance,?\s*trefoil/i]); - const keyIndAirFlat = guessColumnKey(sample, [/inductance in air,?\s*flat/i]); - const keyIndGroundFlat = guessColumnKey(sample, [/inductance in ground,?\s*flat/i]); - const keyIairTrefoil = guessColumnKey(sample, [/current ratings in air,?\s*trefoil/i]); - const keyIairFlat = guessColumnKey(sample, [/current ratings in air,?\s*flat/i]); - const keyIgroundTrefoil = guessColumnKey(sample, [/current ratings in ground,?\s*trefoil/i]); - const keyIgroundFlat = guessColumnKey(sample, [/current ratings in ground,?\s*flat/i]); - const keyScCond = guessColumnKey(sample, [/conductor shortcircuit current/i]); - const keyScScreen = guessColumnKey(sample, [/screen shortcircuit current/i]); - const keyBend = guessColumnKey(sample, [/bending radius/i, /min\. bending radius/i]); - - // Additional row-specific technical data - const keyConductorDiameter = guessColumnKey(sample, [/conductor diameter/i, /diameter conductor/i]); - const keyInsulationThickness = guessColumnKey(sample, [/nominal insulation thickness/i, /insulation thickness/i]); - const keySheathThickness = guessColumnKey(sample, [/nominal sheath thickness/i, /minimum sheath thickness/i, /sheath thickness/i]); - const keyCapacitance = guessColumnKey(sample, [/capacitance/i]); - const keyInductanceTrefoil = guessColumnKey(sample, [/inductance.*trefoil/i]); - const keyInductanceAirFlat = guessColumnKey(sample, [/inductance.*air.*flat/i]); - const keyInductanceGroundFlat = guessColumnKey(sample, [/inductance.*ground.*flat/i]); - const keyCurrentAirTrefoil = guessColumnKey(sample, [/current.*air.*trefoil/i]); - const keyCurrentAirFlat = guessColumnKey(sample, [/current.*air.*flat/i]); - const keyCurrentGroundTrefoil = guessColumnKey(sample, [/current.*ground.*trefoil/i]); - const keyCurrentGroundFlat = guessColumnKey(sample, [/current.*ground.*flat/i]); - const keyHeatingTimeTrefoil = guessColumnKey(sample, [/heating.*time.*trefoil/i]); - const keyHeatingTimeFlat = guessColumnKey(sample, [/heating.*time.*flat/i]); - - const get = (k: string | null) => rows.map(r => normalizeValue(String(r?.[k ?? ''] ?? ''))); - const withUnit = (vals: string[], unit: string) => vals.map(v => (v && looksNumeric(v) ? `${v} ${unit}` : v)); - - // Use labels that are already recognized by the existing PDF regexes. - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'Außen-Ø' : 'Outer diameter', - options: withUnit(get(keyOuter), 'mm'), - expectedLen: rowCount, - existsRe: /outer\s*diameter|außen\s*durchmesser|außen-?ø/i, - }); - - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'Gewicht' : 'Weight', - options: withUnit(get(keyWeight), 'kg/km'), - expectedLen: rowCount, - existsRe: /\bweight\b|gewicht/i, - }); - - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'DC-Leiterwiderstand (20C)' : 'DC resistance at 20 C', - options: withUnit(get(keyDcRes), 'Ohm/km'), - expectedLen: rowCount, - existsRe: /dc\s*resistance|max(?:imum)?\s*resistance|resistance\s+conductor|leiterwiderstand/i, - }); - - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'Kapazität (ca.)' : 'Capacitance (approx.)', - options: withUnit(get(keyCap), 'uF/km'), - expectedLen: rowCount, - existsRe: /capacitance|kapazit/i, - }); - - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'Induktivität, trefoil (ca.)' : 'Inductance, trefoil (approx.)', - options: withUnit(get(keyIndTrefoil), 'mH/km'), - expectedLen: rowCount, - existsRe: /inductance,?\s*trefoil/i, - }); - - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'Induktivität in Luft, flach (ca.)' : 'Inductance in air, flat (approx.)', - options: withUnit(get(keyIndAirFlat), 'mH/km'), - expectedLen: rowCount, - existsRe: /inductance\s+in\s+air,?\s*flat/i, - }); - - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'Induktivität im Erdreich, flach (ca.)' : 'Inductance in ground, flat (approx.)', - options: withUnit(get(keyIndGroundFlat), 'mH/km'), - expectedLen: rowCount, - existsRe: /inductance\s+in\s+ground,?\s*flat/i, - }); - - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'Strombelastbarkeit in Luft, trefoil' : 'Current ratings in air, trefoil', - options: withUnit(get(keyIairTrefoil), 'A'), - expectedLen: rowCount, - existsRe: /current\s+ratings\s+in\s+air,?\s*trefoil/i, - }); - - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'Strombelastbarkeit in Luft, flach' : 'Current ratings in air, flat', - options: withUnit(get(keyIairFlat), 'A'), - expectedLen: rowCount, - existsRe: /current\s+ratings\s+in\s+air,?\s*flat/i, - }); - - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'Strombelastbarkeit im Erdreich, trefoil' : 'Current ratings in ground, trefoil', - options: withUnit(get(keyIgroundTrefoil), 'A'), - expectedLen: rowCount, - existsRe: /current\s+ratings\s+in\s+ground,?\s*trefoil/i, - }); - - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'Strombelastbarkeit im Erdreich, flach' : 'Current ratings in ground, flat', - options: withUnit(get(keyIgroundFlat), 'A'), - expectedLen: rowCount, - existsRe: /current\s+ratings\s+in\s+ground,?\s*flat/i, - }); - - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'Kurzschlussstrom Leiter' : 'Conductor shortcircuit current', - options: withUnit(get(keyScCond), 'kA'), - expectedLen: rowCount, - existsRe: /conductor\s+shortcircuit\s+current/i, - }); - - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'Kurzschlussstrom Schirm' : 'Screen shortcircuit current', - options: withUnit(get(keyScScreen), 'kA'), - expectedLen: rowCount, - existsRe: /screen\s+shortcircuit\s+current/i, - }); - - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'Biegeradius (min.)' : 'Bending radius (min.)', - options: withUnit(get(keyBend), 'mm'), - expectedLen: rowCount, - existsRe: /bending\s*radius|biegeradius/i, - }); - - // Additional row-specific technical data - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'Leiterdurchmesser' : 'Conductor diameter', - options: withUnit(get(keyConductorDiameter), 'mm'), - expectedLen: rowCount, - existsRe: /conductor diameter|diameter conductor/i, - }); - - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'Isolationsdicke' : 'Insulation thickness', - options: withUnit(get(keyInsulationThickness), 'mm'), - expectedLen: rowCount, - existsRe: /insulation thickness|nominal insulation thickness/i, - }); - - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'Manteldicke' : 'Sheath thickness', - options: withUnit(get(keySheathThickness), 'mm'), - expectedLen: rowCount, - existsRe: /sheath thickness|nominal sheath thickness/i, - }); - - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'Kapazität' : 'Capacitance', - options: withUnit(get(keyCapacitance), 'uF/km'), - expectedLen: rowCount, - existsRe: /capacitance/i, - }); - - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'Induktivität trefoil' : 'Inductance trefoil', - options: withUnit(get(keyInductanceTrefoil), 'mH/km'), - expectedLen: rowCount, - existsRe: /inductance.*trefoil/i, - }); - - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'Induktivität Luft flach' : 'Inductance air flat', - options: withUnit(get(keyInductanceAirFlat), 'mH/km'), - expectedLen: rowCount, - existsRe: /inductance.*air.*flat/i, - }); - - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'Induktivität Erdreich flach' : 'Inductance ground flat', - options: withUnit(get(keyInductanceGroundFlat), 'mH/km'), - expectedLen: rowCount, - existsRe: /inductance.*ground.*flat/i, - }); - - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'Strombelastbarkeit Luft trefoil' : 'Current rating air trefoil', - options: withUnit(get(keyCurrentAirTrefoil), 'A'), - expectedLen: rowCount, - existsRe: /current.*air.*trefoil/i, - }); - - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'Strombelastbarkeit Luft flach' : 'Current rating air flat', - options: withUnit(get(keyCurrentAirFlat), 'A'), - expectedLen: rowCount, - existsRe: /current.*air.*flat/i, - }); - - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'Strombelastbarkeit Erdreich trefoil' : 'Current rating ground trefoil', - options: withUnit(get(keyCurrentGroundTrefoil), 'A'), - expectedLen: rowCount, - existsRe: /current.*ground.*trefoil/i, - }); - - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'Strombelastbarkeit Erdreich flach' : 'Current rating ground flat', - options: withUnit(get(keyCurrentGroundFlat), 'A'), - expectedLen: rowCount, - existsRe: /current.*ground.*flat/i, - }); - - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'Heizzeitkonstante trefoil' : 'Heating time constant trefoil', - options: withUnit(get(keyHeatingTimeTrefoil), 's'), - expectedLen: rowCount, - existsRe: /heating.*time.*trefoil/i, - }); - - pushRowAttrIfMissing({ - product, - name: locale === 'de' ? 'Heizzeitkonstante flach' : 'Heating time constant flat', - options: withUnit(get(keyHeatingTimeFlat), 's'), - expectedLen: rowCount, - existsRe: /heating.*time.*flat/i, - }); -} - -function getProductUrl(product: ProductData): string | null { - if (!product.path) return null; - return `https://klz-cables.com${product.path}`; -} - -function drawKeyValueGrid(args: { - title: string; - items: Array<{ label: string; value: string }>; - newPage: () => number; - getPage: () => PDFPage; - page: PDFPage; - y: number; - margin: number; - contentWidth: number; - contentMinY: number; - font: PDFFont; - fontBold: PDFFont; - navy: ReturnType; - darkGray: ReturnType; - mediumGray: ReturnType; - lightGray?: ReturnType; - almostWhite?: ReturnType; - allowNewPage?: boolean; - boxed?: boolean; -}): number { - let { title, items, newPage, getPage, page, y, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, mediumGray } = args; - const allowNewPage = args.allowNewPage ?? true; - const boxed = args.boxed ?? false; - - const lightGray = args.lightGray ?? rgb(0.9020, 0.9137, 0.9294); - const almostWhite = args.almostWhite ?? rgb(0.9725, 0.9765, 0.9804); - - // Inner layout (boxed vs. plain) - // Keep a strict spacing system for more professional datasheets. - const padX = boxed ? 16 : 0; - const padY = boxed ? 14 : 0; - const xBase = margin + padX; - const innerWidth = contentWidth - padX * 2; - const colGap = 16; - const colW = (innerWidth - colGap) / 2; - const rowH = 24; - const headerH = boxed ? 22 : 0; - - const drawBoxFrame = (boxTopY: number, rowsCount: number) => { - const boxH = padY + headerH + rowsCount * rowH + padY; - page = getPage(); - page.drawRectangle({ - x: margin, - y: boxTopY - boxH, - width: contentWidth, - height: boxH, - borderColor: lightGray, - borderWidth: 1, - color: rgb(1, 1, 1), - }); - // Header band for the title - page.drawRectangle({ - x: margin, - y: boxTopY - headerH, - width: contentWidth, - height: headerH, - color: almostWhite, - }); - return boxH; - }; - - const drawTitle = () => { - page = getPage(); - if (boxed) { - // Align title inside the header band. - page.drawText(title, { x: xBase, y: y - 15, size: 11, font: fontBold, color: navy }); - // Divider line below header band - page.drawLine({ - start: { x: margin, y: y - headerH }, - end: { x: margin + contentWidth, y: y - headerH }, - thickness: 0.75, - color: lightGray, - }); - y -= headerH + padY; - } else { - page.drawText(title, { x: margin, y, size: 10, font: fontBold, color: navy }); - y -= 16; - } - }; - - if (y - 22 < contentMinY) { - if (!allowNewPage) return contentMinY - 1; - y = newPage(); - } - page = getPage(); - - // Boxed + multi-page: render separate boxes per page segment. - if (boxed && allowNewPage) { - let i = 0; - while (i < items.length) { - // Compute how many rows fit in the remaining space. - const available = y - contentMinY; - const maxRows = Math.max(1, Math.floor((available - (headerH + padY * 2)) / rowH)); - const maxItems = Math.max(2, maxRows * 2); - const slice = items.slice(i, i + maxItems); - const rowsCount = Math.ceil(slice.length / 2); - const neededH = padY + headerH + rowsCount * rowH + padY; - - if (y - neededH < contentMinY) y = newPage(); - drawBoxFrame(y, rowsCount); - - drawTitle(); - let rowY = y; - for (let j = 0; j < slice.length; j++) { - const col = j % 2; - const x = xBase + col * (colW + colGap); - const { label, value } = slice[j]; - page.drawText(label, { x, y: rowY, size: 7.5, font: fontBold, color: mediumGray, maxWidth: colW }); - page.drawText(value, { x, y: rowY - 12, size: 9.5, font, color: darkGray, maxWidth: colW }); - if (col === 1) rowY -= rowH; - } - - y = rowY - rowH - padY; - i += slice.length; - if (i < items.length) y = newPage(); - } - return y; - } - - // Boxed single-segment (current page) or plain continuation. - if (boxed && items.length) { - const rows = Math.ceil(items.length / 2); - const neededH = padY + headerH + rows * rowH + padY; - if (y - neededH < contentMinY) { - if (!allowNewPage) return contentMinY - 1; - y = newPage(); - } - drawBoxFrame(y, rows); - } - - drawTitle(); - - let rowY = y; - for (let i = 0; i < items.length; i++) { - const col = i % 2; - const x = xBase + col * (colW + colGap); - const { label, value } = items[i]; - - if (col === 0 && rowY - rowH < contentMinY) { - if (!allowNewPage) return contentMinY - 1; - y = newPage(); - page = getPage(); - if (!boxed) drawTitle(); - rowY = y; - } - - // Don't truncate - allow text to fit naturally - page.drawText(label, { x, y: rowY, size: 7.5, font: fontBold, color: mediumGray }); - page.drawText(value, { x, y: rowY - 12, size: 9.5, font, color: darkGray }); - - if (col === 1) rowY -= rowH; - } - - return boxed ? rowY - rowH - padY : rowY - rowH; -} +} as const; function ensureOutputDir(): void { if (!fs.existsSync(CONFIG.outputDir)) { @@ -2139,1936 +25,90 @@ function ensureOutputDir(): void { } } -const stripHtml = (html: string): string => { - if (!html) return ''; - // IMPORTANT: Keep umlauts and common Latin-1 chars (e.g. ü/ö/ä/ß) for DE PDFs. - // pdf-lib's StandardFonts cover WinAnsi; we only normalize “problematic” typography. - let text = html.replace(/<[^>]*>/g, '').normalize('NFC'); - text = text - // whitespace normalization - .replace(/[\u00A0\u202F]/g, ' ') // nbsp / narrow nbsp - // typography normalization - .replace(/[\u2013\u2014]/g, '-') // en/em dash - .replace(/[\u2018\u2019]/g, "'") // curly single quotes - .replace(/[\u201C\u201D]/g, '"') // curly double quotes - .replace(/\u2026/g, '...') // ellipsis - // symbols that can be missing in some encodings - .replace(/[\u2022]/g, '·') // bullet - // math symbols (WinAnsi can't encode these) - .replace(/[\u2264]/g, '<=') // ≤ - .replace(/[\u2265]/g, '>=') // ≥ - .replace(/[\u2248]/g, '~') // ≈ - // electrical symbols (keep meaning; avoid encoding errors) - .replace(/[\u03A9\u2126]/g, 'Ohm') // Ω / Ω - // micro sign / greek mu (WinAnsi can't encode these reliably) - .replace(/[\u00B5\u03BC]/g, 'u') // µ / μ - // directional arrows (WinAnsi can't encode these) - .replace(/[\u2193]/g, 'v') // ↓ - .replace(/[\u2191]/g, '^') // ↑ - // degree symbol (WinAnsi-safe; keep for engineering units like °C) - .replace(/[\u00B0]/g, '°'); - - // Remove control chars, keep all printable unicode. - text = text.replace(/[\u0000-\u001F\u007F]/g, ''); - return text.replace(/\s+/g, ' ').trim(); -}; - -const getLabels = (locale: 'en' | 'de') => ({ - en: { - datasheet: 'PRODUCT DATASHEET', - description: 'DESCRIPTION', - specs: 'TECHNICAL SPECIFICATIONS', - crossSection: 'CROSS-SECTION DATA', - categories: 'CATEGORIES', - sku: 'SKU', - }, - de: { - datasheet: 'PRODUKTDATENBLATT', - description: 'BESCHREIBUNG', - specs: 'TECHNISCHE SPEZIFIKATIONEN', - crossSection: 'QUERSCHNITTSDATEN', - categories: 'KATEGORIEN', - sku: 'ARTIKELNUMMER', - }, -})[locale]; - -const generateFileName = (product: ProductData, locale: 'en' | 'de'): string => { - const baseName = product.slug || product.translationKey || `product-${product.id}`; - const cleanSlug = baseName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, ''); - return `${cleanSlug}-${locale}.pdf`; -}; - -function wrapText(text: string, font: PDFFont, fontSize: number, maxWidth: number): string[] { - const words = text.split(' '); - const lines: string[] = []; - let currentLine = ''; - - const isOrphanWord = (w: string) => { - // Avoid ugly single short words on their own line in DE/EN (e.g. “im”, “in”, “to”). - // This is a typography/UX improvement for datasheets. - const s = w.trim(); - return s.length > 0 && s.length <= 2; - }; - - for (let i = 0; i < words.length; i++) { - const word = words[i]; - const next = i + 1 < words.length ? words[i + 1] : ''; - const testLine = currentLine ? `${currentLine} ${word}` : word; - - if (font.widthOfTextAtSize(testLine, fontSize) <= maxWidth) { - // Orphan control: if adding the *next* word would overflow, don't end the line with a tiny orphan. - // Example: "... mechanischen im" + "Belastungen" should become "... mechanischen" / "im Belastungen ...". - if (currentLine && next && isOrphanWord(word)) { - const testWithNext = `${testLine} ${next}`; - if (font.widthOfTextAtSize(testWithNext, fontSize) > maxWidth) { - lines.push(currentLine); - currentLine = word; - continue; - } - } - - currentLine = testLine; - } else { - if (currentLine) lines.push(currentLine); - currentLine = word; - } - } - if (currentLine) lines.push(currentLine); - return lines; -} - -function ellipsizeToWidth(text: string, font: PDFFont, fontSize: number, maxWidth: number): string { - // WinAnsi-safe ellipsis ("...") - const t = normalizeValue(text); - if (!t) return ''; - if (font.widthOfTextAtSize(t, fontSize) <= maxWidth) return t; - - const ellipsis = '...'; - const eW = font.widthOfTextAtSize(ellipsis, fontSize); - if (eW >= maxWidth) return ''; - - // Binary search for max prefix that fits. - let lo = 0; - let hi = t.length; - while (lo < hi) { - const mid = Math.ceil((lo + hi) / 2); - const s = t.slice(0, mid); - if (font.widthOfTextAtSize(s, fontSize) + eW <= maxWidth) lo = mid; - else hi = mid - 1; - } - const cut = Math.max(0, lo); - return `${t.slice(0, cut)}${ellipsis}`; -} - -function resolveMediaToLocalPath(urlOrPath: string | null | undefined): string | null { - if (!urlOrPath) return null; - - // 1) Already public-relative. - if (urlOrPath.startsWith('/')) return urlOrPath; - - // 2) Some datasets store "media/..." without leading slash. - if (/^media\//i.test(urlOrPath)) return `/${urlOrPath}`; - - // 3) Asset-map can return a few different shapes; normalize them. - const mapped = ASSET_MAP[urlOrPath]; - if (mapped) { - if (mapped.startsWith('/')) return mapped; - if (/^public\//i.test(mapped)) return `/${mapped.replace(/^public\//i, '')}`; - if (/^media\//i.test(mapped)) return `/${mapped}`; - return mapped; - } - - // 4) Fallback (remote URL or unrecognized local path). - return urlOrPath; -} - -async function fetchBytes(url: string): Promise { - const res = await fetch(url); - if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`); - return new Uint8Array(await res.arrayBuffer()); -} - -async function readBytesFromPublic(localPath: string): Promise { - const abs = path.join(PUBLIC_DIR, localPath.replace(/^\//, '')); - return new Uint8Array(fs.readFileSync(abs)); -} - -function transformLogoSvgToPrintBlack(svg: string): string { - // Our source logo is white-on-transparent (for dark headers). For print (white page), we need dark fills. - // Keep it simple: replace fill white with KLZ navy. - return svg - .replace(/fill\s*:\s*white/gi, 'fill:#0E2A47') - .replace(/fill\s*=\s*"white"/gi, 'fill="#0E2A47"') - .replace(/fill\s*=\s*'white'/gi, "fill='#0E2A47'"); -} - -async function toPngBytes(inputBytes: Uint8Array, inputHint: string): Promise { - // pdf-lib supports PNG/JPG. We normalize everything (webp/svg/jpg/png) to PNG to keep embedding simple. - const ext = (path.extname(inputHint).toLowerCase() || '').replace('.', ''); - if (ext === 'png') return inputBytes; - - // Special-case the logo SVG to render as dark for print. - if (ext === 'svg' && /\/media\/logo\.svg$/i.test(inputHint)) { - const svg = Buffer.from(inputBytes).toString('utf8'); - inputBytes = new Uint8Array(Buffer.from(transformLogoSvgToPrintBlack(svg), 'utf8')); - } - - const sharp = await getSharp(); - // Preserve alpha where present (some product images are transparent). - return new Uint8Array(await sharp(Buffer.from(inputBytes)).png().toBuffer()); -} - -type TableColumn = { - key?: string; - label: string; - get: (rowIndex: number) => string; -}; - -function buildProductAttrIndex(product: ProductData): Record { - const idx: Record = {}; - for (const a of product.attributes || []) { - idx[normalizeValue(a.name).toLowerCase()] = a; - } - return idx; -} - -function getAttrCellValue(attr: ProductData['attributes'][number] | undefined, rowIndex: number, rowCount: number): string { - if (!attr) return ''; - if (!attr.options || attr.options.length === 0) return ''; - if (rowCount > 0 && attr.options.length === rowCount) return normalizeValue(attr.options[rowIndex]); - if (attr.options.length === 1) return normalizeValue(attr.options[0]); - // Unknown mapping: do NOT guess (this was the main source of "wrong" tables). - return ''; -} - -function drawTableChunked(args: { - title: string; - configRows: string[]; - columns: Array<{ key: string; label: string; get: (rowIndex: number) => string }>; - firstColLabel?: string; - dense?: boolean; - onePage?: boolean; - cellFormatter?: (value: string, columnKey: string) => string; - locale: 'en' | 'de'; - newPage: () => number; - getPage: () => PDFPage; - page: PDFPage; - y: number; - margin: number; - contentWidth: number; - contentMinY: number; - font: PDFFont; - fontBold: PDFFont; - navy: ReturnType; - darkGray: ReturnType; - lightGray: ReturnType; - almostWhite: ReturnType; - maxDataColsPerTable: number; -}): number { - let { - title, - configRows, - columns, - newPage, - getPage, - page, - y, - margin, - contentWidth, - contentMinY, - font, - fontBold, - navy, - darkGray, - lightGray, - almostWhite, - maxDataColsPerTable, - } = args; - - const dense = args.dense ?? false; - const onePage = args.onePage ?? false; - const formatCell = args.cellFormatter ?? ((v: string) => v); - - // pdf-lib will wrap text when `maxWidth` is set. - // For dense technical tables we want *no wrapping* (clip instead), so we replace spaces with NBSP. - const noWrap = (s: string) => (dense ? String(s).replace(/ /g, '\u00A0') : String(s)); - - // pdf-lib does not clip text to maxWidth; it only uses it for line breaking. - // To prevent header/body text from overlapping into neighboring columns, we manually truncate. - const truncateToWidth = (text: string, f: PDFFont, size: number, maxW: number): string => { - // IMPORTANT: do NOT call normalizeValue() here. - // We may have inserted NBSP to force no-wrapping; normalizeValue() would convert it back to spaces. - const t = String(text) - .replace(/[\r\n\t]+/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - if (!t) return ''; - if (maxW <= 4) return ''; - if (f.widthOfTextAtSize(t, size) <= maxW) return t; - const ellipsis = '…'; - const eW = f.widthOfTextAtSize(ellipsis, size); - if (eW >= maxW) return ''; - - // Binary search for max prefix that fits. - let lo = 0; - let hi = t.length; - while (lo < hi) { - const mid = Math.ceil((lo + hi) / 2); - const s = t.slice(0, mid); - if (f.widthOfTextAtSize(s, size) + eW <= maxW) lo = mid; - else hi = mid - 1; - } - const cut = Math.max(0, lo); - return `${t.slice(0, cut)}${ellipsis}`; - }; - - // Dense table style (more columns, compact typography) - // IMPORTANT: header labels must stay on a single line (no wrapped/stacked headers). - const headerH = dense ? 16 : 16; - let rowH = dense ? 16 : 16; // Increased to prevent row overlap - let bodyFontSize = dense ? 6.5 : 8; - let headerFontSize = dense ? 6.5 : 8; - - const headerFill = dense ? rgb(0.42, 0.44, 0.45) : lightGray; - const headerText = dense ? rgb(1, 1, 1) : navy; - let cellPadX = dense ? 4 : 6; - - const headerLabelFor = (col: { label: string; key?: string }): string => { - // Dense tables: use compact cable industry abbreviations - const raw = normalizeValue(col.label); - if (!dense) return raw; - - const b = raw.toLowerCase(); - const key = col.key || ''; - const hasUnit = /\(|\[/.test(raw); - - const labelWithUnit = (abbr: string, defaultUnit: string): string => { - // Prefer the actual unit we already have in the incoming label (from Excel units row). - // Example: raw = "DC resistance at 20 °C (Ω/km)" => keep Ω/km. - const m = raw.match(/[\[(]\s*([^\])]+?)\s*[\])]/); - const unit = normalizeValue(m?.[1] || '') || defaultUnit; - // WinAnsi safe: Ω => Ohm, µ => u (handled by stripHtml/normalizeValue) - const unitSafe = unit.replace(/Ω/gi, 'Ohm').replace(/[\u00B5\u03BC]/g, 'u'); - return `${abbr} [${unitSafe}]`; - }; - - // Cable industry standard abbreviations (compact, WinAnsi-safe) - // Column set: - // - Bezeichnung (first column) - // - DI, RI, Wi, Ibl, Ibe, Ik, Wm, Rbv, Ø, Fzv, Al, Cu, G - if (key === 'configuration') return args.locale === 'de' ? 'Bezeichnung' : 'Designation'; - if (key === 'DI' || /diameter\s+over\s+insulation/i.test(b)) return labelWithUnit('DI', 'mm'); - if (key === 'RI' || /dc\s*resistance/i.test(b) || /resistance\s+conductor/i.test(b)) return labelWithUnit('RI', 'Ohm/km'); - if (key === 'Wi' || /insulation\s+thickness/i.test(b) || /nominal\s+insulation\s+thickness/i.test(b)) return labelWithUnit('Wi', 'mm'); - if (key === 'Ibl' || /current\s+ratings\s+in\s+air.*trefoil/i.test(b)) return labelWithUnit('Ibl', 'A'); - if (key === 'Ibe' || /current\s+ratings\s+in\s+ground.*trefoil/i.test(b)) return labelWithUnit('Ibe', 'A'); - if (key === 'Ik_cond' || /conductor.*shortcircuit/i.test(b)) return labelWithUnit('Ik', 'kA'); - if (key === 'Wm' || /sheath\s+thickness/i.test(b) || /minimum\s+sheath\s+thickness/i.test(b)) return labelWithUnit('Wm', 'mm'); - // Rbv can be given in mm or as xD (LV/solar). We keep the unit from the label. - if (key === 'Rbv' || /bending\s+radius/i.test(b)) return labelWithUnit('Rbv', 'mm'); - if (key === 'Ø' || /outer\s+diameter/i.test(b) || /outer\s+diameter\s+of\s+cable/i.test(b)) return labelWithUnit('Ø', 'mm'); - if (key === 'Fzv' || /pulling\s+force/i.test(b)) return labelWithUnit('Fzv', 'N'); - if (key === 'Al') return 'Al'; - if (key === 'Cu') return 'Cu'; - if (key === 'G' || /\bweight\b/i.test(b) || /cable\s+weight/i.test(b)) return labelWithUnit('G', 'kg/km'); - - // Cross-section (always needed as first column) - if (/number of cores and cross-section/i.test(b) || /querschnitt/i.test(b)) { - return args.locale === 'de' ? 'QS' : 'CS'; - } - - // Additional technical columns (WinAnsi-compatible abbreviations) - if (key === 'cond_diam') return labelWithUnit('D_cond', 'mm'); - if (key === 'D_screen') return labelWithUnit('D_scr', 'mm'); - if (key === 'S_screen') return labelWithUnit('A_scr', 'mm2'); - if (key === 'cap') return labelWithUnit('C', 'uF/km'); - if (key === 'X') return labelWithUnit('X', 'Ohm/km'); - if (key === 'ind_trefoil') return labelWithUnit('L_t', 'mH/km'); - if (key === 'ind_air_flat') return labelWithUnit('L_af', 'mH/km'); - if (key === 'ind_ground_flat') return labelWithUnit('L_gf', 'mH/km'); - if (key === 'cur_air_flat') return labelWithUnit('I_af', 'A'); - if (key === 'cur_ground_flat') return labelWithUnit('I_gf', 'A'); - if (key === 'heat_trefoil') return labelWithUnit('t_th_t', 's'); - if (key === 'heat_flat') return labelWithUnit('t_th_f', 's'); - if (key === 'max_op_temp') return labelWithUnit('T_op', '°C'); - if (key === 'max_sc_temp') return labelWithUnit('T_sc', '°C'); - if (key === 'temp_range') return labelWithUnit('T', '°C'); - if (key === 'min_store_temp') return labelWithUnit('T_st', '°C'); - if (key === 'min_lay_temp') return labelWithUnit('T_lay', '°C'); - if (key === 'test_volt') return labelWithUnit('U_test', 'kV'); - if (key === 'rated_volt') return labelWithUnit('U_0/U', 'kV'); - if (key === 'conductor') return 'Cond'; - if (key === 'insulation') return 'Iso'; - if (key === 'sheath') return 'Sh'; - if (key === 'norm') return 'Norm'; - if (key === 'standard') return 'Std'; - if (key === 'cpr') return 'CPR'; - if (key === 'flame') return 'FR'; - if (key === 'packaging') return 'Pack'; - if (key === 'ce') return 'CE'; - if (key === 'shape') return 'Shape'; - if (key === 'color_ins') return 'C_iso'; - if (key === 'color_sheath') return 'C_sh'; - if (key === 'tape_below') return 'Tape v'; - if (key === 'copper_screen') return 'Cu Scr'; - if (key === 'tape_above') return 'Tape ^'; - if (key === 'al_foil') return 'Al Foil'; - - // Fallback: keep Excel label (will be truncated) - return raw; - }; - - // Always include a first column (configuration / cross-section). - const configCol = { - key: 'configuration', - label: args.firstColLabel || (args.locale === 'de' ? 'Konfiguration' : 'Configuration'), - get: (i: number) => normalizeValue(configRows[i] || ''), - }; - - const chunks: Array = []; - for (let i = 0; i < columns.length; i += maxDataColsPerTable) { - chunks.push(columns.slice(i, i + maxDataColsPerTable)); - } - - for (let ci = 0; ci < Math.max(1, chunks.length); ci++) { - // Ensure we always draw on the current page reference. - page = getPage(); - - const chunkCols = chunks.length ? chunks[ci] : []; - // UX: never show chunk fractions like "(1/2)". - // If we need multiple chunks, we keep the same title and paginate naturally. - const chunkTitle = title; - const tableCols: TableColumn[] = [configCol, ...chunkCols]; - - // Header labels (may be simplified for dense tables). - const headerLabels = tableCols.map(c => headerLabelFor({ label: c.label, key: c.key })); - - // Auto-fit column widths to content - // Calculate required width for each column based on header and sample data - // For dense tables with many columns, use more generous minimums - const isDenseManyColumns = dense && tableCols.length >= 12; - - // When rendering many columns, auto-compact typography BEFORE measuring widths. - // This prevents overflow where the later columns end up drawn off-page. - if (isDenseManyColumns) { - bodyFontSize = Math.max(5.3, Math.min(bodyFontSize, 5.8)); - headerFontSize = Math.max(5.1, Math.min(headerFontSize, 5.5)); - rowH = Math.max(9, Math.min(rowH, 10)); - cellPadX = 3; - } - - const minColWidth = isDenseManyColumns ? 24 : 20; // Minimum width in points - const maxColWidth = isDenseManyColumns ? 110 : 120; // Cap widths for dense tables - const colGap = isDenseManyColumns ? 1 : 2; // Gap between columns (smaller for dense) - - // Calculate required width for each column - const requiredWidths = tableCols.map((col, i) => { - const key = col.key || ''; - const headerLabel = headerLabels[i] || headerLabelFor({ label: col.label, key: col.key }); - - // Prefer key-aware constraints so the table is readable on A4. - // - configuration needs space (long designations) - // - numeric columns should stay compact - const isCfg = key === 'configuration'; - // NOTE: keep configuration column capped; otherwise it starves the numeric columns. - const cfgMaxAbs = dense ? 150 : 180; - const cfgMaxRel = Math.floor(contentWidth * (dense ? 0.26 : 0.30)); - const cfgMax = Math.max(120, Math.min(cfgMaxAbs, cfgMaxRel)); - const cfgMin = dense ? 90 : 120; - - const minW = isCfg ? cfgMin : minColWidth; - const maxW = isCfg ? cfgMax : (isDenseManyColumns ? 72 : maxColWidth); - - // Measure header width - const headerWidth = fontBold.widthOfTextAtSize(headerLabel, headerFontSize); - - // Measure sample data widths using the *rendered* value (apply cell formatter). - // Using only raw values can under-estimate (e.g. locale decimal, derived values). - let maxDataWidth = 0; - const sampleRows = Math.min(10, configRows.length); - for (let r = 0; r < sampleRows; r++) { - const rawVal = col.get(r); - const rendered = formatCell(rawVal, key); - const dataWidth = font.widthOfTextAtSize(String(rendered), bodyFontSize); - maxDataWidth = Math.max(maxDataWidth, dataWidth); - } - - // Take the maximum of header and data - const padding = isDenseManyColumns ? cellPadX * 1.5 : cellPadX * 2; - const contentWidthNeeded = Math.max(headerWidth, maxDataWidth) + padding; - - // Clamp to min/max - return Math.max(minW, Math.min(maxW, contentWidthNeeded)); - }); - - // Calculate total required width (including gaps) - const baseTotal = requiredWidths.reduce((sum, w) => sum + w, 0) + (tableCols.length - 1) * colGap; - - // If we have extra space, give it primarily to the configuration column - // (best readability gain) and then distribute any remainder proportionally. - const widthsWithExtra = [...requiredWidths]; - if (baseTotal < contentWidth && tableCols.length > 0) { - let remaining = contentWidth - baseTotal; - - const cfgIndex = tableCols.findIndex(c => (c.key || '') === 'configuration'); - if (cfgIndex >= 0 && remaining > 0) { - // Only give the configuration column a controlled share of the remaining space. - // This keeps numeric columns readable and consistent across tables. - const cfgMaxAbs = dense ? 150 : 180; - const cfgMaxRel = Math.floor(contentWidth * (dense ? 0.26 : 0.30)); - const cfgMax = Math.max(120, Math.min(cfgMaxAbs, cfgMaxRel)); - - const cfgRemainingCap = Math.min(remaining, Math.floor(remaining * (dense ? 0.35 : 0.45))); - const add = Math.max(0, Math.min(cfgMax - widthsWithExtra[cfgIndex], cfgRemainingCap)); - widthsWithExtra[cfgIndex] += add; - remaining -= add; - } - - if (remaining > 0) { - const sum = widthsWithExtra.reduce((a, b) => a + b, 0) || 1; - for (let i = 0; i < widthsWithExtra.length; i++) { - if (remaining <= 0) break; - const share = widthsWithExtra[i] / sum; - const add = Math.min(remaining, remaining * share); - widthsWithExtra[i] += add; - remaining -= add; - } - } - } - - const totalRequiredWidth = widthsWithExtra.reduce((sum, w) => sum + w, 0) + (tableCols.length - 1) * colGap; - - // Scale to fit available content width if needed - // Dense tables MUST fit on the page; allow stronger scaling when needed. - let scaleFactor = totalRequiredWidth > contentWidth ? contentWidth / totalRequiredWidth : 1; - if (!isDenseManyColumns) { - // Keep regular tables from becoming too small. - scaleFactor = Math.max(scaleFactor, 0.9); - } - - // Scale widths (gaps are also scaled) - const widthsPt = widthsWithExtra.map(w => w * scaleFactor); - const scaledGap = colGap * scaleFactor; - - const ensureSpace = (needed: number) => { - if (y - needed < contentMinY) y = newPage(); - page = getPage(); - }; - - // One-page mode: adapt row height so the full voltage table can fit on the page. - if (onePage) { - const rows = configRows.length; - const available = y - contentMinY - 10 - 12 /*title*/; - const maxRowH = Math.floor((available - headerH) / Math.max(1, rows)); - rowH = Math.max(8, Math.min(rowH, maxRowH)); - bodyFontSize = Math.max(6, Math.min(bodyFontSize, rowH - 3)); - headerFontSize = Math.max(6, Math.min(headerFontSize, rowH - 3)); - } - - ensureSpace(14 + headerH + rowH * 2); - if (chunkTitle) { - page.drawText(chunkTitle, { - x: margin, - y, - size: 10, - font: fontBold, - color: navy, - }); - y -= dense ? 14 : 16; - } - - // If we are too close to the footer after the title, break before drawing the header. - if (y - headerH - rowH < contentMinY) { - y = newPage(); - page = getPage(); - if (chunkTitle) { - page.drawText(chunkTitle, { - x: margin, - y, - size: 10, - font: fontBold, - color: navy, - }); - y -= 16; - } - } - - const drawHeader = () => { - page = getPage(); - page.drawRectangle({ - x: margin, - y: y - headerH, - width: contentWidth, - height: headerH, - color: headerFill, - }); - - const headerTextY = y - headerH + Math.floor((headerH - headerFontSize) / 2) + 1; - let x = margin; - for (let i = 0; i < tableCols.length; i++) { - const hl = headerLabels[i] || headerLabelFor({ label: tableCols[i].label, key: tableCols[i].key }); - const colWidth = widthsPt[i]; - const colMaxW = colWidth - cellPadX * 2; - - // Overlap guard: always truncate to column width (pdf-lib doesn't clip by itself). - // Don't truncate headers - use auto-fit widths - page.drawText(noWrap(String(hl)), { - x: x + cellPadX, - y: headerTextY, - size: headerFontSize, - font: fontBold, - color: headerText, - // DO NOT set maxWidth in dense mode (it triggers wrapping instead of clipping). - ...(dense ? {} : { maxWidth: colMaxW }), - }); - x += colWidth + scaledGap; - } - y -= headerH; - }; - - drawHeader(); - - for (let r = 0; r < configRows.length; r++) { - if (!onePage && y - rowH < contentMinY) { - y = newPage(); - page = getPage(); - if (chunkTitle) { - page.drawText(chunkTitle, { - x: margin, - y, - size: 12, - font: fontBold, - color: navy, - }); - y -= 16; - } - drawHeader(); - } - - if (onePage && y - rowH < contentMinY) { - // In one-page mode we must not paginate. Clip remaining rows. - break; - } - - if (r % 2 === 0) { - page.drawRectangle({ - x: margin, - y: y - rowH, - width: contentWidth, - height: rowH, - color: almostWhite, - }); - } - - let x = margin; - for (let c = 0; c < tableCols.length; c++) { - const raw = tableCols[c].get(r); - const txt = formatCell(raw, tableCols[c].key || ''); - const colWidth = widthsPt[c]; - const colMaxW = colWidth - cellPadX * 2; - // Don't truncate - use auto-fit widths to ensure everything fits - page.drawText(noWrap(txt), { - x: x + cellPadX, - y: y - (rowH - 5), // Adjusted for new rowH of 16 - size: bodyFontSize, - font, - color: darkGray, - ...(dense ? {} : { maxWidth: colMaxW }), - }); - x += colWidth + scaledGap; - } - - y -= rowH; - } - - y -= dense ? 20 : 24; - } - - return y; -} - -async function loadEmbeddablePng( - src: string | null | undefined, -): Promise<{ pngBytes: Uint8Array; debugLabel: string } | null> { - const resolved = resolveMediaToLocalPath(src); - if (!resolved) return null; - - try { - // Prefer local files for stability and speed. - if (resolved.startsWith('/')) { - const bytes = await readBytesFromPublic(resolved); - return { pngBytes: await toPngBytes(bytes, resolved), debugLabel: resolved }; - } - - // Remote (fallback) - const bytes = await fetchBytes(resolved); - return { pngBytes: await toPngBytes(bytes, resolved), debugLabel: resolved }; - } catch { - return null; - } -} - -async function loadQrPng(data: string): Promise<{ pngBytes: Uint8Array; debugLabel: string } | null> { - // External QR generator (no extra dependency). This must stay resilient; if it fails, we fall back to URL text. - try { - const safe = encodeURIComponent(data); - const url = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${safe}`; - const bytes = await fetchBytes(url); - // Already PNG but normalize anyway. - return { pngBytes: await toPngBytes(bytes, url), debugLabel: url }; - } catch { - return null; - } -} - -type SectionDrawContext = { - pdfDoc: PDFDocument; - page: PDFPage; - width: number; - height: number; - margin: number; - contentWidth: number; - footerY: number; - contentMinY: number; - headerDividerY: number; - colors: { - navy: ReturnType; - mediumGray: ReturnType; - darkGray: ReturnType; - almostWhite: ReturnType; - lightGray: ReturnType; - headerBg: ReturnType; - }; - fonts: { - regular: PDFFont; - bold: PDFFont; - }; - labels: ReturnType; - product: ProductData; - locale: 'en' | 'de'; - logoImage: PDFImage | null; - qrImage: PDFImage | null; - qrUrl: string; -}; - -function drawFooter(ctx: SectionDrawContext): void { - const { page, width, margin, footerY, fonts, colors, locale } = ctx; - - page.drawLine({ - start: { x: margin, y: footerY + 14 }, - end: { x: width - margin, y: footerY + 14 }, - thickness: 0.75, - color: colors.lightGray, - }); - - // Left: site URL (always) - page.drawText(CONFIG.siteUrl, { - x: margin, - y: footerY, - size: 8, - font: fonts.regular, - color: colors.mediumGray, - }); - - const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', { - year: 'numeric', - month: 'long', - day: 'numeric', - }); - - // Right: date + page number (page number filled in after rendering) - const rightText = dateStr; - page.drawText(rightText, { - x: width - margin - fonts.regular.widthOfTextAtSize(rightText, 8), - y: footerY, - size: 8, - font: fonts.regular, - color: colors.mediumGray, - }); -} - -function stampPageNumbers(pdfDoc: PDFDocument, fonts: { regular: PDFFont }, colors: { mediumGray: ReturnType }, margin: number, footerY: number): void { - const pages = pdfDoc.getPages(); - const total = pages.length; - for (let i = 0; i < total; i++) { - const page = pages[i]; - const { width } = page.getSize(); - const text = `${i + 1}/${total}`; - page.drawText(text, { - x: width - margin - fonts.regular.widthOfTextAtSize(text, 8), - y: footerY - 12, - size: 8, - font: fonts.regular, - color: colors.mediumGray, - }); - } -} - -function drawHeader(ctx: SectionDrawContext, yStart: number): number { - const { page, width, margin, contentWidth, fonts, colors, logoImage, qrImage, qrUrl, labels, product } = ctx; - - // Cable-industry look: calm, engineered header with right-aligned meta. - // Keep header compact to free vertical space for technical tables. - const headerH = 52; - const dividerY = yStart - headerH; - ctx.headerDividerY = dividerY; - - page.drawRectangle({ - x: 0, - y: dividerY, - width, - height: headerH, - color: colors.headerBg, - }); - - const qrSize = 36; - const qrGap = 12; - const rightReserved = qrImage ? qrSize + qrGap : 0; - - // Left: logo (preferred) or typographic fallback - if (logoImage) { - const maxLogoW = 120; - const maxLogoH = 24; - const scale = Math.min(maxLogoW / logoImage.width, maxLogoH / logoImage.height); - const w = logoImage.width * scale; - const h = logoImage.height * scale; - const logoY = dividerY + Math.round((headerH - h) / 2); - page.drawImage(logoImage, { - x: margin, - y: logoY, - width: w, - height: h, - }); - } else { - const baseY = dividerY + 22; - page.drawText('KLZ', { - x: margin, - y: baseY, - size: 22, - font: fonts.bold, - color: colors.navy, - }); - page.drawText('Cables', { - x: margin + fonts.bold.widthOfTextAtSize('KLZ', 22) + 4, - y: baseY + 2, - size: 10, - font: fonts.regular, - color: colors.mediumGray, - }); - } - - // Right: datasheet meta + QR (if available) - const metaRightEdge = width - margin - rightReserved; - const metaTitle = labels.datasheet; - const metaTitleSize = 9; - - const mtW = fonts.bold.widthOfTextAtSize(metaTitle, metaTitleSize); - // With SKU removed, vertically center the title within the header block. - const metaY = dividerY + Math.round(headerH / 2 - metaTitleSize / 2); - page.drawText(metaTitle, { - x: metaRightEdge - mtW, - y: metaY, - size: metaTitleSize, - font: fonts.bold, - color: colors.navy, - }); - - if (qrImage) { - const qrX = width - margin - qrSize; - const qrY = dividerY + Math.round((headerH - qrSize) / 2); - page.drawImage(qrImage, { x: qrX, y: qrY, width: qrSize, height: qrSize }); - } else { - // If QR generation failed, keep the URL available as a compact line. - const maxW = 260; - const urlLines = wrapText(qrUrl, fonts.regular, 8, maxW).slice(0, 1); - if (urlLines.length) { - const line = urlLines[0]; - const w = fonts.regular.widthOfTextAtSize(line, 8); - page.drawText(line, { - x: width - margin - w, - y: dividerY + 12, - size: 8, - font: fonts.regular, - color: colors.mediumGray, - }); - } - } - - // Divider line - page.drawLine({ - start: { x: margin, y: dividerY }, - end: { x: margin + contentWidth, y: dividerY }, - thickness: 0.75, - color: colors.lightGray, - }); - - // Content start: keep breathing room below the header, but use page height efficiently. - return dividerY - 22; -} - -function drawCrossSectionChipsRow(args: { - title: string; - configRows: string[]; - locale: 'en' | 'de'; - maxLinesCap?: number; - getPage: () => PDFPage; - page: PDFPage; - y: number; - margin: number; - contentWidth: number; - contentMinY: number; - font: PDFFont; - fontBold: PDFFont; - navy: ReturnType; - darkGray: ReturnType; - mediumGray: ReturnType; - lightGray: ReturnType; - almostWhite: ReturnType; -}): number { - let { title, configRows, locale, getPage, page, y, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, mediumGray, lightGray, almostWhite } = args; - - // Single-page rule: if we can't fit the block, stop. - const titleH = 12; - const summaryH = 12; - const chipH = 16; - const lineGap = 8; - const gapY = 10; - const minLines = 2; - const needed = titleH + summaryH + (chipH * minLines) + (lineGap * (minLines - 1)) + gapY; - if (y - needed < contentMinY) return contentMinY - 1; - - page = getPage(); - - // Normalize: keep only cross-section part, de-dupe, sort. - const itemsRaw = configRows - .map(r => splitConfig(r).crossSection) - .map(s => normalizeValue(s)) - .filter(Boolean); - - const seen = new Set(); - const items = itemsRaw.filter(v => (seen.has(v) ? false : (seen.add(v), true))); - - items.sort((a, b) => { - const pa = parseCoresAndMm2(a); - const pb = parseCoresAndMm2(b); - if (pa.cores !== null && pb.cores !== null && pa.cores !== pb.cores) return pa.cores - pb.cores; - if (pa.mm2 !== null && pb.mm2 !== null && pa.mm2 !== pb.mm2) return pa.mm2 - pb.mm2; - return a.localeCompare(b); - }); - - const total = items.length; - const parsed = items.map(parseCoresAndMm2).filter(p => p.cores !== null && p.mm2 !== null) as Array<{ cores: number; mm2: number }>; - const uniqueCores = Array.from(new Set(parsed.map(p => p.cores))).sort((a, b) => a - b); - const mm2Vals = parsed.map(p => p.mm2).sort((a, b) => a - b); - const mm2Min = mm2Vals.length ? mm2Vals[0] : null; - const mm2Max = mm2Vals.length ? mm2Vals[mm2Vals.length - 1] : null; - - page.drawText(title, { x: margin, y, size: 11, font: fontBold, color: navy }); - y -= titleH; - - const summaryParts: string[] = []; - summaryParts.push(locale === 'de' ? `Varianten: ${total}` : `Options: ${total}`); - if (uniqueCores.length) summaryParts.push((locale === 'de' ? 'Adern' : 'Cores') + `: ${uniqueCores.join(', ')}`); - if (mm2Min !== null && mm2Max !== null) summaryParts.push(`mm²: ${mm2Min}${mm2Max !== mm2Min ? `–${mm2Max}` : ''}`); - page.drawText(summaryParts.join(' · '), { x: margin, y, size: 8, font, color: mediumGray, maxWidth: contentWidth }); - y -= summaryH; - - // Tags (wrapping). Rectangular, engineered (no playful rounding). - const padX = 8; - const chipFontSize = 8; - const chipGap = 8; - const chipPadTop = 5; - - const startY = y - chipH; // baseline for first chip row - const maxLinesAvailable = Math.max(1, Math.floor((startY - contentMinY + lineGap) / (chipH + lineGap))); - // UX/Content priority: don't let cross-section tags consume the whole sheet. - // When technical data is dense, we cap this to keep specs visible. - const maxLines = Math.min(args.maxLinesCap ?? 2, maxLinesAvailable); - - const chipWidth = (text: string) => font.widthOfTextAtSize(text, chipFontSize) + padX * 2; - - type Placement = { text: string; x: number; y: number; w: number; variant: 'normal' | 'more' }; - - const layout = (texts: string[], includeMoreChip: boolean, moreText: string): { placements: Placement[]; shown: number } => { - const placements: Placement[] = []; - let x = margin; - let line = 0; - let cy = startY; - - const advanceLine = () => { - line += 1; - if (line >= maxLines) return false; - x = margin; - cy -= chipH + lineGap; - return true; - }; - - const tryPlace = (text: string, variant: 'normal' | 'more'): boolean => { - const w = chipWidth(text); - if (w > contentWidth) return false; - if (x + w > margin + contentWidth) { - if (!advanceLine()) return false; - } - placements.push({ text, x, y: cy, w, variant }); - x += w + chipGap; - return true; - }; - - let shown = 0; - for (let i = 0; i < texts.length; i++) { - if (!tryPlace(texts[i], 'normal')) break; - shown++; - } - - if (includeMoreChip) { - tryPlace(moreText, 'more'); - } - return { placements, shown }; - }; - - // Group by cores: label on the left, mm² tags to the right. - const byCores = new Map(); - const other: string[] = []; - for (const cs of items) { - const p = parseCoresAndMm2(cs); - if (p.cores !== null && p.mm2 !== null) { - const arr = byCores.get(p.cores) ?? []; - arr.push(p.mm2); - byCores.set(p.cores, arr); - } else { - other.push(cs); - } - } - - const coreKeys = Array.from(byCores.keys()).sort((a, b) => a - b); - for (const k of coreKeys) { - const uniq = Array.from(new Set(byCores.get(k) ?? [])).sort((a, b) => a - b); - byCores.set(k, uniq); - } - - const fmtMm2 = (v: number) => { - const s = Number.isInteger(v) ? String(v) : String(v).replace(/\.0+$/, ''); - return s; - }; - - // Layout engine with group labels. - const labelW = 38; - const placements: Placement[] = []; - let line = 0; - let cy = startY; - let x = margin + labelW; - - const canAdvanceLine = () => line + 1 < maxLines; - const advanceLine = () => { - if (!canAdvanceLine()) return false; - line += 1; - cy -= chipH + lineGap; - x = margin + labelW; - return true; - }; - - const drawGroupLabel = (label: string) => { - // Draw label on each new line for the group (keeps readability when wrapping). - page.drawText(label, { - x: margin, - y: cy + 4, - size: 8, - font: fontBold, - color: mediumGray, - maxWidth: labelW - 4, - }); - }; - - const placeChip = (text: string, variant: 'normal' | 'more') => { - const w = chipWidth(text); - if (w > contentWidth - labelW) return false; - if (x + w > margin + contentWidth) { - if (!advanceLine()) return false; - } - placements.push({ text, x, y: cy, w, variant }); - x += w + chipGap; - return true; - }; - - let truncated = false; - let renderedCount = 0; - const totalChips = coreKeys.reduce((sum, k) => sum + (byCores.get(k)?.length ?? 0), 0) + other.length; - - for (const cores of coreKeys) { - const values = byCores.get(cores) ?? []; - const label = `${cores}×`; - // Ensure label is shown at least once per line block. - drawGroupLabel(label); - for (const v of values) { - const ok = placeChip(fmtMm2(v), 'normal'); - if (!ok) { - truncated = true; - break; - } - renderedCount++; - } - if (truncated) break; - // Add a tiny gap between core groups (only if we have room on the current line) - x += 4; - if (x > margin + contentWidth - 20) { - if (!advanceLine()) { - // out of vertical space; stop - truncated = true; - break; - } - } - } - - if (!truncated && other.length) { - const label = locale === 'de' ? 'Sonst.' : 'Other'; - drawGroupLabel(label); - for (const t of other) { - const ok = placeChip(t, 'normal'); - if (!ok) { - truncated = true; - break; - } - renderedCount++; - } - } - - if (truncated) { - const remaining = Math.max(0, totalChips - renderedCount); - const moreText = locale === 'de' ? `+${remaining} weitere` : `+${remaining} more`; - // Try to place on current line; if not possible, try next line. - if (!placeChip(moreText, 'more')) { - if (advanceLine()) { - placeChip(moreText, 'more'); - } - } - } - - // Draw placements - for (const p of placements) { - page.drawRectangle({ - x: p.x, - y: p.y, - width: p.w, - height: chipH, - borderColor: lightGray, - borderWidth: 1, - color: rgb(1, 1, 1), - }); - page.drawText(p.text, { - x: p.x + padX, - y: p.y + chipPadTop, - size: chipFontSize, - font, - color: p.variant === 'more' ? navy : darkGray, - maxWidth: p.w - padX * 2, - }); - } - - // Return cursor below the last line drawn - const linesUsed = placements.length ? Math.max(...placements.map(p => Math.round((startY - p.y) / (chipH + lineGap)))) + 1 : 1; - const bottomY = startY - (linesUsed - 1) * (chipH + lineGap); - // Consistent section spacing after block. - // IMPORTANT: never return below contentMinY if we actually rendered, - // otherwise callers may think it "didn't fit" and draw a fallback on top (duplicate “Options” lines). - return Math.max(bottomY - 24, contentMinY); -} - -function drawCompactList(args: { - items: string[]; - x: number; - y: number; - colW: number; - cols: number; - rowH: number; - maxRows: number; - page: PDFPage; - font: PDFFont; - fontSize: number; - color: ReturnType; -}): number { - const { items, x, colW, cols, rowH, maxRows, page, font, fontSize, color } = args; - let y = args.y; - const shown = items.slice(0, cols * maxRows); - for (let i = 0; i < shown.length; i++) { - const col = Math.floor(i / maxRows); - const row = i % maxRows; - const ix = x + col * colW; - const iy = y - row * rowH; - page.drawText(shown[i], { - x: ix, - y: iy, - size: fontSize, - font, - color, - maxWidth: colW - 6, - }); - } - return y - maxRows * rowH; -} - -function findAttr(product: ProductData, includes: RegExp): ProductData['attributes'][number] | undefined { - return product.attributes?.find(a => includes.test(a.name)); -} - -function normalizeValue(value: string): string { - return stripHtml(value).replace(/\s+/g, ' ').trim(); -} - -function summarizeOptions(options: string[] | undefined, maxItems: number = 3): string { - const vals = (options || []).map(normalizeValue).filter(Boolean); - if (vals.length === 0) return ''; - const uniq = Array.from(new Set(vals)); - if (uniq.length === 1) return uniq[0]; - if (uniq.length <= maxItems) return uniq.join(' / '); - // UX: avoid showing internal counts like "+8" in customer-facing PDFs. - // Indicate truncation with an ellipsis. - return `${uniq.slice(0, maxItems).join(' / ')} / ...`; -} - -function parseNumericOption(value: string): number | null { - const v = normalizeValue(value).replace(/,/g, '.'); - // First numeric token (works for "12.3", "12.3 mm", "-35", "26.5 kg/km"). - const m = v.match(/-?\d+(?:\.\d+)?/); - if (!m) return null; - const n = Number(m[0]); - return Number.isFinite(n) ? n : null; -} - -function formatNumber(n: number): string { - const s = Number.isInteger(n) ? String(n) : String(n); - return s.replace(/\.0+$/, ''); -} - -function summarizeNumericRange(options: string[] | undefined): { ok: boolean; text: string } { - const vals = (options || []).map(parseNumericOption).filter((n): n is number => n !== null); - if (vals.length < 3) return { ok: false, text: '' }; - const uniq = Array.from(new Set(vals)); - // If there are only a few distinct values, listing is clearer than a range. - if (uniq.length < 4) return { ok: false, text: '' }; - uniq.sort((a, b) => a - b); - const min = uniq[0]; - const max = uniq[uniq.length - 1]; - // UX: don't show internal counts like "n=…" in customer-facing datasheets. - return { ok: true, text: `${formatNumber(min)}–${formatNumber(max)}` }; -} - -function summarizeSmartOptions(label: string, options: string[] | undefined): string { - // Prefer numeric ranges when an attribute has many numeric-ish entries (typical for row-specific data). - const range = summarizeNumericRange(options); - if (range.ok) return range.text; - return summarizeOptions(options, 3); -} - -function looksNumeric(value: string): boolean { - const v = normalizeValue(value).replace(/,/g, '.'); - return /^-?\d+(?:\.\d+)?$/.test(v); -} - -function formatMaybeWithUnit(value: string, unit: string): string { - const v = normalizeValue(value); - if (!v) return ''; - return looksNumeric(v) ? `${v} ${unit}` : v; -} - -function drawRowPreviewTable(args: { - title: string; - rows: Array<{ config: string; col1: string; col2: string }>; - headers: { config: string; col1: string; col2: string }; - getPage: () => PDFPage; - page: PDFPage; - y: number; - margin: number; - contentWidth: number; - contentMinY: number; - font: PDFFont; - fontBold: PDFFont; - navy: ReturnType; - darkGray: ReturnType; - lightGray: ReturnType; - almostWhite: ReturnType; -}): number { - let { title, rows, headers, getPage, page, y, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, lightGray, almostWhite } = args; - - const titleH = 16; - const headerH = 16; - const rowH = 13; - const padAfter = 18; - - // One-page rule: require at least 2 data rows. - const minNeeded = titleH + headerH + rowH * 2 + padAfter; - if (y - minNeeded < contentMinY) return contentMinY - 1; - - page = getPage(); - page.drawText(title, { x: margin, y, size: 10, font: fontBold, color: navy }); - y -= titleH; - - // How many rows fit? - const availableForRows = y - contentMinY - padAfter - headerH; - const maxRows = Math.max(2, Math.floor(availableForRows / rowH)); - const shown = rows.slice(0, Math.max(0, maxRows)); - - const hasCol2 = shown.some(r => Boolean(r.col2)); - - // Widths: favor configuration readability. - const wCfg = 0.46; - const w1 = hasCol2 ? 0.27 : 0.54; - const w2 = hasCol2 ? 0.27 : 0; - - const x0 = margin; - const x1 = margin + contentWidth * wCfg; - const x2 = margin + contentWidth * (wCfg + w1); - - const drawHeader = () => { - page = getPage(); - page.drawRectangle({ x: margin, y: y - headerH, width: contentWidth, height: headerH, color: lightGray }); - page.drawText(headers.config, { x: x0 + 6, y: y - 11, size: 8, font: fontBold, color: navy, maxWidth: contentWidth * wCfg - 12 }); - page.drawText(headers.col1, { x: x1 + 6, y: y - 11, size: 8, font: fontBold, color: navy, maxWidth: contentWidth * w1 - 12 }); - if (hasCol2) { - page.drawText(headers.col2, { x: x2 + 6, y: y - 11, size: 8, font: fontBold, color: navy, maxWidth: contentWidth * w2 - 12 }); - } - y -= headerH; - }; - - drawHeader(); - - for (let i = 0; i < shown.length; i++) { - if (y - rowH < contentMinY) return contentMinY - 1; - page = getPage(); - if (i % 2 === 0) { - page.drawRectangle({ x: margin, y: y - rowH, width: contentWidth, height: rowH, color: almostWhite }); - } - page.drawText(shown[i].config, { x: x0 + 6, y: y - 10, size: 8, font, color: darkGray, maxWidth: contentWidth * wCfg - 12 }); - page.drawText(shown[i].col1, { x: x1 + 6, y: y - 10, size: 8, font, color: darkGray, maxWidth: contentWidth * w1 - 12 }); - if (hasCol2) { - page.drawText(shown[i].col2, { x: x2 + 6, y: y - 10, size: 8, font, color: darkGray, maxWidth: contentWidth * w2 - 12 }); - } - y -= rowH; - } - - y -= padAfter; - return y; -} - -function splitConfig(config: string): { crossSection: string; voltage: string } { - const raw = normalizeValue(config); - const parts = raw.split(/\s*-\s*/); - if (parts.length >= 2) { - return { crossSection: parts[0], voltage: parts.slice(1).join(' - ') }; - } - return { crossSection: raw, voltage: '' }; -} - -function parseCoresAndMm2(crossSection: string): { cores: number | null; mm2: number | null } { - const s = normalizeValue(crossSection) - .replace(/\s+/g, '') - .replace(/×/g, 'x') - .replace(/,/g, '.'); - - // Typical: 3x1.5, 4x25, 1x70 - const m = s.match(/(\d{1,3})x(\d{1,4}(?:\.\d{1,2})?)/i); - if (!m) return { cores: null, mm2: null }; - const cores = Number(m[1]); - const mm2 = Number(m[2]); - return { - cores: Number.isFinite(cores) ? cores : null, - mm2: Number.isFinite(mm2) ? mm2 : null, - }; -} - -async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise { - try { - const labels = getLabels(locale); - const pdfDoc = await PDFDocument.create(); - const pageSizePortrait: [number, number] = [595.28, 841.89]; // DIN A4 portrait - const pageSizeLandscape: [number, number] = [841.89, 595.28]; // DIN A4 landscape - let page = pdfDoc.addPage(pageSizePortrait); - const { width, height } = page.getSize(); - - // STYLEGUIDE.md colors - const navy = rgb(0.0549, 0.1647, 0.2784); // #0E2A47 - const mediumGray = rgb(0.4196, 0.4471, 0.5020); // #6B7280 - const darkGray = rgb(0.1216, 0.1608, 0.2); // #1F2933 - const almostWhite = rgb(0.9725, 0.9765, 0.9804); // #F8F9FA - const lightGray = rgb(0.9020, 0.9137, 0.9294); // #E6E9ED - const headerBg = rgb(0.965, 0.972, 0.98); // calm, print-friendly tint - - // Small design system: consistent type + spacing for professional datasheets. - const DS = { - space: { xs: 4, sm: 8, md: 12, lg: 16, xl: 24 }, - type: { h1: 20, h2: 11, body: 10.5, small: 8 }, - rule: { thin: 0.75 }, - } as const; - - // Line-heights (explicit so vertical rhythm doesn't drift / overlap) - const LH = { - h1: 24, - h2: 16, - body: 14, - small: 10, - } as const; - - const font = await pdfDoc.embedFont(StandardFonts.Helvetica); - const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); - - // Assets - // Prefer a raster logo for reliability (sharp SVG support can vary between environments). - const logoPng = (await loadEmbeddablePng('/media/logo.png')) || (await loadEmbeddablePng('/media/logo.svg')); - const logoImage = logoPng ? await pdfDoc.embedPng(logoPng.pngBytes) : null; - - // Some products have no product-specific images. - // Do NOT fall back to a generic/category hero (misleading in datasheets). - // If missing, we render a neutral placeholder box. - const heroSrc = product.featuredImage || product.images?.[0] || null; - const heroPng = heroSrc ? await loadEmbeddablePng(heroSrc) : null; - - const productUrl = getProductUrl(product) || CONFIG.siteUrl; - const qrPng = await loadQrPng(productUrl); - const qrImage = qrPng ? await pdfDoc.embedPng(qrPng.pngBytes) : null; - - // Engineered page frame (A4): slightly narrower margins but consistent rhythm. - const margin = 54; - const footerY = 54; - const contentMinY = footerY + 42; // keep clear of footer + page numbers - const contentWidth = width - 2 * margin; - - const ctx: SectionDrawContext = { - pdfDoc, - page, - width, - height, - margin, - contentWidth, - footerY, - contentMinY, - headerDividerY: 0, - colors: { navy, mediumGray, darkGray, almostWhite, lightGray, headerBg }, - fonts: { regular: font, bold: fontBold }, - labels, - product, - locale, - logoImage, - qrImage, - qrUrl: productUrl, - }; - - const syncCtxForPage = (p: PDFPage) => { - const sz = p.getSize(); - ctx.page = p; - ctx.width = sz.width; - ctx.height = sz.height; - ctx.contentWidth = sz.width - 2 * ctx.margin; - }; - - const drawPageBackground = (p: PDFPage) => { - const { width: w, height: h } = p.getSize(); - p.drawRectangle({ - x: 0, - y: 0, - width: w, - height: h, - color: rgb(1, 1, 1), - }); - }; - - const drawProductNameOnPage = (p: PDFPage, yStart: number): number => { - const name = stripHtml(product.name); - const maxW = ctx.contentWidth; - const line = wrapText(name, fontBold, 12, maxW).slice(0, 1)[0] || name; - p.drawText(line, { - x: margin, - y: yStart, - size: 12, - font: fontBold, - color: navy, - maxWidth: maxW, - }); - return yStart - 18; - }; - - // Real multi-page support. - // Each new page repeats header + footer for print-friendly, consistent scanning. - const newPage = (opts?: { includeProductName?: boolean; landscape?: boolean }): number => { - page = pdfDoc.addPage(opts?.landscape ? pageSizeLandscape : pageSizePortrait); - syncCtxForPage(page); - drawPageBackground(page); - drawFooter(ctx); - let yStart = drawHeader(ctx, ctx.height - ctx.margin); - if (opts?.includeProductName) yStart = drawProductNameOnPage(page, yStart); - return yStart; - }; - - const hasSpace = (needed: number) => y - needed >= contentMinY; - - // ---- Layout helpers (eliminate magic numbers; enforce consistent rhythm) ---- - const rule = (gapAbove: number = DS.space.md, gapBelow: number = DS.space.lg) => { - // One-page rule: if we can't fit a divider with its spacing, do nothing. - if (!hasSpace(gapAbove + gapBelow + DS.rule.thin)) return; - - y -= gapAbove; - page.drawLine({ - start: { x: margin, y }, - end: { x: margin + contentWidth, y }, - thickness: DS.rule.thin, - color: lightGray, - }); - y -= gapBelow; - }; - - const sectionTitle = (text: string) => { - // One-page rule: if we can't fit the heading + its gap, do nothing. - if (!hasSpace(DS.type.h2 + DS.space.md)) return; - - page.drawText(text, { - x: margin, - y, - size: DS.type.h2, - font: fontBold, - color: navy, - }); - // Use a real line-height to avoid title/body overlap. - y -= LH.h2; - }; - - // Page 1 - syncCtxForPage(page); - drawPageBackground(page); - drawFooter(ctx); - let y = drawHeader(ctx, ctx.height - ctx.margin); - - // === PRODUCT HEADER === - const productName = stripHtml(product.name); - const cats = (product.categories || []).map(c => stripHtml(c.name)).join(' • '); - - // First page: keep the header compact so technical tables start earlier. - const titleW = contentWidth; - const titleSize = 18; - const titleLineH = 21; - const nameLines = wrapText(productName, fontBold, titleSize, titleW); - const shownNameLines = nameLines.slice(0, 1); - for (const line of shownNameLines) { - if (y - titleLineH < contentMinY) y = newPage(); - page.drawText(line, { - x: margin, - y, - size: titleSize, - font: fontBold, - color: navy, - maxWidth: titleW, - }); - y -= titleLineH; - } - - if (cats) { - if (y - 18 < contentMinY) y = newPage(); - page.drawText(cats, { - x: margin, - y, - size: 10.5, - font, - color: mediumGray, - maxWidth: titleW, - }); - y -= DS.space.md; - } - - // Separator after product header - // No dividing lines on first page (cleaner, more space-efficient). - // rule(DS.space.xs, DS.space.md); - - // === HERO IMAGE (full width) === - // Dense technical products need more room for specs; prioritize content over imagery. - const hasLotsOfTech = (product.attributes?.length || 0) >= 18; - let heroH = hasLotsOfTech ? 96 : 120; - const afterHeroGap = DS.space.lg; - if (!hasSpace(heroH + afterHeroGap)) { - // Shrink to remaining space (but keep it usable). - heroH = Math.max(84, Math.floor(y - contentMinY - afterHeroGap)); - } - - const heroBoxX = margin; - const heroBoxY = y - heroH; - page.drawRectangle({ - x: heroBoxX, - y: heroBoxY, - width: contentWidth, - height: heroH, - // Calm frame; gives images consistent presence even with transparency. - color: almostWhite, - borderColor: lightGray, - borderWidth: 1, - }); - - if (heroPng) { - const pad = DS.space.md; - const boxW = contentWidth - pad * 2; - const boxH = heroH - pad * 2; - - // Fit image into the box without cutting it off (contain). - // Technical images often must remain fully visible. - const sharp = await getSharp(); - const contained = await sharp(Buffer.from(heroPng.pngBytes)) - .resize({ - width: 1200, - height: Math.round((1200 * boxH) / boxW), - fit: 'contain', - background: { r: 248, g: 249, b: 250, alpha: 1 }, - }) - .png() - .toBuffer(); - const heroImage = await pdfDoc.embedPng(contained); - - page.drawImage(heroImage, { - x: heroBoxX + pad, - y: heroBoxY + pad, - width: boxW, - height: boxH, - }); - } else { - page.drawText(locale === 'de' ? 'Kein Bild verfügbar' : 'No image available', { - x: heroBoxX + 12, - y: heroBoxY + heroH / 2, - size: 8, - font, - color: mediumGray, - maxWidth: contentWidth - 24, - }); - } - - y = heroBoxY - afterHeroGap; - - // === DESCRIPTION (optional) === - if (product.shortDescriptionHtml || product.descriptionHtml) { - const desc = stripHtml(product.shortDescriptionHtml || product.descriptionHtml); - const descLineH = 14; - // Keep full length: render all lines, paginate if needed. - const boxPadX = DS.space.md; - const boxPadY = DS.space.md; - - sectionTitle(labels.description); - - const maxTextW = contentWidth - boxPadX * 2; - const descLines = wrapText(desc, font, DS.type.body, maxTextW); - - // Draw as boxed paragraphs, paginating as needed. - // We keep a single consistent box per page segment. - let i = 0; - while (i < descLines.length) { - // Ensure we have enough space for at least 2 lines. - const minLines = 2; - const minBoxH = boxPadY * 2 + descLineH * minLines; - if (!hasSpace(minBoxH + DS.space.md)) { - y = newPage({ includeProductName: true }); - } - - // Compute how many lines we can fit on the current page. - const availableH = y - contentMinY - DS.space.md; - const maxLinesThisPage = Math.max(minLines, Math.floor((availableH - boxPadY * 2) / descLineH)); - const slice = descLines.slice(i, i + maxLinesThisPage); - - const boxH = boxPadY * 2 + descLineH * slice.length; - const boxTop = y + DS.space.xs; - const boxBottom = boxTop - boxH; - - page.drawRectangle({ - x: margin, - y: boxBottom, - width: contentWidth, - height: boxH, - color: rgb(1, 1, 1), - borderColor: lightGray, - borderWidth: 1, - }); - - let ty = boxTop - boxPadY - DS.type.body; - for (const line of slice) { - page.drawText(line, { - x: margin + boxPadX, - y: ty, - size: DS.type.body, - font, - color: darkGray, - }); - ty -= descLineH; - } - - y = boxBottom - DS.space.md; - i += slice.length; - - // If there is more text, continue on a new page segment (keeps layout stable). - if (i < descLines.length) { - y = newPage({ includeProductName: true }); - sectionTitle(labels.description); - } - } - - rule(0, DS.space.lg); - } - - // === EXCEL MODEL === - // Priority: render ALL Excel data (technical + per-voltage tables). - const excelModel = buildExcelModel({ product, locale }); - - // Keep the old enrichment as a fallback path only. - // (Some products may not match Excel keying; then we still have a usable PDF.) - ensureExcelCrossSectionAttributes(product, locale); - ensureExcelRowSpecificAttributes(product, locale); - - if (excelModel.ok) { - const tables = excelModel.voltageTables; - const hasMultipleVoltages = tables.length > 1; - - // TECHNICAL DATA (shared across all cross-sections) - const techTitle = locale === 'de' ? 'TECHNISCHE DATEN' : 'TECHNICAL DATA'; - const techItems = excelModel.technicalItems; - - // Track if we've rendered any content before the tables - let hasRenderedContent = false; - - if (techItems.length) { - y = drawKeyValueGrid({ - title: techTitle, - items: techItems, - newPage: () => newPage({ includeProductName: true }), - getPage: () => page, - page, - y, - margin, - contentWidth, - contentMinY, - font, - fontBold, - navy, - darkGray, - mediumGray, - lightGray, - almostWhite, - allowNewPage: true, - boxed: true, - }); - - hasRenderedContent = true; - - // Add spacing after technical data section before first voltage table - if (y - 20 >= contentMinY) y -= 20; - } - - // CROSS-SECTION DATA: one table per voltage rating - const firstColLabel = - locale === 'de' ? 'Adern & Querschnitt' : 'Cores & cross-section'; - - for (const t of tables) { - // Maintain a minimum space between tables (even when staying on the same page). - // This avoids visual collisions between the previous table and the next meta header. - if (hasRenderedContent && y - 20 >= contentMinY) y -= 20; - - // Check if we need a new page for this voltage table - // Estimate: meta block (if shown) + table header + at least 3 data rows - const estimateMetaH = (itemsCount: number) => { - // Always render a voltage-group header (even for single-voltage products) - // so all datasheets look consistent. - const titleH = 14; - const rowH = 14; - const cols = 3; - const rows = Math.max(1, Math.ceil(Math.max(0, itemsCount) / cols)); - return titleH + rows * rowH + 8; - }; - const minTableH = 16 /*header*/ + 9 * 3 /*3 rows*/ + 10 /*pad*/; - const minNeeded = estimateMetaH((t.metaItems || []).length) + minTableH; - if (y - minNeeded < contentMinY) { - y = newPage({ includeProductName: true, landscape: false }); - } - - // Top meta block: always render per voltage group. - // This ensures a consistent structure across products (LV/MV/HV) and makes the - // voltage group visible in the heading. - y = drawDenseMetaGrid({ - title: `${labels.crossSection} — ${t.voltageLabel}`, - items: t.metaItems, - locale, - newPage: () => newPage({ includeProductName: true, landscape: false }), - getPage: () => page, - page, - y, - margin, - contentWidth, - contentMinY, - font, - fontBold, - navy, - darkGray, - mediumGray, - }); - - // Breathing room before the dense table - y -= 14; - - // Cross-section table: exactly 13 columns as specified - // Order: DI, RI, Wi, Ibl, Ibe, Ik, Wm, Rbv, Ø, Fzv, Al, Cu, G - const tableColumns = prioritizeColumnsForDenseTable({ columns: t.columns }); - - // Format dense table cells: compact decimals, no units in cells - const cellFormatter = (value: string, columnKey: string) => { - return compactNumericForLocale(value, locale); - }; - - // Table title: keep empty because the voltage-group title is already shown in the meta block. - const tableTitle = ''; - - y = drawTableChunked({ - title: tableTitle, - configRows: t.crossSections, - columns: tableColumns, - firstColLabel, - dense: true, - onePage: false, // Allow multiple pages for large tables - cellFormatter, - locale, - newPage: () => newPage({ includeProductName: true, landscape: false }), - getPage: () => page, - page, - y, - margin, - contentWidth: ctx.contentWidth, - contentMinY, - font, - fontBold, - navy, - darkGray, - lightGray, - almostWhite, - maxDataColsPerTable: 10_000, // All columns in one table - }); - - hasRenderedContent = true; - } - } else { - // Fallback (non-Excel products): keep existing behavior minimal - const note = locale === 'de' - ? 'Hinweis: Für dieses Produkt liegen derzeit keine Excel-Daten vor.' - : 'Note: No Excel data is available for this product yet.'; - y = drawKeyValueGrid({ - title: locale === 'de' ? 'TECHNISCHE DATEN' : 'TECHNICAL DATA', - items: [{ label: locale === 'de' ? 'Quelle' : 'Source', value: note }], - newPage: () => newPage({ includeProductName: true }), - getPage: () => page, - page, - y, - margin, - contentWidth, - contentMinY, - font, - fontBold, - navy, - darkGray, - mediumGray, - lightGray, - almostWhite, - allowNewPage: true, - boxed: true, - }); - } - - // Add page numbers after all pages are created. - stampPageNumbers(pdfDoc, { regular: font }, { mediumGray }, margin, footerY); - - const pdfBytes = await pdfDoc.save(); - return Buffer.from(pdfBytes); - - } catch (error: any) { - throw new Error(`Failed to generate PDF for product ${product.id} (${locale}): ${error.message}`); - } -} - -async function processChunk(products: ProductData[], chunkIndex: number, totalChunks: number): Promise { - console.log(`\nProcessing chunk ${chunkIndex + 1}/${totalChunks} (${products.length} products)...`); - - for (const product of products) { - try { - const locale = product.locale || 'en'; - const buffer = await generatePDF(product, locale); - const fileName = generateFileName(product, locale); - fs.writeFileSync(path.join(CONFIG.outputDir, fileName), buffer); - console.log(`✓ ${locale.toUpperCase()}: ${fileName}`); - await new Promise(resolve => setTimeout(resolve, 50)); - } catch (error) { - console.error(`✗ Failed to process product ${product.id}:`, error); - } - } -} - async function readProductsStream(): Promise { console.log('Reading products.json...'); return new Promise((resolve, reject) => { const stream = fs.createReadStream(CONFIG.productsFile, { encoding: 'utf8' }); let data = ''; - stream.on('data', (chunk) => { data += chunk; }); + stream.on('data', chunk => { + data += chunk; + }); stream.on('end', () => { try { - const products = JSON.parse(data); + const products = JSON.parse(data) as ProductData[]; console.log(`Loaded ${products.length} products`); resolve(products); } catch (error) { reject(new Error(`Failed to parse JSON: ${error}`)); } }); - stream.on('error', (error) => reject(new Error(`Failed to read file: ${error}`))); + stream.on('error', error => reject(new Error(`Failed to read file: ${error}`))); }); } +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); + fs.writeFileSync(path.join(CONFIG.outputDir, fileName), buffer); + console.log(`✓ ${locale.toUpperCase()}: ${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 - Industrial engineering documentation style'); + console.log('Starting PDF generation (React-PDF)'); ensureOutputDir(); - try { - const allProducts = await readProductsStream(); - if (allProducts.length === 0) { - console.log('No products found'); - return; - } - - // Dev convenience: generate only one locale / one product subset. - // IMPORTANT: apply filters BEFORE PDF_LIMIT so the limit works within the filtered set. - let products = allProducts; - - const onlyLocale = normalizeValue(String(process.env.PDF_LOCALE || '')).toLowerCase(); - if (onlyLocale === 'de' || onlyLocale === 'en') { - products = products.filter(p => (p.locale || 'en') === onlyLocale); - } - - const match = normalizeValue(String(process.env.PDF_MATCH || '')).toLowerCase(); - if (match) { - products = products.filter(p => { - const hay = [p.slug, p.translationKey, p.sku, stripHtml(p.name)] - .filter(Boolean) - .join(' ') - .toLowerCase(); - return hay.includes(match); - }); - } - - // Optional dev convenience: limit how many PDFs we render (useful for design iteration). - // Default behavior remains unchanged. - const limit = Number(process.env.PDF_LIMIT || '0'); - products = Number.isFinite(limit) && limit > 0 ? products.slice(0, limit) : products; - - const enProducts = products.filter(p => (p.locale || 'en') === 'en'); - const deProducts = products.filter(p => (p.locale || 'en') === 'de'); - console.log(`Found ${enProducts.length} EN + ${deProducts.length} DE products`); - - const totalChunks = Math.ceil(products.length / CONFIG.chunkSize); - for (let i = 0; i < totalChunks; i++) { - const chunk = products.slice(i * CONFIG.chunkSize, (i + 1) * CONFIG.chunkSize); - await processChunk(chunk, i, totalChunks); - } - - console.log('\n✅ PDF generation completed!'); - console.log(`Generated ${enProducts.length} EN + ${deProducts.length} DE PDFs`); - console.log(`Output: ${CONFIG.outputDir}`); - - } catch (error) { - console.error('❌ Error:', error); - throw error; + const allProducts = await readProductsStream(); + if (allProducts.length === 0) { + console.log('No products found'); + return; } + + // Dev convenience: generate only one locale / one product subset. + // IMPORTANT: apply filters BEFORE PDF_LIMIT so the limit works within the filtered set. + let products = allProducts; + + const onlyLocale = normalizeValue(String(process.env.PDF_LOCALE || '')).toLowerCase(); + if (onlyLocale === 'de' || onlyLocale === 'en') { + products = products.filter(p => (p.locale || 'en') === onlyLocale); + } + + const match = normalizeValue(String(process.env.PDF_MATCH || '')).toLowerCase(); + if (match) { + products = products.filter(p => { + const hay = [p.slug, p.translationKey, p.sku, 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 { diff --git a/scripts/pdf/model/build-datasheet-model.ts b/scripts/pdf/model/build-datasheet-model.ts new file mode 100644 index 00000000..016f02a5 --- /dev/null +++ b/scripts/pdf/model/build-datasheet-model.ts @@ -0,0 +1,574 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +import type { DatasheetModel, DatasheetVoltageTable, KeyValueItem, ProductData } from './types'; +import type { ExcelMatch } from './excel-index'; +import { findExcelForProduct } 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 ''; + 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 { + if (args.locale === 'en') return normalizeValue(args.excelKey); + const raw = normalizeValue(args.excelKey); + if (!raw) return ''; + return raw + .replace(/\(approx\.?\)/gi, '(ca.)') + .replace(/\bcapacitance\b/gi, 'Kapazität') + .replace(/\binductance\b/gi, 'Induktivität') + .replace(/\breactance\b/gi, 'Reaktanz') + .replace(/\btest voltage\b/gi, 'Prüfspannung') + .replace(/\brated voltage\b/gi, 'Nennspannung') + .replace(/\boperating temperature range\b/gi, 'Temperaturbereich') + .replace(/\bminimum sheath thickness\b/gi, 'Manteldicke (min.)') + .replace(/\bsheath thickness\b/gi, 'Manteldicke') + .replace(/\bnominal insulation thickness\b/gi, 'Isolationsdicke (nom.)') + .replace(/\binsulation thickness\b/gi, 'Isolationsdicke') + .replace(/\bdc resistance at 20\s*°?c\b/gi, 'DC-Leiterwiderstand (20 °C)') + .replace(/\bouter diameter(?: of cable)?\b/gi, 'Außen-Ø') + .replace(/\bbending radius\b/gi, 'Biegeradius') + .replace(/\bpackaging\b/gi, 'Verpackung') + .replace(/\bce\s*-?conformity\b/gi, 'CE-Konformität'); +} + +function metaFullLabel(args: { key: string; excelKey: string; locale: 'en' | 'de' }): string { + const key = normalizeValue(args.key); + if (args.locale === 'de') { + switch (key) { + case 'test_volt': + return 'Prüfspannung'; + case 'temp_range': + return 'Temperaturbereich'; + case 'max_op_temp': + return 'Leitertemperatur (max.)'; + case 'max_sc_temp': + return 'Kurzschlusstemperatur (max.)'; + case 'min_lay_temp': + return 'Minimale Verlegetemperatur'; + case 'min_store_temp': + return 'Minimale Lagertemperatur'; + case 'cpr': + return 'CPR-Klasse'; + case 'flame': + return 'Flammhemmend'; + default: + return formatExcelHeaderLabel(args.excelKey); + } + } + + switch (key) { + case 'test_volt': + return 'Test voltage'; + case 'temp_range': + return 'Operating temperature range'; + case 'max_op_temp': + return 'Conductor temperature (max.)'; + case 'max_sc_temp': + return 'Short-circuit temperature (max.)'; + case 'min_lay_temp': + return 'Minimum laying temperature'; + case 'min_store_temp': + return 'Minimum storage temperature'; + case 'cpr': + return 'CPR class'; + case 'flame': + return 'Flame retardant'; + default: + return formatExcelHeaderLabel(args.excelKey); + } +} + +function 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'; + 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 summarizeOptions(options: string[] | undefined, maxItems: number = 3): string { + const vals = (options || []).map(normalizeValue).filter(Boolean); + if (vals.length === 0) return ''; + const uniq = Array.from(new Set(vals)); + if (uniq.length === 1) return uniq[0]; + if (uniq.length <= maxItems) return uniq.join(' / '); + return `${uniq.slice(0, maxItems).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, 3); +} + +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' }, + }; + + 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 compatibleRows = rows.filter(r => { + const rKeys = Object.keys(r).filter(k => k && k !== 'Part Number' && k !== 'Units').sort(); + return JSON.stringify(rKeys) === JSON.stringify(sampleKeys); + }); + if (compatibleRows.length === 0) return { ok: false, technicalItems: [], voltageTables: [] }; + + const csKey = + guessColumnKey(sample, [/number of cores and cross-section/i, /cross.?section/i, /ross section conductor/i]) || null; + const voltageKey = guessColumnKey(sample, [/rated voltage/i, /voltage rating/i, /nennspannung/i, /spannungs/i]) || null; + if (!csKey) return { ok: false, technicalItems: [], voltageTables: [] }; + + 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 value = compactCellForDenseTable(values[0], unit, args.locale); + if (!technicalItems.find(t => t.label === label)) technicalItems.push({ label, value, unit }); + } + } + technicalItems.sort((a, b) => 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', + 'DI', + 'RI', + 'Wi', + 'Ibl', + 'Ibe', + 'Ik_cond', + 'Wm', + 'Rbv', + 'Ø', + '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(); + for (const c of tableColumns) { + if (!mappedByKey.has(c.mapping.key)) mappedByKey.set(c.mapping.key, c); + } + + const orderedTableColumns = denseTableKeyOrder + .filter(k => mappedByKey.has(k)) + .map(k => mappedByKey.get(k)!) + .map(({ excelKey, mapping }) => { + const unit = normalizeUnit(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]; + const raw = excelKey ? normalizeValue(String(compatibleRows[srcRowIndex]?.[excelKey] ?? '')) : ''; + return compactCellForDenseTable(raw, unit, args.locale); + }, + }; + }); + + voltageTables.push({ voltageLabel: vKey, metaItems, crossSections, columns: orderedTableColumns }); + } + + return { ok: true, technicalItems, voltageTables }; +} + +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.shortDescriptionHtml || args.product.descriptionHtml || ''); + const heroSrc = resolveMediaToLocalPath(args.product.featuredImage || args.product.images?.[0] || null); + const productUrl = getProductUrl(args.product); + + const excelModel = buildExcelModel({ product: args.product, locale: args.locale }); + const voltageTables: DatasheetVoltageTable[] = excelModel.ok + ? excelModel.voltageTables.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: excelModel.ok ? excelModel.technicalItems : [], + voltageTables, + }; +} diff --git a/scripts/pdf/model/excel-index.ts b/scripts/pdf/model/excel-index.ts new file mode 100644 index 00000000..be269964 --- /dev/null +++ b/scripts/pdf/model/excel-index.ts @@ -0,0 +1,95 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { execSync } from 'child_process'; + +import type { ProductData } from './types'; +import { normalizeValue } from './utils'; + +type ExcelRow = Record; +export type ExcelMatch = { rows: ExcelRow[]; units: Record }; + +const EXCEL_SOURCE_FILES = [ + path.join(process.cwd(), 'data/source/high-voltage.xlsx'), + path.join(process.cwd(), 'data/source/medium-voltage-KM.xlsx'), + path.join(process.cwd(), 'data/source/low-voltage-KM.xlsx'), + path.join(process.cwd(), 'data/source/solar-cables.xlsx'), +]; + +let EXCEL_INDEX: Map | 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[] { + const out = execSync(`npx -y xlsx-cli -j "${filePath}"`, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + const trimmed = out.trim(); + const jsonStart = trimmed.indexOf('['); + if (jsonStart < 0) return []; + const jsonText = trimmed.slice(jsonStart); + try { + return JSON.parse(jsonText) as ExcelRow[]; + } catch { + return []; + } +} + +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; +} diff --git a/scripts/pdf/model/types.ts b/scripts/pdf/model/types.ts new file mode 100644 index 00000000..faf20c4b --- /dev/null +++ b/scripts/pdf/model/types.ts @@ -0,0 +1,51 @@ +export interface ProductData { + id: number; + name: string; + shortDescriptionHtml: string; + descriptionHtml: string; + images: string[]; + featuredImage: string | null; + sku: string; + slug?: string; + path?: string; + translationKey?: string; + locale?: 'en' | 'de'; + categories: Array<{ name: string }>; + attributes: Array<{ + name: string; + options: string[]; + }>; +} + +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[]; +}; + diff --git a/scripts/pdf/model/utils.ts b/scripts/pdf/model/utils.ts new file mode 100644 index 00000000..67c67527 --- /dev/null +++ b/scripts/pdf/model/utils.ts @@ -0,0 +1,72 @@ +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: 'PRODUCT DATASHEET', + description: 'DESCRIPTION', + technicalData: 'TECHNICAL DATA', + crossSection: 'CROSS-SECTION DATA', + sku: 'SKU', + noImage: 'No image available', + }, + de: { + datasheet: 'PRODUKTDATENBLATT', + description: 'BESCHREIBUNG', + technicalData: 'TECHNISCHE DATEN', + crossSection: 'QUERSCHNITTSDATEN', + 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..37404b09 --- /dev/null +++ b/scripts/pdf/react-pdf/DatasheetDocument.tsx @@ -0,0 +1,84 @@ +import * as React from 'react'; +import { Document, Image, Page, Text, View } from '@react-pdf/renderer'; + +import type { DatasheetModel, DatasheetVoltageTable } from '../model/types'; +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; +}; + +function chunk(arr: T[], size: number): T[][] { + if (size <= 0) return [arr]; + const out: T[][] = []; + for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); + return out; +} + +export function DatasheetDocument(props: { model: DatasheetModel; assets: Assets }): React.ReactElement { + const { model, assets } = props; + const headerTitle = model.labels.datasheet; + const footerLeft = `${model.labels.sku}: ${model.product.sku}`; + + const firstColLabel = model.locale === 'de' ? 'Adern & Querschnitt' : 'Cores & cross-section'; + + const tablePages: Array<{ table: DatasheetVoltageTable; rows: DatasheetVoltageTable['rows'] }> = + model.voltageTables.flatMap(t => { + const perPage = 30; + const chunks = chunk(t.rows, perPage); + return chunks.map(rows => ({ table: t, rows })); + }); + + return ( + + +
+