@lark-apaas/coding-steering 0.1.18-dev.857e860 → 0.1.18-dev.9dbf919

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.
@@ -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,8 @@ 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
+ css: "<由源模板转译出的 CSS 声明串>"}
102
103
  confidence: high
103
104
 
104
105
  可选 `body:` 块标量 —— 追加到 layouts.md frontmatter 之后作为说明正文。
@@ -141,8 +142,8 @@ KIND_PREFIX = {'background': 'bg', 'logo': 'logo', 'slogan': 'slogan',
141
142
  'texture': 'texture', 'icon': 'icon'}
142
143
  # 交付包的 ref/ 只留 audit.yaml(人复核数值出处用,几 KB)。
143
144
  # 频次原表、聚类原始数据、extract.json、重建图、logo 候选图全部留在 stage1 抽取目录,
144
- # 不拷进包——规范说「下发时链路剥离」,但实测没人剥离,结果是几 MB 材料带着
145
- # 「别读我」一起进消费上下文(vo-lite 的包 4.2MB 里 2.6MB 是这些)。
145
+ # 不拷进包——规范原本指望「下发时链路剥离」,但链路上没有环节真的剥离,结果
146
+ # 审计材料带着「别读我」一起进消费上下文,还占掉包体的大头。
146
147
  REF_CARRY = ()
147
148
 
148
149
 
@@ -274,6 +275,10 @@ def truthy(v):
274
275
  return str(v).strip().lower() in ('1', 'true', 'yes', 'on')
275
276
 
276
277
 
278
+ # 浏览器能解码的图片格式。落进包的资产必须在此列,否则消费端引用到就是一张空白图。
279
+ WEB_SAFE_EXT = {'png', 'jpg', 'jpeg', 'webp', 'gif', 'svg', 'avif'}
280
+
281
+
277
282
  def asset_ext(name):
278
283
  return name.rsplit('.', 1)[-1].lower() if '.' in name else ''
279
284
 
@@ -353,12 +358,25 @@ def place_assets(manifest, extract, stage1, pack):
353
358
  entry['path'] = 'assets/%ss/%s.%s' % (kind, base, cext)
354
359
  if truthy(a.get('use_full')):
355
360
  oext = asset_ext(row['out'])
356
- fdst = os.path.join(sub, '%s@full.%s' % (base, oext))
357
- shutil.copy2(orig_path, fdst)
358
- copied.append(fdst)
359
- entry['full'] = 'assets/%ss/%s@full.%s' % (kind, base, oext)
361
+ if oext.lower() in WEB_SAFE_EXT:
362
+ fdst = os.path.join(sub, '%s@full.%s' % (base, oext))
363
+ shutil.copy2(orig_path, fdst)
364
+ copied.append(fdst)
365
+ entry['full'] = 'assets/%ss/%s@full.%s' % (kind, base, oext)
366
+ else:
367
+ # 原图是浏览器不解码的格式(tiff/bmp 之类)。落进包并在 design.md
368
+ # 里声明成可用资源,消费端引用它就是一张空白图——实测踩过一次,
369
+ # 封面与结束页因此空白。压缩版已经带着全部像素,full 不给。
370
+ print(' ⚠ %s 的原图是 .%s,浏览器不解码,只给压缩版' % (aid, oext))
360
371
  else:
361
372
  oext = asset_ext(row['out'])
373
+ if oext.lower() not in WEB_SAFE_EXT:
374
+ # 转码两条路(Pillow / sips)都没成,原图又是浏览器不解码的格式。
375
+ # 照落进去就是把一张永远显示不出来的图当资产下发——实测封面因此空白。
376
+ raise Fail('%s: 原图是 .%s,浏览器不解码,而转码没有产物'
377
+ '(extract.json 里看 transcode_blocked 的原因)。'
378
+ '装上 Pillow 重跑抽取,或把这条资产从 manifest 删掉'
379
+ '并在 gaps 写明。' % (aid, oext))
362
380
  dst = os.path.join(sub, '%s.%s' % (base, oext))
363
381
  shutil.copy2(orig_path, dst)
364
382
  copied.append(dst)
@@ -384,7 +402,7 @@ def place_assets(manifest, extract, stage1, pack):
384
402
  if boxes:
385
403
  au['boxes'] = boxes
