返回 ppt-master
tbl_to_svg.py
根目录 / skills / ppt-master / scripts / pptx_to_svg / tbl_to_svg.py
1 """Convert a DrawingML <a:tbl> into SVG.
2
3 Tables in PowerPoint are stored under <p:graphicFrame> with
4 graphicData uri="...drawingml/2006/table" wrapping a single <a:tbl>:
5
6 <p:graphicFrame>
7 <p:xfrm>...</p:xfrm>
8 <a:graphic><a:graphicData uri="...table">
9 <a:tbl>
10 <a:tblPr/>
11 <a:tblGrid>
12 <a:gridCol w="..."/>...
13 </a:tblGrid>
14 <a:tr h="...">
15 <a:tc [gridSpan=N] [rowSpan=N] [hMerge=1] [vMerge=1]>
16 <a:txBody>...</a:txBody>
17 <a:tcPr>
18 <a:lnL/><a:lnR/><a:lnT/><a:lnB/>
19 <a:solidFill/>... or <a:gradFill/> ...
20 </a:tcPr>
21 </a:tc>
22 </a:tr>
23 </a:tbl>
24 </a:graphicData></a:graphic>
25 </p:graphicFrame>
26
27 The graphicFrame's <p:xfrm> gives the table's slide-space position and total
28 size; <a:tblGrid> + <a:tr> heights distribute that size across columns/rows.
29
30 Cell painting order:
31 1. background fill (rect at cell box)
32 2. text body (re-uses convert_txbody)
33 3. cell borders (lnT / lnR / lnB / lnL — stroked as separate <line>s so
34 neighbouring cells with different border styles render correctly)
35 """
36
37 from __future__ import annotations
38
39 import copy
40 import math
41 from dataclasses import dataclass
42 from typing import Any
43 from xml.etree import ElementTree as ET
44
45 from pptx_effects import txbody_has_run_effects
46
47 from .color_resolver import ColorPalette, find_color_elem, resolve_color
48 from .emu_units import (
49 NS,
50 Xfrm,
51 emu_to_px,
52 fmt_num,
53 hundredths_pt_to_px,
54 ooxml_bool,
55 )
56 from .fill_to_svg import FillResult, resolve_fill
57 from .ln_to_svg import resolve_stroke
58 from .txbody_to_svg import _resolve_theme_typeface, convert_txbody
59
60
61 BUILTIN_MEDIUM_STYLE_2_ACCENT_1 = "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"
62 _POWERPOINT_COORD_MIN = -(2**31)
63 _POWERPOINT_COORD_MAX = 2**31 - 1
64
65 _BUILTIN_MEDIUM_STYLE_2_ACCENT_1_XML = ET.fromstring(
66 f'''<a:tblStyle xmlns:a="{NS["a"]}"
67 styleId="{BUILTIN_MEDIUM_STYLE_2_ACCENT_1}">
68 <a:wholeTbl>
69 <a:tcTxStyle>
70 <a:fontRef idx="minor"><a:prstClr val="black"/></a:fontRef>
71 <a:schemeClr val="dk1"/>
72 </a:tcTxStyle>
73 <a:tcStyle>
74 <a:tcBdr>
75 <a:left><a:ln w="12700"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill></a:ln></a:left>
76 <a:right><a:ln w="12700"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill></a:ln></a:right>
77 <a:top><a:ln w="12700"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill></a:ln></a:top>
78 <a:bottom><a:ln w="12700"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill></a:ln></a:bottom>
79 <a:insideH><a:ln w="12700"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill></a:ln></a:insideH>
80 <a:insideV><a:ln w="12700"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill></a:ln></a:insideV>
81 </a:tcBdr>
82 <a:fill><a:solidFill><a:schemeClr val="accent1"><a:tint val="20000"/></a:schemeClr></a:solidFill></a:fill>
83 </a:tcStyle>
84 </a:wholeTbl>
85 <a:band1H>
86 <a:tcStyle><a:fill><a:solidFill><a:schemeClr val="accent1"><a:tint val="40000"/></a:schemeClr></a:solidFill></a:fill></a:tcStyle>
87 </a:band1H>
88 <a:band2H><a:tcStyle/></a:band2H>
89 <a:firstRow>
90 <a:tcTxStyle b="on">
91 <a:fontRef idx="minor"><a:prstClr val="black"/></a:fontRef>
92 <a:schemeClr val="lt1"/>
93 </a:tcTxStyle>
94 <a:tcStyle>
95 <a:tcBdr><a:bottom><a:ln w="38100"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill></a:ln></a:bottom></a:tcBdr>
96 <a:fill><a:solidFill><a:schemeClr val="accent1"/></a:solidFill></a:fill>
97 </a:tcStyle>
98 </a:firstRow>
99 </a:tblStyle>'''
100 )
101
102
103 @dataclass
104 class TableResult:
105 """Composite render output, replacement metadata, and effect diagnostics."""
106
107 svg: str = ""
108 defs: list[str] = None
109 native_payload: dict[str, Any] | None = None
110 native_status: str | None = None
111 effect_reason: str | None = None
112
113 def __post_init__(self) -> None:
114 if self.defs is None:
115 self.defs = []
116
117
118 @dataclass(frozen=True)
119 class _TableStyleContext:
120 """Small, best-effort view of the table style regions we render."""
121
122 style: ET.Element | None
123 table_properties: ET.Element | None
124
125 def regions_for_row(self, row_index: int) -> tuple[tuple[str, ET.Element], ...]:
126 if self.style is None:
127 return ()
128
129 names: list[str] = []
130 first_row = bool(
131 self.table_properties is not None
132 and ooxml_bool(self.table_properties.get("firstRow"))
133 )
134 if first_row and row_index == 0:
135 names.append("firstRow")
136 elif (
137 self.table_properties is not None
138 and ooxml_bool(self.table_properties.get("bandRow"))
139 ):
140 band_index = row_index - (1 if first_row else 0)
141 names.append("band1H" if band_index % 2 == 0 else "band2H")
142 names.append("wholeTbl")
143
144 regions: list[tuple[str, ET.Element]] = []
145 for name in names:
146 region = self.style.find(f"a:{name}", NS)
147 if region is not None:
148 regions.append((name, region))
149 return tuple(regions)
150
151
152 def _normalize_table_style_id(value: str | None) -> str:
153 return (value or "").strip().strip("{}").upper()
154
155
156 def _resolve_table_style(
157 tbl: ET.Element,
158 table_styles: ET.Element | None,
159 ) -> _TableStyleContext:
160 tbl_pr = tbl.find("a:tblPr", NS)
161 style_id = (
162 tbl_pr.findtext("a:tableStyleId", default="", namespaces=NS).strip()
163 if tbl_pr is not None else ""
164 )
165 if not style_id and table_styles is not None:
166 style_id = table_styles.get("def", "").strip()
167 normalized_id = _normalize_table_style_id(style_id)
168 supported_id = _normalize_table_style_id(BUILTIN_MEDIUM_STYLE_2_ACCENT_1)
169
170 # P1 deliberately supports one built-in family. Consuming an arbitrary
171 # custom definition here would be asymmetric: native reconstruction keeps
172 # only the style id and does not copy custom tableStyles.xml definitions.
173 if normalized_id != supported_id:
174 return _TableStyleContext(None, tbl_pr)
175
176 if table_styles is not None and normalized_id:
177 for candidate in table_styles.findall("a:tblStyle", NS):
178 if _normalize_table_style_id(candidate.get("styleId")) == normalized_id:
179 return _TableStyleContext(candidate, tbl_pr)
180
181 return _TableStyleContext(_BUILTIN_MEDIUM_STYLE_2_ACCENT_1_XML, tbl_pr)
182
183
184 def _effective_cell_fill(
185 tc_pr: ET.Element | None,
186 table_style: _TableStyleContext,
187 row_index: int,
188 palette: ColorPalette | None,
189 *,
190 id_prefix: str,
191 id_seq: list[int],
192 ) -> FillResult:
193 """Resolve direct cell fill before row-region and whole-table defaults."""
194 direct = resolve_fill(
195 tc_pr, palette, id_prefix=id_prefix, id_seq=id_seq,
196 )
197 if direct.attrs or direct.defs:
198 return direct
199
200 for _name, region in table_style.regions_for_row(row_index):
201 fill_parent = region.find("a:tcStyle/a:fill", NS)
202 if fill_parent is None:
203 continue
204 inherited = resolve_fill(
205 fill_parent, palette, id_prefix=id_prefix, id_seq=id_seq,
206 )
207 if inherited.attrs or inherited.defs:
208 return inherited
209 return direct
210
211
212 def _table_text_run_props(
213 table_style: _TableStyleContext,
214 row_index: int,
215 theme_fonts: dict[str, str],
216 ) -> tuple[ET.Element, ...]:
217 """Materialize table text-style regions as lowest-priority run defaults."""
218 props: list[ET.Element] = []
219 for _name, region in table_style.regions_for_row(row_index):
220 tx_style = region.find("a:tcTxStyle", NS)
221 if tx_style is None:
222 continue
223 run_props = _table_tx_style_run_props(tx_style, theme_fonts)
224 if run_props is not None:
225 props.append(run_props)
226 return tuple(props)
227
228
229 def _table_tx_style_run_props(
230 tx_style: ET.Element,
231 theme_fonts: dict[str, str],
232 ) -> ET.Element | None:
233 run_props = ET.Element(f"{{{NS['a']}}}rPr")
234 for attr in ("b", "i"):
235 value = tx_style.get(attr)
236 if value is not None:
237 run_props.set(attr, "1" if ooxml_bool(value) else "0")
238
239 font_ref = tx_style.find("a:fontRef", NS)
240 font_role = font_ref.get("idx") if font_ref is not None else None
241 if font_role in {"major", "minor"}:
242 prefix = "major" if font_role == "major" else "minor"
243 latin = theme_fonts.get(f"{prefix}Latin")
244 east_asia = theme_fonts.get(f"{prefix}EastAsia") or latin
245 complex_script = theme_fonts.get(f"{prefix}ComplexScript") or latin
246 for tag, typeface in (
247 ("latin", latin),
248 ("ea", east_asia),
249 ("cs", complex_script),
250 ):
251 if typeface:
252 ET.SubElement(
253 run_props, f"{{{NS['a']}}}{tag}",
254 {"typeface": typeface},
255 )
256
257 color = find_color_elem(tx_style)
258 if color is not None:
259 solid_fill = ET.SubElement(run_props, f"{{{NS['a']}}}solidFill")
260 solid_fill.append(copy.deepcopy(color))
261
262 if not run_props.attrib and not list(run_props):
263 return None
264 return run_props
265
266
267 # ---------------------------------------------------------------------------
268 # Public entry
269 # ---------------------------------------------------------------------------
270
271 def convert_tbl(
272 tbl: ET.Element,
273 xfrm: Xfrm,
274 palette: ColorPalette | None,
275 *,
276 table_styles: ET.Element | None = None,
277 theme_fonts: dict[str, str] | None = None,
278 slide_number: int | None = None,
279 id_prefix: str = "tbl",
280 grad_seq: list[int] | None = None,
281 marker_seq: list[int] | None = None,
282 ) -> TableResult:
283 """Render an <a:tbl> at the given absolute xfrm into SVG markup."""
284 grad_seq = grad_seq if grad_seq is not None else [0]
285 marker_seq = marker_seq if marker_seq is not None else [0]
286
287 col_widths_px = _column_widths_px(tbl)
288 if not col_widths_px:
289 return TableResult()
290 rows = tbl.findall("a:tr", NS)
291 if not rows:
292 return TableResult()
293 table_style = _resolve_table_style(tbl, table_styles)
294 row_heights_px = [_row_height_px(r) for r in rows]
295 grid_topology_invalid = any(
296 len(row.findall("a:tc", NS)) != len(col_widths_px)
297 for row in rows
298 )
299 source_geometry_invalid = (
300 grid_topology_invalid
301 or any(width <= 0 for width in col_widths_px)
302 or any(height <= 0 for height in row_heights_px)
303 or sum(col_widths_px) <= 0
304 or sum(row_heights_px) <= 0
305 )
306
307 # PowerPoint's tblGrid widths and tr heights together describe the
308 # *intrinsic* table size. The graphicFrame xfrm width/height may differ;
309 # if it does, scale rows/columns proportionally so the table fills the
310 # frame the way PowerPoint renders it.
311 intrinsic_w = sum(col_widths_px) or xfrm.w
312 intrinsic_h = sum(row_heights_px) or xfrm.h
313 sx = (xfrm.w / intrinsic_w) if intrinsic_w else 1.0
314 sy = (xfrm.h / intrinsic_h) if intrinsic_h else 1.0
315 col_widths = [w * sx for w in col_widths_px]
316 row_heights = [h * sy for h in row_heights_px]
317
318 col_lefts = _cumulative_starts(xfrm.x, col_widths)
319 row_tops = _cumulative_starts(xfrm.y, row_heights)
320
321 # First pass: resolve merge state so spanned cells get the union geometry
322 # and dropped cells don't render anything. PowerPoint expresses merges via
323 # gridSpan/rowSpan on the anchor cell + hMerge/vMerge on the dropped cells.
324 cells = _build_cell_grid(rows, len(col_widths))
325 merge_status = _canonical_native_merge_status(rows, len(col_widths))
326 effect_reason = (
327 "unsupported-run-effect-route:table-cell-text"
328 if any(
329 txbody_has_run_effects(tc.find("a:txBody", NS))
330 for tc in tbl.findall(".//a:tc", NS)
331 )
332 else None
333 )
334 if xfrm.rot or xfrm.flip_h or xfrm.flip_v:
335 native_status = "unsupported-native-transform"
336 elif merge_status:
337 native_status = merge_status
338 elif len(rows) > 1000 or len(col_widths) > 1000:
339 native_status = "unsupported-table-size"
340 elif source_geometry_invalid or (
341 any(width <= 0 for width in col_widths)
342 or any(height <= 0 for height in row_heights)
343 or sum(col_widths) <= 0
344 or sum(row_heights) <= 0
345 ):
346 native_status = "unsupported-table-geometry"
347 elif _table_has_unsupported_style(tbl):
348 native_status = "unsupported-table-style"
349 elif (
350 effect_reason
351 or _table_has_unsupported_direct_formatting(tbl, palette)
352 ):
353 native_status = "unsupported-table-direct-formatting"
354 else:
355 native_status = None
356 native_payload = (
357 None if native_status else _native_table_payload(
358 tbl,
359 xfrm,
360 col_widths,
361 row_heights,
362 cells,
363 palette,
364 theme_fonts or {},
365 )
366 )
367
368 body_parts: list[str] = []
369 defs: list[str] = []
370
371 # Pass A: cell backgrounds.
372 for r, row_cells in enumerate(cells):
373 for c, cell in enumerate(row_cells):
374 if cell is None or cell.is_dropped:
375 continue
376 rect_x = col_lefts[c]
377 rect_y = row_tops[r]
378 rect_w = sum(col_widths[c:c + cell.col_span])
379 rect_h = sum(row_heights[r:r + cell.row_span])
380 tcPr = cell.element.find("a:tcPr", NS)
381 fill = _effective_cell_fill(
382 tcPr, table_style, r, palette,
383 id_prefix=f"{id_prefix}fill",
384 id_seq=grad_seq,
385 )
386 defs.extend(fill.defs)
387 attrs = fill.attrs or {"fill": "none"}
388 attr_str = "".join(f' {k}="{v}"' for k, v in attrs.items())
389 body_parts.append(
390 f'<rect x="{fmt_num(rect_x)}" y="{fmt_num(rect_y)}" '
391 f'width="{fmt_num(rect_w)}" height="{fmt_num(rect_h)}"'
392 f'{attr_str}/>'
393 )
394
395 # Pass B: cell text. Cell xfrm uses default tcPr insets if none specified.
396 for r, row_cells in enumerate(cells):
397 for c, cell in enumerate(row_cells):
398 if cell is None or cell.is_dropped:
399 continue
400 tx_body = cell.element.find("a:txBody", NS)
401 if tx_body is None:
402 continue
403 tcPr = cell.element.find("a:tcPr", NS)
404 cell_x = col_lefts[c]
405 cell_y = row_tops[r]
406 cell_w = sum(col_widths[c:c + cell.col_span])
407 cell_h = sum(row_heights[r:r + cell.row_span])
408 cell_xfrm = Xfrm(x=cell_x, y=cell_y, w=cell_w, h=cell_h)
409 text_result = _convert_cell_text(
410 tx_body, tcPr, cell_xfrm, palette, theme_fonts,
411 fallback_run_props=_table_text_run_props(
412 table_style, r, theme_fonts or {},
413 ),
414 slide_number=slide_number,
415 id_prefix=f"{id_prefix}txt",
416 id_seq=grad_seq,
417 )
418 defs.extend(text_result.defs)
419 if text_result.svg:
420 body_parts.append(text_result.svg)
421
422 # Pass C: cell borders. Drawn last so they appear on top of fills/text.
423 for r, row_cells in enumerate(cells):
424 for c, cell in enumerate(row_cells):
425 if cell is None or cell.is_dropped:
426 continue
427 cell_x = col_lefts[c]
428 cell_y = row_tops[r]
429 cell_w = sum(col_widths[c:c + cell.col_span])
430 cell_h = sum(row_heights[r:r + cell.row_span])
431 tcPr = cell.element.find("a:tcPr", NS)
432 for tag, x1, y1, x2, y2 in (
433 ("a:lnT", cell_x, cell_y, cell_x + cell_w, cell_y),
434 ("a:lnR", cell_x + cell_w, cell_y, cell_x + cell_w, cell_y + cell_h),
435 ("a:lnB", cell_x, cell_y + cell_h, cell_x + cell_w, cell_y + cell_h),
436 ("a:lnL", cell_x, cell_y, cell_x, cell_y + cell_h),
437 ):
438 line_xml = _border_line(
439 tcPr, table_style, r, c, len(rows), len(col_widths), tag,
440 x1, y1, x2, y2, palette,
441 id_prefix=f"{id_prefix}stk", id_seq=marker_seq, defs=defs,
442 )
443 if line_xml:
444 body_parts.append(line_xml)
445
446 return TableResult(
447 svg="\n".join(body_parts),
448 defs=defs,
449 native_payload=native_payload,
450 native_status=native_status,
451 effect_reason=effect_reason,
452 )
453
454
455 # ---------------------------------------------------------------------------
456 # Geometry helpers
457 # ---------------------------------------------------------------------------
458
459 def _column_widths_px(tbl: ET.Element) -> list[float]:
460 grid = tbl.find("a:tblGrid", NS)
461 if grid is None:
462 return []
463 widths: list[float] = []
464 for col in grid.findall("a:gridCol", NS):
465 w_emu = col.attrib.get("w")
466 if w_emu is None:
467 widths.append(0.0)
468 continue
469 value = _safe_emu_integer(w_emu)
470 widths.append(emu_to_px(value) if value is not None else 0.0)
471 return widths
472
473
474 def _row_height_px(row: ET.Element) -> float:
475 h_emu = row.attrib.get("h")
476 if h_emu is None:
477 return 0.0
478 value = _safe_emu_integer(h_emu)
479 return emu_to_px(value) if value is not None else 0.0
480
481
482 def _safe_emu_integer(raw_value: str) -> int | None:
483 """Return a bounded ASCII DrawingML coordinate without float overflow."""
484 token = raw_value.strip(" \t\r\n")
485 digits = token[1:] if token.startswith("-") else token
486 if (
487 not digits
488 or not digits.isascii()
489 or not digits.isdigit()
490 or len(digits) > 10
491 ):
492 return None
493 value = int(token)
494 if not _POWERPOINT_COORD_MIN <= value <= _POWERPOINT_COORD_MAX:
495 return None
496 return value
497
498
499 def _cumulative_starts(origin: float, sizes: list[float]) -> list[float]:
500 out = [origin]
501 acc = origin
502 for size in sizes[:-1]:
503 acc += size
504 out.append(acc)
505 return out
506
507
508 # ---------------------------------------------------------------------------
509 # Cell grid
510 # ---------------------------------------------------------------------------
511
512 @dataclass
513 class _CellSlot:
514 """Per-grid-position resolution of <a:tc> attributes."""
515
516 element: ET.Element
517 col_span: int = 1
518 row_span: int = 1
519 is_dropped: bool = False # True for h/vMerge slaves: don't paint anything
520
521
522 @dataclass(frozen=True)
523 class _CanonicalMergeRegion:
524 row: int
525 col: int
526 row_span: int
527 col_span: int
528
529
530 def _build_cell_grid(rows: list[ET.Element], col_count: int) -> list[list[_CellSlot | None]]:
531 """Map each (row, col) to the <a:tc> that owns it.
532
533 Anchor cells (the top-left of a merge) carry col_span/row_span; merged
534 slaves are marked is_dropped so the renderer skips them. Cells not part
535 of any merge get span 1×1.
536 """
537 grid: list[list[_CellSlot | None]] = [[None] * col_count for _ in rows]
538
539 for r, row in enumerate(rows):
540 row_cells = row.findall("a:tc", NS)
541 # PowerPoint writes one physical <a:tc> for every grid column, including
542 # explicit hMerge/vMerge continuation cells. In that canonical form the
543 # physical index is the grid column; advancing by gridSpan would consume
544 # the continuation cell twice and shift every following cell left.
545 explicit_grid = len(row_cells) >= col_count
546 c = 0
547 for physical_col, tc in enumerate(row_cells):
548 if explicit_grid:
549 c = physical_col
550 else:
551 # Retain best-effort support for compact/non-canonical rows that
552 # omit explicit merge continuation cells.
553 while c < col_count and grid[r][c] is not None:
554 c += 1
555 if c >= col_count:
556 break
557
558 grid_span = _safe_int(tc.attrib.get("gridSpan"), 1)
559 row_span = _safe_int(tc.attrib.get("rowSpan"), 1)
560 h_merge = ooxml_bool(tc.attrib.get("hMerge"))
561 v_merge = ooxml_bool(tc.attrib.get("vMerge"))
562
563 if h_merge or v_merge:
564 # Merge slaves are physical cells but have no independent paint.
565 grid[r][c] = _CellSlot(element=tc, is_dropped=True)
566 c += 1
567 continue
568
569 slot = _CellSlot(
570 element=tc,
571 col_span=max(grid_span, 1),
572 row_span=max(row_span, 1),
573 )
574 for dr in range(slot.row_span):
575 for dc in range(slot.col_span):
576 rr = r + dr
577 cc = c + dc
578 if rr >= len(rows) or cc >= col_count:
579 continue
580 if dr == 0 and dc == 0:
581 grid[rr][cc] = slot
582 else:
583 grid[rr][cc] = _CellSlot(
584 element=tc, is_dropped=True,
585 )
586 c += 1 if explicit_grid else slot.col_span
587
588 return grid
589
590
591 def _safe_int(value: str | None, default: int) -> int:
592 if value is None:
593 return default
594 try:
595 return int(value)
596 except ValueError:
597 return default
598
599
600 def _strict_merge_bool(value: str | None) -> bool:
601 if value is None:
602 return False
603 normalized = value.strip().lower()
604 if normalized in {"1", "on", "true"}:
605 return True
606 if normalized in {"0", "false", "off"}:
607 return False
608 raise ValueError("invalid OOXML boolean")
609
610
611 def _strict_merge_span(value: str | None) -> int:
612 if value is None:
613 return 1
614 normalized = value.strip()
615 if not normalized.isdigit():
616 raise ValueError("invalid OOXML span")
617 span = int(normalized)
618 if span <= 0:
619 raise ValueError("invalid OOXML span")
620 return span
621
622
623 def _canonical_merge_slave_is_empty(tc: ET.Element) -> bool:
624 tc_pr = tc.find("a:tcPr", NS)
625 if tc_pr is None or tc_pr.attrib or list(tc_pr):
626 return False
627 tx_body = tc.find("a:txBody", NS)
628 if tx_body is None:
629 return False
630 paragraph_count = 0
631 for child in tx_body:
632 name = child.tag.rsplit("}", 1)[-1]
633 if name in {"bodyPr", "lstStyle"}:
634 if child.attrib or list(child):
635 return False
636 continue
637 if name == "p" and not child.attrib and not list(child):
638 paragraph_count += 1
639 continue
640 return False
641 return paragraph_count > 0 and not (tx_body.text or "").strip()
642
643
644 def _canonical_native_merge_status(
645 rows: list[ET.Element],
646 col_count: int,
647 ) -> str | None:
648 """Accept only explicit rectangular merge topology safe for regeneration."""
649 physical_rows = [row.findall("a:tc", NS) for row in rows]
650 merge_attrs = {"gridSpan", "rowSpan", "hMerge", "vMerge"}
651 if not any(
652 any(name in tc.attrib for name in merge_attrs)
653 for row_cells in physical_rows
654 for tc in row_cells
655 ):
656 return None
657 if col_count <= 0 or any(
658 len(row_cells) != col_count for row_cells in physical_rows
659 ):
660 return "unsupported-merge-topology"
661
662 states: dict[tuple[int, int], tuple[ET.Element, int, int, bool, bool]] = {}
663 try:
664 for row_idx, row_cells in enumerate(physical_rows):
665 for col_idx, tc in enumerate(row_cells):
666 states[(row_idx, col_idx)] = (
667 tc,
668 _strict_merge_span(tc.get("rowSpan")),
669 _strict_merge_span(tc.get("gridSpan")),
670 _strict_merge_bool(tc.get("hMerge")),
671 _strict_merge_bool(tc.get("vMerge")),
672 )
673 except ValueError:
674 return "unsupported-merge-topology"
675
676 anchors: list[_CanonicalMergeRegion] = []
677 for (
678 (row_idx, col_idx),
679 (_tc, row_span, col_span, h_merge, v_merge),
680 ) in states.items():
681 if not h_merge and not v_merge and (row_span > 1 or col_span > 1):
682 anchors.append(
683 _CanonicalMergeRegion(row_idx, col_idx, row_span, col_span)
684 )
685
686 owners: dict[tuple[int, int], _CanonicalMergeRegion] = {}
687 for region in anchors:
688 if (
689 region.row + region.row_span > len(rows)
690 or region.col + region.col_span > col_count
691 ):
692 return "unsupported-merge-topology"
693 for covered_row in range(region.row, region.row + region.row_span):
694 for covered_col in range(region.col, region.col + region.col_span):
695 position = (covered_row, covered_col)
696 if position in owners:
697 return "unsupported-merge-topology"
698 owners[position] = region
699
700 for (
701 (row_idx, col_idx),
702 (tc, row_span, col_span, h_merge, v_merge),
703 ) in states.items():
704 region = owners.get((row_idx, col_idx))
705 if region is None:
706 if h_merge or v_merge or row_span != 1 or col_span != 1:
707 return "unsupported-merge-topology"
708 continue
709
710 is_anchor = row_idx == region.row and col_idx == region.col
711 expected_row_span = region.row_span if row_idx == region.row else 1
712 expected_col_span = region.col_span if col_idx == region.col else 1
713 if (
714 row_span != expected_row_span
715 or col_span != expected_col_span
716 or h_merge != (col_idx > region.col)
717 or v_merge != (row_idx > region.row)
718 ):
719 return "unsupported-merge-topology"
720 if not is_anchor and not _canonical_merge_slave_is_empty(tc):
721 return "unsupported-merge-topology"
722
723 return None
724
725
726 def _table_has_unsupported_style(tbl: ET.Element) -> bool:
727 tbl_pr = tbl.find("a:tblPr", NS)
728 if tbl_pr is None:
729 return False
730 allowed_attrs = {
731 "firstRow", "bandRow", "firstCol", "lastCol", "lastRow",
732 "bandCol", "rtl",
733 }
734 if any(name not in allowed_attrs for name in tbl_pr.attrib):
735 return True
736 if any(
737 ooxml_bool(tbl_pr.attrib.get(name))
738 for name in ("firstCol", "lastCol", "lastRow", "bandCol", "rtl")
739 ):
740 return True
741 return any(
742 child.tag.rsplit("}", 1)[-1] != "tableStyleId"
743 for child in tbl_pr
744 )
745
746
747 _DIRECT_BORDER_TAGS = {
748 "lnL": "left",
749 "lnR": "right",
750 "lnT": "top",
751 "lnB": "bottom",
752 }
753 _DIRECT_BORDER_WIDTH_MAX = 20116800
754 _OPAQUE_COLOR_MODIFIERS = {
755 "tint",
756 "shade",
757 "lumMod",
758 "lumOff",
759 "satMod",
760 "satOff",
761 }
762
763
764 def _validate_opaque_border_color(color_elem: ET.Element | None) -> None:
765 if color_elem is None:
766 raise ValueError("missing border color")
767 name = color_elem.tag.rsplit("}", 1)[-1]
768 if name not in {"srgbClr", "schemeClr"} or set(color_elem.attrib) != {"val"}:
769 raise ValueError("unsupported border color")
770 for modifier in color_elem:
771 modifier_name = modifier.tag.rsplit("}", 1)[-1]
772 if (
773 modifier_name not in _OPAQUE_COLOR_MODIFIERS
774 or set(modifier.attrib) != {"val"}
775 or list(modifier)
776 ):
777 raise ValueError("unsupported border color modifier")
778 value = modifier.get("val", "")
779 if not value.isdigit() or not 0 <= int(value) <= 100000:
780 raise ValueError("invalid border color modifier")
781
782
783 def _direct_border_payload(
784 ln: ET.Element,
785 palette: ColorPalette | None,
786 ) -> dict[str, Any]:
787 if set(ln.attrib) - {"w", "cap", "cmpd", "algn"}:
788 raise ValueError("unsupported border line attribute")
789 if ln.get("cap") not in {None, "flat"}:
790 raise ValueError("unsupported border cap")
791 if ln.get("cmpd") not in {None, "sng"}:
792 raise ValueError("unsupported border compound style")
793 if ln.get("algn") not in {None, "ctr"}:
794 raise ValueError("unsupported border alignment")
795 width_emu: int | None = None
796 if "w" in ln.attrib:
797 raw_width = ln.get("w", "")
798 if not raw_width.isdigit():
799 raise ValueError("invalid border width")
800 width_emu = int(raw_width)
801 if not 0 < width_emu <= _DIRECT_BORDER_WIDTH_MAX:
802 raise ValueError("invalid border width")
803
804 children = list(ln)
805 child_names = [child.tag.rsplit("}", 1)[-1] for child in children]
806 if child_names == ["noFill"]:
807 no_fill = children[0]
808 if no_fill.attrib or list(no_fill):
809 raise ValueError("invalid noFill border")
810 return {"style": "none"}
811
812 decoration_names = {"round", "headEnd", "tailEnd"}
813 if child_names.count("solidFill") != 1 or any(
814 name not in {"solidFill", "prstDash", *decoration_names}
815 for name in child_names
816 ):
817 raise ValueError("unsupported border paint")
818 if (
819 child_names.count("prstDash") > 1
820 or any(child_names.count(name) > 1 for name in decoration_names)
821 or width_emu is None
822 ):
823 raise ValueError("invalid solid border")
824 dash = next(
825 (child for child in children if child.tag.rsplit("}", 1)[-1] == "prstDash"),
826 None,
827 )
828 if dash is not None and (
829 dash.attrib != {"val": "solid"} or list(dash)
830 ):
831 raise ValueError("unsupported border dash")
832 line_join = next(
833 (child for child in children if child.tag.rsplit("}", 1)[-1] == "round"),
834 None,
835 )
836 if line_join is not None and (line_join.attrib or list(line_join)):
837 raise ValueError("unsupported border line join")
838 for endpoint_name in ("headEnd", "tailEnd"):
839 endpoint = next(
840 (
841 child for child in children
842 if child.tag.rsplit("}", 1)[-1] == endpoint_name
843 ),
844 None,
845 )
846 if endpoint is None:
847 continue
848 if (
849 set(endpoint.attrib) - {"type", "w", "len"}
850 or endpoint.get("type") not in {None, "none"}
851 or endpoint.get("w") not in {None, "med"}
852 or endpoint.get("len") not in {None, "med"}
853 or list(endpoint)
854 ):
855 raise ValueError("unsupported border endpoint")
856
857 solid_fill = next(
858 child for child in children
859 if child.tag.rsplit("}", 1)[-1] == "solidFill"
860 )
861 if solid_fill.attrib or len(list(solid_fill)) != 1:
862 raise ValueError("invalid solid border fill")
863 color_elem = find_color_elem(solid_fill)
864 _validate_opaque_border_color(color_elem)
865 try:
866 color, alpha = resolve_color(color_elem, palette)
867 except (TypeError, ValueError, OverflowError) as exc:
868 raise ValueError("invalid solid border color") from exc
869 if color is None or alpha != 1.0:
870 raise ValueError("border color must resolve to opaque RGB")
871
872 width = _round_payload_number(emu_to_px(str(width_emu)))
873 if width <= 0:
874 raise ValueError("border width is too small")
875 return {
876 "style": "solid",
877 "color": color,
878 "width": width,
879 }
880
881
882 def _table_has_unsupported_direct_formatting(
883 tbl: ET.Element,
884 palette: ColorPalette | None,
885 ) -> bool:
886 """Reject direct cell features the compact native schema cannot retain."""
887 for tc in tbl.findall(".//a:tc", NS):
888 if _table_cell_has_unsupported_topology(tc):
889 return True
890 tc_pr = tc.find("a:tcPr", NS)
891 if tc_pr is not None:
892 allowed_attrs = {"marL", "marR", "marT", "marB", "anchor"}
893 if any(name not in allowed_attrs for name in tc_pr.attrib):
894 return True
895 if tc_pr.get("anchor") not in {None, "t", "ctr", "b"}:
896 return True
897 if any(
898 child.tag.rsplit("}", 1)[-1]
899 not in {"solidFill", "noFill", *_DIRECT_BORDER_TAGS}
900 for child in tc_pr
901 ):
902 return True
903 fills = [
904 child for child in tc_pr
905 if child.tag.rsplit("}", 1)[-1] in {"solidFill", "noFill"}
906 ]
907 if len(fills) > 1:
908 return True
909 no_fill = tc_pr.find("a:noFill", NS)
910 if no_fill is not None and (no_fill.attrib or list(no_fill)):
911 return True
912 for border_tag in _DIRECT_BORDER_TAGS:
913 borders = tc_pr.findall(f"a:{border_tag}", NS)
914 if len(borders) > 1:
915 return True
916 if borders:
917 try:
918 _direct_border_payload(borders[0], palette)
919 except ValueError:
920 return True
921 solid_fill = tc_pr.find("a:solidFill", NS)
922 if solid_fill is not None:
923 if solid_fill.find(".//a:alpha", NS) is not None:
924 return True
925 if _cell_fill_hex(tc_pr, palette) is None:
926 return True
927 tx_body = tc.find("a:txBody", NS)
928 if _text_body_has_unsupported_formatting(tx_body):
929 return True
930 return False
931
932
933 def _table_cell_has_unsupported_topology(tc: ET.Element) -> bool:
934 """Accept only the closed optional txBody -> optional tcPr cell sequence."""
935 if any(
936 name not in {"gridSpan", "rowSpan", "hMerge", "vMerge"}
937 for name in tc.attrib
938 ):
939 return True
940 tx_body_tag = f"{{{NS['a']}}}txBody"
941 tc_pr_tag = f"{{{NS['a']}}}tcPr"
942 child_tags = [child.tag for child in tc]
943 child_index = 0
944 if child_tags[:1] == [tx_body_tag]:
945 child_index += 1
946 if child_tags[child_index:child_index + 1] == [tc_pr_tag]:
947 child_index += 1
948 return child_index != len(child_tags)
949
950
951 def _text_body_has_unsupported_formatting(tx_body: ET.Element | None) -> bool:
952 if tx_body is None:
953 return False
954 if tx_body.attrib:
955 return True
956
957 body_pr_tag = f"{{{NS['a']}}}bodyPr"
958 list_style_tag = f"{{{NS['a']}}}lstStyle"
959 paragraph_tag = f"{{{NS['a']}}}p"
960 body_tags = [child.tag for child in tx_body]
961 body_index = 0
962 if not body_tags or body_tags[0] != body_pr_tag:
963 return True
964 body_index += 1
965 if body_index < len(body_tags) and body_tags[body_index] == list_style_tag:
966 body_index += 1
967 if body_index == len(body_tags) or any(
968 tag != paragraph_tag for tag in body_tags[body_index:]
969 ):
970 return True
971
972 relationship_prefix = f"{{{NS['r']}}}"
973 forbidden_run_children = {
974 f"{{{NS['a']}}}extLst",
975 f"{{{NS['a']}}}hlinkClick",
976 f"{{{NS['a']}}}hlinkMouseOver",
977 }
978 if any(
979 node.tag in forbidden_run_children
980 or any(name.startswith(relationship_prefix) for name in node.attrib)
981 for node in tx_body.iter()
982 ):
983 return True
984
985 body_pr = tx_body.find("a:bodyPr", NS)
986 if body_pr is None or body_pr.attrib or list(body_pr):
987 return True
988 list_style = tx_body.find("a:lstStyle", NS)
989 if list_style is not None and (list_style.attrib or list(list_style)):
990 return True
991 for paragraph in tx_body.findall("a:p", NS):
992 if paragraph.attrib:
993 return True
994 p_pr_tag = f"{{{NS['a']}}}pPr"
995 run_tag = f"{{{NS['a']}}}r"
996 end_r_pr_tag = f"{{{NS['a']}}}endParaRPr"
997 direct_tags = [child.tag for child in paragraph]
998 paragraph_index = 0
999 if direct_tags[:1] == [p_pr_tag]:
1000 paragraph_index += 1
1001 while (
1002 paragraph_index < len(direct_tags)
1003 and direct_tags[paragraph_index] == run_tag
1004 ):
1005 paragraph_index += 1
1006 if (
1007 paragraph_index < len(direct_tags)
1008 and direct_tags[paragraph_index] == end_r_pr_tag
1009 ):
1010 paragraph_index += 1
1011 if paragraph_index != len(direct_tags):
1012 return True
1013
1014 p_pr = paragraph.find("a:pPr", NS)
1015 if p_pr is not None:
1016 p_pr_tags = [child.tag.rsplit("}", 1)[-1] for child in p_pr]
1017 if p_pr_tags.count("defRPr") > 1 or p_pr_tags.count("buNone") > 1:
1018 return True
1019 if any(tag.startswith("bu") and tag != "buNone" for tag in p_pr_tags):
1020 return True
1021
1022 for run in paragraph.findall("a:r", NS):
1023 if run.attrib:
1024 return True
1025 r_pr_tag = f"{{{NS['a']}}}rPr"
1026 text_tag = f"{{{NS['a']}}}t"
1027 run_tags = [child.tag for child in run]
1028 if run_tags not in ([text_tag], [r_pr_tag, text_tag]):
1029 return True
1030 text_node = run.find("a:t", NS)
1031 if text_node is None or list(text_node):
1032 return True
1033 allowed_text_attrs = {"{http://www.w3.org/XML/1998/namespace}space"}
1034 if any(name not in allowed_text_attrs for name in text_node.attrib):
1035 return True
1036 return False
1037
1038
1039 def _legacy_text_body_has_unsupported_formatting(
1040 tx_body: ET.Element | None,
1041 ) -> bool:
1042 """Return the pre-P2-T4 gate so active plain payloads stay unchanged."""
1043 if tx_body is None:
1044 return False
1045 body_pr = tx_body.find("a:bodyPr", NS)
1046 if body_pr is not None and (body_pr.attrib or list(body_pr)):
1047 return True
1048 list_style = tx_body.find("a:lstStyle", NS)
1049 if list_style is not None and (list_style.attrib or list(list_style)):
1050 return True
1051 if (
1052 tx_body.find(".//a:br", NS) is not None
1053 or tx_body.find(".//a:fld", NS) is not None
1054 or tx_body.find(".//a:tab", NS) is not None
1055 ):
1056 return True
1057
1058 run_signatures: set[tuple[str | None, str | None, bytes | None]] = set()
1059 for paragraph in tx_body.findall("a:p", NS):
1060 p_pr = paragraph.find("a:pPr", NS)
1061 alignment = p_pr.get("algn") if p_pr is not None else None
1062 if alignment not in {None, "l", "ctr", "r"}:
1063 return True
1064 if p_pr is not None:
1065 if any(name != "algn" for name in p_pr.attrib):
1066 return True
1067 if any(
1068 child.tag.rsplit("}", 1)[-1] not in {"defRPr", "buNone"}
1069 for child in p_pr
1070 ):
1071 return True
1072
1073 default_r_pr = p_pr.find("a:defRPr", NS) if p_pr is not None else None
1074 if _legacy_run_props_have_unsupported_formatting(default_r_pr):
1075 return True
1076 for run in paragraph.findall("a:r", NS):
1077 r_pr = run.find("a:rPr", NS)
1078 if _legacy_run_props_have_unsupported_formatting(r_pr):
1079 return True
1080 run_signatures.add(
1081 _legacy_effective_run_signature(r_pr, default_r_pr)
1082 )
1083 end_r_pr = paragraph.find("a:endParaRPr", NS)
1084 if _legacy_run_props_have_unsupported_formatting(end_r_pr):
1085 return True
1086 if not paragraph.findall("a:r", NS) and end_r_pr is not None:
1087 run_signatures.add(
1088 _legacy_effective_run_signature(end_r_pr, default_r_pr)
1089 )
1090
1091 return len(run_signatures) > 1
1092
1093
1094 def _legacy_run_props_have_unsupported_formatting(
1095 r_pr: ET.Element | None,
1096 ) -> bool:
1097 if r_pr is None:
1098 return False
1099 if ooxml_bool(r_pr.get("i")):
1100 return True
1101 if r_pr.get("u") not in {None, "none"}:
1102 return True
1103 if r_pr.get("strike") not in {None, "noStrike"}:
1104 return True
1105 if r_pr.get("baseline") not in {None, "0"}:
1106 return True
1107 if r_pr.get("cap") not in {None, "none"}:
1108 return True
1109 if r_pr.get("spc") not in {None, "0"}:
1110 return True
1111 allowed_attrs = {
1112 "lang", "altLang", "sz", "b", "i", "u", "strike", "dirty",
1113 "baseline", "cap", "spc",
1114 }
1115 if any(name not in allowed_attrs for name in r_pr.attrib):
1116 return True
1117 solid_fill = r_pr.find("a:solidFill", NS)
1118 if solid_fill is not None and solid_fill.find(".//a:alpha", NS) is not None:
1119 return True
1120 return any(
1121 child.tag.rsplit("}", 1)[-1] != "solidFill"
1122 for child in r_pr
1123 )
1124
1125
1126 def _legacy_effective_run_signature(
1127 r_pr: ET.Element | None,
1128 default_r_pr: ET.Element | None,
1129 ) -> tuple[str | None, str | None, bytes | None]:
1130 def attr(name: str) -> str | None:
1131 if r_pr is not None and r_pr.get(name) is not None:
1132 return r_pr.get(name)
1133 return default_r_pr.get(name) if default_r_pr is not None else None
1134
1135 solid_fill = r_pr.find("a:solidFill", NS) if r_pr is not None else None
1136 if solid_fill is None and default_r_pr is not None:
1137 solid_fill = default_r_pr.find("a:solidFill", NS)
1138 fill_xml = (
1139 ET.tostring(solid_fill, encoding="utf-8")
1140 if solid_fill is not None else None
1141 )
1142 return attr("b"), attr("sz"), fill_xml
1143
1144
1145 def _round_payload_number(value: float) -> int | float:
1146 rounded = round(float(value), 3)
1147 return int(rounded) if rounded.is_integer() else rounded
1148
1149
1150 def _native_table_payload(
1151 tbl: ET.Element,
1152 xfrm: Xfrm,
1153 column_widths: list[float],
1154 row_heights: list[float],
1155 cells: list[list[_CellSlot | None]],
1156 palette: ColorPalette | None,
1157 theme_fonts: dict[str, str],
1158 ) -> dict[str, Any]:
1159 """Build the SVG native Table replacement payload for an unmerged table."""
1160 tbl_pr = tbl.find("a:tblPr", NS)
1161 payload: dict[str, Any] = {
1162 "x": _round_payload_number(xfrm.x),
1163 "y": _round_payload_number(xfrm.y),
1164 "width": _round_payload_number(xfrm.w),
1165 "height": _round_payload_number(xfrm.h),
1166 "strict_grid": True,
1167 "header_rows": (
1168 1 if tbl_pr is not None and ooxml_bool(tbl_pr.get("firstRow")) else 0
1169 ),
1170 "column_widths": [_round_payload_number(width) for width in column_widths],
1171 "row_heights": [_round_payload_number(height) for height in row_heights],
1172 "rows": [],
1173 }
1174 style: dict[str, Any] = {
1175 "band_row": bool(tbl_pr is not None and ooxml_bool(tbl_pr.get("bandRow"))),
1176 }
1177 if tbl_pr is not None:
1178 table_style_id = tbl_pr.findtext("a:tableStyleId", default="", namespaces=NS).strip()
1179 if table_style_id:
1180 style["table_style_id"] = table_style_id
1181 payload["style"] = style
1182
1183 rows_payload: list[list[Any]] = []
1184 for row_cells in cells:
1185 row_payload: list[Any] = []
1186 for slot in row_cells:
1187 if slot is None or slot.is_dropped:
1188 row_payload.append("")
1189 continue
1190 cell_payload = _native_cell_payload(
1191 slot.element,
1192 palette,
1193 theme_fonts,
1194 )
1195 if slot.row_span > 1:
1196 cell_payload["row_span"] = slot.row_span
1197 if slot.col_span > 1:
1198 cell_payload["col_span"] = slot.col_span
1199 row_payload.append(cell_payload)
1200 rows_payload.append(row_payload)
1201 payload["rows"] = rows_payload
1202 return payload
1203
1204
1205 def _native_cell_payload(
1206 tc: ET.Element,
1207 palette: ColorPalette | None,
1208 theme_fonts: dict[str, str],
1209 ) -> dict[str, Any]:
1210 tx_body = tc.find("a:txBody", NS)
1211 tc_pr = tc.find("a:tcPr", NS)
1212 paragraph_payloads = _cell_paragraph_payloads(tx_body)
1213 rich_paragraphs = _cell_rich_paragraph_payloads(
1214 tx_body,
1215 palette,
1216 theme_fonts,
1217 )
1218 if rich_paragraphs is not None:
1219 cell: dict[str, Any] = {"paragraphs": rich_paragraphs}
1220 elif len(paragraph_payloads) > 1:
1221 cell: dict[str, Any] = {"paragraphs": paragraph_payloads}
1222 else:
1223 cell = {"text": _cell_plain_text(tx_body)}
1224
1225 fill = _cell_fill_hex(tc_pr, palette)
1226 if fill:
1227 cell["fill"] = fill
1228 if rich_paragraphs is None:
1229 color = _cell_text_color(tx_body, palette)
1230 if color:
1231 cell["color"] = color
1232 font_size = _cell_font_size_px(tx_body)
1233 if font_size:
1234 cell["font_size"] = font_size
1235 if len(paragraph_payloads) <= 1:
1236 align = _cell_align(tx_body)
1237 if align:
1238 cell["align"] = align
1239 valign = _cell_valign(tc_pr)
1240 if valign:
1241 cell["valign"] = valign
1242 if rich_paragraphs is None:
1243 bold = _cell_bold(tx_body)
1244 if bold is not None:
1245 cell["bold"] = bold
1246 borders = _cell_borders_payload(tc_pr, palette)
1247 if borders:
1248 cell["borders"] = borders
1249 _copy_cell_margins(tc_pr, cell)
1250 return cell
1251
1252
1253 def _cell_plain_text(tx_body: ET.Element | None) -> str:
1254 if tx_body is None:
1255 return ""
1256 paragraphs: list[str] = []
1257 for paragraph in tx_body.findall("a:p", NS):
1258 text = "".join(node.text or "" for node in paragraph.findall(".//a:t", NS))
1259 if text:
1260 paragraphs.append(text)
1261 return "\n".join(paragraphs)
1262
1263
1264 def _cell_paragraph_payloads(
1265 tx_body: ET.Element | None,
1266 ) -> list[str | dict[str, str]]:
1267 if tx_body is None:
1268 return []
1269 payloads: list[str | dict[str, str]] = []
1270 for paragraph in tx_body.findall("a:p", NS):
1271 text = "".join(node.text or "" for node in paragraph.findall(".//a:t", NS))
1272 p_pr = paragraph.find("a:pPr", NS)
1273 align = p_pr.get("algn") if p_pr is not None else None
1274 if align in {"l", "ctr", "r"}:
1275 payloads.append({"text": text, "align": align})
1276 else:
1277 payloads.append(text)
1278 return payloads
1279
1280
1281 def _effective_run_attr(
1282 r_pr: ET.Element | None,
1283 default_r_pr: ET.Element | None,
1284 name: str,
1285 ) -> str | None:
1286 if r_pr is not None and r_pr.get(name) is not None:
1287 return r_pr.get(name)
1288 return default_r_pr.get(name) if default_r_pr is not None else None
1289
1290
1291 def _effective_run_child(
1292 r_pr: ET.Element | None,
1293 default_r_pr: ET.Element | None,
1294 name: str,
1295 ) -> ET.Element | None:
1296 child = r_pr.find(f"a:{name}", NS) if r_pr is not None else None
1297 if child is not None:
1298 return child
1299 return default_r_pr.find(f"a:{name}", NS) if default_r_pr is not None else None
1300
1301
1302 def _native_run_font_family(
1303 r_pr: ET.Element | None,
1304 default_r_pr: ET.Element | None,
1305 theme_fonts: dict[str, str],
1306 ) -> str | None:
1307 faces: list[str] = []
1308 for tag in ("latin", "ea"):
1309 node = _effective_run_child(r_pr, default_r_pr, tag)
1310 raw_face = node.get("typeface") if node is not None else None
1311 face = _resolve_theme_typeface(raw_face, theme_fonts)
1312 if face and face not in faces:
1313 faces.append(face)
1314 if len(faces) != 1:
1315 return None
1316 face = faces[0].strip()
1317 return face if face and "," not in face else None
1318
1319
1320 def _native_run_payload(
1321 run: ET.Element,
1322 default_r_pr: ET.Element | None,
1323 palette: ColorPalette | None,
1324 theme_fonts: dict[str, str],
1325 ) -> dict[str, Any]:
1326 r_pr = run.find("a:rPr", NS)
1327 text = run.findtext("a:t", default="", namespaces=NS)
1328 payload: dict[str, Any] = {"text": text}
1329
1330 for source, target in (("b", "bold"), ("i", "italic")):
1331 raw = _effective_run_attr(r_pr, default_r_pr, source)
1332 if raw is not None:
1333 payload[target] = ooxml_bool(raw)
1334
1335 underline = _effective_run_attr(r_pr, default_r_pr, "u")
1336 if underline is not None:
1337 payload["underline"] = underline != "none"
1338 strike = _effective_run_attr(r_pr, default_r_pr, "strike")
1339 if strike is not None:
1340 payload["strike"] = strike != "noStrike"
1341
1342 font_size = _canonical_source_font_size_px(
1343 _effective_run_attr(r_pr, default_r_pr, "sz")
1344 )
1345 if font_size is not None:
1346 payload["font_size"] = font_size
1347
1348 solid_fill = _effective_run_child(r_pr, default_r_pr, "solidFill")
1349 if solid_fill is not None and not _solid_fill_is_unsafe(solid_fill, palette):
1350 try:
1351 color, _alpha = resolve_color(find_color_elem(solid_fill), palette)
1352 except (AttributeError, OverflowError, TypeError, ValueError):
1353 color = None
1354 if color:
1355 payload["color"] = color
1356
1357 font_family = _native_run_font_family(
1358 r_pr,
1359 default_r_pr,
1360 theme_fonts,
1361 )
1362 if font_family:
1363 payload["font_family"] = font_family
1364
1365 for source, target in (("lang", "lang"), ("altLang", "alt_lang")):
1366 language = _effective_run_attr(r_pr, default_r_pr, source)
1367 if language and language.strip():
1368 payload[target] = language.strip()
1369 return payload
1370
1371
1372 def _cell_rich_paragraph_payloads(
1373 tx_body: ET.Element | None,
1374 palette: ColorPalette | None,
1375 theme_fonts: dict[str, str],
1376 ) -> list[dict[str, Any]] | None:
1377 """Materialize runs only when the legacy plain contract was insufficient."""
1378 if tx_body is None or not _legacy_text_body_has_unsupported_formatting(tx_body):
1379 return None
1380
1381 paragraphs: list[tuple[str | None, list[dict[str, Any]]]] = []
1382 style_signatures: set[tuple[tuple[str, Any], ...]] = set()
1383 needs_runs = False
1384 run_only_fields = {
1385 "italic", "underline", "strike", "font_family", "lang", "alt_lang",
1386 }
1387 for paragraph in tx_body.findall("a:p", NS):
1388 p_pr = paragraph.find("a:pPr", NS)
1389 align = p_pr.get("algn") if p_pr is not None else None
1390 if align not in {"l", "ctr", "r"}:
1391 align = None
1392 default_r_pr = p_pr.find("a:defRPr", NS) if p_pr is not None else None
1393 runs = [
1394 _native_run_payload(run, default_r_pr, palette, theme_fonts)
1395 for run in paragraph.findall("a:r", NS)
1396 ]
1397 paragraphs.append((align, runs))
1398 for run in runs:
1399 style = tuple(sorted((key, value) for key, value in run.items() if key != "text"))
1400 style_signatures.add(style)
1401 if run_only_fields.intersection(run):
1402 needs_runs = True
1403
1404 if len(style_signatures) > 1:
1405 needs_runs = True
1406 if not needs_runs:
1407 return None
1408
1409 payloads: list[dict[str, Any]] = []
1410 for align, runs in paragraphs:
1411 paragraph_payload: dict[str, Any]
1412 if runs:
1413 paragraph_payload = {"runs": runs}
1414 else:
1415 paragraph_payload = {"text": ""}
1416 if align is not None:
1417 paragraph_payload["align"] = align
1418 payloads.append(paragraph_payload)
1419 return payloads
1420
1421
1422 def _cell_fill_hex(tc_pr: ET.Element | None, palette: ColorPalette | None) -> str | None:
1423 fill = resolve_fill(tc_pr, palette)
1424 color = fill.attrs.get("fill") if fill.attrs else None
1425 if color and color.startswith("#"):
1426 return color
1427 return None
1428
1429
1430 def _cell_text_color(tx_body: ET.Element | None, palette: ColorPalette | None) -> str | None:
1431 for r_pr in _text_run_props_in_priority(tx_body):
1432 solid_fill = r_pr.find("a:solidFill", NS)
1433 if solid_fill is not None and not _solid_fill_is_unsafe(solid_fill, palette):
1434 try:
1435 color, _alpha = resolve_color(find_color_elem(solid_fill), palette)
1436 except (AttributeError, OverflowError, TypeError, ValueError):
1437 continue
1438 if color:
1439 return color
1440 return None
1441
1442
1443 def _cell_font_size_px(tx_body: ET.Element | None) -> int | float | None:
1444 for r_pr in _text_run_props_in_priority(tx_body):
1445 size = _canonical_source_font_size_px(r_pr.get("sz"))
1446 if size is not None:
1447 return size
1448 return None
1449
1450
1451 def _canonical_source_font_size_px(raw_size: str | None) -> int | float | None:
1452 """Return a writer-stable font size for one bounded DrawingML token."""
1453 if (
1454 raw_size is None
1455 or not raw_size.isascii()
1456 or not raw_size.isdigit()
1457 or len(raw_size) > 6
1458 ):
1459 return None
1460 size_hpt = int(raw_size)
1461 if not 100 <= size_hpt <= 400000:
1462 return None
1463 # The writer emits sizes at 0.1pt precision; canonicalize on first import
1464 # so source and native reimport payloads stay stable.
1465 canonical_hpt = round(size_hpt / 10) * 10
1466 return _round_payload_number(hundredths_pt_to_px(canonical_hpt))
1467
1468
1469 def _cell_align(tx_body: ET.Element | None) -> str | None:
1470 if tx_body is None:
1471 return None
1472 p_pr = tx_body.find("a:p/a:pPr", NS)
1473 align = p_pr.get("algn") if p_pr is not None else None
1474 if align in {"l", "ctr", "r"}:
1475 return align
1476 return None
1477
1478
1479 def _cell_valign(tc_pr: ET.Element | None) -> str | None:
1480 anchor = tc_pr.get("anchor") if tc_pr is not None else None
1481 return {
1482 "t": "top",
1483 "ctr": "middle",
1484 "b": "bottom",
1485 }.get(anchor)
1486
1487
1488 def _cell_bold(tx_body: ET.Element | None) -> bool | None:
1489 for r_pr in _text_run_props_in_priority(tx_body):
1490 if r_pr.get("b") is not None:
1491 return ooxml_bool(r_pr.get("b"))
1492 return None
1493
1494
1495 def _cell_borders_payload(
1496 tc_pr: ET.Element | None,
1497 palette: ColorPalette | None,
1498 ) -> dict[str, dict[str, Any]]:
1499 if tc_pr is None:
1500 return {}
1501 borders: dict[str, dict[str, Any]] = {}
1502 for border_tag, side in _DIRECT_BORDER_TAGS.items():
1503 ln = tc_pr.find(f"a:{border_tag}", NS)
1504 if ln is not None:
1505 borders[side] = _direct_border_payload(ln, palette)
1506 return borders
1507
1508
1509 def _text_run_props_in_priority(tx_body: ET.Element | None) -> list[ET.Element]:
1510 if tx_body is None:
1511 return []
1512 props: list[ET.Element] = []
1513 for path in (".//a:r/a:rPr", ".//a:pPr/a:defRPr", ".//a:endParaRPr"):
1514 r_pr = tx_body.find(path, NS)
1515 if r_pr is not None:
1516 props.append(r_pr)
1517 return props
1518
1519
1520 def _copy_cell_margins(tc_pr: ET.Element | None, cell: dict[str, Any]) -> None:
1521 if tc_pr is None:
1522 return
1523 for source, target in (
1524 ("marL", "padding_left"),
1525 ("marR", "padding_right"),
1526 ("marT", "padding_top"),
1527 ("marB", "padding_bottom"),
1528 ):
1529 if source not in tc_pr.attrib:
1530 continue
1531 value = _safe_emu_integer(tc_pr.attrib[source])
1532 if value is None or value < 0:
1533 continue
1534 cell[target] = _round_payload_number(emu_to_px(value))
1535
1536
1537 # ---------------------------------------------------------------------------
1538 # Cell text & borders
1539 # ---------------------------------------------------------------------------
1540
1541 def _convert_cell_text(
1542 tx_body: ET.Element,
1543 tcPr: ET.Element | None,
1544 cell_xfrm: Xfrm,
1545 palette: ColorPalette | None,
1546 theme_fonts: dict[str, str] | None,
1547 *,
1548 fallback_run_props: tuple[ET.Element, ...],
1549 slide_number: int | None,
1550 id_prefix: str,
1551 id_seq: list[int] | None,
1552 ):
1553 """Render cell text. PowerPoint's <a:tcPr> can override txBody insets via
1554 its own marL/marR/marT/marB attrs; convert_txbody reads from <a:bodyPr>,
1555 so we materialise a synthetic bodyPr when tcPr has its own insets. Invalid
1556 malformed source font-size and run-color values are removed from a private
1557 render copy; they have already been omitted from the native payload and
1558 must not crash fallback SVG generation."""
1559 render_tx_body = tx_body
1560 run_props = [
1561 node
1562 for node in tx_body.iter()
1563 if node.tag in {
1564 f"{{{NS['a']}}}defRPr",
1565 f"{{{NS['a']}}}endParaRPr",
1566 f"{{{NS['a']}}}rPr",
1567 }
1568 ]
1569 if any(
1570 _run_props_need_render_normalization(node, palette)
1571 for node in run_props
1572 ):
1573 render_tx_body = copy.deepcopy(tx_body)
1574 for node in render_tx_body.iter():
1575 if node.tag not in {
1576 f"{{{NS['a']}}}defRPr",
1577 f"{{{NS['a']}}}endParaRPr",
1578 f"{{{NS['a']}}}rPr",
1579 }:
1580 continue
1581 if (
1582 node.get("sz") is not None
1583 and _canonical_source_font_size_px(node.get("sz")) is None
1584 ):
1585 node.attrib.pop("sz", None)
1586 solid_fill = node.find("a:solidFill", NS)
1587 if solid_fill is not None and _solid_fill_is_unsafe(solid_fill, palette):
1588 node.remove(solid_fill)
1589
1590 body_pr = render_tx_body.find("a:bodyPr", NS)
1591 overrides = _tcPr_inset_overrides(tcPr)
1592 saved: dict[str, str | None] = {}
1593 if overrides and body_pr is not None:
1594 for key, val in overrides.items():
1595 saved[key] = body_pr.attrib.get(key)
1596 body_pr.set(key, val)
1597 try:
1598 try:
1599 return convert_txbody(
1600 render_tx_body, cell_xfrm, palette, theme_fonts=theme_fonts,
1601 fallback_run_props=fallback_run_props,
1602 slide_number=slide_number,
1603 id_prefix=id_prefix,
1604 id_seq=id_seq,
1605 )
1606 except (AttributeError, OverflowError, TypeError, ValueError):
1607 plain_tx_body = _plain_table_text_body(render_tx_body, overrides)
1608 return convert_txbody(
1609 plain_tx_body, cell_xfrm, palette, theme_fonts=theme_fonts,
1610 fallback_run_props=(),
1611 slide_number=slide_number,
1612 id_prefix=id_prefix,
1613 id_seq=id_seq,
1614 )
1615 finally:
1616 if overrides and body_pr is not None:
1617 for key, prior in saved.items():
1618 if prior is None:
1619 body_pr.attrib.pop(key, None)
1620 else:
1621 body_pr.set(key, prior)
1622
1623
1624 def _run_props_need_render_normalization(
1625 run_props: ET.Element,
1626 palette: ColorPalette | None,
1627 ) -> bool:
1628 size = run_props.get("sz")
1629 if size is not None and _canonical_source_font_size_px(size) is None:
1630 return True
1631 solid_fill = run_props.find("a:solidFill", NS)
1632 return solid_fill is not None and _solid_fill_is_unsafe(solid_fill, palette)
1633
1634
1635 def _solid_fill_is_unsafe(
1636 solid_fill: ET.Element,
1637 palette: ColorPalette | None,
1638 ) -> bool:
1639 color = find_color_elem(solid_fill)
1640 if color is not None and not _color_numeric_tokens_are_finite(color):
1641 return True
1642 try:
1643 resolve_color(color, palette)
1644 except (AttributeError, OverflowError, TypeError, ValueError):
1645 return True
1646 return False
1647
1648
1649 def _color_numeric_tokens_are_finite(color: ET.Element) -> bool:
1650 """Reject non-finite numeric tokens before the resolver can clamp them."""
1651 base_numeric_attrs = {
1652 "hslClr": ("hue", "sat", "lum"),
1653 "scrgbClr": ("r", "g", "b"),
1654 }
1655 modifier_tags = {
1656 "alpha", "alphaMod", "alphaOff", "hueMod", "hueOff",
1657 "lumMod", "lumOff", "satMod", "satOff", "shade", "tint",
1658 }
1659 for node in color.iter():
1660 name = node.tag.rsplit("}", 1)[-1]
1661 attrs = base_numeric_attrs.get(name, ())
1662 if name in modifier_tags:
1663 attrs = ("val",)
1664 for attr in attrs:
1665 raw = node.get(attr)
1666 if raw is None:
1667 return False
1668 try:
1669 value = float(raw)
1670 except (OverflowError, TypeError, ValueError):
1671 return False
1672 if not math.isfinite(value):
1673 return False
1674 return True
1675
1676
1677 def _plain_table_text_body(
1678 tx_body: ET.Element,
1679 overrides: dict[str, str],
1680 ) -> ET.Element:
1681 """Return a text-preserving style-free fallback after malformed styling."""
1682 plain = copy.deepcopy(tx_body)
1683 body_pr = plain.find("a:bodyPr", NS)
1684 if body_pr is None:
1685 body_pr = ET.Element(f"{{{NS['a']}}}bodyPr")
1686 plain.insert(0, body_pr)
1687 body_pr.clear()
1688 for key, value in overrides.items():
1689 body_pr.set(key, value)
1690 list_style = plain.find("a:lstStyle", NS)
1691 if list_style is not None:
1692 list_style.clear()
1693 for paragraph in plain.findall("a:p", NS):
1694 p_pr = paragraph.find("a:pPr", NS)
1695 if p_pr is not None:
1696 align = p_pr.get("algn")
1697 p_pr.clear()
1698 if align in {"l", "ctr", "r"}:
1699 p_pr.set("algn", align)
1700 for path in (".//a:rPr", ".//a:defRPr", ".//a:endParaRPr"):
1701 for run_props in plain.findall(path, NS):
1702 run_props.clear()
1703 return plain
1704
1705
1706 def _tcPr_inset_overrides(tcPr: ET.Element | None) -> dict[str, str]:
1707 if tcPr is None:
1708 return {}
1709 out: dict[str, str] = {}
1710 for src, dst in (("marL", "lIns"), ("marR", "rIns"),
1711 ("marT", "tIns"), ("marB", "bIns")):
1712 if src not in tcPr.attrib:
1713 continue
1714 value = _safe_emu_integer(tcPr.attrib[src])
1715 if value is not None and value >= 0:
1716 out[dst] = str(value)
1717 return out
1718
1719
1720 def _border_line(
1721 tcPr: ET.Element | None,
1722 table_style: _TableStyleContext,
1723 row_index: int,
1724 col_index: int,
1725 row_count: int,
1726 col_count: int,
1727 tag: str,
1728 x1: float, y1: float, x2: float, y2: float,
1729 palette: ColorPalette | None,
1730 *,
1731 id_prefix: str,
1732 id_seq: list[int],
1733 defs: list[str],
1734 ) -> str:
1735 """Emit a single border <line> for a given cell side, or empty string when
1736 that side is explicitly noFill / not specified."""
1737 ln = tcPr.find(tag, NS) if tcPr is not None else None
1738 if ln is not None:
1739 return _line_element_to_svg(
1740 ln, x1, y1, x2, y2, palette,
1741 id_prefix=id_prefix, id_seq=id_seq, defs=defs,
1742 )
1743
1744 # Draw inherited shared edges once, from the upper/left cell. This keeps
1745 # a specific firstRow bottom border from being painted over by the next
1746 # row's whole-table top border. A direct border above still wins because
1747 # it is handled before this de-duplication gate.
1748 if (tag == "a:lnT" and row_index > 0) or (
1749 tag == "a:lnL" and col_index > 0
1750 ):
1751 return ""
1752
1753 for region_name, region in table_style.regions_for_row(row_index):
1754 for border_name in _table_style_border_names(
1755 region_name, row_index, col_index, row_count, col_count, tag,
1756 ):
1757 ln = region.find(
1758 f"a:tcStyle/a:tcBdr/a:{border_name}/a:ln", NS,
1759 )
1760 if ln is not None:
1761 return _line_element_to_svg(
1762 ln, x1, y1, x2, y2, palette,
1763 id_prefix=id_prefix, id_seq=id_seq, defs=defs,
1764 )
1765 return ""
1766
1767
1768 def _table_style_border_names(
1769 region_name: str,
1770 row_index: int,
1771 col_index: int,
1772 row_count: int,
1773 col_count: int,
1774 tag: str,
1775 ) -> tuple[str, ...]:
1776 side_names = {
1777 "a:lnT": ("top", "insideH", row_index > 0),
1778 "a:lnR": ("right", "insideV", col_index < col_count - 1),
1779 "a:lnB": ("bottom", "insideH", row_index < row_count - 1),
1780 "a:lnL": ("left", "insideV", col_index > 0),
1781 }
1782 side, inside, is_internal = side_names[tag]
1783 if region_name == "wholeTbl" and is_internal:
1784 return inside, side
1785 return side, inside
1786
1787
1788 def _line_element_to_svg(
1789 ln: ET.Element,
1790 x1: float,
1791 y1: float,
1792 x2: float,
1793 y2: float,
1794 palette: ColorPalette | None,
1795 *,
1796 id_prefix: str,
1797 id_seq: list[int],
1798 defs: list[str],
1799 ) -> str:
1800 # Skip explicit no-line.
1801 if ln.find("a:noFill", NS) is not None:
1802 return ""
1803
1804 stroke = resolve_stroke(
1805 # resolve_stroke expects a parent that contains <a:ln>; wrap so it
1806 # finds our tag's own children as the line spec.
1807 _make_ln_wrapper(ln),
1808 palette,
1809 id_prefix=id_prefix,
1810 id_seq=id_seq,
1811 )
1812 defs.extend(stroke.defs)
1813 attrs = stroke.attrs
1814 if not attrs.get("stroke"):
1815 return ""
1816 attr_str = "".join(f' {k}="{v}"' for k, v in attrs.items())
1817 return (
1818 f'<line x1="{fmt_num(x1)}" y1="{fmt_num(y1)}" '
1819 f'x2="{fmt_num(x2)}" y2="{fmt_num(y2)}"{attr_str}/>'
1820 )
1821
1822
1823 def _make_ln_wrapper(ln: ET.Element) -> ET.Element:
1824 """resolve_stroke walks for ``parent.find('a:ln')``; tcPr borders ARE the
1825 <a:ln> already, so wrap them in a synthetic parent that points back at
1826 the original element under the expected tag.
1827 """
1828 wrapper = ET.Element(f"{{{NS['a']}}}wrapper")
1829 proxy = ET.SubElement(wrapper, f"{{{NS['a']}}}ln")
1830 # Carry attributes (e.g. w="...") and children (solidFill, prstDash, ...).
1831 for k, v in ln.attrib.items():
1832 proxy.set(k, v)
1833 for child in list(ln):
1834 proxy.append(child)
1835 return wrapper
1836
1836 lines PYTHON