skills/ppt-station-skill/ppt_station/chart_builder/date_axis.py

588 lines
22 KiB
Python
Raw Normal View History

"""
日期轴 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",
)