@lark-apaas/coding-steering 0.1.31 → 0.1.32-beta.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/package.json +1 -1
- package/steering/design-html/skills/pptx-style-extract/SKILL.md +112 -0
- package/steering/design-html/skills/pptx-style-extract/font-fallback.yaml +129 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/census.py +955 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +907 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +945 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/export_consumer_md.py +75 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/export_consumer_zip.py +175 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +765 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +699 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/package.py +1120 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +461 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/query.py +562 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +679 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/verify_font.py +68 -0
- package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +193 -0
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""S1 unpack / S2 reference graph + master triage / S3 theme+clrMap / S4 shape facts."""
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import zipfile
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from xml.etree import ElementTree as ET
|
|
8
|
+
|
|
9
|
+
from ooxml import (NS, R_EMBED, R_ID, R_LINK, OFFICE_DEFAULT_ACCENTS, OFFICE_DEFAULT_FONTS,
|
|
10
|
+
SCHEME_SLOTS, Units, classify_box, describe_effects, describe_fill,
|
|
11
|
+
describe_geom, describe_line, group_transform, local, luminance,
|
|
12
|
+
read_color, read_lvl, read_txbody, read_xfrm, resolve_color,
|
|
13
|
+
resolve_part, rotated_bbox)
|
|
14
|
+
|
|
15
|
+
SLIDE_RE = re.compile(r'ppt/slides/slide\d+\.xml$')
|
|
16
|
+
LAYOUT_RE = re.compile(r'ppt/slideLayouts/slideLayout\d+\.xml$')
|
|
17
|
+
MASTER_RE = re.compile(r'ppt/slideMasters/slideMaster\d+\.xml$')
|
|
18
|
+
THEME_RE = re.compile(r'ppt/theme/theme\d+\.xml$')
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _num(part):
|
|
22
|
+
m = re.search(r'(\d+)\.xml$', part)
|
|
23
|
+
return int(m.group(1)) if m else 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Package:
|
|
27
|
+
"""S1: the opened container + part inventory + content-type discrimination."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, path):
|
|
30
|
+
self.path = path
|
|
31
|
+
self.zip = zipfile.ZipFile(path)
|
|
32
|
+
self.names = set(self.zip.namelist())
|
|
33
|
+
self._cache = {}
|
|
34
|
+
ct = self.zip.read('[Content_Types].xml').decode('utf-8', 'replace')
|
|
35
|
+
m = re.search(r'presentationml\.(presentation|template|slideshow)\.main', ct)
|
|
36
|
+
self.kind = m.group(1) if m else 'unknown'
|
|
37
|
+
self.slides = sorted((n for n in self.names if SLIDE_RE.match(n)), key=_num)
|
|
38
|
+
self.layouts = sorted((n for n in self.names if LAYOUT_RE.match(n)), key=_num)
|
|
39
|
+
self.masters = sorted((n for n in self.names if MASTER_RE.match(n)), key=_num)
|
|
40
|
+
self.themes, self.theme_discovery = self._find_themes(ct)
|
|
41
|
+
# zip directory entries ('ppt/media/') must not be counted as assets —
|
|
42
|
+
# 票 03 recon counted one, which is why its media totals run one high.
|
|
43
|
+
self.media = sorted(n for n in self.names
|
|
44
|
+
if n.startswith('ppt/media/') and not n.endswith('/'))
|
|
45
|
+
|
|
46
|
+
def _find_themes(self, ct):
|
|
47
|
+
"""Theme parts come from [Content_Types].xml, not from a path pattern.
|
|
48
|
+
|
|
49
|
+
Compressors relocate them — 飞书压缩版 puts them at
|
|
50
|
+
ppt/slideMasters/theme/themeN.xml — and a `ppt/theme/` regex then finds
|
|
51
|
+
nothing to bind the masters to, so every schemeClr resolves to
|
|
52
|
+
UNRESOLVED:no-scheme. The path regex stays as the fallback for packages
|
|
53
|
+
whose content types are unreadable.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
root = ET.fromstring(ct.encode('utf-8'))
|
|
57
|
+
found = [o.get('PartName', '').lstrip('/') for o in root.findall('ct:Override', NS)
|
|
58
|
+
if (o.get('ContentType') or '').endswith('theme+xml')]
|
|
59
|
+
found = sorted((n for n in found if n in self.names), key=_num)
|
|
60
|
+
except ET.ParseError:
|
|
61
|
+
found = []
|
|
62
|
+
if found:
|
|
63
|
+
return found, 'content-types'
|
|
64
|
+
return sorted((n for n in self.names if THEME_RE.match(n)), key=_num), 'path-regex'
|
|
65
|
+
|
|
66
|
+
def xml(self, part):
|
|
67
|
+
if part not in self._cache:
|
|
68
|
+
self._cache[part] = ET.fromstring(self.zip.read(part))
|
|
69
|
+
return self._cache[part]
|
|
70
|
+
|
|
71
|
+
def rels(self, part):
|
|
72
|
+
d, f = os.path.split(part)
|
|
73
|
+
rp = '%s/_rels/%s.rels' % (d, f)
|
|
74
|
+
out = {}
|
|
75
|
+
if rp in self.names:
|
|
76
|
+
for r in self.xml(rp).findall('rel:Relationship', NS):
|
|
77
|
+
out[r.get('Id')] = {'type': r.get('Type').rsplit('/', 1)[-1],
|
|
78
|
+
'target': r.get('Target'),
|
|
79
|
+
'external': r.get('TargetMode') == 'External'}
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
def size_of(self, part):
|
|
83
|
+
return self.zip.getinfo(part).file_size if part in self.names else 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class PartCtx:
|
|
87
|
+
"""Per-part resolution context: units + the master's clrMap + its theme's clrScheme."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, pkg, part, layer, units, clrmap, clrscheme, master=None, theme=None):
|
|
90
|
+
self.pkg, self.part, self.layer = pkg, part, layer
|
|
91
|
+
self.units, self.clrmap, self.clrscheme = units, clrmap, clrscheme
|
|
92
|
+
self.master, self.theme = master, theme
|
|
93
|
+
self._rels = pkg.rels(part)
|
|
94
|
+
|
|
95
|
+
def media_of(self, rid):
|
|
96
|
+
if not rid:
|
|
97
|
+
return None
|
|
98
|
+
rel = self._rels.get(rid)
|
|
99
|
+
if not rel or rel['external']:
|
|
100
|
+
return None
|
|
101
|
+
return resolve_part(self.part, rel['target'])
|
|
102
|
+
|
|
103
|
+
def rel_target(self, rid):
|
|
104
|
+
rel = self._rels.get(rid)
|
|
105
|
+
return resolve_part(self.part, rel['target']) if rel and not rel['external'] else None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ------------------------------------------------------------------ S3 theme
|
|
109
|
+
def read_theme(pkg, part):
|
|
110
|
+
root = pkg.xml(part)
|
|
111
|
+
cs = root.find('.//a:clrScheme', NS)
|
|
112
|
+
scheme, scheme_raw = {}, {}
|
|
113
|
+
if cs is not None:
|
|
114
|
+
for slot in cs:
|
|
115
|
+
tag = local(slot.tag)
|
|
116
|
+
for ch in slot:
|
|
117
|
+
raw = read_color(ch)
|
|
118
|
+
if raw:
|
|
119
|
+
scheme_raw[tag] = raw
|
|
120
|
+
r = resolve_color(raw)
|
|
121
|
+
scheme[tag] = (r or {}).get('hex')
|
|
122
|
+
break
|
|
123
|
+
fonts = {}
|
|
124
|
+
fs = root.find('.//a:fontScheme', NS)
|
|
125
|
+
if fs is not None:
|
|
126
|
+
for kind in ('majorFont', 'minorFont'):
|
|
127
|
+
k = fs.find('a:%s' % kind, NS)
|
|
128
|
+
if k is None:
|
|
129
|
+
continue
|
|
130
|
+
d = {}
|
|
131
|
+
for sc in k:
|
|
132
|
+
tag = local(sc.tag)
|
|
133
|
+
if tag in ('latin', 'ea', 'cs') and sc.get('typeface') is not None:
|
|
134
|
+
d[tag] = sc.get('typeface')
|
|
135
|
+
fonts[kind] = d
|
|
136
|
+
accents = [scheme.get('accent%d' % i, '') or '' for i in range(1, 7)]
|
|
137
|
+
factory_colors = bool(accents) and all(
|
|
138
|
+
a.lstrip('#').upper() in OFFICE_DEFAULT_ACCENTS for a in accents if a)
|
|
139
|
+
latins = {fonts.get(k, {}).get('latin') for k in ('majorFont', 'minorFont')}
|
|
140
|
+
factory_fonts = bool(latins - {None}) and all(
|
|
141
|
+
f in OFFICE_DEFAULT_FONTS for f in latins if f)
|
|
142
|
+
bg_styles = len(root.findall('.//a:bgFillStyleLst/*', NS))
|
|
143
|
+
return {
|
|
144
|
+
'part': part,
|
|
145
|
+
'name': root.get('name'),
|
|
146
|
+
'scheme_name': cs.get('name') if cs is not None else None,
|
|
147
|
+
'clrScheme': scheme,
|
|
148
|
+
'clrScheme_raw': {k: v.get('type') + ':' + str(v.get('val')) for k, v in scheme_raw.items()},
|
|
149
|
+
'fontScheme': fonts,
|
|
150
|
+
'factory_colors': factory_colors,
|
|
151
|
+
'factory_fonts': factory_fonts,
|
|
152
|
+
'bg_fill_styles': bg_styles,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def read_clrmap(pkg, master_part):
|
|
157
|
+
cm = pkg.xml(master_part).find('p:clrMap', NS)
|
|
158
|
+
return dict(cm.attrib) if cm is not None else {}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ------------------------------------------------------------------- S2 graph
|
|
162
|
+
def build_graph(pkg):
|
|
163
|
+
"""slide -> layout -> master -> theme, plus master -> layouts ownership."""
|
|
164
|
+
pres = pkg.xml('ppt/presentation.xml')
|
|
165
|
+
prels = pkg.rels('ppt/presentation.xml')
|
|
166
|
+
master_order = []
|
|
167
|
+
for m in pres.findall('p:sldMasterIdLst/p:sldMasterId', NS):
|
|
168
|
+
t = prels.get(m.get(R_ID))
|
|
169
|
+
if t:
|
|
170
|
+
master_order.append(resolve_part('ppt/presentation.xml', t['target']))
|
|
171
|
+
for m in pkg.masters:
|
|
172
|
+
if m not in master_order:
|
|
173
|
+
master_order.append(m)
|
|
174
|
+
|
|
175
|
+
theme_of_master, layouts_of_master = {}, {}
|
|
176
|
+
for mp in master_order:
|
|
177
|
+
mrels = pkg.rels(mp)
|
|
178
|
+
for rel in mrels.values():
|
|
179
|
+
if rel['type'] == 'theme':
|
|
180
|
+
theme_of_master[mp] = resolve_part(mp, rel['target'])
|
|
181
|
+
lids = []
|
|
182
|
+
for l in pkg.xml(mp).findall('p:sldLayoutIdLst/p:sldLayoutId', NS):
|
|
183
|
+
rel = mrels.get(l.get(R_ID))
|
|
184
|
+
if rel:
|
|
185
|
+
lids.append(resolve_part(mp, rel['target']))
|
|
186
|
+
layouts_of_master[mp] = lids
|
|
187
|
+
|
|
188
|
+
master_of_layout = {}
|
|
189
|
+
for mp, lids in layouts_of_master.items():
|
|
190
|
+
for lp in lids:
|
|
191
|
+
master_of_layout.setdefault(lp, mp)
|
|
192
|
+
for lp in pkg.layouts:
|
|
193
|
+
if lp in master_of_layout:
|
|
194
|
+
continue
|
|
195
|
+
for rel in pkg.rels(lp).values():
|
|
196
|
+
if rel['type'] == 'slideMaster':
|
|
197
|
+
master_of_layout[lp] = resolve_part(lp, rel['target'])
|
|
198
|
+
|
|
199
|
+
layout_of_slide = {}
|
|
200
|
+
for sp in pkg.slides:
|
|
201
|
+
for rel in pkg.rels(sp).values():
|
|
202
|
+
if rel['type'] == 'slideLayout':
|
|
203
|
+
layout_of_slide[sp] = resolve_part(sp, rel['target'])
|
|
204
|
+
|
|
205
|
+
slides_per_layout = Counter(layout_of_slide.values())
|
|
206
|
+
slides_per_master = Counter(
|
|
207
|
+
master_of_layout.get(layout_of_slide[sp]) for sp in pkg.slides if sp in layout_of_slide)
|
|
208
|
+
used_themes = {theme_of_master[m] for m in master_order if m in theme_of_master}
|
|
209
|
+
return {
|
|
210
|
+
'master_order': master_order,
|
|
211
|
+
'theme_of_master': theme_of_master,
|
|
212
|
+
'layouts_of_master': layouts_of_master,
|
|
213
|
+
'master_of_layout': master_of_layout,
|
|
214
|
+
'layout_of_slide': layout_of_slide,
|
|
215
|
+
'slides_per_layout': dict(slides_per_layout),
|
|
216
|
+
'slides_per_master': dict(slides_per_master),
|
|
217
|
+
'used_themes': sorted(used_themes),
|
|
218
|
+
'orphan_themes': sorted(set(pkg.themes) - used_themes),
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def triage_masters(pkg, graph, twin_pairs):
|
|
223
|
+
"""S2 母版三分规则: ① 主母版仅用于冲突裁决 ② 模板态保全链 ③ 引用 0 且不孪生 -> dropped."""
|
|
224
|
+
order = graph['master_order']
|
|
225
|
+
used = graph['slides_per_master']
|
|
226
|
+
layout_used = graph['slides_per_layout']
|
|
227
|
+
n_layouts = len(pkg.layouts)
|
|
228
|
+
unused_layouts = sum(1 for lp in pkg.layouts if layout_used.get(lp, 0) == 0)
|
|
229
|
+
unused_ratio = (unused_layouts / n_layouts) if n_layouts else 0.0
|
|
230
|
+
template_mode = pkg.kind == 'template' or unused_ratio >= 0.8
|
|
231
|
+
|
|
232
|
+
twinned = set()
|
|
233
|
+
for a, b in twin_pairs:
|
|
234
|
+
twinned.add(graph['master_of_layout'].get(a))
|
|
235
|
+
twinned.add(graph['master_of_layout'].get(b))
|
|
236
|
+
|
|
237
|
+
primary = max(order, key=lambda m: (used.get(m, 0), -order.index(m))) if order else None
|
|
238
|
+
entries = []
|
|
239
|
+
for mp in order:
|
|
240
|
+
refs = used.get(mp, 0)
|
|
241
|
+
reasons = []
|
|
242
|
+
if refs:
|
|
243
|
+
reasons.append('referenced-by-%d-slides' % refs)
|
|
244
|
+
if template_mode:
|
|
245
|
+
reasons.append('template-mode')
|
|
246
|
+
if mp in twinned:
|
|
247
|
+
reasons.append('twinned-with-main-chain')
|
|
248
|
+
picked = bool(reasons)
|
|
249
|
+
entries.append({
|
|
250
|
+
'part': mp, 'theme': graph['theme_of_master'].get(mp),
|
|
251
|
+
'layouts': len(graph['layouts_of_master'].get(mp, [])),
|
|
252
|
+
'slide_refs': refs, 'picked': picked,
|
|
253
|
+
'reasons': reasons or ['zero-refs-and-not-twinned'],
|
|
254
|
+
'is_primary': mp == primary,
|
|
255
|
+
})
|
|
256
|
+
return {
|
|
257
|
+
'primary': primary,
|
|
258
|
+
'primary_role': 'conflict-arbitration-only',
|
|
259
|
+
'template_mode': template_mode,
|
|
260
|
+
'template_mode_evidence': {'content_type': pkg.kind,
|
|
261
|
+
'layouts_unused': unused_layouts,
|
|
262
|
+
'layouts_total': n_layouts,
|
|
263
|
+
'unused_ratio': round(unused_ratio, 3)},
|
|
264
|
+
'masters': entries,
|
|
265
|
+
'dropped': [e['part'] for e in entries if not e['picked']],
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ------------------------------------------------------------- S4 shape facts
|
|
270
|
+
def _bg_descriptor(root, ctx):
|
|
271
|
+
bg = root.find('p:cSld/p:bg', NS)
|
|
272
|
+
if bg is None:
|
|
273
|
+
return None
|
|
274
|
+
pr = bg.find('p:bgPr', NS)
|
|
275
|
+
if pr is not None:
|
|
276
|
+
d = describe_fill(pr, ctx) or {}
|
|
277
|
+
return {'source': 'bgPr', **d}
|
|
278
|
+
ref = bg.find('p:bgRef', NS)
|
|
279
|
+
if ref is not None:
|
|
280
|
+
col = None
|
|
281
|
+
for ch in ref:
|
|
282
|
+
col = resolve_color(read_color(ch), ctx.clrmap, ctx.clrscheme)
|
|
283
|
+
if col:
|
|
284
|
+
break
|
|
285
|
+
# bgFillStyleLst[idx-1000] is not expanded; the phClr carries the actual hue.
|
|
286
|
+
return {'source': 'bgRef', 'idx': ref.get('idx'), 'color': col,
|
|
287
|
+
'note': 'theme bgFillStyleLst pattern not expanded'}
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _ph(nv):
|
|
292
|
+
ph = nv.find('p:nvPr/p:ph', NS) if nv is not None else None
|
|
293
|
+
if ph is None:
|
|
294
|
+
return None
|
|
295
|
+
return {'type': ph.get('type', 'body'), 'idx': ph.get('idx')}
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
NV_TAGS = {'sp': 'p:nvSpPr', 'pic': 'p:nvPicPr', 'grpSp': 'p:nvGrpSpPr',
|
|
299
|
+
'cxnSp': 'p:nvCxnSpPr', 'graphicFrame': 'p:nvGraphicFramePr'}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def walk_tree(el, ctx, out, path=(), xf=(1.0, 1.0, 0.0, 0.0), depth=0):
|
|
303
|
+
sx, sy, dx, dy = xf
|
|
304
|
+
U, W, H = ctx.units, ctx.units.w, ctx.units.h
|
|
305
|
+
for sp in el:
|
|
306
|
+
tag = local(sp.tag)
|
|
307
|
+
if tag not in NV_TAGS:
|
|
308
|
+
continue
|
|
309
|
+
nv = sp.find(NV_TAGS[tag], NS)
|
|
310
|
+
cNv = nv.find('p:cNvPr', NS) if nv is not None else None
|
|
311
|
+
name = cNv.get('name') if cNv is not None else None
|
|
312
|
+
rec = {'part': ctx.part, 'layer': ctx.layer, 'kind': tag,
|
|
313
|
+
'id': cNv.get('id') if cNv is not None else None, 'name': name,
|
|
314
|
+
'depth': depth}
|
|
315
|
+
if path:
|
|
316
|
+
rec['group_path'] = list(path)
|
|
317
|
+
ph = _ph(nv)
|
|
318
|
+
if ph:
|
|
319
|
+
rec['ph'] = ph
|
|
320
|
+
if cNv is not None and cNv.get('hidden') == '1':
|
|
321
|
+
rec['hidden'] = True
|
|
322
|
+
|
|
323
|
+
raw = read_xfrm(sp, tag)
|
|
324
|
+
if raw:
|
|
325
|
+
ax = raw['x'] * sx + dx
|
|
326
|
+
ay = raw['y'] * sy + dy
|
|
327
|
+
aw, ah = raw['cx'] * sx, raw['cy'] * sy
|
|
328
|
+
rec['box_emu'] = {'x': round(ax), 'y': round(ay), 'cx': round(aw), 'cy': round(ah)}
|
|
329
|
+
bx, by, bw, bh = U.px(ax), U.px(ay), U.px(aw), U.px(ah)
|
|
330
|
+
rec['box_unrotated'] = {'x': bx, 'y': by, 'w': bw, 'h': bh}
|
|
331
|
+
if raw['rot']:
|
|
332
|
+
rec['rot'] = round(raw['rot'], 2)
|
|
333
|
+
rx, ry, rw, rh = rotated_bbox(bx, by, bw, bh, raw['rot'])
|
|
334
|
+
box = {'x': round(rx, 1), 'y': round(ry, 1), 'w': round(rw, 1), 'h': round(rh, 1)}
|
|
335
|
+
else:
|
|
336
|
+
box = dict(rec['box_unrotated'])
|
|
337
|
+
if raw['flipH']:
|
|
338
|
+
rec['flipH'] = True
|
|
339
|
+
if raw['flipV']:
|
|
340
|
+
rec['flipV'] = True
|
|
341
|
+
verdict, over, clamp = classify_box(box['x'], box['y'], box['w'], box['h'], W, H)
|
|
342
|
+
rec['placement'] = verdict
|
|
343
|
+
if over:
|
|
344
|
+
rec['overflow_pct'] = over
|
|
345
|
+
if verdict == 'bleed':
|
|
346
|
+
rec['bleed'] = True
|
|
347
|
+
elif verdict == 'clamped':
|
|
348
|
+
rec['box_before_clamp'] = dict(box)
|
|
349
|
+
box = {'x': clamp[0], 'y': clamp[1], 'w': clamp[2], 'h': clamp[3]}
|
|
350
|
+
rec['clamped'] = True
|
|
351
|
+
rec['box'] = box
|
|
352
|
+
rec['w_pct'] = round(box['w'] / W * 100, 2)
|
|
353
|
+
rec['h_pct'] = round(box['h'] / H * 100, 2)
|
|
354
|
+
if box['w'] == 0 or box['h'] == 0:
|
|
355
|
+
rec['degenerate_axis'] = 'w' if box['w'] == 0 else 'h'
|
|
356
|
+
else:
|
|
357
|
+
rec['placement'] = 'inherited'
|
|
358
|
+
|
|
359
|
+
spPr = sp.find('p:spPr', NS)
|
|
360
|
+
bw = rec.get('box', {}).get('w')
|
|
361
|
+
bh = rec.get('box', {}).get('h')
|
|
362
|
+
geom, radius = describe_geom(spPr, ctx, bw, bh)
|
|
363
|
+
if geom:
|
|
364
|
+
rec['geom'] = geom
|
|
365
|
+
if radius is not None:
|
|
366
|
+
rec['radius_px'] = radius
|
|
367
|
+
f = describe_fill(spPr, ctx)
|
|
368
|
+
if f:
|
|
369
|
+
rec['fill'] = f
|
|
370
|
+
ln = describe_line(spPr, ctx)
|
|
371
|
+
if ln:
|
|
372
|
+
rec['line'] = ln
|
|
373
|
+
ef = describe_effects(spPr, ctx)
|
|
374
|
+
if ef:
|
|
375
|
+
rec['effects'] = ef
|
|
376
|
+
style = sp.find('p:style', NS)
|
|
377
|
+
if style is not None:
|
|
378
|
+
st = {}
|
|
379
|
+
for refname in ('fillRef', 'lnRef', 'effectRef', 'fontRef'):
|
|
380
|
+
e = style.find('a:%s' % refname, NS)
|
|
381
|
+
if e is None:
|
|
382
|
+
continue
|
|
383
|
+
col = None
|
|
384
|
+
for ch in e:
|
|
385
|
+
col = resolve_color(read_color(ch), ctx.clrmap, ctx.clrscheme)
|
|
386
|
+
if col:
|
|
387
|
+
break
|
|
388
|
+
st[refname] = {'idx': e.get('idx'), 'color': col}
|
|
389
|
+
if st:
|
|
390
|
+
rec['styleRef'] = st
|
|
391
|
+
|
|
392
|
+
if tag == 'pic':
|
|
393
|
+
blip = sp.find('p:blipFill/a:blip', NS)
|
|
394
|
+
if blip is not None:
|
|
395
|
+
rec['media'] = ctx.media_of(blip.get(R_EMBED)) or ctx.media_of(blip.get(R_LINK))
|
|
396
|
+
svg = blip.find('a:extLst//asvg:svgBlip', NS)
|
|
397
|
+
if svg is not None:
|
|
398
|
+
rec['media_svg'] = ctx.media_of(svg.get(R_EMBED))
|
|
399
|
+
sr = sp.find('p:blipFill/a:srcRect', NS)
|
|
400
|
+
if sr is not None and sr.attrib:
|
|
401
|
+
rec['crop'] = {k: round(int(v) / 1000.0, 2) for k, v in sr.attrib.items()}
|
|
402
|
+
stretch = sp.find('p:blipFill/a:stretch', NS)
|
|
403
|
+
if stretch is None and sp.find('p:blipFill/a:tile', NS) is not None:
|
|
404
|
+
rec['tile'] = True
|
|
405
|
+
elif tag == 'graphicFrame':
|
|
406
|
+
gd = sp.find('a:graphic/a:graphicData', NS)
|
|
407
|
+
uri = gd.get('uri') if gd is not None else None
|
|
408
|
+
rec['graphic_uri'] = uri
|
|
409
|
+
if gd is not None:
|
|
410
|
+
if gd.find('.//a:tbl', NS) is not None:
|
|
411
|
+
rec['kind'] = 'table'
|
|
412
|
+
tbl = gd.find('.//a:tbl', NS)
|
|
413
|
+
rec['table'] = {'rows': len(tbl.findall('a:tr', NS)),
|
|
414
|
+
'cols': len(tbl.findall('a:tblGrid/a:gridCol', NS))}
|
|
415
|
+
elif uri and 'chart' in uri:
|
|
416
|
+
rec['kind'] = 'chart'
|
|
417
|
+
elif uri and 'diagram' in uri:
|
|
418
|
+
rec['kind'] = 'diagram'
|
|
419
|
+
|
|
420
|
+
tx = sp.find('p:txBody', NS)
|
|
421
|
+
if tx is None:
|
|
422
|
+
tx = sp.find('a:txBody', NS)
|
|
423
|
+
if tx is not None:
|
|
424
|
+
body = read_txbody(tx, ctx)
|
|
425
|
+
if body:
|
|
426
|
+
rec['text'] = body
|
|
427
|
+
out.append(rec)
|
|
428
|
+
|
|
429
|
+
if tag == 'grpSp':
|
|
430
|
+
gsx, gsy, gdx, gdy = group_transform(sp)
|
|
431
|
+
walk_tree(sp, ctx, out, path + (name,),
|
|
432
|
+
(sx * gsx, sy * gsy, dx + gdx * sx, dy + gdy * sy), depth + 1)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def read_part_shapes(pkg, part, layer, units, clrmap, clrscheme, master=None, theme=None):
|
|
436
|
+
ctx = PartCtx(pkg, part, layer, units, clrmap, clrscheme, master, theme)
|
|
437
|
+
root = pkg.xml(part)
|
|
438
|
+
shapes = []
|
|
439
|
+
tree = root.find('p:cSld/p:spTree', NS)
|
|
440
|
+
if tree is not None:
|
|
441
|
+
walk_tree(tree, ctx, shapes)
|
|
442
|
+
return ctx, shapes, _bg_descriptor(root, ctx)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def read_txstyles(pkg, master_part, ctx):
|
|
446
|
+
ts = pkg.xml(master_part).find('p:txStyles', NS)
|
|
447
|
+
if ts is None:
|
|
448
|
+
return None
|
|
449
|
+
out = {}
|
|
450
|
+
for which in ('titleStyle', 'bodyStyle', 'otherStyle'):
|
|
451
|
+
el = ts.find('p:%s' % which, NS)
|
|
452
|
+
if el is None:
|
|
453
|
+
continue
|
|
454
|
+
lvls = {}
|
|
455
|
+
for lvl in el:
|
|
456
|
+
d = read_lvl(lvl, ctx)
|
|
457
|
+
if d:
|
|
458
|
+
lvls[local(lvl.tag)] = d
|
|
459
|
+
if lvls:
|
|
460
|
+
out[which] = lvls
|
|
461
|
+
return out or None
|