This commit is contained in:
2026-01-06 23:54:17 +01:00
parent c12c776e67
commit a7b6aa85f8
51 changed files with 268 additions and 37 deletions

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

@@ -248,9 +248,30 @@ function wrapText(text: string, font: PDFFont, fontSize: number, maxWidth: numbe
const lines: string[] = [];
let currentLine = '';
for (const word of words) {
const isOrphanWord = (w: string) => {
// Avoid ugly single short words on their own line in DE/EN (e.g. “im”, “in”, “to”).
// This is a typography/UX improvement for datasheets.
const s = w.trim();
return s.length > 0 && s.length <= 2;
};
for (let i = 0; i < words.length; i++) {
const word = words[i];
const next = i + 1 < words.length ? words[i + 1] : '';
const testLine = currentLine ? `${currentLine} ${word}` : word;
if (font.widthOfTextAtSize(testLine, fontSize) <= maxWidth) {
// Orphan control: if adding the *next* word would overflow, don't end the line with a tiny orphan.
// Example: "... mechanischen im" + "Belastungen" should become "... mechanischen" / "im Belastungen ...".
if (currentLine && next && isOrphanWord(word)) {
const testWithNext = `${testLine} ${next}`;
if (font.widthOfTextAtSize(testWithNext, fontSize) > maxWidth) {
lines.push(currentLine);
currentLine = word;
continue;
}
}
currentLine = testLine;
} else {
if (currentLine) lines.push(currentLine);
@@ -685,29 +706,18 @@ function drawHeader(ctx: SectionDrawContext, yStart: number): number {
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);
// With SKU removed, vertically center the title within the header block.
const metaY = dividerY + Math.round(headerH / 2 - metaTitleSize / 2);
page.drawText(metaTitle, {
x: metaRightEdge - mtW,
y: dividerY + 38,
y: metaY,
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,
});
}
if (qrImage) {
const qrX = width - margin - qrSize;
const qrY = dividerY + Math.round((headerH - qrSize) / 2);
@@ -745,6 +755,7 @@ function drawCrossSectionChipsRow(args: {
title: string;
configRows: string[];
locale: 'en' | 'de';
maxLinesCap?: number;
getPage: () => PDFPage;
page: PDFPage;
y: number;
@@ -814,7 +825,10 @@ function drawCrossSectionChipsRow(args: {
const chipPadTop = 5;
const startY = y - chipH; // baseline for first chip row
const maxLines = Math.max(1, Math.floor((startY - contentMinY + lineGap) / (chipH + lineGap)));
const maxLinesAvailable = Math.max(1, Math.floor((startY - contentMinY + lineGap) / (chipH + lineGap)));
// UX/Content priority: don't let cross-section tags consume the whole sheet.
// When technical data is dense, we cap this to keep specs visible.
const maxLines = Math.min(args.maxLinesCap ?? 2, maxLinesAvailable);
const chipWidth = (text: string) => font.widthOfTextAtSize(text, chipFontSize) + padX * 2;
@@ -1045,6 +1059,137 @@ function normalizeValue(value: string): string {
return stripHtml(value).replace(/\s+/g, ' ').trim();
}
function summarizeOptions(options: string[] | undefined, maxItems: number = 3): string {
const vals = (options || []).map(normalizeValue).filter(Boolean);
if (vals.length === 0) return '';
const uniq = Array.from(new Set(vals));
if (uniq.length === 1) return uniq[0];
if (uniq.length <= maxItems) return uniq.join(' / ');
return `${uniq.slice(0, maxItems).join(' / ')} (+${uniq.length - maxItems})`;
}
function parseNumericOption(value: string): number | null {
const v = normalizeValue(value).replace(/,/g, '.');
// First numeric token (works for "12.3", "12.3 mm", "-35", "26.5 kg/km").
const m = v.match(/-?\d+(?:\.\d+)?/);
if (!m) return null;
const n = Number(m[0]);
return Number.isFinite(n) ? n : null;
}
function formatNumber(n: number): string {
const s = Number.isInteger(n) ? String(n) : String(n);
return s.replace(/\.0+$/, '');
}
function summarizeNumericRange(options: string[] | undefined): { ok: boolean; text: string } {
const vals = (options || []).map(parseNumericOption).filter((n): n is number => n !== null);
if (vals.length < 3) return { ok: false, text: '' };
const uniq = Array.from(new Set(vals));
if (uniq.length < 2) return { ok: false, text: '' };
uniq.sort((a, b) => a - b);
const min = uniq[0];
const max = uniq[uniq.length - 1];
return { ok: true, text: `${formatNumber(min)}${formatNumber(max)} (n=${uniq.length})` };
}
function summarizeSmartOptions(label: string, options: string[] | undefined): string {
// Prefer numeric ranges when an attribute has many numeric-ish entries (typical for row-specific data).
const range = summarizeNumericRange(options);
if (range.ok) return range.text;
return summarizeOptions(options, 3);
}
function looksNumeric(value: string): boolean {
const v = normalizeValue(value).replace(/,/g, '.');
return /^-?\d+(?:\.\d+)?$/.test(v);
}
function formatMaybeWithUnit(value: string, unit: string): string {
const v = normalizeValue(value);
if (!v) return '';
return looksNumeric(v) ? `${v} ${unit}` : v;
}
function drawRowPreviewTable(args: {
title: string;
rows: Array<{ config: string; col1: string; col2: string }>;
headers: { config: string; col1: string; col2: string };
getPage: () => PDFPage;
page: PDFPage;
y: number;
margin: number;
contentWidth: number;
contentMinY: number;
font: PDFFont;
fontBold: PDFFont;
navy: ReturnType<typeof rgb>;
darkGray: ReturnType<typeof rgb>;
lightGray: ReturnType<typeof rgb>;
almostWhite: ReturnType<typeof rgb>;
}): number {
let { title, rows, headers, getPage, page, y, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, lightGray, almostWhite } = args;
const titleH = 16;
const headerH = 16;
const rowH = 13;
const padAfter = 18;
// One-page rule: require at least 2 data rows.
const minNeeded = titleH + headerH + rowH * 2 + padAfter;
if (y - minNeeded < contentMinY) return contentMinY - 1;
page = getPage();
page.drawText(title, { x: margin, y, size: 10, font: fontBold, color: navy });
y -= titleH;
// How many rows fit?
const availableForRows = y - contentMinY - padAfter - headerH;
const maxRows = Math.max(2, Math.floor(availableForRows / rowH));
const shown = rows.slice(0, Math.max(0, maxRows));
const hasCol2 = shown.some(r => Boolean(r.col2));
// Widths: favor configuration readability.
const wCfg = 0.46;
const w1 = hasCol2 ? 0.27 : 0.54;
const w2 = hasCol2 ? 0.27 : 0;
const x0 = margin;
const x1 = margin + contentWidth * wCfg;
const x2 = margin + contentWidth * (wCfg + w1);
const drawHeader = () => {
page = getPage();
page.drawRectangle({ x: margin, y: y - headerH, width: contentWidth, height: headerH, color: lightGray });
page.drawText(headers.config, { x: x0 + 6, y: y - 11, size: 8, font: fontBold, color: navy, maxWidth: contentWidth * wCfg - 12 });
page.drawText(headers.col1, { x: x1 + 6, y: y - 11, size: 8, font: fontBold, color: navy, maxWidth: contentWidth * w1 - 12 });
if (hasCol2) {
page.drawText(headers.col2, { x: x2 + 6, y: y - 11, size: 8, font: fontBold, color: navy, maxWidth: contentWidth * w2 - 12 });
}
y -= headerH;
};
drawHeader();
for (let i = 0; i < shown.length; i++) {
if (y - rowH < contentMinY) return contentMinY - 1;
page = getPage();
if (i % 2 === 0) {
page.drawRectangle({ x: margin, y: y - rowH, width: contentWidth, height: rowH, color: almostWhite });
}
page.drawText(shown[i].config, { x: x0 + 6, y: y - 10, size: 8, font, color: darkGray, maxWidth: contentWidth * wCfg - 12 });
page.drawText(shown[i].col1, { x: x1 + 6, y: y - 10, size: 8, font, color: darkGray, maxWidth: contentWidth * w1 - 12 });
if (hasCol2) {
page.drawText(shown[i].col2, { x: x2 + 6, y: y - 10, size: 8, font, color: darkGray, maxWidth: contentWidth * w2 - 12 });
}
y -= rowH;
}
y -= padAfter;
return y;
}
function splitConfig(config: string): { crossSection: string; voltage: string } {
const raw = normalizeValue(config);
const parts = raw.split(/\s*-\s*/);
@@ -1110,11 +1255,11 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
const logoPng = (await loadEmbeddablePng('/media/logo.png')) || (await loadEmbeddablePng('/media/logo.svg'));
const logoImage = logoPng ? await pdfDoc.embedPng(logoPng.pngBytes) : null;
// Some products in the processed dataset have no images/attributes.
// Always fall back to a deterministic site hero so the PDF is never "empty".
const fallbackHero = '/media/10648-low-voltage-scaled.webp';
const heroSrc = product.featuredImage || product.images?.[0] || fallbackHero;
const heroPng = await loadEmbeddablePng(heroSrc);
// Some products have no product-specific images.
// Do NOT fall back to a generic/category hero (misleading in datasheets).
// If missing, we render a neutral placeholder box.
const heroSrc = product.featuredImage || product.images?.[0] || null;
const heroPng = heroSrc ? await loadEmbeddablePng(heroSrc) : null;
const productUrl = getProductUrl(product) || CONFIG.siteUrl;
const qrPng = await loadQrPng(productUrl);
@@ -1232,11 +1377,13 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
rule(DS.space.sm, DS.space.lg);
// === HERO IMAGE (full width) ===
let heroH = 160;
// Dense technical products need more room for specs; prioritize content over imagery.
const hasLotsOfTech = (product.attributes?.length || 0) >= 18;
let heroH = hasLotsOfTech ? 120 : 160;
const afterHeroGap = DS.space.xl;
if (!hasSpace(heroH + afterHeroGap)) {
// Shrink to remaining space (but keep it usable).
heroH = Math.max(120, Math.floor(y - contentMinY - afterHeroGap));
heroH = Math.max(96, Math.floor(y - contentMinY - afterHeroGap));
}
const heroBoxX = margin;
@@ -1348,11 +1495,59 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
// - show only a small configuration sample + total count
// - optionally render full tables with PDF_MODE=full
// Prefer a curated list that matches website expectations.
// IMPORTANT: for row-specific arrays we don't attempt per-row mapping here; we summarize as ranges.
const preferredTechAttrs: Array<{ re: RegExp; fallbackLabel: string }> = [
{ re: /standard|norm|vde|iec/i, fallbackLabel: locale === 'de' ? 'Norm' : 'Standard' },
{ re: /rated\s*voltage|voltage\s*rating|nennspannung/i, fallbackLabel: locale === 'de' ? 'Nennspannung' : 'Rated voltage' },
{ re: /test\s*voltage|pr\u00fcfspannung/i, fallbackLabel: locale === 'de' ? 'Pr\u00fcfspannung' : 'Test voltage' },
{ re: /temperature\s*range|operating\s*temperature|betriebstemperatur/i, fallbackLabel: locale === 'de' ? 'Temperaturbereich' : 'Temperature range' },
{ re: /bending\s*radius|biegeradius/i, fallbackLabel: locale === 'de' ? 'Biegeradius' : 'Bending radius' },
{ re: /cpr\s*class/i, fallbackLabel: locale === 'de' ? 'CPR-Klasse' : 'CPR class' },
];
const picked = new Set<string>();
const techItemsPreferred = preferredTechAttrs
.map(({ re, fallbackLabel }) => {
const a = findAttr(product, re);
if (!a) return null;
const label = normalizeValue(a.name) || fallbackLabel;
const value = summarizeSmartOptions(label, a.options);
if (!label || !value) return null;
picked.add(label.toLowerCase());
return { label, value };
})
.filter(Boolean) as Array<{ label: string; value: string }>;
const isConfigLikeAttr = (name: string) =>
/configuration|konfiguration|aufbau|bezeichnung|number of cores and cross-section|querschnitt|cross.?section|mm²|mm2/i.test(name);
const isClearlyMetaAttr = (name: string) => /\bsku\b|artikelnummer|\bid\b|product\s*id/i.test(name);
// Provide additional technical attributes as compact summaries.
// - numeric-heavy arrays become ranges (minmax with count)
// - non-numeric arrays become short lists
// This is what fills the “missing important technical data” without breaking 1-page.
const techItemsMore = (product.attributes || [])
.filter(a => (a.options?.length || 0) > 1)
.filter(a => !isConfigLikeAttr(a.name))
.filter(a => !isClearlyMetaAttr(a.name))
.map(a => {
const label = normalizeValue(a.name);
if (!label) return null;
if (picked.has(label.toLowerCase())) return null;
const value = summarizeSmartOptions(label, a.options);
if (!value) return null;
return { label, value };
})
.filter(Boolean) as Array<{ label: string; value: string }>;
const constantAttrs = (product.attributes || []).filter(a => a.options.length === 1);
const constantItemsAll = constantAttrs
const techItemsFill = constantAttrs
.map(a => ({ label: normalizeValue(a.name), value: normalizeValue(a.options[0]) }))
.filter(i => i.label && i.value)
.slice(0, 12);
.filter(i => !picked.has(i.label.toLowerCase()));
const constantItemsAll = [...techItemsPreferred, ...techItemsMore, ...techItemsFill].slice(0, 20);
// Intentionally do NOT include SKU/categories here (they are already shown in the product header).
@@ -1368,7 +1563,15 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
// 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*/;
// We cap the chips block to keep room for technical data.
const crossMaxLinesCap = 2;
const minCrossBlockH =
12 /*title*/ +
12 /*summary*/ +
(16 * crossMaxLinesCap) /*chips*/ +
8 /*lineGap*/ +
10 /*gapY*/ +
24 /*after*/;
const reservedForCross = hasCrossSectionData ? minCrossBlockH : 0;
const techTitle = locale === 'de' ? 'TECHNISCHE DATEN' : 'TECHNICAL DATA';
@@ -1387,7 +1590,7 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
};
// Pick the largest "nice" amount of items that still guarantees cross-section visibility.
const desiredCap = 8;
const desiredCap = 12;
let chosenCount = 0;
for (let n = Math.min(desiredCap, constantItemsAll.length); n >= 1; n--) {
if (canFitTechWith(n)) {
@@ -1464,21 +1667,16 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
return a;
};
const candidateCols: Array<{ key: string; label: string; re: RegExp }> = [
{ key: 'outerDiameter', label: locale === 'de' ? 'Außen-Ø' : 'Outer Ø', re: /outer\s*diameter|außen\s*durchmesser|außen-?ø/i },
{ key: 'weight', label: locale === 'de' ? 'Gewicht' : 'Weight', re: /\bweight\b|gewicht/i },
{ key: 'maxResistance', label: locale === 'de' ? 'Max. Leiterwiderstand' : 'Max. conductor resistance', re: /maximum\s+resistance\s+of\s+conductor|max\.?\s*resistance|leiterwiderstand/i },
{ key: 'current', label: locale === 'de' ? 'Strombelastbarkeit' : 'Current rating', re: /current\s*(rating|carrying)|ampacity|strombelastbarkeit/i },
];
// NOTE: One-page requirement: cross sections render as a dense list only.
// Row-specific values are intentionally omitted to keep the sheet compact.
const columns: Array<{ label: string; get: (rowIndex: number) => string }> = [];
// Pull the two most important row-specific columns and show a small excerpt table.
const rowOuter = findRowAttr(/outer\s*diameter|außen\s*durchmesser|außen-?ø/i);
const rowWeight = findRowAttr(/\bweight\b|gewicht/i);
const yAfterCross = drawCrossSectionChipsRow({
title: labels.crossSection,
configRows,
locale,
// keep chips as a fallback, but prefer the dense list section below
maxLinesCap: 2,
getPage: () => page,
page,
y,
@@ -1512,6 +1710,39 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
} else {
y = yAfterCross;
}
// Compact per-configuration excerpt (only if it fits).
if (rowOuter && rowWeight) {
const previewRows = configRows.map((cfg, i) => ({
config: normalizeValue(cfg),
col1: formatMaybeWithUnit(getAttrCellValue(rowOuter ?? undefined, i, rowCount), 'mm'),
col2: formatMaybeWithUnit(getAttrCellValue(rowWeight ?? undefined, i, rowCount), 'kg/km'),
}));
const previewTitle = locale === 'de' ? 'Konfigurationswerte (Auszug)' : 'Configuration values (excerpt)';
const yAfterPreview = drawRowPreviewTable({
title: previewTitle,
rows: previewRows,
headers: {
config: locale === 'de' ? 'Konfiguration' : 'Configuration',
col1: locale === 'de' ? 'Außen-Ø' : 'Outer Ø',
col2: locale === 'de' ? 'Gewicht' : 'Weight',
},
getPage: () => page,
page,
y,
margin,
contentWidth,
contentMinY,
font,
fontBold,
navy,
darkGray,
lightGray,
almostWhite,
});
if (yAfterPreview >= contentMinY) y = yAfterPreview;
}
} else {
// If there is no cross-section data, do not render the section at all.
}