浏览器打印的两种实践方案

3,193 阅读3分钟

1.浏览器自身打印

  • 使用 window.print() 调起浏览器自带的打印预览弹框打印
  • 默认会打印 body 里面所有内容
const handlePrintPdf = () => {
  window.print();
}

如果想要局部打印可以使用 CSS 媒体查询,甚至还可以自定义打印时的样式,这些样式只会在打印的时候生效

<!-- 方式1 -->
<link rel="stylesheet" href="./print.css" media="print">

<!-- 方式2 -->
<style media="print"></style>

<!-- 方式3 -->
<style>
  @media print {}
</style>

<!-- 方式4 -->
<style>
  @import url("./print.css") print;
</style>

可使用媒体查询在打印的时候去除页眉页脚,设置打印布局等操作

@media print {
      @page {
        /* 去除页眉 */ 
        /* margin-top: 0; */ 
        /* 去除页脚 */ 
        /* margin-bottom: 0; */ 
        /* 去掉页眉和页脚 */
        margin: 0;
        /* 纵向 */
        size: portrait;
        /* 横向 A4 */
        size:A4 landscape;
      }

      body {
        margin: 1cm;
      }

    }

如果内容固定且具有分页,浏览器自身分页可能会和预想中情况不一样,我们可以使用 CSS 进行强制分页

@media print {
        /* 在main元素前始终插入分页符,强制使其分到下一页 */
        main {
          page-break-before: always;
        }
        /* 在footer元素后始终插入分页符 */
        footer {
          page-break-after: always;
        }
      }

打印部分区域的内容还可以这样做

动态创建一个不可见的 iframe, 将需要打印的 dom 节点插入 iframe 内,并调用 iframeprint 方法

printBtn.addEventListener("click", () => {
  const printContentHtml = document.getElementById("print").innerHTML;
  const iframe = document.createElement("iframe");
  iframe.setAttribute(
    "style",
    "position:absolute;width:0px;height:0px;left:-500px;top:-500px;"
  );
  document.body.appendChild(iframe);
  iframe.contentDocument.write(printContentHtml);
  iframe.contentDocument.close();
  iframe.contentWindow.print();
  document.body.removeChild(iframe);
})

或者在新打开的页面打印

printBtn.addEventListener("click", () => {
  const printContentHtml = document.getElementById("print").innerHTML;
  const printPage = window.open();
  printPage.document.write(printContentHtml);
  printPage.document.close();
  printPage.print();
  printPage.close();
})

当然我们也可以这样,先获取需要打印的 dom 节点,替换当前 body 下的节点。完成打印后恢复 body 下的节点

<body>
  <p class="para1">段落1</p>
  <p class="para2">段落2</p>
  <p class="para3">段落3</p>

  <button id="printBtn">打印</button>

  <script>
    printBtn.addEventListener("click", () => {
      // 缓存页面内容
      const bodyHtml = document.body.innerHTML;
      // 获取打印内容
      const printHtml = document.querySelector(".para2").innerHTML;
      // 替换body内容
      document.body.innerHTML = printHtml;
      // 打印
      window.print();
      // 还原页面内容
      document.body.innerHTML = bodyHtml;
    });
  </script>
</body>

这样的话打印的时候页面原有元素会丢失,并且这时可能图片也未加载完成,很多元素宽度也会有问题要调整,我们可以更进一步封装方便修改,整体代码如下:

