普通的 PDF 暗色模式往往只是给整个页面加上 invert(1):白纸变黑、黑字变白,但照片会变成底片,图表配色也会失真。开源 PDF 阅读器 veil 采用了更有针对性的渲染方式:先用 PDF.js 把页面绘制到 canvas,再反转文字所在的页面层,同时通过第二个 canvas 把图片、图表和示意图的原始像素覆盖回来。
这种方案尤其适合论文、技术手册和扫描文档。它不需要修改 PDF 文件本身,而是在阅读器的显示阶段重新合成页面。
两层 canvas 解决了什么问题
veil 的核心不是简单换一套 CSS,而是把页面拆成两个视觉层:
- 暗色基础层:PDF.js 将整页渲染到 canvas,随后使用 CSS 反色,使浅色纸张和深色文字交换明暗关系。
- 原色覆盖层:第二个透明 canvas 位于基础层上方,仅在图片、图表等区域绘制未经反色的原始像素。
可以把最终画面理解为:
最终页面 = 反色后的完整 PDF 页面 + 原色媒体区域覆盖
这种方式有两个直接好处。PDF.js 继续负责字体、矢量路径、透明度和页面缩放,暗色模式不需要重新实现 PDF 排版;覆盖层只处理需要保真的区域,不会让照片肤色、热力图色阶或电路图标记发生反转。
代价也很明确:阅读器必须知道哪些像素属于图片,哪些属于文字。对于包含独立图片对象的数字 PDF,可以从页面对象或渲染过程推导区域;对于整页都是一张图的扫描件,则需要 OCR 或版面分析进一步识别文字块与插图。
扫描件为什么更难处理
数字 PDF 通常仍保留文字、图片和绘图指令之间的结构边界。扫描 PDF 却可能只有一张覆盖整页的位图。如果直接保护整张位图,页面就不会进入暗色模式;如果反转整张位图,照片和图表又会失真。
这时 OCR 的作用不只是提取可搜索文本,还可以为页面分区提供线索。一个可行的处理链路是:
PDF.js 渲染页面
↓
OCR 获取文字框
↓
版面分析生成文字与非文字区域掩码
↓
反转整页
↓
在第二个 canvas 中恢复非文字区域
项目摘要提到 OCR 能力由 Tess 系列组件提供。工程上需要特别关注 OCR 的执行成本:高分辨率页面会占用较多 CPU 和内存,因此结果应按文档指纹、页码和渲染比例缓存,并避免在用户快速翻页时处理已经离开视口的页面。
OCR 结果也不能被当作绝对准确的边界。公式、细线图、带文字的截图以及表格,常常同时具有文字和图像特征。实际产品通常需要保留“整页反色”“智能模式”和“原始颜色”等切换选项,让用户能够绕过错误分类。
用 PDF.js 复现双层渲染
下面是一个可以直接运行的最小示例。它允许选择本地 PDF,渲染第一页,对底层 canvas 应用反色,并在上层恢复一个手工指定的矩形区域。这里明确采用一个演示假设:protectedRegions 已经由 PDF 对象分析、OCR 或人工标注得到。
将以下内容放入 index.html:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PDF 双层暗色渲染</title>
<style>
body {
margin: 0;
padding: 24px;
color: #e8e8e8;
background: #151515;
font-family: system-ui, sans-serif;
}
#page {
position: relative;
width: max-content;
margin-top: 16px;
}
canvas {
display: block;
}
#dark-layer {
filter: invert(1);
}
#color-layer {
position: absolute;
inset: 0;
pointer-events: none;
}
</style>
</head>
<body>
<input id="file" type="file" accept="application/pdf">
<div id="page">
<canvas id="dark-layer"></canvas>
<canvas id="color-layer"></canvas>
</div>
<script type="module">
import * as pdfjsLib from
"https://cdn.jsdelivr.net/npm/pdfjs-dist@4.10.38/build/pdf.min.mjs";
pdfjsLib.GlobalWorkerOptions.workerSrc =
"https://cdn.jsdelivr.net/npm/pdfjs-dist@4.10.38/build/pdf.worker.min.mjs";
const input = document.querySelector("#file");
const darkCanvas = document.querySelector("#dark-layer");
const colorCanvas = document.querySelector("#color-layer");
input.addEventListener("change", async () => {
const file = input.files[0];
if (!file) return;
const data = new Uint8Array(await file.arrayBuffer());
const pdf = await pdfjsLib.getDocument({ data }).promise;
const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale: 1.5 });
const source = document.createElement("canvas");
source.width = darkCanvas.width = colorCanvas.width = viewport.width;
source.height = darkCanvas.height = colorCanvas.height = viewport.height;
await page.render({
canvasContext: source.getContext("2d"),
viewport
}).promise;
darkCanvas.getContext("2d").drawImage(source, 0, 0);
// 演示假设:这些矩形是需要保持原色的图片或图表区域。
const protectedRegions = [
{ x: 80, y: 180, width: 420, height: 260 }
];
const overlay = colorCanvas.getContext("2d");
overlay.clearRect(0, 0, colorCanvas.width, colorCanvas.height);
for (const region of protectedRegions) {
const { x, y, width, height } = region;
overlay.drawImage(source, x, y, width, height, x, y, width, height);
}
});
</script>
</body>
</html>
在该文件所在目录启动静态服务器:
python3 -m http.server 8000
然后访问 http://localhost:8000 并选择一个 PDF。修改 protectedRegions 的坐标,就能观察上层原色区域如何覆盖反色页面。生产实现还应考虑 devicePixelRatio、页面旋转、缩放后的坐标换算,以及多个恢复区域重叠时的合并。
落地时不要忽略这些边界
双层 canvas 是清晰而实用的架构,但真正影响体验的是区域识别和资源调度。采用类似方案时,可以按下面的清单推进:
- 优先渲染当前页及相邻页,使用
IntersectionObserver暂停远离视口的任务。 - 缓存 OCR 边界和区域掩码,不要在每次缩放时重新识别整页。
- 限制 canvas 像素总量,大页面需要分块或降低离屏分析分辨率。
- 对公式、代码截图、透明 PNG 和彩色文字建立单独测试样本。
- 提供页面级回退开关,允许用户关闭智能恢复或直接查看原始页面。
- 如果 OCR 在浏览器本地执行,应明确性能影响;如果上传服务器处理,则必须说明文档隐私和保存策略。
veil 展示了一条比“整页套滤镜”更可靠的路径:把 PDF 渲染、暗色转换和原色恢复拆成独立步骤。它无法自动消除所有版面识别错误,但能在不篡改源文件的前提下,让文字阅读舒适度和图像颜色准确性同时得到保留。