攝影或3C

Python: Word 文檔章節操作完整指南:從理解結構到精確刪除 ; 段落索引 vs body索引; doc.paragraphs vs doc.element.body

📝 建立測試文檔

首先,讓我們創建一個結構完整的測試文檔,包含標題、段落和表格:

from docx import Document
from docx.shared import Inches
from docx.enum.style import WD_STYLE_TYPE

# 創建新文檔
doc = Document()

# 添加文檔標題
doc.add_heading('技術文件:產品測試規範', 0)

# 第1章
doc.add_heading('1. 概述', 1)
doc.add_paragraph('本文檔定義了產品測試的標準流程和規範。')
doc.add_paragraph('適用於所有硬體產品的測試驗證。')

# 第1章的表格
table1 = doc.add_table(rows=3, cols=3)
table1.style = 'Light List Accent 1'
# 填充表格
header_cells = table1.rows[0].cells
header_cells[0].text = '測試項目'
header_cells[1].text = '標準'
header_cells[2].text = '負責人'

table1.rows[1].cells[0].text = '功能測試'
table1.rows[1].cells[1].text = 'IEC 60950'
table1.rows[1].cells[2].text = '張三'

table1.rows[2].cells[0].text = '性能測試'
table1.rows[2].cells[1].text = 'ISO 9001'
table1.rows[2].cells[2].text = '李四'

# 第2章
doc.add_heading('2. PCBA測試計畫', 1)
doc.add_paragraph('印刷電路板組裝(PCBA)的測試流程包括以下步驟:')
doc.add_paragraph('1) 外觀檢查\n2) 電氣測試\n3) 功能驗證')

# 第2章的表格
table2 = doc.add_table(rows=4, cols=2)
table2.style = 'Light Grid Accent 1'
table2.rows[0].cells[0].text = '測試階段'
table2.rows[0].cells[1].text = '所需時間'
table2.rows[1].cells[0].text = 'ICT測試'
table2.rows[1].cells[1].text = '5分鐘'
table2.rows[2].cells[0].text = '功能測試'
table2.rows[2].cells[1].text = '15分鐘'
table2.rows[3].cells[0].text = '燒機測試'
table2.rows[3].cells[1].text = '24小時'

doc.add_paragraph('測試完成後需要生成測試報告。')

# 第3章
doc.add_heading('3. 環境測試要求', 1)
doc.add_paragraph('所有產品必須通過以下環境測試:')
doc.add_paragraph('• 高低溫測試:-40°C 至 +85°C')
doc.add_paragraph('• 濕度測試:95% RH')
doc.add_paragraph('• 振動測試:10-500Hz')

# 第4章包含子章節
doc.add_heading('4. 品質標準', 1)
doc.add_paragraph('本章定義產品品質標準。')

doc.add_heading('4.1 外觀標準', 2)
doc.add_paragraph('產品外觀不得有明顯瑕疵。')

doc.add_heading('4.2 電氣標準', 2)
doc.add_paragraph('所有電氣參數必須符合規格書要求。')

# 第5章
doc.add_heading('5. PCBA返修流程', 1)
doc.add_paragraph('當PCBA測試失敗時,需要進行返修。')
doc.add_paragraph('返修流程包括:故障分析、維修、重新測試。')

# 保存文檔
doc.save(r'D:\Temp\test_document.docx')
print("✅ 測試文檔已創建:test_document.docx")

生成的 test_document.docx:

📊 視覺化文檔結構

讓我們先看看文檔的內部結構:

