"""Convert a DrawingML <a:tbl> into SVG.

Tables in PowerPoint are stored under <p:graphicFrame> with
graphicData uri="...drawingml/2006/table" wrapping a single <a:tbl>:

    <p:graphicFrame>
      <p:xfrm>...</p:xfrm>
      <a:graphic><a:graphicData uri="...table">
        <a:tbl>
          <a:tblPr/>
          <a:tblGrid>
            <a:gridCol w="..."/>...
          </a:tblGrid>
          <a:tr h="...">
            <a:tc [gridSpan=N] [rowSpan=N] [hMerge=1] [vMerge=1]>
              <a:txBody>...</a:txBody>
              <a:tcPr>
                <a:lnL/><a:lnR/><a:lnT/><a:lnB/>
                <a:solidFill/>... or <a:gradFill/> ...
              </a:tcPr>
            </a:tc>
          </a:tr>
        </a:tbl>
      </a:graphicData></a:graphic>
    </p:graphicFrame>

The graphicFrame's <p:xfrm> gives the table's slide-space position and total
size; <a:tblGrid> + <a:tr> heights distribute that size across columns/rows.

Cell painting order:
1. background fill (rect at cell box)
2. text body (re-uses convert_txbody)
3. cell borders (lnT / lnR / lnB / lnL — stroked as separate <line>s so
   neighbouring cells with different border styles render correctly)
"""

from __future__ import annotations

import copy
import math
from dataclasses import dataclass
from typing import Any
from xml.etree import ElementTree as ET

from .color_resolver import ColorPalette, find_color_elem, resolve_color
from .emu_units import (
    NS,
    Xfrm,
    emu_to_px,
    fmt_num,
    hundredths_pt_to_px,
    ooxml_bool,
)
from .fill_to_svg import FillResult, resolve_fill
from .ln_to_svg import resolve_stroke
from .txbody_to_svg import _resolve_theme_typeface, convert_txbody


BUILTIN_MEDIUM_STYLE_2_ACCENT_1 = "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"
_POWERPOINT_COORD_MIN = -(2**31)
_POWERPOINT_COORD_MAX = 2**31 - 1

_BUILTIN_MEDIUM_STYLE_2_ACCENT_1_XML = ET.fromstring(
    f'''<a:tblStyle xmlns:a="{NS["a"]}"
        styleId="{BUILTIN_MEDIUM_STYLE_2_ACCENT_1}">
      <a:wholeTbl>
        <a:tcTxStyle>
          <a:fontRef idx="minor"><a:prstClr val="black"/></a:fontRef>
          <a:schemeClr val="dk1"/>
        </a:tcTxStyle>
        <a:tcStyle>
          <a:tcBdr>
            <a:left><a:ln w="12700"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill></a:ln></a:left>
            <a:right><a:ln w="12700"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill></a:ln></a:right>
            <a:top><a:ln w="12700"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill></a:ln></a:top>
            <a:bottom><a:ln w="12700"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill></a:ln></a:bottom>
            <a:insideH><a:ln w="12700"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill></a:ln></a:insideH>
            <a:insideV><a:ln w="12700"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill></a:ln></a:insideV>
          </a:tcBdr>
          <a:fill><a:solidFill><a:schemeClr val="accent1"><a:tint val="20000"/></a:schemeClr></a:solidFill></a:fill>
        </a:tcStyle>
      </a:wholeTbl>
      <a:band1H>
        <a:tcStyle><a:fill><a:solidFill><a:schemeClr val="accent1"><a:tint val="40000"/></a:schemeClr></a:solidFill></a:fill></a:tcStyle>
      </a:band1H>
      <a:band2H><a:tcStyle/></a:band2H>
      <a:firstRow>
        <a:tcTxStyle b="on">
          <a:fontRef idx="minor"><a:prstClr val="black"/></a:fontRef>
          <a:schemeClr val="lt1"/>
        </a:tcTxStyle>
        <a:tcStyle>
          <a:tcBdr><a:bottom><a:ln w="38100"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill></a:ln></a:bottom></a:tcBdr>
          <a:fill><a:solidFill><a:schemeClr val="accent1"/></a:solidFill></a:fill>
        </a:tcStyle>
      </a:firstRow>
    </a:tblStyle>'''
)


@dataclass
class TableResult:
    """Composite render output plus optional native table metadata."""

    svg: str = ""
    defs: list[str] = None
    native_payload: dict[str, Any] | None = None
    native_status: str | None = None

    def __post_init__(self) -> None:
        if self.defs is None:
            self.defs = []


@dataclass(frozen=True)
class _TableStyleContext:
    """Small, best-effort view of the table style regions we render."""

    style: ET.Element | None
    table_properties: ET.Element | None

    def regions_for_row(self, row_index: int) -> tuple[tuple[str, ET.Element], ...]:
        if self.style is None:
            return ()

        names: list[str] = []
        first_row = bool(
            self.table_properties is not None
            and ooxml_bool(self.table_properties.get("firstRow"))
        )
        if first_row and row_index == 0:
            names.append("firstRow")
        elif (
            self.table_properties is not None
            and ooxml_bool(self.table_properties.get("bandRow"))
        ):
            band_index = row_index - (1 if first_row else 0)
            names.append("band1H" if band_index % 2 == 0 else "band2H")
        names.append("wholeTbl")

        regions: list[tuple[str, ET.Element]] = []
        for name in names:
            region = self.style.find(f"a:{name}", NS)
            if region is not None:
                regions.append((name, region))
        return tuple(regions)


def _normalize_table_style_id(value: str | None) -> str:
    return (value or "").strip().strip("{}").upper()


def _resolve_table_style(
    tbl: ET.Element,
    table_styles: ET.Element | None,
) -> _TableStyleContext:
    tbl_pr = tbl.find("a:tblPr", NS)
    style_id = (
        tbl_pr.findtext("a:tableStyleId", default="", namespaces=NS).strip()
        if tbl_pr is not None else ""
    )
    if not style_id and table_styles is not None:
        style_id = table_styles.get("def", "").strip()
    normalized_id = _normalize_table_style_id(style_id)
    supported_id = _normalize_table_style_id(BUILTIN_MEDIUM_STYLE_2_ACCENT_1)

    # P1 deliberately supports one built-in family.  Consuming an arbitrary
    # custom definition here would be asymmetric: native reconstruction keeps
    # only the style id and does not copy custom tableStyles.xml definitions.
    if normalized_id != supported_id:
        return _TableStyleContext(None, tbl_pr)

    if table_styles is not None and normalized_id:
        for candidate in table_styles.findall("a:tblStyle", NS):
            if _normalize_table_style_id(candidate.get("styleId")) == normalized_id:
                return _TableStyleContext(candidate, tbl_pr)

    return _TableStyleContext(_BUILTIN_MEDIUM_STYLE_2_ACCENT_1_XML, tbl_pr)


def _effective_cell_fill(
    tc_pr: ET.Element | None,
    table_style: _TableStyleContext,
    row_index: int,
    palette: ColorPalette | None,
    *,
    id_prefix: str,
    id_seq: list[int],
) -> FillResult:
    """Resolve direct cell fill before row-region and whole-table defaults."""
    direct = resolve_fill(
        tc_pr, palette, id_prefix=id_prefix, id_seq=id_seq,
    )
    if direct.attrs or direct.defs:
        return direct

    for _name, region in table_style.regions_for_row(row_index):
        fill_parent = region.find("a:tcStyle/a:fill", NS)
        if fill_parent is None:
            continue
        inherited = resolve_fill(
            fill_parent, palette, id_prefix=id_prefix, id_seq=id_seq,
        )
        if inherited.attrs or inherited.defs:
            return inherited
    return direct


