diff --git a/lib/pdf-datasheet.tsx b/lib/pdf-datasheet.tsx index bd84b5f8..bbf82378 100644 --- a/lib/pdf-datasheet.tsx +++ b/lib/pdf-datasheet.tsx @@ -1,16 +1,10 @@ import * as React from 'react'; import { Document, Page, View, Text, Image, StyleSheet, Font } from '@react-pdf/renderer'; +import type { DatasheetVoltageTable, KeyValueItem } from '../scripts/pdf/model/types'; -// 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 }, - ], -}); +// Standard built-in fonts are used. +Font.registerHyphenationCallback((word) => [word]); -// ─── Brand Tokens (matching brochure) ──────────────────────────────────────── const C = { navy: '#001a4d', navyDeep: '#000d26', @@ -30,32 +24,20 @@ 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, + paddingBottom: 80, + paddingTop: 40, + fontFamily: 'Helvetica', + backgroundColor: C.white, + color: C.gray900, }, - + hero: { paddingBottom: 20, marginBottom: 10 }, header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, }, - logoText: { fontSize: 22, fontWeight: 700, @@ -63,7 +45,6 @@ const styles = StyleSheet.create({ letterSpacing: 2, textTransform: 'uppercase', }, - docTitle: { fontSize: 8, fontWeight: 700, @@ -71,16 +52,8 @@ const styles = StyleSheet.create({ letterSpacing: 2, textTransform: 'uppercase', }, - - productRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 20, - }, - productInfoCol: { - flex: 1, - justifyContent: 'center', - }, + productRow: { flexDirection: 'row', alignItems: 'center', gap: 20 }, + productInfoCol: { flex: 1, justifyContent: 'center' }, productImageCol: { flex: 1, height: 120, @@ -92,51 +65,26 @@ const styles = StyleSheet.create({ backgroundColor: C.white, overflow: 'hidden', }, - - // Product Hero Info - productHero: { - marginTop: 0, - }, - productName: { fontSize: 24, fontWeight: 700, color: C.navyDeep, - marginBottom: 0, textTransform: 'uppercase', letterSpacing: -0.5, }, - productMeta: { fontSize: 10, color: C.gray600, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1, + marginBottom: 2, }, + heroImage: { width: '100%', height: '100%', objectFit: 'contain' }, + noImage: { fontSize: 8, color: C.gray400, textAlign: 'center' }, - heroImage: { - width: '100%', - height: '100%', - objectFit: 'contain', - }, - - noImage: { - fontSize: 8, - color: C.gray400, - textAlign: 'center', - }, - - // Content Area - content: { - paddingHorizontal: MARGIN, - }, - - // Content sections - section: { - marginBottom: 20, - }, - + content: {}, + section: { marginBottom: 20 }, sectionTitle: { fontSize: 8, fontWeight: 700, @@ -145,7 +93,6 @@ const styles = StyleSheet.create({ textTransform: 'uppercase', letterSpacing: 1.5, }, - sectionAccent: { width: 30, height: 2, @@ -153,67 +100,9 @@ const styles = StyleSheet.create({ marginBottom: 8, borderRadius: 1, }, + description: { fontSize: 10, lineHeight: 1.7, color: C.gray600 }, - description: { - fontSize: 10, - lineHeight: 1.7, - color: C.gray600, - }, - - // Technical data table - specsTable: { - marginTop: 4, - borderWidth: 0, - borderRadius: 0, - overflow: 'hidden', - }, - - specsTableRow: { - flexDirection: 'row', - borderBottomWidth: 0.5, - borderBottomColor: C.gray200, - }, - - specsTableRowLast: { - borderBottomWidth: 0, - }, - - specsTableLabelCell: { - flex: 1, - paddingVertical: 5, - paddingHorizontal: 12, - backgroundColor: C.offWhite, - borderRightWidth: 0.5, - borderRightColor: C.gray200, - }, - - specsTableValueCell: { - flex: 1, - paddingVertical: 5, - paddingHorizontal: 12, - }, - - specsTableLabelText: { - fontSize: 8, - fontWeight: 700, - color: C.navyDeep, - textTransform: 'uppercase', - letterSpacing: 0.5, - }, - - specsTableValueText: { - fontSize: 9, - color: C.gray900, - fontWeight: 400, - }, - - // Categories - categories: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 6, - }, - + categories: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 }, categoryTag: { backgroundColor: C.offWhite, paddingHorizontal: 10, @@ -222,7 +111,6 @@ const styles = StyleSheet.create({ borderColor: C.gray200, borderRadius: 3, }, - categoryText: { fontSize: 7, color: C.gray600, @@ -231,7 +119,6 @@ const styles = StyleSheet.create({ letterSpacing: 0.5, }, - // Footer — matches brochure style footer: { position: 'absolute', bottom: 28, @@ -244,7 +131,6 @@ const styles = StyleSheet.create({ borderTopWidth: 2, borderTopColor: C.green, }, - footerText: { fontSize: 7, color: C.gray400, @@ -252,7 +138,6 @@ const styles = StyleSheet.create({ textTransform: 'uppercase', letterSpacing: 0.8, }, - footerBrand: { fontSize: 9, fontWeight: 700, @@ -260,6 +145,43 @@ const styles = StyleSheet.create({ textTransform: 'uppercase', letterSpacing: 1.5, }, + + kvGrid: { width: '100%', borderWidth: 1, borderColor: C.gray200 }, + kvRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: C.gray200 }, + kvRowAlt: { backgroundColor: C.offWhite }, + kvRowLast: { borderBottomWidth: 0 }, + kvCell: { paddingVertical: 6, paddingHorizontal: 8 }, + kvMidDivider: { borderRightWidth: 1, borderRightColor: C.gray200 }, + kvLabelText: { fontSize: 8.5, fontWeight: 700, color: C.gray600 }, + kvValueText: { fontSize: 9.5, color: C.gray900 }, + + tableWrap: { width: '100%', borderWidth: 1, borderColor: C.gray200, marginBottom: 14 }, + tableHeader: { + width: '100%', + flexDirection: 'row', + backgroundColor: C.white, + borderBottomWidth: 1, + borderBottomColor: C.gray200, + }, + tableHeaderCell: { + paddingVertical: 5, + paddingHorizontal: 4, + fontSize: 6.6, + fontWeight: 700, + color: C.navyDeep, + }, + tableHeaderCellCfg: { paddingHorizontal: 6 }, + tableHeaderCellDivider: { borderRightWidth: 1, borderRightColor: C.gray200 }, + tableRow: { + width: '100%', + flexDirection: 'row', + borderBottomWidth: 1, + borderBottomColor: C.gray200, + }, + tableRowAlt: { backgroundColor: C.offWhite }, + tableCell: { paddingVertical: 4, paddingHorizontal: 4, fontSize: 6.6, color: C.gray900 }, + tableCellCfg: { paddingHorizontal: 6 }, + tableCellDivider: { borderRightWidth: 1, borderRightColor: C.gray200 }, }); interface ProductData { @@ -276,29 +198,22 @@ interface ProductData { images?: string[]; featuredImage?: string | null; categories?: Array<{ name: string }>; - attributes?: Array<{ - name: string; - options: string[]; - }>; + attributes?: Array<{ name: string; options: string[] }>; } -interface PDFDatasheetProps { +export interface PDFDatasheetProps { product: ProductData; locale: 'en' | 'de'; logoUrl?: string; - technicalItems?: any[]; - voltageTables?: any[]; - legendItems?: any[]; + technicalItems?: KeyValueItem[]; + voltageTables?: DatasheetVoltageTable[]; + legendItems?: KeyValueItem[]; } -// Helper to strip HTML tags -const stripHtml = (html: string): string => { - return html.replace(/<[^>]*>/g, ''); -}; +const stripHtml = (html: string): string => html.replace(/<[^>]*>/g, ''); -// Helper to get translated labels -const getLabels = (locale: 'en' | 'de') => { - const labels = { +const getLabels = (locale: 'en' | 'de') => + ({ en: { productDatasheet: 'Technical Datasheet', description: 'APPLICATION', @@ -306,6 +221,9 @@ const getLabels = (locale: 'en' | 'de') => { categories: 'CATEGORIES', sku: 'SKU', noImage: 'No image available', + crossSection: 'Configurations', + slug_cs: 'Cores & CS', + abbreviations: 'ABBREVIATIONS', }, de: { productDatasheet: 'Technisches Datenblatt', @@ -314,18 +232,257 @@ const getLabels = (locale: 'en' | 'de') => { categories: 'KATEGORIEN', sku: 'ARTIKELNUMMER', noImage: 'Kein Bild verfügbar', + crossSection: 'Konfigurationen', + slug_cs: 'Adern & QS', + abbreviations: 'ABKÜRZUNGEN', }, - }; - return labels[locale]; -}; + })[locale]; -export const PDFDatasheet: React.FC = ({ product, locale }) => { +function clamp(n: number, min: number, max: number) { + return Math.max(min, Math.min(max, n)); +} +function normTextForMeasure(v: unknown) { + return String(v ?? '') + .replace(/\s+/g, ' ') + .trim(); +} +function textLen(v: unknown) { + return normTextForMeasure(v).length; +} + +function distributeWithMinMax( + weights: number[], + total: number, + minEach: number, + maxEach: number, +): number[] { + const n = weights.length; + if (!n) return []; + const mins = Array.from({ length: n }, () => minEach); + const maxs = Array.from({ length: n }, () => maxEach); + const minSum = mins.reduce((a, b) => a + b, 0); + if (minSum > total) return mins.map((m) => m * (total / minSum)); + + const result = mins.slice(); + let remaining = total - minSum; + let remainingIdx = Array.from({ length: n }, (_, i) => i); + + while (remaining > 1e-9 && remainingIdx.length) { + const wSum = remainingIdx.reduce((acc, i) => acc + Math.max(0, weights[i] || 0), 0); + if (wSum <= 1e-9) { + const even = remaining / remainingIdx.length; + for (const i of remainingIdx) result[i] += even; + remaining = 0; + break; + } + const nextIdx: number[] = []; + for (const i of remainingIdx) { + const w = Math.max(0, weights[i] || 0); + const add = (w / wSum) * remaining; + const capped = Math.min(result[i] + add, maxs[i]); + const used = capped - result[i]; + result[i] = capped; + remaining -= used; + if (result[i] + 1e-9 < maxs[i]) nextIdx.push(i); + } + remainingIdx = nextIdx; + } + const sum = result.reduce((a, b) => a + b, 0); + const drift = total - sum; + if (Math.abs(drift) > 1e-9) result[result.length - 1] += drift; + return result; +} + +function KeyValueGrid({ items }: { items: KeyValueItem[] }) { + const filtered = (items || []).filter((i) => i.label && i.value); + if (!filtered.length) return null; + const rows: Array<[KeyValueItem, KeyValueItem | null]> = []; + for (let i = 0; i < filtered.length; i += 2) rows.push([filtered[i], filtered[i + 1] || null]); + + return ( + + {rows.map(([left, right], rowIndex) => { + const isLast = rowIndex === rows.length - 1; + const leftValue = left.unit ? `${left.value} ${left.unit}` : left.value; + const rightValue = right ? (right.unit ? `${right.value} ${right.unit}` : right.value) : ''; + return ( + + + {left.label} + + + {leftValue} + + + {right?.label || ''} + + + {rightValue} + + + ); + })} + + ); +} + +function DenseTable({ + table, + firstColLabel, +}: { + table: Pick; + firstColLabel: string; +}) { + const cols = table.columns; + const rows = table.rows; + const headerText = (label: string) => + String(label || '') + .replace(/\s+/g, '\u00A0') + .trim(); + + const cfgMin = 0.14, + cfgMax = 0.23; + const cfgContentLen = Math.max( + textLen(firstColLabel), + ...rows.map((r) => textLen(r.configuration)), + 8, + ); + const dataContentLens = cols.map((c, ci) => { + const headerL = textLen(c.label); + let cellMax = 0; + for (const r of rows) cellMax = Math.max(cellMax, textLen(r.cells[ci])); + return Math.max(headerL * 1.15, cellMax, 3); + }); + + const cfgWeight = cfgContentLen * 1.05; + const dataWeights = dataContentLens.map((l) => l); + const dataWeightSum = dataWeights.reduce((a, b) => a + b, 0); + const rawCfgPct = dataWeightSum > 0 ? cfgWeight / (cfgWeight + dataWeightSum) : 0.28; + let cfgPct = clamp(rawCfgPct, cfgMin, cfgMax); + + const minDataPct = + cols.length >= 14 ? 0.045 : cols.length >= 12 ? 0.05 : cols.length >= 10 ? 0.055 : 0.06; + const cfgPctMaxForMinData = 1 - cols.length * minDataPct; + if (Number.isFinite(cfgPctMaxForMinData)) cfgPct = Math.min(cfgPct, cfgPctMaxForMinData); + cfgPct = clamp(cfgPct, cfgMin, cfgMax); + + const dataTotal = Math.max(0, 1 - cfgPct); + const maxDataPct = Math.min(0.24, Math.max(minDataPct * 2.8, dataTotal * 0.55)); + const dataPcts = distributeWithMinMax(dataWeights, dataTotal, minDataPct, maxDataPct); + + const cfgW = `${(cfgPct * 100).toFixed(4)}%`; + const dataWs = dataPcts.map((p, idx) => { + if (idx === dataPcts.length - 1) { + const used = dataPcts.slice(0, -1).reduce((a, b) => a + b, 0); + const remainder = Math.max(0, dataTotal - used); + return `${(remainder * 100).toFixed(4)}%`; + } + return `${(p * 100).toFixed(4)}%`; + }); + + const headerFontSize = + cols.length >= 14 ? 5.7 : cols.length >= 12 ? 5.9 : cols.length >= 10 ? 6.2 : 6.6; + + return ( + + + + + {headerText(firstColLabel)} + + + {cols.map((c, idx) => { + const isLast = idx === cols.length - 1; + return ( + + + {headerText(c.label)} + + + ); + })} + + {rows.map((r, ri) => ( + + + + {r.configuration} + + + {r.cells.map((cell, ci) => ( + + + {cell} + + + ))} + + ))} + + ); +} + +export const PDFDatasheet: React.FC = ({ + product, + locale, + technicalItems = [], + voltageTables = [], + legendItems = [], +}) => { const labels = getLabels(locale); + const description = stripHtml( + product.applicationHtml || + product.shortDescriptionHtml || + product.descriptionHtml || + product.descriptionText || + '', + ); return ( - {/* Hero Header */} @@ -333,18 +490,13 @@ export const PDFDatasheet: React.FC = ({ product, locale }) = {labels.productDatasheet} - - - - - {product.categoriesLine || - (product.categories || []).map((c) => c.name).join(' • ')} - - - {product.name} - + + {product.categoriesLine || + (product.categories || []).map((c) => c.name).join(' • ')} + + {product.name} {product.featuredImage ? ( @@ -357,64 +509,82 @@ export const PDFDatasheet: React.FC = ({ product, locale }) = - {/* Description section */} - {(product.applicationHtml || product.shortDescriptionHtml || product.descriptionHtml) && ( + {description && ( {labels.description} - - {stripHtml( - product.applicationHtml || - product.shortDescriptionHtml || - product.descriptionHtml, - )} - + {description} )} - {/* Technical specifications */} - {product.attributes && product.attributes.length > 0 && ( + {technicalItems.length > 0 && ( {labels.specifications} - - {product.attributes.map((attr, index) => ( - - - {attr.name} - - - {attr.options.join(', ')} - - - ))} - + )} - {/* Categories as clean tags */} - {product.categories && product.categories.length > 0 && ( - - {labels.categories} + {voltageTables.map((table, idx) => ( + + {`${labels.crossSection} — ${table.voltageLabel}`} - - {product.categories.map((cat, index) => ( - - {cat.name} - - ))} - + + + ))} + + {legendItems.length > 0 && ( + + {labels.abbreviations} + + )} + + {!technicalItems.length && + !voltageTables.length && + product.attributes && + product.attributes.length > 0 && ( + + {labels.specifications} + + + {product.attributes.map((attr, index) => ( + + + + {attr.name} + + + + + {attr.options.join(', ')} + + + + ))} + + + )} - {/* Minimal footer */} KLZ CABLES