iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
AI Engineering

知識圖譜 : 技能樹式學習歷程系列 第 20

Day 20 — 內容自動化:從亂碼 PDF 到教材涵蓋稽核

  • 分享至 

  • xImage
  •  

今天要解的問題

寫課文需要參考素材。我手上有大量 PDF 講義(自己修課時的教材),寫課文時要查裡面的例題與數字。

問題:用程式抽文字,有一部分抽出來是亂碼。

栆⇍屯㕁↮㜸
$Q ,QWURGXFWLRQ WR &DWHJRULFDO 'DWD $QDO\VLV

第一行應該是「類別資料分析」,第二行應該是 An Introduction to Categorical Data Analysis

477 份 PDF 裡有 61 份是這種狀態。這代表其中好幾門課的參考素材我完全讀不到——不是讀起來不方便,是程式抽出來就是垃圾。

今天拆解這兩種亂碼的成因,各給一個解法。

前提聲明:這些 PDF 是第三方著作,.gitignore 從第一天就排除了它們(Day 1)。本文只討論方法,不會出現任何講義原文、原圖或原始資料集;示範用的字串都是我自己造的。抽出來的文字也只在本機當寫課文的參考,不會進網站。

先做正確的診斷

我一開始的假設是「編碼猜錯」——以為是 Big5 被當成 UTF-8 之類的。試了各種 codec 組合,全部失敗。

真正的診斷方法是看抽取工具實際拿到什麼

import pymupdf   # 舊名 fitz

doc = pymupdf.open("sample.pdf")
page = doc[0]
for block in page.get_texttrace():
    for ch in block["chars"]:
        ucs, gid = ch[0], ch[1]
        print(f"ucs=U+{ucs:04X} gid={gid} font={block['font']}")

get_texttrace() 回傳每個字元的 (unicode, glyph id) 配對。輸出關鍵在這裡:

ucs=U+FFFD gid=1234 font=MSJH+Identity-H
ucs=U+FFFD gid=1567 font=MSJH+Identity-H

U+FFFD 是 replacement character——PDF 根本沒有告訴我們這個字是什麼。抽取工具只能拿到 glyph id(字型檔裡的第幾個字形)。

所以這不是編碼問題,是 PDF 缺少 glyph → unicode 的對照表。而缺失的原因有兩種,需要兩種解法。


成因一:PowerPoint 匯出的空 Identity CMap

PDF 用 /ToUnicode CMap 來記錄「glyph 3456 對應到『類』」。PowerPoint 匯出中文 PPT 時,這張表會是空的 identity map——形式上存在,內容是 gid → gid

於是抽出來的「文字」其實是一串 glyph id 被當成 unicode 碼位解讀:

「類」的 glyph id = 26758  →  當成 U+6886  →  顯示為「栆」

栆⇍屯㕁↮㜸 就是這樣來的。

解法:從內嵌字型自己建對照表

PDF 有內嵌字型檔,字型檔裡有 cmap 表——那是 unicode → glyph id 的正向對照。我要的是反向,所以:解析 cmap、反轉它。

環境沒有 fontTools(而且我不想加依賴),所以手寫 parser。cmap 只需要支援兩種常見格式:

import struct

def parse_cmap(data):
    """解析 TrueType cmap 表,回傳 {unicode: gid}。只處理 format 4 與 12。"""
    n_tables, = struct.unpack(">H", data[2:4])
    best = None
    for i in range(n_tables):
        off = 4 + i * 8
        plat, enc, sub_off = struct.unpack(">HHI", data[off:off+8])
        # 優先 (3,10) UCS-4,其次 (3,1) BMP,再其次 (0,*) Unicode
        rank = {(3,10): 3, (3,1): 2}.get((plat, enc), 1 if plat == 0 else 0)
        if rank and (best is None or rank > best[0]):
            best = (rank, sub_off)
    if not best:
        return {}
    sub = data[best[1]:]
    fmt, = struct.unpack(">H", sub[:2])
    return _fmt4(sub) if fmt == 4 else (_fmt12(sub) if fmt == 12 else {})