def _table_text_run_props(
    table_style: _TableStyleContext,
    row_index: int,
    theme_fonts: dict[str, str],
) -> tuple[ET.Element, ...]:
    """Materialize table text-style regions as lowest-priority run defaults."""
    props: list[ET.Element] = []
    for _name, region in table_style.regions_for_row(row_index):
        tx_style = region.find("a:tcTxStyle", NS)
        if tx_style is None:
            continue
        run_props = _table_tx_style_run_props(tx_style, theme_fonts)
        if run_props is not None:
            props.append(run_props)
    return tuple(props)


def _table_tx_style_run_props(
    tx_style: ET.Element,
    theme_fonts: dict[str, str],
) -> ET.Element | None:
    run_props = ET.Element(f"{{{NS['a']}}}rPr")
    for attr in ("b", "i"):
        value = tx_style.get(attr)
        if value is not None:
            run_props.set(attr, "1" if ooxml_bool(value) else "0")

    font_ref = tx_style.find("a:fontRef", NS)
    font_role = font_ref.get("idx") if font_ref is not None else None
    if font_role in {"major", "minor"}:
        prefix = "major" if font_role == "major" else "minor"
        latin = theme_fonts.get(f"{prefix}Latin")
        east_asia = theme_fonts.get(f"{prefix}EastAsia") or latin
        complex_script = theme_fonts.get(f"{prefix}ComplexScript") or latin
        for tag, typeface in (
            ("latin", latin),
            ("ea", east_asia),
            ("cs", complex_script),
        ):
            if typeface:
                ET.SubElement(
                    run_props, f"{{{NS['a']}}}{tag}",
                    {"typeface": typeface},
                )

    color = find_color_elem(tx_style)
    if color is not None:
        solid_fill = ET.SubElement(run_props, f"{{{NS['a']}}}solidFill")
        solid_fill.append(copy.deepcopy(color))

    if not run_props.attrib and not list(run_props):
        return None
    return run_props


# ---------------------------------------------------------------------------
# Public entry
# ---------------------------------------------------------------------------

def convert_tbl(
    tbl: ET.Element,
    xfrm: Xfrm,
    palette: ColorPalette | None,
    *,
    table_styles: ET.Element | None = None,
    theme_fonts: dict[str, str] | None = None,
    slide_number: int | None = None,
    id_prefix: str = "tbl",
    grad_seq: list[int] | None = None,
    marker_seq: list[int] | None = None,
) -> TableResult:
    """Render an <a:tbl> at the given absolute xfrm into SVG markup."""
    grad_seq = grad_seq if grad_seq is not None else [0]
    marker_seq = marker_seq if marker_seq is not None else [0]

    col_widths_px = _column_widths_px(tbl)
    if not col_widths_px:
        return TableResult()
    rows = tbl.findall("a:tr", NS)
    if not rows:
        return TableResult()
    table_style = _resolve_table_style(tbl, table_styles)
    row_heights_px = [_row_height_px(r) for r in rows]
    grid_topology_invalid = any(
        len(row.findall("a:tc", NS)) != len(col_widths_px)
        for row in rows
    )
    source_geometry_invalid = (
        grid_topology_invalid
        or any(width <= 0 for width in col_widths_px)
        or any(height <= 0 for height in row_heights_px)
        or sum(col_widths_px) <= 0
        or sum(row_heights_px) <= 0
    )

    # PowerPoint's tblGrid widths and tr heights together describe the
    # *intrinsic* table size. The graphicFrame xfrm width/height may differ;
    # if it does, scale rows/columns proportionally so the table fills the
    # frame the way PowerPoint renders it.
    intrinsic_w = sum(col_widths_px) or xfrm.w
    intrinsic_h = sum(row_heights_px) or xfrm.h
    sx = (xfrm.w / intrinsic_w) if intrinsic_w else 1.0
    sy = (xfrm.h / intrinsic_h) if intrinsic_h else 1.0
    col_widths = [w * sx for w in col_widths_px]
    row_heights = [h * sy for h in row_heights_px]

    col_lefts = _cumulative_starts(xfrm.x, col_widths)
    row_tops = _cumulative_starts(xfrm.y, row_heights)

    # First pass: resolve merge state so spanned cells get the union geometry
    # and dropped cells don't render anything. PowerPoint expresses merges via
    # gridSpan/rowSpan on the anchor cell + hMerge/vMerge on the dropped cells.
    cells = _build_cell_grid(rows, len(col_widths))
    merge_status = _canonical_native_merge_status(rows, len(col_widths))
    if xfrm.rot or xfrm.flip_h or xfrm.flip_v:
        native_status = "unsupported-native-transform"
    elif merge_status:
        native_status = merge_status
    elif len(rows) > 1000 or len(col_widths) > 1000:
        native_status = "unsupported-table-size"
    elif source_geometry_invalid or (
        any(width <= 0 for width in col_widths)
        or any(height <= 0 for height in row_heights)
        or sum(col_widths) <= 0
        or sum(row_heights) <= 0
    ):
        native_status = "unsupported-table-geometry"
    elif _table_has_unsupported_style(tbl):
        native_status = "unsupported-table-style"
    elif _table_has_unsupported_direct_formatting(tbl, palette):
        native_status = "unsupported-table-direct-formatting"
    else:
        native_status = None
    native_payload = (
        None if native_status else _native_table_payload(
            tbl,
            xfrm,
            col_widths,
            row_heights,
            cells,
            palette,
            theme_fonts or {},
        )
    )

    body_parts: list[str] = []
    defs: list[str] = []

    # Pass A: cell backgrounds.
    for r, row_cells in enumerate(cells):
        for c, cell in enumerate(row_cells):
            if cell is None or cell.is_dropped:
                continue
            rect_x = col_lefts[c]
            rect_y = row_tops[r]
            rect_w = sum(col_widths[c:c + cell.col_span])
            rect_h = sum(row_heights[r:r + cell.row_span])
            tcPr = cell.element.find("a:tcPr", NS)
            fill = _effective_cell_fill(
                tcPr, table_style, r, palette,
                id_prefix=f"{id_prefix}fill",
                id_seq=grad_seq,
            )
            defs.extend(fill.defs)
            attrs = fill.attrs or {"fill": "none"}
            attr_str = "".join(f' {k}="{v}"' for k, v in attrs.items())
            body_parts.append(
                f'<rect x="{fmt_num(rect_x)}" y="{fmt_num(rect_y)}" '
                f'width="{fmt_num(rect_w)}" height="{fmt_num(rect_h)}"'
                f'{attr_str}/>'
            )

    # Pass B: cell text. Cell xfrm uses default tcPr insets if none specified.
    for r, row_cells in enumerate(cells):
        for c, cell in enumerate(row_cells):
            if cell is None or cell.is_dropped:
                continue
            tx_body = cell.element.find("a:txBody", NS)
            if tx_body is None:
                continue
            tcPr = cell.element.find("a:tcPr", NS)
            cell_x = col_lefts[c]
            cell_y = row_tops[r]
            cell_w = sum(col_widths[c:c + cell.col_span])
            cell_h = sum(row_heights[r:r + cell.row_span])
            cell_xfrm = Xfrm(x=cell_x, y=cell_y, w=cell_w, h=cell_h)
            text_result = _convert_cell_text(
                tx_body, tcPr, cell_xfrm, palette, theme_fonts,
                fallback_run_props=_table_text_run_props(
                    table_style, r, theme_fonts or {},
                ),
                slide_number=slide_number,
                id_prefix=f"{id_prefix}txt",
                id_seq=grad_seq,
            )
            defs.extend(text_result.defs)
            if text_result.svg:
                body_parts.append(text_result.svg)

    # Pass C: cell borders. Drawn last so they appear on top of fills/text.
    for r, row_cells in enumerate(cells):
        for c, cell in enumerate(row_cells):
            if cell is None or cell.is_dropped:
                continue
            cell_x = col_lefts[c]
            cell_y = row_tops[r]
            cell_w = sum(col_widths[c:c + cell.col_span])
            cell_h = sum(row_heights[r:r + cell.row_span])
            tcPr = cell.element.find("a:tcPr", NS)
            for tag, x1, y1, x2, y2 in (
                ("a:lnT", cell_x, cell_y, cell_x + cell_w, cell_y),
                ("a:lnR", cell_x + cell_w, cell_y, cell_x + cell_w, cell_y + cell_h),
                ("a:lnB", cell_x, cell_y + cell_h, cell_x + cell_w, cell_y + cell_h),
                ("a:lnL", cell_x, cell_y, cell_x, cell_y + cell_h),
            ):
                line_xml = _border_line(
                    tcPr, table_style, r, c, len(rows), len(col_widths), tag,
                    x1, y1, x2, y2, palette,
                    id_prefix=f"{id_prefix}stk", id_seq=marker_seq, defs=defs,
                )
                if line_xml:
                    body_parts.append(line_xml)

    return TableResult(
        svg="\n".join(body_parts),
        defs=defs,
        native_payload=native_payload,
        native_status=native_status,
    )


