☰
pdf.js 1.9.426 内网离线 PDF 预览与中文渲染避坑
2026/11/5 2:30:38 网站建设 项目流程

简介:这是一套面向需要在网页中嵌入 PDF 阅读能力的开发者、前端工程师与项目维护者的 pdf.js 1.9.426 开源实现压缩包,重点解决纯 JavaScript 渲染 PDF、免装浏览器插件的问题。包内共 372 个文件,包含 168 个 bcmap 字符编码映射、105 个 properties 多语言资源、74 个 png 与 9 个 svg 界面图标,以及核心 js、map、css、html、cur 和示例 pdf 等,整体约 5.54MB,目录按渲染所需资源组织,便于直接集成与按需排错。其缩放、打印、查找等功能齐全,兼容火狐、谷歌及 IE9 以上浏览器,但文件体积偏大可能影响首屏加载,且基于 H5 技术不支持 IE8 及以下。已有 993 人学习下载,适合想研究 PDF 预览实现、对照编码映射与界面资源进行二次开发的读者。

1. 老项目为什么还锁着 pdf.js 1.9.426:三个绕不开的现场

内网机器上的浏览器内核停在几年前,装不了插件,也不连外网——这种环境下要在线看 PDF,能选的路其实不多。pdfjs1.9.426 这个包就是为这类现场准备的:它是 pdf.js 的一条老分支产物,整包是 ES5,不依赖构建工具,解压后丢进静态目录就能跑,自带阅读器外壳,也自带解析 worker。它解决的不是“最新最好”,而是“老浏览器能开、离线能部署、中文不豆腐”。适合三类人:还在维护内网系统的后端、要给老后台加预览功能的前端、以及需要把 PDF 渲染嵌进桌面壳子里的客户端开发。

它也有明确的边界:不支持新版 API 的对象化参数,不支持后来才有的某些渲染特性,遇到加密和复杂字体要靠目录里的配套资源补齐。后面的章节按“解压看结构 → 最小跑通 → 报表参数 → 中文与文字层 → 避坑 → 进阶”推下去,每一步都能直接抄。

2. 解压后先别急着部署:build 与 web 两个目录的分工

拿到包,很多人第一反应是把整个目录往服务器一扔,然后发现首页打得开、点进 PDF 就白屏。问题几乎都出在没有分清build和web这两个目录各自的职责。

2.1 目录里到底装了什么

build是库本体,只有两个文件真正重要:主库和 worker。主库负责对外暴露 API、管理文档生命周期;worker 负责真正的解析和渲染计算,跑在独立的线程里。这两个文件必须同版本、同批次,混用就是后面 5.2 节要讲的版本错配。

web是官方给的一个完整阅读器外壳,包含viewer.html、viewer.js、样式表,以及三个容易被忽略但缺了就出问题的资源目录:cmaps(CID 字体的编码映射表,中文 PDF 全靠它)、standard_fonts(PDF 里只写了字体名却没内嵌字体时的替代字形)、locale(界面语言包)。典型的层级关系是这样:

pdfjs-1.9.426/ ├── build/ │ ├── pdf.js # 主库,UMD 打包,挂到 window.PDFJS │ └── pdf.worker.js # 解析 worker,必须与主库同版本 └── web/ ├── viewer.html # 自带阅读器外壳,可直接用,也可只当参考实现 ├── viewer.js ├── pdf_viewer.css ├── cmaps/ # .bcmap 编码表,中文乱码的元凶就在这 ├── standard_fonts/ # 标准字体替代文件 └── locale/

注意:不同打包方式可能把cmaps放在根目录而不是web下,解压后先find . -name "*.bcmap" | head确认一下层级,后面配cMapUrl要用真实路径。

2.2 三行配置把 viewer 跑起来

先别写业务代码,用自带外壳验证包是完整的。解压到一个空目录,放一份测试 PDF 进去,起个静态服务:

cd pdfjs-1.9.426 cp /path/to/test.pdf web/test.pdf python3 -m http.server 8080 # 浏览器打开 http://localhost:8080/web/viewer.html?file=test.pdf

