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

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.
}