# ---------------------------------------------------------------------------
# Geometry helpers
# ---------------------------------------------------------------------------

def _column_widths_px(tbl: ET.Element) -> list[float]:
    grid = tbl.find("a:tblGrid", NS)
    if grid is None:
        return []
    widths: list[float] = []
    for col in grid.findall("a:gridCol", NS):
        w_emu = col.attrib.get("w")
        if w_emu is None:
            widths.append(0.0)
            continue
        value = _safe_emu_integer(w_emu)
        widths.append(emu_to_px(value) if value is not None else 0.0)
    return widths


def _row_height_px(row: ET.Element) -> float:
    h_emu = row.attrib.get("h")
    if h_emu is None:
        return 0.0
    value = _safe_emu_integer(h_emu)
    return emu_to_px(value) if value is not None else 0.0


def _safe_emu_integer(raw_value: str) -> int | None:
    """Return a bounded ASCII DrawingML coordinate without float overflow."""
    token = raw_value.strip(" \t\r\n")
    digits = token[1:] if token.startswith("-") else token
    if (
        not digits
        or not digits.isascii()
        or not digits.isdigit()
        or len(digits) > 10
    ):
        return None
    value = int(token)
    if not _POWERPOINT_COORD_MIN <= value <= _POWERPOINT_COORD_MAX:
        return None
    return value


def _cumulative_starts(origin: float, sizes: list[float]) -> list[float]:
    out = [origin]
    acc = origin
    for size in sizes[:-1]:
        acc += size
        out.append(acc)
    return out


# ---------------------------------------------------------------------------
# Cell grid
# ---------------------------------------------------------------------------

@dataclass
class _CellSlot:
    """Per-grid-position resolution of <a:tc> attributes."""

    element: ET.Element
    col_span: int = 1
    row_span: int = 1
    is_dropped: bool = False  # True for h/vMerge slaves: don't paint anything


@dataclass(frozen=True)
class _CanonicalMergeRegion:
    row: int
    col: int
    row_span: int
    col_span: int


def _build_cell_grid(rows: list[ET.Element], col_count: int) -> list[list[_CellSlot | None]]:
    """Map each (row, col) to the <a:tc> that owns it.

    Anchor cells (the top-left of a merge) carry col_span/row_span; merged
    slaves are marked is_dropped so the renderer skips them. Cells not part
    of any merge get span 1×1.
    """
    grid: list[list[_CellSlot | None]] = [[None] * col_count for _ in rows]

    for r, row in enumerate(rows):
        row_cells = row.findall("a:tc", NS)
        # PowerPoint writes one physical <a:tc> for every grid column, including
        # explicit hMerge/vMerge continuation cells. In that canonical form the
        # physical index is the grid column; advancing by gridSpan would consume
        # the continuation cell twice and shift every following cell left.
        explicit_grid = len(row_cells) >= col_count
        c = 0
        for physical_col, tc in enumerate(row_cells):
            if explicit_grid:
                c = physical_col
            else:
                # Retain best-effort support for compact/non-canonical rows that
                # omit explicit merge continuation cells.
                while c < col_count and grid[r][c] is not None:
                    c += 1
            if c >= col_count:
                break

            grid_span = _safe_int(tc.attrib.get("gridSpan"), 1)
            row_span = _safe_int(tc.attrib.get("rowSpan"), 1)
            h_merge = ooxml_bool(tc.attrib.get("hMerge"))
            v_merge = ooxml_bool(tc.attrib.get("vMerge"))

            if h_merge or v_merge:
                # Merge slaves are physical cells but have no independent paint.
                grid[r][c] = _CellSlot(element=tc, is_dropped=True)
                c += 1
                continue

            slot = _CellSlot(
                element=tc,
                col_span=max(grid_span, 1),
                row_span=max(row_span, 1),
            )
            for dr in range(slot.row_span):
                for dc in range(slot.col_span):
                    rr = r + dr
                    cc = c + dc
                    if rr >= len(rows) or cc >= col_count:
                        continue
                    if dr == 0 and dc == 0:
                        grid[rr][cc] = slot
                    else:
                        grid[rr][cc] = _CellSlot(
                            element=tc, is_dropped=True,
                        )
            c += 1 if explicit_grid else slot.col_span

    return grid


def _safe_int(value: str | None, default: int) -> int:
    if value is None:
        return default
    try:
        return int(value)
    except ValueError:
        return default


def _strict_merge_bool(value: str | None) -> bool:
    if value is None:
        return False
    normalized = value.strip().lower()
    if normalized in {"1", "on", "true"}:
        return True
    if normalized in {"0", "false", "off"}:
        return False
    raise ValueError("invalid OOXML boolean")


def _strict_merge_span(value: str | None) -> int:
    if value is None:
        return 1
    normalized = value.strip()
    if not normalized.isdigit():
        raise ValueError("invalid OOXML span")
    span = int(normalized)
    if span <= 0:
        raise ValueError("invalid OOXML span")
    return span


def _canonical_merge_slave_is_empty(tc: ET.Element) -> bool:
    tc_pr = tc.find("a:tcPr", NS)
    if tc_pr is None or tc_pr.attrib or list(tc_pr):
        return False
    tx_body = tc.find("a:txBody", NS)
    if tx_body is None:
        return False
    paragraph_count = 0
    for child in tx_body:
        name = child.tag.rsplit("}", 1)[-1]
        if name in {"bodyPr", "lstStyle"}:
            if child.attrib or list(child):
                return False
            continue
        if name == "p" and not child.attrib and not list(child):
            paragraph_count += 1
            continue
        return False
    return paragraph_count > 0 and not (tx_body.text or "").strip()


def _canonical_native_merge_status(
    rows: list[ET.Element],
    col_count: int,
) -> str | None:
    """Accept only explicit rectangular merge topology safe for regeneration."""
    physical_rows = [row.findall("a:tc", NS) for row in rows]
    merge_attrs = {"gridSpan", "rowSpan", "hMerge", "vMerge"}
    if not any(
        any(name in tc.attrib for name in merge_attrs)
        for row_cells in physical_rows
        for tc in row_cells
    ):
        return None
    if col_count <= 0 or any(
        len(row_cells) != col_count for row_cells in physical_rows
    ):
        return "unsupported-merge-topology"

    states: dict[tuple[int, int], tuple[ET.Element, int, int, bool, bool]] = {}
    try:
        for row_idx, row_cells in enumerate(physical_rows):
            for col_idx, tc in enumerate(row_cells):
                states[(row_idx, col_idx)] = (
                    tc,
                    _strict_merge_span(tc.get("rowSpan")),
                    _strict_merge_span(tc.get("gridSpan")),
                    _strict_merge_bool(tc.get("hMerge")),
                    _strict_merge_bool(tc.get("vMerge")),
                )
    except ValueError:
        return "unsupported-merge-topology"

    anchors: list[_CanonicalMergeRegion] = []
    for (
        (row_idx, col_idx),
        (_tc, row_span, col_span, h_merge, v_merge),
    ) in states.items():
        if not h_merge and not v_merge and (row_span > 1 or col_span > 1):
            anchors.append(
                _CanonicalMergeRegion(row_idx, col_idx, row_span, col_span)
            )

    owners: dict[tuple[int, int], _CanonicalMergeRegion] = {}
    for region in anchors:
        if (
            region.row + region.row_span > len(rows)
            or region.col + region.col_span > col_count
        ):
            return "unsupported-merge-topology"
        for covered_row in range(region.row, region.row + region.row_span):
            for covered_col in range(region.col, region.col + region.col_span):
                position = (covered_row, covered_col)
                if position in owners:
                    return "unsupported-merge-topology"
                owners[position] = region

    for (
        (row_idx, col_idx),
        (tc, row_span, col_span, h_merge, v_merge),
    ) in states.items():
        region = owners.get((row_idx, col_idx))
        if region is None:
            if h_merge or v_merge or row_span != 1 or col_span != 1:
                return "unsupported-merge-topology"
            continue

        is_anchor = row_idx == region.row and col_idx == region.col
        expected_row_span = region.row_span if row_idx == region.row else 1
        expected_col_span = region.col_span if col_idx == region.col else 1
        if (
            row_span != expected_row_span
            or col_span != expected_col_span
            or h_merge != (col_idx > region.col)
            or v_merge != (row_idx > region.row)
        ):
            return "unsupported-merge-topology"
        if not is_anchor and not _canonical_merge_slave_is_empty(tc):
            return "unsupported-merge-topology"

    return None


