Files

2378 lines
90 KiB
Python

"""Extract native Chart replacement metadata from PPTX chart parts.
The visual chart preview still comes from the existing graphicFrame fallback.
This module only builds a conservative ``data-pptx-replace-with="chart"``
payload when the chart XML cache can be mapped to the current chart schema.
"""
from __future__ import annotations
import math
import re
from dataclasses import dataclass
from typing import Any
from xml.etree import ElementTree as ET
from svg_to_pptx.drawingml.utils import parse_font_family
from svg_to_pptx.native_objects.chart_data import (
validate_chart_payload,
validate_data_label_position,
)
from .chartex_to_svg import UnsupportedChartEx, extract_native_chartex_payload
from .color_resolver import ColorPalette, find_color_elem, resolve_color
from .emu_units import NS, Xfrm, ooxml_bool
from .normalized_chart_svg import SeriesVisualStyle, render_normalized_chart_svg
from .ooxml_loader import OoxmlPackage, PartRef
CHART_URI = "http://schemas.openxmlformats.org/drawingml/2006/chart"
CHARTEX_URI = "http://schemas.microsoft.com/office/drawing/2014/chartex"
C_NS = {
**NS,
"c": "http://schemas.openxmlformats.org/drawingml/2006/chart",
"cx": CHARTEX_URI,
}
@dataclass
class ChartResult:
"""Native chart marker payload or a transparent unsupported status."""
native_payload: dict[str, Any] | None = None
native_status: str | None = None
normalized_svg: str | None = None
class _UnsupportedChart(RuntimeError):
"""Raised when a chart should keep its visual fallback only."""
def __init__(self, status: str) -> None:
super().__init__(status)
self.status = status
def extract_native_chart_payload(
graphic_data: ET.Element | None,
xfrm: Xfrm,
slide_part: PartRef,
pkg: OoxmlPackage,
palette: ColorPalette | None = None,
) -> ChartResult:
"""Return native chart metadata for a supported classic or ChartEx chart."""
if graphic_data is None:
return ChartResult(native_status="unsupported-chart-reference")
uri = graphic_data.attrib.get("uri", "")
if uri == CHARTEX_URI or graphic_data.find("cx:chart", C_NS) is not None:
try:
payload = extract_native_chartex_payload(
graphic_data,
xfrm,
slide_part,
pkg,
palette,
)
except UnsupportedChartEx as exc:
return ChartResult(native_status=exc.status)
except RuntimeError:
return ChartResult(native_status="unsupported-chartex-parse")
return ChartResult(native_payload=payload)
if uri != CHART_URI:
return ChartResult(native_status="unsupported-chart-uri")
if xfrm.rot or xfrm.flip_h or xfrm.flip_v:
return ChartResult(native_status="unsupported-native-transform")
chart_ref = graphic_data.find("c:chart", C_NS)
if chart_ref is None:
return ChartResult(native_status="unsupported-chart-reference")
rid = chart_ref.attrib.get(f"{{{NS['r']}}}id")
if not rid:
return ChartResult(native_status="unsupported-chart-reference")
chart_path = slide_part.resolve_rel(rid)
if not chart_path:
return ChartResult(native_status="unsupported-chart-relationship")
chart_part = pkg.load_part(chart_path)
if chart_part is None:
return ChartResult(native_status="unsupported-chart-part")
try:
payload, visual_styles = _payload_from_chart_xml(
chart_part.xml,
xfrm,
palette=palette,
)
validate_chart_payload(payload)
except _UnsupportedChart as exc:
return ChartResult(native_status=exc.status)
except RuntimeError:
return ChartResult(native_status="unsupported-chart-schema")
except (TypeError, ValueError, AttributeError):
return ChartResult(native_status="unsupported-chart-parse")
# The visual fallback is deliberately best-effort and isolated from the
# active native payload. A renderer defect must never downgrade a chart
# that the native schema has already validated.
try:
normalized_svg = render_normalized_chart_svg(payload, visual_styles)
except (KeyError, TypeError, ValueError, OverflowError, ArithmeticError):
normalized_svg = None
return ChartResult(native_payload=payload, normalized_svg=normalized_svg)
def _payload_from_chart_xml(
chart_root: ET.Element,
xfrm: Xfrm,
*,
palette: ColorPalette | None = None,
) -> tuple[dict[str, Any], list[SeriesVisualStyle]]:
plot_area = chart_root.find(".//c:plotArea", C_NS)
if plot_area is None:
raise _UnsupportedChart("unsupported-chart-plot")
chart_nodes = [
child
for child in list(plot_area)
if _local_name(child.tag).endswith("Chart")
]
if not chart_nodes:
raise _UnsupportedChart("unsupported-chart-plot")
date_system = chart_root.find("c:date1904", C_NS)
if date_system is not None and (
not set(date_system.attrib).issubset({"val"}) or list(date_system)
):
raise _UnsupportedChart("unsupported-date-system")
uses_1904_dates = _strict_axis_bool(
date_system,
date_system is not None,
)
if len(chart_nodes) > 1:
if uses_1904_dates and any(
_category_cache_is_numeric(chart) for chart in chart_nodes
):
raise _UnsupportedChart("unsupported-date-system")
payload, visual_styles = _combo_payload(
chart_root,
plot_area,
chart_nodes,
xfrm,
palette=palette,
)
return payload, visual_styles
chart = chart_nodes[0]
chart_tag = _local_name(chart.tag)
has_date_axis = plot_area.find("c:dateAx", C_NS) is not None
if uses_1904_dates and has_date_axis:
raise _UnsupportedChart("unsupported-date-system")
if has_date_axis:
_validate_canonical_series_order(
[chart],
"unsupported-chart-series-order",
)
if chart_tag in {"area3DChart", "bar3DChart", "line3DChart", "pie3DChart", "surface3DChart"}:
raise _UnsupportedChart("unsupported-3d-chart")
if has_date_axis and chart_tag not in {"areaChart", "stockChart"}:
raise _UnsupportedChart("unsupported-date-axis")
if chart_tag == "barChart":
payload = _category_payload(chart, _bar_chart_type(chart), xfrm)
elif chart_tag in {
"areaChart",
"doughnutChart",
"lineChart",
"ofPieChart",
"pieChart",
"radarChart",
}:
chart_type = {
"areaChart": "area",
"doughnutChart": "doughnut",
"lineChart": "line",
"ofPieChart": "of_pie",
"pieChart": "pie",
"radarChart": "radar",
}[chart_tag]
category_kind = "date" if has_date_axis else "text"
if chart_tag == "radarChart" and _category_cache_is_numeric(chart):
category_kind = "numeric"
payload = _category_payload(
chart,
chart_type,
xfrm,
category_kind=category_kind,
)
if chart_tag == "radarChart":
if category_kind == "numeric":
payload["categories"] = [
str(value) for value in payload["categories"]
]
payload["radar_style"] = _effective_radar_style(chart)
elif chart_tag == "scatterChart":
payload = _xy_payload(chart, "scatter", xfrm)
payload["axes"] = _xy_axis_contract(plot_area, chart)
elif chart_tag == "bubbleChart":
payload = _xy_payload(chart, "bubble", xfrm)
payload["axes"] = _xy_axis_contract(plot_area, chart)
elif chart_tag == "stockChart":
payload, visual_styles = _stock_payload(
chart_root,
plot_area,
chart,
xfrm,
palette=palette,
)
return payload, visual_styles
else:
raise _UnsupportedChart("unsupported-chart-type")
if has_date_axis:
payload["axes"] = _single_axis_contract(
plot_area,
chart,
category_kind="date",
cross_between="midCat",
)
expected_category_format = payload["axes"]["category"].get(
"number_format",
"m/d/yyyy",
)
if _numeric_category_cache_format(chart) != expected_category_format:
raise _UnsupportedChart("unsupported-chart-category-format")
visual_styles = _validate_chart_semantics(
payload,
plot_area,
chart,
palette=palette,
validate_axes=not (
has_date_axis or chart_tag in {"bubbleChart", "scatterChart"}
),
)
_apply_chart_metadata(payload, chart_root, plot_area, chart)
return payload, visual_styles
def _combo_payload(
chart_root: ET.Element,
plot_area: ET.Element,
chart_nodes: list[ET.Element],
xfrm: Xfrm,
*,
palette: ColorPalette | None,
) -> tuple[dict[str, Any], list[SeriesVisualStyle]]:
series_indices_by_plot = [
_plot_series_indices(chart, "unsupported-combo-series-order")
for chart in chart_nodes
]
flat_series_indices = [
index
for series_indices in series_indices_by_plot
for index in series_indices
]
if sorted(flat_series_indices) != list(range(len(flat_series_indices))):
raise _UnsupportedChart("unsupported-combo-series-order")
axes_by_id = _axis_nodes_by_id(plot_area)
if not axes_by_id:
raise _UnsupportedChart("unsupported-combo-chart")
axes: dict[str, dict[str, Any]] = {}
axis_pairs: dict[str, tuple[str, str]] = {}
primary_categories: list[Any] | None = None
primary_categories_are_numeric = False
plots: list[dict[str, Any]] = []
visual_styles: list[SeriesVisualStyle] = []
colors: list[str] = []
referenced_axis_ids: set[str] = set()
for chart, series_indices in zip(chart_nodes, series_indices_by_plot):
chart_tag = _local_name(chart.tag)
if chart_tag == "barChart":
if _bar_chart_type(chart) != "column":
raise _UnsupportedChart("unsupported-combo-chart")
chart_type = "column"
elif chart_tag == "lineChart":
chart_type = "line"
elif chart_tag == "areaChart":
chart_type = "area"
else:
raise _UnsupportedChart("unsupported-combo-chart")
cat_id, cat_axis, val_id, val_axis = _plot_axis_pair(chart, axes_by_id)
if _local_name(cat_axis.tag) != "catAx":
raise _UnsupportedChart("unsupported-combo-chart")
val_position = _element_val(val_axis.find("c:axPos", C_NS))
if val_position == "l":
axis_name = "primary"
category_role = "category"
value_role = "value"
allowed_value_crosses = {"autoZero"}
elif val_position == "r":
axis_name = "secondary"
category_role = "secondary_category"
value_role = "secondary_value"
allowed_value_crosses = {"max"}
else:
raise _UnsupportedChart("unsupported-combo-chart")
axis_pair = (cat_id, val_id)
previous_pair = axis_pairs.get(axis_name)
if previous_pair is not None and previous_pair != axis_pair:
raise _UnsupportedChart("unsupported-combo-chart")
if any(
set(existing_pair).intersection(axis_pair)
for name, existing_pair in axis_pairs.items()
if name != axis_name
):
raise _UnsupportedChart("unsupported-combo-chart")
axis_pairs[axis_name] = axis_pair
category_axis = _axis_config_from_xml(
cat_axis,
role=category_role,
expected_cross_axis_id=val_id,
allowed_crosses={"autoZero"},
expected_cross_between=None,
)
value_axis = _axis_config_from_xml(
val_axis,
role=value_role,
expected_cross_axis_id=cat_id,
allowed_crosses=allowed_value_crosses,
expected_cross_between="between",
)
for role, config in (
(category_role, category_axis),
(value_role, value_axis),
):
if role in axes and axes[role] != config:
raise _UnsupportedChart("unsupported-combo-chart")
axes[role] = config
referenced_axis_ids.update((cat_id, val_id))
categories_are_numeric = _category_cache_is_numeric(chart)
if categories_are_numeric:
expected_category_format = category_axis.get(
"number_format",
"General",
)
if _numeric_category_cache_format(chart) != expected_category_format:
raise _UnsupportedChart("unsupported-combo-category-format")
plot_payload = _category_payload(
chart,
chart_type,
xfrm,
category_kind="numeric" if categories_are_numeric else "text",
)
if axis_name == "primary" and primary_categories is None:
primary_categories = list(plot_payload["categories"])
primary_categories_are_numeric = categories_are_numeric
plot_styles = _validate_chart_semantics(
plot_payload,
plot_area,
chart,
palette=palette,
validate_axes=False,
)
visual_styles.extend(plot_styles)
colors.extend(
str(color)
for color in plot_payload.get("style", {}).get("colors", [])
)
_apply_plot_data_labels(plot_payload, chart)
plot_entry: dict[str, Any] = {
"axis": axis_name,
"categories": list(plot_payload["categories"]),
"category_numeric": categories_are_numeric,
"series": plot_payload["series"],
"series_indices": series_indices,
"type": chart_type,
}
for key in ("data_labels", "grouping", "line_style"):
if plot_payload.get(key) is not None:
plot_entry[key] = plot_payload[key]
plots.append(plot_entry)
if primary_categories is None or not plots:
raise _UnsupportedChart("unsupported-combo-chart")
if referenced_axis_ids != set(axes_by_id):
raise _UnsupportedChart("unsupported-combo-chart")
category_layouts = {
(
bool(plot["category_numeric"]),
tuple(plot["categories"]),
)
for plot in plots
}
if len(category_layouts) == 1:
for plot in plots:
plot.pop("categories")
plot.pop("category_numeric")
payload: dict[str, Any] = {
**_bounds_payload(xfrm),
"axes": axes,
"categories": primary_categories,
"plots": plots,
"type": "combo",
}
if primary_categories_are_numeric:
payload["category_numeric"] = True
if colors:
payload["style"] = {"colors": colors}
_apply_chart_metadata(
payload,
chart_root,
plot_area,
chart_nodes[0],
include_plot_labels=False,
)
return payload, visual_styles
def _validate_stock_semantics(
plot_area: ET.Element,
chart: ET.Element,
) -> None:
allowed_children = {"axId", "dLbls", "hiLowLines", "ser", "upDownBars"}
if any(_local_name(child.tag) not in allowed_children for child in chart):
raise _UnsupportedChart("unsupported-stock-chart")
if plot_area.find("c:dTable", C_NS) is not None:
raise _UnsupportedChart("unsupported-chart-data-table")
for tag in ("dropLines", "errBars", "trendline"):
if chart.find(f".//c:{tag}", C_NS) is not None:
raise _UnsupportedChart("unsupported-chart-analysis-features")
if _data_labels_payload(chart.find("c:dLbls", C_NS)) is not None:
raise _UnsupportedChart("unsupported-chart-data-labels")
if len(chart.findall("c:dLbls", C_NS)) > 1:
raise _UnsupportedChart("unsupported-stock-chart")
hi_low_lines = chart.findall("c:hiLowLines", C_NS)
up_down_bars = chart.findall("c:upDownBars", C_NS)
if len(hi_low_lines) != 1 or len(up_down_bars) != 1:
raise _UnsupportedChart("unsupported-stock-chart")
hi_low_styles = hi_low_lines[0].findall("c:spPr", C_NS)
if (
hi_low_lines[0].attrib
or len(hi_low_styles) > 1
or any(_local_name(child.tag) != "spPr" for child in hi_low_lines[0])
):
raise _UnsupportedChart("unsupported-stock-chart")
up_down = up_down_bars[0]
if up_down.attrib or any(
_local_name(child.tag) not in {"downBars", "gapWidth", "upBars"}
for child in up_down
):
raise _UnsupportedChart("unsupported-stock-chart")
gap_widths = up_down.findall("c:gapWidth", C_NS)
if (
len(gap_widths) != 1
or set(gap_widths[0].attrib) != {"val"}
or list(gap_widths[0])
or _element_val(gap_widths[0]) != "150"
):
raise _UnsupportedChart("unsupported-stock-chart")
for tag in ("upBars", "downBars"):
nodes = up_down.findall(f"c:{tag}", C_NS)
styles = nodes[0].findall("c:spPr", C_NS) if nodes else []
if (
len(nodes) != 1
or nodes[0].attrib
or len(styles) > 1
or any(_local_name(child.tag) != "spPr" for child in nodes[0])
):
raise _UnsupportedChart("unsupported-stock-chart")
allowed_series_children = {
"cat", "extLst", "idx", "marker", "order", "smooth", "spPr",
"tx", "val",
}
for series_node in chart.findall("c:ser", C_NS):
if any(
_local_name(child.tag) not in allowed_series_children
for child in series_node
):
raise _UnsupportedChart("unsupported-stock-chart")
for child_name in allowed_series_children - {"extLst"}:
if len(series_node.findall(f"c:{child_name}", C_NS)) > 1:
raise _UnsupportedChart("unsupported-stock-chart")
marker = series_node.find("c:marker", C_NS)
symbol = marker.find("c:symbol", C_NS) if marker is not None else None
if (
marker is not None
and (
marker.attrib
or len(marker) != 1
or symbol is None
or set(symbol.attrib) != {"val"}
or list(symbol)
or _element_val(symbol) != "none"
)
):
raise _UnsupportedChart("unsupported-stock-chart")
smooth = series_node.find("c:smooth", C_NS)
if smooth is not None:
if set(smooth.attrib) != {"val"} or list(smooth):
raise _UnsupportedChart("unsupported-stock-chart")
if _strict_axis_bool(smooth, False):
raise _UnsupportedChart("unsupported-stock-chart")
for child_name in ("idx", "order"):
child = series_node.find(f"c:{child_name}", C_NS)
if child is None or set(child.attrib) != {"val"} or list(child):
raise _UnsupportedChart("unsupported-stock-chart")
def _stock_payload(
chart_root: ET.Element,
plot_area: ET.Element,
chart: ET.Element,
xfrm: Xfrm,
*,
palette: ColorPalette | None,
) -> tuple[dict[str, Any], list[SeriesVisualStyle]]:
series_nodes = chart.findall("c:ser", C_NS)
if len(series_nodes) != 4:
raise _UnsupportedChart("unsupported-stock-chart")
categories = _numeric_values(series_nodes[0].find("c:cat", C_NS))
if not categories:
raise _UnsupportedChart("unsupported-chart-cache")
series: list[dict[str, Any]] = []
for index, series_node in enumerate(series_nodes, start=1):
expected_index = str(index - 1)
if (
_element_val(series_node.find("c:idx", C_NS)) != expected_index
or _element_val(series_node.find("c:order", C_NS)) != expected_index
):
raise _UnsupportedChart("unsupported-stock-chart")
if _numeric_values(series_node.find("c:cat", C_NS)) != categories:
raise _UnsupportedChart("unsupported-chart-cache")
values = _numeric_values(series_node.find("c:val", C_NS))
if len(values) != len(categories):
raise _UnsupportedChart("unsupported-chart-cache")
series.append({
"name": _series_name(series_node, index),
"values": values,
})
axes = _single_axis_contract(
plot_area,
chart,
category_kind="date",
cross_between="between",
)
expected_category_format = axes["category"].get(
"number_format",
"m/d/yyyy",
)
category_cache_format = _numeric_category_cache_format(chart)
if category_cache_format not in {"General", expected_category_format}:
raise _UnsupportedChart("unsupported-chart-category-format")
_validate_stock_semantics(plot_area, chart)
payload: dict[str, Any] = {
**_bounds_payload(xfrm),
"axes": axes,
"categories": categories,
"series": series,
"type": "stock",
}
visual_styles = _chart_visual_styles(payload, chart, palette)
_apply_chart_metadata(payload, chart_root, plot_area, chart)
return payload, visual_styles
def _canonical_srgb_color(fill: ET.Element | None) -> str:
"""Return an exporter-canonical solid RGB fill, or reject the style."""
if fill is None or fill.attrib:
raise _UnsupportedChart("unsupported-chart-series-style")
children = list(fill)
if (
len(children) != 1
or _local_name(children[0].tag) != "srgbClr"
or set(children[0].attrib) != {"val"}
or list(children[0])
):
raise _UnsupportedChart("unsupported-chart-series-style")
color = children[0].attrib["val"].strip()
if len(color) != 6 or any(char not in "0123456789abcdefABCDEF" for char in color):
raise _UnsupportedChart("unsupported-chart-series-style")
return color.upper()
@dataclass(frozen=True)
class _LinePaint:
color: str | None
opacity: float
width: float
cap: str
visible: bool
automatic: bool
@dataclass(frozen=True)
class _ShapePaint:
fill: str | None
fill_opacity: float
fill_explicit: bool
line: _LinePaint | None
@dataclass(frozen=True)
class _MarkerPaint:
symbol: str | None
size: float
shape: _ShapePaint
_FALLBACK_CHART_COLORS = (
"#4472C4", "#ED7D31", "#A5A5A5", "#FFC000",
"#5B9BD5", "#70AD47", "#264478", "#9E480E",
)
_COLOR_MODIFIERS_WITH_VALUE = {
"alpha", "alphaMod", "alphaOff", "hueMod", "hueOff", "lumMod",
"lumOff", "satMod", "satOff", "shade", "tint",
}
_COLOR_MODIFIERS_WITHOUT_VALUE = {"comp", "gray", "inv"}
def _resolved_solid_fill(
fill: ET.Element,
palette: ColorPalette | None,
) -> tuple[str, float]:
if fill.attrib or len(fill) != 1:
raise _UnsupportedChart("unsupported-chart-series-style")
color = find_color_elem(fill)
if color is None or color is not fill[0]:
raise _UnsupportedChart("unsupported-chart-series-style")
tag = _local_name(color.tag)
allowed_attrs = {
"srgbClr": {"val"},
"schemeClr": {"val"},
"sysClr": {"val", "lastClr"},
"prstClr": {"val"},
"hslClr": {"hue", "sat", "lum"},
"scrgbClr": {"r", "g", "b"},
}.get(tag)
if allowed_attrs is None or not set(color.attrib).issubset(allowed_attrs):
raise _UnsupportedChart("unsupported-chart-series-style")
required = {
"srgbClr": {"val"}, "schemeClr": {"val"}, "prstClr": {"val"},
"hslClr": {"hue", "sat", "lum"}, "scrgbClr": {"r", "g", "b"},
}.get(tag, set())
if not required.issubset(color.attrib):
raise _UnsupportedChart("unsupported-chart-series-style")
for modifier in color:
modifier_name = _local_name(modifier.tag)
if modifier_name in _COLOR_MODIFIERS_WITH_VALUE:
if set(modifier.attrib) != {"val"} or list(modifier):
raise _UnsupportedChart("unsupported-chart-series-style")
try:
modifier_value = float(modifier.attrib["val"])
except (TypeError, ValueError, OverflowError):
raise _UnsupportedChart("unsupported-chart-series-style") from None
if not math.isfinite(modifier_value):
raise _UnsupportedChart("unsupported-chart-series-style")
elif modifier_name in _COLOR_MODIFIERS_WITHOUT_VALUE:
if modifier.attrib or list(modifier):
raise _UnsupportedChart("unsupported-chart-series-style")
else:
raise _UnsupportedChart("unsupported-chart-series-style")
try:
resolved, alpha = resolve_color(color, palette)
except (TypeError, ValueError, OverflowError):
raise _UnsupportedChart("unsupported-chart-series-style") from None
if resolved is None or not math.isfinite(alpha):
raise _UnsupportedChart("unsupported-chart-series-style")
return resolved.upper(), max(0.0, min(1.0, alpha))
def _resolved_line(
line: ET.Element,
palette: ColorPalette | None,
) -> _LinePaint:
if not set(line.attrib).issubset({"w", "cap"}):
raise _UnsupportedChart("unsupported-chart-series-style")
raw_width = line.attrib.get("w")
if raw_width is None:
width = 1.5
else:
if re.fullmatch(r"[0-9]+", raw_width) is None:
raise _UnsupportedChart("unsupported-chart-series-style")
width = int(raw_width) / 9525.0
if not 0 <= width <= 1000:
raise _UnsupportedChart("unsupported-chart-series-style")
cap_aliases = {None: "round", "rnd": "round", "sq": "square", "flat": "butt"}
cap = cap_aliases.get(line.attrib.get("cap"))
if cap is None:
raise _UnsupportedChart("unsupported-chart-series-style")
fill_nodes: list[ET.Element] = []
for child in line:
name = _local_name(child.tag)
if name in {"solidFill", "noFill"}:
fill_nodes.append(child)
elif name == "prstDash":
if child.attrib != {"val": "solid"} or list(child):
raise _UnsupportedChart("unsupported-chart-series-style")
elif name in {"round", "bevel"}:
if child.attrib or list(child):
raise _UnsupportedChart("unsupported-chart-series-style")
elif name == "miter":
if not set(child.attrib).issubset({"lim"}) or list(child):
raise _UnsupportedChart("unsupported-chart-series-style")
raw_limit = child.attrib.get("lim")
if raw_limit is not None and re.fullmatch(r"[0-9]+", raw_limit) is None:
raise _UnsupportedChart("unsupported-chart-series-style")
else:
raise _UnsupportedChart("unsupported-chart-series-style")
if len(fill_nodes) > 1:
raise _UnsupportedChart("unsupported-chart-series-style")
if not fill_nodes:
return _LinePaint(None, 1.0, width, cap, True, True)
fill = fill_nodes[0]
if _local_name(fill.tag) == "noFill":
if fill.attrib or list(fill):
raise _UnsupportedChart("unsupported-chart-series-style")
return _LinePaint(None, 1.0, width, cap, False, False)
color, opacity = _resolved_solid_fill(fill, palette)
return _LinePaint(color, opacity, width, cap, True, False)
def _resolved_shape_paint(
sp_pr: ET.Element | None,
palette: ColorPalette | None,
) -> _ShapePaint:
if sp_pr is None:
return _ShapePaint(None, 1.0, False, None)
if sp_pr.attrib:
raise _UnsupportedChart("unsupported-chart-series-style")
fill_nodes: list[ET.Element] = []
line_nodes: list[ET.Element] = []
for child in sp_pr:
name = _local_name(child.tag)
if name in {"solidFill", "noFill"}:
fill_nodes.append(child)
elif name == "ln":
line_nodes.append(child)
elif name == "effectLst":
if child.attrib or list(child):
raise _UnsupportedChart("unsupported-chart-series-style")
else:
raise _UnsupportedChart("unsupported-chart-series-style")
if len(fill_nodes) > 1 or len(line_nodes) > 1:
raise _UnsupportedChart("unsupported-chart-series-style")
fill_color: str | None = None
fill_opacity = 1.0
fill_explicit = bool(fill_nodes)
if fill_nodes:
fill = fill_nodes[0]
if _local_name(fill.tag) == "noFill":
if fill.attrib or list(fill):
raise _UnsupportedChart("unsupported-chart-series-style")
else:
fill_color, fill_opacity = _resolved_solid_fill(fill, palette)
line = _resolved_line(line_nodes[0], palette) if line_nodes else None
return _ShapePaint(fill_color, fill_opacity, fill_explicit, line)
def _resolved_marker(
marker: ET.Element | None,
palette: ColorPalette | None,
) -> _MarkerPaint:
if marker is None:
return _MarkerPaint(None, 5.0, _resolved_shape_paint(None, palette))
if marker.attrib:
raise _UnsupportedChart("unsupported-chart-series-style")
allowed = {"symbol", "size", "spPr"}
names = [_local_name(child.tag) for child in marker]
if any(name not in allowed for name in names) or len(names) != len(set(names)):
raise _UnsupportedChart("unsupported-chart-series-style")
symbol_node = marker.find("c:symbol", C_NS)
symbol = _element_val(symbol_node)
if symbol_node is not None and (
set(symbol_node.attrib) != {"val"} or list(symbol_node)
):
raise _UnsupportedChart("unsupported-chart-series-style")
if symbol not in {None, "circle", "none"}:
raise _UnsupportedChart("unsupported-chart-series-style")
size_node = marker.find("c:size", C_NS)
size = 5.0
if size_node is not None:
raw_size = size_node.attrib.get("val", "")
if set(size_node.attrib) != {"val"} or list(size_node) or re.fullmatch(r"[0-9]+", raw_size) is None:
raise _UnsupportedChart("unsupported-chart-series-style")
size = float(raw_size)
if not 2 <= size <= 72:
raise _UnsupportedChart("unsupported-chart-series-style")
return _MarkerPaint(
symbol,
size,
_resolved_shape_paint(marker.find("c:spPr", C_NS), palette),
)
def _automatic_color(palette: ColorPalette | None, index: int) -> str:
if palette is not None:
resolved = palette.resolve_scheme(f"accent{index % 6 + 1}")
if resolved:
return f"#{resolved.upper()}"
return _FALLBACK_CHART_COLORS[index % len(_FALLBACK_CHART_COLORS)]
def _line_color(line: _LinePaint | None, default: str | None) -> str | None:
if line is None:
return default
if not line.visible:
return None
return default if line.automatic else line.color
def _series_visual_style(
shape: _ShapePaint,
marker: _MarkerPaint,
*,
chart_type: str,
auto_color: str,
) -> SeriesVisualStyle:
fill_default = auto_color if chart_type in {"area", "bar", "bubble", "column"} else None
fill = shape.fill if shape.fill_explicit else fill_default
if chart_type in {"line", "scatter"}:
line_default = auto_color
elif chart_type == "area":
line_default = auto_color
else:
line_default = None
stroke = _line_color(shape.line, line_default)
marker_fill = marker.shape.fill if marker.shape.fill_explicit else auto_color
marker_stroke = _line_color(marker.shape.line, auto_color)
line = shape.line
marker_line = marker.shape.line
return SeriesVisualStyle(
fill=fill,
fill_opacity=shape.fill_opacity,
stroke=stroke,
stroke_opacity=line.opacity if line is not None else 1.0,
stroke_width=line.width if line is not None else 1.5,
line_cap=line.cap if line is not None else "round",
marker_fill=marker_fill,
marker_fill_opacity=marker.shape.fill_opacity,
marker_stroke=marker_stroke,
marker_stroke_opacity=marker_line.opacity if marker_line is not None else 1.0,
marker_stroke_width=marker_line.width if marker_line is not None else 1.0,
marker_size=marker.size,
)
def _pie_visual_styles(
payload: dict[str, Any],
series: ET.Element,
palette: ColorPalette | None,
) -> list[SeriesVisualStyle]:
chart_type = payload["type"]
expected_count = len(payload["categories"]) + (1 if chart_type == "of_pie" else 0)
base_shape = _resolved_shape_paint(series.find("c:spPr", C_NS), palette)
point_nodes = series.findall("c:dPt", C_NS)
points: dict[int, _ShapePaint] = {}
for point in point_nodes:
if point.attrib:
raise _UnsupportedChart("unsupported-chart-series-style")
allowed = {"idx", "bubble3D", "explosion", "spPr"}
names = [_local_name(child.tag) for child in point]
if any(name not in allowed for name in names) or len(names) != len(set(names)):
raise _UnsupportedChart("unsupported-chart-series-style")
idx = point.find("c:idx", C_NS)
if idx is None or set(idx.attrib) != {"val"} or list(idx):
raise _UnsupportedChart("unsupported-chart-series-style")
try:
point_index = int(idx.attrib["val"])
except ValueError:
raise _UnsupportedChart("unsupported-chart-series-style") from None
bubble_3d = point.find("c:bubble3D", C_NS)
if bubble_3d is not None and (
not set(bubble_3d.attrib).issubset({"val"})
or list(bubble_3d)
or ooxml_bool(bubble_3d.attrib.get("val"), True)
):
raise _UnsupportedChart("unsupported-chart-series-style")
if point_index < 0 or point_index >= expected_count or point_index in points:
raise _UnsupportedChart("unsupported-chart-series-style")
points[point_index] = _resolved_shape_paint(point.find("c:spPr", C_NS), palette)
if points and set(points) != set(range(expected_count)):
raise _UnsupportedChart("unsupported-chart-series-style")
styles: list[SeriesVisualStyle] = []
for index in range(expected_count):
auto = _automatic_color(palette, index)
point_shape = points.get(index, base_shape)
fill = point_shape.fill if point_shape.fill_explicit else auto
stroke = _line_color(point_shape.line, "#FFFFFF")
line = point_shape.line
styles.append(
SeriesVisualStyle(
fill=fill,
fill_opacity=point_shape.fill_opacity,
stroke=stroke,
stroke_opacity=line.opacity if line is not None else 1.0,
stroke_width=line.width if line is not None else 1.0,
line_cap=line.cap if line is not None else "round",
marker_fill=fill,
marker_stroke=stroke,
)
)
return styles
def _chart_visual_styles(
payload: dict[str, Any],
plot: ET.Element,
palette: ColorPalette | None,
) -> list[SeriesVisualStyle]:
chart_type = payload["type"]
series_nodes = plot.findall("c:ser", C_NS)
if chart_type in {"pie", "doughnut", "of_pie"}:
if len(series_nodes) != 1:
raise _UnsupportedChart("unsupported-chart-series-style")
styles = _pie_visual_styles(payload, series_nodes[0], palette)
else:
if any(series.find("c:dPt", C_NS) is not None for series in series_nodes):
raise _UnsupportedChart("unsupported-chart-series-style")
styles = []
for index, series in enumerate(series_nodes):
auto = _automatic_color(palette, index)
shape = _resolved_shape_paint(series.find("c:spPr", C_NS), palette)
marker = _resolved_marker(series.find("c:marker", C_NS), palette)
styles.append(
_series_visual_style(
shape,
marker,
chart_type=chart_type,
auto_color=auto,
)
)
colors = [
style.fill or style.stroke or style.marker_fill or _automatic_color(palette, index)
for index, style in enumerate(styles)
]
if colors:
payload["style"] = {"colors": colors}
return styles
def _strict_axis_bool(elem: ET.Element | None, default: bool) -> bool:
if elem is None:
return default
raw = elem.attrib.get("val")
if raw is None:
return default
key = raw.strip().lower()
if key in {"1", "on", "true"}:
return True
if key in {"0", "false", "off"}:
return False
raise _UnsupportedChart("unsupported-chart-axis-options")
def _validate_canonical_series_order(
plots: list[ET.Element],
status: str,
) -> None:
expected_index = 0
for plot in plots:
for series in plot.findall("c:ser", C_NS):
expected = str(expected_index)
for child_name in ("idx", "order"):
child = series.find(f"c:{child_name}", C_NS)
if (
child is None
or set(child.attrib) != {"val"}
or list(child)
or _element_val(child) != expected
):
raise _UnsupportedChart(status)
expected_index += 1
def _plot_series_indices(plot: ET.Element, status: str) -> list[int]:
indices: list[int] = []
for series in plot.findall("c:ser", C_NS):
values: list[int] = []
for child_name in ("idx", "order"):
child = series.find(f"c:{child_name}", C_NS)
raw_value = _element_val(child)
if (
child is None
or set(child.attrib) != {"val"}
or list(child)
or raw_value is None
or re.fullmatch(r"[0-9]+", raw_value) is None
):
raise _UnsupportedChart(status)
values.append(int(raw_value))
if values[0] != values[1]:
raise _UnsupportedChart(status)
indices.append(values[0])
if not indices or len(set(indices)) != len(indices):
raise _UnsupportedChart(status)
return indices
def _axis_number(elem: ET.Element | None) -> int | float | None:
if elem is None:
return None
raw = elem.attrib.get("val")
try:
number = float(raw) if raw is not None else math.nan
except (TypeError, ValueError, OverflowError):
raise _UnsupportedChart("unsupported-chart-axis-options") from None
if not math.isfinite(number):
raise _UnsupportedChart("unsupported-chart-axis-options")
return int(number) if number.is_integer() else number
def _axis_nodes_by_id(plot_area: ET.Element) -> dict[str, ET.Element]:
if plot_area.find("c:serAx", C_NS) is not None:
raise _UnsupportedChart("unsupported-chart-axis-options")
axes: dict[str, ET.Element] = {}
for tag in ("catAx", "dateAx", "valAx"):
for axis in plot_area.findall(f"c:{tag}", C_NS):
axis_id = _element_val(axis.find("c:axId", C_NS))
if not axis_id or axis_id in axes:
raise _UnsupportedChart("unsupported-chart-axis-options")
axes[axis_id] = axis
return axes
def _plot_axis_pair(
plot: ET.Element,
axes_by_id: dict[str, ET.Element],
) -> tuple[str, ET.Element, str, ET.Element]:
axis_ids = [_element_val(elem) for elem in plot.findall("c:axId", C_NS)]
if len(axis_ids) != 2 or any(not axis_id for axis_id in axis_ids):
raise _UnsupportedChart("unsupported-chart-axis-options")
if len(set(axis_ids)) != 2:
raise _UnsupportedChart("unsupported-chart-axis-options")
resolved = [(axis_id, axes_by_id.get(str(axis_id))) for axis_id in axis_ids]
if any(axis is None for _, axis in resolved):
raise _UnsupportedChart("unsupported-chart-axis-options")
category_axes = [
(str(axis_id), axis)
for axis_id, axis in resolved
if axis is not None and _local_name(axis.tag) in {"catAx", "dateAx"}
]
value_axes = [
(str(axis_id), axis)
for axis_id, axis in resolved
if axis is not None and _local_name(axis.tag) == "valAx"
]
if len(category_axes) != 1 or len(value_axes) != 1:
raise _UnsupportedChart("unsupported-chart-axis-options")
cat_id, cat_axis = category_axes[0]
val_id, val_axis = value_axes[0]
return cat_id, cat_axis, val_id, val_axis
def _axis_config_from_xml(
axis: ET.Element,
*,
role: str,
expected_cross_axis_id: str,
allowed_crosses: set[str],
expected_cross_between: str | None,
) -> dict[str, Any]:
axis_kind = _local_name(axis.tag)
allowed_children = {
"auto", "axId", "axPos", "baseTimeUnit", "crossAx", "crossBetween",
"crosses", "delete", "lblAlgn", "lblOffset", "majorGridlines",
"majorTickMark", "majorUnit", "minorTickMark", "noMultiLvlLbl",
"numFmt", "scaling", "spPr", "tickLblPos", "title", "txPr",
}
if any(_local_name(child.tag) not in allowed_children for child in axis):
raise _UnsupportedChart("unsupported-chart-axis-options")
if axis.attrib:
raise _UnsupportedChart("unsupported-chart-axis-options")
for child_name in allowed_children:
if len(axis.findall(f"c:{child_name}", C_NS)) > 1:
raise _UnsupportedChart("unsupported-chart-axis-options")
simple_children = {
"auto", "axId", "axPos", "baseTimeUnit", "crossAx",
"crossBetween", "crosses", "delete", "lblAlgn", "lblOffset",
"majorTickMark", "majorUnit", "minorTickMark", "noMultiLvlLbl",
"tickLblPos",
}
if any(
_local_name(child.tag) in simple_children
and (set(child.attrib) != {"val"} or list(child))
for child in axis
):
raise _UnsupportedChart("unsupported-chart-axis-options")
if axis.find("c:minorGridlines", C_NS) is not None:
raise _UnsupportedChart("unsupported-chart-axis-options")
scaling = axis.find("c:scaling", C_NS)
config: dict[str, Any] = {}
if scaling is not None:
if scaling.attrib or any(
_local_name(child.tag) not in {"max", "min", "orientation"}
for child in scaling
):
raise _UnsupportedChart("unsupported-chart-axis-options")
for child_name in ("orientation", "max", "min"):
if len(scaling.findall(f"c:{child_name}", C_NS)) > 1:
raise _UnsupportedChart("unsupported-chart-axis-options")
if any(
set(child.attrib) != {"val"} or list(child)
for child in scaling
):
raise _UnsupportedChart("unsupported-chart-axis-options")
orientation = _element_val(scaling.find("c:orientation", C_NS)) or "minMax"
if orientation not in {"maxMin", "minMax"}:
raise _UnsupportedChart("unsupported-chart-axis-options")
config["reverse"] = orientation == "maxMin"
minimum = _axis_number(scaling.find("c:min", C_NS))
maximum = _axis_number(scaling.find("c:max", C_NS))
if minimum is not None:
config["minimum"] = minimum
if maximum is not None:
config["maximum"] = maximum
if minimum is not None and maximum is not None and minimum >= maximum:
raise _UnsupportedChart("unsupported-chart-axis-options")
else:
config["reverse"] = False
position_aliases = {"b": "bottom", "l": "left", "r": "right", "t": "top"}
position = position_aliases.get(_element_val(axis.find("c:axPos", C_NS)) or "")
if position is None:
raise _UnsupportedChart("unsupported-chart-axis-options")
if role in {"category", "secondary_category"}:
if position not in {"bottom", "top"}:
raise _UnsupportedChart("unsupported-chart-axis-options")
config["kind"] = "date" if axis_kind == "dateAx" else "text"
elif role == "x":
if position not in {"bottom", "top"} or axis_kind != "valAx":
raise _UnsupportedChart("unsupported-chart-axis-options")
config["kind"] = "value"
else:
if position not in {"left", "right"} or axis_kind != "valAx":
raise _UnsupportedChart("unsupported-chart-axis-options")
config["kind"] = "value"
config["position"] = position
config["visible"] = not _strict_axis_bool(axis.find("c:delete", C_NS), False)
tick_label_aliases = {
"high": "high",
"low": "low",
"nextTo": "next_to",
"none": "none",
}
tick_label_position = _element_val(axis.find("c:tickLblPos", C_NS)) or "nextTo"
if tick_label_position not in tick_label_aliases:
raise _UnsupportedChart("unsupported-chart-axis-options")
config["label_position"] = tick_label_aliases[tick_label_position]
config["major_gridlines"] = axis.find("c:majorGridlines", C_NS) is not None
num_fmt = axis.find("c:numFmt", C_NS)
if num_fmt is not None:
if list(num_fmt) or not set(num_fmt.attrib).issubset({"formatCode", "sourceLinked"}):
raise _UnsupportedChart("unsupported-chart-axis-options")
number_format = num_fmt.attrib.get("formatCode", "")
if not number_format.strip():
raise _UnsupportedChart("unsupported-chart-axis-number-format")
config["number_format"] = number_format
major_unit = _axis_number(axis.find("c:majorUnit", C_NS))
if major_unit is not None:
if role not in {"value", "secondary_value", "x", "y"} or major_unit <= 0:
raise _UnsupportedChart("unsupported-chart-axis-options")
config["major_unit"] = major_unit
if _element_val(axis.find("c:crossAx", C_NS)) != expected_cross_axis_id:
raise _UnsupportedChart("unsupported-chart-axis-options")
crosses = _element_val(axis.find("c:crosses", C_NS)) or "autoZero"
if crosses not in allowed_crosses:
raise _UnsupportedChart("unsupported-chart-axis-options")
cross_between = _element_val(axis.find("c:crossBetween", C_NS))
if cross_between != expected_cross_between:
raise _UnsupportedChart("unsupported-chart-axis-options")
auto = axis.find("c:auto", C_NS)
if auto is not None:
_strict_axis_bool(auto, True)
major_tick = _element_val(axis.find("c:majorTickMark", C_NS))
minor_tick = _element_val(axis.find("c:minorTickMark", C_NS))
if major_tick not in {None, "none", "out"} or minor_tick not in {None, "none"}:
raise _UnsupportedChart("unsupported-chart-axis-options")
label_alignment = _element_val(axis.find("c:lblAlgn", C_NS))
label_offset = _element_val(axis.find("c:lblOffset", C_NS))
no_multi_level = _element_val(axis.find("c:noMultiLvlLbl", C_NS))
if role in {"category", "secondary_category"}:
if label_alignment not in {None, "ctr"}:
raise _UnsupportedChart("unsupported-chart-axis-options")
if label_offset not in {None, "100"}:
raise _UnsupportedChart("unsupported-chart-axis-options")
if no_multi_level not in {None, "0"}:
raise _UnsupportedChart("unsupported-chart-axis-options")
elif any(
value is not None
for value in (label_alignment, label_offset, no_multi_level, auto)
):
raise _UnsupportedChart("unsupported-chart-axis-options")
if axis_kind == "dateAx":
if _element_val(axis.find("c:baseTimeUnit", C_NS)) not in {None, "days"}:
raise _UnsupportedChart("unsupported-chart-axis-options")
elif axis.find("c:baseTimeUnit", C_NS) is not None:
raise _UnsupportedChart("unsupported-chart-axis-options")
return config
def _single_axis_contract(
plot_area: ET.Element,
plot: ET.Element,
*,
category_kind: str,
cross_between: str,
) -> dict[str, dict[str, Any]]:
axes_by_id = _axis_nodes_by_id(plot_area)
cat_id, cat_axis, val_id, val_axis = _plot_axis_pair(plot, axes_by_id)
if set(axes_by_id) != {cat_id, val_id}:
raise _UnsupportedChart("unsupported-chart-axis-options")
if _local_name(cat_axis.tag) != ("dateAx" if category_kind == "date" else "catAx"):
raise _UnsupportedChart("unsupported-chart-axis-options")
category = _axis_config_from_xml(
cat_axis,
role="category",
expected_cross_axis_id=val_id,
allowed_crosses={"autoZero"},
expected_cross_between=None,
)
value = _axis_config_from_xml(
val_axis,
role="value",
expected_cross_axis_id=cat_id,
allowed_crosses={"autoZero"},
expected_cross_between=cross_between,
)
return {"category": category, "value": value}
def _xy_axis_contract(
plot_area: ET.Element,
plot: ET.Element,
) -> dict[str, dict[str, Any]]:
"""Return the closed two-value-axis contract used by XY charts."""
axes_by_id = _axis_nodes_by_id(plot_area)
plot_axis_nodes = plot.findall("c:axId", C_NS)
if len(plot_axis_nodes) != 2 or any(
set(node.attrib) != {"val"} or list(node)
for node in plot_axis_nodes
):
raise _UnsupportedChart("unsupported-chart-axis-options")
axis_ids = [_element_val(node) for node in plot_axis_nodes]
if any(not axis_id for axis_id in axis_ids) or len(set(axis_ids)) != 2:
raise _UnsupportedChart("unsupported-chart-axis-options")
if set(axes_by_id) != set(axis_ids):
raise _UnsupportedChart("unsupported-chart-axis-options")
resolved = [
(str(axis_id), axes_by_id.get(str(axis_id)))
for axis_id in axis_ids
]
if any(
axis is None or _local_name(axis.tag) != "valAx"
for _, axis in resolved
):
raise _UnsupportedChart("unsupported-chart-axis-options")
horizontal = [
(axis_id, axis)
for axis_id, axis in resolved
if axis is not None
and _element_val(axis.find("c:axPos", C_NS)) in {"b", "t"}
]
vertical = [
(axis_id, axis)
for axis_id, axis in resolved
if axis is not None
and _element_val(axis.find("c:axPos", C_NS)) in {"l", "r"}
]
if len(horizontal) != 1 or len(vertical) != 1:
raise _UnsupportedChart("unsupported-chart-axis-options")
x_id, x_axis = horizontal[0]
y_id, y_axis = vertical[0]
x_config = _axis_config_from_xml(
x_axis,
role="x",
expected_cross_axis_id=y_id,
allowed_crosses={"autoZero"},
expected_cross_between="midCat",
)
y_config = _axis_config_from_xml(
y_axis,
role="y",
expected_cross_axis_id=x_id,
allowed_crosses={"autoZero"},
expected_cross_between="midCat",
)
return {"x": x_config, "y": y_config}
def _validate_legacy_axes(payload: dict[str, Any], plot_area: ET.Element) -> None:
"""Keep the original narrow axis gate for payloads without axes metadata."""
grouping = payload.get("grouping")
axes = plot_area.findall("c:catAx", C_NS) + plot_area.findall("c:valAx", C_NS)
for axis in axes:
axis_kind = _local_name(axis.tag)
axis_position = _element_val(axis.find("c:axPos", C_NS))
delete = axis.find("c:delete", C_NS)
if delete is not None and ooxml_bool(delete.attrib.get("val"), True):
raise _UnsupportedChart("unsupported-chart-axis-options")
scaling = axis.find("c:scaling", C_NS)
if scaling is not None:
for tag in ("logBase", "min", "max"):
if scaling.find(f"c:{tag}", C_NS) is not None:
raise _UnsupportedChart("unsupported-chart-axis-options")
orientation = _element_val(scaling.find("c:orientation", C_NS))
if orientation not in {None, "minMax"}:
raise _UnsupportedChart("unsupported-chart-axis-options")
for tag in (
"majorUnit", "minorUnit", "crossesAt", "dispUnits",
"tickLblSkip", "tickMarkSkip",
):
if axis.find(f"c:{tag}", C_NS) is not None:
raise _UnsupportedChart("unsupported-chart-axis-options")
crosses = _element_val(axis.find("c:crosses", C_NS))
if crosses not in {None, "autoZero"}:
raise _UnsupportedChart("unsupported-chart-axis-options")
auto = axis.find("c:auto", C_NS)
if auto is not None and not ooxml_bool(auto.attrib.get("val"), True):
raise _UnsupportedChart("unsupported-chart-axis-options")
num_fmt = axis.find("c:numFmt", C_NS)
if num_fmt is not None:
format_code = num_fmt.attrib.get("formatCode", "").strip()
allowed_formats = {"", "General"}
if grouping == "percentStacked":
allowed_formats.add("0%")
if format_code not in allowed_formats:
raise _UnsupportedChart("unsupported-chart-axis-number-format")
tick_label_position = _element_val(axis.find("c:tickLblPos", C_NS))
if payload["type"] in {"scatter", "bubble"} or axis_kind == "catAx":
if tick_label_position not in {None, "nextTo"}:
raise _UnsupportedChart("unsupported-chart-axis-options")
elif tick_label_position == "none":
payload["show_value_axis_labels"] = False
elif tick_label_position not in {None, "nextTo"}:
raise _UnsupportedChart("unsupported-chart-axis-options")
has_major_gridlines = axis.find("c:majorGridlines", C_NS) is not None
if payload["type"] in {"scatter", "bubble"}:
expected_major_gridlines = axis_position in {"l", "r"}
else:
expected_major_gridlines = axis_kind == "valAx"
if has_major_gridlines != expected_major_gridlines:
raise _UnsupportedChart("unsupported-chart-axis-options")
if axis.find("c:minorGridlines", C_NS) is not None:
raise _UnsupportedChart("unsupported-chart-axis-options")
def _validate_normalized_axis_topology(
payload: dict[str, Any],
plot_area: ET.Element,
plot: ET.Element,
) -> None:
"""Validate a category/value pair whose presentation will normalize."""
axes_by_id = _axis_nodes_by_id(plot_area)
cat_id, cat_axis, val_id, val_axis = _plot_axis_pair(plot, axes_by_id)
if set(axes_by_id) != {cat_id, val_id}:
raise _UnsupportedChart("unsupported-chart-axis-options")
plot_axis_nodes = plot.findall("c:axId", C_NS)
if any(set(node.attrib) != {"val"} or list(node) for node in plot_axis_nodes):
raise _UnsupportedChart("unsupported-chart-axis-options")
for axis, axis_id, cross_axis_id in (
(cat_axis, cat_id, val_id),
(val_axis, val_id, cat_id),
):
if axis.attrib:
raise _UnsupportedChart("unsupported-chart-axis-options")
id_nodes = axis.findall("c:axId", C_NS)
cross_nodes = axis.findall("c:crossAx", C_NS)
if (
len(id_nodes) != 1
or len(cross_nodes) != 1
or set(id_nodes[0].attrib) != {"val"}
or set(cross_nodes[0].attrib) != {"val"}
or list(id_nodes[0])
or list(cross_nodes[0])
or _element_val(id_nodes[0]) != axis_id
or _element_val(cross_nodes[0]) != cross_axis_id
):
raise _UnsupportedChart("unsupported-chart-axis-options")
position_nodes = (
cat_axis.findall("c:axPos", C_NS),
val_axis.findall("c:axPos", C_NS),
)
if any(
len(nodes) != 1
or set(nodes[0].attrib) != {"val"}
or list(nodes[0])
for nodes in position_nodes
):
raise _UnsupportedChart("unsupported-chart-axis-options")
category_position = _element_val(position_nodes[0][0])
value_position = _element_val(position_nodes[1][0])
if payload["type"] == "bar":
valid_positions = (
category_position in {"l", "r"}
and value_position in {"b", "t"}
)
else:
valid_positions = (
category_position in {"b", "t"}
and value_position in {"l", "r"}
)
if not valid_positions:
raise _UnsupportedChart("unsupported-chart-axis-options")
def _validate_or_normalize_legacy_axes(
payload: dict[str, Any],
plot_area: ET.Element,
plot: ET.Element,
) -> None:
"""Keep exact legacy payloads while allowing presentation normalization."""
try:
_validate_legacy_axes(payload, plot_area)
except _UnsupportedChart as exc:
if exc.status not in {
"unsupported-chart-axis-number-format",
"unsupported-chart-axis-options",
}:
raise
if payload["type"] in {"area", "column", "line"}:
try:
payload["axes"] = _single_axis_contract(
plot_area,
plot,
category_kind="text",
cross_between="between",
)
return
except _UnsupportedChart:
payload.pop("axes", None)
_validate_normalized_axis_topology(payload, plot_area, plot)
def _effective_radar_style(plot: ET.Element) -> str:
"""Map Office radar marker variants to the writer's uniform style set."""
style_nodes = plot.findall("c:radarStyle", C_NS)
if len(style_nodes) > 1:
raise _UnsupportedChart("unsupported-chart-radar-style")
style_node = style_nodes[0] if style_nodes else None
if style_node is not None and (
set(style_node.attrib) != {"val"} or list(style_node)
):
raise _UnsupportedChart("unsupported-chart-radar-style")
style = _element_val(style_node) if style_node is not None else "standard"
if style == "filled":
return "filled"
if style not in {"marker", "standard"}:
raise _UnsupportedChart("unsupported-chart-radar-style")
default_marker = style == "marker"
marker_states: list[bool] = []
for series in plot.findall("c:ser", C_NS):
symbol = _element_val(series.find("c:marker/c:symbol", C_NS))
marker_states.append(
default_marker if symbol is None else symbol != "none"
)
return "lineMarker" if any(marker_states) else "line"
def _effective_scatter_style(
plot: ET.Element,
visual_styles: list[SeriesVisualStyle],
) -> str:
"""Normalize plot-level line intent plus uniform series overrides."""
style_nodes = plot.findall("c:scatterStyle", C_NS)
if len(style_nodes) > 1:
raise _UnsupportedChart("unsupported-chart-scatter-style")
style_node = style_nodes[0] if style_nodes else None
if style_node is not None and (
set(style_node.attrib) != {"val"} or list(style_node)
):
raise _UnsupportedChart("unsupported-chart-scatter-style")
plot_style = _element_val(style_node) or "marker"
if plot_style not in {"line", "lineMarker", "marker", "smooth", "smoothMarker"}:
raise _UnsupportedChart("unsupported-chart-scatter-style")
plot_has_line = plot_style in {"line", "lineMarker", "smooth", "smoothMarker"}
plot_is_smooth = plot_style in {"smooth", "smoothMarker"}
style_by_state = {
(False, True, False): "marker",
(True, False, False): "line",
(True, True, False): "lineMarker",
(True, False, True): "smooth",
(True, True, True): "smoothMarker",
}
effective_styles: set[str] = set()
series_nodes = plot.findall("c:ser", C_NS)
if len(series_nodes) != len(visual_styles):
raise _UnsupportedChart("unsupported-chart-scatter-style")
for series, visual_style in zip(series_nodes, visual_styles):
line_node = series.find("c:spPr/a:ln", C_NS)
has_line = plot_has_line if line_node is None else (
visual_style.stroke is not None
and visual_style.stroke_opacity > 0
)
markers = series.findall("c:marker", C_NS)
if len(markers) != 1:
raise _UnsupportedChart("unsupported-chart-scatter-style")
marker = markers[0]
symbol = marker.find("c:symbol", C_NS) if marker is not None else None
if (
marker is None
or symbol is None
or set(symbol.attrib) != {"val"}
or list(symbol)
):
raise _UnsupportedChart("unsupported-chart-scatter-style")
symbol_value = _element_val(symbol)
if symbol_value not in {"circle", "none"}:
raise _UnsupportedChart("unsupported-chart-scatter-style")
has_marker = symbol_value == "circle"
smooth_nodes = series.findall("c:smooth", C_NS)
if len(smooth_nodes) > 1:
raise _UnsupportedChart("unsupported-chart-scatter-style")
smooth = smooth_nodes[0] if smooth_nodes else None
is_smooth = plot_is_smooth
if smooth is not None:
if not set(smooth.attrib).issubset({"val"}) or list(smooth):
raise _UnsupportedChart("unsupported-chart-scatter-style")
raw_smooth = smooth.attrib.get("val")
key = raw_smooth.strip().lower() if raw_smooth is not None else "true"
if key in {"1", "on", "true"}:
is_smooth = True
elif key in {"0", "false", "off"}:
is_smooth = False
else:
raise _UnsupportedChart("unsupported-chart-scatter-style")
if is_smooth and not has_line:
raise _UnsupportedChart("unsupported-chart-scatter-style")
effective_style = style_by_state.get((has_line, has_marker, is_smooth))
if effective_style is None:
raise _UnsupportedChart("unsupported-chart-scatter-style")
effective_styles.add(effective_style)
if len(effective_styles) != 1:
raise _UnsupportedChart("unsupported-chart-scatter-style")
return effective_styles.pop()
def _bounded_plot_integer(
plot: ET.Element,
name: str,
*,
minimum: int,
maximum: int,
status: str,
) -> int | None:
nodes = plot.findall(f"c:{name}", C_NS)
if len(nodes) > 1:
raise _UnsupportedChart(status)
if not nodes:
return None
node = nodes[0]
raw_value = node.attrib.get("val")
if (
set(node.attrib) != {"val"}
or list(node)
or raw_value is None
or re.fullmatch(r"-?[0-9]+", raw_value) is None
):
raise _UnsupportedChart(status)
value = int(raw_value)
if not minimum <= value <= maximum:
raise _UnsupportedChart(status)
return value
def _validate_chart_semantics(
payload: dict[str, Any],
plot_area: ET.Element,
plot: ET.Element,
*,
palette: ColorPalette | None = None,
validate_axes: bool = True,
) -> list[SeriesVisualStyle]:
"""Reject valid chart features the compact marker cannot reproduce."""
chart_type = payload["type"]
grouping = payload.get("grouping")
visual_styles = _chart_visual_styles(payload, plot, palette)
for tag in (
"trendline", "errBars", "dropLines", "hiLowLines", "upDownBars",
):
if plot.find(f".//c:{tag}", C_NS) is not None:
raise _UnsupportedChart("unsupported-chart-analysis-features")
if plot_area.find("c:dTable", C_NS) is not None:
raise _UnsupportedChart("unsupported-chart-data-table")
ser_line_nodes = plot.findall("c:serLines", C_NS)
if len(ser_line_nodes) > 1:
raise _UnsupportedChart("unsupported-chart-analysis-features")
if ser_line_nodes:
ser_lines = ser_line_nodes[0]
if (
chart_type != "of_pie"
or ser_lines.attrib
or len(ser_lines) > 1
or any(_local_name(child.tag) != "spPr" for child in ser_lines)
):
raise _UnsupportedChart("unsupported-chart-analysis-features")
if validate_axes:
_validate_or_normalize_legacy_axes(payload, plot_area, plot)
if chart_type == "line":
smooth_nodes = [plot.find("c:smooth", C_NS), *plot.findall("c:ser/c:smooth", C_NS)]
if any(
node is not None and ooxml_bool(node.attrib.get("val"), True)
for node in smooth_nodes
):
raise _UnsupportedChart("unsupported-chart-line-style")
plot_marker = plot.find("c:marker", C_NS)
plot_has_markers = bool(
plot_marker is not None
and ooxml_bool(plot_marker.attrib.get("val"), True)
)
marker_states: set[bool] = set()
for series in plot.findall("c:ser", C_NS):
marker_node = series.find("c:marker", C_NS)
symbol = _element_val(series.find("c:marker/c:symbol", C_NS))
if symbol not in {None, "circle", "none"}:
raise _UnsupportedChart("unsupported-chart-line-style")
marker_states.add(plot_has_markers if symbol is None else symbol != "none")
if len(marker_states) > 1:
raise _UnsupportedChart("unsupported-chart-line-style")
if chart_type in {"bar", "column"}:
_bounded_plot_integer(
plot,
"gapWidth",
minimum=0,
maximum=500,
status="unsupported-chart-bar-options",
)
_bounded_plot_integer(
plot,
"overlap",
minimum=-100,
maximum=100,
status="unsupported-chart-bar-options",
)
if chart_type == "bubble":
bubble_scale = _element_val(plot.find("c:bubbleScale", C_NS))
if bubble_scale not in {None, "100"}:
raise _UnsupportedChart("unsupported-chart-bubble-options")
show_negative = plot.find("c:showNegBubbles", C_NS)
if show_negative is not None and ooxml_bool(
show_negative.attrib.get("val"),
True,
):
raise _UnsupportedChart("unsupported-chart-bubble-options")
size_represents = _element_val(plot.find("c:sizeRepresents", C_NS))
if size_represents not in {None, "area"}:
raise _UnsupportedChart("unsupported-chart-bubble-options")
bubble_3d_nodes = [
plot.find("c:bubble3D", C_NS),
*plot.findall("c:ser/c:bubble3D", C_NS),
]
if any(
node is not None and ooxml_bool(node.attrib.get("val"), True)
for node in bubble_3d_nodes
):
raise _UnsupportedChart("unsupported-chart-bubble-options")
if chart_type == "scatter":
payload["scatter_style"] = _effective_scatter_style(plot, visual_styles)
if chart_type in {"pie", "doughnut", "of_pie"}:
for explosion in plot.findall(".//c:explosion", C_NS):
if _element_val(explosion) not in {None, "0"}:
raise _UnsupportedChart("unsupported-chart-pie-options")
first_slice = _element_val(plot.find("c:firstSliceAng", C_NS))
if first_slice not in {None, "0"}:
raise _UnsupportedChart("unsupported-chart-pie-options")
if chart_type == "doughnut":
hole_size = _element_val(plot.find("c:holeSize", C_NS))
if hole_size != "75":
raise _UnsupportedChart("unsupported-chart-doughnut-options")
if chart_type == "of_pie":
for tag in ("splitType", "splitPos", "custSplit"):
if plot.find(f"c:{tag}", C_NS) is not None:
raise _UnsupportedChart("unsupported-chart-of-pie-options")
gap_width = _element_val(plot.find("c:gapWidth", C_NS))
if gap_width != "100":
raise _UnsupportedChart("unsupported-chart-of-pie-options")
second_size = _element_val(plot.find("c:secondPieSize", C_NS))
if second_size not in {None, "75"}:
raise _UnsupportedChart("unsupported-chart-of-pie-options")
return visual_styles
def _apply_plot_data_labels(payload: dict[str, Any], plot: ET.Element) -> None:
if plot.find("c:ser/c:dLbls", C_NS) is not None:
raise _UnsupportedChart("unsupported-chart-series-data-labels")
data_labels = _data_labels_payload(plot.find("c:dLbls", C_NS))
if not data_labels:
return
if payload["type"] not in {"area", "bar", "column", "line"}:
raise _UnsupportedChart("unsupported-chart-data-labels")
try:
validate_data_label_position(
data_labels.get("position"),
payload["type"],
payload.get("grouping"),
)
except RuntimeError:
raise _UnsupportedChart("unsupported-chart-data-labels") from None
payload["data_labels"] = data_labels
def _apply_chart_metadata(
payload: dict[str, Any],
chart_root: ET.Element,
plot_area: ET.Element,
plot: ET.Element,
*,
include_plot_labels: bool = True,
) -> None:
"""Copy visible classic-chart chrome supported by the native schema."""
chart = chart_root.find("c:chart", C_NS)
if chart is None:
return
title_element = chart.find("c:title", C_NS)
title_entries = _canonical_title_entries(title_element)
title = _chart_text(title_element)
if title:
if title_entries is not None:
payload["title"] = title_entries[0]
if len(title_entries) == 2:
payload["subtitle"] = title_entries[1]
else:
payload["title"] = title
legend = chart.find("c:legend", C_NS)
if legend is not None:
delete = legend.find("c:delete", C_NS)
if delete is None or not ooxml_bool(delete.attrib.get("val"), True):
position = _element_val(legend.find("c:legendPos", C_NS)) or "r"
if position not in {"b", "l", "r", "t"}:
raise _UnsupportedChart("unsupported-chart-legend-position")
payload["show_legend"] = True
payload["legend_position"] = position
if include_plot_labels:
_apply_plot_data_labels(payload, plot)
axis_titles: dict[str, str] = {}
category_axis_nodes = (
plot_area.findall("c:catAx", C_NS)
+ plot_area.findall("c:dateAx", C_NS)
)
category_titles = [
text
for axis in category_axis_nodes
if (text := _chart_text(axis.find("c:title", C_NS)))
]
value_titles = [
text
for axis in plot_area.findall("c:valAx", C_NS)
if (text := _chart_text(axis.find("c:title", C_NS)))
]
if payload["type"] == "combo":
if len(set(category_titles)) > 1:
raise _UnsupportedChart("unsupported-chart-axis-titles")
if category_titles:
axis_titles["category"] = category_titles[0]
for axis in plot_area.findall("c:valAx", C_NS):
text = _chart_text(axis.find("c:title", C_NS))
if not text:
continue
position = _element_val(axis.find("c:axPos", C_NS))
key = "secondary_value" if position == "r" else "value"
if key in axis_titles:
raise _UnsupportedChart("unsupported-chart-axis-titles")
axis_titles[key] = text
elif payload["type"] in {"scatter", "bubble"}:
if category_titles:
raise _UnsupportedChart("unsupported-chart-axis-titles")
titled_value_axes = [
(
_element_val(axis.find("c:axPos", C_NS)),
_chart_text(axis.find("c:title", C_NS)),
)
for axis in plot_area.findall("c:valAx", C_NS)
]
for position, text in titled_value_axes:
if not text:
continue
key = "x" if position in {"b", "t"} else "y"
if key in axis_titles:
raise _UnsupportedChart("unsupported-chart-axis-titles")
axis_titles[key] = text
else:
if len(category_titles) > 1 or len(value_titles) > 1:
raise _UnsupportedChart("unsupported-chart-axis-titles")
if category_titles:
axis_titles["category"] = category_titles[0]
if value_titles:
axis_titles["value"] = value_titles[0]
if axis_titles:
payload["axis_titles"] = axis_titles
def _chart_text(container: ET.Element | None) -> str:
if container is None:
return ""
paragraphs: list[str] = []
for paragraph in container.findall(".//a:p", C_NS):
text = "".join(node.text or "" for node in paragraph.findall(".//a:t", C_NS))
if text:
paragraphs.append(text)
if paragraphs:
return "\n".join(paragraphs)
values = [node.text or "" for node in container.findall(".//c:v", C_NS)]
return "".join(values)
def _canonical_title_paragraph(paragraph: ET.Element) -> dict[str, Any] | None:
"""Return one exporter-canonical or basic Office title paragraph."""
if paragraph.attrib or [_local_name(child.tag) for child in paragraph] != ["r"]:
return None
run = paragraph.find("a:r", C_NS)
if run is None or run.attrib:
return None
run_child_names = [_local_name(child.tag) for child in run]
if run_child_names == ["t"]:
text = run.find("a:t", C_NS)
if (
text is None
or text.attrib
or list(text)
or not (text.text or "")
or text.text != text.text.strip()
):
return None
return {"text": text.text}
if run_child_names != ["rPr", "t"]:
return None
run_props = run.find("a:rPr", C_NS)
text = run.find("a:t", C_NS)
if (
run_props is None
or set(run_props.attrib) != {"lang", "sz"}
or text is None
or text.attrib
or list(text)
or not (text.text or "")
or text.text != text.text.strip()
):
return None
size_token = run_props.attrib["sz"]
if re.fullmatch(r"[0-9]+", size_token) is None:
return None
size = int(size_token)
if size % 10 != 0 or not 100 <= size <= 400000:
return None
child_names = [_local_name(child.tag) for child in run_props]
if child_names not in ([], ["solidFill"], ["latin", "ea"], ["solidFill", "latin", "ea"]):
return None
solid_fill = run_props.find("a:solidFill", C_NS)
color = None
if solid_fill is not None:
try:
color = _canonical_srgb_color(solid_fill)
except _UnsupportedChart:
return None
latin = run_props.find("a:latin", C_NS)
east_asian = run_props.find("a:ea", C_NS)
if (latin is None) != (east_asian is None):
return None
for font in (latin, east_asian):
if font is not None and (
set(font.attrib) != {"typeface"}
or not font.attrib["typeface"].strip()
or list(font)
):
return None
entry: dict[str, Any] = {
"text": text.text,
"font_size": _round_payload_number(size / 75.0),
}
if color is not None:
entry["color"] = f"#{color}"
if latin is not None and east_asian is not None:
latin_name = latin.attrib["typeface"]
east_asian_name = east_asian.attrib["typeface"]
font_family = (
latin_name
if latin_name == east_asian_name
else f"{latin_name}, {east_asian_name}"
)
resolved_fonts = parse_font_family(font_family)
if (
resolved_fonts["latin"] != latin_name
or resolved_fonts["ea"] != east_asian_name
):
return None
entry["font_family"] = font_family
return entry
def _canonical_title_entries(title: ET.Element | None) -> list[dict[str, Any]] | None:
"""Recognize exporter-canonical or basic Office rich title structure."""
if title is None:
return None
title_child_names = [_local_name(child.tag) for child in title]
if title_child_names not in (["tx", "layout"], ["tx", "layout", "overlay"]):
return None
overlay = title.find("c:overlay", C_NS)
if overlay is not None and (
set(overlay.attrib) != {"val"}
or list(overlay)
or ooxml_bool(overlay.attrib.get("val"), True)
):
return None
tx = title.find("c:tx", C_NS)
layout = title.find("c:layout", C_NS)
rich = title.find("c:tx/c:rich", C_NS)
if (
tx is None
or tx.attrib
or [_local_name(child.tag) for child in tx] != ["rich"]
or layout is None
or layout.attrib
or list(layout)
or rich is None
or rich.attrib
):
return None
children = list(rich)
child_names = [_local_name(child.tag) for child in children]
if child_names not in (
["bodyPr", "lstStyle", "p"],
["bodyPr", "lstStyle", "p", "p"],
):
return None
if children[0].attrib or list(children[0]) or children[1].attrib or list(children[1]):
return None
entries = [_canonical_title_paragraph(paragraph) for paragraph in children[2:]]
if any(entry is None for entry in entries):
return None
return [entry for entry in entries if entry is not None]
def _data_label_text_style(tx_pr: ET.Element) -> dict[str, Any]:
"""Extract the subset of label text properties emitted by this exporter."""
body_pr = tx_pr.find("a:bodyPr", C_NS)
list_style = tx_pr.find("a:lstStyle", C_NS)
if body_pr is None or body_pr.attrib or list(body_pr):
raise _UnsupportedChart("unsupported-chart-data-labels")
if list_style is None or list_style.attrib or list(list_style):
raise _UnsupportedChart("unsupported-chart-data-labels")
paragraphs = tx_pr.findall("a:p", C_NS)
if len(paragraphs) != 1:
raise _UnsupportedChart("unsupported-chart-data-labels")
paragraph = paragraphs[0]
if any(
child.tag.rsplit("}", 1)[-1] not in {"pPr", "endParaRPr"}
for child in paragraph
):
raise _UnsupportedChart("unsupported-chart-data-labels")
p_pr = paragraph.find("a:pPr", C_NS)
if p_pr is None or p_pr.attrib:
raise _UnsupportedChart("unsupported-chart-data-labels")
if any(
child.tag.rsplit("}", 1)[-1] != "defRPr"
for child in p_pr
):
raise _UnsupportedChart("unsupported-chart-data-labels")
end_r_pr = paragraph.find("a:endParaRPr", C_NS)
if end_r_pr is not None and (
any(name not in {"lang", "altLang"} for name in end_r_pr.attrib)
or list(end_r_pr)
):
raise _UnsupportedChart("unsupported-chart-data-labels")
r_pr = p_pr.find("a:defRPr", C_NS)
if r_pr is None:
return {}
allowed_attrs = {"sz", "b"}
if any(name not in allowed_attrs for name in r_pr.attrib):
raise _UnsupportedChart("unsupported-chart-data-labels")
allowed_children = {"solidFill", "latin", "ea"}
if any(
child.tag.rsplit("}", 1)[-1] not in allowed_children
for child in r_pr
):
raise _UnsupportedChart("unsupported-chart-data-labels")
style: dict[str, Any] = {}
raw_size = r_pr.attrib.get("sz")
if raw_size is not None:
try:
size_px = float(raw_size) / 75.0
except ValueError:
raise _UnsupportedChart("unsupported-chart-data-labels") from None
if size_px <= 0 or not math.isfinite(size_px):
raise _UnsupportedChart("unsupported-chart-data-labels")
style["font_size"] = int(size_px) if size_px.is_integer() else round(size_px, 3)
if r_pr.attrib.get("b") is not None:
style["bold"] = ooxml_bool(r_pr.attrib.get("b"), True)
solid_fill = r_pr.find("a:solidFill", C_NS)
if solid_fill is not None:
color_children = list(solid_fill)
if (
len(color_children) != 1
or color_children[0].tag.rsplit("}", 1)[-1] != "srgbClr"
or list(color_children[0])
):
raise _UnsupportedChart("unsupported-chart-data-labels")
color = color_children[0].attrib.get("val", "").strip()
if len(color) != 6 or any(char not in "0123456789abcdefABCDEF" for char in color):
raise _UnsupportedChart("unsupported-chart-data-labels")
style["color"] = f"#{color.upper()}"
latin = r_pr.find("a:latin", C_NS)
east_asian = r_pr.find("a:ea", C_NS)
latin_face = latin.attrib.get("typeface", "").strip() if latin is not None else ""
east_asian_face = (
east_asian.attrib.get("typeface", "").strip()
if east_asian is not None else ""
)
font_face = (
f"{latin_face}, {east_asian_face}"
if latin_face and east_asian_face and latin_face != east_asian_face
else latin_face or east_asian_face
)
if font_face:
style["font_family"] = font_face
return style
def _data_labels_payload(dlabels: ET.Element | None) -> dict[str, Any] | None:
if dlabels is None:
return None
if dlabels.find("c:dLbl", C_NS) is not None:
raise _UnsupportedChart("unsupported-chart-point-labels")
allowed_children = {
"numFmt", "txPr", "dLblPos", "showLegendKey", "showVal",
"showCatName", "showSerName", "showPercent", "showBubbleSize",
"showLeaderLines",
}
if any(
child.tag.rsplit("}", 1)[-1] not in allowed_children
for child in dlabels
):
raise _UnsupportedChart("unsupported-chart-data-labels")
for tag in ("showLegendKey", "showBubbleSize"):
elem = dlabels.find(f"c:{tag}", C_NS)
if elem is not None and ooxml_bool(elem.attrib.get("val"), True):
raise _UnsupportedChart("unsupported-chart-data-labels")
config: dict[str, Any] = {}
for tag, field in (
("showVal", "show_value"),
("showCatName", "show_category"),
("showSerName", "show_series"),
("showPercent", "show_percent"),
):
elem = dlabels.find(f"c:{tag}", C_NS)
config[field] = (
ooxml_bool(elem.attrib.get("val"), True)
if elem is not None else False
)
if not any(config.values()):
return None
leader_lines = dlabels.find("c:showLeaderLines", C_NS)
if leader_lines is not None:
config["show_leader_lines"] = ooxml_bool(
leader_lines.attrib.get("val"),
True,
)
position = _element_val(dlabels.find("c:dLblPos", C_NS))
if position:
position_aliases = {
"bestFit": "best_fit",
"ctr": "center",
"inBase": "inside_base",
"inEnd": "inside_end",
"outEnd": "outside_end",
"t": "above",
}
normalized_position = position_aliases.get(position)
if normalized_position is None:
raise _UnsupportedChart("unsupported-chart-data-labels")
config["position"] = normalized_position
num_fmt = dlabels.find("c:numFmt", C_NS)
if num_fmt is not None and num_fmt.attrib.get("formatCode"):
config["number_format"] = num_fmt.attrib["formatCode"]
tx_pr = dlabels.find("c:txPr", C_NS)
if tx_pr is not None:
config.update(_data_label_text_style(tx_pr))
return config
def _category_payload(
chart: ET.Element,
chart_type: str,
xfrm: Xfrm,
*,
category_kind: str = "text",
) -> dict[str, Any]:
series_nodes = chart.findall("c:ser", C_NS)
if not series_nodes:
raise _UnsupportedChart("unsupported-chart-cache")
category_reader = (
_numeric_values
if category_kind in {"date", "numeric"}
else _category_values
)
categories = category_reader(series_nodes[0].find("c:cat", C_NS))
if not categories:
raise _UnsupportedChart("unsupported-chart-cache")
series: list[dict[str, Any]] = []
for idx, ser in enumerate(series_nodes, start=1):
if category_reader(ser.find("c:cat", C_NS)) != categories:
raise _UnsupportedChart("unsupported-chart-cache")
values = _numeric_values(ser.find("c:val", C_NS))
if not values or len(values) != len(categories):
raise _UnsupportedChart("unsupported-chart-cache")
series.append({
"name": _series_name(ser, idx),
"values": values,
})
payload: dict[str, Any] = {
**_bounds_payload(xfrm),
"categories": categories,
"series": series,
"type": chart_type,
}
grouping = _element_val(chart.find("c:grouping", C_NS))
if grouping and chart_type in {"area", "bar", "column", "line"}:
payload["grouping"] = grouping
if chart_type == "line":
payload["line_style"] = _line_style(chart, series_nodes)
if chart_type == "of_pie":
payload["of_pie_type"] = _element_val(chart.find("c:ofPieType", C_NS)) or "pie"
return payload
def _xy_payload(chart: ET.Element, chart_type: str, xfrm: Xfrm) -> dict[str, Any]:
series_nodes = chart.findall("c:ser", C_NS)
if not series_nodes:
raise _UnsupportedChart("unsupported-chart-cache")
series: list[dict[str, Any]] = []
for idx, ser in enumerate(series_nodes, start=1):
x_values = _numeric_values(ser.find("c:xVal", C_NS))
y_values = _numeric_values(ser.find("c:yVal", C_NS))
if not x_values or len(x_values) != len(y_values):
raise _UnsupportedChart("unsupported-chart-cache")
item: dict[str, Any] = {
"name": _series_name(ser, idx),
"x": x_values,
"y": y_values,
}
if chart_type == "bubble":
sizes = _numeric_values(ser.find("c:bubbleSize", C_NS))
if len(sizes) != len(x_values):
raise _UnsupportedChart("unsupported-chart-cache")
item["sizes"] = sizes
series.append(item)
payload: dict[str, Any] = {
**_bounds_payload(xfrm),
"series": series,
"type": chart_type,
}
return payload
def _bar_chart_type(chart: ET.Element) -> str:
return "bar" if _element_val(chart.find("c:barDir", C_NS)) == "bar" else "column"
def _line_style(chart: ET.Element, series_nodes: list[ET.Element]) -> str:
symbols = [
_element_val(ser.find("c:marker/c:symbol", C_NS))
for ser in series_nodes
]
if symbols and all(symbol == "none" for symbol in symbols):
return "line"
if any(symbol not in {None, "none"} for symbol in symbols):
return "lineMarker"
marker = chart.find("c:marker", C_NS)
if marker is not None and ooxml_bool(marker.attrib.get("val"), True):
return "lineMarker"
return "line"
def _category_values(cat: ET.Element | None) -> list[str]:
cache = _first_cache(cat, ("strCache", "strLit"))
if cache is not None:
return [str(value) for value in _cache_point_values(cache)]
cache = _first_cache(cat, ("numCache", "numLit"))
if cache is None:
return []
format_code = cache.findtext("c:formatCode", default="", namespaces=C_NS).strip()
if format_code and format_code.lower() != "general":
raise _UnsupportedChart("unsupported-formatted-category-cache")
numbers = _numeric_cache_values(cache)
return [str(value) for value in numbers]
def _category_cache_is_numeric(chart: ET.Element) -> bool:
"""Return the cache representation shared by every series in one plot."""
result: bool | None = None
for series in chart.findall("c:ser", C_NS):
category = series.find("c:cat", C_NS)
has_text = _first_cache(category, ("strCache", "strLit")) is not None
has_number = _first_cache(category, ("numCache", "numLit")) is not None
if has_text == has_number:
raise _UnsupportedChart("unsupported-combo-category-layout")
current = has_number
if result is not None and current != result:
raise _UnsupportedChart("unsupported-combo-category-layout")
result = current
if result is None:
raise _UnsupportedChart("unsupported-combo-category-layout")
return result
def _numeric_category_cache_format(chart: ET.Element) -> str:
"""Return one exact numeric category-cache format shared by the plot."""
formats: set[str] = set()
for series in chart.findall("c:ser", C_NS):
cache = _first_cache(
series.find("c:cat", C_NS),
("numCache", "numLit"),
)
if cache is None:
raise _UnsupportedChart("unsupported-chart-category-format")
number_format = cache.findtext(
"c:formatCode",
default="",
namespaces=C_NS,
)
if not number_format.strip():
raise _UnsupportedChart("unsupported-chart-category-format")
formats.add(number_format)
if len(formats) != 1:
raise _UnsupportedChart("unsupported-chart-category-format")
return next(iter(formats))
def _series_name(ser: ET.Element, index: int) -> str:
tx = ser.find("c:tx", C_NS)
values = _text_cache_values(tx)
if values:
return values[0]
direct = tx.findtext("c:v", default="", namespaces=C_NS) if tx is not None else ""
return direct or f"Series {index}"
def _text_cache_values(parent: ET.Element | None) -> list[str]:
cache = _first_cache(parent, ("strCache", "strLit"))
if cache is not None:
return [str(value) for value in _cache_point_values(cache)]
cache = _first_cache(parent, ("numCache", "numLit"))
return [str(value) for value in _cache_point_values(cache)]
def _numeric_values(parent: ET.Element | None) -> list[int | float]:
cache = _first_cache(parent, ("numCache", "numLit"))
if cache is None:
return []
return _numeric_cache_values(cache)
def _numeric_cache_values(cache: ET.Element) -> list[int | float]:
values: list[int | float] = []
for value in _cache_point_values(cache):
number = float(value)
if not math.isfinite(number):
raise _UnsupportedChart("unsupported-chart-cache")
values.append(int(number) if number.is_integer() else number)
return values
def _first_cache(parent: ET.Element | None, names: tuple[str, ...]) -> ET.Element | None:
if parent is None:
return None
for name in names:
found = parent.find(f".//c:{name}", C_NS)
if found is not None:
return found
return None
def _cache_point_values(cache: ET.Element | None) -> list[str]:
if cache is None:
return []
points: dict[int, str] = {}
for idx, point in enumerate(cache.findall("c:pt", C_NS)):
raw_idx = point.attrib.get("idx")
try:
point_idx = int(raw_idx) if raw_idx is not None else idx
except ValueError:
raise _UnsupportedChart("unsupported-chart-cache")
if point_idx < 0 or point_idx in points:
raise _UnsupportedChart("unsupported-chart-cache")
value = point.findtext("c:v", default="", namespaces=C_NS)
points[point_idx] = value
count_elem = cache.find("c:ptCount", C_NS)
if count_elem is not None:
try:
point_count = int(count_elem.attrib.get("val", ""))
except ValueError:
raise _UnsupportedChart("unsupported-chart-cache")
else:
point_count = len(points)
if (
point_count < 0
or point_count != len(points)
or any(idx not in points for idx in range(point_count))
):
raise _UnsupportedChart("unsupported-chart-cache")
return [points[idx] for idx in range(point_count)]
def _element_val(elem: ET.Element | None) -> str | None:
if elem is None:
return None
return elem.attrib.get("val")
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 _local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1] if "}" in tag else tag