import { jsPDF } from 'jspdf'; import html2canvas from 'html2canvas'; const PAGE_WIDTH_MM = 210; const PAGE_HEIGHT_MM = 297; const MARGIN_MM = 10; const CONTENT_WIDTH_MM = PAGE_WIDTH_MM - MARGIN_MM * 2; const USABLE_HEIGHT_MM = PAGE_HEIGHT_MM - MARGIN_MM * 2; export const usableHeightCssPx = (widthPx: number): number => (USABLE_HEIGHT_MM / CONTENT_WIDTH_MM) * widthPx; /** * Safe page-break offsets for `data-pdf-block` nodes. * * A block's start is only a break point when the whole block fits on one page. * Oversized tables (e.g. a 49-day report) must start under the header instead * of being deferred to page 2. */ export function pdfBlockBreakOffsets( blocks: Array<{ top: number; bottom: number }>, usableHeightPx: number ): number[] { const breaks: number[] = []; for (const block of blocks) { const height = block.bottom - block.top; if (height <= usableHeightPx && block.top > 0) { breaks.push(block.top); } if (block.bottom > 0) { breaks.push(block.bottom); } } return [...new Set(breaks.map((value) => Math.round(value)))].sort((a, b) => a - b); } export function choosePdfSliceHeight( cursor: number, canvasHeight: number, usableHeightPx: number, breakPoints: number[] ): number { const remaining = canvasHeight - cursor; if (remaining <= usableHeightPx) return remaining; const limit = cursor + usableHeightPx; const candidate = [...breakPoints].reverse().find((point) => point > cursor && point <= limit); return candidate ? candidate - cursor : usableHeightPx; } /** * Render an element to a multi-page A4 PDF. * * html2canvas photographs the whole report once, then the canvas is sliced at * `data-pdf-block` boundaries (so a page break never cuts through a table) and * each slice is written as a compressed JPEG page. */ export async function exportElementToPdf( element: HTMLElement, filename: string, options?: { widthPx?: number } ): Promise { const widthPx = options?.widthPx ?? 800; const scale = 1.5; const jpegQuality = 0.82; const blockRects: Array<{ top: number; bottom: number }> = []; const canvas = await html2canvas(element, { scale, useCORS: true, backgroundColor: '#ffffff', windowWidth: widthPx, onclone: (_clonedDoc, clonedElement) => { clonedElement.style.width = `${widthPx}px`; clonedElement.style.maxWidth = `${widthPx}px`; clonedElement.style.margin = '0'; const top = clonedElement.getBoundingClientRect().top; clonedElement.querySelectorAll('[data-pdf-block]').forEach((node) => { const rect = node.getBoundingClientRect(); blockRects.push({ top: rect.top - top, bottom: rect.bottom - top }); }); }, }); const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4', compress: true }); const pageWidthMm = doc.internal.pageSize.getWidth(); const pageHeightMm = doc.internal.pageSize.getHeight(); const contentWidthMm = pageWidthMm - MARGIN_MM * 2; const usableHeightMm = pageHeightMm - MARGIN_MM * 2; const mmPerPx = contentWidthMm / canvas.width; const usableHeightPx = usableHeightMm / mmPerPx; const safeBreaks = pdfBlockBreakOffsets(blockRects, usableHeightCssPx(widthPx)); // Clone-CSS px -> canvas px, de-duplicated and ordered. const breakPoints = [...new Set(safeBreaks.map((value) => Math.round(value * scale)))] .filter((value) => value > 0 && value < canvas.height) .sort((a, b) => a - b); let cursor = 0; let firstPage = true; while (cursor < canvas.height) { const sliceHeight = choosePdfSliceHeight(cursor, canvas.height, usableHeightPx, breakPoints); if (sliceHeight <= 0) break; const slice = document.createElement('canvas'); slice.width = canvas.width; slice.height = Math.max(1, Math.round(sliceHeight)); const ctx = slice.getContext('2d'); if (!ctx) break; ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, slice.width, slice.height); ctx.drawImage(canvas, 0, cursor, canvas.width, slice.height, 0, 0, canvas.width, slice.height); if (!firstPage) doc.addPage(); doc.addImage( slice.toDataURL('image/jpeg', jpegQuality), 'JPEG', MARGIN_MM, MARGIN_MM, contentWidthMm, slice.height * mmPerPx ); firstPage = false; cursor += slice.height; } doc.save(filename); }