Last updated on

一次导出文字“整体下沉”问题的排查与修复

最近我把一个原本集中写在 App.vue 里的周报生成器,重构成了一个更完整的系统化前端。
页面结构更清晰了,组件也拆开了,结果一个很隐蔽但很影响观感的问题冒了出来:

导出 PDF 和导出图片时,所有文字都出现了不同程度的“往下沉”,其中最明显的是红色标题栏里的白字。

页面里看着基本正常,导出后却不对。这类问题一开始很容易让人怀疑是某条 CSS 写坏了,比如:

  • line-height
  • padding
  • align-items
  • font-family
  • vertical-align

但这次最后证明,问题的核心根本不在这些表层样式上,而在于“我到底用了什么导出路径”。


先说结论

这次问题的核心不是标题组件写错,也不是某个表格样式写错,而是:

我原来使用的 html2canvas 导出链路,本质上是“重新绘制 DOM”,而不是真正拿浏览器已经渲染好的结果做导出。
在中文、粗体、小字号、高对比背景这类场景下,它非常容易出现文字基线和真实页面不一致的问题。

最后我把导出改成了基于 html-to-image 的实现,也就是更接近 foreignObject 的路线,导出文字位置恢复正常。


问题现象

这次的问题有几个很典型的特征:

  1. 页面预览基本正常
  2. 导出的 PNG / PDF 文字整体偏下
  3. 红色标题栏最明显
  4. 表格、说明区文字也有轻微下沉
  5. 单独调标题样式,效果很有限

这几个现象放在一起,其实已经很像“导出引擎和浏览器真实排版不一致”了。

换句话说,如果一个页面在浏览器里是正常的,但导出出来明显不正常,那就要高度怀疑:

  • 不是页面排版错了
  • 而是导出工具在“重建页面”时出了偏差

我最开始走过的弯路

刚开始我也先从样式层面排查,怀疑过这些方向:

  • 重构后字体继承变了
  • 根节点 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,而是导出路径

因为这次问题有一个很强的信号:

无论怎么调标题区的 paddingline-heightflex,都只能“看起来稍微顺一点”,但无法真正消掉那种整体下沉感。

如果根因真是样式写错,通常会有一个明确的修复点。
但这次更像是:

  • 页面排版本身没错
  • 导出时文字被另一套机制画歪了

这类问题继续深挖 CSS,收益通常很低。
最好的办法不是继续微调,而是换一条更接近浏览器原生渲染的导出路线。


新的实现路径:html-to-image

这次最终切到的是 html-to-image

它的思路更接近 foreignObject 路线。简单理解:

  • html2canvas:自己重画 DOM
  • html-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. 改动量小

这也是我最后选择它的重要原因。
如果直接切到 PlaywrightPuppeteer,当然会更稳,但改造量、运行环境、部署复杂度都会一起上来。

html-to-image 只需要:

  • 新装一个依赖
  • 替换导出工具文件
  • 保持页面和业务组件基本不动

这个性价比很高。


新方案也不是完美的

虽然这次它成功解决了问题,但它并不意味着“以后所有导出问题都没有了”。

它仍然属于前端 DOM 导出方案,所以也有自己的边界。

1. 它依然不是浏览器原生截图

也就是说,它比 html2canvas 更接近真实渲染,但还不是最稳的那种“所见即所得”。

2. 复杂节点还是要验证

比如这类内容以后都要特别留意:

  • ECharts 这种 canvas 图表
  • 特殊字体
  • 外链图片
  • 跨域资源
  • 某些复杂组件样式

3. PDF 仍然是图片式 PDF

当前 PDF 的生成方式,本质上还是:

  1. 先生成一张图片
  2. 再把图片塞进 PDF

所以它的缺点也依然存在:

  • 文本不能选中
  • 不能搜索
  • 放大后清晰度受图片分辨率影响

如果以后再做导出,我会怎么选

这次踩完坑之后,我对这几条路线的判断会很明确。

方案一:html2canvas

适合:

  • 追求接入快
  • 页面结构简单
  • 对文字排版精度不敏感

不适合:

  • 中文正式报告
  • 高对比标题区
  • 对页面与导出一致性要求高的场景

方案二:html-to-image

适合:

  • 希望保留纯前端导出
  • 改动成本要小
  • 页面导出效果需要尽量接近浏览器显示
  • 以截图型报告为主

这是当前这个项目最合适的平衡点。

方案三:Playwright / Puppeteer

适合:

  • 真正需要“所见即所得”
  • 稳定性要求非常高
  • 愿意接受更高的部署和维护成本

这是我心里长期最稳的方案,但不一定是每个项目当前阶段都值得立刻上。


这次复盘后,我给自己的一个经验

以后再遇到“页面正常、导出错位”的问题,我不会再第一时间陷进 CSS 细节里,而是先问自己两个问题:

  1. 我现在的导出工具,到底是在“截图真实页面”,还是在“重新画一个页面”?
  2. 当前业务内容,是不是对文字排版精度特别敏感?

如果答案是:

  • 不是在真实截图
  • 而且又是中文正式报告

那就应该尽早怀疑导出路径本身,而不是在 paddingline-height 上死磕。


当前项目最终选择

这次的最终选择是:

  • 放弃 html2canvas
  • 改用 html-to-image
  • 保持现有前端交互和 PDF 生成方式不变

这个选择的原因很实际:

  • 效果明显变好
  • 改动小
  • 风险可控
  • 不需要把整个导出系统迁到后端

对当前这个项目阶段来说,这是更正确的一条实现路径。