处理合同、简历和内部资料时,最常见的脱敏方式是在姓名、手机号上盖一个黑色矩形。
问题在于,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 永久遮盖的关键并不是黑框画得多漂亮,而是确保新文件不再包含被遮挡内容的原始对象。
本文方案可以概括为四步:
- 用 PDF.js 逐页渲染。
- 用归一化坐标定位敏感区域。
- 在 Canvas 像素层写入遮盖。
- 用 pdf-lib 创建只包含处理后页面的新 PDF。
它牺牲了搜索、链接和数字签名,但换来了容易解释和验证的数据边界。对于需要快速交付脱敏副本、又不希望文件上传服务器的场景,这是一个实用的浏览器端方案。