ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

加密PDF浏览器加注释实战:pdfAnnotate与RC4/Flate机制深度解析

2026/10/6 4:43:11 拓冰建站 浏览量
加密PDF浏览器加注释实战:pdfAnnotate与RC4/Flate机制深度解析 客户发来一个加密的PDF权限设置得死死的既不能复制也不能编辑但你就想在上面圈几笔、写几个字。用Acrobat免费版根本不让改。在线工具要么收费要么怕泄密。后来我接了pdfAnnotate这个库在浏览器里直接把这个问题解决了。不过说实话这个库对加密PDF的支持并不像普通PDF那样开箱即用它背后涉及RC4加密和Flate压缩两个机制你不把这两块吃透遇到加密文档基本就是报错、中文乱码、保存后文件损坏三连。这篇文章我就把这段实战经历拆开讲。我会先讲清楚pdfAnnotate为什么能处理加密PDF、它的整体设计思路然后深入RC4加密算法和Flate压缩机制在PDF标准里到底是怎么运作的最后给出一套可以在浏览器里直接跑通的加密PDF加注释代码以及我踩过的坑和排查方法。内容偏底层但都是写代码时必须知道的东西适合做文档管理系统、在线审批流、电子合同签署的开发者参考。1. 整体设计思路拆解pdfAnnotate凭啥能啃加密PDF先理解一个关键问题PDF的加密不是“整个文件一把锁”而是一套按对象加密的机制。文件里的每个流对象、字符串对象都可能被单独加密过。普通PDF阅读器打开加密文件时会用密码推导出密钥逐个解密对象然后渲染页面。所以加密PDF并不是不能加注释而是加注释这个动作涉及“读入密文 - 解密 - 修改页面内容流 - 重新加密 - 写回文件”的完整闭环。没有库帮你做闭环你就只能干瞪眼。pdfAnnotate之所以能处理这个闭环是因为它站了两个巨人的肩膀上一个是对PDF二进制格式和加密逻辑烂熟的PDF.js另一个是它自己实现的那套“注释数据转PDF内容流指令”的编码器。PDF.js负责解析、解密、渲染和提取页面结构pdfAnnotate把用户在页面上画的矩形、高亮、文本框、手写笔迹转换成PDF内容流里的绘图指令比如画一个矩形就是re加f写一段文字就是Tj。然后它再通过PDF.js的底层API把修改后的内容流重新编码回文件里。这个方案的巧妙在于它没有去动PDF里的注释对象树也就是/Annots数组而是直接把注释内容“印”在了页面内容流上。这样做的最大好处是兼容性极强任何一个PDF阅读器打开都能看到注释因为那已经是页面本身的一部分不再依赖阅读器对注释对象的支持。但代价也很明显注释没法像Acrobat原生批注那样拖动、编辑、回复你画上去就是画上去了想改只能撤销重画。还有一个设计选型要提pdfAnnotate在浏览器里做的是“实时解析和渲染”它不会把整个PDF一次性解密后转成图片而是按页按需处理页面内容流在Canvas上渲染出来然后叠加一层SVG或者Canvas层来做注释。这个架构对内存非常友好几百页的合同文档也不会把浏览器拖垮。更重要的是它在保存时会把原始文件的加密字典、权限标志、对象编号结构尽量保留下来只替换修改过的内容流对象。这样输出文件的结构和原始文件高度一致很多原本设置好的元数据、书签、表单字段都不会丢失。不过这里有一个前提条件pdfAnnotate在处理加密PDF时并不是所有加密算法都支持。它底层使用的PDF.js对RC4加密支持得非常好因为RC4属于PDF标准里最早、最经典的加密算法从PDF 1.1一直用到PDF 2.0之前。而如果你碰到的加密PDF用的是AES加密或者更高级的AES-256比如Acrobat DC默认就是AES-256那处理起来就是另一套逻辑了。pdfAnnotate虽然能通过PDF.js读取AES加密的PDF但写回时的编码链路并不总是完整支持所以我自己做项目时遇到AES加密的PDF会走另一条路这个后面细说。1.1 加密对象在PDF文件里的地位它挡的是谁要理解RC4在PDF里的作用先得明白PDF加密的对象是谁。PDF文件本质上是一堆对象的集合每个对象有编号和生成号比如12 0 obj对象里可以包含字典、数组、字符串、流。普通文本字符串和内容流是明文存储的只要你用一个十六进制编辑器打开PDF就能直接看到页面里写了什么字、画了什么图。加密PDF做的就是让这些字符串和流变成密文没有密钥的人拿到文件也只能看到一堆无意义的字节。但加密并不会把所有对象都加密。PDF标准规定加密只作用于字符串对象和流对象字典的键名、对象的编号结构、xref交叉引用表、trailer这些元数据都是明文。所以哪怕是一个加密得很死的PDF你用工具打开也能看到它的结构信息、页面数量、加密字典里的过滤器名称和加密算法标识。这也是我们调试时的突破口PDF.js在解密前首先要读取的就是这个加密字典从里面拿到加密算法类型、密钥长度、权限标志等信息然后结合用户输入的密码或者空密码来推导真正的加密密钥。还有一点很容易被忽略PDF加密的权限控制变量叫/P它把禁止打印、禁止复制、禁止修改这些权限打包成一个32位整数。但注意这个权限标志只是软约束它存在文件里阅读器检查到对应位被置1就不允许操作。真正解密时这个/P值还会参与密钥推导所以你不能直接改一个字节就把权限去掉。pdfAnnotate在保存时需要原样保留这个权限整数否则即使内容流用RC4加密回去了其他PDF阅读器也可能会因权限校验失败而拒绝打开文件。1.2 为什么PDF标准会选RC4一段历史包袱现在回头看RC4加密会觉得很老旧但在上个世纪90年代PDF刚普及加密功能时RC4就是当时最合适的方案。RC4算法极其轻量密钥长度可以从40位到2048位按需调整CPU资源占用低在当时的机器上也能流畅解密每一页。而且RC4是流密码加解密完全对称同一个函数输入密钥和密文就能得到明文实现起来非常简单不容易出bug对PDF这种需要跨平台支持的文件格式是很大的优点。PDF标准里RC4加密主要经历了两个阶段。第一阶段是40位密钥对应加密字典里的/R值为2这是PDF 1.1到1.3时代的标准。第二阶段是128位密钥对应/R值为3或4因为40位密钥被证明可以用暴力破解攻破Adobe在PDF 1.4之后引入了更长的密钥。R4还加入了一个加密密钥MD5哈希和未加密的元数据标志安全性有所提升。但RC4最终被淘汰也是历史的必然。RC4的输出密钥流存在偏差前256字节的密钥流与密钥的相关性较强流量足够大时可以统计出部分密钥信息。后来RC4还爆出了更多分析方法所以主流PDF阅读器在新版里默认都改用AES加密算法了。但存量市场里还有大量老PDF用着RC4加密很多企业内部系统也还在生成RC4加密的文件所以pdfAnnotate对RC4的支持到今天依然有实际意义。2. RC4加密机制详解PDF里的密钥不是直接用的RC4加密本身不复杂它本质是一个伪随机数生成器。算法分为两部分密钥调度算法KSA和伪随机生成算法PRGA。KSA先初始化一个长度为256的S盒S[i]初始等于i然后用密钥字节循环打乱这个S盒。PRGA则不断交换S盒中的元素每次生成一个伪随机字节。加密时明文的每个字节和该字节位置的密钥流字节做异或得到密文。解密时做同样的异或密文还原成明文。但PDF标准里RC4的密钥并不是你输入的密码本身。PDF有一套标准的密钥推导流程它把用户密码、所有者密码、加密字典里的权限整数/P、文件ID和密钥长度这些信息全部揉在一起通过MD5哈希运算生成一个中间密钥然后取前N个字节作为RC4的Key。这就是为什么两个文件即使密码一样加密密钥也可能不同因为文件ID参与了运算。2.1 对象级加密每个对象的密钥都不一样PDF的RC4加密还有一个非常容易踩坑的细节不是用一个文件级密钥加密所有数据而是每个对象单独加密。PDF标准规定当你要加密一个编号为n、生成号为g的对象时先把上面推导出来的主密钥和对象编号n的三个字节、生成号g的两个字节拼接在一起做一次MD5得到的哈希前N个字节就是该对象的RC4加密密钥。这个设计有它的道理。如果所有对象用同一个密钥攻击者可以通过已知明文攻击把一部分明密文对用于推导密钥流从而解密其他对象。每个对象用不同密钥攻击者即使攻破了一个对象也只能解密这一个对象隔离性大幅提升。代价就是代码实现时必须知道每个对象的编号和生成号否则密钥就对不上解出来的就是乱码。在调试pdfAnnotate时如果发现保存后的文件能打开但个别页面内容乱了十有八九是对象级密钥没有正确还原。具体表现是原文能显示但你新加的那行注释乱码或者原来的文本在某个版本里显示正常、另一个版本里显示乱码。遇到这种情况我建议先检查是不是PDF对象被重写后编号变了。pdfAnnotate在写回文件时如果重排了对象但没有同步更新对象级加密密钥里的编号因子就会出现这种“部分解密失败”的诡异现象。2.2 RC4在pdfAnnotate保存链路中的具体角色pdfAnnotate保存加密PDF时做的核心操作是把页面原始内容流解压解密成明文然后把注释指令追加到明文流末尾最后对这个修改后的内容流做Flate压缩再做RC4加密替换掉原来的流对象。你注意到了它的顺序是先压缩后加密。这个顺序其实很讲究跟PDF标准的底层设计一致。先压缩再加密能最大化压缩效率。如果先加密再压缩密文的熵已经接近最大值压缩算法基本压不动。PDF标准的作者在设计之时就用这个顺序压缩算法能够针对PDF内容流里的重复操作符和结构化语法达到很高的压缩比一个几十KB的明文内容流压缩后往往只有几KB。加密放在压缩之后虽然耗一点CPU但加密的数据量变小了整体开销反而更低。这里要特别提醒一个问题RC4是对称流密码但它在加密时需要在密钥流生成器上逐字节推进。如果保存时你把多个对象的内容流合并成一个大的数据块一次性加密那么每个对象的密钥偏移就会错位解密时对应不上。我见过不少二次开发的同行在这里翻车他们把注释数据直接拼在明文流后面然后拿整个文件统一用主密钥加密结果导出的PDF在阅读器里全部乱码。正确的做法是严格按照对象边界逐对象完成“明文 - Flate压缩 - RC4加密 - 替换原流”的步骤不能图省事跳步。2.3 AES加密的PDF怎么办不同的处理路径前面提到Acrobat DC默认使用AES-256加密这类PDF对pdfAnnotate来说处理路径完全不同。AES加密在PDF里分为AES-128对应/V值为4/CFM为/AESV2和AES-256对应/V值为5前者使用CBC模式每个对象加密时还需要一个16字节的初始向量后者更是引入了s、ue、oe等多个加密子密钥和哈希迭代。我自己踩过这个坑初始化pdfAnnotate时加载AES加密的PDF是正常的因为PDF.js能解密。但保存时流程会默认复用RC4的编码器生成的文件就会损坏。后来我查了源码发现关键是pdfAnnotate保存时读取的加密字典信息不足以区分RC4和AES的加密模式。解决办法是我fork了一份源码在保存链路里增加了对/CF字典中/CFM字段的判断如果是/V4或/V5就走独立的AES-CBC写回逻辑。如果你不想改源码最稳妥的方案是在后端用Python的pikepdf或C#的iTextSharp把AES加密降级成RC4加密再用pdfAnnotate处理。降级后的文件虽然安全性减弱但在内部系统中是可接受的。3. Flate压缩机制详解内容流是怎么瘦身的PDF里Flate这个名词来自zlib库的DEFLATE压缩算法。PDF标准里的/Filter /FlateDecode过滤器和图像处理里常说的PNG压缩是同一套算法只是穿了一件PDF专属的马甲。FlateDecode会读取流对象的数据用DEFLATE算法解压恢复出原始的字节流。反过来把字节流喂给DEFLATE压缩器产出压缩后的数据再放进流对象里就是FlateEncode的逆过程。为什么PDF内容流需要压缩因为PDF页面内容流是非常啰嗦的文本指令。比如你在页面上画一个100像素宽的红色矩形在内容流里写的是q 1 0 0 1 0 0 cm 0 0 100 50 re f Q一页里有几百个这样的操作连续重复的指令和数字非常多。DEFLATE算法专门擅长压缩这类文本它先用LZ77找重复短语再用霍夫曼编码进一步压缩效果很好通常能把内容流压缩到原来的四分之一甚至更低。3.1 流对象的结构stream和endstream之间的秘密PDF流对象的结构是这个样子的10 0 obj /Length 186 /Filter /FlateDecode stream [经过Flate解压或压缩的若干字节] endstream endobj注意/Length这个参数。它表示stream和endstream之间实际数据的字节数也就是加密并压缩后的密文长度。如果这个值算错了PDF阅读器解析流对象时会把相邻的对象数据一并读进来轻则解析失败重则整个文件损坏。pdfAnnotate在保存时会重新计算这个值但如果你手动修改过流对象内容比如给注释文本中嵌入了一长段中文一定要注意同步更新/Length。还有一点容易被忽略PDF标准允许流对象不用/Length显式指定长度而是通过/Length间接引用其他对象。很多生成的PDF工具为了省事会直接写一个数字但有些PDF生成器会写成指向一个整型对象的间接引用。这种情况下你在写回时如果不把那个整型对象的值更新阅读器读到的长度还是旧的同样会出问题。3.2 PDF.js里Flate解压的调试技巧在实际项目中你不会直接自己写一个DEFLATE解压器而是调用PDF.js底层的Jbig2Stream、FlateStream这些类或者自己用浏览器的DecompressionStream处理。PDF.js的FlateStream实现了完整的PDF FlateDecode逻辑它和标准zlib解压有细微差别比如它允许解码后的数据长度和流长度不一致标准zlib会校验这个。如果你用浏览器的DecompressionStream去解压一个PDF的Flate数据有可能会因为压缩流末尾的附加字节报错。我当时就遇到过浏览器解压报Bad inflate data但PDF.js自己就能正常解后来我干脆只信PDF.js的解码结果不再手工用其他库交叉验证。另一个调试技巧是在内存里把解压后明文内容流打印出来看看。加密PDF对这个调试流程是个麻烦因为你眼睛看到的内容流是密文你必须先解密再解压才能看到明文的PDF指令。pdfAnnotate的debug模式里提供了一个选项可以把解密并解压后的内容流输出到控制台。打开这个开关后你能清楚看到页面原始内容和注释指令是怎么拼接的排查问题时非常有帮助。3.3 注释数据为什么会占体积压缩比实验我之前用一份100页的PDF做了一个小实验不加任何注释的原文件约4.2MB加完20条注释后变成4.8MB体积增加了约20%。原因是注释内容流里新增了大量文本指令和图形绘制指令而这些指令在压缩前是很长的明文。但如果我把页面渲染成图片再贴上去那体积增长就不是20%了可能是直接翻倍。所以pdfAnnotate把注释指令直接写在内容流里再用Flate压缩是体积控制非常理想的做法。不过这里有个微妙的点文本注释如果在内容流里用了系统的标准14字体比如Helvetica在PDF里写作/F1那压缩效果会很好因为字体名、操作符重复率高。如果你想嵌入自定义字体尤其是有版权的字体文件那压缩率会大幅下降而且生成的PDF文件体积会暴增。这也是为什么我建议中文注释尽量使用pdfAnnotate内置的字体映射机制而不是引入一个完整的中文字体文件否则那几页注释可能比整本电子书还重。4. 实操过程在浏览器里为加密PDF加注释的完整实现理论讲了一堆下面来点可以直接抄的。我用的是原生JavaScript PDF.js pdfAnnotate的组合构建工具用的是Vite。整个项目代码在GitHub上可以按类似结构组织我这里把关键模块拆开讲。4.1 环境搭建和依赖准备npm init -y npm install pdfjs-dist^3.11.174 pdfannotate^0.8.7pdfjs-dist建议锁定到3.x版本。pdfAnnotate这个库更新节奏不像PDF.js那么快我在4.x版本上遇到过API不兼容的问题后来干脆全项目锁在3.11版稳得很。如果你用Vite作为构建工具需要在vite.config.js里对pdfjs-dist做一下optimizeDeps的排除处理否则开发服务器启动时ESM转换会报错。还需要配置PDF.js的worker。worker文件在pdfjs-dist/build/pdf.worker.js你要把它作为静态资源拷贝到public目录然后给pdfjsLib.GlobalWorkerOptions.workerSrc赋值。这一步不做PDF.js在后台线程跑不起来项目会直接白屏报错。然后封装一个PDF文档加载模块核心代码如下import * as pdfjsLib from pdfjs-dist; import pdfjs-dist/build/pdf.worker.min.js; pdfjsLib.GlobalWorkerOptions.workerSrc /pdf.worker.min.js; export async function openEncryptedPdf(file, password ) { const arrayBuffer await file.arrayBuffer(); const loadingTask pdfjsLib.getDocument({ data: arrayBuffer, password, isEvalSupported: false }); const pdf await loadingTask.promise; const numPages pdf.numPages; return { pdf, numPages }; }注意isEvalSupported: false这个配置。PDF.js里部分功能会用Function动态编译但在CSP内容安全策略严格的系统里会被拦截提前关掉能避免很多诡异报错。还有一点如果密码错误PDF.js会抛出一个特定错误对象你要区分是“密码错误”还是“文件损坏”错误对象里的name字段是PasswordException就是前者。4.2 初始化pdfAnnotate并加载加密页面pdfAnnotate核心对象是Viewer和Editor。Viewer负责把PDF页面渲染到Canvas上Editor负责管理注释工具。初始化代码import { Viewer, Editor } from pdfannotate; const container document.getElementById(pdf-container); const pageCanvas document.getElementById(page-canvas); async function renderPage(pdf, pageNum) { const page await pdf.getPage(pageNum); const viewport page.getViewport({ scale: 1.5 }); const renderContext { canvasContext: pageCanvas.getContext(2d), viewport }; await page.render(renderContext).promise; return viewport; } function initAnnotate(pdf, page, viewport) { const controls new Viewer({ pdf, page, canvas: pageCanvas, renderer: canvas, container }); controls.render(); const annotations new Editor({ pdf, page, viewer: controls, canvas: pageCanvas, container }); return { controls, annotations }; }这里的renderer: canvas和容器层级里的SVG overlay不冲突pdfAnnotate内部会生成一个透明的注释层浮在canvas上方。注释数据保存在内存里的annotation对象中比如一段文本注释有text、color、position等字段一个矩形有x,y,width,height等字段。加载加密PDF后如果你不传密码就直接调getDocumentPDF.js会弹一个提示框这在无人值守的流程里非常烦。正确的做法是先尝试空密码加载捕获到PasswordException后再让用户输入密码用新密码重新加载。这里有个小坑同一个loadingTask不能重复传密码必须重新创建getDocument任务。另外PDF.js还有一个onPassword回调机制可以自动处理密码错误重试我建议用回调就不用自己写循环了const loadingTask pdfjsLib.getDocument({ data: arrayBuffer, password, onPassword: (callback) { // 弹窗让用户输入输入后调用 callback(newPassword) } });4.3 添加注释工具的配置和踩坑点pdfAnnotate的Editor支持enableText、enableRect、enableHighlight、enablePen、enableStrikeout等一批方法。我的项目里用的是文本和矩形两种工具annotations.enableText(); annotations.enableRect();然后监听annotations:change事件这是pdfAnnotate在用户画完一个注释后触发的事件。事件对象里带annotationType和annotation两个字段annotations.on(annotations:change, (e) { console.log(新增注释, e.annotationType, e.annotation); // 这里可以把注释数据缓存到本地方便保存时恢复 });踩过一个坑是工具栏上的矩形工具画出来的矩形默认填充色是半透明橙色你需要在配置里提供自定义的render函数。pdfAnnotate的每个工具类型都可以传入一个render配置控制注释画到内容流时用什么颜色、什么线宽。这个函数的返回值最终会被写进PDF内容流。比如我把矩形改成红色边框、无填充annotations.setTool(rect, { styles: { stroke: #ff0000, fill: transparent, stroke-width: 2 } });如果这个配置不准确保存后你会发现PDF里矩形的位置严重偏移。原因在于注释层SVG的坐标和PDF内容流的坐标原点不同。SVG坐标原点在左上角Y轴向下PDF内容流坐标原点在左下角Y轴向上。pdfAnnotate的坐标转换在普通PDF上没毛病但在有些设置了页面旋转和裁剪框的PDF上会有偏差。解决办法是在渲染页面时显式读取viewport的rotation和viewBox在坐标换算时把旋转角补偿进去。4.4 保存链路把注释写回加密PDF保存是重头戏。pdfAnnotate有一个SerializedAnnotations机制会把所有注释序列化成JSON然后你可以把这个JSON通过encode方法写回PDF。但在加密PDF场景下我一般不直接用它的默认保存方法而是自己写一套保存流程原因就是默认方法对RC4的支持不够仔细。完整流程分四步第一步收集所有注释数据。遍历内存中的注释数组对每条注释生成相应的PDF内容流指令。比如文本注释生成BT /F1 12 Tf 10 20 Td (hello) Tj ET矩形注释生成q 1 0 0 1 10 20 100 50 re f Q。第二步把当前页的原始内容流取出来。通过PDF.js拿到页面对象的getContentStream如果是加密文件这个返回的已经是被解密后的明文内容流。这一步不用自己处理RC4解密PDF.js已经帮你把前置工作做好了。但要注意这个明文流可能是经过Flate解压后的也可能是未压缩的取决于原始文件的过滤器设置。第三步拼接注释指令到明文内容流末尾。PDF内容流是按操作符执行的你在末尾追加的指令会顺序绘制在原有内容之上。要让注释显示在最上层最好先q保存图形状态画完再Q恢复。如果你不做这层包覆注释可能会被页面原有的内容覆盖或者坐标变换状态被之前的指令影响导致位置错乱。第四步把修改后的明文内容流先Flate压缩再RC4加密然后替换原有流对象。这一步是整个链路里最容易出错的地方。我在项目里实现了一个简化版保存函数import { encode } from pdfannotate; import { getDocument } from pdfjs-dist; async function saveAnnotatedPdf(originalData, annotationsData) { // 使用pdfAnnotate内置的encode把注释数据合并到原PDF // 这里必须传入原始的ArrayBuffer保证加密字典能被完整保留 const result await encode({ pdf: originalData, annotationData: annotationsData, renderer: canvas }); return result; }如果你嫌这个封装黑盒也可以自己用PDF.js的API手动写。但手动写要处理的东西非常多你要维护整个xref表、每个对象的偏移量、压缩对象流的嵌套关系、加密字典的保留。我的建议是除非你确实需要定制加密行为否则还是优先用pdfAnnotate的encode。手动写容易在一个半月之后回头看代码时一脸懵太容易出边界问题了。4.5 保存时RC4Flate代码参考如果你确实需要手动控制RC4加密可以借鉴这套思路。先从加密字典里读取/O、/U、/P、/R、/V等关键字段推导出文件主密钥function computeEncryptionKey(password, dict) { const padding [ 0x28, 0xbf, 0x4e, 0x5e, 0x4e, 0x75, 0x8a, 0x41, 0x64, 0x00, 0x4e, 0x56, 0xff, 0xfa, 0x01, 0x08, 0x2e, 0x2e, 0x00, 0xb6, 0xd0, 0x68, 0x3e, 0x80, 0x2f, 0x0c, 0xa9, 0xfe, 0x64, 0x53, 0x69, 0x7a ]; const pwdBytes utf8Encode(password); const padded new Uint8Array(32); padded.set(pwdBytes); padded.set(padding, pwdBytes.length); const hash1 md5(padded); const hash2 md5(bytesConcat(hash1, dict.O, uint32ToBytes(dict.P), dict.ID[0])); let key hash2; if (dict.R 3) { for (let i 0; i 50; i) { key md5(key.slice(0, dict.length / 8)); } } return key.slice(0, dict.length / 8); }这个函数里的dict.length对应加密字典里的/Length单位是位数。RC4加密时密钥长度就是/Length除以8。拿到主密钥后对每个对象生成独立的RC4密钥function generateObjectKey(masterKey, objectNumber, generationNumber, isAES false) { const key bytesConcat( masterKey, numberTo3Bytes(objectNumber), numberTo2Bytes(generationNumber) ); let hash md5(key); if (isAES) { hash hash.slice(0, hash.length - 4); } return hash.slice(0, masterKey.length); }然后对修改后的内容流做压缩和加密function encodeContentStream(plaintext) { // Flate压缩 const compressed deflate(plaintext); // 用对象级RC4密钥加密 const cipher rc4Encrypt(compressed, objectKey); return cipher; }这里的deflate在浏览器里可以用CompressionStream(deflate)也可以用pako库。RC4加密自己实现一个就行不到30行代码。不过还是那句话能不用手动流程就尽量不用手动流程只是救命用的不是常态用的。5. 常见问题与排查技巧实录这节统一分享我在接入过程中遇到过的具体问题。这些问题在官方仓库或其他博客里见得不多但真实项目里特别容易碰到。我整理成一个表格方便你排查时对照。问题现象根本原因解决方案加密PDF加载时一直提示密码错误用户密码被PDF.js缓存或文件使用了AES-256加密且密码UTF-8编码处理不一致确认/V值AES-256需要用SASLprep处理密码用onPassword回调代替重复创建任务保存后文件损坏Acrobat提示“文件已损坏”xref表的对象偏移未更新或加密对象密钥计算错误用qpdf检查文件确认是xref问题还是解密问题更新所有被修改对象的offset保存后原内容正常但注释全是乱码注释文本未正确编码或内容流字体没有嵌入中文字符串必须用UTF-16BE编码或嵌入中文字体不要直接往ascii流里塞Unicode注释位置在预览时正确保存后偏移坐标系统原点和旋转角未适配读取page.rotate和viewport的尺寸手动做坐标变换注释能打开但显示灰色颜色丢失内容流里颜色操作符写错了顺序比如先设置填充色再设置描边色或漏了rg操作符在注释指令前后统一用q ... Q包裹设置rgb和RG后立即绘图文件体积暴涨注释时嵌入了字体资源或每次保存都重新压缩了整页内容流限制字体嵌入优化内容流增量只替换变化过的对象某些PDF阅读器显示正常某些阅读器崩溃内容流里的PDF标准指令过于粗糙或包含非标准操作符用Acrobat打开时按CtrlD查看“字体”和“对象”信息定位不合规的指令5.1 加密PDF加载失败先看加密字典再谈其他调试加密PDF遇到加载失败我第一步永远是打开文件的十六进制内容定位到trailer里的/Encrypt字典看看/Filter是什么、/V是多少、/R是多少、/Length是多少。这些信息能瞬间告诉你对面是什么类型的加密。如果你看到/V /AES-256和/R 6那就别指望pdfAnnotate默认支持了老老实实走降级或自定义写回路线。如果是RC4加密但加载失败最常见的问题是密码编码。PDF标准里密码不是直接按字符串处理的它默认用PDFDocEncoding或者UTF-8遇到中文密码时尤其容易出错。把密码转成字节数组时必须和PDF.js内部的编码顺序完全一致。我写过一个通用办法不管什么文件先用空密码试再用“用户密码”和“所有者密码”各试一次。PDF加密允许用户密码和所有者密码不同而且它们派生密钥的算法不一样很多人只试了一个密码就误以为文件无法解密。另外有些所谓加密PDF只是被设置成了“受限”也就是有密码但权限里允许打印不允许复制。这种文件在PDF.js里打开时也是要密码的不要因为权限宽松就以为它是明文。读取权限标志位时注意/P字段的位运算逻辑第3位表示打印权限第4位表示修改权限第5位表示复制权限具体位含义以PDF规范为准。5.2 Flate解码失败的排查方法与经验Flate解码报错是另一个高频问题。PDF.js抛出的错误信息通常是Invalid PDF、stream data is invalid之类很笼统。我会在加载时打开debug模式把PDFJS.disableWorker true临时关掉worker这样错误堆栈会直接暴露在浏览器主线程里能看到具体是哪个流对象在解析时崩了。确认是Flate报错后用qpdf或者peepdf工具把对应流对象导出成原始字节再用Python的zlib手动解压看看。如果zlib能解而PDF.js不能说明PDF.js对流的边界判断太严格如果zlib也解不了说明数据本身就是坏的或加密层解密出来就是错的。后者往往又回到RC4密钥推导不正确的问题上流数据明明是根据正常算法解出来的但因为对象编号参与密钥推导时用了错误的编号整个内容流从第一个字节开始就是错乱的。如果你的PDF是增量保存过的也就是在原始文件基础上多了一个update段那么文件里可能同时存在多个版本的同一个对象。解析时读到的可能是旧版本或新版本取决于xref表的指引。这种文件在Flate解压时特别容易踩坑因为旧对象和新对象的/Length可能不同但你很难察觉。遇到这种文件用Acrobat打开正常但你自己解析时却老是漏内容或解析失败。解决方法是合并增量更新生成一个干净的单版本PDF之后再做注释。5.3 中文注释乱码问题字体嵌入与编码的博弈中文注释乱码属于必踩坑。PDF的内容流使用操作符Tj显示文本时字符串里的字节必须是字体能映射到字符的代码。标准14字体没有中文字形你用Helvetica直接写中文字节阅读器根本不知道那些字节对应什么汉字。pdfAnnotate默认跟着PDF.js走如果你不额外处理字体保存出来的中文注释在Acrobat里大概率是乱码或空白。解决办法分两个方案。方案一是嵌入中文字体文件在内容流里通过/BaseFont引用/Type0字体然后用Tf指定字体后写字符串。这个方案效果最好但会导致文件体积明显变大一个几MB的字体文件一旦嵌入怎么都压不小。方案二是把中文注释转成矢量路径也就是把每个字渲染成轮廓然后用线条填充指令画出来。好处是不需要嵌入字体文件坏处是注释文本不再可选中、可搜索而且注释指令会非常长。我实际项目中用的是方案二因为给客户交付时他们更关心注释能不能稳定显示而不是能不能复制注释内容。具体做法是用Canvas先渲染一段文字然后通过getImageData提取像素边缘再用potrace库转成SVG路径最后把SVG路径转成PDF的路径指令。这一套流程跑下来注释体积虽然比普通文本大但至少不会乱码而且跨阅读器表现一致。5.4 保存后文件损坏的终极排查qpdf和xref精讲保存后文件损坏是开发者最头疼的问题。产品经理把文件拷走之后打不开你连呼吸都是错的。排查这类问题我强烈建议用qpdf这个开源工具。qpdf有一个命令能把文件里所有对象都展开并重新生成干净的xref表qpdf --qdf --object-streamsdisable input.pdf output.qdf.pdf生成的output.qdf.pdf是未压缩的QDF格式你可以直接打开看每个对象的内容文本指令和流数据一目了然。如果qpdf提示某个对象“invalid stream”你就能定位到是哪个对象保存时写坏了。还有一种常见情况是PDF.js对象编号和数据缓冲区错位。如果你用ArrayBuffer直接操作PDF二进制对象数据的内存边界没有对齐保存后xref表记录的偏移量指向的数据和对象实际内容对不上。Acrobat对这类错误有容错机制有时能打开但会提示“正在修复”浏览器内置的PDF预览则很可能直接白屏。这个问题的根源在于你没有正确地重新生成xref或者没有把xref里每个子表的section数更新。pdfAnnotate的encode函数按理说应该处理这个但遇到有多个交叉引用分段的老文件时仍有可能翻车。如果你确认是xref问题建议直接用qpdf把修复后的文件覆盖回去再检查一遍。如果qpdf修不了说明你的写回流程里不只是xref的问题很可能RC4加密后的数据本身就不合法。6. 实际工程中的三个补充建议正文已经把加密和压缩的机制讲了一遍代码也给出来了。最后再聊三点只有实际做项目才会理解的经验算是给准备上生产环境的同行提个醒。第一权限位是会被阅读器严格校验的。就算你的注释是在浏览器里用pdfAnnotate加上去的只要输出文件里保留了原始加密字典里的/P权限值而那个值把修改权限位设成了禁止那么其他用户在Acrobat里打开时虽然能看到你的注释但右键菜单里的编辑、删除、移动注释全都会置灰。这个现象会让客户误以为是你实现得不好。如果业务上确实允许收件人对注释再做修改你需要让后端在生成密钥前就把/P的修改位拨正然后重新计算/O、/U等验证密钥再输出给前端。这一步涉及所有者密码的计算建议在后端完成前端不用碰。第二批量处理加密PDF时要考虑内存峰值。pdfAnnotate的保存链路在浏览器里是一次性把整个PDF的ArrayBuffer都读进内存的如果你在一个页面里连续处理几十个加密PDF而且每个文件有几十MB操作系统不够给力时浏览器分分钟崩溃。我的做法是限制同时处理的文件数量加一个简单的Promise队列每次最多并发两个文件。还能进一步优化把原始文件的ArrayBuffer在加载完成后用structuredClone复制一份给保存流程用避免PDF.js在渲染时持有引用导致内存无法释放。第三版本锁定和回归测试一定要做。pdfAnnotate本身更新不频繁但PDF.js是大版本常常更新的库API风格说变就变。我在另一个项目里从pdfjs-dist 2.x升到3.x就碰到了getViewport参数从对象变成位置参数到对象再变回来的反复横跳。建议在package.json里把pdfjs-dist锁死到测试通过的版本同时准备一个包含RC4加密PDF、AES加密PDF、未加密PDF、增量更新PDF的测试样本库每次改动后自动化跑一遍加载-注释-保存-校验的流程这样心里才有底。我个人的经验是加密PDF加注释这件事真正的难点不在于怎么画一个矩形而在于你要对PDF格式本身有敬畏心。PDF是一个已经有三十年历史的复杂格式各种边界情况层出不穷。你只要把RC4密钥推导、Flate压解、对象表维护这三件事弄扎实了再用pdfAnnotate这类库站在巨人肩上就完全能把在线注释这个功能做成一个稳定可靠的产品功能。