問題現場:想做一份「能印」的網頁報表

需求很常見:後台要一頁會計報表(明細列表 + 合計列),會計同事要能直接從瀏覽器按列印、出 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 處理深色主題下的螢幕預覽。下次再有「能不能直接列印」的需求,這套組合拳很夠用。