☰
HTML转PDF实战:html2canvas与jsPDF的清晰度、分页与跨域问题全解
2026/9/30 12:15:07 网站建设 项目流程

去年做企业报表系统的时候,我被"把HTML页面导出成PDF"这个需求折磨得不轻。第一版导出的PDF,字是虚的,像隔了一层磨砂玻璃;第二版倒是清晰了,但表格右边被硬生生切掉一截;第三版更离谱,所有图片直接白屏。当时整个人是崩溃的。后来静下心把html2canvas + jsPDF这条链路的原理彻底捋了一遍,才发现这三大坑背后其实是对canvas绘制机制和PDF页面坐标体系的误解。这篇就把我踩完坑之后的完整方案和为什么这么写的底层逻辑一次性讲清楚。

1. 为什么HTML转PDF总在"能出文件"和"能用文件"之间翻车

先别急着贴代码,我们得先搞清楚一件事:所谓"HTML转PDF",在前端领域从来都不是真的在解析HTML然后重新排版成PDF。主流的html2canvas + jsPDF方案,本质是把DOM"拍一张照"——先由html2canvas把页面截成一张canvas位图,再把这张图片塞进jsPDF生成的多页PDF里。听起来简单,但所有坑都藏在这个"拍照"和"塞图"的过程中。

先说清晰度问题。html2canvas有一个scale参数,默认值是1。这个值代表canvas内部像素和设备像素之间的缩放比例。如果屏幕是2倍屏(devicePixelRatio=2),scale=1意味着你截图时放弃了一半的物理像素,只在canvas里画了一份逻辑像素数据。等pdf.js把这张图渲染出来,相当于把一张缩小图强行放大到实际尺寸,自然就是糊的。这个问题的本质是:你截图时的"采样精度"远低于显示器的物理精度。

再说页边距问题。jsPDF的坐标系和CSS的坐标系完全是两套逻辑。CSS里一个div的宽高、margin都以像素为基准,而jsPDF默认以毫米为基准,按照PDF标准分辨率(96dpi)换算,也就是1毫米约等于3.78像素。很多人直接把DOM高度交给addImage,图片按比例缩放后生成的PDF,上下左右完全没有留白,内容顶到纸的边缘,打印机一打就裁掉。更关键的是,html2canvas截图时如果页面存在滚动容器,它截取的宽度和高度可能比视口大得多,直接导致分页时表格行被拦腰截断。

最后是图片跨域。html2canvas绘制图片时,canvas会被浏览器的安全策略标记为"被污染"(tainted canvas),一旦检测到跨域图片且没有正确的CORS头,canvas的toDataURL方法就会抛出Tainted canvases may not be exported异常,图片区域直接画不上。很多项目里图片CDN域名和业务域名不是同一个,于是导出PDF时图片全部空白。这个问题在本地开发环境往往发现不了,因为本地没有跨域,等部署到测试环境才爆雷。

理解了这三个根源,后面的解决方案就不是瞎试了。下面按三条线分别拆解,最后再给出一个可以直接抄进项目的完整组件。

2. 清晰度问题:scale参数、devicePixelRatio和canvas的那本像素账

清晰度是所有问题里最直观的,也是最容易解决的。但如果你只是把网上搜到的scale: 2抄进代码,遇到高清屏导出依然可能出问题,因为没搞清这个2是怎么来的。

2.1 先弄明白一份PDF图片的分辨率是怎么算出来的

假设页面上一个内容区域的CSS宽度是190mm,换算成像素大约是190 * 3.78 ≈ 718px。如果你用scale: 1截图,生成的就是一张718px宽的位图。把这张图放到PDF的190mm宽度里,每毫米只分配了约3.78个像素,换算成印刷行业常说的dpi就是96。打印机的常规要求是300dpi,145dpi以上才能勉强达到"视觉清晰",96dpi就只能算"屏幕上凑合看"。

