CSS Print 排版指南:Legacy PHP 列印表單不再跑版的實戰經驗


Legacy PHP 系統的「列印」功能,是一個看起來簡單、實際上極其複雜的領域。因為你不只在跟 CSS 打架,還在跟瀏覽器列印引擎、伺服器端 curl、GD 圖片庫、記憶體限制同時打架。

這篇整理了從一個超過 10 年的 PHP 專案中累積的列印踩坑經驗,涵蓋 CSS @media print、批次列印架構、雙面列印、字體渲染等主題。


架構概覽:Legacy PHP 怎麼做列印

在講個別問題之前,先了解整體架構。系統中的列印有兩種模式:

單頁列印

使用者按「列印」,瀏覽器開一個新視窗載入列印版 PHP 頁面,然後 window.print()。靠 CSS @media print 控制排版。

批次列印

一次列印幾十甚至上百個個案的表單。做法是伺服器端用 curl 逐一抓取每個表單的 HTML,拼接成一個大頁面:

// 批次列印的核心迴圈
foreach ($patients as $patient) {
    $url = "http://localhost/module_a/print/form_assess.php"
         . "?id={$patient['id']}&from_batch=1";
    
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_COOKIE, "PHPSESSID=" . session_id());
    $html .= curl_exec($ch);
    curl_close($ch);
}

echo $html; // 輸出整個拼接後的 HTML

這個架構本身就是坑的來源。


坑一:curl 列印 Session 失效——打到外部網址

問題

批次列印突然全部空白。沒有錯誤訊息,curl 回傳的是登入頁面的 HTML。

根因

curl 的目標 URL 用了外部網址:

// ❌ 用外部網址,session 可能無效
$path = "https://{$_SERVER['HTTP_HOST']}/module_a/print/...";

問題是 curl 帶的 PHPSESSID 是當前使用者的 session ID。當請求走外部網址再進來時,nginx 可能把請求導到不同的 PHP-FPM worker,session 檔案不一定存在。

修復

// ✅ 直接走 localhost,確保同一台機器
$path = "http://localhost/module_a/print/...";
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);

教訓:批次列印的 curl 請求一定要走 localhost,不要走外部域名。CURLOPT_FOLLOWLOCATION 也要開,以防 redirect。


坑二:雙面列印的空白頁邏輯

問題

使用者要求雙面列印(duplex)。規則是:每個個案必須從紙張正面開始。如果前一個個案的表單是奇數頁,需要插入一頁空白頁讓下一個個案從正面開始。

聽起來簡單,但有三個邊界條件:

  1. 怎麼知道某個表單渲染出幾頁?
  2. 最後一個個案要不要加空白頁?
  3. 一個個案有多張表單,怎麼判斷「最後一張表單」?

第一版:CSS page-break-after: always

/* ❌ 每個表單後面都加分頁 */
.form-container {
    page-break-after: always;
}

問題:最後一個表單後面也會多一頁空白,造成每次列印多出一張白紙。

第二版:判斷「最後一張」

// ✅ 用 $is_last 控制
$isLastPatient = ($currentPatient === $totalPatients);
$isLastForm = ($currentForm === $totalForms);
$isLast = $isLastPatient && $isLastForm;

if (!$isLast) {
    echo '<div style="page-break-after: always; height: 0; overflow: hidden;"></div>';
}

height: 0; overflow: hidden 是關鍵——只要分頁效果,不要可見內容。

第三版:估算頁數決定是否補空白頁

某些表單的頁數不固定(取決於資料量)。需要在 JavaScript 端估算:

// ✅ 根據行數估算頁數
function estimatePages(container) {
    var rows = container.querySelectorAll('tr').length;
    var firstPageRows = 40;  // 第一頁(含表頭)約 40 行
    var nextPageRows = 45;   // 後續頁約 45 行
    
    if (rows <= firstPageRows) return 1;
    return 1 + Math.ceil((rows - firstPageRows) / nextPageRows);
}

