This commit is contained in:
kim
2026-04-29 17:34:36 +09:00
parent ee10549c5a
commit 4324985508
5 changed files with 108 additions and 45 deletions
+46 -14
View File
@@ -355,30 +355,33 @@ def fit_dimensions(img_w, img_h, max_w, max_h, mode="contain"):
def _ensure_compatible_image(image_path: str) -> str:
"""Convert WebP or other unsupported formats to PNG for python-pptx compatibility.
"""Convert unsupported image formats to PNG for python-pptx compatibility.
python-pptx relies on PIL for image analysis, and PowerPoint/OOXML does not
reliably support WebP. Converting to PNG before embedding avoids silent failures.
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).
"""
ext = os.path.splitext(image_path)[1].lower()
supported = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.emf', '.wmf'}
if ext in supported:
return image_path
# Formats that PowerPoint can embed directly
PPTX_SUPPORTED = {'JPEG', 'PNG', 'GIF', 'BMP', 'TIFF'}
# Attempt PIL conversion to PNG
try:
from PIL import Image as PILImage
with PILImage.open(image_path) as img:
# RGBA or P mode can cause issues with some PowerPoint viewers; convert to RGB
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:
# If conversion fails, return original and let downstream handle the failure
# PIL can't open it — return as-is and let downstream handle the failure
return image_path
@@ -392,18 +395,23 @@ def add_image_or_placeholder(slide, image_path, left, top, max_width, max_height
"""
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:
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)
@@ -423,9 +431,13 @@ def add_image_or_placeholder(slide, image_path, left, top, max_width, max_height
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:
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}")
@@ -434,20 +446,28 @@ def add_image_or_placeholder(slide, image_path, left, top, max_width, max_height
def resolve_image_path(image_path: str, project_dir: str, workspace_path: str) -> str:
"""Resolve image path: check absolute, then project_dir, then workspace, then search by filename."""
if os.path.isabs(image_path):
return image_path
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:
return os.path.join(root, f)
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)
@@ -820,6 +840,9 @@ def generate(spec: dict, workspace_path: str) -> dict:
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"}
@@ -833,6 +856,15 @@ def generate(spec: dict, workspace_path: str) -> dict:
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)
# Download image_url slides into project_dir
_download_images(slides_spec, project_dir, warnings)