@lark-apaas/coding-steering 0.1.32-dev.6f4e4bc → 0.1.32-dev.7b2c2ad
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/steering/design-html/skills/pptx-style-extract/SKILL.md +2 -2
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +56 -19
- package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +7 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +199 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_logo_scope.py +308 -9
- package/steering/design-html/skills/pptx-style-extract/scripts/verify_layout_assets.py +262 -47
package/package.json
CHANGED
|
@@ -130,7 +130,7 @@ 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
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.
|
|
@@ -142,7 +142,7 @@ When generating a deck:
|
|
|
142
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_LAYOUT_ASSETS: FAIL` means
|
|
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
|
-
|
|
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']] =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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, ((
|
|
1685
|
+
for gi, ((_background_key, _band), ps) in enumerate(kept, 1):
|
|
1658
1686
|
rep = max(ps, key=lambda p: len(p['texts']))
|
|
1659
|
-
|
|
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
|
-
|
|
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')
|
|
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
|
-
|
|
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,6 +3074,8 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
|
|
|
3044
3074
|
'页数多于页型时,挑最接近的一个原样套用它的 slot:用不到的槽删掉,'
|
|
3045
3075
|
'内容比槽多就按同类槽的间距等距加,**坐标一律沿用该页型给的那套,不要自己另起网格**。'
|
|
3046
3076
|
'每个生成页面的 `<section>` 都写 `data-pptx-layout="<页型名>"`,'
|
|
3077
|
+
'多主题包默认只选兼容 `default-theme` 的页型;确需切换时,该页同时写 '
|
|
3078
|
+
'`data-pptx-theme="<主题名>"`,且主题必须属于该页型的 `themes`。'
|
|
3047
3079
|
'交付前据此核验该页型绑定的背景与图片资产均已使用,且没有跨页型误用。' % sidecar,
|
|
3048
3080
|
'3. **按页型给的形态落元素** —— 页型给 `flow` 就用流式,给 `slots` 就用绝对,'
|
|
3049
3081
|
'两者只会出现一个。'
|
|
@@ -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
|
|
3067
|
-
'
|
|
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` 另加 '
|
package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py
CHANGED
|
@@ -61,6 +61,13 @@ 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('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)
|
|
64
71
|
self.assertIn('该页型绑定的背景与图片资产均已使用', body)
|
|
65
72
|
self.assertIn('不得省略或换图', body)
|
|
66
73
|
|
|
@@ -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',
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Regression tests for template asset placement in generated deck HTML."""
|
|
3
3
|
import os
|
|
4
|
+
import shutil
|
|
4
5
|
import subprocess
|
|
5
6
|
import sys
|
|
6
7
|
import tempfile
|
|
@@ -12,7 +13,22 @@ from verify_layout_assets import validate_layout_assets
|
|
|
12
13
|
|
|
13
14
|
|
|
14
15
|
class LayoutAssetContractTest(unittest.TestCase):
|
|
16
|
+
def write_asset(self, root, relative_path, contents, copied_prefix):
|
|
17
|
+
source = os.path.join(root, 'assets', relative_path)
|
|
18
|
+
copied = os.path.join(root, copied_prefix, relative_path)
|
|
19
|
+
os.makedirs(os.path.dirname(source), exist_ok=True)
|
|
20
|
+
os.makedirs(os.path.dirname(copied), exist_ok=True)
|
|
21
|
+
with open(source, 'wb') as stream:
|
|
22
|
+
stream.write(contents)
|
|
23
|
+
shutil.copyfile(source, copied)
|
|
24
|
+
|
|
15
25
|
def write_pack(self, root):
|
|
26
|
+
self.write_asset(
|
|
27
|
+
root,
|
|
28
|
+
'logos/1.png',
|
|
29
|
+
b'logo',
|
|
30
|
+
'assets/pptx-volcengine',
|
|
31
|
+
)
|
|
16
32
|
with open(os.path.join(root, 'design.md'), 'w', encoding='utf-8') as stream:
|
|
17
33
|
stream.write(
|
|
18
34
|
'---\n'
|
|
@@ -50,6 +66,12 @@ class LayoutAssetContractTest(unittest.TestCase):
|
|
|
50
66
|
return path
|
|
51
67
|
|
|
52
68
|
def write_texture_pack(self, root):
|
|
69
|
+
self.write_asset(
|
|
70
|
+
root,
|
|
71
|
+
'textures/1.webp',
|
|
72
|
+
b'texture',
|
|
73
|
+
'assets/pptx-claude',
|
|
74
|
+
)
|
|
53
75
|
with open(os.path.join(root, 'design.md'), 'w', encoding='utf-8') as stream:
|
|
54
76
|
stream.write(
|
|
55
77
|
'---\n'
|
|
@@ -77,6 +99,12 @@ class LayoutAssetContractTest(unittest.TestCase):
|
|
|
77
99
|
)
|
|
78
100
|
|
|
79
101
|
def write_background_pack(self, root):
|
|
102
|
+
self.write_asset(
|
|
103
|
+
root,
|
|
104
|
+
'backgrounds/content.webp',
|
|
105
|
+
b'background',
|
|
106
|
+
'assets/pptx-claude',
|
|
107
|
+
)
|
|
80
108
|
with open(os.path.join(root, 'design.md'), 'w', encoding='utf-8') as stream:
|
|
81
109
|
stream.write(
|
|
82
110
|
'---\n'
|
|
@@ -100,6 +128,31 @@ class LayoutAssetContractTest(unittest.TestCase):
|
|
|
100
128
|
'---\n'
|
|
101
129
|
)
|
|
102
130
|
|
|
131
|
+
def write_theme_pack(self, root):
|
|
132
|
+
with open(os.path.join(root, 'design.md'), 'w', encoding='utf-8') as stream:
|
|
133
|
+
stream.write(
|
|
134
|
+
'---\n'
|
|
135
|
+
'themes: [dark, light]\n'
|
|
136
|
+
'default-theme: dark\n'
|
|
137
|
+
'layouts: layouts.md\n'
|
|
138
|
+
'---\n'
|
|
139
|
+
)
|
|
140
|
+
with open(os.path.join(root, 'layouts.md'), 'w', encoding='utf-8') as stream:
|
|
141
|
+
stream.write(
|
|
142
|
+
'---\n'
|
|
143
|
+
'canvas: 1920x1080\n'
|
|
144
|
+
'layouts:\n'
|
|
145
|
+
' dark-content:\n'
|
|
146
|
+
' role: content\n'
|
|
147
|
+
' themes: [dark]\n'
|
|
148
|
+
' slots: []\n'
|
|
149
|
+
' light-content:\n'
|
|
150
|
+
' role: content\n'
|
|
151
|
+
' themes: [light]\n'
|
|
152
|
+
' slots: []\n'
|
|
153
|
+
'---\n'
|
|
154
|
+
)
|
|
155
|
+
|
|
103
156
|
def test_logo_is_limited_to_every_archetype_with_its_slot(self):
|
|
104
157
|
with tempfile.TemporaryDirectory() as root:
|
|
105
158
|
self.write_pack(root)
|
|
@@ -107,10 +160,14 @@ class LayoutAssetContractTest(unittest.TestCase):
|
|
|
107
160
|
root,
|
|
108
161
|
'<deck-stage>'
|
|
109
162
|
'<section data-pptx-layout="cover">'
|
|
110
|
-
'<img
|
|
163
|
+
'<img data-pptx-asset="logo-1" '
|
|
164
|
+
'style="position:absolute;left:64px;top:64px;width:224px;height:48px" '
|
|
165
|
+
'src="assets/pptx-volcengine/logos/1.png"></section>'
|
|
111
166
|
'<section data-pptx-layout="content"><h1>正文</h1></section>'
|
|
112
167
|
'<section data-pptx-layout="closing">'
|
|
113
|
-
'<img
|
|
168
|
+
'<img data-pptx-asset="logo-1" '
|
|
169
|
+
'style="position:absolute;left:64px;top:64px;width:224px;height:48px" '
|
|
170
|
+
'src="assets/pptx-volcengine/logos/1.png"></section>'
|
|
114
171
|
'</deck-stage>',
|
|
115
172
|
)
|
|
116
173
|
|
|
@@ -126,7 +183,9 @@ class LayoutAssetContractTest(unittest.TestCase):
|
|
|
126
183
|
root,
|
|
127
184
|
'<deck-stage>'
|
|
128
185
|
'<section data-pptx-layout="content">'
|
|
129
|
-
'<img
|
|
186
|
+
'<img data-pptx-asset="logo-1" '
|
|
187
|
+
'style="position:absolute;left:64px;top:64px;width:224px;height:48px" '
|
|
188
|
+
'src="assets/pptx-volcengine/logos/1.png"></section>'
|
|
130
189
|
'</deck-stage>',
|
|
131
190
|
)
|
|
132
191
|
|
|
@@ -142,7 +201,9 @@ class LayoutAssetContractTest(unittest.TestCase):
|
|
|
142
201
|
html = self.write_html(
|
|
143
202
|
root,
|
|
144
203
|
'<deck-stage><section data-pptx-layout="cover">'
|
|
145
|
-
'<section class="content"><img
|
|
204
|
+
'<section class="content"><img data-pptx-asset="logo-1" '
|
|
205
|
+
'style="position:absolute;left:64px;top:64px;width:224px;height:48px" '
|
|
206
|
+
'src="assets/pptx-volcengine/logos/1.png">'
|
|
146
207
|
'</section></section></deck-stage>',
|
|
147
208
|
)
|
|
148
209
|
|
|
@@ -164,6 +225,102 @@ class LayoutAssetContractTest(unittest.TestCase):
|
|
|
164
225
|
validate_layout_assets(root, html, 'assets/pptx-volcengine'),
|
|
165
226
|
)
|
|
166
227
|
|
|
228
|
+
def test_missing_deck_slides_is_rejected_even_without_fixed_assets(self):
|
|
229
|
+
with tempfile.TemporaryDirectory() as root:
|
|
230
|
+
self.write_theme_pack(root)
|
|
231
|
+
html = self.write_html(root, '<main><section>普通页面</section></main>')
|
|
232
|
+
|
|
233
|
+
self.assertEqual(
|
|
234
|
+
['没有识别到 deck-stage 的直属 slide section,无法核验模板页型'],
|
|
235
|
+
validate_layout_assets(root, html, 'assets/pptx-theme'),
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
def test_layout_outside_default_theme_requires_explicit_theme(self):
|
|
239
|
+
with tempfile.TemporaryDirectory() as root:
|
|
240
|
+
self.write_theme_pack(root)
|
|
241
|
+
html = self.write_html(
|
|
242
|
+
root,
|
|
243
|
+
'<deck-stage>'
|
|
244
|
+
'<section data-pptx-layout="light-content">正文</section>'
|
|
245
|
+
'</deck-stage>',
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
self.assertEqual(
|
|
249
|
+
['第 1 页页型 light-content 不支持当前主题 dark;'
|
|
250
|
+
'允许主题: light。若确需切换,显式声明 data-pptx-theme'],
|
|
251
|
+
validate_layout_assets(root, html, 'assets/pptx-theme'),
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def test_explicit_non_default_theme_must_match_layout(self):
|
|
255
|
+
with tempfile.TemporaryDirectory() as root:
|
|
256
|
+
self.write_theme_pack(root)
|
|
257
|
+
html = self.write_html(
|
|
258
|
+
root,
|
|
259
|
+
'<deck-stage>'
|
|
260
|
+
'<section data-pptx-layout="light-content" '
|
|
261
|
+
'data-pptx-theme="light">正文</section>'
|
|
262
|
+
'</deck-stage>',
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
self.assertEqual(
|
|
266
|
+
[],
|
|
267
|
+
validate_layout_assets(root, html, 'assets/pptx-theme'),
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
def test_unknown_explicit_theme_is_rejected(self):
|
|
271
|
+
with tempfile.TemporaryDirectory() as root:
|
|
272
|
+
self.write_theme_pack(root)
|
|
273
|
+
html = self.write_html(
|
|
274
|
+
root,
|
|
275
|
+
'<deck-stage>'
|
|
276
|
+
'<section data-pptx-layout="dark-content" '
|
|
277
|
+
'data-pptx-theme="brand-new">正文</section>'
|
|
278
|
+
'</deck-stage>',
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
self.assertEqual(
|
|
282
|
+
['第 1 页声明了不存在的模板主题: brand-new'],
|
|
283
|
+
validate_layout_assets(root, html, 'assets/pptx-theme'),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def test_pack_without_fixed_assets_still_accepts_valid_layout(self):
|
|
287
|
+
with tempfile.TemporaryDirectory() as root:
|
|
288
|
+
self.write_theme_pack(root)
|
|
289
|
+
html = self.write_html(
|
|
290
|
+
root,
|
|
291
|
+
'<deck-stage>'
|
|
292
|
+
'<section data-pptx-layout="dark-content">正文</section>'
|
|
293
|
+
'</deck-stage>',
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
self.assertEqual(
|
|
297
|
+
[],
|
|
298
|
+
validate_layout_assets(root, html, 'assets/pptx-theme'),
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def test_non_mapping_layout_entry_does_not_crash_theme_validation(self):
|
|
302
|
+
with tempfile.TemporaryDirectory() as root:
|
|
303
|
+
self.write_theme_pack(root)
|
|
304
|
+
with open(os.path.join(root, 'layouts.md'), 'w', encoding='utf-8') as stream:
|
|
305
|
+
stream.write(
|
|
306
|
+
'---\n'
|
|
307
|
+
'canvas: 1920x1080\n'
|
|
308
|
+
'layouts:\n'
|
|
309
|
+
' malformed: unavailable\n'
|
|
310
|
+
'---\n'
|
|
311
|
+
)
|
|
312
|
+
html = self.write_html(
|
|
313
|
+
root,
|
|
314
|
+
'<deck-stage>'
|
|
315
|
+
'<section data-pptx-layout="malformed">正文</section>'
|
|
316
|
+
'</deck-stage>',
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
self.assertEqual(
|
|
320
|
+
[],
|
|
321
|
+
validate_layout_assets(root, html, 'assets/pptx-theme'),
|
|
322
|
+
)
|
|
323
|
+
|
|
167
324
|
def test_logo_in_global_css_is_rejected(self):
|
|
168
325
|
with tempfile.TemporaryDirectory() as root:
|
|
169
326
|
self.write_pack(root)
|
|
@@ -209,7 +366,7 @@ class LayoutAssetContractTest(unittest.TestCase):
|
|
|
209
366
|
self.assertEqual(1, len(problems))
|
|
210
367
|
self.assertIn('cover', problems[0])
|
|
211
368
|
self.assertIn('texture-1', problems[0])
|
|
212
|
-
self.assertIn('
|
|
369
|
+
self.assertIn('缺少固定实例', problems[0])
|
|
213
370
|
|
|
214
371
|
def test_layout_bound_texture_on_an_unowned_layout_is_rejected(self):
|
|
215
372
|
with tempfile.TemporaryDirectory() as root:
|
|
@@ -217,7 +374,9 @@ class LayoutAssetContractTest(unittest.TestCase):
|
|
|
217
374
|
html = self.write_html(
|
|
218
375
|
root,
|
|
219
376
|
'<deck-stage><section data-pptx-layout="content">'
|
|
220
|
-
'<img
|
|
377
|
+
'<img data-pptx-asset="texture-1" '
|
|
378
|
+
'style="position:absolute;left:1142px;top:0;width:778px;height:1080px" '
|
|
379
|
+
'src="assets/pptx-claude/textures/1.webp"></section></deck-stage>',
|
|
221
380
|
)
|
|
222
381
|
|
|
223
382
|
problems = validate_layout_assets(root, html, 'assets/pptx-claude')
|
|
@@ -233,6 +392,7 @@ class LayoutAssetContractTest(unittest.TestCase):
|
|
|
233
392
|
html = self.write_html(
|
|
234
393
|
root,
|
|
235
394
|
'<deck-stage><section data-pptx-layout="content" '
|
|
395
|
+
'data-pptx-asset="bg-content-1" '
|
|
236
396
|
'style="background-image:url('
|
|
237
397
|
'\'assets/pptx-claude/backgrounds/content.webp?v=2#slide\')">'
|
|
238
398
|
'正文</section></deck-stage>',
|
|
@@ -261,14 +421,15 @@ class LayoutAssetContractTest(unittest.TestCase):
|
|
|
261
421
|
html = self.write_html(
|
|
262
422
|
root,
|
|
263
423
|
'<deck-stage><section data-pptx-layout="cover">'
|
|
264
|
-
'<img
|
|
424
|
+
'<img data-pptx-asset="texture-1" '
|
|
425
|
+
'style="position:absolute;left:0;top:0;width:100px;height:100px" '
|
|
426
|
+
'src="assets/pptx-claude/textures/1.webp"></section></deck-stage>',
|
|
265
427
|
)
|
|
266
428
|
|
|
267
429
|
problems = validate_layout_assets(root, html, 'assets/pptx-claude')
|
|
268
430
|
|
|
269
431
|
self.assertEqual(1, len(problems))
|
|
270
|
-
self.assertIn('
|
|
271
|
-
self.assertIn('缺少 1 处', problems[0])
|
|
432
|
+
self.assertIn('位置尺寸应为 [1800, 980, 100, 100]', problems[0])
|
|
272
433
|
|
|
273
434
|
def test_legacy_logo_scope_entrypoint_keeps_failure_exit_code(self):
|
|
274
435
|
with tempfile.TemporaryDirectory() as root:
|
|
@@ -296,6 +457,144 @@ class LayoutAssetContractTest(unittest.TestCase):
|
|
|
296
457
|
self.assertEqual(1, result.returncode)
|
|
297
458
|
self.assertIn('PPTX_LAYOUT_ASSETS: FAIL', result.stdout)
|
|
298
459
|
|
|
460
|
+
def test_copied_asset_must_keep_the_source_pptx_bytes(self):
|
|
461
|
+
with tempfile.TemporaryDirectory() as root:
|
|
462
|
+
self.write_texture_pack(root)
|
|
463
|
+
copied = os.path.join(
|
|
464
|
+
root, 'assets', 'pptx-claude', 'textures', '1.webp')
|
|
465
|
+
with open(copied, 'wb') as stream:
|
|
466
|
+
stream.write(b'generated replacement')
|
|
467
|
+
html = self.write_html(
|
|
468
|
+
root,
|
|
469
|
+
'<deck-stage><section data-pptx-layout="cover">'
|
|
470
|
+
'<img data-pptx-asset="texture-1" '
|
|
471
|
+
'style="position:absolute;left:1142px;top:0;width:778px;height:1080px" '
|
|
472
|
+
'src="assets/pptx-claude/textures/1.webp"></section></deck-stage>',
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
problems = validate_layout_assets(root, html, 'assets/pptx-claude')
|
|
476
|
+
|
|
477
|
+
self.assertEqual(1, len(problems))
|
|
478
|
+
self.assertIn('已被替换或改写', problems[0])
|
|
479
|
+
|
|
480
|
+
def test_fixed_asset_must_keep_its_layout_box(self):
|
|
481
|
+
with tempfile.TemporaryDirectory() as root:
|
|
482
|
+
self.write_texture_pack(root)
|
|
483
|
+
html = self.write_html(
|
|
484
|
+
root,
|
|
485
|
+
'<deck-stage><section data-pptx-layout="cover">'
|
|
486
|
+
'<img data-pptx-asset="texture-1" '
|
|
487
|
+
'style="position:absolute;left:0;top:0;width:778px;height:1080px" '
|
|
488
|
+
'src="assets/pptx-claude/textures/1.webp"></section></deck-stage>',
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
problems = validate_layout_assets(root, html, 'assets/pptx-claude')
|
|
492
|
+
|
|
493
|
+
self.assertEqual(1, len(problems))
|
|
494
|
+
self.assertIn('位置尺寸必须为 [1142, 0, 778, 1080]', problems[0])
|
|
495
|
+
|
|
496
|
+
def test_fixed_asset_cannot_be_hidden(self):
|
|
497
|
+
with tempfile.TemporaryDirectory() as root:
|
|
498
|
+
self.write_texture_pack(root)
|
|
499
|
+
html = self.write_html(
|
|
500
|
+
root,
|
|
501
|
+
'<deck-stage><section data-pptx-layout="cover">'
|
|
502
|
+
'<div hidden><img data-pptx-asset="texture-1" '
|
|
503
|
+
'style="position:absolute;left:1142px;top:0;width:778px;height:1080px" '
|
|
504
|
+
'src="assets/pptx-claude/textures/1.webp"></div>'
|
|
505
|
+
'</section></deck-stage>',
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
problems = validate_layout_assets(root, html, 'assets/pptx-claude')
|
|
509
|
+
|
|
510
|
+
self.assertEqual(1, len(problems))
|
|
511
|
+
self.assertIn('不可隐藏', problems[0])
|
|
512
|
+
|
|
513
|
+
def test_zero_sized_fixed_asset_is_hidden(self):
|
|
514
|
+
with tempfile.TemporaryDirectory() as root:
|
|
515
|
+
self.write_texture_pack(root)
|
|
516
|
+
html = self.write_html(
|
|
517
|
+
root,
|
|
518
|
+
'<deck-stage><section data-pptx-layout="cover">'
|
|
519
|
+
'<img data-pptx-asset="texture-1" '
|
|
520
|
+
'style="position:absolute;left:1142px;top:0;width:0;height:1080px" '
|
|
521
|
+
'src="assets/pptx-claude/textures/1.webp"></section></deck-stage>',
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
problems = validate_layout_assets(root, html, 'assets/pptx-claude')
|
|
525
|
+
|
|
526
|
+
self.assertEqual(2, len(problems))
|
|
527
|
+
self.assertTrue(any('不可隐藏' in problem for problem in problems))
|
|
528
|
+
self.assertTrue(any('位置尺寸必须为' in problem for problem in problems))
|
|
529
|
+
|
|
530
|
+
def test_fixed_asset_reference_requires_an_instance_marker(self):
|
|
531
|
+
with tempfile.TemporaryDirectory() as root:
|
|
532
|
+
self.write_texture_pack(root)
|
|
533
|
+
html = self.write_html(
|
|
534
|
+
root,
|
|
535
|
+
'<deck-stage><section data-pptx-layout="cover">'
|
|
536
|
+
'<img style="position:absolute;left:1142px;top:0;width:778px;height:1080px" '
|
|
537
|
+
'src="assets/pptx-claude/textures/1.webp"></section></deck-stage>',
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
problems = validate_layout_assets(root, html, 'assets/pptx-claude')
|
|
541
|
+
|
|
542
|
+
self.assertEqual(2, len(problems))
|
|
543
|
+
self.assertTrue(any(
|
|
544
|
+
'缺少 data-pptx-asset' in problem for problem in problems))
|
|
545
|
+
self.assertTrue(any(
|
|
546
|
+
'缺少固定实例 texture-1' in problem for problem in problems))
|
|
547
|
+
|
|
548
|
+
def test_fixed_asset_cannot_hide_a_generated_source_behind_the_original_url(self):
|
|
549
|
+
with tempfile.TemporaryDirectory() as root:
|
|
550
|
+
self.write_texture_pack(root)
|
|
551
|
+
html = self.write_html(
|
|
552
|
+
root,
|
|
553
|
+
'<deck-stage><section data-pptx-layout="cover">'
|
|
554
|
+
'<img data-pptx-asset="texture-1" '
|
|
555
|
+
'style="position:absolute;left:1142px;top:0;width:778px;height:1080px;'
|
|
556
|
+
'background-image:url(\'assets/pptx-claude/textures/1.webp\')" '
|
|
557
|
+
'src="assets/generated/replacement.webp"></section></deck-stage>',
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
problems = validate_layout_assets(root, html, 'assets/pptx-claude')
|
|
561
|
+
|
|
562
|
+
self.assertEqual(1, len(problems))
|
|
563
|
+
self.assertIn('未引用对应的 PPTX 原素材', problems[0])
|
|
564
|
+
|
|
565
|
+
def test_fixed_asset_nested_in_flow_is_checked_at_its_box(self):
|
|
566
|
+
with tempfile.TemporaryDirectory() as root:
|
|
567
|
+
self.write_texture_pack(root)
|
|
568
|
+
with open(os.path.join(root, 'layouts.md'), 'w', encoding='utf-8') as stream:
|
|
569
|
+
stream.write(
|
|
570
|
+
'---\n'
|
|
571
|
+
'canvas: 1920x1080\n'
|
|
572
|
+
'layouts:\n'
|
|
573
|
+
' cover:\n'
|
|
574
|
+
' role: cover\n'
|
|
575
|
+
' flow:\n'
|
|
576
|
+
' top: 80\n'
|
|
577
|
+
' margin: [80, 80]\n'
|
|
578
|
+
' gap: 32\n'
|
|
579
|
+
' regions:\n'
|
|
580
|
+
' - kind: free\n'
|
|
581
|
+
' items:\n'
|
|
582
|
+
' - {role: texture, type: pic, box: [1142, 0, 778, 1080], asset: texture-1}\n'
|
|
583
|
+
'---\n'
|
|
584
|
+
)
|
|
585
|
+
html = self.write_html(
|
|
586
|
+
root,
|
|
587
|
+
'<deck-stage><section data-pptx-layout="cover">'
|
|
588
|
+
'<img data-pptx-asset="texture-1" '
|
|
589
|
+
'style="position:absolute;left:1142px;top:0;width:778px;height:1080px" '
|
|
590
|
+
'src="assets/pptx-claude/textures/1.webp"></section></deck-stage>',
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
self.assertEqual(
|
|
594
|
+
[],
|
|
595
|
+
validate_layout_assets(root, html, 'assets/pptx-claude'),
|
|
596
|
+
)
|
|
597
|
+
|
|
299
598
|
|
|
300
599
|
if __name__ == '__main__':
|
|
301
600
|
unittest.main()
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Verify that generated slides honor every asset bound to their PPTX layout."""
|
|
3
3
|
import argparse
|
|
4
|
+
import hashlib
|
|
5
|
+
import os
|
|
4
6
|
import posixpath
|
|
5
7
|
import re
|
|
6
8
|
import sys
|
|
@@ -40,14 +42,12 @@ def bound_asset_ids(value, known_asset_ids):
|
|
|
40
42
|
|
|
41
43
|
def layout_asset_contract(pack):
|
|
42
44
|
known_asset_ids = set(pack.assets)
|
|
43
|
-
required = {}
|
|
44
45
|
owners = {}
|
|
45
46
|
for layout_name, (layout, _) in pack.layouts.items():
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
for asset_id in asset_counts:
|
|
47
|
+
for asset_id, _, _ in layout_asset_instances(
|
|
48
|
+
layout, known_asset_ids, pack.canvas):
|
|
49
49
|
owners.setdefault(asset_id, set()).add(layout_name)
|
|
50
|
-
return
|
|
50
|
+
return owners
|
|
51
51
|
|
|
52
52
|
|
|
53
53
|
def asset_urls(pack, asset_prefix, asset_ids):
|
|
@@ -63,10 +63,118 @@ def asset_urls(pack, asset_prefix, asset_ids):
|
|
|
63
63
|
relative = normalized_path(path)
|
|
64
64
|
if relative.startswith('assets/'):
|
|
65
65
|
relative = relative[len('assets/'):]
|
|
66
|
-
urls
|
|
66
|
+
urls.setdefault(posixpath.join(prefix, relative), set()).add(asset_id)
|
|
67
67
|
return urls
|
|
68
68
|
|
|
69
69
|
|
|
70
|
+
def bound_asset_instances(value, known_asset_ids):
|
|
71
|
+
"""Return every positioned fixed-asset instance nested in slots or flow."""
|
|
72
|
+
instances = []
|
|
73
|
+
if isinstance(value, dict):
|
|
74
|
+
box = value.get('box')
|
|
75
|
+
asset_id = value.get('asset')
|
|
76
|
+
if (isinstance(box, list) and len(box) == 4
|
|
77
|
+
and isinstance(asset_id, str)
|
|
78
|
+
and asset_id in known_asset_ids):
|
|
79
|
+
instances.append((asset_id, value.get('role') or 'asset', box))
|
|
80
|
+
for key, child in value.items():
|
|
81
|
+
if key not in ('asset', 'background'):
|
|
82
|
+
instances.extend(bound_asset_instances(child, known_asset_ids))
|
|
83
|
+
elif isinstance(value, list):
|
|
84
|
+
for child in value:
|
|
85
|
+
instances.extend(bound_asset_instances(child, known_asset_ids))
|
|
86
|
+
return instances
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def layout_asset_instances(layout, known_asset_ids, canvas):
|
|
90
|
+
"""Return every fixed image instance declared by one layout."""
|
|
91
|
+
if not isinstance(layout, dict):
|
|
92
|
+
return []
|
|
93
|
+
instances = []
|
|
94
|
+
background = layout.get('background')
|
|
95
|
+
if canvas and isinstance(background, str) and background in known_asset_ids:
|
|
96
|
+
instances.append(
|
|
97
|
+
(background, 'background', [0, 0, canvas[0], canvas[1]]))
|
|
98
|
+
instances.extend(bound_asset_instances(layout, known_asset_ids))
|
|
99
|
+
return instances
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def inline_styles(value):
|
|
103
|
+
styles = {}
|
|
104
|
+
for declaration in (value or '').split(';'):
|
|
105
|
+
if ':' not in declaration:
|
|
106
|
+
continue
|
|
107
|
+
name, raw = declaration.split(':', 1)
|
|
108
|
+
styles[name.strip().lower()] = re.sub(
|
|
109
|
+
r'\s*!important\s*$', '', raw.strip().lower())
|
|
110
|
+
return styles
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def css_number(value):
|
|
114
|
+
match = re.fullmatch(r'(-?(?:\d+(?:\.\d*)?|\.\d+))(?:px)?', value or '')
|
|
115
|
+
return float(match.group(1)) if match else None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def element_box(reference, canvas):
|
|
119
|
+
styles = reference['styles']
|
|
120
|
+
if reference['slide_root'] and not any(
|
|
121
|
+
name in styles for name in ('left', 'top', 'width', 'height')):
|
|
122
|
+
return [0.0, 0.0, float(canvas[0]), float(canvas[1])]
|
|
123
|
+
values = [
|
|
124
|
+
css_number(styles.get(name))
|
|
125
|
+
for name in ('left', 'top', 'width', 'height')
|
|
126
|
+
]
|
|
127
|
+
if styles.get('position') != 'absolute' or any(
|
|
128
|
+
value is None for value in values):
|
|
129
|
+
return None
|
|
130
|
+
return values
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def boxes_match(actual, expected, tolerance=1.0):
|
|
134
|
+
return actual is not None and all(
|
|
135
|
+
abs(actual_value - expected_value) <= tolerance
|
|
136
|
+
for actual_value, expected_value in zip(actual, expected)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def file_sha256(path):
|
|
141
|
+
digest = hashlib.sha256()
|
|
142
|
+
with open(path, 'rb') as stream:
|
|
143
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
|
|
144
|
+
digest.update(chunk)
|
|
145
|
+
return digest.hexdigest()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def copied_asset_problems(pack, html_path, asset_prefix, asset_ids):
|
|
149
|
+
"""Verify copied fixed assets still contain the source PPTX bytes."""
|
|
150
|
+
problems = []
|
|
151
|
+
html_root = os.path.dirname(os.path.abspath(html_path))
|
|
152
|
+
prefix = normalized_path(asset_prefix).rstrip('/')
|
|
153
|
+
for asset_id in sorted(asset_ids):
|
|
154
|
+
entry, _ = pack.assets.get(asset_id, (None, None))
|
|
155
|
+
path = entry.get('path') if isinstance(entry, dict) else None
|
|
156
|
+
if not isinstance(path, str) or not path:
|
|
157
|
+
continue
|
|
158
|
+
relative = normalized_path(path)
|
|
159
|
+
if relative.startswith('assets/'):
|
|
160
|
+
relative = relative[len('assets/'):]
|
|
161
|
+
source_path = os.path.join(pack.root, 'assets', *relative.split('/'))
|
|
162
|
+
copied_relative = posixpath.join(prefix, relative)
|
|
163
|
+
copied_path = os.path.join(html_root, *copied_relative.split('/'))
|
|
164
|
+
if not os.path.isfile(copied_path):
|
|
165
|
+
problems.append(
|
|
166
|
+
'固定素材 %s 未复制到项目: %s' % (asset_id, copied_relative))
|
|
167
|
+
continue
|
|
168
|
+
if not os.path.isfile(source_path):
|
|
169
|
+
problems.append(
|
|
170
|
+
'风格包中的固定素材 %s 不存在: %s' % (asset_id, path))
|
|
171
|
+
continue
|
|
172
|
+
if file_sha256(copied_path) != file_sha256(source_path):
|
|
173
|
+
problems.append(
|
|
174
|
+
'固定素材 %s 已被替换或改写,必须使用 PPTX 原文件' % asset_id)
|
|
175
|
+
return problems
|
|
176
|
+
|
|
177
|
+
|
|
70
178
|
def urls_from_attrs(attrs):
|
|
71
179
|
urls = []
|
|
72
180
|
for key, value in attrs:
|
|
@@ -85,50 +193,72 @@ class SlideAssetParser(HTMLParser):
|
|
|
85
193
|
def __init__(self):
|
|
86
194
|
super().__init__()
|
|
87
195
|
self.slides = []
|
|
88
|
-
self.
|
|
89
|
-
self._sections = []
|
|
90
|
-
self._active_slides = []
|
|
196
|
+
self._stack = []
|
|
91
197
|
self._style_depth = 0
|
|
92
198
|
self.outside_urls = []
|
|
93
199
|
|
|
94
200
|
def handle_starttag(self, tag, attrs):
|
|
95
201
|
tag = tag.lower()
|
|
202
|
+
attrs_map = {key.lower(): value for key, value in attrs}
|
|
96
203
|
urls = urls_from_attrs(attrs)
|
|
97
|
-
parent = self.
|
|
98
|
-
if
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
204
|
+
parent = self._stack[-1] if self._stack else None
|
|
205
|
+
parent_slide = parent['slide'] if parent else None
|
|
206
|
+
slide_root = bool(
|
|
207
|
+
tag == 'section' and parent and parent['tag'] == 'deck-stage')
|
|
208
|
+
slide = ({
|
|
209
|
+
'layout': attrs_map.get('data-pptx-layout'),
|
|
210
|
+
'theme': attrs_map.get('data-pptx-theme'),
|
|
211
|
+
'references': [],
|
|
212
|
+
} if slide_root else parent_slide)
|
|
213
|
+
if slide_root:
|
|
214
|
+
self.slides.append(slide)
|
|
215
|
+
styles = inline_styles(attrs_map.get('style'))
|
|
216
|
+
hidden = bool(
|
|
217
|
+
(parent and parent['hidden'])
|
|
218
|
+
or 'hidden' in attrs_map
|
|
219
|
+
or attrs_map.get('aria-hidden', '').lower() == 'true'
|
|
220
|
+
or styles.get('display') == 'none'
|
|
221
|
+
or styles.get('visibility') in ('hidden', 'collapse')
|
|
222
|
+
or styles.get('content-visibility') == 'hidden'
|
|
223
|
+
or css_number(styles.get('width')) == 0
|
|
224
|
+
or css_number(styles.get('height')) == 0
|
|
225
|
+
or (
|
|
226
|
+
css_number(styles.get('opacity')) is not None
|
|
227
|
+
and css_number(styles.get('opacity')) <= 0
|
|
102
228
|
)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
229
|
+
)
|
|
230
|
+
if slide is not None and (urls or attrs_map.get('data-pptx-asset')):
|
|
231
|
+
slide['references'].append({
|
|
232
|
+
'asset': attrs_map.get('data-pptx-asset'),
|
|
233
|
+
'hidden': hidden,
|
|
234
|
+
'slide_root': slide_root,
|
|
235
|
+
'source': attrs_map.get('src'),
|
|
236
|
+
'styles': styles,
|
|
237
|
+
'tag': tag,
|
|
238
|
+
'urls': urls,
|
|
239
|
+
})
|
|
240
|
+
elif urls:
|
|
109
241
|
self.outside_urls.extend(urls)
|
|
110
242
|
if tag == 'style':
|
|
111
243
|
self._style_depth += 1
|
|
112
244
|
if tag not in VOID_TAGS:
|
|
113
|
-
self.
|
|
245
|
+
self._stack.append({
|
|
246
|
+
'hidden': hidden,
|
|
247
|
+
'slide': slide,
|
|
248
|
+
'tag': tag,
|
|
249
|
+
})
|
|
114
250
|
|
|
115
251
|
def handle_startendtag(self, tag, attrs):
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
self.outside_urls.extend(urls_from_attrs(attrs))
|
|
252
|
+
self.handle_starttag(tag, attrs)
|
|
253
|
+
if tag.lower() not in VOID_TAGS:
|
|
254
|
+
self.handle_endtag(tag)
|
|
120
255
|
|
|
121
256
|
def handle_endtag(self, tag):
|
|
122
257
|
tag = tag.lower()
|
|
123
258
|
if tag == 'style' and self._style_depth:
|
|
124
259
|
self._style_depth -= 1
|
|
125
|
-
if tag
|
|
126
|
-
|
|
127
|
-
if slide is not None:
|
|
128
|
-
self.slides.append(slide)
|
|
129
|
-
self._active_slides.pop()
|
|
130
|
-
if tag not in VOID_TAGS and self._tags:
|
|
131
|
-
self._tags.pop()
|
|
260
|
+
if tag not in VOID_TAGS and self._stack:
|
|
261
|
+
self._stack.pop()
|
|
132
262
|
|
|
133
263
|
def handle_data(self, data):
|
|
134
264
|
if not self._style_depth:
|
|
@@ -139,10 +269,13 @@ class SlideAssetParser(HTMLParser):
|
|
|
139
269
|
def validate_layout_assets(pack_dir, html_path, asset_prefix):
|
|
140
270
|
"""Return violations of the asset contract declared by each layout."""
|
|
141
271
|
pack = Pack(pack_dir)
|
|
142
|
-
|
|
272
|
+
owners = layout_asset_contract(pack)
|
|
143
273
|
known_urls = asset_urls(pack, asset_prefix, owners)
|
|
144
|
-
|
|
145
|
-
|
|
274
|
+
asset_urls_by_id = {
|
|
275
|
+
asset_id: url
|
|
276
|
+
for url, asset_ids in known_urls.items()
|
|
277
|
+
for asset_id in asset_ids
|
|
278
|
+
}
|
|
146
279
|
|
|
147
280
|
with open(html_path, encoding='utf-8') as stream:
|
|
148
281
|
text = stream.read()
|
|
@@ -150,10 +283,13 @@ def validate_layout_assets(pack_dir, html_path, asset_prefix):
|
|
|
150
283
|
parser.feed(text)
|
|
151
284
|
parser.close()
|
|
152
285
|
|
|
153
|
-
problems =
|
|
286
|
+
problems = copied_asset_problems(pack, html_path, asset_prefix, owners)
|
|
287
|
+
if not parser.slides:
|
|
288
|
+
problems.append(
|
|
289
|
+
'没有识别到 deck-stage 的直属 slide section,无法核验模板页型')
|
|
290
|
+
return problems
|
|
154
291
|
for url in parser.outside_urls:
|
|
155
|
-
asset_id
|
|
156
|
-
if asset_id:
|
|
292
|
+
for asset_id in sorted(known_urls.get(normalized_path(url), ())):
|
|
157
293
|
problems.append(
|
|
158
294
|
'模板资产 %s 出现在 slide section 外,无法核验页型归属' % asset_id)
|
|
159
295
|
for number, slide in enumerate(parser.slides, 1):
|
|
@@ -164,23 +300,102 @@ def validate_layout_assets(pack_dir, html_path, asset_prefix):
|
|
|
164
300
|
if layout not in pack.layouts:
|
|
165
301
|
problems.append('第 %d 页声明了不存在的模板页型: %s' % (number, layout))
|
|
166
302
|
continue
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
303
|
+
layout_entry = pack.layouts[layout][0]
|
|
304
|
+
explicit_theme = slide['theme']
|
|
305
|
+
default_theme = pack.design.data.get('default-theme')
|
|
306
|
+
active_theme = explicit_theme or default_theme
|
|
307
|
+
if explicit_theme and explicit_theme not in pack.themes:
|
|
308
|
+
problems.append(
|
|
309
|
+
'第 %d 页声明了不存在的模板主题: %s' % (number, explicit_theme))
|
|
310
|
+
continue
|
|
311
|
+
layout_themes = (
|
|
312
|
+
(layout_entry.get('themes') or [])
|
|
313
|
+
if isinstance(layout_entry, dict) else []
|
|
171
314
|
)
|
|
315
|
+
if active_theme and layout_themes and active_theme not in layout_themes:
|
|
316
|
+
problems.append(
|
|
317
|
+
'第 %d 页页型 %s 不支持当前主题 %s;允许主题: %s。'
|
|
318
|
+
'若确需切换,显式声明 data-pptx-theme'
|
|
319
|
+
% (number, layout, active_theme, '、'.join(layout_themes)))
|
|
320
|
+
continue
|
|
321
|
+
expected = layout_asset_instances(
|
|
322
|
+
layout_entry, set(pack.assets), pack.canvas)
|
|
323
|
+
actual = []
|
|
324
|
+
for reference in slide['references']:
|
|
325
|
+
referenced_ids = set()
|
|
326
|
+
normalized_urls = [normalized_path(url) for url in reference['urls']]
|
|
327
|
+
for url in normalized_urls:
|
|
328
|
+
referenced_ids.update(known_urls.get(url, ()))
|
|
329
|
+
asset_id = reference['asset']
|
|
330
|
+
if not asset_id:
|
|
331
|
+
for referenced_id in sorted(referenced_ids):
|
|
332
|
+
problems.append(
|
|
333
|
+
'第 %d 页模板资产 %s 缺少 data-pptx-asset 实例标记'
|
|
334
|
+
% (number, referenced_id))
|
|
335
|
+
continue
|
|
336
|
+
if asset_id not in pack.assets:
|
|
337
|
+
problems.append(
|
|
338
|
+
'第 %d 页声明了不存在的模板资产: %s' % (number, asset_id))
|
|
339
|
+
continue
|
|
340
|
+
expected_url = asset_urls_by_id.get(asset_id)
|
|
341
|
+
uses_expected_source = (
|
|
342
|
+
len(normalized_urls) == 1
|
|
343
|
+
and normalized_urls[0] == expected_url
|
|
344
|
+
and (
|
|
345
|
+
reference['tag'] != 'img'
|
|
346
|
+
or (
|
|
347
|
+
reference['source']
|
|
348
|
+
and normalized_path(reference['source']) == expected_url
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
)
|
|
352
|
+
if not uses_expected_source:
|
|
353
|
+
problems.append(
|
|
354
|
+
'第 %d 页固定实例 %s 未引用对应的 PPTX 原素材'
|
|
355
|
+
% (number, asset_id))
|
|
356
|
+
if reference['hidden']:
|
|
357
|
+
problems.append(
|
|
358
|
+
'第 %d 页固定实例 %s 不可隐藏' % (number, asset_id))
|
|
359
|
+
actual.append({
|
|
360
|
+
'asset': asset_id,
|
|
361
|
+
'box': element_box(reference, pack.canvas),
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
used_asset_counts = Counter(instance['asset'] for instance in actual)
|
|
172
365
|
for asset_id in sorted(used_asset_counts):
|
|
173
366
|
if layout not in owners.get(asset_id, set()):
|
|
174
367
|
allowed = '、'.join(sorted(owners.get(asset_id) or ())) or '(无)'
|
|
175
368
|
problems.append(
|
|
176
369
|
'第 %d 页页型 %s 不得使用 %s;只允许: %s'
|
|
177
370
|
% (number, layout, asset_id, allowed))
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
371
|
+
unmatched = list(actual)
|
|
372
|
+
for asset_id, _, expected_box in expected:
|
|
373
|
+
matching_index = next((
|
|
374
|
+
index for index, instance in enumerate(unmatched)
|
|
375
|
+
if instance['asset'] == asset_id
|
|
376
|
+
and boxes_match(instance['box'], expected_box)
|
|
377
|
+
), None)
|
|
378
|
+
if matching_index is not None:
|
|
379
|
+
unmatched.pop(matching_index)
|
|
380
|
+
continue
|
|
381
|
+
same_asset = next((
|
|
382
|
+
instance for instance in unmatched
|
|
383
|
+
if instance['asset'] == asset_id
|
|
384
|
+
), None)
|
|
385
|
+
if same_asset:
|
|
386
|
+
problems.append(
|
|
387
|
+
'第 %d 页固定实例 %s 的位置尺寸必须为 %s,当前为 %s'
|
|
388
|
+
% (number, asset_id, expected_box, same_asset['box']))
|
|
389
|
+
unmatched.remove(same_asset)
|
|
390
|
+
else:
|
|
391
|
+
problems.append(
|
|
392
|
+
'第 %d 页页型 %s 缺少固定实例 %s,位置尺寸应为 %s'
|
|
393
|
+
% (number, layout, asset_id, expected_box))
|
|
394
|
+
for instance in unmatched:
|
|
395
|
+
if layout in owners.get(instance['asset'], set()):
|
|
181
396
|
problems.append(
|
|
182
|
-
'第 %d 页页型 %s
|
|
183
|
-
% (number, layout,
|
|
397
|
+
'第 %d 页页型 %s 额外使用了固定实例 %s'
|
|
398
|
+
% (number, layout, instance['asset']))
|
|
184
399
|
return problems
|
|
185
400
|
|
|
186
401
|
|