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?.();
});
}
}