Python DOCX 圖片瘦身實戰 import zipfile; with zipfile.ZipFile(zpath,’r’) as zf

加入好友
加入社群
Python DOCX 圖片瘦身實戰 import zipfile; with zipfile.ZipFile(zpath,'r') as zf - 儲蓄保險王

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

Python DOCX 圖片瘦身實戰 import zipfile; with zipfile.ZipFile(zpath,'r') as zf - 儲蓄保險王

 🎯 教學目標
用一個只有「一段文字 + 一張圖片」的 sample.docx(原始約 1020.6 KB)示範:

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

Python DOCX 圖片瘦身實戰 import zipfile; with zipfile.ZipFile(zpath,'r') as zf - 儲蓄保險王

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


🧪 實驗基準

Python DOCX 圖片瘦身實戰 import zipfile; with zipfile.ZipFile(zpath,'r') as zf - 儲蓄保險王

🧬 圖片引用核心結構(簡化 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% → 檔案幾乎不變
Python DOCX 圖片瘦身實戰 import zipfile; with zipfile.ZipFile(zpath,'r') as zf - 儲蓄保險王

📉 為什麼 prune 後只剩 9.8 KB?

  • 移除圖片檔 + relationship 標記
  • 剩下的:文件骨架 (document.xml + styles + content types…)
  • 這就是「真正瘦身」

🧱 為什麼純文字 docx 不是更小而是 35.8 KB?

  • python-docx 仍會產出 styles.xml、fontTable、numbering、theme、rels、metadata
  • 你保留了文字段落(多行),不追求極限壓縮

若要再縮:可手動刪除 theme1.xmlfontTable.xml、不必要樣式關係(高風險,不建議除非做語料訓練)。


🧪 模式選擇指南

Python DOCX 圖片瘦身實戰 import zipfile; with zipfile.ZipFile(zpath,'r') as zf - 儲蓄保險王

🧩 延伸:如何導出純文字供語料

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:

Python DOCX 圖片瘦身實戰 import zipfile; with zipfile.ZipFile(zpath,'r') as zf - 儲蓄保險王

查看有哪些 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)

輸出:

Python DOCX 圖片瘦身實戰 import zipfile; with zipfile.ZipFile(zpath,'r') as zf - 儲蓄保險王

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')  # 佔位符顯示
  1. 擴充版(加錯誤處理 & 二進位)

文字版(安全):

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

輸出:

Python DOCX 圖片瘦身實戰 import zipfile; with zipfile.ZipFile(zpath,'r') as zf - 儲蓄保險王

這裡如果你嘗試:

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 DOCX 圖片瘦身實戰 import zipfile; with zipfile.ZipFile(zpath,'r') as zf - 儲蓄保險王

儲蓄保險王

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

You may also like...

發佈留言

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