Source code for scitex_msword.tables

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Timestamp: 2026-06-04 12:00:00
# File: src/scitex_msword/tables.py
#
# Part of scitex-msword (AGPL-3.0-only). See LICENSE at the repo root.

"""
Table insertion utilities for python-docx Documents.

The public entry point :func:`insert_table_after_paragraph` builds a
``<w:tbl>`` element directly with pure lxml (so it works on raw
python-docx 1.1.x without going through the high-level Table API) and
inserts it as the next sibling of a target paragraph. When the source
document already has Track Changes enabled, each generated ``<w:tr>``
is marked as an inserted row by emitting
``<w:trPr><w:ins .../></w:trPr>`` so that Word surfaces the new rows
as accept/reject-able revisions — matching the dogfood-time pattern
proj-grant's ``build_v43.py`` shipped in BOOST today.

OOXML refs:

- ``w:tbl``        ECMA-376 §17.4.38
- ``w:tblPr``      ECMA-376 §17.4.59
- ``w:tblGrid``    ECMA-376 §17.4.49
- ``w:tr``         ECMA-376 §17.4.79
- ``w:trPr``       ECMA-376 §17.4.82
- ``w:ins`` (in trPr)  ECMA-376 §17.13.5.18 (row-level insertion marker)
"""

from __future__ import annotations

from copy import deepcopy
from typing import Any, Iterable, List, Optional, Sequence, Tuple, Union

try:
    from docx.document import Document as DocxDocument  # type: ignore[import-untyped]
    from docx.oxml.ns import qn  # type: ignore[import-untyped]
    from lxml import etree

    DOCX_AVAILABLE = True
    _DOCX_IMPORT_ERROR: Optional[Exception] = None
except ImportError as exc:  # pragma: no cover — optional dep
    DOCX_AVAILABLE = False
    _DOCX_IMPORT_ERROR = exc
    DocxDocument = None  # type: ignore[assignment,misc]
    qn = None  # type: ignore[assignment]
    etree = None  # type: ignore[assignment]


_W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
_XML_NS = "http://www.w3.org/XML/1998/namespace"


def _ensure_docx_available() -> None:
    if not DOCX_AVAILABLE:
        raise ImportError(
            "python-docx (and lxml) are required for scitex_msword.tables. "
            "Install via `pip install python-docx`."
        ) from _DOCX_IMPORT_ERROR


def _w(tag: str) -> str:
    """Return the Clark-form qualified WordprocessingML tag for ``tag``."""
    return f"{{{_W_NS}}}{tag}"


def _font_size_to_half_points(font_size_pt: float) -> str:
    """Word's ``<w:sz w:val=…>`` uses half-points (e.g. 10.5pt → "21")."""
    return str(int(round(font_size_pt * 2)))


def _normalise_rows(rows: Iterable[Sequence[str]]) -> List[List[str]]:
    """Coerce ``rows`` into ``List[List[str]]`` with consistent column counts."""
    normalised: List[List[str]] = []
    for r in rows:
        cells = [str(c) for c in r]
        normalised.append(cells)
    if not normalised:
        raise ValueError("rows must contain at least one row")
    n_cols = len(normalised[0])
    if n_cols == 0:
        raise ValueError("rows must have at least one column")
    for i, r in enumerate(normalised):
        if len(r) != n_cols:
            raise ValueError(
                f"all rows must have the same number of columns; "
                f"row 0 has {n_cols} but row {i} has {len(r)}"
            )
    return normalised


def _build_cell(
    *,
    text: str,
    width_dxa: int,
    font_name: str,
    font_sz_half_points: str,
    bold: bool,
) -> "etree._Element":
    """Build a single ``<w:tc>`` element."""
    tc = etree.Element(_w("tc"))
    tcPr = etree.SubElement(tc, _w("tcPr"))
    tcW = etree.SubElement(tcPr, _w("tcW"))
    tcW.set(_w("w"), str(width_dxa))
    tcW.set(_w("type"), "dxa")
    vAlign = etree.SubElement(tcPr, _w("vAlign"))
    vAlign.set(_w("val"), "center")

    p = etree.SubElement(tc, _w("p"))
    r = etree.SubElement(p, _w("r"))
    rPr = etree.SubElement(r, _w("rPr"))
    rFonts = etree.SubElement(rPr, _w("rFonts"))
    for slot in ("ascii", "eastAsia", "hAnsi", "cs"):
        rFonts.set(_w(slot), font_name)
    sz = etree.SubElement(rPr, _w("sz"))
    sz.set(_w("val"), font_sz_half_points)
    szCs = etree.SubElement(rPr, _w("szCs"))
    szCs.set(_w("val"), font_sz_half_points)
    if bold:
        etree.SubElement(rPr, _w("b"))
        etree.SubElement(rPr, _w("bCs"))

    t = etree.SubElement(r, _w("t"))
    if text != text.strip() or " " in text:
        t.set(f"{{{_XML_NS}}}space", "preserve")
    t.text = text
    return tc


