折腾我2周的分页打印和下载pdf实现

时事   2024-11-14 10:02   浙江  

1.背景

一开始接到任务需要打印html,之前用到了vue-print-nb-jeecg来处理Vue2一个打印的问题,现在是遇到需求要在Vue3项目里面去打印十几页的打印和下载为pdf,难点和坑就在于我用的库vue3-print-nb来做分页打印预览,下载pdf后面介绍

2.预览打印实现

    <div id="printMe" style="background:red;">
        <p>葫芦娃,葫芦娃</p>
        <p>一根藤上七朵花 </p>
        <p>小小树藤是我家 啦啦啦啦 </p>
        <p>叮当当咚咚当当 浇不大</p>
        <p> 叮当当咚咚当当 是我家</p>
        <p> 啦啦啦啦</p>
        <p>...</p>
    </div>

    <button v-print="'#printMe'">Print local range</
button>

因为官方提供的方案都是DOM加载完成后然后直接打印,但是我的需求是需要点击打印的时候根据id渲染不同的组件然后渲染DOM,后面仔细看官方文档,有个beforeOpenCallback方法在打印预览之前有个钩子,但是这个钩子没办法确定我接口加载完毕,所以我的思路就是用户先点击我写的点击按钮事件,等异步渲染完毕之后,我再同步触发真正的打印预览按钮,这样就变相解决了我的需求。

  1. 没办法处理接口异步渲染数据展示DOM进行打印操作
  2. 在布局相对定位的时候在谷歌浏览器会发现有布局整体变小的问题(后续用zoom处理的)

3.掉头发之下载pdf

下载pdf这种需求才是我每次去理发店不敢让tony把我头发打薄的原因,我看了很多技术文章,结合个人业务情况,采取的方案是html2canvas把html转成canvas然后转成图片然后通过jsPDF截取图片分页最后下载到本地。本人秉承着不生产水,只做大自然的搬运工的匠人精神,迅速而又果断的从社区来到社区去,然后找到了适配当前业务的逻辑代码(实践出真知)。

import html2canvas from 'html2canvas'
import jsPDF, { RGBAData } from 'jspdf'

/** a4纸的尺寸[595.28,841.89], 单位毫米 */
const [PAGE_WIDTH, PAGE_HEIGHT] = [595.28841.89]

const PAPER_CONFIG = {
  /** 竖向 */
  portrait: {
    height: PAGE_HEIGHT,
    width: PAGE_WIDTH,
    contentWidth: 560
  },
  /** 横向 */
  landscape: {
    height: PAGE_WIDTH,
    width: PAGE_HEIGHT,
    contentWidth: 800
  }
}

