# 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 soft line breaks) into per-line items with
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 = [""] for i in range(nrows): html_parts.append("") for j in range(ncols): if (i, j) in visited: continue tc = grid[i][j]._tc # Compute colspan colspan = 1 while j + colspan < ncols and grid[i][j + colspan]._tc is tc: visited.add((i, j + colspan)) colspan += 1 # Compute rowspan rowspan = 1 while i + rowspan < nrows and grid[i + rowspan][j]._tc is tc: for k in range(colspan): visited.add((i + rowspan, j + k)) rowspan += 1 cell = grid[i][j] content_parts = [] for para in cell.paragraphs: img_list = _extract_images_from_paragraph(para, doc, image_counter) for filename, img_bytes, cx_emu in img_list: rel_path = f"images/{filename}" images[rel_path] = img_bytes if cx_emu and content_width: pct = min(round(cx_emu / content_width * 100), 100) content_parts.append( f'' ) else: content_parts.append(f'') para_html = ( _paragraph_math_to_html(para).strip() if _paragraph_has_math(para) else _runs_to_html(_iter_paragraph_items(para)).strip() or para.text.strip() ) if para_html: content_parts.append(para_html) # Extract text box content from table cell paragraph try: tb_groups = _extract_textbox_paragraphs(para._element) for tb_paras in tb_groups: for tb_p in tb_paras: tb_t_elems = tb_p.findall(f".//{_W}t") tb_text = "".join( t.text for t in tb_t_elems if t.text ).strip() if tb_text: content_parts.append(f"[{tb_text}]") except Exception: pass cell_html = "
".join(content_parts) if content_parts else "" is_header = False trPr = table.rows[i]._tr.find(f"{_W}trPr") if trPr is not None: tbl_header = trPr.find(f"{_W}tblHeader") if tbl_header is not None: val = tbl_header.get(f"{_W}val") is_header = val is None or val.lower() not in ("false", "0", "off") if not is_header and i == 0 and nrows > 1: is_header = ( True # fallback: treat first row as header in multi-row tables ) tag = "th" if is_header else "td" attrs = "" if colspan > 1: attrs += f' colspan="{colspan}"' if rowspan > 1: attrs += f' rowspan="{rowspan}"' html_parts.append(f"<{tag}{attrs}>{cell_html}") html_parts.append("") html_parts.append("
") return "\n".join(html_parts) _MIME_TO_EXT = { "image/jpeg": "jpeg", "image/jpg": "jpg", "image/png": "png", "image/gif": "gif", "image/bmp": "bmp", "image/tiff": "tiff", "image/webp": "webp", "image/svg+xml": "svg", "image/x-emf": "emf", "image/x-wmf": "wmf", } def _extract_images_from_paragraph(para, doc, image_counter: list) -> list: """Extract images from a paragraph. Returns [(filename, bytes, cx_emu)] where cx_emu is 0 if unknown.""" results = [] containers = para._element.findall(f".//{_WPD}inline") + para._element.findall( f".//{_WPD}anchor" ) for container in containers: extent = container.find(f"{_WPD}extent") cx_emu = int(extent.get("cx", 0)) if extent is not None else 0 blip = container.find(f".//{_A}blip") if blip is None: continue r_embed = blip.get(f"{_R}embed") if not r_embed: continue try: rel = doc.part.rels[r_embed] img_bytes = rel.target_part.blob content_type = rel.target_part.content_type ext = _MIME_TO_EXT.get(content_type, "png") image_counter[0] += 1 filename = f"image{image_counter[0]}.{ext}" results.append((filename, img_bytes, cx_emu)) except (KeyError, AttributeError): pass return results def _extract_chart_tables(para, doc) -> list: """Extract chart data as HTML tables from drawings in a paragraph.""" try: from datetime import datetime, timedelta results = [] containers = para._element.findall(f".//{_WPD}inline") + para._element.findall( f".//{_WPD}anchor" ) for container in containers: chart_ref = container.find(f".//{_C}chart") if chart_ref is None: continue r_id = chart_ref.get(f"{_R}id") if not r_id: continue try: rel = doc.part.rels[r_id] chart_part = rel.target_part except (KeyError, AttributeError): continue try: import lxml.etree as etree chart_root = etree.fromstring(chart_part.blob) except Exception: continue # Extract chart title title_text = "" title_el = chart_root.find(f".//{_C}title") if title_el is not None: a_t_els = title_el.findall(f".//{_A}t") title_text = "".join((el.text or "") for el in a_t_els).strip() # Extract axis titles cat_ax_title = "" for ax_tag in (f"{_C}catAx", f"{_C}dateAx"): ax_el = chart_root.find(f".//{ax_tag}") if ax_el is not None: t_el = ax_el.find(f"{_C}title") if t_el is not None: texts = t_el.findall(f".//{_A}t") cat_ax_title = "".join(el.text or "" for el in texts).strip() break val_ax_title = "" val_ax_el = chart_root.find(f".//{_C}valAx") if val_ax_el is not None: t_el = val_ax_el.find(f"{_C}title") if t_el is not None: texts = t_el.findall(f".//{_A}t") val_ax_title = "".join(el.text or "" for el in texts).strip() # Extract series data series_list = chart_root.findall(f".//{_C}ser") if not series_list: continue # Collect categories from first series categories = [] cat_is_date = False date_format_code = "" first_ser = series_list[0] cat_el = first_ser.find(f"{_C}cat") if cat_el is not None: str_cache = cat_el.find(f".//{_C}strCache") num_cache = cat_el.find(f".//{_C}numCache") if str_cache is not None: for pt in str_cache.findall(f"{_C}pt"): v = pt.find(f"{_C}v") categories.append(v.text if v is not None else "") elif num_cache is not None: fmt_el = num_cache.find(f"{_C}formatCode") if fmt_el is not None: fc = fmt_el.text or "" date_format_code = fc cat_is_date = any(k in fc.lower() for k in ("y", "m", "d")) for pt in num_cache.findall(f"{_C}pt"): v = pt.find(f"{_C}v") if v is not None and v.text: try: serial = float(v.text) if cat_is_date: dt = datetime(1899, 12, 30) + timedelta(days=serial) categories.append(dt.strftime("%Y-%m-%d")) else: categories.append(v.text) except (ValueError, OverflowError): categories.append(v.text or "") else: categories.append("") # Collect series names and values series_names = [] series_values = [] for ser in series_list: # Series name: val axis title > > "" name = val_ax_title if not name: tx_el = ser.find(f"{_C}tx") if tx_el is not None: str_ref = tx_el.find(f".//{_C}strCache") if str_ref is not None: pt = str_ref.find(f"{_C}pt") if pt is not None: v = pt.find(f"{_C}v") name = v.text if v is not None else "" else: v = tx_el.find(f"{_C}v") if v is not None: name = v.text or "" series_names.append(name) # Values vals = [] val_el = ser.find(f"{_C}val") if val_el is not None: num_cache = val_el.find(f".//{_C}numCache") if num_cache is not None: pts = { int(pt.get("idx", 0)): pt for pt in num_cache.findall(f"{_C}pt") } max_idx = max(pts.keys()) if pts else -1 for idx in range(max_idx + 1): pt = pts.get(idx) if pt is not None: v = pt.find(f"{_C}v") vals.append(v.text if v is not None else "") else: vals.append("") series_values.append(vals) if not series_names: continue # Build HTML table n_rows = max( len(categories), max((len(v) for v in series_values), default=0), ) html_parts = [""] if title_text: html_parts.append(f"") # Header row — omit entirely if no header info has_header = cat_ax_title or any(name for name in series_names) if has_header: html_parts.append("") html_parts.append(f"") for name in series_names: html_parts.append(f"") html_parts.append("") # Data rows html_parts.append("") for i in range(n_rows): cat = categories[i] if i < len(categories) else "" html_parts.append(f"") for vals in series_values: val = vals[i] if i < len(vals) else "" html_parts.append(f"") html_parts.append("") html_parts.append("
{title_text}
{cat_ax_title}{name}
{cat}{val}
") results.append("".join(html_parts)) return results except Exception: return [] def _extract_textbox_paragraphs(element) -> list: """Extract paragraphs from text boxes embedded in a body element. Text boxes appear as mc:AlternateContent > mc:Choice > wps:txbx > w:txbxContent > w:p. Only mc:Choice is used to avoid duplicating VML fallback content. Returns list of lists of w:p elements, one list per non-empty text box. """ try: result = [] for alt_content in element.iter(f"{_MC}AlternateContent"): choice = alt_content.find(f"{_MC}Choice") if choice is None: continue for txbx in choice.iter(f"{_WPS}txbx"): txbx_content = txbx.find(f"{_W}txbxContent") if txbx_content is None: continue paras = txbx_content.findall(f"{_W}p") if not paras: continue # Skip boxes where all paragraphs are empty has_text = any( p.find(f".//{_W}t") is not None and any(t.text for t in p.findall(f".//{_W}t") if t.text) for p in paras ) if has_text: result.append(paras) return result except Exception: return [] def _textbox_paragraphs_to_markdown( textbox_groups, doc, body_font_size, image_counter, images, content_width ) -> list: """Convert text box paragraph groups to blockquote Markdown lines. Returns a list of strings (lines) to append to the main output. """ try: from docx.text.paragraph import Paragraph except ImportError: return [] output_lines = [] for group_idx, para_elements in enumerate(textbox_groups): group_lines = [] for p_elem in para_elements: try: para = Paragraph(p_elem, doc) # Handle images in text box img_list = _extract_images_from_paragraph(para, doc, image_counter) for filename, img_bytes, cx_emu in img_list: rel_path = f"images/{filename}" images[rel_path] = img_bytes if cx_emu and content_width: pct = min(round(cx_emu / content_width * 100), 100) group_lines.append( f'> ' ) else: group_lines.append(f'> ') # Handle math formulas if _paragraph_has_math(para): math_md = _paragraph_math_to_markdown(para) if math_md: group_lines.append(f"> {math_md}") continue # Plain inline text (no heading/list/code detection for text boxes) inline = _runs_to_markdown(_iter_paragraph_items(para)) if not inline: inline = para.text.strip() if inline: group_lines.append(f"> {inline}") except Exception: continue if group_lines: if output_lines: output_lines.append("") output_lines.extend(group_lines) return output_lines def _build_numbering_map(doc) -> dict: """Parse numbering.xml and return {numId: {ilvl: numFmt}} mapping.""" numbering_map = {} try: numbering_part = doc.part.numbering_part except Exception: return numbering_map numbering_elem = numbering_part._element # Build abstractNumId -> {ilvl: numFmt} abstract = {} for abs_num in numbering_elem.findall(f"{_W}abstractNum"): abs_id = abs_num.get(f"{_W}abstractNumId") levels = {} for lvl in abs_num.findall(f"{_W}lvl"): ilvl = int(lvl.get(f"{_W}ilvl", "0")) fmt_elem = lvl.find(f"{_W}numFmt") fmt = ( fmt_elem.get(f"{_W}val", "bullet") if fmt_elem is not None else "bullet" ) levels[ilvl] = fmt abstract[abs_id] = levels # Map numId -> abstractNumId for num in numbering_elem.findall(f"{_W}num"): num_id = num.get(f"{_W}numId") abs_ref = num.find(f"{_W}abstractNumId") if abs_ref is not None: abs_id = abs_ref.get(f"{_W}val") if abs_id in abstract: numbering_map[num_id] = abstract[abs_id] return numbering_map def _get_list_info(para, numbering_map) -> tuple | None: """Return (list_type, ilvl, num_id) or None. list_type: 'bullet' | 'ordered'""" pPr = para._element.find(f"{_W}pPr") if pPr is None: return None numPr = pPr.find(f"{_W}numPr") if numPr is None: return None numId_elem = numPr.find(f"{_W}numId") ilvl_elem = numPr.find(f"{_W}ilvl") if numId_elem is None: return None num_id = numId_elem.get(f"{_W}val") ilvl = int(ilvl_elem.get(f"{_W}val", "0")) if ilvl_elem is not None else 0 if num_id not in numbering_map: return None fmt = numbering_map[num_id].get(ilvl, "bullet") list_type = ( "ordered" if fmt in ("decimal", "lowerLetter", "upperLetter", "lowerRoman", "upperRoman") else "bullet" ) return (list_type, ilvl, num_id) def _convert_body(doc, *, extract_drawings=True) -> tuple: """Traverse body elements in order and produce Markdown. Returns (markdown_str, images_dict).""" try: from docx.table import Table from docx.text.paragraph import Paragraph except ImportError: raise RuntimeError( "DOCX conversion requires python-docx: pip install paddleocr[doc2md]" ) body_font_size = _get_body_font_size(doc) content_width = _get_content_width(doc) numbering_map = _build_numbering_map(doc) lines: list[str] = [] images: dict = {} image_counter = [0] # wrapped in list so inner functions can mutate it code_buf: list[str] = [] # buffer for consecutive code paragraphs toc_buf: list[tuple] = [] # buffer for consecutive TOC paragraphs ol_counters: dict[str, int] = {} # key = "{numId}-{ilvl}", value = current index prev_was_list = False def flush_code_buf(): """Flush the code buffer as a fenced code block.""" if code_buf: lines.append("```") lines.extend(code_buf) lines.append("```") lines.append("") code_buf.clear() def flush_toc_buf(): """Flush the TOC buffer as a Markdown list.""" if toc_buf: if lines and lines[-1] != "": lines.append("") lines.append(_toc_entries_to_markdown(toc_buf)) lines.append("") toc_buf.clear() field_state = _FieldState() for child in _flatten_body(doc.element.body): tag = child.tag.split("}")[-1] if tag == "p": para = Paragraph(child, doc) # TOC paragraph detection — before image extraction and other processing toc_level = _is_toc_paragraph(para) if toc_level is not None: toc_text = _extract_toc_text(para) if toc_text: toc_anchor = _extract_toc_anchor(child) toc_buf.append((toc_text, toc_anchor, toc_level)) _update_field_state_for_paragraph(child, field_state) continue # Non-TOC paragraph: flush any buffered TOC entries flush_toc_buf() # Extract text box content for this paragraph element pending_textbox_lines = [] if extract_drawings: tb_groups = _extract_textbox_paragraphs(child) if tb_groups: pending_textbox_lines = _textbox_paragraphs_to_markdown( tb_groups, doc, body_font_size, image_counter, images, content_width, ) def _flush_textbox(): if pending_textbox_lines: if lines and lines[-1] != "": lines.append("") lines.extend(pending_textbox_lines) lines.append("") # Extract images first img_list = _extract_images_from_paragraph(para, doc, image_counter) for filename, img_bytes, cx_emu in img_list: flush_code_buf() rel_path = f"images/{filename}" images[rel_path] = img_bytes if cx_emu and content_width: pct = min(round(cx_emu / content_width * 100), 100) lines.append(f'') else: lines.append(f'') lines.append("") # Extract chart data tables chart_tables = _extract_chart_tables(para, doc) for chart_html in chart_tables: flush_code_buf() lines.append(chart_html) lines.append("") text = para.text.strip() # Math formula detection — must check before skipping empty-text paragraphs # (pure formula paragraphs have para.text == "") if _paragraph_has_math(para): flush_code_buf() if prev_was_list: lines.append("") prev_was_list = False math_md = _paragraph_math_to_markdown(para) if math_md: lines.append(math_md) lines.append("") _flush_textbox() _update_field_state_for_paragraph(child, field_state) continue if not text: # Emit any _Toc bookmark anchors even for empty paragraphs. # Headings whose visible text comes from list numbering (not w:t # runs) have para.text == "" but still carry _Toc bookmarks that # TOC entries link to. for toc_bm in _extract_heading_toc_bookmarks(child): lines.append(f'') _flush_textbox() if not img_list and not pending_textbox_lines: if code_buf: code_buf.append("") # preserve blank lines inside code blocks elif lines and lines[-1] != "": lines.append("") _update_field_state_for_paragraph(child, field_state) continue # Code paragraph: buffer it without heading/inline formatting if _is_code_paragraph(para): code_buf.append(para.text) _flush_textbox() _update_field_state_for_paragraph(child, field_state) continue # Non-code paragraph: flush any buffered code first flush_code_buf() level = _detect_heading_level(para, body_font_size) inline = _runs_to_markdown(_iter_paragraph_items(para, field_state)) or text if level > 0: # Strip outer **...** wrapping that headings may have inherited clean = inline.strip() if clean.startswith("**") and clean.endswith("**"): clean = clean[2:-2] # Heading lines cannot span multiple source lines; revert
\n →
clean = clean.replace("
\n", "
") if prev_was_list: lines.append("") prev_was_list = False # Add _Toc bookmark anchors if present (makes TOC links jumpable). # A heading may have multiple _Toc bookmarks from repeated TOC updates. for toc_bm in _extract_heading_toc_bookmarks(child): lines.append(f'') lines.append(f"{'#' * level} {clean}") lines.append("") else: list_info = _get_list_info(para, numbering_map) if list_info: list_type, ilvl, num_id = list_info indent = " " * ilvl if list_type == "ordered": counter_key = f"{num_id}-{ilvl}" ol_counters[counter_key] = ol_counters.get(counter_key, 0) + 1 prefix = f"{indent}{ol_counters[counter_key]}. " else: prefix = f"{indent}- " if not prev_was_list and lines and lines[-1] != "": lines.append("") prev_was_list = True # List item continuation lines need indentation; revert
\n →
lines.append(prefix + inline.replace("
\n", "
")) else: if prev_was_list: lines.append("") prev_was_list = False # Reset ordered list counters when a list is interrupted ol_counters.clear() # Add _Toc bookmark anchors for non-heading paragraphs (e.g. Caption) for toc_bm in _extract_heading_toc_bookmarks(child): lines.append(f'') lines.append(inline) lines.append("") _flush_textbox() elif tag == "tbl": flush_code_buf() flush_toc_buf() if prev_was_list: lines.append("") prev_was_list = False ol_counters.clear() table = Table(child, doc) if lines and lines[-1] != "": lines.append("") lines.append( _table_to_html(table, doc, image_counter, images, content_width) ) lines.append("") # Strip trailing blank lines flush_code_buf() flush_toc_buf() while lines and lines[-1] == "": lines.pop() return "\n".join(lines), images def _extract_headers_footers(doc) -> tuple: """Extract header and footer text from all document sections. Returns (header_lines: list[str], footer_lines: list[str]). Deduplicates content and filters out page-number-only text. """ def _collect_from_parts(parts, seen: set) -> list: results = [] for part in parts: try: if part.is_linked_to_previous: continue texts = [] for para in part.paragraphs: try: items = list(_iter_paragraph_items(para)) inline = _runs_to_markdown(items) if not inline: inline = para.text.strip() if inline: texts.append(inline) except Exception: pass text = " ".join(texts) # Filter: skip empty text, pure digits (page numbers), # page-number patterns (e.g. "第 页", "第6页", "共 页"), and duplicates if ( text and not text.strip().isdigit() and not _RE_PAGE_ONLY.match(text.strip()) and text not in seen ): seen.add(text) results.append(text) except Exception: pass return results header_lines = [] footer_lines = [] seen_headers: set = set() seen_footers: set = set() # Check if the document uses different odd/even page headers/footers try: odd_even = doc.settings.odd_and_even_pages_header_footer except Exception: odd_even = False for section in doc.sections: try: # Collect headers hdrs = [section.header] if odd_even: try: hdrs.append(section.even_page_header) except Exception: pass try: if section.different_first_page_header_footer: hdrs.append(section.first_page_header) except Exception: pass header_lines.extend(_collect_from_parts(hdrs, seen_headers)) # Collect footers ftrs = [section.footer] if odd_even: try: ftrs.append(section.even_page_footer) except Exception: pass try: if section.different_first_page_header_footer: ftrs.append(section.first_page_footer) except Exception: pass footer_lines.extend(_collect_from_parts(ftrs, seen_footers)) except Exception: pass return header_lines, footer_lines @default_registry.register class DocxConverter(BaseConverter): supported_extensions = ["docx"] supported_mimetypes = [ "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ] def convert_file(self, file_path: Path, **kwargs) -> ConvertResult: try: from docx import Document except ImportError: raise RuntimeError( "DOCX conversion requires python-docx: pip install paddleocr[doc2md]" ) extract_drawings = kwargs.pop("extract_drawings", True) extract_headers_footers = kwargs.pop("extract_headers_footers", True) doc = Document(str(file_path)) md_text, images = _convert_body(doc, extract_drawings=extract_drawings) if extract_headers_footers: header_lines, footer_lines = _extract_headers_footers(doc) parts = [] if header_lines: parts.append("\n\n".join(header_lines)) parts.append("") parts.append(md_text) if footer_lines: parts.append("") parts.append("---") parts.append("\n\n".join(footer_lines)) md_text = "\n".join(parts) return ConvertResult( markdown=md_text, images=images, metadata={ "format": "DOCX", "paragraph_count": len(doc.paragraphs), "table_count": len(doc.tables), }, )