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>
This commit is contained in:
kim
2026-05-30 20:42:25 +09:00
co-authored by Claude Opus 4.8
parent 957a13f98c
commit 949a910ad6
2 changed files with 50 additions and 12 deletions
+49 -11
View File
@@ -506,6 +506,26 @@ def add_shape_rect(slide, left, top, width, height, color_hex: str):
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.
@@ -697,12 +717,31 @@ def _auto_text_ratio(slide_spec: dict) -> float:
# ─── Slide Generators ──────────────────────────────────────────────────────────
def make_title_slide(prs, slide_spec, spec, colors, is_dark, fs, bg_image=None):
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
# Background
if bg_image:
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"])
@@ -710,17 +749,17 @@ def make_title_slide(prs, slide_spec, spec, colors, is_dark, fs, bg_image=None):
_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 else Inches(2.5)
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=colors["title"],
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=colors["subtitle"],
subtitle, font_size=Pt(fs["subtitle"]), color_hex=subtitle_col,
alignment=PP_ALIGN.CENTER, font_name=_title_font)
return slide
@@ -779,10 +818,8 @@ def make_content_slide(prs, slide_spec, project_dir, workspace_path, colors, is_
# Semi-transparent dark bar at bottom for text legibility
bar_h = Inches(2.0)
bar_y = SLIDE_H - bar_h
add_shape_rect(slide, Inches(0), bar_y, SLIDE_W, bar_h, "000000")
bar_shape = slide.shapes[-1]
bar_shape.fill.fore_color.rgb = hex_to_rgb("000000")
bar_shape.fill.transparency = 0.45
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:
@@ -1495,7 +1532,8 @@ def generate(spec: dict, workspace_path: str) -> dict:
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)
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)