从Markdown到PDF:前端Canvas排版优化实践

写在前面:

本文基于AI智能问答模块,实现回答结果导出为PDF的功能,对比优化前后的实现差异,重点介绍如何在不引入html2canvas、jsPDF、Puppeteer 的前提下,把"边画边分页"的实现升级为"先测量、后分页、再绘制"的前端排版引擎。

一、项目背景介绍

这个功能来自某AI智能助手中的"AI智能问答"模块。用户在面板里可以进行问答、问数等交互,系统会返回一段结构化程度较高的Markdown内容,例如分析结论、分级标题、列表、表格、代码样式字段、引用说明等。

在业务使用中,这类回答不只是临时展示在聊天窗口里,还需要被沉淀成可以流转、归档、汇报和打印的文档。因此,前端需要提供稳定的导出能力,把聊天回答转换成PDF或 Word。这里的重点不是"把屏幕截图保存下来",而是要让导出的文档具备正常报告的阅读体验:标题清楚、段落换行自然、表格不乱、代码块不被切断、分页位置可控。

最初的PDF导出已经完成了从Markdown到Canvas再到PDF的闭环,但随着回答内容越来越复杂,旧方案暴露出了一些排版问题,例如标题落在页尾、表格被拆开、代码块分页不稳定等。因此这次优化的目标很明确:不换技术栈,不引入额外截图或服务端打印方案,而是在当前前端Canvas导出架构内,把排版引擎做得更可靠。

二、技术方案介绍

1、Markdown:AI 回答的结构化文本格式

Markdown是一种轻量级标记语言。AI返回的回答通常天然适合用Markdown表达,比如用 # 表示标题,用 - 表示列表,用反引号表示行内代码或代码块,用管道符表示表格。它的优势是文本可读、结构清晰,也方便前端继续转换成HTML、AST、Word或PDF。

示例:

复制代码
# 研判结论

- 起火点位于建筑二层
- 建议优先组织人员疏散

| 指标 | 内容 |
| --- | --- |
| 风险等级 | 高 |
| 处置建议 | 内攻搜救 + 外部控火 |
2、AST:Markdown的语法树

AST是 Abstract Syntax Tree 的缩写,中文通常叫"抽象语法树"。如果说Markdown原文是一串字符,那么AST就是解析器理解这串字符后得到的结构化结果

例如一段 Markdown 标题,在原文里只是 # 标题,但解析成AST后,它会变成类似"这是一个一级标题,标题内容是某段文本"的结构。相比直接处理字符串,AST更适合做严肃的文档生成,因为它已经把"这是什么内容"表达清楚了。

示例:

复制代码
const tokens = md.parse(markdown, {})

// tokens 中会包含 heading_open、inline、heading_close、paragraph_open、table_open 等结构
// 核心就是从这些token出发,不再依 HTML DOM。
3、 HTML DOM:浏览器渲染后的结构

旧的优化方案仍然保留了Markdown 渲染流程:先把Markdown转成HTML,再让浏览器生成DOM 结构,然后遍历DOM节点判断标题、段落、列表和表格。这个方案直观,和页面展示逻辑接近,但DOM本身不等于文档排版模型,因此还需要额外的布局层来决定分页。

4、Canvas:前端可控的绘图画布

Canvas 是浏览器提供的一块可编程画布。我们可以在上面绘制文字、线条、矩形、图片和表格。PDF 导出里使用 Canvas 的好处是控制力强:文字画在哪里、表格边框怎么画、代码块背景多高、什么时候换页,都可以由前端逻辑决定。

示例:

复制代码
ctx.fillStyle = '#111827'
ctx.font = '13px Microsoft YaHei'
ctx.fillText('这是一行绘制到 Canvas 上的文字', 64, 120)

但 Canvas 也有一个明显特点:它只负责"画",不会自动帮我们理解段落、标题、表格和分页。因此,如果直接边遍历边绘制,就很容易出现分页不可控的问题。优化后的关键,就是在 Canvas 绘制之前先完成 Measure 和 Pagination。

5、PDF Blob:浏览器里的可下载PDF 文件

