@lark-apaas/coding-steering 0.1.32-dev.942e73f → 0.1.32-dev.a230ed0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/coding-steering",
3
- "version": "0.1.32-dev.942e73f",
3
+ "version": "0.1.32-dev.a230ed0",
4
4
  "description": "Stack-specific steering content for miaoda-coding templates",
5
5
  "type": "module",
6
6
  "files": [
@@ -130,19 +130,19 @@ Read `<pack_dir>/design.md` first, especially `## Usage`, `## Hard Rules`, color
130
130
  When generating a deck:
131
131
 
132
132
  1. Call `copy_starter_component` with `kind: "deck-stage.js"`.
133
- 2. Build `<deck-stage width="<canvas width>" height="<canvas height>">` using the `canvas` declared in `layouts.md` — source decks are not always 16:9, and a default-sized stage shifts every coordinate on the page. Each slide is one static `<section data-pptx-layout="<chosen archetype>">`.
133
+ 2. Build `<deck-stage width="<canvas width>" height="<canvas height>">` using the `canvas` declared in `layouts.md` — source decks are not always 16:9, and a default-sized stage shifts every coordinate on the page. Each slide is one static `<section data-pptx-layout="<chosen archetype>">`. For a multi-theme pack, choose layouts compatible with `default-theme` by default. When a page intentionally switches theme, add `data-pptx-theme="<theme>"`; the theme must be listed by that layout's `themes`.
134
134
  3. Inline CSS variables from `design.md` into the HTML `<style>` block using a `--ppt-*` prefix.
135
135
  4. **位移动画用独立的 `translate` 属性**:`@keyframes fadeUp { from{opacity:0; translate:0 24px} to{opacity:1; translate:0 0} }`。`transform` 是单一属性,动画里碰它会覆盖掉元素原有的那条(`left:50%; transform:translateX(-50%)` 的居中就此丢失);`translate` / `rotate` / `scale` 各自独立,与已有 `transform` 叠加。
136
- 5. Map `layouts.md` slots to absolute-positioned elements inside each section: expand `box: [x,y,w,h]` mechanically to `left/top/width/height`, then apply the slot's `css` declaration string unchanged. `box` owns geometry; `css` owns all rendering style, including the template's text padding, typography, alignment, line height, letter spacing, and rotation. Do not reinterpret PPTX fields or replace slot CSS with your own type scale. A slot with `asset` is a fixed image element (logo, mark): place that asset at that box on that archetype only. Do not reflow any of this as generic web grids.
136
+ 5. Map `layouts.md` slots to absolute-positioned elements inside each section: expand `box: [x,y,w,h]` mechanically to `left/top/width/height`, then apply the slot's `css` declaration string unchanged. `box` owns geometry; `css` owns all rendering style, including the template's text padding, typography, alignment, line height, letter spacing, and rotation. Do not reinterpret PPTX fields or replace slot CSS with your own type scale. A slot with `asset` is a fixed image element: use that exact asset at that box on that archetype only; do not omit or replace it. Do not reflow any of this as generic web grids.
137
137
  6. Copy `<pack_dir>/assets/` into the project, for example `assets/pptx-style/<pack-name>/`, and reference those copied files with relative URLs. The scratch `tmp/pptx-style-extract/...` path must never appear in the final HTML.
138
138
  7. Use the font stacks and fallback rules from `design.md`; do not install fonts or dependencies at runtime.
139
- 8. Before the regular slide preflight, verify every template logo against the selected archetype:
139
+ 8. Before the regular slide preflight, verify every background and fixed image asset against the selected archetype:
140
140
 
141
141
  ```bash
142
- PYTHONDONTWRITEBYTECODE=1 python3 -B scripts/verify_logo_scope.py <pack_dir> <index.html> --asset-prefix <copied-assets-prefix>
142
+ PYTHONDONTWRITEBYTECODE=1 python3 -B scripts/verify_layout_assets.py <pack_dir> <index.html> --asset-prefix <copied-assets-prefix>
143
143
  ```
144
144
 
145
- `PPTX_LOGO_SCOPE: FAIL` means remove or move every listed logo according to `layouts.md`, then rerun this command.
145
+ `PPTX_LAYOUT_ASSETS: FAIL` means fix every reported instance according to `layouts.md`, then rerun the same command. Do not deliver or call `run_commit` until it prints `PPTX_LAYOUT_ASSETS: PASS`.
146
146
  9. Run the slide preflight checks: no resource failures, no section overflow, and sampled screenshots follow the package colors, typography, layouts, assets, and Hard Rules.
147
147
 
148
148
  ## Export Consumer Attachments
@@ -981,6 +981,13 @@ def background_decor(background, canvas):
981
981
  'trace': 'canvas-background'}
