diff --git a/frontend/src/pages/BestellungDetail.tsx b/frontend/src/pages/BestellungDetail.tsx index c304b07..2f1b1b9 100644 --- a/frontend/src/pages/BestellungDetail.tsx +++ b/frontend/src/pages/BestellungDetail.tsx @@ -361,7 +361,13 @@ export default function BestellungDetail() { : String(bestellung.id); const title = `Bestellung #${kennung}`; - let curY = addPdfHeader(doc, title, settings, 210); + let curY = await addPdfHeader(doc, settings, 210); + + // Document title below header + doc.setFontSize(14); + doc.setFont('helvetica', 'bold'); + doc.text(title, 10, curY); + curY += 8; // Metadata block doc.setFontSize(10); diff --git a/frontend/src/pages/Bestellungen.tsx b/frontend/src/pages/Bestellungen.tsx index 17ada09..0f02324 100644 --- a/frontend/src/pages/Bestellungen.tsx +++ b/frontend/src/pages/Bestellungen.tsx @@ -192,7 +192,13 @@ export default function Bestellungen() { let settings; try { settings = await configApi.getPdfSettings(); } catch { settings = { pdf_header: '', pdf_footer: '', pdf_logo: '', pdf_org_name: '' }; } - const startY = addPdfHeader(doc, 'Bestellungen — Übersicht', settings, 210); + let startY = await addPdfHeader(doc, settings, 210); + + // Document title below header + doc.setFontSize(14); + doc.setFont('helvetica', 'bold'); + doc.text('Bestellungen — Übersicht', 10, startY); + startY += 8; const rows = filteredOrders.map((o) => { const brutto = calcBrutto(o); diff --git a/frontend/src/pages/Kalender.tsx b/frontend/src/pages/Kalender.tsx index 0fa3102..0cded8f 100644 --- a/frontend/src/pages/Kalender.tsx +++ b/frontend/src/pages/Kalender.tsx @@ -642,7 +642,14 @@ async function generatePdf( const pdfSettings = await fetchPdfSettings(); // Header - const tableStartY = addPdfHeader(doc, `Kalender — ${monthLabel} ${year}`, pdfSettings, 297); + let tableStartY = await addPdfHeader(doc, pdfSettings, 297); + + // Document title below header + const titleText = `Kalender — ${monthLabel} ${year}`; + doc.setFontSize(14); + doc.setFont('helvetica', 'bold'); + doc.text(titleText, 10, tableStartY); + tableStartY += 8; // Build combined list (same logic as CombinedListView) type ListEntry = diff --git a/frontend/src/utils/pdfExport.ts b/frontend/src/utils/pdfExport.ts index b012d2e..c711236 100644 --- a/frontend/src/utils/pdfExport.ts +++ b/frontend/src/utils/pdfExport.ts @@ -38,86 +38,94 @@ export function renderMarkdownText( return curY; } +/** Load an image from a data URL and return its natural pixel dimensions. */ +function getImageDimensions(src: string): Promise<{ w: number; h: number }> { + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => resolve({ w: img.naturalWidth, h: img.naturalHeight }); + img.onerror = () => resolve({ w: 1, h: 1 }); + img.src = src; + }); +} + /** - * Add a PDF header with white background: - * - Left: pdf_header text (bold italic, multi-line) - * - Right: org name + logo side-by-side + * Add a white-background PDF header matching the official template: + * - Left: pdf_header text (bold italic, multi-line org hierarchy) + * - Right: org name (bold) + logo (aspect-ratio correct) * - Thin dark separator line below - * Returns Y position where content should start. + * + * Does NOT render the document title — callers do that below the returned Y. + * Returns Y position where document content should start. */ -export function addPdfHeader( +export async function addPdfHeader( doc: jsPDF, - title: string, settings: PdfSettings, pageWidth: number, -): number { - const logoSize = 14; +): Promise { const margin = 6; const rightEdge = pageWidth - margin; + const logoMaxH = 16; - // ── Left side: title (bold) ── - doc.setFontSize(14); - doc.setFont('helvetica', 'bold'); - doc.setTextColor(0, 0, 0); - doc.text(title, 10, 12); - - // ── Left side: pdf_header text below title (bold italic, smaller) ── - let headerEndY = 16; - if (settings.pdf_header) { - doc.setFontSize(8); - doc.setTextColor(100, 100, 100); - const headerLines = settings.pdf_header.split('\n'); - let hy = 18; - for (const line of headerLines) { - const segments = line.split('**'); - let hx = 10; - for (let i = 0; i < segments.length; i++) { - const seg = segments[i]; - if (!seg) continue; - const isBold = i % 2 === 1; - doc.setFont('helvetica', isBold ? 'bolditalic' : 'italic'); - doc.text(seg, hx, hy); - hx += doc.getTextWidth(seg); - } - hy += 3.5; - } - headerEndY = Math.max(headerEndY, hy); - } - - // ── Right side: logo + org name ── + // ── Right: logo (aspect-ratio-correct) ── const logoSrc = settings.app_logo || settings.pdf_logo; let logoLeftEdge = rightEdge; if (logoSrc) { try { + const { w, h } = await getImageDimensions(logoSrc); + const ratio = w / h; + const logoH = logoMaxH; + const logoW = logoH * ratio; const fmt = logoSrc.startsWith('data:image/png') ? 'PNG' : 'JPEG'; - const logoX = rightEdge - logoSize; - doc.addImage(logoSrc, fmt, logoX, 3, logoSize, logoSize); + const logoX = rightEdge - logoW; + doc.addImage(logoSrc, fmt, logoX, 2, logoW, logoH); logoLeftEdge = logoX - 3; } catch { /* ignore invalid image */ } } + // ── Right: org name (to the left of logo, vertically centred) ── if (settings.pdf_org_name) { doc.setFontSize(10); doc.setFont('helvetica', 'bold'); doc.setTextColor(0, 0, 0); const nameW = doc.getTextWidth(settings.pdf_org_name); - const nameX = logoLeftEdge - nameW; - // Vertically centered with logo area (~y=10) - doc.text(settings.pdf_org_name, nameX, 11); + doc.text(settings.pdf_org_name, logoLeftEdge - nameW, 11); + } + + // ── Left: pdf_header text (bold italic, multi-line) ── + let headerEndY = 10; + if (settings.pdf_header) { + doc.setFontSize(8); + doc.setTextColor(60, 60, 60); + const headerLines = settings.pdf_header.split('\n'); + let hy = 8; + for (const line of headerLines) { + const segments = line.split('**'); + let hx = margin; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; + if (!seg) continue; + doc.setFont('helvetica', i % 2 === 1 ? 'bolditalic' : 'italic'); + doc.text(seg, hx, hy); + hx += doc.getTextWidth(seg); + } + hy += 4; + } + headerEndY = hy; } // ── Separator line ── - const lineY = Math.max(headerEndY, 20) + 2; + const lineY = Math.max(headerEndY, logoMaxH + 4) + 2; doc.setDrawColor(60, 60, 60); doc.setLineWidth(0.5); doc.line(margin, lineY, pageWidth - margin, lineY); - // Reset + // Reset styles doc.setFont('helvetica', 'normal'); doc.setTextColor(0, 0, 0); + doc.setFontSize(10); - return lineY + 4; + return lineY + 5; } /**