def _build_row(
    *,
    cells: Sequence[str],
    col_widths_dxa: Sequence[int],
    font_name: str,
    font_sz_half_points: str,
    bold: bool,
) -> "etree._Element":
    """Build a single ``<w:tr>`` element with its constituent cells."""
    tr = etree.Element(_w("tr"))
    for cell_text, width in zip(cells, col_widths_dxa):
        tr.append(
            _build_cell(
                text=cell_text,
                width_dxa=width,
                font_name=font_name,
                font_sz_half_points=font_sz_half_points,
                bold=bold,
            )
        )
    return tr


def _mark_row_as_tracked_insertion(
    tr_element: "etree._Element",
    *,
    w_id: int,
    author: str,
    date: str,
) -> None:
    """Insert ``<w:trPr><w:ins .../></w:trPr>`` so Word treats the row as inserted."""
    trPr = tr_element.find(_w("trPr"))
    if trPr is None:
        trPr = etree.Element(_w("trPr"))
        tr_element.insert(0, trPr)
    ins_el = etree.SubElement(trPr, _w("ins"))
    ins_el.set(_w("id"), str(w_id))
    ins_el.set(_w("author"), author)
    ins_el.set(_w("date"), date)


def _build_table(
    *,
    rows: Sequence[Sequence[str]],
    col_widths_dxa: Sequence[int],
    header_row: bool,
    body_font: str,
    header_font: str,
    font_sz_half_points: str,
) -> "etree._Element":
    """Build the full ``<w:tbl>`` element (no track-changes wrapping)."""
    tbl_el = etree.Element(_w("tbl"))

    # tblPr — width + borders
    tblPr = etree.SubElement(tbl_el, _w("tblPr"))
    tblW = etree.SubElement(tblPr, _w("tblW"))
    tblW.set(_w("w"), "5000")
    tblW.set(_w("type"), "pct")
    tblBorders = etree.SubElement(tblPr, _w("tblBorders"))
    for side in ("top", "left", "bottom", "right", "insideH", "insideV"):
        b = etree.SubElement(tblBorders, _w(side))
        b.set(_w("val"), "single")
        b.set(_w("sz"), "4")
        b.set(_w("space"), "0")
        b.set(_w("color"), "auto")

    # tblGrid — per-column declared widths
    tblGrid = etree.SubElement(tbl_el, _w("tblGrid"))
    for width_dxa in col_widths_dxa:
        gc = etree.SubElement(tblGrid, _w("gridCol"))
        gc.set(_w("w"), str(width_dxa))

    # Rows
    for row_idx, cells in enumerate(rows):
        is_header = header_row and row_idx == 0
        tbl_el.append(
            _build_row(
                cells=cells,
                col_widths_dxa=col_widths_dxa,
                font_name=header_font if is_header else body_font,
                font_sz_half_points=font_sz_half_points,
                bold=is_header,
            )
        )

    return tbl_el


def _document_has_track_changes(doc: "DocxDocument") -> bool:
    """Return True iff ``<w:trackRevisions/>`` is present in settings.xml.

    Mirrors :func:`scitex_msword.track_changes.is_track_changes_enabled`
    without importing it (kept self-contained so callers can use tables
    without track-changes wiring).
    """
    settings = doc.settings.element
    return settings.find(qn("w:trackRevisions")) is not None


def _next_row_revision_id(doc: "DocxDocument") -> int:
    """Pick a w:id one larger than the max existing ``w:ins`` / ``w:del`` id in body."""
    body = doc.element.body
    ins_tag = _w("ins")
    del_tag = _w("del")
    id_attr = qn("w:id")
    max_id = 0
    for el in body.iter():
        if el.tag in (ins_tag, del_tag):
            try:
                eid = int(el.get(id_attr) or "0")
            except (TypeError, ValueError):
                continue
            if eid > max_id:
                max_id = eid
    return max_id + 1


def _now_iso() -> str:
    from datetime import datetime, timezone

    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")


