@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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """Stage-1 deterministic extractor for PPTX/POTX style packs (票 09 原型).
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, REPEAT_MIN,
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 = 500 * 1024 # D5b WARN line
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 len(n) > 3 and n.upper() not in ('DEFAULT', 'BLANK'))
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
- if slides_with_ph and len(pkg.layouts) >= 5 and semantic >= 5:
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` 是坐标完全一致的计数(可与票 03 recon 逐数对齐);
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 are 49-64% of this file (volcano: 1.4 MB) and stage 2 reads
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 (see STAGE1-REPORT deviations).
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: image-1-1.jpeg # media-out/ 里的**原图**文件名(不是压缩版)
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: "#2D5A8E"
83
+ primary: "#RRGGBB"
84
84
  safe-area:
85
- content: {top: 60, right: 90, bottom: 113, left: 90, applies-to: [content]}
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: [257, 313, 1406, 130], type: title}
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
- # stage1 ref/ 里随包分发的审计件(其余如 shapes.json 体量大、不进包)
143
- REF_CARRY = ('color-freq-raw.json', 'font-clusters.json', 's5-acceptance.json',
144
- 'content-clusters.json', 'notes.md')
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
- # (volcano logo 224.1/47.8=4.688,用 224/48 算成 4.667)。
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
- if e['kind'] == 'background' and e.get('role') == 'cover':
419
- when = '封面页整幅铺满,必用'
420
- elif e['kind'] == 'background':
421
- when = '内容页整幅铺满——哪个页型用哪张见 `layouts.md` 的 `background`'
422
- elif box:
423
- b = box[0]
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
- when = '按 `%s` 的语义使用' % e['kind']
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 层集中改中文页型名的一块——在这里并回各 archetype,不进产物
928
- names = {}
929
- for line in blocks.get('names', ('', []))[1]:
930
- m = re.match(r'^\s{2}([\w-]+):\s*(.+?)\s*$', line)
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
- names[m.group(1)] = unquote(m.group(2))
933
- out = ['---', 'canvas: %dx%d' % tuple(canvas), 'layouts:']
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 = os.path.join(pack, 'ref')
999
+ # 审计记录写回**抽取工作目录**,不进交付包:包是消费产物,`ref/` 里的东西
1000
+ # 没有任何门禁读、消费模型也用不上,放进去只会让「别读我」和「几 MB 材料」同时下发。
1001
+ ref = os.path.join(stage1, 'ref')
1030
1002
  os.makedirs(ref, exist_ok=True)
1031
- with open(os.path.join(ref, 'audit.yaml'), 'w', encoding='utf-8') as f:
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
- for name in REF_CARRY:
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(' ref/ %s' % ', '.join(carried))
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
- # 03 recon counted one, which is why its media totals run one high.
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 — 飞书压缩版 puts them at
50
- ppt/slideMasters/theme/themeN.xml — and a `ppt/theme/` regex then finds
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 必然不同(feishu 三张 glassCard 实测 10.8/12.8/19.5),
270
- 把它计入键会把一个配方拆成三组;归一到哪一档是 L11 的判断,脚本只报区间。"""
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:1920px;height:1080px;overflow:hidden}
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
- """ % CJK_STACK
287
+ """
288
288
 
289
+ # 缩略框跟着画布比例走,缩放比也一起算——写死 16:9 会把非 16:9 模板的页面裁掉一截
289
290
  INDEX_EXTRA = """<style>
290
- .wrap{width:960px;margin:0 auto;padding:16px 0}
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:960px;height:540px;overflow:hidden;margin-bottom:6px}
293
- .box .page{transform:scale(.5);transform-origin:top left}
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
- W, H = int(1920 * scale), int(1080 * scale)
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[0] != 'slide' or p[1] in only]
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
- index = [HEAD, INDEX_EXTRA, '<div class="wrap">']
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(HEAD + html)
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>')