"""Native Chart Service: render ChartData as native python-pptx charts on slides.""" from typing import List, Optional from pptx.chart.data import CategoryChartData from pptx.dml.color import RGBColor from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION, XL_LABEL_POSITION from pptx.oxml.xmlchemy import OxmlElement from pptx.slide import Slide from pptx.util import Emu, Pt from services.chart_data_extractor import ChartData # Map our chart_type strings to python-pptx chart type enums _CHART_TYPE_MAP = { "bar": XL_CHART_TYPE.BAR_CLUSTERED, "column": XL_CHART_TYPE.COLUMN_CLUSTERED, "line": XL_CHART_TYPE.LINE_MARKERS, "pie": XL_CHART_TYPE.PIE, "doughnut": XL_CHART_TYPE.DOUGHNUT, "area": XL_CHART_TYPE.AREA, "scatter": XL_CHART_TYPE.XY_SCATTER, } # Default brand-neutral colors for chart series _DEFAULT_COLORS = [ "4472C4", "ED7D31", "A5A5A5", "FFC000", "5B9BD5", "70AD47", "264478", "9B57A0", "636363", "EB6E1F", ] class NativeChartService: def add_chart( self, slide: Slide, chart_data: ChartData, left: int, top: int, width: int, height: int, brand_colors: Optional[List[str]] = None, font_name: Optional[str] = None, ) -> None: """Add a native chart to a slide. For standard chart types (bar, column, line, pie, doughnut, area, scatter) uses python-pptx's add_chart API. For gantt and waterfall, falls back to shape-based rendering. Args: slide: The pptx Slide object chart_data: ChartData with type, categories, series left, top, width, height: Position/size in Pt units (raw int, will be wrapped) brand_colors: List of hex color strings (e.g. ["4472C4", "ED7D31"]) font_name: Font family name for labels """ if chart_data.chart_type == "gantt": self._add_gantt_chart(slide, chart_data, left, top, width, height, brand_colors, font_name) return if chart_data.chart_type == "waterfall": self._add_waterfall_chart(slide, chart_data, left, top, width, height, brand_colors, font_name) return xl_chart_type = _CHART_TYPE_MAP.get(chart_data.chart_type, XL_CHART_TYPE.COLUMN_CLUSTERED) colors = brand_colors or _DEFAULT_COLORS # Build chart data object pptx_data = CategoryChartData() pptx_data.categories = chart_data.categories for series in chart_data.series: pptx_data.add_series(series.name, series.values) # Add chart to slide chart_frame = slide.shapes.add_chart( xl_chart_type, Pt(left), Pt(top), Pt(width), Pt(height), pptx_data, ) chart = chart_frame.chart # Style the chart chart.has_legend = len(chart_data.series) > 1 if chart.has_legend: chart.legend.position = XL_LEGEND_POSITION.BOTTOM chart.legend.include_in_layout = False if font_name: chart.legend.font.name = font_name chart.legend.font.size = Pt(9) # Apply title chart.has_title = True chart.chart_title.text_frame.text = chart_data.title if font_name: chart.chart_title.text_frame.paragraphs[0].font.name = font_name chart.chart_title.text_frame.paragraphs[0].font.size = Pt(12) chart.chart_title.text_frame.paragraphs[0].font.bold = True # Apply brand colors to series self._apply_series_colors(chart, colors) # Style axes if chart_data.chart_type not in ("pie", "doughnut"): self._style_axes(chart, font_name) # Add data labels for pie/doughnut if chart_data.chart_type in ("pie", "doughnut"): self._add_pie_labels(chart, font_name) def _apply_series_colors(self, chart, colors: List[str]) -> None: """Apply brand colors to each series in the chart.""" plot = chart.plots[0] for i, series in enumerate(plot.series): color_hex = colors[i % len(colors)] series.format.fill.solid() series.format.fill.fore_color.rgb = RGBColor.from_string(color_hex) # For line charts, also color the line if hasattr(series, 'smooth'): series.format.line.color.rgb = RGBColor.from_string(color_hex) def _style_axes(self, chart, font_name: Optional[str]) -> None: """Style category and value axes.""" try: category_axis = chart.category_axis category_axis.has_minor_gridlines = False if font_name: category_axis.tick_labels.font.name = font_name category_axis.tick_labels.font.size = Pt(8) value_axis = chart.value_axis value_axis.has_minor_gridlines = False if font_name: value_axis.tick_labels.font.name = font_name value_axis.tick_labels.font.size = Pt(8) except Exception: pass # Some chart types don't have axes def _add_pie_labels(self, chart, font_name: Optional[str]) -> None: """Add percentage labels to pie/doughnut charts.""" plot = chart.plots[0] plot.has_data_labels = True data_labels = plot.data_labels data_labels.show_percentage = True data_labels.show_category_name = False data_labels.show_value = False data_labels.number_format = '0%' if font_name: data_labels.font.name = font_name data_labels.font.size = Pt(9) # --- Shape-based charts (Gantt, Waterfall) --- def _add_gantt_chart( self, slide: Slide, chart_data: ChartData, left: int, top: int, width: int, height: int, brand_colors: Optional[List[str]] = None, font_name: Optional[str] = None, ) -> None: """Render a Gantt chart using rectangles. Expects categories = task names, series[0] = start values, series[1] = duration values. Values are numeric (e.g. week numbers or day offsets). """ colors = brand_colors or _DEFAULT_COLORS if len(chart_data.series) < 2: return starts = chart_data.series[0].values durations = chart_data.series[1].values tasks = chart_data.categories n_tasks = len(tasks) if n_tasks == 0: return # Calculate bounds max_end = max(s + d for s, d in zip(starts, durations)) if starts else 1 min_start = min(starts) if starts else 0 chart_left = left + 120 # leave room for labels chart_width = width - 130 bar_height_total = height - 40 # leave room for title bar_h = max(bar_height_total // n_tasks - 4, 10) # Title title_box = slide.shapes.add_textbox(Pt(left), Pt(top), Pt(width), Pt(24)) tf = title_box.text_frame tf.text = chart_data.title if font_name: tf.paragraphs[0].font.name = font_name tf.paragraphs[0].font.size = Pt(12) tf.paragraphs[0].font.bold = True # Draw task bars range_span = max_end - min_start or 1 for i, (task, start, dur) in enumerate(zip(tasks, starts, durations)): y = top + 30 + i * (bar_h + 4) # Task label label = slide.shapes.add_textbox(Pt(left), Pt(y), Pt(115), Pt(bar_h)) label.text_frame.word_wrap = True label.text_frame.text = task if font_name: label.text_frame.paragraphs[0].font.name = font_name label.text_frame.paragraphs[0].font.size = Pt(8) # Bar bar_x = chart_left + int((start - min_start) / range_span * chart_width) bar_w = max(int(dur / range_span * chart_width), 6) from pptx.enum.shapes import MSO_SHAPE bar = slide.shapes.add_shape( MSO_SHAPE.ROUNDED_RECTANGLE, Pt(bar_x), Pt(y), Pt(bar_w), Pt(bar_h), ) bar.fill.solid() color = colors[i % len(colors)] bar.fill.fore_color.rgb = RGBColor.from_string(color) bar.line.fill.background() # no border def _add_waterfall_chart( self, slide: Slide, chart_data: ChartData, left: int, top: int, width: int, height: int, brand_colors: Optional[List[str]] = None, font_name: Optional[str] = None, ) -> None: """Render a waterfall chart using stacked shapes. series[0].values = incremental changes (positive or negative). The last category is treated as the total. """ colors = brand_colors or _DEFAULT_COLORS if not chart_data.series: return values = chart_data.series[0].values cats = chart_data.categories n = len(values) if n == 0: return # Compute cumulative cumulative = [] running = 0 for v in values: cumulative.append(running) running += v all_levels = cumulative + [running] max_val = max(max(all_levels), max(abs(v) for v in values), 1) min_val = min(min(all_levels), 0) val_range = max_val - min_val or 1 chart_area_top = top + 30 chart_area_height = height - 60 chart_area_left = left + 10 chart_area_width = width - 20 bar_width = max(chart_area_width // n - 8, 12) # Title title_box = slide.shapes.add_textbox(Pt(left), Pt(top), Pt(width), Pt(24)) tf = title_box.text_frame tf.text = chart_data.title if font_name: tf.paragraphs[0].font.name = font_name tf.paragraphs[0].font.size = Pt(12) tf.paragraphs[0].font.bold = True from pptx.enum.shapes import MSO_SHAPE positive_color = colors[0] if colors else "4472C4" negative_color = colors[1] if len(colors) > 1 else "ED7D31" total_color = colors[2] if len(colors) > 2 else "A5A5A5" for i in range(n): x = chart_area_left + i * (bar_width + 8) val = values[i] base = cumulative[i] is_last = i == n - 1 if is_last: # Total bar from 0 to cumulative total total = base + val bar_bottom = 0 bar_val = total color = total_color else: if val >= 0: bar_bottom = base bar_val = val color = positive_color else: bar_bottom = base + val bar_val = abs(val) color = negative_color # Convert to pixel positions bar_top_y = chart_area_top + int((max_val - bar_bottom - bar_val) / val_range * chart_area_height) bar_h = max(int(bar_val / val_range * chart_area_height), 4) bar = slide.shapes.add_shape( MSO_SHAPE.RECTANGLE, Pt(x), Pt(bar_top_y), Pt(bar_width), Pt(bar_h), ) bar.fill.solid() bar.fill.fore_color.rgb = RGBColor.from_string(color) bar.line.fill.background() # Category label below lbl = slide.shapes.add_textbox( Pt(x - 4), Pt(chart_area_top + chart_area_height + 2), Pt(bar_width + 8), Pt(20), ) lbl.text_frame.word_wrap = True lbl.text_frame.text = cats[i] if i < len(cats) else "" if font_name: lbl.text_frame.paragraphs[0].font.name = font_name lbl.text_frame.paragraphs[0].font.size = Pt(7)