parser_x 商业 OFD SDK 完整解析笔记

文章目录

  • [附录 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.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 复现)

摘要: 本文基于对 parser_x.jsparser_helper_x.jsparser_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 里抠出来的副本,字节完全一致;保留它只为独立验证与离线查看

样例文件的下载地址:https://github.com/zhiyuan411/ofd.js-free

它是纯前端库 ,三种输出形态可选,不需要后端

  • ✅ 直接产出可插入页面的 DOM(SVG / Canvas),用法与本仓库 ofd.js-free 基本一致
  • ✅ 也产出结构化对象模型,可自行二次渲染
  • ✅ 自带可选阅读器组件,但有两档 :简版 OfdBaseViewer(纯滚动,官方页面用的就是它 ,无工具栏 / 无打印按钮)
    与完整版 OfdViewerPDF.js viewer 的裁剪版 ,工具栏 / 侧边栏 DOM 要调用方自备 ,有缩略图 · 目录 · 附件 · 签章面板,
    无查找、无演示模式、无书签 )。打印支持 ,分两条独立实现(OFDPrintApi / printOFD),打印产物不带水印
  • ❌ 库自身不含任何业务网络请求

注意区分「库」与「用它的页面」:官方主页面另有一个 XHR ,在页面加载时向

../../ScriptsMain/ofdkey.json{secret, digest} 再喂给库 ------ 详见后文授权分析(含真实渲染快照的实测)。

关于授权(后文详述)------ 四条最常被问到的结论:

  • secret / digest 都是服务端签发的凭证对 :生产 secret 39 字符 = 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(胶水工厂)。

由此得出两条硬结论:

  1. examples/parser_helper_x.wasm(3.3 MB)在运行期不会被请求 ------ 它的字节是从 parser_x.js 里抠出来的,保留只为独立校验
  2. parser_helper_x.js(80 KB 胶水)必须存在 ------ 它才是真正的运行时依赖
parser_helper_x.js ------ WASM 胶水

无任何 OFD 关键词,但满是 Emscripten 特征:WebAssemblyinstantiateWasmdynCall__emval_take_value/dev/shm/proc/self/fdEM_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)}

三个要点:

  1. readOFD(arrayBuffer, secret, digest) 是真正的解析入口(由 wasm 承担)
  2. 解析结果被包装成 Ofd_Core 实例 ,既存进全局 X.ofdCore,也作为 success(core) 的参数回传
  3. 支持 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\x04inflateunzipJSZippakofflate 0 齐全(unzipOfd / JSZip
XML 解析 DOMParserparseFromStringx2jsofd-xml-parser 0 齐全
OFD 标签字面量 ofd:Pageofd:TextObjectofd: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.* 方法。

结论:FileArrayBuffer 输入时,完全不引入 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 无关。

打印:支持 ,且有两条互不依赖 的实现(OFDPrintApiprintOFD),详见下文。

简版 OfdBaseVieweropenOFDBaseViewer / openOFDBaseViewerByBase64 / openMetaBaseViewer
能力 有无 证据
连续滚动渲染 / 页宽自适应 watchScroll(container, this._scrollUpdate);页宽 = container.offsetWidth - 20
水印 waterTextcoreHelper.setWaterText(...)
点击签章回调 signatureClickCallback
解析成功 / 失败回调 parserOFDSuccess / parserOFDFail
工具栏 / 页码框 / 缩放按钮 Base 版根本不构造 Toolbar
缩略图 / 签章 / 附件 / 目录面板 这些 Viewer 只在完整版的 openOFDViewer 路径里创建
打印按钮 Base 版不构造 Toolbar;官方 ofdreading 分支里连打印函数都没有 ------ 官方站点上"打印"只出现在另一个功能 ofd2pdf
完整版 OfdVieweropenOFDViewer = initOFDViewer)------ PDF.js viewer 裁剪版

判据:同一份代码里成串出现 PDF.js 的模块名 ------ GenericL10n / EventBus / PDFSidebar / OverlayManager /

PDFDocumentProperties / PDFCursorTools / Toolbar / SecondaryToolbar