// 将元素转化为canvas元素
// 通过 放大 提高清晰度
// width为内容宽度
async function toCanvas(element: HTMLElement, width: number{
  if (!element) return { width, height: 0 }

  // canvas元素
  const canvas = await html2canvas(element, {
    // allowTaint: true, // 允许渲染跨域图片
    scale: window.devicePixelRatio * 2// 增加清晰度
    useCORS: true // 允许跨域
  })

  // 获取canvas转化后的宽高
  const { width: canvasWidth, height: canvasHeight } = canvas

  // html页面生成的canvas在pdf中的高度
  const height = (width / canvasWidth) * canvasHeight

  // 转化成图片Data
  const canvasData = canvas.toDataURL('image/jpeg'1.0)

  return { width, height, data: canvasData }
}

/**
 * 生成pdf(A4多页pdf截断问题, 包括页眉、页脚 和 上下左右留空的护理)
 * @param param0
 * @returns
 */

export async function outputPDF({
  /** pdf内容的dom元素 */
  element,

  /** 页脚dom元素 */
  footer,

  /** 页眉dom元素 */
  header,

  /** pdf文件名 */
  filename,

  /** a4值的方向: portrait or landscape */
  orientation = 'portrait' as 'portrait' | 'landscape'
}
{
  if (!(element instanceof HTMLElement)) {
    return
  }

  if (!['portrait''landscape'].includes(orientation)) {
    return Promise.reject(
      new Error(`Invalid Parameters: the parameter {orientation} is assigned wrong value, you can only assign it with {portrait} or {landscape}`)
    )
  }
  const [A4_WIDTH, A4_HEIGHT] = [PAPER_CONFIG[orientation].width, PAPER_CONFIG[orientation].height]

  /** 一页pdf的内容宽度, 左右预设留白 */
  const { contentWidth } = PAPER_CONFIG[orientation]

  // eslint-disable-next-line new-cap
  const pdf = new jsPDF({
    unit: 'pt',
    format: 'a4',
    orientation
  })

  // 一页的高度, 转换宽度为一页元素的宽度
  const { width, height, data } = await toCanvas(element, contentWidth)

  // 添加
  function addImage(
    _x: number,
    _y: number,
    pdfInstance: jsPDF,
    base_data: string | HTMLImageElement | HTMLCanvasElement | Uint8Array | RGBAData,
    _width: number,
    _height: number
  
{
    pdfInstance.addImage(base_data, 'JPEG', _x, _y, _width, _height)
  }

  // 增加空白遮挡
  function addBlank(x: number, y: number, _width: number, _height: number{
    pdf.setFillColor(255255255)
    pdf.rect(x, y, Math.ceil(_width), Math.ceil(_height), 'F')
  }

  // 页脚元素 经过转换后在PDF页面的高度
  const { height: tFooterHeight, data: headerData } = footer ? await toCanvas(footer, contentWidth) : { height: 0, data: undefined }

  // 页眉元素 经过转换后在PDF的高度
  const { height: tHeaderHeight, data: footerData } = header ? await toCanvas(header, contentWidth) : { height: 0, data: undefined }

  // 添加页脚
  async function addHeader(headerElement: HTMLElement{
    headerData && pdf.addImage(headerData, 'JPEG'00, contentWidth, tHeaderHeight)
  }

  // 添加页眉
  async function addFooter(pageNum: number, now: number, footerElement: HTMLElement{
    if (footerData) {
      pdf.addImage(footerData, 'JPEG'0, A4_HEIGHT - tFooterHeight, contentWidth, tFooterHeight)
    }
  }

  // 距离PDF左边的距离,/ 2 表示居中
  const baseX = (A4_WIDTH - contentWidth) / 2 // 预留空间给左边
  // 距离PDF 页眉和页脚的间距, 留白留空
  const baseY = 15

  // 除去页头、页眉、还有内容与两者之间的间距后 每页内容的实际高度
  const originalPageHeight = A4_HEIGHT - tFooterHeight - tHeaderHeight - 2 * baseY

  // 元素在网页页面的宽度
  const elementWidth = element.offsetWidth

  // PDF内容宽度 和 在HTML中宽度 的比, 用于将 元素在网页的高度 转化为 PDF内容内的高度, 将 元素距离网页顶部的高度  转化为 距离Canvas顶部的高度
  const rate = contentWidth / elementWidth

  // 每一页的分页坐标, PDF高度, 初始值为根元素距离顶部的距离
  const pages = [rate * getElementTop(element)]

  // 获取该元素到页面顶部的高度(注意滑动scroll会影响高度)
  function getElementTop(contentElement{
    if (contentElement.getBoundingClientRect) {
      const rect = contentElement.getBoundingClientRect() || {}
      const topDistance = rect.top

      return topDistance
    }
  }

  // 遍历正常的元素节点
  function traversingNodes(nodes{
    for (const element of nodes) {
      const one = element

      /** */
      /** 注意: 可以根据业务需求,判断其他场景的分页,本代码只判断表格的分页场景 */
      /** */

      // table的每一行元素也是深度终点
      const isTableRow = one.classList && one.classList.contains('ant4-table-row')

      // 对需要处理分页的元素,计算是否跨界,若跨界,则直接将顶部位置作为分页位置,进行分页,且子元素不需要再进行判断
      const { offsetHeight } = one
      // 计算出最终高度
      const offsetTop = getElementTop(one)

      // dom转换后距离顶部的高度
      // 转换成canvas高度
      const top = rate * offsetTop
      const rateOffsetHeight = rate * offsetHeight

      // 对于深度终点元素进行处理
      if (isTableRow) {
        // dom高度转换成生成pdf的实际高度
        // 代码不考虑dom定位、边距、边框等因素,需在dom里自行考虑,如将box-sizing设置为border-box
        updateTablePos(rateOffsetHeight, top)
      }
      // 对于普通元素,则判断是否高度超过分页值,并且深入
      else {
        // 执行位置更新操作
        updateNormalElPos(top)
        // 遍历子节点
        traversingNodes(one.childNodes)
      }
      updatePos()
    }
  }

  // 普通元素更新位置的方法
  // 普通元素只需要考虑到是否到达了分页点,即当前距离顶部高度 - 上一个分页点的高度 大于 正常一页的高度,则需要载入分页点
  function updateNormalElPos(top{
    if (top - (pages.length > 0 ? pages[pages.length - 1] : 0) >= originalPageHeight) {
      pages.push((pages.length > 0 ? pages[pages.length - 1] : 0) + originalPageHeight)
    }
  }

  // 可能跨页元素位置更新的方法
  // 需要考虑分页元素,则需要考虑两种情况
  // 1. 普通达顶情况,如上
  // 2. 当前距离顶部高度加上元素自身高度 大于 整页高度,则需要载入一个分页点
  function updateTablePos(eHeight: number, top: number{
    // 如果高度已经超过当前页,则证明可以分页了
    if (top - (pages.length > 0 ? pages[pages.length - 1] : 0) >= originalPageHeight) {
      pages.push((pages.length > 0 ? pages[pages.length - 1] : 0) + originalPageHeight)
    }
    // 若 距离当前页顶部的高度 加上元素自身的高度 大于 一页内容的高度, 则证明元素跨页,将当前高度作为分页位置
    else if (
      top + eHeight - (pages.length > 0 ? pages[pages.length - 1] : 0) > originalPageHeight &&
      top !== (pages.length > 0 ? pages[pages.length - 1] : 0)
    ) {
      pages.push(top)
    }
  }

  // 深度遍历节点的方法
  traversingNodes(element.childNodes)

  function updatePos({
    while (pages[pages.length - 1] + originalPageHeight < height) {
      pages.push(pages[pages.length - 1] + originalPageHeight)
    }
  }

  // 对pages进行一个值的修正,因为pages生成是根据根元素来的,根元素并不是我们实际要打印的元素,而是element,
  // 所以要把它修正,让其值是以真实的打印元素顶部节点为准
  const newPages = pages.map(item => item - pages[0])

  // 根据分页位置 开始分页
  for (let i = 0; i < newPages.length; ++i) {
    // 根据分页位置新增图片
    addImage(baseX, baseY + tHeaderHeight - newPages[i], pdf, data!, width, height)
    // 将 内容 与 页眉之间留空留白的部分进行遮白处理
    addBlank(0, tHeaderHeight, A4_WIDTH, baseY)
    // 将 内容 与 页脚之间留空留白的部分进行遮白处理
    addBlank(0, A4_HEIGHT - baseY - tFooterHeight, A4_WIDTH, baseY)
    // 对于除最后一页外,对 内容 的多余部分进行遮白处理
    if (i < newPages.length - 1) {
      // 获取当前页面需要的内容部分高度
      const imageHeight = newPages[i + 1] - newPages[i]
      // 对多余的内容部分进行遮白
      addBlank(0, baseY + imageHeight + tHeaderHeight, A4_WIDTH, A4_HEIGHT - imageHeight)
    }

    // 添加页眉
    if (header) {
      await addHeader(header)
    }

    // 添加页脚
    if (footer) {
      await addFooter(newPages.length, i + 1, footer)
    }

    // 若不是最后一页,则分页
    if (i !== newPages.length - 1) {
      // 增加分页
      pdf.addPage()
    }
  }
  return pdf.save(filename)
}

4.分页的小姿势

如果有需求把打印预览的时候的页眉页脚默认取消不展示,然后自定义页面的边距可以这么设置样式

@page {
  size: auto A4 landscape;
  margin3mm;
}

@media print {
  body,
  html {
    height: initial;
    padding0px;
    margin0px;
  }
}

5.关于页眉页脚

由于业务是属于比较自定义化的展示,所以我封装成组件,然后根据返回的数据进行渲染到每个界面,然后利用绝对定位放在相同的位置,最后一点小优化就是,公共化提取界面的样式,然后整合为pub.scss然后引入到界面里面,这样即使产品有一定的样式调整,我也可以在公共样式里面去配置和修改,大大的减少本人的工作量。在日常的开发中也是这样,不要去抱怨需求的变动频繁,而是力争在写组件的过程中考虑到组件的健壮性和灵活度,给自己的工作减负,到点下班。

推荐阅读  点击标题可跳转

1、都应该会的前端代码规范 - 日志打印规范

2、不止WebSocket可以实现长连接,它也可以

3、【干货】前端大佬是如何利用Devtools让自己开发飞起来的

前端大全
点击获取精选前端开发资源。「前端大全」日常分享 Web 前端相关的技术文章、实用案例、工具资源、精选课程、热点资讯。
 最新文章