skills/ppt-station-skill/ppt_station/chart_builder/layout.py

535 lines
24 KiB
Python
Raw Normal View History

"""
图表布局配置模块
控制图表的结构性属性
- 图例位置大小
- 横轴配置日期轴刻度
- 纵轴配置位置刻度
"""
from pptx.enum.chart import XL_LEGEND_POSITION, XL_TICK_MARK, XL_TICK_LABEL_POSITION
from pptx.util import Pt
from typing import Optional, Dict, Any
# ============================================================================
# 图例配置
# ============================================================================
class LegendConfig:
"""图例配置"""
# 预设位置
BOTTOM = XL_LEGEND_POSITION.BOTTOM # 底部
TOP = XL_LEGEND_POSITION.TOP # 顶部
LEFT = XL_LEGEND_POSITION.LEFT # 左侧
RIGHT = XL_LEGEND_POSITION.RIGHT # 右侧
CORNER = XL_LEGEND_POSITION.CORNER # 右上角
def __init__(
self,
position = XL_LEGEND_POSITION.BOTTOM,
font_size_pt: float = 10,
font_name: str = "黑体",
include_in_layout: bool = False,
):
"""
初始化图例配置
Args:
position: 图例位置
- XL_LEGEND_POSITION.BOTTOM (默认)
- XL_LEGEND_POSITION.TOP
- XL_LEGEND_POSITION.LEFT
- XL_LEGEND_POSITION.RIGHT
- XL_LEGEND_POSITION.CORNER
font_size_pt: 字体大小pt
font_name: 字体名称默认黑体
include_in_layout: 是否包含在布局中False 防止图例覆盖图表
"""
self.position = position
self.font_size_pt = font_size_pt
self.font_name = font_name
self.include_in_layout = include_in_layout
def apply_to_chart(self, chart):
"""应用到图表"""
chart.has_legend = True
chart.legend.position = self.position
chart.legend.include_in_layout = self.include_in_layout
if self.font_size_pt:
chart.legend.font.size = Pt(self.font_size_pt)
if self.font_name:
chart.legend.font.name = self.font_name
print(f" - 图例配置: 位置={self._position_name()}, 字体={self.font_name} {self.font_size_pt}pt")
def _position_name(self):
"""获取位置名称"""
position_names = {
XL_LEGEND_POSITION.BOTTOM: "底部",
XL_LEGEND_POSITION.TOP: "顶部",
XL_LEGEND_POSITION.LEFT: "左侧",
XL_LEGEND_POSITION.RIGHT: "右侧",
XL_LEGEND_POSITION.CORNER: "右上角",
}
return position_names.get(self.position, "未知")
# ============================================================================
# 横轴配置
# ============================================================================
class CategoryAxisConfig:
"""横轴(分类轴)配置"""
def __init__(
self,
is_date_axis: bool = False,
major_unit: Optional[float] = None,
major_unit_days: Optional[int] = None,
tick_label_position = XL_TICK_LABEL_POSITION.LOW,
major_tick_mark = XL_TICK_MARK.NONE,
minor_tick_mark = XL_TICK_MARK.NONE,
number_format: Optional[str] = None,
font_size_pt: float = 10,
font_name: str = "黑体",
):
"""
初始化横轴配置
Args:
is_date_axis: 是否为日期轴
major_unit: 主刻度单位用于日期轴单位为天
major_unit_days: 主刻度单位- 便捷参数 major_unit 相同
tick_label_position: 刻度标签位置
major_tick_mark: 主刻度线样式
minor_tick_mark: 次刻度线样式
number_format: 数字格式 'yyyy-mm-dd'
font_size_pt: 字体大小
font_name: 字体名称默认黑体
"""
self.is_date_axis = is_date_axis
self.major_unit = major_unit or major_unit_days
self.tick_label_position = tick_label_position
self.major_tick_mark = major_tick_mark
self.minor_tick_mark = minor_tick_mark
self.number_format = number_format
self.font_size_pt = font_size_pt
self.font_name = font_name
def apply_to_chart(self, chart):
"""应用到图表"""
try:
category_axis = chart.category_axis
# 设置日期轴
if self.is_date_axis:
from pptx.enum.chart import XL_CATEGORY_TYPE
category_axis.category_type = XL_CATEGORY_TYPE.TIME_SCALE
print(f" - 横轴: 日期轴")
# 设置刻度单位
if self.major_unit:
from pptx.enum.chart import XL_TIME_UNIT
category_axis.major_unit = self.major_unit
category_axis.major_unit_scale = XL_TIME_UNIT.DAYS
print(f" - 刻度单位: {self.major_unit}")
else:
print(f" - 横轴: 分类轴")
# 设置刻度标签位置
category_axis.tick_label_position = self.tick_label_position
# 设置刻度线
category_axis.major_tick_mark = self.major_tick_mark
category_axis.minor_tick_mark = self.minor_tick_mark
# 设置数字格式
if self.number_format:
category_axis.tick_labels.number_format = self.number_format
print(f" - 日期格式: {self.number_format}")
# 设置字体大小和字体名称
if self.font_size_pt:
category_axis.tick_labels.font.size = Pt(self.font_size_pt)
if self.font_name:
category_axis.tick_labels.font.name = self.font_name
print(f" - 横轴字体: {self.font_name} {self.font_size_pt}pt")
except Exception as e:
print(f" ⚠️ 横轴配置失败: {e}")
# ============================================================================
# 纵轴配置
# ============================================================================
class ValueAxisConfig:
"""纵轴(数值轴)配置"""
def __init__(
self,
tick_label_position = XL_TICK_LABEL_POSITION.LOW,
major_tick_mark = XL_TICK_MARK.OUTSIDE,
minor_tick_mark = XL_TICK_MARK.NONE,
number_format: Optional[str] = None,
font_size_pt: float = 10,
font_name: str = "黑体",
has_major_gridlines: bool = True,
min_value: Optional[float] = None,
max_value: Optional[float] = None,
major_unit: Optional[float] = None,
):
"""
初始化纵轴配置
Args:
tick_label_position: 刻度标签位置
major_tick_mark: 主刻度线样式
minor_tick_mark: 次刻度线样式
number_format: 数字格式 '0.00%'
font_size_pt: 字体大小
font_name: 字体名称默认黑体
has_major_gridlines: 是否显示主网格线
min_value: 最小值可选
max_value: 最大值可选
major_unit: 主刻度单位可选
"""
self.tick_label_position = tick_label_position
self.major_tick_mark = major_tick_mark
self.minor_tick_mark = minor_tick_mark
self.number_format = number_format
self.font_size_pt = font_size_pt
self.font_name = font_name
self.has_major_gridlines = has_major_gridlines
self.min_value = min_value
self.max_value = max_value
self.major_unit = major_unit
def apply_to_chart(self, chart):
"""应用到图表(主值轴)"""
try:
# ⭐ 通过 XML 直接设置主值轴,确保格式生效
chart_element = chart._element
val_ax_elements = chart_element.findall('.//{http://schemas.openxmlformats.org/drawingml/2006/chart}valAx')
# 主值轴是 position='l' 的那个
primary_ax = None
for ax in val_ax_elements:
ax_pos = ax.find('.//{http://schemas.openxmlformats.org/drawingml/2006/chart}axPos')
if ax_pos is not None and ax_pos.get('val') == 'l':
primary_ax = ax
break
if primary_ax is None:
print(f" ⚠️ 未找到主值轴")
return
from lxml import etree
# 设置数字格式(通过 XML
if self.number_format:
num_fmt = primary_ax.find('.//{http://schemas.openxmlformats.org/drawingml/2006/chart}numFmt')
if num_fmt is None:
# 创建 numFmt 元素
num_fmt = etree.Element('{http://schemas.openxmlformats.org/drawingml/2006/chart}numFmt')
# 在 tickLblPos 之后插入
tick_lbl_pos = primary_ax.find('.//{http://schemas.openxmlformats.org/drawingml/2006/chart}tickLblPos')
if tick_lbl_pos is not None:
parent_list = list(primary_ax)
pos_index = parent_list.index(tick_lbl_pos)
primary_ax.insert(pos_index + 1, num_fmt)
else:
primary_ax.append(num_fmt)
num_fmt.set('formatCode', self.number_format)
num_fmt.set('sourceLinked', '0')
print(f" - 主值轴格式: {self.number_format}")
# 设置字体(通过 XML
if self.font_size_pt or self.font_name:
self._apply_font_to_axis_xml(primary_ax, self.font_name, self.font_size_pt)
print(f" - 主值轴字体: {self.font_name} {self.font_size_pt}pt")
# 设置轴范围和刻度间隔(通过 XML
if self.min_value is not None or self.max_value is not None:
scaling = primary_ax.find('.//{http://schemas.openxmlformats.org/drawingml/2006/chart}scaling')
if scaling is None:
scaling = etree.Element('{http://schemas.openxmlformats.org/drawingml/2006/chart}scaling')
# 在 axId 之后插入
ax_id = primary_ax.find('.//{http://schemas.openxmlformats.org/drawingml/2006/chart}axId')
if ax_id is not None:
parent_list = list(primary_ax)
ax_id_index = parent_list.index(ax_id)
primary_ax.insert(ax_id_index + 1, scaling)
else:
primary_ax.insert(0, scaling)
# 添加 orientation
orientation = etree.SubElement(scaling, '{http://schemas.openxmlformats.org/drawingml/2006/chart}orientation')
orientation.set('val', 'minMax')
if self.min_value is not None:
min_elem = scaling.find('.//{http://schemas.openxmlformats.org/drawingml/2006/chart}min')
if min_elem is None:
min_elem = etree.SubElement(scaling, '{http://schemas.openxmlformats.org/drawingml/2006/chart}min')
min_elem.set('val', str(self.min_value))
print(f" - 主值轴最小值: {self.min_value}")
if self.max_value is not None:
max_elem = scaling.find('.//{http://schemas.openxmlformats.org/drawingml/2006/chart}max')
if max_elem is None:
max_elem = etree.SubElement(scaling, '{http://schemas.openxmlformats.org/drawingml/2006/chart}max')
max_elem.set('val', str(self.max_value))
print(f" - 主值轴最大值: {self.max_value}")
if self.major_unit is not None:
major_unit_elem = primary_ax.find('.//{http://schemas.openxmlformats.org/drawingml/2006/chart}majorUnit')
if major_unit_elem is None:
major_unit_elem = etree.SubElement(primary_ax, '{http://schemas.openxmlformats.org/drawingml/2006/chart}majorUnit')
major_unit_elem.set('val', str(self.major_unit))
print(f" - 主值轴刻度间隔: {self.major_unit}")
except Exception as e:
print(f" ⚠️ 主值轴配置失败: {e}")
import traceback
traceback.print_exc()
def _apply_font_to_axis_xml(self, axis_element, font_name: str, font_size_pt: float):
"""通过 XML 设置轴字体"""
from lxml import etree
# 查找或创建 txPr
txPr = axis_element.find('.//{http://schemas.openxmlformats.org/drawingml/2006/chart}txPr')
if txPr is None:
txPr = etree.SubElement(axis_element, '{http://schemas.openxmlformats.org/drawingml/2006/chart}txPr')
bodyPr = etree.SubElement(txPr, '{http://schemas.openxmlformats.org/drawingml/2006/main}bodyPr')
lstStyle = etree.SubElement(txPr, '{http://schemas.openxmlformats.org/drawingml/2006/main}lstStyle')
# 设置字体
p = txPr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}p')
if p is None:
p = etree.SubElement(txPr, '{http://schemas.openxmlformats.org/drawingml/2006/main}p')
pPr = p.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}pPr')
if pPr is None:
pPr = etree.SubElement(p, '{http://schemas.openxmlformats.org/drawingml/2006/main}pPr')
defRPr = pPr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}defRPr')
if defRPr is None:
defRPr = etree.SubElement(pPr, '{http://schemas.openxmlformats.org/drawingml/2006/main}defRPr')
if font_size_pt:
defRPr.set('sz', str(int(font_size_pt * 100)))
if font_name:
latin = defRPr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}latin')
if latin is None:
latin = etree.SubElement(defRPr, '{http://schemas.openxmlformats.org/drawingml/2006/main}latin')
latin.set('typeface', font_name)
# ============================================================================
# 默认配置
# ============================================================================
# 默认图例配置底部、9pt 黑体
DEFAULT_LEGEND_CONFIG = LegendConfig(
position=XL_LEGEND_POSITION.BOTTOM,
font_size_pt=9,
font_name="黑体",
include_in_layout=False,
)
# 默认横轴配置普通分类轴、外侧刻度线、9pt 黑体
DEFAULT_CATEGORY_AXIS_CONFIG = CategoryAxisConfig(
is_date_axis=False,
major_tick_mark=XL_TICK_MARK.OUTSIDE, # ⭐ 改为 OUTSIDE与原图一致
minor_tick_mark=XL_TICK_MARK.NONE,
font_size_pt=9,
font_name="黑体",
)
# 默认纵轴配置无网格线、9pt 黑体
DEFAULT_VALUE_AXIS_CONFIG = ValueAxisConfig(
tick_label_position=XL_TICK_LABEL_POSITION.LOW,
major_tick_mark=XL_TICK_MARK.OUTSIDE,
minor_tick_mark=XL_TICK_MARK.NONE,
font_size_pt=9,
font_name="黑体",
has_major_gridlines=False, # ⭐ 改为 False原图无网格线
)
# ============================================================================
# 完整布局配置
# ============================================================================
class ChartLayoutConfig:
"""完整的图表布局配置"""
def __init__(
self,
title: Optional[str] = None,
legend_config: Optional[LegendConfig] = None,
category_axis_config: Optional[CategoryAxisConfig] = None,
value_axis_config: Optional[ValueAxisConfig] = None,
secondary_value_axis_config: Optional[ValueAxisConfig] = None, # ⭐ 新增次值轴配置
date_axis_config = None, # DateAxisConfig避免循环导入
):
"""
初始化布局配置
Args:
title: 图表标题
legend_config: 图例配置
category_axis_config: 横轴配置
value_axis_config: 主值轴左轴配置
secondary_value_axis_config: 次值轴右轴配置
date_axis_config: 日期轴配置DateAxisConfig用于精确控制日期轴
"""
self.title = title
self.legend_config = legend_config or DEFAULT_LEGEND_CONFIG
self.category_axis_config = category_axis_config or DEFAULT_CATEGORY_AXIS_CONFIG
self.value_axis_config = value_axis_config or DEFAULT_VALUE_AXIS_CONFIG
self.secondary_value_axis_config = secondary_value_axis_config # ⭐ 新增
self.date_axis_config = date_axis_config # ⭐ 新增
def apply_to_chart(self, chart):
"""应用所有配置到图表"""
print(f"\n⚙️ 应用布局配置:")
# 应用标题
if self.title:
try:
chart.has_title = True
chart.chart_title.text_frame.text = self.title
print(f" - 标题: {self.title}")
except Exception as e:
print(f" ⚠️ 标题设置失败: {e}")
# 应用图例配置
if self.legend_config:
self.legend_config.apply_to_chart(chart)
# ⭐ 应用日期轴配置(优先于普通横轴配置)
if self.date_axis_config:
self.date_axis_config.apply_to_chart(chart)
# 应用横轴配置(如果没有日期轴配置)
elif self.category_axis_config:
self.category_axis_config.apply_to_chart(chart)
# 应用主值轴配置(左轴)
if self.value_axis_config:
self.value_axis_config.apply_to_chart(chart)
# ⭐ 应用次值轴配置(右轴)
if self.secondary_value_axis_config:
self._apply_secondary_axis_config(chart)
def _apply_secondary_axis_config(self, chart):
"""应用次值轴配置(通过 XML"""
try:
# 通过 XML 查找次值轴
chart_element = chart._element
val_ax_elements = chart_element.findall('.//{http://schemas.openxmlformats.org/drawingml/2006/chart}valAx')
# 次值轴通常是第二个 valAxposition='r'
secondary_ax = None
for ax in val_ax_elements:
ax_pos = ax.find('.//{http://schemas.openxmlformats.org/drawingml/2006/chart}axPos')
if ax_pos is not None and ax_pos.get('val') == 'r':
secondary_ax = ax
break
if secondary_ax is None:
print(f" ⚠️ 未找到次值轴")
return
config = self.secondary_value_axis_config
# 设置数字格式
if config.number_format:
num_fmt = secondary_ax.find('.//{http://schemas.openxmlformats.org/drawingml/2006/chart}numFmt')
if num_fmt is not None:
num_fmt.set('formatCode', config.number_format)
num_fmt.set('sourceLinked', '0')
print(f" - 次值轴格式: {config.number_format}")
# 设置字体
if config.font_size_pt or config.font_name:
from pptx.util import Pt
from lxml import etree
# 查找或创建 txPr (文本属性)
txPr = secondary_ax.find('.//{http://schemas.openxmlformats.org/drawingml/2006/chart}txPr')
if txPr is None:
txPr = etree.SubElement(secondary_ax, '{http://schemas.openxmlformats.org/drawingml/2006/chart}txPr')
bodyPr = etree.SubElement(txPr, '{http://schemas.openxmlformats.org/drawingml/2006/main}bodyPr')
lstStyle = etree.SubElement(txPr, '{http://schemas.openxmlformats.org/drawingml/2006/main}lstStyle')
# 设置字体
p = txPr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}p')
if p is None:
p = etree.SubElement(txPr, '{http://schemas.openxmlformats.org/drawingml/2006/main}p')
pPr = p.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}pPr')
if pPr is None:
pPr = etree.SubElement(p, '{http://schemas.openxmlformats.org/drawingml/2006/main}pPr')
defRPr = pPr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}defRPr')
if defRPr is None:
defRPr = etree.SubElement(pPr, '{http://schemas.openxmlformats.org/drawingml/2006/main}defRPr')
if config.font_size_pt:
defRPr.set('sz', str(int(config.font_size_pt * 100)))
if config.font_name:
latin = defRPr.find('.//{http://schemas.openxmlformats.org/drawingml/2006/main}latin')
if latin is None:
latin = etree.SubElement(defRPr, '{http://schemas.openxmlformats.org/drawingml/2006/main}latin')
latin.set('typeface', config.font_name)
print(f" - 次值轴字体: {config.font_name} {config.font_size_pt}pt")
# ⭐ 设置轴范围和刻度间隔(通过 XML
from lxml import etree
if config.min_value is not None or config.max_value is not None:
# 查找或创建 scaling 元素
scaling = secondary_ax.find('.//{http://schemas.openxmlformats.org/drawingml/2006/chart}scaling')
if scaling is None:
scaling = etree.SubElement(secondary_ax, '{http://schemas.openxmlformats.org/drawingml/2006/chart}scaling')
# 添加 orientation
orientation = etree.SubElement(scaling, '{http://schemas.openxmlformats.org/drawingml/2006/chart}orientation')
orientation.set('val', 'minMax')
if config.min_value is not None:
min_elem = scaling.find('.//{http://schemas.openxmlformats.org/drawingml/2006/chart}min')
if min_elem is None:
min_elem = etree.SubElement(scaling, '{http://schemas.openxmlformats.org/drawingml/2006/chart}min')
min_elem.set('val', str(config.min_value))
print(f" - 次值轴最小值: {config.min_value}")
if config.max_value is not None:
max_elem = scaling.find('.//{http://schemas.openxmlformats.org/drawingml/2006/chart}max')
if max_elem is None:
max_elem = etree.SubElement(scaling, '{http://schemas.openxmlformats.org/drawingml/2006/chart}max')
max_elem.set('val', str(config.max_value))
print(f" - 次值轴最大值: {config.max_value}")
if config.major_unit is not None:
major_unit_elem = secondary_ax.find('.//{http://schemas.openxmlformats.org/drawingml/2006/chart}majorUnit')
if major_unit_elem is None:
major_unit_elem = etree.SubElement(secondary_ax, '{http://schemas.openxmlformats.org/drawingml/2006/chart}majorUnit')
major_unit_elem.set('val', str(config.major_unit))
print(f" - 次值轴刻度间隔: {config.major_unit}")
except Exception as e:
print(f" ⚠️ 次值轴配置失败: {e}")
import traceback
traceback.print_exc()