@lark-apaas/coding-steering 0.1.18-dev.857e860 → 0.1.18-dev.87ec805

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.
Files changed (20) hide show
  1. package/package.json +1 -1
  2. package/steering/design-html/skills/pptx-style-extract/SKILL.md +40 -18
  3. package/steering/design-html/skills/pptx-style-extract/font-fallback.yaml +3 -3
  4. package/steering/design-html/skills/pptx-style-extract/scripts/census.py +18 -12
  5. package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +153 -8
  6. package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +991 -185
  7. package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +319 -23
  8. package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +1 -1
  9. package/steering/design-html/skills/pptx-style-extract/scripts/package.py +165 -19
  10. package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +6 -3
  11. package/steering/design-html/skills/pptx-style-extract/scripts/query.py +4 -9
  12. package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +14 -8
  13. package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +57 -0
  14. package/steering/design-html/skills/pptx-style-extract/scripts/test_color_contract.py +58 -0
  15. package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +59 -0
  16. package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +98 -0
  17. package/steering/design-html/skills/pptx-style-extract/scripts/test_rounded_contract.py +112 -0
  18. package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +24 -15
  19. package/steering/design-html/skills/slide-deck/SKILL.md +15 -20
  20. package/steering/design-html/skills/slide-deck/scripts/check_local_references.py +179 -0
@@ -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))
@@ -244,14 +244,10 @@ def _recipe_css(fill, line, radii, effects):
244
244
  dash = (line or {}).get('dash')
245
245
  style = 'dashed' if dash and 'dash' in dash else 'solid'
246
246
  out.append('border: %dpx %s %s' % (w, style, lc))
247
- if radii:
247
+ if radii and all(r >= 1 for r in radii):
248
248
  lo, hi = min(radii), max(radii)
249
249
  if abs(hi - lo) <= 0.5:
250
250
  out.append('border-radius: %gpx' % round(lo, 1))
251
- else:
252
- out.append('border-radius: %gpx\x00/* 源内 %g~%gpx 共 %d 档,归一档位由 L11 定 */'
253
- % (round(sum(radii) / len(radii), 1), round(lo, 1), round(hi, 1),
254
- len(set(round(r, 1) for r in radii))))
255
251
  for e in effects or []:
256
252
  if e.get('type') == 'outerShdw':
257
253
  col = _css_color(e.get('color')) or 'rgba(0,0,0,0.25)'
@@ -266,8 +262,8 @@ def _recipe_css(fill, line, radii, effects):
266
262
 
267
263
  def _sig(fill, line, effects):
