PDF 批注的双引擎与坐标归一化:一份批注怎么在 native 和 pdf.js 上都不飘

0 阅读16分钟

0. 需求:批注不是"画一笔",是"存一个坐标"

一个能用的 PDF 批注至少要满足四件事:

  1. 能画在页面上——高亮、下划线、画笔、矩形、便签;
  2. 缩放不飘——从 50% 拉到 400%,高亮还贴着那行字;
  3. 能带走——导出成带批注的 PDF,屏幕上看什么样,导出就什么样;
  4. 能回去——摘录出来的文字进笔记之后,点一下能跳回原文那一页。

四条里有三条与"画"无关,全都在问同一个问题:这个批注的坐标存在哪、以什么为基准。

所以这一篇不讲 UI,只讲两件事:坐标模型,以及两个渲染引擎怎么共用它

1. 一个几何约定:批注存归一化坐标

所有批注——不管是框、折线还是便签——都以页面像素空间的 [0,1] 归一化坐标存储:

// src/lib/pdfAnnotation.ts
export interface PdfAnnotation {
  id: string;
  type: "highlight" | "underline" | "ink" | "sticky" | "rect";
  /** Normalized [x0, y0, x1, y1] box (0..1) — used by highlight/underline/rect. */
  box?: [number, number, number, number] | null;
  /** Normalized polyline points — used by ink (drawing). */
  points?: [number, number][] | null;
  text?: string;      // 便签正文 / 摘录文字
  color?: string;
  createdAt?: number;
}

/** Normalize a pixel-space rect into a [0,1] box, clamped and ordered. */
export function normCoords(x0, y0, x1, y1, pageW, pageH): [number, number, number, number] {
  const pageWn = pageW > 0 ? pageW : 1;   // 除零保护
  const pageHn = pageH > 0 ? pageH : 1;
  let a = clamp01(x0 / pageWn), b = clamp01(y0 / pageHn);
  let c = clamp01(x1 / pageWn), d = clamp01(y1 / pageHn);
  if (a > c) [a, c] = [c, a];             // 拖拽方向任意,存之前先排序
  if (b > d) [b, d] = [d, b];
  return [a, b, c, d];
}

/** Reverse a [0,1] box back to pixel coordinates (for the overlay canvas). */
export function denormCoords(box, pageW, pageH) {
  const [a, b, c, d] = box;
  return [a * pageW, b * pageH, c * pageW, d * pageH];
}

三个细节都是有代价才换来的:

  • 除零保护 + clamp01:页面尺寸取不到(首屏还没拿到 meta)时不能产出 NaN;超出页面的拖拽要被夹回页内,否则批注会"存在但永远看不见"。
  • 存之前排序:用户可以从右下往左上拖。把"方向"留在数据里,等于让每一个消费方各自处理一次。
  • 归一化 = 与缩放解耦:显示缩放只影响 denormCoords 的入参,不动存储。这是第 2 条需求(缩放不飘)的全部秘密——它压根不是"高亮跟着缩放走",而是"高亮从来不认识缩放"

例外只有一个:便签的大小。 便签图钉在屏幕上应该是固定像素尺寸(缩放时不该变成一个巨大的方块),所以位置归一化、大小按像素算:

if (ann.type === "sticky" && ann.box) {
  const w = Math.min(26, W * 0.06);   // 上限 26px,小页面上不超过页宽的 6%
  const h = Math.min(26, H * 0.06);
  return [ann.box[0] * W, ann.box[1] * H, w, h];
}

这段 annPxBox 是**"导出带批注的 PDF"与"屏幕上的 SVG overlay"共用的同一个函数**——第 3 条需求(导出与屏幕一致)靠的不是"两边都小心点",而是两边调用同一个几何函数

2. 双引擎:一份接口,两个实现

桌面端用原生 MuPDFmupdf-sys,Rust 直接调 C API)光栅化,Web 端用 pdf.js。前端只认一个接口:

// src/lib/pdfRender.ts
export type PdfRenderEngine = "native" | "pdfjs";

