claude-code-cchui/src/lib/export/pdf.ts
gaoziman 6c411438e0 feat(导出功能): 添加对话导出核心库
- 实现 ExportData 和 ExportOptions 类型定义
- 实现 Markdown 格式导出器
- 实现 JSON 格式导出器
- 实现 HTML 格式导出器(含完整样式)
- 实现 PDF 格式导出器(客户端生成)
- 提供统一的导出入口和工具函数
2025-12-24 09:40:03 +08:00

204 lines
5.6 KiB
TypeScript
Raw 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.

/**
* 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,
});
// 创建 PDFA4 尺寸)
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';
},
};