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,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);
// Inner layout (boxed vs. plain)
const padX = boxed ? 14 : 0;
const padY = boxed ? 12 : 0;
// Keep a strict spacing system for more professional datasheets.
const padX = boxed ? 16 : 0;
const padY = boxed ? 14 : 0;
const xBase = margin + padX;
const innerWidth = contentWidth - padX * 2;
const colGap = 14;
const colGap = 16;
const colW = (innerWidth - colGap) / 2;
const rowH = 18;
const headerH = boxed ? 18 : 0;
const rowH = 24;
const headerH = boxed ? 22 : 0;
// Draw a strict rectangular section container (no rounding)
if (boxed && items.length) {
@@ -137,12 +138,12 @@ function drawKeyValueGrid(args: {
page = getPage();
if (boxed) {
// 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
page.drawLine({
start: { x: margin, y: y - headerH },
end: { x: margin + contentWidth, y: y - headerH },
thickness: 1,
thickness: 0.75,
color: lightGray,
});
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(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;
}
@@ -471,7 +472,7 @@ function drawTableChunked(args: {
page.drawText(chunkTitle, {
x: margin,
y,
size: 10,
size: 12,
font: fontBold,
color: navy,
});
@@ -561,6 +562,7 @@ type SectionDrawContext = {
darkGray: ReturnType<typeof rgb>;
almostWhite: ReturnType<typeof rgb>;
lightGray: ReturnType<typeof rgb>;
headerBg: ReturnType<typeof rgb>;
};
fonts: {
regular: PDFFont;
@@ -575,12 +577,12 @@ type SectionDrawContext = {
};
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({
start: { x: margin, y: footerY + 14 },
end: { x: width - margin, y: footerY + 14 },
thickness: 1,
thickness: 0.75,
color: colors.lightGray,
});
@@ -628,7 +630,20 @@ function stampPageNumbers(pdfDoc: PDFDocument, fonts: { regular: PDFFont }, colo
}
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 qrGap = 12;
@@ -636,70 +651,94 @@ function drawHeader(ctx: SectionDrawContext, yStart: number): number {
// Left: logo (preferred) or typographic fallback
if (logoImage) {
const maxLogoW = 110;
const maxLogoH = 28;
const maxLogoW = 120;
const maxLogoH = 30;
const scale = Math.min(maxLogoW / logoImage.width, maxLogoH / logoImage.height);
const w = logoImage.width * scale;
const h = logoImage.height * scale;
const logoY = dividerY + Math.round((headerH - h) / 2);
page.drawImage(logoImage, {
x: margin,
y: yStart - h + 6,
y: logoY,
width: w,
height: h,
});
} else {
const baseY = dividerY + 22;
page.drawText('KLZ', {
x: margin,
y: yStart,
size: 24,
y: baseY,
size: 22,
font: fonts.bold,
color: colors.navy,
});
page.drawText('Cables', {
x: margin + fonts.bold.widthOfTextAtSize('KLZ', 24) + 4,
y: yStart + 2,
x: margin + fonts.bold.widthOfTextAtSize('KLZ', 22) + 4,
y: baseY + 2,
size: 10,
font: fonts.regular,
color: colors.mediumGray,
});
}
// Header divider baseline (shared with footer spacing logic)
const dividerY = yStart - 58;
ctx.headerDividerY = dividerY;
// Right: datasheet meta + QR (if available)
const metaRightEdge = width - margin - rightReserved;
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) {
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 });
} else {
// If QR generation failed, keep the URL available as a small header line.
const maxW = 220;
const urlLines = wrapText(qrUrl, fonts.regular, 8, maxW).slice(0, 2);
let urlY = yStart - 12;
for (const line of urlLines) {
// If QR generation failed, keep the URL available as a compact line.
const maxW = 260;
const urlLines = wrapText(qrUrl, fonts.regular, 8, maxW).slice(0, 1);
if (urlLines.length) {
const line = urlLines[0];
const w = fonts.regular.widthOfTextAtSize(line, 8);
page.drawText(line, {
x: width - margin - w,
y: urlY,
y: dividerY + 12,
size: 8,
font: fonts.regular,
color: colors.mediumGray,
});
urlY -= 10;
}
}
// Header line
// Divider line
page.drawLine({
start: { x: margin, y: dividerY },
end: { x: margin + contentWidth, y: dividerY },
thickness: 1,
thickness: 0.75,
color: colors.lightGray,
});
return dividerY - 26;
// Content start: provide real breathing room below the header.
return dividerY - 40;
}
function drawCrossSectionChipsRow(args: {
@@ -725,9 +764,9 @@ function drawCrossSectionChipsRow(args: {
// Single-page rule: if we can't fit the block, stop.
const titleH = 12;
const summaryH = 12;
const chipH = 14;
const lineGap = 6;
const gapY = 8;
const chipH = 16;
const lineGap = 8;
const gapY = 10;
const minLines = 2;
const needed = titleH + summaryH + (chipH * minLines) + (lineGap * (minLines - 1)) + gapY;
if (y - needed < contentMinY) return contentMinY - 1;
@@ -758,7 +797,7 @@ function drawCrossSectionChipsRow(args: {
const mm2Min = mm2Vals.length ? mm2Vals[0] : 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;
const summaryParts: string[] = [];
@@ -769,10 +808,10 @@ function drawCrossSectionChipsRow(args: {
y -= summaryH;
// Tags (wrapping). Rectangular, engineered (no playful rounding).
const padX = 7;
const chipFontSize = 7.5;
const chipGap = 6;
const chipPadTop = 4;
const padX = 8;
const chipFontSize = 8;
const chipGap = 8;
const chipPadTop = 5;
const startY = y - chipH; // baseline for first chip row
const maxLines = Math.max(1, Math.floor((startY - contentMinY + lineGap) / (chipH + lineGap)));
@@ -844,7 +883,7 @@ function drawCrossSectionChipsRow(args: {
};
// Layout engine with group labels.
const labelW = 34;
const labelW = 38;
const placements: Placement[] = [];
let line = 0;
let cy = startY;
@@ -944,7 +983,7 @@ function drawCrossSectionChipsRow(args: {
height: chipH,
borderColor: lightGray,
borderWidth: 1,
color: almostWhite,
color: rgb(1, 1, 1),
});
page.drawText(p.text, {
x: p.x + padX,
@@ -959,7 +998,10 @@ function drawCrossSectionChipsRow(args: {
// 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 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: {
@@ -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 almostWhite = rgb(0.9725, 0.9765, 0.9804); // #F8F9FA
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 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 qrImage = qrPng ? await pdfDoc.embedPng(qrPng.pngBytes) : null;
// Single-page constraint: keep generous but slightly tighter margins.
const margin = 50;
const footerY = 50;
const contentMinY = footerY + 36; // keep clear of footer
// Engineered page frame (A4): slightly narrower margins but consistent rhythm.
const margin = 54;
const footerY = 54;
const contentMinY = footerY + 42; // keep clear of footer + page numbers
const contentWidth = width - 2 * margin;
const ctx: SectionDrawContext = {
@@ -1078,7 +1136,7 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
footerY,
contentMinY,
headerDividerY: 0,
colors: { navy, mediumGray, darkGray, almostWhite, lightGray },
colors: { navy, mediumGray, darkGray, almostWhite, lightGray, headerBg },
fonts: { regular: font, bold: fontBold },
labels,
product,
@@ -1093,14 +1151,44 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
const newPage = (): number => contentMinY - 1;
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 background (STYLEGUIDE.md)
// Page background (print-friendly)
page.drawRectangle({
x: 0,
y: 0,
width,
height,
color: almostWhite,
color: rgb(1, 1, 1),
});
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 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);
for (const line of shownNameLines) {
if (y - 22 < contentMinY) y = newPage();
if (y - titleLineH < contentMinY) y = newPage();
page.drawText(line, {
x: margin,
y,
size: 18,
size: DS.type.h1,
font: fontBold,
color: navy,
maxWidth: titleW,
});
y -= 22;
y -= titleLineH;
}
if (cats) {
@@ -1131,20 +1220,23 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
page.drawText(cats, {
x: margin,
y,
size: 9,
size: 10.5,
font,
color: mediumGray,
maxWidth: titleW,
});
y -= 18;
y -= DS.space.lg;
}
// Separator after product header
rule(DS.space.sm, DS.space.lg);
// === HERO IMAGE (full width) ===
let heroH = 115;
const heroGap = 12;
if (!hasSpace(heroH + heroGap)) {
let heroH = 160;
const afterHeroGap = DS.space.xl;
if (!hasSpace(heroH + afterHeroGap)) {
// 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;
@@ -1154,13 +1246,14 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
y: heroBoxY,
width: contentWidth,
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,
borderWidth: 1,
});
if (heroPng) {
const pad = 10;
const pad = DS.space.md;
const boxW = contentWidth - pad * 2;
const boxH = heroH - pad * 2;
@@ -1177,12 +1270,12 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
.toBuffer();
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, {
x: heroBoxX + pad,
y: heroBoxY + pad,
width: heroImage.width * scale,
height: heroImage.height * scale,
width: boxW,
height: boxH,
});
} else {
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 ===
if ((product.shortDescriptionHtml || product.descriptionHtml) && hasSpace(40)) {
page.drawText(labels.description, {
x: margin,
y: y,
size: 10,
font: fontBold,
color: navy,
});
y -= 14;
if (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)) {
page.drawText(line, {
// One-page rule: only render description if we can fit it cleanly.
if (hasSpace(descNeeded)) {
sectionTitle(labels.description);
const boxTop = y + DS.space.xs;
const boxBottom = boxTop - boxH;
page.drawRectangle({
x: margin,
y: y,
size: 9,
font: font,
color: darkGray,
y: boxBottom,
width: contentWidth,
height: boxH,
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) ===
@@ -1230,6 +1341,7 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
configAttr ||
findAttr(product, /number of cores and cross-section|querschnitt|cross.?section|mm²|mm2/i);
const rowCount = crossSectionAttr?.options?.length || 0;
const hasCrossSectionData = Boolean(crossSectionAttr && rowCount > 0);
// Compact mode approach:
// - 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
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]) }))
.filter(i => i.label && i.value)
.slice(0, 12);
// 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.
if (constantItems.length === 0) {
constantItems.push({
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.',
// TECH DATA must never crowd out cross-section.
// IMPORTANT: `drawKeyValueGrid()` will return `contentMinY - 1` when it can't fit.
// We must avoid calling it unless we're sure it fits.
const techBox = {
// 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({
title: locale === 'de' ? 'TECHNISCHE DATEN' : 'TECHNICAL DATA',
items: constantItems,
newPage,
getPage: () => page,
page,
y,
margin,
contentWidth,
contentMinY,
font,
fontBold,
navy,
darkGray,
mediumGray,
lightGray,
almostWhite,
allowNewPage: false,
boxed: true,
});
// Consistent spacing after the technical data block (but never push content below min Y)
if (y - DS.space.lg >= contentMinY) y -= DS.space.lg;
// === CROSS-SECTION TABLE (row-specific data) ===
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.
const columns: Array<{ label: string; get: (rowIndex: number) => string }> = [];
y = drawCrossSectionChipsRow({
const yAfterCross = drawCrossSectionChipsRow({
title: labels.crossSection,
configRows,
locale,
@@ -1313,20 +1493,27 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
lightGray,
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 {
// If we couldn't detect cross-sections, still show a small note instead of an empty section.
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;
// If there is no cross-section data, do not render the section at all.
}
// Add page numbers after all pages are created.
@@ -1387,13 +1574,18 @@ async function processProductsInChunks(): Promise<void> {
return;
}
const enProducts = allProducts.filter(p => p.locale === 'en');
const deProducts = allProducts.filter(p => p.locale === 'de');
// Optional dev convenience: limit how many PDFs we render (useful for design iteration).
// 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`);
const totalChunks = Math.ceil(allProducts.length / CONFIG.chunkSize);
const totalChunks = Math.ceil(products.length / CONFIG.chunkSize);
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);
}