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