wip
This commit is contained in:
@@ -14,6 +14,9 @@ export default [
|
|||||||
'data/**',
|
'data/**',
|
||||||
// Scripts + config files are not part of the frontend lint target.
|
// Scripts + config files are not part of the frontend lint target.
|
||||||
'scripts/**',
|
'scripts/**',
|
||||||
|
// …except the PDF generator which is maintained runtime code.
|
||||||
|
'!scripts/pdf/**',
|
||||||
|
'!scripts/generate-pdf-datasheets.ts',
|
||||||
'next.config.*',
|
'next.config.*',
|
||||||
'postcss.config.js',
|
'postcss.config.js',
|
||||||
'tailwind.config.*',
|
'tailwind.config.*',
|
||||||
@@ -21,6 +24,47 @@ export default [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
js.configs.recommended,
|
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
|
// Only lint the actual source files, not build output or scripts
|
||||||
files: ['app/**/*.{ts,tsx}', 'components/**/*.{ts,tsx}', 'lib/**/*.{ts,tsx}'],
|
files: ['app/**/*.{ts,tsx}', 'components/**/*.{ts,tsx}', 'lib/**/*.{ts,tsx}'],
|
||||||
|
|||||||
21
package-lock.json
generated
21
package-lock.json
generated
@@ -42,6 +42,7 @@
|
|||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"sass": "^1.97.1",
|
"sass": "^1.97.1",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vitest": "^4.0.16"
|
"vitest": "^4.0.16"
|
||||||
}
|
}
|
||||||
@@ -9540,6 +9541,26 @@
|
|||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"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": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"export": "next export",
|
"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:export": "node scripts/wordpress-export.js",
|
||||||
"data:process": "node scripts/process-data.js",
|
"data:process": "node scripts/process-data.js",
|
||||||
"data:analyze": "node scripts/analyze-export.js",
|
"data:analyze": "node scripts/analyze-export.js",
|
||||||
@@ -51,6 +55,7 @@
|
|||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"sass": "^1.97.1",
|
"sass": "^1.97.1",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.1.18",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vitest": "^4.0.16"
|
"vitest": "^4.0.16"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
4106
scripts/generate-pdf-datasheets-pdf-lib.ts
Normal file
4106
scripts/generate-pdf-datasheets-pdf-lib.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
574
scripts/pdf/model/build-datasheet-model.ts
Normal file
574
scripts/pdf/model/build-datasheet-model.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
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<string, string>;
|
||||||
|
|
||||||
|
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<string, { header: string; unit: string; key: string }> = {
|
||||||
|
'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<string>();
|
||||||
|
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<string, number[]>();
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
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<string, KeyValueItem>();
|
||||||
|
|
||||||
|
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<string>(denseTableKeyOrder);
|
||||||
|
|
||||||
|
const tableColumns: Array<{ excelKey: string; mapping: { header: string; unit: string; key: string } }> = [];
|
||||||
|
for (const { excelKey, mapping } of deduplicated) {
|
||||||
|
if (excelKey === csKey || excelKey === voltageKey) continue;
|
||||||
|
const values = indices.map(idx => normalizeValue(String(compatibleRows[idx]?.[excelKey] ?? ''))).filter(Boolean);
|
||||||
|
if (!values.length) continue;
|
||||||
|
const unique = Array.from(new Set(values.map(v => v.toLowerCase())));
|
||||||
|
const unit = normalizeUnit(units[excelKey] || mapping.unit || '');
|
||||||
|
|
||||||
|
if (denseTableKeys.has(mapping.key)) {
|
||||||
|
tableColumns.push({ excelKey, mapping });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalConstantColumns.has(excelKey) && !metaKeyPrioritySet.has(mapping.key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = unique.length === 1 ? compactCellForDenseTable(values[0], unit, args.locale) : summarizeSmartOptions(excelKey, values);
|
||||||
|
const label = metaFullLabel({ key: mapping.key, excelKey, locale: args.locale });
|
||||||
|
metaCandidates.set(mapping.key, { label, value, unit });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const k of metaKeyPriority) {
|
||||||
|
const item = metaCandidates.get(k);
|
||||||
|
if (item && item.label && item.value) metaItems.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedByKey = new Map<string, { excelKey: string; mapping: { header: string; unit: string; key: string } }>();
|
||||||
|
for (const c of tableColumns) {
|
||||||
|
if (!mappedByKey.has(c.mapping.key)) mappedByKey.set(c.mapping.key, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
95
scripts/pdf/model/excel-index.ts
Normal file
95
scripts/pdf/model/excel-index.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
export type ExcelMatch = { rows: ExcelRow[]; units: Record<string, string> };
|
||||||
|
|
||||||
|
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<string, ExcelMatch> | 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<string, ExcelMatch> {
|
||||||
|
if (EXCEL_INDEX) return EXCEL_INDEX;
|
||||||
|
const idx = new Map<string, ExcelMatch>();
|
||||||
|
|
||||||
|
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<string, string> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
51
scripts/pdf/model/types.ts
Normal file
51
scripts/pdf/model/types.ts
Normal file
@@ -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[];
|
||||||
|
};
|
||||||
|
|
||||||
72
scripts/pdf/model/utils.ts
Normal file
72
scripts/pdf/model/utils.ts
Normal file
@@ -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];
|
||||||
|
}
|
||||||
84
scripts/pdf/react-pdf/DatasheetDocument.tsx
Normal file
84
scripts/pdf/react-pdf/DatasheetDocument.tsx
Normal file
@@ -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<T>(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 (
|
||||||
|
<Document>
|
||||||
|
<Page size="A4" style={styles.page}>
|
||||||
|
<Header title={headerTitle} logoDataUrl={assets.logoDataUrl} qrDataUrl={assets.qrDataUrl} />
|
||||||
|
<Footer leftText={footerLeft} locale={model.locale} />
|
||||||
|
|
||||||
|
<Text style={styles.h1}>{model.product.name}</Text>
|
||||||
|
{model.product.categoriesLine ? <Text style={styles.subhead}>{model.product.categoriesLine}</Text> : null}
|
||||||
|
|
||||||
|
<View style={styles.heroBox}>
|
||||||
|
{assets.heroDataUrl ? (
|
||||||
|
<Image src={assets.heroDataUrl} style={styles.heroImage} />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.noImage}>{model.labels.noImage}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{model.product.descriptionText ? (
|
||||||
|
<Section title={model.labels.description}>
|
||||||
|
<Text style={styles.body}>{model.product.descriptionText}</Text>
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{model.technicalItems.length ? (
|
||||||
|
<Section title={model.labels.technicalData}>
|
||||||
|
<KeyValueGrid items={model.technicalItems} />
|
||||||
|
</Section>
|
||||||
|
) : null}
|
||||||
|
</Page>
|
||||||
|
|
||||||
|
{tablePages.map((p, index) => (
|
||||||
|
<Page key={`${p.table.voltageLabel}-${index}`} size="A4" style={styles.page}>
|
||||||
|
<Header title={headerTitle} logoDataUrl={assets.logoDataUrl} qrDataUrl={assets.qrDataUrl} />
|
||||||
|
<Footer leftText={footerLeft} locale={model.locale} />
|
||||||
|
|
||||||
|
<Section title={`${model.labels.crossSection} — ${p.table.voltageLabel}`}>
|
||||||
|
{p.table.metaItems.length ? <KeyValueGrid items={p.table.metaItems} /> : null}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<DenseTable table={{ columns: p.table.columns, rows: p.rows }} firstColLabel={firstColLabel} />
|
||||||
|
</Page>
|
||||||
|
))}
|
||||||
|
</Document>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
78
scripts/pdf/react-pdf/assets.ts
Normal file
78
scripts/pdf/react-pdf/assets.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
type SharpLike = (input?: unknown, options?: unknown) => { png: () => { toBuffer: () => Promise<Buffer> } };
|
||||||
|
|
||||||
|
let sharpFn: SharpLike | null = null;
|
||||||
|
async function getSharp(): Promise<SharpLike> {
|
||||||
|
if (sharpFn) return sharpFn;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const mod: any = await import('sharp');
|
||||||
|
sharpFn = (mod?.default || mod) as SharpLike;
|
||||||
|
return sharpFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PUBLIC_DIR = path.join(process.cwd(), 'public');
|
||||||
|
|
||||||
|
async function fetchBytes(url: string): Promise<Uint8Array> {
|
||||||
|
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<Uint8Array> {
|
||||||
|
const abs = path.join(PUBLIC_DIR, localPath.replace(/^\//, ''));
|
||||||
|
return new Uint8Array(fs.readFileSync(abs));
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformLogoSvgToPrintBlack(svg: string): string {
|
||||||
|
return svg
|
||||||
|
.replace(/fill\s*:\s*white/gi, 'fill:#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<Uint8Array> {
|
||||||
|
const ext = (path.extname(inputHint).toLowerCase() || '').replace('.', '');
|
||||||
|
if (ext === 'png') return inputBytes;
|
||||||
|
|
||||||
|
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();
|
||||||
|
return new Uint8Array(await sharp(Buffer.from(inputBytes)).png().toBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDataUrlPng(bytes: Uint8Array): string {
|
||||||
|
return `data:image/png;base64,${Buffer.from(bytes).toString('base64')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadImageAsPngDataUrl(src: string | null): Promise<string | null> {
|
||||||
|
if (!src) return null;
|
||||||
|
try {
|
||||||
|
if (src.startsWith('/')) {
|
||||||
|
const bytes = await readBytesFromPublic(src);
|
||||||
|
const png = await toPngBytes(bytes, src);
|
||||||
|
return toDataUrlPng(png);
|
||||||
|
}
|
||||||
|
const bytes = await fetchBytes(src);
|
||||||
|
const png = await toPngBytes(bytes, src);
|
||||||
|
return toDataUrlPng(png);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadQrAsPngDataUrl(data: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const safe = encodeURIComponent(data);
|
||||||
|
const url = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${safe}`;
|
||||||
|
const bytes = await fetchBytes(url);
|
||||||
|
const png = await toPngBytes(bytes, url);
|
||||||
|
return toDataUrlPng(png);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
52
scripts/pdf/react-pdf/components/DenseTable.tsx
Normal file
52
scripts/pdf/react-pdf/components/DenseTable.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Text, View } from '@react-pdf/renderer';
|
||||||
|
|
||||||
|
import type { DatasheetVoltageTable } from '../../model/types';
|
||||||
|
import { styles } from '../styles';
|
||||||
|
|
||||||
|
function clamp(n: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(max, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DenseTable(props: {
|
||||||
|
table: Pick<DatasheetVoltageTable, 'columns' | 'rows'>;
|
||||||
|
firstColLabel: string;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const cols = props.table.columns;
|
||||||
|
const rows = props.table.rows;
|
||||||
|
|
||||||
|
const cfgPct = cols.length >= 12 ? 0.28 : 0.32;
|
||||||
|
const dataPct = 1 - cfgPct;
|
||||||
|
const each = cols.length ? dataPct / cols.length : dataPct;
|
||||||
|
const cfgW = `${Math.round(cfgPct * 100)}%`;
|
||||||
|
const dataW = `${Math.round(clamp(each, 0.03, 0.12) * 1000) / 10}%`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.tableWrap}>
|
||||||
|
<View style={styles.tableHeader}>
|
||||||
|
<View style={{ width: cfgW }}>
|
||||||
|
<Text style={styles.tableHeaderCell}>{props.firstColLabel}</Text>
|
||||||
|
</View>
|
||||||
|
{cols.map(c => (
|
||||||
|
<View key={c.key} style={{ width: dataW }}>
|
||||||
|
<Text style={styles.tableHeaderCell}>{c.label}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{rows.map((r, ri) => (
|
||||||
|
<View key={`${r.configuration}-${ri}`} style={[styles.tableRow, ri % 2 === 0 ? styles.tableRowAlt : null]}>
|
||||||
|
<View style={{ width: cfgW }}>
|
||||||
|
<Text style={styles.tableCell}>{r.configuration}</Text>
|
||||||
|
</View>
|
||||||
|
{r.cells.map((cell, ci) => (
|
||||||
|
<View key={`${cols[ci]?.key || ci}`} style={{ width: dataW }}>
|
||||||
|
<Text style={styles.tableCell}>{cell}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
21
scripts/pdf/react-pdf/components/Footer.tsx
Normal file
21
scripts/pdf/react-pdf/components/Footer.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Text, View } from '@react-pdf/renderer';
|
||||||
|
|
||||||
|
import { styles } from '../styles';
|
||||||
|
|
||||||
|
export function Footer(props: { leftText: string; locale: 'en' | 'de' }): React.ReactElement {
|
||||||
|
const date = new Date().toLocaleDateString(props.locale === 'en' ? 'en-US' : 'de-DE', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.footer} fixed>
|
||||||
|
<Text>{props.leftText}</Text>
|
||||||
|
<Text>{date}</Text>
|
||||||
|
<Text render={({ pageNumber, totalPages }) => `${pageNumber}/${totalPages}`} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
26
scripts/pdf/react-pdf/components/Header.tsx
Normal file
26
scripts/pdf/react-pdf/components/Header.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Image, Text, View } from '@react-pdf/renderer';
|
||||||
|
|
||||||
|
import { styles } from '../styles';
|
||||||
|
|
||||||
|
export function Header(props: { title: string; logoDataUrl?: string | null; qrDataUrl?: string | null }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<View style={styles.header} fixed>
|
||||||
|
<View style={styles.headerLeft}>
|
||||||
|
{props.logoDataUrl ? (
|
||||||
|
<Image src={props.logoDataUrl} style={styles.logo} />
|
||||||
|
) : (
|
||||||
|
<View style={styles.brandFallback}>
|
||||||
|
<Text style={styles.brandFallbackKlz}>KLZ</Text>
|
||||||
|
<Text style={styles.brandFallbackCables}>Cables</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.headerRight}>
|
||||||
|
<Text style={styles.headerTitle}>{props.title}</Text>
|
||||||
|
{props.qrDataUrl ? <Image src={props.qrDataUrl} style={styles.qr} /> : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
30
scripts/pdf/react-pdf/components/KeyValueGrid.tsx
Normal file
30
scripts/pdf/react-pdf/components/KeyValueGrid.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Text, View } from '@react-pdf/renderer';
|
||||||
|
|
||||||
|
import type { KeyValueItem } from '../../model/types';
|
||||||
|
import { styles } from '../styles';
|
||||||
|
|
||||||
|
export function KeyValueGrid(props: { items: KeyValueItem[] }): React.ReactElement | null {
|
||||||
|
const items = (props.items || []).filter(i => i.label && i.value);
|
||||||
|
if (!items.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.kvGrid}>
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const isLast = index === items.length - 1;
|
||||||
|
const valueText = item.unit ? `${item.value} ${item.unit}` : item.value;
|
||||||
|
return (
|
||||||
|
<View key={`${item.label}-${index}`} style={[styles.kvRow, isLast ? styles.kvRowLast : null]}>
|
||||||
|
<View style={styles.kvLabel}>
|
||||||
|
<Text style={styles.kvLabelText}>{item.label}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.kvValue}>
|
||||||
|
<Text style={styles.kvValueText}>{valueText}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
14
scripts/pdf/react-pdf/components/Section.tsx
Normal file
14
scripts/pdf/react-pdf/components/Section.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Text, View } from '@react-pdf/renderer';
|
||||||
|
|
||||||
|
import { styles } from '../styles';
|
||||||
|
|
||||||
|
export function Section(props: { title: string; children: React.ReactNode }): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>{props.title}</Text>
|
||||||
|
{props.children}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
26
scripts/pdf/react-pdf/generate-datasheet-pdf.tsx
Normal file
26
scripts/pdf/react-pdf/generate-datasheet-pdf.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { renderToBuffer } from '@react-pdf/renderer';
|
||||||
|
|
||||||
|
import type { ProductData } from '../model/types';
|
||||||
|
import { buildDatasheetModel } from '../model/build-datasheet-model';
|
||||||
|
import { loadImageAsPngDataUrl, loadQrAsPngDataUrl } from './assets';
|
||||||
|
import { DatasheetDocument } from './DatasheetDocument';
|
||||||
|
|
||||||
|
export async function generateDatasheetPdfBuffer(args: {
|
||||||
|
product: ProductData;
|
||||||
|
locale: 'en' | 'de';
|
||||||
|
}): Promise<Buffer> {
|
||||||
|
const model = buildDatasheetModel({ product: args.product, locale: args.locale });
|
||||||
|
|
||||||
|
const logoDataUrl =
|
||||||
|
(await loadImageAsPngDataUrl('/media/logo.svg')) ||
|
||||||
|
(await loadImageAsPngDataUrl('/media/logo.webp')) ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
const heroDataUrl = await loadImageAsPngDataUrl(model.product.heroSrc);
|
||||||
|
const qrDataUrl = await loadQrAsPngDataUrl(model.product.productUrl);
|
||||||
|
|
||||||
|
const element = <DatasheetDocument model={model} assets={{ logoDataUrl, heroDataUrl, qrDataUrl }} />;
|
||||||
|
return await renderToBuffer(element);
|
||||||
|
}
|
||||||
|
|
||||||
119
scripts/pdf/react-pdf/styles.ts
Normal file
119
scripts/pdf/react-pdf/styles.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { StyleSheet } from '@react-pdf/renderer';
|
||||||
|
|
||||||
|
export const COLORS = {
|
||||||
|
navy: '#0E2A47',
|
||||||
|
mediumGray: '#6B7280',
|
||||||
|
darkGray: '#1F2933',
|
||||||
|
lightGray: '#E6E9ED',
|
||||||
|
almostWhite: '#F8F9FA',
|
||||||
|
headerBg: '#F6F8FB',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const styles = StyleSheet.create({
|
||||||
|
page: {
|
||||||
|
paddingTop: 54,
|
||||||
|
paddingLeft: 54,
|
||||||
|
paddingRight: 54,
|
||||||
|
paddingBottom: 72,
|
||||||
|
fontFamily: 'Helvetica',
|
||||||
|
fontSize: 10,
|
||||||
|
color: COLORS.darkGray,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
backgroundColor: COLORS.headerBg,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: COLORS.lightGray,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
||||||
|
logo: { width: 110, height: 24, objectFit: 'contain' },
|
||||||
|
brandFallback: { flexDirection: 'row', alignItems: 'baseline', gap: 6 },
|
||||||
|
brandFallbackKlz: { fontSize: 18, fontWeight: 700, color: COLORS.navy },
|
||||||
|
brandFallbackCables: { fontSize: 10, color: COLORS.mediumGray },
|
||||||
|
headerRight: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
||||||
|
headerTitle: { fontSize: 9, fontWeight: 700, color: COLORS.navy, letterSpacing: 0.2 },
|
||||||
|
qr: { width: 34, height: 34, objectFit: 'contain' },
|
||||||
|
|
||||||
|
footer: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 54,
|
||||||
|
right: 54,
|
||||||
|
bottom: 36,
|
||||||
|
paddingTop: 10,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: COLORS.lightGray,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
fontSize: 8,
|
||||||
|
color: COLORS.mediumGray,
|
||||||
|
},
|
||||||
|
|
||||||
|
h1: { fontSize: 18, fontWeight: 700, color: COLORS.navy, marginBottom: 6 },
|
||||||
|
subhead: { fontSize: 10.5, color: COLORS.mediumGray, marginBottom: 14 },
|
||||||
|
|
||||||
|
heroBox: {
|
||||||
|
height: 110,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: COLORS.lightGray,
|
||||||
|
backgroundColor: COLORS.almostWhite,
|
||||||
|
marginBottom: 16,
|
||||||
|
justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
heroImage: { width: '100%', height: '100%', objectFit: 'contain' },
|
||||||
|
noImage: { fontSize: 8, color: COLORS.mediumGray, paddingHorizontal: 12 },
|
||||||
|
|
||||||
|
section: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: COLORS.lightGray,
|
||||||
|
padding: 14,
|
||||||
|
marginBottom: 14,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: COLORS.navy,
|
||||||
|
marginBottom: 8,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
},
|
||||||
|
body: { fontSize: 10, lineHeight: 1.5, color: COLORS.darkGray },
|
||||||
|
|
||||||
|
kvGrid: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: COLORS.lightGray,
|
||||||
|
},
|
||||||
|
kvRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: COLORS.lightGray },
|
||||||
|
kvRowLast: { borderBottomWidth: 0 },
|
||||||
|
kvLabel: {
|
||||||
|
width: '45%',
|
||||||
|
paddingVertical: 6,
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
backgroundColor: COLORS.almostWhite,
|
||||||
|
borderRightWidth: 1,
|
||||||
|
borderRightColor: COLORS.lightGray,
|
||||||
|
},
|
||||||
|
kvValue: { width: '55%', paddingVertical: 6, paddingHorizontal: 8 },
|
||||||
|
kvLabelText: { fontSize: 8.5, fontWeight: 700, color: COLORS.mediumGray },
|
||||||
|
kvValueText: { fontSize: 9.5, color: COLORS.darkGray },
|
||||||
|
|
||||||
|
tableWrap: { borderWidth: 1, borderColor: COLORS.lightGray },
|
||||||
|
tableHeader: { flexDirection: 'row', backgroundColor: '#6B707A' },
|
||||||
|
tableHeaderCell: {
|
||||||
|
paddingVertical: 5,
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
fontSize: 6.6,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
tableRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: COLORS.lightGray },
|
||||||
|
tableRowAlt: { backgroundColor: COLORS.almostWhite },
|
||||||
|
tableCell: { paddingVertical: 4, paddingHorizontal: 4, fontSize: 6.6, color: COLORS.darkGray },
|
||||||
|
});
|
||||||
|
|
||||||
Reference in New Issue
Block a user