first commit
This commit is contained in:
179
ui/lib/utils/pdf.ts
Normal file
179
ui/lib/utils/pdf.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 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`);
|
||||
}
|
||||
Reference in New Issue
Block a user