141 lines
4.3 KiB
Python
141 lines
4.3 KiB
Python
"""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
|