Day 22 把檢測結果整理成 Markdown 審查報告:摘要、依嚴重度 / 類型 / 來源的統計、熱點需求、待複核清單。Markdown 適合放進 Git 或 Wiki,但要交給客戶、主管或審查會議時,大家習慣的還是 PDF;而「高嚴重度 3 個、中 2 個」這種數字,一張長條圖比一張表格更快讓人看懂。
今天把 Day 22 的 ReviewReport 輸出成含圖表的 PDF。程式本身不難,難的是一個所有中文 PDF 都會遇到的問題:字型。
第 4 週:發佈與開源
讀完這篇後,你會完成:
src/pdf_report.py:找出可嵌入的中文 TrueType 字型,找不到就明確報錯tests/test_day23_pdf.py:4 個測試,包含「字型確實嵌入 PDF」PDF 裡的文字是「字型 + 字元編號」。閱讀器要畫出文字,必須有那個字型。有兩種做法:
| 做法 | 檔案大小 | 換一台電腦打開 |
|---|---|---|
| 嵌入字型:把用到的字形放進 PDF | 較大 | 一定正確 |
| 只寫字型名稱:期待閱讀器自己有 | 小 | 看運氣 |
reportlab 內建了幾個 CJK 字型,例如繁體中文的 MSung-Light,不需要任何字型檔就能用,看起來很方便。我一開始就是這樣寫的:
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.cidfonts import UnicodeCIDFont
pdfmetrics.registerFont(UnicodeCIDFont("MSung-Light"))
c.setFont("MSung-Light", 14)
c.drawString(72, 760, "繁體中文測試:衝突、嚴重度、需求 REQ-2.1")
程式執行成功,產生了 PDF。但用 macOS 的預覽程式打開,看到的是:
狗卾篆腮唰蓦鞴堜煜™熙緯東™嫗敞 REQ-2.1
英文和數字正常,中文全部是亂碼。原因就是上表的第二種做法:CID 字型不會嵌入,閱讀器必須自己有 Adobe 的對應字型與編碼表,macOS 的預覽程式沒有。
換成嵌入系統的 TrueType 字型後,同一行文字就正確了。所以今天的原則是:一定要嵌入字型;找不到可嵌入的字型,寧可報錯,也不要產生一份在別人電腦上是亂碼的 PDF。
還有一個限制:reportlab 只能嵌入 TrueType(glyf)格式的字型,常見的 Noto Sans CJK(.otf / CFF 格式)不能用。
ReviewReport(Day 22)
↓ render_pdf(report, path)
register_font()
└─ resolve_font():SRS_PDF_FONT → 系統常見字型 → 找不到就丟 FontNotFoundError
↓
platypus 版面:標題 → 摘要表 → 3 張長條圖 → 熱點需求 → 衝突詳情 → 待複核清單
↓
PDF(字型已嵌入)
ReviewReport
src/pdf_report.py(新增)、requirements.txt(加入 reportlab>=4.0)--format pdf;Day 25 的 Docker 映像檔安裝字型專案結構變化:
srs-review-agent/
├── requirements.txt ← 修改:reportlab>=4.0
├── src/
│ ├── report_generator.py ← Day 22
│ └── pdf_report.py ← 【新增】字型、圖表、PDF
└── tests/
└── test_day23_pdf.py ← 【新增】4 個測試
pip install reportlab
本文使用 reportlab 5.0.1。字型方面,macOS 內建的「黑體」(STHeiti Light.ttc)可以直接使用;Debian / Ubuntu 需要安裝文泉驛正黑:
sudo apt-get install fonts-wqy-zenhei
建立 src/pdf_report.py:
FONT_NAME = "SRSReportCJK"
# (路徑, TTC 子字型編號);只列 TrueType(glyf)字型,reportlab 不支援 CFF/OTF
FONT_CANDIDATES: List[Tuple[str, int]] = [
("/System/Library/Fonts/STHeiti Light.ttc", 0), # macOS
("/Library/Fonts/Arial Unicode.ttf", 0), # macOS(Office)
("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc", 0), # Debian/Ubuntu: fonts-wqy-zenhei
("/usr/share/fonts/truetype/arphic/uming.ttc", 0), # Debian/Ubuntu: fonts-arphic-uming
]
class FontNotFoundError(RuntimeError):
pass
def resolve_font(candidates=None) -> Tuple[str, int]:
env = os.getenv("SRS_PDF_FONT")
if env:
if not Path(env).exists():
raise FontNotFoundError(f"SRS_PDF_FONT 指定的字型不存在:{env}")
return env, int(os.getenv("SRS_PDF_FONT_INDEX", "0"))
for path, index in (candidates if candidates is not None else FONT_CANDIDATES):
if Path(path).exists():
return path, index
raise FontNotFoundError(
"找不到可嵌入的中文 TrueType 字型。請安裝 fonts-wqy-zenhei(Debian/Ubuntu),"
"或用環境變數 SRS_PDF_FONT 指定 .ttf / .ttc 檔案路徑。")
.ttc 是「字型集合」,一個檔案裡有多個字型,所以要指定子字型編號。錯誤訊息直接告訴使用者兩種解法,而不是只說「找不到字型」。
註冊字型:
def register_font() -> str:
"""註冊字型並返回字型名稱(重複呼叫不會重複註冊)"""
if FONT_NAME not in pdfmetrics.getRegisteredFontNames():
path, index = resolve_font()
font = (TTFont(FONT_NAME, path, subfontIndex=index) if path.endswith(".ttc")
else TTFont(FONT_NAME, path))
pdfmetrics.registerFont(font)
return FONT_NAME
TTFont 預設會做子集化:只嵌入 PDF 中實際用到的字形。黑體字型檔有好幾 MB,但一份審查報告只用到幾百個字,產生的 PDF 只有幾十 KB。
報告中用到的符號(「↔」「【】」「|」)我先用 reportlab 讀字型的 charToGlyph 表確認過,黑體與 Arial Unicode 都有收錄;✅ 這類表情符號則沒有,所以 PDF 裡改用文字「是 / 否」表示 LLM 驗證狀態。
reportlab 本身就有繪圖模組,不需要再裝 matplotlib,也不必處理 matplotlib 的中文字型:
def bar_chart(title: str, data: List[Tuple[str, int]], font: str,
bar_colors: Optional[List] = None, width: float = 160 * mm) -> Drawing:
rows = max(len(data), 1)
height = 22 + rows * 18 + 20
drawing = Drawing(width, height)
drawing.add(String(0, height - 12, title, fontName=font, fontSize=11))
chart = HorizontalBarChart()
chart.x, chart.y = 70 * mm, 16
chart.width, chart.height = width - 70 * mm - 10 * mm, rows * 18
labels = [label for label, _ in data] or ["(無)"]
values = [value for _, value in data] or [0]
# reportlab 由下往上畫,反轉後第一筆在最上面
chart.data = [list(reversed(values))]
chart.categoryAxis.categoryNames = list(reversed(labels))
chart.categoryAxis.labels.fontName = font
chart.valueAxis.valueMin = 0
chart.valueAxis.valueMax = max(max(values), 1)
chart.valueAxis.valueStep = max(1, chart.valueAxis.valueMax // 5)
chart.barLabelFormat = "%d" # 長條右側標出數量
...
幾個選擇:
valueStep 不能出現 0.5軸標籤、數字標籤的字型也要設成中文字型,否則類型名稱又會變成方框。完整設定見 src/pdf_report.py。
用 reportlab 的 platypus 版面引擎,把段落、表格、圖表依序放進 story,由它自動分頁:
def _styles(font: str):
base = dict(fontName=font, wordWrap="CJK") # wordWrap=CJK:中文可在任意字元間換行
return {
"title": ParagraphStyle("title", fontSize=18, leading=24, spaceAfter=6, **base),
"h2": ParagraphStyle("h2", fontSize=13, leading=18, spaceBefore=10, spaceAfter=4, **base),
"body": ParagraphStyle("body", fontSize=9.5, leading=14, **base),
"small": ParagraphStyle("small", fontSize=8, leading=11, textColor=colors.grey, **base),
"cell": ParagraphStyle("cell", fontSize=8.5, leading=12, **base),
}
wordWrap="CJK" 是另一個中文專屬的設定:英文靠空白換行,中文句子沒有空白,不設定的話一整句會超出版面。
每個衝突用 KeepTogether 包起來,避免「需求 A」在這一頁、「需求 B」跑到下一頁:
for number, i in enumerate(report.items, start=1):
flag = "(待複核)" if i.needs_review else ""
reqs = (f"{i.req_id_1}:{_escape(i.text_1)}" if i.is_self_contradiction else
f"{i.req_id_1}:{_escape(i.text_1)}<br/>{i.req_id_2}:{_escape(i.text_2)}")
block = [
Paragraph(f"{number}. 【{i.severity}】{_escape(i.type)}{flag}", st["body"]),
Paragraph(reqs, st["cell"]),
Paragraph(f"說明:{_escape(i.description)}", st["cell"]),
Paragraph(f"置信度 {i.confidence:.0%}|來源:{source}|LLM 驗證:{'是' if i.verified else '否'}",
st["small"]),
Spacer(1, 3 * mm),
]
story.append(KeepTogether(block)) # 一個衝突不跨頁
Paragraph 支援類似 HTML 的標記(<br/>),所以需求原文中的 &、<、> 要先轉義,這和 Day 22 Markdown 表格要轉義 | 是同一類問題:輸出格式的特殊字元,都要在放進去之前處理。
沒有衝突時,報告只有摘要,不畫圖表。完整的 render_pdf() 見 src/pdf_report.py。
cd srs-review-agent
python3 -m pytest tests/test_day23_pdf.py -v
tests/test_day23_pdf.py::test_font_errors PASSED [ 25%]
tests/test_day23_pdf.py::test_pdf_embeds_font PASSED [ 50%]
tests/test_day23_pdf.py::test_empty_report_pdf PASSED [ 75%]
tests/test_day23_pdf.py::test_bar_chart_height PASSED [100%]
============================== 4 passed in 0.33s ===============================
最重要的是 test_pdf_embeds_font:
data = Path(path).read_bytes()
assert data.startswith(b"%PDF")
assert b"/FontFile2" in data, "TrueType 字型必須嵌入(/FontFile2)"
assert b"MSung" not in data, "不應使用不嵌入的 CID 字型"
/FontFile2 是 PDF 中「嵌入的 TrueType 字型」的標記。這個測試直接檢查了今天最容易出錯的地方:PDF 能產生,不代表在別人的電腦上能正確顯示。
沒有中文字型的環境,後三個測試會被略過(pytest.mark.skipif),test_font_errors 仍會確認錯誤訊息。
用 Day 22 測試資料(社交媒體 SRS 的 4 個衝突)產生 PDF,約 80 KB,第一頁:
用 Quick Look 預覽第一頁,中文、長條圖標籤、「【高】」都正確顯示。待複核清單在第二頁,其中的「↔」箭頭除了用 charToGlyph 確認字型有收錄,Day 25 在容器裡產生的單頁報告也能直接看到它正確顯示。
fonts-wqy-zenhei
TableOfContents 與頁面回呼加上,這裡先不做git add src/pdf_report.py tests/test_day23_pdf.py requirements.txt
git commit -m "Day 23: PDF 審查報告(嵌入中文字型、長條圖、待複核清單)"
現在要產生一份報告,得寫一段 Python:讀需求、建立檢測器、呼叫 build_report()、再呼叫 render_pdf()。明天 Day 24 我們把這些串成一個命令列工具:python3 -m src.cli analyze spec.md --format pdf,直接從 Markdown 規格書產生報告,並能接進 CI,在出現高嚴重度衝突時讓建置失敗。
把「找不到字型」做成明確錯誤,而不是默默產生亂碼 PDF,這個取捨我很認同。我會再補一份長需求、特殊符號和跨頁表格的固定測試資料,把 PDF 渲染成圖片檢查裁切與換行。/FontFile2 檢查守住嵌入字型,版面回歸測試則守住「字都有,但內容被截掉」這另一種失敗。