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