def visualize_document_structure(doc, max_elements=50):
    """
    可視化文檔結構,幫助理解元素排
    """
    from docx.text.paragraph import Paragraph
    
    body = doc.element.body
    print(f"\n📋 文檔結構分析(共 {len(body)} 個元素)")
    print("=" * 80)
    
    for i, elem in enumerate(list(body)[:max_elements]):
        if elem.tag.endswith('p'):
            para = Paragraph(elem, doc)
            style = para.style.name
            text = para.text[:50] + "..." if len(para.text) > 50 else para.text
            
            # 用不同符號標記不同層級的標題
            if style == 'Heading 1':
                print(f"[{i:3d}] 📌 H1: {text}")
            elif style == 'Heading 2':
                print(f"[{i:3d}]   📎 H2: {text}")
            elif style == 'Title':
                print(f"[{i:3d}] 📑 標題: {text}")
            else:
                print(f"[{i:3d}]   段落: {text}")
                
        elif elem.tag.endswith('tbl'):
            print(f"[{i:3d}] 📊 【表格】")
        else:
            tag_name = elem.tag.split('}')[-1] if '}' in elem.tag else elem.tag
            print(f"[{i:3d}] ❓ 其他: {tag_name}")

# 載入並分析文檔
doc = Document(r'D:\Temp\test_document.docx')
visualize_document_structure(doc)

輸出:

body_children = list(body)

目前使用elem.tag分辨是段落還是表格,
其他方法:

切片到僅有一個段落,一個表格:

code:

def comprehensive_comparison(doc):
    """全面比較三種識別方法"""
    from docx.oxml.text.paragraph import CT_P
    from docx.oxml.table import CT_Tbl
    
    body = doc.element.body
    
    print("🔍 三種方法的比較:\n")
    
    # 方法 1: endswith
    method1_results = []
    for elem in body:
        if elem.tag.endswith('p'):
            method1_results.append('paragraph')
        elif elem.tag.endswith('tbl'):
            method1_results.append('table')
        else:
            method1_results.append('other')
    
    # 方法 2: type() 比較
    method2_results = []
    for elem in body:
        if type(elem).__name__ == 'CT_P':
            method2_results.append('paragraph')
        elif type(elem).__name__ == 'CT_Tbl':
            method2_results.append('table')
        else:
            method2_results.append('other')
    
    # 方法 3: isinstance
    method3_results = []
    for elem in body:
        if isinstance(elem, CT_P):
            method3_results.append('paragraph')
        elif isinstance(elem, CT_Tbl):
            method3_results.append('table')
        else:
            method3_results.append('other')
    
    # 比較結果
    all_same = method1_results == method2_results == method3_results
    print(f"三種方法結果完全一致: {all_same}")
    
    if not all_same:
        print("\n差異分析:")
        for i, (r1, r2, r3) in enumerate(zip(method1_results, method2_results, method3_results)):
            if r1 != r2 or r2 != r3:
                print(f"  索引 {i}: endswith={r1}, type={r2}, isinstance={r3}")

comprehensive_comparison(doc)

輸出:

使用'<w:p>’ in repr(b) 或
‘CT_P’ in repr(b) #可用但不推薦:

建議的優先順序:

  1. 🥇 type(elem).__name__ == 'CT_P' – 最佳平衡
  2. 🥈 elem.tag.endswith('p') – 簡單快速
  3. 🥉 isinstance(elem, CT_P) – 最準確但需導入
  4. 🏅 'CT_P' in repr(elem) – 可用但不推薦,
    效能較差,因為需要生成和搜索字符串,
    不夠優雅,依賴內部實現細節

🔍 理解段落索引 vs Body索引的差異

這是理解文檔操作的關鍵:

def analyze_index_mapping(doc):
    """
    分析段落索引和body索引的對應關
    """
    body = doc.element.body
    body_children = list(body)
    
    print("\n🔗 索引對應關係分析")
    print("=" * 60)
    print("段落索引 → Body索引 | 內容")
    print("-" * 60)
    
    para_idx = 0
    for body_idx, elem in enumerate(body_children):
        if elem.tag.endswith('p'):
            para = doc.paragraphs[para_idx]
            text = para.text[:40] + "..." if len(para.text) > 40 else para.text
            print(f"P[{para_idx:2d}] → B[{body_idx:2d}] | {text}")
            para_idx += 1
        elif elem.tag.endswith('tbl'):
            print(f"      → B[{body_idx:2d}] | 📊 表格(不佔用段落索引)")
    
    print(f"\n📈 統計:")
    print(f"   總段落數:{len(doc.paragraphs)}")
    print(f"   總元素數:{len(body_children)}")
    print(f"   表格數量:{len(doc.tables)}")

