@lark-apaas/coding-steering 0.1.32-beta.0 → 0.1.32-dev.3c06d32
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 +6 -6
- package/steering/design-html/skills/charts/SKILL.md +4 -0
- package/steering/design-html/skills/pptx-style-extract/SKILL.md +71 -26
- package/steering/design-html/skills/pptx-style-extract/font-fallback.yaml +3 -3
- package/steering/design-html/skills/pptx-style-extract/scripts/census.py +26 -14
- package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +153 -8
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +2810 -292
- package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +544 -22
- package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +19 -2
- package/steering/design-html/skills/pptx-style-extract/scripts/package.py +991 -165
- package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +22 -3
- package/steering/design-html/skills/pptx-style-extract/scripts/query.py +4 -9
- package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +20 -12
- package/steering/design-html/skills/pptx-style-extract/scripts/test_asset_judgment_package.py +556 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +364 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_color_contract.py +60 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +76 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_flow_layout_contract.py +528 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +1694 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_logo_scope.py +479 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_rounded_contract.py +112 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +315 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/verify_layout_assets.py +400 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/verify_logo_scope.py +12 -0
- package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +27 -15
- package/steering/design-html/skills/preflight/scripts/probe.sh +0 -0
- package/steering/nestjs-react-fullstack/skills/app-init-feasibility-guide/SKILL.md +1 -0
- package/steering/nestjs-react-fullstack/skills/authn-guide/SKILL.md +6 -0
- package/steering/nestjs-react-fullstack/skills/authz-guide/SKILL.md +5 -5
- package/steering/nestjs-react-fullstack/skills/authz-guide/references/dynamic-permission-guide.md +1 -1
- package/steering/nestjs-react-fullstack/skills/client-builtins-file-storage-service/SKILL.md +37 -113
- package/steering/nestjs-react-fullstack/skills/client-builtins-user-service/SKILL.md +13 -2
- package/steering/nestjs-react-fullstack/skills/code-fix/SKILL.md +7 -7
- package/steering/nestjs-react-fullstack/skills/coding-guide/SKILL.md +149 -24
- package/steering/nestjs-react-fullstack/skills/connections-sdk/SKILL.md +202 -0
- package/steering/nestjs-react-fullstack/skills/nestjs-cache/SKILL.md +255 -0
- package/steering/nestjs-react-fullstack/skills/plugin-guide/SKILL.md +158 -543
- package/steering/nestjs-react-fullstack/skills/plugin-guide/references/plugin-coding-guide.md +15 -1
- package/steering/nestjs-react-fullstack/skills/plugin-guide/references/table.md +30 -14
- package/steering/nestjs-react-fullstack/skills/raw-sql-boundary-audit/SKILL.md +63 -0
- package/steering/nestjs-react-fullstack/skills/server-builtins-file-storage-service/SKILL.md +1 -1
- package/steering/nestjs-react-fullstack/skills_common/trigger-guide/SKILL.md +284 -12
- package/steering/nestjs-react-fullstack/skills_local/plugin-guide/SKILL.md +4 -0
- package/steering/vite-react/skills/plugin-guide/SKILL.md +3 -1
- package/steering/vite-react/skills/react-three-fiber/SKILL.md +4 -0
- package/steering/nestjs-react-fullstack/skills/client-add-aily-web-chat/SKILL.md +0 -139
- package/steering/nestjs-react-fullstack/skills/feishu/SKILL.md +0 -269
- package/steering/nestjs-react-fullstack/skills/feishu/references/approval.md +0 -214
- package/steering/nestjs-react-fullstack/skills/feishu/references/attendance.md +0 -163
- package/steering/nestjs-react-fullstack/skills/feishu/references/bitable.md +0 -311
- package/steering/nestjs-react-fullstack/skills/feishu/references/calendar.md +0 -190
- package/steering/nestjs-react-fullstack/skills/feishu/references/contacts.md +0 -160
- package/steering/nestjs-react-fullstack/skills/feishu/references/doc.md +0 -257
- package/steering/nestjs-react-fullstack/skills/feishu/references/drive.md +0 -104
- package/steering/nestjs-react-fullstack/skills/feishu/references/events.md +0 -199
- package/steering/nestjs-react-fullstack/skills/feishu/references/id-convert.md +0 -128
- package/steering/nestjs-react-fullstack/skills/feishu/references/messaging.md +0 -207
- package/steering/nestjs-react-fullstack/skills/feishu/references/oauth.md +0 -165
- package/steering/nestjs-react-fullstack/skills/feishu/references/perm.md +0 -91
- package/steering/nestjs-react-fullstack/skills/feishu/references/wiki.md +0 -165
- package/steering/nestjs-react-fullstack/skills_common/trigger-guide/references/trigger-lifecycle.md +0 -301
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Stage-1 deterministic extractor for PPTX/POTX style packs
|
|
2
|
+
"""Stage-1 deterministic extractor for PPTX/POTX style packs.
|
|
3
3
|
|
|
4
4
|
python3 extract.py <pptx> <outdir> [--export-all-media]
|
|
5
5
|
|
|
@@ -11,6 +11,8 @@ as-is *and* re-encoded to a webp under ASSET_BUDGET_BYTES; without Pillow the
|
|
|
11
11
|
transcode falls back to darwin `sips`, and failing that the row records
|
|
12
12
|
transcode_blocked and extract.json records pillow_available: false.
|
|
13
13
|
"""
|
|
14
|
+
import hashlib
|
|
15
|
+
import io
|
|
14
16
|
import json
|
|
15
17
|
import os
|
|
16
18
|
import re
|
|
@@ -21,7 +23,8 @@ from datetime import datetime, timezone
|
|
|
21
23
|
|
|
22
24
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
23
25
|
|
|
24
|
-
from census import (DHASH_PREFILTER_MAX, FULLSCREEN_MIN_PCT, PIXDIFF_MAX,
|
|
26
|
+
from census import (ASSET_WARN_SINGLE, DHASH_PREFILTER_MAX, FULLSCREEN_MIN_PCT, PIXDIFF_MAX,
|
|
27
|
+
REPEAT_MIN, SMALL_IMG_W_PCT,
|
|
25
28
|
EPS_PX, color_census, content_clusters, detect_twins, font_census,
|
|
26
29
|
image_census, image_palette, layout_inventory, media_fingerprints,
|
|
27
30
|
font_scheme_by_part, radii_effects_census, read_guides,
|
|
@@ -32,7 +35,7 @@ from parts import (Package, PartCtx, build_graph, read_clrmap, read_part_shapes,
|
|
|
32
35
|
|
|
33
36
|
SCHEMA = 'pptx-extract/stage1-v0.1'
|
|
34
37
|
WEB_FORMATS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'}
|
|
35
|
-
ASSET_BUDGET_BYTES =
|
|
38
|
+
ASSET_BUDGET_BYTES = ASSET_WARN_SINGLE # V2-6 单张 WARN 线
|
|
36
39
|
# Quality ladder first, then resolution — dropping pixels is the more visible
|
|
37
40
|
# loss, so it is only reached once the lowest quality still overshoots.
|
|
38
41
|
WEBP_QUALITY_LADDER = (85, 75, 65, 55, 45)
|
|
@@ -161,6 +164,7 @@ def _webp_from_sips(part, raw, dst_base, budget):
|
|
|
161
164
|
def export_media(pkg, images, outdir, pillow_ok, export_all=False):
|
|
162
165
|
media_dir = os.path.join(outdir, 'media-out')
|
|
163
166
|
os.makedirs(media_dir, exist_ok=True)
|
|
167
|
+
written = {}
|
|
164
168
|
by_media = {i['media']: i for i in images}
|
|
165
169
|
rows = []
|
|
166
170
|
for part in pkg.media:
|
|
@@ -177,6 +181,12 @@ def export_media(pkg, images, outdir, pillow_ok, export_all=False):
|
|
|
177
181
|
reasons.append('variant_group')
|
|
178
182
|
if info['stitch_candidate']:
|
|
179
183
|
reasons.append('crop_stitch')
|
|
184
|
+
if info.get('visible_instance_blocked'):
|
|
185
|
+
reasons.append('visible_instance_fallback')
|
|
186
|
+
if 0 < (info.get('max_w_pct') or 0) < SMALL_IMG_W_PCT and not info['fullscreen']:
|
|
187
|
+
# 小图(页内图标、角标)。不导出的话 L 层只能看着装饰容器里的空洞
|
|
188
|
+
# 自己编图形,编出来的与模板无关。
|
|
189
|
+
reasons.append('icon_candidate')
|
|
180
190
|
if ext in ('svg', 'emf', 'wmf'):
|
|
181
191
|
reasons.append('vector')
|
|
182
192
|
row = {'media': part, 'ext': ext, 'bytes': size,
|
|
@@ -191,7 +201,15 @@ def export_media(pkg, images, outdir, pillow_ok, export_all=False):
|
|
|
191
201
|
body = raw.decode('utf-8', 'replace')
|
|
192
202
|
row['embedded_raster'] = 'data:image/' in body
|
|
193
203
|
out_name = os.path.basename(part)
|
|
194
|
-
|
|
204
|
+
dst_path = os.path.join(media_dir, out_name)
|
|
205
|
+
if dst_path in written:
|
|
206
|
+
# 两个 media part 落到同一个输出名。不覆盖——覆盖等于悄悄换掉一张图。
|
|
207
|
+
row['export_name_conflict'] = written[dst_path]
|
|
208
|
+
out_name = '%s~%d%s' % (os.path.splitext(out_name)[0], len(written),
|
|
209
|
+
os.path.splitext(out_name)[1])
|
|
210
|
+
dst_path = os.path.join(media_dir, out_name)
|
|
211
|
+
written[dst_path] = part
|
|
212
|
+
with open(dst_path, 'wb') as f:
|
|
195
213
|
f.write(raw)
|
|
196
214
|
row.update({'exported': True, 'out': 'media-out/' + out_name,
|
|
197
215
|
'out_bytes': len(raw), 'transcoded': False,
|
|
@@ -210,13 +228,11 @@ def export_media(pkg, images, outdir, pillow_ok, export_all=False):
|
|
|
210
228
|
# rasterising it here would throw away the vector original.
|
|
211
229
|
if needs and ext != 'svg':
|
|
212
230
|
row['needs_transcode'] = needs
|
|
213
|
-
#
|
|
214
|
-
#
|
|
215
|
-
#
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
stem += '-min'
|
|
219
|
-
base = os.path.join(media_dir, stem)
|
|
231
|
+
# 压缩产物必须用**完整原名**当前缀:只去掉扩展名的话,image4.png 的压缩版
|
|
232
|
+
# 就叫 image4.webp —— 而 ppt/media/image4.webp 往往是另一张真实存在的图,
|
|
233
|
+
# 两者抢同一个输出名,后写的覆盖先写的,且全程无人报错。实测某模板因此把
|
|
234
|
+
# 一张 109x109 的小图标当成了整页背景。
|
|
235
|
+
base = os.path.join(media_dir, out_name + '-min')
|
|
220
236
|
done, why = None, 'pillow-unavailable' if not pillow_ok else None
|
|
221
237
|
if pillow_ok:
|
|
222
238
|
try:
|
|
@@ -239,18 +255,463 @@ def export_media(pkg, images, outdir, pillow_ok, export_all=False):
|
|
|
239
255
|
return rows
|
|
240
256
|
|
|
241
257
|
|
|
258
|
+
# ---------------------------------------------------- rendered backgrounds
|
|
259
|
+
def _is_full_canvas_picture(shape):
|
|
260
|
+
return (shape.get('kind') == 'pic' and shape.get('media')
|
|
261
|
+
and not shape.get('hidden')
|
|
262
|
+
and (shape.get('w_pct') or 0) >= 95
|
|
263
|
+
and (shape.get('h_pct') or 0) >= 95)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _shape_has_visible_text(shape):
|
|
267
|
+
text = shape.get('text') or {}
|
|
268
|
+
for paragraph in text.get('paragraphs') or []:
|
|
269
|
+
for run in paragraph.get('runs') or []:
|
|
270
|
+
if str(run.get('text') or '').strip():
|
|
271
|
+
return True
|
|
272
|
+
return False
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _is_full_canvas_fill_overlay(shape):
|
|
276
|
+
fill = shape.get('fill') or {}
|
|
277
|
+
geometry = shape.get('geom') or {}
|
|
278
|
+
line = shape.get('line') or {}
|
|
279
|
+
return (shape.get('kind') == 'sp' and not shape.get('hidden')
|
|
280
|
+
and fill.get('type') in ('solid', 'gradient')
|
|
281
|
+
and not fill.get('path')
|
|
282
|
+
and geometry.get('prst') == 'rect'
|
|
283
|
+
and not shape.get('rot')
|
|
284
|
+
and not line.get('color') and not line.get('gradient')
|
|
285
|
+
and not _shape_has_visible_text(shape)
|
|
286
|
+
and (shape.get('w_pct') or 0) >= 95
|
|
287
|
+
and (shape.get('h_pct') or 0) >= 95)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _picture_has_appearance(shape):
|
|
291
|
+
return bool(shape.get('flipH') or shape.get('flipV') or shape.get('rot')
|
|
292
|
+
or shape.get('crop')
|
|
293
|
+
or (shape.get('opacity') is not None and shape.get('opacity') != 1.0))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _effective_background(parts, bg_by_part):
|
|
297
|
+
background = None
|
|
298
|
+
for part in parts:
|
|
299
|
+
if bg_by_part.get(part):
|
|
300
|
+
background = bg_by_part[part]
|
|
301
|
+
return background
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _draw_picture(canvas, shape, pkg, image_cache=None):
|
|
305
|
+
"""Replay one p:pic's crop, flip, rotation and blip opacity with Pillow."""
|
|
306
|
+
from PIL import Image, ImageEnhance, ImageOps
|
|
307
|
+
|
|
308
|
+
media = shape.get('media')
|
|
309
|
+
if media not in pkg.names:
|
|
310
|
+
return False
|
|
311
|
+
image_cache = image_cache if image_cache is not None else {}
|
|
312
|
+
box = shape.get('box_unrotated') or shape.get('box') or {}
|
|
313
|
+
if media not in image_cache:
|
|
314
|
+
try:
|
|
315
|
+
source = Image.open(io.BytesIO(pkg.zip.read(media))).convert('RGBA')
|
|
316
|
+
source.load()
|
|
317
|
+
image_cache[media] = source
|
|
318
|
+
except Exception:
|
|
319
|
+
return False
|
|
320
|
+
picture = image_cache[media].copy()
|
|
321
|
+
|
|
322
|
+
crop = shape.get('crop') or {}
|
|
323
|
+
left = max(0.0, min(float(crop.get('l', 0) or 0), 100.0))
|
|
324
|
+
top = max(0.0, min(float(crop.get('t', 0) or 0), 100.0))
|
|
325
|
+
right = max(0.0, min(float(crop.get('r', 0) or 0), 100.0))
|
|
326
|
+
bottom = max(0.0, min(float(crop.get('b', 0) or 0), 100.0))
|
|
327
|
+
bounds = (
|
|
328
|
+
int(round(picture.width * left / 100.0)),
|
|
329
|
+
int(round(picture.height * top / 100.0)),
|
|
330
|
+
int(round(picture.width * (1.0 - right / 100.0))),
|
|
331
|
+
int(round(picture.height * (1.0 - bottom / 100.0))),
|
|
332
|
+
)
|
|
333
|
+
if bounds[2] > bounds[0] and bounds[3] > bounds[1]:
|
|
334
|
+
picture = picture.crop(bounds)
|
|
335
|
+
|
|
336
|
+
width = max(1, int(round(box.get('w') or 0)))
|
|
337
|
+
height = max(1, int(round(box.get('h') or 0)))
|
|
338
|
+
resampling = getattr(Image, 'Resampling', Image)
|
|
339
|
+
picture = picture.resize((width, height), resampling.LANCZOS)
|
|
340
|
+
if shape.get('flipH'):
|
|
341
|
+
picture = ImageOps.mirror(picture)
|
|
342
|
+
if shape.get('flipV'):
|
|
343
|
+
picture = ImageOps.flip(picture)
|
|
344
|
+
opacity = shape.get('opacity')
|
|
345
|
+
if opacity is not None:
|
|
346
|
+
picture.putalpha(ImageEnhance.Brightness(picture.getchannel('A')).enhance(
|
|
347
|
+
max(0.0, min(float(opacity), 1.0))))
|
|
348
|
+
|
|
349
|
+
rotation = float(shape.get('rot') or 0)
|
|
350
|
+
if rotation:
|
|
351
|
+
picture = picture.rotate(-rotation, resample=resampling.BICUBIC, expand=True)
|
|
352
|
+
center_x = float(box.get('x') or 0) + width / 2.0
|
|
353
|
+
center_y = float(box.get('y') or 0) + height / 2.0
|
|
354
|
+
x = int(round(center_x - picture.width / 2.0))
|
|
355
|
+
y = int(round(center_y - picture.height / 2.0))
|
|
356
|
+
layer = Image.new('RGBA', canvas.size)
|
|
357
|
+
layer.alpha_composite(picture, (x, y))
|
|
358
|
+
canvas.alpha_composite(layer)
|
|
359
|
+
return True
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _visible_picture_instance(shape, pkg, image_cache=None):
|
|
363
|
+
"""Render one non-background picture exactly as its PPT instance appears."""
|
|
364
|
+
from PIL import Image
|
|
365
|
+
|
|
366
|
+
bounds = shape.get('box') or {}
|
|
367
|
+
source_box = shape.get('box_unrotated') or bounds
|
|
368
|
+
width = max(1, int(round(bounds.get('w') or 0)))
|
|
369
|
+
height = max(1, int(round(bounds.get('h') or 0)))
|
|
370
|
+
if not source_box.get('w') or not source_box.get('h'):
|
|
371
|
+
return None
|
|
372
|
+
local = dict(shape)
|
|
373
|
+
local['box_unrotated'] = {
|
|
374
|
+
'x': float(source_box.get('x') or 0) - float(bounds.get('x') or 0),
|
|
375
|
+
'y': float(source_box.get('y') or 0) - float(bounds.get('y') or 0),
|
|
376
|
+
'w': source_box['w'],
|
|
377
|
+
'h': source_box['h'],
|
|
378
|
+
}
|
|
379
|
+
canvas = Image.new('RGBA', (width, height))
|
|
380
|
+
if not _draw_picture(canvas, local, pkg, image_cache):
|
|
381
|
+
return None
|
|
382
|
+
return canvas if canvas.getchannel('A').getbbox() else None
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _save_picture_instance_webp(canvas, path):
|
|
386
|
+
"""Keep the instance alpha and shrink only after lossless output exceeds budget."""
|
|
387
|
+
def write(options):
|
|
388
|
+
temporary = path + '.tmp'
|
|
389
|
+
try:
|
|
390
|
+
canvas.save(temporary, 'WEBP', **options)
|
|
391
|
+
size = os.path.getsize(temporary)
|
|
392
|
+
os.replace(temporary, path)
|
|
393
|
+
return size
|
|
394
|
+
finally:
|
|
395
|
+
if os.path.exists(temporary):
|
|
396
|
+
os.unlink(temporary)
|
|
397
|
+
|
|
398
|
+
try:
|
|
399
|
+
size = write({'lossless': True, 'method': 0})
|
|
400
|
+
except Exception as exc:
|
|
401
|
+
return 'pillow-error: %s: %s' % (exc.__class__.__name__, exc)
|
|
402
|
+
if size <= ASSET_BUDGET_BYTES:
|
|
403
|
+
return None
|
|
404
|
+
for quality in (95, 90, 85, 75):
|
|
405
|
+
try:
|
|
406
|
+
size = write({'quality': quality, 'method': 6})
|
|
407
|
+
except Exception:
|
|
408
|
+
return None
|
|
409
|
+
if size <= ASSET_BUDGET_BYTES:
|
|
410
|
+
return None
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def export_visible_picture_instances(pkg, shapes, outdir, pillow_ok, blocked=None):
|
|
415
|
+
"""Replace transformed local pictures with rendered media consumers can reuse.
|
|
416
|
+
|
|
417
|
+
A raw media file cannot reproduce an instance-level crop, rotation, flip, or
|
|
418
|
+
opacity. Flattening only those non-fullscreen instances keeps the downstream
|
|
419
|
+
contract simple: every asset still resolves through ordinary ``source_media``.
|
|
420
|
+
"""
|
|
421
|
+
if not pillow_ok:
|
|
422
|
+
return []
|
|
423
|
+
try:
|
|
424
|
+
from PIL import Image # noqa: F401
|
|
425
|
+
except Exception:
|
|
426
|
+
return []
|
|
427
|
+
|
|
428
|
+
media_dir = os.path.join(outdir, 'media-out')
|
|
429
|
+
os.makedirs(media_dir, exist_ok=True)
|
|
430
|
+
blocked = blocked if blocked is not None else {}
|
|
431
|
+
rows_by_media, rendered_specs, image_cache = {}, {}, {}
|
|
432
|
+
for shape in shapes:
|
|
433
|
+
if (shape.get('kind') != 'pic' or shape.get('hidden')
|
|
434
|
+
or not shape.get('media') or shape.get('media') not in pkg.names
|
|
435
|
+
or _is_full_canvas_picture(shape) or not _picture_has_appearance(shape)):
|
|
436
|
+
continue
|
|
437
|
+
source = shape['media']
|
|
438
|
+
source_box = shape.get('box_unrotated') or shape.get('box') or {}
|
|
439
|
+
visible_box = shape.get('box') or {}
|
|
440
|
+
spec = json.dumps({
|
|
441
|
+
'source': source,
|
|
442
|
+
'source_size': [source_box.get('w'), source_box.get('h')],
|
|
443
|
+
'visible_size': [visible_box.get('w'), visible_box.get('h')],
|
|
444
|
+
'crop': shape.get('crop') or {},
|
|
445
|
+
'flipH': bool(shape.get('flipH')),
|
|
446
|
+
'flipV': bool(shape.get('flipV')),
|
|
447
|
+
'rot': shape.get('rot') or 0,
|
|
448
|
+
'opacity': shape.get('opacity', 1.0),
|
|
449
|
+
}, ensure_ascii=False, sort_keys=True)
|
|
450
|
+
media = rendered_specs.get(spec)
|
|
451
|
+
if media is None:
|
|
452
|
+
canvas = _visible_picture_instance(shape, pkg, image_cache)
|
|
453
|
+
if canvas is None:
|
|
454
|
+
continue
|
|
455
|
+
digest = hashlib.sha256(spec.encode('utf-8')).hexdigest()[:16]
|
|
456
|
+
media = 'generated/instance/picture-%s.webp' % digest
|
|
457
|
+
out = 'media-out/' + os.path.basename(media)
|
|
458
|
+
path = os.path.join(outdir, out)
|
|
459
|
+
if not os.path.exists(path):
|
|
460
|
+
reason = _save_picture_instance_webp(canvas, path)
|
|
461
|
+
if reason:
|
|
462
|
+
row = blocked.setdefault(source, {
|
|
463
|
+
'reason': reason, 'attempted_specs': 0, 'part_refs': [],
|
|
464
|
+
})
|
|
465
|
+
row['attempted_specs'] += 1
|
|
466
|
+
row['part_refs'].append(shape.get('part'))
|
|
467
|
+
shape['visible_instance_blocked'] = reason
|
|
468
|
+
rendered_specs[spec] = False
|
|
469
|
+
continue
|
|
470
|
+
rendered_specs[spec] = media
|
|
471
|
+
rows_by_media[media] = {
|
|
472
|
+
'media': media,
|
|
473
|
+
'ext': 'webp',
|
|
474
|
+
'bytes': os.path.getsize(path),
|
|
475
|
+
'used_n': 0,
|
|
476
|
+
'reasons': ['visible_picture_instance'],
|
|
477
|
+
'candidate': True,
|
|
478
|
+
'exported': True,
|
|
479
|
+
'out': out,
|
|
480
|
+
'out_bytes': os.path.getsize(path),
|
|
481
|
+
'transcoded': False,
|
|
482
|
+
'generated': True,
|
|
483
|
+
'rendered_from': source,
|
|
484
|
+
'rendered_with': {
|
|
485
|
+
key: shape[key] for key in ('crop', 'flipH', 'flipV', 'rot', 'opacity')
|
|
486
|
+
if shape.get(key) not in (None, False, {}, 0, 1.0)
|
|
487
|
+
},
|
|
488
|
+
'part_refs': [],
|
|
489
|
+
}
|
|
490
|
+
elif media is False:
|
|
491
|
+
blocked[source]['part_refs'].append(shape.get('part'))
|
|
492
|
+
shape['visible_instance_blocked'] = blocked[source]['reason']
|
|
493
|
+
continue
|
|
494
|
+
row = rows_by_media[media]
|
|
495
|
+
row['used_n'] += 1
|
|
496
|
+
row['part_refs'].append(shape.get('part'))
|
|
497
|
+
shape['media'] = media
|
|
498
|
+
shape.pop('media_svg', None)
|
|
499
|
+
shape['rendered_from'] = source
|
|
500
|
+
return list(rows_by_media.values())
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _draw_background(canvas, background, pkg, image_cache=None):
|
|
504
|
+
"""Draw the effective p:bg underneath picture layers."""
|
|
505
|
+
from PIL import Image
|
|
506
|
+
from render_pages import _grad_image, _grad_stops, _rgba, background_css
|
|
507
|
+
|
|
508
|
+
css = background_css(background)
|
|
509
|
+
canvas.paste(_rgba(css, (255, 255, 255, 255)),
|
|
510
|
+
(0, 0, canvas.width, canvas.height))
|
|
511
|
+
gradient = _grad_stops(css or '')
|
|
512
|
+
if gradient:
|
|
513
|
+
canvas.alpha_composite(
|
|
514
|
+
_grad_image(Image, canvas.width, canvas.height, gradient[0], gradient[1]))
|
|
515
|
+
if background and background.get('type') == 'image' and background.get('media'):
|
|
516
|
+
_draw_picture(canvas, {
|
|
517
|
+
'media': background['media'],
|
|
518
|
+
'box_unrotated': {'x': 0, 'y': 0, 'w': canvas.width, 'h': canvas.height},
|
|
519
|
+
'crop': background.get('crop') or {},
|
|
520
|
+
}, pkg, image_cache)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _draw_background_fill_overlay(canvas, shape):
|
|
524
|
+
"""Draw one full-canvas fill shape that visually modifies a background image."""
|
|
525
|
+
from PIL import Image
|
|
526
|
+
from render_pages import _grad_image, _grad_stops, _rgba, css_color, css_gradient
|
|
527
|
+
|
|
528
|
+
fill = shape.get('fill') or {}
|
|
529
|
+
box = shape.get('box_unrotated') or shape.get('box') or {}
|
|
530
|
+
width = max(1, int(round(box.get('w') or 0)))
|
|
531
|
+
height = max(1, int(round(box.get('h') or 0)))
|
|
532
|
+
x = int(round(box.get('x') or 0))
|
|
533
|
+
y = int(round(box.get('y') or 0))
|
|
534
|
+
if fill.get('type') == 'solid':
|
|
535
|
+
color = _rgba(css_color(fill.get('color')))
|
|
536
|
+
if color is None:
|
|
537
|
+
return False
|
|
538
|
+
overlay = Image.new('RGBA', (width, height), color)
|
|
539
|
+
elif fill.get('type') == 'gradient':
|
|
540
|
+
gradient = _grad_stops(css_gradient(fill) or '')
|
|
541
|
+
if not gradient:
|
|
542
|
+
return False
|
|
543
|
+
overlay = _grad_image(Image, width, height, gradient[0], gradient[1])
|
|
544
|
+
else:
|
|
545
|
+
return False
|
|
546
|
+
canvas.alpha_composite(overlay, (x, y))
|
|
547
|
+
return True
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _background_layer_source(shape):
|
|
551
|
+
if shape.get('kind') == 'pic':
|
|
552
|
+
return {
|
|
553
|
+
'part': shape['part'], 'kind': 'picture', 'media': shape['media'],
|
|
554
|
+
'crop': shape.get('crop'), 'flipH': bool(shape.get('flipH')),
|
|
555
|
+
'flipV': bool(shape.get('flipV')), 'rot': shape.get('rot') or 0,
|
|
556
|
+
'opacity': shape.get('opacity', 1.0),
|
|
557
|
+
}
|
|
558
|
+
return {
|
|
559
|
+
'part': shape['part'], 'kind': 'fill_overlay',
|
|
560
|
+
'box': shape.get('box_unrotated') or shape.get('box'),
|
|
561
|
+
'fill': shape.get('fill'),
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _save_background_webp(canvas, path):
|
|
566
|
+
"""Prefer lossless output; fall back to a quality ladder when oversized."""
|
|
567
|
+
canvas.convert('RGB').save(path, 'WEBP', lossless=True, method=0)
|
|
568
|
+
if os.path.getsize(path) <= ASSET_BUDGET_BYTES:
|
|
569
|
+
return
|
|
570
|
+
rgb = canvas.convert('RGB')
|
|
571
|
+
for quality in (95, 90, 85, 75):
|
|
572
|
+
rgb.save(path, 'WEBP', quality=quality, method=6)
|
|
573
|
+
if os.path.getsize(path) <= ASSET_BUDGET_BYTES:
|
|
574
|
+
return
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def compose_backgrounds(pkg, graph, shapes, bg_by_part, units, outdir, pillow_ok):
|
|
578
|
+
"""Flatten non-trivial full-canvas picture stacks into one web-safe asset."""
|
|
579
|
+
if not pillow_ok:
|
|
580
|
+
return {}, [], []
|
|
581
|
+
try:
|
|
582
|
+
from PIL import Image
|
|
583
|
+
except Exception:
|
|
584
|
+
return {}, [], []
|
|
585
|
+
|
|
586
|
+
by_part = defaultdict(list)
|
|
587
|
+
for shape in shapes:
|
|
588
|
+
by_part[shape['part']].append(shape)
|
|
589
|
+
|
|
590
|
+
chains = {}
|
|
591
|
+
for layout in pkg.layouts:
|
|
592
|
+
master = graph['master_of_layout'].get(layout)
|
|
593
|
+
show_master = pkg.xml(layout).get('showMasterSp', '1') != '0'
|
|
594
|
+
chains[layout] = [part for part in ((master if show_master else None), layout) if part]
|
|
595
|
+
for slide in pkg.slides:
|
|
596
|
+
layout = graph['layout_of_slide'].get(slide)
|
|
597
|
+
master = graph['master_of_layout'].get(layout)
|
|
598
|
+
show_master = pkg.xml(slide).get('showMasterSp', '1') != '0'
|
|
599
|
+
if layout:
|
|
600
|
+
show_master = show_master and pkg.xml(layout).get('showMasterSp', '1') != '0'
|
|
601
|
+
chains[slide] = [part for part in (
|
|
602
|
+
(master if show_master else None), layout, slide) if part]
|
|
603
|
+
|
|
604
|
+
media_dir = os.path.join(outdir, 'media-out')
|
|
605
|
+
os.makedirs(media_dir, exist_ok=True)
|
|
606
|
+
part_map, rows_by_media, images_by_media, rendered_specs = {}, {}, {}, {}
|
|
607
|
+
image_cache = {}
|
|
608
|
+
for target, chain in chains.items():
|
|
609
|
+
layers = [shape for part in chain for shape in by_part.get(part, [])
|
|
610
|
+
if _is_full_canvas_picture(shape) or _is_full_canvas_fill_overlay(shape)]
|
|
611
|
+
pictures = [shape for shape in layers if shape.get('kind') == 'pic']
|
|
612
|
+
if not pictures or (len(pictures) == 1 and not _picture_has_appearance(pictures[0])
|
|
613
|
+
and len(layers) == 1):
|
|
614
|
+
continue
|
|
615
|
+
source_layers = [_background_layer_source(shape) for shape in layers]
|
|
616
|
+
spec = json.dumps({
|
|
617
|
+
'background': _effective_background(chain, bg_by_part),
|
|
618
|
+
'layers': [{k: v for k, v in layer.items() if k != 'part'}
|
|
619
|
+
for layer in source_layers],
|
|
620
|
+
}, ensure_ascii=False, sort_keys=True)
|
|
621
|
+
media = rendered_specs.get(spec)
|
|
622
|
+
if media is None:
|
|
623
|
+
canvas = Image.new('RGBA', (units.w, units.h))
|
|
624
|
+
_draw_background(
|
|
625
|
+
canvas, _effective_background(chain, bg_by_part), pkg, image_cache)
|
|
626
|
+
drawn = [
|
|
627
|
+
shape for shape in layers
|
|
628
|
+
if (_draw_picture(canvas, shape, pkg, image_cache)
|
|
629
|
+
if shape.get('kind') == 'pic'
|
|
630
|
+
else _draw_background_fill_overlay(canvas, shape))
|
|
631
|
+
]
|
|
632
|
+
if len(drawn) != len(layers):
|
|
633
|
+
continue
|
|
634
|
+
digest = hashlib.sha256(canvas.tobytes()).hexdigest()[:16]
|
|
635
|
+
media = 'generated/background/bg-composite-%s.webp' % digest
|
|
636
|
+
out = 'media-out/' + os.path.basename(media)
|
|
637
|
+
path = os.path.join(outdir, out)
|
|
638
|
+
if not os.path.exists(path):
|
|
639
|
+
_save_background_webp(canvas, path)
|
|
640
|
+
rendered_specs[spec] = media
|
|
641
|
+
else:
|
|
642
|
+
out = rows_by_media[media]['out']
|
|
643
|
+
path = os.path.join(outdir, out)
|
|
644
|
+
part_map[target] = media
|
|
645
|
+
row = rows_by_media.setdefault(media, {
|
|
646
|
+
'media': media, 'ext': 'webp', 'bytes': os.path.getsize(path),
|
|
647
|
+
'used_n': 0, 'reasons': ['background_composite'], 'candidate': True,
|
|
648
|
+
'exported': True, 'out': out, 'out_bytes': os.path.getsize(path),
|
|
649
|
+
'transcoded': False, 'generated': True, 'composited_from': source_layers,
|
|
650
|
+
'part_refs': [],
|
|
651
|
+
})
|
|
652
|
+
row['used_n'] += 1
|
|
653
|
+
row['part_refs'].append(target)
|
|
654
|
+
image = images_by_media.setdefault(media, {
|
|
655
|
+
'media': media, 'n': 0, 'boxes': [], 'exact_boxes': [],
|
|
656
|
+
'fullscreen': True, 'fullscreen_n': 0, 'fullscreen_top_cluster_n': 0,
|
|
657
|
+
'repeat_fixed': [], 'max_w_pct': 100.0, 'bleed': False,
|
|
658
|
+
'crop_variants': [], 'stitch_candidate': False,
|
|
659
|
+
'svg_companion': None, 'variant_group': [],
|
|
660
|
+
})
|
|
661
|
+
image['n'] += 1
|
|
662
|
+
image['fullscreen_n'] += 1
|
|
663
|
+
image['fullscreen_top_cluster_n'] += 1
|
|
664
|
+
image['boxes'].append({
|
|
665
|
+
'box': {'x': 0, 'y': 0, 'w': units.w, 'h': units.h},
|
|
666
|
+
'box_emu': {'x': 0, 'y': 0, 'cx': units.cx, 'cy': units.cy},
|
|
667
|
+
'count': 1, 'exact_count': 1, 'exact_variants': 1,
|
|
668
|
+
'w_pct': 100.0, 'h_pct': 100.0, 'parts': [target],
|
|
669
|
+
'layers': ['composite'],
|
|
670
|
+
})
|
|
671
|
+
return part_map, list(rows_by_media.values()), list(images_by_media.values())
|
|
672
|
+
|
|
673
|
+
|
|
242
674
|
# ---------------------------------------------------------------- form hint
|
|
675
|
+
# PowerPoint 出厂版式名(中英两套)。设计师起的名字是「这一页干什么用」,
|
|
676
|
+
# 出厂名只是「这个占位符组合叫什么」——后者不算模板声明了页型。
|
|
677
|
+
STOCK_LAYOUT_NAMES = {
|
|
678
|
+
'default', 'blank', 'custom layout', 'title slide', 'title and content',
|
|
679
|
+
'section header', 'two content', 'comparison', 'title only',
|
|
680
|
+
'content with caption', 'picture with caption', 'title and vertical text',
|
|
681
|
+
'vertical title and text', 'name card', 'quote with caption', 'true or false',
|
|
682
|
+
'空白', '自定义版式', '标题幻灯片', '标题和内容', '节标题', '两栏内容', '比较',
|
|
683
|
+
'仅标题', '内容与标题', '图片与标题', '标题和竖排文字', '竖排标题与文本',
|
|
684
|
+
}
|
|
685
|
+
_NUM_PREFIX = re.compile(r'^\s*\d+[\s._-]*')
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def is_semantic_layout_name(name):
|
|
689
|
+
"""版式名是不是设计师起的「页型名」,而不是出厂名或纯编号。
|
|
690
|
+
|
|
691
|
+
不能按字符数判断——中文页型名两个字就说清了(「封面」「目录」),任何长度门槛
|
|
692
|
+
都会把整套 CJK 命名的模板判成没有语义版式,进而走错 form 分支。
|
|
693
|
+
"""
|
|
694
|
+
n = _NUM_PREFIX.sub('', (name or '').strip())
|
|
695
|
+
if not n or n.strip('0123456789 ._-') == '':
|
|
696
|
+
return False
|
|
697
|
+
return n.lower() not in STOCK_LAYOUT_NAMES
|
|
698
|
+
|
|
699
|
+
|
|
243
700
|
def form_hint(pkg, graph, layouts):
|
|
244
701
|
slides_with_ph = 0
|
|
245
702
|
for sp in pkg.slides:
|
|
246
703
|
if pkg.xml(sp).findall('.//p:ph', NS):
|
|
247
704
|
slides_with_ph += 1
|
|
248
705
|
names = [(l['name'] or '') for l in layouts]
|
|
249
|
-
semantic = sum(1 for n in names if
|
|
706
|
+
semantic = sum(1 for n in names if is_semantic_layout_name(n))
|
|
250
707
|
ev = {'slides_using_placeholders': '%d/%d' % (slides_with_ph, len(pkg.slides)),
|
|
251
708
|
'layouts': len(pkg.layouts), 'masters': len(pkg.masters),
|
|
252
709
|
'semantic_layout_names': semantic}
|
|
253
|
-
|
|
710
|
+
# 「模板自带页型声明」的判据是**比例**不是个数:一套只有 4 个版式但全部起了页型名的
|
|
711
|
+
# 精简模板,和一套 30 个版式里 5 个有名字的模板,前者才是真的按页型组织的。
|
|
712
|
+
semantic_ratio = semantic / float(len(pkg.layouts) or 1)
|
|
713
|
+
ev['semantic_layout_ratio'] = round(semantic_ratio, 3)
|
|
714
|
+
if slides_with_ph and semantic >= 2 and semantic_ratio >= 0.5:
|
|
254
715
|
form = 3
|
|
255
716
|
elif len(pkg.layouts) > 1 and not slides_with_ph:
|
|
256
717
|
form = 2
|
|
@@ -404,7 +865,7 @@ REF_NOTES = """# ref/ 审计层说明(S12)
|
|
|
404
865
|
4. **S6 XPath 口径**(写死并逐项声明):design = `a:solidFill//` 下的颜色 + 渐变
|
|
405
866
|
`a:gs` 的直接子颜色 + `p:bgRef` 的直接子颜色;editor = `p15:clr`(参考线);
|
|
406
867
|
aux = `a:buClr` 与 `p:style/a:*Ref`。effectLst 内的阴影色不计入频次。
|
|
407
|
-
5. **S5 计数双口径**:`exact_boxes`
|
|
868
|
+
5. **S5 计数双口径**:`exact_boxes` 是坐标完全一致的计数(可与逐形状人工点数对齐);
|
|
408
869
|
`boxes[]` 是 ±0.5%({eps:.1f}px)epsilon 聚类计数,会把微偏移的同位实例并进同一簇。
|
|
409
870
|
两者都落盘,差异即「容差带来的合并」。
|
|
410
871
|
6. **满屏判定独立阈值**:w ≥ {fs}% 且 h ≥ {fs}%,上不封顶;出血图(>100%)同样计入满屏。
|
|
@@ -432,6 +893,30 @@ REF_NOTES = """# ref/ 审计层说明(S12)
|
|
|
432
893
|
"""
|
|
433
894
|
|
|
434
895
|
|
|
896
|
+
def dump_source(pkg, outdir):
|
|
897
|
+
"""把 PPTX 解压后的原文原样落到 ref/source/。
|
|
898
|
+
|
|
899
|
+
普查是有损的:它按既定口径抽数,抽不到的、口径外的东西就没了。遇到判断不了的
|
|
900
|
+
情况(这个形状为什么这么摆、某个字段是什么意思),能直接翻原始 XML 比对着二手
|
|
901
|
+
数据猜可靠得多。只进中间产物,交付包里没有。
|
|
902
|
+
"""
|
|
903
|
+
dst = os.path.abspath(os.path.join(outdir, 'ref', 'source'))
|
|
904
|
+
n = 0
|
|
905
|
+
for name in pkg.zip.namelist():
|
|
906
|
+
if name.endswith('/'):
|
|
907
|
+
continue
|
|
908
|
+
p = os.path.abspath(os.path.join(dst, *name.split('/')))
|
|
909
|
+
# zip 条目名是文件里写什么就是什么,带 ../ 就能写到 ref/source 外面去
|
|
910
|
+
# (pptx 是用户上传的,当不可信输入处理)。落在目录外的条目一律不落盘。
|
|
911
|
+
if not p.startswith(dst + os.sep):
|
|
912
|
+
continue
|
|
913
|
+
os.makedirs(os.path.dirname(p), exist_ok=True)
|
|
914
|
+
with open(p, 'wb') as f:
|
|
915
|
+
f.write(pkg.zip.read(name))
|
|
916
|
+
n += 1
|
|
917
|
+
return n
|
|
918
|
+
|
|
919
|
+
|
|
435
920
|
def write_ref(outdir, payload, ref_data, units, parts_ordered, shapes, bg_by_part):
|
|
436
921
|
ref = os.path.join(outdir, 'ref')
|
|
437
922
|
os.makedirs(ref, exist_ok=True)
|
|
@@ -551,6 +1036,9 @@ def extract(pptx, outdir, export_all=False):
|
|
|
551
1036
|
'box_emu': {'x': 0, 'y': 0, 'cx': cx, 'cy': cy},
|
|
552
1037
|
'crop': bg.get('crop'), 'placement': 'inside',
|
|
553
1038
|
'w_pct': 100.0, 'h_pct': 100.0, 'in_group': False})
|
|
1039
|
+
instance_blocked = {}
|
|
1040
|
+
instance_media = export_visible_picture_instances(
|
|
1041
|
+
pkg, shapes, outdir, pillow_ok, instance_blocked)
|
|
554
1042
|
images, variant_groups = image_census(shapes, bg_images, units) # S5
|
|
555
1043
|
t = mark('S5_image_census', t)
|
|
556
1044
|
|
|
@@ -570,11 +1058,20 @@ def extract(pptx, outdir, export_all=False):
|
|
|
570
1058
|
t = mark('derived_censuses', t)
|
|
571
1059
|
|
|
572
1060
|
media_rows = export_media(pkg, images, outdir, pillow_ok, export_all) # S9
|
|
1061
|
+
for row in media_rows:
|
|
1062
|
+
if row['media'] in instance_blocked:
|
|
1063
|
+
row['visible_instance_blocked'] = instance_blocked[row['media']]
|
|
1064
|
+
media_rows += instance_media
|
|
1065
|
+
background_composites, composite_media, composite_images = compose_backgrounds(
|
|
1066
|
+
pkg, graph, shapes, bg_by_part, units, outdir, pillow_ok)
|
|
1067
|
+
media_rows += composite_media
|
|
1068
|
+
images += composite_images
|
|
573
1069
|
t = mark('S9_media_export', t)
|
|
574
1070
|
|
|
575
1071
|
# S5b + S14 run after S9 because the palette only covers exported assets.
|
|
576
|
-
|
|
577
|
-
|
|
1072
|
+
source_images = [i for i in images if i['media'] in pkg.names]
|
|
1073
|
+
fps = media_fingerprints(pkg, [i['media'] for i in source_images], pillow_ok)
|
|
1074
|
+
clusters, cluster_evidence = content_clusters(source_images, fps)
|
|
578
1075
|
exported = {m['media'] for m in media_rows if m.get('exported')}
|
|
579
1076
|
image_palette(images, fps, exported)
|
|
580
1077
|
t = mark('S5b_S14_content_palette', t)
|
|
@@ -646,7 +1143,8 @@ def extract(pptx, outdir, export_all=False):
|
|
|
646
1143
|
'Pillow unavailable: byte-identical media only, no perceptual merging'),
|
|
647
1144
|
'palette_available': pillow_ok,
|
|
648
1145
|
'media': media_rows,
|
|
649
|
-
|
|
1146
|
+
'background_composites': background_composites,
|
|
1147
|
+
# S4 shape-facts dominate this file (roughly half its bytes) and stage 2 reads
|
|
650
1148
|
# them only when a derived statistic needs backing evidence, so they live in
|
|
651
1149
|
# a sidecar and extract.json keeps just the pointer plus the derived censuses.
|
|
652
1150
|
'shapes_ref': 'ref/shapes.json',
|
|
@@ -656,6 +1154,7 @@ def extract(pptx, outdir, export_all=False):
|
|
|
656
1154
|
'slides': len(pkg.slides), 'layouts': len(pkg.layouts),
|
|
657
1155
|
'masters': len(pkg.masters), 'themes': len(pkg.themes),
|
|
658
1156
|
'media': len(pkg.media),
|
|
1157
|
+
'background_composites': len(composite_media),
|
|
659
1158
|
'shapes_total': len(shapes),
|
|
660
1159
|
'shapes_kept': len(kept_shapes),
|
|
661
1160
|
'shapes_dropped_off_canvas': len(dropped_shapes),
|
|
@@ -665,8 +1164,12 @@ def extract(pptx, outdir, export_all=False):
|
|
|
665
1164
|
if r.get('placement') == 'inherited'),
|
|
666
1165
|
'content_clusters': len(clusters),
|
|
667
1166
|
'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
|
|
669
|
-
|
|
1167
|
+
'media_exported': sum(1 for m in media_rows
|
|
1168
|
+
if m.get('exported') and not m.get('generated')),
|
|
1169
|
+
'media_instances': len(instance_media),
|
|
1170
|
+
'media_instance_blocked': len(instance_blocked),
|
|
1171
|
+
'media_transcoded': sum(1 for m in media_rows
|
|
1172
|
+
if m.get('transcoded') and not m.get('generated')),
|
|
670
1173
|
'media_transcode_blocked': sum(1 for m in media_rows
|
|
671
1174
|
if m.get('transcode_blocked')),
|
|
672
1175
|
'media_over_budget': sum(1 for m in media_rows if m.get('over_budget')),
|
|
@@ -723,6 +1226,7 @@ def extract(pptx, outdir, export_all=False):
|
|
|
723
1226
|
with open(out_json, 'w') as f:
|
|
724
1227
|
json.dump(payload, f, ensure_ascii=False, indent=1)
|
|
725
1228
|
write_ref(outdir, payload, ref_data, units, parts_ordered, shapes, bg_by_part)
|
|
1229
|
+
src_n = dump_source(pkg, outdir)
|
|
726
1230
|
perf['total'] = round(time.time() - t0, 3)
|
|
727
1231
|
with open(os.path.join(outdir, 'ref', 'perf.json'), 'w') as f:
|
|
728
1232
|
json.dump(perf, f, ensure_ascii=False, indent=1)
|
|
@@ -734,9 +1238,11 @@ def extract(pptx, outdir, export_all=False):
|
|
|
734
1238
|
print(' shapes %d kept / %d dropped off-canvas / %d bleed / %d clamped'
|
|
735
1239
|
% (payload['counts']['shapes_kept'], payload['counts']['shapes_dropped_off_canvas'],
|
|
736
1240
|
payload['counts']['shapes_bleed'], payload['counts']['shapes_clamped']))
|
|
737
|
-
print(' colors %d fonts %d images %d media exported %d/%d guides %d'
|
|
1241
|
+
print(' colors %d fonts %d images %d media exported %d/%d + %d composites guides %d'
|
|
738
1242
|
% (len(color_freq), len(fonts), len(images),
|
|
739
|
-
payload['counts']['media_exported'], len(pkg.media),
|
|
1243
|
+
payload['counts']['media_exported'], len(pkg.media),
|
|
1244
|
+
len(composite_media), len(guides)))
|
|
1245
|
+
print(' ref/source/ 原文 %d 个部件(判断不了时可直接翻)' % src_n)
|
|
740
1246
|
print(' extract.json %.1f KB total %.2fs'
|
|
741
1247
|
% (os.path.getsize(out_json) / 1024.0, perf['total']))
|
|
742
1248
|
return payload
|
|
@@ -751,13 +1257,29 @@ def main(argv):
|
|
|
751
1257
|
print('unknown option(s): %s' % ' '.join(sorted(unknown)))
|
|
752
1258
|
print(__doc__)
|
|
753
1259
|
return 2
|
|
1260
|
+
if not os.path.isfile(args[0]):
|
|
1261
|
+
# 猜附件文件名是高频错误起手式,报错要把「去哪儿看真名」直接说清楚
|
|
1262
|
+
print('找不到 %s' % args[0])
|
|
1263
|
+
print('不要猜附件文件名。先列出真实文件:ls -la .agent/<conversation_id>/attachments/')
|
|
1264
|
+
print('目录里没有 .pptx / .potx 时,说明这次上传没有落成沙箱本地文件——'
|
|
1265
|
+
'如实告诉用户拿不到模板文件,不要退回附件文本摘要或自造配图当风格来源。')
|
|
1266
|
+
return 2
|
|
754
1267
|
extract(args[0], args[1], export_all='--export-all-media' in flags)
|
|
755
1268
|
if '--no-draft' not in flags:
|
|
756
1269
|
import subprocess
|
|
757
1270
|
d = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'draft.py')
|
|
1271
|
+
sys.stdout.flush()
|
|
758
1272
|
r = subprocess.run([sys.executable, d, args[1]])
|
|
759
1273
|
if r.returncode:
|
|
760
|
-
|
|
1274
|
+
# 普查产物已经齐了,缺的只是草案。重跑整条抽取会同样失败在这一步,
|
|
1275
|
+
# 所以给一个区别于成功的终止哨兵,并指明只需重跑 draft.py。
|
|
1276
|
+
print('EXTRACT_PARTIAL 普查产物齐全,草案生成失败:'
|
|
1277
|
+
'python3 -B scripts/draft.py %s 单独重跑看报错' % args[1])
|
|
1278
|
+
sys.stdout.flush()
|
|
1279
|
+
return 1
|
|
1280
|
+
# 最后一行是终止哨兵:stdout 被截断时退出码仍可能是 0,两个哨兵都没有就是没跑完。
|
|
1281
|
+
print('EXTRACT_OK %s' % os.path.join(args[1], 'l-out'))
|
|
1282
|
+
sys.stdout.flush()
|
|
761
1283
|
return 0
|
|
762
1284
|
|
|
763
1285
|
|