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)。規則是:每個個案必須從紙張正面開始。如果前一個個案的表單是奇數頁,需要插入一頁空白頁讓下一個個案從正面開始。
聽起來簡單,但有三個邊界條件:
- 怎麼知道某個表單渲染出幾頁?
- 最後一個個案要不要加空白頁?
- 一個個案有多張表單,怎麼判斷「最後一張表單」?
第一版: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 = ' ';
container.appendChild(blankPage);
}
第三版的 Bug 修正
一開始,最後一個個案不補空白頁。但使用者回報:「最後一個人的表單如果是奇數頁,下一次列印的第一張會印在背面。」
所以改成:每個個案都要確保是偶數頁,包括最後一個。
// ✅ 最終版:所有個案都補,不只非最後一個
if ($isPreviousPatientOddPages) {
echo '<div style="page-break-before: always;"> </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 個版本)
這個看似簡單的修復實際上經歷了五個版本:
- v2:修正列印和 WORD 匯出中的傷口標記邏輯
- v3:WORD 匯出換 GD 圖片有問題,退回無標記版本
- v3 被 revert:決定還是要標記
- v4:修改生圖失敗時的預設圖片處理
- 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;"> </div>';
}
結語
Legacy PHP 的列印功能之所以複雜,是因為它橫跨了太多層:
| 層 | 問題 |
|---|---|
| CSS | 分頁控制、紙張大小、列印樣式 |
| PHP | 批次拼接、記憶體管理、DB 連線 |
| curl | Session 傳遞、localhost vs 外部域名 |
| GD | 字體渲染、圖片生成 |
| 瀏覽器 | 雙面列印、頁數估算 |
每一層單獨看都不難,但它們組合在一起時,Bug 就會出現在層與層之間的縫隙。
Claude Code 在這裡最大的價值是跨層追蹤。當列印空白時,你不確定是 CSS 的問題、PHP 的問題、還是 curl 的問題。Claude Code 可以同時讀 CSS、PHP、和 curl 設定,一次找到根因在哪一層。