html2canvas 和 jspdf

793 阅读5分钟

需求

好久之前做的两个都是表格数据的页面,客户突然提了个新需求说想要页面能实现下载这两个表格的功能... 实现功能很简单,但是遇到了一个很坑的问题,就是文字被分页截断。所以想总结一下,同时在看一看代码有没有什么可以优化的地方。

我们的项目使用的框架是 Angular, 我就以在 Angular 中的使用举列子。

html2canvas

官网:html2canvas.hertzen.com/

html2canvas 能够实现在用户浏览器端对真整个或者部分页面进行截屏。html2canvas 脚本将当前页面渲染成一个 canvas 图片。通过读取 DOM 并将不同的样式应用到这些元素上面实现。他不需要来自服务器任何渲染,整张图片都是在客户端浏览器创建。 html2canvas 可以通过获取 HTML 的某个 DOM ,然后生成 Canvas, 能让用户保存为图片。这个脚本主要是生成 canvas,那么如果我们需要生成图片还需要将他转化为图片格式.

  1. 安装
npm install html2canvas
  1. 引用
import html2canvas from 'html2canvas';

html2canvas(downloadElement, options?).then(canvas => {})

options 的值请参考官网

jspdf

  1. 安装
npm install jspdf
  1. 引用
import jspdf from 'jspdf';

let pdf = new jspdf('p', 'pt', 'a4'); // A4 size page of PDF

const imageData = canvas.toDataURL('image/jpeg', 1.0);
pdf.addImage(contentDataURL, 'JPEG', 0, 0, imgWidth, imgHeight)

new jspdf(): 第一个参数: 'l' 横向, 'p' 纵向 第二个参数: 测量单位 ("pt","mm", "cm", "m", "in" or "px") 第三个参数: 默认 "A4" 或者 [width, heignt] 还有其他格式。

pdf.addPage() 在 PDF 文档中添加新页面 pdf.addImage(imageData, format, x, y, width, height, alias, compression, rotation) 将图像添加到 PDf

imageData: string | HTMLImageElement | HTMLCanvasElement | Unit8Array format: 'JEPG' | 'PNG' x: x 坐标轴位置 y: y 坐标轴位置 width: 图片长度 height: 图片宽度 alias: 图片别名 compression: 压缩 'FAST' | 'MEDIUM' | 'SLOW' | 'NONE' rotation: 旋转度 (0-359)

删除某页pdf:

let targetPage = pdf.internal.getNumberOfPages();
pdf.deletePage(targetPage)

保存 pdf 文档: pdf.save('name.pdf')

两个插件的使用介绍完了,下面放上我整个实现的 demo 代码

首先,创建一个 dowload pdf 按钮的公共组件 DownloadPDFButtonComponent

down-pdf-button.ts downloadElement 参数为想要下载成 pdf 的 DOM 节点, 可以用 getElementById("idName") 或者 querySelector("#id") 来获取


import { Component, Input, OnInit, NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NzMessageService, NzButtonModule, NzIconModule } from 'ng-zorro-antd';
import jspdf from 'jspdf';
import html2canvas from 'html2canvas';

@Component({
  selector: 'download-pdf-button',
  template: 
  `<button class="app-button right"  nz-button [nzType]="'primary'">
      <i nz-icon nzType="download" nzTheme="outline"></i>下载 PDF
  </button>`,
})

export class DownloadPDFButtonComponent implements OnInit {

  @Input() pdfName: string = "";

  constructor(
    private _message: NzMessageService
  ) { }

  ngOnInit(): void { }
  
  downloadPDF(downloadElement) {
    if (!downloadElement) {
      return;
    }
    const options = {
      useCORS: true,
      allowTaint: true
    };
    const A4_WIDTH = 592.28;
    const A4_HEIGHT = 841;
    html2canvas(downloadElement, options).then(canvas => {
      const contentWidth = canvas.width;
      const contentHeight = canvas.height;
      // 一页pdf显示html页面生成的canvas高度;(A4)
      const pageHeight = contentWidth / A4_WIDTH * A4_HEIGHT;
      // 未生成pdf的html页面高度
      let leftHeight = contentHeight;
      let position = 0;
      const imgWidth = A4_WIDTH;
      const imgHeight = A4_WIDTH / contentWidth * contentHeight;

      const contentDataURL = canvas.toDataURL('image/jpeg', 1.0);
      let pdf = new jspdf('p', 'pt', 'a4'); // A4 size page of PDF
      if (leftHeight < pageHeight) {
        pdf.addImage(contentDataURL, 'JPEG', 0, 0, imgWidth, imgHeight);
      } else {
        while (leftHeight > 0) {
          pdf.addImage(contentDataURL, 'JPEG', 0, position, imgWidth, imgHeight)
          leftHeight -= pageHeight;
          position -= A4_HEIGHT;
          //避免添加空白页
          if (leftHeight > 0) {
            pdf.addPage();
          }
        }
      }
      pdf.save(this.pdfName + '.pdf'); // Generated PDF
    }).catch((e) => { });
  }
}

