欢迎光临
我们一直在努力

前端PDF导出完全指南:JSPDF与HTML2Canvas深度解析与实战(上)

前言:为什么需要前端PDF导出?

在现代Web开发中,将网页内容导出为PDF是一项常见但颇具挑战的需求。无论是生成报告、发票、合同还是数据可视化图表,前端PDF导出都能为用户提供便捷的离线查看和打印体验。传统的PDF生成通常需要在后端完成,但随着前端技术的发展,现在我们可以直接在浏览器中实现高质量的PDF导出功能。

本文将深入探讨两个核心工具——JSPDF和HTML2Canvas,通过详细的原理分析、实战案例和最佳实践,帮助您掌握前端PDF导出的核心技术。

第一部分:工具介绍与技术选型

1.1 JSPDF:轻量级PDF生成库

JSPDF是一个纯JavaScript实现的PDF生成库,它不依赖任何服务器端组件,完全在客户端运行。这个库最初发布于2010年,经过多年的发展,已经成为前端PDF生成的事实标准。

主要特性:
  • 纯客户端运行:不需要服务器支持

  • 轻量级:压缩后仅约60KB

  • 丰富的API:支持文本、图片、形状、字体等

  • 多语言支持:包括中文在内的多种语言

  • 插件系统:可通过插件扩展功能

基本使用:

javascript

// 最简单的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;

    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:contact@example.com'
    });

    // 添加书签
    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');
    });

    赞(0)
    未经允许不得转载:171主机测评 » 前端PDF导出完全指南:JSPDF与HTML2Canvas深度解析与实战(上)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址