591 lines
16 KiB
Python
591 lines
16 KiB
Python
#!/usr/bin/env python3
|
||
"""Build a 16:9 product presentation from a reviewed deck JSON file."""
|
||
|
||
import argparse
|
||
import json
|
||
import re
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
from PIL import Image, ImageOps
|
||
from pptx import Presentation
|
||
from pptx.dml.color import RGBColor
|
||
from pptx.enum.shapes import MSO_SHAPE
|
||
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
|
||
from pptx.util import Inches, Pt
|
||
|
||
|
||
DEFAULT_THEME = {
|
||
"primary": "1F3864",
|
||
"accent": "C00000",
|
||
"background": "FFFFFF",
|
||
"text": "1A1A1A",
|
||
"muted": "666666",
|
||
"light": "F2F5F9",
|
||
"font_zh": "Microsoft YaHei",
|
||
"font_en": "Arial",
|
||
}
|
||
SLIDE_TYPES = {
|
||
"title",
|
||
"section",
|
||
"bullets",
|
||
"two-column",
|
||
"metrics",
|
||
"image",
|
||
"closing",
|
||
}
|
||
NOTES_OPTIONAL_TYPES = {"section"}
|
||
MAX_IMAGE_BYTES = 40 * 1024 * 1024
|
||
MAX_IMAGE_PIXELS = 80_000_000
|
||
RENDER_DPI = 150
|
||
|
||
|
||
def color(value):
|
||
value = value.lstrip("#")
|
||
if not re.fullmatch(r"[0-9a-fA-F]{6}", value):
|
||
raise ValueError(f"颜色必须是六位十六进制值:{value!r}")
|
||
return RGBColor.from_string(value.upper())
|
||
|
||
|
||
def nonempty(value, path):
|
||
if not isinstance(value, str) or not value.strip():
|
||
raise ValueError(f"{path} 必须是非空字符串")
|
||
return value.strip()
|
||
|
||
|
||
def load_deck(path):
|
||
input_path = Path(path).expanduser().resolve()
|
||
with input_path.open(encoding="utf-8") as handle:
|
||
deck = json.load(handle)
|
||
if not isinstance(deck, dict):
|
||
raise ValueError("deck 根节点必须是对象")
|
||
nonempty(deck.get("title"), "title")
|
||
slides = deck.get("slides")
|
||
if not isinstance(slides, list) or not slides:
|
||
raise ValueError("slides 必须是非空数组")
|
||
theme = {**DEFAULT_THEME, **deck.get("theme", {})}
|
||
for key in ("primary", "accent", "background", "text", "muted", "light"):
|
||
color(theme[key])
|
||
for index, slide in enumerate(slides):
|
||
validate_slide(slide, index)
|
||
deck["theme"] = theme
|
||
return input_path, deck
|
||
|
||
|
||
def validate_bullets(values, path, maximum=6):
|
||
if not isinstance(values, list) or not values:
|
||
raise ValueError(f"{path} 必须是非空数组")
|
||
if len(values) > maximum:
|
||
raise ValueError(f"{path} 最多 {maximum} 项")
|
||
for index, value in enumerate(values):
|
||
text = nonempty(value, f"{path}[{index}]")
|
||
if len(text) > 120:
|
||
raise ValueError(f"{path}[{index}] 超过 120 字")
|
||
|
||
|
||
def validate_slide(slide, index):
|
||
path = f"slides[{index}]"
|
||
if not isinstance(slide, dict):
|
||
raise ValueError(f"{path} 必须是对象")
|
||
slide_type = slide.get("type")
|
||
if slide_type not in SLIDE_TYPES:
|
||
raise ValueError(f"{path}.type 未知:{slide_type!r}")
|
||
nonempty(slide.get("title"), f"{path}.title")
|
||
if slide_type in {"bullets", "closing"}:
|
||
validate_bullets(slide.get("bullets"), f"{path}.bullets")
|
||
elif slide_type == "two-column":
|
||
for side in ("left", "right"):
|
||
column = slide.get(side)
|
||
if not isinstance(column, dict):
|
||
raise ValueError(f"{path}.{side} 必须是对象")
|
||
nonempty(column.get("title"), f"{path}.{side}.title")
|
||
validate_bullets(
|
||
column.get("bullets"),
|
||
f"{path}.{side}.bullets",
|
||
maximum=5,
|
||
)
|
||
elif slide_type == "metrics":
|
||
metrics = slide.get("metrics")
|
||
if not isinstance(metrics, list) or not 1 <= len(metrics) <= 4:
|
||
raise ValueError(f"{path}.metrics 必须包含 1 至 4 项")
|
||
for metric_index, metric in enumerate(metrics):
|
||
if not isinstance(metric, dict):
|
||
raise ValueError(
|
||
f"{path}.metrics[{metric_index}] 必须是对象"
|
||
)
|
||
for field in ("value", "label", "detail"):
|
||
nonempty(
|
||
metric.get(field),
|
||
f"{path}.metrics[{metric_index}].{field}",
|
||
)
|
||
elif slide_type == "image":
|
||
nonempty(slide.get("image"), f"{path}.image")
|
||
if slide_type not in NOTES_OPTIONAL_TYPES:
|
||
nonempty(slide.get("notes"), f"{path}.notes")
|
||
|
||
|
||
def add_run_font(run, theme, size, bold=False, color_value=None):
|
||
run.font.name = theme["font_en"]
|
||
run.font.size = Pt(size)
|
||
run.font.bold = bold
|
||
run.font.color.rgb = color(color_value or theme["text"])
|
||
run.font._element.set("lang", "zh-CN")
|
||
|
||
|
||
def add_textbox(
|
||
slide,
|
||
theme,
|
||
x,
|
||
y,
|
||
width,
|
||
height,
|
||
text="",
|
||
size=24,
|
||
bold=False,
|
||
color_value=None,
|
||
align=PP_ALIGN.LEFT,
|
||
vertical=MSO_ANCHOR.TOP,
|
||
margin=0.08,
|
||
):
|
||
shape = slide.shapes.add_textbox(
|
||
Inches(x),
|
||
Inches(y),
|
||
Inches(width),
|
||
Inches(height),
|
||
)
|
||
frame = shape.text_frame
|
||
frame.clear()
|
||
frame.margin_left = Inches(margin)
|
||
frame.margin_right = Inches(margin)
|
||
frame.margin_top = Inches(margin)
|
||
frame.margin_bottom = Inches(margin)
|
||
frame.vertical_anchor = vertical
|
||
paragraph = frame.paragraphs[0]
|
||
paragraph.alignment = align
|
||
run = paragraph.add_run()
|
||
run.text = text
|
||
add_run_font(run, theme, size, bold=bold, color_value=color_value)
|
||
return shape
|
||
|
||
|
||
def set_background(slide, theme, key="background"):
|
||
fill = slide.background.fill
|
||
fill.solid()
|
||
fill.fore_color.rgb = color(theme[key])
|
||
|
||
|
||
def add_header(slide, theme, title):
|
||
add_textbox(
|
||
slide,
|
||
theme,
|
||
0.65,
|
||
0.35,
|
||
11.9,
|
||
0.65,
|
||
title,
|
||
size=26,
|
||
bold=True,
|
||
color_value=theme["primary"],
|
||
)
|
||
line = slide.shapes.add_shape(
|
||
MSO_SHAPE.RECTANGLE,
|
||
Inches(0.65),
|
||
Inches(1.07),
|
||
Inches(1.15),
|
||
Inches(0.06),
|
||
)
|
||
line.fill.solid()
|
||
line.fill.fore_color.rgb = color(theme["accent"])
|
||
line.line.fill.background()
|
||
|
||
|
||
def add_footer(slide, deck, number):
|
||
footer = deck.get("footer", "")
|
||
theme = deck["theme"]
|
||
if footer:
|
||
add_textbox(
|
||
slide,
|
||
theme,
|
||
0.65,
|
||
7.08,
|
||
10.8,
|
||
0.22,
|
||
footer,
|
||
size=8.5,
|
||
color_value=theme["muted"],
|
||
)
|
||
add_textbox(
|
||
slide,
|
||
theme,
|
||
12.0,
|
||
7.02,
|
||
0.55,
|
||
0.25,
|
||
str(number),
|
||
size=9,
|
||
color_value=theme["muted"],
|
||
align=PP_ALIGN.RIGHT,
|
||
)
|
||
|
||
|
||
def add_bullet_frame(slide, theme, x, y, width, height, bullets, size=21):
|
||
shape = slide.shapes.add_textbox(
|
||
Inches(x),
|
||
Inches(y),
|
||
Inches(width),
|
||
Inches(height),
|
||
)
|
||
frame = shape.text_frame
|
||
frame.clear()
|
||
frame.word_wrap = True
|
||
frame.margin_left = Inches(0.12)
|
||
frame.margin_right = Inches(0.08)
|
||
for index, item in enumerate(bullets):
|
||
paragraph = frame.paragraphs[0] if index == 0 else frame.add_paragraph()
|
||
paragraph.text = item
|
||
paragraph.level = 0
|
||
paragraph.space_after = Pt(12)
|
||
paragraph.line_spacing = 1.12
|
||
paragraph.font.size = Pt(size)
|
||
paragraph.font.name = theme["font_en"]
|
||
paragraph.font.color.rgb = color(theme["text"])
|
||
return shape
|
||
|
||
|
||
def add_notes(slide, notes):
|
||
if not notes:
|
||
return
|
||
notes_frame = slide.notes_slide.notes_text_frame
|
||
notes_frame.text = notes
|
||
|
||
|
||
def render_title(slide, deck, item):
|
||
theme = deck["theme"]
|
||
set_background(slide, theme, "primary")
|
||
add_textbox(
|
||
slide,
|
||
theme,
|
||
0.9,
|
||
1.75,
|
||
11.5,
|
||
1.3,
|
||
item["title"],
|
||
size=34,
|
||
bold=True,
|
||
color_value="FFFFFF",
|
||
vertical=MSO_ANCHOR.MIDDLE,
|
||
)
|
||
subtitle = item.get("subtitle", deck.get("subtitle", ""))
|
||
if subtitle:
|
||
add_textbox(
|
||
slide,
|
||
theme,
|
||
0.95,
|
||
3.2,
|
||
10.8,
|
||
0.8,
|
||
subtitle,
|
||
size=20,
|
||
color_value="DCE6F1",
|
||
)
|
||
author = deck.get("author", "")
|
||
if author:
|
||
add_textbox(
|
||
slide,
|
||
theme,
|
||
0.95,
|
||
6.35,
|
||
10.0,
|
||
0.35,
|
||
author,
|
||
size=12,
|
||
color_value="DCE6F1",
|
||
)
|
||
|
||
|
||
def render_section(slide, deck, item):
|
||
theme = deck["theme"]
|
||
set_background(slide, theme, "light")
|
||
add_textbox(
|
||
slide,
|
||
theme,
|
||
1.0,
|
||
2.2,
|
||
11.2,
|
||
1.0,
|
||
item["title"],
|
||
size=32,
|
||
bold=True,
|
||
color_value=theme["primary"],
|
||
align=PP_ALIGN.CENTER,
|
||
vertical=MSO_ANCHOR.MIDDLE,
|
||
)
|
||
if item.get("subtitle"):
|
||
add_textbox(
|
||
slide,
|
||
theme,
|
||
1.5,
|
||
3.35,
|
||
10.2,
|
||
0.7,
|
||
item["subtitle"],
|
||
size=18,
|
||
color_value=theme["muted"],
|
||
align=PP_ALIGN.CENTER,
|
||
)
|
||
|
||
|
||
def render_bullets(slide, deck, item):
|
||
theme = deck["theme"]
|
||
set_background(slide, theme)
|
||
add_header(slide, theme, item["title"])
|
||
add_bullet_frame(slide, theme, 0.9, 1.45, 11.5, 5.2, item["bullets"])
|
||
|
||
|
||
def render_two_column(slide, deck, item):
|
||
theme = deck["theme"]
|
||
set_background(slide, theme)
|
||
add_header(slide, theme, item["title"])
|
||
for x, column in ((0.75, item["left"]), (6.78, item["right"])):
|
||
panel = slide.shapes.add_shape(
|
||
MSO_SHAPE.ROUNDED_RECTANGLE,
|
||
Inches(x),
|
||
Inches(1.55),
|
||
Inches(5.55),
|
||
Inches(4.95),
|
||
)
|
||
panel.fill.solid()
|
||
panel.fill.fore_color.rgb = color(theme["light"])
|
||
panel.line.color.rgb = color("D8E0EA")
|
||
add_textbox(
|
||
slide,
|
||
theme,
|
||
x + 0.3,
|
||
1.8,
|
||
4.95,
|
||
0.5,
|
||
column["title"],
|
||
size=20,
|
||
bold=True,
|
||
color_value=theme["primary"],
|
||
)
|
||
add_bullet_frame(
|
||
slide,
|
||
theme,
|
||
x + 0.25,
|
||
2.5,
|
||
5.0,
|
||
3.55,
|
||
column["bullets"],
|
||
size=17,
|
||
)
|
||
|
||
|
||
def render_metrics(slide, deck, item):
|
||
theme = deck["theme"]
|
||
set_background(slide, theme)
|
||
add_header(slide, theme, item["title"])
|
||
metrics = item["metrics"]
|
||
gap = 0.25
|
||
total_width = 11.8
|
||
width = (total_width - gap * (len(metrics) - 1)) / len(metrics)
|
||
for index, metric in enumerate(metrics):
|
||
x = 0.75 + index * (width + gap)
|
||
panel = slide.shapes.add_shape(
|
||
MSO_SHAPE.ROUNDED_RECTANGLE,
|
||
Inches(x),
|
||
Inches(1.75),
|
||
Inches(width),
|
||
Inches(4.45),
|
||
)
|
||
panel.fill.solid()
|
||
panel.fill.fore_color.rgb = color(theme["light"])
|
||
panel.line.color.rgb = color("D8E0EA")
|
||
add_textbox(
|
||
slide,
|
||
theme,
|
||
x + 0.15,
|
||
2.05,
|
||
width - 0.3,
|
||
1.0,
|
||
metric["value"],
|
||
size=29,
|
||
bold=True,
|
||
color_value=theme["accent"],
|
||
align=PP_ALIGN.CENTER,
|
||
vertical=MSO_ANCHOR.MIDDLE,
|
||
)
|
||
add_textbox(
|
||
slide,
|
||
theme,
|
||
x + 0.15,
|
||
3.15,
|
||
width - 0.3,
|
||
0.6,
|
||
metric["label"],
|
||
size=17,
|
||
bold=True,
|
||
color_value=theme["primary"],
|
||
align=PP_ALIGN.CENTER,
|
||
)
|
||
add_textbox(
|
||
slide,
|
||
theme,
|
||
x + 0.2,
|
||
4.0,
|
||
width - 0.4,
|
||
1.45,
|
||
metric["detail"],
|
||
size=12,
|
||
color_value=theme["muted"],
|
||
align=PP_ALIGN.CENTER,
|
||
)
|
||
|
||
|
||
def prepare_image(image_path, max_width_in, max_height_in, staging_dir):
|
||
"""Normalize orientation and cap pixels so decks stay a usable size."""
|
||
if image_path.stat().st_size > MAX_IMAGE_BYTES:
|
||
raise ValueError(
|
||
f"图片超过 {MAX_IMAGE_BYTES // (1024 * 1024)}MB:{image_path.name}"
|
||
)
|
||
with Image.open(image_path) as image:
|
||
if image.width * image.height > MAX_IMAGE_PIXELS:
|
||
raise ValueError(f"图片像素数过大:{image_path.name}")
|
||
image = ImageOps.exif_transpose(image)
|
||
width, height = image.size
|
||
scale = min(max_width_in / width, max_height_in / height)
|
||
target = (
|
||
max(1, round(width * scale * RENDER_DPI)),
|
||
max(1, round(height * scale * RENDER_DPI)),
|
||
)
|
||
if target[0] >= width and target[1] >= height:
|
||
if image_path.suffix.lower() in {".png", ".jpg", ".jpeg", ".gif"}:
|
||
return image_path, width, height
|
||
target = (width, height)
|
||
resized = image.resize(target, Image.LANCZOS)
|
||
if resized.mode not in ("RGB", "RGBA", "L"):
|
||
resized = resized.convert("RGBA")
|
||
staged = staging_dir / f"{image_path.stem}-{target[0]}x{target[1]}.png"
|
||
resized.save(staged, format="PNG", optimize=True)
|
||
return staged, width, height
|
||
|
||
|
||
def render_image(slide, deck, item, base_dir, staging_dir):
|
||
theme = deck["theme"]
|
||
set_background(slide, theme)
|
||
add_header(slide, theme, item["title"])
|
||
image_path = (base_dir / item["image"]).resolve()
|
||
if base_dir != image_path and base_dir not in image_path.parents:
|
||
raise ValueError(f"图片必须位于 deck.json 目录内:{image_path}")
|
||
if not image_path.is_file():
|
||
raise FileNotFoundError(f"图片不存在:{image_path}")
|
||
max_width, max_height = 11.5, 5.35
|
||
source, width, height = prepare_image(
|
||
image_path, max_width, max_height, staging_dir
|
||
)
|
||
scale = min(max_width / width, max_height / height)
|
||
draw_width, draw_height = width * scale, height * scale
|
||
slide.shapes.add_picture(
|
||
str(source),
|
||
Inches((13.333 - draw_width) / 2),
|
||
Inches(1.35 + (5.35 - draw_height) / 2),
|
||
width=Inches(draw_width),
|
||
height=Inches(draw_height),
|
||
)
|
||
if item.get("caption"):
|
||
add_textbox(
|
||
slide,
|
||
theme,
|
||
1.0,
|
||
6.55,
|
||
11.3,
|
||
0.3,
|
||
item["caption"],
|
||
size=10,
|
||
color_value=theme["muted"],
|
||
align=PP_ALIGN.CENTER,
|
||
)
|
||
|
||
|
||
def render_closing(slide, deck, item):
|
||
theme = deck["theme"]
|
||
set_background(slide, theme, "primary")
|
||
add_textbox(
|
||
slide,
|
||
theme,
|
||
0.9,
|
||
1.2,
|
||
11.5,
|
||
0.9,
|
||
item["title"],
|
||
size=32,
|
||
bold=True,
|
||
color_value="FFFFFF",
|
||
align=PP_ALIGN.CENTER,
|
||
)
|
||
add_bullet_frame(
|
||
slide,
|
||
{**theme, "text": "FFFFFF"},
|
||
2.0,
|
||
2.55,
|
||
9.3,
|
||
3.2,
|
||
item["bullets"],
|
||
size=20,
|
||
)
|
||
|
||
|
||
RENDERERS = {
|
||
"title": render_title,
|
||
"section": render_section,
|
||
"bullets": render_bullets,
|
||
"two-column": render_two_column,
|
||
"metrics": render_metrics,
|
||
"closing": render_closing,
|
||
}
|
||
|
||
|
||
def build_presentation(input_path, deck, output_path):
|
||
output = Path(output_path).expanduser().resolve()
|
||
if output.suffix.lower() != ".pptx":
|
||
raise ValueError("输出必须使用 .pptx 扩展名")
|
||
if output == input_path:
|
||
raise ValueError("输出不能覆盖 deck.json")
|
||
output.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
presentation = Presentation()
|
||
presentation.slide_width = Inches(13.333)
|
||
presentation.slide_height = Inches(7.5)
|
||
blank = presentation.slide_layouts[6]
|
||
with tempfile.TemporaryDirectory(prefix="deck-images-") as staging:
|
||
staging_dir = Path(staging)
|
||
for index, item in enumerate(deck["slides"], 1):
|
||
slide = presentation.slides.add_slide(blank)
|
||
if item["type"] == "image":
|
||
render_image(slide, deck, item, input_path.parent, staging_dir)
|
||
else:
|
||
RENDERERS[item["type"]](slide, deck, item)
|
||
if item["type"] != "title":
|
||
add_footer(slide, deck, index)
|
||
add_notes(slide, item.get("notes", ""))
|
||
presentation.save(output)
|
||
return output
|
||
|
||
|
||
def parse_args():
|
||
parser = argparse.ArgumentParser(description="从 deck.json 生成产品介绍 PPTX")
|
||
parser.add_argument("--input", required=True, help="deck.json")
|
||
parser.add_argument("--output", required=True, help="输出 .pptx")
|
||
return parser.parse_args()
|
||
|
||
|
||
def main():
|
||
args = parse_args()
|
||
input_path, deck = load_deck(args.input)
|
||
output = build_presentation(input_path, deck, args.output)
|
||
print(f"saved: {output}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|