@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,699 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""OOXML parsing primitives for stage-1 extraction (S3 color resolution + S4 shape facts).
|
|
3
|
+
|
|
4
|
+
All length quantities are normalised to px @1920-wide canvas with a single factor
|
|
5
|
+
(`Units.f` = 1920 / sldSz_cx), so coordinates, font sizes, letter spacing, line
|
|
6
|
+
spacing, insets and radii are directly comparable across samples with different
|
|
7
|
+
EMU canvases (方案 v0.2 §1 S4 归一化统一声明).
|
|
8
|
+
"""
|
|
9
|
+
import posixpath
|
|
10
|
+
from xml.etree import ElementTree as ET
|
|
11
|
+
|
|
12
|
+
NS = {
|
|
13
|
+
'a': 'http://schemas.openxmlformats.org/drawingml/2006/main',
|
|
14
|
+
'p': 'http://schemas.openxmlformats.org/presentationml/2006/main',
|
|
15
|
+
'r': 'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
|
|
16
|
+
'rel': 'http://schemas.openxmlformats.org/package/2006/relationships',
|
|
17
|
+
'ct': 'http://schemas.openxmlformats.org/package/2006/content-types',
|
|
18
|
+
'p15': 'http://schemas.microsoft.com/office/powerpoint/2012/main',
|
|
19
|
+
'asvg': 'http://schemas.microsoft.com/office/drawing/2016/SVG/main',
|
|
20
|
+
}
|
|
21
|
+
R_EMBED = '{%s}embed' % NS['r']
|
|
22
|
+
R_LINK = '{%s}link' % NS['r']
|
|
23
|
+
R_ID = '{%s}id' % NS['r']
|
|
24
|
+
|
|
25
|
+
EMU_PER_PT = 12700.0
|
|
26
|
+
CANVAS_W_PX = 1920
|
|
27
|
+
|
|
28
|
+
# clrScheme slot names vs the p:clrMap "map name" keys that point at them.
|
|
29
|
+
SCHEME_SLOTS = ('dk1', 'lt1', 'dk2', 'lt2', 'accent1', 'accent2', 'accent3',
|
|
30
|
+
'accent4', 'accent5', 'accent6', 'hlink', 'folHlink')
|
|
31
|
+
OFFICE_DEFAULT_ACCENTS = {'4472C4', 'ED7D31', 'A5A5A5', 'FFC000', '5B9BD5', '70AD47'}
|
|
32
|
+
OFFICE_DEFAULT_FONTS = {'Calibri', 'Calibri Light', 'Cambria', 'Aptos', 'Aptos Display'}
|
|
33
|
+
|
|
34
|
+
# Weight tokens carried in font names. Keys are lowercased and space/hyphen-stripped.
|
|
35
|
+
# Includes the 31-char-truncation artefacts seen in real files (SemiBol / DemiBol).
|
|
36
|
+
WEIGHT_TOKENS = {
|
|
37
|
+
'thin': 100, 'hairline': 100,
|
|
38
|
+
'extralight': 200, 'ultralight': 200, 'extralight': 200,
|
|
39
|
+
'light': 300,
|
|
40
|
+
'regular': 400, 'normal': 400, 'book': 400, 'roman': 400,
|
|
41
|
+
'medium': 500,
|
|
42
|
+
'semibold': 600, 'semibol': 600, 'demibold': 600, 'demibol': 600, 'demi': 600,
|
|
43
|
+
'bold': 700,
|
|
44
|
+
'extrabold': 800, 'ultrabold': 800,
|
|
45
|
+
'black': 900, 'heavy': 900,
|
|
46
|
+
}
|
|
47
|
+
ITALIC_TOKENS = {'italic', 'oblique', 'it'}
|
|
48
|
+
|
|
49
|
+
# Deterministic latin<->CJK alias hints. Emitted as a separate `alias_group` field,
|
|
50
|
+
# never merged into counts, so the audit trail stays intact (see STAGE1-REPORT deviations).
|
|
51
|
+
FONT_ALIAS_GROUPS = {
|
|
52
|
+
'fzlantinghei': ('方正兰亭黑', 'fzlantingheipro', 'fzlthpro', 'fzlthpros'),
|
|
53
|
+
'bytesans': ('字节跳动', 'bytesans'),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def local(tag):
|
|
58
|
+
return tag.rsplit('}', 1)[-1]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve_part(part, target):
|
|
62
|
+
"""rels target (possibly '../media/x.png') -> package-absolute part name."""
|
|
63
|
+
if target.startswith('/'):
|
|
64
|
+
return target.lstrip('/')
|
|
65
|
+
return posixpath.normpath(posixpath.join(posixpath.dirname(part), target))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Units:
|
|
69
|
+
"""Single normalisation factor shared by every length quantity."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, cx, cy):
|
|
72
|
+
self.cx, self.cy = cx, cy
|
|
73
|
+
self.f = CANVAS_W_PX / float(cx)
|
|
74
|
+
self.w = CANVAS_W_PX
|
|
75
|
+
self.h = round(cy * self.f)
|
|
76
|
+
self.emu_per_px = cx / float(CANVAS_W_PX)
|
|
77
|
+
|
|
78
|
+
def px(self, emu, nd=1):
|
|
79
|
+
if emu is None:
|
|
80
|
+
return None
|
|
81
|
+
return round(int(emu) * self.f, nd)
|
|
82
|
+
|
|
83
|
+
def pt100(self, v, nd=1):
|
|
84
|
+
"""sz / spc / spcPts style values (1/100 pt) -> px."""
|
|
85
|
+
if v is None:
|
|
86
|
+
return None
|
|
87
|
+
return round(float(v) / 100.0 * EMU_PER_PT * self.f, nd)
|
|
88
|
+
|
|
89
|
+
def eighth_pt(self, v, nd=1):
|
|
90
|
+
"""p15:guide pos (1/8 pt) -> px."""
|
|
91
|
+
if v is None:
|
|
92
|
+
return None
|
|
93
|
+
return round(float(v) / 8.0 * EMU_PER_PT * self.f, nd)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# --------------------------------------------------------------------- colour
|
|
97
|
+
def _lin(c):
|
|
98
|
+
c = c / 255.0
|
|
99
|
+
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _srgb(c):
|
|
103
|
+
c = max(0.0, min(1.0, c))
|
|
104
|
+
v = c * 12.92 if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055
|
|
105
|
+
return max(0, min(255, int(round(v * 255))))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _rgb_to_hsl(r, g, b):
|
|
109
|
+
r, g, b = r / 255.0, g / 255.0, b / 255.0
|
|
110
|
+
mx, mn = max(r, g, b), min(r, g, b)
|
|
111
|
+
l = (mx + mn) / 2
|
|
112
|
+
if mx == mn:
|
|
113
|
+
return 0.0, 0.0, l
|
|
114
|
+
d = mx - mn
|
|
115
|
+
s = d / (2 - mx - mn) if l > 0.5 else d / (mx + mn)
|
|
116
|
+
if mx == r:
|
|
117
|
+
h = ((g - b) / d) % 6
|
|
118
|
+
elif mx == g:
|
|
119
|
+
h = (b - r) / d + 2
|
|
120
|
+
else:
|
|
121
|
+
h = (r - g) / d + 4
|
|
122
|
+
return h / 6, s, l
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _hue(p, q, t):
|
|
126
|
+
t = t % 1.0
|
|
127
|
+
if t < 1 / 6:
|
|
128
|
+
return p + (q - p) * 6 * t
|
|
129
|
+
if t < 1 / 2:
|
|
130
|
+
return q
|
|
131
|
+
if t < 2 / 3:
|
|
132
|
+
return p + (q - p) * (2 / 3 - t) * 6
|
|
133
|
+
return p
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _hsl_to_rgb(h, s, l):
|
|
137
|
+
if s == 0:
|
|
138
|
+
v = int(round(l * 255))
|
|
139
|
+
return v, v, v
|
|
140
|
+
q = l * (1 + s) if l < 0.5 else l + s - l * s
|
|
141
|
+
p = 2 * l - q
|
|
142
|
+
return (int(round(_hue(p, q, h + 1 / 3) * 255)),
|
|
143
|
+
int(round(_hue(p, q, h) * 255)),
|
|
144
|
+
int(round(_hue(p, q, h - 1 / 3) * 255)))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
COLOR_TAGS = ('srgbClr', 'schemeClr', 'sysClr', 'prstClr', 'scrgbClr', 'hslClr')
|
|
148
|
+
PRST_CLR = {'black': '000000', 'white': 'FFFFFF', 'red': 'FF0000', 'green': '008000',
|
|
149
|
+
'blue': '0000FF', 'gray': '808080', 'grey': '808080', 'yellow': 'FFFF00'}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def read_color(el):
|
|
153
|
+
"""Parse one colour element into a raw, unresolved descriptor."""
|
|
154
|
+
if el is None:
|
|
155
|
+
return None
|
|
156
|
+
tag = local(el.tag)
|
|
157
|
+
if tag not in COLOR_TAGS:
|
|
158
|
+
return None
|
|
159
|
+
raw = {'type': tag}
|
|
160
|
+
if tag == 'sysClr':
|
|
161
|
+
raw['val'] = el.get('lastClr')
|
|
162
|
+
raw['sys'] = el.get('val')
|
|
163
|
+
elif tag == 'scrgbClr':
|
|
164
|
+
# r/g/b are linear-space percentages in 1/1000 % units.
|
|
165
|
+
raw['val'] = '%02X%02X%02X' % tuple(
|
|
166
|
+
_srgb(int(el.get(k, 0)) / 100000.0) for k in ('r', 'g', 'b'))
|
|
167
|
+
elif tag == 'hslClr':
|
|
168
|
+
r, g, b = _hsl_to_rgb(int(el.get('hue', 0)) / 21600000.0,
|
|
169
|
+
int(el.get('sat', 0)) / 100000.0,
|
|
170
|
+
int(el.get('lum', 0)) / 100000.0)
|
|
171
|
+
raw['val'] = '%02X%02X%02X' % (r, g, b)
|
|
172
|
+
elif tag == 'prstClr':
|
|
173
|
+
raw['val'] = PRST_CLR.get(el.get('val', ''), None)
|
|
174
|
+
raw['prst'] = el.get('val')
|
|
175
|
+
else:
|
|
176
|
+
raw['val'] = el.get('val')
|
|
177
|
+
mods = []
|
|
178
|
+
for m in el:
|
|
179
|
+
mods.append((local(m.tag), m.get('val')))
|
|
180
|
+
if mods:
|
|
181
|
+
raw['mods'] = mods
|
|
182
|
+
return raw
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def raw_token(raw):
|
|
186
|
+
"""Stable audit key, e.g. 'schemeClr:bg1(lumMod=60000)'."""
|
|
187
|
+
if not raw:
|
|
188
|
+
return None
|
|
189
|
+
t = '%s:%s' % ('schemeClr' if raw['type'] == 'schemeClr' else raw['type'], raw.get('val'))
|
|
190
|
+
if raw.get('mods'):
|
|
191
|
+
t += '(' + ','.join('%s=%s' % (k, v) for k, v in raw['mods']) + ')'
|
|
192
|
+
return t
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def resolve_color(raw, clrmap=None, clrscheme=None, phclr=None):
|
|
196
|
+
"""raw descriptor + master clrMap + theme clrScheme -> {hex, alpha, resolved}.
|
|
197
|
+
|
|
198
|
+
This is the schemeClr -> clrMap -> clrScheme indirection the PRD's
|
|
199
|
+
"do not resolve inheritance" rule does NOT cover (方案 v0.2 §3 难点 3).
|
|
200
|
+
"""
|
|
201
|
+
if not raw:
|
|
202
|
+
return None
|
|
203
|
+
hexv, unresolved = None, None
|
|
204
|
+
if raw['type'] == 'schemeClr':
|
|
205
|
+
name = raw.get('val')
|
|
206
|
+
if name == 'phClr':
|
|
207
|
+
hexv = phclr
|
|
208
|
+
if hexv is None:
|
|
209
|
+
unresolved = 'phClr'
|
|
210
|
+
else:
|
|
211
|
+
slot = (clrmap or {}).get(name, name)
|
|
212
|
+
ent = (clrscheme or {}).get(slot)
|
|
213
|
+
if ent is None:
|
|
214
|
+
unresolved = 'no-scheme:%s' % name
|
|
215
|
+
else:
|
|
216
|
+
hexv = ent
|
|
217
|
+
else:
|
|
218
|
+
hexv = raw.get('val')
|
|
219
|
+
if hexv is None:
|
|
220
|
+
unresolved = 'unknown:%s' % raw['type']
|
|
221
|
+
out = {'raw': raw_token(raw), 'alpha': 100.0}
|
|
222
|
+
if unresolved or not hexv:
|
|
223
|
+
out['unresolved'] = unresolved or 'empty'
|
|
224
|
+
return out
|
|
225
|
+
hexv = hexv.lstrip('#').upper()
|
|
226
|
+
try:
|
|
227
|
+
r, g, b = int(hexv[0:2], 16), int(hexv[2:4], 16), int(hexv[4:6], 16)
|
|
228
|
+
except (ValueError, IndexError):
|
|
229
|
+
out['unresolved'] = 'bad-hex:%s' % hexv
|
|
230
|
+
return out
|
|
231
|
+
alpha = 100.0
|
|
232
|
+
for name, val in raw.get('mods', []):
|
|
233
|
+
if val is None:
|
|
234
|
+
continue
|
|
235
|
+
v = int(val)
|
|
236
|
+
if name == 'alpha':
|
|
237
|
+
alpha = v / 1000.0
|
|
238
|
+
elif name == 'lumMod':
|
|
239
|
+
h, s, l = _rgb_to_hsl(r, g, b)
|
|
240
|
+
r, g, b = _hsl_to_rgb(h, s, l * v / 100000.0)
|
|
241
|
+
elif name == 'lumOff':
|
|
242
|
+
h, s, l = _rgb_to_hsl(r, g, b)
|
|
243
|
+
r, g, b = _hsl_to_rgb(h, s, min(1.0, l + v / 100000.0))
|
|
244
|
+
elif name == 'satMod':
|
|
245
|
+
h, s, l = _rgb_to_hsl(r, g, b)
|
|
246
|
+
r, g, b = _hsl_to_rgb(h, min(1.0, s * v / 100000.0), l)
|
|
247
|
+
elif name == 'satOff':
|
|
248
|
+
h, s, l = _rgb_to_hsl(r, g, b)
|
|
249
|
+
r, g, b = _hsl_to_rgb(h, min(1.0, max(0.0, s + v / 100000.0)), l)
|
|
250
|
+
elif name == 'shade':
|
|
251
|
+
f = v / 100000.0
|
|
252
|
+
r, g, b = (_srgb(_lin(r) * f), _srgb(_lin(g) * f), _srgb(_lin(b) * f))
|
|
253
|
+
elif name == 'tint':
|
|
254
|
+
f = v / 100000.0
|
|
255
|
+
r, g, b = (_srgb(_lin(r) * f + (1 - f)), _srgb(_lin(g) * f + (1 - f)),
|
|
256
|
+
_srgb(_lin(b) * f + (1 - f)))
|
|
257
|
+
out['hex'] = '#%02X%02X%02X' % (r, g, b)
|
|
258
|
+
out['alpha'] = round(alpha, 1)
|
|
259
|
+
out['resolved'] = out['hex'] if alpha >= 100 else 'rgba(%d,%d,%d,%.3g)' % (r, g, b, alpha / 100.0)
|
|
260
|
+
return out
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def luminance(hexv):
|
|
264
|
+
hexv = hexv.lstrip('#')
|
|
265
|
+
r, g, b = int(hexv[0:2], 16), int(hexv[2:4], 16), int(hexv[4:6], 16)
|
|
266
|
+
return 0.2126 * _lin(r) + 0.7152 * _lin(g) + 0.0722 * _lin(b)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ------------------------------------------------------------------ fill/line
|
|
270
|
+
def describe_fill(container, ctx):
|
|
271
|
+
"""solidFill / gradFill / blipFill / pattFill / noFill descriptor."""
|
|
272
|
+
if container is None:
|
|
273
|
+
return None
|
|
274
|
+
sf = container.find('a:solidFill', NS)
|
|
275
|
+
if sf is not None:
|
|
276
|
+
for ch in sf:
|
|
277
|
+
c = resolve_color(read_color(ch), ctx.clrmap, ctx.clrscheme)
|
|
278
|
+
if c:
|
|
279
|
+
return {'type': 'solid', 'color': c}
|
|
280
|
+
return {'type': 'solid'}
|
|
281
|
+
gf = container.find('a:gradFill', NS)
|
|
282
|
+
if gf is not None:
|
|
283
|
+
stops = []
|
|
284
|
+
for gs in gf.findall('a:gsLst/a:gs', NS):
|
|
285
|
+
c = None
|
|
286
|
+
for ch in gs:
|
|
287
|
+
c = resolve_color(read_color(ch), ctx.clrmap, ctx.clrscheme)
|
|
288
|
+
if c:
|
|
289
|
+
break
|
|
290
|
+
stops.append({'pos': round(int(gs.get('pos', 0)) / 1000.0, 2), 'color': c})
|
|
291
|
+
d = {'type': 'gradient', 'stops': stops}
|
|
292
|
+
lin = gf.find('a:lin', NS)
|
|
293
|
+
if lin is not None:
|
|
294
|
+
d['angle_deg'] = round(int(lin.get('ang', 0)) / 60000.0, 2)
|
|
295
|
+
d['scaled'] = lin.get('scaled') == '1'
|
|
296
|
+
path = gf.find('a:path', NS)
|
|
297
|
+
if path is not None:
|
|
298
|
+
d['path'] = path.get('path')
|
|
299
|
+
return d
|
|
300
|
+
bf = container.find('a:blipFill', NS)
|
|
301
|
+
if bf is not None:
|
|
302
|
+
blip = bf.find('a:blip', NS)
|
|
303
|
+
rid = blip.get(R_EMBED) if blip is not None else None
|
|
304
|
+
d = {'type': 'image', 'media': ctx.media_of(rid)}
|
|
305
|
+
sr = bf.find('a:srcRect', NS)
|
|
306
|
+
if sr is not None and sr.attrib:
|
|
307
|
+
d['crop'] = {k: round(int(v) / 1000.0, 2) for k, v in sr.attrib.items()}
|
|
308
|
+
if bf.find('a:tile', NS) is not None:
|
|
309
|
+
d['tile'] = True
|
|
310
|
+
return d
|
|
311
|
+
pf = container.find('a:pattFill', NS)
|
|
312
|
+
if pf is not None:
|
|
313
|
+
return {'type': 'pattern', 'preset': pf.get('prst')}
|
|
314
|
+
if container.find('a:noFill', NS) is not None:
|
|
315
|
+
return {'type': 'none'}
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def describe_line(spPr, ctx):
|
|
320
|
+
ln = spPr.find('a:ln', NS) if spPr is not None else None
|
|
321
|
+
if ln is None:
|
|
322
|
+
return None
|
|
323
|
+
d = {}
|
|
324
|
+
if ln.get('w'):
|
|
325
|
+
d['w_px'] = ctx.units.px(ln.get('w'), 2)
|
|
326
|
+
if ln.find('a:noFill', NS) is not None:
|
|
327
|
+
d['none'] = True
|
|
328
|
+
f = describe_fill(ln, ctx)
|
|
329
|
+
if f and f.get('type') == 'solid':
|
|
330
|
+
d['color'] = f.get('color')
|
|
331
|
+
elif f and f.get('type') == 'gradient':
|
|
332
|
+
d['gradient'] = f
|
|
333
|
+
dash = ln.find('a:prstDash', NS)
|
|
334
|
+
if dash is not None:
|
|
335
|
+
d['dash'] = dash.get('val')
|
|
336
|
+
for tag in ('headEnd', 'tailEnd'):
|
|
337
|
+
e = ln.find('a:%s' % tag, NS)
|
|
338
|
+
if e is not None and e.get('type') not in (None, 'none'):
|
|
339
|
+
d[tag] = e.get('type')
|
|
340
|
+
return d or None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
EFFECT_LEN_ATTRS = ('blurRad', 'dist', 'sx', 'sy', 'rad')
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def describe_effects(spPr, ctx):
|
|
347
|
+
if spPr is None:
|
|
348
|
+
return None
|
|
349
|
+
lst = spPr.find('a:effectLst', NS)
|
|
350
|
+
out = []
|
|
351
|
+
if lst is not None:
|
|
352
|
+
for e in lst:
|
|
353
|
+
d = {'type': local(e.tag)}
|
|
354
|
+
for k, v in e.attrib.items():
|
|
355
|
+
if k in ('blurRad', 'dist', 'rad'):
|
|
356
|
+
d[k + '_px'] = ctx.units.px(v, 2)
|
|
357
|
+
elif k == 'dir':
|
|
358
|
+
d['dir_deg'] = round(int(v) / 60000.0, 1)
|
|
359
|
+
else:
|
|
360
|
+
d[k] = v
|
|
361
|
+
for ch in e:
|
|
362
|
+
c = resolve_color(read_color(ch), ctx.clrmap, ctx.clrscheme)
|
|
363
|
+
if c:
|
|
364
|
+
d['color'] = c
|
|
365
|
+
break
|
|
366
|
+
out.append(d)
|
|
367
|
+
if spPr.find('a:effectDag', NS) is not None:
|
|
368
|
+
out.append({'type': 'effectDag'})
|
|
369
|
+
scene = spPr.find('a:scene3d', NS)
|
|
370
|
+
if scene is not None:
|
|
371
|
+
out.append({'type': 'scene3d'})
|
|
372
|
+
return out or None
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def describe_geom(spPr, ctx, w_px=None, h_px=None):
|
|
376
|
+
if spPr is None:
|
|
377
|
+
return None, None
|
|
378
|
+
pg = spPr.find('a:prstGeom', NS)
|
|
379
|
+
if pg is None:
|
|
380
|
+
if spPr.find('a:custGeom', NS) is not None:
|
|
381
|
+
return {'prst': 'custGeom'}, None
|
|
382
|
+
return None, None
|
|
383
|
+
d = {'prst': pg.get('prst')}
|
|
384
|
+
adj = {}
|
|
385
|
+
for gd in pg.findall('a:avLst/a:gd', NS):
|
|
386
|
+
adj[gd.get('name')] = gd.get('fmla')
|
|
387
|
+
if adj:
|
|
388
|
+
d['adj'] = adj
|
|
389
|
+
radius = None
|
|
390
|
+
if d['prst'] in ('roundRect', 'round1Rect', 'round2SameRect', 'round2DiagRect',
|
|
391
|
+
'snipRoundRect') and w_px and h_px:
|
|
392
|
+
fmla = adj.get('adj') or adj.get('adj1') or 'val 16667'
|
|
393
|
+
try:
|
|
394
|
+
val = float(str(fmla).split()[-1])
|
|
395
|
+
except (ValueError, IndexError):
|
|
396
|
+
val = 16667.0
|
|
397
|
+
radius = round(val / 100000.0 * min(w_px, h_px), 1)
|
|
398
|
+
return d, radius
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# ------------------------------------------------------------------ text runs
|
|
402
|
+
def _lnspc(pPr, ctx):
|
|
403
|
+
if pPr is None:
|
|
404
|
+
return None
|
|
405
|
+
pct = pPr.find('a:lnSpc/a:spcPct', NS)
|
|
406
|
+
if pct is not None:
|
|
407
|
+
return {'mult': round(int(pct.get('val')) / 100000.0, 3)}
|
|
408
|
+
pts = pPr.find('a:lnSpc/a:spcPts', NS)
|
|
409
|
+
if pts is not None:
|
|
410
|
+
return {'px': ctx.units.pt100(pts.get('val'))}
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _spc_before_after(pPr, ctx, tag):
|
|
415
|
+
if pPr is None:
|
|
416
|
+
return None
|
|
417
|
+
pct = pPr.find('a:%s/a:spcPct' % tag, NS)
|
|
418
|
+
if pct is not None:
|
|
419
|
+
return {'pct': round(int(pct.get('val')) / 1000.0, 2)}
|
|
420
|
+
pts = pPr.find('a:%s/a:spcPts' % tag, NS)
|
|
421
|
+
if pts is not None:
|
|
422
|
+
return {'px': ctx.units.pt100(pts.get('val'))}
|
|
423
|
+
return None
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def font_weight(raw_name):
|
|
427
|
+
"""Weight carried by the font *name*. `b="1"` is tracked separately (see
|
|
428
|
+
read_rpr's `bold`) because the name is the only source that distinguishes
|
|
429
|
+
Medium/SemiBold/DemiBold — collapsing both into one field loses the axis."""
|
|
430
|
+
return family_of(raw_name)[1] if raw_name else None
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def family_of(raw):
|
|
434
|
+
"""raw typeface -> (family, weight, italic). Weight is kept; only the
|
|
435
|
+
*family key* is stripped, so counting merges while 字重 stays readable."""
|
|
436
|
+
if not raw:
|
|
437
|
+
return None, None, False
|
|
438
|
+
name = raw.strip()
|
|
439
|
+
weight, italic = None, False
|
|
440
|
+
parts = name.replace('_', ' _').split(' ')
|
|
441
|
+
while len(parts) > 1:
|
|
442
|
+
tail = parts[-1].strip().lower().replace('-', '')
|
|
443
|
+
if tail in ITALIC_TOKENS:
|
|
444
|
+
italic = True
|
|
445
|
+
parts.pop()
|
|
446
|
+
continue
|
|
447
|
+
if tail in WEIGHT_TOKENS:
|
|
448
|
+
weight = WEIGHT_TOKENS[tail]
|
|
449
|
+
parts.pop()
|
|
450
|
+
continue
|
|
451
|
+
break
|
|
452
|
+
family = ' '.join(parts).replace(' _', '_').strip(' -')
|
|
453
|
+
return (family or name), weight, italic
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def alias_group(family):
|
|
457
|
+
key = ''.join(ch for ch in family.lower() if ch.isalnum())
|
|
458
|
+
for grp, needles in FONT_ALIAS_GROUPS.items():
|
|
459
|
+
for n in needles:
|
|
460
|
+
nk = ''.join(ch for ch in n.lower() if ch.isalnum())
|
|
461
|
+
if nk and nk in key:
|
|
462
|
+
return grp
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def read_rpr(rPr, ctx):
|
|
467
|
+
d = {}
|
|
468
|
+
if rPr is None:
|
|
469
|
+
return d
|
|
470
|
+
if rPr.get('sz'):
|
|
471
|
+
d['sz_px'] = ctx.units.pt100(rPr.get('sz'))
|
|
472
|
+
if rPr.get('b') == '1':
|
|
473
|
+
d['bold'] = True
|
|
474
|
+
if rPr.get('i') == '1':
|
|
475
|
+
d['italic'] = True
|
|
476
|
+
if rPr.get('u') and rPr.get('u') != 'none':
|
|
477
|
+
d['underline'] = rPr.get('u')
|
|
478
|
+
if rPr.get('strike') and rPr.get('strike') != 'noStrike':
|
|
479
|
+
d['strike'] = rPr.get('strike')
|
|
480
|
+
if rPr.get('spc'):
|
|
481
|
+
d['spc_px'] = ctx.units.pt100(rPr.get('spc'), 2)
|
|
482
|
+
if rPr.get('cap') and rPr.get('cap') != 'none':
|
|
483
|
+
d['cap'] = rPr.get('cap')
|
|
484
|
+
if rPr.get('baseline') and rPr.get('baseline') != '0':
|
|
485
|
+
d['baseline'] = int(rPr.get('baseline')) / 1000.0
|
|
486
|
+
for tag in ('latin', 'ea', 'cs'):
|
|
487
|
+
e = rPr.find('a:%s' % tag, NS)
|
|
488
|
+
if e is not None and e.get('typeface'):
|
|
489
|
+
d[tag] = e.get('typeface')
|
|
490
|
+
f = describe_fill(rPr, ctx)
|
|
491
|
+
if f:
|
|
492
|
+
if f.get('type') == 'solid':
|
|
493
|
+
d['color'] = f.get('color')
|
|
494
|
+
else:
|
|
495
|
+
d['fill'] = f
|
|
496
|
+
ln = rPr.find('a:ln', NS)
|
|
497
|
+
if ln is not None:
|
|
498
|
+
d['outline'] = True
|
|
499
|
+
w = font_weight(d.get('latin') or d.get('ea'))
|
|
500
|
+
if w:
|
|
501
|
+
d['weight'] = w
|
|
502
|
+
return d
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def read_txbody(tx, ctx, kind='txBody'):
|
|
506
|
+
"""Paragraphs with per-run facts + the part-local lstStyle type scale."""
|
|
507
|
+
if tx is None:
|
|
508
|
+
return None
|
|
509
|
+
out = {}
|
|
510
|
+
bp = tx.find('a:bodyPr', NS)
|
|
511
|
+
if bp is not None:
|
|
512
|
+
b = {}
|
|
513
|
+
for k in ('anchor', 'anchorCtr', 'vert', 'wrap', 'rot'):
|
|
514
|
+
if bp.get(k):
|
|
515
|
+
b[k] = bp.get(k)
|
|
516
|
+
ins = {}
|
|
517
|
+
for k in ('lIns', 'tIns', 'rIns', 'bIns'):
|
|
518
|
+
if bp.get(k) is not None:
|
|
519
|
+
ins[k] = ctx.units.px(bp.get(k))
|
|
520
|
+
if ins:
|
|
521
|
+
b['insets_px'] = ins
|
|
522
|
+
if bp.find('a:normAutofit', NS) is not None:
|
|
523
|
+
b['autofit'] = 'norm'
|
|
524
|
+
elif bp.find('a:spAutoFit', NS) is not None:
|
|
525
|
+
b['autofit'] = 'shape'
|
|
526
|
+
if b:
|
|
527
|
+
out['bodyPr'] = b
|
|
528
|
+
ls = tx.find('a:lstStyle', NS)
|
|
529
|
+
if ls is not None and len(ls):
|
|
530
|
+
lvls = {}
|
|
531
|
+
for lvl in ls:
|
|
532
|
+
p = read_lvl(lvl, ctx)
|
|
533
|
+
if p:
|
|
534
|
+
lvls[local(lvl.tag)] = p
|
|
535
|
+
if lvls:
|
|
536
|
+
out['lstStyle'] = lvls
|
|
537
|
+
paras = []
|
|
538
|
+
for p in tx.findall('a:p', NS):
|
|
539
|
+
pPr = p.find('a:pPr', NS)
|
|
540
|
+
info = {}
|
|
541
|
+
if pPr is not None:
|
|
542
|
+
for k in ('algn', 'lvl', 'rtl'):
|
|
543
|
+
if pPr.get(k):
|
|
544
|
+
info[k] = pPr.get(k)
|
|
545
|
+
for k in ('marL', 'marR', 'indent'):
|
|
546
|
+
if pPr.get(k) is not None:
|
|
547
|
+
info[k + '_px'] = ctx.units.px(pPr.get(k))
|
|
548
|
+
v = _lnspc(pPr, ctx)
|
|
549
|
+
if v:
|
|
550
|
+
info['lnSpc'] = v
|
|
551
|
+
for tag in ('spcBef', 'spcAft'):
|
|
552
|
+
v = _spc_before_after(pPr, ctx, tag)
|
|
553
|
+
if v:
|
|
554
|
+
info[tag] = v
|
|
555
|
+
if pPr.find('a:buNone', NS) is not None:
|
|
556
|
+
info['bullet'] = 'none'
|
|
557
|
+
bc = pPr.find('a:buChar', NS)
|
|
558
|
+
if bc is not None:
|
|
559
|
+
info['bullet'] = bc.get('char')
|
|
560
|
+
ba = pPr.find('a:buAutoNum', NS)
|
|
561
|
+
if ba is not None:
|
|
562
|
+
info['bullet'] = 'auto:%s' % ba.get('type', '')
|
|
563
|
+
# Paragraph-level run defaults. Mac Office / Keynote exports put the
|
|
564
|
+
# size here rather than on a:rPr, so skipping it loses the majority
|
|
565
|
+
# of the declared type scale on those decks.
|
|
566
|
+
dr = pPr.find('a:defRPr', NS)
|
|
567
|
+
if dr is not None:
|
|
568
|
+
facts = read_rpr(dr, ctx)
|
|
569
|
+
if facts:
|
|
570
|
+
info['defRPr'] = facts
|
|
571
|
+
runs = []
|
|
572
|
+
for r in list(p.findall('a:r', NS)) + list(p.findall('a:fld', NS)):
|
|
573
|
+
t = r.find('a:t', NS)
|
|
574
|
+
rec = read_rpr(r.find('a:rPr', NS), ctx)
|
|
575
|
+
rec['text'] = (t.text or '') if t is not None else ''
|
|
576
|
+
if local(r.tag) == 'fld':
|
|
577
|
+
rec['field'] = r.get('type')
|
|
578
|
+
runs.append(rec)
|
|
579
|
+
if not runs:
|
|
580
|
+
ep = p.find('a:endParaRPr', NS)
|
|
581
|
+
if ep is not None:
|
|
582
|
+
rec = read_rpr(ep, ctx)
|
|
583
|
+
if rec:
|
|
584
|
+
rec['text'] = ''
|
|
585
|
+
rec['empty_para'] = True
|
|
586
|
+
runs.append(rec)
|
|
587
|
+
info['runs'] = runs
|
|
588
|
+
paras.append(info)
|
|
589
|
+
out['paragraphs'] = paras
|
|
590
|
+
return out
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def read_lvl(lvl, ctx):
|
|
594
|
+
"""lvlNpPr / titleStyle level -> declared paragraph+run defaults (direct read,
|
|
595
|
+
no inheritance resolution)."""
|
|
596
|
+
d = {}
|
|
597
|
+
for k in ('algn',):
|
|
598
|
+
if lvl.get(k):
|
|
599
|
+
d[k] = lvl.get(k)
|
|
600
|
+
for k in ('marL', 'indent', 'defTabSz'):
|
|
601
|
+
if lvl.get(k) is not None:
|
|
602
|
+
d[k + '_px'] = ctx.units.px(lvl.get(k))
|
|
603
|
+
v = _lnspc(lvl, ctx)
|
|
604
|
+
if v:
|
|
605
|
+
d['lnSpc'] = v
|
|
606
|
+
for tag in ('spcBef', 'spcAft'):
|
|
607
|
+
v = _spc_before_after(lvl, ctx, tag)
|
|
608
|
+
if v:
|
|
609
|
+
d[tag] = v
|
|
610
|
+
dr = lvl.find('a:defRPr', NS)
|
|
611
|
+
if dr is not None:
|
|
612
|
+
d.update(read_rpr(dr, ctx))
|
|
613
|
+
bc = lvl.find('a:buChar', NS)
|
|
614
|
+
if bc is not None:
|
|
615
|
+
d['bullet'] = bc.get('char')
|
|
616
|
+
if lvl.find('a:buNone', NS) is not None:
|
|
617
|
+
d['bullet'] = 'none'
|
|
618
|
+
return d
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
# --------------------------------------------------------------------- shapes
|
|
622
|
+
def group_transform(grpSp):
|
|
623
|
+
"""(sx, sy, dx, dy) in EMU mapping a group's child coords into parent coords."""
|
|
624
|
+
x = grpSp.find('p:grpSpPr/a:xfrm', NS)
|
|
625
|
+
if x is None:
|
|
626
|
+
return 1.0, 1.0, 0.0, 0.0
|
|
627
|
+
off, ext = x.find('a:off', NS), x.find('a:ext', NS)
|
|
628
|
+
cOff, cExt = x.find('a:chOff', NS), x.find('a:chExt', NS)
|
|
629
|
+
if off is None or ext is None or cOff is None or cExt is None:
|
|
630
|
+
return 1.0, 1.0, 0.0, 0.0
|
|
631
|
+
ox, oy = int(off.get('x')), int(off.get('y'))
|
|
632
|
+
cx, cy = int(ext.get('cx')), int(ext.get('cy'))
|
|
633
|
+
kx, ky = int(cOff.get('x')), int(cOff.get('y'))
|
|
634
|
+
ex, ey = int(cExt.get('cx')), int(cExt.get('cy'))
|
|
635
|
+
sx = cx / float(ex) if ex else 1.0
|
|
636
|
+
sy = cy / float(ey) if ey else 1.0
|
|
637
|
+
return sx, sy, ox - kx * sx, oy - ky * sy
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
XFRM_PATHS = {
|
|
641
|
+
'sp': 'p:spPr/a:xfrm', 'pic': 'p:spPr/a:xfrm', 'cxnSp': 'p:spPr/a:xfrm',
|
|
642
|
+
'grpSp': 'p:grpSpPr/a:xfrm', 'graphicFrame': 'p:xfrm',
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def read_xfrm(el, tag):
|
|
647
|
+
x = el.find(XFRM_PATHS.get(tag, 'p:spPr/a:xfrm'), NS)
|
|
648
|
+
if x is None:
|
|
649
|
+
return None
|
|
650
|
+
off, ext = x.find('a:off', NS), x.find('a:ext', NS)
|
|
651
|
+
if off is None or ext is None:
|
|
652
|
+
return None
|
|
653
|
+
return {
|
|
654
|
+
'x': int(off.get('x')), 'y': int(off.get('y')),
|
|
655
|
+
'cx': int(ext.get('cx')), 'cy': int(ext.get('cy')),
|
|
656
|
+
'rot': int(x.get('rot', 0)) / 60000.0,
|
|
657
|
+
'flipH': x.get('flipH') == '1', 'flipV': x.get('flipV') == '1',
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def rotated_bbox(x, y, w, h, rot_deg):
|
|
662
|
+
"""Axis-aligned bounding box of a box rotated about its centre."""
|
|
663
|
+
import math
|
|
664
|
+
if not rot_deg:
|
|
665
|
+
return x, y, w, h
|
|
666
|
+
a = math.radians(rot_deg)
|
|
667
|
+
c, s = abs(math.cos(a)), abs(math.sin(a))
|
|
668
|
+
nw, nh = w * c + h * s, w * s + h * c
|
|
669
|
+
return x + (w - nw) / 2.0, y + (h - nh) / 2.0, nw, nh
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
BLEED_TOL = 0.05
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def classify_box(x, y, w, h, W, H):
|
|
676
|
+
"""完全出界 -> drop / 出血 <=5% -> bleed / >5% -> clamp + gap (方案 v0.2 §3 难点 9).
|
|
677
|
+
|
|
678
|
+
Emptiness is an actual rectangle intersection, not a `w <= 0` test: rules and
|
|
679
|
+
connectors are legitimately zero-extent on one axis (a horizontal divider has
|
|
680
|
+
h = 0) and must stay in as design evidence.
|
|
681
|
+
"""
|
|
682
|
+
if w < 0 or h < 0:
|
|
683
|
+
return 'outside', None, None
|
|
684
|
+
if max(x, 0.0) > min(x + w, float(W)) or max(y, 0.0) > min(y + h, float(H)):
|
|
685
|
+
return 'outside', None, None
|
|
686
|
+
over = {
|
|
687
|
+
'left': max(0.0, -x) / W, 'right': max(0.0, x + w - W) / W,
|
|
688
|
+
'top': max(0.0, -y) / H, 'bottom': max(0.0, y + h - H) / H,
|
|
689
|
+
}
|
|
690
|
+
worst = max(over.values())
|
|
691
|
+
if worst <= 0:
|
|
692
|
+
return 'inside', None, None
|
|
693
|
+
detail = {k: round(v * 100, 2) for k, v in over.items() if v > 0}
|
|
694
|
+
if worst <= BLEED_TOL:
|
|
695
|
+
return 'bleed', detail, None
|
|
696
|
+
cx0, cy0 = max(0.0, x), max(0.0, y)
|
|
697
|
+
cx1, cy1 = min(float(W), x + w), min(float(H), y + h)
|
|
698
|
+
return 'clamped', detail, (round(cx0, 1), round(cy0, 1),
|
|
699
|
+
round(cx1 - cx0, 1), round(cy1 - cy0, 1))
|