skills/ppt-station-skill/ppt_station/chart_builder/date_axis.py
wangyitong a65adcc2e5 Initial commit: merged, deduplicated, and vetted skill collection
Sources: extracted from two upstream archives (skill-repo, skills-main),
merged with the following policy:

- 15 broken symlinks (pointing to /Users/jameslee/.cc-switch/skills or
  ../../.agents/skills on a foreign machine) discarded
- 3 real name collisions with identical content (ai-pair, ifind-http-api,
  zhipu-websearch) kept as one copy
- Functional overlaps deduped keeping the strongest variant:
  - docx family: kept docx (official, full toolchain) + docx-cn
    (GB/T 9704 Chinese official-document constants),
    dropped docx_writer (no scripts, name collided with docx)
  - humanizer family: kept humanizer-zh (6 zh reference docs),
    dropped humanizer (en, redundant for CN workflow)
- Skills that only ran in a foreign environment removed:
  ablemind-ops, app-publish, hlb-design-system, openclaw-adj-skill,
  claude-driver
- alphapai excluded from this public repo because its SKILL.md hard-coded
  live credentials

Result: 25 skills, 572 files, ~7.5 MB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:47:12 +08:00

588 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
日期轴 XML 直接操作模块
专门用于设置日期轴的底层 XML 属性,绕过 python-pptx 的限制。
使用场景:
- 横轴确定为日期类型
- 需要精确控制刻度密度
- 需要设置日期范围
"""
from lxml import etree
from typing import Optional, Literal
from datetime import datetime
from .oxml_ns import NAMESPACES
def excel_date(date: datetime) -> float:
"""
将 Python datetime 转换为 Excel 日期序号
Excel 的日期基准1900-01-01 = 1
"""
base_date = datetime(1899, 12, 30) # Excel 的实际基准日期
delta = date - base_date
return float(delta.days)
class DateAxisConfig:
"""
日期轴 XML 配置类
直接操作 XML 以实现完整的日期轴控制
"""
def __init__(
self,
base_unit: Literal["days", "months", "years"] = "days",
major_unit: float = 7.0,
major_unit_scale: Literal["days", "months", "years"] = "days",
minor_unit: Optional[float] = None,
minor_unit_scale: Literal["days", "months", "years"] = "days",
min_date: Optional[datetime] = None,
max_date: Optional[datetime] = None,
number_format: str = "yyyy-mm-dd",
auto_adjust: bool = False,
):
"""
初始化日期轴配置
Args:
base_unit: 基础时间单位days/months/years
major_unit: 主刻度单位数值(如 7 表示每 7 个单位一个刻度)
major_unit_scale: 主刻度单位类型
minor_unit: 次刻度单位数值
minor_unit_scale: 次刻度单位类型
min_date: 最小日期(用于固定范围)
max_date: 最大日期(用于固定范围)
number_format: 日期显示格式
auto_adjust: 是否允许 PowerPoint 自动调整
Examples:
>>> # 每周显示一个刻度
>>> config = DateAxisConfig(
... base_unit="days",
... major_unit=7.0,
... major_unit_scale="days",
... )
>>> # 每月显示一个刻度
>>> config = DateAxisConfig(
... base_unit="months",
... major_unit=1.0,
... major_unit_scale="months",
... )
>>> # 固定日期范围
>>> config = DateAxisConfig(
... base_unit="days",
... major_unit=14.0,
... min_date=datetime(2024, 1, 1),
... max_date=datetime(2024, 12, 31),
... )
"""
self.base_unit = base_unit
self.major_unit = major_unit
self.major_unit_scale = major_unit_scale
self.minor_unit = minor_unit or (major_unit / 7) # 默认为主刻度的 1/7
self.minor_unit_scale = minor_unit_scale
self.min_date = min_date
self.max_date = max_date
self.number_format = number_format
self.auto_adjust = auto_adjust
def apply_to_chart(self, chart):
"""
应用日期轴配置到图表
Args:
chart: python-pptx 的 Chart 对象
"""
print(f"\n📅 应用日期轴配置XML 直接操作):")
try:
# 1. 获取 XML 元素
chart_element = chart._element
# ⭐ 新方案:数据已在 plots.py 中格式化为字符串并使用 strCache
print(f" → 数据已格式化为字符串标签(如 '2024/01'),使用 strCache")
# ⭐ 关键修复查找catAx并转换为dateAx
# PowerPoint 不允许在 catAx 中使用时间单位元素,必须使用 dateAx
cat_ax_elements = chart_element.findall('.//{http://schemas.openxmlformats.org/drawingml/2006/chart}catAx')
if not cat_ax_elements:
print(" ⚠️ 未找到分类轴元素")
return
# ⭐ 新方案:不转换为 dateAx保持 catAx
# PowerPoint 对 dateAx 的时间单位元素支持不稳定
# 改用 catAx + numRef/numCache + scaling(min/max) + numFmt(日期格式)
# 这样更简单且兼容性更好
cat_ax = cat_ax_elements[0]
print(f" ✅ 使用 catAx分类轴+ 格式化字符串标签")
# ⭐ 设置标签位置为"低"(在坐标轴下方)
self._set_tick_label_position(cat_ax, 'low')
print(f" - 标签位置: 低(坐标轴下方)")
# ⭐ 设置标签间隔(控制显示数量不超过 7 个)
# 使用 major_unit 作为间隔值(向下取整)
label_interval = int(self.major_unit)
self._set_label_interval(cat_ax, label_interval)
print(f" - 标签间隔: 每 {label_interval} 个点显示一个标签")
# ⭐ 设置字体大小为 9pt
try:
category_axis = chart.category_axis
from pptx.util import Pt
category_axis.tick_labels.font.size = Pt(9)
category_axis.tick_labels.font.name = "黑体"
print(f" - 横轴字体: 黑体 9pt")
except Exception as e:
print(f" ⚠️ 字体设置失败: {e}")
print(f" ✅ 日期轴配置完成")
except Exception as e:
print(f" ❌ 日期轴配置失败: {e}")
import traceback
traceback.print_exc()
def _convert_cat_to_numcache(self, chart_element):
"""
⚠️ 备用方法:将分类数据从 strCache 转换为 numCache
【重要】此方法已不再默认调用(自修复后)
修复说明:
- 在 api.py 修复后python-pptx 会直接生成 numCache
- 此方法仅在需要手动处理旧文件或特殊场景时使用
- 如果未来遇到 strCache此方法会将字符串日期转换为 Excel 序列号
转换逻辑:
- 从 strCache 读取 "YYYY-MM-DD" 格式的字符串
- 调用 excel_date() 转换为 Excel 日期序列号(如 45567
- 写入 numCache让 PowerPoint 正确识别为日期
"""
from lxml import etree
# 查找所有 c:cat 元素
cat_elements = chart_element.findall('.//{http://schemas.openxmlformats.org/drawingml/2006/chart}cat')
converted_count = 0
for cat_elem in cat_elements:
# 查找 strCache直接子元素
str_cache = cat_elem.find('{http://schemas.openxmlformats.org/drawingml/2006/chart}strCache')
if str_cache is None:
continue
# 提取数据点
pt_count_elem = str_cache.find('{http://schemas.openxmlformats.org/drawingml/2006/chart}ptCount')
pt_count = int(pt_count_elem.get('val')) if pt_count_elem is not None else 0
pts = str_cache.findall('{http://schemas.openxmlformats.org/drawingml/2006/chart}pt')
if not pts:
continue
# 创建新的 numCache 元素
num_cache = etree.Element(f"{{{NAMESPACES['c']}}}numCache")
# 添加格式代码
format_code = etree.SubElement(num_cache, f"{{{NAMESPACES['c']}}}formatCode")
format_code.text = self.number_format if self.number_format else "yyyy/mm"
# 添加点计数
new_pt_count = etree.SubElement(num_cache, f"{{{NAMESPACES['c']}}}ptCount")
new_pt_count.set('val', str(pt_count))
# 复制所有数据点
for pt in pts:
idx = pt.get('idx')
v_elem = pt.find('{http://schemas.openxmlformats.org/drawingml/2006/chart}v')
if v_elem is not None and v_elem.text:
# 创建新的 pt 元素
new_pt = etree.SubElement(num_cache, f"{{{NAMESPACES['c']}}}pt")
new_pt.set('idx', idx)
# 添加值(保持数字格式)
new_v = etree.SubElement(new_pt, f"{{{NAMESPACES['c']}}}v")
new_v.text = v_elem.text
# 获取 strCache 在父元素中的位置
str_cache_index = list(cat_elem).index(str_cache)
# 删除旧的 strCache
cat_elem.remove(str_cache)
# 在相同位置插入新的 numCache
cat_elem.insert(str_cache_index, num_cache)
converted_count += 1
if converted_count > 0:
print(f" → 已将 {converted_count} 个分类数据转换为 numCache数值格式")
def _convert_catax_to_dateax(self, chart_element, cat_ax):
"""
将 catAx (分类轴) 转换为 dateAx (日期轴)
这是关键修复PowerPoint 不允许在 catAx 中使用时间单位元素baseTimeUnit, majorTimeUnit 等)。
必须将整个 catAx 元素替换为 dateAx。
Args:
chart_element: 图表的根 XML 元素
cat_ax: 要转换的 catAx 元素
Returns:
转换后的 dateAx 元素
"""
from lxml import etree
# 创建新的 dateAx 元素
date_ax = etree.Element(f"{{{NAMESPACES['c']}}}dateAx")
# 复制 catAx 的所有子元素到 dateAx
# 移除不兼容的元素(如 lblAlgn, lblOffset, noMultiLvlLbl
# 保留重要元素(如 numFmt, txPr 等)
skip_elements = ['lblAlgn', 'lblOffset', 'noMultiLvlLbl']
for child in cat_ax:
tag_name = child.tag.split('}')[-1] if '}' in child.tag else child.tag
if tag_name not in skip_elements:
# 深度复制元素(包括所有子元素)
new_child = etree.Element(child.tag, child.attrib)
new_child.text = child.text
new_child.tail = child.tail
# 复制所有子元素
for subchild in child:
new_child.append(subchild)
date_ax.append(new_child)
# 在父元素中替换 catAx 为 dateAx
parent = cat_ax.getparent()
if parent is not None:
parent_list = list(parent)
cat_ax_index = parent_list.index(cat_ax)
parent.remove(cat_ax)
parent.insert(cat_ax_index, date_ax)
return date_ax
def _set_or_update_element(self, parent, tag_name: str, value: str):
"""
设置或更新 XML 元素
Args:
parent: 父元素
tag_name: 标签名(不含命名空间)
value: 属性值
"""
full_tag = f"{{{NAMESPACES['c']}}}{tag_name}"
# 查找现有元素
existing = parent.find(f'.//c:{tag_name}', namespaces=NAMESPACES)
if existing is not None:
# 更新现有元素
existing.set('val', value)
else:
# 创建新元素
new_element = etree.Element(full_tag)
new_element.set('val', value)
# ⭐ 关键修复时间单位元素必须在最后auto 之后)
# OOXML 规范的 dateAx 元素顺序:
# axId → scaling → delete → axPos → majorTickMark → minorTickMark → tickLblPos → numFmt → crossAx → crosses → auto → [时间单位]
# 策略:在 auto 之后插入,如果没有 auto 则追加到最后
auto_elements = parent.findall('.//c:auto', namespaces=NAMESPACES)
if auto_elements and len(auto_elements) > 0:
# 在 auto 之后插入
auto_elem = auto_elements[0]
parent_list = list(parent)
auto_index = parent_list.index(auto_elem)
parent.insert(auto_index + 1, new_element)
else:
# 如果没有 auto追加到最后
parent.append(new_element)
def _set_date_range(self, cat_ax):
"""设置日期范围(最小值/最大值)"""
# 查找或创建 scaling 元素
scaling = cat_ax.find('.//c:scaling', namespaces=NAMESPACES)
if scaling is None:
scaling = etree.Element(f"{{{NAMESPACES['c']}}}scaling")
# 插入到合适位置(通常在 axId 之前)
ax_id_elements = cat_ax.findall('.//c:axId', namespaces=NAMESPACES)
if ax_id_elements:
ax_id_elements[0].addprevious(scaling)
else:
cat_ax.insert(0, scaling)
# 设置最小值
if self.min_date:
min_val = excel_date(self.min_date)
self._set_scaling_value(scaling, 'min', str(min_val))
print(f" - 最小日期: {self.min_date.strftime('%Y-%m-%d')} (Excel: {min_val})")
# 设置最大值
if self.max_date:
max_val = excel_date(self.max_date)
self._set_scaling_value(scaling, 'max', str(max_val))
print(f" - 最大日期: {self.max_date.strftime('%Y-%m-%d')} (Excel: {max_val})")
def _set_scaling_value(self, scaling_element, tag_name: str, value: str):
"""在 scaling 元素中设置 min/max"""
full_tag = f"{{{NAMESPACES['c']}}}{tag_name}"
existing = scaling_element.find(f'.//c:{tag_name}', namespaces=NAMESPACES)
if existing is not None:
existing.set('val', value)
else:
new_element = etree.Element(full_tag)
new_element.set('val', value)
scaling_element.append(new_element)
def _set_date_format(self, axis_element, format_code: str):
"""
设置日期轴的数字格式numFmt
Args:
axis_element: 轴元素dateAx 或 catAx
format_code: Excel 格式代码(如 "yyyy/mm", "mm-dd" 等)
"""
from lxml import etree
# 查找或创建 numFmt 元素
numfmt = axis_element.find('.//c:numFmt', namespaces=NAMESPACES)
if numfmt is None:
# 创建新的 numFmt 元素
numfmt = etree.Element(f"{{{NAMESPACES['c']}}}numFmt")
# ⭐ 关键numFmt 应该在 tickLblPos 之后、crossAx 之前插入
# 正确顺序axId → scaling → delete → axPos → majorTickMark → minorTickMark → tickLblPos → numFmt → crossAx → crosses → auto → [时间单位]
tick_lbl_pos_elements = axis_element.findall('.//c:tickLblPos', namespaces=NAMESPACES)
if tick_lbl_pos_elements:
tick_lbl_pos = tick_lbl_pos_elements[0]
parent_list = list(axis_element)
pos_index = parent_list.index(tick_lbl_pos)
axis_element.insert(pos_index + 1, numfmt)
else:
# Fallback在 crossAx 之前
cross_ax_elements = axis_element.findall('.//c:crossAx', namespaces=NAMESPACES)
if cross_ax_elements:
cross_ax = cross_ax_elements[0]
parent_list = list(axis_element)
cross_ax_index = parent_list.index(cross_ax)
axis_element.insert(cross_ax_index, numfmt)
else:
# 最后的fallback追加到最后
axis_element.append(numfmt)
# 设置格式代码和 sourceLinked
numfmt.set('formatCode', format_code)
numfmt.set('sourceLinked', '0') # 不链接到数据源
def _set_date_range_from_data(self, date_ax, chart_element):
"""
从数据中提取日期范围并设置到 dateAx 的 scaling 中
这是关键修复PowerPoint 的 dateAx 默认从 01900-01-01开始计算。
必须明确设置 min 和 max 值来指定实际的日期范围。
Args:
date_ax: dateAx 元素
chart_element: 图表根元素
"""
from lxml import etree
# 查找第一个 cat 元素中的 numCache 或 numRef
cat_elements = chart_element.findall('.//c:cat', namespaces=NAMESPACES)
if not cat_elements:
print(" ⚠️ 未找到分类数据,跳过日期范围设置")
return
cat_elem = cat_elements[0]
# 尝试从 numCache 中提取日期值
num_cache = cat_elem.find('.//c:numCache', namespaces=NAMESPACES)
if num_cache is not None:
pt_elements = num_cache.findall('.//c:pt', namespaces=NAMESPACES)
if pt_elements:
# 获取第一个和最后一个日期值
first_pt = pt_elements[0]
last_pt = pt_elements[-1]
first_v = first_pt.find('c:v', namespaces=NAMESPACES)
last_v = last_pt.find('c:v', namespaces=NAMESPACES)
if first_v is not None and last_v is not None:
try:
min_val = float(first_v.text)
max_val = float(last_v.text)
# 转换为日期显示
from datetime import datetime, timedelta
min_date = datetime(1899, 12, 30) + timedelta(days=min_val)
max_date = datetime(1899, 12, 30) + timedelta(days=max_val)
# 设置 scaling 的 min 和 max
self._set_scaling_min_max(date_ax, min_val, max_val)
print(f" ✅ 已设置日期范围: {min_date.strftime('%Y-%m-%d')} ~ {max_date.strftime('%Y-%m-%d')}")
print(f" (Excel 序列号: {min_val} ~ {max_val})")
return
except (ValueError, AttributeError) as e:
print(f" ⚠️ 提取日期范围失败: {e}")
print(" ⚠️ 未能从数据中提取日期范围")
def _set_scaling_min_max(self, axis_element, min_val, max_val):
"""
设置 scaling 元素的 min 和 max 值
Args:
axis_element: 轴元素dateAx 或 catAx
min_val: 最小值Excel 日期序列号)
max_val: 最大值Excel 日期序列号)
"""
from lxml import etree
# 查找或创建 scaling 元素
scaling = axis_element.find('.//c:scaling', namespaces=NAMESPACES)
if scaling is None:
# 创建 scaling 元素
scaling = etree.Element(f"{{{NAMESPACES['c']}}}scaling")
# scaling 应该在 axId 之后、delete 之前插入
ax_id_elements = axis_element.findall('.//c:axId', namespaces=NAMESPACES)
if ax_id_elements:
ax_id = ax_id_elements[0]
parent_list = list(axis_element)
ax_id_index = parent_list.index(ax_id)
axis_element.insert(ax_id_index + 1, scaling)
else:
axis_element.insert(0, scaling)
# 设置 min 值
self._set_scaling_value(scaling, 'min', str(min_val))
# 设置 max 值
self._set_scaling_value(scaling, 'max', str(max_val))
def _set_tick_label_position(self, axis_element, position: str):
"""
设置刻度标签位置
Args:
axis_element: 轴元素catAx 或 dateAx
position: 位置值('low', 'high', 'nextTo'
"""
# 查找 tickLblPos 元素
tick_lbl_pos = axis_element.find('.//c:tickLblPos', namespaces=NAMESPACES)
if tick_lbl_pos is not None:
# 更新现有元素
tick_lbl_pos.set('val', position)
else:
# 如果不存在,不创建(使用默认值)
pass
def _set_label_interval(self, axis_element, interval: int):
"""
设置标签显示间隔
Args:
axis_element: 轴元素catAx 或 dateAx
interval: 间隔值(每隔多少个数据点显示一个标签)
"""
from lxml import etree
# 查找或创建 tickLblSkip 元素
tick_lbl_skip = axis_element.find('.//c:tickLblSkip', namespaces=NAMESPACES)
if tick_lbl_skip is None:
# 创建新的 tickLblSkip 元素
tick_lbl_skip = etree.Element(f"{{{NAMESPACES['c']}}}tickLblSkip")
# tickLblSkip 应该在 auto 之后插入
auto_elements = axis_element.findall('.//c:auto', namespaces=NAMESPACES)
if auto_elements:
auto_elem = auto_elements[0]
parent_list = list(axis_element)
auto_index = parent_list.index(auto_elem)
axis_element.insert(auto_index + 1, tick_lbl_skip)
else:
# Fallback追加到最后
axis_element.append(tick_lbl_skip)
# 设置间隔值
tick_lbl_skip.set('val', str(interval))
# ============================================================================
# 便捷预设
# ============================================================================
# 每日刻度适用于短期数据1-30天
DAILY_TICKS = DateAxisConfig(
base_unit="days",
major_unit=1.0,
major_unit_scale="days",
number_format="mm-dd",
)
# 每周刻度适用于中期数据1-6个月
WEEKLY_TICKS = DateAxisConfig(
base_unit="days",
major_unit=7.0,
major_unit_scale="days",
number_format="yyyy-mm-dd",
)
# 每双周刻度(适用于季度数据)
BIWEEKLY_TICKS = DateAxisConfig(
base_unit="days",
major_unit=14.0,
major_unit_scale="days",
number_format="yyyy-mm-dd",
)
# 每月刻度(适用于年度数据)
MONTHLY_TICKS = DateAxisConfig(
base_unit="months",
major_unit=1.0,
major_unit_scale="months",
number_format="yyyy-mm",
)
# 每季度刻度(适用于多年数据)
QUARTERLY_TICKS = DateAxisConfig(
base_unit="months",
major_unit=3.0,
major_unit_scale="months",
number_format="yyyy-mm",
)
# 每年刻度(适用于长期数据)
YEARLY_TICKS = DateAxisConfig(
base_unit="years",
major_unit=1.0,
major_unit_scale="years",
number_format="yyyy",
)