424 lines
16 KiB
Python
424 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
ABOUTME: Resolves automatic numbering labels from DOCX documents
|
|
ABOUTME: Parses numbering.xml and computes rendered number strings
|
|
"""
|
|
|
|
import zipfile
|
|
from defusedxml import ElementTree as ET
|
|
from typing import Dict
|
|
|
|
NSMAP = {"w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main"}
|
|
|
|
|
|
class NumberingResolver:
|
|
"""
|
|
Resolves paragraph numbering to rendered label strings.
|
|
|
|
DOCX stores numbering definitions in numbering.xml:
|
|
- abstractNum: Defines format templates (lvlText like "%1.%2.")
|
|
- num: Links numId to abstractNumId
|
|
|
|
Each paragraph references: numId (which definition) + ilvl (which level)
|
|
"""
|
|
|
|
# Number format converters
|
|
FORMAT_CONVERTERS = {
|
|
"decimal": lambda n: str(n),
|
|
"lowerLetter": lambda n: chr(ord("a") + (n - 1) % 26),
|
|
"upperLetter": lambda n: chr(ord("A") + (n - 1) % 26),
|
|
"lowerRoman": lambda n: NumberingResolver._to_roman(n).lower(),
|
|
"upperRoman": lambda n: NumberingResolver._to_roman(n),
|
|
"chineseCountingThousand": lambda n: NumberingResolver._to_chinese(n),
|
|
"ideographTraditional": lambda n: "甲乙丙丁戊己庚辛壬癸"[(n - 1) % 10],
|
|
"bullet": lambda n: "•",
|
|
"none": lambda n: "",
|
|
}
|
|
|
|
def __init__(self, docx_path: str):
|
|
self.abstract_nums: Dict[str, dict] = {} # abstractNumId -> level definitions
|
|
self.num_to_abstract: Dict[str, str] = {} # numId -> abstractNumId
|
|
self.counters: Dict[
|
|
str, Dict[int, int]
|
|
] = {} # numId -> {ilvl -> current_count}
|
|
self.start_overrides: Dict[
|
|
str, Dict[int, int]
|
|
] = {} # numId -> {ilvl -> start_value}
|
|
self.style_numpr: Dict[
|
|
str, dict
|
|
] = {} # styleId -> {numId, ilvl} from styles.xml
|
|
self.style_based_on: Dict[str, str] = {} # styleId -> basedOn styleId
|
|
# Smart numbering merge state (Word's rendering behavior)
|
|
self.last_numId: str = None # Previous paragraph's numId
|
|
self.last_abstract_id: str = None # Previous paragraph's abstractNumId
|
|
self.last_style_id: str = None # Previous paragraph's style ID
|
|
self._parse_numbering_xml(docx_path)
|
|
self._parse_styles_xml(docx_path)
|
|
|
|
def _parse_numbering_xml(self, docx_path: str):
|
|
"""Parse numbering.xml from DOCX archive"""
|
|
try:
|
|
with zipfile.ZipFile(docx_path, "r") as zf:
|
|
if "word/numbering.xml" not in zf.namelist():
|
|
return
|
|
|
|
tree = ET.parse(zf.open("word/numbering.xml"))
|
|
root = tree.getroot()
|
|
|
|
# Parse abstractNum definitions
|
|
for abstract in root.findall(".//w:abstractNum", NSMAP):
|
|
abstract_id = abstract.get(f"{{{NSMAP['w']}}}abstractNumId")
|
|
levels = {}
|
|
|
|
for lvl in abstract.findall("w:lvl", NSMAP):
|
|
ilvl = int(lvl.get(f"{{{NSMAP['w']}}}ilvl"))
|
|
|
|
start_elem = lvl.find("w:start", NSMAP)
|
|
start = (
|
|
int(start_elem.get(f"{{{NSMAP['w']}}}val"))
|
|
if start_elem is not None
|
|
else 1
|
|
)
|
|
|
|
num_fmt_elem = lvl.find("w:numFmt", NSMAP)
|
|
num_fmt = (
|
|
num_fmt_elem.get(f"{{{NSMAP['w']}}}val")
|
|
if num_fmt_elem is not None
|
|
else "decimal"
|
|
)
|
|
|
|
lvl_text_elem = lvl.find("w:lvlText", NSMAP)
|
|
lvl_text = (
|
|
lvl_text_elem.get(f"{{{NSMAP['w']}}}val")
|
|
if lvl_text_elem is not None
|
|
else "%1."
|
|
)
|
|
|
|
is_lgl_elem = lvl.find("w:isLgl", NSMAP)
|
|
is_lgl = False
|
|
if is_lgl_elem is not None:
|
|
val = is_lgl_elem.get(f"{{{NSMAP['w']}}}val")
|
|
is_lgl = val is None or val not in ("0", "false")
|
|
|
|
levels[ilvl] = {
|
|
"start": start,
|
|
"numFmt": num_fmt,
|
|
"lvlText": lvl_text,
|
|
"isLgl": is_lgl,
|
|
}
|
|
|
|
self.abstract_nums[abstract_id] = levels
|
|
|
|
# Parse num -> abstractNum mapping and startOverride
|
|
for num in root.findall(".//w:num", NSMAP):
|
|
num_id = num.get(f"{{{NSMAP['w']}}}numId")
|
|
abstract_ref = num.find("w:abstractNumId", NSMAP)
|
|
if abstract_ref is not None:
|
|
self.num_to_abstract[num_id] = abstract_ref.get(
|
|
f"{{{NSMAP['w']}}}val"
|
|
)
|
|
|
|
# Parse lvlOverride/startOverride for this num
|
|
for lvl_override in num.findall("w:lvlOverride", NSMAP):
|
|
ilvl = int(lvl_override.get(f"{{{NSMAP['w']}}}ilvl"))
|
|
start_override = lvl_override.find("w:startOverride", NSMAP)
|
|
if start_override is not None:
|
|
start_val = int(start_override.get(f"{{{NSMAP['w']}}}val"))
|
|
if num_id not in self.start_overrides:
|
|
self.start_overrides[num_id] = {}
|
|
self.start_overrides[num_id][ilvl] = start_val
|
|
except Exception:
|
|
# Silently ignore parsing errors - document may not have numbering
|
|
pass
|
|
|
|
def _parse_styles_xml(self, docx_path: str):
|
|
"""Parse styles.xml to get style-inherited numbering definitions"""
|
|
try:
|
|
with zipfile.ZipFile(docx_path, "r") as zf:
|
|
if "word/styles.xml" not in zf.namelist():
|
|
return
|
|
|
|
tree = ET.parse(zf.open("word/styles.xml"))
|
|
root = tree.getroot()
|
|
|
|
# Parse style definitions
|
|
for style in root.findall(".//w:style", NSMAP):
|
|
style_id = style.get(f"{{{NSMAP['w']}}}styleId")
|
|
if not style_id:
|
|
continue
|
|
|
|
# Check for basedOn (style inheritance)
|
|
based_on = style.find("w:basedOn", NSMAP)
|
|
if based_on is not None:
|
|
parent_id = based_on.get(f"{{{NSMAP['w']}}}val")
|
|
if parent_id:
|
|
self.style_based_on[style_id] = parent_id
|
|
|
|
# Check for numPr in style's pPr
|
|
pPr = style.find("w:pPr", NSMAP)
|
|
if pPr is not None:
|
|
numPr = pPr.find("w:numPr", NSMAP)
|
|
if numPr is not None:
|
|
num_id_elem = numPr.find("w:numId", NSMAP)
|
|
ilvl_elem = numPr.find("w:ilvl", NSMAP)
|
|
|
|
if num_id_elem is not None:
|
|
num_id = num_id_elem.get(f"{{{NSMAP['w']}}}val")
|
|
ilvl = (
|
|
int(ilvl_elem.get(f"{{{NSMAP['w']}}}val"))
|
|
if ilvl_elem is not None
|
|
else 0
|
|
)
|
|
self.style_numpr[style_id] = {
|
|
"numId": num_id,
|
|
"ilvl": ilvl,
|
|
}
|
|
except Exception:
|
|
# Silently ignore parsing errors
|
|
pass
|
|
|
|
def _get_numbering_from_style(self, style_id: str, visited=None) -> dict:
|
|
"""
|
|
Get numbering definition from style, following inheritance chain.
|
|
|
|
Args:
|
|
style_id: Style ID to look up
|
|
visited: Set of visited style IDs (to prevent circular references)
|
|
|
|
Returns:
|
|
dict with 'numId' and 'ilvl', or None
|
|
"""
|
|
if visited is None:
|
|
visited = set()
|
|
|
|
# Prevent circular references
|
|
if style_id in visited:
|
|
return None
|
|
visited.add(style_id)
|
|
|
|
# Check if this style has numPr
|
|
if style_id in self.style_numpr:
|
|
return self.style_numpr[style_id]
|
|
|
|
# Check parent style
|
|
if style_id in self.style_based_on:
|
|
parent_id = self.style_based_on[style_id]
|
|
return self._get_numbering_from_style(parent_id, visited)
|
|
|
|
return None
|
|
|
|
def reset_tracking_state(self):
|
|
"""
|
|
Reset numbering tracking state.
|
|
|
|
Call this when encountering structural breaks that should
|
|
interrupt numbering continuity:
|
|
- Section breaks (sectPr)
|
|
- Table boundaries (before and after tables)
|
|
|
|
This prevents incorrect numbering continuation across
|
|
document structure boundaries.
|
|
"""
|
|
self.last_numId = None
|
|
self.last_abstract_id = None
|
|
self.last_style_id = None
|
|
|
|
def get_label(self, para_element) -> str:
|
|
"""
|
|
Get rendered numbering label for a paragraph.
|
|
|
|
Checks both direct numPr and style-inherited numbering. Direct numPr
|
|
is a paragraph-local override and applies only to the current
|
|
paragraph; subsequent paragraphs that carry only pStyle fall back to
|
|
the style's numPr declared in styles.xml.
|
|
|
|
Args:
|
|
para_element: lxml Element for <w:p>
|
|
|
|
Returns:
|
|
Rendered label string (e.g., "1.1", "a)", "第一章") or empty string
|
|
"""
|
|
try:
|
|
pPr = para_element.find(f"{{{NSMAP['w']}}}pPr")
|
|
if pPr is None:
|
|
return ""
|
|
|
|
num_id = None
|
|
ilvl = 0
|
|
style_id = None
|
|
|
|
# Get pStyle (if present)
|
|
pStyle = pPr.find(f"{{{NSMAP['w']}}}pStyle")
|
|
if pStyle is not None:
|
|
style_id = pStyle.get(f"{{{NSMAP['w']}}}val")
|
|
|
|
# Check for direct numPr in paragraph
|
|
numPr = pPr.find(f"{{{NSMAP['w']}}}numPr")
|
|
if numPr is not None:
|
|
num_id_elem = numPr.find(f"{{{NSMAP['w']}}}numId")
|
|
ilvl_elem = numPr.find(f"{{{NSMAP['w']}}}ilvl")
|
|
|
|
if num_id_elem is not None:
|
|
num_id = num_id_elem.get(f"{{{NSMAP['w']}}}val")
|
|
ilvl = (
|
|
int(ilvl_elem.get(f"{{{NSMAP['w']}}}val"))
|
|
if ilvl_elem is not None
|
|
else 0
|
|
)
|
|
|
|
# If no direct numPr, fall back to style-inherited numbering.
|
|
# Direct numPr is a paragraph-local override in Word; it must not
|
|
# persist as a runtime default for the style, otherwise subsequent
|
|
# paragraphs that only carry pStyle will keep following the local
|
|
# override instead of the style's declared numPr.
|
|
if num_id is None and style_id:
|
|
style_num = self._get_numbering_from_style(style_id)
|
|
if style_num:
|
|
num_id = style_num["numId"]
|
|
ilvl = style_num["ilvl"]
|
|
|
|
# If still no numbering found, clear state and return empty
|
|
if num_id is None:
|
|
# We should use list structure breaking logic to reset last_numId, last_abstract_id and last_style_id
|
|
return ""
|
|
|
|
# Get abstract definition
|
|
abstract_id = self.num_to_abstract.get(num_id)
|
|
if abstract_id is None or abstract_id not in self.abstract_nums:
|
|
# Clear state for invalid numbering
|
|
self.last_numId = None
|
|
self.last_abstract_id = None
|
|
return ""
|
|
|
|
levels = self.abstract_nums[abstract_id]
|
|
if ilvl not in levels:
|
|
# Clear state for invalid level
|
|
self.last_numId = None
|
|
self.last_abstract_id = None
|
|
return ""
|
|
|
|
# Smart numbering merge: (Word's rendering behavior)
|
|
# When consecutive paragraphs have different numId but same abstractNumId,
|
|
# Word continues the numbering sequence rather than restarting.
|
|
# This happens regardless of whether the numId is new or style matches.
|
|
|
|
if (
|
|
self.last_numId is not None
|
|
and self.last_numId != num_id
|
|
and self.last_abstract_id == abstract_id
|
|
and self.last_numId in self.counters
|
|
):
|
|
# Merge: copy previous numId's counter to current numId
|
|
self.counters[num_id] = self.counters[self.last_numId].copy()
|
|
|
|
# Initialize/update counter
|
|
if num_id not in self.counters:
|
|
self.counters[num_id] = {}
|
|
|
|
# Initialize all parent levels if not present (for deep nested numbering)
|
|
for i in range(ilvl):
|
|
if i not in self.counters[num_id] and i in levels:
|
|
# Use startOverride if exists, otherwise use abstractNum's start value
|
|
if (
|
|
num_id in self.start_overrides
|
|
and i in self.start_overrides[num_id]
|
|
):
|
|
self.counters[num_id][i] = self.start_overrides[num_id][i]
|
|
else:
|
|
self.counters[num_id][i] = levels[i]["start"]
|
|
|
|
# Reset lower levels when higher level increments
|
|
for i in range(ilvl + 1, 10):
|
|
if i in self.counters[num_id]:
|
|
del self.counters[num_id][i]
|
|
|
|
# Initialize current level if needed
|
|
if ilvl not in self.counters[num_id]:
|
|
# Use startOverride if exists, otherwise use abstractNum's start value
|
|
if (
|
|
num_id in self.start_overrides
|
|
and ilvl in self.start_overrides[num_id]
|
|
):
|
|
self.counters[num_id][ilvl] = self.start_overrides[num_id][ilvl]
|
|
else:
|
|
self.counters[num_id][ilvl] = levels[ilvl]["start"]
|
|
else:
|
|
self.counters[num_id][ilvl] += 1
|
|
|
|
# Format the label using lvlText template
|
|
label = self._format_label(num_id, ilvl, levels)
|
|
|
|
# Update tracking state for next paragraph
|
|
self.last_numId = num_id
|
|
self.last_abstract_id = abstract_id
|
|
self.last_style_id = style_id
|
|
|
|
return label
|
|
except Exception:
|
|
# Return empty on any error to avoid breaking document parsing
|
|
return ""
|
|
|
|
def _format_label(self, num_id: str, ilvl: int, levels: dict) -> str:
|
|
"""Format label string by replacing %1, %2, etc."""
|
|
try:
|
|
lvl_text = levels[ilvl]["lvlText"]
|
|
result = lvl_text
|
|
current_is_lgl = levels[ilvl].get("isLgl", False)
|
|
|
|
for i in range(ilvl + 1):
|
|
if i in levels and i in self.counters.get(num_id, {}):
|
|
num_fmt = levels[i]["numFmt"]
|
|
if current_is_lgl and i < ilvl:
|
|
num_fmt = "decimal"
|
|
count = self.counters[num_id][i]
|
|
converter = self.FORMAT_CONVERTERS.get(num_fmt, lambda n: str(n))
|
|
formatted = converter(count)
|
|
result = result.replace(f"%{i + 1}", formatted)
|
|
|
|
return result
|
|
except Exception:
|
|
return ""
|
|
|
|
@staticmethod
|
|
def _to_roman(n: int) -> str:
|
|
"""Convert integer to Roman numeral"""
|
|
if n <= 0 or n >= 4000:
|
|
return str(n)
|
|
values = [
|
|
(1000, "M"),
|
|
(900, "CM"),
|
|
(500, "D"),
|
|
(400, "CD"),
|
|
(100, "C"),
|
|
(90, "XC"),
|
|
(50, "L"),
|
|
(40, "XL"),
|
|
(10, "X"),
|
|
(9, "IX"),
|
|
(5, "V"),
|
|
(4, "IV"),
|
|
(1, "I"),
|
|
]
|
|
result = ""
|
|
for value, numeral in values:
|
|
while n >= value:
|
|
result += numeral
|
|
n -= value
|
|
return result
|
|
|
|
@staticmethod
|
|
def _to_chinese(n: int) -> str:
|
|
"""Convert integer to Chinese numeral"""
|
|
digits = "零一二三四五六七八九"
|
|
if n <= 0 or n > 99:
|
|
return str(n)
|
|
if n < 10:
|
|
return digits[n]
|
|
if n < 20:
|
|
return "十" + (digits[n % 10] if n % 10 else "")
|
|
if n < 100:
|
|
tens = n // 10
|
|
ones = n % 10
|
|
return digits[tens] + "十" + (digits[ones] if ones else "")
|
|
return str(n)
|