@NgModule({
  imports: [CommonModule, NzButtonModule, NzIconModule],
  exports: [DownloadPDFButtonComponent],
  declarations: [DownloadPDFButtonComponent]
})
export class DownloadPDFModule { }

父组件中引用子组件, 父组件中调用子组件的方法 downloadPDF

parentPdf.html

    <download-pdf-button [pdfName] = "'报告的名字'" (click)="downloadPDF('domToPdfId')">
    </download-pdf-button>
     <div nz-row id = "domToPdfId">
         //balabalabala
         <table>
             //balabalabala
         </table>
     </div>

parentPdf.ts

import { Component, OnInit, ElementRef, ViewChild } from '@angular/core';
// 引用子组件
import { DownloadPDFButtonComponent } from '../../../common/download/download-pdf-button';

export class ParentPdfComponent implements OnInit {
    @ViewChild(DownloadPDFButtonComponent)
     public downloadPdfComponent!: DownloadPDFButtonComponent;
     
     downloadPDF(id) {
        const downloadElement = this.element.nativeElement.querySelector("#" + id);
        this.downloadPdfComponent.downloadPDF(downloadElement);
      }
}

分页截断文字解决方法

上面的代码就能实现你想要的 DOM 节点下载,很简单。但是,页面数据很多的时候,超过 A4 大小,他就会分页,分页有时候会在有文字的地方截断,这样肯定是不行的。我其实觉得行,但是测试觉得不行,那就不行。我参考了资下面这个博主的思路,根据我们页面的布局又改了一点点。 参考:blog.csdn.net/Sandy_zhi/a… 整个思路大概就是:

  1. 在每个不可以被截断的节点添加一个标识类,然后遍历每一个标识类的节点。
  2. 根据 DOM 的长度和 A4 长度的比例 * A4 高度就得到 DOM 对应一页 A4 的高度。
  3. 然后在遍历节点的时候,判断该节点的头部和尾部是否在一个页面
const topPageNum = Math.ceil((wholeNodes[i].offsetTop + moreHeight) / pageHeight);
const bottomPageNum = Math.ceil((wholeNodes[i].offsetTop + moreHeight + wholeNodes[i].offsetHeight) / pageHeight);
if (bottomPageNum !== topPageNum) 

博主的代码里面是没有 moreHeigt 的,但是因为我的页面是由一部分的栅格布局 和 下面4 个 table 组成的,标识类是加到每个 tr 上的, offsetTop 不是到整个 DOM 的高度而是到其父元素的高度, 所以计算topPageNum和bottomPageNum 的时候得加上它上面DOM 的高度。 4. 不在一个页面的话就在改节点前增加一个空白的 tr。

最终的down-pdf-button.ts代码

