"""Convert a DrawingML into SVG. Tables in PowerPoint are stored under with graphicData uri="...drawingml/2006/table" wrapping a single : ... ... ... ... or ... The graphicFrame's gives the table's slide-space position and total size; + heights distribute that size across columns/rows. Cell painting order: 1. background fill (rect at cell box) 2. text body (re-uses convert_txbody) 3. cell borders (lnT / lnR / lnB / lnL — stroked as separate s so neighbouring cells with different border styles render correctly) """ from __future__ import annotations import copy import math from dataclasses import dataclass from typing import Any from xml.etree import ElementTree as ET from pptx_effects import txbody_has_run_effects from .color_resolver import ColorPalette, find_color_elem, resolve_color from .emu_units import ( NS, Xfrm, emu_to_px, fmt_num, hundredths_pt_to_px, ooxml_bool, ) from .fill_to_svg import FillResult, resolve_fill from .ln_to_svg import resolve_stroke from .txbody_to_svg import _resolve_theme_typeface, convert_txbody BUILTIN_MEDIUM_STYLE_2_ACCENT_1 = "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}" _POWERPOINT_COORD_MIN = -(2**31) _POWERPOINT_COORD_MAX = 2**31 - 1 _BUILTIN_MEDIUM_STYLE_2_ACCENT_1_XML = ET.fromstring( f''' ''' ) @dataclass class TableResult: """Composite render output, replacement metadata, and effect diagnostics.""" svg: str = "" defs: list[str] = None native_payload: dict[str, Any] | None = None native_status: str | None = None effect_reason: str | None = None def __post_init__(self) -> None: if self.defs is None: self.defs = [] @dataclass(frozen=True) class _TableStyleContext: """Small, best-effort view of the table style regions we render.""" style: ET.Element | None table_properties: ET.Element | None def regions_for_row(self, row_index: int) -> tuple[tuple[str, ET.Element], ...]: if self.style is None: return () names: list[str] = [] first_row = bool( self.table_properties is not None and ooxml_bool(self.table_properties.get("firstRow")) ) if first_row and row_index == 0: names.append("firstRow") elif ( self.table_properties is not None and ooxml_bool(self.table_properties.get("bandRow")) ): band_index = row_index - (1 if first_row else 0) names.append("band1H" if band_index % 2 == 0 else "band2H") names.append("wholeTbl") regions: list[tuple[str, ET.Element]] = [] for name in names: region = self.style.find(f"a:{name}", NS) if region is not None: regions.append((name, region)) return tuple(regions) def _normalize_table_style_id(value: str | None) -> str: return (value or "").strip().strip("{}").upper() def _resolve_table_style( tbl: ET.Element, table_styles: ET.Element | None, ) -> _TableStyleContext: tbl_pr = tbl.find("a:tblPr", NS) style_id = ( tbl_pr.findtext("a:tableStyleId", default="", namespaces=NS).strip() if tbl_pr is not None else "" ) if not style_id and table_styles is not None: style_id = table_styles.get("def", "").strip() normalized_id = _normalize_table_style_id(style_id) supported_id = _normalize_table_style_id(BUILTIN_MEDIUM_STYLE_2_ACCENT_1) # P1 deliberately supports one built-in family. Consuming an arbitrary # custom definition here would be asymmetric: native reconstruction keeps # only the style id and does not copy custom tableStyles.xml definitions. if normalized_id != supported_id: return _TableStyleContext(None, tbl_pr) if table_styles is not None and normalized_id: for candidate in table_styles.findall("a:tblStyle", NS): if _normalize_table_style_id(candidate.get("styleId")) == normalized_id: return _TableStyleContext(candidate, tbl_pr) return _TableStyleContext(_BUILTIN_MEDIUM_STYLE_2_ACCENT_1_XML, tbl_pr) def _effective_cell_fill( tc_pr: ET.Element | None, table_style: _TableStyleContext, row_index: int, palette: ColorPalette | None, *, id_prefix: str, id_seq: list[int], ) -> FillResult: """Resolve direct cell fill before row-region and whole-table defaults.""" direct = resolve_fill( tc_pr, palette, id_prefix=id_prefix, id_seq=id_seq, ) if direct.attrs or direct.defs: return direct for _name, region in table_style.regions_for_row(row_index): fill_parent = region.find("a:tcStyle/a:fill", NS) if fill_parent is None: continue inherited = resolve_fill( fill_parent, palette, id_prefix=id_prefix, id_seq=id_seq, ) if inherited.attrs or inherited.defs: return inherited return direct def _table_text_run_props( table_style: _TableStyleContext, row_index: int, theme_fonts: dict[str, str], ) -> tuple[ET.Element, ...]: """Materialize table text-style regions as lowest-priority run defaults.""" props: list[ET.Element] = [] for _name, region in table_style.regions_for_row(row_index): tx_style = region.find("a:tcTxStyle", NS) if tx_style is None: continue run_props = _table_tx_style_run_props(tx_style, theme_fonts) if run_props is not None: props.append(run_props) return tuple(props) def _table_tx_style_run_props( tx_style: ET.Element, theme_fonts: dict[str, str], ) -> ET.Element | None: run_props = ET.Element(f"{{{NS['a']}}}rPr") for attr in ("b", "i"): value = tx_style.get(attr) if value is not None: run_props.set(attr, "1" if ooxml_bool(value) else "0") font_ref = tx_style.find("a:fontRef", NS) font_role = font_ref.get("idx") if font_ref is not None else None if font_role in {"major", "minor"}: prefix = "major" if font_role == "major" else "minor" latin = theme_fonts.get(f"{prefix}Latin") east_asia = theme_fonts.get(f"{prefix}EastAsia") or latin complex_script = theme_fonts.get(f"{prefix}ComplexScript") or latin for tag, typeface in ( ("latin", latin), ("ea", east_asia), ("cs", complex_script), ): if typeface: ET.SubElement( run_props, f"{{{NS['a']}}}{tag}", {"typeface": typeface}, ) color = find_color_elem(tx_style) if color is not None: solid_fill = ET.SubElement(run_props, f"{{{NS['a']}}}solidFill") solid_fill.append(copy.deepcopy(color)) if not run_props.attrib and not list(run_props): return None return run_props # --------------------------------------------------------------------------- # Public entry # --------------------------------------------------------------------------- def convert_tbl( tbl: ET.Element, xfrm: Xfrm, palette: ColorPalette | None, *, table_styles: ET.Element | None = None, theme_fonts: dict[str, str] | None = None, slide_number: int | None = None, id_prefix: str = "tbl", grad_seq: list[int] | None = None, marker_seq: list[int] | None = None, ) -> TableResult: """Render an at the given absolute xfrm into SVG markup.""" grad_seq = grad_seq if grad_seq is not None else [0] marker_seq = marker_seq if marker_seq is not None else [0] col_widths_px = _column_widths_px(tbl) if not col_widths_px: return TableResult() rows = tbl.findall("a:tr", NS) if not rows: return TableResult() table_style = _resolve_table_style(tbl, table_styles) row_heights_px = [_row_height_px(r) for r in rows] grid_topology_invalid = any( len(row.findall("a:tc", NS)) != len(col_widths_px) for row in rows ) source_geometry_invalid = ( grid_topology_invalid or any(width <= 0 for width in col_widths_px) or any(height <= 0 for height in row_heights_px) or sum(col_widths_px) <= 0 or sum(row_heights_px) <= 0 ) # PowerPoint's tblGrid widths and tr heights together describe the # *intrinsic* table size. The graphicFrame xfrm width/height may differ; # if it does, scale rows/columns proportionally so the table fills the # frame the way PowerPoint renders it. intrinsic_w = sum(col_widths_px) or xfrm.w intrinsic_h = sum(row_heights_px) or xfrm.h sx = (xfrm.w / intrinsic_w) if intrinsic_w else 1.0 sy = (xfrm.h / intrinsic_h) if intrinsic_h else 1.0 col_widths = [w * sx for w in col_widths_px] row_heights = [h * sy for h in row_heights_px] col_lefts = _cumulative_starts(xfrm.x, col_widths) row_tops = _cumulative_starts(xfrm.y, row_heights) # First pass: resolve merge state so spanned cells get the union geometry # and dropped cells don't render anything. PowerPoint expresses merges via # gridSpan/rowSpan on the anchor cell + hMerge/vMerge on the dropped cells. cells = _build_cell_grid(rows, len(col_widths)) merge_status = _canonical_native_merge_status(rows, len(col_widths)) effect_reason = ( "unsupported-run-effect-route:table-cell-text" if any( txbody_has_run_effects(tc.find("a:txBody", NS)) for tc in tbl.findall(".//a:tc", NS) ) else None ) if xfrm.rot or xfrm.flip_h or xfrm.flip_v: native_status = "unsupported-native-transform" elif merge_status: native_status = merge_status elif len(rows) > 1000 or len(col_widths) > 1000: native_status = "unsupported-table-size" elif source_geometry_invalid or ( any(width <= 0 for width in col_widths) or any(height <= 0 for height in row_heights) or sum(col_widths) <= 0 or sum(row_heights) <= 0 ): native_status = "unsupported-table-geometry" elif _table_has_unsupported_style(tbl): native_status = "unsupported-table-style" elif ( effect_reason or _table_has_unsupported_direct_formatting(tbl, palette) ): native_status = "unsupported-table-direct-formatting" else: native_status = None native_payload = ( None if native_status else _native_table_payload( tbl, xfrm, col_widths, row_heights, cells, palette, theme_fonts or {}, ) ) body_parts: list[str] = [] defs: list[str] = [] # Pass A: cell backgrounds. for r, row_cells in enumerate(cells): for c, cell in enumerate(row_cells): if cell is None or cell.is_dropped: continue rect_x = col_lefts[c] rect_y = row_tops[r] rect_w = sum(col_widths[c:c + cell.col_span]) rect_h = sum(row_heights[r:r + cell.row_span]) tcPr = cell.element.find("a:tcPr", NS) fill = _effective_cell_fill( tcPr, table_style, r, palette, id_prefix=f"{id_prefix}fill", id_seq=grad_seq, ) defs.extend(fill.defs) attrs = fill.attrs or {"fill": "none"} attr_str = "".join(f' {k}="{v}"' for k, v in attrs.items()) body_parts.append( f'' ) # Pass B: cell text. Cell xfrm uses default tcPr insets if none specified. for r, row_cells in enumerate(cells): for c, cell in enumerate(row_cells): if cell is None or cell.is_dropped: continue tx_body = cell.element.find("a:txBody", NS) if tx_body is None: continue tcPr = cell.element.find("a:tcPr", NS) cell_x = col_lefts[c] cell_y = row_tops[r] cell_w = sum(col_widths[c:c + cell.col_span]) cell_h = sum(row_heights[r:r + cell.row_span]) cell_xfrm = Xfrm(x=cell_x, y=cell_y, w=cell_w, h=cell_h) text_result = _convert_cell_text( tx_body, tcPr, cell_xfrm, palette, theme_fonts, fallback_run_props=_table_text_run_props( table_style, r, theme_fonts or {}, ), slide_number=slide_number, id_prefix=f"{id_prefix}txt", id_seq=grad_seq, ) defs.extend(text_result.defs) if text_result.svg: body_parts.append(text_result.svg) # Pass C: cell borders. Drawn last so they appear on top of fills/text. for r, row_cells in enumerate(cells): for c, cell in enumerate(row_cells): if cell is None or cell.is_dropped: continue cell_x = col_lefts[c] cell_y = row_tops[r] cell_w = sum(col_widths[c:c + cell.col_span]) cell_h = sum(row_heights[r:r + cell.row_span]) tcPr = cell.element.find("a:tcPr", NS) for tag, x1, y1, x2, y2 in ( ("a:lnT", cell_x, cell_y, cell_x + cell_w, cell_y), ("a:lnR", cell_x + cell_w, cell_y, cell_x + cell_w, cell_y + cell_h), ("a:lnB", cell_x, cell_y + cell_h, cell_x + cell_w, cell_y + cell_h), ("a:lnL", cell_x, cell_y, cell_x, cell_y + cell_h), ): line_xml = _border_line( tcPr, table_style, r, c, len(rows), len(col_widths), tag, x1, y1, x2, y2, palette, id_prefix=f"{id_prefix}stk", id_seq=marker_seq, defs=defs, ) if line_xml: body_parts.append(line_xml) return TableResult( svg="\n".join(body_parts), defs=defs, native_payload=native_payload, native_status=native_status, effect_reason=effect_reason, ) # --------------------------------------------------------------------------- # Geometry helpers # --------------------------------------------------------------------------- def _column_widths_px(tbl: ET.Element) -> list[float]: grid = tbl.find("a:tblGrid", NS) if grid is None: return [] widths: list[float] = [] for col in grid.findall("a:gridCol", NS): w_emu = col.attrib.get("w") if w_emu is None: widths.append(0.0) continue value = _safe_emu_integer(w_emu) widths.append(emu_to_px(value) if value is not None else 0.0) return widths def _row_height_px(row: ET.Element) -> float: h_emu = row.attrib.get("h") if h_emu is None: return 0.0 value = _safe_emu_integer(h_emu) return emu_to_px(value) if value is not None else 0.0 def _safe_emu_integer(raw_value: str) -> int | None: """Return a bounded ASCII DrawingML coordinate without float overflow.""" token = raw_value.strip(" \t\r\n") digits = token[1:] if token.startswith("-") else token if ( not digits or not digits.isascii() or not digits.isdigit() or len(digits) > 10 ): return None value = int(token) if not _POWERPOINT_COORD_MIN <= value <= _POWERPOINT_COORD_MAX: return None return value def _cumulative_starts(origin: float, sizes: list[float]) -> list[float]: out = [origin] acc = origin for size in sizes[:-1]: acc += size out.append(acc) return out # --------------------------------------------------------------------------- # Cell grid # --------------------------------------------------------------------------- @dataclass class _CellSlot: """Per-grid-position resolution of attributes.""" element: ET.Element col_span: int = 1 row_span: int = 1 is_dropped: bool = False # True for h/vMerge slaves: don't paint anything @dataclass(frozen=True) class _CanonicalMergeRegion: row: int col: int row_span: int col_span: int def _build_cell_grid(rows: list[ET.Element], col_count: int) -> list[list[_CellSlot | None]]: """Map each (row, col) to the that owns it. Anchor cells (the top-left of a merge) carry col_span/row_span; merged slaves are marked is_dropped so the renderer skips them. Cells not part of any merge get span 1×1. """ grid: list[list[_CellSlot | None]] = [[None] * col_count for _ in rows] for r, row in enumerate(rows): row_cells = row.findall("a:tc", NS) # PowerPoint writes one physical for every grid column, including # explicit hMerge/vMerge continuation cells. In that canonical form the # physical index is the grid column; advancing by gridSpan would consume # the continuation cell twice and shift every following cell left. explicit_grid = len(row_cells) >= col_count c = 0 for physical_col, tc in enumerate(row_cells): if explicit_grid: c = physical_col else: # Retain best-effort support for compact/non-canonical rows that # omit explicit merge continuation cells. while c < col_count and grid[r][c] is not None: c += 1 if c >= col_count: break grid_span = _safe_int(tc.attrib.get("gridSpan"), 1) row_span = _safe_int(tc.attrib.get("rowSpan"), 1) h_merge = ooxml_bool(tc.attrib.get("hMerge")) v_merge = ooxml_bool(tc.attrib.get("vMerge")) if h_merge or v_merge: # Merge slaves are physical cells but have no independent paint. grid[r][c] = _CellSlot(element=tc, is_dropped=True) c += 1 continue slot = _CellSlot( element=tc, col_span=max(grid_span, 1), row_span=max(row_span, 1), ) for dr in range(slot.row_span): for dc in range(slot.col_span): rr = r + dr cc = c + dc if rr >= len(rows) or cc >= col_count: continue if dr == 0 and dc == 0: grid[rr][cc] = slot else: grid[rr][cc] = _CellSlot( element=tc, is_dropped=True, ) c += 1 if explicit_grid else slot.col_span return grid def _safe_int(value: str | None, default: int) -> int: if value is None: return default try: return int(value) except ValueError: return default def _strict_merge_bool(value: str | None) -> bool: if value is None: return False normalized = value.strip().lower() if normalized in {"1", "on", "true"}: return True if normalized in {"0", "false", "off"}: return False raise ValueError("invalid OOXML boolean") def _strict_merge_span(value: str | None) -> int: if value is None: return 1 normalized = value.strip() if not normalized.isdigit(): raise ValueError("invalid OOXML span") span = int(normalized) if span <= 0: raise ValueError("invalid OOXML span") return span def _canonical_merge_slave_is_empty(tc: ET.Element) -> bool: tc_pr = tc.find("a:tcPr", NS) if tc_pr is None or tc_pr.attrib or list(tc_pr): return False tx_body = tc.find("a:txBody", NS) if tx_body is None: return False paragraph_count = 0 for child in tx_body: name = child.tag.rsplit("}", 1)[-1] if name in {"bodyPr", "lstStyle"}: if child.attrib or list(child): return False continue if name == "p" and not child.attrib and not list(child): paragraph_count += 1 continue return False return paragraph_count > 0 and not (tx_body.text or "").strip() def _canonical_native_merge_status( rows: list[ET.Element], col_count: int, ) -> str | None: """Accept only explicit rectangular merge topology safe for regeneration.""" physical_rows = [row.findall("a:tc", NS) for row in rows] merge_attrs = {"gridSpan", "rowSpan", "hMerge", "vMerge"} if not any( any(name in tc.attrib for name in merge_attrs) for row_cells in physical_rows for tc in row_cells ): return None if col_count <= 0 or any( len(row_cells) != col_count for row_cells in physical_rows ): return "unsupported-merge-topology" states: dict[tuple[int, int], tuple[ET.Element, int, int, bool, bool]] = {} try: for row_idx, row_cells in enumerate(physical_rows): for col_idx, tc in enumerate(row_cells): states[(row_idx, col_idx)] = ( tc, _strict_merge_span(tc.get("rowSpan")), _strict_merge_span(tc.get("gridSpan")), _strict_merge_bool(tc.get("hMerge")), _strict_merge_bool(tc.get("vMerge")), ) except ValueError: return "unsupported-merge-topology" anchors: list[_CanonicalMergeRegion] = [] for ( (row_idx, col_idx), (_tc, row_span, col_span, h_merge, v_merge), ) in states.items(): if not h_merge and not v_merge and (row_span > 1 or col_span > 1): anchors.append( _CanonicalMergeRegion(row_idx, col_idx, row_span, col_span) ) owners: dict[tuple[int, int], _CanonicalMergeRegion] = {} for region in anchors: if ( region.row + region.row_span > len(rows) or region.col + region.col_span > col_count ): return "unsupported-merge-topology" for covered_row in range(region.row, region.row + region.row_span): for covered_col in range(region.col, region.col + region.col_span): position = (covered_row, covered_col) if position in owners: return "unsupported-merge-topology" owners[position] = region for ( (row_idx, col_idx), (tc, row_span, col_span, h_merge, v_merge), ) in states.items(): region = owners.get((row_idx, col_idx)) if region is None: if h_merge or v_merge or row_span != 1 or col_span != 1: return "unsupported-merge-topology" continue is_anchor = row_idx == region.row and col_idx == region.col expected_row_span = region.row_span if row_idx == region.row else 1 expected_col_span = region.col_span if col_idx == region.col else 1 if ( row_span != expected_row_span or col_span != expected_col_span or h_merge != (col_idx > region.col) or v_merge != (row_idx > region.row) ): return "unsupported-merge-topology" if not is_anchor and not _canonical_merge_slave_is_empty(tc): return "unsupported-merge-topology" return None def _table_has_unsupported_style(tbl: ET.Element) -> bool: tbl_pr = tbl.find("a:tblPr", NS) if tbl_pr is None: return False allowed_attrs = { "firstRow", "bandRow", "firstCol", "lastCol", "lastRow", "bandCol", "rtl", } if any(name not in allowed_attrs for name in tbl_pr.attrib): return True if any( ooxml_bool(tbl_pr.attrib.get(name)) for name in ("firstCol", "lastCol", "lastRow", "bandCol", "rtl") ): return True return any( child.tag.rsplit("}", 1)[-1] != "tableStyleId" for child in tbl_pr ) _DIRECT_BORDER_TAGS = { "lnL": "left", "lnR": "right", "lnT": "top", "lnB": "bottom", } _DIRECT_BORDER_WIDTH_MAX = 20116800 _OPAQUE_COLOR_MODIFIERS = { "tint", "shade", "lumMod", "lumOff", "satMod", "satOff", } def _validate_opaque_border_color(color_elem: ET.Element | None) -> None: if color_elem is None: raise ValueError("missing border color") name = color_elem.tag.rsplit("}", 1)[-1] if name not in {"srgbClr", "schemeClr"} or set(color_elem.attrib) != {"val"}: raise ValueError("unsupported border color") for modifier in color_elem: modifier_name = modifier.tag.rsplit("}", 1)[-1] if ( modifier_name not in _OPAQUE_COLOR_MODIFIERS or set(modifier.attrib) != {"val"} or list(modifier) ): raise ValueError("unsupported border color modifier") value = modifier.get("val", "") if not value.isdigit() or not 0 <= int(value) <= 100000: raise ValueError("invalid border color modifier") def _direct_border_payload( ln: ET.Element, palette: ColorPalette | None, ) -> dict[str, Any]: if set(ln.attrib) - {"w", "cap", "cmpd", "algn"}: raise ValueError("unsupported border line attribute") if ln.get("cap") not in {None, "flat"}: raise ValueError("unsupported border cap") if ln.get("cmpd") not in {None, "sng"}: raise ValueError("unsupported border compound style") if ln.get("algn") not in {None, "ctr"}: raise ValueError("unsupported border alignment") width_emu: int | None = None if "w" in ln.attrib: raw_width = ln.get("w", "") if not raw_width.isdigit(): raise ValueError("invalid border width") width_emu = int(raw_width) if not 0 < width_emu <= _DIRECT_BORDER_WIDTH_MAX: raise ValueError("invalid border width") children = list(ln) child_names = [child.tag.rsplit("}", 1)[-1] for child in children] if child_names == ["noFill"]: no_fill = children[0] if no_fill.attrib or list(no_fill): raise ValueError("invalid noFill border") return {"style": "none"} decoration_names = {"round", "headEnd", "tailEnd"} if child_names.count("solidFill") != 1 or any( name not in {"solidFill", "prstDash", *decoration_names} for name in child_names ): raise ValueError("unsupported border paint") if ( child_names.count("prstDash") > 1 or any(child_names.count(name) > 1 for name in decoration_names) or width_emu is None ): raise ValueError("invalid solid border") dash = next( (child for child in children if child.tag.rsplit("}", 1)[-1] == "prstDash"), None, ) if dash is not None and ( dash.attrib != {"val": "solid"} or list(dash) ): raise ValueError("unsupported border dash") line_join = next( (child for child in children if child.tag.rsplit("}", 1)[-1] == "round"), None, ) if line_join is not None and (line_join.attrib or list(line_join)): raise ValueError("unsupported border line join") for endpoint_name in ("headEnd", "tailEnd"): endpoint = next( ( child for child in children if child.tag.rsplit("}", 1)[-1] == endpoint_name ), None, ) if endpoint is None: continue if ( set(endpoint.attrib) - {"type", "w", "len"} or endpoint.get("type") not in {None, "none"} or endpoint.get("w") not in {None, "med"} or endpoint.get("len") not in {None, "med"} or list(endpoint) ): raise ValueError("unsupported border endpoint") solid_fill = next( child for child in children if child.tag.rsplit("}", 1)[-1] == "solidFill" ) if solid_fill.attrib or len(list(solid_fill)) != 1: raise ValueError("invalid solid border fill") color_elem = find_color_elem(solid_fill) _validate_opaque_border_color(color_elem) try: color, alpha = resolve_color(color_elem, palette) except (TypeError, ValueError, OverflowError) as exc: raise ValueError("invalid solid border color") from exc if color is None or alpha != 1.0: raise ValueError("border color must resolve to opaque RGB") width = _round_payload_number(emu_to_px(str(width_emu))) if width <= 0: raise ValueError("border width is too small") return { "style": "solid", "color": color, "width": width, } def _table_has_unsupported_direct_formatting( tbl: ET.Element, palette: ColorPalette | None, ) -> bool: """Reject direct cell features the compact native schema cannot retain.""" for tc in tbl.findall(".//a:tc", NS): if _table_cell_has_unsupported_topology(tc): return True tc_pr = tc.find("a:tcPr", NS) if tc_pr is not None: allowed_attrs = {"marL", "marR", "marT", "marB", "anchor"} if any(name not in allowed_attrs for name in tc_pr.attrib): return True if tc_pr.get("anchor") not in {None, "t", "ctr", "b"}: return True if any( child.tag.rsplit("}", 1)[-1] not in {"solidFill", "noFill", *_DIRECT_BORDER_TAGS} for child in tc_pr ): return True fills = [ child for child in tc_pr if child.tag.rsplit("}", 1)[-1] in {"solidFill", "noFill"} ] if len(fills) > 1: return True no_fill = tc_pr.find("a:noFill", NS) if no_fill is not None and (no_fill.attrib or list(no_fill)): return True for border_tag in _DIRECT_BORDER_TAGS: borders = tc_pr.findall(f"a:{border_tag}", NS) if len(borders) > 1: return True if borders: try: _direct_border_payload(borders[0], palette) except ValueError: return True solid_fill = tc_pr.find("a:solidFill", NS) if solid_fill is not None: if solid_fill.find(".//a:alpha", NS) is not None: return True if _cell_fill_hex(tc_pr, palette) is None: return True tx_body = tc.find("a:txBody", NS) if _text_body_has_unsupported_formatting(tx_body): return True return False def _table_cell_has_unsupported_topology(tc: ET.Element) -> bool: """Accept only the closed optional txBody -> optional tcPr cell sequence.""" if any( name not in {"gridSpan", "rowSpan", "hMerge", "vMerge"} for name in tc.attrib ): return True tx_body_tag = f"{{{NS['a']}}}txBody" tc_pr_tag = f"{{{NS['a']}}}tcPr" child_tags = [child.tag for child in tc] child_index = 0 if child_tags[:1] == [tx_body_tag]: child_index += 1 if child_tags[child_index:child_index + 1] == [tc_pr_tag]: child_index += 1 return child_index != len(child_tags) def _text_body_has_unsupported_formatting(tx_body: ET.Element | None) -> bool: if tx_body is None: return False if tx_body.attrib: return True body_pr_tag = f"{{{NS['a']}}}bodyPr" list_style_tag = f"{{{NS['a']}}}lstStyle" paragraph_tag = f"{{{NS['a']}}}p" body_tags = [child.tag for child in tx_body] body_index = 0 if not body_tags or body_tags[0] != body_pr_tag: return True body_index += 1 if body_index < len(body_tags) and body_tags[body_index] == list_style_tag: body_index += 1 if body_index == len(body_tags) or any( tag != paragraph_tag for tag in body_tags[body_index:] ): return True relationship_prefix = f"{{{NS['r']}}}" forbidden_run_children = { f"{{{NS['a']}}}extLst", f"{{{NS['a']}}}hlinkClick", f"{{{NS['a']}}}hlinkMouseOver", } if any( node.tag in forbidden_run_children or any(name.startswith(relationship_prefix) for name in node.attrib) for node in tx_body.iter() ): return True body_pr = tx_body.find("a:bodyPr", NS) if body_pr is None or body_pr.attrib or list(body_pr): return True list_style = tx_body.find("a:lstStyle", NS) if list_style is not None and (list_style.attrib or list(list_style)): return True for paragraph in tx_body.findall("a:p", NS): if paragraph.attrib: return True p_pr_tag = f"{{{NS['a']}}}pPr" run_tag = f"{{{NS['a']}}}r" end_r_pr_tag = f"{{{NS['a']}}}endParaRPr" direct_tags = [child.tag for child in paragraph] paragraph_index = 0 if direct_tags[:1] == [p_pr_tag]: paragraph_index += 1 while ( paragraph_index < len(direct_tags) and direct_tags[paragraph_index] == run_tag ): paragraph_index += 1 if ( paragraph_index < len(direct_tags) and direct_tags[paragraph_index] == end_r_pr_tag ): paragraph_index += 1 if paragraph_index != len(direct_tags): return True p_pr = paragraph.find("a:pPr", NS) if p_pr is not None: p_pr_tags = [child.tag.rsplit("}", 1)[-1] for child in p_pr] if p_pr_tags.count("defRPr") > 1 or p_pr_tags.count("buNone") > 1: return True if any(tag.startswith("bu") and tag != "buNone" for tag in p_pr_tags): return True for run in paragraph.findall("a:r", NS): if run.attrib: return True r_pr_tag = f"{{{NS['a']}}}rPr" text_tag = f"{{{NS['a']}}}t" run_tags = [child.tag for child in run] if run_tags not in ([text_tag], [r_pr_tag, text_tag]): return True text_node = run.find("a:t", NS) if text_node is None or list(text_node): return True allowed_text_attrs = {"{http://www.w3.org/XML/1998/namespace}space"} if any(name not in allowed_text_attrs for name in text_node.attrib): return True return False def _legacy_text_body_has_unsupported_formatting( tx_body: ET.Element | None, ) -> bool: """Return the pre-P2-T4 gate so active plain payloads stay unchanged.""" if tx_body is None: return False body_pr = tx_body.find("a:bodyPr", NS) if body_pr is not None and (body_pr.attrib or list(body_pr)): return True list_style = tx_body.find("a:lstStyle", NS) if list_style is not None and (list_style.attrib or list(list_style)): return True if ( tx_body.find(".//a:br", NS) is not None or tx_body.find(".//a:fld", NS) is not None or tx_body.find(".//a:tab", NS) is not None ): return True run_signatures: set[tuple[str | None, str | None, bytes | None]] = set() for paragraph in tx_body.findall("a:p", NS): p_pr = paragraph.find("a:pPr", NS) alignment = p_pr.get("algn") if p_pr is not None else None if alignment not in {None, "l", "ctr", "r"}: return True if p_pr is not None: if any(name != "algn" for name in p_pr.attrib): return True if any( child.tag.rsplit("}", 1)[-1] not in {"defRPr", "buNone"} for child in p_pr ): return True default_r_pr = p_pr.find("a:defRPr", NS) if p_pr is not None else None if _legacy_run_props_have_unsupported_formatting(default_r_pr): return True for run in paragraph.findall("a:r", NS): r_pr = run.find("a:rPr", NS) if _legacy_run_props_have_unsupported_formatting(r_pr): return True run_signatures.add( _legacy_effective_run_signature(r_pr, default_r_pr) ) end_r_pr = paragraph.find("a:endParaRPr", NS) if _legacy_run_props_have_unsupported_formatting(end_r_pr): return True if not paragraph.findall("a:r", NS) and end_r_pr is not None: run_signatures.add( _legacy_effective_run_signature(end_r_pr, default_r_pr) ) return len(run_signatures) > 1 def _legacy_run_props_have_unsupported_formatting( r_pr: ET.Element | None, ) -> bool: if r_pr is None: return False if ooxml_bool(r_pr.get("i")): return True if r_pr.get("u") not in {None, "none"}: return True if r_pr.get("strike") not in {None, "noStrike"}: return True if r_pr.get("baseline") not in {None, "0"}: return True if r_pr.get("cap") not in {None, "none"}: return True if r_pr.get("spc") not in {None, "0"}: return True allowed_attrs = { "lang", "altLang", "sz", "b", "i", "u", "strike", "dirty", "baseline", "cap", "spc", } if any(name not in allowed_attrs for name in r_pr.attrib): return True solid_fill = r_pr.find("a:solidFill", NS) if solid_fill is not None and solid_fill.find(".//a:alpha", NS) is not None: return True return any( child.tag.rsplit("}", 1)[-1] != "solidFill" for child in r_pr ) def _legacy_effective_run_signature( r_pr: ET.Element | None, default_r_pr: ET.Element | None, ) -> tuple[str | None, str | None, bytes | None]: def attr(name: str) -> str | None: if r_pr is not None and r_pr.get(name) is not None: return r_pr.get(name) return default_r_pr.get(name) if default_r_pr is not None else None solid_fill = r_pr.find("a:solidFill", NS) if r_pr is not None else None if solid_fill is None and default_r_pr is not None: solid_fill = default_r_pr.find("a:solidFill", NS) fill_xml = ( ET.tostring(solid_fill, encoding="utf-8") if solid_fill is not None else None ) return attr("b"), attr("sz"), fill_xml def _round_payload_number(value: float) -> int | float: rounded = round(float(value), 3) return int(rounded) if rounded.is_integer() else rounded def _native_table_payload( tbl: ET.Element, xfrm: Xfrm, column_widths: list[float], row_heights: list[float], cells: list[list[_CellSlot | None]], palette: ColorPalette | None, theme_fonts: dict[str, str], ) -> dict[str, Any]: """Build the SVG native Table replacement payload for an unmerged table.""" tbl_pr = tbl.find("a:tblPr", NS) payload: dict[str, Any] = { "x": _round_payload_number(xfrm.x), "y": _round_payload_number(xfrm.y), "width": _round_payload_number(xfrm.w), "height": _round_payload_number(xfrm.h), "strict_grid": True, "header_rows": ( 1 if tbl_pr is not None and ooxml_bool(tbl_pr.get("firstRow")) else 0 ), "column_widths": [_round_payload_number(width) for width in column_widths], "row_heights": [_round_payload_number(height) for height in row_heights], "rows": [], } style: dict[str, Any] = { "band_row": bool(tbl_pr is not None and ooxml_bool(tbl_pr.get("bandRow"))), } if tbl_pr is not None: table_style_id = tbl_pr.findtext("a:tableStyleId", default="", namespaces=NS).strip() if table_style_id: style["table_style_id"] = table_style_id payload["style"] = style rows_payload: list[list[Any]] = [] for row_cells in cells: row_payload: list[Any] = [] for slot in row_cells: if slot is None or slot.is_dropped: row_payload.append("") continue cell_payload = _native_cell_payload( slot.element, palette, theme_fonts, ) if slot.row_span > 1: cell_payload["row_span"] = slot.row_span if slot.col_span > 1: cell_payload["col_span"] = slot.col_span row_payload.append(cell_payload) rows_payload.append(row_payload) payload["rows"] = rows_payload return payload def _native_cell_payload( tc: ET.Element, palette: ColorPalette | None, theme_fonts: dict[str, str], ) -> dict[str, Any]: tx_body = tc.find("a:txBody", NS) tc_pr = tc.find("a:tcPr", NS) paragraph_payloads = _cell_paragraph_payloads(tx_body) rich_paragraphs = _cell_rich_paragraph_payloads( tx_body, palette, theme_fonts, ) if rich_paragraphs is not None: cell: dict[str, Any] = {"paragraphs": rich_paragraphs} elif len(paragraph_payloads) > 1: cell: dict[str, Any] = {"paragraphs": paragraph_payloads} else: cell = {"text": _cell_plain_text(tx_body)} fill = _cell_fill_hex(tc_pr, palette) if fill: cell["fill"] = fill if rich_paragraphs is None: color = _cell_text_color(tx_body, palette) if color: cell["color"] = color font_size = _cell_font_size_px(tx_body) if font_size: cell["font_size"] = font_size if len(paragraph_payloads) <= 1: align = _cell_align(tx_body) if align: cell["align"] = align valign = _cell_valign(tc_pr) if valign: cell["valign"] = valign if rich_paragraphs is None: bold = _cell_bold(tx_body) if bold is not None: cell["bold"] = bold borders = _cell_borders_payload(tc_pr, palette) if borders: cell["borders"] = borders _copy_cell_margins(tc_pr, cell) return cell def _cell_plain_text(tx_body: ET.Element | None) -> str: if tx_body is None: return "" paragraphs: list[str] = [] for paragraph in tx_body.findall("a:p", NS): text = "".join(node.text or "" for node in paragraph.findall(".//a:t", NS)) if text: paragraphs.append(text) return "\n".join(paragraphs) def _cell_paragraph_payloads( tx_body: ET.Element | None, ) -> list[str | dict[str, str]]: if tx_body is None: return [] payloads: list[str | dict[str, str]] = [] for paragraph in tx_body.findall("a:p", NS): text = "".join(node.text or "" for node in paragraph.findall(".//a:t", NS)) p_pr = paragraph.find("a:pPr", NS) align = p_pr.get("algn") if p_pr is not None else None if align in {"l", "ctr", "r"}: payloads.append({"text": text, "align": align}) else: payloads.append(text) return payloads def _effective_run_attr( r_pr: ET.Element | None, default_r_pr: ET.Element | None, name: str, ) -> str | None: if r_pr is not None and r_pr.get(name) is not None: return r_pr.get(name) return default_r_pr.get(name) if default_r_pr is not None else None def _effective_run_child( r_pr: ET.Element | None, default_r_pr: ET.Element | None, name: str, ) -> ET.Element | None: child = r_pr.find(f"a:{name}", NS) if r_pr is not None else None if child is not None: return child return default_r_pr.find(f"a:{name}", NS) if default_r_pr is not None else None def _native_run_font_family( r_pr: ET.Element | None, default_r_pr: ET.Element | None, theme_fonts: dict[str, str], ) -> str | None: faces: list[str] = [] for tag in ("latin", "ea"): node = _effective_run_child(r_pr, default_r_pr, tag) raw_face = node.get("typeface") if node is not None else None face = _resolve_theme_typeface(raw_face, theme_fonts) if face and face not in faces: faces.append(face) if len(faces) != 1: return None face = faces[0].strip() return face if face and "," not in face else None def _native_run_payload( run: ET.Element, default_r_pr: ET.Element | None, palette: ColorPalette | None, theme_fonts: dict[str, str], ) -> dict[str, Any]: r_pr = run.find("a:rPr", NS) text = run.findtext("a:t", default="", namespaces=NS) payload: dict[str, Any] = {"text": text} for source, target in (("b", "bold"), ("i", "italic")): raw = _effective_run_attr(r_pr, default_r_pr, source) if raw is not None: payload[target] = ooxml_bool(raw) underline = _effective_run_attr(r_pr, default_r_pr, "u") if underline is not None: payload["underline"] = underline != "none" strike = _effective_run_attr(r_pr, default_r_pr, "strike") if strike is not None: payload["strike"] = strike != "noStrike" font_size = _canonical_source_font_size_px( _effective_run_attr(r_pr, default_r_pr, "sz") ) if font_size is not None: payload["font_size"] = font_size solid_fill = _effective_run_child(r_pr, default_r_pr, "solidFill") if solid_fill is not None and not _solid_fill_is_unsafe(solid_fill, palette): try: color, _alpha = resolve_color(find_color_elem(solid_fill), palette) except (AttributeError, OverflowError, TypeError, ValueError): color = None if color: payload["color"] = color font_family = _native_run_font_family( r_pr, default_r_pr, theme_fonts, ) if font_family: payload["font_family"] = font_family for source, target in (("lang", "lang"), ("altLang", "alt_lang")): language = _effective_run_attr(r_pr, default_r_pr, source) if language and language.strip(): payload[target] = language.strip() return payload def _cell_rich_paragraph_payloads( tx_body: ET.Element | None, palette: ColorPalette | None, theme_fonts: dict[str, str], ) -> list[dict[str, Any]] | None: """Materialize runs only when the legacy plain contract was insufficient.""" if tx_body is None or not _legacy_text_body_has_unsupported_formatting(tx_body): return None paragraphs: list[tuple[str | None, list[dict[str, Any]]]] = [] style_signatures: set[tuple[tuple[str, Any], ...]] = set() needs_runs = False run_only_fields = { "italic", "underline", "strike", "font_family", "lang", "alt_lang", } for paragraph in tx_body.findall("a:p", NS): p_pr = paragraph.find("a:pPr", NS) align = p_pr.get("algn") if p_pr is not None else None if align not in {"l", "ctr", "r"}: align = None default_r_pr = p_pr.find("a:defRPr", NS) if p_pr is not None else None runs = [ _native_run_payload(run, default_r_pr, palette, theme_fonts) for run in paragraph.findall("a:r", NS) ] paragraphs.append((align, runs)) for run in runs: style = tuple(sorted((key, value) for key, value in run.items() if key != "text")) style_signatures.add(style) if run_only_fields.intersection(run): needs_runs = True if len(style_signatures) > 1: needs_runs = True if not needs_runs: return None payloads: list[dict[str, Any]] = [] for align, runs in paragraphs: paragraph_payload: dict[str, Any] if runs: paragraph_payload = {"runs": runs} else: paragraph_payload = {"text": ""} if align is not None: paragraph_payload["align"] = align payloads.append(paragraph_payload) return payloads def _cell_fill_hex(tc_pr: ET.Element | None, palette: ColorPalette | None) -> str | None: fill = resolve_fill(tc_pr, palette) color = fill.attrs.get("fill") if fill.attrs else None if color and color.startswith("#"): return color return None def _cell_text_color(tx_body: ET.Element | None, palette: ColorPalette | None) -> str | None: for r_pr in _text_run_props_in_priority(tx_body): solid_fill = r_pr.find("a:solidFill", NS) if solid_fill is not None and not _solid_fill_is_unsafe(solid_fill, palette): try: color, _alpha = resolve_color(find_color_elem(solid_fill), palette) except (AttributeError, OverflowError, TypeError, ValueError): continue if color: return color return None def _cell_font_size_px(tx_body: ET.Element | None) -> int | float | None: for r_pr in _text_run_props_in_priority(tx_body): size = _canonical_source_font_size_px(r_pr.get("sz")) if size is not None: return size return None def _canonical_source_font_size_px(raw_size: str | None) -> int | float | None: """Return a writer-stable font size for one bounded DrawingML token.""" if ( raw_size is None or not raw_size.isascii() or not raw_size.isdigit() or len(raw_size) > 6 ): return None size_hpt = int(raw_size) if not 100 <= size_hpt <= 400000: return None # The writer emits sizes at 0.1pt precision; canonicalize on first import # so source and native reimport payloads stay stable. canonical_hpt = round(size_hpt / 10) * 10 return _round_payload_number(hundredths_pt_to_px(canonical_hpt)) def _cell_align(tx_body: ET.Element | None) -> str | None: if tx_body is None: return None p_pr = tx_body.find("a:p/a:pPr", NS) align = p_pr.get("algn") if p_pr is not None else None if align in {"l", "ctr", "r"}: return align return None def _cell_valign(tc_pr: ET.Element | None) -> str | None: anchor = tc_pr.get("anchor") if tc_pr is not None else None return { "t": "top", "ctr": "middle", "b": "bottom", }.get(anchor) def _cell_bold(tx_body: ET.Element | None) -> bool | None: for r_pr in _text_run_props_in_priority(tx_body): if r_pr.get("b") is not None: return ooxml_bool(r_pr.get("b")) return None def _cell_borders_payload( tc_pr: ET.Element | None, palette: ColorPalette | None, ) -> dict[str, dict[str, Any]]: if tc_pr is None: return {} borders: dict[str, dict[str, Any]] = {} for border_tag, side in _DIRECT_BORDER_TAGS.items(): ln = tc_pr.find(f"a:{border_tag}", NS) if ln is not None: borders[side] = _direct_border_payload(ln, palette) return borders def _text_run_props_in_priority(tx_body: ET.Element | None) -> list[ET.Element]: if tx_body is None: return [] props: list[ET.Element] = [] for path in (".//a:r/a:rPr", ".//a:pPr/a:defRPr", ".//a:endParaRPr"): r_pr = tx_body.find(path, NS) if r_pr is not None: props.append(r_pr) return props def _copy_cell_margins(tc_pr: ET.Element | None, cell: dict[str, Any]) -> None: if tc_pr is None: return for source, target in ( ("marL", "padding_left"), ("marR", "padding_right"), ("marT", "padding_top"), ("marB", "padding_bottom"), ): if source not in tc_pr.attrib: continue value = _safe_emu_integer(tc_pr.attrib[source]) if value is None or value < 0: continue cell[target] = _round_payload_number(emu_to_px(value)) # --------------------------------------------------------------------------- # Cell text & borders # --------------------------------------------------------------------------- def _convert_cell_text( tx_body: ET.Element, tcPr: ET.Element | None, cell_xfrm: Xfrm, palette: ColorPalette | None, theme_fonts: dict[str, str] | None, *, fallback_run_props: tuple[ET.Element, ...], slide_number: int | None, id_prefix: str, id_seq: list[int] | None, ): """Render cell text. PowerPoint's can override txBody insets via its own marL/marR/marT/marB attrs; convert_txbody reads from , so we materialise a synthetic bodyPr when tcPr has its own insets. Invalid malformed source font-size and run-color values are removed from a private render copy; they have already been omitted from the native payload and must not crash fallback SVG generation.""" render_tx_body = tx_body run_props = [ node for node in tx_body.iter() if node.tag in { f"{{{NS['a']}}}defRPr", f"{{{NS['a']}}}endParaRPr", f"{{{NS['a']}}}rPr", } ] if any( _run_props_need_render_normalization(node, palette) for node in run_props ): render_tx_body = copy.deepcopy(tx_body) for node in render_tx_body.iter(): if node.tag not in { f"{{{NS['a']}}}defRPr", f"{{{NS['a']}}}endParaRPr", f"{{{NS['a']}}}rPr", }: continue if ( node.get("sz") is not None and _canonical_source_font_size_px(node.get("sz")) is None ): node.attrib.pop("sz", None) solid_fill = node.find("a:solidFill", NS) if solid_fill is not None and _solid_fill_is_unsafe(solid_fill, palette): node.remove(solid_fill) body_pr = render_tx_body.find("a:bodyPr", NS) overrides = _tcPr_inset_overrides(tcPr) saved: dict[str, str | None] = {} if overrides and body_pr is not None: for key, val in overrides.items(): saved[key] = body_pr.attrib.get(key) body_pr.set(key, val) try: try: return convert_txbody( render_tx_body, cell_xfrm, palette, theme_fonts=theme_fonts, fallback_run_props=fallback_run_props, slide_number=slide_number, id_prefix=id_prefix, id_seq=id_seq, ) except (AttributeError, OverflowError, TypeError, ValueError): plain_tx_body = _plain_table_text_body(render_tx_body, overrides) return convert_txbody( plain_tx_body, cell_xfrm, palette, theme_fonts=theme_fonts, fallback_run_props=(), slide_number=slide_number, id_prefix=id_prefix, id_seq=id_seq, ) finally: if overrides and body_pr is not None: for key, prior in saved.items(): if prior is None: body_pr.attrib.pop(key, None) else: body_pr.set(key, prior) def _run_props_need_render_normalization( run_props: ET.Element, palette: ColorPalette | None, ) -> bool: size = run_props.get("sz") if size is not None and _canonical_source_font_size_px(size) is None: return True solid_fill = run_props.find("a:solidFill", NS) return solid_fill is not None and _solid_fill_is_unsafe(solid_fill, palette) def _solid_fill_is_unsafe( solid_fill: ET.Element, palette: ColorPalette | None, ) -> bool: color = find_color_elem(solid_fill) if color is not None and not _color_numeric_tokens_are_finite(color): return True try: resolve_color(color, palette) except (AttributeError, OverflowError, TypeError, ValueError): return True return False def _color_numeric_tokens_are_finite(color: ET.Element) -> bool: """Reject non-finite numeric tokens before the resolver can clamp them.""" base_numeric_attrs = { "hslClr": ("hue", "sat", "lum"), "scrgbClr": ("r", "g", "b"), } modifier_tags = { "alpha", "alphaMod", "alphaOff", "hueMod", "hueOff", "lumMod", "lumOff", "satMod", "satOff", "shade", "tint", } for node in color.iter(): name = node.tag.rsplit("}", 1)[-1] attrs = base_numeric_attrs.get(name, ()) if name in modifier_tags: attrs = ("val",) for attr in attrs: raw = node.get(attr) if raw is None: return False try: value = float(raw) except (OverflowError, TypeError, ValueError): return False if not math.isfinite(value): return False return True def _plain_table_text_body( tx_body: ET.Element, overrides: dict[str, str], ) -> ET.Element: """Return a text-preserving style-free fallback after malformed styling.""" plain = copy.deepcopy(tx_body) body_pr = plain.find("a:bodyPr", NS) if body_pr is None: body_pr = ET.Element(f"{{{NS['a']}}}bodyPr") plain.insert(0, body_pr) body_pr.clear() for key, value in overrides.items(): body_pr.set(key, value) list_style = plain.find("a:lstStyle", NS) if list_style is not None: list_style.clear() for paragraph in plain.findall("a:p", NS): p_pr = paragraph.find("a:pPr", NS) if p_pr is not None: align = p_pr.get("algn") p_pr.clear() if align in {"l", "ctr", "r"}: p_pr.set("algn", align) for path in (".//a:rPr", ".//a:defRPr", ".//a:endParaRPr"): for run_props in plain.findall(path, NS): run_props.clear() return plain def _tcPr_inset_overrides(tcPr: ET.Element | None) -> dict[str, str]: if tcPr is None: return {} out: dict[str, str] = {} for src, dst in (("marL", "lIns"), ("marR", "rIns"), ("marT", "tIns"), ("marB", "bIns")): if src not in tcPr.attrib: continue value = _safe_emu_integer(tcPr.attrib[src]) if value is not None and value >= 0: out[dst] = str(value) return out def _border_line( tcPr: ET.Element | None, table_style: _TableStyleContext, row_index: int, col_index: int, row_count: int, col_count: int, tag: str, x1: float, y1: float, x2: float, y2: float, palette: ColorPalette | None, *, id_prefix: str, id_seq: list[int], defs: list[str], ) -> str: """Emit a single border for a given cell side, or empty string when that side is explicitly noFill / not specified.""" ln = tcPr.find(tag, NS) if tcPr is not None else None if ln is not None: return _line_element_to_svg( ln, x1, y1, x2, y2, palette, id_prefix=id_prefix, id_seq=id_seq, defs=defs, ) # Draw inherited shared edges once, from the upper/left cell. This keeps # a specific firstRow bottom border from being painted over by the next # row's whole-table top border. A direct border above still wins because # it is handled before this de-duplication gate. if (tag == "a:lnT" and row_index > 0) or ( tag == "a:lnL" and col_index > 0 ): return "" for region_name, region in table_style.regions_for_row(row_index): for border_name in _table_style_border_names( region_name, row_index, col_index, row_count, col_count, tag, ): ln = region.find( f"a:tcStyle/a:tcBdr/a:{border_name}/a:ln", NS, ) if ln is not None: return _line_element_to_svg( ln, x1, y1, x2, y2, palette, id_prefix=id_prefix, id_seq=id_seq, defs=defs, ) return "" def _table_style_border_names( region_name: str, row_index: int, col_index: int, row_count: int, col_count: int, tag: str, ) -> tuple[str, ...]: side_names = { "a:lnT": ("top", "insideH", row_index > 0), "a:lnR": ("right", "insideV", col_index < col_count - 1), "a:lnB": ("bottom", "insideH", row_index < row_count - 1), "a:lnL": ("left", "insideV", col_index > 0), } side, inside, is_internal = side_names[tag] if region_name == "wholeTbl" and is_internal: return inside, side return side, inside def _line_element_to_svg( ln: ET.Element, x1: float, y1: float, x2: float, y2: float, palette: ColorPalette | None, *, id_prefix: str, id_seq: list[int], defs: list[str], ) -> str: # Skip explicit no-line. if ln.find("a:noFill", NS) is not None: return "" stroke = resolve_stroke( # resolve_stroke expects a parent that contains ; wrap so it # finds our tag's own children as the line spec. _make_ln_wrapper(ln), palette, id_prefix=id_prefix, id_seq=id_seq, ) defs.extend(stroke.defs) attrs = stroke.attrs if not attrs.get("stroke"): return "" attr_str = "".join(f' {k}="{v}"' for k, v in attrs.items()) return ( f'' ) def _make_ln_wrapper(ln: ET.Element) -> ET.Element: """resolve_stroke walks for ``parent.find('a:ln')``; tcPr borders ARE the already, so wrap them in a synthetic parent that points back at the original element under the expected tag. """ wrapper = ET.Element(f"{{{NS['a']}}}wrapper") proxy = ET.SubElement(wrapper, f"{{{NS['a']}}}ln") # Carry attributes (e.g. w="...") and children (solidFill, prstDash, ...). for k, v in ln.attrib.items(): proxy.set(k, v) for child in list(ln): proxy.append(child) return wrapper