news 2026/7/2 5:54:28

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从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_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 兼容逻辑。

欢迎交流!!🌹🌹

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/2 5:50:45

全流程产品管理软件选哪个?2026年主流工具核心功能与选型指南

2026年&#xff0c;面对需求收集混乱、研发进度失控等实际问题&#xff0c;团队究竟该选哪款全流程产品管理软件&#xff1f;本文从需求规划、任务拆解、跨部门协作、数据复盘及扩展集成五个维度&#xff0c;对 ONES、Tower、Jira、Productboard、Aha!、Airfocus、Asana 这七款…

作者头像 李华
网站建设 2026/7/2 5:50:40

企业微信API接口日志为什么要分层记录

在企业微信二次开发项目中&#xff0c;日志体系是排查问题和保障系统稳定的重要基础。很多问题在页面上只表现为“同步失败”“客户状态异常”“群成员不一致”“任务未完成”&#xff0c;但真正原因可能出现在接口调用、回调接收、异步任务、业务规则或人工操作中的某个环节。…

作者头像 李华
网站建设 2026/7/2 5:50:20

自动售货机总是卡货?教你几招轻松搞定~YH

机器卡货真的让人头疼&#xff0c;今天分享几个实用小技巧&#xff5e;为什么会卡货&#xff1f;最常见的原因有三个&#xff1a;商品尺寸不匹配&#xff1a;货道宽度和商品尺寸对不上&#xff0c;容易卡在中间。选品时一定要测量好尺寸。货道弹簧老化&#xff1a;用久了弹簧弹…

作者头像 李华
网站建设 2026/7/2 5:50:03

公证亲属关系要什么材料?公证亲属关系多久办好?

出国探亲、留学担保、房产继承&#xff0c;甚至处理一些家庭财产纠纷时&#xff0c;办事机构经常会甩来一句&#xff1a;“请提供亲属关系公证书”。这薄薄的一纸证明&#xff0c;往往成了卡住办事进度的“拦路虎”。很多人一听到“公证”两个字就头大&#xff0c;脑子里瞬间浮…

作者头像 李华
网站建设 2026/7/2 5:46:07

Ubuntu 安装 Nginx

1、更新sudo apt update sudo apt upgrade -y2、安装sudo apt install nginx -ysudo systemctl start nginx sudo systemctl enable nginx

作者头像 李华