当前项目的最后一步,是把每一页 Canvas 转成图片数据,再手动组装成PDF Blob。Blob可以理解为浏览器中的二进制文件对象,生成后通过临时URL触发下载。

示例:

复制代码
const blob = buildImagePdf(pageImages)
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = '智能辅助决策回答.pdf'
anchor.click()

因此,整套技术方案的本质是:Markdown提供内容结构,HTML或AST提供可解析的中间表示,Layout Engine负责测量和分页,Canvas负责绘制页面,PDF Blob负责生成最终可下载文件。

三、初始方案:Markdown→HTML→ DOM→Canvas→PDF

最初的PDF导出方案采用的是一条非常直观的前端链路:先把AI返回的Markdown渲染成HTML,再创建一个临时DOM容器,遍历DOM节点并绘制到Canvas,最后把多页Canvas图片组装成PDF Blob下载。

1、整体流程

初始方案的流程可以概括为:Markdown负责表达内容结构,HTML DOM负责承载渲染后的节点结构,Canvas负责绘制页面,PDF Blob负责输出文件。

复制代码
const drawRenderedMarkdown = (markdown: string) => {
  const container = document.createElement('div')
  container.innerHTML = md.render(markdown || '暂无回答内容')

  Array.from(container.children).forEach(drawHtmlBlock)
}

核心绘制逻辑集中在 drawHtmlBlock 中。它根据 DOM 标签判断当前内容类型,例如 h1pulpretable,然后调用不同的绘制函数。

复制代码
const drawHtmlBlock = (element: Element) => {
  const tag = element.tagName.toLowerCase()

  if (tag === 'h1' || tag === 'h2') {
    drawInlineSegments(collectInlineSegments(element), headingStyle)
    return
  }

  if (tag === 'p') {
    drawInlineSegments(collectInlineSegments(element), paragraphStyle)
    return
  }

  if (tag === 'pre') {
    drawCodeBlock(element.textContent || '')
    return
  }

  if (tag === 'table') {
    drawHtmlTable(element as HTMLTableElement)
    return
  }
}
2、Canvas为什么生成 PDF

Canvas本身并不是PDF,但它可以作为PDF页面图像的来源。项目中每一页PDF先绘制成一张 Canvas,然后通过**canvas.toDataURL('image/jpeg')**转成图片数据,再写入PDF文件结构中。

复制代码
const commitPage = () => {
  pageImages.push({
    dataUrl: canvas.toDataURL('image/jpeg', 0.94),
    width: canvas.width,
    height: canvas.height,
  })
}

const blob = buildImagePdf(pageImages)

这种方式的好处是可控:页面尺寸、字体、颜色、表格边框、代码块背景都由前端绘制逻辑决定。它不依赖浏览器打印,也不需要后端服务生成PDF,适合当前AI智能助手这种"前端本地导出"的场景。

3、存在的问题

初始方案的问题不在于链路不通,而在于分页不可预测。旧实现中Canvas一边绘制,一边通过ensureSpace 判断是否换页。

复制代码
const ensureSpace = (height: number) => {
  if (y + height <= pageHeight - margin) return

  commitPage()
  createPage()
}

这种方式适合短文本,但面对复杂Markdown内容时会出现明显问题:

问题 具体表现 原因
标题孤立 标题可能单独出现在页尾,正文被挤到下一页。 标题和紧随正文没有作为一个整体排版。
代码块被拆 代码块逐行绘制,容易在页面底部被切开。 绘制前不知道整个代码块总高度。
表格分页不稳 表格行可能被拆到两页,阅读体验差。 缺少表格整体高度计算。
Canvas 职责过重 绘制函数既要画内容,又要决定分页。 没有独立的布局阶段。

四、保持HTML架构下的排版优化

优化后的当前方案没有推翻原来的Markdown → HTML → DOM → Canvas → PDF架构,而是在 DOM 和 Canvas 之间增加了一层Layout Engine。也就是说,HTML DOM仍然作为内容解析入口,但Canvas不再直接负责分页判断。

1、引入Layout Engine

Layout Engine的核心是把DOM节点转换成统一的 LayoutNode。每个块级元素都先变成一个可测量、可分页、可绘制的布局节点。

