美文网首页纵横研究院前端基础技术专题社区
【原创】jspdf+html2canvas生成多页pdf防截断处

【原创】jspdf+html2canvas生成多页pdf防截断处

作者: 拂云枝 | 来源:发表于2019-11-03 16:08 被阅读0次

    将html转pdf的纯前端解决方案通常是jspdf+html2canvas,在保持网页的宽高比以及pdf每页的大小(通常为A4)的情况下,难免会出现网页内容较多需要生成多页pdf的情况,如果按常规的方法生成的pdf很容易出现一个dom元素被截断分散在两页pdf里,因此本文针对此情况提出一些思考和实现,完整代码附在文章结尾供参考。

    实现思路

    • 每页按固定宽高布局
    • 以不被截断的最小固定高度布局
    • 不依赖布局,动态计算每页应放置的dom元素

    从页面布局的角度考虑,如果生成的网页按照与pdf固定的比例刚好是不会被截断的效果就直接解决问题了。因此最直接的方法为明确知道要生成几页pdf,网页按每页pdf的宽高映射一个固定的宽高,然后按照这个固定的宽高放置不超过该大小的dom。另一种方式则是以不被截断的最小固定高度布局,即每一块dom的高度固定,并且一页的高度刚好为该高度的整数倍,则不管怎样,最后生成的多页pdf都不会有截断的情况。由于生成的页面按照固定的pdf每页大小进行分割自然不会截断,生成pdf的代码只需要进行正常的多页pdf生成即可。

    如果网页无法进行固定大小的布局,在生成pdf的时候则需要计算每页pdf放置的dom达到刚好不被截断的边界情况。考虑到dom可能嵌套层级较多,并且对一些属性节点、文本节点不好计算高度,可以给dom元素添加标识来表示是否需要计算高度。

    此外,html2canvas将html生成canvas对象的过程比较慢,但生成多页pdf又需要将页面做拆分,因此可以只生成一个canvas对象,通过在添加canvas到pdf时设置图片定位达到截断的效果,如果页面需要有内边距,还需要在内边距的地方用空白遮挡多余的canvas内容。

    以下就每种实现思路以示例和代码做更详细的说明。

    每页按固定宽高布局

    适用场景:dom元素是确定的,每页可以按固定的宽高来布局,如固定格式的报表之类的。

    html结构示例如下:

    <style>
    .page {
      width: 1000px;
      height: 1600px;
    }
    </style>
    <div class="container">
      <div class="page"></div>
      <div class="page"></div>
      <div class="page"></div>
    </div>
    <script>
    outputPdf({
       element: document.querySelector('.content'),
       contentWidth: 500, // 0-592.28
       contentHeight: 800, // 0-841.89
    })
    </script>
    

    每个page元素内可以添加自定义的元素,但高度不应超过page的高度。

    contentWidth与contentHeight为一页pdf(A4大小)中放置的内容大小,page元素的宽高比必须等于 contentWidth/contentHeight 。

    生成多页pdf的主要代码如下:

    const pageNum = Math.ceil(height / contentHeight); // 总页数
    const arr = Array.from({ length: pageNum }).map((_, i) => i);
    for await (const i of arr) {
      addImage(baseX, baseY - i * contentHeight);
      const isFirst = i === 0;
      const isLast = i === arr.length - 1;
      if (!isFirst) {
        // 用空白遮挡顶部需要隐藏的部分
        pdf.addBlank(0, 0, A4_WIDTH, baseY);
      }
      if (!isLast) {
        // 用空白遮挡底部需要隐藏的部分
        pdf.addBlank(0, baseY + contentHeight, A4_WIDTH, A4_HEIGHT - (baseY + contentHeight));
      }
      await addHeader(isFirst);
      await addFooter(isLast);
      if (!isLast) {
        pdf.addPage();
      }
    }
    

    以不被截断的最小固定高度布局

    适用场景:dom元素是确定的,页面中每一块可以按固定的宽高来布局,如循环生成的列表数据,每一行高度固定,但列表个数是不固定的。

    html结构示例如下:

    <style>
    .item{
      width: 1000px;
      height: 400px;
    }
    </style>
    <div class="container">
      <div class="item"></div>
      <div class="item"></div>
      <div class="item"></div>
      <div class="item"></div>
      <div class="item"></div>
      <div class="item"></div>
      <div class="item"></div>
      <div class="item"></div>
      <div class="item"></div>
    </div>
    <script>
    outputPdf({
       element: document.querySelector('.content'),
       contentWidth: 500, // 0-592.28
       contentHeight: 800, // 0-841.89
    })
    </script>
    

    每个item元素内可以添加自定义的元素,但高度不应超过item的高度。

    contentWidth与contentHeight为一页pdf(A4大小)中放置的内容大小,实际dom每页的宽高比依然要等于 contentWidth/contentHeight,且一页的高度应为item的整数倍,如item的高度可取800、400、320、200等。

    该方式与第一种生成的dom元素本身不会出现截断,所以生成多页pdf的代码完全一样。进一步可以将第一种方式看成第二种方式的特殊情况,即item的高度直接为每页的高度。

    不依赖布局,动态计算每页应放置的dom元素

    适用场景:dom结构的大小不固定(需手动给dom元素添加标识)

    html结构示例如下:

    <style>
    .block{
      height: 323px;
    }
    .small-block {
      height: 155px;
    }
    </style>
    <div class="container">
      <div data-item class="small-block"></div>
      <div data-item class="block"></div>
      <div data-group>
        <div data-item class="small-block"></div>
        <div data-item class="small-block"></div>
        <div data-group>
          <div data-item class="small-block"></div>
          <div data-item class="small-block"></div>
          <div data-item class="small-block"></div>
          <div data-item class="small-block"></div>
          <div data-item class="block"></div>
        </div>
      </div>
      <div data-item class="block"></div>
      <div data-item>
        <div class="small-block"></div>
        <div class="small-block"></div>
        <div class="small-block"></div>
        <div class="small-block"></div>
      </div>
      <div data-item class="block"></div>
    </div>
    <script>
    outputPdf({
       element: document.querySelector('.content'),
       contentWidth: 500, // 0-592.28
       contentHeight: 800, // 0-841.89
    })
    </script>
    

    由于dom结构的大小不固定,这里使用data-item标识不被截断的元素,如果有嵌套的元素,则使用data-group标识,根据该标识遍历子节点中标识为data-item的元素。根据打了标识的元素计算出每页应放置的dom元素的高度,最后生成多页pdf,代码如下:

    // 从根节点遍历dom,计算出每页应放置的内容高度以保证不被截断
    const splitElement = () => {
      const res = [];
      let pos = 0;
      const elementWidth = element.offsetWidth;
      function updatePos (height) {
        if (pos + height <= contentHeight) {
          pos += height;
          return;
        }
        res.push(pos);
        pos = height;
      }
      function traversingNodes (nodes) {
        if (nodes.length === 0) return;
        nodes.forEach(one => {
          if (one.nodeType !== 1) return;
          const { [itemName]: item, [groupName]: group } = one.dataset;
          if (item != null) {
            const { offsetHeight } = one;
            // dom高度转换成生成pdf的实际高度
            // 代码不考虑dom定位、边距、边框等因素,需在dom里自行考虑,如将box-sizing设置为border-box
            updatePos(contentWidth / elementWidth * offsetHeight);
          } else if (group != null) {
            traversingNodes(one.childNodes);
          }
        });
      }
      traversingNodes(element.childNodes);
      res.push(pos);
      return res;
    };
    
    const elements = splitElement();
    let accumulationHeight = 0;
    let currentPage = 0;
    for await (const elementHeight of elements) {
      addImage(baseX, baseY - accumulationHeight);
      accumulationHeight += elementHeight;
      const isFirst = currentPage === 0;
      const isLast = currentPage === elements.length - 1;
      if (!isFirst) {
        pdf.addBlank(0, 0, A4_WIDTH, baseY);
      }
      if (!isLast) {
        pdf.addBlank(0, baseY + elementHeight, A4_WIDTH, A4_HEIGHT - (baseY + elementHeight));
      }
      await addHeader(isFirst);
      await addFooter(isLast);
      if (!isLast) {
        pdf.addPage();
      }
      currentPage++;
    }
    

    生成pdf的完整代码

    以下为生成pdf的完整代码,添加了页眉、页脚功能,附上代码便于直接使用。

    import jsPDF from 'jspdf';
    import html2canvas from 'html2canvas';
    
    const A4_WIDTH = 592.28;
    const A4_HEIGHT = 841.89;
    
    jsPDF.API.output2 = function (outputType = 'save', filename = 'document.pdf') {
      let result = null;
      switch (outputType) {
        case 'file':
          result = new File([this.output('blob')], filename, {
            type: 'application/pdf',
            lastModified: Date.now(),
          });
          break;
        case 'save':
          result = this.save(filename);
          break;
        default:
          result = this.output(outputType);
      }
      return result;
    };
    
    jsPDF.API.addBlank = function (x, y, width, height) {
      this.setFillColor(255, 255, 255);
      this.rect(x, y, Math.ceil(width), Math.ceil(height), 'F');
    };
    
    jsPDF.API.toCanvas = async function (element, width) {
      const canvas = await html2canvas(element);
      const canvasWidth = canvas.width;
      const canvasHeight = canvas.height;
      const height = (width / canvasWidth) * canvasHeight;
      const canvasData = canvas.toDataURL('image/jpeg', 1.0);
      return { width, height, data: canvasData };
    };
    
    jsPDF.API.addHeader = async function (x, width, header) {
      if (!(header instanceof HTMLElement)) return;
      let __header;
      if (this.__header) {
        __header = this.__header;
      } else {
        __header = await this.toCanvas(header, width);
        this.__header = __header;
      }
      const { height, data } = __header;
      this.addImage(data, 'JPEG', x, 0, width, height);
    };
    
    jsPDF.API.addFooter = async function (x, width, footer) {
      if (!(footer instanceof HTMLElement)) return;
      let __footer;
      if (this.__footer) {
        __footer = this.__footer;
      } else {
        __footer = await this.toCanvas(footer, width);
        this.__footer = __footer;
      }
      const { height, data } = __footer;
      this.addImage(data, 'JPEG', x, A4_HEIGHT - height, width, height);
    };
    
    /**
     * 生成pdf(处理多页pdf截断问题)
     * @param {Object} param
     * @param {HTMLElement} param.element - 需要转换的dom根节点
     * @param {number} [param.contentWidth=550] - 一页pdf的内容宽度,0-592.28
     * @param {number} [param.contentHeight=800] - 一页pdf的内容高度,0-841.89
     * @param {string} [param.outputType='save'] - 生成pdf的数据类型,添加了'file'类型,其他支持的类型见http://raw.githack.com/MrRio/jsPDF/master/docs/jsPDF.html#output
     * @param {string} [param.filename='document.pdf'] - pdf文件名
     * @param {number} param.x - pdf页内容距页面左边的高度,默认居中显示,为(A4宽度 - contentWidth) / 2)
     * @param {number} param.y - pdf页内容距页面上边的高度,默认居中显示,为(A4高度 - contentHeight) / 2)
     * @param {HTMLElement} param.header - 页眉dom元素
     * @param {HTMLElement} param.footer - 页脚dom元素
     * @param {boolean} [param.headerOnlyFirst=true] - 是否只在第一页添加页眉
     * @param {boolean} [param.footerOnlyLast=true] - 是否只在最后一页添加页脚
     * @param {string} [param.mode='adaptive'] - 生成pdf的模式,支持'adaptive'、'fixed','adaptive'需给dom添加标识,'fixed'需固定布局。
     * @param {string} [param.itemName='item'] - 给dom添加元素标识的名字,'adaptive'模式需在dom中设置
     * @param {string} [param.groupName='group'] - 给dom添加组标识的名字,'adaptive'模式需在dom中设置
     * @returns {Promise} 根据outputType返回不同的数据类型
     */
    async function outputPdf ({
      element, contentWidth = 550, contentHeight = 800,
      outputType = 'save', filename = 'document.pdf', x, y,
      header, footer, headerOnlyFirst = true, footerOnlyLast = true,
      mode = 'adaptive', itemName = 'item', groupName = 'group',
    }) {
      if (!(element instanceof HTMLElement)) {
        throw new Error('The root element must be HTMLElement.');
      }
    
      const pdf = new jsPDF({
        unit: 'pt',
        format: 'a4',
        orientation: 'p',
      });
      const { width, height, data } = await pdf.toCanvas(element, contentWidth);
      const baseX = x == null ? (A4_WIDTH - contentWidth) / 2 : x;
      const baseY = y == null ? (A4_HEIGHT - contentHeight) / 2 : y;
      async function addHeader (isFirst) {
        if (isFirst || !headerOnlyFirst) {
          await pdf.addHeader(baseX, contentWidth, header);
        }
      }
      async function addFooter (isLast) {
        if (isLast || !footerOnlyLast) {
          await pdf.addFooter(baseX, contentWidth, footer);
        }
      }
      function addImage (_x, _y) {
        pdf.addImage(data, 'JPEG', _x, _y, width, height);
      }
    
      const params = {
        element, contentWidth, contentHeight, itemName, groupName,
        pdf, baseX, baseY, width, height, addImage, addHeader, addFooter,
      };
      switch (mode) {
        case 'adaptive':
          await outputWithAdaptive(params);
          break;
        case 'fixed':
        default:
          await outputWithFixedSize(params);
      }
      return pdf.output2(outputType, filename);
    }
    
    async function outputWithFixedSize ({
      pdf, baseX, baseY, height, addImage, addHeader, addFooter, contentHeight,
    }) {
      const pageNum = Math.ceil(height / contentHeight); // 总页数
      const arr = Array.from({ length: pageNum }).map((_, i) => i);
      for await (const i of arr) {
        addImage(baseX, baseY - i * contentHeight);
        const isFirst = i === 0;
        const isLast = i === arr.length - 1;
        if (!isFirst) {
          // 用空白遮挡顶部需要隐藏的部分
          pdf.addBlank(0, 0, A4_WIDTH, baseY);
        }
        if (!isLast) {
          // 用空白遮挡底部需要隐藏的部分
          pdf.addBlank(0, baseY + contentHeight, A4_WIDTH, A4_HEIGHT - (baseY + contentHeight));
        }
        await addHeader(isFirst);
        await addFooter(isLast);
        if (!isLast) {
          pdf.addPage();
        }
      }
    }
    
    async function outputWithAdaptive ({
      element, contentWidth, itemName, groupName,
      pdf, baseX, baseY, addImage, addHeader, addFooter, contentHeight,
    }) {
      // 从根节点遍历dom,计算出每页应放置的内容高度以保证不被截断
      const splitElement = () => {
        const res = [];
        let pos = 0;
        const elementWidth = element.offsetWidth;
        function updatePos (height) {
          if (pos + height <= contentHeight) {
            pos += height;
            return;
          }
          res.push(pos);
          pos = height;
        }
        function traversingNodes (nodes) {
          if (nodes.length === 0) return;
          nodes.forEach(one => {
            if (one.nodeType !== 1) return;
            const { [itemName]: item, [groupName]: group } = one.dataset;
            if (item != null) {
              const { offsetHeight } = one;
              // dom高度转换成生成pdf的实际高度
              // 代码不考虑dom定位、边距、边框等因素,需在dom里自行考虑,如将box-sizing设置为border-box
              updatePos(contentWidth / elementWidth * offsetHeight);
            } else if (group != null) {
              traversingNodes(one.childNodes);
            }
          });
        }
        traversingNodes(element.childNodes);
        res.push(pos);
        return res;
      };
    
      const elements = splitElement();
      let accumulationHeight = 0;
      let currentPage = 0;
      for await (const elementHeight of elements) {
        addImage(baseX, baseY - accumulationHeight);
        accumulationHeight += elementHeight;
        const isFirst = currentPage === 0;
        const isLast = currentPage === elements.length - 1;
        if (!isFirst) {
          pdf.addBlank(0, 0, A4_WIDTH, baseY);
        }
        if (!isLast) {
          pdf.addBlank(0, baseY + elementHeight, A4_WIDTH, A4_HEIGHT - (baseY + elementHeight));
        }
        await addHeader(isFirst);
        await addFooter(isLast);
        if (!isLast) {
          pdf.addPage();
        }
        currentPage++;
      }
    }
    
    export default outputPdf;
    
    

    参考资源

    相关文章

      网友评论

        本文标题:【原创】jspdf+html2canvas生成多页pdf防截断处

        本文链接:https://www.haomeiwen.com/subject/wolcbctx.html