PDF 盖黑框不等于脱敏:用 PDF.js + Canvas + pdf-lib 实现永久遮盖

处理合同、简历和内部资料时,最常见的脱敏方式是在姓名、手机号上盖一个黑色矩形。

问题在于,PDF 并不是一张图片。页面中可能同时存在文字层、图片、矢量图形、表单、批注和附件。如果只是向页面上方添加一个黑色矩形,底层文字对象可能仍然存在:鼠标拖动时可以选中,复制后也可能重新得到原文。

最近我在实现 WyanHub 工具箱的 PDF 脱敏功能时,选择了一种更直接的处理方式:

text 复制代码
读取 PDF
  ↓
PDF.js 逐页渲染到 Canvas
  ↓
在像素画面上写入遮盖区域
  ↓
把处理后的页面嵌入全新 PDF
  ↓
检查页数、文字层、表单和元数据边界

核心思路不是给原 PDF 增加一层遮挡,而是只把处理后的页面画面带入新文件。

一、用归一化坐标保存遮盖区域

页面预览会随着窗口宽度缩放。如果直接保存鼠标的像素坐标,预览尺寸变化后遮盖位置就会错位。

因此我把选区保存为相对页面宽高的比例:

ts 复制代码
interface RedactionRect {
  page: number;
  x: number;
  y: number;
  width: number;
  height: number;
}

function normalizedRectFromPoints(
  startX: number,
  startY: number,
  endX: number,
  endY: number,
) {
  return {
    x: Math.min(startX, endX),
    y: Math.min(startY, endY),
    width: Math.abs(endX - startX),
    height: Math.abs(endY - startY),
  };
}

指针事件中的坐标也先除以预览容器尺寸:

ts 复制代码
function pointFromEvent(event: PointerEvent, stage: HTMLElement) {
  const box = stage.getBoundingClientRect();
  return {
    x: (event.clientX - box.left) / box.width,
    y: (event.clientY - box.top) / box.height,
  };
}

这样无论预览是 600px 还是 1200px 宽,选区都能映射到同一个页面位置。多页合同中相同位置的页眉、页脚或编号,也可以把同一组归一化坐标复制到其他页面。

二、使用 PDF.js 把页面真正画出来

PDF.js 负责解析和渲染 PDF。导出时不复用屏幕上的低分辨率预览,而是为每一页重新创建较高分辨率的 Canvas:

ts 复制代码
const page = await pdfDocument.getPage(pageNumber);
const viewport = page.getViewport({ scale: 1.5 });

const canvas = document.createElement('canvas');
canvas.width = Math.ceil(viewport.width);
canvas.height = Math.ceil(viewport.height);

const context = canvas.getContext('2d')!;
await page.render({
  canvas,
  canvasContext: context,
  viewport,
}).promise;

渲染完成后,当前页面已经变成像素画面。接下来把属于这一页的所有遮盖区域直接写入 Canvas:

ts 复制代码
context.fillStyle = '#000000';

redactions
  .filter(item => item.page === pageNumber)
  .forEach(item => {
    context.fillRect(
      item.x * canvas.width,
      item.y * canvas.height,
      item.width * canvas.width,
      item.height * canvas.height,
    );
  });

这一步之后,遮挡区域里的原始像素已经被黑色像素替换。后续生成的新 PDF 不会再引用原页面中的文字对象。

三、不要修改原 PDF,而是创建新文档

如果继续以原 PDF 为基础保存,容易无意间保留表单、批注、附件、脚本或源元数据。

因此导出时使用 pdf-lib 创建空白文档,只嵌入处理后的页面图片:

ts 复制代码
const output = await PDFDocument.create();
output.setTitle('已安全展平的 PDF');

const imageBytes = new Uint8Array(
  await (await canvasToBlob(canvas)).arrayBuffer(),
);
const image = await output.embedPng(imageBytes);

const outputPage = output.addPage([
  originalWidth,
  originalHeight,
]);

outputPage.drawImage(image, {
  x: 0,
  y: 0,
  width: originalWidth,
  height: originalHeight,
});

Canvas 导出 Blob 的辅助函数:

ts 复制代码
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
  return new Promise((resolve, reject) => {
    canvas.toBlob(blob => {
      if (blob) resolve(blob);
      else reject(new Error('页面图片生成失败'));
    }, 'image/png');
  });
}

所有页面完成后再一次性保存:

ts 复制代码
const outputBytes = await output.save();
const result = new Blob([outputBytes], {
  type: 'application/pdf',
});

新文档只包含重新生成的页面图片,因此不会复制原 PDF 的可选中文字层、表单字段、批注、附件和链接。

四、导出完成并不代表可以跳过检查

程序可以完成结构检查,但无法判断用户是否漏掉了某个姓名或二维码。

我在导出后重新加载生成文件,至少检查下面几项:

ts 复制代码
const verification = await PDFDocument.load(outputBytes);

const findings = [
  {
    label: '页面完整性',
    passed: verification.getPageCount() === sourcePageCount,
  },
  {
    label: '表单字段',
    passed: verification.getForm().getFields().length === 0,
  },
  {
    label: '遮盖数量',
    passed: redactions.length > 0,
  },
];

另外还需要在界面中明确提示用户:逐页检查姓名、证件号码、联系方式、二维码、印章和图片中的敏感信息。

结构检查只能确认"输出文件没有复制某类对象",不能确认"所有敏感信息都已被人正确框选"。

五、这种方法的代价

栅格化是一种边界清楚但有明显取舍的方案:

  • 原文字搜索和复制能力会消失。
  • 原有链接、表单和数字签名不会保留。
  • 图片化可能增大文件体积。
  • 页面需要逐页渲染,长文档会消耗较多内存。
  • OCR 生成的隐藏文字层同样会被移除。

因此我给浏览器端实现设置了单次页数和文件大小限制,避免大文档耗尽内存。重要文件必须保留原件,脱敏版本只作为新的交付副本。

如果业务要求保留全文搜索、数字签名或精确编辑,就不能简单使用整页栅格化,需要采用真正理解 PDF 内容流的专业脱敏方案,并进行更严格的交付验证。

六、为什么选择浏览器本地处理

脱敏工具处理的往往正是用户不愿上传的文件。这个实现中的 PDF 解析、页面渲染、区域遮盖和文件生成都发生在当前浏览器,不需要先把合同或简历上传到服务端。

当然,"浏览器本地处理"不意味着没有资源成本。PDF.js 和相关运行代码仍需加载,长文档仍然受浏览器内存限制。产品页面应当把文件限制、处理位置和功能边界直接告诉用户。

总结

PDF 永久遮盖的关键并不是黑框画得多漂亮,而是确保新文件不再包含被遮挡内容的原始对象。

本文方案可以概括为四步:

  1. 用 PDF.js 逐页渲染。
  2. 用归一化坐标定位敏感区域。
  3. 在 Canvas 像素层写入遮盖。
  4. 用 pdf-lib 创建只包含处理后页面的新 PDF。

它牺牲了搜索、链接和数字签名,但换来了容易解释和验证的数据边界。对于需要快速交付脱敏副本、又不希望文件上传服务器的场景,这是一个实用的浏览器端方案。

相关推荐
xixingzhe21 小时前
Nginx 看懂请求耗时瓶颈
服务器·前端·nginx
Lank_M1 小时前
前端AVIF编码方案评审,九个想当然逐个实测
前端
程序员鱼皮1 小时前
我用 REA 逆向了付费颜色 APP,结果很意外!
前端
志尊宝1 小时前
Vue3 零基础每日笔记(087):Vue3 高频面试题(下)
前端·javascript·vue.js·笔记·ecmascript·#vue.js
pusheng20252 小时前
普晟传感再获“消防产业核心配套优质品牌”:以可靠传感技术,让全球合规变简单
大数据·前端·人工智能·物联网·安全
Java后端的Ai之路2 小时前
15_ReactJS_Flux_简介
前端·react.js·前端框架
定栓3 小时前
Webpack迁到Vite第五节:第三方库兼容
前端·webpack·node.js
NGINX开源社区3 小时前
与我们交流:NGINX Gateway Fabric 与 NGINX Ingress Controller 社区会议
java·服务器·前端
ShineWinsu3 小时前
对于CSS:弹性布局(Flexbox)的解析
前端·css·面试·浏览器·web·网页·弹性布局