chart_factory.py 20 KB

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