"""Native PowerPoint table conversion."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from xml.etree import ElementTree as ET
from .marker_attributes import native_import_source
from ..drawingml.context import ConvertContext, ShapeResult
from ..drawingml.theme_colors import ThemeColorSpec, color_node_xml
from ..drawingml.utils import (
_xml_escape,
detect_text_lang,
font_px_to_hpt,
text_has_rtl_characters,
text_uses_rtl,
)
from .chart_style import _font_face_xml
from .marker_common import (
TABLE_URI,
_bool_attr,
_bounds,
_clean_hex,
_compact_key,
_first_present,
_font_size_hpt,
_hex_or_none,
_normalized_fallback_text,
_number,
_powerpoint_emu,
_powerpoint_line_width_emu,
_visible_fallback_texts,
)
def _table_text_run(
text: str,
*,
color: str | None,
bold: bool | None,
font_size: int | None,
font_face: str | None,
language: str | None,
default_language: str | None,
theme_color_spec: ThemeColorSpec | None,
italic: bool | None = None,
underline: bool | None = None,
strike: bool | None = None,
alt_language: str | None = None,
exact_font_face: bool = False,
) -> str:
size_attr = f' sz="{font_size}"' if font_size is not None else ""
bold_attr = f' b="{_bool_attr(bold)}"' if bold is not None else ""
italic_attr = f' i="{_bool_attr(italic)}"' if italic is not None else ""
underline_attr = (
f' u="{"sng" if underline else "none"}"'
if underline is not None else ""
)
strike_attr = (
f' strike="{"sngStrike" if strike else "noStrike"}"'
if strike is not None else ""
)
resolved_language = language or detect_text_lang(text, default_language)
language_attr = f' lang="{_xml_escape(resolved_language)}"'
alt_language_attr = (
f' altLang="{_xml_escape(alt_language)}"' if alt_language else ""
)
color_xml = (
f'{color_node_xml(color, theme_color_spec, "text")}'
if color else ""
)
if exact_font_face and font_face:
escaped_face = _xml_escape(font_face)
font_xml = (
f''
f''
f''
)
else:
font_xml = _font_face_xml(font_face)
rtl_xml = '' if text_has_rtl_characters(text) else ''
space_attr = ' xml:space="preserve"' if text != text.strip() else ""
return (
f''
f'{color_xml}'
f'{font_xml}'
f'{rtl_xml}'
""
f"{_xml_escape(text)}"
)
def _table_paragraph_properties(
align: str,
*,
emit_align: bool,
text: str,
language: str | None,
) -> str:
"""Build table paragraph properties with project-aware direction."""
attrs = []
if emit_align:
attrs.append(f'algn="{align}"')
if text_uses_rtl(text, language):
attrs.append('rtl="1"')
suffix = f" {' '.join(attrs)}" if attrs else ''
return f''
def _cell_payload(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
return {"text": "" if value is None else str(value)}
_TABLE_CANONICAL_SPAN_KEYS = {
"col_span",
"row_span",
}
_TABLE_UNSUPPORTED_SPAN_KEYS = {
"colSpan",
"grid_span",
"gridSpan",
"hMerge",
"merge",
"merged",
"rowSpan",
"vMerge",
}
_TABLE_TOP_LEVEL_SPAN_KEYS = {
"merge_cells",
"merged_cells",
"merges",
"spans",
}
_TABLE_MAX_ROWS = 1000
_TABLE_MAX_COLUMNS = 1000
@dataclass(frozen=True)
class _TableMergeRegion:
row: int
col: int
row_span: int
col_span: int
@dataclass(frozen=True)
class _TableBorderSpec:
style: str
color: str | None = None
width: float | None = None
@dataclass(frozen=True)
class _TableRun:
text: str
bold: bool | None = None
italic: bool | None = None
underline: bool | None = None
strike: bool | None = None
color: str | None = None
font_size: int | None = None
font_family: str | None = None
lang: str | None = None
alt_lang: str | None = None
@dataclass(frozen=True)
class _TableParagraph:
text: str
align: str | None = None
runs: tuple[_TableRun, ...] | None = None
def _table_rows(payload: dict[str, Any]) -> list[list[Any]]:
columns = payload.get("columns") or []
rows = payload.get("rows") or []
if not isinstance(columns, list) or not isinstance(rows, list):
raise RuntimeError("Native PPTX table requires columns/rows lists")
for idx, row in enumerate(rows, start=1):
if not isinstance(row, list):
raise RuntimeError(f"Native PPTX table row {idx} must be a list")
table_rows = [list(columns)] if columns else []
table_rows.extend(list(row) for row in rows)
return table_rows
def _table_cell_paragraphs(
cell_data: dict[str, Any],
) -> tuple[_TableParagraph, ...] | None:
if "paragraphs" not in cell_data:
return None
if "text" in cell_data:
raise RuntimeError(
"Native PPTX table cell text and paragraphs are mutually exclusive"
)
raw_paragraphs = cell_data.get("paragraphs")
if not isinstance(raw_paragraphs, list) or not raw_paragraphs:
raise RuntimeError(
"Native PPTX table cell paragraphs must be a non-empty list"
)
paragraphs: list[_TableParagraph] = []
for idx, value in enumerate(raw_paragraphs, start=1):
if isinstance(value, str):
paragraphs.append(_TableParagraph(value))
continue
if not isinstance(value, dict):
raise RuntimeError(
f"Native PPTX table paragraph {idx} must be a string or object"
)
if set(value) - {"text", "runs", "align"}:
raise RuntimeError(
f"Native PPTX table paragraph {idx} accepts text/runs/align only"
)
has_text = "text" in value
has_runs = "runs" in value
if has_text == has_runs:
raise RuntimeError(
f"Native PPTX table paragraph {idx} requires exactly one of text/runs"
)
align = value.get("align")
if align is not None and align not in {"l", "ctr", "r"}:
raise RuntimeError(
f"Native PPTX table paragraph {idx} align must be l, ctr, or r"
)
if has_text:
text = value.get("text")
if not isinstance(text, str):
raise RuntimeError(
f"Native PPTX table paragraph {idx} text must be a string"
)
paragraphs.append(_TableParagraph(text, align))
continue
raw_runs = value.get("runs")
if not isinstance(raw_runs, list) or not raw_runs:
raise RuntimeError(
f"Native PPTX table paragraph {idx} runs must be a non-empty list"
)
runs = tuple(
_table_run(run, paragraph_idx=idx, run_idx=run_idx)
for run_idx, run in enumerate(raw_runs, start=1)
)
paragraphs.append(
_TableParagraph("".join(run.text for run in runs), align, runs)
)
return tuple(paragraphs)
def _table_run(
value: Any,
*,
paragraph_idx: int,
run_idx: int,
) -> _TableRun:
label = f"paragraph {paragraph_idx} run {run_idx}"
if not isinstance(value, dict) or "text" not in value:
raise RuntimeError(f"Native PPTX table {label} must be a text object")
allowed = {
"text", "bold", "italic", "underline", "strike", "color",
"font_size", "font_family", "lang", "alt_lang",
}
unknown = set(value) - allowed
if unknown:
fields = ", ".join(sorted(unknown))
raise RuntimeError(
f"Native PPTX table {label} contains unsupported field(s): {fields}"
)
text = value.get("text")
if not isinstance(text, str):
raise RuntimeError(f"Native PPTX table {label} text must be a string")
booleans: dict[str, bool | None] = {}
for field in ("bold", "italic", "underline", "strike"):
raw = value.get(field)
if raw is not None and not isinstance(raw, bool):
raise RuntimeError(
f"Native PPTX table {label} {field} must be a JSON boolean"
)
booleans[field] = raw
color: str | None = None
if value.get("color") is not None:
if not isinstance(value["color"], str):
raise RuntimeError(f"Native PPTX table {label} color must be a string")
color = _hex_or_none(value["color"])
if color is None:
raise RuntimeError(f"Native PPTX table {label} color is unsupported")
font_size: int | None = None
if value.get("font_size") is not None:
font_size_px = _number(value["font_size"], f"table {label} font_size")
if not 100 / 75 <= font_size_px <= 400000 / 75:
raise RuntimeError(
f"Native PPTX table {label} font_size is outside DrawingML range"
)
font_size = font_px_to_hpt(font_size_px)
if not 100 <= font_size <= 400000:
raise RuntimeError(
f"Native PPTX table {label} font_size is outside DrawingML range"
)
font_family: str | None = None
if value.get("font_family") is not None:
if not isinstance(value["font_family"], str):
raise RuntimeError(
f"Native PPTX table {label} font_family must be a string"
)
font_family = value["font_family"].strip()
if not font_family or "," in font_family:
raise RuntimeError(
f"Native PPTX table {label} font_family must be one typeface"
)
languages: dict[str, str | None] = {}
for field in ("lang", "alt_lang"):
raw = value.get(field)
if raw is None:
languages[field] = None
continue
if not isinstance(raw, str) or not raw.strip():
raise RuntimeError(
f"Native PPTX table {label} {field} must be a non-empty string"
)
languages[field] = raw.strip()
return _TableRun(
text=text,
bold=booleans["bold"],
italic=booleans["italic"],
underline=booleans["underline"],
strike=booleans["strike"],
color=color,
font_size=font_size,
font_family=font_family,
lang=languages["lang"],
alt_lang=languages["alt_lang"],
)
def _table_span_value(
cell_data: dict[str, Any],
key: str,
*,
row_idx: int,
col_idx: int,
) -> int:
value = cell_data.get(key, 1)
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise RuntimeError(
f"Native PPTX table cell R{row_idx}C{col_idx} {key} must be a "
"positive JSON integer"
)
return value
def _merge_covered_cell_is_blank(value: Any) -> bool:
if value is None or value == "":
return True
if not isinstance(value, dict):
return False
if any(key != "text" for key in value):
return False
text = value.get("text")
return text is None or text == ""
def _resolve_table_merge_layout(
payload: dict[str, Any],
table_rows: list[list[Any]],
col_count: int,
) -> dict[tuple[int, int], _TableMergeRegion]:
for key in _TABLE_TOP_LEVEL_SPAN_KEYS:
if key in payload:
raise RuntimeError(
f"Native PPTX table uses unsupported top-level merged-cell field: {key}"
)
owners: dict[tuple[int, int], _TableMergeRegion] = {}
for row_idx, row in enumerate(table_rows, start=1):
for col_idx, cell in enumerate(row, start=1):
if isinstance(cell, dict):
used_keys = sorted(
key for key in _TABLE_UNSUPPORTED_SPAN_KEYS if key in cell
)
if used_keys:
keys = ", ".join(used_keys)
raise RuntimeError(
f"Native PPTX table cell R{row_idx}C{col_idx} uses "
f"unsupported merged-cell field(s): {keys}; use row_span/col_span "
"on the merge anchor"
)
position = (row_idx - 1, col_idx - 1)
owner = owners.get(position)
if owner is not None:
if isinstance(cell, dict) and any(
key in cell for key in _TABLE_CANONICAL_SPAN_KEYS
):
raise RuntimeError(
f"Native PPTX table merge anchor R{row_idx}C{col_idx} overlaps "
f"merge rooted at R{owner.row + 1}C{owner.col + 1}"
)
if not _merge_covered_cell_is_blank(cell):
raise RuntimeError(
f"Native PPTX table merge-covered cell R{row_idx}C{col_idx} "
"must be blank"
)
continue
cell_data = _cell_payload(cell)
row_span = _table_span_value(
cell_data, "row_span", row_idx=row_idx, col_idx=col_idx,
)
col_span = _table_span_value(
cell_data, "col_span", row_idx=row_idx, col_idx=col_idx,
)
if row_span == 1 and col_span == 1:
continue
if (
row_idx - 1 + row_span > len(table_rows)
or col_idx - 1 + col_span > col_count
):
raise RuntimeError(
f"Native PPTX table merge rooted at R{row_idx}C{col_idx} exceeds "
f"the resolved {len(table_rows)}x{col_count} grid"
)
region = _TableMergeRegion(
row=row_idx - 1,
col=col_idx - 1,
row_span=row_span,
col_span=col_span,
)
for covered_row in range(region.row, region.row + region.row_span):
for covered_col in range(region.col, region.col + region.col_span):
covered_position = (covered_row, covered_col)
prior = owners.get(covered_position)
if prior is not None:
raise RuntimeError(
f"Native PPTX table merge rooted at R{row_idx}C{col_idx} "
f"overlaps merge rooted at R{prior.row + 1}C{prior.col + 1}"
)
owners[covered_position] = region
return owners
def _grid_is_strict(payload: dict[str, Any]) -> bool:
value = payload.get("strict_grid", payload.get("strictGrid"))
return _table_bool(value, "strict_grid", default=False)
def _table_bool(value: Any, field_name: str, *, default: bool) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if value in (0, 1):
return bool(value)
key = _compact_key(value)
if key in {"1", "on", "true", "yes"}:
return True
if key in {"0", "false", "no", "off"}:
return False
raise RuntimeError(f"Native PPTX table {field_name} must be a boolean")
def _table_header_rows(payload: dict[str, Any], row_count: int) -> int:
default = 1 if payload.get("columns") else 0
value = _number(payload.get("header_rows", default), "table header_rows")
if not value.is_integer():
raise RuntimeError("Native PPTX table header_rows must be an integer")
header_rows = int(value)
if not 0 <= header_rows <= row_count:
raise RuntimeError(
"Native PPTX table header_rows must be between zero and the resolved row count"
)
return header_rows
def _validate_table_lengths(payload: dict[str, Any], table_rows: list[list[Any]]) -> int:
if not table_rows:
raise RuntimeError("Native PPTX table requires at least one row")
col_count = max(len(row) for row in table_rows)
if col_count <= 0:
raise RuntimeError("Native PPTX table requires at least one column")
if len(table_rows) > _TABLE_MAX_ROWS or col_count > _TABLE_MAX_COLUMNS:
raise RuntimeError("Native PPTX table supports at most 1000 rows and columns")
if _grid_is_strict(payload) and any(len(row) != col_count for row in table_rows):
raise RuntimeError("Native PPTX table strict_grid requires every row to have the same length")
column_widths = payload.get("column_widths")
if column_widths is not None:
if not isinstance(column_widths, list) or len(column_widths) != col_count:
raise RuntimeError("Native PPTX table column_widths must match the resolved column count")
_table_weights(column_widths, "column_widths")
row_heights = payload.get("row_heights")
if row_heights is not None:
if not isinstance(row_heights, list) or len(row_heights) != len(table_rows):
raise RuntimeError("Native PPTX table row_heights must match the resolved row count")
_table_weights(row_heights, "row_heights")
return col_count
def _validate_table_cell_formatting(payload: dict[str, Any], table_rows: list[list[Any]]) -> None:
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
_table_bool(style.get("band_row"), "style.band_row", default=True)
if "borders" in style:
raise RuntimeError(
"Native PPTX table per-side borders are supported on cells only"
)
for row in table_rows:
for cell in row:
cell_data = _cell_payload(cell)
_table_cell_paragraphs(cell_data)
if "bold" in cell_data:
_table_bool(cell_data["bold"], "cell bold", default=False)
for side in ("left", "right", "top", "bottom"):
_table_padding_value(cell_data, style, side)
for border in _table_border_specs(cell_data, style).values():
if border is None or border.style == "none":
continue
assert border.width is not None
_powerpoint_line_width_emu(
border.width,
"table border_width",
)
_table_anchor(cell_data, style)
def _validate_table_payload(
payload: dict[str, Any],
) -> tuple[list[list[Any]], int, dict[tuple[int, int], _TableMergeRegion]]:
table_rows = _table_rows(payload)
col_count = _validate_table_lengths(payload, table_rows)
for row in table_rows:
row.extend([""] * (col_count - len(row)))
merge_layout = _resolve_table_merge_layout(payload, table_rows, col_count)
_table_header_rows(payload, len(table_rows))
_validate_table_cell_formatting(payload, table_rows)
return table_rows, col_count, merge_layout
def _native_table_metadata_texts(table_rows: list[list[Any]]) -> dict[str, int]:
counts: dict[str, int] = {}
for row in table_rows:
for cell in row:
cell_data = _cell_payload(cell)
paragraphs = _table_cell_paragraphs(cell_data)
texts = (
[paragraph.text for paragraph in paragraphs]
if paragraphs is not None
else [cell_data.get("text")]
)
for value in texts:
text = _normalized_fallback_text(value)
if text:
counts[text] = counts.get(text, 0) + 1
return counts
def _native_table_warnings(elem: ET.Element, table_rows: list[list[Any]]) -> list[str]:
fallback_texts = _visible_fallback_texts(elem)
if not fallback_texts:
return []
metadata_counts = _native_table_metadata_texts(table_rows)
missing: list[str] = []
seen_counts: dict[str, int] = {}
for text in fallback_texts:
seen_counts[text] = seen_counts.get(text, 0) + 1
if seen_counts[text] > metadata_counts.get(text, 0):
missing.append(text)
if not missing:
return []
sample = ", ".join(repr(text) for text in missing[:5])
suffix = "" if len(missing) <= 5 else f", and {len(missing) - 5} more"
return [
"Native PPTX table fallback text is missing from metadata columns/rows "
f"and will disappear with --native-charts-and-tables: {sample}{suffix}"
]
def _weighted_lengths(
total: int,
count: int,
weights: list[Any] | None,
*,
field_name: str,
) -> list[int]:
if total < count:
raise RuntimeError(
f"Native PPTX table {field_name} cannot fit {count} positive grid lengths"
)
if weights is None:
base, remainder = divmod(total, count)
return [base + (1 if idx < remainder else 0) for idx in range(count)]
numeric = _table_weights(weights, field_name)
largest = max(numeric)
normalized = [weight / largest for weight in numeric]
normalized_total = sum(normalized)
distributable = total - count
quotas = [distributable * weight / normalized_total for weight in normalized]
extras = [int(quota) for quota in quotas]
remainder = distributable - sum(extras)
if remainder < 0 or remainder > count:
raise RuntimeError(f"Native PPTX table {field_name} allocation overflowed")
order = sorted(
range(count),
key=lambda idx: (quotas[idx] - extras[idx], normalized[idx], -idx),
reverse=True,
)
for idx in order[:remainder]:
extras[idx] += 1
return [extra + 1 for extra in extras]
def _table_weights(weights: list[Any], field_name: str) -> list[float]:
numeric = [
_number(weight, f"{field_name}[{idx}]")
for idx, weight in enumerate(weights, start=1)
]
if any(weight < 0 for weight in numeric):
raise RuntimeError(f"Native PPTX table {field_name} values must be non-negative")
if max(numeric, default=0.0) <= 0:
raise RuntimeError(
f"Native PPTX table {field_name} values must sum to a positive number"
)
return numeric
def _table_padding_value(
cell_data: dict[str, Any],
style: dict[str, Any],
side: str,
) -> int | None:
side_keys = {
"left": ("left", "l", "padding_left", "paddingLeft"),
"right": ("right", "r", "padding_right", "paddingRight"),
"top": ("top", "t", "padding_top", "paddingTop"),
"bottom": ("bottom", "b", "padding_bottom", "paddingBottom"),
}
def from_source(source: dict[str, Any]) -> Any:
for key in side_keys[side]:
if key in source:
return source[key]
padding = source.get("padding", source.get("cell_padding"))
if isinstance(padding, dict):
for key in side_keys[side]:
if key in padding:
return padding[key]
elif padding is not None:
return padding
return None
value = from_source(cell_data)
if value is None:
value = from_source(style)
if value is None:
return None
pixels = max(_number(value, f"table {side} padding"), 0.0)
return _powerpoint_emu(pixels, f"table {side} padding")
def _table_padding_attrs(cell_data: dict[str, Any], style: dict[str, Any]) -> str:
attrs = []
for attr, side in (
("marL", "left"),
("marR", "right"),
("marT", "top"),
("marB", "bottom"),
):
value = _table_padding_value(cell_data, style, side)
if value is not None:
attrs.append(f'{attr}="{value}"')
return (" " + " ".join(attrs)) if attrs else ""
def _table_anchor(cell_data: dict[str, Any], style: dict[str, Any]) -> str:
raw = _first_present(
cell_data.get("valign"),
cell_data.get("vertical_align"),
style.get("valign"),
style.get("vertical_align"),
"middle",
)
aliases = {
"bottom": "b",
"b": "b",
"center": "ctr",
"ctr": "ctr",
"middle": "ctr",
"top": "t",
"t": "t",
}
anchor = aliases.get(_compact_key(raw))
if not anchor:
raise RuntimeError("Native PPTX table valign must be one of: top, middle, bottom")
return anchor
def _table_border_width(cell_data: dict[str, Any], style: dict[str, Any]) -> float:
width_raw = cell_data.get("border_width", cell_data.get("borderWidth", style.get("border_width")))
color_raw = cell_data.get("border_color", cell_data.get("borderColor", style.get("border_color")))
if width_raw is None and color_raw is None:
return 0.0
return _number(1 if width_raw is None else width_raw, "table border_width")
_TABLE_BORDER_SIDES = ("left", "right", "top", "bottom")
_TABLE_BORDER_TAGS = {
"left": "lnL",
"right": "lnR",
"top": "lnT",
"bottom": "lnB",
}
def _strict_table_border_color(value: Any, side: str) -> str:
raw = value if isinstance(value, str) else ""
if len(raw) != 7 or not raw.startswith("#"):
raise RuntimeError(
f"Native PPTX table {side} border color must be #RRGGBB"
)
try:
int(raw[1:], 16)
except ValueError as exc:
raise RuntimeError(
f"Native PPTX table {side} border color must be #RRGGBB"
) from exc
return raw[1:].upper()
def _table_border_override(value: Any, side: str) -> _TableBorderSpec:
if not isinstance(value, dict):
raise RuntimeError(
f"Native PPTX table {side} border must be an object"
)
border_style = value.get("style")
if border_style == "none":
if set(value) != {"style"}:
raise RuntimeError(
f"Native PPTX table {side} border style none accepts no other fields"
)
return _TableBorderSpec("none")
if border_style != "solid":
raise RuntimeError(
f"Native PPTX table {side} border style must be solid or none"
)
if set(value) != {"style", "color", "width"}:
raise RuntimeError(
f"Native PPTX table {side} solid border requires style/color/width only"
)
width = _number(value.get("width"), f"table {side} border width")
if width <= 0:
raise RuntimeError(
f"Native PPTX table {side} solid border width must be positive"
)
_powerpoint_line_width_emu(width, f"table {side} border width")
return _TableBorderSpec(
"solid",
color=_strict_table_border_color(value.get("color"), side),
width=width,
)
def _table_border_specs(
cell_data: dict[str, Any],
style: dict[str, Any],
) -> dict[str, _TableBorderSpec | None]:
raw_borders = cell_data.get("borders")
if raw_borders is None:
border_overrides: dict[str, Any] = {}
elif not isinstance(raw_borders, dict):
raise RuntimeError("Native PPTX table cell borders must be an object")
else:
unknown = sorted(set(raw_borders) - set(_TABLE_BORDER_SIDES))
if unknown:
raise RuntimeError(
"Native PPTX table cell borders use unsupported side(s): "
+ ", ".join(unknown)
)
border_overrides = raw_borders
legacy_width = _table_border_width(cell_data, style)
legacy_spec = (
_TableBorderSpec(
"solid",
color=_clean_hex(
cell_data.get(
"border_color",
cell_data.get("borderColor", style.get("border_color")),
),
"#D9DEE7",
),
width=legacy_width,
)
if legacy_width > 0
else None
)
return {
side: (
_table_border_override(border_overrides[side], side)
if side in border_overrides
else legacy_spec
)
for side in _TABLE_BORDER_SIDES
}
def _table_border_xml(
cell_data: dict[str, Any],
style: dict[str, Any],
theme_color_spec: ThemeColorSpec | None,
) -> str:
border_xml: list[str] = []
for side, border in _table_border_specs(cell_data, style).items():
if border is None:
continue
tag = _TABLE_BORDER_TAGS[side]
if border.style == "none":
border_xml.append(f'')
continue
assert border.color is not None and border.width is not None
line_width = _powerpoint_line_width_emu(
border.width, f"table {side} border width",
)
border_xml.append(
f''
f'{color_node_xml(border.color, theme_color_spec, "stroke")}'
''
''
f''
)
return "".join(border_xml)
def _table_merge_attrs(
region: _TableMergeRegion | None,
row_idx: int,
col_idx: int,
) -> str:
if region is None:
return ""
attrs: list[str] = []
if row_idx == region.row and region.row_span > 1:
attrs.append(f'rowSpan="{region.row_span}"')
if col_idx == region.col and region.col_span > 1:
attrs.append(f'gridSpan="{region.col_span}"')
if col_idx > region.col:
attrs.append('hMerge="1"')
if row_idx > region.row:
attrs.append('vMerge="1"')
return (" " + " ".join(attrs)) if attrs else ""
def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str, Any]) -> ShapeResult:
table_rows, col_count, merge_layout = _validate_table_payload(payload)
header_rows = _table_header_rows(payload, len(table_rows))
preserve_source_style = native_import_source(elem) == "pptx"
style = payload.get("style") if isinstance(payload.get("style"), dict) else {}
header_fill = _clean_hex(style.get("header_fill"), "#1F4E79")
header_text = _clean_hex(style.get("header_text"), "#FFFFFF")
body_fill = _clean_hex(style.get("body_fill"), "#FFFFFF")
body_text = _clean_hex(style.get("body_text"), "#1F2937")
band_fill = _clean_hex(style.get("band_fill"), "#F3F6FA")
font_face = str(style["font_family"]) if style.get("font_family") else None
body_font_size = _font_size_hpt(style.get("font_size"), 18)
band_rows_enabled = _table_bool(
style.get("band_row"),
"style.band_row",
default=True,
)
header_font_size = _font_size_hpt(
style.get("header_font_size", style.get("font_size")),
18,
)
off_x, off_y, ext_cx, ext_cy = _bounds(elem, payload, ctx)
column_widths = payload.get("column_widths")
grid_widths = _weighted_lengths(
ext_cx,
col_count,
column_widths if isinstance(column_widths, list) else None,
field_name="column_widths",
)
row_heights_raw = payload.get("row_heights")
row_heights = _weighted_lengths(
ext_cy,
len(table_rows),
row_heights_raw if isinstance(row_heights_raw, list) else None,
field_name="row_heights",
)
grid_xml = "".join(f'' for width in grid_widths)
rows_xml: list[str] = []
for row_idx, row in enumerate(table_rows):
is_header = row_idx < header_rows
cells_xml: list[str] = []
for col_idx, cell in enumerate(row):
merge_region = merge_layout.get((row_idx, col_idx))
merge_attrs = _table_merge_attrs(merge_region, row_idx, col_idx)
if merge_region is not None and (
row_idx != merge_region.row or col_idx != merge_region.col
):
cells_xml.append(
f''
''
''
''
)
continue
cell_data = _cell_payload(cell)
if preserve_source_style:
fill = (
_clean_hex(cell_data.get("fill"), "#FFFFFF")
if cell_data.get("fill") is not None else None
)
color = (
_clean_hex(cell_data.get("color"), "#000000")
if cell_data.get("color") is not None else None
)
align = str(cell_data.get("align") or "l")
else:
fill = _clean_hex(
cell_data.get("fill"),
header_fill if is_header else (
band_fill
if band_rows_enabled and row_idx % 2 == 0 and row_idx
else body_fill
),
)
color = _clean_hex(
cell_data.get("color"),
header_text if is_header else body_text,
)
align = str(cell_data.get("align") or ("ctr" if is_header else "l"))
if align not in {"l", "ctr", "r"}:
align = "l"
paragraphs = _table_cell_paragraphs(cell_data)
if preserve_source_style:
bold = (
_table_bool(cell_data["bold"], "cell bold", default=False)
if "bold" in cell_data else None
)
cell_font_size = (
_font_size_hpt(cell_data.get("font_size"), 18)
if "font_size" in cell_data else None
)
else:
bold = _table_bool(cell_data.get("bold"), "cell bold", default=is_header)
cell_font_size = (
_font_size_hpt(cell_data.get("font_size"), 18)
if "font_size" in cell_data
else body_font_size
)
if is_header and "font_size" not in cell_data:
cell_font_size = header_font_size
language = (
str(cell_data.get("lang") or style.get("lang") or "").strip()
or None
)
if paragraphs is None:
text = (
"" if cell_data.get("text") is None
else str(cell_data.get("text"))
)
default_language = language or ctx.primary_language
paragraph_props = _table_paragraph_properties(
align,
emit_align=align != "l",
text=text,
language=default_language,
)
text_run_xml = _table_text_run(
text,
color=color,
bold=bold,
font_size=cell_font_size,
font_face=font_face,
language=language,
default_language=ctx.primary_language,
theme_color_spec=ctx.theme_color_spec,
)
paragraphs_xml = (
f"{paragraph_props}{text_run_xml}"
)
else:
paragraph_parts: list[str] = []
for paragraph in paragraphs:
paragraph_align = paragraph.align or align
paragraph_text = (
paragraph.text
if paragraph.runs is None
else ''.join(run.text for run in paragraph.runs)
)
paragraph_props = _table_paragraph_properties(
paragraph_align,
emit_align=(
paragraph.align is not None
or paragraph_align != "l"
),
text=paragraph_text,
language=language or ctx.primary_language,
)
if paragraph.runs is None:
text_run_xml = _table_text_run(
paragraph.text,
color=color,
bold=bold,
font_size=cell_font_size,
font_face=font_face,
language=language,
default_language=ctx.primary_language,
theme_color_spec=ctx.theme_color_spec,
)
else:
text_run_xml = "".join(
_table_text_run(
run.text,
color=run.color or color,
bold=run.bold if run.bold is not None else bold,
font_size=(
run.font_size
if run.font_size is not None
else cell_font_size
),
font_face=run.font_family or font_face,
language=run.lang or language,
default_language=ctx.primary_language,
theme_color_spec=ctx.theme_color_spec,
italic=run.italic,
underline=run.underline,
strike=run.strike,
alt_language=run.alt_lang,
exact_font_face=run.font_family is not None,
)
for run in paragraph.runs
)
paragraph_parts.append(
f"{paragraph_props}{text_run_xml}"
)
paragraphs_xml = "".join(paragraph_parts)
anchor_keys = {"valign", "vertical_align"}
anchor_attr = ""
if not preserve_source_style or anchor_keys.intersection(cell_data) or anchor_keys.intersection(style):
anchor_attr = f' anchor="{_table_anchor(cell_data, style)}"'
tc_pr_attrs = f'{anchor_attr}{_table_padding_attrs(cell_data, style)}'
border_xml = _table_border_xml(
cell_data,
style,
ctx.theme_color_spec,
)
fill_xml = (
''
f'{color_node_xml(fill, ctx.theme_color_spec, "fill")}'
''
if fill else ""
)
cells_xml.append(
f""
""
f"{paragraphs_xml}"
""
f'{border_xml}{fill_xml}'
""
)
rows_xml.append(f'{"".join(cells_xml)}')
shape_id = ctx.next_id()
first_row = _bool_attr(header_rows > 0)
band_row = _bool_attr(band_rows_enabled)
table_style_id = style.get("table_style_id")
if table_style_id is None and not preserve_source_style:
table_style_id = "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"
table_style_xml = (
f'{_xml_escape(str(table_style_id))}'
if table_style_id else ""
)
name = _xml_escape(str(payload.get("name") or elem.get("id") or f"Native Table {shape_id}"))
xml = f'''
{table_style_xml}
{grid_xml}
{''.join(rows_xml)}
'''
return ShapeResult(xml=xml, bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy))