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” )

加入好友
加入社群
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" ) - 儲蓄保險王

sample.zip為sample.docx修改副檔名為.zip
其中的document.xml 內容:

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" ) - 儲蓄保險王

a:blip r:embed=”rId4″ cstate=”print”

目標

  • 從 .docx(或同內容的 .zip)中找出文件內嵌圖片的 rId,
    映射到實際檔案路徑,並讀出圖片位元組。
  • 對比兩種做法:
    1. python-docx 的 qn + ElementTree 的 find/findall
    2. XPath(含前綴版與無前綴 fallback)

可直接貼進 Jupyter 執行。程式避免 Path.resolve 等易踩雷 API。

準備

  • 檔案:sample.docx 或 sample.zip(二擇一;.docx 本質也是 ZIP 容器)
  • 套件:lxml、python-docx
    • 在 Jupyter 可先執行:%pip install lxml python-docx

教學程式(可直接複製執行)

# 若環境未安裝解除註解以下一行
# %pip install lxml python-docx

import zipfile
from pathlib import PurePosixPath
# POSIX = Portable Operating System Interface
# 主要針對類 Unix 系統的介面與行為做規範
# 讓程式在不同 Unix/Unix-like 系統間更容易移植
from lxml import etree
from docx.oxml.ns import qn  # python-docx 內建的前綴URI 轉換

#========================
# 1) 指定你的文件
#========================
# 填入 sample.docx  sample.zip 皆可兩者都是 ZIP 容器
DOCX_OR_ZIP = r"D:\Temp\sample.docx"

assert zipfile.is_zipfile(DOCX_OR_ZIP), "請提供 .docx 或 .zip(OOXML ZIP 容器)路徑"

#========================
# 2) 安全的 ZIP 內相對路徑拼接不使用 resolve
#========================
def join_zip_path(base_dir: str, rel_path: str) -> str | None:
    """
    以 POSIX 規則將 base_dir 與 rel_path 串接並手動規範化 . 與 ..
    回傳 ZipFile.namelist() 可用之相對路徑不以 / 開頭
    
    base='word'rel='media/image1.png''word/media/image1.png'
    base='word'rel='./media/image1.png''word/media/image1.png'(. 會被忽略
    base='word'rel='../media/image2.jpg''media/image2.jpg'往上跳一層
    base='word/charts'rel='../media/image3.png''word/media/image3.png'
    """
    if not rel_path:
        return None
    base = list(PurePosixPath(base_dir).parts)
    rel = list(PurePosixPath(rel_path).parts)
    parts = base[:]
    for seg in rel:
        if not seg or seg == ".":
            continue
        if seg == "..":
            if parts:
                parts.pop()
            continue
        parts.append(seg)
    return "/".join(parts)

#========================
# 3) 讀取核心 XMLword/document.xml  word/_rels/document.xml.rels
#========================
with zipfile.ZipFile(DOCX_OR_ZIP) as z:
    doc_xml = z.read("word/document.xml") #bytes
    rels_xml = z.read("word/_rels/document.xml.rels") #bytes

parser = etree.XMLParser(remove_blank_text=True)
doc_root = etree.fromstring(doc_xml, parser=parser)
rels_root = etree.fromstring(rels_xml, parser=parser)
# lxml.etree._Element

#========================
# 4) 方法一qn + find/findall
# - 不需自行維護 namespaces 字典 {前綴:命名空間URI}
# - 適合抓常見節點與屬性
#========================
def get_rids_with_qn(root_el):
    rids = []
    # a:blip  r:embed
    for blip in root_el.findall(f".//{qn('a:blip')}"):
        # qn('a:blip')
        # '{http://schemas.openxmlformats.org/drawingml/2006/main}blip'
        # blip : lxml.etree._Element
        rid = blip.get( qn("r:embed") ) #'rId4'
        # qn("r:embed")
        # '{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed'
        
        if rid:
            rids.append(rid)
            
    # 某些文件圖片在 pic:pic 節點內
    for blip in root_el.findall(f".//{qn('pic:pic')}//{qn('a:blip')}"):
        # qn('pic:pic')
        # '{http://schemas.openxmlformats.org/drawingml/2006/picture}pic'
        rid = blip.get(qn("r:embed"))
        if rid:
            rids.append(rid)
    return sorted(set(rids))

