前端pdf加水印解决方案

6,964 阅读11分钟

需求背景

最近有需求需要做一个小工具:生成 pdf 水印的网页工具。

探讨解决方案

方案 1一开始是想前端传递文件、水印文案给后端,然后由服务器生成,然后返回前端制作好的带水印的 pdf 链接提供下载。

然后就按照这个方案开始了行动,后端同事一顿噼里啪啦的操作最后使用 python 的 reportlab 完成了本次需求的重中之重的工作。未联调接口之前,程序工作的挺好的。

原本以为这样就结束了本次需求,谁知联调之后问题来了,发现需上传的文件可能几十上百兆,需生成 pdf 的水印文案也可能多达十数个,生成时间就成了一个大问题。据测试单个 10+M 的 pdf 文件加完一个水印就需要 30s 左右,大大的降低了工具效率。

方案 1 失败


方案 2然后提出方案在服务器保留该次任务,等用户下次再打开该页面再来下载之前处理好的文件,但是这样也有效率问题,而且前后端需要两次交互才能把文件下载下来,效率不高。本着效率至上的原则,pass 掉了后端生成的方案。

于是便想着看是否能在前端就把 pdf 给处理完成,不与后端交互,效率肯定能大大的提升起来,在 github 搜索 pdf,筛选 js 库,可以看到很多优秀的库,如:pdf.js(30.9k)/jsPDF(19.2k)/pdfmake(8.3k)/pdfkit(6.2k)/pdf-lib(1.1k)。仿佛看到了巨大的希望,从星星最高的开始查阅,哪个库能够符合我的需求。

pdf.js (官方 api 文档还未完善,只有一个 api.js 的文档,但阅读起来有点费劲) 确实是非常优秀的 pdf 阅读库,提供了非常多的功能,但大多数都是查看类的,没有能够操作的方法,但是其提供了一个在 canvas 中显示的方法(默默记下了这个方法)

jsPDF/pdfmake/pdfkit 都是用来生成 pdf 的库,但是阅读文档后没有发现能够在源文档上直接操作的方法。值得注意的是他们提供了可以从 canvas 中生成 pdf 的方法。

思考:把 pdf 每一页都画在 canvas 上,然后通过 canvas 的 api 在 pdf 上层画一层水印,最后再把 canvas 转成 pdf 下载下来不就可以了么,思路有了就开始实现吧

瞎 B 操作:

/**html代码省略**/

$('#upload-input').change(async function () {
        var files = $(this)[0].files;
        if (files.length) {
          $('#file-name').html(files[0].name);
          showPdf(await files[0].arrayBuffer(),files[0].name)
        }
      })

      function showPdf(file,filename) {
        var size = 80;
        var text = '我是';
        pdfjsLib.workerSrc = '<?php echo CDN; ?>js/pdfjs/pdf.worker.js';
        pdfjsLib.getDocument(file).promise.then(function (pdf) {
          var pageNum = 1;
          var time = 0;
          var timer = setInterval(() => time += 1010)
          var new_pdf;
          setCanvas()
          function setCanvas() {
            pdf.getPage(pageNum).then(function (page) {
              var scale = 1.5;
              var viewport = page.getViewport({scale: scale,});
              var {offsetX, offsetY, width, height} = viewport;
              var orientation = width <= height ? 'p' : 'l'
              var canvas = document.getElementById('the-canvas');
              var wm_canvas = document.getElementById('watermarking-canvas');

              var context = canvas.getContext('2d');
              var wm_context = wm_canvas.getContext('2d');
              wm_context.height = canvas.height = height;
              wm_context.width = canvas.width = width;

              var renderContext = {
                canvasContext: context,
                viewport: viewport
              };
              if (pageNum == 1) {
                new_pdf = new jsPDF({
                  orientation,
                  unit: 'px',
                  format: [width/scale, height/scale],
                })
              }
              // console.log(pdf,page,viewport)
              page.render(renderContext).promise.then(function () {
                context.fillStyle = 'rgba(0,0,0,0.1)';
                context.font = `300 ${size}px "Helvetica Neue", Helvetica, Arial, "PingFang SC""Hiragino Sans GB""Heiti SC""Microsoft YaHei""WenQuanYi Micro Hei", sans-serif`;
                var textW = context.measureText(text).width
                for (let i = 0; i < width + size; i += textW) {
                  for (let j = size; j < height + size; j += size) {
                    context.fillText(text, i, j);
                  }
                }
                // var pageData = canvas.toDataURL('image/jpeg'1.0);

              }).finally(function () {
                pageNum++
                new_pdf.addImage(canvas, 'JPEG', offsetX, offsetY, new_pdf.internal.pageSize.getWidth(), new_pdf.internal.pageSize.getHeight());
                if (pageNum <= pdf.numPages) {
                  new_pdf.addPage([width/scale, height/scale])
                  setCanvas()
                } else {
                  clearInterval(timer)
                  console.log(time)
                  new_pdf.save('水印' + filename)
                }
              })
            })
          }
        });
      }

最后,运行一下程序,真的生成了

WX20200608-172347@2x.png
WX20200608-172347@2x.png

可是问题又来了!

WX20200608-172421@2x.png
WX20200608-172421@2x.png
  1. 生成出来的 pdf 变成了图片,里面的文字不能被选中
  2. 生成的 pdf 会模糊,先放大再缩小之后质量上去了,但是文件大小也上去了,源文件 2.1M 但是现在生成之后是 180M,这不能忍啊!

方案 2 再次被 pass!


方案 3最后抱着试一试的态度试了一下pdf-lib这个库(星星也是少的可怜),但是它很强大,支持直接修改 pdf 源文档,同时也提供了画水印文字的方法,感觉非常符合我目前的需求呀!然后是一顿阅读+操作,最后按照官方给的一个 demo 试试

import { degrees, PDFDocument, rgb, StandardFonts } from 'pdf-lib';

async function modifyPdf() {
  const url = 'https://pdf-lib.js.org/assets/with_update_sections.pdf'
  const existingPdfBytes = await fetch(url).then(res => res.arrayBuffer())

  const pdfDoc = await PDFDocument.load(existingPdfBytes)
  const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica)

  const pages = pdfDoc.getPages()
  const firstPage = pages[0]
  const { width, height } = firstPage.getSize()
  firstPage.drawText('This text was added with JavaScript!', {
    x5,
    y: height / 2 + 300,
    size50,
    font: helveticaFont,
    colorrgb(0.950.10.1),
    rotatedegrees(-45),
    opacity0.75  //pdf-lib作者已经更新1.8版本,支持opacity设置
  })

  const pdfBytes = await pdfDoc.save()
}
tfo6Ld.md.png
tfo6Ld.md.png

很好,非常好,胜利就在前方了!

但是,这时候又出问题了,画上去的水印文字不支持设置透明度。查阅 github 的 issue 之后发现作者说,透明度是个很好的功能呀,可惜我不支持,你们谁想出来了给我发个 pr。。。(吐血...)

还好天无绝人之路,这时看到一位老哥在另一个 issue 中回了一句,pdf-lib 它还支持合并 pdf,你可以搞个水印层的 pdf 和源 pdf 进行合并从而曲线救国,来达到实现 pdf 加水印的效果。略一思量,该方案可行,开干吧。

谁知又遇到了问题,其他库高了一圈也都不能生成带透明度的 pdf 文件,这可咋整啊~(哪位大佬如果知道,欢迎指导)

修改:pdf-lib 作者已经更新 1.8 版本,支持opacity设置

方案 3 再次被 pass


终极方案最后,看来还得借助后端老哥的力量了。前端把文案信息传递给后端,后端生成 pdf 水印层,然后返回文件链接,前端拿到连接后请求下来,前端负责把源文件与水印层 pdf 文件进行合并,由于读取的只有一个单页的水印 pdf 速度还是很快的,源文件在本地读取就行了,效率也不算太差。经测试给一个 80M 的 pdf 添加 5 个水印,耗时 10S 左右吧。

