fix(datasheets): update PDF datasheets and generator for Class 2 conductor spec on NA cables
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 1m25s
Build & Deploy / 🏗️ Build (push) Successful in 2m51s
Build & Deploy / 🚀 Deploy (push) Successful in 16s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m18s
Build & Deploy / 🔔 Notify (push) Successful in 2s
Nightly QA / 💨 Smoke & Health (push) Successful in 1m1s
All checks were successful
Build & Deploy / 🔍 Prepare (push) Successful in 11s
Build & Deploy / 🧪 QA (push) Successful in 1m25s
Build & Deploy / 🏗️ Build (push) Successful in 2m51s
Build & Deploy / 🚀 Deploy (push) Successful in 16s
Build & Deploy / 🧪 Post-Deploy Verification (push) Successful in 1m18s
Build & Deploy / 🔔 Notify (push) Successful in 2s
Nightly QA / 💨 Smoke & Health (push) Successful in 1m1s
This commit is contained in:
396
scripts/generate-pdf-datasheets.ts
Normal file
396
scripts/generate-pdf-datasheets.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
#!/usr/bin/env ts-node
|
||||
/**
|
||||
* PDF Datasheet Generator (React-PDF)
|
||||
*
|
||||
* Renders PDFs via `@react-pdf/renderer`.
|
||||
*
|
||||
* Source of truth:
|
||||
* - All technical data + cross-section tables: Excel files in `data/excel/`
|
||||
* - Product description text: MDX files in `data/products/{en,de}/*.mdx`
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import * as XLSX from 'xlsx';
|
||||
import matter from 'gray-matter';
|
||||
|
||||
import type { ProductData } from './pdf/model/types';
|
||||
import { generateDatasheetPdfBuffer } from './pdf/react-pdf/generate-datasheet-pdf';
|
||||
import { generateFileName, normalizeValue, stripHtml } from './pdf/model/utils';
|
||||
|
||||
const CONFIG = {
|
||||
outputDir: path.join(process.cwd(), 'public/datasheets'),
|
||||
chunkSize: 10,
|
||||
} as const;
|
||||
|
||||
const EXCEL_FILES = [
|
||||
{ path: path.join(process.cwd(), 'data/excel/high-voltage.xlsx'), voltageType: 'high-voltage' },
|
||||
{
|
||||
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;
|
||||
|
||||
type MdxProduct = {
|
||||
slug: string;
|
||||
title: string;
|
||||
sku: string;
|
||||
categories: string[];
|
||||
images: string[];
|
||||
descriptionHtml: string;
|
||||
applicationHtml: string;
|
||||
};
|
||||
|
||||
type MdxIndex = Map<string, MdxProduct>; // key: normalized designation/title
|
||||
|
||||
function ensureOutputDir(): void {
|
||||
if (!fs.existsSync(CONFIG.outputDir)) {
|
||||
fs.mkdirSync(CONFIG.outputDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeExcelKey(value: string): string {
|
||||
return String(value || '')
|
||||
.toUpperCase()
|
||||
.replace(/-\d+$/g, '')
|
||||
.replace(/[^A-Z0-9]+/g, '');
|
||||
}
|
||||
|
||||
function extractDescriptionFromMdxFrontmatter(data: any): string {
|
||||
const description = normalizeValue(String(data?.description || ''));
|
||||
return description;
|
||||
}
|
||||
|
||||
function parseMdxFileSafely(
|
||||
raw: string,
|
||||
fileName: string,
|
||||
): {
|
||||
slug: string;
|
||||
title: string;
|
||||
sku: string;
|
||||
categories: string[];
|
||||
images: string[];
|
||||
descriptionHtml: string;
|
||||
applicationHtml: string;
|
||||
} {
|
||||
let title = '';
|
||||
let sku = '';
|
||||
let slug = path.basename(fileName, '.mdx');
|
||||
let descriptionHtml = '';
|
||||
let applicationHtml = '';
|
||||
const categories: string[] = [];
|
||||
const images: string[] = [];
|
||||
|
||||
try {
|
||||
const parsed = matter(raw);
|
||||
const data = (parsed.data || {}) as any;
|
||||
title = normalizeValue(String(data.title || ''));
|
||||
sku = normalizeValue(String(data.sku || ''));
|
||||
slug = data.slug || slug;
|
||||
descriptionHtml = extractDescriptionFromMdxFrontmatter(data);
|
||||
applicationHtml = normalizeValue(String(data?.application || ''));
|
||||
if (Array.isArray(data.categories)) {
|
||||
for (const c of data.categories) {
|
||||
const val = normalizeValue(typeof c === 'string' ? c : String(c?.category || ''));
|
||||
if (val) categories.push(val);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(data.images)) {
|
||||
for (const i of data.images) {
|
||||
const val = normalizeValue(typeof i === 'string' ? i : String(i?.url || ''));
|
||||
if (val) images.push(val);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fallback regex parsing if YAML has indentation issues
|
||||
const titleMatch = raw.match(/^title:\s*(.+)$/m);
|
||||
if (titleMatch) title = normalizeValue(titleMatch[1]);
|
||||
|
||||
const skuMatch = raw.match(/^sku:\s*(.+)$/m);
|
||||
if (skuMatch) sku = normalizeValue(skuMatch[1]);
|
||||
|
||||
const slugMatch = raw.match(/^slug:\s*(.+)$/m);
|
||||
if (slugMatch) slug = normalizeValue(slugMatch[1]);
|
||||
|
||||
const descMatch = raw.match(
|
||||
/^description:\s*(?:>|>-)?\s*\n([\s\S]*?)(?=\n[a-zA-Z0-9_-]+:|\n---)/,
|
||||
);
|
||||
if (descMatch) descriptionHtml = normalizeValue(descMatch[1].replace(/\n\s*/g, ' ').trim());
|
||||
|
||||
const catMatches = Array.from(raw.matchAll(/category:\s*(.+)$/gm));
|
||||
for (const cm of catMatches) {
|
||||
const val = normalizeValue(cm[1]);
|
||||
if (val && !categories.includes(val)) categories.push(val);
|
||||
}
|
||||
|
||||
const urlMatches = Array.from(raw.matchAll(/url:\s*(.+)$/gm));
|
||||
for (const um of urlMatches) {
|
||||
const val = normalizeValue(um[1]);
|
||||
if (val && val.startsWith('/media/') && !images.includes(val)) images.push(val);
|
||||
}
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
title = path.basename(fileName, '.mdx').toUpperCase();
|
||||
}
|
||||
|
||||
return { slug, title, sku, categories, images, descriptionHtml, applicationHtml };
|
||||
}
|
||||
|
||||
function buildMdxIndex(locale: 'en' | 'de'): MdxIndex {
|
||||
const candidateDirs = [
|
||||
path.join(process.cwd(), 'content/products'),
|
||||
path.join(process.cwd(), 'data/products', locale),
|
||||
path.join(process.cwd(), 'data/products/de'),
|
||||
];
|
||||
const dir = candidateDirs.find((d) => fs.existsSync(d));
|
||||
const idx: MdxIndex = new Map();
|
||||
if (!dir) return idx;
|
||||
|
||||
const files = fs
|
||||
.readdirSync(dir)
|
||||
.filter((f) => f.endsWith('.mdx'))
|
||||
.sort();
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dir, file);
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed = parseMdxFileSafely(raw, file);
|
||||
if (!parsed.title) continue;
|
||||
|
||||
idx.set(normalizeExcelKey(parsed.title), parsed);
|
||||
}
|
||||
|
||||
return idx;
|
||||
}
|
||||
|
||||
function findKeyByHeaderValue(headerRow: Record<string, unknown>, 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 readExcelRows(filePath: string): Array<Record<string, unknown>> {
|
||||
if (!fs.existsSync(filePath)) return [];
|
||||
try {
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
const workbook = XLSX.read(fileBuffer, {
|
||||
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>>;
|
||||
} catch {
|
||||
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) ||
|
||||
findKeyByHeaderValue(headerRow, /^part\s*number$/i) ||
|
||||
'__EMPTY';
|
||||
|
||||
const out = new Map<string, string>();
|
||||
for (const r of rows) {
|
||||
const pn = normalizeValue(String(r?.[partNumberKey] ?? ''));
|
||||
if (!pn || pn === 'Units' || pn === 'Part Number') continue;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function loadAllExcelDesignations(): Map<string, { designation: string; voltageType: string }> {
|
||||
const out = new Map<string, { designation: string; voltageType: string }>();
|
||||
for (const file of EXCEL_FILES) {
|
||||
const m = readDesignationsFromExcelFile(file.path);
|
||||
Array.from(m.entries()).forEach(([k, v]) => {
|
||||
if (!out.has(k)) out.set(k, { designation: v, voltageType: file.voltageType });
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function loadProductsFromExcelAndMdx(locale: 'en' | 'de'): Promise<ProductData[]> {
|
||||
const mdxIndex = buildMdxIndex(locale);
|
||||
const excelDesignations = loadAllExcelDesignations();
|
||||
|
||||
const products: ProductData[] = [];
|
||||
let id = 1;
|
||||
|
||||
Array.from(excelDesignations.entries()).forEach(([key, data]) => {
|
||||
const mdx = mdxIndex.get(key) || null;
|
||||
|
||||
const title = mdx?.title || data.designation;
|
||||
const slug =
|
||||
mdx?.slug ||
|
||||
title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
|
||||
// Only the product description comes from MDX. Everything else is Excel-driven
|
||||
// during model building (technicalItems + voltage tables).
|
||||
const descriptionHtml = mdx?.descriptionHtml || '';
|
||||
|
||||
products.push({
|
||||
id: id++,
|
||||
name: title,
|
||||
shortDescriptionHtml: '',
|
||||
descriptionHtml,
|
||||
applicationHtml: mdx?.applicationHtml || '',
|
||||
images: mdx?.images || [],
|
||||
featuredImage: (mdx?.images && mdx.images[0]) || null,
|
||||
sku: mdx?.sku || title,
|
||||
slug,
|
||||
translationKey: slug,
|
||||
locale,
|
||||
categories: (mdx?.categories || []).map((name) => ({ name })),
|
||||
attributes: [],
|
||||
voltageType: (() => {
|
||||
const cats = (mdx?.categories || []).map((c) => String(c));
|
||||
const isMV = cats.some((c) => /medium[-\s]?voltage|mittelspannung/i.test(c));
|
||||
if (isMV && data.voltageType === 'high-voltage') return 'medium-voltage';
|
||||
return data.voltageType;
|
||||
})(),
|
||||
});
|
||||
});
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
async function processChunk(
|
||||
products: ProductData[],
|
||||
chunkIndex: number,
|
||||
totalChunks: number,
|
||||
): Promise<void> {
|
||||
console.log(
|
||||
`\nProcessing chunk ${chunkIndex + 1}/${totalChunks} (${products.length} products)...`,
|
||||
);
|
||||
|
||||
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);
|
||||
|
||||
// Determine subfolder based on voltage type
|
||||
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 });
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function processProductsInChunks(): Promise<void> {
|
||||
console.log('Starting PDF generation (React-PDF)');
|
||||
ensureOutputDir();
|
||||
|
||||
const onlyLocale = normalizeValue(String(process.env.PDF_LOCALE || '')).toLowerCase();
|
||||
const locales: Array<'en' | 'de'> =
|
||||
onlyLocale === 'de' || onlyLocale === 'en' ? [onlyLocale] : ['en', 'de'];
|
||||
|
||||
const allProducts: ProductData[] = [];
|
||||
for (const locale of locales) {
|
||||
const products = await loadProductsFromExcelAndMdx(locale);
|
||||
allProducts.push(...products);
|
||||
}
|
||||
|
||||
if (allProducts.length === 0) {
|
||||
console.log('No products found');
|
||||
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();
|
||||
if (match) {
|
||||
products = products.filter((p) => {
|
||||
const hay = [p.slug, p.translationKey, p.sku, p.name].filter(Boolean).join(' ').toLowerCase();
|
||||
return hay.includes(match);
|
||||
});
|
||||
}
|
||||
|
||||
const limit = Number(process.env.PDF_LIMIT || '0');
|
||||
products = Number.isFinite(limit) && limit > 0 ? products.slice(0, limit) : products;
|
||||
|
||||
const enProducts = products.filter((p) => (p.locale || 'en') === 'en');
|
||||
const deProducts = products.filter((p) => (p.locale || 'en') === 'de');
|
||||
console.log(`Found ${enProducts.length} EN + ${deProducts.length} DE products`);
|
||||
|
||||
const totalChunks = Math.ceil(products.length / CONFIG.chunkSize);
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const chunk = products.slice(i * CONFIG.chunkSize, (i + 1) * CONFIG.chunkSize);
|
||||
await processChunk(chunk, i, totalChunks);
|
||||
}
|
||||
|
||||
console.log('\n✅ PDF generation completed!');
|
||||
console.log(`Generated ${enProducts.length} EN + ${deProducts.length} DE PDFs`);
|
||||
console.log(`Output: ${CONFIG.outputDir}`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
await processProductsInChunks();
|
||||
console.log(`\nTime: ${((Date.now() - start) / 1000).toFixed(2)}s`);
|
||||
} catch (error) {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
|
||||
export { main as generatePDFDatasheets };
|
||||
1618
scripts/pdf/model/build-datasheet-model.ts
Normal file
1618
scripts/pdf/model/build-datasheet-model.ts
Normal file
File diff suppressed because it is too large
Load Diff
212
scripts/pdf/model/excel-index.ts
Normal file
212
scripts/pdf/model/excel-index.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
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[] {
|
||||
if (!fs.existsSync(filePath)) return [];
|
||||
try {
|
||||
const fileBuffer = fs.readFileSync(filePath);
|
||||
const workbook = XLSX.read(fileBuffer, {
|
||||
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 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;
|
||||
}
|
||||
53
scripts/pdf/model/types.ts
Normal file
53
scripts/pdf/model/types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export interface ProductData {
|
||||
id: number;
|
||||
name: string;
|
||||
shortDescriptionHtml: string;
|
||||
descriptionHtml: string;
|
||||
applicationHtml: 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[];
|
||||
}>;
|
||||
voltageType?: 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[];
|
||||
};
|
||||
74
scripts/pdf/model/utils.ts
Normal file
74
scripts/pdf/model/utils.ts
Normal 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: 'Technical Datasheet',
|
||||
description: 'APPLICATION',
|
||||
technicalData: 'TECHNICAL DATA',
|
||||
crossSection: 'Cross-sections/Voltage',
|
||||
sku: 'SKU',
|
||||
noImage: 'No image available',
|
||||
},
|
||||
de: {
|
||||
datasheet: 'Technisches Datenblatt',
|
||||
description: 'ANWENDUNG',
|
||||
technicalData: 'TECHNISCHE DATEN',
|
||||
crossSection: 'Querschnitte/Spannung',
|
||||
sku: 'ARTIKELNUMMER',
|
||||
noImage: 'Kein Bild verfügbar',
|
||||
},
|
||||
}[locale];
|
||||
}
|
||||
111
scripts/pdf/react-pdf/DatasheetDocument.tsx
Normal file
111
scripts/pdf/react-pdf/DatasheetDocument.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
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 & Querschnitt' : 'Cores & Cross-section';
|
||||
|
||||
return (
|
||||
<Document>
|
||||
<Page size="A4" style={styles.page}>
|
||||
<View style={styles.hero}>
|
||||
<Header
|
||||
title={headerTitle}
|
||||
logoDataUrl={assets.logoDataUrl}
|
||||
qrDataUrl={assets.qrDataUrl}
|
||||
isHero={true}
|
||||
/>
|
||||
|
||||
<View style={styles.productRow}>
|
||||
<View style={styles.productInfoCol}>
|
||||
<View style={styles.productHero}>
|
||||
{model.product.categoriesLine ? (
|
||||
<Text style={styles.productMeta}>{model.product.categoriesLine}</Text>
|
||||
) : null}
|
||||
<Text style={styles.productName}>{model.product.name}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.productImageCol}>
|
||||
{assets.heroDataUrl ? (
|
||||
<Image src={assets.heroDataUrl} style={styles.heroImage} />
|
||||
) : (
|
||||
<Text style={styles.noImage}>{model.labels.noImage}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Footer locale={model.locale} siteUrl={CONFIG.siteUrl} />
|
||||
|
||||
<View style={styles.content}>
|
||||
{model.product.descriptionText ? (
|
||||
<Section title={model.labels.description} minPresenceAhead={24}>
|
||||
<Text style={styles.body}>{model.product.descriptionText}</Text>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{model.technicalItems.length ? (
|
||||
<Section title={model.labels.technicalData} minPresenceAhead={24}>
|
||||
<KeyValueGrid items={model.technicalItems} />
|
||||
</Section>
|
||||
) : null}
|
||||
</View>
|
||||
</Page>
|
||||
|
||||
<Page size="A4" style={styles.page}>
|
||||
<Header title={headerTitle} logoDataUrl={assets.logoDataUrl} qrDataUrl={assets.qrDataUrl} />
|
||||
<Footer locale={model.locale} siteUrl={CONFIG.siteUrl} />
|
||||
|
||||
<View style={styles.content}>
|
||||
{model.voltageTables.map((t: DatasheetVoltageTable) => (
|
||||
<View
|
||||
key={t.voltageLabel}
|
||||
style={{ marginBottom: 14 }}
|
||||
break={false}
|
||||
minPresenceAhead={24}
|
||||
>
|
||||
<Text
|
||||
style={styles.sectionTitle}
|
||||
>{`${model.labels.crossSection} — ${t.voltageLabel}`}</Text>
|
||||
<View style={styles.sectionAccent} />
|
||||
<DenseTable
|
||||
table={{ columns: t.columns, rows: t.rows }}
|
||||
firstColLabel={firstColLabel}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{model.legendItems.length ? (
|
||||
<Section
|
||||
title={model.locale === 'de' ? 'ABKÜRZUNGEN' : 'ABBREVIATIONS'}
|
||||
minPresenceAhead={24}
|
||||
>
|
||||
<KeyValueGrid items={model.legendItems} />
|
||||
</Section>
|
||||
) : null}
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
);
|
||||
}
|
||||
93
scripts/pdf/react-pdf/assets.ts
Normal file
93
scripts/pdf/react-pdf/assets.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
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 QRCode = (await import('qrcode')).default || (await import('qrcode'));
|
||||
const buffer = await QRCode.toBuffer(data, { width: 180, margin: 1, type: 'png' });
|
||||
return `data:image/png;base64,${buffer.toString('base64')}`;
|
||||
} catch {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
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} minPresenceAhead={24}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
25
scripts/pdf/react-pdf/components/Footer.tsx
Normal file
25
scripts/pdf/react-pdf/components/Footer.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
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 style={styles.footerBrand}>KLZ CABLES</Text>
|
||||
<Text style={styles.footerText}>{date}</Text>
|
||||
<Text
|
||||
style={styles.footerText}
|
||||
render={({ pageNumber, totalPages }) => `${pageNumber} / ${totalPages}`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
44
scripts/pdf/react-pdf/components/Header.tsx
Normal file
44
scripts/pdf/react-pdf/components/Header.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
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;
|
||||
isHero?: boolean;
|
||||
}): React.ReactElement {
|
||||
const { isHero = false } = props;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={
|
||||
isHero
|
||||
? styles.header
|
||||
: [
|
||||
styles.header,
|
||||
{
|
||||
paddingHorizontal: 0,
|
||||
backgroundColor: 'transparent',
|
||||
borderBottomWidth: 0,
|
||||
marginBottom: 24,
|
||||
paddingTop: 40,
|
||||
},
|
||||
]
|
||||
}
|
||||
>
|
||||
<View style={styles.headerLeft}>
|
||||
{props.logoDataUrl ? (
|
||||
<Image src={props.logoDataUrl} style={styles.logo} />
|
||||
) : (
|
||||
<Text style={styles.brandFallback}>KLZ</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.headerRight}>
|
||||
<Text style={styles.headerTitle}>{props.title}</Text>
|
||||
{props.qrDataUrl ? <Image src={props.qrDataUrl} style={styles.qr} /> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
40
scripts/pdf/react-pdf/components/KeyValueGrid.tsx
Normal file
40
scripts/pdf/react-pdf/components/KeyValueGrid.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
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;
|
||||
|
||||
// 2-column layout: (label, value)
|
||||
return (
|
||||
<View style={styles.kvGrid}>
|
||||
{items.map((item, rowIndex) => {
|
||||
const isLast = rowIndex === items.length - 1;
|
||||
const value = item.unit ? `${item.value} ${item.unit}` : item.value;
|
||||
|
||||
return (
|
||||
<View
|
||||
key={`${item.label}-${rowIndex}`}
|
||||
style={[
|
||||
styles.kvRow,
|
||||
rowIndex % 2 === 0 ? styles.kvRowAlt : null,
|
||||
isLast ? styles.kvRowLast : null,
|
||||
]}
|
||||
wrap={false}
|
||||
minPresenceAhead={12}
|
||||
>
|
||||
<View style={[styles.kvCell, { width: '50%' }]}>
|
||||
<Text style={styles.kvLabelText}>{item.label}</Text>
|
||||
</View>
|
||||
<View style={[styles.kvCell, { width: '50%' }]}>
|
||||
<Text style={styles.kvValueText}>{value}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
20
scripts/pdf/react-pdf/components/Section.tsx
Normal file
20
scripts/pdf/react-pdf/components/Section.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
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={styles.section} minPresenceAhead={props.minPresenceAhead}>
|
||||
<Text style={styles.sectionTitle}>{props.title}</Text>
|
||||
<View style={styles.sectionAccent} />
|
||||
{props.children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
27
scripts/pdf/react-pdf/generate-datasheet-pdf.tsx
Normal file
27
scripts/pdf/react-pdf/generate-datasheet-pdf.tsx
Normal 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);
|
||||
}
|
||||
270
scripts/pdf/react-pdf/styles.ts
Normal file
270
scripts/pdf/react-pdf/styles.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
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 = {
|
||||
primary: '#001a4d',
|
||||
primaryDark: '#000d26',
|
||||
accent: '#82ed20',
|
||||
textPrimary: '#111827',
|
||||
textSecondary: '#4b5563',
|
||||
textLight: '#9ca3af',
|
||||
neutral: '#f8f9fa',
|
||||
border: '#e5e7eb',
|
||||
} as const;
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
page: {
|
||||
paddingTop: 0,
|
||||
paddingLeft: 30,
|
||||
paddingRight: 30,
|
||||
paddingBottom: 60,
|
||||
fontFamily: 'Helvetica',
|
||||
fontSize: 10,
|
||||
color: COLORS.textPrimary,
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
|
||||
// Hero-style header
|
||||
hero: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
paddingTop: 30,
|
||||
paddingBottom: 0,
|
||||
paddingHorizontal: 0,
|
||||
marginBottom: 20,
|
||||
position: 'relative',
|
||||
borderBottomWidth: 0,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
paddingHorizontal: 0,
|
||||
},
|
||||
headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
||||
logo: { width: 100, height: 22, objectFit: 'contain' },
|
||||
brandFallback: {
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
color: COLORS.primaryDark,
|
||||
letterSpacing: 1,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
headerRight: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
||||
headerTitle: {
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: COLORS.primary,
|
||||
letterSpacing: 1.5,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
qr: { width: 30, height: 30, objectFit: 'contain' },
|
||||
|
||||
productRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 20,
|
||||
},
|
||||
productInfoCol: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
productImageCol: {
|
||||
flex: 1,
|
||||
height: 120,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.border,
|
||||
backgroundColor: '#FFFFFF',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
productHero: {
|
||||
marginTop: 0,
|
||||
paddingHorizontal: 0,
|
||||
},
|
||||
productName: {
|
||||
fontSize: 24,
|
||||
fontWeight: 700,
|
||||
color: COLORS.primaryDark,
|
||||
marginBottom: 0,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
productMeta: {
|
||||
fontSize: 9,
|
||||
color: COLORS.textSecondary,
|
||||
fontWeight: 700,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
marginBottom: 4,
|
||||
},
|
||||
|
||||
content: {
|
||||
paddingHorizontal: 0,
|
||||
},
|
||||
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
left: 30,
|
||||
right: 30,
|
||||
bottom: 30,
|
||||
paddingTop: 16,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: COLORS.border,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
footerBrand: {
|
||||
fontSize: 9,
|
||||
fontWeight: 700,
|
||||
color: COLORS.primaryDark,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 8,
|
||||
color: COLORS.textLight,
|
||||
fontWeight: 500,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
h1: {
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: COLORS.primaryDark,
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
subhead: {
|
||||
fontSize: 10,
|
||||
fontWeight: 700,
|
||||
color: COLORS.textSecondary,
|
||||
marginBottom: 16,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
|
||||
heroBox: {
|
||||
height: 180,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.border,
|
||||
backgroundColor: '#FFFFFF',
|
||||
marginBottom: 24,
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
padding: 0,
|
||||
},
|
||||
heroImage: { width: '100%', height: '100%', objectFit: 'contain' },
|
||||
noImage: { fontSize: 8, color: COLORS.textLight, textAlign: 'center' },
|
||||
|
||||
section: {
|
||||
marginBottom: 10,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: COLORS.primaryDark,
|
||||
marginBottom: 8,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: -0.2,
|
||||
},
|
||||
sectionAccent: {
|
||||
width: 30,
|
||||
height: 3,
|
||||
backgroundColor: COLORS.accent,
|
||||
marginBottom: 8,
|
||||
borderRadius: 1.5,
|
||||
},
|
||||
body: { fontSize: 10, lineHeight: 1.6, color: COLORS.textSecondary },
|
||||
|
||||
kvGrid: {
|
||||
width: '100%',
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.border,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
kvRow: {
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
kvRowAlt: { backgroundColor: COLORS.neutral },
|
||||
kvRowLast: { borderBottomWidth: 0 },
|
||||
kvCell: { paddingVertical: 3, paddingHorizontal: 12 },
|
||||
kvMidDivider: {
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: COLORS.border,
|
||||
},
|
||||
kvLabelText: {
|
||||
fontSize: 8,
|
||||
fontWeight: 700,
|
||||
color: COLORS.primaryDark,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
kvValueText: { fontSize: 9, color: COLORS.textPrimary, fontWeight: 500 },
|
||||
|
||||
tableWrap: {
|
||||
width: '100%',
|
||||
borderWidth: 1,
|
||||
borderColor: COLORS.border,
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 16,
|
||||
},
|
||||
tableHeader: {
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
backgroundColor: COLORS.neutral,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
tableHeaderCell: {
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 6,
|
||||
fontSize: 7,
|
||||
fontWeight: 700,
|
||||
color: COLORS.primaryDark,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
tableHeaderCellCfg: {
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
tableHeaderCellDivider: {
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: COLORS.border,
|
||||
},
|
||||
tableRow: {
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: COLORS.border,
|
||||
},
|
||||
tableRowAlt: { backgroundColor: '#FFFFFF' },
|
||||
tableCell: {
|
||||
paddingVertical: 6,
|
||||
paddingHorizontal: 6,
|
||||
fontSize: 7,
|
||||
color: COLORS.textSecondary,
|
||||
fontWeight: 500,
|
||||
},
|
||||
tableCellCfg: {
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
tableCellDivider: {
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: COLORS.border,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user