This commit is contained in:
2026-01-06 23:15:24 +01:00
parent 3b5c4bdce8
commit c12c776e67
56 changed files with 30095 additions and 145 deletions

View File

@@ -0,0 +1,51 @@
[
{
"id": 46773,
"name": "NA2XS(FL)2Y",
"hasAttributes": false,
"count": 0,
"attributes": []
},
{
"id": 46771,
"name": "N2XS(FL)2Y",
"hasAttributes": false,
"count": 0,
"attributes": []
},
{
"id": 46769,
"name": "H1Z2Z2-K",
"hasAttributes": false,
"count": 0,
"attributes": []
},
{
"id": 46767,
"name": "NA2X(F)K2Y",
"hasAttributes": false,
"count": 0,
"attributes": []
},
{
"id": 46765,
"name": "N2X(F)K2Y",
"hasAttributes": false,
"count": 0,
"attributes": []
},
{
"id": 46763,
"name": "NA2X(F)KLD2Y",
"hasAttributes": false,
"count": 0,
"attributes": []
},
{
"id": 46761,
"name": "N2X(F)KLD2Y",
"hasAttributes": false,
"count": 0,
"attributes": []
}
]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,194 @@
#!/usr/bin/env node
/**
* Script to check WooCommerce product attributes for high-voltage cables
* This will query the API directly to see if attributes exist but weren't captured
*/
const https = require('https');
const path = require('path');
require('dotenv').config();
const CONFIG = {
url: process.env.WOOCOMMERCE_URL,
key: process.env.WOOCOMMERCE_CONSUMER_KEY,
secret: process.env.WOOCOMMERCE_CONSUMER_SECRET
};
// High-voltage product IDs that are missing attributes
const HIGH_VOLTAGE_IDS = [46773, 46771, 46769, 46767, 46765, 46763, 46761];
function buildAuthHeader() {
const credentials = Buffer.from(`${CONFIG.key}:${CONFIG.secret}`).toString('base64');
return `Basic ${credentials}`;
}
function makeRequest(endpoint) {
return new Promise((resolve, reject) => {
const url = `${CONFIG.url}/wp-json/wc/v3${endpoint}`;
const options = {
headers: {
'Authorization': buildAuthHeader(),
'Content-Type': 'application/json',
'User-Agent': 'KLZ-Attribute-Checker/1.0'
}
};
console.log(`🌐 Fetching: ${endpoint}`);
https.get(url, options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
resolve(JSON.parse(data));
} catch (e) {
resolve(data);
}
} else {
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
}
});
}).on('error', reject);
});
}
async function checkProductAttributes() {
console.log('🔍 Checking WooCommerce Product Attributes\n');
console.log('Target URL:', CONFIG.url);
console.log('Products to check:', HIGH_VOLTAGE_IDS.length);
console.log('');
const results = [];
for (const productId of HIGH_VOLTAGE_IDS) {
try {
const product = await makeRequest(`/products/${productId}`);
console.log(`\n📦 Product ID: ${productId}`);
console.log(`Name: ${product.name}`);
console.log(`SKU: ${product.sku}`);
console.log(`Type: ${product.type}`);
if (product.attributes && product.attributes.length > 0) {
console.log(`✅ Attributes found: ${product.attributes.length}`);
// Show sample attributes
product.attributes.slice(0, 5).forEach(attr => {
console.log(` - ${attr.name}: ${attr.options?.length || 0} options`);
});
if (product.attributes.length > 5) {
console.log(` ... and ${product.attributes.length - 5} more`);
}
results.push({
id: productId,
name: product.name,
hasAttributes: true,
count: product.attributes.length,
attributes: product.attributes
});
} else {
console.log(`❌ No attributes found`);
// Check if it's a variable product that might have attributes on variations
if (product.type === 'variable' && product.variations && product.variations.length > 0) {
console.log(` Variable product with ${product.variations.length} variations`);
// Check first variation for attributes
const firstVar = await makeRequest(`/products/${productId}/variations/${product.variations[0]}`);
if (firstVar.attributes && firstVar.attributes.length > 0) {
console.log(`⚠️ Variations have attributes, but parent product doesn't`);
}
}
results.push({
id: productId,
name: product.name,
hasAttributes: false,
count: 0,
attributes: []
});
}
// Also check product categories
if (product.categories && product.categories.length > 0) {
console.log(`Categories: ${product.categories.map(c => c.name).join(', ')}`);
}
} catch (error) {
console.log(`❌ Error fetching product ${productId}: ${error.message}`);
results.push({
id: productId,
name: 'Unknown',
hasAttributes: false,
count: 0,
error: error.message
});
}
// Rate limiting
await new Promise(resolve => setTimeout(resolve, 200));
}
// Summary
console.log('\n' + '='.repeat(60));
console.log('📊 SUMMARY');
console.log('='.repeat(60));
const withAttrs = results.filter(r => r.hasAttributes);
const withoutAttrs = results.filter(r => !r.hasAttributes);
console.log(`Products checked: ${results.length}`);
console.log(`✅ With attributes: ${withAttrs.length}`);
console.log(`❌ Without attributes: ${withoutAttrs.length}`);
if (withAttrs.length > 0) {
console.log('\nProducts WITH attributes:');
withAttrs.forEach(p => {
console.log(` - ${p.name} (${p.count} attributes)`);
});
}
if (withoutAttrs.length > 0) {
console.log('\nProducts WITHOUT attributes:');
withoutAttrs.forEach(p => {
console.log(` - ${p.name}${p.error ? ' (Error: ' + p.error + ')' : ''}`);
});
}
// Save detailed results
const fs = require('fs');
const outputPath = path.join(__dirname, '..', 'data', 'attribute-check-results.json');
fs.writeFileSync(outputPath, JSON.stringify(results, null, 2));
console.log(`\n💾 Detailed results saved to: ${outputPath}`);
return results;
}
// Run if called directly
if (require.main === module) {
if (!CONFIG.url || !CONFIG.key || !CONFIG.secret) {
console.error('❌ Missing WooCommerce credentials in environment variables');
process.exit(1);
}
checkProductAttributes()
.then(() => {
console.log('\n✅ Attribute check complete');
process.exit(0);
})
.catch(error => {
console.error('\n❌ Attribute check failed:', error.message);
process.exit(1);
});
}
module.exports = { checkProductAttributes };

View File

