Deep Dive into extract_image_bytes: How python-docx Handles Images
在處理 Word 自動化時,我們常會遇到一個需求:
「我想要取得文件裡面的這張圖片原始檔」。
但 python-docx 的高層 API (例如 run.picture) 並沒有直接提供 .save() 這樣的方法。
這篇教學將帶您深入 Word 文檔的底層結構 (Relationships與Parts),
並實作一個能從任何 .docx 檔中「無損提取」圖片的函式。
我們將會經歷以下步驟:
- 建立測試場景:用 Python 自動在
D:\Temp生成一個含有圖片的 Word 檔。 - 剖析原理:解釋
rId(關聯ID) 與Part(零件) 的關係。 - 實作核心函式:撰寫
extract_image_bytes。 - 驗證結果:將提取出來的 bytes 轉回圖片顯示,證明提取成功。
# 1. 環境設定與匯入套件
# 我們需要 docx 來操作 Word,PIL 來生成/驗證圖片,io 來處理記憶體內的二進位流
from docx import Document
from docx.shared import Inches
from PIL import Image, ImageDraw
import io
import os
# 設定測試檔案路徑
temp_dir = r"D:\Temp"
docx_path = os.path.join(temp_dir, "demo_extraction.docx")
# 確保目錄存在
os.makedirs(temp_dir, exist_ok=True)
print(f"工作目錄已準備: {temp_dir}")2. 製作測試用的 DOCX 檔案
為了確保大家都能跟著做,我們先用程式碼產生一個「含有圖片」的 Word 檔。
這張圖片會是一張紅色背景,上面寫著 “SECRET” 的 PNG 圖。
# 2. 生成測試用的 docx 檔案
# A. 用 PIL 畫一張圖
img = Image.new('RGB', (300, 100), color=(255, 100, 100)) # 紅色背景
d = ImageDraw.Draw(img)
d.text((10, 40), "SECRET IMAGE DATA", fill=(255, 255, 255))
# B. 存入記憶體 (BytesIO)
img_byte_arr = io.BytesIO()
img.save(img_byte_arr, format='PNG')
img_byte_arr.seek(0) # 倒帶回開頭,準備給 python-docx 讀取
# C. 寫入 Word 檔
doc = Document()
doc.add_paragraph("這是一份機密文件,下方藏著一張圖片:")
run = doc.add_paragraph().add_run()
run.add_picture(img_byte_arr, width=Inches(3.0)) # 插入圖片
doc.add_paragraph("圖片已插入完畢。")
doc.save(docx_path)
print(f"已生成測試文件: {docx_path}")demo_extraction.docx:

