前端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的转换。

工作原理:
  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 初始化与页面设置
javascript 复制代码
// 创建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 文本处理
javascript 复制代码
// 基本文本设置
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 中文支持
javascript 复制代码
// 添加中文字体
// 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 图片处理
javascript 复制代码
// 添加图片
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 图形绘制
javascript 复制代码
// 线条
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 表格生成
javascript 复制代码
// 使用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 页码和页眉页脚
javascript 复制代码
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 水印功能
javascript 复制代码
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 链接和书签
javascript 复制代码
// 内部链接
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 核心组件
javascript 复制代码
// 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 配置参数详解

javascript 复制代码
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 基础集成方案

javascript 复制代码
// 基础集成函数
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 高级集成:智能分页与表格保护

javascript 复制代码
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 响应式与移动端适配

javascript 复制代码
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');
});
相关推荐
大猫会长2 小时前
css中,由基准色提取其他变体
前端·javascript·html
小土豆_7772 小时前
Owl 2.8.1 核心语法速查表(新手专用)
前端·odoo/owl
firstacui2 小时前
LVS三种模式搭建
前端·chrome
wanzhong23332 小时前
开发日记13-响应式变量
开发语言·前端·javascript·vue
代码游侠2 小时前
学习笔记——文件传输工具配置与Makefile详解
运维·前端·arm开发·笔记·学习
踢球的打工仔2 小时前
typescript-类的静态属性和静态方法
前端·javascript·typescript
匠心网络科技2 小时前
前端框架-Vue双向绑定核心机制全解析
前端·javascript·vue.js·前端框架
Jinuss2 小时前
源码分析之React中的FiberRoot节点属性介绍
前端·javascript·react.js
自回归向前看3 小时前
2020-25 Js ES新增加特性
前端·javascript