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,456 @@
1
+ """样式档案:从模板自带的示例幻灯片里学习"页面骨架"。
2
+
3
+ 很多真实模板并不靠版式(layout)来定义观感,而是把设计语言重复地画在每一页上——
4
+ 比如"左上角模块标签 + 圆角矩形标题条 + 中间内容区"。
5
+ 把这种规律自动学出来,新生成的页面才能和模板自己做的页面长得一样。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections import Counter
11
+ from dataclasses import dataclass, field
12
+ from typing import Dict, List, Optional, Tuple
13
+
14
+ from pptx.oxml.ns import qn
15
+
16
+ from .template import TemplateInfo
17
+ from .util import parse_color
18
+
19
+ Box = Tuple[int, int, int, int]
20
+
21
+
22
+ @dataclass
23
+ class TextStyle:
24
+ size_pt: Optional[float] = None
25
+ bold: Optional[bool] = None
26
+ italic: Optional[bool] = None
27
+ font: Optional[str] = None
28
+ color: Optional[str] = None
29
+ align: Optional[str] = None
30
+ anchor: Optional[str] = None
31
+
32
+
33
+ @dataclass
34
+ class Skeleton:
35
+ """一个可复用的页面骨架元素。"""
36
+
37
+ box: Box
38
+ style: TextStyle
39
+ fill: Optional[str] = None
40
+ fill_alpha: float = 1.0
41
+ line: Optional[str] = None
42
+ line_width_pt: float = 1.0
43
+ radius: float = 0.15
44
+ occurrences: int = 0
45
+
46
+
47
+ @dataclass
48
+ class StyleProfile:
49
+ module_label: Optional[Skeleton] = None # 左上角模块标签(文本框)
50
+ title_chip: Optional[Skeleton] = None # 标题条(圆角矩形)
51
+ cover_title_box: Optional[Box] = None
52
+ end_title_box: Optional[Box] = None
53
+ content_title_box: Optional[Box] = None
54
+ content_box: Optional[Box] = None
55
+ # 正文字体/字号也从样张学——主题里的中文字体经常和实际用的不一样
56
+ body_font: Optional[str] = None
57
+ body_size_pt: Optional[float] = None
58
+ body_color: Optional[str] = None
59
+ body_line_spacing: float = 1.30
60
+ slide_w: int = 0
61
+ slide_h: int = 0
62
+ notes: List[str] = field(default_factory=list)
63
+
64
+ @property
65
+ def has_chip_layout(self) -> bool:
66
+ return self.title_chip is not None
67
+
68
+ def content_area(self) -> Box:
69
+ if self.content_box:
70
+ return self.content_box
71
+ return (int(self.slide_w * 0.10), int(self.slide_h * 0.24),
72
+ int(self.slide_w * 0.82), int(self.slide_h * 0.66))
73
+
74
+
75
+ # --------------------------------------------------------------------------
76
+ def learn_profile(tpl: TemplateInfo, *, min_occurrences: int = 3) -> Optional[StyleProfile]:
77
+ prs = tpl._prs
78
+ if prs is None:
79
+ return None
80
+ slides = list(prs.slides)
81
+ if len(slides) < 3:
82
+ return None
83
+
84
+ W, H = tpl.width, tpl.height
85
+ theme = dict(tpl.theme_colors)
86
+ prof = StyleProfile(slide_w=W, slide_h=H)
87
+
88
+ label_votes: Counter = Counter()
89
+ label_repr: Dict[tuple, Skeleton] = {}
90
+ chip_votes: Counter = Counter()
91
+ chip_repr: Dict[tuple, Skeleton] = {}
92
+ chip_xs: List[int] = []
93
+ content_titles: List[Box] = []
94
+ cover_boxes: List[Box] = []
95
+ end_boxes: List[Box] = []
96
+ below_chip_tops: List[float] = []
97
+ body_runs: Counter = Counter() # (字体, 字号) 组合
98
+ body_font_votes: Counter = Counter() # 只按字体
99
+ body_size_votes: Counter = Counter() # 只按显式写出的字号
100
+ body_repr: Dict[tuple, TextStyle] = {}
101
+
102
+ for idx, slide in enumerate(slides):
103
+ is_first = idx == 0
104
+ is_last = idx == len(slides) - 1
105
+ shape_list = [sh for sh in slide.shapes if sh.width and sh.height]
106
+
107
+ # 封面 / 结束页:整页几乎只有一个带文字的标题
108
+ texty = [sh for sh in shape_list
109
+ if sh.has_text_frame and sh.text_frame.text.strip()]
110
+ if (is_first or is_last) and len(texty) <= 2 and len(shape_list) <= 4:
111
+ for sh in texty:
112
+ if sh.is_placeholder:
113
+ box = (int(sh.left), int(sh.top), int(sh.width), int(sh.height))
114
+ (cover_boxes if is_first else end_boxes).append(box)
115
+ continue
116
+
117
+ non_ph_texts = 0
118
+ title_box = None
119
+ for sh in shape_list:
120
+ x, y = int(sh.left or 0), int(sh.top or 0)
121
+ w, h = int(sh.width or 0), int(sh.height or 0)
122
+
123
+ if sh.is_placeholder:
124
+ try:
125
+ ptype = sh.placeholder_format.type
126
+ except Exception:
127
+ ptype = None
128
+ is_title = ptype in (13, 1, 3) or (sh.placeholder_format.idx == 0)
129
+ if is_title and y < H * 0.5 and w < W * 0.95:
130
+ content_titles.append((x, y, w, h))
131
+ title_box = (x, y, w, h)
132
+ continue
133
+
134
+ if not sh.has_text_frame or not sh.text_frame.text.strip():
135
+ continue
136
+ non_ph_texts += 1
137
+
138
+ # --- 左上角模块标签:小文本框,贴在页面左上 ---
139
+ if w < W * 0.24 and x < W * 0.06 and y < H * 0.17 and h < H * 0.13:
140
+ key = (_r(x / W), _r(y / H), _r(w / W), _r(h / H))
141
+ label_votes[key] += 1
142
+ if key not in label_repr:
143
+ label_repr[key] = Skeleton(box=(x, y, w, h),
144
+ style=_text_style(sh, theme),
145
+ fill=None)
146
+ continue
147
+
148
+ # --- 标题条:顶部圆角矩形 ---
149
+ if _prst(sh) == "roundRect" and y < H * 0.13 and h < H * 0.18 and w < W * 0.72:
150
+ key = (_r(y / H), _r(h / H))
151
+ chip_votes[key] += 1
152
+ chip_xs.append(x)
153
+ if key not in chip_repr:
154
+ sk = Skeleton(box=(x, y, w, h), style=_text_style(sh, theme),
155
+ radius=_radius(sh))
156
+ _read_fill(sh, sk, theme)
157
+ chip_repr[key] = sk
158
+
159
+ # --- 正文排版:非占位符、且不是模块标签/标题条的文字 ---
160
+ for sh in shape_list:
161
+ if sh.is_placeholder or not sh.has_text_frame or not sh.text_frame.text.strip():
162
+ continue
163
+ bx, by = int(sh.left or 0), int(sh.top or 0)
164
+ bw, bh = int(sh.width or 0), int(sh.height or 0)
165
+ if bw < W * 0.24 and bx < W * 0.06 and by < H * 0.17 and bh < H * 0.13:
166
+ continue # 模块标签
167
+ if _prst(sh) == "roundRect" and by < H * 0.13 and bh < H * 0.18:
168
+ continue # 标题条
169
+ for para in sh.text_frame.paragraphs:
170
+ for run in para.runs:
171
+ if not run.text.strip():
172
+ continue
173
+ n = len(run.text)
174
+ size = round(run.font.size.pt) if run.font.size else None
175
+ key = (run.font.name, size)
176
+ body_runs[key] += n
177
+ body_repr.setdefault(key, _text_style(sh, theme))
178
+ if run.font.name:
179
+ body_font_votes[run.font.name] += n
180
+ if size:
181
+ body_size_votes[size] += n
182
+
183
+ # 内容区上边界:标题条之下第一块内容
184
+ if title_box is None and non_ph_texts:
185
+ tops = [sh.top / H for sh in slide.shapes
186
+ if sh.top and not sh.is_placeholder and sh.width and sh.height
187
+ and sh.top / H > 0.15]
188
+ if tops:
189
+ below_chip_tops.append(min(tops))
190
+
191
+ # --- 结论 ---
192
+ if label_votes:
193
+ key, n = label_votes.most_common(1)[0]
194
+ if n >= min_occurrences:
195
+ sk = label_repr[key]
196
+ sk.occurrences = n
197
+ prof.module_label = sk
198
+ prof.notes.append(f"学到模块标签:{n} 页重复 @ {_fmt(sk.box, W, H)}")
199
+
200
+ if chip_votes:
201
+ key, n = chip_votes.most_common(1)[0]
202
+ if n >= min_occurrences:
203
+ sk = chip_repr[key]
204
+ sk.occurrences = n
205
+ # 用观测到的最小 x 和最常见高度
206
+ sk.box = (min(chip_xs), sk.box[1], sk.box[2], sk.box[3])
207
+ prof.title_chip = sk
208
+ prof.notes.append(f"学到标题条:{n} 页重复 @ y={sk.box[1]/H:.3f} h={sk.box[3]/H:.3f}")
209
+
210
+ if body_font_votes or body_size_votes:
211
+ bfont = body_font_votes.most_common(1)[0][0] if body_font_votes else None
212
+ bsize = body_size_votes.most_common(1)[0][0] if body_size_votes else None
213
+ prof.body_font = bfont
214
+ prof.body_size_pt = float(bsize) if bsize else None
215
+ st = body_repr.get((bfont, bsize))
216
+ if st is not None and st.color:
217
+ prof.body_color = st.color
218
+ prof.notes.append(f"学到正文排版:{bfont} {bsize}pt")
219
+
220
+ if content_titles:
221
+ prof.content_title_box = _median_box(content_titles)
222
+
223
+ if cover_boxes:
224
+ prof.cover_title_box = _median_box(cover_boxes)
225
+ if end_boxes:
226
+ prof.end_title_box = _median_box(end_boxes)
227
+
228
+ # 内容区:标题条下方 -> 画布 92%
229
+ if prof.title_chip:
230
+ cx, cy, _cw, ch = prof.title_chip.box
231
+ gap = int(H * 0.030)
232
+ top = cy + ch + gap
233
+ prof.content_box = (cx, top, int(W * 0.93) - cx, int(H * 0.90) - top)
234
+ elif below_chip_tops:
235
+ top = int(min(below_chip_tops) * H)
236
+ prof.content_box = (int(W * 0.10), top, int(W * 0.82), int(H * 0.92) - top)
237
+
238
+ if not (prof.module_label or prof.title_chip):
239
+ return None
240
+ return prof
241
+
242
+
243
+ # --------------------------------------------------------------------------
244
+ # 辅助
245
+ # --------------------------------------------------------------------------
246
+ def _r(v: float, tol: float = 0.025) -> float:
247
+ return round(v / tol) * tol
248
+
249
+
250
+ def _prst(shape) -> Optional[str]:
251
+ try:
252
+ geom = shape._element.spPr.find(qn("a:prstGeom"))
253
+ return geom.get("prst") if geom is not None else None
254
+ except Exception:
255
+ return None
256
+
257
+
258
+ def _radius(shape) -> float:
259
+ try:
260
+ gd = shape._element.spPr.find(qn("a:prstGeom")).find(".//" + qn("a:gd"))
261
+ if gd is not None and gd.get("fmla"):
262
+ return int(gd.get("fmla").split()[-1]) / 100000.0
263
+ except Exception:
264
+ pass
265
+ try:
266
+ return float(shape.adjustments[0])
267
+ except Exception:
268
+ return 0.15
269
+
270
+
271
+ def _scheme_to_hex(val: str, theme: Dict[str, str]) -> Optional[str]:
272
+ if not val:
273
+ return None
274
+ key = val
275
+ alias = {
276
+ "background1": "bg1", "background2": "bg2",
277
+ "text1": "tx1", "text2": "tx2",
278
+ "dk1": "dk1", "lt1": "lt1", "dk2": "dk2", "lt2": "lt2",
279
+ "hyperlink": "hlink", "followedhyperlink": "folHlink",
280
+ }
281
+ key = alias.get(val.lower(), val)
282
+ return theme.get(key)
283
+
284
+
285
+ def _color_of(node, theme: Dict[str, str]) -> Optional[str]:
286
+ """从含 srgbClr / schemeClr / sysClr 的节点解析出 #RRGGBB(含 lumMod/lumOff/tint/shade)。"""
287
+ if node is None:
288
+ return None
289
+ color_el = None
290
+ base = None
291
+ for tag, key in (("a:srgbClr", "val"), ("a:schemeClr", "val"), ("a:sysClr", "lastClr")):
292
+ el = node.find(qn(tag))
293
+ if el is not None:
294
+ color_el = el
295
+ v = el.get(key)
296
+ base = _scheme_to_hex(v, theme) if tag == "a:schemeClr" else ((v or "").upper() or None)
297
+ break
298
+ if not base:
299
+ return None
300
+ return _apply_color_mods(base, color_el)
301
+
302
+
303
+ def _apply_color_mods(hex_color: str, el) -> str:
304
+ """按 OOXML 修饰符调整颜色:lumMod/lumOff(亮度偏移)、tint(提亮)、shade(压暗)。"""
305
+ r, g, b = [int(hex_color[i:i + 2], 16) for i in (0, 2, 4)]
306
+ mods: Dict[str, float] = {}
307
+ for child in el:
308
+ tag = child.tag.split("}")[-1]
309
+ v = child.get("val")
310
+ if v:
311
+ try:
312
+ mods[tag] = int(v) / 100000.0
313
+ except ValueError:
314
+ pass
315
+ lum = mods.get("lumMod", 1.0)
316
+ off = mods.get("lumOff", 0.0)
317
+ if lum != 1.0 or off:
318
+ r = max(0, min(255, int(round(r * lum + 255 * off))))
319
+ g = max(0, min(255, int(round(g * lum + 255 * off))))
320
+ b = max(0, min(255, int(round(b * lum + 255 * off))))
321
+ elif "tint" in mods:
322
+ t = mods["tint"]
323
+ r = int(round(r + (255 - r) * t))
324
+ g = int(round(g + (255 - g) * t))
325
+ b = int(round(b + (255 - b) * t))
326
+ elif "shade" in mods:
327
+ t = mods["shade"]
328
+ r = int(round(r * (1 - t)))
329
+ g = int(round(g * (1 - t)))
330
+ b = int(round(b * (1 - t)))
331
+ return f"{r:02X}{g:02X}{b:02X}"
332
+
333
+
334
+ def _resolve_run_color(shape, theme: Dict[str, str]) -> Optional[str]:
335
+ """取文本框里第一个带 solidFill 的 run 颜色(支持主题色与修饰),解析失败返回 None。"""
336
+ try:
337
+ txBody = shape.text_frame._txBody
338
+ except Exception:
339
+ return None
340
+ for rPr in txBody.iter(qn("a:rPr")):
341
+ solid = rPr.find(qn("a:solidFill"))
342
+ if solid is None:
343
+ continue
344
+ c = _color_of(solid, theme)
345
+ if c:
346
+ return c
347
+ return None
348
+
349
+
350
+ def _read_fill(shape, sk: Skeleton, theme: Dict[str, str]) -> None:
351
+ try:
352
+ spPr = shape._element.spPr
353
+ except Exception:
354
+ return
355
+ solid = spPr.find(qn("a:solidFill"))
356
+ if solid is not None:
357
+ sk.fill = _color_of(solid, theme)
358
+ alpha = solid.find(".//" + qn("a:alpha"))
359
+ if alpha is not None and alpha.get("val"):
360
+ sk.fill_alpha = int(alpha.get("val")) / 100000.0
361
+ ln = spPr.find(qn("a:ln"))
362
+ if ln is not None:
363
+ lnfill = ln.find(qn("a:solidFill"))
364
+ if lnfill is not None:
365
+ sk.line = _color_of(lnfill, theme)
366
+ w = ln.get("w")
367
+ if w:
368
+ sk.line_width_pt = int(w) / 12700.0
369
+ if sk.fill is None:
370
+ try:
371
+ fill = shape.fill
372
+ if str(fill.type).startswith("SOLID"):
373
+ sk.fill = parse_color(str(fill.fore_color.rgb))
374
+ except Exception:
375
+ pass
376
+
377
+
378
+ def _text_style(shape, theme: Optional[Dict[str, str]] = None) -> TextStyle:
379
+ ts = TextStyle()
380
+ try:
381
+ tf = shape.text_frame
382
+ ts.anchor = str(tf.vertical_anchor).split(" ")[0].split(".")[-1].lower() if tf.vertical_anchor else None
383
+ except Exception:
384
+ pass
385
+ try:
386
+ for p in shape.text_frame.paragraphs:
387
+ if ts.align is None and p.alignment is not None:
388
+ ts.align = str(p.alignment).split(" ")[0].split(".")[-1].lower()
389
+ for r in p.runs:
390
+ f = r.font
391
+ if f.size is not None and ts.size_pt is None:
392
+ ts.size_pt = f.size.pt
393
+ if f.bold is not None and ts.bold is None:
394
+ ts.bold = f.bold
395
+ if f.italic is not None and ts.italic is None:
396
+ ts.italic = f.italic
397
+ if f.name and ts.font is None:
398
+ ts.font = f.name
399
+ if ts.color is None:
400
+ try:
401
+ ts.color = parse_color(str(f.color.rgb))
402
+ except Exception:
403
+ pass
404
+ if ts.size_pt and ts.font:
405
+ break
406
+ except Exception:
407
+ pass
408
+ # 主题色(schemeClr)python-pptx 读不出 RGB,直接回 XML 里解析,并应用 lumMod 等修饰
409
+ if ts.color is None and theme:
410
+ ts.color = _resolve_run_color(shape, theme)
411
+ return ts
412
+
413
+
414
+ def _median_box(boxes: List[Box]) -> Box:
415
+ import statistics as st
416
+
417
+ return (
418
+ int(st.median(b[0] for b in boxes)),
419
+ int(st.median(b[1] for b in boxes)),
420
+ int(st.median(b[2] for b in boxes)),
421
+ int(st.median(b[3] for b in boxes)),
422
+ )
423
+
424
+
425
+ def _fmt(box: Box, W: int, H: int) -> str:
426
+ return f"({box[0]/W:.2f},{box[1]/H:.2f},{box[2]/W:.2f},{box[3]/H:.2f})"
427
+
428
+
429
+ def describe(prof: Optional[StyleProfile]) -> str:
430
+ if prof is None:
431
+ return "样式档案:未学到(模板可能主要依赖版式,将按版式渲染)"
432
+ lines = ["样式档案(从模板示例页学到)"]
433
+ if prof.module_label:
434
+ sk = prof.module_label
435
+ color = f" 文字色=#{sk.style.color}" if sk.style.color else ""
436
+ lines.append(
437
+ f" 模块标签 : {_fmt(sk.box, prof.slide_w, prof.slide_h)} "
438
+ f"{sk.style.size_pt}pt {sk.style.font} 对齐={sk.style.align}{color} 出现 {sk.occurrences} 次"
439
+ )
440
+ if prof.title_chip:
441
+ sk = prof.title_chip
442
+ color = f" 文字色=#{sk.style.color}" if sk.style.color else ""
443
+ lines.append(
444
+ f" 标题条 : x={sk.box[0]/prof.slide_w:.3f} y={sk.box[1]/prof.slide_h:.3f} "
445
+ f"h={sk.box[3]/prof.slide_h:.3f} 填充=#{sk.fill} 描边=#{sk.line} "
446
+ f"{sk.style.size_pt}pt {sk.style.font}{color} 出现 {sk.occurrences} 次"
447
+ )
448
+ if prof.content_box:
449
+ lines.append(f" 内容区 : {_fmt(prof.content_box, prof.slide_w, prof.slide_h)}")
450
+ if prof.body_font or prof.body_size_pt:
451
+ lines.append(f" 正文排版 : {prof.body_font} {prof.body_size_pt}pt")
452
+ if prof.cover_title_box:
453
+ lines.append(f" 封面标题 : {_fmt(prof.cover_title_box, prof.slide_w, prof.slide_h)}")
454
+ if prof.end_title_box:
455
+ lines.append(f" 结束页标题: {_fmt(prof.end_title_box, prof.slide_w, prof.slide_h)}")
456
+ return "\n".join(lines)