发布:学生会策划部工作包(Skills + MCP)

This commit is contained in:
gitea_admin
2026-09-29 12:35:04 +08:00
commit 2df696c390
147 changed files with 50777 additions and 0 deletions
@@ -0,0 +1,754 @@
#!/usr/bin/env python3
"""
PowerPoint 生成器 — v2.0.0
纯 python-pptx 实现,生成标准可编辑 PPTX。
7 种幻灯片布局、5 套配色方案、图表、表格、图片、渐变背景、页码页脚。
用法:
python3 generate_pptx.py <json_input> [--out output.pptx] [--style business_blue]
python3 generate_pptx.py --interactive
"""
import os
import sys
import json
from pptx import Presentation
from pptx.util import Inches, Pt, Emu
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION, XL_LABEL_POSITION
from pptx.chart.data import CategoryChartData
# ── Color palettes ──────────────────────────────────────
PALETTES = {
"business_blue": {
"primary": RGBColor(30, 60, 114),
"secondary": RGBColor(70, 130, 180),
"accent": RGBColor(255, 193, 7),
"highlight": RGBColor(231, 76, 60),
"success": RGBColor(39, 174, 96),
"warning": RGBColor(230, 126, 34),
"text_primary": RGBColor(44, 62, 80),
"text_secondary": RGBColor(127, 140, 141),
"bg_primary": RGBColor(255, 255, 255),
"bg_secondary": RGBColor(236, 240, 241),
"gradient_start": RGBColor(30, 60, 114),
"gradient_end": RGBColor(52, 152, 219),
},
"academic_white": {
"primary": RGBColor(0, 51, 102),
"secondary": RGBColor(100, 100, 100),
"accent": RGBColor(178, 34, 34),
"highlight": RGBColor(0, 102, 153),
"success": RGBColor(34, 139, 34),
"warning": RGBColor(184, 134, 11),
"text_primary": RGBColor(51, 51, 51),
"text_secondary": RGBColor(119, 119, 119),
"bg_primary": RGBColor(255, 255, 255),
"bg_secondary": RGBColor(245, 245, 245),
"gradient_start": RGBColor(0, 51, 102),
"gradient_end": RGBColor(0, 102, 153),
},
"creative_purple": {
"primary": RGBColor(102, 45, 140),
"secondary": RGBColor(155, 89, 182),
"accent": RGBColor(241, 196, 15),
"highlight": RGBColor(231, 76, 60),
"success": RGBColor(46, 204, 113),
"warning": RGBColor(230, 126, 34),
"text_primary": RGBColor(52, 31, 71),
"text_secondary": RGBColor(136, 114, 158),
"bg_primary": RGBColor(248, 244, 252),
"bg_secondary": RGBColor(235, 225, 245),
"gradient_start": RGBColor(102, 45, 140),
"gradient_end": RGBColor(155, 89, 182),
},
"tech_dark": {
"primary": RGBColor(48, 56, 72),
"secondary": RGBColor(88, 100, 120),
"accent": RGBColor(0, 200, 150),
"highlight": RGBColor(0, 180, 216),
"success": RGBColor(46, 204, 113),
"warning": RGBColor(241, 196, 15),
"text_primary": RGBColor(230, 235, 240),
"text_secondary": RGBColor(170, 180, 195),
"bg_primary": RGBColor(60, 68, 85),
"bg_secondary": RGBColor(75, 85, 105),
"gradient_start": RGBColor(48, 56, 72),
"gradient_end": RGBColor(70, 80, 100),
},
"minimal_gray": {
"primary": RGBColor(80, 80, 80),
"secondary": RGBColor(150, 150, 150),
"accent": RGBColor(0, 120, 215),
"highlight": RGBColor(215, 80, 60),
"success": RGBColor(60, 170, 100),
"warning": RGBColor(210, 160, 40),
"text_primary": RGBColor(51, 51, 51),
"text_secondary": RGBColor(153, 153, 153),
"bg_primary": RGBColor(250, 250, 250),
"bg_secondary": RGBColor(240, 240, 240),
"gradient_start": RGBColor(80, 80, 80),
"gradient_end": RGBColor(140, 140, 140),
},
}
def _c(name, pal):
return pal.get(name, PALETTES["business_blue"][name])
# ── Helpers ──────────────────────────────────────────────
def _add_textbox(slide, left, top, w, h):
return slide.shapes.add_textbox(Inches(left), Inches(top), Inches(w), Inches(h))
def _add_para(tf, text, size=18, color=None, bold=False, align=None, spacing=0, level=0):
if tf.paragraphs and tf.paragraphs[0].text == "":
p = tf.paragraphs[0]
else:
p = tf.add_paragraph()
p.text = text
p.font.size = Pt(size)
p.font.bold = bold
p.level = level
if color:
p.font.color.rgb = color
if align:
p.alignment = align
if spacing:
p.space_after = Pt(spacing)
return p
def _add_shape(slide, shape_type, left, top, w, h, fill_color=None, line_color=None):
shape = slide.shapes.add_shape(shape_type, Inches(left), Inches(top), Inches(w), Inches(h))
shape.line.fill.background()
if fill_color:
shape.fill.solid()
shape.fill.fore_color.rgb = fill_color
else:
shape.fill.background()
if line_color:
shape.line.color.rgb = line_color
shape.line.width = Pt(1)
return shape
def _set_bg(slide, color):
bg = slide.background
fill = bg.fill
fill.solid()
fill.fore_color.rgb = color
def _add_page_number(slide, num, pal, prs):
sw = prs.slide_width
box = slide.shapes.add_textbox(Inches(0.3), Emu(sw) - Inches(0.4), Inches(1), Inches(0.3))
p = box.text_frame.paragraphs[0]
p.text = str(num)
p.font.size = Pt(8)
p.font.color.rgb = _c("text_secondary", pal)
p.alignment = PP_ALIGN.LEFT
def _add_footer(slide, text, pal, prs):
if not text:
return
sw = prs.slide_width
box = slide.shapes.add_textbox(Inches(5), Emu(sw) - Inches(0.4), Inches(5), Inches(0.3))
p = box.text_frame.paragraphs[0]
p.text = text
p.font.size = Pt(8)
p.font.color.rgb = _c("text_secondary", pal)
p.alignment = PP_ALIGN.RIGHT
def _validate_file(path):
if not os.path.exists(path):
raise FileNotFoundError(f"文件不存在: {path}")
# ── Generator ────────────────────────────────────────────
class PPTGenerator:
SLIDE_W = 13.333
SLIDE_H = 7.5
def __init__(self, style="business_blue", footer="", page_numbers=True):
self.prs = Presentation()
self.prs.slide_width = Inches(self.SLIDE_W)
self.prs.slide_height = Inches(self.SLIDE_H)
self.pal = PALETTES.get(style, PALETTES["business_blue"])
self.style = style
self.footer = footer
self.page_numbers = page_numbers
self._page = 0
def _next_page(self):
self._page += 1
return self._page
def _make_slide(self):
slide = self.prs.slides.add_slide(self.prs.slide_layouts[6])
pn = self._next_page()
if self.page_numbers:
_add_page_number(slide, pn, self.pal, self.prs)
if self.footer:
_add_footer(slide, self.footer, self.pal, self.prs)
return slide
def _title_bar(self, slide, title, height=1):
_add_shape(slide, MSO_SHAPE.RECTANGLE, 0, 0, self.SLIDE_W, height,
fill_color=_c("primary", self.pal))
_add_shape(slide, MSO_SHAPE.RECTANGLE, 0, height, self.SLIDE_W, 0.06,
fill_color=_c("accent", self.pal))
tb = _add_textbox(slide, 0.8, 0.12, 11.733, height - 0.2)
_add_para(tb.text_frame, title, size=26, bold=True,
color=RGBColor(255, 255, 255), align=PP_ALIGN.LEFT)
def add_cover(self, title, subtitle="", author="", date="", variant="centered"):
slide = self._make_slide()
if variant == "split":
_add_shape(slide, MSO_SHAPE.RECTANGLE, 0, 0, 6.667, self.SLIDE_H,
fill_color=_c("primary", self.pal))
_add_shape(slide, MSO_SHAPE.RECTANGLE, 6.667, 0, 0.06, self.SLIDE_H,
fill_color=_c("accent", self.pal))
tb = _add_textbox(slide, 7.2, 2, 5.3, 4)
_add_para(tb.text_frame, title, size=38, bold=True, color=_c("bg_primary", self.pal),
align=PP_ALIGN.LEFT, spacing=14)
if subtitle:
_add_para(tb.text_frame, subtitle, size=18, color=_c("bg_secondary", self.pal),
align=PP_ALIGN.LEFT)
elif variant == "left":
_set_bg(slide, _c("bg_primary", self.pal))
_add_shape(slide, MSO_SHAPE.RECTANGLE, 0, 0, 0.6, self.SLIDE_H,
fill_color=_c("primary", self.pal))
_add_shape(slide, MSO_SHAPE.RECTANGLE, 0.6, 0, 0.06, self.SLIDE_H,
fill_color=_c("accent", self.pal))
tb = _add_textbox(slide, 1.5, 2.2, 10.8, 3)
_add_para(tb.text_frame, title, size=40, bold=True, color=_c("primary", self.pal),
align=PP_ALIGN.LEFT, spacing=12)
if subtitle:
_add_para(tb.text_frame, subtitle, size=20, color=_c("text_secondary", self.pal),
align=PP_ALIGN.LEFT)
else:
_set_bg(slide, _c("bg_primary", self.pal))
_add_shape(slide, MSO_SHAPE.RECTANGLE, 3.5, 3.5, 6.333, 0.06,
fill_color=_c("accent", self.pal))
tb = _add_textbox(slide, 1.5, 1.8, 10.333, 2.5)
_add_para(tb.text_frame, title, size=42, bold=True, color=_c("primary", self.pal),
align=PP_ALIGN.CENTER, spacing=14)
if subtitle:
_add_para(tb.text_frame, subtitle, size=20, color=_c("secondary", self.pal),
align=PP_ALIGN.CENTER)
if author or date:
meta = " | ".join(filter(None, [author, date]))
y = 6.2 if variant != "split" else 6
mb = _add_textbox(slide, 1.5, y, 10.333, 0.5)
_add_para(mb.text_frame, meta, size=13,
color=_c("text_secondary", self.pal), align=PP_ALIGN.CENTER)
return slide
def add_section(self, title, subtitle=""):
slide = self._make_slide()
_set_bg(slide, _c("primary", self.pal))
_add_shape(slide, MSO_SHAPE.RECTANGLE, 4, 3.4, 5.333, 0.06,
fill_color=_c("accent", self.pal))
tb = _add_textbox(slide, 1.5, 1.2, 10.333, 2)
_add_para(tb.text_frame, title, size=34, bold=True, color=_c("bg_primary", self.pal),
align=PP_ALIGN.CENTER, spacing=10)
if subtitle:
_add_para(tb.text_frame, subtitle, size=17, color=_c("bg_secondary", self.pal),
align=PP_ALIGN.CENTER)
return slide
def add_content(self, title, items, columns=1):
slide = self._make_slide()
_set_bg(slide, _c("bg_primary", self.pal))
self._title_bar(slide, title)
if columns == 1:
tb = _add_textbox(slide, 0.9, 1.5, 11.533, 5.3)
for item in items:
if isinstance(item, dict):
_add_para(tb.text_frame, item.get("t", ""), size=20, bold=True,
color=_c("primary", self.pal), spacing=4)
for sub in item.get("s", []):
_add_para(tb.text_frame, f" {sub}", size=15,
color=_c("text_secondary", self.pal), spacing=3, level=1)
else:
_add_para(tb.text_frame, str(item), size=17,
color=_c("text_primary", self.pal), spacing=9)
elif columns == 2:
col_w = 5.6
sep_x = 6.6
_add_shape(slide, MSO_SHAPE.RECTANGLE, sep_x, 1.3, 0.03, 5.5,
fill_color=_c("bg_secondary", self.pal))
for ci in range(2):
tb = _add_textbox(slide, 0.9 + ci * (col_w + 1), 1.4, col_w, 5.3)
for item in items[ci::2]:
_add_para(tb.text_frame, str(item), size=15,
color=_c("text_primary", self.pal), spacing=7)
return slide
def add_two_column(self, title, left_items, right_items):
slide = self._make_slide()
_set_bg(slide, _c("bg_primary", self.pal))
self._title_bar(slide, title)
_add_shape(slide, MSO_SHAPE.RECTANGLE, 6.6, 1.3, 0.03, 5.5,
fill_color=_c("bg_secondary", self.pal))
for ci, (items) in enumerate([left_items, right_items]):
tb = _add_textbox(slide, 0.9 + ci * 6, 1.4, 5.3, 5.3)
for item in items:
_add_para(tb.text_frame, str(item), size=15,
color=_c("text_primary", self.pal), spacing=7)
return slide
def add_table(self, title, headers, rows):
slide = self._make_slide()
_set_bg(slide, _c("bg_primary", self.pal))
self._title_bar(slide, title)
nrows = len(rows) + 1
ncols = len(headers)
if ncols == 0 or nrows == 0:
return slide
tbl = slide.shapes.add_table(nrows, ncols, Inches(0.5), Inches(1.5),
Inches(12.333), Inches(5.3)).table
for i, h in enumerate(headers):
c = tbl.cell(0, i)
c.text = str(h)
c.fill.solid()
c.fill.fore_color.rgb = _c("primary", self.pal)
for p in c.text_frame.paragraphs:
p.font.size = Pt(13)
p.font.bold = True
p.font.color.rgb = RGBColor(255, 255, 255)
p.alignment = PP_ALIGN.CENTER
for ri, row in enumerate(rows, 1):
for ci, val in enumerate(row):
c = tbl.cell(ri, ci)
c.text = str(val)
if ri % 2 == 0:
c.fill.solid()
c.fill.fore_color.rgb = _c("bg_secondary", self.pal)
for p in c.text_frame.paragraphs:
p.font.size = Pt(12)
p.font.color.rgb = _c("text_primary", self.pal)
p.alignment = PP_ALIGN.CENTER
return slide
def add_image_slide(self, title, image_path, caption="", overlay=False):
_validate_file(image_path)
slide = self._make_slide()
if overlay:
slide.shapes.add_picture(image_path, Inches(0), Inches(0),
Inches(self.SLIDE_W), Inches(self.SLIDE_H))
_add_shape(slide, MSO_SHAPE.RECTANGLE, 0, 0, self.SLIDE_W, self.SLIDE_H,
fill_color=_c("primary", self.pal))
_add_shape(slide, MSO_SHAPE.RECTANGLE, 3.5, 4.8, 6.333, 0.06,
fill_color=_c("accent", self.pal))
tb = _add_textbox(slide, 1.5, 2, 10.333, 2.5)
_add_para(tb.text_frame, title, size=38, bold=True,
color=RGBColor(255, 255, 255), align=PP_ALIGN.CENTER, spacing=10)
if caption:
_add_para(tb.text_frame, caption, size=18,
color=_c("bg_secondary", self.pal), align=PP_ALIGN.CENTER)
else:
_set_bg(slide, _c("bg_primary", self.pal))
self._title_bar(slide, title)
slide.shapes.add_picture(image_path, Inches(1.2), Inches(1.5),
Inches(10.933), Inches(4.8))
if caption:
tb = _add_textbox(slide, 0.9, 6.4, 11.533, 0.5)
_add_para(tb.text_frame, caption, size=12,
color=_c("text_secondary", self.pal), align=PP_ALIGN.CENTER)
return slide
def add_summary(self, title, points, conclusion=""):
slide = self._make_slide()
_set_bg(slide, _c("bg_primary", self.pal))
self._title_bar(slide, title)
tb = _add_textbox(slide, 1.2, 1.5, 10.933, 4)
for pt in points:
txt = pt if isinstance(pt, str) else pt.get("t", str(pt))
_add_para(tb.text_frame, txt, size=17,
color=_c("text_primary", self.pal), spacing=10)
if conclusion:
box = _add_shape(slide, MSO_SHAPE.ROUNDED_RECTANGLE, 1, 5.6, 11.333, 1,
fill_color=_c("secondary", self.pal))
tf = box.text_frame
tf.word_wrap = True
_add_para(tf, conclusion, size=17, bold=True,
color=RGBColor(255, 255, 255), align=PP_ALIGN.CENTER)
return slide
def add_timeline(self, title, milestones):
slide = self._make_slide()
_set_bg(slide, _c("bg_primary", self.pal))
self._title_bar(slide, title)
n = len(milestones)
if n == 0:
return slide
spacing = 11.333 / max(n, 1)
line_y = 3.8
_add_shape(slide, MSO_SHAPE.RECTANGLE, 0.8, line_y, 11.733, 0.06,
fill_color=_c("secondary", self.pal))
for i, ms in enumerate(milestones):
cx = 0.8 + spacing * i + spacing / 2 - 0.2
if isinstance(ms, dict):
label = ms.get("label", "")
desc = ms.get("desc", "")
active = ms.get("active", i == 0)
else:
label = str(ms)
desc = ""
active = False
dot_color = _c("accent", self.pal) if active else _c("secondary", self.pal)
_add_shape(slide, MSO_SHAPE.OVAL, cx, line_y - 0.2, 0.4, 0.4,
fill_color=dot_color)
lb = _add_textbox(slide, cx - 0.8, line_y - 1.3, 2, 0.9)
_add_para(lb.text_frame, label, size=13, bold=True,
color=_c("primary", self.pal), align=PP_ALIGN.CENTER, spacing=2)
if desc:
db = _add_textbox(slide, cx - 0.8, line_y + 0.7, 2, 0.9)
_add_para(db.text_frame, desc, size=11,
color=_c("text_secondary", self.pal), align=PP_ALIGN.CENTER)
return slide
def add_quote(self, quote_text, attribution=""):
slide = self._make_slide()
_set_bg(slide, _c("bg_primary", self.pal))
qbox = _add_shape(slide, MSO_SHAPE.ROUNDED_RECTANGLE, 1.2, 1.5, 10.933, 4.5,
fill_color=_c("bg_secondary", self.pal))
qbox.line.color.rgb = _c("secondary", self.pal)
qbox.line.width = Pt(1.5)
ib = _add_textbox(slide, 1.2, 1.3, 1, 1)
_add_para(ib.text_frame, "❝", size=44, color=_c("secondary", self.pal),
align=PP_ALIGN.CENTER)
tb = _add_textbox(slide, 2.2, 2, 8.933, 3)
_add_para(tb.text_frame, quote_text, size=22, color=_c("text_primary", self.pal),
align=PP_ALIGN.LEFT, spacing=8)
if attribution:
ab = _add_textbox(slide, 2.2, 4.8, 8.933, 0.7)
_add_para(ab.text_frame, f"— {attribution}", size=15,
color=_c("text_secondary", self.pal), align=PP_ALIGN.RIGHT)
return slide
def add_contact(self, title, contact_info):
slide = self._make_slide()
_set_bg(slide, _c("primary", self.pal))
_add_shape(slide, MSO_SHAPE.RECTANGLE, 4, 3.4, 5.333, 0.06,
fill_color=_c("accent", self.pal))
tb = _add_textbox(slide, 1.5, 1.5, 10.333, 1.5)
_add_para(tb.text_frame, title, size=34, bold=True,
color=RGBColor(255, 255, 255), align=PP_ALIGN.CENTER, spacing=14)
if isinstance(contact_info, list):
contact_info = "\n".join(contact_info)
cb = _add_textbox(slide, 2, 3.8, 9.333, 3)
_add_para(cb.text_frame, contact_info, size=17,
color=_c("bg_secondary", self.pal), align=PP_ALIGN.CENTER, spacing=7)
return slide
def add_agenda(self, title, topics, highlight=0):
slide = self._make_slide()
_set_bg(slide, _c("bg_primary", self.pal))
self._title_bar(slide, title)
tb = _add_textbox(slide, 2.5, 1.8, 8.333, 5)
for i, t in enumerate(topics):
num = f"{i + 1:02d}"
is_hl = i == highlight
_add_para(tb.text_frame, f"{num} {t}", size=19,
bold=is_hl, color=_c("accent", self.pal) if is_hl else _c("text_primary", self.pal),
align=PP_ALIGN.LEFT, spacing=13)
return slide
def add_chart(self, title, chart_type, categories, series_data):
slide = self._make_slide()
_set_bg(slide, _c("bg_primary", self.pal))
self._title_bar(slide, title)
cd = CategoryChartData()
cd.categories = categories
for s in series_data:
cd.add_series(s.get("name", ""), s.get("values", []))
ct_map = {
"bar": XL_CHART_TYPE.COLUMN_CLUSTERED,
"bar_stacked": XL_CHART_TYPE.COLUMN_STACKED,
"line": XL_CHART_TYPE.LINE_MARKERS,
"pie": XL_CHART_TYPE.PIE,
}
ct = ct_map.get(chart_type, XL_CHART_TYPE.COLUMN_CLUSTERED)
chart_shape = slide.shapes.add_chart(
ct, Inches(0.8), Inches(1.5), Inches(11.733), Inches(5.3), cd
)
chart = chart_shape.chart
chart.has_legend = True
chart.legend.position = XL_LEGEND_POSITION.BOTTOM
if hasattr(chart, 'font'):
chart.font.size = Pt(10)
if chart_type == "pie":
plot = chart.plots[0]
plot.has_data_labels = True
data_labels = plot.data_labels
data_labels.show_percentage = True
data_labels.show_value = False
data_labels.font.size = Pt(10)
return slide
def add_quote(self, quote_text, attribution=""):
slide = self._make_slide()
_set_bg(slide, _c("bg_primary", self.pal))
qbox = _add_shape(slide, MSO_SHAPE.ROUNDED_RECTANGLE, 1.2, 1.5, 10.933, 4.5,
fill_color=_c("bg_secondary", self.pal))
qbox.line.color.rgb = _c("secondary", self.pal)
qbox.line.width = Pt(1.5)
ib = _add_textbox(slide, 1.2, 1.3, 1, 1)
_add_para(ib.text_frame, "❝", size=44, color=_c("secondary", self.pal),
align=PP_ALIGN.CENTER)
tb = _add_textbox(slide, 2.2, 2, 8.933, 3)
_add_para(tb.text_frame, quote_text, size=22, color=_c("text_primary", self.pal),
align=PP_ALIGN.LEFT, spacing=8)
if attribution:
ab = _add_textbox(slide, 2.2, 4.8, 8.933, 0.7)
_add_para(ab.text_frame, f"— {attribution}", size=15,
color=_c("text_secondary", self.pal), align=PP_ALIGN.RIGHT)
return slide
def add_contact(self, title, contact_info):
slide = self._make_slide()
_set_bg(slide, _c("primary", self.pal))
_add_shape(slide, MSO_SHAPE.RECTANGLE, 4, 3.4, 5.333, 0.06,
fill_color=_c("accent", self.pal))
tb = _add_textbox(slide, 1.5, 1.5, 10.333, 1.5)
_add_para(tb.text_frame, title, size=34, bold=True,
color=RGBColor(255, 255, 255), align=PP_ALIGN.CENTER, spacing=14)
if isinstance(contact_info, list):
contact_info = "\n".join(contact_info)
cb = _add_textbox(slide, 2, 3.8, 9.333, 3)
_add_para(cb.text_frame, contact_info, size=17,
color=_c("bg_secondary", self.pal), align=PP_ALIGN.CENTER, spacing=7)
return slide
def add_agenda(self, title, topics, highlight=0):
slide = self._make_slide()
_set_bg(slide, _c("bg_primary", self.pal))
_add_shape(slide, MSO_SHAPE.RECTANGLE, 0, 0, self.SLIDE_W, 1,
fill_color=_c("primary", self.pal))
tb = _add_textbox(slide, 0.8, 0.12, 11.733, 0.8)
_add_para(tb.text_frame, title, size=28, bold=True,
color=RGBColor(255, 255, 255), align=PP_ALIGN.LEFT)
tb = _add_textbox(slide, 1.5, 1.8, 10.333, 5)
for i, t in enumerate(topics):
num = f"{i + 1:02d}"
prefix = f"{num} "
if i == highlight:
c = _c("accent", self.pal)
b = True
else:
c = _c("text_primary", self.pal)
b = False
_add_para(tb.text_frame, f"{prefix}{t}", size=20, bold=b,
color=c, align=PP_ALIGN.LEFT, spacing=14)
return slide
def add_chart(self, title, chart_type, categories, series_data):
slide = self._make_slide()
_set_bg(slide, _c("bg_primary", self.pal))
_add_shape(slide, MSO_SHAPE.RECTANGLE, 0, 0, self.SLIDE_W, 1,
fill_color=_c("primary", self.pal))
tb = _add_textbox(slide, 0.8, 0.12, 11.733, 0.8)
_add_para(tb.text_frame, title, size=28, bold=True,
color=RGBColor(255, 255, 255), align=PP_ALIGN.LEFT)
cd = CategoryChartData()
cd.categories = categories
for s in series_data:
cd.add_series(s.get("name", ""), s.get("values", []))
ct_map = {
"bar": XL_CHART_TYPE.COLUMN_CLUSTERED,
"bar_stacked": XL_CHART_TYPE.COLUMN_STACKED,
"line": XL_CHART_TYPE.LINE_MARKERS,
"pie": XL_CHART_TYPE.PIE,
}
ct = ct_map.get(chart_type, XL_CHART_TYPE.COLUMN_CLUSTERED)
chart_shape = slide.shapes.add_chart(
ct, Inches(0.8), Inches(1.5), Inches(11.733), Inches(5.2), cd
)
chart = chart_shape.chart
chart.has_legend = True
chart.legend.position = XL_LEGEND_POSITION.BOTTOM
if hasattr(chart, 'font'):
chart.font.size = Pt(10)
if chart_type == "pie":
plot = chart.plots[0]
plot.has_data_labels = True
data_labels = plot.data_labels
data_labels.show_percentage = True
data_labels.show_value = False
data_labels.font.size = Pt(10)
return slide
# ── JSON input → PPT pipeline ────────────────────────────
def build_from_json(data):
style = data.get("style", "business_blue")
gen = PPTGenerator(
style=style,
footer=data.get("footer", ""),
page_numbers=data.get("page_numbers", True),
)
total = len(data.get("slides", []))
for idx, s in enumerate(data.get("slides", [])):
typ = s.get("type", "content")
title = s.get("title", "")
try:
if typ == "cover":
gen.add_cover(
title=title,
subtitle=s.get("subtitle", ""),
author=s.get("author", ""),
date=s.get("date", ""),
variant=s.get("variant", "centered"),
)
elif typ == "section":
gen.add_section(title=title, subtitle=s.get("subtitle"))
elif typ == "content":
gen.add_content(
title=title,
items=s.get("items", []),
columns=s.get("columns", 1),
)
elif typ == "two_column":
gen.add_two_column(
title=title,
left_items=s.get("left", []),
right_items=s.get("right", []),
)
elif typ == "table":
gen.add_table(
title=title,
headers=s.get("headers", []),
rows=s.get("rows", []),
)
elif typ == "image":
gen.add_image_slide(
title=title,
image_path=s.get("image_path", ""),
caption=s.get("caption", ""),
overlay=s.get("overlay", False),
)
elif typ == "summary":
gen.add_summary(
title=title,
points=s.get("points", []),
conclusion=s.get("conclusion", ""),
)
elif typ == "timeline":
gen.add_timeline(
title=title,
milestones=s.get("milestones", []),
)
elif typ == "quote":
gen.add_quote(
quote_text=s.get("quote", ""),
attribution=s.get("attribution", ""),
)
elif typ == "contact":
gen.add_contact(
title=title,
contact_info=s.get("info", ""),
)
elif typ == "agenda":
gen.add_agenda(
title=title,
topics=s.get("topics", []),
highlight=s.get("highlight", 0),
)
elif typ == "chart":
gen.add_chart(
title=title,
chart_type=s.get("chart_type", "bar"),
categories=s.get("categories", []),
series_data=s.get("series", []),
)
except Exception as e:
print(f"[{idx + 1}/{total}] 跳过 \"{title}\" ({typ}): {e}", file=sys.stderr)
return gen
def main():
args = sys.argv[1:]
if not args or "--help" in args or "-h" in args:
print("""PPT 生成器 v2.0.0
用法:
python3 generate_pptx.py <json_file> [--out output.pptx] [--style business_blue]
JSON 结构:
{
"style": "business_blue",
"footer": "机密",
"page_numbers": true,
"slides": [
{ "type": "cover", "title": "...", "subtitle": "...", "variant": "centered" },
{ "type": "section", "title": "...", "subtitle": "..." },
{ "type": "agenda", "title": "目录", "topics": ["主题1", "主题2"], "highlight": 0 },
{ "type": "content", "title": "...", "items": ["项1", "项2"], "columns": 1 },
{ "type": "two_column", "title": "...", "left": ["a"], "right": ["b"] },
{ "type": "table", "title": "...", "headers": ["A","B"], "rows": [[1,2]] },
{ "type": "chart", "title": "...", "chart_type": "bar|line|pie",
"categories": ["Q1","Q2"], "series": [{"name":"S1","values":[10,20]}] },
{ "type": "timeline", "title": "...",
"milestones": [{"label":"阶段1","desc":"描述"}] },
{ "type": "quote", "title": "...", "quote": "内容", "attribution": "作者" },
{ "type": "image", "title": "...", "image_path": "img.png", "caption": "...", "overlay": false },
{ "type": "summary", "title": "...", "points": ["要点"], "conclusion": "结论" },
{ "type": "contact", "title": "联系我们", "info": "email@example.com" }
]
}
风格: business_blue, academic_white, creative_purple, tech_dark, minimal_gray""")
return
json_path = args[0]
flags = {}
for i, a in enumerate(args):
if a == "--out" and i + 1 < len(args):
flags["out"] = args[i + 1]
if a == "--style" and i + 1 < len(args):
flags["style"] = args[i + 1]
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
if "style" not in data and flags.get("style"):
data["style"] = flags["style"]
gen = build_from_json(data)
out = flags.get("out") or data.get("output", "output.pptx")
gen.prs.save(out)
print(f"✅ 已生成 {out} ({gen._page} 页)")
if __name__ == "__main__":
try:
main()
except FileNotFoundError as e:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)
except json.JSONDecodeError as e:
print(f"JSON 解析错误: {e}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"错误: {e}", file=sys.stderr)
sys.exit(1)