Files
2026-08-26 13:46:05 +07:00

132 lines
4.3 KiB
TypeScript

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<void> {
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<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);
}