Files
klz-cables.com/scripts/generate-pdf-datasheets.ts
2026-01-07 11:34:56 +01:00

2677 lines
91 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;
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);
return match?.rows || [];
}
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 (20°C)' : 'DC resistance at 20 °C', dcResKey, 'Ω/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 (20°C)' : 'DC resistance at 20 °C',
options: withUnit(get(keyDcRes), 'Ω/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), 'μF/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), 'μF/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;
// Draw a strict rectangular section container (no rounding)
if (boxed && items.length) {
const rows = Math.ceil(items.length / 2);
const boxH = padY + headerH + rows * rowH + padY;
const bottomY = y - boxH;
if (bottomY < contentMinY) {
if (!allowNewPage) return contentMinY - 1;
y = newPage();
}
page = getPage();
page.drawRectangle({
x: margin,
y: y - boxH,
width: contentWidth,
height: boxH,
borderColor: lightGray,
borderWidth: 1,
color: rgb(1, 1, 1),
});
// Header band for the title
page.drawRectangle({
x: margin,
y: y - headerH,
width: contentWidth,
height: headerH,
color: almostWhite,
});
}
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();
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();
drawTitle();
rowY = y;
}
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;
}
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'); // µ / μ
// 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 = {
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 }>;
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 headerH = 16;
const rowH = 13;
// Always include configuration as first col.
const configCol = {
key: 'configuration',
label: 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] : [];
const chunkTitle = chunks.length > 1 ? `${title} (${ci + 1}/${chunks.length})` : title;
const tableCols: TableColumn[] = [configCol, ...chunkCols];
// Width distribution (keeps configuration readable)
const configW = 0.32;
const remainingW = 1 - configW;
const perW = remainingW / Math.max(1, tableCols.length - 1);
const widths = tableCols.map((_, idx) => (idx === 0 ? configW : perW));
const ensureSpace = (needed: number) => {
if (y - needed < contentMinY) y = newPage();
page = getPage();
};
ensureSpace(18 + headerH + rowH * 2);
page.drawText(chunkTitle, {
x: margin,
y,
size: 10,
font: fontBold,
color: navy,
});
y -= 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();
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: lightGray,
});
let x = margin;
for (let i = 0; i < tableCols.length; i++) {
page.drawText(tableCols[i].label, {
x: x + 6,
y: y - 11,
size: 8,
font: fontBold,
color: navy,
maxWidth: contentWidth * widths[i] - 12,
});
x += contentWidth * widths[i];
}
y -= headerH;
};
drawHeader();
for (let r = 0; r < configRows.length; r++) {
if (y - rowH < contentMinY) {
y = newPage();
page = getPage();
page.drawText(chunkTitle, {
x: margin,
y,
size: 12,
font: fontBold,
color: navy,
});
y -= 16;
drawHeader();
}
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++) {
page.drawText(tableCols[c].get(r), {
x: x + 6,
y: y - 10,
size: 8,
font,
color: darkGray,
maxWidth: contentWidth * widths[c] - 12,
});
x += contentWidth * widths[c];
}
y -= rowH;
}
y -= 18;
}
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.
const headerH = 64;
const dividerY = yStart - headerH;
ctx.headerDividerY = dividerY;
page.drawRectangle({
x: 0,
y: dividerY,
width,
height: headerH,
color: colors.headerBg,
});
const qrSize = 44;
const qrGap = 12;
const rightReserved = qrImage ? qrSize + qrGap : 0;
// Left: logo (preferred) or typographic fallback
if (logoImage) {
const maxLogoW = 120;
const maxLogoH = 30;
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: provide real breathing room below the header.
return dividerY - 40;
}
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 pageSize: [number, number] = [595.28, 841.89]; // A4
let page = pdfDoc.addPage(pageSize);
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,
};
// Hard requirement: one-page PDFs.
// We never create a second page; we truncate sections to fit.
const newPage = (): number => contentMinY - 1;
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
// Page background (print-friendly)
page.drawRectangle({
x: 0,
y: 0,
width,
height,
color: rgb(1, 1, 1),
});
drawFooter(ctx);
let y = drawHeader(ctx, height - margin);
// === PRODUCT HEADER ===
const productName = stripHtml(product.name);
const cats = (product.categories || []).map(c => stripHtml(c.name)).join(' • ');
const titleW = contentWidth;
const titleLineH = LH.h1;
const nameLines = wrapText(productName, fontBold, DS.type.h1, titleW);
const shownNameLines = nameLines.slice(0, 2);
for (const line of shownNameLines) {
if (y - titleLineH < contentMinY) y = newPage();
page.drawText(line, {
x: margin,
y,
size: DS.type.h1,
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.lg;
}
// Separator after product header
rule(DS.space.sm, DS.space.lg);
// === 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 ? 120 : 160;
const afterHeroGap = DS.space.xl;
if (!hasSpace(heroH + afterHeroGap)) {
// Shrink to remaining space (but keep it usable).
heroH = Math.max(96, 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;
// Pre-crop the image to the target aspect ratio (prevents overflow and removes top/bottom whitespace).
const sharp = await getSharp();
const cropped = await sharp(Buffer.from(heroPng.pngBytes))
.resize({
width: 1200,
height: Math.round((1200 * boxH) / boxW),
fit: 'cover',
position: 'attention',
})
.png()
.toBuffer();
const heroImage = await pdfDoc.embedPng(cropped);
// Exact-fit (we already cropped to this aspect ratio).
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 ===
if (product.shortDescriptionHtml || product.descriptionHtml) {
const desc = stripHtml(product.shortDescriptionHtml || product.descriptionHtml);
const descLineH = 14;
const descMaxLines = 3;
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;
// One-page rule: only render description if we can fit it cleanly.
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.lg;
rule(0, DS.space.xl);
}
}
// === EXCEL SOURCE ENRICHMENT (cross-section + key row-specific attrs) ===
// Some products have cross-section data on the website but not in the WP export.
// When missing, we enrich from the source Excel sheets under `data/source/*`.
ensureExcelCrossSectionAttributes(product, locale);
// Even when cross-sections exist, the WP export can miss row-specific technical columns.
// We add a best-effort set of numeric per-row attributes from Excel (only when row counts match).
ensureExcelRowSpecificAttributes(product, locale);
if (process.env.PDF_DEBUG_EXCEL === '1') {
const hasAnyCfg = (product.attributes || []).some(a =>
/configuration|konfiguration|aufbau|bezeichnung|number of cores and cross-section|querschnitt|cross.?section|mm²|mm2/i.test(a.name),
);
console.log(`[excel] after enrichment: product ${product.id} cfgAttrPresent=${hasAnyCfg}`);
}
// === TECHNICAL DATA (shared across all cross-sections) ===
const configAttr = findAttr(product, /configuration|konfiguration|aufbau|bezeichnung/i);
const crossSectionAttr =
configAttr ||
findAttr(product, /number of cores and cross-section|querschnitt|cross.?section|mm²|mm2/i);
const rowCount = crossSectionAttr?.options?.length || 0;
const hasCrossSectionData = Boolean(crossSectionAttr && rowCount > 0);
if (process.env.PDF_DEBUG_EXCEL === '1') {
console.log(
`[excel] crossSectionAttr=${crossSectionAttr ? normalizeValue(crossSectionAttr.name) : 'none'} rows=${rowCount} hasCross=${hasCrossSectionData}`,
);
}
// Compact mode approach:
// - show constant (non-row) attributes as key/value grid
// - show only a small configuration sample + total count
// - optionally render full tables with PDF_MODE=full
const pdfMode = process.env.PDF_MODE || 'compact'; // 'compact' or 'full'
// Prefer a curated list that matches website expectations.
// IMPORTANT: for row-specific arrays we don't attempt per-row mapping here; we summarize as ranges.
const preferredTechAttrs: Array<{ re: RegExp; fallbackLabel: string }> = [
{ re: /standard|norm|vde|iec/i, fallbackLabel: locale === 'de' ? 'Norm' : 'Standard' },
{ re: /rated\s*voltage|voltage\s*rating|nennspannung/i, fallbackLabel: locale === 'de' ? 'Nennspannung' : 'Rated voltage' },
{ re: /test\s*voltage|pr\u00fcfspannung/i, fallbackLabel: locale === 'de' ? 'Pr\u00fcfspannung' : 'Test voltage' },
{ re: /temperature\s*range|operating\s*temperature|betriebstemperatur/i, fallbackLabel: locale === 'de' ? 'Temperaturbereich' : 'Temperature range' },
{ re: /bending\s*radius|biegeradius/i, fallbackLabel: locale === 'de' ? 'Biegeradius' : 'Bending radius' },
{ re: /cpr\s*class/i, fallbackLabel: locale === 'de' ? 'CPR-Klasse' : 'CPR class' },
{ re: /conductor/i, fallbackLabel: locale === 'de' ? 'Leiter' : 'Conductor' },
{ re: /insulation/i, fallbackLabel: locale === 'de' ? 'Isolierung' : 'Insulation' },
{ re: /sheath/i, fallbackLabel: locale === 'de' ? 'Mantel' : 'Sheath' },
{ re: /flame retardant|flammhemmend/i, fallbackLabel: locale === 'de' ? 'Flammhemmend' : 'Flame retardant' },
{ re: /packaging|verpackung/i, fallbackLabel: locale === 'de' ? 'Verpackung' : 'Packaging' },
{ re: /rohs.*reach/i, fallbackLabel: locale === 'de' ? 'RoHS/REACH' : 'RoHS/REACH' },
];
const picked = new Set<string>();
const techItemsPreferred = preferredTechAttrs
.map(({ re, fallbackLabel }) => {
const a = findAttr(product, re);
if (!a) return null;
const label = normalizeValue(a.name) || fallbackLabel;
const value = summarizeSmartOptions(label, a.options);
if (!label || !value) return null;
picked.add(label.toLowerCase());
return { label, value };
})
.filter(Boolean) as Array<{ label: string; value: string }>;
const isConfigLikeAttr = (name: string) =>
/configuration|konfiguration|aufbau|bezeichnung|number of cores and cross-section|querschnitt|cross.?section|mm²|mm2/i.test(name);
const isClearlyMetaAttr = (name: string) => /\bsku\b|artikelnummer|\bid\b|product\s*id/i.test(name);
// Provide additional technical attributes as compact summaries.
// - numeric-heavy arrays become ranges (minmax with count)
// - non-numeric arrays become short lists
// This is what fills the “missing important technical data” without breaking 1-page.
const techItemsMore = (product.attributes || [])
.filter(a => (a.options?.length || 0) > 1)
.filter(a => !isConfigLikeAttr(a.name))
.filter(a => !isClearlyMetaAttr(a.name))
.map(a => {
const label = normalizeValue(a.name);
if (!label) return null;
if (picked.has(label.toLowerCase())) return null;
const value = summarizeSmartOptions(label, a.options);
if (!value) return null;
return { label, value };
})
.filter(Boolean) as Array<{ label: string; value: string }>;
const constantAttrs = (product.attributes || []).filter(a => a.options.length === 1);
const techItemsFill = constantAttrs
.map(a => ({ label: normalizeValue(a.name), value: normalizeValue(a.options[0]) }))
.filter(i => i.label && i.value)
.filter(i => !picked.has(i.label.toLowerCase()));
const constantItemsAll = [...techItemsPreferred, ...techItemsMore, ...techItemsFill].slice(0, 20);
// Intentionally do NOT include SKU/categories here (they are already shown in the product header).
// TECH DATA must never crowd out cross-section.
// IMPORTANT: `drawKeyValueGrid()` will return `contentMinY - 1` when it can't fit.
// We must avoid calling it unless we're sure it fits.
const techBox = {
// Keep in sync with `drawKeyValueGrid()` boxed metrics
padY: 14,
headerH: 22,
rowH: 24,
} as const;
// Reserve enough space so cross-sections are actually visible when present.
// Mirror `drawCrossSectionChipsRow()` minimum-needed math (+ a bit of padding).
// We cap the chips block to keep room for technical data.
const crossMaxLinesCap = 2;
const minCrossBlockH =
12 /*title*/ +
12 /*summary*/ +
(16 * crossMaxLinesCap) /*chips*/ +
8 /*lineGap*/ +
10 /*gapY*/ +
24 /*after*/;
const reservedForCross = hasCrossSectionData ? minCrossBlockH : 0;
const techTitle = locale === 'de' ? 'TECHNISCHE DATEN' : 'TECHNICAL DATA';
const techBoxHeightFor = (itemsCount: number) => {
const rows = Math.ceil(itemsCount / 2);
return techBox.padY + techBox.headerH + rows * techBox.rowH + techBox.padY;
};
const canFitTechWith = (itemsCount: number) => {
if (itemsCount <= 0) return false;
const techH = techBoxHeightFor(itemsCount);
const afterTechGap = DS.space.lg;
// We need to keep reserved space for cross-section below.
return y - (techH + afterTechGap + reservedForCross) >= contentMinY;
};
// Pick the largest "nice" amount of items that still guarantees cross-section visibility.
const desiredCap = 12;
let chosenCount = 0;
for (let n = Math.min(desiredCap, constantItemsAll.length); n >= 1; n--) {
if (canFitTechWith(n)) {
chosenCount = n;
break;
}
}
if (chosenCount > 0) {
const constantItems = constantItemsAll.slice(0, chosenCount);
y = drawKeyValueGrid({
title: techTitle,
items: constantItems,
newPage,
getPage: () => page,
page,
y,
margin,
contentWidth,
contentMinY,
font,
fontBold,
navy,
darkGray,
mediumGray,
lightGray,
almostWhite,
allowNewPage: false,
boxed: true,
});
} else if (!hasCrossSectionData) {
// If there is no cross-section block, we can afford to show a clear "no data" note.
y = drawKeyValueGrid({
title: techTitle,
items: [
{
label: locale === 'de' ? 'Hinweis' : 'Note',
value:
locale === 'de'
? 'Für dieses Produkt sind derzeit keine technischen Daten hinterlegt.'
: 'No technical data is available for this product yet.',
},
],
newPage,
getPage: () => page,
page,
y,
margin,
contentWidth,
contentMinY,
font,
fontBold,
navy,
darkGray,
mediumGray,
lightGray,
almostWhite,
allowNewPage: false,
boxed: true,
});
}
// Consistent spacing after the technical data block (but never push content below min Y)
if (y - DS.space.lg >= contentMinY) y -= DS.space.lg;
// === CROSS-SECTION TABLE (row-specific data) ===
if (crossSectionAttr && rowCount > 0) {
const configRows = crossSectionAttr.options;
const findRowAttr = (re: RegExp) => {
const a = product.attributes?.find(x => re.test(x.name));
if (!a) return null;
if (!a.options || a.options.length !== rowCount) return null;
return a;
};
// Pull the most important row-specific columns and show a small excerpt table.
const rowOuter = findRowAttr(/outer\s*diameter|außen\s*durchmesser|außen-?ø/i);
const rowWeight = findRowAttr(/\bweight\b|gewicht/i);
const rowDcRes = findRowAttr(/dc resistance|leiterwiderstand/i);
const rowCap = findRowAttr(/capacitance|kapazit/i);
const rowCurrentAir = findRowAttr(/current.*air/i);
const rowCurrentGround = findRowAttr(/current.*ground/i);
const yAfterCross = drawCrossSectionChipsRow({
title: labels.crossSection,
configRows,
locale,
// keep chips as a fallback, but prefer the dense list section below
maxLinesCap: 2,
getPage: () => page,
page,
y,
margin,
contentWidth,
contentMinY,
font,
fontBold,
navy,
darkGray,
mediumGray,
lightGray,
almostWhite,
});
// If the chips block can't fit at all, show a minimal summary line (no chips).
// drawCrossSectionChipsRow returns (contentMinY - 1) in that case.
if (yAfterCross < contentMinY) {
sectionTitle(labels.crossSection);
const total = configRows.length;
const summary = locale === 'de' ? `Varianten: ${total}` : `Options: ${total}`;
page.drawText(summary, {
x: margin,
y,
size: DS.type.body,
font,
color: mediumGray,
maxWidth: contentWidth,
});
y -= LH.body + DS.space.lg;
} else {
y = yAfterCross;
}
// Compact per-configuration excerpt (only if it fits).
// Build columns dynamically based on what's available
const availableColumns: Array<{ key: string; label: string; attr: ProductData['attributes'][number] | null; unit: string }> = [];
if (rowOuter) availableColumns.push({ key: 'outer', label: locale === 'de' ? 'Außen-Ø' : 'Outer Ø', attr: rowOuter, unit: 'mm' });
if (rowWeight) availableColumns.push({ key: 'weight', label: locale === 'de' ? 'Gewicht' : 'Weight', attr: rowWeight, unit: 'kg/km' });
if (rowDcRes) availableColumns.push({ key: 'dcres', label: locale === 'de' ? 'Widerstand' : 'Resistance', attr: rowDcRes, unit: 'Ω/km' });
if (rowCap) availableColumns.push({ key: 'cap', label: locale === 'de' ? 'Kapazität' : 'Capacitance', attr: rowCap, unit: 'μF/km' });
if (rowCurrentAir) availableColumns.push({ key: 'curair', label: locale === 'de' ? 'Strom Luft' : 'Current Air', attr: rowCurrentAir, unit: 'A' });
if (rowCurrentGround) availableColumns.push({ key: 'curground', label: locale === 'de' ? 'Strom Erdreich' : 'Current Ground', attr: rowCurrentGround, unit: 'A' });
if (availableColumns.length >= 2) {
// Use first two available columns for the preview table
const col1 = availableColumns[0];
const col2 = availableColumns[1];
const previewRows = configRows.map((cfg, i) => ({
config: normalizeValue(cfg),
col1: formatMaybeWithUnit(getAttrCellValue(col1.attr ?? undefined, i, rowCount), col1.unit),
col2: formatMaybeWithUnit(getAttrCellValue(col2.attr ?? undefined, i, rowCount), col2.unit),
}));
const previewTitle = locale === 'de' ? 'Konfigurationswerte (Auszug)' : 'Configuration values (excerpt)';
const yAfterPreview = drawRowPreviewTable({
title: previewTitle,
rows: previewRows,
headers: {
config: locale === 'de' ? 'Konfiguration' : 'Configuration',
col1: col1.label,
col2: col2.label,
},
getPage: () => page,
page,
y,
margin,
contentWidth,
contentMinY,
font,
fontBold,
navy,
darkGray,
lightGray,
almostWhite,
});
if (yAfterPreview >= contentMinY) y = yAfterPreview;
}
// Full table mode: show more technical columns if space allows and mode is enabled
if (pdfMode === 'full' && availableColumns.length > 2) {
// Try to show additional columns in a chunked table
const additionalColumns = availableColumns.slice(2).map(col => ({
key: col.key,
label: col.label,
get: (i: number) => formatMaybeWithUnit(getAttrCellValue(col.attr ?? undefined, i, rowCount), col.unit),
}));
if (additionalColumns.length > 0 && y - 100 >= contentMinY) {
y = drawTableChunked({
title: locale === 'de' ? 'Technische Daten (alle)' : 'Technical data (all)',
configRows,
columns: additionalColumns,
locale,
newPage,
getPage: () => page,
page,
y,
margin,
contentWidth,
contentMinY,
font,
fontBold,
navy,
darkGray,
lightGray,
almostWhite,
maxDataColsPerTable: 3,
});
}
}
} else {
// If there is no cross-section data, do not render the section at all.
}
// 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 };