wip
This commit is contained in:
@@ -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 (min–max 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.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user