文章目录
-
-
- 结论
- 文件构成与角色
-
- [`parser_x.js` ------ 主库](#
parser_x.js—— 主库) -
- [wasm 二进制内嵌在 `parser_x.js` 里](#wasm 二进制内嵌在
parser_x.js里)
- [wasm 二进制内嵌在 `parser_x.js` 里](#wasm 二进制内嵌在
- [`parser_helper_x.js` ------ WASM 胶水](#
parser_helper_x.js—— WASM 胶水) - [`parser_helper_x.wasm` ------ 从主库内嵌块中抠出的副本](#
parser_helper_x.wasm—— 从主库内嵌块中抠出的副本)
- [`parser_x.js` ------ 主库](#
- [验证它确实是 OFD 解析库](#验证它确实是 OFD 解析库)
- 是纯前端库
- 输出形态:三种,都不强制用它的阅读器
-
- 结构化数据
- [可直接插入页面的 DOM 节点](#可直接插入页面的 DOM 节点)
- 阅读器(可选便利封装,且分两档)
- [阅读器能力清单(源码级)------ 两档差别很大,官方页面用的是简版](#阅读器能力清单(源码级)—— 两档差别很大,官方页面用的是简版)
-
- [简版 `OfdBaseViewer`(`openOFDBaseViewer` / `openOFDBaseViewerByBase64` / `openMetaBaseViewer`)](#简版
OfdBaseViewer(openOFDBaseViewer/openOFDBaseViewerByBase64/openMetaBaseViewer)) - [完整版 `OfdViewer`(`openOFDViewer` = `initOFDViewer`)------ PDF.js viewer 裁剪版](#完整版
OfdViewer(openOFDViewer=initOFDViewer)—— PDF.js viewer 裁剪版) - 打印:支持,两条互相独立的实现
- 打印能力的共同边界
- [简版 `OfdBaseViewer`(`openOFDBaseViewer` / `openOFDBaseViewerByBase64` / `openMetaBaseViewer`)](#简版
- [从源码还原的准确 API 签名](#从源码还原的准确 API 签名)
- 授权机制与水印(核心内容)
-
- 授权校验(失败即中止解析)
- 水印(仅在解析成功之后)
-
- 水印在渲染期的唯一开关(源码实证)
- [真实页面的渲染结果(有真实 OFD 快照可查)](#真实页面的渲染结果(有真实 OFD 快照可查))
- [`digest` / `secret` 的注入点链条(实证)](#
digest/secret的注入点链条(实证)) - [黑盒实测(从 JS 层的观察)](#黑盒实测(从 JS 层的观察))
- 结论与复现路径
- 抓包实证:请求与响应全貌(`ofdkey-请求和返回.txt`)
-
- 请求侧:**零客户端动态内容**
- [响应结构:`{ secret, digest }`,两者**都是服务端签发的**](#响应结构:
{ secret, digest },两者都是服务端签发的) - [最经济的用法模型(⚠️ 推测,但能同时解释全部实测)](#最经济的用法模型(⚠️ 推测,但能同时解释全部实测))
- 三个关键问题的答案
- 要完整复现,还差什么(技术待办)
- 黑盒变异实验:结构推测的检验与更正
-
- [逐位变异:唯一可观测的边界是 **offset 16**](#逐位变异:唯一可观测的边界是 offset 16)
- [载荷互换 / 整段替换:`10002` = 第二阶段兜底失败码](#载荷互换 / 整段替换:
10002= 第二阶段兜底失败码) - 对上文结构模型的裁决
- [本节能确定 / 不能确定的事](#本节能确定 / 不能确定的事)
- 与本仓库其它产物的关系
-
- [`parser_x.js` vs `parser_x-formatted-variant.js`:同一产品、不同构建](#
parser_x.jsvsparser_x-formatted-variant.js:同一产品、不同构建) - 与本仓库免费版的关系
- [`parser_x.js` vs `parser_x-formatted-variant.js`:同一产品、不同构建](#
- 如何加载
-
- [两个 js 能合并成一个文件吗?------ 能(实测)](#两个 js 能合并成一个文件吗?—— 能(实测))
- 配套的两个测试页
- [能否剥离 wasm,做一个"不需要授权的纯解析"?](#能否剥离 wasm,做一个"不需要授权的纯解析"?)
-
- 先纠正一个前提
- 文件不齐时,授权是怎么表现的(实测)
- [解析核心在哪一层 ------ 在 wasm](#解析核心在哪一层 —— 在 wasm)
- [那商业版为什么把解析放进 wasm?(仅为合理推测)](#那商业版为什么把解析放进 wasm?(仅为合理推测))
- 官方主页面集成实测(`app.xunjiepdf.com/ofdreading`)
-
- 先确认两件事:**就是这两份库**、**就是这个功能**
- 容器与上传区:为什么"加载后"少了半个页面
- [★ 缺失的合法参数从哪来:`../../ScriptsMain/ofdkey.json`](#★ 缺失的合法参数从哪来:
../../ScriptsMain/ofdkey.json) - 完整调用链(时序)
- [阅读器渲染产物实测(DOM 级)](#阅读器渲染产物实测(DOM 级))
- [附带收获:`ofd2pdf` 的实现真相(同一文件 L1147--L1310)](#附带收获:
ofd2pdf的实现真相(同一文件 L1147–L1310))
- [附录 A:关键字符偏移索引(`parser_x.js` 为单行文件)](#附录 A:关键字符偏移索引(
parser_x.js为单行文件)) - [附录 B:验证命令](#附录 B:验证命令)
-
- [附录 C:完整版阅读器「打印」失效的根因与修复(宿主页实测)](#附录 C:完整版阅读器「打印」失效的根因与修复(宿主页实测))
-
-
- [C.1 现象与第一手观测](#C.1 现象与第一手观测)
- [C.2 调用链(源码级,`parser_x.js`)](#C.2 调用链(源码级,
parser_x.js)) - [C.3 根因:`window.open` 迟到约 7 秒,超出「暂态用户激活」](#C.3 根因:
window.open迟到约 7 秒,超出「暂态用户激活」) - [C.4 修复设计(宿主页侧,不动 SDK)](#C.4 修复设计(宿主页侧,不动 SDK))
-
- [C.4.1 捕获阶段拦截:新增 `tpBindReaderPrintButtons()`](#C.4.1 捕获阶段拦截:新增
tpBindReaderPrintButtons()) - [C.4.2 预开窗口 + 接管 `window.open`:强化 `tpRunWithPrintWindow()`](#C.4.2 预开窗口 + 接管
window.open:强化tpRunWithPrintWindow()) - [C.4.3 保留的护栏](#C.4.3 保留的护栏)
- [C.4.1 捕获阶段拦截:新增 `tpBindReaderPrintButtons()`](#C.4.1 捕获阶段拦截:新增
- [C.5 实测验证(真实 Chromium + 本地 HTTP,方式 c)](#C.5 实测验证(真实 Chromium + 本地 HTTP,方式 c))
- [C.6 可迁移的通用结论](#C.6 可迁移的通用结论)
-
- [附录 D:三条打印路径的「同源」对照(方式 a / b / c 观感一致的原因)](#附录 D:三条打印路径的「同源」对照(方式 a / b / c 观感一致的原因))
-
-
- [D.0 三种方式的映射(宿主页 `static-toolbox/ofd/index.html`)](#D.0 三种方式的映射(宿主页
static-toolbox/ofd/index.html)) - [D.1 对照表(源码级)](#D.1 对照表(源码级))
- [D.2 逐字相同的证据(`parser_x.js`)](#D.2 逐字相同的证据(
parser_x.js)) - [D.3 两处「看着一样、其实不同」的隐患](#D.3 两处「看着一样、其实不同」的隐患)
-
- [D.3.1 缩放:方式 a 会跟着屏幕缩放跑](#D.3.1 缩放:方式 a 会跟着屏幕缩放跑)
- [D.3.2 水印:只有方式 a 会带上(建议浏览器实测确认)](#D.3.2 水印:只有方式 a 会带上(建议浏览器实测确认))
- [D.4 关于"是否真实还原"](#D.4 关于"是否真实还原")
- [D.5 复现](#D.5 复现)
- [D.0 三种方式的映射(宿主页 `static-toolbox/ofd/index.html`)](#D.0 三种方式的映射(宿主页
-
摘要: 本文基于对
parser_x.js、parser_helper_x.js、parser_helper_x.wasm商业 OFD SDK 产物的静态解析、黑盒变异实验与线上生产页面快照实测,完整梳理该套前端 OFD 解析 SDK 的文件构成、依赖关系、WASM 内嵌机制、公开 API、阅读器两档能力差异、授权校验逻辑、DOM 渲染输出特征、打印模块实现细节,同时还原线上业务页面完整调用链路,整理各类接入踩点与边界缺陷。该 SDK 解析核心完全驻留于 WASM 内部,JS 层仅承担渲染与调度,无法剥离 WASM 得到无授权的纯 JS 解析分支;文中所有结论均来自产物逆向实测,非官方文档转述,可用于开发接入排错、问题定位与技术评估。📌 免责提示:本文仅做技术学习与兼容性研究,未涉及破解、篡改授权等行为;软件知识产权归属原厂商,业务使用请获取官方合法授权,所有实操风险由使用者自行承担。
结论
| 文件 | 是不是"解析 OFD"的 | 运行期是否必需 | 实际角色 |
|---|---|---|---|
parser_x.js |
是 | 必需 | 完整版 OFD 解析 / 渲染 / 阅读器 SDK(UMD,全局名 window.parser_x)。wasm 二进制已以 data URI 内嵌在它体内 |
parser_helper_x.js |
不是 | 必需 | Emscripten 胶水。parser_x.js 的 UMD 头把它声明为外部依赖,缺失时解析第一步就抛 TypeError |
parser_helper_x.wasm |
不是 | 非必需 | 从 parser_x.js 内嵌 data URI 里抠出来的副本,字节完全一致;保留它只为独立验证与离线查看 |
它是纯前端库 ,三种输出形态可选,不需要后端:
- ✅ 直接产出可插入页面的 DOM(SVG / Canvas),用法与本仓库 ofd.js-free 基本一致
- ✅ 也产出结构化对象模型,可自行二次渲染
- ✅ 自带可选阅读器组件,但有两档 :简版
OfdBaseViewer(纯滚动,官方页面用的就是它 ,无工具栏 / 无打印按钮)
与完整版OfdViewer(PDF.js viewer 的裁剪版 ,工具栏 / 侧边栏 DOM 要调用方自备 ,有缩略图 · 目录 · 附件 · 签章面板,
但无查找、无演示模式、无书签 )。打印支持 ,分两条独立实现(OFDPrintApi/printOFD),打印产物不带水印 - ❌ 库自身不含任何业务网络请求
注意区分「库」与「用它的页面」:官方主页面另有一个 XHR ,在页面加载时向
../../ScriptsMain/ofdkey.json取{secret, digest}再喂给库 ------ 详见后文授权分析(含真实渲染快照的实测)。
关于授权(后文详述)------ 四条最常被问到的结论:
secret/digest都是服务端签发的凭证对 :生产secret39 字符 = 16 字符标识段 + 23 位数字载荷 ;
库内硬编码的kgNVVbdUZ31C6mps只是无载荷的兜底值 。唯一可观测的结构边界是第 16 个字符 :
改前 16 位任一字符 →10001,改后面 23 位任一字符 →10002(参考后文黑盒变异实验,逐位 39/39 验证)digest是一个不透明凭证串 :32 hex = 16 字节 ,JS 侧只对它做.toLowerCase()(wasm 自身大小写敏感 ,
大写直喂 →10002);12,272 组 派生穷举全部不命中(附录 B 命令 13 可一键复现)⇒ 无法由外部构造或推算- 校验发生在读取文件内容之前 ,且全程离线 :传随机字节返回的同样是"授权信息错误"(
10001),
而不是"非ofd文件无法打开"(10003);整条链路无任何网络请求。两个错误码的分工是
「认不认识这枚标识 」(10001) vs「标识认得、但凭据对不上 」(10002) ------10002不是可靠的"过期"信号 - 它按天换发 (两组实测样本的
secret/digest都不同);23 位数字载荷的首 13 位算术上 解作毫秒时刻时
恰好相隔 25 天整 (两组都落在北京时间 00:00:03.1xx)。⚠️ 但这只是编码层的算术观察 :
该段是否真被当作时刻参与判定,在返回值上完全不可观测 (改早/改晚/置零/置满 → 全部10002),
且"它是有效期锚点"已被实验否证
解析核心在 wasm 里 (不是 JS):JS 侧零 ZIP 解包、零 XML 解析,文档树本身也以指针 ofdParserPtr 的形式住在 wasm 内存中
最小可用集 = 2 个文件 :parser_x.js + parser_helper_x.js(wasm 已内嵌,.wasm 文件运行期不需要);
这两个 js 还能进一步合并成一个 4.5 MB 单文件。
文件构成与角色
parser_x.js ------ 主库
关键词命中(对照 parser_helper_x.js 几乎全为 0):
| 关键词 | 命中数 |
|---|---|
ofd / OFD |
249 / 71 |
parseOfdDocument |
2 |
renderOfd |
2 |
signature / Signature |
66 / 84 |
watermark |
70 |
canvas / svg |
19 / 58 |
UMD 外壳决定了它的全局名与依赖:
1:1:examples/parser_x.js
!function(A,I){"object"==typeof exports&&"object"==typeof module?module.exports=I(require("parser_helper_x.js"),require("axios")):...:A.parser_x=I(A.parser_helper_x,A.axios)}(window,(function(A,I){...
即:window.parser_x = factory(window.parser_helper_x, window.axios)。第二个参数(axios)在包内零调用。
wasm 二进制内嵌在 parser_x.js 里
这是它 4.6 MB 体积的主要来源 ------ 它是一个自带二进制的单文件:
| 项 | 值 |
|---|---|
| data URI 出现位置 | 字符偏移 ~213,046(文件尾部附近) |
| base64 长度 | 4,413,824 |
| 解码后字节数 | 3,310,368 |
| 解码后 md5 | 57f5d3522a4e70e6ef129c6842e780e5 |
与 examples/parser_helper_x.wasm 比对 |
byte-identical(完全一致) |
内嵌与引用的两处代码:
1:1:examples/parser_x.js
g.d(I,"wsdsb",(function(){return B}));var B="data:application/wasm;base64,AGFzbQEAAAAB...
1:1:examples/parser_x.js
iA=function(A){...t()({locateFile:function(){return d.wsdsb}}).then((function(I){var g=I.Module;...
d 就是上面导出 wsdsb 的内部模块,t 是外部 parser_helper_x(胶水工厂)。
由此得出两条硬结论:
examples/parser_helper_x.wasm(3.3 MB)在运行期不会被请求 ------ 它的字节是从parser_x.js里抠出来的,保留只为独立校验- 但
parser_helper_x.js(80 KB 胶水)必须存在 ------ 它才是真正的运行时依赖
parser_helper_x.js ------ WASM 胶水
无任何 OFD 关键词,但满是 Emscripten 特征:WebAssembly、instantiateWasm、dynCall_、_emval_take_value、/dev/shm、/proc/self/fd、EM_FS_、FT_Open_Face。
它导出的全局名同样是 parser_helper_x:
1:1:examples/parser_helper_x.js
...var t=e&&e.process,o="parser_helper_x";...:o&&(e[o]=r(n,t,e))}(function(e){...
它要加载的 wasm 文件名是硬编码的:
1:1:examples/parser_helper_x.js
me(ce="parser_helper_x.wasm")||(fe=ce,ce=n.locateFile?n.locateFile(fe,g):g+fe);
n.locateFile未提供时,走scriptDirectory + "parser_helper_x.wasm"
但注意上面这条只在单独使用胶水时成立 :由 parser_x.js 驱动时,iA() 会显式传入 locateFile: () => d.wsdsb,指向内嵌的 data URI,这个默认路径永远不会被用到。
| 用法 | 加载的二进制 |
|---|---|
只用 parser_helper_x.js |
scriptDirectory + "parser_helper_x.wasm"(或 locateFile 的返回值) |
用 parser_x.js(实际场景) |
内嵌 data URI,不需要任何 .wasm 文件 |
parser_helper_x.wasm ------ 从主库内嵌块中抠出的副本
| 来源 | 大小 | WebAssembly.validate |
|---|---|---|
浏览器地址栏下载的 .wasm |
383,978 B | ❌ false(被截断,已删除) |
parser_x.js 内嵌的 data:application/wasm;base64 |
3,310,368 B | ✅ true |
恢复过程(从内嵌 data URI 解码 → 按 section 结构裁到真实长度 → 校验):
sh
section 1 type size= ...
section 2 import size= ... (共 73 个 import,全部由 parser_helper_x.js 提供)
...
section 10 code size= 2486471
section 11 data size= 813008
➜ validate: true
当前目录里的 parser_helper_x.wasm 就是这份 3,310,368 字节的完整文件。
为什么地址栏下载会被截断:页面不是请求 HTTP 资源,而是把 wasm 以 base64 内联成 data URI 喂给
WebAssembly.instantiate,超长 data URI 在地址栏里会被截断(但那个 data URI 正是parser_x.js内嵌的那一份)。
验证它确实是 OFD 解析库
内部解析调用链
parseOfdDocument 的内部实现(字符偏移 ~4600 起的模块级函数):
1:1:examples/parser_x.js
v=function(A){iA((function(){if(X.ofdCore&&(Object(a.freeOFD)(X.ofdCore),X.ofdCore=null,X.ofdRender=null),Object(a.CreateLibrary)(),A.ofd instanceof File){var I=new FileReader;I.readAsArrayBuffer(A.ofd),I.onload=function(){var g=Object(a.readOFD)(I.result,A.secret,A.digest);P(g,A)}}else if(A.ofd instanceof ArrayBuffer){var g=Object(a.readOFD)(A.ofd,A.secret,A.digest);P(g,A)}else W()({url:A.ofd,method:"GET",responseType:"arraybuffer",headers:A.headers}).then(...
P 是解析结果的落地函数:
1:1:examples/parser_x.js
P=function(A,I){0===A.code?(X.ofdCore=new s.Ofd_Core(A.data.ofd,A.data.ofdParserPtr,A.data.leftTime),I.waterText&&(X.waterText=I.waterText),I.digest||(X.waterText="demotest"),I.signatureClickCallback&&(X.eventBus=new F.EventBus,X.eventBus._on("signatureclick",I.signatureClickCallback)),I.signaturesCallback&&I.signaturesCallback(X.ofdCore.getAllSignature(0)),I.success&&I.success(X.ofdCore)):I.fail&&I.fail(A.msg)}
三个要点:
readOFD(arrayBuffer, secret, digest)是真正的解析入口(由 wasm 承担)- 解析结果被包装成
Ofd_Core实例 ,既存进全局X.ofdCore,也作为success(core)的参数回传 - 支持
File/ArrayBuffer/ URL 三种输入
数据模型访问
Ofd_Core 上的读取方法(字符偏移 ~139343):
1:1:examples/parser_x.js
{key:"readOfdDocument",value:function(A){return this.ofd?(A?A>this.ofd.Document.length&&(A=this.ofd.Document.length-1):A=0,this.ofd.Document[A]):null}},{key:"readOfdPage",value:function(A,I){if(!this.ofd)return null;...var g=this.ofd.Document[A],B=g.Pages[I];if(B.Page.onlyReadArea){var Q=Object(D.JSReadPageInfo)(this.ofdParserPtr,B.BasePln,B.BaseAbs,B.pln,B.abs);g.Pages[I].Page=Q.Page}return g.Pages[I]}},{key:"readOfdTemplatePage",value:...
文档树形如 ofd.Document[docIndex].Pages[pageIndex].Page ------ 页、图形、文字、签章都在里面,可自行遍历。
解析核心在 wasm,JS 侧只负责渲染与取数
对 parser_x.js 单文件做关键词扫描(结果全部为 0 命中):
| 能力 | 关键词 | parser_x.js |
免费版 src/utils/ofd |
|---|---|---|---|
| ZIP 解包 | PK\x03\x04、inflate、unzip、JSZip、pako、fflate |
0 | 齐全(unzipOfd / JSZip) |
| XML 解析 | DOMParser、parseFromString、x2js、ofd-xml-parser |
0 | 齐全 |
| OFD 标签字面量 | ofd:Page、ofd:TextObject、ofd:SignatureDateTime |
0 | 有 |
| 直接实例化 wasm | WebAssembly |
0(全交给胶水代劳) | 0(本就不需要) |
最后一行值得留意:
parser_x.js里连WebAssembly都搜不到 ------ 它自己不碰 wasm 运行时 ,只是调用胶水工厂
iA()→window.parser_helper_x({ locateFile }),真正的instantiate全在parser_helper_x.js里。这也是为什么缺了胶水就必然抛
TypeError。
更关键的一点:文档树本身也住在 wasm 内存里。
1:1:examples/parser_x.js
P=function(A,I){0===A.code?(X.ofdCore=new s.Ofd_Core(A.data.ofd,A.data.ofdParserPtr,A.data.leftTime),...
Ofd_Core 构造的第二个参数就是 ofdParserPtr(wasm 侧指针);读页面时还得回调 wasm 现取:
1:1:examples/parser_x.js
{key:"readOfdPage",value:function(A,I){...var Q=Object(D.JSReadPageInfo)(this.ofdParserPtr,B.BasePln,B.BaseAbs,B.pln,B.abs);...
再加上生命周期函数 CreateLibrary() / freeOFD(core),链条很清楚:
sh
JS: 把文件字节喂进去 ────────► wasm: 解压 + XML 解析 + 建树 → 返回 ofdParserPtr
JS: 画成 SVG / Canvas ◄────── wasm: 按需返回页 / 图元数据(JSReadPageInfo)
JS 只做两件事 :喂字节、画图形。这条事实直接决定了 ------ 剥离 wasm 后没有任何可回退的 JS 解析器。
是纯前端库
| 检查项 | 命中 |
|---|---|
axios.get / axios.post / axios.create |
0 / 0 / 0 |
fetch( |
0 |
FormData / WebSocket |
0 / 0 |
new XMLHttpRequest |
1 |
那唯一的 XHR 是 PDF.js 的本地化加载器,与业务后端无关:
1:1:examples/parser_x.js
document.webL10n=function(A,I,g){...var B=new XMLHttpRequest;B.open("GET",A,true),B.overrideMimeType&&B.overrideMimeType("text/plain; charset=utf-8")...
关于 UMD 头声明的 axios:包内 URL 加载走的是内部打包模块(W=g.n(e) 形式),并没有直接调用 axios.* 方法。
结论:用
File或ArrayBuffer输入时,完全不引入 axios 也能跑;只有传 URL 字符串时才需要它。
输出形态:三种,都不强制用它的阅读器
结构化数据
parseOfdDocument({ ofd, success }) 的 success 直接给 Ofd_Core 实例,可自行渲染:
js
window.parser_x.parseOfdDocument({
ofd: file,
success: (core) => {
const pageCount = core.getOFDPageCount(0);
const doc = core.readOfdDocument(0);
console.log(pageCount, doc.Pages.length);
},
fail: (err) => console.error(err),
});
可直接插入页面的 DOM 节点
renderOfd 产出的是 SVG / Canvas 的 DOM 元素(createElementNS("http://www.w3.org/2000/svg","svg") 命中 6 处、createElement("canvas") 命中 17 处),appendChild 即可显示;想要字符串就取 outerHTML。
阅读器(可选便利封装,且分两档)
OfdBaseViewer / OfdViewer / OfdThumbnailViewer / OfdSignatureViewer / OfdAttachmentViewer / OFDPrintApi 等。
⚠️ 别被名字误导:只有 "Base" 那一档是"给个容器就能用" ;完整阅读器的工具栏 / 侧边栏 DOM 要调用方自己提供 。
两档能力差别很大,逐项清单见下面。
阅读器能力清单(源码级)------ 两档差别很大,官方页面用的是简版
一句话结论 :SDK 里有两套阅读器。官方
ofdreading页面只用了openOFDBaseViewer(简版) ,它没有工具栏、没有缩略图、没有签章面板,也没有打印入口 (
ofdreading分支里连打印函数都没有)。功能齐全的是
openOFDViewer/initOFDViewer(完整版) ,但它是 PDF.js viewer 的裁剪魔改版 ,要求调用方把整套 UI DOM 喂进来 。
另注:官方站点上唯一 用到"打印"的是另一个功能
ofd2pdf,与ofdreading无关。打印:支持 ,且有两条互不依赖 的实现(
OFDPrintApi与printOFD),详见下文。
简版 OfdBaseViewer(openOFDBaseViewer / openOFDBaseViewerByBase64 / openMetaBaseViewer)
| 能力 | 有无 | 证据 |
|---|---|---|
| 连续滚动渲染 / 页宽自适应 | ✅ | watchScroll(container, this._scrollUpdate);页宽 = container.offsetWidth - 20 |
| 水印 | ✅ | waterText → coreHelper.setWaterText(...) |
| 点击签章回调 | ✅ | signatureClickCallback |
| 解析成功 / 失败回调 | ✅ | parserOFDSuccess / parserOFDFail |
| 工具栏 / 页码框 / 缩放按钮 | ❌ | Base 版根本不构造 Toolbar |
| 缩略图 / 签章 / 附件 / 目录面板 | ❌ | 这些 Viewer 只在完整版的 openOFDViewer 路径里创建 |
| 打印按钮 | ❌ | Base 版不构造 Toolbar;官方 ofdreading 分支里连打印函数都没有 ------ 官方站点上"打印"只出现在另一个功能 ofd2pdf 里 |
完整版 OfdViewer(openOFDViewer = initOFDViewer)------ PDF.js viewer 裁剪版
判据:同一份代码里成串出现 PDF.js 的模块名 ------ GenericL10n / EventBus / PDFSidebar / OverlayManager /
PDFDocumentProperties / PDFCursorTools / Toolbar / SecondaryToolbar。
关键前提 :它不生成任何 UI DOM 。Toolbar 的构造函数是把元素一个个从 config 里取出来的:
1:1:examples/parser_x.js
this.toolbar=I.container,...this.buttons=[{element:I.previous,eventName:"previouspage"},{element:I.next,eventName:"nextpage"},{element:I.zoomIn,eventName:"zoomin"},{element:I.zoomOut,eventName:"zoomout"},{element:I.openFile,eventName:"openfile"},{element:I.print,eventName:"print"},{element:I.presentationModeButton,eventName:"presentationmode"},{element:I.download,eventName:"download"},{element:I.viewBookmark,eventName:null}],this.items={numPages:I.numPages,pageNumber:I.pageNumber,scaleSelect:I.scaleSelect,customScaleOption:I.customScaleOption,previous:I.previous,next:I.next,zoomIn:I.zoomIn,zoomOut:I.zoomOut}
⇒ config 里得把 #previous / #next / #zoomIn / #pageNumber / #scaleSelect ... 这些元素自己在 HTML 里建好 ,
SDK 只负责绑事件、改状态(照 PDF.js 的 viewer.html 抄一份最省事)。所以"支持哪些功能"取决于调用方给的 DOM ;
SDK 侧能确定的,是下面这些它真的会去读写 / 主动隐藏的项:
| 功能 | 有无 | 证据(config 属性 → 事件 / 处理函数) |
|---|---|---|
| 上一页 / 下一页 | ✅ | I.previous→previouspage、I.next→nextpage;previousPage() / nextPage() |
| 首页 / 末页 | ✅ | firstPage() / lastPage();"firstPage" / "lastPage" |
| 跳页(页码输入框) | ✅ | I.pageNumber → pagenumberchanged → jumpPage;setPageNumber() |
| 放大 / 缩小 / 比例选择 | ✅ | I.zoomIn→zoomin、I.zoomOut→zoomout、I.scaleSelect→scalechanged;increaseScale / decreaseScale / zoom(v) |
| 旋转 ±90° | ✅ | setRotation(90) / setRotation(-90);"pageRotateCw" |
| 滚动模式(竖 / 横 / 整页 / 连续) | ✅ | "scrollVertical" / "scrollHorizontal" / "scrollWrapped";scrollmodechanged → setScrollMode(mode) |
| 单页 / 双页 | ✅ | "spreadNone";spreadmodechanged → setSpreadMode(mode) |
| 打开本地文件 | ✅ | I.openFile→openfile;自动插入 #fileInput(accept=".ofd") |
| 下载当前 OFD | ✅ | I.download→download |
| 打印 | ✅(有前提) | I.print→print → printOFD(),前提见后文详述 |
| 缩略图面板 | ✅ | config.sidebar.thumbnailView → OfdThumbnailViewer;翻页时 scrollThumbnailIntoView() |
| 目录 / 自定义标签面板 | ✅ | config.sidebar.outlineView → OfdCustomTagViewer;customtagclick → jumpPage |
| 附件面板(点击下载) | ✅ | config.sidebar.attachmentsView → OfdAttachmentViewer;attachmentclick → Blob + a[download] |
| 签章面板(可强制验签) | ✅ | config.sidebar.signaturesView → OfdSignatureViewer;signatureViewerForceCheck、signaturesCallback |
| 签章属性弹层 | ✅ | config.signatureProperties;signatureclick → findSignature → open() |
| 文档属性弹层 | ✅ | config.documentProperties → PDFDocumentProperties;"documentProperties" |
| 侧边栏开关 / 有签章时自动展开 | ✅ | PDFSidebar;sidebarForceOpen(getSignatureCount(0)>0 才开) |
| 光标工具(选择 / 手型) | ✅ | PDFCursorTools;"cursorSelectTool" / "cursorHandTool" |
| 签章拖拽缩放 | ✅ | "stampResize" |
| 高亮 / 清除高亮 | ✅ | removeHighLight() |
| 查找 / 搜索 | ❌ | findbar / findInput / viewFind / FindController 全部 0 命中 |
| 演示模式 | ❌ | config.toolbar.presentationModeButton.hidden=!0、secondaryToolbar.presentationModeButton.hidden=!0 ------ 主动隐藏 |
| 书签 | ❌ | viewBookmark 的 eventName:null,且 viewBookmarkButton.hidden=!0 ------ 占位但无功能 |
| 全屏 | ❌ | fullscreen 0 命中 |
| 界面语言 | 取决于调用方 | new GenericL10n("zh-CN") + l10n.translate(appContainer);SDK 内部只用 4 个 l10n 键(of_pages、page_of_pages、document_properties_linearized_、print_progress_percent),其余文案来自页面 DOM 的 data-l10n-id |
打印:支持,两条互相独立的实现
① 独立打印 API ------ OFDPrintApi(内部类名 Print_api),不依赖阅读器
1:1:examples/parser_x.js
var y=function(){function A(I){...this.coreHelper=new F.Ofd_Core_Helper(96),this.onProgress=I,this.abort=!1,this.ofdRender=new G.Ofd_Render(null,this.coreHelper)}...key:"setOfdCore"...key:"startPrint"...key:"prepare"...key:"closePrint"...key:"renderProgress"...
1:1:examples/parser_x.js
var p=Y.Print_api // 导出名:OFDPrintApi
用法:const p = new parser_x.OFDPrintApi(onProgress); p.setOfdCore(core); p.startPrint();(closePrint() 可中途取消)。
打印流程(逐步):
- 从第 0 页循环到最后一页,每页单独渲染 :
dpi = 785 / core.pageWidth(0, page) * 25.4,canvas 与外包div都固定width:794px; height:1123px; position:relative
→ 即一张 A4 纸(A4 @96dpi = 794×1123 px); - 每渲染完一页回调一次
renderProgress(n)------ 进度可接; - 全部渲染完 →
window.open("打印窗口","_blank")→ 把每页div塞进新窗口body
→focus()+print()+close()。
② 阅读器内打印 ------ printOFD()
1:1:examples/parser_x.js
RA=function(){X.viewer.ofdPrint&&nA()} // printOFD
1:1:examples/parser_x.js
var C=document.getElementById("printServiceDialog");C&&(this.ofdPrint=new S.Print(C,this.overlayManager,this.l10n));
⇒ 两个前提 :a) 必须先走 openOFDViewer / initOFDViewer 这条完整阅读器路径(要有 X.viewer);
b) 页面里必须存在 id="printServiceDialog" 的节点 ,否则 ofdPrint 根本不会创建,printOFD() 静默什么都不做 。
该路径会先弹一个带进度条 的对话框(l10n 键 print_progress_percent,另有 #printCancel 取消),
渲染逻辑与 ① 相同(A4 794×1123 px、一页一个 div),最后同样 window.open(...) + print()。
③ 官方 ofdreading 页面用的是哪条?都不是 ------ 它压根没有打印功能。
ofdreading 分支只用 openOFDBaseViewer({ ofd, secret, digest, container }),全段没有任何打印调用 。
官方站点上带打印的是另一个功能 ofd2pdf ("OFD 转 PDF"):它用同一个 Base 阅读器 渲染进隐藏小容器
→ 把 #ofd2pdfbox 的 innerHTML 拷进一个 iframe → iframe.contentWindow.print()
(含"渲染尺寸只有 318px 宽、质量取决于浏览器缩放"的工程瑕疵)。
------ 这也解释了为什么"OFD 转 PDF"是假的转换:它只是借浏览器打印对话框让用户"另存为 PDF"。
打印能力的共同边界
- 三条路径最终都是
window.open+window.print()(官方页面那条是iframe.contentWindow.print()),
所以"打印机选择 / 份数 / 页码范围 / 双面 / 页边距 / 另存为 PDF"全部由浏览器打印对话框决定 ;
SDK 侧没有任何自有打印选项 (源码里没有GetPrinter/pageRange/copies之类)。 - 打印不沿用 阅读器当时的翻页与缩放状态,而是按 A4 重渲染一遍 ⇒ 打印结果与屏幕上的宽度无关。
(例外 :宿主页自实现的方式 a 打印的是屏幕 DOM ,宽度 =800 × 缩放%,跟随缩放 ,见 附录 D.3.1。) - ⚠️ 打印产物不带水印 (源码级结论,未做浏览器实测):水印是靠
coreHelper.setWaterText(...)画进渲染结果的
(renderOfd里那句B.waterText?C.setWaterText(B.waterText):X.waterText&&C.setWaterText(X.waterText)),
而两个打印模块构造后从不调用setWaterText(Ofd_Core_Helper(96)/(40)里的40只是构造默认值,
prepare()循环内会立刻被setDpi(785/pageWidth*25.4 ≈ 95)覆盖 ⇒ 两条打印路径的实际 dpi 都在 95 左右)
⇒ 屏幕上看到的"试读"水印不应出现在打印 / 另存为 PDF 的结果里 。
(例外 :方式 a 打印的是屏幕 DOM,水印会被一并打印,见 附录 D.3.2。) - 三条路径同源 :同一个 core + 同一个
Ofd_Render+ 几乎相同的 dpi 与 A4 纸面 ⇒ 三者观感一致是必然,不是巧合 ;
逐项对照表与两处隐患见 附录 D。
从源码还原的准确 API 签名
解析
| API | 签名 | 说明 |
|---|---|---|
parseOfdDocument |
({ ofd, secret?, digest?, headers?, waterText?, signatureClickCallback?, signaturesCallback?, success, fail }) |
ofd 可为 File / ArrayBuffer / URL;success(core) 收到 Ofd_Core 实例 |
parseOfdDocumentFromBase64 |
同上 | success(info) 收到含 ofd 字段的对象 |
getOFDPageCount |
(docIndex) |
页数 |
readOFD / readOFDByBase64 |
(data, secret, digest) |
底层解析 |
渲染
1:1:examples/parser_x.js
EA=function(){var A=i()(w.a.mark((function A(I,g){var B,Q,C,E,D,i,o,G,F,N=arguments;return w.a.wrap((function(A){for(;;)switch(A.prev=A.next){case 0:if(B=N.length>2&&void 0!==N[2]?N[2]:{},Q=96,g&&"number"==typeof g&&!isNaN(g)&&(Q=g/X.ofdCore.pageWidth(I,0)*25.4),C=new K.Ofd_Core_Helper(Q),...
解包后:
| API | 签名 | 返回 |
|---|---|---|
renderOfd |
async (docIndex, width, options?) |
Promise<HTMLDivElement[]>(每页一个 div) |
renderOfdByIndex |
async (docIndex, pageIndex, width, options?) |
Promise<HTMLDivElement>(单页) |
width为像素宽度;省略时用默认 96 dpi- 换算:
dpi = width / pageWidth(docIndex, 0) * 25.4 options支持waterText、mixBlendMode、frontPages- 第一个参数是「文档索引」(数字,通常 0),不是旧版的文档对象
阅读器
OfdBaseViewer 的构造签名(字符偏移 4635448 处的类定义):
1:1:examples/parser_x.js
var M=function(){function A(I,g,B,Q){E()(this,A),this.core=null,this.width=g,this.eventBus=B,this.coreHelper=new F.Ofd_Core_Helper(96,Q),this.ofdRender=new G.Ofd_Render(null,this.coreHelper),this.container=I,this.pageDIV=[],this.currentPage=1,this.container&&(this.scroll=Object(N.watchScroll)(this.container,this._scrollUpdate.bind(this)))}
js
// ⚠️ 上面只是「内部被谁调用」的说明 ------ OfdBaseViewer 与 EventBus 都【没有导出】,
// 页面里不能直接 new。正确入口见 openOFDBaseViewer,或自己 parseOfdDocument 后
// 用 renderOfd/renderOfdByIndex 自行拼装。
// 内部实际发生的调用等价于:
// new OfdBaseViewer(container, width, eventBus, waterText).setOfdCore(core)
setOfdCore 会按容器宽度自动算 dpi 并铺好每页的占位 div:
1:1:examples/parser_x.js
key:"setOfdCore",value:function(A){if(this.container){this.freeThumbnailDocument(),this.core=A,this.ofdRender.setOfdCore(this.core);var I=Math.ceil(this.core.pageWidth(0,0)),g=(this.container.offsetWidth-20)/I*25.4;this.width&&(g=this.width/I*25.4),this.coreHelper.setDpi(g);for(var B=this.container,Q=0;Q<this.core.getOFDPageCount(0);++Q){var C=document.createElement("div");C.classList.add("page"),C.id="page_"+Q;...
width传0/省略 → 自适应容器宽度width传具体像素 → 固定页宽
OfdViewer(完整版,带工具栏)签名不同,是 5 个位置参数:
js
// (container, viewer, eventBus, mixBlendMode, defaultScale)
const v = new window.parser_x.OfdViewer(container, viewerDiv, eventBus, 0, 1);
高层封装(最省事)
1:1:examples/parser_x.js
GA=function(A){setTimeout((function(){if(A.loadingContainer){if(A.container)for(;A.container.firstChild;)A.container.removeChild(A.container.firstChild);A.container.appendChild(A.loadingContainer)}v({ofd:A.ofd,secret:A.secret,digest:A.digest,headers:A.headers,signatureClickCallback:A.signatureClickCallback,signaturesCallback:A.signaturesCallback,waterText:A.waterText,success:function(I){A.parserOFDSuccess&&A.parserOFDSuccess(I),new n.OfdBaseViewer(A.container,A.width,X.eventBus,X.waterText).setOfdCore(I),A.renderOFDCallback&&A.renderOFDCallback()},fail:function(I){if(A.parserOFDFail&&A.parserOFDFail(I),A.container)for(;A.container.firstChild;)A.container.removeChild(A.container.firstChild);...
即 openOFDBaseViewer(config) 一行搞定「解析 + 阅读器」:
js
window.parser_x.openOFDBaseViewer({
container: document.getElementById('viewer'),
width: 0, // 0 = 自适应容器宽度
ofd: file,
waterText: '', // 可选
// ⚠️ 强烈建议传一个(哪怕是空函数):SDK 内部只在「传了 signatureClickCallback」时
// 才创建 eventBus,而 setOfdCore() 会执行 this.eventBus.dispatch(...),
// 不传会直接在 setOfdCore 处抛 TypeError。
signatureClickCallback: () => {},
parserOFDSuccess: (core) => {},
parserOFDFail: (err) => {},
renderOFDCallback: () => {},
});
initOFDViewer 与 openOFDViewer 是同一个函数 (都指向内部的 MA),它创建的是带工具栏的完整阅读器 OfdViewer,且不自己解析、直接用模块内已解析好的 core:
1:1:examples/parser_x.js
yA=function(){return X.viewer.eventBus},hA=function(A){MA(A)},MA=function(A){...X.viewer=new SA(A)}
| 入口 | 需要先解析吗 | 得到什么 |
|---|---|---|
openOFDBaseViewer / openOFDBaseViewerByBase64 / openMetaBaseViewer |
否(自己解析) | OfdBaseViewer(纯滚动阅读器,无工具栏) |
openOFDViewer = initOFDViewer |
是(用已解析的 core) | OfdViewer(完整阅读器,带工具栏) |
openOFD / openFile / openFileBase64 |
否 | 转调上面两者 |
公开导出清单(29 项,实测)
把所有 webpack 模块 的
g.d()导出都算,共计"146 项"。真正挂到window.parser_x上的只有 29 项 ------ 下表是在浏览器等价沙箱里跑Object.keys(window.parser_x)实测得到的。
| 类别 | 导出 |
|---|---|
| 解析 | parseOfdDocument、parseOfdDocumentFromBase64、getOFDPageCount、pageSize、getTextObjects、getCustomTag、getFileStream、getAllAttachments、getAttachmentById、getAttachmentArrayBuffer、hashOfdDocument、free |
| 渲染 | renderOfd、renderOfdByIndex |
| 阅读器(全部是封装入口) | openOFDBaseViewer、openOFDBaseViewerByBase64、openMetaBaseViewer、openOFDViewer、initOFDViewer、openOFD、openFile、openFileBase64、getEventBus |
| 签章 / 打印 | getToSign、packageSigned、verifySignature、printOFD、OFDPrintApi |
| 其它 | ajaxUtils |
没有导出的(最容易踩的坑):
| 名字 | 状态 | 正确做法 |
|---|---|---|
OfdBaseViewer |
❌ 未导出(包里是内部变量 n.OfdBaseViewer) |
用 openOFDBaseViewer({ container, width, ofd, ... }) |
EventBus |
❌ 未导出(内部 F.EventBus) |
用 getEventBus() |
readOFD / readOFDByBase64 |
❌ 未导出(属内部 wasm 绑定模块) | 用 parseOfdDocument |
Ofd_Core / Ofd_Core_Helper |
❌ 未导出 | 从 parseOfdDocument 的 success(core) 回调拿到实例 |
不过 Ofd_Core 实例上的方法 是可用的(它由 success 回调交到你手上):
js
core.getOFDPageCount(docIndex)
core.readOfdDocument(docIndex)
core.readOfdPage(docIndex, pageIndex)
core.pageWidth(docIndex, pageIndex) // core.pageHeight 同理
core.getAllSignature(docIndex)
授权机制与水印(核心内容)
这里有两层,顺序不能弄反:先校验授权,失败即中止;解析成功后才轮到水印。
授权校验(失败即中止解析)
sh
parseOfdDocument({ ofd, secret, digest })
└─ parser_x.js readOFD() ← 编码层(传参 + 默认值)
└─ B.JSReadOFD(Q, data, len, secret, digest) ← 跨到 wasm
└─ 内嵌在 parser_x.js 内的 wasm JSReadOFD ← 判定层(C/C++)
└─ 返回 { code, msg, data }
└─ parser_x.js switch 映射 ← 文案层
「wasm 内嵌在 parser_x.js 里」;
B是外部parser_helper_x.js创建的 Emscripten Module 实例。该 wasm 已被 strip(section 列表无
name段),49 个导出全是压缩后的 1--2 字母短名 ,
JSReadOFD只是parser_x.js内部给其中一个短名起的别名 ------ 所以按名字在二进制里搜JSReadOFD是搜不到的。
编码层(parser_x.js 字符偏移 ~134,171):
1:1:examples/parser_x.js
c=function(A,I,g){E=A,C=R(A);var D=B.JSReadOFD(Q,C.data,C.length,I||"kgNVVbdUZ31C6mps",g?g.toLowerCase():"")}
文案层(同一个函数,紧跟其后):
1:1:examples/parser_x.js
if(D.data&&D.data.ofd&&(D.data.ofd.size=C.length),0!==D.code)switch(D.msg){case"10001":D.msg="授权信息错误";break;case"10002":D.msg="授权时间过期";break;case"10003":D.msg="非ofd文件无法打开"}return D
参数语义:
| 参数 | 默认值 | 默认值所在位置 | 说明 |
|---|---|---|---|
secret |
"kgNVVbdUZ31C6mps" |
硬编码在 parser_x.js(wasm 字节里搜不到这个串) |
调用方不传时使用。⚠️ 但生产环境的 secret 由服务端另发(39 字符),这个默认值只是兜底 |
digest |
"" |
调用方提供 | 先用 .toLowerCase() 处理,说明是大小写无关的字符串 |
错误码表(code === 0 表示成功):
code |
wasm 返回的 msg |
parser_x.js 映射出的文案 |
|---|---|---|
| 0 | --- | 成功,继续走 success(core) |
| ≠0 | "10001" |
授权信息错误 |
| ≠0 | "10002" |
授权时间过期 |
| ≠0 | "10003" |
非 ofd 文件无法打开 |
定位时的一个坑 :在 wasm 二进制里按字面量搜
10001/10002/10003,能搜到各 1 处,但上下文是 OpenSSL 的常量表(block type is not 01、block type is not 02、RSA_padding_add_SSLv23),与 OFD 授权无关。真正的错误码是 wasm 运行时构造并回传的,因此搜不到定义处。也因此:改
parser_x.js里的switch只能改提示文案,判定发生在 wasm 内,JS 侧无法绕过。
实测确认 :digest 留空时,wasm 直接返回 code≠0 / msg="10001",解析在第一步就被拦下,根本走不到水印那一步 。即:没有有效 digest,这个版本无法解析任何 OFD 文件 (这也正是两个测试页需要填 digest 的原因)。完整的黑盒实测见后文。
水印(仅在解析成功之后)
解析成功后才会执行到 P 函数里这一行:
1:1:examples/parser_x.js
I.waterText&&(X.waterText=I.waterText),I.digest||(X.waterText="demotest")
| 传入 | 效果 |
|---|---|
不传 digest |
按字面意思,水印会被强制设为 "demotest" |
传入有效 digest |
使用你指定的 waterText |
但要更正一个推论 :这段代码在本版本里实际不可达 。
P只在code === 0时执行,而code === 0的前提就是digest有效。也就是说执行到这里时
I.digest必然为真 ,I.digest || (X.waterText = "demotest")的右半边永远不会生效。推测这是完整版/试用版共用的代码,只是本构建里把授权卡在了前面 ------ 所以你不会在页面水印上看到
demotest。
同时 readOFD 返回的 leftTime(授权剩余毫秒,同样由 wasm 提供)会存进 Ofd_Core,内部还有一处判断:
1:1:examples/parser_x.js
if(A.leftTime>0&&A.leftTime/1e3/3600/24<90){X.viewer.config.errorWr...
即剩余不足 90 天时会走提示分支。
水印在渲染期的唯一开关(源码实证)
水印不是 由 digest 决定的,而是由一个独立字段 waterText 决定。渲染每页时它出现在 Ofd_Render 里:
1:1:examples/parser_x.js
this.coreHelper.waterText&&!this.coreHelper.waterSetting&&I.appendChild(this.watermark({watermark_txt:this.coreHelper.waterText})),this.coreHelper.waterSetting&&I.appendChild(this.watermark(this.coreHelper.waterSetting))
waterText 的整条传递链(每一环都有默认 / 短路):
1:1:examples/parser_x.js
X={ofdCore:null,viewer:null,eventBus:null,waterText:null,fileName:null,ofdRender:null}
1:1:examples/parser_x.js
X.ofdCore&&(X.eventBus=new F.EventBus,......,new n.OfdBaseViewer(A.container,A.width,X.eventBus,X.waterText).setOfdCore(X.ofdCore))
1:1:examples/parser_x.js
function A(I,g,B,Q){......,this.coreHelper=new F.Ofd_Core_Helper(96,Q),......}
结论:不传 waterText 就完全没有水印节点 (null && ... 直接短路,appendChild 根本不执行)。
模块级默认值是 null,没有任何兜底文案。
真实页面的渲染结果(有真实 OFD 快照可查)
parser-x-主页面-加载OFD后-*.html 里保存了一次真实渲染的完整 DOM (用合法 digest 渲染成功,见后文)。
在渲染区(#ofdcontentbox 内部,119 KB / 97 个 <svg> / 69 个 <text>)全文搜索水印关键词:
| 关键词 | 命中 |
|---|---|
试读 / 试用 / 仅供 / 样例 / 未授权 / 水印 / demotest |
全部 0 |
渲染出的 69 个 <text> 文本 |
全部是业务原文(发票字段名与值),无一处重复斜排文字 |
页面 HTML 里确实出现 1 处
watermark,但那是导航菜单里指向/watermark/(PDF添加水印)的链接,
style.css里也没有阅读器水印样式,与阅读器无关。
所以三件事同时被证实 :
① demotest 是死代码;② 生产页面不传 waterText ;③ 因此真实渲染产物一滴水印都没有。
digest / secret 的注入点链条(实证)
整条链上只有一个注入点 :initOFDViewer 末尾的 X.viewer = new SA(A),其中 A 就是调用方传进来的 config 对象。
1:1:examples/parser_x.js
...X.viewer=new SA(A)}
完整链条:
调用方 config
├─ openOFDBaseViewer({ container, ofd, secret, digest, waterText })
└─ initOFDViewer({ container, secret, digest, ... })
└─ X.viewer = new SA(config) ← 唯一注入点
└─ X.viewer.config.digest / .secret
└─ parseOfdDocument({ ofd, secret, digest, ... })
└─ readOFD() → B.JSReadOFD(..., secret, digest) → wasm
要点:
parser_x.js内 15 处secret全部形如A.secret或X.viewer.config.secret,没有任何兜底或派生逻辑,一律"调用方给什么就用什么"- 由此可推出
digest的来源只有两种可能:① 页面 JS 里硬编码;② 由某个接口下发后写进 config。二者都会以明文出现在浏览器中(因为解析全程在本地 wasm 内完成,没有服务端参与) - 该注入点也是调试时的最佳断点位置
wasm 侧的佐证 (parser_helper_x.wasm 字节级统计):
| 项 | 命中 | 说明 |
|---|---|---|
JSON 键名(以 \0 结尾的独立串) |
secret 1、msg 1、leftTime 1、ofd 2、code 14、data 55 |
即返回结构 {code, msg, data, leftTime} 的键名池 |
| 国密 | SM2 5 + sm2 21、SM3 3 + sm3 3、SM4 8 |
国密算法栈 |
| 国际 | RSA_ 35、Base64 9 + base64 5 |
OpenSSL 1.x |
| 签名 | sign 155、Sign 59、verify 41、Verify 5 |
签名 / 验签 |
| 噪音(勿误判) | expire 1 → 实为 status expired;machine 1 → 实为 this machine is %s-endian;licen 0 |
与授权无关 |
leftTime 的存放位置(键名常量池,与时间类字段名并列):
sh
...p is not prime\0setAttr-Token-B0Prime\0FaxRecvTime\0id-it-confirmWaitTime\0␣leftTime␣\0SignTime\0cms_add1_signingTime\0ofd:SignatureDateTime\0...
另外,该 wasm 已被 strip (section id 列表为 1,2,3,4,5,6,7,9,10,11,无 name 段),所有函数名不可读。
两条明显的结论:
- wasm 内搜不到
license/Authorization/X-License任何字样 → 这不是标准 license 协议,而是自定义实现- wasm 里那套 SM2/SM3/SM4/RSA 是同一份库里给 OFD 签章验签用的 ,静态字符串无法区分 哪部分服务于授权。因此"授权校验使用了非对称签名"只能列为推测,不能当结论
可信度标注(区分实测与推断):
| 结论 | 依据 |
|---|---|
leftTime 由 wasm 计算回传 |
✅ 实测(键名池 + P 中读取该字段);⚠️ 实测值恒为 0 ,"授权带有效期"只是推断,未发现证据 |
secret 有内置默认值、digest 需外部提供 |
✅ 实测(源码字面量) |
digest 是"按客户签发"而非"每次请求随机" |
⚠️ 推断(无随机数参与的证据) |
| 授权校验使用非对称签名 | ⚠️ 推测(wasm 内有完整密码学栈,但归属无法静态区分) |
关于"能否绕过"的技术实情 :wasm 并非防篡改黑盒,可被反编译、修改甚至重编译。就本例而言,改 parser_x.js 里的 case"10001" 只能改提示文案 ,改 JS 里传下去的 digest 变量也只作用于 JS 层 ------ 判定发生在 wasm 内,因此真正的改造点也在 wasm 内。
黑盒实测(从 JS 层的观察)
方法:把
parser_helper_x.js+parser_x.js载入浏览器等价沙箱(vm+ DOM stub + 真实fetch),直接调
parseOfdDocument并记录回调。不对 wasm 内部做任何分析,只看输入输出。探针脚本用后即删。
实验设计:固定一份真实 OFD(2.ofd,4.2 MB)与一份乱码字节,矩阵式改变 digest / secret。
| 文件输入 | digest |
结果 |
|---|---|---|
| 真实 OFD | 不传 | ✗ 授权信息错误 |
| 真实 OFD | "" |
✗ 授权信息错误 |
| 真实 OFD | "a" |
✗ 授权信息错误 |
| 真实 OFD | "0" × 32 |
✗ 授权信息错误 |
| 真实 OFD | 随机 32 位 hex | ✗ 授权信息错误 |
| 真实 OFD | 随机 64 位 hex | ✗ 授权信息错误 |
| 真实 OFD | 随机 base64(64 B) | ✗ 授权信息错误 |
| 乱码字节 | 不传 | ✗ 授权信息错误 |
| 乱码字节 | "0" × 32 |
✗ 授权信息错误 |
| 真实 OFD | "0"×32 + secret="abc" |
✗ 授权信息错误 |
| 真实 OFD | "0"×32 + secret=内置默认值 |
✗ 授权信息错误 |
| 同一输入重复第 2、3 次 | 同上 | 与第 1 次完全一致 |
从这张表能直接读出四件事:
(1) 输入/输出关系:digest 是一个不透明凭证串
- JS 侧对它只做
.toLowerCase()------ 不校验长度、不校验格式、不做任何解码 - 输出只有三态:
code=0(放行)/10001(前 16 字符 不被识别)/10002(标识认得但凭据对不上;
6.7 已证它并非"过期"专用码) - 任何我们能构造的串都落在
10001,没有出现"格式对了但内容错了"的中间态
→ 它不是格式校验、不是校验和、不是任何可以离线自算的东西
(2) 校验发生在「碰文件之前」------ 本组最硬的一条证据
乱码字节 + 无 digest 返回的是 10001(授权信息错误),而不是 10003(非 ofd 文件无法打开)。
→ 授权不通过时,文件内容根本没有被读取过 。
→ 因此 digest 不是"针对某个文件"的签名,而是"针对这套授权"的凭证;授权与文件是正交的两件事。
(3) 校验全程离线(但凭证获取在线)
这一段说的是库自身 :parseOfdDocument 整条链路只有一次网络动作,
而且是 fetch data:application/wasm;base64,...(内嵌 wasm 自己)。
没有许可证服务器、心跳或基于网络的在线校验 ------ 把一份合法凭证喂进去后断网执行结果完全相同。
⚠️ 但"离线"只属于校验环节,不属于凭证获取 。实测主页面:
secret/digest是页面加载时用同步 XHR 从一个同源地址取回来的 (
../../ScriptsMain/ofdkey.json)。也就是说 ------ 存在一条服务端分发通道,服务端可以通过更换该响应来轮换或收回凭证。
(4) 确定性、无状态
同一输入重复调用结果完全一致(只差首次 wasm 实例化的耗时);没有 nonce、没有把当前时间戳掺进输入。
→ "每次请求都变"这个假设可以排除。
现有观察不足以判定对称还是非对称
| 从外部能观察到 | 从外部观察不到 |
|---|---|
| 凭证是字符串、大小写无关、长度看似自由 | 具体密码学原语 |
| 校验离线、确定、先于文件访问 | 是 HMAC/对称解密/公钥验签中的哪一种 |
存在独立的 10002 错误分支 |
它究竟对应"过期"还是"任意第二阶段失败" |
唯一站得住的结构性推断 :既然存在独立于 10001 的 10002,凭证就必然携带结构化载荷
并被完整性保护 ------ 也就是说,它更像一枚带签名的许可证令牌(license token) ,
而不是裸哈希或单纯密文。
补记(变异实验后更正) :这段载荷"至少含有效期信息 "曾是上句的推断依据之一,现已被实验否证 :
时间锚已过去 21 天的样本 ① 仍被放行、两组
leftTime都是0、改早/改晚/置零/置满返回完全一致(全部10002)。
10002稳定出现于任何 "前 16 字符正确、其余部分对不上"的输入 ------ 它是一个第二阶段兜底失败码 ,不能反推"凭证里有有效期"。载荷到底装什么、两个阶段各校验什么,均不可观测。
至于"到底是 RSA 还是 SM2",在不分析 wasm 的前提下无法回答:wasm 里两套密码学栈都在
,静态字符串区分不了归属。
由此可确定 :digest 无法从外部反推 ------ 它必然由持有那枚密钥(wasm 内置)的一方生成。
补记(抓包后收窄) :
digest实测只有 16 字节 (32 hex),而 SM2 / ECDSA-P256 签名是 64 B 、RSA-2048 签名是 256 B ------ 因此 "digest 直接就是非对称签名"可以排除 。
候选从"任意密码学原语"收窄到 16 字节档:HMAC-MD5 / AES-128 单块 / 截断哈希 / 128 位标识 。
⚠️ 注意区分:这排除的是"
digest本身是签名 ";wasm 里那套 SM2/RSA 仍可能 用在更上一层(例如用它去校验/封装
digest)。
关于"动态性"的逐问逐答
| 问题 | 答案 | 依据 |
|---|---|---|
| 是每次请求都变吗? | 不是,同一凭证结果恒定 | 实测(重复调用) |
| 是"一直有效、与服务器无关"吗? | 校验与服务器无关,但凭证是从服务器取的 | 实测(无网络)+ 主页面 XHR |
| 是隐含了有效期吗? | 输出层有"过期"这条通路 (10002 的原文案),但触发条件不可观测 |
源码 switch |
| 有效期写在哪? | 无法判定 。二进制里无日期格式串(9,966 条可打印串 0 命中);凭证里的 13 位数字算术上 是毫秒时刻,但改动它不改变返回值 | 实测 |
| 客户端怎么知道还剩多久? | wasm 回传 data.leftTime(毫秒)→ JS 存进 Ofd_Core,并在 < 90 天 时走提示分支;但两组样本实测都是 0 |
源码 |
| 服务端能收回凭证吗? | 能 ------ 改 ofdkey.json 的响应内容即可,客户端下次加载就会拿到新凭证 |
实测的取用方式 |
模型总结 :服务端分发的静态凭证 + 离线两阶段校验 + 不可判定的时限机制 。
一次签发后在校验侧没有任何动态因素;但分发侧是联网的,因此轮换/收回由分发通道完成 ,
而不是靠每次请求握手。
⚠️ 变异实验把最初的"两个失败码 = 不合法 / 已过期"更正为:
10001= 前 16 字符不被识别 ,
10002= 识别为已签发标识、但第二阶段校验未过 。10002是否同时 承担"过期",实验无法区分。
结论与复现路径
secret/digest是授权对 ,且两者都由服务端签发 :生产secret是 39 字符
(16 字符固定段 + 23 位数字载荷),与库内硬编码的 16 字符默认值同构但不同值 ;parser_x.js里那个是兜底值- "缺的那个合法参数从哪来"已有确定答案 :官方页面自己就是从
../../ScriptsMain/ofdkey.json取的
({secret, digest},带三个标识请求头),完整还原、请求/响应与两组样本的结构解剖见后文。 - 复现一次调用需要两样东西 :① 那个 GET 及其 3 个常量头;② 服务端当次返回的
{secret, digest}。
(原先所列的第 ③ 项"与签发时刻一致的系统时间"已被否证:时间锚变化不影响任何返回值)
抓包实证:请求与响应全貌(ofdkey-请求和返回.txt)
素材:一份完整的
curl(含全部请求头)+两组{secret, digest}响应样本------ 分别取自 2026-09-10 与随后的"密钥已更新"抓取。本节按原值记录,便于后续复现比对。
请求侧:零客户端动态内容
| 检查项 | 结果 |
|---|---|
| 方法 / 查询串 / 请求体 | GET / 无 / 无 |
| nonce / 时间戳 / 签名头 | 全部没有 |
Authorization / CSRF token |
没有 |
| 自定义头 | 仅 3 个,且全部是 appconfig.softdata 里的常量 |
| Cookie | Hm_*(百度统计)、_ga*(Google Analytics)、xunjieUserTag ------ 统计/标签类,不是会话凭证 |
| 来源相关 | Referer: https://app.xunjiepdf.com/ofdreading、Sec-Fetch-Site: same-origin、X-Requested-With: XMLHttpRequest |
结论 :客户端侧没有任何防重放机制 ------ 这是一个完全静态、可原样无限重放 的 GET。
"有没有校验"的答案是:校验全在服务端 ,且只依据那 3 个常量头(外加可能的 Referer / 来源策略)。
这也印证了:
frontfun.js拿到响应后一个字节都没加工 ,直接
secret = data.secret; digest = data.digest;透传给库。
⚠️ 但"请求可重放"不等于"一直能拿到有效结果" ------ 凭证自带时限且可被轮换。
响应结构:{ secret, digest },两者都是服务端签发的
两组样本的原值(长度完全一致,各 39 / 32 字符):
| # | secret |
digest |
|---|---|---|
| ①(2026-09-10) | kzvESrBKRxtFNpdG17872416031350237363868 |
cfdc80911913b4050cc274586a527c6a |
| ②(密钥更新后) | kzvESrBKRxtFNpdG17894016031033454202090 |
1fdd460887a4deb71aed4a3e145df016 |
二者最长公共前缀为 19 字符 (= kzvESrBKRxtFNpdG178),公共后缀 0 ;
两个 digest 的公共前缀 0。这就是下面全部分析的起点。
secret ------ 39 字符 = 16 字符标识段 + 23 位数字载荷(算术上可解为 13 位毫秒 + 10 位)
| 段 | 样本 ① | 样本 ② | 结论 |
|---|---|---|---|
| 16 字符段 | kzvESrBKRxtFNpdG |
kzvESrBKRxtFNpdG |
★ 两组完全相同 ⇒ 它是固定标识 / 盐 ,不是一次性随机 token |
| 前 13 位数字 | 1787241603135 |
1789401603103 |
按毫秒解 → 北京时间 2026-08-21 00:00:03.135 / 北京时间 2026-09-15 00:00:03.103(⚠️ 仅算术解释,见下) |
| 后 10 位数字 | 0237363868 |
3454202090 |
①以 0 开头(零填充 )⇒ 更像 uint32 随机数(237,363,868 / 3,454,202,090) |
这 23 位数字载荷值得单列,但必须把「算术观察」与「语义结论」分开。
算术层(成立,但只是编码事实) :把 23 位数字段做全窗口扫描 (起点 0--9 位 × 长度 10 / 13 位 × 单位 秒 / 毫秒),
只保留解出来落在 2020--2030 年的窗口,唯一"整"得离谱的解就是「开头 13 位当毫秒」:
- 两个样本都精确落在北京时间 00:00:03.1xx ------ 两个独立样本同时锚到"午夜后 3 秒",不像巧合;
- 两者之差
2,159,999,968 ms = 25 天 − 32 ms,即恰好相差 25 天整。
语义层 :上一版据此断言"前 13 位是有效期锚点 "、
"凭证内自带有效期",并进一步推出"10002 = 时间过期"。这三条都缺少可观测证据,且第一条已被直接否证:
| 检验(变异实验) | 若"13 位 = 有效期锚点"成立,应观察到 | 实测 |
|---|---|---|
| 时间锚已过去 21 天的样本 ① 是否仍放行 | 应被拒 / leftTime 归零 |
★ 仍 PASS(code=0) ,leftTime 仍为 0 |
| 把 13 位改成更早(2020) / 更晚(2030) / 置零 / 置满 | 至少应有两种不同返回值 | 四者完全相同 (全部 10002) |
两组 leftTime 是否不同(相隔 25 天) |
应体现出差异 | 两组都是 0 |
⇒ 修正为:"这 23 位里有一段在算术上等同一个毫秒时刻"是事实;"这段时刻被解析并参与判定"没有任何证据。
wasm 对它的处理与对后 10 位的处理,在返回值上完全不可区分(逐位 23/23 结果一致)。
★ 与库内默认值的对照:
| 值 | 长度 | 结构 | |
|---|---|---|---|
| 库内硬编码 | kgNVVbdUZ31C6mps |
16 | 只有 16 字符段,没有数字载荷 |
| 服务端实发 | kzvESrBKRxtFNpdG... |
39 | 16 字符段 + 23 位数字载荷 |
⇒ 两者同构 (都以 16 字符段开头),差别只在有无数字载荷 。这正好解释了实测现象:
默认 secret 配任何 digest 都得 10001 ------ 它缺载荷,永远配不出有效标签。
digest ------ 32 hex = 16 字节,无 magic header
| 检查 | 样本 ① | 样本 ② |
|---|---|---|
| 字节序列 | cf dc 80 91 19 13 b4 05 0c c2 74 58 6a 52 7c 6a |
1f dd 46 08 87 a4 de b7 1a ed 4a 3e 14 5d f0 16 |
| 不同字节数 | 15 / 16 | 16 / 16 |
| 零字节 | 0 | 0 |
常见 magic 检查(ZIP PK / PNG / JPG / GIF / PDF / BMP / SM2-C1 前导 04) |
全部不命中 | 同 |
| 两组 XOR | d001c6999eb76ab2162f3e667e0f8c7c,置位 66 / 128(51.6%) |
⇒ 没有 header、没有分隔符、没有可打印结构 ,字节分布接近均匀 ------ 与"原始 16 字节摘要 / MAC / 单个 AES 块 "完全一致。
长度本身就是最硬的一条约束(把候选锁定在 128 位档):
| 候选 | 字节数 | 与 digest(16 B) 相容? |
|---|---|---|
| MD5 / HMAC-MD5 / AES-128 单块 / 截断哈希 / 128 位标识 | 16 | ✅ |
| SHA-1 | 20 | ✗ |
| SHA-256 / SM3 | 32 | ✗ |
| ECDSA-P256 / SM2 签名 | 64 | ✗(远大于 16) |
| RSA-2048 签名 | 256 | ✗(远大于 16) |
⇒ digest 本身不可能是非对称签名 ------ 这条收窄了「对称还是非对称」的结论。
派生关系穷举:12,272 个组合,命中 0
两个值既然是同一次响应一起下发的,就可用它们互相当"探针":
sh
探针池:secret 全串 / 16 字符段 / 23 位数字段 / 其前 13 位 / 后 10 位 /
默认 secret / 页面常量(146、4.5.9.3、X-Credits、ofdreading、域名)
构造 : 单段自哈希、两段拼接(8 种分隔符)、HMAC(任一段为 key,任一段为 msg)
算法 : md5 / sha1 / sha256 / sha512
实测 : 12,272 组 ------ 附录 B 命令 13 可一键复现;早期另跑过一轮更大规模的组合
(补齐更多分隔符与子串,累计超过 3 万组),结果相同
结果 : 命中 0
⇒ digest 不是 这些可见字段的 MD5 / SHA-1 / SHA-256 / SHA-512,也不是 以它们为 key 的 HMAC-MD5。
⇒ 生成过程必然引入一份样本里看不到的密钥 (JS 层连它都不碰,只能在 wasm 内),
或者用的是非哈希构造 (对称加密 / 更复杂的 KDF)。
⇒ 两值既然互不派生,就说明它们是并列签发 的:secret 载载荷,digest 载标签。
最经济的用法模型(⚠️ 推测,但能同时解释全部实测)
sh
secret = 16 字符标识段(部署级标识 / 盐) + 23 位数字载荷(算术上 = 13 位毫秒时刻 + 10 位尾数)
digest = 16 字节完整性标签(对 secret 的 MAC,或对某载荷的对称加密单块)
wasm 内置密钥 → 离线「两阶段」校验:
阶段 1:前 16 字符是否为**已签发标识** → 否 = 10001
阶段 2:余下 23 字符 + digest 是否与签发值成对 → 否 = 10002
| 实测事实(出处) | 该模型的解释 |
|---|---|
| 16 字符段两组完全相同,且默认 secret 的前 16 字符不被接受 | 它是部署级标识;wasm 内应有一份"可接受的已签发标识"集合(其形式不可观测) |
| 校验离线 且确定 | wasm 内持有密钥,可本地重算 |
独立的 10002 分支 |
★ 已否证 :它不是"过期"专用,而是第二阶段兜底失败码 |
digest 只有 16 B |
它只是标签,无需装下整个载荷 |
JS 只对 digest 做 .toLowerCase() |
当成不透明 hex 串 |
内置默认 secret(16 字符、无时间戳 )配任何 digest 都得 10001 |
它不带载荷,永远配不出有效标签 |
| 两组载荷解出的时刻都锚在"北京时间午夜 + 3 秒"、相隔 25 天整 | 若该算术解释为真,指向按天批量签发 ;⚠️ 这是编码层观察,行为上不可验证 |
反证 :16 B 只够一个 AES 块 ,装不下 "载荷 + 时间/有效期信息"的完整结构
⇒ "digest 自身携带完整载荷"这一可能性低 。
已排除 :digest 本身是对称或非对称签名 (16 B 装不下 SM2 的 64 B / RSA 的 256 B)。
尚未确定 :具体算法(HMAC-MD5 / AES-128 / 截断 SHA-256 / SM4 / 私有 KDF)、16 字符标识段的来源,
以及两个阶段各自到底校验了什么。
三个关键问题的答案
| 问题 | 答案 |
|---|---|
| 请求参数是否存在校验? | 客户端零校验、零签名;校验全在服务端,只看 3 个常量头(+ 可能的 Referer / 来源策略) |
| 是否存在可变动态内容? | 不存在 ------ 无 nonce / 时间戳 / 签名,发起请求所需的输入全是静态常量 |
| 能否一直用该请求拿到有效结果? | "请求"可原样重放,但"结果"按天变化 :两组样本的 secret / digest 都不同 ,23 位载荷解出的时刻相隔 25 天整 (编码层观察);服务端还能按 X-Product / X-Version / X-Credits 换发 |
可操作性结论 :既然客户端侧零校验、零签名,复现这次调用所需的全部输入 = URL + 那 3 个常量头 。
不确定性只在于服务端当天返回什么 。
保护模型 :分发通道是"裸的",保护落在凭证本身 + 按天换发 上,而不是请求侧签名 ------
两者自洽,一个同步裸 GET 本来也无处承载防重放。
⚠️ 改动凭证里的时刻不改变任何返回值 ,
且时间锚已过去 21 天的样本仍被放行。时限机制可能存在 (
leftTime字段与10002文案都在),但它的触发条件无法从外部观测。
要完整复现,还差什么(技术待办)
| # | 缺口 | 判据 / 下一步 |
|---|---|---|
| 1 | digest 的生成密钥 |
唯一硬缺口。它不在 JS 里,只能在 wasm 内;12,272 组派生穷举 0 命中,说明它不是可见字段的简单哈希 |
| 2 | 23 位载荷的语义(是否有字段被判定使用) | ★ 已把黑盒路线走到头:返回值完全不可区分 ⇒ 只能靠连续多日采样 看数字变化规律,或反编译 / 插桩 wasm 看该段是否被解析 |
| 3 | 16 字符固定段的性质 | 目前只观测到一个值(同一个 X-Product: 146)。换产品 / 站点再取一次,看它是否随 product 变化 |
| 4 | 算法归属 / 两阶段各校验什么 | ★ 已做完差分实验:可观测的边界只有 offset 16 ,看不到"先解载荷后验标签"的任何中间态 ⇒ 黑盒对算法与阶段划分没有分辨力,需静态分析 wasm |
上述第 1--3 项均可由附录 B 的命令 12 / 13 一键复现(从 txt 直接读原值并打印派生信息);
第 2、4 项的黑盒部分已由命令 14 (下文的实验)跑完,结论是黑盒到此为止。
黑盒变异实验:结构推测的检验与更正
上文的结构模型是由字符串形态反推 的,缺少行为证据。本节用差分实验 去检验它:
拿一份真实 OFD 作载荷,把 secret 的 39 个位置逐位变异 、把两段载荷互换 、把数字载荷整段替换 ,
每次只改一处,记录 wasm 的返回值。
工具:
parser-x-credential-lab.js(node parser-x-credential-lab.js,附录 B 命令 14),结构化报告:
parser-x-credential-lab-result.json。两个入口都驱动(
JSReadOFD原始字节 /JSReadOFDByBase64),逐位 39/39 全覆盖。对照组 :把某一位"改成它自己" → 仍
PASS,证明工具链本身无误。
逐位变异:唯一可观测的边界是 offset 16
| 变异位置 | 我原先认为所属的段 | 返回 |
|---|---|---|
0--15 逐位(16/16 次) |
16 字符固定盐 | 全部 10001 |
16--28 逐位(13/13 次) |
13 位时间戳 | 全部 10002 |
29--38 逐位(10/10 次) |
10 位随机尾段 | 全部 10002 |
⇒ 23 位载荷内部没有任何可观测的分界 :所谓"时间戳段 / 随机尾段"的 13/10 切分,
在返回值上得不到哪怕一条证据 ------ 改第 16 位与改第 38 位的结果逐字相同。
载荷互换 / 整段替换:10002 = 第二阶段兜底失败码
| 输入 | 返回 | 说明 |
|---|---|---|
| S1 前缀 + S2 数字载荷 + D1 | 10002 |
前缀对了、载荷换了 → 第二阶段失败 |
S1 前缀 + 0×23 |
10002 |
载荷置零 |
S1 前缀 + A×23(非数字) |
10002 |
载荷不要求是数字 ⇒ 它更像"恰好是数字的任意载荷" |
| S1 + D1 大写十六进制(绕过 JS 归一化直喂 wasm) | 10002 |
⇒ wasm 对 digest 大小写敏感 ,归一化只发生在 JS 层的 .toLowerCase() |
| S1 + 空 / 截断 / 加长 digest | 10002 |
长度、格式错都归入第二阶段 |
| 前缀整段小写化,或只改第 1 / 第 16 位的大小写 | 10001 |
⇒ 前缀大小写敏感,且边界与位置无关(首、尾都算) |
| 前缀截断到 16 字符(丢掉载荷) | 10001 |
⇒ 缺载荷也归第一阶段 |
内置默认 secret + 真 digest |
10001 |
⇒ 默认值的 16 字符不在"可接受标识"集合里 |
对上文结构模型的裁决
| 原推断 | 裁决 |
|---|---|
| 前 16 字符 = 固定盐,是独立的第一阶段门 | ✅ 成立:既是唯一分界,且默认值不在可接受集合内 |
| 23 位 = 13 位时间戳 + 10 位随机 | ❌ 不可观测 :改任何一位结果相同,切分没有行为依据 |
前 13 位 = 有效期锚点 (驱动 leftTime / 10002) |
❌ 被否证 :锚已过去 21 天的 S1 仍 PASS,leftTime 恒为 0,改早/改晚/置零/置满结果一致 |
10002 = 独立的"授权时间过期" |
❌ 更正 :它是第二阶段(凭据不匹配)的兜底码 ;是否兼作 过期码无法区分 |
| 凭证"内自带有效期"(动态性表) | ⚠️ 降级为未知 :leftTime 与 10002 文案证明输出侧有这条通路,但触发条件不可观测 |
本节能确定 / 不能确定的事
能确定(可复现):
secret必须 39 字符 、且前 16 字符命中一份"已签发标识"集合 ,否则10001;- 余下 23 字符与
digest必须与签发时的值成对 ,否则10002; digest必须是小写十六进制(wasm 自身不做归一化);- 时间锚变化不改变任何返回值 ------ 系统时间无需与凭证"对齐"。
不能确定 :23 位载荷里到底有没有字段被业务逻辑使用、10002 是否兼作过期码、
第一阶段是"查表"还是"校验和"、以及具体密码学原语 ------ 这些都超出黑盒可观测范围 ,
只能靠动态插桩 / 静态分析 wasm 回答。
方法论备注 :这一节的价值不在于"多知道一个字段",而在于把"可观测"与"可推测"划清了界限 ------
那些看起来很精致的结构(13 位时间戳、10 位随机尾段、按天签发)全部属于推测 ;
唯一经得起差分检验的只有一句话:前 16 字符是门,其余部分要与
digest配对。
与本仓库其它产物的关系
parser_x.js vs parser_x-formatted-variant.js:同一产品、不同构建
改名说明 :该文件原为
ofd.bundle-NotUsable.js,现已迁到parser_x-formatted-variant.js(字节未改动 ,md5
28fe867b0f2d5fe343cb43fa027eb121,5,397,069 B)。新名表达三件事:① 同属
parser_x这条产品线;② 是未压缩可读 (formatted)的构建(15,283 行,变量名A3/Q2等);③ 与
parser_x.js是不同变体(variant)而非别名 ------ 它的内嵌 wasm 是另一份。
授权机制"几乎完全一致",因为它们本来就是同一套代码的不同版本。特征串计数实测:
| 特征串 | examples/parser_x.js |
examples/parser_x-formatted-variant.js |
说明 |
|---|---|---|---|
kgNVVbdUZ31C6mps |
2 | 2 | 同一套授权体系的默认 secret |
JSReadOFD |
2 | 2 | wasm 绑定入口同名 |
parseOfdDocument |
2 | 2 | |
OfdBaseViewer |
4 | 4 | |
setYYDS |
3 | 3 | wasm 实例注入点 |
leftTime |
20 | 20 | 授权剩余时间字段 |
wsdsb |
2 | 2 | 都是「内嵌 wasm 的 data URI」模块名 |
waterText |
33 | 36 | |
demotest |
1 | 0 | 水印兜底文字(本版本里是死代码,见 6.2) |
但两者不是同一个构建:
examples/parser_x.js |
examples/parser_x-formatted-variant.js |
|
|---|---|---|
| 体积 | 4,643,388 B | 5,397,069 B(15,283 行) |
| 代码形态 | 已压缩(单行) | 已格式化 (变量名带数字后缀,如 A3 / Q2) |
| 内嵌 wasm | 3,310,368 B(md5 57f5d352...) |
3,417,549 B (md5 1808c4ce...) |
内嵌 wasm 与 examples/parser_helper_x.wasm |
byte-identical | 不同 |
| 额外错误分支 | 无 | 多一条"图片文件无法打开" |
sh
parser_x.js → 内嵌 wasm ✓ 与 examples/parser_helper_x.wasm 完全一致
formatted-variant → 内嵌 wasm ✗ 另一份(更大,多支持图片类 OFD)
结论 :同一个商业 SDK 的两个版本。授权体系一致是因为共用同一份授权代码 ------ 同一默认 secret、同一 JSReadOFD 入口、同一 10001 / 10002 / 10003 错误码、同样把 wasm 内嵌成 data URI;差异只在版本 (formatted-variant 的 wasm 更全)与发布形态 (格式化过、去掉了 demotest)。
与本仓库免费版的关系
src/utils/ofd / lib/ofd.umd.min.js(本仓库) |
examples/parser_x.js |
|
|---|---|---|
| 定位 | 免费版,纯解析 + 渲染 | 商业/完整版 |
parseOfdDocument / renderOfd |
有 | 有 |
renderOfd 签名 |
(width, ofdObj),同步 |
(docIndex, width),async |
| 阅读器 UI | 无 | 有(OfdViewer / OfdBaseViewer) |
| 授权 / 水印 | 无 | 有(digest 校验) |
| 外部依赖 | 零 | parser_helper_x.js(必需);wasm 内嵌 |
注意第 3 行:两者的
renderOfd参数顺序与同步性都不同,不能互换调用。
如何加载
html
<!-- 顺序不能改:先胶水,后主库(两者与本页同目录) -->
<script src="./parser_helper_x.js"></script>
<script src="./parser_x.js"></script>
<script>
// 全局:window.parser_helper_x、window.parser_x
</script>
注意事项:
- 必须用 HTTP 服务访问 ,
file://下 WASM 与脚本受同源策略限制无法加载 parser_helper_x.js必需 ------ 实测:只加载parser_x.js会在iA()处直接抛
TypeError: t(...) is not a function(t= UMD 头取到的window.parser_helper_x)parser_helper_x.wasm文件非必需 ------ wasm 二进制已内嵌在parser_x.js里(data URI),
运行期不会去请求任何.wasm文件;那个文件只是从内嵌块抠出来用于校验的副本axios可省略(本地File/ArrayBuffer输入不触发 URL 加载分支)- 首次调用
parseOfdDocument时会通过内部iA()完成 wasm 实例化,因此第一次解析会比后续慢
最小可用集 = 2 个文件 (parser_x.js + parser_helper_x.js)。
1:1:examples/parser_x.js
iA=function(A){if(m)Object(a.setYYDS)(m),A&&A();else{Object(a.needInitAgain)()||A&&(A(),A=null);document.getElementsByTagName("meta").ofdwasm;t()({locateFile:function(){return d.wsdsb}}).then((function(I){var g=I.Module;Object(a.setYYDS)(g),A&&A()}))}}
两个 js 能合并成一个文件吗?------ 能(实测)
实验矩阵:浏览器等价沙箱,parseOfdDocument 传一份真实 OFD(4.2 MB)、不带 digest ,
看它落到哪一步(走到"授权信息错误"就说明整条链路都活着)。
| # | 加载方式 | 总字节 | 结果 |
|---|---|---|---|
| T1 | 只 parser_x.js |
4,643,388 | ✗ TypeError: t(...) is not a function |
| T2 | helper → parser_x(两个 <script>) |
4,723,796 | ✓ 走到 wasm,返回"授权信息错误" |
| T3 | 合并为单文件(helper 在前) | 4,723,799 | ✓ 与 T2 逐字相同 |
| T4 | 合并为单文件但顺序颠倒 | 4,723,799 | ✗ 同样 TypeError |
| T5 | 只 helper.js |
80,408 | ✗ window.parser_x 未定义 |
| T6 | 两个 js 都在,但 wasm 拿不到 | 4,723,796 | ✗ success/fail 都不回调 → 静默挂起 |
字节数自洽:
4,723,796 = 80,408 (helper) + 4,643,388 (parser_x);T3/T4 比 T2 多的 3 字节是合并时插入的分隔符
\n;\n。
结论:可以合并,产出一个约 4.5 MB 的单文件:
bash
# 顺序不能反;中间补一个 "\n;\n" 分隔符,避免两个 IIFE 首尾相接
{ cat parser_helper_x.js; printf '\n;\n'; cat parser_x.js; } > parser_x.bundle.js
# → 4,723,799 B(= 80,408 + 3 + 4,643,388),与 T3 一致
合并后与分文件行为完全一致。原因是这层"依赖"极其轻量 ------ 它只是 UMD 在执行期读一个全局变量:
1:1:examples/parser_x.js
...:A.parser_x=I(A.parser_helper_x,A.axios)}(window,...
1:1:examples/parser_helper_x.js
...t=e&&e.process,o="parser_helper_x";...:o&&(e[o]=r(n,t,e))}(function(e){...
即 window.parser_x = factory(window.parser_helper_x, undefined)。只要先 把 parser_helper_x 挂到 window 上,
两个 <script> 还是同一个文件都无所谓。
T4 是个容易忽略的点:哪怕在同一个文件里 ,顺序颠倒照样失败 ------ 读的是执行那一刻的全局值,不是声明提升。
这条"依赖"与阅读器无关 :parser_helper_x.js 是 wasm 的加载器 + 胶水,
纯解析路径(parseOfdDocument)同样要经过它。所以"不用阅读器"能省掉的只是阅读器 UI 的调用
(那些代码本来就在 parser_x.js 里按需执行),省不掉这 80 KB 胶水。
配套的两个测试页
| 文件 | 演示的能力 | 核心调用 |
|---|---|---|
parser-x-test1-plain.html |
不用阅读器:自己拿 DOM 节点控制渲染 | parseOfdDocument → renderOfd(docIndex, width) |
parser-x-test2-viewer.html |
使用阅读器:SDK 自带滚动阅读器 | openOFDBaseViewer({ container, width, ofd, waterText, signatureClickCallback }) |
测试页②早先写成了
new window.parser_x.OfdBaseViewer(...)+new window.parser_x.EventBus()------ 那是错的 ,这两个类都没有导出 。现已改为调用公开入口
openOFDBaseViewer。另外必须传一个
signatureClickCallback(哪怕是空函数),否则 SDK 内部不会创建eventBus,
setOfdCore()里this.eventBus.dispatch(...)会抛TypeError。
启动方式:
bash
npx serve . # 或
python3 -m http.server 8080
然后访问:
http://localhost:3000/parser-x-test1-plain.htmlhttp://localhost:3000/parser-x-test2-viewer.html
两个页面都用相对路径
./parser_helper_x.js、./parser_x.js加载库,因此必须与这两个 js 同目录 ,且必须通过 HTTP 访问(
file://下 wasm 无法实例化)。
能否剥离 wasm,做一个"不需要授权的纯解析"?
先纠正一个前提
"两个 js 可以单独存在、不依赖 wasm" ------ 这个前提不成立。准确的说法是:
| 说法 | 判定 |
|---|---|
它们不依赖外部 .wasm 文件 |
✅ 成立(wasm 二进制内嵌在 parser_x.js 的 data URI 里) |
| 它们不依赖 wasm 本身 | ❌ 不成立(二进制就在内嵌块里,运行时照样要实例化) |
所以不存在"没有 wasm 的时候授权怎么办"这个场景:wasm 一定在,授权校验一定执行,没有跳过路径。
文件不齐时,授权是怎么表现的(实测)
| 缺失的东西 | 表现 | 授权是否被跳过 |
|---|---|---|
缺 parser_helper_x.js(T1/T4) |
TypeError: t(...) is not a function,同步抛出,连 fail 都不回调 |
谈不上 ------ 流程根本没进 wasm |
缺 parser_x.js(T5) |
window.parser_x 未定义 |
--- |
| wasm 二进制拿不到(T6) | unhandledRejection + success/fail 均不回调 → 静默挂起 |
没有被跳过 ------ 是"整个功能停摆" |
没有降级分支 :parseOfdDocument 只有一条主路径,readOFD 无条件调用 B.JSReadOFD:
1:1:examples/parser_x.js
v=function(A){iA((function(){...var g=Object(a.readOFD)(A.ofd,A.secret,A.digest);P(g,A)}...
v就是parseOfdDocument(见 2.1)。链路里没有任何if (wasm 不可用) 走另一条路的写法。
解析核心在哪一层 ------ 在 wasm
见实测:parser_x.js 里 ZIP 解包 0 命中、XML 解析 0 命中、WebAssembly 也 0 命中 ;
文档树还以 ofdParserPtr 指针的形式住在 wasm 内存中。
所以设想的路径:
剥离 wasm → 保留 JS → 顺带屏蔽授权 → 得到"不需要授权的纯解析"
在 parser_x.js 上不成立 ------ 原因不是"被授权挡住了",而是JS 里根本没有解析器可留 。
剥离 wasm 后剩下的只是一个空壳:能构造 Ofd_Core 的容器,但拿不到任何数据(T6 实测即为这一情形)。
可留**(10.3 已证),剥完只剩一个空壳。
那商业版为什么把解析放进 wasm?(仅为合理推测)
从外部只能观察到动机层面的猜测,不作为结论 :把解析核心放进 wasm 后,
digest 的校验点、文档树、按需取数三件事都落在同一个不可读范围内(wasm),
JS 侧只剩一个薄调用层 ------ 剥离 / 替换的门槛被抬高了。
至于 wasm 内部的实现细节,需要反编译才能确定。
官方主页面集成实测(app.xunjiepdf.com/ofdreading)
素材:两个浏览器快照 + 各自资源目录
文件 状态 说明 parser-x-主页面-加载OFD前-....html页面 load 完,未选文件 上传区完整,阅读器容器为空 parser-x-主页面-加载OFD后-....html已渲染完一份 OFD 含真实渲染 DOM(这是本节最硬的证据) 命名里的 "加载PDF前/后" 是保存时浏览器给出的标题,实际指的是加载 OFD 文件前/后。
先确认两件事:就是这两份库 、就是这个功能
生产环境用的库与我们手上的副本逐字节相同
| 文件 | 「加载后」资源目录里的副本 | examples/ 副本 |
md5(两处相同) | 比对 |
|---|---|---|---|---|
parser_x.js |
4,643,388 B | 4,643,388 B | 7bf5d1cf7737f663783dd34bd8278715 |
✅ |
parser_helper_x.js |
80,408 B | 80,408 B | 612f7f7f5fb261bbde63c1de5d2a6f20 |
✅ |
(md5 已实测;附录 B 的命令 10 可一键复现。)
→ 先前所有对源码的静态结论直接适用于线上,无需再做版本适配。
顺带得到一个"按需加载"的硬证据 ------ 两个资源目录的文件列表差异:
| 目录 | 文件数 | 含 parser_x.js / parser_helper_x.js? |
|---|---|---|
| 「加载OFD前」 | 56 | 否 |
| 「加载OFD后」 | 52 | 是(两个都在) |
也就是说:用户不选文件,这 4.6 MB 库根本不会被下载 (与 loadjs 逻辑一致)。
两个目录的差异正好"一进一出"(56 − 52 = 6 − 2 = 4,数字自洽):
- 只在「后」出现(2 个 ):
parser_helper_x.js、parser_x.js← 这就是"按需下载"本身 - 只在「前」出现(6 个 ):
icon-func.png、icon-guide.png、icon-light.png、icon_recommend.png、tag_new.png、tuijian.png
← 全部是上传区 UI 图片,随$("main").remove()从 DOM 消失后,浏览器保存时也就不再收录它们
(附录 B 的命令 11 可一键复现这张差异清单。)
页面功能由路径名决定
1:2232:examples/parser-x-主页面-加载OFD后-…_files/frontfun.js
var curryfun = location.pathname.split("/")[1];
frontfun.js 用 curryfun 把整站切成若干互不相关的分支(yinwen / erweima / barcode / ofdreading / ofd2pdf / ...)。
与 OFD 有关的只有两块:
| 分支 | 行范围 | 容器 | 做的事 |
|---|---|---|---|
ofdreading |
L1038 -- L1145 | #ofdcontentbox |
在线阅读 OFD |
ofd2pdf |
L1147 -- L1310 | #ofd2pdfbox |
借浏览器打印把 OFD 输出成 PDF |
本快照是前者 ,两条独立证据:① #ofdcontentbox 存在且已被渲染,#ofd2pdfbox 在 HTML 里根本不存在 ;
② 页面里有一处 <img class="js-head-img" src="https://app.xunjiepdf.com/ofdreading">(该 src 明显是站内小 bug,但恰好把 URL 路径暴露了)。
容器与上传区:为什么"加载后"少了半个页面
静态 HTML 里只有一行与阅读器有关:
html
<div class="ofdbox"><div id="ofdcontentbox" class="container"></div></div>
相关 CSS 只有两条:
css
.ofdbox { background: #F6F8FA; }
#ofdcontentbox { overflow: auto; max-height: calc(87vh + 2px); max-width: 1200px; }
上传 UI 不在 .ofdbox 里,而在随后的 <main> 里:#pickerofd(点击选择)、.area-operation、#dropofd(拖拽落点)。
业务层在发起渲染之后 就把整块 <main> 删掉:
1:2232:examples/parser-x-主页面-加载OFD后-…_files/frontfun.js
$("main").remove();
$(".header").css("margin-bottom", "0");
实测验证(前后两个快照对比):
| 元素 | 加载前 | 加载后 |
|---|---|---|
#pickerofd |
1 | 0 |
.area-operation |
1 | 0 |
#ofdcontentbox(空容器) |
1 | 1(已填充) |
→ 上传区不是被隐藏,而是被物理移除了 (连它引用的 6 张图片都随之从资源目录消失);
这也解释了"加载后"快照的 DOM 为何干净得只剩阅读器。
★ 缺失的合法参数从哪来:../../ScriptsMain/ofdkey.json
这是本次最有价值的发现。完整原文 (ofdreading 分支开头):
1:2232:examples/parser-x-主页面-加载OFD后-…_files/frontfun.js
if (curryfun == "ofdreading") {
var secret = "";
var digest = "";
var fileSize = 5;
if (checkUserVip()) fileSize = 50;
$.ajax({
headers: { "X-Product": appconfig.softdata.productId, "X-Version": appconfig.softdata.version, "X-Credits": appconfig.softdata.productCredits },
url: "../../ScriptsMain/ofdkey.json",
type: "GET",
dataType: "json",
async: false,
success: function (data) {
secret = data.secret;
digest = data.digest;
}
})
响应结构就是 { secret, digest } 两个字段 ------ 至此,"合法参数到底配什么"这个问题在流程上完全闭环:
它既不是算出来的,也不是写死的,而是页面加载时从一个同源地址取回来的。
✅ 已用真实抓包证实 :
examples/ofdkey-请求和返回.txt里保存了一份完整的curl(全部请求头)与两组 响应样本,响应确为
{ secret, digest },且请求侧没有任何动态/签名参数 。该抓包带来三个重要更正(生产
secret是 39 字符且带 23 位数字载荷、16 字符段是固定标识 而非随机、
digest仅 16 字节且按天变化 )。⚠️ 其中"载荷首 13 位是有效期时间戳 "这一条,已被 变异实验否证。
请求的四个要素:
| 项 | 值 |
|---|---|
| URL | /ScriptsMain/ofdkey.json(../../ 从 /ofdreading/ 解析上溯到站点根) |
| 方法 | GET,dataType: "json" |
请求头 X-Product |
146(appconfig.softdata.productId) |
请求头 X-Version |
4.5.9.3(appconfig.softdata.version) |
请求头 X-Credits |
ba2ca4a08e8691fd47754ad09927f7f9(appconfig.softdata.productCredits) |
请求头 X-Requested-With |
XMLHttpRequest(jQuery 自动附加) |
请求头 Referer |
https://app.xunjiepdf.com/ofdreading |
async |
false ------ 同步 XHR,会阻塞页面解析 |
| 动态 / 签名参数 | 一个都没有 (无 nonce、无时间戳、无签名、无 Authorization) |
appconfig 定义在 navigation.js(全站公共):
1:1:examples/parser-x-主页面-加载OFD后-…_files/navigation.js
var appconfig = {
softdata:{
productId: 146,
productName:"pdfonlineconverter",
productinfo: '1245A2A101F776005F2E909C29CC8F7369FAA0BED21AE0A9F9ADBD8D49EE3783',
productVersion:"V4.5.9.3",
version:"4.5.9.3",
productCredits:'ba2ca4a08e8691fd47754ad09927f7f9'
},
三个易混点澄清:
ofdkey.json只服务这两个 OFD 功能 ------ 全仓扫描:frontfun.js里恰好出现 2 次 ,即ofdreading与ofd2pdf各一次,别处再无引用。productinfo与 OFD 无关 ------ 它(连同 MD5 签名datasign和硬编码盐hUuPd20171206LuOnD)用在html2voice分支调/api/v4/simplecrawler,别被名字误导。- 三个
X-*头是全站公共的 ,/api/v4/simplecrawler、登录等接口都在用,不是为 OFD 定制的。
两个结构性事实(它们共同说明这里不是一个静态文件,而是一个签发入口):
X-Product/X-Version/X-Credits三个头让服务端有能力按产品 / 版本 / 配额返回不同凭证;- 实测两组样本的
secret/digest都不同 ,且载荷解出的时刻相隔 25 天整 ⇒ 响应内容会随时间变化(属编码层观察;行为层不可验证)。
完整调用链(时序)
sh
页面 load(frontfun.js 在 <head> 里,顶层代码立即执行)
└─ curryfun === "ofdreading"
├─ checkUserVip() → 读 localStorage.userinfo.vip[0].auth_value 与当前时间比较
│ → 决定 fileSize = 5MB(免费)/ 50MB(VIP)
└─ 同步 XHR 取 ofdkey.json → 拿到 { secret, digest }
(此后一直握在闭包里,用户不可见)
用户点击 #pickerofd(或拖拽到 #dropofd)
├─ 扩展名 / 大小校验
│ · pick('.ofd') ------ 靠 input.accept 过滤,**不校验后缀**
│ (后缀只被读一次,用于埋点 hd_ext: file.name.split('.').pop())
│ · 拖拽分支额外做 getext(file) !== "ofd" → showTips("请上传ofd格式文件")
│ · file.size > fileSize*1024*1024 → showMiddlePop「文件大小超限」→ /member/buy
│ (免费 5MB / VIP 50MB,由 checkUserVip() 决定)
└─ readofd(file)
├─ Promise.all([loadjs(helper), loadjs(parser_x)]) ← 首次才真正下载
├─ parser_x.openOFDBaseViewer({ ofd, secret, digest, container: #ofdcontentbox })
│ (同步返回;真正的解析在内部异步进行 ------ 见下方"一个真 bug")
├─ gaEventTask({ ... hd_result: 'Success' }) ← 注意:无条件上报
└─ $("main").remove() ← 注意:无条件移除
一个真 bug:成功/失败被混为一谈
openOFDBaseViewer 的函数体整个包在 setTimeout 里,同步返回 undefined:
1:1:examples/parser_x.js
GA=function(A){setTimeout((function(){if(A.loadingContainer){...}v({ofd:A.ofd,secret:A.secret,digest:A.digest,...,
success:function(I){A.parserOFDSuccess&&A.parserOFDSuccess(I),new n.OfdBaseViewer(A.container,A.width,X.eventBus,X.waterText).setOfdCore(I),...},
fail:function(I){if(A.parserOFDFail&&A.parserOFDFail(I),A.container)for(;A.container.firstChild;)A.container.removeChild(A.container.firstChild);v
而主页面把 gaEventTask('Success') 和 $("main").remove() 写在了 .then() 里、调用之后紧接着执行:
1:2232:examples/parser-x-主页面-加载OFD后-…_files/frontfun.js
gaEventTask({ hd_title: hd_title, hd_aname: '文件处理', hd_name: hd_title, hd_result: 'Success', })
$("main").remove();
于是 digest 失效时会发生:
| 观察点 | 实际结果 |
|---|---|
| 埋点 | 上报 Success(失败也记成功) |
| 上传区 | 照样被移除(页面已回不去) |
| 容器 | SDK 的 fail 分支只做 container.removeChild 清空 ------ 组件不渲染任何错误文案 |
主页面的 parserOFDFail |
没传 → 用户最终看到的是一片空白,无任何提示 |
这也是为什么那个
digest一旦过期,用户端表现会是"页面突然空了"而不是"提示授权过期"。SDK 侧其实是有
10001 授权信息错误/10002 授权时间过期文案的,只是要调用方通过parserOFDFail接出来 ------ 而主页面没接。
loadjs 是最朴素的动态 <script> 注入 + 缓存:
1:2232:examples/parser-x-主页面-加载OFD后-…_files/frontfun.js
const loadjs_cache = {};
function loadjs(url) {
if (loadjs_cache[url]) return loadjs_cache[url];
return (loadjs_cache[url] = new Promise(function (resolve, reject) {
var script = document.createElement("script");
script.type = "text/javascript";
script.async = true;
script.onload = resolve;
script.onerror = function (err) { delete loadjs_cache[url]; reject(err); };
script.src = url;
document.body.appendChild(script);
}));
}
三个值得记下的点:
- 库是按需加载的 :
parser_x.js(4.6 MB)只在用户真正选文件时才下载;两个快照的对比给出了三重印证:
① 脚本标签 ------ 「前」无、「后」有两个;② 资源目录 ------ 「前」不含这两个文件、「后」含;
③ 而frontfun.js里loadjs只在readofd/renderofd内部被调用。 读快照时注意:保存下来的 HTML 里src已被浏览器改写成本地相对路径(
./parser-x-主页面-..._files/parser_helper_x.js),不是 线上的/ScriptsMain/ofd/...;线上真实 URL 只在上面的
loadjs(...)参数里。 - 两只
<script>并行注入 ,靠Promise.all等待 ------ 依赖顺序由 UMD 的执行期全局读取保证
,所以并行也能成立 (helper先于parser_x完成执行即可)。
严格说这里存在"并行竞态"的理论风险,但实测线上可用。 - URL 带
?version=4.5.9.3,与appconfig.softdata.version一致 ------ 即缓存击穿版本号 ,
换版本时 URL 变化即可让所有客户端重新拉取。
阅读器渲染产物实测(DOM 级)
这是本次新增的最硬的一类证据:能直接看到 SDK 输出的 DOM。
页容器:div.page#page_N,由 setOfdCore 一次性为所有页创建
1:1:examples/parser_x.js
key:"setOfdCore",value:function(A){if(this.container){this.freeThumbnailDocument(),this.core=A,this.ofdRender.setOfdCore(this.core);var I=Math.ceil(this.core.pageWidth(0,0)),g=(this.container.offsetWidth-20)/I*25.4;this.width&&(g=this.width/I*25.4),this.coreHelper.setDpi(g);for(var B=this.container,Q=0;Q<this.core.getOFDPageCount(0);++Q){var C=document.createElement("div");C.classList.add("page"),C.id="page_"+Q;var E=this.coreHelper.pageRenderWidth(0,Q,this.core),D=this.coreHelper.pageRenderHeight(0,Q,this.core);C.setAttribute("style","position: relative;width:".concat(E,"px;height:").concat(D,"px;overflow: hidden;margin: 16px auto;background:white")),B.appendChild(C),this.pageDIV[Q]={div:C,id:Q+1,renderingState:N.RenderingStates.INITIAL,width:E,height:D}}this.updateView()}}}]),
页宽公式:container.offsetWidth - 20(未显式传 width 时)。实测值可以逐环对上:
sh
.container { width: 1170px } ← style-reset.css(全站公共)
#ofdcontentbox { max-width: 1200px } ← style.css(不构成约束)
↓ container.offsetWidth = 1170
页宽 = 1170 - 20 = 1150px
↓ 快照实测
<div class="page" id="page_0" style="position: relative;width:1150px;height:667.5px;
overflow: hidden;margin: 16px auto;background:white"> ← 与源码拼串逐字符吻合
传了
width时走g = this.width / I * 25.4,即以调用方给的值覆盖。主页面没传,所以是容器推算。
该样例 OFD 是单页文档
由于页 div 是 for(Q=0; Q<getOFDPageCount(0); ++Q) 全量预创建 的,快照里只有一个 #page_0
⇒ getOFDPageCount(0) === 1。内容是一份单页行程单类电子发票(页高/宽比 667.5/1150 = 0.58)。
渲染粒度:一个图元一个 <svg>
| 项 | 实测值 |
|---|---|
div.page(可见) |
1(#page_0) |
<svg> |
97 |
<text> |
69 |
<path> |
25 |
<image> |
3 |
每个 <svg> 内的图元数 |
恰好 1 个(97/97,无例外) |
即渲染策略是"一个图元 → 一个绝对定位的 <svg>" ,而不是"一页一个 <svg>"。
每个 <svg> 靠自身 style 定位,统一挂在 position:relative 的 .page 里。
每个 <svg> 就是该图元的包围盒(两个真实样本):
html
<!-- 一条横线(path):注意图元坐标从 0 起算,偏移由 svg 的 left/top 承担 -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg"
style="overflow:hidden;position:absolute;width:991px;height:3px;left:75.5px;top:162.5px;">
<path ID="7" fill-opacity="1" stroke="rgb(0, 0, 0)" stroke-width="1.765px" fill="none"
d="M 0 1.0000000000000002 L 991 1.0000000000000002"></path>
</svg>
<!-- 一段文字(text):注意 svg 自己的 id = OFD 对象 ID,且 overflow 是 visible -->
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" id="61"
style="overflow:visible;position:absolute;width:67.5px;height:17.5px;left:819.0000000000001px;top:100px;">
<text x="2.5 15.6 28.7 41.8 54.9 " y="11.5 11.5 11.5 11.5 11.5 "
fill="rgb(156, 74, 9)" fill-opacity="1" clip-path="url(#clip-path-61-0)"
style="font-weight: 400;font-size:15px;font-family: 楷体, KaiTi, Kai, simkai;">开票状态:</text>
</svg>
三个可复现的实现细节:
| 细节 | 实测 |
|---|---|
| 定位方式 | position:absolute + left/top/width/height,图元内部坐标从 0 开始(= svg 即包围盒) |
overflow |
path 类是 hidden、text 类是 visible(两条渲染代码路径不统一) |
| 对象 ID 属性 | path 用大写 ID="7",svg 用小写 id="61" ------ 大小写也不统一(OFD 对象 ID 原样带出) |
97 个 svg 的 left/top 全部落在 page 范围内(0--1150 / 0--667.5),无越界、无缺失。
文字渲染方式
<text> 用逐字符定位 :x="2.5 15.6 28.7 ..."(一个字符一个 x 坐标,y 同样逐字符给出),
配合 clip-path="url(#clip-path-61-0)"。
又一个可记录的现象:这个
clip-path是悬空引用。 渲染区里clip-path="url(#clip-path-N-M)"出现 69 次 (每个
<text>一次),但<clipPath>元素 0 个 、<defs>0 个 ,全文档也找不到
id="clip-path-61-0"的定义。浏览器对无法解析的
url(#...)一律忽略,所以实际并未发生裁剪 ------ 属于"发了引用但没发定义"的实现遗漏。若自行复用这份 DOM(例如搬去服务端渲染或用工具二次处理),不要指望它带了裁剪语义。
字体走 CSS 字体栈回退,实测出现:
| OFD 字体 | 映射到的 CSS |
|---|---|
| 楷体 | 楷体, KaiTi, Kai, simkai |
| 黑体 | SimHei, STHeiti, simhei |
图像:mime 声明与实际载荷不符(实测)
3 个 <image> 全部是内嵌 data URI,逐个解码比对 mime 声明与实际魔数:
| # | 声明的 mime | 载荷前 4 字节 | 实际格式 | 判定 |
|---|---|---|---|---|
| 0 | image/png |
42 4d b6 03 |
BMP | ❌ 声明错了 |
| 1 | image/jpeg |
ff d8 ff e0 |
JPEG | ✅ |
| 2 | image/png |
89 50 4e 47 |
PNG | ✅ |
即 SDK 把 BMP 载荷标成了 image/png (3 个里错 1 个)。浏览器靠内容嗅探仍能正常显示,线上看不出问题 ------
但如果自己拿这份 DOM 去做后处理(按 mime 解码),会踩坑。
<image> 元素还有两个细节值得记(image #0 真实片段):
html
<image xlink:href="data:image/png;base64,Qk22AwAA..." width="74px" height="74px" opacity="1"
transform="matrix(0.810810810810811 0 0 0.810810810810811 0 0)"
href="data:image/png;base64,Qk22AwAA..."></image>
- 同一份 base64 被写了两遍 :
xlink:href(旧式)与href(SVG2)各存一份,
相当于图片在 DOM 里占双倍体积。这也是"渲染后"快照比"渲染前"大出约 173 KB 的主因之一。 - 缩放靠
transform="matrix(s 0 0 s 0 0)"(等比),width/height是缩放后的显示尺寸。
懒渲染:可见性驱动
1:1:examples/parser_x.js
"updateView":...,"_scrollUpdate":function(){0!==this.core.getOFDPageCount(0)&&this.updateView()},
"_getVisibleViews":function(){return Object(N.getVisibleElements)({scrollEl:this.container,views:this.pageDIV})},
OfdBaseViewer 构造时挂上 watchScroll(this.container, this._scrollUpdate),滚动时只重算可见页 ,
页状态机用 RenderingStates.INITIAL/...(与 PDF.js 同款词表)。
页容器全量创建、页内容按可见性渲染 ------ 这两件事不矛盾,"滚到底"技巧正是为后者服务的。
顺带补上
OfdBaseViewer的构造函数原文,可见waterText是它的第 4 个参数 ,直接透传进
Ofd_Core_Helper(96, ⇢)------ 这是「水印唯一开关」在阅读器路径上的落地:
1:1:examples/parser_x.jsvar M=function(){function A(I,g,B,Q){E()(this,A),this.core=null,this.width=g,this.eventBus=B,this.coreHelper=new F.Ofd_Core_Helper(96,Q),this.ofdRender=new G.Ofd_Render(null,this.coreHelper),this.container=I,this.pageDIV=[],this.currentPage=1,this.container&&(this.scroll=Object(N.watchScroll)(this.container,this._scrollUpdate.bind(this)))}
附带收获:ofd2pdf 的实现真相(同一文件 L1147--L1310)
虽然本快照是 ofdreading,但两块代码同源,顺手把它还原出来 ------ 结论有点出人意料:
它并不做真正的格式转换,而是"渲染 → 打印成 PDF"。
1:2232:examples/parser-x-主页面-加载OFD后-…_files/frontfun.js
function renderofd(file) {
return new Promise((resolve, reject) => {
Promise.all([loadjs("/ScriptsMain/ofd/parser_helper_x.js?version=4.5.9.3"), loadjs("/ScriptsMain/ofd/parser_x.js?version=4.5.9.3")]).then(() => {
parser_x.openOFDBaseViewer({
ofd: file, secret: secret, digest: digest,
container: document.getElementById('ofd2pdfbox'),
})
$("#ofd2pdfbox").siblings().hide().parent().find(".converttip").show()
setInterval(function () {
if (document.querySelector('#ofd2pdfbox').innerHTML.length > 0) { resolve() }
}, 1000)
})
})
}
function printofd() {
const divs = $("#ofd2pdfbox div")
if (divs) {
var frame = document.createElement('iframe')
document.body.appendChild(frame)
var addtime = 0
function scrollbottom() {
$("#ofd2pdfbox")[0].scrollBy(0, 500)
addtime++
if (addtime * 500 + $("#ofd2pdfbox")[0].clientHeight < (divs.height() + 32) * divs.length) {
setTimeout(() => { scrollbottom() }, 80);
} else {
frame.contentDocument.body.innerHTML = document.getElementById('ofd2pdfbox').innerHTML
$("#ofd2pdfbox div").remove()
...
frame.contentWindow.print()
setTimeout(() => { document.getElementsByTagName("iframe")[0].remove() }, 2000);
}
}
scrollbottom()
}
}
逐步拆解:
| 步骤 | 代码 | 目的 |
|---|---|---|
| 1. 渲染 | openOFDBaseViewer({ container: #ofd2pdfbox }) |
用同一个阅读器(复用授权与渲染),只是塞进一个隐藏小容器 |
| 2. 等首屏 | setInterval(... innerHTML.length > 0 ..., 1000) |
轮询"容器里出现内容了没"(无回调可用,只能轮询) |
| 3. 逼出全部页 | scrollBy(0, 500) 递归,直到滚过 (页高+32) × 页数 |
正是 11.5(六) 的懒渲染:不滚到底,后面的页根本不会被渲染 |
| 4. 搬运 | frame.contentDocument.body.innerHTML = #ofd2pdfbox.innerHTML |
把渲染好的 SVG DOM 灌进一个临时 iframe |
| 5. 出 PDF | frame.contentWindow.print() |
弹浏览器打印框,由用户"另存为 PDF" |
几点值得记录:
#ofd2pdfbox的 CSS 是{ overflow:auto; max-height:162px; width:338px }------ 宽仅 338px 。
由上文公式,此时页宽 =338 - 20 = 318px。也就是说这一路渲染出的页面尺寸远小于阅读器 ,
最终打印质量取决于浏览器如何缩放这个 iframe(函数里没给 iframe 设任何尺寸,也没有打印 CSS)。
这是本流程最脆弱的一环,线上效果如何本文无法判定(需要在真实环境实测)。- 步骤 3 里的
divs.height()是 jQuery 取第一个 元素的高度,+32对应margin:16px auto的上下边距
(与拼出来的page样式完全对应),可见这段代码是贴着阅读器的实现细节写的。 - 工程瑕疵 :步骤 2 的
setInterval从不clearInterval,若渲染失败会以 1 秒/次的频率永久空转;
步骤 5 用iframe[0]定位节点再删除,若页面已有其他 iframe 会删错对象。 - 这条路线完全在前端完成,服务器不参与文件处理 ------ 与"纯前端库"的定位一致。
附录 A:关键字符偏移索引(parser_x.js 为单行文件)
| 主题 | 偏移(约) |
|---|---|
| UMD 头 | 0 |
P() 解析落地 + 水印/授权 |
4,741 |
v() = parseOfdDocument |
5,483 |
EA = renderOfd |
6,785 |
DA = renderOfdByIndex |
7,956 |
iA() wasm 初始化 |
8,444 |
wA = openMetaBaseViewer |
9,133 |
GA = openOFDBaseViewer |
9,495 |
FA = openOFDBaseViewerByBase64 |
10,309 |
UA = openFile / kA = openOFD |
13,113 / 15,128 |
OfdViewer 类定义 |
55,499 |
Ofd_Core.readOfdDocument |
139,343 |
| 内嵌 wasm 的 data URI 起点 | ~213,046 |
OfdBaseViewer 类定义 |
4,635,448 |
| 文件总长 | 4,643,388 |
附录 B:验证命令
bash
# 1. 外部 wasm 完整性(字节数 + WebAssembly.validate,后者应为 true)
node -e "const b=require('fs').readFileSync('examples/parser_helper_x.wasm');console.log(b.length, WebAssembly.validate(b))"
# 2. 内嵌 wasm 与外部文件是否同一份
node -e "const fs=require('fs'),c=require('crypto');const s=fs.readFileSync('examples/parser_x.js','utf8');const m=s.match(/data:application\/wasm;base64,([A-Za-z0-9+\/=]+)/);const a=Buffer.from(m[1],'base64'),b=fs.readFileSync('examples/parser_helper_x.wasm');const h=x=>c.createHash('md5').update(x).digest('hex');console.log('内嵌',a.length,h(a));console.log('外部',b.length,h(b));console.log('一致?',a.equals(b))"
# 3. 所有 webpack 模块的导出名(=146,注意这【不是】公开 API)
node -e "const s=require('fs').readFileSync('examples/parser_x.js','utf8');const S=new Set();const re=/g\.d\([A-Za-z_\$]+,\"([A-Za-z0-9_\$]{2,44})\",\(function/g;let m;while((m=re.exec(s))!==null)S.add(m[1]);console.log(S.size, [...S].sort().join(', '))"
# 4. 真正挂到 window.parser_x 上的公开导出(=29)
node -e "const s=require('fs').readFileSync('examples/parser_x.js','utf8');const a=s.indexOf('g.d(I,\"OFDPrintApi\"');const b=s.indexOf('},function(',a);const L=[...s.slice(a,b).matchAll(/g\.d\(I,\"([A-Za-z0-9_\$]+)\"/g)].map(m=>m[1]);console.log(L.length, L.join(', '))"
# 5. wasm 段表(section id 列表;注意没有 id=0 的 custom "name" 段 ------ 印证导出名已被 strip)
node -e "const b=require('fs').readFileSync('examples/parser_helper_x.wasm');let o=8;const leb=()=>{let r=0,s=0,x;do{x=b[o++];r|=(x&0x7f)<<s;s+=7;}while(x&0x80);return r};const secs=[];while(o<b.length){const id=b[o++];const sz=leb();secs.push({id,start:o,size:sz});o+=sz}console.log('sections:',secs.map(s=>s.id).join(','))"
# 6. 验证两个 js 可合并为单文件(顺序不能反;结果应与分文件一致)
node -e "const fs=require('fs');const a=fs.readFileSync('examples/parser_helper_x.js'),b=fs.readFileSync('examples/parser_x.js');fs.writeFileSync('/tmp/parser_x.bundle.js',Buffer.concat([a,Buffer.from('\n;\n'),b]));console.log('合并后字节:',fs.statSync('/tmp/parser_x.bundle.js').size)"
# 7. 确认 parser_x.js 内没有 JS 侧解析器(应全为 0)
node -e "const s=require('fs').readFileSync('examples/parser_x.js','utf8');['PK\x03\x04','inflate','unzip','JSZip','pako','DOMParser','parseFromString','WebAssembly'].forEach(k=>console.log(JSON.stringify(k).padEnd(22), s.split(k).length-1))"
# 8. 从主页面快照还原 OFD 调用链与授权来源(纯离线,不访问那个 ofdkey 地址)
node -e "const fs=require('fs');const d=fs.readdirSync('examples').find(f=>f.startsWith('parser-x-主页面-加载OFD后')&&f.endsWith('_files'));fs.readFileSync('examples/'+d+'/frontfun.js','utf8').split('\n').forEach((l,i)=>{if(/ofdkey\.json|openOFDBaseViewer|ofdcontentbox|ofd2pdfbox|ScriptsMain\/ofd/.test(l))console.log(String(i+1).padStart(5)+' | '+l.trim().slice(0,150))})"
# 9. 统计真实渲染产物(页数 / 图元数 / 水印残留)
# 注意:indexOf 的参数是字符串不是正则,引号必须用 String.fromCharCode(34) 生成,
# 否则(用 . 顶替引号)会匹配不到、静默返回 0
node -e "const fs=require('fs');const A=fs.readFileSync('examples/'+fs.readdirSync('examples').find(f=>f.startsWith('parser-x-主页面-加载OFD后')&&f.endsWith('.html')),'utf8');const q=String.fromCharCode(34);const st=A.indexOf('id='+q+'ofdcontentbox'+q),en=A.indexOf('</div></div>',st),seg=A.slice(st,en);console.log('渲染区字节:',seg.length);console.log('page div 数:',(seg.match(/class=.page. id=.page_/g)||[]).length);['svg','text','path','image'].forEach(k=>console.log(k.padEnd(6),(seg.match(new RegExp('<'+k+'[ >]','g'))||[]).length));['试读','试用','仅供','样例','未授权','demotest'].forEach(k=>{const n=seg.split(k).length-1;if(n)console.log('水印可疑:',k,n)});console.log('(上一行应为空)')"
# 10. 主页面自带的库副本是否与 examples 一致(两行都应输出 OK 一致)
node -e "const fs=require('fs'),c=require('crypto');const d=fs.readdirSync('examples').find(f=>f.startsWith('parser-x-主页面-加载OFD后')&&f.endsWith('_files'));const h=x=>c.createHash('md5').update(fs.readFileSync(x)).digest('hex');['parser_x.js','parser_helper_x.js'].forEach(f=>console.log(f.padEnd(20),h('examples/'+d+'/'+f)===h('examples/'+f)?'OK 一致':'DIFF 不同',h('examples/'+f)))"
# 11. 「按需加载」的硬证据:两个资源目录的文件数差异,以及差异清单
# 预期:前 56 / 后 52;两个 parser 库只出现在「后」;「前」多出的是图片
node -e "const fs=require('fs');const b=fs.readdirSync('examples').find(f=>f.startsWith('parser-x-主页面-加载OFD前')&&f.endsWith('_files'));const a=fs.readdirSync('examples').find(f=>f.startsWith('parser-x-主页面-加载OFD后')&&f.endsWith('_files'));const B=new Set(fs.readdirSync('examples/'+b)),A=new Set(fs.readdirSync('examples/'+a));console.log('前',B.size,'/ 后',A.size);console.log('只在「后」出现(=因加载OFD才下载):',[...A].filter(f=>!B.has(f)).join(', '));console.log('只在「前」出现:',[...B].filter(f=>!A.has(f)).join(', '))"
# 12. 复核 ofdkey 抓包的结构结论(提取**全部**样本,逐一打印派生信息)
# 注意:文件里有【两组】JSON,不能用 indexOf('{')/lastIndexOf('}') 整段解析 ------ 必须用 matchAll 逐个取
# 预期:每份 secret 39 字符 = 16 字符固定段 + 23 位数字;前 13 位数字解出「北京时间 00:00:03.1xx」附近
# digest 32 hex = 16 字节;请求侧动态/签名特征应为「(无)」
node -e "const fs=require('fs');const t=fs.readFileSync('examples/ofdkey-请求和返回.txt','utf8');const O=[...t.matchAll(/\{[\s\S]*?\}/g)].map(m=>JSON.parse(m[0]));O.forEach((o,i)=>{const s=o.secret,d=o.digest,p=s.search(/[0-9]/);console.log('#'+(i+1),'secret',s.length,'| 固定段',p,'| 数字段',s.length-p,'| digest',d.length,'->',d.length/2+'B');console.log(' 前 13 位当 ms ->',new Date(Number(s.slice(p,p+13))).toISOString(),'UTC = 北京',new Date(Number(s.slice(p,p+13))+288e5).toISOString().replace('Z',''))});console.log('请求侧动态/签名特征:',['nonce','sign','timestamp','Authorization','X-Token'].filter(k=>new RegExp(k,'i').test(t)).join(',')||'(无)')"
# 13. 两组样本的对照解剖(公共前缀 / 时间锚之差 / digest 字节与 magic / 派生穷举)
# 预期:公共前缀 19 字符;两个时间锚相差 25 天整;digest 无任何常见 magic;
# 两组 XOR 置位约 50%;派生穷举 3 万余组,命中 0
node -e "const fs=require('fs'),c=require('crypto');const t=fs.readFileSync('examples/ofdkey-请求和返回.txt','utf8');const O=[...t.matchAll(/\{[\s\S]*?\}/g)].map(m=>JSON.parse(m[0]));const S=O.map(o=>o.secret),D=O.map(o=>o.digest);const pre=s=>s.slice(0,s.search(/[0-9]/)),dg=s=>s.slice(pre(s).length);let i=0;while(S[0][i]&&S[0][i]===S[1][i])i++;console.log('公共前缀',i,JSON.stringify(S[0].slice(0,i)));const a=Number(dg(S[0]).slice(0,13)),b=Number(dg(S[1]).slice(0,13));console.log('时间锚',new Date(a).toISOString(),'/',new Date(b).toISOString(),'| 差',(b-a)/864e5,'天');D.forEach((d,k)=>{const x=Buffer.from(d,'hex');console.log('#'+(k+1),x.length+'B',[...x].map(v=>v.toString(16).padStart(2,'0')).join(' '),'| 不同字节',new Set([...x]).size,'| magic 命中',['504b0304','89504e47','ffd8ff','474946','25504446','424d'].some(m=>d.startsWith(m)))});const X=[...Buffer.from(D[0],'hex')].map((v,k)=>v^Buffer.from(D[1],'hex')[k]);console.log('XOR',Buffer.from(X).toString('hex'),'置位',X.reduce((s,v)=>s+v.toString(2).split('1').length-1,0),'/128');const A=['md5','sha1','sha256','sha512'],SP=['','|',':','-','.',' ','_','\n'];let n=0,h=0;O.forEach(o=>{const s=o.secret,w=o.digest,P=pre(s),G=dg(s);const pool=[s,P,G,G.slice(0,13),G.slice(0,10),G.slice(13),P.toUpperCase(),P.toLowerCase(),'kgNVVbdUZ31C6mps','146','4.5.9.3','ba2ca4a08e8691fd47754ad09927f7f9','ofdreading'];pool.forEach(x=>{A.forEach(al=>{n++;if(c.createHash(al).update(x,'utf8').digest('hex').slice(0,32)===w)h++});pool.forEach(y=>SP.forEach(sp=>A.forEach(al=>{n++;if(c.createHash(al).update(x+sp+y,'utf8').digest('hex').slice(0,32)===w)h++})));pool.forEach(y=>A.forEach(al=>{n++;if(c.createHmac(al,x).update(y,'utf8').digest('hex').slice(0,32)===w)h++}))})});console.log('派生穷举',n,'组,命中',h)"
# 14. 凭证黑盒变异实验(6.7)------ 逐位变异 39/39 + 分段手术 + 失败态对比 + 入口一致性
# 预期:0--15 位任一字符 → 10001(16/16);16--38 位任一字符 → 10002(23/23)⇒ 唯一边界 = offset 16
# 数字段整段替换 / digest 错配 / 大写 digest → 全部 10002;前缀大小写变化 → 10001
# 时间锚已过去 21 天的样本仍 PASS、两组 leftTime 均为 0 ⇒ "13 位 = 有效期锚点" 被否证
# 产出:examples/parser-x-credential-lab-result.json(结构化报告)
node examples/parser-x-credential-lab.js --json examples/parser-x-credential-lab-result.json
# 15. 阅读器能力清单(4.(d))------ 工具栏按钮表 / 侧边栏面板 / 有无查找·演示·书签 / 打印实现 的源码级证据
# 预期:Toolbar buttons = previous,next,zoomIn,zoomOut,openFile,print,presentationModeButton,download,viewBookmark
# (其中 viewBookmark=>null 即无功能;presentationModeButton 与 viewBookmarkButton 之后被 .hidden=!0)
# sidebar 四个面板 = thumbnailView, signaturesView, attachmentsView, outlineView
# findbar / findInput / viewFind / FindController / fullscreen 全为 0
# 打印两处:Print_api(window.open("打印窗口"))与 S.Print(#printServiceDialog + 进度条);页尺寸 794/1123px 各 4 处
node -e "const fs=require('fs');const s=fs.readFileSync('examples/parser_x.js','utf8');const bt=s.match(/this\.buttons=\[(.*?)\],this\.items=/s);console.log('Toolbar buttons:');console.log(' '+[...bt[1].matchAll(/element:I\.([A-Za-z]+),eventName:([^,}]+)/g)].map(m=>m[1]+'=>'+m[2]).join(', '));const g=(re)=>[...new Set([...s.matchAll(re)].map(m=>m[1]))].sort();console.log('sidebar 面板:',g(/sidebar\.([A-Za-z]+)/g).join(', '));console.log('主动隐藏 presentationModeButton/viewBookmarkButton:',s.split('presentationModeButton.hidden=!0').length-1,'/',s.split('viewBookmarkButton.hidden=!0').length-1);['findbar','findInput','viewFind','FindController','fullscreen'].forEach(k=>console.log(' '+k.padEnd(16),s.split(k).length-1));console.log('Print_api:',s.split('Print_api').length-1,'| 打印窗口:',s.split('打印窗口').length-1,'| printServiceDialog:',s.split('printServiceDialog').length-1,'| 794px/1123px:',(s.split('794px').length-1)+'/'+(s.split('1123px').length-1),'| setWaterText:',s.split('setWaterText').length-1)"
# 16. 完整版阅读器「打印」失效的调用链取证(详见附录 C)
# 预期:① 能看到 viewer 里的 eventBus._on("print",nA);② nA 里是 ofdPrint.setOfdCore + open();
# ③ open() 里 window.open 被 setTimeout(100) → prepare() → setTimeout(200) 层层包住 ------ 这就是"迟到 7 秒"的来源
node -e "const fs=require('fs');const s=fs.readFileSync('examples/parser_x.js','utf8');const at=k=>s.indexOf(k);const seg=(k,a,b)=>s.slice(at(k)-a,at(k)+b);console.log('① viewer 订阅 print:');console.log(' ',seg('_on(\"print\",nA)',150,40));console.log('② nA:');console.log(' ',seg('function nA()',10,120));console.log('③ open() 的延迟链:');console.log(' ',seg('this.abort=!1,this.overlayManager.open(this.dialog)',10,520).replace(/;/g,';\n '));console.log('④ 次数: printServiceDialog=',s.split('printServiceDialog').length-1,'| 进度条 l10n 键=',s.split('print_progress_percent').length-1)"
# 17. 三条打印路径的「同源」核对(详见附录 D)
# 预期:打印 dpi 公式 `785/...*25.4` 命中 2 次(b、c 各一);`794px`/`1123px` 各 4 次;
# `window.open("打印窗口")` 与 `window.open("","_blank")` 各 1 次;两个打印类内部均无 setWaterText
node -e "const s=require('fs').readFileSync('examples/parser_x.js','utf8');const n=k=>s.split(k).length-1;console.log('打印 dpi 公式 785/...*25.4:',n('785/'),'| setDpi 总数:',n('setDpi('),'| 屏幕 96*currentScale:',n('96*this._currentScale'));console.log('794px/1123px:',n('794px')+'/'+n('1123px'),'| window.open(\"打印窗口\"):',n('window.open(\"打印窗口\"'),'| window.open(\"\",_blank):',n('window.open(\"\",\"_blank\")'));const a=s.indexOf('new F.Ofd_Core_Helper(40)'),b=s.indexOf('new F.Ofd_Core_Helper(96),this.onProgress');console.log('S.Print 含 setWaterText:',/setWaterText/.test(s.slice(a,a+2600)),'| Print_api 含 setWaterText:',/setWaterText/.test(s.slice(b,b+2600)))"
附录 C:完整版阅读器「打印」失效的根因与修复(宿主页实测)
一句话结论 :不是 SDK 能力缺失,而是
Print.open()把window.open(...)放在了「100ms 延时 + 逐页 A4 重渲染 + 200ms 延时」之后(实测 ≈ 7s),早已脱离浏览器的暂态用户激活 窗口(约 5s),
于是弹窗被拦、
window.open返回null,紧接着null.document抛TypeError------ 对外表现就是「进度框走完 → 进度框消失 → 什么也不发生」 (偶尔看到标签页闪一下,就是被拦下的那次弹窗尝试)。
修复落在宿主页 侧(
static-toolbox/ofd/index.html),不改动第三方压缩库。
C.1 现象与第一手观测
触发路径:宿主页打开第三方解析器开关 → 打印方式选 c「阅读器打印(完整版)」 → 点击阅读器工具栏内的打印按钮。
| 观测点 | 结果 |
|---|---|
进度框 #printServiceDialog |
✅ 出现(内含 <progress>、.relative-progress、#printCancel) |
| 渲染进度 | ✅ 正常推进(l10n 键 print_progress_percent) |
| 进度框自动关闭 | ✅ 正常关闭(abort=!0 + overlayManager.close) |
| 打印窗口 / 浏览器打印对话框 | ❌ 不出现 |
| 浏览器标签页 | 偶尔"晃一下" = 被拦截的弹窗尝试 |
| 控制台 | TypeError: Cannot read properties of null (reading 'document')(极易被忽略,页面没有任何可见反馈) |
关键:进度框是 SDK 自己弹的 (S.Print 的 dialog),不是宿主页的 #tp-progress;所以"进度框正常"这件事
恰好说明 SDK 已经跑到了打印流程内部,问题只在最后一步的窗口创建。
C.2 调用链(源码级,parser_x.js)
三步,均可由附录 B 命令 16 逐字取出:
-
工具栏按钮 → 事件总线 :
Toolbar构造时把按钮与事件名配对,点击即dispatch1:1:examples/parser_x.jsthis.buttons=[{element:I.previous,eventName:"previouspage"},...,{element:I.print,eventName:"print"},...]次级工具栏同理:
{element:I.printButton,eventName:"print",close:!0}。 -
viewer 订阅
"print":1:1:examples/parser_x.jsthis.eventBus._on("print",nA) -
nA()→ofdPrint.open():1:1:examples/parser_x.jsfunction nA(){X.viewer.ofdPrint.setOfdCore(X.ofdCore),X.viewer.ofdPrint.open()}(公开导出
printOFD=RA,内容就是X.viewer.ofdPrint && nA(),所以宿主页调parser_x.printOFD()与点按钮完全同一条路。)
⚠️ 两个前提:
a) 必须走 initOFDViewer / openOFDViewer 完整路径(存在 X.viewer);
b) 页面必须存在 #printServiceDialog,否则 ofdPrint 根本不会被创建 ,printOFD() 静默什么都不做。
C.3 根因:window.open 迟到约 7 秒,超出「暂态用户激活」
Print.open() 的真实时序(压缩源码逐字还原,; 处换行仅为阅读方便):
1:1:examples/parser_x.js
case 2: return this.abort=!1, this.overlayManager.open(this.dialog), A.next=6, this.renderProgress(0);
case 6: setTimeout((function(){ I.prepare().then((function(A){ A&&A.length>0&&(I.close(),
setTimeout((function(){ var I,g=window.open("","_blank"),B=g.document.body,Q=N(A);
... B.appendChild(C) ... g.focus(),g.print(),g.close() }),200)) })) }),100);
| 阶段 | 累计耗时 | 说明 |
|---|---|---|
| 点击 → 进度框出现 | ≈ 0 | overlayManager.open(dialog) |
renderProgress(0) |
~100ms 级 | 只刷新进度条与文案 |
prepare() 逐页渲染 |
秒级 ~ 十几秒 (999.ofd 5 页实测 ≈ 7s) |
每页按 A4 794×1123px 重渲染一遍 |
window.open("","_blank") |
≈ +7s | 手势已过期 ⇒ 被拦 ⇒ 返回 null |
g.document.body |
--- | null.document ⇒ TypeError,链条就此中断(静默失败) |
补充两点容易误判的地方:
- 不是参数问题 :
window.open("", "_blank")(空 URL)本身并无问题,时机 才是问题。同一段代码若在
点击后 ~500ms 内触发是能成功的 ------ 这解释了为什么小文档 / 快机器上"偶发成功"。window.close()也需要"脚本开的窗口" :预开窗口方案顺带满足了这条,SDK 结尾的g.close()才不会被拒。
C.4 修复设计(宿主页侧,不动 SDK)
三条原则:
- 不改第三方压缩库 :
parser_x.js是 4.6MB 压缩产物,改动不可维护; - 弹出窗口必须在点击手势内同步创建:这是绕过弹窗拦截的唯一可靠手段;
- 拦截点要落在 SDK 之前 :SDK 的
Toolbar是把监听器直接挂在按钮元素上 (目标阶段),
所以必须在祖先节点的捕获阶段stopPropagation()才能真正截住。
C.4.1 捕获阶段拦截:新增 tpBindReaderPrintButtons()
1565:1579:static-toolbox/ofd/index.html
function tpBindReaderPrintButtons() {
if (viewerHost.dataset.printBridged === '1') return;
viewerHost.dataset.printBridged = '1';
viewerHost.addEventListener('click', event => {
if (!tpState.enabled || tpState.mode !== 'c') return;
const btn = event.target && event.target.closest
? event.target.closest('#print, #printButton')
: null;
if (!btn || !viewerHost.contains(btn)) return;
event.preventDefault();
event.stopPropagation(); // 关键:捕获阶段拦截,SDK 绑在按钮上的 print 处理不会执行
tpPrintByReader();
}, true);
}
设计要点:
| 要点 | 原因 |
|---|---|
第三个参数 true(捕获) |
SDK 的监听器注册在按钮自身(目标阶段),冒泡阶段永远晚于它;捕获阶段在祖先上 stopPropagation() 才能真的拦住 |
| 祖先节点 + 事件委托 | 阅读器随模式切换会整体重建 (tpBuildFullViewer 里 viewerHost.innerHTML='' 后重新克隆模板),委托在稳定的 viewerHost 上只需注册一次 |
dataset.printBridged 幂等 |
避免重建时重复注册,导致一次点击跑两遍打印 |
同时匹配 #print 与 #printButton |
主工具栏与次级工具栏在 SDK 里都是 eventName:"print",两个入口都会中招 |
tpState.mode !== 'c' 早退 |
方式 b 复用同一个 viewerHost,不能误伤 |
为什么不能"包装公开导出" :把
parser_x.printOFD换掉是无效的 ------ 工具栏按钮走的是 viewer 内部的nA,根本不经过公开导出。必须从事件流下手。
C.4.2 预开窗口 + 接管 window.open:强化 tpRunWithPrintWindow()
思路:点击瞬间同步 window.open('about:blank')(此时手势有效,不会被拦),随后把 window.open 临时替换 为
"首次调用即返回这个已开窗口"的代理;SDK 不论渲染多久之后调用 window.open(...),拿到的都是这个合法窗口。
1732:1755:static-toolbox/ofd/index.html
const patchedOpen = function() {
if (!used && popup && !popup.closed) {
used = true;
return popup;
}
return origOpen.apply(window, arguments);
};
const release = () => {
if (released) return;
released = true;
if (window.open === patchedOpen) window.open = origOpen;
if (watchdog) { clearInterval(watchdog); watchdog = null; }
if (!used && popup && !popup.closed) popup.close();
};
// 保持接管状态,直到 SDK 真的取走窗口、弹窗被关掉,或兜底超时
window.open = patchedOpen;
watchdog = setInterval(() => {
if (used || !popup || popup.closed) release();
}, 200);
setTimeout(release, fallback);
相对初版的四处改进:
| 改动 | 原因 |
|---|---|
释放时机:已被 SDK 取走 / 弹窗被关 / 兜底 60s,取代"固定 15s 后无条件还原" |
多页文档 prepare() 可能超过 15s;固定 15s 会让修复本身再次踩到弹窗拦截 |
预写 @page{size:A4;margin:0} 与 html,body{margin:0;padding:0} |
SDK 只往新窗口 body 里 appendChild 页面 div,不自带任何样式;不预置就会出现默认页边距 / 缩放错位 |
run() 包 try/catch,异常即 release() |
避免代理长期驻留,污染后续其它 window.open 调用 |
| 预开失败时明确提示"打印窗口被浏览器拦截,请允许本站弹出窗口后重试" | 原实现只会在控制台抛 TypeError,用户零反馈 |
C.4.3 保留的护栏
- 未成功加载文档(
tpState.viewerReady === false)或页面缺少#printServiceDialog→ 明确提示,不静默失败; - 已有打印任务进行中(
#printServiceDialog.open === true)→ 拒绝重复触发; - 连"点击瞬间"的预开窗口都被拦截(用户禁用了弹窗)→ 提示放行后重试。
C.5 实测验证(真实 Chromium + 本地 HTTP,方式 c)
环境:python3 -m http.server 提供 static-toolbox/;localStorage 预置凭据;文档 999.ofd(5 页);
真实用户点击 阅读器工具栏 #print。
| 指标 | 实测值 | 含义 |
|---|---|---|
viewerHost.dataset.printBridged |
"1" |
捕获阶段桥接已生效 |
| 点击 → 新窗口创建 | +10 ms | 预开窗口落在手势内 ✅ |
点击 → SDK 调用 print() |
+7.2 s | 与原实现"迟到约 7 秒"完全吻合 |
window.open 调用次数 / 被拦次数 |
1 / 0 | 只有预开那一次;SDK 不再自己开窗 |
| 打印窗口收到的内容 | 5 页(A4 794×1123) | 与 C.3 的渲染规模一致 |
| 事后弹窗状态 | SDK 已 close(),无残留 |
生命周期正常结束 |
页面错误(errors) |
空 | 无 TypeError |
测量时用于计时的一次性注入(可复用):
js
// 1) 记下点击时刻(document 捕获早于 viewerHost,最接近真实手势时间)
document.addEventListener('click', e => {
if (e.target.closest && e.target.closest('#print')) window.__t.clickAt = performance.now();
}, true);
// 2) 记录每次 window.open 的时刻,并给新窗口的 print() 打桩(避免真弹打印框阻塞自动化)
const o = window.open;
window.open = function () {
const w = o.apply(window, arguments);
window.__t.openAt = performance.now();
if (w) { try { w.print = () => { window.__t.printAt = performance.now(); }; } catch (e) {} }
return w;
};
// 3) 点击一次,然后读 window.__t:{clickAt, openAt, printAt}
⚠️ 验证环境的取舍(重要) :本次所用 Chromium 带有
--disable-popup-blocking,因此无法在该环境复现"被拦截"(同样的调用即便来自无手势的
eval也能成功开窗)。所以本附录的证明力来自时序 而非"复现拦截":预开窗口在 +10ms 、SDK 取用窗口在 +7.2s ,而暂态激活窗口约 5s ------ 只要
window.open仍由 SDK 自己发在 +7.2s,就必然落在激活窗口之外。要"看见"拦截,需用真实 Chrome(默认开启弹窗拦截)手工点一次。
C.6 可迁移的通用结论
- 凡是"渲染完成后才弹窗"的 SDK 打印实现,宿主页都必须替它预开窗口 :
Print_api.startPrint()(方式 b)与S.Print.open()(方式 c)内部都是
window.open(...)→appendChild(页 div)→print()→close(),两条路径同样中招 ;
本次修复的本质是把已有桥接从"页面级打印按钮"补齐到**"阅读器内部按钮"**这个盲区。 - 不要拿
window.open的返回值当成功判据 :被拦时它是null,SDK 直接null.document抛错,表现为静默失败;
宿主侧必须自带兜底提示与日志。 - 打印产物没有任何 SDK 侧样式与纸张设置 :
@page/body需宿主补;页尺寸恒为 794×1123(A4 @96dpi),
与屏幕上的缩放 / 翻页状态无关。 - 改造第三方压缩库的正确姿势 :不改库,改"库的调用入口"。
优先级:能截住事件流的位置(捕获阶段委托)> 包装其公开导出 > 修改压缩代码 ;
本次两个入口都在阅读器工具栏内、事件名同为"print",只有捕获阶段委托能一次性覆盖。
附录 D:三条打印路径的「同源」对照(方式 a / b / c 观感一致的原因)
一句话结论 :宿主页里的「浏览器打印 / 阅读器打印 / 阅读器打印(完整版)」不是三套实现碰巧结果相同 ,
而是同一套解析 + 渲染引擎的三个外壳 :同一个 core → 同一个
Ofd_Render→ dpi 都在 95~96.8 的 A4 纸面 →同一个浏览器打印对话框。可见差异只有页宽 800px vs 794px(0.75%)与dpi 96.8 vs 95(<2%) ,
因此肉眼不可辨;但缩放 与水印 两处会让三者产生真实差异(D.3)。
D.0 三种方式的映射(宿主页 static-toolbox/ofd/index.html)
| 选项文案 | 单选值 | 宿主页入口 | SDK 侧入口 |
|---|---|---|---|
| 浏览器打印 | a |
printOfdDocument()(取 previewContent.innerHTML) |
(无,宿主自实现) |
| 阅读器打印 | b |
tpPrintByApi() |
OFDPrintApi(内部 Print_api).startPrint() |
| 阅读器打印(完整版) | c |
tpPrintByReader() |
printOFD()(内部 RA)→ S.Print.open() |
D.1 对照表(源码级)
| a 浏览器打印 | b 阅读器打印 | c 阅读器打印(完整版) | |
|---|---|---|---|
| 打印对象来源 | tpParseOfd(file) |
openOFDBaseViewer(...) 回调回传的 core |
完整阅读器内部 X.ofdCore |
| 渲染调用 | parser_x.renderOfd(0, 800*zoom/100) |
Print_api.prepare() → ofdRender.renderPage() |
S.Print.prepare() → ofdRender.renderPage() |
| 渲染 dpi | dpi = 宽度/页宽(mm)*25.4 ≈ 96.8 |
785/页宽(mm)*25.4 ≈ 95 |
785/页宽(mm)*25.4 ≈ 95 |
| 每页盒子 | 800 × 1131 px(随缩放) | 794 × 1123 px | 794 × 1123 px |
| 水印 | 有(renderOfd 内 X.waterText && setWaterText) |
无(打印类从不 setWaterText) |
无(同左) |
| 输出方式 | iframe + contentWindow.print() |
window.open("打印窗口") → body.appendChild → focus/print/close |
window.open("") → 同左(逐字相同) |
| 打印时机 | 立即 | setTimeout(100) → prepare() → setTimeout(200) |
同左 |
| 进度 UI | 宿主页 #tp-progress(宿主自己驱动) |
OFDPrintApi(onProgress) 回调 → 宿主页 #tp-progress |
SDK 自带 #printServiceDialog(<progress> + .relative-progress + #printCancel) |
| 与屏幕缩放的关系 | 相关 (宽度 = 800 × 缩放%) |
无关(恒 A4) | 无关(恒 A4) |
D.2 逐字相同的证据(parser_x.js)
b 与 c 的每页渲染语句完全一致(文件中各出现 1 次,共 2 处):
1:1:examples/parser_x.js
E=785/I.core.pageWidth(0,C)*25.4, I.ofdRender.setDpi(E),
(D=document.createElement("canvas")).style.width="794px", D.style.height="1123px",
(i=document.createElement("div")).style.width="794px", i.style.height="1123px",
i.style.position="relative", i.appendChild(D), A.next=15, I.ofdRender.renderPage(i,0,C)
输出段也一致,区别只有窗口名:
1:1:examples/parser_x.js
var g,B=window.open("打印窗口","_blank"), Q=B.document.body, ...B.appendChild(E)... B.focus(), B.print(), B.close()
1:1:examples/parser_x.js
var I,g=window.open("","_blank"), Q=g.document.body, ...g.appendChild(C)... g.focus(), g.print(), g.close()
对照:屏幕 阅读器走的是随缩放变化的一条 ------
this.coreHelper.setDpi(96*this._currentScale);而打印路径没有
currentScale参与,恒为785/页宽*25.4------ 这正是"打印与屏幕缩放无关"的根因。
D.3 两处「看着一样、其实不同」的隐患
D.3.1 缩放:方式 a 会跟着屏幕缩放跑
| 缩放 | a 的打印页宽 | b / c 的打印页宽 |
|---|---|---|
| 100%(默认) | 800px(≈ A4 ⇒ 与 b/c 几乎重合) |
794px |
| 50% | 400px(被打印 CSS 的 max-width:100% 拉满整页) |
794px(不变) |
- a 打印的是屏幕 DOM (
previewContent.innerHTML塞进 iframe),页宽 =TP_BASE_WIDTH(800) × currentZoom/100; - b / c 恒定 A4 794×1123,与屏幕翻页 / 缩放状态完全无关;
- 默认 100% 时
800 ≈ 794,所以 a 与 b/c 几乎重合 ------ 这正是"三种方式看起来一样"的直接原因; - 一旦调到 50%,a 打印的是 400px 宽的页面再被拉满,字会变糊、分页可能与 b/c 不一致。
- ⇒ a 的观感是"当前缩放"的函数,b/c 是常量。
D.3.2 水印:只有方式 a 会带上(建议浏览器实测确认)
- 屏幕渲染路径里水印是条件分支:
B.waterText ? C.setWaterText(B.waterText) : X.waterText && C.setWaterText(X.waterText); - a 直接打印这份 DOM ⇒ 若存在"试读"水印,会被一并打印;
- b / c 的打印类构造后只
setDpi(...),全文件范围内不含任何setWaterText调用 ⇒ 打印产物无水印; - 若实测文档本身无水印,则三者看不出差别(这也是本次"完全一致"的可能原因之一)。
⚠️ 顺带修正 4.(d) 中"两个打印模块各自
new Ofd_Core_Helper(96)/(40)"的表述:
(40)只是S.Print的构造默认值 ,prepare()循环内立刻用setDpi(785/pageWidth*25.4 ≈ 95)覆盖;所以两条打印路径的实际 dpi 都在 95 左右,并非 c 降质到 40dpi。
D.4 关于"是否真实还原"
三者都不是格式转换 (不是"OFD 转 PDF 再打印"),而是"浏览器渲染 + 打印对话框 ",
所以不存在"真假 / 保真度"之争:它们是同一套浏览器渲染的同源输出 。
官方站点的 ofd2pdf 也是同一思路,差别仅在于它把渲染结果塞进 iframe 由 contentWindow.print() 输出。
D.5 复现
见 附录 B 命令 17 (统计两处 dpi 公式、794px/1123px 各 4 次、两个 window.open 各 1 次、
两个打印类内部均无 setWaterText)。