dsh-courseware 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -0
  3. package/cordis.patch.yml +9 -0
  4. package/engine/pptxgen/__init__.py +8 -0
  5. package/engine/pptxgen/__main__.py +4 -0
  6. package/engine/pptxgen/builtin_template.py +218 -0
  7. package/engine/pptxgen/cli.py +575 -0
  8. package/engine/pptxgen/examples.py +290 -0
  9. package/engine/pptxgen/imagegen.py +564 -0
  10. package/engine/pptxgen/layoutmap.py +211 -0
  11. package/engine/pptxgen/patterns.py +106 -0
  12. package/engine/pptxgen/preview.py +126 -0
  13. package/engine/pptxgen/profile.py +456 -0
  14. package/engine/pptxgen/render.py +1673 -0
  15. package/engine/pptxgen/review.py +184 -0
  16. package/engine/pptxgen/schemacheck.py +122 -0
  17. package/engine/pptxgen/schemas/XAdES.xsd +466 -0
  18. package/engine/pptxgen/schemas/XAdESv141.xsd +15 -0
  19. package/engine/pptxgen/schemas/chartEx.xsd +838 -0
  20. package/engine/pptxgen/schemas/dml-chart.xsd +1499 -0
  21. package/engine/pptxgen/schemas/dml-chartDrawing.xsd +146 -0
  22. package/engine/pptxgen/schemas/dml-diagram.xsd +1085 -0
  23. package/engine/pptxgen/schemas/dml-drawing.xsd +63 -0
  24. package/engine/pptxgen/schemas/dml-lockedCanvas.xsd +11 -0
  25. package/engine/pptxgen/schemas/dml-main.xsd +3081 -0
  26. package/engine/pptxgen/schemas/dml-picture.xsd +23 -0
  27. package/engine/pptxgen/schemas/dml-spreadsheetDrawing.xsd +185 -0
  28. package/engine/pptxgen/schemas/dml-wordprocessingDrawing.xsd +287 -0
  29. package/engine/pptxgen/schemas/drawing-chart2012.xsd +129 -0
  30. package/engine/pptxgen/schemas/markup-compatibility.xsd +95 -0
  31. package/engine/pptxgen/schemas/opc-digSig.xsd +49 -0
  32. package/engine/pptxgen/schemas/opc-relationships.xsd +33 -0
  33. package/engine/pptxgen/schemas/pml.xsd +1676 -0
  34. package/engine/pptxgen/schemas/shared-additionalCharacteristics.xsd +28 -0
  35. package/engine/pptxgen/schemas/shared-bibliography.xsd +144 -0
  36. package/engine/pptxgen/schemas/shared-commonSimpleTypes.xsd +172 -0
  37. package/engine/pptxgen/schemas/shared-customXmlDataProperties.xsd +25 -0
  38. package/engine/pptxgen/schemas/shared-customXmlSchemaProperties.xsd +18 -0
  39. package/engine/pptxgen/schemas/shared-documentPropertiesCustom.xsd +59 -0
  40. package/engine/pptxgen/schemas/shared-documentPropertiesExtended.xsd +56 -0
  41. package/engine/pptxgen/schemas/shared-documentPropertiesVariantTypes.xsd +195 -0
  42. package/engine/pptxgen/schemas/shared-math.xsd +582 -0
  43. package/engine/pptxgen/schemas/shared-relationshipReference.xsd +25 -0
  44. package/engine/pptxgen/schemas/signatureInfo.xsd +103 -0
  45. package/engine/pptxgen/schemas/sml.xsd +4439 -0
  46. package/engine/pptxgen/schemas/visio.xsd +829 -0
  47. package/engine/pptxgen/schemas/vml-main.xsd +570 -0
  48. package/engine/pptxgen/schemas/vml-officeDrawing.xsd +509 -0
  49. package/engine/pptxgen/schemas/vml-presentationDrawing.xsd +12 -0
  50. package/engine/pptxgen/schemas/vml-spreadsheetDrawing.xsd +108 -0
  51. package/engine/pptxgen/schemas/vml-wordprocessingDrawing.xsd +96 -0
  52. package/engine/pptxgen/schemas/vmlDrawing.xsd +36 -0
  53. package/engine/pptxgen/schemas/wml.xsd +3643 -0
  54. package/engine/pptxgen/schemas/word12.xsd +66 -0
  55. package/engine/pptxgen/schemas/xlThreadedComments.xsd +59 -0
  56. package/engine/pptxgen/schemas/xlThreadedComments2.xsd +22 -0
  57. package/engine/pptxgen/schemas/xmldsig-core-schema.xsd +318 -0
  58. package/engine/pptxgen/shapes.py +417 -0
  59. package/engine/pptxgen/spec.py +529 -0
  60. package/engine/pptxgen/template.py +834 -0
  61. package/engine/pptxgen/util.py +218 -0
  62. package/engine/pptxgen/xmlutil.py +298 -0
  63. package/engine/pptxgen.sh +7 -0
  64. package/engine/requirements.txt +9 -0
  65. package/lib/index.js +186 -0
  66. package/package.json +34 -0
  67. package/skills/courseware/SKILL.md +130 -0
  68. package/skills/courseware/references/content-polish.md +121 -0
  69. package/skills/courseware/references/deck-spec.md +155 -0
  70. package/skills/courseware/references/design-review.md +121 -0
  71. package/skills/courseware/references/image-backends.md +94 -0
  72. package/skills/courseware/references/quality-gates.md +71 -0
  73. package/skills/courseware/references/roadmap.md +55 -0
  74. package/skills/courseware/references/template-fidelity.md +100 -0
  75. package/skills/courseware/workflows/generate.md +172 -0
  76. package/skills/courseware/workflows/quick.md +41 -0
  77. package/skills/courseware/workflows/revise.md +40 -0
  78. package/skills/courseware/workflows/routing.md +49 -0
  79. package/skills/courseware/workflows/stages/generate-images.md +74 -0
  80. package/skills/courseware/workflows/template-intake.md +112 -0
