From d8d35da574bc5ca6c96ff6f8d7ba73ad913f9870 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Fri, 19 Jun 2026 19:03:37 +0200 Subject: [PATCH] chore: remove dead code and unused PDF generation scripts Removed leftover brochure and PDF datasheet generation scripts from the old template that are not needed in this project. --- app/actions/brochure.ts | 102 - components/emails/BrochureDeliveryEmail.tsx | 145 - lib/excel-products.ts | 388 -- lib/pdf-brochure.tsx | 1436 ------- lib/pdf-page.tsx | 329 -- package.json | 6 - pnpm-lock.yaml | 306 -- scripts/generate-brochure.ts | 102 - scripts/generate-pdf-datasheets-pdf-lib.ts | 4106 ------------------- 9 files changed, 6920 deletions(-) delete mode 100644 app/actions/brochure.ts delete mode 100644 components/emails/BrochureDeliveryEmail.tsx delete mode 100644 lib/excel-products.ts delete mode 100644 lib/pdf-brochure.tsx delete mode 100644 lib/pdf-page.tsx delete mode 100644 scripts/generate-brochure.ts delete mode 100644 scripts/generate-pdf-datasheets-pdf-lib.ts 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.subject} - - -
- {t.subject} -
- -
- - {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 };