对于web端pdf编辑能力,本文提供了一种相对完整且轻量的实现方式,如果你也有类似诉求,希望能对你有所帮助~。下面将从业务场景、技术实现角度对“pdf编辑工具”进行介绍。

背景

业务介绍

版权是内容行业商业化的基础,也是网易云音乐的核心资产。合同作为版权资产的重要组成部分,需要具有高效且高质量的管理方式,云音乐版权后台把大量原本需在线下完成的纸质合同的生成与审核等过程移至线上进行。在合同生成——审核完成的过程中,会有不同身份成员需要查看合同信息以及合同原件。但由于部分成员直接查看合同原件存在一定风险,特别是对合同金额等敏感信息不方便直接露出。因此需要先对pdf格式的合同原件进行编辑处理,即发起人在上传合同原件后,需要对合同原件中的一些敏感信息进行涂改操作,以提供给其他审核人员查看。

合同审核流程

功能调研

通过对已有的pdf编辑组件库或方案的调研,发现两款比较主流可对pdf进行编辑的库:jsPDFpdf-lib(其它冷门库暂不考虑)。

pdf-lib

其中,pdf-lib这个组件对pdf的基本编辑能力已经十分完善,可以轻松实现对pdf的预览(包括放大/缩小、翻页、旋转等)、加水印、图片嵌入、涂改(在线体验)等功能。

其涂改功能的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { PDFDocument, rgb } from 'pdf-lib'

async function drawSvgPaths() {
const svgPath =
'M 0,20 L 100,160 Q 130,200 150,120 C 190,-40 200,200 300,150 L 400,90'
const pdfDoc = await PDFDocument.create()
const page = pdfDoc.addPage()
page.moveTo(100, page.getHeight() - 5)
page.moveDown(25)
page.drawSvgPath(svgPath)
page.moveDown(200)
page.drawSvgPath(svgPath, { borderColor: rgb(0, 1, 0), borderWidth: 5 })
page.moveDown(200)
page.drawSvgPath(svgPath, { color: rgb(1, 0, 0) })
page.moveDown(200)
page.drawSvgPath(svgPath, { scale: 0.5 })
const pdfBytes = await pdfDoc.save()
}

调用pdf-lib的drawSvgPath方法,将指定颜色的线条覆盖到pdf的指定位置,如果说只是实现一个基础的涂改功能那么这个方法已经够用了,但对于定制化的功能来说还不够灵活。原因是使用drawSvgPath进行涂改,需要先将每条画线的坐标位置记录下来,擦除的时只能一条一条的回撤,不能像橡皮擦一样的任意擦除(jsPDF同样无法实现)。

由于策划希望实现一个像画板一样,可以自由灵活的对涂改过的地方进行擦除的工具,且后续会陆续按需增加定制化的新功能。市面上现有支持pdf编辑的工具(如福昕PDF编辑器、Adobe Acrobat DC、PDF-XChange Editor等)具有诸多问题:

  • 收费;
  • 样式难以统一(如字体、表单长度、边框粗细等);
  • pdf可能会被工具获取,存在内容泄漏风险;
  • 无法满足特定业务的定制化需求;
  • …..

因此,我们需要开发一款”有画板功能”且可灵活拓展的web端pdf编辑工具。

方案设计

通过前期调研发现,web端实现画板通常是使用canvas画布来实现,其自身具有很好的用于绘制的api调用方法。加上对已有web端pdf工具库的了解,考虑先将pdf处理成canvas画布,然后再对画布做一系列的编辑操作。

技术可行性拆解

  1. 在线预览与canvas转换:

    可通过pdf.js将pdf转换为图片/canvas格式,并且可调用其自带的功能实现灵活的预览。

  2. pdf的编辑与交互:

    将pdf转换为图片渲染到canvas画布后,就可以利用canvas提供的基础编辑方法实现可视化编辑能力。

  3. 合并canvas并生成新的pdf文件:

    利用pdf-lib的PDFDocument方法,将绘制好的canvas画布转换为pdf文件。

架构概览

架构概览

模块简介

pdf加载与转换

使用pdf.js库中的pdf.worker.js方法将pdf文件处理成一个base64格式的图片数组,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import * as pdf from 'pdfjs-dist';
import * as PdfWorker from 'pdfjs-dist/build/pdf.worker.js';
import { useEffect, useRef, useState } from 'react';

window.pdfjsWorker = PdfWorker;
pdf.GlobalWorkerOptions.workerSrc = PdfWorker;