<body>
  <p class="para1">段落1~</p>
  <p class="para2">段落2~</p>
  <p class="para3">段落3~</p>
  <button id="printBtn">打印</button>

  <script>
    printBtn.addEventListener("click", () => {
      // 获取打印的局部内容
      const para2Content = document.querySelector(".para2").innerHTML;
      printHtml(para2Content);
    });

    function printHtml(html) {
      let printContainer = getContainer(html);
      let printStyle = getStyle();
      // 添加到页面
      document.body.appendChild(printStyle);
      document.body.appendChild(printContainer);

      // 等待页面图片加载完成开始打印,并在打印完成之后移除旧ODM
      LoadedImgsPromise(printContainer).then(() => {
        window.print();
        document.body.removeChild(printStyle);
        document.body.removeChild(printContainer);
      });
    }

    // 新建DOM,将需要打印的内容填充到DOM
    function getContainer(html) {
      cleanBeforePrintDom();
      let container = document.createElement("div");
      container.setAttribute("id", "print-container");
      container.innerHTML = html;
      return container;
    }

    // 创建新的打印DOM前先移除之前可能存在的旧DOM
    function cleanBeforePrintDom() {
      let printContainer = document.getElementById("print-container");
      if (printContainer) document.body.removeChild(printContainer);
    }

    // 设置打印样式
    function getStyle() {
      let styleContent = `
        #print-container {
            display: none;
        }
        @media print {
            body > :not(.print-container) {
                display: none;
            }
            html,
            body {
                display: block;
            }
            #print-container {
                display: block;
            }
        }`;
      let style = document.createElement("style");
      style.innerHTML = styleContent;
      return style;
    }

    // 图片完整加载
    function LoadedImgsPromise(element) {
      const imgs = element.querySelectorAll("img");
      if (imgs.length === 0) return Promise.resolve();
      let loadedImgCount = 0;
      return new Promise((resolve) => {
        const checkimgCount = () => {
          loadedImgCount++;
          if (loadedImgCount === imgs.length) {
            resolve();
          }
        };
        imgs.forEach(() => {
          img.addEventListener("load", checkimgCount);
          img.addEventListener("error", checkimgCount);
        });
      });
    }
  </script>
</body>

很可惜此类方案和很多第三方库会有元素丢失和样式错乱的情况,不尽如人意

此时一个方案浮出水面

2.html2canvas和jspdf

  • 简单说就是将网页通过 html2canvasdom-to-image也可以) 转换为图片,再由 jspdf 生成该图片的 pdf
  • 此方案也会有一些问题,例如 iframe 嵌套(项目中富文本编辑器会用到)的内容会无法打印,无法复制 pdf 上的文字等内容,而且清晰度可能会存在问题
  • 图片输出 pdf 的时候根据宽高裁切,分页的时候可能会有内容生生的被截断,需要根据不同项目特殊处理(逻辑复杂)

先来说说这两者基本使用

安装:

yarn add html2canvas jspdf

使用 html2canvas 对页面截图

// allowTaint 是否允许跨域加载图片
// useCORS 是否使用CORS从服务器加载图片
// scale 渲染像素比率,默认当前设备像素比
printBtn.addEventListener("click", () => {
  html2Canvas(wrap, { allowTaint: true, useCORS: true, scale: 2 }).then(
    (canvas) => {
      document.body.appendChild(canvas);
      // 转换canvase为base64图片,第二个参数1代表图片质量(0-1)
      canvas.toDataURL("image/jpeg", 1);
    }
  );
});

此方法无法打印 iframe 里面的内容,于是我遍历整个 iframedom 替换,此外我们还要书写逻辑手动对图片指定宽高进行截图分页,此部分可以将其封装下方便复用,具体方案如下

<script setup lang="ts">
import HelloWorld from "./components/HelloWorld.vue";
import html2Canvas from "html2canvas";
import jsPdf from "jspdf";
import domtoimage from "dom-to-image";

async function htmlToPdf(selector) {
  // 将html dom节点生成canvas
  let htmlCanvas = await getCanvasByselector(selector);

  // 将canvas对象转为pdf
  let pdf = canvasToPdf(htmlCanvas);

  // 通过浏览器下载pdf
  await downPdf(pdf, "我的pdf");
}

async function getCanvasByselector(selector) {
  let printDom = document.querySelector(selector);

  printDom.querySelectorAll("iframe").forEach((iframe) => {
    // 创建文档碎片和新的div
    let $fragment = document.createDocumentFragment();
    let newDiv = document.createElement("p");
    // ....中间可以设置newDiv属性和样式等....
    // 拿到 iframe 里面每个元素复制
    Array.from(iframe.contentWindow.document.body.children).forEach((ele) => {
      $fragment.appendChild(ele);
    });
    newDiv.appendChild($fragment);
    // 插入newDiv,内容和iframe一致
    iframe.parentNode.insertBefore(newDiv, iframe);
    // 移除iframe
    iframe.remove();
  });

  // allowTaint: true 表示允许跨越的图片加载
  // useCORS 是否尝试使用 CORS 从服务器加载图片
  // scale  渲染的像素比率,默认为当前设备的设备像素比
  let canvas = await html2Canvas(printDom, {
    allowTaint: true,
    useCORS: true,
    scale: 1,
  });
  return canvas;
}

