返回博客

纯前端 Markdown 转 PDF:从 html2pdf.js 到浏览器原生打印的技术演进

MD-TO 团队

在浏览器里把 Markdown 转成 PDF,听起来简单,做起来全是坑。本文记录 md-to.com 在这条路上的技术选型、方案演进和实现细节——从最初使用 html2pdf.js 到最终迁移至浏览器原生打印,踩过的坑和得到的经验。


前端 PDF 生成的三条路

在纯前端(无服务器)的约束下,生成 PDF 主要有三种思路:

方案原理代表库
服务端渲染在 Node.js 中启动无头浏览器渲染 HTML,导出 PDFPuppeteer、Playwright、wkhtmltopdf
客户端 JS 生成在浏览器中将 HTML 渲染为 Canvas,再转成 PDFhtml2pdf.js、jsPDF + html2canvas
浏览器原生打印调用 window.print(),用户在打印对话框中选择”另存为 PDF”无需第三方库

md-to.com 是一个纯静态站点,所有转换都在浏览器本地完成,不依赖后端服务。因此服务端方案直接排除,选择在 html2pdf.js 和浏览器原生打印之间做决策。


第一版:html2pdf.js 的尝试与放弃

初始方案

最初采用 html2pdf.js,它的工作流程是:

  1. 将 HTML 元素交给 html2canvas 渲染为 Canvas 位图
  2. 用 jsPDF 将 Canvas 切片为多页 PDF
  3. 触发浏览器下载

核心配置如下:

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
└──────────────────────────────────┘

流程

  1. 用户点击”下载 PDF”按钮
  2. 调用 showPdfOverlay(),创建全屏 overlay
  3. 动态导入模板系统,获取当前激活的模板
  4. 调用 generatePrintStyles(template) 生成完整的打印样式
  5. 创建 iframe,将样式 + HTML 内容写入 iframe
  6. 用户在 overlay 中预览效果,点击”打印/另存为 PDF”
  7. 调用 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.pdfgetDownloadFileName() 会从 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}`;
}

处理逻辑:

  1. 按行拆分 Markdown 文本,找到第一个匹配 # 标题 格式的行
  2. 去掉 # 前缀和尾部的 # 装饰
  3. 通过 sanitizeFilename() 清理非法字符(\ / : * ? " < > |)、规范化空格、截断至 80 字符
  4. 如果没有找到标题,使用 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 中明确引导用户:

  1. 目标打印机:选择”另存为 PDF”(或 “Save as PDF”)
  2. 背景图形:务必勾选——否则代码块底色、表格斑马纹、引用块背景色全部丢失
  3. 页眉和页脚:取消勾选——去掉 PDF 顶部多余的 URL 和日期信息
  4. 纸张大小:推荐 A4(国内常用)或 Letter(北美常用)
  5. 页边距:建议选”默认”——CSS 中已经定义了 padding: 20mm

总结

回顾整个技术演进:

  • html2pdf.js 解决了”前端生成 PDF”的问题,但输出质量(位图、截断、无法搜索)不达标
  • 浏览器原生打印 利用操作系统的排版引擎,生成矢量 PDF,分页由 CSS 控制,输出质量显著提升
  • Overlay + IFrame 架构 隔离了打印内容和页面 UI,提供所见即所得的预览体验
  • CSS 分页规则 是方案的核心——page-break-inside: avoidtable-header-grouppage-break-after: avoid 三板斧解决了代码截断、表头丢失、标题孤立三大痛点

如果你也在做类似的前端 PDF 导出功能,建议优先考虑浏览器原生打印。它不需要任何第三方依赖,输出质量最好,而且永远不会过时——因为打印是浏览器的基础能力。


相关链接