纯前端 Markdown 转 PDF:从 html2pdf.js 到浏览器原生打印的技术演进
在浏览器里把 Markdown 转成 PDF,听起来简单,做起来全是坑。本文记录 md-to.com 在这条路上的技术选型、方案演进和实现细节——从最初使用 html2pdf.js 到最终迁移至浏览器原生打印,踩过的坑和得到的经验。
前端 PDF 生成的三条路
在纯前端(无服务器)的约束下,生成 PDF 主要有三种思路:
| 方案 | 原理 | 代表库 |
|---|---|---|
| 服务端渲染 | 在 Node.js 中启动无头浏览器渲染 HTML,导出 PDF | Puppeteer、Playwright、wkhtmltopdf |
| 客户端 JS 生成 | 在浏览器中将 HTML 渲染为 Canvas,再转成 PDF | html2pdf.js、jsPDF + html2canvas |
| 浏览器原生打印 | 调用 window.print(),用户在打印对话框中选择”另存为 PDF” | 无需第三方库 |
md-to.com 是一个纯静态站点,所有转换都在浏览器本地完成,不依赖后端服务。因此服务端方案直接排除,选择在 html2pdf.js 和浏览器原生打印之间做决策。
第一版:html2pdf.js 的尝试与放弃
初始方案
最初采用 html2pdf.js,它的工作流程是:
- 将 HTML 元素交给 html2canvas 渲染为 Canvas 位图
- 用 jsPDF 将 Canvas 切片为多页 PDF
- 触发浏览器下载
核心配置如下:
const opt = {
margin: [0.5, 0.5],
filename: pdfFilename,
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2, useCORS: true, logging: false },
jsPDF: { unit: 'in', format: 'letter', orientation: 'portrait' },
pagebreak: {
mode: ['css', 'legacy'],
avoid: ['pre', 'code', 'blockquote', 'img', '.katex-display',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
},
};
await html2pdf().set(opt).from(wrapper).save();
pagebreak.avoid 配置告诉 html2pdf.js 避免在这些元素中间分页——理论上可以解决截断问题。
遇到的问题
实际使用中暴露了几个致命问题:
1. 位图输出,无法选中文字
html2pdf.js 的本质是”HTML → Canvas 截图 → 拼成 PDF”,输出的每一页都是一张 JPEG 图片。PDF 中的文字无法选中、无法搜索,超链接也全部丢失。对于技术文档来说这完全不可接受。
2. 分页截断难以根治
虽然 pagebreak.avoid 能处理简单场景,但当代码块超过一页高度、或者嵌套列表跨页时,html2pdf.js 的分页算法会在元素中间硬切——因为它是基于像素高度计算的,不像浏览器排版引擎那样理解内容结构。
3. 性能和内存问题
html2canvas 需要将整个 DOM 树重新渲染为 Canvas,对于长文档(10+ 页)会消耗大量内存,在移动端甚至可能导致页面崩溃。
第二版:浏览器原生打印方案
为什么选择原生打印
浏览器的打印功能调用的是操作系统的排版引擎,生成的 PDF 是矢量的——文字可选中、可搜索,超链接可点击,文件体积更小。
| 维度 | html2pdf.js | 浏览器原生打印 |
|---|---|---|
| 输出格式 | 位图(Canvas 截图) | 矢量(文字可选中/搜索) |
| 分页控制 | JS 像素计算,容易截断 | CSS page-break-*,浏览器内核处理 |
| 超链接 | 丢失 | 保留 |
| 性能 | 大量内存渲染 Canvas | 调用系统打印内核,极快 |
| 维护性 | 比较麻烦 | 浏览器原生能力,永不过时 |
唯一的”缺点”是用户需要在打印对话框中手动选择”另存为 PDF”——这需要在 UI 上做好引导。
核心架构:Overlay + IFrame
整体架构分为三层:
┌──────────────────────────────────┐
│ Toolbar(工具栏) │ ← 打印按钮、取消按钮
├──────────────────────────────────┤
│ │
│ ┌────────────────────┐ │
│ │ │ │
│ │ IFrame(预览区) │ │ ← 注入带样式的 HTML
│ │ width: 8.5in │ │
│ │ │ │
│ └────────────────────┘ │
│ │
│ Overlay(全屏遮罩) │ ← z-index: 70
└──────────────────────────────────┘
流程
- 用户点击”下载 PDF”按钮
- 调用
showPdfOverlay(),创建全屏 overlay - 动态导入模板系统,获取当前激活的模板
- 调用
generatePrintStyles(template)生成完整的打印样式 - 创建 iframe,将样式 + HTML 内容写入 iframe
- 用户在 overlay 中预览效果,点击”打印/另存为 PDF”
- 调用
iframe.contentWindow.print()触发系统打印对话框
为什么用 IFrame
直接在当前页面调用 window.print() 会打印整个页面(包括编辑器、侧边栏等 UI)。使用 iframe 可以:
- 隔离打印内容,只打印 Markdown 渲染结果
- 注入独立的打印样式,不影响主页面
- 提供 WYSIWYG 预览——iframe 中看到什么,打印出来就是什么
关键实现:
const styles = generatePrintStyles(template);
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (doc) {
doc.open();
doc.write(`
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Document</title>
${styles}
</head>
<body>
${currentHtml}
</body>
</html>
`);
doc.close();
}
iframe 宽度设为 8.5in(Letter 纸张宽度),高度根据内容动态计算:
setTimeout(() => {
const bodyHeight = doc.body.scrollHeight;
iframe.style.height = Math.max(bodyHeight + 40, 800) + 'px';
}, 100);
CSS 分页策略详解
原生打印方案的核心在于 CSS。@media print 规则让浏览器排版引擎在分页时遵循我们的意图。
防止内容截断
@media print {
tr, pre, blockquote, img, .katex-display {
page-break-inside: avoid;
break-inside: avoid;
}
}
同时使用 page-break-inside(旧语法)和 break-inside(新语法)确保最大兼容性。覆盖的元素包括:
pre:代码块blockquote:引用块img:图片.katex-display:数学公式块tr:表格行
表格跨页但表头重复
@media print {
table {
page-break-inside: auto;
}
thead {
display: table-header-group;
}
}
这是一个巧妙的组合:table 允许跨页(因为表格可能很长),但 thead 设为 table-header-group 让浏览器在每一页的表格顶部重复显示表头。在阅读长表格时非常有用——不用翻回第一页看列名。
标题不孤立在页末
@media print {
h1, h2, h3, h4, h5, h6 {
page-break-after: avoid;
break-after: avoid;
}
}
如果一个标题出现在页面底部、而正文在下一页开头,阅读体验很差。page-break-after: avoid 告诉浏览器:标题后面不要分页,把标题和后续内容放在同一页。
保留背景色
@media print {
body {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
浏览器默认不打印背景色(为了省墨水)。这两行 CSS 强制保留背景色——对代码块的底色、表格的斑马纹、引用块的背景色至关重要。不过用户仍需在打印对话框中勾选”背景图形”才能生效。
模板系统与动态样式生成
md-to.com 提供 20+ 文档模板,每个模板定义了完整的视觉参数:
interface DocumentTemplate {
fonts: { body: string; heading: string; code: string };
fontSizes: { body: number; h1: number; h2: number; /* ... */ code: number };
colors: {
text: string; heading: string; link: string;
codeBackground: string; codeText: string; codeBorder: string;
quoteBorder: string; quoteBackground: string; quoteText: string;
tableBorder: string; tableHeaderBg: string; tableHeaderText: string;
tableRowOdd: string; tableRowEven: string;
};
spacing: {
lineHeight: number;
headingBefore: number; headingAfter: number;
paragraphBefore: number; paragraphAfter: number;
};
}
generatePrintStyles(template) 函数将模板参数注入 CSS 模板字符串,生成完整的 <style> 标签。这意味着同一份 Markdown 内容,切换模板后打印出来的 PDF 风格完全不同——字体、配色、间距全部跟随模板变化。
代码高亮样式(highlight.js)也被内联到生成的 CSS 中,确保 PDF 中的代码块保留语法着色。
智能文件名生成
下载的 PDF 文件名不是千篇一律的 download.pdf。getDownloadFileName() 会从 Markdown 源文本中提取第一个标题:
export function getDownloadFileName(
mdText: string,
extension: string,
fallbackPrefix: string = 'markdown-export',
): string {
const lines = mdText.split(/\r?\n/);
const headingLine = lines.find((line) => /^\s{0,3}#{1,6}\s+/.test(line));
if (headingLine) {
const raw = headingLine
.replace(/^\s{0,3}#{1,6}\s+/, '')
.replace(/\s+#*\s*$/, '');
const cleaned = sanitizeFilename(raw);
if (cleaned) return `${cleaned}.${extension}`;
}
return `${fallbackPrefix}-${getDateStamp()}.${extension}`;
}
处理逻辑:
- 按行拆分 Markdown 文本,找到第一个匹配
# 标题格式的行 - 去掉
#前缀和尾部的#装饰 - 通过
sanitizeFilename()清理非法字符(\ / : * ? " < > |)、规范化空格、截断至 80 字符 - 如果没有找到标题,使用
markdown-to-pdf-20260311.pdf格式的 fallback
例如,文档以 # 项目技术方案 开头,下载的文件名就是 项目技术方案.pdf。
踩过的坑
1. html2pdf.js 按钮状态 Bug
html2pdf.js 下载完成后,按钮的 disabled 状态没有重置为 false。用户第一次下载成功后,按钮变灰无法再次点击。这是因为成功路径里只恢复了按钮文字,没有恢复 disabled 属性:
// 成功路径——缺少 btnDownloadPdf.disabled = false
await html2pdf().set(opt).from(wrapper).save();
showToast(texts.downloadStarted);
btnDownloadPdf.innerText = texts.downloadPdf;
而失败路径正确处理了:
// 失败路径——正确恢复
btnDownloadPdf.disabled = false;
btnDownloadPdf.innerText = texts.downloadPdf;
2. innerHTML 引发 XSS 风险
早期版本使用 tipText.innerHTML = texts.tip 注入提示文本。虽然 texts.tip 来自国际化配置而非用户输入,但 innerHTML 本身就是一个危险的 API。代码审查后改为 textContent:
// Before: tipText.innerHTML = texts.tip;
tipText.textContent = texts.tip;
3. z-index 滥用
最初 overlay 的 z-index 设为 99999,toolbar 设为 100000。这种”大力出奇迹”的做法在复杂页面中容易和其他组件冲突。优化后改为语义化的层级——overlay 使用 z-index: 70,toolbar 通过 position: relative 自然叠在 overlay 内容之上,不再需要独立的 z-index。
4. 渐进式迁移策略
迁移到原生打印后,没有删除 html2pdf.js 相关代码,而是通过注释隐藏按钮:
// toolbar.appendChild(btnDownloadPdf); 下载功能有Bug,按钮暂时不显示,请勿删除这一行
toolbar.appendChild(btnPrint);
html2pdf.js 的下载逻辑、配置、事件处理全部保留,只是不展示入口。这样做的好处是:如果未来需要提供”一键下载”(无需用户在打印对话框中操作),可以快速恢复。
给用户的打印设置引导
浏览器原生打印方案的用户体验取决于打印对话框的设置。需要在 UI 中明确引导用户:
- 目标打印机:选择”另存为 PDF”(或 “Save as PDF”)
- 背景图形:务必勾选——否则代码块底色、表格斑马纹、引用块背景色全部丢失
- 页眉和页脚:取消勾选——去掉 PDF 顶部多余的 URL 和日期信息
- 纸张大小:推荐 A4(国内常用)或 Letter(北美常用)
- 页边距:建议选”默认”——CSS 中已经定义了
padding: 20mm
总结
回顾整个技术演进:
- html2pdf.js 解决了”前端生成 PDF”的问题,但输出质量(位图、截断、无法搜索)不达标
- 浏览器原生打印 利用操作系统的排版引擎,生成矢量 PDF,分页由 CSS 控制,输出质量显著提升
- Overlay + IFrame 架构 隔离了打印内容和页面 UI,提供所见即所得的预览体验
- CSS 分页规则 是方案的核心——
page-break-inside: avoid、table-header-group、page-break-after: avoid三板斧解决了代码截断、表头丢失、标题孤立三大痛点
如果你也在做类似的前端 PDF 导出功能,建议优先考虑浏览器原生打印。它不需要任何第三方依赖,输出质量最好,而且永远不会过时——因为打印是浏览器的基础能力。
相关链接: