搞过富文本编辑器导出功能的人应该都有同感:编辑器里排版好好的,一导出就各种翻车。要么样式全乱,要么PDF里面出现截断、乱码,要么图片加载不出来。我在某跨平台项目中做过一个通用导出模块,专门把富文本内容导出成干净可用的HTML静态页面,再转出PDF文件,踩了不少坑,也沉淀了一套靠谱的方案。这篇就把完整的思路、选型逻辑和实操细节摊开讲,给正在做类似功能的朋友做个参考,能少走很多弯路。
1. 应用场景与导出需求拆解
1.1 功能定位:为什么必须导出HTML和PDF
先说结论:富文本编辑器本身只负责“编辑”,它产出的数据结构(不管是JSON还是内部存储格式)并不适合直接交付给终端用户。用户要的是“固定的页面文件”和“规范的可打印文档”。
我归纳下来,最常见的三类场景是这样:
- 合同、标书等正式文书:某企业内部系统里,业务人员用富文本编辑器起草合同,然后需要导出一份排版固定的HTML页面用于在线归档,同时还要一份PDF用于打印、盖章、存档。
- 公众号/门户文章发布:运营人员先在后台编辑器里写完长文,导出HTML静态页面后交给前端发布系统,或者直接转成PDF分发给客户。
- 模板类报告:比如检测报告、评估报告,主体内容由系统拼接,编辑人员手工微调排版,最终交付物需要统一为PDF格式。
这三类场景有一个共同点:内容源头都是富文本编辑器的内部数据,但交付形态必须是“通用的、可读的、不可随意变形的”文件。PDF的好处是跨端一致、不可编辑、适合打印,HTML的好处是体积小、可检索、可继续嵌入网页,而且实现成本更低。
1.2 原始数据形态:内部格式与输出格式的差异
任何富文本编辑器,比如常见的Quill、Editor.js、TinyMCE、CKEditor,内部存储都分两层:一个是编辑区域的DOM结构,一个是可序列化的数据格式(比如Quill的Delta、Editor.js的JSONBlock)。导出时面临的第一道坎就是:怎么把内部格式还原成干净、语义化的HTML字符串。
这里有个典型误区:不少人直接拿editor.root.innerHTML或者editor.getContents()硬拼。结果导出来的HTML里全是编辑器特有的类名、内联样式、额外的容器标签,放到别的页面里就“失控”了。
以某跨平台系统为例,我们当初用的是Quill,内部Delta长这样:
[ { "insert": "项目概述", "attributes": { "bold": true, "color": "#1a1a1a" } }, { "insert": "\n" }, { "insert": "本系统用于统一管理各类文档"。, "attributes": { "size": "14px" } } ]如果直接把Delta循环拼成HTML,会面临两个问题:一是有title这种自定义格式时原生没有映射;二是列表、表格这类块级结构会被拆得支离破碎。所以正确做法是:先利用编辑器的clipboard模块复制“干净的HTML”,比如Quill就有getSemanticHTML(),或者用clipboard.convert()把Delta转换成标准HTML。这一步能极大减轻后续清洗的负担。
1.3 使用人群与交付边界
这几类功能的操作人多是普通业务人员,不是前端工程师。所以导出功能必须做到:点一个按钮,拿到的文件开箱即用。这就界定了两条设计原则:
- 前端不依赖后端参与本页渲染:最好在浏览器本地就完成HTML生成和样式内联,PDF可以在前端直接生成,或者交由后端无头浏览器完成一致性渲染。
- 导出的文件不能依赖原编辑器环境:HTML页面里的
class定义必须自带,不能依赖页面已有CSS;PDF的排版必须不依赖原网页的样式表。
明白这些边界后,方案选型就有方向了。
2. 技术方案选型与对比
2.1 方案清单:四种主流路线
做导出功能之前,先把可选的方案摸一遍。我梳理了实际项目中常见的四条路线:
| 方案 | 核心思路 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| A:html2canvas + jsPDF | 把页面DOM绘制成Canvas图片,再逐页放入PDF | 实现简单,纯前端 | 文本变图片,不可检索;分页容易截断 | 临时导出、页面简短 |
| B:浏览器打印(window.print + @media print) | 调用系统打印预览,让用户自己选“另存为PDF” | 零依赖、效果最稳 | 用户多一步操作;不同浏览器差异明显 | 快捷导出、存在打印机的环境 |
| C:后端无头浏览器渲染 | 用Puppeteer等工具加载HTML页面,调用Page.pdf() | 排版与浏览器一致,支持分页控制 | 需要后端服务,部署较重 | 批量导出、正式报表 |
| D:编辑器原生导出插件 | 编辑器自带PDF导出能力 | 格式还原最好 | 受编辑器生态限制,扩展性差 | 单编辑器产品 |
如果只是做内部工具,“方案B”其实是被低估的。用户点击导出后调起打印对话框,选目标打印机为“另存为PDF”,输出效果和浏览器渲染一模一样,几乎不用写布局代码。缺点是产品体验上多了一步,而且用户在对话框里能调整页边距纸张,不可控。
项目落地我更倾向组合拳:前端用方案A做“预览版快速导出”,再用方案C的轻量化封装做“正式版PDF导出”。下面分别展开这两条路的细节。
2.2 为什么优先选用前端生成路线
后端无头浏览器方案的优势是排版稳定,但代价是引入一个Node/Python服务,增加了部署和运维负担。很多中小项目并不值得。而纯前端Web方案的优势在于:
- 不需要额外的服务资源,支静态页面的部署环境就能跑。
- 编辑器已经在浏览器里渲染了一遍,输出端到端的链路短,调试方便。
- 导出HTML这一步本身就依赖前端DOM,顺手也能生成PDF。
劣势也很纯粹:前端PDF生成面临“样式还原度”和“分页控制”两大难题,这两个问题后面会专门展开。
2.3 工具选型经验:具体依赖怎么选
具体到前端PDF生成,主流组合是:
- html2canvas:负责把DOM节点画到Canvas上。它的问题是“画不出精确到像素的 PDF”,因为它是先转图片再贴图。
- jsPDF:负责创建PDF文档,提供画布贴图、尺寸换算、分页处理。
这两个库单独用问题不大,合在一起就是“分辨率折算”容易掉链子。我的经验是:导出用scale参数把Canvas的分辨率拉高,比如scale: 2,否则PDF中的文字在缩放后会发虚。当然,文本不可选中、不可搜索的问题依然存在,这是所有“图片型PDF”的宿命,项目接受这个约束就行。
如果要“文本型PDF”,就得换思路。可以用后端方案渲染,或使用生成PDF的库(如pdfmake、React-PDF)把编辑器Delta数据映射成PDF元素。这样PDF里的文字是可选中、可搜索的,但需要对每种块级样式逐一映射,工程量翻倍。我在项目里只对报告类模板做了Delta适配,复杂度确实高,但效果也值。
顺带说一句:如果编辑器内容以代码块、表格、公式为主,图片型PDF几乎是唯一省力方案,因为公式和复杂表格在Canvas渲染时直接作为整体贴上去,反而不会出现字符错位。
3. 核心实现:导出HTML静态页面
3.1 内容清洗与序列化
第一步是要拿到“干净”的HTML字符串,我把它拆成三个动作:
- 结构清洗:把编辑器特有的属性剥掉。Quill的Delta转HTML时,要过滤
><h1>项目合同</h1> <p>甲方:某公司</p> <p>乙方:某系统</p> <table> <thead>...</thead> <tbody>...</tbody> </table> <p>以上条款自双方签署之日起生效。</p>没有编辑器痕迹,没有多余的div嵌套,语义清晰。这一层做得好不好,决定下游几个环节是否顺滑。
3.2 生成独立可交付的HTML页面
拿到核心HTML片段后,需要把它包装成一个独立的、自带样式的完整HTML静态页。我给出的标准外壳是:
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>${documentTitle}</title> <style> body { font-family: "Microsoft YaHei", "PingFang SC", sans-serif; font-size: 14px; line-height: 1.8; color: #333; margin: 0 auto; padding: 40px; max-width: 900px; } h1 { font-size: 22px; } table { border-collapse: collapse; width: 100%; } td, th { border: 1px solid #999; padding: 6px 8px; } img { max-width: 100%; } pre { background: #f6f8fa; padding: 12px; overflow: auto; } </style> </head> <body> ${contentHtml} </body> </html>这块有两个容易被忽略的点:
lang="zh-CN"必须写,否则某些浏览器的默认字体渲染会偏好英文排版,导致中文间距别扭。max-width和padding要故意设值,避免内容沾满整个屏幕,阅读体验差。这个页面后续也常常是打印或转PDF的中间物,所以排版应该向A4内容区靠拢。
生成的方式很简单:用
Blob加上URL.createObjectURL触发下载,文件名用时间戳加上语义后缀就行。这一步在所有浏览器里都能跑,不需要node环境。3.3 页面外链资源的相对路径处理
富文本里经常有图片,导出HTML时最折腾的就是图片地址。如果编辑器里存的是后端上传域名下的绝对地址,导出后图片能正常显示。但如果是本地相对路径(比如
/uploads/xxx.png),则导出的HTML文件一旦被单独打开或移走,图片就挂了。处理办法有三个层级:
- 层级一:导出前把相对路径替换成站点绝对路径。
- 层级二:对内容里的图片做跨域抓取,转化成
base64内联。 - 层级三:如果图片本身有防盗链,只能让用户手动上传到OSS后再替换。
最关键的是不能直接把对象URL(
blob:)写进HTML,因为对象URL只在当前页面会话有效,文件一关就失效。我在项目里遇到过线上临时图片导出的问题,后来统一加了“导出前图片资源规范化”这一步:遍历图片节点,若URL是blob:或相对路径,就通过fetch转成base64后再嵌入,确保导出的文件独立可看。4. PDF导出实操:前端与后端两种路线
4.1 前端路线:html2canvas + jsPDF 的分页与尺寸策略
先把最常见的前端方案讲透。核心流程三步:把内容绘制到Canvas,把Canvas拆成多页,逐页添加到PDF。
这里最大的坑是分页时内容被“拦腰截断”,一个段落横跨两页,观感很差。解决思路是“按元素动态分页”,而不是按固定高度硬切。
我实际用的处理逻辑是这样:
- 获取目标容器的完整Canvas;
- 设定PDF页面尺寸(比如A4纵向:595.28pt × 841.89pt);
- 从Canvas顶部以“页面高度”为滑动窗口,遍历内部元素的位置;
- 如果某个元素的顶部跨出了当前窗口,就把窗口底部自动抬升到该元素的顶部,确保该元素完整出现在下一页;
- 每一页转换成图片后放入对应的PDF页。
整体伪代码思路:
async function exportPdfFromDom(container, fileName) { const canvas = await html2canvas(container, { scale: 2, useCORS: true, backgroundColor: '#ffffff', }); const pdfWidth = 595.28; const pdfHeight = 841.89; const pdf = new jsPDF('p', 'pt', 'a4'); const pageCount = Math.ceil(canvas.height / pdfHeight); for (let i = 0; i < pageCount; i++) { if (i > 0) pdf.addPage(); const startY = i * pdfHeight; const slice = document.createElement('canvas'); slice.width = canvas.width; slice.height = Math.min(pdfHeight, canvas.height - startY); const ctx = slice.getContext('2d'); ctx.drawImage(canvas, 0, startY, canvas.width, slice.height, 0, 0, canvas.width, slice.height); const imgData = slice.toDataURL('image/jpeg', 0.92); pdf.addImage(imgData, 'JPEG', 0, 0, pdfWidth, pdfHeight); } pdf.save(fileName); }注意几个参数:
scale: 2:保证清晰度,太低在Retina屏幕上明显虚化。useCORS: true:允许跨域图片绘制,但前提是图片服务端允许跨域访问。backgroundColor: '#ffffff':防止透明背景导致PDF底色异常。
但我上面这个写法有一个隐患:固定按窗口高度切页,遇到段落跨页依然会切断。更稳的做法是通过DOM原生的
getBoundingClientRect计算出每个块级元素在当前页面的位置,判断是否需要强制分页。这个逻辑虽然代码多几十行,但导出的PDF质量完全是两个档次,值得写。4.2 前端路线:利用浏览器原生打印能力生成PDF
如果你的项目不想引第三方生成PDF的库,还有一个思路:先打开一个隐藏的iframe,把整理好的HTML写入,然后调用打印,用户选择“另存为PDF”。
具体操作:
function exportHtmlToPdfByPrint(htmlContent) { const iframe = document.createElement('iframe'); iframe.style.position = 'fixed'; iframe.style.right = '0'; iframe.style.bottom = '0'; iframe.style.width = '0'; iframe.style.height = '0'; document.body.appendChild(iframe); const doc = iframe.contentWindow.document; doc.open(); doc.write(htmlContent); doc.close(); iframe.contentWindow.focus(); iframe.contentWindow.print(); setTimeout(() => { document.body.removeChild(iframe); }, 1000); }关键在于事先在HTML中内置
@media print样式。比如设置A4页面边距、隐藏不需要打印的元素、调整表格边框颜色等。打印预览里的呈现效果,基本就是浏览器的渲染结果,比html2canvas的还原度更高,文字也可选可复制。缺点是两个:
- 用户必须手动在打印弹窗里选择“另存为PDF”,无法一键完成。
- 不同浏览器的打印引擎对CSS支持有差异,火狐和Chrome的排版可能有细微出入。
我通常把这条路线作为“保底方案”,当前端生成库出问题或者用户要求“所见即所得地另存”时,让用户走打印导出。这个方案无论如何不会完全失效。
4.3 后端路线:无头浏览器渲染HTML为PDF
前端方案的短板在超长文档上特别明显:浏览器渲染几千个DOM节点,再绘制成Canvas,内存占用会非常吓人,甚至直接把页面卡死。
如果系统已经具备后端服务能力,我更推荐把PDF生成丢给无头浏览器处理。逻辑是:前端把第二步生成的干净HTML字符串(或页面URL)POST到后端,后端开启一个浏览器实例,加载该HTML,等图片和字体加载完毕,调用页面对象的PDF生成方法。
一个典型的后端处理函数(Node环境)示意:
async function renderPdfFromHtml(html, outputPath) { const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox'] }); const page = await browser.newPage(); await page.setContent(html, { waitUntil: 'networkidle0' }); await page.pdf({ path: outputPath, format: 'A4', printBackground: true, margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' }, displayHeaderFooter: true, headerTemplate: '<span></span>', footerTemplate: `<div style="font-size:9px; width:100%; text-align:center;"><span class="pageNumber"></span> / <span class="totalPages"></span></div>` }); await browser.close(); }这里有两个重点:
waitUntil: 'networkidle0'必须保留,确保所有外链图片和字体加载完再渲染,否则容易出现图片占位空白。printBackground: true,否则CSS里的背景色、背景图全部不显示。
分页控制上,无头浏览器比前端方案自然得多。它对
page-break-before、page-break-after、break-inside: avoid等CSS分页属性有较好支持,可以直接在HTML的样式表里给需要保持完整的模块加规则。我踩过的一个坑是字体渲染:某些字体未安装或未使用WebFont时,PDF里的中文会变成方块。解决方式是在CSS里明确指定常见中文字体栈,或者把字体文件打包到页面中。对内的办公系统,通常服务器装了中文操作系统就没事;但在Linux Docker容器里,必须确认是否有中文字体文件,否则一定要额外下载或引入在线字体。
4.4 输出格式对比:图片型PDF与文本型PDF
做完两种路线后,需要根据交付场景选择最终产出形态。我整理了一个决策表:
对比维度 图片型PDF(Canvas截图) 文本型PDF(打印/无头浏览器渲染) 文字可选可复制 否 是 文件体积 偏大 较小 排版还原度 高,但受分辨率影响 高,依赖CSS支持度 生成速度 快,纯前端 快/中,视文档长度 公式/复杂表格支持 好,整块贴图 一般,需逐元素处理 多页长文档稳定性 差,容易卡顿 好 结论很清楚:普通页面导出,优先用后端无头浏览器或浏览器打印生成文本型PDF;如果项目没有后端资源,才退而求其次用图片型PDF。图片型PDF虽然实现直接,但交付体验和文件体积都不算理想。
5. 分页控制与样式还原的进阶处理
5.1 分页控制的CSS技巧与实现
无论走哪条PDF生成路线,分页控制都是最影响体验的点。CSS里真正的核心属性是这三个:
break-inside: avoid:防止元素内部被拆到两页。page-break-before: always:在指定元素前强制分页。page-break-after: always:在指定元素后强制分页。
实操中我会给这几类元素统一加保护:
/* 表格、图片、代码块、引用块不允许内部断页 */ table, pre, blockquote, .image-wrapper { break-inside: avoid; page-break-inside: avoid; } /* 标题强制与下一段内容同页 */ h1, h2, h3 { page-break-after: avoid; break-after: avoid; }但要注意,
break-inside: avoid在Chrome打印和Puppeteer里支持良好,在html2canvas方案里完全无效,因为它没有经过浏览器排版引擎的分页算法。如果有人用html2canvas做PDF,遇到表格被切断,只能靠JS检测元素位置后手动偏移切割点,没有CSS捷径。5.2 超长文档的分片渲染策略
富文本编辑器导出的长文档,DOM节点往往几千个起。做图片型PDF时,一次性渲染整个文档几百兆内存都很正常。我用过一个改进策略:分段渲染、逐段拼接。把整页内容按可见区域进行切分,一次只把视口附近的部分绘制到Canvas,然后拼接成完整的可滚动截图。或者更省内存的版本:逐步取内容节点切片生成每一页的局部Canvas,不再产出一整张大图。
这个方案能让内存峰值从“整页高度”降为“单页高度”,但要处理滚动位置复位和延迟加载图片的问题。实现较麻烦,不过对长合同、长报告的导出非常有效。
我踩过的一个典型事故是:某长文档导出时用户浏览器标签页直接崩溃,排查下来就是Canvas尺寸超出了浏览器单画布的最大尺寸限制(通常是32767px)。所以超过一定长度的文档,不能直接用一个Canvas包完,必须分页切片。对文本型PDF(无头浏览器)则没有这个问题。
5.3 中文字体和乱码问题排查
PDF导出最诡异的Bug就是“导出的是乱码,但页面显示正常”。这类问题根源基本都在字体上。比如html2canvas方案,如果Canvas绘制时使用了操作系统没安装的字体,绘制出来的文字就会显示成豆腐块或乱码。后端无头浏览器方案也是同理,Linux容器缺中文字体是重灾区。
解决思路按优先级排列:
- 在目标环境安装常用中文字体(比如思源黑体/宋体)。
- 在页面CSS中显式指定字体栈,给无头浏览器加载WebFont。
- 后端渲染时把字体文件注册到页面里,使用
@font-face。
还有一个细微坑:微软雅黑自带版权限制,某些自动化环境无法安装。如果项目要走Linux容器,建议部署前确认字体文件来源是否合规,最好用思源系这类开源授权字体。
6. 常见问题排查与避坑实录
6.1 典型问题速查表
现象 直接原因 解决方案 HTML中图片无法显示 相对路径失效 导出前统一转为绝对路径或base64 图片跨域导致canvas空白 图片服务没有CORS头 后端代理图片或配置CORS PDF文字太小,模糊 Canvas分辨率不足 设置 scale: 2及以上表格在PDF中被切断 分页逻辑没有避让块级元素 加强 break-inside: avoid,或JS动态切页打印导出无背景色 未开启打印背景选项 设置 printBackground: true中文PDF是方块 目标环境缺字体 安装中文字体或加载WebFont 超长文档直接崩溃 Canvas超出浏览器尺寸限制 改为分段渲染 导出HTML包含编辑器专属标签 直接拷贝了内部DOM 先经过清洗序列化 6.2 我踩过最深的坑:图片base64膨胀
有个项目早期为了“独立可用”,把所有图片都转成base64内联。结果一个带十张高清图的文档,HTML文件直接变成几十MB,PDF生成时浏览器内存直接失控。
后来我加了一条规则:HTML导出保留原图URL或相对路径,PDF导出才强制内联图片。因为HTML页面是给人继续编辑或嵌入网页用的,保留URL更灵活;而PDF是最终交付物,必须所见即所得,图片不能依赖外部地址。
这一条规则帮我们后续减少了很多麻烦。如果你也在做类似功能,一开始就要想清楚输出文件的“独立程度”,不要一股脑全内联。
6.3 兼容性坑:不同浏览器打印效果偏差
做打印导出方案时,Chrome和火狐的打印表现各不相同。实际问题中最常见的是:
- 火狐对
page-break-inside: avoid支持较好,但部分老版本对CSS Grid布局打印有bug。 - Chrome打印时默认会缩放网页内容以匹配纸张,如果CSS的宽度用像素写死,A4纸就会莫名其妙多出白边。
- Safari打印时对背景图的打印支持较差,默认不打印背景。
这部分的应对没有银弹,只能靠“预览前自动探测浏览器版本,分别调参数”。如果对一致性要求极高,后端无头浏览器方案反而是最可控的,因为它内置Chromium,行为一致。
6.4 文件命名与页面元信息
最后分享一个体验细节:导出的文件名不要太随便。比如统一规则:
{业务类型}_{日期}_{时间戳}.pdf,这样用户归档时能直接识别。HTML文件的<title>不能是空或者默认“无标题文档”,而是要从编辑器内容里提取第一行标题填入,对后续SEO和网页展示也友好。如果是给内部系统用,还可以在PDF的页脚加页码和系统名称,无头浏览器方案里通过footerTemplate就能实现,这样交付出去的文档更规范,用户才会觉得这是“正式导出”,而不是临时截图。
6.5 复盘:一个相对顺手的整体流程
把上面所有环节串起来,我在项目中最终沉淀的导出流程是这样:
- 编辑器层:拿到内部数据,用编辑器自带能力转换为标准HTML字符串。
- 中间层:DOM清洗、样式内联、图片地址规范化。
- 交付A:把HTML包装成独立页面,触发下载,这步几乎是瞬时的。
- 交付B:把同一份HTML交给打印方案或无头浏览器后端,生成PDF。
- 事后层:打点记录导出日志,观测哪个环节耗时最长、是否频繁出现资源加载失败。
这套流程的好处是所有环节都复用同一份“洗干净”的HTML,前端出图和后端出图不会各自为政,逻辑统一,排查问题也只用看中间层输出的HTML长什么样。
我个人在实际操作中的体会是:导出功能先别急着上复杂方案,先把编辑器内容到干净HTML这一步做好,就解决了80%的样式问题。PDF的分页和字体问题,靠分步调试逐层解决。这套流程我用了很久,稳定性和可维护性都不错,推荐给要做类似功能的朋友。