rids_qn = get_rids_with_qn(doc_root)
print("A) qn + find/findall rId:", rids_qn)
# A) qn + find/findall rId: ['rId4']

#========================
# 5) 方法二XPath
# 5-1) 前綴版可讀性好但需 namespaces 映射
#========================
NS = {
    "w":   "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
    "a":   "http://schemas.openxmlformats.org/drawingml/2006/main",
    "r":   "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
    "pic": "http://schemas.openxmlformats.org/drawingml/2006/picture",
    "wp":  "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
    "a14": "http://schemas.microsoft.com/office/drawing/2010/main",
    "w14": "http://schemas.microsoft.com/office/word/2010/wordml",
} #Dict[前綴,命名空間]

def get_rids_with_xpath_prefix(root_el):
    rids = []
    rids += root_el.xpath("//a:blip/@r:embed", namespaces=NS)
    #要搜尋到: a:blip r:embed="rId4" cstate="print"
    rids += root_el.xpath("//pic:pic//a:blip/@r:embed", namespaces=NS)
    return sorted(set(rids))
"""
在 XPath 裡,/@r:embed 的「/」是必要語法的一部分
表示從前一個步驟選到的節點取其屬性 r:embed 的值」。不能用空格取代
寫法 //a:blip/@r:embed 是正確的:
它會在文件中所有 a:blip 元素上
選取屬性 r:embed 的值例如 "rId4")。
若某個 a:blip 沒有 r:embed該元素就不會產生結果

如果還想同時篩選出具有特定屬性或屬性值的 a:blip可以在元素步驟加條件
只要有 r:embed  a:blip//a:blip[@r:embed] 
同時有 cstate="print"//a:blip[@r:embed][@cstate='print']
#List[ lxml.etree._Element ]
若只想拿到元素節點而不是屬性值),用上面兩個

若要直接拿 rId  //a:blip[@cstate='print']/@r:embed。

//a:blip/@r:embed → 回傳每個 a:blip 的 r:embed 屬性值(如 "rId4")
//a:blip[@r:embed] → 回傳符合條件的 a:blip 元素節點本身(僅篩選出有該屬性的元素)
//a:blip[@cstate='print']/@r:embed → 
先篩選 cstate='print'  a:blip再取其 r:embed 屬性值
"""
rids_xpath_prefix = get_rids_with_xpath_prefix(doc_root)
print("B1) XPath(前綴)rId:", rids_xpath_prefix)

# 5-2) 無前綴 fallback不依賴文件裡的前綴
A = NS["a"]; R = NS["r"]

def get_rids_with_xpath_noprefix(root_el):
    return sorted(set(root_el.xpath(
        "//*[local-name()='blip' and namespace-uri()=$A]"
        "/@*[local-name()='embed' and namespace-uri()=$R]",
        A=A, R=R
    )))

rids_xpath_noprefix = get_rids_with_xpath_noprefix(doc_root)
print("B2) XPath(無前綴)rId:", rids_xpath_noprefix)

#========================
# 6) 合併 rId去重
#========================
rids = sorted(set(rids_qn) | set(rids_xpath_prefix) | set(rids_xpath_noprefix))
print("合併 rId:", rids)

#========================
# 7) 解析 _relsrId -> Target
# - .rels 使用 package relationships 命名空間
# - 三種寫法擇一這裡採用前綴版最直覺
#========================
PKG_REL = "http://schemas.openxmlformats.org/package/2006/relationships"
NS_RELS = {"rel": PKG_REL}

rid_to_target = {
    el.get("Id"): el.get("Target")
    for el in rels_root.xpath("//rel:Relationship", namespaces=NS_RELS)
}

targets = [rid_to_target.get(r) for r in rids]
print("rId → Target:", targets)

#========================
# 8) 轉成 ZIP 內實際路徑 word/ 為基準並讀圖片 bytes
#========================
zip_paths = [join_zip_path("word", t) if t else None for t in targets]
print("ZIP 內圖片路徑:", zip_paths)