386
404
  # aspect 取**未取整**的 box —— 从取整后的整数反算会明显偏
387
- # (volcano logo 224.1/47.8=4.688,用 224/48 算成 4.667)。
405
+ # 先取整再相除,误差会落到小数点后两位,logo 这种细长框尤其明显。
388
406
  raw = clusters[0]['box']
389
407
  if raw.get('h'):
390
408
  au['aspect'] = round(raw['w'] / float(raw['h']), 3)
@@ -490,7 +508,7 @@ def render_assets_block(consumer):
490
508
 
491
509
  # ------------------------------------------------- 数值可追溯机检(抄错即 FAIL)
492
510
  HEX_RE = re.compile(r'#([0-9A-Fa-f]{6})\b')
493
- FONTSIZE_RE = re.compile(r'\bfontSize:\s*([\d.]+)px')
511
+ FONTSIZE_RE = re.compile(r'\b(?:fontSize|font-size):\s*([\d.]+)px')
494
512
  BOX_RE = re.compile(r'\bbox:\s*\[\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,'
495
513
  r'\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\]')
496
514
  KEYLINE_RE = re.compile(r'^(\s*)-?\s*([A-Za-z_][\w-]*):\s*(.*)$')
@@ -633,6 +651,42 @@ def _box_hit(box, idx):
633
651
  JUDGEMENT_KEYS = frozenset(('avoid', 'text_safe', 'pairing_rule'))
634
652
 
635
653
 
654
+ def media_integrity(stage1, extract):
655
+ """extract.json 记录的尺寸,必须和磁盘上那个文件真实的尺寸一致。
656
+
657
+ 这条挡的是「记录全对、文件被顶替」——两个 media 的输出名撞车时,后写的覆盖先写的,
658
+ extract.json 里各自的记录都还是对的,任何只读记录的检查都发现不了;只有把记录和
659
+ 文件本身对一遍才抓得到。实测某模板因此把一张 109x109 的图当成了整页背景。
660
+ """
661
+ try:
662
+ from PIL import Image
663
+ except Exception:
664
+ return [] # 没有 Pillow 就量不了,跳过而不是假装通过
665
+ bad, seen = [], {}
666
+ for m in extract.get('media') or []:
667
+ for key, rel in (('source_px', m.get('out')),
668
+ ('compressed_px', m.get('compressed_out'))):
669
+ rec = m.get(key)
670
+ if not rel or not rec:
671
+ continue
672
+ path = os.path.join(stage1, rel)
673
+ if not os.path.exists(path):
674
+ bad.append('%s 的 %s 指向 %s,文件不存在' % (m['media'], key, rel))
675
+ continue
676
+ try:
677
+ with Image.open(path) as im:
678
+ real = list(im.size)
679
+ except Exception:
680
+ continue
681
+ if real != list(rec):
682
+ bad.append('%s 的 %s 记录 %s,但 %s 实际是 %s'
683
+ % (m['media'], key, rec, rel, real))
684
+ if rel in seen and seen[rel] != m['media']:
685
+ bad.append('%s 与 %s 都写到 %s' % (seen[rel], m['media'], rel))
686
+ seen[rel] = m['media']
687
+ return bad
688
+
689
+
636
690
  def trace_check(pack, extract, shapes, derived_values, derived_tokens, factor):
637
691
  """产物里每个 hex / 字号 / slot 坐标都必须可追溯到普查值或 derived 声明。"""
638
692
  idx = build_trace_index(extract, shapes)
@@ -849,6 +903,58 @@ def build_design(manifest, l_frontmatter, consumer, body, has_sidecar, canvas):
849
903
  return text
850
904
 
851
905
 