最最后,还有一个问题,就是服务器上产生的水印 pdf 垃圾文件怎么办?当然是删除喽,不然留着过年啊!用了 promise.all 来监听是否全部转换完成,完成后发送一个请求给后端,删除掉之前生产的文件。

 let file, water_name_keys,base_url="你的api地址";
      //监听上传按钮
      $('#upload-input').change(function () {
        const files = $(this)[0].files;
        if (files.length) {
          $('#file-name').html(files[0].name);
          file = files[0];
        }
      })

      //提交水印文案,获取打好水印的pdf
      $('#submit').click(function () {
        const water_name = $('[name=users]').val();

        if(!file){
          new_alert('请先上传文件', 1500);
          return
        }else if(!water_name){
          new_alert('请先填写水印文案', 1500);
          return
        }
        let time = 0;
        let timer = setInterval(()=>{time+=100},100);
        const msg_id = new_alert('生成中...', 999999);
  $.ajax({
    url:base_url+'/tool/ExecFileWaterMark',
    data: {water_name},
    method: 'get',
    dataType: 'jsonp',
    success(res) {
            if (res.resultCode == 0) {
              let modifyPdfList = []
              water_name_keys = Object.keys(res.result.data);
              Object.values(res.result.data).map((wm_url, index) => {
                modifyPdfList.push(modifyPdf(wm_url, file, index));
              })
              Promise.all(modifyPdfList).then(() => {
                //统计时间
                clearInterval(timer);
                console.log(time);

                new_alert.close(msg_id);
                new_alert('生成成功', 1500);
                deleteWmFile();
              }).catch(() => {
                new_alert('生成失败', 1500);
                clearInterval(timer);
              })
            } else {
              new_alert.close(msg_id);
              new_alert('生成失败', 1500);
            }
          },
    error() {
            new_alert.close(msg_id);
          }
  })
      })

      //合并pdf
      function modifyPdf(url, file, index) {
        return new Promise(async (resolve, reject) => {
          try {
            const {PDFDocument} = PDFLib;
            //获取源pdf文件文档
            const existingPdfBytes = await file.arrayBuffer();
            const pdfDoc = await PDFDocument.load(existingPdfBytes);
            //获取水印文件文档
            const wmPdf = await fetch(url).then(res => res.arrayBuffer());
            //把水印pdf页面嵌入文档中
            const [wmPreamble] = await pdfDoc.embedPdf(wmPdf, [0]);
            //获取源文件总页数
            const pages = pdfDoc.getPages();
            //循环源pdf页面添加水印
            for (let i = 0; i < pages.length; i++) {
              const page = pages[i];
              const {widthheight} = page.getSize();
              //把水印页面画到源pdf页面上
              page.drawPage(wmPreamble, {width, height, x: 0, y: 0});
            }
            //保存pdf页面,返回二进制unit8Array二进制数组
            const pdfBytes = await pdfDoc.save();
            //二进制数组转为blob数据
            const blob = new Blob([pdfBytes], {type: 'application/pdf'});
            //下载
            const a = document.createElement("a");
            a.href = URL.createObjectURL(blob);
            a.download = water_name_keys[index] + '-' + file.name// 这里填保存成的文件名
            a.click();
            URL.revokeObjectURL(a.href);
            a.remove();
            resolve();
          } catch (e) {
            reject()
          }
        })
      }

      //  删除服务器上的水印文件
      function deleteWmFile() {
        const water_name = water_name_keys.join(',')
        $.ajax({
          url: base_url+'/tool/DeleteFileWaterMark',
          data: {water_name},
          method: 'get',
          dataType: 'jsonp'
        })
      }

来张效果图 ^ _ ^ tfbzNR.md.png

完结撒花!!!