iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0

Day 22 把檢測結果整理成 Markdown 審查報告:摘要、依嚴重度 / 類型 / 來源的統計、熱點需求、待複核清單。Markdown 適合放進 Git 或 Wiki,但要交給客戶、主管或審查會議時,大家習慣的還是 PDF;而「高嚴重度 3 個、中 2 個」這種數字,一張長條圖比一張表格更快讓人看懂。

今天把 Day 22 的 ReviewReport 輸出成含圖表的 PDF。程式本身不難,難的是一個所有中文 PDF 都會遇到的問題:字型。

這一天在系列中的位置

第 4 週:發佈與開源

  • Day 22 → 審查報告生成與統計分析(Markdown)
  • Day 23 → 圖表與 PDF 報告 ← 今天
  • Day 24 → 命令列工具

今日目標

讀完這篇後,你會完成:

  1. 字型處理 src/pdf_report.py:找出可嵌入的中文 TrueType 字型,找不到就明確報錯
  2. 長條圖:依嚴重度、類型、檢測來源三張水平長條圖
  3. PDF 版面:摘要表、圖表、熱點需求、衝突詳情、待複核清單
  4. 測試 tests/test_day23_pdf.py:4 個測試,包含「字型確實嵌入 PDF」

問題背景:中文 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(字型已嵌入)
  • 輸入:Day 22 的 ReviewReport
  • 輸出:PDF 檔案
  • 檔案:src/pdf_report.py(新增)、requirements.txt(加入 reportlab>=4.0)
  • 下游:Day 24 的 CLI 提供 --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

代碼示例

1. 找字型

建立 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 驗證狀態。

2. 長條圖

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"          # 長條右側標出數量
    ...

幾個選擇:

  • 水平長條圖:類型名稱(「安全性衝突」「自相矛盾」)比較長,放在左側比放在 X 軸下方好讀
  • 高度隨筆數增加:每多一筆 18 點,類型多的報告不會擠在一起
  • 刻度至少為 1:衝突數都是整數,valueStep 不能出現 0.5
  • 嚴重度用顏色區分:高 = 紅、中 = 橘、低 = 灰;其他兩張圖用單一藍色

軸標籤、數字標籤的字型也要設成中文字型,否則類型名稱又會變成方框。完整設定見 src/pdf_report.py。

3. 版面

用 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 仍會確認錯誤訊息。

實際產生的 PDF

用 Day 22 測試資料(社交媒體 SRS 的 4 個衝突)產生 PDF,約 80 KB,第一頁:

  • 標題「社交媒體平台 SRS 審查報告」與檢測設定
  • 摘要表:需求數 7、衝突數 4、涉及需求 6、待複核 3
  • 「依嚴重度」:高 3(紅)、中 1(橘)、低 0
  • 「依類型」:安全性衝突 2、自相矛盾 1、邏輯矛盾 1
  • 「依檢測來源」:規則 2、自我矛盾檢查 1、LLM 補充 1
  • 熱點需求 REQ-2 與衝突詳情的開頭

用 Quick Look 預覽第一頁,中文、長條圖標籤、「【高】」都正確顯示。待複核清單在第二頁,其中的「↔」箭頭除了用 charToGlyph 確認字型有收錄,Day 25 在容器裡產生的單頁報告也能直接看到它正確顯示。


權衡與限制

  • 依賴系統字型:沒有中文 TrueType 字型的環境無法產生 PDF。這是刻意的選擇:比起產生亂碼,明確報錯更容易處理。Day 25 的 Docker 映像檔會直接安裝 fonts-wqy-zenhei
  • 字型決定外觀:macOS 用黑體、Docker 用文泉驛正黑,同一份報告在兩邊產生的 PDF 外觀略有不同
  • 沒有內嵌目錄與頁碼:衝突很多時,PDF 會有好幾頁,但沒有目錄。可以用 platypus 的 TableOfContents 與頁面回呼加上,這裡先不做
  • reportlab 的授權:開源版是 BSD 授權,可以自由使用。另有商業版 reportlab PLUS,本文沒有用到

提交變更

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,在出現高嚴重度衝突時讓建置失敗。


上一篇
Day 22:審查報告生成與統計分析
下一篇
Day 24:命令列工具
系列文
解決需求規格書矛盾:用 Claude Code × MCP 實作自律型文檔審查 Agent 共 25 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
helenanova
iT邦新手 5 級 ‧ 2026-10-07 19:15:38

把「找不到字型」做成明確錯誤,而不是默默產生亂碼 PDF,這個取捨我很認同。我會再補一份長需求、特殊符號和跨頁表格的固定測試資料,把 PDF 渲染成圖片檢查裁切與換行。/FontFile2 檢查守住嵌入字型,版面回歸測試則守住「字都有,但內容被截掉」這另一種失敗。

我要留言

立即登入留言