import { Component, Input, OnInit, NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { NzMessageService, NzButtonModule, NzIconModule } from 'ng-zorro-antd';
import jspdf from 'jspdf';
import html2canvas from 'html2canvas';

@Component({
  selector: 'download-pdf-button',
  template: 
  `<button class="app-button right"  nz-button [nzType]="'primary'">
      <i nz-icon nzType="download" nzTheme="outline"></i>下载 PDF
  </button>`,
})
export class DownloadPDFButtonComponent implements OnInit {

  @Input() pdfName: string = "";

  constructor(
    private _message: NzMessageService
  ) { }

  ngOnInit(): void { }


  downloadPDF(downloadElement, moreHeight) {
    if (!downloadElement) {
      return;
    }
    const options = {
      useCORS: true,
      allowTaint: true
    };
    // 避免笔下误 灯下黑 统一写
    const A4_WIDTH = 592.28;
    const A4_HEIGHT = 841;
    let pageHeight = (downloadElement.offsetWidth / A4_WIDTH) * A4_HEIGHT;
    // 将所有不允许被截断的元素进行处理
    //let wholeNodes: any = document.querySelectorAll('.whole-node');
    let wholeNodes: any = document.querySelectorAll('[class*=whole-node]');
    for (let i = 0; i < wholeNodes.length; i++) {
      let className= wholeNodes[i].className.split(" ")[0];
      //两个页面都用到了这个子组件并且两个 pdf 的布局不一样
      //page Two我用ng-for遍历实现的 table,每个table长度不一样,所以他们的标识类用 whole-node-1 ... whole-node-4 来表示方便处理
      if (this.pdfName.indexOf("pageTwo") > -1 && className.split("-")[2] > 1) {
        if (className !== wholeNodes[i-1].className.split(" ")[0]) {
          moreHeight = moreHeight + wholeNodes[i-1].offsetTop + 97;
          if (className.split("-")[2] < 4) {
            moreHeight = moreHeight +  wholeNodes[i-1].offsetHeight
          }
        }
      }
      const topPageNum = Math.ceil((wholeNodes[i].offsetTop + moreHeight) / pageHeight);
      const bottomPageNum = Math.ceil((wholeNodes[i].offsetTop + moreHeight + wholeNodes[i].offsetHeight) / pageHeight);
      if (bottomPageNum !== topPageNum) {
        //说明该dom会被截断
        // 2、插入空白块使被截断元素下移
        
        let divParent = wholeNodes[i].parentNode;
        let newBlock = document.createElement('tr')
        newBlock.className = 'emptyDiv'

        // 3、计算插入空白块的高度 可以适当流出空间使得内容太靠边,根据自己需求而定
        let _H = topPageNum * pageHeight - wholeNodes[i].offsetTop - moreHeight;
        newBlock.style.height = _H + 50 + 'px'
        divParent.insertBefore(newBlock, wholeNodes[i])
      }
    }

    html2canvas(downloadElement, options).then(canvas => {
      let emptyDivs = document.querySelectorAll('.emptyDiv');
      for (let i = 0; i < emptyDivs.length; i++) {
        emptyDivs[i].parentNode.removeChild(emptyDivs[i])
      }

      const contentWidth = canvas.width;
      const contentHeight = canvas.height;
      // 一页pdf显示html页面生成的canvas高度;(A4)
      const pageHeight = contentWidth / A4_WIDTH * A4_HEIGHT;
      // 未生成pdf的html页面高度
      let leftHeight = contentHeight;
      let position = 0;
      const imgWidth = A4_WIDTH;
      const imgHeight = A4_WIDTH / contentWidth * contentHeight;

      const contentDataURL = canvas.toDataURL('image/jpeg', 1.0);
      let pdf = new jspdf('p', 'pt', 'a4'); // A4 size page of PDF
      if (leftHeight < pageHeight) {
        pdf.addImage(contentDataURL, 'JPEG', 0, 0, imgWidth, imgHeight);
      } else {
        while (leftHeight > 0) {
          pdf.addImage(contentDataURL, 'JPEG', 0, position, imgWidth, imgHeight)
          leftHeight -= pageHeight;
          position -= A4_HEIGHT;
          //避免添加空白页
          if (leftHeight > 0) {
            pdf.addPage();
          }
        }
      }
      pdf.save(this.pdfName + '.pdf'); // Generated PDF
    }).catch((e) => { });
  }
}

@NgModule({
  imports: [CommonModule, NzButtonModule, NzIconModule],
  exports: [DownloadPDFButtonComponent],
  declarations: [DownloadPDFButtonComponent]
})
export class DownloadPDFModule { }


放上 page two 的部分 html 代码


<div *ngFor="let table of tableInfo">
            <div nz-col [nzLg]="{span:24}" [nzXl]="{span: 24}" class = "outline">
              <div [class] = "table.pdfClass" ><h3>{{table.title}}</h3></div>
            </div>
            <div nz-col [nzLg]="{span:24}" [nzXl]="{span: 24}" class = "outline" >
              <div *ngIf="table['tableData'].length > 0; else noTableData" class = "bottom">
                <nz-table
                 [nzLoading]="loading"
                 [nzShowPagination]="'false'"
                 [nzFrontPagination]="false"
                 [nzData]="table.tableData"
                 class = "cursor">
                  <thead>
                    <tr [class] = "table.pdfClass">
                      <th *ngFor="let header of table.tableHeader">{{header}}</th>
                    </tr>
                  </thead>
                  <tbody>
                    <tr *ngFor="let data of table.tableData" [class] = "table.pdfClass">
                      <td *ngFor="let name of table.tableName">{{data[name] | undefinedNullTransform}}</td>
                    </tr>
                  </tbody>
                </nz-table>
              </div>
              <ng-template #noTableData>
                <div class = "bottom">
                  <p>{{table.noInfo}}</p>
                </div>
              </ng-template>
            </div>
          </div>

关于这部分逻辑的代码由于着急提测,所以写的更差劲。如果有更好的解决办法或者代码优化的方案麻烦指教~