skills/ppt-station-skill/ppt_station/chart_builder/builder.py
wangyitong a65adcc2e5 Initial commit: merged, deduplicated, and vetted skill collection
Sources: extracted from two upstream archives (skill-repo, skills-main),
merged with the following policy:

- 15 broken symlinks (pointing to /Users/jameslee/.cc-switch/skills or
  ../../.agents/skills on a foreign machine) discarded
- 3 real name collisions with identical content (ai-pair, ifind-http-api,
  zhipu-websearch) kept as one copy
- Functional overlaps deduped keeping the strongest variant:
  - docx family: kept docx (official, full toolchain) + docx-cn
    (GB/T 9704 Chinese official-document constants),
    dropped docx_writer (no scripts, name collided with docx)
  - humanizer family: kept humanizer-zh (6 zh reference docs),
    dropped humanizer (en, redundant for CN workflow)
- Skills that only ran in a foreign environment removed:
  ablemind-ops, app-publish, hlb-design-system, openclaw-adj-skill,
  claude-driver
- alphapai excluded from this public repo because its SKILL.md hard-coded
  live credentials

Result: 25 skills, 572 files, ~7.5 MB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:47:12 +08:00

318 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
图表构建器 - 有状态的编排器
这是重构后的核心,使用有状态的构建器模式来编排 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)