906
+ def shrink_safe_area(lines):
907
+ """text_safe 与 avoid 相交时把安全区收掉重叠的那部分。
908
+
909
+ text_safe 是脚本按「该背景各页型的槽位并集」算的,而模板里的槽位本身可能就压在
910
+ 主视觉上;avoid 是看图的人填的。两者从不同来源来,会直接打架——实测一张背景的
911
+ text_safe 有 34% 落在 avoid 里,消费端按 text_safe 把标题放进了禁放区,正好压在
912
+ 背景的山峰主体上。这里以 avoid 为准收缩:能不能放字是看图的人说了算。
913
+ """
914
+ def parse_box(s):
915
+ m = re.findall(r'-?\d+', s)
916
+ return [int(x) for x in m[:4]] if len(m) >= 4 else None
917
+
918
+ ts_i = ts = None
919
+ avoids = []
920
+ for i, ln in enumerate(lines):
921
+ st = ln.strip()
922
+ if st.startswith('text_safe:'):
923
+ ts_i, ts = i, parse_box(st.split(':', 1)[1].split('#')[0])
924
+ elif st.startswith('avoid:'):
925
+ for chunk in re.findall(r'box:\s*\[[^\]]*\]', st):
926
+ b = parse_box(chunk)
927
+ if b:
928
+ avoids.append(b)
929
+ if ts is None or not avoids:
930
+ return lines, None
931
+ x0, y0, x1, y1 = ts[0], ts[1], ts[0] + ts[2], ts[1] + ts[3]
932
+ for a in avoids:
933
+ ax0, ay0, ax1, ay1 = a[0], a[1], a[0] + a[2], a[1] + a[3]
934
+ if ax1 <= x0 or ax0 >= x1 or ay1 <= y0 or ay0 >= y1:
935
+ continue # 不相交
936
+ # 从被侵占得最少的一边切:优先保留面积最大的剩余矩形
937
+ cands = []
938
+ if ax0 > x0:
939
+ cands.append((x0, y0, ax0, y1))
940
+ if ax1 < x1:
941
+ cands.append((ax1, y0, x1, y1))
942
+ if ay0 > y0:
943
+ cands.append((x0, y0, x1, ay0))
944
+ if ay1 < y1:
945
+ cands.append((x0, ay1, x1, y1))
946
+ if not cands:
947
+ return lines, 'text_safe 被 avoid 完全覆盖,这张背景没有可放文字的区域'
948
+ x0, y0, x1, y1 = max(cands, key=lambda c: (c[2] - c[0]) * (c[3] - c[1]))
949
+ new = [x0, y0, x1 - x0, y1 - y0]
950
+ if new == ts:
951
+ return lines, None
952
+ lines = list(lines)
953
+ lines[ts_i] = ' text_safe: [%d, %d, %d, %d] # 已按 avoid 收缩(原 %s)' % (
954
+ new[0], new[1], new[2], new[3], ts)
955
+ return lines, None
956
+
957
+
852
958
  def build_layouts_md(layouts_blocks, canvas):
853
959
  blocks = {k: (inline, lines) for k, inline, lines in layouts_blocks}
854
960
  if 'canvas' in blocks:
@@ -857,12 +963,13 @@ def build_layouts_md(layouts_blocks, canvas):
857
963
  raise Fail('layouts.yaml 缺顶层键 `layouts:`')
858
964
  # `names:` / `bg_rules:` 是给 L 层集中填判断的两块扁平区——在这里并回各
859
965
  # archetype,本身不进产物。让 L 层只改扁平键值,别去动 layouts 里的
860
- # slots/confidence 结构(实测两次都在那里编辑出事故)。
861
- names = {}
862
- for line in blocks.get('names', ('', []))[1]:
863
- m = re.match(r'^\s{2}([\w-]+):\s*(.+?)\s*$', line)
864
- if m:
865
- names[m.group(1)] = unquote(m.group(2))
966
+ # slots/confidence 结构(嵌套结构手改极易破坏缩进,进而静默改变语义)。
967
+ names, roles = {}, {}
968
+ for key, sink in (('names', names), ('roles', roles)):
969
+ for line in blocks.get(key, ('', []))[1]:
970
+ m = re.match(r'^\s{2}([\w-]+):\s*(.+?)\s*$', line)
971
+ if m:
972
+ sink[m.group(1)] = unquote(m.group(2))
866
973
  bg_rules, cur = {}, None
867
974
  for line in blocks.get('bg_rules', ('', []))[1]:
868
975
  # 键后面允许行内注释(草案会标「用它的页型:…」)
@@ -875,20 +982,53 @@ def build_layouts_md(layouts_blocks, canvas):
875
982
  bg_rules[cur].append(' ' + line.strip())
876
983
  out = ['---', 'canvas: %dx%d' % tuple(canvas)]
877
984
  # 禁放区是背景的属性,按背景写一次;页型只留 `background:` 指针。
