Files
bifrost/ui/lib/utils/pdf.ts
Beyhan Oğur 880f412e2c first commit
2026-04-26 21:52:23 +03:00

179 lines
5.3 KiB
TypeScript

/**
* Reusable PDF export utility.
*
* Captures an array of DOM sections as images via html2canvas and composes
* them into a multi-page A4 PDF with jsPDF. Libraries are dynamically
* imported so they only load when actually needed.
*
* Usage:
* await generatePdf(
* [{ element: el, label: "Overview" }, ...],
* "dashboard-export",
* );
*/
export interface PdfSection {
/** DOM element to capture */
element: HTMLElement;
/** Optional heading printed above the section in the PDF */
label?: string;
}
export interface PdfBranding {
/** Path to logo image (relative to public dir, e.g. "/bifrost-logo.webp") */
logoSrc: string;
/** Text shown next to the logo */
text?: string;
}
export interface PdfOptions {
/** Canvas scale factor (default 1.5) */
scale?: number;
/** JPEG quality 0-1 (default 0.92) */
quality?: number;
/** Page margin in mm (default 10) */
margin?: number;
/** Page orientation (default "portrait") */
orientation?: "portrait" | "landscape";
/** Branding shown at the bottom-right of every page */
branding?: PdfBranding;
}
/** Load an image and return its data URL + natural dimensions. */
async function loadImage(src: string): Promise<{ dataUrl: string; width: number; height: number }> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext("2d");
ctx?.drawImage(img, 0, 0);
resolve({
dataUrl: canvas.toDataURL("image/png"),
width: img.naturalWidth,
height: img.naturalHeight,
});
};
img.onerror = reject;
img.src = src;
});
}
export async function generatePdf(sections: PdfSection[], filename: string, options: PdfOptions = {}): Promise<void> {
const { scale = 1.5, quality = 0.92, margin = 10, orientation = "portrait", branding } = options;
const [{ default: html2canvas }, { jsPDF }] = await Promise.all([import("html2canvas-pro"), import("jspdf")]);
// Pre-load branding logo if configured
let logoData: { dataUrl: string; width: number; height: number } | null = null;
if (branding?.logoSrc) {
try {
logoData = await loadImage(branding.logoSrc);
} catch {
// Logo failed to load — continue without it
}
}
const pdf = new jsPDF({ orientation, unit: "mm", format: "a4" });
const pageWidth = pdf.internal.pageSize.getWidth();
const pageHeight = pdf.internal.pageSize.getHeight();
const contentWidth = pageWidth - margin * 2;
let cursorY = margin;
for (let i = 0; i < sections.length; i++) {
const { element, label } = sections[i];
// Yield between sections so the UI stays responsive
await new Promise((r) => setTimeout(r, 0));
const canvas = await html2canvas(element, {
scale,
useCORS: true,
logging: false,
backgroundColor: "#ffffff",
});
const imgHeight = (canvas.height * contentWidth) / canvas.width;
const headingHeight = label ? 10 : 0;
// Start a new page if the heading + a meaningful chunk won't fit
if (cursorY + headingHeight + 20 > pageHeight - margin) {
pdf.addPage();
cursorY = margin;
}
if (label) {
pdf.setFontSize(14);
pdf.setTextColor(30, 30, 30);
pdf.text(label, margin, cursorY + 5);
cursorY += headingHeight;
}
// Slice the captured image into page-sized chunks
let yOffset = 0;
while (yOffset < imgHeight) {
const remainingOnPage = pageHeight - cursorY - margin;
const sliceHeight = Math.min(remainingOnPage, imgHeight - yOffset);
const sourceY = (yOffset / imgHeight) * canvas.height;
const sourceH = (sliceHeight / imgHeight) * canvas.height;
const sliceCanvas = document.createElement("canvas");
sliceCanvas.width = canvas.width;
sliceCanvas.height = Math.round(sourceH);
const ctx = sliceCanvas.getContext("2d");
if (ctx) {
ctx.drawImage(canvas, 0, sourceY, canvas.width, sourceH, 0, 0, canvas.width, Math.round(sourceH));
const sliceImg = sliceCanvas.toDataURL("image/jpeg", quality);
pdf.addImage(sliceImg, "JPEG", margin, cursorY, contentWidth, sliceHeight);
}
cursorY += sliceHeight;
yOffset += sliceHeight;
if (yOffset < imgHeight) {
pdf.addPage();
cursorY = margin;
}
}
// Small gap between sections
cursorY += 4;
}
// Stamp branding on every page
if (branding && (logoData || branding.text)) {
const totalPages = pdf.getNumberOfPages();
const brandingText = branding.text ?? "";
const logoH = 3.5; // logo height in mm
const logoW = logoData ? (logoData.width / logoData.height) * logoH : 0;
const gap = logoData && brandingText ? 1.5 : 0;
pdf.setFontSize(8);
pdf.setTextColor(150, 150, 150);
const textW = brandingText ? pdf.getTextWidth(brandingText) : 0;
const totalW = textW + gap + logoW;
for (let p = 1; p <= totalPages; p++) {
pdf.setPage(p);
const x = pageWidth - margin - totalW;
const y = pageHeight - margin + 2;
if (brandingText) {
pdf.setFontSize(8);
pdf.setTextColor(150, 150, 150);
pdf.text(brandingText, x, y + logoH / 2 + 1);
}
if (logoData) {
pdf.addImage(logoData.dataUrl, "PNG", x + textW + gap, y, logoW, logoH);
}
}
}
const now = new Date();
const dateStamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
pdf.save(`${filename}-${dateStamp}.pdf`);
}