3. 核心解密:extract_image_bytes 函式
這是本教學的重點。在 docx 的 XML 結構中,
圖片並不是直接嵌在文字旁邊的,
而是存放在一個獨立的資料夾 (word/media/),
並透過 rId (Relationship ID) 來連結。
流程如下:
- Tag (
<w:drawing>與<a:blip>):<w:drawing>是外層容器,代表這裡有一個「繪圖物件」(可能含圖片、圖表或文字方塊)。- 真正藏著
rId的是內層的<a:blip r:embed="rId7">(BiLIP – Binary Large Image Picture) 標籤。 - 程式必須先找到 drawing,再往裡面挖到 blip,才能拿到那是哪張圖的代號。
- Rels (
.rels):DocumentPart(主文件) 有一張對照表 (Relationships),查表可知rId7指向哪一個檔案零件。 - Part (
ImagePart):找到該零件後,它就是一個存放二進位資料的物件。 - Blob (
.blob):這個屬性就是圖片真正的 Raw Data。
from typing import Optional
from docx.document import Document as DocxDocument
def extract_image_bytes(doc: DocxDocument, rid: str) -> Optional[bytes]:
"""
用 rId 從 document part relationships 找出對應的圖片零件 (Part),再取出內容。
參數:
- doc: DocxDocument 物件
- rid: 關係 ID (如 'rId4')
回傳:
- bytes: 圖片的原始二進位資料
"""
# 1. 取得關聯與零件
# doc.part.rels 是一個字典,存放著 rId -> Relationship 的對應
"""
{'rId3': <docx.opc.rel._Relationship at 0x1ec41ac0250>,
'rId4': <docx.opc.rel._Relationship at 0x1ec41ac2950>,
'rId5': <docx.opc.rel._Relationship at 0x1ec41ac00d0>,
'rId6': <docx.opc.rel._Relationship at 0x1ec41ac0150>,
'rId7': <docx.opc.rel._Relationship at 0x1ec41ac01d0>,
'rId8': <docx.opc.rel._Relationship at 0x1ec41ac0390>,
'rId1': <docx.opc.rel._Relationship at 0x1ec41ac0490>,
'rId2': <docx.opc.rel._Relationship at 0x1ec41ac0290>,
'rId9': <docx.opc.rel._Relationship at 0x1ec41ac0690>}
"""
if rid not in doc.part.rels:
return None
rel = doc.part.rels[rid]
""" docx.opc.rel._Relationship
vars(rel) or rel.__dict__
{'_rId': 'rId9',
'_reltype': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image',
'_target': <docx.parts.image.ImagePart at 0x1ec41ab5810>,
'_baseURI': '/word',
'_is_external': False}
"""
# 透過 relationship 找到目標零件 (Target Part)
# 這裡的 target_part 通常是 docx.parts.image.ImagePart 類別的實例
target_part = rel.target_part
"""為什麼使用 rel.target_part ,而非 rel.target
確保拿到物件:.target_part 是一個公開的屬性 (Property),
它的工作就是「掛保證」。不管底層現在存的是字串還是還沒初始化的東西,
它會負責弄出一個完整的 Part 物件給您。
避免碰到內部實作:底層的 _target 是內部實作細節,未來可能會改名或改變行為,
但 .target_part 是對外的承諾介面,使用它最安全穩定。
使用 dir(rel) 就可以看到 target_part 這個屬性
vars(target_part) or target_part.__dict__
{'_partname': '/word/media/image1.png',
'_content_type': 'image/png',
'_blob': b'\x89PNG\r\n\x1a\n\x00\x00...',
'_package': None,
'_image': None,
'_rels': {},
'rels': {}}
"""
# 讓我們印出來看看這是什麼東西 (教學用)
print(f"[Debug] rId={rid}")
print(f" -> 對應到 PartName: {target_part.partname}")
print(f" -> 內容類型 ContentType: {target_part.content_type}")
# [重要] 安全機制:檢查這是不是真的圖片
# 有時候 relationship 會指向註腳 (footnotes) 或樣式表,那些也是 XML 但不是圖片
if "image" not in target_part.content_type:
print(f" -> [警告] 這不是圖片,略過 extract。")
return None
# 2. 取出資料
# 使用 .blob (Binary Large Object) 屬性取出二進位資料
#
# Q: 為什麼要用 getattr(target_part, 'blob', None) 而不是直接 target_part.blob ?
# A: 這是一種防禦性寫法。
# 雖然理論上 ImagePart 一定有 .blob,但若是這份文件的部分零件損毀,或 python-docx 版本差異,
# 直接用 .blob 可能會在屬性不存在時拋出 AttributeError 導致程式崩潰。
# getattr() 允許我們設定一個預設值 (None),當屬性找不到時優雅地回傳 None 讓我們處理。
return getattr(target_part, 'blob', None)4. 尋找圖片的 rId
有了提取函式還不夠,我們得先知道「哪裡有圖片」。
這需要深入 XML 節點尋找 <a:blip> 標籤。
以下程式碼示範如何跑遍整份文件,找出所有圖片的 rId。
from docx.oxml.ns import qn
# 讀取我們剛剛做好的文件
doc = Document(docx_path)
found_rids = []
# 遍歷所有段落和 Run
for p in doc.paragraphs:
for run in p.runs:
# 檢查這個 Run 的 XML 裡面有沒有 <w:drawing> (圖片通常包在這個標籤裡)
if 'w:drawing' in run._element.xml:
# 使用 XPath 找出底下的 <a:blip> 標籤
# namespace 注意: 'a' 通常代表 main drawing namespace
blips = run._element.xpath(".//a:blip")
for blip in blips:
# 取得 r:embed 屬性,這就是 rId
rid = blip.get(qn("r:embed"))
if rid:
print(f"找到圖片參考! rId: {rid}")
found_rids.append(rid)
print(f"總共找到 {len(found_rids)} 個圖片參照。")
5. 實際提取與驗證
最後一步,我們使用剛剛寫好的 extract_image_bytes,把找到的 rId 傳進去,看看拿出來的 bytes 能不能還原回原本的 “SECRET” 圖片。
# 5. 驗證結果
if found_rids:
target_rid = found_rids[0] # 取第一個找到的
# === 使用我們的核心函式 ===
image_data = extract_image_bytes(doc, target_rid)
# ========================
if image_data:
print(f"\n成功提取出 {len(image_data)} bytes 的資料!")
# 用 PIL 讀取這些 bytes,看看是不是我們剛剛畫的那張圖
extracted_img = Image.open(io.BytesIO(image_data))
print("提取出的圖片預覽:")
display(extracted_img) # 在 Jupyter 顯示圖片
else:
print("提取失敗,回傳為 None")
else:
print("沒有找到任何圖片 rId,無法測試。")
推薦hahow線上學習python: https://igrape.net/30afN
💡 為什麼 xpath(".//a:blip") 可以直接用?不用傳字典?
這是一個非常好的觀察!
在標準的 lxml 函式庫中,如果 XML 有 namespaces,通常我們必須這樣寫,非常繁瑣:
# 標準 lxml 寫法 (需要自己定義字典)
namespaces = {
'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
}
# 每次呼叫都要傳入 namespaces
blips = element.xpath(".//a:blip", namespaces=namespaces)
或者被迫用 local-name() 來繞過 namespace 檢查:
# 繞過 namespaces 的寫法 (雖通用但寫法較長)
blips = element.xpath(".//*[local-name()='blip']")
但在 python-docx 裡,所有我們操作的 XML 元素 (如 run._element) 實際上都是 BaseOxmlElement 的實例。
這個類別的 .xpath() 方法被改寫過了,它內建了一份全域的 Namespace 字典 (包含常用的 w, a, r, wp 等前綴)。
所以當你寫 a:blip 時,python-docx 已經在背後自動幫你把 'a' 翻譯成 'http://schemas.openxmlformats.org/drawingml/2006/main' 了。這就是為什麼語法可以保持這麼簡潔,而我們在 nsmap 屬性中卻看不到這些定義的原因 (因為它們被註冊在 Python 程式碼的全域設定裡,而不是寫死在單一 XML 節點上)。
5. 實際提取與驗證
最後一步,我們使用剛剛寫好的 extract_image_bytes,把找到的 rId 傳進去,看看拿出來的 bytes 能不能還原回原本的 “SECRET” 圖片。
# 5. 驗證結果
if found_rids:
target_rid = found_rids[0] # 取第一個找到的
# === 使用我們的核心函式 ===
image_data = extract_image_bytes(doc, target_rid)
# ========================
if image_data:
print(f"\n成功提取出 {len(image_data)} bytes 的資料!")
# 用 PIL 讀取這些 bytes,看看是不是我們剛剛畫的那張圖
extracted_img = Image.open(io.BytesIO(image_data))
print("提取出的圖片預覽:")
display(extracted_img) # 在 Jupyter 顯示圖片
else:
print("提取失敗,回傳為 None")
else:
print("沒有找到任何圖片 rId,無法測試。")
推薦hahow線上學習python: https://igrape.net/30afN



