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/date_axis.py
ADDED
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
"""
|
|
2
|
+
日期轴 XML 直接操作模块
|
|
3
|
+
|
|
4
|
+
专门用于设置日期轴的底层 XML 属性,绕过 python-pptx 的限制。
|
|
5
|
+
|
|
6
|
+
使用场景:
|
|
7
|
+
- 横轴确定为日期类型
|
|
8
|
+
- 需要精确控制刻度密度
|
|
9
|
+
- 需要设置日期范围
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from lxml import etree
|
|
13
|
+
from typing import Optional, Literal
|
|
14
|
+
from datetime import datetime, timedelta
|
|
15
|
+
|
|
16
|
+
from ._log import debug_print as print
|
|
17
|
+
from .oxml_ns import NAMESPACES
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def excel_date(date: datetime) -> float:
|
|
21
|
+
"""
|
|
22
|
+
将 Python datetime 转换为 Excel 日期序号
|
|
23
|
+
|
|
24
|
+
Excel 的日期基准:1900-01-01 = 1
|
|
25
|
+
"""
|
|
26
|
+
base_date = datetime(1899, 12, 30) # Excel 的实际基准日期
|
|
27
|
+
delta = date - base_date
|
|
28
|
+
return float(delta.days)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def format_category_label(value, number_format: str = "yyyy-mm-dd") -> str:
|
|
32
|
+
"""Serialize date-like category values into stable human-readable labels.
|
|
33
|
+
|
|
34
|
+
Rules:
|
|
35
|
+
- when the value is date-like, suppress time-of-day by default
|
|
36
|
+
- honor coarse patterns like year / year-month / month-day
|
|
37
|
+
- fall back to plain string for non-date-like values
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
dt = _coerce_datetime(value)
|
|
41
|
+
if dt is None:
|
|
42
|
+
return str(value)
|
|
43
|
+
|
|
44
|
+
fmt = (number_format or "yyyy-mm-dd").lower()
|
|
45
|
+
if "yyyy/mm" in fmt and "dd" not in fmt:
|
|
46
|
+
return dt.strftime("%Y/%m")
|
|
47
|
+
if "yyyy-mm" in fmt and "dd" not in fmt:
|
|
48
|
+
return dt.strftime("%Y-%m")
|
|
49
|
+
if "yyyy" in fmt and "mm" not in fmt and "dd" not in fmt:
|
|
50
|
+
return dt.strftime("%Y")
|
|
51
|
+
if "mm-dd" in fmt and "yyyy" not in fmt:
|
|
52
|
+
return dt.strftime("%m-%d")
|
|
53
|
+
return dt.strftime("%Y-%m-%d")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _coerce_datetime(value):
|
|
57
|
+
if hasattr(value, "to_pydatetime"):
|
|
58
|
+
value = value.to_pydatetime()
|
|
59
|
+
if isinstance(value, datetime):
|
|
60
|
+
return value
|
|
61
|
+
if isinstance(value, float):
|
|
62
|
+
try:
|
|
63
|
+
return datetime(1899, 12, 30) + timedelta(days=value)
|
|
64
|
+
except Exception:
|
|
65
|
+
return None
|
|
66
|
+
if isinstance(value, str):
|
|
67
|
+
candidate = value.strip()
|
|
68
|
+
if not candidate:
|
|
69
|
+
return None
|
|
70
|
+
candidate = candidate.replace("T", " ")
|
|
71
|
+
try:
|
|
72
|
+
return datetime.fromisoformat(candidate)
|
|
73
|
+
except ValueError:
|
|
74
|
+
for fmt in ("%Y/%m/%d", "%Y-%m-%d", "%Y/%m", "%Y-%m", "%Y%m%d"):
|
|
75
|
+
try:
|
|
76
|
+
return datetime.strptime(candidate, fmt)
|
|
77
|
+
except ValueError:
|
|
78
|
+
continue
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class DateAxisConfig:
|
|
83
|
+
"""
|
|
84
|
+
日期轴 XML 配置类
|
|
85
|
+
|
|
86
|
+
直接操作 XML 以实现完整的日期轴控制
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(
|
|
90
|
+
self,
|
|
91
|
+
base_unit: Literal["days", "months", "years"] = "days",
|
|
92
|
+
major_unit: float = 7.0,
|
|
93
|
+
major_unit_scale: Literal["days", "months", "years"] = "days",
|
|
94
|
+
minor_unit: Optional[float] = None,
|
|
95
|
+
minor_unit_scale: Literal["days", "months", "years"] = "days",
|
|
96
|
+
min_date: Optional[datetime] = None,
|
|
97
|
+
max_date: Optional[datetime] = None,
|
|
98
|
+
number_format: str = "yyyy-mm-dd",
|
|
99
|
+
auto_adjust: bool = False,
|
|
100
|
+
):
|
|
101
|
+
"""
|
|
102
|
+
初始化日期轴配置
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
base_unit: 基础时间单位(days/months/years)
|
|
106
|
+
major_unit: 主刻度单位数值(如 7 表示每 7 个单位一个刻度)
|
|
107
|
+
major_unit_scale: 主刻度单位类型
|
|
108
|
+
minor_unit: 次刻度单位数值
|
|
109
|
+
minor_unit_scale: 次刻度单位类型
|
|
110
|
+
min_date: 最小日期(用于固定范围)
|
|
111
|
+
max_date: 最大日期(用于固定范围)
|
|
112
|
+
number_format: 日期显示格式
|
|
113
|
+
auto_adjust: 是否允许 PowerPoint 自动调整
|
|
114
|
+
|
|
115
|
+
Examples:
|
|
116
|
+
>>> # 每周显示一个刻度
|
|
117
|
+
>>> config = DateAxisConfig(
|
|
118
|
+
... base_unit="days",
|
|
119
|
+
... major_unit=7.0,
|
|
120
|
+
... major_unit_scale="days",
|
|
121
|
+
... )
|
|
122
|
+
|
|
123
|
+
>>> # 每月显示一个刻度
|
|
124
|
+
>>> config = DateAxisConfig(
|
|
125
|
+
... base_unit="months",
|
|
126
|
+
... major_unit=1.0,
|
|
127
|
+
... major_unit_scale="months",
|
|
128
|
+
... )
|
|
129
|
+
|
|
130
|
+
>>> # 固定日期范围
|
|
131
|
+
>>> config = DateAxisConfig(
|
|
132
|
+
... base_unit="days",
|
|
133
|
+
... major_unit=14.0,
|
|
134
|
+
... min_date=datetime(2024, 1, 1),
|
|
135
|
+
... max_date=datetime(2024, 12, 31),
|
|
136
|
+
... )
|
|
137
|
+
"""
|
|
138
|
+
self.base_unit = base_unit
|
|
139
|
+
self.major_unit = major_unit
|
|
140
|
+
self.major_unit_scale = major_unit_scale
|
|
141
|
+
self.minor_unit = minor_unit or (major_unit / 7) # 默认为主刻度的 1/7
|
|
142
|
+
self.minor_unit_scale = minor_unit_scale
|
|
143
|
+
self.min_date = min_date
|
|
144
|
+
self.max_date = max_date
|
|
145
|
+
self.number_format = number_format
|
|
146
|
+
self.auto_adjust = auto_adjust
|
|
147
|
+
|
|
148
|
+
def apply_to_chart(self, chart):
|
|
149
|
+
"""
|
|
150
|
+
应用日期轴配置到图表
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
chart: python-pptx 的 Chart 对象
|
|
154
|
+
"""
|
|
155
|
+
print(f"\n📅 应用日期轴配置(XML 直接操作):")
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
# 1. 获取 XML 元素
|
|
159
|
+
chart_element = chart._element
|
|
160
|
+
|
|
161
|
+
# ⭐ 新方案:数据已在 plots.py 中格式化为字符串并使用 strCache
|
|
162
|
+
print(f" → 数据已格式化为字符串标签(如 '2024/01'),使用 strCache")
|
|
163
|
+
|
|
164
|
+
# ⭐ 关键修复:查找catAx并转换为dateAx
|
|
165
|
+
# PowerPoint 不允许在 catAx 中使用时间单位元素,必须使用 dateAx
|
|
166
|
+
cat_ax_elements = chart_element.findall('.//{http://schemas.openxmlformats.org/drawingml/2006/chart}catAx')
|
|
167
|
+
|
|
168
|
+
if not cat_ax_elements:
|
|
169
|
+
print(" ⚠️ 未找到分类轴元素")
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
# ⭐ 新方案:不转换为 dateAx,保持 catAx
|
|
173
|
+
# PowerPoint 对 dateAx 的时间单位元素支持不稳定
|
|
174
|
+
# 改用 catAx + numRef/numCache + scaling(min/max) + numFmt(日期格式)
|
|
175
|
+
# 这样更简单且兼容性更好
|
|
176
|
+
|
|
177
|
+
cat_ax = cat_ax_elements[0]
|
|
178
|
+
print(f" ✅ 使用 catAx(分类轴)+ 格式化字符串标签")
|
|
179
|
+
|
|
180
|
+
# ⭐ 设置标签位置为"低"(在坐标轴下方)
|
|
181
|
+
self._set_tick_label_position(cat_ax, 'low')
|
|
182
|
+
print(f" - 标签位置: 低(坐标轴下方)")
|
|
183
|
+
|
|
184
|
+
# ⭐ 设置标签间隔(控制显示数量)
|
|
185
|
+
# 由于当前实现走的是 catAx + 格式化字符串标签,major_unit_scale 不能直接
|
|
186
|
+
# 映射为 PowerPoint 的真正 month/year date axis 语义,所以这里按数据长度
|
|
187
|
+
# 做一次显示密度折算。
|
|
188
|
+
category_count = self._infer_category_count(chart_element)
|
|
189
|
+
label_interval = self._resolve_label_interval(category_count)
|
|
190
|
+
self._set_label_interval(cat_ax, label_interval)
|
|
191
|
+
print(f" - 标签间隔: 每 {label_interval} 个点显示一个标签")
|
|
192
|
+
|
|
193
|
+
# ⭐ 设置字体大小为 9pt
|
|
194
|
+
try:
|
|
195
|
+
category_axis = chart.category_axis
|
|
196
|
+
from pptx.util import Pt
|
|
197
|
+
category_axis.tick_labels.font.size = Pt(9)
|
|
198
|
+
category_axis.tick_labels.font.name = "黑体"
|
|
199
|
+
print(f" - 横轴字体: 黑体 9pt")
|
|
200
|
+
except Exception as e:
|
|
201
|
+
print(f" ⚠️ 字体设置失败: {e}")
|
|
202
|
+
|
|
203
|
+
print(f" ✅ 日期轴配置完成")
|
|
204
|
+
|
|
205
|
+
except Exception as e:
|
|
206
|
+
print(f" ❌ 日期轴配置失败: {e}")
|
|
207
|
+
import traceback
|
|
208
|
+
traceback.print_exc()
|
|
209
|
+
|
|
210
|
+
def _resolve_label_interval(self, category_count: int | None) -> int:
|
|
211
|
+
"""Map semantic date intent onto string-label density control."""
|
|
212
|
+
|
|
213
|
+
if category_count is None or category_count <= 0:
|
|
214
|
+
return max(1, int(self.major_unit))
|
|
215
|
+
|
|
216
|
+
if self.major_unit_scale == "months":
|
|
217
|
+
# Long daily histories should land around 8-12 visible month labels.
|
|
218
|
+
if category_count >= 360:
|
|
219
|
+
return max(1, category_count // 10)
|
|
220
|
+
if category_count >= 120:
|
|
221
|
+
return max(1, category_count // 8)
|
|
222
|
+
return max(1, int(self.major_unit))
|
|
223
|
+
|
|
224
|
+
if self.major_unit_scale == "years":
|
|
225
|
+
return max(1, category_count // 6)
|
|
226
|
+
|
|
227
|
+
return max(1, int(self.major_unit))
|
|
228
|
+
|
|
229
|
+
def _infer_category_count(self, chart_element) -> int | None:
|
|
230
|
+
"""Best-effort count of category points from the first category cache."""
|
|
231
|
+
|
|
232
|
+
pt_count = chart_element.find('.//c:cat//c:ptCount', namespaces=NAMESPACES)
|
|
233
|
+
if pt_count is not None:
|
|
234
|
+
try:
|
|
235
|
+
return int(pt_count.get('val'))
|
|
236
|
+
except (TypeError, ValueError):
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
pts = chart_element.findall('.//c:cat//c:pt', namespaces=NAMESPACES)
|
|
240
|
+
return len(pts) if pts else None
|
|
241
|
+
|
|
242
|
+
def _convert_cat_to_numcache(self, chart_element):
|
|
243
|
+
"""
|
|
244
|
+
⚠️ 备用方法:将分类数据从 strCache 转换为 numCache
|
|
245
|
+
|
|
246
|
+
【重要】此方法已不再默认调用(自修复后)
|
|
247
|
+
|
|
248
|
+
修复说明:
|
|
249
|
+
- 在 api.py 修复后,python-pptx 会直接生成 numCache
|
|
250
|
+
- 此方法仅在需要手动处理旧文件或特殊场景时使用
|
|
251
|
+
- 如果未来遇到 strCache,此方法会将字符串日期转换为 Excel 序列号
|
|
252
|
+
|
|
253
|
+
转换逻辑:
|
|
254
|
+
- 从 strCache 读取 "YYYY-MM-DD" 格式的字符串
|
|
255
|
+
- 调用 excel_date() 转换为 Excel 日期序列号(如 45567)
|
|
256
|
+
- 写入 numCache,让 PowerPoint 正确识别为日期
|
|
257
|
+
"""
|
|
258
|
+
from lxml import etree
|
|
259
|
+
|
|
260
|
+
# 查找所有 c:cat 元素
|
|
261
|
+
cat_elements = chart_element.findall('.//{http://schemas.openxmlformats.org/drawingml/2006/chart}cat')
|
|
262
|
+
|
|
263
|
+
converted_count = 0
|
|
264
|
+
for cat_elem in cat_elements:
|
|
265
|
+
# 查找 strCache(直接子元素)
|
|
266
|
+
str_cache = cat_elem.find('{http://schemas.openxmlformats.org/drawingml/2006/chart}strCache')
|
|
267
|
+
if str_cache is None:
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
# 提取数据点
|
|
271
|
+
pt_count_elem = str_cache.find('{http://schemas.openxmlformats.org/drawingml/2006/chart}ptCount')
|
|
272
|
+
pt_count = int(pt_count_elem.get('val')) if pt_count_elem is not None else 0
|
|
273
|
+
|
|
274
|
+
pts = str_cache.findall('{http://schemas.openxmlformats.org/drawingml/2006/chart}pt')
|
|
275
|
+
|
|
276
|
+
if not pts:
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
# 创建新的 numCache 元素
|
|
280
|
+
num_cache = etree.Element(f"{{{NAMESPACES['c']}}}numCache")
|
|
281
|
+
|
|
282
|
+
# 添加格式代码
|
|
283
|
+
format_code = etree.SubElement(num_cache, f"{{{NAMESPACES['c']}}}formatCode")
|
|
284
|
+
format_code.text = self.number_format if self.number_format else "yyyy/mm"
|
|
285
|
+
|
|
286
|
+
# 添加点计数
|
|
287
|
+
new_pt_count = etree.SubElement(num_cache, f"{{{NAMESPACES['c']}}}ptCount")
|
|
288
|
+
new_pt_count.set('val', str(pt_count))
|
|
289
|
+
|
|
290
|
+
# 复制所有数据点
|
|
291
|
+
for pt in pts:
|
|
292
|
+
idx = pt.get('idx')
|
|
293
|
+
v_elem = pt.find('{http://schemas.openxmlformats.org/drawingml/2006/chart}v')
|
|
294
|
+
if v_elem is not None and v_elem.text:
|
|
295
|
+
# 创建新的 pt 元素
|
|
296
|
+
new_pt = etree.SubElement(num_cache, f"{{{NAMESPACES['c']}}}pt")
|
|
297
|
+
new_pt.set('idx', idx)
|
|
298
|
+
|
|
299
|
+
# 添加值(保持数字格式)
|
|
300
|
+
new_v = etree.SubElement(new_pt, f"{{{NAMESPACES['c']}}}v")
|
|
301
|
+
new_v.text = v_elem.text
|
|
302
|
+
|
|
303
|
+
# 获取 strCache 在父元素中的位置
|
|
304
|
+
str_cache_index = list(cat_elem).index(str_cache)
|
|
305
|
+
|
|
306
|
+
# 删除旧的 strCache
|
|
307
|
+
cat_elem.remove(str_cache)
|
|
308
|
+
|
|
309
|
+
# 在相同位置插入新的 numCache
|
|
310
|
+
cat_elem.insert(str_cache_index, num_cache)
|
|
311
|
+
converted_count += 1
|
|
312
|
+
|
|
313
|
+
if converted_count > 0:
|
|
314
|
+
print(f" → 已将 {converted_count} 个分类数据转换为 numCache(数值格式)")
|
|
315
|
+
|
|
316
|
+
def _convert_catax_to_dateax(self, chart_element, cat_ax):
|
|
317
|
+
"""
|
|
318
|
+
将 catAx (分类轴) 转换为 dateAx (日期轴)
|
|
319
|
+
|
|
320
|
+
这是关键修复!PowerPoint 不允许在 catAx 中使用时间单位元素(baseTimeUnit, majorTimeUnit 等)。
|
|
321
|
+
必须将整个 catAx 元素替换为 dateAx。
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
chart_element: 图表的根 XML 元素
|
|
325
|
+
cat_ax: 要转换的 catAx 元素
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
转换后的 dateAx 元素
|
|
329
|
+
"""
|
|
330
|
+
from lxml import etree
|
|
331
|
+
|
|
332
|
+
# 创建新的 dateAx 元素
|
|
333
|
+
date_ax = etree.Element(f"{{{NAMESPACES['c']}}}dateAx")
|
|
334
|
+
|
|
335
|
+
# 复制 catAx 的所有子元素到 dateAx
|
|
336
|
+
# 移除不兼容的元素(如 lblAlgn, lblOffset, noMultiLvlLbl)
|
|
337
|
+
# 保留重要元素(如 numFmt, txPr 等)
|
|
338
|
+
skip_elements = ['lblAlgn', 'lblOffset', 'noMultiLvlLbl']
|
|
339
|
+
|
|
340
|
+
for child in cat_ax:
|
|
341
|
+
tag_name = child.tag.split('}')[-1] if '}' in child.tag else child.tag
|
|
342
|
+
if tag_name not in skip_elements:
|
|
343
|
+
# 深度复制元素(包括所有子元素)
|
|
344
|
+
new_child = etree.Element(child.tag, child.attrib)
|
|
345
|
+
new_child.text = child.text
|
|
346
|
+
new_child.tail = child.tail
|
|
347
|
+
|
|
348
|
+
# 复制所有子元素
|
|
349
|
+
for subchild in child:
|
|
350
|
+
new_child.append(subchild)
|
|
351
|
+
|
|
352
|
+
date_ax.append(new_child)
|
|
353
|
+
|
|
354
|
+
# 在父元素中替换 catAx 为 dateAx
|
|
355
|
+
parent = cat_ax.getparent()
|
|
356
|
+
if parent is not None:
|
|
357
|
+
parent_list = list(parent)
|
|
358
|
+
cat_ax_index = parent_list.index(cat_ax)
|
|
359
|
+
parent.remove(cat_ax)
|
|
360
|
+
parent.insert(cat_ax_index, date_ax)
|
|
361
|
+
|
|
362
|
+
return date_ax
|
|
363
|
+
|
|
364
|
+
def _set_or_update_element(self, parent, tag_name: str, value: str):
|
|
365
|
+
"""
|
|
366
|
+
设置或更新 XML 元素
|
|
367
|
+
|
|
368
|
+
Args:
|
|
369
|
+
parent: 父元素
|
|
370
|
+
tag_name: 标签名(不含命名空间)
|
|
371
|
+
value: 属性值
|
|
372
|
+
"""
|
|
373
|
+
full_tag = f"{{{NAMESPACES['c']}}}{tag_name}"
|
|
374
|
+
|
|
375
|
+
# 查找现有元素
|
|
376
|
+
existing = parent.find(f'.//c:{tag_name}', namespaces=NAMESPACES)
|
|
377
|
+
|
|
378
|
+
if existing is not None:
|
|
379
|
+
# 更新现有元素
|
|
380
|
+
existing.set('val', value)
|
|
381
|
+
else:
|
|
382
|
+
# 创建新元素
|
|
383
|
+
new_element = etree.Element(full_tag)
|
|
384
|
+
new_element.set('val', value)
|
|
385
|
+
|
|
386
|
+
# ⭐ 关键修复:时间单位元素必须在最后(auto 之后)
|
|
387
|
+
# OOXML 规范的 dateAx 元素顺序:
|
|
388
|
+
# axId → scaling → delete → axPos → majorTickMark → minorTickMark → tickLblPos → numFmt → crossAx → crosses → auto → [时间单位]
|
|
389
|
+
|
|
390
|
+
# 策略:在 auto 之后插入,如果没有 auto 则追加到最后
|
|
391
|
+
auto_elements = parent.findall('.//c:auto', namespaces=NAMESPACES)
|
|
392
|
+
if auto_elements and len(auto_elements) > 0:
|
|
393
|
+
# 在 auto 之后插入
|
|
394
|
+
auto_elem = auto_elements[0]
|
|
395
|
+
parent_list = list(parent)
|
|
396
|
+
auto_index = parent_list.index(auto_elem)
|
|
397
|
+
parent.insert(auto_index + 1, new_element)
|
|
398
|
+
else:
|
|
399
|
+
# 如果没有 auto,追加到最后
|
|
400
|
+
parent.append(new_element)
|
|
401
|
+
|
|
402
|
+
def _set_date_range(self, cat_ax):
|
|
403
|
+
"""设置日期范围(最小值/最大值)"""
|
|
404
|
+
# 查找或创建 scaling 元素
|
|
405
|
+
scaling = cat_ax.find('.//c:scaling', namespaces=NAMESPACES)
|
|
406
|
+
|
|
407
|
+
if scaling is None:
|
|
408
|
+
scaling = etree.Element(f"{{{NAMESPACES['c']}}}scaling")
|
|
409
|
+
# 插入到合适位置(通常在 axId 之前)
|
|
410
|
+
ax_id_elements = cat_ax.findall('.//c:axId', namespaces=NAMESPACES)
|
|
411
|
+
if ax_id_elements:
|
|
412
|
+
ax_id_elements[0].addprevious(scaling)
|
|
413
|
+
else:
|
|
414
|
+
cat_ax.insert(0, scaling)
|
|
415
|
+
|
|
416
|
+
# 设置最小值
|
|
417
|
+
if self.min_date:
|
|
418
|
+
min_val = excel_date(self.min_date)
|
|
419
|
+
self._set_scaling_value(scaling, 'min', str(min_val))
|
|
420
|
+
print(f" - 最小日期: {self.min_date.strftime('%Y-%m-%d')} (Excel: {min_val})")
|
|
421
|
+
|
|
422
|
+
# 设置最大值
|
|
423
|
+
if self.max_date:
|
|
424
|
+
max_val = excel_date(self.max_date)
|
|
425
|
+
self._set_scaling_value(scaling, 'max', str(max_val))
|
|
426
|
+
print(f" - 最大日期: {self.max_date.strftime('%Y-%m-%d')} (Excel: {max_val})")
|
|
427
|
+
|
|
428
|
+
def _set_scaling_value(self, scaling_element, tag_name: str, value: str):
|
|
429
|
+
"""在 scaling 元素中设置 min/max"""
|
|
430
|
+
full_tag = f"{{{NAMESPACES['c']}}}{tag_name}"
|
|
431
|
+
|
|
432
|
+
existing = scaling_element.find(f'.//c:{tag_name}', namespaces=NAMESPACES)
|
|
433
|
+
|
|
434
|
+
if existing is not None:
|
|
435
|
+
existing.set('val', value)
|
|
436
|
+
else:
|
|
437
|
+
new_element = etree.Element(full_tag)
|
|
438
|
+
new_element.set('val', value)
|
|
439
|
+
scaling_element.append(new_element)
|
|
440
|
+
|
|
441
|
+
def _set_date_format(self, axis_element, format_code: str):
|
|
442
|
+
"""
|
|
443
|
+
设置日期轴的数字格式(numFmt)
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
axis_element: 轴元素(dateAx 或 catAx)
|
|
447
|
+
format_code: Excel 格式代码(如 "yyyy/mm", "mm-dd" 等)
|
|
448
|
+
"""
|
|
449
|
+
from lxml import etree
|
|
450
|
+
|
|
451
|
+
# 查找或创建 numFmt 元素
|
|
452
|
+
numfmt = axis_element.find('.//c:numFmt', namespaces=NAMESPACES)
|
|
453
|
+
|
|
454
|
+
if numfmt is None:
|
|
455
|
+
# 创建新的 numFmt 元素
|
|
456
|
+
numfmt = etree.Element(f"{{{NAMESPACES['c']}}}numFmt")
|
|
457
|
+
|
|
458
|
+
# ⭐ 关键:numFmt 应该在 tickLblPos 之后、crossAx 之前插入
|
|
459
|
+
# 正确顺序:axId → scaling → delete → axPos → majorTickMark → minorTickMark → tickLblPos → numFmt → crossAx → crosses → auto → [时间单位]
|
|
460
|
+
tick_lbl_pos_elements = axis_element.findall('.//c:tickLblPos', namespaces=NAMESPACES)
|
|
461
|
+
if tick_lbl_pos_elements:
|
|
462
|
+
tick_lbl_pos = tick_lbl_pos_elements[0]
|
|
463
|
+
parent_list = list(axis_element)
|
|
464
|
+
pos_index = parent_list.index(tick_lbl_pos)
|
|
465
|
+
axis_element.insert(pos_index + 1, numfmt)
|
|
466
|
+
else:
|
|
467
|
+
# Fallback:在 crossAx 之前
|
|
468
|
+
cross_ax_elements = axis_element.findall('.//c:crossAx', namespaces=NAMESPACES)
|
|
469
|
+
if cross_ax_elements:
|
|
470
|
+
cross_ax = cross_ax_elements[0]
|
|
471
|
+
parent_list = list(axis_element)
|
|
472
|
+
cross_ax_index = parent_list.index(cross_ax)
|
|
473
|
+
axis_element.insert(cross_ax_index, numfmt)
|
|
474
|
+
else:
|
|
475
|
+
# 最后的fallback:追加到最后
|
|
476
|
+
axis_element.append(numfmt)
|
|
477
|
+
|
|
478
|
+
# 设置格式代码和 sourceLinked
|
|
479
|
+
numfmt.set('formatCode', format_code)
|
|
480
|
+
numfmt.set('sourceLinked', '0') # 不链接到数据源
|
|
481
|
+
|
|
482
|
+
def _set_date_range_from_data(self, date_ax, chart_element):
|
|
483
|
+
"""
|
|
484
|
+
从数据中提取日期范围并设置到 dateAx 的 scaling 中
|
|
485
|
+
|
|
486
|
+
这是关键修复!PowerPoint 的 dateAx 默认从 0(1900-01-01)开始计算。
|
|
487
|
+
必须明确设置 min 和 max 值来指定实际的日期范围。
|
|
488
|
+
|
|
489
|
+
Args:
|
|
490
|
+
date_ax: dateAx 元素
|
|
491
|
+
chart_element: 图表根元素
|
|
492
|
+
"""
|
|
493
|
+
from lxml import etree
|
|
494
|
+
|
|
495
|
+
# 查找第一个 cat 元素中的 numCache 或 numRef
|
|
496
|
+
cat_elements = chart_element.findall('.//c:cat', namespaces=NAMESPACES)
|
|
497
|
+
|
|
498
|
+
if not cat_elements:
|
|
499
|
+
print(" ⚠️ 未找到分类数据,跳过日期范围设置")
|
|
500
|
+
return
|
|
501
|
+
|
|
502
|
+
cat_elem = cat_elements[0]
|
|
503
|
+
|
|
504
|
+
# 尝试从 numCache 中提取日期值
|
|
505
|
+
num_cache = cat_elem.find('.//c:numCache', namespaces=NAMESPACES)
|
|
506
|
+
if num_cache is not None:
|
|
507
|
+
pt_elements = num_cache.findall('.//c:pt', namespaces=NAMESPACES)
|
|
508
|
+
if pt_elements:
|
|
509
|
+
# 获取第一个和最后一个日期值
|
|
510
|
+
first_pt = pt_elements[0]
|
|
511
|
+
last_pt = pt_elements[-1]
|
|
512
|
+
|
|
513
|
+
first_v = first_pt.find('c:v', namespaces=NAMESPACES)
|
|
514
|
+
last_v = last_pt.find('c:v', namespaces=NAMESPACES)
|
|
515
|
+
|
|
516
|
+
if first_v is not None and last_v is not None:
|
|
517
|
+
try:
|
|
518
|
+
min_val = float(first_v.text)
|
|
519
|
+
max_val = float(last_v.text)
|
|
520
|
+
|
|
521
|
+
# 转换为日期显示
|
|
522
|
+
from datetime import datetime, timedelta
|
|
523
|
+
min_date = datetime(1899, 12, 30) + timedelta(days=min_val)
|
|
524
|
+
max_date = datetime(1899, 12, 30) + timedelta(days=max_val)
|
|
525
|
+
|
|
526
|
+
# 设置 scaling 的 min 和 max
|
|
527
|
+
self._set_scaling_min_max(date_ax, min_val, max_val)
|
|
528
|
+
|
|
529
|
+
print(f" ✅ 已设置日期范围: {min_date.strftime('%Y-%m-%d')} ~ {max_date.strftime('%Y-%m-%d')}")
|
|
530
|
+
print(f" (Excel 序列号: {min_val} ~ {max_val})")
|
|
531
|
+
return
|
|
532
|
+
except (ValueError, AttributeError) as e:
|
|
533
|
+
print(f" ⚠️ 提取日期范围失败: {e}")
|
|
534
|
+
|
|
535
|
+
print(" ⚠️ 未能从数据中提取日期范围")
|
|
536
|
+
|
|
537
|
+
def _set_scaling_min_max(self, axis_element, min_val, max_val):
|
|
538
|
+
"""
|
|
539
|
+
设置 scaling 元素的 min 和 max 值
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
axis_element: 轴元素(dateAx 或 catAx)
|
|
543
|
+
min_val: 最小值(Excel 日期序列号)
|
|
544
|
+
max_val: 最大值(Excel 日期序列号)
|
|
545
|
+
"""
|
|
546
|
+
from lxml import etree
|
|
547
|
+
|
|
548
|
+
# 查找或创建 scaling 元素
|
|
549
|
+
scaling = axis_element.find('.//c:scaling', namespaces=NAMESPACES)
|
|
550
|
+
|
|
551
|
+
if scaling is None:
|
|
552
|
+
# 创建 scaling 元素
|
|
553
|
+
scaling = etree.Element(f"{{{NAMESPACES['c']}}}scaling")
|
|
554
|
+
|
|
555
|
+
# scaling 应该在 axId 之后、delete 之前插入
|
|
556
|
+
ax_id_elements = axis_element.findall('.//c:axId', namespaces=NAMESPACES)
|
|
557
|
+
if ax_id_elements:
|
|
558
|
+
ax_id = ax_id_elements[0]
|
|
559
|
+
parent_list = list(axis_element)
|
|
560
|
+
ax_id_index = parent_list.index(ax_id)
|
|
561
|
+
axis_element.insert(ax_id_index + 1, scaling)
|
|
562
|
+
else:
|
|
563
|
+
axis_element.insert(0, scaling)
|
|
564
|
+
|
|
565
|
+
# 设置 min 值
|
|
566
|
+
self._set_scaling_value(scaling, 'min', str(min_val))
|
|
567
|
+
|
|
568
|
+
# 设置 max 值
|
|
569
|
+
self._set_scaling_value(scaling, 'max', str(max_val))
|
|
570
|
+
|
|
571
|
+
def _set_tick_label_position(self, axis_element, position: str):
|
|
572
|
+
"""
|
|
573
|
+
设置刻度标签位置
|
|
574
|
+
|
|
575
|
+
Args:
|
|
576
|
+
axis_element: 轴元素(catAx 或 dateAx)
|
|
577
|
+
position: 位置值('low', 'high', 'nextTo')
|
|
578
|
+
"""
|
|
579
|
+
# 查找 tickLblPos 元素
|
|
580
|
+
tick_lbl_pos = axis_element.find('.//c:tickLblPos', namespaces=NAMESPACES)
|
|
581
|
+
|
|
582
|
+
if tick_lbl_pos is not None:
|
|
583
|
+
# 更新现有元素
|
|
584
|
+
tick_lbl_pos.set('val', position)
|
|
585
|
+
else:
|
|
586
|
+
# 如果不存在,不创建(使用默认值)
|
|
587
|
+
pass
|
|
588
|
+
|
|
589
|
+
def _set_label_interval(self, axis_element, interval: int):
|
|
590
|
+
"""
|
|
591
|
+
设置标签显示间隔
|
|
592
|
+
|
|
593
|
+
Args:
|
|
594
|
+
axis_element: 轴元素(catAx 或 dateAx)
|
|
595
|
+
interval: 间隔值(每隔多少个数据点显示一个标签)
|
|
596
|
+
"""
|
|
597
|
+
from lxml import etree
|
|
598
|
+
|
|
599
|
+
# 查找或创建 tickLblSkip 元素
|
|
600
|
+
tick_lbl_skip = axis_element.find('.//c:tickLblSkip', namespaces=NAMESPACES)
|
|
601
|
+
|
|
602
|
+
if tick_lbl_skip is None:
|
|
603
|
+
# 创建新的 tickLblSkip 元素
|
|
604
|
+
tick_lbl_skip = etree.Element(f"{{{NAMESPACES['c']}}}tickLblSkip")
|
|
605
|
+
|
|
606
|
+
# tickLblSkip 应该在 auto 之后插入
|
|
607
|
+
auto_elements = axis_element.findall('.//c:auto', namespaces=NAMESPACES)
|
|
608
|
+
if auto_elements:
|
|
609
|
+
auto_elem = auto_elements[0]
|
|
610
|
+
parent_list = list(axis_element)
|
|
611
|
+
auto_index = parent_list.index(auto_elem)
|
|
612
|
+
axis_element.insert(auto_index + 1, tick_lbl_skip)
|
|
613
|
+
else:
|
|
614
|
+
# Fallback:追加到最后
|
|
615
|
+
axis_element.append(tick_lbl_skip)
|
|
616
|
+
|
|
617
|
+
# 设置间隔值
|
|
618
|
+
tick_lbl_skip.set('val', str(interval))
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
# ============================================================================
|
|
624
|
+
# 便捷预设
|
|
625
|
+
# ============================================================================
|
|
626
|
+
|
|
627
|
+
# 每日刻度(适用于短期数据:1-30天)
|
|
628
|
+
DAILY_TICKS = DateAxisConfig(
|
|
629
|
+
base_unit="days",
|
|
630
|
+
major_unit=1.0,
|
|
631
|
+
major_unit_scale="days",
|
|
632
|
+
number_format="mm-dd",
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
# 每周刻度(适用于中期数据:1-6个月)
|
|
636
|
+
WEEKLY_TICKS = DateAxisConfig(
|
|
637
|
+
base_unit="days",
|
|
638
|
+
major_unit=7.0,
|
|
639
|
+
major_unit_scale="days",
|
|
640
|
+
number_format="yyyy-mm-dd",
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
# 每双周刻度(适用于季度数据)
|
|
644
|
+
BIWEEKLY_TICKS = DateAxisConfig(
|
|
645
|
+
base_unit="days",
|
|
646
|
+
major_unit=14.0,
|
|
647
|
+
major_unit_scale="days",
|
|
648
|
+
number_format="yyyy-mm-dd",
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
# 每月刻度(适用于年度数据)
|
|
652
|
+
MONTHLY_TICKS = DateAxisConfig(
|
|
653
|
+
base_unit="months",
|
|
654
|
+
major_unit=1.0,
|
|
655
|
+
major_unit_scale="months",
|
|
656
|
+
number_format="yyyy-mm",
|
|
657
|
+
)
|
|
658
|
+
|
|
659
|
+
# 每季度刻度(适用于多年数据)
|
|
660
|
+
QUARTERLY_TICKS = DateAxisConfig(
|
|
661
|
+
base_unit="months",
|
|
662
|
+
major_unit=3.0,
|
|
663
|
+
major_unit_scale="months",
|
|
664
|
+
number_format="yyyy-mm",
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
# 每年刻度(适用于长期数据)
|
|
668
|
+
YEARLY_TICKS = DateAxisConfig(
|
|
669
|
+
base_unit="years",
|
|
670
|
+
major_unit=1.0,
|
|
671
|
+
major_unit_scale="years",
|
|
672
|
+
number_format="yyyy",
|
|
673
|
+
)
|