// 奇數頁且不是最後一人,補空白頁
if (pageCount % 2 === 1 && !isLastPatient) {
    var blankPage = document.createElement('div');
    blankPage.style.pageBreakBefore = 'always';
    blankPage.innerHTML = '&nbsp;';
    container.appendChild(blankPage);
}

第三版的 Bug 修正

一開始,最後一個個案不補空白頁。但使用者回報:「最後一個人的表單如果是奇數頁,下一次列印的第一張會印在背面。」

所以改成:每個個案都要確保是偶數頁,包括最後一個。

// ✅ 最終版:所有個案都補,不只非最後一個
if ($isPreviousPatientOddPages) {
    echo '<div style="page-break-before: always;">&nbsp;</div>';
}

坑三:GD 字體渲染——# 符號變成方框

問題

系統用 PHP GD 函式庫在人體圖上標記傷口位置。標記使用 # 符號加上編號(如 #1#2)。某天使用者回報:標記全部變成方框 □1□2

根因

原本使用的字體是 FreeSansBold.ttf。這個字體在 ASCII 範圍內沒問題,但系統後來加了其他 CJK 字元的標記需求。更新環境後,GD 渲染 # 時使用了字體中不存在的 glyph,顯示為方框。

// ❌ FreeSans 對某些符號支援不完整
imagettftext($img, 12, 0, $x, $y, $color, 'fonts/FreeSansBold.ttf', "#$number");

修復

// ✅ 換成 Noto Sans TC,支援 CJK + 完整 ASCII
imagettftext($img, 12, 0, $x, $y, $color, 'fonts/NotoSansTC-VariableFont_wght.ttf', "#$number");

這個 Bug 的演進過程(5 個版本)

這個看似簡單的修復實際上經歷了五個版本:

  1. v2:修正列印和 WORD 匯出中的傷口標記邏輯
  2. v3:WORD 匯出換 GD 圖片有問題,退回無標記版本
  3. v3 被 revert:決定還是要標記
  4. v4:修改生圖失敗時的預設圖片處理
  5. v5:終於找到根因——換字體

教訓:GD 渲染問題很難在開發環境重現,因為開發機和正式機的字體環境不同。


坑四:批次列印記憶體爆炸

問題

列印 80 個個案 × 每人 5 張表單 = 400 頁 HTML。PHP 記憶體直接爆掉。

修復策略

Claude Code 建議了三個方向:

1. 跳過不需要的資料載入

// ✅ 批次列印時跳過完整資料載入
if (isset($_GET['skip_getdata'])) {
    $patient_arrayinfo = [];
    $bedinfo_arrayinfo = [];
} else {
    include 'getData.php'; // 正常頁面才載入
}

2. 提高限制 + 垃圾回收

ini_set('memory_limit', '512M');
set_time_limit(600);

// 每處理完一個個案就回收
foreach ($patients as $patient) {
    $html .= fetchFormHtml($patient);
    gc_collect_cycles();
}

3. DB 連線重試機制

批次列印耗時長,DB 連線可能中途斷線:

function ensureDBConnection($db) {
    // 用 Reflection 檢查 PDO 連線狀態
    try {
        $db->query("SELECT 1");
    } catch (Exception $e) {
        // 重試最多 5 次,每次間隔 10 秒
        for ($i = 0; $i < 5; $i++) {
            sleep(10);
            try {
                $db->reconnect();
                return true;
            } catch (Exception $e) {
                continue;
            }
        }
        throw new Exception("DB connection lost during batch print");
    }
}

坑五:列印時隱藏空白區塊

問題

表單列印出來有大片空白——某些區塊沒有資料,但仍然佔位。紙張浪費嚴重。

修復

// ✅ 沒資料的區段不渲染
<?php if (!empty($nursingTeamData)): ?>
<tr>
    <td>護理組追蹤評值</td>
    <td><?= $nursingTeamData ?></td>