982
982
 
983
983
 
984
+ def background_identity(background):
985
+ """返回可用于聚类和审计的稳定背景标识,不改变背景的原始表达。"""
986
+ if isinstance(background, dict):
987
+ return json.dumps(background, sort_keys=True, separators=(',', ':'))
988
+ return background
989
+
990
+
984
991
  # ---------------------------------------------------------------- 版式聚类
985
992
  DECOR_MIN = 40.0
986
993
 
@@ -1236,7 +1243,21 @@ def layouts_from_template(d, shapes, cW, cH):
1236
1243
  sample_pages_of_layout = defaultdict(list)
1237
1244
  for slide_part, layout_part in lay_of_slide.items():
1238
1245
  sample_pages_of_layout[layout_part].append(slide_no(slide_part))
1246
+ sampled_theme_counts = Counter(
1247
+ theme_of_master.get(master_of.get(layout_part))
1248
+ for layout_part in lay_of_slide.values()
1249
+ )
1250
+ sampled_theme_counts.pop(None, None)
1239
1251
  default_theme = topo.get('default')
1252
+ if sampled_theme_counts:
1253
+ highest = max(sampled_theme_counts.values())
1254
+ leaders = sorted(
1255
+ theme for theme, count in sampled_theme_counts.items()
1256
+ if count == highest
1257
+ )
1258
+ if default_theme not in leaders:
1259
+ default_theme = leaders[0]
1260
+ topo['default'] = default_theme
1240
1261
  multi = len(topo.get('themes') or []) > 1
1241
1262
 
1242
1263
  rows = []
@@ -1539,16 +1560,19 @@ def draft_layouts(d, outdir, effective_alpha=None):
1539
1560
  image_marks = slide_image_marks(d, overlay_media)
1540
1561
 
1541
1562
  bg_of_slide, layout_of_slide = {}, {}
1542
- bg_of_layout = {row['part']: row.get('background') for row in d.get('layouts') or []}
1563
+ background_of_layout = {
1564
+ row['part']: row.get('background') for row in d.get('layouts') or []
1565
+ }
1543
1566
  for s in d.get('slides', []):
1544
1567
  bg = s.get('background')
1545
- bg_of_slide[s['part']] = json.dumps(bg, sort_keys=True) if isinstance(bg, dict) else bg
1568
+ bg_of_slide[s['part']] = background_identity(bg)
1546
1569
  layout_of_slide[s['part']] = s.get('layout')
1547
1570
  # 版式层的满屏底图(form=2 常态:底图挂在 layout 上)
1548
1571
  composites = d.get('background_composites') or {}
1572
+ bg_media_of_layout = {}
1549
1573
  for s in shapes:
1550
1574
  if s.get('layer') == 'layout' and is_bleed(s) and s.get('media'):
1551
- bg_of_layout[s['part']] = s['media']
1575
+ bg_media_of_layout[s['part']] = s['media']
1552
1576
 
1553
1577
  pages = []
1554
1578
  for part, sh in sorted(by_slide.items(), key=lambda kv: slide_no(kv[0])):
@@ -1556,7 +1580,7 @@ def draft_layouts(d, outdir, effective_alpha=None):
1556
1580
  layout_shapes = by_layout.get(layout_part) or []
