""" 图表构建器 - 有状态的编排器 这是重构后的核心,使用有状态的构建器模式来编排 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 自动创建的图表元素 # 这些通常是 , 等 # 获取所有可能的图表类型元素 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" → 清理引导图表元素: ") 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 - 会创建一个新的绘图元素 (, 等) - 并为每个系列添加 元素 """ 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 级别添加共享分类数据 # 每个系列有自己的 元素,避免重复 # categories = self.df[self.categories_col].tolist() # add_plot_categories(plot_element, categories) # print(f" - 添加共享分类数据({len(categories)} 个分类)") # 为每个系列添加 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 元素顺序正确: ... 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)