2677 lines
91 KiB
TypeScript
2677 lines
91 KiB
TypeScript
#!/usr/bin/env ts-node
|
||
/**
|
||
* PDF Datasheet Generator - Industrial Engineering Documentation Style
|
||
* STYLEGUIDE.md compliant: industrial, technical, restrained
|
||
*/
|
||
|
||
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
import { execSync } from 'child_process';
|
||
import { PDFDocument, rgb, StandardFonts, PDFFont, PDFPage, PDFImage } from 'pdf-lib';
|
||
|
||
let sharpFn: ((input?: any, options?: any) => any) | null = null;
|
||
async function getSharp(): Promise<(input?: any, options?: any) => any> {
|
||
if (sharpFn) return sharpFn;
|
||
// `sharp` is CJS but this script runs as ESM via ts-node.
|
||
// Dynamic import gives stable interop.
|
||
const mod: any = await import('sharp');
|
||
sharpFn = (mod?.default || mod) as (input?: any, options?: any) => any;
|
||
return sharpFn;
|
||
}
|
||
|
||
const CONFIG = {
|
||
productsFile: path.join(process.cwd(), 'data/processed/products.json'),
|
||
outputDir: path.join(process.cwd(), 'public/datasheets'),
|
||
chunkSize: 10,
|
||
siteUrl: 'https://klz-cables.com',
|
||
};
|
||
|
||
const ASSET_MAP_FILE = path.join(process.cwd(), 'data/processed/asset-map.json');
|
||
const PUBLIC_DIR = path.join(process.cwd(), 'public');
|
||
|
||
const EXCEL_SOURCE_FILES = [
|
||
path.join(process.cwd(), 'data/source/high-voltage.xlsx'),
|
||
path.join(process.cwd(), 'data/source/medium-voltage-KM.xlsx'),
|
||
path.join(process.cwd(), 'data/source/low-voltage-KM.xlsx'),
|
||
path.join(process.cwd(), 'data/source/solar-cables.xlsx'),
|
||
];
|
||
|
||
type AssetMap = Record<string, string>;
|
||
|
||
function readAssetMap(): AssetMap {
|
||
try {
|
||
if (!fs.existsSync(ASSET_MAP_FILE)) return {};
|
||
return JSON.parse(fs.readFileSync(ASSET_MAP_FILE, 'utf8')) as AssetMap;
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
|
||
const ASSET_MAP: AssetMap = readAssetMap();
|
||
|
||
interface ProductData {
|
||
id: number;
|
||
name: string;
|
||
shortDescriptionHtml: string;
|
||
descriptionHtml: string;
|
||
images: string[];
|
||
featuredImage: string | null;
|
||
sku: string;
|
||
slug?: string;
|
||
path?: string;
|
||
translationKey?: string;
|
||
locale?: 'en' | 'de';
|
||
categories: Array<{ name: string }>;
|
||
attributes: Array<{
|
||
name: string;
|
||
options: string[];
|
||
}>;
|
||
}
|
||
|
||
type ExcelRow = Record<string, any>;
|
||
type ExcelMatch = { rows: ExcelRow[]; units: Record<string, string> };
|
||
let EXCEL_INDEX: Map<string, ExcelMatch> | null = null;
|
||
|
||
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 (min–max 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 };
|