feat: unify pdf datasheet architecture and regenerate all products
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 2m18s
Build & Deploy / 🏗️ Build (push) Successful in 3m43s
Build & Deploy / 🚀 Deploy (push) Successful in 19s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 4m54s
Build & Deploy / 🔔 Notify (push) Successful in 1s
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 8s
Build & Deploy / 🧪 QA (push) Successful in 2m18s
Build & Deploy / 🏗️ Build (push) Successful in 3m43s
Build & Deploy / 🚀 Deploy (push) Successful in 19s
Build & Deploy / 🧪 Post-Deploy Verification (push) Failing after 4m54s
Build & Deploy / 🔔 Notify (push) Successful in 1s
This commit is contained in:
231
scripts/pdf/react-pdf/components/DenseTable.tsx
Normal file
231
scripts/pdf/react-pdf/components/DenseTable.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
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));
|
||||
}
|
||||
|
||||
function normTextForMeasure(v: unknown): string {
|
||||
return String(v ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function textLen(v: unknown): number {
|
||||
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);
|
||||
|
||||
// If mins don't fit, scale them down proportionally.
|
||||
const minSum = mins.reduce((a, b) => a + b, 0);
|
||||
if (minSum > total) {
|
||||
const k = total / minSum;
|
||||
return mins.map((m) => m * k);
|
||||
}
|
||||
|
||||
const result = mins.slice();
|
||||
let remaining = total - minSum;
|
||||
let remainingIdx = Array.from({ length: n }, (_, i) => i);
|
||||
|
||||
// Distribute remaining proportionally, respecting max constraints.
|
||||
// Loop is guaranteed to terminate because each iteration either:
|
||||
// - removes at least one index due to hitting max, or
|
||||
// - exhausts `remaining`.
|
||||
while (remaining > 1e-9 && remainingIdx.length) {
|
||||
const wSum = remainingIdx.reduce((acc, i) => acc + Math.max(0, weights[i] || 0), 0);
|
||||
if (wSum <= 1e-9) {
|
||||
// No meaningful weights: distribute evenly.
|
||||
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;
|
||||
}
|
||||
|
||||
// Numerical guard: force exact sum by adjusting the last column.
|
||||
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;
|
||||
}
|
||||
|
||||
export function DenseTable(props: {
|
||||
table: Pick<DatasheetVoltageTable, 'columns' | 'rows'>;
|
||||
firstColLabel: string;
|
||||
}): React.ReactElement {
|
||||
const cols = props.table.columns;
|
||||
const rows = props.table.rows;
|
||||
|
||||
const headerText = (label: string): string => {
|
||||
// Table headers must NEVER wrap into a second line.
|
||||
// react-pdf can wrap on spaces, so we replace whitespace with NBSP.
|
||||
return String(label || '')
|
||||
.replace(/\s+/g, '\u00A0')
|
||||
.trim();
|
||||
};
|
||||
|
||||
// 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*
|
||||
// - distribute data columns by estimated content width (header + cells)
|
||||
// so columns better fit their data
|
||||
// Make first column denser so numeric columns get more room.
|
||||
// (Long designations can still wrap in body if needed, but table scanability
|
||||
// benefits more from wider data columns.)
|
||||
const cfgMin = 0.14;
|
||||
const cfgMax = 0.23;
|
||||
|
||||
// A content-based heuristic.
|
||||
// React-PDF doesn't expose a reliable text-measurement API at render time,
|
||||
// so we approximate width by string length (compressed via sqrt to reduce outliers).
|
||||
const cfgContentLen = Math.max(
|
||||
textLen(props.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]));
|
||||
// Slightly prioritize the header (scanability) over a single long cell.
|
||||
return Math.max(headerL * 1.15, cellMax, 3);
|
||||
});
|
||||
|
||||
// Use mostly-linear weights so long headers get noticeably more space.
|
||||
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);
|
||||
|
||||
// Ensure a minimum per-data-column width; if needed, shrink cfgPct.
|
||||
// These floors are intentionally generous. Too-narrow columns are worse than a
|
||||
// slightly narrower first column for scanability.
|
||||
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) => {
|
||||
// Keep the last column as the remainder so percentages sum to exactly 100%.
|
||||
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 (
|
||||
<View style={styles.tableWrap} break={false}>
|
||||
<View style={styles.tableHeader} wrap={false}>
|
||||
<View style={{ width: cfgW }}>
|
||||
<Text
|
||||
style={[
|
||||
styles.tableHeaderCell,
|
||||
styles.tableHeaderCellCfg,
|
||||
{ fontSize: headerFontSize, paddingHorizontal: 3 },
|
||||
cols.length ? styles.tableHeaderCellDivider : null,
|
||||
]}
|
||||
wrap={false}
|
||||
>
|
||||
{headerText(props.firstColLabel)}
|
||||
</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,
|
||||
{ fontSize: headerFontSize, paddingHorizontal: 3 },
|
||||
!isLast ? styles.tableHeaderCellDivider : null,
|
||||
]}
|
||||
wrap={false}
|
||||
>
|
||||
{headerText(c.label)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{rows.map((r, ri) => (
|
||||
<View
|
||||
key={`${r.configuration}-${ri}`}
|
||||
style={[styles.tableRow, ri % 2 === 0 ? styles.tableRowAlt : null]}
|
||||
wrap={false}
|
||||
// If the row doesn't fit, move the whole row to the next page.
|
||||
// This prevents page breaks mid-row.
|
||||
minPresenceAhead={16}
|
||||
>
|
||||
<View style={{ width: cfgW }} wrap={false}>
|
||||
<Text
|
||||
style={[
|
||||
styles.tableCell,
|
||||
styles.tableCellCfg,
|
||||
// Denser first column: slightly smaller type + tighter padding.
|
||||
{ fontSize: 6.2, paddingHorizontal: 3 },
|
||||
cols.length ? styles.tableCellDivider : null,
|
||||
]}
|
||||
wrap={false}
|
||||
>
|
||||
{r.configuration}
|
||||
</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] }} wrap={false}>
|
||||
<Text
|
||||
style={[styles.tableCell, !isLast ? styles.tableCellDivider : null]}
|
||||
wrap={false}
|
||||
>
|
||||
{cell}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
22
scripts/pdf/react-pdf/components/Footer.tsx
Normal file
22
scripts/pdf/react-pdf/components/Footer.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react';
|
||||
import { Text, View } from '@react-pdf/renderer';
|
||||
|
||||
import { styles } from '../styles';
|
||||
|
||||
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>{siteUrl}</Text>
|
||||
<Text>{date}</Text>
|
||||
<Text render={({ pageNumber, totalPages }) => `${pageNumber}/${totalPages}`} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
29
scripts/pdf/react-pdf/components/Header.tsx
Normal file
29
scripts/pdf/react-pdf/components/Header.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
51
scripts/pdf/react-pdf/components/KeyValueGrid.tsx
Normal file
51
scripts/pdf/react-pdf/components/KeyValueGrid.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
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;
|
||||
|
||||
// 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}>
|
||||
{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={`${left.label}-${rowIndex}`}
|
||||
style={[
|
||||
styles.kvRow,
|
||||
rowIndex % 2 === 0 ? styles.kvRowAlt : null,
|
||||
isLast ? styles.kvRowLast : null,
|
||||
]}
|
||||
wrap={false}
|
||||
>
|
||||
<View style={[styles.kvCell, { width: '23%' }]}>
|
||||
<Text style={styles.kvLabelText}>{left.label}</Text>
|
||||
</View>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
22
scripts/pdf/react-pdf/components/Section.tsx
Normal file
22
scripts/pdf/react-pdf/components/Section.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
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;
|
||||
boxed?: boolean;
|
||||
minPresenceAhead?: number;
|
||||
}): React.ReactElement {
|
||||
const boxed = props.boxed ?? true;
|
||||
return (
|
||||
<View
|
||||
style={boxed ? styles.section : styles.sectionPlain}
|
||||
minPresenceAhead={props.minPresenceAhead}
|
||||
>
|
||||
<Text style={styles.sectionTitle}>{props.title}</Text>
|
||||
{props.children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user