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真實移除圖片瘦身? - 儲蓄保險王

sample.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真實移除圖片瘦身? - 儲蓄保險王

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") ) #獲取 屬性名 '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真實移除圖片瘦身? - 儲蓄保險王

部分XML內容:

<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
  <a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">
    <pic:pic xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture">
      <pic:nvPicPr>
        <pic:cNvPr id="1" name="圖片 1"/>
        <pic:cNvPicPr/>
      </pic:nvPicPr>
      <pic:blipFill>
        <a:blip r:embed="rId4" cstate="print">

a:blip r:embed=”rId4″ cstate=”print”
# a:blip這個元素,有一個r:embed屬性,其屬性值為 “rId4”
# a:blip 有一個祖先元素為 w:drawing
# w:drawing的父元素為w:r, 但可以使用.getparent()自動獲取
# w:r .remove() 清理掉w:drawing以下的所有子孫節點
# 可以修訂document.xml 內容 ,但僅視覺效果,尚未清理實體資源
# 內容變少,但容量卻幾乎沒有減少
# 務必記住這個XML的結構
# 留意其祖先:

<a:graphic xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">

有一個屬性xmlns:a (#xml name space)
其屬性值為
“http://schemas.openxmlformats.org/drawingml/2006/main”
這代表a前綴的命名空間為
“http://schemas.openxmlformats.org/drawingml/2006/main”
勿混淆a:graphicData:

<a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture">

這裡的 uri 並不是 XML 命名空間的宣告 (xmlns)

不能看 a:graphicDatauri 屬性來決定 a: 的命名空間,原因有二:

  1. 那個 uri 是一個普通屬性值,不是 xmlns 宣告。
  2. a: 的定義是由上層的 xmlns:a 決定的,
    它始終指向 DrawingML Main (.../2006/main)。

document.xml.rels:

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真實移除圖片瘦身? - 儲蓄保險王

用 VS Code 開啟該檔案。
按下快捷鍵 Shift + Alt + F
或者在編輯區按右鍵,
選擇「格式化文件 (Format Document)」。

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真實移除圖片瘦身? - 儲蓄保險王

元素 Relationships xmlns=”http://schemas.openxmlformats.org/package/2006/relationships”
有一個子元素:
Id=”rId4″ ;
Target=”media/image1.jpeg”

D:\Temp\sample.zip\word\media
image1.jpeg 容量為1009KB
主要目標就是要移除這個檔案,
才能對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真實移除圖片瘦身? - 儲蓄保險王

從document.xml 知道
a:blip r:embed=“rId4”

再從document.xml.rels 知道
Id=”rId4″ ;
Target=”media/image1.jpeg”

從Target=”media/image1.jpeg”
可以拼接出圖檔路徑
word/media/image1.jpeg
重建zip (docx)時,
遇到該路徑,跳過複製

目標

  • 從 .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)
"""
#remove_blank_text=True
處理 Word/Excel XML 結構更乾淨
使用 getchildren() 或索引存取子元素時
不會意外抓到換行符號當作一個元素
若沒有此設定
會導致解析出來的樹狀結構變得很亂
充滿了無意義的文字節點

給程式讀不需要漂亮格式越緊湊越好
給人讀如果真的要 Debug VS Code 或瀏覽器
      打開 XML 自動格式化一下就好
      不需要在 Python 處理階段浪費資源去排版
"""
doc_root = etree.fromstring(doc_xml, parser=parser)
rels_root = etree.fromstring(rels_xml, parser=parser)
# lxml.etree._Element
"""
parser = etree.XMLParser(remove_blank_text=True)
doc_root = etree.XML(doc_xml, parser=parser)

etree.HTML(html, parser = etree.HTMLParser()) 
用來解析網頁

容錯能力 (寬容度)
etree.XML (嚴格): 如果標籤沒閉合例如 <br> 沒寫成 <br/>),
                 或者語法有錯程式會直接報錯 (Error)。
etree.HTML (寬容): 它是設計來解析網路上亂七八糟的網頁原始碼的
                 遇到沒閉合的標籤或錯誤語法它會嘗試修復而不是報錯
"""

