wip
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user