纯前端PDF预览指南:pdf.js渲染、文本层与打印实现详解 简介PDF.js Demo是一套基于Mozilla开源PDF.js库的纯JavaScript示例工程面向前端开发者和需要为网站嵌入文档预览功能的技术人员解决浏览器中无需插件即可实现PDF在线预览、页面缩放、本地文件加载与打印调用等核心问题适合OA系统、电子合同预览、在线课堂资料展示等场景。资源压缩包约2.49MB共376个文件核心包括字形映射、属性配置、图片图标、脚本文件与HTML示例页面另附PDF参考文档和工程配置文件便于对照查阅与二次开发。已有9926人学习下载。示例除了展现基础渲染流程外还实现了动态缩放、通过File API读取本地PDF以及与浏览器打印接口结合的实用逻辑同时涵盖缓存页面、分页加载、使用Web Worker后台解析等性能优化要点并配有目录结构说明和代码注释适合作为在线文档方案的技术蓝本。1. 纯JS PDF在线预览指北为何选择pdf.js以及它到底能解决什么如果你接手过“要在网页里打开PDF但不想加载插件、不想装控件”的需求大概率会绕到pdf.js这条路上来。它是纯前端解析PDF并渲染到canvas的开源方案只要浏览器支持ES5以上和canvas就能把PDF文件变成可交互的页面同时支持打印、文本选中、搜索这些原生体验。最关键的是它不依赖后端转换直接喂它文件路径或二进制流就能工作。如果你是做内部系统、文件管理平台或者教育类工具的开发这套方案能省掉一整套在线预览服务配合打印接口还能把“预览”和“打印”统一交给前端处理。下面我以一份可运行的demo为基准从零拆解它怎么跑起来以及在真实项目中哪些参数值得调、哪些坑必须绕开。2. 快速搭一个预览Demo引入、初始化和第一屏渲染2.1 引入方式从CDN拉全量版还是自托管文件pdf.js的官方分发包含三个入口pdf.js主文件、pdf.worker.js工作线程文件以及可选的cmaps字符映射目录。在demo里最省事的方式是直接引用CDN上的固定版本比如在html里这样写script srchttps://unpkg.com/pdfjs-dist2.16.105/build/pdf.min.js/script但需要注意pdf.js内部会尝试从与主文件相同的目录加载worker文件。如果你只引入主文件它会在运行时发一个动态请求去拉pdf.worker.js。我在实际项目里习惯把两个文件都下载到本地静态目录然后手动指定worker路径这样可以避免CDN版本更新导致行为变化。指定方式如下pdfjsLib.GlobalWorkerOptions.workerSrc /assets/pdfjs/pdf.worker.min.js;这里的逻辑是pdf.js在解析PDF时有一部分计算密集的任务放在Worker线程里执行workerSrc就是告诉主线程“去哪里拿这个线程的代码”。如果不设置它会默认从主文件所在目录找一旦你把主文件和worker文件放在不同地方初始化就会失败。设置路径时建议用绝对路径或相对站点根的路径避免嵌套路由下路径解析出错。2.2 初始化PDF文档getDocument的输入格式与Promise链拿到文件路径或File对象之后调用pdfjsLib.getDocument()创建加载任务。这个函数返回的是PDFDocumentProxy的Promise后续所有操作都必须挂在它后面const loadingTask pdfjsLib.getDocument({ url: /uploads/sample.pdf, cMapUrl: /assets/pdfjs/cmaps/, cMapPacked: true, }); loadingTask.promise.then(pdf { console.log(页数:, pdf.numPages); return pdf.getPage(1); }).then(page { // 拿到page对象后进行渲染 }).catch(err { console.error(加载失败:, err.message); });这里面有两点要说明第一getDocument返回的是PDFDocumentLoadingTask别直接拿这个对象去调用getPage必须先通过.promise属性获取到proxy。第二cMapUrl和cMapPacked是给包含特定字体的PDF用的比如某些CJK中日韩字体。如果你不设置这两个参数遇到这类文档时字形会显示成乱码或者方框。我一般会在demo里直接带上cmaps目录虽然体积多了几MB但换来的是渲染正确率这笔账划算。2.3 渲染第一页从page到canvas的完整链路获取到PDFPageProxy对象后渲染的核心是把PDF页面绘制到canvas上。这一步靠page.render()方法完成它接收一个RenderParameters对象。下面是最基础的第一页渲染代码const canvas document.getElementById(pdf-canvas); const ctx canvas.getContext(2d); const viewport page.getViewport({ scale: 1.5 }); canvas.width viewport.width; canvas.height viewport.height; const renderTask page.render({ canvasContext: ctx, viewport: viewport, }); renderTask.promise.then(() { console.log(第一页渲染完成); });这里的关键在viewport。PDF是一种矢量格式页面尺寸是用point1/72英寸定义的而canvas需要像素尺寸。getViewport({ scale })的作用就是定义一个缩放比例把PDF坐标换算成canvas像素。scale1.5表示把页面放大50%显示。你需要先算好宽高再设置canvas的width和height否则画布尺寸和viewport不匹配画出来的内容会变形或截断。另外render()返回的renderTask是一个带promise的对象渲染过程中也可以调用cancel()取消这在翻页特别快的场景下很有用能避免旧页面渲染阻塞新页面。3. 多页、缩放、旋转与页码控制把Demo扩展成可用的预览器3.1 多页渲染连续滚动与单页切换两种布局demo如果只渲染第一页那还谈不上“预览器”。真实系统里至少要让用户能翻页或者滚动浏览。这里有两种常见做法一种是类似阅读器的单页模式底部放“上一页/下一页”按钮另一种是类似PDF阅读软件的连续滚动模式把所有页先后渲染到同一个滚动容器里。单页模式的好处是每次只渲染一页内存开销小实现也简单let currentPage 1; const totalPages pdf.numPages; function renderPage(pageNum) { return pdf.getPage(pageNum).then(page { const scale 1.2; const viewport page.getViewport({ scale }); canvas.width viewport.width; canvas.height viewport.height; return page.render({ canvasContext: ctx, viewport, }).promise; }); } document.getElementById(next).addEventListener(click, () { if (currentPage totalPages) { currentPage; renderPage(currentPage); } });这段代码里要注意的是renderPage每次都会重新设置canvas尺寸。如果你在getPage和render之间没有把canvas尺寸重置渲染结果会被拉伸。我一般会在切换页面时先清除画布ctx.clearRect再把新的viewport尺寸赋给canvas防止上一次的残留影像干扰视觉。连续滚动模式则更复杂一点。你需要动态判断“哪一页进入视口”然后懒加载渲染。常见做法是监听滚动事件计算当前滚动偏移量对应的页数再调用getPage去渲染可视区域周围的页面超出视口的页面移除canvas节点释放内存。这个方案对长文档友好但增量渲染和DOM回收逻辑需要仔细写否则滚动快时页面会闪白。3.2 缩放与旋转viewport参数附近的细节viewport不只是scale一个属性它还包含旋转角度。PDF内部可以通过page.rotate属性指定页面本身的旋转角度getViewport默认会尊重这个角度。如果你想让用户手动旋转需要传第二个参数rotationconst viewport page.getViewport({ scale: currentScale, rotation: currentRotation, // 90的整数倍 });注意到一个容易出错的地方rotation是在原始旋转基础上的增量不是“重置”。如果PDF本身已经是90度方向你传rotation90会变成180度。所以做旋转功能时要先把page.rotate读到并保存然后在此基础上累加。另外缩放时不要只改scale然后就重新渲染必须同时更新canvas的宽高否则画布内部用了ctx.scale的话会出现“画布CSS尺寸不变但内部像素变化”的模糊现象。我现在实践中的做法是维护一份当前状态对象const state { scale: 1.0, rotation: 0, pageNum: 1, }; function applyViewport(page) { const baseRotation page.rotate || 0; const viewport page.getViewport({ scale: state.scale, rotation: (baseRotation state.rotation) % 360, }); canvas.width viewport.width; canvas.height viewport.height; return viewport; }这么做的好处是每次渲染时都从原始页面状态计算避免上一次操作污染下一次结果。3.3 文本层让预览支持复制、搜索与无障碍阅读纯canvas渲染出的PDF只是一张图片用户无法选中文字浏览器搜索自然也找不到内容。这在文档管理系统里几乎是不可接受的缺点。解决方案是加一层“文本层”text layer。pdf.js的API暴露了page.getTextContent()方法可以把页面里的文字块抽出来再通过TextLayerBuilder这个工具类把它们渲染成一个覆盖在canvas上的透明层。import { TextLayerBuilder } from pdfjs-dist/lib/web/text_layer_builder; const textLayerDiv document.getElementById(text-layer); const textContent await page.getTextContent(); const textLayer new TextLayerBuilder({ textLayerDiv, pageIndex: page.pageNumber - 1, viewport: viewport, }); textLayer.setTextContent(textContent); textLayer.setViewport(viewport); textLayer.render();注意text layer构建器的pageIndex从0开始而page.pageNumber从1开始不减去1的话会导致文本层错位。这个mapper类在官方示例里有但很多人下载的demo里没有包含它复制代码时容易漏。装了文本层后你要保证它的z-index高于canvas但低于页面上的其他按钮。另外缩放时文本层要重新计算所有文字的位置例如我一般会在缩放结束后调用textLayer.setViewport(viewport)再做一次渲染。4. 打印实现用pdf.js的print模块还是自己拼iframe4.1 内置print方法的边界它其实是在“另起炉灶”pdf.js在官方web/viewer.html里自带打印功能如果你直接把整个viewer嵌入站点可以参考它的实现。它内部的逻辑大致是重新创建所有页的canvas计算一套适合打印的viewport然后调用document.execCommand(print)或者hostContainer.print()。但在你自己的demo里如果只引入核心库是没有这个能力的。你需要自己写打印入口。我见过不少开发者试图从pdfjsLib里找出.print()方法但在核心包里确实没有。核心库提供的只是解析、渲染和文本提取打印是viewer那层的事。所以如果你在自定义预览界面做打印需要走下面的路线。4.2 自建iframe打印从canvas到打印文档的转换流程最稳妥的做法是构造一个隐藏的iframe把要打印的PDF页面依次渲染成canvas然后把canvas转成图片塞进iframe的document.body里最后调用iframe的contentWindow.print()。下面是精简版代码async function printPdf(pdf) { // 1. 创建隐藏iframe const iframe document.createElement(iframe); iframe.style.display none; document.body.appendChild(iframe); const win iframe.contentWindow; const doc win.document; doc.write(htmlheadtitleprint/title/headbody/body/html); doc.close(); // 2. 逐页渲染为canvas转图片 for (let i 1; i pdf.numPages; i) { const page await pdf.getPage(i); // 打印用的scale建议用1或1.5太高会导致图片体积巨大 const viewport page.getViewport({ scale: 1.5 }); const canvas document.createElement(canvas); canvas.width viewport.width; canvas.height viewport.height; const ctx canvas.getContext(2d); await page.render({ canvasContext: ctx, viewport }).promise; const img doc.createElement(img); img.src canvas.toDataURL(image/jpeg, 0.9); doc.body.appendChild(img); } // 3. 延迟打印等待图片加载完成 setTimeout(() { win.print(); document.body.removeChild(iframe); }, 500); }这段代码有三个实际问题第一canvas.toDataURL()对大页面会产生很长的base64字符串内存压力大时可能卡顿。建议改成canvas.toBlob()然后用URL.createObjectURL()赋给img.src。第二打印时iframe内的样式要额外写一点比如page { margin: 0 }能去掉默认边距img { width: 100%; page-break-after: always; }能保证每张图片独占一页。第三不要立刻调用print()要给浏览器一点时间加载图片否则可能出现空白页。延迟500ms是经验值图片多时我会用img.onload计数来替代固定延时。4.3 打印参数控制窗口、页面方向和缩放规则到了打印对话框这一步页面方向、页边距这些其实是用户自己控制的。你可以通过CSSpage规则做少量干预例如强制A4纵向page { size: A4 portrait; margin: 10mm; }但要注意page的size在Chrome和Firefox的渲染支持不一致。Firefox对size: A4有明显支持Chrome在打印预览里也会读取这个值。如果你的用户群体里装了老版本浏览器这些规则可能被忽略所以不要完全依赖CSS最好在iframe的图片里直接用width: 100%做自适应。另外一个细节是打印范围全部页还是当前页需要通过win.print()的前置逻辑控制比如只把要打印的页面渲染到iframe而不是打印全部。我一般会在函数参数里加一个range数组比如[1,3,5]代表只打印这几页这样既控制范围也避免打印太多页面导致内存溢出。5. 常见问题与避坑指南我从三个项目里挖出来的坑5.1 跨域Worker加载失败不是所有报错都叫CORS现象控制台报错信息类似Failed to fetch dynamically imported module或者worker file not found页面一直在加载中canvas始终空白。原因pdf.js在加载worker时采用动态import机制如果你的站点是http://a.com而worker路径配置成了http://b.com/assets/pdf.worker.min.js浏览器出于跨域限制拒绝拉取。还有一种情况是本地开发用file://协议打开页面动态import受CORS策略限制同样失败。解决把GlobalWorkerOptions.workerSrc改成和页面同域的相对路径例如从/assets/开始。同时确认你的服务器对.js文件返回的Content-Type是application/javascript不是text/plain。排查时先打开Network面板看worker请求是不是401或CORS error。另外如果你把pdf.js构建成分离的独立包也要检查打包后worker路径是否被混淆器改动了。5.2 渲染大文件时内存一直涨canvas和Promise顺序都没管好现象打开一份几十MB的PDF翻页二三十次后页面开始卡顿甚至浏览器标签页崩溃。原因每一页渲染都会创建一个canvas元素如果你切换页时只把旧的canvas隐藏掉而不移除它的像素数据仍然占用内存。而且fetch和getPage返回的Promise如果没有及时处理会保持文档对象不释放。核心库本身会缓存页面数据但你调用pdf.destroy()之前这些缓存一直存在。解决每次渲染完成后把不再可见的canvas的width和height置为0或者直接从DOM上移除。对于连续滚动模式要在滚动过程中动态回收离屏页面。另外在单页应用里路由离开预览页面时务必调用pdf.destroy()和loadingTask.destroy()把文档引用清掉。如果你同时监听了progress事件记得移除监听器不然也会形成闭包引用。5.3 文本层错位特别是混合字体和跨页文字现象安装了文本层后复制文字时看到的文字位置和canvas里对应位置对不上或者选中某段文字时高亮区域偏移了几个像素。原因多数情况下是因为viewport不一致。渲染canvas时用了一套scale而文本层渲染时用了另一套scale或者你在window.resize后没有重置两个viewport。另外某些字体本身带glyph偏移pdf.js的文本层对这部分字体的还原偏差比较大。解决把canvas和文本层放进同一个父容器用两套完全相同的viewport参数分别调用setViewport。关键点是文本层的定位基于viewport的width/height和transform你可以在渲染文本层前先textLayerDiv.style.width viewport.width px、height同理。我在demo里会在缩放按钮的回调里同时更新canvas和文本层并强制加一行textLayer.reset()再重建避免编辑器复用旧状态。5.4 打印出来的图片发虚或者被拉伸现象iframe里看到的图片是清晰的但打印出来模糊或者一张图被切成了两页。原因打印分辨率取决于canvas的像素大小。如果你用scale1即72dpi渲染打印到纸上会明显偏虚因为屏幕分辨率和打印机分辨率不对等。另一方面如果img的CSS设了width:100%而原图本身宽度就不足打印机会以物理尺寸输出就会发虚。解决打印渲染的canvas建议用scale1.5或2对应150dpi到300dpi。另外在iframe的CSS里设置page { size: auto; }同时给img加image-rendering: pixelated的逆操作——实际上应该去掉这个属性保持默认的平滑缩放。每次打印前我用一张白底测试页先试打确认边距和缩放正常后再进入批量打印逻辑。6. 进阶性能优化与移动端适配的几个实用技巧如果你准备把demo扩展到生产环境有几件小事值得在代码里提前布局。第一件是虚拟滚动。连续滚动模式下渲染全部页面是灾难比如300页的文档。我一般会在滚动容器内只保留约3个canvas节点当前可视页、前一页和后一页其他区域用占位div撑起高度。核心思想是维护一个pages[pageNum]缓存里面存“是否已渲染”标志滚动时对比新页码和当前渲染集合差的就卸载。占位div的高度计算公式为viewport.height / scale乘上缩放系数这样滚动条的位置始终正确。这么做的好处是内存占用从“所有页”降到“3页”坏处是滚动条跳跃感需要微调比如预先渲染未来两页能缓解白闪。第二件是移动端适配。触摸设备的缩放习惯和桌面完全不同双指缩放事件会间接触发多次resize。我会在触摸开始和结束之间停掉所有渲染任务用一个requestAnimationFrame来合并缩放请求。另外移动端的canvas不要设devicePixelRatio之外的额外放大倍数我在iPhone上用2倍像素渲染会直接卡死后来限制最高scale为2给用户一个“高清模式”开关默认走1.5。第三件是文本搜索和链接跳转。pdf.js的getTextContent返回的对象里有items数组每个item带str和transform矩阵。我利用这些数据实现了简单的定位跳转用户点搜索结果时找到那条item的transform[4]和transform[5]x和y坐标再用viewport的convertToViewportPoint方法映射到canvas像素位置最后用canvas.scrollIntoView({ block: center })。这个功能的坑在于搜索结果可能有重复位置在缩放后要重新计算。最后给PDF渲染加一个进度提示其实很有价值。getDocument的loadingTask可以通过onProgress拿到加载进度但真正耗时的是渲染我一般用page.render().promise加一个requestAnimationFrame计时超过300ms就显示“正在解析复杂页面”。这个优化没有高深技术但能显著降低用户等待时的焦虑感。从第一个demo到上线我唯一坚持的习惯是每次改动渲染相关代码后强制自己用一份包含图表、大字体和扫描文件的PDF做回归测试看看canvas尺寸、文本层和打印结果是否保持稳定。这比任何单元测试都靠谱因为pdf.js在不同文档上的行为差异实在太多。希望这份折腾笔记能帮到你少走几步我当年踩过的弯路。本文还有配套的精品资源点击获取