前端生成pdf?jspdf+html2canvas实现pdf预览和导出

最近做后台系统遇到挺多复杂的需求,比如导出pdf,word,excel
一般这种需求后端如果存文件,然后传个流过来,前端就可以下载导出了。
但是如果后端不存文件,只返回字符串(富文本字符串),这时候咋办?
= =不知道,但是我遇到了,也只能头铁干了。


吃货镇楼

路还是有的,讲一下实现方式:

html2canvas+jspdf

具体需求是在弹窗内预览,然后点击下载可以生成对应pdf,预览pdf如果后端没有给文件地址,只返回富文本字符串(类似"<p>123</p>"),比较难做,但是办法还是有的,个人感觉难点在于html2canvas生成完整截图的时机和pdf的分页。
贴一下效果:


预览弹窗
生成的pdf文件

思路:由于pdf不可直接编辑,个人思路是先将html截屏转化成图片,再把图片嵌入生成pdf。

  1. html2canvas:直接npm i html2canvas -S ,用法是截图dom然后转化为canvas,具体api可以去github上看。
  2. jspdf(项目编译报错所以选了个特定的版本):https://cdn.bootcss.com/jspdf/1.5.3/jspdf.debug.js

直接贴代码(项目用的是elementUI,核心代码在preview2pdf这个方法):

<template>
    <div class="preview-modal">
      <el-dialog class="common-dialog"  :width="width" :visible.sync="visible" @opened="openModal" @closed="hiddenModal" destroy-on-close>
            <!-- <template slot="title">
              <div class="common-modal-title">
                <span>{{title}}</span>
              </div>
            </template> -->
            <div class="preview-content">
                <div v-show="isLoading" class="loading">
                  <i class="el-icon-warning-outline"></i>正在生成
                </div>
                <div class="preview-data" style="min-height:2400px" v-if="previewDom || domData">
                      <div style="color: #333; position: relative;padding: 30px;">
                          <!-- 封面 封面页面伸缩(预览效果)通过调整.preview-data 的 width属性控制-->
                          <div style="height: 1320px; padding-top: 100px;text-align: center;">
                              <h2 style="line-height: 50px;">
                                <br />
                                <span>我是猪扒封面</span>
                              </h2>
                          </div>
                            <!-- 具体正文 -->
                          <div class="edit-content" style="margin-top:20px"></div>
                      </div>
                  </div>
                <div class="error-pdf" v-else><i class="el-icon-document-delete preview-icon"></i>找不到pdf文件</div>
            </div>
          <slot name="footer">
            <template slot="footer" class="dialog-footer">
                <el-button v-if="previewDom || domData" type="primary" :disabled="isLoading" @click="downloadPDF">下载</el-button>
                <el-button v-else type="primary" @click="hiddenModal">确定</el-button>
            </template>
          </slot>
      </el-dialog>
    </div>
</template>

<script>
 import '@/utlis/html2canvas'
 import '@/utlis/jspdf.debug'
 import moment from "moment";

