简介:面向微信小程序开发者的绘图接口学习资料,以 PDF 形式系统梳理画布操作核心知识点,涵盖矩形、路径、圆弧、文字与图片绘制,以及渐变、线样式、文本样式等设置方法,并讲解缩放、旋转、位移等变形技巧,适合刚接触小程序 Canvas 或需要快速查阅绘图接口的初中级开发者。资源为单个 PDF 文件,压缩包整体约 180KB,体积轻量,便于随时阅读。已有 870 人学习下载。内容按章节组织,既包括画布创建、输出图片等小程序级操作,也涵盖绘制、保存与恢复、清除绘图等画布级操作,每一小节均配有接口名称与参数说明,可直接对照练习。借助这份整理,开发者能快速掌握在小程序中实现动态图形和交互式界面的绘图思路,并为后续结合网络请求、数据管理等能力构建完整应用打下基础。
1. 微信小程序开发里的 PDF:第一行代码之前就该想清楚的事
产品在周五下午提了个需求:小程序里要能打开合同 PDF、把账单导成 PDF 发给客户。第一反应是“这功能不就在前端套个库”,于是去找 PDF 预览组件,装完跑起来才发现小程序根本不是浏览器。没有 DOM、canvas 能力被裁剪、worker 受限、文件系统封闭,Web 端那套 PDF 方案在小程序里几乎全废。这篇文章把微信小程序开发.pdf 这条路的路线图拆开讲:为什么不能直接渲染、生成 PDF 的可行链路、解析和展示怎么做、真机上报错先查哪里。适合从 Web 转小程序的前端,以及第一次做文档类功能、不想在“能跑”和“能上线”之间反复横跳的开发者。
2. 微信小程序为什么不能直接渲染 PDF:内核限制与三条可行路线
2.1 小程序运行环境和浏览器差在哪
小程序页面的逻辑层跑在 JavaScriptCore 或 V8 上,视图层是原生组件和 Canvas 组成的,不是一套完整的浏览器内核。pdf.js 这类库在浏览器里能工作,依赖的是 DOM 树、Blob、URL.createObjectURL、Web Worker 这些 Web API,小程序里一个都没有。
另一个被低估的问题是 Canvas。小程序的 Canvas 基础库从 2.9.0 开始才提供 Canvas 2D 接口,但还是不能把它当成浏览器里的<canvas>用:没有ctx.measureText的完整实现,toDataURL不存在,导出图片走的是wx.canvasToTempFilePath。PDF 渲染需要逐像素绘制,性能完全取决于小程序 Canvas 的实现质量,和桌面浏览器的差距很大。
搜索“pdf转换微信小程序源码 纯前端实现”能看到不少分享,但要么只处理纯文本、要么缩进和换行是写死的,真正遇到多页、嵌入字体、矢量图形的 PDF 就会露馅。所以先把结果说在前面:在小程序里做 PDF,纯前端不是首选路线,服务端参与是常态。
2.2 三条可行路线怎么选
常见做法是把需求拆成三个方向。第一,服务端出图:后端把 PDF 每页转成 PNG,小程序端只负责用 image 组件展示,这是稳定性最高的一条路。第二,小程序端用 canvas 绘制内容,再把每页导出成图片交给服务端拼 PDF,适合“发票、报告、报名表”这类需要动态生成的场景。第三,用 web-view 嵌入一个 H5 在线预览页,适合已有现成网页版阅读器的团队,但小程序里 web-view 只能打开业务域名下的页面。
| 路线 | 适用场景 | 维护成本 | 主要缺点 |
|---|---|---|---|
| 服务端把 PDF 转图片 | 合同预览、报告展示 | 低,后端稳定后小程序端几乎零维护 | 需要一台能跑转换服务的机器 |
| 小程序 canvas 绘图后合成 | 动态生成简单 PDF | 中,要处理分页和字体 | 复杂排版做不了,字体是硬伤 |
| web-view 嵌 H5 预览 | 已有网页阅读器 | 看 H5 的完成度 | 受“业务域名校验”限制,不能随便指向外链 |
2.3 按业务场景做的技术选型步骤
选型不要从“哪个库好用”出发,要从“数据从哪来、用户拿 PDF 干什么”出发。
- 用户只要“能看”:优先做服务端转图片。后端收到 PDF 文件后,输出
page_1.png、page_2.png这样的静态资源,小程序端用 image 组件分页加载。这个方案在低端安卓机上也稳,因为图片渲染是系统级能力。 - 用户要“导出、下载、打印”:小程序端负责生成内容,canvas 分页绘制,导出图片后传给服务端拼 PDF。
- 用户要“复制 PDF 里的文字”:不要在小程序端做,服务端负责解析文本,把结构化内容吐给前端展示。
提示:小程序端不是不能做生成和解析,而是每次绕过服务端都会在某个角落付出代价——要么是真机上崩溃,要么是线上字体丢失。
3. 在小程序里生成 PDF:canvas 绘图 + 服务端合成的完整链路
3.1 为什么不在小程序里直接输出 PDF 文件
小程序端能直接生成 PDF 文件的库非常少,而且都有明显短板:字体只能跟随 canvas 绘制结果,中文字体文件动辄几 MB,打包进小程序不现实;分页需要自己计算行高和页码位置,稍微复杂一点的段落就会溢出或重叠。常见的取巧方案是把内容画在 canvas 上,导出成图片,再把图片交给服务端拼进 PDF。这样小程序端只做“画”,PDF 的底层封装交给成熟的服务端库,两个环节都省心。
3.2 小程序端:把一页内容画在 canvas 上并导出图片
以一个 A4 纵向页面为例。WXML 里放置一个 canvas 节点,页面 JS 用旧接口wx.createCanvasContext绘画,完成后通过wx.canvasToTempFilePath导出图片。核心代码:
// pages/pdf-builder/index.js Page({ buildPageOne() { // width/height 按 A4 比例 595:842(pt)设置 const ctx = wx.createCanvasContext('pdfCanvas', this) const pageWidth = 595 const pageHeight = 842 // 白色底 ctx.setFillStyle('#ffffff') ctx.fillRect(0, 0, pageWidth, pageHeight) // 标题 ctx.setFillStyle('#1a1a1a') ctx.setFontSize(24) ctx.fillText('服务报告', 40, 60) // 正文,手动换行 ctx.setFontSize(14) ctx.setFillStyle('#333333') ctx.fillText('这是一段会在 PDF 中展示的内容。', 40, 120) ctx.fillText('canvas 绘制时要注意文字宽度。', 40, 150) ctx.draw(false, () => { // 注意 destWidth 不乘 2,导出图在真机上会发虚 wx.canvasToTempFilePath({ canvasId: 'pdfCanvas', fileType: 'png', quality: 1, destWidth: pageWidth * 2, destHeight: pageHeight * 2, success: (res) => { // res.tempFilePath 是这一页的图片临时路径 this.uploadPageImage(res.tempFilePath, 1) }, fail: (err) => { console.error('导出失败', err) } }, this) }) }, uploadPageImage(tempFilePath, pageNo) { // 把图片上传到自己的服务器,服务端负责拼 PDF } })逻辑说明:ctx.draw(false, callback)的false表示不异步绘制,callback 在绘制完成后触发,导出图片必须在这里进行,否则会拿到空白画布。destWidth和destHeight是导出图片的尺寸,和 canvas 实际尺寸不一致时会做缩放。把宽高乘以 2 是为了提高图片在 PDF 中的清晰度,但不要盲目乘太多,图片导出太大,在低端机型上容易触发内存告警。
3.3 服务端:把上传的页面图片拼成 PDF
服务端最常见的做法是 Node.js 环境使用 PDFKit。核心思路是读取前端传上来的 PNG,按 A4 大小写入 PDF 文档,并保持每页尺寸一致:
// server/pdf.js const PDFDocument = require('pdfkit') const fs = require('fs') const images = ['page_1.png', 'page_2.png'] // 由小程序端上传的图片 const doc = new PDFDocument({ size: 'A4', // 即 595.28pt x 841.89pt margin: 40 }) const writeStream = fs.createWriteStream('output.pdf') doc.pipe(writeStream) images.forEach((filePath) => { // A4 去掉左右 margin 后可用的宽度是 515.28pt doc.image(filePath, 40, 40, { width: 515, height: 728 }) doc.addPage() }) doc.end()逻辑说明:先创建 A4 大小、边距 40pt 的文档,每写入一张图片就addPage()加入新的一页。width和height写死有两个好处:保证所有上传的页面图片都按同一个展示尺寸输出,不至于出现第一页大、第二页小的错乱。如果你想让 PDF 中图片覆盖整个页面,可以把margin改为 0,并按595.28和841.89设置图片尺寸,但要保证前端 canvas 的宽高比和这个比例一致,否则图片会被拉伸。
3.4 必调的四个参数
| 参数 | 建议值 | 说明 |
|---|---|---|
| canvas 尺寸 | 595×842 | 按 A4 pt 设置,保持比例 |
| destWidth / destHeight | 页面尺寸×2 | 提升清晰度,真机导出更稳 |
| PNG 还是 JPG | PNG | PDF 里的文字型页面,PNG 更清晰 |
| 上传图片压缩 | 前端压缩后上传 | 单页超过 1MB 时优先压缩 |
这段链路要预留“页面数”字段:前端画完一页就传一页,后端收到全部图片后再合成 PDF 并返回文件地址。如果一次 draw 的内容过多,canvas 在部分安卓真机上会直接空白,解决办法是拆成多页 canvas,一页一个节点,而不是一页画几十屏的内容。
4. 在微信小程序里解析并展示 PDF:pdf.js 适配与不走前端的降级方案
4.1 解析 PDF 的两个目标先分清
解析 PDF 可以分成“抽文本”和“渲染成图片”两种需求。抽文本适合做搜索、敏感词检查、引用原文;渲染成图片适合做在线阅读。小程序里做前者太吃力,因为 PDF 的文本编码、字体映射和排版信息全部需要自己解析,等于是重写一个 PDF 引擎。后者可以用 pdf.js 加载 PDF 后按页绘制到 canvas,代码能写通,但运行环境限制比较多。
4.2 小程序端用 pdf.js 渲染单页的适配写法
pdf.js 默认依赖 Web Worker 和 DOM,小程序里要使用它的渲染能力,常见做法是下载其核心构建文件放到本地,代码里绕过 Worker,在主线程同步解析。下面是单页渲染的缩放版流程:
// pages/pdf-viewer/index.js const pdfjsLib = require('../../libs/pdf.min.js') Page({ data: { pdfUrl: 'https://api.example.com/file/contract.pdf' }, onLoad() { this.loadPdf() }, loadPdf() { wx.request({ url: this.data.pdfUrl, responseType: 'arraybuffer', // 必须,拿二进制数据,默认是 text success: (res) => { const loadingTask = pdfjsLib.getDocument({ data: res.data }) loadingTask.promise.then((pdf) => { this.pdfDoc = pdf this.renderPage(1) }) }, fail: (err) => { console.error('PDF 下载失败', err) } }) }, renderPage(pageNo) { this.pdfDoc.getPage(pageNo).then((page) => { const viewport = page.getViewport({ scale: 2 }) // 2x 提高清晰度 this.setData({ canvasWidth: viewport.width, canvasHeight: viewport.height }) const query = wx.createSelectorQuery() query.select('#pdf-canvas').fields({ node: true, size: true }).exec((res) => { const canvas = res[0].node canvas.width = viewport.width canvas.height = viewport.height const ctx = canvas.getContext('2d') page.render({ canvasContext: ctx, viewport }).promise.then(() => { console.log('第', pageNo, '页渲染完成') }) }) }) } })逻辑说明:代码先用wx.request获取 PDF 二进制内容,注意responseType必须设为arraybuffer,否则数据流不对,PDF 解析直接失败。getDocument是 pdf.js 的入口,返回的 promise 里包含整个 PDF 文档对象,getPage再按需加载某一页。渲染到 canvas 时,scale: 2是为了高分辨率显示,但会带来内存和时间成本,页面文字特别多时建议降到 1.5。
这里有个很现实的问题:pdf.js 在无 DOM 环境下的兼容性依赖构建方式,不同版本表现不一样,遇到getDocument直接报错时,优先检查当前包是否使用了浏览器专用 API。如果一直调不通,不要死磕,走服务端渲染会更可控。
4.3 更稳的降级方案:服务端出图后小程序端只加载图片
把“服务端把 PDF 转图片”作为默认方案。后端接收 PDF 文件后,逐页输出 PNG,小程序端用分页滚动的 image 列表展示。从用户体感来看,几乎和原生渲染没有区别,但稳定性高一个量级。
// 页面数据示意 data: { pages: [ { url: 'https://api.example.com/file/contract_page_1.png' }, { url: 'https://api.example.com/file/contract_page_2.png' } ] }这里的页面图片地址需要后端给一个稳定的 URL,或者返回临时链接,由小程序端用 image 组件的 lazy-load 属性延迟加载。代价是每次预览都会产生图片请求,如果 PDF 有几十页,要控制“当前页±1”的预加载量,不要全部加载。
4.4 用 web-view 兜底的快速方案
如果团队已经有 web 版 PDF 阅读器,小程序端可以做一个套壳页:
<web-view src="{{previewUrl}}"></web-view>previewUrl指向业务域名下的一个带阅读器的页面,由这个 H5 负责加载 PDF 文件。需要特别注意的是,web-view 的src不能是一个 PDF 链接,必须是网页地址,网页内部再通过 pdf.js 等方式渲染。同时微信后台要配置业务域名,开发调试时可以勾选“不校验合法域名”,上线前必须把域名加进白名单。
| 域名类型 | 对应能力 | 不配置的后果 |
|---|---|---|
| request 合法域名 | wx.request、上传下载 | 请求直接报 url not in domain list |
| downloadFile 合法域名 | 下载 PDF 文件 | 下载失败 |
| web-view 业务域名 | web-view 打开页面 | 白屏,控制台提示域名校验失败 |
提示:三个域名对应的后台配置入口不同,特别是 web-view 业务域名,必须在 mp 后台单独添加,不能只配 request 域名。
5. 小程序 PDF 开发里的高频报错与参数排查:真机、域名与基础库
5.1 开发者工具没问题,真机白屏先查什么
真机和开发者工具最大的差别是渲染内核和系统 WebView 版本。PDF 相关页面白屏,先看内存:低端安卓机上 canvas 保持过多页面图片会被系统回收;再看代码里有没有用到基础库新接口,Canvas 2D 在较老的基础库上直接不可用。
基础库版本在哪里设置:微信开发者工具右上角“详情 → 本地设置 → 调试基础库”里切换。页面出现“xxx 接口不支持”之类的报错时,优先把基础库切到较新版本试试,这能排除环境问题。
5.2 uniapp 开发的差异化:HBuilderX 发行到小程序后的 canvas 变化
用 uniapp 开发时,HBuilderX 发行到微信小程序选项后,canvas 相关 API 会被编译层转成微信的调用。此时容易踩的坑是调用uni.canvasToTempFilePath时,有些参数名和微信原生不一样,比如canvasId的对应关系,以及组件内使用时需要传入this。
常见错误是图片导出成功但内容是空白。原因是 uniapp 编译后的 canvas 节点结构发生了变化,回调执行时绘制还没有完成。解决办法是等ctx.draw的完成回调触发后再执行导出,不要在setTimeout里碰运气。如果你的项目是原生小程序,这节可以跳过,但用 uniapp 的同事在微信小程序开发.pdf 这条路上遇到问题时,第一反应基本都在 canvas 导出环节。
5.3 网络请求报错的排查顺序
handshake failed due to invalid upgrade header: null是 PDF 文件请求里的常见报错。看到这个,先确认是不是在开发者工具里开了代理,很多本地代理会在 WebSocket 握手阶段改 headers,导致升级头无效。关掉代理验证,再检查要请求的 PDF 域名是否写进了 downloadFile 合法域名。如果线上没问题、工具里报错,基本就是工具网络设置的问题。
排查步骤:
- 开发者工具右上角 → 详情 → 本地设置 → 勾选“不校验合法域名、web-view 域名、TLS 版本以及 HTTPS 证书”,再试一次。
- 如果链路通了,说明是域名校验问题;如果仍然报错,改用真机调试。
- 真机调试查看 Network 面板里 PDF 请求的响应码和 timing,定位是 DNS、HTTPS 还是服务端 4xx/5xx。
5.4 抓包与临时开关怎么配合使用
需要看 PDF 文件是否真的加载到小程序时,用微信开发者工具的真机调试 + Network 面板即可,不需要额外抓包工具。小程序发出的wx.request和图片加载都会出现在 Network 里,但 websocket 预览请求不一定显示,此时可以在代码里打日志确认 success 或 fail 回调是否触发。
| 现象 | 优先排查 | 次要排查 |
|---|---|---|
| 开发者工具能预览,真机白屏 | 基础库版本 | 图片尺寸过大、内存峰值 |
| 真机报 download 失败 | downloadFile 合法域名 | 证书链是不是被运营商拦截 |
| 报 invalid upgrade header | 本地代理 | WebSocket 服务端协议不匹配 |
| 图片加载极慢 | 是否走 CDN | 分页图片是否一次性全部请求 |
注意:线上环境不要勾选“不校验合法域名”,这会让正式用户也绕过检查,不是正途。
6. PDF 预览的懒加载技巧:内存、白屏与分页渲染
小程序里预览多页 PDF,最容易踩的坑是在 onLoad 里一次性把所有页面渲染出来。画了 30 页 canvas,每页导出成图片,看起来只是代码多写几个循环,真机上大概率内存告警,白屏甚至闪退。服务端完整转好的整本 PDF 也尽量别直接丢给前端加载,理想方案是后端支持按页取图,前端做到“滚动到哪页、渲染哪页、提前预取一页”。
下面是按页懒加载的核心思路:用一个数组记录“已渲染页码”,用页面滚动位置判断是否接近当前页底部,接近时渲染下一页。
// pages/pdf-viewer/index.js Page({ data: { currentPage: 1, windowHeight: 0, offsetTop: 0 }, onLoad() { this.setData({ windowHeight: wx.getWindowInfo().windowHeight }) this.totalPages = 20 // 实际由后端返回 }, onPageScroll(e) { const scrollTop = e.scrollTop // 接近页面底部 200px 时触发预取,避免用户滑到空白区域 if (scrollTop + this.data.windowHeight > this.data.offsetTop + 200) { this.preloadNextPage() } }, preloadNextPage() { if (this.loading || this.data.currentPage >= this.totalPages) return this.loading = true const nextPage = this.data.currentPage + 1 wx.request({ url: `https://api.example.com/pdf/page_${nextPage}.png`, success: (res) => { // res.data 是图片的远程 URL this.setData({ [`pages[${nextPage - 1}].url`]: res.data.url, currentPage: nextPage, offsetTop: this.data.offsetTop + 80 // 按实际页面高度累加 }) this.loading = false }, fail: () => { this.loading = false } }) } })参数选择上,预取触发的阈值 200px 是兼顾“流畅”和“省流量”的折中值,太大会提前加载用户根本不会翻到的页面,太小则滚动到边缘时图片还在加载。图片 URL 的缓存 key 建议用文件的唯一标识加页码,因为同一个合同被打开多次时,缓存能让二次打开更快。
如果后端不支持按页出图,唯一能优化的就是前端用同一个 canvas 节点反复绘制当前页,放弃“预先导出全部图片”的方案。页面切换时清空画布再画新的一页,虽然滑动时稍显粗糙,但能把内存占用控制在单页级别。还会遇到的一个常见参数是顶部导航栏高度,PDF 渲染页若自定义导航,需要把滚动区域高度减去导航栏高度,否则 preload 判断提前或滞后;我的常见做法是调用wx.getMenuButtonBoundingClientRect()计算胶囊按钮位置,再往上下各加几个像素,作为自定义导航栏的真实高度。两种做法选哪种,取决于你能不能让后端按页出图,能,就做按页懒加载;不能,就复用 canvas 保持最低内存。
本文还有配套的精品资源,点击获取