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

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

@@ -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) ===