/*
 *@description: 预览pdf弹窗
 *@version V1.0
 *@API:
 *@ 参数 二选一, 二选一, 二选一
 *previewDom 页面中可看见的预览目标类名 (比如富文本在页面中显示,其容器div类名为'.fuwenben',直接传'.fuwenben'就可以生成预览页面dom的pdf了)
 *domData    页面中看不见的dom字符串(比如后台返回富文本字符串'<div>111</div>',直接传进来就可以生成pdf)
 *@ 事件
 * 需要在父组件指定关闭事件 onModalHidden
 * onModalHidden(){
        this.previewDialogVisible = false
    },
*/
export default {
  name:'PreviewModal',
  props:{
    title: {
      type: String,
      default: ''
    },
    width: {
      type: String,
      default: ''
    },
    isVisible: {
      type: Boolean,
      default: false
    },
    // 预览目标类名
    previewDom:{
      type: String,
      default: ''
    },
    pdfName:{
      type: String,
      default:'pdf'
    },
    domData:{
      type:String,
      default:''
    },
    dateTime: {
      type: Array,
      default: () => {
        return [];
      },
    }
  },
  data(){
   return{
     visible: this.isVisible, // 将props 的属性备份到data中
     pdfFile:null,
     isLoading:true,
   }
  },
    methods: {
    //当前日期
    getDate() {
      let date = new Date();
      const month =
        date.getMonth() + 1 > 9
          ? date.getMonth() + 1
          : 0 + (date.getMonth() + 1);
      return date.getFullYear() + "年" + month + "月" + date.getDate() + "日";
    },
    getDateTime() {
      return (
        this.getSplit(this.dateTime[0]) + "至" + this.getSplit(this.dateTime[1])
      );
    },
    getSplit(date) {
      let arr = moment(date)
        .format("YYYY-MM-DD")
        .split("-");
      return arr[0] + "年" + arr[1] + "月" + arr[2] + "日";
    },
    /**
     * 显示对话框
     */
    showModal() {
      // 如果是隐藏中才显示
      if (!this.visible) { this.visible = true }
    },
    /**
     * 隐藏对话框
     */
    hiddenModal() {
      // 如果是显示中才隐藏
        this.visible = false
        this.isLoading = true
        this.$emit('onModalHidden')
        // console.log(this.visible)
    },
    openModal(){
      this.$emit('onModalOpen')
      this.preview2pdf()
    },
    downloadPDF(){
      this.pdfFile.save(this.pdfName);
      this.$emit('downloadPDF');
    },
    // 预览转pdf
    preview2pdf(){
      // 非法dom直接返回
      if (!this.previewDom && !this.domData) {
        this.isLoading = false
        return
      }

      const parentDom = document.querySelector('.preview-modal')
      const contentDom = parentDom.querySelector('.preview-content')
      // 进行截图的dom
      const canvasDom = document.querySelector('.preview-content .preview-data')
      // 找不到这个dom元素,返回
      if(!canvasDom) {
        this.isLoading = false
        return
      }
      // 传入富文本字符串,添加到原有的子节点中
      const mainBody = canvasDom.querySelector('.edit-content')

      if(mainBody) {
        // 添加内容
        mainBody.innerHTML = `<div>${this.domData}</div>`
      } else {
        // 外部传进来的dom元素
        const previewDom = document.querySelector(this.previewDom)
        mainBody.append(previewDom)
      }

      // 新建ifame标签在线展示pdf
      const iframe = document.createElement('iframe')
      iframe.height = '99%'
      iframe.width = '100%'
      // 进行dom截图 必须让dom更新完再调用
      this.$nextTick(()=>{
        html2canvas(canvasDom, {
          allowTaint: true,
          useCORS: true,
      }).then((canvas)=>{
            // 用iframe标签展示pdf生成预览效果
            contentDom.appendChild(iframe)
            
            var contentWidth = canvas.width;
            var contentHeight = canvas.height;

            //一页pdf显示html页面生成的canvas高度;
            var pageHeight = contentWidth / 592.28 * 841.89;
            //未生成pdf的html页面高度
            var leftHeight = contentHeight;
            //页面偏移
            var position = 0;
            //a4纸的尺寸[595.28,841.89],html页面生成的canvas在pdf中图片的宽高
            var imgWidth = 555.28;
            var imgHeight = 555.28/contentWidth * contentHeight;

            var pageData = canvas.toDataURL('image/jpeg', 1.0);
            // 取消生成状态
            this.isLoading = false
            var pdf = new jsPDF('', 'pt', 'a4');

            //有两个高度需要区分,一个是html页面的实际高度,和生成pdf的页面高度(841.89)
            //当内容未超过pdf一页显示的范围,无需分页
            if (leftHeight < pageHeight) {
                imgWidth = 555.28;
                imgHeight = 555.28/contentWidth * contentHeight;
                pdf.addImage(pageData, 'JPEG', 20, 20, imgWidth, imgHeight );
            } else {
                while(leftHeight > 0) {
                    leftHeight -= pageHeight;
                    pdf.addImage(pageData, 'JPEG', 20, position, imgWidth, imgHeight)
                    position -= 841.89;
                    //避免添加空白页
                    if(leftHeight > 0) {
                        pdf.addPage();
                    }
                }
            }
            // 保存pdf对象
            this.pdfFile = pdf
            // 生成外链让iframe标签展示
            iframe.src = pdf.output('datauristring')
          })
        })
      }
   },
   watch:{
     isVisible(){
       this.visible = this.isVisible;
     }
   }
}
</script>

