返回部落格

純前端 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 匯出功能,建議優先考慮瀏覽器原生列印。它不需要任何第三方相依套件,輸出品質最好,而且永遠不會過時——因為列印是瀏覽器的基礎能力。


相關連結