from __future__ import annotations import json import sys from io import BytesIO from pathlib import Path from PIL import Image, ImageDraw, ImageFont from pptx import Presentation from pptx.enum.shapes import MSO_SHAPE_TYPE CANDIDATES_PATH = Path(sys.argv[1] if len(sys.argv) > 1 else "artifacts/music-template-extraction/candidates.json") OUTPUT_DIR = Path(sys.argv[2] if len(sys.argv) > 2 else "artifacts/music-template-extraction/review") QUOTAS = {"child": 7, "general": 12, "traditional": 7, "activity": 7, "stage": 7, "theory": 7} CANVAS = (1600, 900) FONT_PATH = Path(r"C:\Windows\Fonts\msyh.ttc") def color_value(color, fallback: str = "FFFFFF") -> str: try: rgb = color.rgb return f"#{rgb}" if rgb else f"#{fallback}" except (AttributeError, TypeError, ValueError): return f"#{fallback}" def selected_candidates(data: dict) -> list[dict]: selected: list[dict] = [] seen_names: set[str] = set() seen_signatures: set[str] = set() for scene, quota in QUOTAS.items(): eligible = sorted( ( item for item in data["items"] if item["scene"] == scene and item["score"] >= 78 and 10 <= item["slides"] <= 60 and item["size_mb"] <= 250 and item["median_text"] <= 120 ), key=lambda item: item["score"], reverse=True, ) count = 0 for item in eligible: if count >= quota: break if item["name_key"] in seen_names or item["signature"] in seen_signatures: continue seen_names.add(item["name_key"]) seen_signatures.add(item["signature"]) selected.append(item) count += 1 return selected def dimensions(shape, slide_width: int, slide_height: int) -> tuple[int, int, int, int]: left = round(shape.left / slide_width * CANVAS[0]) top = round(shape.top / slide_height * CANVAS[1]) width = max(1, round(shape.width / slide_width * CANVAS[0])) height = max(1, round(shape.height / slide_height * CANVAS[1])) return left, top, width, height def paste_picture(canvas: Image.Image, shape, box: tuple[int, int, int, int]) -> None: try: with Image.open(BytesIO(shape.image.blob)) as source: image = source.convert("RGBA") crop = ( round(image.width * shape.crop_left), round(image.height * shape.crop_top), round(image.width * (1 - shape.crop_right)), round(image.height * (1 - shape.crop_bottom)), ) image = image.crop(crop).resize((box[2], box[3]), Image.Resampling.LANCZOS) canvas.alpha_composite(image, (box[0], box[1])) except (OSError, ValueError, AttributeError): return def font_for(size: int, bold: bool = False) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: paths = [Path(r"C:\Windows\Fonts\msyhbd.ttc") if bold else FONT_PATH, FONT_PATH] for path in paths: try: return ImageFont.truetype(str(path), max(13, min(size, 74))) except OSError: continue return ImageFont.load_default() def wrap_text(draw: ImageDraw.ImageDraw, text: str, font, width: int, max_lines: int) -> list[str]: lines: list[str] = [] for paragraph in (text or "").splitlines(): current = "" for char in paragraph.strip(): candidate = current + char if current and draw.textbbox((0, 0), candidate, font=font)[2] > width: lines.append(current) current = char if len(lines) >= max_lines: return lines else: current = candidate if current: lines.append(current) if len(lines) >= max_lines: break return lines[:max_lines] def draw_text(canvas: Image.Image, shape, box: tuple[int, int, int, int]) -> None: text = getattr(shape, "text", "").strip() if not text: return draw = ImageDraw.Draw(canvas) first_run = None try: first_run = shape.text_frame.paragraphs[0].runs[0] except (AttributeError, IndexError): pass point_size = round((first_run.font.size.pt if first_run and first_run.font.size else 24) * 1.55) bold = bool(first_run and first_run.font.bold) font = font_for(point_size, bold) color = color_value(first_run.font.color if first_run else None, "222222") line_height = max(18, round(point_size * 1.28)) lines = wrap_text(draw, text, font, max(20, box[2] - 18), max(1, box[3] // line_height)) draw.multiline_text((box[0] + 9, box[1] + 6), "\n".join(lines), font=font, fill=color, spacing=round(point_size * 0.22)) def draw_shape(canvas: Image.Image, shape, slide_width: int, slide_height: int) -> None: box = dimensions(shape, slide_width, slide_height) if shape.shape_type == MSO_SHAPE_TYPE.PICTURE: paste_picture(canvas, shape, box) return if shape.shape_type == MSO_SHAPE_TYPE.GROUP: for child in shape.shapes: draw_shape(canvas, child, slide_width, slide_height) return if shape.shape_type == MSO_SHAPE_TYPE.MEDIA: return draw = ImageDraw.Draw(canvas, "RGBA") try: if shape.fill.type is not None: fill = color_value(shape.fill.fore_color, "FFFFFF") draw.rounded_rectangle((box[0], box[1], box[0] + box[2], box[1] + box[3]), radius=min(22, box[3] // 5), fill=fill) except (AttributeError, TypeError, ValueError): pass if getattr(shape, "has_text_frame", False): draw_text(canvas, shape, box) def render_slide(deck: Presentation, slide_number: int, output: Path) -> None: slide = deck.slides[slide_number - 1] background = "#FFFFFF" try: background = color_value(slide.background.fill.fore_color) except (AttributeError, TypeError, ValueError): pass canvas = Image.new("RGBA", CANVAS, background) for shape in slide.shapes: draw_shape(canvas, shape, deck.slide_width, deck.slide_height) output.parent.mkdir(parents=True, exist_ok=True) canvas.convert("RGB").save(output, quality=88, optimize=True) def main() -> None: data = json.loads(CANDIDATES_PATH.read_text(encoding="utf-8")) rendered: list[dict] = [] for index, item in enumerate(selected_candidates(data), start=1): candidate_id = f"candidate-{index:02d}" try: deck = Presentation(item["path"]) count = len(deck.slides) slide_numbers = sorted({1, max(2, round(count * 0.25)), max(2, round(count * 0.5)), max(2, round(count * 0.75)), count}) samples = [] for slide_number in slide_numbers: name = f"slide-{slide_number:02d}.jpg" render_slide(deck, slide_number, OUTPUT_DIR / candidate_id / name) samples.append(name) rendered.append({ "id": candidate_id, "name": item["name"], "scene": item["scene"], "score": item["score"], "source": item["path"], "samples": samples, }) print(f"{candidate_id} {item['scene']} {item['name']}", flush=True) except (OSError, ValueError, KeyError) as error: print(f"{candidate_id} failed: {error}", flush=True) OUTPUT_DIR.mkdir(parents=True, exist_ok=True) (OUTPUT_DIR / "manifest.json").write_text(json.dumps({"items": rendered}, ensure_ascii=False, indent=2), encoding="utf-8") print(json.dumps({"rendered": len(rendered), "output": str(OUTPUT_DIR)}, ensure_ascii=False)) if __name__ == "__main__": main()