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.
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/cordis.patch.yml +9 -0
- package/engine/pptxgen/__init__.py +8 -0
- package/engine/pptxgen/__main__.py +4 -0
- package/engine/pptxgen/builtin_template.py +218 -0
- package/engine/pptxgen/cli.py +575 -0
- package/engine/pptxgen/examples.py +290 -0
- package/engine/pptxgen/imagegen.py +564 -0
- package/engine/pptxgen/layoutmap.py +211 -0
- package/engine/pptxgen/patterns.py +106 -0
- package/engine/pptxgen/preview.py +126 -0
- package/engine/pptxgen/profile.py +456 -0
- package/engine/pptxgen/render.py +1673 -0
- package/engine/pptxgen/review.py +184 -0
- package/engine/pptxgen/schemacheck.py +122 -0
- package/engine/pptxgen/schemas/XAdES.xsd +466 -0
- package/engine/pptxgen/schemas/XAdESv141.xsd +15 -0
- package/engine/pptxgen/schemas/chartEx.xsd +838 -0
- package/engine/pptxgen/schemas/dml-chart.xsd +1499 -0
- package/engine/pptxgen/schemas/dml-chartDrawing.xsd +146 -0
- package/engine/pptxgen/schemas/dml-diagram.xsd +1085 -0
- package/engine/pptxgen/schemas/dml-drawing.xsd +63 -0
- package/engine/pptxgen/schemas/dml-lockedCanvas.xsd +11 -0
- package/engine/pptxgen/schemas/dml-main.xsd +3081 -0
- package/engine/pptxgen/schemas/dml-picture.xsd +23 -0
- package/engine/pptxgen/schemas/dml-spreadsheetDrawing.xsd +185 -0
- package/engine/pptxgen/schemas/dml-wordprocessingDrawing.xsd +287 -0
- package/engine/pptxgen/schemas/drawing-chart2012.xsd +129 -0
- package/engine/pptxgen/schemas/markup-compatibility.xsd +95 -0
- package/engine/pptxgen/schemas/opc-digSig.xsd +49 -0
- package/engine/pptxgen/schemas/opc-relationships.xsd +33 -0
- package/engine/pptxgen/schemas/pml.xsd +1676 -0
- package/engine/pptxgen/schemas/shared-additionalCharacteristics.xsd +28 -0
- package/engine/pptxgen/schemas/shared-bibliography.xsd +144 -0
- package/engine/pptxgen/schemas/shared-commonSimpleTypes.xsd +172 -0
- package/engine/pptxgen/schemas/shared-customXmlDataProperties.xsd +25 -0
- package/engine/pptxgen/schemas/shared-customXmlSchemaProperties.xsd +18 -0
- package/engine/pptxgen/schemas/shared-documentPropertiesCustom.xsd +59 -0
- package/engine/pptxgen/schemas/shared-documentPropertiesExtended.xsd +56 -0
- package/engine/pptxgen/schemas/shared-documentPropertiesVariantTypes.xsd +195 -0
- package/engine/pptxgen/schemas/shared-math.xsd +582 -0
- package/engine/pptxgen/schemas/shared-relationshipReference.xsd +25 -0
- package/engine/pptxgen/schemas/signatureInfo.xsd +103 -0
- package/engine/pptxgen/schemas/sml.xsd +4439 -0
- package/engine/pptxgen/schemas/visio.xsd +829 -0
- package/engine/pptxgen/schemas/vml-main.xsd +570 -0
- package/engine/pptxgen/schemas/vml-officeDrawing.xsd +509 -0
- package/engine/pptxgen/schemas/vml-presentationDrawing.xsd +12 -0
- package/engine/pptxgen/schemas/vml-spreadsheetDrawing.xsd +108 -0
- package/engine/pptxgen/schemas/vml-wordprocessingDrawing.xsd +96 -0
- package/engine/pptxgen/schemas/vmlDrawing.xsd +36 -0
- package/engine/pptxgen/schemas/wml.xsd +3643 -0
- package/engine/pptxgen/schemas/word12.xsd +66 -0
- package/engine/pptxgen/schemas/xlThreadedComments.xsd +59 -0
- package/engine/pptxgen/schemas/xlThreadedComments2.xsd +22 -0
- package/engine/pptxgen/schemas/xmldsig-core-schema.xsd +318 -0
- package/engine/pptxgen/shapes.py +417 -0
- package/engine/pptxgen/spec.py +529 -0
- package/engine/pptxgen/template.py +834 -0
- package/engine/pptxgen/util.py +218 -0
- package/engine/pptxgen/xmlutil.py +298 -0
- package/engine/pptxgen.sh +7 -0
- package/engine/requirements.txt +9 -0
- package/lib/index.js +186 -0
- package/package.json +34 -0
- package/skills/courseware/SKILL.md +130 -0
- package/skills/courseware/references/content-polish.md +121 -0
- package/skills/courseware/references/deck-spec.md +155 -0
- package/skills/courseware/references/design-review.md +121 -0
- package/skills/courseware/references/image-backends.md +94 -0
- package/skills/courseware/references/quality-gates.md +71 -0
- package/skills/courseware/references/roadmap.md +55 -0
- package/skills/courseware/references/template-fidelity.md +100 -0
- package/skills/courseware/workflows/generate.md +172 -0
- package/skills/courseware/workflows/quick.md +41 -0
- package/skills/courseware/workflows/revise.md +40 -0
- package/skills/courseware/workflows/routing.md +49 -0
- package/skills/courseware/workflows/stages/generate-images.md +74 -0
- 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
|