This commit is contained in:
2026-01-06 22:28:22 +01:00
parent a73b3db0ed
commit 3b5c4bdce8
51 changed files with 190 additions and 34 deletions

View File

@@ -80,19 +80,76 @@ function drawKeyValueGrid(args: {
navy: ReturnType<typeof rgb>;
darkGray: ReturnType<typeof rgb>;
mediumGray: ReturnType<typeof rgb>;
lightGray?: ReturnType<typeof rgb>;
almostWhite?: ReturnType<typeof rgb>;
allowNewPage?: boolean;
boxed?: boolean;
}): number {
let { title, items, newPage, getPage, page, y, margin, contentWidth, contentMinY, font, fontBold, navy, darkGray, mediumGray } = args;
const allowNewPage = args.allowNewPage ?? true;
const boxed = args.boxed ?? false;
const lightGray = args.lightGray ?? rgb(0.9020, 0.9137, 0.9294);
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;
const xBase = margin + padX;
const innerWidth = contentWidth - padX * 2;
const colGap = 14;
const colW = (contentWidth - colGap) / 2;
const rowH = 20;
const colW = (innerWidth - colGap) / 2;
const rowH = 18;
const headerH = boxed ? 18 : 0;
// Draw a strict rectangular section container (no rounding)
if (boxed && items.length) {
const rows = Math.ceil(items.length / 2);
const boxH = padY + headerH + rows * rowH + padY;
const bottomY = y - boxH;
if (bottomY < contentMinY) {
if (!allowNewPage) return contentMinY - 1;
y = newPage();
}
page = getPage();
page.drawRectangle({
x: margin,
y: y - boxH,
width: contentWidth,
height: boxH,
borderColor: lightGray,
borderWidth: 1,
color: rgb(1, 1, 1),
});
// Header band for the title
page.drawRectangle({
x: margin,
y: y - headerH,
width: contentWidth,
height: headerH,
color: almostWhite,
});
}
const drawTitle = () => {
page = getPage();
page.drawText(title, { x: margin, y, size: 10, font: fontBold, color: navy });
y -= 16;
if (boxed) {
// Align title inside the header band.
page.drawText(title, { x: xBase, y: y - 13, size: 9.5, 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,
color: lightGray,
});
y -= headerH + padY;
} else {
page.drawText(title, { x: margin, y, size: 10, font: fontBold, color: navy });
y -= 16;
}
};
if (y - 22 < contentMinY) {
@@ -105,7 +162,7 @@ function drawKeyValueGrid(args: {
let rowY = y;
for (let i = 0; i < items.length; i++) {
const col = i % 2;
const x = margin + col * (colW + colGap);
const x = xBase + col * (colW + colGap);
const { label, value } = items[i];
if (col === 0 && rowY - rowH < contentMinY) {
@@ -116,13 +173,13 @@ function drawKeyValueGrid(args: {
rowY = y;
}
page.drawText(label, { x, y: rowY, size: 8, font: fontBold, color: mediumGray, maxWidth: colW });
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 });
if (col === 1) rowY -= rowH;
}
return rowY - rowH;
return boxed ? rowY - rowH - padY : rowY - rowH;
}
function ensureOutputDir(): void {
@@ -711,15 +768,14 @@ function drawCrossSectionChipsRow(args: {
page.drawText(summaryParts.join(' · '), { x: margin, y, size: 8, font, color: mediumGray, maxWidth: contentWidth });
y -= summaryH;
// Chips (wrapping). We draw small bordered rectangles and wrap to next line.
// Tags (wrapping). Rectangular, engineered (no playful rounding).
const padX = 7;
const chipFontSize = 7.5;
const chipGap = 6;
const chipPadTop = 4;
const startY = y - chipH; // baseline for first chip row
const maxLinesBySpace = Math.max(1, Math.floor((startY - contentMinY + lineGap) / (chipH + lineGap)));
const maxLines = Math.min(6, maxLinesBySpace); // visual cap
const maxLines = Math.max(1, Math.floor((startY - contentMinY + lineGap) / (chipH + lineGap)));
const chipWidth = (text: string) => font.widthOfTextAtSize(text, chipFontSize) + padX * 2;
@@ -762,32 +818,120 @@ function drawCrossSectionChipsRow(args: {
return { placements, shown };
};
// First pass: lay out as many as possible.
const moreTextBase = locale === 'de' ? 'weitere' : 'more';
let shown = 0;
let placements: Placement[] = [];
// Group by cores: label on the left, mm² tags to the right.
const byCores = new Map<number, number[]>();
const other: string[] = [];
for (const cs of items) {
const p = parseCoresAndMm2(cs);
if (p.cores !== null && p.mm2 !== null) {
const arr = byCores.get(p.cores) ?? [];
arr.push(p.mm2);
byCores.set(p.cores, arr);
} else {
other.push(cs);
}
}
// We reserve space for the "+N" chip when there is truncation by iteratively reducing shown.
// This produces stable output without needing to "erase" drawings.
const allTexts = items;
let tmp = layout(allTexts, false, '');
shown = tmp.shown;
const remaining0 = total - shown;
if (remaining0 <= 0) {
placements = tmp.placements;
} else {
// Reduce shown until the "+N" chip fits.
for (let cut = shown; cut >= 0; cut--) {
const remaining = total - cut;
const moreText = `+${remaining} ${moreTextBase}`;
const res = layout(allTexts.slice(0, cut), true, moreText);
// Ensure the more-chip actually placed (variant 'more' exists)
const hasMore = res.placements.some(p => p.variant === 'more');
if (hasMore) {
shown = cut;
placements = res.placements;
const coreKeys = Array.from(byCores.keys()).sort((a, b) => a - b);
for (const k of coreKeys) {
const uniq = Array.from(new Set(byCores.get(k) ?? [])).sort((a, b) => a - b);
byCores.set(k, uniq);
}
const fmtMm2 = (v: number) => {
const s = Number.isInteger(v) ? String(v) : String(v).replace(/\.0+$/, '');
return s;
};
// Layout engine with group labels.
const labelW = 34;
const placements: Placement[] = [];
let line = 0;
let cy = startY;
let x = margin + labelW;
const canAdvanceLine = () => line + 1 < maxLines;
const advanceLine = () => {
if (!canAdvanceLine()) return false;
line += 1;
cy -= chipH + lineGap;
x = margin + labelW;
return true;
};
const drawGroupLabel = (label: string) => {
// Draw label on each new line for the group (keeps readability when wrapping).
page.drawText(label, {
x: margin,
y: cy + 4,
size: 8,
font: fontBold,
color: mediumGray,
maxWidth: labelW - 4,
});
};
const placeChip = (text: string, variant: 'normal' | 'more') => {
const w = chipWidth(text);
if (w > contentWidth - labelW) return false;
if (x + w > margin + contentWidth) {
if (!advanceLine()) return false;
}
placements.push({ text, x, y: cy, w, variant });
x += w + chipGap;
return true;
};
let truncated = false;
let renderedCount = 0;
const totalChips = coreKeys.reduce((sum, k) => sum + (byCores.get(k)?.length ?? 0), 0) + other.length;
for (const cores of coreKeys) {
const values = byCores.get(cores) ?? [];
const label = `${cores}×`;
// Ensure label is shown at least once per line block.
drawGroupLabel(label);
for (const v of values) {
const ok = placeChip(fmtMm2(v), 'normal');
if (!ok) {
truncated = true;
break;
}
renderedCount++;
}
if (truncated) break;
// Add a tiny gap between core groups (only if we have room on the current line)
x += 4;
if (x > margin + contentWidth - 20) {
if (!advanceLine()) {
// out of vertical space; stop
truncated = true;
break;
}
}
}
if (!truncated && other.length) {
const label = locale === 'de' ? 'Sonst.' : 'Other';
drawGroupLabel(label);
for (const t of other) {
const ok = placeChip(t, 'normal');
if (!ok) {
truncated = true;
break;
}
renderedCount++;
}
}
if (truncated) {
const remaining = Math.max(0, totalChips - renderedCount);
const moreText = locale === 'de' ? `+${remaining} weitere` : `+${remaining} more`;
// Try to place on current line; if not possible, try next line.
if (!placeChip(moreText, 'more')) {
if (advanceLine()) {
placeChip(moreText, 'more');
}
}
}
@@ -800,7 +944,7 @@ function drawCrossSectionChipsRow(args: {
height: chipH,
borderColor: lightGray,
borderWidth: 1,
color: p.variant === 'more' ? almostWhite : rgb(1, 1, 1),
color: almostWhite,
});
page.drawText(p.text, {
x: p.x + padX,
@@ -950,6 +1094,15 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
const hasSpace = (needed: number) => y - needed >= contentMinY;
// Page 1
// Page background (STYLEGUIDE.md)
page.drawRectangle({
x: 0,
y: 0,
width,
height,
color: almostWhite,
});
drawFooter(ctx);
let y = drawHeader(ctx, height - margin);
@@ -1114,7 +1267,10 @@ async function generatePDF(product: ProductData, locale: 'en' | 'de'): Promise<B
navy,
darkGray,
mediumGray,
lightGray,
almostWhite,
allowNewPage: false,
boxed: true,
});
// === CROSS-SECTION TABLE (row-specific data) ===