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

318 lines
11 KiB
Python
Raw Normal View History

"""
图表构建器 - 有状态的编排器
这是重构后的核心使用有状态的构建器模式来编排 oxml 层的调用
解决 P(n,2) 组合爆炸问题
"""
from typing import List, Dict, Optional
from pptx.chart.chart import Chart
import pandas as pd
from collections import defaultdict
from .oxml import (
extract_axis_ids,
create_value_axis,
optimize_axis_labels,
optimize_category_axis,
create_plot_element,
add_axis_refs,
add_plot_categories,
add_series_to_plot,
)
# 导入样式模块
try:
from .styles import DEFAULT_STYLE_CONFIG
except ImportError:
# 如果样式模块不存在,设置为 None
DEFAULT_STYLE_CONFIG = None
class ChartBuilder:
"""
有状态的图表构建器
职责
1. 管理坐标轴主轴/次轴
2. (type, axis) 分组系列
3. 委托 oxml 层创建 XML 元素
优势
- 解决 P(n,2) 组合问题不需要为每种组合写代码
- 只需为每种图表类型实现一次 XML 生成
- 主函数动态组合它们
"""
def __init__(self, chart: Chart, df: pd.DataFrame, categories_col: str, style_config=None, layout_config=None):
"""
初始化构建器
Args:
chart: python-pptx 创建的基础图表用于激活 XML 结构
df: 数据源 DataFrame
categories_col: 分类列名
style_config: 样式配置对象可选默认使用 DEFAULT_STYLE_CONFIG
layout_config: 布局配置对象可选包含图例轴配置
"""
self.chart = chart
self.df = df
self.categories_col = categories_col
self.style_config = style_config if style_config is not None else DEFAULT_STYLE_CONFIG
self.layout_config = layout_config # 布局配置
# 访问 XML 结构
self.chartSpace = chart._chartSpace
self.plotArea = self.chartSpace.plotArea
# 提取现有坐标轴 ID
self.cat_ax_id, self.pri_val_ax_id = extract_axis_ids(self.plotArea)
self.sec_val_ax_id = None
# 系列计数器(用于 Excel 列索引)
self._series_counter = 0
print(f"\n📊 ChartBuilder 初始化完成")
print(f" - 分类轴 ID: {self.cat_ax_id}")
print(f" - 主值轴 ID: {self.pri_val_ax_id}")
if self.style_config is not None:
print(f" - 样式配置: 已启用")
else:
print(f" - 样式配置: 默认")
if self.layout_config is not None:
print(f" - 布局配置: 已启用")
def ensure_secondary_axis(self) -> int:
"""
确保次值轴存在只创建一次
Returns:
次值轴 ID
"""
if self.sec_val_ax_id is None:
# 生成新的轴 ID
new_ax_id = max(self.cat_ax_id, self.pri_val_ax_id) + 1000
# ⭐ 创建次值轴右侧标签在右边crosses='max'
self.sec_val_ax_id = create_value_axis(
self.plotArea,
ax_id=new_ax_id,
cross_ax_id=self.cat_ax_id,
position='r',
tick_label_position='high',
crosses_at='max' # 右轴线在图表右边
)
# ⭐ 优化主值轴左侧标签在左边crosses='min',移除网格线)
optimize_axis_labels(
self.plotArea,
self.pri_val_ax_id,
tick_label_position='low',
crosses_at='min', # 左轴线在图表左边
remove_gridlines=True # 移除内部横框
)
# ⭐ 优化分类轴(移除日期间的小竖线)
optimize_category_axis(
self.plotArea,
self.cat_ax_id,
remove_tick_marks=True # 移除底部日期间的小竖线
)
print(f"\n⭐ 次值轴已创建")
print(f" - 次值轴 ID: {self.sec_val_ax_id}")
print(f" - 位置: 右侧 (position='r', crosses='max')")
print(f" - 标签: 右侧 (tickLblPos='high')")
print(f" - 主值轴已优化: 左侧 (tickLblPos='low', crosses='min')")
return self.sec_val_ax_id
def clear_bootstrap_chart(self):
"""
清理引导时创建的图表元素
Note:
使用 python-pptx 创建基础图表时会自动创建一个图表元素
我们需要清理它以便完全通过 XML 自定义
"""
# 查找并删除 python-pptx 自动创建的图表元素
# 这些通常是 <c:barChart>, <c:lineChart> 等
# 获取所有可能的图表类型元素
chart_types = ['barChart', 'lineChart', 'areaChart', 'scatterChart', 'pieChart']
for chart_type in chart_types:
# 查找所有该类型的图表元素
elements = self.plotArea.xpath(f'./c:{chart_type}')
for elem in elements:
# 删除这个元素
self.plotArea.remove(elem)
print(f" → 清理引导图表元素: <c:{chart_type}>")
print(f" → 引导图表已清理")
def add_plot(self, series_group: List[Dict], plot_order_index: int = 0):
"""
添加一组系列同类型同轴
Args:
series_group: 系列配置列表
[
{"key": "col1", "name": "系列1", "type": "bar", "axis": "primary"},
{"key": "col2", "name": "系列2", "type": "bar", "axis": "primary"}
]
plot_order_index: 绘图顺序索引0=最底层1=上一层依此类推
Notes:
- 组内所有系列必须有相同的 type axis
- 会创建一个新的绘图元素 (<c:barChart>, <c:lineChart> )
- 并为每个系列添加 <c:ser> 元素
"""
if not series_group:
return
# 从第一个系列获取共享属性
first_cfg = series_group[0]
chart_type = first_cfg.get("type", "bar")
axis_type = first_cfg.get("axis", "primary")
# 决定使用哪个值轴
if axis_type == 'primary':
val_ax_id = self.pri_val_ax_id
elif axis_type == 'secondary':
val_ax_id = self.ensure_secondary_axis()
else:
raise ValueError(f"未知的轴类型: {axis_type}")
print(f"\n 添加绘图组: type={chart_type}, axis={axis_type}, order={plot_order_index}")
print(f" - 使用值轴 ID: {val_ax_id}")
# 创建绘图元素(不包含轴引用)
plot_element = create_plot_element(
self.plotArea,
chart_type,
self.cat_ax_id,
val_ax_id,
order_index=plot_order_index # ⭐ 传递 order_index
)
# ⭐ 新方案:不在 plot 级别添加共享分类数据
# 每个系列有自己的 <c:cat> 元素,避免重复
# categories = self.df[self.categories_col].tolist()
# add_plot_categories(plot_element, categories)
# print(f" - 添加共享分类数据({len(categories)} 个分类)")
# 为每个系列添加 <c:ser>
for series_cfg in series_group:
add_series_to_plot(
plot_element,
chart_type,
series_cfg,
self._series_counter,
self.df,
self.categories_col,
self.style_config # ⭐ 传递样式配置
)
print(f" - 添加系列: '{series_cfg['name']}' (索引 {self._series_counter})")
self._series_counter += 1
# ⭐ 关键修复:在所有系列添加完成后,再添加轴引用
# 确保 XML 元素顺序正确:<c:cat> <c:ser> ... <c:ser> <c:axId> <c:axId>
add_axis_refs(plot_element, self.cat_ax_id, val_ax_id)
print(f" - 添加轴引用: cat_ax={self.cat_ax_id}, val_ax={val_ax_id}")
def build(self, series_config: List[Dict]):
"""
构建完整的组合图
Args:
series_config: 系列配置列表
[
{"key": "col1", "name": "系列1", "type": "bar", "axis": "primary"},
{"key": "col2", "name": "系列2", "type": "line", "axis": "secondary"},
]
Returns:
构建后的 Chart 对象
"""
print("\n" + "=" * 80)
print("🔨 开始构建组合图")
print("=" * 80)
# 1. 按 (type, axis) 分组
plot_groups = self._group_series(series_config)
print(f"\n📦 系列分组结果:")
for key, group in plot_groups.items():
print(f" - {key}: {len(group)} 个系列")
# 2. 清理引导图表(可选)
self.clear_bootstrap_chart()
# 3. ⭐ 按堆叠顺序添加绘图组
# 规则:先添加的绘图组在底层,后添加的在上层
# 策略:先画"背景"(柱状图/面积图),再画"前景"(折线图/散点图)
plot_order_counter = 0
# 3.1 先添加所有"背景"图表 (bar, area)
for (plot_type, axis_type), series_group in plot_groups.items():
if plot_type in ('bar', 'column', 'area'):
self.add_plot(series_group, plot_order_counter)
plot_order_counter += 1
# 3.2 再添加所有"前景"图表 (line, scatter)
for (plot_type, axis_type), series_group in plot_groups.items():
if plot_type in ('line', 'scatter'):
self.add_plot(series_group, plot_order_counter)
plot_order_counter += 1
# 4. ⭐ 应用布局配置(图例、轴格式等)
if self.layout_config is not None:
self.layout_config.apply_to_chart(self.chart)
# 5. ⭐ ChartJunkCleaner: 自动清洗默认 PPT 样式
try:
from .cleaner import clean_chart
clean_chart(self.chart)
print(f" → ChartJunkCleaner applied")
except Exception as e:
print(f" → ChartJunkCleaner skipped: {e}")
print("\n" + "=" * 80)
print("✅ 组合图构建完成!")
print("=" * 80)
return self.chart
@staticmethod
def _group_series(series_config: List[Dict]) -> Dict[tuple, List[Dict]]:
"""
(type, axis) 分组系列
Args:
series_config: 系列配置列表
Returns:
分组后的字典 {(type, axis): [series_cfg, ...]}
Examples:
输入:
[
{"key": "s1", "type": "bar", "axis": "primary"},
{"key": "s2", "type": "bar", "axis": "primary"},
{"key": "s3", "type": "line", "axis": "secondary"},
]
输出:
{
("bar", "primary"): [{"key": "s1", ...}, {"key": "s2", ...}],
("line", "secondary"): [{"key": "s3", ...}]
}
"""
groups = defaultdict(list)
for cfg in series_config:
key = (cfg.get("type", "bar"), cfg.get("axis", "primary"))
groups[key].append(cfg)
return dict(groups)