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