diff --git a/src/pptx/chart/_scatter_label_layout.py b/src/pptx/chart/_scatter_label_layout.py new file mode 100644 index 000000000..4e744ae30 --- /dev/null +++ b/src/pptx/chart/_scatter_label_layout.py @@ -0,0 +1,349 @@ +"""Pure scatter label layout helpers (no pptx/lxml/openpyxl imports). + +This module is deliberately free of any dependency on the rest of the library so +its clustering and offset math can be unit-tested in isolation. It computes +per-point factorial offsets for scatter data labels: isolated points are left to +PowerPoint's automatic placement (``None``), while clustered points fan out +radially from their cluster centroid. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, Sequence + +if TYPE_CHECKING: + from typing_extensions import TypeAlias + +_DEFAULT_CLUSTER_EPS = 0.12 +_DEFAULT_MIN_CLUSTER = 2 +_DEFAULT_MAG_BASE = 0.036 +_DEFAULT_MAG_MAX = 0.135 +_COINCIDENT_RADIUS = 0.03 +_MIN_ANGLE_SEP = 0.50 +_REF_LABEL_LEN = 12 + +Offset2D: "TypeAlias" = "tuple[float, float]" +OffsetOrAuto: "TypeAlias" = "Offset2D | None" + + +def _normalize_xy( + xs: Sequence[float], + ys: Sequence[float], +) -> tuple[list[float], list[float]]: + x_arr = [float(v) for v in xs] + y_arr = [float(v) for v in ys] + if not x_arr: + return [], [] + x0, y0 = min(x_arr), min(y_arr) + x_span = max(max(x_arr) - x0, 1e-12) + y_span = max(max(y_arr) - y0, 1e-12) + return [(x - x0) / x_span for x in x_arr], [(y - y0) / y_span for y in y_arr] + + +def cluster_scatter_points( + xs: Sequence[float], + ys: Sequence[float], + *, + cluster_eps: float = _DEFAULT_CLUSTER_EPS, +) -> list[list[int]]: + """Connected components by Euclidean distance in normalized [0,1]².""" + n = len(xs) + if n == 0: + return [] + + xn, yn = _normalize_xy(xs, ys) + seen = [False] * n + clusters: list[list[int]] = [] + + for i in range(n): + if seen[i]: + continue + stack = [i] + seen[i] = True + members = [i] + while stack: + u = stack.pop() + for v in range(n): + if seen[v]: + continue + if math.hypot(xn[u] - xn[v], yn[u] - yn[v]) <= cluster_eps: + seen[v] = True + stack.append(v) + members.append(v) + clusters.append(members) + return clusters + + +def _nearest_in_cluster( + idx: int, + members: Sequence[int], + xn: Sequence[float], + yn: Sequence[float], +) -> float: + best = float("inf") + for j in members: + if j == idx: + continue + best = min(best, math.hypot(xn[idx] - xn[j], yn[idx] - yn[j])) + return 0.0 if math.isinf(best) else best + + +def _adaptive_magnitude( + *, + dist_centroid: float, + dist_neighbor: float, + cluster_size: int, + label_len: int, + cluster_eps: float, + magnitude_base: float, + magnitude_max: float, +) -> float: + s_prox = 1.0 / (1.0 + dist_centroid / 0.045) + s_dens = 1.0 / (1.0 + dist_neighbor / max(cluster_eps * 0.35, 0.02)) + s_size = min(1.0, max(0, cluster_size - 1) / 8.0) + s_lab = min(1.0, max(0, label_len - 8) / 28.0) + score = 0.34 * s_prox + 0.30 * s_dens + 0.14 * s_size + 0.22 * s_lab + shaped = math.log1p(3.0 * score) / math.log1p(3.0) + return magnitude_base + (magnitude_max - magnitude_base) * shaped + + +def _spread_angles( + angles: Sequence[float], + *, + min_sep_rad: float = _MIN_ANGLE_SEP, +) -> list[float]: + """Keep circular order; enforce minimum separation. Do NOT rotate the fan.""" + n = len(angles) + if n <= 1: + return list(angles) + + order = sorted(range(n), key=angles.__getitem__) + sorted_angles = [angles[i] for i in order] + unwrapped = [sorted_angles[0]] + for a in sorted_angles[1:]: + cand = a + while cand < unwrapped[-1]: + cand += 2.0 * math.pi + unwrapped.append(cand) + + span = unwrapped[-1] - unwrapped[0] + needed = (n - 1) * min_sep_rad + out = [0.0] * n + + if span + 1e-9 >= needed and span > 1e-9: + placed = [unwrapped[0]] + for a in unwrapped[1:]: + prev = placed[-1] + placed.append(a if (a - prev) >= min_sep_rad else prev + min_sep_rad) + for k, i in enumerate(order): + out[i] = placed[k] % (2.0 * math.pi) + return out + + s = sum(math.sin(a) for a in angles) + c = sum(math.cos(a) for a in angles) + mean = math.atan2(s, c) + half = 0.5 * max(needed, min_sep_rad) + for k, i in enumerate(order): + t = 0.0 if n == 1 else k / (n - 1) + out[i] = (mean - half + 2.0 * half * t) % (2.0 * math.pi) + return out + + +def _bbox_magnitude( + mag: float, + ang: float, + label_len: int, + *, + magnitude_max: float, +) -> float: + half_w = min(0.16, 0.0026 * max(label_len, 1)) + half_h = 0.032 if label_len > 24 else 0.018 + need = abs(math.cos(ang)) * half_w + abs(math.sin(ang)) * half_h + return min(magnitude_max, max(mag, need)) + + +def _obstacle_clearance( + ang: float, + *, + cx: float, + cy: float, + mag: float, + label_len: int, + obstacles: Sequence[tuple[float, float, float]], +) -> float: + if not obstacles: + return 1.0 + + half_w = min(0.14, 0.0024 * max(label_len, 1)) + half_h = 0.028 if label_len > 24 else 0.016 + lx = cx + mag * math.cos(ang) + ly = cy + mag * math.sin(ang) + ux, uy = math.cos(ang), math.sin(ang) + px, py = -uy, ux + samples = ( + (lx, ly), + (lx + ux * half_w * 0.5, ly + uy * half_h * 0.5), + (lx + px * half_w, ly + py * half_h), + (lx - px * half_w, ly - py * half_h), + ) + best = float("inf") + for ox, oy, weight in obstacles: + for sx, sy in samples: + best = min(best, math.hypot(sx - ox, sy - oy) / max(weight, 0.35)) + return best + + +def _orient_coincident_fan( + n: int, + mags: Sequence[float], + label_lens: Sequence[int], + *, + cx: float, + cy: float, + obstacles: Sequence[tuple[float, float, float]], +) -> list[float]: + """Uniform 2π/n fan for coincident cores; longest labels → most vertical slots.""" + if n <= 0: + return [] + + def assign(slots: Sequence[float]) -> list[float]: + slot_order = sorted(range(n), key=lambda i: abs(math.sin(slots[i])), reverse=True) + label_order = sorted(range(n), key=lambda i: label_lens[i], reverse=True) + out = [0.0] * n + for slot_i, label_i in zip(slot_order, label_order): + out[label_i] = slots[slot_i] + return out + + base = [2.0 * math.pi * k / n for k in range(n)] + seed = math.pi / (2.0 * n) + best = assign([(b + seed) % (2.0 * math.pi) for b in base]) + best_score: tuple[float, float, float] | None = None + + for step in range(24): + rot = seed + step * (2.0 * math.pi / 24.0) + angs = assign([(b + rot) % (2.0 * math.pi) for b in base]) + horiz_pen = sum( + (max(0.0, label_lens[i] - 16) / 36.0) * (abs(math.cos(angs[i])) ** 2) for i in range(n) + ) + long_verts = [abs(math.sin(angs[i])) for i in range(n) if label_lens[i] >= 20] + min_vert = min(long_verts) if long_verts else min(abs(math.sin(a)) for a in angs) + clear = ( + min( + _obstacle_clearance( + angs[i], + cx=cx, + cy=cy, + mag=mags[i], + label_len=label_lens[i], + obstacles=obstacles, + ) + for i in range(n) + ) + if obstacles + else 1.0 + ) + score = (min_vert, -horiz_pen, clear) + if best_score is None or score > best_score: + best_score = score + best = angs + return best + + +def hybrid_cluster_offsets( + xs: Sequence[float], + ys: Sequence[float], + labels: Sequence[str] | None = None, + *, + cluster_eps: float = _DEFAULT_CLUSTER_EPS, + min_cluster_size: int = _DEFAULT_MIN_CLUSTER, + magnitude_base: float = _DEFAULT_MAG_BASE, + magnitude_max: float = _DEFAULT_MAG_MAX, + magnitude: float | None = None, + avoid_outsiders: bool = True, +) -> list[OffsetOrAuto]: + """Hybrid factorial offsets: radial only on clusters; isolates → None. + + * Elongated / chain clusters: direction = point − centroid (do not rotate fan). + * Near-coincident cores: uniform fan; long labels on vertical slots. + * Returned Y is already flipped for PowerPoint (positive = downward). + """ + n = len(xs) + offsets: list[OffsetOrAuto] = [None] * n + if n == 0: + return offsets + + if magnitude is not None: + magnitude_base = magnitude_max = float(magnitude) + + if labels is not None and len(labels) != n: + raise ValueError("labels must have the same length as xs/ys.") + label_lens = [len(str(labels[i])) if labels is not None else _REF_LABEL_LEN for i in range(n)] + + xn, yn = _normalize_xy(xs, ys) + + for members in cluster_scatter_points(xs, ys, cluster_eps=cluster_eps): + if len(members) < min_cluster_size: + continue + + cx = sum(xn[i] for i in members) / len(members) + cy = sum(yn[i] for i in members) / len(members) + member_set = set(members) + + obstacles: list[tuple[float, float, float]] = [] + if avoid_outsiders: + for j in range(n): + if j in member_set: + continue + weight = 0.55 + min(0.7, label_lens[j] / 40.0) + obstacles.append((xn[j], yn[j], weight)) + + angles: list[float] = [] + dist_c: list[float] = [] + dist_nn: list[float] = [] + for local_i, i in enumerate(members): + dx, dy = xn[i] - cx, yn[i] - cy + r = math.hypot(dx, dy) + dist_c.append(r) + dist_nn.append(_nearest_in_cluster(i, members, xn, yn)) + if r < 1e-9: + angles.append((2.0 * math.pi * local_i) / len(members)) + else: + angles.append(math.atan2(dy, dx)) + + coincident = max(dist_c) < _COINCIDENT_RADIUS + lens_local = [label_lens[i] for i in members] + mags = [ + _adaptive_magnitude( + dist_centroid=dist_c[k], + dist_neighbor=dist_nn[k], + cluster_size=len(members), + label_len=lens_local[k], + cluster_eps=cluster_eps, + magnitude_base=magnitude_base, + magnitude_max=magnitude_max, + ) + for k in range(len(members)) + ] + + if coincident: + angles = _orient_coincident_fan( + len(members), + mags, + lens_local, + cx=cx, + cy=cy, + obstacles=obstacles, + ) + else: + angles = _spread_angles(angles) + + for local_i, i in enumerate(members): + ang = angles[local_i] + mag = _bbox_magnitude( + mags[local_i], ang, lens_local[local_i], magnitude_max=magnitude_max + ) + # PowerPoint layout Y grows downward. + offsets[i] = (mag * math.cos(ang), -mag * math.sin(ang)) + + return offsets diff --git a/src/pptx/chart/datalabel.py b/src/pptx/chart/datalabel.py index af7cdf5c0..f48b9429a 100644 --- a/src/pptx/chart/datalabel.py +++ b/src/pptx/chart/datalabel.py @@ -2,6 +2,8 @@ from __future__ import annotations +from pptx.chart._scatter_label_layout import hybrid_cluster_offsets +from pptx.oxml.ns import qn from pptx.text.text import Font, TextFrame from pptx.util import lazyproperty @@ -140,6 +142,100 @@ def show_value(self, value): self._element.get_or_add_showVal().val = bool(value) +class XyDataLabels(DataLabels): + """Data labels for an XY (scatter) series. + + Extends |DataLabels| with cell-linked labels (Office 2013+): each label's + text is driven from the chart's worksheet, and any displaced label gets a + native leader line -- no manual text boxes or connectors on the slide. + """ + + def apply_hybrid_scatter_offsets( + self, labels, *, workbook_column=3, show_leader_lines=True, **offset_kwargs + ): + """Compute hybrid cluster offsets for this series and apply them. + + Convenience wrapper that reads this series' X/Y values from the chart, + calls :func:`hybrid_cluster_offsets` (forwarding keywords such as + ``magnitude`` or ``cluster_eps`` in *offset_kwargs*), and hands the + result to :meth:`set_values_from_cells`. + """ + ser = self._scatter_ser() + xs = self._point_values(ser.xVal) + ys = self._point_values(ser.yVal) + offsets = hybrid_cluster_offsets(xs, ys, labels, **offset_kwargs) + self.set_values_from_cells( + labels, + workbook_column=workbook_column, + layout_offsets=offsets, + show_leader_lines=show_leader_lines, + ) + + def set_values_from_cells( + self, labels, *, workbook_column=3, layout_offsets=None, show_leader_lines=True + ): + """Show *labels* as cell-linked scatter data labels. + + *labels* must have one entry per data point. *layout_offsets*, when + provided, is a same-length sequence in which each item is either |None| + (let PowerPoint place that label automatically) or an ``(dx, dy)`` + factorial offset; only the offset points receive an individual `c:dLbl` + with a leader line. Positive *dy* moves a label downward. *workbook_column* + (1-based) selects the worksheet column referenced by the label range. + """ + labels = [str(label) for label in labels] + if not labels: + raise ValueError("labels must contain at least one label") + ser = self._scatter_ser() + point_count = ser.yVal_ptCount_val + if point_count and len(labels) != point_count: + raise ValueError( + "labels has %d entries but series has %d points" % (len(labels), point_count) + ) + if layout_offsets is not None and len(layout_offsets) != len(labels): + raise ValueError("layout_offsets must be the same length as labels") + + self._element.use_cell_range_labels(show_leader_lines=show_leader_lines) + ser.set_datalabels_range(self._range_ref(workbook_column, len(labels)), labels) + + if layout_offsets is None: + return + for idx, offset in enumerate(layout_offsets): + if offset is None: + continue + x, y = float(offset[0]), float(offset[1]) + if abs(x) < 1e-9 and abs(y) < 1e-9: + continue + self._element.add_cell_range_dLbl(idx, x, y, labels[idx]) + + @staticmethod + def _point_values(num_data_source): + """List of the float values cached in a `c:xVal`/`c:yVal` numeric source.""" + if num_data_source is None: + return [] + return [num_data_source.pt_v(idx) for idx in range(num_data_source.ptCount_val)] + + @staticmethod + def _range_ref(column, count): + """Worksheet reference like ``Sheet1!$C$2:$C${count+1}`` for *column*.""" + letters = "" + col = column + while col: + col, rem = divmod(col - 1, 26) + letters = chr(ord("A") + rem) + letters + return "Sheet1!$%s$2:$%s$%d" % (letters, letters, count + 1) + + def _scatter_ser(self): + """The `c:ser` element owning these labels; guard against non-scatter.""" + ser = self._element.getparent() + xChart = None if ser is None else ser.getparent() + if xChart is None: + raise ValueError("data labels are not attached to a series") + if xChart.tag != qn("c:scatterChart"): + raise NotImplementedError("cell-linked labels are only supported on XY scatter charts") + return ser + + class DataLabel(object): """ The data label associated with an individual data point. diff --git a/src/pptx/chart/series.py b/src/pptx/chart/series.py index 16112eabe..c8c06c214 100644 --- a/src/pptx/chart/series.py +++ b/src/pptx/chart/series.py @@ -4,7 +4,7 @@ from collections.abc import Sequence -from pptx.chart.datalabel import DataLabels +from pptx.chart.datalabel import DataLabels, XyDataLabels from pptx.chart.marker import Marker from pptx.chart.point import BubblePoints, CategoryPoints, XyPoints from pptx.dml.chtfmt import ChartFormat @@ -171,6 +171,11 @@ class XySeries(_BaseSeries, _MarkerMixin): A data point series belonging to an XY (scatter) plot. """ + @lazyproperty + def data_labels(self): + """|XyDataLabels| object controlling the data labels for this series.""" + return XyDataLabels(self._ser.get_or_add_dLbls()) + def iter_values(self): """ Generate each float Y value in this series, in the order they appear diff --git a/src/pptx/oxml/__init__.py b/src/pptx/oxml/__init__.py index da2aeb9d3..7b58adcb5 100644 --- a/src/pptx/oxml/__init__.py +++ b/src/pptx/oxml/__init__.py @@ -207,7 +207,9 @@ def register_element_cls(nsptagname: str, cls: Type[BaseOxmlElement]): register_element_cls("c:order", CT_UnsignedInt) register_element_cls("c:overlay", CT_Boolean_Explicit) register_element_cls("c:ptCount", CT_UnsignedInt) +register_element_cls("c:showBubbleSize", CT_Boolean_Explicit) register_element_cls("c:showCatName", CT_Boolean_Explicit) +register_element_cls("c:showLeaderLines", CT_Boolean_Explicit) register_element_cls("c:showLegendKey", CT_Boolean_Explicit) register_element_cls("c:showPercent", CT_Boolean_Explicit) register_element_cls("c:showSerName", CT_Boolean_Explicit) diff --git a/src/pptx/oxml/chart/datalabel.py b/src/pptx/oxml/chart/datalabel.py index b6aac2fd5..0ba17c5b2 100644 --- a/src/pptx/oxml/chart/datalabel.py +++ b/src/pptx/oxml/chart/datalabel.py @@ -2,9 +2,12 @@ from __future__ import annotations +from uuid import uuid4 +from xml.sax.saxutils import escape + from pptx.enum.chart import XL_DATA_LABEL_POSITION from pptx.oxml import parse_xml -from pptx.oxml.ns import nsdecls +from pptx.oxml.ns import nsdecls, qn from pptx.oxml.text import CT_TextBody from pptx.oxml.xmlchemy import ( BaseOxmlElement, @@ -14,6 +17,28 @@ ZeroOrOne, ) +# -- URIs of the Office 2013 (`c15`) chart extensions used for cell-linked +# scatter data labels; the `c15` namespace is declared in `ns.py`. The +# cache-only design tradeoff is documented on `set_datalabels_range`. -- +_LABEL_RANGE_URI = "{02D57815-91ED-43cb-92C2-25804820EDAC}" +_SHOW_RANGE_URI = "{CE6537A1-D6FC-4f65-9D91-7224C49458BB}" + + +def replace_ext(extLst, uri, *children): + """Replace the `c:ext` having *uri* under *extLst* with a fresh one. + + Any `c:ext` whose `uri` differs is left untouched, so unknown extensions + survive a round-trip. The new `c:ext` receives *children* in order. + """ + for ext in extLst.findall(qn("c:ext")): + if ext.get("uri") == uri: + extLst.remove(ext) + ext = parse_xml('' % (nsdecls("c"), uri)) + for child in children: + ext.append(child) + extLst.append(ext) + return ext + class CT_DLbl(BaseOxmlElement): """ @@ -43,6 +68,7 @@ class CT_DLbl(BaseOxmlElement): spPr = ZeroOrOne("c:spPr", successors=_tag_seq[5:]) txPr = ZeroOrOne("c:txPr", successors=_tag_seq[6:]) dLblPos = ZeroOrOne("c:dLblPos", successors=_tag_seq[7:]) + extLst = ZeroOrOne("c:extLst", successors=()) del _tag_seq def get_or_add_rich(self): @@ -104,6 +130,47 @@ def new_dLbl(cls): "" % nsdecls("c", "a") ) + @classmethod + def new_cell_range_dLbl(cls, idx, x, y, text): + """Return a loose `c:dLbl` for a displaced cell-linked scatter label. + + The label is manually positioned at factorial offset (*x*, *y*) and its + text comes from a `CELLRANGE` field (linked to the worksheet), matching + what PowerPoint writes when a data label is dragged. The caller is + responsible for adding the `c15:showDataLabelsRange` extension. + """ + return parse_xml( + "\n" + ' \n' + " \n" + " \n" + ' \n' + ' \n' + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + " \n" + ' \n' + ' \n' + " \n" + " %s\n" + " \n" + ' \n' + " \n" + " \n" + " \n" + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + "" % (nsdecls("c", "a"), idx, x, y, str(uuid4()).upper(), escape(str(text))) + ) + def remove_tx_rich(self): """ Remove any `c:tx[c:rich]` child, or do nothing if not present. @@ -156,6 +223,9 @@ class CT_DLbls(BaseOxmlElement): showCatName = ZeroOrOne("c:showCatName", successors=_tag_seq[8:]) showSerName = ZeroOrOne("c:showSerName", successors=_tag_seq[9:]) showPercent = ZeroOrOne("c:showPercent", successors=_tag_seq[10:]) + showBubbleSize = ZeroOrOne("c:showBubbleSize", successors=_tag_seq[11:]) + showLeaderLines = ZeroOrOne("c:showLeaderLines", successors=_tag_seq[13:]) + extLst = ZeroOrOne("c:extLst", successors=()) del _tag_seq @property @@ -188,6 +258,56 @@ def get_or_add_dLbl_for_point(self, idx): return matches[0] return self._insert_dLbl_in_sequence(idx) + def add_cell_range_dLbl(self, idx, x, y, text): + """Add a displaced cell-linked `c:dLbl` for point *idx*, in `c:idx` order. + + The new `c:dLbl` carries a `manualLayout` at factorial offset (*x*, *y*), + a `CELLRANGE` field showing *text*, and the `c15:dlblFieldTable` / + `c15:showDataLabelsRange` extension PowerPoint expects on such a label. + """ + dLbl = CT_DLbl.new_cell_range_dLbl(idx, x, y, text) + replace_ext( + dLbl.get_or_add_extLst(), + _SHOW_RANGE_URI, + parse_xml("" % nsdecls("c15")), + parse_xml('' % nsdecls("c15")), + ) + siblings = self.dLbl_lst + for existing in siblings: + if existing.idx_val > idx: + existing.addprevious(dLbl) + return dLbl + if siblings: + siblings[-1].addnext(dLbl) + else: + self.insert(0, dLbl) + return dLbl + + def use_cell_range_labels(self, show_leader_lines=True): + """Configure this `c:dLbls` for cell-linked scatter labels. + + Turns off every built-in label content, sets leader lines per + *show_leader_lines*, and installs the `c15:showDataLabelsRange` / + `c15:showLeaderLines` extension. Existing per-point `c:dLbl` children + are removed so the caller starts from a clean slate. + """ + for dLbl in self.dLbl_lst: + self.remove(dLbl) + self.get_or_add_showLegendKey().val = False + self.get_or_add_showVal().val = False + self.get_or_add_showCatName().val = False + self.get_or_add_showSerName().val = False + self.get_or_add_showPercent().val = False + self.get_or_add_showBubbleSize().val = False + self.get_or_add_showLeaderLines().val = bool(show_leader_lines) + leader = "1" if show_leader_lines else "0" + replace_ext( + self.get_or_add_extLst(), + _SHOW_RANGE_URI, + parse_xml('' % nsdecls("c15")), + parse_xml('' % (nsdecls("c15"), leader)), + ) + @classmethod def new_dLbls(cls): """Return a newly created "loose" `c:dLbls` element.""" diff --git a/src/pptx/oxml/chart/series.py b/src/pptx/oxml/chart/series.py index 9264d552d..edc3bc526 100644 --- a/src/pptx/oxml/chart/series.py +++ b/src/pptx/oxml/chart/series.py @@ -2,7 +2,11 @@ from __future__ import annotations -from pptx.oxml.chart.datalabel import CT_DLbls +from xml.sax.saxutils import escape + +from pptx.oxml import parse_xml +from pptx.oxml.chart.datalabel import _LABEL_RANGE_URI, CT_DLbls, replace_ext +from pptx.oxml.ns import nsdecls from pptx.oxml.simpletypes import XsdUnsignedInt from pptx.oxml.xmlchemy import ( BaseOxmlElement, @@ -149,6 +153,7 @@ class CT_SeriesComposite(BaseOxmlElement): yVal = ZeroOrOne("c:yVal", successors=_tag_seq[16:]) smooth = ZeroOrOne("c:smooth", successors=_tag_seq[18:]) bubbleSize = ZeroOrOne("c:bubbleSize", successors=_tag_seq[19:]) + extLst = ZeroOrOne("c:extLst", successors=()) del _tag_seq @property @@ -203,6 +208,38 @@ def get_or_add_dPt_for_point(self, idx): dPt.idx.val = idx return dPt + def set_datalabels_range(self, formula, labels): + """Install/replace the `c15:datalabelsRange` extension for this series. + + *formula* is the worksheet reference (e.g. ``Sheet1!$C$2:$C$4``) written + to `c15:f`; the `c15:dlblRangeCache` is populated from *labels*. + + This is deliberately **cache-only**. Such a label carries its text in two + places -- a `c15:f` formula pointing at a worksheet range and a + `c15:dlblRangeCache` holding the literal values -- and PowerPoint renders + from the cache. We write only the cache plus a plausible formula; we do + *not* write the text into the chart's embedded workbook, since that would + require a read-modify-write of the `.xlsx` (an ``openpyxl`` dependency + that python-pptx deliberately avoids -- it ships only write-only + ``XlsxWriter``). Consequence: labels display and stay editable in + PowerPoint, but the linked cells read as empty under "Edit Data in + Excel". Any unrelated extension on the series is preserved. + """ + pts = "".join( + ' %s\n' % (i, escape(str(label))) + for i, label in enumerate(labels) + ) + data_range = parse_xml( + "\n" + " %s\n" + " \n" + ' \n' + "%s" + " \n" + "" % (nsdecls("c15", "c"), escape(formula), len(labels), pts) + ) + replace_ext(self.get_or_add_extLst(), _LABEL_RANGE_URI, data_range) + @property def xVal_ptCount_val(self): """ diff --git a/src/pptx/oxml/ns.py b/src/pptx/oxml/ns.py index 864e5e758..9fb91b2a7 100644 --- a/src/pptx/oxml/ns.py +++ b/src/pptx/oxml/ns.py @@ -8,6 +8,7 @@ "a": "http://schemas.openxmlformats.org/drawingml/2006/main", "c": "http://schemas.openxmlformats.org/drawingml/2006/chart", "cp": "http://schemas.openxmlformats.org/package/2006/metadata/core-properties", + "c15": "http://schemas.microsoft.com/office/drawing/2012/chart", "cs": "http://schemas.microsoft.com/office/drawing/2012/chartStyle", "cx": "http://schemas.microsoft.com/office/drawing/2014/chartex", "ct": "http://schemas.openxmlformats.org/package/2006/content-types", diff --git a/tests/chart/test_datalabel_ext.py b/tests/chart/test_datalabel_ext.py new file mode 100644 index 000000000..c7b17c829 --- /dev/null +++ b/tests/chart/test_datalabel_ext.py @@ -0,0 +1,145 @@ +"""Integration tests for XyDataLabels cell-linked scatter labels. + +Exercises the OOXML writer end-to-end on a real scatter chart, asserting the +`c15:datalabelsRange`, native `c:dLbls` flags, per-point `c:dLbl` manual layout, +and round-trip preservation of unknown extensions. +""" + +import pytest + +from pptx import Presentation +from pptx.chart.data import BubbleChartData, XyChartData +from pptx.chart.datalabel import XyDataLabels +from pptx.enum.chart import XL_CHART_TYPE +from pptx.oxml import parse_xml +from pptx.oxml.ns import nsdecls, qn +from pptx.util import Inches + +LABELS = ["Alpha", "Beta", "Gamma"] +XS = [0.20, 0.201, 0.24] # Alpha & Beta cluster; Gamma isolated +YS = [0.03, 0.031, -0.08] + + +def _scatter_series(chart_type=XL_CHART_TYPE.XY_SCATTER): + data = XyChartData() + series = data.add_series("S") + for x, y in zip(XS, YS): + series.add_data_point(x, y) + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[5]) + gframe = slide.shapes.add_chart(chart_type, Inches(1), Inches(1), Inches(5), Inches(4), data) + return gframe.chart.series[0] + + +class DescribeXyDataLabelsSetValuesFromCells: + def it_is_the_data_labels_type_for_a_scatter_series(self): + assert isinstance(_scatter_series().data_labels, XyDataLabels) + + def it_writes_a_datalabels_range_with_a_value_cache(self): + series = _scatter_series() + series.data_labels.set_values_from_cells(LABELS) + data_range = series._element.find( + "%s/%s/%s" % (qn("c:extLst"), qn("c:ext"), qn("c15:datalabelsRange")) + ) + assert data_range is not None + assert data_range.find(qn("c15:f")).text == "Sheet1!$C$2:$C$4" + cache = data_range.find(qn("c15:dlblRangeCache")) + assert cache.find(qn("c:ptCount")).get("val") == "3" + assert [pt.find(qn("c:v")).text for pt in cache.findall(qn("c:pt"))] == LABELS + + def it_configures_native_dLbls_flags_and_leader_lines(self): + dLbls = _scatter_series().data_labels + dLbls.set_values_from_cells(LABELS, show_leader_lines=True) + el = dLbls._element + assert el.find(qn("c:showVal")).get("val") == "0" + assert el.find(qn("c:showLeaderLines")).get("val") == "1" + ext = el.find("%s/%s" % (qn("c:extLst"), qn("c:ext"))) + assert ext.find(qn("c15:showDataLabelsRange")).get("val") == "1" + assert ext.find(qn("c15:showLeaderLines")).get("val") == "1" + + def it_adds_a_dLbl_only_for_offset_points(self): + series = _scatter_series() + offsets = [(0.05, -0.06), None, (0.0, 0.0)] + series.data_labels.set_values_from_cells(LABELS, layout_offsets=offsets) + dLbls = series.data_labels._element + dLbl_lst = dLbls.findall(qn("c:dLbl")) + assert len(dLbl_lst) == 1 + dLbl = dLbl_lst[0] + assert dLbl.find(qn("c:idx")).get("val") == "0" + manual = dLbl.find("%s/%s" % (qn("c:layout"), qn("c:manualLayout"))) + assert float(manual.find(qn("c:x")).get("val")) == pytest.approx(0.05) + assert float(manual.find(qn("c:y")).get("val")) == pytest.approx(-0.06) + fld = dLbl.find("%s/%s/%s/%s" % (qn("c:tx"), qn("c:rich"), qn("a:p"), qn("a:fld"))) + assert fld.get("type") == "CELLRANGE" + + def it_can_switch_leader_lines_off(self): + dLbls = _scatter_series().data_labels + dLbls.set_values_from_cells(LABELS, show_leader_lines=False) + assert dLbls._element.find(qn("c:showLeaderLines")).get("val") == "0" + + def it_preserves_unknown_extensions_on_round_trip(self): + series = _scatter_series() + extLst = series._element.get_or_add_extLst() + extLst.append(parse_xml('' % nsdecls("c"))) + series.data_labels.set_values_from_cells(LABELS) + uris = [e.get("uri") for e in series._element.find(qn("c:extLst"))] + assert "{DEAD-BEEF}" in uris + assert "{02D57815-91ED-43cb-92C2-25804820EDAC}" in uris + + def it_uses_the_requested_workbook_column(self): + series = _scatter_series() + series.data_labels.set_values_from_cells(LABELS, workbook_column=5) + f = series._element.find( + "%s/%s/%s/%s" % (qn("c:extLst"), qn("c:ext"), qn("c15:datalabelsRange"), qn("c15:f")) + ) + assert f.text == "Sheet1!$E$2:$E$4" + + def it_rejects_a_label_count_mismatch(self): + series = _scatter_series() + with pytest.raises(ValueError): + series.data_labels.set_values_from_cells(["only", "two"]) + + def it_rejects_a_layout_offsets_length_mismatch(self): + series = _scatter_series() + with pytest.raises(ValueError): + series.data_labels.set_values_from_cells(LABELS, layout_offsets=[None]) + + def it_rejects_an_empty_label_list(self): + series = _scatter_series() + with pytest.raises(ValueError): + series.data_labels.set_values_from_cells([]) + + def it_is_not_supported_on_bubble_series(self): + data = BubbleChartData() + series = data.add_series("S") + for x, y in zip(XS, YS): + series.add_data_point(x, y, 1) + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[5]) + gframe = slide.shapes.add_chart( + XL_CHART_TYPE.BUBBLE, Inches(1), Inches(1), Inches(5), Inches(4), data + ) + with pytest.raises(NotImplementedError): + gframe.chart.series[0].data_labels.set_values_from_cells(LABELS) + + +class DescribeXyDataLabelsApplyHybridScatterOffsets: + def it_reads_series_values_and_applies_offsets(self): + series = _scatter_series() + series.data_labels.apply_hybrid_scatter_offsets(LABELS) + dLbls = series.data_labels._element + # Alpha & Beta cluster get offsets; Gamma is isolated (no c:dLbl). + assert len(dLbls.findall(qn("c:dLbl"))) == 2 + assert ( + dLbls.find("%s/%s/%s" % (qn("c:extLst"), qn("c:ext"), qn("c15:showDataLabelsRange"))) + is not None + ) + + def it_forwards_layout_keywords_to_the_algorithm(self): + series = _scatter_series() + series.data_labels.apply_hybrid_scatter_offsets(LABELS, magnitude=0.065) + idx0 = series.data_labels._element.find(qn("c:dLbl")) + manual = idx0.find("%s/%s" % (qn("c:layout"), qn("c:manualLayout"))) + x = float(manual.find(qn("c:x")).get("val")) + y = float(manual.find(qn("c:y")).get("val")) + assert (x**2 + y**2) ** 0.5 == pytest.approx(0.065, abs=1e-6) diff --git a/tests/chart/test_scatter_label_layout.py b/tests/chart/test_scatter_label_layout.py new file mode 100644 index 000000000..71fee0798 --- /dev/null +++ b/tests/chart/test_scatter_label_layout.py @@ -0,0 +1,115 @@ +"""Unit tests for the pure pptx.chart._scatter_label_layout module.""" + +import math + +import pytest + +from pptx.chart._scatter_label_layout import ( + cluster_scatter_points, + hybrid_cluster_offsets, +) + +# --- fixtures (embedded, no PPTX required) ---------------------------------- + +LABELS_PORTFOLIO = [ + "Itau BOVV11 FIC de FIA", + "Ibovespa", + "4UM Marlim Dividendos FIA", + "Fund Core FIA", + "Fund Core FIA I", + "Fund Core FIC FIA", + "Atmos Institucional FIC FIA", + "Fund Core FIA II", + "Tarpon GT", +] +XS_PORTFOLIO = [0.199, 0.192, 0.183, 0.197, 0.209, 0.196, 0.225, 0.246, 0.240] +YS_PORTFOLIO = [0.014, 0.068, 0.060, 0.071, 0.046, 0.022, 0.009, -0.011, -0.110] + +LABELS_LONG = [ + "Fundo Institucional de Ações Dividendos Premium FIC FIA", + "Fundo Institucional de Ações Value Long Bias FIC FIA", + "Fundo Macro Multimercado Crédito Privado FIC FIM", + "Ibovespa", + "CDI", +] +XS_LONG = [0.198, 0.1985, 0.199, 0.240, 0.185] +YS_LONG = [0.040, 0.041, 0.0395, -0.080, 0.070] + +LABELS_CHAIN = [ + "Elo A", + "Elo B intermediário", + "Elo C intermediário longo", + "Elo D", + "Isolado Curto", +] +XS_CHAIN = [0.195, 0.198, 0.201, 0.204, 0.245] +YS_CHAIN = [0.028, 0.031, 0.034, 0.037, -0.090] + + +def _data_angle(offset): + """Recover the data-space angle from a PPT offset (undo the Y flip).""" + ox, oy = offset + return math.atan2(-oy, ox) + + +class DescribeHybridClusterOffsets: + def it_returns_empty_for_no_points(self): + assert hybrid_cluster_offsets([], []) == [] + + def it_leaves_isolated_points_on_automatic_layout(self): + offsets = hybrid_cluster_offsets(XS_PORTFOLIO, YS_PORTFOLIO, LABELS_PORTFOLIO) + active = [o for o in offsets if o is not None] + assert 2 <= len(active) <= 6 + assert any(o is None for o in offsets) + + def it_keeps_chain_members_on_their_centroid_side(self): + offsets = hybrid_cluster_offsets(XS_CHAIN, YS_CHAIN, LABELS_CHAIN) + assert offsets[4] is None + assert offsets[0][0] < 0 + assert offsets[1][0] < 0 + assert offsets[2][0] > 0 + assert offsets[3][0] > 0 + + def it_sends_the_longest_coincident_label_most_vertical(self): + offsets = hybrid_cluster_offsets(XS_LONG, YS_LONG, LABELS_LONG) + active_idx = [i for i, o in enumerate(offsets) if o is not None] + assert len(active_idx) == 3 + sins = {i: abs(math.sin(_data_angle(offsets[i]))) for i in active_idx} + longest = max(active_idx, key=lambda i: len(LABELS_LONG[i])) + assert sins[longest] == max(sins.values()) + + def it_honors_a_fixed_magnitude(self): + offsets = hybrid_cluster_offsets( + XS_PORTFOLIO, YS_PORTFOLIO, LABELS_PORTFOLIO, magnitude=0.065 + ) + mags = {round(math.hypot(*o), 6) for o in offsets if o is not None} + assert mags == {0.065} + + def it_offsets_every_point_in_a_collapsed_cluster(self): + labels = ["Point %d" % i for i in range(5)] + offsets = hybrid_cluster_offsets([0.205] * 5, [0.030] * 5, labels) + assert all(o is not None for o in offsets) + angles = sorted(_data_angle(o) % (2 * math.pi) for o in offsets) + gaps = [b - a for a, b in zip(angles, angles[1:])] + assert all(g > 0.5 for g in gaps) # roughly 2π/5 apart, none collapsed + + def it_raises_when_labels_length_mismatches(self): + with pytest.raises(ValueError): + hybrid_cluster_offsets([0.1, 0.2], [0.1, 0.2], ["only one"]) + + def it_flips_y_for_powerpoint(self): + # in the elongated chain, the point below the centroid is pushed down + # (oy > 0) and the one above is pushed up (oy < 0), per PPT's Y axis. + offsets = hybrid_cluster_offsets(XS_CHAIN, YS_CHAIN, LABELS_CHAIN) + assert offsets[0][1] > 0 # lowest Y → positive (downward) offset + assert offsets[3][1] < 0 # highest Y → negative (upward) offset + + +class DescribeClusterScatterPoints: + def it_groups_near_points_and_splits_far_ones(self): + clusters = cluster_scatter_points([0.10, 0.11, 0.90], [0.10, 0.11, 0.90]) + sizes = sorted(len(c) for c in clusters) + assert sizes == [1, 2] + + def it_returns_empty_for_no_points(self): + assert cluster_scatter_points([], []) == []