#========================
# 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'
        # {命名空間URI}local_name = tag = 展開的QName
        # QName 是一個概念合格名稱既可以是帶前綴的形式a:blip),
          也可以是展開形式{URI}blip)。
          通俗說法: 元素名稱 (Element Name / Tag Name)
        # blip : lxml.etree._Element
        # qn("r:embed")
        # '{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed'        
        """
        rid = blip.get( qn("r:embed") ) #'rId4'

        if rid:
            rids.append(rid)
    """
    #第一次搜尋: root_el.findall(f".//{qn('a:blip')}")
    .// 代表搜尋 root_el 下所有層級的子節點。
    這意味著它會找出這棵樹裡所有的 <a:blip>,無論它的父節點是誰
    第二個 for 迴圈是完全多餘的
    """    
    # 某些文件圖片在 pic:pic 節點內
    #  root_el 這棵子樹裡搜尋所有層級的節點
    # - 先找出所有 <pic:pic>(不限深度因為使用 .//)
    # - 再在每個 <pic:pic> 之下繼續找其任意深度的 <a:blip>
    # qn('ns:local_name') 會把前綴名轉成 Clark notation 
    # "{namespace-uri}local_name" ElementTree 正確比對
    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")) #'rId4'
        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"
    #取出 屬性名r:embed  屬性值"rId4"
    #List[屬性值] # ['rId4']
    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] #取到的是元素
root_el.xpath("//a:blip[@r:embed]", namespaces=NS):
List[ lxml.etree._Element ]
同時有 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"]
#A #'http://schemas.openxmlformats.org/drawingml/2006/main'
#R #'http://schemas.openxmlformats.org/officeDocument/2006/relationships'

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}
"""
.rels 文件根元素
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
無前綴,需要自定義前綴,命名空間則是
"http://schemas.openxmlformats.org/package/2006/relationships"

其中一個子元素(期待命中的目標): 
<Relationship Id="rId4" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/image1.jpeg"/>
 Id, Target 屬性
"""

rid_to_target = {
    el.get("Id"): el.get("Target")
    for el in rels_root.xpath("//rel:Relationship", namespaces=NS_RELS)
}
""" #rid_to_target 
{'rId3': 'webSettings.xml',
 'rId2': 'settings.xml',
 'rId1': 'styles.xml',
 'rId6': 'theme/theme1.xml',
 'rId5': 'fontTable.xml',
 'rId4': 'media/image1.jpeg'}
