ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

pdf.js纯前端PDF渲染与精准打印实战指南

pdf.js纯前端PDF渲染与精准打印实战指南 简介本资源是一个基于PDF.js开源库的纯JavaScript PDF在线预览与打印功能Demo面向Web前端开发者及需要嵌入PDF阅读能力的项目实践者解决浏览器端无插件渲染、交互式预览和本地文件加载等核心需求。压缩包共376个文件2.49MB包含72个PNG图标资源、105个properties字体映射配置、168个bcmap编码映射表支撑中日韩多语言PDF正确显示、6个核心JS逻辑文件、3个HTML入口页及配套CSS、SVG与License文件结构完整可直接运行调试。已有9923人学习下载资源附带详细实现说明涵盖PDF文档加载、Canvas/SVG双渲染模式、动态缩放控制、File API本地读取、window.print集成打印等关键代码逻辑并提供web目录下的pdf.worker.js等标准PDF.js运行依赖便于快速理解底层原理并二次开发。1. 为什么你写的 PDF 在线预览总在 IE 或低版本 Chrome 里白屏、打印模糊、文字复制乱码——用 pdf.js 实现真正「纯 JS」的 PDF 渲染与可控打印不依赖后端、不调用系统打印服务、不走 iframe 黑盒你可能已经试过iframe srcxxx.pdf也封装过window.print()甚至引入过某些“PDF 预览组件”——但只要用户一换浏览器、一缩放页面、一点击打印立刻暴露文字渲染失真、中文断字、页边距错位、水印盖不住、选中复制全是乱码。这不是前端写得不够勤而是你没真正站在 pdf.js 的设计原点上干活它不是“把 PDF 塞进网页”而是用 Canvas WebAssembly 逐页重绘 PDF 内容流把 PDF 解析器、字体子集加载、文本布局、矢量路径光栅化全搬到浏览器里跑。这意味着——没有服务端解析、不触发浏览器原生 PDF 插件已淘汰、不依赖系统打印驱动。本文带你从零搭起一个可调试、可定制、可灰度上线的 pdf.js 预览打印方案支持自定义工具栏、保留原始分辨率打印、修复中文宋体/黑体缺失、绕过 Chrome 的“打印时禁用 CSS”玄学限制、让 CtrlP 和工具栏按钮输出完全一致。适合需要嵌入合同/报表/电子证照的中后台系统、教育类文档平台、以及对打印合规性有硬性要求如盖章位置像素级对齐的场景。2. 从 CDN 加载到离线部署pdf.js 的三种接入方式与选型依据pdf.js 不是“一个 JS 文件”而是一套包含PDF 解析器pdf.worker.js、渲染引擎pdf.js、字体资源standard_fonts/和默认 UI 框架viewer.js viewer.html的完整前端 PDF 处理栈。不同接入方式直接影响你能否控制字体加载、是否能拦截渲染错误、打印时能否复用同一份 canvas 缓存。下面按落地优先级排序给出每种方式的适用边界和实操命令。2.1 最简验证CDN 直接加载 viewer.html仅用于快速原型这是官方 demo 的默认路径适合 5 分钟验证 pdf.js 是否能跑通你的 PDF# 下载最新 release以 v3.4.120 为例注意替换为实际版本 curl -L https://github.com/mozilla/pdf.js/releases/download/v3.4.120/pdfjs-3.4.120-dist.zip -o pdfjs-dist.zip unzip pdfjs-dist.zip提示不要直接引用cdn.jsdelivr.net/npm/pdfjs-dist3.4.120/web/viewer.html—— 它会强制加载viewer.js中硬编码的 worker 路径且无法修改defaultUrl。必须本地解压后改配置。解压后进入web/目录编辑viewer.js中的// 找到这一行约第 178 行 var DEFAULT_URL compressed.tracemonkey-pldi-09.pdf; // 改为你自己的 PDF 路径支持相对路径或绝对 URL var DEFAULT_URL /static/docs/contract_v2.pdf;再启动一个静态服务Python 3cd web python3 -m http.server 8000访问http://localhost:8000/viewer.html即可看到带工具栏的完整预览界面。注意此方式下所有 PDF 渲染、字体加载、打印逻辑均由 viewer.js 全权接管你无法干预单页渲染时机、无法劫持打印前的 canvas 数据、无法替换默认字体映射表。仅推荐用于验证 PDF 是否能被正确解析比如测试加密 PDF 是否报错。2.2 生产就绪分离 core worker 自定义 UI推荐 95% 场景这才是真正“纯 JS 实现”的核心姿势只引入pdf.js和pdf.worker.js自己用 Canvas 渲染每一页用 DOM 构建工具栏打印时直接调用print()方法传入自渲染 canvas。这样你才能控制页面缩放策略是 fit-width 还是 actual-size字体 fallback 逻辑当 PDF 内嵌字体缺失时用本地SimSun还是Noto Sans CJK SC打印前是否强制重绘高清 canvas避免缩放后打印模糊是否启用 text layer影响文字选择与搜索但增加内存安装依赖npmnpm install pdfjs-dist3.4.120关键代码结构如下PdfPreview.vue/PdfPreview.tsximport * as pdfjsLib from pdfjs-dist; import pdfWorker from pdfjs-dist/build/pdf.worker.entry; // 必须提前设置 worker 路径否则报错 Missing PDF.js worker pdfjsLib.GlobalWorkerOptions.workerSrc pdfWorker; export default { data() { return { pdfDoc: null as pdfjsLib.PdfDocument | null, currentPage: 1, scale: 1.0, numPages: 0 } }, async mounted() { const loadingTask pdfjsLib.getDocument(/static/docs/contract_v2.pdf); this.pdfDoc await loadingTask.promise; this.numPages this.pdfDoc.numPages; this.renderPage(1); }, methods: { async renderPage(pageNum: number) { const page await this.pdfDoc?.getPage(pageNum); const viewport page!.getViewport({ scale: this.scale }); // 创建 canvas 并获取上下文 const canvas document.getElementById(pdf-canvas) as HTMLCanvasElement; const ctx canvas.getContext(2d); if (!ctx) return; // 设置 canvas 尺寸关键必须 match viewport size否则拉伸失真 canvas.height viewport.height; canvas.width viewport.width; // 渲染到 canvas const renderContext { canvasContext: ctx, viewport: viewport, // 启用 text layer生成 div 叠加在 canvas 上实现文字选择 textLayer: document.getElementById(text-layer), // 启用 annotation layer渲染表单域、链接、注释 annotationMode: pdfjsLib.AnnotationMode.ENABLE_FORMS }; await page!.render(renderContext).promise; } } }参数说明viewport.height/width是 PDF 页面在当前缩放下的真实像素尺寸不是 CSS width/heighttextLayer必须传入一个空 div 节点pdf.js 会自动往里面注入 spanannotationMode若设为DISABLE则表单域不可编辑、链接不可点击——这点常被忽略导致“PDF 看得见但点不了”。2.3 极致可控编译自定义 build适用于金融/政务等强合规场景当你需要移除所有 console.warn审计要求替换内置字体映射表比如把STSong-Light映射到Source Han Serif CN禁用 WebAssembly fallback强制用 JS 解析器确保老旧设备兼容打包时剔除 unused locale如只留 zh-CN就必须从源码构建。步骤如下git clone https://github.com/mozilla/pdf.js.git cd pdf.js npm install # 修改 webpack.config.js设置 mode: production关闭 sourceMap # 修改 src/shared/util.js注释掉所有 console.* 调用 # 修改 src/core/fonts.js修改 fontMap 字段例如 # SimSun: { normal: simsun.ttc, bold: simhei.ttf }, npm run build-generic生成的build/generic/web/即为精简版 dist。注意此 build 不含 viewer.html只提供pdf.js和pdf.worker.jsUI 完全由你掌控——这才是标题中“纯 JS 实现”的终极形态。3. 打印不糊、缩放不失真、文字可选pdf.js 渲染三要素参数详解pdf.js 渲染质量不取决于“用了没”而取决于viewport 构造、canvas 设置、text layer 绑定这三个环节是否严丝合缝。任何一处松动都会导致打印模糊、缩放错位、文字无法复制。下面逐项拆解每个参数的物理意义和取值陷阱。3.1 viewportPDF 页面的“真实世界坐标系”不是 CSS 像素PDF 页面本身有内在 DTPDesktop Publishing单位1 inch 72 points1 point ≈ 1.333 CSS pixels在 96dpi 屏幕下。getViewport()返回的height/width是这个坐标系下、经缩放后的真实像素数它决定了 canvas 应该画多大。常见错误写法// ❌ 错误用 CSS width/height 覆盖 canvas导致 canvas 内容被浏览器拉伸 canvas.style.width 100%; canvas.style.height auto; // ✅ 正确canvas 的 width/height 属性必须等于 viewport 尺寸 canvas.width viewport.width; canvas.height viewport.height; // 再用 CSS 控制显示尺寸保持宽高比 canvas.style.width 100%; canvas.style.height auto;getViewport()接收对象参数关键字段参数类型默认值说明scalenumber1.0缩放倍数。1.0 100%1.5 150%。注意不是 CSS transform scalerotationnumber0顺时针旋转角度0/90/180/270PDF 原生支持非 CSS rotatedontRoundbooleanfalse是否禁用像素四舍五入。设为 true 可避免小数像素导致的模糊尤其打印时必开打印前务必用dontRound: true重建 viewportconst printViewport page.getViewport({ scale: window.devicePixelRatio || 1, // 高 DPI 屏幕用 2x 渲染 rotation: 0, dontRound: true // ⚠️ 关键否则打印边缘出现半像素锯齿 });3.2 canvas必须用.width/.height设置而非 CSSCanvas 的渲染分辨率由其width/height属性决定CSSstyle.width只控制显示尺寸。若两者不一致浏览器会插值缩放造成文字毛边、线条虚化。正确初始化顺序// 1. 先设 canvas 原生尺寸 canvas.width viewport.width; canvas.height viewport.height; // 2. 再设 CSS 显示尺寸可响应式 canvas.style.width 100%; canvas.style.height ${viewport.height}px; // 或用 aspect-ratio: ...; // 3. 获取 context此时 canvas 已具备真实分辨率 const ctx canvas.getContext(2d);血泪经验曾有个项目在 iPad 上打印模糊排查发现是canvas.style.width 100vw导致 canvas 原生宽度被浏览器错误计算。最终解法是canvas.width/height 必须用整数且等于 viewport 整数像素值CSS 仅做 display 控制绝不参与尺寸计算。3.3 textLayer文字层不是可选而是“可选文字”的基础设施textLayer 是一个绝对定位的divpdf.js 会往里面注入span每个 span 对应 PDF 中一个文本 glyph并设置left/top/width/height精确定位。只有它存在才能用户用鼠标拖选文字document.execCommand(copy)复制内容window.find()搜索关键词屏幕阅读器读出文字创建 textLayer 的标准方式div idpdf-container styleposition: relative; canvas idpdf-canvas/canvas !-- textLayer 必须与 canvas 同级、同 position -- div idtext-layer styleposition: absolute; left: 0; top: 0; pointer-events: none;/div /div关键 CSS#text-layer span { /* 必须重置所有 inherited 样式否则 span 会继承父级 font-size */ font-size: 0; line-height: 0; /* 让 span 精确覆盖 canvas 上对应文字区域 */ position: absolute; white-space: pre; cursor: text; }注意textLayer 的pointer-events: none是为了不影响 canvas 上的点击事件如 annotation 点击但 span 本身需设cursor: text否则用户看不到光标。4. 打印翻车现场直击pdf.js 打印的 4 个致命坑与绕过方案pdf.js 的打印能力常被高估——它不生成 PDF只是把当前 canvas “截图”后调用window.print()。这导致大量看似合理、实则必翻车的组合。以下是我在线上系统踩过的 4 个真实坑附带可立即抄作业的修复代码。4.1 坑一Chrome 打印时 canvas 模糊文字发虚高频现象页面预览清晰但 CtrlP 或点击打印按钮后输出纸张上的文字边缘全是灰色锯齿尤其小字号中文。原因Chrome 打印预览默认将 canvas 渲染为 96dpi 位图而屏幕是 144dpi/220dpi。pdf.js 默认用scale1渲染未适配 devicePixelRatio。解决打印前用高 DPI 重绘 canvasasync function preparePrint() { const page await this.pdfDoc?.getPage(this.currentPage); const dpiScale window.devicePixelRatio || 1; const printViewport page.getViewport({ scale: dpiScale, dontRound: true }); const canvas document.getElementById(pdf-canvas) as HTMLCanvasElement; canvas.width printViewport.width; canvas.height printViewport.height; const ctx canvas.getContext(2d); const renderContext { canvasContext: ctx, viewport: printViewport, textLayer: null, // 打印时禁用 textLayer避免 span 重叠 }; await page.render(renderContext).promise; // 强制浏览器使用 canvas 当前内容打印绕过 viewport 缩放 const printFrame document.getElementById(print-frame) as HTMLIFrameElement; const doc printFrame.contentDocument; doc.body.innerHTML img src${canvas.toDataURL()} stylewidth:100%;height:auto;; printFrame.contentWindow?.focus(); printFrame.contentWindow?.print(); }关键点toDataURL()生成的是 bitmap不受 CSS 缩放影响用 iframe 打印可完全隔离样式干扰。4.2 坑二打印页眉页脚遮挡内容且无法通过 CSSmedia print移除现象Chrome 打印对话框中勾选“页眉页脚”PDF 内容被裁剪取消勾选后页脚仍残留“URL 页码”。原因Chrome 的页眉页脚是浏览器进程级渲染CSSmedia print { page { margin: 0 } }无效。解决用window.print()前注入隐藏样式并监听beforeprintfunction setupPrintStyles() { const style document.createElement(style); style.textContent media print { body * { visibility: hidden; } #print-content, #print-content * { visibility: visible; } #print-content { position: absolute; left: 0; top: 0; } page { margin: 0; } } ; document.head.appendChild(style); } window.addEventListener(beforeprint, () { setupPrintStyles(); }); // 打印触发 document.getElementById(print-btn).addEventListener(click, () { // 先渲染高清 canvas 到 #print-content document.getElementById(print-content).innerHTML ; const canvas document.getElementById(pdf-canvas); const img new Image(); img.src canvas.toDataURL(); document.getElementById(print-content).appendChild(img); window.print(); });4.3 坑三PDF 中的表单域input/select打印后变成空白或乱码现象PDF 里有签名框、日期选择器预览时可交互但打印后该区域一片空白或显示[Field]。原因pdf.js 的 annotation 渲染默认只支持 visual rendering不导出表单域值且 Chrome 打印不识别 canvas 上的表单语义。解决打印前用getAnnotations()提取表单值生成 overlay DOMasync function renderFormOverlay(pageNum) { const page await this.pdfDoc?.getPage(pageNum); const annotations await page.getAnnotations({ intent: interactive }); const overlayDiv document.createElement(div); overlayDiv.id form-overlay; overlayDiv.style.cssText position:absolute;top:0;left:0;pointer-events:none;; annotations.forEach(annot { if (annot.fieldValue) { const span document.createElement(span); span.textContent annot.fieldValue; span.style.cssText position:absolute; left:${annot.rect[0]}px; top:${annot.rect[1]}px; width:${annot.rect[2]-annot.rect[0]}px; height:${annot.rect[3]-annot.rect[1]}px; font-size:12px; overflow:hidden; ; overlayDiv.appendChild(span); } }); document.getElementById(pdf-container).appendChild(overlayDiv); }4.4 坑四中文宋体/黑体缺失显示为方块或默认 sans-serif现象PDF 里用SimSun但浏览器渲染成 Helvetica文字全变方块。原因pdf.js 内置字体只含 Latin 字符中文需额外加载cmaps和standard_fonts且必须匹配 PDF 中的字体名。解决预加载中文字体并注册// 在 pdfjsLib.getDocument 前执行 pdfjsLib.GlobalWorkerOptions.cMapUrl /static/cmaps/; pdfjsLib.GlobalWorkerOptions.cMapPacked true; // 注册 SimSun 字体需提前下载 simsun.ttc 到 /static/fonts/ pdfjsLib.FontLoader.registerFont({ url: /static/fonts/simsun.ttc, name: SimSun }); // 若 PDF 使用 /STSong-Light需映射 pdfjsLib.core.fonts.addFontMapping(STSong-Light, SimSun);注意cMapUrl必须指向cmaps/目录内含gbk.js、unicode-big5.js等cMapPacked: true表示用压缩版 cmap减小体积。5. 从“能用”到“好用”三个生产级技巧让 pdf.js 真正扛住百万级文档流量做到“能预览、能打印”只是起点。在日均 50w PDF 请求的合同平台中我们靠以下三个技巧把首屏时间压到 800ms 内、打印失败率降至 0.02%、内存泄漏归零。这些不是文档里的“可选项”而是线上系统存活的底线。5.1 技巧一PDF 加载阶段做 progressive rendering分页渐进渲染用户打开一个 100 页 PDF不该等到全部加载完才显示第 1 页。pdf.js 支持range加载但需手动切片// 将 PDF 按 5MB 分片实测平衡速度与内存 const chunkSize 5 * 1024 * 1024; const loadingTask pdfjsLib.getDocument({ url: /static/docs/large.pdf, range: new pdfjsLib.PDFDataRangeTransport({ getRange: (start, end) { return fetch(/api/pdf-range?filelarge.pdfstart${start}end${end}) .then(r r.arrayBuffer()); } }) });更轻量的做法是先加载第 1 页的 header前 8KB拿到 pages 数量后立即渲染第 1 页其余页 lazy load// 用 Range Request 获取前 8KB fetch(/static/docs/large.pdf, { headers: { Range: bytes0-8191 } }) .then(r r.arrayBuffer()) .then(buf { // 解析 PDF header提取 /Pages count需用 pdfjs 的 parser 工具 const doc pdfjsLib.getDocument(new Uint8Array(buf)); doc.then(pdf { this.numPages pdf.numPages; this.renderPage(1); // 立即渲染第 1 页 // 其余页滚动到可视区时再加载 this.lazyLoadPages(); }); });5.2 技巧二打印时复用渲染缓存避免重复 decode rasterize每次打印都重新page.render()CPU 占用飙升。解决方案为每页维护一个 canvas cache mapprivate pageCache new Mapnumber, HTMLCanvasElement(); async renderPage(pageNum: number, force?: boolean) { if (!force this.pageCache.has(pageNum)) { const cached this.pageCache.get(pageNum)!; const canvas document.getElementById(pdf-canvas) as HTMLCanvasElement; canvas.width cached.width; canvas.height cached.height; canvas.getContext(2d)!.drawImage(cached, 0, 0); return; } const page await this.pdfDoc?.getPage(pageNum); const viewport page.getViewport({ scale: this.scale }); 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; this.pageCache.set(pageNum, canvas); // 插入 DOM const target document.getElementById(pdf-canvas); target.width canvas.width; target.height canvas.height; target.getContext(2d)!.drawImage(canvas, 0, 0); }注意cache key 必须包含scale和rotation因为同一页面不同缩放会产生不同 canvas。5.3 技巧三内存泄漏终结者——显式销毁 pdfDoc 与 event listenerpdf.js 不会自动 GC 页面对象。若用户频繁切换 PDFpdfDoc、page、renderTask全部驻留内存beforeUnmount() { // 1. 取消所有 pending render tasks if (this.renderTask) { this.renderTask.cancel(); } // 2. 销毁 pdfDoc释放 wasm memory if (this.pdfDoc) { this.pdfDoc.destroy(); this.pdfDoc null; } // 3. 清理 textLayer DOM const textLayer document.getElementById(text-layer); if (textLayer) textLayer.innerHTML ; // 4. 移除 beforeprint listener window.removeEventListener(beforeprint, this.setupPrintStyles); }特别提醒pdfDoc.destroy()是 pdf.js v2.10 新增 API旧版本需手动delete pdfDoc._transport若用 Vue务必在beforeUnmount非destroyed中调用否则组件已卸载this为空。我上线第一个 pdf.js 项目时以为“引入就能用”。结果上线三天客服收到 27 条“打印出来全是方块”的投诉运维报警说 Node 进程内存涨到 4GB——后来才发现是忘了调pdfDoc.destroy()100 个 PDF 加载后WASM 内存全没释放。现在我的习惯是每写一行 pdf.js 代码先想三件事——这个对象会不会 leak这个 canvas 能不能 reuse这个打印会不会 trigger browser bugpdf.js 不是黑匣子它是你亲手组装的精密仪器。拧紧每一颗螺丝它才能稳稳托住你的业务。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进