def _table_has_unsupported_style(tbl: ET.Element) -> bool:
    tbl_pr = tbl.find("a:tblPr", NS)
    if tbl_pr is None:
        return False
    allowed_attrs = {
        "firstRow", "bandRow", "firstCol", "lastCol", "lastRow",
        "bandCol", "rtl",
    }
    if any(name not in allowed_attrs for name in tbl_pr.attrib):
        return True
    if any(
        ooxml_bool(tbl_pr.attrib.get(name))
        for name in ("firstCol", "lastCol", "lastRow", "bandCol", "rtl")
    ):
        return True
    return any(
        child.tag.rsplit("}", 1)[-1] != "tableStyleId"
        for child in tbl_pr
    )


_DIRECT_BORDER_TAGS = {
    "lnL": "left",
    "lnR": "right",
    "lnT": "top",
    "lnB": "bottom",
}
_DIRECT_BORDER_WIDTH_MAX = 20116800
_OPAQUE_COLOR_MODIFIERS = {
    "tint",
    "shade",
    "lumMod",
    "lumOff",
    "satMod",
    "satOff",
}


def _validate_opaque_border_color(color_elem: ET.Element | None) -> None:
    if color_elem is None:
        raise ValueError("missing border color")
    name = color_elem.tag.rsplit("}", 1)[-1]
    if name not in {"srgbClr", "schemeClr"} or set(color_elem.attrib) != {"val"}:
        raise ValueError("unsupported border color")
    for modifier in color_elem:
        modifier_name = modifier.tag.rsplit("}", 1)[-1]
        if (
            modifier_name not in _OPAQUE_COLOR_MODIFIERS
            or set(modifier.attrib) != {"val"}
            or list(modifier)
        ):
            raise ValueError("unsupported border color modifier")
        value = modifier.get("val", "")
        if not value.isdigit() or not 0 <= int(value) <= 100000:
            raise ValueError("invalid border color modifier")


def _direct_border_payload(
    ln: ET.Element,
    palette: ColorPalette | None,
) -> dict[str, Any]:
    if set(ln.attrib) - {"w", "cap", "cmpd", "algn"}:
        raise ValueError("unsupported border line attribute")
    if ln.get("cap") not in {None, "flat"}:
        raise ValueError("unsupported border cap")
    if ln.get("cmpd") not in {None, "sng"}:
        raise ValueError("unsupported border compound style")
    if ln.get("algn") not in {None, "ctr"}:
        raise ValueError("unsupported border alignment")
    width_emu: int | None = None
    if "w" in ln.attrib:
        raw_width = ln.get("w", "")
        if not raw_width.isdigit():
            raise ValueError("invalid border width")
        width_emu = int(raw_width)
        if not 0 < width_emu <= _DIRECT_BORDER_WIDTH_MAX:
            raise ValueError("invalid border width")

    children = list(ln)
    child_names = [child.tag.rsplit("}", 1)[-1] for child in children]
    if child_names == ["noFill"]:
        no_fill = children[0]
        if no_fill.attrib or list(no_fill):
            raise ValueError("invalid noFill border")
        return {"style": "none"}

    decoration_names = {"round", "headEnd", "tailEnd"}
    if child_names.count("solidFill") != 1 or any(
        name not in {"solidFill", "prstDash", *decoration_names}
        for name in child_names
    ):
        raise ValueError("unsupported border paint")
    if (
        child_names.count("prstDash") > 1
        or any(child_names.count(name) > 1 for name in decoration_names)
        or width_emu is None
    ):
        raise ValueError("invalid solid border")
    dash = next(
        (child for child in children if child.tag.rsplit("}", 1)[-1] == "prstDash"),
        None,
    )
    if dash is not None and (
        dash.attrib != {"val": "solid"} or list(dash)
    ):
        raise ValueError("unsupported border dash")
    line_join = next(
        (child for child in children if child.tag.rsplit("}", 1)[-1] == "round"),
        None,
    )
    if line_join is not None and (line_join.attrib or list(line_join)):
        raise ValueError("unsupported border line join")
    for endpoint_name in ("headEnd", "tailEnd"):
        endpoint = next(
            (
                child for child in children
                if child.tag.rsplit("}", 1)[-1] == endpoint_name
            ),
            None,
        )
        if endpoint is None:
            continue
        if (
            set(endpoint.attrib) - {"type", "w", "len"}
            or endpoint.get("type") not in {None, "none"}
            or endpoint.get("w") not in {None, "med"}
            or endpoint.get("len") not in {None, "med"}
            or list(endpoint)
        ):
            raise ValueError("unsupported border endpoint")

    solid_fill = next(
        child for child in children
        if child.tag.rsplit("}", 1)[-1] == "solidFill"
    )
    if solid_fill.attrib or len(list(solid_fill)) != 1:
        raise ValueError("invalid solid border fill")
    color_elem = find_color_elem(solid_fill)
    _validate_opaque_border_color(color_elem)
    try:
        color, alpha = resolve_color(color_elem, palette)
    except (TypeError, ValueError, OverflowError) as exc:
        raise ValueError("invalid solid border color") from exc
    if color is None or alpha != 1.0:
        raise ValueError("border color must resolve to opaque RGB")

    width = _round_payload_number(emu_to_px(str(width_emu)))
    if width <= 0:
        raise ValueError("border width is too small")
    return {
        "style": "solid",
        "color": color,
        "width": width,
    }


def _table_has_unsupported_direct_formatting(
    tbl: ET.Element,
    palette: ColorPalette | None,
) -> bool:
    """Reject direct cell features the compact native schema cannot retain."""
    for tc in tbl.findall(".//a:tc", NS):
        if _table_cell_has_unsupported_topology(tc):
            return True
        tc_pr = tc.find("a:tcPr", NS)
        if tc_pr is not None:
            allowed_attrs = {"marL", "marR", "marT", "marB", "anchor"}
            if any(name not in allowed_attrs for name in tc_pr.attrib):
                return True
            if tc_pr.get("anchor") not in {None, "t", "ctr", "b"}:
                return True
            if any(
                child.tag.rsplit("}", 1)[-1]
                not in {"solidFill", "noFill", *_DIRECT_BORDER_TAGS}
                for child in tc_pr
            ):
                return True
            fills = [
                child for child in tc_pr
                if child.tag.rsplit("}", 1)[-1] in {"solidFill", "noFill"}
            ]
            if len(fills) > 1:
                return True
            no_fill = tc_pr.find("a:noFill", NS)
            if no_fill is not None and (no_fill.attrib or list(no_fill)):
                return True
            for border_tag in _DIRECT_BORDER_TAGS:
                borders = tc_pr.findall(f"a:{border_tag}", NS)
                if len(borders) > 1:
                    return True
                if borders:
                    try:
                        _direct_border_payload(borders[0], palette)
                    except ValueError:
                        return True
            solid_fill = tc_pr.find("a:solidFill", NS)
            if solid_fill is not None:
                if solid_fill.find(".//a:alpha", NS) is not None:
                    return True
                if _cell_fill_hex(tc_pr, palette) is None:
                    return True
        tx_body = tc.find("a:txBody", NS)
        if _text_body_has_unsupported_formatting(tx_body):
            return True
    return False


