from __future__ import annotations import hashlib import json import re import statistics import sys import zipfile from collections import Counter from pathlib import Path from xml.etree import ElementTree as ET SOURCE_DIR = Path(sys.argv[1] if len(sys.argv) > 1 else r"H:\音乐教材PPT") OUTPUT_PATH = Path(sys.argv[2] if len(sys.argv) > 2 else "artifacts/music-template-extraction/candidates.json") P_NS = "http://schemas.openxmlformats.org/presentationml/2006/main" A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main" def xml_root(archive: zipfile.ZipFile, name: str): try: return ET.fromstring(archive.read(name)) except (KeyError, ET.ParseError): return None def classify_scene(path: Path) -> str: name = path.stem if re.search(r"京剧|戏曲|脸谱|古琴|古筝|二胡|琵琶|民乐|民族|非遗|传统|江南|忆江南|茉莉花|采茶|山水|姑苏|十面埋伏|龙舟|赛马|彩云追月|阳关三叠", name): return "traditional" if re.search(r"节奏|节拍|律动|游戏|活动|表演|舞蹈|打击乐|鼓|拍手|声势|合奏|创编", name): return "activity" if re.search(r"交响|管弦|音乐会|世界|爵士|舞台|歌剧|圆舞曲", name): return "stage" if re.search(r"结构|旋律|乐理|和声|调式|音程|项目|探究", name): return "theory" if re.search(r"童话|动物|动画|小鸟|小猫|小狗|森林|小屋|儿歌|娃娃|小朋友|儿童|春天|郊游|梦|星星|月亮", name): return "child" return "general" def normalized_name(path: Path) -> str: value = re.sub(r"[\s_\-—]+", "", path.stem.lower()) value = re.sub(r"(?:课件|新版|新教材|音乐|第?\d+课时|\(\d+\)|(\d+)|\d+)$", "", value) return value[:80] def inspect(path: Path) -> dict: result = { "path": str(path), "name": path.stem, "scene": classify_scene(path), "size_mb": round(path.stat().st_size / 1024 / 1024, 2), "valid": False, "score": 0, } try: with zipfile.ZipFile(path) as archive: names = archive.namelist() presentation = xml_root(archive, "ppt/presentation.xml") if presentation is None: result["error"] = "missing-presentation" return result size_node = presentation.find(f"{{{P_NS}}}sldSz") cx = int(size_node.attrib.get("cx", 0)) if size_node is not None else 0 cy = int(size_node.attrib.get("cy", 0)) if size_node is not None else 0 ratio = cx / cy if cy else 0 slide_names = sorted( (name for name in names if re.fullmatch(r"ppt/slides/slide\d+\.xml", name)), key=lambda name: int(re.search(r"\d+", Path(name).stem).group()), ) master_count = sum(bool(re.fullmatch(r"ppt/slideMasters/slideMaster\d+\.xml", name)) for name in names) layout_count = sum(bool(re.fullmatch(r"ppt/slideLayouts/slideLayout\d+\.xml", name)) for name in names) image_entries = [entry for entry in archive.infolist() if entry.filename.startswith("ppt/media/") and not re.search(r"\.(mp3|mp4|wav|wma|m4a|mov|avi)$", entry.filename, re.I)] media_entries = [entry for entry in archive.infolist() if entry.filename.startswith("ppt/media/") and re.search(r"\.(mp3|mp4|wav|wma|m4a|mov|avi)$", entry.filename, re.I)] text_counts = [] shape_counts = [] picture_counts = [] fill_colors = Counter() signatures = [] for slide_name in slide_names: root = xml_root(archive, slide_name) if root is None: continue text = "".join(node.text or "" for node in root.iter(f"{{{A_NS}}}t")) text_counts.append(len(text.strip())) shapes = len(list(root.iter(f"{{{P_NS}}}sp"))) pictures = len(list(root.iter(f"{{{P_NS}}}pic"))) shape_counts.append(shapes) picture_counts.append(pictures) colors = [node.attrib.get("val", "").upper() for node in root.iter(f"{{{A_NS}}}srgbClr") if node.attrib.get("val")] fill_colors.update(colors) signatures.append(f"{shapes}:{pictures}:{min(len(text), 300)}:{','.join(colors[:8])}") slide_count = len(slide_names) median_text = statistics.median(text_counts) if text_counts else 0 median_shapes = statistics.median(shape_counts) if shape_counts else 0 median_pictures = statistics.median(picture_counts) if picture_counts else 0 unique_layout_ratio = len(set(signatures)) / max(1, slide_count) common_colors = [color for color, _ in fill_colors.most_common(8)] visual_signature = hashlib.sha1((str(round(ratio, 3)) + "|" + "|".join(common_colors) + "|" + str(layout_count)).encode()).hexdigest()[:16] score = 0.0 score += 18 if 1.74 <= ratio <= 1.79 else -18 score += min(16, slide_count * 0.7) if slide_count >= 8 else -12 score += min(12, layout_count * 1.2) score += min(8, master_count * 2) score += min(12, median_shapes * 0.9) score += min(10, median_pictures * 2.2) score += min(10, unique_layout_ratio * 16) score += 7 if 3 <= len(common_colors) <= 8 else 1 score += 5 if 12 <= median_text <= 120 else (-6 if median_text > 260 else 0) score -= min(10, len(media_entries) * 0.35) score -= 5 if path.stat().st_size > 650 * 1024 * 1024 else 0 result.update( valid=True, score=round(score, 2), ratio=round(ratio, 4), slides=slide_count, masters=master_count, layouts=layout_count, images=len(image_entries), media=len(media_entries), median_text=median_text, median_shapes=median_shapes, median_pictures=median_pictures, unique_layout_ratio=round(unique_layout_ratio, 3), colors=common_colors, signature=visual_signature, name_key=normalized_name(path), ) except (OSError, zipfile.BadZipFile, RuntimeError) as error: result["error"] = str(error) return result def main() -> None: files = sorted(SOURCE_DIR.rglob("*.pptx")) candidates = [] for index, path in enumerate(files, 1): candidates.append(inspect(path)) if index % 100 == 0: print(f"scanned {index}/{len(files)}", flush=True) valid = [item for item in candidates if item["valid"]] valid.sort(key=lambda item: item["score"], reverse=True) OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) OUTPUT_PATH.write_text(json.dumps({"source": str(SOURCE_DIR), "total": len(files), "valid": len(valid), "items": valid}, ensure_ascii=False, indent=2), encoding="utf-8") print(json.dumps({"total": len(files), "valid": len(valid), "output": str(OUTPUT_PATH), "top_score": valid[0]["score"] if valid else None}, ensure_ascii=False)) if __name__ == "__main__": main()