<style lang="scss">
.preview-modal{
      height:100%;

    .common-dialog{
      // height:100%;
      .el-dialog{
        margin-top: 0!important;
        height: 100%
      }
      .common-modal-title{
        width: 100%;
        height: 50px;
        margin: 10px auto 0;
        line-height: 48px;
        border-bottom: 2px solid #d8d8d8;
        box-sizing: border-box;
        font-size: 20px;
        color: #333;

        span {
          display: inline-block;
          border-bottom: 3px solid #43baca;
        }
      }
      .el-dialog__body{
        padding: 10px 40px !important;
        height: calc(100% - 75px)
      }
      .el-dialog__header{
        // padding: 30px 40px 10px;
        padding: 0;
      }
        // 预览对话框
        .preview-content{
            overflow:hidden;
            position: relative;
            height:100%;
            .preview-data{
              // 盖住隐藏dom height 和 width 可控制元素在pdf页面的大小
              // min-height: 2600px;
              width: 48%;
              // width: 50%;
              font-size: 20px;
              z-index: -1;
              position: fixed;
              margin-top: -9999px;
              .edit-content{
              }
            }
            .error-pdf{
                height:100%;
                width:100%;
                display:flex;
                justify-content:center;
                align-items:center;
                font-size:20px;
                .preview-icon{
                    font-size:36px;
                    padding-right:15px
                }
            }
            // background-color:#ff0
        }
        .loading{
          display: flex;
          justify-content: center;
          align-items: center;
          // text-align: center;
          height:100%;
          // 盖在隐藏的canvasDom上面
          z-index: 2;
          font-size: 20px;
          i{
            color: #409EFF;
            font-size: 30px;
            padding-right: 20px;
          }
        }
        .el-dialog__footer{
          text-align: center;
          padding: 0 20px;

        }
        .dialog-footer{
            display:flex;
            justify-content: space-around;
        }
    }
  }
</style>
母猪焊接

遇到坑,注意的点

  1. 控制pdf页面大小取决于html2canvas截图dom的样式,例子中是 .preview-data 这个类,可以观察其中的css样式,其中z-index为负一的原因是html2canvas截图只能截可视dom元素,如果display:none或者是克隆出来的虚拟dom,都截不了,所以只能采取让元素看不见的方法来取巧。

  2. 由于后台返回的是富文本字符串,所以渲染的内容代码用innerHTML赋值了,赋值后dom还未渲染,此时不能立即使用html2canvas截取,需要等dom更新完成再截取,这就是调用vue.$nextTick的原因。

  3. 关于pdf分页问题:position 这个变量控制第二页的偏移位置,即利用偏移制造假分页,实际上pdf渲染出来的东西都在同一页上,只是按高度切割后,把剩余的内容合理偏移,使得看来像分页了而已。
    分页参考: https://blog.csdn.net/weixin_43720095/article/details/87358705

4.预览和下载pdf:jspdf很强大,有一个output('datauristring')的方法,可以生成一个dataurl外链,把它带给iframe标签或者embed标签src就可以在线预览(后台直接返回pdf地址也是这种方法预览),下载则更为简单,调用save方法即可。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 193,968评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,682评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,254评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,074评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,964评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,055评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,484评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,170评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,433评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,512评论 2 308
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,296评论 1 325
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,184评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,545评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,150评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,437评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,630评论 2 335