export const usePDFData = (options) => {
const previewUrls = useRef([]);
const urls = useRef([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
urls.current = [];
setLoading(true);
(async () => {
const pdfDocument = await pdf.getDocument(options.src).promise;
const task = new Array(pdfDocument.numPages).fill(null);
await Promise.all(task.map(async (_, i) => {
const page = await pdfDocument.getPage(i + 1);
const viewport = page.getViewport({ scale: options.scale || 2 });
const canvas = document.createElement('canvas');

canvas.width = viewport.width;
canvas.height = viewport.height;
const ctx = canvas.getContext('2d');
const renderTask = page.render({
canvasContext: ctx,
viewport,
});
await renderTask.promise;
urls.current[i] = canvas.toDataURL('image/jpeg', 1.0);
previewUrls.current[i] = canvas.toDataURL('image/jpeg', 1.0);
}));
setLoading(false);
})();
}, [options.src]);

return {
loading,
urls: urls.current,
previewUrls: previewUrls.current,
};
};

需要注意的是,使用上述方法引入pdfjs-dist库的时候可能会出现由于版本原因导致的webpack报错或pdf文字展示不全的问题。此时,可使用CDN静态链接的方式来引入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.body.appendChild(script);
});
}
useEffect(() => {
const pdfJsSrc = 'https://d2.music.126.net/dmusic/obj/w5zCg8OAw6HDjzjDgMK_/32306762218/0edf/8c16/2c06/4555824a5322d689b942fae7f969fe1d.js';
const pdfWorkerSrc = 'https://d1.music.126.net/dmusic/obj/w5zCg8OAw6HDjzjDgMK_/32306765016/e6dd/7e38/f0e7/a1419bdedd44e403179bee0c8c1132df.js';

if (!window?.pdfjsLib) {
loadScript(pdfJsSrc)
.then(() => loadScript(pdfWorkerSrc))
.then(() => {
producePic(pdfWorkerSrc);
});
} else {
producePic(pdfWorkerSrc);
}
}, [options.src]);

canvas绘画

将pdf的每一页处理成图片以后,就可以直接渲染到canvas画布上进行后续的一系列操作了。

  1. 绘图与擦除功能构建

    采用两层相同大小的canvas画布重叠的方式。上层画布主要用于绘制新内容,下层画布用于展示原始pdf内容,当需要一次还原某页内容时,只需要将上层canvas内容清除即可。选择操作方式(绘图/擦除)后,按下鼠标开始绘制/擦除,抬起鼠标停止绘制/擦除。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    import React, { useEffect, useState, useRef, useCallback } from 'react';
    const OPERATE_ENUM = {
    MOSAIC: 1, // 绘图
    ERASER: 2, // 擦除
    };
    const DrawingBoard = (props) => {
    ...
    /*鼠标按压事件*/
    const onMouseDown = (e) => {
    e.preventDefault();
    /* 鼠标按下事件,记录鼠标位置,并绘制,解锁lock,打开mousemove事件 */
    if (status === OPERATE_ENUM.ERASER) {
    // 橡皮模式
    // 设置擦除初始数据
    } else if (status === OPERATE_ENUM.MOSAIC) {
    // 绘画模式
    // 设置绘画初始数据
    }
    };
    /*鼠标移动事件监听*/
    const onMouseMove = (e) => {
    const canvas = canvasRef.current;
    const ctx = canvas.getContext('2d');
    const _x = (e.clientX - canvas.offsetLeft - containerLeftRef.current) * 2;
    const _y = (e.clientY - canvas.offsetTop - containerTopRef.current) * 2;
    if (status === OPERATE_ENUM.ERASER && startEraser.current) {
    /*橡皮擦擦除函数*/
    } else if (status === OPERATE_ENUM.MOSAIC && startDraw.current) {
    /*绘制线条函数*/
    }
    };

    /*鼠标抬起事件监听*/
    const onMouseUp = () => {
    /* 重置数据 */
    /* 保存当前绘图 */
    };
    return (
    ...
    <canvas
    ref={canvasImgRef}
    id="canvasImg">
    您的浏览器不支持 canvas 标签
    </canvas>
    <canvas
    ref={canvasRef}
    onMouseDown={onMouseDown}
    onMouseMove={onMouseMove}
    onMouseUp={onMouseUp}
    id="canvas">
    您的浏览器不支持 canvas 标签
    </canvas>
    );
    }
    export default DrawingBoard
  2. 翻页时加载之前绘制的内容

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    useEffect(() => {
    // 在返回页面时,使用之前保存的数据重新渲染 canvas
    if (canvasList[currentPage]) {
    const canvas = canvasRef.current;
    const ctx = canvas.getContext('2d');
    const img = new Image();
    img.onload = function () {
    ctx.drawImage(img, 0, 0, widthE, heightE);
    };
    img.src = canvasList[currentPage];
    }
    }, [currentPage, canvasList, widthE, heightE]);
  3. 当放大时,容器会出现滚动条,此时需要监听滚动位置,以确定绘制/擦除时的下笔位置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    // 监听滚动条,确定画笔的位置
    const handleScroll = (e) => {
    if (e.target) {
    containerLeft = e.target.offsetLeft - e.target.scrollLeft;
    containerTop = e.target.offsetTop - e.target.scrollTop;
    }
    }
    useEffect(() => {
    ...
    const drawPaint = document.getElementById('drawPaint');
    if (canvas) {
    ....
    if (drawPaint) {
    drawPaint.addEventListener('scroll', handleScroll);
    }
    return () => {
    if (drawPaint) {
    drawPaint.removeEventListener('scroll', handleScroll);
    }
    };
    }
    }, [status, canvasList]);

