v1.0.4
This commit is contained in:
+46
-14
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user