import { Canvg } from 'canvg'; import html2canvas from 'html2canvas-pro'; import JSPDF from 'jspdf'; import { nextTick } from 'vue'; const A4_WIDTH = 595; const A4_HEIGHT = 842; const HEADER_HEIGHT = 16; const FOOTER_HEIGHT = 24; const PAGE_HEIGHT = A4_HEIGHT - FOOTER_HEIGHT - HEADER_HEIGHT; const PDF_WIDTH = A4_WIDTH - 32; // 左右分别 16px 间距 const CONTAINER_WIDTH = 1190; export const SCALE_RATIO = window.devicePixelRatio * 1.5; // 实际每页高度 = PDF页面高度/页面容器宽度与 pdf 宽度的比例(这里比例*SCALE_RATIO 是因为html2canvas截图时生成的是 SCALE_RATIO 倍的清晰度) export const IMAGE_HEIGHT = Math.ceil(PAGE_HEIGHT * (CONTAINER_WIDTH / PDF_WIDTH) * SCALE_RATIO); export const MAX_CANVAS_HEIGHT = IMAGE_HEIGHT * 20; // 一次截图最大高度是 20 页整(过长会无法截完整,出现空白) /** * 替换svg为base64 */ async function inlineSvgUseElements(container: HTMLElement) { const useElements = container.querySelectorAll('use'); useElements.forEach((useElement) => { const href = useElement.getAttribute('xlink:href') || useElement.getAttribute('href'); if (href) { const symbolId = href.substring(1); const symbol = document.getElementById(symbolId); if (symbol) { const svgElement = useElement.closest('svg'); if (svgElement) { svgElement.innerHTML = symbol.innerHTML; } } } }); } /** * 将svg转换为base64 */ async function convertSvgToBase64(svgElement: SVGSVGElement) { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const svgString = new XMLSerializer().serializeToString(svgElement); if (ctx) { const v = Canvg.fromString(ctx, svgString); canvas.width = svgElement.clientWidth; canvas.height = svgElement.clientHeight; await v.render(); } return canvas.toDataURL('image/png'); } /** * 替换svg为base64 */ export async function replaceSvgWithBase64(container: HTMLElement) { await inlineSvgUseElements(container); const svgElements = container.querySelectorAll('.c-icon'); svgElements.forEach(async (svgElement) => { const img = new Image(); img.src = await convertSvgToBase64(svgElement as SVGSVGElement); img.width = svgElement.clientWidth; img.height = svgElement.clientHeight; img.style.marginRight = '8px'; svgElement.parentNode?.replaceChild(img, svgElement); }); } /** * 处理 DOM 元素的分页,防止内容被截断 * @param containerId 容器 ID * @param pageHeight PDF 一页的有效高度 (对应你的 PAGE_HEIGHT 或 IMAGE_HEIGHT / SCALE_RATIO) */ function handlePageBreak(containerId: string, pageHeight: number) { const container = document.getElementById(containerId); if (!container) return; // 获取所有直接子元素,这里假设子元素是不可分割的块(比如一行表格、一个段落) // 根据实际情况,你可能需要更精确的选择器,比如 '.table-row', 'p', 'img' const children = Array.from(container.children) as HTMLElement[]; let currentPageHeight = 0; const nodesToMove: { node: HTMLElement; spacerHeight: number }[] = []; children.forEach((child) => { const childHeight = child.offsetHeight; // 如果元素本身就比一页还高,那没法处理,只能让它截断 if (childHeight > pageHeight) { currentPageHeight += childHeight; // 重置当前页高度计数,近似处理 currentPageHeight = currentPageHeight % pageHeight; return; } // 判断加上当前元素后是否超出一页 if (currentPageHeight + childHeight > pageHeight) { // 计算需要插入的空白高度,把当前元素挤到下一页开头 const spacerHeight = pageHeight - currentPageHeight; nodesToMove.push({ node: child, spacerHeight }); // 当前元素被挤到下一页了,所以新的当前页高度就是它自己的高度 currentPageHeight = childHeight; } else { // 没超出一页,累加高度 currentPageHeight += childHeight; } }); // 统一插入空白占位符 // 需要倒序插入,否则会影响后续元素的 offsetTop 计算(虽然这里用的是累加高度,倒序更安全) for (let i = nodesToMove.length - 1; i >= 0; i--) { const { node, spacerHeight } = nodesToMove[i]; const spacer = document.createElement('div'); spacer.style.height = `${spacerHeight}px`; spacer.style.width = '100%'; // 标记一下,方便导出后移除 spacer.className = 'pdf-page-break-spacer'; spacer.style.backgroundColor = 'transparent'; // 确保透明 container.insertBefore(spacer, node); } return () => { // 返回一个清理函数,在导出完成后移除这些占位符,恢复网页原样 const spacers = container.querySelectorAll('.pdf-page-break-spacer'); spacers.forEach(spacer => spacer.remove()); } } /** * 导出PDF * @param name 文件名 * @param contentId 内容DOM id * @description 通过html2canvas生成图片,再通过jsPDF生成pdf * (使用html2canvas截图时,因为插件有截图极限,超出极限部分会出现截图失败,所以这里设置了MAX_CANVAS_HEIGHT截图高度,然后根据这个截图高度分页截图,然后根据每个截图裁剪每页 pdf 的图片并添加到 pdf 内) */ export default async function exportPDF(name: string, contentId: string, doneCallback?: () => void) { const element = document.getElementById(contentId); if (element) { await replaceSvgWithBase64(element); const totalHeight = element.scrollHeight; // jsPDFs实例 const pdf = new JSPDF({ unit: 'pt', format: 'a4', orientation: 'p', }); pdf.setFontSize(10); // 计算pdf总页数 let totalPages = 0; let position = 0; // 当前截图位置 let pageIndex = 1; let loopTimes = 0; const screenshotList: HTMLCanvasElement[] = []; // 创建图片裁剪画布 const cropCanvas = document.createElement('canvas'); cropCanvas.width = CONTAINER_WIDTH * SCALE_RATIO; // 因为截图时放大了 SCALE_RATIO 倍,所以这里也要放大 cropCanvas.height = IMAGE_HEIGHT; const tempContext = cropCanvas.getContext('2d', { willReadFrequently: true }); // 这里是大的分页,也就是截图画布的分页 while (position < totalHeight) { // 截图高度 const screenshotHeight = Math.min(MAX_CANVAS_HEIGHT, totalHeight - position); // eslint-disable-next-line no-await-in-loop const canvas = await html2canvas(element, { x: 0, y: position, width: CONTAINER_WIDTH, height: screenshotHeight, backgroundColor: '#f9f9fe', scale: SCALE_RATIO, // 缩放增加清晰度 }); screenshotList.push(canvas); position += screenshotHeight; totalPages += Math.ceil(canvas.height / IMAGE_HEIGHT); loopTimes++; } totalPages -= loopTimes - 1; // 减去多余的页数 // 生成 PDF screenshotList.forEach((_canvas) => { const canvasWidth = _canvas.width; const canvasHeight = _canvas.height; const pages = Math.ceil(canvasHeight / IMAGE_HEIGHT); for (let i = 1; i <= pages; i++) { // 这里是小的分页,是 pdf 的每一页 const pagePosition = (i - 1) * IMAGE_HEIGHT; if (tempContext) { if (pageIndex === totalPages) { // 填充背景颜色为白色 tempContext.fillStyle = '#ffffff'; tempContext.fillRect(0, 0, cropCanvas.width, cropCanvas.height); } // 将大分页的画布图片裁剪成pdf 页面内容大小,并渲染到临时画布上 tempContext.drawImage(_canvas, 0, -pagePosition, canvasWidth, canvasHeight); const tempCanvasData = cropCanvas.toDataURL('image/jpeg', 1); // 将临时画布图片渲染到 pdf 上 pdf.addImage(tempCanvasData, 'PNG', 16, 16, PDF_WIDTH, PAGE_HEIGHT); } cropCanvas.remove(); pdf.text( `${pageIndex} / ${totalPages}`, pdf.internal.pageSize.width / 2 - 10, pdf.internal.pageSize.height - 4 ); if (i < pages) { pdf.addPage(); pageIndex++; } } _canvas.remove(); }); pdf.save(`${name}.pdf`); nextTick(() => { doneCallback?.(); }); } }