This commit is contained in:
kim
2026-04-29 11:45:59 +09:00
commit 3e8974a8eb
277 changed files with 70351 additions and 0 deletions
+756
View File
@@ -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()