一次导出文字“整体下沉”问题的排查与修复
最近我把一个原本集中写在 App.vue 里的周报生成器,重构成了一个更完整的系统化前端。
页面结构更清晰了,组件也拆开了,结果一个很隐蔽但很影响观感的问题冒了出来:
导出 PDF 和导出图片时,所有文字都出现了不同程度的“往下沉”,其中最明显的是红色标题栏里的白字。
页面里看着基本正常,导出后却不对。这类问题一开始很容易让人怀疑是某条 CSS 写坏了,比如:
line-heightpaddingalign-itemsfont-familyvertical-align
但这次最后证明,问题的核心根本不在这些表层样式上,而在于“我到底用了什么导出路径”。
先说结论
这次问题的核心不是标题组件写错,也不是某个表格样式写错,而是:
我原来使用的 html2canvas 导出链路,本质上是“重新绘制 DOM”,而不是真正拿浏览器已经渲染好的结果做导出。
在中文、粗体、小字号、高对比背景这类场景下,它非常容易出现文字基线和真实页面不一致的问题。
最后我把导出改成了基于 html-to-image 的实现,也就是更接近 foreignObject 的路线,导出文字位置恢复正常。
问题现象
这次的问题有几个很典型的特征:
- 页面预览基本正常
- 导出的 PNG / PDF 文字整体偏下
- 红色标题栏最明显
- 表格、说明区文字也有轻微下沉
- 单独调标题样式,效果很有限
这几个现象放在一起,其实已经很像“导出引擎和浏览器真实排版不一致”了。
换句话说,如果一个页面在浏览器里是正常的,但导出出来明显不正常,那就要高度怀疑:
- 不是页面排版错了
- 而是导出工具在“重建页面”时出了偏差
我最开始走过的弯路
刚开始我也先从样式层面排查,怀疑过这些方向:
- 重构后字体继承变了
- 根节点
line-height影响了报告区域 - 标题栏
flex对齐方式不稳定 - 表格单元格的垂直对齐不一致
- 离屏克隆节点丢失父级样式
这些猜测都不算离谱,而且在很多项目里确实可能是根因。
但这次的特点是:怎么调,标题都还是“差一点”,而且不是只影响标题,是整个报告里的文字都有不同程度的轻微下沉。
这时候我才彻底把注意力从 CSS 上移开,去看“导出实现本身”。
旧的实现路径:html2canvas
之前的导出方案依赖 html2canvas。
它的思路不是“拿浏览器屏幕上的真实结果导出来”,而是读取 DOM 和样式,然后自己在 canvas 上再画一遍。
旧方案的代码大致是这样
import html2canvas from 'html2canvas'import jsPDF from 'jspdf'
const canvas = await html2canvas(element, { scale: 2, width: targetWidth, windowWidth: targetWidth, backgroundColor: '#ffffff', useCORS: true, logging: false, scrollY: 0, scrollX: 0, x: 0, y: 0})
const pdf = new jsPDF('p', 'pt', 'a4')pdf.addImage(canvas.toDataURL('image/png'), 'PNG', 20, 20, imgWidth, imgHeight)pdf.save(`${fileBaseName}.pdf`)如果只是看接口,这个方案很顺手:
- API 简单
- 接入成本低
- 能很快做出“导出图片 / 导出 PDF”
但问题也就在这里。
旧方案的本质弊端
html2canvas 更像是在做一件事:
“我理解一下这个 DOM 长什么样,然后自己把它画到 canvas 里。”
这就意味着它和浏览器真实排版之间,天然存在误差空间,尤其体现在:
- 字体度量
- 基线位置
- 粗体渲染
- 中文字符上下留白
- 字体平滑
- 行盒高度
也就是说,页面上你看到的是浏览器原生排版结果,而导出时得到的是另一个“近似版本”。
在纯英文、普通字号、常规背景的页面里,这种误差可能不明显。
但一旦到了这次这种场景:
- 中文
- 粗体
- 小字号
- 白字压在深红背景上
误差就会被肉眼非常明显地放大出来。
为什么我后来判断核心不是 CSS,而是导出路径
因为这次问题有一个很强的信号:
无论怎么调标题区的 padding、line-height、flex,都只能“看起来稍微顺一点”,但无法真正消掉那种整体下沉感。
如果根因真是样式写错,通常会有一个明确的修复点。
但这次更像是:
- 页面排版本身没错
- 导出时文字被另一套机制画歪了
这类问题继续深挖 CSS,收益通常很低。
最好的办法不是继续微调,而是换一条更接近浏览器原生渲染的导出路线。
新的实现路径:html-to-image
这次最终切到的是 html-to-image。
它的思路更接近 foreignObject 路线。简单理解:
html2canvas:自己重画 DOMhtml-to-image:更依赖浏览器去渲染 DOM,再转成图片
这也是为什么它在文字位置、字体表现上,通常会比 html2canvas 更接近页面真实显示结果。
当前项目里的实现代码
现在导出工具的核心代码是这样的:
import { toCanvas } from 'html-to-image'import jsPDF from 'jspdf'
const downloadCanvasAsPNG = (canvas, fileBaseName) => { const link = document.createElement('a') link.download = `${fileBaseName}.png` link.href = canvas.toDataURL('image/png') link.click()}
const captureReportCanvas = async ({ reportContentRef, chartRef, targetWidth = 1200, onExportStart, onExportEnd, waitForLayoutStabilize}) => { const element = reportContentRef?.value if (!element) return null
const originalWidth = element.style.width const originalPadding = element.style.padding const originalBoxSizing = element.style.boxSizing const originalBackground = element.style.background const originalMargin = element.style.margin
try { onExportStart?.() chartRef?.value?.hideTip?.()
await waitForLayoutStabilize?.()
element.style.width = `${targetWidth}px` element.style.padding = '20px' element.style.boxSizing = 'border-box' element.style.background = '#ffffff' element.style.margin = '0'
await waitForLayoutStabilize?.() chartRef?.value?.resizeToContainer?.() await waitForLayoutStabilize?.()
return await toCanvas(element, { pixelRatio: 2, width: targetWidth, canvasWidth: targetWidth * 2, canvasHeight: Math.round(element.scrollHeight * 2), backgroundColor: '#ffffff', cacheBust: true, skipFonts: false, style: { width: `${targetWidth}px`, backgroundColor: '#ffffff' } }) } finally { element.style.width = originalWidth element.style.padding = originalPadding element.style.boxSizing = originalBoxSizing element.style.background = originalBackground element.style.margin = originalMargin
await waitForLayoutStabilize?.() chartRef?.value?.resizeToContainer?.() onExportEnd?.() await waitForLayoutStabilize?.() }}然后 PNG 和 PDF 都复用这张 canvas:
export const exportReportPDF = async (options) => { const { fileBaseName } = options const canvas = await captureReportCanvas(options) if (!canvas) return false
const contentWidth = canvas.width const contentHeight = canvas.height const pdf = new jsPDF('p', 'pt', 'a4') const pageWidth = 595.28 const margin = 20 const imgWidth = pageWidth - (margin * 2) const imgHeight = (contentHeight * imgWidth) / contentWidth
pdf.addImage(canvas.toDataURL('image/png'), 'PNG', margin, margin, imgWidth, imgHeight) pdf.save(`${fileBaseName}.pdf`)
return true}
export const exportReportPNG = async (options) => { const { fileBaseName } = options const canvas = await captureReportCanvas(options) if (!canvas) return false
downloadCanvasAsPNG(canvas, fileBaseName) return true}新方案为什么有效
这次切换后,标题文字的下沉问题消失了,核心原因不是“换了个库名字”,而是渲染路径真的变了。
新方案的收益主要有几个:
1. 文字更接近浏览器真实渲染
这是最重要的点。
对这个项目来说,导出结果最需要的不是“能导出来”,而是“导出来之后看起来和页面一致”。
html-to-image 在这件事上明显比 html2canvas 更适合当前这份报告。
2. 对中文和粗体更友好
这次最明显的问题正好是中文粗体标题。
而这类文字恰恰是最容易暴露 html2canvas 基线误差的地方。
3. 改动量小
这也是我最后选择它的重要原因。
如果直接切到 Playwright 或 Puppeteer,当然会更稳,但改造量、运行环境、部署复杂度都会一起上来。
而 html-to-image 只需要:
- 新装一个依赖
- 替换导出工具文件
- 保持页面和业务组件基本不动
这个性价比很高。
新方案也不是完美的
虽然这次它成功解决了问题,但它并不意味着“以后所有导出问题都没有了”。
它仍然属于前端 DOM 导出方案,所以也有自己的边界。
1. 它依然不是浏览器原生截图
也就是说,它比 html2canvas 更接近真实渲染,但还不是最稳的那种“所见即所得”。
2. 复杂节点还是要验证
比如这类内容以后都要特别留意:
- ECharts 这种 canvas 图表
- 特殊字体
- 外链图片
- 跨域资源
- 某些复杂组件样式
3. PDF 仍然是图片式 PDF
当前 PDF 的生成方式,本质上还是:
- 先生成一张图片
- 再把图片塞进 PDF
所以它的缺点也依然存在:
- 文本不能选中
- 不能搜索
- 放大后清晰度受图片分辨率影响
如果以后再做导出,我会怎么选
这次踩完坑之后,我对这几条路线的判断会很明确。
方案一:html2canvas
适合:
- 追求接入快
- 页面结构简单
- 对文字排版精度不敏感
不适合:
- 中文正式报告
- 高对比标题区
- 对页面与导出一致性要求高的场景
方案二:html-to-image
适合:
- 希望保留纯前端导出
- 改动成本要小
- 页面导出效果需要尽量接近浏览器显示
- 以截图型报告为主
这是当前这个项目最合适的平衡点。
方案三:Playwright / Puppeteer
适合:
- 真正需要“所见即所得”
- 稳定性要求非常高
- 愿意接受更高的部署和维护成本
这是我心里长期最稳的方案,但不一定是每个项目当前阶段都值得立刻上。
这次复盘后,我给自己的一个经验
以后再遇到“页面正常、导出错位”的问题,我不会再第一时间陷进 CSS 细节里,而是先问自己两个问题:
- 我现在的导出工具,到底是在“截图真实页面”,还是在“重新画一个页面”?
- 当前业务内容,是不是对文字排版精度特别敏感?
如果答案是:
- 不是在真实截图
- 而且又是中文正式报告
那就应该尽早怀疑导出路径本身,而不是在 padding、line-height 上死磕。
当前项目最终选择
这次的最终选择是:
- 放弃
html2canvas - 改用
html-to-image - 保持现有前端交互和 PDF 生成方式不变
这个选择的原因很实际:
- 效果明显变好
- 改动小
- 风险可控
- 不需要把整个导出系统迁到后端
对当前这个项目阶段来说,这是更正确的一条实现路径。