def _fmt4(sub):
    """format 4:分段對照,BMP 專用。"""
    seg_x2, = struct.unpack(">H", sub[6:8])
    seg = seg_x2 // 2
    ends   = struct.unpack(f">{seg}H", sub[14:14+seg_x2])
    starts = struct.unpack(f">{seg}H", sub[16+seg_x2:16+2*seg_x2])
    deltas = struct.unpack(f">{seg}h", sub[16+2*seg_x2:16+3*seg_x2])
    ro_off = 16 + 3*seg_x2
    ranges = struct.unpack(f">{seg}H", sub[ro_off:ro_off+seg_x2])
    out = {}
    for i in range(seg):
        for c in range(starts[i], min(ends[i], 0xFFFF) + 1):
            if ranges[i] == 0:
                g = (c + deltas[i]) & 0xFFFF
            else:
                idx = ro_off + i*2 + ranges[i] + (c - starts[i]) * 2
                if idx + 2 > len(sub):
                    continue
                g, = struct.unpack(">H", sub[idx:idx+2])
                if g:
                    g = (g + deltas[i]) & 0xFFFF
            if g:
                out[c] = g
    return out


def _fmt12(sub):
    """format 12:group 對照,支援 BMP 以外的碼位。"""
    n_groups, = struct.unpack(">I", sub[12:16])
    out = {}
    for i in range(n_groups):
        s, e, g0 = struct.unpack(">III", sub[16+i*12 : 28+i*12])
        for c in range(s, e + 1):
            out[c] = g0 + (c - s)
    return out

反轉時有個細節:

def invert(cmap):
    """gid → unicode。同一個 gid 可能對應多個 unicode,要挑常用的。"""
    out = {}
    for uni, gid in cmap.items():
        if gid not in out or _prefer(uni, out[gid]):
            out[gid] = uni
    return out

def _prefer(a, b):
    """偏好常用漢字區,避開康熙部首等相容區。"""
    def score(u):
        if 0x4E00 <= u <= 0x9FFF: return 3      # CJK 統一漢字
        if 0x3000 <= u <= 0x303F: return 2      # CJK 標點
        if 0x2F00 <= u <= 0x2FDF: return 0      # 康熙部首 ← 要避開
        return 1
    return score(a) > score(b)

康熙部首那個坑很真實(U+2F08,康熙部首)與 (U+4EBA,正常漢字)在很多字型裡指向同一個 glyph。如果不做偏好排序,抽出來的文字會滿是看起來像但實際上打不出來的字,複製到搜尋引擎完全找不到。

最大的坑:不能按字型名決定要不要修

我的第一版邏輯是「這份 PDF 的字型是 Identity-H,所以全部套用修復」。結果把原本正確的文字改壞了

原本:(Primary Biliary Cirrhosis )
改壞:Emrimary=Biliary…

原因:同一個 BaseFont 名稱可能同時以簡單字型與 Identity-H 兩種形式出現在同一份 PDF 裡(ArialMT 就是典型)。英文部分用簡單字型(有正確的 ToUnicode),中文部分用 Identity-H(沒有)。按字型名一律套用,就把正確的英文也「修」壞了。

正解是兩層守衛:

# 守衛 1:只修 ucs == 0xFFFD 的字元。已經正確的字絕不動。
if ucs != 0xFFFD:
    keep(chr(ucs))
    continue

# 守衛 2:候選對照表按「gid 覆蓋率」擇優
#   同一頁可能有多個候選字型,選那個「能解釋最多 gid」的
best_table = max(candidates, key=lambda t: sum(1 for g in page_gids if g in t))

「只修壞的、不動好的」是這類資料修復工作的第一原則。 破壞性的「修復」比不修復糟糕得多——因為你會以為問題解決了。


成因二:cmap 整個被移除(iOS/Quartz 重新輸出)

第二種亂碼長得完全不同:

$Q ,QWURGXFWLRQ WR &DWHJRULFDO 'DWD $QDO\VLV

這一眼看得出是位移密碼$AQn,I……

算一下:ord('A') - ord('$') = 65 - 36 = 29。全部差 29。

成因:這些 PDF 是在 iPad 上批註後重新輸出的,Quartz(macOS/iOS 的繪圖層)把字型 subset 並且把 cmap 整個拿掉。但它保留了原字型的 glyph 順序,而在標準 Macintosh 字彙順序(standard Macintosh ordering)下,glyph 3 到 97 依序就是 ASCII 0x20 到 0x7E。

所以:

unicode = gid + 29        (對 ASCII 範圍)

gid=30x20(空白)、gid=360x41(A)。

解法:還原後用英文統計驗證

不能無條件套用這個位移——中文字型的 gid 完全不遵守這個規律,硬套會產生新的垃圾。所以還原之後要驗證結果像不像英文

ENG_FREQ = set("etaoinshrdlcumwfgypbvkjxqz")

def eng_score(text):
    """英文母音與常用字母的比例。真英文約 0.55–0.75。"""
    letters = [c.lower() for c in text if c.isalpha()]
    if len(letters) < 20:
        return 0.0
    common = sum(1 for c in letters if c in "etaoinsr")
    return common / len(letters)

