@lark-apaas/coding-steering 0.1.42 → 0.1.43

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.42",
3
+ "version": "0.1.43",
4
4
  "description": "Stack-specific steering content for miaoda-coding templates",
5
5
  "type": "module",
6
6
  "files": [
@@ -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.
@@ -1243,7 +1243,21 @@ def layouts_from_template(d, shapes, cW, cH):
1243
1243
  sample_pages_of_layout = defaultdict(list)
1244
1244
  for slide_part, layout_part in lay_of_slide.items():
1245
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)
1246
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
1247
1261
  multi = len(topo.get('themes') or []) > 1
1248
1262
 
1249
1263
  rows = []
@@ -2394,7 +2408,9 @@ def emit_manifest(d, assets, vision_groups, ldir, archetypes=()):
2394
2408
  ' TODO: 一句话说清这套模板的视觉性格(底色 / 主色 / 字形 / 版面骨架),给消费模型定调。']
2395
2409
  themes = d['theme_topology'].get('themes') or ['single']
2396
2410
  if themes != ['single'] and len(themes) > 1:
2397
- 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]
2398
2414
  if assets:
2399
2415
  L.append('assets:')
2400
2416
  for a in assets:
@@ -2921,6 +2937,8 @@ def emit_layouts(archetypes, ldir, busy_hints=None, facts=None, recipes=None):
2921
2937
  L.append(' %s:' % a['name'])
2922
2938
  if a.get('role'):
2923
2939
  L.append(' role: %s' % a['role'])
2940
+ if a.get('theme'):
2941
+ L.append(' themes: [%s]' % a['theme'])
2924
2942
  if a['bg']:
2925
2943
  L.append(' background: %s' % a['bg'])
2926
2944
  fl = a.get('flow')
@@ -3056,6 +3074,8 @@ def emit_body(d, tokens, fonts, roles, assets, archetypes, exceptions, cusage, l
3056
3074
  '页数多于页型时,挑最接近的一个原样套用它的 slot:用不到的槽删掉,'
3057
3075
  '内容比槽多就按同类槽的间距等距加,**坐标一律沿用该页型给的那套,不要自己另起网格**。'
3058
3076
  '每个生成页面的 `<section>` 都写 `data-pptx-layout="<页型名>"`,'
3077
+ '多主题包默认只选兼容 `default-theme` 的页型;确需切换时,该页同时写 '
3078
+ '`data-pptx-theme="<主题名>"`,且主题必须属于该页型的 `themes`。'
3059
3079
  '交付前据此核验该页型绑定的背景与图片资产均已使用,且没有跨页型误用。' % sidecar,
3060
3080
  '3. **按页型给的形态落元素** —— 页型给 `flow` 就用流式,给 `slots` 就用绝对,'
3061
3081
  '两者只会出现一个。'
@@ -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': {
@@ -128,6 +128,31 @@ class LayoutAssetContractTest(unittest.TestCase):
128
128
  '---\n'
129
129
  )
130
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
+
131
156
  def test_logo_is_limited_to_every_archetype_with_its_slot(self):
132
157
  with tempfile.TemporaryDirectory() as root:
133
158
  self.write_pack(root)
@@ -200,6 +225,102 @@ class LayoutAssetContractTest(unittest.TestCase):
200
225
  validate_layout_assets(root, html, 'assets/pptx-volcengine'),
201
226
  )
202
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
+
203
324
  def test_logo_in_global_css_is_rejected(self):
204
325
  with tempfile.TemporaryDirectory() as root:
205
326
  self.write_pack(root)
@@ -207,6 +207,7 @@ class SlideAssetParser(HTMLParser):
207
207
  tag == 'section' and parent and parent['tag'] == 'deck-stage')
208
208
  slide = ({
209
209
  'layout': attrs_map.get('data-pptx-layout'),
210
+ 'theme': attrs_map.get('data-pptx-theme'),
210
211
  'references': [],
211
212
  } if slide_root else parent_slide)
