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,1673 @@
1
+ """渲染引擎:把课件规格套用模板渲染成 pptx。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from typing import Dict, List, Optional, Sequence, Tuple
8
+
9
+ from pptx.enum.shapes import MSO_SHAPE
10
+ from pptx.enum.text import MSO_ANCHOR, MSO_AUTO_SIZE
11
+ from pptx.oxml.ns import qn
12
+ from pptx.util import Emu, Pt
13
+
14
+ from . import xmlutil as xu
15
+ from .layoutmap import build_map, resolve_layout_ref
16
+ from .patterns import guess_pattern
17
+ from .profile import StyleProfile, learn_profile
18
+ from .shapes import (ALIGN_MAP, Para, add_line, add_picture_fit, add_shape,
19
+ add_textbox, fit_paras, image_size, rounded_box,
20
+ write_paras)
21
+ from .template import LayoutInfo, PlaceholderInfo, TemplateInfo
22
+ from .util import (EMU_PER_INCH, emu_from_inch, mix, parse_color, pt_from_emu,
23
+ readable_on, relative_luminance, text_width_pt)
24
+
25
+ # 基准字号(针对 16:9 / 13.333x7.5in 的画布),会按画布尺寸缩放
26
+ BASE_FONTS = {
27
+ "cover_title": 40, "cover_sub": 18, "cover_meta": 13,
28
+ "title": 28, "subtitle": 14,
29
+ "toc_item": 17, "toc_num": 20,
30
+ "section_num": 60, "section_title": 32,
31
+ "goal": 17, "card_title": 16, "card_item": 14,
32
+ "bullet0": 17, "bullet1": 14,
33
+ "question": 16, "option": 14, "answer": 13,
34
+ "mind_center": 19, "mind_branch": 15, "mind_item": 12.5,
35
+ "page_num": 10,
36
+ }
37
+
38
+ BULLETS = ["●", "–", "·"]
39
+
40
+
41
+ class RenderReport:
42
+ def __init__(self):
43
+ self.entries: List[tuple] = [] # (页型, 标题)
44
+ self.warnings: List[str] = []
45
+
46
+ @property
47
+ def slides(self) -> List[str]:
48
+ return [f"P{i:>2} [{t}] {title}" for i, (t, title) in enumerate(self.entries, 1)]
49
+
50
+ def add(self, stype: str, title: str) -> None:
51
+ self.entries.append((stype, title))
52
+
53
+ def ok(self) -> bool:
54
+ return not self.warnings
55
+
56
+ def text(self) -> str:
57
+ lines = [f"生成 {len(self.entries)} 页:"]
58
+ lines.extend(" " + s for s in self.slides)
59
+ if self.warnings:
60
+ lines.append("")
61
+ lines.append(f"注意({len(self.warnings)} 条):")
62
+ for w in self.warnings:
63
+ lines.append(f" ! {w}")
64
+ return "\n".join(lines)
65
+
66
+
67
+ class Renderer:
68
+ def __init__(self, tpl: TemplateInfo, deck: dict, base_dir: Optional[str] = None):
69
+ self.tpl = tpl
70
+ self.deck = deck
71
+ self.base_dir = base_dir or deck.get("_dir") or os.getcwd()
72
+ self.prs = tpl._prs
73
+ self.W = tpl.width
74
+ self.H = tpl.height
75
+ self.scale = min(self.W / 12192000.0, self.H / 6858000.0) or 1.0
76
+ self.map = build_map(tpl, deck.get("layout_map"))
77
+ self.opts = deck["options"]
78
+ self.accents = tpl.accent_colors()
79
+ self.primary = tpl.primary_color()
80
+ self.report = RenderReport()
81
+ self.profile: Optional[StyleProfile] = None
82
+ self.use_chip = False
83
+ if str(self.opts.get("style_profile", "auto")).lower() not in ("off", "false", "none", "no", "0"):
84
+ try:
85
+ self.profile = learn_profile(tpl)
86
+ except Exception as exc:
87
+ self.warn(f"样式档案学习失败(已忽略):{exc}")
88
+ self.use_chip = bool(self.profile and self.profile.has_chip_layout)
89
+ self.image_manifest = self._load_image_manifest()
90
+ self._answer_pool: List[dict] = []
91
+ self._page_no = 0
92
+ self._built_types: List[str] = []
93
+ self._last_practice_idx = -1
94
+
95
+ # ==================================================================
96
+ # 主流程
97
+ # ==================================================================
98
+ def render(self, out_path: str, *, keep_template_slides: bool = False) -> RenderReport:
99
+ if not keep_template_slides:
100
+ self._remove_all_slides()
101
+
102
+ slides = self.deck["slides"]
103
+ for i, spec in enumerate(slides):
104
+ self._render_one(spec, i, len(slides))
105
+
106
+ n_answers = 0
107
+ if self.opts["answer_mode"] == "end" and self._answer_pool:
108
+ n_answers = self._render_answer_slides()
109
+ if n_answers and self._last_practice_idx >= 0:
110
+ self._move_tail_slides_after(self._last_practice_idx, n_answers)
111
+
112
+ self._add_page_numbers()
113
+ os.makedirs(os.path.dirname(os.path.abspath(out_path)) or ".", exist_ok=True)
114
+ self.prs.save(out_path)
115
+ return self.report
116
+
117
+ def _move_tail_slides_after(self, anchor: int, count: int) -> None:
118
+ """把末尾的 count 页(参考答案)移动到 anchor 页之后。"""
119
+ sldIdLst = self.prs.slides._sldIdLst
120
+ ids = list(sldIdLst)
121
+ if count <= 0 or len(ids) < count:
122
+ return
123
+ moved = ids[len(ids) - count:]
124
+ for el in moved:
125
+ sldIdLst.remove(el)
126
+ remaining = list(sldIdLst)
127
+ pos = min(anchor + 1, len(remaining))
128
+ for offset, el in enumerate(moved):
129
+ remaining.insert(pos + offset, el)
130
+ for el in remaining:
131
+ sldIdLst.append(el)
132
+ # 报告顺序同步调整
133
+ if self.report.entries and count <= len(self.report.entries):
134
+ tail = self.report.entries[-count:]
135
+ rest = self.report.entries[:-count]
136
+ self.report.entries = rest[:pos] + tail + rest[pos:]
137
+ self._built_types[-count:] = []
138
+
139
+ def _render_one(self, spec: dict, index: int, total: int) -> None:
140
+ stype = spec["type"]
141
+ handler = getattr(self, f"_slide_{stype}", None)
142
+ if handler is None:
143
+ self.warn(f"未知页型 {stype!r},已按知识点页处理")
144
+ handler = self._slide_content
145
+ slide, lay = self._new_slide(spec)
146
+ try:
147
+ handler(slide, lay, spec, index, total)
148
+ except Exception as exc: # 单页失败不应中断整份课件
149
+ self.warn(f"第 {index + 1} 页({stype})渲染异常:{exc}")
150
+ self._prune_empty_placeholders(slide)
151
+ note = spec.get("note")
152
+ if note:
153
+ self._set_notes(slide, note)
154
+ self.report.add(stype, spec.get("title", ""))
155
+ self._built_types.append(stype)
156
+ if stype == "practice":
157
+ self._last_practice_idx = len(self._built_types) - 1
158
+
159
+ # ==================================================================
160
+ # 幻灯片创建 / 装饰层
161
+ # ==================================================================
162
+ def _new_slide(self, spec: dict):
163
+ idx = self.map.get(spec["type"], 0)
164
+ ref = spec.get("layout")
165
+ if ref is not None:
166
+ resolved = resolve_layout_ref(self.tpl, ref)
167
+ if resolved is None:
168
+ self.warn(f"找不到版式 {ref!r},改用默认映射")
169
+ else:
170
+ idx = resolved
171
+ try:
172
+ layout = self.tpl.layout_objects[idx]
173
+ except (IndexError, AttributeError):
174
+ layout = self.prs.slide_layouts[idx]
175
+ slide = self.prs.slides.add_slide(layout)
176
+ lay = self.tpl.layout_by_index(idx)
177
+ self._apply_role_background(slide, spec)
178
+ self._apply_chrome(slide, lay, spec)
179
+ return slide, lay
180
+
181
+ def _load_image_manifest(self) -> dict:
182
+ """读取已生成配图的清单(渲染阶段不需要任何 API key)。"""
183
+ from .imagegen import normalize_dir, read_manifest
184
+
185
+ opts = (self.deck.get("options") or {}).get("images") or {}
186
+ cache_dir = normalize_dir(opts.get("cache_dir"), self.base_dir)
187
+ try:
188
+ return read_manifest(cache_dir)
189
+ except Exception:
190
+ return {}
191
+
192
+ def _apply_role_background(self, slide, spec: dict) -> None:
193
+ """封面/结束页如果自带幻灯片级背景,就照搬过来(母版背景会被它覆盖)。"""
194
+ role_bg = (self.tpl.role_background or {}).get(spec.get("type"))
195
+ if not role_bg:
196
+ return
197
+ try:
198
+ xu.clone_bg(role_bg["owner"], role_bg["element"], slide)
199
+ except Exception as exc:
200
+ self.warn(f"{spec.get('type')} 页背景复制失败:{exc}")
201
+
202
+ def _apply_chrome(self, slide, lay: Optional[LayoutInfo], spec: dict) -> None:
203
+ mode = spec.get("chrome")
204
+ if mode is None:
205
+ mode = self.opts.get("chrome", "auto")
206
+ if mode is False or str(mode).strip().lower() in ("off", "false", "none", "no", "0"):
207
+ return
208
+ new_els = []
209
+ items = list(self.tpl.global_chrome)
210
+ if lay is not None:
211
+ items += list(lay.chrome)
212
+ text_map = self.opts.get("chrome_text_map") or {}
213
+ for item in items:
214
+ try:
215
+ el = xu.clone_shape(item.src_slide, item.element, slide)
216
+ if text_map:
217
+ self._remap_cloned_text(el, text_map)
218
+ new_els.append(el)
219
+ except Exception as exc:
220
+ self.warn(f"装饰形状 {item.name!r} 复制失败:{exc}")
221
+ if new_els:
222
+ spTree = slide.shapes._spTree
223
+ for el in reversed(new_els):
224
+ spTree.remove(el)
225
+ spTree.insert(2, el)
226
+
227
+ bgc = self.tpl.slide_background
228
+ if bgc is not None:
229
+ try:
230
+ xu.clone_bg(bgc["owner"], bgc["element"], slide)
231
+ except Exception as exc:
232
+ self.warn(f"背景复制失败:{exc}")
233
+
234
+ def _remap_cloned_text(self, el, text_map: Dict[str, str]) -> None:
235
+ """把克隆过来的装饰文本按映射整体替换(例如模板封面"立秋"→"立夏")。"""
236
+ for t in el.iter(qn("a:t")):
237
+ if t.text and t.text in text_map:
238
+ t.text = text_map[t.text]
239
+
240
+ def _remove_all_slides(self) -> None:
241
+ sldIdLst = self.prs.slides._sldIdLst
242
+ for sldId in list(sldIdLst):
243
+ rId = sldId.get(qn("r:id"))
244
+ try:
245
+ self.prs.part.drop_rel(rId)
246
+ except Exception:
247
+ pass
248
+ sldIdLst.remove(sldId)
249
+
250
+ def _prune_empty_placeholders(self, slide) -> None:
251
+ for ph in list(slide.placeholders):
252
+ try:
253
+ phtype = ph.placeholder_format.type
254
+ except Exception:
255
+ continue
256
+ try:
257
+ has_text = bool(ph.text_frame.text.strip())
258
+ except Exception:
259
+ has_text = False
260
+ if not has_text:
261
+ el = ph._element
262
+ parent = el.getparent()
263
+ if parent is not None:
264
+ parent.remove(el)
265
+
266
+ def _set_notes(self, slide, text: str) -> None:
267
+ try:
268
+ slide.notes_slide.notes_text_frame.text = str(text)
269
+ except Exception:
270
+ pass
271
+
272
+ # ==================================================================
273
+ # 几何 / 配色
274
+ # ==================================================================
275
+ def fz(self, key: str) -> float:
276
+ return BASE_FONTS.get(key, 16) * self.scale
277
+
278
+ def body_font(self) -> str:
279
+ """正文中文字体:优先用样张里实际用的字体。"""
280
+ if self.profile and self.profile.body_font:
281
+ return self.profile.body_font
282
+ return self.tpl.cjk_font()
283
+
284
+ def body_size(self, key: str = "bullet0") -> float:
285
+ """正文字号:以模板学到的正文为基准,收敛到"图文混排也放得下"的档位。
286
+
287
+ 模板样张常是"一页没几条字"的大字号;一旦页面里有卡片、步骤、配图,
288
+ 同字号会撑爆版面、逼着引擎逐页缩字,反而造成页与页字号忽大忽小。
289
+ 这里统一压到同一档,让纯文字页和卡片页的字号保持一致。
290
+ """
291
+ base = float(self.profile.body_size_pt) if (self.profile and self.profile.body_size_pt) \
292
+ else self.fz(key)
293
+ return max(13.0, min(base * 0.72, 22.0))
294
+
295
+ # 正文族字号阶梯:全部相对正文基准等比推导,避免"28pt 正文"旁边出现"14pt 卡片"
296
+ _CONTENT_RATIO = {
297
+ "body": 1.0, # 正文要点
298
+ "block_title": 1.08, # 卡片标题 / 小标题(加粗,略大)
299
+ "item": 0.88, # 卡片条目 / 步骤说明
300
+ "key": 1.35, # 核心问题 / 结论(视觉锚点)
301
+ "lead": 0.75, # 导语
302
+ "caption": 0.55, # 图注 / 页码
303
+ }
304
+ _CONTENT_CLAMP = {
305
+ "body": (13.0, 22.0), "block_title": (14.0, 24.0), "item": (12.0, 20.0),
306
+ "key": (18.0, 30.0), "lead": (11.0, 17.0), "caption": (9.0, 13.0),
307
+ }
308
+
309
+ def csize(self, kind: str) -> float:
310
+ """正文族字号:相对正文基准等比缩放(模板换字号时整族一起走)。"""
311
+ base = self.body_size()
312
+ lo, hi = self._CONTENT_CLAMP.get(kind, (9.0, 30.0))
313
+ v = base * self._CONTENT_RATIO.get(kind, 1.0)
314
+ return max(lo, min(v, hi))
315
+
316
+ def body_line_spacing(self) -> float:
317
+ if self.profile and self.profile.body_line_spacing:
318
+ return float(self.profile.body_line_spacing)
319
+ return 1.30
320
+
321
+ def _is_dark(self, lay: Optional[LayoutInfo]) -> bool:
322
+ bg = (lay.background if lay else None) or self.tpl.master_background
323
+ if bg:
324
+ if bg.get("kind") == "picture":
325
+ lum = bg.get("luminance")
326
+ if lum is not None:
327
+ return lum < 0.45
328
+ elif bg.get("color"):
329
+ return relative_luminance(bg["color"]) < 0.45
330
+ # 有的模板不用 <p:bg>,而是拿整页纯色形状当背景(画在样张或版式里)
331
+ for item in self.tpl.global_chrome:
332
+ if getattr(item, "is_full_bleed", False) and item.fill_luminance is not None:
333
+ return item.fill_luminance < 0.45
334
+ if lay is not None:
335
+ lum = self._layout_bg_luminance(lay)
336
+ if lum is not None:
337
+ return lum < 0.45
338
+ return False
339
+
340
+ def _layout_bg_luminance(self, lay: LayoutInfo) -> Optional[float]:
341
+ """版式里若画了一个覆盖整页的纯色形状,就把它当作实际底色。"""
342
+ try:
343
+ spTree = self.tpl.layout_objects[lay.index].shapes._spTree
344
+ except Exception:
345
+ return None
346
+ for el in xu.iter_shape_elements(spTree):
347
+ if xu.full_bleed_ratio(el, self.W, self.H) < 0.9:
348
+ continue
349
+ lum = xu.solid_fill_luminance(el, self.tpl.theme_colors)
350
+ if lum is not None:
351
+ return lum
352
+ return None
353
+
354
+ def _palette(self, lay: Optional[LayoutInfo]) -> dict:
355
+ if self._is_dark(lay):
356
+ return {
357
+ "text": "FFFFFF", "muted": "CFD8E3",
358
+ "card": "FFFFFF", "card_alpha": 0.14, "card_line": "FFFFFF",
359
+ "card_line_alpha": 0.30, "card_text": "FFFFFF",
360
+ }
361
+ return {
362
+ "text": self.tpl.text_color(), "muted": self.tpl.muted_color(),
363
+ "card": mix("FFFFFF", self.primary, 0.10), "card_alpha": 1.0,
364
+ "card_line": mix("FFFFFF", self.primary, 0.32), "card_line_alpha": 1.0,
365
+ "card_text": self.tpl.text_color(),
366
+ }
367
+
368
+ def _default_title_box(self) -> Tuple[int, int, int, int]:
369
+ return (int(self.W * 0.075), int(self.H * 0.075),
370
+ int(self.W * 0.85), int(self.H * 0.15))
371
+
372
+ def _default_body_box(self) -> Tuple[int, int, int, int]:
373
+ return (int(self.W * 0.075), int(self.H * 0.27),
374
+ int(self.W * 0.85), int(self.H * 0.62))
375
+
376
+ def _title_box(self, lay: Optional[LayoutInfo]) -> Tuple[int, int, int, int]:
377
+ ph = lay.find_title() if lay else None
378
+ if ph and ph.width and ph.height:
379
+ return ph.box
380
+ return self._default_title_box()
381
+
382
+ def _body_box(self, lay: Optional[LayoutInfo]) -> Tuple[int, int, int, int]:
383
+ ph = lay.find_body() if lay else None
384
+ if ph and ph.width and ph.height:
385
+ return ph.box
386
+ return self._default_body_box()
387
+
388
+ # ==================================================================
389
+ # 占位符 / 文本
390
+ # ==================================================================
391
+ def _ph_shape(self, slide, ph_info: PlaceholderInfo):
392
+ if ph_info.idx is not None:
393
+ for ph in slide.placeholders:
394
+ if ph.placeholder_format.idx == ph_info.idx:
395
+ return ph
396
+ want_title = ph_info.is_title
397
+ for ph in slide.placeholders:
398
+ try:
399
+ is_title = ph.placeholder_format.type in (13, 1, 3) # TITLE/SUBTITLE/CENTER_TITLE
400
+ except Exception:
401
+ is_title = False
402
+ if is_title == want_title:
403
+ return ph
404
+ return None
405
+
406
+ def _fill_ph(self, slide, ph_info: PlaceholderInfo, paras: List[Para],
407
+ anchor: str = "top") -> bool:
408
+ shape = self._ph_shape(slide, ph_info)
409
+ if shape is None:
410
+ return False
411
+ self._clamp_placeholder(shape, ph_info)
412
+ tf = shape.text_frame
413
+ tf.word_wrap = True
414
+ tf.auto_size = MSO_AUTO_SIZE.NONE
415
+ try:
416
+ tf.vertical_anchor = {
417
+ "top": MSO_ANCHOR.TOP, "middle": MSO_ANCHOR.MIDDLE, "bottom": MSO_ANCHOR.BOTTOM,
418
+ }[anchor]
419
+ except Exception:
420
+ pass
421
+ w_pt = pt_from_emu(max(ph_info.width - emu_from_inch(0.1), emu_from_inch(1)))
422
+ h_pt = pt_from_emu(max(ph_info.height, emu_from_inch(0.3)))
423
+ fitted = fit_paras(paras, w_pt, h_pt, floor=self.opts["fit_shrink_floor"])
424
+ if paras and fitted and fitted[0].size < paras[0].size * 0.85:
425
+ self.warn(f"自动缩小字号以适配占位符:{paras[0].text[:24]!r}")
426
+ write_paras(tf, fitted, default_font=ph_info.font_name)
427
+ return True
428
+
429
+ def _clamp_placeholder(self, shape, ph_info: PlaceholderInfo) -> None:
430
+ """模板的占位符本身可能超出画布(尤其是改了画布比例的模板),把它拉回来。"""
431
+ try:
432
+ x, y, cx, cy = int(shape.left), int(shape.top), int(shape.width), int(shape.height)
433
+ except Exception:
434
+ return
435
+ m = max(int(self.W * 0.015), 9144)
436
+ nx, ny, ncx, ncy = x, y, cx, cy
437
+ if nx < m:
438
+ ncx -= (m - nx)
439
+ nx = m
440
+ if ny < m:
441
+ ncy -= (m - ny)
442
+ ny = m
443
+ if nx + ncx > self.W - m:
444
+ ncx = self.W - m - nx
445
+ if ny + ncy > self.H - m:
446
+ ncy = self.H - m - ny
447
+ ncx = max(ncx, int(self.W * 0.15))
448
+ ncy = max(ncy, int(self.H * 0.05))
449
+ if (nx, ny, ncx, ncy) == (x, y, cx, cy):
450
+ return
451
+ try:
452
+ shape.left, shape.top, shape.width, shape.height = nx, ny, ncx, ncy
453
+ except Exception:
454
+ return
455
+ self.warn(f"占位符超出画布,已自动调整:{ph_info.name!r}")
456
+ ph_info.left, ph_info.top, ph_info.width, ph_info.height = nx, ny, ncx, ncy
457
+
458
+ # ---- 从模板样张学到的页面骨架 ------------------------------------
459
+ def _header(self, slide, lay: Optional[LayoutInfo], spec: dict,
460
+ default_module: Optional[str] = None) -> Tuple[int, int, int, int]:
461
+ """画左上角模块标签 + 标题条;返回这一页的内容区。
462
+
463
+ 学到样式档案时走 profile 的模块标签/标题条;没学到(模板主要依赖版式)时,
464
+ 支持 deck 选项把标题与模块名重定向到版式里的指定占位符:
465
+ title_ph_idx 页面标题填进该 idx 的占位符(如窄标题条)
466
+ module_ph_idx 模块名填进该 idx 的占位符(如左上角小方框)
467
+ """
468
+ title = spec.get("title", "") or ""
469
+ if self.use_chip and self.profile:
470
+ module = spec.get("module") or default_module
471
+ if module:
472
+ self._module_label(slide, module)
473
+ if title:
474
+ self._title_chip(slide, title)
475
+ return self.profile.content_area()
476
+ module = spec.get("module") or default_module
477
+ t_idx = self.opts.get("title_ph_idx")
478
+ if t_idx is not None and lay is not None:
479
+ ph = lay.find_placeholder(int(t_idx))
480
+ if ph is not None:
481
+ self._put_title(slide, lay, title, ph=ph)
482
+ else:
483
+ self.warn(f"找不到标题占位符 idx={t_idx},回退默认标题位")
484
+ self._put_title(slide, lay, title)
485
+ else:
486
+ self._put_title(slide, lay, title)
487
+ m_idx = self.opts.get("module_ph_idx")
488
+ if module and m_idx is not None and lay is not None:
489
+ ph = lay.find_placeholder(int(m_idx))
490
+ if ph is not None:
491
+ base = ph.font_size_pt or self.csize("block_title")
492
+ self._fill_ph(slide, ph,
493
+ [Para(text=module, size=base, bold=True, color=None,
494
+ font=ph.font_name, align="center", bullet="")],
495
+ anchor="middle")
496
+ else:
497
+ self.warn(f"找不到模块占位符 idx={m_idx},模块标签未渲染")
498
+ return self._body_box(lay)
499
+
500
+ def _module_label(self, slide, module: str) -> None:
501
+ sk = self.profile.module_label
502
+ if sk is None:
503
+ return
504
+ st = sk.style
505
+ self._textbox(
506
+ slide, sk.box,
507
+ [Para(text=module, size=st.size_pt or self.csize("block_title"),
508
+ bold=bool(st.bold), italic=bool(st.italic), color=st.color,
509
+ font=st.font or self.tpl.heading_font(), align=st.align or "center",
510
+ bullet="")],
511
+ anchor="middle", fit=False)
512
+
513
+ def _title_chip(self, slide, title: str):
514
+ sk = self.profile.title_chip
515
+ st = sk.style
516
+ size = st.size_pt or self.fz("title")
517
+ bold = bool(st.bold)
518
+ pad = emu_from_inch(0.13)
519
+ # 中文字体下英文空格/数字往往比估算更宽,留 18% 余量,保证标题条里不折行
520
+ SAFETY = 1.18
521
+ est_pt = text_width_pt(title, size, bold)
522
+ max_w = int(self.W * 0.74)
523
+ want = emu_from_inch(est_pt * SAFETY / 72.0) + 2 * pad
524
+ if want <= max_w:
525
+ w, use_size = max(int(want), sk.box[2]), size
526
+ else:
527
+ # 太长了:缩小字号让它在一行内放得下,而不是折行溢出标题条
528
+ w = max_w
529
+ avail_pt = pt_from_emu(w - 2 * pad) / SAFETY
530
+ use_size = max(12.0, size * avail_pt / max(est_pt, 1.0))
531
+ box = (sk.box[0], sk.box[1], w, sk.box[3])
532
+ shp = rounded_box(slide, box, fill=sk.fill or "FFFFFF", line=sk.line,
533
+ line_width_pt=max(sk.line_width_pt, 1.0), radius=sk.radius)
534
+ tf = shp.text_frame
535
+ tf.word_wrap = False # 标题条不折行
536
+ tf.auto_size = MSO_AUTO_SIZE.NONE
537
+ tf.vertical_anchor = MSO_ANCHOR.MIDDLE
538
+ tf.margin_left = pad
539
+ tf.margin_right = pad
540
+ tf.margin_top = 0
541
+ tf.margin_bottom = 0
542
+ paras = [Para(text=title, size=use_size, bold=bold, italic=bool(st.italic),
543
+ color=st.color, font=st.font or self.tpl.cjk_font(),
544
+ align=st.align or "left", bullet="")]
545
+ write_paras(tf, paras, default_font=st.font or self.tpl.cjk_font())
546
+ return shp
547
+
548
+ def _put_title(self, slide, lay: Optional[LayoutInfo], text: str, *,
549
+ size_key: str = "title", color: Optional[str] = None,
550
+ align: str = "left", anchor: str = "middle",
551
+ ph: Optional[PlaceholderInfo] = None) -> None:
552
+ if not text:
553
+ return
554
+ if ph is None:
555
+ ph = lay.find_title() if lay else None
556
+ if ph is not None:
557
+ base = ph.font_size_pt or self.fz(size_key)
558
+ paras = [Para(text=text, size=base, bold=True,
559
+ color=color, font=ph.font_name, align=align, bullet="")]
560
+ if self._fill_ph(slide, ph, paras, anchor=anchor):
561
+ return
562
+ pal = self._palette(lay)
563
+ box = self._default_title_box()
564
+ self._textbox(slide, box, [Para(text=text, size=self.fz(size_key), bold=True,
565
+ color=color or pal["text"],
566
+ font=self.tpl.heading_font(), align=align,
567
+ bullet="")], anchor=anchor)
568
+
569
+ def _textbox(self, slide, box, paras: Sequence[Para], *, anchor: str = "top",
570
+ default_font: Optional[str] = None, fit: bool = True):
571
+ x, y, cx, cy = self._clamp_box(box)
572
+ tb = add_textbox(slide, (x, y, cx, cy), anchor=anchor)
573
+ use = [self._expand_highlight(p) for p in paras]
574
+ if fit and use:
575
+ w_pt = pt_from_emu(cx)
576
+ h_pt = pt_from_emu(cy)
577
+ use = fit_paras(use, w_pt, h_pt, floor=self.opts["fit_shrink_floor"])
578
+ write_paras(tb.text_frame, use, default_font=default_font or self.tpl.cjk_font())
579
+ return tb
580
+
581
+ def _clamp_box(self, box) -> Tuple[int, int, int, int]:
582
+ """把文本框限制在画布内,保证没有任何内容跑到幻灯片外面。"""
583
+ x, y, cx, cy = (int(v) for v in box)
584
+ m = max(int(self.H * 0.012), 9144)
585
+ cx = max(cx, int(self.W * 0.04))
586
+ cy = max(cy, int(self.H * 0.025))
587
+ x = max(min(x, self.W - m - cx), m)
588
+ y = max(min(y, self.H - m - cy), m)
589
+ cx = min(cx, self.W - m - x)
590
+ cy = min(cy, self.H - m - y)
591
+ return (x, y, max(cx, 1), max(cy, 1))
592
+
593
+ def _subtitle(self, slide, lay, box, text: str, *, size_key="subtitle",
594
+ color: Optional[str] = None, align: str = "left", anchor: str = "top"):
595
+ if not text:
596
+ return None
597
+ pal = self._palette(lay)
598
+ return self._textbox(slide, box, [Para(text=text, size=self.fz(size_key),
599
+ color=color or pal["muted"],
600
+ font=self.tpl.cjk_font(), align=align,
601
+ bullet="")], anchor=anchor)
602
+
603
+ # ==================================================================
604
+ # 各页型
605
+ # ==================================================================
606
+ def _slide_cover(self, slide, lay, spec, index, total):
607
+ pal = self._palette(lay)
608
+ prof_box = self.profile.cover_title_box if self.profile else None
609
+ if prof_box:
610
+ tph = lay.find_title() if lay else None
611
+ size = (tph.font_size_pt if tph and tph.font_size_pt else None) or self.fz("cover_title")
612
+ self._textbox(slide, prof_box,
613
+ [Para(text=spec.get("title", ""), size=size,
614
+ bold=True, color=None,
615
+ font=(tph.font_name if tph else None) or self.tpl.heading_font(),
616
+ align="center", bullet="")], anchor="middle")
617
+ self._cover_meta(slide, lay, spec, pal, after=prof_box)
618
+ return
619
+ ph = lay.find_title() if lay else None
620
+ if ph is not None:
621
+ base = ph.font_size_pt or self.fz("cover_title")
622
+ self._fill_ph(slide, ph, [Para(
623
+ text=spec.get("title", ""), size=base, bold=True,
624
+ font=ph.font_name or self.tpl.heading_font(), bullet="",
625
+ )], anchor="middle")
626
+ else:
627
+ self._textbox(slide, (int(self.W * 0.1), int(self.H * 0.34), int(self.W * 0.8), int(self.H * 0.18)),
628
+ [Para(text=spec.get("title", ""), size=self.fz("cover_title"), bold=True,
629
+ color=pal["text"], font=self.tpl.heading_font(), align="left", bullet="")],
630
+ anchor="middle")
631
+
632
+ sub = spec.get("subtitle", "")
633
+ # 副标题占位符
634
+ if lay:
635
+ sub_ph = next((p for p in lay.placeholders if p.ph_type == "subtitle"), None)
636
+ if sub_ph is not None and sub:
637
+ self._fill_ph(slide, sub_ph, [Para(
638
+ text=sub, size=sub_ph.font_size_pt or self.fz("cover_sub"),
639
+ color=sub_ph.color, font=sub_ph.font_name or self.tpl.cjk_font(),
640
+ bullet="")], anchor="middle")
641
+ sub = ""
642
+
643
+ tx, ty, tw, th = self._title_box(lay)
644
+ self._cover_meta(slide, lay, spec, pal, after=(tx, ty, tw, th))
645
+
646
+ def _cover_meta(self, slide, lay, spec, pal, after) -> None:
647
+ tx, ty, tw, th = after
648
+ sub = spec.get("subtitle", "")
649
+ if sub:
650
+ self._textbox(slide, (tx, ty + th + int(self.H * 0.025), tw, int(self.H * 0.12)),
651
+ [Para(text=sub, size=self.fz("cover_sub"), color=pal["muted"],
652
+ font=self.tpl.cjk_font(), align="center" if self.profile else None,
653
+ bullet="")])
654
+ meta = " ".join(x for x in [spec.get("teacher", ""), spec.get("school", ""),
655
+ spec.get("date", "")] if x)
656
+ if meta:
657
+ self._textbox(slide, (tx, int(self.H * 0.80), tw, int(self.H * 0.09)),
658
+ [Para(text=meta, size=self.fz("cover_meta"), color=pal["muted"],
659
+ font=self.tpl.cjk_font(), align="center" if self.profile else None,
660
+ bullet="")])
661
+
662
+ def _slide_toc(self, slide, lay, spec, index, total):
663
+ pal = self._palette(lay)
664
+ items = [it for it in spec.get("items", []) if it.get("text")]
665
+ spec = {**spec, "title": spec.get("title") or "目 录"}
666
+ bx, by, bw, bh = self._header(slide, lay, spec, "目录")
667
+ if not items:
668
+ return
669
+ n = len(items)
670
+ two_col = n > 6 and self.W / float(self.H) > 1.4
671
+ per_col = (n + 1) // 2 if two_col else n
672
+ col_w = bw // 2 - int(self.W * 0.02) if two_col else bw
673
+ row_h = max(int(bh / max(per_col, 1)), emu_from_inch(0.42))
674
+ num_size = self.fz("toc_num")
675
+ item_size = self.fz("toc_item")
676
+
677
+ for i, it in enumerate(items):
678
+ col = i // per_col if two_col else 0
679
+ row = i % per_col if two_col else i
680
+ x = bx + col * (col_w + int(self.W * 0.04))
681
+ y = by + row * row_h
682
+ entry_h = int(row_h * 0.78)
683
+ accent = self.accents[i % len(self.accents)]
684
+ # 序号
685
+ self._textbox(slide, (x, y, int(col_w * 0.14), entry_h),
686
+ [Para(text=f"{i + 1:02d}", size=num_size, bold=True, color=accent,
687
+ font=self.tpl.heading_font(), align="left", bullet="")],
688
+ anchor="middle", fit=False)
689
+ # 文本
690
+ self._textbox(slide, (x + int(col_w * 0.16), y, int(col_w * 0.82), entry_h),
691
+ [Para(text=it["text"], size=item_size, color=pal["text"],
692
+ font=self.tpl.cjk_font(), bullet="")],
693
+ anchor="middle")
694
+ # 分隔线
695
+ if row < per_col - 1:
696
+ add_line(slide, x, y + row_h - int(emu_from_inch(0.02)),
697
+ x + col_w, y + row_h - int(emu_from_inch(0.02)),
698
+ color=pal["card_line"], width_pt=0.75)
699
+
700
+ def _slide_section(self, slide, lay, spec, index, total):
701
+ pal = self._palette(lay)
702
+ num = spec.get("index", "")
703
+ title = spec.get("title", "")
704
+ ph = lay.find_title() if lay else None
705
+ if ph is not None:
706
+ base = ph.font_size_pt or self.fz("section_title")
707
+ self._fill_ph(slide, ph, [Para(
708
+ text=title, size=base, bold=True, font=ph.font_name or self.tpl.heading_font(),
709
+ bullet="")], anchor="middle")
710
+ tx, ty, tw, th = ph.box
711
+ else:
712
+ tx, ty, tw, th = self._title_box(lay)
713
+ self._textbox(slide, (tx, ty, tw, th),
714
+ [Para(text=title, size=self.fz("section_title"), bold=True,
715
+ color=pal["text"], font=self.tpl.heading_font(), bullet="")],
716
+ anchor="middle")
717
+
718
+ bottom = int(self.H * 0.90)
719
+ accent = self.accents[(int(num) - 1) % len(self.accents)] if str(num).isdigit() else self.primary
720
+ num_h = int(self.H * 0.18)
721
+ num_y = max(int(self.H * 0.05), ty - num_h - int(self.H * 0.015))
722
+ self._textbox(slide, (tx, num_y, tw, max(ty - num_y - int(self.H * 0.01), int(self.H * 0.08))),
723
+ [Para(text=f"{num:02d}" if str(num).isdigit() else str(num),
724
+ size=self.fz("section_num"), bold=True, color=accent,
725
+ font=self.tpl.heading_font(), bullet="")], anchor="middle", fit=False)
726
+
727
+ cursor = ty + th + int(self.H * 0.02)
728
+ add_line(slide, tx, cursor, tx + int(tw * 0.28), cursor, color=accent, width_pt=3.5)
729
+ cursor += int(self.H * 0.025)
730
+
731
+ sub = spec.get("subtitle", "")
732
+ if sub and bottom - cursor > int(self.H * 0.09):
733
+ h = min(int(self.H * 0.1), bottom - cursor)
734
+ self._subtitle(slide, lay, (tx, cursor, tw, h), sub)
735
+ cursor += h + int(self.H * 0.015)
736
+
737
+ items = [it for it in spec.get("items", []) if it.get("text")]
738
+ if items and bottom - cursor > int(self.H * 0.1):
739
+ self._bullets(slide, lay, (tx, cursor, int(tw * 0.72), bottom - cursor), items,
740
+ base_size=self.body_size() * 0.82)
741
+
742
+ def _slide_objectives(self, slide, lay, spec, index, total):
743
+ pal = self._palette(lay)
744
+ spec = {**spec, "title": spec.get("title") or "学习目标"}
745
+ bx, by, bw, bh = self._header(slide, lay, spec, "学习目标")
746
+ sub = spec.get("subtitle", "")
747
+ if sub:
748
+ self._textbox(slide, (bx, by, bw, int(self.H * 0.07)),
749
+ [Para(text=sub, size=self.csize("lead"), color=pal["muted"],
750
+ font=self.tpl.cjk_font(), bullet="")])
751
+ by += int(self.H * 0.085)
752
+ bh -= int(self.H * 0.085)
753
+ # 支持用 pattern 排目标页(例如把"知识/能力/素养"排成三张卡的本课导航页)
754
+ pattern = str(spec.get("pattern") or "").strip().lower()
755
+ if pattern and pattern != "auto" and (spec.get("cards") or spec.get("compare")
756
+ or spec.get("timeline")):
757
+ if self._render_pattern(slide, lay, (bx, by, bw, bh), spec, [], pattern):
758
+ return
759
+ goals = [g for g in spec.get("goals", []) if g.get("text")]
760
+ keys = [g for g in spec.get("key_points", []) if g.get("text")]
761
+ hards = [g for g in spec.get("hard_points", []) if g.get("text")]
762
+
763
+ has_cards = bool(keys or hards)
764
+ goals_h = int(bh * (0.52 if has_cards else 1.0))
765
+ if goals:
766
+ self._bullets(slide, lay, (bx, by, bw, goals_h), goals,
767
+ base_size=self.body_size(), bullet_char="✓",
768
+ bullet_color=self.accents[2 % len(self.accents)] if len(self.accents) > 2 else self.primary)
769
+
770
+ if has_cards:
771
+ top = by + goals_h + int(self.H * 0.03)
772
+ card_h = max(bh - goals_h - int(self.H * 0.03), int(self.H * 0.2))
773
+ n = 2 if (keys and hards) else 1
774
+ gap = int(bw * 0.03)
775
+ card_w = (bw - gap * (n - 1)) // n
776
+ specs = []
777
+ if keys:
778
+ specs.append(("重点", keys, self.accents[1 % len(self.accents)]))
779
+ if hards:
780
+ specs.append(("难点", hards, self.accents[5 % len(self.accents)]))
781
+ for i, (label, pts, color) in enumerate(specs):
782
+ x = bx + i * (card_w + gap)
783
+ self._card(slide, lay, (x, top, card_w, card_h), label, pts, color)
784
+
785
+ def _card(self, slide, lay, box, label, pts, color):
786
+ pal = self._palette(lay)
787
+ x, y, w, h = box
788
+ rounded_box(slide, box, fill=pal["card"], fill_alpha=pal["card_alpha"],
789
+ line=color, line_width_pt=1.25, radius=0.06)
790
+ pad = int(w * 0.06)
791
+ head_h = int(h * 0.22)
792
+ self._textbox(slide, (x + pad, y + int(h * 0.05), w - 2 * pad, head_h),
793
+ [Para(text=label, size=self.csize("block_title"), bold=True, color=color,
794
+ font=self.tpl.heading_font(), bullet="")], anchor="middle", fit=False)
795
+ inner = (x + pad, y + int(h * 0.05) + head_h, w - 2 * pad, h - head_h - int(h * 0.1))
796
+ self._bullets(slide, lay, inner, pts, base_size=self.body_size() * 0.85,
797
+ color=pal["card_text"], bullet_char="•", bullet_color=color)
798
+
799
+ def _slide_content(self, slide, lay, spec, index, total):
800
+ pal = self._palette(lay)
801
+ images = self._resolve_images(spec)
802
+ bullets = [b for b in spec.get("bullets", []) if b.get("text")]
803
+ bx, by, bw, bh = self._header(slide, lay, spec, "科学解释")
804
+ # 用了标题条就不再用版式的正文占位符(位置对不上)
805
+ ph = None if self.use_chip else (lay.find_body() if lay else None)
806
+ sub = spec.get("subtitle", "")
807
+ if sub:
808
+ self._textbox(slide, (bx, by, bw, int(self.H * 0.07)),
809
+ [Para(text=sub, size=self.csize("lead"), color=pal["muted"],
810
+ font=self.tpl.cjk_font(), bullet="")])
811
+ by += int(self.H * 0.085)
812
+ bh -= int(self.H * 0.085)
813
+
814
+ if sub and not images and not bullets:
815
+ return
816
+
817
+ # 版面骨架:显式 pattern 优先,否则按内容自动挑一个。
818
+ # 背景/配色/模块标签/标题条一律沿用模板,只重排正文区。
819
+ pattern = self._resolve_pattern(spec, bullets, images)
820
+ side_override = None
821
+ if pattern in ("image_left", "image_right"):
822
+ side_override = "left" if pattern == "image_left" else "right"
823
+ pattern = "bullets"
824
+ if pattern != "bullets":
825
+ if self._render_pattern(slide, lay, (bx, by, bw, bh), spec, images, pattern):
826
+ return
827
+
828
+ if not images:
829
+ # 有副标题时不能再往正文占位符里写,否则会和副标题重叠
830
+ if ph is not None and bullets and not sub:
831
+ base = ph.font_size_pt or self.fz("bullet0")
832
+ paras = self._bullets_to_paras(bullets, base, ph.font_name, bullet_char=None)
833
+ if self._fill_ph(slide, ph, paras, anchor="top"):
834
+ return
835
+ if bullets:
836
+ self._bullets(slide, lay, (bx, by, bw, bh), bullets, base_size=self.body_size())
837
+ return
838
+
839
+ # 有配图:左文右图(可切换)
840
+ side = (side_override or spec.get("image_side") or "right").lower()
841
+ gap = int(bw * 0.04)
842
+ if side in ("top", "bottom") or len(images) > 1:
843
+ img_h = int(bh * (0.45 if side != "bottom" else 0.45))
844
+ txt_h = bh - img_h - int(self.H * 0.03)
845
+ if side == "bottom":
846
+ text_box = (bx, by, bw, txt_h)
847
+ img_box = (bx, by + txt_h + int(self.H * 0.03), bw, img_h)
848
+ else:
849
+ img_box = (bx, by, bw, img_h)
850
+ text_box = (bx, by + img_h + int(self.H * 0.03), bw, txt_h)
851
+ if bullets:
852
+ self._bullets(slide, lay, text_box, bullets, base_size=self.body_size())
853
+ self._images_row(slide, images, img_box)
854
+ else:
855
+ txt_w = int(bw * 0.54)
856
+ img_w = bw - txt_w - gap
857
+ if side == "left":
858
+ img_box = (bx, by, img_w, bh)
859
+ txt_box = (bx + img_w + gap, by, txt_w, bh)
860
+ else:
861
+ txt_box = (bx, by, txt_w, bh)
862
+ img_box = (bx + txt_w + gap, by, img_w, bh)
863
+ if bullets:
864
+ self._bullets(slide, lay, txt_box, bullets, base_size=self.body_size())
865
+ self._images_row(slide, images, img_box)
866
+ cap = spec.get("image_caption")
867
+ if cap:
868
+ self._textbox(slide, (img_box[0], img_box[1] + img_box[3], img_box[2], int(self.H * 0.06)),
869
+ [Para(text=cap, size=self.csize("caption"), color=pal["muted"],
870
+ font=self.tpl.cjk_font(), align="center", bullet="")])
871
+
872
+ # ==================================================================
873
+ # 版面骨架(pattern)—— 只重排正文区,模板骨架/配色/背景一律不动
874
+ # ==================================================================
875
+ def _resolve_pattern(self, spec: dict, bullets: Sequence[dict], images) -> str:
876
+ """显式 pattern 优先;没有就按内容形态自动挑一个(确定性,不用随机)。"""
877
+ return guess_pattern(spec, bullets)
878
+
879
+ # 这几类骨架只画"图形化主体",不画 bullets——若规格里还写了要点,
880
+ # 缩小主体区域、在底部留一条要点带渲染,避免内容被静默丢弃。
881
+ _PATTERNS_WITH_FOOTER = ("compare", "cards", "levels", "timeline")
882
+
883
+ def _render_pattern(self, slide, lay, box, spec, images, pattern: str) -> bool:
884
+ fn = getattr(self, f"_pattern_{pattern}", None)
885
+ if fn is None:
886
+ self.warn(f"未知版面骨架 {pattern!r},改用默认排版")
887
+ return False
888
+ try:
889
+ bullets = [b for b in (spec.get("bullets") or [])
890
+ if isinstance(b, dict) and str(b.get("text") or "").strip()]
891
+ if pattern in self._PATTERNS_WITH_FOOTER and bullets:
892
+ bx, by, bw, bh = box
893
+ strip_h = max(min(int(bh * 0.22), emu_from_inch(1.4)), int(bh * 0.14))
894
+ main_box = (bx, by, bw, bh - strip_h)
895
+ if fn(slide, lay, main_box, spec, images):
896
+ self._pattern_footer(slide, lay,
897
+ (bx, by + bh - strip_h, bw, strip_h), bullets)
898
+ return True
899
+ return False
900
+ return bool(fn(slide, lay, box, spec, images))
901
+ except Exception as exc: # 任何异常都退回默认排版,不炸整份课件
902
+ self.warn(f"版面骨架 {pattern!r} 渲染失败(改用默认排版):{exc}")
903
+ return False
904
+
905
+ def _pattern_footer(self, slide, lay, box, bullets) -> None:
906
+ """模式页底部的要点带:承接 compare/cards/levels/timeline 主区域放不下的要点。"""
907
+ pal = self._palette(lay)
908
+ paras = self._bullets_to_paras(bullets, self.csize("item"), self.tpl.cjk_font())
909
+ self._textbox(slide, box, paras, anchor="middle")
910
+
911
+ def _group_bullets(self, bullets: Sequence[dict]) -> List[dict]:
912
+ """按 level-0 把要点分组:[{title, items:[子项]}]。"""
913
+ groups: List[dict] = []
914
+ for b in bullets:
915
+ text = (b.get("text") or "").strip()
916
+ if not text:
917
+ continue
918
+ if int(b.get("level", 0) or 0) == 0:
919
+ groups.append({"title": text, "items": []})
920
+ elif groups:
921
+ groups[-1]["items"].append(text)
922
+ else:
923
+ groups.append({"title": "", "items": [text]})
924
+ return groups
925
+
926
+ def _steps_data(self, spec: dict) -> List[dict]:
927
+ src = [s for s in (spec.get("steps") or []) if s.get("text")]
928
+ if not src:
929
+ src = [b for b in spec.get("bullets", []) if b.get("text")]
930
+ return self._group_bullets(src)
931
+
932
+ def _cards_data(self, spec: dict) -> List[dict]:
933
+ cards = [c for c in (spec.get("cards") or []) if c.get("title") or c.get("items")]
934
+ if cards:
935
+ return cards
936
+ return self._group_bullets([b for b in spec.get("bullets", []) if b.get("text")])
937
+
938
+ def _accent(self, i: int) -> str:
939
+ return self.accents[i % len(self.accents)] if self.accents else self.primary
940
+
941
+ # ---- 文字润色:**关键词** 内嵌强调色 ---------------------------------
942
+ _HL_RE = re.compile(r"\*\*(.+?)\*\*")
943
+
944
+ def _keyword_color(self) -> str:
945
+ """关键词高亮色:优先用模板标题色(与讲义式润色风格一致),否则主色。"""
946
+ if self.profile and self.profile.title_chip and self.profile.title_chip.style.color:
947
+ return self.profile.title_chip.style.color
948
+ return self.primary
949
+
950
+ def _expand_highlight(self, p: Para) -> Para:
951
+ """把段落文本里的 ``**关键词**`` 变成"强调色 + 加粗"的 run,其余文字保持原色。"""
952
+ raw = str(p.text or "")
953
+ if "**" not in raw or p.runs:
954
+ return p
955
+ runs: List[dict] = []
956
+ pos = 0
957
+ for m in self._HL_RE.finditer(raw):
958
+ if m.start() > pos:
959
+ runs.append({"text": raw[pos:m.start()], "color": p.color})
960
+ runs.append({"text": m.group(1), "bold": True, "color": self._keyword_color()})
961
+ pos = m.end()
962
+ if pos < len(raw):
963
+ runs.append({"text": raw[pos:], "color": p.color})
964
+ if not runs:
965
+ return p
966
+ q = Para(**{**p.__dict__})
967
+ q.text = self._HL_RE.sub(r"\1", raw) # 去掉标记,便于精确测量
968
+ q.runs = runs
969
+ return q
970
+
971
+ def _pattern_steps(self, slide, lay, box, spec, images) -> bool:
972
+ """编号步骤卡 + 向下箭头:适合"第 1 步 / 第 2 步"这类递进内容。"""
973
+ pal = self._palette(lay)
974
+ groups = self._steps_data(spec)
975
+ if len(groups) < 2:
976
+ return False
977
+ x, y, w, h = box
978
+ if images:
979
+ img_w = int(w * 0.34)
980
+ gap = int(w * 0.035)
981
+ ax, ay, aw, ah = x, y, w - img_w - gap, h
982
+ else:
983
+ img_w = gap = 0
984
+ ax, ay, aw, ah = box
985
+ n = len(groups)
986
+ row_gap = max(int(ah * 0.05), emu_from_inch(0.12))
987
+ each = (ah - row_gap * (n - 1)) // n
988
+ if each < emu_from_inch(0.5):
989
+ return False
990
+ strip = int(aw * 0.012)
991
+ for i, g in enumerate(groups):
992
+ gy = ay + i * (each + row_gap)
993
+ accent = self._accent(i)
994
+ rounded_box(slide, (ax, gy, aw, each), fill=pal["card"],
995
+ fill_alpha=pal["card_alpha"], line=pal["card_line"], radius=0.1)
996
+ rounded_box(slide, (ax, gy + int(each * 0.1), strip, int(each * 0.8)),
997
+ fill=accent, line=None, radius=0.5)
998
+ if i < n - 1:
999
+ add_shape(slide, MSO_SHAPE.DOWN_ARROW,
1000
+ (ax + aw // 2 - int(aw * 0.011), gy + each,
1001
+ int(aw * 0.022), row_gap),
1002
+ fill=self._accent(0), line=None)
1003
+ paras = [Para(text=f"{i + 1}. {g['title']}", size=self.csize("block_title"),
1004
+ bold=True, color=accent, font=self.tpl.cjk_font(), bullet="")]
1005
+ for it in g["items"][:3]:
1006
+ paras.append(Para(text=it, size=self.csize("item"), color=pal["muted"],
1007
+ font=self.tpl.cjk_font(), bullet="", space_before=2))
1008
+ pad = int(aw * 0.045)
1009
+ self._textbox(slide, (ax + pad + strip, gy, aw - 2 * pad - strip, each),
1010
+ paras, anchor="middle")
1011
+ if images:
1012
+ self._images_row(slide, images, (ax + aw + gap, y, img_w, h))
1013
+ return True
1014
+
1015
+ def _pattern_flow(self, slide, lay, box, spec, images) -> bool:
1016
+ """横向方框 + 箭头:适合 2–5 个环节的流程。"""
1017
+ pal = self._palette(lay)
1018
+ groups = self._steps_data(spec)
1019
+ if not (2 <= len(groups) <= 5):
1020
+ return False
1021
+ x, y, w, h = box
1022
+ n = len(groups)
1023
+ if images:
1024
+ flow_h = int(h * 0.5)
1025
+ img_box = (x, y + flow_h + int(h * 0.06), w, h - flow_h - int(h * 0.06))
1026
+ fy = y
1027
+ else:
1028
+ flow_h = int(h * 0.66)
1029
+ fy = y + (h - flow_h) // 2
1030
+ img_box = None
1031
+ arrow_w = int(w * 0.05)
1032
+ gap = int(w * 0.012)
1033
+ bw_ = (w - arrow_w * (n - 1) - gap * 2 * (n - 1)) // n
1034
+ if bw_ < emu_from_inch(0.95):
1035
+ return False
1036
+ for i, g in enumerate(groups):
1037
+ bx_ = x + i * (bw_ + arrow_w + gap * 2)
1038
+ accent = self._accent(i)
1039
+ rounded_box(slide, (bx_, fy, bw_, flow_h), fill=pal["card"],
1040
+ fill_alpha=pal["card_alpha"], line=accent, line_width_pt=1.6, radius=0.12)
1041
+ badge = min(int(flow_h * 0.28), int(bw_ * 0.24))
1042
+ by_ = fy + int(flow_h * 0.1)
1043
+ rounded_box(slide, (bx_ + (bw_ - badge) // 2, by_, badge, badge),
1044
+ fill=accent, line=None, radius=0.5)
1045
+ self._textbox(slide, (bx_ + (bw_ - badge) // 2, by_, badge, badge),
1046
+ [Para(text=str(i + 1), size=max(self.csize("item") - 1, 10), bold=True,
1047
+ color=readable_on(accent), font=self.tpl.heading_font(),
1048
+ align="center", bullet="")], anchor="middle", fit=False)
1049
+ paras = [Para(text=g["title"], size=self.csize("block_title"), bold=True, color=pal["text"],
1050
+ font=self.tpl.cjk_font(), align="center", bullet="")]
1051
+ if g["items"]:
1052
+ paras.append(Para(text=" ".join(g["items"][:2]), size=self.csize("item"),
1053
+ color=pal["muted"], font=self.tpl.cjk_font(), align="center",
1054
+ bullet="", space_before=2))
1055
+ pad = int(bw_ * 0.07)
1056
+ self._textbox(slide, (bx_ + pad, fy + int(flow_h * 0.34), bw_ - 2 * pad,
1057
+ int(flow_h * 0.64)), paras)
1058
+ if i < n - 1:
1059
+ add_shape(slide, MSO_SHAPE.RIGHT_ARROW,
1060
+ (bx_ + bw_ + gap, fy + int(flow_h * 0.42), arrow_w, int(flow_h * 0.16)),
1061
+ fill=accent, line=None)
1062
+ if img_box:
1063
+ self._images_row(slide, images, img_box)
1064
+ return True
1065
+
1066
+ def _pattern_compare(self, slide, lay, box, spec, images) -> bool:
1067
+ """左右两栏对比 + 中间圆标:适合"A 与 B"的并列对照。"""
1068
+ pal = self._palette(lay)
1069
+ cmp = spec.get("compare") or {}
1070
+ left = cmp.get("left") or {}
1071
+ right = cmp.get("right") or {}
1072
+ if not (left.get("title") or left.get("items") or right.get("title") or right.get("items")):
1073
+ return False
1074
+ x, y, w, h = box
1075
+ if images:
1076
+ card_h = int(h * 0.68)
1077
+ img_box = (x, y + card_h + int(h * 0.05), w, h - card_h - int(h * 0.05))
1078
+ else:
1079
+ card_h = h
1080
+ img_box = None
1081
+ center = int(w * 0.1)
1082
+ cw = (w - center) // 2
1083
+ for idx, side_data in enumerate((left, right)):
1084
+ col = self._accent(0 if idx == 0 else 4)
1085
+ cx = x if idx == 0 else x + cw + center
1086
+ rounded_box(slide, (cx, y, cw, card_h), fill=pal["card"], fill_alpha=pal["card_alpha"],
1087
+ line=col, line_width_pt=1.6, radius=0.08)
1088
+ head_h = int(card_h * 0.24)
1089
+ rounded_box(slide, (cx, y, cw, head_h), fill=col, line=None, radius=0.12)
1090
+ self._textbox(slide, (cx, y, cw, head_h),
1091
+ [Para(text=side_data.get("title", ""), size=self.csize("block_title"),
1092
+ bold=True, color=readable_on(col), font=self.tpl.heading_font(),
1093
+ align="center", bullet="")], anchor="middle")
1094
+ paras = [Para(text=it, size=self.csize("item"), color=pal["text"],
1095
+ font=self.tpl.cjk_font(), bullet="●", space_before=5)
1096
+ for it in side_data.get("items", [])]
1097
+ if paras:
1098
+ self._textbox(slide, (cx + int(cw * 0.07), y + head_h,
1099
+ cw - int(cw * 0.14), card_h - head_h), paras, anchor="middle")
1100
+ vs = str(cmp.get("vs") or "VS")
1101
+ d = min(int(w * 0.08), max(int(card_h * 0.18), emu_from_inch(0.32)))
1102
+ vx = x + cw + (center - d) // 2
1103
+ vy = y + card_h // 2 - d // 2
1104
+ rounded_box(slide, (vx, vy, d, d), fill=self.primary, line=None, radius=0.5)
1105
+ self._textbox(slide, (vx, vy, d, d),
1106
+ [Para(text=vs, size=self.csize("item"), bold=True,
1107
+ color=readable_on(self.primary), font=self.tpl.heading_font(),
1108
+ align="center", bullet="")], anchor="middle", fit=False)
1109
+ if img_box:
1110
+ self._images_row(slide, images, img_box)
1111
+ return True
1112
+
1113
+ def _pattern_cards(self, slide, lay, box, spec, images) -> bool:
1114
+ """卡片网格:适合并列的 2–6 个要点(每条要点带小标题)。
1115
+
1116
+ deck options(可选,默认保持旧行为):
1117
+ cards_font_scale 卡片内文字相对正文基准的字号缩放(默认 1.0,<1 字更小)
1118
+ cards_max_height_in 卡片最大高度(英寸,默认 None 不限制,单行/多行都生效)
1119
+ """
1120
+ pal = self._palette(lay)
1121
+ cards = self._cards_data(spec)
1122
+ if not (2 <= len(cards) <= 6):
1123
+ return False
1124
+ try:
1125
+ font_scale = float(self.opts.get("cards_font_scale", 1.0))
1126
+ except (TypeError, ValueError):
1127
+ font_scale = 1.0
1128
+ try:
1129
+ max_h = emu_from_inch(float(self.opts.get("cards_max_height_in")))
1130
+ except (TypeError, ValueError):
1131
+ max_h = 0
1132
+ x, y, w, h = box
1133
+ if images:
1134
+ area_w = int(w * 0.64)
1135
+ img_box = (x + area_w + int(w * 0.03), y, w - area_w - int(w * 0.03), h)
1136
+ else:
1137
+ area_w, img_box = w, None
1138
+ n = len(cards)
1139
+ cols = n if n <= 3 else (2 if n == 4 else 3)
1140
+ rows = (n + cols - 1) // cols
1141
+ gap_x = int(area_w * 0.025)
1142
+ gap_y = int(h * 0.045)
1143
+ cw = (area_w - gap_x * (cols - 1)) // cols
1144
+ ch = (h - gap_y * (rows - 1)) // rows
1145
+ # 多行卡片限制单卡高度;单行卡片默认占满内容高度,
1146
+ # 设置了 cards_max_height_in 时单行也限制,避免卡片被拉得过长
1147
+ if rows > 1:
1148
+ ch = min(ch, emu_from_inch(2.5))
1149
+ if max_h > 0:
1150
+ ch = min(ch, max_h)
1151
+ grid_h = ch * rows + gap_y * (rows - 1)
1152
+ y0 = y + max((h - grid_h) // 2, 0)
1153
+ if cw < emu_from_inch(1.05) or ch < emu_from_inch(0.62):
1154
+ return False
1155
+ title_size = max(self.csize("block_title") * font_scale, 12.0)
1156
+ item_size = max(self.csize("item") * font_scale, 11.0)
1157
+ for i, c in enumerate(cards):
1158
+ r, col_i = divmod(i, cols)
1159
+ cx = x + col_i * (cw + gap_x)
1160
+ cy = y0 + r * (ch + gap_y)
1161
+ accent = self._accent(i)
1162
+ rounded_box(slide, (cx, cy, cw, ch), fill=pal["card"], fill_alpha=pal["card_alpha"],
1163
+ line=pal["card_line"], radius=0.1)
1164
+ rounded_box(slide, (cx, cy + int(ch * 0.08), int(cw * 0.02), int(ch * 0.84)),
1165
+ fill=accent, line=None, radius=0.5)
1166
+ paras = [Para(text=c["title"], size=title_size, bold=True, color=accent,
1167
+ font=self.tpl.cjk_font(), bullet="")]
1168
+ for it in c["items"][:2]:
1169
+ paras.append(Para(text=it, size=item_size, color=pal["text"],
1170
+ font=self.tpl.cjk_font(), bullet="", space_before=3))
1171
+ pad = int(cw * 0.07)
1172
+ self._textbox(slide, (cx + pad, cy, cw - pad - int(cw * 0.04), ch), paras, anchor="middle")
1173
+ if img_box:
1174
+ self._images_row(slide, images, img_box)
1175
+ return True
1176
+
1177
+ def _pattern_timeline(self, slide, lay, box, spec, images) -> bool:
1178
+ """横向时间轴:适合"先有 A、后有 B"的先后/因果关系。"""
1179
+ pal = self._palette(lay)
1180
+ items = [t for t in (spec.get("timeline") or []) if t.get("text") or t.get("time")]
1181
+ if len(items) < 2:
1182
+ return False
1183
+ x, y, w, h = box
1184
+ if images:
1185
+ area_w = int(w * 0.64)
1186
+ img_box = (x + area_w + int(w * 0.03), y, w - area_w - int(w * 0.03), h)
1187
+ else:
1188
+ area_w, img_box = w, None
1189
+ n = len(items)
1190
+ axis_y = y + h // 2
1191
+ add_line(slide, x, axis_y, x + area_w, axis_y, color=pal["card_line"], width_pt=2.4)
1192
+ seg = area_w // n
1193
+ for i, t in enumerate(items):
1194
+ accent = self._accent(i)
1195
+ cxm = x + seg // 2 + i * seg
1196
+ d = min(int(h * 0.09), emu_from_inch(0.34))
1197
+ rounded_box(slide, (cxm - d // 2, axis_y - d // 2, d, d), fill=accent, line=None, radius=0.5)
1198
+ above = (i % 2 == 0)
1199
+ bh_ = int(h * 0.34)
1200
+ by_ = (axis_y - d // 2 - bh_ - int(h * 0.03)) if above else (axis_y + d // 2 + int(h * 0.03))
1201
+ tw = int(seg * 0.94)
1202
+ paras = []
1203
+ if t.get("time"):
1204
+ paras.append(Para(text=t["time"], size=self.csize("item"), bold=True, color=accent,
1205
+ font=self.tpl.heading_font(), align="center", bullet=""))
1206
+ paras.append(Para(text=t["text"], size=self.csize("item"), color=pal["text"],
1207
+ font=self.tpl.cjk_font(), align="center", bullet="",
1208
+ space_before=2 if t.get("time") else 0))
1209
+ self._textbox(slide, (cxm - tw // 2, by_, tw, bh_), paras, anchor="middle")
1210
+ if img_box:
1211
+ self._images_row(slide, images, img_box)
1212
+ return True
1213
+
1214
+ def _pattern_levels(self, slide, lay, box, spec, images) -> bool:
1215
+ """分级/强弱梯度:适合"绿 < 黄 < 深红""一级 < 二级 < 三级"这类递进等级。
1216
+
1217
+ 用同一色相的深浅阶梯 + 逐级放大的条块表达"由弱到强",
1218
+ 最上一级用语义色(若内容给了 color)或最强强调色收尾。
1219
+ """
1220
+ pal = self._palette(lay)
1221
+ cards = self._cards_data(spec)
1222
+ if not (2 <= len(cards) <= 4):
1223
+ return False
1224
+ x, y, w, h = box
1225
+ if images:
1226
+ area_w = int(w * 0.6)
1227
+ img_box = (x + area_w + int(w * 0.03), y, w - area_w - int(w * 0.03), h)
1228
+ else:
1229
+ area_w, img_box = w, None
1230
+ n = len(cards)
1231
+ gap = int(h * 0.05)
1232
+ each = (h - gap * (n - 1)) // n
1233
+ if each < emu_from_inch(0.5):
1234
+ return False
1235
+ base = self._accent(0)
1236
+ for i, c in enumerate(cards):
1237
+ cy = y + i * (each + gap)
1238
+ t = (i + 1) / n # 由弱到强的强度
1239
+ # 语义色优先(例如雷达图三色),否则用模板强调色的深浅阶梯
1240
+ color = parse_color(c.get("color")) or mix("FFFFFF", base, 0.35 + 0.65 * t * 0.8)
1241
+ block_w = int(area_w * (0.34 + 0.66 * t))
1242
+ rounded_box(slide, (x, cy, area_w, each), fill=pal["card"],
1243
+ fill_alpha=pal["card_alpha"], line=pal["card_line"], radius=0.08)
1244
+ rounded_box(slide, (x, cy, block_w, each), fill=color, line=None, radius=0.08)
1245
+ pad = int(area_w * 0.04)
1246
+ label = " ".join([c["title"]] + list(c["items"][:2]))
1247
+ self._textbox(slide, (x + pad, cy, area_w - 2 * pad, each),
1248
+ [Para(text=label, size=self.csize("block_title"), bold=True,
1249
+ color=readable_on(color), font=self.tpl.cjk_font(), bullet="")],
1250
+ anchor="middle")
1251
+ if img_box:
1252
+ self._images_row(slide, images, img_box)
1253
+ return True
1254
+
1255
+ def _pattern_callout(self, slide, lay, box, spec, images) -> bool:
1256
+ """重点框 + 要点:把第一句提炼成醒目的核心问题/结论。"""
1257
+ pal = self._palette(lay)
1258
+ bullets = [b for b in spec.get("bullets", []) if b.get("text")]
1259
+ if len(bullets) < 2:
1260
+ return False
1261
+ x, y, w, h = box
1262
+ if images:
1263
+ area_w = int(w * 0.62)
1264
+ img_box = (x + area_w + int(w * 0.03), y, w - area_w - int(w * 0.03), h)
1265
+ else:
1266
+ area_w, img_box = w, None
1267
+ key = str(spec.get("key") or bullets[0].get("text") or "").strip()
1268
+ rest = bullets[1:]
1269
+ key_h = int(h * 0.42)
1270
+ accent = self._accent(0)
1271
+ # 底色用主题调色板(深色模板/浅色模板都成立),强调色只出现在描边和色条上
1272
+ rounded_box(slide, (x, y, area_w, key_h), fill=pal["card"],
1273
+ fill_alpha=pal["card_alpha"], line=accent, line_width_pt=2.0, radius=0.1)
1274
+ rounded_box(slide, (x, y + int(key_h * 0.12), int(area_w * 0.012), int(key_h * 0.76)),
1275
+ fill=accent, line=None, radius=0.5)
1276
+ pad = int(area_w * 0.05)
1277
+ # 核心问题/结论要成为视觉锚点:比正文更大
1278
+ key_size = max(self.body_size() * 1.4, self.csize("block_title"))
1279
+ self._textbox(slide, (x + pad, y, area_w - 2 * pad, key_h),
1280
+ [Para(text=key, size=key_size, bold=True, color=pal["text"],
1281
+ font=self.tpl.cjk_font(), bullet="")], anchor="middle")
1282
+ if rest:
1283
+ self._bullets(slide, lay, (x, y + key_h + int(h * 0.07), area_w,
1284
+ h - key_h - int(h * 0.07)), rest, base_size=self.body_size())
1285
+ if img_box:
1286
+ self._images_row(slide, images, img_box)
1287
+ return True
1288
+
1289
+ def _slide_example(self, slide, lay, spec, index, total):
1290
+ pal = self._palette(lay)
1291
+ spec = {**spec, "title": spec.get("title") or "例题讲解"}
1292
+ bx, by, bw, bh = self._header(slide, lay, spec, "科学解释")
1293
+ steps = [s for s in spec.get("steps", []) if s.get("text")]
1294
+ bullets = [b for b in spec.get("bullets", []) if b.get("text")]
1295
+ accent = self.accents[0]
1296
+
1297
+ if bullets:
1298
+ qh = int(bh * 0.3)
1299
+ rounded_box(slide, (bx, by, bw, qh), fill=pal["card"], fill_alpha=pal["card_alpha"],
1300
+ line=pal["card_line"], radius=0.06)
1301
+ pad = int(bw * 0.035)
1302
+ self._textbox(slide, (bx + pad, by + int(qh * 0.12), bw - 2 * pad, int(qh * 0.76)),
1303
+ self._bullets_to_paras(bullets, self.csize("block_title"), self.tpl.cjk_font(),
1304
+ bullet_char=None), anchor="middle")
1305
+ by += qh + int(self.H * 0.03)
1306
+ bh -= qh + int(self.H * 0.03)
1307
+
1308
+ if steps:
1309
+ self._numbered_steps(slide, lay, (bx, by, bw, bh), steps, accent)
1310
+
1311
+ def _slide_practice(self, slide, lay, spec, index, total):
1312
+ pal = self._palette(lay)
1313
+ spec = {**spec, "title": spec.get("title") or "随堂练习"}
1314
+ bx, by, bw, bh = self._header(slide, lay, spec, "科学问答")
1315
+ note = [b for b in spec.get("bullets", []) if b.get("text")]
1316
+ if note:
1317
+ self._textbox(slide, (bx, by, bw, int(self.H * 0.06)),
1318
+ self._bullets_to_paras(note, self.fz("subtitle"), self.tpl.cjk_font(),
1319
+ bullet_char=None, color=pal["muted"]))
1320
+ by += int(self.H * 0.08)
1321
+ bh -= int(self.H * 0.08)
1322
+
1323
+ questions = [q for q in spec.get("questions", []) if q.get("q") or q.get("options")]
1324
+ if not questions:
1325
+ return
1326
+ inline = self.opts["answer_mode"] == "inline"
1327
+ if self.opts["answer_mode"] == "end":
1328
+ self._answer_pool.append({"title": spec.get("title") or "随堂练习", "questions": questions})
1329
+
1330
+ n = len(questions)
1331
+ gap = int(self.H * 0.022)
1332
+ each = max((bh - gap * (n - 1)) // n, int(self.H * 0.1))
1333
+ for i, q in enumerate(questions):
1334
+ y = by + i * (each + gap)
1335
+ self._question_block(slide, lay, (bx, y, bw, each), i + 1, q, inline)
1336
+
1337
+ def _slide_summary(self, slide, lay, spec, index, total):
1338
+ pal = self._palette(lay)
1339
+ spec = {**spec, "title": spec.get("title") or "课堂小结"}
1340
+ bx, by, bw, bh = self._header(slide, lay, spec, "知识回顾")
1341
+ mm = spec.get("mindmap") or {}
1342
+ branches = [b for b in mm.get("branches", []) if b.get("title") or b.get("items")]
1343
+ if branches and (len(branches) > 1 or branches[0].get("title")):
1344
+ self._mindmap(slide, lay, (bx, by, bw, bh), mm.get("center") or spec.get("title", ""), branches)
1345
+ return
1346
+ points = [p for p in spec.get("points", []) if p.get("text")]
1347
+ if points:
1348
+ self._bullets(slide, lay, (bx, by, bw, bh), points, base_size=self.body_size(),
1349
+ bullet_char="✓",
1350
+ bullet_color=self.accents[2 % len(self.accents)] if len(self.accents) > 2 else self.primary)
1351
+
1352
+ def _slide_homework(self, slide, lay, spec, index, total):
1353
+ pal = self._palette(lay)
1354
+ spec = {**spec, "title": spec.get("title") or "课后作业"}
1355
+ bx, by, bw, bh = self._header(slide, lay, spec, "课后任务")
1356
+ items = [it for it in spec.get("items", []) if it.get("text")]
1357
+ note = spec.get("note", "")
1358
+ if note:
1359
+ bh -= int(self.H * 0.12)
1360
+ if items:
1361
+ self._bullets(slide, lay, (bx, by, bw, bh), items, base_size=self.body_size(),
1362
+ bullet_char="▸", bullet_color=self.primary)
1363
+ if note:
1364
+ ny = by + bh + int(self.H * 0.02)
1365
+ rounded_box(slide, (bx, ny, bw, int(self.H * 0.1)), fill=pal["card"],
1366
+ fill_alpha=pal["card_alpha"], line=pal["card_line"], radius=0.08)
1367
+ pad = int(bw * 0.03)
1368
+ self._textbox(slide, (bx + pad, ny, bw - 2 * pad, int(self.H * 0.1)),
1369
+ [Para(text=note, size=self.csize("item"), color=pal["muted"],
1370
+ font=self.tpl.cjk_font(), bullet="")], anchor="middle")
1371
+
1372
+ def _slide_end(self, slide, lay, spec, index, total):
1373
+ pal = self._palette(lay)
1374
+ title = spec.get("title") or "谢谢观看"
1375
+ prof_box = self.profile.end_title_box if self.profile else None
1376
+ if prof_box:
1377
+ self._textbox(slide, prof_box,
1378
+ [Para(text=title, size=self.fz("cover_title"), bold=True,
1379
+ color=pal["text"], font=self.tpl.heading_font(),
1380
+ align="center", bullet="")], anchor="middle")
1381
+ sub0 = spec.get("subtitle", "")
1382
+ if sub0:
1383
+ self._textbox(slide, (int(self.W * 0.15), prof_box[1] + prof_box[3] + int(self.H * 0.05),
1384
+ int(self.W * 0.7), int(self.H * 0.1)),
1385
+ [Para(text=sub0, size=self.fz("cover_sub"), color=pal["muted"],
1386
+ font=self.tpl.cjk_font(), align="center", bullet="")])
1387
+ return
1388
+ ph = lay.find_title() if lay else None
1389
+ # 结束页需要"居中大气",如果模板的标题占位符在页面顶部,就自己画居中的
1390
+ use_ph = False
1391
+ if ph is not None and ph.height:
1392
+ center_ratio = (ph.top + ph.height / 2.0) / float(self.H or 1)
1393
+ use_ph = 0.28 <= center_ratio <= 0.78
1394
+ if use_ph:
1395
+ base = ph.font_size_pt or self.fz("cover_title")
1396
+ self._fill_ph(slide, ph, [Para(text=title, size=max(base, self.fz("cover_title")),
1397
+ bold=True,
1398
+ font=ph.font_name or self.tpl.heading_font(),
1399
+ bullet="")], anchor="middle")
1400
+ else:
1401
+ self._textbox(slide, (int(self.W * 0.1), int(self.H * 0.34), int(self.W * 0.8), int(self.H * 0.18)),
1402
+ [Para(text=title, size=self.fz("cover_title"),
1403
+ bold=True, color=pal["text"], font=self.tpl.heading_font(),
1404
+ align="center", bullet="")], anchor="middle")
1405
+ sub = spec.get("subtitle", "")
1406
+ if sub:
1407
+ self._textbox(slide, (int(self.W * 0.15), int(self.H * 0.58), int(self.W * 0.7), int(self.H * 0.1)),
1408
+ [Para(text=sub, size=self.fz("cover_sub"), color=pal["muted"],
1409
+ font=self.tpl.cjk_font(), align="center", bullet="")])
1410
+
1411
+ def _render_answer_slides(self) -> int:
1412
+ made = 0
1413
+ for pool in self._answer_pool:
1414
+ questions = [q for q in pool["questions"] if q.get("answer") or q.get("analysis")]
1415
+ if not questions:
1416
+ continue
1417
+ slide, lay = self._new_slide({"type": "practice", "chrome": None})
1418
+ pal = self._palette(lay)
1419
+ bx, by, bw, bh = self._header(
1420
+ slide, lay, {"title": f"{pool['title']} · 参考答案", "module": "科学问答"}, "科学问答")
1421
+ n = len(questions)
1422
+ gap = int(self.H * 0.02)
1423
+ each = max((bh - gap * (n - 1)) // n, int(self.H * 0.1))
1424
+ for i, q in enumerate(questions):
1425
+ y = by + i * (each + gap)
1426
+ paras: List[Para] = [Para(text=f"{i + 1}. 答案:{q.get('answer', '')}",
1427
+ size=self.csize("caption") + 1, bold=True,
1428
+ color=self.accents[2 % len(self.accents)],
1429
+ font=self.tpl.cjk_font(), bullet="")]
1430
+ if q.get("analysis"):
1431
+ paras.append(Para(text=f"解析:{q['analysis']}", size=self.csize("caption"),
1432
+ color=pal["text"], font=self.tpl.cjk_font(), bullet="",
1433
+ space_before=2))
1434
+ self._textbox(slide, (bx, y, bw, each), paras)
1435
+ self._prune_empty_placeholders(slide)
1436
+ self.report.add("answers", pool["title"])
1437
+ self._built_types.append("answers")
1438
+ made += 1
1439
+ return made
1440
+
1441
+ # ==================================================================
1442
+ # 组合绘制
1443
+ # ==================================================================
1444
+ def _bullets_to_paras(self, bullets: Sequence[dict], base_size: float,
1445
+ font: Optional[str], *, bullet_char: Optional[str] = None,
1446
+ color: Optional[str] = None, bullet_color: Optional[str] = None
1447
+ ) -> List[Para]:
1448
+ paras: List[Para] = []
1449
+ for b in bullets:
1450
+ level = int(b.get("level", 0) or 0)
1451
+ size = base_size if level == 0 else max(base_size * (0.86 ** level), 9.0)
1452
+ char = None if bullet_char is None else (
1453
+ bullet_char if level == 0 else BULLETS[min(level, len(BULLETS) - 1)])
1454
+ paras.append(Para(
1455
+ text=b["text"], size=size, level=level, bullet=char,
1456
+ color=color, font=font, indent_in=0.0 if level == 0 else 0.28 * level,
1457
+ space_before=6 if level == 0 else 2,
1458
+ space_after=0,
1459
+ ))
1460
+ return paras
1461
+
1462
+ def _bullets(self, slide, lay, box, bullets, *, base_size, color=None,
1463
+ bullet_char="●", bullet_color=None):
1464
+ pal = self._palette(lay)
1465
+ paras = self._bullets_to_paras(
1466
+ bullets, base_size, self.body_font(),
1467
+ bullet_char=bullet_char, color=color or pal["text"],
1468
+ bullet_color=bullet_color or self.primary)
1469
+ return self._textbox(slide, box, paras)
1470
+
1471
+ def _numbered_steps(self, slide, lay, box, steps, accent):
1472
+ pal = self._palette(lay)
1473
+ x, y, w, h = box
1474
+ n = len(steps)
1475
+ gap = int(h * 0.05 / max(n, 1)) if n > 1 else 0
1476
+ each = (h - gap * (n - 1)) // max(n, 1)
1477
+ badge = min(int(each * 0.62), int(w * 0.055))
1478
+ for i, s in enumerate(steps):
1479
+ sy = y + i * (each + gap)
1480
+ rounded_box(slide, (x, sy, badge, badge), fill=accent, line=None, radius=0.5)
1481
+ self._textbox(slide, (x, sy, badge, badge),
1482
+ [Para(text=str(i + 1), size=max(self.csize("item"), 10), bold=True,
1483
+ color=readable_on(accent), font=self.tpl.heading_font(),
1484
+ align="center", bullet="")], anchor="middle", fit=False)
1485
+ self._textbox(slide, (x + badge + int(w * 0.02), sy, w - badge - int(w * 0.02), each),
1486
+ [Para(text=s["text"], size=self.fz("bullet0"), color=pal["text"],
1487
+ font=self.tpl.cjk_font(), bullet="")], anchor="middle")
1488
+
1489
+ def _question_block(self, slide, lay, box, number, q, inline):
1490
+ pal = self._palette(lay)
1491
+ x, y, w, h = box
1492
+ accent = self.accents[(number - 1) % len(self.accents)]
1493
+ head_h = 0
1494
+ paras: List[Para] = []
1495
+ if q.get("q"):
1496
+ paras.append(Para(text=f"{number}. {q['q']}", size=self.csize("block_title"), bold=True,
1497
+ color=pal["text"], font=self.tpl.cjk_font(), bullet=""))
1498
+ from .shapes import measure_block
1499
+
1500
+ need_pt = measure_block(paras, pt_from_emu(w))
1501
+ head_h = min(int(emu_from_inch(need_pt / 72.0)) + emu_from_inch(0.06), int(h * 0.6))
1502
+ self._textbox(slide, (x, y, w, head_h), paras)
1503
+ opts = q.get("options") or []
1504
+ opt_y = y + head_h
1505
+ opt_h = h - head_h
1506
+ if inline and (q.get("answer") or q.get("analysis")):
1507
+ ans_h = int(h * 0.3)
1508
+ opt_h = max(h - head_h - ans_h, int(h * 0.3))
1509
+ ans_paras = [Para(text=f"答案:{q.get('answer', '')}", size=self.csize("caption"), bold=True,
1510
+ color=accent, font=self.tpl.cjk_font(), bullet="")]
1511
+ if q.get("analysis"):
1512
+ ans_paras.append(Para(text=f"解析:{q['analysis']}", size=self.csize("caption"),
1513
+ color=pal["muted"], font=self.tpl.cjk_font(), bullet="",
1514
+ space_before=2))
1515
+ self._textbox(slide, (x + int(w * 0.03), y + head_h + opt_h, w - int(w * 0.03), ans_h), ans_paras)
1516
+ if not opts:
1517
+ return
1518
+ horizontal = self._options_fit_row(opts, w, self.csize("item"))
1519
+ if horizontal:
1520
+ n = len(opts)
1521
+ gap = int(w * 0.02)
1522
+ cw = (w - gap * (n - 1)) // n
1523
+ # 横排时给选项一个紧凑的行高,并在这片区域里垂直居中
1524
+ row_h = min(opt_h, max(int(self.H * 0.055), int(self.csize("item") * 2.2)))
1525
+ cy = opt_y + (opt_h - row_h) // 2
1526
+ for i, o in enumerate(opts):
1527
+ self._option_chip(slide, lay, (x + i * (cw + gap), cy, cw, row_h), o, accent)
1528
+ else:
1529
+ gap = int(opt_h * 0.06)
1530
+ each = (opt_h - gap * (len(opts) - 1)) // len(opts)
1531
+ for i, o in enumerate(opts):
1532
+ self._option_chip(slide, lay, (x + int(w * 0.03), opt_y + i * (each + gap),
1533
+ w - int(w * 0.03), each), o, accent)
1534
+
1535
+ def _option_chip(self, slide, lay, box, text, accent):
1536
+ pal = self._palette(lay)
1537
+ label = text
1538
+ is_answer = False
1539
+ if text and text[0] in "ABCDEFGH" and len(text) > 1 and text[1] in ".、.)):":
1540
+ label = text[2:].strip()
1541
+ is_answer = True
1542
+ x, y, w, h = box
1543
+ if is_answer:
1544
+ dot = max(min(int(h * 0.7), int(w * 0.12)), int(self.csize("item") * 1.5))
1545
+ # 徽标与文字在同一行上垂直居中对齐
1546
+ dy = y + max((h - dot) // 2, 0)
1547
+ rounded_box(slide, (x, dy, dot, dot), fill=pal["card"], fill_alpha=pal["card_alpha"],
1548
+ line=accent, line_width_pt=1.0, radius=0.5)
1549
+ self._textbox(slide, (x, dy, dot, dot),
1550
+ [Para(text=text[0], size=self.csize("item") - 1, bold=True, color=accent,
1551
+ font=self.tpl.heading_font(), align="center", bullet="")],
1552
+ anchor="middle", fit=False)
1553
+ tx = x + dot + int(w * 0.03)
1554
+ tw = max(w - dot - int(w * 0.03), int(w * 0.2))
1555
+ else:
1556
+ tx, tw = x, w
1557
+ self._textbox(slide, (tx, y, tw, h),
1558
+ [Para(text=label, size=self.csize("item"), color=pal["text"],
1559
+ font=self.tpl.cjk_font(), bullet="")], anchor="middle")
1560
+
1561
+ def _options_fit_row(self, opts, width, size) -> bool:
1562
+ from .util import text_width_pt
1563
+
1564
+ total = sum(text_width_pt(o, size) for o in opts)
1565
+ total += 20 * len(opts)
1566
+ return total <= pt_from_emu(width) * 0.98
1567
+
1568
+ def _images_row(self, slide, images, box):
1569
+ x, y, w, h = box
1570
+ n = len(images)
1571
+ if n == 0:
1572
+ return
1573
+ gap = int(w * 0.03) if n > 1 else 0
1574
+ cw = (w - gap * (n - 1)) // n
1575
+ for i, img in enumerate(images):
1576
+ add_picture_fit(slide, img, (x + i * (cw + gap), y, cw, h), mode="contain")
1577
+
1578
+ def _mindmap(self, slide, lay, box, center, branches):
1579
+ pal = self._palette(lay)
1580
+ x, y, w, h = box
1581
+ root_w = int(w * 0.26)
1582
+ gap = int(w * 0.06)
1583
+ br_x = x + root_w + gap
1584
+ br_w = w - root_w - gap
1585
+ n = len(branches)
1586
+ row_gap = int(h * 0.04)
1587
+ each = (h - row_gap * (n - 1)) // max(n, 1)
1588
+
1589
+ root_h = min(int(h * 0.36), max(int(self.H * 0.16), int(each * 1.2)))
1590
+ root_y = y + (h - root_h) // 2
1591
+ rounded_box(slide, (x, root_y, root_w, root_h), fill=self.primary, line=None, radius=0.1)
1592
+ self._textbox(slide, (x + int(root_w * 0.07), root_y, int(root_w * 0.86), root_h),
1593
+ [Para(text=center, size=self.fz("mind_center"), bold=True,
1594
+ color=readable_on(self.primary), font=self.tpl.heading_font(),
1595
+ align="center", bullet="")], anchor="middle")
1596
+
1597
+ for i, br in enumerate(branches):
1598
+ by = y + i * (each + row_gap)
1599
+ accent = self.accents[i % len(self.accents)]
1600
+ items = [it for it in br.get("items", []) if str(it).strip()]
1601
+ head_h = min(int(each * (0.5 if items else 1.0)), int(each * 0.55)) if items else each
1602
+ rounded_box(slide, (br_x, by, br_w, each), fill=pal["card"],
1603
+ fill_alpha=pal["card_alpha"], line=pal["card_line"], radius=0.08)
1604
+ # 左侧色条
1605
+ rounded_box(slide, (br_x, by, int(br_w * 0.012), each),
1606
+ fill=accent, line=None, radius=0.5)
1607
+ pad = int(br_w * 0.035)
1608
+ self._textbox(slide, (br_x + pad + int(br_w * 0.012), by, br_w - 2 * pad, head_h),
1609
+ [Para(text=br.get("title", ""), size=self.fz("mind_branch"), bold=True,
1610
+ color=accent, font=self.tpl.cjk_font(), bullet="")], anchor="middle")
1611
+ if items:
1612
+ self._textbox(slide, (br_x + pad + int(br_w * 0.012), by + head_h,
1613
+ br_w - 2 * pad, each - head_h),
1614
+ [Para(text=" ".join(str(t) for t in items),
1615
+ size=self.fz("mind_item"), color=pal["text"],
1616
+ font=self.tpl.cjk_font(), bullet="")], anchor="middle")
1617
+ add_line(slide, x + root_w, root_y + root_h // 2,
1618
+ br_x, by + each // 2, color=pal["card_line"], width_pt=1.0)
1619
+
1620
+ # ==================================================================
1621
+ # 图片 / 页码 / 杂项
1622
+ # ==================================================================
1623
+ def _resolve_images(self, spec: dict) -> List[str]:
1624
+ paths = []
1625
+ raw = []
1626
+ if spec.get("images"):
1627
+ raw = list(spec["images"]) if isinstance(spec["images"], (list, tuple)) else [spec["images"]]
1628
+ elif spec.get("image"):
1629
+ raw = [spec["image"]]
1630
+ elif spec.get("image_prompt") and self.image_manifest:
1631
+ hit = self.image_manifest.get(spec["image_prompt"].strip())
1632
+ if hit:
1633
+ raw = [hit]
1634
+ else:
1635
+ self.warn(f"配图还没生成(P 的 image_prompt 未命中缓存):{spec['image_prompt'][:24]}…")
1636
+ for p in raw:
1637
+ if not p:
1638
+ continue
1639
+ cand = p if os.path.isabs(str(p)) else os.path.join(self.base_dir, str(p))
1640
+ if not os.path.exists(cand) and self.tpl.path:
1641
+ alt = os.path.join(os.path.dirname(self.tpl.path), str(p))
1642
+ if os.path.exists(alt):
1643
+ cand = alt
1644
+ if os.path.exists(cand):
1645
+ paths.append(cand)
1646
+ else:
1647
+ self.warn(f"找不到图片:{p}")
1648
+ return paths
1649
+
1650
+ def _add_page_numbers(self) -> None:
1651
+ if not self.opts.get("page_numbers", True):
1652
+ return
1653
+ start = int(self.opts.get("page_number_start", 1) or 1)
1654
+ for i, slide in enumerate(self.prs.slides):
1655
+ num = i + start
1656
+ if i == 0 and self.opts.get("skip_number_on_cover", True):
1657
+ continue
1658
+ pal = {"text": self.tpl.muted_color()}
1659
+ box = (int(self.W * 0.86), int(self.H * 0.925), int(self.W * 0.09), int(self.H * 0.05))
1660
+ try:
1661
+ self._textbox(slide, box,
1662
+ [Para(text=str(num), size=self.csize("caption"), color=pal["text"],
1663
+ font=self.tpl.cjk_font(), align="right", bullet="")],
1664
+ anchor="middle", fit=False)
1665
+ except Exception:
1666
+ pass
1667
+
1668
+ def warn(self, msg: str) -> None:
1669
+ if msg not in self.report.warnings:
1670
+ self.report.warnings.append(msg)
1671
+
1672
+
1673
+ EMU_PT = 12700