export interface PdfRenderEngineApi {
  loadPdf: (data: Uint8Array) => Promise<PdfDocumentMeta>;
  getPageMeta: (pageIndex: number) => Promise<PdfPageMeta>;
  getPageTextItems: (pageIndex: number) => Promise<{ str: string; transform: number[] | null; width: number; height: number }[]>;
  getPageText?: (pageIndex: number) => Promise<string>;
  renderPageToBlob: (pageIndex: number, scale: number) => Promise<Blob>;
}

/** Pick the render engine: desktop native when available, else pdf.js fallback. */
export function pickEngine(caps: PdfRenderCapabilities): PdfRenderEngine {
  return caps.native ? "native" : "pdfjs";
}

/** pdf.js should run in a worker + virtualize pages (the JS interpreter is slow). */
export function wantsWorker(engine: PdfRenderEngine, pageBytes: number): boolean {
  return engine === "pdfjs" && pageBytes > 0;
}

这个抽象的实际收益很具体:消费方(阅读器 / 批注画布 / 文本层)只 import 接口,从不 import pdfjs 于是引擎差异被压缩成一个文件,而批注几何、划词、摘录这些"有价值的逻辑"完全不依赖底层是谁。

3. 桌面原生:MuPDF 的四个真实约束

mupdf-sys 而不是上层的安全封装 crate,原因很实在:后者在 x86_64-pc-windows-msvc 上编不过(bindgen 在 MSVC 上不产出 C 内建类型 max_align_t,而它的 device 模块引用到了)。mupdf-sys 在 Windows 上能干净编译链接,并且暴露一套 mupdf_* 便捷 C API,把 MuPDF 的 fz_try/fz_catch longjmp 机制藏在 errptr 后面。这是编译期问题,不是性能问题——很多技术选型其实是这么定的。

接进来之后有四个约束,每一个都是"踩过才知道":

① base context 是进程全局的,不能每次渲染重建

MuPDF 的 base context 内部包裹的是进程级静态临界区。如果按"谁用谁创建"的直觉写成每次渲染 mupdf_new_base_context() + mupdf_drop_base_context(),那等于反复重新初始化再删除全局锁——这是未定义行为,在 Windows 上的表现是访问违例(0xc0000005)。

/// Owns the base `fz_context`. ... So we create the base context **once** and
/// cache it for the process lifetime, never dropping it (the OS reclaims it at exit).
fn shared_context() -> &'static mut fz_context {
    struct CtxPtr(*mut fz_context);
    unsafe impl Send for CtxPtr {}
    unsafe impl Sync for CtxPtr {}

    static CTX: OnceLock<CtxPtr> = OnceLock::new();
    let ptr = (*CTX.get_or_init(|| CtxPtr(unsafe { mupdf_new_base_context() }))).0;
    assert!(!ptr.is_null(), "MuPDF: failed to create base context");
    unsafe { &mut *ptr }
}

注意这里的模式:进程级单例 + 永不释放。它不优雅,但比"优雅地崩在用户机器上"好。每次渲染借出这个 context,而被渲染产生的对象(doc/page/pixmap/buffer)仍然各自 RAII、按时释放——"全局的那一个"和"每次的那一批"必须分开管。

fz_context 不是线程安全的

并发渲染不能靠"多开几个 context"绕过(见 ①),所以整段渲染持锁:加载页、生成 pixmap、拷字节,全在同一把锁里。渲染是 CPU 密集的,这把锁是真会排队的——好在它只是"一页一页看"的场景,可接受。

③ 打开文档是主要成本,所以要缓存,且 buffer 必须活得比 doc 长

大 PDF 的耗时大头是打开而不是渲染。所以进程内缓存已打开的文档:

/// A cached, open MuPDF document plus its source buffer. The buffer must
/// outlive the document (its stream reads from it on demand), so they share
/// one lifetime and are dropped together.
struct CachedDocument { ctx: *mut fz_context, doc: *mut fz_document, buffer: *mut fz_buffer }

那句注释是这类 FFI 代码的典型陷阱:MuPDF 的文档流是"按需读"的,不是打开时全读进内存——所以你把 buffer 提前 drop 掉,文档在后续翻页时才会崩,而且崩在一个和"内存管理"看起来无关的地方。缓存还必须有界(超过 8 个就淘汰最早插入的),否则一个读了很多 PDF 的会话会一路泄漏文档。

