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