Release standalone PPT template workflow 0.5.0

This commit is contained in:
2026-10-10 21:02:47 +08:00
commit 4e3271ff62
303 changed files with 190946 additions and 0 deletions
@@ -0,0 +1,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