④ pixmap 每行可能有对齐填充,必须在 Rust 侧压平

fz_pixmap_stride 可能大于 w × 4(行对齐填充)。而前端是按 宽 × 高 × 4 校验字节数的——多一个字节就会被判成"字节数对不上"。这种差异如果漏到前端,就会变成一个"图能显示但斜了/花屏"的谜题:

/// 把 MuPDF 的行对齐样本压成紧凑 RGBA8(长度恰为 `w × h × 4`)。
/// ... 填充必须在 Rust 侧去掉,别把这个差异漏到前端去当谜题。
pub fn compact_rgba(samples: &[u8], w: usize, h: usize, stride: usize) -> Result<Vec<u8>, String> {
    let row = w.checked_mul(4).ok_or("MuPDF: 页面宽度溢出")?;
    let need = stride.checked_mul(h).ok_or("MuPDF: 页面高度溢出")?;
    if stride < row { return Err(format!("MuPDF: 行跨距 {stride} 小于行宽 {row}")); }
    if samples.len() < need { return Err(...); }
    if stride == row { return Ok(samples[..row * h].to_vec()); }   // 快路径:无需重排
    let mut out = Vec::with_capacity(row * h);
    for y in 0..h { let off = y * stride; out.extend_from_slice(&samples[off..off + row]); }
    Ok(out)
}

"边界差异在本侧消化,不往下游漏"——这条原则在后面 §4 还会再救一次。

4. 前端收口:先校验形状,再碰画布

原生渲染的结果要跨 FFI 回到前端,于是就有了真实存在、但文档里查不到的三种形态:

  1. { width, height, rgba_base64 } —— 现在的契约,所有平台一致;
  2. ArrayBuffer / Uint8Array —— 原始响应在 Windows/Linux 上的形态;
  3. 数字数组[12,240,…])—— macOS / iOS 上原始响应被 JSON 编码后的形态。

旧代码写的是 buf instanceof ArrayBuffer,然后从 8 字节头里读宽高。在形态 3 下:

instanceof 为假 → 落到兜底分支 → width 是 undefined
  → canvas.width / createImageData(undefined, undefined) 收到 NaNWKWebView"Value NaN is outside the range [-2147483648, 2147483647]"Chrome/WebView2 不抛,只是静默画出 0×0 —— 也就是用户看到的"一片空白"

同一个 bug,两个平台两种症状:一个报错、一个白屏。这种问题最容易被误判成"渲染引擎坏了"。

修法不是给每个调用点加 if,而是收口成一道闸门:所有形状都必须过 validatePage,任何非有限/对不上的数值都变成一句说得清的错误:

function validatePage(width: unknown, height: unknown, byteLength: number) {
  if (!Number.isInteger(width) || !Number.isInteger(height))
    throw new Error(`原生渲染结果的宽高不是整数(width=${width}, height=${height})`);
  if (width <= 0 || height <= 0) throw new Error(`原生渲染结果尺寸非法(${width}×${height})`);
  if (width * height > MAX_PAGE_PIXELS)
    throw new Error(`原生渲染结果过大(${width}×${height},超过 ${MAX_PAGE_PIXELS} 像素上限)`);
  const want = width * height * 4;
  if (byteLength !== want)
    throw new Error(`原生渲染结果字节数对不上(${width}×${height} 应为 ${want} 字节,实际 ${byteLength} 字节)`);
  return { width, height };
}

其中像素上限不是性能偏好,是防御

/** 单页像素上限(40MP ≈ 8000×5000)。
 *  不是性能偏好,是防御:缩放倍率或页面尺寸一旦算错(NaN/Infinity 被夹到极大值),
 *  `createImageData` 会尝试分配几百 MB~几 GB 的内存,直接把 WebView 打崩。*/
export const MAX_PAGE_PIXELS = 40_000_000;

同一个原则在 pdf.js 那条路上也有一份——倍率必须在碰画布之前校验

if (!Number.isFinite(scale) || scale <= 0) throw new Error(`页面缩放倍率无效(scale=${String(scale)})`);
const vp = p.getViewport({ scale });
if (!Number.isFinite(vp.width) || vp.width <= 0) throw new Error(`页面尺寸无效(${vp.width}×${vp.height},scale=${scale})`);

还有一处形状兜底值得单独说:canvas.toBlob 在个别 WebView / 画布尺寸下会静默回调 null(画布内存限制是常见成因)。于是页面图像永远出不来,而调用方只看到"一片空白"。做法是失败时回退到更老的 toDataURL 路径——多一步编码,换的是"能看见"

5. Web 侧:pdf.js 的三个平台坑

① worker 兼容垫片:polyfill 进不了 worker 上下文

Android 系统 WebView(我们实测那台停在 Chrome 114)缺 Promise.withResolversAbortSignal.any,而 pdf.js 的 worker 里有 13 处 Promise.withResolvers(Chrome 119+ 才有)。

麻烦在于:页面侧的 polyfill 补不到 worker 里去——worker 是另一个全局上下文。所以 workerSrc 指给的不是真 worker,而是我们自己的垫片,它在 worker 内部先补齐,再动态加载真 worker:

const real  = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).href;
const shim  = new URL("pdfjs-worker-shim.mjs", document.baseURI).href;
pdfjs.GlobalWorkerOptions.workerSrc =
  `${shim}?real=${encodeURIComponent(real)}&v=${encodeURIComponent(APP_VERSION)}`;

v= 那个参数是缓存失效用的:垫片和补齐层都是不带内容哈希的静态文件,改了内容而 URL 不变时,浏览器/WebView 可能一直用旧的——这类"改了没生效"的问题能查一整天。

垫片里还有一个脆弱的顺序不变量,值得一提:polyfill 必须早于真 worker 的模块体执行。而遵守起来很脆——把 await import(real) 顺手写成顶层 import "…",静态 import 会被提升到本文件其余语句之前,polyfill 就落到了后面:代码照样跑、review 看不出来,只有老 WebView 上会死。

所以我们给它单独写了一条门禁(scripts/check-pdfjs-worker-shim.mjs):把 ?real= 指向一个探针模块,探针在自己的模块体里就检查 API 在不在,不在就抛——顺序对则探针看到 API 已就位,顺序错或漏装则直接红。探针是合成的而不是 pdf.js 真 worker,因为真 worker 的模块体不保证会调这个 API,拿它当探针会假绿。

