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,834 @@
1
+ """模板解析:读取并固化 pptx 模板的版式、主题、字体、背景与装饰层。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import os
7
+ from collections import Counter, defaultdict
8
+ from dataclasses import dataclass, field
9
+ from typing import Dict, List, Optional, Tuple
10
+
11
+ from pptx import Presentation
12
+ from pptx.oxml.ns import qn
13
+ from pptx.util import Emu
14
+
15
+ from . import xmlutil as xu
16
+ from .util import inch_from_emu, pt_from_emu, relative_luminance
17
+
18
+ # --------------------------------------------------------------------------
19
+ # 占位符类型 -> 语义名
20
+ # --------------------------------------------------------------------------
21
+ PH_TITLE = {"title", "ctrtitle"}
22
+ PH_BODY = {"body", "obj", "subtitle", "content"}
23
+ PH_OTHER = {"dt", "ftr", "sldnum", "pic", "chart", "tbl", "clipart", "media", "dgm"}
24
+
25
+
26
+ @dataclass
27
+ class PlaceholderInfo:
28
+ idx: Optional[int]
29
+ ph_type: str
30
+ name: str
31
+ left: int
32
+ top: int
33
+ width: int
34
+ height: int
35
+ font_size_pt: Optional[float] = None
36
+ font_name: Optional[str] = None
37
+ bold: Optional[bool] = None
38
+ color: Optional[str] = None
39
+ align: Optional[str] = None
40
+
41
+ @property
42
+ def box(self) -> Tuple[int, int, int, int]:
43
+ return self.left, self.top, self.width, self.height
44
+
45
+ @property
46
+ def is_title(self) -> bool:
47
+ return self.ph_type in PH_TITLE
48
+
49
+ @property
50
+ def is_body(self) -> bool:
51
+ return self.ph_type in PH_BODY
52
+
53
+ @property
54
+ def is_decoration(self) -> bool:
55
+ return self.ph_type in PH_OTHER
56
+
57
+
58
+ @dataclass
59
+ class ShapeInfo:
60
+ name: str
61
+ kind: str
62
+ left: Optional[int]
63
+ top: Optional[int]
64
+ width: Optional[int]
65
+ height: Optional[int]
66
+ is_placeholder: bool
67
+ text: str = ""
68
+
69
+
70
+ @dataclass
71
+ class ChromeItem:
72
+ """模板里反复出现的装饰/背景形状,会被克隆到新幻灯片。"""
73
+
74
+ element: object
75
+ src_slide: object
76
+ name: str
77
+ box: Tuple[Optional[int], Optional[int], Optional[int], Optional[int]]
78
+ occurrences: int
79
+ is_full_bleed: bool
80
+ fill_luminance: Optional[float] = None
81
+
82
+ def signature(self) -> tuple:
83
+ x, y, cx, cy = self.box
84
+ return (self.name, x, y, cx, cy)
85
+
86
+
87
+ @dataclass
88
+ class LayoutInfo:
89
+ index: int
90
+ name: str
91
+ master_index: int = 0
92
+ placeholders: List[PlaceholderInfo] = field(default_factory=list)
93
+ background: Optional[dict] = None
94
+ shape_count: int = 0
95
+ chrome: List[ChromeItem] = field(default_factory=list)
96
+
97
+ def find_title(self) -> Optional[PlaceholderInfo]:
98
+ for ph in self.placeholders:
99
+ if ph.is_title:
100
+ return ph
101
+ return None
102
+
103
+ def find_placeholder(self, idx: int) -> Optional[PlaceholderInfo]:
104
+ """按占位符 idx 查找(覆盖"标题/正文"之外的模板自定义槽位)。"""
105
+ for ph in self.placeholders:
106
+ if ph.idx == idx:
107
+ return ph
108
+ return None
109
+
110
+ def find_body(self) -> Optional[PlaceholderInfo]:
111
+ candidates = [ph for ph in self.placeholders if ph.is_body]
112
+ if not candidates:
113
+ return None
114
+ # 选面积最大的正文占位符
115
+ return max(candidates, key=lambda p: (p.width or 0) * (p.height or 0))
116
+
117
+
118
+ @dataclass
119
+ class TemplateInfo:
120
+ path: Optional[str]
121
+ width: int
122
+ height: int
123
+ theme_colors: Dict[str, str] = field(default_factory=dict)
124
+ theme_fonts: Dict[str, str] = field(default_factory=dict)
125
+ master_background: Optional[dict] = None
126
+ layouts: List[LayoutInfo] = field(default_factory=list)
127
+ layout_objects: List[object] = field(default_factory=list) # 与 layouts 同序,供 add_slide 使用
128
+ masters: List[dict] = field(default_factory=list)
129
+ primary_master: int = 0
130
+ role_layout: Dict[str, int] = field(default_factory=dict) # cover/content/end -> 全局版式下标
131
+ role_background: Dict[str, dict] = field(default_factory=dict) # 封面/结束页自带的幻灯片级背景
132
+ global_chrome: List[ChromeItem] = field(default_factory=list)
133
+ slide_background: Optional[dict] = None
134
+ sample_slide_count: int = 0
135
+ _prs: object = None
136
+
137
+ # ---- 便捷访问 -------------------------------------------------------
138
+ @property
139
+ def aspect(self) -> float:
140
+ return (self.width or 1) / float(self.height or 1)
141
+
142
+ @property
143
+ def is_dark_background(self) -> bool:
144
+ bg = self.master_background or {}
145
+ if bg.get("kind") == "picture":
146
+ lum = bg.get("luminance")
147
+ return lum is not None and lum < 0.45
148
+ color = bg.get("color")
149
+ if color:
150
+ return relative_luminance(color) < 0.45
151
+ return False
152
+
153
+ def layout_by_index(self, idx: int) -> Optional[LayoutInfo]:
154
+ for lay in self.layouts:
155
+ if lay.index == idx:
156
+ return lay
157
+ return None
158
+
159
+ def accent_colors(self) -> List[str]:
160
+ out = []
161
+ for key in ("accent1", "accent2", "accent3", "accent4", "accent5", "accent6"):
162
+ c = self.theme_colors.get(key)
163
+ if c:
164
+ out.append(c)
165
+ return out or ["2F6FED", "E8590C", "2F9E44", "9C36B5", "0C8599", "C2255C"]
166
+
167
+ def primary_color(self) -> str:
168
+ acc = self.accent_colors()
169
+ return acc[0] if acc else "2F6FED"
170
+
171
+ def text_color(self) -> str:
172
+ for key in ("dk1", "tx1"):
173
+ c = self.theme_colors.get(key)
174
+ if c:
175
+ return c
176
+ return "1A1A1A"
177
+
178
+ def muted_color(self) -> str:
179
+ for key in ("dk2", "tx2"):
180
+ c = self.theme_colors.get(key)
181
+ if c:
182
+ return c
183
+ return "5A6472"
184
+
185
+ def cjk_font(self) -> str:
186
+ return (
187
+ self.theme_fonts.get("minor_ea")
188
+ or self.theme_fonts.get("major_ea")
189
+ or self.theme_fonts.get("minor_latin")
190
+ or "Microsoft YaHei"
191
+ )
192
+
193
+ def heading_font(self) -> str:
194
+ return (
195
+ self.theme_fonts.get("major_ea")
196
+ or self.theme_fonts.get("major_latin")
197
+ or self.cjk_font()
198
+ )
199
+
200
+
201
+ # --------------------------------------------------------------------------
202
+ # 解析入口
203
+ # --------------------------------------------------------------------------
204
+ def load_template(path: str, *, detect_chrome: bool = True) -> TemplateInfo:
205
+ prs = Presentation(path)
206
+ info = TemplateInfo(
207
+ path=os.path.abspath(path),
208
+ width=prs.slide_width,
209
+ height=prs.slide_height,
210
+ _prs=prs,
211
+ )
212
+ # 一个模板可能有多个母版(多套设计),python-pptx 只暴露第一个。
213
+ # 必须把所有母版的版式汇成一张全局表,否则会套错设计。
214
+ master_entries = _enumerate_masters(prs)
215
+ info.sample_slide_count = len(prs.slides)
216
+ used = _master_usage(prs, master_entries)
217
+ info.primary_master = max(used, key=lambda k: used[k]) if used else 0
218
+
219
+ info.masters = [
220
+ {
221
+ "index": i,
222
+ "name": e["name"],
223
+ "partname": str(e["part"].partname),
224
+ "layout_count": len(e["layout_objects"]),
225
+ "used_by_slides": used.get(i, 0),
226
+ "theme_colors": e["theme_colors"],
227
+ "theme_fonts": e["theme_fonts"],
228
+ "background": e["background"],
229
+ }
230
+ for i, e in enumerate(master_entries)
231
+ ]
232
+
233
+ global_index = 0
234
+ for mi, entry in enumerate(master_entries):
235
+ master = entry["master"]
236
+ master_fonts = entry["txstyles"]
237
+ master_ph = entry["ph_geometry"]
238
+ fallback_bg = entry["background"]
239
+ bg_from = f"master{mi + 1}(inherited)"
240
+ for layout in entry["layout_objects"]:
241
+ lay = _read_layout(layout, global_index, master, master_fonts, info, master_ph)
242
+ lay.master_index = mi
243
+ if lay.background is None and fallback_bg is not None:
244
+ lay.background = dict(fallback_bg)
245
+ lay.background["from"] = bg_from
246
+ info.layouts.append(lay)
247
+ info.layout_objects.append(layout)
248
+ global_index += 1
249
+
250
+ prim = master_entries[info.primary_master] if master_entries else None
251
+ if prim is not None:
252
+ info.theme_colors = prim["theme_colors"]
253
+ info.theme_fonts = prim["theme_fonts"]
254
+ info.master_background = prim["background"]
255
+
256
+ info.role_layout = _learn_role_layouts(prs, info)
257
+ info.role_background = _learn_role_backgrounds(prs)
258
+
259
+ if detect_chrome:
260
+ _detect_chrome(prs, info)
261
+ return info
262
+
263
+
264
+ def _learn_role_layouts(prs: Presentation, info: TemplateInfo) -> Dict[str, int]:
265
+ """从样张学"哪一类页面用哪个版式":封面取首页,内容取最高频,结束页取末页。"""
266
+ slides = list(prs.slides)
267
+ out: Dict[str, int] = {}
268
+ if not slides:
269
+ return out
270
+ first, last = slides[0], slides[-1]
271
+ cover = _layout_index_of(info, first)
272
+ if cover >= 0:
273
+ out["cover"] = cover
274
+ counts: Counter = Counter()
275
+ for s in slides[1:]:
276
+ idx = _layout_index_of(info, s)
277
+ if idx >= 0:
278
+ counts[idx] += 1
279
+ if counts:
280
+ out["content"] = counts.most_common(1)[0][0]
281
+ end = _layout_index_of(info, last)
282
+ if end >= 0 and len(list(last.shapes)) <= 4:
283
+ out["end"] = end
284
+ elif "cover" in out:
285
+ out["end"] = out["cover"]
286
+ return out
287
+
288
+
289
+ def _learn_role_backgrounds(prs: Presentation) -> Dict[str, dict]:
290
+ """封面/结束页常有自己单独的幻灯片级背景,不跟随母版,要单独记住。"""
291
+ slides = list(prs.slides)
292
+ out: Dict[str, dict] = {}
293
+ if not slides:
294
+ return out
295
+ for role, slide in (("cover", slides[0]), ("end", slides[-1])):
296
+ bg = xu.get_bg(slide._element)
297
+ if bg is None:
298
+ continue
299
+ desc = xu.describe_bg(bg)
300
+ if not desc or desc.get("kind") in (None, "none"):
301
+ continue
302
+ out[role] = {"element": bg, "owner": slide, "kind": desc.get("kind")}
303
+ return out
304
+
305
+
306
+ def _enumerate_masters(prs: Presentation) -> List[dict]:
307
+ """按 presentation 的 sldMasterIdLst 顺序收集全部母版及其版式。"""
308
+ part = prs.part
309
+ lst = part._element.find(qn("p:sldMasterIdLst"))
310
+ out: List[dict] = []
311
+ if lst is None:
312
+ return out
313
+ for smi in lst:
314
+ rId = smi.get(qn("r:id"))
315
+ if not rId:
316
+ continue
317
+ try:
318
+ mpart = part.related_part(rId)
319
+ except Exception:
320
+ continue
321
+ layout_objects = []
322
+ ll = mpart._element.find(qn("p:sldLayoutIdLst"))
323
+ if ll is not None:
324
+ for sli in ll:
325
+ lrid = sli.get(qn("r:id"))
326
+ if not lrid:
327
+ continue
328
+ try:
329
+ lpart = mpart.related_part(lrid)
330
+ layout_objects.append(lpart.slide_layout)
331
+ except Exception:
332
+ continue
333
+ master_obj = _SlideMasterFacade(mpart)
334
+ bg = xu.resolve_bg(master=master_obj)
335
+ if bg:
336
+ _annotate_luminance(prs, master_obj, bg)
337
+ out.append({
338
+ "master": master_obj,
339
+ "part": mpart,
340
+ "name": _master_name(mpart),
341
+ "layout_objects": layout_objects,
342
+ "theme_colors": _read_theme_colors_from(mpart),
343
+ "theme_fonts": _read_theme_fonts_from(mpart),
344
+ "txstyles": _read_master_txstyles(master_obj),
345
+ "ph_geometry": _master_placeholder_geometry(master_obj),
346
+ "background": bg,
347
+ })
348
+ return out
349
+
350
+
351
+ class _SlideMasterFacade:
352
+ """让母版 part 有一个和 python-pptx SlideMaster 相同的 ._element 接口。"""
353
+
354
+ def __init__(self, part):
355
+ self.part = part
356
+ self._element = part._element
357
+
358
+ @property
359
+ def shapes(self):
360
+ from pptx.shapes.shapetree import MasterShapes
361
+
362
+ return MasterShapes(self._element.find(qn("p:cSld")).find(qn("p:spTree")), self)
363
+
364
+
365
+ def _master_name(mpart) -> str:
366
+ cSld = mpart._element.find(qn("p:cSld"))
367
+ return (cSld.get("name") if cSld is not None else None) or str(mpart.partname).split("/")[-1]
368
+
369
+
370
+ def _master_usage(prs: Presentation, entries: List[dict]) -> Dict[int, int]:
371
+ """统计示例幻灯片实际挂在哪个母版上。"""
372
+ part_of = {}
373
+ for i, e in enumerate(entries):
374
+ for lo in e["layout_objects"]:
375
+ part_of[str(lo.part.partname)] = i
376
+ counts: Dict[int, int] = {}
377
+ for slide in prs.slides:
378
+ try:
379
+ key = str(slide.slide_layout.part.partname)
380
+ except Exception:
381
+ continue
382
+ mi = part_of.get(key)
383
+ if mi is not None:
384
+ counts[mi] = counts.get(mi, 0) + 1
385
+ return counts
386
+
387
+
388
+ def _theme_part_of(mpart):
389
+ try:
390
+ return mpart.part_related_by(
391
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme"
392
+ )
393
+ except Exception:
394
+ return None
395
+
396
+
397
+ def _read_theme_colors(prs: Presentation) -> Dict[str, str]:
398
+ return _read_theme_colors_from(prs.slide_master.part)
399
+
400
+
401
+ def _read_theme_colors_from(mpart) -> Dict[str, str]:
402
+ colors: Dict[str, str] = {}
403
+ theme = _theme_part_of(mpart)
404
+ if theme is None:
405
+ return colors
406
+ root = theme._element if hasattr(theme, "_element") else None
407
+ if root is None:
408
+ try:
409
+ from lxml import etree
410
+
411
+ root = etree.fromstring(theme.blob)
412
+ except Exception:
413
+ return colors
414
+ clrScheme = root.find(f".//{qn('a:clrScheme')}")
415
+ if clrScheme is None:
416
+ return colors
417
+ for child in clrScheme:
418
+ key = child.tag.split("}")[-1]
419
+ color = xu._read_color(child)
420
+ if color:
421
+ colors[key] = color
422
+ # 常见别名
423
+ if "dk1" in colors and "tx1" not in colors:
424
+ colors["tx1"] = colors["dk1"]
425
+ if "lt1" in colors and "bg1" not in colors:
426
+ colors["bg1"] = colors["lt1"]
427
+ if "dk2" in colors:
428
+ colors.setdefault("tx2", colors["dk2"])
429
+ if "lt2" in colors:
430
+ colors.setdefault("bg2", colors["lt2"])
431
+ return colors
432
+
433
+
434
+ def _read_theme_fonts(prs: Presentation) -> Dict[str, str]:
435
+ return _read_theme_fonts_from(prs.slide_master.part)
436
+
437
+
438
+ def _read_theme_fonts_from(mpart) -> Dict[str, str]:
439
+ fonts: Dict[str, str] = {}
440
+ theme = _theme_part_of(mpart)
441
+ if theme is None:
442
+ return fonts
443
+ root = getattr(theme, "_element", None)
444
+ if root is None:
445
+ try:
446
+ from lxml import etree
447
+
448
+ root = etree.fromstring(theme.blob)
449
+ except Exception:
450
+ return fonts
451
+ scheme = root.find(f".//{qn('a:fontScheme')}")
452
+ if scheme is None:
453
+ return fonts
454
+ for which in ("major", "minor"):
455
+ node = scheme.find(qn(f"a:{which}Font"))
456
+ if node is None:
457
+ continue
458
+ for script, suffix in (("a:latin", "latin"), ("a:ea", "ea"), ("a:cs", "cs")):
459
+ el = node.find(qn(script))
460
+ if el is not None and el.get("typeface"):
461
+ fonts[f"{which}_{suffix}"] = el.get("typeface")
462
+ return fonts
463
+
464
+
465
+ def _read_master_txstyles(master) -> Dict[str, dict]:
466
+ out: Dict[str, dict] = {}
467
+ txStyles = master._element.find(qn("p:txStyles"))
468
+ if txStyles is None:
469
+ return out
470
+ for name, key in (("p:titleStyle", "title"), ("p:bodyStyle", "body"), ("p:otherStyle", "other")):
471
+ node = txStyles.find(qn(name))
472
+ if node is None:
473
+ continue
474
+ lvl1 = node.find(qn("a:lvl1pPr"))
475
+ if lvl1 is None:
476
+ continue
477
+ out[key] = _read_defRPr(lvl1)
478
+ return out
479
+
480
+
481
+ def _read_defRPr(pPr) -> dict:
482
+ res: dict = {}
483
+ if pPr is None:
484
+ return res
485
+ d = pPr.find(qn("a:defRPr"))
486
+ if d is not None:
487
+ sz = d.get("sz")
488
+ if sz:
489
+ res["size_pt"] = int(sz) / 100.0
490
+ b = d.get("b")
491
+ if b is not None:
492
+ res["bold"] = b in ("1", "true")
493
+ latin = d.find(qn("a:latin"))
494
+ if latin is not None and latin.get("typeface"):
495
+ res["font"] = latin.get("typeface")
496
+ ea = d.find(qn("a:ea"))
497
+ if ea is not None and ea.get("typeface"):
498
+ res.setdefault("font", ea.get("typeface"))
499
+ solid = d.find(qn("a:solidFill"))
500
+ color = xu._read_color(solid if solid is not None else d)
501
+ if color:
502
+ res["color"] = color
503
+ algn = pPr.get("algn")
504
+ if algn:
505
+ res["align"] = algn
506
+ return res
507
+
508
+
509
+ def _master_placeholder_geometry(master) -> Dict[tuple, tuple]:
510
+ """母版上占位符的几何,(idx, type) 与 type 两种键,供版式继承。"""
511
+ out: Dict[tuple, tuple] = {}
512
+ for el in xu.iter_shape_elements(master.shapes._spTree):
513
+ if not xu.is_placeholder(el):
514
+ continue
515
+ idx = xu.placeholder_idx(el)
516
+ ptype = xu.placeholder_type(el) or "body"
517
+ x, y, cx, cy = xu.shape_off_ext(el)
518
+ if not (cx and cy):
519
+ continue
520
+ out[(idx, ptype)] = (x or 0, y or 0, cx, cy)
521
+ out.setdefault(("type", ptype), (x or 0, y or 0, cx, cy))
522
+ return out
523
+
524
+
525
+ def _read_layout(layout, index: int, master, master_fonts, info: TemplateInfo,
526
+ master_ph: Optional[Dict[tuple, tuple]] = None) -> LayoutInfo:
527
+ master_ph = master_ph or {}
528
+ lay = LayoutInfo(index=index, name=layout.name or f"版式{index + 1}")
529
+ bg = xu.resolve_bg(layout=layout, master=master)
530
+ if bg:
531
+ _annotate_luminance(info._prs, layout, bg)
532
+ lay.background = bg
533
+
534
+ spTree = layout.shapes._spTree
535
+ shape_count = 0
536
+ for el in xu.iter_shape_elements(spTree):
537
+ shape_count += 1
538
+ if not xu.is_placeholder(el):
539
+ continue
540
+ ph_type = xu.placeholder_type(el) or "body"
541
+ idx = xu.placeholder_idx(el)
542
+ x, y, cx, cy = xu.shape_off_ext(el)
543
+ if not (cx and cy):
544
+ # 版式常常不写几何,继承自母版同名占位符
545
+ fb = master_ph.get((idx, ph_type)) or master_ph.get(("type", ph_type))
546
+ if fb:
547
+ x, y, cx, cy = fb
548
+ ph = PlaceholderInfo(
549
+ idx=idx,
550
+ ph_type=ph_type,
551
+ name=xu.shape_name(el),
552
+ left=x or 0,
553
+ top=y or 0,
554
+ width=cx or 0,
555
+ height=cy or 0,
556
+ )
557
+ _apply_placeholder_fonts(ph, el, master_fonts, info)
558
+ lay.placeholders.append(ph)
559
+
560
+ # 忽略 idx/type 无效的
561
+ lay.placeholders = [
562
+ p for p in lay.placeholders if p.width and p.height
563
+ ] or lay.placeholders
564
+ lay.shape_count = shape_count
565
+ return lay
566
+
567
+
568
+ def _apply_placeholder_fonts(ph: PlaceholderInfo, el, master_fonts, info: TemplateInfo) -> None:
569
+ txBody = el.find(qn("p:txBody"))
570
+ if txBody is not None:
571
+ lstStyle = txBody.find(qn("a:lstStyle"))
572
+ if lstStyle is not None:
573
+ lvl1 = lstStyle.find(qn("a:lvl1pPr"))
574
+ props = _read_defRPr(lvl1)
575
+ ph.font_size_pt = props.get("size_pt")
576
+ ph.font_name = props.get("font")
577
+ ph.bold = props.get("bold")
578
+ ph.color = props.get("color")
579
+ ph.align = props.get("align")
580
+ # 直接写在 run 上的属性优先级更高
581
+ r = txBody.find(f".//{qn('a:r')}")
582
+ if r is not None:
583
+ rPr = r.find(qn("a:rPr"))
584
+ if rPr is not None:
585
+ if rPr.get("sz"):
586
+ ph.font_size_pt = int(rPr.get("sz")) / 100.0
587
+ if rPr.get("b") is not None:
588
+ ph.bold = rPr.get("b") in ("1", "true")
589
+ if ph.font_size_pt is None or ph.font_name is None:
590
+ fallback = master_fonts.get("title" if ph.is_title else "body") or master_fonts.get("other") or {}
591
+ if ph.font_size_pt is None:
592
+ ph.font_size_pt = fallback.get("size_pt")
593
+ if ph.font_name is None:
594
+ ph.font_name = fallback.get("font")
595
+ if ph.bold is None:
596
+ ph.bold = fallback.get("bold")
597
+ if ph.color is None:
598
+ ph.color = fallback.get("color")
599
+ if ph.font_name is None:
600
+ ph.font_name = info.heading_font() if ph.is_title else info.cjk_font()
601
+
602
+
603
+ def _annotate_luminance(prs, owner, bg: dict) -> None:
604
+ """背景是图片时,估算平均亮度,用来决定文字用深色还是浅色。"""
605
+ if bg.get("kind") != "picture" or not bg.get("rId"):
606
+ return
607
+ try:
608
+ part = owner.part.related_part(bg["rId"])
609
+ blob = part.blob
610
+ except Exception:
611
+ return
612
+ bg["blob"] = blob
613
+ try:
614
+ from PIL import Image
615
+
616
+ with Image.open(io.BytesIO(blob)) as im:
617
+ im = im.convert("L").resize((32, 32))
618
+ px = list(im.getdata())
619
+ bg["luminance"] = sum(px) / (255.0 * len(px))
620
+ except Exception:
621
+ bg["luminance"] = None
622
+
623
+
624
+ # --------------------------------------------------------------------------
625
+ # 装饰层(背景图 / 页眉页脚 / 角标)检测
626
+ # --------------------------------------------------------------------------
627
+ def _detect_chrome(prs: Presentation, info: TemplateInfo) -> None:
628
+ slides = list(prs.slides)
629
+ if not slides:
630
+ return
631
+
632
+ sig_count: Counter = Counter()
633
+ sig_repr: Dict[tuple, ChromeItem] = {}
634
+ per_layout_sigs: Dict[int, Dict[tuple, set]] = defaultdict(lambda: defaultdict(set))
635
+ layout_slide_count: Counter = Counter()
636
+
637
+ for si, slide in enumerate(slides):
638
+ lay_idx = _layout_index_of(info, slide)
639
+ layout_slide_count[lay_idx] += 1
640
+ spTree = slide.shapes._spTree
641
+ for el in xu.iter_shape_elements(spTree):
642
+ if xu.is_placeholder(el):
643
+ continue
644
+ name = xu.shape_name(el)
645
+ if name.startswith("__pptxgen"):
646
+ continue
647
+ box = xu.shape_off_ext(el)
648
+ full_bleed = xu.full_bleed_ratio(el, info.width, info.height) >= 0.9
649
+ key = (name, box[0], box[1], box[2], box[3])
650
+ sig_count[key] += 1
651
+ per_layout_sigs[lay_idx][key].add(si)
652
+ if key not in sig_repr:
653
+ sig_repr[key] = ChromeItem(
654
+ element=el,
655
+ src_slide=slide,
656
+ name=name,
657
+ box=box,
658
+ occurrences=0,
659
+ is_full_bleed=full_bleed,
660
+ fill_luminance=(xu.solid_fill_luminance(el, info.theme_colors)
661
+ if full_bleed else None),
662
+ )
663
+
664
+ threshold = max(2, int(len(slides) * 0.6 + 0.5))
665
+ global_items: List[ChromeItem] = []
666
+ for key, cnt in sig_count.items():
667
+ item = sig_repr[key]
668
+ keep = cnt >= threshold
669
+ # 整页背景图:出现在 >=2 页,或模板本身就只有 1-2 页示例
670
+ if item.is_full_bleed and (cnt >= 2 or len(slides) <= 2):
671
+ keep = True
672
+ if keep:
673
+ item.occurrences = cnt
674
+ global_items.append(item)
675
+
676
+ # 面积大的先画(背景在下),小的在上
677
+ global_items.sort(key=lambda it: -((it.box[2] or 0) * (it.box[3] or 0)))
678
+ info.global_chrome = global_items
679
+
680
+ # 按版式细分:只用某个版式的幻灯片上重复出现的装饰
681
+ for lay in info.layouts:
682
+ slides_using = per_layout_sigs.get(lay.index)
683
+ n = layout_slide_count.get(lay.index, 0)
684
+ if not slides_using or n <= 0:
685
+ continue
686
+ items = []
687
+ for key, sids in slides_using.items():
688
+ item = sig_repr[key]
689
+ if item in global_items:
690
+ continue
691
+ if len(sids) >= max(1, int(n * 0.6 + 0.5)):
692
+ item.occurrences = len(sids)
693
+ items.append(item)
694
+ items.sort(key=lambda it: -((it.box[2] or 0) * (it.box[3] or 0)))
695
+ lay.chrome = items
696
+
697
+ # 幻灯片级背景覆盖:多数示例页都自己设了背景 -> 视为需要继承的设计
698
+ _detect_slide_background(prs, info)
699
+
700
+
701
+ def _detect_slide_background(prs: Presentation, info: TemplateInfo) -> None:
702
+ slides = list(prs.slides)
703
+ if not slides:
704
+ return
705
+ counter: Counter = Counter()
706
+ first: Dict[str, tuple] = {}
707
+ for slide in slides:
708
+ bg = xu.get_bg(slide._element)
709
+ desc = xu.describe_bg(bg)
710
+ if not desc or desc.get("kind") in (None, "none"):
711
+ continue
712
+ key = _bg_key(slide, desc)
713
+ counter[key] += 1
714
+ first.setdefault(key, (bg, slide))
715
+ if not counter:
716
+ return
717
+ key, cnt = counter.most_common(1)[0]
718
+ if cnt >= max(1, int(len(slides) * 0.6 + 0.5)):
719
+ bg, slide = first[key]
720
+ info.slide_background = {
721
+ "element": bg,
722
+ "owner": slide,
723
+ "kind": xu.describe_bg(bg).get("kind"),
724
+ "count": cnt,
725
+ }
726
+
727
+
728
+ def _bg_key(slide, desc: dict) -> str:
729
+ if desc.get("kind") == "picture" and desc.get("rId"):
730
+ try:
731
+ part = slide.part.related_part(desc["rId"])
732
+ return f"pic:{part.partname}"
733
+ except Exception:
734
+ return "pic:unknown"
735
+ return f"{desc.get('kind')}:{desc.get('color')}:{desc.get('theme_idx')}"
736
+
737
+
738
+ def _layout_index_of(info: TemplateInfo, slide) -> int:
739
+ """返回幻灯片所用版式在全局版式表中的下标(跨多母版)。"""
740
+ try:
741
+ partname = str(slide.slide_layout.part.partname)
742
+ except Exception:
743
+ return -1
744
+ for i, lo in enumerate(info.layout_objects):
745
+ try:
746
+ if str(lo.part.partname) == partname:
747
+ return i
748
+ except Exception:
749
+ continue
750
+ return -1
751
+
752
+
753
+ # --------------------------------------------------------------------------
754
+ # 报告
755
+ # --------------------------------------------------------------------------
756
+ def describe(info: TemplateInfo, verbose: bool = False) -> str:
757
+ lines = []
758
+ lines.append(f"模板文件 : {info.path}")
759
+ lines.append(
760
+ f"画布尺寸 : {inch_from_emu(info.width):.2f} x {inch_from_emu(info.height):.2f} in "
761
+ f"({info.width} x {info.height} EMU, 比例 {info.aspect:.3f})"
762
+ )
763
+ lines.append(f"示例幻灯片: {info.sample_slide_count} 页")
764
+ if info.masters:
765
+ lines.append("")
766
+ lines.append(f"母版(共 {len(info.masters)} 个,主母版 = master{info.primary_master + 1})")
767
+ for m in info.masters:
768
+ bg = m.get("background") or {}
769
+ mark = " ← 主母版" if m["index"] == info.primary_master else ""
770
+ lines.append(
771
+ f" master{m['index'] + 1}: 版式 {m['layout_count']} 个,"
772
+ f"被 {m['used_by_slides']} 页示例使用,背景={bg.get('kind', '无')}{mark}"
773
+ )
774
+ if len(info.masters) > 1:
775
+ lines.append(" 注意:多母版模板里各套设计不同,工具会统一使用主母版的配色与字体。")
776
+ lines.append("")
777
+ lines.append("主题配色")
778
+ for k, v in info.theme_colors.items():
779
+ lines.append(f" {k:<12} #{v}")
780
+ lines.append("")
781
+ lines.append("主题字体")
782
+ for k, v in info.theme_fonts.items():
783
+ lines.append(f" {k:<12} {v}")
784
+ lines.append("")
785
+ lines.append("母版背景")
786
+ lines.append(f" {_fmt_bg(info.master_background)}")
787
+ lines.append("")
788
+ lines.append("版式列表")
789
+ for lay in info.layouts:
790
+ title = lay.find_title()
791
+ body = lay.find_body()
792
+ lines.append(
793
+ f" [{lay.index:>2}] {lay.name}"
794
+ f" 占位符={len(lay.placeholders)}"
795
+ f" 标题={'有' if title else '无'}"
796
+ f" 正文={'有' if body else '无'}"
797
+ f" 背景={_fmt_bg(lay.background, short=True)}"
798
+ f" 专属装饰={len(lay.chrome)}"
799
+ )
800
+ if verbose:
801
+ for ph in lay.placeholders:
802
+ color = f"颜色=#{ph.color}" if ph.color else "颜色=继承"
803
+ lines.append(
804
+ f" - idx={ph.idx} type={ph.ph_type} name={ph.name!r} "
805
+ f"box=({inch_from_emu(ph.left):.2f},{inch_from_emu(ph.top):.2f},"
806
+ f"{inch_from_emu(ph.width):.2f},{inch_from_emu(ph.height):.2f}) "
807
+ f"字号={ph.font_size_pt} 字体={ph.font_name} {color}"
808
+ )
809
+ lines.append("")
810
+ lines.append(f"全局装饰层 (会复制到新页): {len(info.global_chrome)} 个")
811
+ for it in info.global_chrome:
812
+ w = inch_from_emu(it.box[2]) if it.box[2] else 0
813
+ h = inch_from_emu(it.box[3]) if it.box[3] else 0
814
+ kind = "整页背景" if it.is_full_bleed else "装饰"
815
+ lines.append(
816
+ f" - {it.name!r} [{kind}] {w:.2f}x{h:.2f}in 出现 {it.occurrences} 次"
817
+ )
818
+ return "\n".join(lines)
819
+
820
+
821
+ def _fmt_bg(bg: Optional[dict], short: bool = False) -> str:
822
+ if not bg:
823
+ return "无"
824
+ kind = bg.get("kind")
825
+ src = bg.get("from", "?")
826
+ if kind == "picture":
827
+ lum = bg.get("luminance")
828
+ lum_s = f" 亮度={lum:.2f}" if isinstance(lum, float) else ""
829
+ return f"图片(from={src}){'' if short else lum_s}"
830
+ if kind == "solid":
831
+ return f"纯色 #{bg.get('color')}(from={src})"
832
+ if kind == "ref":
833
+ return f"主题引用 idx={bg.get('theme_idx')} #{bg.get('color')}(from={src})"
834
+ return f"{kind}(from={src})"