前言:为什么需要前端 PDF 导出?
在现代 Web 开发中,将网页内容导出为 PDF 是一项常见但颇具挑战的需求。无论是生成报告、发票、合同还是数据可视化图表,前端 PDF 导出都能为用户提供便捷的离线查看和打印体验。传统的 PDF 生成通常需要在后端完成,但随着前端技术的发展,现在我们可以直接在浏览器中实现高质量的 PDF 导出功能。
本文将深入探讨两个核心工具——JSPDF 和 HTML2Canvas,通过详细的原理分析、实战案例和最佳实践,帮助您掌握前端 PDF 导出的核心技术。
第一部分:工具介绍与技术选型
1.1 JSPDF:轻量级 PDF 生成库
JSPDF是一个纯 JavaScript 实现的 PDF 生成库,它不依赖任何服务器端组件,完全在客户端运行。这个库最初发布于 2010 年,经过多年的发展,已经成为前端 PDF 生成的事实标准。
主要特性:
- 纯客户端运行:不需要服务器支持
- 轻量级:压缩后仅约 60KB
- 丰富的 API:支持文本、图片、形状、字体等
- 多语言支持:包括中文在内的多种语言
- 插件系统:可通过插件扩展功能
基本使用:
// 最简单的 PDF 生成示例
const doc = new jsPDF();
// 添加文本
doc.text('Hello World!', 10, 10);
// 保存 PDF
doc.save('document.pdf');
1.2 HTML2Canvas:网页截图神器
HTML2Canvas是一个强大的 JavaScript 库,它可以将 HTML 元素渲染为 Canvas。虽然名字中包含"canvas",但它实际上是通过模拟浏览器渲染引擎来实现 HTML 到 Canvas 的转换。
工作原理:
- 解析目标元素的 CSS 样式
- 克隆 DOM 节点并应用样式
- 使用 Canvas 2D API 绘制每个节点
- 处理图片、渐变、阴影等复杂样式
技术特点:
- 基于 Canvas:输出为标准 Canvas 元素
- 样式支持:支持大部分 CSS 属性
- 跨域处理:可以配置跨域图片加载
- 异步处理:使用 Promise API
1.3 为什么选择这两个库?
组合优势:
- JSPDF 擅长 PDF 操作:创建、编辑、保存 PDF 文档
- HTML2Canvas 擅长网页渲染:准确捕获网页视觉状态
- 完美互补:HTML2Canvas 生成图片,JSPDF 将图片转为 PDF
适用场景对比:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 简单文本 PDF | 纯 JSPDF | 轻量、快速、代码简洁 |
| 表格报表 | JSPDF + 表格插件 | 结构化数据友好 |
| 复杂网页截图 | HTML2Canvas + JSPDF | 保留视觉样式 |
| 大量数据导出 | 后端生成 | 性能更好 |
第二部分:JSPDF 深度解析
2.1 核心 API 详解
2.1.1 初始化与页面设置
// 创建 PDF 实例
const doc = new jsPDF({
orientation: 'p', // 方向:p-纵向,l-横向
unit: 'mm', // 单位:pt, mm, cm, in
format: 'a4', // 格式:a3, a4, a5, letter 等
compress: true, // 是否压缩
precision: 16 // 浮点数精度
});
// 页面尺寸获取
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
// 添加新页面
doc.addPage();
// 删除页面
doc.deletePage(2);
// 设置页面背景色
doc.setFillColor(240, 240, 240);
doc.rect(0, 0, pageWidth, pageHeight, 'F');
2.1.2 文本处理
// 基本文本设置
doc.setFont("helvetica"); // 字体
doc.setFontSize(16); // 字号
doc.setTextColor(0, 0, 0); // 颜色
doc.setFontStyle("bold"); // 样式:normal, bold, italic, bolditalic
// 添加文本
doc.text("单行文本", x, y);
doc.text("多行文本", x, y, {
maxWidth: 100, // 最大宽度
align: 'left', // 对齐:left, center, right
baseline: 'top' // 基线:top, middle, bottom
});
// 自动换行文本
const lines = doc.splitTextToSize(
"这是一个很长的文本,需要自动换行显示",
pageWidth - 40
);
doc.text(lines, 20, 30);
// 多行文本带行高
const text = "第一行\n第二行\n第三行";
doc.text(text, 20, 50, { lineHeightFactor: 1.5 });
// 旋转文本
doc.textWithRotation("旋转文本", 100, 100, 45); // 旋转 45 度
2.1.3 中文支持
// 添加中文字体
// 1. 首先需要加载字体文件
const font = 'AAEAAAAQAQAABAAAR0RFRgE...'; // Base64 编码的字体
// 2. 添加到 JSPDF
doc.addFileToVFS('chinese-normal.ttf', font);
doc.addFont('chinese-normal.ttf', 'chinese', 'normal');
// 3. 使用中文字体
doc.setFont('chinese');
doc.text('中文字体测试', 20, 20);
// 或者使用内置的亚洲字体
doc.setFont('simhei');
doc.setFontSize(16);
doc.text('使用黑体显示中文', 20, 40);
2.1.4 图片处理
// 添加图片
const imgData = 'data:image/png;base64,iVBORw0KGgo...';
doc.addImage(imgData, 'PNG', 15, 40, 180, 160);
// 完整参数
doc.addImage({
imageData: imgData,
x: 15,
y: 40,
width: 180,
height: 160,
compression: 'FAST', // 压缩等级:NONE, FAST, MEDIUM, SLOW
rotation: 0, // 旋转角度
alias: 'myImage', // 图片别名
format: 'PNG' // 格式:JPEG, PNG
});
// 获取图片属性
const imgProps = doc.getImageProperties(imgData);
console.log(`图片尺寸:${imgProps.width}x${imgProps.height}`);
// 图片缩放模式
const scaleToFit = (imgWidth, imgHeight, maxWidth, maxHeight) => {
const widthRatio = maxWidth / imgWidth;
const heightRatio = maxHeight / imgHeight;
const ratio = Math.min(widthRatio, heightRatio);
return { width: imgWidth * ratio, height: imgHeight * ratio };
};
2.1.5 图形绘制
// 线条
doc.setLineWidth(0.5); // 线宽
doc.setDrawColor(0, 0, 255); // 线条颜色
doc.line(20, 20, 100, 20); // 直线
// 矩形
doc.setFillColor(255, 0, 0); // 填充色
doc.rect(20, 30, 50, 30, 'F'); // 填充矩形
doc.rect(80, 30, 50, 30, 'S'); // 描边矩形
doc.rect(140, 30, 50, 30, 'FD'); // 填充 + 描边
// 圆形/椭圆
doc.circle(60, 80, 20, 'FD'); // 圆形
doc.ellipse(120, 80, 30, 20, 'FD'); // 椭圆
// 多边形
const triangle = [[100, 120], [120, 100], [140, 120]];
doc.setFillColor(0, 255, 0);
doc.poly(triangle, 'F'); // 路径
doc.setDrawColor(128, 0, 128);
doc.setLineWidth(2);
doc.path('M 160 100 L 180 120 L 160 140 Z'); // SVG 路径语法
// 虚线
doc.setLineDashPattern([5, 5], 0); // 5 像素实线,5 像素间隔
doc.line(20, 150, 200, 150);
doc.setLineDashPattern([], 0); // 恢复实线
2.1.6 表格生成
// 使用 autoTable 插件
import jsPDF from 'jspdf';
import 'jspdf-autotable';
const doc = new jsPDF();
// 简单表格
doc.autoTable({
head: [['ID', '姓名', '年龄', '城市']],
body: [
['1', '张三', '28', '北京'],
['2', '李四', '32', '上海'],
['3', '王五', '25', '广州']
],
startY: 20,
theme: 'grid', // 主题:striped, grid, plain
styles: { fontSize: 10, cellPadding: 3, overflow: 'linebreak' },
headStyles: { fillColor: [22, 160, 133], textColor: 255, fontStyle: 'bold' },
columnStyles: {
0: { cellWidth: 20 }, // 第一列宽度
1: { cellWidth: 40 }
},
margin: { top: 20 },
didDrawPage: function(data) {
// 每页绘制的回调
doc.setFontSize(10);
doc.text(`第 ${data.pageNumber} 页`, data.settings.margin.left, doc.internal.pageSize.height - 10);
}
});
// 多页表格
const largeData = [];
for (let i = 0; i < 100; i++) {
largeData.push([i + 1, `用户${i}`, Math.floor(Math.random() * 50 + 18), '城市']);
}
doc.autoTable({
head: [['ID', '姓名', '年龄', '城市']],
body: largeData,
startY: 20,
pageBreak: 'auto', // 自动分页
rowPageBreak: 'avoid', // 避免行内分页
showHead: 'everyPage' // 每页都显示表头
});
2.2 高级功能
2.2.1 页码和页眉页脚
function addHeaderFooter(doc, totalPages) {
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
// 页眉
doc.setFontSize(10);
doc.setTextColor(100, 100, 100);
doc.text('公司名称', 20, 15);
doc.text(new Date().toLocaleDateString(), pageWidth - 20, 15, { align: 'right' });
// 页脚
doc.setFontSize(9);
doc.text(`第 ${doc.internal.getCurrentPageInfo().pageNumber} 页 / 共 ${totalPages} 页`, pageWidth / 2, pageHeight - 10, { align: 'center' });
// 页脚线
doc.setDrawColor(200, 200, 200);
doc.line(20, pageHeight - 15, pageWidth - 20, pageHeight - 15);
}
// 在每页绘制时调用
const totalPages = 5;
for (let i = 1; i <= totalPages; i++) {
if (i > 1) doc.addPage();
addHeaderFooter(doc, totalPages);
// 添加内容...
}
2.2.2 水印功能
function addWatermark(doc, text) {
const pageWidth = doc.internal.pageSize.getWidth();
const pageHeight = doc.internal.pageSize.getHeight();
// 保存当前状态
doc.saveGraphicsState();
// 设置水印样式
doc.setFontSize(40);
doc.setTextColor(200, 200, 200, 0.3); // 半透明灰色
doc.setFont('helvetica', 'bold');
// 计算文本宽度
const textWidth = doc.getStringUnitWidth(text) * doc.internal.getFontSize() / doc.internal.scaleFactor;
// 旋转和重复水印
const angle = -45 * Math.PI / 180;
const spacing = 150;
for (let x = -pageWidth; x < pageWidth * 2; x += spacing) {
for (let y = -pageHeight; y < pageHeight * 2; y += spacing) {
doc.saveGraphicsState();
doc.translate(x, y);
doc.rotate(angle, { origin: [0, 0] });
doc.text(text, 0, 0);
doc.restoreGraphicsState();
}
}
doc.restoreGraphicsState();
}
// 使用水印
const doc = new jsPDF();
doc.text('文档内容', 20, 20);
addWatermark(doc, '机密文件');
2.2.3 链接和书签
// 内部链接
doc.textWithLink('点击跳转到第 2 页', 20, 20, { pageNumber: 2 });
// 外部链接
doc.textWithLink('访问官网', 20, 40, { url: 'https://example.com' });
// 邮件链接
doc.textWithLink('发送邮件', 20, 60, { url: 'mailto:[email protected]' });
// 添加书签
doc.outline.add(null, "封面", 1);
doc.outline.add(null, "第一章", 2);
doc.outline.add(1, "1.1 简介", 3); // 子书签
// 可点击目录
const tocY = 30;
const chapters = [
{ title: "第一章 简介", page: 1 },
{ title: "第二章 基础", page: 3 },
{ title: "第三章 高级", page: 5 }
];
chapters.forEach((chapter, i) => {
doc.text(chapter.title, 20, tocY + i * 10);
doc.textWithLink(`第${chapter.page}页`, 150, tocY + i * 10, { pageNumber: chapter.page });
});
第三部分:HTML2Canvas 深度解析
3.1 核心原理与架构
HTML2Canvas 的工作原理相当复杂,它本质上是一个简化的浏览器渲染引擎。让我们深入了解它的工作流程:

