发布:学生会策划部工作包(Skills + MCP)
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user