Release standalone PPT template workflow 0.5.0

This commit is contained in:
2026-10-10 21:02:47 +08:00
commit 4e3271ff62
303 changed files with 190946 additions and 0 deletions
@@ -0,0 +1,20 @@
"""PPTX -> SVG semantic converter (reverse of svg_to_pptx).
Reads OOXML (DrawingML) directly from a .pptx zip archive and emits SVG with
shape-level fidelity: <p:sp prst="rect"> -> <rect>, <p:txBody> -> <text>, etc.
Public entry: convert_pptx_to_svg().
"""
from __future__ import annotations
__all__ = ["convert_pptx_to_svg"]
def __getattr__(name: str):
"""Load the public converter lazily so shared submodules stay lightweight."""
if name != "convert_pptx_to_svg":
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
from .converter import convert_pptx_to_svg
return convert_pptx_to_svg
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,422 @@
"""Extract native ChartEx payloads from PPTX chart parts.
The parser is deliberately closed around the seven ChartEx data models that
the native writer already emits. Data topology and caches must be complete;
unmodeled chart chrome, labels, axes, binning options, and style details are
allowed to normalize during editable native reconstruction.
"""
from __future__ import annotations
import math
import re
from typing import Any
from xml.etree import ElementTree as ET
from svg_to_pptx.native_objects.chart_data import validate_chart_payload
from svg_to_pptx.native_objects.marker_common import CHART_COLOR_STYLE_REL_TYPE
from .color_resolver import COLOR_TAGS, ColorPalette, resolve_color
from .emu_units import NS, Xfrm
from .ooxml_loader import OoxmlPackage, PartRef
CHARTEX_URI = "http://schemas.microsoft.com/office/drawing/2014/chartex"
CX_NS = {
**NS,
"cx": CHARTEX_URI,
}
_INT_TOKEN_RE = re.compile(r"[0-9]+")
_HIERARCHY_LAYOUTS = {"sunburst", "treemap"}
_FLAT_LAYOUTS = {"funnel", "waterfall"}
_LEGEND_POSITIONS = {"b", "l", "r", "t"}
class UnsupportedChartEx(RuntimeError):
"""A ChartEx part cannot map to the current native authoring schema."""
def __init__(self, status: str) -> None:
super().__init__(status)
self.status = status
def extract_native_chartex_payload(
graphic_data: ET.Element | None,
xfrm: Xfrm,
slide_part: PartRef,
pkg: OoxmlPackage,
palette: ColorPalette | None = None,
) -> dict[str, Any]:
"""Return a writer-valid payload for one supported ChartEx reference.
``UnsupportedChartEx.status`` is suitable for
``data-pptx-replacement-status`` when the reference, relationship, data
topology, or cache is unusable.
Style-part failures never reject an otherwise valid data payload.
"""
if graphic_data is None:
raise UnsupportedChartEx("unsupported-chart-reference")
if xfrm.rot or xfrm.flip_h or xfrm.flip_v:
raise UnsupportedChartEx("unsupported-native-transform")
chart_ref = graphic_data.find("cx:chart", CX_NS)
if chart_ref is None:
raise UnsupportedChartEx("unsupported-chart-reference")
rid = chart_ref.attrib.get(f"{{{NS['r']}}}id")
if not rid:
raise UnsupportedChartEx("unsupported-chart-reference")
chart_path = slide_part.resolve_rel(rid)
if not chart_path:
raise UnsupportedChartEx("unsupported-chart-relationship")
chart_part = pkg.load_part(chart_path)
if chart_part is None:
raise UnsupportedChartEx("unsupported-chart-part")
try:
payload = _payload_from_chartex_xml(chart_part.xml, xfrm)
colors = _resolved_chart_colors(chart_part, pkg, palette)
if colors:
payload["style"] = {"colors": colors}
validate_chart_payload(payload)
except UnsupportedChartEx:
raise
except RuntimeError as exc:
raise UnsupportedChartEx("unsupported-chartex-schema") from exc
except (AttributeError, OverflowError, TypeError, ValueError) as exc:
raise UnsupportedChartEx("unsupported-chartex-parse") from exc
return payload
def _payload_from_chartex_xml(
chart_root: ET.Element,
xfrm: Xfrm,
) -> dict[str, Any]:
if chart_root.tag != f"{{{CHARTEX_URI}}}chartSpace":
raise UnsupportedChartEx("unsupported-chartex-part")
chart_data = _one_child(chart_root, "chartData", "unsupported-chartex-structure")
chart = _one_child(chart_root, "chart", "unsupported-chartex-structure")
plot_area = _one_child(chart, "plotArea", "unsupported-chartex-structure")
region = _one_child(plot_area, "plotAreaRegion", "unsupported-chartex-structure")
data_by_id = _data_parts(chart_data)
series_nodes = _children(region, "series")
if not series_nodes:
raise UnsupportedChartEx("unsupported-chartex-series")
chart_type = _chart_type(series_nodes)
payload: dict[str, Any] = {
**_bounds_payload(xfrm),
"type": chart_type,
}
referenced_data_ids: list[int]
if chart_type in _HIERARCHY_LAYOUTS:
data_id = _series_data_id(series_nodes[0])
data = _require_data(data_by_id, data_id)
raw_levels = _string_dimension(data, expected_levels=None)
values = _numeric_dimension(data, "size")
if not raw_levels or any(len(level) != len(values) for level in raw_levels):
raise UnsupportedChartEx("unsupported-chartex-cache")
# ChartEx serializes the innermost level first; the authoring schema
# and workbook writer use the natural outermost-to-innermost order.
payload["levels"] = list(reversed(raw_levels))
payload["values"] = values
if chart_type == "treemap":
parent_labels = _treemap_parent_labels(series_nodes[0])
if parent_labels is not None:
payload["parent_label_layout"] = parent_labels
referenced_data_ids = [data_id]
elif chart_type == "histogram":
data_id = _series_data_id(series_nodes[0])
data = _require_data(data_by_id, data_id)
_reject_dimensions(data, allowed={"numDim"})
payload["values"] = _numeric_dimension(data, "val")
referenced_data_ids = [data_id]
elif chart_type in {"funnel", "pareto", "waterfall"}:
data_id = _series_data_id(series_nodes[0])
data = _require_data(data_by_id, data_id)
categories = _string_dimension(data, expected_levels=1)[0]
values = _numeric_dimension(data, "val")
if len(categories) != len(values):
raise UnsupportedChartEx("unsupported-chartex-cache")
payload["categories"] = categories
payload["values"] = values
if chart_type == "waterfall":
payload["subtotals"] = _waterfall_subtotals(series_nodes[0])
referenced_data_ids = [data_id]
elif chart_type == "box_whisker":
items: list[dict[str, Any]] = []
referenced_data_ids = []
for index, series in enumerate(series_nodes, start=1):
data_id = _series_data_id(series)
if data_id in referenced_data_ids:
raise UnsupportedChartEx("unsupported-chartex-data-id")
data = _require_data(data_by_id, data_id)
categories = _string_dimension(data, expected_levels=1)[0]
values = _numeric_dimension(data, "val")
if len(categories) != len(values):
raise UnsupportedChartEx("unsupported-chartex-cache")
items.append({
"categories": categories,
"name": _series_name(series, index),
"values": values,
})
referenced_data_ids.append(data_id)
payload["series"] = items
else: # pragma: no cover - _chart_type is closed, keep the invariant explicit.
raise UnsupportedChartEx("unsupported-chartex-type")
if set(referenced_data_ids) != set(data_by_id):
raise UnsupportedChartEx("unsupported-chartex-data-id")
legend = chart.find("cx:legend", CX_NS)
if legend is not None:
payload["show_legend"] = True
position = legend.attrib.get("pos")
if position in _LEGEND_POSITIONS:
payload["legend_position"] = position
return payload
def _chart_type(series_nodes: list[ET.Element]) -> str:
layouts = [series.attrib.get("layoutId", "") for series in series_nodes]
if len(series_nodes) == 1 and layouts[0] in _HIERARCHY_LAYOUTS | _FLAT_LAYOUTS:
return layouts[0]
if layouts and all(layout == "boxWhisker" for layout in layouts):
return "box_whisker"
if len(series_nodes) == 1 and layouts == ["clusteredColumn"]:
if series_nodes[0].find("cx:layoutPr/cx:binning", CX_NS) is None:
raise UnsupportedChartEx("unsupported-chartex-series")
return "histogram"
if len(series_nodes) == 2 and layouts == ["clusteredColumn", "paretoLine"]:
primary, line = series_nodes
if (
primary.find("cx:layoutPr/cx:aggregation", CX_NS) is None
or line.attrib.get("ownerIdx") != "0"
or line.find("cx:dataId", CX_NS) is not None
):
raise UnsupportedChartEx("unsupported-chartex-series")
return "pareto"
raise UnsupportedChartEx("unsupported-chartex-type")
def _data_parts(chart_data: ET.Element) -> dict[int, ET.Element]:
result: dict[int, ET.Element] = {}
for data in _children(chart_data, "data"):
data_id = _nonnegative_int(data.attrib.get("id"), "unsupported-chartex-data-id")
if data_id in result:
raise UnsupportedChartEx("unsupported-chartex-data-id")
result[data_id] = data
if not result:
raise UnsupportedChartEx("unsupported-chartex-data-id")
return result
def _series_data_id(series: ET.Element) -> int:
data_ids = _children(series, "dataId")
if len(data_ids) != 1:
raise UnsupportedChartEx("unsupported-chartex-data-id")
return _nonnegative_int(
data_ids[0].attrib.get("val"),
"unsupported-chartex-data-id",
)
def _require_data(data_by_id: dict[int, ET.Element], data_id: int) -> ET.Element:
data = data_by_id.get(data_id)
if data is None:
raise UnsupportedChartEx("unsupported-chartex-data-id")
return data
def _string_dimension(
data: ET.Element,
*,
expected_levels: int | None,
) -> list[list[str]]:
_reject_dimensions(data, allowed={"strDim", "numDim"})
dimensions = [
child
for child in _children(data, "strDim")
if child.attrib.get("type") == "cat"
]
if len(dimensions) != 1 or len(_children(data, "strDim")) != 1:
raise UnsupportedChartEx("unsupported-chartex-dimension")
levels = _children(dimensions[0], "lvl")
if not levels or (expected_levels is not None and len(levels) != expected_levels):
raise UnsupportedChartEx("unsupported-chartex-dimension")
return [_level_values(level, numeric=False) for level in levels]
def _numeric_dimension(data: ET.Element, dim_type: str) -> list[int | float]:
dimensions = [
child
for child in _children(data, "numDim")
if child.attrib.get("type") == dim_type
]
if len(dimensions) != 1 or len(_children(data, "numDim")) != 1:
raise UnsupportedChartEx("unsupported-chartex-dimension")
levels = _children(dimensions[0], "lvl")
if len(levels) != 1:
raise UnsupportedChartEx("unsupported-chartex-dimension")
return _level_values(levels[0], numeric=True)
def _reject_dimensions(data: ET.Element, *, allowed: set[str]) -> None:
for child in data:
name = _local_name(child.tag)
if name.endswith("Dim") and name not in allowed:
raise UnsupportedChartEx("unsupported-chartex-dimension")
def _level_values(
level: ET.Element,
*,
numeric: bool,
) -> list[Any]:
point_count = _nonnegative_int(
level.attrib.get("ptCount"),
"unsupported-chartex-cache",
)
points: dict[int, Any] = {}
for point in _children(level, "pt"):
point_index = _nonnegative_int(
point.attrib.get("idx"),
"unsupported-chartex-cache",
)
if point_index >= point_count or point_index in points or list(point):
raise UnsupportedChartEx("unsupported-chartex-cache")
raw_value = point.text or ""
points[point_index] = (
_numeric_value(raw_value)
if numeric
else raw_value
)
if (
len(points) != point_count
or any(index not in points for index in range(point_count))
):
raise UnsupportedChartEx("unsupported-chartex-cache")
return [points[index] for index in range(point_count)]
def _numeric_value(raw_value: str) -> int | float:
if not raw_value.strip():
raise UnsupportedChartEx("unsupported-chartex-cache")
try:
number = float(raw_value)
except (OverflowError, ValueError):
raise UnsupportedChartEx("unsupported-chartex-cache") from None
if not math.isfinite(number):
raise UnsupportedChartEx("unsupported-chartex-cache")
return int(number) if number.is_integer() else number
def _waterfall_subtotals(series: ET.Element) -> list[int]:
subtotals: list[int] = []
for item in series.findall("cx:layoutPr/cx:subtotals/cx:idx", CX_NS):
subtotals.append(
_nonnegative_int(item.attrib.get("val"), "unsupported-chartex-cache")
)
return subtotals
def _treemap_parent_labels(series: ET.Element) -> str | None:
parent = series.find("cx:layoutPr/cx:parentLabelLayout", CX_NS)
if parent is None:
return None
value = parent.attrib.get("val")
return value if value in {"banner", "none", "overlapping"} else None
def _series_name(series: ET.Element, index: int) -> str:
value = series.findtext("cx:tx/cx:txData/cx:v", default="", namespaces=CX_NS)
return value or f"Series {index}"
def _resolved_chart_colors(
chart_part: PartRef,
pkg: OoxmlPackage,
palette: ColorPalette | None,
) -> list[str]:
"""Resolve the base color cycle; any style failure normalizes silently."""
try:
targets = [
info.get("target")
for info in chart_part.rels.values()
if info.get("type") == CHART_COLOR_STYLE_REL_TYPE
and not info.get("external")
and info.get("target")
]
if len(targets) != 1:
return []
colors_part = pkg.load_part(str(targets[0]))
if colors_part is None:
return []
color_nodes = [
child
for child in colors_part.xml
if _namespace(child.tag) == NS["a"]
and _local_name(child.tag) in COLOR_TAGS
]
if not color_nodes:
return []
colors: list[str] = []
for color_node in color_nodes:
color, _alpha = resolve_color(color_node, palette)
if color is None:
return []
colors.append(color)
return colors
except (AttributeError, OverflowError, RuntimeError, TypeError, ValueError):
return []
def _one_child(parent: ET.Element, name: str, status: str) -> ET.Element:
children = _children(parent, name)
if len(children) != 1:
raise UnsupportedChartEx(status)
return children[0]
def _children(parent: ET.Element, name: str) -> list[ET.Element]:
return [
child
for child in parent
if child.tag == f"{{{CHARTEX_URI}}}{name}"
]
def _nonnegative_int(raw_value: str | None, status: str) -> int:
if raw_value is None or _INT_TOKEN_RE.fullmatch(raw_value) is None:
raise UnsupportedChartEx(status)
value = int(raw_value)
if value < 0:
raise UnsupportedChartEx(status)
return value
def _bounds_payload(xfrm: Xfrm) -> dict[str, int | float]:
return {
"height": _round_payload_number(xfrm.h),
"width": _round_payload_number(xfrm.w),
"x": _round_payload_number(xfrm.x),
"y": _round_payload_number(xfrm.y),
}
def _round_payload_number(value: float) -> int | float:
rounded = round(float(value), 3)
return int(rounded) if rounded.is_integer() else rounded
def _namespace(tag: str) -> str:
return tag[1:].split("}", 1)[0] if tag.startswith("{") else ""
def _local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1] if "}" in tag else tag
@@ -0,0 +1,894 @@
"""DrawingML color resolution.
Resolves any of the 6 OOXML color types (srgbClr, schemeClr, sysClr, prstClr,
hslClr, scrgbClr) plus the registered luminance, saturation, hue, alpha,
grayscale, complement, and inverse modifiers into an (#RRGGBB, alpha) pair.
Theme palette is resolved from the slide master's a:clrMap and theme1.xml's
a:clrScheme.
"""
from __future__ import annotations
import colorsys
import re
from collections.abc import Callable
from xml.etree import ElementTree as ET
from .emu_units import NS
from .ooxml_loader import PartRef
# ---------------------------------------------------------------------------
# Preset color names (DrawingML <a:prstClr val="...">)
# ---------------------------------------------------------------------------
# Source: complete ECMA-376 ST_PresetColorVal enumeration (190 values).
PRST_COLORS = {
"aliceBlue": "F0F8FF", "antiqueWhite": "FAEBD7", "aqua": "00FFFF",
"aquamarine": "7FFFD4", "azure": "F0FFFF", "beige": "F5F5DC",
"bisque": "FFE4C4", "black": "000000", "blanchedAlmond": "FFEBCD",
"blue": "0000FF", "blueViolet": "8A2BE2", "brown": "A52A2A",
"burlyWood": "DEB887", "cadetBlue": "5F9EA0", "chartreuse": "7FFF00",
"chocolate": "D2691E", "coral": "FF7F50", "cornflowerBlue": "6495ED",
"cornsilk": "FFF8DC", "crimson": "DC143C", "cyan": "00FFFF",
"darkBlue": "00008B", "darkCyan": "008B8B", "darkGoldenrod": "B8860B",
"darkGray": "A9A9A9", "darkGrey": "A9A9A9", "darkGreen": "006400",
"darkKhaki": "BDB76B", "darkMagenta": "8B008B", "darkOliveGreen": "556B2F",
"darkOrange": "FF8C00", "darkOrchid": "9932CC", "darkRed": "8B0000",
"darkSalmon": "E9967A", "darkSeaGreen": "8FBC8F", "darkSlateBlue": "483D8B",
"darkSlateGray": "2F4F4F", "darkSlateGrey": "2F4F4F",
"darkTurquoise": "00CED1", "darkViolet": "9400D3", "deepPink": "FF1493",
"deepSkyBlue": "00BFFF", "dimGray": "696969", "dimGrey": "696969",
"dkBlue": "00008B", "dkCyan": "008B8B", "dkGoldenrod": "B8860B",
"dkGray": "A9A9A9", "dkGrey": "A9A9A9", "dkGreen": "006400",
"dkKhaki": "BDB76B", "dkMagenta": "8B008B", "dkOliveGreen": "556B2F",
"dkOrange": "FF8C00", "dkOrchid": "9932CC", "dkRed": "8B0000",
"dkSalmon": "E9967A", "dkSeaGreen": "8FBC8F", "dkSlateBlue": "483D8B",
"dkSlateGray": "2F4F4F", "dkSlateGrey": "2F4F4F",
"dkTurquoise": "00CED1", "dkViolet": "9400D3",
"dodgerBlue": "1E90FF", "firebrick": "B22222", "floralWhite": "FFFAF0",
"forestGreen": "228B22", "fuchsia": "FF00FF", "gainsboro": "DCDCDC",
"ghostWhite": "F8F8FF", "gold": "FFD700", "goldenrod": "DAA520",
"gray": "808080", "grey": "808080", "green": "008000",
"greenYellow": "ADFF2F", "honeydew": "F0FFF0", "hotPink": "FF69B4",
"indianRed": "CD5C5C", "indigo": "4B0082", "ivory": "FFFFF0",
"khaki": "F0E68C", "lavender": "E6E6FA", "lavenderBlush": "FFF0F5",
"lawnGreen": "7CFC00", "lemonChiffon": "FFFACD",
"lightBlue": "ADD8E6", "lightCoral": "F08080", "lightCyan": "E0FFFF",
"lightGoldenrodYellow": "FAFAD2", "lightGray": "D3D3D3",
"lightGrey": "D3D3D3", "lightGreen": "90EE90", "lightPink": "FFB6C1",
"lightSalmon": "FFA07A", "lightSeaGreen": "20B2AA",
"lightSkyBlue": "87CEFA", "lightSlateGray": "778899",
"lightSlateGrey": "778899", "lightSteelBlue": "B0C4DE",
"lightYellow": "FFFFE0", "ltBlue": "ADD8E6", "ltCoral": "F08080",
"ltCyan": "E0FFFF", "ltGoldenrodYellow": "FAFAD2", "ltGray": "D3D3D3",
"ltGrey": "D3D3D3", "ltGreen": "90EE90", "ltPink": "FFB6C1",
"ltSalmon": "FFA07A", "ltSeaGreen": "20B2AA", "ltSkyBlue": "87CEFA",
"ltSlateGray": "778899", "ltSlateGrey": "778899",
"ltSteelBlue": "B0C4DE", "ltYellow": "FFFFE0",
"lime": "00FF00", "limeGreen": "32CD32", "linen": "FAF0E6",
"magenta": "FF00FF", "maroon": "800000", "medAquamarine": "66CDAA",
"medBlue": "0000CD", "medOrchid": "BA55D3", "medPurple": "9370DB",
"medSeaGreen": "3CB371", "medSlateBlue": "7B68EE",
"medSpringGreen": "00FA9A", "medTurquoise": "48D1CC",
"medVioletRed": "C71585", "mediumAquamarine": "66CDAA",
"mediumBlue": "0000CD", "mediumOrchid": "BA55D3", "mediumPurple": "9370DB",
"mediumSeaGreen": "3CB371", "mediumSlateBlue": "7B68EE",
"mediumSpringGreen": "00FA9A", "mediumTurquoise": "48D1CC",
"mediumVioletRed": "C71585",
"midnightBlue": "191970", "mintCream": "F5FFFA", "mistyRose": "FFE4E1",
"moccasin": "FFE4B5", "navajoWhite": "FFDEAD", "navy": "000080",
"oldLace": "FDF5E6", "olive": "808000", "oliveDrab": "6B8E23",
"orange": "FFA500", "orangeRed": "FF4500", "orchid": "DA70D6",
"paleGoldenrod": "EEE8AA", "paleGreen": "98FB98", "paleTurquoise": "AFEEEE",
"paleVioletRed": "DB7093", "papayaWhip": "FFEFD5", "peachPuff": "FFDAB9",
"peru": "CD853F", "pink": "FFC0CB", "plum": "DDA0DD",
"powderBlue": "B0E0E6", "purple": "800080", "red": "FF0000",
"rosyBrown": "BC8F8F", "royalBlue": "4169E1", "saddleBrown": "8B4513",
"salmon": "FA8072", "sandyBrown": "F4A460", "seaGreen": "2E8B57",
"seaShell": "FFF5EE", "sienna": "A0522D", "silver": "C0C0C0",
"skyBlue": "87CEEB", "slateBlue": "6A5ACD", "slateGray": "708090",
"slateGrey": "708090", "snow": "FFFAFA", "springGreen": "00FF7F",
"steelBlue": "4682B4", "tan": "D2B48C", "teal": "008080",
"thistle": "D8BFD8", "tomato": "FF6347", "turquoise": "40E0D0",
"violet": "EE82EE", "wheat": "F5DEB3", "white": "FFFFFF",
"whiteSmoke": "F5F5F5", "yellow": "FFFF00", "yellowGreen": "9ACD32",
}
# Scheme color name normalization
SCHEME_ALIASES = {
"bg1": "lt1", "bg2": "lt2",
"tx1": "dk1", "tx2": "dk2",
}
_THEME_SCHEME_SLOTS = (
"dk1",
"lt1",
"dk2",
"lt2",
"accent1",
"accent2",
"accent3",
"accent4",
"accent5",
"accent6",
"hlink",
"folHlink",
)
_COLOR_MAP_SOURCES = (
"bg1",
"tx1",
"bg2",
"tx2",
"accent1",
"accent2",
"accent3",
"accent4",
"accent5",
"accent6",
"hlink",
"folHlink",
)
COLOR_TAGS = (
"srgbClr",
"schemeClr",
"sysClr",
"prstClr",
"hslClr",
"scrgbClr",
)
_COLOR_QNAMES = frozenset({
f"{{{NS['a']}}}{name}"
for name in COLOR_TAGS
})
_THEME_COLOR_TAGS = _COLOR_QNAMES - {f"{{{NS['a']}}}schemeClr"}
_SRGB_HEX_RE = re.compile(r"[0-9A-Fa-f]{6}")
_OOXML_INTEGER_RE = re.compile(r"[+-]?[0-9]+")
_OOXML_INT_MIN = -(2**31)
_OOXML_INT_MAX = 2**31 - 1
_COLOR_MODIFIER_PERCENT_RANGES = {
"alpha": (0, 100000),
"alphaMod": (0, _OOXML_INT_MAX),
"alphaOff": (-100000, 100000),
"hueMod": (0, _OOXML_INT_MAX),
"lumMod": (_OOXML_INT_MIN, _OOXML_INT_MAX),
"lumOff": (_OOXML_INT_MIN, _OOXML_INT_MAX),
"satMod": (_OOXML_INT_MIN, _OOXML_INT_MAX),
"satOff": (_OOXML_INT_MIN, _OOXML_INT_MAX),
"shade": (0, 100000),
"tint": (0, 100000),
}
_COLOR_MODIFIER_FLAG_NAMES = frozenset({"comp", "gray", "inv"})
_SCHEME_COLOR_VALUES = {
"bg1",
"tx1",
"bg2",
"tx2",
"accent1",
"accent2",
"accent3",
"accent4",
"accent5",
"accent6",
"hlink",
"folHlink",
"phClr",
"dk1",
"lt1",
"dk2",
"lt2",
}
_SYSTEM_COLOR_VALUES = {
"scrollBar",
"background",
"activeCaption",
"inactiveCaption",
"menu",
"window",
"windowFrame",
"menuText",
"windowText",
"captionText",
"activeBorder",
"inactiveBorder",
"appWorkspace",
"highlight",
"highlightText",
"btnFace",
"btnShadow",
"grayText",
"btnText",
"inactiveCaptionText",
"btnHighlight",
"3dDkShadow",
"3dLight",
"infoText",
"infoBk",
"hotLight",
"gradientActiveCaption",
"gradientInactiveCaption",
"menuHighlight",
"menuBar",
}
# ---------------------------------------------------------------------------
# ColorPalette
# ---------------------------------------------------------------------------
class ColorPalette:
"""Resolves scheme colors via the master's a:clrMap + theme1's a:clrScheme.
a:clrMap remaps presentation-level scheme names (bg1/tx1) to theme-level
names (lt1/dk1) — this is rarely overridden but must be honored.
"""
def __init__(
self,
master: PartRef | None,
theme: PartRef | None,
*,
strict: bool = True,
diagnostic_sink: Callable[[str, str, str], None] | None = None,
) -> None:
self.scheme: dict[str, str] = {}
self.clr_map: dict[str, str] = {}
self.strict = strict
self.diagnostic_sink = diagnostic_sink
if theme is not None:
try:
self._load_scheme(theme)
except ValueError as exc:
if strict:
raise
self.scheme.clear()
self._diagnose(
"theme-color-scheme-normalized",
str(exc),
"read recognized theme color slots",
)
self._load_scheme_compatible(theme)
if master is not None:
try:
self._load_clr_map(master)
except ValueError as exc:
if strict:
raise
self.clr_map.clear()
self._diagnose(
"theme-color-map-normalized",
str(exc),
"read recognized color-map entries",
)
self._load_clr_map_compatible(master)
def _diagnose(self, code: str, message: str, fallback: str) -> None:
if self.diagnostic_sink is not None:
self.diagnostic_sink(code, message, fallback)
def _load_scheme_compatible(self, theme: PartRef) -> None:
"""Read recognizable theme slots without requiring canonical wrapper XML."""
clr_scheme = theme.xml.find(".//a:clrScheme", NS)
if clr_scheme is None:
return
for slot in clr_scheme:
if not isinstance(slot.tag, str):
continue
name = slot.tag.rsplit("}", 1)[-1]
color = next(
(
child
for child in slot
if isinstance(child.tag, str)
and child.tag.rsplit("}", 1)[-1] in COLOR_TAGS
),
None,
)
hex_, _alpha = resolve_color(color, None, strict=False)
if hex_ is not None:
self.scheme[name] = hex_[1:]
def _load_clr_map_compatible(self, master: PartRef) -> None:
"""Read valid known clrMap entries and ignore unrelated extensions."""
clr_map = master.xml.find("p:clrMap", NS)
if clr_map is None:
return
for source in _COLOR_MAP_SOURCES:
target = clr_map.attrib.get(source)
if target in _THEME_SCHEME_SLOTS:
self.clr_map[source] = target
def _load_scheme(self, theme: PartRef) -> None:
theme_root = theme.xml
if theme_root.tag != f"{{{NS['a']}}}theme":
raise ValueError(f"{theme.path}: expected a:theme root")
theme_elements = theme_root.findall("a:themeElements", NS)
if len(theme_elements) != 1:
raise ValueError(
f"{theme.path}: expected exactly one direct a:themeElements"
)
clr_schemes = theme_elements[0].findall("a:clrScheme", NS)
if len(clr_schemes) != 1:
raise ValueError(
f"{theme.path}: expected exactly one direct a:clrScheme"
)
clr_scheme = clr_schemes[0]
children = list(clr_scheme)
expected_tags = [
f"{{{NS['a']}}}{name}" for name in _THEME_SCHEME_SLOTS
]
if (
set(clr_scheme.attrib) != {"name"}
or (clr_scheme.text or "").strip()
or [child.tag for child in children] != expected_tags
or any((child.tail or "").strip() for child in children)
):
raise ValueError(
f"{theme.path}: invalid DrawingML color scheme structure"
)
for name, slot in zip(_THEME_SCHEME_SLOTS, children):
color_children = list(slot)
if (
slot.attrib
or (slot.text or "").strip()
or len(color_children) != 1
or color_children[0].tag not in _THEME_COLOR_TAGS
or (color_children[0].tail or "").strip()
):
raise ValueError(
f"{theme.path}: invalid DrawingML theme color slot {name!r}"
)
try:
hex_, alpha = resolve_color(color_children[0], None)
except ValueError as exc:
raise ValueError(
f"{theme.path}: invalid DrawingML theme color slot "
f"{name!r}: {exc}"
) from exc
if hex_ is None:
raise ValueError(
f"{theme.path}: unresolved DrawingML theme color slot {name!r}"
)
if alpha != 1.0:
raise ValueError(
f"{theme.path}: non-opaque DrawingML theme color slot {name!r}"
)
self.scheme[name] = hex_[1:]
def _load_clr_map(self, master: PartRef) -> None:
master_root = master.xml
if master_root.tag != f"{{{NS['p']}}}sldMaster":
raise ValueError(f"{master.path}: expected p:sldMaster root")
clr_maps = master_root.findall("p:clrMap", NS)
if len(clr_maps) != 1:
raise ValueError(
f"{master.path}: expected exactly one direct p:clrMap"
)
clr_map = clr_maps[0]
children = list(clr_map)
if (
set(clr_map.attrib) != set(_COLOR_MAP_SOURCES)
or children
or (clr_map.text or "").strip()
):
raise ValueError(
f"{master.path}: invalid DrawingML color map structure"
)
for source in _COLOR_MAP_SOURCES:
target = clr_map.attrib[source]
if target not in _THEME_SCHEME_SLOTS:
raise ValueError(
f"{master.path}: invalid DrawingML color map target "
f"{source}={target!r}"
)
self.clr_map[source] = target
def resolve_scheme(self, name: str) -> str | None:
"""scheme name (e.g. 'accent1', 'bg1') -> 'RRGGBB'. None on miss."""
# apply clrMap remap (bg1 -> lt1, tx1 -> dk1, etc.)
mapped = self.clr_map.get(name, name)
# canonical alias (bg1 -> lt1)
mapped = SCHEME_ALIASES.get(mapped, mapped)
return self.scheme.get(mapped)
# ---------------------------------------------------------------------------
# Color resolution
# ---------------------------------------------------------------------------
def validate_no_fill(elem: ET.Element) -> None:
"""Require the empty DrawingML a:noFill leaf contract."""
if elem.attrib or list(elem) or (elem.text or "").strip():
raise ValueError("Invalid DrawingML noFill structure")
def resolve_solid_fill_color(
elem: ET.Element,
palette: ColorPalette | None,
*,
placeholder_hex: str | None = None,
) -> tuple[str, float]:
"""Resolve one explicit color from a closed DrawingML solid fill."""
children = list(elem)
if not elem.attrib and not children and not (elem.text or "").strip():
raise ValueError(
"DrawingML solid fill requires one explicit color"
)
if (
elem.attrib
or len(children) != 1
or children[0].tag not in _COLOR_QNAMES
or (elem.text or "").strip()
or (children[0].tail or "").strip()
):
raise ValueError("Invalid DrawingML solid fill structure")
hex_, alpha = resolve_color(
children[0],
palette,
placeholder_hex=placeholder_hex,
)
if hex_ is None:
raise ValueError("DrawingML solid fill color cannot be resolved")
return hex_, alpha
def find_color_elem(parent: ET.Element | None) -> ET.Element | None:
"""Return the sole direct registered color, rejecting ambiguous aliases."""
if parent is None:
return None
colors: list[ET.Element] = []
for child in list(parent):
if not isinstance(child.tag, str):
continue
name = child.tag.rsplit("}", 1)[-1]
if name not in COLOR_TAGS:
continue
if child.tag not in _COLOR_QNAMES:
raise ValueError(
f"Invalid DrawingML color child namespace: {child.tag!r}"
)
colors.append(child)
if len(colors) > 1:
raise ValueError(
"DrawingML color container has multiple direct color children"
)
return colors[0] if colors else None
def resolve_color(
color_elem: ET.Element | None,
palette: ColorPalette | None,
*,
placeholder_hex: str | None = None,
strict: bool | None = None,
) -> tuple[str | None, float]:
"""Resolve a color element to (#RRGGBB, alpha).
Args:
color_elem: a:srgbClr / a:schemeClr / etc. May be None.
palette: ColorPalette for resolving schemeClr.
placeholder_hex: when a child uses schemeClr val="phClr" (placeholder
color used inside theme styles), substitute this hex.
Returns:
(hex string with leading '#', alpha in [0,1]) or (None, 1.0) on failure.
"""
if color_elem is None:
return None, 1.0
effective_strict = palette.strict if strict is None and palette is not None else strict
if effective_strict is None:
effective_strict = True
try:
return _resolve_color_closed(
color_elem,
palette,
placeholder_hex=placeholder_hex,
)
except ValueError as exc:
if effective_strict:
raise
if palette is not None:
palette._diagnose(
"color-structure-normalized",
str(exc),
"retain recognized color attributes and modifiers",
)
compatible = _compatible_color_element(color_elem)
if compatible is None:
return None, 1.0
try:
return _resolve_color_closed(
compatible,
palette,
placeholder_hex=placeholder_hex,
)
except (KeyError, TypeError, ValueError):
return None, 1.0
def _resolve_color_closed(
color_elem: ET.Element,
palette: ColorPalette | None,
*,
placeholder_hex: str | None = None,
) -> tuple[str | None, float]:
"""Resolve the registered closed DrawingML color grammar."""
tag = color_elem.tag.split("}", 1)[-1]
base_hex: str | None = None
alpha: float = 1.0
if tag == "srgbClr":
base_hex = _srgb_hex_value(color_elem)
elif tag == "schemeClr":
name = _scheme_color_name(color_elem)
if name == "phClr":
base_hex = _normalize_hex(placeholder_hex) if placeholder_hex else None
elif palette is not None:
base_hex = palette.resolve_scheme(name)
elif tag == "sysClr":
base_hex = _system_color_fallback(color_elem)
elif tag == "prstClr":
base_hex = _preset_color_hex(color_elem)
elif tag == "hslClr":
base_hex = _hsl_color_hex(color_elem)
elif tag == "scrgbClr":
base_hex = _scrgb_color_hex(color_elem)
if base_hex is None:
return None, 1.0
# Apply modifiers (children of the color element).
base_hex, alpha = _apply_modifiers(base_hex, color_elem)
return f"#{base_hex}", alpha
def _compatible_color_element(color_elem: ET.Element) -> ET.Element | None:
"""Return a canonical clone containing only recognized color semantics."""
if not isinstance(color_elem.tag, str):
return None
tag = color_elem.tag.rsplit("}", 1)[-1]
base_attributes = {
"srgbClr": ("val",),
"schemeClr": ("val",),
"sysClr": ("val", "lastClr"),
"prstClr": ("val",),
"hslClr": ("hue", "sat", "lum"),
"scrgbClr": ("r", "g", "b"),
}.get(tag)
if base_attributes is None or color_elem.tag != f"{{{NS['a']}}}{tag}":
return None
clone = ET.Element(color_elem.tag)
for attr in base_attributes:
value = color_elem.attrib.get(attr)
if value is not None:
clone.set(attr, value)
for child in color_elem:
if not isinstance(child.tag, str):
continue
name = child.tag.rsplit("}", 1)[-1]
if child.tag != f"{{{NS['a']}}}{name}":
continue
if name in _COLOR_MODIFIER_FLAG_NAMES:
clone.append(ET.Element(child.tag))
continue
if name not in _COLOR_MODIFIER_PERCENT_RANGES and name != "hueOff":
continue
value = child.attrib.get("val")
if value is None:
continue
modifier = ET.Element(child.tag)
modifier.set("val", value)
clone.append(modifier)
return clone
def _srgb_hex_value(color_elem: ET.Element) -> str:
"""Parse the closed six-digit DrawingML sRGB base token."""
children = list(color_elem)
if (
color_elem.tag != f"{{{NS['a']}}}srgbClr"
or set(color_elem.attrib) != {"val"}
or (color_elem.text or "").strip()
or any((child.tail or "").strip() for child in children)
):
raise ValueError("Invalid DrawingML sRGB color structure")
raw = color_elem.attrib["val"]
if _SRGB_HEX_RE.fullmatch(raw) is None:
raise ValueError(f"Invalid DrawingML sRGB color value: {raw!r}")
return raw.upper()
def _scheme_color_name(color_elem: ET.Element) -> str:
"""Parse one closed DrawingML scheme-color reference."""
children = list(color_elem)
if (
color_elem.tag != f"{{{NS['a']}}}schemeClr"
or set(color_elem.attrib) != {"val"}
or (color_elem.text or "").strip()
or any((child.tail or "").strip() for child in children)
):
raise ValueError("Invalid DrawingML scheme color structure")
name = color_elem.attrib["val"]
if name not in _SCHEME_COLOR_VALUES:
raise ValueError(f"Invalid DrawingML scheme color value: {name!r}")
return name
def _system_color_fallback(color_elem: ET.Element) -> str:
"""Parse a registered system color with a portable six-digit fallback."""
children = list(color_elem)
if (
color_elem.tag != f"{{{NS['a']}}}sysClr"
or set(color_elem.attrib) - {"val", "lastClr"}
or "val" not in color_elem.attrib
or (color_elem.text or "").strip()
or any((child.tail or "").strip() for child in children)
):
raise ValueError("Invalid DrawingML system color structure")
name = color_elem.attrib["val"]
if name not in _SYSTEM_COLOR_VALUES:
raise ValueError(f"Invalid DrawingML system color value: {name!r}")
fallback = color_elem.get("lastClr")
if fallback is None:
raise ValueError(
"DrawingML system color requires a lastClr fallback"
)
if _SRGB_HEX_RE.fullmatch(fallback) is None:
raise ValueError(
f"Invalid DrawingML system color fallback: {fallback!r}"
)
return fallback.upper()
def _preset_color_hex(color_elem: ET.Element) -> str:
"""Resolve one exact value from the complete DrawingML preset enum."""
children = list(color_elem)
if (
color_elem.tag != f"{{{NS['a']}}}prstClr"
or set(color_elem.attrib) != {"val"}
or (color_elem.text or "").strip()
or any((child.tail or "").strip() for child in children)
):
raise ValueError("Invalid DrawingML preset color structure")
name = color_elem.attrib["val"]
hex_ = PRST_COLORS.get(name)
if hex_ is None:
raise ValueError(f"Invalid DrawingML preset color value: {name!r}")
return hex_
def _hsl_color_hex(color_elem: ET.Element) -> str:
"""Resolve one closed DrawingML HSL base color."""
children = list(color_elem)
if (
color_elem.tag != f"{{{NS['a']}}}hslClr"
or set(color_elem.attrib) != {"hue", "sat", "lum"}
or (color_elem.text or "").strip()
or any((child.tail or "").strip() for child in children)
):
raise ValueError("Invalid DrawingML HSL color structure")
hue = _bounded_integer_attribute(
color_elem,
"hue",
minimum=0,
maximum=21_599_999,
label="HSL color",
)
saturation = _bounded_integer_attribute(
color_elem,
"sat",
minimum=0,
maximum=100000,
label="HSL color",
)
luminance = _bounded_integer_attribute(
color_elem,
"lum",
minimum=0,
maximum=100000,
label="HSL color",
)
return _hsl_to_hex(
hue / 21_600_000.0,
saturation / 100000.0,
luminance / 100000.0,
)
def _scrgb_color_hex(color_elem: ET.Element) -> str:
"""Resolve one closed linear-light DrawingML scRGB base color."""
children = list(color_elem)
if (
color_elem.tag != f"{{{NS['a']}}}scrgbClr"
or set(color_elem.attrib) != {"r", "g", "b"}
or (color_elem.text or "").strip()
or any((child.tail or "").strip() for child in children)
):
raise ValueError("Invalid DrawingML scRGB color structure")
channels = [
_bounded_integer_attribute(
color_elem,
attr,
minimum=0,
maximum=100000,
label="scRGB color",
)
/ 100000.0
for attr in ("r", "g", "b")
]
return _linear_rgb01_to_hex(*channels)
def _bounded_integer_attribute(
elem: ET.Element,
attr: str,
*,
minimum: int,
maximum: int,
label: str,
) -> int:
"""Parse one required bounded DrawingML integer attribute."""
raw = elem.attrib[attr]
token = raw.strip()
if _OOXML_INTEGER_RE.fullmatch(token) is None:
raise ValueError(f"Invalid DrawingML {label} {attr}: {raw!r}")
value = int(token)
if not minimum <= value <= maximum:
raise ValueError(f"Invalid DrawingML {label} {attr}: {raw!r}")
return value
def _parse_color_modifier(child: ET.Element) -> tuple[str, int | None]:
"""Parse one modifier from the closed DrawingML color subset."""
tag = child.tag.split("}", 1)[-1]
if child.tag != f"{{{NS['a']}}}{tag}":
raise ValueError(f"invalid-color-modifier-namespace:{tag}")
if tag in _COLOR_MODIFIER_FLAG_NAMES:
if child.attrib or list(child) or (child.text or "").strip():
raise ValueError(f"invalid-color-modifier-structure:{tag}")
return tag, None
bounds = _COLOR_MODIFIER_PERCENT_RANGES.get(tag)
if tag == "hueOff":
bounds = (_OOXML_INT_MIN, _OOXML_INT_MAX)
elif bounds is None:
raise ValueError(f"unsupported-color-modifier:{tag}")
if (
set(child.attrib) != {"val"}
or list(child)
or (child.text or "").strip()
):
raise ValueError(f"invalid-color-modifier-structure:{tag}")
try:
value = _bounded_integer_attribute(
child,
"val",
minimum=bounds[0],
maximum=bounds[1],
label=f"{tag} color modifier",
)
except ValueError:
raise ValueError(f"invalid-{tag}-val:{child.attrib['val']!r}") from None
return tag, value
def _apply_modifiers(hex_color: str, color_elem: ET.Element) -> tuple[str, float]:
"""Apply the registered DrawingML color modifiers in document order."""
r, g, b = _hex_to_rgb01(hex_color)
# Convert to HSL for luminance/saturation ops; convert back when emitting.
h, lum, sat = colorsys.rgb_to_hls(r, g, b)
alpha = 1.0
for child in list(color_elem):
if not isinstance(child.tag, str):
continue
tag, value = _parse_color_modifier(child)
if tag == "tint":
# Tint blends toward white. r' = r + (1-r)*(1-val) is the common
# interpretation; OOXML actually defines tint on luminance.
# Use luminance-based tint per ECMA-376.
ratio = value / 100000.0
lum = lum * ratio + (1.0 - ratio)
elif tag == "shade":
# Shade blends toward black on luminance.
lum = lum * (value / 100000.0)
elif tag == "lumMod":
lum = lum * (value / 100000.0)
elif tag == "lumOff":
lum = lum + (value / 100000.0)
elif tag == "satMod":
sat = sat * (value / 100000.0)
elif tag == "satOff":
sat = sat + (value / 100000.0)
elif tag == "hueMod":
h = (h * (value / 100000.0)) % 1.0
elif tag == "hueOff":
h = (h + value / 21_600_000.0) % 1.0
elif tag == "alpha":
alpha = value / 100000.0
elif tag == "alphaMod":
alpha = max(0.0, min(1.0, alpha * (value / 100000.0)))
elif tag == "alphaOff":
alpha = max(0.0, min(1.0, alpha + (value / 100000.0)))
elif tag == "gray":
# Convert to grayscale based on luminance.
sat = 0.0
elif tag == "comp":
# Complement: hue rotated 180°
h = (h + 0.5) % 1.0
elif tag == "inv":
# RGB invert
rr, gg, bb = colorsys.hls_to_rgb(h, lum, sat)
rr, gg, bb = 1.0 - rr, 1.0 - gg, 1.0 - bb
h, lum, sat = colorsys.rgb_to_hls(rr, gg, bb)
# Clamp luminance / saturation to [0,1]
lum = max(0.0, min(1.0, lum))
sat = max(0.0, min(1.0, sat))
rr, gg, bb = colorsys.hls_to_rgb(h, lum, sat)
return _rgb01_to_hex(rr, gg, bb), alpha
# ---------------------------------------------------------------------------
# Hex / RGB / HSL helpers
# ---------------------------------------------------------------------------
def _normalize_hex(value: str | None) -> str | None:
"""Strip '#' and validate 6-digit hex. Return uppercase or None on failure."""
if value is None:
return None
v = value.strip()
if v.startswith("#"):
v = v[1:]
if len(v) == 3:
v = "".join(c * 2 for c in v)
if len(v) != 6:
return None
try:
int(v, 16)
except ValueError:
return None
return v.upper()
def _hex_to_rgb01(hex_color: str) -> tuple[float, float, float]:
h = _normalize_hex(hex_color) or "000000"
r = int(h[0:2], 16) / 255.0
g = int(h[2:4], 16) / 255.0
b = int(h[4:6], 16) / 255.0
return r, g, b
def _rgb01_to_hex(r: float, g: float, b: float) -> str:
r = max(0.0, min(1.0, r))
g = max(0.0, min(1.0, g))
b = max(0.0, min(1.0, b))
return f"{int(round(r * 255)):02X}{int(round(g * 255)):02X}{int(round(b * 255)):02X}"
def _linear_rgb01_to_hex(r: float, g: float, b: float) -> str:
"""Encode linear-light RGB channels as an SVG sRGB hex color."""
encoded = (
12.92 * channel
if channel <= 0.0031308
else 1.055 * channel ** (1.0 / 2.4) - 0.055
for channel in (r, g, b)
)
return _rgb01_to_hex(*encoded)
def _hsl_to_hex(h: float, s: float, l: float) -> str:
h = h % 1.0 if h != 1.0 else 1.0
s = max(0.0, min(1.0, s))
l = max(0.0, min(1.0, l))
r, g, b = colorsys.hls_to_rgb(h, l, s)
return _rgb01_to_hex(r, g, b)
@@ -0,0 +1,935 @@
"""Top-level orchestrator for PPTX -> SVG conversion.
Public API: convert_pptx_to_svg(pptx_path, output_dir, options).
Composes the per-slide pipeline:
OoxmlPackage -> shape_walker.walk_sp_tree
-> per-shape dispatch (prstgeom / txbody / pic / ...)
-> assembled SVG text + extracted media files
Stages B-F will fill in the per-shape dispatch. For Stage A this entry just
loads the package and reports basic per-slide structure to verify wiring.
"""
from __future__ import annotations
import json
import os
import re
import shutil
import tempfile
from collections.abc import Callable
from dataclasses import dataclass, field
from html import unescape
from pathlib import Path, PurePosixPath
from urllib.parse import unquote, urlsplit
from .color_resolver import ColorPalette
from .emu_units import NS
from .import_diagnostics import ImportDiagnostic, append_diagnostic
from .ooxml_loader import (
OoxmlPackage,
PartRef,
SlideRef,
part_show_master_sp,
)
from .slide_to_svg import assemble_part_solo, assemble_slide
_CJK_THEME_SCRIPTS = frozenset({"Hans", "Hant", "Jpan", "Hang"})
_MANAGED_PRIMARY_SVG_RE = re.compile(
r"(?:slide_\d+|master_\d+_[A-Za-z0-9_-]+|layout_\d+_[A-Za-z0-9_-]+)\.svg"
)
_MANAGED_FLAT_SVG_RE = re.compile(r"slide_\d+\.svg")
_SVG_HREF_RE = re.compile(
r"\b(?:href|xlink:href)\s*=\s*[\"']([^\"']+)[\"']"
)
def _validate_media_subdir(value: str) -> None:
"""Reject media output paths that can escape the conversion workspace."""
path = Path(value)
if path.drive or path.anchor or path.is_absolute() or ".." in path.parts:
raise ValueError(
f"media_subdir must stay within the output workspace: {value!r}"
)
def _validate_media_filename(filename: str) -> None:
"""Require one media basename so asset maps cannot redirect writes."""
path = Path(filename)
if (
not filename
or filename in {".", ".."}
or path.drive
or path.anchor
or path.name != filename
or "/" in filename
or "\\" in filename
):
raise ValueError(f"Media filename must be a basename: {filename!r}")
def _extract_theme_info(
theme: PartRef,
palette: ColorPalette,
) -> tuple[dict[str, str], dict[str, str]]:
from .color_resolver import find_color_elem, resolve_color
colors: dict[str, str] = {}
fonts: dict[str, str] = {}
scheme = theme.xml.find(".//a:clrScheme", NS)
if scheme is not None:
for child in list(scheme):
if not isinstance(child.tag, str):
continue
name = child.tag.split("}", 1)[-1]
try:
color_elem = find_color_elem(child)
hex_, _ = resolve_color(color_elem, palette)
except ValueError as exc:
if palette.strict:
raise
palette._diagnose(
"theme-summary-color-omitted",
str(exc),
"omit only this malformed theme-summary color",
)
continue
if hex_:
colors[name] = hex_
font_scheme = theme.xml.find(".//a:fontScheme", NS)
if font_scheme is not None:
for slot in ("majorFont", "minorFont"):
fnt = font_scheme.find(f"a:{slot}", NS)
if fnt is None:
continue
role_prefix = "major" if slot == "majorFont" else "minor"
latin = fnt.find("a:latin", NS)
if latin is not None and latin.attrib.get("typeface"):
fonts[f"{role_prefix}Latin"] = latin.attrib["typeface"]
ea = fnt.find("a:ea", NS)
if ea is not None and ea.attrib.get("typeface"):
fonts[f"{role_prefix}EastAsia"] = ea.attrib["typeface"]
cs = fnt.find("a:cs", NS)
if cs is not None and cs.attrib.get("typeface"):
fonts[f"{role_prefix}ComplexScript"] = cs.attrib["typeface"]
for supplemental in fnt.findall("a:font", NS):
script = supplemental.attrib.get("script", "")
typeface = supplemental.attrib.get("typeface", "")
if script in _CJK_THEME_SCRIPTS and typeface:
fonts[f"{role_prefix}Script{script}"] = typeface
return colors, fonts
@dataclass
class ConvertOptions:
"""Convert behavior knobs.
media_subdir: where to write media files relative to output_dir. SVG image
href will use './<media_subdir>/<filename>'.
embed_images: when True, base64-encode images inline instead of writing
files. Default False (matches svg_to_pptx default of external images).
keep_hidden: include shapes marked hidden="1". Default False.
inheritance_mode: how to render master/layout shapes per slide SVG.
- "both" (default): emit both views — layered under svg/ for template
designers (master/layout/slide as separate files) and flat under
svg-flat/ for previewers (each slide self-contained). Costs roughly
1.3-1.5× converter time and ~1.6-2× disk vs. either single mode.
- "layered": skip inherited shapes inside the slide. The orchestrator
renders every master and layout to its own SVG, plus
svg/inheritance.json describing the reuse graph. Optimised for
template authors who need to see "what is shared vs. unique".
- "flat": inline the inherited shapes visible under the source
``showMasterSp`` flags. Used by svg_to_pptx round-trip and any caller
that wants self-contained slides (preview pages, screenshot pipelines).
strict: stop on the first unsupported or malformed source construct.
Default False keeps usable content and records structured diagnostics.
"""
media_subdir: str = "assets"
embed_images: bool = False
keep_hidden: bool = False
inheritance_mode: str = "both"
asset_name_map: dict[str, str] = field(default_factory=dict)
strict: bool = False
@dataclass
class PartArtifact:
"""Result of converting a master or layout part to SVG (layered mode only)."""
role: str # "master" | "layout"
part_path: str # OOXML part path, e.g. "ppt/slideLayouts/slideLayout3.xml"
filename: str # output svg filename, e.g. "layout_03_title.xml.svg"
svg: str
media_files: dict[str, bytes] = field(default_factory=dict)
parent_master_part_path: str | None = None
theme_part_path: str | None = None
show_master_shapes: bool = True
@dataclass
class SlideArtifact:
"""Result of converting a single slide."""
index: int # 1-based
svg: str
media_files: dict[str, bytes] = field(default_factory=dict)
layout_part_path: str | None = None
master_part_path: str | None = None
show_inherited_shapes: bool = True
@dataclass
class ConvertResult:
"""Result of converting an entire .pptx.
``slides`` holds the layered/primary view (or, in pure flat mode, the flat
view). ``flat_slides`` is populated only in ``"both"`` mode and contains
self-contained renderings of every slide; callers that don't care about
the flat view can ignore it.
"""
slides: list[SlideArtifact] = field(default_factory=list)
canvas_px: tuple[float, float] = (1280.0, 720.0)
theme_colors: dict[str, str] = field(default_factory=dict)
theme_fonts: dict[str, str] = field(default_factory=dict)
layouts: list[PartArtifact] = field(default_factory=list)
masters: list[PartArtifact] = field(default_factory=list)
flat_slides: list[SlideArtifact] = field(default_factory=list)
master_themes: dict[str, dict[str, object]] = field(default_factory=dict)
diagnostics: list[ImportDiagnostic] = field(default_factory=list)
source_file: str = ""
strict: bool = False
def _palette_diagnostic_sink(
result: ConvertResult,
*,
part_path: str,
slide_index: int | None = None,
) -> Callable[[str, str, str], None]:
"""Build a package-level diagnostic sink for palette initialization."""
def _record(code: str, message: str, fallback: str) -> None:
append_diagnostic(
result.diagnostics,
ImportDiagnostic(
code=code,
message=message,
fallback=fallback,
part_path=part_path,
slide_index=slide_index,
),
)
return _record
def _make_palette(
master: PartRef | None,
theme: PartRef | None,
options: ConvertOptions,
result: ConvertResult,
*,
part_path: str,
slide_index: int | None = None,
) -> ColorPalette:
"""Create one strict or tolerant palette with structured diagnostics."""
return ColorPalette(
master,
theme,
strict=options.strict,
diagnostic_sink=_palette_diagnostic_sink(
result,
part_path=part_path,
slide_index=slide_index,
),
)
# ---------------------------------------------------------------------------
# Entry
# ---------------------------------------------------------------------------
def convert_pptx_to_svg(
pptx_path: Path,
output_dir: Path | None = None,
options: ConvertOptions | None = None,
) -> ConvertResult:
"""Convert a .pptx file to one SVG per slide.
Args:
pptx_path: Source .pptx file.
output_dir: When given, write svg/<slide_NN>.svg + media files there.
When None, files are not written; callers can read SlideArtifact.svg.
options: ConvertOptions; defaults to ConvertOptions().
Returns:
ConvertResult with per-slide SVG strings and resolved theme info.
"""
options = options or ConvertOptions()
if options.inheritance_mode not in {"flat", "layered", "both"}:
raise ValueError(
f"inheritance_mode must be 'flat', 'layered', or 'both', "
f"got {options.inheritance_mode!r}"
)
if not options.embed_images:
_validate_media_subdir(options.media_subdir)
emit_layered = options.inheritance_mode in {"layered", "both"}
emit_flat = options.inheritance_mode in {"flat", "both"}
result = ConvertResult(
source_file=pptx_path.name,
strict=options.strict,
)
with OoxmlPackage(pptx_path) as pkg:
result.canvas_px = pkg.slide_size_px
# Default theme summary is kept for compatibility; conversion itself
# resolves palette/fonts per slide master.
first_slide = pkg.get_slide(1)
default_master = first_slide.master if first_slide else None
default_theme = pkg.resolve_theme(default_master)
palette = _make_palette(
default_master,
default_theme,
options,
result,
part_path=default_theme.path if default_theme is not None else "",
)
if default_theme is not None:
result.theme_colors, result.theme_fonts = _extract_theme_info(default_theme, palette)
for master in pkg.iter_all_masters():
theme = pkg.resolve_theme(master) or default_theme
pal = _make_palette(
master,
theme,
options,
result,
part_path=master.path,
)
colors, fonts = _extract_theme_info(theme, pal) if theme is not None else ({}, {})
result.master_themes[master.path] = {
"themePath": theme.path if theme is not None else None,
"colors": colors,
"fonts": fonts,
}
# Per-slide conversion. The primary view is layered when emitted
# (template designers care most about that one); the flat view is
# rendered alongside when needed.
primary_mode = "layered" if emit_layered else "flat"
for slide in pkg.iter_slides():
slide_theme = pkg.resolve_theme(slide.master) or default_theme
slide_palette = _make_palette(
slide.master,
slide_theme,
options,
result,
part_path=slide.part.path,
slide_index=slide.index,
)
_colors, slide_fonts = _extract_theme_info(slide_theme, slide_palette) if slide_theme is not None else ({}, result.theme_fonts)
artifact = _convert_slide(
pkg,
slide,
slide_palette,
options,
result.diagnostics,
slide_fonts,
inheritance_mode=primary_mode,
)
result.slides.append(artifact)
if emit_layered and emit_flat:
for slide in pkg.iter_slides():
slide_theme = pkg.resolve_theme(slide.master) or default_theme
slide_palette = _make_palette(
slide.master,
slide_theme,
options,
result,
part_path=slide.part.path,
slide_index=slide.index,
)
_colors, slide_fonts = _extract_theme_info(slide_theme, slide_palette) if slide_theme is not None else ({}, result.theme_fonts)
artifact = _convert_slide(
pkg,
slide,
slide_palette,
options,
result.diagnostics,
slide_fonts,
inheritance_mode="flat",
)
result.flat_slides.append(artifact)
# Layered mode: also render each master / layout once.
if emit_layered:
_convert_inheritance_parts(pkg, default_theme, options, result)
if output_dir is not None:
_write_artifacts(output_dir, result, options)
return result
def _convert_slide(
pkg: OoxmlPackage,
slide: SlideRef,
palette: ColorPalette,
options: ConvertOptions,
diagnostics: list[ImportDiagnostic],
theme_fonts: dict[str, str] | None = None,
*,
inheritance_mode: str | None = None,
) -> SlideArtifact:
"""Convert a single slide via the full shape pipeline.
``inheritance_mode`` overrides ``options.inheritance_mode`` so the
orchestrator can render the same slide twice (once layered, once flat)
when the user asked for ``"both"``. Pass ``"flat"`` or ``"layered"``;
``None`` falls back to ``options.inheritance_mode`` (used by direct
callers that want a single mode).
"""
mode = inheritance_mode or options.inheritance_mode
if mode == "both":
mode = "layered" # primary view in both-mode
show_inherited_shapes = part_show_master_sp(slide.part)
svg, media = assemble_slide(
pkg, slide, palette,
theme_fonts=theme_fonts,
media_subdir=options.media_subdir,
embed_images=options.embed_images,
keep_hidden=options.keep_hidden,
inheritance_mode=mode,
asset_name_map=options.asset_name_map,
strict=options.strict,
diagnostics=diagnostics,
)
return SlideArtifact(
index=slide.index,
svg=svg,
media_files=media,
layout_part_path=slide.layout.path if slide.layout else None,
master_part_path=slide.master.path if slide.master else None,
show_inherited_shapes=show_inherited_shapes,
)
def _convert_inheritance_parts(
pkg: OoxmlPackage,
default_theme: PartRef | None,
options: ConvertOptions,
result: ConvertResult,
) -> None:
"""Render every master and layout in the deck to its own SVG (layered mode).
We deliberately render *all* masters / layouts, not only the ones a slide
references. Multi-style template packages routinely ship more design
surfaces than the embedded sample slides exercise, and dropping unused
ones discards the bulk of the template's design intent.
"""
# Collect unique parts in document order so output filenames are
# deterministic for a given .pptx.
seen_masters: dict[str, PartRef] = {}
for master in pkg.iter_all_masters():
if master.path not in seen_masters:
seen_masters[master.path] = master
layouts_with_parent: list[tuple[PartRef, PartRef]] = []
seen_layout_paths: set[str] = set()
for layout, parent_master in pkg.iter_all_layouts_with_parent():
if layout.path in seen_layout_paths:
continue
seen_layout_paths.add(layout.path)
layouts_with_parent.append((layout, parent_master))
for seq, part in enumerate(seen_masters.values(), start=1):
theme = pkg.resolve_theme(part) or default_theme
palette = _make_palette(
part,
theme,
options,
result,
part_path=part.path,
)
_colors, fonts = _extract_theme_info(theme, palette) if theme is not None else ({}, result.theme_fonts)
result.masters.append(_render_part(
pkg, part, palette, options, result.diagnostics, fonts,
role="master", seq=seq, theme_part=theme,
))
for seq, (layout, parent_master) in enumerate(layouts_with_parent, start=1):
theme = pkg.resolve_theme(parent_master) or default_theme
palette = _make_palette(
parent_master,
theme,
options,
result,
part_path=layout.path,
)
_colors, fonts = _extract_theme_info(theme, palette) if theme is not None else ({}, result.theme_fonts)
result.layouts.append(_render_part(
pkg, layout, palette, options, result.diagnostics, fonts,
role="layout", seq=seq, parent_master=parent_master,
theme_part=theme,
))
def _render_part(
pkg: OoxmlPackage,
part: PartRef,
palette: ColorPalette,
options: ConvertOptions,
diagnostics: list[ImportDiagnostic],
theme_fonts: dict[str, str],
*,
role: str,
seq: int,
parent_master: PartRef | None = None,
theme_part: PartRef | None = None,
) -> PartArtifact:
"""Render a master/layout part, returning a PartArtifact with output filename."""
svg, media = assemble_part_solo(
pkg, part, palette,
role=role,
parent_master=parent_master,
theme_fonts=theme_fonts,
media_subdir=options.media_subdir,
embed_images=options.embed_images,
keep_hidden=options.keep_hidden,
asset_name_map=options.asset_name_map,
strict=options.strict,
diagnostics=diagnostics,
)
stem = PurePosixPath(part.path).stem # e.g. "slideLayout3"
safe_stem = re.sub(r"[^A-Za-z0-9_-]+", "_", stem).strip("_") or role
filename = f"{role}_{seq:02d}_{safe_stem}.svg"
return PartArtifact(
role=role,
part_path=part.path,
filename=filename,
svg=svg,
media_files=media,
parent_master_part_path=parent_master.path if parent_master is not None else None,
theme_part_path=theme_part.path if theme_part is not None else None,
show_master_shapes=(
part_show_master_sp(part) if role == "layout" else True
),
)
def _path_lexists(path: Path) -> bool:
"""Return whether a path or symlink exists without following the symlink."""
return path.exists() or path.is_symlink()
def _managed_svg_paths(output_dir: Path) -> list[Path]:
"""Return converter-owned SVG files without traversing user directories."""
managed: list[Path] = []
for dirname, filename_re in (
("svg", _MANAGED_PRIMARY_SVG_RE),
("svg-flat", _MANAGED_FLAT_SVG_RE),
):
svg_dir = output_dir / dirname
if svg_dir.is_symlink():
managed.append(svg_dir)
continue
if not svg_dir.is_dir():
continue
managed.extend(
path
for path in svg_dir.iterdir()
if filename_re.fullmatch(path.name)
and (path.is_file() or path.is_symlink())
)
inheritance = svg_dir / "inheritance.json"
if dirname == "svg" and _path_lexists(inheritance):
managed.append(inheritance)
return managed
def _referenced_local_paths(
output_dir: Path,
svg_paths: list[Path],
) -> set[Path]:
"""Resolve local media referenced by converter-owned SVGs."""
referenced: set[Path] = set()
output_abs = output_dir.absolute()
for svg_path in svg_paths:
if svg_path.is_symlink() or not svg_path.is_file():
continue
try:
svg_text = svg_path.read_text(encoding="utf-8")
except (OSError, UnicodeError):
continue
for raw_href in _SVG_HREF_RE.findall(svg_text):
href = unescape(raw_href)
parsed = urlsplit(href)
if parsed.scheme or parsed.netloc or not parsed.path:
continue
href_path = unquote(parsed.path)
if Path(href_path).is_absolute():
continue
target = Path(os.path.normpath(str(svg_path.parent / href_path)))
try:
relative = target.absolute().relative_to(output_abs)
except ValueError:
continue
if relative.parts:
referenced.add(relative)
return referenced
def _validated_relative_paths(paths: set[str | Path]) -> set[Path]:
"""Normalize caller-supplied managed paths and reject output escapes."""
normalized: set[Path] = set()
for value in paths:
path = Path(value)
if (
path.drive
or path.anchor
or path.is_absolute()
or not path.parts
or ".." in path.parts
):
raise ValueError(f"Managed artifact path must stay relative: {value}")
normalized.add(path)
return normalized
def _reject_symlink_ancestors(
root: Path,
relative_paths: set[Path],
) -> None:
"""Reject managed paths that would traverse a preserved user symlink."""
for relative in relative_paths:
current = root
for component in relative.parts[:-1]:
current /= component
if current.is_symlink():
raise RuntimeError(
"Managed artifact path crosses an unmanaged symlink: "
f"{relative}"
)
def _remove_managed_paths(candidate_dir: Path, relative_paths: set[Path]) -> None:
"""Remove only the previous converter roster from a candidate workspace."""
_reject_symlink_ancestors(candidate_dir, relative_paths)
parents: set[Path] = set()
for relative in sorted(
relative_paths,
key=lambda item: len(item.parts),
reverse=True,
):
target = candidate_dir / relative
if target.is_symlink() or target.is_file():
target.unlink()
elif target.is_dir():
raise RuntimeError(
"Managed artifact path collides with a preserved directory: "
f"{relative}"
)
parent = target.parent
while parent != candidate_dir:
parents.add(parent)
parent = parent.parent
for parent in sorted(parents, key=lambda item: len(item.parts), reverse=True):
if parent.is_symlink() or not parent.is_dir():
continue
try:
parent.rmdir()
except OSError:
pass
def _overlay_staged_tree(staged_dir: Path, candidate_dir: Path) -> None:
"""Overlay generated artifacts without overwriting unmanaged user files."""
for source in sorted(staged_dir.rglob("*")):
relative = source.relative_to(staged_dir)
target = candidate_dir / relative
_reject_symlink_ancestors(candidate_dir, {relative})
if source.is_symlink():
raise RuntimeError(
f"Generated artifact must not be a symlink: {relative}"
)
if source.is_dir():
if (
target.is_symlink()
or (_path_lexists(target) and not target.is_dir())
):
raise RuntimeError(
f"Generated artifact collides with unmanaged path: {relative}"
)
target.mkdir(parents=True, exist_ok=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
if _path_lexists(target):
if target.is_dir() or target.is_symlink():
raise RuntimeError(
f"Generated artifact collides with unmanaged path: {relative}"
)
if target.read_bytes() != source.read_bytes():
raise RuntimeError(
f"Generated artifact collides with unmanaged file: {relative}"
)
shutil.copy2(source, target)
def publish_staged_workspace(
output_dir: Path,
staged_dir: Path,
*,
managed_root_files: set[str | Path] | None = None,
managed_relative_paths: set[str | Path] | None = None,
) -> None:
"""Atomically publish generated artifacts while preserving user files.
Converter-owned SVGs, their local media references, and the named managed
artifacts are replaced as one roster. Everything else already present in
the output directory is copied into the candidate unchanged.
"""
output_dir = output_dir.absolute()
staged_dir = staged_dir.absolute()
if (
output_dir == staged_dir
or output_dir in staged_dir.parents
or staged_dir in output_dir.parents
):
raise ValueError(
"Staged and output workspaces must not contain one another"
)
output_resolved = output_dir.resolve(strict=False)
try:
Path.cwd().resolve().relative_to(output_resolved)
except ValueError:
pass
else:
raise RuntimeError(
"Output workspace must not contain the current working directory"
)
if not staged_dir.is_dir():
raise ValueError(f"Staged workspace does not exist: {staged_dir}")
if (
output_dir.is_symlink()
or (_path_lexists(output_dir) and not output_dir.is_dir())
):
raise RuntimeError(f"Output path must be a real directory: {output_dir}")
output_dir.parent.mkdir(parents=True, exist_ok=True)
transaction_dir = Path(tempfile.mkdtemp(
prefix=f".{output_dir.name}.publish-",
dir=output_dir.parent,
))
candidate_dir = transaction_dir / "candidate"
backup_dir = transaction_dir / "previous"
preserve_backup = False
try:
if output_dir.is_dir():
shutil.copytree(output_dir, candidate_dir, symlinks=True)
else:
candidate_dir.mkdir()
managed_svg = _managed_svg_paths(output_dir)
relative_paths = {
path.relative_to(output_dir)
for path in managed_svg
}
relative_paths.update(_referenced_local_paths(output_dir, managed_svg))
relative_paths.add(Path("conversion-report.json"))
relative_paths.update(_validated_relative_paths(managed_root_files or set()))
relative_paths.update(_validated_relative_paths(managed_relative_paths or set()))
_remove_managed_paths(candidate_dir, relative_paths)
_overlay_staged_tree(staged_dir, candidate_dir)
if output_dir.is_dir():
try:
os.replace(output_dir, backup_dir)
os.replace(candidate_dir, output_dir)
except BaseException as publish_error:
try:
if _path_lexists(backup_dir):
if _path_lexists(output_dir):
failed_output = transaction_dir / "failed-publish"
os.replace(output_dir, failed_output)
os.replace(backup_dir, output_dir)
except BaseException as restore_error:
if (
not _path_lexists(backup_dir)
and _path_lexists(output_dir)
):
raise publish_error
preserve_backup = _path_lexists(backup_dir)
raise RuntimeError(
"Failed to publish the new workspace and restore the "
"previous workspace; recovery directory: "
f"{transaction_dir}"
) from restore_error
raise
else:
os.replace(candidate_dir, output_dir)
finally:
if not preserve_backup:
shutil.rmtree(transaction_dir, ignore_errors=True)
def _write_artifact_tree(
output_dir: Path,
result: ConvertResult,
options: ConvertOptions,
) -> None:
"""Write a complete converter roster into an empty staging directory.
Layout:
- ``svg/`` primary view (layered when emitted, otherwise flat)
- ``svg-flat/`` self-contained per-slide renders (only in "both" mode)
- ``<media_subdir>/`` shared image assets, referenced by both views
"""
output_dir.mkdir(parents=True, exist_ok=True)
svg_dir = output_dir / "svg"
svg_dir.mkdir(exist_ok=True)
media_dir = output_dir / options.media_subdir
media_written: dict[str, bytes] = {}
def _collect_media(media: dict[str, bytes]) -> None:
for filename, blob in media.items():
_validate_media_filename(filename)
if filename in media_written:
if media_written[filename] != blob:
raise RuntimeError(
f"Asset filename collision with different bytes: {filename}"
)
continue
media_written[filename] = blob
# Layered mode: write masters and layouts first so they sort ahead of slides.
for art in result.masters:
(svg_dir / art.filename).write_text(art.svg, encoding="utf-8")
_collect_media(art.media_files)
for art in result.layouts:
(svg_dir / art.filename).write_text(art.svg, encoding="utf-8")
_collect_media(art.media_files)
# Slides (primary view).
for art in result.slides:
target = svg_dir / f"slide_{art.index:02d}.svg"
target.write_text(art.svg, encoding="utf-8")
_collect_media(art.media_files)
# Inheritance graph alongside the layered SVGs (only meaningful when we
# actually emitted a layered view).
if options.inheritance_mode in {"layered", "both"}:
_write_inheritance_json(svg_dir, result)
# Flat companion view (only when result.flat_slides is populated).
if result.flat_slides:
flat_dir = output_dir / "svg-flat"
flat_dir.mkdir(exist_ok=True)
for art in result.flat_slides:
target = flat_dir / f"slide_{art.index:02d}.svg"
target.write_text(art.svg, encoding="utf-8")
_collect_media(art.media_files)
_write_conversion_report(output_dir, result)
if media_written:
media_dir.mkdir(parents=True, exist_ok=True)
for filename, blob in media_written.items():
target = media_dir / filename
if _path_lexists(target):
if (
target.is_symlink()
or not target.is_file()
or target.read_bytes() != blob
):
raise RuntimeError(
f"Asset filename collision with different bytes: {filename}"
)
continue
target.write_bytes(blob)
def _write_artifacts(
output_dir: Path,
result: ConvertResult,
options: ConvertOptions,
) -> None:
"""Stage a complete conversion, then atomically publish its exact roster."""
output_dir = output_dir.absolute()
output_dir.parent.mkdir(parents=True, exist_ok=True)
staging_root = Path(tempfile.mkdtemp(
prefix=f".{output_dir.name}.convert-",
dir=output_dir.parent,
))
staged_dir = staging_root / "generated"
try:
_write_artifact_tree(staged_dir, result, options)
publish_staged_workspace(output_dir, staged_dir)
finally:
shutil.rmtree(staging_root, ignore_errors=True)
def _write_conversion_report(output_dir: Path, result: ConvertResult) -> None:
"""Write the user-visible tolerant-import report."""
report = {
"schemaVersion": 1,
"source": result.source_file,
"mode": "strict" if result.strict else "tolerant",
"summary": {
"slides": len(result.slides),
"warnings": len(result.diagnostics),
},
"diagnostics": [item.to_dict() for item in result.diagnostics],
}
(output_dir / "conversion-report.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
def _write_inheritance_json(svg_dir: Path, result: ConvertResult) -> None:
"""Record layered parentage plus source-owned shape-visibility booleans."""
layout_by_path = {art.part_path: art.filename for art in result.layouts}
master_by_path = {art.part_path: art.filename for art in result.masters}
inheritance = {
"masters": [
{
"file": art.filename,
"partPath": art.part_path,
"themePath": art.theme_part_path,
}
for art in result.masters
],
"layouts": [
{
"file": art.filename,
"partPath": art.part_path,
"master": master_by_path.get(art.parent_master_part_path or ""),
"parentPartPath": art.parent_master_part_path,
"themePath": art.theme_part_path,
"showMasterShapes": art.show_master_shapes,
}
for art in result.layouts
],
"slides": [
{
"file": f"slide_{slide.index:02d}.svg",
"index": slide.index,
"layout": layout_by_path.get(slide.layout_part_path or ""),
"master": master_by_path.get(slide.master_part_path or ""),
"showInheritedShapes": slide.show_inherited_shapes,
}
for slide in result.slides
],
}
(svg_dir / "inheritance.json").write_text(
json.dumps(inheritance, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
@@ -0,0 +1,165 @@
"""DrawingML <a:custGeom> -> SVG <path d="..."> conversion.
Reverse of svg_to_pptx/drawingml/paths.py path_commands_to_drawingml.
Path command mapping:
<a:moveTo> -> M
<a:lnTo> -> L
<a:cubicBezTo> -> C
<a:quadBezTo> -> Q
<a:arcTo> -> A (DrawingML uses center + sweep angles; we convert
to SVG endpoint parameterization)
<a:close/> -> Z
DrawingML <a:path w="..." h="..."> defines a shape-local coordinate system.
Each path resolves guide formulas against that local coordinate system, then
its coordinates are projected into the shape's absolute SVG frame.
"""
from __future__ import annotations
from dataclasses import dataclass
from xml.etree import ElementTree as ET
from pptx_shapes import FormulaEvaluationError, FormulaEvaluator
from .emu_units import NS, Xfrm
from .preset_registry_to_svg import render_evaluated_path
@dataclass(frozen=True)
class _EvaluatedCommand:
name: str
parameters: tuple[float, ...]
def convert_custom_geom(
cust_geom: ET.Element,
xfrm: Xfrm,
) -> str | None:
"""Evaluate a complete custom geometry and return absolute SVG path data."""
path_lst = cust_geom.find("a:pathLst", NS)
if path_lst is None:
return None
shape_evaluator = _custom_geometry_evaluator(cust_geom, xfrm.w, xfrm.h)
paths = [
_convert_one_path(path_elem, cust_geom, xfrm, shape_evaluator)
for path_elem in path_lst.findall("a:path", NS)
]
rendered = [path for path in paths if path]
return " ".join(rendered) if rendered else None
def _custom_geometry_evaluator(
cust_geom: ET.Element,
width: float,
height: float,
) -> FormulaEvaluator:
evaluator = FormulaEvaluator(width, height)
for list_name in ("avLst", "gdLst"):
guide_list = cust_geom.find(f"a:{list_name}", NS)
if guide_list is None:
continue
for guide in guide_list.findall("a:gd", NS):
name = guide.attrib.get("name", "").strip()
formula = guide.attrib.get("fmla", "").strip()
if not name or not formula:
raise FormulaEvaluationError(
f"Custom geometry {list_name} contains an incomplete guide"
)
evaluator.bind(name, evaluator.evaluate(formula))
return evaluator
def _convert_one_path(
path_elem: ET.Element,
cust_geom: ET.Element,
xfrm: Xfrm,
shape_evaluator: FormulaEvaluator,
) -> str:
coordinate_width = _path_extent(
path_elem.get("w"),
xfrm.w,
shape_evaluator,
)
coordinate_height = _path_extent(
path_elem.get("h"),
xfrm.h,
shape_evaluator,
)
path_evaluator = _custom_geometry_evaluator(
cust_geom,
coordinate_width,
coordinate_height,
)
commands = tuple(
_evaluate_path_command(command, path_evaluator)
for command in path_elem
if isinstance(command.tag, str)
)
return render_evaluated_path(
commands,
x=xfrm.x,
y=xfrm.y,
width=xfrm.w,
height=xfrm.h,
coordinate_width=coordinate_width,
coordinate_height=coordinate_height,
)
def _evaluate_path_command(
element: ET.Element,
evaluator: FormulaEvaluator,
) -> _EvaluatedCommand:
name = element.tag.rsplit("}", 1)[-1]
if name == "arcTo":
values = tuple(
evaluator.evaluate_value(_required_attr(element, attr))
for attr in ("wR", "hR", "stAng", "swAng")
)
return _EvaluatedCommand(name=name, parameters=values)
expected_points = {
"moveTo": 1,
"lnTo": 1,
"quadBezTo": 2,
"cubicBezTo": 3,
"close": 0,
}.get(name)
if expected_points is None:
raise FormulaEvaluationError(
f"Unsupported custom geometry path command: {name!r}"
)
points = element.findall("a:pt", NS)
if len(points) != expected_points:
raise FormulaEvaluationError(
f"Custom path command {name!r} expects {expected_points} point(s), "
f"found {len(points)}"
)
values = tuple(
evaluator.evaluate_value(_required_attr(point, coordinate))
for point in points
for coordinate in ("x", "y")
)
return _EvaluatedCommand(name=name, parameters=values)
def _path_extent(
raw: str | None,
shape_extent: float,
evaluator: FormulaEvaluator,
) -> float:
if raw is None:
return shape_extent
value = evaluator.evaluate_value(raw)
return shape_extent if value == 0 else value
def _required_attr(element: ET.Element, name: str) -> str:
value = element.attrib.get(name)
if value is None or not value.strip():
raise FormulaEvaluationError(
f"Custom geometry element {element.tag.rsplit('}', 1)[-1]!r} "
f"requires attribute {name!r}"
)
return value.strip()
@@ -0,0 +1,410 @@
"""Convert the closed DrawingML effect subset into project SVG filters.
One classifiable outer shadow or one glow maps to the public shadow/glow
contract. Every other source effect receives explicit blocking metadata; the
importer never relabels it as another native effect or drops it silently.
"""
from __future__ import annotations
import math
import re
from dataclasses import dataclass
from xml.etree import ElementTree as ET
from pptx_effects import unsupported_effect_metadata
from pptx_shapes.formula import OOXML_COORDINATE_MAX
from .color_resolver import COLOR_TAGS, ColorPalette, resolve_color
from .emu_units import NS, emu_to_px, fmt_num, format_ooxml_alpha
_OOXML_INTEGER_RE = re.compile(r"[+-]?\d+")
_OOXML_HEX_COLOR_RE = re.compile(r"[0-9A-Fa-f]{6}")
_DRAWINGML_NAMESPACE = NS["a"]
_DRAWINGML_TAG_PREFIX = f"{{{_DRAWINGML_NAMESPACE}}}"
_EFFECT_CONTAINER_NAMES = frozenset({"effectLst", "effectDag"})
_OUTER_SHADOW_ATTRIBUTES = frozenset({
"algn",
"blurRad",
"dir",
"dist",
"kx",
"ky",
"rotWithShape",
"sx",
"sy",
})
_OUTER_SHADOW_ALIGNMENTS = frozenset({
"b",
"bl",
"br",
"ctr",
"l",
"r",
"t",
"tl",
"tr",
})
@dataclass(frozen=True)
class EffectResult:
"""One supported SVG filter or one explicit unsupported-source marker."""
filter_id: str | None = None
defs: tuple[str, ...] = ()
metadata: tuple[tuple[str, str], ...] = ()
@classmethod
def unsupported(cls, reason: str) -> "EffectResult":
return cls(metadata=tuple(unsupported_effect_metadata(reason).items()))
def unsupported_target_effect_metadata(
sp_pr: ET.Element | None,
target: str,
) -> dict[str, str]:
"""Mark source effects that cannot attach to this SVG target type."""
if sp_pr is None:
return {}
effect_names: list[str] = []
for container in sp_pr:
if (
not isinstance(container.tag, str)
or _local_name(container) not in _EFFECT_CONTAINER_NAMES
):
continue
container_name = _local_name(container)
if container_name == "effectDag":
effect_names.append(container_name)
continue
effect_names.extend(
_local_name(child)
for child in container
if isinstance(child.tag, str)
)
if not effect_names:
return {}
return unsupported_effect_metadata(
f"unsupported-effect-target:{target}:" + ",".join(effect_names)
)
def convert_effects(
sp_pr: ET.Element | None,
palette: ColorPalette | None,
*,
id_prefix: str = "fx",
id_seq: list[int] | None = None,
target_rotation_degrees: float = 0.0,
) -> EffectResult:
"""Return one supported filter or blocking metadata for source effects."""
if sp_pr is None:
return EffectResult()
containers = [
child
for child in sp_pr
if isinstance(child.tag, str)
and _local_name(child) in _EFFECT_CONTAINER_NAMES
]
if not containers:
return EffectResult()
container_names = [_local_name(child) for child in containers]
if len(containers) != 1:
return EffectResult.unsupported(
"multiple-effect-containers:" + ",".join(container_names)
)
container = containers[0]
container_name = container_names[0]
if not container.tag.startswith(_DRAWINGML_TAG_PREFIX):
return EffectResult.unsupported(
f"invalid-effect-container-namespace:{container_name}"
)
if container_name == "effectDag":
return EffectResult.unsupported("unsupported-effect-container:effectDag")
effects = [
child
for child in container
if isinstance(child.tag, str)
]
if not effects:
return EffectResult()
names = [child.tag.split("}", 1)[-1] for child in effects]
if len(effects) != 1:
return EffectResult.unsupported(
"multiple-effects:" + ",".join(names)
)
effect = effects[0]
effect_name = names[0]
if not effect.tag.startswith(_DRAWINGML_TAG_PREFIX):
return EffectResult.unsupported(
f"invalid-effect-namespace:{effect_name}"
)
try:
if effect_name == "outerShdw":
unsupported_attributes = _unsupported_outer_shadow_attributes(
effect,
target_rotation_degrees=target_rotation_degrees,
)
if unsupported_attributes:
return EffectResult.unsupported(
"unsupported-effect-attributes:outerShdw:"
+ ",".join(unsupported_attributes)
)
primitives = _outer_shadow(effect, palette)
elif effect_name == "glow":
primitives = _glow(effect, palette)
else:
return EffectResult.unsupported(
f"unsupported-effect:{effect_name}"
)
except (OverflowError, TypeError, ValueError) as exc:
return EffectResult.unsupported(
f"invalid-effect:{effect_name}:{exc}"
)
if id_seq is None:
id_seq = [0]
id_seq[0] += 1
filter_id = f"{id_prefix}{id_seq[0]}"
# Filter region needs to extend beyond the bounding box to render shadows
# and glows; choose generous defaults.
filter_x = "-25%"
filter_y = "-25%"
filter_w = "150%"
filter_h = "150%"
defs_xml = (
f'<filter id="{filter_id}" x="{filter_x}" y="{filter_y}" '
f'width="{filter_w}" height="{filter_h}">'
+ primitives
+ "</filter>"
)
return EffectResult(filter_id=filter_id, defs=(defs_xml,))
def _color_alpha(elem: ET.Element, palette: ColorPalette | None) -> tuple[str, float]:
direct_children = [
child
for child in elem
if isinstance(child.tag, str)
]
colors = [
child
for child in direct_children
if _local_name(child) in COLOR_TAGS
]
if len(colors) != 1:
reason = "missing-color" if not colors else "multiple-colors"
raise ValueError(reason)
if len(direct_children) != 1:
extras = [
_local_name(child)
for child in direct_children
if child is not colors[0]
]
raise ValueError("unexpected-effect-child:" + ",".join(extras))
color = colors[0]
if not color.tag.startswith(_DRAWINGML_TAG_PREFIX):
raise ValueError(f"invalid-color-namespace:{_local_name(color)}")
_validate_color(color, palette)
hex_, alpha = resolve_color(color, palette)
if hex_ is None:
raise ValueError(f"unresolvable-color:{_local_name(color)}")
return hex_, alpha
def _validate_color(
color: ET.Element,
palette: ColorPalette | None,
) -> None:
"""Validate the color subset resolved into one SVG filter paint."""
color_name = _local_name(color)
if color_name == "srgbClr":
raw = color.get("val", "")
if _OOXML_HEX_COLOR_RE.fullmatch(raw) is None:
raise ValueError(f"invalid-color:{color_name}")
elif color_name == "schemeClr":
raw = (color.get("val") or "").strip()
resolved = palette.resolve_scheme(raw) if palette is not None else None
if resolved is None or _OOXML_HEX_COLOR_RE.fullmatch(resolved) is None:
raise ValueError(f"unresolvable-color:{color_name}")
elif color_name == "sysClr":
if not (color.get("val") or "").strip():
raise ValueError(f"invalid-color:{color_name}")
raw = color.get("lastClr") or ""
if _OOXML_HEX_COLOR_RE.fullmatch(raw) is None:
raise ValueError(f"unresolvable-color:{color_name}")
elif color_name == "hslClr":
_required_integer(color, "hue", 0, 21_599_999)
_required_integer(color, "sat", 0, 100000)
_required_integer(color, "lum", 0, 100000)
elif color_name == "scrgbClr":
for attr in ("r", "g", "b"):
_required_integer(color, attr, 0, 100000)
elif not (color.get("val") or "").strip():
raise ValueError(f"invalid-color:{color_name}")
def _required_integer(
elem: ET.Element,
attr: str,
minimum: int,
maximum: int,
) -> int:
raw = elem.get(attr)
if raw is None:
raise ValueError(f"missing-{_local_name(elem)}-{attr}")
token = raw.strip()
if _OOXML_INTEGER_RE.fullmatch(token) is None:
raise ValueError(f"invalid-{_local_name(elem)}-{attr}")
value = int(token)
if not minimum <= value <= maximum:
raise ValueError(f"invalid-{_local_name(elem)}-{attr}")
return value
def _local_name(elem: ET.Element) -> str:
return elem.tag.rsplit("}", 1)[-1]
def _effect_integer(
elem: ET.Element,
attr: str,
*,
default: int = 0,
non_negative: bool = False,
maximum: int | None = None,
) -> int:
raw = elem.get(attr)
if raw is None:
value = default
else:
token = raw.strip()
if _OOXML_INTEGER_RE.fullmatch(token) is None:
raise ValueError(f"{attr}={raw!r}")
value = int(token)
if (
(non_negative and value < 0)
or (maximum is not None and value > maximum)
):
raise ValueError(f"{attr}={raw!r}")
return value
def _unsupported_outer_shadow_attributes(
elem: ET.Element,
*,
target_rotation_degrees: float,
) -> tuple[str, ...]:
"""Return source shadow attributes the local SVG filter cannot preserve."""
unsupported = set(elem.attrib) - _OUTER_SHADOW_ATTRIBUTES
neutral_transforms = (
("sx", 100000),
("sy", 100000),
("kx", 0),
("ky", 0),
)
for attr, neutral in neutral_transforms:
if attr in elem.attrib and _effect_integer(elem, attr) != neutral:
unsupported.add(attr)
raw_alignment = elem.get("algn")
if (
raw_alignment is not None
and raw_alignment.strip() not in _OUTER_SHADOW_ALIGNMENTS
):
raise ValueError(f"algn={raw_alignment!r}")
raw_rotates = elem.get("rotWithShape")
if raw_rotates is None:
rotates_with_shape = True
else:
token = raw_rotates.strip()
if token in {"1", "true"}:
rotates_with_shape = True
elif token in {"0", "false"}:
rotates_with_shape = False
else:
raise ValueError(f"rotWithShape={raw_rotates!r}")
target_is_rotated = not math.isclose(
math.remainder(target_rotation_degrees, 360.0),
0.0,
abs_tol=1e-9,
)
if rotates_with_shape and target_is_rotated:
# CT_OuterShadowEffect defaults rotWithShape to true, while the local
# SVG-to-PPTX mapping writes false. Blocking the visible distinction
# prevents the next export from changing the source shadow direction.
unsupported.add("rotWithShape")
return tuple(sorted(unsupported))
def _direction_offset(elem: ET.Element) -> tuple[float, float]:
"""Read dir / dist into (dx, dy) px."""
direction_units = _effect_integer(
elem,
"dir",
non_negative=True,
maximum=21_599_999,
)
dist_emu = _effect_integer(
elem,
"dist",
non_negative=True,
maximum=OOXML_COORDINATE_MAX,
)
direction_deg = direction_units / 60000.0
dist_px = emu_to_px(dist_emu)
rad = math.radians(direction_deg)
return dist_px * math.cos(rad), dist_px * math.sin(rad)
def _blur_radius(elem: ET.Element, attr: str) -> float:
return emu_to_px(_effect_integer(
elem,
attr,
non_negative=True,
maximum=OOXML_COORDINATE_MAX,
))
def _outer_shadow(
elem: ET.Element,
palette: ColorPalette | None,
) -> str:
dx, dy = _direction_offset(elem)
blur = _blur_radius(elem, "blurRad")
dx_token = fmt_num(dx, 8)
dy_token = fmt_num(dy, 8)
if abs(float(dx_token)) <= 0.01 and abs(float(dy_token)) <= 0.01:
raise ValueError("offset-is-not-classifiable")
color, alpha = _color_alpha(elem, palette)
# std deviation ~= blur radius / 2 (rough; PowerPoint shadows are larger)
std = blur / 2.0
# Use feDropShadow for compactness — it's well-supported in modern browsers.
return (
f'<feDropShadow dx="{dx_token}" dy="{dy_token}" '
f'stdDeviation="{fmt_num(std, 8)}" '
f'flood-color="{color}" '
f'flood-opacity="{format_ooxml_alpha(alpha)}"/>'
)
def _glow(elem: ET.Element, palette: ColorPalette | None) -> str:
if elem.get("rad") is None:
raise ValueError("missing-rad")
rad = _blur_radius(elem, "rad")
color, alpha = _color_alpha(elem, palette)
# svg_to_pptx maps stdDeviation directly back to a:glow@rad.
std = rad
return (
f'<feGaussianBlur in="SourceAlpha" stdDeviation="{fmt_num(std, 8)}" result="blurred"/>'
f'<feFlood flood-color="{color}" '
f'flood-opacity="{format_ooxml_alpha(alpha)}" result="flood"/>'
f'<feComposite in="flood" in2="blurred" operator="in" result="glow"/>'
f'<feMerge><feMergeNode in="glow"/><feMergeNode in="SourceGraphic"/></feMerge>'
)
@@ -0,0 +1,258 @@
"""EMU <-> pixel conversion and DrawingML unit constants.
Mirrors svg_to_pptx/drawingml/utils.py and pptx_package/dimensions.py, in reverse.
DrawingML unit conventions:
- Coordinates / sizes: EMU (English Metric Unit). 914400 EMU = 1 inch = 96 px.
- Font size: hundredths of a point. 1 px = 0.75 pt = 75 hundredths-of-a-point.
- Angle: 60000ths of a degree.
- Color tint/shade/lumMod/lumOff/satMod: percent in 1000ths (100% = 100000).
- srcRect / fillRect: percent in 1000ths of the unit rect.
"""
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP, localcontext
from xml.etree import ElementTree as ET
EMU_PER_INCH = 914400
EMU_PER_PX = 9525 # 96 dpi
HUNDREDTHS_PT_PER_PX = 75 # 1 px = 0.75 pt = 75 hundredths
ANGLE_UNIT = 60000 # 1 degree = 60000 angle units
PERCENT_UNIT = 100000 # 100% = 100000 (DrawingML "ST_PositivePercentage")
SRCRECT_UNIT = 100000 # srcRect l/t/r/b are in 1000ths of percent (i.e. 100000 = 100%)
# Namespaces used throughout OOXML
NS = {
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"rel": "http://schemas.openxmlformats.org/package/2006/relationships",
"ct": "http://schemas.openxmlformats.org/package/2006/content-types",
"asvg": "http://schemas.microsoft.com/office/drawing/2016/SVG/main",
"mc": "http://schemas.openxmlformats.org/markup-compatibility/2006",
}
# Register so ET output emits clean prefixes (writers normally don't need this
# since the SVG output uses the SVG namespace, but keep it consistent).
for prefix, uri in NS.items():
try:
ET.register_namespace(prefix, uri)
except (ValueError, AttributeError):
pass
# ---------------------------------------------------------------------------
# Length conversions
# ---------------------------------------------------------------------------
def emu_to_px(emu: float | int | None, default: float = 0.0) -> float:
"""Convert EMU to SVG px (96 dpi). None / unparsable -> default."""
if emu is None:
return default
try:
return float(emu) / EMU_PER_PX
except (ValueError, TypeError):
return default
def emu_attr_to_px(elem: ET.Element | None, attr: str, default: float = 0.0) -> float:
"""Read EMU integer attribute and return px."""
if elem is None:
return default
return emu_to_px(elem.get(attr), default)
def hundredths_pt_to_px(val: float | int | str | None, default: float = 0.0) -> float:
"""Convert font size (a:rPr@sz) to px. 100 = 1 pt = 4/3 px."""
if val is None:
return default
try:
return float(val) / HUNDREDTHS_PT_PER_PX
except (ValueError, TypeError):
return default
def angle_to_deg(val: float | int | str | None, default: float = 0.0) -> float:
"""Convert DrawingML angle (1/60000 deg) to plain degrees."""
if val is None:
return default
try:
return float(val) / ANGLE_UNIT
except (ValueError, TypeError):
return default
def percent_to_ratio(val: float | int | str | None, default: float = 0.0) -> float:
"""Convert DrawingML percentage units (100000 = 100%) to a ratio."""
if val is None:
return default
try:
return float(val) / PERCENT_UNIT
except (ValueError, TypeError):
return default
def ooxml_bool(value: str | None, default: bool = False) -> bool:
"""Parse the boolean lexical forms accepted by OOXML."""
if value is None:
return default
normalized = value.strip().lower()
if normalized in {"1", "true", "on"}:
return True
if normalized in {"0", "false", "off"}:
return False
return default
# ---------------------------------------------------------------------------
# xfrm / transform parsing
# ---------------------------------------------------------------------------
class Xfrm:
"""Resolved <a:xfrm> in pixel space.
Attributes:
x, y: top-left position (px).
w, h: size (px).
rot: rotation in degrees, clockwise around the shape center.
flip_h: bool — horizontal flip.
flip_v: bool — vertical flip.
ch_x, ch_y, ch_w, ch_h: only set when this is a group <p:grpSpPr>'s
xfrm; describes the child coordinate frame (a:chOff / a:chExt).
None on leaf shapes.
"""
__slots__ = ("x", "y", "w", "h", "rot", "flip_h", "flip_v",
"ch_x", "ch_y", "ch_w", "ch_h")
def __init__(
self,
x: float = 0.0,
y: float = 0.0,
w: float = 0.0,
h: float = 0.0,
rot: float = 0.0,
flip_h: bool = False,
flip_v: bool = False,
ch_x: float | None = None,
ch_y: float | None = None,
ch_w: float | None = None,
ch_h: float | None = None,
) -> None:
self.x = x
self.y = y
self.w = w
self.h = h
self.rot = rot
self.flip_h = flip_h
self.flip_v = flip_v
self.ch_x = ch_x
self.ch_y = ch_y
self.ch_w = ch_w
self.ch_h = ch_h
def __repr__(self) -> str:
parts = [f"x={self.x:.1f}", f"y={self.y:.1f}",
f"w={self.w:.1f}", f"h={self.h:.1f}"]
if self.rot:
parts.append(f"rot={self.rot:.2f}")
if self.flip_h:
parts.append("flipH")
if self.flip_v:
parts.append("flipV")
return f"Xfrm({', '.join(parts)})"
def to_svg_transform(self) -> str | None:
"""Build SVG transform attribute for rotation / flip around the center.
Returns None if no rotation / flip is needed.
"""
if not self.rot and not self.flip_h and not self.flip_v:
return None
cx = self.x + self.w / 2.0
cy = self.y + self.h / 2.0
parts: list[str] = []
if self.rot:
parts.append(
f"rotate({_fmt(self.rot, 8)} {_fmt(cx, 8)} {_fmt(cy, 8)})"
)
if self.flip_h or self.flip_v:
sx = -1 if self.flip_h else 1
sy = -1 if self.flip_v else 1
# scale around shape center
parts.append(f"translate({_fmt(cx, 8)} {_fmt(cy, 8)})")
parts.append(f"scale({sx} {sy})")
parts.append(f"translate({_fmt(-cx, 8)} {_fmt(-cy, 8)})")
return " ".join(parts) if parts else None
def parse_xfrm(xfrm_elem: ET.Element | None) -> Xfrm:
"""Parse <a:xfrm> into an Xfrm object. None -> zero Xfrm."""
if xfrm_elem is None:
return Xfrm()
rot = angle_to_deg(xfrm_elem.get("rot"))
flip_h = ooxml_bool(xfrm_elem.get("flipH"))
flip_v = ooxml_bool(xfrm_elem.get("flipV"))
off = xfrm_elem.find("a:off", NS)
ext = xfrm_elem.find("a:ext", NS)
ch_off = xfrm_elem.find("a:chOff", NS)
ch_ext = xfrm_elem.find("a:chExt", NS)
x = emu_attr_to_px(off, "x")
y = emu_attr_to_px(off, "y")
w = emu_attr_to_px(ext, "cx")
h = emu_attr_to_px(ext, "cy")
ch_x = emu_attr_to_px(ch_off, "x") if ch_off is not None else None
ch_y = emu_attr_to_px(ch_off, "y") if ch_off is not None else None
ch_w = emu_attr_to_px(ch_ext, "cx") if ch_ext is not None else None
ch_h = emu_attr_to_px(ch_ext, "cy") if ch_ext is not None else None
return Xfrm(x=x, y=y, w=w, h=h, rot=rot,
flip_h=flip_h, flip_v=flip_v,
ch_x=ch_x, ch_y=ch_y, ch_w=ch_w, ch_h=ch_h)
# ---------------------------------------------------------------------------
# Number formatting for SVG output
# ---------------------------------------------------------------------------
def _fmt(val: float, ndigits: int = 2) -> str:
"""Format a number for SVG attributes: trim trailing zeros, keep ints clean."""
if val == 0:
return "0"
rounded = round(val, ndigits)
if rounded == int(rounded):
return str(int(rounded))
s = f"{rounded:.{ndigits}f}"
# trim trailing zeros after decimal
if "." in s:
s = s.rstrip("0").rstrip(".")
return s
fmt_num = _fmt
def format_ooxml_unit_ratio(value: float) -> str:
"""Format a normalized ratio without losing 1/100000 OOXML precision."""
return _fmt(value, 5)
def format_ooxml_alpha(alpha: float) -> str:
"""Format one normalized OOXML alpha ratio."""
return format_ooxml_unit_ratio(alpha)
def format_canvas_px_from_emu(emu: int) -> str:
"""Format a slide-size coordinate with enough precision to recover EMU."""
with localcontext() as context:
context.prec = 32
value = Decimal(emu) / Decimal(EMU_PER_PX)
rounded = value.quantize(Decimal("0.00001"), rounding=ROUND_HALF_UP)
token = format(rounded, "f").rstrip("0").rstrip(".")
return token or "0"
@@ -0,0 +1,816 @@
"""DrawingML fill -> SVG fill conversion.
Handles:
- <a:solidFill> -> fill="#XXXXXX" (+ fill-opacity)
- <a:noFill/> -> fill="none"
- <a:gradFill> -> linearGradient/radialGradient in <defs>, fill="url(#id)"
- <a:blipFill> -> handled by pic_to_svg (this module short-circuits)
Returned FillResult is a struct of attribute dict + optional <defs> XML so the
slide assembler can collect gradient defs without conflicting IDs.
"""
from __future__ import annotations
import math
import re
from dataclasses import dataclass, field
from decimal import Decimal
from xml.etree import ElementTree as ET
from .color_resolver import (
COLOR_TAGS,
ColorPalette,
find_color_elem,
resolve_color,
resolve_solid_fill_color,
validate_no_fill,
)
from .emu_units import (
ANGLE_UNIT,
NS,
PERCENT_UNIT,
fmt_num,
format_ooxml_alpha,
format_ooxml_unit_ratio,
)
_OOXML_INTEGER_RE = re.compile(r"[+-]?[0-9]+")
_OOXML_PERCENT_LITERAL_RE = re.compile(
r"[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)%"
)
_OOXML_FULL_CIRCLE = 360 * ANGLE_UNIT
_OOXML_PERCENTAGE_MIN = Decimal(-(2**31)) / Decimal(PERCENT_UNIT)
_OOXML_PERCENTAGE_MAX = Decimal(2**31 - 1) / Decimal(PERCENT_UNIT)
_SVG_RADIAL_FOCUS_TOLERANCE = Decimal(1) / Decimal(PERCENT_UNIT)
_DRAWINGML_FILL_NAMES = (
"noFill",
"solidFill",
"gradFill",
"blipFill",
"pattFill",
"grpFill",
)
_DRAWINGML_FILL_TAGS = {
f"{{{NS['a']}}}{name}": name for name in _DRAWINGML_FILL_NAMES
}
@dataclass
class FillResult:
"""Resolved fill: SVG attributes to apply + optional <defs> entries."""
attrs: dict[str, str] = field(default_factory=dict)
defs: list[str] = field(default_factory=list) # XML strings of <linearGradient>/<radialGradient>
@classmethod
def none_fill(cls) -> "FillResult":
return cls(attrs={"fill": "none"})
@classmethod
def inherit(cls) -> "FillResult":
# No fill resolved — let caller decide whether to default
return cls()
def resolve_fill(
sp_pr: ET.Element | None,
palette: ColorPalette | None,
*,
id_prefix: str = "g",
id_seq: list[int] | None = None,
placeholder_hex: str | None = None,
) -> FillResult:
"""Inspect <p:spPr>'s fill children and emit an SVG fill descriptor.
Args:
sp_pr: <p:spPr> or any element that may directly hold a fill child.
palette: ColorPalette for scheme color resolution.
id_prefix: prefix for generated gradient IDs.
id_seq: external counter (single-element list) so callers can share
unique gradient IDs across the whole slide.
Returns:
FillResult. If no recognized fill is found, result.attrs is empty —
the caller should apply its own default (typically transparent /
inherit from the source SVG).
"""
if sp_pr is None:
return FillResult.inherit()
handlers = {
"noFill": _resolve_no_fill,
"solidFill": _resolve_solid_fill,
"gradFill": _resolve_grad_fill,
"blipFill": _resolve_blip_fill,
"pattFill": _resolve_patt_fill,
}
fill_name = _drawingml_fill_name(sp_pr)
fill_elem = sp_pr if fill_name is not None else None
if fill_elem is None:
fill_children = []
for child in sp_pr:
child_name = _drawingml_fill_name(child)
if child_name is not None:
fill_children.append((child, child_name))
if len(fill_children) > 1:
raise ValueError(
"DrawingML container must contain at most one fill"
)
if not fill_children:
return FillResult.inherit()
fill_elem, fill_name = fill_children[0]
if fill_name == "grpFill":
raise ValueError("Unsupported DrawingML fill: grpFill")
return handlers[fill_name](
fill_elem,
palette,
id_prefix,
id_seq,
placeholder_hex,
)
def _drawingml_fill_name(elem: ET.Element) -> str | None:
"""Return one exact DrawingML fill tag name or reject a namespace alias."""
name = _DRAWINGML_FILL_TAGS.get(elem.tag)
if name is not None:
return name
local_name = (
elem.tag.rsplit("}", 1)[-1]
if isinstance(elem.tag, str)
else ""
)
if local_name in _DRAWINGML_FILL_NAMES:
raise ValueError(
f"Invalid DrawingML fill element namespace: {local_name}"
)
return None
# ---------------------------------------------------------------------------
# Per-fill handlers
# ---------------------------------------------------------------------------
def _resolve_no_fill(elem, _palette, _prefix, _seq, _placeholder_hex) -> FillResult:
validate_no_fill(elem)
return FillResult.none_fill()
def _resolve_solid_fill(elem: ET.Element, palette: ColorPalette | None,
_prefix: str, _seq, placeholder_hex: str | None) -> FillResult:
hex_, alpha = resolve_solid_fill_color(
elem,
palette,
placeholder_hex=placeholder_hex,
)
attrs: dict[str, str] = {"fill": hex_}
if alpha < 1.0:
attrs["fill-opacity"] = format_ooxml_alpha(alpha)
return FillResult(attrs=attrs)
def _resolve_grad_fill(elem: ET.Element, palette: ColorPalette | None,
prefix: str, seq, placeholder_hex: str | None) -> FillResult:
"""Convert <a:gradFill> to an SVG linearGradient or radialGradient."""
_validate_gradient_attributes(elem)
_validate_gradient_rotation(elem)
_validate_gradient_flip(elem)
_validate_gradient_tile_rect(elem)
if seq is None:
seq = [0]
seq[0] += 1
grad_id = f"{prefix}grad{seq[0]}"
# Stops
gs_lists = elem.findall("a:gsLst", NS)
if len(gs_lists) != 1:
raise ValueError(
"DrawingML gradient fill requires exactly one gsLst"
)
gradient_stops = [
(gs, _gradient_stop_position(gs))
for gs in _gradient_stop_list(gs_lists[0])
]
if any(
current < previous
for (_, previous), (_, current) in zip(
gradient_stops,
gradient_stops[1:],
)
):
message = "DrawingML gradient stop positions must be nondecreasing"
if palette is None or palette.strict:
raise ValueError(message)
palette._diagnose(
"gradient-stop-order-normalized",
message,
"sort gradient stops by position while preserving equal positions",
)
gradient_stops.sort(key=lambda item: item[1])
stops_xml = []
for gs, pos_pct in gradient_stops:
color_elem = _gradient_stop_color(gs)
hex_, alpha = resolve_color(
color_elem,
palette,
placeholder_hex=placeholder_hex,
)
if hex_ is None:
raise ValueError(
"DrawingML gradient stop color cannot be resolved"
)
opacity_attr = (
f' stop-opacity="{format_ooxml_alpha(alpha)}"'
if alpha < 1.0
else ""
)
stops_xml.append(
f'<stop offset="{format_ooxml_unit_ratio(pos_pct)}" '
f'stop-color="{hex_}"{opacity_attr}/>'
)
# Linear vs radial vs path
linear_directions = elem.findall("a:lin", NS)
path_directions = elem.findall("a:path", NS)
if len(linear_directions) + len(path_directions) > 1:
raise ValueError(
"DrawingML gradient fill must contain at most one lin/path "
"direction"
)
_validate_gradient_child_structure(elem)
lin = linear_directions[0] if linear_directions else None
rad = path_directions[0] if path_directions else None
if lin is not None:
# ang is 1/60000 deg. 0° = horizontal left-to-right.
_validate_linear_gradient_structure(lin)
angle = _linear_gradient_angle(lin)
_validate_linear_gradient_scaling(lin, angle)
angle_deg = angle / ANGLE_UNIT
x1, y1, x2, y2 = _angle_to_unit_endpoints(angle_deg)
defs_xml = (
f'<linearGradient id="{grad_id}" '
f'x1="{fmt_num(x1, 4)}" y1="{fmt_num(y1, 4)}" '
f'x2="{fmt_num(x2, 4)}" y2="{fmt_num(y2, 4)}">'
+ "".join(stops_xml)
+ "</linearGradient>"
)
elif rad is not None:
_validate_path_gradient_structure(rad)
_validate_path_gradient_type(rad)
focus = _validate_path_gradient_focus(rad)
focus_attrs = ""
if focus is not None:
focus_x = focus["l"]
focus_y = focus["t"]
point_focus = (
focus_x + focus["r"] == Decimal(1)
and focus_y + focus["b"] == Decimal(1)
and Decimal(0) <= focus_x <= Decimal(1)
and Decimal(0) <= focus_y <= Decimal(1)
and (
(focus_x - Decimal("0.5")) ** 2
+ (focus_y - Decimal("0.5")) ** 2
<= Decimal("0.25") + _SVG_RADIAL_FOCUS_TOLERANCE
)
)
if (
point_focus
and (
focus_x != Decimal("0.5")
or focus_y != Decimal("0.5")
)
):
focus_attrs = (
f' fx="{format_ooxml_unit_ratio(float(focus_x))}"'
f' fy="{format_ooxml_unit_ratio(float(focus_y))}"'
)
elif not point_focus and palette is not None:
palette._diagnose(
"path-gradient-focus-normalized",
"DrawingML path gradient focus is not one point within "
"the canonical SVG radial circle",
"center the radial gradient while preserving its stops",
)
# Treat as radial regardless of path="circle" / "rect" / "shape" — SVG
# only has circle/ellipse. Point-style fillToRect retains its focus;
# the outer center and radius remain normalized.
defs_xml = (
f'<radialGradient id="{grad_id}" cx="0.5" cy="0.5" '
f'r="0.5"{focus_attrs}>'
+ "".join(stops_xml)
+ "</radialGradient>"
)
else:
# No direction specified — default to horizontal linear
defs_xml = (
f'<linearGradient id="{grad_id}" x1="0" y1="0" x2="1" y2="0">'
+ "".join(stops_xml)
+ "</linearGradient>"
)
return FillResult(
attrs={"fill": f"url(#{grad_id})"},
defs=[defs_xml],
)
def _gradient_stop_position(gs: ET.Element) -> float:
"""Parse one required DrawingML fixed-percentage stop position."""
raw = gs.get("pos")
if raw is None:
raise ValueError("DrawingML gradient stop requires a pos attribute")
token = raw.strip()
if _OOXML_INTEGER_RE.fullmatch(token) is None:
raise ValueError(
f"Invalid DrawingML gradient stop position: {raw!r}"
)
position = int(token)
if not 0 <= position <= PERCENT_UNIT:
raise ValueError(
f"DrawingML gradient stop position={position} is outside "
f"0..{PERCENT_UNIT}"
)
return position / PERCENT_UNIT
def _gradient_stop_list(gs_list: ET.Element) -> list[ET.Element]:
"""Validate one DrawingML gradient-stop list and return its stops."""
stops = list(gs_list)
stop_tag = f"{{{NS['a']}}}gs"
if (
gs_list.attrib
or (gs_list.text or "").strip()
or any(
stop.tag != stop_tag or (stop.tail or "").strip()
for stop in stops
)
):
raise ValueError("Invalid DrawingML gradient stop list structure")
if len(stops) < 2:
raise ValueError(
"DrawingML gradient fill requires at least two color stops"
)
return stops
def _gradient_stop_color(gs: ET.Element) -> ET.Element:
"""Return the single registered color child of one gradient stop."""
children = list(gs)
color_tags = {f"{{{NS['a']}}}{name}" for name in COLOR_TAGS}
if (
set(gs.attrib) != {"pos"}
or len(children) != 1
or children[0].tag not in color_tags
or (gs.text or "").strip()
or (children[0].tail or "").strip()
):
raise ValueError("Invalid DrawingML gradient stop structure")
return children[0]
def _linear_gradient_angle(lin: ET.Element) -> int:
"""Parse one optional DrawingML positive fixed angle."""
raw = lin.get("ang")
if raw is None:
return 0
token = raw.strip()
if _OOXML_INTEGER_RE.fullmatch(token) is None:
raise ValueError(f"Invalid DrawingML linear gradient angle: {raw!r}")
angle = int(token)
if not 0 <= angle < _OOXML_FULL_CIRCLE:
raise ValueError(
f"DrawingML linear gradient angle={angle} is outside "
f"0..{_OOXML_FULL_CIRCLE - 1}"
)
return angle
def _validate_linear_gradient_structure(lin: ET.Element) -> None:
"""Require one leaf a:lin with only the registered attributes."""
unsupported = sorted(set(lin.attrib) - {"ang", "scaled"})
if unsupported or list(lin) or (lin.text or "").strip():
details = ", ".join(unsupported) if unsupported else "payload"
raise ValueError(
f"Invalid DrawingML linear gradient structure: {details}"
)
def _validate_linear_gradient_scaling(
lin: ET.Element,
angle: int,
) -> None:
"""Require a scaling mode representable by unit-box SVG geometry."""
scaled = _parse_ooxml_boolean(
lin.get("scaled"),
default=False,
label="linear gradient scaled value",
)
quarter_turn = 90 * ANGLE_UNIT
if not scaled and angle % quarter_turn:
raise ValueError(
"Unscaled non-cardinal DrawingML linear gradients are not "
"representable by the normalized SVG mapping"
)
def _validate_gradient_rotation(gradient: ET.Element) -> None:
"""Require a gradient that rotates with its containing shape."""
rotates_with_shape = _parse_ooxml_boolean(
gradient.get("rotWithShape"),
default=True,
label="gradient rotWithShape value",
)
if not rotates_with_shape:
raise ValueError(
"DrawingML gradients that do not rotate with their shape are "
"not representable by the local SVG mapping"
)
def _validate_gradient_attributes(gradient: ET.Element) -> None:
"""Reject attributes outside the registered gradient-fill contract."""
unsupported = sorted(set(gradient.attrib) - {"flip", "rotWithShape"})
if unsupported:
raise ValueError(
"Unsupported DrawingML gradient fill attribute(s): "
+ ", ".join(unsupported)
)
def _validate_gradient_child_structure(gradient: ET.Element) -> None:
"""Require the registered DrawingML gradient-fill child sequence."""
namespace = NS["a"]
gs_list = f"{{{namespace}}}gsLst"
linear = f"{{{namespace}}}lin"
path = f"{{{namespace}}}path"
tile_rect = f"{{{namespace}}}tileRect"
child_tags = tuple(child.tag for child in gradient)
allowed_sequences = {
(gs_list,),
(gs_list, linear),
(gs_list, path),
(gs_list, tile_rect),
(gs_list, linear, tile_rect),
(gs_list, path, tile_rect),
}
if (
child_tags not in allowed_sequences
or (gradient.text or "").strip()
or any((child.tail or "").strip() for child in gradient)
):
raise ValueError(
"Invalid DrawingML gradient fill child structure"
)
def _validate_gradient_flip(gradient: ET.Element) -> None:
"""Reject gradient tile flipping absent from project SVG."""
flip = gradient.get("flip", "none")
if flip != "none":
raise ValueError(f"Unsupported DrawingML gradient flip: {flip!r}")
def _validate_gradient_tile_rect(gradient: ET.Element) -> None:
"""Accept only the full-area gradient tile rectangle."""
tile_rects = gradient.findall("a:tileRect", NS)
if len(tile_rects) > 1:
raise ValueError(
"DrawingML gradient fill must contain at most one tileRect"
)
if not tile_rects:
return
values = _relative_rect_values(
tile_rects[0],
label="gradient tileRect",
)
for value in values.values():
if value != 0:
raise ValueError(
"Non-zero DrawingML gradient tileRect is not representable "
"by the project SVG gradient mapping"
)
def _validate_path_gradient_focus(
path: ET.Element,
) -> dict[str, Decimal] | None:
"""Validate and return one path-gradient focus rectangle."""
focus_rects = path.findall("a:fillToRect", NS)
if len(focus_rects) > 1:
raise ValueError(
"DrawingML path gradient must contain at most one fillToRect"
)
if not focus_rects:
return None
values = _relative_rect_values(
focus_rects[0],
label="path gradient fillToRect",
)
return {
edge: values.get(edge, Decimal(0))
for edge in ("l", "t", "r", "b")
}
def _relative_rect_values(
rect: ET.Element,
*,
label: str,
) -> dict[str, Decimal]:
"""Validate one DrawingML relative-rectangle leaf and parse its edges."""
if (
set(rect.attrib) - {"l", "t", "r", "b"}
or list(rect)
or (rect.text or "").strip()
):
raise ValueError(f"Invalid DrawingML {label} structure")
return {
edge: _parse_ooxml_percentage(raw, label=f"{label} {edge}")
for edge, raw in rect.attrib.items()
}
def _parse_ooxml_percentage(raw: str, *, label: str) -> Decimal:
"""Parse one DrawingML ST_Percentage as an exact normalized ratio."""
token = raw.strip()
if _OOXML_INTEGER_RE.fullmatch(token) is not None:
value = Decimal(token) / Decimal(PERCENT_UNIT)
elif _OOXML_PERCENT_LITERAL_RE.fullmatch(token) is not None:
value = Decimal(token[:-1]) / Decimal(100)
else:
raise ValueError(f"Invalid DrawingML {label}: {raw!r}")
if not _OOXML_PERCENTAGE_MIN <= value <= _OOXML_PERCENTAGE_MAX:
raise ValueError(f"Invalid DrawingML {label}: {raw!r}")
return value
def _parse_ooxml_boolean(
raw: str | None,
*,
default: bool,
label: str,
) -> bool:
"""Parse one W3C XML Schema boolean without permissive aliases."""
if raw is None:
return default
token = raw.strip()
if token in {"1", "true"}:
return True
if token in {"0", "false"}:
return False
raise ValueError(f"Invalid DrawingML {label}: {raw!r}")
def _validate_path_gradient_type(path: ET.Element) -> None:
"""Require one registered DrawingML path-shade enum value."""
path_type = path.get("path", "rect")
if path_type not in {"circle", "rect", "shape"}:
raise ValueError(
f"Unsupported DrawingML path gradient type: {path_type!r}"
)
def _validate_path_gradient_structure(path: ET.Element) -> None:
"""Require only the registered path attribute and focus rectangle."""
unsupported = sorted(set(path.attrib) - {"path"})
children = list(path)
fill_to_rect_tag = f"{{{NS['a']}}}fillToRect"
has_invalid_payload = (
(path.text or "").strip()
or any(
child.tag != fill_to_rect_tag or (child.tail or "").strip()
for child in children
)
)
if unsupported or has_invalid_payload:
details = ", ".join(unsupported) if unsupported else "payload"
raise ValueError(
f"Invalid DrawingML path gradient structure: {details}"
)
def _resolve_blip_fill(_elem, _palette, _prefix, _seq, _placeholder_hex) -> FillResult:
"""blipFill on <p:spPr> means a shape filled with an image — handled at
pic_to_svg level. For now mark as transparent so the shape's outline
still draws and pic_to_svg can layer the image on top.
"""
return FillResult.none_fill()
def _resolve_patt_fill(elem: ET.Element, palette: ColorPalette | None,
prefix, seq, placeholder_hex: str | None) -> FillResult:
"""Pattern fills (<a:pattFill prst="..."/> with fg/bg colors)."""
fg = elem.find("a:fgClr", NS)
bg = elem.find("a:bgClr", NS)
fg_hex, fg_alpha = resolve_color(
find_color_elem(fg), palette, placeholder_hex=placeholder_hex,
)
bg_hex, bg_alpha = resolve_color(
find_color_elem(bg), palette, placeholder_hex=placeholder_hex,
)
if fg_hex is None:
return FillResult.inherit()
prst = elem.attrib.get("prst", "")
geom = _pattern_foreground(prst, fg_hex, fg_alpha)
if geom is None:
# Unsupported preset → degrade to solid fg color so the shape at
# least carries the right tone. Round-trip will lose the texture.
attrs: dict[str, str] = {"fill": fg_hex}
if fg_alpha < 1.0:
attrs["fill-opacity"] = format_ooxml_alpha(fg_alpha)
return FillResult(attrs=attrs)
tile_w, tile_h, fg_svg = geom
if seq is None:
seq = [0]
seq[0] += 1
pattern_id = f"{prefix}patt{seq[0]}"
bg_rect = ""
if bg_hex is not None:
bg_opacity = (
f' fill-opacity="{format_ooxml_alpha(bg_alpha)}"'
if bg_alpha < 1.0 else ""
)
bg_rect = (
f'<rect width="{tile_w}" height="{tile_h}" '
f'fill="{bg_hex}"{bg_opacity}/>'
)
# Tag with data attributes so the reverse exporter can rebuild <a:pattFill>
# faithfully (preset + fg/bg colors) instead of inferring from path geometry.
bg_attr = f' data-pptx-bg="{bg_hex}"' if bg_hex is not None else ""
pattern_xml = (
f'<pattern id="{pattern_id}" patternUnits="userSpaceOnUse" '
f'width="{tile_w}" height="{tile_h}" '
f'data-pptx-pattern="{prst}" data-pptx-fg="{fg_hex}"{bg_attr}>'
f'{bg_rect}{fg_svg}</pattern>'
)
return FillResult(
attrs={"fill": f"url(#{pattern_id})"},
defs=[pattern_xml],
)
# ---------------------------------------------------------------------------
# Per-preset SVG geometry for <a:pattFill prst="...">
#
# Each handler returns (tile_w, tile_h, foreground_svg). The caller wraps with
# the background rect + <pattern> element. None means "unsupported preset" and
# the caller degrades to a solid fg color.
# ---------------------------------------------------------------------------
def _pattern_foreground(prst: str, fg: str,
fg_alpha: float) -> tuple[int, int, str] | None:
stroke_op = (
f' stroke-opacity="{format_ooxml_alpha(fg_alpha)}"'
if fg_alpha < 1.0 else ""
)
fill_op = (
f' fill-opacity="{format_ooxml_alpha(fg_alpha)}"'
if fg_alpha < 1.0 else ""
)
# Diagonal stripes — tile size and stroke width pick the visual weight.
diag = {
"ltUpDiag": (8, 1.0, "up", False),
"dkUpDiag": (8, 2.0, "up", False),
"wdUpDiag": (16, 1.0, "up", False),
"dashUpDiag": (8, 1.0, "up", True),
"ltDnDiag": (8, 1.0, "dn", False),
"dkDnDiag": (8, 2.0, "dn", False),
"wdDnDiag": (16, 1.0, "dn", False),
"dashDnDiag": (8, 1.0, "dn", True),
}
if prst in diag:
tile, sw, direction, dashed = diag[prst]
dash = ' stroke-dasharray="3 2"' if dashed else ""
if direction == "up":
d = f"M -2 {tile} L {tile} -2 M 0 {tile + 2} L {tile + 2} 0"
else:
d = f"M -2 0 L {tile} {tile + 2} M 0 -2 L {tile + 2} {tile}"
return tile, tile, (
f'<path d="{d}" stroke="{fg}"{stroke_op} '
f'stroke-width="{fmt_num(sw)}" fill="none"{dash}/>'
)
# Horizontal / vertical lines.
line_specs = {
"horz": ("h", 8, 1.0, False),
"ltHorz": ("h", 8, 0.5, False),
"dkHorz": ("h", 8, 2.0, False),
"narHorz": ("h", 4, 1.0, False),
"dashHorz": ("h", 8, 1.0, True),
"vert": ("v", 8, 1.0, False),
"ltVert": ("v", 8, 0.5, False),
"dkVert": ("v", 8, 2.0, False),
"narVert": ("v", 4, 1.0, False),
"dashVert": ("v", 8, 1.0, True),
}
if prst in line_specs:
axis, tile, sw, dashed = line_specs[prst]
dash = ' stroke-dasharray="3 2"' if dashed else ""
mid = tile / 2.0
if axis == "h":
line = (
f'<line x1="0" y1="{fmt_num(mid)}" '
f'x2="{tile}" y2="{fmt_num(mid)}"'
)
else:
line = (
f'<line x1="{fmt_num(mid)}" y1="0" '
f'x2="{fmt_num(mid)}" y2="{tile}"'
)
return tile, tile, (
f'{line} stroke="{fg}"{stroke_op} '
f'stroke-width="{fmt_num(sw)}"{dash}/>'
)
# Grids / crosses.
if prst == "cross":
return 8, 8, (
f'<line x1="0" y1="4" x2="8" y2="4" stroke="{fg}"{stroke_op} stroke-width="1"/>'
f'<line x1="4" y1="0" x2="4" y2="8" stroke="{fg}"{stroke_op} stroke-width="1"/>'
)
if prst == "diagCross":
d = (
"M -2 8 L 8 -2 M 0 10 L 10 0 "
"M -2 0 L 8 10 M 0 -2 L 10 8"
)
return 8, 8, (
f'<path d="{d}" stroke="{fg}"{stroke_op} stroke-width="1" fill="none"/>'
)
if prst in ("smGrid", "lgGrid"):
tile = 4 if prst == "smGrid" else 16
# Lines along top + left edges; tiles together produce a uniform grid.
return tile, tile, (
f'<path d="M 0 0 L {tile} 0 M 0 0 L 0 {tile}" '
f'stroke="{fg}"{stroke_op} stroke-width="0.5" fill="none"/>'
)
if prst == "dotGrid":
# Dots at corners → tiling yields a uniform dot grid.
return 8, 8, (
f'<circle cx="0" cy="0" r="1" fill="{fg}"{fill_op}/>'
)
if prst == "dotDmnd":
return 8, 8, (
f'<circle cx="0" cy="0" r="1" fill="{fg}"{fill_op}/>'
f'<circle cx="4" cy="4" r="1" fill="{fg}"{fill_op}/>'
)
# Percentage shading — single centered dot whose area matches the target
# density. Approximates PowerPoint's stipple without per-tile artwork.
if prst.startswith("pct"):
try:
pct = float(prst[3:])
except ValueError:
return None
pct = max(0.0, min(pct, 100.0))
tile = 8
radius = math.sqrt(pct / 100.0 * tile * tile / math.pi)
radius = max(0.3, min(radius, tile / 2.0))
return tile, tile, (
f'<circle cx="{tile / 2}" cy="{tile / 2}" '
f'r="{fmt_num(radius, 3)}" fill="{fg}"{fill_op}/>'
)
return None
def _hex_distance(a: str, b: str) -> float:
"""Euclidean distance between two #RRGGBB colors."""
try:
ar, ag, ab = int(a[1:3], 16), int(a[3:5], 16), int(a[5:7], 16)
br, bg, bb = int(b[1:3], 16), int(b[3:5], 16), int(b[5:7], 16)
except (ValueError, IndexError):
return 255.0
return math.sqrt((ar - br) ** 2 + (ag - bg) ** 2 + (ab - bb) ** 2)
# ---------------------------------------------------------------------------
# Geometry helpers
# ---------------------------------------------------------------------------
def _angle_to_unit_endpoints(angle_deg: float) -> tuple[float, float, float, float]:
"""Convert a DrawingML linear gradient angle to SVG x1/y1/x2/y2 in unit box.
DrawingML 0° = horizontal pointing right; angle is clockwise.
SVG default linearGradient is also unit-box (objectBoundingBox).
"""
rad = math.radians(angle_deg % 360)
cos_a = math.cos(rad)
sin_a = math.sin(rad)
# Center of unit box
cx, cy = 0.5, 0.5
# Half-extent in the direction of the angle vector.
# We project the unit box onto the angle direction; the line endpoints are
# the projections of the box corners.
half = abs(cos_a) * 0.5 + abs(sin_a) * 0.5
x1 = cx - cos_a * half
y1 = cy - sin_a * half
x2 = cx + cos_a * half
y2 = cy + sin_a * half
return x1, y1, x2, y2
@@ -0,0 +1,37 @@
"""Structured diagnostics for tolerant PPTX-to-SVG import."""
from __future__ import annotations
from dataclasses import asdict, dataclass
@dataclass(frozen=True)
class ImportDiagnostic:
"""Describe one source-owned construct that required import recovery."""
code: str
message: str
fallback: str
part_path: str = ""
slide_index: int | None = None
shape_id: str = ""
shape_name: str = ""
shape_kind: str = ""
severity: str = "warning"
def to_dict(self) -> dict[str, object]:
"""Return the stable JSON representation."""
return {
key: value
for key, value in asdict(self).items()
if value not in {"", None}
}
def append_diagnostic(
diagnostics: list[ImportDiagnostic],
diagnostic: ImportDiagnostic,
) -> None:
"""Append one diagnostic while suppressing duplicate layered/flat reports."""
if diagnostic not in diagnostics:
diagnostics.append(diagnostic)
@@ -0,0 +1,362 @@
"""DrawingML <a:ln> -> SVG stroke conversion.
Reverse of svg_to_pptx/drawingml/styles.py build_stroke_xml.
Produces an SVG attribute dict with stroke / stroke-width / stroke-opacity /
stroke-dasharray / stroke-linecap / stroke-linejoin / marker-start /
marker-end (markers also need a <defs> entry which is returned alongside).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from xml.etree import ElementTree as ET
from pptx_shapes.formula import validate_ooxml_line_width
from .color_resolver import (
ColorPalette,
find_color_elem,
resolve_color,
resolve_solid_fill_color,
validate_no_fill,
)
from .emu_units import NS, emu_to_px, fmt_num, format_ooxml_alpha
@dataclass
class StrokeResult:
"""Resolved stroke: SVG attributes to apply + optional <defs> for markers."""
attrs: dict[str, str] = field(default_factory=dict)
defs: list[str] = field(default_factory=list)
# Reverse of svg_to_pptx DASH_PRESETS (preset name -> dasharray).
PRST_DASH_TO_ARRAY = {
"solid": None, # no dasharray
"dot": "1 3",
"dash": "4 4",
"lgDash": "8 4",
"dashDot": "4 4 1 4",
"lgDashDot": "8 4 2 4",
"lgDashDotDot": "8 4 2 4 2 4",
"sysDash": "3 3",
"sysDot": "1 3",
"sysDashDot": "3 3 1 3",
"sysDashDotDot": "3 3 1 3 1 3",
}
_OOXML_INT_MAX = 2**31 - 1
_LINE_PAINT_TAGS = {
f"{{{NS['a']}}}{name}": name
for name in (
"noFill",
"solidFill",
"gradFill",
"pattFill",
"blipFill",
"grpFill",
)
}
# DrawingML cap -> SVG stroke-linecap
CAP_MAP = {
"rnd": "round",
"sq": "square",
"flat": "butt",
}
def resolve_stroke(
sp_pr: ET.Element | None,
palette: ColorPalette | None,
*,
id_prefix: str = "m",
id_seq: list[int] | None = None,
style_stroke_default: str | None = None,
) -> StrokeResult:
"""Resolve <a:ln> child of <p:spPr>.
Returns:
StrokeResult.attrs is empty if no <a:ln> present (caller falls back to
the spec default). If <a:ln> exists with <a:noFill/>, attrs has
stroke="none".
"""
if sp_pr is None:
return StrokeResult()
ln = sp_pr.find("a:ln", NS)
if ln is None:
return StrokeResult()
attrs: dict[str, str] = {}
defs: list[str] = []
compound = ln.attrib.get("cmpd")
if compound not in {None, "sng"}:
raise ValueError(
f"Unsupported DrawingML compound line: {compound!r}"
)
alignment = ln.attrib.get("algn")
if alignment not in {None, "ctr"}:
raise ValueError(
f"Unsupported DrawingML line alignment: {alignment!r}"
)
# Width (a:ln@w in EMU)
width_emu = ln.attrib.get("w")
if width_emu is not None:
try:
width_value = int(width_emu)
except (ValueError, TypeError):
raise ValueError(
f"Invalid DrawingML line width: {width_emu!r}"
) from None
validate_ooxml_line_width(width_value)
width_px = emu_to_px(width_value)
attrs["stroke-width"] = fmt_num(width_px, 5)
# Cap
cap = ln.attrib.get("cap")
if cap is not None:
if cap not in CAP_MAP:
raise ValueError(f"Unsupported DrawingML line cap: {cap!r}")
attrs["stroke-linecap"] = CAP_MAP[cap]
# Fill: noFill / solidFill / gradFill
paints = [child for child in ln if child.tag in _LINE_PAINT_TAGS]
if len(paints) > 1:
raise ValueError("DrawingML line must contain at most one paint")
paint = paints[0] if paints else None
paint_name = _LINE_PAINT_TAGS.get(paint.tag) if paint is not None else None
if paint_name not in {None, "noFill", "solidFill", "gradFill"}:
raise ValueError(f"Unsupported DrawingML line paint: {paint_name}")
if paint_name == "noFill":
validate_no_fill(paint)
attrs["stroke"] = "none"
elif paint_name == "solidFill":
hex_, alpha = resolve_solid_fill_color(paint, palette)
attrs["stroke"] = hex_
if alpha < 1.0:
attrs["stroke-opacity"] = format_ooxml_alpha(alpha)
elif paint_name == "gradFill":
# Approximate gradient stroke as the first stop color (SVG supports
# gradient strokes via fill="url()" but it adds a lot of plumbing;
# first-stop is the registered import normalization).
first_gs = paint.find("a:gsLst/a:gs", NS)
if first_gs is None:
raise ValueError("DrawingML gradient line requires a color stop")
color_elem = find_color_elem(first_gs)
hex_, alpha = resolve_color(color_elem, palette)
if hex_ is None:
raise ValueError(
"DrawingML gradient line first color cannot be resolved"
)
attrs["stroke"] = hex_
if alpha < 1.0:
attrs["stroke-opacity"] = format_ooxml_alpha(alpha)
# Dash pattern
preset_tag = f"{{{NS['a']}}}prstDash"
custom_tag = f"{{{NS['a']}}}custDash"
dashes = [child for child in ln if child.tag in {preset_tag, custom_tag}]
if len(dashes) > 1:
raise ValueError("DrawingML line must contain at most one dash")
dash = dashes[0] if dashes else None
if dash is not None and dash.tag == preset_tag:
if (
set(dash.attrib) != {"val"}
or list(dash)
or (dash.text or "").strip()
):
raise ValueError("Invalid DrawingML preset dash structure")
preset = dash.attrib["val"]
if preset not in PRST_DASH_TO_ARRAY:
raise ValueError(
f"Unsupported DrawingML preset dash: {preset!r}"
)
dasharray = PRST_DASH_TO_ARRAY[preset]
if dasharray:
attrs["stroke-dasharray"] = dasharray
elif dash is not None:
cust_dash = dash
if cust_dash.attrib or (cust_dash.text or "").strip():
raise ValueError("Invalid DrawingML custom dash structure")
ds_parts: list[str] = []
sw = float(attrs.get("stroke-width", "1") or "1") or 1.0
dash_stops = list(cust_dash)
expected_tag = f"{{{NS['a']}}}ds"
if not dash_stops or any(
ds.tag != expected_tag
or set(ds.attrib) != {"d", "sp"}
or list(ds)
for ds in dash_stops
):
raise ValueError("Invalid DrawingML custom dash structure")
for ds in dash_stops:
# d, sp are percentages of stroke width (1000ths)
values: dict[str, int] = {}
for name in ("d", "sp"):
raw_value = ds.attrib[name]
try:
value = int(raw_value)
except (ValueError, TypeError):
raise ValueError(
f"Invalid DrawingML custom dash {name}: "
f"{raw_value!r}"
) from None
if not 0 < value <= _OOXML_INT_MAX:
raise ValueError(
f"DrawingML custom dash {name}={value} is "
"outside the positive OOXML integer range"
)
values[name] = value
d_pct = values["d"]
sp_pct = values["sp"]
ds_parts.append(fmt_num(d_pct / 100000.0 * sw, 10))
ds_parts.append(fmt_num(sp_pct / 100000.0 * sw, 10))
attrs["stroke-dasharray"] = " ".join(ds_parts)
# Join
join_names = {
f"{{{NS['a']}}}round": "round",
f"{{{NS['a']}}}bevel": "bevel",
f"{{{NS['a']}}}miter": "miter",
}
joins = [child for child in ln if child.tag in join_names]
if len(joins) > 1:
raise ValueError("DrawingML line must contain at most one join")
if joins:
join = joins[0]
linejoin = join_names[join.tag]
if list(join):
raise ValueError("Invalid DrawingML line join structure")
if linejoin in {"round", "bevel"} and join.attrib:
raise ValueError("Invalid DrawingML line join structure")
if linejoin == "miter":
if set(join.attrib) - {"lim"}:
raise ValueError("Invalid DrawingML line join structure")
limit = join.attrib.get("lim")
if limit != "800000":
raise ValueError(
f"Unsupported DrawingML miter limit: {limit!r}"
)
attrs["stroke-linejoin"] = linejoin
# Arrow markers (head / tail)
if id_seq is None:
id_seq = [0]
for which, attr in (("headEnd", "marker-start"), ("tailEnd", "marker-end")):
endpoints = ln.findall(f"a:{which}", NS)
if len(endpoints) > 1:
raise ValueError(
f"DrawingML line must contain at most one {which}"
)
if not endpoints:
continue
end_elem = endpoints[0]
if (
set(end_elem.attrib) - {"type", "w", "len"}
or list(end_elem)
or (end_elem.text or "").strip()
):
raise ValueError(f"Invalid DrawingML {which} structure")
marker_color = attrs.get("stroke") or style_stroke_default or "#000000"
marker_id, marker_def = _build_arrow_marker(
end_elem,
marker_color,
id_prefix=id_prefix,
seq=id_seq,
reversed_=(which == "headEnd"),
)
if marker_id is None:
continue
defs.append(marker_def)
attrs[attr] = f"url(#{marker_id})"
return StrokeResult(attrs=attrs, defs=defs)
# ---------------------------------------------------------------------------
# Arrow marker generation
# ---------------------------------------------------------------------------
# Bucket -> markerWidth/markerHeight ratio (in stroke widths). These values
# are the stable representatives of the SVG-to-DrawingML bucket thresholds,
# so importing and exporting preserves both ``w`` and ``len`` categories.
SIZE_BUCKET = {"sm": 1.5, "med": 2.5, "lg": 3.5}
def _build_arrow_marker(
end_elem: ET.Element,
stroke_color: str,
*,
id_prefix: str,
seq: list[int],
reversed_: bool,
) -> tuple[str | None, str]:
"""Build an SVG <marker> def for an <a:headEnd>/<a:tailEnd>."""
typ = end_elem.attrib.get("type")
if typ not in {
None,
"none",
"triangle",
"stealth",
"arrow",
"diamond",
"oval",
}:
raise ValueError(f"Unsupported DrawingML line-end type: {typ!r}")
w_b = end_elem.attrib.get("w", "med")
l_b = end_elem.attrib.get("len", "med")
for dimension, bucket in (("width", w_b), ("length", l_b)):
if bucket not in SIZE_BUCKET:
raise ValueError(
f"Unsupported DrawingML line-end {dimension} bucket: "
f"{bucket!r}"
)
if typ is None or typ == "none":
return None, ""
if stroke_color.strip().lower() == "none":
raise ValueError(
"DrawingML line end requires a visible line paint"
)
mw = SIZE_BUCKET[l_b]
mh = SIZE_BUCKET[w_b]
seq[0] += 1
marker_id = f"{id_prefix}arrow{seq[0]}"
# SVG markers are drawn in their own viewBox; we use a 0..10 box and place
# the path so refX is at the line endpoint.
if typ == "triangle":
path = "M 0 0 L 10 5 L 0 10 z"
elif typ == "stealth":
path = "M 0 0 L 10 5 L 0 10 L 3 5 z"
elif typ == "arrow":
path = "M 0 0 L 10 5 L 0 10"
elif typ == "diamond":
path = "M 0 5 L 5 0 L 10 5 L 5 10 z"
elif typ == "oval":
path = "" # use circle below
if typ == "oval":
body = f'<circle cx="5" cy="5" r="4" fill="{stroke_color}"/>'
elif typ == "arrow":
body = (
f'<path d="{path}" fill="none" stroke="{stroke_color}"/>'
)
else:
body = f'<path d="{path}" fill="{stroke_color}"/>'
orient = "auto-start-reverse" if reversed_ else "auto"
# Note: stroke="none" prevents marker from inheriting parent stroke.
marker_def = (
f'<marker id="{marker_id}" viewBox="0 0 10 10" '
f'refX="{"0" if reversed_ else "10"}" refY="5" '
f'markerWidth="{fmt_num(mw, 2)}" markerHeight="{fmt_num(mh, 2)}" '
f'orient="{orient}" markerUnits="strokeWidth">'
f"{body}</marker>"
)
return marker_id, marker_def
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,497 @@
"""OOXML loader: read .pptx zip package, resolve relationships, expose
slide / layout / master / theme / media as a navigable structure.
Mirrors what manifest.py does for analysis, but keeps the parsed XML roots
accessible for downstream shape conversion.
"""
from __future__ import annotations
import posixpath
import re
import zipfile
from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath
from typing import Iterator
from xml.etree import ElementTree as ET
from .emu_units import NS, emu_attr_to_px
# ---------------------------------------------------------------------------
# Relationship parsing
# ---------------------------------------------------------------------------
REL_NS = NS["rel"]
PACKAGE_REL = "_rels/.rels"
PRESENTATION_REL_PREFIX = "ppt/_rels/presentation.xml.rels"
def _normalize_part_path(target: str, base: str | None = None) -> str:
"""Resolve a relationship target against its rels source.
PPTX rels typically use paths relative to the rels file's parent directory,
e.g. slide1.xml.rels says target="../theme/theme1.xml". The result is
normalized to a posix path with no leading slash and no '..'.
"""
target = target.replace("\\", "/")
if target.startswith("/"):
return target.lstrip("/")
if base is None:
return target
base_dir = posixpath.dirname(base.rstrip("/"))
joined = posixpath.normpath(posixpath.join(base_dir, target))
return joined.lstrip("/")
def _rels_path_for(part_path: str) -> str:
"""Given a part path 'ppt/slides/slide1.xml', return its rels path."""
parent, name = posixpath.split(part_path)
if not parent:
return f"_rels/{name}.rels"
return f"{parent}/_rels/{name}.rels"
def _parse_rels(zf: zipfile.ZipFile, rels_path: str) -> dict[str, dict[str, str]]:
"""Parse a .rels file. Returns {rId: {'type': ..., 'target': absolute_part_path}}."""
if rels_path not in zf.namelist():
return {}
try:
root = ET.fromstring(zf.read(rels_path))
except ET.ParseError as exc:
raise RuntimeError(f"Invalid relationships XML in {rels_path}: {exc}") from exc
base = rels_path.replace("/_rels/", "/") # source part path
base = base[:-len(".rels")] # strip .rels suffix
rels: dict[str, dict[str, str]] = {}
for child in root.findall(f"{{{REL_NS}}}Relationship"):
rid = child.attrib.get("Id", "")
rtype = child.attrib.get("Type", "")
target = child.attrib.get("Target", "")
target_mode = child.attrib.get("TargetMode", "")
# External relationships (e.g. hyperlinks) keep the raw target.
if target_mode == "External":
rels[rid] = {"type": rtype, "target": target, "external": "1"}
continue
absolute = _normalize_part_path(target, base)
rels[rid] = {"type": rtype, "target": absolute}
return rels
def _load_xml(zf: zipfile.ZipFile, part_path: str) -> ET.Element | None:
if part_path not in zf.namelist():
return None
try:
return ET.fromstring(zf.read(part_path))
except ET.ParseError as exc:
raise RuntimeError(f"Invalid OOXML part {part_path}: {exc}") from exc
def blip_embed_relationship_ids(blip: ET.Element) -> tuple[str, ...]:
"""Return embedded image relationships in fidelity-preferred order.
Modern Office stores an editable SVG relationship in ``asvg:svgBlip``
while keeping a raster fallback on the owning ``a:blip``. Consumers must
try the SVG relationship first and retain the raster relationship only as
a compatibility fallback.
"""
embed_attr = f"{{{NS['r']}}}embed"
candidates = [
node.attrib.get(embed_attr)
for node in blip.findall(".//asvg:svgBlip", NS)
]
candidates.append(blip.attrib.get(embed_attr))
ordered: list[str] = []
for rel_id in candidates:
if rel_id and rel_id not in ordered:
ordered.append(rel_id)
return tuple(ordered)
# ---------------------------------------------------------------------------
# Data classes for navigable parts
# ---------------------------------------------------------------------------
@dataclass
class PartRef:
"""A loaded XML part with its rels resolved."""
path: str
xml: ET.Element
rels: dict[str, dict[str, str]] = field(default_factory=dict)
def resolve_rel(self, rid: str) -> str | None:
"""Resolve an rId to an absolute part path. Returns None if missing."""
info = self.rels.get(rid)
if info is None:
return None
if info.get("external"):
return None
return info.get("target")
@dataclass
class SlideRef:
"""One slide, with its layout / master chain attached."""
index: int # 1-based
part: PartRef
layout: PartRef | None
master: PartRef | None
def parse_ooxml_boolean(
raw: str | None,
*,
default: bool,
context: str,
) -> bool:
"""Parse one XML Schema boolean without silently accepting bad OOXML."""
if raw is None:
return default
token = raw.strip()
if token in {"1", "true"}:
return True
if token in {"0", "false"}:
return False
raise RuntimeError(f"{context}: invalid boolean value {raw!r}")
def part_show_master_sp(part: PartRef) -> bool:
"""Return one slide/layout part's raw ``showMasterSp`` semantic value."""
return parse_ooxml_boolean(
part.xml.attrib.get("showMasterSp"),
default=True,
context=f"{part.path} showMasterSp",
)
def inherited_shape_visibility(slide: SlideRef) -> tuple[bool, bool]:
"""Return effective ``(layout_shapes, master_shapes)`` visibility.
A slide-level false value suppresses both inherited shape trees. A
layout-level false value suppresses only its parent Master's shape tree.
Background inheritance is separate and intentionally not represented by
these booleans.
"""
show_layout_shapes = part_show_master_sp(slide.part)
layout_shows_master_shapes = (
part_show_master_sp(slide.layout)
if slide.layout is not None else True
)
show_master_shapes = (
show_layout_shapes
and slide.master is not None
and layout_shows_master_shapes
)
return show_layout_shapes, show_master_shapes
# ---------------------------------------------------------------------------
# OoxmlPackage
# ---------------------------------------------------------------------------
# Relationship type constants
REL_TYPES = {
"presentation": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument",
"slide": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide",
"slideLayout": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout",
"slideMaster": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster",
"theme": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme",
"tableStyles": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/tableStyles",
"image": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image",
"media": "http://schemas.openxmlformats.org/officeDocument/2006/relationships/media",
}
class OoxmlPackage:
"""Loaded .pptx ready for shape walking.
Usage:
with OoxmlPackage(Path("foo.pptx")) as pkg:
for slide in pkg.iter_slides():
root = slide.part.xml
...
"""
def __init__(self, pptx_path: Path) -> None:
self.path = pptx_path
self.zip: zipfile.ZipFile | None = None
self.presentation: PartRef | None = None
self.slide_size_px: tuple[float, float] = (1280.0, 720.0)
self.slide_size_emu: tuple[int, int] = (12192000, 6858000)
self.first_slide_number: int = 1
self._slides: list[SlideRef] = []
self._layouts: dict[str, PartRef] = {}
self._masters: dict[str, PartRef] = {}
self._themes: dict[str, PartRef] = {}
self._table_styles: PartRef | None = None
self._table_styles_loaded = False
# ------------------- context manager -------------------
def __enter__(self) -> "OoxmlPackage":
self.open()
return self
def __exit__(self, exc_type, exc, tb) -> None:
self.close()
def open(self) -> None:
if self.zip is not None:
return
self.zip = zipfile.ZipFile(self.path, "r")
self._load_presentation()
self._load_slides()
def close(self) -> None:
if self.zip is not None:
self.zip.close()
self.zip = None
# ------------------- low-level helpers -------------------
def _load_part(self, part_path: str) -> PartRef | None:
assert self.zip is not None
xml = _load_xml(self.zip, part_path)
if xml is None:
return None
rels = _parse_rels(self.zip, _rels_path_for(part_path))
return PartRef(path=part_path, xml=xml, rels=rels)
def load_part(self, part_path: str) -> PartRef | None:
"""Load an arbitrary XML part by package path."""
return self._load_part(part_path)
def read_media(self, part_path: str) -> bytes | None:
"""Return raw bytes of an embedded media part (e.g. ppt/media/image1.png)."""
assert self.zip is not None
if part_path not in self.zip.namelist():
return None
return self.zip.read(part_path)
def media_filename(self, part_path: str) -> str:
"""Last segment of the media path, e.g. 'image1.png'."""
return PurePosixPath(part_path).name
# ------------------- loading sequence -------------------
def _load_presentation(self) -> None:
assert self.zip is not None
# /_rels/.rels -> presentation.xml
package_rels = _parse_rels(self.zip, PACKAGE_REL)
pres_path = None
for info in package_rels.values():
if info.get("type") == REL_TYPES["presentation"]:
pres_path = info["target"]
break
if pres_path is None:
pres_path = "ppt/presentation.xml"
self.presentation = self._load_part(pres_path)
if self.presentation is None:
raise RuntimeError(f"presentation.xml missing in {self.path}")
raw_first_slide_number = self.presentation.xml.attrib.get("firstSlideNum")
if raw_first_slide_number is not None:
first_slide_token = raw_first_slide_number.strip(" \t\r\n")
if re.fullmatch(r"[+-]?[0-9]+", first_slide_token) is None:
raise RuntimeError(
f"Invalid presentation firstSlideNum: {raw_first_slide_number!r}"
)
digits = first_slide_token.lstrip("+-").lstrip("0") or "0"
if len(digits) > 10:
raise RuntimeError("Presentation firstSlideNum is outside xsd:int")
first_slide_number = int(digits)
if first_slide_token.startswith("-"):
first_slide_number = -first_slide_number
if not -(2**31) <= first_slide_number <= 2**31 - 1:
raise RuntimeError(
f"Presentation firstSlideNum is outside xsd:int: {first_slide_number}"
)
self.first_slide_number = first_slide_number
# slide size
size = self.presentation.xml.find("p:sldSz", NS)
if size is not None:
cx = int(size.attrib.get("cx", "12192000"))
cy = int(size.attrib.get("cy", "6858000"))
self.slide_size_emu = (cx, cy)
self.slide_size_px = (cx / 9525.0, cy / 9525.0)
def _load_slides(self) -> None:
assert self.zip is not None and self.presentation is not None
# presentation.xml has <p:sldIdLst><p:sldId r:id="rId..."/></p:sldIdLst>
# in document order.
sld_id_lst = self.presentation.xml.find("p:sldIdLst", NS)
if sld_id_lst is None:
return
for index, sld_id in enumerate(sld_id_lst.findall("p:sldId", NS), start=1):
rid = sld_id.attrib.get(f"{{{NS['r']}}}id")
if not rid:
continue
slide_path = self.presentation.resolve_rel(rid)
if not slide_path:
continue
slide_part = self._load_part(slide_path)
if slide_part is None:
continue
layout = self._resolve_layout(slide_part)
master = self._resolve_master(layout) if layout else None
self._slides.append(SlideRef(
index=index, part=slide_part, layout=layout, master=master,
))
def _resolve_layout(self, slide: PartRef) -> PartRef | None:
for info in slide.rels.values():
if info.get("type") == REL_TYPES["slideLayout"]:
target = info["target"]
cached = self._layouts.get(target)
if cached is None:
cached = self._load_part(target)
if cached is not None:
self._layouts[target] = cached
return cached
return None
def _resolve_master(self, layout: PartRef) -> PartRef | None:
for info in layout.rels.values():
if info.get("type") == REL_TYPES["slideMaster"]:
target = info["target"]
cached = self._masters.get(target)
if cached is None:
cached = self._load_part(target)
if cached is not None:
self._masters[target] = cached
return cached
return None
def resolve_theme(self, master: PartRef | None) -> PartRef | None:
"""Return the theme part referenced by a slide master."""
if master is None:
return None
for info in master.rels.values():
if info.get("type") == REL_TYPES["theme"]:
target = info["target"]
cached = self._themes.get(target)
if cached is None:
cached = self._load_part(target)
if cached is not None:
self._themes[target] = cached
return cached
return None
def resolve_table_styles(self) -> PartRef | None:
"""Return the presentation-level table style list, when usable.
Table style definitions are optional and some producers emit only the
built-in style id. A missing or malformed style part must therefore
not prevent otherwise valid slides from being converted.
"""
if self._table_styles_loaded:
return self._table_styles
self._table_styles_loaded = True
target: str | None = None
if self.presentation is not None:
for info in self.presentation.rels.values():
if info.get("type") == REL_TYPES["tableStyles"]:
target = info.get("target")
break
if target is None:
target = "ppt/tableStyles.xml"
try:
self._table_styles = self._load_part(target)
except RuntimeError:
self._table_styles = None
return self._table_styles
# ------------------- public iteration -------------------
def iter_slides(self) -> Iterator[SlideRef]:
yield from self._slides
@property
def slide_count(self) -> int:
return len(self._slides)
def get_slide(self, index: int) -> SlideRef | None:
"""1-based index lookup."""
if 1 <= index <= len(self._slides):
return self._slides[index - 1]
return None
def iter_all_masters(self) -> Iterator[PartRef]:
"""Yield every slideMaster declared in presentation.xml, regardless of
whether any slide currently uses it.
Template decks routinely ship more masters than the visible sample
slides reference (one master per "style"). The slide-driven traversal
in ``_load_slides`` only caches masters that are actually consumed by
a slide — fine for an authoring deck, but it drops 90% of the design
intent for a multi-style template package. This iterator hits the
presentation's ``sldMasterIdLst`` directly so callers (e.g. the
layered template export) can preserve the full template library.
"""
if self.presentation is None:
return
master_id_lst = self.presentation.xml.find("p:sldMasterIdLst", NS)
if master_id_lst is None:
return
for master_id in master_id_lst.findall("p:sldMasterId", NS):
rid = master_id.attrib.get(f"{{{NS['r']}}}id")
if not rid:
continue
target = self.presentation.resolve_rel(rid)
if not target:
continue
cached = self._masters.get(target)
if cached is None:
cached = self._load_part(target)
if cached is None:
continue
self._masters[target] = cached
yield cached
def iter_all_layouts(self) -> Iterator[PartRef]:
"""Yield every slideLayout reachable from any master, regardless of
slide usage. Layouts live under ``master.sldLayoutIdLst`` in document
order; we walk every master so the export reflects the full set.
See :meth:`iter_all_layouts_with_parent` when you need each layout's
owning master alongside the layout itself (e.g. for theme-fill
resolution during standalone rendering).
"""
for layout, _master in self.iter_all_layouts_with_parent():
yield layout
def iter_all_layouts_with_parent(self) -> Iterator[tuple[PartRef, PartRef]]:
"""Like :meth:`iter_all_layouts` but yields ``(layout, parent_master)``
pairs. The parent master is the one whose ``sldLayoutIdLst`` contains
the layout, which is the source of truth for theme-style resolution
(e.g. ``<p:bgRef idx=...>`` on the layout still hops through the
master's theme).
"""
seen: set[str] = set()
for master in self.iter_all_masters():
layout_id_lst = master.xml.find("p:sldLayoutIdLst", NS)
if layout_id_lst is None:
continue
for layout_id in layout_id_lst.findall("p:sldLayoutId", NS):
rid = layout_id.attrib.get(f"{{{NS['r']}}}id")
if not rid:
continue
target = master.resolve_rel(rid)
if not target or target in seen:
continue
seen.add(target)
cached = self._layouts.get(target)
if cached is None:
cached = self._load_part(target)
if cached is None:
continue
self._layouts[target] = cached
yield cached, master
@@ -0,0 +1,653 @@
"""DrawingML <p:pic> -> SVG <image> conversion.
Reverse of svg_to_pptx convert_image.
DrawingML structure:
<p:pic>
<p:blipFill>
<a:blip r:embed="rIdRaster">
<a:extLst>...<asvg:svgBlip r:embed="rIdSvg"/>...</a:extLst>
</a:blip>
<a:srcRect l/t/r/b="1/100000"/> (optional crop)
<a:stretch><a:fillRect/></a:stretch> (default: fill the shape)
</p:blipFill>
<p:spPr>
<a:xfrm/>
<a:prstGeom prst="rect"/> (usually rect; can be other)
</p:spPr>
</p:pic>
Strategy:
- Prefer the editable SVG relationship in asvg:svgBlip when Office also stores
a raster compatibility preview on a:blip; retain the raster as fallback.
- Default (no srcRect, plain stretch) -> a single <image> filling the box,
preserveAspectRatio="none".
- With srcRect, or with a single oversized tile that covers the frame -> wrap
the <image> in a nested <svg viewBox> in the unit rectangle [0,1] x [0,1],
with overflow hidden so cropping is expressed identically in browsers and
PowerPoint.
- Repeating tile fills still use the legacy plain-image fallback; a repeated
pattern cannot be represented by the project's native picture-crop subset.
- Image bytes are written through the result; the slide assembler decides
the href format (external file vs base64).
"""
from __future__ import annotations
import base64
import hashlib
import io
import math
import mimetypes
import shutil
import subprocess
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from xml.etree import ElementTree as ET
try:
from PIL import Image, ImageEnhance
except ImportError: # pragma: no cover - optional visual enhancement dependency
Image = None
ImageEnhance = None
from .emu_units import NS, Xfrm, emu_to_px, fmt_num, format_ooxml_alpha
from .ooxml_loader import OoxmlPackage, PartRef, blip_embed_relationship_ids
@dataclass(frozen=True)
class PictureDiagnostic:
"""Recoverable loss while converting one DrawingML picture."""
code: str
message: str
fallback: str
@dataclass
class PictureResult:
"""Resolved picture: SVG element string + extracted media bytes."""
svg: str = ""
# Map of {filename: bytes} that the assembler should emit alongside
# the SVG. Filename is the basename inside the package's media dir.
media: dict[str, bytes] = field(default_factory=dict)
diagnostics: tuple[PictureDiagnostic, ...] = ()
class MediaResolutionError(RuntimeError):
"""Raised when a PPTX media relationship cannot be reproduced as SVG."""
def convert_blip_fill(
blip_fill_elem: ET.Element,
xfrm: Xfrm,
slide_part: PartRef,
pkg: OoxmlPackage,
*,
media_subdir: str = "assets",
embed_inline: bool = False,
asset_name_map: dict[str, str] | None = None,
strict: bool = False,
) -> PictureResult:
"""Convert an <a:blipFill> element to SVG <image>.
Handles image fill for both:
- <p:pic><p:blipFill> (standard picture elements)
- <p:sp><p:spPr><a:blipFill> (shape with image fill, e.g. Canva exports)
"""
blip = blip_fill_elem.find("a:blip", NS)
if blip is None:
return PictureResult()
relationship_ids = blip_embed_relationship_ids(blip)
linked_rid = blip.attrib.get(f"{{{NS['r']}}}link")
if not relationship_ids:
if linked_rid:
raise MediaResolutionError(
"Linked image relationships are not supported; embed the image in PowerPoint first"
)
return PictureResult()
target: str | None = None
img_bytes: bytes | None = None
failures: list[str] = []
for rel_id in relationship_ids:
candidate = slide_part.resolve_rel(rel_id)
if not candidate:
failures.append(f"{rel_id}: unresolved")
continue
candidate_bytes = pkg.read_media(candidate)
if candidate_bytes is None:
failures.append(f"{rel_id}: missing {candidate}")
continue
if Path(candidate).suffix.lower() == ".svg" and not _is_valid_svg_media(candidate_bytes):
failures.append(f"{rel_id}: invalid SVG media {candidate}")
continue
target = candidate
img_bytes = candidate_bytes
break
if target is None or img_bytes is None:
details = "; ".join(failures)
raise MediaResolutionError(
f"No embedded image relationship can be read in {slide_part.path}: {details}"
)
filename = (asset_name_map or {}).get(target, pkg.media_filename(target))
filename, img_bytes = _normalize_office_media(filename, img_bytes)
tile_source_bytes = img_bytes
diagnostics = list(_unsupported_blip_effect_diagnostics(blip))
filename, img_bytes, effect_diagnostics = _apply_blip_image_effects(
filename,
img_bytes,
blip,
)
diagnostics.extend(effect_diagnostics)
opacity_attr, opacity_diagnostics = _blip_opacity_attr(blip)
diagnostics.extend(opacity_diagnostics)
if strict and diagnostics:
details = "; ".join(item.message for item in diagnostics)
raise ValueError(f"Cannot reproduce DrawingML picture effects: {details}")
href = _build_href(filename, img_bytes, media_subdir, embed_inline)
# srcRect: l/t/r/b in 1/100000ths (so 50000 = 50%).
src_rect = blip_fill_elem.find("a:srcRect", NS)
crop = _parse_src_rect(src_rect)
# A tile larger than its frame is visually a crop, not a stretch. Preserve
# that common PowerPoint background construction through the same nested
# SVG transport used for srcRect. True repeated patterns retain the legacy
# plain-image fallback because native export has no registered tile subset.
tile = blip_fill_elem.find("a:tile", NS)
if crop is None and tile is not None:
crop = _single_cover_tile_crop(
tile,
blip_fill_elem,
xfrm,
tile_source_bytes,
)
if crop is None:
# Plain unclipped image
svg = (
f'<image href="{href}" x="{fmt_num(xfrm.x)}" y="{fmt_num(xfrm.y)}" '
f'width="{fmt_num(xfrm.w)}" height="{fmt_num(xfrm.h)}" '
f'preserveAspectRatio="none"{opacity_attr}/>'
)
else:
# Crop expressed as a unit-rectangle viewBox on a nested <svg>.
vb_l, vb_t, vb_w, vb_h = crop
svg = (
f'<svg x="{fmt_num(xfrm.x)}" y="{fmt_num(xfrm.y)}" '
f'width="{fmt_num(xfrm.w)}" height="{fmt_num(xfrm.h)}" '
f'viewBox="{fmt_num(vb_l, 5)} {fmt_num(vb_t, 5)} '
f'{fmt_num(vb_w, 5)} {fmt_num(vb_h, 5)}" '
f'preserveAspectRatio="none" overflow="hidden">'
f'<image href="{href}" x="0" y="0" width="1" height="1" '
f'preserveAspectRatio="none"{opacity_attr}/>'
f"</svg>"
)
media: dict[str, bytes] = {}
if not embed_inline:
media[filename] = img_bytes
return PictureResult(
svg=svg,
media=media,
diagnostics=tuple(diagnostics),
)
def convert_picture(
pic_elem: ET.Element,
xfrm: Xfrm,
slide_part: PartRef,
pkg: OoxmlPackage,
*,
media_subdir: str = "assets",
embed_inline: bool = False,
asset_name_map: dict[str, str] | None = None,
strict: bool = False,
) -> PictureResult:
"""Translate <p:pic> to SVG <image> (or nested <svg>+<image> for cropping)."""
blip_fill = pic_elem.find("p:blipFill", NS)
if blip_fill is None:
return PictureResult()
return convert_blip_fill(
blip_fill, xfrm, slide_part, pkg,
media_subdir=media_subdir,
embed_inline=embed_inline,
asset_name_map=asset_name_map,
strict=strict,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _blip_opacity_attr(
blip: ET.Element,
) -> tuple[str, tuple[PictureDiagnostic, ...]]:
"""Translate DrawingML fixed image alpha to an SVG opacity attribute."""
alpha_effects = blip.findall("a:alphaModFix", NS)
if not alpha_effects:
return "", ()
if len(alpha_effects) > 1:
return "", (
_effect_diagnostic(
"duplicate a:alphaModFix effects cannot be reproduced safely"
),
)
alpha = alpha_effects[0]
try:
opacity = float(alpha.attrib.get("amt", "100000")) / 100000.0
except ValueError:
return "", (
_effect_diagnostic(
f"invalid a:alphaModFix amt={alpha.attrib.get('amt')!r}"
),
)
if not math.isfinite(opacity) or not 0.0 <= opacity <= 1.0:
return "", (
_effect_diagnostic(
f"out-of-range a:alphaModFix amt={alpha.attrib.get('amt')!r}"
),
)
if opacity >= 1.0:
return "", ()
return f' opacity="{format_ooxml_alpha(opacity)}"', ()
def _effect_diagnostic(message: str) -> PictureDiagnostic:
return PictureDiagnostic(
code="image-effect-omitted",
message=message,
fallback="retain the source image and omit only this image effect",
)
def _unsupported_blip_effect_diagnostics(
blip: ET.Element,
) -> tuple[PictureDiagnostic, ...]:
"""Report direct a:blip effects outside the implemented subset."""
supported_tags = {
f"{{{NS['a']}}}lum",
f"{{{NS['a']}}}alphaModFix",
f"{{{NS['a']}}}extLst",
}
unsupported = sorted({
child.tag.rsplit("}", 1)[-1]
for child in blip
if child.tag not in supported_tags
})
diagnostics = (
[
_effect_diagnostic(
"unsupported direct a:blip effect(s): "
+ ", ".join(f"a:{name}" for name in unsupported)
)
]
if unsupported
else []
)
if len(blip.findall("a:lum", NS)) > 1:
diagnostics.append(
_effect_diagnostic(
"duplicate a:lum effects cannot be reproduced safely"
)
)
return tuple(diagnostics)
_OFFICE_VECTOR_EXTS = {".emf", ".wmf"}
def _is_valid_svg_media(data: bytes) -> bool:
"""Return whether one media part is a namespaced SVG document."""
try:
root = ET.fromstring(data)
except ET.ParseError:
return False
return root.tag == "{http://www.w3.org/2000/svg}svg"
def _normalize_office_media(filename: str, img_bytes: bytes) -> tuple[str, bytes]:
"""Convert Office-only vector image formats to browser-renderable PNG.
PPTX can contain EMF/WMF assets that PowerPoint renders natively but SVG
viewers generally do not. Keep the original asset in the manifest layer;
the SVG view uses a PNG preview when the local system can make one.
"""
suffix = Path(filename).suffix.lower()
if suffix not in _OFFICE_VECTOR_EXTS:
return filename, img_bytes
converted = _convert_office_vector_to_png(filename, img_bytes)
if converted is None:
return filename, img_bytes
stem = Path(filename).stem
return f"{stem}_preview.png", converted
def _convert_office_vector_to_png(filename: str, img_bytes: bytes) -> bytes | None:
magick = shutil.which("magick")
if not magick:
return None
suffix = Path(filename).suffix.lower() or ".bin"
with tempfile.TemporaryDirectory() as tmp:
tmp_dir = Path(tmp)
src = tmp_dir / f"source{suffix}"
dst = tmp_dir / "preview.png"
src.write_bytes(img_bytes)
try:
subprocess.run(
[magick, str(src), str(dst)],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except (OSError, subprocess.CalledProcessError):
return None
if not dst.exists():
return None
return dst.read_bytes()
def _parse_src_rect(elem: ET.Element | None) -> tuple[float, float, float, float] | None:
"""Convert <a:srcRect l t r b="1/100000"/> to (x, y, w, h) in unit space."""
if elem is None:
return None
if not (elem.attrib.keys() & {"l", "t", "r", "b"}):
return None
l = _pct_attr(elem, "l")
t = _pct_attr(elem, "t")
r = _pct_attr(elem, "r")
b = _pct_attr(elem, "b")
# All zero -> equivalent to no crop
if l == 0 and t == 0 and r == 0 and b == 0:
return None
vb_x = l
vb_y = t
vb_w = max(0.0, 1.0 - l - r)
vb_h = max(0.0, 1.0 - t - b)
if vb_w <= 0 or vb_h <= 0:
return None
return vb_x, vb_y, vb_w, vb_h
def _single_cover_tile_crop(
tile: ET.Element,
blip_fill: ET.Element,
xfrm: Xfrm,
img_bytes: bytes,
) -> tuple[float, float, float, float] | None:
"""Map one oversized DrawingML tile to a unit-image crop window.
The closed SVG/PPTX crop transport cannot express a repeating pattern. It
can, however, reproduce a tile when the aligned first tile alone covers the
complete frame. This is how PowerPoint commonly stores full-page bitmap
backgrounds without distorting their aspect ratio.
"""
if Image is None or xfrm.w <= 0 or xfrm.h <= 0:
return None
if tile.attrib.get("flip", "none") != "none":
return None
natural_size = _image_size_at_96_dpi(img_bytes, blip_fill)
if natural_size is None:
return None
natural_w, natural_h = natural_size
scale_x = _pct_attr_default(tile, "sx", 1.0)
scale_y = _pct_attr_default(tile, "sy", 1.0)
tile_w = natural_w * scale_x
tile_h = natural_h * scale_y
if tile_w <= 0 or tile_h <= 0:
return None
align_x, align_y = _tile_alignment(tile.attrib.get("algn", "tl"))
tile_x = (
xfrm.x
+ (xfrm.w - tile_w) * align_x
+ emu_to_px(tile.attrib.get("tx"))
)
tile_y = (
xfrm.y
+ (xfrm.h - tile_h) * align_y
+ emu_to_px(tile.attrib.get("ty"))
)
tolerance = 1e-4
if (
tile_x > xfrm.x + tolerance
or tile_y > xfrm.y + tolerance
or tile_x + tile_w < xfrm.x + xfrm.w - tolerance
or tile_y + tile_h < xfrm.y + xfrm.h - tolerance
):
return None
crop = (
(xfrm.x - tile_x) / tile_w,
(xfrm.y - tile_y) / tile_h,
xfrm.w / tile_w,
xfrm.h / tile_h,
)
if all(
abs(actual - expected) <= 1e-7
for actual, expected in zip(crop, (0.0, 0.0, 1.0, 1.0))
):
return None
return crop
def _image_size_at_96_dpi(
img_bytes: bytes,
blip_fill: ET.Element,
) -> tuple[float, float] | None:
"""Return the bitmap's physical size in the SVG canvas' 96-DPI pixels."""
try:
with Image.open(io.BytesIO(img_bytes)) as image:
pixel_w, pixel_h = image.size
embedded_dpi = image.info.get("dpi")
except (OSError, ValueError):
return None
configured_dpi = _positive_float(blip_fill.attrib.get("dpi"))
if configured_dpi is not None:
dpi_x = dpi_y = configured_dpi
else:
dpi_x, dpi_y = _dpi_pair(embedded_dpi)
return pixel_w * 96.0 / dpi_x, pixel_h * 96.0 / dpi_y
def _dpi_pair(value: object) -> tuple[float, float]:
if isinstance(value, (tuple, list)) and len(value) >= 2:
dpi_x = _positive_float(value[0]) or 96.0
dpi_y = _positive_float(value[1]) or 96.0
return dpi_x, dpi_y
dpi = _positive_float(value) or 96.0
return dpi, dpi
def _positive_float(value: object) -> float | None:
try:
parsed = float(value)
except (TypeError, ValueError):
return None
return parsed if parsed > 0 else None
def _pct_attr_default(elem: ET.Element, name: str, default: float) -> float:
value = elem.attrib.get(name)
if value is None:
return default
try:
return float(value) / 100000.0
except ValueError:
return default
def _tile_alignment(value: str) -> tuple[float, float]:
alignments = {
"tl": (0.0, 0.0),
"t": (0.5, 0.0),
"tr": (1.0, 0.0),
"l": (0.0, 0.5),
"ctr": (0.5, 0.5),
"r": (1.0, 0.5),
"bl": (0.0, 1.0),
"b": (0.5, 1.0),
"br": (1.0, 1.0),
}
return alignments.get(value, alignments["tl"])
def _apply_blip_image_effects(
filename: str,
img_bytes: bytes,
blip: ET.Element,
) -> tuple[str, bytes, tuple[PictureDiagnostic, ...]]:
"""Bake supported DrawingML blip effects into extracted image bytes.
Brightness and contrast are pixel operations, not picture-shape
shadow/glow effects, so preserve them in the extracted bitmap.
"""
lum_effects = blip.findall("a:lum", NS)
if not lum_effects:
return filename, img_bytes, ()
if len(lum_effects) > 1:
return filename, img_bytes, ()
lum = lum_effects[0]
values: dict[str, float | None] = {}
for name in ("bright", "contrast"):
raw_value = lum.attrib.get(name)
if raw_value is None:
values[name] = None
continue
try:
value = float(raw_value) / 100000.0
except ValueError:
return filename, img_bytes, (
_effect_diagnostic(f"invalid a:lum {name}={raw_value!r}"),
)
if not math.isfinite(value) or not -1.0 <= value <= 1.0:
return filename, img_bytes, (
_effect_diagnostic(
f"out-of-range a:lum {name}={raw_value!r}"
),
)
values[name] = value
bright = values["bright"]
contrast = values["contrast"]
if bright is None and contrast is None:
return filename, img_bytes, ()
if Image is None or ImageEnhance is None:
return filename, img_bytes, (
_effect_diagnostic(
"a:lum requires Pillow, but Pillow is unavailable"
),
)
try:
with Image.open(io.BytesIO(img_bytes)) as source_image:
if getattr(source_image, "is_animated", False):
return filename, img_bytes, (
_effect_diagnostic(
"a:lum on an animated image would flatten its frames"
),
)
output_format = (
source_image.format or _pil_format_from_filename(filename)
)
image = source_image.copy()
if image.mode not in ("RGB", "RGBA"):
image = image.convert("RGBA" if "A" in image.getbands() else "RGB")
if bright is not None:
image = ImageEnhance.Brightness(image).enhance(max(0.0, 1.0 + bright))
if contrast is not None:
image = ImageEnhance.Contrast(image).enhance(max(0.0, 1.0 + contrast))
out = io.BytesIO()
save_format = output_format or "PNG"
save_kwargs = {"quality": 95} if save_format.upper() in {"JPEG", "JPG"} else {}
image.save(out, format=save_format, **save_kwargs)
effect_key = f"lum-{bright}-{contrast}".encode("ascii")
digest = hashlib.sha1(effect_key).hexdigest()[:8]
return (
_effect_filename(filename, digest, save_format),
out.getvalue(),
(),
)
except (KeyError, OSError, ValueError) as exc:
return filename, img_bytes, (
_effect_diagnostic(f"a:lum could not be rendered: {exc}"),
)
def _pil_format_from_filename(filename: str) -> str | None:
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
if ext in {"jpg", "jpeg"}:
return "JPEG"
if ext == "png":
return "PNG"
if ext == "gif":
return "GIF"
if ext == "webp":
return "WEBP"
return None
def _effect_filename(filename: str, digest: str, image_format: str) -> str:
stem, sep, ext = filename.rpartition(".")
if not sep:
ext = (image_format or "png").lower()
stem = filename
if ext.lower() == "jpg":
ext = "jpeg"
return f"{stem}_fx_{digest}.{ext}"
def _pct_attr(elem: ET.Element, name: str) -> float:
val = elem.attrib.get(name)
if val is None:
return 0.0
try:
return float(val) / 100000.0
except ValueError:
return 0.0
def _build_href(filename: str, img_bytes: bytes, subdir: str, embed: bool) -> str:
"""Build an <image href=...> value (relative path or data URI).
The path is relative to the SVG file's location. The slide assembler writes
SVGs to <output>/svg/, so media files in <output>/<subdir>/ resolve via
a leading "../".
"""
if embed:
mime = (
mimetypes.guess_type(filename)[0]
or _sniff_mime(img_bytes)
or "application/octet-stream"
)
encoded = base64.b64encode(img_bytes).decode("ascii")
return f"data:{mime};base64,{encoded}"
rel = f"../{subdir}/{filename}" if subdir else f"../{filename}"
return rel
def _sniff_mime(data: bytes) -> str | None:
"""Best-effort MIME sniffing for embedded images."""
if data.startswith(b"\x89PNG\r\n\x1a\n"):
return "image/png"
if data.startswith(b"\xff\xd8\xff"):
return "image/jpeg"
if data.startswith(b"GIF87a") or data.startswith(b"GIF89a"):
return "image/gif"
if data.startswith(b"<svg") or data.startswith(b"<?xml"):
return "image/svg+xml"
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "image/webp"
return None
@@ -0,0 +1,851 @@
#!/usr/bin/env python3
"""
PPT Master - Authored Preset Shape Contract
Build and validate compact canonical SVG groups for newly authored PowerPoint
preset shapes while retaining expanded authored input compatibility.
Usage:
Import render_preset_shape_fragment or validate_authored_preset_group.
Examples:
fragment = render_preset_shape_fragment(
"rightArrow",
(80, 120, 240, 96),
element_id="next-step",
style={"fill": "#2563EB", "stroke": "none"},
)
Dependencies:
None (only uses standard library and local PPT Master modules)
"""
from __future__ import annotations
import math
import re
from typing import Mapping
from xml.etree import ElementTree as ET
from pptx_shapes import (
CONNECTOR_PRESET_TYPES,
OOXML_COORDINATE_MAX,
OOXML_COORDINATE_MIN,
SUPPORTED_OPERATORS,
get_preset_registry,
resolve_preset_preview_hash,
svg_preset_preview_fingerprint,
validate_ooxml_line_width,
validate_ooxml_xfrm,
)
from .emu_units import EMU_PER_PX, Xfrm, fmt_num
from .preset_registry_to_svg import render_preset_geometry
from .preset_svg_markup import (
attrs_to_xml,
serialize_compact_preset_layers,
serialize_preset_layers,
)
AUTHORING_ATTR = "data-pptx-authoring"
AUTHORING_VALUE = "preset"
_SVG_NAMESPACE = "http://www.w3.org/2000/svg"
_ID_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_.:-]*")
_PAINT_RE = re.compile(r"(?:none|#[0-9A-Fa-f]{6})")
_INTEGER_RE = re.compile(r"[+-]?\d+")
_ADJUSTMENT_PREFIX = "data-pptx-av-"
_STYLE_ATTRS = (
"fill",
"fill-opacity",
"stroke",
"stroke-linecap",
"stroke-linejoin",
"stroke-opacity",
"stroke-width",
)
_SEMANTIC_ATTRS = (
AUTHORING_ATTR,
"data-pptx-object",
"data-pptx-prst",
"data-pptx-frame",
)
_TEMPLATE_ATOM_ATTRS = (
"data-pptx-layer",
"data-pptx-editable",
"data-pptx-carrier",
"data-pptx-role",
)
def render_preset_shape_fragment(
preset: str,
frame: tuple[float, float, float, float],
*,
adjustments: Mapping[str, str | int | float] | None = None,
object_kind: str = "shape",
element_id: str,
name: str | None = None,
style: Mapping[str, str] | None = None,
) -> str:
"""Render one compact authored preset fragment for SVG insertion."""
registry = get_preset_registry()
if preset not in registry:
raise ValueError(f"Unknown DrawingML preset shape: {preset!r}")
if _ID_RE.fullmatch(element_id) is None:
raise ValueError(f"Invalid SVG element id: {element_id!r}")
if object_kind not in {"shape", "connector"}:
raise ValueError("object_kind must be 'shape' or 'connector'")
if preset in CONNECTOR_PRESET_TYPES and object_kind != "connector":
raise ValueError(
f"Connector preset {preset!r} requires object_kind='connector'"
)
if object_kind == "connector" and preset not in CONNECTOR_PRESET_TYPES:
raise ValueError(
f"Authored connector requires a connector preset, got {preset!r}"
)
x, y, width, height = _validate_frame(frame, object_kind)
adjustment_values = _normalize_adjustments(adjustments or {})
_validate_adjustments(preset, adjustment_values)
registry.evaluate(
preset,
width,
height,
adjustments=adjustment_values,
)
rendered = render_preset_geometry(
preset,
Xfrm(x=x, y=y, w=width, h=height),
adjustment_values,
)
if not rendered.paths:
raise ValueError(f"Preset {preset!r} produced no visible SVG paths")
frame_text = " ".join(
fmt_num(value, 8) for value in (x, y, width, height)
)
semantic_attrs = {
AUTHORING_ATTR: AUTHORING_VALUE,
"data-pptx-object": object_kind,
"data-pptx-prst": preset,
"data-pptx-frame": frame_text,
}
if name:
semantic_attrs["data-pptx-shape-name"] = name
for guide_name, formula in adjustment_values.items():
semantic_attrs[f"{_ADJUSTMENT_PREFIX}{guide_name}"] = str(formula)
raw_style = dict(style or {})
if "fill" not in raw_style or "stroke" not in raw_style:
raise ValueError(
"Compact authored preset requires explicit local fill and stroke"
)
style_attrs = _validate_style(raw_style)
if (
style_attrs.get("stroke", "none") != "none"
and "stroke-width" not in style_attrs
):
style_attrs["stroke-width"] = "1"
if object_kind == "connector":
if style_attrs.get("fill", "none") != "none":
raise ValueError("Authored connector fill must be none")
if not _has_visible_stroke(style_attrs):
raise ValueError("Authored connector requires a visible stroke")
group_attrs = {
"id": element_id,
**semantic_attrs,
**style_attrs,
}
return (
f'<g{attrs_to_xml(group_attrs)}>\n'
f"{serialize_compact_preset_layers(rendered.paths, style_attrs)}\n"
"</g>"
)
def authored_preset_encoding(group: ET.Element) -> str | None:
"""Return ``compact`` / ``expanded`` for an authored preset group."""
if (
_local_name(group.tag) != "g"
or group.get(AUTHORING_ATTR) != AUTHORING_VALUE
):
return None
parts = {
child.get("data-pptx-part")
for child in group
if child.get("data-pptx-part") is not None
}
if parts:
return "expanded"
return "compact"
def validate_authored_preset_group(group: ET.Element) -> list[str]:
"""Return authored-preset contract errors for one logical group."""
encoding = authored_preset_encoding(group)
if encoding is None:
return []
if encoding == "compact":
return _validate_compact_authored_preset_group(group)
return _validate_expanded_authored_preset_group(group)
def _validate_compact_authored_preset_group(group: ET.Element) -> list[str]:
"""Validate the project-canonical compact authored-preset form."""
errors: list[str] = []
if not _is_svg_element(group, "g"):
return [f'{AUTHORING_ATTR}="{AUTHORING_VALUE}" requires an SVG <g>']
element_id = group.get("id")
if element_id is None:
errors.append("Authored preset logical group requires a stable id")
elif _ID_RE.fullmatch(element_id) is None:
errors.append(f"Authored preset logical group has invalid id {element_id!r}")
unexpected_group_attrs = sorted(
name for name in group.attrib
if _is_unexpected_group_attr(name, compact=True)
)
if unexpected_group_attrs:
errors.append(
"Authored preset logical group has unsupported attributes: "
+ ", ".join(unexpected_group_attrs)
)
if group.get("data-pptx-preview-sha256") is not None:
errors.append(
"Compact authored preset derives preview integrity from the registry; "
"remove data-pptx-preview-sha256"
)
if (group.text or "").strip():
errors.append("Compact authored preset cannot contain text content")
direct_children = list(group)
if not direct_children:
errors.append("Compact authored preset requires visible direct path layers")
return errors
if any(child.get("data-pptx-part") is not None for child in direct_children):
errors.append(
"Compact authored preset cannot mix transport carrier/preview markers"
)
return errors
if any(
not _is_svg_element(child, "path")
for child in direct_children
):
errors.append(
"Compact authored preset is atomic and may contain only direct SVG paths"
)
return errors
if any(list(child) for child in direct_children):
errors.append("Compact authored preset paths cannot contain child elements")
if any(
(child.text or "").strip() or (child.tail or "").strip()
for child in direct_children
):
errors.append(
"Compact authored preset paths may contain only whitespace around markup"
)
preset = group.get("data-pptx-prst") or ""
object_kind = group.get("data-pptx-object") or ""
if object_kind not in {"shape", "connector"}:
errors.append(
"Authored preset data-pptx-object must be 'shape' or 'connector'"
)
if preset in CONNECTOR_PRESET_TYPES and object_kind != "connector":
errors.append(
f"Connector preset {preset!r} requires data-pptx-object='connector'"
)
if object_kind == "connector" and preset not in CONNECTOR_PRESET_TYPES:
errors.append(
f"Authored connector requires a connector preset, got {preset!r}"
)
try:
frame = _parse_frame(group.get("data-pptx-frame"), object_kind)
canonical_frame = " ".join(fmt_num(value, 8) for value in frame)
if group.get("data-pptx-frame") != canonical_frame:
raise ValueError(
"Compact authored preset data-pptx-frame must use the helper's "
f"canonical spelling {canonical_frame!r}"
)
adjustments = {
name[len(_ADJUSTMENT_PREFIX):]: value
for name, value in group.attrib.items()
if name.startswith(_ADJUSTMENT_PREFIX)
}
_validate_adjustments(preset, adjustments)
rendered = render_preset_geometry(
preset,
Xfrm(x=frame[0], y=frame[1], w=frame[2], h=frame[3]),
adjustments,
)
raw_style = {
name: group.attrib[name]
for name in _STYLE_ATTRS
if name in group.attrib
}
if "fill" not in raw_style or "stroke" not in raw_style:
raise ValueError(
"Compact authored preset requires explicit local fill and stroke"
)
style_attrs = _validate_style(raw_style)
noncanonical_style = sorted(
name for name, value in style_attrs.items()
if raw_style.get(name) != value
)
if noncanonical_style:
raise ValueError(
"Compact authored preset style uses non-canonical values: "
+ ", ".join(noncanonical_style)
)
if style_attrs.get("stroke", "none") != "none" and (
"stroke-width" not in style_attrs
):
raise ValueError(
"Compact authored preset with a visible stroke requires stroke-width"
)
if object_kind == "connector":
if style_attrs.get("fill", "none") != "none":
raise ValueError("Authored connector fill must be none")
if not _has_visible_stroke(style_attrs):
raise ValueError("Authored connector requires a visible stroke")
expected_markup = serialize_compact_preset_layers(
rendered.paths,
style_attrs,
)
expected_root = ET.fromstring(
f'<g xmlns="http://www.w3.org/2000/svg">{expected_markup}</g>'
)
except (ET.ParseError, ValueError) as exc:
errors.append(f"Cannot regenerate compact authored preset: {exc}")
return errors
expected_children = list(expected_root)
if len(direct_children) != len(expected_children):
errors.append(
"Compact authored preset path count differs from registry output: "
f"expected {len(expected_children)}, found {len(direct_children)}"
)
return errors
for index, (actual, expected) in enumerate(
zip(direct_children, expected_children),
start=1,
):
if actual.attrib != expected.attrib:
errors.append(
f"Compact authored preset path {index} differs from registry output"
)
return errors
def _validate_expanded_authored_preset_group(group: ET.Element) -> list[str]:
"""Validate the legacy expanded authored-preset compatibility form."""
if group.get(AUTHORING_ATTR) != AUTHORING_VALUE:
return []
errors: list[str] = []
if not _is_svg_element(group, "g"):
return [f'{AUTHORING_ATTR}="{AUTHORING_VALUE}" requires an SVG <g>']
element_id = group.get("id")
if element_id is None:
errors.append("Authored preset logical group requires a stable id")
elif _ID_RE.fullmatch(element_id) is None:
errors.append(f"Authored preset logical group has invalid id {element_id!r}")
unexpected_group_attrs = sorted(
name for name in group.attrib
if _is_unexpected_group_attr(name, compact=False)
)
if unexpected_group_attrs:
errors.append(
"Authored preset logical group has unsupported attributes: "
+ ", ".join(unexpected_group_attrs)
)
direct_children = list(group)
carriers = [
child
for child in direct_children
if child.get("data-pptx-part") == "geometry"
]
previews = [
child
for child in direct_children
if child.get("data-pptx-part") == "geometry-preview"
]
if len(carriers) != 1:
errors.append(
f"Authored preset requires exactly one direct geometry carrier; "
f"found {len(carriers)}"
)
if len(previews) != 1:
errors.append(
f"Authored preset requires exactly one direct geometry preview; "
f"found {len(previews)}"
)
allowed_children = set(carriers + previews)
foreign_children = [
child for child in direct_children
if child not in allowed_children
]
if foreign_children:
errors.append(
"Authored preset groups are atomic; place labels or decorations "
"in a parent group"
)
if len(carriers) != 1 or len(previews) != 1:
return errors
carrier = carriers[0]
preview = previews[0]
if not _is_svg_element(carrier, "path"):
errors.append("Authored preset geometry carrier must be an SVG <path>")
if not _is_svg_element(preview, "g"):
errors.append("Authored preset geometry preview must be an SVG <g>")
if carrier.get("visibility") != "hidden":
errors.append('Authored preset carrier requires visibility="hidden"')
if carrier.get("pointer-events") != "none":
errors.append('Authored preset carrier requires pointer-events="none"')
for attr_name in _SEMANTIC_ATTRS:
if group.get(attr_name) != carrier.get(attr_name):
errors.append(
f"Authored preset group/carrier {attr_name} values differ"
)
adjustment_names = {
name
for element in (group, carrier)
for name in element.attrib
if name.startswith(_ADJUSTMENT_PREFIX)
}
for attr_name in sorted(adjustment_names):
if group.get(attr_name) != carrier.get(attr_name):
errors.append(
f"Authored preset group/carrier {attr_name} values differ"
)
unexpected_carrier_attrs = [
name
for name in carrier.attrib
if _is_unexpected_carrier_attr(name)
]
if unexpected_carrier_attrs:
errors.append(
"Authored preset carrier has unsupported presentation attributes: "
+ ", ".join(sorted(unexpected_carrier_attrs))
)
preset = carrier.get("data-pptx-prst") or ""
object_kind = carrier.get("data-pptx-object") or ""
if object_kind not in {"shape", "connector"}:
errors.append(
"Authored preset data-pptx-object must be 'shape' or 'connector'"
)
if preset in CONNECTOR_PRESET_TYPES and object_kind != "connector":
errors.append(
f"Connector preset {preset!r} requires data-pptx-object='connector'"
)
if object_kind == "connector" and preset not in CONNECTOR_PRESET_TYPES:
errors.append(
f"Authored connector requires a connector preset, got {preset!r}"
)
try:
frame = _parse_frame(carrier.get("data-pptx-frame"), object_kind)
adjustments = {
name[len(_ADJUSTMENT_PREFIX):]: value
for name, value in carrier.attrib.items()
if name.startswith(_ADJUSTMENT_PREFIX)
}
_validate_adjustments(preset, adjustments)
rendered = render_preset_geometry(
preset,
Xfrm(x=frame[0], y=frame[1], w=frame[2], h=frame[3]),
adjustments,
)
style_attrs = _validate_style({
name: carrier.attrib[name]
for name in _STYLE_ATTRS
if name in carrier.attrib
})
if object_kind == "connector":
if style_attrs.get("fill", "none") != "none":
raise ValueError("Authored connector fill must be none")
if not _has_visible_stroke(style_attrs):
raise ValueError("Authored connector requires a visible stroke")
expected = serialize_preset_layers(
rendered.paths,
{
name: value
for name, value in carrier.attrib.items()
if name in _SEMANTIC_ATTRS
or name.startswith(_ADJUSTMENT_PREFIX)
or name == "data-pptx-shape-name"
},
style_attrs,
)
except ValueError as exc:
errors.append(f"Cannot regenerate authored preset preview: {exc}")
return errors
if (carrier.get("d") or "").strip() != _carrier_path(rendered.paths):
errors.append("Authored preset carrier path differs from registry output")
actual_preview_hash = svg_preset_preview_fingerprint(group)
if actual_preview_hash != expected.preview_hash:
errors.append("Authored preset visible preview differs from registry output")
try:
stored_hash = resolve_preset_preview_hash(group)
except ValueError as exc:
errors.append(f"Invalid authored preset preview fingerprint: {exc}")
else:
if stored_hash != expected.preview_hash:
errors.append(
"Authored preset fingerprint does not match regenerated metadata"
)
return errors
def validate_authored_preset_tree(root: ET.Element) -> list[str]:
"""Return structural errors for every authored preset marker in one SVG."""
errors: list[str] = []
id_counts: dict[str, int] = {}
for element in root.iter():
element_id = element.get("id")
if element_id:
id_counts[element_id] = id_counts.get(element_id, 0) + 1
parents = {
child: parent
for parent in root.iter()
for child in parent
}
for element in root.iter():
authoring = element.get(AUTHORING_ATTR)
if authoring is None:
continue
tag = _local_name(element.tag)
label = _element_label(element)
if authoring != AUTHORING_VALUE:
errors.append(
f"{label}: unsupported {AUTHORING_ATTR} value {authoring!r}"
)
continue
if tag == "g":
errors.extend(
f"{label}: {error}"
for error in validate_authored_preset_group(element)
)
element_id = element.get("id")
if element_id and id_counts.get(element_id, 0) > 1:
errors.append(
f"{label}: authored preset logical group id must be "
"globally unique"
)
continue
if element.get("data-pptx-part") != "geometry":
errors.append(
f"{label}: authored preset metadata is allowed only on the "
"logical group and its direct geometry carrier"
)
continue
parent = parents.get(element)
if (
parent is None
or not _is_svg_element(parent, "g")
or parent.get(AUTHORING_ATTR) != AUTHORING_VALUE
):
errors.append(
f"{label}: authored preset geometry carrier must be a direct "
"child of its authored logical group"
)
return errors
def materialize_compact_authored_preset_tree(root: ET.Element) -> int:
"""Expand validated compact authored presets in memory for conversion.
Source SVG stays compact. The converter reuses the established lossless
carrier/preview path internally, so compact and expanded inputs share one
DrawingML implementation.
"""
materialized = 0
for group in list(root.iter()):
if authored_preset_encoding(group) != "compact":
continue
errors = _validate_compact_authored_preset_group(group)
if errors:
raise ValueError("; ".join(errors))
preset = group.get("data-pptx-prst") or ""
object_kind = group.get("data-pptx-object") or ""
frame = _parse_frame(group.get("data-pptx-frame"), object_kind)
adjustments = {
name[len(_ADJUSTMENT_PREFIX):]: value
for name, value in group.attrib.items()
if name.startswith(_ADJUSTMENT_PREFIX)
}
rendered = render_preset_geometry(
preset,
Xfrm(x=frame[0], y=frame[1], w=frame[2], h=frame[3]),
adjustments,
)
style_attrs = _validate_style({
name: group.attrib[name]
for name in _STYLE_ATTRS
if name in group.attrib
})
semantic_attrs = {
name: value
for name, value in group.attrib.items()
if name in _SEMANTIC_ATTRS
or name.startswith(_ADJUSTMENT_PREFIX)
or name == "data-pptx-shape-name"
}
markup = serialize_preset_layers(
rendered.paths,
semantic_attrs,
style_attrs,
)
for name in _STYLE_ATTRS:
group.attrib.pop(name, None)
group.set("data-pptx-preview-sha256", markup.preview_hash)
for child in list(group):
group.remove(child)
wrapper = ET.fromstring(
'<svg xmlns="http://www.w3.org/2000/svg">'
f"{markup.markup}"
"</svg>"
)
for child in list(wrapper):
wrapper.remove(child)
group.append(child)
materialized += 1
return materialized
def _validate_frame(
frame: tuple[float, float, float, float],
object_kind: str,
) -> tuple[float, float, float, float]:
if len(frame) != 4:
raise ValueError("frame must contain x, y, width, and height")
values = tuple(float(value) for value in frame)
if not all(math.isfinite(value) for value in values):
raise ValueError("frame values must be finite")
width, height = values[2], values[3]
if object_kind == "connector":
if width < 0 or height < 0 or (width == 0 and height == 0):
raise ValueError(
"connector frame dimensions must be non-negative and not both zero"
)
elif width <= 0 or height <= 0:
raise ValueError("shape frame width and height must be positive")
validate_ooxml_xfrm(
round(values[0] * EMU_PER_PX),
round(values[1] * EMU_PER_PX),
round(width * EMU_PER_PX),
round(height * EMU_PER_PX),
)
return values
def _parse_frame(
raw: str | None,
object_kind: str,
) -> tuple[float, float, float, float]:
if raw is None:
raise ValueError("authored preset requires data-pptx-frame")
parts = re.split(r"[\s,]+", raw.strip())
if len(parts) != 4:
raise ValueError("data-pptx-frame must contain four numbers")
return _validate_frame(tuple(float(part) for part in parts), object_kind)
def _validate_style(style: Mapping[str, str]) -> dict[str, str]:
unknown = sorted(set(style) - set(_STYLE_ATTRS))
if unknown:
raise ValueError(f"Unsupported authored preset style attributes: {unknown}")
normalized = {name: str(value).strip() for name, value in style.items()}
if not normalized:
raise ValueError("Authored preset requires explicit fill and/or stroke")
if normalized.get("fill", "none") == "none" and normalized.get(
"stroke", "none"
) == "none":
raise ValueError("Authored preset cannot have both fill and stroke set to none")
for name in ("fill", "stroke"):
value = normalized.get(name, "none")
if _PAINT_RE.fullmatch(value) is None:
raise ValueError(f"{name} must be none or a six-digit HEX color")
normalized[name] = value.upper() if value != "none" else value
if normalized.get("stroke", "none") == "none":
unused_stroke_attrs = sorted(
name for name in normalized
if name.startswith("stroke-")
)
if unused_stroke_attrs:
raise ValueError(
"Stroke presentation attributes require a visible stroke: "
+ ", ".join(unused_stroke_attrs)
)
if normalized.get("stroke-linecap") not in {None, "butt", "round", "square"}:
raise ValueError("stroke-linecap must be butt, round, or square")
if normalized.get("stroke-linejoin") not in {None, "miter", "round", "bevel"}:
raise ValueError("stroke-linejoin must be miter, round, or bevel")
for name in ("fill-opacity", "stroke-opacity"):
if name not in normalized:
continue
value = float(normalized[name])
if not math.isfinite(value) or value < 0 or value > 1:
raise ValueError(f"{name} must be between 0 and 1")
normalized[name] = fmt_num(value, 6)
if "stroke-width" in normalized:
width = float(normalized["stroke-width"])
if not math.isfinite(width) or width < 0:
raise ValueError("stroke-width must be finite and non-negative")
validate_ooxml_line_width(round(width * EMU_PER_PX))
normalized["stroke-width"] = fmt_num(width, 6)
if normalized.get("fill", "none") == "none" and "fill-opacity" in normalized:
raise ValueError("fill-opacity requires a visible fill paint")
if not _has_visible_fill(normalized) and not _has_visible_stroke(normalized):
raise ValueError(
"Authored preset requires at least one non-transparent visible paint"
)
return normalized
def _normalize_adjustments(
adjustments: Mapping[str, str | int | float],
) -> dict[str, str]:
normalized: dict[str, str] = {}
for name, value in adjustments.items():
if isinstance(value, bool):
raise ValueError(f"Adjustment {name!r} must not be boolean")
if isinstance(value, int):
formula = f"val {value}"
elif isinstance(value, float):
if not math.isfinite(value) or not value.is_integer():
raise ValueError(
f"Numeric adjustment {name!r} must be a finite integer"
)
formula = f"val {int(value)}"
else:
formula = str(value).strip()
if len(formula.split()) == 1:
formula = f"val {formula}"
normalized[str(name)] = formula
return normalized
def _validate_adjustments(
preset: str,
adjustments: Mapping[str, str | int | float],
) -> None:
registry = get_preset_registry()
if preset not in registry:
raise ValueError(f"Unknown DrawingML preset shape: {preset!r}")
for name, formula in adjustments.items():
if not isinstance(formula, str) or not formula.strip():
raise ValueError(f"Adjustment {name!r} requires a formula")
parts = formula.split()
if parts[0] not in SUPPORTED_OPERATORS:
raise ValueError(
f"Adjustment {name!r} must use a DrawingML formula operator"
)
if parts[0] == "val" and len(parts) == 2:
try:
float(parts[1])
except ValueError:
pass
else:
if _INTEGER_RE.fullmatch(parts[1]) is None:
raise ValueError(
f"Adjustment {name!r} val operand must be an integer "
"coordinate"
)
if not adjustments:
return
evaluated = registry.evaluate(
preset,
100000,
100000,
adjustments=adjustments,
)
for name, value in evaluated.adjustments.items():
if name not in adjustments:
continue
if not OOXML_COORDINATE_MIN <= value <= OOXML_COORDINATE_MAX:
raise ValueError(
f"Adjustment {name!r} evaluates outside OOXML coordinate range"
)
def _has_visible_fill(style: Mapping[str, str]) -> bool:
return (
style.get("fill", "none") != "none"
and float(style.get("fill-opacity", "1")) > 0
)
def _has_visible_stroke(style: Mapping[str, str]) -> bool:
return (
style.get("stroke", "none") != "none"
and float(style.get("stroke-opacity", "1")) > 0
and float(style.get("stroke-width", "1")) > 0
)
def _is_unexpected_carrier_attr(name: str) -> bool:
if name in {
"d",
"data-pptx-preview-sha256",
"data-pptx-part",
"data-pptx-shape-name",
"visibility",
"pointer-events",
*_SEMANTIC_ATTRS,
*_STYLE_ATTRS,
}:
return False
return not name.startswith(_ADJUSTMENT_PREFIX)
def _is_unexpected_group_attr(name: str, *, compact: bool) -> bool:
allowed = {
"id",
"transform",
"data-pptx-preview-sha256",
"data-pptx-shape-name",
*_SEMANTIC_ATTRS,
}
if compact:
allowed.update(_STYLE_ATTRS)
allowed.update(_TEMPLATE_ATOM_ATTRS)
if name in allowed:
return False
if name.startswith(_ADJUSTMENT_PREFIX):
return False
if name.startswith("data-pptx-runtime-") or name.startswith("aria-"):
return False
return name not in {"role", "tabindex"}
def _carrier_path(paths) -> str:
return " ".join(path.d for path in paths).strip()
def _local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1]
def _is_svg_element(element: ET.Element, local_name: str) -> bool:
return element.tag in {
local_name,
f"{{{_SVG_NAMESPACE}}}{local_name}",
}
def _element_label(element: ET.Element) -> str:
tag = _local_name(element.tag)
element_id = element.get("id")
if element_id:
return f'<{tag} id="{element_id}">'
return f"<{tag}>"
@@ -0,0 +1,216 @@
#!/usr/bin/env python3
"""
PPT Master - Preset Geometry SVG Adapter
Render evaluated DrawingML preset geometry as absolute SVG path layers.
Usage:
Import render_preset_geometry from pptx_to_svg.preset_registry_to_svg.
Examples:
geometry = render_preset_geometry("rightArrow", xfrm)
Dependencies:
None (only uses standard library and local PPT Master modules)
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from typing import Mapping
from pptx_shapes import get_preset_registry
from .emu_units import Xfrm, fmt_num
@dataclass(frozen=True)
class SvgPresetPath:
"""One visible layer from a DrawingML preset's ``a:pathLst``."""
d: str
fill: str
stroke: bool
@dataclass(frozen=True)
class SvgPresetGeometry:
"""A fully evaluated preset preview in slide-absolute SVG coordinates."""
paths: tuple[SvgPresetPath, ...]
def render_preset_geometry(
preset: str,
xfrm: Xfrm,
adjustments: Mapping[str, str | int | float] | None = None,
) -> SvgPresetGeometry:
"""Evaluate ``preset`` and project every DrawingML path into SVG space."""
evaluated = get_preset_registry().evaluate(
preset,
xfrm.w,
xfrm.h,
adjustments=adjustments,
)
layers = tuple(
SvgPresetPath(
d=render_evaluated_path(
path.commands,
x=xfrm.x,
y=xfrm.y,
width=xfrm.w,
height=xfrm.h,
coordinate_width=path.coordinate_width,
coordinate_height=path.coordinate_height,
),
fill=path.fill,
stroke=path.stroke,
)
for path in evaluated.paths
)
return SvgPresetGeometry(paths=tuple(layer for layer in layers if layer.d))
def render_evaluated_path(
commands,
*,
x: float,
y: float,
width: float,
height: float,
coordinate_width: float,
coordinate_height: float,
) -> str:
scale_x = width / coordinate_width if coordinate_width else 1.0
scale_y = height / coordinate_height if coordinate_height else 1.0
def point(px: float, py: float) -> tuple[float, float]:
return x + px * scale_x, y + py * scale_y
parts: list[str] = []
current = (x, y)
subpath_start = current
for command in commands:
values = command.parameters
if command.name == "moveTo":
current = point(values[0], values[1])
subpath_start = current
parts.append(f"M {fmt_num(current[0])} {fmt_num(current[1])}")
elif command.name == "lnTo":
current = point(values[0], values[1])
parts.append(f"L {fmt_num(current[0])} {fmt_num(current[1])}")
elif command.name == "quadBezTo":
control = point(values[0], values[1])
current = point(values[2], values[3])
parts.append(
"Q "
f"{fmt_num(control[0])} {fmt_num(control[1])} "
f"{fmt_num(current[0])} {fmt_num(current[1])}"
)
elif command.name == "cubicBezTo":
control_1 = point(values[0], values[1])
control_2 = point(values[2], values[3])
current = point(values[4], values[5])
parts.append(
"C "
f"{fmt_num(control_1[0])} {fmt_num(control_1[1])} "
f"{fmt_num(control_2[0])} {fmt_num(control_2[1])} "
f"{fmt_num(current[0])} {fmt_num(current[1])}"
)
elif command.name == "arcTo":
arc_parts, current = _render_arc(
current,
radius_x=values[0],
radius_y=values[1],
scale_x=scale_x,
scale_y=scale_y,
start_angle=values[2],
sweep_angle=values[3],
)
parts.extend(arc_parts)
elif command.name == "close":
parts.append("Z")
current = subpath_start
return " ".join(parts)
def _render_arc(
current: tuple[float, float],
*,
radius_x: float,
radius_y: float,
scale_x: float = 1.0,
scale_y: float = 1.0,
start_angle: float,
sweep_angle: float,
) -> tuple[list[str], tuple[float, float]]:
"""Render one DrawingML arc, splitting full circles for SVG validity.
DrawingML resolves the polar angle in the path-local ellipse before the
path coordinate system is scaled into the shape frame. Applying the
angle correction to already-scaled radii bends explicit-extent paths such
as ``cloud`` when the containing shape has a non-square aspect ratio.
"""
radius_x = abs(radius_x)
radius_y = abs(radius_y)
scaled_radius_x = abs(radius_x * scale_x)
scaled_radius_y = abs(radius_y * scale_y)
if (
radius_x <= 1e-12
or radius_y <= 1e-12
or scaled_radius_x <= 1e-12
or scaled_radius_y <= 1e-12
or abs(sweep_angle) <= 1e-12
):
return [], current
start_radians = _ellipse_parameter_angle(
start_angle,
radius_x,
radius_y,
)
center_x = current[0] - scaled_radius_x * math.cos(start_radians)
center_y = current[1] - scaled_radius_y * math.sin(start_radians)
# SVG cannot represent a 360-degree arc with one A command because its
# start and end points coincide. Chunks of at most 180 degrees also keep
# the large-arc flag deterministic for every preset definition.
remaining = sweep_angle
angle = start_angle
parts: list[str] = []
endpoint = current
half_circle = 180.0 * 60000.0
while abs(remaining) > 1e-9:
step = math.copysign(min(abs(remaining), half_circle), remaining)
angle += step
end_radians = _ellipse_parameter_angle(angle, radius_x, radius_y)
endpoint = (
center_x + scaled_radius_x * math.cos(end_radians),
center_y + scaled_radius_y * math.sin(end_radians),
)
large_arc = 1 if abs(step) > half_circle else 0
sweep = 1 if step >= 0 else 0
parts.append(
"A "
f"{fmt_num(scaled_radius_x)} {fmt_num(scaled_radius_y)} "
f"0 {large_arc} {sweep} "
f"{fmt_num(endpoint[0])} {fmt_num(endpoint[1])}"
)
remaining -= step
return parts, endpoint
def _ellipse_parameter_angle(
ooxml_angle: float,
radius_x: float,
radius_y: float,
) -> float:
"""Unskew an OOXML polar angle into an ellipse parameter angle."""
radians = math.radians(ooxml_angle / 60000.0)
return math.atan2(
radius_x * math.sin(radians),
radius_y * math.cos(radians),
)
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""
PPT Master - Preset Shape SVG Markup
Serialize evaluated DrawingML preset layers for lossless transport or compact
project authoring.
Usage:
Import serialize_preset_layers from pptx_to_svg.preset_svg_markup.
Examples:
markup = serialize_preset_layers(layers, semantic_attrs, style_attrs)
Dependencies:
None (only uses standard library and local PPT Master modules)
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Mapping, Sequence
from xml.etree import ElementTree as ET
from pptx_shapes import svg_preset_preview_fingerprint
from .preset_registry_to_svg import SvgPresetPath
@dataclass(frozen=True)
class PresetSvgMarkup:
"""Lossless hidden-carrier and visible-preview markup for one preset."""
carrier: str
preview: str
preview_hash: str
@property
def markup(self) -> str:
"""Return the carrier and preview in canonical document order."""
return f"{self.carrier}\n{self.preview}"
def serialize_preset_layers(
layers: Sequence[SvgPresetPath],
semantic_attrs: Mapping[str, str],
style_attrs: Mapping[str, str],
) -> PresetSvgMarkup:
"""Serialize one preset without duplicating its native PowerPoint object."""
detail_style_attrs = dict(style_attrs)
preview_group_attrs = {"data-pptx-part": "geometry-preview"}
for name in ("filter", "opacity"):
value = detail_style_attrs.pop(name, None)
if value is not None:
preview_group_attrs[name] = value
detail_layers: list[str] = []
for layer in layers:
attrs = dict(detail_style_attrs)
apply_preset_path_fill(attrs, layer.fill)
if not layer.stroke:
remove_stroke_attrs(attrs)
attrs["stroke"] = "none"
attrs["data-pptx-part"] = "geometry-detail"
detail_layers.append(
f'<path d="{_xml_escape(layer.d)}"{attrs_to_xml(attrs)}/>'
)
preview = (
f'<g{attrs_to_xml(preview_group_attrs)}>\n'
+ "\n".join(detail_layers)
+ "\n</g>"
)
preview_root = ET.fromstring(
f'<svg xmlns="http://www.w3.org/2000/svg">{preview}</svg>'
)
preview_hash = svg_preset_preview_fingerprint(preview_root)
carrier_attrs = {
**style_attrs,
**semantic_attrs,
"data-pptx-preview-sha256": preview_hash,
"data-pptx-part": "geometry",
"visibility": "hidden",
"pointer-events": "none",
}
combined_path = " ".join(layer.d for layer in layers)
carrier = (
f'<path d="{_xml_escape(combined_path)}"'
f'{attrs_to_xml(carrier_attrs)}/>'
)
return PresetSvgMarkup(
carrier=carrier,
preview=preview,
preview_hash=preview_hash,
)
def serialize_compact_preset_layers(
layers: Sequence[SvgPresetPath],
style_attrs: Mapping[str, str],
) -> str:
"""Serialize visible preset layers without transport-only duplication.
Base paint lives once on the logical authored group and is inherited by
each path. A path writes only the fill/stroke override required by its
DrawingML layer. The authored-preset validator regenerates this exact
markup from the registry, so no serialized fingerprint is needed.
"""
base_fill = style_attrs.get("fill", "none")
base_stroke = style_attrs.get("stroke", "none")
detail_layers: list[str] = []
for layer in layers:
attrs: dict[str, str] = {}
if layer.fill == "none":
if base_fill != "none":
attrs["fill"] = "none"
elif layer.fill != "norm":
derived_fill = {"fill": base_fill}
apply_preset_path_fill(derived_fill, layer.fill)
if derived_fill.get("fill") != base_fill:
attrs["fill"] = derived_fill["fill"]
if not layer.stroke and base_stroke != "none":
attrs["stroke"] = "none"
detail_layers.append(
f'<path d="{_xml_escape(layer.d)}"{attrs_to_xml(attrs)}/>'
)
return "\n".join(detail_layers)
def apply_preset_path_fill(attrs: dict[str, str], mode: str) -> None:
"""Apply one DrawingML path fill mode to SVG presentation attributes."""
if mode == "none":
attrs["fill"] = "none"
attrs.pop("fill-opacity", None)
return
if mode == "norm":
return
color = attrs.get("fill", "")
if not color.startswith("#") or len(color) != 7:
return
try:
channels = tuple(
int(color[offset:offset + 2], 16)
for offset in (1, 3, 5)
)
except ValueError:
return
if mode in {"darken", "darkenLess"}:
factor = 0.65 if mode == "darken" else 0.82
adjusted = tuple(round(channel * factor) for channel in channels)
elif mode in {"lighten", "lightenLess"}:
amount = 0.4 if mode == "lighten" else 0.2
adjusted = tuple(
round(channel + (255 - channel) * amount)
for channel in channels
)
else:
return
attrs["fill"] = "#" + "".join(
f"{channel:02X}" for channel in adjusted
)
def remove_stroke_attrs(attrs: dict[str, str]) -> None:
"""Remove inherited stroke and marker attributes from one path layer."""
for name in tuple(attrs):
if name.startswith("stroke") or name in {"marker-start", "marker-end"}:
attrs.pop(name, None)
def attrs_to_xml(attrs: Mapping[str, str]) -> str:
"""Serialize SVG attributes in deterministic insertion order."""
return "".join(
f' {name}="{_xml_escape(value)}"'
for name, value in attrs.items()
)
def _xml_escape(value: str) -> str:
text = str(value)
if any(not _is_xml_10_character(character) for character in text):
raise ValueError("SVG markup contains an XML 1.0-incompatible character")
return (
text
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
def _is_xml_10_character(character: str) -> bool:
codepoint = ord(character)
return (
codepoint in {0x09, 0x0A, 0x0D}
or 0x20 <= codepoint <= 0xD7FF
or 0xE000 <= codepoint <= 0xFFFD
or 0x10000 <= codepoint <= 0x10FFFF
)
@@ -0,0 +1,162 @@
"""DrawingML <a:prstGeom> -> SVG geometry conversion.
The visible SVG geometry is accompanied by the source preset name and every
explicit ``a:avLst`` guide formula. This metadata is rendering-neutral and
lets the reverse converter distinguish a native PowerPoint shape from an
arbitrary SVG path without guessing from its appearance.
The standard preset catalog is evaluated by the shared data-driven geometry
engine. Presets outside that locked catalog retain their source semantics on
an explicitly marked bounding-box fallback; they never masquerade as ``rect``.
The evaluated result carries one or more painted SVG path layers plus the
rendering-neutral metadata used by the reverse converter.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from xml.etree import ElementTree as ET
from pptx_shapes import get_preset_registry
from .emu_units import NS, Xfrm, fmt_num
from .preset_registry_to_svg import SvgPresetPath, render_preset_geometry
# ---------------------------------------------------------------------------
# GeomResult
# ---------------------------------------------------------------------------
@dataclass
class GeomResult:
"""Result of converting a prst preset to SVG.
`tag` is the SVG element tag (rect / ellipse / line / polygon / path /
polyline). `attrs` are absolute SVG coordinates already in slide space.
`path_d` (when tag == 'path') is the d attribute. The slide assembler
merges fill/stroke attrs from fill_to_svg/ln_to_svg.
"""
tag: str
attrs: dict[str, str] = field(default_factory=dict)
# When tag == 'path' use path_d for the d attribute.
path_d: str | None = None
# When tag == 'polygon' / 'polyline' use points for the points attribute.
points: str | None = None
# Standard presets can contain multiple independently painted path layers.
layers: tuple[SvgPresetPath, ...] = ()
# ---------------------------------------------------------------------------
# Explicit fallback for presets outside the locked standard catalog
# ---------------------------------------------------------------------------
def _rect(xfrm: Xfrm) -> GeomResult:
return GeomResult(
tag="rect",
attrs={
"x": fmt_num(xfrm.x),
"y": fmt_num(xfrm.y),
"width": fmt_num(xfrm.w),
"height": fmt_num(xfrm.h),
},
)
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
def convert_prst_geom(
prst: str,
xfrm: Xfrm,
sp_pr: ET.Element | None,
) -> GeomResult | None:
"""Convert <a:prstGeom prst="..."> to a GeomResult.
Every emitted result carries ``data-pptx-prst`` plus one
``data-pptx-av-<name>`` attribute per explicit adjustment guide. Unknown
presets use a visibly neutral bounding-box fallback with diagnostic
metadata instead of silently changing their semantic type to ``rect``.
Returns ``None`` only when the logical frame cannot produce geometry.
"""
metadata = _preset_metadata(prst, sp_pr)
registry = get_preset_registry()
if prst not in registry:
result = _rect(xfrm)
result.attrs.update(metadata)
result.attrs.update({
"data-pptx-geometry-status": "unsupported",
"data-pptx-geometry-reason": f"unsupported-preset:{prst}",
})
return result
if xfrm.w < 0 or xfrm.h < 0 or (xfrm.w == 0 and xfrm.h == 0):
return None
try:
rendered = render_preset_geometry(
prst,
xfrm,
_preset_adjustments(sp_pr),
)
except ValueError as exc:
result = _rect(xfrm)
result.attrs.update(metadata)
result.attrs.update({
"data-pptx-geometry-status": "unsupported",
"data-pptx-geometry-reason": (
f"preset-evaluation-error:{type(exc).__name__}"
),
})
return result
if not rendered.paths:
return None
return GeomResult(
tag="path",
attrs=metadata,
path_d=" ".join(path.d for path in rendered.paths),
layers=rendered.paths,
)
def _preset_metadata(
prst: str,
prst_geom: ET.Element | None,
) -> dict[str, str]:
"""Return rendering-neutral SVG attributes for native preset semantics."""
attrs = {"data-pptx-prst": prst}
if prst_geom is None:
return attrs
av_lst = prst_geom.find("a:avLst", NS)
if av_lst is None:
return attrs
for guide in av_lst.findall("a:gd", NS):
name = guide.attrib.get("name", "")
if not name:
continue
attrs[f"data-pptx-av-{name}"] = guide.attrib.get("fmla", "")
return attrs
def _preset_adjustments(
prst_geom: ET.Element | None,
) -> dict[str, str]:
"""Return explicit instance adjustment formulas for registry evaluation."""
if prst_geom is None:
return {}
av_lst = prst_geom.find("a:avLst", NS)
if av_lst is None:
return {}
return {
guide.attrib["name"]: guide.attrib.get("fmla", "")
for guide in av_lst.findall("a:gd", NS)
if guide.attrib.get("name")
}
def supported_presets() -> set[str]:
"""Return the set of recognized prst values for diagnostics."""
return set(get_preset_registry().names)
@@ -0,0 +1,536 @@
"""Shape tree walker.
Reads <p:spTree> from a slide / layout / master and emits a normalized
ShapeNode tree that downstream converters can dispatch on.
Handles:
- <p:sp> -> SHAPE
- <p:pic> -> PICTURE
- <p:cxnSp> -> CONNECTOR
- <p:grpSp> -> GROUP (recurses; resolves a:chOff/a:chExt frame)
- <p:graphicFrame> -> GRAPHIC (table / chart / SmartArt — emitted as opaque
placeholder for v1 so callers can decide a fallback)
- <mc:AlternateContent> -> supported Choice shape with the baked Fallback
preview retained for graphic frames
"""
from __future__ import annotations
from dataclasses import dataclass, field
from xml.etree import ElementTree as ET
from .emu_units import NS, Xfrm, ooxml_bool, parse_xfrm
# ---------------------------------------------------------------------------
# ShapeNode
# ---------------------------------------------------------------------------
SHAPE = "sp"
PICTURE = "pic"
CONNECTOR = "cxnSp"
GROUP = "grpSp"
GRAPHIC = "graphicFrame"
_TITLE_PLACEHOLDER_TYPES = {"title", "ctrTitle"}
_BODY_PLACEHOLDER_TYPES = {
"body",
"chart",
"clipArt",
"dgm",
"media",
"obj",
"pic",
"subTitle",
"tbl",
}
_DEFAULT_PLACEHOLDER_TYPE = "obj"
_DEFAULT_PLACEHOLDER_INDEX = "0"
_TX_STYLE_TITLE_KEY = ("__txStyleTitle", None)
_TX_STYLE_BODY_KEY = ("__txStyleBody", None)
_TX_STYLE_OTHER_KEY = ("__txStyleOther", None)
@dataclass
class PlaceholderInfo:
"""Resolved <p:ph> attributes for a shape if any."""
type: str | None = None # title / body / ctrTitle / subTitle / ftr / dt / ...
idx: str | None = None
sz: str | None = None # full / half / quarter
orient: str | None = None
@dataclass
class ShapeNode:
"""Normalized shape entry produced by the walker."""
kind: str # one of SHAPE / PICTURE / CONNECTOR / GROUP / GRAPHIC
xml: ET.Element # original element
xfrm: Xfrm # resolved geometry in absolute slide pixel space
name: str = ""
spid: str = ""
hidden: bool = False
placeholder: PlaceholderInfo | None = None
inherited_lst_styles: tuple[ET.Element, ...] = ()
inherited_body_properties: tuple[ET.Element, ...] = ()
# Local plus ancestor group rotation; used for effect-fidelity decisions
# without applying the group transform twice to the rendered geometry.
effective_rotation: float = 0.0
# GROUP only: children, in z-order
children: list["ShapeNode"] = field(default_factory=list)
# ---------------------------------------------------------------------------
# Walker
# ---------------------------------------------------------------------------
def _read_nv_sp_pr(parent: ET.Element, nv_tag: str) -> tuple[str, str, bool, PlaceholderInfo | None]:
"""Extract name/id/hidden/placeholder from an nvXXXPr container.
nv_tag is one of nvSpPr / nvPicPr / nvCxnSpPr / nvGrpSpPr / nvGraphicFramePr.
"""
container = parent.find(f"p:{nv_tag}", NS)
name = ""
spid = ""
hidden = False
ph: PlaceholderInfo | None = None
if container is None:
return name, spid, hidden, ph
cnv = container.find("p:cNvPr", NS)
if cnv is not None:
name = cnv.attrib.get("name", "")
spid = cnv.attrib.get("id", "")
if ooxml_bool(cnv.attrib.get("hidden")):
hidden = True
nv_pr = container.find("p:nvPr", NS)
if nv_pr is not None:
ph_elem = nv_pr.find("p:ph", NS)
if ph_elem is not None:
ph = PlaceholderInfo(
type=ph_elem.attrib.get("type"),
idx=ph_elem.attrib.get("idx"),
sz=ph_elem.attrib.get("sz"),
orient=ph_elem.attrib.get("orient"),
)
return name, spid, hidden, ph
def _resolve_xfrm(shape: ET.Element, kind: str) -> ET.Element | None:
"""Find the <a:xfrm> element under the right spPr / grpSpPr container."""
if kind == GROUP:
sp_pr = shape.find("p:grpSpPr", NS)
elif kind == GRAPHIC:
# graphicFrame uses p:xfrm directly (no a: namespace)
return shape.find("p:xfrm", NS)
else:
sp_pr = shape.find("p:spPr", NS)
if sp_pr is None:
return None
return sp_pr.find("a:xfrm", NS)
def _adjust_for_group(child_xfrm: Xfrm, group_xfrm: Xfrm) -> Xfrm:
"""Map a child shape's xfrm from group's child coordinate space into the
parent (group) coordinate space.
DrawingML group rule: a child's a:off/a:ext is in the group's chOff/chExt
coordinate system. We map to the group's actual off/ext on the slide.
If the group has no chOff/chExt, fall back to identity translation.
"""
if (group_xfrm.ch_w is None or group_xfrm.ch_h is None
or group_xfrm.ch_w == 0 or group_xfrm.ch_h == 0):
# No child frame — children already in slide space; just translate.
return child_xfrm
# Linear map: child-frame -> group's (off..off+ext)
sx = group_xfrm.w / group_xfrm.ch_w if group_xfrm.ch_w else 1.0
sy = group_xfrm.h / group_xfrm.ch_h if group_xfrm.ch_h else 1.0
ch_x = group_xfrm.ch_x or 0.0
ch_y = group_xfrm.ch_y or 0.0
new_x = group_xfrm.x + (child_xfrm.x - ch_x) * sx
new_y = group_xfrm.y + (child_xfrm.y - ch_y) * sy
new_w = child_xfrm.w * sx
new_h = child_xfrm.h * sy
return Xfrm(
x=new_x, y=new_y, w=new_w, h=new_h,
rot=child_xfrm.rot,
flip_h=child_xfrm.flip_h,
flip_v=child_xfrm.flip_v,
ch_x=child_xfrm.ch_x, ch_y=child_xfrm.ch_y,
ch_w=child_xfrm.ch_w, ch_h=child_xfrm.ch_h,
)
# Mapping from element tag -> kind / nv tag.
_KIND_MAP = {
"sp": (SHAPE, "nvSpPr"),
"pic": (PICTURE, "nvPicPr"),
"cxnSp": (CONNECTOR, "nvCxnSpPr"),
"grpSp": (GROUP, "nvGrpSpPr"),
"graphicFrame": (GRAPHIC, "nvGraphicFramePr"),
}
def _first_shape_child(container: ET.Element | None) -> ET.Element | None:
if container is None:
return None
for child in list(container):
if not isinstance(child.tag, str):
continue
if child.tag.split("}", 1)[-1] in _KIND_MAP:
return child
return None
def _resolve_alternate_content(wrapper: ET.Element) -> ET.Element | None:
"""Select an AlternateContent shape while retaining its baked preview."""
choice = wrapper.find("mc:Choice", NS)
fallback = wrapper.find("mc:Fallback", NS)
selected = _first_shape_child(choice)
selected_from_choice = selected is not None
if selected is None:
selected = _first_shape_child(fallback)
if selected is None:
return None
clone = ET.fromstring(ET.tostring(selected, encoding="utf-8"))
if (
selected_from_choice
and clone.tag.split("}", 1)[-1] == "graphicFrame"
and fallback is not None
):
graphic_data = clone.find("a:graphic/a:graphicData", NS)
if graphic_data is not None:
preview = ET.Element(f"{{{NS['mc']}}}AlternateContent")
preview.append(
ET.fromstring(ET.tostring(fallback, encoding="utf-8"))
)
graphic_data.append(preview)
return clone
def _walk_container(
container: ET.Element,
parent_group_xfrm: Xfrm | None,
ancestor_rotation: float = 0.0,
placeholder_xfrms: dict[tuple[str | None, str | None], Xfrm] | None = None,
placeholder_lst_styles: dict[
tuple[str | None, str | None],
list[ET.Element],
] | None = None,
placeholder_body_properties: dict[
tuple[str | None, str | None],
list[ET.Element],
] | None = None,
) -> list[ShapeNode]:
"""Walk a p:spTree or p:grpSp subtree. Children kept in document (z) order.
"""
nodes: list[ShapeNode] = []
for child in list(container):
if not isinstance(child.tag, str):
continue
local = child.tag.split("}", 1)[-1]
if local == "AlternateContent":
resolved = _resolve_alternate_content(child)
if resolved is None:
continue
child = resolved
local = child.tag.split("}", 1)[-1]
kind_info = _KIND_MAP.get(local)
if kind_info is None:
continue
kind, nv_tag = kind_info
name, spid, hidden, ph = _read_nv_sp_pr(child, nv_tag)
xfrm = parse_xfrm(_resolve_xfrm(child, kind))
effective_rotation = (ancestor_rotation + xfrm.rot) % 360.0
# Placeholders without their own xfrm inherit geometry from a matching
# placeholder in the layout, then the master. This is what PowerPoint
# itself does when rendering the slide. Without this fallback such
# shapes get a 0×0 box and convert_txbody wraps every glyph onto its
# own line — visually a vertical strip of single characters.
if (ph is not None and placeholder_xfrms
and (xfrm.w == 0 and xfrm.h == 0)):
inherited = _lookup_placeholder_xfrm(ph, placeholder_xfrms)
if inherited is not None:
xfrm = Xfrm(
x=inherited.x, y=inherited.y,
w=inherited.w, h=inherited.h,
rot=xfrm.rot, flip_h=xfrm.flip_h, flip_v=xfrm.flip_v,
ch_x=xfrm.ch_x, ch_y=xfrm.ch_y,
ch_w=xfrm.ch_w, ch_h=xfrm.ch_h,
)
# If we're inside a group, remap to slide-absolute coordinates
if parent_group_xfrm is not None:
xfrm = _adjust_for_group(xfrm, parent_group_xfrm)
inherited_lst_styles: tuple[ET.Element, ...] = ()
if ph is not None and placeholder_lst_styles:
inherited_lst_styles = _lookup_placeholder_lst_styles(
ph, placeholder_lst_styles,
)
inherited_body_properties: tuple[ET.Element, ...] = ()
if ph is not None and placeholder_body_properties:
inherited_body_properties = _lookup_placeholder_body_properties(
ph,
placeholder_body_properties,
)
node = ShapeNode(
kind=kind, xml=child, xfrm=xfrm,
name=name, spid=spid, hidden=hidden, placeholder=ph,
inherited_lst_styles=inherited_lst_styles,
inherited_body_properties=inherited_body_properties,
effective_rotation=effective_rotation,
)
if kind == GROUP:
node.children = _walk_container(
child, xfrm, effective_rotation,
placeholder_xfrms=placeholder_xfrms,
placeholder_lst_styles=placeholder_lst_styles,
placeholder_body_properties=placeholder_body_properties,
)
nodes.append(node)
return nodes
def _lookup_placeholder_xfrm(
ph: PlaceholderInfo,
table: dict[tuple[str | None, str | None], Xfrm],
) -> Xfrm | None:
"""Find inherited geometry after applying the OOXML placeholder defaults."""
ph_type, ph_idx = _placeholder_identity(ph.type, ph.idx)
for key in (
(ph_type, ph_idx),
(ph_type, None),
(None, ph_idx),
):
hit = table.get(key)
if hit is not None and (hit.w > 0 or hit.h > 0):
return hit
return None
def _lookup_placeholder_lst_styles(
ph: PlaceholderInfo,
table: dict[tuple[str | None, str | None], list[ET.Element]],
) -> tuple[ET.Element, ...]:
"""Find inherited txBody/lstStyle elements for a placeholder."""
ph_type, ph_idx = _placeholder_identity(ph.type, ph.idx)
styles: list[ET.Element] = []
seen: set[int] = set()
for key in (
(ph_type, ph_idx),
(ph_type, None),
(None, ph_idx),
_placeholder_tx_style_key(ph),
):
for style in table.get(key, []):
marker = id(style)
if marker in seen:
continue
styles.append(style)
seen.add(marker)
return tuple(styles)
def _lookup_placeholder_body_properties(
ph: PlaceholderInfo,
table: dict[tuple[str | None, str | None], list[ET.Element]],
) -> tuple[ET.Element, ...]:
"""Find inherited txBody/bodyPr elements for a placeholder."""
ph_type, ph_idx = _placeholder_identity(ph.type, ph.idx)
exact = table.get((ph_type, ph_idx), [])
if exact:
return tuple(exact)
for key in ((ph_type, None), (None, ph_idx)):
candidates = table.get(key, [])
if candidates:
return (candidates[0],)
return ()
def _placeholder_tx_style_key(
ph: PlaceholderInfo,
) -> tuple[str | None, str | None]:
ph_type, _ph_idx = _placeholder_identity(ph.type, ph.idx)
if ph_type in _TITLE_PLACEHOLDER_TYPES:
return _TX_STYLE_TITLE_KEY
if ph_type in _BODY_PLACEHOLDER_TYPES:
return _TX_STYLE_BODY_KEY
return _TX_STYLE_OTHER_KEY
def _placeholder_identity(
ph_type: str | None,
ph_idx: str | None,
) -> tuple[str, str]:
"""Resolve the schema defaults used for placeholder inheritance keys."""
return (
_DEFAULT_PLACEHOLDER_TYPE if ph_type is None else ph_type,
_DEFAULT_PLACEHOLDER_INDEX if ph_idx is None else ph_idx,
)
def _build_placeholder_xfrm_table(
*parts: ET.Element | None,
) -> dict[tuple[str | None, str | None], Xfrm]:
"""Index placeholders that *do* have explicit geometry, in priority order.
Pass parts most-specific to least-specific (layout first, master second);
the first writer for a given key wins so layout overrides master, which is
what PowerPoint's inheritance chain expects.
"""
table: dict[tuple[str | None, str | None], Xfrm] = {}
for part_xml in parts:
if part_xml is None:
continue
sp_tree = part_xml.find("p:cSld/p:spTree", NS)
if sp_tree is None:
continue
for sp in sp_tree.iter():
if not isinstance(sp.tag, str) or sp.tag.split("}", 1)[-1] != "sp":
continue
ph_elem = sp.find("p:nvSpPr/p:nvPr/p:ph", NS)
if ph_elem is None:
continue
xfrm_elem = sp.find("p:spPr/a:xfrm", NS)
if xfrm_elem is None:
continue
xfrm = parse_xfrm(xfrm_elem)
if xfrm.w <= 0 and xfrm.h <= 0:
continue
ph_type, ph_idx = _placeholder_identity(
ph_elem.attrib.get("type"),
ph_elem.attrib.get("idx"),
)
for key in ((ph_type, ph_idx),
(ph_type, None),
(None, ph_idx)):
table.setdefault(key, xfrm)
return table
def _build_placeholder_lst_style_table(
*parts: ET.Element | None,
) -> dict[tuple[str | None, str | None], list[ET.Element]]:
"""Index placeholder txBody/lstStyle elements in priority order."""
table: dict[tuple[str | None, str | None], list[ET.Element]] = {}
for part_xml in parts:
if part_xml is None:
continue
sp_tree = part_xml.find("p:cSld/p:spTree", NS)
if sp_tree is None:
continue
for sp in sp_tree.iter():
if not isinstance(sp.tag, str) or sp.tag.split("}", 1)[-1] != "sp":
continue
ph_elem = sp.find("p:nvSpPr/p:nvPr/p:ph", NS)
if ph_elem is None:
continue
lst_style = sp.find("p:txBody/a:lstStyle", NS)
if lst_style is None:
continue
ph_type, ph_idx = _placeholder_identity(
ph_elem.attrib.get("type"),
ph_elem.attrib.get("idx"),
)
for key in ((ph_type, ph_idx),
(ph_type, None),
(None, ph_idx)):
table.setdefault(key, []).append(lst_style)
_append_master_tx_styles(table, part_xml)
return table
def _build_placeholder_body_property_table(
*parts: ET.Element | None,
) -> dict[tuple[str | None, str | None], list[ET.Element]]:
"""Index placeholder txBody/bodyPr elements in priority order."""
table: dict[tuple[str | None, str | None], list[ET.Element]] = {}
for part_xml in parts:
if part_xml is None:
continue
sp_tree = part_xml.find("p:cSld/p:spTree", NS)
if sp_tree is None:
continue
for sp in sp_tree.iter():
if not isinstance(sp.tag, str) or sp.tag.split("}", 1)[-1] != "sp":
continue
ph_elem = sp.find("p:nvSpPr/p:nvPr/p:ph", NS)
body_pr = sp.find("p:txBody/a:bodyPr", NS)
if ph_elem is None or body_pr is None:
continue
ph_type, ph_idx = _placeholder_identity(
ph_elem.attrib.get("type"),
ph_elem.attrib.get("idx"),
)
for key in (
(ph_type, ph_idx),
(ph_type, None),
(None, ph_idx),
):
table.setdefault(key, []).append(body_pr)
return table
def _append_master_tx_styles(
table: dict[tuple[str | None, str | None], list[ET.Element]],
part_xml: ET.Element,
) -> None:
for key, path in (
(_TX_STYLE_TITLE_KEY, "p:txStyles/p:titleStyle"),
(_TX_STYLE_BODY_KEY, "p:txStyles/p:bodyStyle"),
(_TX_STYLE_OTHER_KEY, "p:txStyles/p:otherStyle"),
):
style = part_xml.find(path, NS)
if style is not None:
table.setdefault(key, []).append(style)
def walk_sp_tree(
slide_xml: ET.Element,
*,
layout_xml: ET.Element | None = None,
master_xml: ET.Element | None = None,
) -> list[ShapeNode]:
"""Top-level entry: return shape nodes for a slide / layout / master XML.
When ``slide_xml`` is a regular slide, pass its ``layout_xml`` and
``master_xml`` so placeholders can inherit geometry, text list styles, and
body properties from the layout/master. Layout and master walks pass
neither — their own placeholders are the source of truth.
"""
sp_tree = slide_xml.find("p:cSld/p:spTree", NS)
if sp_tree is None:
return []
placeholder_xfrms = _build_placeholder_xfrm_table(layout_xml, master_xml)
placeholder_lst_styles = _build_placeholder_lst_style_table(
layout_xml, master_xml,
)
placeholder_body_properties = _build_placeholder_body_property_table(
layout_xml,
master_xml,
)
return _walk_container(
sp_tree, parent_group_xfrm=None,
placeholder_xfrms=placeholder_xfrms or None,
placeholder_lst_styles=placeholder_lst_styles or None,
placeholder_body_properties=placeholder_body_properties or None,
)
def get_background(slide_xml: ET.Element) -> ET.Element | None:
"""Return the <p:bg> element if the slide defines its own background."""
return slide_xml.find("p:cSld/p:bg", NS)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff