0. 需求:批注不是"画一笔",是"存一个坐标"
一个能用的 PDF 批注至少要满足四件事:
- 能画在页面上——高亮、下划线、画笔、矩形、便签;
- 缩放不飘——从 50% 拉到 400%,高亮还贴着那行字;
- 能带走——导出成带批注的 PDF,屏幕上看什么样,导出就什么样;
- 能回去——摘录出来的文字进笔记之后,点一下能跳回原文那一页。
四条里有三条与"画"无关,全都在问同一个问题:这个批注的坐标存在哪、以什么为基准。
所以这一篇不讲 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. 双引擎:一份接口,两个实现
桌面端用原生 MuPDF(mupdf-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 回到前端,于是就有了真实存在、但文档里查不到的三种形态:
{ width, height, rgba_base64 }—— 现在的契约,所有平台一致;ArrayBuffer/Uint8Array—— 原始响应在 Windows/Linux 上的形态;- 数字数组(
[12,240,…])—— macOS / iOS 上原始响应被 JSON 编码后的形态。
旧代码写的是 buf instanceof ArrayBuffer,然后从 8 字节头里读宽高。在形态 3 下:
instanceof 为假 → 落到兜底分支 → width 是 undefined
→ canvas.width / createImageData(undefined, undefined) 收到 NaN
→ WKWebView 抛 "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.withResolvers 与 AbortSignal.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.withResolvers | worker 垫片 |
| cmap 报一句看不懂的错 | 子路径下绝对路径 404 → HTML 被当 cmap | 配套资源用相对路径 |
一句话:批注是"坐标数据",不是"画在图上的一笔"——只要它是一份归一化数据,换引擎、换缩放、导出、回链,都只是同一个数的不同投影;剩下的工作,就是别让任何一个平台差异污染这个数。
(本文为 ShuyoNote 的 PDF 批注工程笔记,涉及文件:src/lib/pdfRender.ts、src/lib/pdfAnnotation.ts、src/lib/pdfTextLayer.ts、src/lib/pdfNativePage.ts、src/lib/pdfLayout.ts、src/lib/pdfEngine/pdfjsEngine.ts、src-tauri/src/pdf_native.rs、scripts/check-pdf-reload.mjs、scripts/check-pdfjs-worker-shim.mjs、public/pdfjs-worker-shim.mjs。欢迎评论区聊坐标与取舍。)