Files
homeclaw/scripts/pptx_gen.py
T
kimandClaude Opus 4.8 949a910ad6 Release 2.8.2: render title-slide photo background in PPTX
The slide editor draws a title slide's image as a full-bleed darkened
background, but pptx_gen.py's make_title_slide ignored image_path entirely
and only drew the skin background — so photo backgrounds showed in the
editor but vanished from the generated PPTX / preview.

- make_title_slide now renders image_path full-bleed (cover) with a dark
  overlay and white title/subtitle, matching the editor preview.
- Add set_shape_fill_opacity() that injects OOXML <a:alpha>, since
  python-pptx's fill.transparency is a silent no-op (the overlay was
  rendering as fully opaque black). Apply it to the fullscreen bottom bar too.
- Pass project_dir/workspace_path/warnings into make_title_slide.
- Bump version to 2.8.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 20:42:25 +09:00

1668 lines
71 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
import math
import argparse
# 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, MSO_AUTO_SIZE
from pptx.oxml.ns import qn
from pptx.oxml import parse_xml
from lxml import etree
# ─── Paths ──────────────────────────────────────────────────────────────────────
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.join(SCRIPT_DIR, '..')
SKIN_DIR = os.path.join(PROJECT_ROOT, '.smallclaw', 'skills', 'presenter', 'ppt', 'skin')
TEMPLATE_DIR = os.path.join(PROJECT_ROOT, '.smallclaw', 'skills', 'presenter', '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": 40,
"subtitle": 20,
"slide_title": 28,
"body": 18,
"bullets": 18,
"section": 36,
"image_title": 24,
}
# 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.7) # 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',
'teal', 'emerald', # luminance <140 → JS detects dark but were missing here
'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 clean_text(text) -> str:
"""Remove control characters / OOXML escape artifacts from text.
Handles two cases:
1. Actual control chars (U+000B etc.) → space or stripped
2. Literal '_x000B_' strings from PDF extraction → stripped
"""
if not isinstance(text, str):
text = str(text) if text is not None else ""
# Strip literal OOXML hex-escape patterns like _x000B_, _x000D_, _x000A_ etc.
# Also catch fragments like x000B_ (missing leading _ after prior removal)
text = re.sub(r'_?x[0-9A-Fa-f]{4}_', ' ', text)
# Strip actual control characters
result = []
for ch in text:
cp = ord(ch)
if cp in (0x0B, 0x0C, 0x0D): # VT, FF, CR → space
result.append(' ')
elif cp < 0x20 and cp not in (0x09, 0x0A): # other C0 except HT/LF
pass # strip
elif 0x7F <= cp <= 0x9F: # DEL + C1 controls
pass # strip
else:
result.append(ch)
return re.sub(r' +', ' ', ''.join(result)).strip()
def apply_img_filter(image_path: str, slide_spec: dict) -> str:
"""Apply image filter (blur/grayscale/contrast/bright/round) using Pillow.
Returns the path to the processed image (temp file next to original).
Returns original path if Pillow unavailable or no filter set.
"""
img_filter = slide_spec.get("img_filter") or ""
if not img_filter or img_filter == "none":
return image_path
try:
from PIL import Image, ImageFilter, ImageEnhance, ImageDraw
import tempfile, os as _os
img = Image.open(image_path).convert("RGBA")
if img_filter == "grayscale":
img = img.convert("L").convert("RGBA")
elif img_filter == "blur":
radius = max(1, int(slide_spec.get("img_blur_radius") or 5))
img = img.filter(ImageFilter.GaussianBlur(radius=radius))
elif img_filter == "contrast":
img = ImageEnhance.Contrast(img.convert("RGB")).enhance(1.6)
img = ImageEnhance.Color(img).enhance(1.2)
img = img.convert("RGBA")
elif img_filter == "bright":
img = ImageEnhance.Brightness(img.convert("RGB")).enhance(1.4)
img = img.convert("RGBA")
elif img_filter == "round":
pct = max(5, min(50, int(slide_spec.get("img_corner_radius") or 15)))
radius_px = int(min(img.width, img.height) * pct / 100)
mask = Image.new("L", img.size, 0)
draw = ImageDraw.Draw(mask)
draw.rounded_rectangle([0, 0, img.width - 1, img.height - 1],
radius=radius_px, fill=255)
img.putalpha(mask)
# Save to temp file (PNG to support transparency)
base = _os.path.splitext(image_path)[0]
out_path = base + "_fx.png"
img.save(out_path, "PNG")
return out_path
except Exception as e:
print(f"[pptx_gen] img_filter '{img_filter}' failed: {e}", file=sys.stderr, flush=True)
return image_path
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. Multi-line text (\n) uses <a:br/> for consistent alignment."""
txBox = slide.shapes.add_textbox(left, top, width, height)
tf = txBox.text_frame
tf.word_wrap = True
tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
p = tf.paragraphs[0]
p.alignment = alignment
def _make_run(para, txt):
r = para.add_run()
r.text = txt
r.font.size = font_size
r.font.bold = bold
r.font.color.rgb = hex_to_rgb(color_hex)
r.font.name = font_name or get_font()
return r
lines = text.split('\n')
_make_run(p, lines[0])
for line in lines[1:]:
# <a:br/> keeps the same paragraph (and its alignment) for each visual line
br = etree.SubElement(p._p, qn('a:br'))
rPr = etree.SubElement(br, qn('a:rPr'))
rPr.set('lang', 'ko-KR')
rPr.set('sz', str(int(font_size.pt * 100)))
rPr.set('b', '1' if bold else '0')
rPr.set('dirty', '0')
_make_run(p, line)
return txBox
def _fit_bullet_font_size(items, width_emu, height_emu, max_pt):
"""Return the largest font size (pt) where all bullets fit in the box."""
col_w_pt = width_emu / 12700 # EMU → pt (1pt = 12700 EMU)
col_h_pt = height_emu / 12700
for fs in range(int(max_pt), 9, -1):
space_after = min(8, max(4, fs // 3))
line_h = fs * 1.35 + space_after
chars_per_line = max(1, int(col_w_pt / (fs * 0.52)))
# Items may contain \n (soft breaks) — count each visual line separately
total = 0
for it in items:
sub_lines = str(it).split('\n')
for sl in sub_lines:
total += max(1, math.ceil((len(sl) + 2) / chars_per_line)) * line_h
if total <= col_h_pt:
return fs
return 9
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. Empty items become line spacers (no bullet)."""
txBox = slide.shapes.add_textbox(left, top, width, height)
tf = txBox.text_frame
tf.word_wrap = True
tf.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE
# Fit font size using only non-empty items
real_items = [it for it in items if str(it).strip()]
fitted_pt = _fit_bullet_font_size(real_items or items, width, height, font_size.pt)
fitted_fs = Pt(fitted_pt)
space_after_pt = min(8, max(4, fitted_pt // 3))
hang_emu = int(min(254000, max(152400, fitted_pt * 12700)))
for i, item in enumerate(items):
if i == 0:
p = tf.paragraphs[0]
else:
p = tf.add_paragraph()
is_spacer = not str(item).strip()
if is_spacer:
# 빈 줄 → bullet 없는 spacer 단락
p.space_after = Pt(fitted_pt * 0.6)
pPr = p._p.get_or_add_pPr()
etree.SubElement(pPr, qn('a:buNone'))
r = p.add_run()
r.text = ''
r.font.size = Pt(max(6, fitted_pt // 2))
continue
# Detect sub-bullet: lines starting with "- " or "– " or "— "
raw_text = str(item)
_sub_match = re.match(r'^[-–—]\s+(.*)', raw_text)
is_sub = _sub_match is not None
display_text = _sub_match.group(1) if is_sub else raw_text
# Split display_text by \n for soft line breaks within same bullet
_lines = display_text.split('\n')
p.space_after = Pt(space_after_pt)
if is_sub:
# Level 1 sub-bullet (○) with larger left margin
p.level = 1
sub_hang = int(min(254000, max(152400, fitted_pt * 12700)))
pPr = p._p.get_or_add_pPr()
pPr.set('marL', str(hang_emu + sub_hang))
pPr.set('indent', str(-sub_hang))
buClr = etree.SubElement(pPr, qn('a:buClr'))
srgbClr = etree.SubElement(buClr, qn('a:srgbClr'))
srgbClr.set('val', bullet_color_hex.upper().replace('#', '').zfill(6))
buChar = etree.SubElement(pPr, qn('a:buChar'))
buChar.set('char', '–')
else:
# Level 0 main bullet (●)
p.level = 0
# Hanging indent: wrapped lines align with text start after bullet
pPr = p._p.get_or_add_pPr()
pPr.set('marL', str(hang_emu))
pPr.set('indent', str(-hang_emu))
# Native PPTX bullet character (color + char)
buClr = etree.SubElement(pPr, qn('a:buClr'))
srgbClr = etree.SubElement(buClr, qn('a:srgbClr'))
srgbClr.set('val', bullet_color_hex.upper().replace('#', '').zfill(6))
buChar = etree.SubElement(pPr, qn('a:buChar'))
buChar.set('char', '●')
# First line as normal run, subsequent lines as <a:br/> soft breaks
text_run = p.add_run()
text_run.text = _lines[0]
text_run.font.size = fitted_fs
text_run.font.color.rgb = hex_to_rgb(color_hex)
text_run.font.name = font_name or get_font()
for _ln in _lines[1:]:
br = etree.SubElement(p._p, qn('a:br'))
rPr = etree.SubElement(br, qn('a:rPr'))
rPr.set('lang', 'ko-KR')
rPr.set('sz', str(int(fitted_fs.pt * 100)))
rPr.set('dirty', '0')
text_run = p.add_run()
text_run.text = _ln
text_run.font.size = fitted_fs
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"
# Move to back: add_picture appends to end (top layer) — move behind all content
sp_tree = slide.shapes._spTree
pic_elem = pic._element
sp_tree.remove(pic_elem)
sp_tree.insert(2, pic_elem) # index 2 = after nvGrpSpPr + grpSpPr
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 set_shape_fill_opacity(shape, opacity_pct: float):
"""Make a solid-filled shape semi-transparent.
opacity_pct: 0 (fully transparent) .. 100 (fully opaque).
python-pptx has no fill.transparency setter (it is a silent no-op), so we
inject the OOXML <a:alpha> element directly into the solidFill color.
"""
spPr = shape._element.spPr
fill = spPr.find(qn('a:solidFill'))
if fill is None:
return
srgb = fill.find(qn('a:srgbClr'))
if srgb is None:
return
alpha = srgb.find(qn('a:alpha'))
if alpha is None:
alpha = etree.SubElement(srgb, qn('a:alpha'))
alpha.set('val', str(int(max(0, min(100, opacity_pct)) * 1000)))
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 within project_dir only (NOT workspace-wide — generic names like
# slide2_image.jpg would match other projects' images and corrupt the presentation)
basename = os.path.basename(image_path).lower()
for root, dirs, files in os.walk(project_dir):
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 in project_dir)", 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.
img_scale (20–80) overrides auto: text_ratio = (100 - img_scale) / 100
≤2 bullets / ≤120 chars → 0.32 image-dominant
3–4 bullets / ≤280 chars → 0.44 balanced
5+ bullets / >280 chars → 0.56 text-dominant
"""
img_scale = slide_spec.get("img_scale")
if img_scale is not None:
try:
pct = max(20, min(80, int(img_scale)))
return round((100 - pct) / 100, 2)
except (TypeError, ValueError):
pass
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,
project_dir=None, workspace_path=None, warnings=None):
"""Generate a title slide."""
slide = prs.slides.add_slide(prs.slide_layouts[6]) # blank layout
title_col = colors["title"]
subtitle_col = colors["subtitle"]
# Background — a photo (slide image_path) takes precedence over the skin,
# rendered full-bleed with a dark overlay + white text. This mirrors the
# editor preview, which draws the title image as a darkened background.
img_path = slide_spec.get("image_path")
has_photo = bool(img_path) and project_dir is not None
if has_photo:
resolved = resolve_image_path(img_path, project_dir, workspace_path)
add_image_or_placeholder(slide, resolved,
Inches(0), Inches(0), SLIDE_W, SLIDE_H,
warnings if warnings is not None else [],
fit_mode="cover")
# Full-slide dark overlay (~48% opaque black, matches editor rgba(0,0,0,.48))
overlay = add_shape_rect(slide, Inches(0), Inches(0), SLIDE_W, SLIDE_H, "000000")
set_shape_fill_opacity(overlay, 48) # 48% opaque black, matches editor rgba(0,0,0,.48)
title_col = "FFFFFF"
subtitle_col = "E6E6E6"
elif bg_image:
set_slide_bg_image(slide, bg_image)
elif is_dark:
set_slide_bg(slide, colors["background"])
_title_font = slide_spec.get("_font_override") or None
title = slide_spec.get("title") or spec.get("title") or "Untitled"
title_pt = slide_spec.get("title_size") or fs["title"]
y_pos = Inches(2.8) if (bg_image or has_photo) else Inches(2.5)
title_lines = len(title.split('\n'))
title_h = Inches(max(1.2, title_lines * title_pt * 1.5 / 72))
add_text_box(slide, Inches(0.8), y_pos, Inches(11.7), title_h,
title, font_size=Pt(title_pt), color_hex=title_col,
bold=True, alignment=PP_ALIGN.CENTER, font_name=_title_font)
subtitle = slide_spec.get("subtitle")
if subtitle:
add_text_box(slide, Inches(1.5), y_pos + title_h + Inches(0.15), Inches(10.3), Inches(0.7),
subtitle, font_size=Pt(fs["subtitle"]), color_hex=subtitle_col,
alignment=PP_ALIGN.CENTER, font_name=_title_font)
return slide
def make_section_slide(prs, slide_spec, colors, fs, bg_image=None):
"""Generate a section divider slide."""
slide = prs.slides.add_slide(prs.slide_layouts[6])
if bg_image:
set_slide_bg_image(slide, bg_image)
else:
add_shape_rect(slide, Inches(0), Inches(0), SLIDE_W, SLIDE_H, colors.get("accent", "1668E3"))
title = slide_spec.get("title") or "Section"
text_color = "FFFFFF"
add_text_box(slide, Inches(1), Inches(2.5), Inches(11.3), Inches(1.5),
title, font_size=Pt(fs["section"]), color_hex=text_color,
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.
Layouts (slide_spec["layout"]):
"split-right" (default) — small title top, text left, image right
"split-left" — small title top, image left, text right
"text" — full-width text, image ignored
"fullscreen" — image fills slide, title/text overlaid at bottom
"""
layout = (slide_spec.get("layout") or "split-right").lower()
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")) and layout != "text"
_slide_font = slide_spec.get("_font_override") or None
# text_y: 내용 블록 Y 위치 (슬라이드 높이 비율, 0~1). 편집창 드래그로 설정.
_text_y = slide_spec.get("text_y")
if _text_y is not None:
content_y_override = SLIDE_H * float(_text_y)
else:
content_y_override = None
# ── Fullscreen layout: image fills slide, text/title overlaid ─────────────
if layout == "fullscreen" and has_image:
resolved = resolve_image_path(slide_spec["image_path"], project_dir, workspace_path)
add_image_or_placeholder(slide, resolved,
Inches(0), Inches(0), SLIDE_W, SLIDE_H,
warnings, fit_mode="cover")
# Semi-transparent dark bar at bottom for text legibility
bar_h = Inches(2.0)
bar_y = SLIDE_H - bar_h
bar_shape = add_shape_rect(slide, Inches(0), bar_y, SLIDE_W, bar_h, "000000")
set_shape_fill_opacity(bar_shape, 55) # 55% opaque black
# Title and body overlaid on dark bar
fs_title_pt = slide_spec.get("title_size") or fs["slide_title"]
if has_title:
add_text_box(slide, Inches(0.6), bar_y + Inches(0.15), Inches(12.0), Inches(0.7),
slide_spec["title"], font_size=Pt(fs_title_pt),
color_hex="FFFFFF", bold=True)
body_text = slide_spec.get("body") or slide_spec.get("content")
bullets = slide_spec.get("bullets") or slide_spec.get("bullet_points")
if bullets:
body_text = " ● ".join(str(b) for b in bullets)
if body_text:
add_text_box(slide, Inches(0.6), bar_y + Inches(0.9), Inches(12.0), Inches(0.9),
body_text, font_size=Pt(fs["body"]), color_hex="E0E0E0")
return slide
if content_y_override is not None:
content_y = content_y_override
else:
content_y = Inches(1.5) if has_title else Inches(0.5)
content_h = SLIDE_H - content_y - Inches(0.4)
slide_title_pt = slide_spec.get("title_size") or fs["slide_title"]
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(slide_title_pt),
color_hex=colors["title"], bold=True, font_name=_slide_font)
add_shape_rect(slide, L_MARGIN, Inches(1.15), Inches(2), Inches(0.04),
colors["accent"])
# ── Compare layout: two text columns side by side ─────────────────────────
if layout == "compare":
col_w = (CONTENT_W - COL_GAP) / 2
right_x = L_MARGIN + col_w + COL_GAP
# Vertical divider
div_x = L_MARGIN + col_w + COL_GAP / 2 - Inches(0.02)
add_shape_rect(slide, div_x, content_y + Inches(0.1),
Inches(0.04), content_h - Inches(0.2), colors["accent"])
for col_x, t_key, b_key, pts_key, body_key in [
(L_MARGIN, "left_title", "left_bullets", "left_points", "left_body"),
(right_x, "right_title", "right_bullets", "right_points", "right_body"),
]:
col_title = slide_spec.get(t_key) or ""
col_bullets = slide_spec.get(b_key) or slide_spec.get(pts_key) or []
col_body = slide_spec.get(body_key) or ""
sub_y = content_y
sub_h = content_h
if col_title:
add_text_box(slide, col_x, sub_y, col_w, Inches(0.5),
col_title, font_size=Pt(fs["slide_title"] - 2),
color_hex=colors["accent"], bold=True)
sub_y += Inches(0.6)
sub_h -= Inches(0.6)
if col_bullets:
add_bullet_list(slide, col_x, sub_y, col_w, sub_h,
col_bullets, font_size=Pt(fs["bullets"]),
color_hex=colors["body"],
bullet_color_hex=colors["accent"])
elif col_body:
add_text_box(slide, col_x, sub_y, col_w, sub_h,
col_body, font_size=Pt(fs["body"]),
color_hex=colors["body"])
return slide
# ── Column geometry ───────────────────────────────────────────────────────
if has_image:
text_ratio = _auto_text_ratio(slide_spec)
if layout == "split-left":
# Image left, text right
img_col_x = L_MARGIN
img_col_w = CONTENT_W * (1 - text_ratio) - COL_GAP / 2
text_col_x = img_col_x + img_col_w + COL_GAP
text_col_w = CONTENT_W * text_ratio - COL_GAP / 2
else:
# split-right (default): text left, image right
text_col_x = L_MARGIN
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_x = L_MARGIN
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, text_col_x, content_y, text_col_w, content_h,
bullet_items, font_size=bullet_fs,
color_hex=colors["body"],
bullet_color_hex=colors["accent"],
font_name=_slide_font)
elif body_text:
add_text_box(slide, text_col_x, content_y, text_col_w, content_h,
body_text, font_size=body_fs, color_hex=colors["body"],
font_name=_slide_font)
# ── Image column / free position ─────────────────────────────────────────
if has_image:
resolved = resolve_image_path(slide_spec["image_path"], project_dir, workspace_path)
# Free positioning: img_x/img_y/img_w/img_h as fractions (0-1) of slide
fx = slide_spec.get("img_x")
fy = slide_spec.get("img_y")
fw = slide_spec.get("img_w")
fh = slide_spec.get("img_h")
if fx is not None and fy is not None:
abs_x = SLIDE_W * float(fx)
abs_y = SLIDE_H * float(fy)
abs_w = SLIDE_W * float(fw) if fw is not None else img_col_w or SLIDE_W * 0.45
abs_h = SLIDE_H * float(fh) if fh is not None else content_h
add_image_or_placeholder(slide, resolved,
abs_x, abs_y, abs_w, abs_h,
warnings, color_hex=colors["body"])
elif img_col_x is not None:
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
# ─── Table / Chart / Timeline Slide Generators ─────────────────────────────────
def _style_table_cell(cell, text, font_pt, font_name, color_hex, bold=False,
alignment=PP_ALIGN.LEFT, bg_hex=None):
"""Set text and styling on a python-pptx table cell."""
tf = cell.text_frame
tf.word_wrap = True
cell.text = str(text) if text is not None else ""
p = tf.paragraphs[0]
p.alignment = alignment
if p.runs:
run = p.runs[0]
run.font.size = Pt(font_pt)
run.font.bold = bold
run.font.name = font_name
run.font.color.rgb = hex_to_rgb(color_hex)
if bg_hex:
cell.fill.solid()
cell.fill.fore_color.rgb = hex_to_rgb(bg_hex)
def make_table_slide(prs, slide_spec, colors, is_dark, fs, warnings, bg_image=None):
"""Generate a table slide with optional header row and data rows."""
slide = prs.slides.add_slide(prs.slide_layouts[6])
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, 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"])
headers = slide_spec.get("headers") or []
rows = slide_spec.get("rows") or []
col_count = len(headers) if headers else (len(rows[0]) if rows else 0)
if col_count == 0:
return slide
has_header_row = bool(headers)
row_count = len(rows) + (1 if has_header_row else 0)
if row_count == 0:
return slide
table_top = Inches(1.5) if has_title else Inches(0.6)
HEADER_H = Inches(0.5)
ROW_H = Inches(0.55)
ideal_h = (HEADER_H if has_header_row else 0) + len(rows) * ROW_H
available_h = SLIDE_H - table_top - Inches(0.4)
table_h = min(ideal_h, available_h)
if table_h < available_h:
table_top = int(table_top + (available_h - table_h) // 2)
table_w = int(SLIDE_W * 0.88)
table_left = (SLIDE_W - table_w) // 2
tbl = slide.shapes.add_table(row_count, col_count, table_left, table_top, table_w, table_h).table
# Set explicit row heights so python-pptx doesn't stretch them evenly
for ri2 in range(row_count):
tbl.rows[ri2].height = HEADER_H if (has_header_row and ri2 == 0) else ROW_H
fn = get_font()
body_pt = fs.get("body", 16)
header_pt = min(body_pt, 15)
if has_header_row:
for j, h in enumerate(headers[:col_count]):
_style_table_cell(tbl.cell(0, j), h, header_pt, fn,
"FFFFFF", bold=True, alignment=PP_ALIGN.CENTER,
bg_hex=colors.get("accent", "1668E3"))
for i, row in enumerate(rows):
ri = i + (1 if has_header_row else 0)
if ri >= row_count:
break
if is_dark:
bg = "2A3040" if i % 2 == 0 else "222836"
else:
bg = "F2F4F8" if i % 2 == 0 else "FFFFFF"
for j, val in enumerate(row[:col_count]):
_style_table_cell(tbl.cell(ri, j), val, body_pt, fn,
colors.get("body", "2D3748"), bg_hex=bg)
return slide
def make_chart_slide(prs, slide_spec, colors, is_dark, fs, warnings, bg_image=None):
"""Generate a chart slide (column/bar/line/pie/doughnut)."""
try:
from pptx.chart.data import ChartData
from pptx.enum.chart import XL_CHART_TYPE
except ImportError:
warnings.append("Chart support requires python-pptx >= 0.6.18")
return prs.slides.add_slide(prs.slide_layouts[6])
slide = prs.slides.add_slide(prs.slide_layouts[6])
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, 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"])
type_map = {
"bar": XL_CHART_TYPE.BAR_CLUSTERED,
"bar_stacked": XL_CHART_TYPE.BAR_STACKED,
"column": XL_CHART_TYPE.COLUMN_CLUSTERED,
"column_stacked": XL_CHART_TYPE.COLUMN_STACKED,
"line": XL_CHART_TYPE.LINE,
"line_markers": XL_CHART_TYPE.LINE_MARKERS,
"pie": XL_CHART_TYPE.PIE,
"doughnut": XL_CHART_TYPE.DOUGHNUT,
}
xl_type = type_map.get((slide_spec.get("chart_type") or "column").lower(),
XL_CHART_TYPE.COLUMN_CLUSTERED)
categories = slide_spec.get("categories") or []
series_data = slide_spec.get("series") or []
if not categories or not series_data:
warnings.append(f"Chart slide '{slide_spec.get('title', '')}' missing categories or series — skipped")
return slide
chart_data = ChartData()
chart_data.categories = [str(c) for c in categories]
for s in series_data:
name = str(s.get("name") or s.get("label") or "Series")
values = [float(v) if v is not None else 0.0 for v in (s.get("values") or s.get("data") or [])]
chart_data.add_series(name, values)
chart_top = Inches(1.5) if has_title else Inches(0.5)
chart_frame = slide.shapes.add_chart(
xl_type, L_MARGIN, chart_top,
SLIDE_W - L_MARGIN - R_MARGIN, SLIDE_H - chart_top - Inches(0.4),
chart_data,
)
chart = chart_frame.chart
# Hide built-in chart title (slide title is sufficient)
chart.has_title = False
if len(series_data) > 1:
chart.has_legend = True
# Apply white text on dark backgrounds
if is_dark:
from pptx.dml.color import RGBColor
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
for _axis in [chart.value_axis, chart.category_axis]:
try:
_axis.tick_labels.font.color.rgb = WHITE
except Exception:
pass
try:
if _axis.has_title:
for _para in _axis.axis_title.text_frame.paragraphs:
for _run in _para.runs:
_run.font.color.rgb = WHITE
except Exception:
pass
if chart.has_legend:
try:
chart.legend.font.color.rgb = WHITE
except Exception:
pass
for plot in chart.plots:
try:
plot.data_labels.font.color.rgb = WHITE
except Exception:
pass
return slide
def make_timeline_slide(prs, slide_spec, colors, is_dark, fs, warnings, bg_image=None):
"""Generate a horizontal timeline slide with alternating labels above/below."""
from pptx.enum.shapes import MSO_SHAPE_TYPE
slide = prs.slides.add_slide(prs.slide_layouts[6])
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, 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"])
events = slide_spec.get("events") or []
if not events:
return slide
n = len(events)
accent = colors.get("accent", "1668E3")
body_color = colors.get("body", "2D3748")
line_y = Inches(4.0)
line_left = Inches(1.2)
line_right = SLIDE_W - Inches(1.2)
line_len = line_right - line_left
# Horizontal axis bar
add_shape_rect(slide, line_left, line_y - Inches(0.025), line_len, Inches(0.05), accent)
dot_r = Inches(0.18)
label_w = Inches(1.9)
for i, event in enumerate(events):
cx = line_left + (line_len * i / (n - 1) if n > 1 else line_len / 2)
# Oval dot
from pptx.enum.shapes import MSO_SHAPE
dot = slide.shapes.add_shape(MSO_SHAPE.OVAL, cx - dot_r, line_y - dot_r, dot_r * 2, dot_r * 2)
dot.fill.solid()
dot.fill.fore_color.rgb = hex_to_rgb(accent)
dot.line.fill.background()
label = str(event.get("year") or event.get("label") or str(i + 1))
desc = str(event.get("text") or event.get("description") or "")
lx = cx - label_w / 2
if i % 2 == 0:
# Label above line
add_text_box(slide, lx, line_y - Inches(1.35), label_w, Inches(0.5),
label, font_size=Pt(14), color_hex=accent,
bold=True, alignment=PP_ALIGN.CENTER)
if desc:
add_text_box(slide, lx, line_y - Inches(0.85), label_w, Inches(0.55),
desc, font_size=Pt(11), color_hex=body_color,
alignment=PP_ALIGN.CENTER)
else:
# Label below line
add_text_box(slide, lx, line_y + Inches(0.35), label_w, Inches(0.5),
label, font_size=Pt(14), color_hex=accent,
bold=True, alignment=PP_ALIGN.CENTER)
if desc:
add_text_box(slide, lx, line_y + Inches(0.85), label_w, Inches(0.55),
desc, font_size=Pt(11), color_hex=body_color,
alignment=PP_ALIGN.CENTER)
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"
if not filename.endswith(".pptx"):
filename += ".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)
default_skin = spec.get("default_skin") or ""
# Auto-detect dark theme from existing presentation when editing without explicit theme.
# Strategy 1: solid background fill → check luminance.
# Auto-detect dark theme + extract background image from existing presentation.
# When editing without explicit theme, inherit the visual style of the existing slides.
inherited_bg_image = None # path to extracted background_skin image, if any
# Skip auto-detection when default_skin is explicitly provided — derive darkness from skin name.
if is_edit and not spec.get("theme") and default_skin:
if is_dark_skin(default_skin):
is_dark = True
theme = "dark"
elif is_edit and not spec.get("theme"):
try:
from pptx import Presentation as _Prs
_prs_check = _Prs(existing_path)
_detected = False
_bg_image_path = None
# Check each existing slide for background_skin or solid dark fill
for _slide_check in _prs_check.slides:
# Strategy 1: background_skin picture → extract image and reuse
for _sh in _slide_check.shapes:
if _sh.name == "background_skin":
_detected = True
try:
_img = _sh.image
_ext = _img.ext or "jpg"
_bg_image_path = os.path.join(
os.path.dirname(existing_path),
f"_inherited_bg.{_ext}"
)
with open(_bg_image_path, "wb") as _f:
_f.write(_img.blob)
except Exception:
pass
break
if _detected:
break
# Strategy 2: solid dark fill
try:
_bg = _slide_check.background.fill
if str(_bg.type) == "SOLID (1)":
_rgb = _bg.fore_color.rgb
if (int(_rgb[0]) + int(_rgb[1]) + int(_rgb[2])) / 765.0 < 0.4:
_detected = True
break
except Exception:
pass
# Strategy 3: white/light font → dark bg
for _sh in _slide_check.shapes:
if _sh.has_text_frame and _sh.text_frame.text.strip():
try:
_fc = _sh.text_frame.paragraphs[0].runs[0].font.color.rgb
if (int(_fc[0]) + int(_fc[1]) + int(_fc[2])) / 765.0 > 0.7:
_detected = True
except Exception:
pass
break
if _detected:
break
if _detected:
is_dark = True
theme = "dark"
inherited_bg_image = _bg_image_path
print(f"[pptx_gen] edit: auto-detected dark theme, bg_image={_bg_image_path}", file=sys.stderr, flush=True)
except Exception:
pass
# 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)
# 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
# Collect replace_index targets (1-based → 0-based)
replace_targets = {} # slide_spec_index → zero-based position to replace
for i, s in enumerate(slides_spec):
ri = s.get("replace_index")
if ri is not None:
pos = int(ri) - 1
if is_edit and 0 <= pos < len(prs.slides):
replace_targets[i] = pos
else:
warnings.append(f"replace_index {ri} out of range — slide will be appended instead")
# Track the sldIdLst entry added for each spec index (for post-pass reordering)
spec_sld_entries = {} # spec_index → sldIdLst XML element
_SLIDE_TYPES = {"title", "content", "section", "image", "table", "chart", "timeline"}
for spec_i, slide_spec in enumerate(slides_spec):
# Normalize spec variants produced by different models
slide_spec = dict(slide_spec) # shallow copy — don't mutate original
# 1. Infer `type` from `layout` when missing (e.g. Kimi uses layout="chart")
if not slide_spec.get("type"):
raw_layout = (slide_spec.get("layout") or "").lower()
if raw_layout in _SLIDE_TYPES:
slide_spec["type"] = raw_layout
slide_spec.pop("layout", None)
# 2. camelCase chartType → chart_type
if "chartType" in slide_spec and "chart_type" not in slide_spec:
slide_spec["chart_type"] = slide_spec["chartType"]
# 3. Nested table object → flatten (table / table_data both supported)
for _tbl_key in ("table", "table_data"):
if _tbl_key in slide_spec and isinstance(slide_spec.get(_tbl_key), dict):
tbl = slide_spec[_tbl_key]
if "headers" not in slide_spec and "headers" in tbl:
slide_spec["headers"] = tbl["headers"]
if "rows" not in slide_spec and "rows" in tbl:
slide_spec["rows"] = tbl["rows"]
# 4. content field is a list → treat as bullets
if isinstance(slide_spec.get("content"), list) and "bullets" not in slide_spec:
slide_spec["bullets"] = [str(b) for b in slide_spec["content"]]
slide_spec.pop("content", None)
# 5. Sanitize text fields — remove control chars (VT→space, etc.)
for _tf in ("title", "subtitle", "body", "content", "notes"):
if _tf in slide_spec and slide_spec[_tf]:
slide_spec[_tf] = clean_text(slide_spec[_tf])
if "bullets" in slide_spec and isinstance(slide_spec["bullets"], list):
slide_spec["bullets"] = [clean_text(b) for b in slide_spec["bullets"]]
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_img_path = slide_spec.get("bg_image") or "" # workspace-relative image path
bg_image = None
slide_is_dark = is_dark
if bg_img_path:
# Explicit image background from editor picker (workspace-relative path)
bg_path, bg_dark = resolve_skin_bg(bg_img_path, project_dir, workspace_path)
if bg_path:
bg_image = bg_path
slide_is_dark = bg_dark or is_dark
elif 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
# Inherit background image from existing presentation if none specified
if not bg_image and inherited_bg_image and os.path.exists(inherited_bg_image):
bg_image = inherited_bg_image
slide_is_dark = is_dark # use global theme, not always-dark
# 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
# Apply per-slide custom font / text colors
_title_color = slide_spec.get("title_color") or ""
_body_color = slide_spec.get("body_color") or ""
_font_family = slide_spec.get("font_family") or ""
if _title_color or _body_color or _font_family:
slide_colors = dict(slide_colors) # shallow copy — don't mutate global
if _title_color:
slide_colors["title"] = _title_color.lstrip("#")
slide_colors["subtitle"] = _title_color.lstrip("#")
if _body_color:
slide_colors["body"] = _body_color.lstrip("#")
if _font_family:
slide_spec = dict(slide_spec)
slide_spec["_font_override"] = _font_family
# Apply image filter (blur / grayscale / contrast / round)
_dbg_filter = slide_spec.get("img_filter")
_dbg_ipath = slide_spec.get("image_path")
print(f"[pptx_gen] slide img_filter={_dbg_filter!r} image_path={_dbg_ipath!r}", file=sys.stderr, flush=True)
if _dbg_filter and _dbg_ipath:
_resolved_for_fx = resolve_image_path(slide_spec["image_path"], project_dir, workspace_path)
if os.path.exists(_resolved_for_fx):
_fx_path = apply_img_filter(_resolved_for_fx, slide_spec)
if _fx_path != _resolved_for_fx:
slide_spec = dict(slide_spec)
slide_spec["image_path"] = _fx_path
else:
print(f"[pptx_gen] img_filter SKIP: resolved path not found: {_resolved_for_fx!r}", file=sys.stderr, flush=True)
if slide_type == "section":
slide = make_section_slide(prs, slide_spec, slide_colors, fs, bg_image)
elif slide_type == "title":
slide = make_title_slide(prs, slide_spec, spec, slide_colors, slide_is_dark, fs, bg_image,
project_dir, workspace_path, warnings)
elif slide_type == "image":
slide = make_image_slide(prs, slide_spec, project_dir, workspace_path,
slide_colors, warnings, slide_is_dark, fs, bg_image)
elif slide_type == "table":
slide = make_table_slide(prs, slide_spec, slide_colors, slide_is_dark, fs, warnings, bg_image)
elif slide_type == "chart":
slide = make_chart_slide(prs, slide_spec, slide_colors, slide_is_dark, fs, warnings, bg_image)
elif slide_type == "timeline":
slide = make_timeline_slide(prs, slide_spec, slide_colors, slide_is_dark, fs, warnings, 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
# Track the newly added sldIdLst entry for replacement slides
if spec_i in replace_targets:
spec_sld_entries[spec_i] = prs.slides._sldIdLst[-1]
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)
# Post-pass: now that new slides are added (safe filenames), remove old slides
# and move new ones into their target positions.
# IMPORTANT: add-before-remove avoids _next_slide_partname collisions.
if replace_targets and is_edit:
sldIdLst = prs.slides._sldIdLst
# Remove old slides in reverse position order (keeps lower indices stable)
for spec_i in sorted(replace_targets.keys(), key=lambda k: replace_targets[k], reverse=True):
pos = replace_targets[spec_i]
if 0 <= pos < len(prs.slides):
slide_part = prs.slides[pos].part
for rId, rel in list(prs.part.rels.items()):
if rel.reltype.endswith('/slide') and rel.target_partname == slide_part.partname:
prs.part.drop_rel(rId)
break
sldIdLst.remove(sldIdLst[pos])
# Move new slides from end to target positions (ascending order)
for spec_i, target_pos in sorted(replace_targets.items(), key=lambda kv: kv[1]):
entry = spec_sld_entries.get(spec_i)
if entry is not None and entry in sldIdLst:
sldIdLst.remove(entry)
sldIdLst.insert(target_pos, entry)
# 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
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)}/)\nEXACT PATH (use this verbatim for future edits): {rel_for_link}"
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():
parser = argparse.ArgumentParser(description='Generate PPTX from spec JSON')
parser.add_argument('spec_path', help='Path to spec JSON file')
parser.add_argument('workspace_path', nargs='?', default=os.getcwd(), help='Workspace directory')
parser.add_argument('--skin-dir', default=None, help='Override skin directory path')
parser.add_argument('--template-dir', default=None, help='Override template directory path')
args = parser.parse_args()
spec_path = args.spec_path
workspace_path = args.workspace_path
# Override paths if provided by caller (keeps paths.ts as single source of truth)
global SKIN_DIR, TEMPLATE_DIR, SKIN_INDEX
if args.skin_dir:
SKIN_DIR = args.skin_dir
SKIN_INDEX = _build_skin_index()
if args.template_dir:
TEMPLATE_DIR = args.template_dir
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()