Files
klz-cables.com/scripts/generate-pdf-datasheets.ts
2026-01-13 19:25:39 +01:00

3423 lines
122 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 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 matches = v.matchAll(/(\d+)xD/g);
const numbers = [];
for (const match of matches) {
numbers.push(match[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, '.');
const num = parseFloat(n);
// Abbreviate numbers with k/M suffixes for compactness
// Use 100 as threshold to abbreviate even 3-digit numbers like 500 → 0.5k
let compact = n;
if (Math.abs(num) >= 1000000) {
compact = (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
} else if (Math.abs(num) >= 1000) {
compact = (num / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
} else if (Math.abs(num) >= 100) {
// Abbreviate 3-digit numbers: 500 → 0.5k, 520 → 0.5k, 1000 → 1k
compact = (num / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
} else {
compact = n.replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '');
}
return locale === 'de' ? compact.replace(/\./g, ',') : compact;
});
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(/\bua?f\s*\/\s*km\b/gi, '')
.replace(/\bmh\s*\/\s*km\b/gi, '')
.replace(/\bka\b/gi, '')
.replace(/\ba\b/gi, '')
.replace(/\bmm\b/gi, '')
.replace(/\bkv\b/gi, '')
.replace(/\b°c\b/gi, '')
.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 13 required headers
// 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' },
'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' },
'bending radius': { header: 'Rbv', unit: 'mm', key: 'Rbv' },
'bending radius (min.)': { header: 'Rbv', unit: 'mm', key: 'Rbv' },
'outer diameter': { header: 'OD', unit: 'mm', key: 'Ø' },
'outer diameter (approx.)': { header: 'OD', unit: 'mm', key: 'Ø' },
'outer diameter of cable': { header: 'OD', unit: 'mm', key: 'Ø' },
'pulling force': { header: 'Fzv', unit: 'N', key: 'Fzv' },
'max. pulling force': { header: 'Fzv', unit: 'N', key: 'Fzv' },
'conductor aluminum': { header: 'AL / CU', unit: '', key: 'Al' },
'conductor copper': { header: 'AL / CU', unit: '', key: 'Cu' },
'weight': { header: 'G', unit: 'kg/km', key: 'G' },
'weight (approx.)': { 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' },
'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
// Resistance columns (must come before generic 'conductor' pattern)
'maximum resistance of conductor': { header: 'RI', unit: 'Ohm/km', key: 'RI' },
// Conductor material columns (specific before generic)
// Note: Already defined above (lines 254-255)
'conductor': { header: 'AL / CU', unit: '', key: 'Al' },
// 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', 'Wm', 'Rbv', 'OD', 'Fzv', 'Al', 'Cu', '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);
// Add to technical items if not already there
const existing = technicalItems.find(t => t.label === mapping.header);
if (!existing) {
let value = values[0];
// Show actual conductor material from Excel
if (mapping.key === 'Al' || mapping.key === 'Cu') {
// Keep the actual value from Excel (e.g., "AL" or "CU")
// No transformation needed
}
technicalItems.push({
label: mapping.header,
value: value,
unit: mapping.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: voltage rating
const metaItems: KeyValueItem[] = [];
if (voltageKey) {
const rawV = normalizeValue(String(compatibleRows[indices[0]]?.[voltageKey] ?? ''));
metaItems.push({
label: 'Spannung',
value: normalizeVoltageLabel(rawV || ''),
});
}
// Include ALL columns in the table (both constant and variable)
// Only skip cross-section and voltage keys (these are used for grouping)
const tableColumns: Array<{ excelKey: string; mapping: { header: string; unit: string; key: string } }> = [];
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) {
// Include ALL columns in table, regardless of whether they're constant or variable
tableColumns.push({ excelKey, mapping });
}
}
// 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(', ')}`);
}
}
// Meta items: only voltage rating (everything else goes in the table now)
// This avoids redundancy since all technical data is in the table
const columns = tableColumns.map(({ excelKey, mapping }) => ({
key: mapping.key,
label: `${mapping.header} [${mapping.unit}]`,
get: (rowIndex: number) => {
const srcRowIndex = indices[rowIndex];
const raw = normalizeValue(String(compatibleRows[srcRowIndex]?.[excelKey] ?? ''));
// Remove units from cell values (already in header)
let cleaned = raw;
if (mapping.unit) {
const esc = mapping.unit.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
cleaned = cleaned.replace(new RegExp(`\\s*${esc}\\b`, 'ig'), '').trim();
}
// Show actual conductor material from Excel (AL or CU)
if (mapping.key === 'Al' || mapping.key === 'Cu') {
// Return the cleaned value (e.g., "AL" or "CU")
return cleaned;
}
// Compact decimals (including Rbv values)
return compactNumericForLocale(cleaned, args.locale);
},
}));
voltageTables.push({ voltageLabel: vKey, metaItems, crossSections, columns });
// Debug: Show columns for this voltage
if (process.env.PDF_DEBUG_EXCEL === '1') {
console.log(`[debug] Voltage ${vKey}: ${columns.length} columns`);
console.log(`[debug] Columns: ${columns.map(c => c.key).join(', ')}`);
}
}
technicalItems.sort((a, b) => a.label.localeCompare(b.label));
// Debug: Show technical items
if (process.env.PDF_DEBUG_EXCEL === '1') {
console.log(`[debug] Technical items: ${technicalItems.map(t => t.label).join(', ')}`);
}
return { ok: true, technicalItems, voltageTables };
}
function drawDenseMetaGrid(args: {
title: string;
items: Array<{ label: string; value: string; unit?: string }>;
locale: 'en' | 'de';
newPage: () => number;
getPage: () => PDFPage;
page: PDFPage;
y: number;
margin: number;
contentWidth: number;
contentMinY: number;
font: PDFFont;
fontBold: PDFFont;
navy: ReturnType<typeof rgb>;
darkGray: ReturnType<typeof rgb>;
mediumGray: ReturnType<typeof rgb>;
}): 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;
// Title
if (args.title) {
page = args.getPage();
if (y - 18 < contentMinY) y = args.newPage();
page.drawText(args.title, { x: margin, y, size: 10, font: fontBold, color: navy });
y -= 14;
}
// 2-column layout with clear label/value separation and units
// Each row shows: "Label: Value" on left, "Label: Value" on right
// Units are shown inline with values for clarity
const rowHeight = 16; // Increased for better readability
const colGap = 20; // Gap between columns
const colWidth = (contentWidth - colGap) / 2;
const labelWidth = colWidth * 0.45; // Label gets 45% of column width
const valueWidth = colWidth * 0.55; // Value gets 55% of column width
let currentY = y;
for (let i = 0; i < items.length; i += 2) {
// Check if we need a new page
if (currentY - rowHeight < contentMinY) {
currentY = args.newPage();
page = args.getPage();
// Add continuation title if needed
if (args.title) {
const suffix = args.locale === 'de' ? '(Fortsetzung)' : '(continued)';
page.drawText(`${args.title} ${suffix}`, { x: margin, y: currentY, size: 10, font: fontBold, color: navy });
currentY -= 14;
}
}
// Draw first item in row
const item1 = items[i];
if (item1) {
// Label (bold)
page.drawText(`${item1.label}:`, {
x: margin,
y: currentY,
size: 8,
font: fontBold,
color: mediumGray,
maxWidth: labelWidth,
});
// Value + unit (regular)
const valueText = item1.unit ? `${item1.value} ${item1.unit}` : item1.value;
page.drawText(valueText, {
x: margin + labelWidth + 2,
y: currentY,
size: 8.5,
font,
color: darkGray,
maxWidth: valueWidth - 2,
});
}
// Draw second item in row (if exists)
const item2 = items[i + 1];
if (item2) {
const xLabel = margin + colWidth + colGap;
const xValue = xLabel + labelWidth + 2;
// Label (bold)
page.drawText(`${item2.label}:`, {
x: xLabel,
y: currentY,
size: 8,
font: fontBold,
color: mediumGray,
maxWidth: labelWidth,
});
// Value + unit (regular)
const valueText = item2.unit ? `${item2.value} ${item2.unit}` : item2.value;
page.drawText(valueText, {
x: xValue,
y: currentY,
size: 8.5,
font,
color: darkGray,
maxWidth: valueWidth - 2,
});
}
currentY -= rowHeight;
}
// Return cursor position below the last row
return Math.max(contentMinY, currentY - 12);
}
function prioritizeColumnsForDenseTable(args: {
columns: VoltageTableModel['columns'];
}): VoltageTableModel['columns'] {
// Priority order: the 13 required headers first, then all additional technical data
const priorityOrder = [
// The 13 required headers (in exact order)
// Note: Ik is replaced with Ik_cond (conductor shortcircuit current)
'DI', 'RI', 'Wi', 'Ibl', 'Ibe', 'Ik_cond', 'Wm', 'Rbv', 'OD', 'Fzv', 'Al', 'Cu', 'G',
// Additional technical columns (in logical groups)
// Dimensions and materials
'cond_diam', 'shape', 'conductor', 'insulation', 'sheath',
// Temperatures
'max_op_temp', 'max_sc_temp', 'temp_range', 'min_store_temp', 'min_lay_temp',
// Electrical properties
'cap', 'ind_trefoil', 'ind_air_flat', 'ind_ground_flat',
// Current ratings (flat)
'cur_air_flat', 'cur_ground_flat',
// Heating time constants
'heat_trefoil', 'heat_flat',
// Voltage ratings
'test_volt', 'rated_volt',
// Colors
'color_ins', 'color_sheath',
// Materials and specs
'norm', 'standard', 'cpr', 'flame', 'packaging', 'ce',
// Screen/tape layers
'tape_below', 'copper_screen', 'tape_above', 'al_foil',
// Additional shortcircuit current
'Ik_screen'
];
return [...args.columns].sort((a, b) => {
const ia = priorityOrder.indexOf(a.key);
const ib = priorityOrder.indexOf(b.key);
if (ia !== -1 && ib !== -1) return ia - ib;
if (ia !== -1) return -1;
if (ib !== -1) return 1;
return a.key.localeCompare(b.key);
});
}
function normalizeExcelKey(value: string): string {
// Match product names/slugs and Excel "Part Number" robustly.
// Examples:
// - "NA2XS(FL)2Y" -> "NA2XSFL2Y"
// - "na2xsfl2y-3" -> "NA2XSFL2Y"
return String(value || '')
.toUpperCase()
.replace(/-\d+$/g, '')
.replace(/[^A-Z0-9]+/g, '');
}
function loadExcelRows(filePath: string): ExcelRow[] {
// We intentionally avoid adding a heavy xlsx parser dependency.
// Instead, we use `xlsx-cli` via npx, which is already available at runtime.
// NOTE: `xlsx-cli -j` prints the sheet name on the first line, then JSON.
const out = execSync(`npx -y xlsx-cli -j "${filePath}"`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
const trimmed = out.trim();
const jsonStart = trimmed.indexOf('[');
if (jsonStart < 0) return [];
const jsonText = trimmed.slice(jsonStart);
try {
return JSON.parse(jsonText) as ExcelRow[];
} catch {
return [];
}
}
function getExcelIndex(): Map<string, ExcelMatch> {
if (EXCEL_INDEX) return EXCEL_INDEX;
const idx = new Map<string, ExcelMatch>();
for (const file of EXCEL_SOURCE_FILES) {
if (!fs.existsSync(file)) continue;
const rows = loadExcelRows(file);
if (process.env.PDF_DEBUG_EXCEL === '1') {
console.log(`[excel] loaded ${rows.length} rows from ${path.relative(process.cwd(), file)}`);
}
const unitsRow = rows.find(r => r && r['Part Number'] === 'Units') || null;
const units: Record<string, string> = {};
if (unitsRow) {
for (const [k, v] of Object.entries(unitsRow)) {
if (k === 'Part Number') continue;
const unit = normalizeValue(String(v ?? ''));
if (unit) units[k] = unit;
}
}
for (const r of rows) {
const pn = r?.['Part Number'];
if (!pn || pn === 'Units') continue;
const key = normalizeExcelKey(String(pn));
if (!key) continue;
const cur = idx.get(key);
if (!cur) {
idx.set(key, { rows: [r], units });
} else {
cur.rows.push(r);
if (Object.keys(cur.units).length < Object.keys(units).length) cur.units = units;
}
}
}
EXCEL_INDEX = idx;
return idx;
}
function findExcelForProduct(product: ProductData): ExcelMatch | null {
const idx = getExcelIndex();
const candidates = [
product.name,
product.slug ? product.slug.replace(/-\d+$/g, '') : '',
product.sku,
product.translationKey,
].filter(Boolean) as string[];
if (process.env.PDF_DEBUG_EXCEL === '1') {
const keys = candidates.map(c => normalizeExcelKey(c));
console.log(`[excel] lookup product=${product.id} ${product.locale ?? ''} slug=${product.slug ?? ''} name=${stripHtml(product.name)} keys=${keys.join(',')}`);
}
for (const c of candidates) {
const key = normalizeExcelKey(c);
const match = idx.get(key);
if (match && match.rows.length) return match;
}
return null;
}
function findExcelRowsForProduct(product: ProductData): ExcelRow[] {
const match = findExcelForProduct(product);
if (!match?.rows || match.rows.length === 0) return [];
const rows = match.rows;
// Find the row with most columns as sample
let sample = rows.find(r => r && Object.keys(r).length > 0) || {};
let maxColumns = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units').length;
for (const r of rows) {
const cols = Object.keys(r).filter(k => k && k !== 'Part Number' && k !== 'Units').length;
if (cols > maxColumns) {
sample = r;
maxColumns = cols;
}
}
// Filter to only rows with the same column structure as sample
const sampleKeys = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units').sort();
const compatibleRows = rows.filter(r => {
const rKeys = Object.keys(r).filter(k => k && k !== 'Part Number' && k !== 'Units').sort();
return JSON.stringify(rKeys) === JSON.stringify(sampleKeys);
});
return compatibleRows;
}
function guessColumnKey(row: ExcelRow, patterns: RegExp[]): string | null {
const keys = Object.keys(row || {});
// Try pattern-based matching first
for (const re of patterns) {
const k = keys.find(x => {
const key = String(x);
// Specific exclusions to prevent wrong matches
if (re.test('conductor') && /ross section conductor/i.test(key)) return false;
if (re.test('insulation thickness') && /Diameter over insulation/i.test(key)) return false;
if (re.test('conductor') && !/^conductor$/i.test(key)) return false;
if (re.test('insulation') && !/^insulation$/i.test(key)) return false;
if (re.test('sheath') && !/^sheath$/i.test(key)) return false;
if (re.test('norm') && !/^norm$/i.test(key)) return false;
return re.test(key);
});
if (k) return k;
}
return null;
}
function hasAttr(product: ProductData, nameRe: RegExp, expectedLen?: number): boolean {
const a = product.attributes?.find(x => nameRe.test(x.name));
if (!a) return false;
if (typeof expectedLen === 'number') return (a.options || []).length === expectedLen;
return (a.options || []).length > 0;
}
function pushRowAttrIfMissing(args: {
product: ProductData;
name: string;
options: string[];
expectedLen: number;
existsRe: RegExp;
}): void {
const { product, name, options, expectedLen, existsRe } = args;
if (!options.filter(Boolean).length) return;
if (hasAttr(product, existsRe, expectedLen)) return;
product.attributes = product.attributes || [];
product.attributes.push({ name, options });
}
function pushAttrIfMissing(args: { product: ProductData; name: string; options: string[]; existsRe: RegExp }): void {
const { product, name, options, existsRe } = args;
if (!options.filter(Boolean).length) return;
if (hasAttr(product, existsRe)) return;
product.attributes = product.attributes || [];
product.attributes.push({ name, options });
}
function getUniqueNonEmpty(options: string[]): string[] {
const uniq: string[] = [];
const seen = new Set<string>();
for (const v of options.map(normalizeValue).filter(Boolean)) {
const k = v.toLowerCase();
if (seen.has(k)) continue;
seen.add(k);
uniq.push(v);
}
return uniq;
}
function ensureExcelCrossSectionAttributes(product: ProductData, locale: 'en' | 'de'): void {
const hasCross = (product.attributes || []).some(a => /configuration|konfiguration|aufbau|bezeichnung|number of cores and cross-section|querschnitt|cross.?section|mm²|mm2/i.test(a.name) && (a.options?.length || 0) > 0);
if (hasCross) return;
const rows = findExcelRowsForProduct(product);
if (!rows.length) {
if (process.env.PDF_DEBUG_EXCEL === '1') {
console.log(`[excel] no rows found for product ${product.id} (${product.slug ?? stripHtml(product.name)})`);
}
return;
}
// Find the cross-section column.
const csKey =
guessColumnKey(rows[0], [
/number of cores and cross-section/i,
/cross.?section/i,
/ross section conductor/i,
]) || null;
if (!csKey) {
if (process.env.PDF_DEBUG_EXCEL === '1') {
console.log(`[excel] rows found but no cross-section column for product ${product.id}; available keys: ${Object.keys(rows[0] || {}).slice(0, 30).join(', ')}`);
}
return;
}
// Get all technical column keys using improved detection
const voltageKey = guessColumnKey(rows[0], [/rated voltage/i, /voltage rating/i, /spannungs/i, /nennspannung/i]);
const outerKey = guessColumnKey(rows[0], [/outer diameter\b/i, /outer diameter.*approx/i, /outer diameter of cable/i, /außen/i]);
const weightKey = guessColumnKey(rows[0], [/weight\b/i, /gewicht/i, /cable weight/i]);
const dcResKey = guessColumnKey(rows[0], [/dc resistance/i, /resistance conductor/i, /leiterwiderstand/i]);
// Additional technical columns
const ratedVoltKey = voltageKey; // Already found above
const testVoltKey = guessColumnKey(rows[0], [/test voltage/i, /prüfspannung/i]);
const tempRangeKey = guessColumnKey(rows[0], [/operating temperature range/i, /temperature range/i, /temperaturbereich/i]);
const minLayKey = guessColumnKey(rows[0], [/minimal temperature for laying/i]);
const minStoreKey = guessColumnKey(rows[0], [/minimal storage temperature/i]);
const maxOpKey = guessColumnKey(rows[0], [/maximal operating conductor temperature/i, /max\. operating/i]);
const maxScKey = guessColumnKey(rows[0], [/maximal short-circuit temperature/i, /short\s*circuit\s*temperature/i]);
const insThkKey = guessColumnKey(rows[0], [/nominal insulation thickness/i, /insulation thickness/i]);
const sheathThkKey = guessColumnKey(rows[0], [/nominal sheath thickness/i, /minimum sheath thickness/i]);
const maxResKey = guessColumnKey(rows[0], [/maximum resistance of conductor/i]);
// Material and specification columns
const conductorKey = guessColumnKey(rows[0], [/^conductor$/i]);
const insulationKey = guessColumnKey(rows[0], [/^insulation$/i]);
const sheathKey = guessColumnKey(rows[0], [/^sheath$/i]);
const normKey = guessColumnKey(rows[0], [/^norm$/i, /^standard$/i]);
const cprKey = guessColumnKey(rows[0], [/cpr class/i]);
const rohsKey = guessColumnKey(rows[0], [/^rohs$/i]);
const reachKey = guessColumnKey(rows[0], [/^reach$/i]);
const packagingKey = guessColumnKey(rows[0], [/^packaging$/i]);
const shapeKey = guessColumnKey(rows[0], [/shape of conductor/i]);
const flameKey = guessColumnKey(rows[0], [/flame retardant/i]);
const diamCondKey = guessColumnKey(rows[0], [/diameter conductor/i]);
const diamInsKey = guessColumnKey(rows[0], [/diameter over insulation/i]);
const diamScreenKey = guessColumnKey(rows[0], [/diameter over screen/i]);
const metalScreenKey = guessColumnKey(rows[0], [/metallic screen/i]);
const capacitanceKey = guessColumnKey(rows[0], [/capacitance/i]);
const reactanceKey = guessColumnKey(rows[0], [/reactance/i]);
const electricalStressKey = guessColumnKey(rows[0], [/electrical stress/i]);
const pullingForceKey = guessColumnKey(rows[0], [/max\. pulling force/i, /pulling force/i]);
const heatingTrefoilKey = guessColumnKey(rows[0], [/heating time constant.*trefoil/i]);
const heatingFlatKey = guessColumnKey(rows[0], [/heating time constant.*flat/i]);
const currentAirTrefoilKey = guessColumnKey(rows[0], [/current ratings in air.*trefoil/i]);
const currentAirFlatKey = guessColumnKey(rows[0], [/current ratings in air.*flat/i]);
const currentGroundTrefoilKey = guessColumnKey(rows[0], [/current ratings in ground.*trefoil/i]);
const currentGroundFlatKey = guessColumnKey(rows[0], [/current ratings in ground.*flat/i]);
const scCurrentCondKey = guessColumnKey(rows[0], [/conductor shortcircuit current/i]);
const scCurrentScreenKey = guessColumnKey(rows[0], [/screen shortcircuit current/i]);
const cfgName = locale === 'de' ? 'Anzahl der Adern und Querschnitt' : 'Number of cores and cross-section';
const cfgOptions = rows
.map(r => {
const cs = normalizeValue(String(r?.[csKey] ?? ''));
const v = voltageKey ? normalizeValue(String(r?.[voltageKey] ?? '')) : '';
if (!cs) return '';
if (!v) return cs;
// Keep the existing config separator used by splitConfig(): "cross - voltage".
// Add unit only if not already present.
const vHasUnit = /\bkv\b/i.test(v);
const vText = vHasUnit ? v : `${v} kV`;
return `${cs} - ${vText}`;
})
.filter(Boolean);
if (!cfgOptions.length) return;
const attrs = product.attributes || [];
attrs.push({ name: cfgName, options: cfgOptions });
const pushRowAttr = (name: string, key: string | null, unit?: string) => {
if (!key) return;
const options = rows
.map(r => normalizeValue(String(r?.[key] ?? '')))
.map(v => (unit && v && looksNumeric(v) ? `${v} ${unit}` : v));
if (options.filter(Boolean).length === 0) return;
attrs.push({ name, options });
};
// These names are chosen so existing PDF regexes can detect them.
pushRowAttr(locale === 'de' ? 'Außen-Ø' : 'Outer diameter', outerKey, 'mm');
pushRowAttr(locale === 'de' ? 'Gewicht' : 'Weight', weightKey, 'kg/km');
pushRowAttr(locale === 'de' ? 'DC-Leiterwiderstand (20C)' : 'DC resistance at 20 C', dcResKey, 'Ohm/km');
const colValues = (key: string | null) => rows.map(r => normalizeValue(String(r?.[key ?? ''] ?? '')));
const addConstOrSmallList = (args: { name: string; existsRe: RegExp; key: string | null }) => {
if (!args.key) return;
const uniq = getUniqueNonEmpty(colValues(args.key));
if (!uniq.length) return;
// If all rows share the same value, store as single option.
if (uniq.length === 1) {
pushAttrIfMissing({ product, name: args.name, options: [uniq[0]], existsRe: args.existsRe });
return;
}
// Otherwise store the unique set (TECHNICAL DATA will compact it).
pushAttrIfMissing({ product, name: args.name, options: uniq, existsRe: args.existsRe });
};
addConstOrSmallList({
name: locale === 'de' ? 'Nennspannung' : 'Rated voltage',
existsRe: /rated\s*voltage|voltage\s*rating|nennspannung|spannungsbereich/i,
key: ratedVoltKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'Prüfspannung' : 'Test voltage',
existsRe: /test\s*voltage|prüfspannung/i,
key: testVoltKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'Temperaturbereich' : 'Operating temperature range',
existsRe: /operating\s*temperature\s*range|temperature\s*range|temperaturbereich/i,
key: tempRangeKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'Min. Verlegetemperatur' : 'Minimal temperature for laying',
existsRe: /minimal\s*temperature\s*for\s*laying/i,
key: minLayKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'Min. Lagertemperatur' : 'Minimal storage temperature',
existsRe: /minimal\s*storage\s*temperature/i,
key: minStoreKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'Max. Betriebstemperatur' : 'Maximal operating conductor temperature',
existsRe: /maximal\s*operating\s*conductor\s*temperature|max\.?\s*operating/i,
key: maxOpKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'Kurzschlusstemperatur (max.)' : 'Maximal short-circuit temperature',
existsRe: /maximal\s*short-?circuit\s*temperature|short\s*circuit\s*temperature|kurzschlusstemperatur/i,
key: maxScKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'Isolationsdicke (nom.)' : 'Nominal insulation thickness',
existsRe: /nominal\s*insulation\s*thickness|insulation\s*thickness/i,
key: insThkKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'Manteldicke (nom.)' : 'Nominal sheath thickness',
existsRe: /nominal\s*sheath\s*thickness|minimum\s*sheath\s*thickness|manteldicke/i,
key: sheathThkKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'Max. Leiterwiderstand' : 'Maximum resistance of conductor',
existsRe: /maximum\s*resistance\s*of\s*conductor|max\.?\s*resistance|leiterwiderstand/i,
key: maxResKey,
});
// Add additional technical data from Excel files
addConstOrSmallList({
name: locale === 'de' ? 'Leiter' : 'Conductor',
existsRe: /conductor/i,
key: conductorKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'Isolierung' : 'Insulation',
existsRe: /insulation/i,
key: insulationKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'Mantel' : 'Sheath',
existsRe: /sheath/i,
key: sheathKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'Norm' : 'Standard',
existsRe: /norm|standard|iec|vde/i,
key: normKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'Leiterdurchmesser' : 'Conductor diameter',
existsRe: /diameter conductor|conductor diameter/i,
key: diamCondKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'Isolierungsdurchmesser' : 'Insulation diameter',
existsRe: /diameter over insulation|diameter insulation/i,
key: diamInsKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'Schirmdurchmesser' : 'Screen diameter',
existsRe: /diameter over screen|diameter screen/i,
key: diamScreenKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'Metallischer Schirm' : 'Metallic screen',
existsRe: /metallic screen/i,
key: metalScreenKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'Max. Zugkraft' : 'Max. pulling force',
existsRe: /max.*pulling force|pulling force/i,
key: pullingForceKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'Elektrische Spannung Leiter' : 'Electrical stress conductor',
existsRe: /electrical stress conductor/i,
key: electricalStressKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'Elektrische Spannung Isolierung' : 'Electrical stress insulation',
existsRe: /electrical stress insulation/i,
key: electricalStressKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'Reaktanz' : 'Reactance',
existsRe: /reactance/i,
key: reactanceKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'Heizzeitkonstante trefoil' : 'Heating time constant trefoil',
existsRe: /heating time constant.*trefoil/i,
key: heatingTrefoilKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'Heizzeitkonstante flach' : 'Heating time constant flat',
existsRe: /heating time constant.*flat/i,
key: heatingFlatKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'Flammhemmend' : 'Flame retardant',
existsRe: /flame retardant/i,
key: flameKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'CPR-Klasse' : 'CPR class',
existsRe: /cpr class/i,
key: cprKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'Verpackung' : 'Packaging',
existsRe: /packaging/i,
key: packagingKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'Biegeradius' : 'Bending radius',
existsRe: /bending radius/i,
key: null, // Will be found in row-specific attributes
});
addConstOrSmallList({
name: locale === 'de' ? 'Leiterform' : 'Shape of conductor',
existsRe: /shape of conductor/i,
key: shapeKey,
});
addConstOrSmallList({
name: locale === 'de' ? 'Isolierungsfarbe' : 'Colour of insulation',
existsRe: /colour of insulation/i,
key: null, // Will be found in row-specific attributes
});
addConstOrSmallList({
name: locale === 'de' ? 'Mantelfarbe' : 'Colour of sheath',
existsRe: /colour of sheath/i,
key: null, // Will be found in row-specific attributes
});
addConstOrSmallList({
name: locale === 'de' ? 'RoHS/REACH' : 'RoHS/REACH',
existsRe: /rohs.*reach/i,
key: null, // Will be found in row-specific attributes
});
product.attributes = attrs;
if (process.env.PDF_DEBUG_EXCEL === '1') {
console.log(`[excel] enriched product ${product.id} (${product.slug ?? stripHtml(product.name)}) with ${cfgOptions.length} configurations from excel`);
}
}
function ensureExcelRowSpecificAttributes(product: ProductData, locale: 'en' | 'de'): void {
const rows = findExcelRowsForProduct(product);
if (!rows.length) return;
const crossSectionAttr =
findAttr(product, /configuration|konfiguration|aufbau|bezeichnung/i) ||
findAttr(product, /number of cores and cross-section|querschnitt|cross.?section|mm²|mm2/i);
if (!crossSectionAttr || !crossSectionAttr.options?.length) return;
const rowCount = crossSectionAttr.options.length;
// Only enrich row-specific columns when row counts match (avoid wrong mapping).
if (rows.length !== rowCount) return;
const sample = rows[0] || {};
const keyOuter = guessColumnKey(sample, [/outer diameter \(approx\.?\)/i, /outer diameter of cable/i, /outer diameter\b/i, /diameter over screen/i]);
const keyWeight = guessColumnKey(sample, [/weight \(approx\.?\)/i, /cable weight/i, /\bweight\b/i]);
const keyDcRes = guessColumnKey(sample, [/dc resistance at 20/i, /maximum resistance of conductor/i, /resistance conductor/i]);
const keyCap = guessColumnKey(sample, [/capacitance/i]);
const keyIndTrefoil = guessColumnKey(sample, [/inductance,?\s*trefoil/i]);
const keyIndAirFlat = guessColumnKey(sample, [/inductance in air,?\s*flat/i]);
const keyIndGroundFlat = guessColumnKey(sample, [/inductance in ground,?\s*flat/i]);
const keyIairTrefoil = guessColumnKey(sample, [/current ratings in air,?\s*trefoil/i]);
const keyIairFlat = guessColumnKey(sample, [/current ratings in air,?\s*flat/i]);
const keyIgroundTrefoil = guessColumnKey(sample, [/current ratings in ground,?\s*trefoil/i]);
const keyIgroundFlat = guessColumnKey(sample, [/current ratings in ground,?\s*flat/i]);
const keyScCond = guessColumnKey(sample, [/conductor shortcircuit current/i]);
const keyScScreen = guessColumnKey(sample, [/screen shortcircuit current/i]);
const keyBend = guessColumnKey(sample, [/bending radius/i, /min\. bending radius/i]);
// Additional row-specific technical data
const keyConductorDiameter = guessColumnKey(sample, [/conductor diameter/i, /diameter conductor/i]);
const keyInsulationThickness = guessColumnKey(sample, [/nominal insulation thickness/i, /insulation thickness/i]);
const keySheathThickness = guessColumnKey(sample, [/nominal sheath thickness/i, /minimum sheath thickness/i, /sheath thickness/i]);
const keyCapacitance = guessColumnKey(sample, [/capacitance/i]);
const keyInductanceTrefoil = guessColumnKey(sample, [/inductance.*trefoil/i]);
const keyInductanceAirFlat = guessColumnKey(sample, [/inductance.*air.*flat/i]);
const keyInductanceGroundFlat = guessColumnKey(sample, [/inductance.*ground.*flat/i]);
const keyCurrentAirTrefoil = guessColumnKey(sample, [/current.*air.*trefoil/i]);
const keyCurrentAirFlat = guessColumnKey(sample, [/current.*air.*flat/i]);
const keyCurrentGroundTrefoil = guessColumnKey(sample, [/current.*ground.*trefoil/i]);
const keyCurrentGroundFlat = guessColumnKey(sample, [/current.*ground.*flat/i]);
const keyHeatingTimeTrefoil = guessColumnKey(sample, [/heating.*time.*trefoil/i]);
const keyHeatingTimeFlat = guessColumnKey(sample, [/heating.*time.*flat/i]);
const get = (k: string | null) => rows.map(r => normalizeValue(String(r?.[k ?? ''] ?? '')));
const withUnit = (vals: string[], unit: string) => vals.map(v => (v && looksNumeric(v) ? `${v} ${unit}` : v));
// Use labels that are already recognized by the existing PDF regexes.
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'Außen-Ø' : 'Outer diameter',
options: withUnit(get(keyOuter), 'mm'),
expectedLen: rowCount,
existsRe: /outer\s*diameter|außen\s*durchmesser|außen-?ø/i,
});
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'Gewicht' : 'Weight',
options: withUnit(get(keyWeight), 'kg/km'),
expectedLen: rowCount,
existsRe: /\bweight\b|gewicht/i,
});
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'DC-Leiterwiderstand (20C)' : 'DC resistance at 20 C',
options: withUnit(get(keyDcRes), 'Ohm/km'),
expectedLen: rowCount,
existsRe: /dc\s*resistance|max(?:imum)?\s*resistance|resistance\s+conductor|leiterwiderstand/i,
});
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'Kapazität (ca.)' : 'Capacitance (approx.)',
options: withUnit(get(keyCap), 'uF/km'),
expectedLen: rowCount,
existsRe: /capacitance|kapazit/i,
});
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'Induktivität, trefoil (ca.)' : 'Inductance, trefoil (approx.)',
options: withUnit(get(keyIndTrefoil), 'mH/km'),
expectedLen: rowCount,
existsRe: /inductance,?\s*trefoil/i,
});
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'Induktivität in Luft, flach (ca.)' : 'Inductance in air, flat (approx.)',
options: withUnit(get(keyIndAirFlat), 'mH/km'),
expectedLen: rowCount,
existsRe: /inductance\s+in\s+air,?\s*flat/i,
});
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'Induktivität im Erdreich, flach (ca.)' : 'Inductance in ground, flat (approx.)',
options: withUnit(get(keyIndGroundFlat), 'mH/km'),
expectedLen: rowCount,
existsRe: /inductance\s+in\s+ground,?\s*flat/i,
});
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'Strombelastbarkeit in Luft, trefoil' : 'Current ratings in air, trefoil',
options: withUnit(get(keyIairTrefoil), 'A'),
expectedLen: rowCount,
existsRe: /current\s+ratings\s+in\s+air,?\s*trefoil/i,
});
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'Strombelastbarkeit in Luft, flach' : 'Current ratings in air, flat',
options: withUnit(get(keyIairFlat), 'A'),
expectedLen: rowCount,
existsRe: /current\s+ratings\s+in\s+air,?\s*flat/i,
});
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'Strombelastbarkeit im Erdreich, trefoil' : 'Current ratings in ground, trefoil',
options: withUnit(get(keyIgroundTrefoil), 'A'),
expectedLen: rowCount,
existsRe: /current\s+ratings\s+in\s+ground,?\s*trefoil/i,
});
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'Strombelastbarkeit im Erdreich, flach' : 'Current ratings in ground, flat',
options: withUnit(get(keyIgroundFlat), 'A'),
expectedLen: rowCount,
existsRe: /current\s+ratings\s+in\s+ground,?\s*flat/i,
});
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'Kurzschlussstrom Leiter' : 'Conductor shortcircuit current',
options: withUnit(get(keyScCond), 'kA'),
expectedLen: rowCount,
existsRe: /conductor\s+shortcircuit\s+current/i,
});
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'Kurzschlussstrom Schirm' : 'Screen shortcircuit current',
options: withUnit(get(keyScScreen), 'kA'),
expectedLen: rowCount,
existsRe: /screen\s+shortcircuit\s+current/i,
});
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'Biegeradius (min.)' : 'Bending radius (min.)',
options: withUnit(get(keyBend), 'mm'),
expectedLen: rowCount,
existsRe: /bending\s*radius|biegeradius/i,
});
// Additional row-specific technical data
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'Leiterdurchmesser' : 'Conductor diameter',
options: withUnit(get(keyConductorDiameter), 'mm'),
expectedLen: rowCount,
existsRe: /conductor diameter|diameter conductor/i,
});
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'Isolationsdicke' : 'Insulation thickness',
options: withUnit(get(keyInsulationThickness), 'mm'),
expectedLen: rowCount,
existsRe: /insulation thickness|nominal insulation thickness/i,
});
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'Manteldicke' : 'Sheath thickness',
options: withUnit(get(keySheathThickness), 'mm'),
expectedLen: rowCount,
existsRe: /sheath thickness|nominal sheath thickness/i,
});
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'Kapazität' : 'Capacitance',
options: withUnit(get(keyCapacitance), 'uF/km'),
expectedLen: rowCount,
existsRe: /capacitance/i,
});
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'Induktivität trefoil' : 'Inductance trefoil',
options: withUnit(get(keyInductanceTrefoil), 'mH/km'),
expectedLen: rowCount,
existsRe: /inductance.*trefoil/i,
});
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'Induktivität Luft flach' : 'Inductance air flat',
options: withUnit(get(keyInductanceAirFlat), 'mH/km'),
expectedLen: rowCount,
existsRe: /inductance.*air.*flat/i,
});
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'Induktivität Erdreich flach' : 'Inductance ground flat',
options: withUnit(get(keyInductanceGroundFlat), 'mH/km'),
expectedLen: rowCount,
existsRe: /inductance.*ground.*flat/i,
});
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'Strombelastbarkeit Luft trefoil' : 'Current rating air trefoil',
options: withUnit(get(keyCurrentAirTrefoil), 'A'),
expectedLen: rowCount,
existsRe: /current.*air.*trefoil/i,
});
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'Strombelastbarkeit Luft flach' : 'Current rating air flat',
options: withUnit(get(keyCurrentAirFlat), 'A'),
expectedLen: rowCount,
existsRe: /current.*air.*flat/i,
});
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'Strombelastbarkeit Erdreich trefoil' : 'Current rating ground trefoil',
options: withUnit(get(keyCurrentGroundTrefoil), 'A'),
expectedLen: rowCount,
existsRe: /current.*ground.*trefoil/i,
});
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'Strombelastbarkeit Erdreich flach' : 'Current rating ground flat',
options: withUnit(get(keyCurrentGroundFlat), 'A'),
expectedLen: rowCount,
existsRe: /current.*ground.*flat/i,
});
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'Heizzeitkonstante trefoil' : 'Heating time constant trefoil',
options: withUnit(get(keyHeatingTimeTrefoil), 's'),
expectedLen: rowCount,
existsRe: /heating.*time.*trefoil/i,
});
pushRowAttrIfMissing({
product,
name: locale === 'de' ? 'Heizzeitkonstante flach' : 'Heating time constant flat',
options: withUnit(get(keyHeatingTimeFlat), 's'),
expectedLen: rowCount,
existsRe: /heating.*time.*flat/i,
});
}
function getProductUrl(product: ProductData): string | null {
if (!product.path) return null;
return `https://klz-cables.com${product.path}`;
}
function drawKeyValueGrid(args: {
title: string;
items: Array<{ label: string; value: string }>;
newPage: () => number;
getPage: () => PDFPage;
page: PDFPage;
y: number;
margin: number;
contentWidth: number;
contentMinY: number;
font: PDFFont;
fontBold: PDFFont;
navy: ReturnType<typeof rgb>;
darkGray: ReturnType<typeof rgb>;
mediumGray: ReturnType<typeof rgb>;
lightGray?: ReturnType<typeof rgb>;
almostWhite?: ReturnType<typeof rgb>;
allowNewPage?: boolean;
boxed?: boolean;
}): number {
let { title, items, newPage, getPage, page, y, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, mediumGray } = args;
const allowNewPage = args.allowNewPage ?? true;
const boxed = args.boxed ?? false;
const lightGray = args.lightGray ?? rgb(0.9020, 0.9137, 0.9294);
const almostWhite = args.almostWhite ?? rgb(0.9725, 0.9765, 0.9804);
// Inner layout (boxed vs. plain)
// Keep a strict spacing system for more professional datasheets.
const padX = boxed ? 16 : 0;
const padY = boxed ? 14 : 0;
const xBase = margin + padX;
const innerWidth = contentWidth - padX * 2;
const colGap = 16;
const colW = (innerWidth - colGap) / 2;
const rowH = 24;
const headerH = boxed ? 22 : 0;
const drawBoxFrame = (boxTopY: number, rowsCount: number) => {
const boxH = padY + headerH + rowsCount * rowH + padY;
page = getPage();
page.drawRectangle({
x: margin,
y: boxTopY - boxH,
width: contentWidth,
height: boxH,
borderColor: lightGray,
borderWidth: 1,
color: rgb(1, 1, 1),
});
// Header band for the title
page.drawRectangle({
x: margin,
y: boxTopY - headerH,
width: contentWidth,
height: headerH,
color: almostWhite,
});
return boxH;
};
const drawTitle = () => {
page = getPage();
if (boxed) {
// Align title inside the header band.
page.drawText(title, { x: xBase, y: y - 15, size: 11, font: fontBold, color: navy });
// Divider line below header band
page.drawLine({
start: { x: margin, y: y - headerH },
end: { x: margin + contentWidth, y: y - headerH },
thickness: 0.75,
color: lightGray,
});
y -= headerH + padY;
} else {
page.drawText(title, { x: margin, y, size: 10, font: fontBold, color: navy });
y -= 16;
}
};
if (y - 22 < contentMinY) {
if (!allowNewPage) return contentMinY - 1;
y = newPage();
}
page = getPage();
// Boxed + multi-page: render separate boxes per page segment.
if (boxed && allowNewPage) {
let i = 0;
while (i < items.length) {
// Compute how many rows fit in the remaining space.
const available = y - contentMinY;
const maxRows = Math.max(1, Math.floor((available - (headerH + padY * 2)) / rowH));
const maxItems = Math.max(2, maxRows * 2);
const slice = items.slice(i, i + maxItems);
const rowsCount = Math.ceil(slice.length / 2);
const neededH = padY + headerH + rowsCount * rowH + padY;
if (y - neededH < contentMinY) y = newPage();
drawBoxFrame(y, rowsCount);
drawTitle();
let rowY = y;
for (let j = 0; j < slice.length; j++) {
const col = j % 2;
const x = xBase + col * (colW + colGap);
const { label, value } = slice[j];
page.drawText(label, { x, y: rowY, size: 7.5, font: fontBold, color: mediumGray, maxWidth: colW });
page.drawText(value, { x, y: rowY - 12, size: 9.5, font, color: darkGray, maxWidth: colW });
if (col === 1) rowY -= rowH;
}
y = rowY - rowH - padY;
i += slice.length;
if (i < items.length) y = newPage();
}
return y;
}
// Boxed single-segment (current page) or plain continuation.
if (boxed && items.length) {
const rows = Math.ceil(items.length / 2);
const neededH = padY + headerH + rows * rowH + padY;
if (y - neededH < contentMinY) {
if (!allowNewPage) return contentMinY - 1;
y = newPage();
}
drawBoxFrame(y, rows);
}
drawTitle();
let rowY = y;
for (let i = 0; i < items.length; i++) {
const col = i % 2;
const x = xBase + col * (colW + colGap);
const { label, value } = items[i];
if (col === 0 && rowY - rowH < contentMinY) {
if (!allowNewPage) return contentMinY - 1;
y = newPage();
page = getPage();
if (!boxed) drawTitle();
rowY = y;
}
// Don't truncate - allow text to fit naturally
page.drawText(label, { x, y: rowY, size: 7.5, font: fontBold, color: mediumGray });
page.drawText(value, { x, y: rowY - 12, size: 9.5, font, color: darkGray });
if (col === 1) rowY -= rowH;
}
return boxed ? rowY - rowH - padY : rowY - rowH;
}
function ensureOutputDir(): void {
if (!fs.existsSync(CONFIG.outputDir)) {
fs.mkdirSync(CONFIG.outputDir, { recursive: true });
}
}
const stripHtml = (html: string): string => {
if (!html) return '';
// IMPORTANT: Keep umlauts and common Latin-1 chars (e.g. ü/ö/ä/ß) for DE PDFs.
// pdf-lib's StandardFonts cover WinAnsi; we only normalize “problematic” typography.
let text = html.replace(/<[^>]*>/g, '').normalize('NFC');
text = text
// whitespace normalization
.replace(/[\u00A0\u202F]/g, ' ') // nbsp / narrow nbsp
// typography normalization
.replace(/[\u2013\u2014]/g, '-') // en/em dash
.replace(/[\u2018\u2019]/g, "'") // curly single quotes
.replace(/[\u201C\u201D]/g, '"') // curly double quotes
.replace(/\u2026/g, '...') // ellipsis
// symbols that can be missing in some encodings
.replace(/[\u2022]/g, '·') // bullet
// math symbols (WinAnsi can't encode these)
.replace(/[\u2264]/g, '<=') // ≤
.replace(/[\u2265]/g, '>=') // ≥
.replace(/[\u2248]/g, '~') // ≈
// electrical symbols (keep meaning; avoid encoding errors)
.replace(/[\u03A9\u2126]/g, 'Ohm') // Ω / Ω
// micro sign / greek mu (WinAnsi can't encode these reliably)
.replace(/[\u00B5\u03BC]/g, 'u') // µ / μ
// directional arrows (WinAnsi can't encode these)
.replace(/[\u2193]/g, 'v') // ↓
.replace(/[\u2191]/g, '^') // ↑
// degree symbol
.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 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 || '';
// Cable industry standard abbreviations with WinAnsi-compatible symbols
// 13 required headers (exact order as specified)
if (key === 'DI' || /diameter\s+over\s+insulation/i.test(b) || /isolierungsdurchmesser/i.test(b)) return 'DI [mm]';
if (key === 'RI' || /dc\s*resistance/i.test(b) || /leiterwiderstand/i.test(b) || /widerstand/i.test(b)) return 'RI [Ohm/km]'; // Ω → Ohm
if (key === 'Wi' || /insulation\s+thickness/i.test(b) || /isolationsdicke/i.test(b)) return 'Wi [mm]';
if (key === 'Ibl' || /current\s+ratings\s+in\s+air.*trefoil/i.test(b) || /strombelastbarkeit.*luft.*trefoil/i.test(b)) return 'Ibl [A]';
if (key === 'Ibe' || /current\s+ratings\s+in\s+ground.*trefoil/i.test(b) || /strombelastbarkeit.*erd.*trefoil/i.test(b)) return 'Ibe [A]';
if (key === 'Ik_cond' || key === 'Ik' || /conductor.*shortcircuit/i.test(b)) return 'Ik [kA]';
if (key === 'Ik_screen' || /screen.*shortcircuit/i.test(b)) return 'Ik_s [kA]';
if (key === 'Wm' || /sheath\s+thickness/i.test(b) || /manteldicke/i.test(b)) return 'Wm [mm]';
if (key === 'Rbv' || /bending\s+radius/i.test(b) || /biegeradius/i.test(b)) return 'Rbv [mm]';
if (key === 'Ø' || /outer\s+diameter/i.test(b) || /außen/i.test(b) || /durchmesser/i.test(b)) return 'OD [mm]'; // Ø → OD
if (key === 'Fzv' || /pulling\s+force/i.test(b) || /zugkraft/i.test(b)) return 'Fzv [N]';
if (key === 'Al' || /conductor.*aluminum/i.test(b) || /leiter.*al/i.test(b)) return 'AL / CU';
if (key === 'Cu' || /conductor.*copper/i.test(b) || /leiter.*cu/i.test(b)) return 'Cu';
if (key === 'G' || /\bweight\b/i.test(b) || /gewicht/i.test(b)) return '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 'OD_cond [mm]'; // Ø → OD
if (key === 'cap') return 'C [uF/km]'; // μ → u
if (key === 'ind_trefoil') return 'L_t [mH/km]';
if (key === 'ind_air_flat') return 'L_af [mH/km]';
if (key === 'ind_ground_flat') return 'L_gf [mH/km]';
if (key === 'cur_air_flat') return 'I_af [A]';
if (key === 'cur_ground_flat') return 'I_gf [A]';
if (key === 'heat_trefoil') return 'H_t [s]'; // τ → H
if (key === 'heat_flat') return 'H_f [s]'; // τ → H
if (key === 'max_op_temp') return 'T_op_max [C]'; // ° → C
if (key === 'max_sc_temp') return 'T_sc_max [C]'; // ° → C
if (key === 'temp_range') return 'T_range [C]'; // ° → C
if (key === 'min_store_temp') return 'T_st_min [C]'; // ° → C
if (key === 'min_lay_temp') return 'T_lay_min [C]'; // ° → C
if (key === 'test_volt') return 'V_test [kV]';
if (key === 'rated_volt') return 'V_rat [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;
const minColWidth = isDenseManyColumns ? 28 : 20; // Minimum width in points
const maxColWidth = isDenseManyColumns ? 150 : 120; // Maximum width in points
const colGap = isDenseManyColumns ? 1 : 2; // Gap between columns (smaller for dense)
// Calculate required width for each column
const requiredWidths = tableCols.map((col, i) => {
const headerLabel = headerLabels[i] || headerLabelFor({ label: col.label, key: col.key });
// Measure header width
const headerWidth = fontBold.widthOfTextAtSize(headerLabel, headerFontSize);
// Measure sample data widths (check first few rows)
let maxDataWidth = 0;
const sampleRows = Math.min(3, configRows.length);
for (let r = 0; r < sampleRows; r++) {
const cellValue = col.get(r);
const dataWidth = font.widthOfTextAtSize(cellValue, bodyFontSize);
maxDataWidth = Math.max(maxDataWidth, dataWidth);
}
// Take the maximum of header and data
// Add generous padding for dense tables
const padding = isDenseManyColumns ? cellPadX * 1.5 : cellPadX * 2;
const contentWidthNeeded = Math.max(headerWidth, maxDataWidth) + padding;
// Clamp to min/max
return Math.max(minColWidth, Math.min(maxColWidth, contentWidthNeeded));
});
// Calculate total required width (including gaps)
const totalRequiredWidth = requiredWidths.reduce((sum, w) => sum + w, 0) + (tableCols.length - 1) * colGap;
// Scale to fit available content width if needed
// For dense tables, be more lenient with scaling
let scaleFactor = totalRequiredWidth > contentWidth ? contentWidth / totalRequiredWidth : 1;
const minScaleFactor = isDenseManyColumns ? 0.85 : 0.9; // Don't scale too aggressively
scaleFactor = Math.max(scaleFactor, minScaleFactor); // Apply minimum
// Scale widths (gaps are also scaled)
const widthsPt = requiredWidths.map(w => w * scaleFactor);
const scaledGap = colGap * scaleFactor;
// When rendering many columns in a single table, auto-compact typography.
// (All columns must be visible; landscape helps, but we also reduce type.)
if (dense && tableCols.length >= 12) {
// Less aggressive font reduction to keep text readable
bodyFontSize = Math.max(5.5, Math.min(bodyFontSize, 6.0));
headerFontSize = Math.max(5.5, Math.min(headerFontSize, 5.8));
rowH = Math.max(9, Math.min(rowH, 10));
cellPadX = 3;
}
const ensureSpace = (needed: number) => {
if (y - needed < contentMinY) y = newPage();
page = getPage();
};
// One-page mode: adapt row height so the full voltage table can fit on the page.
if (onePage) {
const rows = configRows.length;
const available = y - contentMinY - 10 - 12 /*title*/;
const maxRowH = Math.floor((available - headerH) / Math.max(1, rows));
rowH = Math.max(8, Math.min(rowH, maxRowH));
bodyFontSize = Math.max(6, Math.min(bodyFontSize, rowH - 3));
headerFontSize = Math.max(6, Math.min(headerFontSize, rowH - 3));
}
ensureSpace(14 + headerH + rowH * 2);
if (chunkTitle) {
page.drawText(chunkTitle, {
x: margin,
y,
size: 10,
font: fontBold,
color: navy,
});
y -= dense ? 14 : 16;
}
// If we are too close to the footer after the title, break before drawing the header.
if (y - headerH - rowH < contentMinY) {
y = newPage();
page = getPage();
if (chunkTitle) {
page.drawText(chunkTitle, {
x: margin,
y,
size: 10,
font: fontBold,
color: navy,
});
y -= 16;
}
}
const drawHeader = () => {
page = getPage();
page.drawRectangle({
x: margin,
y: y - headerH,
width: contentWidth,
height: headerH,
color: headerFill,
});
const headerTextY = y - headerH + Math.floor((headerH - headerFontSize) / 2) + 1;
let x = margin;
for (let i = 0; i < tableCols.length; i++) {
const hl = headerLabels[i] || headerLabelFor({ label: tableCols[i].label, key: tableCols[i].key });
const colWidth = widthsPt[i];
const colMaxW = colWidth - cellPadX * 2;
// Overlap guard: always truncate to column width (pdf-lib doesn't clip by itself).
// Don't truncate headers - use auto-fit widths
page.drawText(noWrap(String(hl)), {
x: x + cellPadX,
y: headerTextY,
size: headerFontSize,
font: fontBold,
color: headerText,
// DO NOT set maxWidth in dense mode (it triggers wrapping instead of clipping).
...(dense ? {} : { maxWidth: colMaxW }),
});
x += colWidth + scaledGap;
}
y -= headerH;
};
drawHeader();
for (let r = 0; r < configRows.length; r++) {
if (!onePage && y - rowH < contentMinY) {
y = newPage();
page = getPage();
if (chunkTitle) {
page.drawText(chunkTitle, {
x: margin,
y,
size: 12,
font: fontBold,
color: navy,
});
y -= 16;
}
drawHeader();
}
if (onePage && y - rowH < contentMinY) {
// In one-page mode we must not paginate. Clip remaining rows.
break;
}
if (r % 2 === 0) {
page.drawRectangle({
x: margin,
y: y - rowH,
width: contentWidth,
height: rowH,
color: almostWhite,
});
}
let x = margin;
for (let c = 0; c < tableCols.length; c++) {
const raw = tableCols[c].get(r);
const txt = formatCell(raw, tableCols[c].key || '');
const colWidth = widthsPt[c];
const colMaxW = colWidth - cellPadX * 2;
// Don't truncate - use auto-fit widths to ensure everything fits
page.drawText(noWrap(txt), {
x: x + cellPadX,
y: y - (rowH - 5), // Adjusted for new rowH of 16
size: bodyFontSize,
font,
color: darkGray,
...(dense ? {} : { maxWidth: colMaxW }),
});
x += colWidth + scaledGap;
}
y -= rowH;
}
y -= dense ? 20 : 24;
}
return y;
}
async function loadEmbeddablePng(
src: string | null | undefined,
): Promise<{ pngBytes: Uint8Array; debugLabel: string } | null> {
const resolved = resolveMediaToLocalPath(src);
if (!resolved) return null;
try {
// Prefer local files for stability and speed.
if (resolved.startsWith('/')) {
const bytes = await readBytesFromPublic(resolved);
return { pngBytes: await toPngBytes(bytes, resolved), debugLabel: resolved };
}
// Remote (fallback)
const bytes = await fetchBytes(resolved);
return { pngBytes: await toPngBytes(bytes, resolved), debugLabel: resolved };
} catch {
return null;
}
}
async function loadQrPng(data: string): Promise<{ pngBytes: Uint8Array; debugLabel: string } | null> {
// External QR generator (no extra dependency). This must stay resilient; if it fails, we fall back to URL text.
try {
const safe = encodeURIComponent(data);
const url = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${safe}`;
const bytes = await fetchBytes(url);
// Already PNG but normalize anyway.
return { pngBytes: await toPngBytes(bytes, url), debugLabel: url };
} catch {
return null;
}
}
type SectionDrawContext = {
pdfDoc: PDFDocument;
page: PDFPage;
width: number;
height: number;
margin: number;
contentWidth: number;
footerY: number;
contentMinY: number;
headerDividerY: number;
colors: {
navy: ReturnType<typeof rgb>;
mediumGray: ReturnType<typeof rgb>;
darkGray: ReturnType<typeof rgb>;
almostWhite: ReturnType<typeof rgb>;
lightGray: ReturnType<typeof rgb>;
headerBg: ReturnType<typeof rgb>;
};
fonts: {
regular: PDFFont;
bold: PDFFont;
};
labels: ReturnType<typeof getLabels>;
product: ProductData;
locale: 'en' | 'de';
logoImage: PDFImage | null;
qrImage: PDFImage | null;
qrUrl: string;
};
function drawFooter(ctx: SectionDrawContext): void {
const { page, width, margin, footerY, fonts, colors, locale } = ctx;
page.drawLine({
start: { x: margin, y: footerY + 14 },
end: { x: width - margin, y: footerY + 14 },
thickness: 0.75,
color: colors.lightGray,
});
// Left: site URL (always)
page.drawText(CONFIG.siteUrl, {
x: margin,
y: footerY,
size: 8,
font: fonts.regular,
color: colors.mediumGray,
});
const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
// Right: date + page number (page number filled in after rendering)
const rightText = dateStr;
page.drawText(rightText, {
x: width - margin - fonts.regular.widthOfTextAtSize(rightText, 8),
y: footerY,
size: 8,
font: fonts.regular,
color: colors.mediumGray,
});
}
function stampPageNumbers(pdfDoc: PDFDocument, fonts: { regular: PDFFont }, colors: { mediumGray: ReturnType<typeof rgb> }, margin: number, footerY: number): void {
const pages = pdfDoc.getPages();
const total = pages.length;
for (let i = 0; i < total; i++) {
const page = pages[i];
const { width } = page.getSize();
const text = `${i + 1}/${total}`;
page.drawText(text, {
x: width - margin - fonts.regular.widthOfTextAtSize(text, 8),
y: footerY - 12,
size: 8,
font: fonts.regular,
color: colors.mediumGray,
});
}
}
function drawHeader(ctx: SectionDrawContext, yStart: number): number {
const { page, width, margin, contentWidth, fonts, colors, logoImage, qrImage, qrUrl, labels, product } = ctx;
// Cable-industry look: calm, engineered header with right-aligned meta.
// Keep header compact to free vertical space for technical tables.
const headerH = 52;
const dividerY = yStart - headerH;
ctx.headerDividerY = dividerY;
page.drawRectangle({
x: 0,
y: dividerY,
width,
height: headerH,
color: colors.headerBg,
});
const qrSize = 36;
const qrGap = 12;
const rightReserved = qrImage ? qrSize + qrGap : 0;
// Left: logo (preferred) or typographic fallback
if (logoImage) {
const maxLogoW = 120;
const maxLogoH = 24;
const scale = Math.min(maxLogoW / logoImage.width, maxLogoH / logoImage.height);
const w = logoImage.width * scale;
const h = logoImage.height * scale;
const logoY = dividerY + Math.round((headerH - h) / 2);
page.drawImage(logoImage, {
x: margin,
y: logoY,
width: w,
height: h,
});
} else {
const baseY = dividerY + 22;
page.drawText('KLZ', {
x: margin,
y: baseY,
size: 22,
font: fonts.bold,
color: colors.navy,
});
page.drawText('Cables', {
x: margin + fonts.bold.widthOfTextAtSize('KLZ', 22) + 4,
y: baseY + 2,
size: 10,
font: fonts.regular,
color: colors.mediumGray,
});
}
// Right: datasheet meta + QR (if available)
const metaRightEdge = width - margin - rightReserved;
const metaTitle = labels.datasheet;
const metaTitleSize = 9;
const mtW = fonts.bold.widthOfTextAtSize(metaTitle, metaTitleSize);
// With SKU removed, vertically center the title within the header block.
const metaY = dividerY + Math.round(headerH / 2 - metaTitleSize / 2);
page.drawText(metaTitle, {
x: metaRightEdge - mtW,
y: metaY,
size: metaTitleSize,
font: fonts.bold,
color: colors.navy,
});
if (qrImage) {
const qrX = width - margin - qrSize;
const qrY = dividerY + Math.round((headerH - qrSize) / 2);
page.drawImage(qrImage, { x: qrX, y: qrY, width: qrSize, height: qrSize });
} else {
// If QR generation failed, keep the URL available as a compact line.
const maxW = 260;
const urlLines = wrapText(qrUrl, fonts.regular, 8, maxW).slice(0, 1);
if (urlLines.length) {
const line = urlLines[0];
const w = fonts.regular.widthOfTextAtSize(line, 8);
page.drawText(line, {
x: width - margin - w,
y: dividerY + 12,
size: 8,
font: fonts.regular,
color: colors.mediumGray,
});
}
}
// Divider line
page.drawLine({
start: { x: margin, y: dividerY },
end: { x: margin + contentWidth, y: dividerY },
thickness: 0.75,
color: colors.lightGray,
});
// Content start: keep breathing room below the header, but use page height efficiently.
return dividerY - 22;
}
function drawCrossSectionChipsRow(args: {
title: string;
configRows: string[];
locale: 'en' | 'de';
maxLinesCap?: number;
getPage: () => PDFPage;
page: PDFPage;
y: number;
margin: number;
contentWidth: number;
contentMinY: number;
font: PDFFont;
fontBold: PDFFont;
navy: ReturnType<typeof rgb>;
darkGray: ReturnType<typeof rgb>;
mediumGray: ReturnType<typeof rgb>;
lightGray: ReturnType<typeof rgb>;
almostWhite: ReturnType<typeof rgb>;
}): number {
let { title, configRows, locale, getPage, page, y, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, mediumGray, lightGray, almostWhite } = args;
// Single-page rule: if we can't fit the block, stop.
const titleH = 12;
const summaryH = 12;
const chipH = 16;
const lineGap = 8;
const gapY = 10;
const minLines = 2;
const needed = titleH + summaryH + (chipH * minLines) + (lineGap * (minLines - 1)) + gapY;
if (y - needed < contentMinY) return contentMinY - 1;
page = getPage();
// Normalize: keep only cross-section part, de-dupe, sort.
const itemsRaw = configRows
.map(r => splitConfig(r).crossSection)
.map(s => normalizeValue(s))
.filter(Boolean);
const seen = new Set<string>();
const items = itemsRaw.filter(v => (seen.has(v) ? false : (seen.add(v), true)));
items.sort((a, b) => {
const pa = parseCoresAndMm2(a);
const pb = parseCoresAndMm2(b);
if (pa.cores !== null && pb.cores !== null && pa.cores !== pb.cores) return pa.cores - pb.cores;
if (pa.mm2 !== null && pb.mm2 !== null && pa.mm2 !== pb.mm2) return pa.mm2 - pb.mm2;
return a.localeCompare(b);
});
const total = items.length;
const parsed = items.map(parseCoresAndMm2).filter(p => p.cores !== null && p.mm2 !== null) as Array<{ cores: number; mm2: number }>;
const uniqueCores = Array.from(new Set(parsed.map(p => p.cores))).sort((a, b) => a - b);
const mm2Vals = parsed.map(p => p.mm2).sort((a, b) => a - b);
const mm2Min = mm2Vals.length ? mm2Vals[0] : null;
const mm2Max = mm2Vals.length ? mm2Vals[mm2Vals.length - 1] : null;
page.drawText(title, { x: margin, y, size: 11, font: fontBold, color: navy });
y -= titleH;
const summaryParts: string[] = [];
summaryParts.push(locale === 'de' ? `Varianten: ${total}` : `Options: ${total}`);
if (uniqueCores.length) summaryParts.push((locale === 'de' ? 'Adern' : 'Cores') + `: ${uniqueCores.join(', ')}`);
if (mm2Min !== null && mm2Max !== null) summaryParts.push(`mm²: ${mm2Min}${mm2Max !== mm2Min ? `${mm2Max}` : ''}`);
page.drawText(summaryParts.join(' · '), { x: margin, y, size: 8, font, color: mediumGray, maxWidth: contentWidth });
y -= summaryH;
// Tags (wrapping). Rectangular, engineered (no playful rounding).
const padX = 8;
const chipFontSize = 8;
const chipGap = 8;
const chipPadTop = 5;
const startY = y - chipH; // baseline for first chip row
const maxLinesAvailable = Math.max(1, Math.floor((startY - contentMinY + lineGap) / (chipH + lineGap)));
// UX/Content priority: don't let cross-section tags consume the whole sheet.
// When technical data is dense, we cap this to keep specs visible.
const maxLines = Math.min(args.maxLinesCap ?? 2, maxLinesAvailable);
const chipWidth = (text: string) => font.widthOfTextAtSize(text, chipFontSize) + padX * 2;
type Placement = { text: string; x: number; y: number; w: number; variant: 'normal' | 'more' };
const layout = (texts: string[], includeMoreChip: boolean, moreText: string): { placements: Placement[]; shown: number } => {
const placements: Placement[] = [];
let x = margin;
let line = 0;
let cy = startY;
const advanceLine = () => {
line += 1;
if (line >= maxLines) return false;
x = margin;
cy -= chipH + lineGap;
return true;
};
const tryPlace = (text: string, variant: 'normal' | 'more'): boolean => {
const w = chipWidth(text);
if (w > contentWidth) return false;
if (x + w > margin + contentWidth) {
if (!advanceLine()) return false;
}
placements.push({ text, x, y: cy, w, variant });
x += w + chipGap;
return true;
};
let shown = 0;
for (let i = 0; i < texts.length; i++) {
if (!tryPlace(texts[i], 'normal')) break;
shown++;
}
if (includeMoreChip) {
tryPlace(moreText, 'more');
}
return { placements, shown };
};
// Group by cores: label on the left, mm² tags to the right.
const byCores = new Map<number, number[]>();
const other: string[] = [];
for (const cs of items) {
const p = parseCoresAndMm2(cs);
if (p.cores !== null && p.mm2 !== null) {
const arr = byCores.get(p.cores) ?? [];
arr.push(p.mm2);
byCores.set(p.cores, arr);
} else {
other.push(cs);
}
}
const coreKeys = Array.from(byCores.keys()).sort((a, b) => a - b);
for (const k of coreKeys) {
const uniq = Array.from(new Set(byCores.get(k) ?? [])).sort((a, b) => a - b);
byCores.set(k, uniq);
}
const fmtMm2 = (v: number) => {
const s = Number.isInteger(v) ? String(v) : String(v).replace(/\.0+$/, '');
return s;
};
// Layout engine with group labels.
const labelW = 38;
const placements: Placement[] = [];
let line = 0;
let cy = startY;
let x = margin + labelW;
const canAdvanceLine = () => line + 1 < maxLines;
const advanceLine = () => {
if (!canAdvanceLine()) return false;
line += 1;
cy -= chipH + lineGap;
x = margin + labelW;
return true;
};
const drawGroupLabel = (label: string) => {
// Draw label on each new line for the group (keeps readability when wrapping).
page.drawText(label, {
x: margin,
y: cy + 4,
size: 8,
font: fontBold,
color: mediumGray,
maxWidth: labelW - 4,
});
};
const placeChip = (text: string, variant: 'normal' | 'more') => {
const w = chipWidth(text);
if (w > contentWidth - labelW) return false;
if (x + w > margin + contentWidth) {
if (!advanceLine()) return false;
}
placements.push({ text, x, y: cy, w, variant });
x += w + chipGap;
return true;
};
let truncated = false;
let renderedCount = 0;
const totalChips = coreKeys.reduce((sum, k) => sum + (byCores.get(k)?.length ?? 0), 0) + other.length;
for (const cores of coreKeys) {
const values = byCores.get(cores) ?? [];
const label = `${cores}×`;
// Ensure label is shown at least once per line block.
drawGroupLabel(label);
for (const v of values) {
const ok = placeChip(fmtMm2(v), 'normal');
if (!ok) {
truncated = true;
break;
}
renderedCount++;
}
if (truncated) break;
// Add a tiny gap between core groups (only if we have room on the current line)
x += 4;
if (x > margin + contentWidth - 20) {
if (!advanceLine()) {
// out of vertical space; stop
truncated = true;
break;
}
}
}
if (!truncated && other.length) {
const label = locale === 'de' ? 'Sonst.' : 'Other';
drawGroupLabel(label);
for (const t of other) {
const ok = placeChip(t, 'normal');
if (!ok) {
truncated = true;
break;
}
renderedCount++;
}
}
if (truncated) {
const remaining = Math.max(0, totalChips - renderedCount);
const moreText = locale === 'de' ? `+${remaining} weitere` : `+${remaining} more`;
// Try to place on current line; if not possible, try next line.
if (!placeChip(moreText, 'more')) {
if (advanceLine()) {
placeChip(moreText, 'more');
}
}
}
// Draw placements
for (const p of placements) {
page.drawRectangle({
x: p.x,
y: p.y,
width: p.w,
height: chipH,
borderColor: lightGray,
borderWidth: 1,
color: rgb(1, 1, 1),
});
page.drawText(p.text, {
x: p.x + padX,
y: p.y + chipPadTop,
size: chipFontSize,
font,
color: p.variant === 'more' ? navy : darkGray,
maxWidth: p.w - padX * 2,
});
}
// Return cursor below the last line drawn
const linesUsed = placements.length ? Math.max(...placements.map(p => Math.round((startY - p.y) / (chipH + lineGap)))) + 1 : 1;
const bottomY = startY - (linesUsed - 1) * (chipH + lineGap);
// Consistent section spacing after block.
// IMPORTANT: never return below contentMinY if we actually rendered,
// otherwise callers may think it "didn't fit" and draw a fallback on top (duplicate “Options” lines).
return Math.max(bottomY - 24, contentMinY);
}
function drawCompactList(args: {
items: string[];
x: number;
y: number;
colW: number;
cols: number;
rowH: number;
maxRows: number;
page: PDFPage;
font: PDFFont;
fontSize: number;
color: ReturnType<typeof rgb>;
}): number {
const { items, x, colW, cols, rowH, maxRows, page, font, fontSize, color } = args;
let y = args.y;
const shown = items.slice(0, cols * maxRows);
for (let i = 0; i < shown.length; i++) {
const col = Math.floor(i / maxRows);
const row = i % maxRows;
const ix = x + col * colW;
const iy = y - row * rowH;
page.drawText(shown[i], {
x: ix,
y: iy,
size: fontSize,
font,
color,
maxWidth: colW - 6,
});
}
return y - maxRows * rowH;
}
function findAttr(product: ProductData, includes: RegExp): ProductData['attributes'][number] | undefined {
return product.attributes?.find(a => includes.test(a.name));
}
function normalizeValue(value: string): string {
return stripHtml(value).replace(/\s+/g, ' ').trim();
}
function summarizeOptions(options: string[] | undefined, maxItems: number = 3): string {
const vals = (options || []).map(normalizeValue).filter(Boolean);
if (vals.length === 0) return '';
const uniq = Array.from(new Set(vals));
if (uniq.length === 1) return uniq[0];
if (uniq.length <= maxItems) return uniq.join(' / ');
// UX: avoid showing internal counts like "+8" in customer-facing PDFs.
// Indicate truncation with an ellipsis.
return `${uniq.slice(0, maxItems).join(' / ')} / ...`;
}
function parseNumericOption(value: string): number | null {
const v = normalizeValue(value).replace(/,/g, '.');
// First numeric token (works for "12.3", "12.3 mm", "-35", "26.5 kg/km").
const m = v.match(/-?\d+(?:\.\d+)?/);
if (!m) return null;
const n = Number(m[0]);
return Number.isFinite(n) ? n : null;
}
function formatNumber(n: number): string {
const s = Number.isInteger(n) ? String(n) : String(n);
return s.replace(/\.0+$/, '');
}
function summarizeNumericRange(options: string[] | undefined): { ok: boolean; text: string } {
const vals = (options || []).map(parseNumericOption).filter((n): n is number => n !== null);
if (vals.length < 3) return { ok: false, text: '' };
const uniq = Array.from(new Set(vals));
// If there are only a few distinct values, listing is clearer than a range.
if (uniq.length < 4) return { ok: false, text: '' };
uniq.sort((a, b) => a - b);
const min = uniq[0];
const max = uniq[uniq.length - 1];
// UX: don't show internal counts like "n=…" in customer-facing datasheets.
return { ok: true, text: `${formatNumber(min)}${formatNumber(max)}` };
}
function summarizeSmartOptions(label: string, options: string[] | undefined): string {
// Prefer numeric ranges when an attribute has many numeric-ish entries (typical for row-specific data).
const range = summarizeNumericRange(options);
if (range.ok) return range.text;
return summarizeOptions(options, 3);
}
function looksNumeric(value: string): boolean {
const v = normalizeValue(value).replace(/,/g, '.');
return /^-?\d+(?:\.\d+)?$/.test(v);
}
function formatMaybeWithUnit(value: string, unit: string): string {
const v = normalizeValue(value);
if (!v) return '';
return looksNumeric(v) ? `${v} ${unit}` : v;
}
function drawRowPreviewTable(args: {
title: string;
rows: Array<{ config: string; col1: string; col2: string }>;
headers: { config: string; col1: string; col2: string };
getPage: () => PDFPage;
page: PDFPage;
y: number;
margin: number;
contentWidth: number;
contentMinY: number;
font: PDFFont;
fontBold: PDFFont;
navy: ReturnType<typeof rgb>;
darkGray: ReturnType<typeof rgb>;
lightGray: ReturnType<typeof rgb>;
almostWhite: ReturnType<typeof rgb>;
}): number {
let { title, rows, headers, getPage, page, y, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, lightGray, almostWhite } = args;
const titleH = 16;
const headerH = 16;
const rowH = 13;
const padAfter = 18;
// One-page rule: require at least 2 data rows.
const minNeeded = titleH + headerH + rowH * 2 + padAfter;
if (y - minNeeded < contentMinY) return contentMinY - 1;
page = getPage();
page.drawText(title, { x: margin, y, size: 10, font: fontBold, color: navy });
y -= titleH;
// How many rows fit?
const availableForRows = y - contentMinY - padAfter - headerH;
const maxRows = Math.max(2, Math.floor(availableForRows / rowH));
const shown = rows.slice(0, Math.max(0, maxRows));
const hasCol2 = shown.some(r => Boolean(r.col2));
// Widths: favor configuration readability.
const wCfg = 0.46;
const w1 = hasCol2 ? 0.27 : 0.54;
const w2 = hasCol2 ? 0.27 : 0;
const x0 = margin;
const x1 = margin + contentWidth * wCfg;
const x2 = margin + contentWidth * (wCfg + w1);
const drawHeader = () => {
page = getPage();
page.drawRectangle({ x: margin, y: y - headerH, width: contentWidth, height: headerH, color: lightGray });
page.drawText(headers.config, { x: x0 + 6, y: y - 11, size: 8, font: fontBold, color: navy, maxWidth: contentWidth * wCfg - 12 });
page.drawText(headers.col1, { x: x1 + 6, y: y - 11, size: 8, font: fontBold, color: navy, maxWidth: contentWidth * w1 - 12 });
if (hasCol2) {
page.drawText(headers.col2, { x: x2 + 6, y: y - 11, size: 8, font: fontBold, color: navy, maxWidth: contentWidth * w2 - 12 });
}
y -= headerH;
};
drawHeader();
for (let i = 0; i < shown.length; i++) {
if (y - rowH < contentMinY) return contentMinY - 1;
page = getPage();
if (i % 2 === 0) {
page.drawRectangle({ x: margin, y: y - rowH, width: contentWidth, height: rowH, color: almostWhite });
}
page.drawText(shown[i].config, { x: x0 + 6, y: y - 10, size: 8, font, color: darkGray, maxWidth: contentWidth * wCfg - 12 });
page.drawText(shown[i].col1, { x: x1 + 6, y: y - 10, size: 8, font, color: darkGray, maxWidth: contentWidth * w1 - 12 });
if (hasCol2) {
page.drawText(shown[i].col2, { x: x2 + 6, y: y - 10, size: 8, font, color: darkGray, maxWidth: contentWidth * w2 - 12 });
}
y -= rowH;
}
y -= padAfter;
return y;
}
function splitConfig(config: string): { crossSection: string; voltage: string } {
const raw = normalizeValue(config);
const parts = raw.split(/\s*-\s*/);
if (parts.length >= 2) {
return { crossSection: parts[0], voltage: parts.slice(1).join(' - ') };
}
return { crossSection: raw, voltage: '' };
}
function parseCoresAndMm2(crossSection: string): { cores: number | null; mm2: number | null } {
const s = normalizeValue(crossSection)
.replace(/\s+/g, '')
.replace(/×/g, 'x')
.replace(/,/g, '.');
// Typical: 3x1.5, 4x25, 1x70
const m = s.match(/(\d{1,3})x(\d{1,4}(?:\.\d{1,2})?)/i);
if (!m) return { cores: null, mm2: null };
const cores = Number(m[1]);
const mm2 = Number(m[2]);
return {
cores: Number.isFinite(cores) ? cores : null,
mm2: Number.isFinite(mm2) ? mm2 : null,
};
}
async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<Buffer> {
try {
const labels = getLabels(locale);
const pdfDoc = await PDFDocument.create();
const pageSizePortrait: [number, number] = [595.28, 841.89]; // DIN A4 portrait
const pageSizeLandscape: [number, number] = [841.89, 595.28]; // DIN A4 landscape
let page = pdfDoc.addPage(pageSizePortrait);
const { width, height } = page.getSize();
// STYLEGUIDE.md colors
const navy = rgb(0.0549, 0.1647, 0.2784); // #0E2A47
const mediumGray = rgb(0.4196, 0.4471, 0.5020); // #6B7280
const darkGray = rgb(0.1216, 0.1608, 0.2); // #1F2933
const almostWhite = rgb(0.9725, 0.9765, 0.9804); // #F8F9FA
const lightGray = rgb(0.9020, 0.9137, 0.9294); // #E6E9ED
const headerBg = rgb(0.965, 0.972, 0.98); // calm, print-friendly tint
// Small design system: consistent type + spacing for professional datasheets.
const DS = {
space: { xs: 4, sm: 8, md: 12, lg: 16, xl: 24 },
type: { h1: 20, h2: 11, body: 10.5, small: 8 },
rule: { thin: 0.75 },
} as const;
// Line-heights (explicit so vertical rhythm doesn't drift / overlap)
const LH = {
h1: 24,
h2: 16,
body: 14,
small: 10,
} as const;
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
// Assets
// Prefer a raster logo for reliability (sharp SVG support can vary between environments).
const logoPng = (await loadEmbeddablePng('/media/logo.png')) || (await loadEmbeddablePng('/media/logo.svg'));
const logoImage = logoPng ? await pdfDoc.embedPng(logoPng.pngBytes) : null;
// Some products have no product-specific images.
// Do NOT fall back to a generic/category hero (misleading in datasheets).
// If missing, we render a neutral placeholder box.
const heroSrc = product.featuredImage || product.images?.[0] || null;
const heroPng = heroSrc ? await loadEmbeddablePng(heroSrc) : null;
const productUrl = getProductUrl(product) || CONFIG.siteUrl;
const qrPng = await loadQrPng(productUrl);
const qrImage = qrPng ? await pdfDoc.embedPng(qrPng.pngBytes) : null;
// Engineered page frame (A4): slightly narrower margins but consistent rhythm.
const margin = 54;
const footerY = 54;
const contentMinY = footerY + 42; // keep clear of footer + page numbers
const contentWidth = width - 2 * margin;
const ctx: SectionDrawContext = {
pdfDoc,
page,
width,
height,
margin,
contentWidth,
footerY,
contentMinY,
headerDividerY: 0,
colors: { navy, mediumGray, darkGray, almostWhite, lightGray, headerBg },
fonts: { regular: font, bold: fontBold },
labels,
product,
locale,
logoImage,
qrImage,
qrUrl: productUrl,
};
const syncCtxForPage = (p: PDFPage) => {
const sz = p.getSize();
ctx.page = p;
ctx.width = sz.width;
ctx.height = sz.height;
ctx.contentWidth = sz.width - 2 * ctx.margin;
};
const drawPageBackground = (p: PDFPage) => {
const { width: w, height: h } = p.getSize();
p.drawRectangle({
x: 0,
y: 0,
width: w,
height: h,
color: rgb(1, 1, 1),
});
};
const drawProductNameOnPage = (p: PDFPage, yStart: number): number => {
const name = stripHtml(product.name);
const maxW = ctx.contentWidth;
const line = wrapText(name, fontBold, 12, maxW).slice(0, 1)[0] || name;
p.drawText(line, {
x: margin,
y: yStart,
size: 12,
font: fontBold,
color: navy,
maxWidth: maxW,
});
return yStart - 18;
};
// Real multi-page support.
// Each new page repeats header + footer for print-friendly, consistent scanning.
const newPage = (opts?: { includeProductName?: boolean; landscape?: boolean }): number => {
page = pdfDoc.addPage(opts?.landscape ? pageSizeLandscape : pageSizePortrait);
syncCtxForPage(page);
drawPageBackground(page);
drawFooter(ctx);
let yStart = drawHeader(ctx, ctx.height - ctx.margin);
if (opts?.includeProductName) yStart = drawProductNameOnPage(page, yStart);
return yStart;
};
const hasSpace = (needed: number) => y - needed >= contentMinY;
// ---- Layout helpers (eliminate magic numbers; enforce consistent rhythm) ----
const rule = (gapAbove: number = DS.space.md, gapBelow: number = DS.space.lg) => {
// One-page rule: if we can't fit a divider with its spacing, do nothing.
if (!hasSpace(gapAbove + gapBelow + DS.rule.thin)) return;
y -= gapAbove;
page.drawLine({
start: { x: margin, y },
end: { x: margin + contentWidth, y },
thickness: DS.rule.thin,
color: lightGray,
});
y -= gapBelow;
};
const sectionTitle = (text: string) => {
// One-page rule: if we can't fit the heading + its gap, do nothing.
if (!hasSpace(DS.type.h2 + DS.space.md)) return;
page.drawText(text, {
x: margin,
y,
size: DS.type.h2,
font: fontBold,
color: navy,
});
// Use a real line-height to avoid title/body overlap.
y -= LH.h2;
};
// Page 1
syncCtxForPage(page);
drawPageBackground(page);
drawFooter(ctx);
let y = drawHeader(ctx, ctx.height - ctx.margin);
// === PRODUCT HEADER ===
const productName = stripHtml(product.name);
const cats = (product.categories || []).map(c => stripHtml(c.name)).join(' • ');
// First page: keep the header compact so technical tables start earlier.
const titleW = contentWidth;
const titleSize = 18;
const titleLineH = 21;
const nameLines = wrapText(productName, fontBold, titleSize, titleW);
const shownNameLines = nameLines.slice(0, 1);
for (const line of shownNameLines) {
if (y - titleLineH < contentMinY) y = newPage();
page.drawText(line, {
x: margin,
y,
size: titleSize,
font: fontBold,
color: navy,
maxWidth: titleW,
});
y -= titleLineH;
}
if (cats) {
if (y - 18 < contentMinY) y = newPage();
page.drawText(cats, {
x: margin,
y,
size: 10.5,
font,
color: mediumGray,
maxWidth: titleW,
});
y -= DS.space.md;
}
// Separator after product header
// No dividing lines on first page (cleaner, more space-efficient).
// rule(DS.space.xs, DS.space.md);
// === HERO IMAGE (full width) ===
// Dense technical products need more room for specs; prioritize content over imagery.
const hasLotsOfTech = (product.attributes?.length || 0) >= 18;
let heroH = hasLotsOfTech ? 96 : 120;
const afterHeroGap = DS.space.lg;
if (!hasSpace(heroH + afterHeroGap)) {
// Shrink to remaining space (but keep it usable).
heroH = Math.max(84, Math.floor(y - contentMinY - afterHeroGap));
}
const heroBoxX = margin;
const heroBoxY = y - heroH;
page.drawRectangle({
x: heroBoxX,
y: heroBoxY,
width: contentWidth,
height: heroH,
// Calm frame; gives images consistent presence even with transparency.
color: almostWhite,
borderColor: lightGray,
borderWidth: 1,
});
if (heroPng) {
const pad = DS.space.md;
const boxW = contentWidth - pad * 2;
const boxH = heroH - pad * 2;
// Fit image into the box without cutting it off (contain).
// Technical images often must remain fully visible.
const sharp = await getSharp();
const contained = await sharp(Buffer.from(heroPng.pngBytes))
.resize({
width: 1200,
height: Math.round((1200 * boxH) / boxW),
fit: 'contain',
background: { r: 248, g: 249, b: 250, alpha: 1 },
})
.png()
.toBuffer();
const heroImage = await pdfDoc.embedPng(contained);
page.drawImage(heroImage, {
x: heroBoxX + pad,
y: heroBoxY + pad,
width: boxW,
height: boxH,
});
} else {
page.drawText(locale === 'de' ? 'Kein Bild verfügbar' : 'No image available', {
x: heroBoxX + 12,
y: heroBoxY + heroH / 2,
size: 8,
font,
color: mediumGray,
maxWidth: contentWidth - 24,
});
}
y = heroBoxY - afterHeroGap;
// === DESCRIPTION (optional) ===
if (product.shortDescriptionHtml || product.descriptionHtml) {
const desc = stripHtml(product.shortDescriptionHtml || product.descriptionHtml);
const descLineH = 14;
const descMaxLines = 2;
const boxPadX = DS.space.md;
const boxPadY = DS.space.md;
const boxH = boxPadY * 2 + descLineH * descMaxLines;
const descNeeded = DS.type.h2 + DS.space.md + boxH + DS.space.lg + DS.space.xl;
// Only render description if we can fit it cleanly on the current page.
// If not, we skip it (tables + technical data have higher value for customers).
if (hasSpace(descNeeded)) {
sectionTitle(labels.description);
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,
});
const descLines = wrapText(desc, font, DS.type.body, contentWidth - boxPadX * 2);
let ty = boxTop - boxPadY - DS.type.body;
for (const line of descLines.slice(0, descMaxLines)) {
page.drawText(line, {
x: margin + boxPadX,
y: ty,
size: DS.type.body,
font,
color: darkGray,
maxWidth: contentWidth - boxPadX * 2,
});
ty -= descLineH;
}
y = boxBottom - DS.space.md;
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) => {
if (!hasMultipleVoltages) return 0; // No meta block if single voltage
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: only if multiple voltage ratings exist
// If single voltage, the technical data section already covers shared values
if (hasMultipleVoltages) {
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: show voltage label only if multiple voltages
const tableTitle = hasMultipleVoltages ? '' : `${labels.crossSection}${t.voltageLabel}`;
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;
}
// 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');
const products = Number.isFinite(limit) && limit > 0 ? allProducts.slice(0, limit) : allProducts;
const enProducts = products.filter(p => p.locale === 'en');
const deProducts = products.filter(p => p.locale === '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 };