analyze_index_mapping(doc)

輸出結果:

段落/表格以外,
其他可能遇到的兄弟元素:

# Word 文檔中常見的 body 子元素類型
COMMON_BODY_ELEMENTS = {
    'p': '段落',
    'tbl': '表格',
    'bookmarkStart': '書籤開始',
    'bookmarkEnd': '書籤結束',
    'sectPr': '節屬性(頁面設置)',
    'sdt': '結構化文檔標籤(內容控制項)',
    'customXml': '自訂 XML',
    'altChunk': '替代內容塊',
    'ins': '插入修訂(追蹤修訂)',
    'del': '刪除修訂(追蹤修訂)',
}

🎯 查找章節邊界(核心功能)

實現一個完整的章節邊界查找功能:

#from docx.text.paragraph import Paragraph
def find_heading_boundaries(doc, target_text="", style_name="Heading 1",
                          exact=False, case_sensitive=False):
    """
    找出章節的真實邊界,包含所有元
    """
    from docx.text.paragraph import Paragraph
    # 取得文檔主體的所有子元素段落表格等
    body = doc.element.body
    body_children = list(body)
    
    # # 設定文字標準化函數根據是否區分大小寫
    if case_sensitive:
        norm = lambda s: s.strip()
        search_key = target_text.strip() if target_text else ""
    else:
        norm = lambda s: s.strip().lower()
        search_key = target_text.strip().lower() if target_text else ""
        #target_text = "L10 Test Plan"
        #search_key #'l10 test plan'
    
    # 內部匹配函數
    def matches_search(text):
        """這個 text 會接收每個Heading 1段落的文字內
        #'1\tProduct Feature Summary'...
        #'9\tL10 Test Plan'(需命中此Heading 1標題)...
        """
        normalized_text = norm(text)
        #'1\tproduct feature summary'...'9\tl10 test plan'...
        #依據case_sensitive True/False, 使用不同的norm函數
        #再依據exact True/False ,決定用== 還是in 邏輯 
        if exact:
            # 精確匹配必須完全相同
            return normalized_text == search_key
        else:
            # 模糊匹配只要包含搜尋文字即可
            # 例如: 搜尋 'l10 test plan' 可以匹配
            # '9\tl10 test plan'
            return search_key in normalized_text
            
    
    sections = [] # 儲存所有找到的章節
    current_section = None # 當前正在處理的章節
    
    # 遍歷所有 body 元素
    for body_idx, elem in enumerate(body_children):
        if elem.tag.endswith('p'):
        #if type(elem).__name__ == "CT_P"
            # 將底層 XML 元素包裝成高層 Paragraph 對象
            para = Paragraph(elem, doc)
            #Paragraph(elem, body)
            """
            # 重要:這裡必須使用 doc 作為 parent,不能用 body
            為什麼要用 Paragraph(elem, doc):
            
doc 對象有 'part' 屬性允許訪問 para.style
可以正確獲取樣式名稱 (para.style.name)
這是手動創建 Paragraph 時訪問 style 的唯一方法
            
不要使用 Paragraph(elem, body):
            - body (CT_Body) 沒有 'part' 屬性
            - 訪問 para.style 時會報錯'CT_Body' object has no attribute 'part'
            
            注意這種用法不是官方 API但在需要遍歷 body 元素
            並同時訪問樣式時這是可行的解決方案
            """
            
            #檢查這個段落是否為目標標題例如Heading 1)
            if para.style.name == style_name:
                # 完成前一個章節
                if current_section is not None:
                    current_section['body_end'] = body_idx
                    
                    # 檢查是否匹配搜索條件
                    if not target_text or matches_search(current_section['heading_text']):
                        sections.append(current_section)
                
                # 開始新章節
                current_section = {
                    'heading_text': para.text,# 標題文字
                    'body_start': body_idx,   # 章節開始位置包含標題本身
                    'body_end': None,         # 章節結束位置稍後填充
                    'tables': [],             # 章節內的表格索引列表
                    'paragraphs': [body_idx], # 章節內的段落索引列表含標題
                }
            else:
                # 記錄段落位置
                # 不是標題的段落記錄到當前章節中
                if current_section is not None:
                    current_section['paragraphs'].append(body_idx)
                    
        elif elem.tag.endswith('tbl'):
            # 記錄表格位置
            if current_section is not None:
                current_section['tables'].append(body_idx)
    
    # 處理最後一個章節
    if current_section is not None:
        current_section['body_end'] = len(body_children)
        if not target_text or matches_search(current_section['heading_text']):
            sections.append(current_section)
    
    # 添加統計信息
    for section in sections:
        section['table_count'] = len(section['tables'])
        section['paragraph_count'] = len(section['paragraphs'])
    
    return sections

