Python DOCX 手術刀:精準切片與瘦身 (OOXML 實戰)

加入好友
加入社群
Python DOCX 手術刀:精準切片與瘦身 (OOXML 實戰) - 儲蓄保險王

這篇教學將帶您深入 Word (.docx) 的本質——它其實只是一個 Zip 壓縮包。我們將實作一個「切片手術」:

切除 (Slice):保留指定段落,切除其餘內容。
清創 (Prune):深入 Zip 結構,移除因為切除內容而變成「孤兒」的未引用圖片,大幅縮小檔案體積。
核心概念
複製策略:不要從頭建立新檔,而是「複製整份文件」然後「刪除不要的」。這樣能完美保留樣式 (Styles)、頁首頁尾 (Header/Footer) 和設定。
OOXML 關係:圖片存在 word/media/ 資料夾中,並透過 document.xml.rels 與內文建立關聯。只刪除內文圖片標籤,圖片實體檔案依然存在 Zip 中(導致檔案虛胖),必須手動清理。

  1. 環境準備與產生測試資料
    首先,我們建立一個包含三個章節的 Word 文件。

第 1 章:文字
第 2 章:文字 + 紅色圖片 (我們打算保留這個章節)
第 3 章:文字 + 藍色圖片 (我們打算切除這個章節)

import os
from docx import Document
from docx.shared import Inches
from PIL import Image
import io

# 建立測試用的圖片 (紅色與藍色)
def create_dummy_image(color, filename):
    img = Image.new('RGB', (200, 200), color=color)
    img.save(filename)
    return filename

create_dummy_image('red', 'red.png')
create_dummy_image('blue', 'blue.png')

# 建立原始 Word 文件
doc = Document()
doc.add_heading('Chapter 1: Intro', level=1)
doc.add_paragraph('This is the introduction.')

doc.add_heading('Chapter 2: Target Content', level=1)
doc.add_paragraph('We want to KEEP this section and the red image.')
doc.add_picture('red.png', width=Inches(1.0))

doc.add_heading('Chapter 3: Unwanted Content', level=1)
doc.add_paragraph('We want to REMOVE this section and the blue image.')
doc.add_picture('blue.png', width=Inches(1.0))

source_path = 'source_doc.docx'
doc.save(source_path)

print(f"原始檔案已建立: {source_path}")
print(f"原始大小: {os.path.getsize(source_path)} bytes")

原始檔案已建立: source_doc.docx
原始大小: 37460 bytes

Python DOCX 手術刀:精準切片與瘦身 (OOXML 實戰) - 儲蓄保險王

生成的source_doc.docx:

Python DOCX 手術刀:精準切片與瘦身 (OOXML 實戰) - 儲蓄保險王
  1. 核心步驟一:切片 (Slicing)
    我們使用 python-docx 載入文件,保留 Chapter 2 (索引 2 到 5),刪除其餘部分。

注意:這一步雖然刪除了 Chapter 3 的文字和圖片標籤,但藍色圖片的實體檔案還留在 Zip 裡!

# %%
import shutil
from docx import Document

# 設定切片範圍 (模擬堆疊演算法算出的結果)
# Index 0: Chapter 1 Heading
# Index 1: Chapter 1 Text
# Index 2: Chapter 2 Heading (Start)
# Index 3: Chapter 2 Text
# Index 4: Chapter 2 Image
# Index 5: Chapter 3 Heading (End - Exclusive)
start_idx = 2
end_idx = 5

# 1. 複製檔案 (保留樣式)
sliced_path = 'sliced_bloated.docx'
shutil.copy2(source_path, sliced_path)

# 2. 開啟副本進行手術
doc = Document(sliced_path)
body = doc.element.body
all_elements = list(body.iterchildren())

