Files
homeclaw/scripts/pptx_gen.py
T
2026-05-01 01:09:04 +09:00

888 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)
# Layout constants for two-column slides (text + image)
L_MARGIN = Inches(0.6) # left margin (aligns with title)
R_MARGIN = Inches(0.3) # right margin
COL_GAP = Inches(0.3) # gap between text and image columns
CONTENT_W = SLIDE_W - L_MARGIN - R_MARGIN # ~12.43"
# 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 (matches TypeScript projectSlug logic)."""
s = str(text or "presentation").strip()
s = re.sub(r"\.pptx$", "", s, flags=re.IGNORECASE)
s = re.sub(r'[^a-zA-Z0-9ㄱ-힣_\-]', "_", s)
s = re.sub(r"_+", "_", s)
s = s.strip("_").lower()
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
pic = slide.shapes.add_picture(
image_path, Inches(0), Inches(0), SLIDE_W, SLIDE_H
)
# Tag so pptx_preview.py can distinguish skin backgrounds from content images
pic.name = "background_skin"
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
"""
# Tiny breathing margin — image fills nearly the full bounding box
max_w = int(max_w * 1.0)
max_h = int(max_h * 1.0)
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 _ensure_compatible_image(image_path: str) -> str:
"""Convert unsupported image formats to PNG for python-pptx compatibility.
PowerPoint only supports JPEG, PNG, GIF, BMP, TIFF, EMF, WMF.
AVIF, WebP, and other formats must be converted. We check the actual
format via PIL rather than trusting the file extension, since downloaded
images often have mismatched extensions (e.g. AVIF data saved as .jpg).
"""
# Formats that PowerPoint can embed directly
PPTX_SUPPORTED = {'JPEG', 'PNG', 'GIF', 'BMP', 'TIFF'}
try:
from PIL import Image as PILImage
with PILImage.open(image_path) as img:
actual_format = img.format # e.g. 'AVIF', 'WEBP', 'JPEG', 'PNG'
if actual_format and actual_format.upper() in PPTX_SUPPORTED:
return image_path
# Unsupported format (AVIF, WEBP, etc.) — convert to PNG
if img.mode in ('RGBA', 'P', 'LA', 'L'):
rgb_img = img.convert('RGB')
else:
rgb_img = img
new_path = image_path + '.converted.png'
rgb_img.save(new_path, 'PNG')
print(f"[pptx_gen] Converted {actual_format} image to PNG: {os.path.basename(image_path)} -> {os.path.basename(new_path)}", file=sys.stderr, flush=True)
return new_path
except Exception:
# PIL can't open it — return as-is and let downstream handle the failure
return image_path
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 subtle placeholder if not found/unsupported.
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):
converted_path = _ensure_compatible_image(image_path)
print(f"[pptx_gen] add_image: {os.path.basename(image_path)} exists, converted={os.path.basename(converted_path) if converted_path != image_path else 'same'}", file=sys.stderr, flush=True)
try:
# Read native dimensions and compute aspect-ratio-preserving size
from PIL import Image as PILImage
with PILImage.open(converted_path) as img:
img_w, img_h = img.size
img_fmt = getattr(img, 'format', 'unknown')
print(f"[pptx_gen] add_image: {img_fmt} {img_w}x{img_h} -> fit_mode={fit_mode}", file=sys.stderr, flush=True)
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(converted_path, center_x, center_y, fit_w, fit_h)
print(f"[pptx_gen] add_image: OK, embedded at ({int(center_x)},{int(center_y)})", file=sys.stderr, flush=True)
return
except Exception as e:
print(f"[pptx_gen] add_image: FAILED to embed {os.path.basename(image_path)}: {e}", file=sys.stderr, flush=True)
# If image dimension reading fails, try with native size (no stretching)
try:
pic = slide.shapes.add_picture(converted_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)
print(f"[pptx_gen] add_image: fallback native size OK", file=sys.stderr, flush=True)
return
except Exception as e2:
print(f"[pptx_gen] add_image: fallback also FAILED: {e2}", file=sys.stderr, flush=True)
pass
else:
print(f"[pptx_gen] add_image: file NOT FOUND: {image_path}", file=sys.stderr, flush=True)
# Draw a subtle gray placeholder instead of red error text
add_shape_rect(slide, left, top, max_width, max_height, "E8E8E8")
warnings.append(f"Image missing or unsupported: {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):
if os.path.exists(image_path):
print(f"[pptx_gen] resolve_image_path: {image_path} (absolute, exists)", file=sys.stderr, flush=True)
return image_path
else:
print(f"[pptx_gen] resolve_image_path: {image_path} (absolute, NOT FOUND)", file=sys.stderr, flush=True)
candidates = [
os.path.join(project_dir, image_path),
os.path.join(workspace_path, image_path),
]
for c in candidates:
if os.path.exists(c):
print(f"[pptx_gen] resolve_image_path: {image_path} -> {c} (found)", file=sys.stderr, flush=True)
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:
resolved = os.path.join(root, f)
print(f"[pptx_gen] resolve_image_path: {image_path} -> {resolved} (found by filename search)", file=sys.stderr, flush=True)
return resolved
print(f"[pptx_gen] resolve_image_path: {image_path} NOT FOUND in {project_dir} or {workspace_path}", file=sys.stderr, flush=True)
return candidates[0] # return first even if missing (placeholder will show)
# ─── Auto Layout Helpers ────────────────────────────────────────────────────────
def _text_weight(slide_spec: dict):
"""Return (bullet_count, total_chars) for the text content of a slide."""
bullets = slide_spec.get("bullets") or slide_spec.get("bullet_points") or []
body = slide_spec.get("body") or slide_spec.get("content") or ""
if bullets:
return len(bullets), sum(len(str(b)) for b in bullets)
return (1 if body else 0), len(body)
def _auto_text_ratio(slide_spec: dict) -> float:
"""Compute text column share (0–1) based on content volume.
≤2 bullets / ≤120 chars → 0.32 image-dominant
3–4 bullets / ≤280 chars → 0.44 balanced
5+ bullets / >280 chars → 0.56 text-dominant
"""
n_bullets, n_chars = _text_weight(slide_spec)
if n_bullets <= 2 and n_chars <= 120:
return 0.32
elif n_bullets <= 4 and n_chars <= 280:
return 0.44
else:
return 0.56
# ─── 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"))
has_image = bool(slide_spec.get("image_path"))
content_y = Inches(1.5) if has_title else Inches(0.5)
content_h = SLIDE_H - content_y - Inches(0.3)
if has_title:
add_text_box(slide, L_MARGIN, Inches(0.4), SLIDE_W - L_MARGIN - R_MARGIN, Inches(0.8),
slide_spec["title"], font_size=Pt(fs["slide_title"]),
color_hex=colors["title"], bold=True)
add_shape_rect(slide, L_MARGIN, Inches(1.15), Inches(2), Inches(0.04),
colors["accent"])
# ── Two-column layout when image present ──────────────────────────────────
if has_image:
text_ratio = _auto_text_ratio(slide_spec)
text_col_w = CONTENT_W * text_ratio - COL_GAP / 2
img_col_x = L_MARGIN + text_col_w + COL_GAP
img_col_w = CONTENT_W * (1 - text_ratio) - COL_GAP / 2
else:
text_col_w = CONTENT_W
img_col_x = None
img_col_w = None
# ── Text content ──────────────────────────────────────────────────────────
bullet_items = slide_spec.get("bullets") or slide_spec.get("bullet_points")
body_text = slide_spec.get("body") or slide_spec.get("content")
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, L_MARGIN, content_y, text_col_w, content_h,
bullet_items, font_size=bullet_fs,
color_hex=colors["body"],
bullet_color_hex=colors["accent"])
elif body_text:
add_text_box(slide, L_MARGIN, content_y, text_col_w, content_h,
body_text, font_size=body_fs, color_hex=colors["body"])
# ── Image column ──────────────────────────────────────────────────────────
if has_image:
resolved = resolve_image_path(slide_spec["image_path"], project_dir, workspace_path)
add_image_or_placeholder(slide, resolved,
img_col_x, content_y, img_col_w, content_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)
# Add padding so the photo doesn't bleed edge-to-edge;
# use contain so the full photo is visible without cropping.
h_pad = Inches(0.5)
if has_title:
img_top = Inches(1.2)
v_pad = Inches(0.3)
else:
img_top = Inches(0.4)
v_pad = Inches(0.4)
add_image_or_placeholder(slide, resolved,
h_pad, img_top,
SLIDE_W - 2 * h_pad,
SLIDE_H - img_top - v_pad,
warnings, fit_mode="contain")
return slide
# ─── Download Page Generator ────────────────────────────────────────────────────
def _create_download_page(pptx_path: str, download_url: str, title: str, slide_count: int):
"""Create a local download.html next to the PPTX so users can grab the file via browser."""
project_dir = os.path.dirname(pptx_path)
filename = os.path.basename(pptx_path)
html_path = os.path.join(project_dir, "download.html")
# Build a relative link so the HTML works whether opened via file:// or served
html = f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>{title} - 다운로드</title>
<style>
body {{ font-family: 'Malgun Gothic', sans-serif; max-width: 600px; margin: 60px auto; padding: 20px; text-align: center; background: #f8f9fa; }}
.card {{ background: white; border-radius: 12px; padding: 40px 30px; box-shadow: 0 4px 20px rgba(0,0,0,0.08); }}
h1 {{ color: #1a1a2e; font-size: 24px; margin-bottom: 10px; }}
p {{ color: #5f6f86; margin-bottom: 30px; }}
.btn {{ display: inline-block; background: #1668e3; color: white; text-decoration: none; padding: 14px 36px; border-radius: 8px; font-size: 16px; font-weight: bold; transition: background 0.2s; }}
.btn:hover {{ background: #1255bb; }}
.meta {{ margin-top: 20px; font-size: 12px; color: #888; }}
</style>
</head>
<body>
<div class="card">
<h1>{title}</h1>
<p>슬라이드 {slide_count}장이 준비되었습니다.</p>
<a class="btn" href="{filename}" download>프레젠테이션 다운로드</a>
<div class="meta">{pptx_path}</div>
</div>
</body>
</html>
"""
with open(html_path, "w", encoding="utf-8") as f:
f.write(html)
# ─── 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"
print(f"[pptx_gen] generate: title='{title}' slug='{project_slug}' slides={len(slides_spec)}", file=sys.stderr, flush=True)
print(f"[pptx_gen] generate: workspace='{workspace_path}' theme='{theme}'", file=sys.stderr, flush=True)
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)
print(f"[pptx_gen] generate: project_dir='{project_dir}'", file=sys.stderr, flush=True)
# Log slide specs with image info before processing
for i, s in enumerate(slides_spec):
ip = s.get("image_path")
iu = s.get("image_url")
if ip or iu:
print(f"[pptx_gen] slide {i+1}: type={s.get('type','?')} image_path='{ip}' image_url='{iu[:60] if iu else ''}'", file=sys.stderr, flush=True)
# Warn about any remaining image_url slides (downloading is now handled by TypeScript)
for s in slides_spec:
if s.get("image_url") and not s.get("image_path"):
warnings.append(f"image_url not downloaded (TypeScript should handle this): {s['image_url'][:80]}")
s.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 could not be embedded and appear as light-gray placeholders. This is expected — do NOT retry."
# Create a local download.html page so users can download via browser even when the API gateway isn't rendering the link
try:
_create_download_page(output_path, download_url, title, total_slides)
except Exception:
pass
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()