1557
1581
  bg_media = top_bleed_media(sh)
1558
1582
  if bg_media is None:
1559
- bg_media = bg_of_layout.get(layout_part)
1583
+ bg_media = bg_media_of_layout.get(layout_part)
1560
1584
  rendered_bg = (composites.get(part)
1561
1585
  or composites.get(layout_part)
1562
1586
  or bg_media)
@@ -1602,7 +1626,7 @@ def draft_layouts(d, outdir, effective_alpha=None):
1602
1626
  marks.append(mark)
1603
1627
  background = next((s.get('background') for s in d.get('slides') or []
1604
1628
  if s.get('part') == part and s.get('background')), None)
1605
- background = background or bg_of_layout.get(layout_part)
1629
+ background = background or background_of_layout.get(layout_part)
1606
1630
  pages.append({'part': part, 'no': slide_no(part), 'bg_media': bg_media,
1607
1631
  'rendered_bg': rendered_bg,
1608
1632
  'bg_color': bg_of_slide.get(part), 'background': background,
@@ -1632,7 +1656,11 @@ def draft_layouts(d, outdir, effective_alpha=None):
1632
1656
  # 不替模型下结论。和首页一样,拆组只避免它被聚类代表页吞掉。
1633
1657
  groups[('__last__', -2)] = [p]
1634
1658
  continue
1635
- groups[(p['bg_media'] or p['bg_color'] or 'none', density_band(p))].append(p)
1659
+ background_key = background_identity(
1660
+ p.get('rendered_bg') or p.get('bg_media')
1661
+ or p.get('background') or p.get('bg_color')
1662
+ ) or 'none'
1663
+ groups[(background_key, density_band(p))].append(p)
1636
1664
 
1637
1665
  ranked = sorted(groups.items(), key=lambda kv: (-len(kv[1]), kv[1][0]['no']))
1638
1666
  # 首页所在的组一定收——deck 的第一页是模板的门面,孤例也不能被名额挤掉。
@@ -1654,15 +1682,9 @@ def draft_layouts(d, outdir, effective_alpha=None):
1654
1682
  leftover = sorted(p['no'] for g in ranked if g not in kept for p in g[1])
1655
1683
 
1656
1684
  archetypes = []
1657
- for gi, ((bg_raw, _band), ps) in enumerate(kept, 1):
1685
+ for gi, ((_background_key, _band), ps) in enumerate(kept, 1):
1658
1686
  rep = max(ps, key=lambda p: len(p['texts']))
1659
- if bg_raw == '__first__':
1660
- bg_raw = rep['bg_media'] or rep['bg_color'] or 'none'
1661
- elif bg_raw == '__last__':
1662
- bg_raw = rep['bg_media'] or rep['bg_color'] or 'none'
1663
- rendered_bg = rep.get('rendered_bg')
1664
- if rendered_bg:
1665
- bg_raw = rendered_bg
1687
+ bg_raw = rep.get('rendered_bg') or rep.get('bg_media')
1666
1688
  name = 'layout-%d' % gi
1667
1689
  # 标题按「位置 + 跨度」认,不按字号——big-number 类的巨号数值常比标题还大
1668
1690
  # 标题 = 该页最靠上的那批文本里最宽的一块。不按「画布前 28%」这类固定比例切:
@@ -1739,9 +1761,13 @@ def draft_layouts(d, outdir, effective_alpha=None):
1739
1761
  p['layout'] for p in ps if p.get('layout')
1740
1762
  }),
1741
1763
  '_source_backgrounds': sorted({
1742
- p.get('rendered_bg') or p.get('bg_media') or p.get('bg_color')
1764
+ background_identity(
1765
+ p.get('rendered_bg') or p.get('bg_media')
1766
+ or p.get('background') or p.get('bg_color')
1767
+ )
1743
1768
  for p in ps
1744
- if p.get('rendered_bg') or p.get('bg_media') or p.get('bg_color')
1769
+ if (p.get('rendered_bg') or p.get('bg_media')
1770
+ or p.get('background') or p.get('bg_color'))
1745
1771
  }),
