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,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)
|