关键前提 :它不生成任何 UI DOMToolbar 的构造函数是把元素一个个从 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.previouspreviouspageI.nextnextpagepreviousPage() / nextPage()
首页 / 末页 firstPage() / lastPage()"firstPage" / "lastPage"
跳页(页码输入框) I.pageNumberpagenumberchangedjumpPagesetPageNumber()
放大 / 缩小 / 比例选择 I.zoomInzoominI.zoomOutzoomoutI.scaleSelectscalechangedincreaseScale / decreaseScale / zoom(v)
旋转 ±90° setRotation(90) / setRotation(-90)"pageRotateCw"
滚动模式(竖 / 横 / 整页 / 连续) "scrollVertical" / "scrollHorizontal" / "scrollWrapped"scrollmodechangedsetScrollMode(mode)
单页 / 双页 "spreadNone"spreadmodechangedsetSpreadMode(mode)
打开本地文件 I.openFileopenfile;自动插入 #fileInputaccept=".ofd"
下载当前 OFD I.downloaddownload
打印 ✅(有前提) I.printprintprintOFD()前提见后文详述
缩略图面板 config.sidebar.thumbnailViewOfdThumbnailViewer;翻页时 scrollThumbnailIntoView()
目录 / 自定义标签面板 config.sidebar.outlineViewOfdCustomTagViewercustomtagclickjumpPage
附件面板(点击下载) config.sidebar.attachmentsViewOfdAttachmentViewerattachmentclick → Blob + a[download]
签章面板(可强制验签) config.sidebar.signaturesViewOfdSignatureViewersignatureViewerForceChecksignaturesCallback
签章属性弹层 config.signaturePropertiessignatureclickfindSignatureopen()
文档属性弹层 config.documentPropertiesPDFDocumentProperties"documentProperties"
侧边栏开关 / 有签章时自动展开 PDFSidebarsidebarForceOpengetSignatureCount(0)>0 才开)
光标工具(选择 / 手型) PDFCursorTools"cursorSelectTool" / "cursorHandTool"
签章拖拽缩放 "stampResize"
高亮 / 清除高亮 removeHighLight()
查找 / 搜索 findbar / findInput / viewFind / FindController 全部 0 命中
演示模式 config.toolbar.presentationModeButton.hidden=!0secondaryToolbar.presentationModeButton.hidden=!0 ------ 主动隐藏
书签 viewBookmarkeventName:null,且 viewBookmarkButton.hidden=!0 ------ 占位但无功能
全屏 fullscreen 0 命中
界面语言 取决于调用方 new GenericL10n("zh-CN") + l10n.translate(appContainer);SDK 内部只用 4 个 l10n 键(of_pagespage_of_pagesdocument_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() 可中途取消)。

打印流程(逐步):

  1. 从第 0 页循环到最后一页,每页单独渲染
    dpi = 785 / core.pageWidth(0, page) * 25.4,canvas 与外包 div 都固定 width:794px; height:1123px; position:relative
    → 即一张 A4 纸(A4 @96dpi = 794×1123 px);
  2. 每渲染完一页回调一次 renderProgress(n) ------ 进度可接;
  3. 全部渲染完 → 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 阅读器 渲染进隐藏小容器

→ 把 #ofd2pdfboxinnerHTML 拷进一个 iframeiframe.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)),
    而两个打印模块构造后从不调用 setWaterTextOfd_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 支持 waterTextmixBlendModefrontPages
  • 第一个参数是「文档索引」(数字,通常 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;...
  • width0/省略 → 自适应容器宽度
  • 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: () => {},
});

initOFDVieweropenOFDViewer 是同一个函数 (都指向内部的 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) 实测得到的。

