mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 09:11:44 +08:00
feat(slides): harden svglide visual pipeline
This commit is contained in:
@@ -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 或自由重画骨架。
|
||||
|
||||
@@ -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 |
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 等不能互相退化成同一种卡片页。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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`.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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 导出实现。
|
||||
|
||||
通过标准:
|
||||
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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(
|
||||
[
|
||||
|
||||
2028
skills/lark-slides/scripts/svglide_gen_runtime.py
Normal file
2028
skills/lark-slides/scripts/svglide_gen_runtime.py
Normal file
File diff suppressed because it is too large
Load Diff
776
skills/lark-slides/scripts/svglide_gen_runtime_test.py
Normal file
776
skills/lark-slides/scripts/svglide_gen_runtime_test.py
Normal 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()
|
||||
112
skills/lark-slides/scripts/svglide_golden_suite.py
Normal file
112
skills/lark-slides/scripts/svglide_golden_suite.py
Normal 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())
|
||||
78
skills/lark-slides/scripts/svglide_golden_suite_test.py
Normal file
78
skills/lark-slides/scripts/svglide_golden_suite_test.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
675
skills/lark-slides/scripts/svglide_strategist.py
Normal file
675
skills/lark-slides/scripts/svglide_strategist.py
Normal 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())
|
||||
231
skills/lark-slides/scripts/svglide_strategist_test.py
Normal file
231
skills/lark-slides/scripts/svglide_strategist_test.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user