1746
1772
  '_text_n': len(rep['texts']),
1747
1773
  '_last_page_candidate': rep['no'] == last_page_no,
@@ -2382,7 +2408,9 @@ def emit_manifest(d, assets, vision_groups, ldir, archetypes=()):
2382
2408
  ' TODO: 一句话说清这套模板的视觉性格(底色 / 主色 / 字形 / 版面骨架),给消费模型定调。']
2383
2409
  themes = d['theme_topology'].get('themes') or ['single']
2384
2410
  if themes != ['single'] and len(themes) > 1:
2385
- L += ['themes: [%s]' % ', '.join(themes), 'default-theme: %s' % themes[0]]
2411
+ default_theme = d['theme_topology'].get('default') or themes[0]
2412
+ L += ['themes: [%s]' % ', '.join(themes),
2413
+ 'default-theme: %s' % default_theme]
2386
2414
  if assets:
2387
2415
  L.append('assets:')
2388
2416
  for a in assets:
@@ -2909,6 +2937,8 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
2909
2937
  L.append(' %s:' % a['name'])
2910
2938
  if a.get('role'):
2911
2939
  L.append(' role: %s' % a['role'])
2940
+ if a.get('theme'):
2941
+ L.append(' themes: [%s]' % a['theme'])
2912
2942
  if a['bg']:
2913
2943
  L.append(' background: %s' % a['bg'])
2914
2944
  fl = a.get('flow')
@@ -3044,7 +3074,9 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
3044
3074
  '页数多于页型时,挑最接近的一个原样套用它的 slot:用不到的槽删掉,'
3045
3075
  '内容比槽多就按同类槽的间距等距加,**坐标一律沿用该页型给的那套,不要自己另起网格**。'
3046
3076
  '每个生成页面的 `<section>` 都写 `data-pptx-layout="<页型名>"`,'
3047
- '交付前据此核验 logo 只出现在该页型的资产槽中。' % sidecar,
3077
+ '多主题包默认只选兼容 `default-theme` 的页型;确需切换时,该页同时写 '
3078
+ '`data-pptx-theme="<主题名>"`,且主题必须属于该页型的 `themes`。'
3079
+ '交付前据此核验该页型绑定的背景与图片资产均已使用,且没有跨页型误用。' % sidecar,
3048
3080
  '3. **按页型给的形态落元素** —— 页型给 `flow` 就用流式,给 `slots` 就用绝对,'
3049
3081
  '两者只会出现一个。'
3050
3082
  '**flow**:整块用一个纵向 flex 容器,`top` 是它的起始 y,`margin` 是整块的左右边距,'
@@ -3063,8 +3095,13 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
3063
3095
  '`[x, y, w, h]`(%dx%d 画布上的绝对像素),机械展开成 `left/top/width/height`;'
3064
3096
  'slot 的 `css` 是模板排版属性已转译好的声明串,原样写进 style,不要另选字号、'
3065
3097
  '内边距、颜色或对齐。'
3066
- '带 `asset` 的 slot 是图片元素(logo、角标),把该资产放在它自己的 `box` 里;'
3067
- '这个页型没有 `asset` 槽,这一页就不出现该资产。' % (canvas[0], canvas[1]),
3098
+ '带 `asset` 的 slot 是固定图片实例:元素写 `data-pptx-asset="<asset id>"`,'
3099
+ '引用复制后的原资产,并把 `box` 直接写成 inline '
3100
+ '`position:absolute;left:<x>px;top:<y>px;width:<w>px;height:<h>px`。'
3101
+ '元素必须可见,不得省略或换图,也不得隐藏或只在 CSS 里伪装引用;'
3102
+ '这个页型没有 `asset` 槽,这一页就不出现该资产。'
3103
+ '页型的 `background` 是图片资产时遵循同一实例契约,`box` 使用全画布 '
3104
+ '`[0, 0, %d, %d]`。' % (canvas[0], canvas[1], canvas[0], canvas[1]),
3068
3105
  '5. **铺装饰几何** —— 页型的 `decor` 是这一页的图形骨架(图标托底的圆、'