212
213
  if slide_root:
@@ -270,8 +271,6 @@ def validate_layout_assets(pack_dir, html_path, asset_prefix):
270
271
  pack = Pack(pack_dir)
271
272
  owners = layout_asset_contract(pack)
272
273
  known_urls = asset_urls(pack, asset_prefix, owners)
273
- if not known_urls:
274
- return []
275
274
  asset_urls_by_id = {
276
275
  asset_id: url
277
276
  for url, asset_ids in known_urls.items()
@@ -285,6 +284,10 @@ def validate_layout_assets(pack_dir, html_path, asset_prefix):
285
284
  parser.close()
286
285
 
287
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
288
291
  for url in parser.outside_urls:
289
292
  for asset_id in sorted(known_urls.get(normalized_path(url), ())):
290
293
  problems.append(
@@ -297,8 +300,26 @@ def validate_layout_assets(pack_dir, html_path, asset_prefix):
297
300
  if layout not in pack.layouts:
298
301
  problems.append('第 %d 页声明了不存在的模板页型: %s' % (number, layout))
299
302
  continue
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 []
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
300
321
  expected = layout_asset_instances(
301
- pack.layouts[layout][0], set(pack.assets), pack.canvas)
322
+ layout_entry, set(pack.assets), pack.canvas)
302
323
  actual = []
303
324
  for reference in slide['references']:
304
325
  referenced_ids = set()
@@ -71,6 +71,7 @@ available-agents:
71
71
  | 实时多人编辑、毫秒级协同 | 弱实时刷新、提交后同步 |
72
72
  | 原生 App、桌面端、浏览器插件 | Web 应用、响应式页面 |
73
73
  | 服务端持久写本地文件 | 平台文件服务、数据库、临时 `/tmp` |
74
+ | 服务启动时下载大文件、全量同步或批量迁移 | 采用异步处理,避免阻塞服务启动 |
74
75
  | 无凭证调用受限第三方系统 | 要求用户提供 API、凭证或授权方式 |
75
76
  | 自建账号体系绕过平台登录 | 使用平台内置身份与权限 |
76
77
  | 多语言 i18n、深浅色主题切换(非平台内置) | 需自行实现并计入工作量;规格中先确认是否必要 |
@@ -232,8 +232,9 @@ shared/ # 前后端共享的目录
232
232
  - **三方集成**:调用第三方 API 需在后端实现,使用 @nestjs/axios
233
233
  - **能力边界**:服务端不支持文件上传(FaaS 限制),前端用 dataloom SDK 上传,服务端仅保存元信息
234
234
  - **环境判断**:`process.env.NODE_ENV === "production"` 表示生产环境
235
- - **文件系统**:**严禁使用 `fs` 写入非 `/tmp` 路径**(无状态 FaaS 容器)
235
+ - **文件系统**:临时文件、下载内容和处理中间产物统一写入 `/tmp` 下的独立目录,用完后清理
236
236
  - **服务端运行时资源文件**(字体 / 证书 / 模板 / wasm 等需在运行时读取的非代码文件,CRITICAL — 发布后静默失效根因):① 必须在 `nest-cli.json` 的 `assets` 中声明(如 `{"include": "assets/<dir>/**/*", "outDir": "dist/server"}`),否则不会进 `dist/` 构建产物;② 路径用 `__dirname` 相对**编译产物**定位(如 `path.join(__dirname, "../assets/...")`),**禁止 `process.cwd()` 相对源码路径**——dev 跑源码能命中、发布跑 `dist/` 会落空;③ 资源缺失或加载失败必须 **fail-loud**(抛错或明确错误日志),禁止 `catch` 后静默返回残缺产物
237
+ - **启动生命周期**:`constructor`、`onModuleInit` 和 `onApplicationBootstrap` 应快速完成,避免在其中下载大文件、全量同步或批量迁移;仅将钩子声明为 `async` 仍会被等待,无法缩短启动时间
237
238
 
238
239
  ## 日志约定
239
240