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

This commit is contained in:
2026-03-06 23:42:46 +01:00
parent b80136894c
commit 20051244d9
62 changed files with 2026 additions and 49 deletions

View File

@@ -15,10 +15,17 @@ import * as path from 'path';
import * as XLSX from 'xlsx';
import { getPayload } from 'payload';
import configPromise from '@payload-config';
import { renderToBuffer } from '@react-pdf/renderer';
import * as React from 'react';
import type { ProductData } from './pdf/model/types';
import { generateDatasheetPdfBuffer } from './pdf/react-pdf/generate-datasheet-pdf';
import type { ProductData as BaseProductData } from './pdf/model/types';
interface ProductData extends BaseProductData {
voltageType: string;
}
import { buildDatasheetModel } from './pdf/model/build-datasheet-model';
import { loadImageAsPngDataUrl, loadQrAsPngDataUrl } from './pdf/react-pdf/assets';
import { generateFileName, normalizeValue, stripHtml } from './pdf/model/utils';
import { PDFDatasheet } from '../lib/pdf-datasheet';
const CONFIG = {
outputDir: path.join(process.cwd(), 'public/datasheets'),
@@ -31,10 +38,6 @@ const EXCEL_FILES = [
path: path.join(process.cwd(), 'data/excel/medium-voltage-KM.xlsx'),
voltageType: 'medium-voltage',
},
{
path: path.join(process.cwd(), 'data/excel/medium-voltage-KM 170126.xlsx'),
voltageType: 'medium-voltage',
},
{ path: path.join(process.cwd(), 'data/excel/low-voltage-KM.xlsx'), voltageType: 'low-voltage' },
{ path: path.join(process.cwd(), 'data/excel/solar-cables.xlsx'), voltageType: 'solar' },
] as const;
@@ -67,6 +70,7 @@ function normalizeExcelKey(value: string): string {
async function buildCmsIndex(locale: 'en' | 'de'): Promise<CmsIndex> {
const idx: CmsIndex = new Map();
try {
// Attempt to connect to DB, but don't fail the whole script if it fails
const payload = await getPayload({ config: configPromise });
const isDev = process.env.NODE_ENV === 'development';
const result = await payload.find({
@@ -94,7 +98,7 @@ async function buildCmsIndex(locale: 'en' | 'de'): Promise<CmsIndex> {
: [];
const descriptionHtml = normalizeValue(String(doc.description || ''));
const applicationHtml = ''; // Application usually part of description in Payload now
const applicationHtml = '';
const slug = doc.slug || '';
idx.set(normalizeExcelKey(title), {
@@ -108,7 +112,10 @@ async function buildCmsIndex(locale: 'en' | 'de'): Promise<CmsIndex> {
});
}
} catch (error) {
console.error(`[Payload] Failed to fetch products for CMS index (${locale}):`, error);
console.warn(
`[Payload] Warning: Could not fetch CMS index (${locale}). Using Excel data only.`,
error instanceof Error ? error.message : error,
);
}
return idx;
@@ -124,26 +131,38 @@ function findKeyByHeaderValue(headerRow: Record<string, unknown>, pattern: RegEx
}
function readExcelRows(filePath: string): Array<Record<string, unknown>> {
if (!fs.existsSync(filePath)) return [];
const workbook = XLSX.readFile(filePath, { cellDates: false, cellNF: false, cellText: false });
const sheetName = workbook.SheetNames[0];
if (!sheetName) return [];
const sheet = workbook.Sheets[sheetName];
if (!sheet) return [];
if (!fs.existsSync(filePath)) {
console.warn(`[Excel] Warning: File not found: ${filePath}`);
return [];
}
try {
const data = fs.readFileSync(filePath);
const workbook = XLSX.read(data, {
type: 'buffer',
cellDates: false,
cellNF: false,
cellText: false,
});
const sheetName = workbook.SheetNames[0];
if (!sheetName) return [];
const sheet = workbook.Sheets[sheetName];
if (!sheet) return [];
return XLSX.utils.sheet_to_json(sheet, {
defval: '',
raw: false,
blankrows: false,
}) as Array<Record<string, unknown>>;
return XLSX.utils.sheet_to_json(sheet, {
defval: '',
raw: false,
blankrows: false,
}) as Array<Record<string, unknown>>;
} catch (err) {
console.error(`[Excel] Failed to read ${filePath}:`, err);
return [];
}
}
function readDesignationsFromExcelFile(filePath: string): Map<string, string> {
const rows = readExcelRows(filePath);
if (!rows.length) return new Map();
// Legacy sheets use "Part Number" as a column key.
// The new MV sheet uses __EMPTY* keys and stores the human headers in row 0 values.
const headerRow = rows[0] || {};
const partNumberKey =
(Object.prototype.hasOwnProperty.call(headerRow, 'Part Number') ? 'Part Number' : null) ||
@@ -158,7 +177,6 @@ function readDesignationsFromExcelFile(filePath: string): Map<string, string> {
const key = normalizeExcelKey(pn);
if (!key) continue;
// Keep first-seen designation string (stable filenames from MDX slug).
if (!out.has(key)) out.set(key, pn);
}
@@ -195,8 +213,6 @@ async function loadProductsFromExcelAndCms(locale: 'en' | 'de'): Promise<Product
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
// Only the product description comes from CMS. Everything else is Excel-driven
// during model building (technicalItems + voltage tables).
const descriptionHtml = cmsItem?.descriptionHtml || '';
products.push({
@@ -204,7 +220,6 @@ async function loadProductsFromExcelAndCms(locale: 'en' | 'de'): Promise<Product
name: title,
shortDescriptionHtml: '',
descriptionHtml,
applicationHtml: cmsItem?.applicationHtml || '',
images: cmsItem?.images || [],
featuredImage: (cmsItem?.images && cmsItem.images[0]) || null,
sku: cmsItem?.sku || title,
@@ -222,12 +237,10 @@ async function loadProductsFromExcelAndCms(locale: 'en' | 'de'): Promise<Product
});
});
// Deterministic order: by slug, then name.
products.sort(
(a, b) => (a.slug || '').localeCompare(b.slug || '') || a.name.localeCompare(b.name),
);
// Drop products that have no readable name.
return products.filter((p) => stripHtml(p.name));
}
@@ -243,29 +256,53 @@ async function processChunk(
for (const product of products) {
try {
const locale = (product.locale || 'en') as 'en' | 'de';
const buffer = await generateDatasheetPdfBuffer({ product, locale });
const fileName = generateFileName(product, locale);
console.log(`[${product.id}] Starting: ${product.name} (${locale})`);
// Determine subfolder based on voltage type
const model = buildDatasheetModel({ product, locale });
if (!model) {
console.warn(`[${product.id}] Warning: buildDatasheetModel returned nothing`);
continue;
}
// Load assets as Data URLs for React-PDF
const heroDataUrl = await loadImageAsPngDataUrl(model.product.heroSrc);
const fileName = generateFileName(product, locale);
const voltageType = (product as any).voltageType || 'other';
const subfolder = path.join(CONFIG.outputDir, voltageType);
// Create subfolder if it doesn't exist
if (!fs.existsSync(subfolder)) {
fs.mkdirSync(subfolder, { recursive: true });
}
// Render using the unified component
const element = (
<PDFDatasheet
product={{
...model.product,
featuredImage: heroDataUrl,
}}
locale={locale}
technicalItems={model.technicalItems}
voltageTables={model.voltageTables}
legendItems={model.legendItems}
/>
);
const buffer = await renderToBuffer(element);
fs.writeFileSync(path.join(subfolder, fileName), buffer);
console.log(`${locale.toUpperCase()}: ${voltageType}/${fileName}`);
await new Promise((resolve) => setTimeout(resolve, 25));
} catch (error) {
console.error(`✗ Failed to process product ${product.id}:`, error);
console.error(`✗ Failed to process product ${product.id} (${product.name}):`, error);
}
}
}
async function processProductsInChunks(): Promise<void> {
console.log('Starting PDF generation (React-PDF)');
console.log('Starting PDF generation (React-PDF - Unified Component)');
ensureOutputDir();
const onlyLocale = normalizeValue(String(process.env.PDF_LOCALE || '')).toLowerCase();
@@ -283,8 +320,6 @@ async function processProductsInChunks(): Promise<void> {
return;
}
// Dev convenience: generate only one product subset.
// IMPORTANT: apply filters BEFORE PDF_LIMIT so the limit works within the filtered set.
let products = allProducts;
const match = normalizeValue(String(process.env.PDF_MATCH || '')).toLowerCase();

View File

@@ -0,0 +1,894 @@
import * as fs from 'fs';
import * as path from 'path';
import type { DatasheetModel, DatasheetVoltageTable, KeyValueItem, ProductData } from './types';
import type { ExcelMatch, MediumVoltageCrossSectionExcelMatch } from './excel-index';
import { findExcelForProduct, findMediumVoltageCrossSectionExcelForProduct } from './excel-index';
import { getLabels, getProductUrl, normalizeValue, stripHtml } from './utils';
type ExcelRow = Record<string, unknown>;
type VoltageTableModel = {
voltageLabel: string;
metaItems: KeyValueItem[];
crossSections: string[];
columns: Array<{ key: string; label: string; get: (rowIndex: number) => string }>;
};
type BuildExcelModelResult = {
ok: boolean;
technicalItems: KeyValueItem[];
voltageTables: VoltageTableModel[];
};
type AssetMap = Record<string, string>;
const ASSET_MAP_FILE = path.join(process.cwd(), 'data/processed/asset-map.json');
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();
function normalizeUnit(unitRaw: string): string {
const u = normalizeValue(unitRaw);
if (!u) return '';
if (/^c$/i.test(u) || /^°c$/i.test(u)) return '°C';
return u.replace(/Ω/gi, 'Ohm').replace(/[\u00B5\u03BC]/g, 'u');
}
function formatExcelHeaderLabel(key: string, unit?: string): string {
const k = normalizeValue(key);
if (!k) return '';
const u = normalizeValue(unit || '');
const compact = k
.replace(/\s*\(approx\.?\)\s*/gi, ' (approx.) ')
.replace(/\s+/g, ' ')
.trim();
if (!u) return compact;
if (new RegExp(`\\(${u.replace(/[.*+?^${}()|[\\]\\]/g, '\\$&')}\\)`, 'i').test(compact))
return compact;
return `${compact} (${u})`;
}
function normalizeVoltageLabel(raw: string): string {
const v = normalizeValue(raw);
if (!v) return '';
const cleaned = v.replace(/\s+/g, ' ');
if (/\bkv\b/i.test(cleaned)) return cleaned.replace(/\bkv\b/i, 'kV');
const num = cleaned.match(/\d+(?:[.,]\d+)?(?:\s*\/\s*\d+(?:[.,]\d+)?)?/);
if (!num) return cleaned;
if (/[a-z]/i.test(cleaned)) return cleaned;
return `${cleaned} kV`;
}
function parseVoltageSortKey(voltageLabel: string): number {
const v = normalizeVoltageLabel(voltageLabel);
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 compactNumericForLocale(value: string, locale: 'en' | 'de'): string {
const v = normalizeValue(value);
if (!v) return '';
// Compact common bending-radius style: "15xD (Single core); 12xD (Multi core)" -> "15/12xD".
// Keep semantics, reduce width. Never truncate with ellipses.
if (/\d+xD/i.test(v)) {
const nums = Array.from(v.matchAll(/(\d+)xD/gi))
.map((m) => m[1])
.filter(Boolean);
const unique: string[] = [];
for (const n of nums) {
if (!unique.includes(n)) unique.push(n);
}
if (unique.length) return `${unique.join('/')}xD`;
}
const hasDigit = /\d/.test(v);
if (!hasDigit) return v;
const trimmed = v.replace(/\s+/g, ' ').trim();
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, '.');
const compact = n
.replace(/\.0+$/, '')
.replace(/(\.\d*?)0+$/, '$1')
.replace(/\.$/, '');
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) {
const esc = u.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
v = v.replace(new RegExp(`\\s*${esc}\\b`, 'ig'), '').trim();
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();
}
v = v
.replace(/\s*\s*/g, '-')
.replace(/\s*-\s*/g, '-')
.replace(/\s*\/\s*/g, '/')
.replace(/\s+/g, ' ')
.trim();
return compactNumericForLocale(v, locale);
}
function resolveMediaToLocalPath(urlOrPath: string | null | undefined): string | null {
if (!urlOrPath) return null;
if (urlOrPath.startsWith('/')) return urlOrPath;
if (/^media\//i.test(urlOrPath)) return `/${urlOrPath}`;
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;
}
return urlOrPath;
}
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);
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 technicalFullLabel(args: { key: string; excelKey: string; locale: 'en' | 'de' }): string {
if (args.locale === 'en') return normalizeValue(args.excelKey);
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');
}
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);
}
}
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 denseAbbrevLabel(args: { key: string; locale: 'en' | 'de'; unit?: string }): string {
const u = normalizeUnit(args.unit || '');
const unitSafe = u.replace(/Ω/gi, 'Ohm').replace(/[\u00B5\u03BC]/g, 'u');
const suffix = 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':
// Capacitance. Use a clear label; lowercase "cap" looks like an internal key.
return `Cap${suffix}`;
case 'X':
return `X${suffix}`;
case 'test_volt':
return `U_test${suffix}`;
case 'rated_volt':
return `U0/U${suffix}`;
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}`;
case 'cpr':
return `CPR${suffix}`;
case 'flame':
return `FR${suffix}`;
default:
return args.key || '';
}
}
function summarizeOptions(options: string[] | undefined): 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];
// Never use ellipsis truncation in datasheets. Prefer full value list.
// (Long values should be handled by layout; if needed we can later add wrapping rules.)
return uniq.join(' / ');
}
function parseNumericOption(value: string): number | null {
const v = normalizeValue(value).replace(/,/g, '.');
const m = v.match(/-?\d+(?:\.\d+)?/);
if (!m) return null;
const n = Number(m[0]);
return Number.isFinite(n) ? n : null;
}
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 (uniq.length < 4) return { ok: false, text: '' };
uniq.sort((a, b) => a - b);
const min = uniq[0];
const max = uniq[uniq.length - 1];
const fmt = (n: number) => (Number.isInteger(n) ? String(n) : String(n)).replace(/\.0+$/, '');
return { ok: true, text: `${fmt(min)}${fmt(max)}` };
}
function summarizeSmartOptions(_label: string, options: string[] | undefined): string {
const range = summarizeNumericRange(options);
if (range.ok) return range.text;
return summarizeOptions(options);
}
function normalizeDesignation(value: string): string {
return String(value || '')
.toUpperCase()
.replace(/-\d+$/g, '')
.replace(/[^A-Z0-9]+/g, '');
}
function buildExcelModel(args: {
product: ProductData;
locale: 'en' | 'de';
}): BuildExcelModelResult {
const match = findExcelForProduct(args.product) as ExcelMatch | null;
if (!match || match.rows.length === 0)
return { ok: false, technicalItems: [], voltageTables: [] };
const units = match.units || {};
const rows = match.rows;
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;
}
}
const columnMapping: Record<string, { header: string; unit: string; key: string }> = {
'number of cores and cross-section': {
header: 'Cross-section',
unit: '',
key: 'cross_section',
},
'ross section conductor': { header: 'Cross-section', unit: '', key: 'cross_section' },
'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': { header: 'Fzv', unit: 'N', key: 'Fzv' },
'max. pulling force': { header: 'Fzv', unit: 'N', key: 'Fzv' },
'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' },
'shape of conductor': { header: 'Conductor shape', unit: '', key: 'shape' },
'operating temperature range': {
header: 'Operating temp range',
unit: '°C',
key: 'temp_range',
},
'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',
},
'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' },
'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' },
// High-value electrical/screen columns
'capacitance (approx.)': { header: 'Capacitance', unit: 'uF/km', key: 'cap' },
capacitance: { header: 'Capacitance', unit: 'uF/km', key: 'cap' },
reactance: { header: 'Reactance', unit: 'Ohm/km', key: 'X' },
'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' },
};
const excelKeys = Object.keys(sample).filter((k) => k && k !== 'Part Number' && k !== 'Units');
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;
}
}
}
const seenKeys = new Set<string>();
const deduplicated: typeof matchedColumns = [];
for (const item of matchedColumns) {
if (!seenKeys.has(item.mapping.key)) {
seenKeys.add(item.mapping.key);
deduplicated.push(item);
}
}
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: [] };
const byVoltage = new Map<string, number[]>();
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 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);
});
const technicalItems: KeyValueItem[] = [];
const globalConstantColumns = new Set<string>();
for (const { excelKey, mapping } of deduplicated) {
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);
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);
if (!technicalItems.find((t) => t.label === label))
technicalItems.push({ label, value, unit });
}
}
technicalItems.sort((a, b) => a.label.localeCompare(b.label));
const voltageTables: VoltageTableModel[] = [];
for (const vKey of voltageKeysSorted) {
const indices = byVoltage.get(vKey) || [];
if (!indices.length) continue;
const crossSections = indices.map((idx) =>
normalizeValue(String(compatibleRows[idx]?.[csKey] ?? '')),
);
const metaItems: KeyValueItem[] = [];
const metaCandidates = new Map<string, KeyValueItem>();
if (voltageKey) {
const rawV = normalizeValue(String(compatibleRows[indices[0]]?.[voltageKey] ?? ''));
metaItems.push({
label: args.locale === 'de' ? 'Spannung' : 'Voltage',
value: normalizeVoltageLabel(rawV || ''),
});
}
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);
const denseTableKeyOrder = [
'Cond',
'shape',
// Electrical properties (when present)
'cap',
'X',
// Dimensions and ratings
'DI',
'RI',
'Wi',
'Ibl',
'Ibe',
'Ik_cond',
'Wm',
'Rbv',
'Ø',
// Screen data (when present)
'D_screen',
'S_screen',
'Fzv',
'G',
] as const;
const denseTableKeys = new Set<string>(denseTableKeyOrder);
const tableColumns: Array<{
excelKey: string;
mapping: { header: string; unit: string; key: string };
}> = [];
for (const { excelKey, mapping } of deduplicated) {
if (excelKey === csKey || excelKey === voltageKey) continue;
const values = indices
.map((idx) => normalizeValue(String(compatibleRows[idx]?.[excelKey] ?? '')))
.filter(Boolean);
if (!values.length) continue;
const unique = Array.from(new Set(values.map((v) => v.toLowerCase())));
const unit = normalizeUnit(units[excelKey] || mapping.unit || '');
if (denseTableKeys.has(mapping.key)) {
tableColumns.push({ excelKey, mapping });
continue;
}
if (globalConstantColumns.has(excelKey) && !metaKeyPrioritySet.has(mapping.key)) {
continue;
}
const value =
unique.length === 1
? compactCellForDenseTable(values[0], unit, args.locale)
: summarizeSmartOptions(excelKey, values);
const label = metaFullLabel({ key: mapping.key, excelKey, locale: args.locale });
metaCandidates.set(mapping.key, { label, value, unit });
}
for (const k of metaKeyPriority) {
const item = metaCandidates.get(k);
if (item && item.label && item.value) metaItems.push(item);
}
const mappedByKey = new Map<
string,
{ excelKey: string; mapping: { header: string; unit: string; key: string } }
>();
for (const c of tableColumns) {
if (!mappedByKey.has(c.mapping.key)) mappedByKey.set(c.mapping.key, c);
}
// If conductor material is missing in Excel, derive it from designation.
// NA... => Al, N... => Cu (common for this dataset).
if (!mappedByKey.has('Cond')) {
mappedByKey.set('Cond', {
excelKey: '',
mapping: { header: 'Cond.', unit: '', key: 'Cond' },
});
}
const orderedTableColumns = denseTableKeyOrder
.filter((k) => mappedByKey.has(k))
.map((k) => mappedByKey.get(k)!)
.map(({ excelKey, mapping }) => {
const unit = normalizeUnit((excelKey ? units[excelKey] : '') || mapping.unit || '');
return {
key: mapping.key,
label:
denseAbbrevLabel({ key: mapping.key, locale: args.locale, unit }) ||
formatExcelHeaderLabel(excelKey, unit),
get: (rowIndex: number) => {
const srcRowIndex = indices[rowIndex];
if (mapping.key === 'Cond' && !excelKey) {
const pn = normalizeDesignation(
args.product.name || args.product.slug || args.product.sku || '',
);
if (/^NA/.test(pn)) return 'Al';
if (/^N/.test(pn)) return 'Cu';
return '';
}
const raw = excelKey
? normalizeValue(String(compatibleRows[srcRowIndex]?.[excelKey] ?? ''))
: '';
return compactCellForDenseTable(raw, unit, args.locale);
},
};
});
voltageTables.push({
voltageLabel: vKey,
metaItems,
crossSections,
columns: orderedTableColumns,
});
}
return { ok: true, technicalItems, voltageTables };
}
function isMediumVoltageProduct(product: ProductData): boolean {
const hay = [
product.slug,
product.path,
product.translationKey,
...(product.categories || []).map((c) => c.name),
]
.filter(Boolean)
.join(' ');
return /medium[-\s]?voltage|mittelspannung/i.test(hay);
}
type AbbrevColumn = { colKey: string; unit: string };
function isAbbreviatedHeaderKey(key: string): boolean {
const k = normalizeValue(key);
if (!k) return false;
if (/^__EMPTY/i.test(k)) return false;
// Examples from the MV sheet: "LD mm", "RI Ohm", "G kg", "SBL 30", "SBE 20", "BK", "BR", "LF".
// Keep this permissive but focused on compact, non-sentence identifiers.
if (k.length > 12) return false;
if (/[a-z]{4,}/.test(k)) return false;
if (!/[A-ZØ]/.test(k)) return false;
return true;
}
function extractAbbrevColumnsFromMediumVoltageHeader(args: {
headerRow: Record<string, unknown>;
units: Record<string, string>;
partNumberKey: string;
crossSectionKey: string;
ratedVoltageKey: string | null;
}): AbbrevColumn[] {
const out: AbbrevColumn[] = [];
for (const colKey of Object.keys(args.headerRow || {})) {
if (!colKey) continue;
if (colKey === args.partNumberKey) continue;
if (colKey === args.crossSectionKey) continue;
if (args.ratedVoltageKey && colKey === args.ratedVoltageKey) continue;
if (!isAbbreviatedHeaderKey(colKey)) continue;
const unit = normalizeUnit(args.units[colKey] || '');
out.push({ colKey, unit });
}
return out;
}
function buildMediumVoltageCrossSectionTableFromNewExcel(args: {
product: ProductData;
locale: 'en' | 'de';
}): BuildExcelModelResult & { legendItems: KeyValueItem[] } {
const mv = findMediumVoltageCrossSectionExcelForProduct(
args.product,
) as MediumVoltageCrossSectionExcelMatch | null;
if (!mv || !mv.rows.length)
return { ok: false, technicalItems: [], voltageTables: [], legendItems: [] };
if (!mv.crossSectionKey)
return { ok: false, technicalItems: [], voltageTables: [], legendItems: [] };
const abbrevCols = extractAbbrevColumnsFromMediumVoltageHeader({
headerRow: mv.headerRow,
units: mv.units,
partNumberKey: mv.partNumberKey,
crossSectionKey: mv.crossSectionKey,
ratedVoltageKey: mv.ratedVoltageKey,
});
if (!abbrevCols.length)
return { ok: false, technicalItems: [], voltageTables: [], legendItems: [] };
// Collect legend items: abbreviation -> description from header row
const legendItems: KeyValueItem[] = [];
for (const col of abbrevCols) {
const description = normalizeValue(String(mv.headerRow[col.colKey] || ''));
if (description && description !== col.colKey) {
legendItems.push({
label: col.colKey,
value: description,
});
}
}
const byVoltage = new Map<string, number[]>();
for (let i = 0; i < mv.rows.length; i++) {
const cs = normalizeValue(
String((mv.rows[i] as Record<string, unknown>)?.[mv.crossSectionKey] ?? ''),
);
if (!cs) continue;
const rawV = mv.ratedVoltageKey
? normalizeValue(String((mv.rows[i] as Record<string, unknown>)?.[mv.ratedVoltageKey] ?? ''))
: '';
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 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);
});
const voltageTables: VoltageTableModel[] = [];
for (const vKey of voltageKeysSorted) {
const indices = byVoltage.get(vKey) || [];
if (!indices.length) continue;
const crossSections = indices.map((idx) =>
normalizeValue(String((mv.rows[idx] as Record<string, unknown>)?.[mv.crossSectionKey] ?? '')),
);
const metaItems: KeyValueItem[] = [];
if (mv.ratedVoltageKey) {
const rawV = normalizeValue(
String((mv.rows[indices[0]] as Record<string, unknown>)?.[mv.ratedVoltageKey] ?? ''),
);
metaItems.push({
label: args.locale === 'de' ? 'Spannung' : 'Voltage',
value: normalizeVoltageLabel(rawV || ''),
});
}
const columns = abbrevCols.map((col) => {
return {
key: col.colKey,
// Use the abbreviated title from the first row as the table header.
label: normalizeValue(col.colKey),
get: (rowIndex: number) => {
const srcRowIndex = indices[rowIndex];
const raw = normalizeValue(
String((mv.rows[srcRowIndex] as Record<string, unknown>)?.[col.colKey] ?? ''),
);
return compactCellForDenseTable(raw, col.unit, args.locale);
},
};
});
voltageTables.push({ voltageLabel: vKey, metaItems, crossSections, columns });
}
return { ok: true, technicalItems: [], voltageTables, legendItems };
}
export function buildDatasheetModel(args: {
product: ProductData;
locale: 'en' | 'de';
}): DatasheetModel {
const labels = getLabels(args.locale);
const categoriesLine = (args.product.categories || []).map((c) => stripHtml(c.name)).join(' • ');
const descriptionText = stripHtml(
args.product.shortDescriptionHtml || args.product.descriptionHtml || '',
);
const heroSrc = resolveMediaToLocalPath(
args.product.featuredImage || args.product.images?.[0] || null,
);
const productUrl = getProductUrl(args.product);
// Technical data MUST stay sourced from the existing Excel index (legacy sheets).
const excelModel = buildExcelModel({ product: args.product, locale: args.locale });
// Cross-section tables: for medium voltage only, prefer the new MV sheet (abbrev columns in header row).
const crossSectionModel = isMediumVoltageProduct(args.product)
? buildMediumVoltageCrossSectionTableFromNewExcel({
product: args.product,
locale: args.locale,
})
: { ok: false, technicalItems: [], voltageTables: [], legendItems: [] };
const voltageTablesSrc = crossSectionModel.ok
? crossSectionModel.voltageTables
: excelModel.ok
? excelModel.voltageTables
: [];
const voltageTables: DatasheetVoltageTable[] = voltageTablesSrc.map((t) => {
const columns = t.columns.map((c) => ({ key: c.key, label: c.label }));
const rows = t.crossSections.map((configuration, rowIndex) => ({
configuration,
cells: t.columns.map((c) => compactNumericForLocale(c.get(rowIndex), args.locale)),
}));
return {
voltageLabel: t.voltageLabel,
metaItems: t.metaItems,
columns,
rows,
};
});
return {
locale: args.locale,
product: {
id: args.product.id,
name: stripHtml(args.product.name),
sku: args.product.sku,
categoriesLine,
descriptionText,
heroSrc,
productUrl,
},
labels,
technicalItems: excelModel.ok ? excelModel.technicalItems : [],
voltageTables,
legendItems: crossSectionModel.legendItems || [],
};
}

View File

@@ -0,0 +1,204 @@
import * as fs from 'fs';
import * as path from 'path';
import { execSync } from 'child_process';
import type { ProductData } from './types';
import { normalizeValue } from './utils';
type ExcelRow = Record<string, unknown>;
export type ExcelMatch = { rows: ExcelRow[]; units: Record<string, string> };
export type MediumVoltageCrossSectionExcelMatch = {
headerRow: ExcelRow;
rows: ExcelRow[];
units: Record<string, string>;
partNumberKey: string;
crossSectionKey: string;
ratedVoltageKey: string | null;
};
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'),
];
// Medium-voltage cross-section table (new format with multi-row header).
// IMPORTANT: this must NOT be used for the technical data table.
const MV_CROSS_SECTION_FILE = path.join(process.cwd(), 'data/excel/medium-voltage-KM 170126.xlsx');
type MediumVoltageCrossSectionIndex = {
headerRow: ExcelRow;
units: Record<string, string>;
partNumberKey: string;
crossSectionKey: string;
ratedVoltageKey: string | null;
rowsByDesignation: Map<string, ExcelRow[]>;
};
let EXCEL_INDEX: Map<string, ExcelMatch> | null = null;
let MV_CROSS_SECTION_INDEX: MediumVoltageCrossSectionIndex | null = null;
export function normalizeExcelKey(value: string): string {
return String(value || '')
.toUpperCase()
.replace(/-\d+$/g, '')
.replace(/[^A-Z0-9]+/g, '');
}
function loadExcelRows(filePath: string): ExcelRow[] {
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 findKeyByHeaderValue(headerRow: ExcelRow, pattern: RegExp): string | null {
for (const [k, v] of Object.entries(headerRow || {})) {
const text = normalizeValue(String(v ?? ''));
if (!text) continue;
if (pattern.test(text)) return k;
}
return null;
}
function getMediumVoltageCrossSectionIndex(): MediumVoltageCrossSectionIndex {
if (MV_CROSS_SECTION_INDEX) return MV_CROSS_SECTION_INDEX;
const rows = fs.existsSync(MV_CROSS_SECTION_FILE) ? loadExcelRows(MV_CROSS_SECTION_FILE) : [];
const headerRow = (rows[0] || {}) as ExcelRow;
const partNumberKey = findKeyByHeaderValue(headerRow, /^part\s*number$/i) || '__EMPTY';
const crossSectionKey = findKeyByHeaderValue(headerRow, /querschnitt|cross.?section/i) || '';
const ratedVoltageKey =
findKeyByHeaderValue(headerRow, /rated voltage|voltage rating|nennspannung/i) || null;
const unitsRow =
rows.find((r) => normalizeValue(String((r as ExcelRow)?.[partNumberKey] ?? '')) === 'Units') ||
null;
const units: Record<string, string> = {};
if (unitsRow) {
for (const [k, v] of Object.entries(unitsRow)) {
if (k === partNumberKey) continue;
const unit = normalizeValue(String(v ?? ''));
if (unit) units[k] = unit;
}
}
const rowsByDesignation = new Map<string, ExcelRow[]>();
for (const r of rows) {
if (r === headerRow) continue;
const pn = normalizeValue(String((r as ExcelRow)?.[partNumberKey] ?? ''));
if (!pn || pn === 'Units' || pn === 'Part Number') continue;
const key = normalizeExcelKey(pn);
if (!key) continue;
const cur = rowsByDesignation.get(key) || [];
cur.push(r);
rowsByDesignation.set(key, cur);
}
MV_CROSS_SECTION_INDEX = {
headerRow,
units,
partNumberKey,
crossSectionKey,
ratedVoltageKey,
rowsByDesignation,
};
return MV_CROSS_SECTION_INDEX;
}
export function getExcelIndex(): Map<string, ExcelMatch> {
if (EXCEL_INDEX) return EXCEL_INDEX;
const idx = new Map<string, ExcelMatch>();
for (const file of EXCEL_SOURCE_FILES) {
if (!fs.existsSync(file)) continue;
const rows = loadExcelRows(file);
const unitsRow = rows.find((r) => r && r['Part Number'] === 'Units') || null;
const units: Record<string, string> = {};
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;
}
export 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[];
for (const c of candidates) {
const key = normalizeExcelKey(c);
const match = idx.get(key);
if (match && match.rows.length) return match;
}
return null;
}
export function findMediumVoltageCrossSectionExcelForProduct(
product: ProductData,
): MediumVoltageCrossSectionExcelMatch | null {
const idx = getMediumVoltageCrossSectionIndex();
const candidates = [
product.name,
product.slug ? product.slug.replace(/-\d+$/g, '') : '',
product.sku,
product.translationKey,
].filter(Boolean) as string[];
for (const c of candidates) {
const key = normalizeExcelKey(c);
const rows = idx.rowsByDesignation.get(key) || [];
if (rows.length) {
return {
headerRow: idx.headerRow,
rows,
units: idx.units,
partNumberKey: idx.partNumberKey,
crossSectionKey: idx.crossSectionKey,
ratedVoltageKey: idx.ratedVoltageKey,
};
}
}
return null;
}

View File

@@ -0,0 +1,51 @@
export 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[];
}>;
}
export type KeyValueItem = { label: string; value: string; unit?: string };
export type DatasheetVoltageTable = {
voltageLabel: string;
metaItems: KeyValueItem[];
columns: Array<{ key: string; label: string }>;
rows: Array<{ configuration: string; cells: string[] }>;
};
export type DatasheetModel = {
locale: 'en' | 'de';
product: {
id: number;
name: string;
sku: string;
categoriesLine: string;
descriptionText: string;
heroSrc: string | null;
productUrl: string;
};
labels: {
datasheet: string;
description: string;
technicalData: string;
crossSection: string;
sku: string;
noImage: string;
};
technicalItems: KeyValueItem[];
voltageTables: DatasheetVoltageTable[];
legendItems: KeyValueItem[];
};

View File

@@ -0,0 +1,74 @@
import * as path from 'path';
import type { ProductData } from './types';
export const CONFIG = {
siteUrl: 'https://klz-cables.com',
publicDir: path.join(process.cwd(), 'public'),
assetMapFile: path.join(process.cwd(), 'data/processed/asset-map.json'),
} as const;
export function stripHtml(html: string): string {
if (!html) return '';
let text = String(html)
.replace(/<[^>]*>/g, '')
.normalize('NFC');
text = text
.replace(/[\u00A0\u202F]/g, ' ')
.replace(/[\u2013\u2014]/g, '-')
.replace(/[\u2018\u2019]/g, "'")
.replace(/[\u201C\u201D]/g, '"')
.replace(/\u2026/g, '...')
.replace(/[\u2022]/g, '·')
.replace(/[\u2264]/g, '<=')
.replace(/[\u2265]/g, '>=')
.replace(/[\u2248]/g, '~')
.replace(/[\u03A9\u2126]/g, 'Ohm')
.replace(/[\u00B5\u03BC]/g, 'u')
.replace(/[\u2193]/g, 'v')
.replace(/[\u2191]/g, '^')
.replace(/[\u00B0]/g, '°');
// eslint-disable-next-line no-control-regex
text = text.replace(/[\u0000-\u001F\u007F]/g, '');
return text.replace(/\s+/g, ' ').trim();
}
export function normalizeValue(value: string): string {
return stripHtml(value).replace(/\s+/g, ' ').trim();
}
export function getProductUrl(product: ProductData): string {
if (product.path) return `${CONFIG.siteUrl}${product.path}`;
return CONFIG.siteUrl;
}
export function 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`;
}
export function getLabels(locale: 'en' | 'de') {
return {
en: {
datasheet: 'PRODUCT DATASHEET',
description: 'DESCRIPTION',
technicalData: 'TECHNICAL DATA',
crossSection: 'CROSS-SECTION DATA',
sku: 'SKU',
noImage: 'No image available',
},
de: {
datasheet: 'PRODUKTDATENBLATT',
description: 'BESCHREIBUNG',
technicalData: 'TECHNISCHE DATEN',
crossSection: 'QUERSCHNITTSDATEN',
sku: 'ARTIKELNUMMER',
noImage: 'Kein Bild verfügbar',
},
}[locale];
}

