Microsoft Wordの.docxファイルから「番号付きの見出し」を抽出したい、というニーズは多くの現場で存在します。
.docxファイルの構造はOpen XML仕様として公開されており、基本的には.docxを.zipファイルとして解凍し、word/document.xml
ファイルを参照することで、本文の情報を得ることができます。
しかし、単純にdocument.xml
だけを解析しても、Word上で表示される見出し番号や階層構造を正確に再現することはできません。なぜなら、見出しの番号付与ルールやスタイル情報は、document.xml
以外の複数のXMLファイルに分散して定義されているからです。
見出し番号の構造
Wordの見出し番号は以下の要素の組み合わせで決まります:
-
段落ごとの
<w:numPr>
: 段落自身に番号付けが記録されている場合はこれを最優先します -
スタイルに紐づく
<w:numPr>
: 段落に<w:numPr>
が無い場合、適用されているスタイルにデフォルトの番号付与ルールがあればそれを利用します -
numbering.xml: 番号付与ルール本体が定義されます。どのレベルでどんな形式(decimal, upperLetter, lowerRomanなど)の番号が使われるか、また
%1.%2.
のように親レベルを含むかどうかもここで指定されます -
styles.xml: 各段落スタイル(Heading1, Heading2など)がどの番号付与ルールに紐付いているか、またアウトラインレベル(
<w:outlineLvl>
)が何かが記述されています
特に、見出し判定にはスタイル名が「Heading1」「Heading2」だからといって盲信するのではなく、<w:outlineLvl>
の値を参照するのが確実です。例えば Heading1
スタイルは通常 <w:outlineLvl w:val="0"/>
を持ち、Heading2
なら <w:outlineLvl w:val="1"/>
となります。
XMLサンプル
例えば、Wordのdocument.xml
には以下のような段落(見出し)が記述されています。
<w:p>
<w:pPr>
<w:pStyle w:val="Heading1" />
<w:numPr>
<w:ilvl w:val="0" />
<w:numId w:val="9" />
</w:numPr>
</w:pPr>
<w:r>
<w:t>見出し1だよー</w:t>
</w:r>
</w:p>
numbering.xml
には番号付与ルールが記述されています。例えば:
<w:numbering>
<w:abstractNum w:abstractNumId="9">
<w:lvl w:ilvl="0">
<w:numFmt w:val="decimal" />
<w:lvlText w:val="%1." />
</w:lvl>
<w:lvl w:ilvl="1">
<w:numFmt w:val="decimal" />
<w:lvlText w:val="%1.%2." />
</w:lvl>
</w:abstractNum>
<w:num w:numId="9">
<w:abstractNumId w:val="9" />
</w:num>
</w:numbering>
styles.xml
にはスタイル定義が記述されています。例えば:
<w:styles>
<w:style w:type="paragraph" w:styleId="Heading1">
<w:pPr>
<w:outlineLvl w:val="0" />
<w:numPr>
<w:ilvl w:val="0" />
<w:numId w:val="9" />
</w:numPr>
</w:pPr>
</w:style>
<w:style w:type="paragraph" w:styleId="Heading2">
<w:pPr>
<w:outlineLvl w:val="1" />
<w:numPr>
<w:ilvl w:val="1" />
<w:numId w:val="9" />
</w:numPr>
</w:pPr>
</w:style>
</w:styles>
Pythonサンプルコード
以下は、styles.xml
とnumbering.xml
を横断的に参照しながら見出しの番号パターンを抽出する例です。実際にWord上と同じ番号列を再現するには、ここで取得した<w:lvlText>
を元にカウンタ管理とプレースホルダ展開処理が必要になります。
import zipfile
from lxml import etree
import io
with zipfile.ZipFile("sample.docx", "r") as docx:
doc_xml = docx.read("word/document.xml")
styles_xml = docx.read("word/styles.xml")
numbering_xml = docx.read("word/numbering.xml")
doc_tree = etree.parse(io.BytesIO(doc_xml))
style_tree = etree.parse(io.BytesIO(styles_xml))
num_tree = etree.parse(io.BytesIO(numbering_xml))
# スタイルIDからデフォルトのnumId/ilvlとアウトラインレベルを取得
style_numpr_map = {}
for style in style_tree.xpath('//w:style', namespaces={'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}):
style_id = style.get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}styleId')
pPr = style.find('w:pPr', {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'})
if pPr is not None:
outline_lvl = pPr.find('w:outlineLvl', {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'})
numPr = pPr.find('w:numPr', {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'})
if outline_lvl is not None:
lvl_val = outline_lvl.get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val')
else:
lvl_val = None
if numPr is not None:
ilvl_elem = numPr.find('w:ilvl', {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'})
numId_elem = numPr.find('w:numId', {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'})
if numId_elem is not None:
numId = numId_elem.get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val')
ilvl = ilvl_elem.get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val') if ilvl_elem is not None else None
style_numpr_map[style_id] = {'numId': numId, 'ilvl': ilvl, 'outlineLvl': lvl_val}
# 段落ごとに見出し・番号を抽出
for p in doc_tree.xpath('//w:p', namespaces={'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}):
pPr = p.find('w:pPr', {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'})
pStyle = None
if pPr is not None:
pStyle_elem = pPr.find('w:pStyle', {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'})
if pStyle_elem is not None:
pStyle = pStyle_elem.get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val')
if pStyle and pStyle in style_numpr_map:
# スタイルからnumId/ilvl/outlineLvlを取得
numId = style_numpr_map[pStyle]['numId']
ilvl = style_numpr_map[pStyle]['ilvl']
outlineLvl = style_numpr_map[pStyle]['outlineLvl']
# numbering.xmlから番号パターンを取得
number = None
if numId and ilvl:
num_elem = num_tree.find(f".//w:num[@w:numId='{numId}']", {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'})
if num_elem is not None:
absnum_elem = num_elem.find('w:abstractNumId', {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'})
if absnum_elem is not None:
absnumid = absnum_elem.get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val')
lvl_elem = num_tree.find(f".//w:abstractNum[@w:abstractNumId='{absnumid}']/w:lvl[@w:ilvl='{ilvl}']", {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'})
if lvl_elem is not None:
lvlText_elem = lvl_elem.find('w:lvlText', {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'})
if lvlText_elem is not None:
number = lvlText_elem.get('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val')
text = ''.join([t.text for t in p.xpath('.//w:t', namespaces={'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}) if t.text])
print(f"見出し: {text} (スタイル: {pStyle}, アウトラインレベル: {outlineLvl}, 番号パターン: {number})")
まとめ
- 番号付き見出しは document.xml, styles.xml, numbering.xml の三者を突き合わせる必要がある
- 段落に
<w:numPr>
があればそれを優先し、無ければスタイルに定義された<w:numPr>
を利用する - 見出しかどうかはスタイル名ではなく
<w:outlineLvl>
を見るのが正しい - 実際の番号列再現には
%1
%2
プレースホルダを展開するカウンタロジックが必須
これらを踏まえれば、PythonからでもWord上と同じ階層付き番号を再現できます。