diff --git a/app/actions/brochure.ts b/app/actions/brochure.ts
deleted file mode 100644
index 2d340abea..000000000
--- a/app/actions/brochure.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-'use server';
-
-import { getServerAppServices } from '@/lib/services/create-services.server';
-
-export async function requestBrochureAction(formData: FormData) {
- const services = getServerAppServices();
- const logger = services.logger.child({ action: 'requestBrochureAction' });
-
- const { headers } = await import('next/headers');
- const requestHeaders = await headers();
-
- if ('setServerContext' in services.analytics) {
- (services.analytics as any).setServerContext({
- userAgent: requestHeaders.get('user-agent') || undefined,
- language: requestHeaders.get('accept-language')?.split(',')[0] || undefined,
- referrer: requestHeaders.get('referer') || undefined,
- ip: requestHeaders.get('x-forwarded-for')?.split(',')[0] || undefined,
- });
- }
-
- services.analytics.track('brochure-request-attempt');
-
- const email = formData.get('email') as string;
- const locale = (formData.get('locale') as string) || 'en';
-
- // Anti-spam Honeypot Check
- const honeypot = formData.get('company_website') as string;
- if (honeypot) {
- logger.warn('Spam detected via honeypot in brochure request', { email });
- // Silently succeed to fool the bot without doing actual work
- return { success: true };
- }
-
- if (!email) {
- logger.warn('Missing email in brochure request');
- return { success: false, error: 'Missing email address' };
- }
-
- // Basic email validation
- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
- return { success: false, error: 'Invalid email address' };
- }
-
- // 1. Save to CMS - Removed along with Payload CMS
-
- // 2. Notify via Gotify
- try {
- await services.notifications.notify({
- title: 'π Brochure Download Request',
- message: `New brochure download request from ${email} (${locale})`,
- priority: 3,
- });
- } catch (error) {
- logger.error('Failed to send notification', { error });
- }
-
- // 3. Send Brochure via Email
- const brochureUrl = `https://e-tib.com/brochure/etib-product-catalog-${locale}.pdf`;
-
- try {
- const { sendEmail } = await import('@/lib/mail/mailer');
- const { render } = await import('@mintel/mail');
- const React = await import('react');
- const { BrochureDeliveryEmail } = await import('@/components/emails/BrochureDeliveryEmail');
-
- const html = await render(
- React.createElement(BrochureDeliveryEmail, {
- _email: email,
- brochureUrl,
- locale: locale as 'en' | 'de',
- }),
- );
-
- const emailResult = await sendEmail({
- to: email,
- subject: locale === 'de' ? 'Ihr E-TIB Kabelkatalog' : 'Your E-TIB Cable Catalog',
- html,
- });
-
- if (emailResult.success) {
- logger.info('Brochure email sent successfully', { email });
- } else {
- logger.error('Failed to send brochure email', { error: emailResult.error, email });
- services.errors.captureException(new Error(`Brochure email failed: ${emailResult.error}`), {
- action: 'requestBrochureAction_email',
- email,
- });
- return { success: false, error: 'Failed to send email. Please try again later.' };
- }
- } catch (error) {
- logger.error('Exception while sending brochure email', { error });
- return { success: false, error: 'Failed to send email. Please try again later.' };
- }
-
- // 4. Track success
- services.analytics.track('brochure-request-success', {
- locale,
- delivery_method: 'email',
- });
-
- return { success: true };
-}
diff --git a/components/emails/BrochureDeliveryEmail.tsx b/components/emails/BrochureDeliveryEmail.tsx
deleted file mode 100644
index 13d76ac9b..000000000
--- a/components/emails/BrochureDeliveryEmail.tsx
+++ /dev/null
@@ -1,145 +0,0 @@
-import {
- Body,
- Container,
- Head,
- Heading,
- Hr,
- Html,
- Preview,
- Section,
- Text,
- Button,
-} from '@react-email/components';
-import * as React from 'react';
-
-interface BrochureDeliveryEmailProps {
- _email: string;
- brochureUrl: string;
- locale: 'en' | 'de';
-}
-
-export const BrochureDeliveryEmail = ({
- _email,
- brochureUrl,
- locale = 'en',
-}: BrochureDeliveryEmailProps) => {
- const t =
- locale === 'de'
- ? {
- subject: 'Ihr E-TIB Kabelkatalog',
- greeting: 'Vielen Dank fΓΌr Ihr Interesse an E-TIB.',
- body: 'Anbei erhalten Sie den Link zu unserem aktuellen Produktkatalog. Dieser enthΓ€lt alle wichtigen technischen Spezifikationen und detaillierten Produktdaten.',
- button: 'Katalog herunterladen',
- footer: 'Diese E-Mail wurde von e-tib.com gesendet.',
- }
- : {
- subject: 'Your E-TIB Cable Catalog',
- greeting: 'Thank you for your interest in E-TIB.',
- body: 'Below you will find the link to our current product catalog. It contains all key technical specifications and detailed product data.',
- button: 'Download Catalog',
- footer: 'This email was sent from e-tib.com.',
- };
-
- return (
-
-
-
-
-
-
-
- {t.greeting}
-
- {t.body}
-
-
-
-
-
- {t.footer}
-
-
-
- );
-};
-
-export default BrochureDeliveryEmail;
-
-const main = {
- backgroundColor: '#f6f9fc',
- fontFamily:
- '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
-};
-
-const container = {
- backgroundColor: '#ffffff',
- margin: '0 auto',
- padding: '0 0 48px',
- marginBottom: '64px',
- borderRadius: '8px',
- overflow: 'hidden',
- border: '1px solid #e6ebf1',
-};
-
-const headerSection = {
- backgroundColor: '#111111',
- padding: '32px 48px',
- borderBottom: '4px solid #117c61',
-};
-
-const h1 = {
- color: '#ffffff',
- fontSize: '24px',
- fontWeight: 'bold',
- margin: '0',
-};
-
-const section = {
- padding: '32px 48px 0',
-};
-
-const text = {
- color: '#333',
- fontSize: '16px',
- lineHeight: '24px',
- textAlign: 'left' as const,
-};
-
-const buttonContainer = {
- textAlign: 'center' as const,
- marginTop: '32px',
- marginBottom: '32px',
-};
-
-const button = {
- backgroundColor: '#117c61',
- borderRadius: '4px',
- color: '#ffffff',
- fontSize: '16px',
- fontWeight: 'bold',
- textDecoration: 'none',
- textAlign: 'center' as const,
- display: 'inline-block',
- padding: '16px 32px',
-};
-
-const hr = {
- borderColor: '#e6ebf1',
- margin: '20px 0',
-};
-
-const footer = {
- color: '#8898aa',
- fontSize: '12px',
- lineHeight: '16px',
- textAlign: 'center' as const,
- marginTop: '20px',
-};
diff --git a/lib/excel-products.ts b/lib/excel-products.ts
deleted file mode 100644
index b29788f2d..000000000
--- a/lib/excel-products.ts
+++ /dev/null
@@ -1,388 +0,0 @@
-/**
- * Excel Products Module
- *
- * Provides typed access to technical product data from Excel source files.
- * Reuses the parsing logic from the PDF datasheet generator.
- */
-
-import * as fs from 'fs';
-import * as path from 'path';
-import * as XLSX from 'xlsx';
-
-// Configuration
-const EXCEL_SOURCE_FILES = [
- path.join(process.cwd(), 'data/excel/high-voltage.xlsx'),
- path.join(process.cwd(), 'data/excel/medium-voltage-KM.xlsx'),
- path.join(process.cwd(), 'data/excel/low-voltage-KM.xlsx'),
- path.join(process.cwd(), 'data/excel/solar-cables.xlsx'),
-];
-
-// Types
-export type ExcelRow = Record;
-
-export interface ExcelMatch {
- rows: ExcelRow[];
- units: Record;
-}
-
-export interface TechnicalData {
- configurations: string[];
- attributes: Array<{
- name: string;
- options: string[];
- }>;
-}
-
-export interface ProductLookupParams {
- name?: string;
- slug?: string;
- sku?: string;
- translationKey?: string;
-}
-
-// Cache singleton
-let EXCEL_INDEX: Map | null = null;
-
-/**
- * Normalize Excel key to match product identifiers
- * Examples:
- * - "NA2XS(FL)2Y" -> "NA2XSFL2Y"
- * - "na2xsfl2y-3" -> "NA2XSFL2Y"
- */
-function normalizeExcelKey(value: string): string {
- return String(value || '')
- .toUpperCase()
- .replace(/-\d+$/g, '')
- .replace(/[^A-Z0-9]+/g, '');
-}
-
-/**
- * Normalize value (strip HTML, trim whitespace)
- */
-function normalizeValue(value: string): string {
- if (!value) return '';
- return String(value)
- .replace(/<[^>]*>/g, '')
- .replace(/\s+/g, ' ')
- .trim();
-}
-
-/**
- * Check if value looks numeric
- */
-function looksNumeric(value: string): boolean {
- const v = normalizeValue(value).replace(/,/g, '.');
- return /^-?\d+(?:\.\d+)?$/.test(v);
-}
-
-/**
- * Load Excel rows from a file using xlsx library
- */
-function loadExcelRows(filePath: string): ExcelRow[] {
- if (!fs.existsSync(filePath)) {
- console.warn(`[excel-products] File not found: ${filePath}`);
- return [];
- }
-
- try {
- const workbook = XLSX.readFile(filePath, {
- cellDates: false,
- cellNF: false,
- cellText: false
- });
-
- // Get the first sheet
- const sheetName = workbook.SheetNames[0];
- const worksheet = workbook.Sheets[sheetName];
-
- // Convert to JSON
- const rows = XLSX.utils.sheet_to_json(worksheet, {
- defval: '',
- raw: false
- }) as ExcelRow[];
-
- return rows;
- } catch (error) {
- console.error(`[excel-products] Error reading ${filePath}:`, error);
- return [];
- }
-}
-
-/**
- * Build the Excel index from all source files
- */
-export function getExcelIndex(): Map {
- if (EXCEL_INDEX) return EXCEL_INDEX;
-
- const idx = new Map();
-
- for (const file of EXCEL_SOURCE_FILES) {
- const rows = loadExcelRows(file);
- if (rows.length === 0) continue;
-
- // Find units row (if present)
- const unitsRow = rows.find(r => r && r['Part Number'] === 'Units') || null;
- const units: Record = {};
-
- 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;
- }
- }
-
- // Index rows by Part Number
- 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);
- // Keep the most comprehensive units
- if (Object.keys(cur.units).length < Object.keys(units).length) {
- cur.units = units;
- }
- }
- }
- }
-
- EXCEL_INDEX = idx;
- return idx;
-}
-
-/**
- * Find Excel match for a product using various identifiers
- */
-function findExcelForProduct(params: ProductLookupParams): ExcelMatch | null {
- const idx = getExcelIndex();
-
- const candidates = [
- params.name,
- params.slug ? params.slug.replace(/-\d+$/g, '') : '',
- params.sku,
- params.translationKey,
- ].filter(Boolean) as string[];
-
- for (const c of candidates) {
- const key = normalizeExcelKey(c);
- const match = idx.get(key);
- if (match && match.rows.length) return match;
- }
-
- return null;
-}
-
-/**
- * Guess column key based on patterns
- */
-function guessColumnKey(row: ExcelRow, patterns: RegExp[]): string | null {
- const keys = Object.keys(row || {});
-
- 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;
-}
-
-/**
- * Get unique non-empty values from an array
- */
-function getUniqueNonEmpty(options: string[]): string[] {
- const uniq: string[] = [];
- const seen = new Set();
- 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;
-}
-
-/**
- * Get technical data for a product from Excel files
- */
-export function getExcelTechnicalDataForProduct(params: ProductLookupParams): TechnicalData | null {
- const match = findExcelForProduct(params);
- if (!match || match.rows.length === 0) return null;
-
- const rows = match.rows;
- const sample = rows[0];
-
- // Find cross-section column
- const csKey = guessColumnKey(sample, [
- /number of cores and cross-section/i,
- /cross.?section/i,
- /ross section conductor/i,
- ]);
-
- if (!csKey) return null;
-
- // Extract configurations
- const voltageKey = guessColumnKey(sample, [/rated voltage/i, /voltage rating/i, /spannungs/i, /nennspannung/i]);
-
- const configurations = rows
- .map(r => {
- const cs = normalizeValue(String(r?.[csKey] ?? ''));
- const v = voltageKey ? normalizeValue(String(r?.[voltageKey] ?? '')) : '';
- if (!cs) return '';
- if (!v) return cs;
- const vHasUnit = /\bkv\b/i.test(v);
- const vText = vHasUnit ? v : `${v} kV`;
- return `${cs} - ${vText}`;
- })
- .filter(Boolean);
-
- if (configurations.length === 0) return null;
-
- // Extract technical attributes
- const attributes: Array<{ name: string; options: string[] }> = [];
-
- // Key technical columns
- const outerKey = guessColumnKey(sample, [/outer diameter\b/i, /outer diameter.*approx/i, /outer diameter of cable/i, /auΓen/i]);
- const weightKey = guessColumnKey(sample, [/weight\b/i, /gewicht/i, /cable weight/i]);
- const dcResKey = guessColumnKey(sample, [/dc resistance/i, /resistance conductor/i, /leiterwiderstand/i]);
- const ratedVoltKey = voltageKey;
- const testVoltKey = guessColumnKey(sample, [/test voltage/i, /prΓΌfspannung/i]);
- const tempRangeKey = guessColumnKey(sample, [/operating temperature range/i, /temperature range/i, /temperaturbereich/i]);
- const conductorKey = guessColumnKey(sample, [/^conductor$/i]);
- const insulationKey = guessColumnKey(sample, [/^insulation$/i]);
- const sheathKey = guessColumnKey(sample, [/^sheath$/i]);
- const normKey = guessColumnKey(sample, [/^norm$/i, /^standard$/i]);
- const cprKey = guessColumnKey(sample, [/cpr class/i]);
- const packagingKey = guessColumnKey(sample, [/^packaging$/i]);
- const shapeKey = guessColumnKey(sample, [/shape of conductor/i]);
- const flameKey = guessColumnKey(sample, [/flame retardant/i]);
- const diamCondKey = guessColumnKey(sample, [/diameter conductor/i]);
- const diamInsKey = guessColumnKey(sample, [/diameter over insulation/i]);
- const diamScreenKey = guessColumnKey(sample, [/diameter over screen/i]);
- const metalScreenKey = guessColumnKey(sample, [/metallic screen/i]);
- const capacitanceKey = guessColumnKey(sample, [/capacitance/i]);
- const reactanceKey = guessColumnKey(sample, [/reactance/i]);
- const electricalStressKey = guessColumnKey(sample, [/electrical stress/i]);
- const pullingForceKey = guessColumnKey(sample, [/max\. pulling force/i, /pulling force/i]);
- const heatingTrefoilKey = guessColumnKey(sample, [/heating time constant.*trefoil/i]);
- const heatingFlatKey = guessColumnKey(sample, [/heating time constant.*flat/i]);
- const currentAirTrefoilKey = guessColumnKey(sample, [/current ratings in air.*trefoil/i]);
- const currentAirFlatKey = guessColumnKey(sample, [/current ratings in air.*flat/i]);
- const currentGroundTrefoilKey = guessColumnKey(sample, [/current ratings in ground.*trefoil/i]);
- const currentGroundFlatKey = guessColumnKey(sample, [/current ratings in ground.*flat/i]);
- const scCurrentCondKey = guessColumnKey(sample, [/conductor shortcircuit current/i]);
- const scCurrentScreenKey = guessColumnKey(sample, [/screen shortcircuit current/i]);
- const minLayKey = guessColumnKey(sample, [/minimal temperature for laying/i]);
- const minStoreKey = guessColumnKey(sample, [/minimal storage temperature/i]);
- const maxOpKey = guessColumnKey(sample, [/maximal operating conductor temperature/i, /max\. operating/i]);
- const maxScKey = guessColumnKey(sample, [/maximal short-circuit temperature/i, /short\s*circuit\s*temperature/i]);
- const insThkKey = guessColumnKey(sample, [/nominal insulation thickness/i, /insulation thickness/i]);
- const sheathThkKey = guessColumnKey(sample, [/nominal sheath thickness/i, /minimum sheath thickness/i]);
- const maxResKey = guessColumnKey(sample, [/maximum resistance of conductor/i]);
- const bendKey = guessColumnKey(sample, [/bending radius/i, /min\. bending radius/i]);
-
- // Helper to add attribute
- const addAttr = (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))
- .filter(Boolean);
-
- if (options.length === 0) return;
-
- const uniqueOptions = getUniqueNonEmpty(options);
- attributes.push({ name, options: uniqueOptions });
- };
-
- // Add attributes
- addAttr('Outer diameter', outerKey, 'mm');
- addAttr('Weight', weightKey, 'kg/km');
- addAttr('DC resistance at 20 Β°C', dcResKey, 'Ξ©/km');
- addAttr('Rated voltage', ratedVoltKey, '');
- addAttr('Test voltage', testVoltKey, '');
- addAttr('Operating temperature range', tempRangeKey, '');
- addAttr('Minimal temperature for laying', minLayKey, '');
- addAttr('Minimal storage temperature', minStoreKey, '');
- addAttr('Maximal operating conductor temperature', maxOpKey, '');
- addAttr('Maximal short-circuit temperature', maxScKey, '');
- addAttr('Nominal insulation thickness', insThkKey, 'mm');
- addAttr('Nominal sheath thickness', sheathThkKey, 'mm');
- addAttr('Maximum resistance of conductor', maxResKey, 'Ξ©/km');
- addAttr('Conductor', conductorKey, '');
- addAttr('Insulation', insulationKey, '');
- addAttr('Sheath', sheathKey, '');
- addAttr('Standard', normKey, '');
- addAttr('Conductor diameter', diamCondKey, 'mm');
- addAttr('Insulation diameter', diamInsKey, 'mm');
- addAttr('Screen diameter', diamScreenKey, 'mm');
- addAttr('Metallic screen', metalScreenKey, '');
- addAttr('Max. pulling force', pullingForceKey, '');
- addAttr('Electrical stress conductor', electricalStressKey, '');
- addAttr('Electrical stress insulation', electricalStressKey, '');
- addAttr('Reactance', reactanceKey, '');
- addAttr('Heating time constant trefoil', heatingTrefoilKey, 's');
- addAttr('Heating time constant flat', heatingFlatKey, 's');
- addAttr('Flame retardant', flameKey, '');
- addAttr('CPR class', cprKey, '');
- addAttr('Packaging', packagingKey, '');
- addAttr('Bending radius', bendKey, 'mm');
- addAttr('Shape of conductor', shapeKey, '');
- addAttr('Capacitance', capacitanceKey, 'ΞΌF/km');
- addAttr('Current ratings in air, trefoil', currentAirTrefoilKey, 'A');
- addAttr('Current ratings in air, flat', currentAirFlatKey, 'A');
- addAttr('Current ratings in ground, trefoil', currentGroundTrefoilKey, 'A');
- addAttr('Current ratings in ground, flat', currentGroundFlatKey, 'A');
- addAttr('Conductor shortcircuit current', scCurrentCondKey, 'kA');
- addAttr('Screen shortcircuit current', scCurrentScreenKey, 'kA');
-
- return {
- configurations,
- attributes,
- };
-}
-
-/**
- * Get raw Excel rows for a product (for detailed inspection)
- */
-export function getExcelRowsForProduct(params: ProductLookupParams): ExcelRow[] {
- const match = findExcelForProduct(params);
- return match?.rows || [];
-}
-
-/**
- * Clear the Excel index cache (useful for development)
- */
-export function clearExcelCache(): void {
- EXCEL_INDEX = null;
-}
-
-/**
- * Preload Excel data on module initialization
- * This ensures the cache is built during build time
- */
-export function preloadExcelData(): void {
- getExcelIndex();
-}
-
-// Preload when imported
-if (require.main === module) {
- preloadExcelData();
-}
\ No newline at end of file
diff --git a/lib/pdf-brochure.tsx b/lib/pdf-brochure.tsx
deleted file mode 100644
index 012c67c49..000000000
--- a/lib/pdf-brochure.tsx
+++ /dev/null
@@ -1,1436 +0,0 @@
-import * as React from 'react';
-import { Document, Page, View, Text, Image } from '@react-pdf/renderer';
-
-// βββ Brand Tokens βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
-const C = {
- navy: '#111111',
- navyDeep: '#050505',
- green: '#117c61',
- greenLight: '#e1f2ee',
- white: '#FFFFFF',
- offWhite: '#f8f9fa',
- gray100: '#f3f4f6',
- gray200: '#e5e7eb',
- gray300: '#d1d5db',
- gray400: '#9ca3af',
- gray600: '#4b5563',
- gray900: '#111827',
-};
-
-const MARGIN = 56;
-const HEADER_H = 52;
-const FOOTER_H = 48;
-const BODY_TOP = HEADER_H + 40;
-const BODY_BOTTOM = FOOTER_H + 24;
-
-// βββ Types ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
-export interface BrochureProduct {
- id: number;
- name: string;
- shortDescriptionHtml: string;
- descriptionHtml: string;
- applicationHtml?: string;
- images: string[];
- featuredImage: string | null;
- sku: string;
- slug: string;
- categories: Array<{ name: string }>;
- attributes: Array<{ name: string; options: string[] }>;
- qrWebsite?: string | Buffer;
- qrDatasheet?: string | Buffer;
-}
-
-export interface BrochureProps {
- products: BrochureProduct[];
- locale: 'en' | 'de';
- companyInfo: {
- tagline: string;
- values: Array<{ title: string; description: string }>;
- address: string;
- phone: string;
- email: string;
- website: string;
- };
- logoBlack?: string | Buffer;
- logoWhite?: string | Buffer;
- introContent?: { title: string; excerpt: string; heroImage?: string | Buffer };
- marketingSections?: Array<{
- title: string;
- subtitle: string;
- description?: string;
- items?: Array<{ title: string; description: string }>;
- highlights?: Array<{ value: string; label: string }>;
- pullQuote?: string;
- }>;
- galleryImages?: Array;
- messages?: Record;
- directorPhotos?: { michael?: Buffer; klaus?: Buffer };
-}
-
-// βββ Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
-const imgValid = (src?: string | Buffer): boolean => {
- if (!src) return false;
- if (Buffer.isBuffer(src)) return src.length > 0;
- return true;
-};
-
-const labels = (locale: 'en' | 'de') =>
- locale === 'de'
- ? {
- catalog: 'UnternehmensbroschΓΌre',
- subtitle:
- 'IHR PARTNER FΓR KABELLEITUNGSNETZBAU, HORIZONTALSPΓLBOHRUNGEN, PLANUNG UND VERMESSUNG.',
- about: 'Γber uns',
- toc: 'Inhalt',
- overview: 'Γbersicht',
- application: 'Leistungsspektrum',
- specs: 'Technische Details',
- contact: 'Kontakt',
- qrWeb: 'Details',
- qrPdf: 'PDF',
- values: 'Unsere Werte',
- edition: 'Ausgabe',
- page: 'Seite',
- property: 'Eigenschaft',
- value: 'Wert',
- other: 'Sonstige',
- }
- : {
- catalog: 'Company Brochure',
- subtitle:
- 'YOUR PARTNER FOR CABLE NETWORK CONSTRUCTION, HORIZONTAL DIRECTIONAL DRILLING, PLANNING AND SURVEYING.',
- about: 'About Us',
- toc: 'Contents',
- overview: 'Overview',
- application: 'Services',
- specs: 'Technical Details',
- contact: 'Contact',
- qrWeb: 'Details',
- qrPdf: 'PDF',
- values: 'Our Values',
- edition: 'Edition',
- page: 'Page',
- property: 'Property',
- value: 'Value',
- other: 'Other',
- };
-
-// βββ Rich Text ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
-const RichText: React.FC<{ children: string; style?: any; gap?: number; color?: string }> = ({
- children,
- style = {},
- gap = 8,
- color,
-}) => {
- const paragraphs = children.split('\n\n').filter((p) => p.trim());
- return (
-
- {paragraphs.map((para, pIdx) => {
- const parts: Array<{ text: string; bold?: boolean; italic?: boolean }> = [];
- let rem = para;
- while (rem.length > 0) {
- const bm = rem.match(/\*\*(.+?)\*\*/);
- const im = rem.match(/(? (a!.index || 0) - (b!.index || 0))[0];
- if (!first || first.index === undefined) {
- parts.push({ text: rem });
- break;
- }
- if (first.index > 0) parts.push({ text: rem.substring(0, first.index) });
- parts.push({
- text: first[1],
- bold: first[0].startsWith('**'),
- italic: !first[0].startsWith('**'),
- });
- rem = rem.substring(first.index + first[0].length);
- }
- return (
-
- {parts.map((part, i) => (
-
- {part.text}
-
- ))}
-
- );
- })}
-
- );
-};
-
-// βββ Shared Components ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
-// Thin brand bar at the top of every page
-const Header: React.FC<{ logo?: string | Buffer; right?: string; dark?: boolean }> = ({
- logo,
- right,
- dark,
-}) => (
-
- {logo ? (
-
- ) : (
- E-TIB
- )}
- {right && (
-
- {right}
-
- )}
-
-);
-
-const PageFooter: React.FC<{ left: string; right: string; dark?: boolean }> = ({
- left,
- right,
- dark,
-}) => (
-
-
- {left}
-
-
- {right}
-
-
-);
-
-// Green accent bar
-const AccentBar = () => (
-
-);
-
-// βββ FadeImage βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-// Simulates a gradient fade at one edge using stacked opacity bands.
-// React-pdf has no CSS gradient support, so we stack 14 semi-opaque rectangles.
-//
-// 'position' param: which edge fades INTO the page background
-// 'bottom' β image visible at top, fades down into bgColor
-// 'top' β image visible at bottom, fades up into bgColor
-// 'right' β image on left side, fades right into bgColor
-//
-// The component must be placed ABSOLUTE (position: 'absolute') on the page.
-
-const FadeImage: React.FC<{
- src: string | Buffer;
- top?: number;
- left?: number;
- right?: number;
- bottom?: number;
- width: number | string;
- height: number;
- fadeEdge: 'bottom' | 'top' | 'right' | 'left';
- fadeSize?: number; // how many points the fade spans
- bgColor: string;
- opacity?: number; // overall image darkness (0β1, applied via overlay)
-}> = ({
- src,
- top,
- left,
- right,
- bottom,
- width,
- height,
- fadeEdge,
- fadeSize = 120,
- bgColor,
- opacity = 0,
-}) => {
- const STEPS = 40; // High number of overlapping bands
-
- const bands = Array.from({ length: STEPS }, (_, i) => {
- // i=0 is the widest band reaching deepest into the image.
- // i=STEPS-1 is the narrowest band right at the fade edge.
- // Because they all anchor at the edge and overlap, their opacity compounds.
- // We use an ease-in curve for distance to make the fade look natural.
- const t = 1.0 / STEPS;
- const easeDist = Math.pow((i + 1) / STEPS, 1.2);
- const dist = fadeSize * easeDist;
-
- const style: any = {
- position: 'absolute',
- backgroundColor: bgColor,
- opacity: t,
- };
-
- // All bands anchor at the fade edge and extend inward by `dist`
- if (fadeEdge === 'bottom') {
- Object.assign(style, { left: 0, right: 0, height: dist, bottom: 0 });
- } else if (fadeEdge === 'top') {
- Object.assign(style, { left: 0, right: 0, height: dist, top: 0 });
- } else if (fadeEdge === 'right') {
- Object.assign(style, { top: 0, bottom: 0, width: dist, right: 0 });
- } else {
- Object.assign(style, { top: 0, bottom: 0, width: dist, left: 0 });
- }
-
- return style;
- });
-
- return (
-
-
- {/* Overlay using bgColor to "wash out" / dilute the image */}
- {opacity > 0 && (
-
- )}
- {/* Gradient fade bands */}
- {bands.map((s, i) => (
-
- ))}
-
- );
-};
-
-// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-// PAGE 1: COVER
-// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
-const CoverPage: React.FC<{
- locale: 'en' | 'de';
- introContent?: BrochureProps['introContent'];
- logoWhite?: string | Buffer;
- galleryImages?: Array;
-}> = ({ locale, introContent, logoWhite, galleryImages }) => {
- const l = labels(locale);
- const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
- year: 'numeric',
- month: 'long',
- });
- const bg = galleryImages?.[0] || introContent?.heroImage;
-
- return (
-
- {/* Full-page background image with dark overlay */}
- {imgValid(bg) && (
-
-
-
-
- )}
- {!imgValid(bg) && (
-
- )}
-
- {/* Vertical accent stripe */}
-
-
- {/* Logo top-left */}
-
- {imgValid(logoWhite) ? (
-
- ) : (
- KLZ
- )}
-
-
- {/* Main title block β bottom third of page */}
-
-
-
- {l.catalog}
-
-
- {introContent?.excerpt || l.subtitle}
-
-
-
- {/* Bottom bar */}
-
-
- {l.edition} {dateStr}
-
- www.e-tib.com
-
-
- );
-};
-
-// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-// PAGES 2βN: INFO PAGES (each marketing section = own page)
-// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
-const InfoPage: React.FC<{
- section: NonNullable[0];
- image?: string | Buffer;
- logoBlack?: string | Buffer;
- logoWhite?: string | Buffer;
- dark?: boolean;
- imagePosition?: 'top' | 'bottom-half';
-}> = ({ section, image, logoBlack, logoWhite, dark, imagePosition = 'top' }) => {
- const bg = dark ? C.navyDeep : C.white;
- const textColor = dark ? C.gray300 : C.gray600;
- const titleColor = dark ? C.white : C.navyDeep;
- const boldColor = dark ? C.white : C.navyDeep;
- const headerLogo = dark ? logoWhite || logoBlack : logoBlack;
-
- // Image at top: 240pt tall, content starts below via paddingTop
- const IMG_TOP_H = 240;
- const bodyTopWithImg =
- imagePosition === 'top' && imgValid(image)
- ? IMG_TOP_H + 24 // content starts below image
- : BODY_TOP;
-
- return (
-
- {/* Absolute image β from page edge, fades into bg */}
- {imgValid(image) && imagePosition === 'top' && (
-
- )}
- {imgValid(image) && imagePosition === 'bottom-half' && (
-
- )}
-
- {/* Header β on top of image */}
-
-
-
- {/* Content β pushed below image when top-position */}
-
- {/* Label + Title */}
-
- {section.subtitle}
-
-
- {section.title}
-
-
- {/* Description */}
- {section.description && (
-
-
- {section.description}
-
-
- )}
-
- {/* Highlights */}
- {section.highlights && section.highlights.length > 0 && (
-
- {section.highlights.map((h, i) => (
-
-
- {h.value}
-
-
- {h.label}
-
-
- ))}
-
- )}
-
- {/* Pull quote */}
- {section.pullQuote && (
-
-
- β{section.pullQuote}"
-
-
- )}
-
- {/* Items β 2-column grid */}
- {section.items && section.items.length > 0 && (
-
- {section.items.map((item, i) => (
-
-
- {item.title}
-
-
-
- {item.description}
-
-
- ))}
-
- )}
-
-
- );
-};
-
-// About page (first info page)
-const AboutPage: React.FC<{
- locale: 'en' | 'de';
- companyInfo: BrochureProps['companyInfo'];
- logoBlack?: string | Buffer;
- image?: string | Buffer;
- messages?: Record;
- directorPhotos?: { michael?: Buffer; klaus?: Buffer };
-}> = ({ locale, companyInfo, logoBlack, image, messages, directorPhotos }) => {
- const l = labels(locale);
-
- // Image at top: 200pt tall (smaller to leave more room for content)
- const IMG_TOP_H = 200;
- const bodyTopWithImg = imgValid(image) ? IMG_TOP_H + 16 : BODY_TOP;
-
- // Pull directors content from messages if available
- const team = messages?.Team || {};
- const michael = team.michael;
- const klaus = team.klaus;
-
- return (
-
- {/* Top-aligned image fading into white bottom */}
- {imgValid(image) && (
-
- )}
-
-
-
-
- {/* Content pushed below the fading image */}
-
-
- {l.about}
-
-
- E-TIB GmbH
-
-
-
-
- {companyInfo.tagline}
-
-
- {/* Company mission β makes immediately clear what E-TIB does */}
-
-
- {locale === 'de'
- ? 'E-TIB GmbH ist Ihr Spezialist fΓΌr Kabelleitungsnetzbau, HorizontalspΓΌlbohrungen, Planung und Vermessung. Wir realisieren komplexe Infrastrukturprojekte fΓΌr Energieversorger und die Industrie β von der ersten Planung bis zur termingerechten AusfΓΌhrung. Mit modernster Technik und erfahrener Mannschaft sorgen wir fΓΌr Ihre Netze von morgen.'
- : 'E-TIB GmbH is your specialist for cable network construction, horizontal directional drilling, planning and surveying. We realize complex infrastructure projects for energy providers and industry β from initial planning to on-time execution. With state-of-the-art technology and an experienced team, we ensure your networks of tomorrow.'}
-
-
-
- {/* Directors β two-column */}
- {(michael || klaus) && (
-
-
- {locale === 'de' ? 'Die GeschΓ€ftsfΓΌhrer' : 'The Directors'}
-
-
- {[
- { data: michael, photo: directorPhotos?.michael },
- { data: klaus, photo: directorPhotos?.klaus },
- ]
- .filter((p) => p.data)
- .map((p, i) => (
-
-
- {p.photo && (
-
- )}
-
-
- {p.data.name}
-
-
- {p.data.role}
-
-
-
-
- {p.data.description}
-
- {p.data.quote && (
-
-
- β{p.data.quote}β
-
-
- )}
-
- ))}
-
-
- )}
-
- {/* Values grid */}
-
-
- {l.values}
-
-
- {companyInfo.values.map((v, i) => (
-
-
-
- 0{i + 1}
-
- {v.title}
-
-
- {v.description}
-
-
- ))}
-
-
-
-
- );
-};
-
-// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-// TOC PAGE
-// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
-const TocPage: React.FC<{
- products: BrochureProduct[];
- locale: 'en' | 'de';
- logoBlack?: string | Buffer;
- productStartPage: number;
-}> = ({ products, locale, logoBlack, productStartPage }) => {
- const l = labels(locale);
-
- // Group products by their first category
- const categories: Array<{
- name: string;
- products: Array;
- }> = [];
- let currentPageNum = productStartPage;
- for (const p of products) {
- const catName = p.categories[0]?.name || l.other;
- let category = categories.find((c) => c.name === catName);
- if (!category) {
- category = { name: catName, products: [] };
- categories.push(category);
- }
- category.products.push({ ...p, startingPage: currentPageNum });
- currentPageNum++;
- }
-
- return (
-
-
-
-
-
-
- {l.catalog}
-
-
- {categories.map((cat, i) => (
-
- {cat.name && (
-
- {cat.name}
-
- )}
-
- {cat.products.map((p, j) => (
-
- {p.name}
-
-
- {(p.startingPage || 0).toString().padStart(2, '0')}
-
-
- ))}
-
-
- ))}
-
-
- );
-};
-
-// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-// PRODUCT PAGES
-// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
-const ProductPage: React.FC<{
- product: BrochureProduct;
- locale: 'en' | 'de';
- logoBlack?: string | Buffer;
-}> = ({ product, locale, logoBlack }) => {
- const l = labels(locale);
-
- return (
-
-
-
-
- {/* Product image block reduced strictly to 110pt high */}
- {product.featuredImage && (
-
-
-
- )}
-
- {/* Labels & Name */}
- {product.categories.length > 0 && (
-
- {product.categories.map((c) => c.name).join(' β’ ')}
-
- )}
-
- {product.name}
-
-
- {/* Description β full width */}
- {product.descriptionHtml && (
-
-
- {l.application}
-
-
- {product.descriptionHtml}
-
-
- )}
-
- {/* Technical Data β full-width striped table */}
- {product.attributes && product.attributes.length > 0 && (
-
-
- {l.specs}
-
-
- {/* Table header */}
-
-
-
- {l.property}
-
-
-
-
- {l.value}
-
-
-
-
- {product.attributes.map((attr, i) => (
-
-
- {attr.name}
-
-
- {attr.options.join(', ')}
-
-
- ))}
-
- )}
-
- {/* QR Codes β horizontal row at bottom */}
- {(product.qrWebsite || product.qrDatasheet) && (
-
- {product.qrWebsite && (
-
-
-
-
-
-
- {l.qrWeb}
-
-
- {locale === 'de' ? 'Produktseite' : 'Product Page'}
-
-
-
- )}
- {product.qrDatasheet && (
-
-
-
-
-
-
- {l.qrPdf}
-
-
- {locale === 'de' ? 'Datenblatt' : 'Datasheet'}
-
-
-
- )}
-
- )}
-
- );
-};
-
-// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-// BACK COVER
-// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
-const BackCover: React.FC<{
- companyInfo: BrochureProps['companyInfo'];
- locale: 'en' | 'de';
- logoWhite?: string | Buffer;
- image?: string | Buffer;
-}> = ({ companyInfo, locale, logoWhite, image }) => {
- const l = labels(locale);
-
- return (
-
- {/* Background */}
- {imgValid(image) && (
-
-
-
-
- )}
- {!imgValid(image) && (
-
- )}
-
-
- {imgValid(logoWhite) ? (
-
- ) : (
-
- KLZ CABLES
-
- )}
-
-
-
-
- {l.contact}
-
-
- {companyInfo.address}
-
-
- {companyInfo.phone}
-
- {companyInfo.email}
-
-
- {companyInfo.website}
-
-
-
-
- Β© {new Date().getFullYear()} KLZ Cables GmbH
-
-
-
- );
-};
-
-// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-// DOCUMENT
-// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-
-export const PDFBrochure: React.FC = ({
- products,
- locale,
- companyInfo,
- introContent,
- marketingSections,
- logoBlack,
- logoWhite,
- galleryImages,
- messages,
- directorPhotos,
-}) => {
- // Cover(1) + About(1) + marketingSections.length + TOC(1) + products + BackCover(1)
- const numInfoPages = 1 + (marketingSections?.length || 0);
- const productStartPage = 1 + numInfoPages + 1;
-
- // Image assignment β each page gets a UNIQUE image, never repeating
- // galleryImages indices: 0=cover, 1=about, 2..N-2=info sections, N-1=back cover
- // TOC intentionally gets NO image (clean list page)
- const totalGallery = galleryImages?.length || 0;
- const backCoverImgIdx = totalGallery - 1;
-
- // Section themes: alternate light/dark
- const sectionThemes: Array<'light' | 'dark'> = [];
- // imagePosition: alternate between top and bottom-half for variety
- const imagePositions: Array<'top' | 'bottom-half'> = [];
- if (marketingSections) {
- for (let i = 0; i < marketingSections.length; i++) {
- sectionThemes.push(i % 2 === 1 ? 'dark' : 'light');
- imagePositions.push(i % 2 === 0 ? 'top' : 'bottom-half');
- }
- }
-
- return (
-
-
-
- {/* About page β image[1] */}
-
-
- {/* Info sections β images[2..] each unique, alternating top/bottom and light/dark */}
- {marketingSections?.map((section, i) => (
-
- ))}
-
- {/* TOC β no decorative image, clean list */}
-
-
- {/* Products β each on its own page */}
- {products.map((p) => (
-
- ))}
-
- {/* Back cover β last gallery image */}
-
-
- );
-};
diff --git a/lib/pdf-page.tsx b/lib/pdf-page.tsx
deleted file mode 100644
index 4235462f6..000000000
--- a/lib/pdf-page.tsx
+++ /dev/null
@@ -1,329 +0,0 @@
-import * as React from 'react';
-import { Document, Page, View, Text, StyleSheet, Font, Link } from '@react-pdf/renderer';
-
-// Register fonts (using system fonts for now, can be customized)
-Font.register({
- family: 'Helvetica',
- fonts: [
- { src: '/fonts/Helvetica.ttf', fontWeight: 400 },
- { src: '/fonts/Helvetica-Bold.ttf', fontWeight: 700 },
- ],
-});
-
-// βββ Brand Tokens (matching datasheet) ββββββββββββββββββββββββββββββββββ
-const C = {
- navy: '#001a4d',
- navyDeep: '#000d26',
- green: '#4da612',
- greenLight: '#e8f5d8',
- white: '#FFFFFF',
- offWhite: '#f8f9fa',
- gray100: '#f3f4f6',
- gray200: '#e5e7eb',
- gray300: '#d1d5db',
- gray400: '#9ca3af',
- gray600: '#4b5563',
- gray900: '#111827',
-};
-
-const MARGIN = 56;
-
-const styles = StyleSheet.create({
- page: {
- color: C.gray900,
- lineHeight: 1.5,
- backgroundColor: C.white,
- paddingTop: 0,
- paddingBottom: 80,
- fontFamily: 'Helvetica',
- },
-
- // Hero-style header
- hero: {
- backgroundColor: C.white,
- paddingTop: 24,
- paddingBottom: 0,
- paddingHorizontal: MARGIN,
- marginBottom: 20,
- position: 'relative',
- borderBottomWidth: 0,
- },
-
- header: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- marginBottom: 16,
- },
-
- logoText: {
- fontSize: 22,
- fontWeight: 700,
- color: C.navyDeep,
- letterSpacing: 2,
- textTransform: 'uppercase',
- },
-
- docTitle: {
- fontSize: 8,
- fontWeight: 700,
- color: C.green,
- letterSpacing: 2,
- textTransform: 'uppercase',
- },
-
- // Content Area
- content: {
- paddingHorizontal: MARGIN,
- },
-
- pageTitle: {
- fontSize: 24,
- fontWeight: 700,
- color: C.navyDeep,
- marginBottom: 8,
- marginTop: 10,
- textTransform: 'uppercase',
- letterSpacing: -0.5,
- },
-
- accentBar: {
- width: 30,
- height: 2,
- backgroundColor: C.green,
- marginBottom: 20,
- borderRadius: 1,
- },
-
- // Lexical Elements
- paragraph: {
- fontSize: 10,
- color: C.gray600,
- lineHeight: 1.7,
- marginBottom: 12,
- },
- heading1: {
- fontSize: 16,
- fontWeight: 700,
- color: C.navyDeep,
- marginTop: 20,
- marginBottom: 10,
- textTransform: 'uppercase',
- letterSpacing: 0.5,
- },
- heading2: {
- fontSize: 12,
- fontWeight: 700,
- color: C.navyDeep,
- marginTop: 16,
- marginBottom: 8,
- },
- heading3: {
- fontSize: 10,
- fontWeight: 700,
- color: C.navyDeep,
- marginTop: 12,
- marginBottom: 6,
- },
- list: {
- marginBottom: 12,
- marginLeft: 8,
- },
- listItem: {
- flexDirection: 'row',
- marginBottom: 4,
- },
- listItemBullet: {
- width: 12,
- fontSize: 10,
- color: C.green,
- fontWeight: 700,
- },
- listItemContent: {
- flex: 1,
- fontSize: 10,
- color: C.gray600,
- lineHeight: 1.7,
- },
- link: {
- color: C.green,
- textDecoration: 'none',
- },
- textBold: {
- fontWeight: 700,
- fontFamily: 'Helvetica-Bold',
- color: C.navyDeep,
- },
- textItalic: {
- fontStyle: 'italic',
- },
-
- // Footer β matches brochure style
- footer: {
- position: 'absolute',
- bottom: 28,
- left: MARGIN,
- right: MARGIN,
- flexDirection: 'row',
- justifyContent: 'space-between',
- alignItems: 'center',
- paddingTop: 12,
- borderTopWidth: 2,
- borderTopColor: C.green,
- },
-
- footerText: {
- fontSize: 7,
- color: C.gray400,
- fontWeight: 400,
- textTransform: 'uppercase',
- letterSpacing: 0.8,
- },
-
- footerBrand: {
- fontSize: 9,
- fontWeight: 700,
- color: C.navyDeep,
- textTransform: 'uppercase',
- letterSpacing: 1.5,
- },
-});
-
-// βββ Lexical to React-PDF Renderer ββββββββββββββββββββββββββββββββ
-
-const renderLexicalNode = (node: any, idx: number): React.ReactNode => {
- if (!node) return null;
-
- switch (node.type) {
- case 'text': {
- const format = node.format || 0;
- const isBold = (format & 1) !== 0;
- const isItalic = (format & 2) !== 0;
-
- let elementStyle: any = {};
- if (isBold) elementStyle = { ...elementStyle, ...styles.textBold };
- if (isItalic) elementStyle = { ...elementStyle, ...styles.textItalic };
-
- return (
-
- {node.text}
-
- );
- }
-
- case 'paragraph': {
- return (
-
- {node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
-
- );
- }
-
- case 'heading': {
- let hStyle = styles.heading3;
- if (node.tag === 'h1') hStyle = styles.heading1;
- if (node.tag === 'h2') hStyle = styles.heading2;
-
- return (
-
- {node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
-
- );
- }
-
- case 'list': {
- return (
-
- {node.children?.map((child: any, i: number) => {
- if (child.type === 'listitem') {
- return (
-
-
- {node.listType === 'number' ? `${i + 1}.` : 'β’'}
-
-
- {child.children?.map((c: any, ci: number) => renderLexicalNode(c, ci))}
-
-
- );
- }
- return renderLexicalNode(child, i);
- })}
-
- );
- }
-
- case 'link': {
- const href = node.fields?.url || node.url || '#';
- return (
-
- {node.children?.map((child: any, i: number) => renderLexicalNode(child, i))}
-
- );
- }
-
- case 'linebreak': {
- return {'\n'};
- }
-
- // Ignore payload blocks recursively to avoid crashing
- case 'block':
- return null;
-
- default:
- if (node.children) {
- return (
-
- {node.children.map((child: any, i: number) => renderLexicalNode(child, i))}
-
- );
- }
- return null;
- }
-};
-
-interface PDFPageProps {
- page: any;
- locale?: string;
-}
-
-export const PDFPage: React.FC = ({ page, locale = 'de' }) => {
- const dateStr = new Date().toLocaleDateString(locale === 'en' ? 'en-US' : 'de-DE', {
- year: 'numeric',
- month: 'long',
- day: 'numeric',
- });
-
- return (
-
-
- {/* Hero Header */}
-
-
-
- KLZ
-
- {locale === 'en' ? 'Document' : 'Dokument'}
-
-
-
-
- {page.title}
-
-
-
- {page.content?.root?.children?.map((node: any, i: number) =>
- renderLexicalNode(node, i),
- )}
-
-
-
- {/* Minimal footer */}
-
- KLZ CABLES
- {dateStr}
-
-
-
- );
-};
diff --git a/package.json b/package.json
index 22657c56e..3f88de781 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,6 @@
"@mintel/next-utils": "1.9.5",
"@next/mdx": "^16.1.6",
"@react-email/components": "1.0.8",
- "@react-pdf/renderer": "^4.3.2",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@sentry/nextjs": "^10.39.0",
@@ -31,7 +30,6 @@
"next-intl": "^4.8.2",
"next-mdx-remote": "^5.0.0",
"nodemailer": "^8.0.7",
- "pdf-lib": "^1.17.1",
"pino": "^10.3.0",
"pino-pretty": "^13.1.3",
"react": "^19.2.4",
@@ -124,10 +122,6 @@
"check:assets": "tsx ./scripts/check-broken-assets.ts",
"check:forms": "tsx ./scripts/check-forms.ts",
"check:apis": "tsx ./scripts/check-apis.ts",
- "pdf:datasheets": "tsx ./scripts/generate-pdf-datasheets.tsx",
- "pdf:datasheets:legacy": "tsx ./scripts/generate-pdf-datasheets-pdf-lib.ts",
- "excel:datasheets": "tsx ./scripts/generate-excel-datasheets.ts",
- "brochure:generate": "tsx ./scripts/generate-brochure.ts",
"assets:push:staging": "bash ./scripts/assets-sync.sh local staging",
"assets:push:prod": "bash ./scripts/assets-sync.sh local prod",
"assets:pull:testing": "bash ./scripts/assets-sync.sh testing local",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 35f522772..f6ab1717c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -33,9 +33,6 @@ importers:
'@react-email/components':
specifier: 1.0.8
version: 1.0.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
- '@react-pdf/renderer':
- specifier: ^4.3.2
- version: 4.3.2(react@19.2.4)
'@react-three/drei':
specifier: ^10.7.7
version: 10.7.7(@react-three/fiber@9.5.0(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.183.2))(@types/react@19.2.14)(@types/three@0.183.1)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.183.2)
@@ -93,9 +90,6 @@ importers:
nodemailer:
specifier: ^8.0.7
version: 8.0.7
- pdf-lib:
- specifier: ^1.17.1
- version: 1.17.1
pino:
specifier: ^10.3.0
version: 10.3.1
@@ -1897,12 +1891,6 @@ packages:
'@paulirish/trace_engine@0.0.53':
resolution: {integrity: sha512-PUl/vlfo08Oj804VI5nDPeSk9vyslnBlVzDDwFt8SUVxY8+KdGMkra/vrXjEEHe8gb7+RqVTfOIlGw0nyrEelA==}
- '@pdf-lib/standard-fonts@1.0.0':
- resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==}
-
- '@pdf-lib/upng@1.0.1':
- resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==}
-
'@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
@@ -2221,49 +2209,6 @@ packages:
react: ^18.0.0
react-dom: ^18.0.0
- '@react-pdf/fns@3.1.2':
- resolution: {integrity: sha512-qTKGUf0iAMGg2+OsUcp9ffKnKi41RukM/zYIWMDJ4hRVYSr89Q7e3wSDW/Koqx3ea3Uy/z3h2y3wPX6Bdfxk6g==}
-
- '@react-pdf/font@4.0.4':
- resolution: {integrity: sha512-8YtgGtL511txIEc9AjiilpZ7yjid8uCd8OGUl6jaL3LIHnrToUupSN4IzsMQpVTCMYiDLFnDNQzpZsOYtRS/Pg==}
-
- '@react-pdf/image@3.0.4':
- resolution: {integrity: sha512-z0ogVQE0bKqgXQ5smgzIU857rLV7bMgVdrYsu3UfXDDLSzI7QPvzf6MFTFllX6Dx2rcsF13E01dqKPtJEM799g==}
-
- '@react-pdf/layout@4.4.2':
- resolution: {integrity: sha512-gNu2oh8MiGR+NJZYTJ4c4q0nWCESBI6rKFiodVhE7OeVAjtzZzd6l65wsN7HXdWJqOZD3ttD97iE+tf5SOd/Yg==}
-
- '@react-pdf/pdfkit@4.1.0':
- resolution: {integrity: sha512-Wm/IOAv0h/U5Ra94c/PltFJGcpTUd/fwVMVeFD6X9tTTPCttIwg0teRG1Lqq617J8K4W7jpL/B0HTH0mjp3QpQ==}
-
- '@react-pdf/png-js@3.0.0':
- resolution: {integrity: sha512-eSJnEItZ37WPt6Qv5pncQDxLJRK15eaRwPT+gZoujP548CodenOVp49GST8XJvKMFt9YqIBzGBV/j9AgrOQzVA==}
-
- '@react-pdf/primitives@4.1.1':
- resolution: {integrity: sha512-IuhxYls1luJb7NUWy6q5avb1XrNaVj9bTNI40U9qGRuS6n7Hje/8H8Qi99Z9UKFV74bBP3DOf3L1wV2qZVgVrQ==}
-
- '@react-pdf/reconciler@2.0.0':
- resolution: {integrity: sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
-
- '@react-pdf/render@4.3.2':
- resolution: {integrity: sha512-el5KYM1sH/PKcO4tRCIm8/AIEmhtraaONbwCrBhFdehoGv6JtgnXiMxHGAvZbI5kEg051GbyP+XIU6f6YbOu6Q==}
-
- '@react-pdf/renderer@4.3.2':
- resolution: {integrity: sha512-EhPkj35gO9rXIyyx29W3j3axemvVY5RigMmlK4/6Ku0pXB8z9PEE/sz4ZBOShu2uot6V4xiCR3aG+t9IjJJlBQ==}
- peerDependencies:
- react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
-
- '@react-pdf/stylesheet@6.1.2':
- resolution: {integrity: sha512-E3ftGRYUQGKiN3JOgtGsLDo0hGekA6dmkmi/MYACytmPTKxQRBSO3126MebmCq+t1rgU9uRlREIEawJ+8nzSbw==}
-
- '@react-pdf/textkit@6.1.0':
- resolution: {integrity: sha512-sFlzDC9CDFrJsnL3B/+NHrk9+Advqk7iJZIStiYQDdskbow8GF/AGYrpIk+vWSnh35YxaGbHkqXq53XOxnyrjQ==}
-
- '@react-pdf/types@2.9.2':
- resolution: {integrity: sha512-dufvpKId9OajLLbgn9q7VLUmyo1Jf+iyGk2ZHmCL8nIDtL8N1Ejh9TH7+pXXrR0tdie1nmnEb5Bz9U7g4hI4/g==}
-
'@react-three/drei@10.7.7':
resolution: {integrity: sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==}
peerDependencies:
@@ -3438,9 +3383,6 @@ packages:
resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
- abs-svg-path@0.1.1:
- resolution: {integrity: sha512-d8XPSGjfyzlXC3Xx891DJRyZfqk5JU0BJrDQcsWomFIV1/BIzPW5HDH5iDdWpqWaav0YVIEzT1RHTwWr0FFshA==}
-
accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
@@ -3776,9 +3718,6 @@ packages:
brotli@1.3.3:
resolution: {integrity: sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==}
- browserify-zlib@0.2.0:
- resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==}
-
browserslist@4.28.2:
resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@@ -3973,9 +3912,6 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
- color-string@1.9.1:
- resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
-
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
@@ -4112,9 +4048,6 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
- crypto-js@4.2.0:
- resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
-
crypto-random-string@2.0.0:
resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==}
engines: {node: '>=8'}
@@ -4430,9 +4363,6 @@ packages:
electron-to-chromium@1.5.330:
resolution: {integrity: sha512-jFNydB5kFtYUobh4IkWUnXeyDbjf/r9gcUEXe1xcrcUxIGfTdzPXA+ld6zBRbwvgIGVzDll/LTIiDztEtckSnA==}
- emoji-regex-xs@1.0.0:
- resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==}
-
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
@@ -5170,12 +5100,6 @@ packages:
resolution: {integrity: sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ==}
engines: {node: '>= 6.0.0'}
- hsl-to-hex@1.0.0:
- resolution: {integrity: sha512-K6GVpucS5wFf44X0h2bLVRDsycgJmf9FF2elg+CrqD8GcFU8c6vYhgXn8NjUkFCwj+xDFb70qgLbTUm6sxwPmA==}
-
- hsl-to-rgb-for-reals@1.1.1:
- resolution: {integrity: sha512-LgOWAkrN0rFaQpfdWBQlv/VhkOxb5AsBjk6NQVx4yEzWS923T07X0M1Y0VNko2H52HeSpZrZNNMJ0aFqsdVzQg==}
-
html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -5257,9 +5181,6 @@ packages:
engines: {node: '>=18'}
hasBin: true
- hyphen@1.14.1:
- resolution: {integrity: sha512-kvL8xYl5QMTh+LwohVN72ciOxC0OEV79IPdJSTwEXok9y9QHebXGdFgrED4sWfiax/ODx++CAMk3hMy4XPJPOw==}
-
i18next-fs-backend@2.6.3:
resolution: {integrity: sha512-/5aW996nbolGh/qVU9urjAJ2QiXpiyGuhG8x/ZvvZQ3bMlrK+ouUdD6whS4e+sU9CCdUIzl7x/heEjsBh/3xtw==}
@@ -5393,9 +5314,6 @@ packages:
is-arrayish@0.2.1:
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
- is-arrayish@0.3.4:
- resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
-
is-async-function@2.1.1:
resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==}
engines: {node: '>= 0.4'}
@@ -5554,9 +5472,6 @@ packages:
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
engines: {node: '>=18'}
- is-url@1.2.4:
- resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
-
is-weakmap@2.0.2:
resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
engines: {node: '>= 0.4'}
@@ -5598,9 +5513,6 @@ packages:
jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
- jay-peg@1.1.1:
- resolution: {integrity: sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==}
-
jest-worker@27.5.1:
resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==}
engines: {node: '>= 10.13.0'}
@@ -6034,9 +5946,6 @@ packages:
mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
- media-engine@1.0.3:
- resolution: {integrity: sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==}
-
media-typer@0.3.0:
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
@@ -6383,9 +6292,6 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
- normalize-svg-path@1.1.0:
- resolution: {integrity: sha512-r9KHKG2UUeB5LoTouwDzBy2VxXlHsiM6fyLQvnJa0S5hrhzqElH/CH7TUGhT1fVvIYBIKf3OpY4YJ4CK+iaqHg==}
-
npm-run-path@4.0.1:
resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
engines: {node: '>=8'}
@@ -6532,9 +6438,6 @@ packages:
pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
- pako@1.0.11:
- resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
-
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -6553,9 +6456,6 @@ packages:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
- parse-svg-path@0.1.2:
- resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==}
-
parse5-htmlparser2-tree-adapter@7.1.0:
resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==}
@@ -6607,9 +6507,6 @@ packages:
pause-stream@0.0.11:
resolution: {integrity: sha512-e3FBlXLmN/D1S+zHzanP4E/4Z60oFAa3O051qt1pxa7DEJWKAyil6upYVXCWadEnuoqa4Pkc9oUx9zsxYeRv8A==}
- pdf-lib@1.17.1:
- resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==}
-
pdf-parse@2.4.5:
resolution: {integrity: sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==}
engines: {node: '>=20.16.0 <21 || >=22.3.0'}
@@ -6826,9 +6723,6 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
- queue@6.0.2:
- resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
-
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
@@ -7122,9 +7016,6 @@ packages:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
- scheduler@0.25.0-rc-603e6108-20241029:
- resolution: {integrity: sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==}
-
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -7222,9 +7113,6 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
- simple-swizzle@0.2.4:
- resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
-
sirv@2.0.4:
resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==}
engines: {node: '>= 10'}
@@ -7401,9 +7289,6 @@ packages:
resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==}
engines: {node: '>= 0.4'}
- string_decoder@1.3.0:
- resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
-
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
@@ -7489,9 +7374,6 @@ packages:
peerDependencies:
react: '>=17.0'
- svg-arc-to-cubic-bezier@3.2.0:
- resolution: {integrity: sha512-djbJ/vZKZO+gPoSDThGNpKDO+o+bAeA4XQKovvkNCqnIS2t+S4qnLAGQhyyrulhCFRl1WWzAp0wUDV8PpTVU3g==}
-
svg-to-pdfkit@0.1.8:
resolution: {integrity: sha512-QItiGZBy5TstGy+q8mjQTMGRlDDOARXLxH+sgVm1n/LYeo0zFcQlcCh8m4zi8QxctrxB9Kue/lStc/RD5iLadQ==}
@@ -7875,10 +7757,6 @@ packages:
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
- vite-compatible-readable-stream@3.6.1:
- resolution: {integrity: sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==}
- engines: {node: '>= 6'}
-
vite@8.0.3:
resolution: {integrity: sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -8217,9 +8095,6 @@ packages:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
- yoga-layout@3.2.1:
- resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
-
zod-validation-error@4.0.2:
resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==}
engines: {node: '>=18.0.0'}
@@ -9863,14 +9738,6 @@ snapshots:
legacy-javascript: 0.0.1
third-party-web: 0.29.2
- '@pdf-lib/standard-fonts@1.0.0':
- dependencies:
- pako: 1.0.11
-
- '@pdf-lib/upng@1.0.1':
- dependencies:
- pako: 1.0.11
-
'@pinojs/redact@0.4.0': {}
'@pkgjs/parseargs@0.11.0':
@@ -10150,107 +10017,6 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
- '@react-pdf/fns@3.1.2': {}
-
- '@react-pdf/font@4.0.4':
- dependencies:
- '@react-pdf/pdfkit': 4.1.0
- '@react-pdf/types': 2.9.2
- fontkit: 2.0.4
- is-url: 1.2.4
-
- '@react-pdf/image@3.0.4':
- dependencies:
- '@react-pdf/png-js': 3.0.0
- jay-peg: 1.1.1
-
- '@react-pdf/layout@4.4.2':
- dependencies:
- '@react-pdf/fns': 3.1.2
- '@react-pdf/image': 3.0.4
- '@react-pdf/primitives': 4.1.1
- '@react-pdf/stylesheet': 6.1.2
- '@react-pdf/textkit': 6.1.0
- '@react-pdf/types': 2.9.2
- emoji-regex-xs: 1.0.0
- queue: 6.0.2
- yoga-layout: 3.2.1
-
- '@react-pdf/pdfkit@4.1.0':
- dependencies:
- '@babel/runtime': 7.29.2
- '@react-pdf/png-js': 3.0.0
- browserify-zlib: 0.2.0
- crypto-js: 4.2.0
- fontkit: 2.0.4
- jay-peg: 1.1.1
- linebreak: 1.1.0
- vite-compatible-readable-stream: 3.6.1
-
- '@react-pdf/png-js@3.0.0':
- dependencies:
- browserify-zlib: 0.2.0
-
- '@react-pdf/primitives@4.1.1': {}
-
- '@react-pdf/reconciler@2.0.0(react@19.2.4)':
- dependencies:
- object-assign: 4.1.1
- react: 19.2.4
- scheduler: 0.25.0-rc-603e6108-20241029
-
- '@react-pdf/render@4.3.2':
- dependencies:
- '@babel/runtime': 7.29.2
- '@react-pdf/fns': 3.1.2
- '@react-pdf/primitives': 4.1.1
- '@react-pdf/textkit': 6.1.0
- '@react-pdf/types': 2.9.2
- abs-svg-path: 0.1.1
- color-string: 1.9.1
- normalize-svg-path: 1.1.0
- parse-svg-path: 0.1.2
- svg-arc-to-cubic-bezier: 3.2.0
-
- '@react-pdf/renderer@4.3.2(react@19.2.4)':
- dependencies:
- '@babel/runtime': 7.29.2
- '@react-pdf/fns': 3.1.2
- '@react-pdf/font': 4.0.4
- '@react-pdf/layout': 4.4.2
- '@react-pdf/pdfkit': 4.1.0
- '@react-pdf/primitives': 4.1.1
- '@react-pdf/reconciler': 2.0.0(react@19.2.4)
- '@react-pdf/render': 4.3.2
- '@react-pdf/types': 2.9.2
- events: 3.3.0
- object-assign: 4.1.1
- prop-types: 15.8.1
- queue: 6.0.2
- react: 19.2.4
-
- '@react-pdf/stylesheet@6.1.2':
- dependencies:
- '@react-pdf/fns': 3.1.2
- '@react-pdf/types': 2.9.2
- color-string: 1.9.1
- hsl-to-hex: 1.0.0
- media-engine: 1.0.3
- postcss-value-parser: 4.2.0
-
- '@react-pdf/textkit@6.1.0':
- dependencies:
- '@react-pdf/fns': 3.1.2
- bidi-js: 1.0.3
- hyphen: 1.14.1
- unicode-properties: 1.4.1
-
- '@react-pdf/types@2.9.2':
- dependencies:
- '@react-pdf/font': 4.0.4
- '@react-pdf/primitives': 4.1.1
- '@react-pdf/stylesheet': 6.1.2
-
'@react-three/drei@10.7.7(@react-three/fiber@9.5.0(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.183.2))(@types/react@19.2.14)(@types/three@0.183.1)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.183.2)':
dependencies:
'@babel/runtime': 7.29.2
@@ -11429,8 +11195,6 @@ snapshots:
abbrev@2.0.0: {}
- abs-svg-path@0.1.1: {}
-
accepts@1.3.8:
dependencies:
mime-types: 2.1.35
@@ -11751,10 +11515,6 @@ snapshots:
dependencies:
base64-js: 1.5.1
- browserify-zlib@0.2.0:
- dependencies:
- pako: 1.0.11
-
browserslist@4.28.2:
dependencies:
baseline-browser-mapping: 2.10.13
@@ -11969,11 +11729,6 @@ snapshots:
color-name@1.1.4: {}
- color-string@1.9.1:
- dependencies:
- color-name: 1.1.4
- simple-swizzle: 0.2.4
-
colorette@2.0.20: {}
combined-stream@1.0.8:
@@ -12120,8 +11875,6 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
- crypto-js@4.2.0: {}
-
crypto-random-string@2.0.0: {}
csp_evaluator@1.1.5: {}
@@ -12453,8 +12206,6 @@ snapshots:
electron-to-chromium@1.5.330: {}
- emoji-regex-xs@1.0.0: {}
-
emoji-regex@10.6.0: {}
emoji-regex@8.0.0: {}
@@ -13516,12 +13267,6 @@ snapshots:
hoopy@0.1.4: {}
- hsl-to-hex@1.0.0:
- dependencies:
- hsl-to-rgb-for-reals: 1.1.1
-
- hsl-to-rgb-for-reals@1.1.1: {}
-
html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0):
dependencies:
'@exodus/bytes': 1.15.0(@noble/hashes@1.8.0)
@@ -13620,8 +13365,6 @@ snapshots:
husky@9.1.7: {}
- hyphen@1.14.1: {}
-
i18next-fs-backend@2.6.3: {}
i18next@25.10.10(typescript@5.9.3):
@@ -13773,8 +13516,6 @@ snapshots:
is-arrayish@0.2.1: {}
- is-arrayish@0.3.4: {}
-
is-async-function@2.1.1:
dependencies:
async-function: 1.0.0
@@ -13912,8 +13653,6 @@ snapshots:
is-unicode-supported@2.1.0: {}
- is-url@1.2.4: {}
-
is-weakmap@2.0.2: {}
is-weakref@1.1.1:
@@ -13964,10 +13703,6 @@ snapshots:
optionalDependencies:
'@pkgjs/parseargs': 0.11.0
- jay-peg@1.1.1:
- dependencies:
- restructure: 3.0.2
-
jest-worker@27.5.1:
dependencies:
'@types/node': 22.19.15
@@ -14516,8 +14251,6 @@ snapshots:
mdn-data@2.27.1: {}
- media-engine@1.0.3: {}
-
media-typer@0.3.0: {}
meow@13.2.0: {}
@@ -14984,10 +14717,6 @@ snapshots:
normalize-path@3.0.0: {}
- normalize-svg-path@1.1.0:
- dependencies:
- svg-arc-to-cubic-bezier: 3.2.0
-
npm-run-path@4.0.1:
dependencies:
path-key: 3.1.1
@@ -15198,8 +14927,6 @@ snapshots:
pako@0.2.9: {}
- pako@1.0.11: {}
-
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -15227,8 +14954,6 @@ snapshots:
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
- parse-svg-path@0.1.2: {}
-
parse5-htmlparser2-tree-adapter@7.1.0:
dependencies:
domhandler: 5.0.3
@@ -15279,13 +15004,6 @@ snapshots:
dependencies:
through: 2.3.8
- pdf-lib@1.17.1:
- dependencies:
- '@pdf-lib/standard-fonts': 1.0.0
- '@pdf-lib/upng': 1.0.1
- pako: 1.0.11
- tslib: 1.14.1
-
pdf-parse@2.4.5:
dependencies:
'@napi-rs/canvas': 0.1.80
@@ -15535,10 +15253,6 @@ snapshots:
queue-microtask@1.2.3: {}
- queue@6.0.2:
- dependencies:
- inherits: 2.0.4
-
quick-format-unescaped@4.0.4: {}
range-parser@1.2.1: {}
@@ -15957,8 +15671,6 @@ snapshots:
dependencies:
xmlchars: 2.2.0
- scheduler@0.25.0-rc-603e6108-20241029: {}
-
scheduler@0.27.0: {}
schema-dts@1.1.5: {}
@@ -16111,10 +15823,6 @@ snapshots:
signal-exit@4.1.0: {}
- simple-swizzle@0.2.4:
- dependencies:
- is-arrayish: 0.3.4
-
sirv@2.0.4:
dependencies:
'@polka/url': 1.0.0-next.29
@@ -16351,10 +16059,6 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
- string_decoder@1.3.0:
- dependencies:
- safe-buffer: 5.2.1
-
stringify-entities@4.0.4:
dependencies:
character-entities-html4: 2.1.0
@@ -16425,8 +16129,6 @@ snapshots:
dependencies:
react: 19.2.4
- svg-arc-to-cubic-bezier@3.2.0: {}
-
svg-to-pdfkit@0.1.8:
dependencies:
pdfkit: 0.18.0
@@ -16895,12 +16597,6 @@ snapshots:
d3-time: 3.1.0
d3-timer: 3.0.1
- vite-compatible-readable-stream@3.6.1:
- dependencies:
- inherits: 2.0.4
- string_decoder: 1.3.0
- util-deprecate: 1.0.2
-
vite@8.0.3(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.15)(esbuild@0.27.4)(jiti@2.6.1)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
dependencies:
lightningcss: 1.32.0
@@ -17232,8 +16928,6 @@ snapshots:
yoctocolors@2.1.2: {}
- yoga-layout@3.2.1: {}
-
zod-validation-error@4.0.2(zod@3.25.76):
dependencies:
zod: 3.25.76
diff --git a/scripts/generate-brochure.ts b/scripts/generate-brochure.ts
deleted file mode 100644
index fc2acf080..000000000
--- a/scripts/generate-brochure.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-#!/usr/bin/env ts-node
-/**
- * Master Brochure Generator
- *
- * Generates a multi-page PDF brochure containing all products.
- * Combines Payload CMS marketing text with Excel technical data.
- */
-
-import * as fs from 'fs';
-import * as path from 'path';
-import * as React from 'react';
-import { renderToBuffer } from '@react-pdf/renderer';
-import { getAllProducts } from '../lib/products';
-import { getExcelTechnicalDataForProduct, preloadExcelData } from '../lib/excel-products';
-import { PDFBrochure } from '../lib/pdf-brochure';
-import { ProductData as PDFProductData } from '../lib/pdf-datasheet';
-
-const CONFIG = {
- outputDir: path.join(process.cwd(), 'public/downloads'),
- locales: ['en', 'de'] as const,
-} as const;
-
-async function ensureOutputDir() {
- if (!fs.existsSync(CONFIG.outputDir)) {
- fs.mkdirSync(CONFIG.outputDir, { recursive: true });
- }
-}
-
-async function generateBrochure(locale: 'en' | 'de') {
- console.log(`\nGenerating brochure for locale: ${locale.toUpperCase()}...`);
-
- // 1. Load data
- const products = await getAllProducts(locale);
- console.log(`- Loaded ${products.length} products from Payload`);
-
- // 2. Map and Enrich
- const pdfProducts: PDFProductData[] = products.map((p, index) => {
- const excelData = getExcelTechnicalDataForProduct({
- slug: p.slug,
- sku: p.frontmatter.sku,
- name: p.frontmatter.title,
- });
-
- return {
- id: index + 1,
- name: p.frontmatter.title,
- sku: p.frontmatter.sku,
- shortDescriptionHtml: p.frontmatter.description,
- descriptionHtml: p.frontmatter.description,
- applicationHtml: '',
- featuredImage: p.frontmatter.images[0] || null,
- images: p.frontmatter.images,
- categories: p.frontmatter.categories.map(name => ({ name })),
- attributes: excelData?.attributes || [],
- };
- });
-
- if (pdfProducts.length === 0) {
- console.warn(`! No products found for ${locale}. Skipping.`);
- return;
- }
-
- // 3. Render
- console.log(`- Rendering PDF with ${pdfProducts.length} pages...`);
- const buffer = await renderToBuffer(
- React.createElement(PDFBrochure, {
- products: pdfProducts,
- locale,
- title: locale === 'de' ? 'Produktkatalog' : 'Product Catalog',
- subtitle: '2026',
- })
- );
-
- // 4. Save
- const fileName = `KLZ_Cables_Brochure_2026_${locale.toUpperCase()}.pdf`;
- const filePath = path.join(CONFIG.outputDir, fileName);
- fs.writeFileSync(filePath, buffer);
-
- const stats = fs.statSync(filePath);
- console.log(`β Generated: ${fileName} (${(stats.size / 1024 / 1024).toFixed(2)} MB)`);
-}
-
-async function main() {
- const start = Date.now();
- console.log('--- KLZ Brochure Generator ---');
-
- try {
- await ensureOutputDir();
- preloadExcelData();
-
- for (const locale of CONFIG.locales) {
- await generateBrochure(locale);
- }
-
- console.log(`\nβ
Brochure generation completed in ${((Date.now() - start) / 1000).toFixed(2)}s`);
- } catch (error) {
- console.error('\nβ Fatal error:', error);
- process.exit(1);
- }
-}
-
-main().catch(console.error);
diff --git a/scripts/generate-pdf-datasheets-pdf-lib.ts b/scripts/generate-pdf-datasheets-pdf-lib.ts
deleted file mode 100644
index 08ecd7c5c..000000000
--- a/scripts/generate-pdf-datasheets-pdf-lib.ts
+++ /dev/null
@@ -1,4106 +0,0 @@
-#!/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;
-
-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;
-type ExcelMatch = { rows: ExcelRow[]; units: Record };
-let EXCEL_INDEX: Map | null = null;
-
-type KeyValueItem = { label: string; value: string; unit?: string };
-type VoltageTableModel = {
- voltageLabel: string;
- metaItems: KeyValueItem[];
- crossSections: string[];
- columns: Array<{ key: string; label: string; get: (rowIndex: number) => string }>;
-};
-
-function estimateDenseMetaGridHeight(itemsCount: number): number {
- // Must stay in sync with the layout constants in `drawDenseMetaGrid()`.
- const cols = itemsCount >= 7 ? 3 : 2;
- const cellH = 34;
- const titleH = 18;
- const headerPadY = 10;
- const rows = Math.ceil(Math.max(0, itemsCount) / cols);
- const boxH = headerPadY + titleH + rows * cellH + headerPadY;
- // `drawDenseMetaGrid()` returns the cursor below the box with additional spacing.
- return boxH + 18;
-}
-
-function normalizeUnit(unitRaw: string): string {
- const u = normalizeValue(unitRaw);
- if (!u) return '';
- // Temperature units: show Β°C (not plain C).
- if (/^c$/i.test(u) || /^Β°c$/i.test(u)) return 'Β°C';
- // Common WinAnsi-safe normalizations.
- return u
- .replace(/Ξ©/gi, 'Ohm')
- .replace(/[\u00B5\u03BC]/g, 'u');
-}
-
-function denseAbbrevLabel(args: {
- key: string;
- locale: 'en' | 'de';
- unit?: string;
- withUnit?: boolean;
-}): string {
- const u = normalizeUnit(args.unit || '');
- const withUnit = args.withUnit ?? true;
- const unitSafe = u
- .replace(/Ξ©/gi, 'Ohm')
- .replace(/[\u00B5\u03BC]/g, 'u');
- const suffix = withUnit && unitSafe ? ` [${unitSafe}]` : '';
-
- switch (args.key) {
- case 'DI':
- case 'RI':
- case 'Wi':
- case 'Ibl':
- case 'Ibe':
- case 'Wm':
- case 'Rbv':
- case 'Fzv':
- case 'G':
- return `${args.key}${suffix}`;
- case 'Ik_cond':
- return `Ik${suffix}`;
- case 'Ik_screen':
- return `Ik_s${suffix}`;
- case 'Γ':
- return `Γ${suffix}`;
- case 'Cond':
- return args.locale === 'de' ? 'Leiter' : 'Cond.';
- case 'shape':
- return args.locale === 'de' ? 'Form' : 'Shape';
- // Electrical
- case 'cap':
- return `C${suffix}`;
- case 'X':
- return `X${suffix}`;
- // Temperatures
- case 'temp_range':
- return `T${suffix}`;
- case 'max_op_temp':
- return `T_op${suffix}`;
- case 'max_sc_temp':
- return `T_sc${suffix}`;
- case 'min_store_temp':
- return `T_st${suffix}`;
- case 'min_lay_temp':
- return `T_lay${suffix}`;
- // Compliance
- case 'cpr':
- return `CPR${suffix}`;
- case 'flame':
- return `FR${suffix}`;
- // Voltages
- case 'test_volt':
- return `U_test${suffix}`;
- case 'rated_volt':
- return `U0/U${suffix}`;
- default:
- return args.key || '';
- }
-}
-
-function metaFullLabel(args: { key: string; excelKey: string; locale: 'en' | 'de' }): string {
- const key = normalizeValue(args.key);
- if (args.locale === 'de') {
- switch (key) {
- case 'test_volt':
- return 'PrΓΌfspannung';
- case 'temp_range':
- return 'Temperaturbereich';
- case 'max_op_temp':
- return 'Leitertemperatur (max.)';
- case 'max_sc_temp':
- return 'Kurzschlusstemperatur (max.)';
- case 'min_lay_temp':
- return 'Minimale Verlegetemperatur';
- case 'min_store_temp':
- return 'Minimale Lagertemperatur';
- case 'cpr':
- return 'CPR-Klasse';
- case 'flame':
- return 'Flammhemmend';
- default:
- return formatExcelHeaderLabel(args.excelKey);
- }
- }
-
- // EN
- switch (key) {
- case 'test_volt':
- return 'Test voltage';
- case 'temp_range':
- return 'Operating temperature range';
- case 'max_op_temp':
- return 'Conductor temperature (max.)';
- case 'max_sc_temp':
- return 'Short-circuit temperature (max.)';
- case 'min_lay_temp':
- return 'Minimum laying temperature';
- case 'min_store_temp':
- return 'Minimum storage temperature';
- case 'cpr':
- return 'CPR class';
- case 'flame':
- return 'Flame retardant';
- default:
- return formatExcelHeaderLabel(args.excelKey);
- }
-}
-
-function expandMetaLabel(label: string, locale: 'en' | 'de'): string {
- const l = normalizeValue(label);
- if (!l) return '';
-
- // Safety net: the voltage-group meta grid must never show abbreviated labels.
- // (Even if upstream mapping changes, we keep customer-facing readability.)
- const mapDe: Record = {
- U_test: 'PrΓΌfspannung',
- 'U0/U': 'Nennspannung',
- 'U_0/U': 'Nennspannung',
- T: 'Temperaturbereich',
- T_op: 'Leitertemperatur (max.)',
- T_sc: 'Kurzschlusstemperatur (max.)',
- T_lay: 'Minimale Verlegetemperatur',
- T_st: 'Minimale Lagertemperatur',
- CPR: 'CPR-Klasse',
- FR: 'Flammhemmend',
- };
-
- const mapEn: Record = {
- U_test: 'Test voltage',
- 'U0/U': 'Rated voltage',
- 'U_0/U': 'Rated voltage',
- T: 'Operating temperature range',
- T_op: 'Conductor temperature (max.)',
- T_sc: 'Short-circuit temperature (max.)',
- T_lay: 'Minimum laying temperature',
- T_st: 'Minimum storage temperature',
- CPR: 'CPR class',
- FR: 'Flame retardant',
- };
-
- const mapped = (locale === 'de' ? mapDe[l] : mapEn[l]) || '';
- return mapped || label;
-}
-
-function technicalFullLabel(args: { key: string; excelKey: string; locale: 'en' | 'de' }): string {
- const k = normalizeValue(args.key);
-
- if (args.locale === 'de') {
- // Prefer stable internal keys (from columnMapping.key) to translate Technical Data labels.
- switch (k) {
- case 'DI':
- return 'Durchmesser ΓΌber Isolierung';
- case 'RI':
- return 'DC-Leiterwiderstand (20 Β°C)';
- case 'Wi':
- return 'Isolationsdicke';
- case 'Ibl':
- return 'Strombelastbarkeit in Luft (trefoil)';
- case 'Ibe':
- return 'Strombelastbarkeit im Erdreich (trefoil)';
- case 'Ik_cond':
- return 'Kurzschlussstrom Leiter';
- case 'Ik_screen':
- return 'Kurzschlussstrom Schirm';
- case 'Wm':
- return 'Manteldicke';
- case 'Rbv':
- return 'Biegeradius (min.)';
- case 'Γ':
- return 'AuΓen-Γ';
- case 'Fzv':
- return 'Zugkraft (max.)';
- case 'G':
- return 'Gewicht';
- case 'Cond':
- case 'conductor':
- return 'Leiter';
- case 'shape':
- return 'Leiterform';
- case 'insulation':
- return 'Isolierung';
- case 'sheath':
- return 'Mantel';
- case 'cap':
- return 'KapazitΓ€t';
- case 'ind_trefoil':
- return 'InduktivitΓ€t (trefoil)';
- case 'ind_air_flat':
- return 'InduktivitΓ€t (Luft, flach)';
- case 'ind_ground_flat':
- return 'InduktivitΓ€t (Erdreich, flach)';
- case 'X':
- return 'Reaktanz';
- case 'test_volt':
- return 'PrΓΌfspannung';
- case 'rated_volt':
- return 'Nennspannung';
- case 'temp_range':
- return 'Temperaturbereich';
- case 'max_op_temp':
- return 'Leitertemperatur (max.)';
- case 'max_sc_temp':
- return 'Kurzschlusstemperatur (max.)';
- case 'min_store_temp':
- return 'Minimale Lagertemperatur';
- case 'min_lay_temp':
- return 'Minimale Verlegetemperatur';
- case 'cpr':
- return 'CPR-Klasse';
- case 'flame':
- return 'Flammhemmend';
- case 'packaging':
- return 'Verpackung';
- case 'ce':
- return 'CE-KonformitΓ€t';
- case 'norm':
- return 'Norm';
- case 'standard':
- return 'Standard';
- case 'D_screen':
- return 'Durchmesser ΓΌber Schirm';
- case 'S_screen':
- return 'Metallischer Schirm';
- default:
- break;
- }
-
- // Fallback: best-effort translation from the raw Excel header (prevents English in DE PDFs).
- const raw = normalizeValue(args.excelKey);
- if (!raw) return '';
- return raw
- .replace(/\(approx\.?\)/gi, '(ca.)')
- .replace(/\bcapacitance\b/gi, 'KapazitΓ€t')
- .replace(/\binductance\b/gi, 'InduktivitΓ€t')
- .replace(/\breactance\b/gi, 'Reaktanz')
- .replace(/\btest voltage\b/gi, 'PrΓΌfspannung')
- .replace(/\brated voltage\b/gi, 'Nennspannung')
- .replace(/\boperating temperature range\b/gi, 'Temperaturbereich')
- .replace(/\bminimum sheath thickness\b/gi, 'Manteldicke (min.)')
- .replace(/\bsheath thickness\b/gi, 'Manteldicke')
- .replace(/\bnominal insulation thickness\b/gi, 'Isolationsdicke (nom.)')
- .replace(/\binsulation thickness\b/gi, 'Isolationsdicke')
- .replace(/\bdc resistance at 20\s*Β°?c\b/gi, 'DC-Leiterwiderstand (20 Β°C)')
- .replace(/\bouter diameter(?: of cable)?\b/gi, 'AuΓen-Γ')
- .replace(/\bbending radius\b/gi, 'Biegeradius')
- .replace(/\bpackaging\b/gi, 'Verpackung')
- .replace(/\bce\s*-?conformity\b/gi, 'CE-KonformitΓ€t');
- }
-
- // EN: keep as-is (Excel headers are already English).
- return normalizeValue(args.excelKey);
-}
-
-function compactNumericForLocale(value: string, locale: 'en' | 'de'): string {
- const v = normalizeValue(value);
- if (!v) return '';
-
- // Handle special text patterns like "15xD (Single core); 12xD (Multi core)"
- // Compact to "15/12xD" or "10xD" for single values
- if (/\d+xD/.test(v)) {
- // Extract all number+xD patterns
- const numbers = [];
- const matches = Array.from(v.matchAll(/(\d+)xD/g));
- for (let i = 0; i < matches.length; i++) numbers.push(matches[i][1]);
- if (numbers.length > 0) {
- // Remove duplicates while preserving order
- const unique: string[] = [];
- for (const num of numbers) {
- if (!unique.includes(num)) {
- unique.push(num);
- }
- }
- return unique.join('/') + 'xD';
- }
- }
-
- // Normalize decimals for the target locale if it looks numeric-ish.
- const hasDigit = /\d/.test(v);
- if (!hasDigit) return v;
- const trimmed = v.replace(/\s+/g, ' ').trim();
- // Keep ranges like "1.2β3.4" or "1.2-3.4".
- const parts = trimmed.split(/(β|-)/);
- const out = parts.map(p => {
- if (p === 'β' || p === '-') return p;
- const s = p.trim();
- if (!/^-?\d+(?:[\.,]\d+)?$/.test(s)) return p;
- const n = s.replace(/,/g, '.');
-
- // Datasheets: do NOT use k/M suffixes (can be misleading for engineering values).
- // Only trim trailing zeros for compactness.
- const compact = n
- .replace(/\.0+$/, '')
- .replace(/(\.\d*?)0+$/, '$1')
- .replace(/\.$/, '');
-
- // Preserve leading "+" when present (typical for temperature cells like "+90").
- const hadPlus = /^\+/.test(s);
- const withPlus = hadPlus && !/^\+/.test(compact) ? `+${compact}` : compact;
- return locale === 'de' ? withPlus.replace(/\./g, ',') : withPlus;
- });
- return out.join('');
-}
-
-function compactCellForDenseTable(value: string, unit: string | undefined, locale: 'en' | 'de'): string {
- let v = normalizeValue(value);
- if (!v) return '';
- const u = normalizeValue(unit || '');
- if (u) {
- // Remove unit occurrences from the cell value (unit is already in the header).
- const esc = u.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
- v = v.replace(new RegExp(`\\s*${esc}\\b`, 'ig'), '').trim();
- // Common composite units appear in the data cells too; strip them.
- v = v
- .replace(/\bkg\s*\/\s*km\b/gi, '')
- .replace(/\bohm\s*\/\s*km\b/gi, '')
- .replace(/\bΞ©\s*\/\s*km\b/gi, '')
- .replace(/\bu\s*f\s*\/\s*km\b/gi, '')
- .replace(/\bmh\s*\/\s*km\b/gi, '')
- .replace(/\bkA\b/gi, '')
- .replace(/\bmm\b/gi, '')
- .replace(/\bkv\b/gi, '')
- .replace(/\bΒ°?c\b/gi, '')
- .replace(/\s+/g, ' ')
- .trim();
- }
-
- // Normalize common separators to compact but readable tokens.
- // Example: "-35 - +90" -> "-35-+90", "-40Β°C / +90Β°C" -> "-40/+90".
- v = v
- .replace(/\s*β\s*/g, '-')
- .replace(/\s*-\s*/g, '-')
- .replace(/\s*\/\s*/g, '/')
- .replace(/\s+/g, ' ')
- .trim();
-
- return compactNumericForLocale(v, locale);
-}
-
-function normalizeVoltageLabel(raw: string): string {
- const v = normalizeValue(raw);
- if (!v) return '';
- // Keep common MV/HV notation like "6/10" or "12/20".
- const cleaned = v.replace(/\s+/g, ' ');
- if (/\bkv\b/i.test(cleaned)) return cleaned.replace(/\bkv\b/i, 'kV');
- // If purely numeric-ish (e.g. "20"), add unit.
- const num = cleaned.match(/\d+(?:[\.,]\d+)?(?:\s*\/\s*\d+(?:[\.,]\d+)?)?/);
- if (!num) return cleaned;
- // If the string already contains other words, keep as-is.
- if (/[a-z]/i.test(cleaned)) return cleaned;
- return `${cleaned} kV`;
-}
-
-function parseVoltageSortKey(voltageLabel: string): number {
- const v = normalizeVoltageLabel(voltageLabel);
- // Sort by the last number (e.g. 6/10 -> 10, 12/20 -> 20)
- const nums = v
- .replace(/,/g, '.')
- .match(/\d+(?:\.\d+)?/g)
- ?.map(n => Number(n))
- .filter(n => Number.isFinite(n));
- if (!nums || nums.length === 0) return Number.POSITIVE_INFINITY;
- return nums[nums.length - 1];
-}
-
-function formatExcelHeaderLabel(key: string, unit?: string): string {
- const k = normalizeValue(key);
- if (!k) return '';
- const u = normalizeValue(unit || '');
-
- // Prefer compact but clear labels.
- const compact = k
- .replace(/\s*\(approx\.?\)\s*/gi, ' (approx.) ')
- .replace(/\s+/g, ' ')
- .trim();
-
- if (!u) return compact;
- // Avoid double units like "(mm) mm".
- if (new RegExp(`\\(${u.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')}\\)`, 'i').test(compact)) return compact;
- return `${compact} (${u})`;
-}
-
-function formatExcelCellValue(value: string, unit?: string, opts?: { appendUnit?: boolean }): string {
- const v = normalizeValue(value);
- if (!v) return '';
- const u = normalizeValue(unit || '');
- if (!u) return v;
- const appendUnit = opts?.appendUnit ?? true;
- // Only auto-append unit for pure numbers; many Excel cells already contain units.
- if (!appendUnit) return v;
- return looksNumeric(v) ? `${v} ${u}` : v;
-}
-
-function buildExcelModel(args: {
- product: ProductData;
- locale: 'en' | 'de';
-}): { ok: boolean; technicalItems: KeyValueItem[]; voltageTables: VoltageTableModel[] } {
- const match = findExcelForProduct(args.product);
- if (!match || match.rows.length === 0) return { ok: false, technicalItems: [], voltageTables: [] };
-
- const units = match.units || {};
-
- // Filter rows to only include compatible column structures
- // This handles products that exist in multiple Excel files with different column structures
- const rows = match.rows;
-
- // Find the row with most columns as sample
- let sample = rows.find(r => r && Object.keys(r).length > 0) || {};
- let maxColumns = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units').length;
-
- for (const r of rows) {
- const cols = Object.keys(r).filter(k => k && k !== 'Part Number' && k !== 'Units').length;
- if (cols > maxColumns) {
- sample = r;
- maxColumns = cols;
- }
- }
-
- // Map Excel column names to our compact/standardized keys.
- // This mapping covers all common Excel column names found in the source files
- // IMPORTANT: More specific patterns must come before generic ones to avoid false matches
- const columnMapping: Record = {
- // Cross-section and voltage (these are used for grouping, not shown in table) - MUST COME FIRST
- 'number of cores and cross-section': { header: 'Cross-section', unit: '', key: 'cross_section' },
- 'ross section conductor': { header: 'Cross-section', unit: '', key: 'cross_section' },
-
- // The 13 required headers (exact order as specified)
- 'diameter over insulation': { header: 'DI', unit: 'mm', key: 'DI' },
- 'diameter over insulation (approx.)': { header: 'DI', unit: 'mm', key: 'DI' },
- 'dc resistance at 20 Β°C': { header: 'RI', unit: 'Ohm/km', key: 'RI' },
- 'dc resistance at 20Β°C': { header: 'RI', unit: 'Ohm/km', key: 'RI' },
- 'resistance conductor': { header: 'RI', unit: 'Ohm/km', key: 'RI' },
- 'maximum resistance of conductor': { header: 'RI', unit: 'Ohm/km', key: 'RI' },
- 'insulation thickness': { header: 'Wi', unit: 'mm', key: 'Wi' },
- 'nominal insulation thickness': { header: 'Wi', unit: 'mm', key: 'Wi' },
- 'current ratings in air, trefoil': { header: 'Ibl', unit: 'A', key: 'Ibl' },
- 'current ratings in air, trefoil*': { header: 'Ibl', unit: 'A', key: 'Ibl' },
- 'current ratings in ground, trefoil': { header: 'Ibe', unit: 'A', key: 'Ibe' },
- 'current ratings in ground, trefoil*': { header: 'Ibe', unit: 'A', key: 'Ibe' },
- 'conductor shortcircuit current': { header: 'Ik', unit: 'kA', key: 'Ik_cond' },
- 'screen shortcircuit current': { header: 'Ik', unit: 'kA', key: 'Ik_screen' },
- 'sheath thickness': { header: 'Wm', unit: 'mm', key: 'Wm' },
- 'minimum sheath thickness': { header: 'Wm', unit: 'mm', key: 'Wm' },
- 'nominal sheath thickness': { header: 'Wm', unit: 'mm', key: 'Wm' },
- 'bending radius': { header: 'Rbv', unit: 'mm', key: 'Rbv' },
- 'bending radius (min.)': { header: 'Rbv', unit: 'mm', key: 'Rbv' },
- 'outer diameter': { header: 'Γ', unit: 'mm', key: 'Γ' },
- 'outer diameter (approx.)': { header: 'Γ', unit: 'mm', key: 'Γ' },
- 'outer diameter of cable': { header: 'Γ', unit: 'mm', key: 'Γ' },
- // Pulling force is typically expressed in N in datasheets.
- // Some sources appear to store values in kN (e.g. 4.5), we normalize in the cell getter.
- 'pulling force': { header: 'Fzv', unit: 'N', key: 'Fzv' },
- 'max. pulling force': { header: 'Fzv', unit: 'N', key: 'Fzv' },
- // Conductor material (we render this as a single column)
- 'conductor aluminum': { header: 'Cond.', unit: '', key: 'Cond' },
- 'conductor copper': { header: 'Cond.', unit: '', key: 'Cond' },
- 'weight': { header: 'G', unit: 'kg/km', key: 'G' },
- 'weight (approx.)': { header: 'G', unit: 'kg/km', key: 'G' },
- 'cable weight': { header: 'G', unit: 'kg/km', key: 'G' },
-
- // Additional technical columns (to include ALL Excel data)
- // Specific material/property columns must come before generic 'conductor' pattern
- 'conductor diameter (approx.)': { header: 'Conductor diameter', unit: 'mm', key: 'cond_diam' },
- 'conductor diameter': { header: 'Conductor diameter', unit: 'mm', key: 'cond_diam' },
- 'diameter conductor': { header: 'Conductor diameter', unit: 'mm', key: 'cond_diam' },
- 'diameter over screen': { header: 'Diameter over screen', unit: 'mm', key: 'D_screen' },
- 'metallic screen mm2': { header: 'Metallic screen', unit: 'mm2', key: 'S_screen' },
- 'metallic screen': { header: 'Metallic screen', unit: 'mm2', key: 'S_screen' },
- 'reactance': { header: 'Reactance', unit: 'Ohm/km', key: 'X' },
- 'capacitance (approx.)': { header: 'Capacitance', unit: 'uF/km', key: 'cap' },
- 'capacitance': { header: 'Capacitance', unit: 'uF/km', key: 'cap' },
- 'inductance, trefoil (approx.)': { header: 'Inductance trefoil', unit: 'mH/km', key: 'ind_trefoil' },
- 'inductance, trefoil': { header: 'Inductance trefoil', unit: 'mH/km', key: 'ind_trefoil' },
- 'inductance in air, flat (approx.)': { header: 'Inductance air flat', unit: 'mH/km', key: 'ind_air_flat' },
- 'inductance in air, flat': { header: 'Inductance air flat', unit: 'mH/km', key: 'ind_air_flat' },
- 'inductance in ground, flat (approx.)': { header: 'Inductance ground flat', unit: 'mH/km', key: 'ind_ground_flat' },
- 'inductance in ground, flat': { header: 'Inductance ground flat', unit: 'mH/km', key: 'ind_ground_flat' },
- 'current ratings in air, flat': { header: 'Current air flat', unit: 'A', key: 'cur_air_flat' },
- 'current ratings in air, flat*': { header: 'Current air flat', unit: 'A', key: 'cur_air_flat' },
- 'current ratings in ground, flat': { header: 'Current ground flat', unit: 'A', key: 'cur_ground_flat' },
- 'current ratings in ground, flat*': { header: 'Current ground flat', unit: 'A', key: 'cur_ground_flat' },
- 'heating time constant, trefoil*': { header: 'Heating time trefoil', unit: 's', key: 'heat_trefoil' },
- 'heating time constant, trefoil': { header: 'Heating time trefoil', unit: 's', key: 'heat_trefoil' },
- 'heating time constant, flat*': { header: 'Heating time flat', unit: 's', key: 'heat_flat' },
- 'heating time constant, flat': { header: 'Heating time flat', unit: 's', key: 'heat_flat' },
-
- // Temperature and other technical data
- 'maximal operating conductor temperature': { header: 'Max operating temp', unit: 'Β°C', key: 'max_op_temp' },
- 'maximal short-circuit temperature': { header: 'Max short-circuit temp', unit: 'Β°C', key: 'max_sc_temp' },
- 'operating temperature range': { header: 'Operating temp range', unit: 'Β°C', key: 'temp_range' },
- 'minimal storage temperature': { header: 'Min storage temp', unit: 'Β°C', key: 'min_store_temp' },
- 'minimal temperature for laying': { header: 'Min laying temp', unit: 'Β°C', key: 'min_lay_temp' },
- 'test voltage': { header: 'Test voltage', unit: 'kV', key: 'test_volt' },
- 'rated voltage': { header: 'Rated voltage', unit: 'kV', key: 'rated_volt' },
-
- // Material and specification data
- // Note: More specific patterns must come before generic ones to avoid conflicts
-
- // Conductor description/type (keep as technical/meta data, not as material column)
- 'conductor': { header: 'Conductor', unit: '', key: 'conductor' },
-
- // Screen and tape columns
- 'copper wire screen and tape': { header: 'Copper screen', unit: '', key: 'copper_screen' },
- 'CUScreen': { header: 'Copper screen', unit: '', key: 'copper_screen' },
- 'conductive tape below screen': { header: 'Conductive tape below', unit: '', key: 'tape_below' },
- 'non conducting tape above screen': { header: 'Non-conductive tape above', unit: '', key: 'tape_above' },
- 'al foil': { header: 'Al foil', unit: '', key: 'al_foil' },
-
- // Material properties
- 'shape of conductor': { header: 'Conductor shape', unit: '', key: 'shape' },
- 'colour of insulation': { header: 'Insulation color', unit: '', key: 'color_ins' },
- 'colour of sheath': { header: 'Sheath color', unit: '', key: 'color_sheath' },
- 'insulation': { header: 'Insulation', unit: '', key: 'insulation' },
- 'sheath': { header: 'Sheath', unit: '', key: 'sheath' },
- 'norm': { header: 'Norm', unit: '', key: 'norm' },
- 'standard': { header: 'Standard', unit: '', key: 'standard' },
- 'cpr class': { header: 'CPR class', unit: '', key: 'cpr' },
- 'flame retardant': { header: 'Flame retardant', unit: '', key: 'flame' },
- 'self-extinguishing of single cable': { header: 'Flame retardant', unit: '', key: 'flame' },
- 'packaging': { header: 'Packaging', unit: '', key: 'packaging' },
- 'ce-conformity': { header: 'CE conformity', unit: '', key: 'ce' },
- 'rohs/reach': { header: 'RoHS/REACH', unit: '', key: 'rohs_reach' },
- };
-
- // Get all Excel keys from sample
- const excelKeys = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units');
-
- // Find which Excel keys match our mapping
- const matchedColumns: Array<{ excelKey: string; mapping: { header: string; unit: string; key: string } }> = [];
- for (const excelKey of excelKeys) {
- const normalized = normalizeValue(excelKey).toLowerCase();
- for (const [pattern, mapping] of Object.entries(columnMapping)) {
- if (normalized === pattern.toLowerCase() || new RegExp(pattern, 'i').test(normalized)) {
- matchedColumns.push({ excelKey, mapping });
- break;
- }
- }
- }
-
- // Deduplicate by mapping.key to avoid duplicate columns
- const seenKeys = new Set();
- const deduplicated: typeof matchedColumns = [];
- for (const item of matchedColumns) {
- if (!seenKeys.has(item.mapping.key)) {
- seenKeys.add(item.mapping.key);
- deduplicated.push(item);
- }
- }
- matchedColumns.length = 0;
- matchedColumns.push(...deduplicated);
-
- // Separate into 13 required headers vs additional columns
- const requiredHeaderKeys = ['DI', 'RI', 'Wi', 'Ibl', 'Ibe', 'Ik_cond', 'Wm', 'Rbv', 'Γ', 'Fzv', 'Cond', 'G'];
- const isRequiredHeader = (key: string) => requiredHeaderKeys.includes(key);
-
- // Filter rows to only include those with the same column structure as sample
- const sampleKeys = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units').sort();
- const compatibleRows = rows.filter(r => {
- const rKeys = Object.keys(r).filter(k => k && k !== 'Part Number' && k !== 'Units').sort();
- return JSON.stringify(rKeys) === JSON.stringify(sampleKeys);
- });
-
- if (compatibleRows.length === 0) return { ok: false, technicalItems: [], voltageTables: [] };
-
- const csKey =
- guessColumnKey(sample, [/number of cores and cross-section/i, /cross.?section/i, /ross section conductor/i]) || null;
- const voltageKey =
- guessColumnKey(sample, [/rated voltage/i, /voltage rating/i, /nennspannung/i, /spannungs/i]) || null;
-
- if (!csKey) return { ok: false, technicalItems: [], voltageTables: [] };
-
- // Group rows by voltage rating
- const byVoltage = new Map();
- for (let i = 0; i < compatibleRows.length; i++) {
- const cs = normalizeValue(String(compatibleRows[i]?.[csKey] ?? ''));
- if (!cs) continue;
- const rawV = voltageKey ? normalizeValue(String(compatibleRows[i]?.[voltageKey] ?? '')) : '';
- const voltageLabel = normalizeVoltageLabel(rawV || '');
- const key = voltageLabel || (args.locale === 'de' ? 'Spannung unbekannt' : 'Voltage unknown');
- const arr = byVoltage.get(key) ?? [];
- arr.push(i);
- byVoltage.set(key, arr);
- }
-
- const voltageTables: VoltageTableModel[] = [];
- const technicalItems: KeyValueItem[] = [];
-
- const voltageKeysSorted = Array.from(byVoltage.keys()).sort((a, b) => {
- const na = parseVoltageSortKey(a);
- const nb = parseVoltageSortKey(b);
- if (na !== nb) return na - nb;
- return a.localeCompare(b);
- });
-
- // Track which columns are constant across ALL voltage groups (global constants)
- const globalConstantColumns = new Set();
-
- // First pass: identify columns that are constant across all rows
- for (const { excelKey, mapping } of matchedColumns) {
- const values = compatibleRows.map(r => normalizeValue(String(r?.[excelKey] ?? ''))).filter(Boolean);
- const unique = Array.from(new Set(values.map(v => v.toLowerCase())));
-
- if (unique.length === 1 && values.length > 0) {
- globalConstantColumns.add(excelKey);
-
- // Global constants belong to TECHNICAL DATA (shown once).
- const unit = normalizeUnit(units[excelKey] || mapping.unit || '');
- const labelBase = technicalFullLabel({ key: mapping.key, excelKey, locale: args.locale });
- const label = formatExcelHeaderLabel(labelBase, unit);
- const value = compactCellForDenseTable(values[0], unit, args.locale);
- const existing = technicalItems.find(t => t.label === label);
- if (!existing) technicalItems.push({ label, value, unit });
- }
- }
-
- // Second pass: for each voltage group, separate constant vs variable columns
- for (const vKey of voltageKeysSorted) {
- const indices = byVoltage.get(vKey) || [];
- if (!indices.length) continue;
-
- const crossSections = indices.map(idx => normalizeValue(String(compatibleRows[idx]?.[csKey] ?? '')));
-
- // Meta items: keep a consistent, compact set across products.
- // This is the "voltage-group meta header" (parameter block) above each table.
- const metaItems: KeyValueItem[] = [];
- const metaCandidates = new Map();
- if (voltageKey) {
- const rawV = normalizeValue(String(compatibleRows[indices[0]]?.[voltageKey] ?? ''));
- metaItems.push({
- label: args.locale === 'de' ? 'Spannung' : 'Voltage',
- value: normalizeVoltageLabel(rawV || ''),
- });
- }
-
- // Which non-table fields we want to show consistently per voltage group.
- const metaKeyPriority = [
- 'test_volt',
- 'temp_range',
- 'max_op_temp',
- 'max_sc_temp',
- 'min_lay_temp',
- 'min_store_temp',
- 'cpr',
- 'flame',
- ];
- const metaKeyPrioritySet = new Set(metaKeyPriority);
-
- // Cross-section table is limited to the core industry columns (compact for A4).
- // To avoid βtoo little dataβ we always render the full set of core columns in the table,
- // even when a value is constant across the voltage group.
- const tableColumns: Array<{ excelKey: string; mapping: { header: string; unit: string; key: string } }> = [];
-
- const denseTableKeyOrder = [
- // Conductor should be the first technical column (after designation).
- // This improves scanability in the cable industry (material/type is a primary discriminator).
- 'Cond',
- // Next highest priority for LV/MV: conductor shape/type (RE/RM/RMV/...).
- 'shape',
-
- // Electrical properties (when available) β high value for MV/HV engineering.
- 'cap',
- 'X',
-
- 'DI',
- 'RI',
- 'Wi',
- 'Ibl',
- 'Ibe',
- 'Ik_cond',
- 'Wm',
- 'Rbv',
- 'Γ',
-
- // Extra high-value dimensions/metal screen info (when available): common in MV/HV.
- // Keep close to Γ to support engineers comparing layer builds.
- 'D_screen',
- 'S_screen',
-
- 'Fzv',
- 'G',
- ] as const;
- const denseTableKeys = new Set(denseTableKeyOrder);
-
- // Extra data handling:
- // - global constants => TECHNICAL DATA
- // - voltage-group constants => metaItems
- // - voltage-group varying (non-core) => metaItems as ranges/lists (keeps A4 compact)
-
- // Pre-scan: detect if bending radius is expressed as xD (common in LV/Solar sheets)
- // so we can label the unit correctly (Rbv [xD] instead of Rbv [mm]).
- // Detect bending radius representation (mm vs xD) from the matched Excel column.
- const bendingRadiusKey = matchedColumns.find(c => c.mapping.key === 'Rbv')?.excelKey || null;
- let bendUnitOverride = '';
- if (bendingRadiusKey) {
- const bendVals = indices
- .map(idx => normalizeValue(String(compatibleRows[idx]?.[bendingRadiusKey] ?? '')))
- .filter(Boolean);
- if (bendVals.some(v => /\bxD\b/i.test(v))) bendUnitOverride = 'xD';
- }
-
- // 1) Collect mapped columns.
- for (const { excelKey, mapping } of matchedColumns) {
- // Skip cross-section and voltage keys (these are used for grouping)
- if (excelKey === csKey || excelKey === voltageKey) continue;
-
- // Get values for this voltage group
- const values = indices.map(idx => normalizeValue(String(compatibleRows[idx]?.[excelKey] ?? ''))).filter(Boolean);
-
- if (values.length > 0) {
- const unique = Array.from(new Set(values.map(v => v.toLowerCase())));
- let unit = normalizeUnit(units[excelKey] || mapping.unit || '');
- if (mapping.key === 'Rbv' && bendUnitOverride) unit = bendUnitOverride;
-
- // Always keep the core 13 columns in the table (even if constant).
- if (denseTableKeys.has(mapping.key)) {
- tableColumns.push({ excelKey, mapping });
- continue;
- }
-
- // For meta header: collect candidates, but only *display* a consistent subset.
- // Global constants are normally in TECHNICAL DATA, but we still allow them
- // into the voltage meta block if they are part of the priority set.
- if (globalConstantColumns.has(excelKey) && !metaKeyPrioritySet.has(mapping.key)) {
- continue;
- }
-
- const value =
- unique.length === 1
- ? compactCellForDenseTable(values[0], unit, args.locale)
- : summarizeSmartOptions(excelKey, values);
-
- // Meta header: keep labels fully readable (no abbreviations).
- // Units are shown separately by the meta grid.
- const label = metaFullLabel({ key: mapping.key, excelKey, locale: args.locale });
-
- metaCandidates.set(mapping.key, { label, value, unit });
- }
- }
-
- // 1b) Materialize meta items in a stable order.
- // This keeps LV/MV/HV tables visually consistent (no "MV has much more in front").
- for (const k of metaKeyPriority) {
- const item = metaCandidates.get(k);
- if (item && item.label && item.value) metaItems.push(item);
- }
-
- // 2) Build the compact cross-section table.
- // If a column is not available in the Excel source and we cannot derive it safely,
- // we omit it (empty columns waste A4 width and reduce readability).
- const mappedByKey = new Map();
- for (const c of tableColumns) {
- if (!mappedByKey.has(c.mapping.key)) mappedByKey.set(c.mapping.key, c);
- }
-
- // Helper keys for derived values.
- // We derive DI (diameter over insulation) from Γ and Wm when DI is missing.
- const outerDiameterKey = (mappedByKey.get('Γ')?.excelKey || '') || null;
- const sheathThicknessKey = (mappedByKey.get('Wm')?.excelKey || '') || null;
-
- const canDeriveDenseKey = (k: (typeof denseTableKeyOrder)[number]): boolean => {
- if (k === 'DI') return Boolean(outerDiameterKey && sheathThicknessKey);
- if (k === 'Cond') return true; // derived from product designation when missing
- return false;
- };
-
- const orderedTableColumns = denseTableKeyOrder
- .filter(k => mappedByKey.has(k) || canDeriveDenseKey(k))
- .map(k => {
- const existing = mappedByKey.get(k);
- if (existing) return existing;
- return {
- excelKey: '',
- mapping: { header: k, unit: '', key: k },
- };
- });
-
- // Debug: Check for duplicate keys
- if (process.env.PDF_DEBUG_EXCEL === '1') {
- const keys = tableColumns.map(c => c.mapping.key);
- const duplicates = keys.filter((k, i) => keys.indexOf(k) !== i);
- if (duplicates.length > 0) {
- console.log(`[debug] Duplicate keys found: ${duplicates.join(', ')}`);
- console.log(`[debug] All columns: ${tableColumns.map(c => c.mapping.key + '(' + c.mapping.header + ')').join(', ')}`);
- }
- }
-
- const columns = orderedTableColumns.map(({ excelKey, mapping }) => {
- // Default units for the compact column set (used when an Excel unit is missing).
- // We keep Excel units when available.
- const defaultUnitByKey: Record = {
- DI: 'mm',
- RI: 'Ohm/km',
- Wi: 'mm',
- Ibl: 'A',
- Ibe: 'A',
- Ik_cond: 'kA',
- Wm: 'mm',
- Rbv: 'mm',
- 'Γ': 'mm',
- Fzv: 'N',
- G: 'kg/km',
- };
-
- let unit = normalizeUnit((excelKey ? units[excelKey] : '') || mapping.unit || defaultUnitByKey[mapping.key] || '');
- if (mapping.key === 'Rbv' && bendUnitOverride) unit = bendUnitOverride;
-
- return {
- key: mapping.key,
- // Keep labels compact for dense tables; headerLabelFor() will use these.
- label: denseAbbrevLabel({ key: mapping.key, locale: args.locale, unit, withUnit: true }) || formatExcelHeaderLabel(excelKey, unit),
- get: (rowIndex: number) => {
- const srcRowIndex = indices[rowIndex];
- const raw = excelKey ? normalizeValue(String(compatibleRows[srcRowIndex]?.[excelKey] ?? '')) : '';
- const unitLocal = unit;
-
- // LV sheets (e.g. NA2XY): current ratings (Ibl/Ibe) and short-circuit current (Ik)
- // are typically not part of the source Excel. Keep cells empty (donβt guess).
- // However, for DI we can derive a usable engineering approximation.
-
- // Derived values (only when the source column is missing/empty).
- // DI (diameter over insulation): approx. from Γ and Wm when available.
- if (mapping.key === 'DI' && !raw && outerDiameterKey && sheathThicknessKey) {
- const odRaw = normalizeValue(String(compatibleRows[srcRowIndex]?.[outerDiameterKey] ?? ''));
- const wmRaw = normalizeValue(String(compatibleRows[srcRowIndex]?.[sheathThicknessKey] ?? ''));
- const od = parseNumericOption(odRaw);
- const wm = parseNumericOption(wmRaw);
- if (od !== null && wm !== null) {
- const di = od - 2 * wm;
- if (Number.isFinite(di) && di > 0) return `~${compactNumericForLocale(String(di), args.locale)}`;
- }
- }
-
- // Conductor material: if not present in Excel, derive from part number prefix.
- // NA⦠=> Al, N⦠=> Cu (common cable designation pattern in this dataset).
- if (mapping.key === 'Cond' && !raw) {
- const pn = normalizeExcelKey(args.product.name || args.product.slug || args.product.sku || '');
- if (/^NA/.test(pn)) return 'Al';
- if (/^N/.test(pn)) return 'Cu';
- }
-
- // If bending radius is given as xD, keep it as-is (unit label reflects xD).
- if (mapping.key === 'Rbv' && /\bxD\b/i.test(raw)) return compactNumericForLocale(raw, args.locale);
-
- // HV source: "Min. bending radius" appears to be stored in meters (e.g. 1.70).
- // Convert to mm when we label it as mm.
- if (mapping.key === 'Rbv' && unitLocal.toLowerCase() === 'mm') {
- const n = parseNumericOption(raw);
- const looksLikeMeters = n !== null && n > 0 && n < 50 && /[\.,]\d{1,3}/.test(raw) && !/\dxD/i.test(raw);
- if (looksLikeMeters) return compactNumericForLocale(String(Math.round(n * 1000)), args.locale);
- }
-
- // Pulling force: some sources appear to store kN values (e.g. 4.5) without unit.
- // When header/unit is N and the value is small, normalize to N.
- if (mapping.key === 'Fzv' && unitLocal.toLowerCase() === 'n') {
- const n = parseNumericOption(raw);
- const looksLikeKN = n !== null && n > 0 && n < 100 && !/\bN\b/i.test(raw) && !/\bkN\b/i.test(raw);
- if (looksLikeKN) return compactNumericForLocale(String(Math.round(n * 1000)), args.locale);
- }
-
- return compactCellForDenseTable(raw, unitLocal, args.locale);
- },
- };
- });
-
- voltageTables.push({ voltageLabel: vKey, metaItems, crossSections, columns });
-
- // Debug: Show columns for this voltage
- if (process.env.PDF_DEBUG_EXCEL === '1') {
- console.log(`[debug] Voltage ${vKey}: ${columns.length} columns`);
- console.log(`[debug] Columns: ${columns.map(c => c.key).join(', ')}`);
- }
- }
-
- technicalItems.sort((a, b) => a.label.localeCompare(b.label));
-
- // Debug: Show technical items
- if (process.env.PDF_DEBUG_EXCEL === '1') {
- console.log(`[debug] Technical items: ${technicalItems.map(t => t.label).join(', ')}`);
- }
-
- return { ok: true, technicalItems, voltageTables };
-}
-
-// Unified key-value grid renderer for both metagrid and technical data
-function drawKeyValueTable(args: {
- title: string;
- items: Array<{ label: string; value: string; unit?: string }>;
- locale: 'en' | 'de';
- newPage: () => number;
- getPage: () => PDFPage;
- page: PDFPage;
- y: number;
- margin: number;
- contentWidth: number;
- contentMinY: number;
- font: PDFFont;
- fontBold: PDFFont;
- navy: ReturnType;
- darkGray: ReturnType;
- mediumGray: ReturnType;
- expandLabels?: boolean; // true for metagrid, false for technical data
-}): number {
- let { page, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, mediumGray } = args;
- let y = args.y;
- const items = (args.items || []).filter(i => i.label && i.value);
- if (!items.length) return y;
-
- const lightGray = rgb(0.9020, 0.9137, 0.9294);
- const almostWhite = rgb(0.9725, 0.9765, 0.9804);
- const expandLabels = args.expandLabels ?? false;
-
- // Auto-determine columns based on item count
- const cols = items.length >= 7 ? 3 : 2;
- const colWidth = contentWidth / cols;
- const cellH = 34;
- const padX = 10;
- const titleH = 18;
- const headerPadY = 10;
-
- const labelSize = 7.25;
- const valueSize = 8.75;
- const labelYOff = 12;
- const valueYOff = 28;
-
- const rows = Math.ceil(items.length / cols);
- const boxH = headerPadY + titleH + rows * cellH + headerPadY;
- const needed = boxH + 10;
-
- if (y - needed < contentMinY) y = args.newPage();
- page = args.getPage();
-
- const boxTopY = y;
- const boxBottomY = boxTopY - boxH;
-
- // Outer frame
- page.drawRectangle({
- x: margin,
- y: boxBottomY,
- width: contentWidth,
- height: boxH,
- borderColor: lightGray,
- borderWidth: 1,
- color: rgb(1, 1, 1),
- });
-
- // Title band
- page.drawRectangle({
- x: margin,
- y: boxTopY - (headerPadY + titleH),
- width: contentWidth,
- height: headerPadY + titleH,
- color: almostWhite,
- });
-
- if (args.title) {
- page.drawText(args.title, {
- x: margin + padX,
- y: boxTopY - (headerPadY + 12),
- size: 10,
- font: fontBold,
- color: navy,
- maxWidth: contentWidth - padX * 2,
- });
- }
-
- // Separator below title
- const gridTopY = boxTopY - (headerPadY + titleH);
- page.drawLine({
- start: { x: margin, y: gridTopY },
- end: { x: margin + contentWidth, y: gridTopY },
- thickness: 0.75,
- color: lightGray,
- });
-
- // Render grid cells
- for (let r = 0; r < rows; r++) {
- const rowTopY = gridTopY - r * cellH;
- const rowBottomY = rowTopY - cellH;
-
- // Zebra striping
- if (r % 2 === 0) {
- page.drawRectangle({
- x: margin,
- y: rowBottomY,
- width: contentWidth,
- height: cellH,
- color: rgb(0.99, 0.992, 0.995),
- });
- }
-
- // Row separator (except last)
- if (r !== rows - 1) {
- page.drawLine({
- start: { x: margin, y: rowBottomY },
- end: { x: margin + contentWidth, y: rowBottomY },
- thickness: 0.5,
- color: lightGray,
- });
- }
-
- for (let c = 0; c < cols; c++) {
- const idx = r * cols + c;
- const item = items[idx];
- const x0 = margin + c * colWidth;
-
- // Column separator (except last) - draw from grid top to bottom for full height
- if (c !== cols - 1) {
- page.drawLine({
- start: { x: x0 + colWidth, y: gridTopY },
- end: { x: x0 + colWidth, y: boxBottomY + headerPadY },
- thickness: 0.5,
- color: lightGray,
- });
- }
-
- if (!item) continue;
-
- const labelText = expandLabels ? expandMetaLabel(item.label, args.locale) : item.label;
- const valueText = item.unit ? `${item.value} ${item.unit}` : item.value;
-
- const maxW = colWidth - padX * 2 - 2;
- const labelOneLine = ellipsizeToWidth(labelText, fontBold, labelSize, maxW);
- const valueOneLine = ellipsizeToWidth(valueText, font, valueSize, maxW);
-
- page.drawText(labelOneLine, {
- x: x0 + padX,
- y: rowTopY - labelYOff,
- size: labelSize,
- font: fontBold,
- color: mediumGray,
- });
- page.drawText(valueOneLine, {
- x: x0 + padX,
- y: rowTopY - valueYOff,
- size: valueSize,
- font,
- color: darkGray,
- });
- }
- }
-
- return Math.max(contentMinY, boxBottomY - 18);
-}
-
-// Backward compatibility wrapper for metagrid
-function drawDenseMetaGrid(args: {
- title: string;
- items: Array<{ label: string; value: string; unit?: string }>;
- locale: 'en' | 'de';
- newPage: () => number;
- getPage: () => PDFPage;
- page: PDFPage;
- y: number;
- margin: number;
- contentWidth: number;
- contentMinY: number;
- font: PDFFont;
- fontBold: PDFFont;
- navy: ReturnType;
- darkGray: ReturnType;
- mediumGray: ReturnType;
-}): number {
- return drawKeyValueTable({ ...args, expandLabels: true });
-}
-
-function prioritizeColumnsForDenseTable(args: {
- columns: VoltageTableModel['columns'];
-}): VoltageTableModel['columns'] {
- // Priority order: compact cross-section table columns first, then all additional technical data
- const priorityOrder = [
- // Compact cross-section table columns (industry standard)
- // NOTE: The designation/configuration is the *first* table column already.
- // We put conductor material/type first among the technical columns for better scanability.
- // Note: Ik is represented by Ik_cond (conductor shortcircuit current)
- 'Cond',
- 'shape',
- 'cap',
- 'X',
- 'DI',
- 'RI',
- 'Wi',
- 'Ibl',
- 'Ibe',
- 'Ik_cond',
- 'Wm',
- 'Rbv',
- 'Γ',
- // Extra high-value dimensions/metal screen info (when present)
- 'D_screen',
- 'S_screen',
- 'Fzv',
- 'G',
-
- // Additional technical columns (in logical groups)
- // Dimensions and materials
- 'cond_diam', 'shape', 'conductor', 'insulation', 'sheath',
- // Temperatures
- 'max_op_temp', 'max_sc_temp', 'temp_range', 'min_store_temp', 'min_lay_temp',
- // Electrical properties
- 'cap', 'ind_trefoil', 'ind_air_flat', 'ind_ground_flat',
- // Current ratings (flat)
- 'cur_air_flat', 'cur_ground_flat',
- // Heating time constants
- 'heat_trefoil', 'heat_flat',
- // Voltage ratings
- 'test_volt', 'rated_volt',
- // Colors
- 'color_ins', 'color_sheath',
- // Materials and specs
- 'norm', 'standard', 'cpr', 'flame', 'packaging', 'ce',
- // Screen/tape layers
- 'tape_below', 'copper_screen', 'tape_above', 'al_foil',
- // Additional shortcircuit current
- 'Ik_screen'
- ];
-
- return [...args.columns].sort((a, b) => {
- const ia = priorityOrder.indexOf(a.key);
- const ib = priorityOrder.indexOf(b.key);
- if (ia !== -1 && ib !== -1) return ia - ib;
- if (ia !== -1) return -1;
- if (ib !== -1) return 1;
- return a.key.localeCompare(b.key);
- });
-}
-
-function normalizeExcelKey(value: string): string {
- // Match product names/slugs and Excel "Part Number" robustly.
- // Examples:
- // - "NA2XS(FL)2Y" -> "NA2XSFL2Y"
- // - "na2xsfl2y-3" -> "NA2XSFL2Y"
- return String(value || '')
- .toUpperCase()
- .replace(/-\d+$/g, '')
- .replace(/[^A-Z0-9]+/g, '');
-}
-
-function loadExcelRows(filePath: string): ExcelRow[] {
- // We intentionally avoid adding a heavy xlsx parser dependency.
- // Instead, we use `xlsx-cli` via npx, which is already available at runtime.
- // NOTE: `xlsx-cli -j` prints the sheet name on the first line, then JSON.
- const out = execSync(`npx -y xlsx-cli -j "${filePath}"`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
- const trimmed = out.trim();
- const jsonStart = trimmed.indexOf('[');
- if (jsonStart < 0) return [];
- const jsonText = trimmed.slice(jsonStart);
- try {
- return JSON.parse(jsonText) as ExcelRow[];
- } catch {
- return [];
- }
-}
-
-function getExcelIndex(): Map {
- if (EXCEL_INDEX) return EXCEL_INDEX;
- const idx = new Map();
- 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 = {};
- if (unitsRow) {
- for (const [k, v] of Object.entries(unitsRow)) {
- if (k === 'Part Number') continue;
- const unit = normalizeValue(String(v ?? ''));
- if (unit) units[k] = unit;
- }
- }
-
- for (const r of rows) {
- const pn = r?.['Part Number'];
- if (!pn || pn === 'Units') continue;
- const key = normalizeExcelKey(String(pn));
- if (!key) continue;
- const cur = idx.get(key);
- if (!cur) {
- idx.set(key, { rows: [r], units });
- } else {
- cur.rows.push(r);
- if (Object.keys(cur.units).length < Object.keys(units).length) cur.units = units;
- }
- }
- }
- EXCEL_INDEX = idx;
- return idx;
-}
-
-function findExcelForProduct(product: ProductData): ExcelMatch | null {
- const idx = getExcelIndex();
- const candidates = [
- product.name,
- product.slug ? product.slug.replace(/-\d+$/g, '') : '',
- product.sku,
- product.translationKey,
- ].filter(Boolean) as string[];
-
- if (process.env.PDF_DEBUG_EXCEL === '1') {
- const keys = candidates.map(c => normalizeExcelKey(c));
- console.log(`[excel] lookup product=${product.id} ${product.locale ?? ''} slug=${product.slug ?? ''} name=${stripHtml(product.name)} keys=${keys.join(',')}`);
- }
-
- for (const c of candidates) {
- const key = normalizeExcelKey(c);
- const match = idx.get(key);
- if (match && match.rows.length) return match;
- }
- return null;
-}
-
-function findExcelRowsForProduct(product: ProductData): ExcelRow[] {
- const match = findExcelForProduct(product);
- if (!match?.rows || match.rows.length === 0) return [];
-
- const rows = match.rows;
-
- // Find the row with most columns as sample
- let sample = rows.find(r => r && Object.keys(r).length > 0) || {};
- let maxColumns = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units').length;
-
- for (const r of rows) {
- const cols = Object.keys(r).filter(k => k && k !== 'Part Number' && k !== 'Units').length;
- if (cols > maxColumns) {
- sample = r;
- maxColumns = cols;
- }
- }
-
- // Filter to only rows with the same column structure as sample
- const sampleKeys = Object.keys(sample).filter(k => k && k !== 'Part Number' && k !== 'Units').sort();
- const compatibleRows = rows.filter(r => {
- const rKeys = Object.keys(r).filter(k => k && k !== 'Part Number' && k !== 'Units').sort();
- return JSON.stringify(rKeys) === JSON.stringify(sampleKeys);
- });
-
- return compatibleRows;
-}
-
-function guessColumnKey(row: ExcelRow, patterns: RegExp[]): string | null {
- const keys = Object.keys(row || {});
-
- // Try pattern-based matching first
- for (const re of patterns) {
- const k = keys.find(x => {
- const key = String(x);
-
- // Specific exclusions to prevent wrong matches
- if (re.test('conductor') && /ross section conductor/i.test(key)) return false;
- if (re.test('insulation thickness') && /Diameter over insulation/i.test(key)) return false;
- if (re.test('conductor') && !/^conductor$/i.test(key)) return false;
- if (re.test('insulation') && !/^insulation$/i.test(key)) return false;
- if (re.test('sheath') && !/^sheath$/i.test(key)) return false;
- if (re.test('norm') && !/^norm$/i.test(key)) return false;
-
- return re.test(key);
- });
- if (k) return k;
- }
- return null;
-}
-
-function hasAttr(product: ProductData, nameRe: RegExp, expectedLen?: number): boolean {
- const a = product.attributes?.find(x => nameRe.test(x.name));
- if (!a) return false;
- if (typeof expectedLen === 'number') return (a.options || []).length === expectedLen;
- return (a.options || []).length > 0;
-}
-
-function pushRowAttrIfMissing(args: {
- product: ProductData;
- name: string;
- options: string[];
- expectedLen: number;
- existsRe: RegExp;
-}): void {
- const { product, name, options, expectedLen, existsRe } = args;
- if (!options.filter(Boolean).length) return;
- if (hasAttr(product, existsRe, expectedLen)) return;
- product.attributes = product.attributes || [];
- product.attributes.push({ name, options });
-}
-
-function pushAttrIfMissing(args: { product: ProductData; name: string; options: string[]; existsRe: RegExp }): void {
- const { product, name, options, existsRe } = args;
- if (!options.filter(Boolean).length) return;
- if (hasAttr(product, existsRe)) return;
- product.attributes = product.attributes || [];
- product.attributes.push({ name, options });
-}
-
-function getUniqueNonEmpty(options: string[]): string[] {
- const uniq: string[] = [];
- const seen = new Set();
- for (const v of options.map(normalizeValue).filter(Boolean)) {
- const k = v.toLowerCase();
- if (seen.has(k)) continue;
- seen.add(k);
- uniq.push(v);
- }
- return uniq;
-}
-
-function ensureExcelCrossSectionAttributes(product: ProductData, locale: 'en' | 'de'): void {
- const hasCross = (product.attributes || []).some(a => /configuration|konfiguration|aufbau|bezeichnung|number of cores and cross-section|querschnitt|cross.?section|mmΒ²|mm2/i.test(a.name) && (a.options?.length || 0) > 0);
- if (hasCross) return;
-
- const rows = findExcelRowsForProduct(product);
- if (!rows.length) {
- if (process.env.PDF_DEBUG_EXCEL === '1') {
- console.log(`[excel] no rows found for product ${product.id} (${product.slug ?? stripHtml(product.name)})`);
- }
- return;
- }
-
- // Find the cross-section column.
- const csKey =
- guessColumnKey(rows[0], [
- /number of cores and cross-section/i,
- /cross.?section/i,
- /ross section conductor/i,
- ]) || null;
- if (!csKey) {
- if (process.env.PDF_DEBUG_EXCEL === '1') {
- console.log(`[excel] rows found but no cross-section column for product ${product.id}; available keys: ${Object.keys(rows[0] || {}).slice(0, 30).join(', ')}`);
- }
- return;
- }
-
- // Get all technical column keys using improved detection
- const voltageKey = guessColumnKey(rows[0], [/rated voltage/i, /voltage rating/i, /spannungs/i, /nennspannung/i]);
- const outerKey = guessColumnKey(rows[0], [/outer diameter\b/i, /outer diameter.*approx/i, /outer diameter of cable/i, /auΓen/i]);
- const weightKey = guessColumnKey(rows[0], [/weight\b/i, /gewicht/i, /cable weight/i]);
- const dcResKey = guessColumnKey(rows[0], [/dc resistance/i, /resistance conductor/i, /leiterwiderstand/i]);
-
- // Additional technical columns
- const ratedVoltKey = voltageKey; // Already found above
- const testVoltKey = guessColumnKey(rows[0], [/test voltage/i, /prΓΌfspannung/i]);
- const tempRangeKey = guessColumnKey(rows[0], [/operating temperature range/i, /temperature range/i, /temperaturbereich/i]);
- const minLayKey = guessColumnKey(rows[0], [/minimal temperature for laying/i]);
- const minStoreKey = guessColumnKey(rows[0], [/minimal storage temperature/i]);
- const maxOpKey = guessColumnKey(rows[0], [/maximal operating conductor temperature/i, /max\. operating/i]);
- const maxScKey = guessColumnKey(rows[0], [/maximal short-circuit temperature/i, /short\s*circuit\s*temperature/i]);
- const insThkKey = guessColumnKey(rows[0], [/nominal insulation thickness/i, /insulation thickness/i]);
- const sheathThkKey = guessColumnKey(rows[0], [/nominal sheath thickness/i, /minimum sheath thickness/i]);
- const maxResKey = guessColumnKey(rows[0], [/maximum resistance of conductor/i]);
-
- // Material and specification columns
- const conductorKey = guessColumnKey(rows[0], [/^conductor$/i]);
- const insulationKey = guessColumnKey(rows[0], [/^insulation$/i]);
- const sheathKey = guessColumnKey(rows[0], [/^sheath$/i]);
- const normKey = guessColumnKey(rows[0], [/^norm$/i, /^standard$/i]);
- const cprKey = guessColumnKey(rows[0], [/cpr class/i]);
- const rohsKey = guessColumnKey(rows[0], [/^rohs$/i]);
- const reachKey = guessColumnKey(rows[0], [/^reach$/i]);
- const packagingKey = guessColumnKey(rows[0], [/^packaging$/i]);
- const shapeKey = guessColumnKey(rows[0], [/shape of conductor/i]);
- const flameKey = guessColumnKey(rows[0], [/flame retardant/i]);
- const diamCondKey = guessColumnKey(rows[0], [/diameter conductor/i]);
- const diamInsKey = guessColumnKey(rows[0], [/diameter over insulation/i]);
- const diamScreenKey = guessColumnKey(rows[0], [/diameter over screen/i]);
- const metalScreenKey = guessColumnKey(rows[0], [/metallic screen/i]);
- const capacitanceKey = guessColumnKey(rows[0], [/capacitance/i]);
- const reactanceKey = guessColumnKey(rows[0], [/reactance/i]);
- const electricalStressKey = guessColumnKey(rows[0], [/electrical stress/i]);
- const pullingForceKey = guessColumnKey(rows[0], [/max\. pulling force/i, /pulling force/i]);
- const heatingTrefoilKey = guessColumnKey(rows[0], [/heating time constant.*trefoil/i]);
- const heatingFlatKey = guessColumnKey(rows[0], [/heating time constant.*flat/i]);
- const currentAirTrefoilKey = guessColumnKey(rows[0], [/current ratings in air.*trefoil/i]);
- const currentAirFlatKey = guessColumnKey(rows[0], [/current ratings in air.*flat/i]);
- const currentGroundTrefoilKey = guessColumnKey(rows[0], [/current ratings in ground.*trefoil/i]);
- const currentGroundFlatKey = guessColumnKey(rows[0], [/current ratings in ground.*flat/i]);
- const scCurrentCondKey = guessColumnKey(rows[0], [/conductor shortcircuit current/i]);
- const scCurrentScreenKey = guessColumnKey(rows[0], [/screen shortcircuit current/i]);
-
- const cfgName = locale === 'de' ? 'Anzahl der Adern und Querschnitt' : 'Number of cores and cross-section';
- const cfgOptions = rows
- .map(r => {
- const cs = normalizeValue(String(r?.[csKey] ?? ''));
- const v = voltageKey ? normalizeValue(String(r?.[voltageKey] ?? '')) : '';
- if (!cs) return '';
- if (!v) return cs;
- // Keep the existing config separator used by splitConfig(): "cross - voltage".
- // Add unit only if not already present.
- const vHasUnit = /\bkv\b/i.test(v);
- const vText = vHasUnit ? v : `${v} kV`;
- return `${cs} - ${vText}`;
- })
- .filter(Boolean);
-
- if (!cfgOptions.length) return;
-
- const attrs = product.attributes || [];
- attrs.push({ name: cfgName, options: cfgOptions });
-
- const pushRowAttr = (name: string, key: string | null, unit?: string) => {
- if (!key) return;
- const options = rows
- .map(r => normalizeValue(String(r?.[key] ?? '')))
- .map(v => (unit && v && looksNumeric(v) ? `${v} ${unit}` : v));
- if (options.filter(Boolean).length === 0) return;
- attrs.push({ name, options });
- };
-
- // These names are chosen so existing PDF regexes can detect them.
- pushRowAttr(locale === 'de' ? 'AuΓen-Γ' : 'Outer diameter', outerKey, 'mm');
- pushRowAttr(locale === 'de' ? 'Gewicht' : 'Weight', weightKey, 'kg/km');
- pushRowAttr(locale === 'de' ? 'DC-Leiterwiderstand (20C)' : 'DC resistance at 20 C', dcResKey, 'Ohm/km');
-
- const colValues = (key: string | null) => rows.map(r => normalizeValue(String(r?.[key ?? ''] ?? '')));
-
- const addConstOrSmallList = (args: { name: string; existsRe: RegExp; key: string | null }) => {
- if (!args.key) return;
- const uniq = getUniqueNonEmpty(colValues(args.key));
- if (!uniq.length) return;
- // If all rows share the same value, store as single option.
- if (uniq.length === 1) {
- pushAttrIfMissing({ product, name: args.name, options: [uniq[0]], existsRe: args.existsRe });
- return;
- }
- // Otherwise store the unique set (TECHNICAL DATA will compact it).
- pushAttrIfMissing({ product, name: args.name, options: uniq, existsRe: args.existsRe });
- };
-
- addConstOrSmallList({
- name: locale === 'de' ? 'Nennspannung' : 'Rated voltage',
- existsRe: /rated\s*voltage|voltage\s*rating|nennspannung|spannungsbereich/i,
- key: ratedVoltKey,
- });
- addConstOrSmallList({
- name: locale === 'de' ? 'PrΓΌfspannung' : 'Test voltage',
- existsRe: /test\s*voltage|prΓΌfspannung/i,
- key: testVoltKey,
- });
- addConstOrSmallList({
- name: locale === 'de' ? 'Temperaturbereich' : 'Operating temperature range',
- existsRe: /operating\s*temperature\s*range|temperature\s*range|temperaturbereich/i,
- key: tempRangeKey,
- });
- addConstOrSmallList({
- name: locale === 'de' ? 'Min. Verlegetemperatur' : 'Minimal temperature for laying',
- existsRe: /minimal\s*temperature\s*for\s*laying/i,
- key: minLayKey,
- });
- addConstOrSmallList({
- name: locale === 'de' ? 'Min. Lagertemperatur' : 'Minimal storage temperature',
- existsRe: /minimal\s*storage\s*temperature/i,
- key: minStoreKey,
- });
- addConstOrSmallList({
- name: locale === 'de' ? 'Max. Betriebstemperatur' : 'Maximal operating conductor temperature',
- existsRe: /maximal\s*operating\s*conductor\s*temperature|max\.?\s*operating/i,
- key: maxOpKey,
- });
- addConstOrSmallList({
- name: locale === 'de' ? 'Kurzschlusstemperatur (max.)' : 'Maximal short-circuit temperature',
- existsRe: /maximal\s*short-?circuit\s*temperature|short\s*circuit\s*temperature|kurzschlusstemperatur/i,
- key: maxScKey,
- });
- addConstOrSmallList({
- name: locale === 'de' ? 'Isolationsdicke (nom.)' : 'Nominal insulation thickness',
- existsRe: /nominal\s*insulation\s*thickness|insulation\s*thickness/i,
- key: insThkKey,
- });
- addConstOrSmallList({
- name: locale === 'de' ? 'Manteldicke (nom.)' : 'Nominal sheath thickness',
- existsRe: /nominal\s*sheath\s*thickness|minimum\s*sheath\s*thickness|manteldicke/i,
- key: sheathThkKey,
- });
- addConstOrSmallList({
- name: locale === 'de' ? 'Max. Leiterwiderstand' : 'Maximum resistance of conductor',
- existsRe: /maximum\s*resistance\s*of\s*conductor|max\.?\s*resistance|leiterwiderstand/i,
- key: maxResKey,
- });
-
- // Add additional technical data from Excel files
- addConstOrSmallList({
- name: locale === 'de' ? 'Leiter' : 'Conductor',
- existsRe: /conductor/i,
- key: conductorKey,
- });
-
- addConstOrSmallList({
- name: locale === 'de' ? 'Isolierung' : 'Insulation',
- existsRe: /insulation/i,
- key: insulationKey,
- });
-
- addConstOrSmallList({
- name: locale === 'de' ? 'Mantel' : 'Sheath',
- existsRe: /sheath/i,
- key: sheathKey,
- });
-
- addConstOrSmallList({
- name: locale === 'de' ? 'Norm' : 'Standard',
- existsRe: /norm|standard|iec|vde/i,
- key: normKey,
- });
-
- addConstOrSmallList({
- name: locale === 'de' ? 'Leiterdurchmesser' : 'Conductor diameter',
- existsRe: /diameter conductor|conductor diameter/i,
- key: diamCondKey,
- });
-
- addConstOrSmallList({
- name: locale === 'de' ? 'Isolierungsdurchmesser' : 'Insulation diameter',
- existsRe: /diameter over insulation|diameter insulation/i,
- key: diamInsKey,
- });
-
- addConstOrSmallList({
- name: locale === 'de' ? 'Schirmdurchmesser' : 'Screen diameter',
- existsRe: /diameter over screen|diameter screen/i,
- key: diamScreenKey,
- });
-
- addConstOrSmallList({
- name: locale === 'de' ? 'Metallischer Schirm' : 'Metallic screen',
- existsRe: /metallic screen/i,
- key: metalScreenKey,
- });
-
- addConstOrSmallList({
- name: locale === 'de' ? 'Max. Zugkraft' : 'Max. pulling force',
- existsRe: /max.*pulling force|pulling force/i,
- key: pullingForceKey,
- });
-
- addConstOrSmallList({
- name: locale === 'de' ? 'Elektrische Spannung Leiter' : 'Electrical stress conductor',
- existsRe: /electrical stress conductor/i,
- key: electricalStressKey,
- });
-
- addConstOrSmallList({
- name: locale === 'de' ? 'Elektrische Spannung Isolierung' : 'Electrical stress insulation',
- existsRe: /electrical stress insulation/i,
- key: electricalStressKey,
- });
-
- addConstOrSmallList({
- name: locale === 'de' ? 'Reaktanz' : 'Reactance',
- existsRe: /reactance/i,
- key: reactanceKey,
- });
-
- addConstOrSmallList({
- name: locale === 'de' ? 'Heizzeitkonstante trefoil' : 'Heating time constant trefoil',
- existsRe: /heating time constant.*trefoil/i,
- key: heatingTrefoilKey,
- });
-
- addConstOrSmallList({
- name: locale === 'de' ? 'Heizzeitkonstante flach' : 'Heating time constant flat',
- existsRe: /heating time constant.*flat/i,
- key: heatingFlatKey,
- });
-
- addConstOrSmallList({
- name: locale === 'de' ? 'Flammhemmend' : 'Flame retardant',
- existsRe: /flame retardant/i,
- key: flameKey,
- });
-
- addConstOrSmallList({
- name: locale === 'de' ? 'CPR-Klasse' : 'CPR class',
- existsRe: /cpr class/i,
- key: cprKey,
- });
-
- addConstOrSmallList({
- name: locale === 'de' ? 'Verpackung' : 'Packaging',
- existsRe: /packaging/i,
- key: packagingKey,
- });
-
- addConstOrSmallList({
- name: locale === 'de' ? 'Biegeradius' : 'Bending radius',
- existsRe: /bending radius/i,
- key: null, // Will be found in row-specific attributes
- });
-
- addConstOrSmallList({
- name: locale === 'de' ? 'Leiterform' : 'Shape of conductor',
- existsRe: /shape of conductor/i,
- key: shapeKey,
- });
-
- addConstOrSmallList({
- name: locale === 'de' ? 'Isolierungsfarbe' : 'Colour of insulation',
- existsRe: /colour of insulation/i,
- key: null, // Will be found in row-specific attributes
- });
-
- addConstOrSmallList({
- name: locale === 'de' ? 'Mantelfarbe' : 'Colour of sheath',
- existsRe: /colour of sheath/i,
- key: null, // Will be found in row-specific attributes
- });
-
- addConstOrSmallList({
- name: locale === 'de' ? 'RoHS/REACH' : 'RoHS/REACH',
- existsRe: /rohs.*reach/i,
- key: null, // Will be found in row-specific attributes
- });
-
- product.attributes = attrs;
-
- if (process.env.PDF_DEBUG_EXCEL === '1') {
- console.log(`[excel] enriched product ${product.id} (${product.slug ?? stripHtml(product.name)}) with ${cfgOptions.length} configurations from excel`);
- }
-}
-
-function ensureExcelRowSpecificAttributes(product: ProductData, locale: 'en' | 'de'): void {
- const rows = findExcelRowsForProduct(product);
- if (!rows.length) return;
-
- const crossSectionAttr =
- findAttr(product, /configuration|konfiguration|aufbau|bezeichnung/i) ||
- findAttr(product, /number of cores and cross-section|querschnitt|cross.?section|mmΒ²|mm2/i);
- if (!crossSectionAttr || !crossSectionAttr.options?.length) return;
-
- const rowCount = crossSectionAttr.options.length;
- // Only enrich row-specific columns when row counts match (avoid wrong mapping).
- if (rows.length !== rowCount) return;
-
- const sample = rows[0] || {};
-
- const keyOuter = guessColumnKey(sample, [/outer diameter \(approx\.?\)/i, /outer diameter of cable/i, /outer diameter\b/i, /diameter over screen/i]);
- const keyWeight = guessColumnKey(sample, [/weight \(approx\.?\)/i, /cable weight/i, /\bweight\b/i]);
- const keyDcRes = guessColumnKey(sample, [/dc resistance at 20/i, /maximum resistance of conductor/i, /resistance conductor/i]);
- const keyCap = guessColumnKey(sample, [/capacitance/i]);
- const keyIndTrefoil = guessColumnKey(sample, [/inductance,?\s*trefoil/i]);
- const keyIndAirFlat = guessColumnKey(sample, [/inductance in air,?\s*flat/i]);
- const keyIndGroundFlat = guessColumnKey(sample, [/inductance in ground,?\s*flat/i]);
- const keyIairTrefoil = guessColumnKey(sample, [/current ratings in air,?\s*trefoil/i]);
- const keyIairFlat = guessColumnKey(sample, [/current ratings in air,?\s*flat/i]);
- const keyIgroundTrefoil = guessColumnKey(sample, [/current ratings in ground,?\s*trefoil/i]);
- const keyIgroundFlat = guessColumnKey(sample, [/current ratings in ground,?\s*flat/i]);
- const keyScCond = guessColumnKey(sample, [/conductor shortcircuit current/i]);
- const keyScScreen = guessColumnKey(sample, [/screen shortcircuit current/i]);
- const keyBend = guessColumnKey(sample, [/bending radius/i, /min\. bending radius/i]);
-
- // Additional row-specific technical data
- const keyConductorDiameter = guessColumnKey(sample, [/conductor diameter/i, /diameter conductor/i]);
- const keyInsulationThickness = guessColumnKey(sample, [/nominal insulation thickness/i, /insulation thickness/i]);
- const keySheathThickness = guessColumnKey(sample, [/nominal sheath thickness/i, /minimum sheath thickness/i, /sheath thickness/i]);
- const keyCapacitance = guessColumnKey(sample, [/capacitance/i]);
- const keyInductanceTrefoil = guessColumnKey(sample, [/inductance.*trefoil/i]);
- const keyInductanceAirFlat = guessColumnKey(sample, [/inductance.*air.*flat/i]);
- const keyInductanceGroundFlat = guessColumnKey(sample, [/inductance.*ground.*flat/i]);
- const keyCurrentAirTrefoil = guessColumnKey(sample, [/current.*air.*trefoil/i]);
- const keyCurrentAirFlat = guessColumnKey(sample, [/current.*air.*flat/i]);
- const keyCurrentGroundTrefoil = guessColumnKey(sample, [/current.*ground.*trefoil/i]);
- const keyCurrentGroundFlat = guessColumnKey(sample, [/current.*ground.*flat/i]);
- const keyHeatingTimeTrefoil = guessColumnKey(sample, [/heating.*time.*trefoil/i]);
- const keyHeatingTimeFlat = guessColumnKey(sample, [/heating.*time.*flat/i]);
-
- const get = (k: string | null) => rows.map(r => normalizeValue(String(r?.[k ?? ''] ?? '')));
- const withUnit = (vals: string[], unit: string) => vals.map(v => (v && looksNumeric(v) ? `${v} ${unit}` : v));
-
- // Use labels that are already recognized by the existing PDF regexes.
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'AuΓen-Γ' : 'Outer diameter',
- options: withUnit(get(keyOuter), 'mm'),
- expectedLen: rowCount,
- existsRe: /outer\s*diameter|auΓen\s*durchmesser|auΓen-?ΓΈ/i,
- });
-
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'Gewicht' : 'Weight',
- options: withUnit(get(keyWeight), 'kg/km'),
- expectedLen: rowCount,
- existsRe: /\bweight\b|gewicht/i,
- });
-
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'DC-Leiterwiderstand (20C)' : 'DC resistance at 20 C',
- options: withUnit(get(keyDcRes), 'Ohm/km'),
- expectedLen: rowCount,
- existsRe: /dc\s*resistance|max(?:imum)?\s*resistance|resistance\s+conductor|leiterwiderstand/i,
- });
-
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'KapazitΓ€t (ca.)' : 'Capacitance (approx.)',
- options: withUnit(get(keyCap), 'uF/km'),
- expectedLen: rowCount,
- existsRe: /capacitance|kapazit/i,
- });
-
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'InduktivitΓ€t, trefoil (ca.)' : 'Inductance, trefoil (approx.)',
- options: withUnit(get(keyIndTrefoil), 'mH/km'),
- expectedLen: rowCount,
- existsRe: /inductance,?\s*trefoil/i,
- });
-
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'InduktivitΓ€t in Luft, flach (ca.)' : 'Inductance in air, flat (approx.)',
- options: withUnit(get(keyIndAirFlat), 'mH/km'),
- expectedLen: rowCount,
- existsRe: /inductance\s+in\s+air,?\s*flat/i,
- });
-
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'InduktivitΓ€t im Erdreich, flach (ca.)' : 'Inductance in ground, flat (approx.)',
- options: withUnit(get(keyIndGroundFlat), 'mH/km'),
- expectedLen: rowCount,
- existsRe: /inductance\s+in\s+ground,?\s*flat/i,
- });
-
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'Strombelastbarkeit in Luft, trefoil' : 'Current ratings in air, trefoil',
- options: withUnit(get(keyIairTrefoil), 'A'),
- expectedLen: rowCount,
- existsRe: /current\s+ratings\s+in\s+air,?\s*trefoil/i,
- });
-
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'Strombelastbarkeit in Luft, flach' : 'Current ratings in air, flat',
- options: withUnit(get(keyIairFlat), 'A'),
- expectedLen: rowCount,
- existsRe: /current\s+ratings\s+in\s+air,?\s*flat/i,
- });
-
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'Strombelastbarkeit im Erdreich, trefoil' : 'Current ratings in ground, trefoil',
- options: withUnit(get(keyIgroundTrefoil), 'A'),
- expectedLen: rowCount,
- existsRe: /current\s+ratings\s+in\s+ground,?\s*trefoil/i,
- });
-
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'Strombelastbarkeit im Erdreich, flach' : 'Current ratings in ground, flat',
- options: withUnit(get(keyIgroundFlat), 'A'),
- expectedLen: rowCount,
- existsRe: /current\s+ratings\s+in\s+ground,?\s*flat/i,
- });
-
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'Kurzschlussstrom Leiter' : 'Conductor shortcircuit current',
- options: withUnit(get(keyScCond), 'kA'),
- expectedLen: rowCount,
- existsRe: /conductor\s+shortcircuit\s+current/i,
- });
-
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'Kurzschlussstrom Schirm' : 'Screen shortcircuit current',
- options: withUnit(get(keyScScreen), 'kA'),
- expectedLen: rowCount,
- existsRe: /screen\s+shortcircuit\s+current/i,
- });
-
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'Biegeradius (min.)' : 'Bending radius (min.)',
- options: withUnit(get(keyBend), 'mm'),
- expectedLen: rowCount,
- existsRe: /bending\s*radius|biegeradius/i,
- });
-
- // Additional row-specific technical data
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'Leiterdurchmesser' : 'Conductor diameter',
- options: withUnit(get(keyConductorDiameter), 'mm'),
- expectedLen: rowCount,
- existsRe: /conductor diameter|diameter conductor/i,
- });
-
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'Isolationsdicke' : 'Insulation thickness',
- options: withUnit(get(keyInsulationThickness), 'mm'),
- expectedLen: rowCount,
- existsRe: /insulation thickness|nominal insulation thickness/i,
- });
-
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'Manteldicke' : 'Sheath thickness',
- options: withUnit(get(keySheathThickness), 'mm'),
- expectedLen: rowCount,
- existsRe: /sheath thickness|nominal sheath thickness/i,
- });
-
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'KapazitΓ€t' : 'Capacitance',
- options: withUnit(get(keyCapacitance), 'uF/km'),
- expectedLen: rowCount,
- existsRe: /capacitance/i,
- });
-
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'InduktivitΓ€t trefoil' : 'Inductance trefoil',
- options: withUnit(get(keyInductanceTrefoil), 'mH/km'),
- expectedLen: rowCount,
- existsRe: /inductance.*trefoil/i,
- });
-
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'InduktivitΓ€t Luft flach' : 'Inductance air flat',
- options: withUnit(get(keyInductanceAirFlat), 'mH/km'),
- expectedLen: rowCount,
- existsRe: /inductance.*air.*flat/i,
- });
-
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'InduktivitΓ€t Erdreich flach' : 'Inductance ground flat',
- options: withUnit(get(keyInductanceGroundFlat), 'mH/km'),
- expectedLen: rowCount,
- existsRe: /inductance.*ground.*flat/i,
- });
-
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'Strombelastbarkeit Luft trefoil' : 'Current rating air trefoil',
- options: withUnit(get(keyCurrentAirTrefoil), 'A'),
- expectedLen: rowCount,
- existsRe: /current.*air.*trefoil/i,
- });
-
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'Strombelastbarkeit Luft flach' : 'Current rating air flat',
- options: withUnit(get(keyCurrentAirFlat), 'A'),
- expectedLen: rowCount,
- existsRe: /current.*air.*flat/i,
- });
-
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'Strombelastbarkeit Erdreich trefoil' : 'Current rating ground trefoil',
- options: withUnit(get(keyCurrentGroundTrefoil), 'A'),
- expectedLen: rowCount,
- existsRe: /current.*ground.*trefoil/i,
- });
-
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'Strombelastbarkeit Erdreich flach' : 'Current rating ground flat',
- options: withUnit(get(keyCurrentGroundFlat), 'A'),
- expectedLen: rowCount,
- existsRe: /current.*ground.*flat/i,
- });
-
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'Heizzeitkonstante trefoil' : 'Heating time constant trefoil',
- options: withUnit(get(keyHeatingTimeTrefoil), 's'),
- expectedLen: rowCount,
- existsRe: /heating.*time.*trefoil/i,
- });
-
- pushRowAttrIfMissing({
- product,
- name: locale === 'de' ? 'Heizzeitkonstante flach' : 'Heating time constant flat',
- options: withUnit(get(keyHeatingTimeFlat), 's'),
- expectedLen: rowCount,
- existsRe: /heating.*time.*flat/i,
- });
-}
-
-function getProductUrl(product: ProductData): string | null {
- if (!product.path) return null;
- return `https://klz-cables.com${product.path}`;
-}
-
-function drawKeyValueGrid(args: {
- title: string;
- items: Array<{ label: string; value: string }>;
- newPage: () => number;
- getPage: () => PDFPage;
- page: PDFPage;
- y: number;
- margin: number;
- contentWidth: number;
- contentMinY: number;
- font: PDFFont;
- fontBold: PDFFont;
- navy: ReturnType;
- darkGray: ReturnType;
- mediumGray: ReturnType;
- lightGray?: ReturnType;
- almostWhite?: ReturnType;
- allowNewPage?: boolean;
- boxed?: boolean;
-}): number {
- let { title, items, newPage, getPage, page, y, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, mediumGray } = args;
- const allowNewPage = args.allowNewPage ?? true;
- const boxed = args.boxed ?? false;
-
- const lightGray = args.lightGray ?? rgb(0.9020, 0.9137, 0.9294);
- const almostWhite = args.almostWhite ?? rgb(0.9725, 0.9765, 0.9804);
-
- // Inner layout (boxed vs. plain)
- // Keep a strict spacing system for more professional datasheets.
- const padX = boxed ? 16 : 0;
- const padY = boxed ? 14 : 0;
- const xBase = margin + padX;
- const innerWidth = contentWidth - padX * 2;
- const colGap = 16;
- const colW = (innerWidth - colGap) / 2;
- const rowH = 24;
- const headerH = boxed ? 22 : 0;
-
- const drawBoxFrame = (boxTopY: number, rowsCount: number) => {
- const boxH = padY + headerH + rowsCount * rowH + padY;
- page = getPage();
- page.drawRectangle({
- x: margin,
- y: boxTopY - boxH,
- width: contentWidth,
- height: boxH,
- borderColor: lightGray,
- borderWidth: 1,
- color: rgb(1, 1, 1),
- });
- // Header band for the title
- page.drawRectangle({
- x: margin,
- y: boxTopY - headerH,
- width: contentWidth,
- height: headerH,
- color: almostWhite,
- });
- return boxH;
- };
-
- const drawTitle = () => {
- page = getPage();
- if (boxed) {
- // Align title inside the header band.
- page.drawText(title, { x: xBase, y: y - 15, size: 11, font: fontBold, color: navy });
- // Divider line below header band
- page.drawLine({
- start: { x: margin, y: y - headerH },
- end: { x: margin + contentWidth, y: y - headerH },
- thickness: 0.75,
- color: lightGray,
- });
- y -= headerH + padY;
- } else {
- page.drawText(title, { x: margin, y, size: 10, font: fontBold, color: navy });
- y -= 16;
- }
- };
-
- if (y - 22 < contentMinY) {
- if (!allowNewPage) return contentMinY - 1;
- y = newPage();
- }
- page = getPage();
-
- // Boxed + multi-page: render separate boxes per page segment.
- if (boxed && allowNewPage) {
- let i = 0;
- while (i < items.length) {
- // Compute how many rows fit in the remaining space.
- const available = y - contentMinY;
- const maxRows = Math.max(1, Math.floor((available - (headerH + padY * 2)) / rowH));
- const maxItems = Math.max(2, maxRows * 2);
- const slice = items.slice(i, i + maxItems);
- const rowsCount = Math.ceil(slice.length / 2);
- const neededH = padY + headerH + rowsCount * rowH + padY;
-
- if (y - neededH < contentMinY) y = newPage();
- drawBoxFrame(y, rowsCount);
-
- drawTitle();
- let rowY = y;
- for (let j = 0; j < slice.length; j++) {
- const col = j % 2;
- const x = xBase + col * (colW + colGap);
- const { label, value } = slice[j];
- page.drawText(label, { x, y: rowY, size: 7.5, font: fontBold, color: mediumGray, maxWidth: colW });
- page.drawText(value, { x, y: rowY - 12, size: 9.5, font, color: darkGray, maxWidth: colW });
- if (col === 1) rowY -= rowH;
- }
-
- y = rowY - rowH - padY;
- i += slice.length;
- if (i < items.length) y = newPage();
- }
- return y;
- }
-
- // Boxed single-segment (current page) or plain continuation.
- if (boxed && items.length) {
- const rows = Math.ceil(items.length / 2);
- const neededH = padY + headerH + rows * rowH + padY;
- if (y - neededH < contentMinY) {
- if (!allowNewPage) return contentMinY - 1;
- y = newPage();
- }
- drawBoxFrame(y, rows);
- }
-
- drawTitle();
-
- let rowY = y;
- for (let i = 0; i < items.length; i++) {
- const col = i % 2;
- const x = xBase + col * (colW + colGap);
- const { label, value } = items[i];
-
- if (col === 0 && rowY - rowH < contentMinY) {
- if (!allowNewPage) return contentMinY - 1;
- y = newPage();
- page = getPage();
- if (!boxed) drawTitle();
- rowY = y;
- }
-
- // Don't truncate - allow text to fit naturally
- page.drawText(label, { x, y: rowY, size: 7.5, font: fontBold, color: mediumGray });
- page.drawText(value, { x, y: rowY - 12, size: 9.5, font, color: darkGray });
-
- if (col === 1) rowY -= rowH;
- }
-
- return boxed ? rowY - rowH - padY : rowY - rowH;
-}
-
-function ensureOutputDir(): void {
- if (!fs.existsSync(CONFIG.outputDir)) {
- fs.mkdirSync(CONFIG.outputDir, { recursive: true });
- }
-}
-
-const stripHtml = (html: string): string => {
- if (!html) return '';
- // IMPORTANT: Keep umlauts and common Latin-1 chars (e.g. ΓΌ/ΓΆ/Γ€/Γ) for DE PDFs.
- // pdf-lib's StandardFonts cover WinAnsi; we only normalize βproblematicβ typography.
- let text = html.replace(/<[^>]*>/g, '').normalize('NFC');
- text = text
- // whitespace normalization
- .replace(/[\u00A0\u202F]/g, ' ') // nbsp / narrow nbsp
- // typography normalization
- .replace(/[\u2013\u2014]/g, '-') // en/em dash
- .replace(/[\u2018\u2019]/g, "'") // curly single quotes
- .replace(/[\u201C\u201D]/g, '"') // curly double quotes
- .replace(/\u2026/g, '...') // ellipsis
- // symbols that can be missing in some encodings
- .replace(/[\u2022]/g, 'Β·') // bullet
- // math symbols (WinAnsi can't encode these)
- .replace(/[\u2264]/g, '<=') // β€
- .replace(/[\u2265]/g, '>=') // β₯
- .replace(/[\u2248]/g, '~') // β
- // electrical symbols (keep meaning; avoid encoding errors)
- .replace(/[\u03A9\u2126]/g, 'Ohm') // Ξ© / β¦
- // micro sign / greek mu (WinAnsi can't encode these reliably)
- .replace(/[\u00B5\u03BC]/g, 'u') // Β΅ / ΞΌ
- // directional arrows (WinAnsi can't encode these)
- .replace(/[\u2193]/g, 'v') // β
- .replace(/[\u2191]/g, '^') // β
- // degree symbol (WinAnsi-safe; keep for engineering units like Β°C)
- .replace(/[\u00B0]/g, 'Β°');
-
- // Remove control chars, keep all printable unicode.
- text = text.replace(/[\u0000-\u001F\u007F]/g, '');
- return text.replace(/\s+/g, ' ').trim();
-};
-
-const getLabels = (locale: 'en' | 'de') => ({
- en: {
- datasheet: 'PRODUCT DATASHEET',
- description: 'DESCRIPTION',
- specs: 'TECHNICAL SPECIFICATIONS',
- crossSection: 'CROSS-SECTION DATA',
- categories: 'CATEGORIES',
- sku: 'SKU',
- },
- de: {
- datasheet: 'PRODUKTDATENBLATT',
- description: 'BESCHREIBUNG',
- specs: 'TECHNISCHE SPEZIFIKATIONEN',
- crossSection: 'QUERSCHNITTSDATEN',
- categories: 'KATEGORIEN',
- sku: 'ARTIKELNUMMER',
- },
-})[locale];
-
-const generateFileName = (product: ProductData, locale: 'en' | 'de'): string => {
- const baseName = product.slug || product.translationKey || `product-${product.id}`;
- const cleanSlug = baseName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
- return `${cleanSlug}-${locale}.pdf`;
-};
-
-function wrapText(text: string, font: PDFFont, fontSize: number, maxWidth: number): string[] {
- const words = text.split(' ');
- const lines: string[] = [];
- let currentLine = '';
-
- const isOrphanWord = (w: string) => {
- // Avoid ugly single short words on their own line in DE/EN (e.g. βimβ, βinβ, βtoβ).
- // This is a typography/UX improvement for datasheets.
- const s = w.trim();
- return s.length > 0 && s.length <= 2;
- };
-
- for (let i = 0; i < words.length; i++) {
- const word = words[i];
- const next = i + 1 < words.length ? words[i + 1] : '';
- const testLine = currentLine ? `${currentLine} ${word}` : word;
-
- if (font.widthOfTextAtSize(testLine, fontSize) <= maxWidth) {
- // Orphan control: if adding the *next* word would overflow, don't end the line with a tiny orphan.
- // Example: "... mechanischen im" + "Belastungen" should become "... mechanischen" / "im Belastungen ...".
- if (currentLine && next && isOrphanWord(word)) {
- const testWithNext = `${testLine} ${next}`;
- if (font.widthOfTextAtSize(testWithNext, fontSize) > maxWidth) {
- lines.push(currentLine);
- currentLine = word;
- continue;
- }
- }
-
- currentLine = testLine;
- } else {
- if (currentLine) lines.push(currentLine);
- currentLine = word;
- }
- }
- if (currentLine) lines.push(currentLine);
- return lines;
-}
-
-function ellipsizeToWidth(text: string, font: PDFFont, fontSize: number, maxWidth: number): string {
- // WinAnsi-safe ellipsis ("...")
- const t = normalizeValue(text);
- if (!t) return '';
- if (font.widthOfTextAtSize(t, fontSize) <= maxWidth) return t;
-
- const ellipsis = '...';
- const eW = font.widthOfTextAtSize(ellipsis, fontSize);
- if (eW >= maxWidth) return '';
-
- // Binary search for max prefix that fits.
- let lo = 0;
- let hi = t.length;
- while (lo < hi) {
- const mid = Math.ceil((lo + hi) / 2);
- const s = t.slice(0, mid);
- if (font.widthOfTextAtSize(s, fontSize) + eW <= maxWidth) lo = mid;
- else hi = mid - 1;
- }
- const cut = Math.max(0, lo);
- return `${t.slice(0, cut)}${ellipsis}`;
-}
-
-function resolveMediaToLocalPath(urlOrPath: string | null | undefined): string | null {
- if (!urlOrPath) return null;
-
- // 1) Already public-relative.
- if (urlOrPath.startsWith('/')) return urlOrPath;
-
- // 2) Some datasets store "media/..." without leading slash.
- if (/^media\//i.test(urlOrPath)) return `/${urlOrPath}`;
-
- // 3) Asset-map can return a few different shapes; normalize them.
- const mapped = ASSET_MAP[urlOrPath];
- if (mapped) {
- if (mapped.startsWith('/')) return mapped;
- if (/^public\//i.test(mapped)) return `/${mapped.replace(/^public\//i, '')}`;
- if (/^media\//i.test(mapped)) return `/${mapped}`;
- return mapped;
- }
-
- // 4) Fallback (remote URL or unrecognized local path).
- return urlOrPath;
-}
-
-async function fetchBytes(url: string): Promise {
- 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 {
- 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 {
- // pdf-lib supports PNG/JPG. We normalize everything (webp/svg/jpg/png) to PNG to keep embedding simple.
- const ext = (path.extname(inputHint).toLowerCase() || '').replace('.', '');
- if (ext === 'png') return inputBytes;
-
- // Special-case the logo SVG to render as dark for print.
- if (ext === 'svg' && /\/media\/logo\.svg$/i.test(inputHint)) {
- const svg = Buffer.from(inputBytes).toString('utf8');
- inputBytes = new Uint8Array(Buffer.from(transformLogoSvgToPrintBlack(svg), 'utf8'));
- }
-
- const sharp = await getSharp();
- // Preserve alpha where present (some product images are transparent).
- return new Uint8Array(await sharp(Buffer.from(inputBytes)).png().toBuffer());
-}
-
-type TableColumn = {
- key?: string;
- label: string;
- get: (rowIndex: number) => string;
-};
-
-function buildProductAttrIndex(product: ProductData): Record {
- const idx: Record = {};
- for (const a of product.attributes || []) {
- idx[normalizeValue(a.name).toLowerCase()] = a;
- }
- return idx;
-}
-
-function getAttrCellValue(attr: ProductData['attributes'][number] | undefined, rowIndex: number, rowCount: number): string {
- if (!attr) return '';
- if (!attr.options || attr.options.length === 0) return '';
- if (rowCount > 0 && attr.options.length === rowCount) return normalizeValue(attr.options[rowIndex]);
- if (attr.options.length === 1) return normalizeValue(attr.options[0]);
- // Unknown mapping: do NOT guess (this was the main source of "wrong" tables).
- return '';
-}
-
-function drawTableChunked(args: {
- title: string;
- configRows: string[];
- columns: Array<{ key: string; label: string; get: (rowIndex: number) => string }>;
- firstColLabel?: string;
- dense?: boolean;
- onePage?: boolean;
- cellFormatter?: (value: string, columnKey: string) => string;
- locale: 'en' | 'de';
- newPage: () => number;
- getPage: () => PDFPage;
- page: PDFPage;
- y: number;
- margin: number;
- contentWidth: number;
- contentMinY: number;
- font: PDFFont;
- fontBold: PDFFont;
- navy: ReturnType;
- darkGray: ReturnType;
- lightGray: ReturnType;
- almostWhite: ReturnType;
- maxDataColsPerTable: number;
-}): number {
- let {
- title,
- configRows,
- columns,
- newPage,
- getPage,
- page,
- y,
- margin,
- contentWidth,
- contentMinY,
- font,
- fontBold,
- navy,
- darkGray,
- lightGray,
- almostWhite,
- maxDataColsPerTable,
- } = args;
-
- const dense = args.dense ?? false;
- const onePage = args.onePage ?? false;
- const formatCell = args.cellFormatter ?? ((v: string) => v);
-
- // pdf-lib will wrap text when `maxWidth` is set.
- // For dense technical tables we want *no wrapping* (clip instead), so we replace spaces with NBSP.
- const noWrap = (s: string) => (dense ? String(s).replace(/ /g, '\u00A0') : String(s));
-
- // pdf-lib does not clip text to maxWidth; it only uses it for line breaking.
- // To prevent header/body text from overlapping into neighboring columns, we manually truncate.
- const truncateToWidth = (text: string, f: PDFFont, size: number, maxW: number): string => {
- // IMPORTANT: do NOT call normalizeValue() here.
- // We may have inserted NBSP to force no-wrapping; normalizeValue() would convert it back to spaces.
- const t = String(text)
- .replace(/[\r\n\t]+/g, ' ')
- .replace(/\s+/g, ' ')
- .trim();
- if (!t) return '';
- if (maxW <= 4) return '';
- if (f.widthOfTextAtSize(t, size) <= maxW) return t;
- const ellipsis = 'β¦';
- const eW = f.widthOfTextAtSize(ellipsis, size);
- if (eW >= maxW) return '';
-
- // Binary search for max prefix that fits.
- let lo = 0;
- let hi = t.length;
- while (lo < hi) {
- const mid = Math.ceil((lo + hi) / 2);
- const s = t.slice(0, mid);
- if (f.widthOfTextAtSize(s, size) + eW <= maxW) lo = mid;
- else hi = mid - 1;
- }
- const cut = Math.max(0, lo);
- return `${t.slice(0, cut)}${ellipsis}`;
- };
-
- // Dense table style (more columns, compact typography)
- // IMPORTANT: header labels must stay on a single line (no wrapped/stacked headers).
- const headerH = dense ? 16 : 16;
- let rowH = dense ? 16 : 16; // Increased to prevent row overlap
- let bodyFontSize = dense ? 6.5 : 8;
- let headerFontSize = dense ? 6.5 : 8;
-
- const headerFill = dense ? rgb(0.42, 0.44, 0.45) : lightGray;
- const headerText = dense ? rgb(1, 1, 1) : navy;
- let cellPadX = dense ? 4 : 6;
-
- const headerLabelFor = (col: { label: string; key?: string }): string => {
- // Dense tables: use compact cable industry abbreviations
- const raw = normalizeValue(col.label);
- if (!dense) return raw;
-
- const b = raw.toLowerCase();
- const key = col.key || '';
- const hasUnit = /\(|\[/.test(raw);
-
- const labelWithUnit = (abbr: string, defaultUnit: string): string => {
- // Prefer the actual unit we already have in the incoming label (from Excel units row).
- // Example: raw = "DC resistance at 20 Β°C (Ξ©/km)" => keep Ξ©/km.
- const m = raw.match(/[\[(]\s*([^\])]+?)\s*[\])]/);
- const unit = normalizeValue(m?.[1] || '') || defaultUnit;
- // WinAnsi safe: Ξ© => Ohm, Β΅ => u (handled by stripHtml/normalizeValue)
- const unitSafe = unit.replace(/Ξ©/gi, 'Ohm').replace(/[\u00B5\u03BC]/g, 'u');
- return `${abbr} [${unitSafe}]`;
- };
-
- // Cable industry standard abbreviations (compact, WinAnsi-safe)
- // Column set:
- // - Bezeichnung (first column)
- // - DI, RI, Wi, Ibl, Ibe, Ik, Wm, Rbv, Γ, Fzv, Al, Cu, G
- if (key === 'configuration') return args.locale === 'de' ? 'Bezeichnung' : 'Designation';
- if (key === 'DI' || /diameter\s+over\s+insulation/i.test(b)) return labelWithUnit('DI', 'mm');
- if (key === 'RI' || /dc\s*resistance/i.test(b) || /resistance\s+conductor/i.test(b)) return labelWithUnit('RI', 'Ohm/km');
- if (key === 'Wi' || /insulation\s+thickness/i.test(b) || /nominal\s+insulation\s+thickness/i.test(b)) return labelWithUnit('Wi', 'mm');
- if (key === 'Ibl' || /current\s+ratings\s+in\s+air.*trefoil/i.test(b)) return labelWithUnit('Ibl', 'A');
- if (key === 'Ibe' || /current\s+ratings\s+in\s+ground.*trefoil/i.test(b)) return labelWithUnit('Ibe', 'A');
- if (key === 'Ik_cond' || /conductor.*shortcircuit/i.test(b)) return labelWithUnit('Ik', 'kA');
- if (key === 'Wm' || /sheath\s+thickness/i.test(b) || /minimum\s+sheath\s+thickness/i.test(b)) return labelWithUnit('Wm', 'mm');
- // Rbv can be given in mm or as xD (LV/solar). We keep the unit from the label.
- if (key === 'Rbv' || /bending\s+radius/i.test(b)) return labelWithUnit('Rbv', 'mm');
- if (key === 'Γ' || /outer\s+diameter/i.test(b) || /outer\s+diameter\s+of\s+cable/i.test(b)) return labelWithUnit('Γ', 'mm');
- if (key === 'Fzv' || /pulling\s+force/i.test(b)) return labelWithUnit('Fzv', 'N');
- if (key === 'Al') return 'Al';
- if (key === 'Cu') return 'Cu';
- if (key === 'G' || /\bweight\b/i.test(b) || /cable\s+weight/i.test(b)) return labelWithUnit('G', 'kg/km');
-
- // Cross-section (always needed as first column)
- if (/number of cores and cross-section/i.test(b) || /querschnitt/i.test(b)) {
- return args.locale === 'de' ? 'QS' : 'CS';
- }
-
- // Additional technical columns (WinAnsi-compatible abbreviations)
- if (key === 'cond_diam') return labelWithUnit('D_cond', 'mm');
- if (key === 'D_screen') return labelWithUnit('D_scr', 'mm');
- if (key === 'S_screen') return labelWithUnit('A_scr', 'mm2');
- if (key === 'cap') return labelWithUnit('C', 'uF/km');
- if (key === 'X') return labelWithUnit('X', 'Ohm/km');
- if (key === 'ind_trefoil') return labelWithUnit('L_t', 'mH/km');
- if (key === 'ind_air_flat') return labelWithUnit('L_af', 'mH/km');
- if (key === 'ind_ground_flat') return labelWithUnit('L_gf', 'mH/km');
- if (key === 'cur_air_flat') return labelWithUnit('I_af', 'A');
- if (key === 'cur_ground_flat') return labelWithUnit('I_gf', 'A');
- if (key === 'heat_trefoil') return labelWithUnit('t_th_t', 's');
- if (key === 'heat_flat') return labelWithUnit('t_th_f', 's');
- if (key === 'max_op_temp') return labelWithUnit('T_op', 'Β°C');
- if (key === 'max_sc_temp') return labelWithUnit('T_sc', 'Β°C');
- if (key === 'temp_range') return labelWithUnit('T', 'Β°C');
- if (key === 'min_store_temp') return labelWithUnit('T_st', 'Β°C');
- if (key === 'min_lay_temp') return labelWithUnit('T_lay', 'Β°C');
- if (key === 'test_volt') return labelWithUnit('U_test', 'kV');
- if (key === 'rated_volt') return labelWithUnit('U_0/U', 'kV');
- if (key === 'conductor') return 'Cond';
- if (key === 'insulation') return 'Iso';
- if (key === 'sheath') return 'Sh';
- if (key === 'norm') return 'Norm';
- if (key === 'standard') return 'Std';
- if (key === 'cpr') return 'CPR';
- if (key === 'flame') return 'FR';
- if (key === 'packaging') return 'Pack';
- if (key === 'ce') return 'CE';
- if (key === 'shape') return 'Shape';
- if (key === 'color_ins') return 'C_iso';
- if (key === 'color_sheath') return 'C_sh';
- if (key === 'tape_below') return 'Tape v';
- if (key === 'copper_screen') return 'Cu Scr';
- if (key === 'tape_above') return 'Tape ^';
- if (key === 'al_foil') return 'Al Foil';
-
- // Fallback: keep Excel label (will be truncated)
- return raw;
- };
-
- // Always include a first column (configuration / cross-section).
- const configCol = {
- key: 'configuration',
- label: args.firstColLabel || (args.locale === 'de' ? 'Konfiguration' : 'Configuration'),
- get: (i: number) => normalizeValue(configRows[i] || ''),
- };
-
- const chunks: Array = [];
- for (let i = 0; i < columns.length; i += maxDataColsPerTable) {
- chunks.push(columns.slice(i, i + maxDataColsPerTable));
- }
-
- for (let ci = 0; ci < Math.max(1, chunks.length); ci++) {
- // Ensure we always draw on the current page reference.
- page = getPage();
-
- const chunkCols = chunks.length ? chunks[ci] : [];
- // UX: never show chunk fractions like "(1/2)".
- // If we need multiple chunks, we keep the same title and paginate naturally.
- const chunkTitle = title;
- const tableCols: TableColumn[] = [configCol, ...chunkCols];
-
- // Header labels (may be simplified for dense tables).
- const headerLabels = tableCols.map(c => headerLabelFor({ label: c.label, key: c.key }));
-
- // Auto-fit column widths to content
- // Calculate required width for each column based on header and sample data
- // For dense tables with many columns, use more generous minimums
- const isDenseManyColumns = dense && tableCols.length >= 12;
-
- // When rendering many columns, auto-compact typography BEFORE measuring widths.
- // This prevents overflow where the later columns end up drawn off-page.
- if (isDenseManyColumns) {
- bodyFontSize = Math.max(5.3, Math.min(bodyFontSize, 5.8));
- headerFontSize = Math.max(5.1, Math.min(headerFontSize, 5.5));
- rowH = Math.max(9, Math.min(rowH, 10));
- cellPadX = 3;
- }
-
- const minColWidth = isDenseManyColumns ? 24 : 20; // Minimum width in points
- const maxColWidth = isDenseManyColumns ? 110 : 120; // Cap widths for dense tables
- const colGap = isDenseManyColumns ? 1 : 2; // Gap between columns (smaller for dense)
-
- // Calculate required width for each column
- const requiredWidths = tableCols.map((col, i) => {
- const key = col.key || '';
- const headerLabel = headerLabels[i] || headerLabelFor({ label: col.label, key: col.key });
-
- // Prefer key-aware constraints so the table is readable on A4.
- // - configuration needs space (long designations)
- // - numeric columns should stay compact
- const isCfg = key === 'configuration';
- // NOTE: keep configuration column capped; otherwise it starves the numeric columns.
- const cfgMaxAbs = dense ? 150 : 180;
- const cfgMaxRel = Math.floor(contentWidth * (dense ? 0.26 : 0.30));
- const cfgMax = Math.max(120, Math.min(cfgMaxAbs, cfgMaxRel));
- const cfgMin = dense ? 90 : 120;
-
- const minW = isCfg ? cfgMin : minColWidth;
- const maxW = isCfg ? cfgMax : (isDenseManyColumns ? 72 : maxColWidth);
-
- // Measure header width
- const headerWidth = fontBold.widthOfTextAtSize(headerLabel, headerFontSize);
-
- // Measure sample data widths using the *rendered* value (apply cell formatter).
- // Using only raw values can under-estimate (e.g. locale decimal, derived values).
- let maxDataWidth = 0;
- const sampleRows = Math.min(10, configRows.length);
- for (let r = 0; r < sampleRows; r++) {
- const rawVal = col.get(r);
- const rendered = formatCell(rawVal, key);
- const dataWidth = font.widthOfTextAtSize(String(rendered), bodyFontSize);
- maxDataWidth = Math.max(maxDataWidth, dataWidth);
- }
-
- // Take the maximum of header and data
- const padding = isDenseManyColumns ? cellPadX * 1.5 : cellPadX * 2;
- const contentWidthNeeded = Math.max(headerWidth, maxDataWidth) + padding;
-
- // Clamp to min/max
- return Math.max(minW, Math.min(maxW, contentWidthNeeded));
- });
-
- // Calculate total required width (including gaps)
- const baseTotal = requiredWidths.reduce((sum, w) => sum + w, 0) + (tableCols.length - 1) * colGap;
-
- // If we have extra space, give it primarily to the configuration column
- // (best readability gain) and then distribute any remainder proportionally.
- const widthsWithExtra = [...requiredWidths];
- if (baseTotal < contentWidth && tableCols.length > 0) {
- let remaining = contentWidth - baseTotal;
-
- const cfgIndex = tableCols.findIndex(c => (c.key || '') === 'configuration');
- if (cfgIndex >= 0 && remaining > 0) {
- // Only give the configuration column a controlled share of the remaining space.
- // This keeps numeric columns readable and consistent across tables.
- const cfgMaxAbs = dense ? 150 : 180;
- const cfgMaxRel = Math.floor(contentWidth * (dense ? 0.26 : 0.30));
- const cfgMax = Math.max(120, Math.min(cfgMaxAbs, cfgMaxRel));
-
- const cfgRemainingCap = Math.min(remaining, Math.floor(remaining * (dense ? 0.35 : 0.45)));
- const add = Math.max(0, Math.min(cfgMax - widthsWithExtra[cfgIndex], cfgRemainingCap));
- widthsWithExtra[cfgIndex] += add;
- remaining -= add;
- }
-
- if (remaining > 0) {
- const sum = widthsWithExtra.reduce((a, b) => a + b, 0) || 1;
- for (let i = 0; i < widthsWithExtra.length; i++) {
- if (remaining <= 0) break;
- const share = widthsWithExtra[i] / sum;
- const add = Math.min(remaining, remaining * share);
- widthsWithExtra[i] += add;
- remaining -= add;
- }
- }
- }
-
- const totalRequiredWidth = widthsWithExtra.reduce((sum, w) => sum + w, 0) + (tableCols.length - 1) * colGap;
-
- // Scale to fit available content width if needed
- // Dense tables MUST fit on the page; allow stronger scaling when needed.
- let scaleFactor = totalRequiredWidth > contentWidth ? contentWidth / totalRequiredWidth : 1;
- if (!isDenseManyColumns) {
- // Keep regular tables from becoming too small.
- scaleFactor = Math.max(scaleFactor, 0.9);
- }
-
- // Scale widths (gaps are also scaled)
- const widthsPt = widthsWithExtra.map(w => w * scaleFactor);
- const scaledGap = colGap * scaleFactor;
-
- const ensureSpace = (needed: number) => {
- if (y - needed < contentMinY) y = newPage();
- page = getPage();
- };
-
- // One-page mode: adapt row height so the full voltage table can fit on the page.
- if (onePage) {
- const rows = configRows.length;
- const available = y - contentMinY - 10 - 12 /*title*/;
- const maxRowH = Math.floor((available - headerH) / Math.max(1, rows));
- rowH = Math.max(8, Math.min(rowH, maxRowH));
- bodyFontSize = Math.max(6, Math.min(bodyFontSize, rowH - 3));
- headerFontSize = Math.max(6, Math.min(headerFontSize, rowH - 3));
- }
-
- ensureSpace(14 + headerH + rowH * 2);
- if (chunkTitle) {
- page.drawText(chunkTitle, {
- x: margin,
- y,
- size: 10,
- font: fontBold,
- color: navy,
- });
- y -= dense ? 14 : 16;
- }
-
- // If we are too close to the footer after the title, break before drawing the header.
- if (y - headerH - rowH < contentMinY) {
- y = newPage();
- page = getPage();
- if (chunkTitle) {
- page.drawText(chunkTitle, {
- x: margin,
- y,
- size: 10,
- font: fontBold,
- color: navy,
- });
- y -= 16;
- }
- }
-
- const drawHeader = () => {
- page = getPage();
- page.drawRectangle({
- x: margin,
- y: y - headerH,
- width: contentWidth,
- height: headerH,
- color: headerFill,
- });
-
- const headerTextY = y - headerH + Math.floor((headerH - headerFontSize) / 2) + 1;
- let x = margin;
- for (let i = 0; i < tableCols.length; i++) {
- const hl = headerLabels[i] || headerLabelFor({ label: tableCols[i].label, key: tableCols[i].key });
- const colWidth = widthsPt[i];
- const colMaxW = colWidth - cellPadX * 2;
-
- // Overlap guard: always truncate to column width (pdf-lib doesn't clip by itself).
- // Don't truncate headers - use auto-fit widths
- page.drawText(noWrap(String(hl)), {
- x: x + cellPadX,
- y: headerTextY,
- size: headerFontSize,
- font: fontBold,
- color: headerText,
- // DO NOT set maxWidth in dense mode (it triggers wrapping instead of clipping).
- ...(dense ? {} : { maxWidth: colMaxW }),
- });
- x += colWidth + scaledGap;
- }
- y -= headerH;
- };
-
- drawHeader();
-
- for (let r = 0; r < configRows.length; r++) {
- if (!onePage && y - rowH < contentMinY) {
- y = newPage();
- page = getPage();
- if (chunkTitle) {
- page.drawText(chunkTitle, {
- x: margin,
- y,
- size: 12,
- font: fontBold,
- color: navy,
- });
- y -= 16;
- }
- drawHeader();
- }
-
- if (onePage && y - rowH < contentMinY) {
- // In one-page mode we must not paginate. Clip remaining rows.
- break;
- }
-
- if (r % 2 === 0) {
- page.drawRectangle({
- x: margin,
- y: y - rowH,
- width: contentWidth,
- height: rowH,
- color: almostWhite,
- });
- }
-
- let x = margin;
- for (let c = 0; c < tableCols.length; c++) {
- const raw = tableCols[c].get(r);
- const txt = formatCell(raw, tableCols[c].key || '');
- const colWidth = widthsPt[c];
- const colMaxW = colWidth - cellPadX * 2;
- // Don't truncate - use auto-fit widths to ensure everything fits
- page.drawText(noWrap(txt), {
- x: x + cellPadX,
- y: y - (rowH - 5), // Adjusted for new rowH of 16
- size: bodyFontSize,
- font,
- color: darkGray,
- ...(dense ? {} : { maxWidth: colMaxW }),
- });
- x += colWidth + scaledGap;
- }
-
- y -= rowH;
- }
-
- y -= dense ? 20 : 24;
- }
-
- return y;
-}
-
-async function loadEmbeddablePng(
- src: string | null | undefined,
-): Promise<{ pngBytes: Uint8Array; debugLabel: string } | null> {
- const resolved = resolveMediaToLocalPath(src);
- if (!resolved) return null;
-
- try {
- // Prefer local files for stability and speed.
- if (resolved.startsWith('/')) {
- const bytes = await readBytesFromPublic(resolved);
- return { pngBytes: await toPngBytes(bytes, resolved), debugLabel: resolved };
- }
-
- // Remote (fallback)
- const bytes = await fetchBytes(resolved);
- return { pngBytes: await toPngBytes(bytes, resolved), debugLabel: resolved };
- } catch {
- return null;
- }
-}
-
-async function loadQrPng(data: string): Promise<{ pngBytes: Uint8Array; debugLabel: string } | null> {
- // External QR generator (no extra dependency). This must stay resilient; if it fails, we fall back to URL text.
- try {
- const safe = encodeURIComponent(data);
- const url = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${safe}`;
- const bytes = await fetchBytes(url);
- // Already PNG but normalize anyway.
- return { pngBytes: await toPngBytes(bytes, url), debugLabel: url };
- } catch {
- return null;
- }
-}
-
-type SectionDrawContext = {
- pdfDoc: PDFDocument;
- page: PDFPage;
- width: number;
- height: number;
- margin: number;
- contentWidth: number;
- footerY: number;
- contentMinY: number;
- headerDividerY: number;
- colors: {
- navy: ReturnType;
- mediumGray: ReturnType;
- darkGray: ReturnType;
- almostWhite: ReturnType;
- lightGray: ReturnType;
- headerBg: ReturnType;
- };
- fonts: {
- regular: PDFFont;
- bold: PDFFont;
- };
- labels: ReturnType;
- 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 }, margin: number, footerY: number): void {
- const pages = pdfDoc.getPages();
- const total = pages.length;
- for (let i = 0; i < total; i++) {
- const page = pages[i];
- const { width } = page.getSize();
- const text = `${i + 1}/${total}`;
- page.drawText(text, {
- x: width - margin - fonts.regular.widthOfTextAtSize(text, 8),
- y: footerY - 12,
- size: 8,
- font: fonts.regular,
- color: colors.mediumGray,
- });
- }
-}
-
-function drawHeader(ctx: SectionDrawContext, yStart: number): number {
- const { page, width, margin, contentWidth, fonts, colors, logoImage, qrImage, qrUrl, labels, product } = ctx;
-
- // Cable-industry look: calm, engineered header with right-aligned meta.
- // Keep header compact to free vertical space for technical tables.
- const headerH = 52;
- const dividerY = yStart - headerH;
- ctx.headerDividerY = dividerY;
-
- page.drawRectangle({
- x: 0,
- y: dividerY,
- width,
- height: headerH,
- color: colors.headerBg,
- });
-
- const qrSize = 36;
- const qrGap = 12;
- const rightReserved = qrImage ? qrSize + qrGap : 0;
-
- // Left: logo (preferred) or typographic fallback
- if (logoImage) {
- const maxLogoW = 120;
- const maxLogoH = 24;
- const scale = Math.min(maxLogoW / logoImage.width, maxLogoH / logoImage.height);
- const w = logoImage.width * scale;
- const h = logoImage.height * scale;
- const logoY = dividerY + Math.round((headerH - h) / 2);
- page.drawImage(logoImage, {
- x: margin,
- y: logoY,
- width: w,
- height: h,
- });
- } else {
- const baseY = dividerY + 22;
- page.drawText('KLZ', {
- x: margin,
- y: baseY,
- size: 22,
- font: fonts.bold,
- color: colors.navy,
- });
- page.drawText('Cables', {
- x: margin + fonts.bold.widthOfTextAtSize('KLZ', 22) + 4,
- y: baseY + 2,
- size: 10,
- font: fonts.regular,
- color: colors.mediumGray,
- });
- }
-
- // Right: datasheet meta + QR (if available)
- const metaRightEdge = width - margin - rightReserved;
- const metaTitle = labels.datasheet;
- const metaTitleSize = 9;
-
- const mtW = fonts.bold.widthOfTextAtSize(metaTitle, metaTitleSize);
- // With SKU removed, vertically center the title within the header block.
- const metaY = dividerY + Math.round(headerH / 2 - metaTitleSize / 2);
- page.drawText(metaTitle, {
- x: metaRightEdge - mtW,
- y: metaY,
- size: metaTitleSize,
- font: fonts.bold,
- color: colors.navy,
- });
-
- if (qrImage) {
- const qrX = width - margin - qrSize;
- const qrY = dividerY + Math.round((headerH - qrSize) / 2);
- page.drawImage(qrImage, { x: qrX, y: qrY, width: qrSize, height: qrSize });
- } else {
- // If QR generation failed, keep the URL available as a compact line.
- const maxW = 260;
- const urlLines = wrapText(qrUrl, fonts.regular, 8, maxW).slice(0, 1);
- if (urlLines.length) {
- const line = urlLines[0];
- const w = fonts.regular.widthOfTextAtSize(line, 8);
- page.drawText(line, {
- x: width - margin - w,
- y: dividerY + 12,
- size: 8,
- font: fonts.regular,
- color: colors.mediumGray,
- });
- }
- }
-
- // Divider line
- page.drawLine({
- start: { x: margin, y: dividerY },
- end: { x: margin + contentWidth, y: dividerY },
- thickness: 0.75,
- color: colors.lightGray,
- });
-
- // Content start: keep breathing room below the header, but use page height efficiently.
- return dividerY - 22;
-}
-
-function drawCrossSectionChipsRow(args: {
- title: string;
- configRows: string[];
- locale: 'en' | 'de';
- maxLinesCap?: number;
- getPage: () => PDFPage;
- page: PDFPage;
- y: number;
- margin: number;
- contentWidth: number;
- contentMinY: number;
- font: PDFFont;
- fontBold: PDFFont;
- navy: ReturnType;
- darkGray: ReturnType;
- mediumGray: ReturnType;
- lightGray: ReturnType;
- almostWhite: ReturnType;
-}): 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();
- 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();
- 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;
-}): 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;
- darkGray: ReturnType;
- lightGray: ReturnType;
- almostWhite: ReturnType;
-}): 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 {
- try {
- const labels = getLabels(locale);
- const pdfDoc = await PDFDocument.create();
- const pageSizePortrait: [number, number] = [595.28, 841.89]; // DIN A4 portrait
- const pageSizeLandscape: [number, number] = [841.89, 595.28]; // DIN A4 landscape
- let page = pdfDoc.addPage(pageSizePortrait);
- const { width, height } = page.getSize();
-
- // STYLEGUIDE.md colors
- const navy = rgb(0.0549, 0.1647, 0.2784); // #0E2A47
- const mediumGray = rgb(0.4196, 0.4471, 0.5020); // #6B7280
- const darkGray = rgb(0.1216, 0.1608, 0.2); // #1F2933
- const almostWhite = rgb(0.9725, 0.9765, 0.9804); // #F8F9FA
- const lightGray = rgb(0.9020, 0.9137, 0.9294); // #E6E9ED
- const headerBg = rgb(0.965, 0.972, 0.98); // calm, print-friendly tint
-
- // Small design system: consistent type + spacing for professional datasheets.
- const DS = {
- space: { xs: 4, sm: 8, md: 12, lg: 16, xl: 24 },
- type: { h1: 20, h2: 11, body: 10.5, small: 8 },
- rule: { thin: 0.75 },
- } as const;
-
- // Line-heights (explicit so vertical rhythm doesn't drift / overlap)
- const LH = {
- h1: 24,
- h2: 16,
- body: 14,
- small: 10,
- } as const;
-
- const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
- const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
-
- // Assets
- // Prefer a raster logo for reliability (sharp SVG support can vary between environments).
- const logoPng = (await loadEmbeddablePng('/media/logo.png')) || (await loadEmbeddablePng('/media/logo.svg'));
- const logoImage = logoPng ? await pdfDoc.embedPng(logoPng.pngBytes) : null;
-
- // Some products have no product-specific images.
- // Do NOT fall back to a generic/category hero (misleading in datasheets).
- // If missing, we render a neutral placeholder box.
- const heroSrc = product.featuredImage || product.images?.[0] || null;
- const heroPng = heroSrc ? await loadEmbeddablePng(heroSrc) : null;
-
- const productUrl = getProductUrl(product) || CONFIG.siteUrl;
- const qrPng = await loadQrPng(productUrl);
- const qrImage = qrPng ? await pdfDoc.embedPng(qrPng.pngBytes) : null;
-
- // Engineered page frame (A4): slightly narrower margins but consistent rhythm.
- const margin = 54;
- const footerY = 54;
- const contentMinY = footerY + 42; // keep clear of footer + page numbers
- const contentWidth = width - 2 * margin;
-
- const ctx: SectionDrawContext = {
- pdfDoc,
- page,
- width,
- height,
- margin,
- contentWidth,
- footerY,
- contentMinY,
- headerDividerY: 0,
- colors: { navy, mediumGray, darkGray, almostWhite, lightGray, headerBg },
- fonts: { regular: font, bold: fontBold },
- labels,
- product,
- locale,
- logoImage,
- qrImage,
- qrUrl: productUrl,
- };
-
- const syncCtxForPage = (p: PDFPage) => {
- const sz = p.getSize();
- ctx.page = p;
- ctx.width = sz.width;
- ctx.height = sz.height;
- ctx.contentWidth = sz.width - 2 * ctx.margin;
- };
-
- const drawPageBackground = (p: PDFPage) => {
- const { width: w, height: h } = p.getSize();
- p.drawRectangle({
- x: 0,
- y: 0,
- width: w,
- height: h,
- color: rgb(1, 1, 1),
- });
- };
-
- const drawProductNameOnPage = (p: PDFPage, yStart: number): number => {
- const name = stripHtml(product.name);
- const maxW = ctx.contentWidth;
- const line = wrapText(name, fontBold, 12, maxW).slice(0, 1)[0] || name;
- p.drawText(line, {
- x: margin,
- y: yStart,
- size: 12,
- font: fontBold,
- color: navy,
- maxWidth: maxW,
- });
- return yStart - 18;
- };
-
- // Real multi-page support.
- // Each new page repeats header + footer for print-friendly, consistent scanning.
- const newPage = (opts?: { includeProductName?: boolean; landscape?: boolean }): number => {
- page = pdfDoc.addPage(opts?.landscape ? pageSizeLandscape : pageSizePortrait);
- syncCtxForPage(page);
- drawPageBackground(page);
- drawFooter(ctx);
- let yStart = drawHeader(ctx, ctx.height - ctx.margin);
- if (opts?.includeProductName) yStart = drawProductNameOnPage(page, yStart);
- return yStart;
- };
-
- const hasSpace = (needed: number) => y - needed >= contentMinY;
-
- // ---- Layout helpers (eliminate magic numbers; enforce consistent rhythm) ----
- const rule = (gapAbove: number = DS.space.md, gapBelow: number = DS.space.lg) => {
- // One-page rule: if we can't fit a divider with its spacing, do nothing.
- if (!hasSpace(gapAbove + gapBelow + DS.rule.thin)) return;
-
- y -= gapAbove;
- page.drawLine({
- start: { x: margin, y },
- end: { x: margin + contentWidth, y },
- thickness: DS.rule.thin,
- color: lightGray,
- });
- y -= gapBelow;
- };
-
- const sectionTitle = (text: string) => {
- // One-page rule: if we can't fit the heading + its gap, do nothing.
- if (!hasSpace(DS.type.h2 + DS.space.md)) return;
-
- page.drawText(text, {
- x: margin,
- y,
- size: DS.type.h2,
- font: fontBold,
- color: navy,
- });
- // Use a real line-height to avoid title/body overlap.
- y -= LH.h2;
- };
-
- // Page 1
- syncCtxForPage(page);
- drawPageBackground(page);
- drawFooter(ctx);
- let y = drawHeader(ctx, ctx.height - ctx.margin);
-
- // === PRODUCT HEADER ===
- const productName = stripHtml(product.name);
- const cats = (product.categories || []).map(c => stripHtml(c.name)).join(' β’ ');
-
- // First page: keep the header compact so technical tables start earlier.
- const titleW = contentWidth;
- const titleSize = 18;
- const titleLineH = 21;
- const nameLines = wrapText(productName, fontBold, titleSize, titleW);
- const shownNameLines = nameLines.slice(0, 1);
- for (const line of shownNameLines) {
- if (y - titleLineH < contentMinY) y = newPage();
- page.drawText(line, {
- x: margin,
- y,
- size: titleSize,
- font: fontBold,
- color: navy,
- maxWidth: titleW,
- });
- y -= titleLineH;
- }
-
- if (cats) {
- if (y - 18 < contentMinY) y = newPage();
- page.drawText(cats, {
- x: margin,
- y,
- size: 10.5,
- font,
- color: mediumGray,
- maxWidth: titleW,
- });
- y -= DS.space.md;
- }
-
- // Separator after product header
- // No dividing lines on first page (cleaner, more space-efficient).
- // rule(DS.space.xs, DS.space.md);
-
- // === HERO IMAGE (full width) ===
- // Dense technical products need more room for specs; prioritize content over imagery.
- const hasLotsOfTech = (product.attributes?.length || 0) >= 18;
- let heroH = hasLotsOfTech ? 96 : 120;
- const afterHeroGap = DS.space.lg;
- if (!hasSpace(heroH + afterHeroGap)) {
- // Shrink to remaining space (but keep it usable).
- heroH = Math.max(84, Math.floor(y - contentMinY - afterHeroGap));
- }
-
- const heroBoxX = margin;
- const heroBoxY = y - heroH;
- page.drawRectangle({
- x: heroBoxX,
- y: heroBoxY,
- width: contentWidth,
- height: heroH,
- // Calm frame; gives images consistent presence even with transparency.
- color: almostWhite,
- borderColor: lightGray,
- borderWidth: 1,
- });
-
- if (heroPng) {
- const pad = DS.space.md;
- const boxW = contentWidth - pad * 2;
- const boxH = heroH - pad * 2;
-
- // Fit image into the box without cutting it off (contain).
- // Technical images often must remain fully visible.
- const sharp = await getSharp();
- const contained = await sharp(Buffer.from(heroPng.pngBytes))
- .resize({
- width: 1200,
- height: Math.round((1200 * boxH) / boxW),
- fit: 'contain',
- background: { r: 248, g: 249, b: 250, alpha: 1 },
- })
- .png()
- .toBuffer();
- const heroImage = await pdfDoc.embedPng(contained);
-
- page.drawImage(heroImage, {
- x: heroBoxX + pad,
- y: heroBoxY + pad,
- width: boxW,
- height: boxH,
- });
- } else {
- page.drawText(locale === 'de' ? 'Kein Bild verfΓΌgbar' : 'No image available', {
- x: heroBoxX + 12,
- y: heroBoxY + heroH / 2,
- size: 8,
- font,
- color: mediumGray,
- maxWidth: contentWidth - 24,
- });
- }
-
- y = heroBoxY - afterHeroGap;
-
- // === DESCRIPTION (optional) ===
- if (product.shortDescriptionHtml || product.descriptionHtml) {
- const desc = stripHtml(product.shortDescriptionHtml || product.descriptionHtml);
- const descLineH = 14;
- // Keep full length: render all lines, paginate if needed.
- const boxPadX = DS.space.md;
- const boxPadY = DS.space.md;
-
- sectionTitle(labels.description);
-
- const maxTextW = contentWidth - boxPadX * 2;
- const descLines = wrapText(desc, font, DS.type.body, maxTextW);
-
- // Draw as boxed paragraphs, paginating as needed.
- // We keep a single consistent box per page segment.
- let i = 0;
- while (i < descLines.length) {
- // Ensure we have enough space for at least 2 lines.
- const minLines = 2;
- const minBoxH = boxPadY * 2 + descLineH * minLines;
- if (!hasSpace(minBoxH + DS.space.md)) {
- y = newPage({ includeProductName: true });
- }
-
- // Compute how many lines we can fit on the current page.
- const availableH = y - contentMinY - DS.space.md;
- const maxLinesThisPage = Math.max(minLines, Math.floor((availableH - boxPadY * 2) / descLineH));
- const slice = descLines.slice(i, i + maxLinesThisPage);
-
- const boxH = boxPadY * 2 + descLineH * slice.length;
- const boxTop = y + DS.space.xs;
- const boxBottom = boxTop - boxH;
-
- page.drawRectangle({
- x: margin,
- y: boxBottom,
- width: contentWidth,
- height: boxH,
- color: rgb(1, 1, 1),
- borderColor: lightGray,
- borderWidth: 1,
- });
-
- let ty = boxTop - boxPadY - DS.type.body;
- for (const line of slice) {
- page.drawText(line, {
- x: margin + boxPadX,
- y: ty,
- size: DS.type.body,
- font,
- color: darkGray,
- });
- ty -= descLineH;
- }
-
- y = boxBottom - DS.space.md;
- i += slice.length;
-
- // If there is more text, continue on a new page segment (keeps layout stable).
- if (i < descLines.length) {
- y = newPage({ includeProductName: true });
- sectionTitle(labels.description);
- }
- }
-
- rule(0, DS.space.lg);
- }
-
- // === EXCEL MODEL ===
- // Priority: render ALL Excel data (technical + per-voltage tables).
- const excelModel = buildExcelModel({ product, locale });
-
- // Keep the old enrichment as a fallback path only.
- // (Some products may not match Excel keying; then we still have a usable PDF.)
- ensureExcelCrossSectionAttributes(product, locale);
- ensureExcelRowSpecificAttributes(product, locale);
-
- if (excelModel.ok) {
- const tables = excelModel.voltageTables;
- const hasMultipleVoltages = tables.length > 1;
-
- // TECHNICAL DATA (shared across all cross-sections)
- const techTitle = locale === 'de' ? 'TECHNISCHE DATEN' : 'TECHNICAL DATA';
- const techItems = excelModel.technicalItems;
-
- // Track if we've rendered any content before the tables
- let hasRenderedContent = false;
-
- if (techItems.length) {
- y = drawKeyValueGrid({
- title: techTitle,
- items: techItems,
- newPage: () => newPage({ includeProductName: true }),
- getPage: () => page,
- page,
- y,
- margin,
- contentWidth,
- contentMinY,
- font,
- fontBold,
- navy,
- darkGray,
- mediumGray,
- lightGray,
- almostWhite,
- allowNewPage: true,
- boxed: true,
- });
-
- hasRenderedContent = true;
-
- // Add spacing after technical data section before first voltage table
- if (y - 20 >= contentMinY) y -= 20;
- }
-
- // CROSS-SECTION DATA: one table per voltage rating
- const firstColLabel =
- locale === 'de' ? 'Adern & Querschnitt' : 'Cores & cross-section';
-
- for (const t of tables) {
- // Maintain a minimum space between tables (even when staying on the same page).
- // This avoids visual collisions between the previous table and the next meta header.
- if (hasRenderedContent && y - 20 >= contentMinY) y -= 20;
-
- // Check if we need a new page for this voltage table
- // Estimate: meta block (if shown) + table header + at least 3 data rows
- const estimateMetaH = (itemsCount: number) => {
- // Always render a voltage-group header (even for single-voltage products)
- // so all datasheets look consistent.
- const titleH = 14;
- const rowH = 14;
- const cols = 3;
- const rows = Math.max(1, Math.ceil(Math.max(0, itemsCount) / cols));
- return titleH + rows * rowH + 8;
- };
- const minTableH = 16 /*header*/ + 9 * 3 /*3 rows*/ + 10 /*pad*/;
- const minNeeded = estimateMetaH((t.metaItems || []).length) + minTableH;
- if (y - minNeeded < contentMinY) {
- y = newPage({ includeProductName: true, landscape: false });
- }
-
- // Top meta block: always render per voltage group.
- // This ensures a consistent structure across products (LV/MV/HV) and makes the
- // voltage group visible in the heading.
- y = drawDenseMetaGrid({
- title: `${labels.crossSection} β ${t.voltageLabel}`,
- items: t.metaItems,
- locale,
- newPage: () => newPage({ includeProductName: true, landscape: false }),
- getPage: () => page,
- page,
- y,
- margin,
- contentWidth,
- contentMinY,
- font,
- fontBold,
- navy,
- darkGray,
- mediumGray,
- });
-
- // Breathing room before the dense table
- y -= 14;
-
- // Cross-section table: exactly 13 columns as specified
- // Order: DI, RI, Wi, Ibl, Ibe, Ik, Wm, Rbv, Γ, Fzv, Al, Cu, G
- const tableColumns = prioritizeColumnsForDenseTable({ columns: t.columns });
-
- // Format dense table cells: compact decimals, no units in cells
- const cellFormatter = (value: string, columnKey: string) => {
- return compactNumericForLocale(value, locale);
- };
-
- // Table title: keep empty because the voltage-group title is already shown in the meta block.
- const tableTitle = '';
-
- y = drawTableChunked({
- title: tableTitle,
- configRows: t.crossSections,
- columns: tableColumns,
- firstColLabel,
- dense: true,
- onePage: false, // Allow multiple pages for large tables
- cellFormatter,
- locale,
- newPage: () => newPage({ includeProductName: true, landscape: false }),
- getPage: () => page,
- page,
- y,
- margin,
- contentWidth: ctx.contentWidth,
- contentMinY,
- font,
- fontBold,
- navy,
- darkGray,
- lightGray,
- almostWhite,
- maxDataColsPerTable: 10_000, // All columns in one table
- });
-
- hasRenderedContent = true;
- }
- } else {
- // Fallback (non-Excel products): keep existing behavior minimal
- const note = locale === 'de'
- ? 'Hinweis: FΓΌr dieses Produkt liegen derzeit keine Excel-Daten vor.'
- : 'Note: No Excel data is available for this product yet.';
- y = drawKeyValueGrid({
- title: locale === 'de' ? 'TECHNISCHE DATEN' : 'TECHNICAL DATA',
- items: [{ label: locale === 'de' ? 'Quelle' : 'Source', value: note }],
- newPage: () => newPage({ includeProductName: true }),
- getPage: () => page,
- page,
- y,
- margin,
- contentWidth,
- contentMinY,
- font,
- fontBold,
- navy,
- darkGray,
- mediumGray,
- lightGray,
- almostWhite,
- allowNewPage: true,
- boxed: true,
- });
- }
-
- // Add page numbers after all pages are created.
- stampPageNumbers(pdfDoc, { regular: font }, { mediumGray }, margin, footerY);
-
- const pdfBytes = await pdfDoc.save();
- return Buffer.from(pdfBytes);
-
- } catch (error: any) {
- throw new Error(`Failed to generate PDF for product ${product.id} (${locale}): ${error.message}`);
- }
-}
-
-async function processChunk(products: ProductData[], chunkIndex: number, totalChunks: number): Promise {
- 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 {
- 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 {
- console.log('Starting PDF generation - Industrial engineering documentation style');
- ensureOutputDir();
-
- try {
- const allProducts = await readProductsStream();
- if (allProducts.length === 0) {
- console.log('No products found');
- return;
- }
-
- // Dev convenience: generate only one locale / one product subset.
- // IMPORTANT: apply filters BEFORE PDF_LIMIT so the limit works within the filtered set.
- let products = allProducts;
-
- const onlyLocale = normalizeValue(String(process.env.PDF_LOCALE || '')).toLowerCase();
- if (onlyLocale === 'de' || onlyLocale === 'en') {
- products = products.filter(p => (p.locale || 'en') === onlyLocale);
- }
-
- const match = normalizeValue(String(process.env.PDF_MATCH || '')).toLowerCase();
- if (match) {
- products = products.filter(p => {
- const hay = [p.slug, p.translationKey, p.sku, stripHtml(p.name)]
- .filter(Boolean)
- .join(' ')
- .toLowerCase();
- return hay.includes(match);
- });
- }
-
- // Optional dev convenience: limit how many PDFs we render (useful for design iteration).
- // Default behavior remains unchanged.
- const limit = Number(process.env.PDF_LIMIT || '0');
- products = Number.isFinite(limit) && limit > 0 ? products.slice(0, limit) : products;
-
- const enProducts = products.filter(p => (p.locale || 'en') === 'en');
- const deProducts = products.filter(p => (p.locale || 'en') === 'de');
- console.log(`Found ${enProducts.length} EN + ${deProducts.length} DE products`);
-
- const totalChunks = Math.ceil(products.length / CONFIG.chunkSize);
- for (let i = 0; i < totalChunks; i++) {
- const chunk = products.slice(i * CONFIG.chunkSize, (i + 1) * CONFIG.chunkSize);
- await processChunk(chunk, i, totalChunks);
- }
-
- console.log('\nβ
PDF generation completed!');
- console.log(`Generated ${enProducts.length} EN + ${deProducts.length} DE PDFs`);
- console.log(`Output: ${CONFIG.outputDir}`);
-
- } catch (error) {
- console.error('β Error:', error);
- throw error;
- }
-}
-
-async function main(): Promise {
- 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 };