页面能出内容、能翻页、能选中文字,说明包没问题,可以进入集成阶段。如果页面出来了但正文区域空白,先按 F12 看 Network 里pdf.worker.js是不是 404——外壳自己的 worker 路径是相对web/算的,一旦你把viewer.html拷到别处,这条路径就断了。

不想用自带外壳、只想嵌到自己页面里,那就只引build下的文件,并在调用getDocument之前把全局开关设好:

<script src="./build/pdf.js"></script> <script> // 1.9 的接口挂在 window.PDFJS 上,2.x 之后改名为 pdfjsLib PDFJS.workerSrc = './build/pdf.worker.js'; // 必须指向真实可访问的 URL PDFJS.cMapUrl = './web/cmaps/'; // 以 / 结尾,内部会拼文件名 PDFJS.cMapPacked = true; // 目录里是 .bcmap 就必须开 PDFJS.verbosity = PDFJS.VERBOSITY_LEVELS.errors; // 少刷日志,排错时再调回 warnings </script>

这三个开关是全局状态,赋值语句必须写在任何PDFJS.getDocument之前。我见过把workerSrc写进then回调里的写法,第一次渲染就走了降级路径,性能直接掉一个量级,还很难查。

2.3 按需裁剪成精简包

完整包体积不小,如果只做“渲染出来看看”,可以只带必要文件,体积能砍掉一大半。

路径作用能否省略
build/pdf.js主库不能
build/pdf.worker.js解析 worker不能,除非接受主线程渲染
web/cmaps/CID 字体编码表有中文内容就不能省
web/standard_fonts/标准字体替代建议保留,省了会遇到部分文档空白
web/locale/界面语言不用自带外壳就能省
web/images/外壳图标能省
web/viewer.*自带外壳自己写页面就能省

裁剪之后最容易翻车的不是缺文件本身,而是相对路径变了。cmaps一旦挪位置,cMapUrl必须跟着改;standard_fonts是外壳按web/为基准去找的,把viewer.html拆出来单独部署时,这个基准就失效了,表现为个别 PDF 的正文是空白但能选中。

3. 用 API 渲染第一页:viewport、canvas 尺寸与渲染任务的调度

外壳只是验证工具,真正落到业务里,都是用 API 自己画。这一章把渲染流水线拆开,重点在两个地方:getViewport的版本差异,和 canvas 尺寸的双重换算。

3.1 getDocument 的参数怎么填

getDocument收一个参数,可以是 URL 字符串、Uint8Array、或者一个配置对象。业务里我一般用配置对象,因为要带鉴权头和控制加载策略:

var PDFJS = window.PDFJS || window.pdfjsLib; // 兼容两种全局名 PDFJS.workerSrc = '/static/pdfjs/build/pdf.worker.js'; PDFJS.cMapUrl = '/static/pdfjs/web/cmaps/'; PDFJS.cMapPacked = true; var task = PDFJS.getDocument({ url: '/api/file/1024.pdf', // 也可以是 { data: uint8array } httpHeaders: { 'X-Token': token }, // 需要鉴权时带上,比自己 fetch 省事 withCredentials: true, // 跨域带 cookie 时开 rangeChunkSize: 65536, // 分片大小,默认 64KB disableAutoFetch: false, // true 表示只按需拉可见页 disableStream: false, // true 会关掉流式加载 password: '' // 加密文档的回调式密码见 3.2 末尾 }); task.onProgress = function (p) { if (p.total) { console.log('加载进度', Math.round(p.loaded / p.total * 100) + '%'); } }; task.promise.then(function (pdf) { console.log('总页数', pdf.numPages); return pdf.getPage(1); }).then(function (page) { // 进入渲染环节 });

参数里最值得调的是disableAutoFetch和rangeChunkSize。几百页的文档,服务端支持Range请求时,把disableAutoFetch设成true,用户翻到第几页才拉第几页,首屏时间能明显下降;服务端不支持分片(返回 200 而不是 206),这两个参数再怎么调都没用,反而会多几次无效请求。判断方法很直接:Network 面板看首个请求的响应码和Accept-Ranges头。

password只对“已知密码”的场景有效。用户手动输密码的场景要用回调:task.onPassword = function (updatePassword, reason) { updatePassword(prompt('请输入密码')); },reason为 1 表示需要密码,为 2 表示密码错了。

3.2 渲染流水线的正确顺序

渲染不是一句render就完事,顺序错了会出现“图出来了但糊”“翻页闪白”“切页后旧内容盖上来”。标准流程是:拿页 → 算 viewport → 设 canvas 尺寸 → 调 render → 保存 renderTask 以便取消。

var renderTask = null; function renderPage(pdf, pageNo, canvas, scale) { // 上一页还没画完就切页,必须先取消,否则两条流水线抢同一个 canvas if (renderTask) { renderTask.cancel(); renderTask = null; } return pdf.getPage(pageNo).then(function (page) { // 1.9 的 getViewport 收数字;2.x 改成 { scale: n },抄新代码会算出 NaN var viewport = page.getViewport(scale); var ctx = canvas.getContext('2d'); var dpr = window.devicePixelRatio || 1; // 物理像素 = CSS 像素 × dpr,两套尺寸都要设,只设一套必糊 canvas.width = Math.floor(viewport.width * dpr); canvas.height = Math.floor(viewport.height * dpr); canvas.style.width = Math.floor(viewport.width) + 'px'; canvas.style.height = Math.floor(viewport.height) + 'px'; var transform = dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : null; renderTask = page.render({ canvasContext: ctx, viewport: viewport, transform: transform, intent: 'display' // 可选 display / print,打印走另一套光栅化 }); return renderTask.promise.then(function () { page.cleanup(); // 释放这一页的中间对象,长文档必须调 }, function (err) { if (err && err.name === 'RenderingCancelledException') { return; } throw err; }); }); }

几个关键点值得单独说。canvas.width和canvas.style.width是两个不同的东西:前者是画布分辨率,后者是页面占位,只设前者会让图被拉伸,只设后者会糊。transform的四个分量是 dpr 缩放矩阵,dpr为 1 时传null而不是单位矩阵,能少一次矩阵乘。intent选print时注释和表单域的处理策略不同,导出打印稿才需要改。

renderTask.cancel()抛出的是RenderingCancelledException,属于正常流程,不能当成错误弹窗。这一点在快速翻页的场景里特别明显——不捕获它,控制台会被红色报错刷满,用户还以为页面崩了。

page.cleanup()放在渲染成功之后调,作用是丢掉这一页的字体、图像等中间缓存。不调的话,翻到三五十页浏览器内存就上去了,移动端直接白屏。

3.3 缩放策略与适配

scale到底填多少,取决于你要“适配宽度”还是“固定清晰度”。适配容器的算法是先用scale = 1拿到原始 viewport,再除以容器宽度:

var base = page.getViewport(1); var scale = container.clientWidth / base.width; var viewport = page.getViewport(scale);

要注意base.width是 PDF 的页面宽度(单位是 pt 换算后的 CSS 像素),横版和竖版混排的文档,同一个scale出来的高度差很多,容器要有滚动而不是硬压。

另外,scale乘上 dpr 之后的总像素不能无限大。移动端 canvas 有面积上限(不同内核阈值不一样,通常在千万像素量级),A4 页面放大到 4 倍以上就会踩线,表现为渲染后一片黑或者直接抛错。稳妥做法是给总像素设一个上限,超了就降 scale,清晰度损失比白屏好。

4. 中文 PDF 的显示与复制:cMap、字体目录和文字层三件事

英文 PDF 一把过,中文 PDF 一堆坑,这是老版本上最集中的抱怨区。原因不复杂:中文 PDF 里大量使用 CID 字体,字符编码到字形的映射不在文件里,得靠外部编码表补齐。

4.1 中文空白、方框、乱码的根因

PDF 内部存的是字形编号(CID),不是 Unicode。渲染时库需要一张编码表把它翻译回字符,这张表就是cmaps目录里的.bcmap文件。表找不到,结果分两种:文字层里是空白(能画出来但选不中)、或者整页渲染直接失败。

配置只需两行,但位置和结尾斜杠都得对:

PDFJS.cMapUrl = '/static/pdfjs/web/cmaps/'; // 必须能被浏览器直接访问 PDFJS.cMapPacked = true; // .bcmap 是压缩格式,不能漏

排查方法:打开 Network 面板筛选bcmap,加载一份中文 PDF,看有没有请求发出、状态码是不是 200。没有请求,说明cMapUrl没生效(多半是赋值晚于getDocument,或者这个 PDF 根本没用 CID 字体);有请求但 404,说明路径错了,用find . -name "*.bcmap"找到真实目录再改。

还有一种情况:bcmap全部加载成功,中文也显示了,但复制出来是乱码。这不是渲染问题,是 PDF 本身没写ToUnicode映射,属于文档缺陷,前端改不了。

提示:cmaps目录里有上百个文件,别整个目录预加载,库是按需请求的,你只需要保证目录可访问。

4.2 文字层:从能选到选得准

canvas 画出来的只是像素,选中、复制、搜索靠的是叠在上面的文字层。老版本的做法是手动调PDFJS.renderTextLayer,把getTextContent的结果塞进一个绝对定位的容器:

// 容器必须绝对定位覆盖在 canvas 上,且样式来自 pdf_viewer.css 的 .textLayer var container = document.getElementById('textLayer'); container.style.width = viewport.width + 'px'; container.style.height = viewport.height + 'px'; page.getTextContent({ normalizeWhitespace: true }).then(function (textContent) { var textDivs = []; var task = PDFJS.renderTextLayer({ textContent: textContent, container: container, viewport: viewport, textDivs: textDivs // 传出去,搜索高亮时要用 }); return task.promise; });

.textLayer那套 CSS 必须引进来(就在web/pdf_viewer.css里),它做的是两件看着不起眼的事:把文字设为透明、以及给每个 span 设置transform: scaleX()让字宽和 canvas 对齐。样式一旦被项目里的全局 reset 覆盖,就会出现“能选中但选区错位几厘米”的玄学现象。

文字层和 canvas 的缩放必须同一套参数算,第二次调用getViewport时传入的 scale 要和渲染时完全一致,差一点都会错位累积到页尾。

4.3 字体加载失败与替代方案

另一类空白来自字体:PDF 里只写了字体名(比如宋体),但没有内嵌字形文件。浏览器本地有就用本地的,没有就得靠standard_fonts里的替代字形。这条链路断了,表现是整页文字消失但图片正常。

判断方法是看控制台有没有字体相关的警告,再看 Network 里standard_fonts目录有没有被请求。被请求且 404,说明拆包时把目录挪走了,把web/standard_fonts放回原位置或者改外壳的基准路径。

还有一条兜底:PDFJS.disableFontFace = true。它让库放弃@font-face,改用 canvas 直接绘制字形。代价是文字层会失去部分信息、复制可能不准,好处是彻底绕开字体加载的跨域和路径问题。我一般只在“内网 + 字体目录不可控”的场景下开这个开关,正常部署不开。

5. 避坑排查:从假 worker 到版本错配的五个现场

这一章的五条,都是我或者同事真实翻过车的。每条按“现象 → 原因 → 解决”写,照着比对能省掉半天。

5.1 现象:控制台出现 fake worker 警告,滚动卡顿

原因:pdf.worker.js没有加载成功,库自动降级到主线程解析。触发条件通常是三种——workerSrc路径写错;页面跑在file://下,worker 被同源策略拦掉;worker 放在别的域名,跨域没配好。

解决:先看 Network 里有没有pdf.worker.js的请求,没有就是路径没生效(赋值晚于getDocument,或者被别的模块覆盖了)。开发期想临时绕过,设PDFJS.disableWorker = true,但这条只适合调试,主线程解析大文档会把页面冻住好几秒。正式环境老老实实走 HTTP 服务,别双击本地文件。

5.2 现象:报 API 版本与 Worker 版本不一致,或者渲染直接抛异常

原因:pdf.js和pdf.worker.js来自不同版本。常见于“主库用 CDN 上的新版、worker 用本地老包”,或者升级时只换了一个文件。1.9 的getViewport收数字,2.x 收对象,这种签名变更混用会算出NaN宽高,canvas 尺寸设成 0,页面一片空白且不报错。

解决:两个文件当成一个整体管理,版本号写进静态资源目录名(比如/static/pdfjs-1.9.426/),升级时整目录替换。从别处抄代码前先确认对方用的版本,getViewport的参数形式是最快的识别标志。

5.3 现象:翻到几十页后浏览器内存飙升,移动端直接白屏

原因:每页渲染完没有调page.cleanup(),也没有在切页时取消上一个renderTask;组件卸载时没调pdf.destroy()。老版本对页面对象的缓存策略偏保守,1100 页的文档能把内存吃到几百兆。

解决:渲染成功回调里紧跟page.cleanup();切页前renderTask.cancel()并捕获RenderingCancelledException;组件销毁时pdf.destroy()并把 canvas 宽高置 0。三条都做上,内存曲线会从锯齿爬升变成平稳波动。

5.4 现象:canvas 尺寸设了但还是糊,或者右侧被裁掉一截

原因:只设了canvas.width没设canvas.style.width,或者反过来;transform里的 dpr 和 CSS 里的 dpr 不是同一个值(比如用了zoom而不是devicePixelRatio)。也有可能容器宽度取的是offsetWidth,包含了边框和内边距。

解决:物理尺寸用Math.floor(viewport.width * dpr),CSS 尺寸用Math.floor(viewport.width),两套都写。容器测量统一用clientWidth,并在resize时重算重绘,别指望 CSS 自动缩放能把清晰度救回来。

5.5 现象:部分 PDF 打开就报错,换一个文件又正常

原因:文档本身不符合规范,或者用了加密、增量更新、非标准交叉引用表。老版本的容错比新版弱,某些畸形结构会直接抛解析异常。

解决:getDocument的 promise 上挂catch,给用户一个“该文件暂不支持预览,请下载后查看”的降级出口,别让整个页面挂掉。同时把PDFJS.verbosity调回warnings抓一次详细日志,确认是解析阶段还是渲染阶段失败,再决定要不要在服务端做一次预处理。

6. 进阶落点:关键词定位高亮与长文档的内存治理

搜索高亮是这类项目里最常被追加的需求。思路不复杂:拿getTextContent的每一项,用PDFJS.Util.transform把文本坐标乘上 viewport 矩阵,换算成画布坐标,再在上面盖一层高亮 div。

function locate(page, viewport, keyword) { return page.getTextContent().then(function (tc) { var hits = []; tc.items.forEach(function (item) { if (!item.str || item.str.indexOf(keyword) === -1) { return; } // item.transform = [a,b,c,d,e,f],e/f 是文本基线起点(文本坐标系) var m = PDFJS.Util.transform(viewport.transform, item.transform); var charW = item.width / item.str.length; // 均分近似,等宽字体才精确 var offset = item.str.indexOf(keyword) * charW * viewport.scale; hits.push({ left: m[4] + offset, top: m[5] - item.height * viewport.scale * 0.85, // 基线往上抬一个字高 width: keyword.length * charW * viewport.scale, height: item.height * viewport.scale }); }); return hits; }); }

坐标换算里有两点要留意。item.width是整段文本的宽度,除以字符数得到的是平均字宽,遇到中英混排会偏,实际项目里我会把高亮框做成“段落级”而不是“字符级”,误差肉眼看不出来。m[5]是基线位置,直接当 top 用会让高亮框压住下半行,往上抬约 0.85 个字高才对得上。

长文档的治理,我现在的默认策略是这样一张表:

场景策略参数
百页以内一次性加载,逐页渲染disableAutoFetch: false
几百页以上按需分片,只渲染可视页disableAutoFetch: true,服务端开 Range
移动端限制总像素,降 scale 保稳定总像素上限约 800 万
打印导出单独走一次 print intentintent: 'print',scale 不低于 2

按需分片那条,前提是服务端返回 206。我见过前端参数配得很漂亮,后端是一个把整个文件读进内存再吐出来的接口,disableAutoFetch开了等于没开,首屏还是要等全量下载完。

最后说个习惯。早些年我接过一个内网预览模块,图省事直接引了外部的库文件,本地测试全绿,上线当天内网机器访问不到外链,整个预览功能静默失效,日志里连个报错都没有——因为降级路径把异常吞了。从那以后我每次部署这类包,都强制走一遍“断网 + 老内核 + 中文文档”的三项验证,确认worker、cmaps、standard_fonts三个目录都在本地且路径正确,再交付。拿到 pdfjs1.9.426 这个包,建议你也先按第 2 章的目录核对一遍,把三个 404 提前消灭在自己机器上。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询