chart_factory.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572
  1. """
  2. Native editable chart factory using python-pptx.
  3. NO matplotlib / PNG insertion. All charts are native PowerPoint objects.
  4. """
  5. from pptx.chart.data import ChartData, CategoryChartData
  6. from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION, XL_TICK_MARK, XL_DATA_LABEL_POSITION
  7. from pptx.enum.text import PP_ALIGN
  8. from pptx.util import Emu, Pt
  9. from pptx.dml.color import RGBColor
  10. # Color palette — aligned with reference design theme YAML
  11. C_BLUE = RGBColor(0x1E, 0x3A, 0x5F) # primary
  12. C_BLUE_DARK = RGBColor(0x1E, 0x3A, 0x5F) # primary dark
  13. C_ACCENT = RGBColor(0x10, 0xB9, 0x81) # accent (growth)
  14. C_ACCENT_NEG = RGBColor(0xEF, 0x44, 0x44) # accentNeg (decline)
  15. C_ORANGE = RGBColor(0xED, 0x7D, 0x31)
  16. C_GRAY = RGBColor(0x64, 0x74, 0x8B) # secondary
  17. C_GREEN = RGBColor(0x10, 0xB9, 0x81)
  18. C_RED = RGBColor(0xEF, 0x44, 0x44)
  19. C_TEXT = RGBColor(0x33, 0x33, 0x33)
  20. C_GRID = RGBColor(0xD9, 0xD9, 0xD9)
  21. C_WHITE = RGBColor(0xFF, 0xFF, 0xFF)
  22. # Default series colors for multi-color charts like doughnut/pie
  23. DEFAULT_COLORS = [
  24. RGBColor(0x1E, 0x3A, 0x5F), # primary
  25. RGBColor(0x10, 0xB9, 0x81), # accent
  26. RGBColor(0xED, 0x7D, 0x31), # orange
  27. RGBColor(0x64, 0x74, 0x8B), # secondary
  28. RGBColor(0xEF, 0x44, 0x44), # red
  29. RGBColor(0x70, 0x70, 0x70), # dark gray
  30. RGBColor(0x44, 0x72, 0xC4), # indigo
  31. RGBColor(0x10, 0xB9, 0x81), # accent2
  32. ]
  33. def _apply_common_style(chart, show_legend=False, category_axis_title=None, value_axis_title=None,
  34. theme_colors=None):
  35. """Apply common styling to a chart. Safe for all chart types."""
  36. tc = theme_colors or {}
  37. chart.has_title = False
  38. # Legend handling
  39. if show_legend:
  40. chart.has_legend = True
  41. chart.legend.position = XL_LEGEND_POSITION.BOTTOM
  42. chart.legend.include_in_layout = False
  43. chart.legend.font.size = Pt(10)
  44. chart.legend.font.name = '微软雅黑'
  45. else:
  46. chart.has_legend = False
  47. # Category axis (not all charts have one, e.g. DOUGHNUT)
  48. try:
  49. cat_axis = chart.category_axis
  50. if cat_axis:
  51. cat_axis.tick_labels.font.size = Pt(10)
  52. cat_axis.tick_labels.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
  53. cat_axis.tick_labels.font.name = '微软雅黑'
  54. cat_axis.format.line.fill.background()
  55. try:
  56. cat_axis.major_tick_mark = XL_TICK_MARK.NONE
  57. except Exception:
  58. pass
  59. if category_axis_title:
  60. try:
  61. cat_axis.has_title = True
  62. cat_axis.axis_title.text_frame.text = category_axis_title
  63. cat_axis.axis_title.text_frame.paragraphs[0].font.size = Pt(10)
  64. cat_axis.axis_title.text_frame.paragraphs[0].font.name = '微软雅黑'
  65. cat_axis.axis_title.text_frame.paragraphs[0].font.color.rgb = RGBColor(0x66, 0x66, 0x66)
  66. except Exception:
  67. pass
  68. except Exception:
  69. pass
  70. # Value axis
  71. try:
  72. val_axis = chart.value_axis
  73. if val_axis:
  74. val_axis.tick_labels.font.size = Pt(10)
  75. val_axis.tick_labels.font.color.rgb = RGBColor(0x66, 0x66, 0x66)
  76. val_axis.tick_labels.font.name = '微软雅黑'
  77. val_axis.format.line.fill.background()
  78. try:
  79. val_axis.major_tick_mark = XL_TICK_MARK.NONE
  80. except Exception:
  81. pass
  82. try:
  83. if val_axis.has_major_gridlines:
  84. val_axis.major_gridlines.format.line.color.rgb = tc.get('grid', C_GRID)
  85. val_axis.major_gridlines.format.line.width = Pt(0.75)
  86. except Exception:
  87. pass
  88. if value_axis_title:
  89. try:
  90. val_axis.has_title = True
  91. val_axis.axis_title.text_frame.text = value_axis_title
  92. val_axis.axis_title.text_frame.paragraphs[0].font.size = Pt(10)
  93. val_axis.axis_title.text_frame.paragraphs[0].font.name = '微软雅黑'
  94. val_axis.axis_title.text_frame.paragraphs[0].font.color.rgb = RGBColor(0x66, 0x66, 0x66)
  95. except Exception:
  96. pass
  97. except Exception:
  98. pass
  99. # Plot area & chart area background cleanup
  100. try:
  101. chart.plot_area.format.fill.background()
  102. chart.plot_area.format.line.fill.background()
  103. except Exception:
  104. pass
  105. try:
  106. chart.format.fill.background()
  107. chart.format.line.fill.background()
  108. except Exception:
  109. pass
  110. def _apply_data_labels(series, show_value=True, show_percent=False, font_size=Pt(10),
  111. position=XL_DATA_LABEL_POSITION.OUTSIDE_END, number_format=None,
  112. theme_colors=None):
  113. """Add data labels to a series with safe formatting."""
  114. tc = theme_colors or {}
  115. text_color = tc.get('text', C_TEXT)
  116. series.has_data_labels = True
  117. for point in series.points:
  118. dl = point.data_label
  119. dl.font.size = font_size
  120. dl.font.color.rgb = text_color
  121. dl.font.name = '微软雅黑'
  122. if show_percent and hasattr(dl, 'show_percent'):
  123. try:
  124. dl.show_percent = True
  125. except Exception:
  126. pass
  127. if show_value and hasattr(dl, 'show_value'):
  128. try:
  129. dl.show_value = True
  130. except Exception:
  131. pass
  132. try:
  133. dl.position = position
  134. except Exception:
  135. pass
  136. if number_format and hasattr(dl, 'number_format'):
  137. try:
  138. dl.number_format = number_format
  139. except Exception:
  140. pass
  141. def _set_series_color(series, color):
  142. """Safely set series fill color."""
  143. try:
  144. series.format.fill.solid()
  145. series.format.fill.fore_color.rgb = color
  146. except Exception:
  147. pass
  148. def add_column_chart(slide, categories, values, left, top, width, height,
  149. series_name='数值', color=C_BLUE, show_data_labels=True,
  150. second_series=None, second_color=C_ORANGE,
  151. category_axis_title=None, value_axis_title=None):
  152. """Add a clustered column chart."""
  153. chart_data = ChartData()
  154. chart_data.categories = categories
  155. chart_data.add_series(series_name, values)
  156. if second_series:
  157. chart_data.add_series(second_series[0], second_series[1])
  158. chart = slide.shapes.add_chart(
  159. XL_CHART_TYPE.COLUMN_CLUSTERED,
  160. left, top, width, height,
  161. chart_data
  162. ).chart
  163. _apply_common_style(chart, show_legend=bool(second_series),
  164. category_axis_title=category_axis_title,
  165. value_axis_title=value_axis_title)
  166. # Color first series
  167. _set_series_color(chart.series[0], color)
  168. if second_series and len(chart.series) > 1:
  169. _set_series_color(chart.series[1], second_color)
  170. if show_data_labels:
  171. _apply_data_labels(chart.series[0])
  172. if second_series and len(chart.series) > 1:
  173. _apply_data_labels(chart.series[1])
  174. return chart
  175. def add_bar_chart(slide, categories, values, left, top, width, height,
  176. series_name='数值', color=C_BLUE, reverse_order=True,
  177. show_data_labels=True,
  178. category_axis_title=None, value_axis_title=None):
  179. """Add a clustered bar chart (horizontal)."""
  180. chart_data = ChartData()
  181. chart_data.categories = categories
  182. chart_data.add_series(series_name, values)
  183. chart = slide.shapes.add_chart(
  184. XL_CHART_TYPE.BAR_CLUSTERED,
  185. left, top, width, height,
  186. chart_data
  187. ).chart
  188. _apply_common_style(chart, show_legend=False,
  189. category_axis_title=category_axis_title,
  190. value_axis_title=value_axis_title)
  191. if reverse_order:
  192. try:
  193. chart.category_axis.reverse_order = True
  194. except Exception:
  195. pass
  196. _set_series_color(chart.series[0], color)
  197. if show_data_labels:
  198. _apply_data_labels(chart.series[0], position=XL_DATA_LABEL_POSITION.OUTSIDE_END)
  199. return chart
  200. def add_horizontal_bar_chart(slide, categories, values, left, top, width, height,
  201. series_name='数值', color=C_BLUE, reverse_order=True,
  202. show_data_labels=True, data_label_format=None,
  203. category_axis_title=None, value_axis_title=None):
  204. """Add a horizontal bar chart (alias for add_bar_chart with enhanced defaults)."""
  205. return add_bar_chart(
  206. slide, categories, values, left, top, width, height,
  207. series_name=series_name, color=color, reverse_order=reverse_order,
  208. show_data_labels=show_data_labels,
  209. category_axis_title=category_axis_title,
  210. value_axis_title=value_axis_title
  211. )
  212. def add_line_chart(slide, categories, values, left, top, width, height,
  213. series_name='数值', color=C_BLUE, marker_size=7,
  214. show_data_labels=False, second_series=None, second_color=C_ORANGE,
  215. category_axis_title=None, value_axis_title=None):
  216. """Add a line chart with markers."""
  217. chart_data = ChartData()
  218. chart_data.categories = categories
  219. chart_data.add_series(series_name, values)
  220. if second_series:
  221. chart_data.add_series(second_series[0], second_series[1])
  222. chart = slide.shapes.add_chart(
  223. XL_CHART_TYPE.LINE_MARKERS,
  224. left, top, width, height,
  225. chart_data
  226. ).chart
  227. _apply_common_style(chart, show_legend=bool(second_series),
  228. category_axis_title=category_axis_title,
  229. value_axis_title=value_axis_title)
  230. chart.series[0].format.line.color.rgb = color
  231. chart.series[0].format.line.width = Pt(2.5)
  232. try:
  233. chart.series[0].marker.style = 1 # circle
  234. chart.series[0].marker.size = marker_size
  235. except Exception:
  236. pass
  237. if second_series and len(chart.series) > 1:
  238. chart.series[1].format.line.color.rgb = second_color
  239. chart.series[1].format.line.width = Pt(2.5)
  240. try:
  241. chart.series[1].marker.style = 1
  242. chart.series[1].marker.size = marker_size
  243. except Exception:
  244. pass
  245. if show_data_labels:
  246. _apply_data_labels(chart.series[0])
  247. return chart
  248. def add_pie_chart(slide, categories, values, left, top, width, height,
  249. colors=None, show_data_labels=True, show_legend=True, show_percent=True):
  250. """Add a pie chart with legend and data labels."""
  251. chart_data = ChartData()
  252. chart_data.categories = categories
  253. chart_data.add_series('占比', values)
  254. chart = slide.shapes.add_chart(
  255. XL_CHART_TYPE.PIE,
  256. left, top, width, height,
  257. chart_data
  258. ).chart
  259. _apply_common_style(chart, show_legend=show_legend)
  260. # Color each point individually
  261. series = chart.series[0]
  262. point_colors = colors if colors else DEFAULT_COLORS
  263. for i, point in enumerate(series.points):
  264. try:
  265. point.format.fill.solid()
  266. point.format.fill.fore_color.rgb = point_colors[i % len(point_colors)]
  267. except Exception:
  268. pass
  269. total = sum(values) if values else 0
  270. if show_data_labels:
  271. series.has_data_labels = True
  272. for i, point in enumerate(series.points):
  273. dl = point.data_label
  274. dl.font.size = Pt(9)
  275. dl.font.color.rgb = C_TEXT
  276. dl.font.name = '微软雅黑'
  277. val = values[i] if i < len(values) else 0
  278. pct = val / total * 100 if total else 0
  279. # Compact label: fit inside slice; show value+percent, omit long category name
  280. if pct >= 15:
  281. dl.text_frame.text = f'{val}\n({pct:.1f}%)'
  282. else:
  283. dl.text_frame.text = f'{pct:.1f}%'
  284. dl.show_value = False
  285. dl.show_percent = False
  286. dl.show_category_name = False
  287. try:
  288. dl.position = XL_DATA_LABEL_POSITION.CENTER
  289. except Exception:
  290. pass
  291. return chart
  292. def add_doughnut_chart(slide, categories, values, left, top, width, height,
  293. colors=None, hole_size=0.5, show_data_labels=True,
  294. show_legend=True, show_percent=True, ring_ratio=None):
  295. """Add a doughnut chart with legend and data labels.
  296. Args:
  297. ring_ratio: Alias for hole_size as a ratio (0.0-1.0). If provided, overrides hole_size.
  298. """
  299. if ring_ratio is not None:
  300. hole_size = ring_ratio
  301. chart_data = ChartData()
  302. chart_data.categories = categories
  303. chart_data.add_series('占比', values)
  304. chart = slide.shapes.add_chart(
  305. XL_CHART_TYPE.DOUGHNUT,
  306. left, top, width, height,
  307. chart_data
  308. ).chart
  309. _apply_common_style(chart, show_legend=show_legend)
  310. # Hole size
  311. try:
  312. if hasattr(chart.plots[0], 'hole_size'):
  313. chart.plots[0].hole_size = int(hole_size * 100)
  314. except Exception:
  315. pass
  316. # Color each point individually
  317. series = chart.series[0]
  318. point_colors = colors if colors else DEFAULT_COLORS
  319. for i, point in enumerate(series.points):
  320. try:
  321. point.format.fill.solid()
  322. point.format.fill.fore_color.rgb = point_colors[i % len(point_colors)]
  323. except Exception:
  324. pass
  325. total = sum(values) if values else 0
  326. if show_data_labels:
  327. series.has_data_labels = True
  328. for i, point in enumerate(series.points):
  329. dl = point.data_label
  330. dl.font.size = Pt(9)
  331. dl.font.color.rgb = C_TEXT
  332. dl.font.name = '微软雅黑'
  333. val = values[i] if i < len(values) else 0
  334. pct = val / total * 100 if total else 0
  335. # Compact label: fit inside slice; show value+percent, omit long category name
  336. if pct >= 15:
  337. dl.text_frame.text = f'{val}\n({pct:.1f}%)'
  338. else:
  339. dl.text_frame.text = f'{pct:.1f}%'
  340. dl.show_value = False
  341. dl.show_percent = False
  342. dl.show_category_name = False
  343. try:
  344. dl.position = XL_DATA_LABEL_POSITION.CENTER
  345. except Exception:
  346. pass
  347. return chart
  348. def add_funnel_chart(slide, categories, values, left, top, width, height,
  349. colors=None, show_data_labels=True, show_percent=True):
  350. """Add a funnel-style chart using horizontal bar chart with reversed order.
  351. Displays stages from top to bottom with data labels showing quantity + percentage.
  352. """
  353. total = sum(values) if values else 0
  354. chart_data = ChartData()
  355. chart_data.categories = categories
  356. chart_data.add_series('数量', values)
  357. chart = slide.shapes.add_chart(
  358. XL_CHART_TYPE.BAR_CLUSTERED,
  359. left, top, width, height,
  360. chart_data
  361. ).chart
  362. _apply_common_style(chart, show_legend=False)
  363. # Reverse order so first category is at top
  364. try:
  365. chart.category_axis.reverse_order = True
  366. except Exception:
  367. pass
  368. # Color each point individually
  369. series = chart.series[0]
  370. point_colors = colors if colors else DEFAULT_COLORS
  371. for i, point in enumerate(series.points):
  372. try:
  373. point.format.fill.solid()
  374. point.format.fill.fore_color.rgb = point_colors[i % len(point_colors)]
  375. except Exception:
  376. pass
  377. if show_data_labels:
  378. series.has_data_labels = True
  379. for i, point in enumerate(series.points):
  380. dl = point.data_label
  381. dl.font.size = Pt(11)
  382. dl.font.color.rgb = C_TEXT
  383. dl.font.name = '微软雅黑'
  384. dl.show_value = True
  385. if show_percent and total > 0:
  386. pct = values[i] / total * 100
  387. # Custom label with value and percent
  388. dl.text_frame.text = f"{values[i]} ({pct:.1f}%)"
  389. dl.show_value = False
  390. dl.show_percent = False
  391. dl.position = XL_DATA_LABEL_POSITION.OUTSIDE_END
  392. return chart
  393. def add_grouped_bar_chart(slide, categories, series_list, left, top, width, height,
  394. colors=None, show_data_labels=True, show_legend=True,
  395. category_axis_title=None, value_axis_title=None):
  396. """Add a grouped (clustered) column/bar chart with multiple series.
  397. Args:
  398. series_list: list of tuples [(series_name, values), ...]
  399. colors: list of RGBColor for each series
  400. """
  401. chart_data = ChartData()
  402. chart_data.categories = categories
  403. for name, vals in series_list:
  404. chart_data.add_series(name, vals)
  405. chart = slide.shapes.add_chart(
  406. XL_CHART_TYPE.COLUMN_CLUSTERED,
  407. left, top, width, height,
  408. chart_data
  409. ).chart
  410. _apply_common_style(chart, show_legend=show_legend,
  411. category_axis_title=category_axis_title,
  412. value_axis_title=value_axis_title)
  413. point_colors = colors if colors else DEFAULT_COLORS
  414. for i, series in enumerate(chart.series):
  415. _set_series_color(series, point_colors[i % len(point_colors)])
  416. if show_data_labels:
  417. _apply_data_labels(series)
  418. return chart
  419. def add_table(slide, rows, cols, data, left, top, width, height,
  420. header_color=RGBColor(0x2E, 0x5B, 0x8B),
  421. header_text_color=RGBColor(0xFF, 0xFF, 0xFF),
  422. cell_color=RGBColor(0xFF, 0xFF, 0xFF),
  423. alternate_color=RGBColor(0xF8, 0xFA, 0xFC),
  424. font_size=Pt(11),
  425. max_cell_chars=45):
  426. """
  427. Add a styled table with auto row height and text truncation.
  428. data: list of lists, first row is header.
  429. max_cell_chars: max characters per cell before truncating with '...'
  430. """
  431. from pptx.util import Pt
  432. table = slide.shapes.add_table(rows, cols, left, top, width, height).table
  433. # Set column widths proportionally
  434. for i in range(cols):
  435. table.columns[i].width = width // cols
  436. # Calculate row height based on content
  437. base_row_height = height // rows
  438. for r_idx, row_data in enumerate(data):
  439. for c_idx, val in enumerate(row_data):
  440. if c_idx >= cols:
  441. break
  442. cell = table.cell(r_idx, c_idx)
  443. text = str(val) if val is not None else ''
  444. # Truncate if too long
  445. if len(text) > max_cell_chars:
  446. text = text[:max_cell_chars - 1] + '...'
  447. cell.text = text
  448. # Enable word wrap
  449. cell.text_frame.word_wrap = True
  450. # Font styling
  451. paragraph = cell.text_frame.paragraphs[0]
  452. paragraph.font.size = font_size
  453. paragraph.font.name = '微软雅黑'
  454. paragraph.font.color.rgb = header_text_color if r_idx == 0 else C_TEXT
  455. # Background
  456. cell.fill.solid()
  457. if r_idx == 0:
  458. cell.fill.fore_color.rgb = header_color
  459. paragraph.font.bold = True
  460. else:
  461. if r_idx % 2 == 0 and alternate_color:
  462. cell.fill.fore_color.rgb = alternate_color
  463. else:
  464. cell.fill.fore_color.rgb = cell_color
  465. # Vertical alignment
  466. cell.vertical_anchor = 1 # middle
  467. return table
  468. if __name__ == '__main__':
  469. from pptx import Presentation
  470. prs = Presentation()
  471. blank = prs.slide_layouts[6]
  472. s = prs.slides.add_slide(blank)
  473. add_column_chart(s, ['A', 'B', 'C'], [10, 20, 15],
  474. Emu(1000000), Emu(1000000), Emu(5000000), Emu(3000000))
  475. add_bar_chart(s, ['X', 'Y', 'Z'], [30, 40, 25],
  476. Emu(1000000), Emu(4500000), Emu(5000000), Emu(3000000))
  477. add_doughnut_chart(s, ['A', 'B', 'C'], [30, 40, 25],
  478. Emu(1000000), Emu(8000000), Emu(3000000), Emu(2000000),
  479. show_legend=True)
  480. add_pie_chart(s, ['A', 'B', 'C'], [30, 40, 25],
  481. Emu(4500000), Emu(8000000), Emu(3000000), Emu(2000000),
  482. show_legend=True)
  483. add_funnel_chart(s, ['Stage1', 'Stage2', 'Stage3'], [100, 60, 30],
  484. Emu(1000000), Emu(11000000), Emu(5000000), Emu(3000000))
  485. add_grouped_bar_chart(s, ['A', 'B', 'C'], [
  486. ('本期', [10, 20, 15]),
  487. ('上期', [8, 18, 12])
  488. ], Emu(6000000), Emu(11000000), Emu(5000000), Emu(3000000))
  489. prs.save('chart_test.pptx')
  490. print('Saved chart_test.pptx')