场景:一份中文 PRD 的 PDF 缺 ToUnicode 字体映射,
pdftotext提取出来全是空白和乱码(214 个中文字符 vs 1700+ 行版式残留)。机器上没有 Python(Windows 商店占位 stub)、没有pdftoppm,Node 还是 v16。最终用纯 Node 方案把 38 页 PDF 逐页渲染成 PNG,交给多模态模型阅读。整个过程踩了 5 个连环坑,记录下来。
一、为什么文本提取会失败
PDF 里的文字能不能被复制/提取,取决于字体有没有 ToUnicode CMap------它把字形编码(glyph id)映射回 Unicode 码点。很多用设计软件(Instant 秒设计、Photoshop 导出、某些旧排版工具)生成的 PDF 没有这个映射,于是:
- 视觉上文字完好;
pdftotext只能拿到版式信息,中文全部丢失;- 这类 PDF 也没法可靠地 OCR 前先分栏,直接整页渲染成图片是稳妥路线。
判断方法很简单:pdftotext 输出里 grep -c '[一-鿿]' 接近 0,而文件行数很多,基本就是缺 ToUnicode。
二、技术选型
| 方案 | 本机状况 | 结论 |
|---|---|---|
| pdftoppm / pdfinfo(poppler) | 不在 PATH | ✗ |
| Python + pdfplumber/PyMuPDF | 只有商店占位 stub | ✗ |
| 浏览器无头截图 | 只能渲第一页 | ✗ |
| Node + pdfjs-dist + @napi-rs/canvas | Node v16 可用,npm 正常 | ✓ |
@napi-rs/canvas 比 node-canvas 好在零编译(napi 预编译二进制),Windows 上不用碰 node-gyp。
三、五个连环坑
坑 1:pdfjs 一 import 就崩------DOMException is not defined
pdfjs-dist/legacy 在模块加载时就执行 NativeDOMException.prototype 探测,Node 16 没有全局 DOMException(17 才有)。注意 node-domexception 包在 CJS require 下拿到的是空对象(ESM 优先的包),别依赖它,直接写一个最小 polyfill 即可:
js
class DOMExceptionPolyfill extends Error {
constructor(message, name) { super(message); this.name = name || 'Error'; }
}
globalThis.DOMException = DOMExceptionPolyfill;
坑 2:ReadableStream is not defined
渲染到一半才炸。pdfjs 的 MessageHandler 用流式传输算子列表,Node 16 也没有这个全局。好在 stream/web 模块在 16.5+ 已内置:
js
const { ReadableStream } = require('stream/web');
globalThis.ReadableStream = ReadableStream;
坑 3:pdfjs 内置 NodeCanvasFactory 与 @napi-rs/canvas 不兼容
pdfjs 的 Node 工厂假定你装了 canvas(node-canvas),硬编码 require("canvas");而且它的 destroy() 会把 canvas.width = 0 来释放内存------对 napi-rs 的 CanvasElement 会抛 Failed to unwrap exclusive reference(napi 独占引用不能二次 unwrap)。
解法:不要用它内置工厂,自己实现一个 canvasFactory 传给 getDocument 和 page.render:
js
const { createCanvas, Path2D, DOMMatrix } = require('@napi-rs/canvas');
globalThis.Path2D = Path2D; // pdfjs 绘制路径也依赖这两个全局
globalThis.DOMMatrix = DOMMatrix;
class NapiCanvasFactory {
create(width, height) {
const canvas = createCanvas(width, height);
return { canvas, context: canvas.getContext('2d') };
}
reset(cc, w, h) { cc.canvas.width = w; cc.canvas.height = h; }
destroy(cc) { cc.canvas = null; cc.context = null; } // 关键:不碰 width/height
}
渲染 PDF 里的透明组(endGroup)时 pdfjs 会创建临时 canvas 再 drawImage 合成------这就是内置工厂炸掉的现场;自定义工厂创建的 canvas 是"亲生"的 napi 对象,drawImage 才认。
坑 4:真正的原始错误被二次异常吞掉
render() 失败时,pdfjs 内部先走 cancel → endDrawing → destroy 清理链,内置工厂的 unwrap 错误会覆盖掉原始渲染错误 ,你看到的栈全是假象。调试时务必挂 process.on('unhandledRejection', ...) 并在 render().promise 外包 try/catch 打全栈,必要时给内置 destroy 打补丁(noop 掉 canvas.width = 0 两行)让真凶现形。
坑 5:路径与导出 API
- Git Bash 的
/tmp/xxx传给 Node 的writeFileSync会被当成 Windows 相对路径\tmp\xxx(落到当前盘根目录),脚本里一律用带盘符的绝对路径。 - napi-rs/canvas 1.x 里同步导出是
canvas.toBuffer('image/png');encode()的返回值类型因版本而异,别混用。
四、完整可用脚本(Node 16 验证通过)
js
const fs = require('fs');
const { createCanvas, Path2D, DOMMatrix } = require('@napi-rs/canvas');
globalThis.Path2D = Path2D;
globalThis.DOMMatrix = DOMMatrix;
globalThis.DOMException = class extends Error {
constructor(m, n) { super(m); this.name = n || 'Error'; }
};
globalThis.ReadableStream = require('stream/web').ReadableStream;
const pdfjs = require('pdfjs-dist/legacy/build/pdf.js'); // 3.11.x
class NapiCanvasFactory {
create(w, h) {
const canvas = createCanvas(w, h);
return { canvas, context: canvas.getContext('2d') };
}
reset(cc, w, h) { cc.canvas.width = w; cc.canvas.height = h; }
destroy(cc) { cc.canvas = null; cc.context = null; }
}
const canvasFactory = new NapiCanvasFactory();
(async () => {
const data = new Uint8Array(fs.readFileSync('C:/abs/path/input.pdf'));
const doc = await pdfjs.getDocument({ data, canvasFactory }).promise;
for (let i = 1; i <= doc.numPages; i++) {
const page = await doc.getPage(i);
const viewport = page.getViewport({ scale: 2 }); // ~144dpi,小字清楚
const canvas = createCanvas(viewport.width, viewport.height);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, viewport.width, viewport.height);
await page.render({ canvasContext: ctx, viewport, canvasFactory }).promise;
fs.writeFileSync(`C:/abs/path/out/page-${i}.png`, canvas.toBuffer('image/png'));
}
})();
五、延伸建议
- Node 18+ 能省掉坑 1、2 (
DOMException/ReadableStream已全局化),如果环境允许尽量升。 - pdfjs-dist 4.x+ 需要 ESM 和更高 Node 版本,Node 16 请锁
pdfjs-dist@3.11.x。 - 渲染出的整页 PNG 交给多模态大模型阅读时,scale 2.0(约 144dpi)是清晰度和 token 消耗的合理平衡点;表格小字看不清时用裁剪区域二次精读,而不是整体提到 300dpi。
- 如果你控制 PDF 的生成环节:导出时务必勾选"嵌入 ToUnicode/文本可检索",省下下游所有人的麻烦。