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/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
+ )