def _table_cell_has_unsupported_topology(tc: ET.Element) -> bool:
    """Accept only the closed optional txBody -> optional tcPr cell sequence."""
    if any(
        name not in {"gridSpan", "rowSpan", "hMerge", "vMerge"}
        for name in tc.attrib
    ):
        return True
    tx_body_tag = f"{{{NS['a']}}}txBody"
    tc_pr_tag = f"{{{NS['a']}}}tcPr"
    child_tags = [child.tag for child in tc]
    child_index = 0
    if child_tags[:1] == [tx_body_tag]:
        child_index += 1
    if child_tags[child_index:child_index + 1] == [tc_pr_tag]:
        child_index += 1
    return child_index != len(child_tags)


def _text_body_has_unsupported_formatting(tx_body: ET.Element | None) -> bool:
    if tx_body is None:
        return False
    if tx_body.attrib:
        return True

    body_pr_tag = f"{{{NS['a']}}}bodyPr"
    list_style_tag = f"{{{NS['a']}}}lstStyle"
    paragraph_tag = f"{{{NS['a']}}}p"
    body_tags = [child.tag for child in tx_body]
    body_index = 0
    if not body_tags or body_tags[0] != body_pr_tag:
        return True
    body_index += 1
    if body_index < len(body_tags) and body_tags[body_index] == list_style_tag:
        body_index += 1
    if body_index == len(body_tags) or any(
        tag != paragraph_tag for tag in body_tags[body_index:]
    ):
        return True

    relationship_prefix = f"{{{NS['r']}}}"
    forbidden_run_children = {
        f"{{{NS['a']}}}extLst",
        f"{{{NS['a']}}}hlinkClick",
        f"{{{NS['a']}}}hlinkMouseOver",
    }
    if any(
        node.tag in forbidden_run_children
        or any(name.startswith(relationship_prefix) for name in node.attrib)
        for node in tx_body.iter()
    ):
        return True

    body_pr = tx_body.find("a:bodyPr", NS)
    if body_pr is None or body_pr.attrib or list(body_pr):
        return True
    list_style = tx_body.find("a:lstStyle", NS)
    if list_style is not None and (list_style.attrib or list(list_style)):
        return True
    for paragraph in tx_body.findall("a:p", NS):
        if paragraph.attrib:
            return True
        p_pr_tag = f"{{{NS['a']}}}pPr"
        run_tag = f"{{{NS['a']}}}r"
        end_r_pr_tag = f"{{{NS['a']}}}endParaRPr"
        direct_tags = [child.tag for child in paragraph]
        paragraph_index = 0
        if direct_tags[:1] == [p_pr_tag]:
            paragraph_index += 1
        while (
            paragraph_index < len(direct_tags)
            and direct_tags[paragraph_index] == run_tag
        ):
            paragraph_index += 1
        if (
            paragraph_index < len(direct_tags)
            and direct_tags[paragraph_index] == end_r_pr_tag
        ):
            paragraph_index += 1
        if paragraph_index != len(direct_tags):
            return True

        p_pr = paragraph.find("a:pPr", NS)
        if p_pr is not None:
            p_pr_tags = [child.tag.rsplit("}", 1)[-1] for child in p_pr]
            if p_pr_tags.count("defRPr") > 1 or p_pr_tags.count("buNone") > 1:
                return True
            if any(tag.startswith("bu") and tag != "buNone" for tag in p_pr_tags):
                return True

        for run in paragraph.findall("a:r", NS):
            if run.attrib:
                return True
            r_pr_tag = f"{{{NS['a']}}}rPr"
            text_tag = f"{{{NS['a']}}}t"
            run_tags = [child.tag for child in run]
            if run_tags not in ([text_tag], [r_pr_tag, text_tag]):
                return True
            text_node = run.find("a:t", NS)
            if text_node is None or list(text_node):
                return True
            allowed_text_attrs = {"{http://www.w3.org/XML/1998/namespace}space"}
            if any(name not in allowed_text_attrs for name in text_node.attrib):
                return True
    return False


def _legacy_text_body_has_unsupported_formatting(
    tx_body: ET.Element | None,
) -> bool:
    """Return the pre-P2-T4 gate so active plain payloads stay unchanged."""
    if tx_body is None:
        return False
    body_pr = tx_body.find("a:bodyPr", NS)
    if body_pr is not None and (body_pr.attrib or list(body_pr)):
        return True
    list_style = tx_body.find("a:lstStyle", NS)
    if list_style is not None and (list_style.attrib or list(list_style)):
        return True
    if (
        tx_body.find(".//a:br", NS) is not None
        or tx_body.find(".//a:fld", NS) is not None
        or tx_body.find(".//a:tab", NS) is not None
    ):
        return True

    run_signatures: set[tuple[str | None, str | None, bytes | None]] = set()
    for paragraph in tx_body.findall("a:p", NS):
        p_pr = paragraph.find("a:pPr", NS)
        alignment = p_pr.get("algn") if p_pr is not None else None
        if alignment not in {None, "l", "ctr", "r"}:
            return True
        if p_pr is not None:
            if any(name != "algn" for name in p_pr.attrib):
                return True
            if any(
                child.tag.rsplit("}", 1)[-1] not in {"defRPr", "buNone"}
                for child in p_pr
            ):
                return True

        default_r_pr = p_pr.find("a:defRPr", NS) if p_pr is not None else None
        if _legacy_run_props_have_unsupported_formatting(default_r_pr):
            return True
        for run in paragraph.findall("a:r", NS):
            r_pr = run.find("a:rPr", NS)
            if _legacy_run_props_have_unsupported_formatting(r_pr):
                return True
            run_signatures.add(
                _legacy_effective_run_signature(r_pr, default_r_pr)
            )
        end_r_pr = paragraph.find("a:endParaRPr", NS)
        if _legacy_run_props_have_unsupported_formatting(end_r_pr):
            return True
        if not paragraph.findall("a:r", NS) and end_r_pr is not None:
            run_signatures.add(
                _legacy_effective_run_signature(end_r_pr, default_r_pr)
            )

    return len(run_signatures) > 1


def _legacy_run_props_have_unsupported_formatting(
    r_pr: ET.Element | None,
) -> bool:
    if r_pr is None:
        return False
    if ooxml_bool(r_pr.get("i")):
        return True
    if r_pr.get("u") not in {None, "none"}:
        return True
    if r_pr.get("strike") not in {None, "noStrike"}:
        return True
    if r_pr.get("baseline") not in {None, "0"}:
        return True
    if r_pr.get("cap") not in {None, "none"}:
        return True
    if r_pr.get("spc") not in {None, "0"}:
        return True
    allowed_attrs = {
        "lang", "altLang", "sz", "b", "i", "u", "strike", "dirty",
        "baseline", "cap", "spc",
    }
    if any(name not in allowed_attrs for name in r_pr.attrib):
        return True
    solid_fill = r_pr.find("a:solidFill", NS)
    if solid_fill is not None and solid_fill.find(".//a:alpha", NS) is not None:
        return True
    return any(
        child.tag.rsplit("}", 1)[-1] != "solidFill"
        for child in r_pr
    )


def _legacy_effective_run_signature(
    r_pr: ET.Element | None,
    default_r_pr: ET.Element | None,
) -> tuple[str | None, str | None, bytes | None]:
    def attr(name: str) -> str | None:
        if r_pr is not None and r_pr.get(name) is not None:
            return r_pr.get(name)
        return default_r_pr.get(name) if default_r_pr is not None else None

    solid_fill = r_pr.find("a:solidFill", NS) if r_pr is not None else None
    if solid_fill is None and default_r_pr is not None:
        solid_fill = default_r_pr.find("a:solidFill", NS)
    fill_xml = (
        ET.tostring(solid_fill, encoding="utf-8")
        if solid_fill is not None else None
    )
    return attr("b"), attr("sz"), fill_xml


def _round_payload_number(value: float) -> int | float:
    rounded = round(float(value), 3)
    return int(rounded) if rounded.is_integer() else rounded


def _native_table_payload(
    tbl: ET.Element,
    xfrm: Xfrm,
    column_widths: list[float],
    row_heights: list[float],
    cells: list[list[_CellSlot | None]],
    palette: ColorPalette | None,
    theme_fonts: dict[str, str],
) -> dict[str, Any]:
    """Build the SVG data-pptx-native payload for an unmerged table."""
    tbl_pr = tbl.find("a:tblPr", NS)
    payload: dict[str, Any] = {
        "x": _round_payload_number(xfrm.x),
        "y": _round_payload_number(xfrm.y),
        "width": _round_payload_number(xfrm.w),
        "height": _round_payload_number(xfrm.h),
        "strict_grid": True,
        "header_rows": (
            1 if tbl_pr is not None and ooxml_bool(tbl_pr.get("firstRow")) else 0
        ),
        "column_widths": [_round_payload_number(width) for width in column_widths],
        "row_heights": [_round_payload_number(height) for height in row_heights],
        "rows": [],
    }
    style: dict[str, Any] = {
        "band_row": bool(tbl_pr is not None and ooxml_bool(tbl_pr.get("bandRow"))),
    }
    if tbl_pr is not None:
        table_style_id = tbl_pr.findtext("a:tableStyleId", default="", namespaces=NS).strip()
        if table_style_id:
            style["table_style_id"] = table_style_id
    payload["style"] = style

    rows_payload: list[list[Any]] = []
    for row_cells in cells:
        row_payload: list[Any] = []
        for slot in row_cells:
            if slot is None or slot.is_dropped:
                row_payload.append("")
                continue
            cell_payload = _native_cell_payload(
                slot.element,
                palette,
                theme_fonts,
            )
            if slot.row_span > 1:
                cell_payload["row_span"] = slot.row_span
            if slot.col_span > 1:
                cell_payload["col_span"] = slot.col_span
            row_payload.append(cell_payload)
        rows_payload.append(row_payload)
    payload["rows"] = rows_payload
    return payload


def _native_cell_payload(
    tc: ET.Element,
    palette: ColorPalette | None,
    theme_fonts: dict[str, str],
) -> dict[str, Any]:
    tx_body = tc.find("a:txBody", NS)
    tc_pr = tc.find("a:tcPr", NS)
    paragraph_payloads = _cell_paragraph_payloads(tx_body)
    rich_paragraphs = _cell_rich_paragraph_payloads(
        tx_body,
        palette,
        theme_fonts,
    )
    if rich_paragraphs is not None:
        cell: dict[str, Any] = {"paragraphs": rich_paragraphs}
    elif len(paragraph_payloads) > 1:
        cell: dict[str, Any] = {"paragraphs": paragraph_payloads}
    else:
        cell = {"text": _cell_plain_text(tx_body)}

    fill = _cell_fill_hex(tc_pr, palette)
    if fill:
        cell["fill"] = fill
    if rich_paragraphs is None:
        color = _cell_text_color(tx_body, palette)
        if color:
            cell["color"] = color
        font_size = _cell_font_size_px(tx_body)
        if font_size:
            cell["font_size"] = font_size
    if len(paragraph_payloads) <= 1:
        align = _cell_align(tx_body)
        if align:
            cell["align"] = align
    valign = _cell_valign(tc_pr)
    if valign:
        cell["valign"] = valign
    if rich_paragraphs is None:
        bold = _cell_bold(tx_body)
        if bold is not None:
            cell["bold"] = bold
    borders = _cell_borders_payload(tc_pr, palette)
    if borders:
        cell["borders"] = borders
    _copy_cell_margins(tc_pr, cell)
    return cell


def _cell_plain_text(tx_body: ET.Element | None) -> str:
    if tx_body is None:
        return ""
    paragraphs: list[str] = []
    for paragraph in tx_body.findall("a:p", NS):
        text = "".join(node.text or "" for node in paragraph.findall(".//a:t", NS))
        if text:
            paragraphs.append(text)
    return "\n".join(paragraphs)


def _cell_paragraph_payloads(
    tx_body: ET.Element | None,
) -> list[str | dict[str, str]]:
    if tx_body is None:
        return []
    payloads: list[str | dict[str, str]] = []
    for paragraph in tx_body.findall("a:p", NS):
        text = "".join(node.text or "" for node in paragraph.findall(".//a:t", NS))
        p_pr = paragraph.find("a:pPr", NS)
        align = p_pr.get("algn") if p_pr is not None else None
        if align in {"l", "ctr", "r"}:
            payloads.append({"text": text, "align": align})
        else:
            payloads.append(text)
    return payloads


def _effective_run_attr(
    r_pr: ET.Element | None,
    default_r_pr: ET.Element | None,
    name: str,
) -> str | None:
    if r_pr is not None and r_pr.get(name) is not None:
        return r_pr.get(name)
    return default_r_pr.get(name) if default_r_pr is not None else None


def _effective_run_child(
    r_pr: ET.Element | None,
    default_r_pr: ET.Element | None,
    name: str,
) -> ET.Element | None:
    child = r_pr.find(f"a:{name}", NS) if r_pr is not None else None
    if child is not None:
        return child
    return default_r_pr.find(f"a:{name}", NS) if default_r_pr is not None else None


def _native_run_font_family(
    r_pr: ET.Element | None,
    default_r_pr: ET.Element | None,
    theme_fonts: dict[str, str],
) -> str | None:
    faces: list[str] = []
    for tag in ("latin", "ea"):
        node = _effective_run_child(r_pr, default_r_pr, tag)
        raw_face = node.get("typeface") if node is not None else None
        face = _resolve_theme_typeface(raw_face, theme_fonts)
        if face and face not in faces:
            faces.append(face)
    if len(faces) != 1:
        return None
    face = faces[0].strip()
    return face if face and "," not in face else None


def _native_run_payload(
    run: ET.Element,
    default_r_pr: ET.Element | None,
    palette: ColorPalette | None,
    theme_fonts: dict[str, str],
) -> dict[str, Any]:
    r_pr = run.find("a:rPr", NS)
    text = run.findtext("a:t", default="", namespaces=NS)
    payload: dict[str, Any] = {"text": text}

    for source, target in (("b", "bold"), ("i", "italic")):
        raw = _effective_run_attr(r_pr, default_r_pr, source)
        if raw is not None:
            payload[target] = ooxml_bool(raw)

    underline = _effective_run_attr(r_pr, default_r_pr, "u")
    if underline is not None:
        payload["underline"] = underline != "none"
    strike = _effective_run_attr(r_pr, default_r_pr, "strike")
    if strike is not None:
        payload["strike"] = strike != "noStrike"

    font_size = _canonical_source_font_size_px(
        _effective_run_attr(r_pr, default_r_pr, "sz")
    )
    if font_size is not None:
        payload["font_size"] = font_size

    solid_fill = _effective_run_child(r_pr, default_r_pr, "solidFill")
    if solid_fill is not None and not _solid_fill_is_unsafe(solid_fill, palette):
        try:
            color, _alpha = resolve_color(find_color_elem(solid_fill), palette)
        except (AttributeError, OverflowError, TypeError, ValueError):
            color = None
        if color:
            payload["color"] = color

    font_family = _native_run_font_family(
        r_pr,
        default_r_pr,
        theme_fonts,
    )
    if font_family:
        payload["font_family"] = font_family

    for source, target in (("lang", "lang"), ("altLang", "alt_lang")):
        language = _effective_run_attr(r_pr, default_r_pr, source)
        if language and language.strip():
            payload[target] = language.strip()
    return payload


