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([], []) == []