def try_standard_order(chars):
    restored = "".join(chr(g + 29) if 3 <= g <= 97 else "�" for g in chars)
    return restored if eng_score(restored) > 0.45 else None

eng_score > 0.45 這個門檻是實測調出來的:真英文文本大約 0.55–0.75,隨機字元大約 0.30。0.45 給了安全邊界,而且中文字型不會誤觸發(還原後全是控制字元,score 接近 0)。

這是「猜測 + 驗證」的模式:允許演算法做有風險的猜測,但必須有一個獨立的判準來否決錯誤的猜測

順手濾掉的噪音

iPad 筆記 app 的點陣背景(那種淡淡的格線點)會被抽成上萬個 .

# 濾除:單頁出現超過 200 個孤立的 "." 就當背景噪音
if text.count(".") > 200 and len(text.replace(".", "").strip()) < 100:
    text = ""

兩種都救不回來的:只能 OCR

跑完上面兩招之後:49 份完全救回(輸出 <檔名>.txt 放在 PDF 同層)。

剩下 12 份沒救——那是中文 subset 字型,cmap 沒有、glyph 順序也不是標準序。字型檔裡只有「這些字形長什麼樣」,沒有任何「這是哪個字」的資訊。演算法上無解。

只能 OCR。而環境沒有 root 權限。

免 root 裝 tesseract

#!/bin/bash
# scripts/setup-tesseract.sh —— 不需要 root
set -e
DEST="$(dirname "$0")/../.tools/tesseract"
mkdir -p "$DEST/debs"
cd "$DEST/debs"

# apt-get download 不需要 root(只是下載 .deb)
apt-get download \
  tesseract-ocr tesseract-ocr-eng tesseract-ocr-chi-tra \
  libtesseract5 libleptonica-dev liblept5 \
  libarchive13t64 libgif7 libwebpmux3 libwebp7 \
  libopenjp2-7 libjbig0 libtiff6 2>/dev/null || true

# dpkg -x 只解壓,不需要 root、不動系統
for f in *.deb; do dpkg -x "$f" "$DEST/root"; done

cat > "$DEST/env.sh" <<'EOF'
export TESSDATA_PREFIX="$(cd "$(dirname "${BASH_SOURCE[0]}")/root/usr/share/tesseract-ocr/5/tessdata" && pwd)"
export LD_LIBRARY_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/root/usr/lib/x86_64-linux-gnu" && pwd):$LD_LIBRARY_PATH"
export PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")/root/usr/bin" && pwd):$PATH"
EOF
echo "完成。使用前先 source $DEST/env.sh"

apt-get download + dpkg -x免 root 安裝系統套件的通用手法:前者只下載、後者只解壓到指定目錄,兩者都不碰 /usr 也不需要權限。代價是要自己補相依鏈——我實際踩到需要補的是 liblept5libarchive13t64libgif7libwebpmux3,缺任何一個 tesseract 就啟動失敗(error while loading shared libraries)。

.tools/ 從第一天就在 .gitignore 裡(Day 1)——這種東西不該進版控。

效能雷:--oem 差 80 倍

# scripts/ocr-pdf.py
import subprocess, pymupdf
from concurrent.futures import ProcessPoolExecutor

def ocr_page(args):
    pdf_path, page_no = args
    doc = pymupdf.open(pdf_path)
    pix = doc[page_no].get_pixmap(dpi=300)          # 300 dpi:品質與速度的平衡點
    png = pix.tobytes("png")
    r = subprocess.run(
        ["tesseract", "stdin", "stdout",
         "-l", "chi_tra+eng",
         "--oem", "1",          # ← 這個參數是關鍵
         "--psm", "3"],
        input=png, capture_output=True, timeout=180)
    return page_no, r.stdout.decode("utf-8", "replace")

with ProcessPoolExecutor(max_workers=8) as ex:
    results = sorted(ex.map(ocr_page, [(path, i) for i in range(n_pages)]))

--oem 是 OCR engine mode:

引擎 文字密集頁的實測速度
3(預設 LSTM + legacy 混合 > 180 秒/頁(會 timeout)
1 純 LSTM 2.2 秒/頁

約 80 倍差距。 我第一次跑的時候以為程式壞了——一頁跑了三分鐘還沒完。原因是 legacy 引擎在中文密集頁上會做大量無效的字元分割嘗試。

而預設值是 3。這是那種「不知道就會以為這條路不可行」的參數。純 LSTM 的辨識品質對印刷體完全足夠,我沒有觀察到品質下降。

OCR 的品質邊界

  • 印刷文字:很好。 一份含資料表格的講義完整救回 33 筆數值。
  • 手寫批註:抽不出來。 那本來就是講義上的手寫筆記,OCR 出來是雜訊。這個限制要接受——並且輸出檔要與演算法還原的分開命名.ocr.txt vs .txt),因為兩者可信度不同。之後查資料時我會知道「這份是 OCR 的,數字要回頭核對原圖」。

