Release 2.8.1: pptx-wizard slide editor + fixes
- Fix pptx-wizard slide editor modal opening off-screen (header clipped) when a saved drag position is restored on a fresh page load: clear the CSS centering transform and clamp coordinates into the viewport. - Add pptx-wizard.html and python-runner.html web UIs. - Bump version to 2.8.1. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+275
-39
@@ -51,6 +51,9 @@ 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 ──────────────────────────────────────────────────────────────────────
|
||||
@@ -109,6 +112,7 @@ 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',
|
||||
}
|
||||
@@ -228,6 +232,76 @@ def is_dark_skin(name):
|
||||
|
||||
# ─── 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()
|
||||
@@ -252,19 +326,34 @@ def get_font() -> str:
|
||||
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."""
|
||||
"""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
|
||||
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()
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -276,8 +365,12 @@ def _fit_bullet_font_size(items, width_emu, height_emu, max_pt):
|
||||
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)))
|
||||
total = sum(max(1, math.ceil((len(str(it)) + 2) / chars_per_line)) * line_h
|
||||
for it in items)
|
||||
# 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
|
||||
@@ -285,15 +378,18 @@ def _fit_bullet_font_size(items, width_emu, height_emu, max_pt):
|
||||
|
||||
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."""
|
||||
"""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
|
||||
|
||||
fitted_pt = _fit_bullet_font_size(items, width, height, font_size.pt)
|
||||
# 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:
|
||||
@@ -301,22 +397,76 @@ def add_bullet_list(slide, left, top, width, height, items, font_size=Pt(16),
|
||||
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)
|
||||
p.level = 0
|
||||
|
||||
# Add bullet character manually for reliable rendering
|
||||
bullet_run = p.add_run()
|
||||
bullet_run.text = "● "
|
||||
bullet_run.font.size = fitted_fs
|
||||
bullet_run.font.color.rgb = hex_to_rgb(bullet_color_hex)
|
||||
bullet_run.font.name = font_name or get_font()
|
||||
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 = str(item)
|
||||
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
|
||||
|
||||
|
||||
@@ -339,6 +489,11 @@ def set_slide_bg_image(slide, image_path: str):
|
||||
)
|
||||
# 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):
|
||||
@@ -519,10 +674,18 @@ def _text_weight(slide_spec: dict):
|
||||
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
|
||||
@@ -544,17 +707,21 @@ def make_title_slide(prs, slide_spec, spec, colors, is_dark, fs, bg_image=None):
|
||||
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 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)
|
||||
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"],
|
||||
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 + Inches(1.3), Inches(10.3), Inches(0.7),
|
||||
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"],
|
||||
alignment=PP_ALIGN.CENTER)
|
||||
alignment=PP_ALIGN.CENTER, font_name=_title_font)
|
||||
return slide
|
||||
|
||||
|
||||
@@ -594,6 +761,14 @@ def make_content_slide(prs, slide_spec, project_dir, workspace_path, colors, is_
|
||||
|
||||
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:
|
||||
@@ -609,9 +784,10 @@ def make_content_slide(prs, slide_spec, project_dir, workspace_path, colors, is_
|
||||
bar_shape.fill.fore_color.rgb = hex_to_rgb("000000")
|
||||
bar_shape.fill.transparency = 0.45
|
||||
# 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["slide_title"]),
|
||||
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")
|
||||
@@ -622,13 +798,17 @@ def make_content_slide(prs, slide_spec, project_dir, workspace_path, colors, is_
|
||||
body_text, font_size=Pt(fs["body"]), color_hex="E0E0E0")
|
||||
return slide
|
||||
|
||||
content_y = Inches(1.5) if has_title else Inches(0.5)
|
||||
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(fs["slide_title"]),
|
||||
color_hex=colors["title"], bold=True)
|
||||
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"])
|
||||
|
||||
@@ -698,17 +878,33 @@ def make_content_slide(prs, slide_spec, project_dir, workspace_path, colors, is_
|
||||
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"])
|
||||
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"])
|
||||
body_text, font_size=body_fs, color_hex=colors["body"],
|
||||
font_name=_slide_font)
|
||||
|
||||
# ── Image column ──────────────────────────────────────────────────────────
|
||||
if has_image and img_col_x is not None:
|
||||
# ── Image column / free position ─────────────────────────────────────────
|
||||
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"])
|
||||
# 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
|
||||
|
||||
@@ -1226,22 +1422,33 @@ def generate(spec: dict, workspace_path: str) -> dict:
|
||||
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_name:
|
||||
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
|
||||
else:
|
||||
# Not a skin name — might be a file path already resolved
|
||||
pass
|
||||
|
||||
# Inherit background image from existing presentation if none specified
|
||||
if not bg_image and inherited_bg_image and os.path.exists(inherited_bg_image):
|
||||
@@ -1256,6 +1463,35 @@ def generate(spec: dict, workspace_path: str) -> dict:
|
||||
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":
|
||||
|
||||
Reference in New Issue
Block a user