v1.0
This commit is contained in:
@@ -0,0 +1,756 @@
|
||||
#!/usr/bin/env python3
|
||||
"""pptx_gen.py — Generate PowerPoint files using python-pptx.
|
||||
|
||||
Usage: python pptx_gen.py <spec_json_path> [workspace_path]
|
||||
|
||||
Spec JSON format (same schema as the Node.js create_presentation tool):
|
||||
{
|
||||
"title": "Presentation Title",
|
||||
"filename": "output.pptx", // optional, default: presentation.pptx
|
||||
"theme": "light", // optional: "light" or "dark"
|
||||
"template": "business", // optional: template name
|
||||
"default_skin": "ocean", // optional: default skin for all slides
|
||||
"font_sizes": { // optional: override default font sizes (pt)
|
||||
"title": 36, "subtitle": 18, "slide_title": 24,
|
||||
"body": 16, "bullets": 16, "section": 32, "image_title": 22
|
||||
},
|
||||
"slides": [
|
||||
{ "type": "title", "title": "...", "subtitle": "..." },
|
||||
{ "type": "content", "title": "...", "bullets": ["a", "b"] },
|
||||
{ "type": "content", "title": "...", "body": "body text" },
|
||||
{ "type": "content", "title": "...", "content": "alias for body" },
|
||||
{ "type": "content", "title": "...", "bullet_points": ["alias for bullets"] },
|
||||
{ "type": "content", "title": "...", "font_size": 20 }, // per-slide override
|
||||
{ "type": "section", "title": "Section Name" },
|
||||
{ "type": "image", "title": "...", "image_path": "photo.jpg" },
|
||||
{ ..., "background": "ocean" }, // skin name or image file path
|
||||
{ ..., "background": "my_bg.jpg" }, // image file in project folder
|
||||
{ ..., "notes": "speaker notes" }
|
||||
]
|
||||
}
|
||||
|
||||
Output: JSON printed to stdout
|
||||
{"success": true, "path": "...", "folder": "...", "slides": 10, "warnings": []}
|
||||
{"success": false, "error": "error message"}
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
# Force UTF-8 output on Windows
|
||||
if sys.platform == 'win32':
|
||||
import io
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8')
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ─── Paths ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
PROJECT_ROOT = os.path.join(SCRIPT_DIR, '..')
|
||||
SKIN_DIR = os.path.join(PROJECT_ROOT, 'ppt', 'skin')
|
||||
TEMPLATE_DIR = os.path.join(PROJECT_ROOT, 'ppt', 'template')
|
||||
|
||||
SKIN_EXTENSIONS = {'.png', '.jpg', '.jpeg'}
|
||||
|
||||
# ─── Constants ──────────────────────────────────────────────────────────────────
|
||||
|
||||
FONT = "Calibri"
|
||||
FONT_CJK = "Malgun Gothic" # Windows Korean font
|
||||
|
||||
COLORS_LIGHT = {
|
||||
"title": "1A1A2E",
|
||||
"subtitle": "5F6F86",
|
||||
"body": "2D3748",
|
||||
"accent": "1668E3",
|
||||
"background": "FFFFFF",
|
||||
}
|
||||
|
||||
COLORS_DARK = {
|
||||
"title": "FFFFFF",
|
||||
"subtitle": "C0C0C0",
|
||||
"body": "E0E0E0",
|
||||
"accent": "4C8DFF",
|
||||
"background": "1F242D",
|
||||
}
|
||||
|
||||
# Default font sizes (overridable via spec.font_sizes)
|
||||
DEFAULT_FONT_SIZES = {
|
||||
"title": 36,
|
||||
"subtitle": 18,
|
||||
"slide_title": 24,
|
||||
"body": 16,
|
||||
"bullets": 16,
|
||||
"section": 32,
|
||||
"image_title": 22,
|
||||
}
|
||||
|
||||
# Slide dimensions (LAYOUT_WIDE = 13.333 x 7.5 inches)
|
||||
SLIDE_W = Inches(13.333)
|
||||
SLIDE_H = Inches(7.5)
|
||||
|
||||
# Dark skin names — slides with these backgrounds use light text
|
||||
DARK_SKINS = {
|
||||
'Cave', 'Deep Sea', 'Dream', 'Galaxy', 'Imagination', 'Metal', 'Space', 'Universe',
|
||||
'charcoal', 'midnight', 'ocean', 'sunset', 'forest_green', 'mint',
|
||||
'navy', 'slate', 'burgundy', 'moss', 'plum', 'deep_red',
|
||||
'Skin_film', 'Skin_theater',
|
||||
'Skin_slate', 'Skin_navy', 'Skin_burgundy', 'Skin_moss', 'Skin_plum', 'Skin_deep_red',
|
||||
}
|
||||
|
||||
|
||||
# ─── Skin / Template Resolution ─────────────────────────────────────────────────
|
||||
|
||||
def _build_skin_index():
|
||||
"""Build an index of skin name → file path."""
|
||||
index = {}
|
||||
if os.path.isdir(SKIN_DIR):
|
||||
for f in os.listdir(SKIN_DIR):
|
||||
ext = os.path.splitext(f)[1].lower()
|
||||
if ext not in SKIN_EXTENSIONS:
|
||||
continue
|
||||
name = os.path.splitext(f)[0]
|
||||
index[name] = os.path.join(SKIN_DIR, f)
|
||||
return index
|
||||
|
||||
SKIN_INDEX = _build_skin_index()
|
||||
SKIN_NAMES = sorted(SKIN_INDEX.keys(), key=lambda s: s.lower())
|
||||
|
||||
|
||||
def _load_template_configs():
|
||||
"""Load template JSON configs from ppt/template/."""
|
||||
configs = {}
|
||||
if os.path.isdir(TEMPLATE_DIR):
|
||||
for f in os.listdir(TEMPLATE_DIR):
|
||||
if not f.endswith('.json'):
|
||||
continue
|
||||
try:
|
||||
with open(os.path.join(TEMPLATE_DIR, f), 'r', encoding='utf-8') as fh:
|
||||
cfg = json.load(fh)
|
||||
configs[cfg.get('name', '').lower()] = cfg
|
||||
except Exception:
|
||||
pass
|
||||
return configs
|
||||
|
||||
TEMPLATE_CONFIGS = _load_template_configs()
|
||||
TEMPLATE_NAMES = list(TEMPLATE_CONFIGS.keys())
|
||||
|
||||
|
||||
def resolve_skin_bg(name, project_dir=None, workspace_path=None):
|
||||
"""Resolve a skin name or image file path to an absolute file path.
|
||||
|
||||
Checks: skin directory → project_dir → workspace_path.
|
||||
Returns (file_path, is_dark) or (None, False).
|
||||
"""
|
||||
# 1. Try skin name in ppt/skin/
|
||||
if name in SKIN_INDEX:
|
||||
path = SKIN_INDEX[name]
|
||||
if os.path.isfile(path):
|
||||
return path, name in DARK_SKINS
|
||||
|
||||
# Case-insensitive skin lookup
|
||||
name_lower = name.lower()
|
||||
for skin_name, skin_path in SKIN_INDEX.items():
|
||||
if skin_name.lower() == name_lower and os.path.isfile(skin_path):
|
||||
return skin_path, skin_name in DARK_SKINS
|
||||
|
||||
# 2. Try as file path relative to project_dir, then workspace
|
||||
if project_dir:
|
||||
candidate = os.path.join(project_dir, name)
|
||||
if os.path.isfile(candidate):
|
||||
return candidate, False
|
||||
|
||||
if workspace_path:
|
||||
candidate = os.path.join(workspace_path, name)
|
||||
if os.path.isfile(candidate):
|
||||
return candidate, False
|
||||
|
||||
# 3. Try as absolute path
|
||||
if os.path.isabs(name) and os.path.isfile(name):
|
||||
return name, False
|
||||
|
||||
return None, False
|
||||
|
||||
|
||||
def resolve_template(name):
|
||||
"""Resolve template by name. Returns config dict or None."""
|
||||
if not name:
|
||||
return None
|
||||
return TEMPLATE_CONFIGS.get(name.lower())
|
||||
|
||||
|
||||
def get_template_colors(tpl):
|
||||
"""Extract colors dict from template config, falling back to LIGHT defaults."""
|
||||
if tpl and 'colors' in tpl:
|
||||
return tpl['colors']
|
||||
return COLORS_LIGHT
|
||||
|
||||
|
||||
def get_template_font_sizes(tpl):
|
||||
"""Extract font size overrides from template config."""
|
||||
sizes = dict(DEFAULT_FONT_SIZES)
|
||||
if tpl:
|
||||
for key in ('titleSlide', 'contentSlide', 'sectionSlide'):
|
||||
section = tpl.get(key, {})
|
||||
mapping = {
|
||||
'titleSize': ('title', 'slide_title'),
|
||||
'subtitleSize': ('subtitle',),
|
||||
'titleSize_slide': ('slide_title',),
|
||||
'bodySize': ('body', 'bullets'),
|
||||
'sectionTitleSize': ('section',),
|
||||
}
|
||||
for tpl_key, size_keys in mapping.items():
|
||||
if tpl_key in section:
|
||||
for sk in size_keys:
|
||||
sizes[sk] = section[tpl_key]
|
||||
return sizes
|
||||
|
||||
|
||||
def is_dark_skin(name):
|
||||
"""Check if a skin name is considered dark (light text on dark background)."""
|
||||
return name in DARK_SKINS
|
||||
|
||||
|
||||
# ─── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
"""Convert a title to a safe folder name."""
|
||||
s = str(text or "presentation").strip()
|
||||
s = re.sub(r"\.pptx$", "", s, flags=re.IGNORECASE)
|
||||
s = re.sub(r'[\(\)<>:"/\\|?*]+', "_", s)
|
||||
s = re.sub(r"\s+", "_", s)
|
||||
s = re.sub(r"_+", "_", s)
|
||||
s = s.strip("_")
|
||||
return s[:60] or "presentation"
|
||||
|
||||
|
||||
def hex_to_rgb(hex_str: str) -> RGBColor:
|
||||
"""Convert hex color string to RGBColor."""
|
||||
h = hex_str.lstrip("#")
|
||||
return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
|
||||
|
||||
|
||||
def get_font() -> str:
|
||||
"""Return appropriate font with CJK fallback."""
|
||||
return FONT_CJK
|
||||
|
||||
|
||||
def add_text_box(slide, left, top, width, height, text, font_size=Pt(16),
|
||||
color_hex="2D3748", bold=False, alignment=PP_ALIGN.LEFT,
|
||||
font_name=None):
|
||||
"""Add a text box with a single paragraph."""
|
||||
txBox = slide.shapes.add_textbox(left, top, width, height)
|
||||
tf = txBox.text_frame
|
||||
tf.word_wrap = True
|
||||
p = tf.paragraphs[0]
|
||||
p.alignment = alignment
|
||||
run = p.add_run()
|
||||
run.text = text
|
||||
run.font.size = font_size
|
||||
run.font.bold = bold
|
||||
run.font.color.rgb = hex_to_rgb(color_hex)
|
||||
run.font.name = font_name or get_font()
|
||||
return txBox
|
||||
|
||||
|
||||
def add_bullet_list(slide, left, top, width, height, items, font_size=Pt(16),
|
||||
color_hex="2D3748", bullet_color_hex="1668E3", font_name=None):
|
||||
"""Add a text box with bullet points."""
|
||||
txBox = slide.shapes.add_textbox(left, top, width, height)
|
||||
tf = txBox.text_frame
|
||||
tf.word_wrap = True
|
||||
|
||||
for i, item in enumerate(items):
|
||||
if i == 0:
|
||||
p = tf.paragraphs[0]
|
||||
else:
|
||||
p = tf.add_paragraph()
|
||||
|
||||
p.space_after = Pt(4)
|
||||
p.level = 0
|
||||
|
||||
# Add bullet character manually for reliable rendering
|
||||
bullet_run = p.add_run()
|
||||
bullet_run.text = "● "
|
||||
bullet_run.font.size = font_size
|
||||
bullet_run.font.color.rgb = hex_to_rgb(bullet_color_hex)
|
||||
bullet_run.font.name = font_name or get_font()
|
||||
|
||||
text_run = p.add_run()
|
||||
text_run.text = str(item)
|
||||
text_run.font.size = font_size
|
||||
text_run.font.color.rgb = hex_to_rgb(color_hex)
|
||||
text_run.font.name = font_name or get_font()
|
||||
|
||||
return txBox
|
||||
|
||||
|
||||
def set_slide_bg(slide, color_hex: str):
|
||||
"""Set solid background color for a slide."""
|
||||
bg = slide.background
|
||||
fill = bg.fill
|
||||
fill.solid()
|
||||
fill.fore_color.rgb = hex_to_rgb(color_hex)
|
||||
|
||||
|
||||
def set_slide_bg_image(slide, image_path: str):
|
||||
"""Set a background image for a slide."""
|
||||
bg = slide.background
|
||||
fill = bg.fill
|
||||
fill.background()
|
||||
# python-pptx doesn't have a direct bg-image API — add a full-slide image instead
|
||||
slide.shapes.add_picture(
|
||||
image_path, Inches(0), Inches(0), SLIDE_W, SLIDE_H
|
||||
)
|
||||
|
||||
|
||||
def add_shape_rect(slide, left, top, width, height, color_hex: str):
|
||||
"""Add a filled rectangle shape."""
|
||||
from pptx.enum.shapes import MSO_SHAPE
|
||||
shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, left, top, width, height)
|
||||
shape.fill.solid()
|
||||
shape.fill.fore_color.rgb = hex_to_rgb(color_hex)
|
||||
shape.line.fill.background()
|
||||
return shape
|
||||
|
||||
|
||||
def fit_dimensions(img_w, img_h, max_w, max_h, mode="contain"):
|
||||
"""Calculate dimensions preserving aspect ratio.
|
||||
|
||||
mode='contain': fit entirely within max_w × max_h (default, no cropping)
|
||||
mode='cover': fill max_w × max_h completely, cropping if necessary
|
||||
"""
|
||||
if img_w <= 0 or img_h <= 0:
|
||||
return max_w, max_h
|
||||
ratio = img_w / img_h
|
||||
box_ratio = max_w / max_h if max_h > 0 else 1
|
||||
|
||||
if mode == "cover":
|
||||
# Scale so image completely covers the box; overflow is cropped
|
||||
if ratio > box_ratio:
|
||||
# Wider than box → fit height, overflow width
|
||||
h = max_h
|
||||
w = max_h * ratio
|
||||
else:
|
||||
# Taller than box → fit width, overflow height
|
||||
w = max_w
|
||||
h = max_w / ratio
|
||||
return w, h
|
||||
|
||||
# contain (default)
|
||||
if ratio > box_ratio:
|
||||
w = max_w
|
||||
h = max_w / ratio
|
||||
else:
|
||||
h = max_h
|
||||
w = max_h * ratio
|
||||
return w, h
|
||||
|
||||
|
||||
def add_image_or_placeholder(slide, image_path, left, top, max_width, max_height,
|
||||
warnings, color_hex="FF0000", fit_mode="contain"):
|
||||
"""Add an image preserving aspect ratio, or a red placeholder if not found.
|
||||
|
||||
fit_mode:
|
||||
'contain' (default) — entire image visible, may leave empty bars
|
||||
'cover' — fill the bounding box completely, cropping if needed
|
||||
"""
|
||||
if os.path.exists(image_path):
|
||||
try:
|
||||
# Read native dimensions and compute aspect-ratio-preserving size
|
||||
from PIL import Image as PILImage
|
||||
with PILImage.open(image_path) as img:
|
||||
img_w, img_h = img.size
|
||||
fit_w, fit_h = fit_dimensions(img_w, img_h, max_width, max_height, mode=fit_mode)
|
||||
# Center within the bounding box both horizontally and vertically
|
||||
center_x = left + (max_width - fit_w) / 2
|
||||
center_y = top + (max_height - fit_h) / 2
|
||||
slide.shapes.add_picture(image_path, center_x, center_y, fit_w, fit_h)
|
||||
return
|
||||
except Exception:
|
||||
# If image dimension reading fails, try with native size (no stretching)
|
||||
try:
|
||||
pic = slide.shapes.add_picture(image_path, left, top)
|
||||
# Scale down if larger than max bounds
|
||||
native_w = pic.width
|
||||
native_h = pic.height
|
||||
if native_w > max_width or native_h > max_height:
|
||||
fit_w, fit_h = fit_dimensions(native_w, native_h, max_width, max_height, mode=fit_mode)
|
||||
pic.width = int(fit_w)
|
||||
pic.height = int(fit_h)
|
||||
center_x = left + (max_width - fit_w) / 2
|
||||
center_y = top + (max_height - fit_h) / 2
|
||||
pic.left = int(center_x)
|
||||
pic.top = int(center_y)
|
||||
else:
|
||||
center_x = left + (max_width - native_w) / 2
|
||||
center_y = top + (max_height - native_h) / 2
|
||||
pic.left = int(center_x)
|
||||
pic.top = int(center_y)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
add_text_box(slide, left, top, max_width, max_height,
|
||||
f"[Image not found: {image_path}]",
|
||||
font_size=Pt(14), color_hex=color_hex,
|
||||
alignment=PP_ALIGN.CENTER)
|
||||
warnings.append(f"Image not found: {image_path}")
|
||||
|
||||
|
||||
def resolve_image_path(image_path: str, project_dir: str, workspace_path: str) -> str:
|
||||
"""Resolve image path: check absolute, then project_dir, then workspace, then search by filename."""
|
||||
if os.path.isabs(image_path):
|
||||
return image_path
|
||||
candidates = [
|
||||
os.path.join(project_dir, image_path),
|
||||
os.path.join(workspace_path, image_path),
|
||||
]
|
||||
for c in candidates:
|
||||
if os.path.exists(c):
|
||||
return c
|
||||
# Fallback: search by filename in all subdirectories
|
||||
basename = os.path.basename(image_path).lower()
|
||||
for root, dirs, files in os.walk(workspace_path):
|
||||
for f in files:
|
||||
if f.lower() == basename:
|
||||
return os.path.join(root, f)
|
||||
return candidates[0] # return first even if missing (placeholder will show)
|
||||
|
||||
|
||||
# ─── Slide Generators ──────────────────────────────────────────────────────────
|
||||
|
||||
def make_title_slide(prs, slide_spec, spec, colors, is_dark, fs, bg_image=None):
|
||||
"""Generate a title slide."""
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6]) # blank layout
|
||||
|
||||
# Background
|
||||
if bg_image:
|
||||
set_slide_bg_image(slide, bg_image)
|
||||
elif is_dark:
|
||||
set_slide_bg(slide, colors["background"])
|
||||
|
||||
title = slide_spec.get("title") or spec.get("title") or "Untitled"
|
||||
y_pos = Inches(2.8) if bg_image else Inches(2.5)
|
||||
add_text_box(slide, Inches(0.8), y_pos, Inches(11.7), Inches(1.2),
|
||||
title, font_size=Pt(fs["title"]), color_hex=colors["title"],
|
||||
bold=True, alignment=PP_ALIGN.CENTER)
|
||||
|
||||
subtitle = slide_spec.get("subtitle")
|
||||
if subtitle:
|
||||
add_text_box(slide, Inches(1.5), y_pos + Inches(1.3), Inches(10.3), Inches(0.7),
|
||||
subtitle, font_size=Pt(fs["subtitle"]), color_hex=colors["subtitle"],
|
||||
alignment=PP_ALIGN.CENTER)
|
||||
return slide
|
||||
|
||||
|
||||
def make_section_slide(prs, slide_spec, colors, fs):
|
||||
"""Generate a section divider slide."""
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
add_shape_rect(slide, Inches(0), Inches(0), SLIDE_W, SLIDE_H, colors.get("accent", "1668E3"))
|
||||
|
||||
title = slide_spec.get("title") or "Section"
|
||||
add_text_box(slide, Inches(1), Inches(2.5), Inches(11.3), Inches(1.5),
|
||||
title, font_size=Pt(fs["section"]), color_hex="FFFFFF",
|
||||
bold=True, alignment=PP_ALIGN.CENTER)
|
||||
return slide
|
||||
|
||||
|
||||
def make_content_slide(prs, slide_spec, project_dir, workspace_path, colors, is_dark, fs, warnings, bg_image=None):
|
||||
"""Generate a content slide with title + bullets/body."""
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
|
||||
# Background
|
||||
if bg_image:
|
||||
set_slide_bg_image(slide, bg_image)
|
||||
elif is_dark:
|
||||
set_slide_bg(slide, colors["background"])
|
||||
|
||||
has_title = bool(slide_spec.get("title"))
|
||||
content_y = Inches(1.5) if has_title else Inches(0.5)
|
||||
|
||||
if has_title:
|
||||
add_text_box(slide, Inches(0.6), Inches(0.4), Inches(12.1), Inches(0.8),
|
||||
slide_spec["title"], font_size=Pt(fs["slide_title"]),
|
||||
color_hex=colors["title"], bold=True)
|
||||
# Accent underline
|
||||
add_shape_rect(slide, Inches(0.6), Inches(1.15), Inches(2), Inches(0.04),
|
||||
colors["accent"])
|
||||
|
||||
# Get bullet items (check aliases)
|
||||
bullet_items = slide_spec.get("bullets") or slide_spec.get("bullet_points")
|
||||
body_text = slide_spec.get("body") or slide_spec.get("content")
|
||||
|
||||
# Per-slide font_size override
|
||||
override = slide_spec.get("font_size")
|
||||
body_fs = Pt(override) if override else Pt(fs["body"])
|
||||
bullet_fs = Pt(override) if override else Pt(fs["bullets"])
|
||||
|
||||
if bullet_items and len(bullet_items) > 0:
|
||||
add_bullet_list(slide, Inches(0.8), content_y, Inches(11.7), Inches(5),
|
||||
bullet_items, font_size=bullet_fs,
|
||||
color_hex=colors["body"],
|
||||
bullet_color_hex=colors["accent"])
|
||||
elif body_text:
|
||||
add_text_box(slide, Inches(0.8), content_y, Inches(11.7), Inches(5),
|
||||
body_text, font_size=body_fs, color_hex=colors["body"])
|
||||
|
||||
# Image on content slide (right side)
|
||||
image_path = slide_spec.get("image_path")
|
||||
if image_path:
|
||||
resolved = resolve_image_path(image_path, project_dir, workspace_path)
|
||||
img_y = Inches(1.5) if has_title else Inches(0.5)
|
||||
img_max_h = Inches(5) if has_title else Inches(6)
|
||||
add_image_or_placeholder(slide, resolved,
|
||||
Inches(7), img_y, Inches(5.5), img_max_h,
|
||||
warnings, color_hex=colors["body"])
|
||||
|
||||
return slide
|
||||
|
||||
|
||||
def make_image_slide(prs, slide_spec, project_dir, workspace_path, colors, warnings, is_dark, fs, bg_image=None):
|
||||
"""Generate an image slide."""
|
||||
slide = prs.slides.add_slide(prs.slide_layouts[6])
|
||||
|
||||
# Background
|
||||
if bg_image:
|
||||
set_slide_bg_image(slide, bg_image)
|
||||
elif is_dark:
|
||||
set_slide_bg(slide, colors["background"])
|
||||
|
||||
has_title = bool(slide_spec.get("title"))
|
||||
|
||||
if has_title:
|
||||
add_text_box(slide, Inches(0.5), Inches(0.3), Inches(12.3), Inches(0.7),
|
||||
slide_spec["title"], font_size=Pt(fs["image_title"]),
|
||||
color_hex=colors["title"], bold=True)
|
||||
|
||||
image_path = slide_spec.get("image_path")
|
||||
if image_path:
|
||||
resolved = resolve_image_path(image_path, project_dir, workspace_path)
|
||||
# Image slides use full-slide bounding box with cover mode
|
||||
# so photos completely fill the available area (cropping if needed)
|
||||
if has_title:
|
||||
img_y = Inches(1.1)
|
||||
max_h = SLIDE_H - Inches(1.1)
|
||||
else:
|
||||
img_y = Inches(0)
|
||||
max_h = SLIDE_H
|
||||
add_image_or_placeholder(slide, resolved,
|
||||
Inches(0), img_y, SLIDE_W, max_h,
|
||||
warnings, fit_mode="cover")
|
||||
|
||||
return slide
|
||||
|
||||
|
||||
# ─── Main Generation ───────────────────────────────────────────────────────────
|
||||
|
||||
def generate(spec: dict, workspace_path: str) -> dict:
|
||||
"""Generate a PPTX file from spec. Returns result dict."""
|
||||
warnings = []
|
||||
|
||||
title = spec.get("title") or "Presentation"
|
||||
project_slug = slugify(title)
|
||||
# Derive filename from project slug if not explicitly provided
|
||||
filename = spec.get("filename", "").replace(" ", "_") if spec.get("filename") else f"{project_slug}.pptx"
|
||||
slides_spec = spec.get("slides") or []
|
||||
theme = spec.get("theme") or "light"
|
||||
is_dark = theme == "dark"
|
||||
|
||||
if not slides_spec:
|
||||
return {"success": False, "error": "spec.slides must be a non-empty array"}
|
||||
|
||||
existing_path = spec.get("existing_path", "")
|
||||
is_edit = bool(existing_path) and os.path.exists(existing_path)
|
||||
|
||||
# Create project folder (no separate images/ subdir — images go directly in project dir)
|
||||
if is_edit:
|
||||
project_dir = os.path.dirname(existing_path)
|
||||
else:
|
||||
project_dir = os.path.join(workspace_path, project_slug)
|
||||
os.makedirs(project_dir, exist_ok=True)
|
||||
|
||||
# Download image_url slides into project_dir
|
||||
# Set a browser-like User-Agent so image hosts (Wikipedia, Unsplash, etc.) don't block us
|
||||
from urllib.request import build_opener, Request
|
||||
_img_opener = build_opener()
|
||||
_img_opener.addheaders = [('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36')]
|
||||
|
||||
for i, slide_spec in enumerate(slides_spec):
|
||||
image_url = slide_spec.get("image_url")
|
||||
if image_url:
|
||||
try:
|
||||
ext = os.path.splitext(image_url.split("/")[-1].split("?")[0])[1] or ".jpg"
|
||||
fname = f"slide{i + 1}_image{ext}"
|
||||
dest = os.path.join(project_dir, fname)
|
||||
req = Request(image_url, headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'})
|
||||
with _img_opener.open(req, timeout=30) as resp:
|
||||
with open(dest, 'wb') as f:
|
||||
f.write(resp.read())
|
||||
slide_spec["image_path"] = fname
|
||||
except Exception as e:
|
||||
err_msg = f"Failed to download image from {image_url}: {e}"
|
||||
warnings.append(err_msg)
|
||||
print(f"[pptx_gen] WARNING: {err_msg}", file=sys.stderr, flush=True)
|
||||
slide_spec.pop("image_url", None)
|
||||
|
||||
if is_edit:
|
||||
output_path = existing_path
|
||||
else:
|
||||
output_path = os.path.join(project_dir, filename)
|
||||
|
||||
# Font sizes: merge spec.font_sizes over defaults
|
||||
spec_fs = spec.get("font_sizes") or {}
|
||||
fs = {**DEFAULT_FONT_SIZES, **spec_fs}
|
||||
|
||||
# Template: resolve and apply
|
||||
tpl_name = spec.get("template") or "business"
|
||||
tpl = resolve_template(tpl_name)
|
||||
tpl_colors = get_template_colors(tpl)
|
||||
|
||||
# Default skin from spec or empty
|
||||
default_skin = spec.get("default_skin") or ""
|
||||
|
||||
# Determine base colors: dark theme overrides template
|
||||
if is_dark:
|
||||
colors = COLORS_DARK
|
||||
else:
|
||||
colors = tpl_colors if tpl_colors else COLORS_LIGHT
|
||||
|
||||
# Create or open existing presentation
|
||||
if is_edit:
|
||||
prs = Presentation(existing_path)
|
||||
else:
|
||||
prs = Presentation()
|
||||
prs.slide_width = SLIDE_W
|
||||
prs.slide_height = SLIDE_H
|
||||
start_slide_num = 0
|
||||
|
||||
for slide_spec in slides_spec:
|
||||
slide_type = slide_spec.get("type") or "content"
|
||||
|
||||
try:
|
||||
# Resolve background skin/image for this slide
|
||||
bg_name = slide_spec.get("background") or default_skin or ""
|
||||
bg_image = None
|
||||
slide_is_dark = is_dark
|
||||
|
||||
if bg_name:
|
||||
bg_path, bg_dark = resolve_skin_bg(bg_name, project_dir, workspace_path)
|
||||
if bg_path:
|
||||
bg_image = bg_path
|
||||
slide_is_dark = bg_dark or is_dark
|
||||
else:
|
||||
# Not a skin name — might be a file path already resolved
|
||||
pass
|
||||
|
||||
# Pick colors for this slide: dark skin → light text on dark bg
|
||||
if slide_is_dark and not is_dark:
|
||||
slide_colors = COLORS_DARK
|
||||
elif not slide_is_dark and is_dark:
|
||||
slide_colors = tpl_colors if tpl_colors else COLORS_LIGHT
|
||||
else:
|
||||
slide_colors = colors
|
||||
|
||||
# Section slides always use accent fill — no background image
|
||||
if slide_type == "section":
|
||||
slide = make_section_slide(prs, slide_spec, slide_colors, fs)
|
||||
elif slide_type == "title":
|
||||
slide = make_title_slide(prs, slide_spec, spec, slide_colors, slide_is_dark, fs, bg_image)
|
||||
elif slide_type == "image":
|
||||
slide = make_image_slide(prs, slide_spec, project_dir, workspace_path,
|
||||
slide_colors, warnings, slide_is_dark, fs, bg_image)
|
||||
else: # content or default
|
||||
slide = make_content_slide(prs, slide_spec, project_dir, workspace_path, slide_colors, slide_is_dark, fs, warnings, bg_image)
|
||||
|
||||
# Speaker notes
|
||||
notes = slide_spec.get("notes")
|
||||
if notes and hasattr(slide, "notes_slide"):
|
||||
slide.notes_slide.notes_text_frame.text = notes
|
||||
except Exception as e:
|
||||
err_msg = f"Failed to generate slide (type={slide_type}, title={slide_spec.get('title','')[:30]}): {e}"
|
||||
warnings.append(err_msg)
|
||||
print(f"[pptx_gen] ERROR: {err_msg}", file=sys.stderr, flush=True)
|
||||
|
||||
# Save
|
||||
try:
|
||||
prs.save(output_path)
|
||||
except Exception as e:
|
||||
print(f"[pptx_gen] ERROR: Failed to save PPTX to {output_path}: {e}", file=sys.stderr, flush=True)
|
||||
raise
|
||||
|
||||
# Build download and preview links
|
||||
relative_path = f"{project_slug}/{filename}"
|
||||
from urllib.parse import quote
|
||||
encoded_path = "/".join(quote(s, safe='') for s in relative_path.split("/"))
|
||||
download_url = f"/api/files/{encoded_path}"
|
||||
preview_url = f"/api/pptx/preview?path={quote(relative_path)}"
|
||||
|
||||
total_slides = len(prs.slides)
|
||||
added_slides = len(slides_spec)
|
||||
action = "updated" if (existing_path and os.path.exists(existing_path)) else "created"
|
||||
|
||||
# Build download and preview links
|
||||
rel_for_link = os.path.relpath(output_path, workspace_path).replace("\\", "/")
|
||||
from urllib.parse import quote
|
||||
encoded_path = "/".join(quote(s, safe='') for s in rel_for_link.split("/"))
|
||||
download_url = f"/api/files/{encoded_path}"
|
||||
preview_url = f"/api/pptx/preview?path={quote(rel_for_link)}"
|
||||
|
||||
# Compose stdout with download link so UI can render it inline
|
||||
# Do NOT include [Preview slides] link — it causes the model to think more work is needed
|
||||
stdout_text = f"Presentation {action}: [{os.path.basename(output_path)}]({download_url}) ({total_slides} total slides, {added_slides} added, folder: {os.path.basename(project_dir)}/)"
|
||||
if warnings:
|
||||
stdout_text += "\n\nWarnings:\n" + "\n".join(f"- {w}" for w in warnings)
|
||||
if any("download" in w.lower() or "image" in w.lower() for w in warnings):
|
||||
stdout_text += "\n\nNote: Some images appear as red placeholders. This is expected — do NOT retry."
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"path": output_path,
|
||||
"folder": project_slug,
|
||||
"filename": os.path.basename(output_path),
|
||||
"slides": total_slides,
|
||||
"added": added_slides,
|
||||
"warnings": warnings,
|
||||
"download_url": download_url,
|
||||
"preview_url": preview_url,
|
||||
"stdout": stdout_text,
|
||||
}
|
||||
|
||||
|
||||
# ─── CLI Entry Point ───────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(json.dumps({"success": False, "error": "Usage: python pptx_gen.py <spec_json_path> [workspace_path]"}))
|
||||
sys.exit(1)
|
||||
|
||||
spec_path = sys.argv[1]
|
||||
workspace_path = sys.argv[2] if len(sys.argv) > 2 else os.getcwd()
|
||||
|
||||
try:
|
||||
with open(spec_path, "r", encoding="utf-8") as f:
|
||||
spec = json.load(f)
|
||||
except Exception as e:
|
||||
print(json.dumps({"success": False, "error": f"Failed to read spec file: {e}"}))
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
result = generate(spec, workspace_path)
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
print(json.dumps({"success": False, "error": str(e)}))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user