Files
dashboard/frontend/src/utils/pdfExport.ts
Matthias Hochmeister d4adf9230d update
2026-03-26 11:25:28 +01:00

160 lines
4.7 KiB
TypeScript

import type jsPDF from 'jspdf';
import type { PdfSettings } from '../services/config';
/**
* Render text with basic markdown (**bold**) and line breaks into a jsPDF doc.
* Returns the final Y position after rendering.
*/
export function renderMarkdownText(
doc: jsPDF,
text: string,
x: number,
y: number,
options?: { fontSize?: number; lineHeight?: number },
): number {
const fontSize = options?.fontSize ?? 9;
const lineHeight = options?.lineHeight ?? fontSize * 0.5;
doc.setFontSize(fontSize);
doc.setTextColor(0, 0, 0);
const lines = text.split('\n');
let curY = y;
for (const line of lines) {
const segments = line.split('**');
let curX = x;
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
if (!seg) continue;
const isBold = i % 2 === 1;
doc.setFont('helvetica', isBold ? 'bold' : 'normal');
doc.text(seg, curX, curY);
curX += doc.getTextWidth(seg);
}
curY += lineHeight;
}
doc.setFont('helvetica', 'normal');
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 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
*
* Does NOT render the document title — callers do that below the returned Y.
* Returns Y position where document content should start.
*/
export async function addPdfHeader(
doc: jsPDF,
settings: PdfSettings,
pageWidth: number,
): Promise<number> {
const margin = 6;
const rightEdge = pageWidth - margin;
const logoMaxH = 16;
// ── 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 - 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);
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, logoMaxH + 4) + 2;
doc.setDrawColor(60, 60, 60);
doc.setLineWidth(0.5);
doc.line(margin, lineY, pageWidth - margin, lineY);
// Reset styles
doc.setFont('helvetica', 'normal');
doc.setTextColor(0, 0, 0);
doc.setFontSize(10);
return lineY + 9;
}
/**
* Returns a `didDrawPage` callback that renders pdf_footer (left) and
* "Seite X/Y" page numbers (bottom right) on every page.
*/
export function addPdfFooter(
doc: jsPDF,
settings: PdfSettings,
): (data: any) => void {
return (data: any) => {
const pageHeight = doc.internal.pageSize.height;
const pageWidth = doc.internal.pageSize.width;
doc.setFontSize(8);
doc.setFont('helvetica', 'normal');
doc.setTextColor(100, 100, 100);
if (settings.pdf_footer) {
renderMarkdownText(doc, settings.pdf_footer, 10, pageHeight - 8, { fontSize: 8 });
}
const pageNum = data?.pageNumber ?? 1;
const pageCount = data?.pageCount ?? 1;
const pageText = `Seite ${pageNum}/${pageCount}`;
const textWidth = doc.getTextWidth(pageText);
doc.text(pageText, pageWidth - 10 - textWidth, pageHeight - 8);
doc.setTextColor(0, 0, 0);
};
}