Files
klz-cables.com/scripts/generate-pdf-datasheets-pdf-lib.ts
Marc Mintel 02be8e59b2
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Failing after 1m53s
Build & Deploy / 🏗️ Build (push) Has been skipped
Build & Deploy / 🚀 Deploy (push) Has been skipped
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been skipped
Build & Deploy / 🔔 Notify (push) Successful in 2s
feat: auto-opening brochure modal with mintel/mail delivery
- implemented BrochureDeliveryEmail template

- created AutoBrochureModal wrapper with 5s delay

- updated layout.tsx and BrochureCTA to use new success state

- added tests/brochure-modal.test.ts e2e test
2026-03-02 23:08:05 +01:00

4556 lines
149 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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.23.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 (dont 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.902, 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.902, 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.3));
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.3));
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>;
green: 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: 1.5,
color: colors.green,
});
// Left: site URL (always)
page.drawText(CONFIG.siteUrl, {
x: margin,
y: footerY,
size: 7,
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, 7),
y: footerY,
size: 7,
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.green,
});
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();
// Brand tokens (matching brochure)
const navy = rgb(0, 0.051, 0.149); // #000d26 (brochure navyDeep)
const green = rgb(0.302, 0.651, 0.071); // #4da612 (brochure green)
const mediumGray = rgb(0.294, 0.333, 0.388); // #4b5563 (brochure gray600)
const darkGray = rgb(0.067, 0.098, 0.165); // #111827 (brochure gray900)
const almostWhite = rgb(0.973, 0.976, 0.98); // #F8F9FA (brochure offWhite)
const lightGray = rgb(0.898, 0.906, 0.918); // #E5E7EB (brochure gray200)
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, green, 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 };