ARTICLE DETAIL

建站实战干货

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

PDF.js实现受控在线预览:隐藏下载打印与全局水印方案

2026/9/20 7:37:07 拓冰建站 浏览量
PDF.js实现受控在线预览:隐藏下载打印与全局水印方案 简介《Web项目引入PDF.js并添加水印禁止下载》是面向Web前端开发者的PDF预览保护方案技术文档针对需要在项目中实现PDF在线预览、同时保护文档不被随意下载或打印的场景作者基于pdfjs-2.2.228版本梳理出一套从引入到加固的完整落地思路。资源为1个PDF文件压缩包约499KB图文结合覆盖PDF.js库下载引入、viewer动态预览配置、隐藏打开/下载/打印按钮、拦截CtrlP与CtrlS快捷键、禁用鼠标右键操作以及基于canvas添加全局水印等核心模块每个步骤均附有对应代码修改位置与配置说明。文档按7个章节递进展开可直接对照操作适合有一定JavaScript基础、正在开发内部文档系统或在线预览功能的技术人员参考。该资源已有2396人学习是一份针对性强、可快速落地的PDF.js实战参考资料。1. PDF.js 预览的边界下载、打印和水印都不受控如果只是给一个 PDF 链接浏览器自带的查看器会把下载、打印、打开文件这些入口全部暴露给用户你完全控制不了。我在普通 SSH 项目里被这个问题卡过几次最后固定用 PDF.js 2.2.228-dist 的 viewer 作为预览壳。选它不是因为它版本新而是它自带一个完整 viewer.html不需要 npm 构建能直接放进 WebRoot/script 这类传统目录结构viewer.js 里的工具栏、快捷键、渲染扩展点都可以改。这套方案最终能实现在线预览、动态切换 PDF、隐藏下载打印入口、拦截 CtrlP/CtrlS、添加全局水印。适合维护老 web 工程、又要控制 PDF 内容不外流的开发者。下面按我实际改过的顺序写。2. 部署与首个在线预览workerSrc 路径和 viewer 结构2.1 下载、解压与目录复制从 PDF.js 官网下载 pdfjs-2.2.228-dist 后解压出来的目录里真正需要的是build和web两个子目录。根目录下的 LICENSE 等文件不用一起复制。我在WebRoot/script下新建pdfjs-dist把这两个子目录放进去最终结构是这样的WebRoot ├─ script │ └─ pdfjs-dist │ ├─ build │ │ ├─ pdf.js │ │ └─ pdf.worker.js │ └─ web │ ├─ viewer.html │ ├─ viewer.js │ └─ viewer.css └─ compressed.tracemonkey-pldi-09.pdf之所以保持build和web同级是因为 viewer.html 里的脚本引用都用了相对路径拆开之后第一次测试很容易报错。这一步先不修改任何文件直接把官方自带的compressed.tracemonkey-pldi-09.pdf放到 WebRoot 根目录访问http://localhost:8080/script/pdfjs-dist/web/viewer.html?file/compressed.tracemonkey-pldi-09.pdf如果能看到官方样例 PDF 预览成功说明基础链路是通的。如果提示无法加载pdf.worker.js按下面 2.2 节处理。2.2 workerSrc 为什么要改成绝对路径在script/pdfjs-dist/web/viewer.js里能找到这样一段workerSrc: { value: ../build/pdf.worker.js, kind: OptionKind.WORKER }这个配置负责告诉 PDF.js 去哪里加载pdf.worker.js。官方默认值是从 viewer.html 所在目录出发的相对路径在原始目录结构下通常没问题。但实际 SSH 项目里preview 页面可能是 Struts2 Action 转发出来的浏览器地址栏变成pdfView.action甚至带了一长串参数相对路径就会退回到错误的目录。我一般直接改成 web 根路径workerSrc: { value: /script/pdfjs-dist/build/pdf.worker.js, kind: OptionKind.WORKER }如果项目不是部署在 Tomcat 根上下文而是带应用名需要把应用名也拼进去。常见做法是在 JSP 里通过${pageContext.request.contextPath}生成或者部署后确认实际 URL 再写死。这个字段常见的问题可以对照着排查现象原因对策console 报 Failed to load PDF fileworkerSrc找不到 worker 脚本改成 web 根绝对路径界面一直转圈pdf.worker.js和pdf.js版本不一致使用同一份 dist不要混着从别处拷文件提示 Setting up fake workerworker 加载失败PDF.js 回退到主线程解析打开 network 面板看 worker 请求的实际状态码pdf.worker.js负责在后台线程解析 PDF 字节流避免阻塞页面交互。路径一旦不对PDF.js 会尝试降级但降级后复杂 PDF 的渲染速度会明显变慢所以这一步值得单独验证。2.3 测试 PDF 的打开与页面渲染重新打开上面的预览地址确认三件事页面能显示第一页、控制台没有pdf.worker.js相关报错、鼠标滚轮翻页正常。这三点通过后再进入动态预览改造。否则后面加再多的水印和拦截加载链路不稳都白搭。3. 动态预览不同 PDF从 viewer.html 改造到 Struts2 传参3.1 为 View 层创建 pdfView 目录官方 viewer.html 是静态文件不能直接读取后台 Action 传过来的文件路径。所以我在WebRoot下新建pdfView目录把 viewer.html 复制进去改成viewer.jsp让它既保留 viewer 功能又能输出动态内容。复制之后有一步不能漏viewer.html 引用的viewer.js和viewer.css是相对路径现在 viewer.jsp 在pdfView目录下浏览器会去请求/pdfView/viewer.js结果必然是 404。我习惯在 viewer.jsp 的 head 里改写成 web 根绝对路径link relstylesheet href/script/pdfjs-dist/web/viewer.css script src/script/pdfjs-dist/web/viewer.js/script script var productFilePath file${filePath}; /script如果项目带着应用名部署把/script改成%request.getContextPath()%/script即可。viewer.js内部对../build/pdf.js的引用是相对于它自己所在目录计算的所以 viewer.js 放在原始位置内部相对路径不会乱。3.2 替换 viewer.js 的 queryString 来源PDF.js viewer 默认通过 URL 上的?file参数决定打开哪个 PDF。在viewer.js的webViewerInitialized函数里原来的逻辑是var file; var queryString document.location.search.substring(1); var params _ui_utils.parseQueryString(queryString); file file in params ? params.file : _app_options.AppOptions.get(defaultUrl); validateFileURL(file);改成读取我们刚才定义的productFilePathvar file; var queryString productFilePath; var params _ui_utils.parseQueryString(queryString); file file in params ? params.file : _app_options.AppOptions.get(defaultUrl); validateFileURL(file);这样viewer.jsp里的${filePath}就被传进 viewer.js动态预览的目标文件由后台控制。parseQueryString本质上只解析keyvalue形式所以productFilePath里的字符串格式必须带上前缀file。这里有个容易踩的坑如果filePath中包含中文、空格或字符直接拼进 JS 字符串可能被截断或报错。常见做法是在 Action 里先对路径做 URL 编码或者在 JSP 里用encodeURIComponent处理后再拼。我自己更倾向于后台返回相对路径JSP 里这样写var productFilePath file encodeURIComponent(${filePath});这样最终传给 viewer.js 的是编码后的完整路径validateFileURL会再解码中文文件名不会出问题。3.3 Struts2 Action 返回 pdfView 视图动态预览的最终入口是 Struts2 Action而不是直接访问 JSP。Action 里的典型写法public class PdfViewAction extends ActionSupport { private String filePath; public String execute() { // 实际项目里根据 fileId 到数据库或磁盘查询 filePath /upload/contract-2024.pdf; return pdfView; } public String getFilePath() { return filePath; } }struts.xml 里配置返回视图package namepdf extendsstruts-default action namepdfView classcom.example.PdfViewAction result namepdfView/pdfView/viewer.jsp/result /action /package用户访问pdfView.action?fileId123Action 把filePath放进 requestJSP 渲染出 viewer 页面。这样预览地址始终固定PDF 文件的真实路径不会直接暴露在 URL 参数里比原来viewer.html?file/upload/xxx.pdf的方式安全一些。变量关系总结如下变量类型来源作用filePathStringAction 传入PDF 文件在服务器上的路径productFilePathJS StringJSP 拼装让 viewer.js 不再依赖 URL 参数params.fileStringparseQueryString 解析传给 PDFViewerApplication.open 的实际文件4. 禁止下载与打印的完整控制链UI 隐藏、快捷键、鼠标4.1 先处理工具栏openFile、print、download在 viewer.jsp 里找到div#toolbarViewerRight下面的按钮中 id 为openFile、print、download的三个是默认的下载和打印入口。原方案是给它们加visibleMediumView这个 classbutton idopenFile classtoolbarButton visibleMediumView title打开文件 button idprint classtoolbarButton visibleMediumView title打印 button iddownload classtoolbarButton visibleMediumView title下载visibleMediumView是 PDF.js 样式表里已有的 class但它的显隐逻辑和屏幕宽度绑定不一定在所有 viewport 下都生效。我加完 class 后还会在 viewer.css 里补一段兜底确保无论如何都看不到这些入口#openFile, #print, #download { display: none !important; }二级工具栏里的同名按钮也不要放过。它们的位置在div#secondaryToolbarButtonContainer下面对应的 id 通常是secondaryOpenFile、secondaryPrint、secondaryDownload。另外两个与内容复制相关的按钮也要隐藏cursorSelectTool是鼠标选择工具documentProperties是文档属性查看。#secondaryToolbarButtonContainer #secondaryOpenFile, #secondaryToolbarButtonContainer #secondaryPrint, #secondaryToolbarButtonContainer #secondaryDownload, #cursorSelectTool, #documentProperties { display: none !important; }隐藏cursorSelectTool的意义在于PDF.js 默认允许鼠标划选文字隐藏后用户无法通过工具栏切回选择模式复制文本的路径被堵住了。4.2 拦截 CtrlP / CtrlS 的 keydown 逻辑工具栏按钮隐藏后还要处理键盘。PDF.js viewer.js 里原本有一段监听CtrlP打印的代码var hasAttachEvent !!document.attachEvent; window.addEventListener(keydown, function (event) { if ((event.keyCode 80 || event.keyCode 83) (event.ctrlKey || event.metaKey) !event.altKey (!event.shiftKey || window.chrome || window.opera)) { // window.print(); // 这里不再调用打印 if (hasAttachEvent) { return; } event.preventDefault(); if (event.stopImmediatePropagation) { event.stopImmediatePropagation(); } else { event.stopPropagation(); } return; } }, true);代码里同时保留了80和83两个 keyCode分别对应字母P和S。也就是说CtrlP打印和CtrlS保存页面都被拦截了。window.chrome || window.opera这个条件是从 PDF.js 原逻辑里保留的作用是兼容 Chrome/Opera 下CtrlShiftP这类组合键避免把浏览器隐私模式快捷键误伤。原来的代码把window.print()放在条件里现在注释掉之后即使按键被捕获也不会弹出打印预览。event.preventDefault()必须放在判断通过后的第一件事否则浏览器默认行为仍可能先执行。有一点需要说明这种拦截只对 viewer 页面内部生效。用户如果绕过 viewer 直接访问 PDF 原文件链接依然可以下载。所以实际项目中真正可靠的控制方式是把 PDF 文件放在受保护的目录只允许通过后端接口读取。4.3 禁用鼠标右键与文本选择viewer.jsp 的 body 标签可以加三个禁止属性body tabindex1 oncontextmenureturn false; onselectstartreturn false; ondragstartreturn false;oncontextmenureturn false阻止右键菜单onselectstart阻止文本选中ondragstart阻止拖拽。这三个属性对 PDF.js viewer 的页面容器有效能挡住大多数用户从页面上另存或复制内容的操作。需要注意它们不能完全替代后端权限控制但配合按钮隐藏和快捷键拦截日常场景已经够用。控制顺序可以整理成一张表入口处理方式效果工具栏下载按钮隐藏#download和#secondaryDownload没有下载入口工具栏打印按钮隐藏#print和#secondaryPrint没有打印入口键盘打印/保存keydown 拦截并 preventDefaultCtrlP/CtrlS 失效鼠标右键body oncontextmenu return false右键菜单不出现文本选择隐藏 cursorSelectTool onselectstart复制文本变得困难5. 全局水印渲染层插入、缩放适配和动态内容5.1 在 textLayer 创建处插入水印节点前面做的都是防止下载和打印水印是为了即使有人截图也能追溯到来源。PDF.js 每个 PDF 页面会走textLayer创建逻辑找viewer.js里大约 11973 行附近在canvasWrapper.style.height设置之后插入水印节点if (this.textLayerMode ! _ui_utils.TextLayerMode.DISABLE this.textLayerFactory) { var textLayerDiv document.createElement(div); textLayerDiv.className textLayer; textLayerDiv.style.width canvasWrapper.style.width; textLayerDiv.style.height canvasWrapper.style.height; // 水印开始 var cover document.createElement(div); cover.className cover; cover.innerText 内容保密请勿复制或下载; if (this.annotationLayer this.annotationLayer.div) { div.insertBefore(textLayerDiv, this.annotationLayer.div); } else { div.appendChild(textLayerDiv); } div.appendChild(cover); var coverEle document.getElementsByClassName(cover); var nowWidth parseFloat(canvasWrapper.style.width); var size 50 * nowWidth / 714 px; for (var i 0; i coverEle.length; i) { coverEle[i].style.fontSize size; coverEle[i].style.width canvasWrapper.style.width; coverEle[i].style.height parseInt(canvasWrapper.style.height) / 10 px; } // 水印结束 textLayer this.textLayerFactory.createTextLayerBuilder(textLayerDiv, this.id - 1, this.viewport, this.textLayerMode _ui_utils.TextLayerMode.ENABLE_ENHANCE); }这里选择在 textLayer 创建处插入而不是用全屏 fixed 层是因为每渲染一页都会执行一段这样的逻辑水印会跟随每一页的位置出现。全屏 fixed 方案在页面滚动时水印和 PDF 内容容易脱节截图时也会出现水印不在正文上的问题。div.appendChild(cover)会把水印放到当前页面的末尾而.cover的 CSS 定位是 absolute 且 z-index 较高所以水印能浮在页面内容之上。5.2 水印尺寸与缩放水印字号不是写死的而是根据页面宽度动态计算。714是 PDF.js viewer 在默认 100% 缩放时常见的一页宽度50是此时水印文字的基准字号。页面缩放时canvasWrapper.style.width会变化字号也会跟着等比缩放避免放大后水印挤成一团。配套的 CSS 放在 viewer.css 文件开头.cover { z-index: 100; position: absolute; top: 41%; left: 1%; transform: rotate(330deg); text-align: center; font-size: 310%; padding-left: 30px; letter-spacing: 18px; color: rgba(162, 162, 162, 0.4); pointer-events: none; user-select: none; }pointer-events: none很关键否则水印层会挡住用户点击 PDF 里的链接和注释。user-select: none让水印文本不能被选中复制。rotate(330deg)等价于逆时针旋转 30 度斜向水印在内容多的时候遮挡面积最小又不容易被裁掉。如果实际项目的 viewer 默认宽度不是 714可以在浏览器控制台执行document.querySelector(.page).style.width拿到真实宽度替换公式里的714。5.3 水印内容从后台动态传入水印写死成“内容保密请勿复制或下载”只适合通用场景。要定位截图来源最好把登录人姓名或工号显示在水印上。具体做法和filePath传值一样在 viewer.jsp head 里加一个全局变量script var waterMarkText ${waterMarkText}; /script然后 viewer.js 里把水印内容改成cover.innerText waterMarkText || 内容保密请勿复制或下载;这样后台 Action 可以从 session 里取当前用户名拼到waterMarkText里返回。哪一页的截图流出去看水印就知道是谁的操作。对比很多团队直接在 PDF 导出时加水印的方案这种在线预览时动态加水的实现改造成本低不需要额外安装字体或生成临时 PDF 文件。本文还有配套的精品资源点击获取