import { jsPDF } from 'jspdf'; import html2canvas from 'html2canvas'; import { revealPdfHeaders } from '../components/shared/PdfReportHeader.tsx'; 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. 49-day Hitung Ayam) 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 a card to a multi-page A4 PDF without slicing through its contents. * * The previous approach placed ONE tall image on every page at a shifting * negative offset, so a page break landed wherever 277mm happened to fall — * straight through the middle of a chart. This version cuts the canvas at * declared block boundaries instead: mark the parts that must stay whole with * `data-pdf-block`, and a break is pushed up to the nearest boundary that fits. * * Boundaries are measured inside `onclone`, because html2canvas lays the clone * out in its own iframe at `windowWidth` — offsets taken from the live DOM would * describe a different layout. */ export const exportCardToPdf = async ( element: HTMLElement, filename: string, options?: { widthPx?: number } ): Promise => { const widthPx = options?.widthPx ?? 800; const scale = 1.5; const jpegQuality = 0.82; /** Offsets, in cloned-CSS pixels, where a page may safely break. */ 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'; revealPdfHeaders(clonedElement); // Side-by-side small multiples do not fit an A4 column; stack them so // each chart keeps a readable width in print. clonedElement.querySelectorAll('[data-pdf-stack]').forEach((node) => { node.style.display = 'block'; }); 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); };