147 lines
5.1 KiB
TypeScript
147 lines
5.1 KiB
TypeScript
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<void> => {
|
|
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<HTMLElement>('[data-pdf-stack]').forEach((node) => {
|
|
node.style.display = 'block';
|
|
});
|
|
|
|
const top = clonedElement.getBoundingClientRect().top;
|
|
clonedElement.querySelectorAll<HTMLElement>('[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);
|
|
};
|