295 lines
10 KiB
Python
295 lines
10 KiB
Python
"""Classify template-fill chart and table edits before package mutation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from xml.etree import ElementTree as ET
|
|
|
|
from .ooxml import NS, _qn
|
|
|
|
_XY_PLOTS = frozenset({"bubbleChart", "scatterChart"})
|
|
|
|
|
|
def _local_name(tag: str) -> str:
|
|
return tag.rsplit("}", 1)[-1]
|
|
|
|
|
|
def _tag_namespace(tag: str) -> str:
|
|
if not tag.startswith("{") or "}" not in tag:
|
|
return ""
|
|
return tag[1:].split("}", 1)[0]
|
|
|
|
|
|
def _chart_reference(frame: ET.Element) -> tuple[str, str]:
|
|
"""Return ``(classic|chartex|unknown, relationship id)`` for a chart frame."""
|
|
graphic_data = frame.find(".//a:graphicData", NS)
|
|
if graphic_data is None:
|
|
return "", ""
|
|
|
|
for node in graphic_data.iter():
|
|
if _local_name(node.tag) != "chart":
|
|
continue
|
|
namespace = _tag_namespace(node.tag)
|
|
rel_id = node.attrib.get(_qn(NS["r"], "id"), "")
|
|
if namespace == NS["c"]:
|
|
return "classic", rel_id
|
|
if "chartex" in namespace.lower():
|
|
return "chartex", rel_id
|
|
return "unknown", rel_id
|
|
|
|
uri = graphic_data.attrib.get("uri", "").lower()
|
|
if "chartex" in uri:
|
|
return "chartex", ""
|
|
if "chart" in uri:
|
|
return "unknown", ""
|
|
return "", ""
|
|
|
|
|
|
def _chart_frames(slide_root: ET.Element) -> list[ET.Element]:
|
|
"""Return classic, ChartEx, and unknown chart graphic frames."""
|
|
return [
|
|
frame
|
|
for frame in slide_root.findall(".//p:graphicFrame", NS)
|
|
if _chart_reference(frame)[0]
|
|
]
|
|
|
|
|
|
def _unsupported_chart_capability(
|
|
code: str,
|
|
message: str,
|
|
*,
|
|
plot_type: str | None = None,
|
|
plot_count: int = 0,
|
|
data_model: str = "unknown",
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"supported": False,
|
|
"code": code,
|
|
"message": message,
|
|
"plot_type": plot_type,
|
|
"plot_count": plot_count,
|
|
"data_model": data_model,
|
|
}
|
|
|
|
|
|
def _chart_edit_capability(chart_root: ET.Element) -> dict[str, Any]:
|
|
"""Classify whether the category cache writer can safely edit a chart part."""
|
|
root_namespace = _tag_namespace(chart_root.tag)
|
|
if "chartex" in root_namespace.lower():
|
|
return _unsupported_chart_capability(
|
|
"chart_edit_chartex_unsupported",
|
|
"template-fill chart edits do not support ChartEx",
|
|
)
|
|
if root_namespace != NS["c"]:
|
|
return _unsupported_chart_capability(
|
|
"chart_edit_plot_type_unsupported",
|
|
"template-fill chart edits require a classic DrawingML chart part",
|
|
)
|
|
|
|
plot_area = chart_root.find(".//c:plotArea", NS)
|
|
if plot_area is None:
|
|
return _unsupported_chart_capability(
|
|
"chart_edit_plot_type_unsupported",
|
|
"template-fill chart edits require a classic chart plotArea",
|
|
)
|
|
|
|
plot_nodes = [
|
|
child
|
|
for child in list(plot_area)
|
|
if _tag_namespace(child.tag) == NS["c"] and _local_name(child.tag).endswith("Chart")
|
|
]
|
|
if len(plot_nodes) > 1:
|
|
return _unsupported_chart_capability(
|
|
"chart_edit_multi_plot_unsupported",
|
|
"template-fill chart edits do not support multi-plot or combination charts",
|
|
plot_count=len(plot_nodes),
|
|
)
|
|
if not plot_nodes:
|
|
return _unsupported_chart_capability(
|
|
"chart_edit_plot_type_unsupported",
|
|
"template-fill chart edits require exactly one recognized chart plot",
|
|
)
|
|
|
|
plot = plot_nodes[0]
|
|
plot_type = _local_name(plot.tag)
|
|
if plot_type == "scatterChart":
|
|
return _unsupported_chart_capability(
|
|
"chart_edit_scatter_unsupported",
|
|
"template-fill chart edits do not support scatter xVal/yVal data",
|
|
plot_type=plot_type,
|
|
plot_count=1,
|
|
data_model="xy",
|
|
)
|
|
if plot_type == "bubbleChart":
|
|
return _unsupported_chart_capability(
|
|
"chart_edit_bubble_unsupported",
|
|
"template-fill chart edits do not support bubble xVal/yVal/bubbleSize data",
|
|
plot_type=plot_type,
|
|
plot_count=1,
|
|
data_model="bubble",
|
|
)
|
|
series_nodes = plot.findall("c:ser", NS)
|
|
if not series_nodes:
|
|
return _unsupported_chart_capability(
|
|
"chart_edit_no_series",
|
|
"template-fill chart edits require at least one editable series",
|
|
plot_type=plot_type,
|
|
plot_count=1,
|
|
data_model="category",
|
|
)
|
|
for series in series_nodes:
|
|
if any(
|
|
series.find(f"c:{tag}", NS) is not None
|
|
for tag in ("xVal", "yVal", "bubbleSize")
|
|
):
|
|
return _unsupported_chart_capability(
|
|
"chart_edit_data_model_unsupported",
|
|
"template-fill chart edits require c:cat/c:val series",
|
|
plot_type=plot_type,
|
|
plot_count=1,
|
|
)
|
|
category = series.find("c:cat", NS)
|
|
values = series.find("c:val", NS)
|
|
if category is None or values is None:
|
|
return _unsupported_chart_capability(
|
|
"chart_edit_data_model_unsupported",
|
|
"template-fill chart edits require c:cat/c:val on every series",
|
|
plot_type=plot_type,
|
|
plot_count=1,
|
|
)
|
|
capability_warnings: list[dict[str, str]] = []
|
|
if plot_area.find("c:dateAx", NS) is not None:
|
|
capability_warnings.append(
|
|
{
|
|
"code": "chart_edit_date_axis_flattened",
|
|
"message": (
|
|
"template-fill will flatten date-axis categories to the "
|
|
"replacement single-level category cache"
|
|
),
|
|
}
|
|
)
|
|
if any(
|
|
series.find("c:cat/c:multiLvlStrRef", NS) is not None
|
|
for series in series_nodes
|
|
):
|
|
capability_warnings.append(
|
|
{
|
|
"code": "chart_edit_multilevel_categories_flattened",
|
|
"message": (
|
|
"template-fill will flatten multi-level categories to the "
|
|
"replacement single-level category cache"
|
|
),
|
|
}
|
|
)
|
|
|
|
return {
|
|
"supported": True,
|
|
"code": "chart_edit_category_single_plot",
|
|
"message": "single classic plot uses c:cat/c:val series",
|
|
"plot_type": plot_type,
|
|
"plot_count": 1,
|
|
"data_model": "category",
|
|
"warnings": capability_warnings,
|
|
}
|
|
|
|
|
|
def _is_verified_category_capability(capability: Any) -> bool:
|
|
"""Return whether an analyzer capability matches the runtime structural gate."""
|
|
return bool(
|
|
isinstance(capability, dict)
|
|
and capability.get("supported") is True
|
|
and capability.get("code") == "chart_edit_category_single_plot"
|
|
and capability.get("data_model") == "category"
|
|
and capability.get("plot_count") == 1
|
|
and isinstance(capability.get("plot_type"), str)
|
|
and bool(capability.get("plot_type"))
|
|
and capability.get("plot_type") not in _XY_PLOTS
|
|
)
|
|
|
|
|
|
def _require_supported_chart_edit(chart_root: ET.Element) -> dict[str, Any]:
|
|
"""Raise before mutation unless ``chart_root`` uses the verified category model."""
|
|
capability = _chart_edit_capability(chart_root)
|
|
if not _is_verified_category_capability(capability):
|
|
code = capability.get("code") or "chart_edit_capability_unknown"
|
|
message = capability.get("message") or "chart edit capability is unknown"
|
|
raise RuntimeError(f"{message} [{code}]")
|
|
return capability
|
|
|
|
|
|
def _ooxml_bool(value: str | None) -> bool:
|
|
return str(value or "").strip().lower() in {"1", "on", "true"}
|
|
|
|
|
|
def _positive_span(value: str | None) -> int:
|
|
try:
|
|
return max(int(value or "1"), 1)
|
|
except ValueError:
|
|
return 1
|
|
|
|
|
|
def _table_cell_merge_info(cell: ET.Element) -> dict[str, Any]:
|
|
"""Return the merge role encoded directly on one physical ``a:tc`` cell."""
|
|
h_merge = _ooxml_bool(cell.attrib.get("hMerge"))
|
|
v_merge = _ooxml_bool(cell.attrib.get("vMerge"))
|
|
row_span = _positive_span(cell.attrib.get("rowSpan"))
|
|
col_span = _positive_span(cell.attrib.get("gridSpan"))
|
|
is_merge_slave = h_merge or v_merge
|
|
is_merge_anchor = not is_merge_slave and (row_span > 1 or col_span > 1)
|
|
merge_role = "slave" if is_merge_slave else "anchor" if is_merge_anchor else "none"
|
|
return {
|
|
"merge_role": merge_role,
|
|
"is_merge_anchor": is_merge_anchor,
|
|
"is_merge_slave": is_merge_slave,
|
|
"row_span": row_span,
|
|
"col_span": col_span,
|
|
"h_merge": h_merge,
|
|
"v_merge": v_merge,
|
|
}
|
|
|
|
|
|
def _table_merge_topology(table: ET.Element) -> dict[str, Any]:
|
|
"""Describe merge anchors and physical slave cells in a DrawingML table."""
|
|
cell_states: list[tuple[int, int, dict[str, Any]]] = []
|
|
anchors: list[dict[str, int]] = []
|
|
for row_index, row in enumerate(table.findall("a:tr", NS)):
|
|
for col_index, cell in enumerate(row.findall("a:tc", NS)):
|
|
info = _table_cell_merge_info(cell)
|
|
cell_states.append((row_index, col_index, info))
|
|
if info["is_merge_anchor"]:
|
|
anchors.append(
|
|
{
|
|
"row": row_index,
|
|
"col": col_index,
|
|
"row_span": info["row_span"],
|
|
"col_span": info["col_span"],
|
|
}
|
|
)
|
|
|
|
covered_by: dict[tuple[int, int], dict[str, int]] = {}
|
|
for anchor in anchors:
|
|
for row_index in range(anchor["row"], anchor["row"] + anchor["row_span"]):
|
|
for col_index in range(anchor["col"], anchor["col"] + anchor["col_span"]):
|
|
if (row_index, col_index) == (anchor["row"], anchor["col"]):
|
|
continue
|
|
covered_by.setdefault(
|
|
(row_index, col_index),
|
|
{"row": anchor["row"], "col": anchor["col"]},
|
|
)
|
|
|
|
slave_cells: list[dict[str, Any]] = []
|
|
for row_index, col_index, info in cell_states:
|
|
if not info["is_merge_slave"]:
|
|
continue
|
|
slave: dict[str, Any] = {"row": row_index, "col": col_index}
|
|
anchor = covered_by.get((row_index, col_index))
|
|
if anchor is not None:
|
|
slave["anchor"] = anchor
|
|
slave_cells.append(slave)
|
|
|
|
return {
|
|
"has_merges": bool(anchors or slave_cells),
|
|
"anchors": anchors,
|
|
"slave_cells": slave_cells,
|
|
}
|