ARTICLE DETAIL

资讯详情

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

纯前端PDF在线预览与打印:基于pdf.js的实战与避坑指南

纯前端PDF在线预览与打印:基于pdf.js的实战与避坑指南 简介面向前端开发者的PDF.js开源演示项目以纯JavaScript实现PDF在线预览、打印、页面缩放及本地文件打开适配主流现代浏览器无需安装任何插件。压缩包共376个文件、2.49MB涵盖JavaScript核心库、HTML示例页面、样式表、PDF样例及bcmap编码资源内含src与web目录、example.js及说明文档便于对照学习。项目已有9918人学习适合希望快速掌握PDF.js API调用、渲染流程与打印交互的中级前端工程师。资源提供完整Demo源码附带官方pdf.js与pdf.worker.js文件并总结页面缓存、Web Worker后台解析、大文件分页加载等优化技巧同时介绍使用File API读取本地文件、通过scale参数动态缩放以及window.print触发打印的方法也有助于理解旧版浏览器兼容性和不可信文件的安全风险可深入理解跨平台免插件的PDF渲染方案并扩展至注释、搜索等丰富场景。1. pdf.js Demo纯 JS 实现 PDF 在线预览与打印的落地资源做 Web 端 PDF 预览绝大多数人第一反应是浏览器自带预览——地址栏扔一个 .pdf 链接浏览器自己渲染。但凡是做过 OA、ERP、在线教育或合同系统的都知道这条路有多窄Chrome 与 Edge 表现尚可换成国产浏览器或低版本内核直接变成下载提示或一片空白更不用说打印布局完全不可控。pdf.js 解决的正是这个问题它把 PDF 解析和渲染全部搬进浏览器用纯 JavaScript 完成从文件流到 canvas 位图的转换不依赖任何插件或后端服务在线预览、缩放、旋转、打印都在一套 API 里完成。这篇笔记拆的是一份 pdf.js Demo 资源覆盖 getDocument 加载、page.render 渲染、canvas 显示和打印触发四条主线并给出部署文件结构、参数配置和真实项目里的踩坑记录。适合三类人被浏览器兼容性折磨的前端开发、需要在内网环境做文档预览的交付工程师、以及想快速在管理系统里嵌入 PDF 能力的全栈开发者。2. pdf.js 的渲染原理Canvas、Worker 与异步管线2.1 Worker 线程为什么 PDF 解析不能放在主线程pdf.js 的架构核心是 Web Worker。PDF 文件的解析本质是一个流式词法分析过程读取字节流、解析对象树、重建页面内容流、执行字体与图像解码这套逻辑如果跑在浏览器主线程上一个 10MB 的 PDF 就能让页面卡死好几秒用户点击任何按钮都没有响应。pdf.js 把解析过程塞进专用 Worker主线程只负责把 ArrayBuffer 传给 WorkerWorker 解析完成后回传一个 PDFDocumentProxy 对象主线程再用它来请求具体页面。这份 Demo 里用的是pdf.js/build/pdf.worker.mjs引入方式和老版本有一些差别。新版本的典型写法是import * as pdfjsLib from pdfjs-dist; // 关键告知 pdf.js 去哪里找 worker 文件 pdfjsLib.GlobalWorkerOptions.workerSrc ./pdf.worker.mjs; const loadingTask pdfjsLib.getDocument(./sample.pdf); loadingTask.promise.then((pdf) { console.log(PDF 加载完成共 ${pdf.numPages} 页); });逻辑说明workerSrc指向 worker 脚本的 URLpdf.js 内部会用new Worker()创建解析线程。getDocument返回一个PDFDocumentLoadingTask它的promise在文档解析成功后 resolve此时拿到的是PDFDocumentProxy还没有渲染任何页面。参数说明workerSrc必须是一个可以被当前页面同源访问的路径跨域 worker 会直接报 SecurityError。另外新版 pdf.js 要求pdf.worker.mjs与主库版本完全一致混用版本大概率出现「The API version does not match the Worker version」的报错。血的教训这种版本不一致问题表现很隐蔽控制台只有一行 warning但渲染出来的页面可能是空白或残缺。2.2 页面渲染管线从 PDFPageProxy 到 canvas拿到PDFDocumentProxy之后渲染过程是逐页进行的。每页对应一个PDFPageProxy调用它的render方法把页面内容绘制到 canvas 的 2D 上下文中。async function renderPage(pdf, pageNumber, canvas) { const page await pdf.getPage(pageNumber); const viewport page.getViewport({ scale: 1.5 }); // 设置 canvas 尺寸与 viewport 匹配 const context canvas.getContext(2d); canvas.width viewport.width; canvas.height viewport.height; const renderTask page.render({ canvasContext: context, viewport: viewport }); await renderTask.promise; console.log(第 ${pageNumber} 页渲染完成); }逻辑说明getPage从已解析的文档中提取指定页每次调用都会执行页面对象树的懒加载所以循环渲染时不需要一次性把全部页面读进内存。getViewport({ scale })根据页面的原始尺寸和缩放因子计算渲染尺寸canvas 的宽高必须手动对齐 viewport否则渲染结果会被拉伸或裁切。参数说明scale直接决定渲染清晰度。1.0 表示 100% 原生大小但普通屏幕下 PDF 页面宽度通常超过 700px所以实际项目中一般用 1.5 或 2.0。注意render返回的是RenderTask它也有promise属性await它才能确保绘制完成。如果需要在渲染中途取消比如用户快速翻页可以调用renderTask.cancel()。2.3 Demo 目录结构与关键文件职责拿到这份 Demo 资源后第一件事不是打开 HTML而是理清文件结构。一个标准的 pdf.js Demo 目录通常长这样文件/目录职责是否需要改动index.html页面骨架canvas 容器与按钮按需main.js核心逻辑加载 PDF 并渲染必改pdf.jspdf.js 主库一般不碰pdf.worker.mjsworker 线程脚本一般不碰sample.pdf演示文档可替换为自己的文件print.js打印逻辑封装按需理解这份结构的价值在于pdf.js 的主库文件是压缩过的生产构建不需要读它的源码但你要知道pdf.worker.mjs必须放在workerSrc指向的位置。很多新手把整个目录按原样塞进项目后发现本地打开 HTML 能跑部署到服务器就报错原因就是workerSrc用了相对路径而部署后的路径层级变了。我一般建议用绝对路径或用构建工具处理静态资源引用而不是手动写相对路径。3. 把 Demo 跑起来本地部署与第一个可交互预览3.1 版本检查先确认你手里的 pdf.js 是哪个大版本pdf.js 的 API 从 v2 到 v3、v4 有过几次破坏性变更最典型的就是GlobalWorkerOptions.workerSrc的赋值方式和模块格式。v2 时代用pdfjsLib.getDocument全局变量v3 之后是 ES Module 的import方式。如果你拿到的是旧版 Demo直接复制代码到新版环境里会报pdfjsLib is not defined这不是代码写错了是引入方式没跟上版本。检查办法很快用编辑打开pdf.js文件头部看版本注释。如果是 2.xAPI 调用方式是pdfjsLib.getDocument如果是 3.x 以上通常需要使用import * as pdfjsLib from pdfjs-dist。这份 Demo 里如果带package.json也可以看dependencies.pdfjs-dist的版本号。版本决定了后面所有代码怎么写这一步省不得。3.2 Vite 快速搭建演示环境如果是纯静态 Demo直接用浏览器打开index.html就行但有两个限制一是 ES Module 在file://协议下有跨域限制二是 worker 文件无法通过file://正常加载。所以本地跑 Demo 时我习惯起一个最简 Vite 服务# 在 Demo 目录下初始化 npm init -y npm install vite --save-dev npx vitenpx vite启动后终端会打印一个本地地址浏览器打开就是正式的 HTTP 环境worker 脚本和模块加载都不会有协议限制。如果不想装 Node 环境也可以直接用 Python 的http.serverpython3 -m http.server 8080提示file://直接打开 HTML 时import语句会被浏览器安全策略拦下这是最常见的「代码没问题但就是跑不起来」原因。3.3 加载本地 PDF 与远程 PDF 的差异处理Demo 默认加载同目录下的sample.pdf换成自己的文件时注意两点。第一如果 PDF 放在同域名下直接传 URL 字符串给getDocument即可第二如果 PDF 来自后端接口或第三方对象存储需要先取回 ArrayBuffer 再传给getDocumentasync function loadPDF(url) { let data; if (url.startsWith(http)) { const response await fetch(url); data await response.arrayBuffer(); } else { // 本地文件用 FileReader 读取 data await readFileAsArrayBuffer(url); } const loadingTask pdfjsLib.getDocument({ data }); return loadingTask.promise; } function readFileAsArrayBuffer(file) { return new Promise((resolve, reject) { const reader new FileReader(); reader.onload () resolve(reader.result); reader.onerror reject; reader.readAsArrayBuffer(file); }); }逻辑说明getDocument支持直接传 URL 字符串也支持传{ data: ArrayBuffer }。传 URL 时 pdf.js 内部自己发fetch请求但请求头、鉴权方式都不可控所以实践中更推荐自己先fetch拿 ArrayBuffer再交给 pdf.js 解析这样可以把 token 加到请求头里也能在加载失败时统一处理错误。参数说明fetch拿到的response.arrayBuffer()才是真正的二进制数据response.text()会破坏 PDF 的字节结构。如果后端返回的是 Base64 字符串记得用atob解码后再转 ArrayBuffer这一步绕不过去。3.4 把 Demo 里的写死路径改成可配置项拿到 Demo 后我会先把main.js里写死的 PDF 路径、缩放值、初始页码抽成配置对象后面部署到不同项目时只改配置不改代码const config { pdfUrl: ./sample.pdf, // 替换为目标 PDF 路径 initialScale: 1.5, // 首屏缩放 maxScale: 3.0, // 最大放大倍数 minScale: 0.5, // 最小缩小倍数 defaultPage: 1, // 默认显示页码 enablePrint: true // 是否显示打印按钮 };这样做的直接好处是你把这份 Demo 发给同事做集成测试时对方不需要理解渲染逻辑只要改config就能看到效果。这个习惯是从多次交付项目里养出来的——每次交付 Demo 都会被问「怎么换文件」抽成配置后这个问题彻底消失。4. 打印功能实现从 canvas 到打印机4.1 两种打印方案的取舍预览做好之后打印是用户必点的功能。pdf.js Demo 里通常有两种实现路径第一种是用iframe加载 PDF 原文件然后调iframe.contentWindow.print()浏览器原生打印对话框直接处理第二种是把 canvas 转成图片或复制到隐藏打印容器再触发window.print()。第一种方案简单直接但有两个问题如果 PDF 是 ArrayBuffer 加载的需要临时生成 Blob URL如果目标浏览器 PDF 插件被禁用iframe 里就是空白。第二种方案可控性更强所有打印内容都是你自己生成的 canvas 图片布局、缩放、边距都能精确控制缺点是打印多页时需要循环把每页 canvas 塞进打印容器。这份 Demo 的打印逻辑走的是第二条路——canvas 渲染完成后把 canvas 节点克隆到专门为打印准备的隐藏div里然后调用window.print()核心代码async function printPDF(pdf) { const printContainer document.getElementById(print-container); printContainer.innerHTML ; // 清空上次的打印内容 for (let i 1; i pdf.numPages; i) { const page await pdf.getPage(i); const viewport page.getViewport({ scale: 2.0 }); const canvas document.createElement(canvas); canvas.width viewport.width; canvas.height viewport.height; const ctx canvas.getContext(2d); await page.render({ canvasContext: ctx, viewport: viewport }).promise; printContainer.appendChild(canvas); } window.print(); }逻辑说明循环逐页渲染到临时 canvas全部挂到print-container节点下。scale: 2.0是为了保证打印清晰度——屏幕显示 1.5 倍就够但打印稿的像素密度需要高一些否则文字边缘会发虚。参数说明window.print()触发的是浏览器全局打印对话框打印范围受 CSS 控制。print-container里的 canvas 需要配合media print样式才能确保打印时只输出这部分内容见下节。4.2 打印样式与分页处理直接appendChild把所有 canvas 放进同一个容器打印时会出现跨页截断。每个 canvas 代表一个 PDF 页面需要在 CSS 里强制每个 canvas 独占一页media print { body * { visibility: hidden; } #print-container, #print-container * { visibility: visible; } #print-container { position: absolute; left: 0; top: 0; margin: 0; } #print-container canvas { page-break-after: always; break-after: page; width: 100%; max-height: 100vh; object-fit: contain; } }逻辑说明核心思路是「隐藏一切显式展示打印容器」。visibility: hidden不会改变布局占位所以#print-container之外的内容原样占着位但不可见打印输出里就只有 canvas 内容。break-after: page是 CSS 分页标准属性page-break-after是旧版浏览器的兼容写法两个都写上覆盖更多内核。参数说明max-height: 100vh在打印语境下等于「不超过一页纸的高度」配合object-fit: contain可以等比缩放避免 PDF 页面比纸张长时被切掉。不同打印机的实际可打印区域不同100vh 只是近似值遇到特殊场景再调。这个 CSS 是「玄学」重灾区——同一个样式在 Chrome 和 Edge 上打印结果不同我遇到过一次 Chrome 正常但 Edge 每页只打印半截最后是给 canvas 加了固定宽度width: 100%同时设height: auto解决。4.3 旋转与尺寸参数让预览和打印结果一致PDF 页面自带旋转属性某些扫描件或 CAD 导出的图纸是横向的。getViewport支持传入rotation参数Demo 里如果要加旋转功能关键代码是const viewport page.getViewport({ scale: currentScale, rotation: currentRotation // 0, 90, 180, 270 });逻辑说明旋转是页面级的属性pdf.js 在计算 viewport 时会自动交换宽高。如果你做了旋转按钮需要重新获取 viewport 并重新设置 canvas 尺寸然后再次调用page.render。注意渲染已经完成后改变旋转会触发整个页面重绘频繁旋转时会出现短暂的空白可以在渲染前显示 loading 遮罩。参数说明rotation必须是 90 的倍数传 45 会被忽略。从用户交互角度角度值应该存到全局状态里每次旋转点击做(currentRotation 90) % 360的递增。这里的坑在于打印时的旋转与屏幕预览是两套状态屏幕旋转了 90 度打印时没有同步同样旋转用户就会投诉「预览是好的打印出来方向不对」。所以打印循环里要沿用当前旋转角度不是用默认的 0。5. 避坑与排查这份 Demo 最常见的五个真实坑点5.1 worker 文件 404现象页面初始化时报Failed to fetch dynamically imported module: ./pdf.worker.mjs预览区域一直空白。原因workerSrc配置的路径在部署后找不到文件。最常见的是把 Demo 目录直接拖进 Spring Boot 或 Nginx 的静态资源目录目录层级变更导致相对路径失效。解决GlobalWorkerOptions.workerSrc改成绝对路径或者在构建流程中把pdf.worker.mjs复制到输出目录然后手动改workerSrc为构建后的相对路径。我用 Vite 时会在vite.config.js里配置worker格式为 es并用url引入方式import workerUrl from pdfjs-dist/build/pdf.worker.mjs?url; pdfjsLib.GlobalWorkerOptions.workerSrc workerUrl;这种方式由构建工具生成正确的资产路径开发环境和生产环境都不会 404。5.2 跨域导致的加载失败现象控制台报Access to fetch at ... has been blocked by CORS policyPDF 加载失败。原因getDocument传入远程 URL 时pdf.js 发起的请求违反目标服务器 CORS 策略。常见于把 PDF 放在 OSS 或 MinIO 对象存储上但存储桶未开放跨域。解决两选一。后端在响应头加Access-Control-Allow-Origin: *或指定域名前端改为先fetch拿 ArrayBuffer 再解析但fetch同样受 CORS 约束所以本质还是要后端放行。如果对象存储不支持配置 CORS就改成后端代理下载 PDF 再返回二进制流前端拿 ArrayBuffer 处理。5.3 大文件渲染内存溢出现象50MB 以上的 PDF渲染到十几页时浏览器崩溃或页签变灰内存占用持续攀升。原因每页渲染产生一个 canvas 位图canvas 内存占用约等于width * height * 4字节。一个 A4 页面的 2 倍缩放 canvas内存轻松超过 20MB循环加载几十页就爆了。解决虚拟化渲染——只渲染当前可视区域内的页面离开视口就销毁 canvas 并释放引用。常用做法是监听滚动事件维护一个可见页索引集合function updateVisiblePages(scrollTop, viewportHeight) { const startPage Math.floor(scrollTop / estimatedPageHeight) 1; const endPage Math.ceil((scrollTop viewportHeight) / estimatedPageHeight); // 销毁不可见页面的 canvas for (let p of renderedPages) { if (p startPage - 1 || p endPage 1) { removeCanvas(p); } } // 补充渲染新进入视口的页面 for (let p startPage; p endPage; p) { if (!renderedPages.has(p)) { renderPageIntoView(p); } } }参数说明estimatedPageHeight需要根据页面原始尺寸和当前缩放值估算建议第一页渲染后取实际高度作为基准而不是写死。销毁 canvas 时要同时从 DOM 移除节点并置空引用否则 GC 不会回收位图内存。这是所有 PDF 预览项目的必经之路Demo 本身可能只做了简单连续渲染接入真实项目时必须加这层。5.4 字体渲染缺字或乱码现象PDF 里的中文或特殊字体显示为方块、空白或乱码。原因pdf.js 解析 PDF 内嵌字体失败或者字体子集在解码过程中出错。常见于用方正字体、CAD 图纸导出、以及某些国产设计软件的 PDF 输出。解决升级 pdf.js 到最新版本——字体解析是 pdf.js 重点优化领域新版本修复了大量 CID 字体问题。另外检查 PDF 是否真的内嵌了字体用pdfinfo命令查看如果没有内嵌重新生成 PDF 时勾选嵌入字体选项。纯前端层面无法补全缺失字体这是文件本身的问题只能提示用户换源文件。5.5 打印空白或只出最后一页现象点击打印按钮后打印预览里有大量空白页或者只显示最后一页的内容第一页丢失。原因打印循环里await page.render().promise在主线程与打印事件之间存在异步竞态。window.print()在渲染循环还没有全部完成时就被调用了打印的内容是不完整的。另一个常见情况是 canvas 的visibility: hidden状态下没有触发重排打印容器尺寸为 0内容被折叠。解决打印前强制等待所有渲染任务完成并做一次容器高度确认async function handlePrint() { await renderAllPagesForPrint(); // 确保所有页面渲染完成 // 强制重排确保打印容器尺寸正确 const container document.getElementById(print-container); const height container.offsetHeight; // 读取一次高度强制 reflow if (height 0) { console.error(打印容器高度为 0请检查 CSS); } window.print(); }逻辑说明offsetHeight的读取会强制浏览器执行同步排版确保隐藏容器内的 canvas 布局信息已存在。打印事件不要用setTimeout硬等因为渲染完成时间和 setTimeout 之间没有确定性关系必须用await renderTask.promise保证顺序。6. 进阶玩法给 Demo 加上手势缩放与缩略图侧栏预览与打印跑通之后如果是要交付给客户使用的系统还差两个能力移动端手势缩放和缩略图导航。这份 Demo 的渲染逻辑完全可以扩展出这两个功能具体做法如下。手势缩放的核心是监听触摸事件以双指距离变化作为缩放因子。实现起来需要维护一个基准距离和基准缩放值let baseDistance 0; let baseScale currentScale; canvas.addEventListener(touchstart, (e) { if (e.touches.length 2) { baseDistance getDistance(e.touches[0], e.touches[1]); baseScale currentScale; } }); canvas.addEventListener(touchmove, (e) { if (e.touches.length 2) { e.preventDefault(); const distance getDistance(e.touches[0], e.touches[1]); const newScale Math.min(config.maxScale, Math.max(config.minScale, baseScale * distance / baseDistance)); // 触发重绘 renderCurrentPage(newScale); } }); function getDistance(touchA, touchB) { const dx touchA.clientX - touchB.clientX; const dy touchA.clientY - touchB.clientY; return Math.sqrt(dx * dx dy * dy); }参数说明缩放范围必须做上下限钳制否则用户在手机上不断放大canvas 尺寸会膨胀到内存爆炸。currentScale是全局状态每次缩放后更新滚动翻页时也要基于最新的缩放值计算页面高度。缩略图侧栏相对简单复用渲染管线但缩小 scaleasync function generateThumbnails(pdf) { const sidebar document.getElementById(sidebar); for (let i 1; i pdf.numPages; i) { const page await pdf.getPage(i); const viewport page.getViewport({ scale: 0.3 }); const canvas document.createElement(canvas); canvas.width viewport.width; canvas.height viewport.height; const ctx canvas.getContext(2d); await page.render({ canvasContext: ctx, viewport: viewport }).promise; canvas.addEventListener(click, () { currentPage i; renderCurrentPage(config.initialScale); }); sidebar.appendChild(canvas); } }参数说明scale: 0.3是经验值侧栏宽度在 200px 左右时清晰度刚好太大浪费内存太小看不清内容。点击缩略图表切到目标页时用现有完整渲染管线重新绘制主区域。这一步做完你的 Demo 就从一个只能看单页的样例变成了接近成熟阅读器的原型。我在交付一个移动端审批系统时就吃过亏——部署给客户后对方反馈「手机上不能放大没法看清图纸细节」后来我养成了一个习惯任何 PDF 预览功能做完第一件事是模拟移动端测试双指缩放第二件事是拿一个 30MB 以上的图纸文件压一遍。这两个动作每次都强制走一遍从那以后基本没再接到过预览类的返工单。希望这份拆解能帮你少走几步弯路。本文还有配套的精品资源点击获取
返回列表