feat(slides): harden svglide visual pipeline

This commit is contained in:
songtianyi.theo
2026-06-17 18:09:46 +08:00
parent 9e5f94b92b
commit 27062ee254
28 changed files with 6161 additions and 1848 deletions

View File

@@ -203,6 +203,30 @@ SVG 创建不使用单独的规划目录。新建或大幅改写 SVG deck 时,
}
```
每页还必须有 `visual_design_contract`。它不是新的一套平行 plan而是把现有
`seed_id``visual_recipe``renderer_id``design_pattern_selection` 和页面
视觉目标锁成可验收字段:
```json
{
"visual_design_contract": {
"schema_version": "svglide-visual-design-contract/v1",
"page_kind": "chart_takeaway",
"visual_thesis": "这一页视觉上要让用户记住的结论",
"composition_archetype": "data_stage",
"pattern_bundle": ["chart.bar_chart"],
"density": "dense",
"primary_motif": "takeaway_chart",
"required_visual_evidence": ["chart_geometry", "insight_strip", "full_page_archetype"]
}
}
```
`required_visual_evidence` 必须由 renderer 写入
`receipts/emitted_components.json` 的 page-level component `effects`
`primitives``renderer_id` 或 component id。`quality_gate` 会核对这些证据;
只在 plan 里声明但 SVG/component report 没有落地时不得进入 dry-run/live。
模板也复用现有 `template_tool.py search -> summarize -> extract` 路由。模板摘要只用于选择主题、页面流、视觉节奏和布局骨架;生成 SVG 时要把模板结构翻译成 SVG layout boxes / visual recipes不要照搬模板 XML也不要读取完整模板 XML。
SVG deck 的 `slides[]` 还必须包含这些可校验字段,避免生成结果虽然能创建但内容千篇一律、信息量不足或在资料缺失时编造事实:
@@ -244,7 +268,7 @@ SVG deck 的 `slides[]` 还必须包含这些可校验字段,避免生成结
"visual_intent": "use a product-console dashboard surface to make metrics feel operational",
"visual_focal_point": "central metric card and trend line",
"visual_signature": "fake product console frame + micro chart geometry + status chips",
"reference_asset": {"source": "ppt-master", "asset_id": "chart.kpi_cards", "usage": "page-type geometry only; do not copy raw SVG paths"},
"reference_asset": {"source": "svglide_design_pattern", "asset_id": "chart.kpi_cards", "usage": "page-type geometry only; do not copy raw SVG paths"},
"svg_effects": ["chart_geometry", "connector_flow", "typography"],
"required_primitives": ["dashboard", "micro_chart"],
"svg_primitives": ["dashboard", "micro_chart", "typography", "geometric_shape"],
@@ -267,16 +291,16 @@ SVG deck 的 `slides[]` 还必须包含这些可校验字段,避免生成结
}
```
### PPT Master Lessons For SVGlide
### SVGlide Design Pattern Lessons
`ppt-master` 的可借鉴点是生成流程和页型合同,不是 PPTX/DrawingML 导出链路,也不是复制现成 SVG。SVGlide 生成时必须把它的经验收敛成 protocol-safe 字段:
SVGlide design pattern 的可借鉴点是生成流程和页型合同,不是 PPTX/DrawingML 导出链路,也不是复制现成 SVG。SVGlide 生成时必须把经验收敛成 protocol-safe 字段:
- `page_rhythm`: `anchor` / `breathing` / `dense`。8 页以上 deck 不能全是 dense 或全是同一张卡片结构;封面、目录、章节、收尾应形成节奏。
- `page_type`: 页面叙事类型,例如 `cover``editor_note``contents``chart_takeaway``chapter``closing`
- `chart_type`: 当页面主视觉是图表时必须声明,例如 `kpi_cards``bar_chart``dumbbell_chart``bubble_chart``donut_chart``horizontal_bar_chart``comparison_table``sankey_chart``pareto_chart``hub_spoke``dual_axis_line_chart``quadrant_text_bullets`
- `main_visual_anchor`: 一句话说明这页的主视觉锚点。不能写成“高级图表”或“信息可视化”;必须能被截图肉眼确认。
- `annotation_zone`: 右侧观察、底部 source、图例或 callout 的固定区域。密集页靠图表承载信息,文字只做解释。
- `reference_asset`: 记录借鉴的 ppt-master layout/chart/image/style 资产,只表示结构和选择理由,不表示复制 raw SVG。
- `reference_asset`: 记录借鉴的 SVGlide design pattern layout/chart/image/style 资产,只表示结构和选择理由,不表示复制 raw SVG。
经验规则:
@@ -289,7 +313,7 @@ SVG deck 的 `slides[]` 还必须包含这些可校验字段,避免生成结
经验归属:
- 本文件只保留 create-svg 的入口流程、plan 字段边界和 CLI 发布约束。
- 页型、图表几何、视觉节奏和 ppt-master 借鉴规则归 [svglide-craft.md](svglide-craft.md)。
- 页型、图表几何、视觉节奏和 SVGlide design pattern 借鉴规则归 [svglide-craft.md](svglide-craft.md)。
- 项目目录、stage、timing、prepare resume、asset usage receipt 和 live guard 归 [svglide-project-pipeline.md](svglide-project-pipeline.md)。
- 自动门禁和 archetype drift 归 [validation-checklist.md](validation-checklist.md)。
- 截图/人工审美判断和 label/backing 遮挡经验归 [svg-aesthetic-review.md](svg-aesthetic-review.md)。
@@ -397,6 +421,7 @@ SVG 私有 recipe 只属于 `slides +create-svg` route用于鼓励更强的 S
| `layout_skeleton_id` | 绑定 seed 的机器化版式骨架 | 不匹配或缺失时从 seed 复制;大改结构必须换 seed |
| `layout_family` | 做 deck 级版式多样性检查 | 相邻页重复时换阅读方向、主视觉位置或信息结构 |
| `visual_recipe` | 说明这页为什么值得走 SVG | 从 `svg-recipes.json` 选择,不能自造枚举 |
| `visual_design_contract` | 锁定视觉 thesis、composition archetype、motif 和必须落地的 evidence | 补齐合同,或改 renderer/component report 证明 evidence |
| `layout_boxes` | seed 派生的标题、正文、视觉、chart、footer 坐标 | 缺角色或 bbox 非正数时先修布局 |
| `content_budget` / `text_capacity` | seed 派生的文本容量预算 | 超量时删内容、拆页或换 seed |
| `text_budget_by_role` | seed 派生的 role 级文本预算 | 局部超量时删内容、拆页或换 seed不用缩字/竖排硬塞 |
@@ -445,7 +470,7 @@ comparison >= 4 rows or columns
- MUST: 主体元素使用安全区,建议 `safe = x:48 y:40 w:864 h:460`。除全屏背景外,文本、卡片、图表、标签、节点和图例都必须落在安全区内。
- MUST: 多页 deck 应包含明确的 closing slide。8 页以上讲解/汇报型 deck 不要把 roadmap / next-playbook 当作结束页;最后一页应包含 `closing``summary``Q&A``Thanks` 或下一步联系信息。
- MUST: `slides[]` 必须记录 `renderer_id`,且它要对应真实几何结构,而不是 `two-column-1` / `two-column-2` 这种名字变化。10 页以上 deck 至少 5 种 renderer/layout family不得连续 3 页使用同一 renderer。
- MUST: `slides[]` 必须记录 `seed_id``layout_skeleton_id``layout_family``layout_boxes``content_budget``text_capacity``text_budget_by_role``reserved_bands.footer``footer_safe_zone``vertical_text_policy``one_idea``key_message``visual_recipe``visual_intent``visual_focal_point``required_primitives``svg_primitives``xml_like_risk``content_density_contract``risk_flags``source_policy``asset_contract` 应尽量记录MVP 阶段缺失只 warning。没有 SVG-native recipe 的页面不应走 `slides +create-svg`,应改用普通 Slides XML 或重新选择 SVG recipe。
- MUST: `slides[]` 必须记录 `seed_id``layout_skeleton_id``layout_family``layout_boxes``content_budget``text_capacity``text_budget_by_role``reserved_bands.footer``footer_safe_zone``vertical_text_policy``one_idea``key_message``visual_recipe``visual_intent``visual_focal_point``visual_design_contract``required_primitives``svg_primitives``xml_like_risk``content_density_contract``risk_flags``source_policy``asset_contract` 应尽量记录MVP 阶段缺失只 warning。没有 SVG-native recipe 的页面不应走 `slides +create-svg`,应改用普通 Slides XML 或重新选择 SVG recipe。
- MUST: `visual_recipe` 必须来自 catalog`svg_primitives` 必须覆盖该 recipe 的 required primitives。`renderer_id` 不能替代 `visual_recipe`
- MUST: `seed_id` 必须来自 `svg-seeds.json`seed 的 `visual_recipe` / `layout_family` 必须和 plan 一致。`route_private` 只隐藏具体 private recipe不跳过 seed/layout/budget 控制。
- MUST: seed skeleton 是版式合同,不是灵感参考。生成器必须继承 seed 的 `required_layout_box_roles``layout_skeleton_id``layout_boxes``text_budget_by_role``footer_safe_zone``vertical_text_policy`;内容放不下时删减、拆页或换 seed不得放宽 seed budget 或自由重画骨架。

View File

@@ -1,100 +0,0 @@
# ppt-master Asset Inventory
This file is generated by `skills/lark-slides/scripts/ppt_master_asset_inventory.py`.
The JSON asset map is the source of truth; this Markdown is a human-readable summary.
## Safety Defaults
- Raw ppt-master SVG assets start as `needs_normalization`, `derive_contract_only`, `reference_only`, and `candidate`.
- Icon assets are summarized by library and searchable index metadata; individual icon SVG files are not injected into the main prompt.
- Examples are summarized by project/page/media counts and treated as reference corpus, not runtime assets.
- Production/golden selection must not use rejected, reference-only, or raw unnormalized assets.
## Counts
| Kind | Count |
| --- | ---: |
| `brand_preset` | 2 |
| `chart_template` | 71 |
| `deck_template` | 8 |
| `example_media_files` | 259 |
| `example_pages` | 356 |
| `example_project` | 21 |
| `icon_library` | 5 |
| `icon_svg_files` | 11631 |
| `image_palette` | 14 |
| `image_reference_collection` | 3 |
| `image_rendering` | 20 |
| `image_type_template` | 11 |
| `layout_template` | 7 |
| `narrative_mode` | 5 |
| `total_resources` | 191 |
| `visual_style` | 18 |
| `workflow_reference` | 6 |
## Digests
| Scope | SHA-256 prefix |
| --- | --- |
| `all_source_files` | `42030551448bb8d1` |
| `brands` | `e43ae292b361bac3` |
| `charts` | `ca05fc31a7a98182` |
| `decks` | `4e5d701c8b5f949e` |
| `examples` | `33acfc6626f61c7e` |
| `icons` | `bf1ee25d54ae5702` |
| `layouts` | `9be264ac4ac3699b` |
| `references` | `f7c3b07574ea660b` |
## Resource Summary
| Kind | Example IDs |
| --- | --- |
| `brand_preset` | `brand.anthropic`, `brand.google` |
| `chart_template` | `chart.agenda_list`, `chart.arc_anchored_list`, `chart.area_chart`, `chart.bar_chart`, `chart.basic_table`, `chart.box_plot_chart`, `chart.bubble_chart`, `chart.bullet_chart`, ... (+63) |
| `deck_template` | `deck.中国电信`, `deck.中国电建_常规`, `deck.中国电建_现代`, `deck.中汽研_商务`, `deck.中汽研_常规`, `deck.中汽研_现代`, `deck.招商银行`, `deck.重庆大学` |
| `example_project` | `example.ppt169_attention_is_all_you_need`, `example.ppt169_brutalist_ai_newspaper_2026`, `example.ppt169_building_effective_agents`, `example.ppt169_cangzhuo`, `example.ppt169_fashion_weekly_digest`, `example.ppt169_general_dark_tech_claude_code_auto_mode`, `example.ppt169_glassmorphism_demo`, `example.ppt169_global_ai_capital_2026`, ... (+13) |
| `icon_library` | `icon_library.chunk-filled`, `icon_library.phosphor-duotone`, `icon_library.simple-icons`, `icon_library.tabler-filled`, `icon_library.tabler-outline` |
| `image_palette` | `image_palette.cool-corporate`, `image_palette.dark-cinematic`, `image_palette.duotone`, `image_palette.earthy-dusty`, `image_palette.editorial-classic`, `image_palette.frost-ice`, `image_palette.jewel-tone`, `image_palette.macaron`, ... (+6) |
| `image_reference_collection` | `image_reference_collection.palette`, `image_reference_collection.rendering`, `image_reference_collection.type` |
| `image_rendering` | `image_rendering.3d-isometric`, `image_rendering.blueprint`, `image_rendering.chalkboard`, `image_rendering.corporate-photo`, `image_rendering.digital-dashboard`, `image_rendering.editorial`, `image_rendering.fantasy-animation`, `image_rendering.flat`, ... (+12) |
| `image_type_template` | `image_type.comparison`, `image_type.cycle`, `image_type.flowchart`, `image_type.framework`, `image_type.funnel`, `image_type.infographic`, `image_type.map`, `image_type.matrix`, ... (+3) |
| `layout_template` | `layout.academic_defense`, `layout.ai_ops`, `layout.government_blue`, `layout.government_red`, `layout.medical_university`, `layout.pixel_retro`, `layout.psychology_attachment` |
| `narrative_mode` | `mode.briefing`, `mode.instructional`, `mode.narrative`, `mode.pyramid`, `mode.showcase` |
| `visual_style` | `visual_style.blueprint`, `visual_style.brutalist`, `visual_style.chalkboard`, `visual_style.dark-tech`, `visual_style.data-journalism`, `visual_style.editorial`, `visual_style.glassmorphism`, `visual_style.ink-notes`, ... (+10) |
| `workflow_reference` | `workflow.executor-base`, `workflow.image-layout-patterns`, `workflow.image-layout-spec`, `workflow.shared-standards`, `workflow.strategist`, `workflow.visual-review` |
## Example Corpus
| Example | Pages | Media | Base64 | Digest |
| --- | ---: | ---: | ---: | --- |
| `example.ppt169_attention_is_all_you_need` | 16 | 11 | 11 | `38bc99f26cc47232` |
| `example.ppt169_brutalist_ai_newspaper_2026` | 10 | 5 | 5 | `4d2b449dd8110214` |
| `example.ppt169_building_effective_agents` | 12 | 8 | 8 | `fb5afb97ba4c1718` |
| `example.ppt169_cangzhuo` | 14 | 6 | 6 | `578f91a97e1c81b3` |
| `example.ppt169_fashion_weekly_digest` | 16 | 23 | 22 | `ba01eb82f8842691` |
| `example.ppt169_general_dark_tech_claude_code_auto_mode` | 10 | 4 | 4 | `e9d6f351c60915cb` |
| `example.ppt169_glassmorphism_demo` | 12 | 8 | 8 | `bb17f9a2001bcaca` |
| `example.ppt169_global_ai_capital_2026` | 20 | 3 | 3 | `3377a4a832f87b5f` |
| `example.ppt169_high_rise_renewal` | 15 | 25 | 21 | `ba8a732dc29cb2c1` |
| `example.ppt169_home_design_trends_2026` | 12 | 24 | 22 | `f1dc9774224cf00e` |
| `example.ppt169_image_text_showcase` | 20 | 22 | 22 | `e077d46f669fac80` |
| `example.ppt169_indie_bookstore_zine_guide` | 18 | 10 | 10 | `53d75c7fc3ff0b14` |
| `example.ppt169_kimsoong_loyalty_programme` | 10 | 4 | 3 | `ebc6c414ab46be63` |
| `example.ppt169_kubernetes_blueprint_2026` | 10 | 0 | 0 | `132c3d1a12c190c2` |
| `example.ppt169_lin_huiyin_architect` | 10 | 14 | 10 | `20b9855195eb7b96` |
| `example.ppt169_lin_huiyin_architect_revised` | 9 | 13 | 10 | `cf255646e3ac6c65` |
| `example.ppt169_liziqi_plant_dye_colors` | 12 | 32 | 16 | `f6b0d492a8afb107` |
| `example.ppt169_lora_hu_2021` | 15 | 10 | 10 | `5e0b0e096424d588` |
| `example.ppt169_pritzker_2026` | 11 | 25 | 20 | `f629a8386f3c896c` |
| `example.ppt169_sugar_rush_memphis` | 14 | 9 | 9 | `bcdeafc36693f36d` |
| `example.ppt169_swiss_grid_systems` | 14 | 3 | 3 | `1823080b6fef9ef4` |
## Icon Libraries
| Library | Icons | Policy |
| --- | ---: | --- |
| `icon_library.chunk-filled` | 640 | library_summary_only |
| `icon_library.phosphor-duotone` | 1248 | library_summary_only |
| `icon_library.simple-icons` | 3651 | library_summary_only |
| `icon_library.tabler-filled` | 1053 | library_summary_only |
| `icon_library.tabler-outline` | 5039 | library_summary_only |

View File

@@ -114,7 +114,7 @@
"style_id": "data_journalism_editorial",
"display_name": "Data Journalism Editorial",
"group": "Restrained",
"source_token": "ppt-master/global_ai_capital_2026",
"source_token": "svglide/editorial_ai_capital_2026",
"formality": "high",
"vibe": ["editorial", "data_journalism", "financial", "cinematic"],
"best_for": ["hero_typography", "infographic_scorecard", "path_flow", "geometric_composition", "icon_capability_map"],

View File

@@ -1,6 +1,6 @@
# SVGlide Style Presets
`style-presets.json` is the runtime source of truth for the 35 `beautiful-feishu-whiteboard` style presets plus the SVGlide `data_journalism_editorial` preset distilled from ppt-master. This Markdown file is only a human-readable guide.
`style-presets.json` is the runtime source of truth for the 35 `beautiful-feishu-whiteboard` style presets plus the SVGlide `data_journalism_editorial` preset. This Markdown file is only a human-readable guide.
## Boundary
@@ -57,9 +57,9 @@ Use `visual_plan` as a nested container when useful. `svg_preflight.py` accepts
3. Keep the semantic plan stable. Switching from `raw_grid` to `reading_room` should change visual treatment, not invent new facts or rearrange the story.
4. Pick page-level overrides only for cover, section divider, or poster-like moments. Most slides should inherit the deck-level `style_preset`.
## PPT Master Editorial Preset
## SVGlide Editorial Preset
Use `data_journalism_editorial` when a deck needs the dark, data-journalism feel seen in ppt-master's `ppt169_global_ai_capital_2026` example. Translate the reference into SVGlide-safe parts: dark graphite ground, large editorial title hierarchy, thin chart rules, small source/footer text, restrained red numeric emphasis, and real chart geometry. Do not copy ppt-master SVG paths, images, or PPTX export assumptions.
Use `data_journalism_editorial` when a deck needs a dark data-journalism feel. Translate the preset into SVGlide-safe parts: dark graphite ground, large editorial title hierarchy, thin chart rules, small source/footer text, restrained red numeric emphasis, and real chart geometry. Do not copy raw SVG paths, images, or PPTX export assumptions.
## SVGlide-Safe Translation

View File

@@ -86,10 +86,9 @@ Project runner 中的 `svg_preview_lint.py` 负责缺 preview、破损 SVG、明
- 图表/流程/表格选择是否适合它要表达的关系?
- 颜色和强调方式是否和整套 deck 保持一致?
## PPT Master / AI Capital Archetype Review
## SVGlide Archetype Review
`ppt-master` 样张和本地 AI Capital 多轮 preview 得到的经验要落在渲染后审查,
而不是只写进 prompt。
SVGlide archetype 样张和本地多轮 preview 得到的经验要落在渲染后审查,而不是只写进 prompt。
- 第一眼必须看出页型cover、contents、section、bubble chart、donut chart、
sankey/flow、hub-spoke、table、closing 等不能互相退化成同一种卡片页。

View File

