适用产品:Foxit PDF SDK for Web(WebPDF Viewer / PDFViewCtrl / UIExtension)
验证版本:11.1.0(文中接口在 8.x/9.x 文档线均已有对应写法,跨版本使用请以所用版本官方文档为准)
适用场景:在线教育/课件展示、章节授权阅读、审阅分工、按章节下发内容等”只允许查看指定页码区间”的场景
一、需求与结论
需求:PDF 全文共 N 页,希望初始化时仅展示/渲染指定页码区间(如第 1-10 页),并且区间可以在运行时动态修改(如讲师授课推进到第 3 章,把展示区间切到第 21-30 页;未授权页显示占位提示)。
结论:Web SDK 没有”指定逻辑页码加载范围”的打开参数(不存在 loadPageRange:[1,10] 之类的选项),但可以通过 customs.PageCustomRender 自定义渲染钩子 实现按页控制”是否渲染”,配合一个闭包持有的可变页码集合实现运行时动态切换。该方案经 WebSDK 11.1 真机自动化验证:初始化限制、三段区间动态切换、区间回切恢复、50 次连续切换压测(0 失败)、跳页+放行联动全部通过。
同时,SDK 原生的按需加载机制(openPDFByHttpRangeRequest + HTTP Range 分块下载)天然只下载当前可见页所需的字节,与本方案叠加使用可以在”展示范围受限”的同时获得”传输量受限”的效果——但要注意:Range 按需加载只是传输优化,内存中最终仍可解析全文,不能作为安全边界。真正的”禁止查看”必须落在渲染控制(本文方案)或服务端文档切分上。
二、核心原理
PDFViewer 构造参数的 customs.PageCustomRender 是一个工厂函数,返回的类会被 SDK 对每个页面实例化一次:
- 构造参数:
eCustom(页面自定义容器 DOM 节点,即 .fv__pdf-custom-page-container)、pdfPageRender(PDFPageRender 实例);
render():每页进入渲染流程时被调用。返回 false 则 SDK 跳过该页内容渲染(页面只剩容器框架,无内容 canvas);返回 true/undefined/Promise 正常渲染;
destroy():页面被回收/文档关闭时调用,用于清理。
于是”限制可看页码区间”就变成:在 render() 里查询当前页码,不在集合内就返回 false(可以顺手写入占位提示 DOM)。而”运行时动态切换”则是:把允许渲染的页码集合放在工厂闭包里(实例化时无法更换工厂,但闭包变量可变),切换区间时更新集合并触发重渲染。
名称辨析:customs.PDFPageRendering(覆盖类,示例目录 /examples/PDFViewCtrl/override-rendering)是”加载占位 UI”钩子(rendering/rendered/destroy 生命周期),没有”返回 false 跳过渲染”的能力,不要与 PageCustomRender(示例目录 /examples/PDFViewCtrl/load-before-rendering)混淆。
三、完整实现代码(可直接落地)
// ========== 1. 闭包持有"当前允许渲染的页码集合"(0 基页码) ==========
var allowedPages = new Set();
function fillRange(from, to) { // 1 基闭区间,如 (1, 10) 表示第 1~10 页
allowedPages.clear();
for (var i = from - 1; i <= to - 1; i++) allowedPages.add(i);
}
fillRange(1, 10); // 初始:仅第 1-10 页
// 曾经进入过允许区间的页码(用于清扫残留内容,见第 4 节要点 3)
var everAllowed = new Set();
function markEverAllowed() { allowedPages.forEach(function (i) { everAllowed.add(i); }); }
var pdfViewer = new PDFViewCtrl.PDFViewer({
libPath: './lib',
jr: { licenseSN: licenseSN, licenseKey: licenseKey },
customs: {
PageCustomRender: (function () {
function CustomPageCustomRender(eCustom, pdfPageRender) {
this.eCustom = eCustom;
this.pdfPageRender = pdfPageRender;
}
CustomPageCustomRender.prototype.render = function () {
var self = this;
return self.pdfPageRender.getPDFPage().then(function (page) {
var idx = page.getIndex(); // 0 基页码
if (!allowedPages.has(idx)) {
// 不在区间内:显示占位提示并跳过该页内容渲染
self.eCustom.innerHTML =
'<div class="locked-page">第 ' + (idx + 1) + ' 页不在当前展示区间内</div>';
return false; // ★ 核心:跳过渲染
}
// 在区间内:清掉历史占位,交还 SDK 正常渲染
if (self.eCustom.innerHTML !== '') self.eCustom.innerHTML = '';
});
};
CustomPageCustomRender.prototype.destroy = function () {
this.eCustom.innerHTML = '';
};
return CustomPageCustomRender;
})(),
// ★ 关键点 1:滚动宿主 = #pdf-viewer 的父元素(见第 4 节要点 1)
ScrollWrap: PDFViewCtrl.CustomScrollWrap
}
});
pdfViewer.init('#pdf-viewer');
// ========== 2. 打开文档(HTTP Range 按需加载,可选但推荐) ==========
pdfViewer.openPDFByHttpRangeRequest(
{ range: { url: '/docs/course.pdf' } }, // 服务器需支持 Range/206
{ fileName: 'course.pdf', isRenderOnDocLoaded: true }
);
// ========== 3. 运行时动态切换展示区间 ==========
// 清扫"曾允许、现已锁定"页面的残留内容层
function sweepLockedPages(total) {
for (var i = 0; i < total; i++) {
if (allowedPages.has(i) || !everAllowed.has(i)) continue;
var lay = document.querySelector('#pdf-viewer .fv__pdf-page-layout[data-index="' + i + '"]');
if (!lay) continue;
var pc = lay.querySelector('.fv__pdf-page-container');
if (pc && pc.childNodes.length) pc.innerHTML = ''; // 清残留 canvas
var custom = lay.querySelector('.fv__pdf-custom-page-container');
if (custom && !custom.querySelector('.locked-page')) {
custom.innerHTML = '<div class="locked-page">第 ' + (i + 1) + ' 页不在当前展示区间内</div>';
}
}
}
function setVisibleRange(from, to) { // 1 基闭区间
markEverAllowed(); // 记录旧区间
fillRange(from, to);
markEverAllowed(); // 记录新区间
sweepLockedPages(/* 文档总页数 */);
var doc = pdfViewer.getPDFDocRender(); // 注意:PDFViewCtrl 下同步返回,不是 Promise
doc.goToPage(from - 1);
// ★ 关键点 2:对新区间逐页显式强制重渲染(见第 4 节要点 2)
var seq = Promise.resolve();
for (var i = from; i <= to; i++) {
(function (idx0) {
seq = seq.then(function () {
var pr = doc.getPDFPageRender(idx0);
return pr ? pr.render().catch(function () {}) : Promise.resolve();
});
})(i - 1);
}
return seq.then(function () {
return doc.goToPage(from - 1); // 回到区间首页
});
}
// 讲师授课到第 3 章(第 21-30 页):
setVisibleRange(21, 30);
页面骨架(注意滚动容器关系,与官方 load-before-rendering 示例一致):
<style>
/* 滚动容器是 #pdf-viewer 的父元素;配合 CustomScrollWrap 使用 */
#viewer-scroll { width: 100%; height: 500px; overflow: auto; }
.locked-page { display:flex; align-items:center; justify-content:center;
height:100%; color:#9ca3af; font-size:15px; }
</style>
<div id="viewer-scroll"><div id="pdf-viewer"></div></div>
四、四个必须注意的关键点(踩坑实录)
要点 1:自行包裹滚动容器时,必须传 customs.ScrollWrap = PDFViewCtrl.CustomScrollWrap
缺省的 ScrollWrap 监听的是 window/document 滚动。如果采用”外层 div(overflow:auto)包住 #pdf-viewer“的常见布局而不传此项,外层容器滚动时 SDK 完全感知不到,除首屏页外其它页面永远不会触发渲染(表现为大片空白页)。PDFViewCtrl.CustomScrollWrap 会把滚动宿主设为 #pdf-viewer 的父元素,与官方示例一致。(UIExtension 全功能模式下使用其内置 UI 布局时无此问题。)
要点 2:曾被锁定(render 返回 false)的页,重新放行后必须逐页显式调用 PDFPageRender.render()
页面一旦因 PageCustomRender.render() 返回 false 被跳过,之后 goToPage、redraw(true) 等常规渲染链路不会重新调用该页的 PageCustomRender.render()(实测逐页 goToPage 无法恢复)。必须对新区间内每一页显式调用 doc.getPDFPageRender(idx).render() 强制重渲染(见上文代码)。否则会出现”切回旧区间后页面不恢复”的问题。
要点 3:锁定前已渲染过的页面,切换区间后要清扫残留内容
被锁定页在锁定前若已渲染出内容,切区间后其 canvas 仍会残留在 .fv__pdf-page-container 中。需要维护 everAllowed(曾经允许过的页)集合,切换后对”曾允许、现已锁定”的页面清除 canvas 并补占位提示。注意不要把新区间内”尚未轮到渲染”的页误判为锁定页(这也是需要 everAllowed 而不是直接全量清扫的原因)。
要点 4:这是”渲染层”控制,不等于内容安全
- 该方案只控制页面内容是否渲染,文档逻辑上所有页都存在:
getCurrentPageIndex、缩略图、书签、文本搜索等仍能感知全部页面。若要给用户”完全不可见”的体验,需再屏蔽缩略图/书签/导航/搜索入口。
- 若业务要求严格不可见 + 防下载(如仅授权章节),推荐服务端方案:用福昕 GSDK 预先把原 PDF 按章节切分为子 PDF,Web 端按需打开对应子文档;或对 PDF 加密并按权限下发。渲染层控制适合”引导视线/分步展示”类场景,不适合作为唯一的安全手段。
openPDFByHttpRangeRequest 的按需下载只优化传输(chunkSize 默认 128KB,按 Range 分块取),不能防止用户拿到完整文件——服务端仍需鉴权控制文件访问。
五、可用 API 速查(WebSDK 11.1 验证)
| API | 说明 |
|---|
customs.PageCustomRender | 工厂函数返回类;render() 返回 false 跳过该页渲染;构造参数 eCustom/pdfPageRender |
customs.ScrollWrap: PDFViewCtrl.CustomScrollWrap | 滚动宿主改为 #pdf-viewer 父元素;自定滚动布局必传 |
pdfPageRender.getPDFPage() → page.getIndex() | 在 render() 中取当前页 0 基页码 |
pdfViewer.getPDFDocRender() | 取 PDFDocRender(PDFViewCtrl 下同步返回,非 Promise) |
doc.goToPage(index) / doc.getCurrentPageIndex() | 跳页 / 读当前页(0 基) |
doc.getPDFPageRender(idx).render() | 对指定页强制重渲染(放行已锁定页的关键调用;非文档化 API,升级版本需回归验证) |
pdfViewer.redraw(force) | 重绘当前可见页(force=true 同时刷新缓存) |
openPDFByHttpRangeRequest({range:{url}}, {fileName, isRenderOnDocLoaded}) | HTTP Range 按需加载打开远程文档;服务器需支持 Range/206 |
ViewerEvents.renderPageSuccess / beforeRenderPage / renderFileSuccess | 渲染成功/渲染前/文档渲染完成事件(viewer.eventEmitter.on) |
注意:Web 端没有 Android/iOS 那种 onPageChanged(old, cur) 页面变化事件;感知翻页可用 renderPageSuccess 事件 + getCurrentPageIndex() 组合,或在跳页动作处主动读取。
六、场景落地建议
| 需求 | 推荐做法 |
|---|
| 初始化只展示第 1-10 页 | 初始 fillRange(1,10) + PageCustomRender,打开后视口内只渲染区间内页 |
| 授课进度切换章节(如跳第 21-30 页) | setVisibleRange(21, 30):更新集合 + 清扫 + 逐页 PDFPageRender.render() + goToPage 回首页 |
| 监听翻页进度/同步 UI | renderPageSuccess 事件里读 getCurrentPageIndex() 更新进度条 |
| 锁定页占位体验 | 在 render() 中向 eCustom 写占位 DOM,比留白友好 |
| 严格不可见 + 防下载 | 服务端 GSDK 切分子 PDF / 文档加密+权限下发;渲染层控制仅作辅助 |
| 大文件传输优化 | openPDFByHttpRangeRequest(Range 分块,chunkSize 默认 128KB),服务器配好 Accept-Ranges/206 |
七、版本与兼容性说明
- 文中实现基于 WebSDK 11.1.0 的
PDFViewCtrl 模块验证;PageCustomRender 在 8.1 Developer Guide 与官方示例(load-before-rendering)中已存在,属稳定能力,但 PDFPageRender.render() 为非文档化 API(11.1 API 参考的 PDFPageRender 类页面未列出),升级 SDK 版本时请对本功能做回归验证。
- UIExtension(全功能 UI)模式:完整用法见下方「UIExtension(PDFUI)模式的差异」,同样验证通过(6 项全过,压测 50 次 0 失败)。
- 完整可运行验证工程(含自动化 6 项验证脚本)见内部档案
IS-006-web-pagerange-customrender。
八、UIExtension(PDFUI)模式的差异与完整示例
UIExtension 全功能 UI 模式下,方案同样适用且验证通过(WebSDK 11.1.0 / index-pdfui.html / run-repro-pdfui.mjs:初始化限 1-10 页、三段区间切换、区间回切恢复、50 次连续切换 0 失败、跳页+放行联动 全部 ✅)。与裸 PDFViewer 模式有以下关键差异:
| 差异点 | 裸 PDFViewer | UIExtension.PDFUI |
|---|
customs 传入层级 | new PDFViewer({ customs: {...} }) | new PDFUI({ viewerOptions: { customs: {...} } }) |
ScrollWrap | 必须显式传 PDFViewCtrl.CustomScrollWrap(自定滚动布局时需要) | 不要传 —— PDFUI 的 constructPDFViewer 会自动用 UI 容器创建滚动宿主(lib 取证:i.ScrollWrap||(i.ScrollWrap=Gx.Z.create(this.wrapperElement))),自带内置滚动布局 |
| 打开文档 | pdfViewer.openPDFByHttpRangeRequest(...) | pdfui.openPDFByHttpRangeRequest(...)(直接在 PDFUI 实例上调用,内部转发给 viewer) |
| 取 PDFViewer / PDFDocRender | pdfViewer.getPDFDocRender()(同步) | await pdfui.getPDFViewer()(Promise)→ viewer.getPDFDocRender()(同步) |
| 事件订阅 | pdfViewer.eventEmitter.on(ViewerEvents.xxx, fn) | 同(拿到 viewer 实例后仍用 viewer.eventEmitter),另有 pdfui.addViewerEventListener(type, fn) 便捷接口 |
| 加载文件 | lib/PDFViewCtrl.full.js + PDFViewCtrl.css | lib/UIExtension.full.js + UIExtension.css,须加载 uix-addons/allInOne.js(或 mobile 版)addons |
PageCustomRender 钩子本身、render() 返回 false 跳页机制、放行后逐页 PDFPageRender.render() 恢复、everAllowed 清扫逻辑,在两种模式下完全一致(UIExtension lib 中同样是 new(customs.PageCustomRender||默认)(.fv__pdf-custom-page-container, 页渲染器) 实例化)。PDFUI 模式初始化代码:
var pdfui = new UIExtension.PDFUI({
viewerOptions: {
libPath: './lib',
jr: { licenseSN: licenseSN, licenseKey: licenseKey },
customs: {
PageCustomRender: (function () {
function CustomPageCustomRender(eCustom, pdfPageRender) {
this.eCustom = eCustom;
this.pdfPageRender = pdfPageRender;
}
CustomPageCustomRender.prototype.render = function () {
return this.pdfPageRender.getPDFPage().then(function (page) {
var idx = page.getIndex();
if (!allowedPages.has(idx)) { // allowedPages 同为闭包可变集合
this.eCustom.innerHTML = '<div class="locked-page">第 ' + (idx + 1) + ' 页不在当前展示区间内</div>';
return false;
}
});
};
CustomPageCustomRender.prototype.destroy = function () { this.eCustom.innerHTML = ''; };
return CustomPageCustomRender;
})()
// 注意:不传 ScrollWrap,PDFUI 会自动创建
}
},
renderTo: '#pdf-ui',
appearance: UIExtension.appearances.adaptive,
fragments: [],
addons: UIExtension.PDFViewCtrl.DeviceInfo.isMobile ?
'./lib/uix-addons/allInOne.mobile.js' : './lib/uix-addons/allInOne.js'
});
// 打开文档:pdfui.openPDFByHttpRangeRequest(...)(或 openPDFByFile)
// 动态切换:await pdfui.getPDFViewer() → viewer.getPDFDocRender() → 与裸 PDFViewer 相同
本文基于 Foxit PDF SDK for Web 11.1.0 实测验证编写(2026-09)。示例中的 licenseSN/licenseKey 请替换为你自己的授权。