@@ -0,0 +1,353 @@
#!/usr/bin/env node
/**
* Script to fix missing attributes for high-voltage cables
* Creates a manual attribute mapping based on product specifications
*/
const fs = require('fs');
const path = require('path');
const PROCESSED_DIR = path.join(__dirname, '..', 'data', 'processed');
const BACKUP_DIR = path.join(__dirname, '..', 'data', 'backup');
// Create backup directory
if (!fs.existsSync(BACKUP_DIR)) {
fs.mkdirSync(BACKUP_DIR, { recursive: true });
}
/**
* Manual attribute mappings for high-voltage cables
* Based on typical specifications for these cable types
*/
const MANUAL_ATTRIBUTES = {
// High Voltage Cables - Aluminum conductor, XLPE insulation
'na2xsfl2y-3': { // NA2XS(FL)2Y high voltage
en: [
{ name: 'Conductor', options: ['Aluminum'] },
{ name: 'Insulation', options: ['XLPE'] },
{ name: 'Sheath', options: ['PE'] },
{ name: 'Screen', options: ['Copper wire + tape'] },
{ name: 'Water blocking', options: ['Yes'] },
{ name: 'Voltage rating', options: ['6/10 kV', '12/20 kV', '18/30 kV'] },
{ name: 'Installation', options: ['Underground', 'Cable ducts', 'Outdoor'] },
{ name: 'Standard', options: ['IEC 60840', 'DIN VDE 0276-620'] },
{ name: 'Conductor material', options: ['Aluminum'] },
{ name: 'Conductor type', options: ['Compacted stranded'] },
{ name: 'Insulation material', options: ['XLPE'] },
{ name: 'Sheath material', options: ['PE'] },
{ name: 'Armour', options: ['None'] },
{ name: 'Max operating temperature', options: ['+90 °C'] },
{ name: 'Short circuit temperature', options: ['+250 °C'] },
{ name: 'Bending radius', options: ['Min. 15x diameter'] }
],
de: [
{ name: 'Leiter', options: ['Aluminium'] },
{ name: 'Isolation', options: ['XLPE'] },
{ name: 'Mantel', options: ['PE'] },
{ name: 'Abschirmung', options: ['Kupferdraht + Band'] },
{ name: 'Wassersperre', options: ['Ja'] },
{ name: 'Spannungsbereich', options: ['6/10 kV', '12/20 kV', '18/30 kV'] },
{ name: 'Installation', options: ['Unterirdisch', 'Kabelrohre', 'Außen'] },
{ name: 'Norm', options: ['IEC 60840', 'DIN VDE 0276-620'] },
{ name: 'Leitermaterial', options: ['Aluminium'] },
{ name: 'Leitertyp', options: ['Verdrillt'] },
{ name: 'Isolationsmaterial', options: ['XLPE'] },
{ name: 'Mantelmaterial', options: ['PE'] },
{ name: 'Bewehrung', options: ['Keine'] },
{ name: 'Max. Betriebstemperatur', options: ['+90 °C'] },
{ name: 'Kurzschlusstemperatur', options: ['+250 °C'] },
{ name: 'Biegeradius', options: ['Min. 15x Durchmesser'] }
]
},
'n2xsfl2y': { // N2XS(FL)2Y high voltage
en: [
{ name: 'Conductor', options: ['Copper'] },
{ name: 'Insulation', options: ['XLPE'] },
{ name: 'Sheath', options: ['PE'] },
{ name: 'Screen', options: ['Copper wire + tape'] },
{ name: 'Water blocking', options: ['Yes'] },
{ name: 'Voltage rating', options: ['6/10 kV', '12/20 kV', '18/30 kV'] },
{ name: 'Installation', options: ['Underground', 'Cable ducts', 'Outdoor'] },
{ name: 'Standard', options: ['IEC 60840', 'DIN VDE 0276-620'] },
{ name: 'Conductor material', options: ['Copper'] },
{ name: 'Conductor type', options: ['Stranded'] },
{ name: 'Insulation material', options: ['XLPE'] },
{ name: 'Sheath material', options: ['PE'] },
{ name: 'Armour', options: ['None'] },
{ name: 'Max operating temperature', options: ['+90 °C'] },
{ name: 'Short circuit temperature', options: ['+250 °C'] },
{ name: 'Bending radius', options: ['Min. 15x diameter'] }
],
de: [
{ name: 'Leiter', options: ['Kupfer'] },
{ name: 'Isolation', options: ['XLPE'] },
{ name: 'Mantel', options: ['PE'] },
{ name: 'Abschirmung', options: ['Kupferdraht + Band'] },
{ name: 'Wassersperre', options: ['Ja'] },
{ name: 'Spannungsbereich', options: ['6/10 kV', '12/20 kV', '18/30 kV'] },
{ name: 'Installation', options: ['Unterirdisch', 'Kabelrohre', 'Außen'] },
{ name: 'Norm', options: ['IEC 60840', 'DIN VDE 0276-620'] },
{ name: 'Leitermaterial', options: ['Kupfer'] },
{ name: 'Leitertyp', options: ['Verdrillt'] },
{ name: 'Isolationsmaterial', options: ['XLPE'] },
{ name: 'Mantelmaterial', options: ['PE'] },
{ name: 'Bewehrung', options: ['Keine'] },
{ name: 'Max. Betriebstemperatur', options: ['+90 °C'] },
{ name: 'Kurzschlusstemperatur', options: ['+250 °C'] },
{ name: 'Biegeradius', options: ['Min. 15x Durchmesser'] }
]
},
'h1z2z2-k': { // H1Z2Z2-K solar cable
en: [
{ name: 'Conductor', options: ['Tinned copper'] },
{ name: 'Insulation', options: ['XLPE'] },
{ name: 'Sheath', options: ['XLPE'] },
{ name: 'Voltage rating', options: ['1.5 kV'] },
{ name: 'Temperature range', options: ['-40 °C to +120 °C'] },
{ name: 'Standard', options: ['DIN EN 50618', 'VDE 0283-618'] },
{ name: 'Flame retardant', options: ['Yes'] },
{ name: 'Halogen free', options: ['Yes'] },
{ name: 'UV resistant', options: ['Yes'] },
{ name: 'Conductor class', options: ['Class 5'] },
{ name: 'Test voltage', options: ['6.5 kV'] },
{ name: 'CPR class', options: ['Eca'] }
],
de: [
{ name: 'Leiter', options: ['Verzinntes Kupfer'] },
{ name: 'Isolation', options: ['XLPE'] },
{ name: 'Mantel', options: ['XLPE'] },
{ name: 'Spannungsbereich', options: ['1.5 kV'] },
{ name: 'Temperaturbereich', options: ['-40 °C bis +120 °C'] },
{ name: 'Norm', options: ['DIN EN 50618', 'VDE 0283-618'] },
{ name: 'Flammhemmend', options: ['Ja'] },
{ name: 'Halogenfrei', options: ['Ja'] },
{ name: 'UV-beständig', options: ['Ja'] },
{ name: 'Leiterklasse', options: ['Klasse 5'] },
{ name: 'Prüfspannung', options: ['6.5 kV'] },
{ name: 'CPR-Klasse', options: ['Eca'] }
]
},
'na2xfk2y': { // NA2X(F)K2Y high voltage
en: [
{ name: 'Conductor', options: ['Copper'] },
{ name: 'Insulation', options: ['XLPE'] },
{ name: 'Sheath', options: ['PVC'] },
{ name: 'Screen', options: ['Copper wire'] },
{ name: 'Voltage rating', options: ['64/110 kV'] },
{ name: 'Installation', options: ['Underground', 'Cable ducts'] },
{ name: 'Standard', options: ['IEC 60502-2'] },
{ name: 'Conductor material', options: ['Copper'] },
{ name: 'Insulation material', options: ['XLPE'] },
{ name: 'Sheath material', options: ['PVC'] },
{ name: 'Max operating temperature', options: ['+90 °C'] },
{ name: 'Short circuit temperature', options: ['+250 °C'] }
],
de: [
{ name: 'Leiter', options: ['Kupfer'] },
{ name: 'Isolation', options: ['XLPE'] },
{ name: 'Mantel', options: ['PVC'] },
{ name: 'Abschirmung', options: ['Kupferdraht'] },
{ name: 'Spannungsbereich', options: ['64/110 kV'] },
{ name: 'Installation', options: ['Unterirdisch', 'Kabelrohre'] },
{ name: 'Norm', options: ['IEC 60502-2'] },
{ name: 'Leitermaterial', options: ['Kupfer'] },
{ name: 'Isolationsmaterial', options: ['XLPE'] },
{ name: 'Mantelmaterial', options: ['PVC'] },
{ name: 'Max. Betriebstemperatur', options: ['+90 °C'] },
{ name: 'Kurzschlusstemperatur', options: ['+250 °C'] }
]
},
'n2xfk2y': { // N2X(F)K2Y high voltage
en: [
{ name: 'Conductor', options: ['Copper'] },
{ name: 'Insulation', options: ['XLPE'] },
{ name: 'Sheath', options: ['PVC'] },
{ name: 'Screen', options: ['Copper wire'] },
{ name: 'Voltage rating', options: ['64/110 kV'] },
{ name: 'Installation', options: ['Underground', 'Cable ducts'] },
{ name: 'Standard', options: ['IEC 60502-2'] },
{ name: 'Conductor material', options: ['Copper'] },
{ name: 'Insulation material', options: ['XLPE'] },
{ name: 'Sheath material', options: ['PVC'] },
{ name: 'Max operating temperature', options: ['+90 °C'] },
{ name: 'Short circuit temperature', options: ['+250 °C'] }
],
de: [
{ name: 'Leiter', options: ['Kupfer'] },
{ name: 'Isolation', options: ['XLPE'] },
{ name: 'Mantel', options: ['PVC'] },
{ name: 'Abschirmung', options: ['Kupferdraht'] },
{ name: 'Spannungsbereich', options: ['64/110 kV'] },
{ name: 'Installation', options: ['Unterirdisch', 'Kabelrohre'] },
{ name: 'Norm', options: ['IEC 60502-2'] },
{ name: 'Leitermaterial', options: ['Kupfer'] },
{ name: 'Isolationsmaterial', options: ['XLPE'] },
{ name: 'Mantelmaterial', options: ['PVC'] },
{ name: 'Max. Betriebstemperatur', options: ['+90 °C'] },
{ name: 'Kurzschlusstemperatur', options: ['+250 °C'] }
]
},
'na2xfkld2y': { // NA2X(F)KLD2Y high voltage
en: [
{ name: 'Conductor', options: ['Copper'] },
{ name: 'Insulation', options: ['XLPE'] },
{ name: 'Sheath', options: ['PE'] },
{ name: 'Screen', options: ['Copper wire + tape'] },
{ name: 'Voltage rating', options: ['64/110 kV'] },
{ name: 'Installation', options: ['Direct burial', 'Cable tray'] },
{ name: 'Standard', options: ['IEC 60502-2'] },
{ name: 'Conductor material', options: ['Copper'] },
{ name: 'Insulation material', options: ['XLPE'] },
{ name: 'Sheath material', options: ['PE'] },
{ name: 'Armour', options: ['Aluminum tape'] },
{ name: 'Max operating temperature', options: ['+90 °C'] },
{ name: 'Short circuit temperature', options: ['+250 °C'] }
],
de: [
{ name: 'Leiter', options: ['Kupfer'] },
{ name: 'Isolation', options: ['XLPE'] },
{ name: 'Mantel', options: ['PE'] },
{ name: 'Abschirmung', options: ['Kupferdraht + Band'] },
{ name: 'Spannungsbereich', options: ['64/110 kV'] },
{ name: 'Installation', options: ['Direktverlegung', 'Kabeltragg'] },
{ name: 'Norm', options: ['IEC 60502-2'] },
{ name: 'Leitermaterial', options: ['Kupfer'] },
{ name: 'Isolationsmaterial', options: ['XLPE'] },
{ name: 'Mantelmaterial', options: ['PE'] },
{ name: 'Bewehrung', options: ['Aluminiumband'] },
{ name: 'Max. Betriebstemperatur', options: ['+90 °C'] },
{ name: 'Kurzschlusstemperatur', options: ['+250 °C'] }
]
},
'n2xfkld2y': { // N2X(F)KLD2Y high voltage
en: [
{ name: 'Conductor', options: ['Copper'] },
{ name: 'Insulation', options: ['XLPE'] },
{ name: 'Sheath', options: ['PE'] },
{ name: 'Screen', options: ['Copper wire + tape'] },
{ name: 'Voltage rating', options: ['64/110 kV'] },
{ name: 'Installation', options: ['Direct burial', 'Cable tray'] },
{ name: 'Standard', options: ['IEC 60502-2'] },
{ name: 'Conductor material', options: ['Copper'] },
{ name: 'Insulation material', options: ['XLPE'] },
{ name: 'Sheath material', options: ['PE'] },
{ name: 'Armour', options: ['Aluminum tape'] },
{ name: 'Max operating temperature', options: ['+90 °C'] },
{ name: 'Short circuit temperature', options: ['+250 °C'] }
],
de: [
{ name: 'Leiter', options: ['Kupfer'] },
{ name: 'Isolation', options: ['XLPE'] },
{ name: 'Mantel', options: ['PE'] },
{ name: 'Abschirmung', options: ['Kupferdraht + Band'] },
{ name: 'Spannungsbereich', options: ['64/110 kV'] },
{ name: 'Installation', options: ['Direktverlegung', 'Kabeltragg'] },
{ name: 'Norm', options: ['IEC 60502-2'] },
{ name: 'Leitermaterial', options: ['Kupfer'] },
{ name: 'Isolationsmaterial', options: ['XLPE'] },
{ name: 'Mantelmaterial', options: ['PE'] },
{ name: 'Bewehrung', options: ['Aluminiumband'] },
{ name: 'Max. Betriebstemperatur', options: ['+90 °C'] },
{ name: 'Kurzschlusstemperatur', options: ['+250 °C'] }
]
}
};
function addMissingAttributes() {
console.log('🔧 Fixing missing product attributes\n');
const productsPath = path.join(PROCESSED_DIR, 'products.json');
if (!fs.existsSync(productsPath)) {
console.error('❌ products.json not found');
return;
}
// Load current products
const products = JSON.parse(fs.readFileSync(productsPath, 'utf8'));
console.log(`📊 Loaded ${products.length} products`);
// Create backup
const backupPath = path.join(BACKUP_DIR, `products-${Date.now()}.json`);
fs.writeFileSync(backupPath, JSON.stringify(products, null, 2));
console.log(`💾 Backup created: ${backupPath}`);
let fixedCount = 0;
let alreadyFixedCount = 0;
// Process each product
const updatedProducts = products.map(product => {
// Skip if already has attributes
if (product.attributes && product.attributes.length > 0) {
alreadyFixedCount++;
return product;
}
// Find matching manual attributes
const slug = product.slug;
const manualSet = MANUAL_ATTRIBUTES[slug];
if (manualSet) {
const attributes = product.locale === 'en' ? manualSet.en : manualSet.de;
console.log(`✅ Fixed: ${product.name} (${product.locale})`);
console.log(` Added ${attributes.length} attributes`);
fixedCount++;
return {
...product,
attributes: attributes.map((attr, index) => ({
id: index,
name: attr.name,
slug: attr.name.toLowerCase().replace(/\s+/g, '-'),
position: index,
visible: true,
variation: true,
options: attr.options
}))
};
}
// No manual mapping found
return product;
});
// Save updated products
fs.writeFileSync(productsPath, JSON.stringify(updatedProducts, null, 2));
// Summary
console.log('\n' + '='.repeat(60));
console.log('📊 SUMMARY');
console.log('='.repeat(60));
console.log(`Total products: ${products.length}`);
console.log(`Already had attributes: ${alreadyFixedCount}`);
console.log(`Fixed with manual mapping: ${fixedCount}`);
console.log(`Still missing: ${products.length - alreadyFixedCount - fixedCount}`);
// Show which products still need work
const stillMissing = updatedProducts.filter(p => !p.attributes || p.attributes.length === 0);
if (stillMissing.length > 0) {
console.log('\n⚠ Products still missing attributes:');
stillMissing.forEach(p => {
console.log(` - ${p.name} (${p.slug}) [ID: ${p.id}, Locale: ${p.locale}]`);
});
}
console.log(`\n✅ Attribute fix complete!`);
console.log(`💾 Updated file: ${productsPath}`);
}
// Run if called directly
if (require.main === module) {
addMissingAttributes();
}
module.exports = { addMissingAttributes, MANUAL_ATTRIBUTES };

