| 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 |