@@ -383,7 +383,7 @@
"body": 180,
"footer": 34,
"max_text_boxes": 8,
"min_text_boxes": 4
"min_text_boxes": 3
},
"default_text_capacity": {
"max_visible_chars": 260,
@@ -391,7 +391,7 @@
"body": 180,
"footer": 34,
"max_text_boxes": 8,
"min_text_boxes": 4
"min_text_boxes": 3
},
"reserved_bands": {
"footer": {
@@ -677,7 +677,7 @@
},
{
"id": "left-panel",
"role": "body",
"role": "visual",
"x": 72,
"y": 136,
"width": 384,
@@ -685,7 +685,7 @@
},
{
"id": "right-panel",
"role": "body",
"role": "visual",
"x": 504,
"y": 136,
"width": 384,
@@ -739,7 +739,6 @@
],
"required_layout_box_roles": [
"title",
"body",
"table",
"footer"
],
@@ -752,13 +751,11 @@
"id": "comparison_two_column_decision_skeleton",
"reading_order": [
"title",
"body",
"table",
"footer"
],
"locked_roles": [
"title",
"body",
"table",
"footer"
],
@@ -800,6 +797,18 @@
"max_boxes": 2,
"min_font_px": 12
},
"table": {
"max_chars": 180,
"max_lines": 2,
"max_boxes": 9,
"min_font_px": 11
},
"visual": {
"max_chars": 180,
"max_lines": 2,
"max_boxes": 9,
"min_font_px": 11
},
"footer": {
"max_chars": 36,
"max_lines": 1,
@@ -1128,10 +1137,16 @@
"min_font_px": 12
},
"metric": {
"max_chars": 80,
"max_chars": 110,
"max_lines": 3,
"max_boxes": 1,
"min_font_px": 12
"max_boxes": 7,
"min_font_px": 9
},
"chart": {
"max_chars": 40,
"max_lines": 1,
"max_boxes": 4,
"min_font_px": 8
},
"footer": {
"max_chars": 34,
@@ -1357,6 +1372,14 @@
"width": 548,
"height": 128
},
{
"id": "secondary-metric-grid",
"role": "metric",
"x": 348,
"y": 106,
"width": 548,
"height": 128
},
{
"id": "chart-row",
"role": "chart",
@@ -1481,10 +1504,16 @@
"min_font_px": 12
},
"metric": {
"max_chars": 80,
"max_chars": 110,
"max_lines": 3,
"max_boxes": 1,
"min_font_px": 12
"max_boxes": 7,
"min_font_px": 9
},
"chart": {
"max_chars": 40,
"max_lines": 1,
"max_boxes": 4,
"min_font_px": 8
},
"footer": {
"max_chars": 36,

View File

@@ -38,9 +38,9 @@ choose style_preset
每页 plan 必须有 `seed_id``layout_skeleton_id``layout_boxes``content_budget``text_capacity``text_budget_by_role``one_idea``key_message``reserved_bands.footer``footer_safe_zone``vertical_text_policy`。如果内容放不进 seed不要从空白画布重画先删内容、拆页或换一个更合适的 seed。
## PPT Master Lessons
## SVGlide Design Pattern Lessons
`ppt-master` 好看的关键是页型先行和节奏先行。SVGlide 只能借鉴流程与结构合同,不复制 PPTX 导出、DrawingML 限制或 raw SVG path。
SVGlide 内置设计模式的关键是页型先行和节奏先行。生成时只能借鉴流程与结构合同,不复制 PPTX 导出、DrawingML 限制或 raw SVG path。
每页先锁定:
@@ -51,7 +51,7 @@ choose style_preset
"chart_type": "bar_chart | sankey_chart | hub_spoke | ...",
"main_visual_anchor": "the visible chart/scene/motif that makes this page memorable",
"annotation_zone": {"role": "right_observation", "x": 690, "y": 126, "width": 206, "height": 246},
"reference_asset": {"source": "ppt-master", "asset_id": "chart.bar_chart", "usage": "geometry pattern only"}
"reference_asset": {"source": "svglide_design_pattern", "asset_id": "chart.bar_chart", "usage": "geometry pattern only"}
}
```

View File

@@ -0,0 +1,53 @@
# SVGlide Design Pattern Inventory
This file describes internal SVGlide design patterns used by the SVG generation pipeline.
The CLI must not read an external slide-generation project at runtime; all usable patterns here are abstracted into SVGlide-owned ids, contracts, and renderer inputs.
## Policy
- Runtime dependency: none.
- Source SVG/PPTX assets are not copied into output.
- Patterns represent page rhythm, layout archetypes, chart geometry, style cues, image composition, and review heuristics.
- Production use still requires a normalized SVGlide renderer, clear asset/license status, and quality gate evidence.
## Counts
- `brand_preset`: 2
- `chart_template`: 71
- `deck_template`: 8
- `example_media_files`: 259
- `example_pages`: 356
- `example_project`: 21
- `icon_library`: 5
- `icon_svg_files`: 11631
- `image_palette`: 14
- `image_reference_collection`: 3
- `image_rendering`: 20
- `image_type_template`: 11
- `layout_template`: 7
- `narrative_mode`: 5
- `total_resources`: 191
- `visual_style`: 18
- `workflow_reference`: 6
## Pattern Kinds
- `brand_preset`: `brand.anthropic`, `brand.google`
- `chart_template`: `chart.agenda_list`, `chart.arc_anchored_list`, `chart.area_chart`, `chart.bar_chart`, `chart.basic_table`, `chart.box_plot_chart`, `chart.bubble_chart`, `chart.bullet_chart` (+63)
- `deck_template`: `deck.中国电信`, `deck.中国电建_常规`, `deck.中国电建_现代`, `deck.中汽研_商务`, `deck.中汽研_常规`, `deck.中汽研_现代`, `deck.招商银行`, `deck.重庆大学`
- `example_project`: `example.svglide_16x9_attention_is_all_you_need`, `example.svglide_16x9_brutalist_ai_newspaper_2026`, `example.svglide_16x9_building_effective_agents`, `example.svglide_16x9_cangzhuo`, `example.svglide_16x9_fashion_weekly_digest`, `example.svglide_16x9_general_dark_tech_claude_code_auto_mode`, `example.svglide_16x9_glassmorphism_demo`, `example.svglide_16x9_editorial_ai_capital_2026` (+13)
- `icon_library`: `icon_library.chunk-filled`, `icon_library.phosphor-duotone`, `icon_library.simple-icons`, `icon_library.tabler-filled`, `icon_library.tabler-outline`
- `image_palette`: `image_palette.cool-corporate`, `image_palette.dark-cinematic`, `image_palette.duotone`, `image_palette.earthy-dusty`, `image_palette.editorial-classic`, `image_palette.frost-ice`, `image_palette.jewel-tone`, `image_palette.macaron` (+6)
- `image_reference_collection`: `image_reference_collection.palette`, `image_reference_collection.rendering`, `image_reference_collection.type`
- `image_rendering`: `image_rendering.3d-isometric`, `image_rendering.blueprint`, `image_rendering.chalkboard`, `image_rendering.corporate-photo`, `image_rendering.digital-dashboard`, `image_rendering.editorial`, `image_rendering.fantasy-animation`, `image_rendering.flat` (+12)
- `image_type_template`: `image_type.comparison`, `image_type.cycle`, `image_type.flowchart`, `image_type.framework`, `image_type.funnel`, `image_type.infographic`, `image_type.map`, `image_type.matrix` (+3)
- `layout_template`: `layout.academic_defense`, `layout.ai_ops`, `layout.government_blue`, `layout.government_red`, `layout.medical_university`, `layout.pixel_retro`, `layout.psychology_attachment`
- `narrative_mode`: `mode.briefing`, `mode.instructional`, `mode.narrative`, `mode.pyramid`, `mode.showcase`
- `visual_style`: `visual_style.blueprint`, `visual_style.brutalist`, `visual_style.chalkboard`, `visual_style.dark-tech`, `visual_style.data-journalism`, `visual_style.editorial`, `visual_style.glassmorphism`, `visual_style.ink-notes` (+10)
- `workflow_reference`: `workflow.executor-base`, `workflow.image-layout-patterns`, `workflow.image-layout-spec`, `workflow.shared-standards`, `workflow.strategist`, `workflow.visual-review`
## Runtime Contract
- Select patterns through `design_pattern_selection.selected_assets`.
- Prove emitted usage through `receipts/design-pattern-usage.json`.
- Store page-level evidence in `page_usages[].component_ids` and `source_trace`.

View File

@@ -147,16 +147,16 @@ Disallowed P0 behavior:
- mutate authoring `pages/*.svg`
- replace images or tokens outside the existing `slides +create-svg` transport path
## PPT Master Asset Receipt
## SVGlide Design Pattern Receipt
`ppt-master` references are allowed only as structure, rhythm, chart geometry,
SVGlide design pattern references are allowed only as structure, rhythm, chart geometry,
style, and review inspiration. They are not runtime dependencies and raw SVG/PPTX
assets must not be copied into SVGlide output.
For quality lanes, `selected_assets` means "actually used by the generated
pages", not "interesting candidates found during research". Candidate assets
stay in research notes. Used assets must be proven by
`receipts/ppt-master-asset-usage.json` with page-level trace entries that point
`receipts/design-pattern-usage.json` with page-level trace entries that point
to the SVG evidence. The quality gate must fail when `selected_assets` includes
an asset that is not present in the usage receipt.
@@ -195,9 +195,12 @@ without `preview/preview.html`, but it must not proceed to guarded live creation
or production/golden delivery until preview lint and quality gate have passed.
`quality_gate` reads the latest preflight receipt, preview lint receipt, raster
report, allowlist, asset selection, and component report evidence. During P0
migration, authoring/debug dry-run may use an unexpired legacy component waiver.
Production, golden, and live lanes must not use that waiver.
report, allowlist, asset selection, visual design contract, and component report
evidence. If a slide declares `visual_design_contract.required_visual_evidence`,
the same page in `receipts/emitted_components.json` must prove those evidence
tokens through component `effects`, `primitives`, `renderer_id`, or component id.
During P0 migration, authoring/debug dry-run may use an unexpired legacy
component waiver. Production, golden, and live lanes must not use that waiver.
## PPE Proof
@@ -265,5 +268,5 @@ asset, or protocol values.
- Do not introduce a fourth structure catalog.
- Do not modify Open Design for this rollout.
- Do not rewrite Go CLI code in P0.
- Do not copy `ppt-master` runtime or assets.
- Do not copy external runtime or raw assets.
- Do not treat local preview as a replacement for live readback.

View File

@@ -65,7 +65,9 @@ python3 skills/lark-slides/scripts/svg_preflight.py \
- public `visual_recipe` 必须存在于 `references/svg-recipes.json`;研究文档里的 dotted recipe 名称不能直接写入 `slide_plan.json`
- `style_selection_reason` 必须说明为什么这个 preset 适合当前 deck。
- `style_system` 必须包含 palette、typography、background strategy 和 motif。
- 多页 deck 必须声明 `page_rhythm` / `deck_rhythm`用于检查节奏、密度变化和重复页风险authoring profile 下缺失会给 warning`validation_profile=golden` 或 strict profile 下为 error。
- 每页必须包含 `seed_id``layout_skeleton_id``layout_boxes``content_budget``text_capacity``text_budget_by_role``one_idea``key_message``reserved_bands.footer``footer_safe_zone``vertical_text_policy``visual_recipe``visual_signature``svg_effects``required_primitives``svg_primitives``xml_like_risk``content_density_contract``risk_flags``source_policy`
- Strategist contract 字段必须可检查:每页声明 `page_type`、可定位到 layout box / SVG element / component / bbox 的 `main_visual_anchor``reference_asset` 不能只写描述性文字,必须是 `{source, asset_id/id}` 或带 source/license/path 的资产元数据。
- declared `svg_effects``required_primitives` 必须能在对应 SVG source 中命中。
- 可见 slide 文本不得泄漏 preset 名称、source token、prompt、tool name 或本地文件路径。
@@ -96,6 +98,11 @@ python3 skills/lark-slides/scripts/svg_preflight.py \
| `plan_missing_visual_signature` | 页面没有声明 SVG 视觉记忆点 | 写清这页相对普通 PPT/XML 模板的独特视觉结构 |
| `plan_missing_svg_effects` | 页面没有声明 SVG 表达能力 | 声明真实会绘制的 `path``connector_flow``gradient``texture``chart_geometry` 等 |
| `plan_svg_effect_not_found` | plan 声明的 effect 没在 SVG source 中出现 | 修改 SVG source或删除不真实的 effect 声明 |
| `plan_missing_page_rhythm` | 多页 deck 没有声明节奏合同 | 添加 deck-level `page_rhythm`,说明封面/章节/内容/总结节奏和重复页约束 |
| `plan_missing_page_type` | 页面缺少可检查页型 | 添加 `page_type`,并让 renderer/layout/visual_recipe 与页型一致 |
| `plan_missing_main_visual_anchor` | 主视觉锚点缺失或只是自然语言 | 指向 layout box role、`#svg-element-id`、component_id或写明确 bbox |
| `plan_main_visual_anchor_not_met` | SVG source 没有在主视觉锚点区域生成可见几何 | 调整 source SVG把主视觉放回声明的锚点区域或先更新 plan anchor |
| `plan_reference_asset_unstructured` | `reference_asset` 是纯文字或缺少 source/id/path | 改成结构化 source metadata没有参考资产时显式写 no_asset |
| `plan_style_preset_visible_leak` | 可见文本泄漏 preset 名/source token | 仅在 plan metadata 中保留 preset 信息,画面只写用户主题内容 |
## SVGlide Aesthetic Preview Review
@@ -116,14 +123,22 @@ SVGlide 项目必须同时检查计划、执行 manifest、SVG source 和 receip
preflight 只检查已传入的子集。
- `prepare` receipt 在 plan、manifest 或 source SVG 变化后必须失效;后续
`preflight``preview_lint``quality_gate``dry_run` 不得复用旧 prepare。
- 声明 `chart_type`ppt-master chart 参考时SVG 必须命中对应几何合同:
- 声明 `chart_type`SVGlide design pattern chart 参考时SVG 必须命中对应几何合同:
`bubble_chart` 至少有多枚圆形节点,`donut_chart` 至少有环形/圆形结构和中心
文本,`bar_chart` 至少有可识别轴/条形/数值区域。不能把图表页退化成普通
卡片、closing 或 bullet list。
- `ppt_master_asset_selection.selected_assets` 只放真正启用并落地的参考资产;
- `design_pattern_selection.selected_assets` 只放真正启用并落地的参考资产;
`enabled:false` 可作为候选保留,但不进入 quality gate。启用资产必须由
`receipts/ppt-master-asset-usage.json` 的 page-level trace 证明。
- `ppt-master` 参考只允许变成 SVGlide-safe 的页型、图表几何、节奏、色彩纪律
`receipts/design-pattern-usage.json` 的 page-level trace 证明。
- `visual_design_contract.required_visual_evidence` 必须由
`receipts/emitted_components.json` 的 page-level component `effects`
`primitives``renderer_id` 或 component id 证明。缺少 evidence 时
`quality_gate` 失败;这类问题不能只改 plan 字段,必须修 renderer 或 SVG。
- `quality_gate` 会把 preflight 中的 Strategist contract issue codes 写入
`strategist_contract` 摘要,并把 visual design contract 证明写入
`visual_design_contract` 摘要;`validation_profile=golden` 要求零 warning、结构化
component report以及正式 schema 的 design-pattern usage receipt。
- SVGlide design pattern 参考只允许变成 SVGlide-safe 的页型、图表几何、节奏、色彩纪律
和审查规则;不要复制 raw SVG、图片或 PPTX/DrawingML 导出实现。
通过标准:

View File

@@ -1,882 +0,0 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import argparse
import hashlib
import json
import re
from collections import Counter
from pathlib import Path
from typing import Iterable
SCHEMA_VERSION = "svglide-ppt-master-asset-map/v1"
REQUIRED_FIELDS = {
"id",
"source_path",
"kind",
"ppt_master_role",
"svglide_target",
"protocol_compatibility",
"conversion_strategy",
"activation_status",
"selection_tags",
"copy_policy",
"license_status",
"granularity",
"unsupported_features",
"normalized_primitives",
"non_migratable_reason",
"risk_flags",
"golden_example_required",
}
REQUIRED_KINDS = {
"brand_preset",
"layout_template",
"deck_template",
"chart_template",
"icon_library",
"visual_style",
"image_palette",
"image_rendering",
"image_type_template",
"narrative_mode",
"example_project",
"workflow_reference",
}
RAW_SVG_NORMALIZED_PRIMITIVES = [
"960x540_canvas",
"slide_role_shape",
"foreignObject_text",
"primitive_shapes",
]
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
TOKEN_RE = re.compile(r"[a-z0-9]+|[\u4e00-\u9fff]+", re.IGNORECASE)
class InventoryError(ValueError):
"""Raised when the local ppt-master tree cannot produce a valid inventory."""
def script_path() -> Path:
return Path(__file__).resolve()
def cli_worktree_root() -> Path:
return script_path().parents[3]
def default_source_root() -> Path:
return cli_worktree_root().parents[1] / "ppt-master"
def read_json(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def file_digest(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()[:16]
def digest_paths(source_root: Path, paths: Iterable[Path]) -> str:
digest = hashlib.sha256()
for path in sorted({p for p in paths if p.exists() and p.is_file()}):
rel = path.relative_to(source_root).as_posix()
digest.update(rel.encode("utf-8"))
digest.update(b"\0")
digest.update(str(path.stat().st_size).encode("ascii"))
digest.update(b"\0")
digest.update(file_digest(path).encode("ascii"))
digest.update(b"\0")
return digest.hexdigest()[:16]
def rel_source(path: Path, source_root: Path) -> str:
path = path.resolve()
source_root = source_root.resolve()
if path == source_root:
return source_root.name
return f"{source_root.name}/{path.relative_to(source_root).as_posix()}"
def slug(value: str) -> str:
normalized = re.sub(r"[^0-9A-Za-z_\-\u4e00-\u9fff]+", "_", value.strip())
normalized = re.sub(r"_+", "_", normalized).strip("_").lower()
return normalized or "item"
def tokenize(*values: object) -> list[str]:
tokens: set[str] = set()
for value in values:
if value is None:
continue
if isinstance(value, (list, tuple, set)):
tokens.update(tokenize(*value))
continue
tokens.update(match.group(0).lower() for match in TOKEN_RE.finditer(str(value)))
return sorted(tokens)
def limited_tags(*values: object, extra: Iterable[str] = ()) -> list[str]:
tags = tokenize(*values)
for tag in extra:
tags.extend(tokenize(tag))
return sorted(set(tags))[:32]
def files_under(path: Path) -> list[Path]:
if not path.exists():
return []
return sorted(p for p in path.rglob("*") if p.is_file())
def direct_md_files(path: Path) -> list[Path]:
return sorted(p for p in path.glob("*.md") if p.name != "_index.md")
def direct_dirs(path: Path) -> list[Path]:
return sorted(p for p in path.iterdir() if p.is_dir()) if path.exists() else []
def svg_files(path: Path) -> list[Path]:
if path.is_file() and path.suffix.lower() == ".svg":
return [path]
return sorted(p for p in path.rglob("*.svg") if p.is_file()) if path.exists() else []
def image_files(path: Path) -> list[Path]:
if not path.exists():
return []
return sorted(p for p in path.rglob("*") if p.is_file() and p.suffix.lower() in IMAGE_SUFFIXES)
def count_base64_images(paths: Iterable[Path]) -> int:
count = 0
for path in paths:
try:
count += path.read_text(encoding="utf-8", errors="ignore").count("data:image")
except OSError:
continue
return count
def detect_svg_unsupported(paths: Iterable[Path]) -> list[str]:
features: set[str] = set()
for path in paths:
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
lowered = text.lower()
if "<text" in lowered:
features.add("raw_svg_text")
if "slide:role" not in lowered:
features.add("missing_slide_role")
if "viewbox=\"0 0 1280 720\"" in lowered or "width=\"1280\"" in lowered or "height=\"720\"" in lowered:
features.add("1280x720_canvas")
if "<image" in lowered:
features.add("embedded_image")
if "data:image" in lowered:
features.add("embedded_base64_image")
if "<foreignobject" in lowered:
features.add("foreignObject")
if "<filter" in lowered:
features.add("filter_effect")
if "<mask" in lowered:
features.add("mask_effect")
if "<clippath" in lowered:
features.add("clipPath")
if "<use" in lowered:
features.add("symbol_use")
return sorted(features)
def make_resource(
*,
resource_id: str,
source_path: Path,
source_root: Path,
kind: str,
ppt_master_role: str,
svglide_target: str,
protocol_compatibility: str,
conversion_strategy: str,
selection_tags: Iterable[str],
copy_policy: str = "derive_contract_only",
license_status: str = "reference_only",
granularity: str = "file",
unsupported_features: Iterable[str] = (),
normalized_primitives: Iterable[str] = (),
non_migratable_reason: str = "",
risk_flags: Iterable[str] = (),
golden_example_required: bool = True,
activation_status: str = "candidate",
metadata: dict | None = None,
summary: str = "",
) -> dict:
source_files = files_under(source_path) if source_path.is_dir() else [source_path]
resource = {
"id": resource_id,
"source_path": rel_source(source_path, source_root),
"kind": kind,
"ppt_master_role": ppt_master_role,
"svglide_target": svglide_target,
"protocol_compatibility": protocol_compatibility,
"conversion_strategy": conversion_strategy,
"activation_status": activation_status,
"selection_tags": sorted(set(selection_tags)),
"copy_policy": copy_policy,
"license_status": license_status,
"granularity": granularity,
"unsupported_features": sorted(set(unsupported_features)),
"normalized_primitives": sorted(set(normalized_primitives)),
"non_migratable_reason": non_migratable_reason,
"risk_flags": sorted(set(risk_flags)),
"golden_example_required": golden_example_required,
"normalized_fixture": "",
"source_digest": digest_paths(source_root, source_files),
}
if summary:
resource["summary"] = summary
if metadata:
resource["metadata"] = metadata
return resource
def build_brand_resources(source_root: Path) -> list[dict]:
index_path = source_root / "skills/ppt-master/templates/brands/brands_index.json"
brands = read_json(index_path)
resources = []
for name, data in sorted(brands.items()):
root = index_path.parent / name
summary = data.get("summary", "")
svgs = svg_files(root)
resources.append(
make_resource(
resource_id=f"brand.{slug(name)}",
source_path=root,
source_root=source_root,
kind="brand_preset",
ppt_master_role="brand identity preset",
svglide_target="style-presets.json/brand-visual-tokens",
protocol_compatibility="needs_normalization",
conversion_strategy="Extract color, typography, spacing, and brand-tone contracts; do not copy brand marks into runtime output.",
selection_tags=limited_tags("brand", name, summary),
granularity="template_directory",
unsupported_features=sorted(set(detect_svg_unsupported(svgs) + ["brand_logo_assets"])),
normalized_primitives=["color_tokens", "typography_rules", "logo_exclusion_zone"],
non_migratable_reason="Brand marks and logos require explicit authorization before production use.",
risk_flags=["brand_trademark", "third_party_logo", "requires_license_review"],
metadata={
"summary": summary,
"primary_color": data.get("primary_color", ""),
"svg_file_count": len(svgs),
"image_file_count": len(image_files(root)),
"file_count": len(files_under(root)),
},
summary=summary,
)
)
return resources
def build_layout_resources(source_root: Path) -> list[dict]:
index_path = source_root / "skills/ppt-master/templates/layouts/layouts_index.json"
layouts = read_json(index_path)
resources = []
for name, data in sorted(layouts.items()):
root = index_path.parent / name
summary = data.get("summary", "")
svgs = svg_files(root)
resources.append(
make_resource(
resource_id=f"layout.{slug(name)}",
source_path=root,
source_root=source_root,
kind="layout_template",
ppt_master_role="page skeleton and rhythm template",
svglide_target="svg-seeds.json/layout-skeletons",
protocol_compatibility="needs_normalization",
conversion_strategy="Extract page slots, density, rhythm, and spacing contracts; regenerate as SVGlide seeds.",
selection_tags=limited_tags("layout", name, summary, data.get("page_types", [])),
granularity="template_directory",
unsupported_features=detect_svg_unsupported(svgs),
normalized_primitives=RAW_SVG_NORMALIZED_PRIMITIVES + ["layout_boxes", "content_budget"],
non_migratable_reason="Raw 1280x720 SVG templates lack SVGlide slide roles and must be re-authored before activation.",
risk_flags=["pptx_export_bias", "requires_svglide_normalization"],
metadata={
"summary": summary,
"canvas_format": data.get("canvas_format", ""),
"page_count": data.get("page_count", len(svgs)),
"page_types": data.get("page_types", []),
"svg_file_count": len(svgs),
},
summary=summary,
)
)
return resources
def build_deck_resources(source_root: Path) -> list[dict]:
index_path = source_root / "skills/ppt-master/templates/decks/decks_index.json"
decks = read_json(index_path)
resources = []
for name, data in sorted(decks.items()):
root = index_path.parent / name
summary = data.get("summary", "")
svgs = svg_files(root)
resources.append(
make_resource(
resource_id=f"deck.{slug(name)}",
source_path=root,
source_root=source_root,
kind="deck_template",
ppt_master_role="finished deck style and page-sequence template",
svglide_target="deck-rhythm-pack/style-pack reference",
protocol_compatibility="needs_normalization",
conversion_strategy="Extract page sequence, rhythm, and style rules; do not copy finished deck SVG or logos.",
selection_tags=limited_tags("deck", name, summary),
granularity="template_directory",
unsupported_features=sorted(set(detect_svg_unsupported(svgs) + ["finished_deck_template"])),
normalized_primitives=RAW_SVG_NORMALIZED_PRIMITIVES + ["deck_rhythm", "style_tokens"],
non_migratable_reason="Finished deck templates contain brand assets and raw SVG pages that require re-authoring.",
risk_flags=["brand_trademark", "third_party_logo", "requires_svglide_normalization"],
metadata={
"summary": summary,
"canvas_format": data.get("canvas_format", ""),
"page_count": data.get("page_count", len(svgs)),
"primary_color": data.get("primary_color", ""),
"svg_file_count": len(svgs),
"image_file_count": len(image_files(root)),
},
summary=summary,
)
)
return resources
def build_chart_resources(source_root: Path) -> list[dict]:
index_path = source_root / "skills/ppt-master/templates/charts/charts_index.json"
index = read_json(index_path)
resources = []
for name, data in sorted(index.get("charts", {}).items()):
path = index_path.parent / f"{name}.svg"
summary = data.get("summary", "")
resources.append(
make_resource(
resource_id=f"chart.{slug(name)}",
source_path=path,
source_root=source_root,
kind="chart_template",
ppt_master_role="visualization template",
svglide_target="svg-recipes.json/component-catalog",
protocol_compatibility="needs_normalization",
conversion_strategy="Extract geometry slots and chart role contract; do not copy final SVG as a page template.",
selection_tags=limited_tags("chart", name, summary),
granularity="file",
unsupported_features=detect_svg_unsupported([path]),
normalized_primitives=RAW_SVG_NORMALIZED_PRIMITIVES + ["chart_data_slots", "geometry_contract"],
non_migratable_reason="Raw chart SVG must be normalized into SVGlide primitives and checked before activation.",
risk_flags=["pptx_export_bias", "requires_svglide_normalization"],
metadata={"summary": summary, "view_box": index.get("meta", {}).get("defaultViewBox", "")},
summary=summary,
)
)
return resources
def icon_style(library: str) -> str:
styles = {
"chunk-filled": "filled sharp-corner geometric icons",
"tabler-filled": "filled rounded icons",
"tabler-outline": "stroke outline icons",
"phosphor-duotone": "duotone soft-depth icons",
"simple-icons": "brand-logo silhouettes",
}
return styles.get(library, "SVG icon library")
def build_icon_resources(source_root: Path) -> list[dict]:
icons_root = source_root / "skills/ppt-master/templates/icons"
resources = []
for root in direct_dirs(icons_root):
icon_paths = sorted(root.glob("*.svg"))
samples = [path.stem for path in icon_paths[:20]]
is_brand = root.name == "simple-icons"
resources.append(
make_resource(
resource_id=f"icon_library.{slug(root.name)}",
source_path=root,
source_root=source_root,
kind="icon_library",
ppt_master_role="icon library summary and searchable index",
svglide_target="icon-registry/style-binding",
protocol_compatibility="needs_normalization",
conversion_strategy="Expose a library-level summary and searchable index; select one style library per deck and never inject every SVG into the prompt.",
selection_tags=limited_tags("icon", root.name, icon_style(root.name), samples),
granularity="library_summary",
unsupported_features=["raw_svg_icon_files"] + (["brand_logo_assets"] if is_brand else []),
normalized_primitives=["data-icon_placeholder", "searchable_icon_index", "single_library_binding"],
non_migratable_reason="Individual icon SVGs are not copied into the main prompt; brand icons require license review.",
risk_flags=(["brand_trademark", "third_party_logo"] if is_brand else ["large_library_prompt_risk"]),
metadata={
"style": icon_style(root.name),
"icon_count": len(icon_paths),
"sample_icons": samples,
"index_policy": "library_summary_only",
},
summary=icon_style(root.name),
)
)
return resources
def build_reference_doc_resources(
source_root: Path,
*,
rel_dir: str,
kind: str,
id_prefix: str,
ppt_master_role: str,
svglide_target: str,
conversion_strategy: str,
normalized_primitives: list[str],
extra_tags: Iterable[str] = (),
) -> list[dict]:
root = source_root / rel_dir
resources = []
for path in direct_md_files(root):
name = path.stem
summary = first_heading_or_line(path)
resources.append(
make_resource(
resource_id=f"{id_prefix}.{slug(name)}",
source_path=path,
source_root=source_root,
kind=kind,
ppt_master_role=ppt_master_role,
svglide_target=svglide_target,
protocol_compatibility="documentation_reference",
conversion_strategy=conversion_strategy,
selection_tags=limited_tags(id_prefix, name, summary, extra_tags),
copy_policy="derive_rules_only",
license_status="reference_only",
granularity="file",
unsupported_features=[],
normalized_primitives=normalized_primitives,
non_migratable_reason="Reference prose is a source for derived rules, not a runtime asset.",
risk_flags=["style_drift_if_copied_verbatim"],
metadata={"summary": summary, "bytes": path.stat().st_size},
summary=summary,
)
)
return resources
def first_heading_or_line(path: Path) -> str:
try:
for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
stripped = line.strip()
if stripped.startswith("#"):
return stripped.lstrip("#").strip()
if stripped:
return stripped[:160]
except OSError:
return ""
return ""
def build_mode_resources(source_root: Path) -> list[dict]:
return build_reference_doc_resources(
source_root,
rel_dir="skills/ppt-master/references/modes",
kind="narrative_mode",
id_prefix="mode",
ppt_master_role="deck-level narrative mode",
svglide_target="deck intent and page rhythm selector",
conversion_strategy="Derive narrative mode tags and page rhythm signals.",
normalized_primitives=["deck_intent", "page_rhythm", "story_arc"],
extra_tags=["narrative", "mode"],
)
def build_workflow_reference_resources(source_root: Path) -> list[dict]:
rel_paths = [
"skills/ppt-master/references/strategist.md",
"skills/ppt-master/references/executor-base.md",
"skills/ppt-master/references/visual-review.md",
"skills/ppt-master/references/shared-standards.md",
"skills/ppt-master/references/image-layout-patterns.md",
"skills/ppt-master/references/image-layout-spec.md",
]
resources = []
for rel_path in rel_paths:
path = source_root / rel_path
summary = first_heading_or_line(path)
resources.append(
make_resource(
resource_id=f"workflow.{slug(path.stem)}",
source_path=path,
source_root=source_root,
kind="workflow_reference",
ppt_master_role="pipeline and quality-gate reference",
svglide_target="runner receipts, quality gate, and asset planning docs",
protocol_compatibility="documentation_reference",
conversion_strategy="Derive process checks, receipt fields, and review heuristics without importing ppt-master as runtime dependency.",
selection_tags=limited_tags("workflow", path.stem, summary),
copy_policy="derive_process_contract_only",
license_status="reference_only",
granularity="file",
unsupported_features=[],
normalized_primitives=["receipt_contract", "quality_gate_signal", "stage_boundary"],
non_migratable_reason="Workflow references shape implementation rules and are not prompt-time visual assets.",
risk_flags=["second_protocol_risk"],
golden_example_required=False,
metadata={"summary": summary, "bytes": path.stat().st_size if path.exists() else 0},
summary=summary,
)
)
return resources
def build_image_reference_collections(source_root: Path) -> list[dict]:
root = source_root / "skills/ppt-master/references/ai-image-comparison"
collections = [
("type", "image_type_visual_references"),
("rendering", "image_rendering_visual_references"),
("palette", "image_palette_visual_references"),
]
resources = []
for dirname, role in collections:
path = root / dirname
if not path.exists():
continue
pngs = image_files(path)
resources.append(
make_resource(
resource_id=f"image_reference_collection.{slug(dirname)}",
source_path=path,
source_root=source_root,
kind="image_reference_collection",
ppt_master_role=role,
svglide_target="asset_strategy/reference-oracle",
protocol_compatibility="reference_only",
conversion_strategy="Summarize bitmap reference coverage; do not copy images into CLI runtime.",
selection_tags=limited_tags("image reference", dirname, [p.stem for p in pngs[:20]]),
copy_policy="derive_contract_only",
license_status="reference_only",
granularity="collection_summary",
unsupported_features=["bitmap_reference_images"],
normalized_primitives=["asset_strategy_tags", "visual_oracle_labels"],
non_migratable_reason="Reference PNGs require provenance and authorization before production use.",
risk_flags=["third_party_image", "runtime_dependency_risk"],
golden_example_required=False,
metadata={"image_count": len(pngs), "sample_images": [p.name for p in pngs[:12]]},
summary=f"{dirname} bitmap reference collection",
)
)
return resources
def build_example_resources(source_root: Path) -> list[dict]:
examples_root = source_root / "examples"
resources = []
for project in direct_dirs(examples_root):
final_svgs = svg_files(project / "svg_final")
output_svgs = svg_files(project / "svg_output")
pages = final_svgs or output_svgs
media = image_files(project / "images")
base64_count = count_base64_images(final_svgs + output_svgs)
notes_count = len(direct_md_files(project / "notes")) if (project / "notes").exists() else 0
all_project_files = files_under(project)
unsupported = ["finished_deck_asset"]
if media:
unsupported.append("third_party_images")
if base64_count:
unsupported.append("embedded_base64_image")
if list((project / "exports").glob("*.pptx")):
unsupported.append("pptx_export")
summary = first_heading_or_line(project / "README.md") or project.name
resources.append(
make_resource(
resource_id=f"example.{slug(project.name)}",
source_path=project,
source_root=source_root,
kind="example_project",
ppt_master_role="finished example deck and mining corpus",
svglide_target="golden/negative corpus and style-mining source",
protocol_compatibility="reference_only",
conversion_strategy="Mine page rhythm, style signals, and failure cases; do not copy finished pages, images, base64, or PPTX exports.",
selection_tags=limited_tags("example", project.name, summary, [p.stem for p in pages[:8]]),
copy_policy="derive_contract_only",
license_status="reference_only",
granularity="project_page_media_summary",
unsupported_features=unsupported,
normalized_primitives=["page_rhythm", "layout_family_labels", "style_signals", "negative_corpus_signals"],
non_migratable_reason="Example projects are finished artifacts with generated images, possible third-party media, base64, and PPTX exports.",
risk_flags=["third_party_image", "base64_asset", "finished_deck_copy_risk"],
golden_example_required=False,
metadata={
"summary": summary,
"page_count": len(pages),
"svg_final_count": len(final_svgs),
"svg_output_count": len(output_svgs),
"media_count": len(media),
"base64_count": base64_count,
"notes_count": notes_count,
"pptx_export_count": len(list((project / "exports").glob("*.pptx"))) if (project / "exports").exists() else 0,
"page_samples": [p.name for p in pages[:10]],
"media_samples": [p.name for p in media[:10]],
"project_digest": digest_paths(source_root, all_project_files),
},
summary=summary,
)
)
return resources
def build_asset_map(source_root: Path) -> dict:
source_root = source_root.resolve()
if not source_root.exists():
raise InventoryError(f"ppt-master source does not exist: {source_root}")
resources: list[dict] = []
resources.extend(build_brand_resources(source_root))
resources.extend(build_layout_resources(source_root))
resources.extend(build_deck_resources(source_root))
resources.extend(build_chart_resources(source_root))
resources.extend(build_icon_resources(source_root))
resources.extend(
build_reference_doc_resources(
source_root,
rel_dir="skills/ppt-master/references/visual-styles",
kind="visual_style",
id_prefix="visual_style",
ppt_master_role="visual style guide",
svglide_target="style-presets.json/quality-oracle",
conversion_strategy="Derive style tags, visual anti-patterns, and quality-oracle signals.",
normalized_primitives=["style_tags", "forbidden_patterns", "quality_oracle"],
extra_tags=["style", "visual"],
)
)
resources.extend(
build_reference_doc_resources(
source_root,
rel_dir="skills/ppt-master/references/image-palettes",
kind="image_palette",
id_prefix="image_palette",
ppt_master_role="image palette reference",
svglide_target="asset_strategy/image-palette-binding",
conversion_strategy="Derive palette tags and image-planning hints; do not copy generated images.",
normalized_primitives=["palette_tags", "image_style_binding"],
extra_tags=["image", "palette"],
)
)
resources.extend(
build_reference_doc_resources(
source_root,
rel_dir="skills/ppt-master/references/image-renderings",
kind="image_rendering",
id_prefix="image_rendering",
ppt_master_role="image rendering preset",
svglide_target="asset_strategy/rendering-preset",
conversion_strategy="Derive rendering tags and fallback policy for image planning.",
normalized_primitives=["rendering_tags", "fallback_policy"],
extra_tags=["image", "rendering"],
)
)
resources.extend(
build_reference_doc_resources(
source_root,
rel_dir="skills/ppt-master/references/image-type-templates",
kind="image_type_template",
id_prefix="image_type",
ppt_master_role="image composition recipe",
svglide_target="asset_strategy/image-composition-recipe",
conversion_strategy="Derive image composition contracts and prompt planning tags.",
normalized_primitives=["composition_recipe", "asset_slots"],
extra_tags=["image", "composition"],
)
)
resources.extend(build_mode_resources(source_root))
resources.extend(build_example_resources(source_root))
resources.extend(build_workflow_reference_resources(source_root))
resources.extend(build_image_reference_collections(source_root))
resources.sort(key=lambda item: item["id"])
counts = Counter(resource["kind"] for resource in resources)
icon_svg_count = sum(resource.get("metadata", {}).get("icon_count", 0) for resource in resources)
example_page_count = sum(resource.get("metadata", {}).get("page_count", 0) for resource in resources)
example_media_count = sum(resource.get("metadata", {}).get("media_count", 0) for resource in resources)
all_source_files = files_under(source_root)
asset_map = {
"schema_version": SCHEMA_VERSION,
"generated_from": source_root.name,
"generation_policy": {
"runtime_dependency": "none",
"raw_svg_default": {
"protocol_compatibility": "needs_normalization",
"copy_policy": "derive_contract_only",
"license_status": "reference_only",
"activation_status": "candidate",
},
"icon_policy": "library_summary_and_searchable_index_only",
"example_policy": "project_page_media_summary_only",
},
"summary": {
"counts": {
**dict(sorted(counts.items())),
"total_resources": len(resources),
"icon_svg_files": icon_svg_count,
"example_pages": example_page_count,
"example_media_files": example_media_count,
},
"digests": {
"all_source_files": digest_paths(source_root, all_source_files),
"brands": digest_paths(source_root, files_under(source_root / "skills/ppt-master/templates/brands")),
"layouts": digest_paths(source_root, files_under(source_root / "skills/ppt-master/templates/layouts")),
"decks": digest_paths(source_root, files_under(source_root / "skills/ppt-master/templates/decks")),
"charts": digest_paths(source_root, files_under(source_root / "skills/ppt-master/templates/charts")),
"icons": digest_paths(source_root, files_under(source_root / "skills/ppt-master/templates/icons")),
"references": digest_paths(source_root, files_under(source_root / "skills/ppt-master/references")),
"examples": digest_paths(source_root, files_under(source_root / "examples")),
},
},
"resources": resources,
}
validate_asset_map(asset_map)
return asset_map
def validate_asset_map(asset_map: dict) -> None:
if asset_map.get("schema_version") != SCHEMA_VERSION:
raise InventoryError("unexpected schema_version")
resources = asset_map.get("resources")
if not isinstance(resources, list) or not resources:
raise InventoryError("asset map has no resources")
counts = Counter()
for index, resource in enumerate(resources):
missing = REQUIRED_FIELDS - set(resource)
if missing:
raise InventoryError(f"resource[{index}] missing fields: {sorted(missing)}")
counts[resource["kind"]] += 1
if resource["activation_status"] == "rejected" and not resource["non_migratable_reason"]:
raise InventoryError(f"{resource['id']} is rejected without a reason")
if resource["activation_status"] == "active" and not resource["golden_example_required"]:
raise InventoryError(f"{resource['id']} active resources must require golden examples")
raw_like = (
resource["source_path"].endswith(".svg")
or resource.get("metadata", {}).get("svg_file_count", 0)
or resource.get("metadata", {}).get("raw_svg_file_count", 0)
or "raw_svg_icon_files" in resource["unsupported_features"]
)
if raw_like and resource["protocol_compatibility"] == "needs_normalization":
if resource["copy_policy"] != "derive_contract_only":
raise InventoryError(f"{resource['id']} raw SVG must use derive_contract_only")
if resource["license_status"] != "reference_only":
raise InventoryError(f"{resource['id']} raw SVG must be reference_only")
if resource["activation_status"] in {"validated", "active"}:
raise InventoryError(f"{resource['id']} raw SVG cannot start as validated/active")
for field in ("selection_tags", "unsupported_features", "normalized_primitives", "risk_flags"):
if not isinstance(resource[field], list):
raise InventoryError(f"{resource['id']} field {field} must be a list")
missing_kinds = sorted(REQUIRED_KINDS - set(counts))
if missing_kinds:
raise InventoryError(f"inventory missing required resource kinds: {missing_kinds}")
def render_markdown(asset_map: dict) -> str:
counts = asset_map["summary"]["counts"]
digests = asset_map["summary"]["digests"]
resources = asset_map["resources"]
lines = [
"# ppt-master Asset Inventory",
"",
"This file is generated by `skills/lark-slides/scripts/ppt_master_asset_inventory.py`.",
"The JSON asset map is the source of truth; this Markdown is a human-readable summary.",
"",
"## Safety Defaults",
"",
"- Raw ppt-master SVG assets start as `needs_normalization`, `derive_contract_only`, `reference_only`, and `candidate`.",
"- Icon assets are summarized by library and searchable index metadata; individual icon SVG files are not injected into the main prompt.",
"- Examples are summarized by project/page/media counts and treated as reference corpus, not runtime assets.",
"- Production/golden selection must not use rejected, reference-only, or raw unnormalized assets.",
"",
"## Counts",
"",
"| Kind | Count |",
"| --- | ---: |",
]
for key, value in sorted(counts.items()):
lines.append(f"| `{key}` | {value} |")
lines.extend(["", "## Digests", "", "| Scope | SHA-256 prefix |", "| --- | --- |"])
for key, value in sorted(digests.items()):
lines.append(f"| `{key}` | `{value}` |")
lines.extend(["", "## Resource Summary", "", "| Kind | Example IDs |", "| --- | --- |"])
by_kind: dict[str, list[str]] = {}
for resource in resources:
by_kind.setdefault(resource["kind"], []).append(resource["id"])
for kind, ids in sorted(by_kind.items()):
preview = ", ".join(f"`{item}`" for item in ids[:8])
if len(ids) > 8:
preview += f", ... (+{len(ids) - 8})"
lines.append(f"| `{kind}` | {preview} |")
lines.extend(["", "## Example Corpus", "", "| Example | Pages | Media | Base64 | Digest |", "| --- | ---: | ---: | ---: | --- |"])
for resource in resources:
if resource["kind"] != "example_project":
continue
metadata = resource.get("metadata", {})
lines.append(
f"| `{resource['id']}` | {metadata.get('page_count', 0)} | {metadata.get('media_count', 0)} | "
f"{metadata.get('base64_count', 0)} | `{metadata.get('project_digest', '')}` |"
)
lines.extend(["", "## Icon Libraries", "", "| Library | Icons | Policy |", "| --- | ---: | --- |"])
for resource in resources:
if resource["kind"] != "icon_library":
continue
metadata = resource.get("metadata", {})
lines.append(f"| `{resource['id']}` | {metadata.get('icon_count', 0)} | {metadata.get('index_policy', '')} |")
return "\n".join(lines) + "\n"
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Build the SVGlide ppt-master asset inventory.")
parser.add_argument("--source", type=Path, default=default_source_root(), help="Path to the local ppt-master checkout.")
parser.add_argument("--out-json", type=Path, help="Output path for ppt-master-asset-map.json.")
parser.add_argument("--out-md", type=Path, help="Output path for ppt-master-asset-inventory.md.")
parser.add_argument("--check", action="store_true", help="Validate the source and generated map without writing files.")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
asset_map = build_asset_map(args.source)
if args.check:
print(json.dumps(asset_map["summary"], ensure_ascii=False, indent=2))
return 0
if args.out_json:
args.out_json.parent.mkdir(parents=True, exist_ok=True)
args.out_json.write_text(json.dumps(asset_map, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if args.out_md:
args.out_md.parent.mkdir(parents=True, exist_ok=True)
args.out_md.write_text(render_markdown(asset_map), encoding="utf-8")
if not args.out_json and not args.out_md:
print(json.dumps(asset_map, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -1,96 +0,0 @@
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import json
import sys
import unittest
from collections import Counter
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
import ppt_master_asset_inventory as inventory
REFERENCE_DIR = SCRIPT_DIR.parent / "references"
def ppt_master_source() -> Path:
return inventory.default_source_root()
class PptMasterAssetInventoryTest(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.source = ppt_master_source()
cls.asset_map = inventory.build_asset_map(cls.source)
def test_schema_and_counts_cover_required_categories(self) -> None:
inventory.validate_asset_map(self.asset_map)
resources = self.asset_map["resources"]
counts = Counter(resource["kind"] for resource in resources)
self.assertEqual(self.asset_map["schema_version"], inventory.SCHEMA_VERSION)
self.assertEqual(counts["brand_preset"], 2)
self.assertEqual(counts["layout_template"], 7)
self.assertEqual(counts["deck_template"], 8)
self.assertEqual(counts["chart_template"], 71)
self.assertEqual(counts["icon_library"], 5)
self.assertEqual(counts["example_project"], 21)
self.assertEqual(counts["visual_style"], 18)
self.assertEqual(counts["image_palette"], 14)
self.assertEqual(counts["image_rendering"], 20)
self.assertEqual(counts["image_type_template"], 11)
self.assertEqual(counts["narrative_mode"], 5)
self.assertGreaterEqual(counts["workflow_reference"], 6)
for resource in resources:
missing = inventory.REQUIRED_FIELDS - set(resource)
self.assertEqual(missing, set(), resource["id"])
def test_checked_in_asset_map_matches_source_counts_and_digests(self) -> None:
checked_in = json.loads((REFERENCE_DIR / "ppt-master-asset-map.json").read_text(encoding="utf-8"))
self.assertEqual(checked_in["schema_version"], inventory.SCHEMA_VERSION)
self.assertEqual(checked_in["summary"]["counts"], self.asset_map["summary"]["counts"])
self.assertEqual(checked_in["summary"]["digests"], self.asset_map["summary"]["digests"])
def test_raw_svg_resources_are_never_active(self) -> None:
raw_resources = [
resource
for resource in self.asset_map["resources"]
if resource["protocol_compatibility"] == "needs_normalization"
]
self.assertGreater(len(raw_resources), 0)
for resource in raw_resources:
self.assertEqual(resource["copy_policy"], "derive_contract_only", resource["id"])
self.assertEqual(resource["license_status"], "reference_only", resource["id"])
self.assertNotIn(resource["activation_status"], {"validated", "active"}, resource["id"])
def test_icons_and_examples_are_summarized_not_expanded(self) -> None:
icon_resources = [resource for resource in self.asset_map["resources"] if resource["kind"] == "icon_library"]
icon_file_count = self.asset_map["summary"]["counts"]["icon_svg_files"]
self.assertEqual(len(icon_resources), 5)
self.assertGreater(icon_file_count, 11000)
self.assertLess(len(self.asset_map["resources"]), icon_file_count)
for resource in icon_resources:
self.assertEqual(resource["granularity"], "library_summary")
self.assertEqual(resource["metadata"]["index_policy"], "library_summary_only")
self.assertIn("sample_icons", resource["metadata"])
examples = [resource for resource in self.asset_map["resources"] if resource["kind"] == "example_project"]
self.assertEqual(len(examples), 21)
for resource in examples:
self.assertEqual(resource["granularity"], "project_page_media_summary")
self.assertIn("page_count", resource["metadata"])
self.assertIn("media_count", resource["metadata"])
self.assertIn("base64_count", resource["metadata"])
self.assertIn("project_digest", resource["metadata"])
if __name__ == "__main__":
unittest.main()

View File

@@ -1025,8 +1025,8 @@ def validate_chart_markers(root: ET.Element) -> list[dict[str, Any]]:
def bbox_for_element(element: ET.Element) -> dict[str, float] | None:
name = local_name(element.tag)
if name in {"rect", "foreignObject", "image"}:
name = local_name(element.tag).lower()
if name in {"rect", "foreignobject", "image"}:
x = parse_required_number(element, "x")
y = parse_required_number(element, "y")
width = parse_required_number(element, "width")
@@ -1096,6 +1096,16 @@ def is_safe_area_exempt_backing(element: ET.Element, bbox: dict[str, float], can
return False
def is_safe_area_exempt_page_chrome(element: ET.Element, bbox: dict[str, float], canvas_width: float, canvas_height: float) -> bool:
identifier = normalize_name(element_identifier_text(element))
name = local_name(element.tag).lower()
if re.search(r"(^|_)(top|bottom|page)_(rule|line)$", identifier):
return bbox["height"] <= 8 and bbox["width"] >= canvas_width * 0.5
if "footer" in identifier and name in {"foreignobject", "text"}:
return bbox["y"] >= SAFE_AREA["y"] + SAFE_AREA["height"] - 2 and bbox["x"] >= SAFE_AREA["x"] and bbox_right(bbox) <= SAFE_AREA["x"] + SAFE_AREA["width"]
return False
def bbox_outside(bbox: dict[str, float], rect: dict[str, float]) -> bool:
return (
bbox["x"] < rect["x"]
@@ -1289,7 +1299,11 @@ def validate_geometry(elements: list[ET.Element], canvas_width: float, canvas_he
"Non-background elements must fit inside the slide canvas.",
)
)
elif bbox_outside(bbox, SAFE_AREA) and not is_safe_area_exempt_backing(element, bbox, canvas_width, canvas_height):
elif (
bbox_outside(bbox, SAFE_AREA)
and not is_safe_area_exempt_backing(element, bbox, canvas_width, canvas_height)
and not is_safe_area_exempt_page_chrome(element, bbox, canvas_width, canvas_height)
):
issues.append(
issue(
"warning",
@@ -1938,6 +1952,7 @@ def summarize_visual_primitives(root: ET.Element, elements: list[ET.Element], te
semi_transparent_rects: list[dict[str, float]] = []
primitive_areas: dict[str, float] = {}
hidden_counts: dict[str, int] = {}
element_bboxes: list[dict[str, Any]] = []
for element in elements:
name = local_name(element.tag)
@@ -1952,6 +1967,15 @@ def summarize_visual_primitives(root: ET.Element, elements: list[ET.Element], te
for primitive in ["path"] if name == "path" else []:
hidden_counts[primitive] = hidden_counts.get(primitive, 0) + 1
continue
if not is_background_bbox(bbox, canvas_width, canvas_height):
element_bboxes.append(
{
"tag": name,
"role": svg_role(element) or "",
"element_id": get_attr(element, "id") or "",
"bbox": {key: round(value, 2) for key, value in bbox.items()},
}
)
area = bbox["width"] * bbox["height"]
signal_area = primitive_signal_area(element, bbox)
hidden = element_is_hidden(element)
@@ -2102,6 +2126,7 @@ def summarize_visual_primitives(root: ET.Element, elements: list[ET.Element], te
"counts": counts,
"primitive_areas": {key: round(value, 2) for key, value in sorted(primitive_areas.items())},
"hidden_counts": dict(sorted(hidden_counts.items())),
"bboxes": element_bboxes,
"gradient_count": gradients,
"gradient_ref_count": gradient_refs,
"filter_count": filters + filter_refs,
@@ -2397,10 +2422,23 @@ def validation_profile(plan: dict[str, Any]) -> dict[str, Any]:
return profile if isinstance(profile, dict) else {}
def validation_profile_name(plan: dict[str, Any]) -> str:
profile = plan.get("validation_profile")
if isinstance(profile, str):
raw = normalize_name(profile)
elif isinstance(profile, dict):
raw = normalize_name(profile.get("profile") or profile.get("name") or profile.get("mode"))
else:
raw = ""
if raw in {"gold", "golden_regression"}:
return "golden"
return raw
def seed_gate_level(plan: dict[str, Any]) -> str:
profile = validation_profile(plan)
drift_policy = normalize_name(profile.get("drift_policy") or profile.get("seed_policy") or profile.get("mode"))
if profile.get("strict") is True or drift_policy in {"error", "errors", "strict", "fail", "fail_closed"}:
if profile.get("strict") is True or validation_profile_name(plan) == "golden" or drift_policy in {"error", "errors", "strict", "fail", "fail_closed"}:
return "error"
return "warning"
@@ -3005,12 +3043,12 @@ def slide_chart_type(slide: dict[str, Any]) -> str:
for key in ["chart_type", "chartType", "primary_chart_type", "primaryChartType"]:
if key in slide:
return normalize_name(slide.get(key))
reference = slide.get("reference_asset") or slide.get("ppt_master_reference_asset")
reference = slide.get("reference_asset") or slide.get("design_reference_asset")
if isinstance(reference, dict):
asset_id = textify(reference.get("asset_id") or reference.get("id")).strip()
if asset_id.startswith("chart."):
return normalize_name(asset_id.split(".", 1)[1])
references = slide.get("ppt_master_reference_assets")
references = slide.get("design_reference_assets")
if isinstance(references, list):
for item in references:
if not isinstance(item, dict):
@@ -3021,6 +3059,146 @@ def slide_chart_type(slide: dict[str, Any]) -> str:
return ""
def strategist_contract_level(plan: dict[str, Any]) -> str:
return "error" if seed_gate_level(plan) == "error" or validation_profile_name(plan) == "golden" else "warning"
def page_rhythm_contract(plan: dict[str, Any]) -> Any:
for key in ["page_rhythm", "deck_rhythm", "rhythm_plan", "narrative_rhythm"]:
value = plan.get(key)
if value is not None and textify(value).strip():
return value
return None
def slide_page_type(slide: dict[str, Any]) -> str:
for key in ["page_type", "slide_type", "archetype", "layout_type"]:
value = normalize_name(slide.get(key))
if value:
return value
return ""
def slide_main_visual_anchor(slide: dict[str, Any]) -> Any:
for key in ["main_visual_anchor", "visual_anchor", "visual_focal_anchor", "primary_visual_anchor"]:
if key in slide:
return slide.get(key)
return None
def slide_reference_assets(slide: dict[str, Any]) -> list[Any]:
out: list[Any] = []
for key in ["reference_asset", "design_reference_asset"]:
if key in slide:
out.append(slide.get(key))
references = slide.get("reference_assets")
if isinstance(references, list):
out.extend(references)
references = slide.get("design_reference_assets")
if isinstance(references, list):
out.extend(references)
return out
def reference_asset_is_structured(value: Any) -> bool:
if value is None:
return True
if isinstance(value, str):
return bool(NO_ASSET_RE.search(value))
if isinstance(value, list):
return all(reference_asset_is_structured(item) for item in value)
if not isinstance(value, dict):
return False
if value.get("enabled") is False:
return True
identity = textify(
value.get("asset_id")
or value.get("id")
or value.get("source_id")
or value.get("local_path")
or value.get("local_path_or_href")
or value.get("href")
or value.get("path")
).strip()
source = textify(value.get("source") or value.get("source_type") or value.get("kind")).strip()
return bool(identity and source)
def anchor_matches_layout_box(anchor: str, layout_boxes: list[dict[str, Any]]) -> bool:
normalized_anchor = normalize_name(anchor.removeprefix("#"))
if not normalized_anchor:
return False
for box in layout_boxes:
if not isinstance(box, dict):
continue
candidates = [
box.get("role"),
box.get("id"),
box.get("name"),
box.get("layout_box_role"),
]
if normalized_anchor in {normalize_name(candidate) for candidate in candidates if textify(candidate).strip()}:
return True
return anchor.strip().startswith("#")
def anchor_contract_is_checkable(anchor: Any, layout_boxes: list[dict[str, Any]]) -> bool:
if isinstance(anchor, dict):
if layout_box_has_positive_bbox(anchor):
return True
for key in ["layout_box_role", "role", "element_id", "svg_element_id", "component_id"]:
if textify(anchor.get(key)).strip():
return True
return False
if isinstance(anchor, str):
return anchor_matches_layout_box(anchor, layout_boxes)
return False
def anchor_bbox(anchor: Any, layout_boxes: list[dict[str, Any]]) -> dict[str, float] | None:
if isinstance(anchor, dict) and layout_box_has_positive_bbox(anchor):
return bbox_from_mapping(anchor)
role = ""
if isinstance(anchor, dict):
role = textify(anchor.get("layout_box_role") or anchor.get("role")).strip()
elif isinstance(anchor, str):
role = anchor.removeprefix("#").strip()
normalized_role = normalize_name(role)
if not normalized_role:
return None
for box in layout_boxes:
if not isinstance(box, dict):
continue
candidates = [box.get("role"), box.get("id"), box.get("name"), box.get("layout_box_role")]
if normalized_role in {normalize_name(candidate) for candidate in candidates if textify(candidate).strip()} and layout_box_has_positive_bbox(box):
return bbox_from_mapping(box)
return None
def source_has_geometry_in_bbox(file: dict[str, Any], bbox: dict[str, float]) -> bool:
visual = file.get("visual_primitives", {})
boxes = visual.get("bboxes") if isinstance(visual, dict) else None
if not isinstance(boxes, list):
return False
for item in boxes:
if not isinstance(item, dict):
continue
item_bbox = item.get("bbox")
if not isinstance(item_bbox, dict):
continue
try:
candidate = {key: float(item_bbox[key]) for key in ["x", "y", "width", "height"]}
except (KeyError, TypeError, ValueError):
continue
if candidate["width"] <= 0 or candidate["height"] <= 0:
continue
if is_background_bbox(candidate, CANVAS_WIDTH, CANVAS_HEIGHT):
continue
if overlap_area(candidate, bbox) >= min(candidate["width"] * candidate["height"], bbox["width"] * bbox["height"]) * 0.2:
return True
return False
def required_plan_primitives(slide: dict[str, Any]) -> set[str]:
return normalize_primitives(slide.get("required_primitives"))
@@ -3165,6 +3343,7 @@ def lint_plan(plan: dict[str, Any], path: str = "<plan>", context: dict[str, Any
is_create_svg_route = context.get("route_id") == CREATE_SVG_ROUTE_ID
is_svg_plan = plan.get("output_mode") == "svglide-svg" or is_create_svg_route
seed_level = seed_gate_level(plan)
strategist_level = strategist_contract_level(plan)
deck_preset_id = deck_style_preset_id(plan)
deck_style_system = style_system(plan)
if is_svg_plan:
@@ -3238,6 +3417,16 @@ def lint_plan(plan: dict[str, Any], path: str = "<plan>", context: dict[str, Any
f"SVGlide style_system must include {field}",
)
)
if len(slides) > 1 and page_rhythm_contract(plan) is None:
issues.append(
plan_issue(
strategist_level,
"plan_missing_page_rhythm",
"multi-page SVGlide plans must declare page_rhythm so deck pacing can be checked",
None,
"Add page_rhythm/deck_rhythm with per-section rhythm, density changes, and repeated-page guardrails.",
)
)
renderer_ids: list[str] = []
layout_families: list[str] = []
@@ -3255,6 +3444,7 @@ def lint_plan(plan: dict[str, Any], path: str = "<plan>", context: dict[str, Any
continue
visual_plan = slide_visual_plan(slide)
layout_boxes = slide_layout_boxes(visual_plan)
renderer_id = slide_renderer_id(visual_plan)
renderer_ids.append(renderer_id)
@@ -3280,14 +3470,47 @@ def lint_plan(plan: dict[str, Any], path: str = "<plan>", context: dict[str, Any
"SVGlide plan slides must include layout_family so deck-level layout diversity is enforceable",
slide,
"Use layout families such as hero, agenda_matrix, dashboard, table, timeline, flow, risk_grid, swimlane, or closing.",
)
)
)
raw_visual_recipe = normalize_name(visual_plan.get("visual_recipe"))
visual_recipe = raw_visual_recipe
route_private_requested = raw_visual_recipe == ROUTE_PRIVATE_VISUAL_RECIPE
seed_id = slide_seed_id(visual_plan)
seed_data = None
page_type = slide_page_type(visual_plan)
if not page_type:
issues.append(
plan_issue(
strategist_level,
"plan_missing_page_type",
"SVGlide plan slides must declare page_type so Strategist intent is checkable",
slide,
"Use page_type values such as cover, section, process, comparison, data_story, image_story, dashboard, chart, or closing.",
)
)
anchor = slide_main_visual_anchor(visual_plan)
if not anchor_contract_is_checkable(anchor, layout_boxes):
issues.append(
plan_issue(
strategist_level,
"plan_missing_main_visual_anchor",
"SVGlide plan slides must declare a checkable main_visual_anchor",
slide,
"Point main_visual_anchor at a layout box role, #svg-element-id, component_id, or explicit x/y/width/height bbox.",
)
)
for reference in slide_reference_assets(visual_plan):
if not reference_asset_is_structured(reference):
issues.append(
plan_issue(
strategist_level,
"plan_reference_asset_unstructured",
"reference_asset must be structured source metadata, not prose",
slide,
"Use {source, asset_id/id} for design references or source/license/path metadata for concrete assets.",
)
)
if not seed_id:
issues.append(
plan_issue(
@@ -3332,7 +3555,6 @@ def lint_plan(plan: dict[str, Any], path: str = "<plan>", context: dict[str, Any
"Seed and visual_recipe must describe the same structure; choose a different seed or update the recipe.",
)
)
layout_boxes = slide_layout_boxes(visual_plan)
if not layout_boxes:
issues.append(
plan_issue(
@@ -4103,6 +4325,7 @@ def lint_plan_svg_alignment(plan: dict[str, Any], files: list[dict[str, Any]], c
chart_type_contracts = context.get("chart_type_contracts", CHART_TYPE_CONTRACTS)
for slide_index, slide, file in alignments:
visual_plan = slide_visual_plan(slide)
layout_boxes = slide_layout_boxes(visual_plan)
recipe = resolved_visual_recipe_for_slide(visual_plan, slide_index, context)
seed_id = slide_seed_id(visual_plan)
seed_data = seed_catalog.get(seed_id) if seed_id in seed_catalog else None
@@ -4209,7 +4432,19 @@ def lint_plan_svg_alignment(plan: dict[str, Any], files: list[dict[str, Any]], c
"plan_chart_type_contract_not_met",
f'chart_type "{chart_type}" geometry contract is not met: {", ".join(chart_failures)}',
slide,
f"SVG file {file.get('path')} does not expose enough chart geometry for the declared ppt-master-style page type.",
f"SVG file {file.get('path')} does not expose enough chart geometry for the declared design-pattern page type.",
)
)
anchor = slide_main_visual_anchor(visual_plan)
visual_anchor_bbox = anchor_bbox(anchor, layout_boxes)
if visual_anchor_bbox is not None and not source_has_geometry_in_bbox(file, visual_anchor_bbox):
issues.append(
plan_issue(
"error",
"plan_main_visual_anchor_not_met",
"main_visual_anchor does not overlap visible SVG source geometry",
slide,
f"SVG file {file.get('path')} must place the declared main visual inside the anchored layout box or bbox.",
)
)
if "image" in source_primitives:

View File

@@ -581,6 +581,24 @@ class SvgPreflightTest(unittest.TestCase):
self.assertNotIn("safe_area", codes)
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_svg_does_not_warn_safe_area_for_page_chrome_rule_and_footer(self) -> None:
svg = """
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:slide="https://slides.bytedance.com/ns"
slide:role="slide"
width="960" height="540" viewBox="0 0 960 540">
<rect id="top-rule" slide:role="shape" x="48" y="32" width="864" height="4" fill="#2563EB" />
<foreignObject id="footer" slide:role="shape" slide:shape-type="text" x="64" y="500" width="832" height="24">
<div xmlns="http://www.w3.org/1999/xhtml" style="font-size:9px;color:#64748B;">Demo footer · 01</div>
</foreignObject>
<rect id="badge" slide:role="shape" x="12" y="20" width="80" height="40" fill="#2563EB" />
</svg>
"""
result = svg_preflight.lint_svg(with_contract(svg))
safe_area_ids = [issue.get("element_id") for issue in result.get("issues", []) if issue.get("code") == "safe_area"]
self.assertEqual(["badge"], safe_area_ids)
def test_lint_svg_reports_text_bbox_overlap(self) -> None:
svg = """
<svg xmlns="http://www.w3.org/2000/svg"
@@ -1044,11 +1062,45 @@ class SvgPreflightTest(unittest.TestCase):
"geometric_composition",
["geometric_shape", "path"],
chart_type="",
reference_asset={"source": "ppt-master", "asset_id": "chart.agenda_list"},
reference_asset={"source": "svglide_design_pattern", "asset_id": "chart.agenda_list"},
)
result = svg_preflight.lint_plan(plan)
self.assertNotIn("plan_unknown_chart_type", issue_codes(result))
def test_lint_plan_warns_when_multi_page_deck_missing_page_rhythm(self) -> None:
plan = single_slide_plan()
second_slide = dict(plan["slides"][0])
second_slide["page"] = 2
second_slide["title"] = "Route 2"
plan["page_count"] = 2
plan["slides"] = [plan["slides"][0], second_slide]
result = svg_preflight.lint_plan(plan)
self.assertIn("plan_missing_page_rhythm", issue_codes(result))
self.assertEqual(issue_levels(result, "plan_missing_page_rhythm"), ["warning"])
def test_lint_plan_golden_requires_strategist_contract_fields(self) -> None:
plan = single_slide_plan()
second_slide = dict(plan["slides"][0])
second_slide["page"] = 2
plan["page_count"] = 2
plan["slides"] = [plan["slides"][0], second_slide]
plan["validation_profile"] = {"profile": "golden"}
result = svg_preflight.lint_plan(plan)
self.assertEqual(issue_levels(result, "plan_missing_page_rhythm"), ["error"])
self.assertIn("plan_missing_page_type", issue_codes(result))
self.assertIn("plan_missing_main_visual_anchor", issue_codes(result))
def test_lint_plan_reports_unstructured_reference_asset(self) -> None:
plan = single_slide_plan(reference_asset="use the chart reference")
result = svg_preflight.lint_plan(plan)
self.assertIn("plan_reference_asset_unstructured", issue_codes(result))
def test_public_recipe_registry_is_runtime_catalog_source(self) -> None:
registry = read_json(PUBLIC_RECIPE_REGISTRY_PATH)
recipes = registry.get("recipes")
@@ -1763,6 +1815,22 @@ class SvgPreflightTest(unittest.TestCase):
result = svg_preflight.lint_files([str(svg_path)], str(plan_path))
self.assertIn("plan_chart_type_contract_not_met", plan_issue_codes(result))
def test_lint_files_reports_main_visual_anchor_not_met_by_source(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
tmp = Path(tmpdir)
svg_path = tmp / "page-001.svg"
plan_path = tmp / "slide_plan.json"
svg_path.write_text(VALID_SVG, encoding="utf-8")
plan = single_slide_plan(
main_visual_anchor={"x": 880, "y": 450, "width": 40, "height": 40},
)
plan["svg_files"] = [{"page": 1, "path": "page-001.svg"}]
plan_path.write_text(json.dumps(plan), encoding="utf-8")
result = svg_preflight.lint_files([str(svg_path)], str(plan_path))
self.assertIn("plan_main_visual_anchor_not_met", plan_issue_codes(result))
def test_lint_files_reports_svg_input_count_mismatch_with_plan_page_count(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
tmp = Path(tmpdir)
@@ -1814,13 +1882,13 @@ class SvgPreflightTest(unittest.TestCase):
"geometric_composition",
list(svg_preflight.VISUAL_RECIPE_CATALOG["geometric_composition"]["required_primitives"]),
chart_type="bubble_chart",
ppt_master_reference_assets=[{"asset_id": "chart.bubble_chart", "source": "ppt-master"}],
design_reference_assets=[{"asset_id": "chart.bubble_chart", "source": "svglide_design_pattern"}],
)
donut_plan = single_slide_plan(
"infographic_scorecard",
["typography", "micro_chart"],
chart_type="donut_chart",
ppt_master_reference_assets=[{"asset_id": "chart.donut_chart", "source": "ppt-master"}],
design_reference_assets=[{"asset_id": "chart.donut_chart", "source": "svglide_design_pattern"}],
)
slide7 = bubble_plan["slides"][0]
slide8 = donut_plan["slides"][0]
@@ -2092,7 +2160,7 @@ class SvgPreflightTest(unittest.TestCase):
codes = [issue["code"] for issue in result["plan"]["issues"]]
self.assertIn("plan_asset_contract_missing_metadata", codes)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 1)
self.assertEqual(codes.count("plan_asset_contract_missing_metadata"), 1)
def test_lint_files_accepts_preview_image_asset_metadata(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:

View File

@@ -36,6 +36,10 @@ NUMBER_RE = re.compile(r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?")
STYLE_DECL_RE = re.compile(r"\s*([A-Za-z-]+)\s*:\s*([^;]+)")
RGB_RE = re.compile(r"rgba?\(([^)]+)\)", re.IGNORECASE)
TRANSFORM_RE = re.compile(r"(translate|scale)\s*\(([^)]*)\)", re.IGNORECASE)
PLACEHOLDER_COPY_RE = re.compile(
r"\b(contract renderer|placeholder|lorem ipsum|todo|draft only|smoke deck)\b|SVGlide\s+contract\s+renderer",
re.IGNORECASE,
)
LOCAL_IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg")
RENDERABLE_TAGS = {"rect", "circle", "ellipse", "line", "path", "polygon", "polyline", "image", "text", "foreignobject"}
PRESENTATION_ATTRS = {
@@ -849,6 +853,27 @@ def is_label_text(element: RenderElement) -> bool:
return any(token in ident for token in ["label", "name", "title", "badge", "pill"])
def is_page_chrome(element: RenderElement, canvas_height: float) -> bool:
ident = element.element_id.lower()
if element.background:
return True
if ident in {"top-rule", "bottom-rule", "page-rule"}:
return True
if "footer" in ident:
return True
if element.tag in {"text", "foreignobject"} and element.box.y >= canvas_height - 52 and element.font_size <= 12:
return True
if element.tag in {"rect", "line"} and element.box.height <= 6 and element.box.width >= 0.5 * DEFAULT_CANVAS_WIDTH:
return True
return False
def semantic_label_token_count(elements: list[RenderElement]) -> int:
text = " ".join(element.text for element in elements if element.text.strip())
parts = re.split(r"[/、,;\n]+", text)
return sum(1 for part in parts if len(part.strip()) >= 2)
def element_path(project: Path, source: SvgSource) -> str:
if source.path:
return safe_rel(source.path, project)
@@ -861,9 +886,17 @@ def lint_svg_source(project: Path, source: SvgSource) -> list[dict[str, Any]]:
elements = collect_render_elements(source)
body_elements = [element for element in elements if not element.background and element.box.area > 4]
text_elements = [element for element in elements if element.tag in {"text", "foreignobject"} and element.text.strip()]
semantic_text_elements = [element for element in text_elements if not is_page_chrome(element, canvas_height)]
dark_backings = [element for element in elements if element.tag in {"rect", "path", "polygon"} and is_dark_color(element.fill)]
label_backings = [element for element in elements if is_label_backing(element)]
image_elements = [element for element in elements if element.tag == "image" and element.box.area > 0]
meaningful_visual_elements = [
element
for element in body_elements
if element.tag not in {"text", "foreignobject"}
and not is_page_chrome(element, canvas_height)
and element.box.area > 80
]
path = element_path(project, source)
if body_elements:
@@ -894,6 +927,35 @@ def lint_svg_source(project: Path, source: SvgSource) -> list[dict[str, Any]]:
source=source.label,
)
)
if any(PLACEHOLDER_COPY_RE.search(element.text) for element in text_elements):
checks.append(
check(
"placeholder_renderer_copy",
"warning",
"high",
"page exposes implementation, placeholder, or smoke-test copy",
page=source.page,
path=path,
source=source.label,
)
)
if (
not image_elements
and len(meaningful_visual_elements) >= 5
and len(semantic_text_elements) <= 2
and semantic_label_token_count(semantic_text_elements) < 4
):
checks.append(
check(
"unlabeled_visual_system",
"warning",
"medium",
"page has many visual marks but too few semantic labels or annotations",
page=source.page,
path=path,
source=source.label,
)
)
for element in elements:
if element.tag in {"text", "foreignobject"} and element.box.area <= 0:

View File

@@ -228,6 +228,50 @@ class SvgPreviewLintTest(unittest.TestCase):
self.assertIn("low_information_density", self.codes(result))
self.assertLess(result["visual_score"], 100)
def test_placeholder_renderer_copy_gets_warning(self) -> None:
project = self.make_project()
self.write_preview(project)
self.write_svg(
project,
"""
<rect x="0" y="0" width="960" height="540" fill="#f8fafc" />
<rect x="96" y="120" width="260" height="190" fill="#dbeafe" />
<path d="M120 350 C240 260 420 380 620 250" fill="none" stroke="#2563eb" stroke-width="8" />
<text x="80" y="96" font-size="30" fill="#111827">Smoke deck</text>
<text x="80" y="156" font-size="18" fill="#334155">A generated page that still exposes implementation copy.</text>
<text id="footer" x="320" y="512" font-size="10" fill="#64748b">SVGlide contract renderer · 01</text>
""",
)
result = self.lint(project)
self.assertEqual(result["status"], "passed")
self.assertIn("placeholder_renderer_copy", self.codes(result))
self.assertLess(result["visual_score"], 100)
def test_unlabeled_visual_system_gets_density_warning(self) -> None:
project = self.make_project()
self.write_preview(project)
self.write_svg(
project,
"""
<rect x="0" y="0" width="960" height="540" fill="#f8fafc" />
<rect x="80" y="128" width="160" height="96" fill="#dbeafe" />
<rect x="280" y="128" width="160" height="96" fill="#bfdbfe" />
<rect x="480" y="128" width="160" height="96" fill="#93c5fd" />
<rect x="680" y="128" width="160" height="96" fill="#60a5fa" />
<path d="M120 340 L260 300 L420 360 L620 278 L820 330" fill="none" stroke="#2563eb" stroke-width="8" />
<text x="80" y="82" font-size="30" fill="#111827">Shape-only dashboard</text>
<text x="80" y="444" font-size="18" fill="#334155">The message is present but the visual system is not labeled.</text>
""",
)
result = self.lint(project)
self.assertEqual(result["status"], "passed")
self.assertIn("unlabeled_visual_system", self.codes(result))
self.assertLess(result["visual_score"], 100)
def test_repeated_multi_page_layout_gets_variety_warning(self) -> None:
project = self.make_project()
refs = []

View File

@@ -54,7 +54,7 @@ def script_path() -> Path:
def default_asset_map_path() -> Path:
return script_path().parents[1] / "references/ppt-master-asset-map.json"
return script_path().parents[1] / "references/svglide-design-pattern-map.json"
def load_asset_map(path: Path) -> dict:
@@ -272,7 +272,7 @@ def estimate_prompt_tokens(selected_assets: list[dict]) -> int:
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Select a small SVGlide active asset context from ppt-master-asset-map.json.")
parser = argparse.ArgumentParser(description="Select a small SVGlide active asset context from svglide-design-pattern-map.json.")
parser.add_argument("--asset-map", type=Path, default=default_asset_map_path())
parser.add_argument("--brief", default="", help="User brief or deck topic.")
parser.add_argument("--tags", default="", help="Comma-separated explicit selector tags.")

View File

@@ -25,7 +25,7 @@ def resource(
) -> dict:
return {
"id": resource_id,
"source_path": f"ppt-master/{resource_id}.svg",
"source_path": f"svglide-design-patterns/{resource_id}.svg",
"kind": kind,
"summary": summary,
"selection_tags": tags or ["business", "strategy", "roadmap", "chart"],
@@ -40,13 +40,34 @@ def resource(
def asset_map(resources: list[dict]) -> dict:
return {
"schema_version": "svglide-ppt-master-asset-map/v1",
"schema_version": "svglide-design-pattern-map/v1",
"summary": {"digests": {"all_source_files": "fixture"}},
"resources": resources,
}
class SVGlideAssetSelectorTest(unittest.TestCase):
def test_skill_sources_do_not_reference_external_reference_project_names(self) -> None:
skill_root = SCRIPT_DIR.parent
banned_tokens = [
"ppt" + "-master",
"ppt" + "_master",
"ppt" + " master",
"hugo" + "he3",
"ppt" + "169",
"global" + "_ai" + "_capital",
]
hits: list[str] = []
for path in skill_root.rglob("*"):
if path.suffix not in {".py", ".md", ".json"} or not path.is_file():
continue
text = path.read_text(encoding="utf-8").lower()
for token in banned_tokens:
if token in text:
hits.append(f"{path.relative_to(skill_root)} contains {token}")
self.assertEqual([], hits)
def test_reference_only_is_excluded_from_production(self) -> None:
data = asset_map(
[

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,776 @@
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import contextlib
import io
import json
import sys
import tempfile
import unittest
import xml.etree.ElementTree as ET
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
import svglide_gen_runtime as runtime
import svg_preview_lint as preview_lint
class SVGlideGenRuntimeTest(unittest.TestCase):
def test_render_demo_slide_emits_protocol_svg_and_component_report(self) -> None:
report = runtime.ComponentReport()
svg = runtime.render_demo_slide(
page=1,
kind="timeline",
title="Timeline",
summary="Pick for milestone events.",
asset_id="chart.timeline",
report=report,
)
data = report.to_dict()
self.assertIn('slide:role="slide"', svg)
self.assertIn('width="960"', svg)
self.assertEqual(data["schema_version"], "svglide-component-report/v1")
self.assertEqual(data["pages"][0]["page"], 1)
self.assertGreaterEqual(len(data["pages"][0]["components"]), 3)
component = data["pages"][0]["components"][0]
self.assertIn("bbox", component)
self.assertTrue(component["primitives"])
def test_design_pattern_usage_receipt_requires_page_trace(self) -> None:
report = runtime.ComponentReport()
runtime.render_demo_slide(
page=2,
kind="bar_chart",
title="Bar Chart",
summary="Pick for category comparison.",
asset_id="chart.bar_chart",
report=report,
)
receipt = runtime.design_pattern_usage_receipt(report.to_dict())
self.assertEqual(receipt["schema_version"], "svglide-design-pattern-usage/v1")
self.assertEqual(receipt["status"], "passed")
self.assertEqual(receipt["page_usages"][0]["asset_id"], "chart.bar_chart")
self.assertEqual(receipt["page_usages"][0]["page"], 2)
self.assertTrue(receipt["page_usages"][0]["component_ids"])
self.assertTrue(receipt["page_usages"][0]["source_trace"])
def test_design_pattern_visual_contracts_for_hero_pages(self) -> None:
cover_report = runtime.ComponentReport()
cover_svg = runtime.render_demo_slide(
page=1,
kind="cover",
title="Global AI Capital 2026",
summary="Capital, compute, and control.",
asset_id="layout.page_type.cover",
report=cover_report,
)
self.assertIn('id="cover-master-title"', cover_svg)
self.assertIn('font-size:64px', cover_svg)
self.assertIn("Cambria", cover_svg)
self.assertIn('id="slash-1"', cover_svg)
self.assertNotIn('id="title-surface"', cover_svg)
self.assertIn("large_hero_type", json.dumps(cover_report.to_dict()))
note_report = runtime.ComponentReport()
note_svg = runtime.render_demo_slide(
page=2,
kind="editor_note",
title="Why AI Capital Now",
summary="Editorial note.",
asset_id="layout.page_type.content",
report=note_report,
)
self.assertIn('id="quote_ticks-1"', note_svg)
self.assertIn('font-size:50px', note_svg)
self.assertIn("Cambria", note_svg)
self.assertIn("hero_metrics", json.dumps(note_report.to_dict()))
closing_report = runtime.ComponentReport()
closing_svg = runtime.render_demo_slide(
page=8,
kind="closing",
title="Closing Thesis",
summary="Takeaways.",
asset_id="layout.page_type.ending",
report=closing_report,
)
self.assertIn('id="closing-red-index-1"', closing_svg)
self.assertIn("numbered_hierarchy", json.dumps(closing_report.to_dict()))
def test_hub_renderer_uses_orbit_system_not_plain_cards(self) -> None:
report = runtime.ComponentReport()
svg = runtime.render_demo_slide(
page=7,
kind="hub_spoke",
title="Stargate Hub",
summary="Project finance meets compute scarcity.",
asset_id="chart.hub_spoke",
report=report,
)
self.assertIn('id="hub-orbit-outer"', svg)
self.assertIn('id="hub-core-glow"', svg)
self.assertIn("CAPEX LOOP", svg)
self.assertIn("orbit_system", json.dumps(report.to_dict()))
def test_chart_renderers_use_design_pattern_asset_level_structures(self) -> None:
cases = [
("kpi_cards", "kpi-observation-label", "editorial_sidebar"),
("bar_chart", "bar-insight-value", "editorial_sidebar"),
("donut_chart", "donut-investor-label", "investor_breakdown"),
("sankey_chart", "sankey-return-block", "return_flow"),
]
for kind, required_id, required_effect in cases:
with self.subTest(kind=kind):
report = runtime.ComponentReport()
svg = runtime.render_demo_slide(
page=3,
kind=kind,
title=kind.replace("_", " "),
summary="SVGlide pattern-level renderer contract",
asset_id=f"chart.{kind}",
report=report,
)
self.assertIn(f'id="{required_id}"', svg)
self.assertIn(required_effect, json.dumps(report.to_dict()))
self.assertIn("Cambria", svg)
def test_global_ai_pages_7_8_keep_design_pattern_bubble_then_donut_archetypes(self) -> None:
with tempfile.TemporaryDirectory() as raw:
project = Path(raw) / "runtime-project"
project.mkdir()
plan = project / "slide_plan.json"
plan.write_text(
json.dumps(
{
"title": "Global AI Capital 2026",
"slides": [
{"page_kind": "cover", "title": "Cover"},
{"page_kind": "editor_note", "title": "Editor's Note"},
{"page_kind": "kpi_cards", "title": "Q1 VC Landscape"},
{"page_kind": "bar_chart", "title": "Hyperscaler Capex"},
{"page_kind": "donut_chart", "title": "OpenAI Investors"},
{"page_kind": "sankey_chart", "title": "Nvidia Loop"},
{
"page_kind": "bubble_chart",
"asset_id": "chart.bubble_chart",
"title": "估值兑现度:气泡大小 = 投资人数",
"summary": "Valuation × ARR × investor count",
"bubbles": [
{"name": "xAI", "arr": 5, "valuation": 230, "investors": 8, "note": "$5B / $230B · 8+ investors"},
{"name": "OpenAI", "arr": 24, "valuation": 852, "investors": 7, "note": "$24B / $852B · 7+ investors"},
{"name": "Anthropic", "arr": 30, "valuation": 380, "investors": 5, "note": "$30B / $380B · 5 investors"},
],
"insight": "所有气泡都站在公允线之上xAI 偏离最远。",
},
{
"page_kind": "donut_chart",
"asset_id": "chart.donut_chart",
"title": "OpenAI $122B 这笔钱,从哪来",
"center": {"value": "$122B", "label": "TOTAL ROUND", "note": "@ $852B post-money"},
"segments": [
{"name": "Amazon", "value": "$50B", "share": 41, "note": "41% · AWS 算力承诺"},
{"name": "Nvidia", "value": "$30B", "share": 25, "note": "25% · 10GW 系统部署"},
{"name": "SoftBank", "value": "$30B", "share": 25, "note": "25% · Stargate 联合主体"},
{"name": "MSFT + 其他", "value": "$12B", "share": 9, "note": "9% · 其他投资人"},
],
},
],
}
),
encoding="utf-8",
)
runtime.main(["compose", "--project", str(project), "--plan", "slide_plan.json"])
page7 = (project / "pages" / "page-007.svg").read_text(encoding="utf-8")
page8 = (project / "pages" / "page-008.svg").read_text(encoding="utf-8")
cache = json.loads((project / "receipts" / "runtime-cache.json").read_text(encoding="utf-8"))
usage = json.loads((project / "receipts" / "design-pattern-usage.json").read_text(encoding="utf-8"))
self.assertEqual(cache["pages"][6]["page_kind"], "bubble_chart")
self.assertEqual(cache["pages"][7]["page_kind"], "donut_chart")
self.assertIn('id="bubble-openai"', page7)
self.assertIn('id="bubble-anthropic"', page7)
self.assertIn('id="bubble-xai"', page7)
self.assertIn('id="bubble-insight-band"', page7)
self.assertIn('id="bubble-openai-name-plate"', page7)
self.assertNotIn('id="bubble-openai-label-back"', page7)
self.assertIn('id="donut-track"', page8)
self.assertIn("$122B", page8)
self.assertNotIn('id="hub-orbit-outer"', page7)
self.assertNotIn('id="closing-list"', page8)
self.assertEqual(usage["page_usages"][6]["asset_id"], "chart.bubble_chart")
self.assertEqual(usage["page_usages"][7]["asset_id"], "chart.donut_chart")
root = ET.fromstring(page7)
elements = {element.get("id"): element for element in root.iter() if element.get("id")}
for name in ("bubble-xai", "bubble-openai", "bubble-anthropic"):
label = elements[f"{name}-label"]
note = elements[f"{name}-note"]
plate = elements[f"{name}-name-plate"]
self.assertGreaterEqual(float(note.get("y", "0")) - float(label.get("y", "0")), 24)
self.assertLessEqual(float(plate.get("y", "0")) + float(plate.get("height", "0")), float(note.get("y", "0")))
def test_strategist_contract_renderer_uses_real_labels_not_placeholder_footer(self) -> None:
spec = {
"schema_version": "svglide-strategist-contract/v1",
"page_type": "kpi_overview",
"key_message": "低空物流网络的关键运营指标,包括时效、成本、覆盖、可靠性",
"text_budget_by_role": {
"title": {"max_chars": 18, "max_boxes": 1},
"metric": {"max_chars": 12, "max_boxes": 1},
"body": {"max_chars": 72, "max_boxes": 1},
"footer": {"max_chars": 32, "max_boxes": 1},
},
"layout_boxes": [
{"id": "title", "role": "title", "x": 48, "y": 34, "width": 864, "height": 48},
{"id": "primary-kpi", "role": "metric", "x": 64, "y": 106, "width": 260, "height": 128},
{"id": "secondary-grid", "role": "grid", "x": 348, "y": 106, "width": 548, "height": 128},
{"id": "chart-row", "role": "chart", "x": 64, "y": 258, "width": 832, "height": 150},
{"id": "body", "role": "body", "x": 64, "y": 426, "width": 832, "height": 56},
{"id": "footer", "role": "footer", "x": 64, "y": 500, "width": 832, "height": 24},
],
}
report = runtime.ComponentReport()
svg = runtime.render_contract_slide(
page=2,
kind="kpi_cards",
title="",
summary="",
asset_id="chart.kpi_cards",
accent="#2563EB",
spec=spec,
report=report,
deck_title="城市级低空物流网络策划案",
)
self.assertNotIn("SVGlide contract renderer", svg)
self.assertIn("准点率", svg)
self.assertIn("时效", svg)
self.assertIn("城市级低空物流网络策划案", svg)
def test_strategist_contract_dense_pages_emit_semantic_visual_labels(self) -> None:
base_spec = {
"schema_version": "svglide-strategist-contract/v1",
"key_message": "低空物流网络需要把订单入口、空域调度、无人机执行和末端交付串成闭环",
"text_budget_by_role": {
"title": {"max_chars": 24, "max_boxes": 1},
"body": {"max_chars": 96, "max_boxes": 1},
"callout": {"max_chars": 60, "max_boxes": 1},
"footer": {"max_chars": 32, "max_boxes": 1},
},
"layout_boxes": [
{"id": "title", "role": "title", "x": 64, "y": 46, "width": 680, "height": 52},
{"id": "visual", "role": "visual", "x": 92, "y": 132, "width": 776, "height": 300},
{"id": "body", "role": "body", "x": 96, "y": 388, "width": 760, "height": 46},
{"id": "footer", "role": "footer", "x": 64, "y": 500, "width": 832, "height": 24},
],
}
cases = [
("process_flow", "process_flow", 'id="callout"', "订单入口"),
("capability_map", "hub_spoke", 'id="legend"', "空域调度"),
("comparison", "comparison_table", 'id="comparison-dimension-1"', "拥堵"),
("chart_takeaway", "bar_chart", 'id="callout"', "订单入口"),
("closing", "closing", 'id="closing-step-card-1"', "一中台"),
]
for page_type, kind, required_id, required_copy in cases:
with self.subTest(page_type=page_type):
spec = dict(base_spec, page_type=page_type, title=page_type)
report = runtime.ComponentReport()
svg = runtime.render_contract_slide(
page=3,
kind=kind,
title="",
summary="",
asset_id=f"chart.{kind}",
accent="#2563EB",
spec=spec,
report=report,
deck_title="城市级低空物流网络策划案",
)
self.assertIn(required_id, svg)
self.assertIn(required_copy, svg)
def test_strategist_contract_uses_full_page_archetype_geometry(self) -> None:
base_spec = {
"schema_version": "svglide-strategist-contract/v1",
"key_message": "低空物流网络需要形成可运营、可调度、可复盘的城市级基础设施",
"text_budget_by_role": {
"title": {"max_chars": 24, "max_boxes": 1},
"body": {"max_chars": 96, "max_boxes": 1},
"callout": {"max_chars": 60, "max_boxes": 1},
"footer": {"max_chars": 32, "max_boxes": 1},
},
"layout_boxes": [
{"id": "title", "role": "title", "x": 64, "y": 46, "width": 680, "height": 52},
{"id": "visual", "role": "visual", "x": 92, "y": 132, "width": 776, "height": 300},
{"id": "body", "role": "body", "x": 96, "y": 388, "width": 760, "height": 46},
{"id": "callout", "role": "callout", "x": 612, "y": 380, "width": 250, "height": 60},
{"id": "footer", "role": "footer", "x": 64, "y": 500, "width": 832, "height": 24},
],
}
cases = [
("cover", "cover", ["cover-map-field", "cover-route-ribbon", "cover-coordinate-stack-1"], ["full_page_archetype", "hero_route", "title_field"]),
("agenda", "agenda", ["agenda-route-backplane", "agenda-number-1", "agenda-route-path"], ["numbered_path", "section_index", "semantic_labels"]),
("section_divider", "section", ["section-signal-field", "section-index-rail", "section-hero-number"], ["section_index", "hero_signal", "full_page_archetype"]),
("process_flow", "process_flow", ["flow-backplane", "flow-lane-upper", "flow-lane-lower"], ["connector_flow", "flow_lanes", "full_page_archetype"]),
("capability_map", "hub_spoke", ["hub-backplane", "hub-sector-1", "hub-satellite-panel-1"], ["hub_spoke", "sector_field", "semantic_labels"]),
("chart_takeaway", "bar_chart", ["bar-plot-backplane", "bar-insight-strip", "bar-variance-path"], ["chart_geometry", "insight_strip", "full_page_archetype"]),
("closing", "closing", ["closing-backplane", "closing-step-card-1", "closing-route-ribbon"], ["closing_ribbon", "action_cards", "full_page_archetype"]),
]
for page_type, kind, required_ids, required_evidence in cases:
with self.subTest(page_type=page_type):
spec = dict(base_spec, page_type=page_type, title=page_type)
spec["visual_design_contract"] = {
"required_visual_evidence": required_evidence,
}
if page_type == "cover":
spec["layout_boxes"] = [
{"id": "title", "role": "title", "x": 72, "y": 150, "width": 560, "height": 120},
{"id": "body", "role": "body", "x": 76, "y": 284, "width": 520, "height": 72},
{"id": "visual", "role": "visual", "x": 600, "y": 84, "width": 288, "height": 360},
{"id": "footer", "role": "footer", "x": 64, "y": 500, "width": 832, "height": 24},
]
report = runtime.ComponentReport()
svg = runtime.render_contract_slide(
page=4,
kind=kind,
title="",
summary="",
asset_id=f"chart.{kind}",
accent="#2563EB",
spec=spec,
report=report,
deck_title="城市级低空物流网络策划案",
)
for required_id in required_ids:
self.assertIn(f'id="{required_id}"', svg)
encoded_report = json.dumps(report.to_dict())
for evidence in spec["visual_design_contract"]["required_visual_evidence"]:
self.assertIn(evidence, encoded_report)
def test_evidence_effects_does_not_echo_contract_required_evidence_as_proof(self) -> None:
effects = runtime.evidence_effects(
{"visual_design_contract": {"required_visual_evidence": ["fake_contract_evidence"]}},
["chart_geometry"],
)
self.assertEqual(["chart_geometry"], effects)
self.assertNotIn("fake_contract_evidence", effects)
def test_style_system_palette_changes_rendered_svg_fingerprint(self) -> None:
def compose_with_accent(accent: str) -> str:
with tempfile.TemporaryDirectory() as raw:
project = Path(raw) / "runtime-project"
project.mkdir()
plan = project / "slide_plan.json"
plan.write_text(
json.dumps(
{
"schema_version": "svglide-strategist-contract/v1",
"title": "Theme Accent Test",
"style_system": {"palette": {"accent": accent}},
"slides": [
{
"page": 1,
"page_type": "agenda",
"title": "Contents",
"key_message": "Agenda route",
"visual_design_contract": {
"required_visual_evidence": ["numbered_path", "section_index", "semantic_labels"],
},
}
],
}
),
encoding="utf-8",
)
runtime.compose_project(project, plan)
return (project / "pages" / "page-001.svg").read_text(encoding="utf-8")
blue = compose_with_accent("#4A90E2")
red = compose_with_accent("#E91E63")
self.assertIn("#4A90E2", blue)
self.assertIn("#E91E63", red)
self.assertNotEqual(blue, red)
def test_contract_navigation_pages_emit_enough_semantic_labels_for_preview_lint(self) -> None:
deck_title = "新疆阿克苏城区居住区策划案"
cases = [
(
"cover",
"cover",
{
"page_type": "cover",
"title": "以水为脉·四时为序",
"key_message": "四境共生·四季归心的绿洲栖居范本",
"layout_boxes": [
{"id": "title", "role": "title", "x": 72, "y": 150, "width": 560, "height": 120},
{"id": "body", "role": "body", "x": 76, "y": 284, "width": 520, "height": 72},
{"id": "visual", "role": "visual", "x": 600, "y": 84, "width": 288, "height": 360},
{"id": "footer", "role": "footer", "x": 64, "y": 500, "width": 832, "height": 24},
],
},
),
(
"agenda",
"agenda",
{
"page_type": "agenda",
"title": "目录",
"layout_boxes": [
{"id": "title", "role": "title", "x": 64, "y": 54, "width": 600, "height": 54},
{"id": "rail", "role": "timeline", "x": 96, "y": 136, "width": 48, "height": 312},
{"id": "body", "role": "body", "x": 154, "y": 126, "width": 650, "height": 330},
{"id": "visual", "role": "visual", "x": 820, "y": 156, "width": 64, "height": 64},
{"id": "footer", "role": "footer", "x": 64, "y": 500, "width": 832, "height": 24},
],
},
),
(
"section",
"section",
{
"page_type": "section_divider",
"title": "01 项目核心定位与愿景",
"key_message": "以水串联四季,打造全季态生态居住区",
"layout_boxes": [
{"id": "section-number", "role": "kicker", "x": 72, "y": 92, "width": 160, "height": 90},
{"id": "title", "role": "title", "x": 180, "y": 188, "width": 600, "height": 88},
{"id": "body", "role": "body", "x": 184, "y": 292, "width": 560, "height": 38},
{"id": "visual", "role": "visual", "x": 0, "y": 320, "width": 960, "height": 180},
{"id": "footer", "role": "footer", "x": 64, "y": 500, "width": 832, "height": 24},
],
},
),
]
with tempfile.TemporaryDirectory() as raw:
project = Path(raw)
for page, (kind, asset_id, spec) in enumerate(cases, 1):
svg = runtime.render_contract_slide(
page=page,
kind=kind,
title="",
summary="",
asset_id=asset_id,
accent="#4A90E2",
spec={**spec, "_deck_title": deck_title},
report=runtime.ComponentReport(),
deck_title=deck_title,
)
source = preview_lint.SvgSource(page=page, label=f"page-{page}", root=ET.fromstring(svg), base_dir=project)
codes = {check["code"] for check in preview_lint.lint_svg_source(project, source)}
self.assertNotIn("unlabeled_visual_system", codes)
def test_asset_marks_are_arc_free_and_reject_unknown_ids(self) -> None:
spark = "".join(runtime.asset_mark("spark", 12, 24, 1.25, "#E63946", opacity=0.8))
self.assertIn("<path", spark)
self.assertIn('id="spark-1"', spark)
self.assertNotRegex(spark, r'\sd="[^"]*[Aa](?=[\s,\d.+-])')
with self.assertRaisesRegex(ValueError, "unknown asset mark"):
runtime.asset_mark("missing", 0, 0, 1, "#000000")
def test_path_rejects_arc_commands(self) -> None:
with self.assertRaisesRegex(ValueError, "arc commands"):
runtime.path("bad-arc", "M10 10 A20 20 0 0 1 30 30")
def test_compose_cli_writes_supported_pages_and_receipts(self) -> None:
with tempfile.TemporaryDirectory() as raw:
project = Path(raw) / "runtime-project"
project.mkdir()
plan = project / "slide_plan.json"
kinds = [
"cover",
"editor_note",
"kpi_cards",
"bar_chart",
"bubble_chart",
"donut_chart",
"sankey_chart",
"hub_spoke",
"closing",
]
chart_assets = [
"chart.kpi_cards",
"chart.bar_chart",
"chart.bubble_chart",
"chart.donut_chart",
"chart.sankey_chart",
"chart.hub_spoke",
]
plan.write_text(
json.dumps(
{
"title": "Runtime Slice",
"design_pattern_selection": {"selected_assets": [{"id": asset} for asset in chart_assets]},
"slides": [
{
"page_kind": kind,
"title": kind.replace("_", " ").title(),
"summary": f"Compose fixture for {kind}",
}
for kind in kinds
],
}
),
encoding="utf-8",
)
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
exit_code = runtime.main(["compose", "--project", str(project), "--plan", "slide_plan.json"])
self.assertEqual(exit_code, 0)
result = json.loads(stdout.getvalue())
self.assertEqual(result["schema_version"], "svglide-gen-runtime-cache/v1")
self.assertEqual(result["page_count"], len(kinds))
self.assertEqual([page["page_kind"] for page in result["pages"]], kinds)
for index in range(1, len(kinds) + 1):
svg_path = project / "pages" / f"page-{index:03d}.svg"
self.assertTrue(svg_path.exists())
svg = svg_path.read_text(encoding="utf-8")
self.assertIn('slide:role="slide"', svg)
self.assertIn('viewBox="0 0 960 540"', svg)
self.assertNotRegex(svg, r'<path\b[^>]*\sd="[^"]*[Aa](?=[\s,\d.+-])')
ET.parse(svg_path)
component_report = json.loads((project / "receipts" / "emitted_components.json").read_text(encoding="utf-8"))
self.assertEqual(component_report["schema_version"], "svglide-component-report/v1")
self.assertEqual(len(component_report["pages"]), len(kinds))
self.assertGreaterEqual(component_report["summary"]["component_count"], 16)
usage = json.loads((project / "receipts" / "design-pattern-usage.json").read_text(encoding="utf-8"))
self.assertEqual(usage["schema_version"], "svglide-design-pattern-usage/v1")
used_assets = {item["asset_id"] for item in usage["page_usages"]}
self.assertTrue(set(chart_assets).issubset(used_assets))
self.assertTrue(set(chart_assets).issubset(set(usage["used_asset_ids"])))
cache = json.loads((project / "receipts" / "runtime-cache.json").read_text(encoding="utf-8"))
self.assertEqual(cache, result)
self.assertTrue(set(kinds).issubset(set(cache["supported_page_kinds"])))
self.assertIn("agenda", cache["supported_page_kinds"])
self.assertIn("section", cache["supported_page_kinds"])
def test_compose_uses_real_slide_plan_fields(self) -> None:
with tempfile.TemporaryDirectory() as raw:
project = Path(raw) / "runtime-project"
project.mkdir()
plan = project / "slide_plan.json"
plan.write_text(
json.dumps(
{
"title": "Field Mapping",
"slides": [
{
"page": 1,
"page_type": "chart",
"chart_type": "bar_chart",
"reference_asset": {"asset_id": "chart.bar_chart", "source": "svglide_design_pattern"},
"visual_plan": {
"key_message": "Capex bar chart",
"body": "Renderer should use chart_type and nested copy.",
},
}
],
}
),
encoding="utf-8",
)
runtime.main(["compose", "--project", str(project), "--plan", "slide_plan.json"])
svg = (project / "pages" / "page-001.svg").read_text(encoding="utf-8")
cache = json.loads((project / "receipts" / "runtime-cache.json").read_text(encoding="utf-8"))
usage = json.loads((project / "receipts" / "design-pattern-usage.json").read_text(encoding="utf-8"))
self.assertEqual(cache["pages"][0]["page_kind"], "bar_chart")
self.assertIn("Capex bar chart", svg)
self.assertIn("Renderer should use chart_type", svg)
self.assertEqual(usage["page_usages"][0]["asset_id"], "chart.bar_chart")
def test_compose_uses_nested_reference_asset(self) -> None:
with tempfile.TemporaryDirectory() as raw:
project = Path(raw) / "runtime-project"
project.mkdir()
plan = project / "slide_plan.json"
plan.write_text(
json.dumps(
{
"title": "Nested Asset Mapping",
"slides": [
{
"page": 1,
"page_type": "content",
"visual_plan": {
"page_kind": "editor_note",
"reference_asset": {"asset_id": "layout.page_type.content", "source": "svglide_design_pattern"},
"key_message": "Nested reference should drive the receipt",
},
}
],
}
),
encoding="utf-8",
)
runtime.main(["compose", "--project", str(project), "--plan", "slide_plan.json"])
cache = json.loads((project / "receipts" / "runtime-cache.json").read_text(encoding="utf-8"))
usage = json.loads((project / "receipts" / "design-pattern-usage.json").read_text(encoding="utf-8"))
self.assertEqual(cache["pages"][0]["page_kind"], "editor_note")
self.assertEqual(cache["pages"][0]["asset_id"], "layout.page_type.content")
self.assertEqual(usage["page_usages"][0]["asset_id"], "layout.page_type.content")
def test_compose_uses_nested_asset_id(self) -> None:
with tempfile.TemporaryDirectory() as raw:
project = Path(raw) / "runtime-project"
project.mkdir()
plan = project / "slide_plan.json"
plan.write_text(
json.dumps(
{
"title": "Nested Asset ID Mapping",
"slides": [
{
"page": 1,
"page_type": "chart",
"chart_type": "donut_chart",
"visual_plan": {
"asset_id": "chart.donut_chart",
"key_message": "Nested asset_id should drive the receipt",
},
}
],
}
),
encoding="utf-8",
)
runtime.main(["compose", "--project", str(project), "--plan", "slide_plan.json"])
cache = json.loads((project / "receipts" / "runtime-cache.json").read_text(encoding="utf-8"))
usage = json.loads((project / "receipts" / "design-pattern-usage.json").read_text(encoding="utf-8"))
self.assertEqual(cache["pages"][0]["page_kind"], "donut_chart")
self.assertEqual(cache["pages"][0]["asset_id"], "chart.donut_chart")
self.assertEqual(usage["page_usages"][0]["asset_id"], "chart.donut_chart")
def test_compose_parameterizes_design_pattern_renderers_for_non_ai_topic(self) -> None:
with tempfile.TemporaryDirectory() as raw:
project = Path(raw) / "runtime-project"
project.mkdir()
plan = project / "slide_plan.json"
plan.write_text(
json.dumps(
{
"title": "Aksu Oasis Living District",
"slides": [
{
"page_kind": "cover",
"title": "以水为脉 四时为序",
"summary": "阿克苏城区生态居住区策划案",
"kicker": "AKSU OASIS / RESIDENTIAL STRATEGY",
"meta_1": "OASIS PLANNING",
"meta_2": "FOUR SEASONS\nLIVING MAP",
"year": "2026",
},
{
"page_kind": "kpi_cards",
"title": "四季地块价值矩阵",
"metrics": [
{"value": "4", "label": "四季地块", "note": "春夏秋冬差异化主题"},
{"value": "1", "label": "水系闭环", "note": "串联全区公共空间"},
{"value": "3", "label": "价值引擎", "note": "配套 景观 文化"},
{"value": "全年龄", "label": "人群覆盖", "note": "儿童 青年 长者"},
],
"insight": {
"label": "PLANNING NOTE",
"title": "水系不是装饰,\n是组织结构。",
"copy": "用一条蓝绿生态脉络串联地块、配套和归家体验。",
"number": "4境",
},
},
{
"page_kind": "sankey_chart",
"title": "水系如何转化为空间价值",
"origin": {"name": "水系", "value": "1环", "label": "ecological spine"},
"targets": [
{"name": "春配套", "value": ""},
{"name": "夏活力", "value": ""},
{"name": "秋静谧", "value": ""},
{"name": "冬暖居", "value": ""},
],
"return_flow": {"title": "归心体验", "value": "全年", "note": "four-season loop"},
"insight": "Water organizes the community into a legible four-season living loop.",
},
{
"page_kind": "hub_spoke",
"title": "四境联动系统",
"hub": {"value": "水脉", "label": "OASIS LOOP"},
"nodes": [
{"name": "春之地块", "note": "配套入口"},
{"name": "夏之地块", "note": "运动活力"},
{"name": "秋之地块", "note": "胡杨静谧"},
{"name": "冬之地块", "note": "暖廊归家"},
{"name": "艾德莱斯", "note": "地域纹样"},
{"name": "公共服务", "note": "全年龄覆盖"},
],
"side_note": "四境共生 四季归心",
"side_index": "OASIS LOOP",
},
{
"page_kind": "closing",
"title": "总结与展望",
"takeaways": [
"以水为脉组织归家体验",
"四季地块形成差异化记忆",
"胡杨与艾德莱斯强化地域识别",
"配套共享覆盖全年龄需求",
"生态景观提升长期溢价",
"成为阿克苏人居升级样板",
],
"critical_copy": "四境共生,四季归心。",
"sidebar": "OASIS\nLIVING\nAKSU",
},
],
}
),
encoding="utf-8",
)
runtime.main(["compose", "--project", str(project), "--plan", "slide_plan.json"])
all_svg = "\n".join(
(project / "pages" / f"page-{index:03d}.svg").read_text(encoding="utf-8")
for index in range(1, 6)
)
self.assertIn("AKSU OASIS", all_svg)
self.assertIn("四季地块", all_svg)
self.assertIn("水系不是装饰", all_svg)
self.assertIn("归心体验", all_svg)
self.assertIn("胡杨静谧", all_svg)
self.assertIn("四境共生,四季归心", all_svg)
self.assertNotIn("Global AI", all_svg)
self.assertNotIn("Nvidia", all_svg)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import argparse
import copy
import json
import sys
from typing import Any
MANIFEST_SCHEMA_VERSION = "svglide-golden-suite-manifest/v1"
_GOLDEN_CASES: tuple[dict[str, Any], ...] = (
{
"case_id": "ai-capital-editorial",
"theme_domain": "ai_infrastructure_finance",
"prompt_summary": "Editorial deck about capital flows, compute commitments, and investor risk signals in the AI infrastructure cycle.",
"expected_archetypes": [
"cover",
"editor_note",
"kpi_cards",
"bar_chart",
"donut_chart",
"sankey_chart",
"bubble_chart",
"closing",
],
"required_evidence": [
"design_pattern_usage_receipt",
"component_report_with_chart_archetypes",
"svg_preflight_pass",
],
},
{
"case_id": "aksu-oasis-planning",
"theme_domain": "urban_oasis_residential_planning",
"prompt_summary": "Planning deck for an Aksu oasis living district, using water, seasonal blocks, and local identity as the organizing system.",
"expected_archetypes": [
"cover",
"agenda",
"section",
"kpi_cards",
"sankey_chart",
"hub_spoke",
"comparison_table",
"closing",
],
"required_evidence": [
"domain_copy_retained",
"agenda_numbered_path",
"section_signal",
"non_ai_topic_parameterization",
"svg_preflight_pass",
],
},
{
"case_id": "runtime-smoke",
"theme_domain": "svglide_runtime_health",
"prompt_summary": "Small deterministic smoke case for validating runtime composition, manifest plumbing, and receipt emission before larger regressions.",
"expected_archetypes": [
"cover",
"kpi_cards",
"bar_chart",
"closing",
],
"required_evidence": [
"runtime_cache_written",
"component_report_written",
"svg_preflight_pass",
],
},
)
def list_cases() -> list[dict[str, Any]]:
return copy.deepcopy(list(_GOLDEN_CASES))
def build_manifest() -> dict[str, Any]:
cases = list_cases()
return {
"schema_version": MANIFEST_SCHEMA_VERSION,
"case_count": len(cases),
"cases": cases,
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Emit the built-in SVGlide golden suite case manifest.")
subparsers = parser.add_subparsers(dest="command", required=True)
list_parser = subparsers.add_parser("list", help="list built-in golden suite cases")
list_parser.add_argument("--json", action="store_true", help="emit the case manifest as JSON")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.command == "list":
if not args.json:
parser.error("list currently supports only --json")
print(json.dumps(build_manifest(), ensure_ascii=False, indent=2, sort_keys=True))
return 0
parser.error(f"unsupported command: {args.command}")
return 2
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,78 @@
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import contextlib
import io
import json
import sys
import unittest
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
import svglide_golden_suite as golden_suite
class SVGlideGoldenSuiteTest(unittest.TestCase):
def test_list_json_outputs_manifest_with_required_cases(self) -> None:
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
exit_code = golden_suite.main(["list", "--json"])
self.assertEqual(exit_code, 0)
manifest = json.loads(stdout.getvalue())
self.assertEqual(manifest["schema_version"], "svglide-golden-suite-manifest/v1")
self.assertEqual(manifest["case_count"], len(manifest["cases"]))
self.assertGreaterEqual(manifest["case_count"], 3)
case_ids = {case["case_id"] for case in manifest["cases"]}
self.assertTrue(
{
"ai-capital-editorial",
"aksu-oasis-planning",
"runtime-smoke",
}.issubset(case_ids)
)
for case in manifest["cases"]:
for key in ["case_id", "theme_domain", "prompt_summary", "expected_archetypes", "required_evidence"]:
self.assertIn(key, case)
self.assertIsInstance(case["expected_archetypes"], list)
self.assertTrue(case["expected_archetypes"])
def test_manifest_has_no_external_reference_project_words(self) -> None:
encoded = json.dumps(golden_suite.build_manifest(), ensure_ascii=False).lower()
banned_tokens = [
"ppt" + "-master",
"ppt" + "_master",
"ppt" + " master",
"hugo" + "he3",
"ppt" + "169",
]
for token in banned_tokens:
with self.subTest(token=token):
self.assertNotIn(token, encoded)
def test_each_case_declares_required_evidence(self) -> None:
for case in golden_suite.list_cases():
with self.subTest(case_id=case["case_id"]):
evidence = case["required_evidence"]
self.assertIsInstance(evidence, list)
self.assertTrue(evidence)
self.assertTrue(all(isinstance(item, str) and item for item in evidence))
def test_aksu_case_locks_agenda_and_section_regression(self) -> None:
cases = {case["case_id"]: case for case in golden_suite.list_cases()}
aksu = cases["aksu-oasis-planning"]
self.assertIn("agenda", aksu["expected_archetypes"])
self.assertIn("section", aksu["expected_archetypes"])
self.assertIn("agenda_numbered_path", aksu["required_evidence"])
self.assertIn("section_signal", aksu["required_evidence"])
if __name__ == "__main__":
unittest.main()

View File

@@ -16,11 +16,14 @@ import struct
import subprocess
import sys
import time
import xml.etree.ElementTree as ET
import zlib
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import svg_preflight
STAGES = [
"generate",
@@ -67,10 +70,20 @@ PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
STAGE_FINGERPRINT_SCHEMA = "svglide-stage-fingerprint/v1"
QUALITY_GATE_SCHEMA = "svglide-quality-gate/v1"
ENV_PROOF_SCHEMA = "svglide-env-proof/v1"
PPT_MASTER_ASSET_USAGE_SCHEMA = "svglide-ppt-master-asset-usage/v1"
DESIGN_PATTERN_USAGE_SCHEMA = "svglide-design-pattern-usage/v1"
COMPONENT_REPORT_SCHEMA = "svglide-component-report/v1"
PREVIEW_LINT_WAIVER_TTL_MS = 30 * 60 * 1000
DEFAULT_VALIDATION_PROFILE = "authoring"
STRATEGIST_CONTRACT_CODES = {
"plan_missing_page_rhythm",
"plan_missing_page_type",
"plan_missing_main_visual_anchor",
"plan_main_visual_anchor_not_met",
"plan_reference_asset_unstructured",
"plan_unknown_chart_type",
"plan_chart_type_recipe_mismatch",
"plan_chart_type_contract_not_met",
}
VISUAL_SCORE_THRESHOLDS = {
"authoring": 75,
"production": 85,
@@ -464,6 +477,95 @@ def run_manifest_command(project: Path, data: dict[str, Any], stage: str) -> dic
return {"status": "passed", "command": args, "log": str(log_path(project, stage).relative_to(project))}
def manifest_page_descriptions(data: dict[str, Any]) -> list[str]:
descriptions: list[str] = []
raw_descriptions = data.get("page_descriptions")
if isinstance(raw_descriptions, list):
descriptions.extend(text_from_any(item) for item in raw_descriptions if text_from_any(item))
for key in ["slides", "pages"]:
raw_pages = data.get(key)
if not isinstance(raw_pages, list):
continue
for item in raw_pages:
if isinstance(item, str) and item.strip():
descriptions.append(item.strip())
continue
if not isinstance(item, dict):
continue
text = " ".join(
part
for part in [
text_from_any(item.get("title") or item.get("headline") or item.get("name")),
text_from_any(item.get("description") or item.get("summary") or item.get("key_message")),
]
if part
)
if text:
descriptions.append(text)
return descriptions
def project_brief_text(project: Path, data: dict[str, Any]) -> str:
parts: list[str] = []
for key in ["brief", "prompt", "query", "source_brief"]:
value = text_from_any(data.get(key))
if value:
parts.append(value)
for key in ["brief_file", "prompt_file", "source_brief_file", "source_file"]:
value = text_from_any(data.get(key))
if not value:
continue
path = safe_existing_file(project_file(project, value), root=project)
parts.append(path.read_text(encoding="utf-8"))
for relative in ["source/brief.md", "source/prompt.md", "brief.md", "prompt.md"]:
candidate = project / relative
if candidate.exists() and candidate.is_file():
parts.append(candidate.read_text(encoding="utf-8"))
return "\n\n".join(part.strip() for part in parts if part.strip())
def builtin_generate(project: Path, data: dict[str, Any]) -> dict[str, Any]:
if data.get("generated") is True:
return {"status": "skipped", "reason": "generated=true"}
import svglide_gen_runtime
import svglide_strategist
plan_path = safe_output_file(plan_file(project, data), root=project, suffix=".json")
existing_plan = read_json(plan_path, {}) if plan_path.exists() else None
if existing_plan is not None and not isinstance(existing_plan, dict):
raise RunnerError("slide_plan.json must contain an object")
contract = svglide_strategist.build_contract(
brief=project_brief_text(project, data),
slide_plan=existing_plan,
page_descriptions=manifest_page_descriptions(data),
)
if not text_from_any(contract.get("title")):
contract["title"] = deck_title(project, data)
write_json(plan_path, contract)
runtime_cache = svglide_gen_runtime.compose_project(project, plan_path)
body = {
"status": "passed",
"generator": "builtin:svglide_strategist_runtime",
"plan": rel_to_project(project, plan_path),
"page_count": runtime_cache.get("page_count", 0),
"outputs": runtime_cache.get("outputs", {}),
"runtime_cache": runtime_cache,
}
log_path(project, "generate").write_text(json.dumps(body, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
return body
def run_generate(project: Path, data: dict[str, Any]) -> dict[str, Any]:
command = stage_command(data, "generate")
if not command or command in {"builtin:svglide_runtime", "builtin:svglide_strategist_runtime"}:
return builtin_generate(project, data)
if command.startswith("builtin:"):
raise RunnerError(f"unknown generate builtin: {command}")
return run_manifest_command(project, data, "generate")
def current_input_digest(project: Path, data: dict[str, Any], *, prepared: bool) -> str:
pieces: list[str] = []
plan = plan_file(project, data)
@@ -575,6 +677,20 @@ def stage_input_fingerprint(stage: str, project: Path, args: argparse.Namespace)
add_file_fingerprint(inputs, project, "manifest", manifest_path(project))
add_value_fingerprint(inputs, "stage_command", stage_command(data, stage))
if stage == "generate":
plan = plan_file(project, data)
if plan.exists():
add_file_fingerprint(inputs, project, "plan", plan)
for relative in ["source/brief.md", "source/prompt.md", "brief.md", "prompt.md"]:
add_file_fingerprint(inputs, project, "brief", project / relative)
scripts = repo_root() / "skills" / "lark-slides" / "scripts"
references = repo_root() / "skills" / "lark-slides" / "references"
for script_name in ["svglide_strategist.py", "svglide_gen_runtime.py"]:
add_file_fingerprint(inputs, project, "script", scripts / script_name)
for catalog in ["style-presets.json", "svg-seeds.json", "svg-recipes.json", "svglide-design-pattern-map.json"]:
add_file_fingerprint(inputs, project, "catalog", references / catalog)
add_value_fingerprint(inputs, "brief", project_brief_text(project, data))
add_value_fingerprint(inputs, "page_descriptions", manifest_page_descriptions(data))
if stage in {"generate", "prepare"}:
for page in project_pages(project, data, prepared=False):
add_file_fingerprint(inputs, project, "source_svg", page)
@@ -604,7 +720,7 @@ def stage_input_fingerprint(stage: str, project: Path, args: argparse.Namespace)
for dependency in ["preflight", "preview_lint"]:
add_file_fingerprint(inputs, project, f"{dependency}_receipt", receipt_path(project, dependency, data))
add_file_fingerprint(inputs, project, "component_report", project / "receipts" / "emitted_components.json")
add_file_fingerprint(inputs, project, "ppt_master_asset_usage", project / "receipts" / "ppt-master-asset-usage.json")
add_file_fingerprint(inputs, project, "design_pattern_usage", project / "receipts" / "design-pattern-usage.json")
add_file_fingerprint(inputs, project, "component_waiver", project / "receipts" / "emitted-components-waiver.json")
add_file_fingerprint(inputs, project, "allowlist_receipt", project / "receipts" / "allowlist.json")
try:
@@ -839,7 +955,7 @@ def run_preflight(project: Path, data: dict[str, Any]) -> dict[str, Any]:
args.extend(["--input", str(safe_existing_file(path, suffix=".svg", root=project))])
result = run_command(args, cwd=repo_root())
log_path(project, "preflight").write_text(result.stdout + result.stderr, encoding="utf-8")
parsed = parse_json_output(result.stdout)
parsed = parse_json_output(result.stdout) or parse_json_output(result.stderr)
if result.returncode != 0:
raise RunnerError(f"preflight failed: {result.returncode}; see {log_path(project, 'preflight')}")
return {
@@ -860,6 +976,22 @@ def parse_json_output(text: str) -> Any:
return None
def is_cli_auth_config_failure(result: CommandResult, parsed: Any) -> bool:
combined = f"{result.stdout}\n{result.stderr}".lower()
if "keychain get failed" in combined or ("keychain" in combined and "not initialized" in combined):
return True
if not isinstance(parsed, dict):
return False
error = parsed.get("error")
if not isinstance(error, dict):
return False
error_type = text_from_any(error.get("type")).lower()
message = text_from_any(error.get("message") or error.get("hint")).lower()
return error_type in {"auth", "authentication", "config"} and any(
token in message for token in ["keychain", "credential", "auth", "login"]
)
def build_create_svg_command(project: Path, data: dict[str, Any], cli: str, *, dry_run: bool) -> list[str]:
inputs = project_pages(project, data, prepared=True)
if not inputs:
@@ -1018,6 +1150,33 @@ def extract_stage_counts(stage: str, receipt: dict[str, Any], *, allow_waived: b
raise RunnerError(f"cannot extract counts for stage: {stage}")
def preflight_strategist_contract_summary(receipt: dict[str, Any]) -> dict[str, Any]:
issues = nested(receipt, ["summary", "plan", "issues"])
if not isinstance(issues, list):
issues = []
matched: list[dict[str, Any]] = []
for item in issues:
if not isinstance(item, dict):
continue
code = text_from_any(item.get("code"))
if code in STRATEGIST_CONTRACT_CODES:
matched.append(
{
"level": text_from_any(item.get("level")) or "error",
"code": code,
"page": item.get("page"),
}
)
return {
"status": "passed" if not any(item["level"] == "error" for item in matched) else "failed",
"issue_count": len(matched),
"error_count": sum(1 for item in matched if item["level"] == "error"),
"warning_count": sum(1 for item in matched if item["level"] == "warning"),
"issue_codes": sorted({item["code"] for item in matched}),
"issues": matched,
}
def dry_run_waiver_allowed(
data: dict[str, Any], args: argparse.Namespace | None = None, *, project: Path | None = None
) -> bool:
@@ -1061,6 +1220,294 @@ def component_report_summary(project: Path, data: dict[str, Any], args: argparse
raise RunnerError("quality gate requires receipts/emitted_components.json or active legacy component waiver")
def component_bbox_area(component: dict[str, Any]) -> float:
bbox = component.get("bbox")
if not isinstance(bbox, dict):
return 0.0
width = bbox.get("width")
height = bbox.get("height")
if not isinstance(width, (int, float)) or isinstance(width, bool):
return 0.0
if not isinstance(height, (int, float)) or isinstance(height, bool):
return 0.0
return max(0.0, float(width)) * max(0.0, float(height))
def component_tokens(component: dict[str, Any], key: str) -> set[str]:
values = component.get(key)
if not isinstance(values, list):
return set()
return {text_from_any(value) for value in values if text_from_any(value)}
def bbox_area(box: dict[str, float]) -> float:
return max(0.0, float(box.get("width", 0.0))) * max(0.0, float(box.get("height", 0.0)))
def bbox_right(box: dict[str, float]) -> float:
return float(box.get("x", 0.0)) + float(box.get("width", 0.0))
def bbox_bottom(box: dict[str, float]) -> float:
return float(box.get("y", 0.0)) + float(box.get("height", 0.0))
def bbox_overlap_area(left: dict[str, float], right: dict[str, float]) -> float:
x1 = max(float(left.get("x", 0.0)), float(right.get("x", 0.0)))
y1 = max(float(left.get("y", 0.0)), float(right.get("y", 0.0)))
x2 = min(bbox_right(left), bbox_right(right))
y2 = min(bbox_bottom(left), bbox_bottom(right))
return max(0.0, x2 - x1) * max(0.0, y2 - y1)
def component_bbox(component: dict[str, Any]) -> dict[str, float] | None:
raw = component.get("bbox")
if not isinstance(raw, dict):
return None
out: dict[str, float] = {}
for key in ("x", "y", "width", "height"):
value = raw.get(key)
if not isinstance(value, (int, float)) or isinstance(value, bool):
return None
out[key] = float(value)
if out["width"] <= 0 or out["height"] <= 0:
return None
return out
def source_element_tokens(element: ET.Element) -> set[str]:
name = svg_preflight.local_name(element.tag).lower()
identifier = svg_preflight.element_identifier_text(element).lower()
tokens: set[str] = set()
if name == "foreignobject":
tokens.add("typography")
if name in {"rect", "circle", "ellipse", "path", "line", "polygon", "polyline"}:
tokens.add("geometric_shape")
if name in {"path", "line", "polyline"} or re.search(r"(route|journey|flow|loop|path|spine|ribbon|connector)", identifier):
tokens.update({"path", "connector"})
if re.search(r"(chart|metric|kpi|bar|plot|donut|bubble|sankey|variance|insight-strip)", identifier):
tokens.add("micro_chart")
if re.search(r"(dashboard|grid|metric|kpi|panel|card)", identifier):
tokens.add("dashboard")
if re.search(r"(label|legend|caption|note|callout|annotation|insight)", identifier):
tokens.add("annotation")
if re.search(r"(icon|node|satellite|glyph|hub)", identifier):
tokens.add("icon")
if re.search(r"(spotlight|hotspot|highlight|focus)", identifier):
tokens.add("spotlight")
return tokens
def collect_source_page_elements(project: Path, data: dict[str, Any]) -> dict[int, list[dict[str, Any]]]:
elements_by_page: dict[int, list[dict[str, Any]]] = {}
manifest_pages = data.get("pages")
if isinstance(manifest_pages, list) and manifest_pages:
page_paths: list[tuple[int, Path]] = []
for index, page in enumerate(manifest_pages, 1):
if not isinstance(page, dict):
continue
try:
page_number = int(page.get("page", index))
except (TypeError, ValueError):
page_number = index
raw_path = text_from_any(page.get("prepared_svg"))
if raw_path:
page_paths.append((page_number, project_file(project, raw_path)))
if not page_paths:
page_paths = [(index, path) for index, path in enumerate(project_pages(project, data, prepared=True), 1)]
else:
page_paths = [(index, path) for index, path in enumerate(project_pages(project, data, prepared=True), 1)]
for page_number, path in page_paths:
try:
root = ET.parse(path).getroot()
except ET.ParseError:
continue
entries: list[dict[str, Any]] = []
for element in root.iter():
if svg_preflight.svg_role(element) not in {"shape", "image"}:
continue
bbox = svg_preflight.bbox_for_element(element)
if bbox is None or bbox_area(bbox) <= 0:
continue
entries.append(
{
"id": svg_preflight.element_identifier_text(element),
"tag": svg_preflight.local_name(element.tag).lower(),
"bbox": bbox,
"tokens": source_element_tokens(element),
}
)
elements_by_page[page_number] = entries
return elements_by_page
def source_tokens_for_component(component: dict[str, Any], source_elements: list[dict[str, Any]]) -> tuple[set[str], dict[str, float]]:
box = component_bbox(component)
if box is None:
return set(), {}
tokens: set[str] = set()
token_area: dict[str, float] = {}
for entry in source_elements:
source_bbox = entry.get("bbox")
if not isinstance(source_bbox, dict):
continue
overlap = bbox_overlap_area(box, source_bbox)
if overlap <= 4:
continue
for token in entry.get("tokens", set()):
if not token:
continue
tokens.add(token)
token_area[token] = token_area.get(token, 0.0) + overlap
return tokens, token_area
def source_supports_visual_evidence(component: dict[str, Any], source_elements: list[dict[str, Any]], evidence: str) -> bool:
tokens, token_area = source_tokens_for_component(component, source_elements)
area = component_bbox_area(component)
if evidence in {"chart_geometry", "metric_hierarchy", "dashboard_grid"}:
return bool({"micro_chart", "dashboard"} & tokens) and max(token_area.get("micro_chart", 0.0), token_area.get("dashboard", 0.0)) >= min(area, 5_000)
if evidence in {"numbered_path", "hero_route", "connector_flow", "flow_lanes", "phase_spine", "closing_ribbon"}:
return bool({"path", "connector"} & tokens) and max(token_area.get("path", 0.0), token_area.get("connector", 0.0)) >= min(area, 5_000)
if evidence in {"full_page_archetype", "hero_signal", "decision_matrix", "contrast_panels"}:
return bool({"geometric_shape", "path", "connector"} & tokens) and max(token_area.get("geometric_shape", 0.0), token_area.get("path", 0.0), token_area.get("connector", 0.0)) >= min(area, 20_000)
if evidence == "sector_field":
return "geometric_shape" in tokens and token_area.get("geometric_shape", 0.0) >= min(area, 10_000)
if evidence == "hub_spoke":
return bool({"connector", "icon", "geometric_shape"} & tokens) and sum(token_area.get(token, 0.0) for token in {"connector", "icon", "geometric_shape"}) >= min(area, 10_000)
if evidence == "insight_strip":
return bool({"micro_chart", "geometric_shape"} & tokens) and max(token_area.get("micro_chart", 0.0), token_area.get("geometric_shape", 0.0)) >= min(area, 5_000)
if evidence in {"title_field", "section_index", "semantic_labels", "action_cards"}:
return "typography" in tokens and token_area.get("typography", 0.0) >= 1_000
if evidence == "spotlight":
return bool({"annotation", "geometric_shape", "spotlight"} & tokens) and sum(token_area.get(token, 0.0) for token in {"annotation", "geometric_shape", "spotlight"}) >= min(area, 5_000)
return evidence in tokens
def component_supports_visual_evidence(component: dict[str, Any], evidence: str) -> bool:
primitives = component_tokens(component, "primitives")
effects = component_tokens(component, "effects")
area = component_bbox_area(component)
if evidence in {"chart_geometry", "metric_hierarchy", "dashboard_grid"}:
return bool({"micro_chart", "dashboard"} & primitives) and area >= 5_000
if evidence in {"numbered_path", "hero_route", "connector_flow", "flow_lanes", "phase_spine", "closing_ribbon"}:
return bool({"path", "connector"} & primitives) and area >= 5_000
if evidence in {"full_page_archetype", "hero_signal", "decision_matrix", "contrast_panels"}:
return bool({"geometric_shape", "path", "connector"} & primitives) and area >= 20_000
if evidence == "sector_field":
return "geometric_shape" in primitives and area >= 10_000
if evidence == "hub_spoke":
return bool({"connector", "icon", "geometric_shape"} & primitives) and area >= 10_000
if evidence == "insight_strip":
return bool({"micro_chart", "geometric_shape"} & primitives) and area >= 5_000
if evidence in {"title_field", "section_index", "semantic_labels", "action_cards"}:
return "typography" in primitives and area >= 1_000
if evidence == "spotlight":
return bool({"annotation", "geometric_shape"} & primitives) and area >= 5_000
return evidence in primitives or evidence in effects
def component_proves_visual_evidence(component: dict[str, Any], source_elements: list[dict[str, Any]], evidence: str) -> bool:
return component_supports_visual_evidence(component, evidence) and source_supports_visual_evidence(component, source_elements, evidence)
def collect_component_page_evidence(report: dict[str, Any], source_elements_by_page: dict[int, list[dict[str, Any]]] | None = None) -> dict[int, list[dict[str, Any]]]:
components_by_page: dict[int, list[dict[str, Any]]] = {}
pages = report.get("pages")
if not isinstance(pages, list):
return components_by_page
for page in pages:
if not isinstance(page, dict):
continue
try:
page_number = int(page.get("page"))
except (TypeError, ValueError):
continue
components_by_page.setdefault(page_number, [])
components = page.get("components")
if not isinstance(components, list):
continue
for component in components:
if not isinstance(component, dict):
continue
components_by_page[page_number].append(component)
return components_by_page
def visual_contract_required_for_plan(plan: dict[str, Any]) -> bool:
return text_from_any(plan.get("schema_version")).startswith("svglide-strategist-contract/")
def visual_design_contract_summary(project: Path, data: dict[str, Any], args: argparse.Namespace | None = None) -> dict[str, Any]:
plan = read_json(plan_file(project, data), {})
if not isinstance(plan, dict):
return {"status": "not_configured", "page_count": 0, "error_count": 0, "warning_count": 0}
slides = plan.get("slides")
if not isinstance(slides, list):
slides = []
required_for_plan = visual_contract_required_for_plan(plan)
configured_count = 0
issues: list[dict[str, Any]] = []
page_requirements: dict[int, list[str]] = {}
required_fields = ("visual_thesis", "composition_archetype", "primary_motif", "required_visual_evidence")
for index, slide in enumerate(slides, 1):
if not isinstance(slide, dict):
continue
page = slide.get("page", index)
try:
page_number = int(page)
except (TypeError, ValueError):
page_number = index
contract = slide.get("visual_design_contract")
if not isinstance(contract, dict):
if required_for_plan:
issues.append({"page": page_number, "code": "missing_visual_design_contract"})
continue
configured_count += 1
for field in required_fields:
value = contract.get(field)
if value in (None, "", []):
issues.append({"page": page_number, "code": f"missing_{field}"})
raw_evidence = contract.get("required_visual_evidence")
evidence = [text_from_any(item) for item in raw_evidence] if isinstance(raw_evidence, list) else []
evidence = [item for item in evidence if item]
page_requirements[page_number] = evidence
if configured_count == 0 and not issues:
return {"status": "not_configured", "page_count": 0, "error_count": 0, "warning_count": 0}
report_path = project / "receipts" / "emitted_components.json"
if not report_path.exists():
return {
"status": "failed",
"page_count": configured_count,
"error_count": 1,
"warning_count": 0,
"issues": [{"code": "missing_component_report"}],
"path": rel_to_project(project, report_path),
}
report = read_json(report_path, {})
if not isinstance(report, dict):
raise RunnerError("emitted_components.json must contain an object")
source_elements_by_page = collect_source_page_elements(project, data)
components_by_page = collect_component_page_evidence(report, source_elements_by_page)
for page_number, required_evidence in sorted(page_requirements.items()):
components = components_by_page.get(page_number, [])
source_elements = source_elements_by_page.get(page_number, [])
for evidence in required_evidence:
if not any(component_proves_visual_evidence(component, source_elements, evidence) for component in components):
issues.append({"page": page_number, "code": "missing_visual_evidence", "evidence": evidence})
return {
"status": "passed" if not issues else "failed",
"page_count": configured_count,
"error_count": len(issues),
"warning_count": 0,
"issues": issues,
"path": rel_to_project(project, report_path),
}
def asset_id_from(value: Any) -> str:
if isinstance(value, str):
return value.strip()
@@ -1081,12 +1528,12 @@ def add_asset_ids(output: set[str], value: Any) -> None:
output.add(asset_id)
def collect_ppt_master_asset_ids(project: Path, data: dict[str, Any]) -> set[str]:
def collect_design_pattern_ids(project: Path, data: dict[str, Any]) -> set[str]:
plan = read_json(plan_file(project, data), {})
if not isinstance(plan, dict):
return set()
asset_ids: set[str] = set()
selection = plan.get("ppt_master_asset_selection")
selection = plan.get("design_pattern_selection")
if isinstance(selection, dict):
add_asset_ids(asset_ids, selection.get("selected_assets"))
slides = plan.get("slides")
@@ -1095,20 +1542,20 @@ def collect_ppt_master_asset_ids(project: Path, data: dict[str, Any]) -> set[str
for slide in slides:
if not isinstance(slide, dict):
continue
add_asset_ids(asset_ids, slide.get("ppt_master_reference_assets"))
add_asset_ids(asset_ids, slide.get("design_reference_assets"))
visual_plan = slide.get("visual_plan")
if isinstance(visual_plan, dict):
add_asset_ids(asset_ids, visual_plan.get("ppt_master_reference_assets"))
add_asset_ids(asset_ids, visual_plan.get("design_reference_assets"))
svg_files = plan.get("svg_files")
if not isinstance(svg_files, list):
svg_files = []
for slide in svg_files:
if isinstance(slide, dict):
add_asset_ids(asset_ids, slide.get("ppt_master_reference_assets"))
add_asset_ids(asset_ids, slide.get("design_reference_assets"))
return asset_ids
def collect_used_ppt_master_asset_ids(report: dict[str, Any]) -> set[str]:
def collect_used_design_pattern_ids(report: dict[str, Any]) -> set[str]:
used: set[str] = set()
usages = report.get("page_usages")
if isinstance(usages, list):
@@ -1126,7 +1573,7 @@ def collect_used_ppt_master_asset_ids(report: dict[str, Any]) -> set[str]:
return used
def active_ppt_master_asset_waivers(report: dict[str, Any]) -> tuple[set[str], list[dict[str, Any]]]:
def active_design_pattern_waivers(report: dict[str, Any]) -> tuple[set[str], list[dict[str, Any]]]:
waived: set[str] = set()
waivers: list[dict[str, Any]] = []
raw_waivers = report.get("waived_assets")
@@ -1139,16 +1586,23 @@ def active_ppt_master_asset_waivers(report: dict[str, Any]) -> tuple[set[str], l
validation = validate_legacy_waiver(item)
if asset_id and validation.get("active"):
waived.add(asset_id)
waivers.append({"asset_id": asset_id, "type": "ppt_master_asset_usage", **validation})
waivers.append({"asset_id": asset_id, "type": "design_pattern_usage", **validation})
return waived, waivers
def ppt_master_asset_usage_summary(project: Path, data: dict[str, Any], args: argparse.Namespace | None = None) -> dict[str, Any]:
expected = collect_ppt_master_asset_ids(project, data)
def strict_design_pattern_usage_required(
data: dict[str, Any], args: argparse.Namespace | None = None, project: Path | None = None
) -> bool:
profile = resolved_validation_profile(data, args, project=project)
return is_production_lane(data, args, project=project) or profile == "golden"
def design_pattern_usage_summary(project: Path, data: dict[str, Any], args: argparse.Namespace | None = None) -> dict[str, Any]:
expected = collect_design_pattern_ids(project, data)
if not expected:
return {"status": "not_configured", "selected_count": 0, "error_count": 0, "warning_count": 0}
report_path = project / "receipts" / "ppt-master-asset-usage.json"
report_path = project / "receipts" / "design-pattern-usage.json"
if not report_path.exists():
return {
"status": "missing",
@@ -1161,9 +1615,11 @@ def ppt_master_asset_usage_summary(project: Path, data: dict[str, Any], args: ar
report = read_json(report_path)
if not isinstance(report, dict):
raise RunnerError("ppt-master-asset-usage.json must contain an object")
if report.get("schema_version") not in {"", None, PPT_MASTER_ASSET_USAGE_SCHEMA}:
raise RunnerError("ppt-master-asset-usage.json has unsupported schema_version")
raise RunnerError("design-pattern-usage.json must contain an object")
if strict_design_pattern_usage_required(data, args, project=project) and report.get("schema_version") != DESIGN_PATTERN_USAGE_SCHEMA:
raise RunnerError(f"design-pattern-usage.json schema_version must be {DESIGN_PATTERN_USAGE_SCHEMA}")
if report.get("schema_version") not in {"", None, DESIGN_PATTERN_USAGE_SCHEMA}:
raise RunnerError("design-pattern-usage.json has unsupported schema_version")
status = text_from_any(report.get("status") or "passed")
if status not in {"passed", "verified", "ok"}:
return {
@@ -1174,11 +1630,11 @@ def ppt_master_asset_usage_summary(project: Path, data: dict[str, Any], args: ar
"path": rel_to_project(project, report_path),
}
used = collect_used_ppt_master_asset_ids(report)
waived, waivers = active_ppt_master_asset_waivers(report)
used = collect_used_design_pattern_ids(report)
waived, waivers = active_design_pattern_waivers(report)
missing = sorted(expected - used - waived)
error_count = required_int(report.get("error_count", 0), "ppt_master_asset_usage.error_count")
warning_count = required_int(report.get("warning_count", 0), "ppt_master_asset_usage.warning_count")
error_count = required_int(report.get("error_count", 0), "design_pattern_usage.error_count")
warning_count = required_int(report.get("warning_count", 0), "design_pattern_usage.warning_count")
if missing:
error_count += 1
if waivers and not dry_run_waiver_allowed(data, args, project=project):
@@ -1248,9 +1704,11 @@ def run_quality_gate(project: Path, data: dict[str, Any], args: argparse.Namespa
data, args, project=project
)
preflight_counts = extract_stage_counts("preflight", preflight)
strategist_contract = preflight_strategist_contract_summary(preflight)
preview_counts = extract_stage_counts("preview_lint", preview_lint, allow_waived=preview_waiver_allowed)
component_counts = component_report_summary(project, data, args)
ppt_master_usage = ppt_master_asset_usage_summary(project, data, args)
visual_contract = visual_design_contract_summary(project, data, args)
design_usage = design_pattern_usage_summary(project, data, args)
raster_counts = raster_summary(project, data)
allowlist = allowlist_summary(project, data)
profile = quality_validation_profile(resolved_validation_profile(data, args, project=project))
@@ -1262,9 +1720,9 @@ def run_quality_gate(project: Path, data: dict[str, Any], args: argparse.Namespa
waivers.append({"type": "preview_lint", **preview_lint.get("waiver", {})})
if component_counts["status"] == "waived" and isinstance(component_counts.get("waiver"), dict):
waivers.append(component_counts["waiver"])
ppt_master_waivers = ppt_master_usage.get("waivers")
if isinstance(ppt_master_waivers, list):
for waiver in ppt_master_waivers:
design_waivers = design_usage.get("waivers")
if isinstance(design_waivers, list):
for waiver in design_waivers:
if isinstance(waiver, dict):
waivers.append(waiver)
@@ -1275,8 +1733,10 @@ def run_quality_gate(project: Path, data: dict[str, Any], args: argparse.Namespa
failures.append("preview_lint.error_count must be 0")
if component_counts["error_count"] != 0:
failures.append("component_report.error_count must be 0")
if ppt_master_usage["error_count"] != 0:
failures.append("ppt-master asset usage must be proven")
if visual_contract["error_count"] != 0:
failures.append("SVGlide visual design contract must be proven")
if design_usage["error_count"] != 0:
failures.append("SVGlide design pattern usage must be proven")
if raster_counts["error_count"] != 0:
failures.append("raster.error_count must be 0")
preview_score = preview_counts.get("visual_score")
@@ -1293,7 +1753,8 @@ def run_quality_gate(project: Path, data: dict[str, Any], args: argparse.Namespa
preflight_counts["warning_count"]
+ preview_counts["warning_count"]
+ component_counts["warning_count"]
+ ppt_master_usage["warning_count"]
+ visual_contract["warning_count"]
+ design_usage["warning_count"]
+ raster_counts["warning_count"]
)
if production or profile == "golden":
@@ -1312,9 +1773,11 @@ def run_quality_gate(project: Path, data: dict[str, Any], args: argparse.Namespa
"schema_version": QUALITY_GATE_SCHEMA,
"status": status,
"preflight": preflight_counts,
"strategist_contract": strategist_contract,
"preview_lint": preview_counts,
"component_report": component_counts,
"ppt_master_asset_usage": ppt_master_usage,
"visual_design_contract": visual_contract,
"design_pattern_usage": design_usage,
"raster": raster_counts,
"allowlist": allowlist,
"validation_profile": profile,
@@ -1329,12 +1792,21 @@ def run_dry_run(project: Path, data: dict[str, Any], cli: str, args: argparse.Na
if args is None:
args = default_runner_args()
args.cli = cli
ensure_project_dirs(project)
require_latest_quality_gate(project, data, args)
args = build_create_svg_command(project, data, cli, dry_run=True)
result = run_command(args, cwd=repo_root())
log_path(project, "dry_run").write_text(result.stdout + result.stderr, encoding="utf-8")
parsed = parse_json_output(result.stdout)
parsed = parse_json_output(result.stdout) or parse_json_output(result.stderr)
if result.returncode != 0:
if is_cli_auth_config_failure(result, parsed):
return {
"status": "blocked_by_auth",
"command": args,
"log": str(log_path(project, "dry_run").relative_to(project)),
"reason": "lark-cli auth/config is unavailable in this local execution environment",
"summary": parsed,
}
raise RunnerError(f"dry-run failed: {result.returncode}; see {log_path(project, 'dry_run')}")
return {
"status": "passed",
@@ -2177,7 +2649,7 @@ def execute_stage(stage: str, project: Path, data: dict[str, Any], args: argpars
started = now_ms()
ensure_project_dirs(project)
if stage == "generate":
body = run_manifest_command(project, data, "generate")
body = run_generate(project, data)
return write_stage_receipt(project, data, stage, started, body, prepared_digest=False, args=args)
if stage == "prepare":
body = run_prepare(project, data)

View File

@@ -6,15 +6,21 @@ import contextlib
import io
import json
import shutil
import sys
import tempfile
import unittest
from argparse import Namespace
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
import svg_preflight
import svglide_project_runner as runner
SVG = """<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" width="960" height="540" viewBox="0 0 960 540"><rect slide:role="shape" x="0" y="0" width="960" height="540" fill="#fff" /></svg>"""
CHART_SVG = """<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" width="960" height="540" viewBox="0 0 960 540"><rect id="background" slide:role="shape" x="0" y="0" width="960" height="540" fill="#fff" /><rect id="chart-plot-backplane" slide:role="shape" x="70" y="110" width="780" height="320" fill="#eef6ff" /><rect id="chart-bar-1" slide:role="shape" x="120" y="260" width="92" height="130" fill="#4A90E2" /><rect id="chart-bar-2" slide:role="shape" x="250" y="220" width="92" height="170" fill="#4A90E2" /><rect id="chart-insight-strip" slide:role="shape" x="610" y="145" width="190" height="72" fill="#E91E63" /><foreignObject id="chart-insight-label" slide:role="shape" slide:shape-type="text" x="628" y="160" width="150" height="34"><div xmlns="http://www.w3.org/1999/xhtml" style="font-size:16px">核心洞察</div></foreignObject></svg>"""
def write_json(path: Path, data: object) -> None:
@@ -68,6 +74,41 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
body = runner.builtin_prepare(project, data)
runner.write_stage_receipt(project, data, "prepare", runner.now_ms(), body, prepared_digest=False, args=self.args(project))
def test_builtin_generate_composes_plan_pages_and_receipts(self) -> None:
root = Path(tempfile.mkdtemp(dir=runner.repo_root()))
self.addCleanup(lambda: shutil.rmtree(root, ignore_errors=True))
project = root / "runtime-generate"
project.mkdir(parents=True)
write_json(
project / "project_manifest.json",
{
"deck_id": "runtime-generate",
"title": "Runtime Generate",
"plan": "slide_plan.json",
"brief": "Create an operations review with KPI dashboard and closing next steps.",
"page_descriptions": [
"Cover: operating thesis and review scope",
"KPI dashboard: four health metrics with micro trends",
"Closing: next steps and decision request",
],
},
)
data = runner.manifest(project)
receipt = runner.execute_stage("generate", project, data, self.args(project))
self.assertEqual("passed", receipt["status"])
self.assertEqual("builtin:svglide_strategist_runtime", receipt["generator"])
self.assertTrue((project / "slide_plan.json").exists())
self.assertTrue((project / "pages" / "page-001.svg").exists())
self.assertTrue((project / "receipts" / "emitted_components.json").exists())
self.assertTrue((project / "receipts" / "design-pattern-usage.json").exists())
plan = runner.read_json(project / "slide_plan.json", {})
self.assertEqual(3, plan["page_count"])
self.assertIn("design_pattern_selection", plan)
self.assertEqual("briefing", plan["mode"])
self.assertEqual({"error_count": 0, "warning_count": 0}, svg_preflight.lint_plan(plan)["summary"])
def write_gate_inputs(
self,
project: Path,
@@ -77,6 +118,7 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
preview_warning_count: int = 0,
visual_score: int = 100,
validation_profile: str | None = None,
preflight_plan_issues: list[dict[str, object]] | None = None,
) -> None:
profile = validation_profile or runner.quality_validation_profile(
runner.resolved_validation_profile(data, args, project=project)
@@ -87,7 +129,16 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
data,
"preflight",
runner.now_ms(),
{"status": "passed", "summary": {"summary": {"error_count": 0, "warning_count": 0}}},
{
"status": "passed",
"summary": {
"summary": {
"error_count": sum(1 for item in preflight_plan_issues or [] if item.get("level") == "error"),
"warning_count": sum(1 for item in preflight_plan_issues or [] if item.get("level") == "warning"),
},
"plan": {"issues": preflight_plan_issues or [], "summary": {"error_count": 0, "warning_count": 0}},
},
},
args=args,
)
runner.write_stage_receipt(
@@ -150,6 +201,12 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
cli.chmod(cli.stat().st_mode | 0o111)
return cli
def write_fake_cli_body(self, project: Path, body: str) -> Path:
cli = project / "fake-lark-cli"
cli.write_text(body, encoding="utf-8")
cli.chmod(cli.stat().st_mode | 0o111)
return cli
def test_builtin_prepare_copies_source_svg_to_prepared(self) -> None:
project = self.make_project()
data = runner.manifest(project)
@@ -470,6 +527,30 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
self.assertEqual(body["visual_score_threshold"], 75)
self.assertEqual(body["visual_score_mode"], "advisory")
def test_quality_gate_records_preflight_strategist_contract_issues(self) -> None:
project = self.make_project()
data = runner.manifest(project)
args = self.args(project)
self.write_gate_inputs(
project,
data,
args,
preflight_plan_issues=[
{
"level": "warning",
"code": "plan_missing_page_rhythm",
"message": "multi-page SVGlide plans must declare page_rhythm",
}
],
)
self.write_component_report(project)
body = runner.run_quality_gate(project, data, args)
self.assertEqual(body["status"], "passed")
self.assertEqual(body["strategist_contract"]["warning_count"], 1)
self.assertIn("plan_missing_page_rhythm", body["strategist_contract"]["issue_codes"])
def test_quality_gate_rejects_production_score_below_threshold(self) -> None:
project = self.make_project()
data = runner.manifest(project)
@@ -494,7 +575,7 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
with self.assertRaisesRegex(runner.RunnerError, "golden warning_budget must be 0"):
runner.run_quality_gate(project, data, args)
def test_quality_gate_rejects_unproven_ppt_master_asset_selection(self) -> None:
def test_quality_gate_rejects_unproven_design_pattern_selection(self) -> None:
project = self.make_project()
data = runner.manifest(project)
write_json(
@@ -502,7 +583,7 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
{
"output_mode": "svglide-svg",
"title": "Demo Deck",
"ppt_master_asset_selection": {
"design_pattern_selection": {
"selected_assets": [
{"id": "chart.timeline", "kind": "chart_template"},
],
@@ -513,10 +594,10 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
self.write_gate_inputs(project, data, args)
self.write_component_report(project)
with self.assertRaisesRegex(runner.RunnerError, "ppt-master asset usage"):
with self.assertRaisesRegex(runner.RunnerError, "SVGlide design pattern usage"):
runner.run_quality_gate(project, data, args)
def test_quality_gate_accepts_proven_ppt_master_asset_selection(self) -> None:
def test_quality_gate_accepts_proven_design_pattern_selection(self) -> None:
project = self.make_project()
data = runner.manifest(project)
write_json(
@@ -524,7 +605,7 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
{
"output_mode": "svglide-svg",
"title": "Demo Deck",
"ppt_master_asset_selection": {
"design_pattern_selection": {
"selected_assets": [
{"id": "chart.timeline", "kind": "chart_template"},
],
@@ -535,9 +616,9 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
self.write_gate_inputs(project, data, args)
self.write_component_report(project)
write_json(
project / "receipts" / "ppt-master-asset-usage.json",
project / "receipts" / "design-pattern-usage.json",
{
"schema_version": "svglide-ppt-master-asset-usage/v1",
"schema_version": "svglide-design-pattern-usage/v1",
"status": "passed",
"used_asset_ids": ["chart.timeline"],
"page_usages": [
@@ -556,7 +637,300 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
body = runner.run_quality_gate(project, data, args)
self.assertEqual(body["status"], "passed")
self.assertEqual(body["ppt_master_asset_usage"]["used_count"], 1)
self.assertEqual(body["design_pattern_usage"]["used_count"], 1)
def test_quality_gate_rejects_unproven_visual_design_contract(self) -> None:
project = self.make_project()
data = runner.manifest(project)
write_json(
project / "slide_plan.json",
{
"schema_version": "svglide-strategist-contract/v1",
"output_mode": "svglide-svg",
"slides": [
{
"page": 1,
"visual_design_contract": {
"visual_thesis": "Show the capital flow",
"composition_archetype": "data_stage",
"primary_motif": "takeaway_chart",
"required_visual_evidence": ["chart_geometry", "insight_strip"],
},
}
],
},
)
args = self.args(project)
self.write_gate_inputs(project, data, args)
self.write_structured_component_report(project)
with self.assertRaisesRegex(runner.RunnerError, "visual design contract"):
runner.run_quality_gate(project, data, args)
def test_quality_gate_accepts_proven_visual_design_contract(self) -> None:
project = self.make_project()
(project / "pages" / "page-001.svg").write_text(CHART_SVG, encoding="utf-8")
data = runner.manifest(project)
write_json(
project / "slide_plan.json",
{
"schema_version": "svglide-strategist-contract/v1",
"output_mode": "svglide-svg",
"slides": [
{
"page": 1,
"visual_design_contract": {
"visual_thesis": "Show the capital flow",
"composition_archetype": "data_stage",
"primary_motif": "takeaway_chart",
"required_visual_evidence": ["chart_geometry", "insight_strip"],
},
}
],
},
)
args = self.args(project)
self.write_gate_inputs(project, data, args)
write_json(
project / "receipts" / "emitted_components.json",
{
"schema_version": "svglide-component-report/v1",
"status": "passed",
"pages": [
{
"page": 1,
"components": [
{
"id": "contract-bar",
"renderer_id": "contract.bar",
"bbox": {"x": 80, "y": 120, "width": 760, "height": 300},
"primitives": ["typography", "micro_chart"],
"effects": ["chart_geometry", "insight_strip"],
}
],
}
],
"summary": {"error_count": 0, "warning_count": 0},
},
)
body = runner.run_quality_gate(project, data, args)
self.assertEqual(body["status"], "passed")
self.assertEqual(body["visual_design_contract"]["status"], "passed")
self.assertEqual(body["visual_design_contract"]["page_count"], 1)
def test_quality_gate_rejects_visual_contract_receipt_not_backed_by_prepared_svg(self) -> None:
project = self.make_project()
data = runner.manifest(project)
write_json(
project / "slide_plan.json",
{
"schema_version": "svglide-strategist-contract/v1",
"output_mode": "svglide-svg",
"slides": [
{
"page": 1,
"visual_design_contract": {
"visual_thesis": "Show the capital flow",
"composition_archetype": "data_stage",
"primary_motif": "takeaway_chart",
"required_visual_evidence": ["chart_geometry", "insight_strip"],
},
}
],
},
)
args = self.args(project)
self.write_gate_inputs(project, data, args)
write_json(
project / "receipts" / "emitted_components.json",
{
"schema_version": "svglide-component-report/v1",
"status": "passed",
"pages": [
{
"page": 1,
"components": [
{
"id": "contract-bar",
"renderer_id": "contract.bar",
"bbox": {"x": 80, "y": 120, "width": 760, "height": 300},
"primitives": ["typography", "micro_chart"],
"effects": ["chart_geometry", "insight_strip"],
}
],
}
],
"summary": {"error_count": 0, "warning_count": 0},
},
)
with self.assertRaisesRegex(runner.RunnerError, "visual design contract"):
runner.run_quality_gate(project, data, args)
def test_quality_gate_rejects_visual_contract_primitive_only_receipt(self) -> None:
project = self.make_project()
data = runner.manifest(project)
write_json(
project / "slide_plan.json",
{
"schema_version": "svglide-strategist-contract/v1",
"output_mode": "svglide-svg",
"slides": [
{
"page": 1,
"visual_design_contract": {
"visual_thesis": "Show typography",
"composition_archetype": "text_stage",
"primary_motif": "title_field",
"required_visual_evidence": ["typography"],
},
}
],
},
)
args = self.args(project)
self.write_gate_inputs(project, data, args)
write_json(
project / "receipts" / "emitted_components.json",
{
"schema_version": "svglide-component-report/v1",
"status": "passed",
"pages": [
{
"page": 1,
"components": [
{
"id": "fake-typography",
"renderer_id": "contract.fake",
"bbox": {"x": 80, "y": 120, "width": 760, "height": 300},
"primitives": ["typography"],
"effects": [],
}
],
}
],
"summary": {"error_count": 0, "warning_count": 0},
},
)
with self.assertRaisesRegex(runner.RunnerError, "visual design contract"):
runner.run_quality_gate(project, data, args)
def test_quality_gate_binds_source_proof_by_manifest_page_number(self) -> None:
project = self.make_project()
write_json(
project / "project_manifest.json",
{
"deck_id": "demo",
"title": "Demo Deck",
"plan": "slide_plan.json",
"pages": [
{"page": 2, "source_svg": "pages/page-002.svg", "prepared_svg": "prepared/page-002.svg"},
{"page": 1, "source_svg": "pages/page-001.svg", "prepared_svg": "prepared/page-001.svg"},
],
"stage_commands": {"prepare": "builtin:copy_and_normalize_svg"},
},
)
(project / "pages" / "page-001.svg").write_text(SVG, encoding="utf-8")
(project / "pages" / "page-002.svg").write_text(CHART_SVG, encoding="utf-8")
data = runner.manifest(project)
write_json(
project / "slide_plan.json",
{
"schema_version": "svglide-strategist-contract/v1",
"output_mode": "svglide-svg",
"slides": [
{
"page": 2,
"visual_design_contract": {
"visual_thesis": "Show the capital flow",
"composition_archetype": "data_stage",
"primary_motif": "takeaway_chart",
"required_visual_evidence": ["chart_geometry"],
},
}
],
},
)
args = self.args(project)
self.write_gate_inputs(project, data, args)
write_json(
project / "receipts" / "emitted_components.json",
{
"schema_version": "svglide-component-report/v1",
"status": "passed",
"pages": [
{
"page": 2,
"components": [
{
"id": "contract-bar",
"renderer_id": "contract.bar",
"bbox": {"x": 80, "y": 120, "width": 760, "height": 300},
"primitives": ["typography", "micro_chart"],
"effects": ["chart_geometry"],
}
],
}
],
"summary": {"error_count": 0, "warning_count": 0},
},
)
body = runner.run_quality_gate(project, data, args)
self.assertEqual(body["visual_design_contract"]["status"], "passed")
def test_quality_gate_rejects_evidence_effect_without_matching_geometry(self) -> None:
project = self.make_project()
data = runner.manifest(project)
write_json(
project / "slide_plan.json",
{
"schema_version": "svglide-strategist-contract/v1",
"output_mode": "svglide-svg",
"slides": [
{
"page": 1,
"visual_design_contract": {
"visual_thesis": "Show a chart takeaway",
"composition_archetype": "data_stage",
"primary_motif": "takeaway_chart",
"required_visual_evidence": ["chart_geometry"],
},
}
],
},
)
args = self.args(project)
self.write_gate_inputs(project, data, args)
write_json(
project / "receipts" / "emitted_components.json",
{
"schema_version": "svglide-component-report/v1",
"status": "passed",
"pages": [
{
"page": 1,
"components": [
{
"id": "ordinary-text-box",
"renderer_id": "demo.text",
"bbox": {"x": 80, "y": 120, "width": 360, "height": 48},
"primitives": ["typography"],
"effects": ["chart_geometry"],
}
],
}
],
"summary": {"error_count": 0, "warning_count": 0},
},
)
with self.assertRaisesRegex(runner.RunnerError, "visual design contract"):
runner.run_quality_gate(project, data, args)
def test_quality_gate_rejects_selected_assets_not_used_by_receipt(self) -> None:
project = self.make_project()
@@ -566,7 +940,7 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
{
"output_mode": "svglide-svg",
"title": "Demo Deck",
"ppt_master_asset_selection": {
"design_pattern_selection": {
"selected_assets": [
{"id": "chart.bubble_chart", "kind": "chart_template"},
{"id": "chart.hub_spoke", "kind": "chart_template"},
@@ -578,9 +952,9 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
self.write_gate_inputs(project, data, args)
self.write_component_report(project)
write_json(
project / "receipts" / "ppt-master-asset-usage.json",
project / "receipts" / "design-pattern-usage.json",
{
"schema_version": "svglide-ppt-master-asset-usage/v1",
"schema_version": "svglide-design-pattern-usage/v1",
"status": "passed",
"page_usages": [
{
@@ -595,10 +969,10 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
},
)
with self.assertRaisesRegex(runner.RunnerError, "ppt-master asset usage"):
with self.assertRaisesRegex(runner.RunnerError, "SVGlide design pattern usage"):
runner.run_quality_gate(project, data, args)
def test_quality_gate_ignores_disabled_ppt_master_selected_assets(self) -> None:
def test_quality_gate_ignores_disabled_design_pattern_selected_assets(self) -> None:
project = self.make_project()
data = runner.manifest(project)
write_json(
@@ -606,7 +980,7 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
{
"output_mode": "svglide-svg",
"title": "Demo Deck",
"ppt_master_asset_selection": {
"design_pattern_selection": {
"selected_assets": [
{"id": "chart.bubble_chart", "kind": "chart_template", "enabled": True},
{"id": "chart.hub_spoke", "kind": "chart_template", "enabled": False},
@@ -618,9 +992,9 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
self.write_gate_inputs(project, data, args)
self.write_component_report(project)
write_json(
project / "receipts" / "ppt-master-asset-usage.json",
project / "receipts" / "design-pattern-usage.json",
{
"schema_version": "svglide-ppt-master-asset-usage/v1",
"schema_version": "svglide-design-pattern-usage/v1",
"status": "passed",
"page_usages": [
{
@@ -638,8 +1012,8 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
body = runner.run_quality_gate(project, data, args)
self.assertEqual(body["status"], "passed")
self.assertEqual(body["ppt_master_asset_usage"]["selected_count"], 1)
self.assertNotIn("chart.hub_spoke", body["ppt_master_asset_usage"]["missing_asset_ids"])
self.assertEqual(body["design_pattern_usage"]["selected_count"], 1)
self.assertNotIn("chart.hub_spoke", body["design_pattern_usage"]["missing_asset_ids"])
def test_quality_gate_rejects_shallow_component_report_for_golden(self) -> None:
project = self.make_project()
@@ -653,6 +1027,46 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
with self.assertRaisesRegex(runner.RunnerError, "component report schema_version"):
runner.run_quality_gate(project, data, args)
def test_quality_gate_rejects_legacy_design_usage_receipt_for_golden(self) -> None:
project = self.make_project()
data = runner.manifest(project)
data["validation_profile"] = {"profile": "golden"}
write_json(project / "project_manifest.json", data)
write_json(
project / "slide_plan.json",
{
"output_mode": "svglide-svg",
"title": "Demo Deck",
"design_pattern_selection": {
"selected_assets": [
{"id": "chart.timeline", "kind": "chart_template"},
],
},
},
)
args = self.args(project)
self.write_gate_inputs(project, data, args)
self.write_structured_component_report(project)
write_json(
project / "receipts" / "design-pattern-usage.json",
{
"status": "passed",
"page_usages": [
{
"page": 1,
"asset_id": "chart.timeline",
"component_ids": ["component.timeline.1"],
"source_trace": "derived_renderer_contract",
}
],
"error_count": 0,
"warning_count": 0,
},
)
with self.assertRaisesRegex(runner.RunnerError, "design-pattern-usage.json schema_version"):
runner.run_quality_gate(project, data, args)
def test_quality_gate_accepts_structured_component_report_for_golden(self) -> None:
project = self.make_project()
data = runner.manifest(project)
@@ -688,7 +1102,7 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
self.assertEqual(body["status"], "passed")
self.assertEqual(body["component_report"]["status"], "passed")
def test_quality_gate_rejects_ppt_master_usage_without_page_trace(self) -> None:
def test_quality_gate_rejects_design_usage_without_page_trace(self) -> None:
project = self.make_project()
data = runner.manifest(project)
write_json(
@@ -696,7 +1110,7 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
{
"output_mode": "svglide-svg",
"title": "Demo Deck",
"ppt_master_asset_selection": {
"design_pattern_selection": {
"selected_assets": [
{"id": "chart.timeline", "kind": "chart_template"},
],
@@ -707,9 +1121,9 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
self.write_gate_inputs(project, data, args)
self.write_component_report(project)
write_json(
project / "receipts" / "ppt-master-asset-usage.json",
project / "receipts" / "design-pattern-usage.json",
{
"schema_version": "svglide-ppt-master-asset-usage/v1",
"schema_version": "svglide-design-pattern-usage/v1",
"status": "passed",
"used_asset_ids": ["chart.timeline"],
"error_count": 0,
@@ -717,10 +1131,10 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
},
)
with self.assertRaisesRegex(runner.RunnerError, "ppt-master asset usage"):
with self.assertRaisesRegex(runner.RunnerError, "SVGlide design pattern usage"):
runner.run_quality_gate(project, data, args)
def test_quality_gate_rejects_unproven_slide_level_ppt_master_reference(self) -> None:
def test_quality_gate_rejects_unproven_slide_level_design_pattern_reference(self) -> None:
project = self.make_project()
data = runner.manifest(project)
write_json(
@@ -732,7 +1146,7 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
{
"page": 1,
"visual_plan": {
"ppt_master_reference_assets": [
"design_reference_assets": [
{"id": "layout.government_blue", "kind": "layout_template"},
],
},
@@ -744,7 +1158,7 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
self.write_gate_inputs(project, data, args)
self.write_component_report(project)
with self.assertRaisesRegex(runner.RunnerError, "ppt-master asset usage"):
with self.assertRaisesRegex(runner.RunnerError, "SVGlide design pattern usage"):
runner.run_quality_gate(project, data, args)
def test_dry_run_requires_latest_quality_gate(self) -> None:
@@ -754,6 +1168,54 @@ class SVGlideProjectRunnerTest(unittest.TestCase):
with self.assertRaisesRegex(runner.RunnerError, "quality_gate"):
runner.run_dry_run(project, data, "./lark-cli", self.args(project))
def test_dry_run_marks_keychain_config_failure_as_blocked_by_auth(self) -> None:
project = self.make_project()
data = runner.manifest(project)
cli = self.write_fake_cli_body(
project,
"""#!/bin/sh
if [ "$1" = "--version" ]; then
echo "lark-cli 1.2.3"
exit 0
fi
echo '{"ok":false,"error":{"type":"config","message":"keychain Get failed: keychain not initialized"}}'
exit 1
""",
)
args = self.args(project, cli=str(cli))
self.write_gate_inputs(project, data, args)
self.write_component_report(project)
quality = runner.run_quality_gate(project, data, args)
runner.write_stage_receipt(project, data, "quality_gate", runner.now_ms(), quality, args=args)
body = runner.run_dry_run(project, data, str(cli), args)
self.assertEqual(body["status"], "blocked_by_auth")
self.assertEqual(body["summary"]["error"]["type"], "config")
def test_dry_run_still_fails_for_create_svg_errors(self) -> None:
project = self.make_project()
data = runner.manifest(project)
cli = self.write_fake_cli_body(
project,
"""#!/bin/sh
if [ "$1" = "--version" ]; then
echo "lark-cli 1.2.3"
exit 0
fi
echo '{"ok":false,"error":{"type":"validation","message":"bad svg"}}'
exit 1
""",
)
args = self.args(project, cli=str(cli))
self.write_gate_inputs(project, data, args)
self.write_component_report(project)
quality = runner.run_quality_gate(project, data, args)
runner.write_stage_receipt(project, data, "quality_gate", runner.now_ms(), quality, args=args)
with self.assertRaisesRegex(runner.RunnerError, "dry-run failed"):
runner.run_dry_run(project, data, str(cli), args)
def test_ppe_proof_normalizes_raw_env_proof(self) -> None:
project = self.make_project()
data = runner.manifest(project)

View File

@@ -0,0 +1,675 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import argparse
import copy
import json
import re
import sys
from pathlib import Path
from typing import Any
CONTRACT_SCHEMA_VERSION = "svglide-strategist-contract/v1"
TOKEN_RE = re.compile(r"[a-z0-9]+|[\u4e00-\u9fff]+", re.IGNORECASE)
HEX_COLOR_RE = re.compile(r"#[0-9A-Fa-f]{6}")
CANVAS = {"width": 960, "height": 540, "viewBox": "0 0 960 540"}
SAFE_AREA = {"x": 48, "y": 40, "width": 864, "height": 460}
DEFAULT_GUARDRAILS = [
"renderer_id must change actual geometry, not only the name",
"visual_recipe must map to SVGlide-safe primitives present in the SVG source",
"main text and chart labels stay inside safe area",
"dense page uses a structured visual carrier, not a long bullet box",
"avoid XML-like card layout unless the page has real SVG-native visual structure",
]
PAGE_PROFILES: dict[str, dict[str, Any]] = {
"cover": {
"seed_id": "cover_hero_statement",
"page_type": "cover",
"composition_archetype": "full_bleed_field",
"required_visual_evidence": ["full_page_archetype", "hero_route", "title_field"],
"primary_motif": "hero_route",
"chart_type": "",
"page_rhythm": "anchor",
"renderer_id": "cover_hero_statement",
"main_visual_anchor": {"layout_box_role": "visual", "description": "large thesis text paired with one abstract SVG motif"},
"svg_effects": ["typography", "path"],
"asset_id": "chart.vertical_list",
"density_contract": "one thesis plus one visual motif",
},
"agenda": {
"seed_id": "agenda_numbered_path",
"page_type": "agenda",
"composition_archetype": "indexed_path",
"required_visual_evidence": ["numbered_path", "section_index", "semantic_labels"],
"primary_motif": "numbered_route",
"chart_type": "",
"page_rhythm": "breathing",
"renderer_id": "agenda_numbered_path",
"main_visual_anchor": {"layout_box_role": "timeline", "description": "numbered agenda route with compact section labels"},
"svg_effects": ["typography", "connector_flow", "path"],
"asset_id": "chart.agenda_list",
"density_contract": "agenda route >= 4 section labels",
},
"section": {
"seed_id": "section_divider_index",
"page_type": "section_divider",
"composition_archetype": "section_signal",
"required_visual_evidence": ["section_index", "hero_signal", "full_page_archetype"],
"primary_motif": "section_index",
"chart_type": "",
"page_rhythm": "anchor",
"renderer_id": "section_divider_index",
"main_visual_anchor": {"layout_box_role": "visual", "description": "oversized chapter index paired with a full-page signal field"},
"svg_effects": ["typography", "gradient"],
"asset_id": "chart.numbered_steps",
"density_contract": "one chapter signal plus one transition sentence",
},
"dashboard": {
"seed_id": "dashboard_kpi_grid",
"page_type": "kpi_overview",
"composition_archetype": "data_stage",
"required_visual_evidence": ["metric_hierarchy", "chart_geometry", "dashboard_grid"],
"primary_motif": "metric_grid",
"chart_type": "",
"page_rhythm": "dense",
"renderer_id": "dashboard_kpi_grid",
"main_visual_anchor": {"layout_box_role": "chart", "description": "KPI dashboard grid with hero metrics and micro trends"},
"svg_effects": ["typography", "chart_geometry"],
"asset_id": "chart.kpi_cards",
"density_contract": "dashboard >= 4 metrics",
},
"roadmap": {
"seed_id": "timeline_roadmap",
"page_type": "roadmap",
"composition_archetype": "layered_timeline",
"required_visual_evidence": ["connector_flow", "phase_spine", "full_page_archetype"],
"primary_motif": "phase_spine",
"chart_type": "",
"page_rhythm": "dense",
"renderer_id": "timeline_roadmap",
"main_visual_anchor": {"layout_box_role": "timeline", "description": "milestone spine with compact phase labels"},
"svg_effects": ["typography", "connector_flow", "path"],
"asset_id": "chart.timeline",
"density_contract": "timeline >= 3 milestones",
},
"process": {
"seed_id": "process_pipeline",
"page_type": "process_flow",
"composition_archetype": "layered_timeline",
"required_visual_evidence": ["connector_flow", "flow_lanes", "full_page_archetype"],
"primary_motif": "flow_route",
"chart_type": "",
"page_rhythm": "dense",
"renderer_id": "process_pipeline",
"main_visual_anchor": {"layout_box_role": "flow", "description": "left-to-right process path with input and output anchors"},
"svg_effects": ["typography", "connector_flow", "path"],
"asset_id": "chart.process_flow",
"density_contract": "process path >= 4 steps",
},
"comparison": {
"seed_id": "comparison_two_column_decision",
"page_type": "comparison",
"composition_archetype": "comparison_matrix",
"required_visual_evidence": ["decision_matrix", "contrast_panels", "semantic_labels"],
"primary_motif": "decision_axis",
"chart_type": "",
"page_rhythm": "dense",
"renderer_id": "comparison_two_column_decision",
"main_visual_anchor": {"layout_box_role": "table", "description": "two-column decision matrix with dimension rail"},
"svg_effects": ["typography", "path"],
"asset_id": "chart.comparison_table",
"density_contract": "comparison table >= 4 cells",
},
"capability": {
"seed_id": "capability_icon_map",
"page_type": "capability_map",
"composition_archetype": "radial_system",
"required_visual_evidence": ["hub_spoke", "sector_field", "semantic_labels"],
"primary_motif": "radial_hub",
"chart_type": "hub_spoke",
"page_rhythm": "dense",
"renderer_id": "capability_icon_map",
"main_visual_anchor": {"layout_box_role": "visual", "description": "central capability node with surrounding module grid"},
"svg_effects": ["typography", "connector_flow"],
"asset_id": "chart.hub_spoke",
"density_contract": "capability map >= 4 nodes",
},
"chart": {
"seed_id": "single_chart_takeaway",
"page_type": "chart_takeaway",
"composition_archetype": "data_stage",
"required_visual_evidence": ["chart_geometry", "insight_strip", "full_page_archetype"],
"primary_motif": "takeaway_chart",
"chart_type": "bar_chart",
"page_rhythm": "dense",
"renderer_id": "single_chart_takeaway",
"main_visual_anchor": {"layout_box_role": "chart", "description": "single chart area with one takeaway annotation"},
"svg_effects": ["typography", "chart_geometry"],
"asset_id": "chart.bar_chart",
"density_contract": "chart >= 3 visible marks",
},
"closing": {
"seed_id": "closing_summary",
"page_type": "closing",
"composition_archetype": "closing_manifesto",
"required_visual_evidence": ["closing_ribbon", "action_cards", "full_page_archetype"],
"primary_motif": "closing_route",
"chart_type": "",
"page_rhythm": "anchor",
"renderer_id": "closing_summary",
"main_visual_anchor": {"layout_box_role": "callout", "description": "closing statement plus next-action callout"},
"svg_effects": ["typography"],
"asset_id": "chart.numbered_steps",
"density_contract": "one closing message plus one next action",
},
"annotation": {
"seed_id": "spotlight_diagnosis_callout",
"page_type": "insight_callout",
"composition_archetype": "annotated_spotlight",
"required_visual_evidence": ["spotlight", "annotation", "semantic_labels"],
"primary_motif": "spotlight_field",
"chart_type": "",
"page_rhythm": "breathing",
"renderer_id": "spotlight_diagnosis_callout",
"main_visual_anchor": {"layout_box_role": "spotlight", "description": "annotated visual field with one spotlight and side note"},
"svg_effects": ["typography", "spotlight"],
"asset_id": "chart.labeled_card",
"density_contract": "spotlight callout <= 2 targets",
},
}
KEYWORD_PROFILES: tuple[tuple[str, tuple[str, ...]], ...] = (
("agenda", ("agenda", "contents", "table of contents", "toc", "目录", "议程")),
("section", ("section", "chapter", "section divider", "divider", "transition", "章节", "过渡", "转场", "第1章", "第2章", "第3章", "01 ", "02 ", "03 ")),
("dashboard", ("dashboard", "kpi", "metric", "metrics", "status", "scorecard", "health", "看板", "指标", "状态")),
("roadmap", ("roadmap", "timeline", "milestone", "phase", "plan", "规划", "里程碑", "阶段")),
("process", ("process", "pipeline", "workflow", "flow", "funnel", "步骤", "流程", "链路")),
("comparison", ("compare", "comparison", "versus", "vs", "matrix", "table", "decision", "对比", "比较", "矩阵")),
("capability", ("capability", "module", "architecture", "system", "hub", "spoke", "能力", "模块", "架构")),
("chart", ("chart", "bar", "line", "trend", "data", "evidence", "数据", "图表", "趋势")),
("closing", ("closing", "summary", "next", "thanks", "q&a", "结尾", "总结", "下一步")),
("cover", ("cover", "opening", "title", "thesis", "封面", "开场", "标题")),
)
STYLE_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = (
("raw_grid", ("dashboard", "kpi", "metric", "technical", "dense", "status", "ops", "operation", "看板", "指标")),
("long_table", ("process", "pipeline", "responsibility", "plan", "workflow", "流程", "责任")),
("riptide_cobalt", ("technology", "architecture", "flow", "system", "tech", "技术", "架构")),
("data_journalism_editorial", ("financial", "market", "report", "data", "analysis", "finance", "数据", "分析")),
("monochrome", ("serious", "formal", "minimal", "decision", "compare", "正式", "决策")),
)
NARRATIVE_MODES = {"briefing", "instructional", "narrative", "pyramid", "showcase"}
MODE_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = (
("instructional", ("training", "tutorial", "course", "lesson", "how-to", "playbook", "教学", "培训", "教程")),
("pyramid", ("decision", "board", "strategy", "proposal", "investment", "consulting", "决策", "战略", "提案", "投资")),
("showcase", ("launch", "showcase", "portfolio", "brand", "event", "product reveal", "发布", "展示", "作品集", "品牌")),
("narrative", ("story", "journey", "case", "vision", "future", "体验", "旅程", "故事", "案例", "愿景")),
("briefing", ("briefing", "review", "report", "status", "operations", "weekly", "汇报", "报告", "复盘", "经营", "周报")),
)
def script_path() -> Path:
return Path(__file__).resolve()
def references_dir() -> Path:
return script_path().parents[1] / "references"
def read_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def clone_json(value: Any) -> Any:
return copy.deepcopy(value)
def tokenize(*values: object) -> set[str]:
tokens: set[str] = set()
for value in values:
if value is None:
continue
if isinstance(value, dict):
tokens.update(tokenize(*value.values()))
continue
if isinstance(value, (list, tuple, set)):
tokens.update(tokenize(*value))
continue
tokens.update(match.group(0).lower() for match in TOKEN_RE.finditer(str(value)))
return tokens
def compact_text(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value.strip()
if isinstance(value, dict):
return " ".join(compact_text(item) for item in value.values() if compact_text(item))
if isinstance(value, (list, tuple, set)):
return " ".join(compact_text(item) for item in value if compact_text(item))
return str(value).strip()
def load_catalogs(ref_dir: Path | None = None) -> dict[str, Any]:
root = ref_dir or references_dir()
style_data = read_json(root / "style-presets.json")
seed_data = read_json(root / "svg-seeds.json")
recipe_data = read_json(root / "svg-recipes.json")
pattern_data = read_json(root / "svglide-design-pattern-map.json")
return {
"style_presets": {item["style_id"]: item for item in style_data.get("presets", []) if isinstance(item, dict) and item.get("style_id")},
"seeds": seed_data.get("seeds", {}),
"recipes": recipe_data.get("recipes", {}),
"chart_type_contracts": recipe_data.get("chart_type_contracts", {}),
"pattern_ids": {item.get("id") for item in pattern_data.get("resources", []) if isinstance(item, dict) and item.get("id")},
}
def first_present(data: dict[str, Any], keys: tuple[str, ...]) -> str:
for key in keys:
value = compact_text(data.get(key))
if value:
return value
visual_plan = data.get("visual_plan")
if isinstance(visual_plan, dict):
for key in keys:
value = compact_text(visual_plan.get(key))
if value:
return value
return ""
def classify_profile(text: str, *, index: int, total: int) -> str:
lowered = text.lower()
if index == 0 and any(keyword in lowered for keyword in ("cover", "opening", "title", "thesis", "封面", "开场")):
return "cover"
if total > 1 and index == total - 1 and any(keyword in lowered for keyword in ("closing", "summary", "next", "thanks", "q&a", "结尾", "总结", "下一步")):
return "closing"
for profile, keywords in KEYWORD_PROFILES:
if any(keyword in lowered for keyword in keywords):
return profile
if index == 0:
return "cover"
if total > 1 and index == total - 1:
return "closing"
return "annotation"
def style_preset_from_brief(brief: str, catalogs: dict[str, Any]) -> str:
lowered = brief.lower()
for style_id, keywords in STYLE_HINTS:
if style_id in catalogs["style_presets"] and any(keyword in lowered for keyword in keywords):
return style_id
return "raw_grid" if "raw_grid" in catalogs["style_presets"] else sorted(catalogs["style_presets"])[0]
def narrative_mode_from_brief(brief: str, slide_plan: dict[str, Any] | None = None) -> str:
lowered = " ".join([brief, compact_text(slide_plan or {})]).lower()
tokens = tokenize(lowered)
for mode, keywords in MODE_HINTS:
if any((keyword in tokens if re.fullmatch(r"[a-z0-9-]+", keyword) else keyword in lowered) for keyword in keywords):
return mode
return "briefing"
def style_system_from_preset(style_id: str, catalogs: dict[str, Any]) -> dict[str, Any]:
preset = catalogs["style_presets"].get(style_id, {})
palette = preset.get("palette") if isinstance(preset.get("palette"), dict) else {}
shape_language = preset.get("shape_language") if isinstance(preset.get("shape_language"), dict) else {}
density = preset.get("density") if isinstance(preset.get("density"), dict) else {}
return {
"palette": {
"background": palette.get("background", "#F5F5F5"),
"text": palette.get("text", "#0A0A0A"),
"accent": palette.get("accent", "#2563EB"),
"support": clone_json(palette.get("support", [])),
},
"typography": "strong title, readable native text labels",
"background_strategy": shape_language.get("panel_treatment", "structured panels with explicit text surfaces"),
"motif": shape_language.get("texture", "local SVG motif derived from selected page recipe"),
"density": {
"text_density": density.get("text_density", "medium"),
"label_density": density.get("label_density", "medium"),
"connector_density": density.get("connector_density", "medium"),
},
}
def apply_brief_palette(style_system: dict[str, Any], brief: str) -> dict[str, Any]:
colors: list[str] = []
for match in HEX_COLOR_RE.finditer(brief):
color = match.group(0).upper()
if color not in colors:
colors.append(color)
if not colors:
return style_system
output = clone_json(style_system)
palette = output.setdefault("palette", {})
if isinstance(palette, dict):
palette["accent"] = colors[0]
if len(colors) > 1:
palette["support"] = colors[1:]
output["palette_source"] = "brief_hex_colors"
return output
def slide_text(slide: dict[str, Any], fallback_description: str) -> str:
parts = [
first_present(slide, ("title", "headline", "name")),
first_present(slide, ("description", "summary", "body", "key_message", "page_type", "chart_type", "visual_recipe")),
fallback_description,
]
return " ".join(part for part in parts if part)
def seed_for_profile(profile: str, catalogs: dict[str, Any]) -> tuple[str, dict[str, Any]]:
seed_id = PAGE_PROFILES[profile]["seed_id"]
seeds = catalogs["seeds"]
if seed_id not in seeds:
raise ValueError(f"missing SVG seed: {seed_id}")
return seed_id, seeds[seed_id]
def seed_for_slide(slide: dict[str, Any], profile: str, catalogs: dict[str, Any]) -> tuple[str, dict[str, Any], dict[str, Any]]:
explicit_seed = compact_text(slide.get("seed_id"))
seeds = catalogs["seeds"]
if explicit_seed and explicit_seed in seeds:
seed = seeds[explicit_seed]
profile_data = dict(PAGE_PROFILES[profile])
profile_data["seed_id"] = explicit_seed
return explicit_seed, seed, profile_data
chart_type = compact_text(slide.get("chart_type")).replace("-", "_").lower()
visual_recipe = compact_text(slide.get("visual_recipe")).replace("-", "_").lower()
for candidate, data in PAGE_PROFILES.items():
if chart_type and chart_type == data["chart_type"]:
seed_id, seed = seed_for_profile(candidate, catalogs)
return seed_id, seed, data
if visual_recipe and seeds.get(data["seed_id"], {}).get("visual_recipe") == visual_recipe:
seed_id, seed = seed_for_profile(candidate, catalogs)
return seed_id, seed, data
seed_id, seed = seed_for_profile(profile, catalogs)
return seed_id, seed, PAGE_PROFILES[profile]
def list_union(*values: Any) -> list[str]:
out: list[str] = []
for value in values:
if isinstance(value, str):
candidates = [value]
elif isinstance(value, (list, tuple, set)):
candidates = [str(item) for item in value if str(item).strip()]
else:
candidates = []
for item in candidates:
if item not in out:
out.append(item)
return out
def setdefault_clone(target: dict[str, Any], key: str, value: Any) -> None:
if key not in target or target[key] in (None, "", []):
target[key] = clone_json(value)
def normalize_empty_chart_type(value: Any) -> str:
normalized = compact_text(value).replace("-", "_").lower()
return "" if normalized in {"none", "na", "n_a", "not_applicable", "no_chart", "no"} else normalized
def known_profile_evidence() -> set[str]:
evidence: set[str] = set()
for profile in PAGE_PROFILES.values():
raw = profile.get("required_visual_evidence")
if isinstance(raw, list):
evidence.update(str(item) for item in raw if str(item).strip())
return evidence
def complete_visual_design_contract(completed: dict[str, Any], profile_data: dict[str, Any]) -> dict[str, Any]:
existing = completed.get("visual_design_contract")
contract = clone_json(existing) if isinstance(existing, dict) else {}
thesis = first_present(completed, ("visual_thesis", "key_message", "one_idea", "title", "headline", "description"))
existing_required = list_union(contract.get("required_visual_evidence"))
manual_required = [item for item in existing_required if item not in known_profile_evidence()]
required = list_union(manual_required, profile_data.get("required_visual_evidence"))
pattern_bundle = list_union(contract.get("pattern_bundle"), profile_data.get("asset_id"))
setdefault_clone(contract, "schema_version", "svglide-visual-design-contract/v1")
setdefault_clone(contract, "page_kind", profile_data["page_type"])
setdefault_clone(contract, "visual_thesis", thesis)
setdefault_clone(contract, "composition_archetype", profile_data["composition_archetype"])
contract["pattern_bundle"] = pattern_bundle
setdefault_clone(contract, "density", profile_data["page_rhythm"])
setdefault_clone(contract, "primary_motif", profile_data["primary_motif"])
contract["required_visual_evidence"] = required
setdefault_clone(contract, "renderer_id", profile_data["renderer_id"])
setdefault_clone(contract, "layout_seed_id", completed.get("seed_id"))
setdefault_clone(contract, "visual_recipe", completed.get("visual_recipe"))
return contract
def complete_slide(slide: dict[str, Any], *, brief: str, fallback_description: str, index: int, total: int, catalogs: dict[str, Any]) -> dict[str, Any]:
completed = clone_json(slide)
if "chart_type" in completed:
completed["chart_type"] = normalize_empty_chart_type(completed.get("chart_type"))
text = slide_text(completed, fallback_description) or brief
profile = classify_profile(text, index=index, total=total)
seed_id, seed, profile_data = seed_for_slide(completed, profile, catalogs)
recipe = compact_text(completed.get("visual_recipe")) or compact_text(seed.get("visual_recipe"))
recipe_contract = catalogs["recipes"].get(recipe, {})
required_primitives = list_union(recipe_contract.get("required_primitives"), seed.get("required_primitives"), completed.get("required_primitives"))
svg_primitives = list_union(completed.get("svg_primitives"), required_primitives, ["typography", "geometric_shape"])
setdefault_clone(completed, "page", index + 1)
setdefault_clone(completed, "key_message", first_present(completed, ("key_message", "one_idea", "title", "headline", "description")) or text)
setdefault_clone(completed, "renderer_id", profile_data["renderer_id"])
setdefault_clone(completed, "page_rhythm", profile_data["page_rhythm"])
setdefault_clone(completed, "page_type", profile_data["page_type"])
setdefault_clone(completed, "chart_type", profile_data["chart_type"])
setdefault_clone(completed, "main_visual_anchor", profile_data["main_visual_anchor"])
setdefault_clone(completed, "seed_id", seed_id)
completed["layout_skeleton_id"] = clone_json(seed.get("layout_skeleton", {}).get("id", f"{seed_id}_skeleton"))
completed["layout_family"] = clone_json(seed.get("layout_family", profile))
setdefault_clone(completed, "visual_recipe", recipe)
setdefault_clone(completed, "visual_signature", f"{profile_data['page_type']} / {profile_data['renderer_id']} / {profile_data['asset_id']}")
completed["svg_effects"] = clone_json(profile_data["svg_effects"])
completed["layout_boxes"] = clone_json(seed.get("layout_boxes", []))
completed["content_budget"] = clone_json(seed.get("content_budget", {}))
completed["text_capacity"] = clone_json(seed.get("default_text_capacity") or seed.get("content_budget", {}))
completed["text_budget_by_role"] = clone_json(seed.get("text_budget_by_role", {}))
completed["reserved_bands"] = clone_json(seed.get("reserved_bands", {}))
completed["footer_safe_zone"] = clone_json(seed.get("footer_safe_zone", {}))
completed["vertical_text_policy"] = clone_json(seed.get("vertical_text_policy", {"mode": "deny", "allowed_roles": [], "max_chars": 0, "max_lines": 0}))
setdefault_clone(
completed,
"reference_asset",
{
"source": "svglide_design_pattern",
"asset_id": profile_data["asset_id"],
"usage": "page-type geometry only; do not copy raw SVG paths",
},
)
setdefault_clone(completed, "visual_intent", f"use {recipe.replace('_', ' ')} structure to make the page readable as SVG-native content")
setdefault_clone(completed, "visual_focal_point", profile_data["main_visual_anchor"])
setdefault_clone(completed, "required_primitives", required_primitives)
setdefault_clone(completed, "svg_primitives", svg_primitives)
setdefault_clone(completed, "xml_like_risk", "without the declared SVG primitives this page would degrade into ordinary text boxes")
setdefault_clone(completed, "content_density_contract", profile_data["density_contract"])
setdefault_clone(completed, "asset_contract", "none_required")
setdefault_clone(completed, "risk_flags", [])
setdefault_clone(completed, "source_status", "user_prompt_only")
setdefault_clone(completed, "source_policy", "when source material is missing, mark missing evidence and avoid numeric claims")
setdefault_clone(completed, "layout_guardrails", DEFAULT_GUARDRAILS)
completed["visual_design_contract"] = complete_visual_design_contract(completed, profile_data)
return completed
def normalize_slide_inputs(slide_plan: dict[str, Any] | None, page_descriptions: list[str]) -> list[dict[str, Any]]:
plan = slide_plan or {}
raw_slides = plan.get("slides")
if isinstance(raw_slides, list) and raw_slides:
slides = [clone_json(item) for item in raw_slides if isinstance(item, dict)]
else:
pages = plan.get("pages")
slides = [clone_json(item) for item in pages if isinstance(item, dict)] if isinstance(pages, list) and pages else []
if not slides:
slides = [{"description": description} for description in page_descriptions]
if not slides:
slides = [{"description": "Cover: core message"}, {"description": "Closing: next steps"}]
for index, description in enumerate(page_descriptions):
if index >= len(slides):
slides.append({"description": description})
elif description and not compact_text(slides[index].get("description")):
slides[index]["description"] = description
return slides
def selected_asset_id(slide: dict[str, Any]) -> str:
reference = slide.get("reference_asset")
if isinstance(reference, dict):
asset_id = compact_text(reference.get("asset_id") or reference.get("id"))
if asset_id:
return asset_id
asset_id = compact_text(slide.get("asset_id") or slide.get("design_pattern_id"))
return asset_id
def deck_rhythm_from_slides(slides: list[dict[str, Any]]) -> list[dict[str, Any]]:
rhythm: list[dict[str, Any]] = []
for index, slide in enumerate(slides, 1):
item = {
"page": slide.get("page", index),
"rhythm": compact_text(slide.get("page_rhythm")) or "breathing",
"page_type": compact_text(slide.get("page_type")) or "content",
}
rhythm.append(item)
return rhythm
def build_design_pattern_selection(slides: list[dict[str, Any]], existing: Any, catalogs: dict[str, Any]) -> dict[str, Any]:
selection = clone_json(existing) if isinstance(existing, dict) else {}
raw_assets = selection.get("selected_assets")
selected_assets: list[dict[str, Any]] = [clone_json(item) for item in raw_assets if isinstance(item, dict)] if isinstance(raw_assets, list) else []
seen = {compact_text(asset.get("id") or asset.get("asset_id")) for asset in selected_assets}
pattern_ids = catalogs.get("pattern_ids", set())
for slide in slides:
asset_id = selected_asset_id(slide)
if not asset_id or asset_id in seen or (pattern_ids and asset_id not in pattern_ids):
continue
selected_assets.append(
{
"id": asset_id,
"kind": "chart_template",
"usage": "geometry_contract",
"copy_policy": "derive_contract_only",
}
)
seen.add(asset_id)
setdefault_clone(selection, "schema_version", "svglide-design-pattern-selection/v1")
setdefault_clone(selection, "mode", "local_contract")
setdefault_clone(selection, "selected_assets", selected_assets)
setdefault_clone(selection, "proof_status", "pending_component_report")
return selection
def build_contract(
*,
brief: str = "",
slide_plan: dict[str, Any] | None = None,
page_descriptions: list[str] | None = None,
ref_dir: Path | None = None,
) -> dict[str, Any]:
catalogs = load_catalogs(ref_dir)
descriptions = page_descriptions or []
source_plan = clone_json(slide_plan) if isinstance(slide_plan, dict) else {}
output = source_plan
slides = normalize_slide_inputs(source_plan, descriptions)
completed_slides = [
complete_slide(slide, brief=brief, fallback_description=descriptions[index] if index < len(descriptions) else "", index=index, total=len(slides), catalogs=catalogs)
for index, slide in enumerate(slides)
]
style_id = compact_text(output.get("style_preset")) or style_preset_from_brief(brief or compact_text(output), catalogs)
if style_id not in catalogs["style_presets"]:
style_id = style_preset_from_brief(brief or compact_text(output), catalogs)
explicit_mode = compact_text(output.get("narrative_mode") or output.get("mode"))
narrative_mode = explicit_mode if explicit_mode in NARRATIVE_MODES else narrative_mode_from_brief(brief, output)
setdefault_clone(output, "schema_version", CONTRACT_SCHEMA_VERSION)
setdefault_clone(output, "output_mode", "svglide-svg")
output["mode"] = narrative_mode
setdefault_clone(output, "narrative_mode", narrative_mode)
setdefault_clone(output, "canvas", CANVAS)
setdefault_clone(output, "safe_area", SAFE_AREA)
output["style_preset"] = style_id
setdefault_clone(output, "style_selection_reason", f"{style_id} matches the brief and the selected SVG page recipes")
output["style_system"] = apply_brief_palette(style_system_from_preset(style_id, catalogs), brief)
for slide in completed_slides:
contract = slide.get("visual_design_contract")
if isinstance(contract, dict):
setdefault_clone(contract, "style_preset", style_id)
output["slides"] = completed_slides
output["page_count"] = len(completed_slides)
setdefault_clone(output, "page_rhythm", deck_rhythm_from_slides(completed_slides))
output["design_pattern_selection"] = build_design_pattern_selection(completed_slides, output.get("design_pattern_selection"), catalogs)
return output
def read_text_arg(path: str | None, inline: str | None) -> str:
parts: list[str] = []
if inline:
parts.append(inline)
if path:
parts.append(Path(path).expanduser().read_text(encoding="utf-8"))
return "\n".join(part.strip() for part in parts if part.strip())
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Build or complete a conservative SVGlide plan contract.")
parser.add_argument("--brief", help="path to a brief text file")
parser.add_argument("--brief-text", help="inline brief text")
parser.add_argument("--plan", help="existing slide_plan.json to complete")
parser.add_argument("--page-description", action="append", default=[], help="simple page description; may be repeated")
parser.add_argument("--output", help="output JSON path; defaults to stdout")
parser.add_argument("--in-place", action="store_true", help="write the completed contract back to --plan")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.in_place and not args.plan:
parser.error("--in-place requires --plan")
brief = read_text_arg(args.brief, args.brief_text)
slide_plan = read_json(Path(args.plan).expanduser()) if args.plan else None
if slide_plan is not None and not isinstance(slide_plan, dict):
raise ValueError("--plan must point to a JSON object")
result = build_contract(brief=brief, slide_plan=slide_plan, page_descriptions=args.page_description)
output_path = Path(args.output).expanduser() if args.output else (Path(args.plan).expanduser() if args.in_place else None)
if output_path:
write_json(output_path, result)
else:
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,231 @@
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import json
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
import svglide_strategist as strategist
class SVGlideStrategistTest(unittest.TestCase):
def test_build_contract_from_brief_and_page_descriptions(self) -> None:
plan = strategist.build_contract(
brief="Create an operations review with KPI dashboard, roadmap, and closing next steps.",
page_descriptions=[
"Cover: operating thesis and review scope",
"KPI dashboard: four health metrics with micro trends",
"Roadmap: three milestone phases and ownership",
"Closing: next steps and decision request",
],
)
self.assertEqual("svglide-svg", plan["output_mode"])
self.assertEqual("briefing", plan["mode"])
self.assertEqual("briefing", plan["narrative_mode"])
self.assertEqual("raw_grid", plan["style_preset"])
self.assertEqual({"background", "text", "accent"}, set(plan["style_system"]["palette"]).intersection({"background", "text", "accent"}))
self.assertEqual(4, plan["page_count"])
self.assertEqual(
[
{"page": 1, "rhythm": "anchor", "page_type": "cover"},
{"page": 2, "rhythm": "dense", "page_type": "kpi_overview"},
{"page": 3, "rhythm": "dense", "page_type": "roadmap"},
{"page": 4, "rhythm": "anchor", "page_type": "closing"},
],
plan["page_rhythm"],
)
self.assertEqual(["chart.vertical_list", "chart.kpi_cards", "chart.timeline", "chart.numbered_steps"], [asset["id"] for asset in plan["design_pattern_selection"]["selected_assets"]])
dashboard = plan["slides"][1]
self.assertEqual("KPI dashboard: four health metrics with micro trends", dashboard["key_message"])
self.assertEqual("dense", dashboard["page_rhythm"])
self.assertEqual("kpi_overview", dashboard["page_type"])
self.assertEqual("", dashboard["chart_type"])
self.assertEqual({"layout_box_role": "chart", "description": "KPI dashboard grid with hero metrics and micro trends"}, dashboard["main_visual_anchor"])
self.assertEqual("kpi_overview / dashboard_kpi_grid / chart.kpi_cards", dashboard["visual_signature"])
self.assertEqual(["typography", "chart_geometry"], dashboard["svg_effects"])
self.assertEqual("fake_ui_dashboard", dashboard["visual_recipe"])
self.assertEqual("chart.kpi_cards", dashboard["reference_asset"]["asset_id"])
self.assertIn("main text and chart labels stay inside safe area", dashboard["layout_guardrails"])
self.assertEqual(
{
"schema_version": "svglide-visual-design-contract/v1",
"page_kind": "kpi_overview",
"visual_thesis": "KPI dashboard: four health metrics with micro trends",
"composition_archetype": "data_stage",
"pattern_bundle": ["chart.kpi_cards"],
"density": "dense",
"primary_motif": "metric_grid",
"required_visual_evidence": ["metric_hierarchy", "chart_geometry", "dashboard_grid"],
"renderer_id": "dashboard_kpi_grid",
"layout_seed_id": "dashboard_kpi_grid",
"visual_recipe": "fake_ui_dashboard",
"style_preset": "raw_grid",
},
dashboard["visual_design_contract"],
)
chart_contracts = strategist.load_catalogs()["chart_type_contracts"]
allowed_rhythms = {"anchor", "breathing", "dense"}
for slide in plan["slides"]:
self.assertIn(slide["page_rhythm"], allowed_rhythms)
if slide["chart_type"]:
self.assertIn(slide["chart_type"], chart_contracts)
def test_brief_hex_colors_drive_style_system_palette(self) -> None:
plan = strategist.build_contract(
brief="新疆阿克苏城区居住区策划案,主色澄澈水蓝 #4A90E2辅色春芽嫩绿 #8BC34A强调色艾德莱斯绸朱红 #E91E63。",
page_descriptions=["Cover: 以水为脉·四时为序"],
)
palette = plan["style_system"]["palette"]
self.assertEqual("#4A90E2", palette["accent"])
self.assertEqual(["#8BC34A", "#E91E63"], palette["support"])
self.assertEqual("brief_hex_colors", plan["style_system"]["palette_source"])
def test_complete_existing_plan_preserves_manual_fields(self) -> None:
base = {
"title": "Pipeline Decision",
"style_preset": "monochrome",
"slides": [
{
"page": 1,
"title": "Keep this title",
"page_type": "custom_decision",
"chart_type": "comparison_table",
"visual_recipe": "geometric_composition",
"reference_asset": {"source": "manual", "asset_id": "chart.comparison_table", "usage": "approved"},
}
],
}
plan = strategist.build_contract(brief="Compare two launch options.", slide_plan=base)
slide = plan["slides"][0]
self.assertEqual("monochrome", plan["style_preset"])
self.assertEqual("pyramid", plan["mode"])
self.assertEqual("Keep this title", slide["title"])
self.assertEqual("custom_decision", slide["page_type"])
self.assertEqual("comparison_table", slide["chart_type"])
self.assertEqual("geometric_composition", slide["visual_recipe"])
self.assertEqual({"source": "manual", "asset_id": "chart.comparison_table", "usage": "approved"}, slide["reference_asset"])
self.assertIn("layout_boxes", slide)
self.assertIn("footer_safe_zone", slide)
self.assertIn("layout_guardrails", slide)
def test_existing_plan_refreshes_seed_derived_contract_fields(self) -> None:
base = {
"slides": [
{
"description": "KPI dashboard with secondary metric cards and chart labels",
"seed_id": "dashboard_kpi_grid",
"layout_boxes": [
{"id": "title", "role": "title", "x": 48, "y": 34, "width": 864, "height": 48},
{"id": "primary-kpi", "role": "metric", "x": 64, "y": 106, "width": 260, "height": 128},
{"id": "secondary-grid", "role": "grid", "x": 348, "y": 106, "width": 548, "height": 128},
],
"text_budget_by_role": {
"metric": {"max_chars": 80, "max_lines": 3, "max_boxes": 1, "min_font_px": 12},
},
}
]
}
plan = strategist.build_contract(brief="KPI dashboard", slide_plan=base)
slide = plan["slides"][0]
self.assertIn("secondary-metric-grid", [box["id"] for box in slide["layout_boxes"]])
self.assertEqual(7, slide["text_budget_by_role"]["metric"]["max_boxes"])
self.assertEqual(4, slide["text_budget_by_role"]["chart"]["max_boxes"])
def test_normalizes_none_chart_type_to_empty_contract(self) -> None:
plan = strategist.build_contract(
brief="Brand showcase opening.",
slide_plan={"slides": [{"title": "Opening", "chart_type": "none"}]},
)
self.assertEqual("", plan["slides"][0]["chart_type"])
def test_classifies_agenda_and_section_as_first_class_profiles(self) -> None:
plan = strategist.build_contract(
brief="新疆阿克苏城区居住区策划案,包含目录和章节过渡。",
page_descriptions=[
"Cover: 以水为脉·四时为序",
"目录:项目核心定位、春之地块、夏之地块、秋之地块、冬之地块",
"章节过渡页01 项目核心定位与愿景",
],
)
agenda = plan["slides"][1]
section = plan["slides"][2]
self.assertEqual("agenda", agenda["page_type"])
self.assertEqual("agenda_numbered_path", agenda["seed_id"])
self.assertEqual("indexed_path", agenda["visual_design_contract"]["composition_archetype"])
self.assertEqual(["numbered_path", "section_index", "semantic_labels"], agenda["visual_design_contract"]["required_visual_evidence"])
self.assertEqual("chart.agenda_list", agenda["reference_asset"]["asset_id"])
self.assertEqual("section_divider", section["page_type"])
self.assertEqual("section_divider_index", section["seed_id"])
self.assertEqual("section_signal", section["visual_design_contract"]["composition_archetype"])
self.assertEqual(["section_index", "hero_signal", "full_page_archetype"], section["visual_design_contract"]["required_visual_evidence"])
self.assertEqual("chart.numbered_steps", section["reference_asset"]["asset_id"])
def test_cli_reads_text_and_plan_and_writes_clean_contract(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
tmp = Path(tmpdir)
brief_path = tmp / "brief.txt"
plan_path = tmp / "slide_plan.json"
out_path = tmp / "contract.json"
brief_path.write_text("Technical KPI dashboard for weekly status.", encoding="utf-8")
plan_path.write_text(json.dumps({"slides": [{"title": "Status", "description": "KPI dashboard with micro bars"}]}), encoding="utf-8")
exit_code = strategist.main(["--brief", str(brief_path), "--plan", str(plan_path), "--output", str(out_path)])
self.assertEqual(0, exit_code)
result = json.loads(out_path.read_text(encoding="utf-8"))
encoded = json.dumps(result, ensure_ascii=False).lower()
self.assertEqual("fake_ui_dashboard", result["slides"][0]["visual_recipe"])
self.assertNotIn("source" + "_token", encoded)
self.assertNotIn("beautiful" + "-feishu-whiteboard", encoded)
self.assertNotIn("ppt" + "-master", encoded)
self.assertNotIn("hugo" + "he3", encoded)
def test_preserves_manual_visual_design_contract_and_fills_missing_fields(self) -> None:
plan = strategist.build_contract(
brief="AI capital market briefing with one chart.",
slide_plan={
"slides": [
{
"title": "Capital Flow",
"chart_type": "bar_chart",
"visual_design_contract": {
"visual_thesis": "Manual thesis",
"composition_archetype": "manual_data_stage",
"required_visual_evidence": ["custom_evidence"],
},
}
]
},
)
contract = plan["slides"][0]["visual_design_contract"]
self.assertEqual("Manual thesis", contract["visual_thesis"])
self.assertEqual("manual_data_stage", contract["composition_archetype"])
self.assertEqual(["custom_evidence", "chart_geometry", "insight_strip", "full_page_archetype"], contract["required_visual_evidence"])
self.assertEqual(["chart.bar_chart"], contract["pattern_bundle"])
self.assertEqual("dense", contract["density"])
self.assertEqual("takeaway_chart", contract["primary_motif"])
self.assertEqual(plan["style_preset"], contract["style_preset"])
if __name__ == "__main__":
unittest.main()

View File

@@ -24,9 +24,12 @@ import (
//go:embed skills/lark-slides/scripts/svg_safe_rewrite.py
//go:embed skills/lark-slides/scripts/svg_raster_renderer.py
//go:embed skills/lark-slides/scripts/svglide_project_runner.py
//go:embed skills/lark-slides/scripts/svg_preflight.py
//go:embed skills/lark-slides/scripts/svg_preview_lint.py
//go:embed skills/lark-slides/scripts/ppt_master_asset_inventory.py
//go:embed skills/lark-slides/scripts/svglide_asset_selector.py
//go:embed skills/lark-slides/scripts/svglide_strategist.py
//go:embed skills/lark-slides/scripts/svglide_gen_runtime.py
//go:embed skills/lark-slides/scripts/svglide_golden_suite.py
var skillsEmbedFS embed.FS
// init wires the embedded tree in as the default skill content. It compiles into