ablechart 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,614 @@
1
+ """GTM 级图表注释层 — 提炼自国际投行市场指南类报告的签名元素。
2
+
3
+ 覆盖的模式(对应该类报告的惯用手法):
4
+
5
+ - **均值/参考虚线 + 行内彩色标签**("Average"、"Trend growth: 2.9%")
6
+ - **目标区间色带**(RBA Target band 2-3% 灰色横带)
7
+ - **末点圆点 + 日期/数值标注**("Aug '25 4.2%",文字颜色跟随系列色)
8
+ - **预测分隔虚线 + 斜纹预测柱**(2025F/2026F 用 ltUpDiag 斜纹与实际值区分)
9
+ - **柱上数值标签**(原生 dLbls,可条内白字或条端系列色)
10
+ - **单类目高亮**(一组灰柱里把 "S&P 500" 涂成主题色)
11
+
12
+ 几何策略与 waterfall 一致:manualLayout 钉定绘图区 + 读取显式值轴范围,
13
+ slide 级 overlay 与图表元素精确对位。数值标签 / 高亮 / 斜纹用原生 XML
14
+ (dLbls / dPt),保持可编辑性。
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import math
20
+ from typing import Any, Dict, List, Optional, Tuple
21
+
22
+ import pandas as pd
23
+ from lxml import etree
24
+ from pptx.dml.color import RGBColor
25
+ from pptx.enum.shapes import MSO_SHAPE
26
+ from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
27
+ from pptx.util import Inches, Pt
28
+
29
+ from .oxml_ns import NAMESPACES
30
+ from .polish import AXIS_FONT, pin_plot_area
31
+
32
+ C = NAMESPACES["c"]
33
+ A = NAMESPACES["a"]
34
+
35
+ from .tokens import get_chart_token # 颜色真源
36
+ FORECAST_PATTERN = "ltUpDiag"
37
+
38
+
39
+ # ============================================================================
40
+ # 几何:钉定绘图区 + 坐标换算
41
+ # ============================================================================
42
+
43
+ class PlotGeometry:
44
+ """钉定后的绘图区几何,负责 数据值 ↔ 幻灯片英寸 的换算。"""
45
+
46
+ def __init__(self, position, size, fractions, n_categories: int,
47
+ y_left: Optional[Tuple[float, float]],
48
+ y_right: Optional[Tuple[float, float]] = None):
49
+ self.left = _emu_to_inches(position[0])
50
+ self.top = _emu_to_inches(position[1])
51
+ self.width = _emu_to_inches(size[0])
52
+ self.height = _emu_to_inches(size[1])
53
+ fx, fy, fw, fh = fractions
54
+ self.plot_left = self.left + fx * self.width
55
+ self.plot_top = self.top + fy * self.height
56
+ self.plot_width = fw * self.width
57
+ self.plot_height = fh * self.height
58
+ self.n = max(1, n_categories)
59
+ self.y_left = y_left
60
+ self.y_right = y_right
61
+
62
+ def y(self, value: float, axis: str = "left") -> float:
63
+ rng = self.y_right if axis == "right" else self.y_left
64
+ if rng is None:
65
+ raise ValueError(f"{axis} 轴没有显式范围,无法定位注释")
66
+ vmin, vmax = rng
67
+ # 裁剪到轴范围:越界注释不允许画出绘图区
68
+ ratio = max(0.0, min(1.0, (value - vmin) / (vmax - vmin)))
69
+ return self.plot_top + self.plot_height * (1 - ratio)
70
+
71
+ def in_range(self, value: float, axis: str = "left") -> bool:
72
+ rng = self.y_right if axis == "right" else self.y_left
73
+ if rng is None:
74
+ return False
75
+ vmin, vmax = rng
76
+ return vmin <= value <= vmax
77
+
78
+ def x_center(self, index: int) -> float:
79
+ slot = self.plot_width / self.n
80
+ return self.plot_left + (index + 0.5) * slot
81
+
82
+ def x_slot_left(self, index: int) -> float:
83
+ return self.plot_left + index * (self.plot_width / self.n)
84
+
85
+
86
+ def read_axis_ranges(chart) -> Dict[str, Optional[Tuple[float, float]]]:
87
+ """从 XML scaling 读左右值轴显式范围(polish 保证默认已设置)。"""
88
+ out: Dict[str, Optional[Tuple[float, float]]] = {"left": None, "right": None}
89
+ plot_area = chart._chartSpace.find(f".//{{{C}}}plotArea")
90
+ for ax in plot_area.findall(f"{{{C}}}valAx"):
91
+ pos_el = ax.find(f"{{{C}}}axPos")
92
+ side = "right" if (pos_el is not None and pos_el.get("val") == "r") else "left"
93
+ scaling = ax.find(f"{{{C}}}scaling")
94
+ if scaling is None:
95
+ continue
96
+ vmin = scaling.find(f"{{{C}}}min")
97
+ vmax = scaling.find(f"{{{C}}}max")
98
+ if vmin is not None and vmax is not None:
99
+ out[side] = (float(vmin.get("val")), float(vmax.get("val")))
100
+ return out
101
+
102
+
103
+ def combo_plot_fractions(chart, *, has_secondary: bool) -> Tuple[float, float, float, float]:
104
+ """combo 图钉定比例:按标题/图例位置留边。"""
105
+ top, bottom = 0.06, 0.14
106
+ if chart.has_title:
107
+ top += 0.11
108
+ if len(chart.chart_title.text_frame.paragraphs) > 1:
109
+ top += 0.05 # 副标题行
110
+ try:
111
+ legend_pos = chart.legend.position if chart.has_legend else None
112
+ except Exception:
113
+ legend_pos = None
114
+ if legend_pos is not None:
115
+ from pptx.enum.chart import XL_LEGEND_POSITION
116
+ if legend_pos == XL_LEGEND_POSITION.TOP:
117
+ top += 0.08
118
+ elif legend_pos == XL_LEGEND_POSITION.BOTTOM:
119
+ bottom += 0.08
120
+ left = 0.085
121
+ right = 0.075 if has_secondary else 0.025
122
+ return (left, top, 1.0 - left - right, 1.0 - top - bottom)
123
+
124
+
125
+ # ============================================================================
126
+ # 注释渲染(slide overlay)
127
+ # ============================================================================
128
+
129
+ def apply_annotations(
130
+ chart,
131
+ slide,
132
+ *,
133
+ df: pd.DataFrame,
134
+ categories_col: str,
135
+ series_config: List[Dict],
136
+ annotations: List[Dict],
137
+ position,
138
+ size,
139
+ date_format: str = "%Y/%m",
140
+ ) -> None:
141
+ """钉定绘图区并渲染注释列表。需在 polish(显式轴范围)之后调用。"""
142
+ if not annotations:
143
+ return
144
+
145
+ ranges = read_axis_ranges(chart)
146
+ has_secondary = ranges["right"] is not None
147
+ fractions = combo_plot_fractions(chart, has_secondary=has_secondary)
148
+ pin_plot_area(chart, x=fractions[0], y=fractions[1], w=fractions[2], h=fractions[3])
149
+
150
+ geo = PlotGeometry(position, size, fractions, len(df), ranges["left"], ranges["right"])
151
+
152
+ for ann in annotations:
153
+ kind = str(ann.get("type", "hline")).lower()
154
+ if kind in ("hline", "average", "reference"):
155
+ _draw_hline(slide, geo, ann)
156
+ elif kind == "band":
157
+ _draw_band(slide, geo, ann)
158
+ elif kind == "vline":
159
+ _draw_vline(slide, geo, ann, df, categories_col)
160
+ elif kind == "vband":
161
+ _draw_vband(slide, geo, ann, df, categories_col)
162
+ elif kind == "last_point":
163
+ _draw_last_point(chart, slide, geo, ann, df, series_config, date_format)
164
+
165
+
166
+ def _draw_hline(slide, geo: PlotGeometry, ann: Dict) -> None:
167
+ axis = "right" if str(ann.get("axis", "left")).lower() in ("right", "secondary", "y2") else "left"
168
+ value = float(ann["value"])
169
+ if not geo.in_range(value, axis):
170
+ return # 越界参考线直接跳过,避免画在图外
171
+ color = ann.get("color") or get_chart_token("annotation_line")
172
+ dashed = str(ann.get("style", "dashed")).lower() != "solid"
173
+ y = geo.y(value, axis)
174
+
175
+ _add_line(slide, geo.plot_left, y, geo.plot_left + geo.plot_width, y,
176
+ color=color, width_pt=1.25, dashed=dashed)
177
+
178
+ label = ann.get("label")
179
+ if label:
180
+ # 标签贴线上方,默认偏右(GTM 惯例),颜色与线一致
181
+ at = str(ann.get("label_at", "right")).lower()
182
+ x = {"left": geo.plot_left + 0.05,
183
+ "center": geo.plot_left + geo.plot_width / 2 - 0.8,
184
+ "right": geo.plot_left + geo.plot_width - 1.85}.get(at, geo.plot_left + geo.plot_width - 1.85)
185
+ _add_label(slide, str(label), x, y - 0.215, 1.8, 0.18,
186
+ color=color, bold=True, align=PP_ALIGN.RIGHT if at == "right" else PP_ALIGN.LEFT)
187
+
188
+
189
+ def _draw_band(slide, geo: PlotGeometry, ann: Dict) -> None:
190
+ axis = "right" if str(ann.get("axis", "left")).lower() in ("right", "secondary", "y2") else "left"
191
+ lo = float(ann.get("from", ann.get("low")))
192
+ hi = float(ann.get("to", ann.get("high")))
193
+ if not (geo.in_range(lo, axis) or geo.in_range(hi, axis)):
194
+ return # 整个区间都在轴范围外 → 跳过(y() 同时兜底裁剪部分越界)
195
+ color = ann.get("color") or get_chart_token("band")
196
+ alpha = int(ann.get("alpha", 30))
197
+ y_top = geo.y(max(lo, hi), axis)
198
+ y_bot = geo.y(min(lo, hi), axis)
199
+
200
+ shape = slide.shapes.add_shape(
201
+ MSO_SHAPE.RECTANGLE,
202
+ Inches(geo.plot_left), Inches(y_top),
203
+ Inches(geo.plot_width), Inches(max(y_bot - y_top, 0.02)),
204
+ )
205
+ shape.fill.solid()
206
+ shape.fill.fore_color.rgb = RGBColor.from_string(_norm_hex(color))
207
+ shape.line.fill.background()
208
+ shape.shadow.inherit = False
209
+ _set_shape_alpha(shape, alpha)
210
+
211
+ label = ann.get("label")
212
+ if label:
213
+ _add_label(slide, str(label), geo.plot_left + 0.06, y_top + 0.03, 1.6, 0.18,
214
+ color=get_chart_token("subtitle"), bold=True, align=PP_ALIGN.LEFT)
215
+
216
+
217
+ def _draw_vline(slide, geo: PlotGeometry, ann: Dict, df, categories_col) -> None:
218
+ index = ann.get("index")
219
+ if index is None and ann.get("category") is not None:
220
+ matches = df.index[df[categories_col].astype(str) == str(ann["category"])].tolist()
221
+ index = matches[0] if matches else None
222
+ if index is None:
223
+ return
224
+ x = geo.x_slot_left(int(index))
225
+ color = ann.get("color") or get_chart_token("subtitle")
226
+ dashed = str(ann.get("style", "dashed")).lower() != "solid"
227
+ _add_line(slide, x, geo.plot_top, x, geo.plot_top + geo.plot_height,
228
+ color=color, width_pt=1.0, dashed=dashed)
229
+ label = ann.get("label")
230
+ if label:
231
+ _add_label(slide, str(label), x + 0.04, geo.plot_top, 1.2, 0.18,
232
+ color=color, bold=False, align=PP_ALIGN.LEFT)
233
+
234
+
235
+ def _draw_vband(slide, geo: PlotGeometry, ann: Dict, df, categories_col) -> None:
236
+ """竖向阴影带(GTM 的衰退期/事件期灰色竖带)。"""
237
+ def to_index(key_cat, key_idx):
238
+ idx = ann.get(key_idx)
239
+ if idx is None and ann.get(key_cat) is not None:
240
+ matches = df.index[df[categories_col].astype(str) == str(ann[key_cat])].tolist()
241
+ idx = matches[0] if matches else None
242
+ return idx
243
+
244
+ i_from = to_index("from_category", "from_index")
245
+ i_to = to_index("to_category", "to_index")
246
+ if i_from is None and i_to is None:
247
+ return
248
+ i_from = int(i_from if i_from is not None else 0)
249
+ i_to = int(i_to if i_to is not None else len(df) - 1)
250
+ if i_from > i_to:
251
+ i_from, i_to = i_to, i_from
252
+
253
+ x_left = geo.x_slot_left(i_from)
254
+ x_right = geo.x_slot_left(i_to) + geo.plot_width / geo.n
255
+ color = ann.get("color") or get_chart_token("band")
256
+ alpha = int(ann.get("alpha", 25))
257
+
258
+ shape = slide.shapes.add_shape(
259
+ MSO_SHAPE.RECTANGLE,
260
+ Inches(x_left), Inches(geo.plot_top),
261
+ Inches(max(x_right - x_left, 0.02)), Inches(geo.plot_height),
262
+ )
263
+ shape.fill.solid()
264
+ shape.fill.fore_color.rgb = RGBColor.from_string(_norm_hex(color))
265
+ shape.line.fill.background()
266
+ shape.shadow.inherit = False
267
+ _set_shape_alpha(shape, alpha)
268
+
269
+ label = ann.get("label")
270
+ if label:
271
+ _add_label(slide, str(label), x_left + 0.04, geo.plot_top + 0.02, 1.4, 0.18,
272
+ color=get_chart_token("subtitle"), bold=True, align=PP_ALIGN.LEFT)
273
+
274
+
275
+ def _draw_last_point(chart, slide, geo: PlotGeometry, ann: Dict, df, series_config, date_format) -> None:
276
+ """末点圆点(原生 dPt marker)+ 「日期 + 数值」标注。"""
277
+ series_name = ann.get("series")
278
+ cfg = next((s for s in series_config if s["name"] == series_name or s["key"] == series_name), None)
279
+ if cfg is None:
280
+ return
281
+ values = pd.to_numeric(df[cfg["key"]], errors="coerce")
282
+ idx = int(values.last_valid_index()) if values.last_valid_index() is not None else len(df) - 1
283
+ value = float(values.iloc[idx])
284
+ axis = "right" if cfg.get("axis") == "secondary" else "left"
285
+
286
+ ser = find_series_element(chart, cfg["name"])
287
+ color = ann.get("color") or (_series_color(ser) if ser is not None else None) or get_chart_token("annotation_line")
288
+ if ser is not None and cfg.get("type", "bar") == "line":
289
+ _add_last_point_marker(ser, idx, color)
290
+
291
+ x = geo.x_center(idx)
292
+ y = geo.y(value, axis)
293
+
294
+ cat_value = df.iloc[idx][_categories_col_of(df, series_config)]
295
+ if hasattr(cat_value, "strftime"):
296
+ cat_text = cat_value.strftime(date_format)
297
+ else:
298
+ cat_text = str(cat_value)
299
+ value_text = format_value(value, ann.get("format"))
300
+ text = f"{cat_text} {value_text}" if ann.get("show_category", True) else value_text
301
+
302
+ above = bool(ann.get("above", True))
303
+ label_y = y - 0.30 if above else y + 0.08
304
+ _add_label(slide, text, min(x - 1.0, geo.plot_left + geo.plot_width - 1.6), label_y,
305
+ 1.6, 0.18, color=color, bold=True, align=PP_ALIGN.RIGHT)
306
+
307
+
308
+ def _categories_col_of(df, series_config) -> str:
309
+ keys = {s["key"] for s in series_config}
310
+ for col in df.columns:
311
+ if col not in keys:
312
+ return col
313
+ return df.columns[0]
314
+
315
+
316
+ # ============================================================================
317
+ # 原生 XML 元素:数值标签 / 单点填充 / 斜纹预测 / 图例项隐藏
318
+ # ============================================================================
319
+
320
+ def find_series_element(chart, name: str):
321
+ for ser in chart._chartSpace.findall(f".//{{{C}}}ser"):
322
+ for path in ("c:tx/c:strRef/c:strCache/c:pt/c:v", "c:tx/c:v"):
323
+ node = ser.find(path, namespaces=NAMESPACES)
324
+ if node is not None and node.text == name:
325
+ return ser
326
+ return None
327
+
328
+
329
+ def _series_index(ser) -> int:
330
+ idx = ser.find(f"{{{C}}}idx")
331
+ return int(idx.get("val")) if idx is not None else 0
332
+
333
+
334
+ def _series_color(ser) -> Optional[str]:
335
+ for path in ("c:spPr/a:solidFill/a:srgbClr", "c:spPr/a:ln/a:solidFill/a:srgbClr"):
336
+ node = ser.find(path, namespaces=NAMESPACES)
337
+ if node is not None:
338
+ return node.get("val")
339
+ return None
340
+
341
+
342
+ def _insert_before_cat(ser, element) -> None:
343
+ """按 CT_*Ser 规范,dPt/dLbls 必须位于 <c:cat>/<c:val> 之前。"""
344
+ for tag in ("cat", "val", "xVal"):
345
+ anchor = ser.find(f"{{{C}}}{tag}")
346
+ if anchor is not None:
347
+ ser.insert(list(ser).index(anchor), element)
348
+ return
349
+ ser.append(element)
350
+
351
+
352
+ def add_value_labels(
353
+ chart,
354
+ series_name: str,
355
+ *,
356
+ number_format: Optional[str] = None,
357
+ position: str = "outside",
358
+ color: Optional[str] = None,
359
+ font_size_pt: float = 9,
360
+ bold: bool = True,
361
+ font: str = AXIS_FONT,
362
+ ) -> None:
363
+ """为某系列添加原生数值标签(c:dLbls)。
364
+
365
+ position: outside(条端外,默认)| inside | center(条内白字用 center + color=white)
366
+ color: None 时跟随系列色。
367
+ """
368
+ ser = find_series_element(chart, series_name)
369
+ if ser is None:
370
+ return
371
+ old = ser.find(f"{{{C}}}dLbls")
372
+ if old is not None:
373
+ ser.remove(old)
374
+
375
+ parent_tag = ser.getparent().tag.split("}")[-1]
376
+ grouping_el = ser.getparent().find(f"{{{C}}}grouping")
377
+ stacked = grouping_el is not None and grouping_el.get("val") in ("stacked", "percentStacked")
378
+ pos_map = {"outside": "outEnd", "inside": "inEnd", "center": "ctr"}
379
+ dlbl_pos = pos_map.get(position, "outEnd")
380
+ if parent_tag == "barChart" and stacked and dlbl_pos == "outEnd":
381
+ dlbl_pos = "ctr" # OOXML: 堆叠柱不允许 outEnd
382
+ if parent_tag == "lineChart" and dlbl_pos in ("outEnd", "inEnd"):
383
+ dlbl_pos = "t"
384
+
385
+ label_color = _norm_hex(color) if color else (_series_color(ser) or get_chart_token("zero_axis"))
386
+
387
+ dLbls = etree.Element(f"{{{C}}}dLbls")
388
+ if number_format:
389
+ num_fmt = etree.SubElement(dLbls, f"{{{C}}}numFmt")
390
+ num_fmt.set("formatCode", number_format)
391
+ num_fmt.set("sourceLinked", "0")
392
+ spPr = etree.SubElement(dLbls, f"{{{C}}}spPr")
393
+ etree.SubElement(spPr, f"{{{A}}}noFill")
394
+ ln = etree.SubElement(spPr, f"{{{A}}}ln")
395
+ etree.SubElement(ln, f"{{{A}}}noFill")
396
+ txPr = etree.SubElement(dLbls, f"{{{C}}}txPr")
397
+ etree.SubElement(txPr, f"{{{A}}}bodyPr")
398
+ etree.SubElement(txPr, f"{{{A}}}lstStyle")
399
+ p = etree.SubElement(txPr, f"{{{A}}}p")
400
+ pPr = etree.SubElement(p, f"{{{A}}}pPr")
401
+ defRPr = etree.SubElement(pPr, f"{{{A}}}defRPr")
402
+ defRPr.set("sz", str(int(font_size_pt * 100)))
403
+ if bold:
404
+ defRPr.set("b", "1")
405
+ fill = etree.SubElement(defRPr, f"{{{A}}}solidFill")
406
+ etree.SubElement(fill, f"{{{A}}}srgbClr").set("val", label_color)
407
+ latin = etree.SubElement(defRPr, f"{{{A}}}latin")
408
+ latin.set("typeface", font)
409
+ ea = etree.SubElement(defRPr, f"{{{A}}}ea")
410
+ ea.set("typeface", font)
411
+ etree.SubElement(p, f"{{{A}}}endParaRPr")
412
+ pos_el = etree.SubElement(dLbls, f"{{{C}}}dLblPos")
413
+ pos_el.set("val", dlbl_pos)
414
+ for tag, val in (("showLegendKey", "0"), ("showVal", "1"), ("showCatName", "0"),
415
+ ("showSerName", "0"), ("showPercent", "0"), ("showBubbleSize", "0")):
416
+ el = etree.SubElement(dLbls, f"{{{C}}}{tag}")
417
+ el.set("val", val)
418
+
419
+ _insert_before_cat(ser, dLbls)
420
+
421
+
422
+ def highlight_category(chart, series_name: str, df, categories_col: str,
423
+ category, color: str) -> None:
424
+ """单类目高亮:一组灰柱中把指定类目涂成强调色(原生 c:dPt)。"""
425
+ matches = df.index[df[categories_col].astype(str) == str(category)].tolist()
426
+ if not matches:
427
+ return
428
+ set_point_fill(chart, series_name, int(matches[0]), color)
429
+
430
+
431
+ def set_point_fill(chart, series_name: str, point_index: int, color: str) -> None:
432
+ ser = find_series_element(chart, series_name)
433
+ if ser is None:
434
+ return
435
+ dPt = etree.Element(f"{{{C}}}dPt")
436
+ idx = etree.SubElement(dPt, f"{{{C}}}idx")
437
+ idx.set("val", str(point_index))
438
+ inv = etree.SubElement(dPt, f"{{{C}}}invertIfNegative")
439
+ inv.set("val", "0")
440
+ spPr = etree.SubElement(dPt, f"{{{C}}}spPr")
441
+ fill = etree.SubElement(spPr, f"{{{A}}}solidFill")
442
+ etree.SubElement(fill, f"{{{A}}}srgbClr").set("val", _norm_hex(color))
443
+ ln = etree.SubElement(spPr, f"{{{A}}}ln")
444
+ etree.SubElement(ln, f"{{{A}}}noFill")
445
+ _insert_before_cat(ser, dPt)
446
+
447
+
448
+ def apply_forecast_pattern(chart, series_name: str, from_index: int, n_points: int) -> None:
449
+ """从 from_index 起的柱体改为斜纹填充(GTM 预测期惯例)。"""
450
+ ser = find_series_element(chart, series_name)
451
+ if ser is None:
452
+ return
453
+ base_color = _series_color(ser) or get_chart_token("subtitle")
454
+ for i in range(from_index, n_points):
455
+ dPt = etree.Element(f"{{{C}}}dPt")
456
+ idx = etree.SubElement(dPt, f"{{{C}}}idx")
457
+ idx.set("val", str(i))
458
+ inv = etree.SubElement(dPt, f"{{{C}}}invertIfNegative")
459
+ inv.set("val", "0")
460
+ spPr = etree.SubElement(dPt, f"{{{C}}}spPr")
461
+ patt = etree.SubElement(spPr, f"{{{A}}}pattFill")
462
+ patt.set("prst", FORECAST_PATTERN)
463
+ fg = etree.SubElement(patt, f"{{{A}}}fgClr")
464
+ etree.SubElement(fg, f"{{{A}}}srgbClr").set("val", base_color)
465
+ bg = etree.SubElement(patt, f"{{{A}}}bgClr")
466
+ etree.SubElement(bg, f"{{{A}}}srgbClr").set("val", "FFFFFF")
467
+ ln = etree.SubElement(spPr, f"{{{A}}}ln")
468
+ etree.SubElement(ln, f"{{{A}}}noFill")
469
+ _insert_before_cat(ser, dPt)
470
+
471
+
472
+ def delete_legend_entry(chart, series_index: int) -> None:
473
+ """隐藏某系列的图例项(如 range 图的透明基底系列)。"""
474
+ legend = chart._chartSpace.find(f".//{{{C}}}legend")
475
+ if legend is None:
476
+ return
477
+ entry = etree.Element(f"{{{C}}}legendEntry")
478
+ idx = etree.SubElement(entry, f"{{{C}}}idx")
479
+ idx.set("val", str(series_index))
480
+ delete = etree.SubElement(entry, f"{{{C}}}delete")
481
+ delete.set("val", "1")
482
+ pos = legend.find(f"{{{C}}}legendPos")
483
+ if pos is not None:
484
+ legend.insert(list(legend).index(pos) + 1, entry)
485
+ else:
486
+ legend.insert(0, entry)
487
+
488
+
489
+ def style_marker_only_series(ser, *, symbol: str, size: int, color: str,
490
+ border_color: str = None, border_width_pt: float = 1.0) -> None:
491
+ """折线系列改为「只显示标记点」:线 noFill + 指定 marker(range 图的当前值/均值刻度)。
492
+
493
+ border_color: marker 描边色(如白色描边让菱形从深色区间条上跳出来)。
494
+ """
495
+ spPr = ser.find(f"{{{C}}}spPr")
496
+ if spPr is not None:
497
+ ser.remove(spPr)
498
+ spPr = etree.Element(f"{{{C}}}spPr")
499
+ ln = etree.SubElement(spPr, f"{{{A}}}ln")
500
+ etree.SubElement(ln, f"{{{A}}}noFill")
501
+ tx = ser.find(f"{{{C}}}tx")
502
+ ser.insert(list(ser).index(tx) + 1 if tx is not None else 0, spPr)
503
+
504
+ old_marker = ser.find(f"{{{C}}}marker")
505
+ if old_marker is not None:
506
+ ser.remove(old_marker)
507
+ marker = etree.Element(f"{{{C}}}marker")
508
+ sym = etree.SubElement(marker, f"{{{C}}}symbol")
509
+ sym.set("val", symbol)
510
+ size_el = etree.SubElement(marker, f"{{{C}}}size")
511
+ size_el.set("val", str(size))
512
+ mk_spPr = etree.SubElement(marker, f"{{{C}}}spPr")
513
+ fill = etree.SubElement(mk_spPr, f"{{{A}}}solidFill")
514
+ etree.SubElement(fill, f"{{{A}}}srgbClr").set("val", _norm_hex(color))
515
+ mk_ln = etree.SubElement(mk_spPr, f"{{{A}}}ln")
516
+ if border_color:
517
+ mk_ln.set("w", str(int(border_width_pt * 12700)))
518
+ ln_fill = etree.SubElement(mk_ln, f"{{{A}}}solidFill")
519
+ etree.SubElement(ln_fill, f"{{{A}}}srgbClr").set("val", _norm_hex(border_color))
520
+ else:
521
+ etree.SubElement(mk_ln, f"{{{A}}}noFill")
522
+ ser.insert(list(ser).index(spPr) + 1, marker)
523
+
524
+
525
+ def _add_last_point_marker(ser, point_index: int, color: str) -> None:
526
+ """末点 dPt:圆形 marker(线系列默认无 marker,仅末点显示)。"""
527
+ dPt = etree.Element(f"{{{C}}}dPt")
528
+ idx = etree.SubElement(dPt, f"{{{C}}}idx")
529
+ idx.set("val", str(point_index))
530
+ inv = etree.SubElement(dPt, f"{{{C}}}invertIfNegative")
531
+ inv.set("val", "0")
532
+ marker = etree.SubElement(dPt, f"{{{C}}}marker")
533
+ sym = etree.SubElement(marker, f"{{{C}}}symbol")
534
+ sym.set("val", "circle")
535
+ size_el = etree.SubElement(marker, f"{{{C}}}size")
536
+ size_el.set("val", "7")
537
+ spPr = etree.SubElement(marker, f"{{{C}}}spPr")
538
+ fill = etree.SubElement(spPr, f"{{{A}}}solidFill")
539
+ etree.SubElement(fill, f"{{{A}}}srgbClr").set("val", _norm_hex(color))
540
+ ln = etree.SubElement(spPr, f"{{{A}}}ln")
541
+ etree.SubElement(ln, f"{{{A}}}noFill")
542
+ _insert_before_cat(ser, dPt)
543
+
544
+
545
+ # ============================================================================
546
+ # 工具
547
+ # ============================================================================
548
+
549
+ def format_value(value: float, fmt: Optional[str]) -> str:
550
+ """极简数值格式化:支持 0% / 0.0% / 0.00 / #,##0 等常用码。"""
551
+ if fmt:
552
+ token = fmt.strip()
553
+ if token.endswith("%"):
554
+ decimals = token.count("0", token.find(".")) if "." in token else 0
555
+ return f"{value * 100:.{decimals}f}%"
556
+ if "#,##" in token:
557
+ decimals = len(token.split(".")[1]) if "." in token else 0
558
+ return f"{value:,.{decimals}f}"
559
+ if token.startswith("0") and "." in token:
560
+ decimals = len(token.split(".")[1])
561
+ return f"{value:.{decimals}f}"
562
+ if token == "0":
563
+ return f"{value:.0f}"
564
+ if abs(value) >= 1000:
565
+ return f"{value:,.0f}"
566
+ return f"{value:.2f}".rstrip("0").rstrip(".")
567
+
568
+
569
+ def _norm_hex(color: str) -> str:
570
+ return str(color).lstrip("#").upper()
571
+
572
+
573
+ def _emu_to_inches(value) -> float:
574
+ if hasattr(value, "inches"):
575
+ return float(value.inches)
576
+ return float(value) / 914400.0
577
+
578
+
579
+ def _add_line(slide, x1, y1, x2, y2, *, color: str, width_pt: float, dashed: bool) -> None:
580
+ from pptx.enum.shapes import MSO_CONNECTOR
581
+
582
+ conn = slide.shapes.add_connector(
583
+ MSO_CONNECTOR.STRAIGHT, Inches(x1), Inches(y1), Inches(x2), Inches(y2))
584
+ conn.line.color.rgb = RGBColor.from_string(_norm_hex(color))
585
+ conn.line.width = Pt(width_pt)
586
+ conn.shadow.inherit = False
587
+ if dashed:
588
+ ln = conn.line._get_or_add_ln()
589
+ dash = etree.SubElement(ln, f"{{{A}}}prstDash")
590
+ dash.set("val", "dash")
591
+
592
+
593
+ def _add_label(slide, text, x, y, w, h, *, color, bold, align, font_size: float = 9.5) -> None:
594
+ box = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h))
595
+ tf = box.text_frame
596
+ tf.word_wrap = False
597
+ for margin in ("margin_left", "margin_right", "margin_top", "margin_bottom"):
598
+ setattr(tf, margin, Pt(0))
599
+ tf.vertical_anchor = MSO_ANCHOR.MIDDLE
600
+ para = tf.paragraphs[0]
601
+ para.alignment = align
602
+ run = para.add_run()
603
+ run.text = text
604
+ run.font.name = AXIS_FONT
605
+ run.font.size = Pt(font_size)
606
+ run.font.bold = bold
607
+ run.font.color.rgb = RGBColor.from_string(_norm_hex(color))
608
+
609
+
610
+ def _set_shape_alpha(shape, alpha_percent: int) -> None:
611
+ srgb = shape.fill.fore_color._xFill.find(f".//{{{A}}}srgbClr")
612
+ if srgb is not None:
613
+ alpha = etree.SubElement(srgb, f"{{{A}}}alpha")
614
+ alpha.set("val", str(alpha_percent * 1000))