sample.docx內容僅有一小段文字
下方插入一張大圖檔,
容量1021KB:

🎯 教學目標
用一個只有「一段文字 + 一張圖片」的 sample.docx(原始約 1020.6 KB)示範:
為什麼刪掉顯示圖片的段落,檔案幾乎不會變小
正確刪除圖片所需的「關係 (relationship) + 圖片檔」雙重步驟
如何掃描圖片引用的 rId
如何 prune(精簡)後將檔案縮到 9.8 KB
如何建立純文字極簡版本(約 35.8 KB)
延伸:何時選擇「保留圖片」 vs 「全砍」模式
🔍 前置:DOCX 內部結構速覽
DOCX 本質是 ZIP 封包,圖片不是「塞在段落裡」,而是獨立檔案,段落只是引用它:

一張圖片若 1000 KB,刪掉它所在的 <w:p> 只會減少幾百 bytes(XML 壓縮後很小),真正要「瘦」必須刪它的 media 檔與關係。
🧪 實驗基準

🧬 圖片引用核心結構(簡化 XML)
<w:p>
<w:r>
<w:drawing>
<wp:inline>
<a:graphic>
<a:graphicData>
<pic:pic>
<pic:blipFill>
<a:blip r:embed="rId4"/>
</pic:blipFill>
</pic:pic>
</a:graphicData>
</a:graphic>
</wp:inline>
</w:drawing>
</w:r>
</w:p>對應在 word/_rels/document.xml.rels:
<Relationship Id="rId4"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image"
Target="media/image1.jpeg"/>只刪 <w:p>:顯示消失,但 image1.jpeg 還在。
刪 <Relationship> 但不刪圖檔:圖檔仍佔空間。
必須「雙刪」:才真正瘦。
🛠️ 全部流程一鍵示範(Python 腳本)
保存為 docx_image_demo.py,執行:python docx_image_demo.py
(需先 pip install python-docx)
import zipfile, re, shutil, os
from pathlib import Path
from docx import Document
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
SOURCE = Path(r"D:\Temp\sample.docx")
OUT_DIR = Path(r"D:\Temp\docx_demo_out"); OUT_DIR.mkdir(exist_ok=True)
def kb(p: Path): return f"{p.stat().st_size/1024:.1f} KB"
def read(zpath: Path, name: str):
"""
讀取 DOCX (其實是 ZIP) 容器內部指定成員的文字內容。
參數:
zpath: 外層 .docx 檔案路徑 (Path 或 str)。
name: ZIP 內部『相對路徑 / part 路徑』,必須與 ZipFile.namelist() 之一完全一致。
常見範例:
'[Content_Types].xml'
'_rels/.rels'
'word/document.xml'
'word/_rels/document.xml.rels'
'word/media/image1.jpeg'
'word/theme/theme1.xml'
'word/settings.xml'
'word/styles.xml'
'word/webSettings.xml'
'word/fontTable.xml'
'docProps/core.xml'
'docProps/app.xml'
"""
with zipfile.ZipFile(zpath,'r') as zf:
return zf.read(name).decode('utf-8','ignore')
def read_bytes(zpath: Path, name: str) -> bytes:
"""
讀取 ZIP / DOCX 內部任意成員的原始 bytes(適用圖片、二進位資源)。
"""
with zipfile.ZipFile(zpath, 'r') as zf:
return zf.read(name)
def list_media(zpath: Path):
with zipfile.ZipFile(zpath,'r') as zf:
media = [i for i in zf.namelist() if i.startswith("word/media/")]
print(f"[media] {len(media)} files")
for m in media:
print(" -", m, f"({zf.getinfo(m).file_size/1024:.1f} KB)")
print()
def find_rids(zpath: Path):
doc_xml = read(zpath,"word/document.xml")
rels_xml = read(zpath,"word/_rels/document.xml.rels")
rid_pat = re.compile(r'(?:r:embed|r:link|r:id)="(rId[0-9]+)"')
used = set(rid_pat.findall(doc_xml))
rel_map = dict(re.findall(r'Id="(rId[0-9]+)".+?Target="([^"]+)"', rels_xml))
image_rids = {rid:tgt for rid,tgt in rel_map.items() if tgt.startswith("media/")}
print("[rIds used in document.xml]:", used)
print("[image relationships]:")
for rid,tgt in image_rids.items():
print(f" {rid} -> {tgt} (in-use? {'YES' if rid in used else 'NO'})")
return used, image_rids
def remove_paragraph_with_rid(zpath: Path, rid: str, out: Path):
xml = read(zpath,"word/document.xml")
new_xml, n = re.compile(r'<w:p[^>]*>.*?'+rid+r'.*?</w:p>', re.DOTALL).subn('', xml, count=1)
with zipfile.ZipFile(zpath,'r') as zin, zipfile.ZipFile(out,'w',zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
data = zin.read(item.filename)
if item.filename == "word/document.xml":
zout.writestr(item.filename, new_xml if n else xml)
else:
zout.writestr(item, data)
print(f"[remove_paragraph] removed? {bool(n)} -> {out.name}")
def prune_unused_images(zpath: Path, out: Path):
doc_xml = read(zpath,"word/document.xml")
rels_xml = read(zpath,"word/_rels/document.xml.rels")
used = set(re.findall(r'(?:r:embed|r:link|r:id)="(rId[0-9]+)"', doc_xml))
rel_entries = re.findall(r'(<Relationship [^>]+/>)', rels_xml)
removed_files = set(); kept_xml=[]
for chunk in rel_entries:
attrs = dict(re.findall(r'(\w+)="([^"]+)"', chunk))
rid = attrs.get("Id"); tgt = attrs.get("Target","")
if tgt.startswith("media/") and rid not in used:
removed_files.add("word/"+tgt); continue
kept_xml.append(chunk)
new_rels = '<?xml version="1.0"?>\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">\n' + "\n".join(kept_xml) + "\n</Relationships>"
with zipfile.ZipFile(zpath,'r') as zin, zipfile.ZipFile(out,'w',zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
if item.filename in removed_files: continue
data = zin.read(item.filename)
if item.filename == "word/_rels/document.xml.rels":
zout.writestr(item.filename, new_rels)
else:
zout.writestr(item, data)
print(f"[prune] used_rids={len(used)} removed_media={len(removed_files)} -> {out.name}")
def build_text_only(orig: Path, out: Path):
src = Document(orig)
dst = Document()
for p in src.paragraphs:
t = p.text.strip()
if t: dst.add_paragraph(t)
# 確保 sectPr 存在
if not any(el.tag == qn('w:sectPr') for el in dst.element.body):
dst.element.body.append(OxmlElement('w:sectPr'))
dst.save(out); print(f"[text-only] -> {out.name}")
def main():
if not SOURCE.exists():
print("source missing"); return
print("原始大小:", kb(SOURCE))
list_media(SOURCE)
used, image_rids = find_rids(SOURCE)
if not image_rids:
print("No images."); return
rid = next(iter(image_rids))
removed_para = OUT_DIR/"sample_removed_paragraph.docx"
prune_doc = OUT_DIR/"sample_pruned.docx"
txt_only = OUT_DIR/"sample_text_only.docx"
remove_paragraph_with_rid(SOURCE, rid, removed_para)
print("刪段落後大小:", kb(removed_para), "(vs 原始", kb(SOURCE), ")")
prune_unused_images(removed_para, prune_doc)
print("Prune 後大小:", kb(prune_doc))
build_text_only(SOURCE, txt_only)
print("純文字大小:", kb(txt_only))
print("\n== 對照 ==")
for p in [SOURCE, removed_para, prune_doc, txt_only]:
print(f"{p.name:30s} {kb(p)}")
if __name__ == "__main__":
main()🧷 為什麼刪段落只少 2.6 KB?
- 刪的是壓縮效率極佳的 XML 文字(顯示容器)
- 圖片仍完整保留(1008 KB)→ 占比 98% → 檔案幾乎不變

📉 為什麼 prune 後只剩 9.8 KB?
- 移除圖片檔 + relationship 標記
- 剩下的:文件骨架 (document.xml + styles + content types…)
- 這就是「真正瘦身」
🧱 為什麼純文字 docx 不是更小而是 35.8 KB?
- python-docx 仍會產出 styles.xml、fontTable、numbering、theme、rels、metadata
- 你保留了文字段落(多行),不追求極限壓縮
若要再縮:可手動刪除 theme1.xml、fontTable.xml、不必要樣式關係(高風險,不建議除非做語料訓練)。
🧪 模式選擇指南

🧩 延伸:如何導出純文字供語料
def export_plain_text(docx_path: str, txt_path: str):
doc = Document(docx_path)
lines = []
for p in doc.paragraphs:
t = p.text.strip()
if t: lines.append(t)
Path(txt_path).write_text("\n".join(lines), encoding="utf-8")
# 使用
export_plain_text(r"D:\Temp\sample.docx", r"D:\Temp\sample.txt")✅ 總結重點一句話
圖片真正佔空間;段落只是引用。要瘦,就要處理「Relationship + media 檔」,不只是刪顯示段落。
推薦hahow線上學習python: https://igrape.net/30afN
from pathlib import Path
import zipfile
def read(zpath: Path, name: str):
"""
讀取 DOCX (其實是 ZIP) 容器內部指定成員的文字內容。
參數:
zpath: 外層 .docx 檔案路徑 (Path 或 str)。
name: ZIP 內部『相對路徑 / part 路徑』,必須與 ZipFile.namelist() 之一完全一致。
常見範例:
'[Content_Types].xml'
'_rels/.rels'
'word/document.xml'
'word/_rels/document.xml.rels'
'word/media/image1.jpeg'
'word/theme/theme1.xml'
'word/settings.xml'
'word/styles.xml'
'word/webSettings.xml'
'word/fontTable.xml'
'docProps/core.xml'
'docProps/app.xml'
說明:
- name 不是顯示名稱,而是壓縮包實際儲存的路徑字串 (folder + filename)。
- 在 OPC / DOCX 規範語境中,這是一個 package part 的位址。
- 若目標是媒體 (word/media/*) 等二進位,文字解碼會失真;請改用 read_bytes。
回傳:
UTF-8 (errors='ignore') 解碼後的文字內容。若原始是二進位資源,結果可能不正確。
"""
with zipfile.ZipFile(zpath, 'r') as zf:
return zf.read(name).decode('utf-8', 'ignore')
def read_bytes(zpath: Path, name: str) -> bytes:
"""
讀取 ZIP / DOCX 內部任意成員的原始 bytes(適用圖片、二進位資源)。
"""
with zipfile.ZipFile(zpath, 'r') as zf:
return zf.read(name)詳細講解這個函數 read(zpath: Path, name: str),不跳其他細節。分 10 個面向:用途 → 參數 → 流程 → 典型內部路徑 → 回傳值 → 失敗狀況 → 為什麼要 decode → ignore 的意義 → 擴充版本 → 實際對比。
用途 (一句話)
從一個 DOCX(其實是 ZIP)中讀取指定內部檔案的文字內容,回傳成 Python 字串。
參數意義
zpath: 外層壓縮檔路徑(Path 物件)。例:Path(r”D:\Temp\sample.docx”)
name: 壓縮包裡的檔案路徑字串(相對路徑)。例:
正文 XML: “word/document.xml”
關係表: “word/_rels/document.xml.rels”
樣式: “word/styles.xml”
函數流程(逐行拆解)
with zipfile.ZipFile(zpath,'r') as zf:開啟一個 ZIP 讀取器(因為 .docx = ZIP)。zf 是 ZipFile 物件。
return zf.read(name).decode('utf-8','ignore')zf.read(name):讀內部檔案原始 bytes。
.decode(‘utf-8′,’ignore’):把 bytes 解碼成字串(把非 UTF-8 的錯誤位元組略過)。
回傳字串。
為什麼能讀 DOCX?
DOCX 本質 = ZIP 檔;你手動改副檔名為 .zip 後解壓可以看到與 zf.namelist() 列出的內容完全一致。
因此 zipfile.ZipFile 可以直接操作它。
典型可用的 name 值(常見)
目的 name
正文段落內容 word/document.xml
圖片引用關係 word/_rels/document.xml.rels
樣式定義 word/styles.xml
編號定義 word/numbering.xml
主題 word/theme/theme1.xml
文件屬性 docProps/core.xml
應用屬性 docProps/app.xml
類型對照表 [Content_Types].xml手動改副檔名為 .zip:

查看有哪些 name 可以用:
import zipfile
from pathlib import Path
with zipfile.ZipFile(Path(r"D:\Temp\sample.docx"), 'r') as zf:
for n in zf.namelist():
print(n)輸出:

zf.namelist():
[Content_Types].xml
_rels/.rels
word/document.xml
word/_rels/document.xml.rels
word/media/image1.jpeg
word/theme/theme1.xml
word/settings.xml
word/styles.xml
word/webSettings.xml
word/fontTable.xml
docProps/core.xml
docProps/app.xml要記的最小記憶包
正文:word/document.xml
關係表:word/_rels/document.xml.rels
圖片檔:word/media/imageX.*
圖片引用: <a:blip r:embed="rIdX"> + <Relationship Id="rIdX" Target="media/...">
回傳值
成功:回傳指定內部檔案的文字內容(str)
失敗情況:
外部檔案不存在 → FileNotFoundError
指定 name 不存在 → KeyError
內容不是 UTF-8(例如圖片)→ decode 後變成奇怪符號或被忽略
為什麼 decode(‘utf-8′,’ignore’)
內部 XML 檔案都是 UTF-8 編碼;使用 decode 轉字串好操作(搜尋 rId、正則、解析 XML)。
加 ignore 是保底措施:遇到非 UTF-8 位元組(理論上不該在 XML 裡)不拋錯,直接略過。
若你讀圖片(JPEG/PNG),這種 decode 就不合適,應該改成讀 bytes。
ignore 的副作用
好處:不會因單一壞字節中斷流程。
風險:少數特殊標記被丟掉你不會發現。
若要更嚴謹:用 ‘strict’(預設)或 ‘replace’。
例:
zf.read(name).decode('utf-8','strict') # 有編碼錯誤就拋例外
zf.read(name).decode('utf-8','replace') # 用 � 佔位符顯示- 擴充版(加錯誤處理 & 二進位)
文字版(安全):
def read_text(zpath: Path, name: str, default: str = "") -> str:
try:
with zipfile.ZipFile(zpath,'r') as zf:
return zf.read(name).decode('utf-8','ignore')
except (FileNotFoundError, KeyError):
return default二進位版(讀圖片):
def read_bytes(zpath: Path, name: str) -> bytes:
with zipfile.ZipFile(zpath,'r') as zf:
return zf.read(name)實際對比示例(同一檔案兩種讀法)
from pathlib import Path
import zipfile
docx = Path(r"D:\Temp\sample.docx")
# 讀正文 XML
xml_text = read(docx, "word/document.xml")
print(xml_text[:200]) # 前200字
# 直接讀圖片 bytes
with zipfile.ZipFile(docx,'r') as zf:
img_bytes = zf.read("word/media/image1.jpeg")
print("圖片大小(bytes):", len(img_bytes))輸出:

這裡如果你嘗試:
bad = read(docx, "word/media/image1.jpeg")
print(bad[:200])會得到亂碼或空字串 —— 因為圖片不是 UTF-8 字元資料。
簡短總結
read(zpath, name) = 用 ZIP API 讀 DOCX 裡某個內部檔案 + 以 UTF-8 解碼為字串。zpath 指向外部 .docx;name 是壓縮包內部路徑,來源就是 zf.namelist() 或手動解壓後看到的檔名。
圖片/二進位資源不要用這個函式;用 bytes 版。
推薦hahow線上學習python: https://igrape.net/30afN





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


![Python: List[ pandas.Series ] 轉DataFrame技巧:正確理解row和column的關係,同 concat( List[ pandas.Series ], axis=1 ).T Python: List[ pandas.Series ] 轉DataFrame技巧:正確理解row和column的關係,同 concat( List[ pandas.Series ], axis=1 ).T](https://i0.wp.com/savingking.com.tw/wp-content/uploads/2025/04/20250422150133_0_1cfa94.png?quality=90&zoom=2&ssl=1&resize=350%2C233)

近期留言