而scale: 2意味着canvas以两倍的像素密度进行采样,718px的CSS宽度会生成1436px的位图,放进同样的190mm里,dpi提升到192。如果继续用scale: window.devicePixelRatio,在2倍屏上效果等同于scale=2,在3倍屏上是scale=3。但这里有个细节很容易忽略:截图会把整个DOM内容放大,包括文字。canvas是一个位图容器,里面的所有元素都会被放大绘制,如果你在截图容器上设置了固定的CSS宽度,那么2倍scale相当于在绘制时把所有内容拉伸到2倍尺寸,文字会变清晰,但也意味着内存占用翻4倍(宽和高各翻一倍)。

2.2 实操中的清晰度参数该怎么定

我的实践是,导出A4横纵通用场景下,scale取Math.min(window.devicePixelRatio, 3)比较稳。为什么封顶3?因为超过3之后,肉眼几乎分辨不出差异,但浏览器的canvas内存直接爆炸。曾经测试过一张结构复杂的表单单页截图,scale=4时在低端安卓机上内存占用飙到400MB,页面直接卡死;scale=3时大约180MB,属于可以接受的范围。

async function captureToCanvas(element, scale) { const canvas = await html2canvas(element, { scale: scale || Math.min(window.devicePixelRatio, 3), useCORS: true, logging: false, backgroundColor: '#ffffff', // 关键:留出足够的绘制空间,防止内容被裁切 windowWidth: element.scrollWidth, windowHeight: element.scrollHeight }); return canvas; }

windowWidth是另一个关键参数。如果页面外层有overflow: auto的滚动容器,html2canvas默认只按视口尺寸绘制,超出部分会被裁掉。把windowWidth和windowHeight设为元素的scroll宽高,是解决"截图不全"的常规做法。

2.3 顺带把"导出表格断行"的苗头也解决掉

清晰度问题解决后,紧接着会遇到表格被截断。这段在讲清晰度章节里必须提醒:如果表格很长,你直接截一整张长图塞进PDF,分页时jsPDF会按页面高度均分切割,很可能把某一行文字切掉一半。我的做法是:对表格这类结构,不截整个页面,而是先判断内容高度是否超过单页可容纳高度,如果超过,就用canvas的getContext('2d').getImageData手动按页高切片,一页一页生成。这个逻辑在下一页会细说,但那段的思路和清晰度定位不同——那段的重点是"切割的位置要对齐整行",这段的重点是"画布尺寸和缩放参数要匹配"。

3. 页边距与分页切割:jsPDF不是Word,要按"页面坐标系"思考

很多人在 jsPDF 里设置margin无效之后,就再也不碰这个参数了。其实jsPDF的margin不是不能设,而是它的布局逻辑和Word完全不一样。Word是先排版再分页,jsPDF是手动画图再addPage,所有页面的内容都得你自己控制位置。

3.1 为什么margin参数经常"看起来失效"

直观感受是:设置了margin: { top: 20, left: 20 },但导出的PDF内容还是贴着纸边。原因在于pdf.addImage(imageData, 'JPEG', x, y, width, height)里的x、y是图片绘制的起始坐标,如果你在绘制图片时直接把x固定成0、y固定成0,那页面设置里的margin自然不会生效——因为你压根没用它。

正确做法是:把margin体现在addImage的坐标和尺寸计算里。以A4竖版(210mm x 297mm)为例,假设margin上下左右都是10mm,那么有效绘图宽度就是210 - 10 * 2 = 190mm,有效绘图高度是297 - 10 * 2 = 277mm。所有分页和缩放都以这个有效宽高为基准,而不是以PDF物理页为基础。

3.2 分页切割的核心:坐标换算与canvas切片

知道了有效绘图区,接下来就是分页。分页有两种思路:第一种是整页缩放——把整张长截图等比缩放到恰好一页放下,但这在内容高过一页时没有意义,字会小到看不见;第二种是多页切割——把长图按"每页可容纳的高度"切成多份,每份作为一页PDF的内容,这才是正确方向。

难点在于切割时如何避开"把文字行切掉一半"。html2canvas绘制出的canvas是一张位图,你没法直接知道哪一行文字在哪,但可以变通:先从页面布局上避开。最稳妥的做法是给页面加上break-inside: avoid的CSS,配合jsPDF的自定义分页起点。我用的方案是:不直接对着整张长图切割,而是先测量目标元素列表,如果某个table或某个模块的总高度超过单页容量,就把这个模块作为"独立切割单位",单独绘制,上一页留白,下一页从模块起点开始。虽然留白多一点,但导出美观度远比紧凑分页重要。

具体代码分两部分:第一部分计算当前页的高度余量。

const pdf = new jsPDF('p', 'mm', 'a4'); const pageWidth = pdf.internal.pageSize.getWidth(); const pageHeight = pdf.internal.pageSize.getHeight(); const margin = { top: 10, right: 10, bottom: 10, left: 10 }; const contentWidth = pageWidth - margin.left - margin.right; const contentHeight = pageHeight - margin.top - margin.bottom; let currentY = margin.top; // 当前页已用高度 function getRemainingHeight() { return contentHeight - (currentY - margin.top); } function addImageToNextPage(imgData, imgWidth, imgHeight) { // 按实际宽度等比缩放后的图片高度 const scaledHeight = (imgWidth * imgHeight) / contentWidth; pdf.addImage(imgData, 'JPEG', margin.left, currentY, contentWidth, scaledHeight); currentY += scaledHeight; }

第二部分是核心:当发现剩余空间放不下下一个模块时,用pdf.addPage()换页,并把currentY重置回margin.top。

async function appendElementToPDF(pdf, element, imgData) { const elHeightMm = element.offsetHeight * 0.264583; // 像素转毫米,1px ≈ 0.264583mm if (elHeightMm > getRemainingHeight()) { pdf.addPage(); currentY = margin.top; } // 这里计算当前元素在原canvas中的相对位置,并按需切割 addImageToNextPage(imgData, contentWidth, elHeightMm); }

像素和毫米的换算系数 0.264583 必须重点强调:它是96dpi标准下的毫米像素比。如果你用了高分屏scale=2,那换算时要先乘以scale再转毫米,否则所有尺寸都会差一半。

3.3 分页断行的兜底方案:CSS辅助

有的内容不是独立模块,而是散排的长文本或长表格,没法按模块切。这时候需要在CSS层面配合,给table的tr加上break-inside: avoid,给td加上固定的行高。html2canvas本身不渲染分页符,但我们可以先把内容按一个固定的"视觉页高"去截取,再对齐到行高倍数。这里有个实用技巧:把内容区域的行高强制设为固定值,比如22px,单页可容纳高度是277mm约等于1043px,1043 ÷ 22 ≈ 47.4行,取整为47行,那么每页截取的高度就是47 * 22 = 1034px。这样切出来的图片保证不会出现半行文字。

这是容易忽略但极其重要的细节:切断点一定是"行的整数倍",而不是页面高度的均分值。

4. 图片跨域:canvas安全机制是最后一道锁,得按规矩解锁

对比清晰度和边距问题,图片跨域是最容易让项目从"本地一切正常"到"上线必崩"的定时炸弹。

4.1 canvas的"污染检测"到底在拦什么

浏览器出于对用户数据的保护,规定canvas一旦绘制了跨域资源且未获得资源服务器的明确授权(CORS头),这个canvas就被标记为"被污染"(tainted)。被污染之后,你无法调用canvas.toDataURL()或canvas.toBlob()——也就是"这个canvas只能看,不能导出"。html2canvas内部走的就是toDataURL,所以跨域图片会导致导出时整张图直接报错或者图片区域空白。

注意:普通img标签在页面上显示跨域图片日常没问题,因为浏览器只负责显示不涉及像素读取;但canvas把图片画进去之后,再执行toDataURL,就触发了安全策略。这也是为什么很多人在浏览器页面里看到图片显示正常,但导出PDF时一片白。

4.2 解锁步骤一:图片标签增加crossOrigin属性

在Vue项目里,处理图片跨域要从源头做起。对需要导出的图片,img标签必须加上crossorigin="anonymous"。这个属性的作用是:浏览器在请求该图片时会带上Origin头,并且要求响应中必须包含Access-Control-Allow-Origin,否则资源不被canvas使用。

<img src="//cdn.example.com/a.png" crossorigin="anonymous" />

如果是动态图片(如后端返回的图片URL列表),需要在前端利用Image对象预加载:

function loadCrossOriginImage(url) { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => resolve(img); img.onerror = reject; img.src = url; }); }

注意一个细节:img.src赋值必须在crossOrigin属性设置之后,顺序反了浏览器不会执行CORS模式。

4.3 解锁步骤二:服务端配合返回CORS响应头

加了crossOrigin只是发起请求,资源服务器还必须给回应。以Nginx为例,对静态资源Location添加如下配置:

location /images/ { add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Credentials true; }

如果不是自己的资源服务器,比如用OSS或CDN,一般都有配置跨域的地方(阿里云OSS在传输管理-跨域设置里配置,七牛云在存储空间的跨域规则里配置),允许的来源填业务域名,允许的方法填GET,允许的头填 *。

4.4 兜底方案:提前把图片转成base64内联

即使做到上面两步,依然存在一个隐患:如果图片服务器宕机或响应头配置漏了,export还是会失败。所以最保险的兜底逻辑是:在进入导出流程前,把目标容器里所有图片统一预取一遍,判断能否正常通过toDataURL导出。对于加载失败或跨域异常的图片,降级为把一张本地占位图转成base64替换进去,保证PDF至少能顺利生成。更彻底的做法是:构建阶段把所有小尺寸图片以base64形式打包进前端代码,静态资源的跨域问题直接消失。但注意base64体积比原文件大约33%,只适合小图标,不适合大图。

async function replaceImagesWithBase64(container) { const imgs = container.querySelectorAll('img'); for (const img of imgs) { try { const imgWithCors = await loadCrossOriginImage(img.src); const tempCanvas = document.createElement('canvas'); tempCanvas.width = imgWithCors.naturalWidth; tempCanvas.height = imgWithCors.naturalHeight; tempCanvas.getContext('2d').drawImage(imgWithCors, 0, 0); img.src = tempCanvas.toDataURL('image/jpeg', 0.92); } catch (e) { console.warn('图片加载失败,替换为占位图:', img.src); img.src = fallbackBase64Placeholder; } } }

4.5 还有一个经常被忽略的坑:canvas本身的图像toDataURL

如果页面内本身就有canvas绘制的图表(比如ECharts),ECharts默认会把canvas的绘图数据导出为base64图片,这个图片不会跨域。但如果你用了echarts-gl之类的库加载了纹理图,那canvas内部本身可能就带了跨域纹理,同理需要检查纹理图的CORS。判断逻辑其实很统一:凡是要进canvas的内容,都先问一句"它的资源源是否和页面源一致,不一致是否带了合法CORS头"。

5. 把三件事串起来的完整Vue导出组件

有了前面的原理铺垫,现在给一份可以直接用到项目里的Vue组件。我把清晰度、页边距、跨域这三块整合成两个阶段:预处理阶段和导出阶段。

5.1 组件设计:为什么把图片预加载拆成一个独立阶段

我见过不少人把跨域处理和导出逻辑写在一块,结果每次导出都要等图片加载,而且失败后不好定位是跨域问题还是html2canvas本身的渲染问题。所以我刻意把流程拆成两步:先prepareDom,再exportToPdf。prepareDom里做图片预取、base64替换、样式锁定;exportToPdf只做截图和计算,职责单一,排错容易。

<template> <div> <div ref="contentRef" class="export-container"> <slot /> </div> <button :loading="exporting" @click="handleExport">导出PDF</button> </div> </template> <script setup> import { ref } from 'vue'; import html2canvas from 'html2canvas'; import jsPDF from 'jspdf'; const props = defineProps({ fileName: { type: String, default: '导出文档' }, scale: { type: Number, default: Math.min(window.devicePixelRatio, 3) }, margin: { type: Object, default: () => ({ top: 10, right: 10, bottom: 10, left: 10 }) } }); const contentRef = ref(null); const exporting = ref(false); const mmToPx = (mm) => (mm * 96) / 25.4; const pxToMm = (px) => (px * 25.4) / 96; function loadCrossOriginImage(url) { return new Promise((resolve, reject) => { const img = new Image(); img.crossOrigin = 'anonymous'; img.onload = () => resolve(img); img.onerror = reject; img.src = url; }); } async function replaceImagesWithBase64(container) { const imgs = container.querySelectorAll('img'); for (const img of imgs) { try { const corsImg = await loadCrossOriginImage(img.src); const canvas = document.createElement('canvas'); canvas.width = corsImg.naturalWidth; canvas.height = corsImg.naturalHeight; canvas.getContext('2d').drawImage(corsImg, 0, 0); img.setAttribute('src', canvas.toDataURL('image/jpeg', 0.92)); } catch (e) { console.warn('图片无法导出,已替换为空白占位:', img.src); img.setAttribute('src', 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'); } } } function getLineHeight(container) { const style = window.getComputedStyle(container); const fontMetrics = style.fontSize ? parseFloat(style.fontSize) : 14; const lineHeight = style.lineHeight === 'normal' ? fontMetrics * 1.5 : parseFloat(style.lineHeight); return Math.round(lineHeight); // 保证按整数行切割 } async function prepareDom(container) { await replaceImagesWithBase64(container); // 锁定字体和行高,防止截图过程中字体加载导致布局抖动 document.fonts.ready.then(() => { container.style.lineHeight = `${getLineHeight(container)}px`; }); } async function captureCanvas(container, scale) { return html2canvas(container, { scale, useCORS: true, logging: false, backgroundColor: '#ffffff', windowWidth: container.scrollWidth, windowHeight: container.scrollHeight }); } function buildPdfFromCanvas(canvas, filename, margin, lineHeightPx) { const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' }); const pageWidth = pdf.internal.pageSize.getWidth(); const pageHeight = pdf.internal.pageSize.getHeight(); const contentWidthMm = pageWidth - margin.left - margin.right; const contentHeightMm = pageHeight - margin.top - margin.bottom; const canvasWidth = canvas.width; const canvasHeight = canvas.height; // 把canvas宽度缩放成内容区宽度,换算整体高度 const contentHeightPx = (canvasHeight * (contentWidthMm / pxToMm(canvasWidth))); const usableHeightPx = pxToMm(contentHeightMm) * (canvasWidth / (contentWidthMm / pxToMm(canvasWidth))); let position = 0; const pageSliceHeightPx = lineHeightPx * Math.floor(pxToMm(contentHeightMm) / lineHeightPx); const imgFormat = 'JPEG'; pdf.setPage(1); while (position < canvasHeight) { if (position > 0) { pdf.addPage(); } const sliceCanvas = document.createElement('canvas'); sliceCanvas.width = canvasWidth; sliceCanvas.height = Math.min(pageSliceHeightPx, canvasHeight - position); const ctx = sliceCanvas.getContext('2d'); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, sliceCanvas.width, sliceCanvas.height); ctx.drawImage(canvas, 0, position, canvasWidth, sliceCanvas.height, 0, 0, canvasWidth, sliceCanvas.height); const imgData = sliceCanvas.toDataURL('image/jpeg', 0.92); pdf.addImage(imgData, imgFormat, margin.left, margin.top, contentWidthMm, (sliceCanvas.height / canvasWidth) * contentWidthMm); position += sliceCanvas.height; } pdf.save(filename); } async function handleExport() { if (exporting.value) return; exporting.value = true; try { const container = contentRef.value; if (!container) return; await prepareDom(container); const canvas = await captureCanvas(container, props.scale); await new Promise((resolve) => requestAnimationFrame(resolve)); buildPdfFromCanvas(canvas, props.fileName, props.margin, getLineHeight(container)); } catch (error) { console.error('导出失败:', error); } finally { exporting.value = false; } } </script>

5.2 关键参数的解释和踩坑提醒

这个组件里pageSliceHeightPx的计算是整个分页逻辑的定海神针。Math.floor(pxToMm(contentHeightMm) / lineHeightPx)算出的是当前页能放多少整行,再乘行高得到实际的切割高度。这样切出来的图片,每一页的最后一行和下一页的第一行之间不会出现被腰斩的内容。我调整过很多次,确认过切割高度必须是行高整数倍和页面容量取最小值,否则一旦某行文字跨页,观感极差。

document.fonts.ready.then()这一段容易被忽略但很实用。如果页面用了自定义字体且还在加载中,html2canvas截出来的字体可能是回退的宋体,和用户看到的页面完全不一样。我遇到过明明系统里装的字体很好看,导出的PDF却显示成默认字体,排查半天才想起是字体没加载完就截图了。

5.3 空白占位图的选择

跨域兜底方案里,我用了一个1x1的透明GIF base64作为失败占位。为什么不用data:image/jpeg;base64,/9j/...这类更通用的?原因有两点:一是GIF base64字符串最短,减少打包体积;二是在打印场景下,白色背景透明GIF不会引入额外的灰底或黑块。如果你希望失败时看出"这里原来有图片"的错误提示,可以换成一张带斜杠或文字的自绘占位canvas。

6. 从这套方案里沉淀出的排查清单(复制即用)

把这三条线全部打通之后,我给自己总结了一张排查清单。每次新项目需要导出PDF,按这张表检查,基本一遍过。

症状检查方向处理动作
导出模糊scale参数、devicePixelRatioscale调为 2 或 dpr,封顶3
内容右侧被截windowWidth/windowHeight设置设为元素scroll宽度高度
页边距没生效addImage的x、y、width是否基于margin计算所有绘制坐标从margin.left/top起步
表格文字被切断分页切割是否按行高整数倍固定lineHeight,切割高度为lineHeight整数倍
图片空白crossOrigin属性、服务端CORS头img加crossOrigin,Nginx/OSS配置跨域头
导出报Tainted canvases图片资源是否带合法CORS响应预加载阶段全量转base64
字体不对自定义字体未加载完成await document.fonts.ready 后再截图
内存飙升/页面卡死scale过大、canvas面积过大scale封顶3,切片时控制单页宽度

这张表是通用的,但不是万能的。如果你的项目用了特殊渲染方式(比如WebGL、SVG滤镜、CSS mix-blend-mode),html2canvas的支持程度各不相同。按照我个人的处理经验,遇到这类特殊节点,直接改为单独画canvas再drawImage到主canvas,比试图让html2canvas理解项目的复杂样式要省时得多。

结尾想分享一个观念:前端转PDF这件事,真正难的不是API的调用,而是理解"位图采样 + 坐标系换算 + 浏览器安全策略"这条暗线。把这三条暗线摸熟,任何html转PDF的需求拿来都能拆解出清晰、空边、截图完整、图片正常四个子问题。我后来遇到不少同事拿着各种插件来问为什么报错,基本都能在5分钟内定位到具体是哪条暗线出了问题。希望这篇文能帮你省下我当时一周的折腾时间。

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

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

立即咨询