sanitize_music_templates.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. from __future__ import annotations
  2. import os
  3. import sys
  4. import time
  5. import zipfile
  6. import xml.etree.ElementTree as ET
  7. from pathlib import Path
  8. from pptx import Presentation
  9. from pptx.enum.shapes import MSO_SHAPE_TYPE
  10. TARGET_DIR = Path("public/ai-courseware-templates")
  11. TEMPLATES = [
  12. (
  13. "fairytale-forest.pptx",
  14. Path(r"H:\音乐教材PPT\人教版(2024)\1年级下册(新)\【2025新教材】人教版音乐一年级下册-《动画音乐会》-课件.pptx"),
  15. ),
  16. ("warm-handdrawn.pptx", Path(r"H:\音乐教材PPT\人音版(2024)\3年级上册(新)\音乐小屋.pptx")),
  17. (
  18. "music-doodle.pptx",
  19. Path(r"H:\音乐教材PPT\人音版(2024)\7年级上册(新)\课件《溜冰圆舞曲》新版人音版七年级上册第二单元.pptx"),
  20. ),
  21. ("ink-wash.pptx", Path(r"H:\音乐教材PPT\人音版(2024)\8年级上册(新)\《姑苏行》《十面埋伏》.pptx")),
  22. ("stage-documentary.pptx", Path(r"H:\音乐教材PPT\人音版(2024)\8年级上册(新)\《鼓乐》.pptx")),
  23. (
  24. "modern-geometry.pptx",
  25. Path(r"H:\音乐教材PPT\人教版(2024)\8年级上册(新)\学习项目二 探索旋律结构的规律.pptx"),
  26. ),
  27. ]
  28. def select_slide_indexes(total: int) -> set[int]:
  29. """保留正文版式,避开通常包含版权页的首尾页。"""
  30. if total <= 4:
  31. return set(range(total))
  32. candidates = [1, round(total * 0.35), round(total * 0.6), total - 2]
  33. return {min(total - 1, max(0, index)) for index in candidates}
  34. def delete_shape(shape) -> None:
  35. element = shape._element
  36. element.getparent().remove(element)
  37. def clear_shape_text(shape, ordinal: list[int]) -> None:
  38. if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
  39. for child in list(shape.shapes):
  40. clear_shape_text(child, ordinal)
  41. return
  42. if shape.shape_type in {MSO_SHAPE_TYPE.MEDIA, MSO_SHAPE_TYPE.WEB_VIDEO}:
  43. delete_shape(shape)
  44. return
  45. if not getattr(shape, "has_text_frame", False):
  46. return
  47. text_frame = shape.text_frame
  48. if not text_frame.text.strip():
  49. return
  50. text_frame.clear()
  51. placeholder = "章节标题" if ordinal[0] == 0 else "在此填写教学内容"
  52. paragraph = text_frame.paragraphs[0]
  53. paragraph.text = placeholder
  54. ordinal[0] += 1
  55. def remove_media_relationships(slide) -> None:
  56. for relation_id, relation in list(slide.part.rels.items()):
  57. relation_type = relation.reltype.lower()
  58. if any(token in relation_type for token in ("audio", "video", "media")):
  59. slide.part.drop_rel(relation_id)
  60. def remove_notes_parts(path: Path) -> None:
  61. """删除备注、课件标题等不可见信息,避免原课程信息随模板保留。"""
  62. temp_path = path.with_suffix(".cleaning.pptx")
  63. with zipfile.ZipFile(path, "r") as source, zipfile.ZipFile(temp_path, "w", zipfile.ZIP_DEFLATED) as target:
  64. for entry in source.infolist():
  65. name = entry.filename
  66. if name.startswith(("ppt/notesSlides/", "ppt/notesMasters/")) or name == "docProps/app.xml":
  67. continue
  68. content = source.read(entry)
  69. if name.endswith(".rels"):
  70. root = ET.fromstring(content)
  71. for relation in list(root):
  72. relation_type = relation.attrib.get("Type", "")
  73. if relation_type.endswith(("/notesSlide", "/notesMaster", "/extended-properties")):
  74. root.remove(relation)
  75. content = ET.tostring(root, encoding="utf-8", xml_declaration=True)
  76. elif name == "[Content_Types].xml":
  77. root = ET.fromstring(content)
  78. for override in list(root):
  79. part_name = override.attrib.get("PartName", "")
  80. if "notesSlides" in part_name or "notesMasters" in part_name or part_name == "/docProps/app.xml":
  81. root.remove(override)
  82. content = ET.tostring(root, encoding="utf-8", xml_declaration=True)
  83. elif name.startswith("ppt/slides/slide") and name.endswith(".xml"):
  84. root = ET.fromstring(content)
  85. presentation_ns = "{http://schemas.openxmlformats.org/presentationml/2006/main}"
  86. drawing_ns = "{http://schemas.openxmlformats.org/drawingml/2006/main}"
  87. media_shape_tags = {
  88. f"{drawing_ns}audioFile",
  89. f"{drawing_ns}videoFile",
  90. "{http://schemas.microsoft.com/office/powerpoint/2010/main}media"
  91. }
  92. for parent in root.iter():
  93. for child in list(parent):
  94. if child.tag != f"{presentation_ns}pic":
  95. continue
  96. if any(node.tag in media_shape_tags for node in child.iter()):
  97. parent.remove(child)
  98. for text_node in root.iter("{http://schemas.openxmlformats.org/drawingml/2006/main}t"):
  99. if text_node.text and text_node.text.strip():
  100. text_node.text = "在此填写教学内容"
  101. for properties in root.iter(f"{presentation_ns}cNvPr"):
  102. properties.attrib.pop("descr", None)
  103. properties.attrib.pop("title", None)
  104. content = ET.tostring(root, encoding="utf-8", xml_declaration=True)
  105. target.writestr(entry, content)
  106. for attempt in range(5):
  107. try:
  108. os.replace(temp_path, path)
  109. return
  110. except PermissionError:
  111. if attempt == 4:
  112. raise
  113. time.sleep(1)
  114. def retain_selected_slides(presentation: Presentation, keep_indexes: set[int]) -> None:
  115. for index in reversed(range(len(presentation.slides))):
  116. if index in keep_indexes:
  117. continue
  118. relation_id = presentation.slides._sldIdLst[index].rId
  119. presentation.part.drop_rel(relation_id)
  120. del presentation.slides._sldIdLst[index]
  121. def sanitize(source: Path, output: Path) -> None:
  122. presentation = Presentation(source)
  123. retain_selected_slides(presentation, select_slide_indexes(len(presentation.slides)))
  124. for slide in presentation.slides:
  125. ordinal = [0]
  126. for shape in list(slide.shapes):
  127. clear_shape_text(shape, ordinal)
  128. remove_media_relationships(slide)
  129. presentation.core_properties.author = "系统模板"
  130. presentation.core_properties.last_modified_by = "系统模板"
  131. presentation.core_properties.subject = "音乐课件模板"
  132. presentation.core_properties.keywords = ""
  133. presentation.core_properties.comments = ""
  134. presentation.save(output)
  135. remove_notes_parts(output)
  136. def main() -> None:
  137. TARGET_DIR.mkdir(parents=True, exist_ok=True)
  138. if "--clean-output" in sys.argv:
  139. for output in sorted(TARGET_DIR.glob("*.pptx")):
  140. remove_notes_parts(output)
  141. print(f"{output.name}\t{output.stat().st_size / 1024 / 1024:.1f} MB")
  142. return
  143. for filename, source in TEMPLATES:
  144. output = TARGET_DIR / filename
  145. sanitize(source, output)
  146. print(f"{filename}\t{output.stat().st_size / 1024 / 1024:.1f} MB")
  147. if __name__ == "__main__":
  148. main()