文件内容
engine/chart_engine.py
"""
DeckCraft v5 — Native Chart Engine
Generates editable native PowerPoint charts using python-pptx chart API.
All charts are fully editable in PowerPoint — double-click to modify data, colors, labels.
No matplotlib dependency. No PNG embedding. 100% native elements.
"""
from pptx.util import Inches, Pt, Emu
from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION, XL_LABEL_POSITION
from pptx.chart.data import CategoryChartData
from pptx.dml.color import RGBColor
from .constants import c, chart_colors, THEMES
def _get_colors(theme_name, count):
"""Get color list for charts."""
colors = chart_colors(theme_name)
if count <= len(colors):
return colors[:count]
return [colors[i % len(colors)] for i in range(count)]
def _add_chart_frame(slide, chart_frame, title=None, theme_name="business"):
"""Apply theme styling to a chart frame: title, legend, plot area."""
theme = THEMES.get(theme_name, THEMES["business"])
text_color = c(theme.get('text', (51, 51, 51)))
chart = chart_frame.chart
# Chart title
if title:
chart.has_title = True
chart.chart_title.text_frame.paragraphs[0].text = title
title_run = chart.chart_title.text_frame.paragraphs[0].runs[0] if chart.chart_title.text_frame.paragraphs[0].runs else chart.chart_title.text_frame.paragraphs[0].add_run()
title_run.font.size = Pt(12)
title_run.font.bold = True
title_run.font.color.rgb = text_color
else:
chart.has_title = False
return chart_frame
def _style_series(series, color_tuple, has_data_labels=True):
"""Apply color and data labels to a chart series."""
color = c(color_tuple)
series.format.fill.solid()
series.format.fill.fore_color.rgb = color
if has_data_labels:
series.has_data_labels = True
data_labels = series.data_labels
data_labels.font.size = Pt(9)
data_labels.font.bold = True
data_labels.number_format = '0.0'
# Position varies by chart type — set in caller
def bar_chart(slide, left, top, width, height,
data, labels, title="", theme_name="business",
orientation="vertical", series_names=None):
"""
Add a native bar/column chart to a slide.
slide: target slide
left, top, width, height: chart frame position (Inches)
data: list of lists (each inner list = one series values)
labels: category labels
orientation: "vertical" (column) or "horizontal" (bar)
series_names: legend labels
Returns: chart shape (fully editable)
"""
chart_type = (XL_CHART_TYPE.BAR_CLUSTERED if orientation == "horizontal"
else XL_CHART_TYPE.COLUMN_CLUSTERED)
chart_data = CategoryChartData()
chart_data.categories = labels
for i, series_data in enumerate(data):
name = series_names[i] if series_names and i < len(series_names) else f"Series {i+1}"
chart_data.add_series(name, series_data)
chart_frame = slide.shapes.add_chart(
chart_type, int(left), int(top), int(width), int(height), chart_data
)
_add_chart_frame(slide, chart_frame, title, theme_name)
chart = chart_frame.chart
colors = _get_colors(theme_name, len(data))
for i, series in enumerate(chart.series):
_style_series(series, colors[i])
if orientation == "vertical":
series.data_labels.label_position = XL_LABEL_POSITION.OUTSIDE_END
else:
series.data_labels.label_position = XL_LABEL_POSITION.OUTSIDE_END
# Style axes
chart.category_axis.tick_labels.font.size = Pt(9)
chart.value_axis.tick_labels.font.size = Pt(8)
chart.value_axis.has_major_gridlines = True
chart.value_axis.major_gridlines.format.line.color.rgb = RGBColor(0xE0, 0xE0, 0xE0)
# Legend
if series_names and len(series_names) > 1:
chart.has_legend = True
chart.legend.position = XL_LEGEND_POSITION.BOTTOM
chart.legend.include_in_layout = False
chart.legend.font.size = Pt(9)
else:
chart.has_legend = False
return chart_frame
def pie_chart(slide, left, top, width, height,
data, labels, title="", theme_name="business",
donut=True):
"""
Add a native pie or donut chart to a slide.
data: list of values
labels: list of segment labels
donut: True for donut, False for pie
Returns: chart shape (fully editable)
"""
chart_type = XL_CHART_TYPE.DOUGHNUT if donut else XL_CHART_TYPE.PIE
chart_data = CategoryChartData()
chart_data.categories = labels
chart_data.add_series('Data', data)
chart_frame = slide.shapes.add_chart(
chart_type, int(left), int(top), int(width), int(height), chart_data
)
_add_chart_frame(slide, chart_frame, title, theme_name)
chart = chart_frame.chart
colors = _get_colors(theme_name, len(data))
plot = chart.plots[0]
# Color each point
series = plot.series[0]
for i, point in enumerate(series.points):
point.format.fill.solid()
point.format.fill.fore_color.rgb = c(colors[i] if i < len(colors) else colors[i % len(colors)])
# Data labels
plot.has_data_labels = True
data_labels = plot.data_labels
data_labels.font.size = Pt(9)
data_labels.font.bold = True
data_labels.number_format = '0%'
data_labels.show_percentage = True
data_labels.show_value = False
data_labels.show_category_name = True
data_labels.show_series_name = False
data_labels.separator = '\n'
# Legend
chart.has_legend = True
chart.legend.position = XL_LEGEND_POSITION.BOTTOM
chart.legend.include_in_layout = False
chart.legend.font.size = Pt(9)
return chart_frame
def line_chart(slide, left, top, width, height,
data, labels, title="", theme_name="business",
series_names=None, fill_area=False):
"""
Add a native line chart to a slide.
data: list of lists (each inner list = one series values)
labels: x-axis labels
fill_area: True for area chart (not yet supported natively, uses line)
Returns: chart shape (fully editable)
"""
chart_type = XL_CHART_TYPE.AREA if fill_area else XL_CHART_TYPE.LINE
chart_data = CategoryChartData()
chart_data.categories = labels
for i, series_data in enumerate(data):
name = series_names[i] if series_names and i < len(series_names) else f"Series {i+1}"
chart_data.add_series(name, series_data)
chart_frame = slide.shapes.add_chart(
chart_type, int(left), int(top), int(width), int(height), chart_data
)
_add_chart_frame(slide, chart_frame, title, theme_name)
chart = chart_frame.chart
colors = _get_colors(theme_name, len(data))
for i, series in enumerate(chart.series):
color = c(colors[i])
series.format.line.color.rgb = color
series.format.line.width = Pt(2)
if not fill_area:
series.smooth = False
# Data labels
series.has_data_labels = True
series.data_labels.font.size = Pt(8)
series.data_labels.number_format = '0'
series.data_labels.label_position = XL_LABEL_POSITION.ABOVE
# Style axes
chart.category_axis.tick_labels.font.size = Pt(9)
chart.value_axis.tick_labels.font.size = Pt(8)
chart.value_axis.has_major_gridlines = True
chart.value_axis.major_gridlines.format.line.color.rgb = RGBColor(0xE0, 0xE0, 0xE0)
# Legend
if series_names and len(series_names) > 1:
chart.has_legend = True
chart.legend.position = XL_LEGEND_POSITION.BOTTOM
chart.legend.include_in_layout = False
chart.legend.font.size = Pt(9)
else:
chart.has_legend = False
return chart_frame
def gauge_chart(slide, left, top, width, height,
value, max_value=100, title="", theme_name="business",
label=""):
"""
Add a native doughnut chart styled as a gauge/speedometer.
Uses a two-segment doughnut chart to simulate a gauge.
The filled portion shows the value, the remainder is gray.
Returns: chart shape (fully editable)
"""
pct = min(value / max_value, 1.0) if max_value > 0 else 0
chart_data = CategoryChartData()
chart_data.categories = ['Value', 'Remaining']
chart_data.add_series('Gauge', [pct * 100, (1 - pct) * 100])
chart_frame = slide.shapes.add_chart(
XL_CHART_TYPE.DOUGHNUT, int(left), int(top), int(width), int(height), chart_data
)
_add_chart_frame(slide, chart_frame, title, theme_name)
chart = chart_frame.chart
colors = _get_colors(theme_name, 1)
plot = chart.plots[0]
series = plot.series[0]
# Value segment — colored
point_value = series.points[0]
point_value.format.fill.solid()
point_value.format.fill.fore_color.rgb = c(colors[0])
# Remaining segment — light gray
point_remaining = series.points[1]
point_remaining.format.fill.solid()
point_remaining.format.fill.fore_color.rgb = RGBColor(0xE0, 0xE0, 0xE0)
# No legend for gauge
chart.has_legend = False
# Hide data labels, show value as text annotation via chart title
plot.has_data_labels = False
if not title:
chart.has_title = True
chart.chart_title.text_frame.paragraphs[0].text = f"{value}"
for run in chart.chart_title.text_frame.paragraphs[0].runs:
run.font.size = Pt(28)
run.font.bold = True
return chart_frame