def _cell_rich_paragraph_payloads(
    tx_body: ET.Element | None,
    palette: ColorPalette | None,
    theme_fonts: dict[str, str],
) -> list[dict[str, Any]] | None:
    """Materialize runs only when the legacy plain contract was insufficient."""
    if tx_body is None or not _legacy_text_body_has_unsupported_formatting(tx_body):
        return None

    paragraphs: list[tuple[str | None, list[dict[str, Any]]]] = []
    style_signatures: set[tuple[tuple[str, Any], ...]] = set()
    needs_runs = False
    run_only_fields = {
        "italic", "underline", "strike", "font_family", "lang", "alt_lang",
    }
    for paragraph in tx_body.findall("a:p", NS):
        p_pr = paragraph.find("a:pPr", NS)
        align = p_pr.get("algn") if p_pr is not None else None
        if align not in {"l", "ctr", "r"}:
            align = None
        default_r_pr = p_pr.find("a:defRPr", NS) if p_pr is not None else None
        runs = [
            _native_run_payload(run, default_r_pr, palette, theme_fonts)
            for run in paragraph.findall("a:r", NS)
        ]
        paragraphs.append((align, runs))
        for run in runs:
            style = tuple(sorted((key, value) for key, value in run.items() if key != "text"))
            style_signatures.add(style)
            if run_only_fields.intersection(run):
                needs_runs = True

    if len(style_signatures) > 1:
        needs_runs = True
    if not needs_runs:
        return None

    payloads: list[dict[str, Any]] = []
    for align, runs in paragraphs:
        paragraph_payload: dict[str, Any]
        if runs:
            paragraph_payload = {"runs": runs}
        else:
            paragraph_payload = {"text": ""}
        if align is not None:
            paragraph_payload["align"] = align
        payloads.append(paragraph_payload)
    return payloads


def _cell_fill_hex(tc_pr: ET.Element | None, palette: ColorPalette | None) -> str | None:
    fill = resolve_fill(tc_pr, palette)
    color = fill.attrs.get("fill") if fill.attrs else None
    if color and color.startswith("#"):
        return color
    return None


def _cell_text_color(tx_body: ET.Element | None, palette: ColorPalette | None) -> str | None:
    for r_pr in _text_run_props_in_priority(tx_body):
        solid_fill = r_pr.find("a:solidFill", NS)
        if solid_fill is not None and not _solid_fill_is_unsafe(solid_fill, palette):
            try:
                color, _alpha = resolve_color(find_color_elem(solid_fill), palette)
            except (AttributeError, OverflowError, TypeError, ValueError):
                continue
            if color:
                return color
    return None


def _cell_font_size_px(tx_body: ET.Element | None) -> int | float | None:
    for r_pr in _text_run_props_in_priority(tx_body):
        size = _canonical_source_font_size_px(r_pr.get("sz"))
        if size is not None:
            return size
    return None


def _canonical_source_font_size_px(raw_size: str | None) -> int | float | None:
    """Return a writer-stable font size for one bounded DrawingML token."""
    if (
        raw_size is None
        or not raw_size.isascii()
        or not raw_size.isdigit()
        or len(raw_size) > 6
    ):
        return None
    size_hpt = int(raw_size)
    if not 100 <= size_hpt <= 400000:
        return None
    # The writer emits sizes at 0.1pt precision; canonicalize on first import
    # so source and native reimport payloads stay stable.
    canonical_hpt = round(size_hpt / 10) * 10
    return _round_payload_number(hundredths_pt_to_px(canonical_hpt))


def _cell_align(tx_body: ET.Element | None) -> str | None:
    if tx_body is None:
        return None
    p_pr = tx_body.find("a:p/a:pPr", NS)
    align = p_pr.get("algn") if p_pr is not None else None
    if align in {"l", "ctr", "r"}:
        return align
    return None


def _cell_valign(tc_pr: ET.Element | None) -> str | None:
    anchor = tc_pr.get("anchor") if tc_pr is not None else None
    return {
        "t": "top",
        "ctr": "middle",
        "b": "bottom",
    }.get(anchor)


def _cell_bold(tx_body: ET.Element | None) -> bool | None:
    for r_pr in _text_run_props_in_priority(tx_body):
        if r_pr.get("b") is not None:
            return ooxml_bool(r_pr.get("b"))
    return None


def _cell_borders_payload(
    tc_pr: ET.Element | None,
    palette: ColorPalette | None,
) -> dict[str, dict[str, Any]]:
    if tc_pr is None:
        return {}
    borders: dict[str, dict[str, Any]] = {}
    for border_tag, side in _DIRECT_BORDER_TAGS.items():
        ln = tc_pr.find(f"a:{border_tag}", NS)
        if ln is not None:
            borders[side] = _direct_border_payload(ln, palette)
    return borders


def _text_run_props_in_priority(tx_body: ET.Element | None) -> list[ET.Element]:
    if tx_body is None:
        return []
    props: list[ET.Element] = []
    for path in (".//a:r/a:rPr", ".//a:pPr/a:defRPr", ".//a:endParaRPr"):
        r_pr = tx_body.find(path, NS)
        if r_pr is not None:
            props.append(r_pr)
    return props


def _copy_cell_margins(tc_pr: ET.Element | None, cell: dict[str, Any]) -> None:
    if tc_pr is None:
        return
    for source, target in (
        ("marL", "padding_left"),
        ("marR", "padding_right"),
        ("marT", "padding_top"),
        ("marB", "padding_bottom"),
    ):
        if source not in tc_pr.attrib:
            continue
        value = _safe_emu_integer(tc_pr.attrib[source])
        if value is None or value < 0:
            continue
        cell[target] = _round_payload_number(emu_to_px(value))


# ---------------------------------------------------------------------------
# Cell text & borders
# ---------------------------------------------------------------------------

def _convert_cell_text(
    tx_body: ET.Element,
    tcPr: ET.Element | None,
    cell_xfrm: Xfrm,
    palette: ColorPalette | None,
    theme_fonts: dict[str, str] | None,
    *,
    fallback_run_props: tuple[ET.Element, ...],
    slide_number: int | None,
    id_prefix: str,
    id_seq: list[int] | None,
):
    """Render cell text. PowerPoint's <a:tcPr> can override txBody insets via
    its own marL/marR/marT/marB attrs; convert_txbody reads from <a:bodyPr>,
    so we materialise a synthetic bodyPr when tcPr has its own insets. Invalid
    malformed source font-size and run-color values are removed from a private
    render copy; they have already been omitted from the native payload and
    must not crash fallback SVG generation."""
    render_tx_body = tx_body
    run_props = [
        node
        for node in tx_body.iter()
        if node.tag in {
            f"{{{NS['a']}}}defRPr",
            f"{{{NS['a']}}}endParaRPr",
            f"{{{NS['a']}}}rPr",
        }
    ]
    if any(
        _run_props_need_render_normalization(node, palette)
        for node in run_props
    ):
        render_tx_body = copy.deepcopy(tx_body)
        for node in render_tx_body.iter():
            if node.tag not in {
                f"{{{NS['a']}}}defRPr",
                f"{{{NS['a']}}}endParaRPr",
                f"{{{NS['a']}}}rPr",
            }:
                continue
            if (
                node.get("sz") is not None
                and _canonical_source_font_size_px(node.get("sz")) is None
            ):
                node.attrib.pop("sz", None)
            solid_fill = node.find("a:solidFill", NS)
            if solid_fill is not None and _solid_fill_is_unsafe(solid_fill, palette):
                node.remove(solid_fill)

    body_pr = render_tx_body.find("a:bodyPr", NS)
    overrides = _tcPr_inset_overrides(tcPr)
    saved: dict[str, str | None] = {}
    if overrides and body_pr is not None:
        for key, val in overrides.items():
            saved[key] = body_pr.attrib.get(key)
            body_pr.set(key, val)
    try:
        try:
            return convert_txbody(
                render_tx_body, cell_xfrm, palette, theme_fonts=theme_fonts,
                fallback_run_props=fallback_run_props,
                slide_number=slide_number,
                id_prefix=id_prefix,
                id_seq=id_seq,
            )
        except (AttributeError, OverflowError, TypeError, ValueError):
            plain_tx_body = _plain_table_text_body(render_tx_body, overrides)
            return convert_txbody(
                plain_tx_body, cell_xfrm, palette, theme_fonts=theme_fonts,
                fallback_run_props=(),
                slide_number=slide_number,
                id_prefix=id_prefix,
                id_seq=id_seq,
            )
    finally:
        if overrides and body_pr is not None:
            for key, prior in saved.items():
                if prior is None:
                    body_pr.attrib.pop(key, None)
                else:
                    body_pr.set(key, prior)