(这条验收最后还是写成了独立脚本而不是 vitest 用例:垫片里那次动态 import 被 vitest 的模块解析接管了,同一个路径在裸 Node 里好好的、在 vitest 里 Cannot find module"要真的执行一个文件"的验收,别硬塞进单测框架。

② pdf.js 会把你传进去的 buffer transfer 掉

这条是那种"看代码完全正常、跑起来莫名失败"的坑。pdf.js 的 GetDocRequest transfer list 里就是 data.buffer传过一次,原 buffer 就 detached 了

而 React 18 开发模式的 StrictMode 会"挂载 → 清理 → 再挂载",[open, bytes] 这个 effect 依赖就是拿同一个对象跑两遍:第二次交出去的是一块已经 detached 的 buffer,直接抛 DataCloneError(WebKit 的话术是 The object can not be cloned.),界面表现成"这份 PDF 没能打开"。

// 交给 pdf.js 的永远是**一份私有副本**:一次内存拷贝,换"重复加载不会莫名失败"。
const owned = new Uint8Array(data);
task = pdfjs.getDocument({ data: owned, cMapUrl: "pdfjs/cmaps/", cMapPacked: true,
                           standardFontDataUrl: "pdfjs/standard_fonts/" });

这条机制有门禁钉着(scripts/check-pdf-reload.mjs:直接调 pdf.js 传两次必然失败)——因为"只在不该失败的时候失败"最容易在后续重构里被改回去。

③ 配套资源必须是相对路径

cMapUrl: "pdfjs/cmaps/" 这里的相对路径是有原因的:应用部署在子路径(比如 /app/)下,写成 /pdfjs/cmaps/ 会解析到域名根,404 之后 pdf.js 会把返回的 HTML 当 cmap 解析,最终抛出一句完全指不到真因的错误:Cannot convert object to primitive value

错误信息的指向性,决定了这类问题的排查成本。 我们在代码注释里把这条因果链写全了,就是为了下一次有人在子路径下改回绝对路径时,能一眼看到会发生什么。

6. 文本层精确划词:从"拖拽框"到"贴着字的框"

自由拖拽的高亮框和真实文字之间总是差一点。有文本层时,把它吸附到实际文字项的包围盒上:

// src/lib/pdfTextLayer.ts
/** Approximate a pdf.js text item's page box (scale-1 page coords). */
export function textItemBox(item: TextItemLike): [number, number, number, number] | null {
  const t = item.transform;
  if (!t || t.length < 6) return null;
  const x = Number(t[4] ?? 0);   // transform[4],[5] = 文字基线的页面坐标
  const y = Number(t[5] ?? 0);
  return [x, y, x + Number(item.width ?? 0), y + Number(item.height ?? 0)];
}

/** Snap a drag box (page coords) to the union of the text-item boxes it overlaps.
 *  Returns a normalized [0,1] box, or null when nothing overlapped (caller falls
 *  back to the raw drag box). */
export function snapHighlightToText(dragBox, items, pageW, pageH) {
  let union = null;
  for (const item of items) {
    const box = textItemBox(item);
    if (!box || !intersects(box, dragBox)) continue;
    union = union ? [Math.min(union[0], box[0]), Math.min(union[1], box[1]),
                    Math.max(union[2], box[2]), Math.max(union[3], box[3])]
                  : [box[0], box[1], box[2], box[3]];
  }
  if (!union) return null;                                   // 没碰到字 → 调用方用原始框
  return normCoords(union[0], union[1], union[2], union[3], pageW, pageH);
}

三个设计点:

  • 求并集而不是取最近一项:跨行、跨段落的高亮本来就该是一个框;
  • null 是一个有语义的返回值:没吸到字时不返回"差不多"的框,而是让调用方自己决定退回原始框(这个决定属于 UI 层,不属于几何层);
  • 归一化在最后一步:函数内部全程用页面坐标算,只有出参归一化——所以它既能被"吸附"用,也能被导出/回链复用。

没有文本层怎么办?不硬撑。降级策略是一条纯函数:

/** Text-layer degrade strategy: when a page has a usable text layer we allow
 *  text-level highlight/underline; otherwise we fall back to rect + ink + sticky
 *  (rect doesn't depend on the text layer, so it's the stable baseline). */
export function annotationMode(hasTextLayer: boolean): "text" | "rect" {
  return hasTextLayer ? "text" : "rect";
}

即:矩形 / 画笔 / 便签是不依赖文本层的稳定基线,扫描件上照样能批注;精确划词是"有文本层才有的加分项",识别(OCR)则是另一条兜底通道(见本系列上一篇)。

顺带一个复用:textInBox 把框里的文字按文本项拼出来,交给"AI 帮读"当输入——同一个 textItemBox,三种用途。几何层写干净了,上层能力是长出来的。

7. 摘录成块与 pdf:// 回链:让批注能被引用

批注如果只能画在 PDF 上,它就还是个"阅读器功能"。要做到第 4 条需求(能回去),摘录必须变成笔记里的一个块,并且带回去的坐标:

/** Stable back-ref string for an excerpt turned into a block. */
export function pdfRef(attachmentId: string, pageIndex: number): string {
  return `pdf://${attachmentId}#${pageIndex}`;
}

/** Parse a `pdf://attachment#page` ref back into its parts (for reopening). */
export function parsePdfRef(ref: string) {
  const m = /^pdf:\/\/(.+)#(\d+)$/.exec(ref);
  return m ? { attachmentId: m[1], pageIndex: Number(m[2]) } : null;
}

pageToBlock 把这个引用写成笔记编辑器(Lexical)能吃的 JSON:一个 paragraph,里面一个文本节点 + 一个自定义 pdfref 节点。引用是"数据"而不是"链接文本"——所以它能被点击、被重新解析、被跳转到正确那一页。

这套东西真正值钱的地方在于它把一条链子接通了PDF → 批注 → 摘录 → 笔记块 → 点回 PDF。而链子越长,坐标基准越不能动——中间任何一环的基准变了(比如换渲染引擎),回的就不是"原来那一页"了(见 §8)。

8. 诚实部分:接口里的"假字段",和还没验的东西

  • 接口签名里有一个实现里不存在的字段。 PdfRenderEngineApi.getPageMeta() 返回 hasTextLayer,但 pdf.js 的实现里它恒为 false(占位)——真实判定在调用方按 textItems.length > 0 推导:

    // 不在此做 getTextContent()(慢):hasTextLayer 由 getPageTextItems 推导,
    // 让页面图像/宽高秒出,不阻塞首屏。
    return { index: pageIndex, width: vp.width, height: vp.height, hasTextLayer: false };
    

    做出这个决定的理由(别让首屏为了一次 getTextContent() 卡住)是对的,但代价是签名在骗人:任何直接读这个字段的新代码都会拿到 false。这类"为了性能把字段留成占位"的做法,要么在类型上表达出来(hasTextLayer?: boolean),要么就该在文档里写死——我们目前是靠注释。

  • /Rotate 的页面,坐标没有做过对拍。 getViewport() 会处理页面旋转,而文本项的 transform 是页面空间的——这两者在旋转页上是否始终一致,我们没有任何测试钉住。所以这条是"已知风险"而不是"已验证"。

  • 跨引擎坐标对拍没做。 我们核实过一套把 Web 光栅化也换到 WASM 版 MuPDF 的方案,结论里最硬的一条就是:getPageTextItems 的坐标语义必须写"两引擎对拍"单测(同一份 PDF、同样的文本项、坐标在容差内一致),否则划词必偏。这套对拍没做,方案也就停在文档上——现在还是"桌面 native + Web pdf.js"

  • hasTextLayer 决定 OCR 触发条件,所以上面那个占位字段的影响不只是划词:它连着"什么时候该提示用户去识别"。

  • 让便签大小写死像素值(26px 上限)是审美与一致性的取舍,不是几何必然;在超大页面上它会显得偏小。

9. 结论

做完这套,我最大的感受是:批注系统的难点从来不在"画",而在"坐标在几个地方保持一致"。

它有四个投影面:屏幕 overlay、缩放、导出、回链。让它们一致的唯一可靠办法,是让它们共用同一份归一化数据和同一个几何函数,而不是"每个地方都小心一点"。

第二件事是把平台差异挡在校验层。这一篇里几乎所有难查的 bug,本质都是同一个形状:

现象真因挡法
WKWebView 抛 NaN / Chrome 白屏响应形态不是 ArrayBuffer出入口统一 validatePage
"这页一直是空的"toBlob 静默返回 null回退 toDataURL
图能显示但花屏/斜切pixmap 行对齐填充Rust 侧 compact_rgba
第二次打开同一个 PDF 失败buffer 被 transfer 掉交私有副本 + 门禁
Android WebView 里 pdf.js 起不来worker 缺 Promise.withResolversworker 垫片
cmap 报一句看不懂的错子路径下绝对路径 404 → HTML 被当 cmap配套资源用相对路径

一句话:批注是"坐标数据",不是"画在图上的一笔"——只要它是一份归一化数据,换引擎、换缩放、导出、回链,都只是同一个数的不同投影;剩下的工作,就是别让任何一个平台差异污染这个数。


(本文为 ShuyoNote 的 PDF 批注工程笔记,涉及文件:src/lib/pdfRender.tssrc/lib/pdfAnnotation.tssrc/lib/pdfTextLayer.tssrc/lib/pdfNativePage.tssrc/lib/pdfLayout.tssrc/lib/pdfEngine/pdfjsEngine.tssrc-tauri/src/pdf_native.rsscripts/check-pdf-reload.mjsscripts/check-pdfjs-worker-shim.mjspublic/pdfjs-worker-shim.mjs。欢迎评论区聊坐标与取舍。)