| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572 |
- """
- 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')
|