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/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'));
  }
})();

五、延伸建议

  1. Node 18+ 能省掉坑 1、2 (DOMException/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_448771727 小时前
使用Aspose给PDF添加水印
java·pdf
魔都吴所谓13 小时前
【我的开源】Python实现一个离线 PDF 工具箱:架构、骑缝章与踩坑记录
python·pdf·开源
凯程序猿1号14 小时前
SafeDRM 的视频和 PDF 如何做跨设备交付验证?
pdf·电脑
学术 学术 Fun14 小时前
没有 Visio 怎样打开 VSDX?从查看、审阅到导出 PDF
pdf
五花马印务16 小时前
个人书稿交付印刷前,文件格式要规范什么?(Word/PDF/ 字体 / 出血)
pdf·个人出书
指针向南1 天前
PDF转长图用什么?3种方法实测(2026)
图像处理·人工智能·计算机视觉·pdf
GlobalTradercrb2 天前
独立站SEO先写博客还是先优化产品页?看站点阶段
数据库·人工智能·google·pdf·go·seo
热爱生活的五柒2 天前
zotero能导入已经下载好的pdf文件嘛,并且点击条目时,能直接看到下载的pdf文件嘛,我还希望能在里面做笔记
笔记·pdf
开开心心就好3 天前
白天晚上自动调蓝光,护眼工具亮度可调
服务器·开发语言·算法·pdf·ocr·intellij-idea·idea
asdzx673 天前
C# 将 HTML 转换为 PDF:基于 Chrome 的转换方案
pdf·c#·html