3069
3106
  '卡片、分隔线):每条渲染成一个绝对定位空元素,`box` 给位置,`css` 逐项原样写进 '
3070
3107
  'style;没有 `border-radius` 就按 `0`。只有 `geom: ellipse` 另加 '
@@ -61,7 +61,15 @@ class DesignConsumerContractTest(unittest.TestCase):
61
61
  self.assertIn('确认全部页型后再开始搭页', body)
62
62
  self.assertIn('不能只看摘要、前几个页型', body)
63
63
  self.assertIn('data-pptx-layout="<页型名>"', body)
64
- self.assertIn('交付前据此核验 logo 只出现在该页型的资产槽中', body)
64
+ self.assertIn('data-pptx-asset="<asset id>"', body)
65
+ self.assertIn(
66
+ 'position:absolute;left:<x>px;top:<y>px;width:<w>px;height:<h>px',
67
+ body,
68
+ )
69
+ self.assertIn('元素必须可见', body)
70
+ self.assertIn('`box` 使用全画布 `[0, 0, 1920, 1080]`', body)
71
+ self.assertIn('该页型绑定的背景与图片资产均已使用', body)
72
+ self.assertIn('不得省略或换图', body)
65
73
 
66
74
 
67
75
  if __name__ == '__main__':
@@ -15,6 +15,7 @@ from draft import (asset_vision_contexts, bound_visual_candidates, # noqa: E402
15
15
  cover_background_media,
16
16
  emit_asset_vision_groups, emit_layouts, emit_manifest,
17
17
  fullscreen_overlay_media,
18
+ layouts_from_template,
18
19
  needs_asset_judgment, preserve_image_bearing_groups,
19
20
  select_asset_vision_groups, slide_image_marks, slot_style,
20
21
  visual_slot_candidates)