![Python 讀取 DOCX 圖片關聯:qn+find/findall 與 XPath 的實戰對照 from lxml import etree ; from docx.oxml.ns import qn; lxml.etree._Element.findall( f”.//{ qn(‘a:blip’) }” ) ; .get( qn(“r:embed”) ) #獲取 屬性名 ‘r:embed’ 的 屬性值(如: ‘rId4’) ; lxml.etree._Element.xpath( “//a:blip/@r:embed”, namespaces = NS) #/@r:embed = 獲取 屬性名 ‘r:embed’ 的 屬性值(如: ‘rId4’),使用.findall() 要先.findall()獲取List[_Element],再迴圈_Element.get()獲取屬性值, .xpath() 第一個參數path 使用”//a:blip/@r:embed” ,可直接獲取屬性值(List[str]如: [‘rId4’, ‘rId5’]) ; 如何對docx真實移除圖片瘦身? Python 讀取 DOCX 圖片關聯:qn+find/findall 與 XPath 的實戰對照 from lxml import etree ; from docx.oxml.ns import qn; lxml.etree._Element.findall( f”.//{ qn(‘a:blip’) }” ) ; .get( qn(“r:embed”) ) #獲取 屬性名 ‘r:embed’ 的 屬性值(如: ‘rId4’) ; lxml.etree._Element.xpath( “//a:blip/@r:embed”, namespaces = NS) #/@r:embed = 獲取 屬性名 ‘r:embed’ 的 屬性值(如: ‘rId4’),使用.findall() 要先.findall()獲取List[_Element],再迴圈_Element.get()獲取屬性值, .xpath() 第一個參數path 使用”//a:blip/@r:embed” ,可直接獲取屬性值(List[str]如: [‘rId4’, ‘rId5’]) ; 如何對docx真實移除圖片瘦身?](https://i2.wp.com/savingking.com.tw/wp-content/uploads/2025/11/20251119130848_0_3fbf6b.png?quality=90&zoom=2&ssl=1&resize=350%2C233)






近期留言