Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae381442aa |
File diff suppressed because one or more lines are too long
BIN
data/excel/high-voltage.xlsx
Normal file
BIN
data/excel/high-voltage.xlsx
Normal file
Binary file not shown.
BIN
data/excel/low-voltage-KM.xlsx
Normal file
BIN
data/excel/low-voltage-KM.xlsx
Normal file
Binary file not shown.
BIN
data/excel/medium-voltage-KM 170126.xlsx
Normal file
BIN
data/excel/medium-voltage-KM 170126.xlsx
Normal file
Binary file not shown.
BIN
data/excel/medium-voltage-KM.xlsx
Normal file
BIN
data/excel/medium-voltage-KM.xlsx
Normal file
Binary file not shown.
BIN
data/excel/solar-cables.xlsx
Normal file
BIN
data/excel/solar-cables.xlsx
Normal file
Binary file not shown.
@@ -116,7 +116,7 @@
|
||||
"prepare": "husky",
|
||||
"preinstall": "npx only-allow pnpm"
|
||||
},
|
||||
"version": "2.3.35",
|
||||
"version": "2.3.36",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
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