這份教學以一份可重現的示範 PDF 為基礎:
– 示範 PDF:`D:\Temp\fitz_demo_tutorial.pdf`
這份 PDF 已先建立好,內容包含:
– 一般文字
– 不同 font 的 heading / body
– 同一行內混用不同 font 的 spans
– 一個用線條畫出的簡單表格,
可用 `page.find_tables()` 示範
![Python PyMuPDF fitz 教學:從pdf中抓文字、抓 fonts、抓表格; pip install PyMuPDF ; import fitz ; text_dict = page.get_text("dict") #type(page) is pymupdf.Page ; blocks:list[dict] = text_dict['blocks'] ; page.find_tables().tables [0].extract() ;如何判斷粗體字? - 儲蓄保險王](https://savingking.com.tw/wp-content/uploads/2026/05/20260527141829_0_5a8a75.png)
目標是把幾個最重要的觀念講清楚:
– `fitz.open()` 怎麼讀 PDF
– `page.get_text(“blocks”)`、
`page.get_text(“dict”)` 差在哪裡
– 文字層級中的 `block -> line -> span`
– 怎麼抓 `font / size / flags / color`
– 怎麼用 `find_tables()` 抓表格
– 怎麼把結果整理成 DataFrame,方便在 Jupyter 驗證
先記住一個很重要的點:
– 安裝時的套件名稱是 `PyMuPDF`
– import 時的模組名稱是 `fitz`
也就是:
“`powershell
pip install PyMuPDF
“`
但程式裡要寫:
“`python
import fitz
“`
這兩個名稱不一樣是正常的,不是你裝錯套件。
## 1. 先用 fitz 把內容寫進 PDF
PyMuPDF 不只是拿來讀 PDF,
也可以直接把文字和線條畫進 PDF。
它的思路比較像「在指定座標作畫」,
不是像 Word 那樣自動流式排版。
最常用的幾個 API 是:
– `doc = fitz.open()`:建立新 PDF
– `page = doc.new_page()`:新增頁面
– `page.insert_text((x, y), text, …)`:把文字畫到指定座標
– `page.draw_line((x0, y0), (x1, y1))`:畫線
– `fitz.get_text_length(text, fontname=…, fontsize=…)`:
計算一段文字大概會佔多寬
– `doc.save(path)`:存檔
### 1.0 先理解:同一 line 有多個 spans 時,通常要自己排
如果同一 line 裡要混用不同 font / size,
通常不是一次 `insert_text()` 就全部搞定,而是:
– 先決定這一行共用的 `y` baseline
– 每個 span 各自呼叫一次 `insert_text()`
– 每寫完一段,就把 `x` 往右推到下一段的起點
也就是:
– 同一 `line`:通常共用同一個 `y`
– 不同 `span`:主要改 `x`
– 新的一行:再把 `y` 往下加固定行距
### 1.0.1 `fitz.get_text_length()`
會幫你算寬度,但不會自動排版
`fitz.get_text_length()` 的角色是:
– 幫你估算某一段文字在指定 font / size 下大概有多寬
它不會自動幫你做:
– 下一個 span 的定位
– 自動換行
– 自動 paragraph 排版
– 自動計算整段 line height
它只是幫你回答:
這段字如果畫上去,大概會有多寬?
所以常見用法是:
page.insert_text((x, y), span_text, fontsize=size, fontname=font)
x += fitz.get_text_length(span_text, fontname=font, fontsize=size)### 1.0.2 同一 line 插入多個 spans 的最小示範
這段 code 很適合直接在 Jupyter 驗證:
import os
import fitz
output_path = r"D:\Temp\fitz_multi_span_line.pdf"
os.makedirs(os.path.dirname(output_path), exist_ok=True)
doc = fitz.open()
page = doc.new_page(width=595, height=842)
y = 100
x = 50
spans = [
{"text": "Mixed font: ", "font": "Helvetica", "size": 12},
{"text": "BOLD_PART", "font": "Helvetica-Bold", "size": 12},
{"text": " normal tail", "font": "Helvetica", "size": 12},
]
for span in spans:
page.insert_text(
(x, y),
span["text"],
fontsize=span["size"],
fontname=span["font"],
)
x += fitz.get_text_length(
span["text"],
fontname=span["font"],
fontsize=span["size"],
)
doc.save(output_path)
doc.close()
print(output_path)fitz_multi_span_line.pdf:
![Python PyMuPDF fitz 教學:從pdf中抓文字、抓 fonts、抓表格; pip install PyMuPDF ; import fitz ; text_dict = page.get_text("dict") #type(page) is pymupdf.Page ; blocks:list[dict] = text_dict['blocks'] ; page.find_tables().tables [0].extract() ;如何判斷粗體字? - 儲蓄保險王](https://savingking.com.tw/wp-content/uploads/2026/05/20260527142621_0_b8ffb4.png)
這段最重要的觀念是:
– `y` 固定,表示它們在同一個 `line`,不是 table 的 `row`
– `x` 每次都往右累加
– 你是靠 `get_text_length()` 手動把下一個 `span` 接上去
– 若要換行,可以利用 `size` 來計算下一行的 `y`:
line_spacing = 1.2 # 常見的標準行距倍率
line_height = spans[0]["size"] * line_spacing
y += line_height # 往下移動到下一行完整示範:
# %%
import os
import fitz
# 準備存檔路徑
output_path = r"D:\Temp\fitz_two_lines_demo.pdf"
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# 建立新 PDF 與頁面
doc = fitz.open()
page = doc.new_page(width=595, height=842)
y = 100
x = 50
# --- 繪製第一行 ---
span_line1 = {"text": "This is the first line.", "font": "Helvetica", "size": 18}
page.insert_text(
(x, y),
span_line1["text"],
fontsize=span_line1["size"],
fontname=span_line1["font"],
)
# --- 計算行高並換行 ---
line_spacing = 1.2 # 常見的標準行距倍率
line_height = span_line1["size"] * line_spacing
y += line_height # 往下移動到下一行的 y 基準線
# x = 50 # 如果需要退回行首,可以將 x 重設 (這邊因為本身 x 沒改變,所以仍是 50)
# --- 繪製第二行 ---
span_line2 = {"text": "This is the second line, perfectly spaced.", "font": "Helvetica", "size": 18}
page.insert_text(
(x, y),
span_line2["text"],
fontsize=span_line2["size"],
fontname=span_line2["font"],
)
# 存檔
doc.save(output_path)
doc.close()
print("PDF 已經成功輸出至:", output_path)fitz_two_lines_demo.pdf:
![Python PyMuPDF fitz 教學:從pdf中抓文字、抓 fonts、抓表格; pip install PyMuPDF ; import fitz ; text_dict = page.get_text("dict") #type(page) is pymupdf.Page ; blocks:list[dict] = text_dict['blocks'] ; page.find_tables().tables [0].extract() ;如何判斷粗體字? - 儲蓄保險王](https://savingking.com.tw/wp-content/uploads/2026/05/20260602090642_0_bb580a.png)
### 1.1 寫入最小文字 PDF
這段 code 可以直接在 Jupyter 驗證:
import os
import fitz
output_path = r"D:\Temp\fitz_write_demo.pdf"
os.makedirs(os.path.dirname(output_path), exist_ok=True)
doc = fitz.open()
page = doc.new_page(width=595, height=842)
page.insert_text(
(50, 60),
"PyMuPDF Demo Title",
fontsize=18,
fontname="Helvetica-Bold",
)
page.insert_text(
(50, 95),
"This is a normal line.",
fontsize=11,
fontname="Times-Roman",
)
page.insert_text(
(50, 125),
"Mixed font: ",
fontsize=12,
fontname="Helvetica",
)
page.insert_text(
(120, 125),
"BOLD_PART",
fontsize=12,
fontname="Helvetica-Bold",
)
page.insert_text(
(195, 125),
" normal tail",
fontsize=12,
fontname="Helvetica",
)
doc.save(output_path)
doc.close()
print(output_path)fitz_write_demo.pdf
![Python PyMuPDF fitz 教學:從pdf中抓文字、抓 fonts、抓表格; pip install PyMuPDF ; import fitz ; text_dict = page.get_text("dict") #type(page) is pymupdf.Page ; blocks:list[dict] = text_dict['blocks'] ; page.find_tables().tables [0].extract() ;如何判斷粗體字? - 儲蓄保險王](https://savingking.com.tw/wp-content/uploads/2026/05/20260527143202_0_e6c834.png)
### 1.2 再讀回來驗證
import fitz
pdf_path = r"D:\Temp\fitz_write_demo.pdf"
doc = fitz.open(pdf_path)
page = doc[0]
print(page.get_text())
doc.close()![Python PyMuPDF fitz 教學:從pdf中抓文字、抓 fonts、抓表格; pip install PyMuPDF ; import fitz ; text_dict = page.get_text("dict") #type(page) is pymupdf.Page ; blocks:list[dict] = text_dict['blocks'] ; page.find_tables().tables [0].extract() ;如何判斷粗體字? - 儲蓄保險王](https://savingking.com.tw/wp-content/uploads/2026/05/20260527143349_0_43588e.png)
### 1.3 用線條加文字做簡單表格
如果你想做一個可以拿來測
`find_tables()` 的簡單 PDF,
可以畫格線再放文字:
import os
import fitz
output_path = r"D:\Temp\fitz_write_table_demo.pdf"
os.makedirs(os.path.dirname(output_path), exist_ok=True)
doc = fitz.open()
page = doc.new_page(width=595, height=842)
left = 50
right = 430
top = 120
row_h = 28
col_x = [left, 180, 300, right]
rows = [top + i * row_h for i in range(5)]
for x in col_x:
page.draw_line((x, rows[0]), (x, rows[-1]), width=1)
for y in rows:
page.draw_line((left, y), (right, y), width=1)
headers = ["Item", "Class", "Capacity"]
for idx, text in enumerate(headers):
page.insert_text(
(col_x[idx] + 8, top + 18),
text,
fontsize=11,
fontname="Helvetica-Bold",
)
data = [
("M", "Module", "16GB"),
("R", "RDIMM", "32GB"),
("U", "UDIMM", "64GB"),
]
for row_idx, row in enumerate(data, start=1):
y = top + row_idx * row_h + 18
for col_idx, value in enumerate(row):
page.insert_text(
(col_x[col_idx] + 8, y),
value,
fontsize=11,
fontname="Helvetica",
)
doc.save(output_path)
doc.close()
print(output_path)fitz_write_table_demo.pdf
![Python PyMuPDF fitz 教學:從pdf中抓文字、抓 fonts、抓表格; pip install PyMuPDF ; import fitz ; text_dict = page.get_text("dict") #type(page) is pymupdf.Page ; blocks:list[dict] = text_dict['blocks'] ; page.find_tables().tables [0].extract() ;如何判斷粗體字? - 儲蓄保險王](https://savingking.com.tw/wp-content/uploads/2026/05/20260527143554_0_1a3ce4.png)
### 1.4 這份教學的示範 PDF也是這樣做出來的
本教學用的 `D:\Temp\fitz_demo_tutorial.pdf`,
本質上也是用:
– `insert_text()` 寫文字
– `draw_line()` 畫表格線
先把頁面內容造出來,
再回頭示範怎麼用 fitz 把它讀出來。
## 2. 先讀 PDF
最基本的起點:
import fitz
pdf_path = r"D:\Temp\fitz_demo_tutorial.pdf"
doc = fitz.open(pdf_path)
print(len(doc))
doc.close()這份示範 PDF 的實際結果是:
![Python PyMuPDF fitz 教學:從pdf中抓文字、抓 fonts、抓表格; pip install PyMuPDF ; import fitz ; text_dict = page.get_text("dict") #type(page) is pymupdf.Page ; blocks:list[dict] = text_dict['blocks'] ; page.find_tables().tables [0].extract() ;如何判斷粗體字? - 儲蓄保險王](https://savingking.com.tw/wp-content/uploads/2026/05/20260527143837_0_ae364b.png)
## 3. 最重要的文字層級觀念
PyMuPDF 在 `dict` 模式下,最常用的層級是:
– `block`
– `line`
– `span`(文字片段)
可以先這樣理解:
– `block`:頁面上的一大塊文字區域
– `line`:block 裡的一行文字
– `span`:同一行裡樣式一致的一小段文字,
也可先把它理解成一個文字片段
如果你熟 `python-docx`,可以先用這個類比來記:
– `fitz` 的 `span`,大致相當於 `python-docx` 的 `run`
– 它們都不是整段 paragraph,
而是段內一小段、可帶自己樣式的文字
也就是:
page
-> blocks
-> lines
-> spans這些名字的確不太能直接望文生義,
原因通常不是概念太玄,
而是它們是各自工具鏈裡沿用很久的技術名詞:
– `python-docx` 的 `run`,比較接近「一段連續套用同一格式的文字」
– `fitz` 的 `span`,比較接近「一小段連續、樣式一致的文字範圍」
所以實務上不要硬背字面意思,
直接記它們在文件裡扮演的角色會比較快:
– `paragraph` / `line`:
比較像你肉眼看到的一整段或一整行
– `run` / `span`:比較像那一段裡面、
樣式沒有變的一小截文字
其中 `span` 最重要;如果要補一個中文對照,
這份教學統一把它叫做「文字片段」,
而 font 資訊通常就在這一層。
## 4. `blocks` 模式:快速,但不夠細
如果你只是想先看大致座標與文字內容,可以用:
import fitz
from typing import TypeAlias
BlockTuple: TypeAlias = tuple[float, float, float, float, str, int, int]
# (x0, y0, x1, y1, text, block_no, block_type)
pdf_path = r"D:\Temp\fitz_demo_tutorial.pdf"
doc = fitz.open(pdf_path)
page = doc[0]
blocks: list[BlockTuple] = page.get_text("blocks")
for block in blocks[:5]:
print(block)
doc.close()這裡不是 `blocks: tuple`,而是:
– `blocks: list[BlockTuple]`
– 每一個 `block` 才是一個 `tuple`
每一個 `block` (也就是 `BlockTuple`) 通常會長這樣:
(x0, y0, x1, y1, text, block_no, block_type)
![Python PyMuPDF fitz 教學:從pdf中抓文字、抓 fonts、抓表格; pip install PyMuPDF ; import fitz ; text_dict = page.get_text("dict") #type(page) is pymupdf.Page ; blocks:list[dict] = text_dict['blocks'] ; page.find_tables().tables [0].extract() ;如何判斷粗體字? - 儲蓄保險王](https://savingking.com.tw/wp-content/uploads/2026/05/20260527145858_0_35db1f.png)
如果把這個 tuple 的型別攤開來看,就是:
tuple[float, float, float, float, str, int, int]依序可以讀成:
– 第 1 個 `float`:`x0`,左上角 x
– 第 2 個 `float`:`y0`,左上角 y
– 第 3 個 `float`:`x1`,右下角 x
– 第 4 個 `float`:`y1`,右下角 y
– 第 5 個 `str`:`text`,這個 block 的文字內容
– 第 6 個 `int`:`block_no`,這個 block 在頁面中的編號
– 第 7 個 `int`:`block_type`,block 類型;
常見好用值是 `0 = text`、`1 = image`、`3 = vector`
這個欄位最實用的地方,是你可以很快先做分流:
– `block_type == 0`:保留成文字分析主流程
– `block_type == 1`:代表這個 block 是圖片,
不要拿去做 font / line / span 分析
– `block_type == 3`:
代表這個 block 是向量圖形,例如線段或矩形
小提醒:
– 如果你的目標是抽正文、抓 `font`、做 `line / span` 分析,
主力幾乎都是 `block_type == 0`
– 如果你的目標是偵測表格結構,就不能只看 `0`,
因為表格格線常和 `vector`(`3`)有關
– 不過如果你是用 `page.find_tables()`,
通常不需要自己手動處理 `block_type == 3`
用途:
– 快速看頁面有哪些文字區塊
– 先抓大致座標
限制:
– 不容易直接拿到每個細碎文字片段的 `font`
– 不適合做 `is_bold` 判斷
– 不適合做 line 級 / span 級分析
所以,如果你要抓 font,主力通常要換成 `dict`。
## 5. `dict` 模式:抓文字與 fonts 的主力
最常用的做法:
import fitz
pdf_path = r"D:\Temp\fitz_demo_tutorial.pdf"
doc = fitz.open(pdf_path)
page = doc[0]
text_dict = page.get_text("dict")
print(text_dict.keys())
doc.close()![Python PyMuPDF fitz 教學:從pdf中抓文字、抓 fonts、抓表格; pip install PyMuPDF ; import fitz ; text_dict = page.get_text("dict") #type(page) is pymupdf.Page ; blocks:list[dict] = text_dict['blocks'] ; page.find_tables().tables [0].extract() ;如何判斷粗體字? - 儲蓄保險王](https://savingking.com.tw/wp-content/uploads/2026/05/20260527150312_0_88628d.png)
這裡真正重要的是 text_dict[“blocks”]: list[dict]
(就型別而言,這是一個裝滿字典的清單 `list[dict]`)。。
![Python PyMuPDF fitz 教學:從pdf中抓文字、抓 fonts、抓表格; pip install PyMuPDF ; import fitz ; text_dict = page.get_text("dict") #type(page) is pymupdf.Page ; blocks:list[dict] = text_dict['blocks'] ; page.find_tables().tables [0].extract() ;如何判斷粗體字? - 儲蓄保險王](https://savingking.com.tw/wp-content/uploads/2026/05/20260527151126_0_71bd98.png)
這表示 `text_dict[“blocks”]` 裡的每個元素,
本身是一個 block dictionary。
以文字 block 來說,這幾個 key 很重要:
– `number`:這個 block 的編號;
在你直接遍歷原始 `text_dict[“blocks”]` 時,
通常會和 list index 一樣,
但如果你先過濾、重排或只取部分 blocks,
就不要把它當成新的 list index
– `type`:block 類型,文字通常是 `0`
– `bbox`:這個 block 的座標範圍
– `lines`:這個 block 底下的 line dictionaries
![Python PyMuPDF fitz 教學:從pdf中抓文字、抓 fonts、抓表格; pip install PyMuPDF ; import fitz ; text_dict = page.get_text("dict") #type(page) is pymupdf.Page ; blocks:list[dict] = text_dict['blocks'] ; page.find_tables().tables [0].extract() ;如何判斷粗體字? - 儲蓄保險王](https://savingking.com.tw/wp-content/uploads/2026/05/20260527152758_0_9f9147.png)
小提醒:這裡不是直接遍歷 `text_dict`,
而是遍歷 `text_dict[“blocks”]`,
因為 `text_dict` 本身是整頁的總 dictionary;
如果你直接 `for x in text_dict:`,
拿到的會是 `width`、`height`、`blocks` 這些 key,
不是 block 內容本身。
text_dict.keys()
# dict_keys(['width', 'height', 'blocks'])
所以資料階層是:
text_dict = page.get_text("dict") #type(page) is pymupdf.Page
-> blocks :list[dict]
-> block :dict #dict_keys(['number', 'type', 'bbox', 'lines'])
-> lines :list[dict]
-> line :dict #dict_keys(['spans', 'wmode', 'dir', 'bbox'])
-> spans :list[dict]
-> span :dict #dict_keys(['size', 'flags', 'bidi', 'char_flags', 'font', 'color', 'alpha', 'ascender', 'descender', 'text', 'origin', 'bbox'])你可以這樣數一頁裡有多少
`text block / line / span`:
import fitz
from typing import Any
pdf_path = r"D:\Temp\fitz_demo_tutorial.pdf"
doc = fitz.open(pdf_path)
for i, page in enumerate(doc, start=1):
# 1. 取得整頁的 dictionary
text_dict: dict[str, Any] = page.get_text("dict")
# 2. 取得所有的 blocks (這裡包含文字、圖片等各種區塊)
all_blocks: list[dict[str, Any]] = text_dict.get("blocks", [])
# 3. 過濾出純文字的 blocks (type == 0)
text_blocks: list[dict[str, Any]] = [
block for block in all_blocks if block.get("type") == 0
]
# 4. 展開所有的 lines
text_lines: list[dict[str, Any]] = []
for block in text_blocks:
lines: list[dict[str, Any]] = block.get("lines", [])
text_lines.extend(lines)
# 5. 計算總數
line_count: int = len(text_lines)
# 計算所有 lines 裡面的 spans 總數
span_count: int = 0
for line in text_lines:
spans: list[dict[str, Any]] = line.get("spans", [])
span_count += len(spans)
print(f"page={i} text_blocks={len(text_blocks)} lines={line_count} spans={span_count}")
doc.close()這份示範 PDF 的實際結果:
![Python PyMuPDF fitz 教學:從pdf中抓文字、抓 fonts、抓表格; pip install PyMuPDF ; import fitz ; text_dict = page.get_text("dict") #type(page) is pymupdf.Page ; blocks:list[dict] = text_dict['blocks'] ; page.find_tables().tables [0].extract() ;如何判斷粗體字? - 儲蓄保險王](https://savingking.com.tw/wp-content/uploads/2026/05/20260527150744_0_0b51d1.png)
如果你想把這個階層看得更具體,
可以直接把每一層的 `.keys()` 攤開來看:
import fitz
pdf_path = r"D:\Temp\fitz_demo_tutorial.pdf"
doc = fitz.open(pdf_path)
page:pymupdf.Page = doc[0]
text_dict = page.get_text("dict")
print("text_dict.keys() =", text_dict.keys())
blocks = text_dict.get("blocks", [])
if blocks:
first_block = blocks[0]
print("first_block.keys() =", first_block.keys())
lines = first_block.get("lines", [])
if lines:
first_line = lines[0]
print("first_line.keys() =", first_line.keys())
spans = first_line.get("spans", [])
if spans:
first_span = spans[0]
print("first_span.keys() =", first_span.keys())
doc.close()你通常會看到接近這種層級:
text_dict.keys() = dict_keys(['width', 'height', 'blocks'])
first_block.keys() = dict_keys(['number', 'type', 'bbox', 'lines'])
first_line.keys() = dict_keys(['spans', 'wmode', 'dir', 'bbox'])
first_span.keys() = dict_keys(['size', 'flags', 'bidi', 'char_flags',
'font', 'color', 'alpha', 'ascender', 'descender',
'text', 'origin', 'bbox'])## 6. `span`(文字片段)為什麼重要
在 `dict` 結構裡,順序不是直接 `block -> span`,而是:
– 先有 `block`
– `block` 裡面有多個 `line`
– 每個 `line` 再往下拆成一個或多個 `spans`
也就是說,`line` 是你肉眼比較容易理解的一整行,
而 `span` 是這一行裡樣式一致的更小單位。
如果一整個 `line` 都是同一種 font / size,
它可能只有 1 個 `span`。
如果同一個 `line` 中途換了 font、size、bold 或其他樣式,
它就常會被拆成多個 `spans`。
先看一個最小示範,
直接觀察 `line` 怎麼拆成 `spans`
import fitz
pdf_path = r"D:\Temp\fitz_demo_tutorial.pdf"
doc = fitz.open(pdf_path)
page = doc[0]
text_dict = page.get_text("dict")
for block in text_dict.get("blocks", []):
if block.get("type") != 0:
continue
for line in block.get("lines", []):
spans = line.get("spans", [])
if not spans:
continue
line_text = "".join(span.get("text", "") for span in spans).strip()
if not line_text:
continue
print(f"line_text={line_text!r}, span_count={len(spans)}")
for idx, span in enumerate(spans, start=1):
print(
f" span{idx}: text={span.get('text', '')!r}, "
f"font={span.get('font', '')!r}, size={span.get('size', 0)}"
)
doc.close()這裡的 `!r` 表示用 `repr(…)` 形式輸出,
會更容易看出單引號、空字串、前後空白,
以及像 `\n` 這類不容易直接看見的字元細節。
如果某一行只有 1 個 `span`,
通常代表這一整行的樣式都一致。
如果某一行印出 `span_count > 1`,
通常就表示這一行中途有樣式切換。
![Python PyMuPDF fitz 教學:從pdf中抓文字、抓 fonts、抓表格; pip install PyMuPDF ; import fitz ; text_dict = page.get_text("dict") #type(page) is pymupdf.Page ; blocks:list[dict] = text_dict['blocks'] ; page.find_tables().tables [0].extract() ;如何判斷粗體字? - 儲蓄保險王](https://savingking.com.tw/wp-content/uploads/2026/05/20260527161451_0_7e42f3.png)
`span` 通常不只這幾個欄位,
first_span.keys() = dict_keys(['size', 'flags', 'bidi', 'char_flags',
'font', 'color', 'alpha', 'ascender', 'descender',
'text', 'origin', 'bbox'])
但這份教學先記最常用、最值得優先看的 6 個:
– `text`
– `font`
– `size`
– `flags`
– `color`
– `bbox`
如果你實際印出 `first_span.keys()`,
也常會看到這些欄位:
– `char_flags`:字元層級的 flags;
但這份教學前面的 bold 判斷主力不是看它
– `origin`:這個 span 的起始座標,
常可理解成文字繪製時的基準點
– `ascender` / `descender`:
字型度量資訊,
做更細的排版或高度推估時才比較有感
– `alpha`:透明度
– `bidi`:雙向文字相關資訊
所以不是漏寫,而是先把主線集中在最常用的欄位上;
如果你目前的目標是抓文字、抓 font、判斷 bold、整理 line,
前面那 6 個通常就最夠用。
最常見的讀法:
import fitz
pdf_path = r"D:\Temp\fitz_demo_tutorial.pdf"
doc = fitz.open(pdf_path)
page = doc[0]
text_dict = page.get_text("dict")
for block in text_dict.get("blocks", []):
if block.get("type") != 0:
continue
for line in block.get("lines", []):
for span in line.get("spans", []):
print({
"text": span.get("text", ""),
"font": span.get("font", ""),
"size": span.get("size", 0),
"flags": span.get("flags", 0),
"color": span.get("color", ""),
"bbox": span.get("bbox", None),
})
doc.close()![Python PyMuPDF fitz 教學:從pdf中抓文字、抓 fonts、抓表格; pip install PyMuPDF ; import fitz ; text_dict = page.get_text("dict") #type(page) is pymupdf.Page ; blocks:list[dict] = text_dict['blocks'] ; page.find_tables().tables [0].extract() ;如何判斷粗體字? - 儲蓄保險王](https://savingking.com.tw/wp-content/uploads/2026/05/20260527162758_0_97585f.png)
## 7. 真實例子:同一 line 裡有多個 spans
這份示範 PDF 的 page 1 有一行混用不同 font。
下面這段 code 可以直接抓出來:
import fitz
pdf_path = r"D:\Temp\fitz_demo_tutorial.pdf"
doc = fitz.open(pdf_path)
page = doc[0]
text_dict:dict = page.get_text("dict")
# dict_keys(['width', 'height', 'blocks'])
for block in text_dict.get("blocks", []):
# block: dict
# dict_keys(['number', 'type', 'bbox', 'lines'])
if block.get("type") != 0:
continue
for line in block.get("lines", []):
# line: dict
# dict_keys(['spans', 'wmode', 'dir', 'bbox'])
spans = line.get("spans", [])
# spans:list[dict]
if len(spans) >= 2:
print("line_text =", "".join(span.get("text", "") for span in spans))
for span in spans:
#span.keys()
#dict_keys(['size', 'flags', 'bidi', 'char_flags', 'font', 'color', 'alpha', 'ascender', 'descender', 'text', 'origin', 'bbox'])
print({
"text": span.get("text", ""),
"font": span.get("font", ""),
"size": span.get("size", 0),
"flags": span.get("flags", 0),
})
doc.close()這份示範 PDF 的實際結果是:
![Python PyMuPDF fitz 教學:從pdf中抓文字、抓 fonts、抓表格; pip install PyMuPDF ; import fitz ; text_dict = page.get_text("dict") #type(page) is pymupdf.Page ; blocks:list[dict] = text_dict['blocks'] ; page.find_tables().tables [0].extract() ;如何判斷粗體字? - 儲蓄保險王](https://savingking.com.tw/wp-content/uploads/2026/05/20260528085025_0_1c045e.png)
這就是為什麼:
– `line` 是你最後想輸出的可讀粒度
– `span` 是你拿 `font / flags / is_bold` 的來源
## 8. 怎麼做 line 級輸出
實務上很常見的做法是:
– 底層讀 `span`
– 最後輸出成 `line` 一列
最小示範:
# %%
import fitz
import pandas as pd
pdf_path = r"D:\Temp\fitz_demo_tutorial.pdf"
doc = fitz.open(pdf_path)
rows = []
for page_no, page in enumerate(doc, start=1):
text_dict = page.get_text("dict")
for block_no, block in enumerate(text_dict.get("blocks", [])):
if block.get("type") != 0:
continue
for line_no, line in enumerate(block.get("lines", [])):
spans = line.get("spans", [])
if not spans:
continue
line_text = "".join(span.get("text", "") for span in spans)
if not line_text.strip():
continue
main_span = max(spans, key=lambda s: len(s.get("text", "")))
span_texts = [span.get("text", "") for span in spans]
span_fonts = [span.get("font", "") for span in spans]
unique_fonts = sorted(set(span_fonts))
font_char_counts = {}
for span in spans:
font_name = span.get("font", "")
font_char_counts[font_name] = font_char_counts.get(font_name, 0) + len(span.get("text", ""))
fonts_summary = ", ".join(
f"{font_name}({char_count} chars)"
for font_name, char_count in font_char_counts.items()
)
rows.append({
"page": page_no,
"block_no": block_no,
"line_no": line_no,
"text": line_text,
"font_main": main_span.get("font", ""),
"size_main": main_span.get("size", 0),
"flags_main": main_span.get("flags", 0),
"span_count": len(spans),
"span_texts": span_texts,
"span_fonts": span_fonts,
"font_count": len(unique_fonts),
"has_mixed_fonts": len(unique_fonts) >= 2,
"fonts_summary": fonts_summary,
})
doc.close()
df = pd.DataFrame(rows)
df[df["span_count"] >= 2][
[
"page",
"block_no",
"line_no",
"text",
"span_count",
"fonts_summary",
"span_texts",
"span_fonts",
"has_mixed_fonts",
]
].head(10)這段比前一版更適合在 Jupyter 驗證,
因為它不只保留整條 `line` 的文字,
還把每個 `span` 的 `text / font` 一起攤開來看。
![Python PyMuPDF fitz 教學:從pdf中抓文字、抓 fonts、抓表格; pip install PyMuPDF ; import fitz ; text_dict = page.get_text("dict") #type(page) is pymupdf.Page ; blocks:list[dict] = text_dict['blocks'] ; page.find_tables().tables [0].extract() ;如何判斷粗體字? - 儲蓄保險王](https://savingking.com.tw/wp-content/uploads/2026/05/20260528090644_0_376f59.png)
程式裡仍然有保留
`font_main / size_main / flags_main` 這些欄位,
方便你後面做較精簡的 line 級摘要;
只是這裡最後顯示的 DataFrame,
先刻意改成偏向教學觀察用途,
把多 `span`、多 `font` 的細節攤開來看。
例如像 `BOLD_PART and normal tail.` 這一列,
你就更容易看出:
– 它仍然是同一個 `line`
– 但 `span_count == 2`
– `fonts_summary` 會接近
`Helvetica-Bold(9 chars), Helvetica(18 chars)`
– `span_texts` 會接近 `[‘BOLD_PART’, ‘ and normal tail.’]`
– `span_fonts` 會接近 `[‘Helvetica-Bold’, ‘Helvetica’]`
– `has_mixed_fonts == True`
### 8.1 實務版:把巢狀走訪封裝成 iterator
如果你覺得 3~4 層 `for` 太深,
實務上常做法是把「資料結構走訪」和「業務邏輯」拆開。
– 走訪層:集中在一個 `iter_text_lines()`
– 業務層:主流程只處理 `spans`、統計欄位、組 `rows`
這樣的好處是:
– 主流程可讀性更高
– 之後要改遍歷規則(例如跳過特定 block)只改一個函式
– 不同報表可重用同一個 iterator
可直接貼去跑的版本:
import fitz
import pandas as pd
from typing import Any, Iterator
def iter_text_lines(doc: fitz.Document) -> Iterator[tuple[int, int, int, dict[str, Any]]]:
for page_no, page in enumerate(doc, start=1):
text_dict: dict[str, Any] = page.get_text("dict")
for block_no, block in enumerate(text_dict.get("blocks", [])):
if block.get("type") != 0:
continue
for line_no, line in enumerate(block.get("lines", [])):
yield page_no, block_no, line_no, line
pdf_path = r"D:\Temp\fitz_demo_tutorial.pdf"
doc = fitz.open(pdf_path)
rows = []
for page_no, block_no, line_no, line in iter_text_lines(doc):
spans = line.get("spans", [])
if not spans:
continue
line_text = "".join((span.get("text") or "") for span in spans)
if not line_text.strip():
continue
main_span = max(spans, key=lambda s: len(s.get("text") or ""))
span_texts = [(span.get("text") or "") for span in spans]
span_fonts = [(span.get("font") or "") for span in spans]
unique_fonts = sorted(set(span_fonts))
font_char_counts: dict[str, int] = {}
for span in spans:
font_name = span.get("font") or "<UNKNOWN_FONT>"
span_text = span.get("text") or ""
font_char_counts[font_name] = font_char_counts.get(font_name, 0) + len(span_text)
fonts_summary = ", ".join(
f"{font_name}({char_count} chars)"
for font_name, char_count in font_char_counts.items()
)
rows.append({
"page": page_no,
"block_no": block_no,
"line_no": line_no,
"text": line_text,
"font_main": main_span.get("font") or "",
"size_main": main_span.get("size", 0),
"flags_main": main_span.get("flags", 0),
"span_count": len(spans),
"span_texts": span_texts,
"span_fonts": span_fonts,
"font_count": len(unique_fonts),
"has_mixed_fonts": len(unique_fonts) >= 2,
"fonts_summary": fonts_summary,
})
doc.close()
df = pd.DataFrame(rows)
df[df["span_count"] >= 2][
[
"page",
"block_no",
"line_no",
"text",
"span_count",
"fonts_summary",
"span_texts",
"span_fonts",
"has_mixed_fonts",
]
].head(10)## 9. 怎麼判斷 bold
在前面的實驗裡,
`flags & 16` 和 bold font 高度一致。
最常用的示範寫法:
def is_bold_span(font_name: str, flags: int) -> bool:
lower_font_name = font_name.lower()
if "bold" in lower_font_name:
return True
return bool(flags & 16)幾個重點:
– `font_name` 含 `Bold`,通常是主訊號
– `flags & 16` 常可作為 fallback
– 也可能出現 `font_name` 不含 `Bold`,
但 `flags == 16` 的情況,所以不要只靠字型名稱字串
– 這個 `is_bold_span()` 是看 `font_name` 和 `flags`,
不是看 `char_flags`
– `flags & 16` 回傳的原本是整數,不是布林
– `bool(flags & 16)` 才會變成 `True / False`
例如:
print(16 & 16) # 16
print(bool(16 & 16)) # True
print(17 & 16) # 16
print(bool(17 & 16)) # True
print(4 & 16) # 0
print(bool(4 & 16)) # False### 9.1 官方依據(為什麼是檢查 16)
這不是經驗猜測,PyMuPDF 文件有明確定義:
– TextPage 的 span dictionary:
`flags` 的 bit 對應中,`bit 4 = bold`(0-based)
– https://pymupdf.readthedocs.io/en/latest/textpage.html#span-dictionary
– Constants and Enumerations 的 Font Properties:
`TEXT_FONT_BOLD = 16`
– https://pymupdf.readthedocs.io/en/latest/vars.html#font-properties
因此 `flags & 16` 本質上就是在檢查
`TEXT_FONT_BOLD` 這個旗標位是否被設為 1。
你也可以用二進位直觀確認:
`bin(16) == ‘0b10000’`,
代表只有從右邊數第 5 位(0-based 的 bit 4)是 1;
所以 `flags & 16` 就是在測這一位。
另外,官方在文字教學也有示範
把 `flags` 當 bit field 拆解(含 `2**4` 對應 bold):
– https://pymupdf.readthedocs.io/en/latest/recipes-text.html#how-to-analyze-font-characteristics
## 10. 怎麼抓表格
PyMuPDF 新版很方便的一點,是可以直接用:
page.find_tables()但這裡可以拆細一點看,
不然第一次在 Jupyter 印結果時很容易混掉:
– `page.find_tables()` 回傳的不是 table list,而是一個 `TableFinder` 物件
– `page.find_tables().tables` 才是「這一頁找到的 tables 清單」
– 清單裡的每一個元素,才是一個 `Table` 物件
也就是說,你可以先這樣理解:
page.find_tables()
-> TableFinder
-> .tables
-> [Table, Table, ...]所以你在 Jupyter 裡看到:
– `page.find_tables()` 印出像 `<pymupdf.table.TableFinder …>`
– `page.find_tables().tables` 印出像 `[<pymupdf.table.Table …>]`
這是正常的。
最常見的拆法是:
tables_result = page.find_tables() # TableFinder
tables = tables_result.tables # list[Table]
first_table = tables[0] # Table
rows = first_table.extract() # list[list[str]]這裡的 `rows = first_table.extract()` 可以再拆細成:
– `rows`:整張表的所有列,所以是 `list[…]`
– `rows[0]`:第 1 列,通常是 header
– `rows[1]`:第 2 列,通常是第 1 筆資料
– `rows[i]` 裡面的每個元素:該列中的一個 cell,所以內層又是 `list[str]`
也就是說,它不是 DataFrame,也不是 dict,而是很單純的「二維 list」:
rows
-> [row0, row1, row2, ...]
-> [cell0, cell1, cell2, ...]以這份示範 PDF 來說,`rows` 會接近:
[
['Item', 'Class', 'Capacity'],
['M', 'Module', '16GB'],
['R', 'RDIMM', '32GB'],
['U', 'UDIMM', '64GB'],
]所以你也可以這樣直接驗證:
print(rows)
print(rows[0]) # header row
print(rows[1]) # first data row
print(rows[1][0]) # first cell of first data row![Python PyMuPDF fitz 教學:從pdf中抓文字、抓 fonts、抓表格; pip install PyMuPDF ; import fitz ; text_dict = page.get_text("dict") #type(page) is pymupdf.Page ; blocks:list[dict] = text_dict['blocks'] ; page.find_tables().tables [0].extract() ;如何判斷粗體字? - 儲蓄保險王](https://savingking.com.tw/wp-content/uploads/2026/05/20260528092420_0_f125df.png)
最小示範:
import fitz
pdf_path = r"D:\Temp\fitz_demo_tutorial.pdf"
doc = fitz.open(pdf_path)
page = doc[0]
tables_result = page.find_tables()
tables = tables_result.tables
print(len(tables))
for table in tables:
print(table.bbox)
for row in table.extract():
print(row)
doc.close()這份示範 PDF 的實際結果:
– `page 1` 抓到 `1` 個 table
– `bbox = (50.0, 260.0, 430.0, 372.0)`
抽取結果:
![Python PyMuPDF fitz 教學:從pdf中抓文字、抓 fonts、抓表格; pip install PyMuPDF ; import fitz ; text_dict = page.get_text("dict") #type(page) is pymupdf.Page ; blocks:list[dict] = text_dict['blocks'] ; page.find_tables().tables [0].extract() ;如何判斷粗體字? - 儲蓄保險王](https://savingking.com.tw/wp-content/uploads/2026/05/20260528091211_0_d7f7d7.png)
## 11. 轉成 DataFrame
表格一旦抓到,最常見的下一步就是直接轉 DataFrame:
import fitz
import pandas as pd
pdf_path = r"D:\Temp\fitz_demo_tutorial.pdf"
doc = fitz.open(pdf_path)
page = doc[0]
tables = page.find_tables().tables
if tables:
table_df = pd.DataFrame(tables[0].extract())
print(table_df)
doc.close()![Python PyMuPDF fitz 教學:從pdf中抓文字、抓 fonts、抓表格; pip install PyMuPDF ; import fitz ; text_dict = page.get_text("dict") #type(page) is pymupdf.Page ; blocks:list[dict] = text_dict['blocks'] ; page.find_tables().tables [0].extract() ;如何判斷粗體字? - 儲蓄保險王](https://savingking.com.tw/wp-content/uploads/2026/05/20260528091354_0_37beda.png)
如果你想把第一列當 header:
import fitz
import pandas as pd
pdf_path = r"D:\Temp\fitz_demo_tutorial.pdf"
doc = fitz.open(pdf_path)
page = doc[0]
tables = page.find_tables().tables
if tables:
rows = tables[0].extract()
header = rows[0]
body = rows[1:]
table_df = pd.DataFrame(body, columns=header)
print(table_df)
doc.close()![Python PyMuPDF fitz 教學:從pdf中抓文字、抓 fonts、抓表格; pip install PyMuPDF ; import fitz ; text_dict = page.get_text("dict") #type(page) is pymupdf.Page ; blocks:list[dict] = text_dict['blocks'] ; page.find_tables().tables [0].extract() ;如何判斷粗體字? - 儲蓄保險王](https://savingking.com.tw/wp-content/uploads/2026/05/20260528092605_0_bd13bb.png)
## 12. 表格碎片的現實問題
`find_tables()` 很方便,
但實務上常見這些問題:
– 會抓到殘缺 table
– 會抓到只有一兩個值的碎片
– 同一頁可能被切成太多小表格
因此常要加一層清理,例如:
import pandas as pd
def normalize_table_df(table_df: pd.DataFrame) -> pd.DataFrame:
normalized_df = table_df.copy()
normalized_df = normalized_df.map(
lambda value: value.strip() if isinstance(value, str) else value
)
normalized_df = normalized_df.replace("", pd.NA)
normalized_df = normalized_df.dropna(axis=0, how="all")
normalized_df = normalized_df.dropna(axis=1, how="all")
return normalized_df.reset_index(drop=True)
def is_meaningful_table(table_df: pd.DataFrame) -> bool:
if table_df.empty:
return False
# pandas DataFrame 的 .sum() 行為說明:
# 1. table_df.notna():把整個表格所有的格子變成 True/False (有值為 True, 沒值或 NA 為 False)
# 2. 第一次 .sum():會「逐個直行(column)」把 True 當成 1 算總和,回傳的是一個 Series,例如:colA 有 2 個值, colB 有 1 個值
# 3. 第二次 .sum():把剛剛算出來的 Series (每一行的總數) 再全部加總起來,變成一個單一的整數 (整張表有幾個非空值)
non_empty_cells = int(table_df.notna().sum().sum())
# 這裡的邏輯是:如果整張表格有值(非空)的格子加起來不到 3 個,我們就當它是誤判的垃圾碎片
return non_empty_cells >= 3## 13. 一次抓完整頁面:文字 + 表格
這是最接近實務版的最小骨架:
import fitz
import pandas as pd
pdf_path = r"D:\Temp\fitz_demo_tutorial.pdf"
doc = fitz.open(pdf_path)
raw_rows = []
tables_index = []
for page_no, page in enumerate(doc, start=1):
text_dict = page.get_text("dict")
for block_no, block in enumerate(text_dict.get("blocks", [])):
if block.get("type") != 0:
continue
for line_no, line in enumerate(block.get("lines", [])):
spans = line.get("spans", [])
if not spans:
continue
text = "".join(span.get("text", "") for span in spans)
if not text.strip():
continue
main_span = max(spans, key=lambda s: len(s.get("text", "")))
raw_rows.append({
"page": page_no,
"block_no": block_no,
"line_no": line_no,
"text": text,
"font_main": main_span.get("font", ""),
"flags_main": main_span.get("flags", 0),
})
tables = page.find_tables().tables
for table_idx, table in enumerate(tables, start=1):
table_df = pd.DataFrame(table.extract())
tables_index.append({
"page": page_no,
"table_id": f"table{table_idx}",
"bbox": table.bbox,
"row_count": table_df.shape[0],
"col_count": table_df.shape[1],
})
doc.close()
raw_text_df = pd.DataFrame(raw_rows)
tables_index_df = pd.DataFrame(tables_index)
print(raw_text_df.head())
print(tables_index_df)![Python PyMuPDF fitz 教學:從pdf中抓文字、抓 fonts、抓表格; pip install PyMuPDF ; import fitz ; text_dict = page.get_text("dict") #type(page) is pymupdf.Page ; blocks:list[dict] = text_dict['blocks'] ; page.find_tables().tables [0].extract() ;如何判斷粗體字? - 儲蓄保險王](https://savingking.com.tw/wp-content/uploads/2026/05/20260528092841_0_6005bb.png)
## 14. 建議的學習順序
如果你剛開始碰 fitz,建議順序是:
1. 先會 `fitz.open()` 與逐頁迭代
2. 先看 `page.get_text(“blocks”)`,理解頁面上有哪些文字區塊
3. 再切到 `page.get_text(“dict”)`,理解 `block -> line -> span`
4. 確認 `font / size / flags / color` 都在 `span`
5. 先做 line 級整理,再談 `font_main / is_bold`
6. 之後再接 `page.find_tables()` 與表格清理
## 15. 這份教學最該記住的事
– 抓 font 時,主力要看 `span`
– 最後輸出通常不要太碎,常整理成 `line`
– `blocks` 適合先看大區塊,不適合細緻 font 分析
– `find_tables()` 很方便,但常需要後處理
– 表格與一般文字通常應分流保存
– Jupyter 很適合先驗證單頁、單 line、單 table 的結果,再做成完整腳本
推薦hahow線上學習python: https://igrape.net/30afN





![Python TQC考題610 平均溫度,不要自找麻煩用2D list做,可練習2D轉1D: 一維串列.extend(二維串列[index]) Python TQC考題610 平均溫度,不要自找麻煩用2D list做,可練習2D轉1D: 一維串列.extend(二維串列[index])](https://i2.wp.com/savingking.com.tw/wp-content/uploads/2022/05/20220515192908_35.png?quality=90&zoom=2&ssl=1&resize=350%2C233)


![使用 Python 檢驗字符串格式:掌握正則表達式(Regular Expression)的起始^與終止$符號, pattern = r’^GATR[0-9]{4}$’ 使用 Python 檢驗字符串格式:掌握正則表達式(Regular Expression)的起始^與終止$符號, pattern = r’^GATR[0-9]{4}$’](https://i0.wp.com/savingking.com.tw/wp-content/uploads/2024/07/20240712093637_0.png?quality=90&zoom=2&ssl=1&resize=350%2C233)

近期留言