@@ -24,6 +25,125 @@ from package import (FONTSIZE_RE, Fail, apply_asset_decisions, # noqa: E402
24
25
 
25
26
 
26
27
  class LayoutCssTest(unittest.TestCase):
28
+ def test_template_layouts_use_sampled_theme_as_default_and_keep_theme_scope(self):
29
+ dark_master = 'ppt/slideMasters/slideMaster1.xml'
30
+ light_master = 'ppt/slideMasters/slideMaster2.xml'
31
+ dark_layout = 'ppt/slideLayouts/slideLayout1.xml'
32
+ light_layout = 'ppt/slideLayouts/slideLayout2.xml'
33
+ data = {
34
+ 'layouts': [
35
+ {'part': dark_layout, 'name': 'Dark content', 'used_by_slides': 2},
36
+ {'part': light_layout, 'name': 'Light content', 'used_by_slides': 0},
37
+ ],
38
+ 'slides': [],
39
+ 'theme_topology': {
40
+ 'themes': ['light', 'dark'],
41
+ 'default': 'light',
42
+ 'per_master': [
43
+ {'master': dark_master, 'theme_label': 'dark'},
44
+ {'master': light_master, 'theme_label': 'light'},
45
+ ],
46
+ },
47
+ 'reference_graph': {
48
+ 'master_of_layout': {
49
+ dark_layout: dark_master,
50
+ light_layout: light_master,
51
+ },
52
+ 'layout_of_slide': {
53
+ 'ppt/slides/slide1.xml': dark_layout,
54
+ 'ppt/slides/slide2.xml': dark_layout,
55
+ },
56
+ },
57
+ 'background_composites': {},
58
+ }
59
+ shapes = [
60
+ {
61
+ 'part': part,
62
+ 'layer': 'layout',
63
+ 'kind': 'sp',
64
+ 'box': {'x': 100, 'y': 100, 'w': 800, 'h': 100},
65
+ 'ph': {'type': 'title', 'idx': '1'},
66
+ 'text': {'paragraphs': [{'runs': [{'text': 'Title', 'sz_px': 40}]}]},
67
+ }
68
+ for part in (dark_layout, light_layout)
69
+ ]
70
+
71
+ archetypes = layouts_from_template(data, shapes, 1920, 1080)
72
+
73
+ self.assertEqual('dark', data['theme_topology']['default'])
74
+ self.assertEqual(
75
+ {'content': 'dark', 'content-2': 'light'},
76
+ {item['name']: item['theme'] for item in archetypes},
77
+ )
78
+ with tempfile.TemporaryDirectory() as output_dir:
79
+ emit_layouts(archetypes, output_dir)
80
+ emit_manifest(data, [], [], output_dir, archetypes)
81
+ with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
82
+ layouts = stream.read()
83
+ with open(os.path.join(output_dir, 'manifest.yaml'), encoding='utf-8') as stream:
84
+ manifest = stream.read()
85
+ self.assertIn(' themes: [dark]', layouts)
86
+ self.assertIn(' themes: [light]', layouts)
87
+ self.assertIn('default-theme: dark', manifest)
88
+
89
+ def test_template_twin_layouts_keep_topology_but_scope_retained_layout(self):
90
+ dark_master = 'ppt/slideMasters/slideMaster1.xml'
91
+ light_master = 'ppt/slideMasters/slideMaster2.xml'
92
+ dark_layout = 'ppt/slideLayouts/slideLayout1.xml'
93
+ light_layout = 'ppt/slideLayouts/slideLayout2.xml'
94
+ data = {
95
+ 'layouts': [
96
+ {'part': dark_layout, 'name': 'Content', 'used_by_slides': 2},
97
+ {'part': light_layout, 'name': 'Content', 'used_by_slides': 0},
98
+ ],
99
+ 'slides': [],
100
+ 'theme_topology': {
101
+ 'themes': ['light', 'dark'],
102
+ 'default': 'light',
103
+ 'per_master': [
104
+ {'master': dark_master, 'theme_label': 'dark'},
105
+ {'master': light_master, 'theme_label': 'light'},
106
+ ],
107
+ },
108
+ 'reference_graph': {
109
+ 'master_of_layout': {
110
+ dark_layout: dark_master,
111
+ light_layout: light_master,
112
+ },
113
+ 'layout_of_slide': {
114
+ 'ppt/slides/slide1.xml': dark_layout,
115
+ 'ppt/slides/slide2.xml': dark_layout,
116
+ },
117
+ },
118
+ 'background_composites': {},
119
+ }
120
+ shapes = [
121
+ {
122
+ 'part': part,
123
+ 'layer': 'layout',
124
+ 'kind': 'sp',
125
+ 'box': {'x': 100, 'y': 100, 'w': 800, 'h': 100},
126
+ 'ph': {'type': 'title', 'idx': '1'},
127
+ 'text': {'paragraphs': [{'runs': [{'text': 'Title', 'sz_px': 40}]}]},
128
+ }
129
+ for part in (dark_layout, light_layout)
130
+ ]
131
+
132
+ archetypes = layouts_from_template(data, shapes, 1920, 1080)
133
+
134
+ self.assertEqual(1, len(archetypes))
135
+ self.assertEqual('dark', archetypes[0]['theme'])
136
+ with tempfile.TemporaryDirectory() as output_dir:
137
+ emit_layouts(archetypes, output_dir)
138
+ emit_manifest(data, [], [], output_dir, archetypes)
139
+ with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
140
+ layouts = stream.read()
141
+ with open(os.path.join(output_dir, 'manifest.yaml'), encoding='utf-8') as stream:
142
+ manifest = stream.read()
143
+ self.assertIn(' themes: [dark]', layouts)
144
+ self.assertIn('themes: [light, dark]', manifest)
145
+ self.assertIn('default-theme: dark', manifest)
146
+
27
147
  def test_text_slot_emits_rendering_style_as_css_only(self):
28
148
  shape = {
29
149
  'text': {
@@ -1185,6 +1305,85 @@ layouts:
1185
1305
  self.assertIn('value: "[0, 0, 1920, 1080]"', manifest)
1186
1306
  self.assertIn('reason: "PPT 背景铺满画布"', manifest)
1187
1307
 
1308
+ def test_sampled_layouts_group_inherited_structured_background(self):
1309
+ layout_part = 'ppt/slideLayouts/slideLayout1.xml'
1310
+ slides = [{
1311
+ 'part': 'ppt/slides/slide%d.xml' % index,
1312
+ 'layout': layout_part,
1313
+ 'background': None,
1314
+ } for index in range(1, 4)]
1315
+ data = {
1316
+ 'canvas': {'px': [1920, 1080]},
1317
+ 'form_hint': {'form': 1},
1318
+ 'layouts': [{
1319
+ 'part': layout_part,
1320
+ 'name': '内容',
1321
+ 'used_by_slides': 3,
1322
+ }],
1323
+ 'slides': slides,
1324
+ 'images': [],
1325
+ 'media': [],
1326
+ 'theme_topology': {'themes': ['single'], 'per_master': [], 'default': 'single'},
1327
+ 'reference_graph': {
1328
+ 'master_of_layout': {},
1329
+ 'layout_of_slide': {
1330
+ slide['part']: layout_part for slide in slides
1331
+ },
1332
+ },
1333
+ 'background_composites': {},
1334
+ }
1335
+ shapes = [{
1336
+ 'part': slide['part'],
1337
+ 'layer': 'slide',
1338
+ 'kind': 'sp',
1339
+ 'box': {'x': 100, 'y': 100, 'w': 600, 'h': 100},
1340
+ 'ph': {'type': 'title', 'idx': '1'},
1341
+ 'text': {'paragraphs': [{'runs': [{
1342
+ 'text': 'Title %d' % index,
1343
+ 'sz_px': 40,
1344
+ }]}]},
1345
+ } for index, slide in enumerate(slides, 1)]
1346
+
1347
+ backgrounds = [{
1348
+ 'source': 'bgPr',
1349
+ 'type': 'solid',
1350
+ 'color': {'resolved': '#FAF9F5'},
1351
+ }, {
1352
+ 'type': 'gradient',
1353
+ 'stops': [
1354
+ {'pos': 0, 'color': {'resolved': '#4B5CF5'}},
1355
+ {'pos': 100, 'color': {'resolved': '#233AFB'}},
1356
+ ],
1357
+ 'angle_deg': 0,
1358
+ }]
1359
+ for background in backgrounds:
1360
+ with self.subTest(background=background['type']):
1361
+ data['layouts'][0]['background'] = background
1362
+ with tempfile.TemporaryDirectory() as output_dir:
1363
+ os.makedirs(os.path.join(output_dir, 'ref'))
1364
+ with open(os.path.join(output_dir, 'ref', 'shapes.json'), 'w',
1365
+ encoding='utf-8') as stream:
1366
+ import json
1367
+ json.dump({'shapes': shapes}, stream)
1368
+ archetypes, pages, _ = draft_layouts(
1369
+ data, output_dir, effective_alpha={})
1370
+
1371
+ self.assertEqual([1, 2, 3], sorted(
1372
+ page for archetype in archetypes for page in archetype['pages']))
1373
+ self.assertTrue(all(page['background'] == background for page in pages))
1374
+ self.assertTrue(all(archetype['bg_raw'] is None
1375
+ for archetype in archetypes))
1376
+ self.assertTrue(all(
1377
+ any(decor.get('trace') == 'canvas-background'
1378
+ for decor in archetype['decor'])
1379
+ for archetype in archetypes
1380
+ ))
1381
+ self.assertTrue(all(
1382
+ all(isinstance(value, str)
1383
+ for value in archetype['_source_backgrounds'])
1384
+ for archetype in archetypes
1385
+ ))
1386
+
1188
1387
  def test_asset_vision_groups_fail_when_page_screenshot_is_unreadable(self):
1189
1388
  candidates = [{
1190
1389
  'id': 'asset-1',