@lark-apaas/coding-steering 0.1.18-dev.21ea0ba → 0.1.18-dev.28c4f05
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 +27 -13
- package/steering/design-html/skills/pptx-style-extract/font-fallback.yaml +3 -3
- package/steering/design-html/skills/pptx-style-extract/scripts/census.py +18 -12
- package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +13 -6
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +957 -197
- package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +48 -7
- package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +1 -1
- package/steering/design-html/skills/pptx-style-extract/scripts/package.py +115 -149
- package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +3 -3
- package/steering/design-html/skills/pptx-style-extract/scripts/query.py +2 -2
- package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +16 -10
- package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +14 -11
|
@@ -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
|
|
|
@@ -21,7 +21,8 @@ from datetime import datetime, timezone
|
|
|
21
21
|
|
|
22
22
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
23
23
|
|
|
24
|
-
from census import (DHASH_PREFILTER_MAX, FULLSCREEN_MIN_PCT, PIXDIFF_MAX,
|
|
24
|
+
from census import (ASSET_WARN_SINGLE, DHASH_PREFILTER_MAX, FULLSCREEN_MIN_PCT, PIXDIFF_MAX,
|
|
25
|
+
REPEAT_MIN, SMALL_IMG_W_PCT,
|
|
25
26
|
EPS_PX, color_census, content_clusters, detect_twins, font_census,
|
|
26
27
|
image_census, image_palette, layout_inventory, media_fingerprints,
|
|
27
28
|
font_scheme_by_part, radii_effects_census, read_guides,
|
|
@@ -32,7 +33,7 @@ from parts import (Package, PartCtx, build_graph, read_clrmap, read_part_shapes,
|
|
|
32
33
|
|
|
33
34
|
SCHEMA = 'pptx-extract/stage1-v0.1'
|
|
34
35
|
WEB_FORMATS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'}
|
|
35
|
-
ASSET_BUDGET_BYTES =
|
|
36
|
+
ASSET_BUDGET_BYTES = ASSET_WARN_SINGLE # V2-6 单张 WARN 线
|
|
36
37
|
# Quality ladder first, then resolution — dropping pixels is the more visible
|
|
37
38
|
# loss, so it is only reached once the lowest quality still overshoots.
|
|
38
39
|
WEBP_QUALITY_LADDER = (85, 75, 65, 55, 45)
|
|
@@ -177,6 +178,10 @@ def export_media(pkg, images, outdir, pillow_ok, export_all=False):
|
|
|
177
178
|
reasons.append('variant_group')
|
|
178
179
|
if info['stitch_candidate']:
|
|
179
180
|
reasons.append('crop_stitch')
|
|
181
|
+
if 0 < (info.get('max_w_pct') or 0) < SMALL_IMG_W_PCT and not info['fullscreen']:
|
|
182
|
+
# 小图(页内图标、角标)。不导出的话 L 层只能看着装饰容器里的空洞
|
|
183
|
+
# 自己编图形,编出来的与模板无关。
|
|
184
|
+
reasons.append('icon_candidate')
|
|
180
185
|
if ext in ('svg', 'emf', 'wmf'):
|
|
181
186
|
reasons.append('vector')
|
|
182
187
|
row = {'media': part, 'ext': ext, 'bytes': size,
|
|
@@ -240,17 +245,46 @@ def export_media(pkg, images, outdir, pillow_ok, export_all=False):
|
|
|
240
245
|
|
|
241
246
|
|
|
242
247
|
# ---------------------------------------------------------------- form hint
|
|
248
|
+
# PowerPoint 出厂版式名(中英两套)。设计师起的名字是「这一页干什么用」,
|
|
249
|
+
# 出厂名只是「这个占位符组合叫什么」——后者不算模板声明了页型。
|
|
250
|
+
STOCK_LAYOUT_NAMES = {
|
|
251
|
+
'default', 'blank', 'custom layout', 'title slide', 'title and content',
|
|
252
|
+
'section header', 'two content', 'comparison', 'title only',
|
|
253
|
+
'content with caption', 'picture with caption', 'title and vertical text',
|
|
254
|
+
'vertical title and text', 'name card', 'quote with caption', 'true or false',
|
|
255
|
+
'空白', '自定义版式', '标题幻灯片', '标题和内容', '节标题', '两栏内容', '比较',
|
|
256
|
+
'仅标题', '内容与标题', '图片与标题', '标题和竖排文字', '竖排标题与文本',
|
|
257
|
+
}
|
|
258
|
+
_NUM_PREFIX = re.compile(r'^\s*\d+[\s._-]*')
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def is_semantic_layout_name(name):
|
|
262
|
+
"""版式名是不是设计师起的「页型名」,而不是出厂名或纯编号。
|
|
263
|
+
|
|
264
|
+
不能按字符数判断——中文页型名两个字就说清了(「封面」「目录」),任何长度门槛
|
|
265
|
+
都会把整套 CJK 命名的模板判成没有语义版式,进而走错 form 分支。
|
|
266
|
+
"""
|
|
267
|
+
n = _NUM_PREFIX.sub('', (name or '').strip())
|
|
268
|
+
if not n or n.strip('0123456789 ._-') == '':
|
|
269
|
+
return False
|
|
270
|
+
return n.lower() not in STOCK_LAYOUT_NAMES
|
|
271
|
+
|
|
272
|
+
|
|
243
273
|
def form_hint(pkg, graph, layouts):
|
|
244
274
|
slides_with_ph = 0
|
|
245
275
|
for sp in pkg.slides:
|
|
246
276
|
if pkg.xml(sp).findall('.//p:ph', NS):
|
|
247
277
|
slides_with_ph += 1
|
|
248
278
|
names = [(l['name'] or '') for l in layouts]
|
|
249
|
-
semantic = sum(1 for n in names if
|
|
279
|
+
semantic = sum(1 for n in names if is_semantic_layout_name(n))
|
|
250
280
|
ev = {'slides_using_placeholders': '%d/%d' % (slides_with_ph, len(pkg.slides)),
|
|
251
281
|
'layouts': len(pkg.layouts), 'masters': len(pkg.masters),
|
|
252
282
|
'semantic_layout_names': semantic}
|
|
253
|
-
|
|
283
|
+
# 「模板自带页型声明」的判据是**比例**不是个数:一套只有 4 个版式但全部起了页型名的
|
|
284
|
+
# 精简模板,和一套 30 个版式里 5 个有名字的模板,前者才是真的按页型组织的。
|
|
285
|
+
semantic_ratio = semantic / float(len(pkg.layouts) or 1)
|
|
286
|
+
ev['semantic_layout_ratio'] = round(semantic_ratio, 3)
|
|
287
|
+
if slides_with_ph and semantic >= 2 and semantic_ratio >= 0.5:
|
|
254
288
|
form = 3
|
|
255
289
|
elif len(pkg.layouts) > 1 and not slides_with_ph:
|
|
256
290
|
form = 2
|
|
@@ -404,7 +438,7 @@ REF_NOTES = """# ref/ 审计层说明(S12)
|
|
|
404
438
|
4. **S6 XPath 口径**(写死并逐项声明):design = `a:solidFill//` 下的颜色 + 渐变
|
|
405
439
|
`a:gs` 的直接子颜色 + `p:bgRef` 的直接子颜色;editor = `p15:clr`(参考线);
|
|
406
440
|
aux = `a:buClr` 与 `p:style/a:*Ref`。effectLst 内的阴影色不计入频次。
|
|
407
|
-
5. **S5 计数双口径**:`exact_boxes`
|
|
441
|
+
5. **S5 计数双口径**:`exact_boxes` 是坐标完全一致的计数(可与逐形状人工点数对齐);
|
|
408
442
|
`boxes[]` 是 ±0.5%({eps:.1f}px)epsilon 聚类计数,会把微偏移的同位实例并进同一簇。
|
|
409
443
|
两者都落盘,差异即「容差带来的合并」。
|
|
410
444
|
6. **满屏判定独立阈值**:w ≥ {fs}% 且 h ≥ {fs}%,上不封顶;出血图(>100%)同样计入满屏。
|
|
@@ -646,7 +680,7 @@ def extract(pptx, outdir, export_all=False):
|
|
|
646
680
|
'Pillow unavailable: byte-identical media only, no perceptual merging'),
|
|
647
681
|
'palette_available': pillow_ok,
|
|
648
682
|
'media': media_rows,
|
|
649
|
-
# S4 shape-facts
|
|
683
|
+
# S4 shape-facts dominate this file (roughly half its bytes) and stage 2 reads
|
|
650
684
|
# them only when a derived statistic needs backing evidence, so they live in
|
|
651
685
|
# a sidecar and extract.json keeps just the pointer plus the derived censuses.
|
|
652
686
|
'shapes_ref': 'ref/shapes.json',
|
|
@@ -751,6 +785,13 @@ def main(argv):
|
|
|
751
785
|
print('unknown option(s): %s' % ' '.join(sorted(unknown)))
|
|
752
786
|
print(__doc__)
|
|
753
787
|
return 2
|
|
788
|
+
if not os.path.isfile(args[0]):
|
|
789
|
+
# 猜附件文件名是高频错误起手式,报错要把「去哪儿看真名」直接说清楚
|
|
790
|
+
print('找不到 %s' % args[0])
|
|
791
|
+
print('不要猜附件文件名。先列出真实文件:ls -la .agent/<conversation_id>/attachments/')
|
|
792
|
+
print('目录里没有 .pptx / .potx 时,说明这次上传没有落成沙箱本地文件——'
|
|
793
|
+
'如实告诉用户拿不到模板文件,不要退回附件文本摘要或自造配图当风格来源。')
|
|
794
|
+
return 2
|
|
754
795
|
extract(args[0], args[1], export_all='--export-all-media' in flags)
|
|
755
796
|
if '--no-draft' not in flags:
|
|
756
797
|
import subprocess
|
|
@@ -47,7 +47,7 @@ WEIGHT_TOKENS = {
|
|
|
47
47
|
ITALIC_TOKENS = {'italic', 'oblique', 'it'}
|
|
48
48
|
|
|
49
49
|
# Deterministic latin<->CJK alias hints. Emitted as a separate `alias_group` field,
|
|
50
|
-
# never merged into counts, so the audit trail stays intact
|
|
50
|
+
# never merged into counts, so the audit trail stays intact.
|
|
51
51
|
FONT_ALIAS_GROUPS = {
|
|
52
52
|
'fzlantinghei': ('方正兰亭黑', 'fzlantingheipro', 'fzlthpro', 'fzlthpros'),
|
|
53
53
|
'bytesans': ('字节跳动', 'bytesans'),
|
|
@@ -38,7 +38,7 @@ L 层判断单 schema(<l-out-dir> 四个文件,这段就是填写说明书
|
|
|
38
38
|
assets: # 可选;无资产包可整段省略
|
|
39
39
|
- id: bg-cover # 必填。命名规则 <kind 前缀>-<语义名>,
|
|
40
40
|
# logo- / slogan- / bg- / texture- / icon-
|
|
41
|
-
source_media:
|
|
41
|
+
source_media: <原图文件名> # media-out/ 里的**原图**文件名(不是压缩版)
|
|
42
42
|
kind: background # logo|slogan|background|texture|icon
|
|
43
43
|
role: cover # background/icon 用;封闭枚举见规范 §2
|
|
44
44
|
theme: dark # 双主题包按需
|
|
@@ -80,9 +80,9 @@ L 层判断单 schema(<l-out-dir> 四个文件,这段就是填写说明书
|
|
|
80
80
|
|
|
81
81
|
colors:
|
|
82
82
|
surface: "#FFFFFF"
|
|
83
|
-
primary: "#
|
|
83
|
+
primary: "#RRGGBB"
|
|
84
84
|
safe-area:
|
|
85
|
-
content: {top:
|
|
85
|
+
content: {top: <px>, right: <px>, bottom: <px>, left: <px>, applies-to: [content]}
|
|
86
86
|
confidence: medium
|
|
87
87
|
|
|
88
88
|
--------------------------------------------------------------------------------
|
|
@@ -98,7 +98,7 @@ L 层判断单 schema(<l-out-dir> 四个文件,这段就是填写说明书
|
|
|
98
98
|
role: cover
|
|
99
99
|
background: bg-cover
|
|
100
100
|
slots:
|
|
101
|
-
- {role: title, box: [
|
|
101
|
+
- {role: title, box: [<x>, <y>, <w>, <h>], type: title}
|
|
102
102
|
confidence: high
|
|
103
103
|
|
|
104
104
|
可选 `body:` 块标量 —— 追加到 layouts.md frontmatter 之后作为说明正文。
|
|
@@ -139,9 +139,11 @@ ASSET_CONSUMER_FIELDS = ['path', 'url', 'color', 'full', 'kind', 'role',
|
|
|
139
139
|
ASSET_AUDIT_FIELDS = ['boxes', 'aspect', 'mark', 'confidence']
|
|
140
140
|
KIND_PREFIX = {'background': 'bg', 'logo': 'logo', 'slogan': 'slogan',
|
|
141
141
|
'texture': 'texture', 'icon': 'icon'}
|
|
142
|
-
#
|
|
143
|
-
|
|
144
|
-
|
|
142
|
+
# 交付包的 ref/ 只留 audit.yaml(人复核数值出处用,几 KB)。
|
|
143
|
+
# 频次原表、聚类原始数据、extract.json、重建图、logo 候选图全部留在 stage1 抽取目录,
|
|
144
|
+
# 不拷进包——规范原本指望「下发时链路剥离」,但链路上没有环节真的剥离,结果
|
|
145
|
+
# 审计材料带着「别读我」一起进消费上下文,还占掉包体的大头。
|
|
146
|
+
REF_CARRY = ()
|
|
145
147
|
|
|
146
148
|
|
|
147
149
|
class Fail(SystemExit):
|
|
@@ -382,7 +384,7 @@ def place_assets(manifest, extract, stage1, pack):
|
|
|
382
384
|
if boxes:
|
|
383
385
|
au['boxes'] = boxes
|
|
384
386
|
# aspect 取**未取整**的 box —— 从取整后的整数反算会明显偏
|
|
385
|
-
#
|
|
387
|
+
# 先取整再相除,误差会落到小数点后两位,logo 这种细长框尤其明显。
|
|
386
388
|
raw = clusters[0]['box']
|
|
387
389
|
if raw.get('h'):
|
|
388
390
|
au['aspect'] = round(raw['w'] / float(raw['h']), 3)
|
|
@@ -406,6 +408,27 @@ def _pages_of(extract, source_media):
|
|
|
406
408
|
return sorted(pages)
|
|
407
409
|
|
|
408
410
|
|
|
411
|
+
def _archetypes_using(layouts_text, aid, key='asset'):
|
|
412
|
+
"""layouts.yaml 里哪些 archetype 引用了这个资产。
|
|
413
|
+
|
|
414
|
+
key='asset' 查 slots 里的图片槽;key='background' 查页型底图。
|
|
415
|
+
"""
|
|
416
|
+
out, cur, sect = [], None, None
|
|
417
|
+
for line in (layouts_text or '').split('\n'):
|
|
418
|
+
if re.match(r'^\w[\w-]*:\s*$', line):
|
|
419
|
+
sect = line.split(':')[0]
|
|
420
|
+
continue
|
|
421
|
+
if sect != 'layouts':
|
|
422
|
+
continue
|
|
423
|
+
m = re.match(r'^ ([\w-]+):\s*$', line)
|
|
424
|
+
if m:
|
|
425
|
+
cur = m.group(1)
|
|
426
|
+
continue
|
|
427
|
+
if cur and re.search(r'%s:\s*%s\b' % (key, re.escape(aid)), line) and cur not in out:
|
|
428
|
+
out.append(cur)
|
|
429
|
+
return out
|
|
430
|
+
|
|
431
|
+
|
|
409
432
|
def expand_placeholders(body, manifest, consumer, audit, extract, layouts_text):
|
|
410
433
|
"""body.md 里的 `{{ASSET_TABLE}}` / `{{LAYOUT_LIST}}` 由本脚本按落盘真值渲染——
|
|
411
434
|
路径与页型清单是打包期才确定的事实,不该由 L 层手抄(抄错就是死链)。"""
|
|
@@ -415,18 +438,19 @@ def expand_placeholders(body, manifest, consumer, audit, extract, layouts_text):
|
|
|
415
438
|
for aid, e in consumer.items():
|
|
416
439
|
pages = _pages_of(extract, src_of.get(aid))
|
|
417
440
|
box = (audit.get(aid) or {}).get('boxes') or []
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
when = '固定位 (%d, %d),尺寸 %dx%d px' % tuple(b)
|
|
425
|
-
when += (',全 %d 页里只出现在第 %s 页' % (extract['counts']['slides'],
|
|
426
|
-
'、'.join(map(str, pages)))
|
|
427
|
-
if pages and len(pages) <= 4 else ',每页固定放一次')
|
|
441
|
+
# 单元格保持短句:哪个页型用哪张由 `background` 字段决定,
|
|
442
|
+
# 那句说明放在表格前一次即可,逐行重复只是把同一句抄 N 遍。
|
|
443
|
+
if e['kind'] == 'background':
|
|
444
|
+
users = _archetypes_using(layouts_text, aid, key='background')
|
|
445
|
+
when = ('%s 用它' % '/'.join('`%s`' % o for o in users[:6])
|
|
446
|
+
if users else '未被任何页型引用')
|
|
428
447
|
else:
|
|
429
|
-
|
|
448
|
+
# 位置逐页型不同(同一 logo 常在封面左上、内容页右上,尺寸也不同),
|
|
449
|
+
# 所以这里只给归属,坐标一律交给 layouts.md 的 slots。
|
|
450
|
+
owners = _archetypes_using(layouts_text, aid)
|
|
451
|
+
when = ('%s 这 %d 个页型带它' % ('/'.join('`%s`' % o for o in owners[:6]),
|
|
452
|
+
len(owners))
|
|
453
|
+
if owners else '⚠ 没有页型声明它的位置,本包不使用它')
|
|
430
454
|
f = '`%s`' % e['path'] if e.get('path') else (
|
|
431
455
|
'`%s`' % e['url'] if e.get('url') else '纯色 `%s`' % e.get('color'))
|
|
432
456
|
if e.get('full'):
|
|
@@ -604,6 +628,11 @@ def _box_hit(box, idx):
|
|
|
604
628
|
% (best[0], best[1], best[2], best[3], best[4], bestd))
|
|
605
629
|
|
|
606
630
|
|
|
631
|
+
# 判断产物:L 层看着背景图划出来的区域,本就不该命中普查值——整键豁免,
|
|
632
|
+
# 不要求写 derived。逼它们走 derived 只会把整段豁免掉,机检反而更弱。
|
|
633
|
+
JUDGEMENT_KEYS = frozenset(('avoid', 'text_safe', 'pairing_rule'))
|
|
634
|
+
|
|
635
|
+
|
|
607
636
|
def trace_check(pack, extract, shapes, derived_values, derived_tokens, factor):
|
|
608
637
|
"""产物里每个 hex / 字号 / slot 坐标都必须可追溯到普查值或 derived 声明。"""
|
|
609
638
|
idx = build_trace_index(extract, shapes)
|
|
@@ -614,6 +643,9 @@ def trace_check(pack, extract, shapes, derived_values, derived_tokens, factor):
|
|
|
614
643
|
if not text:
|
|
615
644
|
continue
|
|
616
645
|
for lineno, path, raw in _scan_keyed(text):
|
|
646
|
+
if JUDGEMENT_KEYS & set(path):
|
|
647
|
+
checked['judgement'] += 1
|
|
648
|
+
continue
|
|
617
649
|
exempt_token = any(k in derived_tokens for k in path)
|
|
618
650
|
for m in HEX_RE.finditer(raw):
|
|
619
651
|
val = '#' + m.group(1).upper()
|
|
@@ -762,106 +794,6 @@ def build_package_manifest(manifest, consumer, audit, extract, pack):
|
|
|
762
794
|
return {k: v for k, v in pkg.items() if v is not None}
|
|
763
795
|
|
|
764
796
|
|
|
765
|
-
def layout_fast_index(layouts_blocks, max_slots=4):
|
|
766
|
-
if not layouts_blocks:
|
|
767
|
-
return []
|
|
768
|
-
blocks = {k: (inline, lines) for k, inline, lines in layouts_blocks}
|
|
769
|
-
if 'layouts' not in blocks:
|
|
770
|
-
return []
|
|
771
|
-
items = []
|
|
772
|
-
cur = None
|
|
773
|
-
for line in blocks['layouts'][1]:
|
|
774
|
-
m = re.match(r'^ ([\w-]+):\s*$', line)
|
|
775
|
-
if m:
|
|
776
|
-
cur = {'id': m.group(1), 'name': '', 'role': '', 'background': '', 'slots': []}
|
|
777
|
-
items.append(cur)
|
|
778
|
-
continue
|
|
779
|
-
if not cur:
|
|
780
|
-
continue
|
|
781
|
-
m = re.match(r'^ (name|role|background):\s*(.+?)\s*$', line)
|
|
782
|
-
if m:
|
|
783
|
-
cur[m.group(1)] = unquote(m.group(2))
|
|
784
|
-
continue
|
|
785
|
-
m = re.match(r'^\s{6}-\s*\{(.+)\}\s*$', line)
|
|
786
|
-
if m and len(cur['slots']) < max_slots:
|
|
787
|
-
raw = m.group(1)
|
|
788
|
-
role = re.search(r'role:\s*([^,}]+)', raw)
|
|
789
|
-
box = re.search(r'box:\s*\[([^\]]+)\]', raw)
|
|
790
|
-
typ = re.search(r'type:\s*([^,}]+)', raw)
|
|
791
|
-
if role and box:
|
|
792
|
-
label = unquote(role.group(1).strip())
|
|
793
|
-
if typ:
|
|
794
|
-
label += '/' + unquote(typ.group(1).strip())
|
|
795
|
-
cur['slots'].append('%s [%s]' % (label, box.group(1).strip()))
|
|
796
|
-
out = []
|
|
797
|
-
for item in items:
|
|
798
|
-
parts = ['`%s`' % item['id']]
|
|
799
|
-
if item.get('name'):
|
|
800
|
-
parts.append(item['name'])
|
|
801
|
-
if item.get('role'):
|
|
802
|
-
parts.append(item['role'])
|
|
803
|
-
if item.get('background'):
|
|
804
|
-
parts.append('背景 `%s`' % item['background'])
|
|
805
|
-
slots = ';'.join(item['slots'])
|
|
806
|
-
out.append('%s:%s' % (' / '.join(parts), slots or '按最接近用途套用'))
|
|
807
|
-
return out
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
def render_fast_path(manifest, consumer, has_sidecar, canvas, layouts_index=None):
|
|
811
|
-
"""Render the small consumer-first block that keeps runtime agents out of ref/."""
|
|
812
|
-
cover = next((aid for aid, a in consumer.items()
|
|
813
|
-
if a.get('kind') == 'background' and a.get('role') == 'cover'), None)
|
|
814
|
-
content_bgs = [aid for aid, a in consumer.items()
|
|
815
|
-
if a.get('kind') == 'background' and a.get('role') == 'content']
|
|
816
|
-
logos = [aid for aid, a in consumer.items() if a.get('kind') in ('logo', 'slogan')]
|
|
817
|
-
assets = []
|
|
818
|
-
for aid, a in consumer.items():
|
|
819
|
-
path = a.get('path') or a.get('color') or a.get('url')
|
|
820
|
-
role = a.get('role') or a.get('kind')
|
|
821
|
-
if path:
|
|
822
|
-
assets.append('`%s` -> `%s` (%s)' % (aid, path, role))
|
|
823
|
-
if len(assets) > 8:
|
|
824
|
-
assets = assets[:8] + ['其余资产见 frontmatter `assets`,不要去 `ref/` 里临时挑图。']
|
|
825
|
-
|
|
826
|
-
lines = [
|
|
827
|
-
'## Agent Fast Path',
|
|
828
|
-
'',
|
|
829
|
-
'消费本风格时先读这一节;它是给生成 Agent 的短路径,目标是把风格理解控制在 1 分钟内,避免把审计材料重新理解一遍。',
|
|
830
|
-
'',
|
|
831
|
-
'- **时间预算**:风格导入最多做 1 次 `read_file design.md`。本节已经内联常用坐标,读完后必须直接开始生成,不要再探索风格包。',
|
|
832
|
-
'- **只读入口**:常规生成只需要 `design.md`。只有本节的版式索引无法覆盖目标页时,才打开 %s。'
|
|
833
|
-
% ('`layouts.md`' if has_sidecar else '`design.md` 里的 `layouts`'),
|
|
834
|
-
'- **附件/zip 兜底**:如果当前内容来自 zip 附件的文本摘要,直接使用摘要中 `design.md` / `layouts.md` 的文本;不要尝试修复 zip、解析二进制、搜索附件目录或重建压缩包。',
|
|
835
|
-
'- **禁止动作**:不要读取 `ref/color-freq-raw.json`、`ref/font-clusters.json`、`ref/extract.json`、`ref/rebuild/`、`ref/rebuild/png/*`;不要 summarize / view 参考页;不要重新统计颜色、字体或版式。',
|
|
836
|
-
'- **信息来源优先级**:本节 > `## Usage` > frontmatter `assets` / `colors` / `typography` > `layouts.md`。除此之外的文件只用于人工审计,不用于生成。',
|
|
837
|
-
'- **缺信息时降级**:如果某个细节本节没有写,用 frontmatter token 和最接近的 `layouts.md` archetype 推断;不要打开审计文件补证。',
|
|
838
|
-
]
|
|
839
|
-
if canvas:
|
|
840
|
-
lines.append('- **画布**:所有坐标按 `%dx%d` 绝对像素理解。' % tuple(canvas))
|
|
841
|
-
if cover:
|
|
842
|
-
lines.append('- **封面背景**:优先使用 `%s`;整幅铺满画布,禁止自造渐变替代。' % cover)
|
|
843
|
-
if content_bgs:
|
|
844
|
-
lines.append('- **内容页背景**:按 `layouts.md` 中 archetype 的 `background` 字段取;常用内容背景为 %s。'
|
|
845
|
-
% '、'.join('`%s`' % x for x in content_bgs[:4]))
|
|
846
|
-
if cover or content_bgs:
|
|
847
|
-
lines.append('- **背景安全区**:背景图和版式必须配对。按 archetype 的 `background`、`text_safe`、`avoid` 一起放文字和卡片;标题、正文、关键数字、图表、卡片、时间线及其容器的外接矩形都不得压到背景视觉主体、强光斑、深色透明区上,透明容器也不能跨进禁放区。')
|
|
848
|
-
if logos:
|
|
849
|
-
lines.append('- **标识资产**:只使用 %s;不得重画、不得改比例。'
|
|
850
|
-
% '、'.join('`%s`' % x for x in logos))
|
|
851
|
-
lines += [
|
|
852
|
-
'- **配色与字体**:颜色只取 frontmatter `colors`;字体/字号只取 frontmatter `typography`;内容主题不得引入新色相。',
|
|
853
|
-
'- **版式**:优先使用下面内联版式索引;需要更多 slot 时才读 `layouts.md`;不要用 `ref/rebuild/png` 反推坐标。',
|
|
854
|
-
]
|
|
855
|
-
if layouts_index:
|
|
856
|
-
lines += ['', '内联版式索引(先用这里,不要为了选页型再读文件):']
|
|
857
|
-
lines += ['- ' + x for x in layouts_index]
|
|
858
|
-
if assets:
|
|
859
|
-
lines += ['', '关键资产:']
|
|
860
|
-
lines += ['- ' + a for a in assets]
|
|
861
|
-
lines.append('')
|
|
862
|
-
return '\n'.join(lines) + '\n'
|
|
863
|
-
|
|
864
|
-
|
|
865
797
|
def strip_agent_fast_path(body):
|
|
866
798
|
if '## Agent Fast Path' not in body:
|
|
867
799
|
return body
|
|
@@ -869,8 +801,7 @@ def strip_agent_fast_path(body):
|
|
|
869
801
|
count=1, flags=re.S).lstrip('\n')
|
|
870
802
|
|
|
871
803
|
|
|
872
|
-
def build_design(manifest, l_frontmatter, consumer, body, has_sidecar, canvas
|
|
873
|
-
layouts_index=None):
|
|
804
|
+
def build_design(manifest, l_frontmatter, consumer, body, has_sidecar, canvas):
|
|
874
805
|
blocks = {k: (inline, lines) for k, inline, lines in l_frontmatter}
|
|
875
806
|
bad = set(blocks) - L_FRONTMATTER_KEYS
|
|
876
807
|
if bad:
|
|
@@ -908,9 +839,9 @@ def build_design(manifest, l_frontmatter, consumer, body, has_sidecar, canvas,
|
|
|
908
839
|
out.append('canvas: %dx%d' % tuple(canvas))
|
|
909
840
|
out.append('---')
|
|
910
841
|
text = '\n'.join(out) + '\n'
|
|
842
|
+
# Fast Path 已废弃:它是 Usage / Hard Rules / 资产表 / 页型清单的第二份副本。
|
|
843
|
+
# 规则的唯一出处是正文各段,这里只负责把历史产物里的残留剥掉。
|
|
911
844
|
body = strip_agent_fast_path(body)
|
|
912
|
-
text += '\n' + render_fast_path(manifest, consumer, has_sidecar, canvas,
|
|
913
|
-
layouts_index=layouts_index)
|
|
914
845
|
if body:
|
|
915
846
|
text += body if body.startswith('\n') else '\n' + body
|
|
916
847
|
if not text.endswith('\n'):
|
|
@@ -924,20 +855,55 @@ def build_layouts_md(layouts_blocks, canvas):
|
|
|
924
855
|
raise Fail('layouts.yaml 不要写 canvas —— 脚本从 extract.json 取')
|
|
925
856
|
if 'layouts' not in blocks:
|
|
926
857
|
raise Fail('layouts.yaml 缺顶层键 `layouts:`')
|
|
927
|
-
# `names:` 是给 L
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
858
|
+
# `names:` / `bg_rules:` 是给 L 层集中填判断的两块扁平区——在这里并回各
|
|
859
|
+
# archetype,本身不进产物。让 L 层只改扁平键值,别去动 layouts 里的
|
|
860
|
+
# slots/confidence 结构(嵌套结构手改极易破坏缩进,进而静默改变语义)。
|
|
861
|
+
names, roles = {}, {}
|
|
862
|
+
for key, sink in (('names', names), ('roles', roles)):
|
|
863
|
+
for line in blocks.get(key, ('', []))[1]:
|
|
864
|
+
m = re.match(r'^\s{2}([\w-]+):\s*(.+?)\s*$', line)
|
|
865
|
+
if m:
|
|
866
|
+
sink[m.group(1)] = unquote(m.group(2))
|
|
867
|
+
bg_rules, cur = {}, None
|
|
868
|
+
for line in blocks.get('bg_rules', ('', []))[1]:
|
|
869
|
+
# 键后面允许行内注释(草案会标「用它的页型:…」)
|
|
870
|
+
m = re.match(r'^\s{2}([\w-]+):\s*(?:#.*)?$', line)
|
|
931
871
|
if m:
|
|
932
|
-
|
|
933
|
-
|
|
872
|
+
cur = m.group(1)
|
|
873
|
+
bg_rules[cur] = []
|
|
874
|
+
continue
|
|
875
|
+
if cur and line.strip():
|
|
876
|
+
bg_rules[cur].append(' ' + line.strip())
|
|
877
|
+
out = ['---', 'canvas: %dx%d' % tuple(canvas)]
|
|
878
|
+
# 禁放区是背景的属性,按背景写一次;页型只留 `background:` 指针。
|
|
879
|
+
# 合并进每个页型会把同一句话按页型数复制 N 遍。
|
|
880
|
+
if bg_rules:
|
|
881
|
+
out.append('backgrounds:')
|
|
882
|
+
for bg, lines in bg_rules.items():
|
|
883
|
+
out.append(' %s:' % bg)
|
|
884
|
+
out += lines
|
|
885
|
+
out.append('layouts:')
|
|
934
886
|
for line in blocks['layouts'][1]:
|
|
935
887
|
out.append(line)
|
|
936
888
|
m = re.match(r'^ ([\w-]+):\s*$', line)
|
|
937
889
|
if m and m.group(1) in names:
|
|
938
890
|
out.append(' name: "%s"' % names.pop(m.group(1)))
|
|
891
|
+
if m and m.group(1) in roles:
|
|
892
|
+
out.append(' role: %s' % roles.pop(m.group(1)))
|
|
939
893
|
if names:
|
|
940
894
|
raise Fail('names 里这些页型在 layouts 下找不到:%s' % ', '.join(sorted(names)))
|
|
895
|
+
if roles:
|
|
896
|
+
raise Fail('roles 里这些页型在 layouts 下找不到:%s' % ', '.join(sorted(roles)))
|
|
897
|
+
missing_role = [k for k in re.findall(r'^ ([\w-]+):\s*$', '\n'.join(blocks['layouts'][1]), re.M)
|
|
898
|
+
if not re.search(r'^ %s:\s*$(?:\n(?! \S).*)*?\n role:' % re.escape(k),
|
|
899
|
+
'\n'.join(out), re.M)]
|
|
900
|
+
if missing_role:
|
|
901
|
+
raise Fail('这些页型没有 role(在 layouts.yaml 的 roles 段填):%s' % ', '.join(missing_role))
|
|
902
|
+
declared = set(re.findall(r'^ background:\s*(\S+)\s*$',
|
|
903
|
+
'\n'.join(blocks['layouts'][1]), re.M))
|
|
904
|
+
stray = set(bg_rules) - declared
|
|
905
|
+
if stray:
|
|
906
|
+
raise Fail('backgrounds 段里这些背景没有任何页型在用:%s' % ', '.join(sorted(stray)))
|
|
941
907
|
out.append('---')
|
|
942
908
|
text = '\n'.join(out) + '\n'
|
|
943
909
|
if 'body' in blocks:
|
|
@@ -993,6 +959,12 @@ def main(argv=None):
|
|
|
993
959
|
if left:
|
|
994
960
|
raise Fail('判断单还有 %d 处草案占位没改(TODO 是 draft.py 留给 L 层的判断点):\n %s'
|
|
995
961
|
% (len(left), '\n '.join(left)))
|
|
962
|
+
mdesc = re.search(r'^description:\s*[|>]?\s*\n((?:\s+\S.*\n?)+)',
|
|
963
|
+
open(os.path.join(lout, 'manifest.yaml'), encoding='utf-8').read(), re.M)
|
|
964
|
+
if mdesc and len(re.sub(r'\s+', '', mdesc.group(1))) < 20:
|
|
965
|
+
raise Fail('manifest 的 description 只有 %d 个字符——它是消费模型定调的唯一入口,'
|
|
966
|
+
'写清底色/主色/字形/版面骨架,不要留占位'
|
|
967
|
+
% len(re.sub(r'\s+', '', mdesc.group(1))))
|
|
996
968
|
|
|
997
969
|
manifest = read_manifest(os.path.join(lout, 'manifest.yaml'))
|
|
998
970
|
if args.style_name:
|
|
@@ -1016,34 +988,23 @@ def main(argv=None):
|
|
|
1016
988
|
|
|
1017
989
|
lay_text = open(lay_path, encoding='utf-8').read() if os.path.exists(lay_path) else ''
|
|
1018
990
|
body = expand_placeholders(body, manifest, consumer, audit, extract, lay_text)
|
|
1019
|
-
layouts_index = layout_fast_index(layouts_blocks)
|
|
1020
991
|
design = build_design(manifest, l_fm, consumer, body,
|
|
1021
|
-
has_sidecar=layouts_blocks is not None, canvas=canvas
|
|
1022
|
-
layouts_index=layouts_index)
|
|
992
|
+
has_sidecar=layouts_blocks is not None, canvas=canvas)
|
|
1023
993
|
with open(os.path.join(pack, 'design.md'), 'w', encoding='utf-8') as f:
|
|
1024
994
|
f.write(design)
|
|
1025
995
|
if layouts_blocks is not None:
|
|
1026
996
|
with open(os.path.join(pack, 'layouts.md'), 'w', encoding='utf-8') as f:
|
|
1027
997
|
f.write(build_layouts_md(layouts_blocks, canvas))
|
|
1028
998
|
|
|
1029
|
-
ref
|
|
999
|
+
# 审计记录写回**抽取工作目录**,不进交付包:包是消费产物,`ref/` 里的东西
|
|
1000
|
+
# 没有任何门禁读、消费模型也用不上,放进去只会让「别读我」和「几 MB 材料」同时下发。
|
|
1001
|
+
ref = os.path.join(stage1, 'ref')
|
|
1030
1002
|
os.makedirs(ref, exist_ok=True)
|
|
1031
|
-
|
|
1003
|
+
audit_path = os.path.join(ref, 'audit.yaml')
|
|
1004
|
+
with open(audit_path, 'w', encoding='utf-8') as f:
|
|
1032
1005
|
f.write(render_audit(manifest, extract, audit))
|
|
1033
1006
|
carried = []
|
|
1034
|
-
|
|
1035
|
-
srcf = os.path.join(stage1, 'ref', name)
|
|
1036
|
-
if os.path.exists(srcf):
|
|
1037
|
-
shutil.copy2(srcf, os.path.join(ref, name))
|
|
1038
|
-
carried.append(name)
|
|
1039
|
-
shutil.copy2(ex_path, os.path.join(ref, 'extract.json'))
|
|
1040
|
-
carried.append('extract.json')
|
|
1041
|
-
for sub in ('rebuild', 'logo-candidates'):
|
|
1042
|
-
s = os.path.join(stage1, 'ref', sub)
|
|
1043
|
-
if os.path.isdir(s):
|
|
1044
|
-
shutil.copytree(s, os.path.join(ref, sub), dirs_exist_ok=True)
|
|
1045
|
-
carried.append(sub + '/')
|
|
1046
|
-
# L 层自备的 ref 补充件(判断理由、版式溯源等)原样带入
|
|
1007
|
+
# L 层自备的 ref 补充件(判断理由、版式溯源等)也归到抽取目录
|
|
1047
1008
|
l_ref = os.path.join(lout, 'ref')
|
|
1048
1009
|
if os.path.isdir(l_ref):
|
|
1049
1010
|
shutil.copytree(l_ref, ref, dirs_exist_ok=True)
|
|
@@ -1061,7 +1022,9 @@ def main(argv=None):
|
|
|
1061
1022
|
print(' assets %d 条目 / %d 文件落盘' % (len(consumer), len(copied)))
|
|
1062
1023
|
for d in copied:
|
|
1063
1024
|
print(' %-52s %8d B' % (os.path.relpath(d, pack), os.path.getsize(d)))
|
|
1064
|
-
print('
|
|
1025
|
+
print(' 审计记录 -> %s%s(不进交付包)'
|
|
1026
|
+
% (os.path.relpath(audit_path, os.path.dirname(stage1)),
|
|
1027
|
+
' ' + ', '.join(carried) if carried else ''))
|
|
1065
1028
|
|
|
1066
1029
|
# ---- 数值可追溯机检:产物里每个色值/字号/坐标都得能指回普查值
|
|
1067
1030
|
derived_values, derived_tokens = set(), set()
|
|
@@ -1080,8 +1043,10 @@ def main(argv=None):
|
|
|
1080
1043
|
problems, checked = trace_check(pack, extract, shapes,
|
|
1081
1044
|
derived_values, derived_tokens, factor)
|
|
1082
1045
|
print('\n--- 数值可追溯机检 ---')
|
|
1083
|
-
print(' 受检 hex %d / fontSize %d / slot box %d;derived 豁免 %d%s'
|
|
1046
|
+
print(' 受检 hex %d / fontSize %d / slot box %d;derived 豁免 %d%s%s'
|
|
1084
1047
|
% (checked['hex'], checked['size'], checked['box'], checked['exempt'],
|
|
1048
|
+
';判断键跳过 %d 行(avoid/text_safe/pairing_rule)' % checked['judgement']
|
|
1049
|
+
if checked['judgement'] else '',
|
|
1085
1050
|
';rebase_factor=%g' % factor if factor else ''))
|
|
1086
1051
|
if not shapes:
|
|
1087
1052
|
print(' ⚠ 未找到 %s,slot 坐标一项无法校验' % sp)
|
|
@@ -1108,6 +1073,7 @@ def main(argv=None):
|
|
|
1108
1073
|
print('check_v1: 未探测到($DSM_V1_DIR / 同级 skill / 开发机路径均无)—— v1 门禁未跑,交付前必须补跑')
|
|
1109
1074
|
if args.check_v1:
|
|
1110
1075
|
print('\n--- check_v1 ---')
|
|
1076
|
+
sys.stdout.flush()
|
|
1111
1077
|
r1 = subprocess.run([sys.executable, args.check_v1,
|
|
1112
1078
|
os.path.join(pack, 'design.md'), 'slide'])
|
|
1113
1079
|
rc = rc or r1.returncode
|
|
@@ -39,15 +39,15 @@ class Package:
|
|
|
39
39
|
self.masters = sorted((n for n in self.names if MASTER_RE.match(n)), key=_num)
|
|
40
40
|
self.themes, self.theme_discovery = self._find_themes(ct)
|
|
41
41
|
# zip directory entries ('ppt/media/') must not be counted as assets —
|
|
42
|
-
#
|
|
42
|
+
# they inflate media totals by one.
|
|
43
43
|
self.media = sorted(n for n in self.names
|
|
44
44
|
if n.startswith('ppt/media/') and not n.endswith('/'))
|
|
45
45
|
|
|
46
46
|
def _find_themes(self, ct):
|
|
47
47
|
"""Theme parts come from [Content_Types].xml, not from a path pattern.
|
|
48
48
|
|
|
49
|
-
Compressors relocate them —
|
|
50
|
-
ppt/slideMasters/theme/
|
|
49
|
+
Compressors relocate them — some rewrite them under
|
|
50
|
+
ppt/slideMasters/theme/ — and a `ppt/theme/` regex then finds
|
|
51
51
|
nothing to bind the masters to, so every schemeClr resolves to
|
|
52
52
|
UNRESOLVED:no-scheme. The path regex stays as the fallback for packages
|
|
53
53
|
whose content types are unreadable.
|
|
@@ -266,8 +266,8 @@ def _recipe_css(fill, line, radii, effects):
|
|
|
266
266
|
|
|
267
267
|
def _sig(fill, line, effects):
|
|
268
268
|
"""分组键 = 填充 + 描边 + 效果。**不含圆角**——OOXML 圆角是 min(w,h) 的百分比,
|
|
269
|
-
同一配方在不同尺寸的卡上绝对 px
|
|
270
|
-
|
|
269
|
+
同一配方在不同尺寸的卡上绝对 px 必然不同,把它计入键会把一个配方拆成多组;
|
|
270
|
+
归一到哪一档是判断,脚本只报区间。"""
|
|
271
271
|
f = 'none'
|
|
272
272
|
if isinstance(fill, dict):
|
|
273
273
|
if fill.get('type') == 'solid':
|
|
@@ -279,18 +279,19 @@ def render_shape(shape, media_url, stats):
|
|
|
279
279
|
|
|
280
280
|
HEAD = """<style>
|
|
281
281
|
body{margin:0;background:#2b2b2b;font-family:%s}
|
|
282
|
-
.page{position:relative;width
|
|
282
|
+
.page{position:relative;width:%dpx;height:%dpx;overflow:hidden}
|
|
283
283
|
.sp{position:absolute;box-sizing:border-box}
|
|
284
284
|
.sp p{margin:0}
|
|
285
285
|
.ph{position:absolute;left:6px;top:4px;font:16px/1.2 monospace;color:#ff2b88;background:#fff9;padding:1px 4px}
|
|
286
286
|
</style>
|
|
287
|
-
"""
|
|
287
|
+
"""
|
|
288
288
|
|
|
289
|
+
# 缩略框跟着画布比例走,缩放比也一起算——写死 16:9 会把非 16:9 模板的页面裁掉一截
|
|
289
290
|
INDEX_EXTRA = """<style>
|
|
290
|
-
.wrap{width
|
|
291
|
+
.wrap{width:%dpx;margin:0 auto;padding:16px 0}
|
|
291
292
|
.lbl{color:#eee;font:13px/1.6 monospace;margin:14px 0 4px}
|
|
292
|
-
.box{width
|
|
293
|
-
.box .page{transform:scale(
|
|
293
|
+
.box{width:%dpx;height:%dpx;overflow:hidden;margin-bottom:6px}
|
|
294
|
+
.box .page{transform:scale(%g);transform-origin:top left}
|
|
294
295
|
</style>
|
|
295
296
|
"""
|
|
296
297
|
|
|
@@ -508,7 +509,8 @@ def render_pages_png(pages, outdir, data, scale=0.5):
|
|
|
508
509
|
for m in data.get('media') or [] if m.get('exported') and m.get('out')}
|
|
509
510
|
png_dir = os.path.join(outdir, 'ref', 'rebuild', 'png')
|
|
510
511
|
os.makedirs(png_dir, exist_ok=True)
|
|
511
|
-
|
|
512
|
+
cpx = ((data.get('canvas') or {}).get('px') or [1920, 1080])
|
|
513
|
+
W, H = int(cpx[0] * scale), int(cpx[1] * scale)
|
|
512
514
|
for kind, n, label, layers, default_color, page_bg in pages:
|
|
513
515
|
canvas = Image.new('RGBA', (W, H), _rgba(page_bg) or (136, 136, 136, 255))
|
|
514
516
|
g = _grad_stops(page_bg or '')
|
|
@@ -642,15 +644,19 @@ def main():
|
|
|
642
644
|
tx1_of_master.get(master, '#000000'),
|
|
643
645
|
layout_bg.get(part, '#888')))
|
|
644
646
|
|
|
645
|
-
if only is not None:
|
|
646
|
-
pages = [p for p in pages if p[
|
|
647
|
+
if only is not None: # 对样张和版式一律生效(form=3 的代表页是版式,不是样张)
|
|
648
|
+
pages = [p for p in pages if p[1] in only]
|
|
647
649
|
|
|
648
650
|
stats = {'shapes': 0, 'text': 0, 'img_ok': 0, 'img_missing': 0, 'no_box': 0}
|
|
649
|
-
|
|
651
|
+
cw, ch = ((data.get('canvas') or {}).get('px') or [1920, 1080])[:2]
|
|
652
|
+
head = HEAD % (CJK_STACK, cw, ch) # 视口跟画布走,非 16:9 模板不能按 1920x1080 裁
|
|
653
|
+
thumb_scale = 960.0 / cw
|
|
654
|
+
index = [head, INDEX_EXTRA % (960, 960, round(ch * thumb_scale), thumb_scale),
|
|
655
|
+
'<div class="wrap">']
|
|
650
656
|
for kind, n, label, layers, default_color, page_bg in (() if args.no_html else pages):
|
|
651
657
|
html = page_html(layers, default_color, page_bg, media_url, stats)
|
|
652
658
|
with open(os.path.join(rebuild, '%s-%d.html' % (kind, n)), 'w', encoding='utf-8') as f:
|
|
653
|
-
f.write(
|
|
659
|
+
f.write(head + html)
|
|
654
660
|
index += ['<div class="lbl">%s</div>' % esc(label),
|
|
655
661
|
'<div class="box">%s</div>' % html]
|
|
656
662
|
index.append('</div>')
|