@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,765 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stage-1 deterministic extractor for PPTX/POTX style packs (票 09 原型).
|
|
3
|
+
|
|
4
|
+
python3 extract.py <pptx> <outdir> [--export-all-media]
|
|
5
|
+
|
|
6
|
+
Writes <outdir>/extract.json + <outdir>/media-out/ + <outdir>/ref/.
|
|
7
|
+
`--export-all-media` also copies out the non-candidate media (for L1 eyeballing);
|
|
8
|
+
it changes nothing else — candidate judgement and transcoding are untouched.
|
|
9
|
+
Stdlib plus an optional Pillow. Oversized or non-web candidate assets are kept
|
|
10
|
+
as-is *and* re-encoded to a webp under ASSET_BUDGET_BYTES; without Pillow the
|
|
11
|
+
transcode falls back to darwin `sips`, and failing that the row records
|
|
12
|
+
transcode_blocked and extract.json records pillow_available: false.
|
|
13
|
+
"""
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
from collections import Counter, defaultdict
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
|
|
22
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
23
|
+
|
|
24
|
+
from census import (DHASH_PREFILTER_MAX, FULLSCREEN_MIN_PCT, PIXDIFF_MAX, REPEAT_MIN,
|
|
25
|
+
EPS_PX, color_census, content_clusters, detect_twins, font_census,
|
|
26
|
+
image_census, image_palette, layout_inventory, media_fingerprints,
|
|
27
|
+
font_scheme_by_part, radii_effects_census, read_guides,
|
|
28
|
+
spacing_candidates, text_scale, theme_topology)
|
|
29
|
+
from ooxml import NS, Units, local
|
|
30
|
+
from parts import (Package, PartCtx, build_graph, read_clrmap, read_part_shapes, read_theme,
|
|
31
|
+
read_txstyles, triage_masters)
|
|
32
|
+
|
|
33
|
+
SCHEMA = 'pptx-extract/stage1-v0.1'
|
|
34
|
+
WEB_FORMATS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'}
|
|
35
|
+
ASSET_BUDGET_BYTES = 500 * 1024 # D5b WARN line
|
|
36
|
+
# Quality ladder first, then resolution — dropping pixels is the more visible
|
|
37
|
+
# loss, so it is only reached once the lowest quality still overshoots.
|
|
38
|
+
WEBP_QUALITY_LADDER = (85, 75, 65, 55, 45)
|
|
39
|
+
WEBP_SCALE_LADDER = (1.0, 0.75, 0.5, 0.35, 0.25)
|
|
40
|
+
WEBP_MAX_EDGE = 16383 # hard format limit
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def bind_themes(graph, themes_by_part):
|
|
44
|
+
"""master -> theme, with a last-resort binding when the rels chain misses.
|
|
45
|
+
|
|
46
|
+
Normal path is the master's own theme relationship. If that target is not a
|
|
47
|
+
readable theme part and the package holds exactly one theme with a usable
|
|
48
|
+
clrScheme, bind that one rather than emitting UNRESOLVED:no-scheme for every
|
|
49
|
+
schemeClr in the file. Each departure is recorded in `scheme_fallback`.
|
|
50
|
+
"""
|
|
51
|
+
usable = [t for t in themes_by_part.values() if t.get('clrScheme')]
|
|
52
|
+
bound, trace = {}, []
|
|
53
|
+
for mp in graph['master_order']:
|
|
54
|
+
want = graph['theme_of_master'].get(mp)
|
|
55
|
+
theme = themes_by_part.get(want)
|
|
56
|
+
if theme is not None:
|
|
57
|
+
bound[mp] = theme
|
|
58
|
+
continue
|
|
59
|
+
if len(usable) == 1:
|
|
60
|
+
bound[mp] = usable[0]
|
|
61
|
+
trace.append({'master': mp, 'wanted_theme': want, 'bound_theme': usable[0]['part'],
|
|
62
|
+
'rule': 'file-unique-theme'})
|
|
63
|
+
else:
|
|
64
|
+
bound[mp] = {}
|
|
65
|
+
trace.append({'master': mp, 'wanted_theme': want, 'bound_theme': None,
|
|
66
|
+
'rule': 'unbound', 'usable_themes': len(usable)})
|
|
67
|
+
return bound, trace
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def probe_pillow():
|
|
71
|
+
try:
|
|
72
|
+
import PIL # noqa: F401
|
|
73
|
+
from PIL import Image # noqa: F401
|
|
74
|
+
return True, getattr(__import__('PIL'), '__version__', 'unknown')
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
return False, str(exc.__class__.__name__)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ------------------------------------------------------------ S9 media export
|
|
80
|
+
def _webp_from_pillow(raw, dst, budget):
|
|
81
|
+
"""Re-encode `raw` to webp under `budget`, preserving alpha.
|
|
82
|
+
|
|
83
|
+
Returns the row fields describing the result. The smallest encoding is kept
|
|
84
|
+
even when nothing fits the budget, so an oversized asset still shrinks and
|
|
85
|
+
the row says by how much it missed.
|
|
86
|
+
"""
|
|
87
|
+
import io
|
|
88
|
+
|
|
89
|
+
from PIL import Image
|
|
90
|
+
|
|
91
|
+
im = Image.open(io.BytesIO(raw))
|
|
92
|
+
im.load()
|
|
93
|
+
has_alpha = im.mode in ('RGBA', 'LA') or (im.mode == 'P' and 'transparency' in im.info)
|
|
94
|
+
im = im.convert('RGBA' if has_alpha else 'RGB')
|
|
95
|
+
w0, h0 = im.size
|
|
96
|
+
if max(w0, h0) > WEBP_MAX_EDGE:
|
|
97
|
+
k = WEBP_MAX_EDGE / float(max(w0, h0))
|
|
98
|
+
im = im.resize((max(1, int(w0 * k)), max(1, int(h0 * k))), Image.LANCZOS)
|
|
99
|
+
best = None
|
|
100
|
+
for scale in WEBP_SCALE_LADDER:
|
|
101
|
+
work = im if scale == 1.0 else im.resize(
|
|
102
|
+
(max(1, int(im.width * scale)), max(1, int(im.height * scale))), Image.LANCZOS)
|
|
103
|
+
for q in WEBP_QUALITY_LADDER:
|
|
104
|
+
buf = io.BytesIO()
|
|
105
|
+
work.save(buf, 'WEBP', quality=q, method=4)
|
|
106
|
+
data = buf.getvalue()
|
|
107
|
+
if best is None or len(data) < best[0]:
|
|
108
|
+
best = (len(data), data, q, work.size)
|
|
109
|
+
if len(data) <= budget:
|
|
110
|
+
best = (len(data), data, q, work.size)
|
|
111
|
+
break
|
|
112
|
+
if best[0] <= budget:
|
|
113
|
+
break
|
|
114
|
+
size, data, q, dims = best
|
|
115
|
+
with open(dst, 'wb') as f:
|
|
116
|
+
f.write(data)
|
|
117
|
+
row = {'transcoded': True, 'compressed_bytes': size, 'compressed_via': 'pillow',
|
|
118
|
+
'compressed_quality': q, 'compressed_px': list(dims),
|
|
119
|
+
'compressed_alpha': has_alpha, 'source_px': [w0, h0]}
|
|
120
|
+
if size > budget:
|
|
121
|
+
row['over_budget'] = True
|
|
122
|
+
return row
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _webp_from_sips(part, raw, dst_base, budget):
|
|
126
|
+
"""darwin fallback when Pillow is missing: sips can only reach jpeg/png."""
|
|
127
|
+
import shutil
|
|
128
|
+
import subprocess
|
|
129
|
+
import tempfile
|
|
130
|
+
if sys.platform != 'darwin' or not shutil.which('sips'):
|
|
131
|
+
return None
|
|
132
|
+
tmpdir = tempfile.mkdtemp()
|
|
133
|
+
try:
|
|
134
|
+
src = os.path.join(tmpdir, os.path.basename(part))
|
|
135
|
+
with open(src, 'wb') as f:
|
|
136
|
+
f.write(raw)
|
|
137
|
+
dst = dst_base + '.jpg'
|
|
138
|
+
best = None
|
|
139
|
+
for maxdim in (0, 2400, 1600, 1200, 800):
|
|
140
|
+
cmd = ['sips', '-s', 'format', 'jpeg', '-s', 'formatOptions', '70']
|
|
141
|
+
if maxdim:
|
|
142
|
+
cmd += ['-Z', str(maxdim)]
|
|
143
|
+
cmd += [src, '--out', dst]
|
|
144
|
+
r = subprocess.run(cmd, capture_output=True)
|
|
145
|
+
if r.returncode != 0 or not os.path.exists(dst):
|
|
146
|
+
return None
|
|
147
|
+
best = os.path.getsize(dst)
|
|
148
|
+
if best <= budget:
|
|
149
|
+
break
|
|
150
|
+
row = {'transcoded': True, 'compressed_bytes': best, 'compressed_via': 'sips',
|
|
151
|
+
'compressed_out_ext': 'jpg', 'compressed_alpha': False}
|
|
152
|
+
if best > budget:
|
|
153
|
+
row['over_budget'] = True
|
|
154
|
+
return row
|
|
155
|
+
except OSError:
|
|
156
|
+
return None
|
|
157
|
+
finally:
|
|
158
|
+
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def export_media(pkg, images, outdir, pillow_ok, export_all=False):
|
|
162
|
+
media_dir = os.path.join(outdir, 'media-out')
|
|
163
|
+
os.makedirs(media_dir, exist_ok=True)
|
|
164
|
+
by_media = {i['media']: i for i in images}
|
|
165
|
+
rows = []
|
|
166
|
+
for part in pkg.media:
|
|
167
|
+
ext = part.rsplit('.', 1)[-1].lower()
|
|
168
|
+
size = pkg.size_of(part)
|
|
169
|
+
info = by_media.get(part)
|
|
170
|
+
reasons = []
|
|
171
|
+
if info:
|
|
172
|
+
if info['repeat_fixed']:
|
|
173
|
+
reasons.append('repeat_fixed')
|
|
174
|
+
if info['fullscreen']:
|
|
175
|
+
reasons.append('fullscreen')
|
|
176
|
+
if info['variant_group']:
|
|
177
|
+
reasons.append('variant_group')
|
|
178
|
+
if info['stitch_candidate']:
|
|
179
|
+
reasons.append('crop_stitch')
|
|
180
|
+
if ext in ('svg', 'emf', 'wmf'):
|
|
181
|
+
reasons.append('vector')
|
|
182
|
+
row = {'media': part, 'ext': ext, 'bytes': size,
|
|
183
|
+
'used_n': (info or {}).get('n', 0), 'reasons': reasons,
|
|
184
|
+
'candidate': bool(reasons)}
|
|
185
|
+
if not reasons and not export_all:
|
|
186
|
+
row['exported'] = False
|
|
187
|
+
rows.append(row)
|
|
188
|
+
continue
|
|
189
|
+
raw = pkg.zip.read(part)
|
|
190
|
+
if ext == 'svg':
|
|
191
|
+
body = raw.decode('utf-8', 'replace')
|
|
192
|
+
row['embedded_raster'] = 'data:image/' in body
|
|
193
|
+
out_name = os.path.basename(part)
|
|
194
|
+
with open(os.path.join(media_dir, out_name), 'wb') as f:
|
|
195
|
+
f.write(raw)
|
|
196
|
+
row.update({'exported': True, 'out': 'media-out/' + out_name,
|
|
197
|
+
'out_bytes': len(raw), 'transcoded': False,
|
|
198
|
+
'exported_reason': 'candidate' if reasons else 'all-media'})
|
|
199
|
+
if not reasons:
|
|
200
|
+
# Exported for eyeballing only. Transcode stays a candidate-only
|
|
201
|
+
# concern, so this row deliberately gets no needs_transcode.
|
|
202
|
+
rows.append(row)
|
|
203
|
+
continue
|
|
204
|
+
needs = []
|
|
205
|
+
if ext not in WEB_FORMATS:
|
|
206
|
+
needs.append('non-web-format')
|
|
207
|
+
if len(raw) > ASSET_BUDGET_BYTES:
|
|
208
|
+
needs.append('over-%dKB' % (ASSET_BUDGET_BYTES // 1024))
|
|
209
|
+
# svg is already web-native; its raster payload is checked above, and
|
|
210
|
+
# rasterising it here would throw away the vector original.
|
|
211
|
+
if needs and ext != 'svg':
|
|
212
|
+
row['needs_transcode'] = needs
|
|
213
|
+
# The compressed file is only ever .webp (Pillow) or .jpg (sips). When
|
|
214
|
+
# the source already carries that extension the naive stem would point
|
|
215
|
+
# at the exported original and silently overwrite it.
|
|
216
|
+
stem = out_name.rsplit('.', 1)[0]
|
|
217
|
+
if ext in ('webp', 'jpg'):
|
|
218
|
+
stem += '-min'
|
|
219
|
+
base = os.path.join(media_dir, stem)
|
|
220
|
+
done, why = None, 'pillow-unavailable' if not pillow_ok else None
|
|
221
|
+
if pillow_ok:
|
|
222
|
+
try:
|
|
223
|
+
done = _webp_from_pillow(raw, base + '.webp', ASSET_BUDGET_BYTES)
|
|
224
|
+
done['compressed_out'] = ('media-out/'
|
|
225
|
+
+ os.path.basename(base) + '.webp')
|
|
226
|
+
except Exception as exc:
|
|
227
|
+
why = 'pillow-error: %s: %s' % (exc.__class__.__name__, exc)
|
|
228
|
+
if done is None:
|
|
229
|
+
done = _webp_from_sips(part, raw, base, ASSET_BUDGET_BYTES)
|
|
230
|
+
if done is not None:
|
|
231
|
+
done['compressed_out'] = ('media-out/' + os.path.basename(base)
|
|
232
|
+
+ '.' + done['compressed_out_ext'])
|
|
233
|
+
done['transcode_fallback_from'] = why
|
|
234
|
+
row.update(done if done is not None else {'transcode_blocked': why})
|
|
235
|
+
elif needs:
|
|
236
|
+
row['needs_transcode'] = needs
|
|
237
|
+
rows.append(row)
|
|
238
|
+
rows.sort(key=lambda r: (not r['candidate'], -r['bytes']))
|
|
239
|
+
return rows
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------- form hint
|
|
243
|
+
def form_hint(pkg, graph, layouts):
|
|
244
|
+
slides_with_ph = 0
|
|
245
|
+
for sp in pkg.slides:
|
|
246
|
+
if pkg.xml(sp).findall('.//p:ph', NS):
|
|
247
|
+
slides_with_ph += 1
|
|
248
|
+
names = [(l['name'] or '') for l in layouts]
|
|
249
|
+
semantic = sum(1 for n in names if len(n) > 3 and n.upper() not in ('DEFAULT', 'BLANK'))
|
|
250
|
+
ev = {'slides_using_placeholders': '%d/%d' % (slides_with_ph, len(pkg.slides)),
|
|
251
|
+
'layouts': len(pkg.layouts), 'masters': len(pkg.masters),
|
|
252
|
+
'semantic_layout_names': semantic}
|
|
253
|
+
if slides_with_ph and len(pkg.layouts) >= 5 and semantic >= 5:
|
|
254
|
+
form = 3
|
|
255
|
+
elif len(pkg.layouts) > 1 and not slides_with_ph:
|
|
256
|
+
form = 2
|
|
257
|
+
elif len(pkg.layouts) <= 1:
|
|
258
|
+
form = 1
|
|
259
|
+
else:
|
|
260
|
+
form = 0
|
|
261
|
+
ev['form'] = form
|
|
262
|
+
ev['note'] = {1: 'good deck as template: layouts carry no page semantics, '
|
|
263
|
+
'L5 must fall back to per-page slot tables',
|
|
264
|
+
2: 'layouts exist only as background carriers',
|
|
265
|
+
3: 'proper template: read page types straight from layouts',
|
|
266
|
+
0: 'ambiguous'}[form]
|
|
267
|
+
return ev
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def confidence_seed(themes_picked, guides, layouts, form, spacing):
|
|
271
|
+
non_factory = any(not t['factory_colors'] for t in themes_picked)
|
|
272
|
+
return {
|
|
273
|
+
'_rule': 'direct read = high / single-signal inference = medium / '
|
|
274
|
+
'clustering or visual judgement = low',
|
|
275
|
+
'canvas': 'high',
|
|
276
|
+
'theme_topology': 'high',
|
|
277
|
+
'colors': 'high' if non_factory else 'medium',
|
|
278
|
+
'typography_family': 'high',
|
|
279
|
+
'typography_scale': 'medium',
|
|
280
|
+
'layouts': 'high' if form == 3 else ('medium' if form == 2 else 'low'),
|
|
281
|
+
'assets': 'low',
|
|
282
|
+
'safe_area': 'medium' if guides else 'low',
|
|
283
|
+
'spacing': 'medium' if spacing['paddings'] else 'low',
|
|
284
|
+
'components': 'low',
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ------------------------------------------------------------- S12 ref writer
|
|
289
|
+
def fmt_color(c):
|
|
290
|
+
if not c:
|
|
291
|
+
return '-'
|
|
292
|
+
if c.get('unresolved'):
|
|
293
|
+
return '?%s' % c['unresolved']
|
|
294
|
+
return c.get('resolved') or c.get('hex') or '-'
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def fmt_fill(f):
|
|
298
|
+
if not f:
|
|
299
|
+
return None
|
|
300
|
+
t = f.get('type')
|
|
301
|
+
if t == 'solid':
|
|
302
|
+
return fmt_color(f.get('color'))
|
|
303
|
+
if t == 'gradient':
|
|
304
|
+
return 'grad[%s]@%s' % (' | '.join('%s%%=%s' % (s['pos'], fmt_color(s['color']))
|
|
305
|
+
for s in f['stops']), f.get('angle_deg'))
|
|
306
|
+
if t == 'image':
|
|
307
|
+
return 'image(%s%s)' % (f.get('media'), ' crop' if f.get('crop') else '')
|
|
308
|
+
if t == 'none':
|
|
309
|
+
return 'noFill'
|
|
310
|
+
return t
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def render_shapes_txt(units, parts_ordered, shapes, bg_by_part):
|
|
314
|
+
L = ['CANVAS %dx%d px (sldSz %d x %d EMU, 1 px = %.1f EMU)'
|
|
315
|
+
% (units.w, units.h, units.cx, units.cy, units.emu_per_px), '']
|
|
316
|
+
by_part = defaultdict(list)
|
|
317
|
+
for r in shapes:
|
|
318
|
+
by_part[r['part']].append(r)
|
|
319
|
+
for part in parts_ordered:
|
|
320
|
+
recs = by_part.get(part, [])
|
|
321
|
+
L.append('=' * 100)
|
|
322
|
+
L.append('%s bg=%s shapes=%d' % (part, fmt_fill(bg_by_part.get(part)), len(recs)))
|
|
323
|
+
L.append('=' * 100)
|
|
324
|
+
for r in recs:
|
|
325
|
+
ind = ' ' * (r.get('depth', 0) + 1)
|
|
326
|
+
b = r.get('box') or {}
|
|
327
|
+
head = '%s[%s]' % (ind, r['kind'])
|
|
328
|
+
if r.get('name'):
|
|
329
|
+
head += ' %r' % r['name']
|
|
330
|
+
if r.get('ph'):
|
|
331
|
+
head += ' ph=%s/%s' % (r['ph']['type'], r['ph'].get('idx'))
|
|
332
|
+
if b:
|
|
333
|
+
head += ' box=(%s,%s %sx%s)' % (b.get('x'), b.get('y'), b.get('w'), b.get('h'))
|
|
334
|
+
else:
|
|
335
|
+
head += ' box=inherited'
|
|
336
|
+
for k, label in (('rot', 'rot'), ('flipH', 'flipH'), ('flipV', 'flipV'),
|
|
337
|
+
('placement', 'place'), ('radius_px', 'r')):
|
|
338
|
+
if r.get(k) not in (None, False):
|
|
339
|
+
head += ' %s=%s' % (label, r[k])
|
|
340
|
+
if r.get('geom'):
|
|
341
|
+
head += ' geom=%s' % r['geom']['prst']
|
|
342
|
+
if r['geom'].get('adj'):
|
|
343
|
+
head += '(%s)' % ','.join('%s=%s' % kv for kv in r['geom']['adj'].items())
|
|
344
|
+
if r.get('fill'):
|
|
345
|
+
head += ' fill=%s' % fmt_fill(r['fill'])
|
|
346
|
+
if r.get('line'):
|
|
347
|
+
ln = r['line']
|
|
348
|
+
head += ' line=%s/%spx' % (fmt_color(ln.get('color')), ln.get('w_px'))
|
|
349
|
+
if r.get('effects'):
|
|
350
|
+
head += ' fx=%s' % ','.join(e['type'] for e in r['effects'])
|
|
351
|
+
if r.get('media'):
|
|
352
|
+
head += ' media=%s' % r['media']
|
|
353
|
+
if r.get('crop'):
|
|
354
|
+
head += ' crop=%s' % r['crop']
|
|
355
|
+
L.append(head)
|
|
356
|
+
text = r.get('text') or {}
|
|
357
|
+
if text.get('bodyPr'):
|
|
358
|
+
L.append('%s bodyPr=%s' % (ind, text['bodyPr']))
|
|
359
|
+
for lvl, d in (text.get('lstStyle') or {}).items():
|
|
360
|
+
L.append('%s TYPE %s: %s' % (ind, lvl, d))
|
|
361
|
+
for p in text.get('paragraphs', []):
|
|
362
|
+
meta = {k: v for k, v in p.items() if k != 'runs'}
|
|
363
|
+
txt = ''.join(run.get('text') or '' for run in p.get('runs', []))
|
|
364
|
+
styles = []
|
|
365
|
+
for run in p.get('runs', []):
|
|
366
|
+
st = {k: (fmt_color(v) if k == 'color' else v)
|
|
367
|
+
for k, v in run.items() if k != 'text'}
|
|
368
|
+
if st and st not in styles:
|
|
369
|
+
styles.append(st)
|
|
370
|
+
if not txt and not styles:
|
|
371
|
+
continue
|
|
372
|
+
L.append('%s p%s: %r' % (ind, (' ' + str(meta)) if meta else '', txt))
|
|
373
|
+
for st in styles:
|
|
374
|
+
L.append('%s run %s' % (ind, st))
|
|
375
|
+
L.append('')
|
|
376
|
+
return '\n'.join(L)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
REF_NOTES = """# ref/ 审计层说明(S12)
|
|
380
|
+
|
|
381
|
+
本目录是确定性脚本层的审计产物,随包可剥离。所有数值的唯一来源是 extract.json;
|
|
382
|
+
本目录补充「原始 token / 取舍理由 / 逐条溯源」,供人工与 check 复核。
|
|
383
|
+
|
|
384
|
+
| 文件 | 内容 |
|
|
385
|
+
|---|---|
|
|
386
|
+
| shapes.txt | S4 shape-facts 全量 dump(人类可读) |
|
|
387
|
+
| shapes.json | S4 shape-facts 机器版(extract.json 的 `shapes_ref` 指向这里;按 `part` 过滤取用,不整读) |
|
|
388
|
+
| color-freq-raw.json | S6 频次原表:逐条 (part, layer, class, raw token, resolved) |
|
|
389
|
+
| font-clusters.json | S6 字体聚类表:family -> variants(raw/weight/是否 31 字符截断) |
|
|
390
|
+
| masters-triage.json | S2 母版三分裁决:picked/dropped/理由/主母版(仅冲突裁决用) |
|
|
391
|
+
| layout-trace.json | S7 版式清单 + 跨母版孪生对 + 占位符几何签名 |
|
|
392
|
+
| s5-acceptance.json | S5 验收对照:每图 EMU 原值 + px 归一值 + 精确/epsilon 两种计数 |
|
|
393
|
+
| guides.json | S8 参考线(含按 part 分布,用于证明 master 级不存在) |
|
|
394
|
+
| perf.json | 各模块耗时(不进 extract.json 正文) |
|
|
395
|
+
|
|
396
|
+
## 已知近似与口径(实现与方案 v0.2 的偏差都记在这里)
|
|
397
|
+
|
|
398
|
+
1. **颜色变换**:lumMod/lumOff/satMod/satOff 在 HSL 空间计算;shade/tint 在线性 RGB
|
|
399
|
+
空间计算(`C' = C*f` / `C' = C*f + (1-f)`)。ECMA-376 未给逐位算法,此为通行近似。
|
|
400
|
+
2. **bgRef 未展开**:`p:bgRef idx=1001` 指向 theme 的 bgFillStyleLst,脚本不展开该
|
|
401
|
+
图案,只解析其内联 schemeClr(即 phClr)作为背景有效色,并在字段里标注 note。
|
|
402
|
+
3. **不解样式继承**:占位符/lstStyle/txStyles 的继承链不解(PRD 约束)。只直读各层
|
|
403
|
+
自己声明的值;schemeClr→clrMap→clrScheme 的*引用解析*照做(不做则 P0 无输出)。
|
|
404
|
+
4. **S6 XPath 口径**(写死并逐项声明):design = `a:solidFill//` 下的颜色 + 渐变
|
|
405
|
+
`a:gs` 的直接子颜色 + `p:bgRef` 的直接子颜色;editor = `p15:clr`(参考线);
|
|
406
|
+
aux = `a:buClr` 与 `p:style/a:*Ref`。effectLst 内的阴影色不计入频次。
|
|
407
|
+
5. **S5 计数双口径**:`exact_boxes` 是坐标完全一致的计数(可与票 03 recon 逐数对齐);
|
|
408
|
+
`boxes[]` 是 ±0.5%({eps:.1f}px)epsilon 聚类计数,会把微偏移的同位实例并进同一簇。
|
|
409
|
+
两者都落盘,差异即「容差带来的合并」。
|
|
410
|
+
6. **满屏判定独立阈值**:w ≥ {fs}% 且 h ≥ {fs}%,上不封顶;出血图(>100%)同样计入满屏。
|
|
411
|
+
7. **画布外三分类**:完全出界 → 从 `ref/shapes.json` 剔除(计数留 counts);
|
|
412
|
+
出血 ≤5% → 保留并标 `bleed`;>5% → clamp 到边界并记 `box_before_clamp`。
|
|
413
|
+
**图片普查(S5)不做剔除**,出界实例带 `placement` 标记仍计入,以免漏掉证据。
|
|
414
|
+
8. **字体跨文字系统别名不自动合并**:中文名与拉丁名(方正兰亭黑Pro ↔ FZLanTingHeiPro)
|
|
415
|
+
保持为两个 family,只给 `alias_group` 提示,合并交阶段二 L4 判断。
|
|
416
|
+
9. **段落级 `a:pPr/a:defRPr` 的采集与去重**:它是「该段 run 的默认值」,Mac Office /
|
|
417
|
+
Keynote 导出的 deck 把字号字体写在这一层。计数时段内 run 已显式声明的就不重复计
|
|
418
|
+
(字号整段判定,字体按 latin/ea/cs 分槽判定),`sources` 里单列 `pPr` 类别。
|
|
419
|
+
10. **空段落声明:`text_scale` 与 `font_families` 口径不同,是有意为之。**
|
|
420
|
+
只有 `<a:endParaRPr/>`、零 `a:r` 的段落渲染不出任何字形。
|
|
421
|
+
- `text_scale` **直接跳过**这类段落:字号轴服务排版消费,渲染不出的字号进轴只会污染
|
|
422
|
+
L9 的档位归纳。
|
|
423
|
+
- `font_families` **收进来但单列** `sources.pPr_empty`,且不计入 `rendered_n`;
|
|
424
|
+
只有空段声明的 family 打 `renders_no_text`,排序按 `rendered_n` 降权。
|
|
425
|
+
目的是既保留审计可见性(脚手架字体确实被声明过),又不让一个不承载任何可见文字的
|
|
426
|
+
字体在频次上压过真正在排版的字体。
|
|
427
|
+
**消费侧规则:判"这个字体/字号有没有在用"一律看 `rendered_n`,不要看 `n`。**
|
|
428
|
+
11. **主题字体占位符按实名计数**:`+mj-lt` / `+mn-ea` 等沿母版链绑定 theme 的 fontScheme
|
|
429
|
+
解析成实名后计数(与 schemeClr 同哲学),来源占位符记在 `theme_refs`。
|
|
430
|
+
主题槽位显式为空串(`<a:cs typeface=""/>`)视为「不指定」而丢弃,**不是**解析失败;
|
|
431
|
+
只有压根没有可用 fontScheme 才保留占位符并标 `unresolved_theme_ref`。
|
|
432
|
+
"""
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def write_ref(outdir, payload, ref_data, units, parts_ordered, shapes, bg_by_part):
|
|
436
|
+
ref = os.path.join(outdir, 'ref')
|
|
437
|
+
os.makedirs(ref, exist_ok=True)
|
|
438
|
+
|
|
439
|
+
def dump(name, obj):
|
|
440
|
+
with open(os.path.join(ref, name), 'w') as f:
|
|
441
|
+
json.dump(obj, f, ensure_ascii=False, indent=1)
|
|
442
|
+
|
|
443
|
+
with open(os.path.join(ref, 'shapes.txt'), 'w') as f:
|
|
444
|
+
f.write(render_shapes_txt(units, parts_ordered, shapes, bg_by_part))
|
|
445
|
+
# Machine-readable twin of shapes.txt, and the sidecar extract.json points at.
|
|
446
|
+
dump('shapes.json', {'schema': payload['schema'], 'canvas': payload['canvas'],
|
|
447
|
+
'counts': {k: v for k, v in payload['counts'].items()
|
|
448
|
+
if k.startswith('shapes')},
|
|
449
|
+
'shapes': ref_data['kept_shapes']})
|
|
450
|
+
dump('color-freq-raw.json', {'rows': ref_data['color_rows'],
|
|
451
|
+
'aggregated': payload['color_freq']})
|
|
452
|
+
dump('font-clusters.json', payload['font_families'])
|
|
453
|
+
dump('masters-triage.json', payload['masters'])
|
|
454
|
+
dump('layout-trace.json', {'layouts': ref_data['layout_rows'],
|
|
455
|
+
'twin_pairs': payload['layout_twins']['pairs'],
|
|
456
|
+
'unpaired': payload['layout_twins']['unpaired']})
|
|
457
|
+
dump('s5-acceptance.json', ref_data['s5'])
|
|
458
|
+
dump('content-clusters.json', ref_data['content_clusters'])
|
|
459
|
+
dump('guides.json', ref_data['guides_detail'])
|
|
460
|
+
dump('perf.json', ref_data['perf'])
|
|
461
|
+
with open(os.path.join(ref, 'notes.md'), 'w') as f:
|
|
462
|
+
f.write(REF_NOTES.format(eps=EPS_PX, fs=FULLSCREEN_MIN_PCT))
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# --------------------------------------------------------------------- driver
|
|
466
|
+
def extract(pptx, outdir, export_all=False):
|
|
467
|
+
t0 = time.time()
|
|
468
|
+
perf = {}
|
|
469
|
+
|
|
470
|
+
def mark(name, since):
|
|
471
|
+
perf[name] = round(time.time() - since, 3)
|
|
472
|
+
return time.time()
|
|
473
|
+
|
|
474
|
+
os.makedirs(outdir, exist_ok=True)
|
|
475
|
+
pillow_ok, pillow_note = probe_pillow()
|
|
476
|
+
|
|
477
|
+
t = time.time()
|
|
478
|
+
pkg = Package(pptx) # S1
|
|
479
|
+
pres = pkg.xml('ppt/presentation.xml')
|
|
480
|
+
sz = pres.find('p:sldSz', NS)
|
|
481
|
+
cx, cy = int(sz.get('cx')), int(sz.get('cy'))
|
|
482
|
+
units = Units(cx, cy)
|
|
483
|
+
t = mark('S1_unpack', t)
|
|
484
|
+
|
|
485
|
+
graph = build_graph(pkg) # S2
|
|
486
|
+
t = mark('S2_refs', t)
|
|
487
|
+
|
|
488
|
+
themes_by_part = {tp: read_theme(pkg, tp) for tp in pkg.themes} # S3
|
|
489
|
+
clrmap_by_master = {mp: read_clrmap(pkg, mp) for mp in graph['master_order']}
|
|
490
|
+
theme_of, scheme_fallback = bind_themes(graph, themes_by_part)
|
|
491
|
+
t = mark('S3_theme_clrmap', t)
|
|
492
|
+
|
|
493
|
+
# S4: every shape of every master / layout / slide, in one px@1920 domain.
|
|
494
|
+
shapes, bg_by_part, part_ctxs, txstyles = [], {}, [], {}
|
|
495
|
+
parts_ordered = []
|
|
496
|
+
for mp in graph['master_order']:
|
|
497
|
+
theme = theme_of.get(mp, {})
|
|
498
|
+
ctx, recs, bg = read_part_shapes(pkg, mp, 'master', units,
|
|
499
|
+
clrmap_by_master.get(mp, {}),
|
|
500
|
+
theme.get('clrScheme', {}), mp,
|
|
501
|
+
graph['theme_of_master'].get(mp))
|
|
502
|
+
shapes += recs
|
|
503
|
+
bg_by_part[mp] = bg
|
|
504
|
+
part_ctxs.append(ctx)
|
|
505
|
+
parts_ordered.append(mp)
|
|
506
|
+
txstyles[mp] = read_txstyles(pkg, mp, ctx)
|
|
507
|
+
for lp in pkg.layouts:
|
|
508
|
+
mp = graph['master_of_layout'].get(lp)
|
|
509
|
+
theme = theme_of.get(mp, {})
|
|
510
|
+
ctx, recs, bg = read_part_shapes(pkg, lp, 'layout', units,
|
|
511
|
+
clrmap_by_master.get(mp, {}),
|
|
512
|
+
theme.get('clrScheme', {}), mp,
|
|
513
|
+
graph['theme_of_master'].get(mp))
|
|
514
|
+
shapes += recs
|
|
515
|
+
bg_by_part[lp] = bg
|
|
516
|
+
part_ctxs.append(ctx)
|
|
517
|
+
parts_ordered.append(lp)
|
|
518
|
+
for sp in pkg.slides:
|
|
519
|
+
lp = graph['layout_of_slide'].get(sp)
|
|
520
|
+
mp = graph['master_of_layout'].get(lp)
|
|
521
|
+
theme = theme_of.get(mp, {})
|
|
522
|
+
ctx, recs, bg = read_part_shapes(pkg, sp, 'slide', units,
|
|
523
|
+
clrmap_by_master.get(mp, {}),
|
|
524
|
+
theme.get('clrScheme', {}), mp,
|
|
525
|
+
graph['theme_of_master'].get(mp))
|
|
526
|
+
shapes += recs
|
|
527
|
+
bg_by_part[sp] = bg
|
|
528
|
+
part_ctxs.append(ctx)
|
|
529
|
+
parts_ordered.append(sp)
|
|
530
|
+
t = mark('S4_shape_facts', t)
|
|
531
|
+
|
|
532
|
+
layout_rows = layout_inventory(pkg, graph, shapes, bg_by_part) # S7
|
|
533
|
+
twin_pairs, unpaired = detect_twins(layout_rows)
|
|
534
|
+
t = mark('S7_layouts_twins', t)
|
|
535
|
+
|
|
536
|
+
triage = triage_masters(pkg, graph, # S2 三分规则
|
|
537
|
+
[(p['a'], p['b']) for p in twin_pairs])
|
|
538
|
+
t = mark('S2_master_triage', t)
|
|
539
|
+
|
|
540
|
+
topology = theme_topology(graph, triage, themes_by_part, clrmap_by_master, # S13
|
|
541
|
+
bg_by_part, twin_pairs)
|
|
542
|
+
t = mark('S13_topology', t)
|
|
543
|
+
|
|
544
|
+
bg_images = []
|
|
545
|
+
for part, bg in bg_by_part.items():
|
|
546
|
+
if bg and bg.get('type') == 'image' and bg.get('media'):
|
|
547
|
+
layer = ('master' if part in graph['master_order']
|
|
548
|
+
else 'layout' if part in pkg.layouts else 'slide')
|
|
549
|
+
bg_images.append({'part': part, 'layer': layer, 'via': 'bg',
|
|
550
|
+
'box': {'x': 0, 'y': 0, 'w': units.w, 'h': units.h},
|
|
551
|
+
'box_emu': {'x': 0, 'y': 0, 'cx': cx, 'cy': cy},
|
|
552
|
+
'crop': bg.get('crop'), 'placement': 'inside',
|
|
553
|
+
'w_pct': 100.0, 'h_pct': 100.0, 'in_group': False})
|
|
554
|
+
images, variant_groups = image_census(shapes, bg_images, units) # S5
|
|
555
|
+
t = mark('S5_image_census', t)
|
|
556
|
+
|
|
557
|
+
color_freq, color_rows = color_census(pkg, part_ctxs) # S6
|
|
558
|
+
fonts = font_census(shapes, txstyles, list(themes_by_part.values()),
|
|
559
|
+
font_scheme_by_part(pkg, graph, theme_of))
|
|
560
|
+
t = mark('S6_color_font', t)
|
|
561
|
+
|
|
562
|
+
guides = read_guides(pkg, units, ['ppt/presentation.xml'] + pkg.layouts
|
|
563
|
+
+ graph['master_order'] + pkg.slides) # S8
|
|
564
|
+
guide_parts = Counter(g['part'].rsplit('/', 2)[-2] for g in guides)
|
|
565
|
+
t = mark('S8_guides', t)
|
|
566
|
+
|
|
567
|
+
scale = text_scale(shapes, txstyles)
|
|
568
|
+
spacing = spacing_candidates(shapes, units)
|
|
569
|
+
radii, geom_census, effects = radii_effects_census(shapes)
|
|
570
|
+
t = mark('derived_censuses', t)
|
|
571
|
+
|
|
572
|
+
media_rows = export_media(pkg, images, outdir, pillow_ok, export_all) # S9
|
|
573
|
+
t = mark('S9_media_export', t)
|
|
574
|
+
|
|
575
|
+
# S5b + S14 run after S9 because the palette only covers exported assets.
|
|
576
|
+
fps = media_fingerprints(pkg, [i['media'] for i in images], pillow_ok)
|
|
577
|
+
clusters, cluster_evidence = content_clusters(images, fps)
|
|
578
|
+
exported = {m['media'] for m in media_rows if m.get('exported')}
|
|
579
|
+
image_palette(images, fps, exported)
|
|
580
|
+
t = mark('S5b_S14_content_palette', t)
|
|
581
|
+
|
|
582
|
+
themes_picked = [themes_by_part[tp] for tp in graph['used_themes'] if tp in themes_by_part]
|
|
583
|
+
form = form_hint(pkg, graph, layout_rows)
|
|
584
|
+
dropped_shapes = [r for r in shapes if r.get('placement') == 'outside']
|
|
585
|
+
kept_shapes = [r for r in shapes if r.get('placement') != 'outside']
|
|
586
|
+
|
|
587
|
+
payload = {
|
|
588
|
+
'schema': SCHEMA,
|
|
589
|
+
'source': {
|
|
590
|
+
'filename': os.path.basename(pptx),
|
|
591
|
+
'bytes': os.path.getsize(pptx),
|
|
592
|
+
'content_type_kind': pkg.kind,
|
|
593
|
+
'is_template': pkg.kind == 'template',
|
|
594
|
+
'extracted_at': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
|
595
|
+
},
|
|
596
|
+
'pillow_available': pillow_ok,
|
|
597
|
+
'pillow_note': ('transcode enabled' if pillow_ok else
|
|
598
|
+
'export-only degradation: %s' % pillow_note),
|
|
599
|
+
'canvas': {
|
|
600
|
+
'px': [units.w, units.h],
|
|
601
|
+
'source': {'cx': cx, 'cy': cy, 'unit': 'EMU'},
|
|
602
|
+
'emu_per_px': round(units.emu_per_px, 4),
|
|
603
|
+
'ratio': round(cx / float(cy), 3),
|
|
604
|
+
'is_16_9': abs(cx / float(cy) - 16 / 9.0) < 0.01,
|
|
605
|
+
},
|
|
606
|
+
'form_hint': form,
|
|
607
|
+
'theme_topology': topology,
|
|
608
|
+
'themes': [dict(t, picked=(t['part'] in graph['used_themes']))
|
|
609
|
+
for t in themes_by_part.values()],
|
|
610
|
+
'theme_discovery': pkg.theme_discovery,
|
|
611
|
+
'scheme_fallback': scheme_fallback,
|
|
612
|
+
'masters': triage,
|
|
613
|
+
'reference_graph': {
|
|
614
|
+
'layout_of_slide': graph['layout_of_slide'],
|
|
615
|
+
'master_of_layout': graph['master_of_layout'],
|
|
616
|
+
'theme_of_master': graph['theme_of_master'],
|
|
617
|
+
'slides_per_layout': graph['slides_per_layout'],
|
|
618
|
+
'slides_per_master': graph['slides_per_master'],
|
|
619
|
+
'orphan_themes': graph['orphan_themes'],
|
|
620
|
+
},
|
|
621
|
+
'layouts': [{k: v for k, v in r.items() if k != 'ph_signature'} for r in layout_rows],
|
|
622
|
+
# Instance pages carry their own p:bg; without this the only backgrounds
|
|
623
|
+
# on record are the layouts', and per-page overrides vanish.
|
|
624
|
+
'slides': [{'part': sp, 'layout': graph['layout_of_slide'].get(sp),
|
|
625
|
+
'background': bg_by_part.get(sp)} for sp in pkg.slides],
|
|
626
|
+
'layout_twins': {'pairs': twin_pairs, 'unpaired': unpaired,
|
|
627
|
+
'pair_n': len(twin_pairs)},
|
|
628
|
+
'guides': guides,
|
|
629
|
+
'guides_by_layer': dict(guide_parts),
|
|
630
|
+
'color_freq': color_freq,
|
|
631
|
+
'font_families': fonts,
|
|
632
|
+
'text_scale': scale,
|
|
633
|
+
'spacing_candidates': spacing,
|
|
634
|
+
'radii_census': radii,
|
|
635
|
+
'geom_census': geom_census,
|
|
636
|
+
'effects_census': effects,
|
|
637
|
+
'images': images,
|
|
638
|
+
'variant_groups': variant_groups,
|
|
639
|
+
'media_clusters': clusters,
|
|
640
|
+
'content_cluster_mode': ('sha256+dhash+pixel-confirm' if pillow_ok
|
|
641
|
+
else 'sha256-only'),
|
|
642
|
+
'content_cluster_note': (
|
|
643
|
+
'dHash(9x8) hamming <= %d prefilters candidates; a pair only merges when '
|
|
644
|
+
'the 64x64 composited thumbnails also differ by <= %.1f mean channel value'
|
|
645
|
+
% (DHASH_PREFILTER_MAX, PIXDIFF_MAX) if pillow_ok else
|
|
646
|
+
'Pillow unavailable: byte-identical media only, no perceptual merging'),
|
|
647
|
+
'palette_available': pillow_ok,
|
|
648
|
+
'media': media_rows,
|
|
649
|
+
# S4 shape-facts are 49-64% of this file (volcano: 1.4 MB) and stage 2 reads
|
|
650
|
+
# them only when a derived statistic needs backing evidence, so they live in
|
|
651
|
+
# a sidecar and extract.json keeps just the pointer plus the derived censuses.
|
|
652
|
+
'shapes_ref': 'ref/shapes.json',
|
|
653
|
+
'confidence_seed': confidence_seed(themes_picked, guides, layout_rows,
|
|
654
|
+
form['form'], spacing),
|
|
655
|
+
'counts': {
|
|
656
|
+
'slides': len(pkg.slides), 'layouts': len(pkg.layouts),
|
|
657
|
+
'masters': len(pkg.masters), 'themes': len(pkg.themes),
|
|
658
|
+
'media': len(pkg.media),
|
|
659
|
+
'shapes_total': len(shapes),
|
|
660
|
+
'shapes_kept': len(kept_shapes),
|
|
661
|
+
'shapes_dropped_off_canvas': len(dropped_shapes),
|
|
662
|
+
'shapes_bleed': sum(1 for r in shapes if r.get('bleed')),
|
|
663
|
+
'shapes_clamped': sum(1 for r in shapes if r.get('clamped')),
|
|
664
|
+
'shapes_inherited_box': sum(1 for r in shapes
|
|
665
|
+
if r.get('placement') == 'inherited'),
|
|
666
|
+
'content_clusters': len(clusters),
|
|
667
|
+
'content_clusters_multi_media': sum(1 for c in clusters if c['member_n'] > 1),
|
|
668
|
+
'media_exported': sum(1 for m in media_rows if m.get('exported')),
|
|
669
|
+
'media_transcoded': sum(1 for m in media_rows if m.get('transcoded')),
|
|
670
|
+
'media_transcode_blocked': sum(1 for m in media_rows
|
|
671
|
+
if m.get('transcode_blocked')),
|
|
672
|
+
'media_over_budget': sum(1 for m in media_rows if m.get('over_budget')),
|
|
673
|
+
'guides': len(guides),
|
|
674
|
+
},
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
ref_data = {
|
|
678
|
+
'kept_shapes': kept_shapes,
|
|
679
|
+
'color_rows': color_rows,
|
|
680
|
+
'layout_rows': [dict(r, ph_signature=[list(s) for s in r['ph_signature']])
|
|
681
|
+
for r in layout_rows],
|
|
682
|
+
'guides_detail': {'by_part': dict(Counter(g['part'] for g in guides)),
|
|
683
|
+
'master_level_guides': sum(
|
|
684
|
+
1 for g in guides if 'slideMaster' in g['part']),
|
|
685
|
+
'guides': guides},
|
|
686
|
+
's5': {
|
|
687
|
+
'canvas': {'px': [units.w, units.h], 'emu': [cx, cy],
|
|
688
|
+
'emu_per_px': units.emu_per_px},
|
|
689
|
+
'thresholds': {'epsilon_px': EPS_PX, 'fullscreen_min_pct': FULLSCREEN_MIN_PCT,
|
|
690
|
+
'repeat_min': REPEAT_MIN},
|
|
691
|
+
'images': [{
|
|
692
|
+
'media': i['media'], 'n': i['n'],
|
|
693
|
+
'exact_boxes': i['exact_boxes'],
|
|
694
|
+
'epsilon_clusters': [{'box': c['box'], 'box_emu': c['box_emu'],
|
|
695
|
+
'count': c['count'], 'exact_count': c['exact_count'],
|
|
696
|
+
'exact_variants': c['exact_variants'],
|
|
697
|
+
'w_pct': c['w_pct'], 'h_pct': c['h_pct']}
|
|
698
|
+
for c in i['boxes']],
|
|
699
|
+
'fullscreen_n': i['fullscreen_n'],
|
|
700
|
+
'fullscreen_top_cluster_n': i['fullscreen_top_cluster_n'],
|
|
701
|
+
'max_w_pct': i['max_w_pct'], 'bleed': i['bleed'],
|
|
702
|
+
'crop_variants': i['crop_variants'],
|
|
703
|
+
'variant_group': i['variant_group'],
|
|
704
|
+
} for i in images],
|
|
705
|
+
'variant_groups': variant_groups,
|
|
706
|
+
},
|
|
707
|
+
'content_clusters': {
|
|
708
|
+
'mode': payload['content_cluster_mode'],
|
|
709
|
+
'thresholds': {'dhash_prefilter_max': DHASH_PREFILTER_MAX,
|
|
710
|
+
'pixel_diff_max': PIXDIFF_MAX},
|
|
711
|
+
'clusters': clusters,
|
|
712
|
+
# Every pair the prefilter admitted, merged or not — the rejections are
|
|
713
|
+
# the evidence that near-miss assets stayed apart.
|
|
714
|
+
'pair_evidence': sorted(cluster_evidence,
|
|
715
|
+
key=lambda e: (e['level'], e['dhash_distance'])),
|
|
716
|
+
'fingerprints': {m: {k: v for k, v in f.items() if not k.startswith('_')}
|
|
717
|
+
for m, f in fps.items()},
|
|
718
|
+
},
|
|
719
|
+
'perf': perf,
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
out_json = os.path.join(outdir, 'extract.json')
|
|
723
|
+
with open(out_json, 'w') as f:
|
|
724
|
+
json.dump(payload, f, ensure_ascii=False, indent=1)
|
|
725
|
+
write_ref(outdir, payload, ref_data, units, parts_ordered, shapes, bg_by_part)
|
|
726
|
+
perf['total'] = round(time.time() - t0, 3)
|
|
727
|
+
with open(os.path.join(outdir, 'ref', 'perf.json'), 'w') as f:
|
|
728
|
+
json.dump(perf, f, ensure_ascii=False, indent=1)
|
|
729
|
+
|
|
730
|
+
print('%s -> %s' % (os.path.basename(pptx), outdir))
|
|
731
|
+
print(' canvas %dx%d px (sldSz %d x %d EMU) form=%d themes=%s (%s)'
|
|
732
|
+
% (units.w, units.h, cx, cy, form['form'],
|
|
733
|
+
topology['themes'], topology['mechanism']))
|
|
734
|
+
print(' shapes %d kept / %d dropped off-canvas / %d bleed / %d clamped'
|
|
735
|
+
% (payload['counts']['shapes_kept'], payload['counts']['shapes_dropped_off_canvas'],
|
|
736
|
+
payload['counts']['shapes_bleed'], payload['counts']['shapes_clamped']))
|
|
737
|
+
print(' colors %d fonts %d images %d media exported %d/%d guides %d'
|
|
738
|
+
% (len(color_freq), len(fonts), len(images),
|
|
739
|
+
payload['counts']['media_exported'], len(pkg.media), len(guides)))
|
|
740
|
+
print(' extract.json %.1f KB total %.2fs'
|
|
741
|
+
% (os.path.getsize(out_json) / 1024.0, perf['total']))
|
|
742
|
+
return payload
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def main(argv):
|
|
746
|
+
args = [a for a in argv[1:] if not a.startswith('--')]
|
|
747
|
+
flags = {a for a in argv[1:] if a.startswith('--')}
|
|
748
|
+
unknown = flags - {'--export-all-media', '--no-draft'}
|
|
749
|
+
if len(args) != 2 or unknown:
|
|
750
|
+
if unknown:
|
|
751
|
+
print('unknown option(s): %s' % ' '.join(sorted(unknown)))
|
|
752
|
+
print(__doc__)
|
|
753
|
+
return 2
|
|
754
|
+
extract(args[0], args[1], export_all='--export-all-media' in flags)
|
|
755
|
+
if '--no-draft' not in flags:
|
|
756
|
+
import subprocess
|
|
757
|
+
d = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'draft.py')
|
|
758
|
+
r = subprocess.run([sys.executable, d, args[1]])
|
|
759
|
+
if r.returncode:
|
|
760
|
+
print(' ⚠ 草案生成失败,可单独重跑 draft.py 看报错')
|
|
761
|
+
return 0
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
if __name__ == '__main__':
|
|
765
|
+
sys.exit(main(sys.argv))
|