- 实现 ExportData 和 ExportOptions 类型定义 - 实现 Markdown 格式导出器 - 实现 JSON 格式导出器 - 实现 HTML 格式导出器(含完整样式) - 实现 PDF 格式导出器(客户端生成) - 提供统一的导出入口和工具函数
204 lines
5.6 KiB
TypeScript
204 lines
5.6 KiB
TypeScript
/**
|
||
* PDF 导出器
|
||
* 使用 jspdf + html2canvas 在客户端生成 PDF
|
||
* 使用 iframe 方案确保HTML样式正确渲染
|
||
*/
|
||
|
||
import type { ExportData, ExportOptions, Exporter } from './types';
|
||
import { htmlExporter } from './html';
|
||
|
||
/**
|
||
* 在客户端生成 PDF(使用 iframe 方案)
|
||
* 这个函数需要在浏览器环境中调用
|
||
*/
|
||
export async function generatePdfInBrowser(
|
||
data: ExportData,
|
||
options: ExportOptions
|
||
): Promise<Blob> {
|
||
// 动态导入 jspdf 和 html2canvas(仅在客户端)
|
||
const [{ default: jsPDF }, { default: html2canvas }] = await Promise.all([
|
||
import('jspdf'),
|
||
import('html2canvas'),
|
||
]);
|
||
|
||
// 首先生成 HTML 内容
|
||
const htmlContent = await htmlExporter.export(data, options) as string;
|
||
|
||
// 创建隐藏的 iframe 来渲染完整的 HTML 文档
|
||
const iframe = document.createElement('iframe');
|
||
iframe.style.cssText = `
|
||
position: fixed;
|
||
left: -9999px;
|
||
top: 0;
|
||
width: 900px;
|
||
height: 100%;
|
||
border: none;
|
||
visibility: hidden;
|
||
`;
|
||
document.body.appendChild(iframe);
|
||
|
||
try {
|
||
// 等待 iframe 准备就绪并写入 HTML 内容
|
||
await new Promise<void>((resolve, reject) => {
|
||
const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
|
||
if (!iframeDoc) {
|
||
reject(new Error('无法访问 iframe 文档'));
|
||
return;
|
||
}
|
||
|
||
// 写入完整的 HTML 文档
|
||
iframeDoc.open();
|
||
iframeDoc.write(htmlContent);
|
||
iframeDoc.close();
|
||
|
||
// 等待 iframe 加载完成
|
||
iframe.onload = () => resolve();
|
||
|
||
// 设置超时,防止 onload 不触发
|
||
setTimeout(() => resolve(), 1000);
|
||
});
|
||
|
||
// 获取 iframe 中的文档
|
||
const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
|
||
if (!iframeDoc) {
|
||
throw new Error('无法访问 iframe 文档');
|
||
}
|
||
|
||
// 获取要渲染的容器(.container 或 body)
|
||
const container = iframeDoc.querySelector('.container') || iframeDoc.body;
|
||
|
||
// 等待图片加载
|
||
const images = container.querySelectorAll('img');
|
||
await Promise.all(
|
||
Array.from(images).map(
|
||
(img) =>
|
||
new Promise<void>((resolve) => {
|
||
if ((img as HTMLImageElement).complete) {
|
||
resolve();
|
||
} else {
|
||
(img as HTMLImageElement).onload = () => resolve();
|
||
(img as HTMLImageElement).onerror = () => resolve();
|
||
}
|
||
})
|
||
)
|
||
);
|
||
|
||
// 额外等待一下确保样式完全渲染
|
||
await new Promise(resolve => setTimeout(resolve, 300));
|
||
|
||
// 使用 html2canvas 渲染 iframe 中的内容
|
||
const canvas = await html2canvas(container as HTMLElement, {
|
||
scale: 2,
|
||
useCORS: true,
|
||
allowTaint: true,
|
||
backgroundColor: '#F8F9FA', // 与页面背景色一致
|
||
logging: false,
|
||
width: 900,
|
||
windowWidth: 900,
|
||
});
|
||
|
||
// 创建 PDF(A4 尺寸)
|
||
const pdf = new jsPDF({
|
||
orientation: 'portrait',
|
||
unit: 'mm',
|
||
format: 'a4',
|
||
});
|
||
|
||
const pdfWidth = pdf.internal.pageSize.getWidth();
|
||
const pdfHeight = pdf.internal.pageSize.getHeight();
|
||
|
||
// 计算图片在 PDF 中的尺寸
|
||
// 保持宽度铺满,按比例计算高度
|
||
const imgWidth = canvas.width;
|
||
const imgHeight = canvas.height;
|
||
|
||
// 图片缩放到 PDF 宽度
|
||
const scaledWidth = pdfWidth;
|
||
const scaledHeight = (imgHeight * pdfWidth) / imgWidth;
|
||
|
||
// 计算需要多少页
|
||
const totalPages = Math.ceil(scaledHeight / pdfHeight);
|
||
|
||
// 分页添加内容
|
||
for (let page = 0; page < totalPages; page++) {
|
||
if (page > 0) {
|
||
pdf.addPage();
|
||
}
|
||
|
||
// 计算当前页在原始图片中的位置
|
||
const sourceY = (page * pdfHeight * imgWidth) / pdfWidth;
|
||
const sourceHeight = Math.min(
|
||
(pdfHeight * imgWidth) / pdfWidth,
|
||
imgHeight - sourceY
|
||
);
|
||
|
||
// 如果没有更多内容,跳过
|
||
if (sourceHeight <= 0) break;
|
||
|
||
// 创建当前页的裁剪画布
|
||
const pageCanvas = document.createElement('canvas');
|
||
pageCanvas.width = imgWidth;
|
||
pageCanvas.height = Math.ceil(sourceHeight);
|
||
|
||
const ctx = pageCanvas.getContext('2d');
|
||
if (ctx) {
|
||
// 从原始画布中裁剪当前页的内容
|
||
ctx.drawImage(
|
||
canvas,
|
||
0, // 源 x
|
||
Math.floor(sourceY), // 源 y
|
||
imgWidth, // 源宽度
|
||
Math.ceil(sourceHeight), // 源高度
|
||
0, // 目标 x
|
||
0, // 目标 y
|
||
imgWidth, // 目标宽度
|
||
Math.ceil(sourceHeight) // 目标高度
|
||
);
|
||
|
||
// 将裁剪后的画布添加到 PDF
|
||
const pageImgData = pageCanvas.toDataURL('image/png');
|
||
const pageScaledHeight = (sourceHeight * pdfWidth) / imgWidth;
|
||
|
||
pdf.addImage(
|
||
pageImgData,
|
||
'PNG',
|
||
0,
|
||
0,
|
||
pdfWidth,
|
||
pageScaledHeight
|
||
);
|
||
}
|
||
}
|
||
|
||
// 返回 Blob
|
||
return pdf.output('blob');
|
||
} finally {
|
||
// 清理 iframe
|
||
document.body.removeChild(iframe);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* PDF 导出器(服务端占位符)
|
||
* 实际的 PDF 生成在客户端通过 generatePdfInBrowser 函数执行
|
||
*/
|
||
export const pdfExporter: Exporter = {
|
||
async export(data: ExportData, options: ExportOptions): Promise<Blob> {
|
||
// 检查是否在浏览器环境
|
||
if (typeof window !== 'undefined') {
|
||
return generatePdfInBrowser(data, options);
|
||
}
|
||
|
||
// 在服务端,返回一个错误提示
|
||
throw new Error('PDF export must be performed on the client side');
|
||
},
|
||
|
||
getContentType(): string {
|
||
return 'application/pdf';
|
||
},
|
||
|
||
getFileExtension(): string {
|
||
return 'pdf';
|
||
},
|
||
};
|