# 測試查找功能
print("\n🔍 查找所有章節:")
all_sections = find_heading_boundaries(doc)
for s in all_sections:
    print(f"\n📍 {s['heading_text']}")
    print(f"   範圍: body[{s['body_start']}:{s['body_end']}]")
    print(f"   包含: {s['paragraph_count']} 個段落, {s['table_count']} 個表格")

# 查找特定章節
print("\n🔍 查找包含 'PCBA' 的章節:")
pcba_sections = find_heading_boundaries(doc, "PCBA")
for s in pcba_sections:
    print(f"   找到: {s['heading_text']}")
    
"""find_heading_boundaries(doc)
[{'heading_text': '1\tProduct Feature Summary',
  'body_start': 31,
  'body_end': 33,
  'tables': [],
  'paragraphs': [31, 32],
  'table_count': 0,
  'paragraph_count': 2},
   {'heading_text': '2\tPCBA (L6) Test Plan of Record',
  'body_start': 33,
  'body_end': 52,
  'tables': [35],
  'paragraphs': [33, 34, 36, 37, 38, 39, 40, 42, 43, 44, 46, 47, 48, 50, 51],
  'table_count': 1,
  'paragraph_count': 15},...
"""

輸出:

Paragraph(elem, body) 的作用是:

  1. elem:段落的 XML 元素(CT_P 對象)
  2. body:包含這個段落的父容器
    #使用doc當成父容器才有完整的屬性可以使用

這個構造方式讓你能夠:

  • 將底層的 XML 元素轉換為高級的 Paragraph 對象
  • 保留文檔的完整結構和順序
  • 處理 doc.paragraphs 無法處理的特殊情況
    #doc.paragraphs 無法獲取所有段落順序(跳過表格…)

🗑️ 刪除章節(最終實現)