268
264
  """分组键 = 填充 + 描边 + 效果。**不含圆角**——OOXML 圆角是 min(w,h) 的百分比,
269
- 同一配方在不同尺寸的卡上绝对 px 必然不同(feishu 三张 glassCard 实测 10.8/12.8/19.5),
270
- 把它计入键会把一个配方拆成三组;归一到哪一档是 L11 的判断,脚本只报区间。"""
265
+ 同一配方在不同尺寸的卡上绝对 px 必然不同,把它计入键会把一个配方拆成多组;
266
+ 只有组内每个形状都明确共享同一绝对半径时才输出组级圆角,否则留给逐形状 CSS。"""
271
267
  f = 'none'
272
268
  if isinstance(fill, dict):
273
269
  if fill.get('type') == 'solid':
@@ -305,8 +301,7 @@ def cmd_recipes(a, outdir):
305
301
  g = groups.setdefault(k, {'n': 0, 'parts': Counter(), 'sizes': [], 'radii': [],
306
302
  'fill': fill, 'line': line, 'fx': fx})
307
303
  g['n'] += 1
308
- if radius:
309
- g['radii'].append(radius)
304
+ g['radii'].append(radius or 0)
310
305
  g['parts'][short(s['part'])] += 1
311
306
  b = s.get('box') or {}
312
307
  if b.get('w'):
@@ -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,58 @@
1
+ #!/usr/bin/env python3
2
+ """Regression test for the generated consumer-facing color contract."""
3
+ import os
4
+ import tempfile
5
+ import unittest
6
+
7
+ from draft import emit_body
8
+
9
+
10
+ class ColorContractTest(unittest.TestCase):
11
+ def test_body_allows_supporting_colors_without_a_second_accent_system(self):
12
+ data = {
13
+ 'canvas': {'px': [1920, 1080]},
14
+ 'counts': {'slides': 1},
15
+ 'form_hint': {'form': 3},
16
+ }
17
+ tokens = [
18
+ ('primary', {'hex': '#123456'}),
19
+ ('surface', {'hex': '#FFFFFF'}),
20
+ ]
21
+ fonts = [
22
+ {
23
+ 'names': ['Example Sans'],
24
+ 'stack': ['Example Sans', 'Noto Sans SC'],
25
+ },
26
+ ]
27
+ roles = {'body': {'sz_px': 36}}
28
+ archetypes = [
29
+ {
30
+ 'name': 'content',
31
+ 'slots': [],
32
+ },
33
+ ]
34
+
35
+ with tempfile.TemporaryDirectory() as output_dir:
36
+ emit_body(
37
+ data,
38
+ tokens,
39
+ fonts,
40
+ roles,
41
+ [],
42
+ archetypes,
43
+ [],
44
+ {},
45
+ output_dir,
46
+ )
47
+ with open(os.path.join(output_dir, 'body.md'), encoding='utf-8') as stream:
48
+ body = stream.read()
49
+
50
+ self.assertIn('允许新增中性色、低彩度辅助色或局部语义色', body)
51
+ self.assertRegex(body, r'正负.*风险.*警告.*状态.*图表序列')
52
+ self.assertIn('不得自行新增第二强调色', body)
53
+ self.assertIn('高饱和、高对比、大面积或跨页重复', body)
54
+ self.assertIn('标题、关键数字、图表主序列、卡片底色或渐变', body)
55
+
56
+
57
+ if __name__ == '__main__':
58
+ unittest.main()
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env python3
2
+ """Regression tests for consumer rules emitted into design.md."""
3
+ import os
4
+ import tempfile
5
+ import unittest
6
+
7
+ from draft import emit_body
8
+
9
+
10
+ class DesignConsumerContractTest(unittest.TestCase):
11
+ def test_generated_design_owns_template_consumption_rules(self):
12
+ data = {
13
+ 'canvas': {'px': [1920, 1080]},
14
+ 'counts': {'slides': 1},
15
+ 'form_hint': {'form': 3},
16
+ }
17
+ tokens = [('primary', {'hex': '#123456'})]
18
+ fonts = [
19
+ {
20
+ 'names': ['Example Sans'],
21
+ 'stack': ['Example Sans', 'Noto Sans SC'],
22
+ },
23
+ ]
24
+ roles = {'body': {'sz_px': 36}}
25
+ assets = [
26
+ {
27
+ 'id': 'bg-content',
28
+ 'kind': 'background',
29
+ 'role': 'content',
30
+ },
31
+ ]
32
+ archetypes = [{'name': 'content', 'slots': []}]
33
+
34
+ with tempfile.TemporaryDirectory() as output_dir:
35
+ emit_body(
36
+ data,
37
+ tokens,
38
+ fonts,
39
+ roles,
40
+ assets,
41
+ archetypes,
42
+ [],
43
+ {},
44
+ output_dir,
45
+ )
46
+ with open(os.path.join(output_dir, 'body.md'), encoding='utf-8') as stream:
47
+ body = stream.read()
48
+
49
+ self.assertIn('是全局 token', body)
50
+ self.assertIn('局部 slot / decor 的 `css` 优先', body)
51
+ self.assertIn('字体使用 Typography 的完整栈与降级', body)
52
+ self.assertIn('将包内 `assets/` 复制到项目内相对目录', body)
53
+ self.assertIn('本机绝对路径', body)
54
+ self.assertIn('背景、资产和本段规则均来自本风格包', body)
55
+ self.assertIn('页面无资源加载失败、内容溢出或画幅裁切', body)
56
+
57
+
58
+ if __name__ == '__main__':
59
+ unittest.main()