2378 lines
90 KiB
Python
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
|