wip
This commit is contained in:
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