def delete_sections_by_heading(doc, remove_texts, 
                              case_sensitive=False, 
                              exact=False,
                              keep_heading=False,
                              dry_run=False):
    """
    刪除指定的章節,包含其中的所有內
    """
    if not remove_texts:
        print("❌ 沒有指定要刪除的章節")
        return doc
    
    # 確保 remove_texts 是列表
    if isinstance(remove_texts, str):
        remove_texts = [remove_texts]
    
    # 收集所有要刪除的章節
    all_targets = []
    for text in remove_texts:
        matches = find_heading_boundaries(doc, text, "Heading 1", 
        exact, case_sensitive)
        all_targets.extend(matches)
    
    # 去重
    unique_targets = []
    seen = set()
    for target in all_targets:
        key = target['body_start']
        if key not in seen:
            seen.add(key)
            unique_targets.append(target)
    
    if not unique_targets:
        print(f"❌ 沒有找到匹配的章節")
        return doc
    
    print(f"\n🎯 找到 {len(unique_targets)} 個要刪除的章節")
    
    if dry_run:
        print("\n📋 預覽模式 - 將會刪除以下章節:")
        for section in unique_targets:
            print(f"\n   章節: {section['heading_text']}")
            print(f"   範圍: body[{section['body_start']}:{section['body_end']}]")
            print(f"   內容: {section['paragraph_count']} 段落, {section['table_count']} 表格")
            if keep_heading:
                print("   (保留標題)")
        return doc
    
    # 執行刪除
    body = doc.element.body
    
    # 按位置倒序刪除避免索引變化
    for section in sorted(unique_targets, key=lambda x: x['body_start'], reverse=True):
        print(f"\n🗑️ 正在刪除: {section['heading_text']}")
        
        # 確定刪除範圍
        start = section['body_start'] + 1 if keep_heading else section['body_start']
        end = section['body_end']
        
        # 收集要刪除的元素
        elements_to_remove = []
        for i in range(start, end):
            if i < len(body):
                elements_to_remove.append(body[i])
        
        # 執行刪除
        removed = {'paragraphs': 0, 'tables': 0}
        for elem in elements_to_remove:
            try:
                body.remove(elem)
                if elem.tag.endswith('tbl'):
                    removed['tables'] += 1
                else:
                    removed['paragraphs'] += 1
            except:
                pass
        
        print(f"   ✅ 已刪除: {removed['paragraphs']} 段落, {removed['tables']} 表格")
    
    return doc

# 測試刪除功能
# 1. 預覽模式
print("\n" + "="*60)
print("測試 1: 預覽刪除包含 'PCBA' 的章節")
delete_sections_by_heading(doc, "PCBA", dry_run=True)

# 2. 實際刪除並保存
print("\n" + "="*60)
print("測試 2: 實際刪除並保存")
doc_copy = Document(r'D:\Temp\test_document.docx')  # 重新載入
delete_sections_by_heading(doc_copy, ["PCBA", "環境"], dry_run=False)
doc_copy.save(r'D:\Temp\test_document_modified.docx')
print("\n✅ 已保存修改後的文檔:test_document_modified.docx")

# 3. 驗證結果
print("\n" + "="*60)
print("驗證刪除結果:")
doc_modified = Document(r'D:\Temp\test_document_modified.docx')
remaining_sections = find_heading_boundaries(doc_modified)
print(f"\n剩餘章節數:{len(remaining_sections)}")
for s in remaining_sections:
    print(f"   ✓ {s['heading_text']}")

刪除表格的方式:

# 方法1直接從父元素移除
table_element.getparent().remove(table_element)

# 方法2 body 移除
body.remove(table_element)

輸出結果:

test_document_modified.docx:

📚 完整的測試腳本

將所有功能整合在一起:

# 完整測試流程
def run_complete_test():
    """執行完整的測試流程"""
    print("🚀 開始完整測試流程\n")
    
    # 1. 創建測試文檔
    print("步驟 1: 創建測試文檔")
    from docx import Document
    doc = Document()
    doc.add_heading('測試文檔', 0)
    
    # 添加多個章節
    sections_data = [
        ("1. 產品概述", ["這是產品介紹", "包含基本信息"], True),
        ("2. PCBA測試規範", ["測試流程說明", "測試標準定義"], True),
        ("3. 軟體測試", ["軟體功能測試", "性能測試"], False),
        ("4. PCBA維修指南", ["維修流程", "注意事項"], True),
        ("5. 品質保證", ["品質標準", "檢驗流程"], False),
    ]
    
    for title, paragraphs, add_table in sections_data:
        doc.add_heading(title, 1)
        for p in paragraphs:
            doc.add_paragraph(p)
        if add_table:
            table = doc.add_table(rows=2, cols=2)
            table.style = 'Light List Accent 1'
            table.rows[0].cells[0].text = '項目'
            table.rows[0].cells[1].text = '說明'
    
    doc.save('complete_test.docx')
    print("   ✅ 文檔已創建\n")
    
    # 2. 分析文檔結構
    print("步驟 2: 分析文檔結構")
    doc = Document('complete_test.docx')
    sections = find_heading_boundaries(doc)
    print(f"   找到 {len(sections)} 個章節\n")
    
    # 3. 測試不同的刪除場景
    print("步驟 3: 測試各種刪除場景\n")
    
    # 場景1: 刪除包含特定關鍵字的章節
    print("   場景 1: 刪除所有包含 'PCBA' 的章節")
    doc1 = Document('complete_test.docx')
    delete_sections_by_heading(doc1, "PCBA", dry_run=False)
    doc1.save('test_result_1.docx')
    print("   ✅ 已保存到 test_result_1.docx\n")
    
    # 場景2: 精確匹配刪除
    print("   場景 2: 精確匹配刪除 '3. 軟體測試'")
    doc2 = Document('complete_test.docx')
    delete_sections_by_heading(doc2, "3. 軟體測試", exact=True, dry_run=False)
    doc2.save('test_result_2.docx')
    print("   ✅ 已保存到 test_result_2.docx\n")
    
    # 場景3: 保留標題只刪除內容
    print("   場景 3: 刪除 '品質保證' 但保留標題")
    doc3 = Document('complete_test.docx')
    delete_sections_by_heading(doc3, "品質保證", keep_heading=True, dry_run=False)
    doc3.save('test_result_3.docx')
    print("   ✅ 已保存到 test_result_3.docx\n")
    
    print("🎉 測試完成!請查看生成的文檔文件。")

# 執行測試
run_complete_test()

輸出:

test_result_1.docx

🎓 總結與最佳實踐

關鍵概念

  1. 段落索引 vs Body索引:段落索引只計算段落,Body索引包含所有元素
  2. 表格不佔用段落索引:這是最常見的錯誤來源
  3. 倒序刪除:避免索引變化影響

使用建議

  • 總是使用 dry_run=True 先預覽
  • 保存原始文檔的備份
  • 使用視覺化函數理解文檔結構

進階應用

  • 可以擴展支持 Heading 2, Heading 3 等多層級
  • 可以添加更多過濾條件(如日期、作者等)
  • 可以整合到自動化文檔處理流程中

這個完整的解決方案解決了技術債問題,提供了準確、可靠的章節刪除功能!

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

簡化版的 find_heading_boundaries:

# %%
def find_heading_boundaries(doc, target_text="", style_name=HEADING1,
                            exact=False, 
                            case_sensitive=False) -> list[tuple[int, int, str]]:
    """
    取得符合 target_text 的指定層級 Heading 區間邊界
    回傳: list[(start_idx, end_idx, heading_text)]
    start_idx: 目標 Heading 所在段落索引
    end_idx  : 下一個同級 Heading  start_idx最後一章節為 len(doc.paragraphs)
    exact=True  -> 文字完全比對 (strip )
    exact=False -> target_text 為子字串
    case_sensitive=True  -> 大小寫敏感 (原樣比對)
    case_sensitive=False -> 忽略大小寫 (統一轉小寫後比對)

    target_text="" , exact=False,因為 "" in 任意字串為 True
    會回傳所有 Heading 1  (start,end,text);
    功能上可取代build_heading_sections
    可讀性用空字串代表全部不直觀後續閱讀者需要知道這個技巧
    """
    tgt = target_text.strip()
    # if case_sensitive:
    #     tgt = tgt
    # else:
    #     tgt = tgt.lower()
    tgt = tgt if case_sensitive else tgt.lower()

    from docx.text.paragraph import Paragraph
    body = doc.element.body
    headings = []
    for i,elem in enumerate(body):
        if type(elem).__name__ == "CT_P":
            para = Paragraph(elem,doc)
            if para.style.name == style_name:
                headings.append( (i,para.text) )

    if not headings:
        return []

    total = len(body)
    out = []
    for k, (start_idx, raw_text) in enumerate(headings):
        
        comp = raw_text.strip().replace("\r", "").replace("\n", " ")
        #處理": ('9\tFixture and capacity plan\nTest Capacity Plan(TBD)', 'Heading 1', 152)
        # 標題本 不該  換行符號,其實是範本的問題,從範本中修訂:
        #('9\tFixture and capacity plan', 'Heading 1')
        # 使用 exact=False ; in 邏輯 會比較能夠匹配實際狀況
        if case_sensitive:
            comp_norm = comp
        else:
            comp_norm = comp.lower()
        hit = (comp_norm == tgt) if exact else (tgt in comp_norm)
        if hit:
            end_idx = headings[k + 1][0] if k + 1 < len(headings) else total
            out.append((start_idx, end_idx, raw_text))
    return out

