Files
cody-crm/frontend/packages/lib-shared/method/exportPdf.ts
wangziqi 72e2110987
Some checks failed
Synchronize to Gitee / repo-sync (push) Has been cancelled
Typos Checking / Spell Check with Typos (push) Has been cancelled
chore: initial commit
2026-06-23 11:56:23 +08:00

220 lines
8.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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