类别 导出
解析 parseOfdDocumentparseOfdDocumentFromBase64getOFDPageCountpageSizegetTextObjectsgetCustomTaggetFileStreamgetAllAttachmentsgetAttachmentByIdgetAttachmentArrayBufferhashOfdDocumentfree
渲染 renderOfdrenderOfdByIndex
阅读器(全部是封装入口 openOFDBaseVieweropenOFDBaseViewerByBase64openMetaBaseVieweropenOFDViewerinitOFDVieweropenOFDopenFileopenFileBase64getEventBus
签章 / 打印 getToSignpackageSignedverifySignatureprintOFDOFDPrintApi
其它 ajaxUtils

没有导出的(最容易踩的坑)

名字 状态 正确做法
OfdBaseViewer ❌ 未导出(包里是内部变量 n.OfdBaseViewer openOFDBaseViewer({ container, width, ofd, ... })
EventBus ❌ 未导出(内部 F.EventBus getEventBus()
readOFD / readOFDByBase64 ❌ 未导出(属内部 wasm 绑定模块) parseOfdDocument
Ofd_Core / Ofd_Core_Helper ❌ 未导出 parseOfdDocumentsuccess(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 01block type is not 02RSA_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.js15 处 secret 全部形如 A.secretX.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 expiredmachine 1 → 实为 this machine is %s-endianlicen 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,11name),所有函数名不可读。

两条明显的结论:

  1. wasm 内搜不到 license / Authorization / X-License 任何字样 → 这不是标准 license 协议,而是自定义实现
  2. 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 错误分支 它究竟对应"过期"还是"任意第二阶段失败"

唯一站得住的结构性推断 :既然存在独立于 1000110002,凭证就必然携带结构化载荷

并被完整性保护 ------ 也就是说,它更像一枚带签名的许可证令牌(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/ofdreadingSec-Fetch-Site: same-originX-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.jsnode 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 仍 PASSleftTime 恒为 0,改早/改晚/置零/置满结果一致
10002 = 独立的"授权时间过期" 更正 :它是第二阶段(凭据不匹配)的兜底码 ;是否兼作 过期码无法区分
凭证"内自带有效期"(动态性表) ⚠️ 降级为未知leftTime10002 文案证明输出侧有这条通路,但触发条件不可观测
本节能确定 / 不能确定的事

能确定(可复现)

  1. secret 必须 39 字符 、且前 16 字符命中一份"已签发标识"集合 ,否则 10001
  2. 余下 23 字符与 digest 必须与签发时的值成对 ,否则 10002
  3. digest 必须是小写十六进制(wasm 自身不做归一化);
  4. 时间锚变化不改变任何返回值 ------ 系统时间无需与凭证"对齐"。

不能确定 :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>

注意事项:

  1. 必须用 HTTP 服务访问file:// 下 WASM 与脚本受同源策略限制无法加载
  2. parser_helper_x.js 必需 ------ 实测:只加载 parser_x.js 会在 iA() 处直接抛
    TypeError: t(...) is not a functiont = UMD 头取到的 window.parser_helper_x
  3. parser_helper_x.wasm 文件非必需 ------ wasm 二进制已内嵌在 parser_x.js 里(data URI),
    运行期不会去请求任何 .wasm 文件;那个文件只是从内嵌块抠出来用于校验的副本
  4. axios 可省略(本地 File / ArrayBuffer 输入不触发 URL 加载分支)
  5. 首次调用 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 helperparser_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 节点控制渲染 parseOfdDocumentrenderOfd(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.html
  • http://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.jsZIP 解包 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.jsparser_x.js这就是"按需下载"本身
  • 只在「前」出现(6 个 ):icon-func.pngicon-guide.pngicon-light.pngicon_recommend.pngtag_new.pngtuijian.png
    ← 全部是上传区 UI 图片,随 $("main").remove() 从 DOM 消失后,浏览器保存时也就不再收录它们

(附录 B 的命令 11 可一键复现这张差异清单。)

页面功能由路径名决定

1:2232:examples/parser-x-主页面-加载OFD后-…_files/frontfun.js 复制代码
var curryfun = location.pathname.split("/")[1];

frontfun.jscurryfun 把整站切成若干互不相关的分支(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/ 解析上溯到站点根)
方法 GETdataType: "json"
请求头 X-Product 146appconfig.softdata.productId
请求头 X-Version 4.5.9.3appconfig.softdata.version
请求头 X-Credits ba2ca4a08e8691fd47754ad09927f7f9appconfig.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'
    },

三个易混点澄清:

  1. ofdkey.json 只服务这两个 OFD 功能 ------ 全仓扫描:frontfun.js 里恰好出现 2 次 ,即 ofdreadingofd2pdf 各一次,别处再无引用。
  2. productinfo 与 OFD 无关 ------ 它(连同 MD5 签名 datasign 和硬编码盐 hUuPd20171206LuOnD)用在 html2voice 分支调 /api/v4/simplecrawler,别被名字误导。
  3. 三个 X-* 头是全站公共的/api/v4/simplecrawler、登录等接口都在用,不是为 OFD 定制的。

两个结构性事实(它们共同说明这里不是一个静态文件,而是一个签发入口):

  1. X-Product / X-Version / X-Credits 三个头让服务端有能力按产品 / 版本 / 配额返回不同凭证
  2. 实测两组样本的 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);
	}));
}

三个值得记下的点:

  1. 库是按需加载的parser_x.js(4.6 MB)只在用户真正选文件时才下载;两个快照的对比给出了三重印证:
    ① 脚本标签 ------ 「前」无、「后」有两个;② 资源目录 ------ 「前」不含这两个文件、「后」含;
    ③ 而 frontfun.jsloadjs 只在 readofd/renderofd 内部被调用。 读快照时注意:保存下来的 HTML 里 src 已被浏览器改写成本地相对路径

    ./parser-x-主页面-..._files/parser_helper_x.js),不是 线上的 /ScriptsMain/ofd/...

    线上真实 URL 只在上面的 loadjs(...) 参数里。

  2. 两只 <script> 并行注入 ,靠 Promise.all 等待 ------ 依赖顺序由 UMD 的执行期全局读取保证
    ,所以并行也能成立helper 先于 parser_x 完成执行即可)。
    严格说这里存在"并行竞态"的理论风险,但实测线上可用。
  3. 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 类是 hiddentext 类是 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.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)))}
附带收获: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.documentTypeError ------ 对外表现就是

「进度框走完 → 进度框消失 → 什么也不发生」 (偶尔看到标签页闪一下,就是被拦下的那次弹窗尝试)。