更簡化版的 find_heading_boundaries:

def find_heading_boundaries(doc, target_text="", style_name=HEADING1,
                            exact=False, case_sensitive=False):
    from docx.text.paragraph import Paragraph
    body = doc.element.body
    tgt = target_text.strip().lower() if not case_sensitive else target_text.strip()
    
    # 找出所有符合的標題索引
    matches = []
    all_headings = []
    
    for i, elem in enumerate(body):
        if type(elem).__name__ == "CT_P":
            p = Paragraph(elem, doc)
            if p.style.name == style_name:
                all_headings.append(i)
                comp = p.text.strip().lower() if not case_sensitive else p.text.strip()
                if (comp == tgt if exact else tgt in comp):
                    matches.append((i, p.text))
    
    # 計算結束位置
    return [
        (start, next((h for h in all_headings if h > start), len(body)), text)
        for start, text in matches
    ]

改良版:

def find_heading_boundaries(doc, target_text="", style_name=None,
                            exact=False, case_sensitive=False):
    from docx.text.paragraph import Paragraph
    body = doc.element.body
    tgt = target_text.strip().lower() if not case_sensitive else target_text.strip()
    
    # 預處理 style_name
    if isinstance(style_name, str):
        style_name = [style_name]
    
    matches = []
    all_headings = []
    
    for i, elem in enumerate(body):
        if type(elem).__name__ == "CT_P":
            p = Paragraph(elem, doc)
            style = p.style.name
            
            # 檢查樣式
            is_target_style = (
                style.startswith("Heading") if style_name is None 
                else style in style_name
            )
            
            if is_target_style:
                all_headings.append(i)
                comp = p.text.strip().lower() if not case_sensitive else p.text.strip()
                if (comp == tgt if exact else tgt in comp):
                    matches.append((i, p.text))
    # 計算每個符合標題的區間範圍
    return [
        (start, # 標題開始位置
        # 找下一個標題位置若無則用文件結尾
        next((h for h in all_headings if h > start), len(body)), 
        text # 標題文字
        )
        for start, text in matches
    ]

改良版(typing / 註解完整):

from typing import List, Tuple, Union, Optional
from docx import Document

