@lark-apaas/coding-steering 0.1.18-dev.ec88bb5 → 0.1.18-dev.ee5404f
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 +58 -22
- package/steering/design-html/skills/pptx-style-extract/font-fallback.yaml +3 -3
- package/steering/design-html/skills/pptx-style-extract/scripts/census.py +18 -12
- package/steering/design-html/skills/pptx-style-extract/scripts/check_v2.py +153 -8
- package/steering/design-html/skills/pptx-style-extract/scripts/draft.py +1659 -240
- package/steering/design-html/skills/pptx-style-extract/scripts/extract.py +325 -22
- package/steering/design-html/skills/pptx-style-extract/scripts/ooxml.py +1 -1
- package/steering/design-html/skills/pptx-style-extract/scripts/package.py +289 -154
- package/steering/design-html/skills/pptx-style-extract/scripts/parts.py +6 -3
- package/steering/design-html/skills/pptx-style-extract/scripts/query.py +4 -9
- package/steering/design-html/skills/pptx-style-extract/scripts/render_pages.py +16 -10
- package/steering/design-html/skills/pptx-style-extract/scripts/test_background_composite.py +57 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_color_contract.py +58 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py +62 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_layout_css.py +98 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_rounded_contract.py +112 -0
- package/steering/design-html/skills/pptx-style-extract/scripts/test_text_role_contract.py +168 -0
- package/steering/design-html/skills/pptx-style-extract/v2-format-spec.md +26 -15
- package/steering/design-html/skills/slide-deck/SKILL.md +15 -20
- package/steering/design-html/skills/slide-deck/scripts/check_local_references.py +179 -0
|
@@ -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
|
|
270
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
"""
|
|
287
|
+
"""
|
|
288
288
|
|
|
289
|
+
# 缩略框跟着画布比例走,缩放比也一起算——写死 16:9 会把非 16:9 模板的页面裁掉一截
|
|
289
290
|
INDEX_EXTRA = """<style>
|
|
290
|
-
.wrap{width
|
|
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
|
|
293
|
-
.box .page{transform:scale(
|
|
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
|
-
|
|
512
|
+
cpx = ((data.get('canvas') or {}).get('px') or [1920, 1080])
|
|
513
|
+
W, H = int(cpx[0] * scale), int(cpx[1] * scale)
|
|
512
514
|
for kind, n, label, layers, default_color, page_bg in pages:
|
|
513
515
|
canvas = Image.new('RGBA', (W, H), _rgba(page_bg) or (136, 136, 136, 255))
|
|
514
516
|
g = _grad_stops(page_bg or '')
|
|
@@ -642,15 +644,19 @@ def main():
|
|
|
642
644
|
tx1_of_master.get(master, '#000000'),
|
|
643
645
|
layout_bg.get(part, '#888')))
|
|
644
646
|
|
|
645
|
-
if only is not None:
|
|
646
|
-
pages = [p for p in pages if p[
|
|
647
|
+
if only is not None: # 对样张和版式一律生效(form=3 的代表页是版式,不是样张)
|
|
648
|
+
pages = [p for p in pages if p[1] in only]
|
|
647
649
|
|
|
648
650
|
stats = {'shapes': 0, 'text': 0, 'img_ok': 0, 'img_missing': 0, 'no_box': 0}
|
|
649
|
-
|
|
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(
|
|
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()
|
package/steering/design-html/skills/pptx-style-extract/scripts/test_design_consumer_contract.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
self.assertIn('沿用该页型已有的标题层级与局部 `css`', body)
|
|
57
|
+
self.assertIn('背景中已经可见的固定标题不再创建文本', body)
|
|
58
|
+
self.assertIn('没有 `subtitle` 槽就不新增副标题', body)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == '__main__':
|
|
62
|
+
unittest.main()
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Regression tests for the consumer-facing layout slot CSS contract."""
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
9
|
+
|
|
10
|
+
from check_v2 import Pack, rule_v2_16 # noqa: E402
|
|
11
|
+
from draft import emit_layouts, slot_style # noqa: E402
|
|
12
|
+
from package import FONTSIZE_RE, build_layouts_md, split_top_blocks # noqa: E402
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LayoutCssTest(unittest.TestCase):
|
|
16
|
+
def test_text_slot_emits_rendering_style_as_css_only(self):
|
|
17
|
+
shape = {
|
|
18
|
+
'text': {
|
|
19
|
+
'bodyPr': {
|
|
20
|
+
'anchor': 'ctr',
|
|
21
|
+
'insets_px': {'lIns': 12, 'tIns': 8, 'rIns': 10, 'bIns': 6},
|
|
22
|
+
'rot': '900000',
|
|
23
|
+
},
|
|
24
|
+
'lstStyle': {
|
|
25
|
+
'lvl1pPr': {
|
|
26
|
+
'sz_px': 48,
|
|
27
|
+
'weight': 600,
|
|
28
|
+
'italic': True,
|
|
29
|
+
'underline': 'sng',
|
|
30
|
+
'strike': 'sngStrike',
|
|
31
|
+
'spc_px': 1.5,
|
|
32
|
+
'color': {'resolved': '#123456'},
|
|
33
|
+
'algn': 'ctr',
|
|
34
|
+
'lnSpc': {'mult': 1.0},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
'paragraphs': [],
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
slot = {
|
|
41
|
+
'role': 'title',
|
|
42
|
+
'type': 'title',
|
|
43
|
+
'box': [100, 80, 800, 160],
|
|
44
|
+
'sz': 48,
|
|
45
|
+
'txt': '标题',
|
|
46
|
+
}
|
|
47
|
+
slot.update(slot_style(shape))
|
|
48
|
+
archetype = {
|
|
49
|
+
'name': 'layout-1',
|
|
50
|
+
'zh': '标题页',
|
|
51
|
+
'role': 'content',
|
|
52
|
+
'bg': None,
|
|
53
|
+
'slots': [slot],
|
|
54
|
+
'decor': [],
|
|
55
|
+
'pages': [1],
|
|
56
|
+
'rep': 1,
|
|
57
|
+
'pic_n': 0,
|
|
58
|
+
'confidence': 'high',
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
with tempfile.TemporaryDirectory() as output_dir:
|
|
62
|
+
emit_layouts([archetype], output_dir)
|
|
63
|
+
with open(os.path.join(output_dir, 'layouts.yaml'), encoding='utf-8') as stream:
|
|
64
|
+
layouts_yaml = stream.read()
|
|
65
|
+
layouts_md = build_layouts_md(split_top_blocks(layouts_yaml), (1920, 1080))
|
|
66
|
+
|
|
67
|
+
self.assertIn(
|
|
68
|
+
'css: "box-sizing: border-box; padding: 8px 10px 6px 12px; '
|
|
69
|
+
'font-size: 48px; font-weight: 600; font-style: italic; '
|
|
70
|
+
'text-decoration: underline line-through; letter-spacing: 1.5px; color: #123456; '
|
|
71
|
+
'text-align: center; line-height: 1.2; display: flex; '
|
|
72
|
+
'flex-direction: column; justify-content: center; rotate: 15deg"',
|
|
73
|
+
layouts_md,
|
|
74
|
+
)
|
|
75
|
+
for legacy_key in ('size', 'weight', 'color', 'align', 'valign', 'insets_px'):
|
|
76
|
+
self.assertNotRegex(layouts_md, rf'[,{{]\s*{legacy_key}:')
|
|
77
|
+
self.assertEqual(FONTSIZE_RE.findall(layouts_md), ['48'])
|
|
78
|
+
|
|
79
|
+
def test_layout_gate_rejects_legacy_slot_style_keys(self):
|
|
80
|
+
with tempfile.TemporaryDirectory() as pack_dir:
|
|
81
|
+
with open(os.path.join(pack_dir, 'design.md'), 'w', encoding='utf-8') as stream:
|
|
82
|
+
stream.write('---\nversion: alpha\nlayouts: layouts.md\n---\n\nRead layouts.md.\n')
|
|
83
|
+
with open(os.path.join(pack_dir, 'layouts.md'), 'w', encoding='utf-8') as stream:
|
|
84
|
+
stream.write(
|
|
85
|
+
'---\ncanvas: 1920x1080\nlayouts:\n cover:\n role: cover\n'
|
|
86
|
+
' slots:\n - {role: title, type: title, box: [0, 0, 800, 100], '
|
|
87
|
+
'size: 48, align: center}\n confidence: high\n---\n'
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
result = rule_v2_16(Pack(pack_dir))
|
|
91
|
+
|
|
92
|
+
self.assertEqual(result.level, 'FAIL')
|
|
93
|
+
self.assertEqual(len(result.fails), 1)
|
|
94
|
+
self.assertIn('旧样式键 align/size', result.fails[0])
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == '__main__':
|
|
98
|
+
unittest.main()
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Regression tests for preserving per-container radii."""
|
|
3
|
+
import os
|
|
4
|
+
import tempfile
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
from draft import emit_body, emit_frontmatter
|
|
8
|
+
from query import _recipe_css
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def render_frontmatter(radii):
|
|
12
|
+
data = {
|
|
13
|
+
'radii_census': radii,
|
|
14
|
+
'spacing_candidates': {},
|
|
15
|
+
}
|
|
16
|
+
tokens = [('surface', {'hex': '#FFFFFF'})]
|
|
17
|
+
|
|
18
|
+
with tempfile.TemporaryDirectory() as output_dir:
|
|
19
|
+
emit_frontmatter(
|
|
20
|
+
data,
|
|
21
|
+
tokens,
|
|
22
|
+
[],
|
|
23
|
+
{},
|
|
24
|
+
[],
|
|
25
|
+
[],
|
|
26
|
+
output_dir,
|
|
27
|
+
)
|
|
28
|
+
with open(os.path.join(output_dir, 'frontmatter.yaml'), encoding='utf-8') as stream:
|
|
29
|
+
return stream.read()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def render_body():
|
|
33
|
+
data = {
|
|
34
|
+
'canvas': {'px': [1920, 1080]},
|
|
35
|
+
'form_hint': {'form': 2},
|
|
36
|
+
'counts': {'slides': 1},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
with tempfile.TemporaryDirectory() as output_dir:
|
|
40
|
+
emit_body(
|
|
41
|
+
data,
|
|
42
|
+
[],
|
|
43
|
+
[],
|
|
44
|
+
{},
|
|
45
|
+
[],
|
|
46
|
+
[],
|
|
47
|
+
[],
|
|
48
|
+
{},
|
|
49
|
+
output_dir,
|
|
50
|
+
)
|
|
51
|
+
with open(os.path.join(output_dir, 'body.md'), encoding='utf-8') as stream:
|
|
52
|
+
return stream.read()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RoundedContractTest(unittest.TestCase):
|
|
56
|
+
def test_multiple_radius_tiers_are_not_collapsed_into_one_card_token(self):
|
|
57
|
+
frontmatter = render_frontmatter(
|
|
58
|
+
[
|
|
59
|
+
{'px': 6.9, 'n': 2},
|
|
60
|
+
{'px': 11.9, 'n': 2},
|
|
61
|
+
{'px': 14.4, 'n': 3},
|
|
62
|
+
]
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
self.assertNotIn('rounded:', frontmatter)
|
|
66
|
+
|
|
67
|
+
def test_single_radius_tier_can_remain_a_global_token(self):
|
|
68
|
+
frontmatter = render_frontmatter([{'px': 12, 'n': 9}])
|
|
69
|
+
|
|
70
|
+
self.assertIn('rounded:\n card: 12px', frontmatter)
|
|
71
|
+
|
|
72
|
+
def test_rare_rounded_exceptions_do_not_override_a_zero_radius_majority(self):
|
|
73
|
+
frontmatter = render_frontmatter(
|
|
74
|
+
[
|
|
75
|
+
{'px': 0, 'n': 241},
|
|
76
|
+
{'px': 3.4, 'n': 4},
|
|
77
|
+
{'px': 6.9, 'n': 3},
|
|
78
|
+
{'px': 50.5, 'n': 1},
|
|
79
|
+
]
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
self.assertNotIn('rounded:', frontmatter)
|
|
83
|
+
|
|
84
|
+
def test_generated_usage_defaults_unspecified_container_radius_to_zero(self):
|
|
85
|
+
body = render_body()
|
|
86
|
+
|
|
87
|
+
self.assertIn('没有 `border-radius` 就按 `0`', body)
|
|
88
|
+
self.assertIn('不得自行补圆角', body)
|
|
89
|
+
|
|
90
|
+
def test_recipe_does_not_promote_one_rounded_exception_to_the_whole_group(self):
|
|
91
|
+
css = _recipe_css(
|
|
92
|
+
{'type': 'solid', 'color': {'hex': '#FFFFFF'}},
|
|
93
|
+
None,
|
|
94
|
+
[0] * 70 + [50.5],
|
|
95
|
+
None,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
self.assertFalse(any('border-radius' in declaration for declaration in css))
|
|
99
|
+
|
|
100
|
+
def test_recipe_keeps_a_radius_shared_by_the_whole_group(self):
|
|
101
|
+
css = _recipe_css(
|
|
102
|
+
{'type': 'solid', 'color': {'hex': '#FFFFFF'}},
|
|
103
|
+
None,
|
|
104
|
+
[6.9] * 17,
|
|
105
|
+
None,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
self.assertIn('border-radius: 6.9px', css)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == '__main__':
|
|
112
|
+
unittest.main()
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Regression tests for inherited layout text and model-decided text roles."""
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import tempfile
|
|
7
|
+
import unittest
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
10
|
+
|
|
11
|
+
from draft import draft_layouts, emit_layouts, inherited_text_shapes
|
|
12
|
+
from package import build_layouts_md, split_top_blocks
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def text_shape(part, layer, shape_id, box, text, placeholder):
|
|
16
|
+
return {
|
|
17
|
+
'part': part,
|
|
18
|
+
'layer': layer,
|
|
19
|
+
'id': shape_id,
|
|
20
|
+
'kind': 'sp',
|
|
21
|
+
'name': 'Text Placeholder',
|
|
22
|
+
'ph': placeholder,
|
|
23
|
+
'box': box,
|
|
24
|
+
'text': {
|
|
25
|
+
'bodyPr': {},
|
|
26
|
+
'lstStyle': {
|
|
27
|
+
'lvl1pPr': {
|
|
28
|
+
'sz_px': 48,
|
|
29
|
+
'weight': 600,
|
|
30
|
+
'color': {'resolved': '#C41230'},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
'paragraphs': [
|
|
34
|
+
{
|
|
35
|
+
'runs': [{'text': text}],
|
|
36
|
+
},
|
|
37
|
+
] if text else [],
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TextRoleContractTest(unittest.TestCase):
|
|
43
|
+
def test_empty_non_placeholder_layout_shape_is_not_a_text_slot(self):
|
|
44
|
+
layout_part = 'ppt/slideLayouts/slideLayout2.xml'
|
|
45
|
+
decorative_shape = text_shape(
|
|
46
|
+
layout_part,
|
|
47
|
+
'layout',
|
|
48
|
+
'9',
|
|
49
|
+
{'x': 0, 'y': 0, 'w': 1920, 'h': 24},
|
|
50
|
+
'',
|
|
51
|
+
None,
|
|
52
|
+
)
|
|
53
|
+
decorative_shape['name'] = 'Decorative bar'
|
|
54
|
+
|
|
55
|
+
self.assertEqual(inherited_text_shapes([decorative_shape], []), [])
|
|
56
|
+
|
|
57
|
+
def test_empty_slide_inherits_text_slot_and_css_from_its_layout(self):
|
|
58
|
+
layout_part = 'ppt/slideLayouts/slideLayout2.xml'
|
|
59
|
+
slide_part = 'ppt/slides/slide1.xml'
|
|
60
|
+
shapes = [
|
|
61
|
+
text_shape(
|
|
62
|
+
layout_part,
|
|
63
|
+
'layout',
|
|
64
|
+
'10',
|
|
65
|
+
{'x': 120, 'y': 80, 'w': 840, 'h': 120},
|
|
66
|
+
'Example heading',
|
|
67
|
+
{'type': 'body', 'idx': '10'},
|
|
68
|
+
),
|
|
69
|
+
text_shape(
|
|
70
|
+
slide_part,
|
|
71
|
+
'slide',
|
|
72
|
+
'2',
|
|
73
|
+
None,
|
|
74
|
+
'',
|
|
75
|
+
{'type': 'body', 'idx': '10'},
|
|
76
|
+
),
|
|
77
|
+
]
|
|
78
|
+
data = {
|
|
79
|
+
'canvas': {'px': [1920, 1080]},
|
|
80
|
+
'form_hint': {'form': 0},
|
|
81
|
+
'slides': [
|
|
82
|
+
{
|
|
83
|
+
'part': slide_part,
|
|
84
|
+
'layout': layout_part,
|
|
85
|
+
'background': None,
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
'background_composites': {},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
with tempfile.TemporaryDirectory() as output_dir:
|
|
92
|
+
os.makedirs(os.path.join(output_dir, 'ref'))
|
|
93
|
+
with open(
|
|
94
|
+
os.path.join(output_dir, 'ref', 'shapes.json'),
|
|
95
|
+
'w',
|
|
96
|
+
encoding='utf-8',
|
|
97
|
+
) as stream:
|
|
98
|
+
json.dump({'shapes': shapes}, stream)
|
|
99
|
+
archetypes, _, _ = draft_layouts(data, output_dir)
|
|
100
|
+
|
|
101
|
+
self.assertEqual(len(archetypes), 1)
|
|
102
|
+
self.assertEqual(len(archetypes[0]['slots']), 1)
|
|
103
|
+
slot = archetypes[0]['slots'][0]
|
|
104
|
+
self.assertEqual(slot['box'], [120, 80, 840, 120])
|
|
105
|
+
self.assertEqual(slot['txt'], 'Example heading')
|
|
106
|
+
self.assertEqual(slot['role'], 'body')
|
|
107
|
+
self.assertEqual(slot['type'], 'body')
|
|
108
|
+
self.assertTrue(slot['_needs_role'])
|
|
109
|
+
self.assertIn('font-size: 48px', slot['css'])
|
|
110
|
+
self.assertIn('font-weight: 600', slot['css'])
|
|
111
|
+
self.assertIn('color: #C41230', slot['css'])
|
|
112
|
+
|
|
113
|
+
def test_text_role_judgement_changes_semantics_without_dropping_slot(self):
|
|
114
|
+
archetype = {
|
|
115
|
+
'name': 'layout-1',
|
|
116
|
+
'zh': None,
|
|
117
|
+
'role': 'content',
|
|
118
|
+
'bg': None,
|
|
119
|
+
'slots': [
|
|
120
|
+
{
|
|
121
|
+
'role': 'body',
|
|
122
|
+
'type': 'body',
|
|
123
|
+
'box': [120, 80, 840, 120],
|
|
124
|
+
'sz': 48,
|
|
125
|
+
'txt': 'Example heading',
|
|
126
|
+
'css': 'font-size: 48px; color: #C41230',
|
|
127
|
+
'_needs_role': True,
|
|
128
|
+
'_source_layer': 'layout',
|
|
129
|
+
'_placeholder': 'body/10',
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
'decor': [],
|
|
133
|
+
'pages': [1],
|
|
134
|
+
'rep': 1,
|
|
135
|
+
'pic_n': 0,
|
|
136
|
+
'confidence': 'low',
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
with tempfile.TemporaryDirectory() as output_dir:
|
|
140
|
+
emit_layouts([archetype], output_dir)
|
|
141
|
+
path = os.path.join(output_dir, 'layouts.yaml')
|
|
142
|
+
with open(path, encoding='utf-8') as stream:
|
|
143
|
+
draft = stream.read()
|
|
144
|
+
|
|
145
|
+
self.assertIn('text_roles:', draft)
|
|
146
|
+
self.assertIn(
|
|
147
|
+
'layout-1-text-1: TODO文本角色',
|
|
148
|
+
draft,
|
|
149
|
+
)
|
|
150
|
+
self.assertEqual(draft.count('box: [120, 80, 840, 120]'), 1)
|
|
151
|
+
|
|
152
|
+
decided = draft.replace(
|
|
153
|
+
'layout-1-text-1: TODO文本角色',
|
|
154
|
+
'layout-1-text-1: title',
|
|
155
|
+
)
|
|
156
|
+
layouts_md = build_layouts_md(split_top_blocks(decided), (1920, 1080))
|
|
157
|
+
|
|
158
|
+
self.assertNotIn('text_roles:', layouts_md)
|
|
159
|
+
self.assertEqual(layouts_md.count('box: [120, 80, 840, 120]'), 1)
|
|
160
|
+
self.assertIn(
|
|
161
|
+
'role: title, box: [120, 80, 840, 120], type: title',
|
|
162
|
+
layouts_md,
|
|
163
|
+
)
|
|
164
|
+
self.assertIn('css: "font-size: 48px; color: #C41230"', layouts_md)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == '__main__':
|
|
168
|
+
unittest.main()
|