skills/ppt-station-skill/ppt_station/chart_builder/oxml/axes.py

216 lines
7.2 KiB
Python
Raw Normal View History

"""
坐标轴 XML 操作模块
负责创建提取和优化坐标轴元素
"""
from lxml import etree
from typing import Tuple
from ..oxml_ns import NAMESPACES
def extract_axis_ids(plotArea) -> Tuple[int, int]:
"""
提取现有坐标轴的 ID
Args:
plotArea: 绘图区元素 (lxml Element)
Returns:
(cat_ax_id, val_ax_id) 元组
Raises:
ValueError: 如果无法找到坐标轴 ID
"""
# python-pptx 的 BaseOxmlElement.xpath() 已经注册了命名空间
cat_ax_elements = plotArea.xpath('.//c:catAx/c:axId')
val_ax_elements = plotArea.xpath('.//c:valAx/c:axId')
if not cat_ax_elements or not val_ax_elements:
raise ValueError("无法找到现有坐标轴 ID")
cat_ax_id = int(cat_ax_elements[0].get('val'))
val_ax_id = int(val_ax_elements[0].get('val'))
return cat_ax_id, val_ax_id
def create_value_axis(
plotArea,
ax_id: int,
cross_ax_id: int,
position: str = 'r',
tick_label_position: str = 'high',
crosses_at: str = 'max',
) -> int:
"""
创建一个新的值轴 (Y轴)
Args:
plotArea: 绘图区元素 (lxml Element)
ax_id: 新轴的 ID
cross_ax_id: 交叉轴的 ID通常是分类轴
position: 轴位置 ('l'=, 'r'=, 't'=, 'b'=)
tick_label_position: 标签位置 ('low'=/, 'high'=/, 'nextTo'=靠近轴)
crosses_at: 交叉位置 ('min'=最小值/左边, 'max'=最大值/右边)
Returns:
创建的轴 ID
Notes:
- 严格按照 OOXML 规范 (ISO/IEC 29500-1:2016) 的元素顺序
- 'low' 'high' 用于双轴图确保标签不重叠
- crosses_at='min' 让轴线在图表左边'max' 让轴线在图表右边
"""
# 创建值轴元素
valAx = etree.SubElement(plotArea, f"{{{NAMESPACES['c']}}}valAx")
# ⭐ 严格按照 OOXML 规范顺序添加子元素
# 1. axId (轴 ID) - 必需
axId_elem = etree.SubElement(valAx, f"{{{NAMESPACES['c']}}}axId")
axId_elem.set('val', str(ax_id))
# 2. scaling (缩放) - 必需
scaling = etree.SubElement(valAx, f"{{{NAMESPACES['c']}}}scaling")
orientation = etree.SubElement(scaling, f"{{{NAMESPACES['c']}}}orientation")
orientation.set('val', 'minMax')
# 3. delete (是否隐藏) - 必需
delete = etree.SubElement(valAx, f"{{{NAMESPACES['c']}}}delete")
delete.set('val', '0')
# 4. axPos (轴位置) - 必需
axPos = etree.SubElement(valAx, f"{{{NAMESPACES['c']}}}axPos")
axPos.set('val', position)
# 5. majorGridlines (主网格线) - 可选
# 次轴通常不显示网格线,避免与主轴重叠
# 如果需要,调用方可以手动添加
# 6. numFmt (数字格式) - 可选
numFmt = etree.SubElement(valAx, f"{{{NAMESPACES['c']}}}numFmt")
numFmt.set('formatCode', 'General')
numFmt.set('sourceLinked', '0')
# 7. majorTickMark (主刻度线) - 可选
majorTickMark = etree.SubElement(valAx, f"{{{NAMESPACES['c']}}}majorTickMark")
majorTickMark.set('val', 'out')
# 8. minorTickMark (次刻度线) - 可选
minorTickMark = etree.SubElement(valAx, f"{{{NAMESPACES['c']}}}minorTickMark")
minorTickMark.set('val', 'none')
# 9. tickLblPos (标签位置) - 可选
tickLblPos = etree.SubElement(valAx, f"{{{NAMESPACES['c']}}}tickLblPos")
tickLblPos.set('val', tick_label_position)
# 10. crossAx (交叉轴 ID) - 必需
crossAx = etree.SubElement(valAx, f"{{{NAMESPACES['c']}}}crossAx")
crossAx.set('val', str(cross_ax_id))
# 11. crosses (交叉方式) - 可选
# ⭐ 关键修复:根据 crosses_at 参数决定交叉位置
# 'min' = 在最小值(左边)交叉,'max' = 在最大值(右边)交叉
crosses = etree.SubElement(valAx, f"{{{NAMESPACES['c']}}}crosses")
crosses.set('val', crosses_at)
# 12. crossBetween (交叉位置) - 可选
crossBetween = etree.SubElement(valAx, f"{{{NAMESPACES['c']}}}crossBetween")
crossBetween.set('val', 'between')
return ax_id
def optimize_axis_labels(
plotArea,
ax_id: int,
tick_label_position: str = 'low',
crosses_at: str = 'min',
remove_gridlines: bool = True,
):
"""
优化现有轴的标签位置和交叉位置
Args:
plotArea: 绘图区元素
ax_id: 要优化的轴 ID
tick_label_position: 标签位置 ('low'=/, 'high'=/)
crosses_at: 交叉位置 ('min'=最小值/左边, 'max'=最大值/右边)
remove_gridlines: 是否移除网格线默认移除
Notes:
主要用于优化主值轴使其与次值轴协调
crosses_at='min' 让主轴线在图表左边'max' 让次轴线在图表右边
"""
# 查找指定的值轴
val_ax_elements = plotArea.xpath(f'.//c:valAx[c:axId[@val="{ax_id}"]]')
if not val_ax_elements:
return # 轴不存在,跳过
val_ax = val_ax_elements[0]
# ⭐ 设置 crosses 位置
crosses_elements = val_ax.xpath('./c:crosses')
if crosses_elements:
crosses_elements[0].set('val', crosses_at)
# 设置标签位置
tickLblPos_elements = val_ax.xpath('./c:tickLblPos')
if tickLblPos_elements:
tickLblPos_elements[0].set('val', tick_label_position)
else:
# 如果不存在,创建一个
tickLblPos = etree.Element(f"{{{NAMESPACES['c']}}}tickLblPos")
tickLblPos.set('val', tick_label_position)
# 插入到 crossAx 之前(保持正确顺序)
cross_ax_elements = val_ax.xpath('./c:crossAx')
if cross_ax_elements:
cross_ax_elements[0].addprevious(tickLblPos)
# ⭐ 移除网格线(取消内部横框)
if remove_gridlines:
gridlines = val_ax.xpath('./c:majorGridlines')
for gridline in gridlines:
val_ax.remove(gridline)
print(f" → 已移除主值轴网格线")
def optimize_category_axis(
plotArea,
cat_ax_id: int,
remove_tick_marks: bool = True,
):
"""
优化分类轴X轴的显示
Args:
plotArea: 绘图区元素
cat_ax_id: 分类轴 ID
remove_tick_marks: 是否移除主刻度线默认移除即取消日期间的小竖线
Notes:
用于清理分类轴的视觉元素让图表更简洁
"""
# 查找分类轴
cat_ax_elements = plotArea.xpath(f'.//c:catAx[c:axId[@val="{cat_ax_id}"]]')
if not cat_ax_elements:
return # 轴不存在,跳过
cat_ax = cat_ax_elements[0]
# ⭐ 移除或设置主刻度线为 'none'(取消日期间的小竖线)
if remove_tick_marks:
majorTickMark_elements = cat_ax.xpath('./c:majorTickMark')
if majorTickMark_elements:
# 修改为 'none' 而不是删除元素
majorTickMark_elements[0].set('val', 'none')
print(f" → 已移除分类轴主刻度线(日期间的小竖线)")
# 同时也设置次刻度线为 'none'
minorTickMark_elements = cat_ax.xpath('./c:minorTickMark')
if minorTickMark_elements:
minorTickMark_elements[0].set('val', 'none')