def find_heading_boundaries(
    doc: Document, 
    target_text: str = "", 
    style_name: Union[str, List[str], None] = None,
    exact: bool = False, 
    case_sensitive: bool = False
) -> List[Tuple[int, int, str]]:
    """
    取得符合條件的標題章節邊界
    
    在文件中尋找符合指定文字的標題並回傳每個符合標題的段落範圍
    可用於定位擷取或刪除特定章節
    
    Args:
        doc: python-docx Document 物件
        target_text: 要搜尋的文字內容
            - 空字串 "" (預設): 回傳所有符合樣式的標題
            - 非空字串: 根據 exact 參數進行比對
        style_name: 要搜尋的標題樣式
            - None (預設): 所有 "Heading" 開頭的樣式
            - str: 單一樣式名稱 "Heading 1"
            - List[str]: 多個樣式名稱 ["Heading 1", "Heading 2"]
        exact: 是否精確比對
            - False (預設): target_text 為子字串即符合
            - True: 必須完全相同會先 strip 空白
        case_sensitive: 是否區分大小寫
            - False (預設): 忽略大小寫差異
            - True: 大小寫必須相符
    
    Returns:
        List[Tuple[int, int, str]]: 符合條件的標題清單每個元素包含
            - [0] start_idx: 標題段落在 body 中的索引
            - [1] end_idx: 下個同級標題的索引或文件結尾
            - [2] heading_text: 標題的原始文字內容
    
    Examples:
        >>> # 找出所有 Heading 1 標題
        >>> sections = find_heading_boundaries(doc, style_name="Heading 1")
        
        >>> # 找出包含 "Test Plan" 的所有標題
        >>> test_sections = find_heading_boundaries(doc, "Test Plan")
        
        >>> # 精確尋找特定標題
        >>> exact_section = find_heading_boundaries(
        ...     doc, "9\tL10 Test Plan", 
        ...     style_name="Heading 1", 
        ...     exact=True
        ... )
        
        >>> # 找出 Heading 1  2 中包含 "test" 的章節不分大小寫
        >>> multi_level = find_heading_boundaries(
        ...     doc, "test",
        ...     style_name=["Heading 1", "Heading 2"],
        ...     case_sensitive=False
        ... )
    
    Note:
        - 標題文字可能包含特殊字元如 \\t (tab)  \\n (換行)
        - end_idx 指向下一個同級標題的開始位置可用於擷取完整章節內容
        -  target_text=""  exact=False 會回傳所有符合樣式的標題
    """
    from docx.text.paragraph import Paragraph
    
    # 取得文件主體元素
    body = doc.element.body
    
    # 預處理搜尋文字
    tgt = target_text.strip()
    if not case_sensitive:
        tgt = tgt.lower()
    
    # 統一處理 style_name  list 格式單一字串轉為 list
    if isinstance(style_name, str):
        style_name = [style_name]
    
    # 儲存符合條件的標題和所有目標樣式的標題位置
    matches: List[Tuple[int, str]] = []  # [(段落索引, 標題文字), ...]
    all_headings: List[int] = []  # 所有符合樣式的標題索引用於計算區間
    
    # 遍歷文件主體的所有元素
    for i, elem in enumerate(body):
        # 只處理段落元素CT_P = Complex Type Paragraph
        if type(elem).__name__ == "CT_P":
            p = Paragraph(elem, doc)
            style = p.style.name
            
            # 判斷是否為目標樣式
            is_target_style = (
                style.startswith("Heading") if style_name is None  
                # None = 所有 Heading
                else style in style_name  # 檢查是否在指定樣式清單中
            )
            
            if is_target_style:
                # 記錄所有目標樣式的標題位置用於後續計算區間範圍
                all_headings.append(i)
                
                # 準備比對用的文字根據 case_sensitive 決定是否轉小寫
                comp = p.text.strip()
                if not case_sensitive:
                    comp = comp.lower()
                
                # 執行文字比對只有匹配的才加入結果
                is_match = (comp == tgt) if exact else (tgt in comp)
                
                if is_match:
                    # 保留原始文字不做 lower 處理
                    matches.append((i, p.text))
    
    # 計算每個符合標題的區間範圍
    total_elements = len(body)
    results: List[Tuple[int, int, str]] = []
    
    for start_idx, heading_text in matches:
        # 使用 next() 找出下一個同級標題的位置
        # 如果沒有下一個標題則以文件結尾為邊界
        end_idx = next(
            (h for h in all_headings if h > start_idx),  # 找第一個大於 start_idx 的標題
            total_elements  # 預設值文件結尾
        )
        results.append((start_idx, end_idx, heading_text))
    
    return results

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

儲蓄保險王

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