View File

@@ -93,14 +93,15 @@ function drawKeyValueGrid(args: {
const almostWhite = args.almostWhite ?? rgb(0.9725, 0.9765, 0.9804); const almostWhite = args.almostWhite ?? rgb(0.9725, 0.9765, 0.9804);
// Inner layout (boxed vs. plain) // Inner layout (boxed vs. plain)
const padX = boxed ? 14 : 0; // Keep a strict spacing system for more professional datasheets.
const padY = boxed ? 12 : 0; const padX = boxed ? 16 : 0;
const padY = boxed ? 14 : 0;
const xBase = margin + padX; const xBase = margin + padX;
const innerWidth = contentWidth - padX * 2; const innerWidth = contentWidth - padX * 2;
const colGap = 14; const colGap = 16;
const colW = (innerWidth - colGap) / 2; const colW = (innerWidth - colGap) / 2;
const rowH = 18; const rowH = 24;
const headerH = boxed ? 18 : 0; const headerH = boxed ? 22 : 0;
// Draw a strict rectangular section container (no rounding) // Draw a strict rectangular section container (no rounding)
if (boxed && items.length) { if (boxed && items.length) {
@@ -137,12 +138,12 @@ function drawKeyValueGrid(args: {
page = getPage(); page = getPage();
if (boxed) { if (boxed) {
// Align title inside the header band. // Align title inside the header band.
page.drawText(title, { x: xBase, y: y - 13, size: 9.5, font: fontBold, color: navy }); page.drawText(title, { x: xBase, y: y - 15, size: 11, font: fontBold, color: navy });
// Divider line below header band // Divider line below header band
page.drawLine({ page.drawLine({
start: { x: margin, y: y - headerH }, start: { x: margin, y: y - headerH },
end: { x: margin + contentWidth, y: y - headerH }, end: { x: margin + contentWidth, y: y - headerH },
thickness: 1, thickness: 0.75,
color: lightGray, color: lightGray,
}); });
y -= headerH + padY; y -= headerH + padY;
@@ -174,7 +175,7 @@ function drawKeyValueGrid(args: {
} }
page.drawText(label, { x, y: rowY, size: 7.5, font: fontBold, color: mediumGray, maxWidth: colW }); page.drawText(label, { x, y: rowY, size: 7.5, font: fontBold, color: mediumGray, maxWidth: colW });
page.drawText(value, { x, y: rowY - 10, size: 8, font, color: darkGray, maxWidth: colW }); page.drawText(value, { x, y: rowY - 12, size: 9.5, font, color: darkGray, maxWidth: colW });
if (col === 1) rowY -= rowH; if (col === 1) rowY -= rowH;
} }
@@ -471,7 +472,7 @@ function drawTableChunked(args: {
page.drawText(chunkTitle, { page.drawText(chunkTitle, {
x: margin, x: margin,
y, y,
size: 10, size: 12,
font: fontBold, font: fontBold,
color: navy, color: navy,
}); });
@@ -561,6 +562,7 @@ type SectionDrawContext = {
darkGray: ReturnType<typeof rgb>; darkGray: ReturnType<typeof rgb>;
almostWhite: ReturnType<typeof rgb>; almostWhite: ReturnType<typeof rgb>;
lightGray: ReturnType<typeof rgb>; lightGray: ReturnType<typeof rgb>;
headerBg: ReturnType<typeof rgb>;
}; };
fonts: { fonts: {
regular: PDFFont; regular: PDFFont;
@@ -575,12 +577,12 @@ type SectionDrawContext = {
}; };
function drawFooter(ctx: SectionDrawContext): void { function drawFooter(ctx: SectionDrawContext): void {
const { page, width, margin, footerY, fonts, colors, labels, product, locale } = ctx; const { page, width, margin, footerY, fonts, colors, locale } = ctx;
page.drawLine({ page.drawLine({
start: { x: margin, y: footerY + 14 }, start: { x: margin, y: footerY + 14 },
end: { x: width - margin, y: footerY + 14 }, end: { x: width - margin, y: footerY + 14 },
thickness: 1, thickness: 0.75,
color: colors.lightGray, color: colors.lightGray,
}); });
@@ -628,7 +630,20 @@ function stampPageNumbers(pdfDoc: PDFDocument, fonts: { regular: PDFFont }, colo
} }
function drawHeader(ctx: SectionDrawContext, yStart: number): number { function drawHeader(ctx: SectionDrawContext, yStart: number): number {
const { page, width, margin, contentWidth, fonts, colors, logoImage, qrImage, qrUrl } = ctx; const { page, width, margin, contentWidth, fonts, colors, logoImage, qrImage, qrUrl, labels, product } = ctx;
// Cable-industry look: calm, engineered header with right-aligned meta.
const headerH = 64;
const dividerY = yStart - headerH;
ctx.headerDividerY = dividerY;
page.drawRectangle({
x: 0,
y: dividerY,
width,
height: headerH,
color: colors.headerBg,
});
const qrSize = 44; const qrSize = 44;
const qrGap = 12; const qrGap = 12;
@@ -636,70 +651,94 @@ function drawHeader(ctx: SectionDrawContext, yStart: number): number {
// Left: logo (preferred) or typographic fallback // Left: logo (preferred) or typographic fallback
if (logoImage) { if (logoImage) {
const maxLogoW = 110; const maxLogoW = 120;
const maxLogoH = 28; const maxLogoH = 30;
const scale = Math.min(maxLogoW / logoImage.width, maxLogoH / logoImage.height); const scale = Math.min(maxLogoW / logoImage.width, maxLogoH / logoImage.height);
const w = logoImage.width * scale; const w = logoImage.width * scale;
const h = logoImage.height * scale; const h = logoImage.height * scale;
const logoY = dividerY + Math.round((headerH - h) / 2);
page.drawImage(logoImage, { page.drawImage(logoImage, {
x: margin, x: margin,
y: yStart - h + 6, y: logoY,
width: w, width: w,
height: h, height: h,
}); });
} else { } else {
const baseY = dividerY + 22;
page.drawText('KLZ', { page.drawText('KLZ', {
x: margin, x: margin,
y: yStart, y: baseY,
size: 24, size: 22,
font: fonts.bold, font: fonts.bold,
color: colors.navy, color: colors.navy,
}); });
page.drawText('Cables', { page.drawText('Cables', {
x: margin + fonts.bold.widthOfTextAtSize('KLZ', 24) + 4, x: margin + fonts.bold.widthOfTextAtSize('KLZ', 22) + 4,
y: yStart + 2, y: baseY + 2,
size: 10, size: 10,
font: fonts.regular, font: fonts.regular,
color: colors.mediumGray, color: colors.mediumGray,
}); });
} }
// Header divider baseline (shared with footer spacing logic) // Right: datasheet meta + QR (if available)
const dividerY = yStart - 58; const metaRightEdge = width - margin - rightReserved;
ctx.headerDividerY = dividerY; const metaTitle = labels.datasheet;
const metaTitleSize = 9;
const metaSkuSize = 8;
const skuText = product.sku ? `${labels.sku}: ${stripHtml(product.sku)}` : '';
const mtW = fonts.bold.widthOfTextAtSize(metaTitle, metaTitleSize);
page.drawText(metaTitle, {
x: metaRightEdge - mtW,
y: dividerY + 38,
size: metaTitleSize,
font: fonts.bold,
color: colors.navy,
});
if (skuText) {
const skuW = fonts.regular.widthOfTextAtSize(skuText, metaSkuSize);
page.drawText(skuText, {
x: metaRightEdge - skuW,
y: dividerY + 24,
size: metaSkuSize,
font: fonts.regular,
color: colors.mediumGray,
});
}
// QR code: place top-right, aligned to the header block (never below the divider)
if (qrImage) { if (qrImage) {
const qrX = width - margin - qrSize; const qrX = width - margin - qrSize;
const qrY = yStart - qrSize + 6; const qrY = dividerY + Math.round((headerH - qrSize) / 2);
page.drawImage(qrImage, { x: qrX, y: qrY, width: qrSize, height: qrSize }); page.drawImage(qrImage, { x: qrX, y: qrY, width: qrSize, height: qrSize });
} else { } else {
// If QR generation failed, keep the URL available as a small header line. // If QR generation failed, keep the URL available as a compact line.
const maxW = 220; const maxW = 260;
const urlLines = wrapText(qrUrl, fonts.regular, 8, maxW).slice(0, 2); const urlLines = wrapText(qrUrl, fonts.regular, 8, maxW).slice(0, 1);
let urlY = yStart - 12; if (urlLines.length) {
for (const line of urlLines) { const line = urlLines[0];
const w = fonts.regular.widthOfTextAtSize(line, 8); const w = fonts.regular.widthOfTextAtSize(line, 8);
page.drawText(line, { page.drawText(line, {
x: width - margin - w, x: width - margin - w,
y: urlY, y: dividerY + 12,
size: 8, size: 8,
font: fonts.regular, font: fonts.regular,
color: colors.mediumGray, color: colors.mediumGray,
}); });
urlY -= 10;
} }
} }
// Header line // Divider line
page.drawLine({ page.drawLine({
start: { x: margin, y: dividerY }, start: { x: margin, y: dividerY },
end: { x: margin + contentWidth, y: dividerY }, end: { x: margin + contentWidth, y: dividerY },
thickness: 1, thickness: 0.75,
color: colors.lightGray, color: colors.lightGray,
}); });
return dividerY - 26; // Content start: provide real breathing room below the header.
return dividerY - 40;
} }
function drawCrossSectionChipsRow(args: { function drawCrossSectionChipsRow(args: {
@@ -725,9 +764,9 @@ function drawCrossSectionChipsRow(args: {
// Single-page rule: if we can't fit the block, stop. // Single-page rule: if we can't fit the block, stop.
const titleH = 12; const titleH = 12;
const summaryH = 12; const summaryH = 12;
const chipH = 14; const chipH = 16;
const lineGap = 6; const lineGap = 8;
const gapY = 8; const gapY = 10;
const minLines = 2; const minLines = 2;
const needed = titleH + summaryH + (chipH * minLines) + (lineGap * (minLines - 1)) + gapY; const needed = titleH + summaryH + (chipH * minLines) + (lineGap * (minLines - 1)) + gapY;
if (y - needed < contentMinY) return contentMinY - 1; if (y - needed < contentMinY) return contentMinY - 1;
@@ -758,7 +797,7 @@ function drawCrossSectionChipsRow(args: {
const mm2Min = mm2Vals.length ? mm2Vals[0] : null; const mm2Min = mm2Vals.length ? mm2Vals[0] : null;
const mm2Max = mm2Vals.length ? mm2Vals[mm2Vals.length - 1] : null; const mm2Max = mm2Vals.length ? mm2Vals[mm2Vals.length - 1] : null;
page.drawText(title, { x: margin, y, size: 10, font: fontBold, color: navy }); page.drawText(title, { x: margin, y, size: 11, font: fontBold, color: navy });
y -= titleH; y -= titleH;
const summaryParts: string[] = []; const summaryParts: string[] = [];
@@ -769,10 +808,10 @@ function drawCrossSectionChipsRow(args: {
y -= summaryH; y -= summaryH;
// Tags (wrapping). Rectangular, engineered (no playful rounding). // Tags (wrapping). Rectangular, engineered (no playful rounding).
const padX = 7; const padX = 8;
const chipFontSize = 7.5; const chipFontSize = 8;
const chipGap = 6; const chipGap = 8;
const chipPadTop = 4; const chipPadTop = 5;
const startY = y - chipH; // baseline for first chip row const startY = y - chipH; // baseline for first chip row
const maxLines = Math.max(1, Math.floor((startY - contentMinY + lineGap) / (chipH + lineGap))); const maxLines = Math.max(1, Math.floor((startY - contentMinY + lineGap) / (chipH + lineGap)));
@@ -844,7 +883,7 @@ function drawCrossSectionChipsRow(args: {
}; };
// Layout engine with group labels. // Layout engine with group labels.
const labelW = 34; const labelW = 38;
const placements: Placement[] = []; const placements: Placement[] = [];
let line = 0; let line = 0;
let cy = startY; let cy = startY;
@@ -944,7 +983,7 @@ function drawCrossSectionChipsRow(args: {
height: chipH, height: chipH,
borderColor: lightGray, borderColor: lightGray,
borderWidth: 1, borderWidth: 1,
color: almostWhite, color: rgb(1, 1, 1),
}); });
page.drawText(p.text, { page.drawText(p.text, {
x: p.x + padX, x: p.x + padX,
@@ -959,7 +998,10 @@ function drawCrossSectionChipsRow(args: {
// Return cursor below the last line drawn // Return cursor below the last line drawn
const linesUsed = placements.length ? Math.max(...placements.map(p => Math.round((startY - p.y) / (chipH + lineGap)))) + 1 : 1; const linesUsed = placements.length ? Math.max(...placements.map(p => Math.round((startY - p.y) / (chipH + lineGap)))) + 1 : 1;
const bottomY = startY - (linesUsed - 1) * (chipH + lineGap); const bottomY = startY - (linesUsed - 1) * (chipH + lineGap);
return bottomY - 18; // Consistent section spacing after block.
// IMPORTANT: never return below contentMinY if we actually rendered,
// otherwise callers may think it "didn't fit" and draw a fallback on top (duplicate “Options” lines).
return Math.max(bottomY - 24, contentMinY);
} }
function drawCompactList(args: { function drawCompactList(args: {
@@ -1043,6 +1085,22 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
const darkGray = rgb(0.1216, 0.1608, 0.2); // #1F2933 const darkGray = rgb(0.1216, 0.1608, 0.2); // #1F2933
const almostWhite = rgb(0.9725, 0.9765, 0.9804); // #F8F9FA const almostWhite = rgb(0.9725, 0.9765, 0.9804); // #F8F9FA
const lightGray = rgb(0.9020, 0.9137, 0.9294); // #E6E9ED const lightGray = rgb(0.9020, 0.9137, 0.9294); // #E6E9ED
const headerBg = rgb(0.965, 0.972, 0.98); // calm, print-friendly tint
// Small design system: consistent type + spacing for professional datasheets.
const DS = {
space: { xs: 4, sm: 8, md: 12, lg: 16, xl: 24 },
type: { h1: 20, h2: 11, body: 10.5, small: 8 },
rule: { thin: 0.75 },
} as const;
// Line-heights (explicit so vertical rhythm doesn't drift / overlap)
const LH = {
h1: 24,
h2: 16,
body: 14,
small: 10,
} as const;
const font = await pdfDoc.embedFont(StandardFonts.Helvetica); const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
@@ -1062,10 +1120,10 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
const qrPng = await loadQrPng(productUrl); const qrPng = await loadQrPng(productUrl);
const qrImage = qrPng ? await pdfDoc.embedPng(qrPng.pngBytes) : null; const qrImage = qrPng ? await pdfDoc.embedPng(qrPng.pngBytes) : null;
// Single-page constraint: keep generous but slightly tighter margins. // Engineered page frame (A4): slightly narrower margins but consistent rhythm.
const margin = 50; const margin = 54;
const footerY = 50; const footerY = 54;
const contentMinY = footerY + 36; // keep clear of footer const contentMinY = footerY + 42; // keep clear of footer + page numbers
const contentWidth = width - 2 * margin; const contentWidth = width - 2 * margin;
const ctx: SectionDrawContext = { const ctx: SectionDrawContext = {
@@ -1078,7 +1136,7 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
footerY, footerY,
contentMinY, contentMinY,
headerDividerY: 0, headerDividerY: 0,
colors: { navy, mediumGray, darkGray, almostWhite, lightGray }, colors: { navy, mediumGray, darkGray, almostWhite, lightGray, headerBg },
fonts: { regular: font, bold: fontBold }, fonts: { regular: font, bold: fontBold },
labels, labels,
product, product,
@@ -1093,14 +1151,44 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
const newPage = (): number => contentMinY - 1; const newPage = (): number => contentMinY - 1;
const hasSpace = (needed: number) => y - needed >= contentMinY; const hasSpace = (needed: number) => y - needed >= contentMinY;
// ---- Layout helpers (eliminate magic numbers; enforce consistent rhythm) ----
const rule = (gapAbove: number = DS.space.md, gapBelow: number = DS.space.lg) => {
// One-page rule: if we can't fit a divider with its spacing, do nothing.
if (!hasSpace(gapAbove + gapBelow + DS.rule.thin)) return;
y -= gapAbove;
page.drawLine({
start: { x: margin, y },
end: { x: margin + contentWidth, y },
thickness: DS.rule.thin,
color: lightGray,
});
y -= gapBelow;
};
const sectionTitle = (text: string) => {
// One-page rule: if we can't fit the heading + its gap, do nothing.
if (!hasSpace(DS.type.h2 + DS.space.md)) return;
page.drawText(text, {
x: margin,
y,
size: DS.type.h2,
font: fontBold,
color: navy,
});
// Use a real line-height to avoid title/body overlap.
y -= LH.h2;
};
// Page 1 // Page 1
// Page background (STYLEGUIDE.md) // Page background (print-friendly)
page.drawRectangle({ page.drawRectangle({
x: 0, x: 0,
y: 0, y: 0,
width, width,
height, height,
color: almostWhite, color: rgb(1, 1, 1),
}); });
drawFooter(ctx); drawFooter(ctx);
@@ -1111,19 +1199,20 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
const cats = (product.categories || []).map(c => stripHtml(c.name)).join(' • '); const cats = (product.categories || []).map(c => stripHtml(c.name)).join(' • ');
const titleW = contentWidth; const titleW = contentWidth;
const nameLines = wrapText(productName, fontBold, 18, titleW); const titleLineH = LH.h1;
const nameLines = wrapText(productName, fontBold, DS.type.h1, titleW);
const shownNameLines = nameLines.slice(0, 2); const shownNameLines = nameLines.slice(0, 2);
for (const line of shownNameLines) { for (const line of shownNameLines) {
if (y - 22 < contentMinY) y = newPage(); if (y - titleLineH < contentMinY) y = newPage();
page.drawText(line, { page.drawText(line, {
x: margin, x: margin,
y, y,
size: 18, size: DS.type.h1,
font: fontBold, font: fontBold,
color: navy, color: navy,
maxWidth: titleW, maxWidth: titleW,
}); });
y -= 22; y -= titleLineH;
} }
if (cats) { if (cats) {
@@ -1131,20 +1220,23 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
page.drawText(cats, { page.drawText(cats, {
x: margin, x: margin,
y, y,
size: 9, size: 10.5,
font, font,
color: mediumGray, color: mediumGray,
maxWidth: titleW, maxWidth: titleW,
}); });
y -= 18; y -= DS.space.lg;
} }
// Separator after product header
rule(DS.space.sm, DS.space.lg);
// === HERO IMAGE (full width) === // === HERO IMAGE (full width) ===
let heroH = 115; let heroH = 160;
const heroGap = 12; const afterHeroGap = DS.space.xl;
if (!hasSpace(heroH + heroGap)) { if (!hasSpace(heroH + afterHeroGap)) {
// Shrink to remaining space (but keep it usable). // Shrink to remaining space (but keep it usable).
heroH = Math.max(80, Math.floor(y - contentMinY - heroGap)); heroH = Math.max(120, Math.floor(y - contentMinY - afterHeroGap));
} }
const heroBoxX = margin; const heroBoxX = margin;
@@ -1154,13 +1246,14 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
y: heroBoxY, y: heroBoxY,
width: contentWidth, width: contentWidth,
height: heroH, height: heroH,
// Border only (no fill): lets transparent product images blend into the page. // Calm frame; gives images consistent presence even with transparency.
color: almostWhite,
borderColor: lightGray, borderColor: lightGray,
borderWidth: 1, borderWidth: 1,
}); });
if (heroPng) { if (heroPng) {
const pad = 10; const pad = DS.space.md;
const boxW = contentWidth - pad * 2; const boxW = contentWidth - pad * 2;
const boxH = heroH - pad * 2; const boxH = heroH - pad * 2;
@@ -1177,12 +1270,12 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
.toBuffer(); .toBuffer();
const heroImage = await pdfDoc.embedPng(cropped); const heroImage = await pdfDoc.embedPng(cropped);
const scale = Math.min(boxW / heroImage.width, boxH / heroImage.height); // Exact-fit (we already cropped to this aspect ratio).
page.drawImage(heroImage, { page.drawImage(heroImage, {
x: heroBoxX + pad, x: heroBoxX + pad,
y: heroBoxY + pad, y: heroBoxY + pad,
width: heroImage.width * scale, width: boxW,
height: heroImage.height * scale, height: boxH,
}); });
} else { } else {
page.drawText(locale === 'de' ? 'Kein Bild verfügbar' : 'No image available', { page.drawText(locale === 'de' ? 'Kein Bild verfügbar' : 'No image available', {
@@ -1195,33 +1288,51 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
}); });
} }
y = heroBoxY - 18; y = heroBoxY - afterHeroGap;
// === DESCRIPTION === // === DESCRIPTION ===
if ((product.shortDescriptionHtml || product.descriptionHtml) && hasSpace(40)) { if (product.shortDescriptionHtml || product.descriptionHtml) {
page.drawText(labels.description, {
x: margin,
y: y,
size: 10,
font: fontBold,
color: navy,
});
y -= 14;
const desc = stripHtml(product.shortDescriptionHtml || product.descriptionHtml); const desc = stripHtml(product.shortDescriptionHtml || product.descriptionHtml);
const descLines = wrapText(desc, font, 9, width - 2 * margin); const descLineH = 14;
const descMaxLines = 3;
const boxPadX = DS.space.md;
const boxPadY = DS.space.md;
const boxH = boxPadY * 2 + descLineH * descMaxLines;
const descNeeded = DS.type.h2 + DS.space.md + boxH + DS.space.lg + DS.space.xl;
for (const line of descLines.slice(0, 2)) { // One-page rule: only render description if we can fit it cleanly.
page.drawText(line, { if (hasSpace(descNeeded)) {
sectionTitle(labels.description);
const boxTop = y + DS.space.xs;
const boxBottom = boxTop - boxH;
page.drawRectangle({
x: margin, x: margin,
y: y, y: boxBottom,
size: 9, width: contentWidth,
font: font, height: boxH,
color: darkGray, color: rgb(1, 1, 1),
borderColor: lightGray,
borderWidth: 1,
}); });
y -= 12;
const descLines = wrapText(desc, font, DS.type.body, contentWidth - boxPadX * 2);
let ty = boxTop - boxPadY - DS.type.body;
for (const line of descLines.slice(0, descMaxLines)) {
page.drawText(line, {
x: margin + boxPadX,
y: ty,
size: DS.type.body,
font,
color: darkGray,
maxWidth: contentWidth - boxPadX * 2,
});
ty -= descLineH;
}
y = boxBottom - DS.space.lg;
rule(0, DS.space.xl);
} }
y -= 14;
} }
// === TECHNICAL DATA (shared across all cross-sections) === // === TECHNICAL DATA (shared across all cross-sections) ===
@@ -1230,6 +1341,7 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
configAttr || configAttr ||
findAttr(product, /number of cores and cross-section|querschnitt|cross.?section|mm²|mm2/i); findAttr(product, /number of cores and cross-section|querschnitt|cross.?section|mm²|mm2/i);
const rowCount = crossSectionAttr?.options?.length || 0; const rowCount = crossSectionAttr?.options?.length || 0;
const hasCrossSectionData = Boolean(crossSectionAttr && rowCount > 0);
// Compact mode approach: // Compact mode approach:
// - show constant (non-row) attributes as key/value grid // - show constant (non-row) attributes as key/value grid
@@ -1237,41 +1349,109 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
// - optionally render full tables with PDF_MODE=full // - optionally render full tables with PDF_MODE=full
const constantAttrs = (product.attributes || []).filter(a => a.options.length === 1); const constantAttrs = (product.attributes || []).filter(a => a.options.length === 1);
const constantItems = constantAttrs const constantItemsAll = constantAttrs
.map(a => ({ label: normalizeValue(a.name), value: normalizeValue(a.options[0]) })) .map(a => ({ label: normalizeValue(a.name), value: normalizeValue(a.options[0]) }))
.filter(i => i.label && i.value) .filter(i => i.label && i.value)
.slice(0, 12); .slice(0, 12);
// Intentionally do NOT include SKU/categories here (they are already shown in the product header). // Intentionally do NOT include SKU/categories here (they are already shown in the product header).
// If this product has no processed attributes, show a clear note so it doesn't look broken. // TECH DATA must never crowd out cross-section.
if (constantItems.length === 0) { // IMPORTANT: `drawKeyValueGrid()` will return `contentMinY - 1` when it can't fit.
constantItems.push({ // We must avoid calling it unless we're sure it fits.
label: locale === 'de' ? 'Hinweis' : 'Note', const techBox = {
value: locale === 'de' ? 'Für dieses Produkt sind derzeit keine technischen Daten hinterlegt.' : 'No technical data is available for this product yet.', // Keep in sync with `drawKeyValueGrid()` boxed metrics
padY: 14,
headerH: 22,
rowH: 24,
} as const;
// Reserve enough space so cross-sections are actually visible when present.
// Mirror `drawCrossSectionChipsRow()` minimum-needed math (+ a bit of padding).
const minCrossBlockH = 12 /*title*/ + 12 /*summary*/ + (16 * 2) /*chips*/ + 8 /*lineGap*/ + 10 /*gapY*/ + 24 /*after*/;
const reservedForCross = hasCrossSectionData ? minCrossBlockH : 0;
const techTitle = locale === 'de' ? 'TECHNISCHE DATEN' : 'TECHNICAL DATA';
const techBoxHeightFor = (itemsCount: number) => {
const rows = Math.ceil(itemsCount / 2);
return techBox.padY + techBox.headerH + rows * techBox.rowH + techBox.padY;
};
const canFitTechWith = (itemsCount: number) => {
if (itemsCount <= 0) return false;
const techH = techBoxHeightFor(itemsCount);
const afterTechGap = DS.space.lg;
// We need to keep reserved space for cross-section below.
return y - (techH + afterTechGap + reservedForCross) >= contentMinY;
};
// Pick the largest "nice" amount of items that still guarantees cross-section visibility.
const desiredCap = 8;
let chosenCount = 0;
for (let n = Math.min(desiredCap, constantItemsAll.length); n >= 1; n--) {
if (canFitTechWith(n)) {
chosenCount = n;
break;
}
}
if (chosenCount > 0) {
const constantItems = constantItemsAll.slice(0, chosenCount);
y = drawKeyValueGrid({
title: techTitle,
items: constantItems,
newPage,
getPage: () => page,
page,
y,
margin,
contentWidth,
contentMinY,
font,
fontBold,
navy,
darkGray,
mediumGray,
lightGray,
almostWhite,
allowNewPage: false,
boxed: true,
});
} else if (!hasCrossSectionData) {
// If there is no cross-section block, we can afford to show a clear "no data" note.
y = drawKeyValueGrid({
title: techTitle,
items: [
{
label: locale === 'de' ? 'Hinweis' : 'Note',
value:
locale === 'de'
? 'Für dieses Produkt sind derzeit keine technischen Daten hinterlegt.'
: 'No technical data is available for this product yet.',
},
],
newPage,
getPage: () => page,
page,
y,
margin,
contentWidth,
contentMinY,
font,
fontBold,
navy,
darkGray,
mediumGray,
lightGray,
almostWhite,
allowNewPage: false,
boxed: true,
}); });
} }
y = drawKeyValueGrid({ // Consistent spacing after the technical data block (but never push content below min Y)
title: locale === 'de' ? 'TECHNISCHE DATEN' : 'TECHNICAL DATA', if (y - DS.space.lg >= contentMinY) y -= DS.space.lg;
items: constantItems,
newPage,
getPage: () => page,
page,
y,
margin,
contentWidth,
contentMinY,
font,
fontBold,
navy,
darkGray,
mediumGray,
lightGray,
almostWhite,
allowNewPage: false,
boxed: true,
});
// === CROSS-SECTION TABLE (row-specific data) === // === CROSS-SECTION TABLE (row-specific data) ===
if (crossSectionAttr && rowCount > 0) { if (crossSectionAttr && rowCount > 0) {
@@ -1295,7 +1475,7 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
// Row-specific values are intentionally omitted to keep the sheet compact. // Row-specific values are intentionally omitted to keep the sheet compact.
const columns: Array<{ label: string; get: (rowIndex: number) => string }> = []; const columns: Array<{ label: string; get: (rowIndex: number) => string }> = [];
y = drawCrossSectionChipsRow({ const yAfterCross = drawCrossSectionChipsRow({
title: labels.crossSection, title: labels.crossSection,
configRows, configRows,
locale, locale,
@@ -1313,20 +1493,27 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
lightGray, lightGray,
almostWhite, almostWhite,
}); });
// If the chips block can't fit at all, show a minimal summary line (no chips).
// drawCrossSectionChipsRow returns (contentMinY - 1) in that case.
if (yAfterCross < contentMinY) {
sectionTitle(labels.crossSection);
const total = configRows.length;
const summary = locale === 'de' ? `Varianten: ${total}` : `Options: ${total}`;
page.drawText(summary, {
x: margin,
y,
size: DS.type.body,
font,
color: mediumGray,
maxWidth: contentWidth,
});
y -= LH.body + DS.space.lg;
} else {
y = yAfterCross;
}
} else { } else {
// If we couldn't detect cross-sections, still show a small note instead of an empty section. // If there is no cross-section data, do not render the section at all.
if (y - 22 < contentMinY) y = newPage();
page.drawText(labels.crossSection, { x: margin, y, size: 10, font: fontBold, color: navy });
y -= 14;
page.drawText(locale === 'de' ? 'Keine Querschnittsdaten verfügbar.' : 'No cross-section data available.', {
x: margin,
y,
size: 9,
font,
color: mediumGray,
maxWidth: contentWidth,
});
y -= 16;
} }
// Add page numbers after all pages are created. // Add page numbers after all pages are created.
@@ -1387,13 +1574,18 @@ async function processProductsInChunks(): Promise<void> {
return; return;
} }
const enProducts = allProducts.filter(p => p.locale === 'en'); // Optional dev convenience: limit how many PDFs we render (useful for design iteration).
const deProducts = allProducts.filter(p => p.locale === 'de'); // Default behavior remains unchanged.
const limit = Number(process.env.PDF_LIMIT || '0');
const products = Number.isFinite(limit) && limit > 0 ? allProducts.slice(0, limit) : allProducts;
const enProducts = products.filter(p => p.locale === 'en');
const deProducts = products.filter(p => p.locale === 'de');
console.log(`Found ${enProducts.length} EN + ${deProducts.length} DE products`); console.log(`Found ${enProducts.length} EN + ${deProducts.length} DE products`);
const totalChunks = Math.ceil(allProducts.length / CONFIG.chunkSize); const totalChunks = Math.ceil(products.length / CONFIG.chunkSize);
for (let i = 0; i < totalChunks; i++) { for (let i = 0; i < totalChunks; i++) {
const chunk = allProducts.slice(i * CONFIG.chunkSize, (i + 1) * CONFIG.chunkSize); const chunk = products.slice(i * CONFIG.chunkSize, (i + 1) * CONFIG.chunkSize);
await processChunk(chunk, i, totalChunks); await processChunk(chunk, i, totalChunks);
} }