保存下载

  1. 合并图片

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    // 加载图片
    const loadImage = (src) => {
    return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => resolve(img);
    img.onerror = reject;
    img.src = src;
    });
    }

    // 将两张图片合并
    const mergeImage = async () => {
    if (!urls.length) return;
    const result = [];
    await urls?.map((image1,index) => {
    const image2 = canvasList[index];
    if (image2) {
    const canvas = document.createElement('canvas');
    const ctx = canvas.getContext("2d");
    canvas.width = 550 * 4
    canvas.height = 779 * 4
    // 确保两张图片都加载完成后绘制
    Promise.all([
    loadImage(image1),
    loadImage(image2)
    ])
    .then(([loadedImg1, loadedImg2]) => {
    // 绘制第一张图片
    ctx.drawImage(loadedImg1, 0, 0, canvas.width, canvas.height);
    ctx.globalAlpha = 1.0; // 设置合并透明度
    // 绘制第二张图片
    ctx.drawImage(loadedImg2, 0, 0, canvas.width, canvas.height);
    // 获取合并后的图片的数据 URL
    const mergedImage = canvas.toDataURL('image/jpeg');
    result[index] = mergedImage;
    });
    } else {
    result[index] = image1;
    }
    });
    return result;
    }

    为了避免pdf在转换和生成的过程中失真,本文通过手动设置canvas大小的方法解决。由于一般的pdf文件都是A4纸大小,转换为px为单位就是:2480 px * 3508 px。为了方便展示在初始化预览时将canvas的大小设置为:2480/4 * 3508/4 的宽高,在合并时将宽高*4还原为之前的大小,这样做可以解决由于转化过程中引起的画面失真问题。

  2. 使用pdf-lib将合并好的图片转化为pdf,以方便下载或者上传

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    import { PDFDocument } from 'pdf-lib';

    for (const image of images) {
    const imageBytes = await fetch(image).then(res => res.arrayBuffer());
    const img = await pdfDoc.embedJpg(imageBytes);
    const page = pdfDoc.addPage();
    const { width, height } = page.getSize();
    page.drawImage(img, {
    x: 0,
    y: 0,
    width: width,
    height: height,
    });
    }
    const pdfBytes = await pdfDoc.save();
    // 下载 PDF 文件
    const blob = new Blob([pdfBytes], { type: 'application/pdf' });
    const link = document.createElement('a');
    link.href = window.URL.createObjectURL(blob);
    link.download = 'combined_images.pdf';
    link.click();
    setUploading(false);

功能展示

  1. 预览

    预览

  2. 涂改

    涂改

  3. 橡皮擦擦除

    橡皮擦擦除

  4. 翻页/旋转/放大/缩小

    翻页/旋转/放大/缩小

总结

为了在web端实现对pdf的编辑能力,本文的主要实现方式是:

  1. 使用pdf.js将pdf文件转换为canvas格式,以便预览和后续放大/缩小、翻页、涂鸦等;
  2. 构建canvas画板能力,通过调用canvas本身api方法实现所需编辑功能;
  3. 完成绘制后,先将canvas画布转换为图片,再调用pdf-lib的PDFDocument方法将图片转换为pdf文件。

仓库地址:https://github.com/Laighten/pdf-paint