images = {}
with zipfile.ZipFile(DOCX_OR_ZIP) as z:
    names = set(z.namelist())
    for p in zip_paths:
        if p and p in names:
            images[p] = z.read(p)

print(f"讀到 {len(images)} 張圖片。前幾個鍵:", list(images.keys())[:3])
# 如需落地存檔可解除以下範例
# for i, (p, data) in enumerate(images.items(), 1):
#     out = f"img_{i}{PurePosixPath(p).suffix}"
#     with open(out, "wb") as f:
#         f.write(data)
#     print("saved:", out)

輸出:

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" ) - 儲蓄保險王

你會學到什麼

  • qn 是什麼、為什麼好用
    • qn 由 python-docx 提供(docx.oxml.ns.qn),
      把 “前綴:名稱(local name)” 轉成
      lxml 需要的擴展 QName(tag) “{uri}local”。
    • 好處:不用自己維護 namespaces 字典;docx 常用前綴(w, a, r, pic, wp …)已內建。
    • 適用:Element/屬性存取(find/findall/get/set)。不適用:XPath 字串內直接用 Clark notation。
  • find/findall 的優勢與侷限
    • 優勢:寫法直覺、學習成本低;搭配 qn 幾乎不會被前綴差異卡住。
    • 侷限:只能走樹狀路徑,複雜條件(AND/OR、位置過濾、聚合)表達力不足。
  • XPath 的兩種寫法與取捨
    • 前綴版:可讀性最好,像 //a:blip/@r:embed;但必須提供 namespaces=…,且文件內前綴要吻合。
    • 無前綴版:用 local-name() + namespace-uri() 避免前綴依賴,較冗長,勝在穩定。
  • .rels 檔的命名空間
  • ZIP 內路徑處理的坑
    • OOXML 是 ZIP 容器,內部路徑一律使用 POSIX「/」。
    • 用 PurePosixPath 規範化相對路徑(處理 ..、.)避免平台差異;不要用 OS 的實體路徑 API 來拼 ZIP 內部路徑。
  • 圖片抽取流程的重要關聯
    • document.xml 中的 a:blip/@r:embed 給出 rId。
    • document.xml.rels 將 rId 對應到 Target 路徑(例如 media/image1.png)。
    • 以 word/ 為基準拼成 ZIP 內實際路徑,最後從 ZIP 讀出位元組。

qn(‘a:blip’) ; qn(“r:embed”) ; qn(‘pic:pic’)

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" ) - 儲蓄保險王

print( etree.tostring(rels_root, encoding=”unicode” , pretty_print=True) )

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" ) - 儲蓄保險王

get_rids_with_qn()
最終可以獲得[‘rId4’]
再看rels_root 的內容
‘rId4’ 對應的Target=”media/image1.jpeg”
若僅修訂 document.xml 內容,讓圖片消失
檔案幾乎不會縮小
要刪除這一張圖,才能對docx瘦身

<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/webSettings" Target="webSettings.xml"/>
  <Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/>
  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
  <Relationship Id="rId6" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="theme/theme1.xml"/>
  <Relationship Id="rId5" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable" Target="fontTable.xml"/>
  <Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.jpeg"/>
</Relationships>

實務建議

  • 初學與日常維護
    • 先選 qn + find/findall:簡單、穩、減少命名空間錯誤;對抓 blip 與 rId 已足夠。
    • 只在需要複雜條件(例如依屬性篩選、一次抓多型資料、跨節點關係)時,才切到 XPath。
  • 撰寫 XPath 的策略
    • 優先用前綴版(可讀性好),並集中定義 namespaces 字典。
    • 需要兼容「前綴不一致或缺失」的文件時,加上無前綴 fallback。兩段都跑、合併去重,實務最穩。
  • 針對 .rels
    • 一律用前綴或無前綴 XPath;或改用 Element.findall 的 Clark notation({uri}tag)但那不是 XPath 字串。
  • 路徑與平台
    • 只要處理 ZIP/OOXML 內部路徑,一律用 POSIX 風格與 PurePosixPath;不要把這些路徑丟給 Windows 路徑 API。
  • 可靠性與除錯
    • 每步都 print 出中間結果(rIds、targets、zip_paths、已讀圖片數量),可快速定位問題點。
    • 用 set 去重,避免重複 rId 或重複圖片。
  • 延伸
    • 若需處理圖表、外部連結、嵌入物件,流程相同:從主文檔取得 rId → 讀對應 .rels → 拼路徑 → 讀取資源。
    • 可將三種取得法封裝為函式與單元測試,之後套在任意 DOCX。

