文件预览

chart_engine.py

查看 Deckcraft 技能包中的文件内容。

文件内容

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