# 3. 倒序刪除不在範圍內的元素
# (倒序是為了避免刪除元素後後續元素的 index 跑掉)
for i in reversed(range(len(all_elements))):
    if i < start_idx or i >= end_idx:
        # 這是 sectPr (Section Properties) 保護機制避免刪除最後一個 sectPr 導致格式跑掉
        if all_elements[i].tag.endswith('sectPr'):
            continue
        body.remove(all_elements[i])

doc.save(sliced_path)

print(f"切片完成 (尚未瘦身): {sliced_path}")
print(f"切片後大小: {os.path.getsize(sliced_path)} bytes")
print("觀察:檔案大小幾乎沒有變小,因為藍色圖片還在裡面!")

輸出結果:

Python DOCX 手術刀:精準切片與瘦身 (OOXML 實戰) - 儲蓄保險王

sliced_bloated.docx

Python DOCX 手術刀:精準切片與瘦身 (OOXML 實戰) - 儲蓄保險王

3. 核心步驟二:瘦身 (Pruning)

這是最精彩的部分。我們直接操作 OOXML (Zip) 結構:

  1. 解析 document.xml 找出還活著的關聯 ID (rId)。
  2. 解析 document.xml.rels 找出這些 rId 對應的檔案。
  3. 重寫 Zip,只保留有被引用的媒體檔案。
import zipfile
import tempfile
import shutil
from lxml import etree

final_path = 'final_clean.docx'

# 定義 XML Namespace
ns_map = {
    'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main',
    'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
    'pkg': 'http://schemas.openxmlformats.org/package/2006/relationships'
}

# 準備暫存檔
with tempfile.NamedTemporaryFile(delete=False) as tmp:
    tmp_zip_path = tmp.name

with zipfile.ZipFile(sliced_path, 'r') as zfin, \
     zipfile.ZipFile(tmp_zip_path, 'w', zipfile.ZIP_DEFLATED) as zfout:
    
    # A. 讀取 document.xml 找出所有使用中的 rId
    doc_xml = zfin.read('word/document.xml')
    doc_tree = etree.fromstring(doc_xml)
    # 抓取所有 r:embed, r:link, r:id 屬性值
    used_rids = set(doc_tree.xpath("//@r:embed | //@r:link | //@r:id", namespaces=ns_map))
    
    # B. 讀取 document.xml.rels 建立白名單
    rels_xml = zfin.read('word/_rels/document.xml.rels')
    rels_tree = etree.fromstring(rels_xml)
    
    keep_files = set()
    rels_to_remove = []
    
    for rel in rels_tree.xpath("//pkg:Relationship", namespaces=ns_map):
        rid = rel.get('Id')
        target = rel.get('Target')
        
        # 正面表列如果是 media  embeddings rId 沒被使用就標記刪除
        if target.startswith(('media/', 'embeddings/')):
            if rid not in used_rids:
                rels_to_remove.append(rel)
                continue # 跳過不加入 keep_files
            else:
                # 要保留的圖片補上 word/ 前綴以匹配 Zip 路徑
                keep_files.add('word/' + target)
        
        # 非媒體類 ( styles.xml, theme.xml) 全部保留
        else:
            pass 

    # C.  XML 樹中移除未使用的 Relationship 節點
    if rels_to_remove:
        for r in rels_to_remove:
            r.getparent().remove(r)
        # 更新 rels 內容
        new_rels_xml = etree.tostring(rels_tree, encoding='utf-8', xml_declaration=True, standalone=True)
    else:
        new_rels_xml = rels_xml

    # D. 重寫 Zip (過濾孤兒檔案)
    for item in zfin.infolist():
        # 如果是我們要修改的 rels 寫入新內容
        if item.filename == 'word/_rels/document.xml.rels':
            zfout.writestr(item, new_rels_xml)
            continue
        
        # 如果是 media 資料夾下的檔案且不在白名單中 -> 刪除 (跳過不寫入)
        if item.filename.startswith(('word/media/', 'word/embeddings/')):
            if item.filename not in keep_files:
                print(f"移除孤兒檔案: {item.filename}")
                continue
        
        # 其他檔案原樣複製
        zfout.writestr(item, zfin.read(item.filename))

