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

将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;

参考资源

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