Files

1836 lines
62 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Convert a DrawingML <a:tbl> into SVG.
Tables in PowerPoint are stored under <p:graphicFrame> with
graphicData uri="...drawingml/2006/table" wrapping a single <a:tbl>:
<p:graphicFrame>
<p:xfrm>...</p:xfrm>
<a:graphic><a:graphicData uri="...table">
<a:tbl>
<a:tblPr/>
<a:tblGrid>
<a:gridCol w="..."/>...
</a:tblGrid>
<a:tr h="...">
<a:tc [gridSpan=N] [rowSpan=N] [hMerge=1] [vMerge=1]>
<a:txBody>...</a:txBody>
<a:tcPr>
<a:lnL/><a:lnR/><a:lnT/><a:lnB/>
<a:solidFill/>... or <a:gradFill/> ...
</a:tcPr>
</a:tc>
</a:tr>
</a:tbl>
</a:graphicData></a:graphic>
</p:graphicFrame>
The graphicFrame's <p:xfrm> gives the table's slide-space position and total
size; <a:tblGrid> + <a:tr> 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 <line>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'''<a:tblStyle xmlns:a="{NS["a"]}"
styleId="{BUILTIN_MEDIUM_STYLE_2_ACCENT_1}">
<a:wholeTbl>
<a:tcTxStyle>
<a:fontRef idx="minor"><a:prstClr val="black"/></a:fontRef>
<a:schemeClr val="dk1"/>
</a:tcTxStyle>
<a:tcStyle>
<a:tcBdr>
<a:left><a:ln w="12700"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill></a:ln></a:left>
<a:right><a:ln w="12700"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill></a:ln></a:right>
<a:top><a:ln w="12700"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill></a:ln></a:top>
<a:bottom><a:ln w="12700"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill></a:ln></a:bottom>
<a:insideH><a:ln w="12700"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill></a:ln></a:insideH>
<a:insideV><a:ln w="12700"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill></a:ln></a:insideV>
</a:tcBdr>
<a:fill><a:solidFill><a:schemeClr val="accent1"><a:tint val="20000"/></a:schemeClr></a:solidFill></a:fill>
</a:tcStyle>
</a:wholeTbl>
<a:band1H>
<a:tcStyle><a:fill><a:solidFill><a:schemeClr val="accent1"><a:tint val="40000"/></a:schemeClr></a:solidFill></a:fill></a:tcStyle>
</a:band1H>
<a:band2H><a:tcStyle/></a:band2H>
<a:firstRow>
<a:tcTxStyle b="on">
<a:fontRef idx="minor"><a:prstClr val="black"/></a:fontRef>
<a:schemeClr val="lt1"/>
</a:tcTxStyle>
<a:tcStyle>
<a:tcBdr><a:bottom><a:ln w="38100"><a:solidFill><a:schemeClr val="lt1"/></a:solidFill></a:ln></a:bottom></a:tcBdr>
<a:fill><a:solidFill><a:schemeClr val="accent1"/></a:solidFill></a:fill>
</a:tcStyle>
</a:firstRow>
</a:tblStyle>'''
)
@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 <a:tbl> 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'<rect x="{fmt_num(rect_x)}" y="{fmt_num(rect_y)}" '
f'width="{fmt_num(rect_w)}" height="{fmt_num(rect_h)}"'
f'{attr_str}/>'
)
# 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 <a:tc> 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 <a:tc> 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 <a:tc> 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 <a:tcPr> can override txBody insets via
its own marL/marR/marT/marB attrs; convert_txbody reads from <a:bodyPr>,
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 <line> 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 <a:ln>; 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'<line x1="{fmt_num(x1)}" y1="{fmt_num(y1)}" '
f'x2="{fmt_num(x2)}" y2="{fmt_num(y2)}"{attr_str}/>'
)
def _make_ln_wrapper(ln: ET.Element) -> ET.Element:
"""resolve_stroke walks for ``parent.find('a:ln')``; tcPr borders ARE the
<a:ln> 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