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

前端 PDF 导出实战:JSPDF 与 HTML2Canvas 技术详解

综述由AI生成前端生成 PDF 的两种核心方案:JSPDF 用于直接生成 PDF 文档,HTML2Canvas 用于将网页元素渲染为 Canvas。文章详细解析了 JSPDF 的初始化、文本、图片、表格及高级功能如水印和书签;深入讲解了 HTML2Canvas 的工作原理与配置参数。最后提供了两者集成的实战代码,涵盖基础导出、智能分页保护、响应式适配等场景,帮助开发者实现高质量的前端 PDF 导出功能。

CodeArtist发布于 2026/4/6更新于 2026/5/2229 浏览
前端 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;
    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 响应式与移动端适配
  • 💰 8折买阿里云服务器限时8折了解详情
  • Magick API 一键接入全球大模型注册送1000万token查看
  • 🤖 一键搭建Deepseek满血版了解详情
  • 一键打造专属AI 智能体了解详情
极客日志微信公众号二维码

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

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

更多推荐文章

查看全部
  • Python 文件操作详解:读写、序列化与路径管理
  • Go 语言 strings 包常用方法实战
  • 基于 Higress 将 REST API 转换为 MCP Server 工具
  • 提示词工程(Prompt Engineering)全面指南
  • AI Agent 新范式:基于 FastGPT 与 MCP 协议构建工具增强型智能体
  • WebView 与 Android View 体系深度对比:绘制、事件与渲染机制
  • 基于 Java 的实体店综合管理系统设计与实现
  • Whisper 语音识别库安装与环境配置指南
  • 转行网络安全的动因分析与入门指南
  • Flutter 与 React Native 跨平台开发技术选型深度对比
  • Python 常用数据结构:集合(Set)详解与实战
  • 后仿真 SDF 反标 Warning 详解与排查指南
  • FastGPT 集成 MCP 协议构建工具增强型智能体
  • 构建 AI 临床副驾驶:基于 Go 的电子病历智能助手与 HIS 对接实战(下)
  • MoltBot 接入钉钉 Stream 流式接口配置详解
  • GitHub 学生开发者包认证全流程指南
  • 前端拖拽交互实战:告别原生 API 的卡顿体验
  • MySQL 9.5 版本在 Windows 下的安装与配置指南
  • Dev-C++ 下载与安装指南
  • 滑动窗口算法解析:串联所有单词的子串与最小覆盖子串

相关免费在线工具

  • 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