function canvasToPdf(canvas) {
  // a4纸的正常尺寸是宽592.28,高是841.89
  const pageWidth = 592.28;
  const pageHeight = 841.89;
  // 设置内容的宽高
  const contentWidth = canvas.width;
  const contentHeight = canvas.height;
  // 默认的偏移量
  let position = 0;
  // 设置生成图片的宽高
  const imgCanvasWidth = pageWidth;
  const imgCanvasHeight = (592.28 / contentWidth) * contentHeight;
  let imageHeight = imgCanvasHeight;
  // 生成canvas截图,1表示生成的截图质量(0-1)
  let pageData = canvas.toDataURL("image/jpeg", 1);
  // new jsPdf接收三个参数,landscape表示横向,(默认不填是纵向),打印单位和纸张尺寸
  let PDF = new jsPdf("", "pt", "a4");
  // 当内容不超过a4纸一页的情况下
  if (imageHeight < pageHeight) {
    PDF.addImage(pageData, "JPEG", 20, 20, imgCanvasWidth, imgCanvasHeight);
  } else {
    // 当内容超过a4纸一页的情况下,需要增加一页
    while (imageHeight > 0) {
      PDF.addImage(
        pageData,
        "JPEG",
        20,
        position,
        imgCanvasWidth,
        imgCanvasHeight
      );
      imageHeight -= pageHeight;
      position -= pageHeight;
      // 避免添加空白页
      if (imageHeight > 0) {
        PDF.addPage();
      }
    }
  }
  return PDF;
}

function downPdf(pdfInstance: any, title: string) {
  let maxFileNameLen = 50;

  // 文件名过长导致下载失败
  if (title.length > maxFileNameLen) {
    title = title.substring(title.length - maxFileNameLen);
  }

  return pdfInstance.save(title + ".pdf", { returnPromise: true });
}

const handlePrintPDF = () => {
  htmlToPdf("#wrap");
};
</script>

注意打印完之后最好在顶层组件封装个 reload 方法关联 v-if 通过 provide 提供下去,组件调用下重新渲染一遍(因为之前我们替换了页面元素,不刷新还原可能会有问题)

reload() {
  this.isRouteAlive = false;
  this.$nextTick(() => {
    this.isRouteAlive = true;
  });
},

最后这个截断问题比较棘手,我们可以这样解决

  • 设置对应转换后的 canvas 为白色背景
  • 转换图片后获取对应截断处的图片像素
  • 从截断处一行行往上扫描像素点颜色
  • 碰到这一行是全白的,代表从这里截断,将这个高度往下的内容放到下一页

具体代码如下!

<script setup lang="ts">
import HelloWorld from "./components/HelloWorld.vue";
import html2Canvas from "html2canvas";
import jsPdf from "jspdf";
import domtoimage from "dom-to-image";

async function htmlToPdf(selector) {
  // 将html dom节点生成canvas
  let htmlCanvas = await getCanvasByselector(selector);

  // 将canvas对象转为pdf
  let pdf = canvasToPdf(htmlCanvas, "我的pdf");
}

async function getCanvasByselector(selector) {
  let printDom = document.querySelector(selector);

  printDom.querySelectorAll("iframe").forEach((iframe) => {
    // 创建文档碎片和新的div
    let $fragment = document.createDocumentFragment();
    let newDiv = document.createElement("p");
    // ....中间可以设置newDiv属性和样式等....
    // 拿到 iframe 里面每个元素复制
    Array.from(iframe.contentWindow.document.body.children).forEach((ele) => {
      $fragment.appendChild(ele);
    });
    newDiv.appendChild($fragment);
    // 插入newDiv,内容和iframe一致
    iframe.parentNode.insertBefore(newDiv, iframe);
    // 移除iframe
    iframe.remove();
  });

  // allowTaint: true 表示允许跨越的图片加载
  // useCORS 是否尝试使用 CORS 从服务器加载图片
  // scale  渲染的像素比率,默认为当前设备的设备像素比
  let canvas = await html2Canvas(printDom, {
    allowTaint: true,
    useCORS: true,
    scale: 1,
  });
  return canvas;
}

