Node 16 下用 pdfjs + @napi-rs/canvas 把中文 PDF 渲染成图片

场景:一份中文 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/canvasnode-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 传给 getDocumentpage.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'));
  }
})();

五、延伸建议

  1. Node 18+ 能省掉坑 1、2DOMException/ReadableStream 已全局化),如果环境允许尽量升。
  2. pdfjs-dist 4.x+ 需要 ESM 和更高 Node 版本,Node 16 请锁 pdfjs-dist@3.11.x
  3. 渲染出的整页 PNG 交给多模态大模型阅读时,scale 2.0(约 144dpi)是清晰度和 token 消耗的合理平衡点;表格小字看不清时用裁剪区域二次精读,而不是整体提到 300dpi。
  4. 如果你控制 PDF 的生成环节:导出时务必勾选"嵌入 ToUnicode/文本可检索",省下下游所有人的麻烦。
相关推荐
weixin_4935036710 小时前
Vue3 前端生成 PDF:会员证书与活动签到表的三种打印方案与踩坑记录
前端·pdf·状态模式
、如果14 小时前
PDF图片文字提取零依赖方案:pymupdf+AI视觉实战
人工智能·数据分析·pdf·图片提取·文字提取·skills
SamChan9017 小时前
PDF翻译后的格式完整性校验:用Python自动比对译文与原文档的表格与段落结构
开发语言·python·ai·pdf·机器翻译
web打印社区17 小时前
浏览器静默打印?先别装第三个库了
前端·vue.js·chrome·electron·pdf
慧都小妮子1 天前
DevExpress Java 文档处理 API 免费 CTP:PDF、PowerPoint、条码与跨平台部署
java·pdf·powerpoint·devexpress·文档处理·条码生成
SamChan901 天前
大文件多语言PDF翻译性能实测:300页文档的耗时、内存占用与失败率分析
python·ai·pdf·机器翻译
染的人1 天前
Java 10x15cm面单PDF转换A4格式PDF
java·开发语言·pdf
2601_967760781 天前
2026年PDF压缩与页码添加工具技术实测:性能、算法与本地化适配深度对比
算法·pdf
weixin_450813532 天前
pdf文档转为markdown文档
linux·pdf