复制代码
type LayoutNode = {
  type: string
  height: number
  marginTop: number
  marginBottom: number
  padding: PdfPadding
  keepTogether: boolean
  children: LayoutNode[]

  segments?: PdfInlineSegment[]
  style?: AiDecisionPdfStyle
  lines?: PdfTextLine[]
  level?: number
  rawRows?: PdfTableCell[][]
  tableRows?: PdfTableRowLayout[]
  image?: HTMLImageElement
}

这样,DOM 遍历阶段只负责识别结构,不再立即绘制。标题、段落、表格、引用、列表、代码块都会先进入 LayoutNode Tree。

复制代码
const buildMarkdownLayout = async (markdown: string) => {
  const container = document.createElement('div')
  container.innerHTML = md.render(markdown || '暂无回答内容')

  const nodes: LayoutNode[] = []
  for (const child of Array.from(container.children)) {
    nodes.push(...await elementToLayoutNodes(child))
  }

  return nodes
}
2、Measure:先测量后绘制

优化后的关键原则是:每个块级元素必须先计算真实渲染高度,再决定是否分页,最后才绘制。

复制代码
const measureTextNode = (node: LayoutNode, width = contentWidth) => {
  const style = node.style || { fontSize: 13, lineHeight: 22 }
  const availableWidth = width - node.padding.left - node.padding.right

  node.lines = layoutInlineSegments(
    node.segments || [{ text: node.text || '' }],
    style,
    availableWidth,
  )

  node.height =
    node.padding.top
    + node.lines.length * aiDecisionPdfLineHeight(style)
    + node.padding.bottom
}

表格、图片和代码块也走同样思路:先测量整体高度,再参与分页。

复制代码
const measureTable = (node: LayoutNode) => {
  const rows = node.rawRows || []
  const columnCount = Math.max(...rows.map((row) => row.length), 1)
  const cellWidth = contentWidth / columnCount

  node.tableRows = rows.map((row) => {
    const cells = row.map((cell) => ({
      ...cell,
      lines: wrapPdfText(ctx, cell.text, cellWidth - 16, cellStyle),
    }))

    return {
      cells,
      height: calculateRowHeight(cells),
    }
  })

  node.height = node.tableRows.reduce((sum, row) => sum + row.height, 0)
}
3、Pagination:智能分页

分页逻辑从"绘制过程中检查"改成了"绘制前根据块高度计算"。新的判断条件是:

复制代码
if (currentY + blockHeight > pageBottom) {
  pages.push(page)
  page = []
  currentY = pageTop
}

这比旧方案的if (currentY > pageBottom) 更可靠,因为它判断的是"当前块完整放下以后是否会超页"。如果会超页,就先换页,再绘制。

复制代码
const paginateBlocks = (nodes: LayoutNode[]) => {
  const pages: LayoutNode[][] = []
  let page: LayoutNode[] = []
  let currentY = pageTop

  nodes.forEach((node) => {
    const blockHeight = outerHeight(node)

    if (page.length && currentY + blockHeight > pageBottom) {
      pages.push(page)
      page = []
      currentY = pageTop
    }

    page.push(node)
    currentY += blockHeight
  })

  if (page.length) pages.push(page)
  return pages
}
4、Keep Together:块级元素整体分页

对于图片、表格、引用块、代码块、列表等内容,优化方案默认不拆开。如果当前页剩余空间不足,就把整个块移动到下一页。

一级标题和二级标题还会和紧随其后的正文组成一个group,避免标题单独落在页尾。

复制代码
const applyHeadingKeepTogether = (nodes: LayoutNode[]) => {
  const grouped: LayoutNode[] = []

  for (let index = 0; index < nodes.length; index += 1) {
    const current = nodes[index]
    const next = nodes[index + 1]

    if (
      current?.type === 'heading'
      && (current.level === 1 || current.level === 2)
      && next
      && !['heading', 'divider'].includes(next.type)
    ) {
      grouped.push(createNode({
        type: 'group',
        children: [current, next],
        keepTogether: true,
      }))
      index += 1
    } else if (current) {
      grouped.push(current)
    }
  }

  return grouped
}
5、优化效果对比
维度 初始方案 当前优化方案
分页时机 绘制过程中临时判断。 绘制前完成块级分页。
Canvas 职责 同时负责绘制和分页。 只负责绘制,不决定分页。
标题处理 可能孤立在页尾。 一级/二级标题和正文 Keep Together。
表格处理 逐行绘制,整体不可控。 先测量所有行高,再整体分页。
代码块处理 可能跨页断开。 整体测量,默认不拆页。

