
写在前面本文基于AI智能问答模块实现回答结果导出为PDF的功能对比优化前后的实现差异重点介绍如何在不引入html2canvas、jsPDF、Puppeteer 的前提下把“边画边分页”的实现升级为“先测量、后分页、再绘制”的前端排版引擎。一、项目背景介绍这个功能来自某AI智能助手中的“AI智能问答”模块。用户在面板里可以进行问答、问数等交互系统会返回一段结构化程度较高的Markdown内容例如分析结论、分级标题、列表、表格、代码样式字段、引用说明等。在业务使用中这类回答不只是临时展示在聊天窗口里还需要被沉淀成可以流转、归档、汇报和打印的文档。因此前端需要提供稳定的导出能力把聊天回答转换成PDF或 Word。这里的重点不是“把屏幕截图保存下来”而是要让导出的文档具备正常报告的阅读体验标题清楚、段落换行自然、表格不乱、代码块不被切断、分页位置可控。最初的PDF导出已经完成了从Markdown到Canvas再到PDF的闭环但随着回答内容越来越复杂旧方案暴露出了一些排版问题例如标题落在页尾、表格被拆开、代码块分页不稳定等。因此这次优化的目标很明确不换技术栈不引入额外截图或服务端打印方案而是在当前前端Canvas导出架构内把排版引擎做得更可靠。二、技术方案介绍1、MarkdownAI 回答的结构化文本格式Markdown是一种轻量级标记语言。AI返回的回答通常天然适合用Markdown表达比如用#表示标题用-表示列表用反引号表示行内代码或代码块用管道符表示表格。它的优势是文本可读、结构清晰也方便前端继续转换成HTML、AST、Word或PDF。示例# 研判结论 - 起火点位于建筑二层 - 建议优先组织人员疏散 | 指标 | 内容 | | --- | --- | | 风险等级 | 高 | | 处置建议 | 内攻搜救 外部控火 |2、ASTMarkdown的语法树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 标签判断当前内容类型例如h1、p、ul、pre、table然后调用不同的绘制函数。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为什么生成 PDFCanvas本身并不是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 EngineLayout 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、为什么引入ASTHTML 方案需要先把 Markdown 变成 HTML再从 DOM 标签中反推出文档结构。AST 方案则直接读取 Markdown 的语义结构路径更短也更适合做文档生成。例如标题在 DOM 方案里是h1标签在 AST 方案里是heading_opentoken。两者都能表达标题但 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 兼容逻辑。欢迎交流