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