ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

PDF.js 渲染加水印与防下载:Web 文档安全实践

PDF.js 渲染加水印与防下载:Web 文档安全实践 简介这份资源面向需要在Web项目中实现在线PDF预览并保护文档内容的前端与Java开发者围绕PDF.js的集成与安全加固展开。包内共1个PDF文件约499KB以图文形式完整记录了从下载引入PDF.js、动态预览PDF文件到隐藏打开、下载、打印按钮禁用CtrlP、CtrlS等键盘组合键屏蔽鼠标右键与选择操作再到利用canvas添加全局水印的整套实现思路并附有workerSrc路径配置、viewer.jsp改造等关键代码片段与排错提示。目前已有2401人学习下载适合正在搭建受控PDF预览系统、希望防止文档被随意保存或打印的开发者参考可据此快速理解PDF.js的定制流程与常见坑点将水印与禁用下载能力落地到实际项目中。1. 从一次内部资料泄露说起PDF.js 加水印到底防住了谁去年帮一家做工程咨询的团队做内部知识库他们把三百多份造价分析报告直接嵌在 Web 页面里给合作方看。上线第三天一份带报价明细的 PDF 就被完整下载转发了出去。事后复盘发现前端用的是浏览器原生embed标签右键另存为就能拿走原文件连水印都没有。这件事之后我把方案换成了 PDF.js 渲染加动态水印才把「谁在什么时候看了哪份文件」这条链路补上。这篇要讲的就是这套组合用 PDF.js 在 Web 项目里渲染 PDF叠加可追溯的水印同时把下载、打印、右键另存这些出口尽量堵住。适合正在做在线文档预览、合同查看、内部资料分发的后端和前端同学。需要先说清楚一个前提纯前端方案挡不住铁了心要拿文件的人它的价值在于提高门槛、留下追溯线索而不是做成铜墙铁壁。想明白这一点后面的技术选型和参数设置才不会走偏。2. PDF.js 渲染管线拆开看为什么水印不能直接画在 canvas 上2.1 从 getDocument 到 canvas 的完整链路PDF.js 的核心工作流分三步加载文档、取页、渲染。很多人第一次用的时候会以为getDocument返回的就是一个可以直接操作的 DOM其实它返回的是一个PDFDocumentLoadingTask真正的文档对象要等 promise resolve 之后才拿得到。这个异步特性决定了水印的插入时机——你必须等页面渲染完成才能确定 canvas 的实际尺寸和位置。import * as pdfjsLib from pdfjs-dist; // 设置 worker 路径这一步漏了会直接报错 pdfjsLib.GlobalWorkerOptions.workerSrc /static/pdf.worker.min.js; async function renderPdf(url, container) { // getDocument 返回 loadingTask不是文档本身 const loadingTask pdfjsLib.getDocument({ url, cMapUrl: /static/cmaps/, // 中文 PDF 必须配否则乱码 cMapPacked: true, disableAutoFetch: true, // 大文件按需加载省带宽 disableStream: false }); const pdf await loadingTask.promise; const page await pdf.getPage(1); // scale 决定渲染分辨率1.5 是清晰度和性能的平衡点 const viewport page.getViewport({ scale: 1.5 }); const canvas document.createElement(canvas); const ctx canvas.getContext(2d); // 处理高分屏devicePixelRatio 不乘会导致模糊 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; await page.render({ canvasContext: ctx, viewport, transform: dpr ! 1 ? [dpr, 0, 0, dpr, 0, 0] : null }).promise; container.appendChild(canvas); return { pdf, page, viewport, canvas }; }这段代码里有几个参数值得单独说。cMapUrl指向的是 PDF.js 的字符映射表中文、日文、韩文 PDF 如果不配这个渲染出来全是方块这是新手最容易翻车的地方。scale设成 1.5 而不是 2是因为在普通笔记本上 2 倍渲染会让滚动明显卡顿1.5 在 1080P 屏幕上已经足够清晰。devicePixelRatio那段是给 Retina 屏准备的不处理的话 canvas 会被拉伸变糊。2.2 水印层为什么必须独立于 canvas一个常见的错误做法是拿到 canvas 的 2D context 之后直接用ctx.fillText把水印画上去。这样做有两个致命问题第一PDF.js 每次重新渲染比如缩放、翻页都会清空 canvas水印跟着消失第二用户只要把 canvas 导出成图片水印和内容就一起被带走了但如果你把水印画在独立的覆盖层上导出 canvas 时水印不在里面反而能作为「内容被单独提取」的判定依据。正确的结构是三层底层是 PDF.js 渲染的 canvas中间是水印层顶层是拦截交互的遮罩层。水印层用绝对定位盖在 canvas 上尺寸跟随 viewport 变化。这样缩放时只需要重新计算水印的平铺密度不用重绘 PDF 内容。div classpdf-wrapper styleposition: relative; display: inline-block; canvas idpdf-canvas/canvas div idwatermark-layer styleposition: absolute; inset: 0; pointer-events: none;/div div idshield-layer styleposition: absolute; inset: 0; z-index: 10;/div /divpointer-events: none让水印层不拦截鼠标事件shield-layer则用来吃掉右键和选择操作。这个结构看起来简单但它是后面所有防护逻辑的地基。3. 多行多列文字水印的实现平铺算法与参数调优3.1 用 Canvas 生成水印瓦片再平铺水印的实现方式有两种一种是用 DOM 元素逐个定位另一种是生成一张水印瓦片然后用background-repeat平铺。前者在文档页数多的时候会产生大量节点后者只需要一张小图。我一般用后者性能差距在几十页的文档上非常明显。function createWatermarkTile(text, options {}) { const { fontSize 14, fontFamily Microsoft YaHei, sans-serif, color rgba(0, 0, 0, 0.12), rotate -22, // 旋转角度负值是逆时针 tileWidth 260, // 瓦片宽度决定横向间距 tileHeight 180 // 瓦片高度决定纵向间距 } options; const canvas document.createElement(canvas); const ctx canvas.getContext(2d); const dpr window.devicePixelRatio || 1; canvas.width tileWidth * dpr; canvas.height tileHeight * dpr; ctx.scale(dpr, dpr); ctx.font ${fontSize}px ${fontFamily}; ctx.fillStyle color; ctx.textAlign center; ctx.textBaseline middle; // 平移到瓦片中心再旋转保证文字居中 ctx.translate(tileWidth / 2, tileHeight / 2); ctx.rotate((rotate * Math.PI) / 180); ctx.fillText(text, 0, 0); return canvas.toDataURL(image/png); } function applyWatermark(layerEl, text, options) { const tileUrl createWatermarkTile(text, options); layerEl.style.backgroundImage url(${tileUrl}); layerEl.style.backgroundRepeat repeat; }tileWidth和tileHeight这两个参数直接决定水印的疏密。设得太小文字会挤在一起影响阅读设得太大又容易被人用截图工具裁掉一块干净区域。我的经验值是正文文档用 260×180表格密集的报表用 320×220。rotate用 -22 度而不是 -45 度是因为 45 度斜排在宽屏上会显得过于张扬22 度既能防止被水平裁剪又不至于太抢眼。3.2 水印内容怎么设计才有追溯价值水印文字不能只写「机密」两个字那样等于没写。有效的追溯水印应该包含三个要素查看者标识、时间戳、文档标识。常见做法是把用户名和手机号后四位拼进去比如「张三 138****1234 2024-06-12」。这样一旦截图流出能直接定位到人。但这里有个隐私边界要注意不要在页面上明文展示完整手机号或身份证号用掩码形式即可。另外时间戳建议精确到天而不是秒避免水印文字过长导致平铺密度被迫调大。function buildWatermarkText(user, docId) { const date new Date().toISOString().slice(0, 10); const maskedPhone user.phone.replace(/(\d{3})\d{4}(\d{4})/, $1****$2); return ${user.name} ${maskedPhone} ${date}; }如果文档需要分享给外部人员可以把user.name换成「访客」加一串随机 ID这样既能追溯又不暴露内部人员姓名。3.3 缩放和翻页时水印怎么跟着走PDF.js 支持手势缩放和按钮缩放每次缩放都会触发重新渲染。水印层因为是独立 DOM不需要重绘但它的尺寸必须跟着 canvas 的 CSS 尺寸走。做法是监听渲染完成事件把 canvas 的clientWidth和clientHeight同步给水印层。function syncWatermarkSize(canvas, layer) { const rect canvas.getBoundingClientRect(); layer.style.width rect.width px; layer.style.height rect.height px; } // 在每次 render 完成后调用 await page.render({ canvasContext: ctx, viewport }).promise; syncWatermarkSize(canvas, watermarkLayer);手势缩放场景下getBoundingClientRect会在 CSS transform 之后返回实际显示尺寸所以这个同步逻辑对双指缩放同样有效。但要注意如果缩放比例变化很大水印瓦片的视觉密度会跟着变——放大时水印看起来变稀疏缩小时变密集。如果对一致性要求高可以在缩放结束后按新的 viewport 尺寸重新生成瓦片。4. 禁止下载的几道防线从右键拦截到 Blob 地址管理4.1 拦截右键、CtrlS 和打印快捷键最基础的防护是屏蔽浏览器默认的下载和打印入口。右键菜单、CtrlS、CtrlP这三个是最常用的出口需要分别处理。function bindProtectionEvents(container) { // 屏蔽右键菜单 container.addEventListener(contextmenu, (e) { e.preventDefault(); return false; }); // 屏蔽 CtrlS / CtrlP / CtrlShiftS document.addEventListener(keydown, (e) { const key e.key.toLowerCase(); if ((e.ctrlKey || e.metaKey) [s, p].includes(key)) { e.preventDefault(); return false; } // 有些浏览器用 CtrlShiftS 触发另存为 if ((e.ctrlKey || e.metaKey) e.shiftKey key s) { e.preventDefault(); return false; } }); // 屏蔽拖拽保存 container.addEventListener(dragstart, (e) e.preventDefault()); }这些拦截只能挡住普通用户开发者工具里禁用 JavaScript 就能绕过。所以它们的作用是「提高操作成本」不是「绝对禁止」。真正要防的是直接拿到 PDF 文件地址。4.2 用 Blob URL 替代静态文件地址如果 PDF 文件放在静态目录里用户打开开发者工具就能看到真实 URL直接访问就能下载。正确做法是后端返回文件流前端用Blob转成临时 URL并且用完即释放。async function loadPdfFromApi(apiUrl, token) { const response await fetch(apiUrl, { headers: { Authorization: Bearer ${token} } }); if (!response.ok) throw new Error(加载失败); const blob await response.blob(); // 创建临时 URL这个 URL 只在当前页面会话有效 const blobUrl URL.createObjectURL(blob); return { url: blobUrl, revoke: () URL.revokeObjectURL(blobUrl) // 组件卸载时调用 }; }URL.createObjectURL生成的地址形如blob:https://yourdomain.com/uuid它只在当前文档上下文里有效复制到新标签页打不开。配合后端鉴权即使有人拿到了这个 URL换个会话也访问不了。组件销毁时一定要调revoke否则内存里的 Blob 不会释放长时间使用会越来越卡。4.3 禁用 PDF.js 自带的下载按钮PDF.js 的默认 viewer 自带下载、打印按钮如果你用的是完整 viewer 而不是自己写的渲染层需要把这些按钮隐藏掉。通过 CSS 覆盖是最直接的方式。/* 隐藏 PDF.js viewer 的下载和打印按钮 */ #download, #print, .toolbarButton.download, .toolbarButton.print { display: none !important; } /* 隐藏右侧的次要工具栏 */ .secondaryToolbar { display: none !important; }但 CSS 隐藏只是视觉上的按钮对应的事件监听还在。更彻底的做法是在 viewer 初始化时通过配置项关掉或者在webViewerLoaded事件里直接移除对应 DOM 节点。如果项目允许我建议自己写渲染层不依赖完整 viewer可控性高很多。5. 避坑与排查水印和禁止下载最容易翻车的五个点5.1 中文 PDF 渲染出来全是方块现象PDF 内容能加载但文字显示为一个个方框或乱码。原因PDF.js 需要额外的 CMap 文件来解析中日韩字符编码默认不加载。解决下载 PDF.js 发行包里的cmaps目录放到静态资源路径下在getDocument时传入cMapUrl和cMapPacked: true。注意路径结尾要带斜杠否则拼接出来的 URL 会 404。5.2 水印在打印预览里消失现象页面上水印正常显示但用户按CtrlP打开打印预览时水印不见了。原因水印层用的是background-image浏览器打印时默认不打印背景图。解决在 CSS 里加-webkit-print-color-adjust: exact; print-color-adjust: exact;强制打印背景。但更推荐的做法是直接拦截打印快捷键从源头堵住这个出口。5.3 缩放后水印和内容错位现象双指缩放或点击放大后水印位置偏移和 PDF 文字对不上。原因水印层尺寸没有跟随 canvas 的 CSS 尺寸更新或者更新时机早于渲染完成。解决在page.render().promise的then里调用尺寸同步函数确保 canvas 已经完成布局。如果用了 CSS transform 缩放用getBoundingClientRect而不是style.width来取值。5.4 Blob URL 在组件切换后失效现象单页应用里切换路由再回来PDF 加载失败控制台报net::ERR_FILE_NOT_FOUND。原因组件卸载时调用了URL.revokeObjectURL但缓存里还留着旧 URL。解决每次加载都重新请求并创建新的 Blob URL不要缓存 URL 本身。如果要做缓存缓存 Blob 对象而不是 URL用的时候再createObjectURL。5.5 移动端长按弹出系统下载菜单现象在手机浏览器上长按 PDF 区域弹出「保存图片」「下载文件」等系统菜单。原因移动端浏览器对 canvas 和图片有默认的长按行为。解决给容器加-webkit-touch-callout: none;和user-select: none;同时监听touchstart阻止默认行为。但要注意完全禁止触摸事件会影响正常滚动只拦截长按相关的contextmenu即可。6. 进阶把水印做成隐写标记与泄露溯源前面讲的水印都是肉眼可见的它的作用是威慑。但如果真的发生了泄露你需要一套能确认「是哪一次访问泄露的」的机制。我一般会在可见水印之外再加一层肉眼不可见的标记。具体做法是利用 canvas 的像素操作在渲染完成的 PDF canvas 上按固定间隔修改某些像素的 Alpha 通道值编码进访问者的会话 ID。这种改动肉眼看不出来但用脚本读取像素值就能还原出 ID。function embedInvisibleMark(canvas, sessionId) { const ctx canvas.getContext(2d); const imageData ctx.getImageData(0, 0, canvas.width, canvas.height); const data imageData.data; // 把 sessionId 转成二进制串 const bits sessionId.split().map(c c.charCodeAt(0).toString(2).padStart(8, 0) ).join(); // 每隔 997 个像素嵌入一位997 是质数减少规律性 const step 997; for (let i 0; i bits.length; i) { const pixelIndex i * step * 4; if (pixelIndex 3 data.length) break; // 只改 Alpha 通道的最低位视觉无感知 data[pixelIndex 3] (data[pixelIndex 3] 0xFE) | parseInt(bits[i]); } ctx.putImageData(imageData, 0, 0); }这段代码的关键在于「只改 Alpha 最低位」。PNG 的 Alpha 通道最低位变化 1 在视觉上完全不可见但截图或导出成 PNG 后这个信息还在。如果泄露者把截图转成 JPEGAlpha 通道会丢失标记也就没了——所以这层防护主要针对 PNG 截图和 canvas 导出。验证方法是反向读取拿到泄露的图片后按同样的步长提取 Alpha 最低位拼回二进制再转成字符串就能得到 sessionId。我在实际项目里会把 sessionId 和访问日志关联这样就能定位到具体是哪次会话泄露的。需要提醒的是这种隐写方式对图片压缩、裁剪、缩放都很敏感。如果泄露者做了二次处理标记大概率会损坏。所以它的定位是「辅助溯源」不是「万能证据」。真正要追责还是得靠后端日志、访问鉴权和可见水印三管齐下。最后说个我自己的习惯每次上线这类防护功能之前我都会用开发者工具把能想到的绕过方式试一遍——禁用 JS、直接调 API、导出 canvas、打印预览。试完之后在代码注释里写清楚「这里防不住什么」免得后面接手的人以为真的万无一失。技术防护的边界感比防护本身更重要。希望帮到你。本文还有配套的精品资源点击获取
返回列表