878
- # 合并进每个页型会把同一句话复制 N 遍(实测某模板 14 个页型 14 条一字不差)。
985
+ # 合并进每个页型会把同一句话按页型数复制 N 遍。
879
986
  if bg_rules:
880
987
  out.append('backgrounds:')
881
988
  for bg, lines in bg_rules.items():
989
+ lines, err = shrink_safe_area(lines)
990
+ if err:
991
+ raise Fail('%s: %s' % (bg, err))
882
992
  out.append(' %s:' % bg)
883
993
  out += lines
884
994
  out.append('layouts:')
885
995
  for line in blocks['layouts'][1]:
996
+ # 判断单里的结构事实(栅格、间距序列、样张字数、命中配方)是给 L 层判断用的,
997
+ # 不进产物——消费端要的是结论,不是推导过程。
998
+ if line.lstrip().startswith('#'):
999
+ continue
886
1000
  out.append(line)
887
1001
  m = re.match(r'^ ([\w-]+):\s*$', line)
888
1002
  if m and m.group(1) in names:
889
1003
  out.append(' name: "%s"' % names.pop(m.group(1)))
1004
+ if m and m.group(1) in roles:
1005
+ out.append(' role: %s' % roles.pop(m.group(1)))
890
1006
  if names:
891
1007
  raise Fail('names 里这些页型在 layouts 下找不到:%s' % ', '.join(sorted(names)))
1008
+ if roles:
1009
+ raise Fail('roles 里这些页型在 layouts 下找不到:%s' % ', '.join(sorted(roles)))
1010
+ missing_role = [k for k in re.findall(r'^ ([\w-]+):\s*$', '\n'.join(blocks['layouts'][1]), re.M)
1011
+ if not re.search(r'^ %s:\s*$(?:\n(?! \S).*)*?\n role:' % re.escape(k),
1012
+ '\n'.join(out), re.M)]
1013
+ if missing_role:
1014
+ raise Fail('这些页型没有 role(在 layouts.yaml 的 roles 段填):%s' % ', '.join(missing_role))
1015
+ # flow 与 slots 二选一:两份都留下,消费端不知道该听哪份坐标——design.md 正文与
1016
+ # layouts.md 打架就是这么来的,别重演。
1017
+ both, cur, has = [], None, {}
1018
+ for line in blocks['layouts'][1]:
1019
+ m = re.match(r'^ ([\w-]+):\s*$', line)
1020
+ if m:
1021
+ if cur and has.get('flow') and has.get('slots'):
1022
+ both.append(cur)
1023
+ cur, has = m.group(1), {}
1024
+ continue
1025
+ m = re.match(r'^ (flow|slots):\s*$', line)
1026
+ if m:
1027
+ has[m.group(1)] = True
1028
+ if cur and has.get('flow') and has.get('slots'):
1029
+ both.append(cur)
1030
+ if both:
1031
+ raise Fail('这些页型同时留了 flow 和 slots,二选一删掉另一个:%s' % ', '.join(both))
892
1032
  declared = set(re.findall(r'^ background:\s*(\S+)\s*$',
893
1033
  '\n'.join(blocks['layouts'][1]), re.M))
894
1034
  stray = set(bg_rules) - declared
@@ -1032,6 +1172,12 @@ def main(argv=None):
1032
1172
  shapes = json.load(open(sp, encoding='utf-8'))['shapes'] if os.path.exists(sp) else []
1033
1173
  problems, checked = trace_check(pack, extract, shapes,
1034
1174
  derived_values, derived_tokens, factor)
1175
+ integrity = media_integrity(stage1, extract)
1176
+ if integrity:
1177
+ print('\n--- 媒体产物完整性 ---')
1178
+ for b in integrity:
1179
+ print(' FAIL ' + b)
1180
+ raise Fail('media-out 里的文件和 extract.json 的记录对不上(%d 处)' % len(integrity))
1035
1181
  print('\n--- 数值可追溯机检 ---')
