This commit is contained in:
2026-01-14 18:49:33 +01:00
parent 558bcbd946
commit f29ceacb51
57 changed files with 237 additions and 66 deletions

View File

@@ -2,6 +2,7 @@ import * as React from 'react';
import { Document, Image, Page, Text, View } from '@react-pdf/renderer';
import type { DatasheetModel, DatasheetVoltageTable } from '../model/types';
import { CONFIG } from '../model/utils';
import { styles } from './styles';
import { Header } from './components/Header';
import { Footer } from './components/Footer';
@@ -25,9 +26,9 @@ function chunk<T>(arr: T[], size: number): T[][] {
export function DatasheetDocument(props: { model: DatasheetModel; assets: Assets }): React.ReactElement {
const { model, assets } = props;
const headerTitle = model.labels.datasheet;
const footerLeft = `${model.labels.sku}: ${model.product.sku}`;
const firstColLabel = model.locale === 'de' ? 'Adern & Querschnitt' : 'Cores & cross-section';
// Dense tables require compact headers (no wrapping). Use standard abbreviations.
const firstColLabel = model.locale === 'de' ? 'Adern & QS' : 'Cores & CS';
const tablePages: Array<{ table: DatasheetVoltageTable; rows: DatasheetVoltageTable['rows'] }> =
model.voltageTables.flatMap(t => {
@@ -40,7 +41,7 @@ export function DatasheetDocument(props: { model: DatasheetModel; assets: Assets
<Document>
<Page size="A4" style={styles.page}>
<Header title={headerTitle} logoDataUrl={assets.logoDataUrl} qrDataUrl={assets.qrDataUrl} />
<Footer leftText={footerLeft} locale={model.locale} />
<Footer locale={model.locale} siteUrl={CONFIG.siteUrl} />
<Text style={styles.h1}>{model.product.name}</Text>
{model.product.categoriesLine ? <Text style={styles.subhead}>{model.product.categoriesLine}</Text> : null}
@@ -66,19 +67,18 @@ export function DatasheetDocument(props: { model: DatasheetModel; assets: Assets
) : null}
</Page>
{tablePages.map((p, index) => (
<Page key={`${p.table.voltageLabel}-${index}`} size="A4" style={styles.page}>
<Header title={headerTitle} logoDataUrl={assets.logoDataUrl} qrDataUrl={assets.qrDataUrl} />
<Footer leftText={footerLeft} locale={model.locale} />
{tablePages.map((p, index) => (
<Page key={`${p.table.voltageLabel}-${index}`} size="A4" style={styles.page}>
<Header title={headerTitle} logoDataUrl={assets.logoDataUrl} qrDataUrl={assets.qrDataUrl} />
<Footer locale={model.locale} siteUrl={CONFIG.siteUrl} />
<Section title={`${model.labels.crossSection}${p.table.voltageLabel}`}>
{p.table.metaItems.length ? <KeyValueGrid items={p.table.metaItems} /> : null}
</Section>
<DenseTable table={{ columns: p.table.columns, rows: p.rows }} firstColLabel={firstColLabel} />
</Page>
))}
</Document>
);
</Page>
))}
</Document>
);
}

View File

@@ -15,38 +15,99 @@ export function DenseTable(props: {
const cols = props.table.columns;
const rows = props.table.rows;
const cfgPct = cols.length >= 12 ? 0.28 : 0.32;
const dataPct = 1 - cfgPct;
const each = cols.length ? dataPct / cols.length : dataPct;
const cfgW = `${Math.round(cfgPct * 100)}%`;
const dataW = `${Math.round(clamp(each, 0.03, 0.12) * 1000) / 10}%`;
const noWrapHeader = (label: string): string => {
const raw = String(label || '').trim();
if (!raw) return '';
// Ensure the header never wraps into a second line.
// - Remove whitespace break opportunities (NBSP)
// NOTE: Avoid inserting zero-width joiners between letters.
// Some PDF viewers render them with spacing/odd glyph behavior.
// This is intentionally aggressive because broken headers destroy scanability.
return raw.replace(/\s+/g, '\u00A0');
};
// Column widths: use explicit percentages (no rounding gaps) so the table always
// consumes the full content width.
// Goal:
// - keep the designation column *not too wide*
// - guarantee enough width for data headers when there is available space
const cfgMin = 0.18;
const cfgMax = 0.30;
let cfgPct = cols.length >= 14 ? 0.22 : cols.length >= 12 ? 0.24 : cols.length >= 10 ? 0.26 : 0.30;
cfgPct = clamp(cfgPct, cfgMin, cfgMax);
const minDataPct = cols.length >= 14 ? 0.045 : cols.length >= 12 ? 0.05 : cols.length >= 10 ? 0.055 : 0.06;
// If the initial cfgPct leaves too little width per data column, reduce cfgPct.
const cfgPctMaxForMinData = 1 - cols.length * minDataPct;
if (Number.isFinite(cfgPctMaxForMinData)) {
cfgPct = Math.min(cfgPct, cfgPctMaxForMinData);
cfgPct = clamp(cfgPct, cfgMin, cfgMax);
}
const cfgW = `${(cfgPct * 100).toFixed(4)}%`;
const dataTotal = 1 - cfgPct;
const each = cols.length ? dataTotal / cols.length : dataTotal;
const dataWs = cols.map((_, idx) => {
// Keep the last column as the remainder so percentages sum to exactly 100%.
if (idx === cols.length - 1) {
const used = each * Math.max(0, cols.length - 1);
const remainder = Math.max(0, dataTotal - used);
return `${(remainder * 100).toFixed(4)}%`;
}
return `${(each * 100).toFixed(4)}%`;
});
return (
<View style={styles.tableWrap}>
<View style={styles.tableHeader}>
<View style={styles.tableHeader} wrap={false}>
<View style={{ width: cfgW }}>
<Text style={styles.tableHeaderCell}>{props.firstColLabel}</Text>
<Text
style={[
styles.tableHeaderCell,
styles.tableHeaderCellCfg,
cols.length ? styles.tableHeaderCellDivider : null,
]}
wrap={false}
>
{noWrapHeader(props.firstColLabel)}
</Text>
</View>
{cols.map(c => (
<View key={c.key} style={{ width: dataW }}>
<Text style={styles.tableHeaderCell}>{c.label}</Text>
</View>
))}
{cols.map((c, idx) => {
const isLast = idx === cols.length - 1;
return (
<View key={c.key} style={{ width: dataWs[idx] }}>
<Text
style={[styles.tableHeaderCell, !isLast ? styles.tableHeaderCellDivider : null]}
wrap={false}
>
{noWrapHeader(c.label)}
</Text>
</View>
);
})}
</View>
{rows.map((r, ri) => (
<View key={`${r.configuration}-${ri}`} style={[styles.tableRow, ri % 2 === 0 ? styles.tableRowAlt : null]}>
<View
key={`${r.configuration}-${ri}`}
style={[styles.tableRow, ri % 2 === 0 ? styles.tableRowAlt : null]}
wrap={false}
>
<View style={{ width: cfgW }}>
<Text style={styles.tableCell}>{r.configuration}</Text>
<Text style={[styles.tableCell, styles.tableCellCfg, cols.length ? styles.tableCellDivider : null]}>{r.configuration}</Text>
</View>
{r.cells.map((cell, ci) => (
<View key={`${cols[ci]?.key || ci}`} style={{ width: dataW }}>
<Text style={styles.tableCell}>{cell}</Text>
</View>
))}
{r.cells.map((cell, ci) => {
const isLast = ci === r.cells.length - 1;
return (
<View key={`${cols[ci]?.key || ci}`} style={{ width: dataWs[ci] }}>
<Text style={[styles.tableCell, !isLast ? styles.tableCellDivider : null]}>{cell}</Text>
</View>
);
})}
</View>
))}
</View>
);
}

View File

@@ -3,19 +3,20 @@ import { Text, View } from '@react-pdf/renderer';
import { styles } from '../styles';
export function Footer(props: { leftText: string; locale: 'en' | 'de' }): React.ReactElement {
export function Footer(props: { locale: 'en' | 'de'; siteUrl?: string }): React.ReactElement {
const date = new Date().toLocaleDateString(props.locale === 'en' ? 'en-US' : 'de-DE', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const siteUrl = props.siteUrl || 'https://klz-cables.com';
return (
<View style={styles.footer} fixed>
<Text>{props.leftText}</Text>
<Text>{siteUrl}</Text>
<Text>{date}</Text>
<Text render={({ pageNumber, totalPages }) => `${pageNumber}/${totalPages}`} />
</View>
);
}

View File

@@ -8,18 +8,36 @@ export function KeyValueGrid(props: { items: KeyValueItem[] }): React.ReactEleme
const items = (props.items || []).filter(i => i.label && i.value);
if (!items.length) return null;
// 4-column layout: (label, value, label, value)
const rows: Array<[KeyValueItem, KeyValueItem | null]> = [];
for (let i = 0; i < items.length; i += 2) {
rows.push([items[i], items[i + 1] || null]);
}
return (
<View style={styles.kvGrid}>
{items.map((item, index) => {
const isLast = index === items.length - 1;
const valueText = item.unit ? `${item.value} ${item.unit}` : item.value;
{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 (
<View key={`${item.label}-${index}`} style={[styles.kvRow, isLast ? styles.kvRowLast : null]}>
<View style={styles.kvLabel}>
<Text style={styles.kvLabelText}>{item.label}</Text>
<View
key={`${left.label}-${rowIndex}`}
style={[styles.kvRow, isLast ? styles.kvRowLast : null]}
wrap={false}
>
<View style={[styles.kvCell, { width: '23%' }]}>
<Text style={styles.kvLabelText}>{left.label}</Text>
</View>
<View style={styles.kvValue}>
<Text style={styles.kvValueText}>{valueText}</Text>
<View style={[styles.kvCell, styles.kvMidDivider, { width: '27%' }]}>
<Text style={styles.kvValueText}>{leftValue}</Text>
</View>
<View style={[styles.kvCell, { width: '23%' }]}>
<Text style={styles.kvLabelText}>{right?.label || ''}</Text>
</View>
<View style={[styles.kvCell, { width: '27%' }]}>
<Text style={styles.kvValueText}>{rightValue}</Text>
</View>
</View>
);
@@ -27,4 +45,3 @@ export function KeyValueGrid(props: { items: KeyValueItem[] }): React.ReactEleme
</View>
);
}

View File

@@ -3,12 +3,16 @@ import { Text, View } from '@react-pdf/renderer';
import { styles } from '../styles';
export function Section(props: { title: string; children: React.ReactNode }): React.ReactElement {
export function Section(props: {
title: string;
children: React.ReactNode;
boxed?: boolean;
}): React.ReactElement {
const boxed = props.boxed ?? true;
return (
<View style={styles.section}>
<View style={boxed ? styles.section : styles.sectionPlain}>
<Text style={styles.sectionTitle}>{props.title}</Text>
{props.children}
</View>
);
}

View File

@@ -1,4 +1,8 @@
import { StyleSheet } from '@react-pdf/renderer';
import { Font, StyleSheet } from '@react-pdf/renderer';
// Prevent automatic word hyphenation, which can create multi-line table headers
// even when we try to keep them single-line.
Font.registerHyphenationCallback(word => [word]);
export const COLORS = {
navy: '#0E2A47',
@@ -76,6 +80,10 @@ export const styles = StyleSheet.create({
padding: 14,
marginBottom: 14,
},
sectionPlain: {
paddingVertical: 2,
marginBottom: 12,
},
sectionTitle: {
fontSize: 10,
fontWeight: 700,
@@ -86,34 +94,56 @@ export const styles = StyleSheet.create({
body: { fontSize: 10, lineHeight: 1.5, color: COLORS.darkGray },
kvGrid: {
width: '100%',
borderWidth: 1,
borderColor: COLORS.lightGray,
},
kvRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: COLORS.lightGray },
kvRow: {
flexDirection: 'row',
borderBottomWidth: 1,
borderBottomColor: COLORS.lightGray,
},
kvRowLast: { borderBottomWidth: 0 },
kvLabel: {
width: '45%',
paddingVertical: 6,
paddingHorizontal: 8,
backgroundColor: COLORS.almostWhite,
kvCell: { paddingVertical: 6, paddingHorizontal: 8 },
// Visual separator between (label,value) pairs in the 4-col KV grid.
// Matches the engineering-table look and improves scanability.
kvMidDivider: {
borderRightWidth: 1,
borderRightColor: COLORS.lightGray,
},
kvValue: { width: '55%', paddingVertical: 6, paddingHorizontal: 8 },
kvLabelText: { fontSize: 8.5, fontWeight: 700, color: COLORS.mediumGray },
kvValueText: { fontSize: 9.5, color: COLORS.darkGray },
tableWrap: { borderWidth: 1, borderColor: COLORS.lightGray },
tableHeader: { flexDirection: 'row', backgroundColor: '#6B707A' },
tableWrap: { width: '100%', borderWidth: 1, borderColor: COLORS.lightGray },
tableHeader: {
width: '100%',
flexDirection: 'row',
backgroundColor: '#FFFFFF',
borderBottomWidth: 1,
borderBottomColor: COLORS.lightGray,
},
tableHeaderCell: {
paddingVertical: 5,
paddingHorizontal: 4,
fontSize: 6.6,
fontWeight: 700,
color: '#FFFFFF',
color: COLORS.navy,
},
tableRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: COLORS.lightGray },
tableHeaderCellCfg: {
paddingHorizontal: 6,
},
tableHeaderCellDivider: {
borderRightWidth: 1,
borderRightColor: COLORS.lightGray,
},
tableRow: { width: '100%', flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: COLORS.lightGray },
tableRowAlt: { backgroundColor: COLORS.almostWhite },
tableCell: { paddingVertical: 4, paddingHorizontal: 4, fontSize: 6.6, color: COLORS.darkGray },
tableCellCfg: {
paddingHorizontal: 6,
},
tableCellDivider: {
borderRightWidth: 1,
borderRightColor: COLORS.lightGray,
},
});