365 lines
12 KiB
Python
365 lines
12 KiB
Python
|
|
"""
|
|||
|
|
公共 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)
|
|||
|
|
|
|||
|
|
用途:
|
|||
|
|
- 激活 <c:plotArea>,使其可以通过 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
|
|||
|
|
)
|
|||
|
|
|