View File

@@ -0,0 +1,90 @@
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';
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;
};
export function DatasheetDocument(props: {
model: DatasheetModel;
assets: Assets;
}): React.ReactElement {
const { model, assets } = props;
const headerTitle = model.labels.datasheet;
// Dense tables require compact headers (no wrapping). Use standard abbreviations.
const firstColLabel = model.locale === 'de' ? 'Adern & QS' : 'Cores & CS';
return (
<Document>
<Page size="A4" style={styles.page}>
<Header title={headerTitle} logoDataUrl={assets.logoDataUrl} qrDataUrl={assets.qrDataUrl} />
<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}
<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>
{/*
Render all voltage sections in a single flow so React-PDF can paginate naturally.
This avoids hard page breaks that waste remaining whitespace at the bottom of a page.
*/}
<Page size="A4" style={styles.page}>
<Header title={headerTitle} logoDataUrl={assets.logoDataUrl} qrDataUrl={assets.qrDataUrl} />
<Footer locale={model.locale} siteUrl={CONFIG.siteUrl} />
{model.voltageTables.map((t: DatasheetVoltageTable) => (
<View key={t.voltageLabel} style={{ marginBottom: 14 }} break={false}>
<Text
style={styles.sectionTitle}
>{`${model.labels.crossSection}${t.voltageLabel}`}</Text>
<DenseTable
table={{ columns: t.columns, rows: t.rows }}
firstColLabel={firstColLabel}
/>
</View>
))}
{model.legendItems.length ? (
<Section title={model.locale === 'de' ? 'ABKÜRZUNGEN' : 'ABBREVIATIONS'}>
<KeyValueGrid items={model.legendItems} />
</Section>
) : null}
</Page>
</Document>
);
}

