skills/ppt-station-skill/ppt_station/chart_builder/parser.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

628 lines
24 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.

"""
图表解析器 - ChartBuilder 的反向操作
这是 ChartBuilder 的逆向工程:
- ChartBuilder: Config -> XML
- ChartParser: XML -> Config
核心职责:
1. 解析坐标轴映射(主轴/次轴)
2. 解析绘图区中的所有系列
3. 提取嵌入的 Excel 数据
4. 生成可用于 ChartBuilder 的配置
"""
from typing import Dict, List, Tuple, Optional
from lxml import etree
from pptx import Presentation
from pptx.chart.chart import Chart
import pandas as pd
import io
from openpyxl import load_workbook
from .oxml_ns import NAMESPACES
class ChartParser:
"""
图表解析器 - 从现有图表提取配置
使用场景:
1. 从"黄金标准"PPTX 提取图表配置
2. 批量复现图表
3. 数据更新和刷新
"""
def __init__(self, chart: Chart):
"""
初始化解析器
Args:
chart: python-pptx Chart 对象
"""
self.chart = chart
self.chart_element = chart._element
self.plotArea = self.chart_element.find('.//c:plotArea', namespaces=NAMESPACES)
# 解析坐标轴映射
self.axis_map = self._parse_axes()
print("\n📖 ChartParser 初始化完成")
print(f" - 发现坐标轴: {len(self.axis_map)}")
for ax_id, ax_info in self.axis_map.items():
print(f"{ax_id}: {ax_info['type']} ({ax_info.get('position', 'N/A')})")
def _parse_axes(self) -> Dict[str, Dict]:
"""
解析所有坐标轴,建立 ID 映射表
Returns:
{
"123456": {"type": "category", "position": None},
"789001": {"type": "primary", "position": "l"},
"789002": {"type": "secondary", "position": "r"}
}
"""
axis_map = {}
# 1. 解析所有值轴 (Y轴)
val_axes = self.plotArea.findall('.//c:valAx', namespaces=NAMESPACES)
for val_ax in val_axes:
ax_id_elem = val_ax.find('.//c:axId', namespaces=NAMESPACES)
if ax_id_elem is None:
continue
ax_id = ax_id_elem.get('val')
# 查找轴的位置
ax_pos_elem = val_ax.find('.//c:axPos', namespaces=NAMESPACES)
position = ax_pos_elem.get('val') if ax_pos_elem is not None else 'l'
# 判断主轴还是次轴
# 'r' (right) = 次轴, 'l' (left) = 主轴
if position == 'r':
axis_map[ax_id] = {"type": "secondary", "position": position}
else:
axis_map[ax_id] = {"type": "primary", "position": position}
# 2. 解析所有分类轴 (X轴)
cat_axes = self.plotArea.findall('.//c:catAx', namespaces=NAMESPACES)
for cat_ax in cat_axes:
ax_id_elem = cat_ax.find('.//c:axId', namespaces=NAMESPACES)
if ax_id_elem is None:
continue
ax_id = ax_id_elem.get('val')
axis_map[ax_id] = {"type": "category", "position": None}
return axis_map
def parse(self) -> Tuple[List[Dict], pd.DataFrame, str, Optional[Dict]]:
"""
解析图表配置和数据
Returns:
(series_config, df, categories_col, layout_info)
series_config: 系列配置列表
df: 数据 DataFrame
categories_col: 分类列名
layout_info: 布局信息(图例、轴配置等)
"""
print("\n" + "=" * 80)
print("🔍 开始解析图表")
print("=" * 80)
# 1. 提取嵌入的 Excel 数据
df, categories_col = self._extract_embedded_data()
print(f"\n📊 数据提取完成:")
print(f" - 行数: {len(df)}")
print(f" - 列数: {len(df.columns)}")
print(f" - 分类列: {categories_col}")
print(f" - 数据列: {list(df.columns[1:])}")
# 2. 解析系列配置
series_config = self._parse_series_config()
print(f"\n📋 系列配置解析完成:")
for i, cfg in enumerate(series_config):
print(f" {i+1}. {cfg['name']}: type={cfg['type']}, axis={cfg['axis']}, key={cfg['key']}")
# 3. ⭐ 新增:解析布局配置
layout_info = self._parse_layout_info()
if layout_info:
print(f"\n⚙️ 布局配置:")
if 'legend' in layout_info:
leg = layout_info['legend']
print(f" - 图例: 位置={leg.get('position')}, 字体={leg.get('font_size_pt')}pt")
if 'category_axis' in layout_info:
cat = layout_info['category_axis']
print(f" - 横轴: 类型={cat.get('type')}, 格式={cat.get('number_format')}")
print("\n" + "=" * 80)
print("✅ 图表解析完成!")
print("=" * 80)
return series_config, df, categories_col, layout_info
def _parse_layout_info(self) -> Dict:
"""
解析布局信息(图例、轴配置、标题、尺寸等)
Returns:
布局信息字典
"""
layout_info = {}
try:
# 1. 解析图表标题
try:
if self.chart.has_title:
title_frame = self.chart.chart_title.text_frame
if title_frame and title_frame.text:
layout_info["title"] = {
"text": title_frame.text,
"has_title": True,
}
except Exception as e:
print(f" ⚠️ 标题解析失败: {e}")
# 2. 解析图例
if self.chart.has_legend:
legend = self.chart.legend
layout_info["legend"] = {
"position": legend.position,
"font_size_pt": legend.font.size.pt if legend.font.size else None,
"include_in_layout": legend.include_in_layout,
}
# 3. 解析横轴(分类轴)
try:
category_axis = self.chart.category_axis
layout_info["category_axis"] = {
"type": category_axis.category_type,
"major_unit": category_axis.major_unit if hasattr(category_axis, 'major_unit') else None,
"number_format": category_axis.tick_labels.number_format if hasattr(category_axis.tick_labels, 'number_format') else None,
"font_size_pt": category_axis.tick_labels.font.size.pt if category_axis.tick_labels.font.size else None,
}
except Exception as e:
print(f" ⚠️ 横轴解析失败: {e}")
# 4. 解析纵轴(值轴)
try:
value_axis = self.chart.value_axis
layout_info["value_axis"] = {
"number_format": value_axis.tick_labels.number_format if hasattr(value_axis.tick_labels, 'number_format') else None,
"font_size_pt": value_axis.tick_labels.font.size.pt if value_axis.tick_labels.font.size else None,
"has_major_gridlines": value_axis.has_major_gridlines if hasattr(value_axis, 'has_major_gridlines') else None,
}
except Exception as e:
print(f" ⚠️ 纵轴解析失败: {e}")
except Exception as e:
print(f" ⚠️ 布局信息解析失败: {e}")
return layout_info
def _extract_embedded_data(self) -> Tuple[pd.DataFrame, str]:
"""
提取嵌入在图表中的 Excel 数据
Returns:
(df, categories_col)
"""
try:
# 方法1: 尝试从 chart 的内部部分获取 Excel 数据
# python-pptx 的图表对象包含嵌入的 Excel workbook
chart_part = self.chart.part
# 查找 Excel workbook 关系
for rel in chart_part.rels.values():
if 'package' in rel.reltype or 'oleObject' in rel.reltype or 'embeddings' in rel.reltype:
try:
xlsx_blob = rel.target_part.blob
wb = load_workbook(io.BytesIO(xlsx_blob))
ws = wb.active
# 转换为 DataFrame
data = []
for row in ws.iter_rows(values_only=True):
data.append(row)
if data:
# 第一行是表头
df = pd.DataFrame(data[1:], columns=data[0])
categories_col = df.columns[0]
print(f" ✅ 成功从嵌入的 Excel 提取数据")
return df, categories_col
except Exception as e2:
continue
# 如果上面都失败,尝试从 XML 中提取
print(f" ⚠️ 未找到嵌入的 Excel 数据,从 XML 提取")
return self._extract_data_from_xml()
except Exception as e:
print(f" ⚠️ 提取嵌入数据失败: {e}")
# 如果失败,尝试从 XML 中提取数据点
return self._extract_data_from_xml()
def _extract_data_from_xml(self) -> Tuple[pd.DataFrame, str]:
"""
从 XML 中提取数据点(备用方法)
Returns:
(df, categories_col)
"""
print(" → 尝试从 XML 提取数据...")
from datetime import datetime, timedelta
# 查找所有绘图元素
plot_types = ['barChart', 'lineChart', 'areaChart', 'scatterChart']
data_dict = {}
categories = []
categories_col = "分类"
max_data_points = 0
# 第一遍:找到所有系列和最大数据点数量
for plot_tag in plot_types:
plot_elements = self.plotArea.findall(f'.//c:{plot_tag}', namespaces=NAMESPACES)
for plot_element in plot_elements:
# 提取分类数据(只需提取一次)
if not categories:
cat_elem = plot_element.find('.//c:cat', namespaces=NAMESPACES)
if cat_elem is not None:
# ⭐ 优先尝试 strCache文本
cat_cache = cat_elem.find('.//c:strCache', namespaces=NAMESPACES)
if cat_cache is not None:
# ⭐ 修复 A: 强制将字符串转为 datetime
print(" → 发现 strCache尝试转换为 datetime...")
for pt in cat_cache.findall('.//c:pt', namespaces=NAMESPACES):
v = pt.find('.//c:v', namespaces=NAMESPACES)
if v is not None:
try:
# 尝试用 pandas 智能解析多种日期格式
dt = pd.to_datetime(v.text).to_pydatetime()
categories.append(dt)
print(f" • 转换: {v.text}{dt}")
except (ValueError, TypeError):
categories.append(v.text) # 回退到字符串
if categories:
print(f" → 成功将 {len(categories)} 个字符串日期转换为 datetime")
else:
# ⭐ 如果没有 strCache尝试 numCache日期轴
num_cache = cat_elem.find('.//c:numCache', namespaces=NAMESPACES)
if num_cache is not None:
# 提取格式代码(如 "yyyy/mm"
format_code = num_cache.find('.//c:formatCode', namespaces=NAMESPACES)
date_format = format_code.text if format_code is not None else None
# ⭐ 修复 B: 强制将 Excel 序列号转为 datetime
print(f" → 发现 numCache格式: {date_format}),转换为 datetime...")
for pt in num_cache.findall('.//c:pt', namespaces=NAMESPACES):
v = pt.find('.//c:v', namespaces=NAMESPACES)
if v is not None:
try:
excel_date_num = float(v.text)
# Excel 基准是 1899-12-30
dt = datetime(1899, 12, 30) + timedelta(days=excel_date_num)
categories.append(dt)
except (ValueError, OverflowError):
categories.append(v.text) # 回退到字符串
if categories:
print(f" → 成功将 {len(categories)} 个 Excel 序列号转换为 datetime")
# 提取每个系列的数据
series_elements = plot_element.findall('.//c:ser', namespaces=NAMESPACES)
for ser in series_elements:
# 获取系列名称
name = self._extract_series_name(ser)
# 获取数值
values = self._extract_series_values(ser)
if name and values:
data_dict[name] = values
max_data_points = max(max_data_points, len(values))
# 如果没有分类数据,使用最大数据点数量生成索引
if not categories and max_data_points > 0:
categories = [f"项目{i+1}" for i in range(max_data_points)]
print(f" → 未找到分类数据,生成 {len(categories)} 个默认分类")
elif not categories and not data_dict:
# 如果完全没有数据,返回空 DataFrame
print(" ⚠️ 未找到任何数据,返回空 DataFrame")
return pd.DataFrame({categories_col: []}), categories_col
# 确保所有系列的长度与分类数量一致
target_length = len(categories)
for name, values in data_dict.items():
if len(values) < target_length:
# 补齐 None
data_dict[name] = values + [None] * (target_length - len(values))
elif len(values) > target_length:
# 截断
data_dict[name] = values[:target_length]
print(f" → 系列 '{name}' 数据点从 {len(values)} 截断到 {target_length}")
# 构建 DataFrame
df_data = {categories_col: categories}
df_data.update(data_dict)
df = pd.DataFrame(df_data)
print(f" → 从 XML 提取了 {len(categories)} 行数据,{len(data_dict)} 个系列")
return df, categories_col
def _parse_series_config(self) -> List[Dict]:
"""
解析系列配置
Returns:
系列配置列表
"""
series_config = []
# 图表类型映射
plot_type_map = {
'barChart': 'bar',
'lineChart': 'line',
'areaChart': 'area',
'scatterChart': 'scatter'
}
# 遍历所有图表类型
for plot_tag, chart_type in plot_type_map.items():
plot_elements = self.plotArea.findall(f'.//c:{plot_tag}', namespaces=NAMESPACES)
for plot_element in plot_elements:
# ⭐ 关键修复:在绘图元素级别获取轴引用
plot_axis_type = self._extract_plot_axis(plot_element)
# 遍历该绘图中的所有系列
series_elements = plot_element.findall('.//c:ser', namespaces=NAMESPACES)
for ser in series_elements:
# 解析单个系列
config_entry = self._parse_single_series(ser, chart_type, plot_axis_type)
if config_entry:
series_config.append(config_entry)
return series_config
def _extract_plot_axis(self, plot_element) -> str:
"""
提取绘图元素使用的坐标轴(主轴/次轴)
Args:
plot_element: 绘图元素 (<c:barChart>, <c:lineChart> 等)
Returns:
'primary''secondary'
"""
# 查找绘图元素引用的轴 ID
ax_id_refs = plot_element.findall('.//c:axId', namespaces=NAMESPACES)
for ax_id_ref in ax_id_refs:
ax_id = ax_id_ref.get('val')
if ax_id in self.axis_map:
axis_info = self.axis_map[ax_id]
axis_type = axis_info['type']
# 如果是值轴(主轴或次轴),返回它
if axis_type in ('primary', 'secondary'):
return axis_type
# 默认返回主轴
return 'primary'
def _parse_single_series(self, ser, chart_type: str, axis_type: str) -> Optional[Dict]:
"""
解析单个系列
Args:
ser: 系列元素 (<c:ser>)
chart_type: 图表类型
axis_type: 轴类型(从父绘图元素获取)
Returns:
系列配置字典,或 None
"""
config_entry = {}
# A. 获取系列名称
name = self._extract_series_name(ser)
if not name:
return None
config_entry["name"] = name
config_entry["key"] = name # 默认使用 name 作为 key
# B. 设置图表类型
config_entry["type"] = chart_type
# C. 使用传入的轴分配
config_entry["axis"] = axis_type
return config_entry
def _extract_series_name(self, ser) -> Optional[str]:
"""
提取系列名称
Args:
ser: 系列元素 (<c:ser>)
Returns:
系列名称,或 None
"""
# 方法1: 从 <c:tx> 中提取
tx_elem = ser.find('.//c:tx', namespaces=NAMESPACES)
if tx_elem is not None:
# 尝试从 strRef 中提取
v_elem = tx_elem.find('.//c:v', namespaces=NAMESPACES)
if v_elem is not None and v_elem.text:
return v_elem.text
return None
def _extract_series_values(self, ser) -> List:
"""
提取系列数值
Args:
ser: 系列元素 (<c:ser>)
Returns:
数值列表
"""
values = []
# 尝试从 <c:val> 提取(柱状图、折线图、面积图)
val_elem = ser.find('.//c:val', namespaces=NAMESPACES)
if val_elem is not None:
num_cache = val_elem.find('.//c:numCache', namespaces=NAMESPACES)
if num_cache is not None:
for pt in num_cache.findall('.//c:pt', namespaces=NAMESPACES):
v = pt.find('.//c:v', namespaces=NAMESPACES)
if v is not None:
try:
values.append(float(v.text))
except (ValueError, TypeError):
values.append(0)
# 尝试从 <c:yVal> 提取(散点图)
if not values:
yVal_elem = ser.find('.//c:yVal', namespaces=NAMESPACES)
if yVal_elem is not None:
num_cache = yVal_elem.find('.//c:numCache', namespaces=NAMESPACES)
if num_cache is not None:
for pt in num_cache.findall('.//c:pt', namespaces=NAMESPACES):
v = pt.find('.//c:v', namespaces=NAMESPACES)
if v is not None:
try:
values.append(float(v.text))
except (ValueError, TypeError):
values.append(0)
return values
def parse_chart_from_pptx(pptx_path: str, slide_idx: int = 0, shape_idx: int = 0) -> Tuple[List[Dict], pd.DataFrame, str]:
"""
从 PPTX 文件解析图表
Args:
pptx_path: PPTX 文件路径
slide_idx: 幻灯片索引0-based
shape_idx: 图表形状索引0-based
Returns:
(series_config, df, categories_col)
Example:
>>> series_config, df, categories_col = parse_chart_from_pptx("template.pptx")
>>> # 现在可以用这些配置来复现图表
>>> builder.build(series_config)
"""
# 加载 PPTX
prs = Presentation(pptx_path)
if slide_idx >= len(prs.slides):
raise ValueError(f"幻灯片索引 {slide_idx} 超出范围(共 {len(prs.slides)} 张)")
slide = prs.slides[slide_idx]
# 查找图表
chart = None
chart_shape = None
for shape in slide.shapes:
if hasattr(shape, 'chart'):
if shape_idx == 0:
chart = shape.chart
chart_shape = shape
break
shape_idx -= 1
if chart is None:
raise ValueError(f"在幻灯片 {slide_idx} 上未找到图表")
# 解析图表
parser = ChartParser(chart)
return parser.parse()
def parse_all_charts_from_pptx(pptx_path: str) -> List[Dict]:
"""
解析 PPTX 文件中的所有图表
Args:
pptx_path: PPTX 文件路径
Returns:
图表信息列表
[
{
"slide_idx": 0,
"shape_idx": 0,
"shape_name": "图表 1",
"series_config": [...],
"df": DataFrame,
"categories_col": "日期"
},
...
]
"""
prs = Presentation(pptx_path)
all_charts = []
print(f"\n🔍 扫描 PPTX 文件: {pptx_path}")
print(f" - 共 {len(prs.slides)} 张幻灯片\n")
for slide_idx, slide in enumerate(prs.slides):
chart_count = 0
for shape_idx, shape in enumerate(slide.shapes):
# 使用 has_chart 属性检查,而不是直接访问 chart
if hasattr(shape, 'has_chart') and shape.has_chart:
chart_count += 1
try:
# 解析图表
parser = ChartParser(shape.chart)
series_config, df, categories_col, layout_info = parser.parse()
chart_info = {
"slide_index": slide_idx,
"chart_index": chart_count - 1, # 0-based
"shape_idx": shape_idx,
"shape_name": shape.name if hasattr(shape, 'name') else f"图表 {chart_count}",
"series_config": series_config,
"df": df,
"categories_col": categories_col,
"layout_info": layout_info # 新增:布局信息
}
all_charts.append(chart_info)
print(f"✅ 幻灯片 {slide_idx + 1}, 图表 {chart_count}: {chart_info['shape_name']}")
print(f" - {len(series_config)} 个系列")
except Exception as e:
print(f"⚠️ 幻灯片 {slide_idx + 1}, 图表 {chart_count} 解析失败: {e}")
if chart_count > 0:
print(f" → 幻灯片 {slide_idx + 1}: 发现 {chart_count} 个图表\n")
print(f"\n📊 总计解析 {len(all_charts)} 个图表")
return all_charts