适用版本:福昕 PDF SDK for Web 11.1(PDFViewCtrl / UIExtension 两种初始化模式均适用)
本文所有接口写法与行为均经 11.1 实机验证(含保存回读与 PDF 字节级核对)。
1. 需求与总体思路
需求:在线预览 PDF 时,通过代码创建「带各种属性的书签」,并把书签动作设置为:
- GoToR(远程跳转):点击书签打开另一个 PDF 的指定页面位置;
- Launch(启动外部文件):点击书签打开一个非 PDF 文件(.txt / .docx / 任意文件)。
WebSDK 的书签能力分两层,选错层是此类需求最常见的失败原因:
| 层 |
入口 |
能力 |
是否支持 GoToR/Launch |
| UI 书签面板层 |
pdfui.getBookmarkDataService().addBookmark(options) |
仅文档内跳页(TypeGoto),AddBookmarkOptions = {color?, destId?, destination?, relationship, style?, title?},没有 action 字段 |
❌ 不支持 |
| 核心书签 API 层 |
await (await docRender.getPDFDoc()).getBookmarkAPI() |
书签树增删改、任意 PDF 动作(TypeGoto / TypeGoToR / TypeGoToE / TypeLaunch / TypeURI / TypeJavaScript / …)、属性、保存 |
✅ 必须用这层 |
两层可混用:核心层创建的带动作书签会正常显示在 UI 书签面板里。
2. 核心访问路径(11.1 实测)
// —— 裸 PDFViewCtrl 模式 ——
const docRender = pdfViewer.getPDFDocRender(); // 同步返回
const pdfDoc = await docRender.getPDFDoc(); // 主线程核心 PDFDoc
const bapi = await pdfDoc.getBookmarkAPI(); // ★ 文档级书签 API
// —— UIExtension(PDFUI)模式 ——
const viewer = await pdfui.getPDFViewer();
const pdfDoc = await viewer.getPDFDocRender().getPDFDoc();
const bapi = await pdfDoc.getBookmarkAPI();
注意:不要依赖 pdfDoc.getRootBookmark() / createRootBookmark() —— 11.1 主线程上这两个方法不存在(仅内部链路使用)。一切通过 getBookmarkAPI() 完成。
3. 最小完整代码
3.1 创建 GoToR 书签(打开另一个 PDF 的指定页)
async function createGoToRBookmark(bapi) {
// 1) 插入书签(destination 必填;插入到根时 relationship 只能 0=FIRST_CHILD 或 1=LAST_CHILD)
const id = await bapi.insertBookmarkTree({
relationship: 0, // 0=根下第一个孩子
title: '打开另一PDF:target.pdf 第3页',
destination: { pageIndex: 0, zoomMode: 'ZoomFitPage' } // 占位目标,稍后被 GoToR 覆盖
});
// 2) 设置属性:颜色走 setProperties(支持 '#RRGGBB'),粗体 style=2(1=斜体,可组合)
await bapi.setProperties(id, { color: '#FF0000', style: 2 });
// 3) 关键:插入时 SDK 自动挂了一个 TypeGoto 动作,必须先移除,
// 否则新动作会被挂成旧动作的子动作(/Next)而不是替换
await bapi.removeAction(id);
// 4) 写入 GoToR 动作:打开 target.pdf 第 3 页(pageIndex 为目标文档的 0 基页码)
await bapi.setAction(id, {
type: 'TypeGoToR',
destination: { pageIndex: 2, zoomMode: 'ZoomFitPage' }, // ZoomXYZ 时可加 left/top/zoomFactor
fileSpec: { fileName: 'target.pdf' }, // 目标 PDF 路径/URL;不要传 description
newWindowFlag: 1 // 0=False, 1=True, 2=None
});
// 5) 回读确认(可选)
const action = await bapi.getAction(id);
console.log(action.exportToJson());
// → { type:'TypeGoToR', destination:{pageIndex:2, zoomMode:'ZoomFitPage'},
// newWindowFlag:1, fileSpec:{fileName:'target.pdf'} }
return id;
}
保存后 PDF 内部对象(字节级核对一致):
书签对象: <</A 28 0 R /C[1 0 0] /Title(…) /Parent … /Count …>>
动作对象: <</NewWindow true /S/GoToR /D[2/Fit] /F 29 0 R /Type/Action>>
FileSpec: <</UF(target.pdf) /F(target.pdf) /Type/Filespec>>
3.2 创建 Launch 书签(打开非 PDF 文件)
async function createLaunchBookmark(bapi) {
const id = await bapi.insertBookmarkTree({
destId: undefined, relationship: 1, // 1=LAST_CHILD,插到根末尾
title: '打开附件:note.txt',
destination: { pageIndex: 0, zoomMode: 'ZoomFitPage' }
});
await bapi.setProperties(id, { color: '#0000FF', style: 1 }); // 1=斜体
await bapi.removeAction(id); // 同样先清自动生成的 TypeGoto
await bapi.setAction(id, {
type: 'TypeLaunch',
fileSpec: { fileName: 'note.txt' } // 非 PDF 文件(.txt/.docx/…均可)
});
return id;
}
保存后 PDF 内部对象:<</S/Launch /F 32 0 R /Type/Action>> + <</UF(note.txt) /F(note.txt) /Type/Filespec>>。
3.3 创建多级书签树(带各种属性)
// 根级第 1 个:FIRST_CHILD;之后逐个用 destId=前一个 + NEXT_SIBLING(3) 串成兄弟链
const id1 = await bapi.insertBookmarkTree({
relationship: 0, title: '1. 打开另一PDF:target.pdf 第3页',
destination: { pageIndex: 0, zoomMode: 'ZoomFitPage' }, style: 2 });
await bapi.setProperties(id1, { color: '#FF0000' });
const id2 = await bapi.insertBookmarkTree({
destId: id1, relationship: 3, // 3=NEXT_SIBLING(id1 的下一个兄弟)
title: '2. 打开非PDF文件:note.txt',
destination: { pageIndex: 0, zoomMode: 'ZoomFitPage' }, style: 1 });
await bapi.setProperties(id2, { color: '#0000FF' });
// 二级子书签:destId=父id + FIRST_CHILD(0)
const id11 = await bapi.insertBookmarkTree({
destId: id1, relationship: 0, title: '1.1 子书签(文档内第2页)',
destination: { pageIndex: 1, zoomMode: 'ZoomFitPage' } });
// 整树回读:[{id, deep, title, color, isBold, isItalic, children:[…]}]
const tree = await pdfDoc.getBookmarksJson();
relationship 全表:0=FIRST_CHILD, 1=LAST_CHILD, 2=PREVIOUS_SIBLING, 3=NEXT_SIBLING, 4=FIRST_SIBLING, 5=LAST_SIBLING(destId 省略即插入根时只允许 0/1)。
3.4 保存
const data = await pdfDoc.getFile({ flags: 0, fileName: 'output.pdf' }); // 返回 Blob
// 例:上传回服务端
const bytes = new Uint8Array(await data.arrayBuffer());
await fetch('/save?name=output.pdf', { method: 'POST', body: bytes });
保存 → 重新打开后,getAction() 回读的动作类型/目标页/文件名与写入完全一致(实机两轮验证)。
4. 必须注意的 6 个坑(全部实测踩过)
- UI 面板
addBookmark 做不到任意动作:AddBookmarkOptions 无 action 字段,创建的书签固定为 TypeGoto(还会自动带当前视口位置)。要 GoToR/Launch 必须走 getBookmarkAPI()。
- 先
removeAction 再 setAction:插入书签时 SDK 自动挂 TypeGoto;直接 setAction 会把新动作追加为子动作(/Next)而非替换,回读仍是 TypeGoto。
- 插入根的 relationship 只能 0/1:否则抛
When inserting a bookmark into the root, the relationship must be either "BookmarkPosition.e_PosFirstChild" or "BookmarkPosition.e_PosLastChild". 根级兄弟用 destId=前一个, relationship:3。
- 颜色用
setProperties 传:insertBookmarkTree options 里的 color 需要 int,传 '#FF0000' 字符串会落成黑色;setProperties(id,{color:'#FF0000'}) 内部做了转换,字符串最稳妥。style:1=斜体, 2=粗体(可按位组合)。
- GoToR 的 fileSpec 不要传
description:11.1 会把 JS 字符串直接传给底层 FileSpec.SetDescription 触发 embind 错误 Cannot pass "…" as a String。另外该 API 写入的 GoToR FileSpec 会附带一个空嵌入式文件流(/EF,SDK 实现行为),阅读器按 /F 路径解析目标,不影响动作合法性。
pageIndex 是目标文档的 0 基页码:destination:{pageIndex:2} 即目标文件第 3 页;校验目标文档页数是应用层职责(SDK 写入时不校验目标文件)。
5. 浏览器端执行行为的现实约束
- GoToR:动作数据合法且已写入 PDF,桌面阅读器(福昕桌面版/Acrobat)可直接执行。Web 端点击书签后的行为取决于阅读器实现与应用集成——浏览器沙箱内”打开另一本地文件”天然受限。eCTD 等 B/S 场景建议:应用层监听书签点击/动作执行,按 fileSpec 自行加载对应文档(WebSDK 提供
ActionCallbackManager.setEmbeddedGotoCallback 一类回调出口,11.1 运行库实测存在)。
- Launch:普通 http 页面无法静默启动本地程序打开非 PDF 文件,浏览器会阻止或转为下载,且通常需要真实用户点按。动作合法性体现在 PDF 内容层(写入正确、可回读、桌面端可执行)。Web 应用更常见的等效做法是:监听点击后由应用层处理(下载、新窗口、或调用在线预览组件打开 .docx 等)。
- UI 书签面板与核心层共用同一书签存储(
BookmarkDataService.reloadBookmarkChildren() 可刷新面板数据),核心层创建的书签(标题/颜色/样式/层级)在面板中可见可点;BookmarkDataService.performAction(id) 可编程触发动作执行(实测调用无异常;浏览器对跨文档/外部文件的实际打开受安全策略约束,见上两条)。
6. 一页速查(API 速览)
| 目的 |
调用 |
| 拿书签 API |
(await viewer.getPDFDocRender().getPDFDoc()).getBookmarkAPI() |
| 插入书签 |
bapi.insertBookmarkTree({destId?, relationship, title, destination, style?}) → Promise<id> |
| 改属性 |
bapi.setProperties(id, {title?, color?, style?}) |
| 读属性 |
bapi.getProperties(id) |
| 清动作 |
bapi.removeAction(id) |
| 设 GoToR |
bapi.setAction(id, {type:'TypeGoToR', destination:{pageIndex, zoomMode}, fileSpec:{fileName}, newWindowFlag}) |
| 设 Launch |
bapi.setAction(id, {type:'TypeLaunch', fileSpec:{fileName}}) |
| 读动作 |
bapi.getAction(id) → 对象,.exportToJson() 序列化 |
| 整树回读 |
pdfDoc.getBookmarksJson() |
| 删书签/移动 |
bapi.remove(id) / bapi.moveTo({srcId, destId, relationship}) |
| 保存 |
pdfDoc.getFile({flags:0, fileName}) → Blob |
(以上均为 WebSDK 11.1 实机验证过的写法;getBookmarkAPI/insertBookmarkTree/setAction/... 未出现在公开 API 文档与声明文件中,属于运行库实机验证过的可用接口,跨大版本升级时建议做回归验证。)