3.1.2 核心组件
// HTML2Canvas 内部结构示意
class HTML2CanvasRenderer {
constructor(element, options) {
this.element = element;
this.options = options;
this.canvas = document.createElement('canvas');
this.ctx = this.canvas.getContext('2d');
}
async render() {
// 1. 解析 DOM 树
const root = this.parseDOM(this.element);
// 2. 计算样式
this.calculateStyles(root);
// 3. 创建渲染树
const renderTree = this.createRenderTree(root);
// 4. 布局计算
this.calculateLayout(renderTree);
// 5. 绘制
await this.draw(renderTree);
return this.canvas;
}
// ... 其他方法
}
3.2 配置参数详解
const options = {
// 基本配置
allowTaint: false, // 是否允许污染 canvas
useCORS: true, // 是否使用 CORS 加载图片
backgroundColor: '#ffffff', // 背景色
scale: 2, // 缩放比例(设备像素比)
width: null, // 自定义宽度
height: null, // 自定义高度
// 日志和调试
logging: true, // 启用日志
onclone: null, // DOM 克隆后的回调
// 图像配置
imageTimeout: 15000, // 图片加载超时 (ms)
proxy: null, // 代理服务器 URL
removeContainer: true, // 是否移除临时容器
// 高级配置
foreignObjectRendering: false, // 使用 foreignObject(SVG)
ignoreElements: (element) => false, // 忽略特定元素
onrendered: null, // 渲染完成回调
// 性能配置
async: true, // 异步渲染
cacheBust: false, // 缓存破坏
letterRendering: false, // 文字渲染优化
// Canvas 配置
canvas: null, // 使用现有 canvas
x: 0, // 水平偏移
y: 0, // 垂直偏移
scrollX: 0, // 水平滚动
scrollY: 0, // 垂直滚动
windowWidth: window.innerWidth, // 窗口宽度
windowHeight: window.innerHeight // 窗口高度
};
第四部分:JSPDF 与 HTML2Canvas 集成实战
4.1 基础集成方案
// 基础集成函数
async function exportToPDF(elementId, fileName = 'document.pdf', options = {}) {
// 默认配置
const defaultOptions = {
pdf: { orientation: 'p', unit: 'mm', format: 'a4', compress: true },
html2canvas: { scale: 2, useCORS: true, logging: false },
margin: { top: 15, right: 15, bottom: 15, left: 15 }
};
// 合并配置
const config = {
pdf: { ...defaultOptions.pdf, ...options.pdf },
html2canvas: { ...defaultOptions.html2canvas, ...options.html2canvas },
margin: { ...defaultOptions.margin, ...options.margin }
};
try {
// 1. 获取目标元素
const element = document.getElementById(elementId);
if (!element) {
throw new Error(`元素 #${elementId} 未找到`);
}
// 2. 使用 html2canvas 生成 canvas
console.log('开始生成 canvas...');
const canvas = await html2canvas(element, config.html2canvas);
// 3. 获取图片数据
const imgData = canvas.toDataURL('image/png', 1.0);
const imgProps = { width: canvas.width, height: canvas.height };
// 4. 计算 PDF 尺寸
const pdfWidth = config.pdf.format === 'a4' ? 210 : 297; // A4 宽度 210mm
const pdfHeight = config.pdf.format === 'a4' ? 297 : 210; // A4 高度 297mm
// 可用宽度 = PDF 宽度 - 左右边距
const usableWidth = pdfWidth - config.margin.left - config.margin.right;
// 5. 计算缩放比例
const pixelsPerMM = imgProps.width / usableWidth;
const scale = usableWidth / imgProps.width;
const imgHeightMM = imgProps.height * scale;
// 6. 创建 PDF
console.log('创建 PDF 文档...');
const doc = new jsPDF(config.pdf);
// 7. 处理分页
const pageHeight = pdfHeight - config.margin.top - config.margin.bottom;
let position = 0;
if (imgHeightMM <= pageHeight) {
// 一页能放下
doc.addImage(imgData, 'PNG', config.margin.left, config.margin.top, usableWidth, imgHeightMM);
} else {
// 需要分页
while (position < imgHeightMM) {
if (position > 0) {
doc.addPage();
}
doc.addImage(imgData, 'PNG', config.margin.left, config.margin.top - position, usableWidth, imgHeightMM);
position += pageHeight;
}
}
// 8. 保存 PDF
doc.save(fileName);
console.log('PDF 导出完成');
return doc;
} catch (error) {
console.error('导出 PDF 失败:', error);
throw error;
}
}
// 使用示例
document.getElementById('export-btn').addEventListener('click', async () => {
try {
await exportToPDF('content', '报告.pdf', {
pdf: { format: 'a4', orientation: 'portrait' },
html2canvas: { scale: 3, // 更高清
backgroundColor: '#ffffff'
},
margin: { top: 20, right: 20, bottom: 20, left: 20 }
});
} catch (error) {
alert('导出失败:' + error.message);
}
});
4.2 高级集成:智能分页与表格保护
class SmartPDFExporter {
constructor(options = {}) {
this.options = this.mergeOptions(options);
this.pageBreaks = [];
}
mergeOptions(options) {
const defaults = {
elementId: null,
fileName: 'export.pdf',
pdf: { orientation: 'p', unit: 'mm', format: 'a4' },
html2canvas: { scale: 2, logging: false, useCORS: true },
styling: { tableProtection: true, keepTogetherClass: 'keep-together', avoidBreakClass: 'avoid-break' },
margins: { top: 15, right: 15, bottom: 15, left: 15 },
features: { pageNumbers: true, header: true, footer: true, watermark: false }
};
return this.deepMerge(defaults, options);
}
deepMerge(target, source) {
for (const key in source) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
if (!target[key]) target[key] = {};
this.deepMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
async export() {
try {
// 1. 验证和准备
this.validateInput();
// 2. 应用保护样式
if (this.options.styling.tableProtection) {
this.applyProtectionStyles();
}
// 3. 分析 DOM 结构
const analysis = await this.analyzeDOM();
// 4. 计算分页点
this.calculatePageBreaks(analysis);
// 5. 生成 canvas
const canvas = await this.generateCanvas();
// 6. 创建 PDF
const pdf = await this.generatePDF(canvas, analysis);
// 7. 保存
pdf.save(this.options.fileName);
return { success: true, pdf };
} catch (error) {
console.error('导出失败:', error);
throw error;
}
}
validateInput() {
if (!this.options.elementId) {
throw new Error('elementId 必须提供');
}
const element = document.getElementById(this.options.elementId);
if (!element) {
throw new Error(`元素 #${this.options.elementId} 未找到`);
}
this.element = element;
}
applyProtectionStyles() {
const style = document.createElement('style');
style.textContent = `
.${this.options.styling.keepTogetherClass} { page-break-inside: avoid !important; break-inside: avoid !important; }
.${this.options.styling.avoidBreakClass} { page-break-before: avoid !important; break-before: avoid !important; }
table { page-break-inside: avoid !important; break-inside: avoid !important; border-collapse: collapse !important; }
tr { page-break-inside: avoid !important; break-inside: avoid !important; }
`;
// 移除可能存在的旧样式
const oldStyles = this.element.querySelectorAll('style[data-pdf-protection]');
oldStyles.forEach(s => s.remove());
style.setAttribute('data-pdf-protection', 'true');
this.element.appendChild(style);
// 为表格添加保护类
const tables = this.element.querySelectorAll('table');
tables.forEach(table => {
table.classList.add(this.options.styling.keepTogetherClass);
});
// 为重要元素添加避免分页类
const importantElements = this.element.querySelectorAll('h1, h2, .important');
importantElements.forEach(el => {
el.classList.add(this.options.styling.avoidBreakClass);
});
}
async analyzeDOM() {
const tables = this.element.querySelectorAll('table');
const importantElements = this.element.querySelectorAll(`.${this.options.styling.keepTogetherClass}`);
const elementRect = this.element.getBoundingClientRect();
const analysis = {
tables: [],
importantElements: [],
containerHeight: this.element.offsetHeight,
containerWidth: this.element.offsetWidth
};
// 分析表格
tables.forEach((table, index) => {
const rect = table.getBoundingClientRect();
analysis.tables.push({
index,
element: table,
top: rect.top - elementRect.top,
bottom: rect.bottom - elementRect.top,
height: rect.height,
rowCount: table.querySelectorAll('tr').length,
canBreak: !table.classList.contains(this.options.styling.keepTogetherClass)
});
});
// 分析重要元素
importantElements.forEach((el, index) => {
if (el.tagName !== 'TABLE') { // 表格已经分析过了
const rect = el.getBoundingClientRect();
analysis.importantElements.push({
index,
element: el,
top: rect.top - elementRect.top,
bottom: rect.bottom - elementRect.top,
height: rect.height,
tagName: el.tagName
});
}
});
// 按位置排序
analysis.tables.sort((a, b) => a.top - b.top);
analysis.importantElements.sort((a, b) => a.top - b.top);
return analysis;
}
calculatePageBreaks(analysis) {
const pdf = new jsPDF(this.options.pdf);
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
const usableWidth = pdfWidth - this.options.margins.left - this.options.margins.right;
const usableHeight = pdfHeight - this.options.margins.top - this.options.margins.bottom;
// 像素到毫米的转换比例
const pxToMM = usableWidth / analysis.containerWidth;
const breaks = [];
let currentPageHeightMM = 0;
// 处理所有需要保护的元素
const allElements = [...analysis.tables, ...analysis.importantElements]
.sort((a, b) => a.top - b.top);
allElements.forEach((element, index) => {
const elementHeightMM = element.height * pxToMM;
// 检查是否放得下
if (currentPageHeightMM + elementHeightMM > usableHeight && currentPageHeightMM > 0) {
// 放不下,需要分页
if (index > 0) {
const prevElement = allElements[index - 1];
// 在元素前分页
breaks.push({ type: 'before', element: element, position: element.top, reason: `${element.tagName || 'table'} 无法放入当前页` });
currentPageHeightMM = elementHeightMM;
}
} else {
// 放得下
currentPageHeightMM += elementHeightMM;
}
});
this.pageBreaks = breaks;
console.log('计算出的分页点:', this.pageBreaks);
}
async generateCanvas() {
// 固定尺寸,防止渲染时变化
const originalStyle = {
width: this.element.style.width,
height: this.element.style.height,
position: this.element.style.position
};
this.element.style.width = `${this.element.offsetWidth}px`;
this.element.style.height = `${this.element.offsetHeight}px`;
this.element.style.position = 'relative';
try {
const canvas = await html2canvas(this.element, this.options.html2canvas);
return canvas;
} finally {
// 恢复原始样式
Object.assign(this.element.style, originalStyle);
}
}
async generatePDF(canvas, analysis) {
const pdf = new jsPDF(this.options.pdf);
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
const usableWidth = pdfWidth - this.options.margins.left - this.options.margins.right;
const usableHeight = pdfHeight - this.options.margins.top - this.options.margins.bottom;
// 计算缩放
const scale = usableWidth / canvas.width;
const totalHeightMM = canvas.height * scale;
// 如果有分页点,使用智能分页
if (this.pageBreaks.length > 0) {
await this.generatePDFWithBreaks(pdf, canvas, scale, usableWidth, usableHeight);
} else {
// 无分页点,使用简单分页
await this.generatePDFSimple(pdf, canvas, scale, usableWidth, usableHeight);
}
// 添加页眉页脚
if (this.options.features.header || this.options.features.footer) {
this.addHeaderFooter(pdf, this.pageBreaks.length + 1);
}
// 添加水印
if (this.options.features.watermark) {
this.addWatermark(pdf);
}
return pdf;
}
async generatePDFWithBreaks(pdf, canvas, scale, usableWidth, usableHeight) {
const imgData = canvas.toDataURL('image/png', 1.0);
// 转换分页点为 canvas 像素位置
const breakPoints = this.pageBreaks.map(breakInfo => breakInfo.position);
breakPoints.sort((a, b) => a - b);
// 确保从 0 开始,到 canvas 高度结束
const allPoints = [0, ...breakPoints, canvas.height];
let startY = 0;
for (let i = 1; i < allPoints.length; i++) {
const segmentHeight = allPoints[i] - startY;
if (segmentHeight <= 0) continue;
// 创建当前页的 canvas 片段
const segmentCanvas = document.createElement('canvas');
segmentCanvas.width = canvas.width;
segmentCanvas.height = segmentHeight;
const ctx = segmentCanvas.getContext('2d');
ctx.drawImage(
canvas,
0, startY, canvas.width, segmentHeight,
0, 0, canvas.width, segmentHeight
);
const segmentImgData = segmentCanvas.toDataURL('image/png', 1.0);
const segmentHeightMM = segmentHeight * scale;
// 如果不是第一页,添加新页
if (i > 1) {
pdf.addPage();
}
// 添加图片到 PDF
pdf.addImage(
segmentImgData, 'PNG', this.options.margins.left, this.options.margins.top, usableWidth, segmentHeightMM
);
startY = allPoints[i];
}
}
generatePDFSimple(pdf, canvas, scale, usableWidth, usableHeight) {
const imgData = canvas.toDataURL('image/png', 1.0);
const totalHeightMM = canvas.height * scale;
if (totalHeightMM <= usableHeight) {
// 一页能放下
pdf.addImage(
imgData, 'PNG', this.options.margins.left, this.options.margins.top, usableWidth, totalHeightMM
);
} else {
// 需要分页
let position = 0;
while (position < totalHeightMM) {
if (position > 0) {
pdf.addPage();
}
pdf.addImage(
imgData, 'PNG', this.options.margins.left, this.options.margins.top - position, usableWidth, totalHeightMM
);
position += usableHeight;
}
}
}
addHeaderFooter(pdf, totalPages) {
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
const currentPage = pdf.internal.getCurrentPageInfo().pageNumber;
// 保存当前状态
const originalFont = pdf.internal.getFont();
const originalSize = pdf.internal.getFontSize();
const originalColor = pdf.internal.getTextColor();
// 页眉
if (this.options.features.header) {
pdf.setFontSize(10);
pdf.setTextColor(100, 100, 100);
// 左侧:标题
pdf.text(
this.options.fileName.replace('.pdf', ''),
this.options.margins.left,
this.options.margins.top - 10
);
// 右侧:日期
const dateStr = new Date().toLocaleDateString();
pdf.text(
dateStr,
pdfWidth - this.options.margins.right,
this.options.margins.top - 10,
{ align: 'right' }
);
// 页眉线
pdf.setDrawColor(200, 200, 200);
pdf.line(
this.options.margins.left, this.options.margins.top - 5,
pdfWidth - this.options.margins.right, this.options.margins.top - 5
);
}
// 页脚
if (this.options.features.footer) {
// 页脚线
pdf.setDrawColor(200, 200, 200);
pdf.line(
this.options.margins.left, pdfHeight - this.options.margins.bottom + 5,
pdfWidth - this.options.margins.right, pdfHeight - this.options.margins.bottom + 5
);
// 页码
if (this.options.features.pageNumbers) {
pdf.setFontSize(9);
pdf.setTextColor(100, 100, 100);
const pageText = `第 ${currentPage} 页 / 共 ${totalPages} 页`;
pdf.text(
pageText,
pdfWidth / 2,
pdfHeight - this.options.margins.bottom + 10,
{ align: 'center' }
);
}
}
// 恢复原始状态
pdf.setFont(originalFont[0], originalFont[1]);
pdf.setFontSize(originalSize);
pdf.setTextColor(originalColor[0], originalColor[1], originalColor[2]);
}
addWatermark(pdf) {
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
// 保存状态
pdf.saveGraphicsState();
// 设置水印样式
pdf.setFontSize(40);
pdf.setTextColor(200, 200, 200, 0.1);
pdf.setFont('helvetica', 'bold');
// 旋转和重复
const angle = -45 * Math.PI / 180;
const text = this.options.features.watermark.text || 'CONFIDENTIAL';
for (let x = -pdfWidth; x < pdfWidth * 2; x += 150) {
for (let y = -pdfHeight; y < pdfHeight * 2; y += 150) {
pdf.saveGraphicsState();
pdf.translate(x, y);
pdf.rotate(angle, { origin: [0, 0] });
pdf.text(text, 0, 0);
pdf.restoreGraphicsState();
}
}
pdf.restoreGraphicsState();
}
}
// 使用示例
const exporter = new SmartPDFExporter({
elementId: 'report-content',
fileName: '智能报告.pdf',
pdf: { format: 'a4', orientation: 'portrait' },
html2canvas: { scale: 2, useCORS: true, backgroundColor: '#ffffff' },
features: {
pageNumbers: true,
header: true,
footer: true,
watermark: { text: '公司机密', enabled: true }
},
styling: { tableProtection: true, keepTogetherClass: 'pdf-keep-together' }
});
// 触发导出
document.getElementById('smart-export').addEventListener('click', async () => {
try {
await exporter.export();
alert('导出成功!');
} catch (error) {
alert('导出失败:' + error.message);
}
});
4.3 响应式与移动端适配
class ResponsivePDFExporter {
constructor(options = {}) {
this.options = {
breakpoints: {
mobile: 768,
tablet: 1024,
desktop: 1200
},
scaling: {
mobile: 1,
tablet: 1.5,
desktop: 2
},
...options
};
}
detectDeviceType() {
const width = window.innerWidth;
if (width < this.options.breakpoints.mobile) {
return 'mobile';
} else if (width < this.options.breakpoints.tablet) {
return 'tablet';
} else {
return 'desktop';
}
}
async exportResponsive(elementId, fileName) {
const deviceType = this.detectDeviceType();
const scale = this.options.scaling[deviceType] || 1.5;
console.log(`设备类型:${deviceType}, 使用缩放:${scale}`);
// 调整元素样式以适应 PDF
const element = document.getElementById(elementId);
const originalStyles = this.backupStyles(element);
try {
// 应用响应式样式
this.applyResponsiveStyles(element, deviceType);
// 生成 PDF
const canvas = await html2canvas(element, {
scale,
useCORS: true,
backgroundColor: '#ffffff'
});
const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' });
const pdfWidth = pdf.internal.pageSize.getWidth();
const imgWidth = canvas.width;
const imgHeight = canvas.height;
// 计算缩放比例
const margin = 10;
const usableWidth = pdfWidth - 2 * margin;
const scaleFactor = usableWidth / imgWidth;
pdf.addImage(
canvas.toDataURL('image/png', 1.0),
'PNG',
margin, margin,
usableWidth, imgHeight * scaleFactor
);
pdf.save(fileName);
} finally {
// 恢复原始样式
this.restoreStyles(element, originalStyles);
}
}
backupStyles(element) {
return {
width: element.style.width,
height: element.style.height,
fontSize: element.style.fontSize,
padding: element.style.padding,
margin: element.style.margin,
display: element.style.display
};
}
applyResponsiveStyles(element, deviceType) {
// 根据设备类型应用不同的样式
const styles = {
mobile: { width: '100%', fontSize: '12px', padding: '10px', margin: '0', display: 'block' },
tablet: { width: '90%', fontSize: '13px', padding: '15px', margin: '0 auto', display: 'block' },
desktop: { width: '80%', fontSize: '14px', padding: '20px', margin: '0 auto', display: 'block' }
};
const deviceStyles = styles[deviceType] || styles.desktop;
Object.assign(element.style, deviceStyles);
// 特别处理表格
const tables = element.querySelectorAll('table');
tables.forEach(table => {
if (deviceType === 'mobile') {
table.style.fontSize = '10px';
table.style.width = '100%';
}
});
}
restoreStyles(element, originalStyles) {
Object.assign(element.style, originalStyles);
}
}
// 使用响应式导出
const responsiveExporter = new ResponsivePDFExporter();
// 响应式导出按钮
document.getElementById('responsive-export').addEventListener('click', () => {
responsiveExporter.exportResponsive('content', '响应式报告.pdf');
});


