問題現場:想做一份「能印」的網頁報表
需求很常見:後台要一頁會計報表(明細列表 + 合計列),會計同事要能直接從瀏覽器按列印、出 A4。沒人想為這種小需求引入 PDF library 或產 PDF 的後端服務 —— 瀏覽器列印就好。
寫下去才發現坑滿地:頁碼從哪來?跨頁時表頭會不會消失?頁面外面那層 admin sidebar、左側選單會不會也跟著被印出來?螢幕上預覽是黑背景白字,印到紙上會變什麼樣?
這些問題的答案幾乎都不在「常見 CSS 教學」裡,而是藏在一份很少人讀的 W3C 規範:CSS Paged Media Module。本文用一份實際做出來的會計報表為例,把這些技巧串起來。
為什麼不選 PDF library?
開發者第一個反射動作往往是「裝個 jsPDF / pdfmake 直接產 PDF」。在跳進那條路之前,先看清各方案的代價:
| 方案 | 中文字型 | 排版精度 | Bundle | 適用情境 |
|---|---|---|---|---|
CSS @page(本文) | ✅ 系統字型 | 高(向量字) | 0 KB | 後台報表、現場列印、人工流程 |
| html2canvas + jsPDF | ⚠️ 需嵌字型 | 中(光柵化) | ~300 KB | 簡單表單下載 |
| pdfmake / React-PDF(向量 PDF) | ⚠️ 需嵌字型 | 高 | ~500 KB+ | 給外部讀者的下載檔 |
| 後端 Puppeteer / WeasyPrint | ✅ 系統字型 | 最高 | 0(前端) | 批次寄信、加密簽章 |
前端 PDF library 最大的痛點是中文字型。系統列印免費借用作業系統字型;jsPDF 預設只內建 Helvetica,要中文不亂碼就得把整套思源黑體(壓縮後仍有 5-10 MB)打包進 bundle,否則出現方塊或缺字。
第二個雷是 html2canvas 路線的光柵化失真:先把 DOM 拍成 PNG 再塞進 PDF,文字變圖片、選不到、搜尋不到、放大就糊。對會計這種要逐筆比對的場景特別痛。
那 PDF library 什麼時候該用? 要產符合 PDF/A 規範的電子發票、要程式化簽章、要後端批次產月結對帳寄信。這幾類本來就不該由瀏覽器負責——應該交給後端 Puppeteer 或 WeasyPrint headless render,避免每個前端使用者各自承擔。
「現場列印」型需求就用 CSS,下載式或自動化才需要 PDF library。本文剩下的篇幅都在講前者。
心智模型:列印是一種「媒介」
CSS 從一開始就把「螢幕」和「紙張」當成不同的 media。比較多人寫過的是 @media print { ... }——把螢幕版的樣式切換成適合列印的版本(隱藏側邊欄、改字色、調間距)。但 @media print 只能管「規則切換」,它管不到紙張本身:邊界多寬、頁碼放哪、跨頁怎麼排,都不在它的權限內。
真正控制紙張版面的工具是 @page:
@page {
size: A4 portrait;
margin: 18mm 14mm 22mm 14mm;
}
這段程式碼宣告:紙張 A4 直印、四周邊界各留多少 mm。注意單位用 mm 不是 px,因為紙是真實的物理介質,px 的概念不存在。
@page 還有一個鮮為人知的能力:margin box。每一頁邊界外圍可以塞內容(頁碼、頁首、浮水印),這正是頁碼魔法的來源。
頁碼:@page margin box + counter
不要用 JavaScript 算頁碼,瀏覽器列印引擎可以直接幫你做:
@page {
size: A4 portrait;
margin: 18mm 14mm 22mm 14mm;
@bottom-right {
content: "第 " counter(page) " 頁 / 共 " counter(pages) " 頁";
font-size: 9pt;
color: #555;
}
}
counter(page) 是當前頁碼、counter(pages) 是總頁數,由分頁引擎在版面計算完成後注入。
為什麼下緣 margin 要 22mm 不是 18mm? margin box 內容是擠進 margin 區域裡的,太窄會被切。Chrome / Edge 的 Blink 引擎完整支援這個語法;Firefox 的 margin box 支援度差一截,如果你的使用者主要在 Firefox,可能要 fallback 到 JS 計算或乾脆放棄頁碼。對大多數企業內部後台(Chrome 為主),這幾行就解決了。
跨頁表頭:<thead> 的隱藏天賦
報表跨多頁時,每一頁要重複「報表標題 + 期間 + 欄位列」 —— 不然第二頁打開沒前後文,誰看得懂哪欄是什麼。
很多人第一次寫會把標題和期間放在 <table> 外面的 <div>、再單獨給表格 <thead>。結果列印時:表頭跨頁重複、但標題和期間只出現第一頁。
關鍵:所有要跨頁重複的東西,都得在 <thead> 裡面。
<table>
<thead>
<!-- 第一列:報表標題 + 期間(colSpan 跨滿全部欄位)-->
<tr>
<th colSpan="6">
<div class="report-title">會計報表</div>
<div class="report-meta">
期間:2026-01-01 ~ 2026-04-30 | 產生時間:2026-05-06
</div>
</th>
</tr>
<!-- 第二列:欄位標題 -->
<tr>
<th>訂單編號</th>
<th>成立日期</th>
<th>金額</th>
<th>付款狀態</th>
<th>發票號碼</th>
<th>發票日期</th>
</tr>
</thead>
<tbody>...</tbody>
<tfoot>
<tr><td colSpan="6">合計:N 筆 / 總金額 NT$ X</td></tr>
</tfoot>
</table>
@media print {
thead { display: table-header-group; }
tfoot { display: table-footer-group; }
tr { page-break-inside: avoid; }
}
display: table-header-group 是 thead 的預設值,多數情況不寫也行 —— 但某些 CSS reset(例如 Bootstrap、Strapi admin、Tailwind 預設 preflight)會把 table 結構打亂,明確寫出來保險。page-break-inside: avoid 防止單一資料列被切到兩頁中間。
列印隔離:visibility: hidden 的經典手法
這是最容易踩、最隱形的坑:你的列印頁不可能存在於真空,它一定包在某個應用框架(admin、Dashboard、SPA)裡。如果直接觸發 window.print(),整個 admin shell(左側 sidebar、上方 header、下方 footer)也會被印出來。
解法是個古老但仍然最乾淨的 pattern:
@media print {
/* 1. 把所有東西藏起來 */
body * {
visibility: hidden;
}
/* 2. 只露出列印容器 */
.acct-print,
.acct-print * {
visibility: visible;
}
/* 3. 把列印容器拉到頁面左上角,不要被原本的 layout 卡住 */
.acct-print {
position: absolute;
left: 0;
top: 0;
width: 100%;
}
}
為什麼用 visibility: hidden 不用 display: none?
| 屬性 | 視覺 | 佔位 | 子孫元素能否覆寫 |
|---|---|---|---|
display: none | 不顯示 | 不佔空間(layout 重排) | ❌ 子孫一起消失,無法救回 |
visibility: hidden | 不顯示 | 保留空間 | ✅ 子孫可單獨改回 visible |
我們需要「父元素藏、子元素露」這種挑出特定子樹的能力 —— display: none 會把整棵樹砍掉、子代沒救。visibility: hidden 才能搭配後面的 visibility: visible 把目標子樹挖出來。
Screen vs Print 雙模式:一個容器兩種命
開新分頁顯示列印頁時,使用者會在螢幕上先看到那一頁(瀏覽器列印對話框是 modal,後面就是這個 preview 畫面)。如果你的 admin 是深色主題,列印容器會繼承深色文字 —— 但 CSS 給偶數列加了白底斑馬條紋 —— 白底白字,看不見任何東西。
實戰修法:在容器裡強制反向配色,蓋掉 admin theme:
.acct-print {
background: #fff;
color: #000;
min-height: 100vh;
}
/* 連同所有後代,全部黑字 */
.acct-print, .acct-print * {
color: #000 !important;
}
.acct-print tbody tr:nth-child(even) {
background: #f7f7f7;
}
!important 在這裡是必要之惡 —— 你不知道 admin theme 用了多少個 selector 強加白色文字,與其逐一 override,不如用最大的權重把這條子樹整個切割出來。
觸發列印 + 資料傳遞
最後一塊拼圖:怎麼把列表頁的資料送到列印頁?兩條路:
// 列表頁:把資料塞進 localStorage 用一次性 key
const key = crypto.randomUUID()
localStorage.setItem(`accounting:print:${key}`, JSON.stringify(payload))
window.open(`/admin/accounting/print?key=${key}`, '_blank')
// 列印頁:讀完立刻清掉
const raw = localStorage.getItem(`accounting:print:${key}`)
localStorage.removeItem(`accounting:print:${key}`) // 先清再用
const payload = JSON.parse(raw)
// 等 DOM 渲染完再觸發列印
useEffect(() => {
setTimeout(() => window.print(), 300)
}, [payload])
為什麼不用 query string? 報表幾千筆資料 URL 放不下、瀏覽器網址列也會爆。為什麼是 localStorage 而不是 sessionStorage? window.open 開新分頁時 sessionStorage 行為依賴 noopener flag —— 同源不帶 noopener 時新分頁會「複製」一份,但不是同步更新;要避這個邊角,localStorage 是更穩的選擇,反正讀完就刪。
結語
可列印的網頁報表不需要 PDF library、不需要後端產檔,純 CSS 就能做出產品級體驗。關鍵是把 CSS Paged Media Module 的四個工具串起來:@page margin box 寫頁碼、<thead> 跨頁重複報表標題、visibility: hidden 隔離 admin shell、color: #000 !important 處理深色主題下的螢幕預覽。下次再有「能不能直接列印」的需求,這套組合拳很夠用。