def _run_props_need_render_normalization(
    run_props: ET.Element,
    palette: ColorPalette | None,
) -> bool:
    size = run_props.get("sz")
    if size is not None and _canonical_source_font_size_px(size) is None:
        return True
    solid_fill = run_props.find("a:solidFill", NS)
    return solid_fill is not None and _solid_fill_is_unsafe(solid_fill, palette)


def _solid_fill_is_unsafe(
    solid_fill: ET.Element,
    palette: ColorPalette | None,
) -> bool:
    color = find_color_elem(solid_fill)
    if color is not None and not _color_numeric_tokens_are_finite(color):
        return True
    try:
        resolve_color(color, palette)
    except (AttributeError, OverflowError, TypeError, ValueError):
        return True
    return False


def _color_numeric_tokens_are_finite(color: ET.Element) -> bool:
    """Reject non-finite numeric tokens before the resolver can clamp them."""
    base_numeric_attrs = {
        "hslClr": ("hue", "sat", "lum"),
        "scrgbClr": ("r", "g", "b"),
    }
    modifier_tags = {
        "alpha", "alphaMod", "alphaOff", "hueMod", "hueOff",
        "lumMod", "lumOff", "satMod", "satOff", "shade", "tint",
    }
    for node in color.iter():
        name = node.tag.rsplit("}", 1)[-1]
        attrs = base_numeric_attrs.get(name, ())
        if name in modifier_tags:
            attrs = ("val",)
        for attr in attrs:
            raw = node.get(attr)
            if raw is None:
                return False
            try:
                value = float(raw)
            except (OverflowError, TypeError, ValueError):
                return False
            if not math.isfinite(value):
                return False
    return True


def _plain_table_text_body(
    tx_body: ET.Element,
    overrides: dict[str, str],
) -> ET.Element:
    """Return a text-preserving style-free fallback after malformed styling."""
    plain = copy.deepcopy(tx_body)
    body_pr = plain.find("a:bodyPr", NS)
    if body_pr is None:
        body_pr = ET.Element(f"{{{NS['a']}}}bodyPr")
        plain.insert(0, body_pr)
    body_pr.clear()
    for key, value in overrides.items():
        body_pr.set(key, value)
    list_style = plain.find("a:lstStyle", NS)
    if list_style is not None:
        list_style.clear()
    for paragraph in plain.findall("a:p", NS):
        p_pr = paragraph.find("a:pPr", NS)
        if p_pr is not None:
            align = p_pr.get("algn")
            p_pr.clear()
            if align in {"l", "ctr", "r"}:
                p_pr.set("algn", align)
    for path in (".//a:rPr", ".//a:defRPr", ".//a:endParaRPr"):
        for run_props in plain.findall(path, NS):
            run_props.clear()
    return plain


def _tcPr_inset_overrides(tcPr: ET.Element | None) -> dict[str, str]:
    if tcPr is None:
        return {}
    out: dict[str, str] = {}
    for src, dst in (("marL", "lIns"), ("marR", "rIns"),
                     ("marT", "tIns"), ("marB", "bIns")):
        if src not in tcPr.attrib:
            continue
        value = _safe_emu_integer(tcPr.attrib[src])
        if value is not None and value >= 0:
            out[dst] = str(value)
    return out


def _border_line(
    tcPr: ET.Element | None,
    table_style: _TableStyleContext,
    row_index: int,
    col_index: int,
    row_count: int,
    col_count: int,
    tag: str,
    x1: float, y1: float, x2: float, y2: float,
    palette: ColorPalette | None,
    *,
    id_prefix: str,
    id_seq: list[int],
    defs: list[str],
) -> str:
    """Emit a single border <line> for a given cell side, or empty string when
    that side is explicitly noFill / not specified."""
    ln = tcPr.find(tag, NS) if tcPr is not None else None
    if ln is not None:
        return _line_element_to_svg(
            ln, x1, y1, x2, y2, palette,
            id_prefix=id_prefix, id_seq=id_seq, defs=defs,
        )

    # Draw inherited shared edges once, from the upper/left cell.  This keeps
    # a specific firstRow bottom border from being painted over by the next
    # row's whole-table top border.  A direct border above still wins because
    # it is handled before this de-duplication gate.
    if (tag == "a:lnT" and row_index > 0) or (
        tag == "a:lnL" and col_index > 0
    ):
        return ""

    for region_name, region in table_style.regions_for_row(row_index):
        for border_name in _table_style_border_names(
            region_name, row_index, col_index, row_count, col_count, tag,
        ):
            ln = region.find(
                f"a:tcStyle/a:tcBdr/a:{border_name}/a:ln", NS,
            )
            if ln is not None:
                return _line_element_to_svg(
                    ln, x1, y1, x2, y2, palette,
                    id_prefix=id_prefix, id_seq=id_seq, defs=defs,
                )
    return ""


def _table_style_border_names(
    region_name: str,
    row_index: int,
    col_index: int,
    row_count: int,
    col_count: int,
    tag: str,
) -> tuple[str, ...]:
    side_names = {
        "a:lnT": ("top", "insideH", row_index > 0),
        "a:lnR": ("right", "insideV", col_index < col_count - 1),
        "a:lnB": ("bottom", "insideH", row_index < row_count - 1),
        "a:lnL": ("left", "insideV", col_index > 0),
    }
    side, inside, is_internal = side_names[tag]
    if region_name == "wholeTbl" and is_internal:
        return inside, side
    return side, inside


def _line_element_to_svg(
    ln: ET.Element,
    x1: float,
    y1: float,
    x2: float,
    y2: float,
    palette: ColorPalette | None,
    *,
    id_prefix: str,
    id_seq: list[int],
    defs: list[str],
) -> str:
    # Skip explicit no-line.
    if ln.find("a:noFill", NS) is not None:
        return ""

    stroke = resolve_stroke(
        # resolve_stroke expects a parent that contains <a:ln>; wrap so it
        # finds our tag's own children as the line spec.
        _make_ln_wrapper(ln),
        palette,
        id_prefix=id_prefix,
        id_seq=id_seq,
    )
    defs.extend(stroke.defs)
    attrs = stroke.attrs
    if not attrs.get("stroke"):
        return ""
    attr_str = "".join(f' {k}="{v}"' for k, v in attrs.items())
    return (
        f'<line x1="{fmt_num(x1)}" y1="{fmt_num(y1)}" '
        f'x2="{fmt_num(x2)}" y2="{fmt_num(y2)}"{attr_str}/>'
    )


def _make_ln_wrapper(ln: ET.Element) -> ET.Element:
    """resolve_stroke walks for ``parent.find('a:ln')``; tcPr borders ARE the
    <a:ln> already, so wrap them in a synthetic parent that points back at
    the original element under the expected tag.
    """
    wrapper = ET.Element(f"{{{NS['a']}}}wrapper")
    proxy = ET.SubElement(wrapper, f"{{{NS['a']}}}ln")
    # Carry attributes (e.g. w="...") and children (solidFill, prstDash, ...).
    for k, v in ln.attrib.items():
        proxy.set(k, v)
    for child in list(ln):
        proxy.append(child)
    return wrapper
