返回 ppt-master
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
1114 lines PYTHON