# 移動暫存檔到最終路徑
shutil.move(tmp_zip_path, final_path)

print(f"\n最終檔案: {final_path}")
print(f"最終大小: {os.path.getsize(final_path)} bytes")

移除孤兒檔案: word/media/image2.png

最終檔案: final_clean.docx
最終大小: 37152 bytes:

Python DOCX 手術刀:精準切片與瘦身 (OOXML 實戰) - 儲蓄保險王

4. 結果驗證

比較三個檔案的大小,您會發現 final_clean.docx 的大小顯著小於 sliced_bloated.docx,證明藍色圖片已被成功移除。

# %%
import pandas as pd

data = {
    'File': ['Source', 'Sliced (Bloated)', 'Final (Clean)'],
    'Size (Bytes)': [
        os.path.getsize(source_path),
        os.path.getsize(sliced_path),
        os.path.getsize(final_path)
    ]
}
df = pd.DataFrame(data)
df['Reduction'] = df['Size (Bytes)'].pct_change()
df

df:

Python DOCX 手術刀:精準切片與瘦身 (OOXML 實戰) - 儲蓄保險王

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

我們產生的「測試圖片」太小了(純色圖片壓縮率極高),導致「圖片本身的大小」相對於「Word 檔案結構的開銷(XML, Styles, Fonts)」來說微不足道。

純色圖片極小:

我們用 PIL 產生的 200×200 純色 PNG 圖片,經過壓縮後可能只有 幾百 bytes。
所以刪除一張圖片,檔案大小只減少了 37381 – 37152 = 229 bytes。這完全符合預期(一張純色 PNG 大概就是這麼小)。
Word 檔案的基底開銷:

一個空的 .docx 檔案,光是包含基本的 styles.xml, theme.xml, settings.xml 等等,大小就可能有 10KB ~ 30KB。
所以 37KB 的檔案中,絕大部分是 Word 的「骨架」,而不是圖片。
如何驗證「瘦身」真的有效?
我們需要讓圖片「有感」一點。我們可以用 os.urandom 產生雜訊圖片,或者直接產生大一點的圖片,這樣刪除後的效果就會非常顯著。

請嘗試修改 第 1 步:產生測試資料 的程式碼,改用這段產生「大圖片」的邏輯:

# %%
import os
from docx import Document
from docx.shared import Inches
from PIL import Image
import io
import numpy as np

# 建立測試用的圖片 (使用隨機雜訊讓檔案變大且難以壓縮)
def create_heavy_image(color, filename, size=(1000, 1000)):
    # 產生隨機雜訊 (這樣 PNG 壓縮不下來檔案會很大)
    # 使用 numpy 產生隨機像素
    arr = np.random.randint(0, 255, (size[0], size[1], 3), dtype=np.uint8)
    img = Image.fromarray(arr, 'RGB')
    img.save(filename)
    return filename

print("正在產生大型測試圖片 (可能需要幾秒鐘)...")
create_heavy_image('red', 'red.png')   # 這張會很大 ( 2-3 MB)
create_heavy_image('blue', 'blue.png') # 這張也會很大

# 建立原始 Word 文件
doc = Document()
doc.add_heading('Chapter 1: Intro', level=1)
doc.add_paragraph('This is the introduction.')

doc.add_heading('Chapter 2: Target Content', level=1)
doc.add_paragraph('We want to KEEP this section and the red image.')
doc.add_picture('red.png', width=Inches(3.0))

doc.add_heading('Chapter 3: Unwanted Content', level=1)
doc.add_paragraph('We want to REMOVE this section and the blue image.')
doc.add_picture('blue.png', width=Inches(3.0))

source_path = 'source_doc.docx'
doc.save(source_path)

print(f"原始檔案已建立: {source_path}")
print(f"原始大小: {os.path.getsize(source_path) / 1024 / 1024:.2f} MB")

重新執行後續的步驟,最終結果:

Python DOCX 手術刀:精準切片與瘦身 (OOXML 實戰) - 儲蓄保險王

數據完美驗證:

Sliced (Bloated): 6048243 bytes。

只比原始檔案少了 79 bytes(大概就是刪掉的那幾行文字的 XML 標籤大小)。
證明:單純用 python-docx 刪除內容,不會刪除圖片實體檔案。
Final (Clean): 3043151 bytes。

減少了約 49.69%。
證明:我們的 prune_docx 函式成功地深入 Zip 結構,精準地移除了那張約
3MB 的藍色圖片,同時保留了紅色圖片。

這套流程(複製 -> 切片 -> 瘦身)現在已經被證實是處理 Word 文件分割與優化的黃金準則。

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

核心邏輯:DOCX 切片與瘦身
這套流程的核心哲學是:「與其重建,不如雕刻」。

我們不嘗試在一個空白的新檔案中重建內容(因為樣式、頁首頁尾太難完美複製),
而是複製整個檔案,然後把不要的部分「雕刻」掉。

第一階段:切片 (Slicing) —— 雕刻法
目標:產生一份視覺上正確,包含指定章節的文件。

複製 (Clone):

直接複製原始檔案 (source.docx) 成為副本 (sliced.docx)。
原因:這樣能完美繼承原始文件的所有「DNA」
(樣式 Styles、頁首頁尾、版面設定、字型)。
移除 (Remove):

使用高階工具 (python-docx) 打開副本。
保留指定範圍(例如 Chapter 2)的段落。
刪除範圍以外的所有內容。
結果:打開 Word 看起來是對的,但檔案虛胖。
因為雖然你在內文中刪除了圖片的「標籤」,
但圖片的「實體檔案」還留在 Zip 壓縮包的深處。

第二階段:瘦身 (Pruning) —— 清道夫

目標:移除虛胖,物理刪除未使用的媒體檔案。

這階段我們不把 DOCX 當成 Word 檔,而是把它當成 Zip 壓縮包 來處理。

  1. 盤點需求 (Scan Document)
    • 讀取內文 (document.xml)。
    • 統計:現在內文裡還剩下哪些「資源 ID」(rId)?
    • (例如:內文說我還需要 rId1 和 rId3
  2. 查閱目錄 (Check Relationships)
    • 讀取關聯表 (document.xml.rels)。
    • 翻譯:將 rId 翻譯成真實檔名。
    • (例如:rId1 = image_A.pngrId2 = image_B.png
  3. 正面表列 (Allowlist Strategy)
    • 邏輯
      • rId1 有人用 -> image_A.png 保留
      • rId2 沒人用 -> image_B.png 丟棄(這是孤兒)。
    • 關鍵:只針對 media/ 資料夾內的檔案進行篩選,其他系統檔案(如樣式表)一律保留。
  4. 重組 (Repack)
    • 建立一個新的 Zip。
    • 將「白名單」內的檔案複製過去。
    • 結果:檔案體積大幅縮小,且結構完整。

總結圖解

  1. 原始狀態:[內文: A, B, C] + [圖片: A, B, C] = 5 MB
  2. 切片後:[內文: B

] + [圖片: A, B, C] = 5 MB (視覺上只有 B,但圖片都在)
3. 瘦身後:[內文: B] + [圖片: B] = 2 MB (真正的乾淨)

(exe檔,無需python環境也可執行)
SavingKingIRR_csv.exe
需要自己寫好逗點分隔檔
支援首續期不同保費
以及一次計算多年期IRR
 
不用寫逗點分隔檔
但首續期保費需相同
一次只能算一個年期
 
畫面會停在
您將輸入n年度末解約金?n=?
待使用者輸入下一筆資料或-9999離開
 
加入好友
加入社群
Python DOCX 手術刀:精準切片與瘦身 (OOXML 實戰) - 儲蓄保險王

儲蓄保險王

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

You may also like...

發佈留言

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