"""DOCX table → markdown rows with ^ / > merge markers.""" from __future__ import annotations from docx.oxml.ns import qn from docx.table import Table as DocxTable def _cell_v_merge(tc) -> str: """Return none | restart | continue for vertical merge.""" tcPr = tc.tcPr if tcPr is None: return "none" vMerge = tcPr.find(qn("w:vMerge")) if vMerge is None: return "none" val = vMerge.get(qn("w:val")) if val in (None, "continue"): return "continue" return "restart" def _cell_grid_span(tc) -> int: tcPr = tc.tcPr if tcPr is None: return 1 span = tcPr.find(qn("w:gridSpan")) if span is None: return 1 try: return max(1, int(span.get(qn("w:val")) or "1")) except ValueError: return 1 def _cell_text(cell) -> str: from .text import paragraph_plain parts: list[str] = [] for p in cell.paragraphs: t = paragraph_plain(p) if t: parts.append(t) return " ".join(parts).replace("|", "\\|") def table_to_grid(docx_table: DocxTable) -> tuple[list[list[str]], list[list[tuple[str, str]]]]: """ Expand a Word table into a rectangular grid. merges[r][c] = (v, h) where v/h in {none, restart, continue}. Horizontally spanned continue cells are filled with '>' semantics. Vertically continued cells use '^'. """ rows_xml = docx_table._tbl.tr_lst # First pass: determine max columns from grid spans max_cols = 0 row_infos: list[list[tuple[object, int, str]]] = [] # (tc, span, vmerge) for tr in rows_xml: info: list[tuple[object, int, str]] = [] col = 0 for tc in tr.tc_lst: span = _cell_grid_span(tc) v = _cell_v_merge(tc) info.append((tc, span, v)) col += span max_cols = max(max_cols, col) row_infos.append(info) if max_cols == 0: return [], [] texts: list[list[str]] = [["-" for _ in range(max_cols)] for _ in range(len(row_infos))] merges: list[list[tuple[str, str]]] = [ [("none", "none") for _ in range(max_cols)] for _ in range(len(row_infos)) ] # Map tc elements to python-docx cells by index for r, info in enumerate(row_infos): c = 0 cell_idx = 0 for tc, span, v in info: try: cell = docx_table.rows[r].cells[cell_idx] text = _cell_text(cell) except Exception: text = "" cell_idx += 1 if v == "continue": merges[r][c] = ("continue", "none") texts[r][c] = "^" else: merges[r][c] = ("restart" if v == "restart" else "none", "none" if span == 1 else "restart") texts[r][c] = text if text else " " for k in range(1, span): if c + k < max_cols: merges[r][c + k] = ("none", "continue") texts[r][c + k] = ">" c += span return texts, merges def is_formula_table(docx_table: DocxTable) -> bool: """Heuristic: 1×2 table with Formula Content / Formula Numbering styles.""" try: if len(docx_table.rows) != 1 or len(docx_table.columns) != 2: return False except Exception: return False styles: list[str] = [] for cell in docx_table.rows[0].cells: for p in cell.paragraphs: name = (p.style.name if p.style is not None else "") or "" styles.append(name) joined = " ".join(styles).lower() return "formula" in joined def formula_table_parts(docx_table: DocxTable) -> tuple[object | None, str | None]: """Return (omml_element_or_None, number_text_or_None) from a formula table.""" from .omml import find_omml, omml_to_latex from .text import paragraph_plain left = docx_table.rows[0].cells[0] right = docx_table.rows[0].cells[1] latex = "" for p in left.paragraphs: for om in find_omml(p._element): latex = omml_to_latex(om) if latex: break if latex: break if not latex: latex = " ".join(paragraph_plain(p) for p in left.paragraphs).strip() number = " ".join(paragraph_plain(p) for p in right.paragraphs).strip() number = number.strip("() ").strip() or None return latex, number