| 1 | """Native PowerPoint table conversion.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | from dataclasses import dataclass |
| 6 | from typing import Any |
| 7 | from xml.etree import ElementTree as ET |
| 8 | |
| 9 | from .marker_attributes import native_import_source |
| 10 | |
| 11 | from ..drawingml.context import ConvertContext, ShapeResult |
| 12 | from ..drawingml.theme_colors import ThemeColorSpec, color_node_xml |
| 13 | from ..drawingml.utils import ( |
| 14 | _xml_escape, |
| 15 | detect_text_lang, |
| 16 | font_px_to_hpt, |
| 17 | text_has_rtl_characters, |
| 18 | text_uses_rtl, |
| 19 | ) |
| 20 | from .chart_style import _font_face_xml |
| 21 | from .marker_common import ( |
| 22 | TABLE_URI, |
| 23 | _bool_attr, |
| 24 | _bounds, |
| 25 | _clean_hex, |
| 26 | _compact_key, |
| 27 | _first_present, |
| 28 | _font_size_hpt, |
| 29 | _hex_or_none, |
| 30 | _normalized_fallback_text, |
| 31 | _number, |
| 32 | _powerpoint_emu, |
| 33 | _powerpoint_line_width_emu, |
| 34 | _visible_fallback_texts, |
| 35 | ) |
| 36 | |
| 37 | |
| 38 | def _table_text_run( |
| 39 | text: str, |
| 40 | *, |
| 41 | color: str | None, |
| 42 | bold: bool | None, |
| 43 | font_size: int | None, |
| 44 | font_face: str | None, |
| 45 | language: str | None, |
| 46 | default_language: str | None, |
| 47 | theme_color_spec: ThemeColorSpec | None, |
| 48 | italic: bool | None = None, |
| 49 | underline: bool | None = None, |
| 50 | strike: bool | None = None, |
| 51 | alt_language: str | None = None, |
| 52 | exact_font_face: bool = False, |
| 53 | ) -> str: |
| 54 | size_attr = f' sz="{font_size}"' if font_size is not None else "" |
| 55 | bold_attr = f' b="{_bool_attr(bold)}"' if bold is not None else "" |
| 56 | italic_attr = f' i="{_bool_attr(italic)}"' if italic is not None else "" |
| 57 | underline_attr = ( |
| 58 | f' u="{"sng" if underline else "none"}"' |
| 59 | if underline is not None else "" |
| 60 | ) |
| 61 | strike_attr = ( |
| 62 | f' strike="{"sngStrike" if strike else "noStrike"}"' |
| 63 | if strike is not None else "" |
| 64 | ) |
| 65 | resolved_language = language or detect_text_lang(text, default_language) |
| 66 | language_attr = f' lang="{_xml_escape(resolved_language)}"' |
| 67 | alt_language_attr = ( |
| 68 | f' altLang="{_xml_escape(alt_language)}"' if alt_language else "" |
| 69 | ) |
| 70 | color_xml = ( |
| 71 | f'<a:solidFill>{color_node_xml(color, theme_color_spec, "text")}</a:solidFill>' |
| 72 | if color else "" |
| 73 | ) |
| 74 | if exact_font_face and font_face: |
| 75 | escaped_face = _xml_escape(font_face) |
| 76 | font_xml = ( |
| 77 | f'<a:latin typeface="{escaped_face}"/>' |
| 78 | f'<a:ea typeface="{escaped_face}"/>' |
| 79 | f'<a:cs typeface="{escaped_face}"/>' |
| 80 | ) |
| 81 | else: |
| 82 | font_xml = _font_face_xml(font_face) |
| 83 | rtl_xml = '<a:rtl val="1"/>' if text_has_rtl_characters(text) else '' |
| 84 | space_attr = ' xml:space="preserve"' if text != text.strip() else "" |
| 85 | return ( |
| 86 | f'<a:r><a:rPr{language_attr}{alt_language_attr}{size_attr}{bold_attr}' |
| 87 | f'{italic_attr}{underline_attr}{strike_attr}>' |
| 88 | f'{color_xml}' |
| 89 | f'{font_xml}' |
| 90 | f'{rtl_xml}' |
| 91 | "</a:rPr>" |
| 92 | f"<a:t{space_attr}>{_xml_escape(text)}</a:t></a:r>" |
| 93 | ) |
| 94 | |
| 95 | |
| 96 | def _table_paragraph_properties( |
| 97 | align: str, |
| 98 | *, |
| 99 | emit_align: bool, |
| 100 | text: str, |
| 101 | language: str | None, |
| 102 | ) -> str: |
| 103 | """Build table paragraph properties with project-aware direction.""" |
| 104 | attrs = [] |
| 105 | if emit_align: |
| 106 | attrs.append(f'algn="{align}"') |
| 107 | if text_uses_rtl(text, language): |
| 108 | attrs.append('rtl="1"') |
| 109 | suffix = f" {' '.join(attrs)}" if attrs else '' |
| 110 | return f'<a:pPr{suffix}/>' |
| 111 | |
| 112 | |
| 113 | def _cell_payload(value: Any) -> dict[str, Any]: |
| 114 | if isinstance(value, dict): |
| 115 | return value |
| 116 | return {"text": "" if value is None else str(value)} |
| 117 | |
| 118 | |
| 119 | _TABLE_CANONICAL_SPAN_KEYS = { |
| 120 | "col_span", |
| 121 | "row_span", |
| 122 | } |
| 123 | _TABLE_UNSUPPORTED_SPAN_KEYS = { |
| 124 | "colSpan", |
| 125 | "grid_span", |
| 126 | "gridSpan", |
| 127 | "hMerge", |
| 128 | "merge", |
| 129 | "merged", |
| 130 | "rowSpan", |
| 131 | "vMerge", |
| 132 | } |
| 133 | _TABLE_TOP_LEVEL_SPAN_KEYS = { |
| 134 | "merge_cells", |
| 135 | "merged_cells", |
| 136 | "merges", |
| 137 | "spans", |
| 138 | } |
| 139 | _TABLE_MAX_ROWS = 1000 |
| 140 | _TABLE_MAX_COLUMNS = 1000 |
| 141 | |
| 142 | |
| 143 | @dataclass(frozen=True) |
| 144 | class _TableMergeRegion: |
| 145 | row: int |
| 146 | col: int |
| 147 | row_span: int |
| 148 | col_span: int |
| 149 | |
| 150 | |
| 151 | @dataclass(frozen=True) |
| 152 | class _TableBorderSpec: |
| 153 | style: str |
| 154 | color: str | None = None |
| 155 | width: float | None = None |
| 156 | |
| 157 | |
| 158 | @dataclass(frozen=True) |
| 159 | class _TableRun: |
| 160 | text: str |
| 161 | bold: bool | None = None |
| 162 | italic: bool | None = None |
| 163 | underline: bool | None = None |
| 164 | strike: bool | None = None |
| 165 | color: str | None = None |
| 166 | font_size: int | None = None |
| 167 | font_family: str | None = None |
| 168 | lang: str | None = None |
| 169 | alt_lang: str | None = None |
| 170 | |
| 171 | |
| 172 | @dataclass(frozen=True) |
| 173 | class _TableParagraph: |
| 174 | text: str |
| 175 | align: str | None = None |
| 176 | runs: tuple[_TableRun, ...] | None = None |
| 177 | |
| 178 | |
| 179 | def _table_rows(payload: dict[str, Any]) -> list[list[Any]]: |
| 180 | columns = payload.get("columns") or [] |
| 181 | rows = payload.get("rows") or [] |
| 182 | if not isinstance(columns, list) or not isinstance(rows, list): |
| 183 | raise RuntimeError("Native PPTX table requires columns/rows lists") |
| 184 | for idx, row in enumerate(rows, start=1): |
| 185 | if not isinstance(row, list): |
| 186 | raise RuntimeError(f"Native PPTX table row {idx} must be a list") |
| 187 | |
| 188 | table_rows = [list(columns)] if columns else [] |
| 189 | table_rows.extend(list(row) for row in rows) |
| 190 | return table_rows |
| 191 | |
| 192 | |
| 193 | def _table_cell_paragraphs( |
| 194 | cell_data: dict[str, Any], |
| 195 | ) -> tuple[_TableParagraph, ...] | None: |
| 196 | if "paragraphs" not in cell_data: |
| 197 | return None |
| 198 | if "text" in cell_data: |
| 199 | raise RuntimeError( |
| 200 | "Native PPTX table cell text and paragraphs are mutually exclusive" |
| 201 | ) |
| 202 | raw_paragraphs = cell_data.get("paragraphs") |
| 203 | if not isinstance(raw_paragraphs, list) or not raw_paragraphs: |
| 204 | raise RuntimeError( |
| 205 | "Native PPTX table cell paragraphs must be a non-empty list" |
| 206 | ) |
| 207 | |
| 208 | paragraphs: list[_TableParagraph] = [] |
| 209 | for idx, value in enumerate(raw_paragraphs, start=1): |
| 210 | if isinstance(value, str): |
| 211 | paragraphs.append(_TableParagraph(value)) |
| 212 | continue |
| 213 | if not isinstance(value, dict): |
| 214 | raise RuntimeError( |
| 215 | f"Native PPTX table paragraph {idx} must be a string or object" |
| 216 | ) |
| 217 | if set(value) - {"text", "runs", "align"}: |
| 218 | raise RuntimeError( |
| 219 | f"Native PPTX table paragraph {idx} accepts text/runs/align only" |
| 220 | ) |
| 221 | has_text = "text" in value |
| 222 | has_runs = "runs" in value |
| 223 | if has_text == has_runs: |
| 224 | raise RuntimeError( |
| 225 | f"Native PPTX table paragraph {idx} requires exactly one of text/runs" |
| 226 | ) |
| 227 | align = value.get("align") |
| 228 | if align is not None and align not in {"l", "ctr", "r"}: |
| 229 | raise RuntimeError( |
| 230 | f"Native PPTX table paragraph {idx} align must be l, ctr, or r" |
| 231 | ) |
| 232 | if has_text: |
| 233 | text = value.get("text") |
| 234 | if not isinstance(text, str): |
| 235 | raise RuntimeError( |
| 236 | f"Native PPTX table paragraph {idx} text must be a string" |
| 237 | ) |
| 238 | paragraphs.append(_TableParagraph(text, align)) |
| 239 | continue |
| 240 | |
| 241 | raw_runs = value.get("runs") |
| 242 | if not isinstance(raw_runs, list) or not raw_runs: |
| 243 | raise RuntimeError( |
| 244 | f"Native PPTX table paragraph {idx} runs must be a non-empty list" |
| 245 | ) |
| 246 | runs = tuple( |
| 247 | _table_run(run, paragraph_idx=idx, run_idx=run_idx) |
| 248 | for run_idx, run in enumerate(raw_runs, start=1) |
| 249 | ) |
| 250 | paragraphs.append( |
| 251 | _TableParagraph("".join(run.text for run in runs), align, runs) |
| 252 | ) |
| 253 | return tuple(paragraphs) |
| 254 | |
| 255 | |
| 256 | def _table_run( |
| 257 | value: Any, |
| 258 | *, |
| 259 | paragraph_idx: int, |
| 260 | run_idx: int, |
| 261 | ) -> _TableRun: |
| 262 | label = f"paragraph {paragraph_idx} run {run_idx}" |
| 263 | if not isinstance(value, dict) or "text" not in value: |
| 264 | raise RuntimeError(f"Native PPTX table {label} must be a text object") |
| 265 | allowed = { |
| 266 | "text", "bold", "italic", "underline", "strike", "color", |
| 267 | "font_size", "font_family", "lang", "alt_lang", |
| 268 | } |
| 269 | unknown = set(value) - allowed |
| 270 | if unknown: |
| 271 | fields = ", ".join(sorted(unknown)) |
| 272 | raise RuntimeError( |
| 273 | f"Native PPTX table {label} contains unsupported field(s): {fields}" |
| 274 | ) |
| 275 | text = value.get("text") |
| 276 | if not isinstance(text, str): |
| 277 | raise RuntimeError(f"Native PPTX table {label} text must be a string") |
| 278 | |
| 279 | booleans: dict[str, bool | None] = {} |
| 280 | for field in ("bold", "italic", "underline", "strike"): |
| 281 | raw = value.get(field) |
| 282 | if raw is not None and not isinstance(raw, bool): |
| 283 | raise RuntimeError( |
| 284 | f"Native PPTX table {label} {field} must be a JSON boolean" |
| 285 | ) |
| 286 | booleans[field] = raw |
| 287 | |
| 288 | color: str | None = None |
| 289 | if value.get("color") is not None: |
| 290 | if not isinstance(value["color"], str): |
| 291 | raise RuntimeError(f"Native PPTX table {label} color must be a string") |
| 292 | color = _hex_or_none(value["color"]) |
| 293 | if color is None: |
| 294 | raise RuntimeError(f"Native PPTX table {label} color is unsupported") |
| 295 | |
| 296 | font_size: int | None = None |
| 297 | if value.get("font_size") is not None: |
| 298 | font_size_px = _number(value["font_size"], f"table {label} font_size") |
| 299 | if not 100 / 75 <= font_size_px <= 400000 / 75: |
| 300 | raise RuntimeError( |
| 301 | f"Native PPTX table {label} font_size is outside DrawingML range" |
| 302 | ) |
| 303 | font_size = font_px_to_hpt(font_size_px) |
| 304 | if not 100 <= font_size <= 400000: |
| 305 | raise RuntimeError( |
| 306 | f"Native PPTX table {label} font_size is outside DrawingML range" |
| 307 | ) |
| 308 | |
| 309 | font_family: str | None = None |
| 310 | if value.get("font_family") is not None: |
| 311 | if not isinstance(value["font_family"], str): |
| 312 | raise RuntimeError( |
| 313 | f"Native PPTX table {label} font_family must be a string" |
| 314 | ) |
| 315 | font_family = value["font_family"].strip() |
| 316 | if not font_family or "," in font_family: |
| 317 | raise RuntimeError( |
| 318 | f"Native PPTX table {label} font_family must be one typeface" |
| 319 | ) |
| 320 | |
| 321 | languages: dict[str, str | None] = {} |
| 322 | for field in ("lang", "alt_lang"): |
| 323 | raw = value.get(field) |
| 324 | if raw is None: |
| 325 | languages[field] = None |
| 326 | continue |
| 327 | if not isinstance(raw, str) or not raw.strip(): |
| 328 | raise RuntimeError( |
| 329 | f"Native PPTX table {label} {field} must be a non-empty string" |
| 330 | ) |
| 331 | languages[field] = raw.strip() |
| 332 | |
| 333 | return _TableRun( |
| 334 | text=text, |
| 335 | bold=booleans["bold"], |
| 336 | italic=booleans["italic"], |
| 337 | underline=booleans["underline"], |
| 338 | strike=booleans["strike"], |
| 339 | color=color, |
| 340 | font_size=font_size, |
| 341 | font_family=font_family, |
| 342 | lang=languages["lang"], |
| 343 | alt_lang=languages["alt_lang"], |
| 344 | ) |
| 345 | |
| 346 | |
| 347 | def _table_span_value( |
| 348 | cell_data: dict[str, Any], |
| 349 | key: str, |
| 350 | *, |
| 351 | row_idx: int, |
| 352 | col_idx: int, |
| 353 | ) -> int: |
| 354 | value = cell_data.get(key, 1) |
| 355 | if isinstance(value, bool) or not isinstance(value, int) or value <= 0: |
| 356 | raise RuntimeError( |
| 357 | f"Native PPTX table cell R{row_idx}C{col_idx} {key} must be a " |
| 358 | "positive JSON integer" |
| 359 | ) |
| 360 | return value |
| 361 | |
| 362 | |
| 363 | def _merge_covered_cell_is_blank(value: Any) -> bool: |
| 364 | if value is None or value == "": |
| 365 | return True |
| 366 | if not isinstance(value, dict): |
| 367 | return False |
| 368 | if any(key != "text" for key in value): |
| 369 | return False |
| 370 | text = value.get("text") |
| 371 | return text is None or text == "" |
| 372 | |
| 373 | |
| 374 | def _resolve_table_merge_layout( |
| 375 | payload: dict[str, Any], |
| 376 | table_rows: list[list[Any]], |
| 377 | col_count: int, |
| 378 | ) -> dict[tuple[int, int], _TableMergeRegion]: |
| 379 | for key in _TABLE_TOP_LEVEL_SPAN_KEYS: |
| 380 | if key in payload: |
| 381 | raise RuntimeError( |
| 382 | f"Native PPTX table uses unsupported top-level merged-cell field: {key}" |
| 383 | ) |
| 384 | |
| 385 | owners: dict[tuple[int, int], _TableMergeRegion] = {} |
| 386 | for row_idx, row in enumerate(table_rows, start=1): |
| 387 | for col_idx, cell in enumerate(row, start=1): |
| 388 | if isinstance(cell, dict): |
| 389 | used_keys = sorted( |
| 390 | key for key in _TABLE_UNSUPPORTED_SPAN_KEYS if key in cell |
| 391 | ) |
| 392 | if used_keys: |
| 393 | keys = ", ".join(used_keys) |
| 394 | raise RuntimeError( |
| 395 | f"Native PPTX table cell R{row_idx}C{col_idx} uses " |
| 396 | f"unsupported merged-cell field(s): {keys}; use row_span/col_span " |
| 397 | "on the merge anchor" |
| 398 | ) |
| 399 | |
| 400 | position = (row_idx - 1, col_idx - 1) |
| 401 | owner = owners.get(position) |
| 402 | if owner is not None: |
| 403 | if isinstance(cell, dict) and any( |
| 404 | key in cell for key in _TABLE_CANONICAL_SPAN_KEYS |
| 405 | ): |
| 406 | raise RuntimeError( |
| 407 | f"Native PPTX table merge anchor R{row_idx}C{col_idx} overlaps " |
| 408 | f"merge rooted at R{owner.row + 1}C{owner.col + 1}" |
| 409 | ) |
| 410 | if not _merge_covered_cell_is_blank(cell): |
| 411 | raise RuntimeError( |
| 412 | f"Native PPTX table merge-covered cell R{row_idx}C{col_idx} " |
| 413 | "must be blank" |
| 414 | ) |
| 415 | continue |
| 416 | |
| 417 | cell_data = _cell_payload(cell) |
| 418 | row_span = _table_span_value( |
| 419 | cell_data, "row_span", row_idx=row_idx, col_idx=col_idx, |
| 420 | ) |
| 421 | col_span = _table_span_value( |
| 422 | cell_data, "col_span", row_idx=row_idx, col_idx=col_idx, |
| 423 | ) |
| 424 | if row_span == 1 and col_span == 1: |
| 425 | continue |
| 426 | if ( |
| 427 | row_idx - 1 + row_span > len(table_rows) |
| 428 | or col_idx - 1 + col_span > col_count |
| 429 | ): |
| 430 | raise RuntimeError( |
| 431 | f"Native PPTX table merge rooted at R{row_idx}C{col_idx} exceeds " |
| 432 | f"the resolved {len(table_rows)}x{col_count} grid" |
| 433 | ) |
| 434 | |
| 435 | region = _TableMergeRegion( |
| 436 | row=row_idx - 1, |
| 437 | col=col_idx - 1, |
| 438 | row_span=row_span, |
| 439 | col_span=col_span, |
| 440 | ) |
| 441 | for covered_row in range(region.row, region.row + region.row_span): |
| 442 | for covered_col in range(region.col, region.col + region.col_span): |
| 443 | covered_position = (covered_row, covered_col) |
| 444 | prior = owners.get(covered_position) |
| 445 | if prior is not None: |
| 446 | raise RuntimeError( |
| 447 | f"Native PPTX table merge rooted at R{row_idx}C{col_idx} " |
| 448 | f"overlaps merge rooted at R{prior.row + 1}C{prior.col + 1}" |
| 449 | ) |
| 450 | owners[covered_position] = region |
| 451 | return owners |
| 452 | |
| 453 | |
| 454 | def _grid_is_strict(payload: dict[str, Any]) -> bool: |
| 455 | value = payload.get("strict_grid", payload.get("strictGrid")) |
| 456 | return _table_bool(value, "strict_grid", default=False) |
| 457 | |
| 458 | |
| 459 | def _table_bool(value: Any, field_name: str, *, default: bool) -> bool: |
| 460 | if value is None: |
| 461 | return default |
| 462 | if isinstance(value, bool): |
| 463 | return value |
| 464 | if value in (0, 1): |
| 465 | return bool(value) |
| 466 | key = _compact_key(value) |
| 467 | if key in {"1", "on", "true", "yes"}: |
| 468 | return True |
| 469 | if key in {"0", "false", "no", "off"}: |
| 470 | return False |
| 471 | raise RuntimeError(f"Native PPTX table {field_name} must be a boolean") |
| 472 | |
| 473 | |
| 474 | def _table_header_rows(payload: dict[str, Any], row_count: int) -> int: |
| 475 | default = 1 if payload.get("columns") else 0 |
| 476 | value = _number(payload.get("header_rows", default), "table header_rows") |
| 477 | if not value.is_integer(): |
| 478 | raise RuntimeError("Native PPTX table header_rows must be an integer") |
| 479 | header_rows = int(value) |
| 480 | if not 0 <= header_rows <= row_count: |
| 481 | raise RuntimeError( |
| 482 | "Native PPTX table header_rows must be between zero and the resolved row count" |
| 483 | ) |
| 484 | return header_rows |
| 485 | |
| 486 | |
| 487 | def _validate_table_lengths(payload: dict[str, Any], table_rows: list[list[Any]]) -> int: |
| 488 | if not table_rows: |
| 489 | raise RuntimeError("Native PPTX table requires at least one row") |
| 490 | col_count = max(len(row) for row in table_rows) |
| 491 | if col_count <= 0: |
| 492 | raise RuntimeError("Native PPTX table requires at least one column") |
| 493 | if len(table_rows) > _TABLE_MAX_ROWS or col_count > _TABLE_MAX_COLUMNS: |
| 494 | raise RuntimeError("Native PPTX table supports at most 1000 rows and columns") |
| 495 | if _grid_is_strict(payload) and any(len(row) != col_count for row in table_rows): |
| 496 | raise RuntimeError("Native PPTX table strict_grid requires every row to have the same length") |
| 497 | |
| 498 | column_widths = payload.get("column_widths") |
| 499 | if column_widths is not None: |
| 500 | if not isinstance(column_widths, list) or len(column_widths) != col_count: |
| 501 | raise RuntimeError("Native PPTX table column_widths must match the resolved column count") |
| 502 | _table_weights(column_widths, "column_widths") |
| 503 | |
| 504 | row_heights = payload.get("row_heights") |
| 505 | if row_heights is not None: |
| 506 | if not isinstance(row_heights, list) or len(row_heights) != len(table_rows): |
| 507 | raise RuntimeError("Native PPTX table row_heights must match the resolved row count") |
| 508 | _table_weights(row_heights, "row_heights") |
| 509 | |
| 510 | return col_count |
| 511 | |
| 512 | |
| 513 | def _validate_table_cell_formatting(payload: dict[str, Any], table_rows: list[list[Any]]) -> None: |
| 514 | style = payload.get("style") if isinstance(payload.get("style"), dict) else {} |
| 515 | _table_bool(style.get("band_row"), "style.band_row", default=True) |
| 516 | if "borders" in style: |
| 517 | raise RuntimeError( |
| 518 | "Native PPTX table per-side borders are supported on cells only" |
| 519 | ) |
| 520 | for row in table_rows: |
| 521 | for cell in row: |
| 522 | cell_data = _cell_payload(cell) |
| 523 | _table_cell_paragraphs(cell_data) |
| 524 | if "bold" in cell_data: |
| 525 | _table_bool(cell_data["bold"], "cell bold", default=False) |
| 526 | for side in ("left", "right", "top", "bottom"): |
| 527 | _table_padding_value(cell_data, style, side) |
| 528 | for border in _table_border_specs(cell_data, style).values(): |
| 529 | if border is None or border.style == "none": |
| 530 | continue |
| 531 | assert border.width is not None |
| 532 | _powerpoint_line_width_emu( |
| 533 | border.width, |
| 534 | "table border_width", |
| 535 | ) |
| 536 | _table_anchor(cell_data, style) |
| 537 | |
| 538 | |
| 539 | def _validate_table_payload( |
| 540 | payload: dict[str, Any], |
| 541 | ) -> tuple[list[list[Any]], int, dict[tuple[int, int], _TableMergeRegion]]: |
| 542 | table_rows = _table_rows(payload) |
| 543 | col_count = _validate_table_lengths(payload, table_rows) |
| 544 | for row in table_rows: |
| 545 | row.extend([""] * (col_count - len(row))) |
| 546 | merge_layout = _resolve_table_merge_layout(payload, table_rows, col_count) |
| 547 | _table_header_rows(payload, len(table_rows)) |
| 548 | _validate_table_cell_formatting(payload, table_rows) |
| 549 | return table_rows, col_count, merge_layout |
| 550 | |
| 551 | |
| 552 | def _native_table_metadata_texts(table_rows: list[list[Any]]) -> dict[str, int]: |
| 553 | counts: dict[str, int] = {} |
| 554 | for row in table_rows: |
| 555 | for cell in row: |
| 556 | cell_data = _cell_payload(cell) |
| 557 | paragraphs = _table_cell_paragraphs(cell_data) |
| 558 | texts = ( |
| 559 | [paragraph.text for paragraph in paragraphs] |
| 560 | if paragraphs is not None |
| 561 | else [cell_data.get("text")] |
| 562 | ) |
| 563 | for value in texts: |
| 564 | text = _normalized_fallback_text(value) |
| 565 | if text: |
| 566 | counts[text] = counts.get(text, 0) + 1 |
| 567 | return counts |
| 568 | |
| 569 | |
| 570 | def _native_table_warnings(elem: ET.Element, table_rows: list[list[Any]]) -> list[str]: |
| 571 | fallback_texts = _visible_fallback_texts(elem) |
| 572 | if not fallback_texts: |
| 573 | return [] |
| 574 | metadata_counts = _native_table_metadata_texts(table_rows) |
| 575 | missing: list[str] = [] |
| 576 | seen_counts: dict[str, int] = {} |
| 577 | for text in fallback_texts: |
| 578 | seen_counts[text] = seen_counts.get(text, 0) + 1 |
| 579 | if seen_counts[text] > metadata_counts.get(text, 0): |
| 580 | missing.append(text) |
| 581 | if not missing: |
| 582 | return [] |
| 583 | |
| 584 | sample = ", ".join(repr(text) for text in missing[:5]) |
| 585 | suffix = "" if len(missing) <= 5 else f", and {len(missing) - 5} more" |
| 586 | return [ |
| 587 | "Native PPTX table fallback text is missing from metadata columns/rows " |
| 588 | f"and will disappear with --native-charts-and-tables: {sample}{suffix}" |
| 589 | ] |
| 590 | |
| 591 | |
| 592 | def _weighted_lengths( |
| 593 | total: int, |
| 594 | count: int, |
| 595 | weights: list[Any] | None, |
| 596 | *, |
| 597 | field_name: str, |
| 598 | ) -> list[int]: |
| 599 | if total < count: |
| 600 | raise RuntimeError( |
| 601 | f"Native PPTX table {field_name} cannot fit {count} positive grid lengths" |
| 602 | ) |
| 603 | if weights is None: |
| 604 | base, remainder = divmod(total, count) |
| 605 | return [base + (1 if idx < remainder else 0) for idx in range(count)] |
| 606 | |
| 607 | numeric = _table_weights(weights, field_name) |
| 608 | largest = max(numeric) |
| 609 | normalized = [weight / largest for weight in numeric] |
| 610 | normalized_total = sum(normalized) |
| 611 | distributable = total - count |
| 612 | quotas = [distributable * weight / normalized_total for weight in normalized] |
| 613 | extras = [int(quota) for quota in quotas] |
| 614 | remainder = distributable - sum(extras) |
| 615 | if remainder < 0 or remainder > count: |
| 616 | raise RuntimeError(f"Native PPTX table {field_name} allocation overflowed") |
| 617 | order = sorted( |
| 618 | range(count), |
| 619 | key=lambda idx: (quotas[idx] - extras[idx], normalized[idx], -idx), |
| 620 | reverse=True, |
| 621 | ) |
| 622 | for idx in order[:remainder]: |
| 623 | extras[idx] += 1 |
| 624 | return [extra + 1 for extra in extras] |
| 625 | |
| 626 | |
| 627 | def _table_weights(weights: list[Any], field_name: str) -> list[float]: |
| 628 | numeric = [ |
| 629 | _number(weight, f"{field_name}[{idx}]") |
| 630 | for idx, weight in enumerate(weights, start=1) |
| 631 | ] |
| 632 | if any(weight < 0 for weight in numeric): |
| 633 | raise RuntimeError(f"Native PPTX table {field_name} values must be non-negative") |
| 634 | if max(numeric, default=0.0) <= 0: |
| 635 | raise RuntimeError( |
| 636 | f"Native PPTX table {field_name} values must sum to a positive number" |
| 637 | ) |
| 638 | return numeric |
| 639 | |
| 640 | |
| 641 | def _table_padding_value( |
| 642 | cell_data: dict[str, Any], |
| 643 | style: dict[str, Any], |
| 644 | side: str, |
| 645 | ) -> int | None: |
| 646 | side_keys = { |
| 647 | "left": ("left", "l", "padding_left", "paddingLeft"), |
| 648 | "right": ("right", "r", "padding_right", "paddingRight"), |
| 649 | "top": ("top", "t", "padding_top", "paddingTop"), |
| 650 | "bottom": ("bottom", "b", "padding_bottom", "paddingBottom"), |
| 651 | } |
| 652 | |
| 653 | def from_source(source: dict[str, Any]) -> Any: |
| 654 | for key in side_keys[side]: |
| 655 | if key in source: |
| 656 | return source[key] |
| 657 | padding = source.get("padding", source.get("cell_padding")) |
| 658 | if isinstance(padding, dict): |
| 659 | for key in side_keys[side]: |
| 660 | if key in padding: |
| 661 | return padding[key] |
| 662 | elif padding is not None: |
| 663 | return padding |
| 664 | return None |
| 665 | |
| 666 | value = from_source(cell_data) |
| 667 | if value is None: |
| 668 | value = from_source(style) |
| 669 | if value is None: |
| 670 | return None |
| 671 | pixels = max(_number(value, f"table {side} padding"), 0.0) |
| 672 | return _powerpoint_emu(pixels, f"table {side} padding") |
| 673 | |
| 674 | |
| 675 | def _table_padding_attrs(cell_data: dict[str, Any], style: dict[str, Any]) -> str: |
| 676 | attrs = [] |
| 677 | for attr, side in ( |
| 678 | ("marL", "left"), |
| 679 | ("marR", "right"), |
| 680 | ("marT", "top"), |
| 681 | ("marB", "bottom"), |
| 682 | ): |
| 683 | value = _table_padding_value(cell_data, style, side) |
| 684 | if value is not None: |
| 685 | attrs.append(f'{attr}="{value}"') |
| 686 | return (" " + " ".join(attrs)) if attrs else "" |
| 687 | |
| 688 | |
| 689 | def _table_anchor(cell_data: dict[str, Any], style: dict[str, Any]) -> str: |
| 690 | raw = _first_present( |
| 691 | cell_data.get("valign"), |
| 692 | cell_data.get("vertical_align"), |
| 693 | style.get("valign"), |
| 694 | style.get("vertical_align"), |
| 695 | "middle", |
| 696 | ) |
| 697 | aliases = { |
| 698 | "bottom": "b", |
| 699 | "b": "b", |
| 700 | "center": "ctr", |
| 701 | "ctr": "ctr", |
| 702 | "middle": "ctr", |
| 703 | "top": "t", |
| 704 | "t": "t", |
| 705 | } |
| 706 | anchor = aliases.get(_compact_key(raw)) |
| 707 | if not anchor: |
| 708 | raise RuntimeError("Native PPTX table valign must be one of: top, middle, bottom") |
| 709 | return anchor |
| 710 | |
| 711 | |
| 712 | def _table_border_width(cell_data: dict[str, Any], style: dict[str, Any]) -> float: |
| 713 | width_raw = cell_data.get("border_width", cell_data.get("borderWidth", style.get("border_width"))) |
| 714 | color_raw = cell_data.get("border_color", cell_data.get("borderColor", style.get("border_color"))) |
| 715 | if width_raw is None and color_raw is None: |
| 716 | return 0.0 |
| 717 | return _number(1 if width_raw is None else width_raw, "table border_width") |
| 718 | |
| 719 | |
| 720 | _TABLE_BORDER_SIDES = ("left", "right", "top", "bottom") |
| 721 | _TABLE_BORDER_TAGS = { |
| 722 | "left": "lnL", |
| 723 | "right": "lnR", |
| 724 | "top": "lnT", |
| 725 | "bottom": "lnB", |
| 726 | } |
| 727 | |
| 728 | |
| 729 | def _strict_table_border_color(value: Any, side: str) -> str: |
| 730 | raw = value if isinstance(value, str) else "" |
| 731 | if len(raw) != 7 or not raw.startswith("#"): |
| 732 | raise RuntimeError( |
| 733 | f"Native PPTX table {side} border color must be #RRGGBB" |
| 734 | ) |
| 735 | try: |
| 736 | int(raw[1:], 16) |
| 737 | except ValueError as exc: |
| 738 | raise RuntimeError( |
| 739 | f"Native PPTX table {side} border color must be #RRGGBB" |
| 740 | ) from exc |
| 741 | return raw[1:].upper() |
| 742 | |
| 743 | |
| 744 | def _table_border_override(value: Any, side: str) -> _TableBorderSpec: |
| 745 | if not isinstance(value, dict): |
| 746 | raise RuntimeError( |
| 747 | f"Native PPTX table {side} border must be an object" |
| 748 | ) |
| 749 | border_style = value.get("style") |
| 750 | if border_style == "none": |
| 751 | if set(value) != {"style"}: |
| 752 | raise RuntimeError( |
| 753 | f"Native PPTX table {side} border style none accepts no other fields" |
| 754 | ) |
| 755 | return _TableBorderSpec("none") |
| 756 | if border_style != "solid": |
| 757 | raise RuntimeError( |
| 758 | f"Native PPTX table {side} border style must be solid or none" |
| 759 | ) |
| 760 | if set(value) != {"style", "color", "width"}: |
| 761 | raise RuntimeError( |
| 762 | f"Native PPTX table {side} solid border requires style/color/width only" |
| 763 | ) |
| 764 | width = _number(value.get("width"), f"table {side} border width") |
| 765 | if width <= 0: |
| 766 | raise RuntimeError( |
| 767 | f"Native PPTX table {side} solid border width must be positive" |
| 768 | ) |
| 769 | _powerpoint_line_width_emu(width, f"table {side} border width") |
| 770 | return _TableBorderSpec( |
| 771 | "solid", |
| 772 | color=_strict_table_border_color(value.get("color"), side), |
| 773 | width=width, |
| 774 | ) |
| 775 | |
| 776 | |
| 777 | def _table_border_specs( |
| 778 | cell_data: dict[str, Any], |
| 779 | style: dict[str, Any], |
| 780 | ) -> dict[str, _TableBorderSpec | None]: |
| 781 | raw_borders = cell_data.get("borders") |
| 782 | if raw_borders is None: |
| 783 | border_overrides: dict[str, Any] = {} |
| 784 | elif not isinstance(raw_borders, dict): |
| 785 | raise RuntimeError("Native PPTX table cell borders must be an object") |
| 786 | else: |
| 787 | unknown = sorted(set(raw_borders) - set(_TABLE_BORDER_SIDES)) |
| 788 | if unknown: |
| 789 | raise RuntimeError( |
| 790 | "Native PPTX table cell borders use unsupported side(s): " |
| 791 | + ", ".join(unknown) |
| 792 | ) |
| 793 | border_overrides = raw_borders |
| 794 | |
| 795 | legacy_width = _table_border_width(cell_data, style) |
| 796 | legacy_spec = ( |
| 797 | _TableBorderSpec( |
| 798 | "solid", |
| 799 | color=_clean_hex( |
| 800 | cell_data.get( |
| 801 | "border_color", |
| 802 | cell_data.get("borderColor", style.get("border_color")), |
| 803 | ), |
| 804 | "#D9DEE7", |
| 805 | ), |
| 806 | width=legacy_width, |
| 807 | ) |
| 808 | if legacy_width > 0 |
| 809 | else None |
| 810 | ) |
| 811 | return { |
| 812 | side: ( |
| 813 | _table_border_override(border_overrides[side], side) |
| 814 | if side in border_overrides |
| 815 | else legacy_spec |
| 816 | ) |
| 817 | for side in _TABLE_BORDER_SIDES |
| 818 | } |
| 819 | |
| 820 | |
| 821 | def _table_border_xml( |
| 822 | cell_data: dict[str, Any], |
| 823 | style: dict[str, Any], |
| 824 | theme_color_spec: ThemeColorSpec | None, |
| 825 | ) -> str: |
| 826 | border_xml: list[str] = [] |
| 827 | for side, border in _table_border_specs(cell_data, style).items(): |
| 828 | if border is None: |
| 829 | continue |
| 830 | tag = _TABLE_BORDER_TAGS[side] |
| 831 | if border.style == "none": |
| 832 | border_xml.append(f'<a:{tag}><a:noFill/></a:{tag}>') |
| 833 | continue |
| 834 | assert border.color is not None and border.width is not None |
| 835 | line_width = _powerpoint_line_width_emu( |
| 836 | border.width, f"table {side} border width", |
| 837 | ) |
| 838 | border_xml.append( |
| 839 | f'<a:{tag} w="{line_width}">' |
| 840 | f'<a:solidFill>{color_node_xml(border.color, theme_color_spec, "stroke")}' |
| 841 | '</a:solidFill>' |
| 842 | '<a:prstDash val="solid"/>' |
| 843 | f'</a:{tag}>' |
| 844 | ) |
| 845 | return "".join(border_xml) |
| 846 | |
| 847 | |
| 848 | def _table_merge_attrs( |
| 849 | region: _TableMergeRegion | None, |
| 850 | row_idx: int, |
| 851 | col_idx: int, |
| 852 | ) -> str: |
| 853 | if region is None: |
| 854 | return "" |
| 855 | attrs: list[str] = [] |
| 856 | if row_idx == region.row and region.row_span > 1: |
| 857 | attrs.append(f'rowSpan="{region.row_span}"') |
| 858 | if col_idx == region.col and region.col_span > 1: |
| 859 | attrs.append(f'gridSpan="{region.col_span}"') |
| 860 | if col_idx > region.col: |
| 861 | attrs.append('hMerge="1"') |
| 862 | if row_idx > region.row: |
| 863 | attrs.append('vMerge="1"') |
| 864 | return (" " + " ".join(attrs)) if attrs else "" |
| 865 | |
| 866 | |
| 867 | def _build_native_table(elem: ET.Element, ctx: ConvertContext, payload: dict[str, Any]) -> ShapeResult: |
| 868 | table_rows, col_count, merge_layout = _validate_table_payload(payload) |
| 869 | header_rows = _table_header_rows(payload, len(table_rows)) |
| 870 | preserve_source_style = native_import_source(elem) == "pptx" |
| 871 | |
| 872 | style = payload.get("style") if isinstance(payload.get("style"), dict) else {} |
| 873 | header_fill = _clean_hex(style.get("header_fill"), "#1F4E79") |
| 874 | header_text = _clean_hex(style.get("header_text"), "#FFFFFF") |
| 875 | body_fill = _clean_hex(style.get("body_fill"), "#FFFFFF") |
| 876 | body_text = _clean_hex(style.get("body_text"), "#1F2937") |
| 877 | band_fill = _clean_hex(style.get("band_fill"), "#F3F6FA") |
| 878 | font_face = str(style["font_family"]) if style.get("font_family") else None |
| 879 | body_font_size = _font_size_hpt(style.get("font_size"), 18) |
| 880 | band_rows_enabled = _table_bool( |
| 881 | style.get("band_row"), |
| 882 | "style.band_row", |
| 883 | default=True, |
| 884 | ) |
| 885 | header_font_size = _font_size_hpt( |
| 886 | style.get("header_font_size", style.get("font_size")), |
| 887 | 18, |
| 888 | ) |
| 889 | |
| 890 | off_x, off_y, ext_cx, ext_cy = _bounds(elem, payload, ctx) |
| 891 | |
| 892 | column_widths = payload.get("column_widths") |
| 893 | grid_widths = _weighted_lengths( |
| 894 | ext_cx, |
| 895 | col_count, |
| 896 | column_widths if isinstance(column_widths, list) else None, |
| 897 | field_name="column_widths", |
| 898 | ) |
| 899 | row_heights_raw = payload.get("row_heights") |
| 900 | row_heights = _weighted_lengths( |
| 901 | ext_cy, |
| 902 | len(table_rows), |
| 903 | row_heights_raw if isinstance(row_heights_raw, list) else None, |
| 904 | field_name="row_heights", |
| 905 | ) |
| 906 | |
| 907 | grid_xml = "".join(f'<a:gridCol w="{width}"/>' for width in grid_widths) |
| 908 | rows_xml: list[str] = [] |
| 909 | for row_idx, row in enumerate(table_rows): |
| 910 | is_header = row_idx < header_rows |
| 911 | cells_xml: list[str] = [] |
| 912 | for col_idx, cell in enumerate(row): |
| 913 | merge_region = merge_layout.get((row_idx, col_idx)) |
| 914 | merge_attrs = _table_merge_attrs(merge_region, row_idx, col_idx) |
| 915 | if merge_region is not None and ( |
| 916 | row_idx != merge_region.row or col_idx != merge_region.col |
| 917 | ): |
| 918 | cells_xml.append( |
| 919 | f'<a:tc{merge_attrs}>' |
| 920 | '<a:txBody><a:bodyPr/><a:lstStyle/><a:p/></a:txBody>' |
| 921 | '<a:tcPr/>' |
| 922 | '</a:tc>' |
| 923 | ) |
| 924 | continue |
| 925 | |
| 926 | cell_data = _cell_payload(cell) |
| 927 | if preserve_source_style: |
| 928 | fill = ( |
| 929 | _clean_hex(cell_data.get("fill"), "#FFFFFF") |
| 930 | if cell_data.get("fill") is not None else None |
| 931 | ) |
| 932 | color = ( |
| 933 | _clean_hex(cell_data.get("color"), "#000000") |
| 934 | if cell_data.get("color") is not None else None |
| 935 | ) |
| 936 | align = str(cell_data.get("align") or "l") |
| 937 | else: |
| 938 | fill = _clean_hex( |
| 939 | cell_data.get("fill"), |
| 940 | header_fill if is_header else ( |
| 941 | band_fill |
| 942 | if band_rows_enabled and row_idx % 2 == 0 and row_idx |
| 943 | else body_fill |
| 944 | ), |
| 945 | ) |
| 946 | color = _clean_hex( |
| 947 | cell_data.get("color"), |
| 948 | header_text if is_header else body_text, |
| 949 | ) |
| 950 | align = str(cell_data.get("align") or ("ctr" if is_header else "l")) |
| 951 | if align not in {"l", "ctr", "r"}: |
| 952 | align = "l" |
| 953 | paragraphs = _table_cell_paragraphs(cell_data) |
| 954 | if preserve_source_style: |
| 955 | bold = ( |
| 956 | _table_bool(cell_data["bold"], "cell bold", default=False) |
| 957 | if "bold" in cell_data else None |
| 958 | ) |
| 959 | cell_font_size = ( |
| 960 | _font_size_hpt(cell_data.get("font_size"), 18) |
| 961 | if "font_size" in cell_data else None |
| 962 | ) |
| 963 | else: |
| 964 | bold = _table_bool(cell_data.get("bold"), "cell bold", default=is_header) |
| 965 | cell_font_size = ( |
| 966 | _font_size_hpt(cell_data.get("font_size"), 18) |
| 967 | if "font_size" in cell_data |
| 968 | else body_font_size |
| 969 | ) |
| 970 | if is_header and "font_size" not in cell_data: |
| 971 | cell_font_size = header_font_size |
| 972 | language = ( |
| 973 | str(cell_data.get("lang") or style.get("lang") or "").strip() |
| 974 | or None |
| 975 | ) |
| 976 | if paragraphs is None: |
| 977 | text = ( |
| 978 | "" if cell_data.get("text") is None |
| 979 | else str(cell_data.get("text")) |
| 980 | ) |
| 981 | default_language = language or ctx.primary_language |
| 982 | paragraph_props = _table_paragraph_properties( |
| 983 | align, |
| 984 | emit_align=align != "l", |
| 985 | text=text, |
| 986 | language=default_language, |
| 987 | ) |
| 988 | text_run_xml = _table_text_run( |
| 989 | text, |
| 990 | color=color, |
| 991 | bold=bold, |
| 992 | font_size=cell_font_size, |
| 993 | font_face=font_face, |
| 994 | language=language, |
| 995 | default_language=ctx.primary_language, |
| 996 | theme_color_spec=ctx.theme_color_spec, |
| 997 | ) |
| 998 | paragraphs_xml = ( |
| 999 | f"<a:p>{paragraph_props}{text_run_xml}</a:p>" |
| 1000 | ) |
| 1001 | else: |
| 1002 | paragraph_parts: list[str] = [] |
| 1003 | for paragraph in paragraphs: |
| 1004 | paragraph_align = paragraph.align or align |
| 1005 | paragraph_text = ( |
| 1006 | paragraph.text |
| 1007 | if paragraph.runs is None |
| 1008 | else ''.join(run.text for run in paragraph.runs) |
| 1009 | ) |
| 1010 | paragraph_props = _table_paragraph_properties( |
| 1011 | paragraph_align, |
| 1012 | emit_align=( |
| 1013 | paragraph.align is not None |
| 1014 | or paragraph_align != "l" |
| 1015 | ), |
| 1016 | text=paragraph_text, |
| 1017 | language=language or ctx.primary_language, |
| 1018 | ) |
| 1019 | if paragraph.runs is None: |
| 1020 | text_run_xml = _table_text_run( |
| 1021 | paragraph.text, |
| 1022 | color=color, |
| 1023 | bold=bold, |
| 1024 | font_size=cell_font_size, |
| 1025 | font_face=font_face, |
| 1026 | language=language, |
| 1027 | default_language=ctx.primary_language, |
| 1028 | theme_color_spec=ctx.theme_color_spec, |
| 1029 | ) |
| 1030 | else: |
| 1031 | text_run_xml = "".join( |
| 1032 | _table_text_run( |
| 1033 | run.text, |
| 1034 | color=run.color or color, |
| 1035 | bold=run.bold if run.bold is not None else bold, |
| 1036 | font_size=( |
| 1037 | run.font_size |
| 1038 | if run.font_size is not None |
| 1039 | else cell_font_size |
| 1040 | ), |
| 1041 | font_face=run.font_family or font_face, |
| 1042 | language=run.lang or language, |
| 1043 | default_language=ctx.primary_language, |
| 1044 | theme_color_spec=ctx.theme_color_spec, |
| 1045 | italic=run.italic, |
| 1046 | underline=run.underline, |
| 1047 | strike=run.strike, |
| 1048 | alt_language=run.alt_lang, |
| 1049 | exact_font_face=run.font_family is not None, |
| 1050 | ) |
| 1051 | for run in paragraph.runs |
| 1052 | ) |
| 1053 | paragraph_parts.append( |
| 1054 | f"<a:p>{paragraph_props}{text_run_xml}</a:p>" |
| 1055 | ) |
| 1056 | paragraphs_xml = "".join(paragraph_parts) |
| 1057 | anchor_keys = {"valign", "vertical_align"} |
| 1058 | anchor_attr = "" |
| 1059 | if not preserve_source_style or anchor_keys.intersection(cell_data) or anchor_keys.intersection(style): |
| 1060 | anchor_attr = f' anchor="{_table_anchor(cell_data, style)}"' |
| 1061 | tc_pr_attrs = f'{anchor_attr}{_table_padding_attrs(cell_data, style)}' |
| 1062 | border_xml = _table_border_xml( |
| 1063 | cell_data, |
| 1064 | style, |
| 1065 | ctx.theme_color_spec, |
| 1066 | ) |
| 1067 | fill_xml = ( |
| 1068 | '<a:solidFill>' |
| 1069 | f'{color_node_xml(fill, ctx.theme_color_spec, "fill")}' |
| 1070 | '</a:solidFill>' |
| 1071 | if fill else "" |
| 1072 | ) |
| 1073 | cells_xml.append( |
| 1074 | f"<a:tc{merge_attrs}>" |
| 1075 | "<a:txBody><a:bodyPr/><a:lstStyle/>" |
| 1076 | f"{paragraphs_xml}" |
| 1077 | "</a:txBody>" |
| 1078 | f'<a:tcPr{tc_pr_attrs}>{border_xml}{fill_xml}</a:tcPr>' |
| 1079 | "</a:tc>" |
| 1080 | ) |
| 1081 | rows_xml.append(f'<a:tr h="{row_heights[row_idx]}">{"".join(cells_xml)}</a:tr>') |
| 1082 | |
| 1083 | shape_id = ctx.next_id() |
| 1084 | first_row = _bool_attr(header_rows > 0) |
| 1085 | band_row = _bool_attr(band_rows_enabled) |
| 1086 | table_style_id = style.get("table_style_id") |
| 1087 | if table_style_id is None and not preserve_source_style: |
| 1088 | table_style_id = "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}" |
| 1089 | table_style_xml = ( |
| 1090 | f'<a:tableStyleId>{_xml_escape(str(table_style_id))}</a:tableStyleId>' |
| 1091 | if table_style_id else "" |
| 1092 | ) |
| 1093 | name = _xml_escape(str(payload.get("name") or elem.get("id") or f"Native Table {shape_id}")) |
| 1094 | xml = f'''<p:graphicFrame> |
| 1095 | <p:nvGraphicFramePr> |
| 1096 | <p:cNvPr id="{shape_id}" name="{name}"/> |
| 1097 | <p:cNvGraphicFramePr><a:graphicFrameLocks noGrp="1"/></p:cNvGraphicFramePr> |
| 1098 | <p:nvPr/> |
| 1099 | </p:nvGraphicFramePr> |
| 1100 | <p:xfrm><a:off x="{off_x}" y="{off_y}"/><a:ext cx="{ext_cx}" cy="{ext_cy}"/></p:xfrm> |
| 1101 | <a:graphic> |
| 1102 | <a:graphicData uri="{TABLE_URI}"> |
| 1103 | <a:tbl> |
| 1104 | <a:tblPr firstRow="{first_row}" bandRow="{band_row}"> |
| 1105 | {table_style_xml} |
| 1106 | </a:tblPr> |
| 1107 | <a:tblGrid>{grid_xml}</a:tblGrid> |
| 1108 | {''.join(rows_xml)} |
| 1109 | </a:tbl> |
| 1110 | </a:graphicData> |
| 1111 | </a:graphic> |
| 1112 | </p:graphicFrame>''' |
| 1113 | return ShapeResult(xml=xml, bounds_emu=(off_x, off_y, off_x + ext_cx, off_y + ext_cy)) |
| 1114 |