microsoft/hve-core
Publicmirrored from https://github.com/microsoft/hve-coreAvailable
.github/skills/experimental/powerpoint/scripts/pptx_charts.py
154lines · modecode
| 1 | # Copyright (c) Microsoft Corporation. |
| 2 | # SPDX-License-Identifier: MIT |
| 3 | """Chart build and extract utilities for PowerPoint skill scripts. |
| 4 | |
| 5 | Provides add_chart_element() for building charts from YAML definitions |
| 6 | and extract_chart() for extracting chart data from existing presentations. |
| 7 | """ |
| 8 | |
| 9 | from pptx.chart.data import BubbleChartData, CategoryChartData, XyChartData |
| 10 | from pptx.enum.chart import XL_CHART_TYPE |
| 11 | from pptx.util import Inches |
| 12 | from pptx_colors import apply_color_to_fill, resolve_color |
| 13 | from pptx_utils import emu_to_inches |
| 14 | |
| 15 | CHART_TYPE_MAP = { |
| 16 | "column_clustered": XL_CHART_TYPE.COLUMN_CLUSTERED, |
| 17 | "column_stacked": XL_CHART_TYPE.COLUMN_STACKED, |
| 18 | "bar_clustered": XL_CHART_TYPE.BAR_CLUSTERED, |
| 19 | "bar_stacked": XL_CHART_TYPE.BAR_STACKED, |
| 20 | "line": XL_CHART_TYPE.LINE, |
| 21 | "line_markers": XL_CHART_TYPE.LINE_MARKERS, |
| 22 | "pie": XL_CHART_TYPE.PIE, |
| 23 | "doughnut": XL_CHART_TYPE.DOUGHNUT, |
| 24 | "area": XL_CHART_TYPE.AREA, |
| 25 | "radar": XL_CHART_TYPE.RADAR, |
| 26 | "scatter": XL_CHART_TYPE.XY_SCATTER, |
| 27 | "bubble": XL_CHART_TYPE.BUBBLE, |
| 28 | } |
| 29 | |
| 30 | CHART_TYPE_REVERSE = {v: k for k, v in CHART_TYPE_MAP.items()} |
| 31 | |
| 32 | SCATTER_CHART_TYPES = {"scatter", "scatter_lines", "scatter_smooth"} |
| 33 | BUBBLE_CHART_TYPES = {"bubble"} |
| 34 | |
| 35 | |
| 36 | def add_chart_element(slide, elem: dict, colors: dict): |
| 37 | """Add a chart element from a content.yaml definition. |
| 38 | |
| 39 | YAML schema: |
| 40 | - type: chart |
| 41 | chart_type: column_clustered |
| 42 | left: 1.0 |
| 43 | top: 2.0 |
| 44 | width: 8.0 |
| 45 | height: 4.5 |
| 46 | title: "Quarterly Sales" |
| 47 | has_legend: true |
| 48 | chart_style: 10 |
| 49 | categories: ["Q1", "Q2", "Q3", "Q4"] |
| 50 | series: |
| 51 | - name: "East" |
| 52 | values: [19.2, 22.3, 18.4, 23.1] |
| 53 | color: "#0078D4" |
| 54 | """ |
| 55 | chart_type_name = elem.get("chart_type", "column_clustered") |
| 56 | chart_type = CHART_TYPE_MAP.get(chart_type_name, XL_CHART_TYPE.COLUMN_CLUSTERED) |
| 57 | |
| 58 | # Choose data class based on chart type |
| 59 | if chart_type_name in SCATTER_CHART_TYPES: |
| 60 | chart_data = XyChartData() |
| 61 | for series_spec in elem.get("series", []): |
| 62 | series = chart_data.add_series(series_spec.get("name", "")) |
| 63 | x_values = series_spec.get("x_values", []) |
| 64 | y_values = series_spec.get("y_values", []) |
| 65 | for x_val, y_val in zip(x_values, y_values): |
| 66 | series.add_data_point(x_val, y_val) |
| 67 | elif chart_type_name in BUBBLE_CHART_TYPES: |
| 68 | chart_data = BubbleChartData() |
| 69 | for series_spec in elem.get("series", []): |
| 70 | series = chart_data.add_series(series_spec.get("name", "")) |
| 71 | x_values = series_spec.get("x_values", []) |
| 72 | y_values = series_spec.get("y_values", []) |
| 73 | sizes = series_spec.get("sizes", []) |
| 74 | for x, y, size in zip(x_values, y_values, sizes): |
| 75 | series.add_data_point(x, y, size) |
| 76 | else: |
| 77 | chart_data = CategoryChartData() |
| 78 | chart_data.categories = elem.get("categories", []) |
| 79 | for series_spec in elem.get("series", []): |
| 80 | chart_data.add_series( |
| 81 | series_spec.get("name", ""), |
| 82 | series_spec.get("values", []), |
| 83 | ) |
| 84 | |
| 85 | chart_shape = slide.shapes.add_chart( |
| 86 | chart_type, |
| 87 | Inches(elem["left"]), |
| 88 | Inches(elem["top"]), |
| 89 | Inches(elem["width"]), |
| 90 | Inches(elem["height"]), |
| 91 | chart_data, |
| 92 | ) |
| 93 | chart = chart_shape.chart |
| 94 | |
| 95 | # Chart properties |
| 96 | if "title" in elem: |
| 97 | chart.has_title = True |
| 98 | chart.chart_title.text_frame.text = elem["title"] |
| 99 | if "has_legend" in elem: |
| 100 | chart.has_legend = elem["has_legend"] |
| 101 | if "chart_style" in elem: |
| 102 | chart.style = elem["chart_style"] |
| 103 | |
| 104 | # Series coloring |
| 105 | for i, series_spec in enumerate(elem.get("series", [])): |
| 106 | if "color" in series_spec and i < len(chart.series): |
| 107 | series = chart.series[i] |
| 108 | series.format.fill.solid() |
| 109 | color_spec = resolve_color(series_spec["color"], colors) |
| 110 | apply_color_to_fill(series.format.fill, color_spec) |
| 111 | |
| 112 | if "name" in elem: |
| 113 | chart_shape.name = elem["name"] |
| 114 | |
| 115 | return chart_shape |
| 116 | |
| 117 | |
| 118 | def extract_chart(shape) -> dict: |
| 119 | """Extract a chart element definition from a GraphicFrame shape.""" |
| 120 | chart = shape.chart |
| 121 | |
| 122 | elem = { |
| 123 | "type": "chart", |
| 124 | "chart_type": CHART_TYPE_REVERSE.get(chart.chart_type, "column_clustered"), |
| 125 | "left": emu_to_inches(shape.left), |
| 126 | "top": emu_to_inches(shape.top), |
| 127 | "width": emu_to_inches(shape.width), |
| 128 | "height": emu_to_inches(shape.height), |
| 129 | "name": shape.name, |
| 130 | } |
| 131 | |
| 132 | if chart.has_title: |
| 133 | try: |
| 134 | elem["title"] = chart.chart_title.text_frame.text |
| 135 | except (AttributeError, TypeError): |
| 136 | pass |
| 137 | elem["has_legend"] = chart.has_legend |
| 138 | |
| 139 | # Extract categories and series data |
| 140 | try: |
| 141 | plot = chart.plots[0] |
| 142 | if hasattr(plot, "categories") and plot.categories: |
| 143 | elem["categories"] = list(plot.categories) |
| 144 | elem["series"] = [] |
| 145 | for series in plot.series: |
| 146 | series_data = { |
| 147 | "name": series.name or "", |
| 148 | "values": list(series.values), |
| 149 | } |
| 150 | elem["series"].append(series_data) |
| 151 | except (IndexError, AttributeError): |
| 152 | pass |
| 153 | |
| 154 | return elem |
| 155 | |