""" 公共 API - 简洁的高层接口 这是用户(您的工作室同事)唯一需要导入的模块。 隐藏所有实现细节。 """ from typing import List, Dict, Optional from pptx.slide import Slide from pptx.enum.chart import XL_CHART_TYPE from pptx.chart.data import CategoryChartData from pptx.util import Inches import pandas as pd from .builder import ChartBuilder # 导入样式模块 try: from .styles import StyleConfig, DEFAULT_STYLE_CONFIG except ImportError: StyleConfig = None DEFAULT_STYLE_CONFIG = None # 导入布局模块 try: from .layout import ChartLayoutConfig except ImportError: ChartLayoutConfig = None def create_combo_chart( slide: Slide, df: pd.DataFrame, categories_col: str, series_config: List[Dict], position: tuple = (Inches(1), Inches(2)), size: tuple = (Inches(8), Inches(4.5)), style_config=None, layout_config=None, ): """ 创建组合图(支持 P(n,2) 任意组合) Args: slide: 幻灯片对象 df: 数据 DataFrame categories_col: 分类列名(X 轴) series_config: 系列配置列表 [ {"key": "销售额", "name": "销售额", "type": "bar", "axis": "primary"}, {"key": "增长率", "name": "增长率", "type": "line", "axis": "secondary"}, {"key": "市场份额", "name": "市场份额", "type": "line", "axis": "secondary"}, ] position: 图表位置 (left, top) size: 图表大小 (width, height) style_config: 样式配置对象(可选,默认使用 DEFAULT_STYLE_CONFIG) 可以是 StyleConfig 实例,或 None 使用默认样式 layout_config: 布局配置对象(可选) 可以是 ChartLayoutConfig 实例,包含图例、轴配置 Returns: Chart 对象 Examples: >>> # 示例 1: 使用默认样式和布局 >>> chart = create_combo_chart( ... slide=slide, ... df=df, ... categories_col="日期", ... series_config=[ ... {"key": "销售额", "name": "销售额", "type": "bar", "axis": "primary"}, ... {"key": "增长率", "name": "增长率", "type": "line", "axis": "secondary"}, ... ] ... ) >>> # 示例 2: 自定义样式 + 布局 >>> from ppt_station.chart_builder.styles import StyleConfig >>> from ppt_station.chart_builder.layout import ( ... ChartLayoutConfig, ... LegendConfig, ... CategoryAxisConfig, ... ) >>> >>> # 样式配置 >>> custom_style = StyleConfig( ... color_scheme="dark_only", ... line_width_pt=1.5, ... marker_style="none", ... ) >>> >>> # 布局配置 >>> custom_layout = ChartLayoutConfig( ... legend_config=LegendConfig(position="bottom", font_size_pt=10), ... category_axis_config=CategoryAxisConfig( ... is_date_axis=True, ... major_unit_days=7, # 每周显示一个刻度 ... number_format="yyyy-mm-dd", ... ), ... ) >>> >>> chart = create_combo_chart( ... slide=slide, ... df=df, ... categories_col="日期", ... series_config=[...], ... style_config=custom_style, ... layout_config=custom_layout ... ) Supported Combinations: - type: 'bar', 'column', 'line', 'area' (散点图待实现) - axis: 'primary', 'secondary' - 任意 (type1, axis1) + (type2, axis2) 的组合 - 支持主轴多种类型,次轴多种类型 Notes: - 分类(X轴)在 Excel 中占用 A 列 - 系列数据从 B 列开始 - 支持超过 25 个系列(AA, AB, ...) - 左轴标签在左侧,右轴标签在右侧,不会重叠 - 默认样式:无标记点、1pt 线宽、深浅色交替 - 默认布局:图例在底部、横轴普通分类轴 """ if not series_config: raise ValueError("series_config 不能为空") # 1. 按 (type, axis) 分组(用于决定引导图表类型) plot_groups = _group_series(series_config) # 2. 创建引导图表(写入全部系列数据到嵌入 Excel) chart = _bootstrap_chart( slide, df, categories_col, series_config, position, size ) # ⭐ 核心修复:修正嵌入的 Excel 工作表中的日期数据 # 如果分类列是日期类型,需要将 Excel 工作表中的文本日期转换为真实的日期数值 _fix_embedded_excel_dates(chart, df, categories_col) # 3. 使用构建器完成剩余工作(传递样式配置和布局配置) builder = ChartBuilder( chart, df, categories_col, style_config=style_config if style_config is not None else DEFAULT_STYLE_CONFIG, layout_config=layout_config ) # 注意:引导图表已经创建了第一个系列,构建器会继续追加 # 如果需要完全自定义,可以在 builder.clear_bootstrap_chart() 中清理 return builder.build(series_config) def _bootstrap_chart( slide: Slide, df: pd.DataFrame, categories_col: str, series_config: List[Dict], position: tuple, size: tuple, ): """ 创建引导图表(写入全部系列数据到嵌入 Excel) 用途: - 激活 ,使其可以通过 XML 访问 - 创建初始的分类轴和值轴 - 将所有系列数据写入嵌入 Excel(确保"编辑数据"不丢数据) - 图表 XML 结构后续由 ChartBuilder 重建 Args: slide: 幻灯片对象 df: 数据 DataFrame categories_col: 分类列名 series_config: 全部系列配置列表 position: (left, top) size: (width, height) Returns: Chart 对象 """ chart_data = CategoryChartData() # 设置分类(X轴) categories = df[categories_col].tolist() if pd.api.types.is_datetime64_any_dtype(df[categories_col]): categories_bootstrap = [cat.strftime("%Y-%m-%d") if hasattr(cat, 'strftime') else str(cat) for cat in categories] else: categories_bootstrap = categories chart_data.categories = categories_bootstrap # 添加全部系列数据(确保嵌入 Excel 包含所有列) for series_cfg in series_config: chart_data.add_series( series_cfg["name"], df[series_cfg["key"]].tolist() ) # 使用第一个系列的类型决定引导图表类型 chart_type = _get_chart_type(series_config[0].get("type", "bar")) # 创建图表 left, top = position width, height = size graphic_frame = slide.shapes.add_chart( chart_type, left, top, width, height, chart_data ) return graphic_frame.chart def _get_chart_type(type_str: str) -> XL_CHART_TYPE: """将图表类型字符串转换为 XL_CHART_TYPE 枚举""" type_map = { "bar": XL_CHART_TYPE.COLUMN_CLUSTERED, "column": XL_CHART_TYPE.COLUMN_CLUSTERED, "line": XL_CHART_TYPE.LINE, "area": XL_CHART_TYPE.AREA, } return type_map.get(type_str.lower(), XL_CHART_TYPE.COLUMN_CLUSTERED) def _group_series(series_config: List[Dict]) -> Dict[tuple, List[Dict]]: """按 (type, axis) 分组系列""" from collections import defaultdict groups = defaultdict(list) for cfg in series_config: key = (cfg.get("type", "bar"), cfg.get("axis", "primary")) groups[key].append(cfg) return dict(groups) def _fix_embedded_excel_dates(chart, df: pd.DataFrame, categories_col: str): """ 修正嵌入的 Excel 工作表中的日期数据 新方案:将日期格式化为字符串标签(如 "2024/01") 这样 PowerPoint 就会正确显示,而不会出现 1900 年问题 Args: chart: python-pptx Chart 对象 df: 数据 DataFrame categories_col: 分类列名 """ # 检查是否为日期类型 if not pd.api.types.is_datetime64_any_dtype(df[categories_col]): print(f" → 分类列不是日期类型,跳过 Excel 工作表修正") return # 不是日期类型,无需修正 print(f"\n🔧 修正嵌入的 Excel 工作表日期数据(转换为格式化字符串)...") try: from datetime import datetime from openpyxl import load_workbook import io # 获取嵌入的 Excel 数据 chart_part = chart.part xlsx_part = chart_part.chart_workbook.xlsx_part print(f" → 找到嵌入的 Excel 工作表") # 将 Excel blob 加载为 openpyxl workbook xlsx_stream = io.BytesIO(xlsx_part.blob) wb = load_workbook(xlsx_stream) ws = wb.active print(f" → 工作表行数: {ws.max_row}, 列数: {ws.max_column}") # 获取日期数据 categories = df[categories_col].tolist() print(f" → 准备修正 {len(categories)} 个日期值") print(f" → 第一个值: {categories[0]} (类型: {type(categories[0])})") # 修正 A 列(分类列)的数据 - 转换为格式化字符串 # Excel 工作表的第一行是表头,数据从第二行开始 fixed_count = 0 for i, cat_value in enumerate(categories, start=2): if hasattr(cat_value, 'to_pydatetime'): cat_value = cat_value.to_pydatetime() if isinstance(cat_value, datetime): # ⭐ 将日期格式化为字符串 "yyyy/mm"(年份/月份) date_str = cat_value.strftime('%Y/%m') ws.cell(row=i, column=1).value = date_str # 不设置数字格式,保持为文本 fixed_count += 1 print(f" → 已修正 {fixed_count} 个单元格") print(f" → 示例:{categories[0].strftime('%Y/%m') if isinstance(categories[0], datetime) or hasattr(categories[0], 'strftime') else 'N/A'}") # 将修改后的 workbook 写回 blob output_stream = io.BytesIO() wb.save(output_stream) xlsx_part._blob = output_stream.getvalue() print(f" ✅ 嵌入 Excel 工作表修正完成({fixed_count} 个日期值转换为格式化字符串)") except Exception as e: print(f" ⚠️ 修正嵌入 Excel 工作表失败: {e}") import traceback traceback.print_exc() # ============================================================================ # 便捷函数:向后兼容 # ============================================================================ def create_dual_axis_chart( slide: Slide, df: pd.DataFrame, categories_col: str, bar_columns: List[str], bar_names: List[str], line_columns: List[str], line_names: List[str], position: tuple = (Inches(1), Inches(2)), size: tuple = (Inches(8), Inches(4.5)), ): """ 便捷函数:创建双轴组合图(柱状图 + 折线图) 这是向后兼容的 API,与旧的 xml_chart_patcher 接口一致。 Example: >>> create_dual_axis_chart( ... slide=slide, ... df=df, ... categories_col="日期", ... bar_columns=["销售额", "成本"], ... bar_names=["销售额", "成本"], ... line_columns=["利润率"], ... line_names=["利润率"], ... ) """ # 构建统一的 series_config series_config = [] # 主轴柱状图 for col, name in zip(bar_columns, bar_names): series_config.append({ "key": col, "name": name, "type": "bar", "axis": "primary" }) # 次轴折线图 for col, name in zip(line_columns, line_names): series_config.append({ "key": col, "name": name, "type": "line", "axis": "secondary" }) # 调用统一的 API return create_combo_chart( slide, df, categories_col, series_config, position, size )