This commit is contained in:
2026-01-14 18:02:47 +01:00
parent 2c41f5619b
commit 558bcbd946
68 changed files with 5490 additions and 4032 deletions

View File

@@ -0,0 +1,84 @@
import * as React from 'react';
import { Document, Image, Page, Text, View } from '@react-pdf/renderer';
import type { DatasheetModel, DatasheetVoltageTable } from '../model/types';
import { styles } from './styles';
import { Header } from './components/Header';
import { Footer } from './components/Footer';
import { Section } from './components/Section';
import { KeyValueGrid } from './components/KeyValueGrid';
import { DenseTable } from './components/DenseTable';
type Assets = {
logoDataUrl: string | null;
heroDataUrl: string | null;
qrDataUrl: string | null;
};
function chunk<T>(arr: T[], size: number): T[][] {
if (size <= 0) return [arr];
const out: T[][] = [];
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
return out;
}
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';
const tablePages: Array<{ table: DatasheetVoltageTable; rows: DatasheetVoltageTable['rows'] }> =
model.voltageTables.flatMap(t => {
const perPage = 30;
const chunks = chunk(t.rows, perPage);
return chunks.map(rows => ({ table: t, rows }));
});
return (
<Document>
<Page size="A4" style={styles.page}>
<Header title={headerTitle} logoDataUrl={assets.logoDataUrl} qrDataUrl={assets.qrDataUrl} />
<Footer leftText={footerLeft} locale={model.locale} />
<Text style={styles.h1}>{model.product.name}</Text>
{model.product.categoriesLine ? <Text style={styles.subhead}>{model.product.categoriesLine}</Text> : null}
<View style={styles.heroBox}>
{assets.heroDataUrl ? (
<Image src={assets.heroDataUrl} style={styles.heroImage} />
) : (
<Text style={styles.noImage}>{model.labels.noImage}</Text>
)}
</View>
{model.product.descriptionText ? (
<Section title={model.labels.description}>
<Text style={styles.body}>{model.product.descriptionText}</Text>
</Section>
) : null}
{model.technicalItems.length ? (
<Section title={model.labels.technicalData}>
<KeyValueGrid items={model.technicalItems} />
</Section>
) : 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} />
<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>
);
}

View File

@@ -0,0 +1,78 @@
import * as fs from 'fs';
import * as path from 'path';
type SharpLike = (input?: unknown, options?: unknown) => { png: () => { toBuffer: () => Promise<Buffer> } };
let sharpFn: SharpLike | null = null;
async function getSharp(): Promise<SharpLike> {
if (sharpFn) return sharpFn;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mod: any = await import('sharp');
sharpFn = (mod?.default || mod) as SharpLike;
return sharpFn;
}
const PUBLIC_DIR = path.join(process.cwd(), 'public');
async function fetchBytes(url: string): Promise<Uint8Array> {
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to fetch ${url}: ${res.status} ${res.statusText}`);
return new Uint8Array(await res.arrayBuffer());
}
async function readBytesFromPublic(localPath: string): Promise<Uint8Array> {
const abs = path.join(PUBLIC_DIR, localPath.replace(/^\//, ''));
return new Uint8Array(fs.readFileSync(abs));
}
function transformLogoSvgToPrintBlack(svg: string): string {
return svg
.replace(/fill\s*:\s*white/gi, 'fill:#0E2A47')
.replace(/fill\s*=\s*"white"/gi, 'fill="#0E2A47"')
.replace(/fill\s*=\s*'white'/gi, "fill='#0E2A47'");
}
async function toPngBytes(inputBytes: Uint8Array, inputHint: string): Promise<Uint8Array> {
const ext = (path.extname(inputHint).toLowerCase() || '').replace('.', '');
if (ext === 'png') return inputBytes;
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();
return new Uint8Array(await sharp(Buffer.from(inputBytes)).png().toBuffer());
}
function toDataUrlPng(bytes: Uint8Array): string {
return `data:image/png;base64,${Buffer.from(bytes).toString('base64')}`;
}
export async function loadImageAsPngDataUrl(src: string | null): Promise<string | null> {
if (!src) return null;
try {
if (src.startsWith('/')) {
const bytes = await readBytesFromPublic(src);
const png = await toPngBytes(bytes, src);
return toDataUrlPng(png);
}
const bytes = await fetchBytes(src);
const png = await toPngBytes(bytes, src);
return toDataUrlPng(png);
} catch {
return null;
}
}
export async function loadQrAsPngDataUrl(data: string): Promise<string | null> {
try {
const safe = encodeURIComponent(data);
const url = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${safe}`;
const bytes = await fetchBytes(url);
const png = await toPngBytes(bytes, url);
return toDataUrlPng(png);
} catch {
return null;
}
}

