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.
- ablechart/__init__.py +351 -0
- ablechart/_log.py +34 -0
- ablechart/annotations.py +614 -0
- ablechart/api.py +518 -0
- ablechart/builder.py +370 -0
- ablechart/cleaner.py +144 -0
- ablechart/date_axis.py +673 -0
- ablechart/examples.py +113 -0
- ablechart/inspect.py +230 -0
- ablechart/layout.py +535 -0
- ablechart/metadata.py +198 -0
- ablechart/oxml/__init__.py +22 -0
- ablechart/oxml/axes.py +219 -0
- ablechart/oxml/plots.py +267 -0
- ablechart/oxml/series.py +509 -0
- ablechart/oxml_ns.py +8 -0
- ablechart/parser.py +1007 -0
- ablechart/plot_area.py +119 -0
- ablechart/polish.py +560 -0
- ablechart/presets.py +519 -0
- ablechart/range_chart.py +180 -0
- ablechart/range_snapshot.py +1174 -0
- ablechart/replace.py +321 -0
- ablechart/scatter.py +301 -0
- ablechart/schema.py +205 -0
- ablechart/semantic_anchor.py +125 -0
- ablechart/semantic_family.py +2239 -0
- ablechart/spec.py +1375 -0
- ablechart/styles.py +298 -0
- ablechart/tokens.py +163 -0
- ablechart/waterfall.py +750 -0
- ablechart-0.1.0.dist-info/METADATA +279 -0
- ablechart-0.1.0.dist-info/RECORD +36 -0
- ablechart-0.1.0.dist-info/WHEEL +5 -0
- ablechart-0.1.0.dist-info/licenses/LICENSE +21 -0
- ablechart-0.1.0.dist-info/top_level.txt +1 -0
ablechart/api.py
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
"""
|
|
2
|
+
公共 API - 简洁的高层接口
|
|
3
|
+
|
|
4
|
+
这是用户(您的工作室同事)唯一需要导入的模块。
|
|
5
|
+
隐藏所有实现细节。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import List, Dict, Optional
|
|
9
|
+
from pptx.slide import Slide
|
|
10
|
+
from pptx.enum.chart import XL_CHART_TYPE
|
|
11
|
+
from pptx.chart.data import BubbleChartData, CategoryChartData, XyChartData
|
|
12
|
+
from pptx.util import Inches
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
from ._log import debug_print as print
|
|
16
|
+
from .builder import ChartBuilder
|
|
17
|
+
from .date_axis import format_category_label
|
|
18
|
+
from .metadata import _write_embedded_metadata
|
|
19
|
+
|
|
20
|
+
XY_CHART_TYPES = ("scatter", "bubble")
|
|
21
|
+
|
|
22
|
+
# 导入样式模块
|
|
23
|
+
try:
|
|
24
|
+
from .styles import StyleConfig, DEFAULT_STYLE_CONFIG
|
|
25
|
+
except ImportError:
|
|
26
|
+
StyleConfig = None
|
|
27
|
+
DEFAULT_STYLE_CONFIG = None
|
|
28
|
+
|
|
29
|
+
# 导入布局模块
|
|
30
|
+
try:
|
|
31
|
+
from .layout import ChartLayoutConfig
|
|
32
|
+
except ImportError:
|
|
33
|
+
ChartLayoutConfig = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def create_combo_chart(
|
|
37
|
+
slide: Slide,
|
|
38
|
+
df: pd.DataFrame,
|
|
39
|
+
categories_col: str,
|
|
40
|
+
series_config: List[Dict],
|
|
41
|
+
position: tuple = (Inches(1), Inches(2)),
|
|
42
|
+
size: tuple = (Inches(8), Inches(4.5)),
|
|
43
|
+
style_config=None,
|
|
44
|
+
layout_config=None,
|
|
45
|
+
metadata: Optional[Dict] = None,
|
|
46
|
+
polish: bool = True,
|
|
47
|
+
orientation: str = "vertical",
|
|
48
|
+
):
|
|
49
|
+
"""
|
|
50
|
+
创建组合图(支持 P(n,2) 任意组合)
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
slide: 幻灯片对象
|
|
54
|
+
df: 数据 DataFrame
|
|
55
|
+
categories_col: 分类列名(X 轴)
|
|
56
|
+
series_config: 系列配置列表
|
|
57
|
+
[
|
|
58
|
+
{"key": "销售额", "name": "销售额", "type": "bar", "axis": "primary"},
|
|
59
|
+
{"key": "增长率", "name": "增长率", "type": "line", "axis": "secondary"},
|
|
60
|
+
{"key": "市场份额", "name": "市场份额", "type": "line", "axis": "secondary"},
|
|
61
|
+
]
|
|
62
|
+
position: 图表位置 (left, top)
|
|
63
|
+
size: 图表大小 (width, height)
|
|
64
|
+
style_config: 样式配置对象(可选,默认使用 DEFAULT_STYLE_CONFIG)
|
|
65
|
+
可以是 StyleConfig 实例,或 None 使用默认样式
|
|
66
|
+
layout_config: 布局配置对象(可选)
|
|
67
|
+
可以是 ChartLayoutConfig 实例,包含图例、轴配置
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Chart 对象
|
|
71
|
+
|
|
72
|
+
Examples:
|
|
73
|
+
>>> # 示例 1: 使用默认样式和布局
|
|
74
|
+
>>> chart = create_combo_chart(
|
|
75
|
+
... slide=slide,
|
|
76
|
+
... df=df,
|
|
77
|
+
... categories_col="日期",
|
|
78
|
+
... series_config=[
|
|
79
|
+
... {"key": "销售额", "name": "销售额", "type": "bar", "axis": "primary"},
|
|
80
|
+
... {"key": "增长率", "name": "增长率", "type": "line", "axis": "secondary"},
|
|
81
|
+
... ]
|
|
82
|
+
... )
|
|
83
|
+
|
|
84
|
+
>>> # 示例 2: 自定义样式 + 布局
|
|
85
|
+
>>> from ablechart import StyleConfig
|
|
86
|
+
>>> from ablechart import (
|
|
87
|
+
... ChartLayoutConfig,
|
|
88
|
+
... LegendConfig,
|
|
89
|
+
... CategoryAxisConfig,
|
|
90
|
+
... )
|
|
91
|
+
>>>
|
|
92
|
+
>>> # 样式配置
|
|
93
|
+
>>> custom_style = StyleConfig(
|
|
94
|
+
... color_scheme="dark_only",
|
|
95
|
+
... line_width_pt=1.5,
|
|
96
|
+
... marker_style="none",
|
|
97
|
+
... )
|
|
98
|
+
>>>
|
|
99
|
+
>>> # 布局配置
|
|
100
|
+
>>> custom_layout = ChartLayoutConfig(
|
|
101
|
+
... legend_config=LegendConfig(position="bottom", font_size_pt=10),
|
|
102
|
+
... category_axis_config=CategoryAxisConfig(
|
|
103
|
+
... is_date_axis=True,
|
|
104
|
+
... major_unit_days=7, # 每周显示一个刻度
|
|
105
|
+
... number_format="yyyy-mm-dd",
|
|
106
|
+
... ),
|
|
107
|
+
... )
|
|
108
|
+
>>>
|
|
109
|
+
>>> chart = create_combo_chart(
|
|
110
|
+
... slide=slide,
|
|
111
|
+
... df=df,
|
|
112
|
+
... categories_col="日期",
|
|
113
|
+
... series_config=[...],
|
|
114
|
+
... style_config=custom_style,
|
|
115
|
+
... layout_config=custom_layout
|
|
116
|
+
... )
|
|
117
|
+
|
|
118
|
+
Supported Combinations:
|
|
119
|
+
- type: 'bar', 'column', 'line', 'area', 'scatter', 'bubble'
|
|
120
|
+
- axis: 'primary', 'secondary'
|
|
121
|
+
- 任意 (type1, axis1) + (type2, axis2) 的组合
|
|
122
|
+
- 支持主轴多种类型,次轴多种类型
|
|
123
|
+
|
|
124
|
+
Initial XY-family Support:
|
|
125
|
+
- scatter / bubble 当前仅支持纯 XY 家族图表
|
|
126
|
+
- 不支持与分类轴图表混搭,也不支持 scatter 与 bubble 互混
|
|
127
|
+
- 所有 XY 系列必须共享同一组 X 数据并使用主轴
|
|
128
|
+
|
|
129
|
+
Notes:
|
|
130
|
+
- 分类(X轴)在 Excel 中占用 A 列
|
|
131
|
+
- 系列数据从 B 列开始
|
|
132
|
+
- 支持超过 25 个系列(AA, AB, ...)
|
|
133
|
+
- 左轴标签在左侧,右轴标签在右侧,不会重叠
|
|
134
|
+
- 默认样式:无标记点、1pt 线宽、深浅色交替
|
|
135
|
+
- 默认布局:图例在底部、横轴普通分类轴
|
|
136
|
+
"""
|
|
137
|
+
if not series_config:
|
|
138
|
+
raise ValueError("series_config 不能为空")
|
|
139
|
+
|
|
140
|
+
position = _normalize_position_tuple(position)
|
|
141
|
+
size = _normalize_size_tuple(size)
|
|
142
|
+
_validate_series_config(df, categories_col, series_config)
|
|
143
|
+
|
|
144
|
+
# 2. 创建引导图表(写入全部系列数据到嵌入 Excel)
|
|
145
|
+
chart = _bootstrap_chart(
|
|
146
|
+
slide, df, categories_col, series_config, position, size, orientation=orientation
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# ⭐ 日期分类的显示格式:跟随 layout_config.date_axis_config.number_format
|
|
150
|
+
date_format = _resolve_category_date_format(layout_config)
|
|
151
|
+
|
|
152
|
+
# ⭐ 核心修复:修正嵌入的 Excel 工作表中的日期数据
|
|
153
|
+
# 如果分类列是日期类型,需要将 Excel 工作表中的文本日期转换为真实的日期数值
|
|
154
|
+
_fix_embedded_excel_dates(chart, df, categories_col, date_format=date_format)
|
|
155
|
+
_write_embedded_metadata(chart, categories_col, series_config, metadata=metadata)
|
|
156
|
+
|
|
157
|
+
# 3. 使用构建器完成剩余工作(传递样式配置和布局配置)
|
|
158
|
+
builder = ChartBuilder(
|
|
159
|
+
chart,
|
|
160
|
+
df,
|
|
161
|
+
categories_col,
|
|
162
|
+
style_config=style_config if style_config is not None else DEFAULT_STYLE_CONFIG,
|
|
163
|
+
layout_config=layout_config,
|
|
164
|
+
polish=polish,
|
|
165
|
+
date_format=date_format,
|
|
166
|
+
orientation=orientation,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# 注意:引导图表已经创建了第一个系列,构建器会继续追加
|
|
170
|
+
# 如果需要完全自定义,可以在 builder.clear_bootstrap_chart() 中清理
|
|
171
|
+
|
|
172
|
+
return builder.build(series_config)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _bootstrap_chart(
|
|
176
|
+
slide: Slide,
|
|
177
|
+
df: pd.DataFrame,
|
|
178
|
+
categories_col: str,
|
|
179
|
+
series_config: List[Dict],
|
|
180
|
+
position: tuple,
|
|
181
|
+
size: tuple,
|
|
182
|
+
orientation: str = "vertical",
|
|
183
|
+
):
|
|
184
|
+
"""
|
|
185
|
+
创建引导图表(写入全部系列数据到嵌入 Excel)
|
|
186
|
+
|
|
187
|
+
用途:
|
|
188
|
+
- 激活 <c:plotArea>,使其可以通过 XML 访问
|
|
189
|
+
- 创建初始的分类轴和值轴
|
|
190
|
+
- 将所有系列数据写入嵌入 Excel(确保"编辑数据"不丢数据)
|
|
191
|
+
- 图表 XML 结构后续由 ChartBuilder 重建
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
slide: 幻灯片对象
|
|
195
|
+
df: 数据 DataFrame
|
|
196
|
+
categories_col: 分类列名
|
|
197
|
+
series_config: 全部系列配置列表
|
|
198
|
+
position: (left, top)
|
|
199
|
+
size: (width, height)
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Chart 对象
|
|
203
|
+
"""
|
|
204
|
+
chart_family = _get_chart_family(series_config)
|
|
205
|
+
if chart_family in XY_CHART_TYPES:
|
|
206
|
+
chart_data, chart_type = _build_xy_chart_data(chart_family, df, categories_col, series_config)
|
|
207
|
+
else:
|
|
208
|
+
chart_data = CategoryChartData()
|
|
209
|
+
|
|
210
|
+
# 设置分类(X轴)
|
|
211
|
+
categories = df[categories_col].tolist()
|
|
212
|
+
|
|
213
|
+
categories_bootstrap = [format_category_label(cat) for cat in categories]
|
|
214
|
+
|
|
215
|
+
chart_data.categories = categories_bootstrap
|
|
216
|
+
|
|
217
|
+
# 添加全部系列数据(确保嵌入 Excel 包含所有列)
|
|
218
|
+
for series_cfg in series_config:
|
|
219
|
+
chart_data.add_series(
|
|
220
|
+
series_cfg["name"],
|
|
221
|
+
df[series_cfg["key"]].tolist()
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# 使用第一个系列的类型决定引导图表类型
|
|
225
|
+
chart_type = _get_chart_type(series_config[0].get("type", "bar"))
|
|
226
|
+
if orientation == "horizontal" and chart_type == XL_CHART_TYPE.COLUMN_CLUSTERED:
|
|
227
|
+
chart_type = XL_CHART_TYPE.BAR_CLUSTERED
|
|
228
|
+
|
|
229
|
+
# 创建图表
|
|
230
|
+
left, top = _normalize_position_tuple(position)
|
|
231
|
+
width, height = _normalize_size_tuple(size)
|
|
232
|
+
graphic_frame = slide.shapes.add_chart(
|
|
233
|
+
chart_type, left, top, width, height, chart_data
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
return graphic_frame.chart
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _get_chart_type(type_str: str) -> XL_CHART_TYPE:
|
|
240
|
+
"""将图表类型字符串转换为 XL_CHART_TYPE 枚举"""
|
|
241
|
+
type_map = {
|
|
242
|
+
"bar": XL_CHART_TYPE.COLUMN_CLUSTERED,
|
|
243
|
+
"column": XL_CHART_TYPE.COLUMN_CLUSTERED,
|
|
244
|
+
"line": XL_CHART_TYPE.LINE,
|
|
245
|
+
"area": XL_CHART_TYPE.AREA,
|
|
246
|
+
"scatter": XL_CHART_TYPE.XY_SCATTER,
|
|
247
|
+
"bubble": XL_CHART_TYPE.BUBBLE,
|
|
248
|
+
}
|
|
249
|
+
return type_map.get(type_str.lower(), XL_CHART_TYPE.COLUMN_CLUSTERED)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _build_xy_chart_data(
|
|
253
|
+
chart_family: str,
|
|
254
|
+
df: pd.DataFrame,
|
|
255
|
+
categories_col: str,
|
|
256
|
+
series_config: List[Dict],
|
|
257
|
+
):
|
|
258
|
+
chart_data = XyChartData() if chart_family == "scatter" else BubbleChartData()
|
|
259
|
+
|
|
260
|
+
for series_cfg in series_config:
|
|
261
|
+
series = chart_data.add_series(series_cfg["name"])
|
|
262
|
+
x_values = pd.to_numeric(df[series_cfg.get("x_key", categories_col)], errors="raise").tolist()
|
|
263
|
+
y_values = pd.to_numeric(df[series_cfg["key"]], errors="raise").tolist()
|
|
264
|
+
|
|
265
|
+
if chart_family == "bubble":
|
|
266
|
+
size_values = pd.to_numeric(df[series_cfg["size_key"]], errors="raise").tolist()
|
|
267
|
+
for x_value, y_value, size_value in zip(x_values, y_values, size_values):
|
|
268
|
+
series.add_data_point(x_value, y_value, size_value)
|
|
269
|
+
else:
|
|
270
|
+
for x_value, y_value in zip(x_values, y_values):
|
|
271
|
+
series.add_data_point(x_value, y_value)
|
|
272
|
+
|
|
273
|
+
return chart_data, _get_chart_type(chart_family)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _normalize_position_tuple(position: tuple):
|
|
277
|
+
if len(position) != 2:
|
|
278
|
+
raise ValueError("position 必须是 (left, top)")
|
|
279
|
+
return tuple(_normalize_measure(value) for value in position)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _normalize_size_tuple(size: tuple):
|
|
283
|
+
if len(size) != 2:
|
|
284
|
+
raise ValueError("size 必须是 (width, height)")
|
|
285
|
+
return tuple(_normalize_measure(value) for value in size)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _normalize_measure(value):
|
|
289
|
+
if hasattr(value, "emu"):
|
|
290
|
+
return value
|
|
291
|
+
try:
|
|
292
|
+
numeric = float(value)
|
|
293
|
+
except Exception:
|
|
294
|
+
return value
|
|
295
|
+
if numeric < 1000:
|
|
296
|
+
return Inches(numeric)
|
|
297
|
+
return int(round(numeric))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _group_series(series_config: List[Dict]) -> Dict[tuple, List[Dict]]:
|
|
301
|
+
"""按 (type, axis) 分组系列"""
|
|
302
|
+
from collections import defaultdict
|
|
303
|
+
groups = defaultdict(list)
|
|
304
|
+
for cfg in series_config:
|
|
305
|
+
key = (cfg.get("type", "bar"), cfg.get("axis", "primary"))
|
|
306
|
+
groups[key].append(cfg)
|
|
307
|
+
return dict(groups)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _get_chart_family(series_config: List[Dict]) -> str:
|
|
311
|
+
normalized_types = {str(cfg.get("type", "bar")).lower() for cfg in series_config}
|
|
312
|
+
xy_types = normalized_types.intersection(XY_CHART_TYPES)
|
|
313
|
+
|
|
314
|
+
if not xy_types:
|
|
315
|
+
return "category"
|
|
316
|
+
if xy_types != normalized_types:
|
|
317
|
+
return "mixed"
|
|
318
|
+
if len(xy_types) > 1:
|
|
319
|
+
return "mixed_xy"
|
|
320
|
+
return next(iter(xy_types))
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _validate_series_config(df: pd.DataFrame, categories_col: str, series_config: List[Dict]) -> None:
|
|
324
|
+
chart_family = _get_chart_family(series_config)
|
|
325
|
+
if chart_family == "mixed":
|
|
326
|
+
raise ValueError("初始 scatter/bubble 支持不允许与 bar/line/area 混搭")
|
|
327
|
+
if chart_family == "mixed_xy":
|
|
328
|
+
raise ValueError("初始 XY 支持要求 scatter 和 bubble 分开使用")
|
|
329
|
+
if chart_family in XY_CHART_TYPES:
|
|
330
|
+
_validate_xy_series_config(df, categories_col, series_config, chart_family)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _validate_xy_series_config(
|
|
334
|
+
df: pd.DataFrame,
|
|
335
|
+
categories_col: str,
|
|
336
|
+
series_config: List[Dict],
|
|
337
|
+
chart_family: str,
|
|
338
|
+
) -> None:
|
|
339
|
+
shared_x_key = None
|
|
340
|
+
shared_x_values = None
|
|
341
|
+
|
|
342
|
+
for series_cfg in series_config:
|
|
343
|
+
if series_cfg.get("axis", "primary") != "primary":
|
|
344
|
+
raise ValueError("初始 scatter/bubble 支持仅允许 primary 轴")
|
|
345
|
+
|
|
346
|
+
x_key = series_cfg.get("x_key", categories_col)
|
|
347
|
+
if x_key not in df.columns:
|
|
348
|
+
raise ValueError(f"散点/气泡图缺少 X 列: {x_key}")
|
|
349
|
+
if series_cfg["key"] not in df.columns:
|
|
350
|
+
raise ValueError(f"散点/气泡图缺少 Y 列: {series_cfg['key']}")
|
|
351
|
+
|
|
352
|
+
x_values = df[x_key].tolist()
|
|
353
|
+
if shared_x_key is None:
|
|
354
|
+
shared_x_key = x_key
|
|
355
|
+
shared_x_values = x_values
|
|
356
|
+
elif x_key != shared_x_key or x_values != shared_x_values:
|
|
357
|
+
raise ValueError("初始 scatter/bubble 支持要求所有系列共享同一个 X 列和相同 X 数据")
|
|
358
|
+
|
|
359
|
+
pd.to_numeric(df[x_key], errors="raise")
|
|
360
|
+
pd.to_numeric(df[series_cfg["key"]], errors="raise")
|
|
361
|
+
|
|
362
|
+
if chart_family == "bubble":
|
|
363
|
+
size_key = series_cfg.get("size_key")
|
|
364
|
+
if not size_key:
|
|
365
|
+
raise ValueError("bubble 系列必须提供 size_key")
|
|
366
|
+
if size_key not in df.columns:
|
|
367
|
+
raise ValueError(f"气泡图缺少 size 列: {size_key}")
|
|
368
|
+
pd.to_numeric(df[size_key], errors="raise")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _resolve_category_date_format(layout_config) -> str:
|
|
372
|
+
"""从 layout_config 推导日期分类的 strftime 格式,默认 '%Y/%m'。"""
|
|
373
|
+
from .polish import strftime_from_excel
|
|
374
|
+
|
|
375
|
+
number_format = None
|
|
376
|
+
if layout_config is not None and getattr(layout_config, "date_axis_config", None) is not None:
|
|
377
|
+
number_format = getattr(layout_config.date_axis_config, "number_format", None)
|
|
378
|
+
return strftime_from_excel(number_format)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _fix_embedded_excel_dates(chart, df: pd.DataFrame, categories_col: str, date_format: str = "%Y/%m"):
|
|
382
|
+
"""
|
|
383
|
+
修正嵌入的 Excel 工作表中的日期数据
|
|
384
|
+
|
|
385
|
+
新方案:将日期格式化为字符串标签(如 "2024/01")
|
|
386
|
+
这样 PowerPoint 就会正确显示,而不会出现 1900 年问题
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
chart: python-pptx Chart 对象
|
|
390
|
+
df: 数据 DataFrame
|
|
391
|
+
categories_col: 分类列名
|
|
392
|
+
date_format: 日期 → 字符串的 strftime 格式
|
|
393
|
+
"""
|
|
394
|
+
# 检查是否为日期类型
|
|
395
|
+
if not pd.api.types.is_datetime64_any_dtype(df[categories_col]):
|
|
396
|
+
print(f" → 分类列不是日期类型,跳过 Excel 工作表修正")
|
|
397
|
+
return # 不是日期类型,无需修正
|
|
398
|
+
|
|
399
|
+
print(f"\n🔧 修正嵌入的 Excel 工作表日期数据(转换为格式化字符串)...")
|
|
400
|
+
|
|
401
|
+
try:
|
|
402
|
+
from datetime import datetime
|
|
403
|
+
from openpyxl import load_workbook
|
|
404
|
+
import io
|
|
405
|
+
|
|
406
|
+
# 获取嵌入的 Excel 数据
|
|
407
|
+
chart_part = chart.part
|
|
408
|
+
xlsx_part = chart_part.chart_workbook.xlsx_part
|
|
409
|
+
|
|
410
|
+
print(f" → 找到嵌入的 Excel 工作表")
|
|
411
|
+
|
|
412
|
+
# 将 Excel blob 加载为 openpyxl workbook
|
|
413
|
+
xlsx_stream = io.BytesIO(xlsx_part.blob)
|
|
414
|
+
wb = load_workbook(xlsx_stream)
|
|
415
|
+
ws = wb.active
|
|
416
|
+
|
|
417
|
+
print(f" → 工作表行数: {ws.max_row}, 列数: {ws.max_column}")
|
|
418
|
+
|
|
419
|
+
# 获取日期数据
|
|
420
|
+
categories = df[categories_col].tolist()
|
|
421
|
+
|
|
422
|
+
print(f" → 准备修正 {len(categories)} 个日期值")
|
|
423
|
+
print(f" → 第一个值: {categories[0]} (类型: {type(categories[0])})")
|
|
424
|
+
|
|
425
|
+
# 修正 A 列(分类列)的数据 - 转换为格式化字符串
|
|
426
|
+
# Excel 工作表的第一行是表头,数据从第二行开始
|
|
427
|
+
fixed_count = 0
|
|
428
|
+
for i, cat_value in enumerate(categories, start=2):
|
|
429
|
+
if hasattr(cat_value, 'to_pydatetime'):
|
|
430
|
+
cat_value = cat_value.to_pydatetime()
|
|
431
|
+
|
|
432
|
+
if isinstance(cat_value, datetime):
|
|
433
|
+
# ⭐ 将日期格式化为字符串(格式跟随 date_axis_config.number_format)
|
|
434
|
+
date_str = cat_value.strftime(date_format)
|
|
435
|
+
ws.cell(row=i, column=1).value = date_str
|
|
436
|
+
fixed_count += 1
|
|
437
|
+
|
|
438
|
+
print(f" → 已修正 {fixed_count} 个单元格")
|
|
439
|
+
print(f" → 示例:{categories[0].strftime(date_format) if isinstance(categories[0], datetime) or hasattr(categories[0], 'strftime') else 'N/A'}")
|
|
440
|
+
|
|
441
|
+
# 将修改后的 workbook 写回 blob
|
|
442
|
+
output_stream = io.BytesIO()
|
|
443
|
+
wb.save(output_stream)
|
|
444
|
+
xlsx_part._blob = output_stream.getvalue()
|
|
445
|
+
|
|
446
|
+
print(f" ✅ 嵌入 Excel 工作表修正完成({fixed_count} 个日期值转换为格式化字符串)")
|
|
447
|
+
|
|
448
|
+
except Exception as e:
|
|
449
|
+
print(f" ⚠️ 修正嵌入 Excel 工作表失败: {e}")
|
|
450
|
+
import traceback
|
|
451
|
+
traceback.print_exc()
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
# Metadata persistence moved to ``ablechart.metadata`` (2026-05-24).
|
|
455
|
+
# ``_write_embedded_metadata`` is now re-exported from this module via the
|
|
456
|
+
# top-of-file ``from .metadata import _write_embedded_metadata``, so callers
|
|
457
|
+
# that do ``from .api import _write_embedded_metadata`` continue working
|
|
458
|
+
# unchanged. The legacy 60-line implementation that used to live here has
|
|
459
|
+
# been moved verbatim to ``metadata._write_workbook_hidden_sheet`` and is
|
|
460
|
+
# now the single source of truth.
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
# ============================================================================
|
|
464
|
+
# 便捷函数:向后兼容
|
|
465
|
+
# ============================================================================
|
|
466
|
+
|
|
467
|
+
def create_dual_axis_chart(
|
|
468
|
+
slide: Slide,
|
|
469
|
+
df: pd.DataFrame,
|
|
470
|
+
categories_col: str,
|
|
471
|
+
bar_columns: List[str],
|
|
472
|
+
bar_names: List[str],
|
|
473
|
+
line_columns: List[str],
|
|
474
|
+
line_names: List[str],
|
|
475
|
+
position: tuple = (Inches(1), Inches(2)),
|
|
476
|
+
size: tuple = (Inches(8), Inches(4.5)),
|
|
477
|
+
):
|
|
478
|
+
"""
|
|
479
|
+
便捷函数:创建双轴组合图(柱状图 + 折线图)
|
|
480
|
+
|
|
481
|
+
这是向后兼容的 API,与旧的 xml_chart_patcher 接口一致。
|
|
482
|
+
|
|
483
|
+
Example:
|
|
484
|
+
>>> create_dual_axis_chart(
|
|
485
|
+
... slide=slide,
|
|
486
|
+
... df=df,
|
|
487
|
+
... categories_col="日期",
|
|
488
|
+
... bar_columns=["销售额", "成本"],
|
|
489
|
+
... bar_names=["销售额", "成本"],
|
|
490
|
+
... line_columns=["利润率"],
|
|
491
|
+
... line_names=["利润率"],
|
|
492
|
+
... )
|
|
493
|
+
"""
|
|
494
|
+
# 构建统一的 series_config
|
|
495
|
+
series_config = []
|
|
496
|
+
|
|
497
|
+
# 主轴柱状图
|
|
498
|
+
for col, name in zip(bar_columns, bar_names):
|
|
499
|
+
series_config.append({
|
|
500
|
+
"key": col,
|
|
501
|
+
"name": name,
|
|
502
|
+
"type": "bar",
|
|
503
|
+
"axis": "primary"
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
# 次轴折线图
|
|
507
|
+
for col, name in zip(line_columns, line_names):
|
|
508
|
+
series_config.append({
|
|
509
|
+
"key": col,
|
|
510
|
+
"name": name,
|
|
511
|
+
"type": "line",
|
|
512
|
+
"axis": "secondary"
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
# 调用统一的 API
|
|
516
|
+
return create_combo_chart(
|
|
517
|
+
slide, df, categories_col, series_config, position, size
|
|
518
|
+
)
|