资讯动态

PDF.js 在线阅读水印与禁止下载:前端能拦到什么程度

发布时间:2026/9/24 13:23:51 来源:尧图企业网站定制
简介这份资源面向需要在Web项目中实现在线PDF预览并保护文档内容的开发者尤其适合使用SSH或Struts2框架的Java Web项目。资源围绕PDF.js库的集成展开涵盖动态预览不同PDF文件、隐藏打开与下载打印按钮、拦截CtrlP与CtrlS等键盘组合键、禁用鼠标右键及选择操作以及通过canvas为每一页添加全局水印等关键环节帮助读者构建只能在线查看、难以另存或打印的PDF预览页面。压缩包内共1个PDF文件约499KB以图文形式完整记录了从引入pdfjs-dist到水印渲染的实现思路与代码片段便于对照项目结构逐步落地。目前已有2399人学习下载适合希望提升前端文档安全处理能力、需要快速复用预览与水印方案的开发者参考。1. 用 PDF.js 做在线阅读水印和禁止下载到底能拦到什么程度很多团队做后台管理系统时都会遇到同一个需求合同、报表、图纸这类 PDF 不能让用户直接下载只能在页面里看最好再叠一层带用户信息的水印。前端选型上PDF.js 几乎是默认答案——它把 PDF 渲染成 canvas不依赖浏览器自带插件可控性高。但这里有个反直觉的结论只要文件字节流到了浏览器就没有绝对意义上的禁止下载。你能做的是提高门槛、留下追溯痕迹而不是造一把锁。这篇讲的就是这条链路怎么在 web 项目里引入 PDF.js、怎么把水印画进渲染流程、怎么把下载入口堵到普通人懒得折腾的程度以及哪些做法是自欺欺人。适合正在做文档预览模块的前端和全栈也适合被产品一句加个水印防泄露卡住的同学。下面所有方案都基于 PDF.js 的 canvas 渲染管线不涉及任何服务端加密库的深度改造。2. PDF.js 接入 web 项目从静态资源到渲染出第一页2.1 为什么不用 iframe 直接嵌 PDF浏览器原生iframe srcx.pdf或embed确实能显示 PDF但它有几个致命问题一是渲染样式完全由浏览器控制你没法在 canvas 上叠水印二是用户右键就能另存为甚至浏览器自带下载按钮三是移动端表现参差不齐。PDF.js 把每一页渲染成 canvas 元素水印、遮罩、事件拦截都能挂上去这才是可控的前提。选型上还有一层考虑PDF.js 分pdfjs-dist的 legacy 构建和现代构建。现代构建用了较新的 JS 语法打包体积小legacy 构建兼容老浏览器。如果你的后台还要兼容一些老版本内核直接上 legacy 更省心。我一般会先确认目标浏览器范围再决定引哪个。2.2 最小可运行接入CDN 与 npm 两种方式先给一个不打包、直接 CDN 引用的最小例子方便快速验证!-- 引入 PDF.js 主库和 worker版本号按实际锁定 -- script srchttps://cdn.jsdelivr.net/npm/pdfjs-dist3.11.174/build/pdf.min.js/script script // 指定 worker 路径否则 PDF.js 会退化到主线程解析大文件卡顿 pdfjsLib.GlobalWorkerOptions.workerSrc https://cdn.jsdelivr.net/npm/pdfjs-dist3.11.174/build/pdf.worker.min.js; async function renderFirstPage(url, canvas) { // 关闭自动获取避免某些环境下 range 请求异常 const loadingTask pdfjsLib.getDocument({ url, disableAutoFetch: true }); const pdf await loadingTask.promise; const page await pdf.getPage(1); // scale 1.5 是清晰度和性能的折中可按容器宽度动态算 const viewport page.getViewport({ scale: 1.5 }); const ctx canvas.getContext(2d); canvas.width viewport.width; canvas.height viewport.height; await page.render({ canvasContext: ctx, viewport }).promise; } /script逻辑说明getDocument返回的是 loadingTask真正的 PDF 对象在.promise里getPage拿到页对象后必须用getViewport算出渲染尺寸canvas 的宽高要手动设成 viewport 的宽高否则会模糊或拉伸。参数上scale直接决定清晰度和内存占用1.5 到 2 之间比较稳disableAutoFetch在文件较大、服务端不支持 range 请求时能避免一堆报错。如果用 npm 工程写法是npm install pdfjs-distimport * as pdfjsLib from pdfjs-dist; // Vite/webpack 下用 URL 方式引 worker避免路径解析失败 import workerUrl from pdfjs-dist/build/pdf.worker.min.js?url; pdfjsLib.GlobalWorkerOptions.workerSrc workerUrl;这里最容易翻车的是 worker 路径。打包工具对 worker 的处理方式不同Vite 用?url后缀webpack 5 用new URL(..., import.meta.url)。如果 worker 加载失败控制台会提示降级到假 worker表现就是大 PDF 渲染时页面直接卡死。2.3 多页渲染与容器滚动的基本结构真实场景不会只渲染一页。常见做法是把所有页的 canvas 预先创建好塞进滚动容器或者做懒加载——只渲染可视区域附近的页。后者性能好但实现复杂前者简单但几十页的 PDF 会吃掉大量内存。async function renderAll(pdf, container) { for (let i 1; i pdf.numPages; i) { const page await pdf.getPage(i); const viewport page.getViewport({ scale: 1.5 }); const canvas document.createElement(canvas); canvas.width viewport.width; canvas.height viewport.height; canvas.style.display block; container.appendChild(canvas); await page.render({ canvasContext: canvas.getContext(2d), viewport, }).promise; } }参数说明numPages是总页数循环里逐页 await 是为了避免同时渲染多页导致主线程阻塞。如果 PDF 超过 50 页建议改成 IntersectionObserver 懒加载只渲染进入视口的页离开视口的页可以把 canvas 尺寸清掉释放内存。这一步不做用户滚到后面就会明显掉帧。3. 把水印画进 canvas多行多列文字水印的实现细节3.1 水印应该画在哪一层水印有两种画法一是画在 PDF 内容之上二是画在单独的覆盖层。画在 canvas 上渲染完成后追加绘制的好处是水印和内容绑定用户截图时水印一定在坏处是每次重绘都要重画水印。覆盖层绝对定位的 div 或第二个 canvas的好处是水印独立、可以做成动态的但用户用开发者工具删掉那个 div 就没了。我的选择是画进渲染 canvas因为防的是随手截图转发不是防专业逆向。多行多列文字水印是热词里提到的形态也是实际最常用的——单条水印容易被裁掉铺满整页才难处理。3.2 多行多列水印的绘制代码function drawWatermark(canvas, text, options {}) { const ctx canvas.getContext(2d); const { fontSize 16, // 字号太小看不清太大挡内容 gapX 220, // 横向间距 gapY 140, // 纵向间距 angle -Math.PI / 6, // 旋转 -30 度斜向更难被裁 color rgba(0,0,0,0.12), // 透明度是关键太深影响阅读 } options; ctx.save(); ctx.font ${fontSize}px sans-serif; ctx.fillStyle color; ctx.textAlign center; ctx.textBaseline middle; // 旋转后需要扩大覆盖范围否则四角会漏 const diagonal Math.sqrt(canvas.width ** 2 canvas.height ** 2); for (let y -diagonal; y diagonal; y gapY) { for (let x -diagonal; x diagonal; x gapX) { ctx.save(); ctx.translate(x, y); ctx.rotate(angle); ctx.fillText(text, 0, 0); ctx.restore(); } } ctx.restore(); }逻辑说明先save保存状态设置字体和填充色然后以画布中心为基准做双重循环铺点。关键点是循环范围用对角线长度而不是画布宽高——因为水印旋转了如果只按宽高铺四个角会出现空白区泄露时容易被裁掉水印部分。每个点单独translaterotate保证旋转围绕自身而不是画布原点。参数说明color的透明度建议在 0.08 到 0.15 之间低于 0.08 截图压缩后可能看不见高于 0.2 就明显干扰阅读。gapX和gapY决定密度间距越小越难去除但绘制开销也越大一般 200×140 左右够用。angle用 -30 度是经验值正负都行斜向比水平更难被简单裁剪。3.3 把用户信息嵌进水印做追溯水印的价值不只是防更是追。把当前登录用户的手机号后四位、工号或时间戳拼进水印文本一旦泄露就能定位到人。const watermarkText ${user.name} ${user.id} ${new Date().toLocaleDateString()}; drawWatermark(canvas, watermarkText, { fontSize: 14, color: rgba(0,0,0,0.1) });注意水印文本不要用完整手机号或身份证号一是隐私合规问题二是水印本身可见泄露用户信息反而惹麻烦。用姓名缩写加工号就够追溯了。另外时间戳建议精确到天精确到秒会让水印文本过长铺开后视觉很乱。4. 禁止下载的几层拦截从右键到请求头4.1 前端能做的拦截手段先把话说清楚前端拦截全是防君子。真正要防得靠服务端不返回完整文件或做权限校验。但前端这几层做了能挡住 90% 的普通用户。第一层禁用右键和选择document.addEventListener(contextmenu, (e) e.preventDefault()); document.addEventListener(selectstart, (e) e.preventDefault());第二层拦截常见快捷键document.addEventListener(keydown, (e) { // CtrlS 保存、CtrlP 打印、CtrlShiftS 另存 if ((e.ctrlKey || e.metaKey) [s, p].includes(e.key.toLowerCase())) { e.preventDefault(); } });第三层PDF.js 自带的下载按钮要关掉。如果你用的是官方 viewer工具栏里有下载和打印按钮需要在 viewer 配置里禁用如果是自己写的渲染逻辑本来就没有这些按钮反而更干净。4.2 服务端配合让文件地址不可直接访问前端再怎么拦用户打开开发者工具一看网络请求PDF 的真实 URL 就暴露了。所以服务端要做两件事一是文件地址用一次性 token 或短时效签名二是校验 Referer 和登录态。// 前端请求时带上短期 token服务端校验后才返回文件流 const res await fetch(/api/pdf/${docId}?token${shortLivedToken}); const data await res.arrayBuffer(); const pdf await pdfjsLib.getDocument({ data }).promise;参数说明token建议有效期 5 到 10 分钟绑定用户 ID 和文档 ID服务端校验通过才吐文件流。用arrayBuffer方式加载而不是直接传 URL好处是文件流不经过浏览器缓存用户没法在缓存里翻到。代价是内存占用高一些大文件要评估。4.3 水印 拦截的组合效果评估把上面几层叠起来实际效果是这样的普通用户右键没反应、快捷键被拦、网络请求里看到的是带 token 的接口地址、就算拿到文件也有满屏水印。这套组合挡不住会写脚本的人但能覆盖绝大多数内部泄露场景。评估标准很简单——问自己泄露者需要多高的技术门槛才能拿到干净文件如果答案是要会抓包、会改 JS、会去水印那基本达标了。5. 避坑与排查水印和拦截最容易翻车的地方5.1 水印画了但截图里没有现象页面上能看到水印但用户截图发出来水印不见了。原因通常是水印画在了独立的覆盖层 div 上而截图工具或某些浏览器截图 API 只抓 canvas 内容。解决把水印直接画进渲染 canvas或者用第二个 canvas 叠在上面并确保它在截图范围内。5.2 大 PDF 渲染卡死或白屏现象几十页的 PDF 打开后页面无响应或者只显示前几页。原因一是 worker 没加载成功PDF.js 退化到主线程同步解析二是所有页一次性渲染内存爆了。解决先确认workerSrc路径正确控制台没有降级警告再把渲染改成懒加载只渲染视口附近的页。5.3 水印透明度调了但看起来还是糊现象水印颜色设成rgba(0,0,0,0.1)了但页面上还是显得很重。原因是 canvas 在高分屏下被浏览器做了缩放实际绘制像素和 CSS 像素不一致透明度视觉上被放大。解决按devicePixelRatio放大 canvas 尺寸再缩放 context让绘制精度匹配屏幕。const dpr window.devicePixelRatio || 1; canvas.width viewport.width * dpr; canvas.height viewport.height * dpr; canvas.style.width viewport.width px; canvas.style.height viewport.height px; ctx.scale(dpr, dpr);5.4 禁用右键后整个页面都不能复制了现象为了禁 PDF 下载把contextmenu和selectstart全局禁了结果用户连页面上的其他文字都选不了投诉一堆。原因事件监听挂在了document上影响全站。解决把监听范围缩小到 PDF 容器元素只在该容器内拦截。5.5 token 过期导致翻页失败现象用户打开 PDF 看了几分钟翻到后面几页时请求 401。原因token 有效期太短而 PDF.js 是按需请求分片的。解决要么把 token 有效期设长一点比如 30 分钟要么在 token 快过期时静默刷新要么一次性把整个文件流加载到内存适合中小文件。6. 进阶用动态水印和渲染钩子做更细的控制前面讲的是基础版。如果要做得更细有两个方向值得投入。第一个是动态水印。静态水印谁都能看到动态水印可以做成随时间变化、随滚动位置变化甚至每隔几秒重绘一次。这样即使用户录屏水印也在动去水印的难度陡增。实现上就是在渲染完成后启动一个定时器定期清掉水印区域重画——但注意别整页重绘只重画水印层。// 每 8 秒刷新一次水印文本加入当前时间 setInterval(() { const now new Date().toLocaleTimeString(); drawWatermark(canvas, ${user.id} ${now}, { color: rgba(0,0,0,0.1) }); }, 8000);这里有个坑如果水印和 PDF 内容画在同一个 canvas 上重画水印会覆盖内容。正确做法是用两个 canvas 叠放底层放 PDF 内容顶层放水印定时只清顶层。这样重绘开销小也不会破坏内容。第二个是利用 PDF.js 的渲染钩子。page.render返回的 task 有onContinue回调可以在渲染过程中插入自定义绘制。不过这个回调触发时机和渲染进度相关用来画水印不太稳定更适合做进度条。真正稳的水印还是等render().promiseresolve 之后再画。验证方案是否生效我一般用三步一是打开开发者工具看网络请求确认 PDF 不是以静态文件 URL 直接暴露二是右键和快捷键试一遍确认拦截生效三是截图后用图片工具看水印是否在像素层而不是 DOM 层。三步都过基本可以交付。最后说个血泪经验别把禁止下载当成安全目标写进需求文档。它是个体验门槛不是安全边界。真正敏感的文件该走服务端权限、该加水印追溯、该做访问审计一个都不能少。前端这套只是第一道减速带。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价