From be8a67b8949d543be85fddaaf4981ee990ee21af Mon Sep 17 00:00:00 2001 From: "songtianyi.theo" Date: Wed, 10 Jun 2026 19:23:25 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=20SVGlide=20=E8=A7=86?= =?UTF-8?q?=E8=A7=89=E7=94=9F=E6=88=90=E4=B8=8E=E9=A2=84=E6=A3=80=E9=97=A8?= =?UTF-8?q?=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 style presets、visual recipe 和 aesthetic review gate,并接入 lark-slides skill 执行链路。 扩展 create-svg 图片处理、协议说明与 svg_preflight 校验,补充对应测试。 --- shortcuts/slides/slides_create_svg_test.go | 6 +- shortcuts/slides/svg_helpers.go | 38 +- shortcuts/slides/svg_helpers_test.go | 41 +- skills/lark-slides/SKILL.md | 25 +- .../references/lark-slides-create-svg.md | 288 ++- .../lark-slides/references/planning-layer.md | 25 +- .../lark-slides/references/style-presets.json | 542 +++++ .../lark-slides/references/style-presets.md | 89 + .../references/svg-aesthetic-review.md | 107 + skills/lark-slides/references/svg-protocol.md | 61 +- .../references/svg-visual-recipes.md | 106 + .../references/validation-checklist.md | 50 + .../lark-slides/references/visual-planning.md | 11 + skills/lark-slides/scripts/svg_preflight.py | 1834 ++++++++++++++++- .../lark-slides/scripts/svg_preflight_test.py | 979 ++++++++- 15 files changed, 4133 insertions(+), 69 deletions(-) create mode 100644 skills/lark-slides/references/style-presets.json create mode 100644 skills/lark-slides/references/style-presets.md create mode 100644 skills/lark-slides/references/svg-aesthetic-review.md create mode 100644 skills/lark-slides/references/svg-visual-recipes.md diff --git a/shortcuts/slides/slides_create_svg_test.go b/shortcuts/slides/slides_create_svg_test.go index cba2d8a1e..d915bcc9f 100644 --- a/shortcuts/slides/slides_create_svg_test.go +++ b/shortcuts/slides/slides_create_svg_test.go @@ -139,8 +139,10 @@ func TestSlidesCreateSVGExecuteCreatesSlidesInFileOrder(t *testing.T) { t.Fatalf("slide_ids = %v, want [slide_1 slide_2]", data["slide_ids"]) } - assertSlideCreateBodyContains(t, slideStub1, testSVGlidePage1) - assertSlideCreateBodyContains(t, slideStub2, testSVGlidePage2) + assertSlideCreateBodyContains(t, slideStub1, `slide:contract-version="svglide-authoring-contract/v1"`) + assertSlideCreateBodyContains(t, slideStub1, ``) + assertSlideCreateBodyContains(t, slideStub2, `slide:contract-version="svglide-authoring-contract/v1"`) + assertSlideCreateBodyContains(t, slideStub2, ``) } func TestSlidesCreateSVGPartialFailureIncludesRecoveryContext(t *testing.T) { diff --git a/shortcuts/slides/svg_helpers.go b/shortcuts/slides/svg_helpers.go index 2bfcdd56e..cdd6a261e 100644 --- a/shortcuts/slides/svg_helpers.go +++ b/shortcuts/slides/svg_helpers.go @@ -15,7 +15,11 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) -const maxSVGFileSizeBytes int64 = 2 * 1024 * 1024 +const ( + maxSVGFileSizeBytes int64 = 2 * 1024 * 1024 + svglideSlideNS = "https://slides.bytedance.com/ns" + svglideContractVersion = "svglide-authoring-contract/v1" +) type RewrittenSVGPage struct { Content string @@ -112,6 +116,11 @@ func readSVGFiles(runtime *common.RuntimeContext, paths []string) ([]string, err return nil, output.ErrValidation("--file %s: SVG file is empty", path) } svg := string(data) + var normalizeErr error + svg, normalizeErr = ensureSVGlideRootContractVersion(svg, path) + if normalizeErr != nil { + return nil, normalizeErr + } if err := validateSVGlideSVG(svg, path); err != nil { return nil, err } @@ -130,18 +139,41 @@ func validateSVGlideSVG(svg, path string) error { return output.ErrValidation("--file %s: root element must be non-namespaced ", path) } attrs := svg[m[6]:m[7]] - if !hasXMLAttr(attrs, "xmlns:slide", "https://slides.bytedance.com/ns") { - return output.ErrValidation("--file %s: root must declare xmlns:slide=\"https://slides.bytedance.com/ns\"", path) + if !hasXMLAttr(attrs, "xmlns:slide", svglideSlideNS) { + return output.ErrValidation("--file %s: root must declare xmlns:slide=\"%s\"", path, svglideSlideNS) } if !hasXMLAttr(attrs, "slide:role", "slide") { return output.ErrValidation("--file %s: root must include slide:role=\"slide\"", path) } + if version := xmlAttrValue(attrs, "slide:contract-version"); version != svglideContractVersion { + return output.ErrValidation("--file %s: root must include slide:contract-version=\"%s\"", path, svglideContractVersion) + } if svg[m[8]:m[9]] == "/>" { return nil } return validateSVGlideChildren(svg[m[9]:], path) } +func ensureSVGlideRootContractVersion(svg, path string) (string, error) { + m := svgRootOpenTagRegex.FindStringSubmatchIndex(svg) + if m == nil { + return svg, nil + } + tagName := svg[m[4]:m[5]] + if tagName != "svg" { + return svg, nil + } + attrs := svg[m[6]:m[7]] + version := xmlAttrValue(attrs, "slide:contract-version") + if version == svglideContractVersion { + return svg, nil + } + if strings.TrimSpace(version) != "" { + return "", output.ErrValidation("--file %s: root must include slide:contract-version=\"%s\"", path, svglideContractVersion) + } + return svg[:m[8]] + fmt.Sprintf(` slide:contract-version="%s"`, svglideContractVersion) + svg[m[8]:], nil +} + func hasXMLAttr(attrs, name, want string) bool { return xmlAttrValue(attrs, name) == want } diff --git a/shortcuts/slides/svg_helpers_test.go b/shortcuts/slides/svg_helpers_test.go index 66e0bddc8..9d34d82bf 100644 --- a/shortcuts/slides/svg_helpers_test.go +++ b/shortcuts/slides/svg_helpers_test.go @@ -92,6 +92,38 @@ func TestInjectSVGTransportAssetMetadataMergesExisting(t *testing.T) { } } +func TestEnsureSVGlideRootContractVersionInjectsMissingVersion(t *testing.T) { + t.Parallel() + + in := `` + got, err := ensureSVGlideRootContractVersion(in, "page.svg") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(got, `slide:contract-version="svglide-authoring-contract/v1"`) { + t.Fatalf("contract version missing after normalization: %s", got) + } + if strings.Index(got, `slide:contract-version`) > strings.Index(got, `>` + _, err := ensureSVGlideRootContractVersion(in, "page.svg") + if err == nil { + t.Fatal("expected wrong contract-version to fail") + } + if !strings.Contains(err.Error(), `slide:contract-version="svglide-authoring-contract/v1"`) { + t.Fatalf("error = %v, want contract-version guidance", err) + } +} + func TestValidateSVGlideSVGRecursiveChildren(t *testing.T) { t.Parallel() @@ -251,7 +283,7 @@ func TestValidateSVGlideSVGRecursiveChildren(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - err := validateSVGlideSVG(tt.svg, "page.svg") + err := validateSVGlideSVG(withTestSVGlideContractVersion(tt.svg), "page.svg") if tt.wantErr == "" { if err != nil { t.Fatalf("unexpected error: %v", err) @@ -268,6 +300,13 @@ func TestValidateSVGlideSVGRecursiveChildren(t *testing.T) { } } +func withTestSVGlideContractVersion(svg string) string { + if strings.Contains(svg, `slide:contract-version=`) { + return svg + } + return strings.Replace(svg, `slide:role="slide"`, `slide:role="slide" slide:contract-version="svglide-authoring-contract/v1"`, 1) +} + func TestExtractSVGlideErrorJSON(t *testing.T) { t.Parallel() diff --git a/skills/lark-slides/SKILL.md b/skills/lark-slides/SKILL.md index 7d91553be..b83e348c7 100644 --- a/skills/lark-slides/SKILL.md +++ b/skills/lark-slides/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-slides -version: 1.0.0 -description: "飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。不负责:云文档内容编辑(走 lark-doc)、云文档里的独立画板对象(走 lark-whiteboard,注意 slide 内嵌的流程图/架构图仍属本 skill)、上传或下载普通文件(走 lark-drive)。" +version: 1.0.2 +description: "飞书幻灯片:创建和编辑幻灯片,接口通过 XML/SVG 协议通信。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。不负责:云文档内容编辑(走 lark-doc)、云文档里的独立画板对象(走 lark-whiteboard,注意 slide 内嵌的流程图/架构图仍属本 skill)、上传或下载普通文件(走 lark-drive)。" metadata: requires: bins: ["lark-cli"] @@ -15,11 +15,11 @@ metadata: | 用户需求 | 优先动作 | 关键文档 / 命令 | |----------|----------|-----------------| | 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` | -| AI 生成 SVG 创建 PPT | 复用 `.lark-slides/plan//slide_plan.json` 规划,生成 SVGlide SVG 后调用 `slides +create-svg` | `lark-slides-create-svg.md`、`svg-protocol.md` | +| AI 生成 SVG 创建 PPT | 复用 `.lark-slides/plan//slide_plan.json` 规划,生成 SVGlide SVG 后调用 `slides +create-svg` | `lark-slides-create-svg.md`、`svg-protocol.md`、`svg-visual-recipes.md`、`svg-aesthetic-review.md` | | 大幅改写页面 | 先回读现有 XML,写入新 plan,再替换或重建相关页面 | `xml_presentations.get`、`+replace-slide`、`lark-slides-edit-workflows.md` | | 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide`、`lark-slides-replace-slide.md` | | 读取或分析已有 PPT | 解析 slides/wiki token,回读全文或单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `xml_presentations.get`、`xml_presentation.slide.get` | -| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides` 的 `@./path` 占位符 | +| 上传或使用图片 | Preview 阶段优先多用真实图片增强视觉冲击;可先用公开可访问 http(s)/data 图片或本地 `@./path`,来源/授权只 warning 不阻断;正式交付再替换为授权清晰的 file token / 本地资产 | `slides +media-upload`,或 `+create --slides` / `+create-svg` 的 `@./path` 占位符 | | 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `` 元素 | `xml-schema-quick-ref.md` | | 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) | | 使用语义图标 | 先检索 IconPark,再写 `` | `iconpark_tool.py search → resolve`、`iconpark.md` | @@ -32,7 +32,11 @@ metadata: **CRITICAL — 走 `slides +create-svg` 时,输入必须是 SVGlide SVG:root `` 声明 `xmlns:slide` 且 `slide:role="slide"`;可渲染 SVG 元素必须用 `slide:role="shape"` 或 `slide:role="image"` 表达;`g` / 嵌套 `svg` 可作为容器,但容器内实际渲染元素仍必须各自声明 role。CLI 只读取文件、上传/替换图片占位符、注入 transport metadata 和调用现有 `/slide` 路由,不会把普通 SVG 自动补齐成协议 SVG。** -**CRITICAL — 高质量 SVG deck 生成时,MUST 同时读取 [lark-slides-create-svg.md](references/lark-slides-create-svg.md):复用现有 `.lark-slides/plan//slide_plan.json` 作为设计状态,先做 deck-level density plan,再定义布局盒,给 `foreignObject` 文本留足安全高度,默认必须使用真实图片资产(本地 `@./path` 或 file token),相邻页面要显著换版式;调用 API 前必须跑本地 preflight(优先使用 [`scripts/svg_preflight.py`](scripts/svg_preflight.py)),live 创建后必须 readback 校验。这些是生成技巧,不替代 [svg-protocol.md](references/svg-protocol.md) 的硬协议约束。** +**CRITICAL — SVGlide deck 页数默认值:当用户要求生成 SVG/SVGlide 幻灯片但未说明页数,或使用“一份 slide / 一份 PPT / 做个 slide / 生成一个 slide”这类模糊表达时,默认生成 `10` 页,不要仅因页数缺失而停下来追问。只有用户明确说“一页 / 单页 / onepage / one slide / 只要封面”等单页意图时,才生成 `1` 页;用户给出明确页数时始终服从用户要求。默认 10 页时必须在 `slide_plan.json` 写入 `page_count` 或 `target_slide_count=10`,并包含明确 closing slide。** + +**CRITICAL — 高质量 SVG deck 生成时,MUST 同时读取 [lark-slides-create-svg.md](references/lark-slides-create-svg.md) 和 [svg-visual-recipes.md](references/svg-visual-recipes.md):复用现有 `.lark-slides/plan//slide_plan.json` 作为设计状态,先做 deck-level density plan,再为每页选择 `visual_recipe`、声明 `svg_primitives` / `visual_focal_point` / `xml_like_risk`,然后定义布局盒,给 `foreignObject` 文本留足安全高度。生成器必须在写 SVG 前做 preflight-aware 自检:由实际组件 manifest 反推出 primitives,按 `content_density_contract` 计数,检查主体元素 safe area / text bbox,不要只靠最终 `svg_preflight.py` 兜底。Preview 阶段默认必须使用丰富真实图片资产,并 SHOULD 优先根据用户 query / deck 主题 / 章节标题去网络检索和拉取强相关图片;公开图、场景图、产品图、截图、纹理/材质、图鉴图均可作为占位视觉。版权/授权不作为 preview 阻断,但要在 `asset_contract` 里标记 `retrieval_query`、`source_url` 和 `preview_unverified`;正式交付再替换为授权清晰的本地 `@./path` / file token。相邻页面要显著换版式且 8 页以上至少使用 5 种 visual recipe family;如果 agent 支持本地浏览器预览,SHOULD 生成并打开 `preview.html`,并按 [svg-aesthetic-review.md](references/svg-aesthetic-review.md) 检查明显视觉问题;调用 API 前必须跑本地 preflight(优先使用 [`scripts/svg_preflight.py`](scripts/svg_preflight.py)),live 创建后必须 readback 校验。这些是生成技巧,不替代 [svg-protocol.md](references/svg-protocol.md) 的硬协议约束。** + +**CRITICAL — SVGlide 高质量生成必须读取 [style-presets.md](references/style-presets.md),并从 [style-presets.json](references/style-presets.json) 选择一个 deck-level `style_preset`。`style_preset` 只表达视觉语言,不替代 `visual_recipe`;`visual_recipe` 的选择和安全效果边界以 [svg-visual-recipes.md](references/svg-visual-recipes.md) 为准。生成顺序是 semantic plan -> visual_recipe -> style_preset/style_system -> layout boxes -> SVG。每页必须声明 `visual_signature` 和 `svg_effects`,说明这一页相对普通 XML/PPT 模板的 SVG 视觉优势。** **CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan//slide_plan.json`,再生成 XML 或 SVGlide SVG。先创建对应目录,规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。** @@ -40,7 +44,7 @@ metadata: **CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。** -**CRITICAL — 创建或大幅改写后,MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险;XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py),SVG 创建前的本地 preflight 优先使用 [`scripts/svg_preflight.py`](scripts/svg_preflight.py)。** +**CRITICAL — 创建或大幅改写后,MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险;XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py),SVG 创建前的本地 preflight 优先使用 [`scripts/svg_preflight.py`](scripts/svg_preflight.py),SVG 本地预览后按 [svg-aesthetic-review.md](references/svg-aesthetic-review.md) 做审美和重复问题复核。** **CRITICAL — 创建前自检或失败排障时,MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。** @@ -85,7 +89,7 @@ lark-cli auth login --domain slides 按需再读: -- 创建:[`lark-slides-create.md`](references/lark-slides-create.md);SVG 创建:[`lark-slides-create-svg.md`](references/lark-slides-create-svg.md)、[`svg-protocol.md`](references/svg-protocol.md) +- 创建:[`lark-slides-create.md`](references/lark-slides-create.md);SVG 创建:[`lark-slides-create-svg.md`](references/lark-slides-create-svg.md)、[`svg-protocol.md`](references/svg-protocol.md)、[`style-presets.md`](references/style-presets.md)、[`svg-visual-recipes.md`](references/svg-visual-recipes.md)、[`svg-aesthetic-review.md`](references/svg-aesthetic-review.md) - 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md) - 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md) - 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) @@ -133,8 +137,9 @@ lark-cli auth login --domain slides - 不要所有页面复用同一种标题 + 三 bullets 版式。 - 不要用低对比文字或低对比图标,例如浅灰字压在浅色背景上。 - 不要让装饰线穿过文字,或让页脚、来源、编号挤压主体内容。 -- 不要使用版权状态不明的图片、logo、截图或素材;图片必须来自用户提供、公司/项目自有、明确可商用授权图库,或授权条件清晰的 AI 生成资产,并在产物说明或素材清单中记录来源、授权/许可类型、原始 URL 和是否需要署名。 +- Preview 阶段不要因为版权/授权缺失而退回纯矢量;推荐先把用户 query、deck 标题和每页章节主题拆成图片检索词,去网络拉取强相关真实图片、网页截图、产品截图或图库图做视觉占位。必须记录 `retrieval_query`、来源 URL,或标记 `license=preview_unverified`,并避免误导性商标背书、敏感肖像和明显不适当素材。正式交付时再替换为用户提供、公司/项目自有、明确可商用授权图库,或授权条件清晰的 AI 生成资产。 - 不要把素材缺失表现为空白图片框;必须先尝试获取或生成可用图片资产。只有用户明确要求纯矢量、网络/权限不可用,或主题确实不适合图片时,才按 `fallback_if_missing` 生成 XML-native 视觉,并在结果中说明。 +- Preview/MVP 阶段图片来源/授权/外链问题不作为 `svg_preflight.py` 的 hard blocker,但必须保留 warning 并在 live readback 后检查图片是否可见;正式交付仍优先用本地 `@./path` 自动上传或 file token。 - 不要留下模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。 ### 创建方式选择 @@ -164,7 +169,7 @@ python3 skills/lark-slides/scripts/template_tool.py extract --template `;SVG 路径使用 ``。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传,或 `+create --slides` / `+create-svg` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进图片引用」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src` / `href`。**图片最大 20 MB**(slides upload API 不支持分片上传)。 +8. **Preview 阶段图片要优先丰富,不要纯矢量兜底**:XML 路径使用 ``;SVG 路径使用 ``。推荐流程是「从用户 query / 页面主题生成图片检索词 → 网络拉取主题强相关图片 → 存成本地资产 → 用 `slides +media-upload` 上传,或 `+create --slides` / `+create-svg` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进图片引用」。Preview/MVP 阶段 `svg_preflight.py` 对 http(s) / data 图片、来源/授权不完整只 warning,不阻断;如果时间紧,可先保留公开可访问图片 URL 做视觉验证,并在 `asset_contract` 标记 `retrieval_query`、`source_url` 和 `preview_unverified`。正式交付再统一替换为本地 `@./path` 或 file token。**图片最大 20 MB**(slides upload API 不支持分片上传)。 ## 权限速查 diff --git a/skills/lark-slides/references/lark-slides-create-svg.md b/skills/lark-slides/references/lark-slides-create-svg.md index da4df8146..c75ac750a 100644 --- a/skills/lark-slides/references/lark-slides-create-svg.md +++ b/skills/lark-slides/references/lark-slides-create-svg.md @@ -96,6 +96,18 @@ SVG 创建不使用单独的规划目录。新建或大幅改写 SVG deck 时, "output_mode": "svglide-svg", "canvas": {"width": 960, "height": 540, "viewBox": "0 0 960 540"}, "safe_area": {"x": 48, "y": 40, "width": 864, "height": 460}, + "style_preset": "raw_grid", + "style_selection_reason": "raw_grid fits technical training pages that need dense but readable visual structure", + "style_system": { + "palette": { + "background": "#F5F5F5", + "text": "#0A0A0A", + "accent": "#F2D4CF" + }, + "typography": "strong title, readable native text labels", + "background_strategy": "muted grid panels with one stable background family", + "motif": "dense grid panels with restrained accent labels" + }, "svg_constraints": { "text_element": "foreignObject slide:role=shape slide:shape-type=text", "path_commands": "M/L/H/V/C/Q/Z only", @@ -106,7 +118,7 @@ SVG 创建不使用单独的规划目录。新建或大幅改写 SVG deck 时, {"page": 1, "path": ".lark-slides/plan//pages/page-001.svg"} ], "preflight": { - "command": "python3 skills/lark-slides/scripts/svg_preflight.py --input .lark-slides/plan//pages/page-001.svg", + "command": "python3 skills/lark-slides/scripts/svg_preflight.py --plan .lark-slides/plan//slide_plan.json --input .lark-slides/plan//pages/page-001.svg", "status": "pending" }, "readback_verification": { @@ -118,6 +130,138 @@ SVG 创建不使用单独的规划目录。新建或大幅改写 SVG deck 时, 模板也复用现有 `template_tool.py search -> summarize -> extract` 路由。模板摘要只用于选择主题、页面流、视觉节奏和布局骨架;生成 SVG 时要把模板结构翻译成 SVG layout boxes / visual recipes,不要照搬模板 XML,也不要读取完整模板 XML。 +SVG deck 的 `slides[]` 还必须包含这些可校验字段,避免生成结果虽然能创建但内容千篇一律、信息量不足或在资料缺失时编造事实: + +```json +{ + "page": 3, + "page_type": "content", + "renderer_id": "dashboard_scorecard", + "layout_family": "dashboard", + "visual_recipe": "fake_ui_dashboard", + "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", + "svg_effects": ["chart_geometry", "connector_flow", "typography"], + "required_primitives": ["dashboard", "micro_chart"], + "svg_primitives": ["dashboard", "micro_chart", "typography", "geometric_shape"], + "xml_like_risk": "without SVG primitives this page would degrade into three metric cards plus bullets", + "recipe_fallback": "if dashboard micro charts are too dense, keep the fake UI frame and simplify charts to bar-like rects", + "density": "high", + "density_structure": "dashboard with four metric cards, trend line, and source note", + "content_density_contract": "dashboard >= 4 metrics", + "asset_contract": "none_required | {mode: preview|production, retrieval_query, source_type, license, local_path_or_href, usage_page, source_url/generated_by, replacement_required}", + "risk_flags": ["text_overflow", "image_license", "conversion_dasharray"], + "source_status": "source_verified | attachment_missing | user_prompt_only", + "source_policy": "when attachment_missing, show 待从附件补齐 / 来源缺失 and avoid numeric claims", + "layout_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" + ] +} +``` + +### Style Preset Catalog + +SVGlide 高质量生成必须先从 [style-presets.json](style-presets.json) 选择一个 deck-level `style_preset`,并把它翻译成 `style_system`。`style_preset` 不替代 `visual_recipe`:前者定义视觉语言,后者定义页面结构和 SVG-native 表达价值。 + +生成前还必须读取 [svg-visual-recipes.md](svg-visual-recipes.md)。该文件是当前 CLI 执行链路的短规则入口,负责把研究 catalog 映射成可写入 `slide_plan.json` 的 underscore `visual_recipe` 枚举、安全效果边界和 deck 多样性要求。 + +生成顺序: + +```text +semantic plan +-> visual_recipe +-> style_preset + style_system +-> layout boxes +-> SVG source +-> svg_preflight.py --plan +``` + +`style_system` 至少包含: + +- `palette`: 背景、正文、强调色。 +- `typography`: 标题、标签、正文的字号/字重策略。 +- `background_strategy`: 全 deck 背景和例外页规则。 +- `motif`: 可复用的视觉母题,例如 grid panels、stamp labels、court lanes、riso color plates。 + +每页必须声明: + +- `visual_signature`: 这一页相对普通 XML/PPT 模板的独特 SVG 视觉记忆点。 +- `svg_effects`: 真实使用或计划使用的 SVG 表达能力,例如 `path`、`connector_flow`、`gradient`、`texture`、`chart_geometry`、`image_overlay`。 + +`svg_preflight.py` 会校验 preset 是否存在、`style_system` 是否完整、可见文本是否泄漏 preset 名称/source token/tool/path,以及 declared `svg_effects` 是否能在 SVG source 中命中。 + +### SVG-native visual recipe catalog + +SVG 不是普通矢量图文件的传输外壳。每页都必须选择一个 `visual_recipe`,并在 `svg_primitives` 中声明真实会绘制的 SVGlide-safe primitives。`renderer_id` 负责几何布局命名;`visual_recipe` 负责说明这页为什么值得走 SVG。 + +本节保留协议内置摘要;实际生成前优先读 [svg-visual-recipes.md](svg-visual-recipes.md),避免把研究文档里的 dotted recipe 名称直接写进运行时 plan。 + +| `visual_recipe` | 适用页型 | required primitives | forbidden patterns / fallback | +|---|---|---|---| +| `hero_typography` | 封面、章节页、观点页 | `typography`, `geometric_shape` | 不要只写普通标题;大字用 `foreignObject`,描边/裁切感用大字背板、路径轮廓或分层 shape 模拟 | +| `geometric_composition` | 战略框架、阶段划分、版式强分区 | `geometric_shape`, `path` | 不要只堆 3 个矩形卡片;斜切块、多边形全部用 `path` 写,不用 `polygon` | +| `path_flow` | 路线、旅程、流程、增长路径 | `path`, `annotation` | 不依赖 `marker` / `stroke-dasharray`;箭头用显式三角 `path`,虚线用短 line/dot 组合 | +| `infographic_scorecard` | 数据战报、OKR、业务复盘 | `typography`, `micro_chart` | 不要只放大数字;补环形/条形/标尺等微图表,圆环用双层填充圆或 path | +| `icon_capability_map` | 能力地图、模块总览、平台能力 | `icon`, `geometric_shape` | 图标用 SVGlide-safe path/line/rect 组合,不用外链 iconfont 或根级 `` | +| `gradient_depth` | 能力升级、概念页、氛围页 | `gradient`, `geometric_shape` | 渐变只作为层次,不能替代信息结构;关键文字必须有深色承载底 | +| `mask_clip_showcase` | 成果展示、产品/品牌视觉页 | `typography`, `image_overlay` | 不直接依赖 `mask` / `clipPath`;用大字描边、半透明 shape 遮罩、裁切安全区模拟 | +| `technical_texture` | 技术架构封面、工程系统页 | `texture`, `path` | 不用 ``;网格、点阵、扫描线用重复 line/circle/rect 显式绘制 | +| `metaphor_loop` | 闭环、反馈系统、运营机制 | `path`, `geometric_shape` | 不只画 4 个圆节点;旁边必须补机制表、KPI 标签、输入输出或责任说明 | +| `spotlight_annotation` | 问题定位、架构标注、案例诊断 | `spotlight`, `annotation` | 发光用多层半透明 circle/rect/path 模拟,不依赖复杂 filter;标注线和 callout 必须对齐目标 | +| `fake_ui_dashboard` | 产品能力、CLI/平台/监控展示 | `dashboard`, `micro_chart` | 不要把 3 张指标卡伪装成 dashboard;必须有 UI frame、状态栏、图表/日志/趋势等操作界面细节 | +| `brand_system` | 系列化 deck、主题页、收尾页 | `typography`, `geometric_shape` | 不只换颜色;必须复用标题位置、边栏、编号、强调色、图标线宽或背景 motif | + +`svg_preflight.py` 会校验 `visual_recipe` 枚举、必填字段、recipe required primitives、8 页以上 recipe family 多样性,以及 plan 声明的 primitives 是否能在 SVG source 中检测到。生成器不能只在 plan 里声明 recipe,实际仍画 XML 式卡片。 + +### 生成阶段 Fail-Fast Gate + +`slide_plan.json` 不是说明文档,而是生成阶段的硬契约。生成器必须先通过 plan gate,再渲染 SVG;本地 `svg_preflight.py --plan` 失败时禁止调用 live API。 + +每页 SVG plan 必填: + +| Field | 作用 | 失败后处理 | +|---|---|---| +| `renderer_id` | 标识具体渲染器/几何结构 | 换真实 renderer,不用 `two_column_1` 这类假命名 | +| `layout_family` | 做 deck 级版式多样性检查 | 相邻页重复时换阅读方向、主视觉位置或信息结构 | +| `visual_recipe` | 说明这页为什么值得走 SVG | 从 recipe catalog 选择,不能自造枚举 | +| `required_primitives` | 这页必须在 SVG source 中真实出现的 primitive | 至少覆盖 recipe required primitives | +| `svg_primitives` | 实际计划绘制的 primitive | 必须覆盖 `required_primitives` | +| `visual_intent` | SVG 视觉表达目的 | 写清楚 SVG-native 价值,不写空泛风格词 | +| `visual_focal_point` | 页面视觉焦点 | 用于判断布局是否围绕主视觉组织 | +| `xml_like_risk` | 退化成普通 XML 卡片页的风险 | 明确说明不用 SVG 会丢失什么结构 | +| `content_density_contract` | 信息密度硬契约 | 高密度页必须量化,例如 `dashboard >= 4 metrics` | +| `asset_contract` | 图片/素材来源与许可契约 | 无图写 `none_required`;Preview 网络图必须记录 `retrieval_query` / `source_url`,授权未确认可写 `license=preview_unverified` 且不阻断;正式交付必须补 source/license/local path 或替换 | +| `risk_flags` | 生成风险显式登记 | 无风险用空数组;不要省略字段 | +| `source_policy` | 缺数据/数字声明处理策略 | 防止自动扩写时编造业务数字 | + +deck 级硬门禁: + +- 用户未说明页数,或只说“一份 slide / 一份 PPT / 做个 slide / 生成一个 slide”这类模糊表达时,默认 `page_count=10`;不要仅因页数缺失而停下来追问。明确“一页 / 单页 / onepage / one slide / 只要封面”才按 `page_count=1`。默认 10 页必须包含 closing slide,并满足 10 页 deck 的 layout / renderer 多样性门禁。 +- 8 页以上必须有明确 closing slide。 +- 10 页以上至少 5 种 `layout_family`。 +- 不允许连续 3 页使用同一 `layout_family`。 +- 8 页以上至少 5 种 `visual_recipe` family。 +- 10 页以上至少 5 种真实 `renderer_id`。 +- 高密度页必须有量化 `content_density_contract`,不能只写“信息丰富”。 + +量化密度契约建议: + +```text +matrix/table >= 6 cells +timeline >= 4 nodes +dashboard >= 4 metrics +flow >= 4 stages +risk_grid >= 4 items +comparison >= 4 rows or columns +``` + +如果 SVG source 无法满足对应数量,`svg_preflight.py` 会报 `plan_content_density_contract_not_met`,生成器必须补真实结构,不要只改字段名。 + ### 生成前强约束 以下规则来自实际 SVGlide live 生成、回读和修复经验,生成器必须先满足这些规则,再追求视觉复杂度。 @@ -125,7 +269,15 @@ SVG 创建不使用单独的规划目录。新建或大幅改写 SVG deck 时, - MUST: 默认使用 Lark Slides 当前回读画布 `960 x 540`,即 root 写成 `width="960" height="540" viewBox="0 0 960 540"`。不要默认用 `1280 x 720`,否则服务端回读后可能整页偏大并裁切。 - 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[]` 必须记录 `layout_family`、`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: `visual_recipe` 必须来自 catalog,且 `svg_primitives` 必须覆盖该 recipe 的 required primitives。`renderer_id` 不能替代 `visual_recipe`。 +- MUST: 8 页以上 SVG deck 至少使用 5 种 visual recipe family;不能整套 deck 都是卡片、双栏或普通 dashboard。 +- MUST: 高密度页必须声明 `density_structure` 和量化 `content_density_contract`,例如 `matrix/table >= 6 cells`、`timeline >= 4 nodes`、`dashboard >= 4 metrics`、`flow >= 4 stages`、`risk_grid >= 4 items`。只有“大标题 + 大图 + 2-3 个短 chip”不算高密度。 +- MUST: 来源不足、附件缺失、用户未提供数据时,必须在 plan 中写 `source_status` 和 `source_policy`,并在页面上显式表达“待从附件补齐 / 来源缺失 / no numeric claims”。不要编造客户、排名、真实论文数据、金额、占比、链接、logo 或引用。 - MUST: `foreignObject` 文本样式使用显式 CSS:`font-size`、`font-weight`、`font-family`、`color`、`line-height`、`text-align`。不要用 `font:` shorthand 表达关键字号和加粗。 +- MUST: 白色或接近白色的文字必须完整落在深色 shape 承载底上。标题、封面副标题、CTA、页脚等不能跨出深色底,压到浅色图片、白色蒙层或白底上;需要时扩大色块、加深色背板/遮罩,或改用深色文字。 +- MUST: 圆形/椭圆节点只承载短标签,不承载解释句。节点内 `foreignObject` bbox 必须小于节点 bbox;微解释、指标、下一步和注释放到独立说明卡、图例、机制表或外侧 callout。 - MUST: 提交前和 live 回读后都检查边界和重叠:非背景元素不得越过 `960 x 540`,第 2/3 页等信息密集页必须额外检查 text bbox overlap。 - SHOULD: 如果本地预览使用更大画布,例如 `1280 x 720`,必须在输出给 `slides +create-svg` 前按比例换算为 `960 x 540`,而不是只改 root viewBox。 @@ -138,25 +290,69 @@ SVG 创建不使用单独的规划目录。新建或大幅改写 SVG deck 时, - MUST: SVG 生成 helper 的返回类型保持一致。推荐统一返回 `string`,或统一返回 `string[]` 后在页面末尾 `flat().filter(Boolean).join("\n")`;不要混用 `...items.map(...).join("\n")`,这会把已拼好的 SVG 标签按字符展开,生成非法 XML。 - MUST: 所有组件都从稳定布局盒推导坐标,避免散点手调。文本、标签、图例、曲线端点和卡片内容应有明确的父盒和对齐规则。 - MUST: 生成脚本要先写 deck plan / asset list,再写页面;不能边补坐标边生成最终 SVG。 +- MUST: 生成器要把 preflight 规则前移为本地 assert。写 SVG 前先由实际组件 manifest 反推出 `svg_primitives`,再检查 `visual_recipe` required primitives、`required_primitives`、`content_density_contract` 数量、主体 safe area、文本 bbox 和最小文本框高度;断言失败时修组件或布局,不要只改 `slide_plan.json` 字段。 +- MUST: 高密度结构要由组件实际数量驱动,例如 `scorecard >= 4 metrics` 必须生成 4 个能被识别为 metric/bar/card 的元素;`timeline >= 4 nodes` 必须生成 4 个真实节点和标签;不要用文字描述冒充结构。 +- MUST: 文本组件要按字号、行高和预估行数计算最小 `foreignObject` 高度。卡片、节点、脚注、图例的正文框不得出现 0、高度个位数或明显低于一行文字的 bbox。 +- MUST: 主体文本、卡片、图表、标签、节点和图例必须落在 safe area;全画布背景、边缘承载底、图片遮罩和装饰边框可以超出 safe area,但应只承担背景/承载作用,不承载关键文本。 - SHOULD: 对高风险页面使用更保守的留白:标题与图表标签至少相隔 24px,曲线端点标签不要压在标题/图例区域,卡片内文字与边框至少留 10-14px。 - SHOULD: 把每页的 `safe`、`titleBox`、`visualBox`、`textBox` 等布局盒保存为可检查数据,便于自动计算越界和重叠。 +推荐生成顺序: + +```text +deck/page plan +-> layout boxes +-> components with emitted primitive manifest +-> generator asserts: recipe/primitives/density/text/safe-area +-> write SVG + slide_plan.json from the same manifest +-> svg_preflight.py --plan ... +-> dry-run / live create / readback +``` + +### 本地 HTML 预览(建议) + +HTML 预览是生成阶段的轻量质检,不是 SVGlide 协议或 CLI API 的硬依赖。 + +- SHOULD: 生成 SVGlide deck 后、调用 `slides +create-svg` 前,生成一个本地 `preview.html`,把每页 SVG 按 16:9 画布嵌入,并展示页码、标题、`renderer_id` / `visual_recipe`、图片资产状态、preview-only 图片来源和明显 warning。 +- SHOULD: 如果当前 agent、IDE 或浏览器工具支持打开本地文件,打开 `preview.html` 进行人工或截图式预览,优先检查: + - 页面是否空白、明显裁切或整体偏大。 + - 标题、正文、图片和装饰元素是否重叠。 + - 白色/浅色文字是否压到浅色背景或图片亮部。 + - 相邻页面是否版式过度重复。 + - 信息密度是否明显不足,尤其是高密度页是否真的有 matrix/table/timeline/dashboard/flow/risk grid。 + - 结尾页是否存在。 + - 图片是否显示,是否有破图、空图片框、图片过少或 preview-only 来源未记录。 +- SHOULD: 在最终产物目录记录 `preview.html` 路径;如果未生成或无法打开,说明原因,并继续执行 preflight / dry-run / readback。 +- MUST NOT: 用 HTML 预览替代 `svg_preflight.py`、`slides +create-svg --dry-run` 或 live readback。HTML 预览主要提前发现审美、布局和素材问题;服务端转换后的字体、path bbox、图片 token 和部分 SVG 效果仍必须通过 readback 验证。 + +打开预览后必须按 [svg-aesthetic-review.md](svg-aesthetic-review.md) 做一次人工或截图式审查。重点看所有页面的标题区、装饰线、badge、文本框、图片框、safe area、重复版式和 SVG 视觉优势;如果多页出现同类问题,修生成规则后重新生成,不要只逐页微调坐标。 + 本地 preflight 必须在 `slides +create-svg` 前执行,失败即停: -- `python3 skills/lark-slides/scripts/svg_preflight.py --input page-*.svg` 通过;如果脚本不可用,再退回 `xmllint --noout page-*.svg` 加人工检查。 +- `python3 skills/lark-slides/scripts/svg_preflight.py --plan .lark-slides/plan//slide_plan.json --input page-*.svg` 通过;如果脚本不可用,再退回 `xmllint --noout page-*.svg` 加人工检查。 - root 是 `width="960" height="540" viewBox="0 0 960 540"`。 - root / leaf `slide:role` 完整,所有 leaf 有几何必填属性。 -- 禁止 `font:` shorthand、http(s) / data URL 图片、未下载的远程图片、空图片框。 +- plan 中每页 `layout_family`、`visual_recipe`、`visual_intent`、`visual_focal_point`、`required_primitives`、`svg_primitives`、`xml_like_risk`、`content_density_contract`、`risk_flags`、`source_policy` 完整,且 recipe required primitives 能在对应 SVG source 中命中。`asset_contract` 在 MVP 阶段缺失只 warning;有条件时仍应补全。 +- 禁止 SVG 退化成 XML-like 卡片页:如果页面基本只有 `rect + foreignObject`,且没有 path、gradient、image overlay、annotation、micro chart、icon、texture、spotlight、flow 等 SVG-native primitive,preflight 必须失败。 +- 禁止零尺寸元素;文本框、图片、卡片和圆/椭圆必须有正向宽高,不能生成 `height="0"` 的隐藏说明。 +- `` 或图片 style 里写 `opacity:` 在 MVP 阶段只 warning;当前转换链路不会稳定保留到 readback ``。需要淡化图片时,优先把透明度预合成进 PNG/JPG,或在图片上方加半透明 `rect` 遮罩。 +- 禁止白色/浅色文字跨出深色承载底;如果 preflight 报 `light_text_without_dark_backing`,优先扩大深色背景或加文本背板,不要只缩小字号。 +- 禁止把解释文字塞进圆形/椭圆节点;如果 preflight 报 `node_text_overflow`,节点内只保留短标签,把说明迁移到旁边卡片、表格或图例。 +- 警惕 `circle` / `ellipse` 的 `stroke-width`;当前转换链路可能只保留 border color 而丢失 width。关键圆环、节点外圈和粗描边用双层填充圆/椭圆模拟,或改成 path/rect。 +- 禁止关键路线、闭环、流程连接、timeline rail 使用 `stroke-dasharray`;普通装饰虚线也会 warning。关键路线必须用显式短线段或小圆点 markers 组成,不要把虚线作为唯一视觉表达。 +- 禁止 `font:` shorthand 和空图片框。MVP 阶段 http(s) / data URL 图片、未下载远程图片只 warning;正式交付和可见性要求高的 deck 仍应下载到本地并走 `@./path` 上传或使用 file token。 - 禁止 unsupported path command;`path d` 只含 `M/L/H/V/C/Q/Z`。 - 非背景元素不得越界;主体元素应在 safe area 内。 - 文本框做 bbox overlap 近似检查,尤其是目录、痛点、竞品表、案例图表和总结页。 -- 图片资产文件存在、大小合理、授权来源清单完整。 +- 图片资产文件存在、大小合理,或 http(s)/data URL 能在 preview 中显示。Preview 阶段来源/授权不完整只 warning,但必须用 `asset_contract.license=preview_unverified` 或 `risk_flags=["image_preview_only"]` 显式标记;正式交付再补齐来源/授权或替换。 +- deck plan 通过 renderer 多样性、layout family 多样性、closing slide、高密度结构、资产契约、来源保护六类校验。 创建顺序: ```text generate deck plan -> generate assets -> generate SVG files --> local preflight -> lark-cli slides +create-svg --dry-run +-> optional preview.html and browser preview when supported +-> local preflight with --plan -> lark-cli slides +create-svg --dry-run -> live create -> xml_presentations get readback -> readback bbox / text overlap / closing slide checks ``` @@ -175,7 +371,7 @@ readback 不能省略。服务端会把 SVGlide 转成 Slides XML,文字 bbox "audience": "who will read it", "goal": "what the deck should make the audience understand or decide", "density_strategy": "how low/medium/high density pages are distributed", - "asset_strategy": "which real images are needed, where they will be used, copyright/license source, and fallback if unavailable", + "asset_strategy": "which query/topic-related web images should be searched and fetched, where they will be used, preview source/url/license risk, and production replacement plan if needed", "visual_rhythm": "how layout, imagery, charts, and text density vary across pages", "slides": [ { @@ -204,8 +400,10 @@ risk, tradeoff, summary, closing, q-and-a, appendix 密度规则: - MUST: 每页都要有明确 `takeaway`,即使是封面、分隔页和结束页。 -- MUST: 每个 SVG deck 默认都要包含真实图片资产,不要全程只用矢量 shape 冒充“配图”。展示型、宣传型、产品型、品牌型和案例型 deck 至少包含 3 处图片使用,其中至少 1 页使用全幅或半出血图片主视觉。 +- MUST: 每个 SVG deck 默认都要包含真实图片资产,不要全程只用矢量 shape 冒充“配图”。Preview 阶段应优先根据用户 query、deck 标题和页面主题去网络检索并拉取强相关图片,再补充产品截图、网页截图、场景图、材质纹理、图鉴图和 AI 生成图增强视觉冲击;展示型、宣传型、产品型、品牌型和案例型 deck 至少包含 3 处图片使用,其中至少 1 页使用全幅或半出血图片主视觉。 - MUST: 高密度页必须有承载信息的视觉结构,例如矩阵、流程、地图、时间线、标注图、案例卡或手绘微图表,不能只有装饰图形。 +- MUST: 生成器必须先扩写页面“结构信息”,再绘制 SVG。信息密度不足时,优先补结构化解释层,例如编号标签、微解释、比较维度、轴线、图例、阶段、来源状态、下一步,而不是把同一句话换写成多个 chip。 +- MUST: 流程页、闭环页、机制页和产品体系页不能只有“4 个圆节点 + 短标签”。至少补 1 层结构化信息,例如机制表、KPI 标签、触发条件、责任/频率、输入输出、风险提示或下一步动作。 - SHOULD: 高密度内容页通常包含 3-6 个信息块和若干可读细节,但 executive brief、品牌页、产品视觉页、短汇报可以降低数量,只保留强结论、关键证据和视觉锚点。 - SHOULD NOT: 不要让所有高密度页长成同一种“主结论 + 3-6 卡片 + 3 个 callout”模板。 - MUST NOT: 缺少素材或数据时不要编造数字、客户名、logo、排名、引用或真实案例;用 qualitative label、relative scale、hypothesis/assumption 标注兜底。 @@ -263,7 +461,7 @@ notesGrid = x:54 y:430 w:760 h:48 ### 文本安全余量 -`foreignObject` 文本优先使用显式 CSS。为了服务端转换到 SXSD/XML 后保留样式,字号、加粗、颜色、行距和对齐必须写成独立属性;不要把关键样式藏在 `font:` shorthand 或只写在复杂外层 wrapper 上: +`foreignObject` 文本优先使用显式 CSS。为了服务端转换后保留样式,字号、加粗、颜色、行距和对齐必须写成独立属性;不要把关键样式藏在 `font:` shorthand 或只写在复杂外层 wrapper 上: ```xml @@ -281,6 +479,8 @@ notesGrid = x:54 y:430 w:760 h:48 - 小型标签文本盒不小于 14px。 - 多行文字要按行高预估高度,再额外留 8-12px。 - 右侧图例或矩阵格里的文字不得贴边,水平 padding 至少 10-14px。 +- 白色/浅色文字的 bbox 必须完全落在深色 rect/card/overlay 内;封面标题如果跨出色块,应优先扩大色块或改成深色字,不要让白字压在浅色图片或白色蒙层上。 +- 圆形/椭圆节点内只放短标签,解释文字移动到节点外的 callout、legend 或机制表;不要让圆内文本框宽度超过圆形直径。 - 服务端支持 `foreignObject` 内的 `
`。为了本地预览和标题排版稳定,标题/大段文本优先使用多个块级 `div` 或 `p` 控制行高,不要只靠 `
` 调整复杂布局。 - 如果需要垂直居中,优先通过更准确的文本框高度、段落行高和 y 坐标解决;布局 wrapper 可以使用,但实际文字节点仍要带显式 `font-size` / `font-weight` / `color`。 @@ -309,37 +509,46 @@ Transform 参数同样使用数字或 `px`。不要写 `translate(10%, 20%)`, 相邻页面至少改变一个主结构维度:主视觉位置、网格列数、图片用法、文本密度或阅读方向。 -### 图片使用 +### 图片使用与 Preview Image Mode -默认必须规划和使用图片资产,并规避版权风险。图片必须来自用户提供、公司/项目自有、明确可商用授权图库,或授权条件清晰的 AI 生成资产;不要使用版权状态不明的图片、logo、截图、新闻配图、竞品官网图或搜索引擎随手抓取的素材。正确流程是先下载或生成到本地,再写成本地占位符: +默认必须规划和使用图片资产。Preview 阶段的目标是验证 SVGlide 的视觉表达上限,版权/授权不作为阻断条件;不要因为 license 未确认就退回纯矢量或低信息卡片页。推荐先从用户 query、deck 标题、章节标题和页面 takeaway 生成 2-5 个图片检索词,去网络检索并拉取主题强相关图片;再补充网页截图、产品截图、图库图、新闻/历史/艺术/科普图片、材质纹理或 AI 生成图做占位视觉。必须在 plan / README 里记录 `retrieval_query`、来源 URL,或标记 `license=preview_unverified`,并避免明显不适当素材、敏感肖像和会造成商业背书误导的 logo/商标。正式交付时,再统一替换为用户提供、公司/项目自有、明确可商用授权图库,或授权条件清晰的 AI 生成资产。 + +最稳流程仍然是先下载或生成到本地,再写成本地占位符: ```xml ``` -图片不只用于局部卡片背景,也可以作为整页背景、半出血主视觉、材质纹理、案例示例、产品截图、数据仪表截图或图鉴封面。作为整页背景时,必须叠加半透明遮罩或暗角,保证标题和正文对比度。 +推荐的网络拉图流程: + +1. 从用户 query、deck title、page takeaway、章节标题中提取 `retrieval_query`,优先使用具体名词、场景、人物、作品、产品、地点、历史事件或学科对象,避免只搜抽象词。 +2. 对封面、章节过渡页、案例页、教学解释页和产品/品牌页优先执行网络图片搜索或网页截图获取,选择和主题直接相关的真实图片,不用无关风景图凑数。 +3. 能下载时先保存到 `assets/` 并用 `@./assets/...` 引用;来不及下载时可以先保留 http(s) URL 进入 preview,但 live/readback 后必须确认可见。 +4. 每张图在 `asset_contract` 记录 `retrieval_query`、`source_type`、`source_url`、`retrieved_at`、`license=preview_unverified`、`usage_page`、`replacement_required=true`。 +5. 网络不可用或无法找到强相关图片时,才退回 AI 生成图、程序化纹理或纯 SVG 视觉,并在 `risk_flags` 写 `network_image_fetch_unavailable`。 + +图片不只用于局部卡片背景,也可以作为整页背景、半出血主视觉、材质纹理、案例示例、产品截图、数据仪表截图、网页/应用界面截图、人物/场景图、图鉴封面、历史/艺术/科学素材或产品细节局部。作为整页背景时,必须叠加半透明遮罩或暗角,保证标题和正文对比度。 图片数量与用法建议: -- MUST: 在 `asset_strategy` 或产物 README 中记录图片来源、授权/许可类型、下载 URL 或生成方式;无法确认授权时不得使用。 -- MUST: 5 页以上 deck 至少使用 1 张真实图片;8 页以上 deck 至少使用 2 张;宣传/产品/品牌/案例型 deck 至少使用 3 张。 +- MUST: 在 `asset_strategy` 或产物 README 中记录图片检索词、图片来源、授权/许可类型、下载 URL 或生成方式;Preview 阶段无法确认授权时写 `license=preview_unverified` 和 `replacement_required=true`,preflight 不阻断,最终交付应替换为可授权资产。 +- MUST: 5 页以上 deck 至少使用 2 张真实图片;8 页以上 deck 至少使用 4 张;宣传/产品/品牌/案例/教学型 deck 至少使用 5 张或至少 40% 页面含图片。 - MUST: 封面优先使用图片或图片+抽象图形混合主视觉,不要只用网格、光效和几何背景。 - MUST: 案例页优先使用行业场景图、产品截图、仪表盘截图或真实质感背景,并叠加数据 callout。 -- SHOULD: 同一 deck 中混用全幅背景、半出血图片、卡片图、纹理/材质背景和标注型截图,避免所有图片都只是小卡片背景。 -- MUST NOT: 保留空图片框、破图、http(s) 外链或 data URL。素材不可用时要重新获取/生成,或在最终说明中明确为什么退回矢量。 +- MUST: 同一 deck 中混用全幅背景、半出血图片、卡片图、纹理/材质背景、标注型截图、图鉴式小图和局部裁切特写,避免所有图片都只是小卡片背景。 +- SHOULD: 对教育、历史、艺术、医学、产品讲解等主题,优先用图片建立具象认知:人物、器物、场景、局部特写、对比图、流程截图、资料封面或时间背景图。 +- MUST NOT: 保留空图片框或破图。Preview/MVP 阶段允许 http(s) 外链或 data URL 先进入 preflight warning,但 live/readback 后必须确认可见;正式交付应替换为本地 `@./path` 或 file token。 -优先使用这些来源,但每张图仍必须检查并记录具体页面上的授权信息: +Preview 阶段优先使用这些来源来快速获得丰富视觉;正式交付时再逐图确认授权、署名和替换计划: -| Source | 适合用途 | 规则 | +| Source | 适合用途 | Preview 规则 | |--------|----------|------| -| Unsplash | 高质量摄影、封面背景、场景图 | 可商用图库;记录图片页 URL 和 license | -| Pexels | 商务、科技、生活类配图 | 可商用图库;记录图片页 URL 和 license | -| Pixabay | 图片、插画、视频、音频 | 可商用图库;避开人物/品牌/商标误导 | -| Openverse | CC / Public Domain 搜索 | 每张图 license 不同;按单图要求署名 | -| Wikimedia Commons | 百科、历史、技术、公共领域素材 | 每张图 license 不同;常见需要署名 | -| The Met Open Access | 艺术品、历史图像、文化视觉 | 仅使用 Open Access / CC0 条目 | -| Smithsonian Open Access | 博物馆、科学、历史、2D/3D 资产 | 仅使用 Open Access / CC0 条目 | -| NASA Image and Video Library | 太空、科技、地球、航天视觉 | 避开 NASA 标识商业背书、人物肖像和第三方权利 | +| Web image search / topic query | 和用户 query、页面主题、作品/人物/地点/产品直接相关的真实图片 | 优先使用;记录 `retrieval_query`、图片页 URL 和 `preview_unverified`,正式交付再确认或替换 | +| Unsplash / Pexels / Pixabay | 高质量摄影、封面背景、场景图 | 结合主题 query 检索;记录图片页 URL;license 可先写 `preview_unverified`,正式交付再确认 | +| Openverse / Wikimedia Commons | 百科、历史、技术、公共领域素材 | 记录单图 URL 和作者/页面;preview 可先用,正式交付补 license / attribution | +| The Met / Smithsonian / NASA Open Access | 艺术、科学、历史、航天视觉 | 记录条目 URL;preview 可先用,正式交付确认 Open Access / 第三方权利 | +| 官网 / 产品页 / 新闻图 / 搜索图 | 产品截图、竞品页、事件背景、真实语境 | Preview 可作为视觉占位;必须标记 `license=preview_unverified`,正式交付替换或删去 | +| AI 生成图 / 程序化纹理 | 抽象背景、材质、概念图 | 记录生成方式和提示词摘要;正式交付确认模型/平台授权 | 素材清单建议字段: @@ -347,11 +556,15 @@ Transform 参数同样使用数字或 `px`。不要写 `translate(10%, 20%)`, { "local_path": "./assets/hero.jpg", "source": "Unsplash", + "retrieval_query": "Beethoven Symphony No. 5 concert hall orchestra", "source_url": "https://...", - "license": "Unsplash License", - "commercial_use": true, + "retrieved_at": "2026-06-08", + "license": "preview_unverified", + "commercial_use": "unknown_in_preview", + "replacement_required": true, "attribution_required": false, - "notes": "No recognizable trademark or misleading endorsement" + "usage_page": 1, + "notes": "Preview-only visual placeholder; replace or verify license before production delivery" } ``` @@ -374,8 +587,20 @@ Transform 参数同样使用数字或 `px`。不要写 `translate(10%, 20%)`, - 局部标注、刻度、坐标轴、图例。 - 行业标签、材料纹理、指标卡。 - 路线节点、连接线、层级分区。 +- 流程/闭环图旁边补机制表或说明卡,例如“触发条件 / 运营动作 / 衡量指标”,不要把说明句塞进圆形节点内部。 - 小型表格、雷达/柱状/散点等微图表。 +### 转换稳定性经验 + +这些规则来自 live 创建后对比 source SVG 与 readback XML 的结果,属于生成侧必须规避的转换差异: + +- `image opacity` 不稳定:本地 SVG 里的 `` / `` 可能会在 readback `` 中丢失透明度。MVP preflight 只 warning;生成器仍应把淡化效果烘焙进图片本身,或使用半透明 shape 遮罩。 +- shape opacity 稳定:`rect`、`circle`、`path` 等 shape 的 `opacity` 会转换为 XML `alpha`,可用于蒙层、暗角和装饰层。 +- circle / ellipse stroke width 不稳定:圆形/椭圆描边可能只保留颜色、不保留宽度。关键外圈使用“外层有色圆 + 内层背景圆”的双 shape ring,或用 path 绘制;不要用单个 stroked circle 承载关键视觉。 +- dashed stroke 不稳定:`stroke-dasharray` 可能降级,尤其是自定义 path 的虚线闭环。关键路线用短 line segment 或 filled dot markers 手工排布;普通装饰虚线也要经 readback 复核。 +- path 会转换为 `type="custom"` 并做 bbox 内坐标归一化,这是预期行为;只要 readback bbox 和视觉位置正确,不算差异。 +- 字体会被转换为服务端支持字体,例如 `Noto Sans` / `思源黑体`,因此生成阶段要给 `foreignObject` 留足高度,不要按浏览器本地字体做极限排版。 + ### 生成后检查 生成脚本或人工复核必须检查: @@ -393,7 +618,7 @@ Transform 参数同样使用数字或 `px`。不要写 `translate(10%, 20%)`, - 每页是否有明确 takeaway;高密度页的视觉结构是否承载信息,而不只是装饰。 - 内容页是否避免了“大标题 + 大图 + 2-3 个短 chip”的低信息布局。 - 自称数据、排名、客户、引用、logo 或案例时,是否有来源;没有来源时是否改为定性或假设表达。 -- 图片是否已变成本地 `@./path` 或 file token,不能保留 http(s) / data URL。 +- 图片是否足够丰富并可见;如果 Preview/MVP 阶段暂时保留 http(s) / data URL 或 `preview_unverified` 来源,要记录 warning、确认 live/readback 可见,并在正式交付前列出替换项。 验证记录建议写回 `.lark-slides/plan//slide_plan.json` 的 `readback_verification` 字段,并在最终回复中简述: @@ -403,7 +628,7 @@ Transform 参数同样使用数字或 `px`。不要写 `translate(10%, 20%)`, - Dry-run:已确认 create presentation + N 次 /slide。 - Readback:实际页数 N / 预期 N;未发现空白页、破图或缺失 closing slide。 - 版式:检查 safe area、文本重叠、越界和相邻页版式变化。 -- 资产:图片均为本地 @path 自动上传或 file token,无 http(s)/data URL。 +- 资产:Preview 阶段优先丰富图片和 readback 可见性;若保留 http(s)/data URL 或 `preview_unverified` 来源,必须记录 warning。正式交付再替换为本地 @path 自动上传或 file token,并补齐授权。 ``` ## 错误处理 @@ -435,7 +660,8 @@ Transform 参数同样使用数字或 `px`。不要写 `translate(10%, 20%)`, 1. 本地 preflight 已失败:修对应 SVG 文件,不要调用 live API。 2. live 添加页失败且带 `svglide_error`:按 `type` / `tag_name` / `hint` 收敛 SVG 子集,例如降级复杂 filter、path、CSS 或文本结构。 3. plain XML 在同一路由成功但 SVG 失败:优先确认目标 server lane 是否部署了 SVGlide parser,不要盲目重写整套 deck。 -4. 已创建 presentation 或部分页面时,默认保留现场并回读确认;是否删除空 presentation 必须单独由用户确认。 +4. SVG 通过本地 preflight 且失败在第 1 页,服务端只返回 generic `nodeServer invalid param`:优先检查 `lark-cli` 环境、代理和 PPE/BOE lane 是否命中目标 slide server。不要先把已通过协议校验的 deck 改回低质量 SVG。 +5. 已创建 presentation 或部分页面时,默认保留现场并回读确认;是否删除空 presentation 必须单独由用户确认。 ### 编辑已创建的 SVG deck diff --git a/skills/lark-slides/references/planning-layer.md b/skills/lark-slides/references/planning-layer.md index 51731fa03..54071b37f 100644 --- a/skills/lark-slides/references/planning-layer.md +++ b/skills/lark-slides/references/planning-layer.md @@ -6,7 +6,7 @@ ## Required Flow -1. 理解用户需求,必要时澄清主题、受众、页数、风格。 +1. 理解用户需求,必要时澄清主题、受众、页数、风格。SVGlide 新建 deck 如果用户未说明页数,或只说“一份 slide / 一份 PPT / 做个 slide / 生成一个 slide”等模糊表达,默认按 10 页写入 `page_count` / `target_slide_count`,不要仅因页数缺失而停下来追问;只有明确“一页 / 单页 / onepage / one slide / 只要封面”才按 1 页。 2. 如果适合模板,先用 `template_tool.py search` 检索,锁定模板后用 `summarize` 获取主题和页型信息。 3. 选择唯一 plan 目录:`.lark-slides/plan//`。 4. 先创建目录:`mkdir -p .lark-slides/plan/`。 @@ -67,6 +67,18 @@ Exception: "accent": "Used only for key numbers, conclusions, or focus markers." } }, + "style_preset": "raw_grid", + "style_selection_reason": "raw_grid fits technical training pages that need dense but readable visual structure", + "style_system": { + "palette": { + "background": "#F5F5F5", + "text": "#0A0A0A", + "accent": "#F2D4CF" + }, + "typography": "strong title, readable native text labels", + "background_strategy": "muted grid panels with one stable background family", + "motif": "dense grid panels with restrained accent labels" + }, "typography_constraints": { "title_max_lines": 2, "body_max_lines_per_box": 2, @@ -107,6 +119,9 @@ Top-level fields: - `audience`: target readers or listeners and their assumed background. - `theme_style`: visual tone, palette direction, and professional style. - `visual_system`: deck-level visual rules that must stay stable across pages, including background strategy, recurring motif, and color roles. +- `style_preset`: required for SVGlide SVG decks. Choose one id from `references/style-presets.json`; omit only for non-SVG XML/SXSD plans. +- `style_selection_reason`: required for SVGlide SVG decks. Explain why the preset fits the audience, topic, density, and expected tone. +- `style_system`: required for SVGlide SVG decks. Translate the selected preset into concrete palette, typography, background strategy, and motif rules. This is separate from `visual_system`: `visual_system` describes the deck identity, while `style_system` records the executable style preset translation. - `typography_constraints`: deck-level limits for line count, text box density, and how to handle long text before XML generation. - `verification_plan`: explicit checks to perform after creation or major edits; include background consistency, text fit, visual focus, and asset rendering when relevant. - `slides`: ordered page plans. @@ -122,6 +137,14 @@ Each slide must include: - `text_density`: `low`, `medium`, or `high`. - `speaker_intent`: why the speaker needs this page and how it advances the story. +SVGlide SVG slides must also include: + +- `visual_recipe`: the SVG-native page recipe, such as `path_flow`, `technical_texture`, or `fake_ui_dashboard`. +- `visual_signature`: the page's distinctive SVG visual memory point compared with a normal XML/PPT template. +- `svg_effects`: canonical effect names actually used or planned, such as `path`, `connector_flow`, `gradient`, `texture`, `chart_geometry`, or `image_overlay`. +- `required_primitives` and `svg_primitives`: the planned SVGlide-safe primitives that must be present in the SVG source. +- `xml_like_risk`, `content_density_contract`, `risk_flags`, and `source_policy`: quality and source-safety fields consumed by `svg_preflight.py --plan`. + ## Layout Vocabulary Use one of these `layout_type` values unless the user explicitly needs a custom structure: diff --git a/skills/lark-slides/references/style-presets.json b/skills/lark-slides/references/style-presets.json new file mode 100644 index 000000000..7d2295ff5 --- /dev/null +++ b/skills/lark-slides/references/style-presets.json @@ -0,0 +1,542 @@ +{ + "version": "2026-06-10", + "source": "beautiful-feishu-whiteboard", + "canvas": "960x540", + "selection_rule": [ + "Choose intensity first: Restrained for quiet/formal decks, Balanced for most business or training decks, Bold for poster-like or high-energy decks.", + "Use preset style tokens to shape palette, panel treatment, connector density, typography scale, and texture.", + "Do not copy raw whiteboard nodes, raw coordinates, source prompts, source file paths, tool names, source tokens, or preset names into visible slide content." + ], + "groups": { + "Restrained": {"expected_count": 9, "use_when": "Serious, quiet, editorial, institutional, or text-first decks."}, + "Balanced": {"expected_count": 15, "use_when": "General business, technical, educational, and explanatory decks."}, + "Bold": {"expected_count": 11, "use_when": "Posters, showcases, events, playful explainers, and high-energy visual impact."} + }, + "presets": [ + { + "style_id": "avocado_press", + "display_name": "Avocado Press", + "group": "Restrained", + "source_token": "TIBNwZ6fLhfPh1bZlAQuFRnFswW", + "formality": "high", + "vibe": ["editorial", "fresh", "structured"], + "best_for": ["agenda.structured", "process.flow", "quote.insight"], + "avoid_for": ["cover.hero"], + "palette": {"background": "#FFFFFF", "text": "#1F2329", "muted": "#0055A4", "accent": "#DCF4A2", "support": ["#0055A4"]}, + "shape_language": {"panel_treatment": "editorial blocks with bright accent labels", "corner_radius": "low", "border_weight": "medium", "texture": "clean print-like structure"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 59, "source_text": 39, "source_shapes": 9, "source_connectors": 11}}, + "slide_translation": {"recommended_layouts": ["agenda", "process", "quote"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Keep text native and use explicit connector lines for process structure."}, + "quality_oracle": {"expected_style_signals": ["blue structural labels", "avocado accent", "editorial spacing"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 2}} + }, + { + "style_id": "grove", + "display_name": "Grove", + "group": "Restrained", + "source_token": "IOCVwTYCYhhUj9bbkAwuncDTslf", + "formality": "high", + "vibe": ["institutional", "organic", "calm"], + "best_for": ["architecture.layered", "section.divider", "process.flow"], + "avoid_for": ["brand_system"], + "palette": {"background": "#E8E4D6", "text": "#192B1B", "muted": "#D4CFBF", "accent": "#C8524A", "support": ["#DEDAD0"]}, + "shape_language": {"panel_treatment": "soft editorial panels with deep green hierarchy", "corner_radius": "low", "border_weight": "light", "texture": "warm paper bands"}, + "density": {"text_density": "medium", "label_density": "high", "connector_density": "low", "node_budget": {"source_nodes": 62, "source_text": 44, "source_shapes": 13, "source_connectors": 2}}, + "slide_translation": {"recommended_layouts": ["architecture", "section", "process"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use grouped green panels and sparse connectors rather than dense arrows."}, + "quality_oracle": {"expected_style_signals": ["deep green title mass", "muted paper background", "small warm accent"], "warning_thresholds": {"text_boxes_max": 26, "accent_colors_max": 2}} + }, + { + "style_id": "jade_lens", + "display_name": "Jade Lens", + "group": "Restrained", + "source_token": "T0eswEvY1h6uSZbbt1FujZp0sZf", + "formality": "high", + "vibe": ["research", "clean", "knowledge"], + "best_for": ["timeline.roadmap", "architecture.layered", "agenda.structured"], + "avoid_for": ["cover.hero"], + "palette": {"background": "#F5F1EE", "text": "#0E5A3C", "muted": "#2BA483", "accent": "#2CAE8C", "support": ["#FFFFFF"]}, + "shape_language": {"panel_treatment": "jade framed cards with layered labels", "corner_radius": "medium", "border_weight": "medium", "texture": "lens-like panels"}, + "density": {"text_density": "medium", "label_density": "high", "connector_density": "medium", "node_budget": {"source_nodes": 72, "source_text": 44, "source_shapes": 19, "source_connectors": 9}}, + "slide_translation": {"recommended_layouts": ["timeline", "architecture", "agenda"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Keep jade labels as native text; simplify nested frames when crowded."}, + "quality_oracle": {"expected_style_signals": ["jade green framing", "white content panels", "medium connector scaffolding"], "warning_thresholds": {"text_boxes_max": 28, "accent_colors_max": 2}} + }, + { + "style_id": "long_table", + "display_name": "Long Table", + "group": "Restrained", + "source_token": "VrJhwVUTwhjU2zbBpI7uEjy2szg", + "formality": "high", + "vibe": ["procedural", "responsibility", "planning"], + "best_for": ["table.visual-summary", "process.flow", "timeline.roadmap"], + "avoid_for": ["cover.hero"], + "palette": {"background": "#FAF1E2", "text": "#1F2329", "muted": "#F2E5CF", "accent": "#B53D2A", "support": ["#FFFFFF"]}, + "shape_language": {"panel_treatment": "long horizontal table bands with clear separators", "corner_radius": "low", "border_weight": "medium", "texture": "tabular rows"}, + "density": {"text_density": "high", "label_density": "high", "connector_density": "high", "node_budget": {"source_nodes": 69, "source_text": 40, "source_shapes": 9, "source_connectors": 17}}, + "slide_translation": {"recommended_layouts": ["table", "process", "timeline"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Prefer table/grid native shapes; connector count can be reduced if slide becomes cramped."}, + "quality_oracle": {"expected_style_signals": ["long horizontal rows", "red emphasis", "explicit flow connectors"], "warning_thresholds": {"text_boxes_max": 28, "accent_colors_max": 2}} + }, + { + "style_id": "macchiato", + "display_name": "Macchiato", + "group": "Restrained", + "source_token": "Jhl9w3gZghgXzeb6WLwu44VXsMg", + "formality": "high", + "vibe": ["quiet", "editorial", "warm"], + "best_for": ["quote.insight", "section.divider", "comparison.two-column"], + "avoid_for": ["dashboard.kpi-grid"], + "palette": {"background": "#EDE7DD", "text": "#25211B", "muted": "#9A917F", "accent": "#6E6558", "support": ["#FFFFFF"]}, + "shape_language": {"panel_treatment": "minimal editorial blocks with low contrast", "corner_radius": "low", "border_weight": "light", "texture": "coffee paper tone"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 48, "source_text": 37, "source_shapes": 5, "source_connectors": 6}}, + "slide_translation": {"recommended_layouts": ["quote", "section", "comparison"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use whitespace and typographic hierarchy instead of many decorative nodes."}, + "quality_oracle": {"expected_style_signals": ["low contrast warm neutrals", "large quiet text", "few panels"], "warning_thresholds": {"text_boxes_max": 20, "accent_colors_max": 1}} + }, + { + "style_id": "monochrome", + "display_name": "Monochrome", + "group": "Restrained", + "source_token": "ApDnwnul9hlwg8b4Jl1uDweAs2c", + "formality": "high", + "vibe": ["serious", "technical", "minimal"], + "best_for": ["architecture.layered", "quote.insight", "agenda.structured"], + "avoid_for": ["brand_system"], + "palette": {"background": "#FAFADF", "text": "#1A1A16", "muted": "#8A8A80", "accent": "#5E5E54", "support": ["#FFFFFF"]}, + "shape_language": {"panel_treatment": "monochrome rules and restrained blocks", "corner_radius": "low", "border_weight": "light", "texture": "minimal editorial grid"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "medium", "node_budget": {"source_nodes": 48, "source_text": 37, "source_shapes": 3, "source_connectors": 8}}, + "slide_translation": {"recommended_layouts": ["architecture", "quote", "agenda"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Rely on black/gray hierarchy and simple connectors; avoid fake color accents."}, + "quality_oracle": {"expected_style_signals": ["black text hierarchy", "single neutral accent", "minimal blocks"], "warning_thresholds": {"text_boxes_max": 22, "accent_colors_max": 1}} + }, + { + "style_id": "papier_bleu", + "display_name": "Papier Bleu", + "group": "Restrained", + "source_token": "HWi5woaS8h1D4EbKutnulYWdsWc", + "formality": "medium", + "vibe": ["blueprint", "clear", "knowledge"], + "best_for": ["process.flow", "timeline.roadmap", "image.story"], + "avoid_for": ["brand_system"], + "palette": {"background": "#FAF3EB", "text": "#1A3C8F", "muted": "#4FB8D8", "accent": "#72D0E9", "support": ["#FFFFFF"]}, + "shape_language": {"panel_treatment": "blue paper panels with airy blocks", "corner_radius": "medium", "border_weight": "medium", "texture": "light blueprint geometry"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 64, "source_text": 38, "source_shapes": 22, "source_connectors": 4}}, + "slide_translation": {"recommended_layouts": ["process", "timeline", "image"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use blue diagram cards and short labels; avoid overusing cyan fills."}, + "quality_oracle": {"expected_style_signals": ["blue structural panels", "cream paper base", "clear diagram labels"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 2}} + }, + { + "style_id": "reading_room", + "display_name": "Reading Room", + "group": "Restrained", + "source_token": "Wx8Ow5ThFhDn5Lb1VFzu5bDEskD", + "formality": "high", + "vibe": ["paper", "training", "reading"], + "best_for": ["quote.insight", "section.divider", "paper.explainer"], + "avoid_for": ["fake_ui_dashboard"], + "palette": {"background": "#F6EBD8", "text": "#0B0A09", "muted": "#F1DAB1", "accent": "#DE916A", "support": ["#D6C7CC"]}, + "shape_language": {"panel_treatment": "reading-card panels with warm paper blocks", "corner_radius": "low", "border_weight": "light", "texture": "bookish paper"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "medium", "node_budget": {"source_nodes": 54, "source_text": 38, "source_shapes": 6, "source_connectors": 7}}, + "slide_translation": {"recommended_layouts": ["quote", "section", "paper"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use warm panels and pull quotes; keep connectors secondary."}, + "quality_oracle": {"expected_style_signals": ["book paper background", "warm orange accent", "quiet reading hierarchy"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 2}} + }, + { + "style_id": "salmon_stamp", + "display_name": "Salmon Stamp", + "group": "Restrained", + "source_token": "IITLwQQ7Vhj0lzbBmhvuvtDWs4c", + "formality": "medium", + "vibe": ["stamp", "training", "emphasis"], + "best_for": ["section.divider", "kpi.big-number", "agenda.structured"], + "avoid_for": ["technical_texture"], + "palette": {"background": "#FFFFFF", "text": "#000000", "muted": "#F0AE9E", "accent": "#049550", "support": ["#F0AE9E"]}, + "shape_language": {"panel_treatment": "stamp-like salmon labels with green accents", "corner_radius": "low", "border_weight": "medium", "texture": "print stamp blocks"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 60, "source_text": 40, "source_shapes": 9, "source_connectors": 11}}, + "slide_translation": {"recommended_layouts": ["section", "kpi", "agenda"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Keep stamp labels editable; use green only for key emphasis."}, + "quality_oracle": {"expected_style_signals": ["salmon blocks", "black type", "green action accent"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 2}} + }, + { + "style_id": "apricot_arc", + "display_name": "Apricot Arc", + "group": "Balanced", + "source_token": "JvQewyVpphPc2MbOD4xuiTD5sbd", + "formality": "medium", + "vibe": ["warm", "motion", "roadmap"], + "best_for": ["timeline.roadmap", "process.flow", "cover.hero"], + "avoid_for": ["monochrome.audit"], + "palette": {"background": "#FFF8EE", "text": "#7A4A33", "muted": "#F9C2BD", "accent": "#C7561E", "support": ["#F69834"]}, + "shape_language": {"panel_treatment": "warm arcs and rounded progression panels", "corner_radius": "medium", "border_weight": "medium", "texture": "arc motion geometry"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 76, "source_text": 37, "source_shapes": 23, "source_connectors": 16}}, + "slide_translation": {"recommended_layouts": ["timeline", "process", "cover"], "svglide_primitives": ["path", "connector_flow", "geometric_shape"], "fallback_policy": "Use explicit arc paths and staged labels; simplify connectors if crowded."}, + "quality_oracle": {"expected_style_signals": ["apricot arcs", "warm motion path", "stage progression"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 3}} + }, + { + "style_id": "berry_pop", + "display_name": "Berry Pop", + "group": "Balanced", + "source_token": "JAcFwmlcIh8NKNbJOzGupeTKscg", + "formality": "medium", + "vibe": ["business", "soft", "case"], + "best_for": ["comparison.two-column", "image.story", "dashboard.kpi-grid"], + "avoid_for": ["legal.audit"], + "palette": {"background": "#EDF0FA", "text": "#6E1E3A", "muted": "#C7D2F0", "accent": "#9E2B50", "support": ["#9DB0E8"]}, + "shape_language": {"panel_treatment": "soft berry panels with cool support areas", "corner_radius": "medium", "border_weight": "light", "texture": "soft editorial contrast"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "medium", "node_budget": {"source_nodes": 65, "source_text": 40, "source_shapes": 13, "source_connectors": 12}}, + "slide_translation": {"recommended_layouts": ["comparison", "image", "dashboard"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use berry for key claims and blue support panels for evidence."}, + "quality_oracle": {"expected_style_signals": ["berry headline", "cool soft panels", "balanced contrast"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 3}} + }, + { + "style_id": "bold_poster", + "display_name": "Bold Poster", + "group": "Balanced", + "source_token": "TOwewbtxrhmxNgbzko3udFBvsBd", + "formality": "medium", + "vibe": ["poster", "direct", "high-contrast"], + "best_for": ["cover.hero", "quote.insight", "section.divider"], + "avoid_for": ["dense.table"], + "palette": {"background": "#F5F2EF", "text": "#1C1410", "muted": "#FFFFFF", "accent": "#D8000F", "support": ["#1C1410"]}, + "shape_language": {"panel_treatment": "poster blocks with strong red hits", "corner_radius": "low", "border_weight": "heavy", "texture": "flat poster"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 52, "source_text": 40, "source_shapes": 6, "source_connectors": 6}}, + "slide_translation": {"recommended_layouts": ["cover", "quote", "section"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use large type and one strong red visual anchor; avoid dense micro text."}, + "quality_oracle": {"expected_style_signals": ["red poster accent", "black mass", "large type"], "warning_thresholds": {"text_boxes_max": 20, "accent_colors_max": 2}} + }, + { + "style_id": "checker_bloom", + "display_name": "Checker Bloom", + "group": "Balanced", + "source_token": "S5tEwSOiAhbuvObGnNkuCAUps8d", + "formality": "medium", + "vibe": ["grid", "capability", "modular"], + "best_for": ["dashboard.kpi-grid", "icon_capability_map", "table.visual-summary"], + "avoid_for": ["quote.insight"], + "palette": {"background": "#E8F1DD", "text": "#151515", "muted": "#5E9E4A", "accent": "#2C6EE0", "support": ["#FFFFFF"]}, + "shape_language": {"panel_treatment": "checker grid cells with bloom accents", "corner_radius": "medium", "border_weight": "medium", "texture": "modular checker geometry"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "medium", "node_budget": {"source_nodes": 73, "source_text": 37, "source_shapes": 20, "source_connectors": 12}}, + "slide_translation": {"recommended_layouts": ["dashboard", "capability_map", "table"], "svglide_primitives": ["geometric_shape", "icon", "micro_chart"], "fallback_policy": "Build true grid modules; do not reduce to three generic cards."}, + "quality_oracle": {"expected_style_signals": ["checker grid", "blue-green accents", "capability modules"], "warning_thresholds": {"text_boxes_max": 26, "accent_colors_max": 3}} + }, + { + "style_id": "cobalt_bloom", + "display_name": "Cobalt Bloom", + "group": "Balanced", + "source_token": "Ts2iwaXOuhOBkYbKD80uLjyCsje", + "formality": "medium", + "vibe": ["modern", "tech", "status"], + "best_for": ["architecture.layered", "fake_ui_dashboard", "cover.hero"], + "avoid_for": ["legal.audit"], + "palette": {"background": "#F4EFE9", "text": "#171717", "muted": "#DDA8A2", "accent": "#4746C6", "support": ["#CE968F"]}, + "shape_language": {"panel_treatment": "modern cobalt panels with soft pink supports", "corner_radius": "medium", "border_weight": "medium", "texture": "tech editorial blocks"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 56, "source_text": 40, "source_shapes": 11, "source_connectors": 5}}, + "slide_translation": {"recommended_layouts": ["architecture", "dashboard", "cover"], "svglide_primitives": ["typography", "geometric_shape", "dashboard"], "fallback_policy": "Use cobalt as structural system color and pink as secondary highlight."}, + "quality_oracle": {"expected_style_signals": ["cobalt blocks", "modern soft support panels", "technical editorial tone"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 3}} + }, + { + "style_id": "coral", + "display_name": "Coral", + "group": "Balanced", + "source_token": "Ez05w4JTahrMIjb6hjcuJRDpsOy", + "formality": "medium", + "vibe": ["report", "warm", "clear"], + "best_for": ["agenda.structured", "comparison.two-column", "kpi.big-number"], + "avoid_for": ["monochrome.audit"], + "palette": {"background": "#F5F0E8", "text": "#1A1A1A", "muted": "#6B6B6B", "accent": "#E85D5D", "support": ["#D44A4A"]}, + "shape_language": {"panel_treatment": "coral emphasis cards with neutral body text", "corner_radius": "medium", "border_weight": "light", "texture": "warm report blocks"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 68, "source_text": 37, "source_shapes": 23, "source_connectors": 5}}, + "slide_translation": {"recommended_layouts": ["agenda", "comparison", "kpi"], "svglide_primitives": ["typography", "geometric_shape", "micro_chart"], "fallback_policy": "Use coral only for claims, values, or section anchors."}, + "quality_oracle": {"expected_style_signals": ["coral claim blocks", "neutral report structure", "clear emphasis hierarchy"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 2}} + }, + { + "style_id": "cut_bloom", + "display_name": "Cut Bloom", + "group": "Balanced", + "source_token": "QHtIwZ4aeha6q8bDkfiuU7TCsgb", + "formality": "medium", + "vibe": ["geometric", "sectioned", "structured"], + "best_for": ["geometric_composition", "architecture.layered", "section.divider"], + "avoid_for": ["dense.table"], + "palette": {"background": "#FFFFFF", "text": "#2E3566", "muted": "#535D9E", "accent": "#F0CB65", "support": ["#FFFFFF"]}, + "shape_language": {"panel_treatment": "cut color planes and strong section blocks", "corner_radius": "low", "border_weight": "medium", "texture": "cut-paper geometry"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 68, "source_text": 40, "source_shapes": 26, "source_connectors": 2}}, + "slide_translation": {"recommended_layouts": ["geometric", "architecture", "section"], "svglide_primitives": ["path", "geometric_shape", "typography"], "fallback_policy": "Translate angled blocks into safe path geometry, not polygons."}, + "quality_oracle": {"expected_style_signals": ["cut planes", "navy structure", "yellow highlight"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 3}} + }, + { + "style_id": "editorial_forest", + "display_name": "Editorial Forest", + "group": "Balanced", + "source_token": "DeJQwotDFhKucHbVvdUufxepsAe", + "formality": "high", + "vibe": ["research", "editorial", "ecosystem"], + "best_for": ["quote.insight", "comparison.two-column", "paper.explainer"], + "avoid_for": ["brand_system"], + "palette": {"background": "#EFE7D4", "text": "#1A1A17", "muted": "#243A21", "accent": "#E89CB1", "support": ["#2E4A2A"]}, + "shape_language": {"panel_treatment": "forest editorial blocks with pink accent", "corner_radius": "low", "border_weight": "light", "texture": "journal-like composition"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 51, "source_text": 39, "source_shapes": 6, "source_connectors": 6}}, + "slide_translation": {"recommended_layouts": ["quote", "comparison", "paper"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Let text hierarchy carry the page; use pink accent sparingly."}, + "quality_oracle": {"expected_style_signals": ["forest green editorial base", "pink accent", "quiet paper field"], "warning_thresholds": {"text_boxes_max": 22, "accent_colors_max": 2}} + }, + { + "style_id": "lime_slab", + "display_name": "Lime Slab", + "group": "Balanced", + "source_token": "T3oLwlQLohw8G7b4qfJuFqw9syd", + "formality": "medium", + "vibe": ["energetic", "technical", "slab"], + "best_for": ["cover.hero", "architecture.layered", "process.flow"], + "avoid_for": ["quiet.executive"], + "palette": {"background": "#FFFFF2", "text": "#0A0A05", "muted": "#2F2E25", "accent": "#EEFA79", "support": ["#FFFFFF"]}, + "shape_language": {"panel_treatment": "thick slab panels with lime highlight", "corner_radius": "low", "border_weight": "heavy", "texture": "high-energy slab geometry"}, + "density": {"text_density": "high", "label_density": "high", "connector_density": "medium", "node_budget": {"source_nodes": 82, "source_text": 43, "source_shapes": 28, "source_connectors": 11}}, + "slide_translation": {"recommended_layouts": ["cover", "architecture", "process"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Use lime as high-energy focal surface, but keep text boxes large."}, + "quality_oracle": {"expected_style_signals": ["lime slab title", "heavy black structure", "dense technical labels"], "warning_thresholds": {"text_boxes_max": 28, "accent_colors_max": 2}} + }, + { + "style_id": "linen_cut", + "display_name": "Linen Cut", + "group": "Balanced", + "source_token": "WI3Yw4BakhfaoNbiEfRuCwq0slg", + "formality": "medium", + "vibe": ["business", "linen", "structured"], + "best_for": ["agenda.structured", "table.visual-summary", "comparison.two-column"], + "avoid_for": ["cover.hero"], + "palette": {"background": "#E4D2C4", "text": "#1F1A14", "muted": "#044D99", "accent": "#F61B27", "support": ["#04B24F"]}, + "shape_language": {"panel_treatment": "linen panels with sharp color tabs", "corner_radius": "low", "border_weight": "medium", "texture": "woven warm base"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 54, "source_text": 37, "source_shapes": 14, "source_connectors": 3}}, + "slide_translation": {"recommended_layouts": ["agenda", "table", "comparison"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Keep red/green/blue as role colors, not decoration."}, + "quality_oracle": {"expected_style_signals": ["linen base", "sharp red/green/blue tabs", "business grid"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 3}} + }, + { + "style_id": "pin_paper", + "display_name": "Pin & Paper", + "group": "Balanced", + "source_token": "NZ5bwYLs1hAat1bHo29uDdhSsJx", + "formality": "medium", + "vibe": ["workshop", "action", "paper"], + "best_for": ["agenda.structured", "process.flow", "quote.insight"], + "avoid_for": ["legal.audit"], + "palette": {"background": "#FFFFFF", "text": "#1F2329", "muted": "#2A3C99", "accent": "#F1E84E", "support": ["#FFFFFF"]}, + "shape_language": {"panel_treatment": "pinned paper notes with blue rails", "corner_radius": "medium", "border_weight": "medium", "texture": "workshop paper"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 66, "source_text": 40, "source_shapes": 21, "source_connectors": 2}}, + "slide_translation": {"recommended_layouts": ["agenda", "process", "quote"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use pinned note panels and explicit action labels; avoid showing the preset name."}, + "quality_oracle": {"expected_style_signals": ["pinned paper cards", "blue rails", "yellow highlight"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 3}} + }, + { + "style_id": "raw_grid", + "display_name": "Raw Grid", + "group": "Balanced", + "source_token": "Z6CbwF2oPhvXQ8biBRnufsyksqf", + "formality": "medium", + "vibe": ["technical", "dense", "grid"], + "best_for": ["dashboard.kpi-grid", "table.visual-summary", "architecture.layered"], + "avoid_for": ["cover.hero"], + "palette": {"background": "#F5F5F5", "text": "#0A0A0A", "muted": "#333333", "accent": "#F2D4CF", "support": ["#FFFFFF", "#E5EDD6"]}, + "shape_language": {"panel_treatment": "grid panels with dense labels", "corner_radius": "low", "border_weight": "medium", "texture": "explicit grid geometry"}, + "density": {"text_density": "high", "label_density": "high", "connector_density": "low", "node_budget": {"source_nodes": 98, "source_text": 52, "source_shapes": 44, "source_connectors": 2}}, + "slide_translation": {"recommended_layouts": ["dashboard", "table", "architecture"], "svglide_primitives": ["geometric_shape", "typography", "texture", "annotation"], "fallback_policy": "Keep native text and basic shapes; fallback only for over-dense visual tables."}, + "quality_oracle": {"expected_style_signals": ["grid geometry", "dense labels", "muted panels"], "warning_thresholds": {"text_boxes_max": 28, "accent_colors_max": 2}} + }, + { + "style_id": "riptide_cobalt", + "display_name": "Riptide Cobalt", + "group": "Balanced", + "source_token": "Qpyow1AnZhU763b3d51ur42csZd", + "formality": "medium", + "vibe": ["technology", "flow", "cobalt"], + "best_for": ["path_flow", "architecture.layered", "fake_ui_dashboard"], + "avoid_for": ["quiet.executive"], + "palette": {"background": "#FDF0E0", "text": "#1A2240", "muted": "#2741C0", "accent": "#375DFE", "support": ["#FFFFFF"]}, + "shape_language": {"panel_treatment": "cobalt panels with flowing technical emphasis", "corner_radius": "medium", "border_weight": "medium", "texture": "blue flow geometry"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 69, "source_text": 41, "source_shapes": 26, "source_connectors": 2}}, + "slide_translation": {"recommended_layouts": ["path", "architecture", "dashboard"], "svglide_primitives": ["path", "geometric_shape", "dashboard"], "fallback_policy": "Use cobalt flow paths and layered blocks; do not overdo connectors."}, + "quality_oracle": {"expected_style_signals": ["cobalt flow", "cream contrast", "layered tech panels"], "warning_thresholds": {"text_boxes_max": 26, "accent_colors_max": 3}} + }, + { + "style_id": "soft_editorial", + "display_name": "Soft Editorial", + "group": "Balanced", + "source_token": "T7A4w3ioHhgOjLbuFywuqZwbsUg", + "formality": "medium", + "vibe": ["soft", "editorial", "summary"], + "best_for": ["quote.insight", "agenda.structured", "comparison.two-column"], + "avoid_for": ["technical_texture"], + "palette": {"background": "#ECE9DC", "text": "#1C1A17", "muted": "#E7C6AD", "accent": "#E2A8CE", "support": ["#C9DA4F"]}, + "shape_language": {"panel_treatment": "soft editorial fields with small text fragments", "corner_radius": "medium", "border_weight": "light", "texture": "gentle magazine layout"}, + "density": {"text_density": "medium", "label_density": "high", "connector_density": "low", "node_budget": {"source_nodes": 51, "source_text": 40, "source_shapes": 5, "source_connectors": 6}}, + "slide_translation": {"recommended_layouts": ["quote", "agenda", "comparison"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Split text into readable editorial fragments; keep labels concise."}, + "quality_oracle": {"expected_style_signals": ["soft editorial colors", "small text fragments", "gentle accent palette"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 3}} + }, + { + "style_id": "violet_marker", + "display_name": "Violet Marker", + "group": "Balanced", + "source_token": "HISlwkosLhVgFVb8y3KucknAslb", + "formality": "medium", + "vibe": ["marker", "training", "concept"], + "best_for": ["section.divider", "process.flow", "icon_capability_map"], + "avoid_for": ["legal.audit"], + "palette": {"background": "#FFFFFF", "text": "#000000", "muted": "#666463", "accent": "#C5A1FF", "support": ["#CFEE30"]}, + "shape_language": {"panel_treatment": "marker-like labels with violet and lime accents", "corner_radius": "medium", "border_weight": "medium", "texture": "hand-highlighted marker blocks"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 59, "source_text": 39, "source_shapes": 9, "source_connectors": 11}}, + "slide_translation": {"recommended_layouts": ["section", "process", "capability_map"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Use violet/lime marker tags for concepts, not all text."}, + "quality_oracle": {"expected_style_signals": ["violet marker accent", "lime support", "concept labels"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 3}} + }, + { + "style_id": "blockframe", + "display_name": "BlockFrame", + "group": "Bold", + "source_token": "Qu3Lwf4VRhyYzWbjI1xuBn5UsEc", + "formality": "low", + "vibe": ["showcase", "blocky", "colorful"], + "best_for": ["cover.hero", "brand_system", "image.story"], + "avoid_for": ["legal.audit"], + "palette": {"background": "#FFFFFF", "text": "#000000", "muted": "#C0F7FE", "accent": "#F7CB46", "support": ["#FE90E8", "#99E885", "#FFDC8B"]}, + "shape_language": {"panel_treatment": "strong colored block frames", "corner_radius": "low", "border_weight": "heavy", "texture": "graphic frame system"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 75, "source_text": 41, "source_shapes": 31, "source_connectors": 2}}, + "slide_translation": {"recommended_layouts": ["cover", "brand", "image"], "svglide_primitives": ["geometric_shape", "typography", "brand_system"], "fallback_policy": "Use block frames as visual identity; keep content hierarchy simple."}, + "quality_oracle": {"expected_style_signals": ["multi-color frames", "black outlines", "bold block identity"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 5}} + }, + { + "style_id": "burst_panel", + "display_name": "Burst Panel", + "group": "Bold", + "source_token": "IUVZwJZirhaIZQbxkfnuj2jPskh", + "formality": "low", + "vibe": ["poster", "burst", "dense"], + "best_for": ["cover.hero", "dashboard.kpi-grid", "brand_system"], + "avoid_for": ["quiet.executive"], + "palette": {"background": "#FFFAF0", "text": "#1E1E1E", "muted": "#FBD65A", "accent": "#FFA76D", "support": ["#CFACE8", "#AAE4BA"]}, + "shape_language": {"panel_treatment": "bursting panels with many colored regions", "corner_radius": "medium", "border_weight": "medium", "texture": "poster panel collage"}, + "density": {"text_density": "high", "label_density": "high", "connector_density": "low", "node_budget": {"source_nodes": 89, "source_text": 50, "source_shapes": 37, "source_connectors": 2}}, + "slide_translation": {"recommended_layouts": ["cover", "dashboard", "brand"], "svglide_primitives": ["geometric_shape", "typography", "micro_chart"], "fallback_policy": "Preserve high-energy panels but cap text boxes to avoid crowding."}, + "quality_oracle": {"expected_style_signals": ["burst panels", "warm poster palette", "high information blocks"], "warning_thresholds": {"text_boxes_max": 30, "accent_colors_max": 5}} + }, + { + "style_id": "confetti_wedge", + "display_name": "Confetti Wedge", + "group": "Bold", + "source_token": "YDyLwTCiHhKJ0NbuP89uHUV8sNh", + "formality": "low", + "vibe": ["playful", "light", "wedge"], + "best_for": ["section.divider", "image.story", "quote.insight"], + "avoid_for": ["dense.table"], + "palette": {"background": "#F4F8FB", "text": "#000000", "muted": "#3A3C3E", "accent": "#62C0A5", "support": ["#F8BED4", "#65C8CD"]}, + "shape_language": {"panel_treatment": "confetti wedges and light motion shapes", "corner_radius": "medium", "border_weight": "light", "texture": "playful wedge accents"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 52, "source_text": 37, "source_shapes": 13, "source_connectors": 2}}, + "slide_translation": {"recommended_layouts": ["section", "image", "quote"], "svglide_primitives": ["path", "geometric_shape", "typography"], "fallback_policy": "Use a few wedge paths as memory points; avoid confetti over text."}, + "quality_oracle": {"expected_style_signals": ["wedge accents", "light playful palette", "simple panel structure"], "warning_thresholds": {"text_boxes_max": 22, "accent_colors_max": 4}} + }, + { + "style_id": "court_press", + "display_name": "Court Press", + "group": "Bold", + "source_token": "JH9owJl4sh0PeIbUm9SujYCTsTc", + "formality": "low", + "vibe": ["sports", "team", "operations"], + "best_for": ["process.flow", "timeline.roadmap", "metaphor_loop"], + "avoid_for": ["legal.audit"], + "palette": {"background": "#F2EFE6", "text": "#2F4224", "muted": "#66914C", "accent": "#DA9EB7", "support": ["#FFFFFF"]}, + "shape_language": {"panel_treatment": "court-like lanes and team panels", "corner_radius": "medium", "border_weight": "medium", "texture": "court lane geometry"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 61, "source_text": 37, "source_shapes": 11, "source_connectors": 13}}, + "slide_translation": {"recommended_layouts": ["process", "timeline", "loop"], "svglide_primitives": ["connector_flow", "path", "geometric_shape"], "fallback_policy": "Use lanes and flows to encode motion; keep sports metaphor subtle."}, + "quality_oracle": {"expected_style_signals": ["court lanes", "green team palette", "pink accent"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 3}} + }, + { + "style_id": "crayon_stack", + "display_name": "Crayon Stack", + "group": "Bold", + "source_token": "JjKIwig1vhnXq7bbeCfutuKFseh", + "formality": "low", + "vibe": ["creative", "playful", "stacked"], + "best_for": ["cover.hero", "icon_capability_map", "brand_system"], + "avoid_for": ["executive.audit"], + "palette": {"background": "#FFFFFF", "text": "#222222", "muted": "#8A2E43", "accent": "#FF472B", "support": ["#D3FE79", "#FBB8FD", "#2A8F6D", "#7E90FC"]}, + "shape_language": {"panel_treatment": "stacked crayon color bars", "corner_radius": "medium", "border_weight": "heavy", "texture": "handmade colorful blocks"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 51, "source_text": 37, "source_shapes": 8, "source_connectors": 3}}, + "slide_translation": {"recommended_layouts": ["cover", "capability_map", "brand"], "svglide_primitives": ["geometric_shape", "icon", "typography"], "fallback_policy": "Use colorful stacks for categories; avoid making every label a different color."}, + "quality_oracle": {"expected_style_signals": ["crayon color stack", "playful strong accents", "bold category blocks"], "warning_thresholds": {"text_boxes_max": 22, "accent_colors_max": 6}} + }, + { + "style_id": "grove_block", + "display_name": "Grove Block", + "group": "Bold", + "source_token": "Mq2XwYKDEhbnRmbIxAfuZGCQsOb", + "formality": "medium", + "vibe": ["ecosystem", "bold", "result"], + "best_for": ["kpi.big-number", "metaphor_loop", "process.flow"], + "avoid_for": ["quiet.editorial"], + "palette": {"background": "#FCF6F1", "text": "#01623F", "muted": "#008248", "accent": "#FCC715", "support": ["#F7F1EC", "#F6BDDA"]}, + "shape_language": {"panel_treatment": "bold green blocks with yellow emphasis", "corner_radius": "medium", "border_weight": "medium", "texture": "organic block system"}, + "density": {"text_density": "medium", "label_density": "high", "connector_density": "medium", "node_budget": {"source_nodes": 73, "source_text": 44, "source_shapes": 20, "source_connectors": 9}}, + "slide_translation": {"recommended_layouts": ["kpi", "loop", "process"], "svglide_primitives": ["geometric_shape", "connector_flow", "typography"], "fallback_policy": "Use green blocks for system stages and yellow for outcome emphasis."}, + "quality_oracle": {"expected_style_signals": ["bold green blocks", "yellow result accent", "ecosystem grouping"], "warning_thresholds": {"text_boxes_max": 28, "accent_colors_max": 3}} + }, + { + "style_id": "mint_brut", + "display_name": "Mint Brut", + "group": "Bold", + "source_token": "BMe4wBmwlhGfNCbooCvuShTfs4v", + "formality": "low", + "vibe": ["brutalist", "technical", "mint"], + "best_for": ["architecture.layered", "process.flow", "cover.hero"], + "avoid_for": ["quiet.training"], + "palette": {"background": "#FFFBF3", "text": "#000000", "muted": "#D0FDE4", "accent": "#70F0A8", "support": ["#F888C8", "#F0DE4E"]}, + "shape_language": {"panel_treatment": "brutalist mint panels with loud highlights", "corner_radius": "low", "border_weight": "heavy", "texture": "brutalist blocks"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 75, "source_text": 38, "source_shapes": 23, "source_connectors": 11}}, + "slide_translation": {"recommended_layouts": ["architecture", "process", "cover"], "svglide_primitives": ["geometric_shape", "connector_flow", "typography"], "fallback_policy": "Use heavy frames and mint surfaces; keep text large and sparse."}, + "quality_oracle": {"expected_style_signals": ["mint brutalist panels", "black outlines", "pink/yellow highlights"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 4}} + }, + { + "style_id": "neo_grid_bold", + "display_name": "Neo-Grid Bold", + "group": "Bold", + "source_token": "WwstwfAj1hIZXhbDwdDuwdpDsBh", + "formality": "medium", + "vibe": ["neo-grid", "technical", "high-contrast"], + "best_for": ["technical_texture", "architecture.layered", "fake_ui_dashboard"], + "avoid_for": ["paper.explainer"], + "palette": {"background": "#F5F4EF", "text": "#0A0A0A", "muted": "#8A8A85", "accent": "#E6FF3D", "support": ["#0A0A0A"]}, + "shape_language": {"panel_treatment": "black grid rails with neon highlight", "corner_radius": "low", "border_weight": "heavy", "texture": "explicit neo grid"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 69, "source_text": 40, "source_shapes": 12, "source_connectors": 17}}, + "slide_translation": {"recommended_layouts": ["technical_texture", "architecture", "dashboard"], "svglide_primitives": ["texture", "connector_flow", "geometric_shape"], "fallback_policy": "Use explicit grid lines/dots; do not rely on SVG pattern."}, + "quality_oracle": {"expected_style_signals": ["black grid structure", "neon lime focus", "technical rails"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 2}} + }, + { + "style_id": "riso_brut", + "display_name": "Riso Brut", + "group": "Bold", + "source_token": "Mztnwj2ouhot6VbtbwMuWN4SsRb", + "formality": "low", + "vibe": ["riso", "poster", "multicolor"], + "best_for": ["brand_system", "cover.hero", "image.story"], + "avoid_for": ["legal.audit"], + "palette": {"background": "#EFE9D9", "text": "#0F0F0F", "muted": "#136636", "accent": "#F5C518", "support": ["#1F8A4C", "#E85A1F", "#F06CA8", "#D14E8B"]}, + "shape_language": {"panel_treatment": "riso-print blocks and overlapping color plates", "corner_radius": "low", "border_weight": "medium", "texture": "riso poster plates"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 71, "source_text": 38, "source_shapes": 29, "source_connectors": 4}}, + "slide_translation": {"recommended_layouts": ["brand", "cover", "image"], "svglide_primitives": ["geometric_shape", "typography", "image_overlay"], "fallback_policy": "Use flat color plates; avoid opacity-heavy overlaps unless preflight accepts them."}, + "quality_oracle": {"expected_style_signals": ["riso color plates", "poster energy", "cream paper"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 6}} + }, + { + "style_id": "specimen_bold", + "display_name": "Specimen Bold", + "group": "Bold", + "source_token": "BNFmwKX6OhWRfbb3SX4uRoHesD6", + "formality": "medium", + "vibe": ["specimen", "concept", "modular"], + "best_for": ["icon_capability_map", "comparison.two-column", "section.divider"], + "avoid_for": ["paper.explainer"], + "palette": {"background": "#F3F3F3", "text": "#2E302E", "muted": "#30A1E5", "accent": "#3EC06A", "support": ["#FFFFFF", "#FBEF4A"]}, + "shape_language": {"panel_treatment": "specimen cards with multiple focus markers", "corner_radius": "medium", "border_weight": "medium", "texture": "sample-card layout"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "medium", "node_budget": {"source_nodes": 57, "source_text": 37, "source_shapes": 8, "source_connectors": 12}}, + "slide_translation": {"recommended_layouts": ["capability_map", "comparison", "section"], "svglide_primitives": ["icon", "geometric_shape", "connector_flow"], "fallback_policy": "Use specimen cards for concepts and blue/green markers for relationships."}, + "quality_oracle": {"expected_style_signals": ["specimen cards", "blue/green markers", "modular concept blocks"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 4}} + }, + { + "style_id": "stencil_tablet", + "display_name": "Stencil & Tablet", + "group": "Bold", + "source_token": "J1BKw4SxhhC3kTbD77auLJ5UsRc", + "formality": "medium", + "vibe": ["stencil", "method", "action"], + "best_for": ["quote.insight", "process.flow", "agenda.structured"], + "avoid_for": ["quiet.editorial"], + "palette": {"background": "#F4EFE0", "text": "#0A0A0A", "muted": "#E2DCC9", "accent": "#D8A93B", "support": ["#EE7A2E", "#C73B7A", "#2D7E73"]}, + "shape_language": {"panel_treatment": "stencil panels and tablet-like blocks", "corner_radius": "low", "border_weight": "medium", "texture": "stencil print panels"}, + "density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 51, "source_text": 39, "source_shapes": 7, "source_connectors": 5}}, + "slide_translation": {"recommended_layouts": ["quote", "process", "agenda"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use stencil panels for method/action emphasis; keep decorative colors role-bound."}, + "quality_oracle": {"expected_style_signals": ["stencil panels", "tablet blocks", "gold/orange/magenta accents"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 5}} + } + ] +} diff --git a/skills/lark-slides/references/style-presets.md b/skills/lark-slides/references/style-presets.md new file mode 100644 index 000000000..9daf2af5a --- /dev/null +++ b/skills/lark-slides/references/style-presets.md @@ -0,0 +1,89 @@ +# SVGlide Style Presets + +`style-presets.json` is the runtime source of truth for the 35 `beautiful-feishu-whiteboard` style presets. This Markdown file is only a human-readable guide. + +## Boundary + +Style presets are not slide templates. They do not replace `visual_recipe`, `renderer_id`, or the page semantic plan. + +- `visual_recipe`: explains the page structure and SVG-native value, such as `path_flow`, `technical_texture`, or `fake_ui_dashboard`. +- `style_preset`: selects the visual language, palette, panel treatment, connector density, label density, and texture. +- `style_system`: records how the selected preset is translated into the current deck. + +Do not copy raw whiteboard nodes, raw coordinates, source prompts, source file paths, tool names, source tokens, or preset names into visible slide content. + +## Required Plan Fields + +For `output_mode="svglide-svg"`, the deck plan must include: + +```json +{ + "style_preset": "raw_grid", + "style_selection_reason": "raw_grid fits technical training pages that need dense but readable visual structure", + "style_system": { + "palette": { + "background": "#F5F5F5", + "text": "#0A0A0A", + "accent": "#F2D4CF" + }, + "typography": "strong title, readable native text labels", + "background_strategy": "muted grid panels with one stable background family", + "motif": "dense grid panels with restrained accent labels" + } +} +``` + +Each slide must also include: + +```json +{ + "visual_recipe": "path_flow", + "visual_signature": "curved route path with explicit stage annotations", + "svg_effects": ["path", "connector_flow", "typography"], + "svg_primitives": ["path", "annotation"], + "required_primitives": ["path", "annotation"] +} +``` + +Use `visual_plan` as a nested container when useful. `svg_preflight.py` accepts both the nested shape and the existing flat fields; nested `visual_plan` wins when both are present. + +## Selection Rule + +1. Choose intensity first. + - `Restrained`: serious, quiet, institutional, text-first decks. + - `Balanced`: default for business, technical, training, and explanatory decks. + - `Bold`: posters, showcases, event material, playful explainers, high-energy pages. +2. Match the user's tone and topic. +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`. + +## SVGlide-Safe Translation + +Translate style into supported SVG primitives: + +- Palette -> explicit `fill`, `stroke`, and text colors. +- Panel treatment -> `rect`, `path`, and grouped layout boxes. +- Connector density -> explicit `line` or supported `path`; do not rely on `marker` or key-path `stroke-dasharray`. +- Texture -> repeated native `line`, `circle`, or `rect`; do not rely on `` as the only effect. +- Image overlay -> real `` plus explicit shape masks/overlays when needed. + +Unsafe effects such as `filter`, `mask_clip`, `pattern`, `symbol`, `stroke_dasharray`, and `image_opacity` may appear in the plan only when a safe rewrite or fallback is declared. + +## Quality Gates + +Before calling `slides +create-svg`, run: + +```bash +python3 skills/lark-slides/scripts/svg_preflight.py \ + --plan .lark-slides/plan//slide_plan.json \ + --input .lark-slides/plan//pages/page-001.svg +``` + +The preflight checks: + +- preset exists in `style-presets.json`; +- `style_system` has palette, typography, background strategy, and motif; +- each page declares `visual_signature` and `svg_effects`; +- unsafe effects have fallback or rewrite notes; +- declared effects and primitives are present in the SVG source; +- visible slide text does not leak preset names, source tokens, prompts, tool names, or local file paths. diff --git a/skills/lark-slides/references/svg-aesthetic-review.md b/skills/lark-slides/references/svg-aesthetic-review.md new file mode 100644 index 000000000..8d444a746 --- /dev/null +++ b/skills/lark-slides/references/svg-aesthetic-review.md @@ -0,0 +1,107 @@ +# SVGlide Aesthetic Review + +Use this file after generating local SVG/HTML preview and before calling +`slides +create-svg`. It is the short execution checklist distilled from: +`/Users/bytedance/bd-projects/workspaces/SVGlide/svglide-visual-guidance/svg_aesthetic_rubric.md`. + +This review complements `svg_preflight.py`. Preflight catches deterministic +protocol, plan, and bbox problems; this checklist catches rendered visual +quality issues that need human or screenshot-based judgment. + +## Required Review Flow + +1. Generate local SVG files and, when possible, a local `preview.html`. +2. Run `svg_preflight.py --plan ... --input ...`; fix all errors first. +3. Open or inspect the preview. Review all slides, not just the cover. +4. Repair repeated layout problems in the generator or source SVG, not by + changing only `slide_plan.json`. +5. Re-run preflight and preview before live creation. + +Do not use preview review as a substitute for live readback. Service conversion +can still change text boxes, image tokens, path bounds, and unsupported effects. + +## Blocking Visual Issues + +Fix these before calling live API: + +| Issue | Action | +|---|---| +| Text overlap, text container overflow, or clipped headline | Regenerate layout boxes or reduce text; do not just shrink everything | +| Badge, pill, section tag, or page label touches/overlaps headline | Move badge outside title block or add at least 12-16px vertical gap | +| Decorative line or band presses against title | Move the line above the title zone or lower title; keep clear breathing room | +| Main content outside `960 x 540` or safe area | Recompute coordinates using the 960x540 canvas | +| Low contrast text over light image/background | Add solid backing, overlay, or switch text color | +| Empty image frame or broken preview image | Replace asset or use a visual fallback before live creation | +| Page lacks focal point | Rebuild the page around one dominant number, diagram, image, route, or title | +| Page is ordinary cards/bullets with no SVG advantage | Choose a better `visual_recipe` or switch away from SVG route | +| Same layout issue repeats across multiple slides | Fix the shared generator rule, then regenerate affected pages | + +## Issue Severity + +Use these severities in preview notes and final validation records: + +| Severity | Meaning | Action | +|---|---|---| +| P0 | The deck should not be created live | Fix or regenerate before `slides +create-svg` | +| P1 | The deck can render, but user-facing quality is clearly below target | Repair before delivery; only continue as draft if explicitly accepted | +| P2 | Minor polish or residual risk | Record and fix when time allows | + +Default mapping: + +- P0: preflight error, unsafe SVG, broken/empty image, canvas crop, clipped or overlapping essential text, unreadable contrast, missing required asset, no fallback for unsupported visual. +- P1: weak focal point, repeated layout skeleton, decorative/title crowding, low visual hierarchy, chart/diagram intent mismatch, visible SVG advantage weak. +- P2: minor alignment variance, small color inconsistency, non-critical source metadata warning, polish-only spacing issue. + +## Scoring Rubric + +Use a 0-100 review score. The default target for user-facing decks is `>= 75`. +Below `65`, regenerate or repair before live creation. + +| Dimension | Weight | Good result | +|---|---:|---| +| Communication fit | 15 | Page type and visual form match the user's intent | +| Visual hierarchy | 15 | One focal point is clear within two seconds | +| Layout stability | 15 | Grid, spacing, alignment, and safe area are consistent | +| Readability | 15 | Font size, line length, contrast, and wrapping are readable | +| Color discipline | 10 | Accent colors are few and semantically consistent | +| Data/diagram integrity | 10 | Charts, flows, and diagrams express relationships honestly | +| Style consistency | 8 | Icons, radii, strokes, shadows, and motif feel like one deck | +| SVG advantage | 7 | The page visibly benefits from path, texture, chart geometry, flow, or overlay | +| Source/asset traceability | 5 | External references and preview assets are recorded when used | + +## Review Questions + +Ask these for each page: + +- What is the one-sentence takeaway? +- Where does the eye land first, and is that the intended `visual_focal_point`? +- Does the scan path follow title -> focal visual -> evidence -> detail? +- Are any badges, lines, watermarks, labels, or thumbnails crowding text? +- Is the page using SVG-native structure, or only ordinary boxes and text? +- If this page were converted to a plain XML/PPT card layout, what would be lost? +- Are chart/flow/table choices appropriate for the relationship being shown? +- Are colors and emphasis consistent with the rest of the deck? + +## Repair Priority + +1. Layout correctness: canvas, safe area, overlap, overflow, clipping. +2. Readability: contrast, font size, line length, enough text box height. +3. Hierarchy: one focal object, clear title, supporting details demoted. +4. SVG advantage: path/flow/chart/icon/texture/image overlay actually present. +5. Deck rhythm: avoid repeating the same skeleton with only copy changed. +6. Asset/source hygiene: preview assets are visible and source metadata exists. + +## Accepted Output Note + +When reporting validation, say exactly what was checked: + +```text +SVG preview review: +- preflight: passed / fixed errors first +- preview_path: .lark-slides/plan//preview.html +- preview: checked all N pages for overlap, safe area, readability, and repeated layout issues +- visual_score: 82 / threshold 75 +- issue_ids: none / [P1 visual.layout.decorative_line_title_pressure page=3] +- action: create_live / repair_and_rerun / draft_only +- remaining risk: live readback may still change text bbox or unsupported effects +``` diff --git a/skills/lark-slides/references/svg-protocol.md b/skills/lark-slides/references/svg-protocol.md index 2853c1e45..8b0345020 100644 --- a/skills/lark-slides/references/svg-protocol.md +++ b/skills/lark-slides/references/svg-protocol.md @@ -7,6 +7,7 @@ xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" + slide:contract-version="svglide-authoring-contract/v1" width="960" height="540" viewBox="0 0 960 540" @@ -24,6 +25,7 @@ - root 必须是非 namespaced 的 ``,不能是 ``。 - root 必须声明 `xmlns:slide="https://slides.bytedance.com/ns"`。 - root 必须包含 `slide:role="slide"`。 +- root 应包含 `slide:contract-version="svglide-authoring-contract/v1"`,用于标识这是 SVGlide authoring contract 输入,而不是普通 SVG。 - 可渲染元素必须有对应 `slide:role`:shape 使用 `slide:role="shape"`,图片使用 `slide:role="image"`。 - `` 和嵌套 `` 可以作为容器,用于继承样式和 transform;容器内真正渲染的元素仍必须声明 `slide:role`。 - `slide:role="shape"` 目前只支持 `rect`、`ellipse`、`circle`、`line`、`path`、`foreignObject`。 @@ -74,8 +76,36 @@ CLI 会把这些几何属性作为生成质量门禁:值只能是数字或 `px - Path: 只使用 `M/L/H/V/C/Q/Z`;CLI 会拒绝 arc `A`、smooth curve `S/T` 和其他未知命令。 - Text: `foreignObject slide:shape-type="text"` 内支持常见 XHTML 文本标签、`br` 和基础文字样式。 +## SVG-native 效果的 SVGlide-safe 写法 + +视觉参考图、浏览器 SVG demo 或 `svglide-visual-effects-gallery.html` 只能作为效果方向,不能直接当作 `slides +create-svg` 输入。生成器必须把浏览器 SVG 能力改写为当前 SVGlide 支持面: + +| 浏览器 SVG 常见写法 | SVGlide-safe 写法 | +|---|---| +| 根级 `` / 普通 SVG text | `foreignObject slide:role="shape" slide:shape-type="text"`,并显式写 `font-size`、`font-weight`、`color`、`line-height` | +| `` / `` | 改成 `path slide:role="shape"`,只使用 `M/L/H/V/C/Q/Z` | +| `` 箭头 | 用独立三角形 `path` 或短 line + arrowhead path 显式绘制 | +| `` 网格、点阵、纹理 | 用重复的 `line`、`circle`、`rect` 显式铺排;不要依赖 pattern 展开 | +| `mask` / `clipPath` 大字裁切 | 用大字描边、深色/渐变背板、半透明 shape overlay 或裁切后的本地图片替代 | +| 多层 `filter`、blur、glow | 用多层半透明 circle/rect/path 模拟光晕;仅把简单 drop-shadow 当增强,不当核心表达 | +| `stroke-dasharray` 关键路线 | 用短 line segment 或 filled dot markers 手工排布;关键流程不要只靠虚线;带 `route` / `path` / `flow` / `loop` / `timeline` / `rail` 等语义的虚线会被 preflight 视为错误 | +| `` | MVP preflight 只 warning;高保真场景应预合成到图片,或在图片上方加半透明 `rect slide:role="shape"` | +| iconfont / 外链 SVG 图标 | 用 SVGlide-safe path/line/rect/circle 组合本地绘制,或先转成受支持的本地图片资产 | + +每个 SVG 页面应通过 `visual_recipe` 证明自己值得使用 SVG:要么有强视觉主标题,要么有路径/流向/隐喻/标注/图标系统/微图表/纹理/仪表盘等 SVG-native 结构。只有 `rect + foreignObject` 的普通卡片页应优先走 XML/SXSD。 + 文本样式应使用 parser 友好的显式 CSS 属性,例如 `font-size`、`font-weight`、`font-family`、`color`、`line-height`、`text-align`、`letter-spacing`。不要依赖 `font:` shorthand、复杂 flex 布局或浏览器默认样式来表达关键字号、加粗和行距;这些在转换到 SXSD/XML 时可能降级为默认样式。 +白色或接近白色的文字必须完整落在深色 shape 承载底上;如果标题跨到浅色图片、白色蒙层或白底,生成器应扩大深色底、加背板/遮罩,或改用深色文字。圆形/椭圆节点内只放短标签,解释句、指标和说明放到独立 callout、legend 或机制表中。 + +生成 live smoke 或跨 lane 验证用 SVG 时,颜色优先写成 hex/rgb 加独立透明度属性,例如 `fill="#0F172A" opacity="0.72"`、`stroke="#38BDF8" stroke-opacity="0.8"`。不要在首轮验证里大量依赖 `rgba(...)` 作为 SVG leaf 的 `fill` / `stroke` 值;不同 server lane 的 paint 解析能力可能不一致,hex + opacity 更容易定位问题。渐变仍按 XML 协议要求使用 `rgba(...)` 停靠点。 + +图片透明度当前不是稳定协议面:`` 在 SVG 输入中会通过 CLI 传给服务端,但转换后的 Slides XML `` 不一定保留 alpha。MVP 阶段 preflight 只 warning;生成器不得在高保真页面依赖 image opacity,要么把淡化效果预合成到本地图片文件,要么用一个半透明 `rect slide:role="shape"` 覆盖在图片上方。shape opacity 会转换为 Slides XML `alpha`,比 image opacity 更稳定。 + +圆形和椭圆描边宽度也不是稳定协议面:`circle` / `ellipse` 的 `stroke-width` 可能在 readback 中降级。关键圆环请用两层填充圆/椭圆模拟,或改用 path/rect;普通细描边可以保留但需要视觉回读确认。 + +虚线描边也不是稳定协议面:`stroke-dasharray`,尤其是自定义 path 上的虚线闭环,可能在 readback 中降级。关键流程线、路线图和闭环图用短 line segment 或 filled dot markers 显式绘制;带 `route`、`path`、`flow`、`loop`、`timeline`、`rail` 等语义的 dashed path 会被 `svg_preflight.py` 作为 error 拦截。普通装饰虚线也需要 live readback 复核。 + ## 不支持 - 不要把普通 SVG 直接交给 `+create-svg`,CLI 不会自动补齐 SVGlide 协议。 @@ -83,16 +113,41 @@ CLI 会把这些几何属性作为生成质量门禁:值只能是数字或 `px - 不要把 `` 当作可渲染 shape;`` 只是容器,实际 `rect`、`path`、`foreignObject`、`image` 等子元素仍需各自声明 `slide:role`。 - 不支持根级 ``;用 `foreignObject + slide:shape-type="text"`。 - 不要在 `` 上保留 `xlink:href`;CLI 会统一输出 canonical `href`。 -- 不要用 http(s) 或 data URL 外链图片;先下载到本地并让 CLI 上传,或用 `--assets` 提供已上传 file token。 +- Preview/MVP 阶段允许 http(s) 或 data URL 图片通过 preflight warning,用于快速验证丰富视觉;live 转换和 readback 可见性不保证,必须回读确认。正式交付优先下载到本地并让 CLI 上传,或用 `--assets` 提供已上传 file token。 - `slides +create-svg` MVP 不支持指定 `beforeSlideBlockID` 插入到某一页前;它创建新 presentation 后按 `--file` 顺序追加。 这些能力依赖 slide server SVGlide parser 新版本。如果 BOE/线上未部署对应 server 分支,CLI 放行后仍可能收到服务端 `SVGLIDE_ERROR_JSON` 或 generic invalid param。 ## 图片与 Metadata -SVG deck 默认应使用真实图片资产,不要为了规避上传链路而全程用纯矢量 shape 冒充配图。宣传、产品、品牌、案例和视觉展示型 deck 至少应包含封面/半出血主视觉/案例场景/产品截图等图片使用;只有用户明确要求纯矢量,或图片获取、上传链路不可用时,才退回纯矢量方案,并在结果中说明原因。 +SVG deck 默认应使用真实图片资产,不要为了规避上传链路而全程用纯矢量 shape 冒充配图。Preview 阶段图片是拉开 SVGlide 和 XML 生成差距的关键能力:宣传、产品、品牌、案例、教学和视觉展示型 deck 应优先根据用户 query、deck 主题和页面标题去网络检索并拉取强相关图片,再包含封面/半出血主视觉/案例场景/产品截图/材质纹理/图鉴图等图片使用;只有用户明确要求纯矢量,或图片获取、上传链路完全不可用时,才退回纯矢量方案,并在结果中说明原因。 -图片资产还必须规避版权风险:只使用用户提供、公司/项目自有、明确可商用授权图库,或授权条件清晰的 AI 生成资产。推荐来源包括 Unsplash、Pexels、Pixabay、Openverse、Wikimedia Commons、The Met Open Access、Smithsonian Open Access 和 NASA Image and Video Library,但每张图都必须检查具体 license。不要使用版权状态不明的搜索图片、新闻配图、第三方 logo、竞品官网截图或素材站预览图。生成 deck 时应在素材清单或 README 中记录图片来源、授权/许可类型、下载 URL 或生成方式、是否需要署名;无法确认授权时不得使用该图片。 +图片资产采用双模式: + +- **Preview mode**:版权/授权不是阻断项。SHOULD 先从用户 query、deck 标题、章节标题和 page takeaway 生成图片检索词,去网络检索并拉取主题强相关图片;也可以使用公开可访问图片 URL、搜索图片、新闻/历史/艺术/科普图片、官网截图、产品截图、网页截图、材质纹理或 AI 生成图作为视觉占位。必须记录 `retrieval_query`、`source_url` 或生成方式,并把 `license` 写成 `preview_unverified`。不要使用明显不适当素材、敏感肖像,或会造成商业背书误导的 logo/商标。 +- **Production mode**:正式交付必须替换为用户提供、公司/项目自有、明确可商用授权图库,或授权条件清晰的 AI 生成资产。推荐来源包括 Unsplash、Pexels、Pixabay、Openverse、Wikimedia Commons、The Met Open Access、Smithsonian Open Access 和 NASA Image and Video Library,但每张图都应检查具体 license、署名和第三方权利。 + +MVP 阶段素材清单不完整只作为 warning,但生成 deck 时仍应在素材清单或 README 中记录图片检索词、图片来源、授权/许可类型、下载 URL 或生成方式、是否需要署名;无法确认授权时应显式标记风险并在正式交付前替换。 + +当 SVG source 使用 `` 时,对应 slide plan 应尽量有 `asset_contract`,并至少包含: + +```json +{ + "mode": "preview", + "source_type": "public_url | web_search_preview | screenshot | Unsplash | Pexels | procedural | ai_generated | user_provided | owned", + "retrieval_query": "topic-specific image query derived from user query and page topic", + "license": "preview_unverified", + "local_path": "@./assets/hero.jpg", + "href": "https://example.com/hero.jpg", + "usage_page": 1, + "source_url": "https://...", + "retrieved_at": "2026-06-08", + "generated_by": "optional when source_type is procedural/ai_generated", + "replacement_required": true +} +``` + +无图片页可以写 `"asset_contract": "none_required"`。如果 SVG source 检测到 image primitive,但 `asset_contract` 缺少检索词、来源、许可、本地路径或使用页,MVP 阶段 preflight 只 warning;preview 中可用 `license=preview_unverified` 明确标记,正式交付仍应补齐或替换为来源清晰的图片资产。 `slides +create-svg` 会把 `` 上传为 file token,并注入: diff --git a/skills/lark-slides/references/svg-visual-recipes.md b/skills/lark-slides/references/svg-visual-recipes.md new file mode 100644 index 000000000..02182d775 --- /dev/null +++ b/skills/lark-slides/references/svg-visual-recipes.md @@ -0,0 +1,106 @@ +# SVGlide Visual Recipes + +This file is the short executable recipe guide for `slides +create-svg`. +It distills the research catalog into generation-time rules that can fit into +the agent context. The longer research source remains outside the CLI skill: +`/Users/bytedance/bd-projects/workspaces/SVGlide/svglide-visual-guidance/visual_recipe_catalog.md`. + +## Boundary + +- `visual_recipe` defines page structure and why this page deserves SVG. +- `style_preset` defines visual language, palette, texture, density, and motif. +- `renderer_id` defines the concrete geometry renderer. + +Do not use `style_preset` as a substitute for `visual_recipe`. Do not invent +new recipe ids in `slide_plan.json`. + +## Hard Defaults + +- Canvas: `width="960" height="540" viewBox="0 0 960 540"`. +- Safe area: keep key text, labels, charts, cards, nodes, and legends inside + `x=48..912` and `y=40..500`. +- Grid: use a stable 12-column or 8px-step layout; avoid ad hoc coordinates. +- Text: Chinese body copy should stay around 28 characters per line; English + body copy around 62 characters per line. +- Decoration: decorative lines, watermarks, texture, and background geometry + must not compete with or touch the title/focal content. +- Deck diversity: 8+ page SVG decks should use at least 5 visual recipe families. + +## Plan Fields + +Every SVG page plan must include these fields before writing SVG: + +```json +{ + "visual_recipe": "path_flow", + "visual_intent": "show a staged route from current state to target state", + "visual_focal_point": "curved route spine with the final target node", + "visual_signature": "curved route path plus stage annotations", + "svg_effects": ["path", "connector_flow", "typography"], + "required_primitives": ["path", "annotation"], + "svg_primitives": ["path", "annotation", "typography"], + "xml_like_risk": "without the route geometry this becomes ordinary bullets", + "content_density_contract": "flow >= 4 stages", + "risk_flags": [], + "source_policy": "do not invent unsupported numbers" +} +``` + +## Recipe Selection Matrix + +Use these CLI-supported underscore ids in `slide_plan.json`. + +| User intent | `visual_recipe` | Must show in SVG source | +|---|---|---| +| Cover, section opener, hero statement | `hero_typography` | large type, geometric carrier, clear focal object | +| Strategic framework, strong geometric layout | `geometric_composition` | non-card geometry, `path` or shaped regions | +| Roadmap, journey, process, route | `path_flow` | explicit path/line spine, arrows or stage markers | +| KPI, scorecard, data recap | `infographic_scorecard` | big number plus micro chart or gauge geometry | +| Capability map, module overview | `icon_capability_map` | consistent SVG-safe icons and labeled regions | +| Depth, atmosphere, concept emphasis | `gradient_depth` | gradient or layered translucent geometry plus readable text | +| Product/result/image story | `mask_clip_showcase` | image region plus safe overlay/crop simulation | +| Technical system, grid, coded texture | `technical_texture` | repeated lines/dots/rects, grid, scanline, or diagram texture | +| Loop, flywheel, feedback system | `metaphor_loop` | closed path or looped process plus input/output labels | +| Diagnosis, callout, focused annotation | `spotlight_annotation` | highlight region, callout line, annotation target | +| Dashboard, console, monitoring surface | `fake_ui_dashboard` | UI frame, status bar, metrics, micro charts/log rows | +| Brand or series identity page | `brand_system` | stable title system, motif, palette, and repeated identity element | + +## Safe Effects + +Prefer effects that can be represented by SVGlide-safe primitives: + +- `path`: curves, waves, routes, custom shapes. +- `gradient`: background depth and emphasis; keep text on solid backing. +- `texture`: repeated `line`, `circle`, or `rect`; do not rely on ``. +- `connector_flow`: explicit line/path plus arrow triangles or dots. +- `chart_geometry`: bars, points, lines, gauges, axes, and labels. +- `grid_geometry`: matrix, table-like visual summary, structured alignment grid. +- `watermark_text`: low-contrast large text that never blocks reading. +- `image_overlay`: real image plus explicit translucent shape overlays. +- `spotlight`: layered translucent shapes, not complex filter-only glow. + +## Risky Effects + +These may appear in visual planning only when a safe rewrite or fallback is +declared in `risk_flags` / `recipe_fallback`: + +- `filter` +- `mask_clip` +- `pattern` +- `symbol` +- `stroke_dasharray` +- `image_opacity` + +For critical visuals, rewrite them into explicit shapes, lines, dots, overlays, +or pre-composited images before calling `slides +create-svg`. + +## Anti-Regression Rules + +- A page that is mostly `rect + foreignObject` is not enough for SVGlide unless + it also has a real SVG-native structure: path, chart geometry, icon system, + texture, spotlight, dashboard frame, connector flow, or image overlay. +- The first visible object should match the page's `visual_focal_point`. +- Similar pages may share `style_preset`, but should not share the same layout + skeleton with only text and background color changed. +- Dotted recipe names from research notes, such as `cover.hero`, are not valid + runtime ids. Map them to the underscore ids above before writing the plan. diff --git a/skills/lark-slides/references/validation-checklist.md b/skills/lark-slides/references/validation-checklist.md index a55ffaf99..ad446ecf4 100644 --- a/skills/lark-slides/references/validation-checklist.md +++ b/skills/lark-slides/references/validation-checklist.md @@ -44,6 +44,56 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input ` 转义 | | `bbox_overlap` | 文本元素的估算绘制区域明显重叠 | 拉开文本坐标、缩小文本框/字号,或改成明确的分栏/分组结构 | +## Automated SVGlide Plan And SVG Preflight + +走 `slides +create-svg` 前,必须先运行 SVG plan/source preflight: + +```bash +python3 skills/lark-slides/scripts/svg_preflight.py \ + --plan .lark-slides/plan//slide_plan.json \ + --input .lark-slides/plan//pages/page-001.svg +``` + +通过标准: + +- `summary.error_count == 0`,任何 error 都必须先修复再调用 live API。 +- `style_preset` 必须存在于 `references/style-presets.json`。 +- `style_selection_reason` 必须说明为什么这个 preset 适合当前 deck。 +- `style_system` 必须包含 palette、typography、background strategy 和 motif。 +- 每页必须包含 `visual_recipe`、`visual_signature`、`svg_effects`、`required_primitives`、`svg_primitives`、`xml_like_risk`、`content_density_contract`、`risk_flags`、`source_policy`。 +- declared `svg_effects` 和 `required_primitives` 必须能在对应 SVG source 中命中。 +- 可见 slide 文本不得泄漏 preset 名称、source token、prompt、tool name 或本地文件路径。 + +常见 code 的处理方向: + +| code | 含义 | 处理方式 | +|------|------|----------| +| `plan_style_preset_unknown` | plan 引用了不存在的 35 preset | 从 `style-presets.json` 选择有效 `style_id` | +| `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_style_preset_visible_leak` | 可见文本泄漏 preset 名/source token | 仅在 plan metadata 中保留 preset 信息,画面只写用户主题内容 | + +## SVGlide Aesthetic Preview Review + +`svg_preflight.py` 通过后,走 `slides +create-svg` 前还必须做本地预览审查。读取 [svg-aesthetic-review.md](svg-aesthetic-review.md),检查 rendered preview,而不是只看 plan 字段或静态 XML。 + +通过标准: + +- 所有页面都检查过,不只检查封面。 +- 无标题、正文、badge、装饰线、图片框、图表标签的明显重叠或裁切。 +- root 和主要内容遵循 `960 x 540` 画布和 safe area。 +- 每页有清晰 `visual_focal_point`,视觉焦点对应 `visual_signature`。 +- 页面不是普通卡片/bullet 页伪装成 SVG;应能看出 path、texture、chart geometry、connector flow、image overlay、icon system、dashboard frame 或其他 SVG-native 结构。 +- 多页没有重复出现同一个布局错误;如果有,必须修生成规则并重新生成相关页面。 +- 用户可见交付 deck 的审美目标默认不低于 `75/100`;低于 `65/100` 应重新生成或显式降级为草稿。 +- 验证记录包含 `preview_path`、`visual_score`、`threshold`、`issue_ids`、`action`。`action=create_live` 才能继续调用 live API;`action=repair_and_rerun` 必须先修 source SVG / plan 并重新跑 preflight。 + +这一步和 preflight 分工如下: + +- `svg_preflight.py`: 负责协议、plan、枚举、必填字段、bbox、primitive 命中和确定性错误。 +- `svg-aesthetic-review.md`: 负责截图/预览视角的层级、节奏、压迫感、重复问题、可读性和 SVG 视觉优势。 + ## Page Count And Structure - 实际页数必须等于用户要求或 `slide_plan.json` 的页数。 diff --git a/skills/lark-slides/references/visual-planning.md b/skills/lark-slides/references/visual-planning.md index e17c9da66..b58680462 100644 --- a/skills/lark-slides/references/visual-planning.md +++ b/skills/lark-slides/references/visual-planning.md @@ -20,6 +20,16 @@ - Keep backgrounds consistent with the deck's `visual_system.background_strategy`. Normal content pages should use the same base background unless there is a clear page-role reason to change. - Treat text fit as a layout constraint, not a cleanup step. If a text box is too small for the intended line count, shorten the text, split it, or allocate more space before creating XML. +## Title Zone Guardrails + +The title zone is the most common place for subtle overlap. Treat badges, decorative rules, headlines, and the first content row as one unit. + +- If a page uses a chapter badge, status pill, or small label above the headline, the headline text top must be at least `8` px below the badge bottom; prefer `12` px when the headline is bold or larger than `28` px. +- If a decorative horizontal line, accent rule, or divider sits above a headline, the line bottom must be at least `16` px above the headline text top; prefer `20-28` px when the headline is larger than `48` px. +- When a headline is moved down to create breathing room, move the subtitle, column headers, and main content start down together. Do not fix one collision by creating a new one below. +- Do not place large headlines directly under a top border or accent stripe. The decoration should frame the title, not press on it. +- Check the same page family across the whole deck. If one section/title page has a badge-headline collision, scan all pages with the same badge pattern before accepting the deck. + ## Background And Motif Consistency Decks can vary page backgrounds, but variation must be intentional and legible: @@ -47,6 +57,7 @@ Use these as conservative minimums on a 960 x 540 canvas. Increase height when u Additional rules: - Do not put long Chinese sentences or long English phrases into `height=18` or `height=22` boxes. Those heights are for short labels only. +- Text must fit both its own text box and its nearest visible container. A card, pill, footer bar, or table band should provide enough width and height for the visible wording with padding; do not rely on clipping, browser overflow, or SVG default wrapping to hide mistakes. - Footer/source text should usually be one short line. If it needs more, make it a real caption block above the footer area. - Bottom conclusion bars should be at least `40` px tall for one emphasized line and at least `54` px tall for two lines. - Diagram labels should be short enough to fit the shape. Prefer two short lines over one cramped long line. diff --git a/skills/lark-slides/scripts/svg_preflight.py b/skills/lark-slides/scripts/svg_preflight.py index ccf79174a..632a0a0e6 100644 --- a/skills/lark-slides/scripts/svg_preflight.py +++ b/skills/lark-slides/scripts/svg_preflight.py @@ -5,6 +5,7 @@ from __future__ import annotations import json +import math import re import sys import xml.etree.ElementTree as ET @@ -15,18 +16,222 @@ from typing import Any SLIDE_NS = "https://slides.bytedance.com/ns" XLINK_NS = "http://www.w3.org/1999/xlink" SVG_NS = "http://www.w3.org/2000/svg" +SVG_CONTRACT_VERSION = "svglide-authoring-contract/v1" CANVAS_WIDTH = 960.0 CANVAS_HEIGHT = 540.0 SAFE_AREA = {"x": 48.0, "y": 40.0, "width": 864.0, "height": 460.0} +BADGE_HEADLINE_MIN_GAP = 8.0 +DECORATIVE_LINE_HEADLINE_MIN_GAP = 16.0 +TEXT_CONTAINER_TOLERANCE = 2.0 NUMBER_RE = re.compile(r"^[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?(?:px)?$") PATH_NUMBER_RE = re.compile(r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?") FONT_SHORTHAND_RE = re.compile(r"(^|;)\s*font\s*:", re.IGNORECASE) +STYLE_IMAGE_OPACITY_RE = re.compile(r"(^|;)\s*opacity\s*:", re.IGNORECASE) +STYLE_STROKE_WIDTH_RE = re.compile(r"(^|;)\s*stroke-width\s*:", re.IGNORECASE) +STYLE_STROKE_DASHARRAY_RE = re.compile(r"(^|;)\s*stroke-dasharray\s*:", re.IGNORECASE) +RGB_RE = re.compile(r"rgba?\(([^)]+)\)", re.IGNORECASE) +FONT_SIZE_RE = re.compile(r"font-size\s*:\s*([0-9.]+)px?", re.IGNORECASE) +KEY_PATH_RE = re.compile(r"(critical|flow|journey|loop|main|path|rail|route|spine|timeline)", re.IGNORECASE) +PATH_LIKE_RE = re.compile( + r"(^|\s)(/Users/|/tmp/|/private/|\.{1,2}/|[A-Za-z]:\\|[A-Za-z0-9._-]+\.(?:json|md|py|go|svg|png|jpe?g))", + re.IGNORECASE, +) +TOOL_LEAK_RE = re.compile(r"\b(lark-cli|svg_preflight|beautiful-feishu-whiteboard|source_token|source prompt|tool name|file path|prompt:)\b", re.IGNORECASE) SUPPORTED_SHAPES = {"rect", "ellipse", "circle", "line", "path", "foreignObject"} RENDERABLE_TAGS = SUPPORTED_SHAPES | {"image", "text", "polygon", "polyline"} IGNORED_SUBTREES = {"defs", "style"} +VISUAL_RECIPE_CATALOG: dict[str, dict[str, Any]] = { + "hero_typography": { + "family": "hero", + "required_primitives": {"typography", "geometric_shape"}, + }, + "geometric_composition": { + "family": "geometry", + "required_primitives": {"geometric_shape", "path"}, + }, + "path_flow": { + "family": "flow", + "required_primitives": {"path", "annotation"}, + }, + "infographic_scorecard": { + "family": "data", + "required_primitives": {"typography", "micro_chart"}, + }, + "icon_capability_map": { + "family": "icon", + "required_primitives": {"icon", "geometric_shape"}, + }, + "gradient_depth": { + "family": "depth", + "required_primitives": {"gradient", "geometric_shape"}, + }, + "mask_clip_showcase": { + "family": "showcase", + "required_primitives": {"typography", "image_overlay"}, + }, + "technical_texture": { + "family": "texture", + "required_primitives": {"texture", "path"}, + }, + "metaphor_loop": { + "family": "flow", + "required_primitives": {"path", "geometric_shape"}, + }, + "spotlight_annotation": { + "family": "annotation", + "required_primitives": {"spotlight", "annotation"}, + }, + "fake_ui_dashboard": { + "family": "data", + "required_primitives": {"dashboard", "micro_chart"}, + }, + "brand_system": { + "family": "brand", + "required_primitives": {"typography", "geometric_shape"}, + }, +} + +PRIMITIVE_ALIASES = { + "annotation_line": "annotation", + "annotations": "annotation", + "bar": "micro_chart", + "bars": "micro_chart", + "callout": "annotation", + "callouts": "annotation", + "card": "geometric_shape", + "cards": "geometric_shape", + "chart": "micro_chart", + "charts": "micro_chart", + "circle": "geometric_shape", + "circles": "geometric_shape", + "clip": "image_overlay", + "clip_path": "image_overlay", + "clipPath": "image_overlay", + "dashboard_card": "dashboard", + "data_callout": "annotation", + "diagram": "geometric_shape", + "ellipse": "geometric_shape", + "ellipses": "geometric_shape", + "flow": "flow", + "glow": "spotlight", + "grid": "texture", + "highlight": "spotlight", + "hotspot": "spotlight", + "image": "image", + "image_mask": "image_overlay", + "image_overlay": "image_overlay", + "line": "annotation", + "lines": "annotation", + "mask": "image_overlay", + "metric": "micro_chart", + "metrics": "micro_chart", + "path": "path", + "paths": "path", + "rect": "geometric_shape", + "rectangle": "geometric_shape", + "shape": "geometric_shape", + "shapes": "geometric_shape", + "spotlight": "spotlight", + "text": "typography", + "texture": "texture", + "typography": "typography", + "ui": "dashboard", + "wireframe": "dashboard", +} + +SVG_EFFECT_CATALOG: dict[str, dict[str, Any]] = { + "chart_geometry": {"safe": True}, + "connector_flow": {"safe": True}, + "filter": {"safe": False, "requires_fallback": True}, + "gradient": {"safe": True}, + "grid_geometry": {"safe": True}, + "image_opacity": {"safe": False, "requires_fallback": True}, + "image_overlay": {"safe": True}, + "mask_clip": {"safe": False, "requires_fallback": True}, + "path": {"safe": True}, + "pattern": {"safe": False, "requires_fallback": True}, + "spotlight": {"safe": True}, + "stroke_dasharray": {"safe": False, "requires_fallback": True}, + "symbol": {"safe": False, "requires_fallback": True}, + "texture": {"safe": True}, + "typography": {"safe": True}, + "watermark_text": {"safe": True}, +} + +EFFECT_ALIASES = { + "bar": "chart_geometry", + "bars": "chart_geometry", + "chart": "chart_geometry", + "chart_geometry": "chart_geometry", + "clip": "mask_clip", + "clip_path": "mask_clip", + "clippath": "mask_clip", + "connector": "connector_flow", + "connector_flow": "connector_flow", + "connectors": "connector_flow", + "drop_shadow": "filter", + "filter": "filter", + "grid": "grid_geometry", + "grid_geometry": "grid_geometry", + "image_mask": "image_overlay", + "image_opacity": "image_opacity", + "image_overlay": "image_overlay", + "lineargradient": "gradient", + "linear_gradient": "gradient", + "mask": "mask_clip", + "mask_clip": "mask_clip", + "path": "path", + "pattern": "pattern", + "radialgradient": "gradient", + "radial_gradient": "gradient", + "shadow": "filter", + "spotlight": "spotlight", + "stroke_dasharray": "stroke_dasharray", + "symbol": "symbol", + "texture": "texture", + "typography": "typography", + "watermark": "watermark_text", + "watermark_text": "watermark_text", +} + +VISIBLE_PLAN_TEXT_KEYS = [ + "title", + "subtitle", + "key_message", + "takeaway", + "visible_source_note", + "speaker_intent", + "visual_signature", + "visual_intent", + "visual_focal_point", +] + + +def load_style_preset_catalog() -> dict[str, dict[str, Any]]: + path = Path(__file__).resolve().parent.parent / "references" / "style-presets.json" + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + presets = data.get("presets") if isinstance(data, dict) else None + if not isinstance(presets, list): + return {} + out: dict[str, dict[str, Any]] = {} + for preset in presets: + if not isinstance(preset, dict): + continue + raw_style_id = preset.get("style_id") + style_id = re.sub(r"[^a-z0-9]+", "_", str(raw_style_id or "").strip().lower()).strip("_") + if style_id: + out[style_id] = preset + return out + + +STYLE_PRESET_CATALOG = load_style_preset_catalog() + class SvgPreflightError(Exception): pass @@ -38,9 +243,16 @@ def fail(message: str) -> None: def parse_args(argv: list[str]) -> dict[str, Any]: inputs: list[str] = [] + plan: str | None = None index = 0 while index < len(argv): token = argv[index] + if token == "--plan": + if index + 1 >= len(argv): + fail("--plan requires a slide_plan.json path") + plan = argv[index + 1] + index += 2 + continue if token in {"--input", "-i"}: if index + 1 >= len(argv): fail(f"{token} requires a file path") @@ -53,7 +265,7 @@ def parse_args(argv: list[str]) -> dict[str, Any]: index += 1 if not inputs: fail("at least one --input is required") - return {"inputs": inputs} + return {"inputs": inputs, "plan": plan} def local_name(tag: str) -> str: @@ -158,6 +370,26 @@ def validate_root(root: ET.Element) -> tuple[list[dict[str, Any]], float, float] issues.append(issue("error", "root_not_svg", "root element must be ")) if svg_role(root) != "slide": issues.append(issue("error", "missing_root_role", 'root must include slide:role="slide"', root)) + contract_version = get_attr(root, "contract-version", SLIDE_NS) + if contract_version is None: + issues.append( + issue( + "error", + "root_contract_version_missing", + f'root must include slide:contract-version="{SVG_CONTRACT_VERSION}"', + root, + "The slide server rejects SVG roots without the SVGlide authoring contract marker.", + ) + ) + elif contract_version != SVG_CONTRACT_VERSION: + issues.append( + issue( + "error", + "root_contract_version_mismatch", + f'root must use slide:contract-version="{SVG_CONTRACT_VERSION}"', + root, + ) + ) width = parse_number(get_attr(root, "width")) height = parse_number(get_attr(root, "height")) @@ -240,11 +472,22 @@ def validate_roles_and_attrs(elements: list[ET.Element]) -> list[dict[str, Any]] if is_external_href(image_href): issues.append( issue( - "error", + "warning", "external_image_href", - " must not use http(s) or data href", + " uses an http(s) or data href", element, - 'Download or generate the image locally and use href="@./path", or provide a file token.', + 'MVP preflight allows this, but live conversion is more reliable with href="@./path" auto-upload or a file token.', + ) + ) + style = get_attr(element, "style") or "" + if get_attr(element, "opacity") is not None or STYLE_IMAGE_OPACITY_RE.search(style): + issues.append( + issue( + "warning", + "image_opacity_unsupported", + " opacity is not preserved by the current SVGlide conversion path", + element, + "MVP preflight allows this, but visual readback may differ; pre-blend opacity or place a semi-transparent rect overlay when fidelity matters.", ) ) else: @@ -294,6 +537,23 @@ def is_background_bbox(bbox: dict[str, float], canvas_width: float, canvas_heigh return bbox["x"] <= 0 and bbox["y"] <= 0 and bbox["x"] + bbox["width"] >= canvas_width and bbox["y"] + bbox["height"] >= canvas_height +def is_safe_area_exempt_backing(element: ET.Element, bbox: dict[str, float], canvas_width: float, canvas_height: float) -> bool: + name = local_name(element.tag) + if name not in {"rect", "image"}: + return False + full_height_edge = bbox["y"] <= 0 and bbox["y"] + bbox["height"] >= canvas_height and bbox["width"] >= canvas_width * 0.2 + full_width_edge = bbox["x"] <= 0 and bbox["x"] + bbox["width"] >= canvas_width and bbox["height"] >= canvas_height * 0.2 + if full_height_edge or full_width_edge: + return True + if name == "rect": + has_no_fill = (get_attr(element, "fill") or "").strip().lower() == "none" + has_stroke = bool(get_attr(element, "stroke") or parse_style_props(get_attr(element, "style")).get("stroke")) + large_frame = bbox["width"] >= canvas_width * 0.85 and bbox["height"] >= canvas_height * 0.85 + near_canvas_edge = bbox["x"] <= SAFE_AREA["x"] and bbox["y"] <= SAFE_AREA["y"] + return has_no_fill and has_stroke and large_frame and near_canvas_edge + return False + + def bbox_outside(bbox: dict[str, float], rect: dict[str, float]) -> bool: return ( bbox["x"] < rect["x"] @@ -312,6 +572,130 @@ def intersects(left: dict[str, float], right: dict[str, float]) -> bool: ) +def bbox_contains(outer: dict[str, float], inner: dict[str, float], tolerance: float = 0.5) -> bool: + return ( + inner["x"] >= outer["x"] - tolerance + and inner["y"] >= outer["y"] - tolerance + and inner["x"] + inner["width"] <= outer["x"] + outer["width"] + tolerance + and inner["y"] + inner["height"] <= outer["y"] + outer["height"] + tolerance + ) + + +def bbox_center(bbox: dict[str, float]) -> tuple[float, float]: + return (bbox["x"] + bbox["width"] / 2, bbox["y"] + bbox["height"] / 2) + + +def point_in_bbox(x: float, y: float, bbox: dict[str, float]) -> bool: + return bbox["x"] <= x <= bbox["x"] + bbox["width"] and bbox["y"] <= y <= bbox["y"] + bbox["height"] + + +def parse_style_props(style: str | None) -> dict[str, str]: + props: dict[str, str] = {} + if not style: + return props + for part in style.split(";"): + if ":" not in part: + continue + key, value = part.split(":", 1) + props[key.strip().lower()] = value.strip() + return props + + +def parse_css_number(value: str) -> float | None: + try: + value = value.strip() + if value.endswith("%"): + return float(value[:-1]) * 2.55 + return float(value) + except ValueError: + return None + + +def parse_color(value: str | None) -> tuple[float, float, float, float] | None: + if value is None: + return None + value = value.strip() + lower = value.lower() + if lower in {"none", "transparent"}: + return None + named = { + "white": (255.0, 255.0, 255.0, 1.0), + "black": (0.0, 0.0, 0.0, 1.0), + } + if lower in named: + return named[lower] + if lower.startswith("#"): + hex_value = lower[1:] + if len(hex_value) == 3: + hex_value = "".join(char * 2 for char in hex_value) + if len(hex_value) == 6: + try: + return (float(int(hex_value[0:2], 16)), float(int(hex_value[2:4], 16)), float(int(hex_value[4:6], 16)), 1.0) + except ValueError: + return None + match = RGB_RE.match(lower) + if match: + parts = [part.strip() for part in match.group(1).split(",")] + if len(parts) in {3, 4}: + channels = [parse_css_number(part) for part in parts[:3]] + if any(channel is None for channel in channels): + return None + alpha = 1.0 + if len(parts) == 4: + try: + alpha = float(parts[3]) + except ValueError: + return None + return (channels[0] or 0.0, channels[1] or 0.0, channels[2] or 0.0, alpha) + return None + + +def parse_opacity(value: str | None) -> float: + if value is None: + return 1.0 + try: + return max(0.0, min(1.0, float(value.strip()))) + except ValueError: + return 1.0 + + +def relative_luminance(color: tuple[float, float, float, float]) -> float: + def channel(value: float) -> float: + value = max(0.0, min(255.0, value)) / 255.0 + if value <= 0.03928: + return value / 12.92 + return ((value + 0.055) / 1.055) ** 2.4 + + r, g, b, _alpha = color + return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b) + + +def text_color(element: ET.Element) -> tuple[float, float, float, float] | None: + for child in element.iter(): + props = parse_style_props(get_attr(child, "style")) + color = parse_color(props.get("color")) + if color is not None: + return color + return None + + +def fill_color(element: ET.Element) -> tuple[float, float, float, float] | None: + props = parse_style_props(get_attr(element, "style")) + color = parse_color(get_attr(element, "fill") or props.get("fill")) + if color is None: + return None + opacity = parse_opacity(get_attr(element, "opacity") or props.get("opacity")) + return (color[0], color[1], color[2], color[3] * opacity) + + +def is_light_text_color(color: tuple[float, float, float, float]) -> bool: + return color[3] >= 0.8 and relative_luminance(color) >= 0.88 + + +def is_dark_backing_color(color: tuple[float, float, float, float]) -> bool: + return color[3] >= 0.65 and relative_luminance(color) <= 0.45 + + def validate_geometry(elements: list[ET.Element], canvas_width: float, canvas_height: float) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: issues: list[dict[str, Any]] = [] text_boxes: list[dict[str, Any]] = [] @@ -321,6 +705,17 @@ def validate_geometry(elements: list[ET.Element], canvas_width: float, canvas_he bbox = bbox_for_element(element) if bbox is None: continue + if name != "line" and (bbox["width"] <= 0 or bbox["height"] <= 0): + issues.append( + issue( + "error", + "non_positive_bbox", + f"<{name}> must have positive width and height", + element, + "Do not submit zero-size text boxes, cards, images, circles, or ellipses; allocate real space or remove the element.", + ) + ) + continue if is_background_bbox(bbox, canvas_width, canvas_height): continue if bbox_outside(bbox, canvas): @@ -333,7 +728,7 @@ 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): + elif bbox_outside(bbox, SAFE_AREA) and not is_safe_area_exempt_backing(element, bbox, canvas_width, canvas_height): issues.append( issue( "warning", @@ -370,6 +765,469 @@ def validate_text_overlap(text_boxes: list[dict[str, Any]]) -> list[dict[str, An return issues +def text_line_height(element: ET.Element, font_size: float) -> float: + for child in element.iter(): + props = parse_style_props(get_attr(child, "style")) + value = props.get("line-height") + if not value: + continue + lower = value.strip().lower() + try: + if lower.endswith("px"): + return max(font_size, float(lower[:-2])) + return max(font_size, font_size * float(lower)) + except ValueError: + continue + return font_size * 1.25 + + +def text_width_units(text: str) -> float: + units = 0.0 + for char in text: + if char.isspace(): + units += 0.35 + elif ord(char) > 127: + units += 1.0 + elif char in "MW@#%&": + units += 0.9 + elif char.isupper() or char.isdigit(): + units += 0.66 + elif char in "il.,:;|": + units += 0.28 + else: + units += 0.52 + return units + + +def text_required_size(text: str, font_size: float, max_width: float) -> tuple[float, float, int]: + lines = [line.strip() for line in re.split(r"[\r\n]+", text) if line.strip()] + if not lines: + return (0.0, 0.0, 0) + longest = max(text_width_units(line) * font_size for line in lines) + available_width = max(1.0, max_width) + wrapped_lines = sum(max(1, math.ceil((text_width_units(line) * font_size) / available_width)) for line in lines) + return (longest, wrapped_lines * font_size * 1.25, wrapped_lines) + + +def horizontal_overlap(left: dict[str, float], right: dict[str, float], tolerance: float = 0.0) -> bool: + return left["x"] < right["x"] + right["width"] + tolerance and left["x"] + left["width"] + tolerance > right["x"] + + +def bbox_right(bbox: dict[str, float]) -> float: + return bbox["x"] + bbox["width"] + + +def bbox_bottom(bbox: dict[str, float]) -> float: + return bbox["y"] + bbox["height"] + + +def is_top_badge(element: ET.Element, bbox: dict[str, float]) -> bool: + if local_name(element.tag) != "rect" or svg_role(element) != "shape": + return False + return bbox["x"] <= 220 and bbox["y"] <= 90 and 36 <= bbox["width"] <= 190 and 18 <= bbox["height"] <= 56 + + +def is_headline_text(item: dict[str, Any]) -> bool: + bbox = item["bbox"] + element = item["element"] + font_size = text_font_size(element) or 0.0 + return bbox["x"] <= 260 and bbox["y"] <= 155 and (font_size >= 24 or bbox["height"] >= 36) and bbox["width"] >= 140 + + +def is_visible_container_rect(element: ET.Element, bbox: dict[str, float], canvas_width: float, canvas_height: float) -> bool: + if local_name(element.tag) != "rect" or svg_role(element) != "shape": + return False + if is_background_bbox(bbox, canvas_width, canvas_height): + return False + if is_safe_area_exempt_backing(element, bbox, canvas_width, canvas_height): + return False + fill = (get_attr(element, "fill") or parse_style_props(get_attr(element, "style")).get("fill") or "").strip().lower() + stroke = (get_attr(element, "stroke") or parse_style_props(get_attr(element, "style")).get("stroke") or "").strip().lower() + if fill in {"none", "transparent"} and not stroke: + return False + return bbox["width"] >= 24 and bbox["height"] >= 18 + + +def is_decorative_horizontal_rule(element: ET.Element, bbox: dict[str, float]) -> bool: + name = local_name(element.tag) + identifier = element_identifier_text(element) + if name == "line": + return bbox["width"] >= 180 and bbox["height"] <= 3 + if name == "rect" and bbox["width"] >= 180 and 1 <= bbox["height"] <= 10: + return True + return bool(re.search(r"(divider|rule|line|stripe|bar|decor)", identifier)) and bbox["width"] >= 160 and bbox["height"] <= 14 + + +def validate_layout_pressure( + elements: list[ET.Element], + text_boxes: list[dict[str, Any]], + canvas_width: float, + canvas_height: float, +) -> list[dict[str, Any]]: + issues: list[dict[str, Any]] = [] + bboxes = [(element, bbox_for_element(element)) for element in elements] + shaped = [(element, bbox) for element, bbox in bboxes if bbox is not None] + badges = [(element, bbox) for element, bbox in shaped if is_top_badge(element, bbox)] + headlines = [item for item in text_boxes if is_headline_text(item)] + + for headline in headlines: + headline_bbox = headline["bbox"] + for badge_element, badge_bbox in badges: + if not horizontal_overlap(badge_bbox, headline_bbox, tolerance=80): + continue + gap = headline_bbox["y"] - bbox_bottom(badge_bbox) + if gap < BADGE_HEADLINE_MIN_GAP: + issue_item = issue( + "error", + "badge_headline_overlap", + "chapter/status badge is too close to the headline", + headline["element"], + "Move the headline down or move the badge up; keep at least 8px between badge bottom and headline top, preferably 12px for bold headlines.", + ) + issue_item["badge_element_id"] = get_attr(badge_element, "id") + issue_item["gap"] = round(gap, 2) + issues.append(issue_item) + break + + containers = [(element, bbox) for element, bbox in shaped if is_visible_container_rect(element, bbox, canvas_width, canvas_height)] + for item in text_boxes: + element = item["element"] + bbox = item["bbox"] + text = textify(item.get("text")).strip() + font_size = text_font_size(element) or 14.0 + required_width, required_height, required_lines = text_required_size(text, font_size, bbox["width"]) + if text and (required_width > bbox["width"] + TEXT_CONTAINER_TOLERANCE and required_lines <= 1 or required_height > bbox["height"] + TEXT_CONTAINER_TOLERANCE): + overflow_issue = issue( + "error", + "text_container_overflow", + "visible text does not fit within its text box", + element, + "Increase the text box, shorten the wording, split the sentence into multiple lines, or reduce font size before creating the slide.", + ) + overflow_issue["required_width"] = round(required_width, 2) + overflow_issue["required_height"] = round(required_height, 2) + issues.append(overflow_issue) + + center_x, center_y = bbox_center(bbox) + containing = [ + (container_element, container_bbox) + for container_element, container_bbox in containers + if point_in_bbox(center_x, center_y, container_bbox) and container_bbox["width"] * container_bbox["height"] >= bbox["width"] * bbox["height"] + ] + if not containing: + continue + container_element, container_bbox = min(containing, key=lambda item_: item_[1]["width"] * item_[1]["height"]) + if not bbox_contains(container_bbox, bbox, tolerance=TEXT_CONTAINER_TOLERANCE): + overflow_issue = issue( + "error", + "text_container_overflow", + "text box extends beyond its nearest visual container", + element, + "Make the card, pill, footer bar, or table band large enough for the visible text, or move the text back inside the container.", + ) + overflow_issue["container_element_id"] = get_attr(container_element, "id") + issues.append(overflow_issue) + + decorative_rules = [(element, bbox) for element, bbox in shaped if is_decorative_horizontal_rule(element, bbox)] + for headline in headlines: + headline_bbox = headline["bbox"] + for rule_element, rule_bbox in decorative_rules: + if bbox_bottom(rule_bbox) > headline_bbox["y"]: + continue + if not horizontal_overlap(rule_bbox, headline_bbox, tolerance=12): + continue + gap = headline_bbox["y"] - bbox_bottom(rule_bbox) + if 0 <= gap < DECORATIVE_LINE_HEADLINE_MIN_GAP: + pressure_issue = issue( + "warning", + "decorative_line_title_pressure", + "decorative horizontal rule is too close to the headline", + headline["element"], + "Keep decorative line groups at least 16px above headline text, preferably 20-28px for large titles.", + ) + pressure_issue["rule_element_id"] = get_attr(rule_element, "id") + pressure_issue["gap"] = round(gap, 2) + issues.append(pressure_issue) + break + + return issues + + +def validate_visible_svg_text_leaks(text_boxes: list[dict[str, Any]]) -> list[dict[str, Any]]: + issues: list[dict[str, Any]] = [] + style_terms: list[str] = [] + for preset in STYLE_PRESET_CATALOG.values(): + for value in [preset.get("style_id"), preset.get("display_name"), preset.get("source_token")]: + term = textify(value).strip() + if term: + style_terms.append(term) + style_terms.append("beautiful-feishu-whiteboard") + + for item in text_boxes: + text = textify(item.get("text")).strip() + if not text: + continue + lower = text.lower() + leaked_style_terms = [term for term in style_terms if term.lower() in lower] + if leaked_style_terms or TOOL_LEAK_RE.search(text) or PATH_LIKE_RE.search(text): + element = item.get("element") + issues.append( + issue( + "error", + "visible_svg_metadata_leak", + "visible SVG text must not expose style preset names, source tokens, prompts, tool names, or local file paths", + element if isinstance(element, ET.Element) else None, + "Keep preset metadata and generation notes in slide_plan.json; visible SVG text should only contain user-facing deck content.", + ) + ) + return issues + + +def element_identifier_text(element: ET.Element) -> str: + parts = [ + get_attr(element, "id") or "", + get_attr(element, "class") or "", + get_attr(element, "aria-label") or "", + ] + return " ".join(part for part in parts if part).lower() + + +def text_font_size(element: ET.Element) -> float | None: + for child in element.iter(): + style = get_attr(child, "style") or "" + match = FONT_SIZE_RE.search(style) + if match: + try: + return float(match.group(1)) + except ValueError: + return None + return None + + +def is_card_like_rect(element: ET.Element, bbox: dict[str, float], canvas_width: float, canvas_height: float) -> bool: + if local_name(element.tag) != "rect" or svg_role(element) != "shape": + return False + if is_background_bbox(bbox, canvas_width, canvas_height): + return False + return 70 <= bbox["width"] <= 420 and 40 <= bbox["height"] <= 240 + + +def summarize_visual_primitives(root: ET.Element, elements: list[ET.Element], text_boxes: list[dict[str, Any]], canvas_width: float, canvas_height: float) -> dict[str, Any]: + counts = { + "path": 0, + "line": 0, + "rect": 0, + "circle": 0, + "ellipse": 0, + "image": 0, + "foreignObject": 0, + "card_like_rect": 0, + "small_shape": 0, + "bar_like_rect": 0, + } + identifiers: list[str] = [] + semi_transparent_rects: list[dict[str, float]] = [] + + for element in elements: + name = local_name(element.tag) + if name in counts: + counts[name] += 1 + identifier = element_identifier_text(element) + if identifier: + identifiers.append(identifier) + bbox = bbox_for_element(element) + if bbox is None: + continue + area = bbox["width"] * bbox["height"] + if is_card_like_rect(element, bbox, canvas_width, canvas_height): + counts["card_like_rect"] += 1 + if svg_role(element) == "shape" and name in {"rect", "circle", "ellipse", "line", "path"} and area <= 3600: + counts["small_shape"] += 1 + if name == "rect" and 8 <= bbox["width"] <= 240 and 4 <= bbox["height"] <= 70: + counts["bar_like_rect"] += 1 + if name == "rect": + color = fill_color(element) + if color is not None and 0.05 < color[3] < 0.85: + semi_transparent_rects.append(bbox) + + root_identifiers = " ".join(identifiers) + gradients = sum(1 for element in root.iter() if local_name(element.tag) in {"linearGradient", "radialGradient"}) + patterns = sum(1 for element in root.iter() if local_name(element.tag) == "pattern") + masks = sum(1 for element in root.iter() if local_name(element.tag) in {"mask", "clipPath"}) + symbols = sum(1 for element in root.iter() if local_name(element.tag) in {"symbol", "use"}) + filters = sum(1 for element in root.iter() if local_name(element.tag) == "filter") + filter_refs = sum(1 for element in root.iter() if get_attr(element, "filter") or "filter:" in (get_attr(element, "style") or "")) + mask_refs = sum(1 for element in root.iter() if get_attr(element, "mask") or get_attr(element, "clip-path") or "clip-path:" in (get_attr(element, "style") or "")) + image_opacity = sum( + 1 + for element in root.iter() + if local_name(element.tag) == "image" + and (get_attr(element, "opacity") is not None or STYLE_IMAGE_OPACITY_RE.search(get_attr(element, "style") or "")) + ) + dasharrays = sum( + 1 + for element in root.iter() + if get_attr(element, "stroke-dasharray") is not None or STYLE_STROKE_DASHARRAY_RE.search(get_attr(element, "style") or "") + ) + large_text = 0 + for item in text_boxes: + bbox = item["bbox"] + font_size = text_font_size(item["element"]) or 0 + if font_size >= 42 or (bbox["width"] >= 300 and bbox["height"] >= 76): + large_text += 1 + + primitives: set[str] = set() + if counts["path"]: + primitives.add("path") + primitives.add("geometric_shape") + if counts["line"]: + primitives.add("annotation") + primitives.add("geometric_shape") + if counts["rect"] or counts["circle"] or counts["ellipse"]: + primitives.add("geometric_shape") + if counts["image"]: + primitives.add("image") + if counts["image"] and semi_transparent_rects: + primitives.add("image_overlay") + if gradients: + primitives.add("gradient") + if filters or filter_refs: + primitives.add("spotlight") + if large_text: + primitives.add("typography") + if counts["bar_like_rect"] >= 3 or re.search(r"(chart|metric|score|kpi|bar)", root_identifiers): + primitives.add("micro_chart") + if counts["card_like_rect"] >= 3 and (counts["bar_like_rect"] >= 2 or re.search(r"(dashboard|console|panel|metric)", root_identifiers)): + primitives.add("dashboard") + if counts["small_shape"] >= 6 or re.search(r"(icon|glyph|capability)", root_identifiers): + primitives.add("icon") + if counts["path"] and (counts["circle"] + counts["ellipse"] >= 2 or re.search(r"(route|journey|flow|loop|path)", root_identifiers)): + primitives.add("flow") + if counts["small_shape"] >= 10 or re.search(r"(texture|grid|dot|scan|pattern)", root_identifiers): + primitives.add("texture") + if counts["line"] or counts["path"] or re.search(r"(annotation|callout|label|legend)", root_identifiers): + if text_boxes: + primitives.add("annotation") + if re.search(r"(spotlight|hotspot|highlight|focus)", root_identifiers): + primitives.add("spotlight") + if re.search(r"(brand|system|identity)", root_identifiers): + primitives.add("brand_system") + + effects: set[str] = set() + if counts["path"]: + effects.add("path") + if counts["line"] or counts["path"]: + effects.add("connector_flow") + if counts["bar_like_rect"] >= 3: + effects.add("chart_geometry") + if gradients: + effects.add("gradient") + if counts["image"] and semi_transparent_rects: + effects.add("image_overlay") + if filters or filter_refs: + effects.add("filter") + effects.add("spotlight") + if patterns: + effects.add("pattern") + if masks or mask_refs: + effects.add("mask_clip") + if symbols: + effects.add("symbol") + if image_opacity: + effects.add("image_opacity") + if dasharrays: + effects.add("stroke_dasharray") + if counts["small_shape"] >= 10 or re.search(r"(texture|grid|dot|scan|pattern)", root_identifiers): + effects.add("texture") + if text_boxes: + effects.add("typography") + if re.search(r"(watermark|page-mark|ghost)", root_identifiers): + effects.add("watermark_text") + + return { + "present": sorted(primitives), + "counts": counts, + "gradient_count": gradients, + "filter_count": filters + filter_refs, + "effects": sorted(effects), + } + + +def validate_xml_like_layout(elements: list[ET.Element], text_boxes: list[dict[str, Any]], primitive_summary: dict[str, Any]) -> list[dict[str, Any]]: + counts = primitive_summary["counts"] + present = set(primitive_summary["present"]) + svg_native = present & {"path", "gradient", "image", "image_overlay", "annotation", "micro_chart", "dashboard", "icon", "texture", "spotlight", "flow"} + if counts["card_like_rect"] >= 3 and len(text_boxes) >= 3 and not svg_native: + return [ + { + "level": "error", + "code": "xml_like_svg_layout", + "message": "SVG page degenerates into card-like rect + text layout without SVG-native visual primitives", + "hint": "Use a visual_recipe with path, gradient, image overlay, annotation, icon system, micro chart, texture, spotlight, or flow primitives; otherwise prefer the XML/SXSD path.", + } + ] + return [] + + +def validate_visual_quality(elements: list[ET.Element]) -> list[dict[str, Any]]: + issues: list[dict[str, Any]] = [] + dark_backings: list[dict[str, Any]] = [] + node_containers: list[dict[str, Any]] = [] + + for element in elements: + name = local_name(element.tag) + role = svg_role(element) + bbox = bbox_for_element(element) + if bbox is None: + continue + + if role == "shape" and name in {"rect", "circle", "ellipse", "path"}: + color = fill_color(element) + if color is not None and is_dark_backing_color(color): + dark_backings.append({"element": element, "bbox": bbox}) + + if role == "shape" and name in {"circle", "ellipse"} and 16 <= bbox["width"] <= 140 and 16 <= bbox["height"] <= 140: + node_containers.append({"element": element, "bbox": bbox}) + + if name != "foreignObject" or role != "shape" or svg_shape_type(element) != "text": + continue + if not "".join(element.itertext()).strip(): + continue + + color = text_color(element) + if color is not None and is_light_text_color(color): + if not any(bbox_contains(backing["bbox"], bbox) for backing in dark_backings): + issues.append( + issue( + "error", + "light_text_without_dark_backing", + "light text must be fully contained by an explicit dark backing shape", + element, + "Keep white/light text inside a dark rect/card/overlay, or switch to dark text before the text crosses onto a light image or white background.", + ) + ) + + center_x, center_y = bbox_center(bbox) + containing_nodes = [container for container in node_containers if point_in_bbox(center_x, center_y, container["bbox"])] + if containing_nodes: + container = max(containing_nodes, key=lambda item: item["bbox"]["width"] * item["bbox"]["height"]) + container_bbox = container["bbox"] + if bbox["width"] > container_bbox["width"] + 1 or bbox["height"] > container_bbox["height"] + 1: + overflow_issue = issue( + "error", + "node_text_overflow", + "text inside a circle/ellipse node must fit within the node bounds", + element, + "Put only short labels inside round nodes; move explanations to separate callout cards, legends, or a mechanism table.", + ) + container_id = get_attr(container["element"], "id") + if container_id: + overflow_issue["container_element_id"] = container_id + issues.append(overflow_issue) + + return issues + + def validate_styles(root: ET.Element) -> list[dict[str, Any]]: issues: list[dict[str, Any]] = [] for element in root.iter(): @@ -384,6 +1242,39 @@ def validate_styles(root: ET.Element) -> list[dict[str, Any]]: "Use explicit font-size, font-weight, font-family, color, line-height, and text-align properties.", ) ) + name = local_name(element.tag) + if name in {"circle", "ellipse"} and (get_attr(element, "stroke-width") is not None or STYLE_STROKE_WIDTH_RE.search(style)): + issues.append( + issue( + "warning", + "ellipse_stroke_width_unstable", + "/ stroke-width may be downgraded during SVGlide conversion", + element, + "Use a two-shape ring, or convert the outline to a path/rect when border width is visually important.", + ) + ) + if get_attr(element, "stroke-dasharray") is not None or STYLE_STROKE_DASHARRAY_RE.search(style): + identifier = element_identifier_text(element) + if KEY_PATH_RE.search(identifier): + issues.append( + issue( + "error", + "stroke_dasharray_key_path", + "key routes, loops, flows, timelines, and rails must not rely on stroke-dasharray", + element, + "Use explicit short line segments or filled dot markers for important routes before calling slides +create-svg.", + ) + ) + continue + issues.append( + issue( + "warning", + "stroke_dasharray_unstable", + "stroke-dasharray may be downgraded during SVGlide conversion", + element, + "Use explicit short line segments or filled dot markers when dashed routes are visually important.", + ) + ) return issues @@ -416,6 +1307,892 @@ def validate_paths(elements: list[ET.Element]) -> list[dict[str, Any]]: return issues +PLAN_STRUCTURED_RE = re.compile( + r"(architecture|atlas|board|canvas|cards|chart|cohort|comparison|dashboard|flow|funnel|grid|heatmap|lane|map|matrix|model|network|pipeline|process|pyramid|quadrant|roadmap|route|scorecard|stack|swimlane|system|table|timeline)", + re.IGNORECASE, +) +CONTENT_DENSITY_KIND_RE = re.compile(r"(architecture|comparison|dashboard|flow|map|matrix|risk[_ -]?grid|scorecard|table|timeline)", re.IGNORECASE) +CONTENT_DENSITY_COUNT_RE = re.compile(r"(?:>=|=>|at\s+least|min(?:imum)?|不少于)\s*([0-9]+)|([0-9]+)\s*(?:\+|or\s+more)", re.IGNORECASE) +PLAN_CLOSING_RE = re.compile(r"(closing|conclusion|q-and-a|q&a|summary|thanks|致谢|总结|展望|下一步|行动号召|启动)", re.IGNORECASE) +PLAN_MISSING_SOURCE_RE = re.compile(r"(missing|unavailable|pending|缺失|待补|待从|未提供|来源不足)", re.IGNORECASE) +PLAN_SOURCE_GUARD_RE = re.compile(r"(no numeric|source guard|pending|待补|待从|缺失|来源|未提供|不编造|不虚构|占位)", re.IGNORECASE) +NO_ASSET_RE = re.compile(r"(none|no[_ -]?asset|no[_ -]?image|not[_ -]?needed|无|不需要)", re.IGNORECASE) + + +def plan_issue(level: str, code: str, message: str, slide: dict[str, Any] | None = None, hint: str | None = None) -> dict[str, Any]: + out: dict[str, Any] = {"level": level, "code": code, "message": message} + if slide is not None and "page" in slide: + out["page"] = slide.get("page") + if hint: + out["hint"] = hint + return out + + +def textify(value: Any) -> str: + if value is None: + return "" + if isinstance(value, str): + return value + if isinstance(value, (int, float, bool)): + return str(value) + if isinstance(value, list): + return " ".join(textify(item) for item in value) + if isinstance(value, dict): + return " ".join(textify(item) for item in value.values()) + return str(value) + + +def normalize_name(value: Any) -> str: + return re.sub(r"[^a-z0-9]+", "_", textify(value).strip().lower()).strip("_") + + +def normalize_primitive(value: Any) -> str: + name = normalize_name(value) + return PRIMITIVE_ALIASES.get(name, name) + + +def normalize_primitives(value: Any) -> set[str]: + if isinstance(value, list): + return {normalize_primitive(item) for item in value if normalize_primitive(item)} + if isinstance(value, str): + parts = [part for part in re.split(r"[,/|;\s]+", value) if part] + return {normalize_primitive(part) for part in parts if normalize_primitive(part)} + return set() + + +def normalize_effect(value: Any) -> str: + name = normalize_name(value) + return EFFECT_ALIASES.get(name, name) + + +def normalize_effects(value: Any) -> set[str]: + if isinstance(value, list): + out: set[str] = set() + for item in value: + if isinstance(item, dict): + effect = normalize_effect(item.get("effect") or item.get("name") or item.get("type")) + else: + effect = normalize_effect(item) + if effect: + out.add(effect) + return out + if isinstance(value, str): + parts = [part for part in re.split(r"[,/|;\s]+", value) if part] + return {normalize_effect(part) for part in parts if normalize_effect(part)} + return set() + + +def nested_dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def style_preset_id_from(value: Any) -> str: + if isinstance(value, str): + return normalize_name(value) + if not isinstance(value, dict): + return "" + for key in ["preset_id", "style_id", "id", "style_preset"]: + preset_id = normalize_name(value.get(key)) + if preset_id: + return preset_id + style_binding = value.get("style_binding") + if isinstance(style_binding, dict): + preset_id = style_preset_id_from(style_binding) + if preset_id: + return preset_id + return "" + + +def deck_style_preset_id(plan: dict[str, Any]) -> str: + for value in [ + plan.get("style_preset"), + plan.get("style_binding"), + nested_dict(plan.get("style_system")).get("style_preset"), + nested_dict(plan.get("style_system")).get("style_binding"), + nested_dict(plan.get("theme_system")).get("style_preset"), + nested_dict(plan.get("theme_system")).get("style_binding"), + ]: + preset_id = style_preset_id_from(value) + if preset_id: + return preset_id + return "" + + +def slide_style_preset_id(slide: dict[str, Any]) -> str: + for value in [slide.get("style_preset"), slide.get("style_binding"), nested_dict(slide.get("visual_plan")).get("style_binding")]: + preset_id = style_preset_id_from(value) + if preset_id: + return preset_id + return "" + + +def style_system(plan: dict[str, Any]) -> dict[str, Any]: + system = plan.get("style_system") + if isinstance(system, dict): + return system + theme = plan.get("theme_system") + if isinstance(theme, dict): + return theme + return {} + + +def slide_visual_plan(slide: dict[str, Any]) -> dict[str, Any]: + visual_plan = slide.get("visual_plan") + if isinstance(visual_plan, dict): + merged = dict(slide) + merged.update(visual_plan) + return merged + return slide + + +def has_effect_fallback(slide: dict[str, Any], effect: str) -> bool: + candidates = [ + slide.get("effect_fallbacks"), + slide.get("svg_effect_fallbacks"), + slide.get("safe_rewrite"), + slide.get("recipe_fallback"), + slide.get("fallback_policy"), + nested_dict(slide.get("visual_plan")).get("effect_fallbacks"), + nested_dict(slide.get("visual_plan")).get("safe_rewrite"), + nested_dict(slide.get("visual_plan")).get("fallback_policy"), + ] + text = " ".join(textify(value) for value in candidates) + if not text.strip(): + return False + normalized_text = normalize_name(text) + return "fallback" in normalized_text or "rewrite" in normalized_text or effect in normalized_text + + +def visible_slide_text(slide: dict[str, Any]) -> str: + visual_plan = nested_dict(slide.get("visual_plan")) + parts = [textify(slide.get(key)) for key in VISIBLE_PLAN_TEXT_KEYS] + parts.extend(textify(visual_plan.get(key)) for key in VISIBLE_PLAN_TEXT_KEYS) + return " ".join(part for part in parts if part).strip() + + +def recipe_family(recipe: str) -> str: + return textify(VISUAL_RECIPE_CATALOG.get(recipe, {}).get("family") or recipe) + + +def plan_slide_text(slide: dict[str, Any], keys: list[str] | None = None) -> str: + if keys is None: + return textify(slide) + return " ".join(textify(slide.get(key)) for key in keys) + + +def slide_renderer_id(slide: dict[str, Any]) -> str: + return textify(slide.get("renderer_id") or slide.get("layout_type") or slide.get("visual_structure")).strip() + + +def slide_layout_family(slide: dict[str, Any]) -> str: + return textify(slide.get("layout_family")).strip() + + +def required_plan_primitives(slide: dict[str, Any]) -> set[str]: + return normalize_primitives(slide.get("required_primitives")) + + +def density_contract_kind_count(contract: Any) -> tuple[str, int] | None: + if isinstance(contract, dict): + kind = normalize_name(contract.get("type") or contract.get("kind") or contract.get("structure")) + count = None + for key, value in contract.items(): + normalized_key = normalize_name(key) + if normalized_key in {"min", "minimum", "min_count", "count"} or normalized_key.startswith("min_"): + try: + count = int(value) + break + except (TypeError, ValueError): + continue + if kind and count is not None: + return kind, count + return None + text = textify(contract) + kind_match = CONTENT_DENSITY_KIND_RE.search(text) + count_match = CONTENT_DENSITY_COUNT_RE.search(text) + if not kind_match or not count_match: + return None + count_value = count_match.group(1) or count_match.group(2) + try: + return normalize_name(kind_match.group(1)), int(count_value) + except (TypeError, ValueError): + return None + + +def asset_contract_present(contract: Any) -> bool: + if contract is None: + return False + if isinstance(contract, str): + return bool(contract.strip()) + if isinstance(contract, list): + return True + if isinstance(contract, dict): + return True + return False + + +def asset_contract_has_metadata(contract: Any) -> bool: + if isinstance(contract, list): + return bool(contract) and all(asset_contract_has_metadata(item) for item in contract) + if not isinstance(contract, dict): + return False + source_type = textify(contract.get("source_type") or contract.get("source")).strip() + license_text = textify(contract.get("license")).strip() + local_path = textify(contract.get("local_path") or contract.get("local_path_or_href") or contract.get("href") or contract.get("path")).strip() + usage_page = textify(contract.get("usage_page") or contract.get("page")).strip() + source_url = textify(contract.get("source_url") or contract.get("href")).strip() + generated_by = textify(contract.get("generated_by")).strip() + retrieval_query = textify(contract.get("retrieval_query") or contract.get("image_search_query") or contract.get("query")).strip() + no_url_source_types = {"original", "procedural", "ai_generated", "user_provided", "owned", "screenshot", "web_search_preview", "public_url"} + no_query_source_types = {"original", "procedural", "ai_generated", "user_provided", "owned"} + normalized_source_type = source_type.lower() + has_source_reference = source_url or generated_by or normalized_source_type in no_url_source_types + has_query_reference = retrieval_query or normalized_source_type in no_query_source_types + return bool(source_type and license_text and local_path and usage_page and has_source_reference and has_query_reference) + + +def asset_contract_declares_no_asset(contract: Any) -> bool: + return bool(isinstance(contract, str) and NO_ASSET_RE.search(contract)) + + +def source_density_count(kind: str, primitive_summary: dict[str, Any]) -> int: + counts = primitive_summary.get("counts", {}) + kind = normalize_name(kind) + if kind in {"matrix", "table", "comparison", "risk_grid"}: + return int(counts.get("card_like_rect", 0)) + if kind in {"dashboard", "scorecard"}: + return max(int(counts.get("card_like_rect", 0)), int(counts.get("bar_like_rect", 0))) + if kind in {"flow", "timeline"}: + if int(counts.get("path", 0)) <= 0: + return 0 + return int(counts.get("circle", 0)) + int(counts.get("ellipse", 0)) + int(counts.get("small_shape", 0)) + if kind in {"map", "architecture"}: + return int(counts.get("card_like_rect", 0)) + int(counts.get("path", 0)) + return 0 + + +def lint_plan(plan: dict[str, Any], path: str = "") -> dict[str, Any]: + issues: list[dict[str, Any]] = [] + slides = plan.get("slides") + if not isinstance(slides, list) or not slides: + issues.append(plan_issue("error", "plan_missing_slides", "slide_plan.json must include a non-empty slides array")) + return { + "path": path, + "issues": issues, + "summary": {"slide_count": 0, "error_count": 1, "warning_count": 0}, + } + + page_count = plan.get("page_count") or plan.get("target_slide_count") + if page_count is not None: + try: + expected_count = int(page_count) + if expected_count != len(slides): + issues.append( + plan_issue( + "error", + "plan_page_count_mismatch", + f"plan page_count is {expected_count}, but slides array has {len(slides)} items", + ) + ) + except (TypeError, ValueError): + issues.append(plan_issue("error", "plan_page_count_invalid", "plan page_count must be an integer when present")) + + is_svg_plan = plan.get("output_mode") == "svglide-svg" + deck_preset_id = deck_style_preset_id(plan) + deck_style_system = style_system(plan) + if is_svg_plan: + if not STYLE_PRESET_CATALOG: + issues.append( + plan_issue( + "error", + "plan_style_preset_catalog_unavailable", + "SVGlide style preset catalog is unavailable", + None, + "Ensure references/style-presets.json exists and contains the 35 beautiful-feishu-whiteboard presets.", + ) + ) + if not deck_preset_id: + issues.append( + plan_issue( + "error", + "plan_missing_style_preset", + "SVGlide plan must include a deck-level style_preset or style_binding.preset_id", + None, + "Choose one preset from references/style-presets.json before generating SVG.", + ) + ) + elif deck_preset_id not in STYLE_PRESET_CATALOG: + issues.append( + plan_issue( + "error", + "plan_style_preset_unknown", + f'unknown style_preset "{deck_preset_id}"', + None, + "Use one of: " + ", ".join(sorted(STYLE_PRESET_CATALOG)), + ) + ) + if not textify(plan.get("style_selection_reason")).strip(): + issues.append( + plan_issue( + "error", + "plan_missing_style_selection_reason", + "SVGlide plan must explain why the selected style_preset fits this deck", + None, + "Add style_selection_reason so style choice is auditable and not a random skin.", + ) + ) + if not deck_style_system: + issues.append( + plan_issue( + "error", + "plan_missing_style_system", + "SVGlide plan must include deck-level style_system or theme_system", + None, + "Translate the selected preset into palette, typography, background_strategy, motif, and shape language.", + ) + ) + else: + for field in ["palette", "typography", "background_strategy", "motif"]: + if not textify(deck_style_system.get(field)).strip(): + issues.append( + plan_issue( + "error", + f"plan_style_system_missing_{field}", + f"SVGlide style_system must include {field}", + ) + ) + + renderer_ids: list[str] = [] + layout_families: list[str] = [] + visual_recipes: list[str] = [] + visual_recipe_families: list[str] = [] + for slide in slides: + if not isinstance(slide, dict): + issues.append(plan_issue("error", "plan_slide_invalid", "each slide entry must be an object")) + continue + + visual_plan = slide_visual_plan(slide) + + renderer_id = slide_renderer_id(visual_plan) + renderer_ids.append(renderer_id) + layout_family = slide_layout_family(visual_plan) + if layout_family: + layout_families.append(layout_family) + if is_svg_plan and not textify(visual_plan.get("renderer_id")).strip(): + issues.append( + plan_issue( + "error", + "plan_missing_renderer_id", + "SVGlide plan slides must include renderer_id so layout diversity is checkable", + slide, + "Use stable renderer IDs such as cover_full_bleed, agenda_matrix, timeline_rail, comparison_table, or closing_cta.", + ) + ) + if is_svg_plan: + if not layout_family: + issues.append( + plan_issue( + "error", + "plan_missing_layout_family", + "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.", + ) + ) + + visual_recipe = normalize_name(visual_plan.get("visual_recipe")) + if not visual_recipe: + issues.append( + plan_issue( + "error", + "plan_missing_visual_recipe", + "SVGlide plan slides must include visual_recipe", + slide, + "Choose one SVG-native recipe such as hero_typography, path_flow, infographic_scorecard, or fake_ui_dashboard.", + ) + ) + elif visual_recipe not in VISUAL_RECIPE_CATALOG: + issues.append( + plan_issue( + "error", + "plan_unknown_visual_recipe", + f'unknown visual_recipe "{visual_recipe}"', + slide, + "Use one of: " + ", ".join(sorted(VISUAL_RECIPE_CATALOG)), + ) + ) + else: + visual_recipes.append(visual_recipe) + visual_recipe_families.append(recipe_family(visual_recipe)) + + declared_primitives = normalize_primitives(visual_plan.get("svg_primitives")) + plan_required_primitives = required_plan_primitives(visual_plan) + if not plan_required_primitives: + issues.append( + plan_issue( + "error", + "plan_missing_required_primitives", + "SVGlide plan slides must include required_primitives", + slide, + "Copy the recipe required primitives, then add any page-specific required primitives the SVG source must expose.", + ) + ) + recipe_required_primitives = set(VISUAL_RECIPE_CATALOG[visual_recipe]["required_primitives"]) + missing_declared = sorted(recipe_required_primitives - declared_primitives) + if missing_declared: + issues.append( + plan_issue( + "error", + "plan_recipe_primitives_mismatch", + f'{visual_recipe} requires svg_primitives: {", ".join(sorted(recipe_required_primitives))}', + slide, + "Declare the SVG-native primitives the page will actually draw, not only a renderer_id.", + ) + ) + missing_required_field = sorted(recipe_required_primitives - plan_required_primitives) + if plan_required_primitives and missing_required_field: + issues.append( + plan_issue( + "error", + "plan_required_primitives_mismatch", + f'{visual_recipe} requires required_primitives: {", ".join(sorted(recipe_required_primitives))}', + slide, + "required_primitives must cover the selected recipe's hard requirements.", + ) + ) + missing_required_declaration = sorted(plan_required_primitives - declared_primitives) + if plan_required_primitives and missing_required_declaration: + issues.append( + plan_issue( + "error", + "plan_required_primitives_not_declared", + "required_primitives must also appear in svg_primitives", + slide, + f"Missing from svg_primitives: {', '.join(missing_required_declaration)}.", + ) + ) + + for field in ["visual_intent", "visual_focal_point", "visual_signature", "xml_like_risk"]: + if not textify(visual_plan.get(field)).strip(): + issues.append( + plan_issue( + "error", + f"plan_missing_{field}", + f"SVGlide plan slides must include {field}", + slide, + ) + ) + if not normalize_primitives(visual_plan.get("svg_primitives")): + issues.append( + plan_issue( + "error", + "plan_missing_svg_primitives", + "SVGlide plan slides must include non-empty svg_primitives", + slide, + "List SVGlide-safe primitives such as path, gradient, typography, annotation, micro_chart, texture, image_overlay, or dashboard.", + ) + ) + declared_effects = normalize_effects(visual_plan.get("svg_effects")) + if not declared_effects: + issues.append( + plan_issue( + "error", + "plan_missing_svg_effects", + "SVGlide plan slides must include svg_effects", + slide, + "Declare the SVG effects that create the page's visual advantage, such as path, connector_flow, gradient, texture, chart_geometry, or image_overlay.", + ) + ) + for effect in sorted(declared_effects): + if effect not in SVG_EFFECT_CATALOG: + issues.append( + plan_issue( + "error", + "plan_unknown_svg_effect", + f'unknown svg_effect "{effect}"', + slide, + "Use one of: " + ", ".join(sorted(SVG_EFFECT_CATALOG)), + ) + ) + continue + if SVG_EFFECT_CATALOG[effect].get("requires_fallback") and not has_effect_fallback(visual_plan, effect): + issues.append( + plan_issue( + "error", + "plan_svg_effect_requires_safe_fallback", + f'svg_effect "{effect}" requires an explicit SVGlide-safe fallback or rewrite', + slide, + "Declare effect_fallbacks, safe_rewrite, recipe_fallback, or fallback_policy before rendering SVG.", + ) + ) + slide_preset_id = slide_style_preset_id(slide) + effective_preset_id = slide_preset_id or deck_preset_id + if slide_preset_id and slide_preset_id not in STYLE_PRESET_CATALOG: + issues.append( + plan_issue( + "error", + "plan_style_preset_unknown", + f'unknown slide style_preset "{slide_preset_id}"', + slide, + "Use one of: " + ", ".join(sorted(STYLE_PRESET_CATALOG)), + ) + ) + if slide_preset_id and deck_preset_id and slide_preset_id != deck_preset_id: + issues.append( + plan_issue( + "warning", + "plan_slide_style_preset_mismatch", + f'slide style_preset "{slide_preset_id}" overrides deck style_preset "{deck_preset_id}"', + slide, + "Only override per page when the visual rhythm explicitly needs a cover, section, or poster treatment.", + ) + ) + if effective_preset_id in STYLE_PRESET_CATALOG: + preset = STYLE_PRESET_CATALOG[effective_preset_id] + visible = visible_slide_text(visual_plan).lower() + leaked_terms = [ + textify(preset.get("style_id")), + textify(preset.get("display_name")), + textify(preset.get("source_token")), + ] + leaked_terms = [term for term in leaked_terms if term and term.lower() in visible] + if leaked_terms: + issues.append( + plan_issue( + "error", + "plan_style_preset_visible_leak", + "visible slide fields must not expose style preset names, ids, or source tokens", + slide, + "Keep preset metadata in style_binding/style_system only; visible content should describe the user's topic.", + ) + ) + visible = visible_slide_text(visual_plan) + if TOOL_LEAK_RE.search(visible) or PATH_LIKE_RE.search(visible): + issues.append( + plan_issue( + "error", + "plan_visible_tool_or_path_leak", + "visible slide fields must not expose prompts, tool names, source tokens, or local file paths", + slide, + ) + ) + if not asset_contract_present(visual_plan.get("asset_contract")): + issues.append( + plan_issue( + "warning", + "plan_missing_asset_contract", + "SVGlide plan slides must include asset_contract", + slide, + 'MVP preflight allows missing asset_contract, but use "none_required" when the page has no image asset; otherwise provide preview image metadata including retrieval_query/source_url or mark license="preview_unverified".', + ) + ) + if "risk_flags" not in visual_plan: + issues.append( + plan_issue( + "error", + "plan_missing_risk_flags", + "SVGlide plan slides must include risk_flags", + slide, + "Use an empty list when no known generation risk applies; otherwise list risks such as text_overflow, image_preview_only, image_query_mismatch, network_image_fetch_unavailable, image_license, or conversion_dasharray.", + ) + ) + if not textify(visual_plan.get("source_policy")).strip(): + issues.append( + plan_issue( + "error", + "plan_missing_source_policy", + "SVGlide plan slides must include source_policy", + slide, + "State how the generator handles missing data and numeric claims.", + ) + ) + density_contract = visual_plan.get("content_density_contract") + if not textify(density_contract).strip(): + issues.append( + plan_issue( + "error", + "plan_missing_content_density_contract", + "SVGlide plan slides must include content_density_contract", + slide, + 'For high-density pages use a quantified contract such as "dashboard >= 4 metrics" or {"type":"table","min_cells":6}.', + ) + ) + + density = textify(visual_plan.get("density") or visual_plan.get("text_density")).strip().lower() + if density == "high": + structure_text = plan_slide_text(visual_plan, ["density_structure", "visual_structure", "renderer_id", "layout_type"]) + if not PLAN_STRUCTURED_RE.search(structure_text): + issues.append( + plan_issue( + "error", + "plan_high_density_without_structure", + "high-density slides must declare a structured visual carrier", + slide, + "Use density_structure or renderer_id with matrix, table, timeline, flow, comparison, dashboard, map, or similar structure.", + ) + ) + if is_svg_plan and density_contract_kind_count(visual_plan.get("content_density_contract")) is None: + issues.append( + plan_issue( + "error", + "plan_high_density_contract_not_quantified", + "high-density SVG slides must quantify their content_density_contract", + slide, + 'Use a measurable contract such as "matrix >= 6 cells", "timeline >= 4 nodes", "dashboard >= 4 metrics", "flow >= 4 stages", or "risk_grid >= 4 items".', + ) + ) + + source_status = plan_slide_text(visual_plan, ["source_status", "source_state"]) + requires_attachment = bool(visual_plan.get("requires_attachment")) + if requires_attachment or PLAN_MISSING_SOURCE_RE.search(source_status): + guard_text = plan_slide_text( + visual_plan, + ["source_policy", "source_guard", "visible_source_note", "key_message", "takeaway", "speaker_intent", "notes"], + ) + if not PLAN_SOURCE_GUARD_RE.search(guard_text): + issues.append( + plan_issue( + "error", + "plan_missing_source_guard", + "slides with missing attachment/source must explicitly guard against fabricated facts", + slide, + "Add source_policy/source_guard or visible note such as 待从附件补齐 / 来源缺失 / no numeric claims.", + ) + ) + + if len(slides) >= 8: + last_slide = slides[-1] if isinstance(slides[-1], dict) else {} + closing_plan = slide_visual_plan(last_slide) if isinstance(last_slide, dict) else {} + closing_text = plan_slide_text(closing_plan, ["page_type", "layout_type", "renderer_id", "title", "key_message", "takeaway"]) + if not PLAN_CLOSING_RE.search(closing_text): + issues.append( + plan_issue( + "error", + "plan_missing_closing_slide", + "decks with 8 or more pages must end with an explicit closing/summary/thanks/Q&A page", + last_slide if isinstance(last_slide, dict) else None, + ) + ) + + comparable_renderers = [renderer for renderer in renderer_ids if renderer] + comparable_layout_families = [family for family in layout_families if family] + if len(slides) >= 10 and len(set(comparable_renderers)) < 5: + issues.append( + plan_issue( + "error", + "plan_renderer_diversity_low", + "decks with 10 or more pages must use at least 5 distinct renderer/layout families", + None, + "Do not rely on layout_type names only; renderer_id must reflect actual geometry.", + ) + ) + if is_svg_plan and len(slides) >= 10 and len(set(comparable_layout_families)) < 5: + issues.append( + plan_issue( + "error", + "plan_layout_family_diversity_low", + "SVGlide decks with 10 or more pages must use at least 5 distinct layout_family values", + None, + "Vary actual reading direction and information structure, not only renderer names.", + ) + ) + for index in range(len(comparable_renderers) - 2): + if comparable_renderers[index] == comparable_renderers[index + 1] == comparable_renderers[index + 2]: + issues.append( + plan_issue( + "error", + "plan_renderer_repetition", + f"renderer '{comparable_renderers[index]}' repeats for 3 consecutive slides", + slides[index + 2] if isinstance(slides[index + 2], dict) else None, + "Change geometry, reading direction, image usage, or information structure before generating SVG.", + ) + ) + for index in range(len(comparable_layout_families) - 2): + if comparable_layout_families[index] == comparable_layout_families[index + 1] == comparable_layout_families[index + 2]: + issues.append( + plan_issue( + "error", + "plan_layout_family_repetition", + f"layout_family '{comparable_layout_families[index]}' repeats for 3 consecutive slides", + slides[index + 2] if isinstance(slides[index + 2], dict) else None, + "Change the page structure before rendering SVG; recipe names alone are not enough.", + ) + ) + if is_svg_plan and len(slides) >= 8 and len(set(visual_recipe_families)) < 5: + issues.append( + plan_issue( + "error", + "plan_visual_recipe_diversity_low", + "SVGlide decks with 8 or more pages must use at least 5 distinct visual_recipe families", + None, + "Mix hero/title, flow/metaphor, data/dashboard, texture/depth, annotation/showcase, icon, geometry, and brand recipes.", + ) + ) + + result: dict[str, Any] = { + "path": path, + "slide_count": len(slides), + "distinct_renderer_count": len(set(comparable_renderers)), + "distinct_layout_family_count": len(set(comparable_layout_families)), + "distinct_visual_recipe_count": len(set(visual_recipes)), + "distinct_visual_recipe_family_count": len(set(visual_recipe_families)), + "summary": { + "error_count": sum(1 for item in issues if item["level"] == "error"), + "warning_count": sum(1 for item in issues if item["level"] == "warning"), + }, + } + if issues: + result["issues"] = issues + return result + + +def load_plan_json(path: str) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: + try: + plan = json.loads(Path(path).read_text(encoding="utf-8")) + except json.JSONDecodeError as error: + return None, { + "path": path, + "issues": [ + { + "level": "error", + "code": "plan_json_invalid", + "message": f"slide_plan.json is not valid JSON: {error}", + "hint": "Fix the plan before generating SVG or calling slides +create-svg.", + } + ], + "summary": {"error_count": 1, "warning_count": 0}, + } + if not isinstance(plan, dict): + return None, { + "path": path, + "issues": [ + { + "level": "error", + "code": "plan_root_invalid", + "message": "slide_plan.json root must be an object", + } + ], + "summary": {"error_count": 1, "warning_count": 0}, + } + return plan, None + + +def lint_plan_file(path: str) -> dict[str, Any]: + plan, load_error = load_plan_json(path) + if load_error: + return load_error + return lint_plan(plan or {}, path) + + +def planned_svg_path(slide: dict[str, Any], plan: dict[str, Any]) -> str: + direct = textify(slide.get("svg_path") or slide.get("path") or slide.get("file")) + if direct: + return direct + page = slide.get("page") + svg_files = plan.get("svg_files") + if isinstance(svg_files, list): + for item in svg_files: + if not isinstance(item, dict): + continue + if item.get("page") == page and textify(item.get("path")): + return textify(item.get("path")) + return "" + + +def lint_plan_svg_alignment(plan: dict[str, Any], files: list[dict[str, Any]]) -> list[dict[str, Any]]: + if plan.get("output_mode") != "svglide-svg": + return [] + slides = plan.get("slides") + if not isinstance(slides, list) or not slides: + return [] + + files_by_name = {Path(textify(file.get("path"))).name: file for file in files if textify(file.get("path"))} + alignments: list[tuple[dict[str, Any], dict[str, Any]]] = [] + for index, slide in enumerate(slides): + if not isinstance(slide, dict): + continue + svg_path = planned_svg_path(slide, plan) + if svg_path: + file = files_by_name.get(Path(svg_path).name) + if file is not None: + alignments.append((slide, file)) + continue + if len(files) == len(slides): + alignments.append((slide, files[index])) + + issues: list[dict[str, Any]] = [] + for slide, file in alignments: + visual_plan = slide_visual_plan(slide) + recipe = normalize_name(visual_plan.get("visual_recipe")) + if recipe not in VISUAL_RECIPE_CATALOG: + continue + source_primitives = set(file.get("visual_primitives", {}).get("present", [])) + source_effects = set(file.get("visual_primitives", {}).get("effects", [])) + declared_primitives = normalize_primitives(visual_plan.get("svg_primitives")) + required_primitives = set(VISUAL_RECIPE_CATALOG[recipe]["required_primitives"]) | required_plan_primitives(visual_plan) + missing_required = sorted(required_primitives - source_primitives) + if missing_required: + issues.append( + plan_issue( + "error", + "plan_recipe_required_primitives_not_found", + f'{recipe} required primitives not found in SVG source: {", ".join(missing_required)}', + slide, + f"SVG file {file.get('path')} exposes primitives {sorted(source_primitives)}; adjust SVG source or choose a more accurate visual_recipe.", + ) + ) + if declared_primitives and not (declared_primitives & source_primitives): + issues.append( + plan_issue( + "error", + "plan_svg_primitives_not_found", + "slide svg_primitives do not match any detected SVG source primitive", + slide, + f"Declared {sorted(declared_primitives)}, detected {sorted(source_primitives)} in {file.get('path')}.", + ) + ) + declared_effects = normalize_effects(visual_plan.get("svg_effects")) + missing_effects = sorted(effect for effect in declared_effects if effect in SVG_EFFECT_CATALOG and effect not in source_effects) + if missing_effects: + issues.append( + plan_issue( + "error", + "plan_svg_effect_not_found", + f'declared svg_effects not found in SVG source: {", ".join(missing_effects)}', + slide, + f"SVG file {file.get('path')} exposes effects {sorted(source_effects)}; adjust SVG source or remove inaccurate effects.", + ) + ) + contract = density_contract_kind_count(visual_plan.get("content_density_contract")) + density = textify(visual_plan.get("density") or visual_plan.get("text_density")).strip().lower() + if density == "high" and contract is not None: + kind, minimum = contract + source_count = source_density_count(kind, file.get("visual_primitives", {})) + if source_count < minimum: + issues.append( + plan_issue( + "error", + "plan_content_density_contract_not_met", + f'content_density_contract "{kind} >= {minimum}" is not met by SVG source', + slide, + f"Detected count is {source_count} in {file.get('path')}; add real cells/nodes/metrics/stages/items before rendering.", + ) + ) + if "image" in source_primitives: + contract_value = visual_plan.get("asset_contract") + if asset_contract_declares_no_asset(contract_value) or not asset_contract_has_metadata(contract_value): + issues.append( + plan_issue( + "warning", + "plan_asset_contract_missing_metadata", + "SVG source uses image primitives, but asset_contract lacks required source/license metadata", + slide, + 'MVP preflight allows incomplete image metadata; for preview add retrieval_query, source_type, license="preview_unverified", local_path_or_href, usage_page, and source_url/href when available.', + ) + ) + return issues + + def lint_svg(svg: str, path: str = "") -> dict[str, Any]: result: dict[str, Any] = {"path": path, "issues": []} try: @@ -436,12 +2213,25 @@ def lint_svg(svg: str, path: str = "") -> dict[str, Any]: elements = walk_renderable(root) role_issues = validate_roles_and_attrs(elements) geometry_issues, text_boxes = validate_geometry(elements, width, height) - issues = root_issues + role_issues + validate_styles(root) + validate_paths(elements) + geometry_issues + validate_text_overlap(text_boxes) + primitive_summary = summarize_visual_primitives(root, elements, text_boxes, width, height) + issues = ( + root_issues + + role_issues + + validate_styles(root) + + validate_paths(elements) + + geometry_issues + + validate_text_overlap(text_boxes) + + validate_layout_pressure(elements, text_boxes, width, height) + + validate_visible_svg_text_leaks(text_boxes) + + validate_visual_quality(elements) + + validate_xml_like_layout(elements, text_boxes, primitive_summary) + ) result["width"] = width result["height"] = height result["element_count"] = len(elements) result["text_box_count"] = len(text_boxes) + result["visual_primitives"] = primitive_summary result["issues"] = issues result["summary"] = { "error_count": sum(1 for item in issues if item["level"] == "error"), @@ -452,25 +2242,45 @@ def lint_svg(svg: str, path: str = "") -> dict[str, Any]: return result -def lint_files(paths: list[str]) -> dict[str, Any]: +def lint_files(paths: list[str], plan_path: str | None = None) -> dict[str, Any]: files: list[dict[str, Any]] = [] for path in paths: svg = Path(path).read_text(encoding="utf-8") files.append(lint_svg(svg, path)) - return { + plan_result = None + if plan_path: + plan, load_error = load_plan_json(plan_path) + plan_result = load_error or lint_plan(plan or {}, plan_path) + if plan is not None: + alignment_issues = lint_plan_svg_alignment(plan, files) + if alignment_issues: + plan_result.setdefault("issues", []).extend(alignment_issues) + plan_result["summary"]["error_count"] = sum(1 for item in plan_result["issues"] if item["level"] == "error") + plan_result["summary"]["warning_count"] = sum(1 for item in plan_result["issues"] if item["level"] == "warning") + summary = { + "file_count": len(files), + "error_count": sum(file["summary"]["error_count"] for file in files), + "warning_count": sum(file["summary"]["warning_count"] for file in files), + } + if plan_result: + summary["plan_count"] = 1 + summary["error_count"] += plan_result["summary"]["error_count"] + summary["warning_count"] += plan_result["summary"]["warning_count"] + result: dict[str, Any] = { "summary": { - "file_count": len(files), - "error_count": sum(file["summary"]["error_count"] for file in files), - "warning_count": sum(file["summary"]["warning_count"] for file in files), + **summary, }, "files": files, } + if plan_result: + result["plan"] = plan_result + return result def main(argv: list[str]) -> int: try: options = parse_args(argv) - result = lint_files(options["inputs"]) + result = lint_files(options["inputs"], options["plan"]) except SvgPreflightError as error: print(f"svg_preflight: {error}", file=sys.stderr) return 2 diff --git a/skills/lark-slides/scripts/svg_preflight_test.py b/skills/lark-slides/scripts/svg_preflight_test.py index 87b51673c..29a36a25e 100644 --- a/skills/lark-slides/scripts/svg_preflight_test.py +++ b/skills/lark-slides/scripts/svg_preflight_test.py @@ -2,7 +2,10 @@ # SPDX-License-Identifier: MIT from __future__ import annotations +import json +import tempfile import unittest +from pathlib import Path import svg_preflight @@ -11,6 +14,7 @@ VALID_SVG = """ @@ -25,6 +29,70 @@ VALID_SVG = """ """ +def with_contract(svg: str) -> str: + if "slide:contract-version=" in svg: + return svg + return svg.replace( + 'slide:role="slide"', + f'slide:role="slide"\n slide:contract-version="{svg_preflight.SVG_CONTRACT_VERSION}"', + 1, + ) + + +def style_plan_fields(preset_id: str = "raw_grid") -> dict[str, object]: + return { + "style_preset": preset_id, + "style_selection_reason": "raw_grid fits technical training pages that need dense but readable visual structure", + "style_system": { + "palette": { + "background": "#F5F5F5", + "text": "#0A0A0A", + "accent": "#F2D4CF", + }, + "typography": "strong title, readable native text labels", + "background_strategy": "muted grid panels with one stable background family", + "motif": "dense grid panels with restrained accent labels", + }, + } + + +def effects_for_primitives(primitives: list[str]) -> list[str]: + effects = {"typography"} + primitive_set = set(primitives) + if "path" in primitive_set or "annotation" in primitive_set: + effects.add("path") + effects.add("connector_flow") + if "micro_chart" in primitive_set or "dashboard" in primitive_set: + effects.add("chart_geometry") + if "gradient" in primitive_set: + effects.add("gradient") + if "texture" in primitive_set: + effects.add("texture") + if "image_overlay" in primitive_set: + effects.add("image_overlay") + if "spotlight" in primitive_set: + effects.add("spotlight") + return sorted(effects) + + +def recipe_fields(recipe: str, primitives: list[str]) -> dict[str, object]: + return { + "layout_family": recipe, + "visual_recipe": recipe, + "visual_intent": f"use {recipe} as the SVG-native visual carrier", + "visual_focal_point": "main visual structure", + "visual_signature": f"{recipe} creates a distinct SVG visual memory point", + "svg_effects": effects_for_primitives(primitives), + "required_primitives": primitives, + "svg_primitives": primitives, + "xml_like_risk": "would fall back to generic cards and bullets in XML", + "content_density_contract": "dashboard >= 4 metrics", + "asset_contract": "none_required", + "risk_flags": [], + "source_policy": "Use prompt-provided content only; mark missing numbers as pending.", + } + + class SvgPreflightTest(unittest.TestCase): def test_lint_svg_accepts_valid_svglide(self) -> None: result = svg_preflight.lint_svg(VALID_SVG) @@ -35,12 +103,29 @@ class SvgPreflightTest(unittest.TestCase): result = svg_preflight.lint_svg( VALID_SVG.replace('width="960" height="540" viewBox="0 0 960 540"', 'width="1280" height="720" viewBox="0 0 1280 720"') ) - codes = [issue["code"] for issue in result["issues"]] + codes = [issue["code"] for issue in result.get("issues", [])] self.assertIn("root_canvas_mismatch", codes) self.assertIn("root_viewbox_mismatch", codes) self.assertEqual(result["summary"]["error_count"], 2) - def test_lint_svg_reports_external_image_and_font_shorthand(self) -> None: + def test_lint_svg_reports_missing_contract_version(self) -> None: + svg = VALID_SVG.replace(' slide:contract-version="svglide-authoring-contract/v1"\n', "") + result = svg_preflight.lint_svg(svg) + codes = [issue["code"] for issue in result.get("issues", [])] + self.assertIn("root_contract_version_missing", codes) + self.assertEqual(result["summary"]["error_count"], 1) + + def test_lint_svg_reports_contract_version_mismatch(self) -> None: + svg = VALID_SVG.replace( + 'slide:contract-version="svglide-authoring-contract/v1"', + 'slide:contract-version="legacy"', + ) + result = svg_preflight.lint_svg(svg) + codes = [issue["code"] for issue in result.get("issues", [])] + self.assertIn("root_contract_version_mismatch", codes) + self.assertEqual(result["summary"]["error_count"], 1) + + def test_lint_svg_warns_external_image_and_reports_font_shorthand(self) -> None: svg = """ """ - result = svg_preflight.lint_svg(svg) - codes = [issue["code"] for issue in result["issues"]] + result = svg_preflight.lint_svg(with_contract(svg)) + codes = [issue["code"] for issue in result.get("issues", [])] self.assertIn("external_image_href", codes) self.assertIn("font_shorthand", codes) + self.assertEqual(result["summary"]["error_count"], 1) + self.assertEqual(result["summary"]["warning_count"], 1) + + def test_lint_svg_warns_image_opacity_as_unsupported(self) -> None: + svg = """ + + + + """ + result = svg_preflight.lint_svg(with_contract(svg)) + codes = [issue["code"] for issue in result["issues"]] + self.assertIn("image_opacity_unsupported", codes) + self.assertEqual(result["summary"]["error_count"], 0) + self.assertEqual(result["summary"]["warning_count"], 1) + + def test_lint_svg_warns_circle_stroke_width_is_unstable(self) -> None: + svg = """ + + + + """ + result = svg_preflight.lint_svg(with_contract(svg)) + codes = [issue["code"] for issue in result["issues"]] + self.assertIn("ellipse_stroke_width_unstable", codes) + self.assertEqual(result["summary"]["error_count"], 0) + self.assertEqual(result["summary"]["warning_count"], 1) + + def test_lint_svg_warns_decorative_stroke_dasharray_is_unstable(self) -> None: + svg = """ + + + + """ + result = svg_preflight.lint_svg(with_contract(svg)) + codes = [issue["code"] for issue in result["issues"]] + self.assertIn("stroke_dasharray_unstable", codes) + self.assertEqual(result["summary"]["error_count"], 0) + self.assertEqual(result["summary"]["warning_count"], 1) + + def test_lint_svg_reports_key_path_stroke_dasharray_as_error(self) -> None: + svg = """ + + + + """ + result = svg_preflight.lint_svg(with_contract(svg)) + codes = [issue["code"] for issue in result["issues"]] + self.assertIn("stroke_dasharray_key_path", codes) + self.assertEqual(result["summary"]["error_count"], 1) def test_lint_svg_reports_canvas_error_and_safe_area_warning(self) -> None: svg = """ @@ -67,13 +213,32 @@ class SvgPreflightTest(unittest.TestCase): """ - result = svg_preflight.lint_svg(svg) + result = svg_preflight.lint_svg(with_contract(svg)) codes = [issue["code"] for issue in result["issues"]] self.assertIn("safe_area", codes) self.assertIn("canvas_bounds", codes) self.assertEqual(result["summary"]["error_count"], 1) self.assertEqual(result["summary"]["warning_count"], 1) + def test_lint_svg_does_not_warn_for_edge_backing_and_decorative_frame(self) -> None: + svg = """ + + + + + +
Title
+
+
+ """ + result = svg_preflight.lint_svg(with_contract(svg)) + codes = [issue["code"] for issue in result.get("issues", [])] + self.assertNotIn("safe_area", codes) + self.assertEqual(result["summary"]["error_count"], 0) + def test_lint_svg_reports_text_bbox_overlap(self) -> None: svg = """ """ - result = svg_preflight.lint_svg(svg) + result = svg_preflight.lint_svg(with_contract(svg)) self.assertEqual(result["summary"]["error_count"], 1) self.assertEqual(result["issues"][0]["code"], "text_bbox_overlap") + def test_lint_svg_reports_badge_headline_overlap(self) -> None: + svg = """ + + + +
回顾与复盘
+
+
+ """ + result = svg_preflight.lint_svg(with_contract(svg)) + codes = [issue["code"] for issue in result["issues"]] + self.assertIn("badge_headline_overlap", codes) + + def test_lint_svg_accepts_badge_headline_safe_gap(self) -> None: + svg = """ + + + +
回顾与复盘
+
+
+ """ + result = svg_preflight.lint_svg(with_contract(svg)) + codes = [issue["code"] for issue in result.get("issues", [])] + self.assertNotIn("badge_headline_overlap", codes) + + def test_lint_svg_reports_text_container_overflow(self) -> None: + svg = """ + + + +
会议输出:统一市场判断、年度策略、团队分工与执行节奏
+
+
+ """ + result = svg_preflight.lint_svg(with_contract(svg)) + codes = [issue["code"] for issue in result["issues"]] + self.assertIn("text_container_overflow", codes) + + def test_lint_svg_warns_decorative_line_title_pressure(self) -> None: + svg = """ + + + +
去年 400 万营收
+
+
+ """ + result = svg_preflight.lint_svg(with_contract(svg)) + codes = [issue["code"] for issue in result["issues"]] + self.assertIn("decorative_line_title_pressure", codes) + self.assertEqual(result["summary"]["warning_count"], 1) + + def test_lint_svg_reports_visible_metadata_leak(self) -> None: + svg = """ + + +
raw_grid beautiful-feishu-whiteboard /tmp/source.svg prompt:
+
+
+ """ + result = svg_preflight.lint_svg(with_contract(svg)) + codes = [issue["code"] for issue in result["issues"]] + self.assertIn("visible_svg_metadata_leak", codes) + + def test_lint_svg_allows_user_visible_slash_text(self) -> None: + svg = """ + + +
A/B testing improves DATA/METHOD alignment
+
+
+ """ + result = svg_preflight.lint_svg(with_contract(svg)) + codes = [issue["code"] for issue in result.get("issues", [])] + self.assertNotIn("visible_svg_metadata_leak", codes) + + def test_lint_svg_reports_light_text_without_dark_backing(self) -> None: + svg = """ + + + +
Crossing title
+
+
+ """ + result = svg_preflight.lint_svg(with_contract(svg)) + codes = [issue["code"] for issue in result["issues"]] + self.assertIn("light_text_without_dark_backing", codes) + + def test_lint_svg_accepts_light_text_inside_dark_backing(self) -> None: + svg = """ + + + +
Contained title
+
+
+ """ + result = svg_preflight.lint_svg(with_contract(svg)) + codes = [issue["code"] for issue in result.get("issues", [])] + self.assertNotIn("light_text_without_dark_backing", codes) + self.assertEqual(result["summary"]["error_count"], 0) + + def test_lint_svg_reports_round_node_text_overflow(self) -> None: + svg = """ + + + +
到期前价值提醒
+
+
+ """ + result = svg_preflight.lint_svg(with_contract(svg)) + codes = [issue["code"] for issue in result["issues"]] + self.assertIn("node_text_overflow", codes) + + def test_lint_svg_reports_zero_size_text_box(self) -> None: + svg = """ + + +
Hidden
+
+
+ """ + result = svg_preflight.lint_svg(with_contract(svg)) + self.assertEqual(result["summary"]["error_count"], 1) + self.assertEqual(result["issues"][0]["code"], "non_positive_bbox") + + def test_lint_plan_accepts_diverse_svglide_plan(self) -> None: + plan = { + "output_mode": "svglide-svg", + "page_count": 10, + **style_plan_fields(), + "slides": [ + { + "page": 1, + "renderer_id": "cover_full_bleed", + "density": "low", + "title": "Cover", + "takeaway": "Start", + **recipe_fields("hero_typography", ["typography", "geometric_shape"]), + }, + { + "page": 2, + "renderer_id": "agenda_matrix", + "density": "medium", + "title": "Agenda", + "takeaway": "Map", + **recipe_fields("geometric_composition", ["geometric_shape", "path"]), + }, + { + "page": 3, + "renderer_id": "dashboard_scorecard", + "density": "high", + "density_structure": "dashboard with four metric cards and trend line", + "title": "Signal", + "takeaway": "Evidence", + **recipe_fields("infographic_scorecard", ["typography", "micro_chart"]), + }, + { + "page": 4, + "renderer_id": "comparison_table", + "density": "high", + "density_structure": "comparison table", + "title": "Compare", + **recipe_fields("path_flow", ["path", "annotation"]), + }, + { + "page": 5, + "renderer_id": "timeline_rail", + "density": "medium", + "title": "Timeline", + **recipe_fields("gradient_depth", ["gradient", "geometric_shape"]), + }, + { + "page": 6, + "renderer_id": "process_flow", + "density": "high", + "density_structure": "five node flow", + "title": "Flow", + **recipe_fields("technical_texture", ["texture", "path"]), + }, + { + "page": 7, + "renderer_id": "case_card_wall", + "density": "medium", + "title": "Case", + **recipe_fields("icon_capability_map", ["icon", "geometric_shape"]), + }, + { + "page": 8, + "renderer_id": "source_guard_panel", + "density": "medium", + "source_status": "attachment_missing", + "source_policy": "待从附件补齐;no numeric claims", + "title": "Attachment", + **recipe_fields("spotlight_annotation", ["spotlight", "annotation"]), + }, + { + "page": 9, + "renderer_id": "risk_matrix", + "density": "high", + "density_structure": "2x2 risk matrix", + "title": "Risk", + **recipe_fields("fake_ui_dashboard", ["dashboard", "micro_chart"]), + }, + { + "page": 10, + "renderer_id": "closing_cta", + "density": "low", + "page_type": "closing", + "title": "Thanks", + **recipe_fields("brand_system", ["typography", "geometric_shape"]), + }, + ], + } + result = svg_preflight.lint_plan(plan) + self.assertEqual(result["summary"]["error_count"], 0) + self.assertGreaterEqual(result["distinct_renderer_count"], 5) + self.assertGreaterEqual(result["distinct_visual_recipe_family_count"], 5) + + def test_style_preset_catalog_has_35_complete_entries(self) -> None: + catalog = svg_preflight.STYLE_PRESET_CATALOG + self.assertEqual(len(catalog), 35) + group_counts: dict[str, int] = {} + tokens = set() + for style_id, preset in catalog.items(): + self.assertEqual(style_id, preset["style_id"]) + self.assertIn(preset["group"], {"Restrained", "Balanced", "Bold"}) + group_counts[preset["group"]] = group_counts.get(preset["group"], 0) + 1 + self.assertTrue(preset.get("display_name")) + self.assertTrue(preset.get("source_token")) + tokens.add(preset["source_token"]) + self.assertIn("palette", preset) + self.assertRegex(preset["palette"]["background"], r"^#[0-9A-Fa-f]{6}$") + self.assertRegex(preset["palette"]["text"], r"^#[0-9A-Fa-f]{6}$") + self.assertRegex(preset["palette"]["accent"], r"^#[0-9A-Fa-f]{6}$") + self.assertIn("shape_language", preset) + self.assertIn("density", preset) + self.assertIn("slide_translation", preset) + self.assertIn("quality_oracle", preset) + self.assertEqual(group_counts, {"Restrained": 9, "Balanced": 15, "Bold": 11}) + self.assertEqual(len(tokens), 35) + + def test_lint_plan_reports_unknown_style_preset(self) -> None: + plan = { + "output_mode": "svglide-svg", + "page_count": 1, + **style_plan_fields("not_a_real_style"), + "slides": [ + { + "page": 1, + "renderer_id": "route_story", + "density": "medium", + "title": "Route", + **recipe_fields("path_flow", ["path", "annotation"]), + } + ], + } + result = svg_preflight.lint_plan(plan) + codes = [issue["code"] for issue in result["issues"]] + self.assertIn("plan_style_preset_unknown", codes) + + def test_lint_plan_accepts_nested_visual_plan(self) -> None: + plan = { + "output_mode": "svglide-svg", + "page_count": 1, + **style_plan_fields(), + "slides": [ + { + "page": 1, + "renderer_id": "route_story", + "title": "Route", + "visual_plan": { + "layout_family": "flow", + "density": "medium", + **recipe_fields("path_flow", ["path", "annotation"]), + }, + } + ], + } + result = svg_preflight.lint_plan(plan) + self.assertEqual(result["summary"]["error_count"], 0) + + def test_lint_plan_reports_unsafe_svg_effect_without_fallback(self) -> None: + plan = { + "output_mode": "svglide-svg", + "page_count": 1, + **style_plan_fields(), + "slides": [ + { + "page": 1, + "renderer_id": "route_story", + "density": "medium", + "title": "Route", + **{ + **recipe_fields("path_flow", ["path", "annotation"]), + "svg_effects": ["stroke_dasharray"], + }, + } + ], + } + result = svg_preflight.lint_plan(plan) + codes = [issue["code"] for issue in result["issues"]] + self.assertIn("plan_svg_effect_requires_safe_fallback", codes) + + def test_lint_files_reports_declared_svg_effect_missing_from_source(self) -> None: + svg = """ + + + +
Title
+
+
+ """ + with tempfile.TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + svg_path = tmp / "page-001.svg" + plan_path = tmp / "slide_plan.json" + svg_path.write_text(with_contract(svg), encoding="utf-8") + plan = { + "output_mode": "svglide-svg", + "page_count": 1, + **style_plan_fields(), + "svg_files": [{"page": 1, "path": "page-001.svg"}], + "slides": [ + { + "page": 1, + "renderer_id": "route_story", + "layout_family": "flow", + "density": "medium", + **{ + **recipe_fields("path_flow", ["path", "annotation"]), + "svg_effects": ["gradient"], + }, + } + ], + } + plan_path.write_text(json.dumps(plan), encoding="utf-8") + result = svg_preflight.lint_files([str(svg_path)], str(plan_path)) + codes = [issue["code"] for issue in result["plan"]["issues"]] + self.assertIn("plan_svg_effect_not_found", codes) + + def test_lint_plan_reports_deck_level_generation_risks(self) -> None: + plan = { + "output_mode": "svglide-svg", + "page_count": 10, + **style_plan_fields(), + "slides": [ + {"page": 1, "renderer_id": "two_column", "density": "low", "title": "Cover"}, + {"page": 2, "renderer_id": "two_column", "density": "medium", "title": "Agenda"}, + {"page": 3, "renderer_id": "two_column", "density": "medium", "title": "Context"}, + {"page": 4, "renderer_id": "two_column", "density": "high", "title": "Dense"}, + {"page": 5, "renderer_id": "two_column", "density": "medium", "title": "Problem"}, + { + "page": 6, + "renderer_id": "two_column", + "density": "medium", + "requires_attachment": True, + "source_status": "attachment_missing", + "title": "Numbers", + "key_message": "Use exact numeric claims", + }, + {"page": 7, "renderer_id": "two_column", "density": "medium", "title": "Plan"}, + {"page": 8, "renderer_id": "two_column", "density": "medium", "title": "Action"}, + {"page": 9, "renderer_id": "two_column", "density": "medium", "title": "Review"}, + {"page": 10, "renderer_id": "two_column", "density": "medium", "title": "Roadmap"}, + ], + } + result = svg_preflight.lint_plan(plan) + codes = [issue["code"] for issue in result["issues"]] + self.assertIn("plan_missing_visual_recipe", codes) + self.assertIn("plan_renderer_repetition", codes) + self.assertIn("plan_renderer_diversity_low", codes) + self.assertIn("plan_visual_recipe_diversity_low", codes) + self.assertIn("plan_high_density_without_structure", codes) + self.assertIn("plan_missing_source_guard", codes) + self.assertIn("plan_missing_closing_slide", codes) + + def test_lint_plan_requires_svglide_generation_contract_fields(self) -> None: + plan = { + "output_mode": "svglide-svg", + "page_count": 1, + **style_plan_fields(), + "slides": [ + { + "page": 1, + "renderer_id": "dashboard_scorecard", + "density": "high", + "density_structure": "dashboard", + "visual_recipe": "fake_ui_dashboard", + "visual_intent": "show an operating dashboard", + "visual_focal_point": "metric cards", + "svg_primitives": ["dashboard", "micro_chart"], + "xml_like_risk": "would become generic cards", + } + ], + } + result = svg_preflight.lint_plan(plan) + codes = [issue["code"] for issue in result["issues"]] + self.assertIn("plan_missing_layout_family", codes) + self.assertIn("plan_missing_required_primitives", codes) + self.assertIn("plan_missing_asset_contract", codes) + self.assertIn("plan_missing_risk_flags", codes) + self.assertIn("plan_missing_source_policy", codes) + self.assertIn("plan_missing_content_density_contract", codes) + self.assertIn("plan_high_density_contract_not_quantified", codes) + + def test_lint_plan_reports_layout_family_diversity_and_repetition(self) -> None: + slides = [] + recipes = [ + "hero_typography", + "geometric_composition", + "infographic_scorecard", + "path_flow", + "gradient_depth", + "technical_texture", + "icon_capability_map", + "spotlight_annotation", + "fake_ui_dashboard", + "brand_system", + ] + for index, recipe in enumerate(recipes, 1): + slide = { + "page": index, + "renderer_id": f"renderer_{index}", + "layout_family": "two_column", + "density": "medium", + "title": "Thanks" if index == 10 else f"Page {index}", + **recipe_fields(recipe, list(svg_preflight.VISUAL_RECIPE_CATALOG[recipe]["required_primitives"])), + } + slide["layout_family"] = "two_column" + slides.append(slide) + result = svg_preflight.lint_plan({"output_mode": "svglide-svg", "page_count": 10, **style_plan_fields(), "slides": slides}) + codes = [issue["code"] for issue in result["issues"]] + self.assertIn("plan_layout_family_diversity_low", codes) + self.assertIn("plan_layout_family_repetition", codes) + + def test_lint_files_accepts_recipe_source_alignment(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_path.write_text( + """ + { + "output_mode": "svglide-svg", + "page_count": 1, + "style_preset": "raw_grid", + "style_selection_reason": "raw_grid fits technical training pages that need dense but readable visual structure", + "style_system": { + "palette": {"background": "#F5F5F5", "text": "#0A0A0A", "accent": "#F2D4CF"}, + "typography": "strong title, readable native text labels", + "background_strategy": "muted grid panels", + "motif": "dense grid panels" + }, + "svg_files": [{"page": 1, "path": "page-001.svg"}], + "slides": [{ + "page": 1, + "renderer_id": "route_story", + "layout_family": "flow", + "density": "medium", + "visual_recipe": "path_flow", + "visual_intent": "show a rising product route", + "visual_focal_point": "curved route line", + "visual_signature": "curved route path with explicit annotations", + "svg_effects": ["path", "connector_flow", "typography"], + "required_primitives": ["path", "annotation"], + "svg_primitives": ["path", "annotation"], + "xml_like_risk": "would become cards plus arrows in XML", + "content_density_contract": "flow >= 4 stages", + "asset_contract": { + "source_type": "procedural", + "license": "original generated asset", + "local_path": "@./assets/hero.jpg", + "usage_page": 1, + "generated_by": "unit test" + }, + "risk_flags": [], + "source_policy": "Use prompt-provided content only." + }] + } + """, + encoding="utf-8", + ) + result = svg_preflight.lint_files([str(svg_path)], str(plan_path)) + self.assertEqual(result["summary"]["error_count"], 0) + + def test_lint_files_reports_declared_recipe_without_source_primitives(self) -> None: + svg = """ + + + +
Title
+
+
+ """ + with tempfile.TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + svg_path = tmp / "page-001.svg" + plan_path = tmp / "slide_plan.json" + svg_path.write_text(with_contract(svg), encoding="utf-8") + plan_path.write_text( + """ + { + "output_mode": "svglide-svg", + "page_count": 1, + "style_preset": "raw_grid", + "style_selection_reason": "raw_grid fits technical training pages that need dense but readable visual structure", + "style_system": { + "palette": {"background": "#F5F5F5", "text": "#0A0A0A", "accent": "#F2D4CF"}, + "typography": "strong title, readable native text labels", + "background_strategy": "muted grid panels", + "motif": "dense grid panels" + }, + "svg_files": [{"page": 1, "path": "page-001.svg"}], + "slides": [{ + "page": 1, + "renderer_id": "route_story", + "layout_family": "flow", + "density": "medium", + "visual_recipe": "path_flow", + "visual_intent": "show a rising product route", + "visual_focal_point": "curved route line", + "visual_signature": "curved route path with explicit annotations", + "svg_effects": ["path", "connector_flow", "typography"], + "required_primitives": ["path", "annotation"], + "svg_primitives": ["path", "annotation"], + "xml_like_risk": "would become cards plus arrows in XML", + "content_density_contract": "flow >= 4 stages", + "asset_contract": "none_required", + "risk_flags": [], + "source_policy": "Use prompt-provided content only." + }] + } + """, + encoding="utf-8", + ) + result = svg_preflight.lint_files([str(svg_path)], str(plan_path)) + codes = [issue["code"] for issue in result["plan"]["issues"]] + self.assertIn("plan_recipe_required_primitives_not_found", codes) + + def test_lint_files_warns_image_without_asset_metadata(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 = { + "output_mode": "svglide-svg", + "page_count": 1, + **style_plan_fields(), + "svg_files": [{"page": 1, "path": "page-001.svg"}], + "slides": [ + { + "page": 1, + "renderer_id": "route_story", + "layout_family": "flow", + "density": "medium", + "visual_recipe": "path_flow", + "visual_intent": "show a rising product route", + "visual_focal_point": "curved route line", + "visual_signature": "curved route path with explicit annotations", + "svg_effects": ["path", "connector_flow", "typography"], + "required_primitives": ["path", "annotation"], + "svg_primitives": ["path", "annotation"], + "xml_like_risk": "would become cards plus arrows in XML", + "content_density_contract": "flow >= 4 stages", + "asset_contract": "none_required", + "risk_flags": [], + "source_policy": "Use prompt-provided content only.", + } + ], + } + plan_path.write_text(json.dumps(plan), encoding="utf-8") + result = svg_preflight.lint_files([str(svg_path)], str(plan_path)) + 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) + + def test_lint_files_accepts_preview_image_asset_metadata(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 = { + "output_mode": "svglide-svg", + "page_count": 1, + **style_plan_fields(), + "svg_files": [{"page": 1, "path": "page-001.svg"}], + "slides": [ + { + "page": 1, + "renderer_id": "route_story", + "layout_family": "flow", + "density": "medium", + "visual_recipe": "path_flow", + "visual_intent": "show a rising product route", + "visual_focal_point": "curved route line", + "visual_signature": "curved route path with explicit annotations", + "svg_effects": ["path", "connector_flow", "typography"], + "required_primitives": ["path", "annotation"], + "svg_primitives": ["path", "annotation"], + "xml_like_risk": "would become cards plus arrows in XML", + "content_density_contract": "flow >= 4 stages", + "asset_contract": { + "mode": "preview", + "source_type": "public_url", + "retrieval_query": "strategy review product route hero image", + "license": "preview_unverified", + "href": "https://example.com/hero.jpg", + "usage_page": 1, + "source_url": "https://example.com/hero.jpg", + "replacement_required": True, + }, + "risk_flags": ["image_preview_only"], + "source_policy": "Preview image source is marked and will be replaced before production.", + } + ], + } + plan_path.write_text(json.dumps(plan), encoding="utf-8") + result = svg_preflight.lint_files([str(svg_path)], str(plan_path)) + codes = [issue["code"] for issue in result.get("plan", {}).get("issues", [])] + self.assertNotIn("plan_asset_contract_missing_metadata", codes) + self.assertEqual(result["summary"]["error_count"], 0) + + def test_lint_files_warns_preview_web_image_without_retrieval_query(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 = { + "output_mode": "svglide-svg", + "page_count": 1, + **style_plan_fields(), + "svg_files": [{"page": 1, "path": "page-001.svg"}], + "slides": [ + { + "page": 1, + "renderer_id": "route_story", + "layout_family": "flow", + "density": "medium", + "visual_recipe": "path_flow", + "visual_intent": "show a rising product route", + "visual_focal_point": "curved route line", + "visual_signature": "curved route path with explicit annotations", + "svg_effects": ["path", "connector_flow", "typography"], + "required_primitives": ["path", "annotation"], + "svg_primitives": ["path", "annotation"], + "xml_like_risk": "would become cards plus arrows in XML", + "content_density_contract": "flow >= 4 stages", + "asset_contract": { + "mode": "preview", + "source_type": "public_url", + "license": "preview_unverified", + "href": "https://example.com/hero.jpg", + "usage_page": 1, + "source_url": "https://example.com/hero.jpg", + "replacement_required": True, + }, + "risk_flags": ["image_preview_only"], + "source_policy": "Preview image source is marked and will be replaced before production.", + } + ], + } + plan_path.write_text(json.dumps(plan), encoding="utf-8") + result = svg_preflight.lint_files([str(svg_path)], str(plan_path)) + codes = [issue["code"] for issue in result["plan"]["issues"]] + self.assertIn("plan_asset_contract_missing_metadata", codes) + self.assertEqual(result["summary"]["error_count"], 0) + + def test_lint_files_reports_density_contract_not_met_by_source(self) -> None: + svg = """ + + + + + + + + + + """ + with tempfile.TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + svg_path = tmp / "page-001.svg" + plan_path = tmp / "slide_plan.json" + svg_path.write_text(with_contract(svg), encoding="utf-8") + plan_path.write_text( + """ + { + "output_mode": "svglide-svg", + "page_count": 1, + "style_preset": "raw_grid", + "style_selection_reason": "raw_grid fits technical training pages that need dense but readable visual structure", + "style_system": { + "palette": {"background": "#F5F5F5", "text": "#0A0A0A", "accent": "#F2D4CF"}, + "typography": "strong title, readable native text labels", + "background_strategy": "muted grid panels", + "motif": "dense grid panels" + }, + "svg_files": [{"page": 1, "path": "page-001.svg"}], + "slides": [{ + "page": 1, + "renderer_id": "dashboard_scorecard", + "layout_family": "dashboard", + "density": "high", + "density_structure": "dashboard", + "visual_recipe": "fake_ui_dashboard", + "visual_intent": "show an operating dashboard", + "visual_focal_point": "metric cards", + "visual_signature": "dashboard cards with bar geometry", + "svg_effects": ["chart_geometry"], + "required_primitives": ["dashboard", "micro_chart"], + "svg_primitives": ["dashboard", "micro_chart"], + "xml_like_risk": "would become generic cards", + "content_density_contract": "dashboard >= 5 metrics", + "asset_contract": "none_required", + "risk_flags": [], + "source_policy": "Use prompt-provided content only." + }] + } + """, + encoding="utf-8", + ) + result = svg_preflight.lint_files([str(svg_path)], str(plan_path)) + codes = [issue["code"] for issue in result["plan"]["issues"]] + self.assertIn("plan_content_density_contract_not_met", codes) + + def test_lint_svg_reports_xml_like_card_layout(self) -> None: + svg = """ + + + + + + +
A
+
+ +
B
+
+ +
C
+
+
+ """ + result = svg_preflight.lint_svg(with_contract(svg)) + codes = [issue["code"] for issue in result["issues"]] + self.assertIn("xml_like_svg_layout", codes) + + def test_lint_files_includes_optional_plan_result(self) -> None: + result = svg_preflight.lint_files([], None) + self.assertEqual(result["summary"]["file_count"], 0) + if __name__ == "__main__": unittest.main()