View File

@@ -0,0 +1,87 @@
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:#000000')
.replace(/fill\s*=\s*"white"/gi, 'fill="#000000"')
.replace(/fill\s*=\s*'white'/gi, "fill='#000000'")
.replace(/fill\s*:\s*#[0-9a-fA-F]{6}/gi, 'fill:#000000')
.replace(/fill\s*=\s*"#[0-9a-fA-F]{6}"/gi, 'fill="#000000"')
.replace(/fill\s*=\s*'#[0-9a-fA-F]{6}'/gi, "fill='#000000'");
}
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) || /\/logo-blue\.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,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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,27 @@
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('/logo-blue.svg')) ||
(await loadImageAsPngDataUrl('/logo-white.svg')) ||
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,155 @@
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',
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,
},
sectionPlain: {
paddingVertical: 2,
marginBottom: 12,
},
sectionTitle: {
fontSize: 10,
fontWeight: 700,
color: COLORS.navy,
marginBottom: 8,
letterSpacing: 0.2,
},
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,
},
kvRowAlt: { backgroundColor: COLORS.almostWhite },
kvRowLast: { borderBottomWidth: 0 },
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,
},
kvLabelText: { fontSize: 8.5, fontWeight: 700, color: COLORS.mediumGray },
kvValueText: { fontSize: 9.5, color: COLORS.darkGray },
tableWrap: { width: '100%', borderWidth: 1, borderColor: COLORS.lightGray, marginBottom: 14 },
tableHeader: {
width: '100%',
flexDirection: 'row',
backgroundColor: '#FFFFFF',
borderBottomWidth: 1,
borderBottomColor: COLORS.lightGray,
},
tableHeaderCell: {
paddingVertical: 5,
paddingHorizontal: 4,
fontSize: 6.6,
fontWeight: 700,
color: COLORS.navy,
},
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,
},
});