[docs] def insert_table_after_paragraph( doc: "DocxDocument", paragraph_index: int, rows: Sequence[Sequence[str]], col_widths_dxa: Sequence[int] = (3000, 6000), header_row: bool = True, body_font: str = "MS 明朝", header_font: str = "MS ゴシック", font_size_pt: float = 10.5, *, track_changes: Optional[bool] = None, track_changes_author: str = "agent", track_changes_date: Optional[str] = None, ) -> "etree._Element": """Insert a Word table immediately after ``doc.paragraphs[paragraph_index]``. The table is built directly as a ``<w:tbl>`` element via lxml and appended via ``addnext()`` — no python-docx high-level Table API is used, so the same code path works on stripped-down ``Document`` objects where the document part is largely raw OOXML (common in Track-Changes-preserving dogfood pipelines). Parameters ---------- doc : docx.Document The Document to mutate in place. paragraph_index : int 0-based index into ``doc.paragraphs``. The new table is inserted as the immediate next sibling of that paragraph in the body. rows : sequence of sequence of str Row contents. All rows must have the same column count, equal to ``len(col_widths_dxa)``. The first row is rendered as a header when ``header_row=True``. col_widths_dxa : sequence of int, default ``(3000, 6000)`` Column widths in dxa (twentieths of a point). Length sets the column count. Default reproduces the 2-col 1:2 layout used by proj-grant's ``build_v43.py``. header_row : bool, default ``True`` When ``True``, row 0 is styled with ``header_font`` + bold; the body rows use ``body_font`` without bold. body_font : str, default ``"MS 明朝"`` Font family applied to body-row runs (``rFonts`` ascii / eastAsia / hAnsi / cs). header_font : str, default ``"MS ゴシック"`` Font family applied to the header-row runs. font_size_pt : float, default ``10.5`` Run font size in points. Word stores this as half-points in ``<w:sz w:val=…>`` (10.5pt → "21"). track_changes : bool, optional Control row-level ``<w:trPr><w:ins/></w:trPr>`` emission: - ``None`` (default): auto-detect — wrap iff the document has ``<w:trackRevisions/>`` in settings.xml at call time. - ``True``: always wrap, regardless of document state. - ``False``: never wrap (plain insertion). track_changes_author : str, default ``"agent"`` Author string written to each row's ``w:ins/@w:author``. track_changes_date : str, optional ISO-8601 timestamp written to each row's ``w:ins/@w:date``. Defaults to ``now(UTC)``. Returns ------- lxml.etree._Element The freshly inserted ``<w:tbl>`` element. Callers that want to chain further insertions (e.g. add a paragraph after the table) can ``addnext()`` to this element. Raises ------ IndexError ``paragraph_index`` is out of range for ``doc.paragraphs``. ValueError ``rows`` is empty, has zero-width rows, or has rows whose column count does not match ``len(col_widths_dxa)`` (or, when ``col_widths_dxa`` is unset, an inconsistent column count across rows). ImportError ``python-docx`` is not installed. Examples -------- .. code-block:: python from docx import Document from scitex_msword import insert_table_after_paragraph doc = Document("draft.docx") insert_table_after_paragraph( doc, paragraph_index=17, rows=[ ("役割", "モジュール名"), ("論文執筆", "scitex-writer, scitex-msword"), ], col_widths_dxa=(3000, 6000), header_row=True, ) doc.save("draft_with_table.docx") """ _ensure_docx_available() n_paragraphs = len(doc.paragraphs) if not -n_paragraphs <= paragraph_index < n_paragraphs: raise IndexError( f"paragraph_index={paragraph_index} out of range for document " f"with {n_paragraphs} paragraphs" ) normalised_rows = _normalise_rows(rows) n_cols = len(normalised_rows[0]) col_widths = list(col_widths_dxa) if len(col_widths) != n_cols: raise ValueError( f"col_widths_dxa has {len(col_widths)} entries but rows have " f"{n_cols} columns" ) half_points = _font_size_to_half_points(font_size_pt) tbl_el = _build_table( rows=normalised_rows, col_widths_dxa=col_widths, header_row=header_row, body_font=body_font, header_font=header_font, font_sz_half_points=half_points, ) if track_changes is None: should_wrap = _document_has_track_changes(doc) else: should_wrap = bool(track_changes) if should_wrap: author = track_changes_author date = track_changes_date or _now_iso() next_id = _next_row_revision_id(doc) tr_tag = _w("tr") for offset, tr_el in enumerate(tbl_el.findall(tr_tag)): _mark_row_as_tracked_insertion( tr_el, w_id=next_id + offset, author=author, date=date, ) target_p = doc.paragraphs[paragraph_index] target_p._p.addnext(tbl_el) return tbl_el
__all__ = ["insert_table_after_paragraph"]