Release standalone PPT template workflow 0.5.0
This commit is contained in:
@@ -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("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
)
|
||||
|
||||
|
||||
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
Reference in New Issue
Block a user