</tr>
<?php endif; ?>

<?php if (!empty($socialWorkQ1) || !empty($socialWorkQ2)): ?>
<tr>
    <td>社工組追蹤評值</td>
    <td><?= $socialWorkQ1 ?> <?= $socialWorkQ2 ?></td>
</tr>
<?php endif; ?>

看起來理所當然?但在 Legacy 系統中,表單是固定的 HTML 結構,大量使用 <table> 排版。移除某一行會影響整個表格的對齊。需要逐個表單逐個區塊確認。

Claude Code 的做法是讀完整個列印 PHP,列出所有可能為空的區塊,生成 if 條件。


坑六:日期範圍不對齊——列表和列印結果不一致

問題

使用者在列表頁搜尋「2026 年 3 月」的資料,看到 23 筆。按「批次列印」,印出來只有 18 筆。

根因

列表頁用使用者選的日期搜尋,列印頁卻用程式碼寫死的日期:

// ❌ 列印頁用寫死的月份首尾日
$startDate = date("Ym01"); // 當月 1 號
$endDate = date("Ymt");    // 當月最後一天

如果使用者在 4 月 1 日列印 3 月的資料,date("Ym01") 會變成 20260401,查的是 4 月而不是 3 月。

修復

// ✅ 使用與列表頁相同的日期參數
$startDate = $_GET['date1'] ?? date("Ym01");
$endDate = $_GET['date2'] ?? date("Ymt");

另外也修了一個防禦性問題:當沒有符合條件的個案時,空的 IN () 子句會造成全表查詢:

// ❌ 空的 IN 子句 = 全表掃描
$sql = "WHERE HospNo IN ({$emptyString})";

// ✅ 沒資料就直接返回空結果
if (empty($hospNos)) {
    $sql = "WHERE 1 = 0";
}

CSS Print 速查表

整理在 Legacy PHP 列印中常用的 CSS 技巧:

分頁控制

/* 表單之間強制分頁 */
.form-page {
    page-break-before: always;
}

/* 防止表格跨頁斷裂 */
.data-table tr {
    page-break-inside: avoid;
}

/* 隱藏分頁符(只要效果不要內容) */
.page-break-spacer {
    page-break-after: always;
    height: 0;
    overflow: hidden;
}

紙張大小

@page {
    size: A4 portrait;
    margin: 10mm 15mm;
}

/* 橫向 */
@page landscape-page {
    size: A4 landscape;
}

列印專用樣式

@media print {
    /* 隱藏導覽列、按鈕 */
    .navbar, .btn-group, .no-print {
        display: none !important;
    }
    
    /* 確保背景色印出來 */
    * {
        -webkit-print-color-adjust: exact;
        print-color-adjust: exact;
    }
    
    /* 表格框線 */
    table, th, td {
        border: 1px solid #000 !important;
    }
}

PHP 端控制

// 批次列印時區分「最後一頁」
if ($isLast) {
    echo '<div class="form-page">';
} else {
    echo '<div class="form-page" style="page-break-after: always;">';
}

// 動態判斷是否需要雙面列印空白頁
if ($pageCount % 2 === 1 && $needDuplex) {
    echo '<div style="page-break-before: always;">&nbsp;</div>';
}

結語

Legacy PHP 的列印功能之所以複雜,是因為它橫跨了太多層:

問題
CSS分頁控制、紙張大小、列印樣式
PHP批次拼接、記憶體管理、DB 連線
curlSession 傳遞、localhost vs 外部域名
GD字體渲染、圖片生成
瀏覽器雙面列印、頁數估算

每一層單獨看都不難,但它們組合在一起時,Bug 就會出現在層與層之間的縫隙。

Claude Code 在這裡最大的價值是跨層追蹤。當列印空白時,你不確定是 CSS 的問題、PHP 的問題、還是 curl 的問題。Claude Code 可以同時讀 CSS、PHP、和 curl 設定,一次找到根因在哪一層。