function canvasToPdf(canvas, title) {
  //未生成pdf的html页面高度
  let leftHeight = canvas.height;
  let a4Width = 595.28;
  let a4Height = 841.89; //A4大小,210mm x 297mm,四边各保留10mm的边距,显示区域190x277
  //一页pdf显示html页面生成的canvas高度;
  let a4HeightRef = Math.floor((canvas.width / a4Width) * a4Height);

  //pdf页面偏移
  let position = 0;

  let pageData = canvas.toDataURL("image/jpeg", 1.0);

  let pdf = new jsPdf("p", "pt", "a4"); //A4纸,纵向
  let index = 1,
    canvas1 = document.createElement("canvas"),
    height;
  pdf.setDisplayMode("fullwidth", "continuous", "FullScreen");

  let pdfName = title;
  function createImpl(canvas) {
    console.log(leftHeight, a4HeightRef);
    if (leftHeight > 0) {
      index++;

      let checkCount = 0;
      if (leftHeight > a4HeightRef) {
        let i;
        for (i = position + a4HeightRef; i >= position; i--) {
          let isWrite = true;
          for (let j = 0; j < canvas.width; j++) {
            let c = canvas.getContext("2d").getImageData(j, i, 1, 1).data;
            if (c[0] != 0xff || c[1] != 0xff || c[2] != 0xff) {
              isWrite = false;
              break;
            }
          }
          if (isWrite) {
            checkCount++;
            if (checkCount >= 10) {
              break;
            }
          } else {
            checkCount = 0;
          }
        }
        height = Math.round(i - position) || Math.min(leftHeight, a4HeightRef);
        console.log("Math.round(i - position)", Math.round(i - position));
        console.log("Math.min(leftHeight, a4HeightRef);", Math.min(leftHeight, a4HeightRef));
        
        console.log("height", height);
        
        if (height <= 0) {
          height = a4HeightRef;
        }
      } else {  height = leftHeight;
      }

      canvas1.width = canvas.width;
      canvas1.height = height;

      console.log(index, "height:", height, "pos", position);

      let ctx = canvas1.getContext("2d");
      ctx.drawImage(
        canvas,
        0,
        position,
        canvas.width,
        height,
        0,
        0,
        canvas.width,
        height
      );

      if (position != 0) {
        pdf.addPage();
      }
      pdf.addImage(
        canvas1.toDataURL("image/jpeg", 1.0),
        "JPEG",
        10,
        10,
        a4Width,
        (a4Width / canvas1.width) * height
      );
      leftHeight -= height;
      position += height;
      if (leftHeight > 0) {
        setTimeout(createImpl, 500, canvas);
      } else {
        pdf.save(pdfName + ".pdf");
      }
    }
  }

  //当内容未超过pdf一页显示的范围,无需分页
  if (leftHeight < a4HeightRef) {
    pdf.addImage(
      pageData,
      "JPEG",
      0,
      0,
      a4Width,
      (a4Width / canvas.width) * leftHeight
    );
    pdf.save(pdfName + ".pdf");
  } else {
    try {
      pdf.deletePage(0);
      setTimeout(createImpl, 500, canvas);
    } catch (err) {
      // console.log(err);
    }
  }
}

// function downPdf (pdfInstance: any, title: string) {
//     let maxFileNameLen = 50;

//     // 文件名过长导致下载失败
//     if (title.length > maxFileNameLen) {
//         title = title.substring(title.length - maxFileNameLen);
//     }

//     return pdfInstance.save(title + '.pdf', { returnPromise: true });
// }

const handlePrintPDF = () => {
  htmlToPdf("#wrap");
};
</script>