修复落在宿主页 侧(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 逐字取出:

  1. 工具栏按钮 → 事件总线Toolbar 构造时把按钮与事件名配对,点击即 dispatch

    1:1:examples/parser_x.js 复制代码
    this.buttons=[{element:I.previous,eventName:"previouspage"},...,{element:I.print,eventName:"print"},...]

    次级工具栏同理:{element:I.printButton,eventName:"print",close:!0}

  2. viewer 订阅 "print"

    1:1:examples/parser_x.js 复制代码
    this.eventBus._on("print",nA)
  3. nA()ofdPrint.open()

    1:1:examples/parser_x.js 复制代码
    function 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.documentTypeError,链条就此中断(静默失败

补充两点容易误判的地方:

  • 不是参数问题window.open("", "_blank")(空 URL)本身并无问题,时机 才是问题。同一段代码若在
    点击后 ~500ms 内触发是能成功的 ------ 这解释了为什么小文档 / 快机器上"偶发成功"。
  • window.close() 也需要"脚本开的窗口" :预开窗口方案顺带满足了这条,SDK 结尾的 g.close() 才不会被拒。

C.4 修复设计(宿主页侧,不动 SDK)

三条原则:

  1. 不改第三方压缩库parser_x.js 是 4.6MB 压缩产物,改动不可维护;
  2. 弹出窗口必须在点击手势内同步创建:这是绕过弹窗拦截的唯一可靠手段;
  3. 拦截点要落在 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() 才能真的拦住
祖先节点 + 事件委托 阅读器随模式切换会整体重建tpBuildFullViewerviewerHost.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 只往新窗口 bodyappendChild 页面 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 可迁移的通用结论

  1. 凡是"渲染完成后才弹窗"的 SDK 打印实现,宿主页都必须替它预开窗口
    Print_api.startPrint()(方式 b)与 S.Print.open()(方式 c)内部都是
    window.open(...)appendChild(页 div)print()close(),两条路径同样中招
    本次修复的本质是把已有桥接从"页面级打印按钮"补齐到**"阅读器内部按钮"**这个盲区。
  2. 不要拿 window.open 的返回值当成功判据 :被拦时它是 null,SDK 直接 null.document 抛错,表现为静默失败;
    宿主侧必须自带兜底提示与日志。
  3. 打印产物没有任何 SDK 侧样式与纸张设置@page / body 需宿主补;页尺寸恒为 794×1123(A4 @96dpi),
    与屏幕上的缩放 / 翻页状态无关。
  4. 改造第三方压缩库的正确姿势 :不改库,改"库的调用入口"。
    优先级:能截住事件流的位置(捕获阶段委托)> 包装其公开导出 > 修改压缩代码
    本次两个入口都在阅读器工具栏内、事件名同为 "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.496.8 785/页宽(mm)*25.495 785/页宽(mm)*25.495
每页盒子 800 × 1131 px(随缩放) 794 × 1123 px 794 × 1123 px
水印 有(renderOfdX.waterText && setWaterText 无(打印类从不 setWaterText 无(同左)
输出方式 iframe + contentWindow.print() window.open("打印窗口")body.appendChildfocus/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 打印的是屏幕 DOMpreviewContent.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)。

相关推荐
Felicia-侧听2 天前
OFD转PDF怎么转?在线转换教程与常见问题全解析
ofd·电子发票·ofd转pdf·ofd文件
Felicia-侧听7 天前
PDF怎么转换成OFD?在线PDF转OFD方法
pdf·pdf转换·ofd·电子发票·文件格式转换·pdf转ofd·ofd格式
not coder15 天前
我给鸿蒙原生 OFD 阅读器加了手写签批:手写笔防误触、矢量笔迹、批注追溯
华为·harmonyos·arkts·ofd
k4m7v2pz1 个月前
网吧广告样本留底与壳检测谬误修正:SHA256 指纹聚类与 VMProtect 并存结论
vmprotect·逆向分析·pe分析·哈希比对·样本溯源·网吧安全
雨田哥3 个月前
Qt Ironclad Reader (授权/加密/OFD签章/OFD验章/PDF/导出)
pdf·ofd·签章·验章·qt ofd·qt pdf·授权加密
递归4044 个月前
ofdkit-harmony 0.2.0 发布:鸿蒙原生 OFD 阅读库,已上架 ohpm
开源·harmonyos·arkts·ofd·ohpm
诸葛大钢铁4 个月前
OFD如何转Word?OFD转为可编辑Word的两种方法
经验分享·word·ofd·ofd转word
not coder4 个月前
HarmonyOS NEXT 原生OFD阅读库实测:纯ArkTS无依赖,完美适配电子发票与政务公文开发
华为·harmonyos·鸿蒙·ofd·政务
飞鸿踏雪(蓝屏选手)4 个月前
137 ≤ Chrome 主密钥获取研究
c++·chrome·windows·网络安全·逆向分析