""" Native editable chart factory using python-pptx. NO matplotlib / PNG insertion. All charts are native PowerPoint objects. """ from pptx.chart.data import ChartData, CategoryChartData from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION, XL_TICK_MARK, XL_DATA_LABEL_POSITION from pptx.enum.text import PP_ALIGN from pptx.util import Emu, Pt from pptx.dml.color import RGBColor # Color palette — aligned with reference design theme YAML C_BLUE = RGBColor(0x1E, 0x3A, 0x5F) # primary C_BLUE_DARK = RGBColor(0x1E, 0x3A, 0x5F) # primary dark C_ACCENT = RGBColor(0x10, 0xB9, 0x81) # accent (growth) C_ACCENT_NEG = RGBColor(0xEF, 0x44, 0x44) # accentNeg (decline) C_ORANGE = RGBColor(0xED, 0x7D, 0x31) C_GRAY = RGBColor(0x64, 0x74, 0x8B) # secondary C_GREEN = RGBColor(0x10, 0xB9, 0x81) C_RED = RGBColor(0xEF, 0x44, 0x44) C_TEXT = RGBColor(0x33, 0x33, 0x33) C_GRID = RGBColor(0xD9, 0xD9, 0xD9) C_WHITE = RGBColor(0xFF, 0xFF, 0xFF) # Default series colors for multi-color charts like doughnut/pie DEFAULT_COLORS = [ RGBColor(0x1E, 0x3A, 0x5F), # primary RGBColor(0x10, 0xB9, 0x81), # accent RGBColor(0xED, 0x7D, 0x31), # orange RGBColor(0x64, 0x74, 0x8B), # secondary RGBColor(0xEF, 0x44, 0x44), # red RGBColor(0x70, 0x70, 0x70), # dark gray RGBColor(0x44, 0x72, 0xC4), # indigo RGBColor(0x10, 0xB9, 0x81), # accent2 ] def _apply_common_style(chart, show_legend=False, category_axis_title=None, value_axis_title=None, theme_colors=None): """Apply common styling to a chart. Safe for all chart types.""" tc = theme_colors or {} chart.has_title = False # Legend handling if show_legend: chart.has_legend = True chart.legend.position = XL_LEGEND_POSITION.BOTTOM chart.legend.include_in_layout = False chart.legend.font.size = Pt(10) chart.legend.font.name = '微软雅黑' else: chart.has_legend = False # Category axis (not all charts have one, e.g. DOUGHNUT) try: cat_axis = chart.category_axis if cat_axis: cat_axis.tick_labels.font.size = Pt(10) cat_axis.tick_labels.font.color.rgb = RGBColor(0x66, 0x66, 0x66) cat_axis.tick_labels.font.name = '微软雅黑' cat_axis.format.line.fill.background() try: cat_axis.major_tick_mark = XL_TICK_MARK.NONE except Exception: pass if category_axis_title: try: cat_axis.has_title = True cat_axis.axis_title.text_frame.text = category_axis_title cat_axis.axis_title.text_frame.paragraphs[0].font.size = Pt(10) cat_axis.axis_title.text_frame.paragraphs[0].font.name = '微软雅黑' cat_axis.axis_title.text_frame.paragraphs[0].font.color.rgb = RGBColor(0x66, 0x66, 0x66) except Exception: pass except Exception: pass # Value axis try: val_axis = chart.value_axis if val_axis: val_axis.tick_labels.font.size = Pt(10) val_axis.tick_labels.font.color.rgb = RGBColor(0x66, 0x66, 0x66) val_axis.tick_labels.font.name = '微软雅黑' val_axis.format.line.fill.background() try: val_axis.major_tick_mark = XL_TICK_MARK.NONE except Exception: pass try: if val_axis.has_major_gridlines: val_axis.major_gridlines.format.line.color.rgb = tc.get('grid', C_GRID) val_axis.major_gridlines.format.line.width = Pt(0.75) except Exception: pass if value_axis_title: try: val_axis.has_title = True val_axis.axis_title.text_frame.text = value_axis_title val_axis.axis_title.text_frame.paragraphs[0].font.size = Pt(10) val_axis.axis_title.text_frame.paragraphs[0].font.name = '微软雅黑' val_axis.axis_title.text_frame.paragraphs[0].font.color.rgb = RGBColor(0x66, 0x66, 0x66) except Exception: pass except Exception: pass # Plot area & chart area background cleanup try: chart.plot_area.format.fill.background() chart.plot_area.format.line.fill.background() except Exception: pass try: chart.format.fill.background() chart.format.line.fill.background() except Exception: pass def _apply_data_labels(series, show_value=True, show_percent=False, font_size=Pt(10), position=XL_DATA_LABEL_POSITION.OUTSIDE_END, number_format=None, theme_colors=None): """Add data labels to a series with safe formatting.""" tc = theme_colors or {} text_color = tc.get('text', C_TEXT) series.has_data_labels = True for point in series.points: dl = point.data_label dl.font.size = font_size dl.font.color.rgb = text_color dl.font.name = '微软雅黑' if show_percent and hasattr(dl, 'show_percent'): try: dl.show_percent = True except Exception: pass if show_value and hasattr(dl, 'show_value'): try: dl.show_value = True except Exception: pass try: dl.position = position except Exception: pass if number_format and hasattr(dl, 'number_format'): try: dl.number_format = number_format except Exception: pass def _set_series_color(series, color): """Safely set series fill color.""" try: series.format.fill.solid() series.format.fill.fore_color.rgb = color except Exception: pass def add_column_chart(slide, categories, values, left, top, width, height, series_name='数值', color=C_BLUE, show_data_labels=True, second_series=None, second_color=C_ORANGE, category_axis_title=None, value_axis_title=None): """Add a clustered column chart.""" chart_data = ChartData() chart_data.categories = categories chart_data.add_series(series_name, values) if second_series: chart_data.add_series(second_series[0], second_series[1]) chart = slide.shapes.add_chart( XL_CHART_TYPE.COLUMN_CLUSTERED, left, top, width, height, chart_data ).chart _apply_common_style(chart, show_legend=bool(second_series), category_axis_title=category_axis_title, value_axis_title=value_axis_title) # Color first series _set_series_color(chart.series[0], color) if second_series and len(chart.series) > 1: _set_series_color(chart.series[1], second_color) if show_data_labels: _apply_data_labels(chart.series[0]) if second_series and len(chart.series) > 1: _apply_data_labels(chart.series[1]) return chart def add_bar_chart(slide, categories, values, left, top, width, height, series_name='数值', color=C_BLUE, reverse_order=True, show_data_labels=True, category_axis_title=None, value_axis_title=None): """Add a clustered bar chart (horizontal).""" chart_data = ChartData() chart_data.categories = categories chart_data.add_series(series_name, values) chart = slide.shapes.add_chart( XL_CHART_TYPE.BAR_CLUSTERED, left, top, width, height, chart_data ).chart _apply_common_style(chart, show_legend=False, category_axis_title=category_axis_title, value_axis_title=value_axis_title) if reverse_order: try: chart.category_axis.reverse_order = True except Exception: pass _set_series_color(chart.series[0], color) if show_data_labels: _apply_data_labels(chart.series[0], position=XL_DATA_LABEL_POSITION.OUTSIDE_END) return chart def add_horizontal_bar_chart(slide, categories, values, left, top, width, height, series_name='数值', color=C_BLUE, reverse_order=True, show_data_labels=True, data_label_format=None, category_axis_title=None, value_axis_title=None): """Add a horizontal bar chart (alias for add_bar_chart with enhanced defaults).""" return add_bar_chart( slide, categories, values, left, top, width, height, series_name=series_name, color=color, reverse_order=reverse_order, show_data_labels=show_data_labels, category_axis_title=category_axis_title, value_axis_title=value_axis_title ) def add_line_chart(slide, categories, values, left, top, width, height, series_name='数值', color=C_BLUE, marker_size=7, show_data_labels=False, second_series=None, second_color=C_ORANGE, category_axis_title=None, value_axis_title=None): """Add a line chart with markers.""" chart_data = ChartData() chart_data.categories = categories chart_data.add_series(series_name, values) if second_series: chart_data.add_series(second_series[0], second_series[1]) chart = slide.shapes.add_chart( XL_CHART_TYPE.LINE_MARKERS, left, top, width, height, chart_data ).chart _apply_common_style(chart, show_legend=bool(second_series), category_axis_title=category_axis_title, value_axis_title=value_axis_title) chart.series[0].format.line.color.rgb = color chart.series[0].format.line.width = Pt(2.5) try: chart.series[0].marker.style = 1 # circle chart.series[0].marker.size = marker_size except Exception: pass if second_series and len(chart.series) > 1: chart.series[1].format.line.color.rgb = second_color chart.series[1].format.line.width = Pt(2.5) try: chart.series[1].marker.style = 1 chart.series[1].marker.size = marker_size except Exception: pass if show_data_labels: _apply_data_labels(chart.series[0]) return chart def add_pie_chart(slide, categories, values, left, top, width, height, colors=None, show_data_labels=True, show_legend=True, show_percent=True): """Add a pie chart with legend and data labels.""" chart_data = ChartData() chart_data.categories = categories chart_data.add_series('占比', values) chart = slide.shapes.add_chart( XL_CHART_TYPE.PIE, left, top, width, height, chart_data ).chart _apply_common_style(chart, show_legend=show_legend) # Color each point individually series = chart.series[0] point_colors = colors if colors else DEFAULT_COLORS for i, point in enumerate(series.points): try: point.format.fill.solid() point.format.fill.fore_color.rgb = point_colors[i % len(point_colors)] except Exception: pass total = sum(values) if values else 0 if show_data_labels: series.has_data_labels = True for i, point in enumerate(series.points): dl = point.data_label dl.font.size = Pt(9) dl.font.color.rgb = C_TEXT dl.font.name = '微软雅黑' val = values[i] if i < len(values) else 0 pct = val / total * 100 if total else 0 # Compact label: fit inside slice; show value+percent, omit long category name if pct >= 15: dl.text_frame.text = f'{val}\n({pct:.1f}%)' else: dl.text_frame.text = f'{pct:.1f}%' dl.show_value = False dl.show_percent = False dl.show_category_name = False try: dl.position = XL_DATA_LABEL_POSITION.CENTER except Exception: pass return chart def add_doughnut_chart(slide, categories, values, left, top, width, height, colors=None, hole_size=0.5, show_data_labels=True, show_legend=True, show_percent=True, ring_ratio=None): """Add a doughnut chart with legend and data labels. Args: ring_ratio: Alias for hole_size as a ratio (0.0-1.0). If provided, overrides hole_size. """ if ring_ratio is not None: hole_size = ring_ratio chart_data = ChartData() chart_data.categories = categories chart_data.add_series('占比', values) chart = slide.shapes.add_chart( XL_CHART_TYPE.DOUGHNUT, left, top, width, height, chart_data ).chart _apply_common_style(chart, show_legend=show_legend) # Hole size try: if hasattr(chart.plots[0], 'hole_size'): chart.plots[0].hole_size = int(hole_size * 100) except Exception: pass # Color each point individually series = chart.series[0] point_colors = colors if colors else DEFAULT_COLORS for i, point in enumerate(series.points): try: point.format.fill.solid() point.format.fill.fore_color.rgb = point_colors[i % len(point_colors)] except Exception: pass total = sum(values) if values else 0 if show_data_labels: series.has_data_labels = True for i, point in enumerate(series.points): dl = point.data_label dl.font.size = Pt(9) dl.font.color.rgb = C_TEXT dl.font.name = '微软雅黑' val = values[i] if i < len(values) else 0 pct = val / total * 100 if total else 0 # Compact label: fit inside slice; show value+percent, omit long category name if pct >= 15: dl.text_frame.text = f'{val}\n({pct:.1f}%)' else: dl.text_frame.text = f'{pct:.1f}%' dl.show_value = False dl.show_percent = False dl.show_category_name = False try: dl.position = XL_DATA_LABEL_POSITION.CENTER except Exception: pass return chart def add_funnel_chart(slide, categories, values, left, top, width, height, colors=None, show_data_labels=True, show_percent=True): """Add a funnel-style chart using horizontal bar chart with reversed order. Displays stages from top to bottom with data labels showing quantity + percentage. """ total = sum(values) if values else 0 chart_data = ChartData() chart_data.categories = categories chart_data.add_series('数量', values) chart = slide.shapes.add_chart( XL_CHART_TYPE.BAR_CLUSTERED, left, top, width, height, chart_data ).chart _apply_common_style(chart, show_legend=False) # Reverse order so first category is at top try: chart.category_axis.reverse_order = True except Exception: pass # Color each point individually series = chart.series[0] point_colors = colors if colors else DEFAULT_COLORS for i, point in enumerate(series.points): try: point.format.fill.solid() point.format.fill.fore_color.rgb = point_colors[i % len(point_colors)] except Exception: pass if show_data_labels: series.has_data_labels = True for i, point in enumerate(series.points): dl = point.data_label dl.font.size = Pt(11) dl.font.color.rgb = C_TEXT dl.font.name = '微软雅黑' dl.show_value = True if show_percent and total > 0: pct = values[i] / total * 100 # Custom label with value and percent dl.text_frame.text = f"{values[i]} ({pct:.1f}%)" dl.show_value = False dl.show_percent = False dl.position = XL_DATA_LABEL_POSITION.OUTSIDE_END return chart def add_grouped_bar_chart(slide, categories, series_list, left, top, width, height, colors=None, show_data_labels=True, show_legend=True, category_axis_title=None, value_axis_title=None): """Add a grouped (clustered) column/bar chart with multiple series. Args: series_list: list of tuples [(series_name, values), ...] colors: list of RGBColor for each series """ chart_data = ChartData() chart_data.categories = categories for name, vals in series_list: chart_data.add_series(name, vals) chart = slide.shapes.add_chart( XL_CHART_TYPE.COLUMN_CLUSTERED, left, top, width, height, chart_data ).chart _apply_common_style(chart, show_legend=show_legend, category_axis_title=category_axis_title, value_axis_title=value_axis_title) point_colors = colors if colors else DEFAULT_COLORS for i, series in enumerate(chart.series): _set_series_color(series, point_colors[i % len(point_colors)]) if show_data_labels: _apply_data_labels(series) return chart def add_table(slide, rows, cols, data, left, top, width, height, header_color=RGBColor(0x2E, 0x5B, 0x8B), header_text_color=RGBColor(0xFF, 0xFF, 0xFF), cell_color=RGBColor(0xFF, 0xFF, 0xFF), alternate_color=RGBColor(0xF8, 0xFA, 0xFC), font_size=Pt(11), max_cell_chars=45): """ Add a styled table with auto row height and text truncation. data: list of lists, first row is header. max_cell_chars: max characters per cell before truncating with '...' """ from pptx.util import Pt table = slide.shapes.add_table(rows, cols, left, top, width, height).table # Set column widths proportionally for i in range(cols): table.columns[i].width = width // cols # Calculate row height based on content base_row_height = height // rows for r_idx, row_data in enumerate(data): for c_idx, val in enumerate(row_data): if c_idx >= cols: break cell = table.cell(r_idx, c_idx) text = str(val) if val is not None else '' # Truncate if too long if len(text) > max_cell_chars: text = text[:max_cell_chars - 1] + '...' cell.text = text # Enable word wrap cell.text_frame.word_wrap = True # Font styling paragraph = cell.text_frame.paragraphs[0] paragraph.font.size = font_size paragraph.font.name = '微软雅黑' paragraph.font.color.rgb = header_text_color if r_idx == 0 else C_TEXT # Background cell.fill.solid() if r_idx == 0: cell.fill.fore_color.rgb = header_color paragraph.font.bold = True else: if r_idx % 2 == 0 and alternate_color: cell.fill.fore_color.rgb = alternate_color else: cell.fill.fore_color.rgb = cell_color # Vertical alignment cell.vertical_anchor = 1 # middle return table if __name__ == '__main__': from pptx import Presentation prs = Presentation() blank = prs.slide_layouts[6] s = prs.slides.add_slide(blank) add_column_chart(s, ['A', 'B', 'C'], [10, 20, 15], Emu(1000000), Emu(1000000), Emu(5000000), Emu(3000000)) add_bar_chart(s, ['X', 'Y', 'Z'], [30, 40, 25], Emu(1000000), Emu(4500000), Emu(5000000), Emu(3000000)) add_doughnut_chart(s, ['A', 'B', 'C'], [30, 40, 25], Emu(1000000), Emu(8000000), Emu(3000000), Emu(2000000), show_legend=True) add_pie_chart(s, ['A', 'B', 'C'], [30, 40, 25], Emu(4500000), Emu(8000000), Emu(3000000), Emu(2000000), show_legend=True) add_funnel_chart(s, ['Stage1', 'Stage2', 'Stage3'], [100, 60, 30], Emu(1000000), Emu(11000000), Emu(5000000), Emu(3000000)) add_grouped_bar_chart(s, ['A', 'B', 'C'], [ ('本期', [10, 20, 15]), ('上期', [8, 18, 12]) ], Emu(6000000), Emu(11000000), Emu(5000000), Emu(3000000)) prs.save('chart_test.pptx') print('Saved chart_test.pptx')