1036
1182
  print(' 受检 hex %d / fontSize %d / slot box %d;derived 豁免 %d%s%s'
1037
1183
  % (checked['hex'], checked['size'], checked['box'], checked['exempt'],
@@ -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.
@@ -393,6 +393,9 @@ def walk_tree(el, ctx, out, path=(), xf=(1.0, 1.0, 0.0, 0.0), depth=0):
393
393
  blip = sp.find('p:blipFill/a:blip', NS)
394
394
  if blip is not None:
395
395
  rec['media'] = ctx.media_of(blip.get(R_EMBED)) or ctx.media_of(blip.get(R_LINK))
396
+ alpha = blip.find('a:alphaModFix', NS)
397
+ if alpha is not None and alpha.get('amt') is not None:
398
+ rec['opacity'] = round(int(alpha.get('amt')) / 100000.0, 6)
396
399
  svg = blip.find('a:extLst//asvg:svgBlip', NS)
397
400
  if svg is not None:
398
401
  rec['media_svg'] = ctx.media_of(svg.get(R_EMBED))
@@ -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 '')
@@ -646,11 +648,15 @@ def main():
646
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>')
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env python3
2
+ """Focused regression tests for deterministic picture compositing."""
3
+ import io
4
+ import os
5
+ import sys
6
+ import unittest
7
+
8
+ from PIL import Image
9
+
10
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
11
+
12
+ from extract import _draw_picture # noqa: E402
13
+
14
+
15
+ class _Archive:
16
+ def __init__(self, media, raw):
17
+ self.names = {media}
18
+ self.zip = self
19
+ self.raw = raw
20
+
21
+ def read(self, media):
22
+ if media not in self.names:
23
+ raise KeyError(media)
24
+ return self.raw
25
+
26
+
27
+ def _png(pixels):
28
+ image = Image.new('RGBA', (len(pixels), 1))
29
+ image.putdata(pixels)
30
+ output = io.BytesIO()
31
+ image.save(output, 'PNG')
32
+ return output.getvalue()
33
+
34
+
35
+ class BackgroundCompositeTest(unittest.TestCase):
36
+ def test_applies_flip_and_opacity_once(self):
37
+ media = 'ppt/media/source.png'
38
+ package = _Archive(media, _png([(255, 0, 0, 255), (0, 0, 255, 255)]))
39
+ canvas = Image.new('RGBA', (2, 1), (255, 255, 255, 255))
40
+
41
+ drawn = _draw_picture(canvas, {
42
+ 'media': media,
43
+ 'box_unrotated': {'x': 0, 'y': 0, 'w': 2, 'h': 1},
44
+ 'flipH': True,
45
+ 'opacity': 0.5,
46
+ }, package)
47
+
48
+ self.assertTrue(drawn)
49
+ left, right = canvas.getpixel((0, 0)), canvas.getpixel((1, 0))
50
+ self.assertEqual(left[:3], (128, 128, 255))
51
+ self.assertEqual(right[:3], (255, 128, 128))
52
+ self.assertEqual(left[3], 255)
53
+ self.assertEqual(right[3], 255)
54
+
55
+
56
+ if __name__ == '__main__':
57
+ unittest.main()
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env python3
2
+ """Regression tests for the consumer-facing layout slot CSS contract."""
3
+ import os
4
+ import sys
5
+ import tempfile
6
+ import unittest
7
+
8
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
9
+
10
+ from check_v2 import Pack, rule_v2_16 # noqa: E402
11
+ from draft import emit_layouts, slot_style # noqa: E402
12
+ from package import FONTSIZE_RE, build_layouts_md, split_top_blocks # noqa: E402
13
+
14
+
15
+ class LayoutCssTest(unittest.TestCase):
16
+ def test_text_slot_emits_rendering_style_as_css_only(self):
17
+ shape = {
18
+ 'text': {
19
+ 'bodyPr': {
20
+ 'anchor': 'ctr',
21
+ 'insets_px': {'lIns': 12, 'tIns': 8, 'rIns': 10, 'bIns': 6},
22
+ 'rot': '900000',
23
+ },
24
+ 'lstStyle': {
25
+ 'lvl1pPr': {
26
+ 'sz_px': 48,
27
+ 'weight': 600,
28
+ 'italic': True,
29
+ 'underline': 'sng',
30
+ 'strike': 'sngStrike',
31
+ 'spc_px': 1.5,
32
+ 'color': {'resolved': '#123456'},
33
+ 'algn': 'ctr',
34
+ 'lnSpc': {'mult': 1.0},
35
+ },
36
+ },
37
+ 'paragraphs': [],
38
+ },
39
+ }
40
+ slot = {
41
+ 'role': 'title',
42
+ 'type': 'title',
43
+ 'box': [100, 80, 800, 160],
44
+ 'sz': 48,
45
+ 'txt': '标题',
46
+ }
47
+ slot.update(slot_style(shape))
48
+ archetype = {
49
+ 'name': 'layout-1',
50
+ 'zh': '标题页',
51
+ 'role': 'content',
52
+ 'bg': None,
53
+ 'slots': [slot],
54
+ 'decor': [],
55
+ 'pages': [1],
56
+ 'rep': 1,
57
+ 'pic_n': 0,
58
+ 'confidence': 'high',
59
+ }
60
+
61
+ with tempfile.TemporaryDirectory() as output_dir:
62
+ emit_layouts([archetype], output_dir)
63
+ with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
64
+ layouts_yaml = stream.read()
65
+ layouts_md = build_layouts_md(split_top_blocks(layouts_yaml), (1920, 1080))
66
+
67
+ self.assertIn(
68
+ 'css: "box-sizing: border-box; padding: 8px 10px 6px 12px; '
69
+ 'font-size: 48px; font-weight: 600; font-style: italic; '
70
+ 'text-decoration: underline line-through; letter-spacing: 1.5px; color: #123456; '
71
+ 'text-align: center; line-height: 1.2; display: flex; '
72
+ 'flex-direction: column; justify-content: center; rotate: 15deg"',
73
+ layouts_md,
74
+ )
75
+ for legacy_key in ('size', 'weight', 'color', 'align', 'valign', 'insets_px'):
76
+ self.assertNotRegex(layouts_md, rf'[,{{]\s*{legacy_key}:')
77
+ self.assertEqual(FONTSIZE_RE.findall(layouts_md), ['48'])
78
+
79
+ def test_layout_gate_rejects_legacy_slot_style_keys(self):
80
+ with tempfile.TemporaryDirectory() as pack_dir:
81
+ with open(os.path.join(pack_dir, 'design.md'), 'w', encoding='utf-8') as stream:
82
+ stream.write('---\nversion: alpha\nlayouts: layouts.md\n---\n\nRead layouts.md.\n')
83
+ with open(os.path.join(pack_dir, 'layouts.md'), 'w', encoding='utf-8') as stream:
84
+ stream.write(
85
+ '---\ncanvas: 1920x1080\nlayouts:\n cover:\n role: cover\n'
86
+ ' slots:\n - {role: title, type: title, box: [0, 0, 800, 100], '
87
+ 'size: 48, align: center}\n confidence: high\n---\n'
88
+ )
89
+
90
+ result = rule_v2_16(Pack(pack_dir))
91
+
92
+ self.assertEqual(result.level, 'FAIL')
93
+ self.assertEqual(len(result.fails), 1)
94
+ self.assertIn('旧样式键 align/size', result.fails[0])
95
+
96
+
97
+ if __name__ == '__main__':
98
+ unittest.main()
@@ -16,7 +16,7 @@
16
16
 
17
17
  - 消费方直接读 design.md,靠目录约定找 sidecar 与资产;frontmatter `path` 是唯一文件引用点。
18
18
  - manifest(`manifest.json`,识别靠内部 `schemaVersion` 字面量不靠文件名)服务存储、索引、校验和迁移;消费模型不需要读它。
19
- - `ref/` 只放 `audit.yaml`(数值出处的人工复核记录,几 KB)。频次原表、聚类原始数据、`extract.json`、重建图、logo 候选图**留在抽取工作目录,不进交付包**——原设计是「下发时链路剥离」,实测没有环节做剥离,结果几 MB 审计材料带着「别读我」一起进消费上下文(某样本包 4.2MB 里 2.6MB 是它)。
19
+ - `ref/` 只放 `audit.yaml`(数值出处的人工复核记录,几 KB)。频次原表、聚类原始数据、`extract.json`、重建图、logo 候选图**留在抽取工作目录,不进交付包**——原设计是「下发时链路剥离」,但链路上没有环节真的做剥离,审计材料会连带进消费上下文并占掉包体的大头。
20
20
 
21
21
  ## 1. design.md frontmatter 新增键
22
22
 
@@ -53,10 +53,10 @@ canvas: 1920x1080 # layouts.md 首键;px = round(EMU / sldSz_cx * 1920)
53
53
  themes: [dark, light]
54
54
  default-theme: dark # 双主题包必填(V2-13)
55
55
  colors: # v1 单层扁平,主题进 token 名
56
- dark-surface: "#0A0E1E"
57
- dark-on-surface: "#FFFFFF"
58
- light-surface: "#FFFFFF"
59
- primary: "#3C7FFF" # 共用色不加前缀
56
+ dark-surface: "#RRGGBB"
57
+ dark-on-surface: "#RRGGBB"
58
+ light-surface: "#RRGGBB"
59
+ primary: "#RRGGBB" # 共用色不加前缀
60
60
  ```
61
61
 
62
62
  - token 前缀约定(`dark-X`/`light-X` = 主题专属,无前缀 = 共用)在 `## Usage` 里向消费者写一句;主题机制溯源(clrMap 反转等)落 `ref/audit.yaml`。
@@ -65,7 +65,7 @@ colors: # v1 单层扁平,主题进 token 名
65
65
 
66
66
  ### 1.3 排除色
67
67
 
68
- - **排除色进 `## Hard Rules` 带证据计数**,正向给替代(如「`#FBAE40` 为编辑器参考线色(出现 72 次),非设计色」)。频次原表与 color-confidence 证据进 `ref/`。
68
+ - **排除色进 `## Hard Rules` 带证据计数**,正向给替代(如「`<hex>` 为编辑器参考线色(出现 <N> 次),非设计色」)。频次原表与 color-confidence 证据进 `ref/`。
69
69
  - 排除色断言必须以**解析后频次**为准,`styleRef` 主题兜底引用(不渲染)与真实设计用色分开。
70
70
 
71
71
  ## 2. `assets` 段
@@ -84,7 +84,7 @@ assets:
84
84
  kind: background
85
85
  role: cover # cover | content | section | closing | accent
86
86
  theme: dark
87
- recipe: "linear-gradient(135deg, #0A0E1E 0%, #12204A 100%)" # 可选:CSS 重绘配方
87
+ recipe: "linear-gradient(<angle>, <color> 0%, <color> 100%)" # 可选:CSS 重绘配方
88
88
  bg-content-solid: # 纯色背景:无 path/url,引 colors token
89
89
  kind: background
90
90
  role: content
@@ -123,13 +123,17 @@ layouts:
123
123
  themes: [dark, light] # 深浅孪生合并
124
124
  background: {dark: bg-cover-dark, light: bg-cover-light}
125
125
  slots:
126
- - {role: title, box: [110, 340, 970, 160], type: title}
127
- - {role: logo, box: [120, 121, 235, 74], asset: {dark: logo-on-dark, light: logo-on-light}}
126
+ - {role: title, box: [<x>, <y>, <w>, <h>], type: title, css: "<CSS 声明串>"}
127
+ - {role: logo, box: [<x>, <y>, <w>, <h>], asset: {dark: logo-on-dark, light: logo-on-light}}
128
+ decor:
129
+ - {box: [<x>, <y>, <w>, <h>], geom: ellipse, css: "<CSS 声明串>"}
128
130
  confidence: high
129
131
  ```
130
132
 
131
133
  - **`background` 三形态**:`<asset-id>` / `{<theme>: <asset-id>}` / `{color: <colors-token>}`(`color` 是保留键,主题名禁止叫 color)。`asset` 两形态:`<asset-id>` / `{<theme>: <asset-id>}`。
132
134
  - **背景安全扩展**:有真实背景图的 archetype 建议写 `text_safe: [x,y,w,h]`、`avoid: [{box: [x,y,w,h], reason: "..."}]`、`pairing_rule: "..."`。这些是消费约束,不参与封闭枚举;用于避免标题、正文、图表、卡片、表格、时间线及其容器外接矩形覆盖背景视觉主体、强光斑或深色透明区;透明容器也不能跨进禁放区。
135
+ - **`decor`(可选)**:这一页无文字的图形骨架——图标托底的圆、卡片、分隔线。每条 `{box, geom, css}`:`box` 定位,`css` 是可直接写进 style 的声明串,`geom` 取源形状的 prst(`ellipse` 另加 `border-radius: 50%`)。层级在背景之上、`slots` 之下;带 `asset` 的槽落在 decor 之上是版式本意,不算重叠。
136
+ - **slot 样式契约**:`box` 只承载 `[x,y,w,h]` 几何;可渲染属性统一放进 `css`,并可直接写入 HTML `style`。PPTX `bodyPr.insets_px` 转成 `box-sizing: border-box; padding: ...`,字号/字重/颜色/水平与垂直对齐/行高/字距/旋转分别转成标准 CSS。禁止在 slot 中输出 `size` / `weight` / `color` / `align` / `valign` / `insets_px` 等旧字段。
133
137
  - **`type` 封闭枚举**:`title | subtitle | body | pic | table | chart | media | slide-number | footer`。大数字/序号走 `type: title`,语义由 `role`(如 `big-number`)承担。
134
138
  - **`slots.*.role` 开放不校验**(语义槽位):优先复用已知词表(OOXML ST_SlideLayoutType / Slidev 20 布局 / Google PredefinedLayout,如 big-number、caption、main-point),确无对应再自造。
135
139
  - archetype ≤15(内联降级形态 ≤11);深浅孪生合并为一条;版式溯源/母版取舍进 `ref/`。
@@ -139,14 +143,14 @@ layouts:
139
143
 
140
144
  ```yaml
141
145
  safe-area: # 开放命名 map,可多套边距体系
142
- content: {top: 214, right: 133, bottom: 77, left: 133, applies-to: [content, quote]}
143
- editorial: {left: 120, right: 120, applies-to: [cover, section, closing]}
146
+ content: {top: <px>, right: <px>, bottom: <px>, left: <px>, applies-to: [content, quote]}
147
+ editorial: {left: <px>, right: <px>, applies-to: [cover, section, closing]}
144
148
  confidence: medium
145
149
  ```
146
150
 
147
151
  冲突裁决:`slots.box` 是实例真值,`safe-area` 是归纳框架,**以 slots.box 为准**。
148
152
 
149
- ## 5. check_v2 校验(16 行:V2-1..V2-13 + V2-R5/R6/R7)
153
+ ## 5. check_v2 校验(19 行:V2-1..V2-16 + V2-R5/R6/R7)
150
154
 
151
155
  check_v1 全部规则原样生效。扫描范围 = 包目录,V2-1/V2-2 跨 design.md + layouts.md 求并集。
152
156
 
@@ -165,6 +169,9 @@ check_v1 全部规则原样生效。扫描范围 = 包目录,V2-1/V2-2 跨 des
165
169
  | V2-11 | 键名命中 YAML 1.1 布尔字面量 | FAIL |
166
170
  | V2-12 | `path`/`url`/`color` 恰好存在一个;`color` 仅 background 允许;`full` 仅可伴随 `path` | FAIL |
167
171
  | V2-13 | 多主题(`themes` 长度 >1)缺 `default-theme`(只看 design.md frontmatter——冲突以 design.md 为准) | WARN |
172
+ | V2-14 | 版式的 `flow` 与 `slots` 互斥(同时出现 = FAIL);`flow.regions[].kind` 在 `grid`/`stack`/`free` 内,`grid` 必带 `cols` | FAIL |
173
+ | V2-15 | 同段字段自洽:`backgrounds.*` 的 `text_safe` 不得与任一 `avoid` 相交;两者形态须为 `[x, y, w, h]` 四个数且 w/h 为正 | FAIL |
174
+ | V2-16 | slot/flow item 的渲染样式只通过 `css` 承载;出现 `size/weight/color/align/valign/insets_px` 旧键 | FAIL |
168
175
  | V2-R5 | sidecar frontmatter 重复 design.md 已有顶层键(`layouts` 载荷键与 `canvas` 除外——canvas 的家就在 sidecar) | WARN |
169
176
  | V2-R6 | assets 条目出现审计字段(boxes/aspect/mark/confidence)或 design.md 顶层出现 canvas/canvas-source/theme-mechanism/color-confidence——应移 `ref/audit.yaml` / layouts.md | WARN |
170
177
  | V2-R7 | 有 layouts sidecar 指针但正文未出现 `layouts.md` 字样(弱指针,消费者到不了版式数据) | WARN |