# Copyright (c) 2025 PaddlePaddle Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import re
from collections import Counter
from pathlib import Path
from ..base import BaseConverter, ConvertResult
from ..math import convert_omath as _convert_omath
from ..math import _M
from ..registry import default_registry
# Regex patterns for Chinese numbered headings
_RE_H2 = re.compile(r"^[一二三四五六七八九十百千]+[、..]")
_RE_H3 = re.compile(r"^([一二三四五六七八九十百千]+)")
# Regex for field-code hyperlink instruction
_RE_FIELD_HYPERLINK = re.compile(r'HYPERLINK\s+"([^"]+)"')
# Regex for page-number-only footer/header text (e.g. "第 页", "共 页", "- 3 -", "Page of")
_RE_PAGE_ONLY = re.compile(r"^[\s第页共of\d\-/|·]*$", re.IGNORECASE)
# Word XML namespace
_W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
_W = "{" + _W_NS + "}"
# Markup Compatibility namespace (mc:AlternateContent)
_MC_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006"
_MC = "{" + _MC_NS + "}"
# WordprocessingShape namespace (wps:txbx)
_WPS_NS = "http://schemas.microsoft.com/office/word/2010/wordprocessingShape"
_WPS = "{" + _WPS_NS + "}"
# Chart namespace
_C_NS = "http://schemas.openxmlformats.org/drawingml/2006/chart"
_C = "{" + _C_NS + "}"
# WordprocessingDrawing namespace (wp:inline, wp:anchor)
_WPD = "{http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing}"
# DrawingML main namespace
_A = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
# OPC relationships namespace
_R = "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}"
def _escape_md_url(url: str) -> str:
"""Escape parentheses in URL for Markdown link syntax."""
return url.replace("(", "%28").replace(")", "%29")
def _effective_bold(run, para) -> bool:
"""Resolve effective bold: run-level > character style > paragraph style."""
b = run.bold
if b is not None:
return b
try:
if run.style and run.style.font.bold is not None:
return run.style.font.bold
except Exception:
pass
try:
if para.style and para.style.font.bold is not None:
return para.style.font.bold
except Exception:
pass
return False
def _effective_italic(run, para) -> bool:
"""Resolve effective italic: run-level > character style > paragraph style."""
i = run.italic
if i is not None:
return i
try:
if run.style and run.style.font.italic is not None:
return run.style.font.italic
except Exception:
pass
try:
if para.style and para.style.font.italic is not None:
return para.style.font.italic
except Exception:
pass
return False
def _effective_underline(run, para) -> bool:
"""Resolve effective underline: run-level > character style > paragraph style."""
u = run.underline
if u is not None:
return bool(u)
try:
if run.style and run.style.font.underline is not None:
return bool(run.style.font.underline)
except Exception:
pass
try:
if para.style and para.style.font.underline is not None:
return bool(para.style.font.underline)
except Exception:
pass
return False
def _effective_superscript(run) -> bool:
"""Return True if run has superscript set (w:vertAlign w:val='superscript')."""
try:
return bool(run.font.superscript)
except Exception:
return False
def _effective_subscript(run) -> bool:
"""Return True if run has subscript set (w:vertAlign w:val='subscript')."""
try:
return bool(run.font.subscript)
except Exception:
return False
def _paragraph_has_math(para) -> bool:
"""Check if paragraph XML contains OMML math elements."""
return para._element.find(f".//{_M}oMath") is not None
def _iter_math_paragraph_parts(para) -> list:
"""Parse a paragraph with mixed text/math content into a list of parts.
Returns a list where each element is either:
- ("text", items_list) — a group of text runs to be formatted
- ("display_math", latex_str) — a display math block ($$...$$)
- ("inline_math", latex_str) — an inline math expression ($...$)
"""
from docx.text.run import Run
from docx.text.hyperlink import Hyperlink
text_items = []
parts = []
def flush_text():
if text_items:
parts.append(("text", list(text_items)))
text_items.clear()
for child in para._element:
tag = child.tag
local = tag.split("}")[-1] if "}" in tag else tag
if tag == f"{_M}oMathPara":
flush_text()
for omath in child.findall(f"{_M}oMath"):
latex = _convert_omath(omath)
if latex:
parts.append(("display_math", latex))
elif tag == f"{_M}oMath":
flush_text()
latex = _convert_omath(child)
if latex:
parts.append(("inline_math", latex))
elif local == "r":
try:
run = Run(child, para)
if run.text:
text_items.append(
(
_effective_bold(run, para),
_effective_italic(run, para),
_effective_underline(run, para),
bool(run.font.strike),
_effective_superscript(run),
_effective_subscript(run),
run.text,
"",
)
)
except Exception:
pass
elif local == "hyperlink":
try:
hl = Hyperlink(child, para)
try:
url = hl.url or ""
except (KeyError, AttributeError):
url = ""
for run in hl.runs:
if run.text:
text_items.append(
(
_effective_bold(run, para),
_effective_italic(run, para),
False,
bool(run.font.strike),
_effective_superscript(run),
_effective_subscript(run),
run.text,
url,
)
)
except Exception:
pass
flush_text()
return parts
def _paragraph_math_to_markdown(para) -> str:
"""Convert a paragraph containing OMML math to Markdown."""
result = []
for kind, data in _iter_math_paragraph_parts(para):
if kind == "text":
md = _runs_to_markdown(data)
if md:
result.append(md)
elif kind == "display_math":
result.append(f"$$\n{data}\n$$")
elif kind == "inline_math":
result.append(f"${data}$")
return "".join(result)
def _paragraph_math_to_html(para) -> str:
"""Convert a paragraph containing OMML math to HTML inline text."""
result = []
for kind, data in _iter_math_paragraph_parts(para):
if kind == "text":
html = _runs_to_html(data)
if html:
result.append(html)
elif kind == "display_math":
result.append(f"$$\n{data}\n$$")
elif kind == "inline_math":
result.append(f"${data}$")
return "".join(result)
def _get_body_font_size(doc) -> float:
"""Return the most common font size in the document (used as body size). Defaults to 16.0."""
sizes: Counter = Counter()
for p in doc.paragraphs:
if not p.text.strip():
continue
for run in p.runs:
if run.font.size:
sizes[run.font.size.pt] += 1
break # only check the first run with an explicit size per paragraph
return sizes.most_common(1)[0][0] if sizes else 16.0
def _detect_heading_level(para, body_font_size: float) -> int:
"""Return heading level (0 = not a heading, 1-6 = heading level)."""
# Prefer Word built-in Heading styles
style_name = para.style.name if para.style else ""
if style_name.startswith("Heading"):
try:
return int(style_name.split()[-1])
except ValueError:
return 1
if style_name == "Title":
return 1
if style_name == "Subtitle":
return 2
text = para.text.strip()
if not text:
return 0
# Use font size of the first run that has an explicit size
font_size = None
for run in para.runs:
if run.font.size:
font_size = run.font.size.pt
break
# Significantly larger than body font -> treat as heading (threshold: 1.5x, short paragraphs only)
if font_size and font_size > body_font_size * 1.5:
from docx.enum.text import WD_ALIGN_PARAGRAPH
if para.alignment == WD_ALIGN_PARAGRAPH.CENTER:
return 1
if len(text) <= 60:
return 2
# Chinese numbered heading patterns
if _RE_H2.match(text):
return 2
if _RE_H3.match(text):
return 3
return 0
class _FieldState:
"""Mutable state for tracking w:fldChar field codes across paragraphs."""
__slots__ = ("active", "phase", "nest_depth", "url")
active: bool
phase: object # None | "instr" | "result"
nest_depth: int
url: object # None | str
def __init__(self):
self.active = False
self.phase = None # None | "instr" | "result"
self.nest_depth = 0
self.url = None
def _update_field_state_for_paragraph(para_element, field_state):
"""Update field_state by scanning a paragraph element's runs.
Used for early-exit paragraphs (TOC / math / empty / code) to keep
cross-paragraph field tracking accurate without building item lists.
"""
for child in para_element:
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
if tag != "r":
continue
fld_char = child.find(_W + "fldChar")
if fld_char is not None:
fld_type = fld_char.get(_W + "fldCharType")
if fld_type == "begin":
if field_state.phase == "result":
field_state.nest_depth += 1
else:
field_state.active = True
field_state.phase = "instr"
field_state.url = None
elif fld_type == "separate":
if field_state.nest_depth == 0:
field_state.phase = "result"
elif fld_type == "end":
if field_state.nest_depth > 0:
field_state.nest_depth -= 1
else:
field_state.active = False
field_state.phase = None
field_state.url = None
continue
instr_elem = child.find(_W + "instrText")
if instr_elem is not None and field_state.phase == "instr":
if instr_elem.text:
m = _RE_FIELD_HYPERLINK.search(instr_elem.text)
if m:
field_state.url = m.group(1)
def _iter_paragraph_items(para, field_state=None) -> list:
"""Extract (bold, italic, underline, strikethrough, superscript, subscript, text, url) tuples from a paragraph in document order.
Handles python-docx Hyperlink objects and w:fldChar field-code hyperlinks.
Silently degrades to plain text on error.
Note: underline is forced to False inside Hyperlink/field-hyperlink runs to avoid Word's default hyperlink underline style.
field_state: optional _FieldState instance for cross-paragraph field tracking.
If None, a fresh _FieldState is created (single-paragraph mode).
"""
if field_state is None:
field_state = _FieldState()
def _split_breaks(items):
"""Expand items containing \\n (from
separators."""
expanded = []
for (
bold,
italic,
underline,
strikethrough,
superscript,
subscript,
text,
url,
) in items:
if "\n" not in text:
expanded.append(
(
bold,
italic,
underline,
strikethrough,
superscript,
subscript,
text,
url,
)
)
continue
segments = text.split("\n")
for j, seg in enumerate(segments):
if seg:
expanded.append(
(
bold,
italic,
underline,
strikethrough,
superscript,
subscript,
seg,
url,
)
)
if j < len(segments) - 1:
expanded.append(
(False, False, False, False, False, False, "
\n", "")
)
return expanded
try:
from docx.text.hyperlink import Hyperlink
except ImportError:
return _split_breaks(
[
(
_effective_bold(r, para),
_effective_italic(r, para),
_effective_underline(r, para),
bool(r.font.strike),
_effective_superscript(r),
_effective_subscript(r),
r.text,
"",
)
for r in para.runs
if r.text
]
)
items = []
try:
content_iter = para.iter_inner_content()
except Exception:
return _split_breaks(
[
(
_effective_bold(r, para),
_effective_italic(r, para),
_effective_underline(r, para),
bool(r.font.strike),
_effective_superscript(r),
_effective_subscript(r),
r.text,
"",
)
for r in para.runs
if r.text
]
)
for element in content_iter:
try:
if isinstance(element, Hyperlink):
try:
url = element.url or ""
except (KeyError, AttributeError):
url = ""
for run in element.runs:
if not run.text:
continue
# Force underline=False: Word's Hyperlink style adds underline by default
items.append(
(
_effective_bold(run, para),
_effective_italic(run, para),
False,
bool(run.font.strike),
_effective_superscript(run),
_effective_subscript(run),
run.text,
url,
)
)
# Fallback: hyperlink with no runs but has text
if not element.runs and element.text:
items.append(
(False, False, False, False, False, False, element.text, url)
)
else:
# Plain Run — check for fldChar control elements first
fld_char = element._element.find(_W + "fldChar")
if fld_char is not None:
fld_type = fld_char.get(_W + "fldCharType")
if fld_type == "begin":
if field_state.phase == "result":
field_state.nest_depth += 1
else:
field_state.active = True
field_state.phase = "instr"
field_state.url = None
elif fld_type == "separate":
if field_state.nest_depth == 0:
field_state.phase = "result"
elif fld_type == "end":
if field_state.nest_depth > 0:
field_state.nest_depth -= 1
else:
field_state.active = False
field_state.phase = None
field_state.url = None
continue
instr_elem = element._element.find(_W + "instrText")
if instr_elem is not None:
if field_state.active and field_state.phase == "instr":
# Extract HYPERLINK url from instrText
if instr_elem.text:
m = _RE_FIELD_HYPERLINK.search(instr_elem.text)
if m:
field_state.url = m.group(1)
continue # Never emit instrText run as content
# Plain Run
if not element.text:
continue
# If we are in the result phase of a field-code hyperlink, apply URL
# and suppress underline (same as w:hyperlink element handling above).
if (
field_state.phase == "result"
and field_state.nest_depth == 0
and field_state.url
):
items.append(
(
_effective_bold(element, para),
_effective_italic(element, para),
False, # suppress underline for field hyperlinks
bool(element.font.strike),
_effective_superscript(element),
_effective_subscript(element),
element.text,
field_state.url,
)
)
else:
items.append(
(
_effective_bold(element, para),
_effective_italic(element, para),
_effective_underline(element, para),
bool(element.font.strike),
_effective_superscript(element),
_effective_subscript(element),
element.text,
"",
)
)
except Exception:
continue
return _split_breaks(items)
def _merge_runs(items) -> list:
"""Merge adjacent items with identical (bold, italic, underline, strikethrough, superscript, subscript, url).
Returns [(bold, italic, underline, strikethrough, superscript, subscript, text, url)].
"""
merged: list[tuple[bool, bool, bool, bool, bool, bool, str, str]] = []
for (
bold,
italic,
underline,
strikethrough,
superscript,
subscript,
text,
url,
) in items:
if not text:
continue
if (
merged
and merged[-1][0] == bold
and merged[-1][1] == italic
and merged[-1][2] == underline
and merged[-1][3] == strikethrough
and merged[-1][4] == superscript
and merged[-1][5] == subscript
and merged[-1][7] == url
):
merged[-1] = (
bold,
italic,
underline,
strikethrough,
superscript,
subscript,
merged[-1][6] + text,
url,
)
else:
merged.append(
(
bold,
italic,
underline,
strikethrough,
superscript,
subscript,
text,
url,
)
)
return merged
def _runs_to_markdown(items) -> str:
"""Convert paragraph items to Markdown inline text, merging adjacent items with identical formatting."""
parts = []
for (
bold,
italic,
underline,
strikethrough,
superscript,
subscript,
text,
url,
) in _merge_runs(items):
if bold or italic or underline or strikethrough or superscript or subscript:
# CommonMark: marker characters must not be surrounded by spaces
leading = len(text) - len(text.lstrip())
trailing = len(text) - len(text.rstrip())
prefix = text[:leading] if leading else ""
suffix = text[len(text) - trailing :] if trailing else ""
inner = text.strip()
if inner:
# Apply strikethrough first (innermost)
if strikethrough:
inner = f"~~{inner}~~"
# Apply bold/italic
if bold and italic:
inner = f"***{inner}***"
elif bold:
inner = f"**{inner}**"
elif italic:
inner = f"*{inner}*"
# Apply underline
if underline:
inner = f"{inner}"
# Apply superscript/subscript (outermost)
if superscript:
inner = f"{inner}"
elif subscript:
inner = f"{inner}"
text = prefix + inner + suffix
elif underline and text:
# Pure whitespace + underline = fill-in line (e.g. "作者姓名:___")
# Replace spaces with NBSP so Markdown renderers preserve width
text = "" + "\u00a0" * len(text) + ""
if url:
text = f"[{text}]({_escape_md_url(url)})"
parts.append(text)
# Prevent bold/italic/strikethrough markers from merging with adjacent alphanumeric text (CommonMark requirement)
result = []
for i, part in enumerate(parts):
if i > 0 and result:
prev = result[-1]
# Previous part ends with closing marker and current part starts with alphanumeric
if prev.endswith(("**", "*", "~~")) and part and part[0].isalnum():
result.append("\u200b")
result.append(part)
return "".join(result)
def _runs_to_html(items) -> str:
"""Convert paragraph items to HTML inline text."""
parts = []
for (
bold,
italic,
underline,
strikethrough,
superscript,
subscript,
text,
url,
) in _merge_runs(items):
if bold:
text = f"{text}"
if italic:
text = f"{text}"
if underline:
text = f"{text}"
if strikethrough:
text = f"{text}"
if superscript:
text = f"{text}"
if subscript:
text = f"{text}"
if url:
text = f'{text}'
parts.append(text)
return "".join(parts)
# Regex for TOC style names (e.g. "toc 1", "TOC 2", "TOC3")
_RE_TOC_STYLE = re.compile(r"(?i)^toc\s*(\d+)$")
# Flat TOC styles with no level concept (e.g. figure/table of contents)
_TOC_FLAT_STYLES = {"table of figures"}
# Regex for PAGEREF anchor in field instructions
_RE_PAGEREF = re.compile(r"PAGEREF\s+(_Toc\w+)")
def _is_toc_paragraph(para):
"""Return TOC level (1-9) if paragraph uses a TOC style, else None."""
style_name = (para.style.name if para.style else "").strip()
m = _RE_TOC_STYLE.match(style_name)
if m:
return int(m.group(1))
if style_name.lower() in _TOC_FLAT_STYLES:
return 1
return None
def _extract_toc_text(para) -> str:
"""Extract display text from a TOC paragraph, stripping trailing page numbers."""
text = para.text
# Remove trailing page number: split on last tab, discard if it's a pure number
if "\t" in text:
before_tab, _, after_tab = text.rpartition("\t")
if after_tab.strip().isdigit():
text = before_tab
return text.strip()
def _extract_toc_anchor(para_element):
"""Extract anchor name from a TOC paragraph element.
Priority:
1. w:hyperlink[@w:anchor] attribute
2. PAGEREF _TocXXXX in w:instrText
"""
# Check for w:hyperlink with anchor attribute
for hl in para_element.findall(f".//{_W}hyperlink"):
anchor = hl.get(f"{_W}anchor")
if anchor and anchor.startswith("_Toc"):
return anchor
# Fall back to PAGEREF field instruction
for instr in para_element.findall(f".//{_W}instrText"):
if instr.text:
m = _RE_PAGEREF.search(instr.text)
if m:
return m.group(1)
return None
def _toc_entries_to_markdown(entries: list) -> str:
"""Convert list of (text, anchor, level) TOC entries to Markdown list."""
lines = []
for text, anchor, level in entries:
indent = " " * (level - 1)
if anchor:
lines.append(f"{indent}- [{text}](#{anchor})")
else:
lines.append(f"{indent}- {text}")
return "\n".join(lines)
def _extract_heading_toc_bookmarks(para_element):
"""Return all _Toc bookmark names found in a paragraph element.
A heading may carry multiple _Toc bookmarks from repeated TOC updates
(each update inserts a new bookmark without removing old ones). Returning
all of them ensures that TOC entries from any generation can link here.
"""
names = []
for bm in para_element.findall(f".//{_W}bookmarkStart"):
name = bm.get(f"{_W}name", "")
if name.startswith("_Toc"):
names.append(name)
return names
def _flatten_body(body):
"""Yield body children, expanding sdt elements into their sdtContent children."""
for child in body:
tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag
if tag == "sdt":
sdt_content = child.find(f"{_W}sdtContent")
if sdt_content is not None:
yield from _flatten_body(sdt_content)
else:
yield child
_CODE_FONTS = {
"Courier New",
"Courier",
"Consolas",
"Monaco",
"Menlo",
"Source Code Pro",
"Fira Code",
"DejaVu Sans Mono",
"monospace",
}
def _is_code_paragraph(para) -> bool:
"""Return True if all text-bearing runs in the paragraph use a monospace font."""
runs_with_text = [r for r in para.runs if r.text.strip()]
if not runs_with_text:
return False
runs_with_font = [r for r in runs_with_text if r.font.name]
# At least one run must have an explicit font, and all such runs must be monospace
if not runs_with_font:
return False
return all(r.font.name in _CODE_FONTS for r in runs_with_font)
def _get_content_width(doc) -> int:
"""Return the content area width of the document in EMU."""
section = doc.sections[0]
return section.page_width - section.left_margin - section.right_margin
def _table_to_html(
table, doc, image_counter: list, images: dict, content_width: int = 0
) -> str:
"""Convert a python-docx Table to an HTML table, handling merged cells and inline images."""
grid = [[cell for cell in row.cells] for row in table.rows]
nrows = len(grid)
if nrows == 0:
return ""
ncols = len(grid[0])
visited: set[tuple[int, int]] = set()
html_parts = ["
| {cat_ax_title} | ") for name in series_names: html_parts.append(f"{name} | ") html_parts.append("
|---|---|
| {cat} | ") for vals in series_values: val = vals[i] if i < len(vals) else "" html_parts.append(f"{val} | ") html_parts.append("