优化前:

优化后:

五、下一步优化:Markdown AST 渲染方案

在当前 HTML + Layout 方案稳定之后,下一步可以考虑进一步减少中间层:从 Markdown 直接进入 AST,再从 AST 构建布局块,最后绘制到 Canvas。

1、AST是什么

AST 是 Abstract Syntax Tree 的缩写,中文叫抽象语法树。在 Markdown 场景中,它表示 Markdown 被解析后的语法结构。比如 # 标题 不再只是一行字符串,而是一个 heading token;代码块、列表、表格也都会变成对应的结构化 token。

2、为什么引入AST

HTML 方案需要先把 Markdown 变成 HTML,再从 DOM 标签中反推出文档结构。AST 方案则直接读取 Markdown 的语义结构,路径更短,也更适合做文档生成。

例如标题在 DOM 方案里是 h1 标签,在 AST 方案里是 heading_open token。两者都能表达标题,但 AST 更接近 Markdown 原始语义。

3、AST + Layout + Canvas架构

AST 方案不是替代 Canvas,也不是替代 PDF Blob。它替代的是 Markdown → HTML → DOM 这段中间解析路径。

复制代码
const astBlocks = markdownAstToBlocks(answer)

for (const block of astBlocks) {
  await measureAstBlock(block)
}

const pages = paginateAstBlocks(astBlocks)

pages.forEach((page) => {
  createPage()
  let currentY = pageTop

  page.forEach((block) => {
    drawAstBlock(block, currentY)
    currentY += outerHeight(block)
  })

  commitPage()
})
4、与HTML方案对比
维度 HTML + Layout 方案 AST + Layout 方案
输入结构 Markdown 渲染后的 DOM。 Markdown 解析后的 token AST。
语义来源 依赖 HTML 标签。 依赖 Markdown token。
兼容性 更适合兼容 HTML 内容。 更适合纯 Markdown 文档生成。
维护重点 DOM 标签到 LayoutNode 的映射。 Markdown token 到 LayoutBlock 的映射。

六、三种方案对比

从演进角度看,这个导出功能可以拆成三种方案:初始 HTML → Canvas、改进一 HTML + Layout → Canvas、改进二 AST → Canvas。

1、HTML → Canvas

这是最初的方案,优点是实现直接、理解成本低。Markdown 渲染成 HTML 后,前端按 DOM 标签逐个绘制即可。

它适合内容较短、结构较简单的回答。但当内容变成长文档,包含表格、代码块、引用和多级标题时,就容易出现分页不可控的问题。

2、HTML + Layout → Canvas(改进方案一)

当前方案保留 HTML DOM 作为入口,但在 Canvas 前增加 Layout Engine。它的核心价值是稳定:先测量、再分页、后绘制。

这是目前最适合作为主线的方案,因为它既能兼容现有 Markdown 渲染结果,又解决了分页和块级元素整体性问题。

3、AST → Canvas(改进方案二)

AST 方案更适合后续演进。它直接从 Markdown 语义结构出发,减少 HTML DOM 中间层,更像一个真正的文档生成器。

不过 AST 方案需要覆盖更多 Markdown token 类型,也要额外考虑混入 HTML 的情况。因此它适合作为未来优化方向,而不是立刻完全替换当前 HTML + Layout 方案。

方案 核心链路 优点 不足
初始方案 Markdown → HTML → DOM → Canvas → PDF 实现简单,能快速完成导出。 分页和复杂块处理不稳定。
当前方案 Markdown → HTML → DOM → Layout → Canvas → PDF 分页可控,表格、代码块、标题处理更稳。 仍然依赖 DOM 作为中间结构。
未来方案 Markdown → AST → Layout → Canvas → PDF 语义直接,更适合文档生成。 需要补齐更多 token 兼容逻辑。

欢迎交流!!🌹🌹