全庫盤點結果:先把「歷史批次」與「目前檔案」分開

2026-07-29 做修復時的來源集合是 477 份 PDF,因此修復批次的分母仍然是 477:

# 只處理壞掉的(--only-broken 會先檢測再決定)
python3 scripts/extract-pdf-text.py .reference --only-broken
python3 scripts/ocr-pdf.py .reference/path/to/hard.pdf
類別 份數
原本就正常 416
空 CMap → 內嵌字型 cmap 救回 37
標準字彙順序 → 演算法救回 12
無 cmap 中文 subset → OCR 12
歷史批次總計 477

8/8 重跑全庫 inventory 時,磁碟上的現況是 464 PDF+75 TXT;75 個文字檔裡,72 個是 PDF sidecar、3 個是獨立的類別資料文字來源。來源集合會重整,文章不能把歷史批次的 477 永久當成目前值。

這也帶出下一個問題:文字救回來,不代表知識已經進課文。 如果只停在「現在搜尋得到 PDF」,我仍然不知道哪些主題完全沒教、哪些只在某課提過一句、哪些已有完整章課。

從 PDF 修復延伸成 coverage audit

我新增 scripts/audit-reference-coverage.py,把「來源盤點」與「網站現況」放進同一個可重跑流程:

  1. 盤點所有 PDF/TXT、sidecar、大小與 SHA-256,按課程來源路徑分組。
  2. 先排除不該算課綱的東西:行政表單、會議紀錄、考卷/作業、內容等價 mirror。SQL50 目錄裡的統計書、ML 教材與考研筆記也不能因為放錯資料夾就算成 SQL。
  3. 用 Node vm 真正執行 curriculum.js 和全部 js/lessons/*.js,取得 runtime 章課 title/focus 與去標籤正文。不能只 grep 原始 JS,因為課文有 template literal 與 JSON.stringify 兩種表示。
  4. 以檔名、sidecar、可選的 PDF 前 N 頁與 topic aliases 計算來源/課綱/正文命中,再人工覆核為 missingshallowcovered
# 快速:檔名 + 已有 TXT/OCR sidecar + runtime 課文
python3 scripts/audit-reference-coverage.py --json /tmp/reference-audit.json

# 深掃:無 sidecar 的 PDF 再讀前 12 頁
python3 scripts/audit-reference-coverage.py --scan-pdf --pdf-pages 12 --report wiki/09-reference-coverage-audit.md

深掃 464 份 PDF 約 24 秒。輸出不含任何教材原文,只保存計數、主題、來源路徑、命中的 lesson key 與建議。

稽核結果不是「關鍵詞有出現就算教過」

人工覆核 109 個候選主題:

狀態 數量 判準
missing 55 沒有實質課文,或課程承諾/來源本身有明確斷層
shallow 23 只在一課順帶提到,沒有推導、決策例或實作
covered 31 已有獨立章課與足夠正文

其中 99 個候選有本機教材直接支持;另外 9 個是課名/完整知識鏈檢查,1 個是來源品質問題。幾個最有行動價值的結果:

  • SEM 來源很豐富,網站仍只有 12 課;WLSMV、FIML、測量恆等與多群組是大缺口。
  • 高等資料庫的 ER/正規化很深,但 ANSI 三層、關聯代數、交易復原、Hadoop/Spark 與具體 NoSQL 實作缺席。
  • AWS 已有 10 章平台主幹,但新盤點的七類證照來源還有 Bedrock/AIF、Migration、FinOps、Developer/SysOps 缺口。
  • GCP 的 PMLE 已實質涵蓋;唯一 GCP PCA 兩頁檔卻不是 PCA 教材,不能拿檔名當內容證據。
  • 初級統計,以及已擴成 24 課的迴歸/CDA/時間序列/多變量主幹大致完整,不應為了整齊再平均灌章。

coverage audit 自己也要能驗證

assert inventory["pdf"] == 464
assert inventory["txt"] == 75
assert sum(g["pdf"] for g in groups.values()) == 464
assert total_courses == 18
assert total_chapters == 111
assert total_lessons == 340
assert all(src_path.exists() for src_path in curriculum_sources)

報告由腳本生成後再重跑一次,用 cmp 確認完全一致;Markdown 本機連結也逐一驗證。這讓下一次擴章後可以比較前後 JSON,而不是靠印象說「好像補得更完整」。

PDF 修復解決的是「機器讀不讀得到」;coverage audit 解決的是「讀到的知識到底有沒有教」。兩者缺一不可。

踩到的雷(總結)

1. 診斷錯誤方向會浪費最多時間。 我花了幾小時試各種編碼組合,因為「亂碼」的第一反應就是編碼。真正的線索是 get_texttrace()U+FFFD——要看工具實際拿到什麼,不要憑輸出的樣子猜。

2. 破壞性的修復比不修復糟。 (Primary Biliary Cirrhosis ) 被改成 Emrimary=Biliary… 那次,如果我沒有隨機抽查,就會產生一批「看起來被修好了但其實錯了」的文字,然後拿去寫課文。原則:只修明確標記為壞的(U+FFFD),並且要有獨立驗證(eng_score)。

3. 預設參數不一定合理。 --oem 3 慢 80 倍這件事,官方文件沒有警告。當某個操作慢得不合理時,先查參數,不要先假設「這條路不可行」。

4. python3 -m pip 在受管環境要加旗標。 系統 Python 是 externally-managed,安裝 pymupdf 需要:

python3 -m pip install --user --break-system-packages pymupdf

--break-system-packages 名字很嚇人,但在 --user 模式下它只是繞過 PEP 668 的保護,裝在使用者目錄、不動系統套件。

驗證

抽取正確性的檢查(不需要人工看):

# scripts/check-extract.py
import re, sys, pathlib

BAD_RANGES = [(0x2F00, 0x2FDF)]    # 康熙部首
def suspicious(text):
    """回傳可疑比例:U+FFFD、康熙部首、孤立標點的佔比"""
    if not text: return 1.0
    bad = sum(1 for c in text
              if c == "�" or any(lo <= ord(c) <= hi for lo, hi in BAD_RANGES))
    return bad / len(text)

fails = []
for p in pathlib.Path(sys.argv[1]).rglob("*.txt"):
    t = p.read_text("utf-8", "replace")
    r = suspicious(t)
    if r > 0.02:
        fails.append((str(p), round(r, 4)))
print(f"{len(fails)} 份仍可疑")
for f in fails[:10]: print(" ", f)
sys.exit(1 if fails else 0)

抽樣人工檢查:每種成因各挑 3 份,比對 PDF 原圖與抽出的文字,確認標題、數字、公式符號都對。自動檢查抓「明顯壞掉」,人工抽驗抓「看起來對但其實錯」。

第二階段結算(Day 11–20)

交付 關鍵技術
11 深色模式 CSS 變數 + 對比度驗證腳本
12 全站搜尋 bigram 倒排索引 + 原文驗證排序
13 統計數值表 erf / 不完全 gamma / 不完全 beta + 二分反函數
14 章末總測驗 正解用 id、種子亂序、狀態機
15 名詞對照頁 從課文自動抽取 + 反向索引
16 離線化 自架 MathJax(30→4.2 MB)+ SW + PWA
17 學習儀表板 匯出匯入 + 三張手寫 SVG 圖
18 教學動畫 離線訓練 → 資料檔 → 前端推論
19 效能與無障礙 按需載入(1.52→0.22 MB)、Lighthouse 94/100/100
20 PDF 修復+涵蓋稽核 cmap 反轉、免 root OCR、runtime topic scoring

新增的驗證腳本:對比度、數值基準、glossary 過期、模型推論、標題層級。每個新功能都帶一支驗證——這是 Day 10 那條原則貫穿第二階段的結果。

小結與明天預告

今天的四個可帶走的原則:

  1. 診斷要看工具實際拿到什麼,不要從輸出的樣子猜成因。
  2. 只修明確壞掉的部分,並且要有獨立驗證來否決錯誤的猜測。 破壞性修復比不修復糟。
  3. 預設參數不一定合理——慢得不合理時先查參數。
  4. 可讀不等於已涵蓋——來源與課文要用可重跑索引比較,最後再由人判斷教學深度。

明天進入第三階段:上雲。這網站至今只跑在 python3 -m http.server 上。接下來 10 天要做部署選型、CI、CDN、安全 header、IaC、Serverless 同步、可觀測性。第一站是實測比較四個部署平台,並且說明為什麼我最後用了兩套


上一篇
Day 19 — 效能與無障礙:把 Lighthouse 推到綠
下一篇
Day 21 — 部署選型:四個平台的實測比較
系列文
知識圖譜 : 技能樹式學習歷程22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言