View File

@@ -0,0 +1,52 @@
import * as React from 'react';
import { Text, View } from '@react-pdf/renderer';
import type { DatasheetVoltageTable } from '../../model/types';
import { styles } from '../styles';
function clamp(n: number, min: number, max: number): number {
return Math.max(min, Math.min(max, n));
}
export function DenseTable(props: {
table: Pick<DatasheetVoltageTable, 'columns' | 'rows'>;
firstColLabel: string;
}): React.ReactElement {
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}%`;
return (
<View style={styles.tableWrap}>
<View style={styles.tableHeader}>
<View style={{ width: cfgW }}>
<Text style={styles.tableHeaderCell}>{props.firstColLabel}</Text>
</View>
{cols.map(c => (
<View key={c.key} style={{ width: dataW }}>
<Text style={styles.tableHeaderCell}>{c.label}</Text>
</View>
))}
</View>
{rows.map((r, ri) => (
<View key={`${r.configuration}-${ri}`} style={[styles.tableRow, ri % 2 === 0 ? styles.tableRowAlt : null]}>
<View style={{ width: cfgW }}>
<Text style={styles.tableCell}>{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>
))}
</View>
))}
</View>
);
}

View File

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

View File

@@ -0,0 +1,26 @@
import * as React from 'react';
import { Image, Text, View } from '@react-pdf/renderer';
import { styles } from '../styles';
export function Header(props: { title: string; logoDataUrl?: string | null; qrDataUrl?: string | null }): React.ReactElement {
return (
<View style={styles.header} fixed>
<View style={styles.headerLeft}>
{props.logoDataUrl ? (
<Image src={props.logoDataUrl} style={styles.logo} />
) : (
<View style={styles.brandFallback}>
<Text style={styles.brandFallbackKlz}>KLZ</Text>
<Text style={styles.brandFallbackCables}>Cables</Text>
</View>
)}
</View>
<View style={styles.headerRight}>
<Text style={styles.headerTitle}>{props.title}</Text>
{props.qrDataUrl ? <Image src={props.qrDataUrl} style={styles.qr} /> : null}
</View>
</View>
);
}

View File

@@ -0,0 +1,30 @@
import * as React from 'react';
import { Text, View } from '@react-pdf/renderer';
import type { KeyValueItem } from '../../model/types';
import { styles } from '../styles';
export function KeyValueGrid(props: { items: KeyValueItem[] }): React.ReactElement | null {
const items = (props.items || []).filter(i => i.label && i.value);
if (!items.length) return 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;
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>
<View style={styles.kvValue}>
<Text style={styles.kvValueText}>{valueText}</Text>
</View>
</View>
);
})}
</View>
);
}

View File

@@ -0,0 +1,14 @@
import * as React from 'react';
import { Text, View } from '@react-pdf/renderer';
import { styles } from '../styles';
export function Section(props: { title: string; children: React.ReactNode }): React.ReactElement {
return (
<View style={styles.section}>
<Text style={styles.sectionTitle}>{props.title}</Text>
{props.children}
</View>
);
}

View File

@@ -0,0 +1,26 @@
import * as React from 'react';
import { renderToBuffer } from '@react-pdf/renderer';
import type { ProductData } from '../model/types';
import { buildDatasheetModel } from '../model/build-datasheet-model';
import { loadImageAsPngDataUrl, loadQrAsPngDataUrl } from './assets';
import { DatasheetDocument } from './DatasheetDocument';
export async function generateDatasheetPdfBuffer(args: {
product: ProductData;
locale: 'en' | 'de';
}): Promise<Buffer> {
const model = buildDatasheetModel({ product: args.product, locale: args.locale });
const logoDataUrl =
(await loadImageAsPngDataUrl('/media/logo.svg')) ||
(await loadImageAsPngDataUrl('/media/logo.webp')) ||
null;
const heroDataUrl = await loadImageAsPngDataUrl(model.product.heroSrc);
const qrDataUrl = await loadQrAsPngDataUrl(model.product.productUrl);
const element = <DatasheetDocument model={model} assets={{ logoDataUrl, heroDataUrl, qrDataUrl }} />;
return await renderToBuffer(element);
}

View File

@@ -0,0 +1,119 @@
import { StyleSheet } from '@react-pdf/renderer';
export const COLORS = {
navy: '#0E2A47',
mediumGray: '#6B7280',
darkGray: '#1F2933',
lightGray: '#E6E9ED',
almostWhite: '#F8F9FA',
headerBg: '#F6F8FB',
} as const;
export const styles = StyleSheet.create({
page: {
paddingTop: 54,
paddingLeft: 54,
paddingRight: 54,
paddingBottom: 72,
fontFamily: 'Helvetica',
fontSize: 10,
color: COLORS.darkGray,
backgroundColor: '#FFFFFF',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 16,
backgroundColor: COLORS.headerBg,
borderBottomWidth: 1,
borderBottomColor: COLORS.lightGray,
marginBottom: 16,
},
headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 },
logo: { width: 110, height: 24, objectFit: 'contain' },
brandFallback: { flexDirection: 'row', alignItems: 'baseline', gap: 6 },
brandFallbackKlz: { fontSize: 18, fontWeight: 700, color: COLORS.navy },
brandFallbackCables: { fontSize: 10, color: COLORS.mediumGray },
headerRight: { flexDirection: 'row', alignItems: 'center', gap: 10 },
headerTitle: { fontSize: 9, fontWeight: 700, color: COLORS.navy, letterSpacing: 0.2 },
qr: { width: 34, height: 34, objectFit: 'contain' },
footer: {
position: 'absolute',
left: 54,
right: 54,
bottom: 36,
paddingTop: 10,
borderTopWidth: 1,
borderTopColor: COLORS.lightGray,
flexDirection: 'row',
justifyContent: 'space-between',
fontSize: 8,
color: COLORS.mediumGray,
},
h1: { fontSize: 18, fontWeight: 700, color: COLORS.navy, marginBottom: 6 },
subhead: { fontSize: 10.5, color: COLORS.mediumGray, marginBottom: 14 },
heroBox: {
height: 110,
borderWidth: 1,
borderColor: COLORS.lightGray,
backgroundColor: COLORS.almostWhite,
marginBottom: 16,
justifyContent: 'center',
overflow: 'hidden',
},
heroImage: { width: '100%', height: '100%', objectFit: 'contain' },
noImage: { fontSize: 8, color: COLORS.mediumGray, paddingHorizontal: 12 },
section: {
borderWidth: 1,
borderColor: COLORS.lightGray,
padding: 14,
marginBottom: 14,
},
sectionTitle: {
fontSize: 10,
fontWeight: 700,
color: COLORS.navy,
marginBottom: 8,
letterSpacing: 0.2,
},
body: { fontSize: 10, lineHeight: 1.5, color: COLORS.darkGray },
kvGrid: {
borderWidth: 1,
borderColor: COLORS.lightGray,
},
kvRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: COLORS.lightGray },
kvRowLast: { borderBottomWidth: 0 },
kvLabel: {
width: '45%',
paddingVertical: 6,
paddingHorizontal: 8,
backgroundColor: COLORS.almostWhite,
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' },
tableHeaderCell: {
paddingVertical: 5,
paddingHorizontal: 4,
fontSize: 6.6,
fontWeight: 700,
color: '#FFFFFF',
},
tableRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: COLORS.lightGray },
tableRowAlt: { backgroundColor: COLORS.almostWhite },
tableCell: { paddingVertical: 4, paddingHorizontal: 4, fontSize: 6.6, color: COLORS.darkGray },
});