"""

targets = [rid_to_target.get(r) for r in rids]
print("rId → Target:", targets)
# ['media/image1.jpeg']

#========================
# 8) 轉成 ZIP 內實際路徑 word/ 為基準並讀圖片 bytes
#========================
zip_paths = [join_zip_path("word", t) if t else None for t in targets]
#img_pathes 會比較貼切
print("ZIP 內圖片路徑:", zip_paths)
# ['word/media/image1.jpeg']

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)

XPath 語法說明:


root_el.xpath(
        "//*[local-name()='blip' and namespace-uri()=$A]"
        "/@*[local-name()='embed' and namespace-uri()=$R]",
        A=A, R=R
    )
# XPath 語法說明
# //*                           → 遍歷整個文件中所有節點(“*”代表任意元素)
#   [local-name()='blip'篩選本地名稱忽略命名空間前綴 blip 的元素
#    and namespace-uri()=$A]    → 並且該元素的命名空間 URI 等於參數 $A
# /@*從這些元素中取出所有屬性@* 代表任意屬性
#   [local-name()='embed'篩選本地名稱為 embed 的屬性
#    and namespace-uri()=$R]    → 並且屬性的命名空間 URI 等於參數 $R
//* 是「從文件根節點開始,選取整個文件中所有元素(任意層級、任意名稱)」。
因為開頭有 /它會先回到根節點再展開 descendant-or-self 
.//* 則是「從當前節點開始,選取該節點及其後代中的所有元素」。
只有當上下文節點本來就在根節點時
.//* 和 //* 才會選到同樣的節點;
若上下文不是根節點兩者就不同

語法結構動作 vs. 目標
XPath 的邏輯可以拆解為路徑怎麼走)」節點測試找什麼)」。

// (Double Slash):這是一個**軸(Axis)**或路徑運算符。
它的意思是從當前位置向下遞歸查找所有後代Descendants)」。它只代表方向」。
* (Asterisk):這是一個節點測試Node Test)。
它的意思是任何元素Element)」。它代表目標」。
結論
如果您只寫 //,就像是跟程式說「去幫我把所有的......找出來」,
但是沒說要找什麼解析器 lxml會報 Syntax Error語法錯誤)。

 XPath / 是根節點// 是後代。
通常我們寫 //*(連在一起)。
意思是從根節點開始 (/),遞歸查找 (/) 所有元素 (*)
如果您寫 /只有一個斜線):這代表文件本身Document Root)」,
它不是一個列表而是整個文件的起點物件

輸出:

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真實移除圖片瘦身? - 儲蓄保險王

你會學到什麼

  • 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 讀出位元組。

在 XML 領域,「tag」通常指帶有前綴的名稱或裸名稱,例如 a:blip 或 blip;
而 “{namespace-uri}local-name” 是一種序列化表示法(Clark notation),
用來在程式庫中唯一定位元素或屬性,不是人讀的「標籤字面」。

說清楚幾個名詞

  • qualified name(QName,合格名稱):prefix:local-name,例如 a:blip、r:embed。
  • local name:不含前綴的實名,例如 blip、embed。
  • namespace URI:例如 http://schemas.openxmlformats.org/drawingml/2006/main。
  • Clark notation:”{namespace-uri}local-name” 的字串形式,
    用於 ElementTree 等庫做匹配,例如 “{…/drawingml/2006/main}blip”。

在 lxml/ElementTree 裡

  • 元素的 .tag 屬性值就是 Clark notation(若有命名空間):elem.tag == “{ns}local”.
  • 沒有命名空間時,.tag 直接是 “local”。
  • qn(“a:blip”) 會回傳 “{ns-of-a}blip”,讓你在 XPath 或 findall 中精確匹配。

所以:

  • 人類閱讀或原始 XML 內看到的 <a:blip> 叫「tag(QName)」。
  • 在 ElementTree 內部,對應的 .tag 字串是 Clark notation;
    它也是一個「tag 字串表示」,但重點在唯一性,而非人類可讀的標籤寫法。

qn(‘a:blip’) ; qn(“r:embed”) ; qn(‘pic:pic’)
在 Python ElementTree / lxml 的程式開發語境下,它就是「tag」。

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真實移除圖片瘦身? - 儲蓄保險王

for blip in root_el.findall(f".//{qn('a:blip')}"):
print( etree.tostring(blip, 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") ) #獲取 屬性名 '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真實移除圖片瘦身? - 儲蓄保險王

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") ) #獲取 屬性名 '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真實移除圖片瘦身? - 儲蓄保險王

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 裡少其中一個檔案,這裡就會報錯,屬於早期應該發現的問題。

若parser確定要使用 etree.XMLParser(),
可以使用
etree.XML(doc_xml) 取代
etree.fromstring(doc_xml, parser=parser)

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真實移除圖片瘦身? - 儲蓄保險王
  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

如何真實移除圖片檔?
對docx瘦身?

Office Open XML (OOXML) 的本質:
「內容描述 (XML)」與「實體資源 (Binary)」是分離的

要達成需求,我們需要執行以下步驟:

  1. 修改 XML (視覺移除)
    word/document.xml 中找到圖片的標籤
    (通常包裹在 <w:drawing> 中),
    將其刪除。這樣打開 Word 時就看不到圖片了。
  2. 策略一 (假移除):只存回修改過的 XML,
    但保留 ZIP 內的 media/ 資料夾。
  3. 策略二 (真瘦身):存回修改過的 XML,
    並且在重組 ZIP 時,跳過 那些被參照的圖片檔案。

以下是完整的實作程式碼:

import zipfile
import os
from lxml import etree
from docx.oxml.ns import qn

#========================
# 1) 設定檔案路徑
#========================
INPUT_DOCX = r"D:\Temp\sample.docx"      # 原始檔
OUTPUT_FAKE = r"D:\Temp\sample_fake.docx" # 假移除 (看不到圖但容量沒變)
OUTPUT_SLIM = r"D:\Temp\sample_slim.docx" # 真瘦身 (看不到圖容量變小)

# 命名空間定義
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",
    "rel": "http://schemas.openxmlformats.org/package/2006/relationships"
}
# 為什麼 "rel"  value 這樣定義?
# 請看 document.xml.rels 的最外層: 
# <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">

#========================
# 2) 核心邏輯 document.xml 移除圖片節點並收集 rId
#========================
from typing import Set, Tuple
# 定義語意化的型別別名 
XmlBytes = bytes
def remove_images_from_xml(doc_bytes:XmlBytes ) -> Tuple[XmlBytes, Set[str]]:
    """
    解析 XML,移除所有含有圖片 (a:blip) 的 w:drawing 節點
    回傳: (修改後的 XML bytes, 被移除的 rId 集合)
    """
    parser = etree.XMLParser(remove_blank_text=True)
    root = etree.fromstring(doc_bytes, parser=parser)
    # lxml.etree._Element
    
    removed_rids = set()
    
    # 策略找到所有含有 <a:blip>  <w:drawing> 元素
    # w:drawing  Word 中圖片圖表形狀的容器
    # a:blip 是實際指向圖片資源的節點
    drawings_with_images = root.xpath("//w:drawing[.//a:blip]", 
                                      namespaces=NS)
    
    for drawing in drawings_with_images:
        # 1. 先抓出裡面的 rId以便後續知道要刪除哪個檔案
        blips = drawing.xpath(".//a:blip", 
                              namespaces=NS)
        for blip in blips:
            rid = blip.get(qn("r:embed"))
            if rid:
                removed_rids.add(rid)
        
        # 2.  XML 樹狀結構中移除這個 drawing 節點
        parent = drawing.getparent()
        if parent is not None:
            parent.remove(drawing)
            
    # 另外也有可能是在 <w:pict> (舊版格式) 
    # 這裡簡化只處理 <w:drawing>
    
    return etree.tostring(root, encoding="UTF-8", 
                          xml_declaration=True), removed_rids

#========================
# 3) 輔助邏輯解析 _rels 找出 rId 對應的檔名
#========================
def get_target_files(rels_bytes, rids_to_remove):
    """
    解析 .rels,找出 rId 對應的 Target 路徑 (例如 media/image1.jpeg)
    """
    if not rids_to_remove:
        return set()

    parser = etree.XMLParser(remove_blank_text=True)
    root = etree.fromstring(rels_bytes, parser=parser)
    
    targets = set()
    # 搜尋所有 Relationship
    for rel in root.xpath("//rel:Relationship", namespaces=NS):
        rid = rel.get("Id")
        if rid in rids_to_remove:
            target = rel.get("Target")
            # Target 通常是相對路徑 "media/image1.jpeg"
            #  ZIP 結構中它相對於 word/ 資料夾
            # 所以完整路徑是 word/media/image1.jpeg
            if target:
                # 簡單處理路徑拼接 (假設沒有 ../ 跳轉)
                full_path = f"word/{target}"
                targets.add(full_path)
    return targets

#========================
# 4) 執行流程
#========================

# 4-1 讀取原始資料
with zipfile.ZipFile(INPUT_DOCX, 'r') as z_in:
    # 讀取必要 XML
    doc_xml = z_in.read("word/document.xml")
    rels_xml = z_in.read("word/_rels/document.xml.rels")
    
    # 取得所有檔案列表
    all_files = z_in.namelist()

# 4-2 處理 XML (記憶體中操作)
new_doc_xml, removed_rids = remove_images_from_xml(doc_xml)
files_to_delete = get_target_files(rels_xml, removed_rids)

print(f"偵測到將被移除的圖片 rId: {removed_rids}")
print(f"對應的 ZIP 內部路徑: {files_to_delete}")

#========================
# 5) 產出檔案 A假移除 (Fake Removal)
#    - document.xml 已經沒有圖片標籤 -> 打開 Word 看不到圖
#    -  ZIP 裡面還保留著 media/image.png -> 檔案大小幾乎不變
#========================
with zipfile.ZipFile(INPUT_DOCX, 'r') as z_in:
    with zipfile.ZipFile(OUTPUT_FAKE, 'w', 
                    compression=zipfile.ZIP_DEFLATED) as z_out:
        for filename in z_in.namelist():
            if filename == "word/document.xml":
                # 寫入修改過的 XML (沒有圖片標籤)
                z_out.writestr(filename, new_doc_xml)
            else:
                # 其他檔案原封不動複製 (包含那些圖片檔!)
                z_out.writestr(filename, z_in.read(filename))

#========================
# 6) 產出檔案 B真瘦身 (Real Slimming)
#    - document.xml 已經沒有圖片標籤
#    - ZIP 裡面跳過 media/image.png 不寫入 -> 檔案大小顯著縮小
#========================
with zipfile.ZipFile(INPUT_DOCX, 'r') as z_in:
    with zipfile.ZipFile(OUTPUT_SLIM, 'w', 
            compression=zipfile.ZIP_DEFLATED) as z_out:
        for filename in z_in.namelist():
            # 1. 如果是 document.xml寫入修改版
            if filename == "word/document.xml":
                z_out.writestr(filename, new_doc_xml)
            
            # 2. 如果是我們要刪除的圖片直接跳過 (Skip)
            elif filename in files_to_delete:
                continue 
            
            # 3. 其他檔案照常寫入
            else:
                z_out.writestr(filename, z_in.read(filename))

#========================
# 7) 結果比較
#========================
size_origin = os.path.getsize(INPUT_DOCX)
size_fake = os.path.getsize(OUTPUT_FAKE)
size_slim = os.path.getsize(OUTPUT_SLIM)

print("-" * 30)
print(f"原始檔案大小: {size_origin/1024:.2f} KB")
print(f"假移除大小  : {size_fake/1024:.2f} KB (視覺無圖,但屍體還在)")
print(f"真瘦身大小  : {size_slim/1024:.2f} KB (視覺無圖,且移除資源)")
print("-" * 30)

輸出:

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真實移除圖片瘦身? - 儲蓄保險王

程式碼解析與策略說明

這段程式碼展示了操作 .docx 的兩個層次:

1. 邏輯層 (Logical Layer) – document.xml

  • 操作:我們使用 lxml 找到 <w:drawing> 標籤並將其 remove()
  • 效果:這就像你在 Word 裡選取圖片並按下 Delete 鍵。Word 的排版引擎讀不到這個標籤,所以畫面上不會顯示圖片。
  • 假移除的真相:如果你只做這一步(如 sample_fake.docx),Word 雖然不顯示圖片,但圖片的二進位資料(例如 image1.jpeg)仍然躺在 ZIP 壓縮包的 word/media/ 資料夾裡。這就是為什麼很多 Word 檔明明沒幾頁字,卻異常肥大的原因(可能有許多「刪除不乾淨」的圖片殘留)。

2. 物理層 (Physical Layer) – ZIP Container

  • 操作:我們在建立新的 ZIP 檔時,利用 filename in files_to_delete 判斷式。
  • 效果
    • Fake 模式:我們把舊 ZIP 裡的所有檔案(包含圖片)都複製過去。
    • Slim 模式:我們刻意不將圖片檔案寫入新的 ZIP。這才是真正減少檔案大小的關鍵步驟。
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真實移除圖片瘦身? - 儲蓄保險王

進階思考:關於 _rels 的殘留

在「真瘦身」的程式碼中,為了簡化邏輯,
沒有去修改 word/_rels/document.xml.rels

  • 現狀document.xml 裡的圖片參照沒了,圖片檔案也沒了,但是 rels 檔案裡還留著一句「rId4 對應到 media/image1.jpeg」。
  • Word 的反應:當你打開 sample_slim.docx 時,Word 讀取 document.xml,發現沒有任何地方用到 rId4,所以它根本不會去試著載入那張圖片。因此,即使 rels 裡有殘留的定義,通常也不會報錯,檔案依然可以正常開啟且體積變小。
  • 完美主義:如果要做到 100% 乾淨,還應該順便把 rels XML 裡面的那個 <Relationship Id="rId4"...> 節點也刪除,這樣才算完全清除了該圖片的所有痕跡。

推薦hahow線上學習python: https://igrape.net/30afN

NS = {
    "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main",
    "r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
    # ... 把所有可能用到的都列進去
}

如果遍歷xml內容的xmlns建立NS會怎樣?

ECMA-376 (OOXML 標準) 規定了 Word 文件必須使用這個 URL
這就像是寫網頁爬蟲:

  • 標準做法:我知道 HTML 的 <a> 標籤是用來做超連結的,所以我寫程式去抓 <a>
  • 遍歷做法:我先去分析這個網頁用了哪些標籤,發現它用了 <a>,所以我才去抓 <a>

2. 為什麼「遍歷」反而危險? (The Prefix Trap)

假設您寫了一個程式,邏輯是:「讀取 XML -> 抓取所有 xmlns -> 建立 NS 字典 -> 用於 XPath」。

情境 A (正常 Word)
XML 內容:<w:document xmlns:w="http://.../main"> ... </w:document>
程式抓到:{'w': 'http://.../main'}
XPath //w:p:成功

情境 B (怪異但合法的 XML)
XML 內容:<my:document xmlns:my="http://.../main"> ... </my:document>
(注意:前綴變成了 my,但 URL 還是標準的 URL)
程式抓到:{'my': 'http://.../main'}
XPath //w:p:失敗! (因為字典裡沒有 w,只有 my)
情境 C (人工寫死/標準做法)
程式寫死:NS = {'w': 'http://.../main'}
XML 內容:<my:document xmlns:my="http://.../main"> ... </my:document>
XPath //w:p:成功!
(為什麼?因為 lxml 會把您的 w 對應到 URL,然後去 XML 裡找「對應到同一個 URL」的標籤,不管那個標籤在檔案裡叫 w 還是 my。)

結論

您說的沒錯,資訊來源都是 XML。但:

  • 人工寫 (Hardcode):是建立一個「我預期要用的標準別名表」。這讓您的 XPath 程式碼 (//w:p) 永遠有效,不受檔案內部前綴命名影響。
  • 程式遍歷 (Dynamic):是建立一個「檔案實際使用的別名表」。這會導致您的 XPath 程式碼必須跟著檔案變(如果檔案用 my:p,您的 XPath 也得改成 //my:p),這會讓程式變得非常難寫且不穩定。

所以,我們寫 NS 字典,其實是在定義我們自己的「查詢語言」,強迫 XML 解析器聽懂我們的 w 是什麼意思,而不是我們去配合 XML 檔案裡的叫法。

您可以在以下幾個地方查閱 ECMA-376 (OOXML) 標準:

  1. ECMA International 官方網站 (最權威,但文件極其龐大):
  2. ISO/IEC 標準 (與 ECMA-376 等價):
    • ISO/IEC 29500。這是經過 ISO 認證後的版本,內容與 ECMA-376 幾乎一致。
  3. Microsoft Open Specifications (實用性最高,推薦開發者使用):
  4. Office Open XML (OOXML) 結構瀏覽網站 (非官方,但極好用的速查工具):
    • http://officeopenxml.com/
    • 這個網站把龐大的規格書整理成好讀的網頁,分類清楚(Word, Excel, PowerPoint),解釋每個 XML 標籤的用途和屬性,非常適合開發者快速查找。

關於命名空間 (Namespaces) 的部分:
在上述文件的 “Namespaces” 章節,或者直接看 XML Schema 定義檔 (XSD),您就會看到那些我們熟悉的 URL:

  • http://schemas.openxmlformats.org/wordprocessingml/2006/main (對應 w)
  • http://schemas.openxmlformats.org/officeDocument/2006/relationships (對應 r)

這些 URL 就是定義在這些標準文件裡的「金鑰」,全球通用。

加入好友
加入社群
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真實移除圖片瘦身? - 儲蓄保險王

儲蓄保險王

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

You may also like...

發佈留言

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