@@ -0,0 +1,529 @@
1
+ """课件内容规格:解析与规整 YAML / JSON / Markdown 大纲。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ import yaml
11
+
12
+ from .util import clean_lines
13
+
14
+ # --------------------------------------------------------------------------
15
+ # 页型定义
16
+ # --------------------------------------------------------------------------
17
+ TYPE_ALIASES = {
18
+ "cover": "cover", "封面": "cover", "首页": "cover", "title": "cover",
19
+ "toc": "toc", "目录": "toc", "contents": "toc", "agenda": "toc",
20
+ "section": "section", "章节": "section", "过渡": "section", "divider": "section",
21
+ "objectives": "objectives", "目标": "objectives", "学习目标": "objectives",
22
+ "重难点": "objectives", "objective": "objectives", "goals": "objectives",
23
+ "content": "content", "内容": "content", "知识点": "content", "正文": "content",
24
+ "point": "content", "knowledge": "content",
25
+ "practice": "practice", "练习": "practice", "习题": "practice", "检测": "practice",
26
+ "quiz": "practice", "exercise": "practice", "example": "example", "例题": "example",
27
+ "summary": "summary", "小结": "summary", "总结": "summary", "课堂小结": "summary",
28
+ "思维导图": "summary", "mindmap": "summary",
29
+ "homework": "homework", "作业": "homework", "课后作业": "homework",
30
+ "end": "end", "结束": "end", "谢谢": "end", "thanks": "end",
31
+ }
32
+
33
+ KNOWN_TYPES = {
34
+ "cover", "toc", "section", "objectives", "content",
35
+ "example", "practice", "summary", "homework", "end",
36
+ }
37
+
38
+ TYPE_LABELS = {
39
+ "cover": "封面",
40
+ "toc": "目录",
41
+ "section": "章节过渡",
42
+ "objectives": "学习目标",
43
+ "content": "知识点",
44
+ "example": "例题",
45
+ "practice": "课堂练习",
46
+ "summary": "课堂小结",
47
+ "homework": "课后作业",
48
+ "end": "结束页",
49
+ }
50
+
51
+
52
+ class SpecError(Exception):
53
+ pass
54
+
55
+
56
+ def normalize_type(value: Optional[str], default: str = "content") -> str:
57
+ if not value:
58
+ return default
59
+ key = str(value).strip().lower()
60
+ if key in TYPE_ALIASES:
61
+ return TYPE_ALIASES[key]
62
+ raw = str(value).strip()
63
+ if raw in TYPE_ALIASES:
64
+ return TYPE_ALIASES[raw]
65
+ if key in KNOWN_TYPES:
66
+ return key
67
+ return default
68
+
69
+
70
+ # --------------------------------------------------------------------------
71
+ # 字段规整
72
+ # --------------------------------------------------------------------------
73
+ def norm_bullets(value) -> List[dict]:
74
+ """统一成 [{'text': str, 'level': int}]。"""
75
+ out: List[dict] = []
76
+ for item in clean_lines(value):
77
+ if isinstance(item, dict):
78
+ text = item.get("text") or item.get("title") or item.get("q") or ""
79
+ level = int(item.get("level", 0) or 0)
80
+ entry = {"text": str(text), "level": max(0, min(level, 3))}
81
+ for k, v in item.items():
82
+ if k not in ("text", "level"):
83
+ entry[k] = v
84
+ out.append(entry)
85
+ else:
86
+ text = str(item)
87
+ stripped = text.lstrip()
88
+ indent = len(text) - len(stripped)
89
+ # 支持 " - 子项" 这种手工缩进
90
+ level = 1 if indent >= 2 and stripped[:1] not in ("",) else 0
91
+ text = re.sub(r"^[-*•]\s*", "", stripped)
92
+ out.append({"text": text, "level": level})
93
+ return out
94
+
95
+
96
+ def norm_questions(value) -> List[dict]:
97
+ out = []
98
+ for item in value or []:
99
+ if isinstance(item, str):
100
+ out.append({"q": item, "options": [], "answer": "", "analysis": ""})
101
+ continue
102
+ if not isinstance(item, dict):
103
+ continue
104
+ q = item.get("q") or item.get("question") or item.get("text") or ""
105
+ opts = item.get("options") or item.get("choices") or []
106
+ answer = item.get("answer") or item.get("ans") or ""
107
+ analysis = item.get("analysis") or item.get("explain") or item.get("note") or ""
108
+ entry = {
109
+ "q": str(q),
110
+ "options": [str(o) for o in (opts if isinstance(opts, (list, tuple)) else [opts])],
111
+ "answer": str(answer),
112
+ "analysis": str(analysis),
113
+ }
114
+ for k, v in item.items():
115
+ if k not in entry:
116
+ entry[k] = v
117
+ out.append(entry)
118
+ return out
119
+
120
+
121
+ def norm_mindmap(value, fallback_points=None) -> dict:
122
+ if isinstance(value, dict):
123
+ center = value.get("center") or value.get("title") or ""
124
+ branches = []
125
+ for b in value.get("branches") or []:
126
+ if isinstance(b, dict):
127
+ branches.append({
128
+ "title": str(b.get("title") or b.get("name") or ""),
129
+ "items": [str(i) for i in (b.get("items") or b.get("points") or [])],
130
+ })
131
+ else:
132
+ branches.append({"title": str(b), "items": []})
133
+ if branches:
134
+ return {"center": str(center), "branches": branches}
135
+ # 退化成要点列表 -> 单分支
136
+ pts = [p["text"] for p in norm_bullets(fallback_points)] if fallback_points else []
137
+ return {"center": "", "branches": [{"title": "", "items": pts}] if pts else []}
138
+
139
+
140
+ def norm_compare(value) -> Optional[dict]:
141
+ """对比页:{left: {title, items}, right: {title, items}}。"""
142
+ if not isinstance(value, dict):
143
+ return None
144
+ def side(v):
145
+ if isinstance(v, dict):
146
+ return {
147
+ "title": str(v.get("title") or v.get("name") or ""),
148
+ "items": [str(i) for i in (v.get("items") or v.get("points") or [])],
149
+ }
150
+ if isinstance(v, (list, tuple)):
151
+ return {"title": "", "items": [str(i) for i in v]}
152
+ return None
153
+ left, right = side(value.get("left")), side(value.get("right"))
154
+ if not left and not right:
155
+ return None
156
+ return {"left": left or {"title": "", "items": []},
157
+ "right": right or {"title": "", "items": []},
158
+ "vs": str(value.get("vs") or "") or None}
159
+
160
+
161
+ def norm_timeline(value) -> List[dict]:
162
+ """时间轴:[{time, text}]。"""
163
+ out: List[dict] = []
164
+ for item in value or []:
165
+ if isinstance(item, dict):
166
+ out.append({
167
+ "time": str(item.get("time") or item.get("when") or item.get("label") or ""),
168
+ "text": str(item.get("text") or item.get("title") or item.get("event") or ""),
169
+ })
170
+ else:
171
+ out.append({"time": "", "text": str(item)})
172
+ return [it for it in out if it["text"] or it["time"]]
173
+
174
+
175
+ def norm_cards(value) -> List[dict]:
176
+ """卡片组:[{title, items:[str], color}]。"""
177
+ out: List[dict] = []
178
+ for item in value or []:
179
+ if isinstance(item, dict):
180
+ items = item.get("items") or item.get("points") or item.get("bullets") or []
181
+ if isinstance(items, str):
182
+ items = [items]
183
+ out.append({
184
+ "title": str(item.get("title") or item.get("text") or item.get("name") or ""),
185
+ "items": [str(i) for i in items],
186
+ "color": item.get("color"),
187
+ })
188
+ else:
189
+ out.append({"title": str(item), "items": [], "color": None})
190
+ return [c for c in out if c["title"] or c["items"]]
191
+
192
+
193
+ def _norm_answer_mode(value, default="end") -> str:
194
+ v = str(value or default).strip().lower()
195
+ return v if v in ("end", "inline", "none") else default
196
+
197
+
198
+ # --------------------------------------------------------------------------
199
+ # 单页规整
200
+ # --------------------------------------------------------------------------
201
+ def normalize_slide(raw: Dict[str, Any], index: int, section_counter: List[int]) -> dict:
202
+ if isinstance(raw, str):
203
+ raw = {"type": "content", "title": raw}
204
+ slide = dict(raw)
205
+ stype = normalize_type(slide.get("type"), default="content")
206
+ slide["type"] = stype
207
+
208
+ if stype == "cover":
209
+ slide["title"] = slide.get("title") or slide.get("deck_title") or ""
210
+ slide["subtitle"] = slide.get("subtitle") or ""
211
+ slide["teacher"] = slide.get("teacher") or slide.get("author") or ""
212
+ slide["school"] = slide.get("school") or ""
213
+ slide["date"] = slide.get("date") or ""
214
+ elif stype == "toc":
215
+ slide["title"] = slide.get("title") or "目 录"
216
+ slide["items"] = norm_bullets(slide.get("items") or slide.get("bullets"))
217
+ elif stype == "section":
218
+ section_counter[0] += 1
219
+ slide["index"] = slide.get("index", section_counter[0])
220
+ slide["title"] = slide.get("title") or ""
221
+ slide["subtitle"] = slide.get("subtitle") or ""
222
+ slide["items"] = norm_bullets(slide.get("items"))
223
+ elif stype == "objectives":
224
+ slide["title"] = slide.get("title") or "学习目标"
225
+ slide["goals"] = norm_bullets(slide.get("goals") or slide.get("bullets") or slide.get("items"))
226
+ slide["key_points"] = norm_bullets(slide.get("key_points") or slide.get("key") or slide.get("重点"))
227
+ slide["hard_points"] = norm_bullets(slide.get("hard_points") or slide.get("hard") or slide.get("难点"))
228
+ elif stype in ("content", "example", "practice", "summary", "homework"):
229
+ slide["title"] = slide.get("title") or ""
230
+ slide["subtitle"] = slide.get("subtitle") or ""
231
+ if stype in ("content", "example"):
232
+ slide["bullets"] = norm_bullets(slide.get("bullets") or slide.get("points") or slide.get("items"))
233
+ slide["steps"] = norm_bullets(slide.get("steps"))
234
+ slide["compare"] = norm_compare(slide.get("compare"))
235
+ slide["timeline"] = norm_timeline(slide.get("timeline"))
236
+ slide["cards"] = norm_cards(slide.get("cards"))
237
+ slide["pattern"] = slide.get("pattern") or slide.get("variant") or None
238
+ if stype == "practice":
239
+ slide["questions"] = norm_questions(slide.get("questions") or slide.get("items"))
240
+ slide["bullets"] = norm_bullets(slide.get("bullets"))
241
+ if stype == "summary":
242
+ slide["points"] = norm_bullets(slide.get("points") or slide.get("bullets") or slide.get("items"))
243
+ slide["mindmap"] = norm_mindmap(slide.get("mindmap"), slide.get("points"))
244
+ if stype == "homework":
245
+ slide["items"] = norm_bullets(slide.get("items") or slide.get("bullets"))
246
+ slide["note"] = slide.get("note") or slide.get("tips") or ""
247
+ elif stype == "end":
248
+ slide["title"] = slide.get("title") or "谢谢观看"
249
+ slide["subtitle"] = slide.get("subtitle") or ""
250
+
251
+ # 通用可选字段
252
+ slide.setdefault("image", None)
253
+ slide.setdefault("image_side", "right")
254
+ slide.setdefault("image_caption", None)
255
+ slide.setdefault("images", None)
256
+ slide.setdefault("note", slide.get("note") or "")
257
+ slide.setdefault("layout", None)
258
+ slide.setdefault("pattern", slide.get("pattern") or slide.get("variant") or None)
259
+ slide.setdefault("cards", norm_cards(slide.get("cards")))
260
+ slide.setdefault("compare", norm_compare(slide.get("compare")))
261
+ slide.setdefault("timeline", norm_timeline(slide.get("timeline")))
262
+ slide.setdefault("chrome", None)
263
+ return slide
264
+
265
+
266
+ def normalize_deck(raw: Dict[str, Any]) -> dict:
267
+ if not isinstance(raw, dict):
268
+ raise SpecError("课件文件的顶层必须是映射(key: value)结构")
269
+
270
+ meta = dict(raw.get("deck") or raw.get("meta") or {})
271
+ for k in ("title", "subtitle", "subject", "grade", "teacher", "school", "date", "term"):
272
+ if k in raw and k not in meta:
273
+ meta[k] = raw[k]
274
+
275
+ options = {
276
+ "answer_mode": _norm_answer_mode((raw.get("options") or {}).get("answer_mode")
277
+ or raw.get("answer_mode")),
278
+ "page_numbers": bool((raw.get("options") or {}).get("page_numbers", True)),
279
+ "page_number_start": int((raw.get("options") or {}).get("page_number_start", 1) or 1),
280
+ "chrome": (raw.get("options") or {}).get("chrome", "auto"),
281
+ "auto_toc": bool((raw.get("options") or {}).get("auto_toc", False)),
282
+ "section_cover": bool((raw.get("options") or {}).get("section_cover", True)),
283
+ "default_layout": (raw.get("options") or {}).get("default_layout"),
284
+ "fit_shrink_floor": float((raw.get("options") or {}).get("fit_shrink_floor", 0.62)),
285
+ }
286
+ # 其余自定义选项(例如 images 配图设置)原样保留,不要丢掉
287
+ for _k, _v in (raw.get("options") or {}).items():
288
+ if _k not in options:
289
+ options[_k] = _v
290
+
291
+ raw_slides = raw.get("slides") or raw.get("pages") or []
292
+ if not isinstance(raw_slides, list):
293
+ raise SpecError("slides 必须是列表")
294
+
295
+ section_counter = [0]
296
+ slides = [normalize_slide(s, i, section_counter) for i, s in enumerate(raw_slides)]
297
+
298
+ # 如果第一页不是封面,自动从 meta 生成封面
299
+ if not slides or slides[0]["type"] != "cover":
300
+ cover = normalize_slide({
301
+ "type": "cover",
302
+ "title": meta.get("title", ""),
303
+ "subtitle": meta.get("subtitle", ""),
304
+ "teacher": meta.get("teacher", ""),
305
+ "school": meta.get("school", ""),
306
+ "date": meta.get("date", ""),
307
+ }, 0, section_counter)
308
+ slides.insert(0, cover)
309
+
310
+ # 自动目录
311
+ if options["auto_toc"] and not any(s["type"] == "toc" for s in slides):
312
+ items = []
313
+ for s in slides:
314
+ if s["type"] == "section" and s.get("title"):
315
+ items.append({"text": s["title"], "level": 0})
316
+ if items:
317
+ toc = normalize_slide({"type": "toc", "title": "目 录", "items": items},
318
+ len(slides), section_counter)
319
+ slides.insert(1, toc)
320
+
321
+ return {
322
+ "meta": meta,
323
+ "options": options,
324
+ "template": raw.get("template"),
325
+ "output": raw.get("output"),
326
+ "layout_map": raw.get("layout_map") or {},
327
+ "style": raw.get("style") or {},
328
+ "slides": slides,
329
+ }
330
+
331
+
332
+ # --------------------------------------------------------------------------
333
+ # 读取
334
+ # --------------------------------------------------------------------------
335
+ def load_deck(path: str) -> dict:
336
+ if not os.path.exists(path):
337
+ raise SpecError(f"找不到课件文件:{path}")
338
+ with open(path, "r", encoding="utf-8") as f:
339
+ text = f.read()
340
+ ext = os.path.splitext(path)[1].lower()
341
+ if ext == ".json":
342
+ raw = json.loads(text)
343
+ elif ext == ".md" or ext == ".markdown":
344
+ raw = parse_markdown(text)
345
+ else:
346
+ raw = yaml.safe_load(text)
347
+ deck = normalize_deck(raw or {})
348
+ deck["_source"] = os.path.abspath(path)
349
+ deck["_dir"] = os.path.dirname(os.path.abspath(path))
350
+ return deck
351
+
352
+
353
+ # --------------------------------------------------------------------------
354
+ # Markdown 大纲 -> deck
355
+ # # 封面标题 | 副标题
356
+ # ## [章节] 第一节 xxx
357
+ # ## [目标] 学习目标
358
+ # ## [练习] 随堂练习
359
+ # > 重点:xxx
360
+ # --------------------------------------------------------------------------
361
+ _HEAD_RE = re.compile(r"^(#{1,6})\s*(.*)$")
362
+ _MARK_RE = re.compile(r"^\[([^\]]+)\]\s*(.*)$")
363
+ _ANS_RE = re.compile(r"^[→➜>]\s*(?:答案|answer)\s*[::]\s*(.*)$", re.I)
364
+ _ANA_RE = re.compile(r"^[※*]\s*(?:解析|分析|说明)\s*[::]\s*(.*)$")
365
+ _OPT_RE = re.compile(r"^[((]?([A-Ha-h])[)).、.::]\s*(.*)$")
366
+ _NUM_RE = re.compile(r"^\s*(\d{1,2})[.、.))]\s+(.*)$")
367
+
368
+
369
+ def parse_markdown(text: str) -> dict:
370
+ lines = text.replace("\r\n", "\n").split("\n")
371
+ meta: Dict[str, Any] = {}
372
+ body_start = 0
373
+ if lines and lines[0].strip() == "---":
374
+ for i in range(1, len(lines)):
375
+ if lines[i].strip() == "---":
376
+ body_start = i + 1
377
+ break
378
+ if ":" in lines[i]:
379
+ k, v = lines[i].split(":", 1)
380
+ meta[k.strip()] = v.strip()
381
+ lines = lines[body_start:]
382
+
383
+ slides: List[dict] = []
384
+ cur: Optional[dict] = None
385
+ mode = "bullets"
386
+ pending_q: Optional[dict] = None
387
+ goals: List[str] = []
388
+ key_points: List[str] = []
389
+ hard_points: List[str] = []
390
+
391
+ def flush():
392
+ nonlocal cur, pending_q, goals, key_points, hard_points
393
+ if cur is not None:
394
+ if cur["type"] == "objectives":
395
+ cur["goals"] = goals
396
+ cur["key_points"] = key_points
397
+ cur["hard_points"] = hard_points
398
+ if pending_q and cur.get("questions") is not None:
399
+ cur["questions"].append(pending_q)
400
+ slides.append(cur)
401
+ cur, pending_q = None, None
402
+ goals, key_points, hard_points = [], [], []
403
+
404
+ for raw_line in lines:
405
+ line = raw_line.rstrip()
406
+ stripped = line.strip()
407
+
408
+ m = _HEAD_RE.match(stripped)
409
+ if m:
410
+ level = len(m.group(1))
411
+ text_part = m.group(2).strip()
412
+ if level == 1:
413
+ flush()
414
+ title, _, sub = text_part.partition("|")
415
+ meta.setdefault("title", title.strip())
416
+ if sub.strip():
417
+ meta.setdefault("subtitle", sub.strip())
418
+ continue
419
+ flush()
420
+ mm = _MARK_RE.match(text_part)
421
+ if mm:
422
+ stype = normalize_type(mm.group(1), default="content")
423
+ title = mm.group(2).strip()
424
+ else:
425
+ stype = "content"
426
+ title = text_part
427
+ cur = {"type": stype, "title": title}
428
+ mode = {
429
+ "objectives": "objectives",
430
+ "practice": "practice",
431
+ "example": "steps",
432
+ "homework": "items",
433
+ "summary": "points",
434
+ }.get(stype, "bullets")
435
+ if mode == "objectives":
436
+ cur["goals"], cur["key_points"], cur["hard_points"] = [], [], []
437
+ if mode == "practice":
438
+ cur["questions"] = []
439
+ continue
440
+
441
+ if stripped == "":
442
+ continue
443
+
444
+ if cur is None:
445
+ # 文件开头的自由文本,作为封面副标题
446
+ meta.setdefault("subtitle", stripped)
447
+ continue
448
+
449
+ # 重点/难点(引用块)
450
+ if stripped.startswith(">"):
451
+ content = stripped.lstrip("> ").strip()
452
+ if re.match(r"^(重点|教学重点)", content):
453
+ key_points.extend(_split_inline(re.sub(r"^(重点|教学重点)\s*[::]\s*", "", content)))
454
+ continue
455
+ if re.match(r"^(难点|教学难点)", content):
456
+ hard_points.extend(_split_inline(re.sub(r"^(难点|教学难点)\s*[::]\s*", "", content)))
457
+ continue
458
+ if re.match(r"^(目标|学习目标)", content):
459
+ goals.extend(_split_inline(re.sub(r"^(目标|学习目标)\s*[::]\s*", "", content)))
460
+ continue
461
+ if cur["type"] == "content":
462
+ cur.setdefault("bullets", []).append({"text": content, "level": 1})
463
+ continue
464
+
465
+ # 练习页
466
+ if cur["type"] == "practice" or (cur["type"] == "example" and mode == "steps"):
467
+ am = _ANS_RE.match(stripped)
468
+ if am:
469
+ if pending_q is None:
470
+ pending_q = {"q": "", "options": [], "answer": "", "analysis": ""}
471
+ pending_q["answer"] = am.group(1).strip()
472
+ continue
473
+ nm = _ANA_RE.match(stripped)
474
+ if nm:
475
+ if pending_q is None:
476
+ pending_q = {"q": "", "options": [], "answer": "", "analysis": ""}
477
+ pending_q["analysis"] = nm.group(1).strip()
478
+ continue
479
+ om = _OPT_RE.match(stripped)
480
+ if om and pending_q is not None:
481
+ pending_q["options"].append(f"{om.group(1).upper()}. {om.group(2).strip()}")
482
+ continue
483
+ qm = _NUM_RE.match(line)
484
+ if qm:
485
+ if pending_q:
486
+ cur["questions"].append(pending_q)
487
+ pending_q = {"q": qm.group(2).strip(), "options": [], "answer": "", "analysis": ""}
488
+ continue
489
+ if stripped.startswith(("-", "*", "•")) or re.match(r"^[((]?\d+[)).、.]", stripped):
490
+ text = re.sub(r"^[-*•]\s*", "", stripped)
491
+ if cur["type"] == "example" and not re.match(r"^[((]?\d+[)).、.]", text):
492
+ cur.setdefault("steps", []).append({"text": text, "level": 0})
493
+ else:
494
+ cur.setdefault("questions", []).append(
495
+ {"q": text, "options": [], "answer": "", "analysis": ""})
496
+ continue
497
+ # 续行
498
+ if pending_q is not None:
499
+ pending_q["q"] = (pending_q["q"] + " " + stripped).strip()
500
+ continue
501
+
502
+ # 目标页
503
+ if mode == "objectives":
504
+ text = re.sub(r"^[-*•]\s*", "", stripped)
505
+ goals.append(text)
506
+ continue
507
+
508
+ # 通用列表
509
+ indent = len(line) - len(line.lstrip())
510
+ text = re.sub(r"^[-*•]\s*", "", stripped)
511
+ text = re.sub(r"^\d{1,2}[.、.))]\s*", "", text)
512
+ level = 1 if indent >= 2 else 0
513
+ target = {
514
+ "points": "points", "items": "items", "steps": "steps", "bullets": "bullets",
515
+ }.get(mode, "bullets")
516
+ cur.setdefault(target, []).append({"text": text, "level": level})
517
+
518
+ flush()
519
+
520
+ raw = {"deck": meta, "slides": slides}
521
+ for k in ("template", "output"):
522
+ if k in meta:
523
+ raw[k] = meta.pop(k)
524
+ return raw
525
+
526
+
527
+ def _split_inline(text: str) -> List[str]:
528
+ parts = re.split(r"[;;]|,\s*(?=[\u4e00-\u9fff])", text)
529
+ return [p.strip() for p in parts if p.strip()]