跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客GitHub 精选镜像AI 生图工具UI配色美学隐私政策关于联系
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
JavaScript大前端

前端 PDF 导出实战:JSPDF 与 HTML2Canvas 深度解析

深入解析前端 PDF 导出的核心技术,重点介绍 JSPDF 和 HTML2Canvas 两个库。涵盖 JSPDF 的初始化、文本处理、中文支持、图片及表格生成等 API 详解,以及 HTML2Canvas 的渲染原理与配置参数。通过实战案例展示了基础集成方案、智能分页保护样式、响应式适配等高级功能,帮助开发者在浏览器端实现高质量的网页内容 PDF 导出。

涅槃凤凰发布于 2026/4/5更新于 2026/7/2557 浏览
前端 PDF 导出实战:JSPDF 与 HTML2Canvas 深度解析

前言:为什么需要前端 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 的转换。

工作原理:
  1. 解析目标元素的 CSS 样式
  2. 克隆 DOM 节点并应用样式
  3. 使用 Canvas 2D API 绘制每个节点
  4. 处理图片、渐变、阴影等复杂样式
技术特点:
  • 基于 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;
    doc.saveGraphicsState();
    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');
});

目录

  1. 前言:为什么需要前端 PDF 导出?
  2. 第一部分:工具介绍与技术选型
  3. 1.1 JSPDF:轻量级 PDF 生成库
  4. 主要特性:
  5. 基本使用:
  6. 1.2 HTML2Canvas:网页截图神器
  7. 工作原理:
  8. 技术特点:
  9. 1.3 为什么选择这两个库?
  10. 第二部分:JSPDF 深度解析
  11. 2.1 核心 API 详解
  12. 2.1.1 初始化与页面设置
  13. 2.1.2 文本处理
  14. 2.1.3 中文支持
  15. 2.1.4 图片处理
  16. 2.1.5 图形绘制
  17. 2.1.6 表格生成
  18. 2.2 高级功能
  19. 2.2.1 页码和页眉页脚
  20. 2.2.2 水印功能
  21. 2.2.3 链接和书签
  22. 第三部分:HTML2Canvas 深度解析
  23. 3.1 核心原理与架构
  24. 3.1.2 核心组件
  25. 3.2 配置参数详解
  26. 第四部分:JSPDF 与 HTML2Canvas 集成实战
  27. 4.1 基础集成方案
  28. 4.2 高级集成:智能分页与表格保护
  29. 4.3 响应式与移动端适配
  • 免费图片AI生成工具免费生成了解详情
  • Magick API 一键接入全球大模型注册送1000万token查看
  • 免费图片视频在线生成30秒,将你的创意变成现实开始设计
  • X/Twitter免费视频下载器免登陆无限额度免费视频解析下载了解详情
  • 100+免费在线小游戏爽一把
极客日志微信公众号二维码

微信扫一扫,关注极客日志

微信公众号「极客日志V2」,在微信中扫描左侧二维码关注。展示文案:极客日志V2 zeeklog

更多推荐文章

查看全部
  • AIGC 核心概念解析:22 个基础术语详解
  • Python 素数判断与查找算法详解
  • OpenCode 使用 GitHub Copilot 按次计费账单暴涨原因与优化方案
  • Spring Web MVC 核心概念与实战应用
  • OpenClaw 安全 AI 助理搭建与部署实战指南
  • Rust 控制流详解:条件、循环与模式匹配
  • 免费版 Trae 编辑器实测:i18n 任务排队千位与死循环问题
  • C++ 核心就业方向与技术成长指南
  • web应用集成AI翻译:前端+后端完整调用示例代码分享
  • 前端实现视频画中画功能 - 主页面与小窗同步控制
  • Git 工作流程详解:核心概念与主流模式选择
  • Git 新手必学:git clone -b 命令详解与实操
  • 前端 API 设计最佳实践
  • HTML/CSS 文本字体与字号设置实战指南
  • 宇树 G1 机器人开发入门:有线与无线连接配置
  • OpenClaw 开源多渠道 AI 个人助手技术架构与实践
  • 数据结构与算法:单链表综合练习(删除节点、反转、找中间节点)
  • OpenCode:开源 AI 编程代理技术与行业分析
  • AIGC 时代 R 语言在数据科学中的应用与优势
  • AI 大模型 API 中转接入指南与配置说明

相关免费在线工具

  • Keycode 信息

    查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online

  • Escape 与 Native 编解码

    JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online

  • JavaScript / HTML 格式化

    使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online

  • JavaScript 压缩与混淆

    Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online

  • Base64 字符串编码/解码

    将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online

  • Base64 文件转换器

    将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online