| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195 |
- 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()
|