以下逐段與逐個函式解釋用途、重點與常見坑點。
為方便對照,先用小標題標出程式段落名稱,不重貼整段程式碼。

  1. 套件與基本設定
  • 作用
    • 匯入 zipfile:讀 .docx/.zip 內容(.docx 本質是 ZIP)。
    • 匯入 lxml.etree:解析 XML。
    • 匯入 docx.oxml.ns.qn:把 “前綴:名稱” 轉為擴展 QName “{uri}local”,供 Element API 使用。
    • 匯入 PurePosixPath:用 POSIX 規則組合 ZIP 內部路徑。
  • 重點
    • 你可以用 sample.docx 或 sample.zip,兩者 zipfile 都能讀。
    • assert zipfile.is_zipfile 確保路徑真的是 ZIP 容器,早期失敗早期發現。
  1. join_zip_path(base_dir, rel_path)
  • 功能
    • 把 .rels 的 Target(通常是相對路徑,如 media/image1.png 或 ../media/image2.jpg)與基準目錄(word/)以 POSIX 規則結合,並手動處理 . 與 ..。
  • 為什麼不用 os.path 或 Path.resolve
    • OOXML/ZIP 內部路徑永遠是 /,與作業系統無關。用 PurePosixPath 可避免 Windows 反斜線或磁碟機代號污染。
    • 我們只處理 ZIP 內部字串,不需要也不應該觸碰實體檔案系統。
  • 邏輯
    • 拆 base_dir 與 rel_path 的 parts。
    • 迭代 rel_path 的每個片段:
      • “.” 忽略
      • “..” 就把 parts 最後一段 pop 掉(回上一層)
      • 其他字串就 append
    • 最後用 “/” join 回去,得到像 “word/media/image1.png” 這樣的 ZIP 相對路徑。
  • 常見坑
    • rel_path 可能為空或 None,先行判斷。
    • 不回傳以 “/” 開頭的絕對路徑,因為 ZipFile.namelist() 內的名稱都是相對路徑。
  1. 讀取核心 XML(document.xml 與 document.xml.rels)
  • 功能
    • 用 ZipFile 直接讀出兩個檔案:
      • word/document.xml:主文檔,內含段落、圖片錨點等。
      • word/_rels/document.xml.rels:主文檔對外部資源(含圖片)的關聯表。
    • 用 etree.fromstring 解析成元素樹 root。
  • 重點
    • XMLParser(remove_blank_text=True) 幫助後續輸出整齊,但對查詢無影響。
    • 如果 zip 裡少其中一個檔案,這裡就會報錯,屬於早期應該發現的問題。
  1. get_rids_with_qn(root_el) — 方法一:qn + find/findall
  • 功能
    • 在 document.xml 裡找出所有圖片用到的 rId(關聯 ID),來源是 a:blip 節點的 r:embed 屬性。
  • 兩段查找的原因
    • “.//a:blip”:通用情況,多數圖片都會出現在 a:blip。
    • “.//pic:pic//a:blip”:某些文件的圖片包在 pic:pic 節點內層,補抓一次。
  • 為什麼用 qn
    • find/findall 需要 Clark notation “{uri}local”。用 qn(“a:blip”)、qn(“r:embed”) 讓 python-docx 替你把前綴轉 uri,不必自己維護命名空間字典。
  • 回傳值
    • 去重後的 rId 清單(排序穩定,利於除錯與比較)。
  • 常見坑
    • 若文件使用 python-docx 不認得的前綴,qn 會丟 ValueError。但對 OOXML 常見前綴(w、a、r、pic…)是安全的。
  1. get_rids_with_xpath_prefix(root_el) — 方法二之一:XPath(前綴版)
  • 功能
    • 用 XPath 直接抓 //a:blip/@r:embed 與 //pic:pic//a:blip/@r:embed。
  • 為何需要 NS 映射
    • XPath 裡的 a:、r:、pic: 是前綴,不等於實際 URI。必須傳 namespaces=NS 讓引擎知道每個前綴對應的 URI。
  • 優點
    • 表達式短且可讀性高,適合熟悉 XPath 的人,也利於加入條件過濾。
  • 缺點
    • 文件若用非常規前綴(雖然 URI 一樣),寫死的前綴 XPath 還是能用,因為前綴對照由你提供;但你要保證 NS 字典正確且齊全。
  1. get_rids_with_xpath_noprefix(root_el) — 方法二之二:XPath(無前綴 fallback)
  • 功能
    • 用 local-name() 與 namespace-uri(),避免依賴文件內前綴。
  • 表達式解讀
    • 先找所有 blip 元素,且它們的 namespace-uri() 必須等於 A(DrawingML)。
    • 然後取其屬性中名為 embed 且屬性的 namespace-uri() 等於 R(Relationships)的值。
  • 優點
    • 文件即使更換前綴也不受影響;對怪文件更堅固。
  • 缺點
    • 表達式冗長;你仍需要維護關鍵 URI 常數(A、R)。
  1. 合併 rId
  • 為什麼要合併
    • 三種找法可能重疊或取到不同位置的重複 rId。以 set 合併去重,再排序,讓後續映射更乾淨。
  • 好處
    • 即使某一種方法在特定文件失效,其他方法仍可補足,提高穩定度。
  1. 解析 .rels:rId → Target
  • 背景
    • document.xml 本身只有 rId,不知道實際檔案位置。document.xml.rels 把每個 rId 對應到 Target(路徑或 URL)。
  • XPath 的選擇
  • 為什麼不用 “{uri}Relationship” 放在 XPath
    • Clark notation 是 Element API 的語法,XPath 不認得。若這樣寫會報 XPathEvalError。
  • 回傳
    • 建一張 dict:{Id -> Target},例如 {“rId5”: “media/image1.png”}。
  1. 組 ZIP 內實際路徑並讀圖片 bytes
  • 為什麼基準是 “word/”
    • document.xml 位於 word/,它的 .rels 內 Target 通常是相對於 word/ 的路徑(例如 media/image1.png 或 ../media/image2.jpg)。
  • join_zip_path 的用途
    • 把 “word/” 與 Target 正確合併(處理 .. 與 .)。
  • 讀取
    • 用 ZipFile.namelist() 比對檔名存在後,再 read 取出位元組。
    • 收集成 images 字典:{zip內路徑: bytes},方便後續存檔或內存處理。
  • 常見坑
    • 有些 Target 可能指向外部 URL(TargetMode=”External”),這種不是 ZIP 內檔案,read 會失敗。你的程式現在會自動跳過(因為 zip 裡找不到那個名字)。

整體流程圖(文字版)

  • document.xml
    • 找 a:blip → 取 @r:embed → 得到 rId 清單
  • document.xml.rels
    • 查 //Relationship → 用 rId 查到 Target
  • ZIP 內
    • 用 word/ 作基底 + Target → 實際路徑 → 讀出圖片 bytes

什麼時候選哪一種查法

  • 只要「抓 blip 的 r:embed」這類固定任務:優先 qn + find/findall,簡潔且不需 NS 字典。
  • 需要條件化與複合查詢(例如按父節點類型、同時抓多種節點、位置篩選):改用 XPath(前綴版)。
  • 需要兼容未知或奇怪前綴:加跑一次無前綴 XPath,與其他結果 union。

推薦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" ) - 儲蓄保險王

儲蓄保險王

儲蓄險是板主最喜愛的儲蓄工具,最喜愛的投資理財工具則是ETF,最喜愛的省錢工具則是信用卡

You may also like...

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *