Compare commits

...

16 Commits

Author SHA1 Message Date
songtianyi.theo
4335ffaafd feat(slides): add svglide pipeline receipts 2026-06-17 21:33:04 +08:00
songtianyi.theo
9aa38a3597 feat(slides): capture svglide generation strategy 2026-06-17 20:39:53 +08:00
songtianyi.theo
9c79fe1ca2 fix(slides): harden svglide visual pipeline gates 2026-06-17 20:07:11 +08:00
songtianyi.theo
27062ee254 feat(slides): harden svglide visual pipeline 2026-06-17 18:09:46 +08:00
songtianyi.theo
9e5f94b92b feat(slides): add optional SVG raster effect fallback 2026-06-17 15:24:47 +08:00
songtianyi.theo
d70a01b6a8 test(slides): harden SVGlide visual quality gates 2026-06-17 15:22:41 +08:00
songtianyi.theo
b7519b4ce3 feat(slides): add SVGlide project pipeline gates 2026-06-17 01:05:19 +08:00
songtianyi.theo
db8781f7d6 完善 SVGlide 私有规则与 PPE 发布校验 2026-06-12 18:35:42 +08:00
songtianyi.theo
07e57d7857 支持 SVGlide 图表 spec 标记 2026-06-11 19:51:06 +08:00
songtianyi.theo
566f6cfd47 feat(slides): support SVGlide chart markers 2026-06-11 15:56:24 +08:00
songtianyi.theo
cd0854e931 中文化 SVGlide 视觉规则文档
将 visual recipe 和 aesthetic review 两个执行入口改为中文说明,保留字段名、枚举、命令和 JSON 契约。
2026-06-11 15:23:49 +08:00
songtianyi.theo
be8a67b894 完善 SVGlide 视觉生成与预检门禁
新增 style presets、visual recipe 和 aesthetic review gate,并接入 lark-slides skill 执行链路。

扩展 create-svg 图片处理、协议说明与 svg_preflight 校验,补充对应测试。
2026-06-11 15:23:49 +08:00
songtianyi.theo
f5502416c8 docs: harden svglide workflow guidance 2026-06-11 15:22:23 +08:00
songtianyi.theo
182c541ea6 docs: strengthen svglide generation guidance 2026-06-11 15:22:23 +08:00
songtianyi.theo
d980e54ab8 docs: add svglide deck density planning 2026-06-11 15:22:23 +08:00
songtianyi.theo
85965e41e4 feat: add svglide create-svg shortcut 2026-06-11 15:22:22 +08:00
63 changed files with 42416 additions and 64 deletions

View File

@@ -55,9 +55,8 @@ func WithKeychain(kc keychain.KeychainAccess) BuildOption {
// embeddedSkillContent is the skill tree wired into cmdutil.Factory.SkillContent
// at build time. It is registered by the repo-root package main's init via
// SetEmbeddedSkillContent — it cannot be threaded through main.go without
// breaking the single-file preview build (see skills_embed.go). nil in builds
// that embed no skills; the `skills` commands then return a typed internal error.
// SetEmbeddedSkillContent. nil in builds that embed no skills; the `skills`
// commands then return a typed internal error.
var embeddedSkillContent fs.FS
// SetEmbeddedSkillContent registers the embedded skill tree. Called from the

View File

@@ -26,7 +26,7 @@ build_target() {
local output="$OUT_DIR/bin/lark-cli-${goos}-${goarch}${ext}"
echo "Building ${goos}/${goarch} -> ${output}"
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags "$LDFLAGS" -o "$output" ./main.go
CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" go build -trimpath -ldflags "$LDFLAGS" -o "$output" .
}
build_target darwin arm64

View File

@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
SlidesCreate,
SlidesCreateSVG,
SlidesMediaUpload,
SlidesReplaceSlide,
}

View File

@@ -124,35 +124,19 @@ var SlidesCreate = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
title := effectiveTitle(runtime.Str("title"))
content := buildPresentationXML(title)
slidesStr := runtime.Str("slides")
// Step 1: Create presentation
data, err := runtime.CallAPITyped(
"POST",
"/open-apis/slides_ai/v1/xml_presentations",
nil,
map[string]interface{}{
"xml_presentation": map[string]interface{}{
"content": content,
},
},
)
presentationID, revisionID, err := createEmptyPresentation(runtime, title)
if err != nil {
return err
}
presentationID := common.GetString(data, "xml_presentation_id")
if presentationID == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "slides create returned no xml_presentation_id")
}
result := map[string]interface{}{
"xml_presentation_id": presentationID,
"title": title,
}
if revisionID := common.GetFloat(data, "revision_id"); revisionID > 0 {
result["revision_id"] = int(revisionID)
if revisionID > 0 {
result["revision_id"] = revisionID
}
// Step 2: Add slides if provided
@@ -197,6 +181,9 @@ var SlidesCreate = common.Shortcut{
if sid := common.GetString(slideData, "slide_id"); sid != "" {
slideIDs = append(slideIDs, sid)
}
if latest := common.GetFloat(slideData, "revision_id"); latest > 0 {
result["revision_id"] = int(latest)
}
}
result["slide_ids"] = slideIDs
@@ -204,19 +191,7 @@ var SlidesCreate = common.Shortcut{
}
}
// Build the presentation URL locally from the token. The brand-standard
// host transparently redirects to the tenant domain (same fallback used by
// drive +upload / wiki +node-create). This avoids the prior best-effort
// drive metas/batch_query call, which needed an extra drive scope and 403'd
// for users who only authorized slides scopes — without ever blocking an
// otherwise-successful creation.
if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
result["url"] = url
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, presentationID, "slides"); grant != nil {
result["permission_grant"] = grant
}
fillPresentationResult(runtime, presentationID, result)
runtime.Out(result, nil)
return nil
@@ -243,6 +218,44 @@ func buildPresentationXML(title string) string {
)
}
func createEmptyPresentation(runtime *common.RuntimeContext, title string) (string, int, error) {
data, err := runtime.CallAPI(
"POST",
"/open-apis/slides_ai/v1/xml_presentations",
nil,
map[string]interface{}{
"xml_presentation": map[string]interface{}{
"content": buildPresentationXML(title),
},
},
)
if err != nil {
return "", 0, err
}
presentationID := common.GetString(data, "xml_presentation_id")
if presentationID == "" {
return "", 0, errs.NewInternalError(errs.SubtypeInvalidResponse, "slides create returned no xml_presentation_id")
}
revisionID := 0
if rev := common.GetFloat(data, "revision_id"); rev > 0 {
revisionID = int(rev)
}
return presentationID, revisionID, nil
}
func fillPresentationResult(runtime *common.RuntimeContext, presentationID string, result map[string]interface{}) {
// Build the presentation URL locally from the token. The brand-standard
// host transparently redirects to the tenant domain. This avoids the prior
// best-effort drive metas/batch_query call, which needed an extra drive scope.
if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
result["url"] = url
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, presentationID, "slides"); grant != nil {
result["permission_grant"] = grant
}
}
// uploadSlidesPlaceholders uploads each unique placeholder path against the
// presentation and returns the path→file_token map. The second return value is
// the number of files successfully uploaded before any error, so callers can

View File

@@ -0,0 +1,197 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// SlidesCreateSVG creates a new Lark Slides presentation from one or more
// SVGlide SVG files by adding each page through the existing XML slide route.
var SlidesCreateSVG = common.Shortcut{
Service: "slides",
Command: "+create-svg",
Description: "Create a Lark Slides presentation from SVG",
Risk: "write",
AuthTypes: []string{"user", "bot"},
Scopes: []string{
"slides:presentation:create",
"slides:presentation:write_only",
"docs:document.media:upload",
},
Flags: []common.Flag{
{Name: "title", Desc: "presentation title"},
{
Name: "file",
Type: "string_array",
Required: true,
Desc: "SVG file path; repeat for multiple pages",
},
{Name: "assets", Desc: "optional assets.json path mapping SVG @path placeholders to uploaded file tokens"},
{
Name: "svg-rasterize-effects",
Default: "off",
Enum: []string{"off", "auto", "strict", "force-page"},
Desc: "Rasterize unsupported rich SVG effects before upload: off|auto|strict|force-page",
},
{
Name: "svg-rasterize-scale",
Type: "int",
Default: "2",
Desc: "PNG raster scale; default 2",
},
{Name: "svg-rasterize-report", Desc: "optional raster report output path"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateSVGFileInputs(runtime, runtime.StrArray("file")); err != nil {
return err
}
if err := validateSVGRasterizeFlags(runtime); err != nil {
return err
}
return validateSVGAssetsPath(runtime, runtime.Str("assets"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
title := effectiveTitle(runtime.Str("title"))
dry := common.NewDryRunAPI()
svgs, prepareReport, err := prepareSVGFilesForCreate(
runtime,
runtime.StrArray("file"),
svgPrepareOptionsFromRuntime(runtime, true),
)
if err != nil {
return dry.Set("error", err.Error())
}
assets, err := parseSVGAssets(runtime, runtime.Str("assets"))
if err != nil {
return dry.Set("error", err.Error())
}
if err := validateSVGRasterAssetConflicts(assets, prepareReport); err != nil {
return dry.Set("error", err.Error())
}
pages, uploadPaths := dryRunRewriteSVGImagePlaceholders(svgs, assets)
if prepareReport != nil {
dry.Set("svg_rasterize_report", prepareReport)
}
total := 1 + len(uploadPaths) + len(pages)
descSuffix := ""
if len(uploadPaths) > 0 {
descSuffix = fmt.Sprintf(" + upload %d image(s)", len(uploadPaths))
}
dry.Desc(fmt.Sprintf("Create presentation from %d SVG page(s)%s", len(pages), descSuffix)).
POST("/open-apis/slides_ai/v1/xml_presentations").
Desc(fmt.Sprintf("[1/%d] Create presentation", total)).
Body(map[string]interface{}{
"xml_presentation": map[string]interface{}{"content": buildPresentationXML(title)},
})
for i, path := range uploadPaths {
appendSlidesUploadDryRun(dry, path, "<xml_presentation_id>", i+2)
}
slideStepStart := 2 + len(uploadPaths)
for i, page := range pages {
content, injectErr := injectSVGTransportAssetMetadata(page.Content, page.Tokens)
if injectErr != nil {
return common.NewDryRunAPI().Set("error", injectErr.Error())
}
dry.POST("/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>/slide").
Desc(fmt.Sprintf("[%d/%d] Add SVG page %d", slideStepStart+i, total, i+1)).
Params(map[string]interface{}{"revision_id": -1}).
Body(buildCreateSVGBody(content))
}
if runtime.IsBot() {
dry.Desc("After creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new presentation.")
}
return dry.Set("title", title)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
title := effectiveTitle(runtime.Str("title"))
svgs, prepareReport, err := prepareSVGFilesForCreate(
runtime,
runtime.StrArray("file"),
svgPrepareOptionsFromRuntime(runtime, false),
)
if err != nil {
return err
}
assets, err := parseSVGAssets(runtime, runtime.Str("assets"))
if err != nil {
return err
}
if err := validateSVGRasterAssetConflicts(assets, prepareReport); err != nil {
return err
}
presentationID, revisionID, err := createEmptyPresentation(runtime, title)
if err != nil {
return err
}
result := map[string]interface{}{
"xml_presentation_id": presentationID,
"title": title,
}
if prepareReport != nil {
result["svg_rasterize_report"] = prepareReport
}
if revisionID > 0 {
result["revision_id"] = revisionID
}
pages, uploaded, err := rewriteSVGImagePlaceholders(runtime, presentationID, svgs, assets)
if err != nil {
return output.Errorf(output.ExitAPI, "api_error",
"image upload failed: %v (presentation %s was created; %d image(s) uploaded before failure)",
err, presentationID, uploaded)
}
if uploaded > 0 {
result["images_uploaded"] = uploaded
}
slideURL := fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide",
validate.EncodePathSegment(presentationID),
)
var slideIDs []string
for i, page := range pages {
content, err := injectSVGTransportAssetMetadata(page.Content, page.Tokens)
if err != nil {
return output.Errorf(output.ExitValidation, "validation",
"page %d/%d failed before API call: %v (presentation %s was created; %d slide(s) added; slide_ids=%s)",
i+1, len(pages), err, presentationID, len(slideIDs), strings.Join(slideIDs, ","))
}
slideData, err := runtime.CallAPI(
"POST",
slideURL,
map[string]interface{}{"revision_id": -1},
buildCreateSVGBody(content),
)
if err != nil {
return output.Errorf(output.ExitAPI, "api_error",
"page %d/%d failed: %v%s (presentation %s was created; %d slide(s) added; slide_ids=%s)",
i+1, len(pages), err, formatSVGlideErrorSuffix(err), presentationID, len(slideIDs), strings.Join(slideIDs, ","))
}
if sid := common.GetString(slideData, "slide_id"); sid != "" {
slideIDs = append(slideIDs, sid)
}
if latest := common.GetFloat(slideData, "revision_id"); latest > 0 {
result["revision_id"] = int(latest)
}
}
result["slide_ids"] = slideIDs
result["slides_added"] = len(slideIDs)
fillPresentationResult(runtime, presentationID, result)
runtime.Out(result, nil)
return nil
},
}

View File

@@ -0,0 +1,502 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"bytes"
"encoding/json"
"os"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
const testSVGlidePage1 = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect slide:role="shape" x="80" y="80" width="320" height="180"/></svg>`
const testSVGlidePage2 = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="320" height="80"><p xmlns="http://www.w3.org/1999/xhtml">second</p></foreignObject></svg>`
func TestSlidesCreateSVGMissingFileFlag(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--title", "missing file",
"--as", "user",
})
if err == nil {
t.Fatal("expected missing --file error")
}
if !strings.Contains(err.Error(), "file") {
t.Fatalf("err = %v, want mention of file", err)
}
}
func TestSlidesCreateSVGFileMissing(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "missing.svg",
"--title", "missing svg",
"--as", "user",
})
if err == nil {
t.Fatal("expected validation error for missing SVG")
}
if !strings.Contains(err.Error(), "missing.svg") {
t.Fatalf("err = %v, want mention of missing.svg", err)
}
}
func TestSlidesCreateSVGEmptyFile(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("empty.svg", nil, 0o644); err != nil {
t.Fatalf("write empty.svg: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "empty.svg",
"--title", "empty svg",
"--as", "user",
})
if err == nil {
t.Fatal("expected validation error for empty SVG")
}
if !strings.Contains(err.Error(), "empty.svg") || !strings.Contains(err.Error(), "empty") {
t.Fatalf("err = %v, want empty.svg empty-file message", err)
}
}
func TestSlidesCreateSVGExecuteCreatesSlidesInFileOrder(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("page1.svg", []byte(testSVGlidePage1), 0o644); err != nil {
t.Fatalf("write page1.svg: %v", err)
}
if err := os.WriteFile("page2.svg", []byte(testSVGlidePage2), 0o644); err != nil {
t.Fatalf("write page2.svg: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation_id": "pres_svg",
"revision_id": 1,
},
},
})
slideStub1 := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_svg/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_1", "revision_id": 2}},
}
slideStub2 := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_svg/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_2", "revision_id": 3}},
}
reg.Register(slideStub1)
reg.Register(slideStub2)
registerBatchQueryStub(reg, "pres_svg", "https://x.feishu.cn/slides/pres_svg")
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "page1.svg",
"--file", "page2.svg",
"--title", "SVG Deck",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["xml_presentation_id"] != "pres_svg" {
t.Fatalf("xml_presentation_id = %v, want pres_svg", data["xml_presentation_id"])
}
if data["slides_added"] != float64(2) {
t.Fatalf("slides_added = %v, want 2", data["slides_added"])
}
if data["revision_id"] != float64(3) {
t.Fatalf("revision_id = %v, want latest revision 3", data["revision_id"])
}
slideIDs, ok := data["slide_ids"].([]interface{})
if !ok || len(slideIDs) != 2 || slideIDs[0] != "slide_1" || slideIDs[1] != "slide_2" {
t.Fatalf("slide_ids = %v, want [slide_1 slide_2]", data["slide_ids"])
}
assertSlideCreateBodyContains(t, slideStub1, `slide:contract-version="svglide-authoring-contract/v1"`)
assertSlideCreateBodyContains(t, slideStub1, `<rect slide:role="shape" x="80" y="80" width="320" height="180"/>`)
assertSlideCreateBodyContains(t, slideStub2, `slide:contract-version="svglide-authoring-contract/v1"`)
assertSlideCreateBodyContains(t, slideStub2, `<foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="320" height="80">`)
}
func TestSlidesCreateSVGChartMarkerPassesThroughSlideContent(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720">` + testSVGlideChartMarker(testSVGlideChartMetadata(testSVGlideChartSpecJSON())) + `</svg>`
if err := os.WriteFile("chart.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write chart.svg: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_chart", "revision_id": 1}},
})
slideStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_chart/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_chart", "revision_id": 2}},
}
reg.Register(slideStub)
registerBatchQueryStub(reg, "pres_chart", "https://x.feishu.cn/slides/pres_chart")
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "chart.svg",
"--title", "chart marker",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
t.Fatalf("decode slide body: %v", err)
}
if len(body) != 1 {
t.Fatalf("slide create body should only contain slide wrapper, got: %v", body)
}
slide, ok := body["slide"].(map[string]interface{})
if !ok || len(slide) != 1 {
t.Fatalf("slide create body should be {slide:{content}}, got: %v", body)
}
content, ok := slide["content"].(string)
if !ok {
t.Fatalf("slide.content should be a string, got: %v", slide["content"])
}
for _, want := range []string{
`slide:contract-version="svglide-authoring-contract/v1"`,
`<g slide:role="chart" slide:chart-ref="chart-1" x="80" y="96" width="420" height="260">`,
`data-svglide-chart="svglide-chart-inline/v1"`,
`data-format="svglide-chart-spec-v1"`,
`data-encoding="base64url-json"`,
`data-payload-hash="sha256:`,
} {
if !strings.Contains(content, want) {
t.Fatalf("content missing %s: %s", want, content)
}
}
}
func TestSlidesCreateSVGPartialFailureIncludesRecoveryContext(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("page1.svg", []byte(testSVGlidePage1), 0o644); err != nil {
t.Fatalf("write page1.svg: %v", err)
}
if err := os.WriteFile("page2.svg", []byte(testSVGlidePage2), 0o644); err != nil {
t.Fatalf("write page2.svg: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"xml_presentation_id": "pres_svg_partial",
"revision_id": 1,
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_svg_partial/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_ok", "revision_id": 2}},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_svg_partial/slide",
Body: map[string]interface{}{
"code": 400,
"msg": "invalid svg",
},
})
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "page1.svg",
"--file", "page2.svg",
"--title", "partial svg",
"--as", "user",
})
if err == nil {
t.Fatal("expected slide create failure")
}
errMsg := err.Error()
for _, want := range []string{"pres_svg_partial", "page 2/2", "1 slide(s) added", "slide_ok"} {
if !strings.Contains(errMsg, want) {
t.Fatalf("err = %v, want mention of %q", err, want)
}
}
}
func TestSlidesCreateSVGFailureExtractsSVGlideMarker(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("page.svg", []byte(testSVGlidePage1), 0o644); err != nil {
t.Fatalf("write page.svg: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_marker", "revision_id": 1}},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_marker/slide",
Body: map[string]interface{}{
"code": 400,
"msg": `SVGLIDE_ERROR_JSON:{"type":"svg_validation_error","page_index":0,"tag_name":"foreignObject","hint":"Use supported elements"}`,
},
})
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "page.svg",
"--title", "marker",
"--as", "user",
})
if err == nil {
t.Fatal("expected marker failure")
}
errMsg := err.Error()
for _, want := range []string{"svglide_error=", "svg_validation_error", "foreignObject", "Use supported elements"} {
if !strings.Contains(errMsg, want) {
t.Fatalf("err = %v, want marker field %q", err, want)
}
}
}
func TestSlidesCreateSVGAssetsReplaceImageAndInjectMetadata(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><image slide:role="image" xlink:href='@./hero.png' x="0" y="0" width="320" height="180"/></svg>`
if err := os.WriteFile("page.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write page.svg: %v", err)
}
if err := os.WriteFile("assets.json", []byte(`{"@./hero.png":"boxcn_asset"}`), 0o644); err != nil {
t.Fatalf("write assets.json: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_asset", "revision_id": 1}},
})
slideStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_asset/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_asset", "revision_id": 2}},
}
reg.Register(slideStub)
registerBatchQueryStub(reg, "pres_asset", "https://x.feishu.cn/slides/pres_asset")
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "page.svg",
"--assets", "assets.json",
"--title", "assets",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
t.Fatalf("decode slide body: %v", err)
}
content := body["slide"].(map[string]interface{})["content"].(string)
if strings.Contains(content, "@./hero.png") || strings.Contains(content, "xlink:href") {
t.Fatalf("content should canonicalize asset placeholder: %s", content)
}
for _, want := range []string{`href="boxcn_asset"`, `<metadata data-svglide-assets="true">`, `<img src="boxcn_asset" />`} {
if !strings.Contains(content, want) {
t.Fatalf("content missing %s: %s", want, content)
}
}
if _, ok := decodeSlidesCreateEnvelope(t, stdout)["images_uploaded"]; ok {
t.Fatalf("--assets token mapping should not upload local images")
}
}
func TestSlidesCreateSVGNestedImageAssetsReplaceAndInjectMetadata(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><g transform="translate(10 20)"><image slide:role="image" xlink:href='@./hero.png' x="0" y="0" width="320" height="180"/></g></svg>`
if err := os.WriteFile("page.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write page.svg: %v", err)
}
if err := os.WriteFile("assets.json", []byte(`{"@./hero.png":"boxcn_asset"}`), 0o644); err != nil {
t.Fatalf("write assets.json: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_nested_asset", "revision_id": 1}},
})
slideStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_nested_asset/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_nested_asset", "revision_id": 2}},
}
reg.Register(slideStub)
registerBatchQueryStub(reg, "pres_nested_asset", "https://x.feishu.cn/slides/pres_nested_asset")
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "page.svg",
"--assets", "assets.json",
"--title", "nested assets",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
t.Fatalf("decode slide body: %v", err)
}
content := body["slide"].(map[string]interface{})["content"].(string)
for _, want := range []string{
`href="boxcn_asset"`,
`<metadata data-svglide-assets="true">`,
`<img src="boxcn_asset" />`,
`<g transform="translate(10 20)">`,
} {
if !strings.Contains(content, want) {
t.Fatalf("content missing %s: %s", want, content)
}
}
for _, notWant := range []string{`xlink:href`, `@./hero.png`} {
if strings.Contains(content, notWant) {
t.Fatalf("content should not contain %s: %s", notWant, content)
}
}
if _, ok := decodeSlidesCreateEnvelope(t, stdout)["images_uploaded"]; ok {
t.Fatalf("--assets token mapping should not upload local images")
}
}
func TestSlidesCreateSVGUploadsLocalImagesAndInjectsMetadata(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
svg := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><image slide:role="image" href="@hero.png" x="0" y="0" width="320" height="180"/></svg>`
if err := os.WriteFile("page.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write page.svg: %v", err)
}
if err := os.WriteFile("hero.png", []byte("png"), 0o644); err != nil {
t.Fatalf("write hero.png: %v", err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"xml_presentation_id": "pres_upload", "revision_id": 1}},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"file_token": "boxcn_uploaded"}},
})
slideStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_upload/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_upload", "revision_id": 2}},
}
reg.Register(slideStub)
registerBatchQueryStub(reg, "pres_upload", "https://x.feishu.cn/slides/pres_upload")
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "page.svg",
"--title", "upload",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["images_uploaded"] != float64(1) {
t.Fatalf("images_uploaded = %v, want 1", data["images_uploaded"])
}
var body map[string]interface{}
if err := json.Unmarshal(slideStub.CapturedBody, &body); err != nil {
t.Fatalf("decode slide body: %v", err)
}
content := body["slide"].(map[string]interface{})["content"].(string)
for _, want := range []string{`href="boxcn_uploaded"`, `<img src="boxcn_uploaded" />`} {
if !strings.Contains(content, want) {
t.Fatalf("content missing %s: %s", want, content)
}
}
}
func runSlidesCreateSVGShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "slides"}
SlidesCreateSVG.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
func assertSlideCreateBodyContains(t *testing.T, stub *httpmock.Stub, want string) {
t.Helper()
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode slide body: %v\nraw=%s", err, string(stub.CapturedBody))
}
slide, _ := body["slide"].(map[string]interface{})
content, _ := slide["content"].(string)
if !strings.Contains(content, want) {
t.Fatalf("slide content = %s\nwant to contain %s", content, want)
}
}
func registerBatchQueryStub(_ *httpmock.Registry, _, _ string) {
// fillPresentationResult now builds presentation URLs locally, so SVG create
// tests keep this helper as a no-op compatibility shim for older assertions.
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,430 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"reflect"
"strings"
"testing"
)
func TestExtractSVGImagePlaceholderPaths(t *testing.T) {
t.Parallel()
svgs := []string{
`<svg><image slide:role="image" href="@./hero.png"/><a href="@./link.png"/></svg>`,
`<svg><image xlink:href='@./hero.png'/><image href = "@./other.png"/></svg>`,
}
got := extractSVGImagePlaceholderPaths(svgs, map[string]string{"@./other.png": "boxcn_other"})
want := []string{"./hero.png"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestRewriteSVGImagePlaceholdersWithTokens(t *testing.T) {
t.Parallel()
in := `<svg><image slide:role="image" href="@./hero.png"/><image xlink:href='@./logo.png'/><image data-href="@./ignored.png"/><a href="@./link.png">link</a><image href="https://example.com/noop.png"/></svg>`
got, tokens := rewriteSVGImagePlaceholdersWithTokens(in, map[string]string{
"./hero.png": "boxcn_hero",
"./logo.png": "boxcn_logo",
})
for _, want := range []string{`href="boxcn_hero"`, `href="boxcn_logo"`} {
if !strings.Contains(got, want) {
t.Fatalf("rewritten SVG missing %s: %s", want, got)
}
}
if strings.Contains(got, "xlink:href") {
t.Fatalf("rewritten SVG must not retain xlink:href: %s", got)
}
if !strings.Contains(got, `<a href="@./link.png">`) {
t.Fatalf("non-image href should be untouched: %s", got)
}
if !strings.Contains(got, `data-href="@./ignored.png"`) {
t.Fatalf("non-href image attribute should be untouched: %s", got)
}
wantTokens := []string{"boxcn_hero", "boxcn_logo"}
if !reflect.DeepEqual(tokens, wantTokens) {
t.Fatalf("tokens = %v, want %v", tokens, wantTokens)
}
}
func TestInjectSVGTransportAssetMetadata(t *testing.T) {
t.Parallel()
in := `<?xml version="1.0"?><!DOCTYPE svg><!-- lead --><svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect/></svg>`
got, err := injectSVGTransportAssetMetadata(in, []string{"boxcn_a", "boxcn_b", "boxcn_a"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
rootIdx := strings.Index(got, "<svg")
metaIdx := strings.Index(got, `<metadata data-svglide-assets="true">`)
if rootIdx < 0 || metaIdx < rootIdx {
t.Fatalf("metadata should be injected inside root <svg>, got: %s", got)
}
if strings.Count(got, `src="boxcn_a"`) != 1 {
t.Fatalf("boxcn_a should be deduped, got: %s", got)
}
if !strings.Contains(got, `src="boxcn_b"`) {
t.Fatalf("boxcn_b missing, got: %s", got)
}
}
func TestInjectSVGTransportAssetMetadataMergesExisting(t *testing.T) {
t.Parallel()
in := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><metadata data-svglide-assets="true"><img src="boxcn_a" /></metadata><image href="boxcn_a"/></svg>`
got, err := injectSVGTransportAssetMetadata(in, []string{"boxcn_a", "boxcn_b"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if strings.Count(got, `<metadata data-svglide-assets="true">`) != 1 {
t.Fatalf("should keep a single transport metadata block, got: %s", got)
}
if strings.Count(got, `src="boxcn_a"`) != 1 {
t.Fatalf("boxcn_a should remain deduped, got: %s", got)
}
if !strings.Contains(got, `src="boxcn_b"`) {
t.Fatalf("boxcn_b should be appended, got: %s", got)
}
}
func TestEnsureSVGlideRootContractVersionInjectsMissingVersion(t *testing.T) {
t.Parallel()
in := `<?xml version="1.0"?><!DOCTYPE svg><!-- lead --><svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg>`
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, `><rect`) {
t.Fatalf("contract version should be injected on the root open tag: %s", got)
}
if err := validateSVGlideSVG(got, "page.svg"); err != nil {
t.Fatalf("normalized SVG should pass validation: %v", err)
}
}
func TestEnsureSVGlideRootContractVersionRejectsWrongVersion(t *testing.T) {
t.Parallel()
in := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" slide:contract-version="old"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg>`
_, 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()
tests := []struct {
name string
svg string
wantErr string
}{
{
name: "supported shape rect",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg>`,
},
{
name: "supported text foreignObject",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="shape" slide:shape-type="text" x="0" y="0" width="200" height="80"><p xmlns="http://www.w3.org/1999/xhtml">hello</p></foreignObject></svg>`,
},
{
name: "supported image href",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" href="boxcn_img" x="0" y="0" width="100" height="60"/></svg>`,
},
{
name: "supported image xlink href before rewrite",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" xlink:href="@./hero.png" x="0" y="0" width="100" height="60"/></svg>`,
},
{
name: "supported path commands",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><path slide:role="shape" d="M1e-3 0 L80 0 H120 V40 C120 60 100 80 80 80 Q40 80 20 40 Z" fill="#123456"/></svg>`,
},
{
name: "defs and metadata are ignored",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><defs><rect id="r"/></defs><metadata data-svglide-assets="true"><img src="boxcn_img"/></metadata><circle slide:role="shape" cx="50" cy="50" r="20"/></svg>`,
},
{
name: "group container with role-fixed child",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g fill="#112233" transform="translate(10 20)"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></g></svg>`,
},
{
name: "nested svg container with role-fixed child",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><svg viewBox="0 0 100 100"><circle slide:role="shape" cx="50" cy="50" r="20"/></svg></svg>`,
},
{
name: "group container ignores its own role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g slide:role="shape"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></g></svg>`,
},
{
name: "nested svg container ignores its own role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><svg slide:role="shape" viewBox="0 0 100 100"><circle slide:role="shape" cx="50" cy="50" r="20"/></svg></svg>`,
},
{
name: "root chart marker with inline payload",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadata(testSVGlideChartSpecJSON())) + `</svg>`,
},
{
name: "style and nested defs are ignored",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><style>.primary{fill:#123456}</style><g><defs><linearGradient id="g"><stop offset="0%" stop-color="#fff"/><stop offset="100%" stop-color="#000"/></linearGradient></defs></g><rect slide:role="shape" class="primary" x="0" y="0" width="100" height="60" fill="url(#g)"/></svg>`,
},
{
name: "filter and shadow styles are preserved",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><style>.card{filter:drop-shadow(2px 4px 8px rgba(0,0,0,.2));box-shadow:0 8px 20px rgba(0,0,0,.18)}</style><g><defs><filter id="shadow"><feDropShadow dx="2" dy="3" stdDeviation="5" flood-color="#000" flood-opacity=".25"/></filter></defs></g><rect slide:role="shape" class="card" x="0" y="0" width="100" height="60" filter="url(#shadow)"/></svg>`,
},
{
name: "foreignObject XHTML subtree is not role-validated",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="shape" slide:shape-type="text" x="0" y="0" width="200" height="80"><div xmlns="http://www.w3.org/1999/xhtml"><span>hello</span></div></foreignObject></svg>`,
},
{
name: "foreignObject XHTML br is allowed",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="shape" slide:shape-type="text" x="0" y="0" width="200" height="80"><div xmlns="http://www.w3.org/1999/xhtml">hello<br />world</div></foreignObject></svg>`,
},
{
name: "namespaced root is rejected with precise message",
svg: `<svg:svg xmlns:svg="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg:svg>`,
wantErr: `root element must be non-namespaced <svg>`,
},
{
name: "root child missing role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect x="0" y="0" width="100" height="60"/></svg>`,
wantErr: `<rect> must include slide:role="shape" or slide:role="image"`,
},
{
name: "group child missing role is rejected",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g><rect x="0" y="0" width="100" height="60"/></g></svg>`,
wantErr: `<rect> must include slide:role="shape" or slide:role="image"`,
},
{
name: "unsupported text element remains rejected",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><text slide:role="shape" x="0" y="20">bad</text></svg>`,
wantErr: `<text slide:role="shape"> is not supported by SVGlide`,
},
{
name: "rect shape requires geometry",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" height="60"/></svg>`,
wantErr: `<rect slide:role="shape"> missing required attribute "width"`,
},
{
name: "path shape requires d",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><path slide:role="shape" fill="#123456"/></svg>`,
wantErr: `<path slide:role="shape"> missing required attribute "d"`,
},
{
name: "rect rejects percent geometry",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="0" y="0" width="50%" height="60"/></svg>`,
wantErr: `attribute "width" must be a number or px length`,
},
{
name: "rect rejects calc geometry",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="shape" x="calc(10px)" y="0" width="100" height="60"/></svg>`,
wantErr: `attribute "x" must be a number or px length`,
},
{
name: "container transform rejects percent argument",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g transform="translate(10% 20)"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></g></svg>`,
wantErr: `transform translate() argument must be a number or px length`,
},
{
name: "path rejects arc command",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><path slide:role="shape" d="M0 0 A10 10 0 0 1 20 20" fill="#123456"/></svg>`,
wantErr: `unsupported path command or character "A"`,
},
{
name: "path rejects smooth command",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><path slide:role="shape" d="M0 0 S10 10 20 20" fill="#123456"/></svg>`,
wantErr: `unsupported path command or character "S"`,
},
{
name: "plain metadata remains rejected",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><metadata><desc>not transport metadata</desc></metadata></svg>`,
wantErr: `<metadata> must include slide:role="shape" or slide:role="image"`,
},
{
name: "whiteboard role is explicitly rejected",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g slide:role="whiteboard" x="0" y="0" width="100" height="60"/></svg>`,
wantErr: `slide:role="whiteboard" is not supported`,
},
{
name: "legacy whiteboard marker metadata is explicitly rejected",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><metadata data-svglide-whiteboard="svglide-whiteboard-inline/v1">abc</metadata></svg>`,
wantErr: `legacy SVGlide whiteboard marker metadata is not supported`,
},
{
name: "foreignObject shape requires text type",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="shape"><p xmlns="http://www.w3.org/1999/xhtml">hello</p></foreignObject></svg>`,
wantErr: `<foreignObject slide:role="shape"> must include slide:shape-type="text"`,
},
{
name: "image role must be image tag",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="image" href="boxcn_img"/></svg>`,
wantErr: `<rect slide:role="image"> is not supported`,
},
{
name: "image requires href",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" x="0" y="0" width="100" height="60"/></svg>`,
wantErr: `<image slide:role="image"> must include href`,
},
{
name: "image requires geometry",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" href="boxcn_img" x="0" y="0" height="60"/></svg>`,
wantErr: `<image slide:role="image"> missing required attribute "width"`,
},
{
name: "image rejects external href",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><image slide:role="image" href="https://images.unsplash.com/photo.jpg" x="0" y="0" width="100" height="60"/></svg>`,
wantErr: `<image slide:role="image"> must not use external http(s) or data href`,
},
{
name: "unsupported role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="decor"/></svg>`,
wantErr: `unsupported slide:role="decor"`,
},
{
name: "nested chart marker is rejected",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g>` + testSVGlideChartMarker(testSVGlideChartMetadata(testSVGlideChartSpecJSON())) + `</g></svg>`,
wantErr: `<g slide:role="chart"> must be a direct child of root <svg>`,
},
{
name: "chart marker requires ref",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g slide:role="chart" x="0" y="0" width="100" height="60">` + testSVGlideChartMetadata(testSVGlideChartSpecJSON()) + `</g></svg>`,
wantErr: `missing required attribute "slide:chart-ref"`,
},
{
name: "chart marker rejects bad bbox",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><g slide:role="chart" slide:chart-ref="chart-1" x="10%" y="0" width="100" height="60">` + testSVGlideChartMetadata(testSVGlideChartSpecJSON()) + `</g></svg>`,
wantErr: `attribute "x" must be a number or px length`,
},
{
name: "chart marker requires single metadata",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadata(testSVGlideChartSpecJSON())+testSVGlideChartMetadata(testSVGlideChartSpecJSON())) + `</svg>`,
wantErr: `must contain exactly one metadata child`,
},
{
name: "chart marker rejects duplicate chart refs",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadata(testSVGlideChartSpecJSON())) + testSVGlideChartMarker(testSVGlideChartMetadata(testSVGlideLineChartSpecJSON())) + `</svg>`,
wantErr: `duplicate slide:chart-ref "chart-1"`,
},
{
name: "chart marker rejects bad base64url",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(`<metadata data-svglide-chart="svglide-chart-inline/v1" data-format="svglide-chart-spec-v1" data-encoding="base64url-json" data-payload-hash="sha256:`+strings.Repeat("0", 64)+`">bad+payload</metadata>`) + `</svg>`,
wantErr: `payload must be base64url`,
},
{
name: "chart marker rejects old sxsd chart format",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(`<metadata data-svglide-chart="svglide-chart-inline/v1" data-format="sxsd-chart-v1" data-encoding="base64url" data-payload-hash="sha256:`+strings.Repeat("0", 64)+`">`+base64.RawURLEncoding.EncodeToString([]byte(`<chart />`))+`</metadata>`) + `</svg>`,
wantErr: `data-format="svglide-chart-spec-v1"`,
},
{
name: "chart marker rejects hash mismatch",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadataWithHash(testSVGlideChartSpecJSON(), "sha256:"+strings.Repeat("0", 64))) + `</svg>`,
wantErr: `data-payload-hash does not match`,
},
{
name: "chart marker decoded payload must be json",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadata(`<chart />`)) + `</svg>`,
wantErr: `decoded payload must be valid svglide-chart-spec-v1 JSON`,
},
{
name: "chart marker rejects unsupported chart type",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadata(`{"version":"svglide-chart-spec/v1","chartType":"pie","data":{"categories":["Q1"],"series":[{"name":"Revenue","values":[12]}]}}`)) + `</svg>`,
wantErr: `chartType must be one of bar,line`,
},
{
name: "chart marker rejects values length mismatch",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadata(`{"version":"svglide-chart-spec/v1","chartType":"bar","data":{"categories":["Q1","Q2"],"series":[{"name":"Revenue","values":[12]}]}}`)) + `</svg>`,
wantErr: `values length must match data.categories length`,
},
{
name: "chart marker rejects nonnumeric values",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide">` + testSVGlideChartMarker(testSVGlideChartMetadata(`{"version":"svglide-chart-spec/v1","chartType":"line","data":{"categories":["Q1"],"series":[{"name":"Revenue","values":["12"]}]}}`)) + `</svg>`,
wantErr: `must be a finite number`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateSVGlideSVG(withTestSVGlideContractVersion(tt.svg), "page.svg")
if tt.wantErr == "" {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err == nil {
t.Fatalf("expected error containing %q", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want to contain %q", err.Error(), tt.wantErr)
}
})
}
}
func testSVGlideChartMarker(metadata string) string {
return `<g slide:role="chart" slide:chart-ref="chart-1" x="80" y="96" width="420" height="260">` + metadata + `</g>`
}
func testSVGlideChartSpecJSON() string {
return `{"version":"svglide-chart-spec/v1","chartType":"bar","data":{"categories":["Q1","Q2"],"series":[{"name":"Revenue","values":[12.5,18]}]}}`
}
func testSVGlideLineChartSpecJSON() string {
return `{"version":"svglide-chart-spec/v1","chartType":"line","data":{"categories":["Q1","Q2"],"series":[{"name":"Revenue","values":[12.5,18]}]}}`
}
func testSVGlideChartMetadata(chartJSON string) string {
sum := sha256.Sum256([]byte(chartJSON))
return testSVGlideChartMetadataWithHash(chartJSON, fmt.Sprintf("sha256:%x", sum))
}
func testSVGlideChartMetadataWithHash(chartJSON, hash string) string {
payload := base64.RawURLEncoding.EncodeToString([]byte(chartJSON))
return fmt.Sprintf(
`<metadata data-svglide-chart="svglide-chart-inline/v1" data-format="svglide-chart-spec-v1" data-encoding="base64url-json" data-payload-hash="%s">%s</metadata>`,
hash,
payload,
)
}
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()
err := errors.New(`api error: SVGLIDE_ERROR_JSON:{"type":"svg_validation_error","page_index":0,"tag_name":"foreignObject","hint":"Use supported elements"}`)
got := extractSVGlideErrorJSON(err)
if got["type"] != "svg_validation_error" {
t.Fatalf("type = %v", got["type"])
}
if got["tag_name"] != "foreignObject" {
t.Fatalf("tag_name = %v", got["tag_name"])
}
suffix := formatSVGlideErrorSuffix(err)
for _, want := range []string{"svglide_error=", "svg_validation_error", "foreignObject"} {
if !strings.Contains(suffix, want) {
t.Fatalf("suffix = %q, want %q", suffix, want)
}
}
}

View File

@@ -0,0 +1,366 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"bytes"
"context"
"encoding/json"
"fmt"
"image/png"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const (
svgRasterizerSkillPath = "lark-slides/scripts/svg_rasterize_effects.py"
svgRasterizerSourcePath = "skills/" + svgRasterizerSkillPath
svgRasterizedOutputRoot = ".lark-slides/rasterized"
maxSVGRasterPNGBytes int64 = 20 * 1024 * 1024
)
var svgRasterizerEmbeddedSkillPaths = []string{
"lark-slides/scripts/svg_rasterize_effects.py",
"lark-slides/scripts/svg_effect_classifier.py",
"lark-slides/scripts/svg_safe_rewrite.py",
"lark-slides/scripts/svg_raster_renderer.py",
}
type svgRasterRuntime struct {
PythonPath string
}
type svgRasterizerInvocation struct {
PythonPath string
ScriptPath string
Args []string
}
var (
svgRasterizeResolveRuntime = resolveSVGRasterRuntime
svgRasterizeRunScript = runSVGRasterizerScript
)
func rasterizeRichSVGEffects(
runtime *common.RuntimeContext,
svgs []string,
paths []string,
opts svgPrepareOptions,
) ([]string, *svgPrepareReport, error) {
if len(svgs) != len(paths) {
return nil, nil, output.ErrValidation("internal svg rasterization error: SVG count %d does not match path count %d", len(svgs), len(paths))
}
if opts.RasterizeScale == 0 {
opts.RasterizeScale = 2
}
scriptPath, err := resolveSVGRasterizerScript(runtime)
if err != nil {
return nil, nil, err
}
rasterRuntime, err := svgRasterizeResolveRuntime(contextFromRuntime(runtime))
if err != nil {
return nil, nil, err
}
baseDir, err := runtime.ResolveSavePath(".")
if err != nil {
return nil, nil, output.ErrValidation("resolve current working directory for SVG rasterization: %v", err)
}
runID := newSVGRasterRunID()
runDir := filepath.ToSlash(filepath.Join(svgRasterizedOutputRoot, runID))
if err := ensureSVGRasterOutputDir(runtime, runDir); err != nil {
return nil, nil, err
}
report := &svgPrepareReport{
Version: "1",
Mode: string(opts.RasterizeMode),
RunID: runID,
BaseDir: baseDir,
Quality: svgPrepareQuality{
GatePassed: true,
},
Pages: make([]svgPreparePageReport, 0, len(svgs)),
}
prepared := make([]string, 0, len(svgs))
for i, svg := range svgs {
pageNo := i + 1
inputPath := filepath.ToSlash(filepath.Join(runDir, fmt.Sprintf("page-%03d.rich.svg", pageNo)))
outputPath := filepath.ToSlash(filepath.Join(runDir, fmt.Sprintf("page-%03d.safe.svg", pageNo)))
pageReportPath := filepath.ToSlash(filepath.Join(runDir, fmt.Sprintf("page-%03d-raster-report.json", pageNo)))
if _, err := runtime.FileIO().Save(inputPath, fileio.SaveOptions{ContentType: "image/svg+xml", ContentLength: int64(len(svg))}, strings.NewReader(svg)); err != nil {
return nil, report, common.WrapSaveErrorTyped(err)
}
invocation := svgRasterizerInvocation{
PythonPath: rasterRuntime.PythonPath,
ScriptPath: scriptPath,
Args: []string{
"--mode", string(opts.RasterizeMode),
"--scale", strconv.Itoa(opts.RasterizeScale),
"--input", inputPath,
"--output", outputPath,
"--asset-dir", runDir,
"--base-dir", baseDir,
"--report", pageReportPath,
},
}
start := time.Now()
if err := svgRasterizeRunScript(contextFromRuntime(runtime), invocation); err != nil {
return nil, report, err
}
renderMS := time.Since(start).Milliseconds()
data, err := cmdutil.ReadInputFile(runtime.FileIO(), outputPath)
if err != nil {
return nil, report, common.WrapInputStatError(err, fmt.Sprintf("raster safe SVG %s", outputPath))
}
safeSVG := string(data)
pageReport := readSVGRasterPageReport(runtime, pageReportPath)
if pageReport.SourcePath == "" {
pageReport.SourcePath = paths[i]
}
pageReport.SafePath = outputPath
if pageReport.Mode == "" {
pageReport.Mode = string(opts.RasterizeMode)
}
if opts.RasterizeMode == svgRasterizeForcePage && pageReport.FallbackReason == "" {
pageReport.FallbackReason = "force-page"
}
pngs := extractSVGImagePlaceholderPaths([]string{safeSVG}, nil)
if len(pngs) == 0 {
pngs = pageReport.PNGs
}
pngs = dedupeStrings(pngs)
pageReport.PNGs = pngs
if len(pageReport.Islands) == 0 {
pageReport.Islands = islandsFromRasterPNGs(pngs, opts.RasterizeScale, renderMS)
}
if err := validateSVGRasterPNGs(runtime, pngs); err != nil {
return nil, report, err
}
for _, pngPath := range pngs {
report.GeneratedAssets = append(report.GeneratedAssets, pngPath)
}
report.RasterImageCount += len(pngs)
report.RasterTotalMS += renderMS
if pageReport.FallbackReason != "" {
report.FullPageFallbackCount++
}
for _, island := range pageReport.Islands {
report.RasterTotalBytes += island.Bytes
}
report.Pages = append(report.Pages, pageReport)
prepared = append(prepared, safeSVG)
}
report.GeneratedAssets = dedupeStrings(report.GeneratedAssets)
if err := writeSVGRasterDeckReport(runtime, report, runDir, opts.ReportPath); err != nil {
return nil, report, err
}
return prepared, report, nil
}
func resolveSVGRasterizerScript(runtime *common.RuntimeContext) (string, error) {
if _, err := runtime.FileIO().Stat(svgRasterizerSourcePath); err == nil {
return svgRasterizerSourcePath, nil
}
if runtime.Factory == nil || runtime.Factory.SkillContent == nil {
return "", output.ErrValidation("svg rasterization requires bundled lark-slides raster scripts; rebuild CLI with scripts embedded")
}
dir, err := os.MkdirTemp("", "lark-cli-svg-rasterizer-*") //nolint:forbidigo // extracting embedded runtime script to process-local temp dir for execution.
if err != nil {
return "", output.ErrValidation("extract SVG rasterizer script: %v", err)
}
for _, skillPath := range svgRasterizerEmbeddedSkillPaths {
data, err := fs.ReadFile(runtime.Factory.SkillContent, skillPath)
if err != nil {
return "", output.ErrValidation("svg rasterization requires bundled lark-slides raster script %s; rebuild CLI with scripts embedded", skillPath)
}
target := filepath.Join(dir, filepath.Base(skillPath))
if err := os.WriteFile(target, data, 0o600); err != nil { //nolint:forbidigo // writes embedded scripts into the temp dir created above.
return "", output.ErrValidation("extract SVG rasterizer script %s: %v", skillPath, err)
}
}
return filepath.Join(dir, "svg_rasterize_effects.py"), nil
}
func resolveSVGRasterRuntime(ctx context.Context) (svgRasterRuntime, error) {
pythonPath, err := exec.LookPath("python3")
if err != nil {
return svgRasterRuntime{}, output.ErrValidation("svg rasterization requires python3 on PATH")
}
cmd := exec.CommandContext(ctx, pythonPath, "-c", "import playwright") //nolint:gosec // fixed interpreter probe, no user-controlled code.
if out, err := cmd.CombinedOutput(); err != nil {
return svgRasterRuntime{}, output.ErrValidation("svg rasterization requires Python package 'playwright' and installed Chromium; run `python3 -m pip install playwright && python3 -m playwright install chromium` (%s)", strings.TrimSpace(string(out)))
}
return svgRasterRuntime{PythonPath: pythonPath}, nil
}
func runSVGRasterizerScript(ctx context.Context, invocation svgRasterizerInvocation) error {
args := append([]string{invocation.ScriptPath}, invocation.Args...)
cmd := exec.CommandContext(ctx, invocation.PythonPath, args...) //nolint:gosec // script path is resolved from source or embedded skill content; args are fixed CLI flags.
out, err := cmd.CombinedOutput()
if err != nil {
msg := strings.TrimSpace(string(out))
if msg == "" {
msg = err.Error()
}
return output.ErrValidation("svg rasterization failed: %s", msg)
}
return nil
}
func ensureSVGRasterOutputDir(runtime *common.RuntimeContext, runDir string) error {
keep := filepath.ToSlash(filepath.Join(runDir, ".keep"))
if _, err := runtime.FileIO().Save(keep, fileio.SaveOptions{ContentType: "text/plain", ContentLength: 0}, strings.NewReader("")); err != nil {
return common.WrapSaveErrorTyped(err)
}
return nil
}
func readSVGRasterPageReport(runtime *common.RuntimeContext, path string) svgPreparePageReport {
data, err := cmdutil.ReadInputFile(runtime.FileIO(), path)
if err != nil || len(bytes.TrimSpace(data)) == 0 {
return svgPreparePageReport{}
}
var page svgPreparePageReport
if json.Unmarshal(data, &page) == nil && (page.SafePath != "" || len(page.PNGs) > 0 || len(page.Islands) > 0) {
return page
}
var deck svgPrepareReport
if json.Unmarshal(data, &deck) == nil && len(deck.Pages) > 0 {
return deck.Pages[0]
}
return svgPreparePageReport{}
}
func islandsFromRasterPNGs(pngs []string, scale int, renderMS int64) []svgPrepareIslandReport {
islands := make([]svgPrepareIslandReport, 0, len(pngs))
for i, pngPath := range pngs {
islands = append(islands, svgPrepareIslandReport{
ID: fmt.Sprintf("page-island-%03d", i+1),
Reason: "script-generated",
OutputPNG: pngPath,
Scale: scale,
RenderMS: renderMS,
})
}
return islands
}
func validateSVGRasterPNGs(runtime *common.RuntimeContext, paths []string) error {
for _, path := range paths {
if err := validateSVGRasterPNGPath(path); err != nil {
return err
}
stat, err := runtime.FileIO().Stat(path)
if err != nil {
return common.WrapInputStatError(err, fmt.Sprintf("raster PNG %s", path))
}
if stat.Size() <= 0 {
return output.ErrValidation("raster PNG %s is empty", path)
}
if stat.Size() > maxSVGRasterPNGBytes {
return output.ErrValidation("raster PNG %s size %s exceeds %s limit", path, common.FormatSize(stat.Size()), common.FormatSize(maxSVGRasterPNGBytes))
}
if err := validateSVGRasterPNGContent(runtime, path); err != nil {
return err
}
}
return nil
}
func validateSVGRasterPNGPath(path string) error {
clean := filepath.ToSlash(filepath.Clean(path))
if strings.HasPrefix(path, "/private/tmp/") {
return nil
}
if filepath.IsAbs(path) {
return output.ErrValidation("raster PNG %s must use a cwd-relative @./ path for upload", path)
}
if !strings.HasPrefix(clean, ".lark-slides/rasterized/") {
return output.ErrValidation("raster PNG %s must be generated under .lark-slides/rasterized", path)
}
if strings.Contains(clean, "../") || clean == ".." {
return output.ErrValidation("raster PNG %s cannot escape the raster output directory", path)
}
return nil
}
func validateSVGRasterPNGContent(runtime *common.RuntimeContext, path string) error {
f, err := runtime.FileIO().Open(path)
if err != nil {
return common.WrapInputStatError(err, fmt.Sprintf("raster PNG %s", path))
}
defer f.Close()
img, err := png.Decode(f)
if err != nil {
if err == io.ErrUnexpectedEOF {
return output.ErrValidation("raster PNG %s is truncated", path)
}
return output.ErrValidation("raster PNG %s is not a valid PNG: %v", path, err)
}
bounds := img.Bounds()
if bounds.Dx() <= 0 || bounds.Dy() <= 0 {
return output.ErrValidation("raster PNG %s has invalid dimensions %dx%d", path, bounds.Dx(), bounds.Dy())
}
allTransparent := true
for y := bounds.Min.Y; y < bounds.Max.Y && allTransparent; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
_, _, _, a := img.At(x, y).RGBA()
if a != 0 {
allTransparent = false
break
}
}
}
if allTransparent {
return output.ErrValidation("raster PNG %s is fully transparent", path)
}
return nil
}
func writeSVGRasterDeckReport(runtime *common.RuntimeContext, report *svgPrepareReport, runDir, requestedPath string) error {
data, err := json.MarshalIndent(report, "", " ")
if err != nil {
return output.ErrValidation("marshal SVG raster report: %v", err)
}
defaultPath := filepath.ToSlash(filepath.Join(runDir, "raster-report.json"))
if _, err := runtime.FileIO().Save(defaultPath, fileio.SaveOptions{ContentType: "application/json", ContentLength: int64(len(data))}, bytes.NewReader(data)); err != nil {
return common.WrapSaveErrorTyped(err)
}
if strings.TrimSpace(requestedPath) == "" || filepath.Clean(requestedPath) == filepath.Clean(defaultPath) {
return nil
}
if _, err := runtime.FileIO().Save(requestedPath, fileio.SaveOptions{ContentType: "application/json", ContentLength: int64(len(data))}, bytes.NewReader(data)); err != nil {
return common.WrapSaveErrorTyped(err)
}
return nil
}
func newSVGRasterRunID() string {
id := strings.ReplaceAll(uuid.NewString(), "-", "")
return time.Now().UTC().Format("20060102-150405") + "-" + id[:8]
}
func contextFromRuntime(runtime *common.RuntimeContext) context.Context {
if runtime == nil || runtime.Ctx() == nil {
return context.Background()
}
return runtime.Ctx()
}

View File

@@ -0,0 +1,275 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"bytes"
"context"
"encoding/json"
"image"
"image/color"
"image/png"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"testing/fstest"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
func TestSlidesCreateSVGFlagsExposeRasterOptions(t *testing.T) {
byName := map[string]common.Flag{}
for _, fl := range SlidesCreateSVG.Flags {
byName[fl.Name] = fl
}
if got := byName["svg-rasterize-effects"]; got.Default != "off" || strings.Join(got.Enum, ",") != "off,auto,strict,force-page" {
t.Fatalf("svg-rasterize-effects flag = %+v", got)
}
if got := byName["svg-rasterize-scale"]; got.Type != "int" || got.Default != "2" {
t.Fatalf("svg-rasterize-scale flag = %+v", got)
}
if _, ok := byName["svg-rasterize-report"]; !ok {
t.Fatal("missing svg-rasterize-report flag")
}
}
func TestPrepareSVGFilesForCreateOffKeepsNativeReadPath(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("page.svg", []byte(testSVGlidePage1), 0o644); err != nil {
t.Fatalf("write page.svg: %v", err)
}
runtime := newSVGRasterTestRuntime(t, nil)
got, report, err := prepareSVGFilesForCreate(runtime, []string{"page.svg"}, svgPrepareOptions{RasterizeMode: svgRasterizeOff})
if err != nil {
t.Fatalf("prepare off: %v", err)
}
if report != nil {
t.Fatalf("report = %+v, want nil in off mode", report)
}
if len(got) != 1 || !strings.Contains(got[0], `slide:contract-version="svglide-authoring-contract/v1"`) {
t.Fatalf("prepared SVG = %#v", got)
}
}
func TestPrepareSVGFilesForCreateForcePageRunsScriptAndGatesSafeSVG(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("page.svg", []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><defs><filter id="glow"/></defs><rect filter="url(#glow)" x="0" y="0" width="100" height="60"/></svg>`), 0o644); err != nil {
t.Fatalf("write page.svg: %v", err)
}
restore := stubSVGRasterizer(t)
defer restore()
runtime := newSVGRasterTestRuntime(t, embeddedSVGRasterizerTestFS())
got, report, err := prepareSVGFilesForCreate(runtime, []string{"page.svg"}, svgPrepareOptions{
RasterizeMode: svgRasterizeForcePage,
RasterizeScale: 2,
})
if err != nil {
t.Fatalf("prepare force-page: %v", err)
}
if len(got) != 1 || strings.Contains(got[0], "<filter") || !strings.Contains(got[0], `href="@./.lark-slides/rasterized/`) {
t.Fatalf("safe SVG did not pass through rasterizer: %s", got[0])
}
if report == nil || report.Mode != "force-page" || len(report.Pages) != 1 || !report.Pages[0].RuntimeGateOK {
t.Fatalf("report = %+v", report)
}
if len(report.GeneratedAssets) != 1 {
t.Fatalf("GeneratedAssets = %v, want one PNG", report.GeneratedAssets)
}
if gotPaths := extractSVGImagePlaceholderPaths(got, nil); len(gotPaths) != 1 || gotPaths[0] != report.GeneratedAssets[0] {
t.Fatalf("placeholder paths = %v, generated = %v", gotPaths, report.GeneratedAssets)
}
}
func TestSlidesCreateSVGForcePageDryRunIncludesRasterReport(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
if err := os.WriteFile("page.svg", []byte(testSVGlidePage1), 0o644); err != nil {
t.Fatalf("write page.svg: %v", err)
}
restore := stubSVGRasterizer(t)
defer restore()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
f.SkillContent = embeddedSVGRasterizerTestFS()
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "page.svg",
"--title", "raster dry",
"--svg-rasterize-effects", "force-page",
"--as", "user",
"--dry-run",
})
if err != nil {
t.Fatalf("dry-run force-page: %v", err)
}
out := stdout.String()
for _, want := range []string{"svg_rasterize_report", ".lark-slides/rasterized/", "uploaded_file_token:"} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q: %s", want, out)
}
}
}
func TestValidateSVGRasterizeFlagsRejectsLowScale(t *testing.T) {
runtime := newSVGRasterTestRuntime(t, nil)
runtime.Cmd.Flags().Set("svg-rasterize-effects", "force-page")
runtime.Cmd.Flags().Set("svg-rasterize-scale", "1")
err := validateSVGRasterizeFlags(runtime)
if err == nil || !strings.Contains(err.Error(), "svg-rasterize-scale") {
t.Fatalf("err = %v, want scale validation", err)
}
}
func TestValidateSafeSVGNoResidualRichEffectsRejectsHardTags(t *testing.T) {
err := validateSafeSVGNoResidualRichEffects(`<svg><defs><filter id="f"/></defs><rect filter="url(#f)"/></svg>`, "safe.svg")
if err == nil || !strings.Contains(err.Error(), "safe SVG") {
t.Fatalf("err = %v, want safe SVG hard-tag rejection", err)
}
}
func TestResolveSVGRasterizerScriptUsesSourceThenEmbedded(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
sourcePath := filepath.Join("skills", "lark-slides", "scripts")
if err := os.MkdirAll(sourcePath, 0o755); err != nil {
t.Fatalf("mkdir source: %v", err)
}
if err := os.WriteFile(filepath.Join(sourcePath, "svg_rasterize_effects.py"), []byte("# source"), 0o644); err != nil {
t.Fatalf("write source: %v", err)
}
runtime := newSVGRasterTestRuntime(t, nil)
got, err := resolveSVGRasterizerScript(runtime)
if err != nil {
t.Fatalf("resolve source: %v", err)
}
if got != svgRasterizerSourcePath {
t.Fatalf("script path = %s, want source path", got)
}
dir = t.TempDir()
withSlidesTestWorkingDir(t, dir)
runtime = newSVGRasterTestRuntime(t, embeddedSVGRasterizerTestFS())
got, err = resolveSVGRasterizerScript(runtime)
if err != nil {
t.Fatalf("resolve embedded: %v", err)
}
data, err := os.ReadFile(got)
if err != nil {
t.Fatalf("read extracted script: %v", err)
}
if string(data) != "# embedded" {
t.Fatalf("extracted script = %q", data)
}
}
func embeddedSVGRasterizerTestFS() fstest.MapFS {
return fstest.MapFS{
"lark-slides/scripts/svg_rasterize_effects.py": &fstest.MapFile{Data: []byte("# embedded")},
"lark-slides/scripts/svg_effect_classifier.py": &fstest.MapFile{Data: []byte("# classifier")},
"lark-slides/scripts/svg_safe_rewrite.py": &fstest.MapFile{Data: []byte("# rewrite")},
"lark-slides/scripts/svg_raster_renderer.py": &fstest.MapFile{Data: []byte("# renderer")},
}
}
func TestValidateSVGRasterAssetConflicts(t *testing.T) {
report := &svgPrepareReport{GeneratedAssets: []string{".lark-slides/rasterized/run/page.png"}}
err := validateSVGRasterAssetConflicts(map[string]string{"@.lark-slides/rasterized/run/page.png": "boxcn_existing"}, report)
if err == nil || !strings.Contains(err.Error(), "--assets conflicts") {
t.Fatalf("err = %v, want conflict", err)
}
}
func newSVGRasterTestRuntime(t *testing.T, skills fs.FS) *common.RuntimeContext {
t.Helper()
f, _, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
f.SkillContent = skills
cmd := &cobra.Command{Use: "test"}
for _, fl := range SlidesCreateSVG.Flags {
switch fl.Type {
case "int":
cmd.Flags().Int(fl.Name, 0, "")
if fl.Default != "" {
cmd.Flags().Set(fl.Name, fl.Default)
}
case "string_array":
cmd.Flags().StringArray(fl.Name, nil, "")
default:
cmd.Flags().String(fl.Name, fl.Default, "")
}
}
return &common.RuntimeContext{
Config: slidesTestConfig(t, ""),
Cmd: cmd,
Factory: f,
}
}
func stubSVGRasterizer(t *testing.T) func() {
t.Helper()
origResolve := svgRasterizeResolveRuntime
origRun := svgRasterizeRunScript
svgRasterizeResolveRuntime = func(context.Context) (svgRasterRuntime, error) {
return svgRasterRuntime{PythonPath: "python3"}, nil
}
svgRasterizeRunScript = func(_ context.Context, invocation svgRasterizerInvocation) error {
args := map[string]string{}
for i := 0; i+1 < len(invocation.Args); i += 2 {
args[invocation.Args[i]] = invocation.Args[i+1]
}
out := args["--output"]
assetDir := args["--asset-dir"]
reportPath := args["--report"]
pngPath := "./" + filepath.ToSlash(filepath.Join(assetDir, "page-001-island-001.png"))
if err := writeTestRasterPNG(pngPath); err != nil {
return err
}
safe := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 960 540"><image slide:role="image" href="@` + pngPath + `" x="0" y="0" width="960" height="540"/></svg>`
if err := os.WriteFile(out, []byte(safe), 0o644); err != nil {
return err
}
report := svgPreparePageReport{
Mode: "force-page",
FallbackReason: "force-page",
PNGs: []string{pngPath},
Islands: []svgPrepareIslandReport{{
ID: "page-001-island-001",
Reason: "force-page",
OutputPNG: pngPath,
Scale: 2,
Bytes: 1,
RenderMS: 1,
}},
}
data, err := json.Marshal(report)
if err != nil {
return err
}
return os.WriteFile(reportPath, data, 0o644)
}
return func() {
svgRasterizeResolveRuntime = origResolve
svgRasterizeRunScript = origRun
}
}
func writeTestRasterPNG(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
img.Set(0, 0, color.RGBA{R: 255, A: 255})
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return err
}
return os.WriteFile(path, buf.Bytes(), 0o644)
}

View File

@@ -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,27 +15,40 @@ metadata:
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|----------|----------|-----------------|
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md``visual-planning.md``asset-planning.md``slides +create` |
| AI 生成 SVG 创建 PPT | 复用 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json` 规划,生成 SVGlide SVG 后调用 `slides +create-svg` | `lark-slides-create-svg.md``svg-protocol.md``style-presets.md``svg-seeds.json``svg-recipes.json``svg-visual-recipes.md``svglide-craft.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` 占位符 |
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) |
| 上传或使用图片 | Preview 阶段优先多用真实图片增强视觉冲击;可先用公开可访问 http(s)/data 图片或本地 `@./path`,来源/授权只 warning 不阻断;正式交付再替换为授权清晰的 file token / 本地资产 | `slides +media-upload`,或 `+create --slides` / `+create-svg``@./path` 占位符 |
| 在 slide 中绘制柱/条/折线等 MVP 支持的数据图表 | XML 路径使用原生 `<chart>`SVG 路径如需原生 chart使用 `slide:role="chart"` spec marker | `xml-schema-quick-ref.md``svg-protocol.md` |
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | XML 路径必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素`slides +create-svg` 明确不支持 whiteboard marker | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)`svg-protocol.md` |
| 使用语义图标 | 先检索 IconPark再写 `<icon iconType="...">` | `iconpark_tool.py search → resolve``iconpark.md` |
| 用户提到模板、主题、版式 | 先检索模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md``validation-checklist.md` |
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),认证、权限和全局参数均以 lark-shared 为准。**
**CRITICAL — 生成任何 XML 之前MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
**CRITICAL — 走 XML 创建/编辑路径时,生成任何 XML 之前MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。走 SVG 创建路径(`slides +create-svg`MUST 改读 [svg-protocol.md](references/svg-protocol.md),不要求读取 XML schema。**
**CRITICAL — 新建演示文稿或大幅改写页面时MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免**
**CRITICAL — 走 `slides +create-svg` 时,输入必须是 SVGlide SVGroot `<svg>` 声明 `xmlns:slide` 且 `slide:role="slide"`;可渲染 SVG 元素必须用 `slide:role="shape"` 或 `slide:role="image"` 表达;`g` / 嵌套 `svg` 可作为容器,但容器内实际渲染元素仍必须各自声明 role。CLI 只读取文件、上传/替换图片占位符、注入 transport metadata 和调用现有 `/slide` 路由,不会把普通 SVG 自动补齐成协议 SVG**
**CRITICAL — `slides +create-svg` 只允许 chart spec marker不允许 whiteboard marker。chart marker 必须是 root `<svg>` 的直系 `<g slide:role="chart" slide:chart-ref="..." x="..." y="..." width="..." height="...">`,且只包含一个 `data-svglide-chart="svglide-chart-inline/v1"` metadatametadata 必须使用 `data-format="svglide-chart-spec-v1"`、`data-encoding="base64url-json"`payload 是 canonical JSON chart spec不是 SXSD `<chart>` XML也不是 chart snapshot/staticData。CLI 校验 marker 外壳、base64url/hash、JSON 基础结构、bar/line chartType、categories/series/values 长度和数值合法性,不会为 chart 调任何额外 API。`slide:role="whiteboard"` 和旧的 `data-svglide-whiteboard` marker 必须改走 XML/whiteboard 路径。**
**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)、[style-presets.md](references/style-presets.md)、[svg-visual-recipes.md](references/svg-visual-recipes.md) 和 [svglide-craft.md](references/svglide-craft.md):复用现有 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json` 作为设计状态,先做 deck-level density plan再选择 `style_preset` / `style_system`,然后为每页从 [svg-seeds.json](references/svg-seeds.json) 选择 `seed_id`,按 seed 填写 `visual_recipe`、`layout_boxes`、`content_budget`、`reserved_bands`、`one_idea`、`svg_primitives` / `visual_focal_point` / `visual_signature` / `xml_like_risk`,给 `foreignObject` 文本留足安全高度。Public runtime recipe 机器真源是 [svg-recipes.json](references/svg-recipes.json)public seed 机器真源是 [svg-seeds.json](references/svg-seeds.json),研究文档里的 dotted recipe 名称不得直接写入 `slide_plan.json`。SVG 私有 recipe 只属于 `slides +create-svg` route共享 `slide_plan.json` 不得写 exact private recipe id只能写抽象 `visual_recipe=route_private` 并由 route-private sidecar 解析XML/SXSD 路径不得读取或调用这些私有 recipe。生成器必须在写 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。若目标 live lane 尚未证明支持 image token必须先用纯 SVG 页和图片页 smoke图片上传成功但 `/slide` 失败时,可生成单独 `online-pure` 发布版,用 SVG-native 几何/渐变替代图片区域,但不得删除 authoring preview 中的真实图片,且交付时必须说明降级和后续 image-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)create-svg route 使用 `--route-manifest references/routes/create-svg/route.manifest.json --report-scope public`live 创建后必须 readback 校验。这些是生成技巧,不替代 [svg-protocol.md](references/svg-protocol.md) 的硬协议约束。**
**CRITICAL — OD 本地化硬门禁:`slides +create-svg` 不得从空白 SVG 自由摆组件。每页必须先从 [svg-seeds.json](references/svg-seeds.json) 选择 seed skeleton并在 `slide_plan.json` 显式继承 `layout_skeleton_id`、`layout_boxes`、`content_budget` / `text_capacity`、`text_budget_by_role`、`reserved_bands.footer`、`footer_safe_zone` 和 `vertical_text_policy`。内容放不下时,只能删减、拆页或换 seed不得放宽 seed budget、删除 required layout role、把 footer band 改成正文区,或用缩小字号/竖排正文/writing-mode 解决溢出。默认禁止竖排正文、`writing-mode`、`text-orientation` 和 90° 旋转长文本;只有 seed 明确允许的短装饰标签可使用,并必须通过本地 preview review。**
**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 -> style_preset/style_system -> deck arc -> page visual_recipe -> layout boxes -> SVG`visual_recipe` 可以反向校验 preset 是否适合当前页。每页必须声明 `visual_signature` 和 `svg_effects`,说明这一页相对普通 XML/PPT 模板的 SVG 视觉优势。**
**CRITICAL — 新建演示文稿或大幅改写页面时MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML 或 SVGlide SVG。先创建对应目录规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
**CRITICAL — 新建演示文稿或大幅改写页面时,生成 XML 前 MUST 读取 [visual-planning.md](references/visual-planning.md),确保 `layout_type`、`visual_focus`、`text_density` 实际改变页面几何、主视觉和文本量。**
**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)。**
**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 和布局风险。**
@@ -80,7 +93,7 @@ lark-cli auth login --domain slides
按需再读:
- 创建:[`lark-slides-create.md`](references/lark-slides-create.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-seeds.json`](references/svg-seeds.json)、[`svg-recipes.json`](references/svg-recipes.json)、[`svg-visual-recipes.md`](references/svg-visual-recipes.md)、[`svglide-craft.md`](references/svglide-craft.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)
@@ -104,7 +117,7 @@ lark-cli auth login --domain slides
- **背景一致性**:先确定全 deck 的背景策略,默认保持同一明暗基调和底色体系;只有分节、转场或强调页才有意改变背景,并必须通过相同主色、纹理、边栏或 motif 让变化看起来属于同一套设计。无论深浅,都要保证正文、图标和线条对比充足。
- **统一 motif**:选择一个可复用视觉母题贯穿全文,例如粗侧边栏、圆形图标底、半出血图片区、编号节点、卡片左上角色块或大号数字。不要每页换一套装饰语言。
每页至少要有一个视觉元素:图片、图标、图表、表格、流程、对比结构、大号数字、示意图或由 shape 组成的抽象视觉。文本框本身不算主视觉。
每页至少要有一个视觉元素:图片、图标、图表、表格、流程、对比结构、大号数字、示意图或由 shape 组成的抽象视觉。文本框本身不算主视觉。展示型、宣传型、产品型和案例型 deck 不能全程纯矢量,必须包含真实图片资产作为封面、半出血主视觉、案例场景、产品截图或材质背景。
可优先考虑这些页面形态:
@@ -128,7 +141,10 @@ lark-cli auth login --domain slides
- 不要所有页面复用同一种标题 + 三 bullets 版式。
- 不要用低对比文字或低对比图标,例如浅灰字压在浅色背景上。
- 不要让装饰线穿过文字,或让页脚、来源、编号挤压主体内容。
- 不要把素材缺失表现为空白图片框;必须按 `fallback_if_missing` 生成 XML-native 视觉
- 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。
- live lane 图片 token 不稳定时,不要把 authoring preview 改成无图版本;应另建 online-pure 发布目录,并在最终回复中说明图片 token 兼容性边界。
- 不要留下模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。
### 创建方式选择
@@ -137,6 +153,7 @@ lark-cli auth login --domain slides
|------|----------|
| 简单 XML1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 |
| 复杂 XML多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多 | **两步创建**:先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide create` 逐页添加 |
| AI 生成 SVGlide SVG希望减少 shell XML 转义、按文件逐页创建) | `slides +create-svg --file page1.svg --file page2.svg --title "<标题>"` |
| 已有 PPT 继续追加或插入页面 | 使用 `xml_presentation.slide create`,必要时配合 `before_slide_id` |
> [!WARNING]
@@ -157,18 +174,20 @@ python3 skills/lark-slides/scripts/template_tool.py extract --template <template
```text
Step 1: 需求澄清 & 读取知识
- 澄清主题、受众、页数、风格;模板需求按“模板与脚本优先流程”处理
- 澄清主题、受众、页数、风格;SVGlide 模糊页数按默认 10 页处理,不因页数缺失单独阻塞;模板需求按“模板与脚本优先流程”处理
- 纯主题输入不能直接跳到页面绘制;先形成结构化资料层,至少记录 `input_profile`、`source_brief`、事实/数字状态和缺来源处理策略
- SVGlide 生成前锁定核心决策:`canvas`、`page_count`、`audience`、`narrative_mode`、`visual_style`、`asset_strategy.mode`、`chart_policy`、`icon_policy`
- 读取 xml-schema-quick-ref.md新建 / 大幅改写时还要读取 planning-layer.md、visual-planning.md、asset-planning.md
Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json
- 生成结构化大纲供用户确认;如使用模板,标明基于哪个模板改写
- 新建 / 大幅改写必须先创建目录并写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
- plan 字段、路径命名、模板边界和 `asset_need` 结构按 planning-layer.md / asset-planning.md 执行
- plan 字段、路径命名、模板边界、`strategy_locks`、`source_pack`、`asset_need` 和 chart/icon policy 结构按 planning-layer.md / asset-planning.md 执行
Step 3: 按 slide_plan.json 生成 XML → 创建
Step 3: 按 slide_plan.json 生成 XML 或 SVGlide SVG → 创建
- 逐页消费 plankey_message 定主结论layout_type 定几何visual_focus 定主视觉text_density 定文本量
- 缺少真实素材时必须用 `fallback_if_missing` 生成 XML-native 兜底视觉;不要留空
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
- XML 路径按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行SVG 路径按 lark-slides-create-svg.md 和 svg-protocol.md 执行,产物是 `.svg` 文件而不是 Slides XML仍复用同一个 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
Step 4: 审查 & 交付
- 创建完成后,必须用 xml_presentations.get 读取全文 XML并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
@@ -264,6 +283,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-slides-create.md) | 创建 PPT可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
| [`+create-svg`](references/lark-slides-create-svg.md) | 从一个或多个 SVGlide SVG 文件创建 PPT`--file` 顺序逐页调用现有 `/slide` 路由 |
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
@@ -279,12 +299,34 @@ lark-cli slides <resource> <method> [flags] # 调用 API
## 核心规则
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
2. **创建流程**:简单短 XML1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide.create` 逐页添加
2. **创建流程**:简单短 XML1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide.create` 逐页添加AI SVG 路径使用 `slides +create-svg`,不要把 SVG 塞进 `--slides`
3. **`<slide>` 直接子元素只有 `<style>``<data>``<note>`**:文本和图形必须放在 `<data>`
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
5. **保存关键 ID**:后续操作需要 `xml_presentation_id``slide_id``revision_id`
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
7. **编辑已有页面优先块级替换**:修改单个 shape/img 用 `+replace-slide``block_replace` / `block_insert`),不要整页重建;只有需要替换整页结构时才用 `slide.delete` + `slide.create`
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides``@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**slides upload API 不支持分片上传)。
8. **Preview 阶段图片要优先丰富,不要纯矢量兜底**XML 路径使用 `<img src="...">`SVG 路径使用 `<image slide:role="image" href="...">`。推荐流程是「从用户 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 不支持分片上传)。
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。
## 权限速查
| 方法 | 所需 scope |
|------|-----------|
| `slides +create` | `slides:presentation:create`, `slides:presentation:write_only`(含 `@` 占位符时还需 `docs:document.media:upload` |
| `slides +create-svg` | `slides:presentation:create`, `slides:presentation:write_only`, `docs:document.media:upload` |
| `slides +media-upload` | `docs:document.media:upload`wiki URL 解析还需 `wiki:node:read` |
| `slides +replace-slide` | `slides:presentation:update`wiki URL 解析还需 `wiki:node:read` |
| `xml_presentations.get` | `slides:presentation:read` |
| `xml_presentation.slide.create` | `slides:presentation:update``slides:presentation:write_only` |
| `xml_presentation.slide.delete` | `slides:presentation:update``slides:presentation:write_only` |
| `xml_presentation.slide.get` | `slides:presentation:read` |
| `xml_presentation.slide.replace` | `slides:presentation:update` |
> **注意**XML 路径如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致以后两者为准SVG 路径以 [svg-protocol.md](references/svg-protocol.md) 为准。
## SVG 排障
`slides +create-svg` 失败时,优先查看错误中是否包含 `svglide_error` 或服务端 `SVGLIDE_ERROR_JSON:` marker。常见修复
- `svg_validation_error`:按 [svg-protocol.md](references/svg-protocol.md) 修正 root `<svg>``xmlns:slide``slide:role` 或不支持元素。
- 图片不显示:确认 `<image>` 使用 canonical `href="file_token"`,不要保留 `xlink:href`;本地图片用 `href="@./image.png"` 让 CLI 上传,或用 `--assets assets.json` 提供 token 映射。
- 有 file token 仍失败:确认 SVG 内存在 transport metadata`<metadata data-svglide-assets="true"><img src="同一个 file_token" /></metadata>``+create-svg` 会自动注入,手写 SVG 时不要删除。

View File

@@ -4,15 +4,53 @@
本文件只定义轻量资产规划。不要把它理解成素材采集流程。
## SVGlide Asset Modes
`slides +create-svg` 的资产模式只写在现有 `asset_strategy.mode` 和 page-level
`visual_plan.asset_contract.mode` 里。不要新增平行的顶层 `asset_mode`
`image_policy`
允许的 mode
- `authoring_preview_rich`:默认 authoring 模式。只要有助于页面表达,就使用丰富 preview 资产;同时记录来源元数据,并把未知授权标为 preview-only。
- `online_pure_fallback`live lane 尚未证明 image token 可用时的发布 fallback。保留丰富 authoring preview另行准备 pure-SVG 发布输出。
- `production_asset_strict`正式交付模式。每张图片、logo 和资产都必须有可审计来源、授权、本地路径或 file token以及使用页。
迁移期兼容映射:
- `preview` 映射到 `authoring_preview_rich`
- `production` 映射到 `production_asset_strict`
P0 preflight 可以 warning 并映射旧值。等示例和文档迁完后,旧值应升级为 error。
## Core Rules
- `asset_need` is metadata only. It can guide page design, but it must not require web search, local download, media upload, or external tools.
- SVG decks must keep deck-level image and icon strategy consistent. Use existing `asset_strategy.mode` for image lane; do not add a parallel `image_policy`.
- `asset_strategy.icon_policy` or top-level `icon_policy` should define one semantic icon family, line/fill style, and mapping rule. Icons should label concepts, roles, steps, or status; they are not filler decoration.
- Every planned asset must include a fallback visual plan so the slide can be generated with XML shapes, text, arrows, tables, simple charts, whiteboard diagrams, or placeholder regions.
- Asset needs must serve the page's `key_message` and `visual_focus`. Do not add decorative assets that do not clarify the page.
- Prefer a few high-value asset plans over one asset on every page. For a 6-page technical or business deck, plan assets on at least 3 pages when the content allows.
- If a real local asset already exists or the user provides one, it can be used through the normal media-upload workflow. Still keep `fallback_if_missing` in the plan.
- Do not leave blank image boxes in final XML. If the asset is missing, render the fallback visual.
Recommended SVGlide policy shape:
```json
{
"asset_strategy": {
"mode": "authoring_preview_rich",
"image_strategy": "svg",
"fallback": "prefer SVGlide-safe vector primitives when source or license is unavailable"
},
"icon_policy": {
"style": "single_consistent_family",
"semantic_mapping_required": true,
"consistency_rule": "one deck uses one icon stroke/fill language"
}
}
```
## JSON Shape
Use an object for one planned asset, or an array when a page genuinely needs multiple assets. Keep each item compact.

View File

@@ -0,0 +1,967 @@
# slides +create-svg
从一个或多个 SVGlide SVG 文件创建飞书幻灯片:
```bash
lark-cli slides +create-svg \
--as user \
--title "Demo" \
--file page1.svg \
--file page2.svg
```
## 适用场景
- AI 已经能生成符合 [svg-protocol.md](svg-protocol.md) 的 SVGlide SVG。
- 希望按文件逐页创建,避免把大段 XML/SVG 塞进 shell 参数。
- 需要 SVG 内本地图片占位符自动上传并替换为 file token。
- 需要把原生 chart 的 canonical JSON spec 作为 root chart spec marker 透传给服务端。
不适用:
- 你只有普通 SVG且没有 `slide:role` 协议标记。
- 复杂普通 SVG 不能直接提交;需要把实际可渲染元素标成 SVGlide role。`g` / 嵌套 `svg` 容器可以保留,但不能代替子元素 role。
- 你想通过 SVG 路径提交 whiteboard marker`slide:role="whiteboard"` 和旧 `data-svglide-whiteboard` marker 会被 CLI 拒绝。
- 你需要插入到指定页前MVP 只创建新 presentation 并按顺序追加页面。
## Flags
| Flag | 说明 |
|------|------|
| `--title` | presentation 标题,省略时为 `Untitled` |
| `--file` | SVG 文件路径;可重复,页面顺序就是 flag 顺序 |
| `--assets` | 可选 `assets.json`,把 SVG `@path` 映射到已上传 file token |
| `--dry-run` | 展示创建空白 presentation + N 次 `/slide` 调用,不真实创建 |
## 请求链路
CLI 先创建空白 presentation
```http
POST /open-apis/slides_ai/v1/xml_presentations
```
随后对每个 SVG 文件调用现有 slide create 路由:
```http
POST /open-apis/slides_ai/v1/xml_presentations/{xml_presentation_id}/slide?revision_id=-1
```
body
```json
{
"slide": {
"content": "<svg ...>...</svg>"
}
}
```
不会新增 `/svg_slide` 路由,也不会把 `file_meta_map` 当成 CLI 到服务端的契约。
chart spec marker 也不新增 API。CLI 不会上传 chart 资源,也不会调用任何 chart 创建接口;它只把通过 marker 外壳、hash 和 JSON spec 基础校验的 marker 留在同一个 `slide.content` SVG 中。
## 图片处理
SVG 内本地图片写成:
```xml
<image slide:role="image" href="@./hero.png" x="0" y="0" width="320" height="180" />
```
`<image>` 可以位于 `g` / 嵌套 `svg` 容器中CLI 会全局扫描 `<image href="@...">``<image xlink:href="@...">` 并替换为 canonical `href="file_token"`
CLI 会:
1. 上传本地图片到新 presentation。
2.`href="@./hero.png"``xlink:href="@./hero.png"` 替换为 canonical `href="file_token"`
3. 注入 transport metadata`<metadata data-svglide-assets="true"><img src="file_token" /></metadata>`
注意:图片上传成功只证明 Drive media upload 成功,不证明目标 live lane 已能解析
SVGlide image token。若某个 lane 中纯 SVG 页可创建、但带 `@./assets/...`
的页面在上传后进入 `/slide` 阶段报 `nodeServer internal error`,应按
image-token 兼容性问题处理,而不是继续修改图片文件本身。`ppe_pure_svg`
的内部路由和已知边界记录在
[routes/create-svg/private/ppe-pure-svg-live.md](routes/create-svg/private/ppe-pure-svg-live.md)
这份知识只属于 `slides +create-svg` routeXML/SXSD 路径不得调用。
预上传资源可用 `--assets`
```json
{
"@./hero.png": "boxcn..."
}
```
## Chart Spec Marker
`slides +create-svg` 支持一种最小 chart marker用于透传 canonical JSON chart spec。payload 不是 SXSD `<chart>` XML也不是 chart snapshot/staticData服务端会在 SVGlide parser 内部把 spec 转成 chart 创建所需数据:
```xml
<svg 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">
<g slide:role="chart"
slide:chart-ref="chart-sales-001"
x="80" y="96" width="420" height="260">
<metadata
data-svglide-chart="svglide-chart-inline/v1"
data-format="svglide-chart-spec-v1"
data-encoding="base64url-json"
data-payload-hash="sha256:<64 hex>"
>BASE64URL_PAYLOAD</metadata>
</g>
</svg>
```
Decoded canonical JSON shape:
```json
{"version":"svglide-chart-spec/v1","chartType":"bar","data":{"categories":["Q1","Q2"],"series":[{"name":"Revenue","values":[12.5,18]}]}}
```
CLI 校验范围只包括:
- marker 必须是 root `<svg>` 直系 `<g slide:role="chart">`
- `slide:chart-ref``x/y/width/height` bbox 必填bbox 只接受数字或 `px`
- marker 内必须且只能有一个 `<metadata>`
- metadata 必须使用 `data-svglide-chart="svglide-chart-inline/v1"``data-format="svglide-chart-spec-v1"``data-encoding="base64url-json"`
- payload 必须是无 padding base64url`data-payload-hash` 必须匹配 decoded canonical JSON bytes 的 sha256不要对 base64 文本计算 hash。
- decoded payload 必须是 JSON object且包含 `version="svglide-chart-spec/v1"``chartType``data.categories``data.series[].name``data.series[].values`
- MVP 只支持 `chartType="bar"` / `"line"``categories` 和每个 `values` 数组长度必须一致;`values` 只能是有限 JSON number。
`sxsd-chart-v1` / `base64url` 的 SXSD `<chart>` XML payload 不属于 SVGlide chart marker 协议面,会被 CLI 拒绝。`slide:role="whiteboard"` 和旧的 `data-svglide-whiteboard` marker 明确不属于 `+create-svg` 协议面。
## 生成质量规则
这些规则用于生成阶段主动规避服务端降级、近似和泛化错误。几何数值、path 命令、role/必填属性、图片 href 等基础约束已由 CLI 强校验;版式、美观和文本溢出仍需要生成器或人工复核。
### Open Design 方法在 SVGlide 中的边界
SVGlide 借鉴 Open Design 的“受约束创作系统”,不是照搬 HTML deck runtime。
| Open Design 机制 | SVGlide 对应做法 |
|-|-|
| deck mode / skill routing | 只有 `slides +create-svg` route 加载 SVG 私有规则XML/SXSD 路径不得读取 SVG private recipe。 |
| template seed / layout catalog | 使用 `svg-seeds.json` 中的 `seed_id + layout_skeleton + visual_recipe + layout_boxes + content_budget + text_budget_by_role + footer_safe_zone + vertical_text_policy` 作为 SVG seed不复制 HTML section。 |
| design system / tokens | 使用 `style-presets.json``style_system`,翻译为显式 SVG `fill` / `stroke` / 字号 / 文本承载面。 |
| craft | 读取 `svglide-craft.md`约束排版、SVG advantage、反 AI 味和资产 lane。 |
| artifact lint / publication guard | 使用 `svg_preflight.py`、preview review、dry-run、live readback 和截图/native oracle。 |
不要照搬 HTML deck 的 `1920x1080` stage、`runtime.js`、keyboard navigation、localStorage、CSS animation、Chart.js 或 canvas FX。SVGlide 只复制 Open Design 的生成控制体系:先选 seed/layout再替换内容保留结构和预算不从空白画布开始。SVGlide 的硬边界仍是 `960x540` 协议化 SVG并由 Slides server 转成真实线上文档快照。
### Seed Skeleton Contract
seed skeleton 不是灵感参考,而是初始版式骨架。生成器必须保留 seed 的 `required_layout_box_roles`、关键 `layout_boxes``reserved_bands.footer``footer_safe_zone` 和文本容量上限;允许在 seed 容忍范围内微调坐标,但不得删除角色、把 footer 改成正文区,或用 freestyle 坐标绕过 seed。
每页必须先选 `seed_id`,再填内容。内容放不下时,处理顺序是删减、拆页、换 seed不得放宽 seed 的 `content_budget``text_budget_by_role``footer_safe_zone``vertical_text_policy`。新增页面结构时,先新增/更新 `svg-seeds.json`,再生成 SVG。
### 与现有规划层对齐
SVG 创建不使用单独的规划目录。新建或大幅改写 SVG deck 时,仍然复用 [planning-layer.md](planning-layer.md) 规定的 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,不要另建 `.lark-slides/svg-plan` 或只保留散落的 `.svg` 文件。
当需要把 source、strategy、generate、prepare、preview、preflight、preview_lint、chart_verify、quality_gate、dry-run、PPE proof、live create 和 readback 串成可恢复流水线时,项目级 `project_manifest.json``state.json``prepared/``receipts/` 规则见 [svglide-project-pipeline.md](svglide-project-pipeline.md)。该文档只管本地执行状态,不替代 `slide_plan.json`、SVG 协议或 route-private 规则。
在通用 plan 字段基础上SVG deck 还应补充这些 SVG 专属字段:
```json
{
"output_mode": "svglide-svg",
"canvas": {"width": 960, "height": 540, "viewBox": "0 0 960 540"},
"safe_area": {"x": 48, "y": 40, "width": 864, "height": 460},
"input_profile": {"input_type": "topic", "source_status": "user_prompt_only"},
"source_pack": {
"schema_version": "svglide-source-pack/v1",
"source_status": "user_prompt_only",
"numeric_claim_policy": "cite_or_remove",
"items": [{"id": "brief", "type": "user_prompt", "status": "available", "source_ref": "source/brief.md"}]
},
"narrative_mode": "briefing",
"visual_style": "data_journalism",
"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"
},
"strategy_locks": [
{"id": "canvas", "decision": {"width": 960, "height": 540}, "evidence_ref": "plan.canvas"},
{"id": "page_count", "decision": 10, "evidence_ref": "plan.page_count"},
{"id": "audience", "decision": "inferred_from_brief", "evidence_ref": "plan.audience"},
{"id": "narrative_mode", "decision": "briefing", "evidence_ref": "plan.narrative_mode"},
{"id": "visual_style", "decision": "data_journalism", "evidence_ref": "plan.visual_style"},
{"id": "style_preset", "decision": "raw_grid", "evidence_ref": "plan.style_preset"},
{"id": "asset_strategy", "decision": "svg", "evidence_ref": "plan.asset_strategy.mode"},
{"id": "chart_policy", "decision": "data_relationship_first", "evidence_ref": "plan.chart_policy"}
],
"chart_policy": {
"selection_rule": "data_relationship_first",
"requires_data_coordinate_check": true,
"receipt": "receipts/chart-verify.json"
},
"icon_policy": {
"style": "single_consistent_family",
"semantic_mapping_required": true
},
"svg_constraints": {
"text_element": "foreignObject slide:role=shape slide:shape-type=text",
"path_commands": "M/L/H/V/C/Q/Z only",
"image_href": "@./path or file token only",
"css": "explicit font-size/font-weight/color/line-height/text-align; no font shorthand"
},
"svg_files": [
{"page": 1, "path": ".lark-slides/plan/<deck-id>/pages/page-001.svg"}
],
"preflight": {
"command": "python3 skills/lark-slides/scripts/svg_preflight.py --route-manifest skills/lark-slides/references/routes/create-svg/route.manifest.json --report-scope public --plan .lark-slides/plan/<deck-id>/slide_plan.json --input .lark-slides/plan/<deck-id>/pages/page-001.svg",
"status": "pending"
},
"readback_verification": {
"status": "pending",
"checks": ["page_count", "blank_page", "canvas_bounds", "text_overlap", "asset_tokens", "closing_slide"]
}
}
```
每页还必须有 `visual_design_contract`。它不是新的一套平行 plan而是把现有
`seed_id``visual_recipe``renderer_id``design_pattern_selection` 和页面
视觉目标锁成可验收字段:
```json
{
"visual_design_contract": {
"schema_version": "svglide-visual-design-contract/v1",
"page_kind": "chart_takeaway",
"visual_thesis": "这一页视觉上要让用户记住的结论",
"composition_archetype": "data_stage",
"pattern_bundle": ["chart.bar_chart"],
"density": "dense",
"primary_motif": "takeaway_chart",
"required_visual_evidence": ["chart_geometry", "insight_strip", "full_page_archetype"]
}
}
```
`required_visual_evidence` 必须由 renderer 写入
`receipts/emitted_components.json` 的 page-level component `effects`
`primitives``renderer_id` 或 component id。`quality_gate` 会核对这些证据;
只在 plan 里声明但 SVG/component report 没有落地时不得进入 dry-run/live。
Renderer 的可执行覆盖面由 [svglide-renderer-registry.json](svglide-renderer-registry.json) 声明。只有 `status=active` 且能映射到现有 `svg-seeds.json``svg-recipes.json` 的 renderer 才能被自动选择candidate renderer 只能用于实验或手工样张。
模板也复用现有 `template_tool.py search -> summarize -> extract` 路由。模板摘要只用于选择主题、页面流、视觉节奏和布局骨架;生成 SVG 时要把模板结构翻译成 SVG layout boxes / visual recipes不要照搬模板 XML也不要读取完整模板 XML。
SVG deck 的 `slides[]` 还必须包含这些可校验字段,避免生成结果虽然能创建但内容千篇一律、信息量不足或在资料缺失时编造事实:
```json
{
"page": 3,
"renderer_id": "dashboard_scorecard",
"page_rhythm": "dense",
"page_type": "kpi_overview",
"chart_type": "kpi_cards",
"main_visual_anchor": "2x2 KPI scorecard with hero numbers and micro bars",
"annotation_zone": {"role": "right_observation", "x": 690, "y": 126, "width": 206, "height": 246},
"seed_id": "dashboard_kpi_grid",
"layout_skeleton_id": "dashboard_kpi_grid_skeleton",
"layout_family": "dashboard",
"visual_recipe": "fake_ui_dashboard",
"layout_boxes": [
{"id": "title", "role": "title", "x": 48, "y": 34, "width": 864, "height": 48},
{"id": "primary-kpi", "role": "metric", "x": 64, "y": 106, "width": 260, "height": 128},
{"id": "secondary-grid", "role": "grid", "x": 348, "y": 106, "width": 548, "height": 128},
{"id": "chart-row", "role": "chart", "x": 64, "y": 258, "width": 832, "height": 150},
{"id": "body", "role": "body", "x": 64, "y": 426, "width": 832, "height": 56},
{"id": "footer", "role": "footer", "x": 64, "y": 500, "width": 832, "height": 24}
],
"content_budget": {"max_visible_chars": 360, "title": 34, "body": 230, "footer": 36, "max_text_boxes": 14},
"text_capacity": {"max_visible_chars": 360, "title": 34, "body": 230, "footer": 36, "max_text_boxes": 14},
"text_budget_by_role": {
"title": {"max_chars": 34, "max_lines": 2, "max_boxes": 1, "min_font_px": 22},
"body": {"max_chars": 230, "max_lines": 5, "max_boxes": 1, "min_font_px": 12},
"metric": {"max_chars": 96, "max_lines": 3, "max_boxes": 4, "min_font_px": 12},
"footer": {"max_chars": 36, "max_lines": 1, "max_boxes": 1, "min_font_px": 9}
},
"reserved_bands": {"footer": {"x": 48, "y": 496, "width": 864, "height": 32}},
"footer_safe_zone": {"x": 48, "y": 496, "width": 864, "height": 32, "allowed_roles": ["footer"], "min_gap_above_px": 8},
"vertical_text_policy": {"mode": "deny", "allowed_roles": [], "max_chars": 0, "max_lines": 0},
"one_idea": "operating status is readable through dashboard structure",
"key_message": "dashboard structure turns scattered metrics into one operating view",
"visual_intent": "use a product-console dashboard surface to make metrics feel operational",
"visual_focal_point": "central metric card and trend line",
"visual_signature": "fake product console frame + micro chart geometry + status chips",
"reference_asset": {"source": "svglide_design_pattern", "asset_id": "chart.kpi_cards", "usage": "page-type geometry only; do not copy raw SVG paths"},
"svg_effects": ["chart_geometry", "connector_flow", "typography"],
"required_primitives": ["dashboard", "micro_chart"],
"svg_primitives": ["dashboard", "micro_chart", "typography", "geometric_shape"],
"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: authoring_preview_rich|online_pure_fallback|production_asset_strict, 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",
"source_refs": ["brief"],
"asset_selection_reason": "chart.kpi_cards is selected because it matches page_type, density, and geometry needs",
"rejected_asset_alternatives": [
{"asset_id": "chart.timeline", "reason": "rejected because this page is metric-led rather than phase-led"}
],
"chart_decision": {
"chart_type": "bar_chart",
"reason": "bar chart fits category comparison and supports one takeaway",
"data_ref": "brief",
"anchor_role": "chart",
"bbox_tolerance_px": 12
},
"chart_verification": {
"status": "required",
"receipt": "receipts/chart-verify.json",
"checks": ["plot_area", "mark_count", "label_alignment", "scale_mapping"]
},
"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"
]
}
```
### SVGlide Design Pattern Lessons
SVGlide design pattern 的可借鉴点是生成流程和页型合同,不是 PPTX/DrawingML 导出链路,也不是复制现成 SVG。SVGlide 生成时必须把经验收敛成 protocol-safe 字段:
- `page_rhythm`: `anchor` / `breathing` / `dense`。8 页以上 deck 不能全是 dense 或全是同一张卡片结构;封面、目录、章节、收尾应形成节奏。
- `page_type`: 页面叙事类型,例如 `cover``editor_note``contents``chart_takeaway``chapter``closing`
- `chart_type`: 当页面主视觉是图表时必须声明,例如 `kpi_cards``bar_chart``dumbbell_chart``bubble_chart``donut_chart``horizontal_bar_chart``comparison_table``sankey_chart``pareto_chart``hub_spoke``dual_axis_line_chart``quadrant_text_bullets`
- `main_visual_anchor`: 一句话说明这页的主视觉锚点。不能写成“高级图表”或“信息可视化”;必须能被截图肉眼确认。
- `annotation_zone`: 右侧观察、底部 source、图例或 callout 的固定区域。密集页靠图表承载信息,文字只做解释。
- `reference_asset`: 记录借鉴的 SVGlide design pattern layout/chart/image/style 资产,只表示结构和选择理由,不表示复制 raw SVG。
经验规则:
- 先选页型和图表,再写 SVG不要从空白画布临场拼元素。
- 图表页必须有真实 chart geometry。声明了 `sankey_chart` 就要有 source/node/sink/flow声明了 `bar_chart` 就要有 axis/bar/value/callout。
- 密集页的复杂度来自图表、表格、flow、hub、quadrant 等结构,不来自增加装饰线或文字盒。
- 封面和章节页可以使用图片/纹理作为 atmosphere但图片必须预留负空间、无可见文字并由 `asset_contract` 记录来源和替代策略。
- 红色/高饱和强调色只打关键数字、风险信号或章节标记;不要让所有图形同等抢眼。
经验归属:
- 本文件只保留 create-svg 的入口流程、plan 字段边界和 CLI 发布约束。
- 页型、图表几何、视觉节奏和 SVGlide design pattern 借鉴规则归 [svglide-craft.md](svglide-craft.md)。
- 项目目录、stage、timing、prepare resume、asset usage receipt 和 live guard 归 [svglide-project-pipeline.md](svglide-project-pipeline.md)。
- 自动门禁和 archetype drift 归 [validation-checklist.md](validation-checklist.md)。
- 截图/人工审美判断和 label/backing 遮挡经验归 [svg-aesthetic-review.md](svg-aesthetic-review.md)。
### Runtime Recipe Registry
Public `visual_recipe` 的机器真源是 [svg-recipes.json](svg-recipes.json)`svg_preflight.py` 会直接读取它。[svg-visual-recipes.md](svg-visual-recipes.md) 是人类可读的选择指南,不是第二份 runtime catalog。
研究文档可以使用内部 taxonomy 讨论设计,但写入 `slide_plan.json` 前必须映射到 `svg-recipes.json` 中的 public underscore id例如 `infographic_scorecard``geometric_composition``path_flow``fake_ui_dashboard`。公开文档、公开 fixtures 和 public preflight report 不应暴露 route-private 或 research dotted id。
### Runtime Seed Registry
SVGlide page seed 的机器真源是 [svg-seeds.json](svg-seeds.json)`svg_preflight.py` 会直接读取它。seed 是 Open Design 经验的本地化控制层,不是可复制的 HTML 模板。
每个 seed 至少包含:
- `page_use`: 适用页型。
- `visual_recipe` / `layout_family`: seed 对应的结构类型。
- `layout_boxes`: 可复用的 title/body/visual/chart/footer 等盒子坐标。
- `content_budget` / `default_text_capacity`: 全页、title、body、footer 和 text box 数量预算。
- `reserved_bands`: footer/source/legal/page mark 等保留区域。
- `required_primitives`: seed 需要 SVG source 实现的 primitives。
- `quality_rules`: seed 的本地审查规则。
生成器必须先选 `seed_id`,再替换内容。允许微调 layout boxes但必须保留 seed 的关键角色和 reserved band如果需要完全不同的信息结构换 seed不要在原 seed 上硬塞内容。
### 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
-> style_preset + style_system
-> deck arc
-> page seed_id from svg-seeds.json
-> page visual_recipe
-> layout boxes
-> SVG source
-> svg_preflight.py --route-manifest ... --report-scope public --plan
```
`visual_recipe` 可以反向校验 `style_preset`:如果页面结构需要强数据密度、路径流动或真实图片叙事,而 preset 的留白、色彩或 text surface 无法承载,应调整 preset 或重写该页视觉计划。
`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 runtime recipe summary
SVG 不是普通矢量图文件的传输外壳。每页都必须选择一个 `visual_recipe`,并在 `svg_primitives` 中声明真实会绘制的 SVGlide-safe primitives。`renderer_id` 负责几何布局命名;`visual_recipe` 负责说明这页为什么值得走 SVG。
本节只是 `svg-recipes.json` 的人工摘要,帮助快速选型;不要手工维护为第二份 runtime catalog。实际生成和校验以 [svg-recipes.json](svg-recipes.json) 为准,生成前再读 [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 或根级 `<text>` |
| `gradient_depth` | 能力升级、概念页、氛围页 | `gradient`, `geometric_shape` | 渐变只作为层次,不能替代信息结构;关键文字必须有深色承载底 |
| `mask_clip_showcase` | 成果展示、产品/品牌视觉页 | `typography`, `image_overlay` | 不直接依赖 `mask` / `clipPath`;用大字描边、半透明 shape 遮罩、裁切安全区模拟 |
| `technical_texture` | 技术架构封面、工程系统页 | `texture`, `path` | 不用 `<pattern>`;网格、点阵、扫描线用重复 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 式卡片。
### Create-SVG Route-Private Recipes
SVG 私有 recipe 只属于 `slides +create-svg` route用于鼓励更强的 SVG-native 艺术处理。它们不是 XML/SXSD 的共享知识,也不能写进普通共享 plan。
- Public/shared `slide_plan.json` 只能使用 public recipe id或在 create-svg route 下使用抽象值 `visual_recipe: "route_private"`
- Exact private recipe id 只允许出现在 route-private manifest、route-private selection sidecar、private fixture 或 internal report。
- 当使用 `route_private` 时,必须传入 create-svg route manifest如果需要解析到具体 private recipe还必须传入 route-private selection sidecar。没有 manifest/sidecar 时 preflight fail-closed不猜测、不回退。
- Public preflight report 使用 `--report-scope public`,不得输出 exact private recipe id、manifest path、selection path 或 private enum list。
- XML 创建、普通 `+create`、共享 planning docs 和公开 fixtures 不得读取 route-private manifest也不得调用 SVG 私有 recipe。
### 生成阶段 Fail-Fast Gate
`slide_plan.json` 不是说明文档,而是生成阶段的硬契约。生成器必须先通过 plan gate再渲染 SVG本地 `svg_preflight.py --plan` 失败时禁止调用 live API。
每页 SVG plan 必填:
| Field | 作用 | 失败后处理 |
|---|---|---|
| `renderer_id` | 标识具体渲染器/几何结构 | 换真实 renderer不用 `two_column_1` 这类假命名 |
| `seed_id` | 绑定 `svg-seeds.json` 中的 page seed | 先选 seed再写内容不要从空白页直接开始 |
| `layout_skeleton_id` | 绑定 seed 的机器化版式骨架 | 不匹配或缺失时从 seed 复制;大改结构必须换 seed |
| `layout_family` | 做 deck 级版式多样性检查 | 相邻页重复时换阅读方向、主视觉位置或信息结构 |
| `visual_recipe` | 说明这页为什么值得走 SVG | 从 `svg-recipes.json` 选择,不能自造枚举 |
| `visual_design_contract` | 锁定视觉 thesis、composition archetype、motif 和必须落地的 evidence | 补齐合同,或改 renderer/component report 证明 evidence |
| `layout_boxes` | seed 派生的标题、正文、视觉、chart、footer 坐标 | 缺角色或 bbox 非正数时先修布局 |
| `content_budget` / `text_capacity` | seed 派生的文本容量预算 | 超量时删内容、拆页或换 seed |
| `text_budget_by_role` | seed 派生的 role 级文本预算 | 局部超量时删内容、拆页或换 seed不用缩字/竖排硬塞 |
| `one_idea` / `key_message` | 单页只承载一个核心观点 | 先收敛消息,再替换 seed 内容 |
| `reserved_bands` | footer/source/legal/page mark 等保留区域 | body/callout 不得侵入 footer band |
| `footer_safe_zone` | footer band 的 allowed roles 和上方净距 | 只有 footer/source/legal/page mark 可以进入;正文、图表标签和图例必须离开 |
| `vertical_text_policy` | 竖排/旋转文本策略 | 默认 deny只有 seed 明确允许的短装饰标签可使用 |
| `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 生成、回读和修复经验,生成器必须先满足这些规则,再追求视觉复杂度。
- 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[]` 必须记录 `seed_id``layout_skeleton_id``layout_family``layout_boxes``content_budget``text_capacity``text_budget_by_role``reserved_bands.footer``footer_safe_zone``vertical_text_policy``one_idea``key_message``visual_recipe``visual_intent``visual_focal_point``visual_design_contract``required_primitives``svg_primitives``xml_like_risk``content_density_contract``risk_flags``source_policy``asset_contract` 应尽量记录MVP 阶段缺失只 warning。没有 SVG-native recipe 的页面不应走 `slides +create-svg`,应改用普通 Slides XML 或重新选择 SVG recipe。
- MUST: `visual_recipe` 必须来自 catalog`svg_primitives` 必须覆盖该 recipe 的 required primitives。`renderer_id` 不能替代 `visual_recipe`
- MUST: `seed_id` 必须来自 `svg-seeds.json`seed 的 `visual_recipe` / `layout_family` 必须和 plan 一致。`route_private` 只隐藏具体 private recipe不跳过 seed/layout/budget 控制。
- MUST: seed skeleton 是版式合同,不是灵感参考。生成器必须继承 seed 的 `required_layout_box_roles``layout_skeleton_id``layout_boxes``text_budget_by_role``footer_safe_zone``vertical_text_policy`;内容放不下时删减、拆页或换 seed不得放宽 seed budget 或自由重画骨架。
- MUST: 任何 footer/source/note/legal/page mark 文本必须落在 `reserved_bands.footer`正文、callout、chart labels、图例、标签和解释文字不得压进 footer band并应与 `footer_safe_zone` 顶部保持至少 `min_gap_above_px` 净距。背景和装饰可穿过 footer band但不得承载关键文字。
- MUST: 默认禁止 `writing-mode``text-orientation`、竖排正文和 90° 旋转长文本;不要用竖排解决窄列或溢出。只有 seed 的 `vertical_text_policy` 明确允许时,才可用短标题、章节号或装饰性标签,并且必须通过 preview review。
- MUST: 标签、chip、badge、装饰块不得覆盖标题、正文、竖排说明或图表标签如果需要强调用独立 layout box 或扩大文本承载面,不要把标记压在可读文本上。
- MUST: 不要把可见内容写进 `display:none``visibility:hidden`、近零 opacity、`overflow:hidden``clip-path``mask` 裁切的 text box`svg_preflight.py` 会报 `hidden_visible_text``clipped_visible_text`
- 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。
### 生成器实现约束与 Preflight
生成器必须先把高概率错误拦在本地,再调用 `lark-cli`。不要依赖 live 创建后的人工修补来发现基础问题。
实现约束:
- 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但应只承担背景/承载作用,不承载关键文本。
- MUST: 承载可见文字的卡片、callout、badge、panel 和 insight box 必须有 `text_surface_contract`,不能默认裸白底黑字。至少使用一种 style-preset 派生处理tinted fill、accent rail、visible stroke、glass overlay、number/icon marker、深色背板或与背景共用的承载色。
- MUST: `titleBox` 是不可侵入区域。callout、badge、panel、connector、装饰线和图片标签与标题框底部至少保持 24px 视觉间距;如果标题是两行或大号字,优先扩大间距或移动卡片,而不是压缩标题。
- MUST: connector line/path 只连接到卡片、节点或图表边缘,不能穿过 title、中心文字、callout 文案或图例。若线条只是背景纹理,降低 opacity 并在 plan 中标为 decorative不要让它承担 connector_flow。
- SHOULD: 对高风险页面使用更保守的留白:标题与图表标签至少相隔 24px曲线端点标签不要压在标题/图例区域,卡片内文字与边框至少留 10-14px。
- SHOULD: 把每页的 `safe``titleBox``visualBox``textBox``calloutBox``connectorPath` 等布局盒保存为可检查数据,便于自动计算越界、重叠、标题压力和 connector 穿字。
推荐生成顺序:
```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。
- 在 project runner quality lane 中,`preview_lint` 是 hard gate缺少
`preview.html` 或出现高置信 objective lint error 时,必须在 `dry_run`
前失败。手工排障路径仍可在无 preview 时继续 preflight、dry-run 和 readback
但不得进入 guarded live creation也不得标记为 production/golden 交付。
- 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 --route-manifest skills/lark-slides/references/routes/create-svg/route.manifest.json --report-scope public --plan .lark-slides/plan/<deck-id>/slide_plan.json --input page-*.svg` 通过;如果脚本不可用,再退回 `xmllint --noout page-*.svg` 加人工检查。
- 生成脚本和 preflight 不得并行读写同一个 output 目录;必须等 SVG 文件全部写完后再跑 preflight避免读到中间态导致误判。
- root 是 `width="960" height="540" viewBox="0 0 960 540"`
- root / leaf `slide:role` 完整,所有 leaf 有几何必填属性。
- 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 primitivepreflight 必须失败。
- 禁止零尺寸元素;文本框、图片、卡片和圆/椭圆必须有正向宽高,不能生成 `height="0"` 的隐藏说明。
- `<image opacity="...">` 或图片 style 里写 `opacity:` 在 MVP 阶段只 warning当前转换链路不会稳定保留到 readback `<img>`。需要淡化图片时,优先把透明度预合成进 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。
- 对尚未证明支持 image token 的 live lane先用一页纯 SVG 和一页含 `@./assets/...` 的图片 SVG 分别 smoke如果图片页上传成功但 `/slide` 失败,可以为线上发布生成单独的 `online-pure` SVG 目录,用 SVG-native 几何和渐变替代图片区域,但必须保留原图片预览版本并在交付说明里标注降级。
- 禁止 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
-> 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
```
readback 不能省略。服务端会把 SVGlide 转成 Slides XML文字 bbox、path bounds 和图片 token 可能和本地 SVG 预估不同;本地 preflight 负责拦住确定错误readback 负责发现转换后的版式漂移。
### Deck 级密度规划
生成多页 SVG deck 前,先写 deck-level plan。页面类型只定义叙事职责密度由 `deck_type`、受众、页面目的和节奏共同决定,不要把某个 page type 永久绑定为固定密度。
最小 plan schema
```json
{
"deck_type": "explain | decision | product | brand | technical | education | report",
"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 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": [
{
"page": 1,
"page_type": "cover",
"density": "low",
"density_mode": "visual-dense",
"takeaway": "one sentence the audience should remember",
"evidence": [],
"visual_structure": "full-bleed image with title overlay",
"layout_guardrails": ["large hero title", "no dense body copy"]
}
]
}
```
常用 `page_type`
```text
cover, opener, agenda, section-divider, context, problem, opportunity,
executive-summary, content, data, comparison, process, case-study, demo,
architecture, system, roadmap, timeline, decision, recommendation,
risk, tradeoff, summary, closing, q-and-a, appendix
```
密度规则:
- MUST: 每页都要有明确 `takeaway`,即使是封面、分隔页和结束页。
- 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 标注兜底。
### 结构示例
8-10 页讲解型 deck 可参考这个节奏,但不要把它当成唯一模板;如果 deck 已经包含 roadmap / playbook仍建议再补一页 closing
```text
cover -> opener/context -> agenda/map -> content -> data/comparison
-> process/system breakdown -> case-study/demo -> content/implications
-> summary -> closing
```
5 页决策汇报优先前置结论:
```text
cover -> executive-summary -> options/comparison -> recommendation/risk -> next steps
```
6 页产品/品牌 deck 可以强化视觉叙事:
```text
cover -> value proposition -> user scenario -> feature map/demo
-> proof/roadmap -> closing
```
边界处理:
- 3-5 页短 deck 可以省略 agenda把 summary 并入 closing。
- 15 页以上长 deck 应增加 section-divider 或 recap避免连续高密度阅读疲劳。
- 技术方案要混合 architecture、process、tradeoff、risk不要连续堆文字。
- 教学讲解要前置 context / concept map逐步增加密度。
- 素材不足时用抽象视觉系统、定性矩阵、annotated wireframe、scenario card 兜底,并标明假设。
### 先定义布局盒
不要直接手写散点坐标。每页先定义稳定布局盒,再把文字、图形、图例和图片放进盒内:
```text
page = 960 x 540
safe = x:48 y:40 w:864 h:460
titleBox = x:54 y:52 w:600 h:96
visualBox = x:516 y:176 w:350 h:260
notesGrid = x:54 y:430 w:760 h:48
```
生成后检查:
- 关键元素必须在 safe area 内。
- 同组元素使用同一个父盒推导坐标。
- 图例、标签、指标不能浮在不上不下的位置,必须相对主视觉左/右/下边对齐。
- 图表页必须检查 `chart_decision.anchor_role` 是否对应图表 layout box有数据源时还要核对柱高、折线点、堆叠比例、雷达顶点或 flow 宽度是否与源数据映射一致。
- 如果页面有圆、节点、卡片或框体,内容中心应和外框中心基本一致,不靠手调 `x + 10``y + 10` 维持观感。
- 不要把 1280x720 的坐标直接提交给 `slides +create-svg`。当前服务端回读画布通常是 960x540错误坐标系会表现为每页偏大、右侧卡片裁切、底部标签越界。
### 文本安全余量
`foreignObject` 文本优先使用显式 CSS。为了服务端转换后保留样式字号、加粗、颜色、行距和对齐必须写成独立属性不要把关键样式藏在 `font:` shorthand 或只写在复杂外层 wrapper 上:
```xml
<foreignObject slide:role="shape" slide:shape-type="text" x="54" y="62" width="600" height="42">
<div xmlns="http://www.w3.org/1999/xhtml"
style="margin:0;padding:0;font-size:30px;font-weight:900;font-family:Arial,'Source Han Sans SC';color:#111827;line-height:1.12;text-align:left;letter-spacing:0;">
关键结论:增长来自三件事
</div>
</foreignObject>
```
中文和混排字体要留安全高度:
- subtitle 不小于 64px。
- note / chip 单行文本盒不小于 20px。
- 小型标签文本盒不小于 14px。
- 多行文字要按行高预估高度,再额外留 8-12px。
- 右侧图例或矩阵格里的文字不得贴边,水平 padding 至少 10-14px。
- 白色/浅色文字的 bbox 必须完全落在深色 rect/card/overlay 内;封面标题如果跨出色块,应优先扩大色块或改成深色字,不要让白字压在浅色图片或白色蒙层上。
- 圆形/椭圆节点内只放短标签,解释文字移动到节点外的 callout、legend 或机制表;不要让圆内文本框宽度超过圆形直径。
- 服务端支持 `foreignObject` 内的 `<br />`。为了本地预览和标题排版稳定,标题/大段文本优先使用多个块级 `div``p` 控制行高,不要只靠 `<br />` 调整复杂布局。
- 如果需要垂直居中,优先通过更准确的文本框高度、段落行高和 y 坐标解决;布局 wrapper 可以使用,但实际文字节点仍要带显式 `font-size` / `font-weight` / `color`
### 文本承载面美学
文本框不是视觉主元素,但承载文字的 surface 是页面层级的一部分。生成器必须先决定 surface 类型,再放置文字:
```json
{
"text_surface_contract": {
"surface_kind": "accent_rail_card | tinted_panel | glass_overlay | dark_backing | label_chip | metric_tile",
"min_gap_to_title": 24,
"padding": {"x": 14, "y": 12},
"allow_plain_white_panel": false
}
}
```
- `accent_rail_card`: 卡片左侧或顶部有 6-10px 强调条,适合 callout、洞察点和团队分工。
- `tinted_panel`: 使用 preset 派生浅底和细描边,适合普通信息组;不要用纯白裸框。
- `glass_overlay`: 图片上方使用半透明浅底或深底,并与图片遮罩同色系。
- `dark_backing`: 白字必须完整落在深色承载底内。
- `label_chip`: 仅承载短标签,避免承载解释句。
- `metric_tile`: KPI 数字可用高对比底色,但仍要有角色色、分隔线或图形关系。
`svg_preflight.py` 会把以下问题作为 error`plain_white_text_panel``title_surface_pressure``connector_crosses_text`。这些错误必须修 source SVG / layout boxes不能只在 plan 里改字段。
### 几何与 path 安全线
leaf 几何属性必须写数字或 `px`,不要生成百分比、`em/rem``calc(...)`
```xml
<rect slide:role="shape" x="80" y="96" width="420px" height="240px" />
```
`path d` 只生成 `M/L/H/V/C/Q/Z` 命令。不要生成 `A``S``T` 等命令;需要圆角或弧线时,用 `C` / `Q` 近似,或改用 `circle` / `ellipse` / `rect`
Transform 参数同样使用数字或 `px`。不要写 `translate(10%, 20%)`,先在布局盒里换算成绝对坐标。
### 版式节奏
同一 deck 不能连续复用同一种“暗色网格 + 左文案 + 右卡片 + 底部 chips”。10 页左右的讲解型 deck 至少混用这些结构:
- 封面 / 全幅图片背景页。
- 目录矩阵页或行业地图页。
- 左文右图 / 左图右文双栏页。
- 全幅路线图或时间线页。
- 2x2 / 2x4 总结矩阵页。
- 数据仪表页、流程页、对比页或案例页。
相邻页面至少改变一个主结构维度:主视觉位置、网格列数、图片用法、文本密度或阅读方向。
### 图片使用与 Preview Image Mode
默认必须规划和使用图片资产。Preview 阶段的目标是验证 SVGlide 的视觉表达上限,版权/授权不作为阻断条件;不要因为 license 未确认就退回纯矢量或低信息卡片页。推荐先从用户 query、deck 标题、章节标题和页面 takeaway 生成 2-5 个图片检索词,去网络检索并拉取主题强相关图片;再补充网页截图、产品截图、图库图、新闻/历史/艺术/科普图片、材质纹理或 AI 生成图做占位视觉。必须在 plan / README 里记录 `retrieval_query`、来源 URL或标记 `license=preview_unverified`,并避免明显不适当素材、敏感肖像和会造成商业背书误导的 logo/商标。正式交付时,再统一替换为用户提供、公司/项目自有、明确可商用授权图库,或授权条件清晰的 AI 生成资产。
最稳流程仍然是先下载或生成到本地,再写成本地占位符:
```xml
<image slide:role="image" href="@./assets/hero.jpg" x="0" y="0" width="960" height="540" />
```
推荐的网络拉图流程:
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 或生成方式Preview 阶段无法确认授权时写 `license=preview_unverified``replacement_required=true`preflight 不阻断,最终交付应替换为可授权资产。
- MUST: 5 页以上 deck 至少使用 2 张真实图片8 页以上 deck 至少使用 4 张;宣传/产品/品牌/案例/教学型 deck 至少使用 5 张或至少 40% 页面含图片。
- MUST: 封面优先使用图片或图片+抽象图形混合主视觉,不要只用网格、光效和几何背景。
- MUST: 案例页优先使用行业场景图、产品截图、仪表盘截图或真实质感背景,并叠加数据 callout。
- MUST: 同一 deck 中混用全幅背景、半出血图片、卡片图、纹理/材质背景、标注型截图、图鉴式小图和局部裁切特写,避免所有图片都只是小卡片背景。
- SHOULD: 对教育、历史、艺术、医学、产品讲解等主题,优先用图片建立具象认知:人物、器物、场景、局部特写、对比图、流程截图、资料封面或时间背景图。
- MUST NOT: 保留空图片框或破图。Preview/MVP 阶段允许 http(s) 外链或 data URL 先进入 preflight warning但 live/readback 后必须确认可见;正式交付应替换为本地 `@./path` 或 file token。
Preview 阶段优先使用这些来源来快速获得丰富视觉;正式交付时再逐图确认授权、署名和替换计划:
| Source | 适合用途 | Preview 规则 |
|--------|----------|------|
| Web image search / topic query | 和用户 query、页面主题、作品/人物/地点/产品直接相关的真实图片 | 优先使用;记录 `retrieval_query`、图片页 URL 和 `preview_unverified`,正式交付再确认或替换 |
| Unsplash / Pexels / Pixabay | 高质量摄影、封面背景、场景图 | 结合主题 query 检索;记录图片页 URLlicense 可先写 `preview_unverified`,正式交付再确认 |
| Openverse / Wikimedia Commons | 百科、历史、技术、公共领域素材 | 记录单图 URL 和作者/页面preview 可先用,正式交付补 license / attribution |
| The Met / Smithsonian / NASA Open Access | 艺术、科学、历史、航天视觉 | 记录条目 URLpreview 可先用,正式交付确认 Open Access / 第三方权利 |
| 官网 / 产品页 / 新闻图 / 搜索图 | 产品截图、竞品页、事件背景、真实语境 | Preview 可作为视觉占位;必须标记 `license=preview_unverified`,正式交付替换或删去 |
| AI 生成图 / 程序化纹理 | 抽象背景、材质、概念图 | 记录生成方式和提示词摘要;正式交付确认模型/平台授权 |
素材清单建议字段:
```json
{
"local_path": "./assets/hero.jpg",
"source": "Unsplash",
"retrieval_query": "Beethoven Symphony No. 5 concert hall orchestra",
"source_url": "https://...",
"retrieved_at": "2026-06-08",
"license": "preview_unverified",
"commercial_use": "unknown_in_preview",
"replacement_required": true,
"attribution_required": false,
"usage_page": 1,
"notes": "Preview-only visual placeholder; replace or verify license before production delivery"
}
```
### 信息密度与图鉴感
短 note 不要占一个很宽胶囊。优先写成“编号/标签 + 主句 + 微解释/数值”:
```text
03 GRID ENERGY 86% | storage demand peaks before grid balancing
```
内容页可以用三种方式提高密度,不要把高密度等同于堆文字:
- `text-dense`: 多解释、多证据、多注释,适合背景分析和概念讲解。
- `chart-dense`: SVG shape 手绘矩阵、流程、时间线、微柱状、雷达、散点、标尺;如果需要原生 bar/line chart使用 root chart spec marker不要把外部图表截图当成唯一方案。
- `visual-dense`: 高级视觉图案或图片上叠加标注层、数据 callout、局部标签、对比线和图例。
视觉区要补足可读细节,避免只有装饰符号:
- 局部标注、刻度、坐标轴、图例。
- 行业标签、材料纹理、指标卡。
- 路线节点、连接线、层级分区。
- 流程/闭环图旁边补机制表或说明卡,例如“触发条件 / 运营动作 / 衡量指标”,不要把说明句塞进圆形节点内部。
- 小型表格、雷达/柱状/散点等微图表。
### 转换稳定性经验
这些规则来自 live 创建后对比 source SVG 与 readback XML 的结果,属于生成侧必须规避的转换差异:
- `image opacity` 不稳定:本地 SVG 里的 `<image opacity="0.18">` / `<image opacity="0.22">` 可能会在 readback `<img>` 中丢失透明度。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` 留足高度,不要按浏览器本地字体做极限排版。
- image token 支持可能随 live lane 漂移:如果 readback 前创建失败,先区分“图片上传失败”和“上传后 `/slide` 失败”。后者是目标 lane 的 token 解析/权限问题,短期发布可改用独立 online-pure 版本;不要把 authoring preview 里的真实图片直接删掉。
### 生成后检查
生成脚本或人工复核必须检查:
- 是否已执行本地 preflight且所有 SVG 通过 XML、协议、资产、bbox 和文本重叠检查。
- 是否已执行 `slides +create-svg --dry-run`,确认请求链路是创建 presentation + 按页追加 SVG。
- 如果使用 `@./assets/...`dry-run 是否展示预期的 `medias/upload_all` 和 transport metadatalive 失败时是否已经用纯 SVG 页和图片页隔离出 lane 问题。
- live 创建后是否已用 `xml_presentations get` 读回,重新检查画布、页数、越界、文本重叠和 closing slide。
- root / leaf role 是否完整。
- 每个 leaf 是否有 [svg-protocol.md](svg-protocol.md) 中列出的几何必填属性。
- 几何属性和 transform 参数是否只使用数字或 `px`
- `path d` 是否只包含 `M/L/H/V/C/Q/Z`
- 文本是否截断、重叠或贴边。
- 内容是否在 safe area 内,关键图例和外框是否对齐。
- 相邻页面是否明显换版式。
- 每页是否有明确 takeaway高密度页的视觉结构是否承载信息而不只是装饰。
- 图表页是否已记录 `chart_decision``chart_verification`;有数据源时是否写入 `receipts/chart-verify.json` 并核对数据到坐标映射。
- 内容页是否避免了“大标题 + 大图 + 2-3 个短 chip”的低信息布局。
- 自称数据、排名、客户、引用、logo 或案例时,是否有来源;没有来源时是否改为定性或假设表达。
- 图片是否足够丰富并可见;如果 Preview/MVP 阶段暂时保留 http(s) / data URL 或 `preview_unverified` 来源,要记录 warning、确认 live/readback 可见,并在正式交付前列出替换项。
验证记录建议写回 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json``readback_verification` 字段,并在最终回复中简述:
```text
验证记录:
- PreflightN/N SVG 通过 root/role/geometry/path/image/bbox 检查。
- Dry-run已确认 create presentation + N 次 /slide。
- Readback实际页数 N / 预期 N未发现空白页、破图或缺失 closing slide。
- Chart datachecked N/N chart pagesfailed Mmissing data K。
- 版式:检查 safe area、文本重叠、越界和相邻页版式变化。
- 资产Preview 阶段优先丰富图片和 readback 可见性;若保留 http(s)/data URL 或 `preview_unverified` 来源,必须记录 warning。正式交付再替换为本地 @path 自动上传或 file token并补齐授权。
```
## 错误处理
任一页失败时,错误会包含:
- `xml_presentation_id`
- 失败页序号
- 已成功页数
- 已创建的 `slide_ids`
如果服务端 detail 带有 `SVGLIDE_ERROR_JSON:` markerCLI 会提取并在错误中展示 `svglide_error`,用于定位 `type``page_index``tag_name``element_id``role``hint`
失败后不要假设没有创建任何资源。先把恢复状态写回 plan 的 `recovery` 字段:
```json
{
"xml_presentation_id": "slides...",
"failed_page": 3,
"failed_svg_file": ".lark-slides/plan/<deck-id>/pages/page-003.svg",
"successful_slide_ids": ["abc", "def"],
"svglide_error": {"type": "svg_validation_error", "tag_name": "foreignObject"},
"next_action": "fix source SVG and rerun preflight before retry"
}
```
恢复顺序:
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. SVG 通过本地 preflight 且失败在第 1 页,服务端只返回 generic `nodeServer invalid param`:优先检查 `lark-cli` 环境、代理和 PPE/BOE lane 是否命中目标 slide server。不要先把已通过协议校验的 deck 改回低质量 SVG。
5. 已创建 presentation 或部分页面时,默认保留现场并回读确认;是否删除空 presentation 必须单独由用户确认。
### 编辑已创建的 SVG deck
SVG deck 后续编辑走双轨,不承诺 source SVG id 能稳定映射到 readback XML block id
| 修改类型 | 推荐路径 | 说明 |
|----------|----------|------|
| 小改标题、文本、图片或坐标 | `xml_presentation.slide.get` 读回 XML -> 找当前 block_id -> `slides +replace-slide` | 使用转换后的 XML 做块级编辑,页序和 slide_id 不变 |
| 大幅换版式、重画图表、调整视觉系统 | 修改 source SVG -> 重新 preflight -> 重新创建或替换目标页 | 保持 SVG 的视觉表达优势,避免在转换后 XML 上手搓复杂 SVG 结构 |
| 无法定位 block_id 或映射不可信 | 回 source SVG 修改 | 不生成 `edit-map.json`,除非服务端或转换结果能证明 source id 可稳定保留 |
小改前必须重新 `slide.get` 拿最新 block id 和 revision大改后必须更新同一个 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,保持 plan、SVG 文件、创建结果和验证记录一致。

View File

@@ -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/<deck-or-task-id>/`
4. 先创建目录:`mkdir -p .lark-slides/plan/<deck-or-task-id>`
@@ -55,8 +55,19 @@ Exception:
```json
{
"input_profile": {
"input_type": "topic",
"source_status": "research_required"
},
"source_brief": {
"path": "source/brief.md",
"evidence_index": "source/evidence.json",
"numeric_claim_policy": "cite_or_remove"
},
"presentation_goal": "Explain the proposal and secure approval for the next phase.",
"audience": "Product and engineering leaders who know the domain but need a concise decision narrative.",
"narrative_mode": "briefing",
"visual_style": "data_journalism",
"theme_style": "Clean business style, light background, restrained blue accent, strong visual hierarchy.",
"visual_system": {
"background_strategy": "Content pages use one light base; cover and closing may use a related dark treatment with the same accent system.",
@@ -67,12 +78,50 @@ 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"
},
"strategy_locks": [
{"id": "canvas", "decision": {"width": 960, "height": 540}, "evidence_ref": "plan.canvas"},
{"id": "page_count", "decision": 10, "evidence_ref": "plan.page_count"},
{"id": "audience", "decision": "Product and engineering leaders", "evidence_ref": "plan.audience"},
{"id": "narrative_mode", "decision": "briefing", "evidence_ref": "plan.narrative_mode"},
{"id": "visual_style", "decision": "data_journalism", "evidence_ref": "plan.visual_style"},
{"id": "style_preset", "decision": "raw_grid", "evidence_ref": "plan.style_preset"},
{"id": "asset_strategy", "decision": "authoring_preview_rich", "evidence_ref": "plan.asset_strategy.mode"},
{"id": "chart_policy", "decision": "data_relationship_first", "evidence_ref": "plan.chart_policy"}
],
"chart_policy": {
"selection_rule": "data_relationship_first",
"requires_data_coordinate_check": true,
"receipt": "receipts/chart-verify.json"
},
"icon_policy": {
"style": "single_consistent_family",
"semantic_mapping_required": true
},
"typography_constraints": {
"title_max_lines": 2,
"body_max_lines_per_box": 2,
"footer_max_lines": 1,
"long_text_handling": "Shorten, split into multiple boxes, or move detail to speaker notes instead of shrinking into a tight box."
},
"text_surface_contract": {
"allowed_surface_kinds": ["accent_rail_card", "tinted_panel", "glass_overlay", "dark_backing", "label_chip", "metric_tile"],
"allow_plain_white_panel": false,
"min_gap_to_title": 24,
"padding": {"x": 14, "y": 12},
"connector_policy": "Connectors terminate at card/node/chart edges and must not cross visible text boxes."
},
"verification_plan": {
"check_background_consistency": true,
"check_text_fit": true,
@@ -103,11 +152,22 @@ Exception:
Top-level fields:
- `input_profile`: input type and source status. For pure topic prompts, mark `source_status` as `research_required` or `user_prompt_only` rather than pretending a source document exists.
- `source_brief`: path and evidence index for structured source material. Numeric claims must be cited or removed according to `numeric_claim_policy`.
- `presentation_goal`: what the whole deck is trying to achieve.
- `audience`: target readers or listeners and their assumed background.
- `narrative_mode`: the story mode, such as `briefing`, `instructional`, `narrative`, `pyramid`, or `showcase`. Do not put visual style names here.
- `visual_style`: the visual language target, separate from `narrative_mode` and separate from the executable `style_preset`.
- `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.
- `strategy_locks`: required for SVGlide SVG decks. Record exactly eight locked decisions: `canvas`, `page_count`, `audience`, `narrative_mode`, `visual_style`, `style_preset`, `asset_strategy`, and `chart_policy`. Each lock must have `id`, `decision`, and `evidence_ref`.
- `chart_policy`: deck-level chart rule. Select chart type from data relationship and page purpose first; chart pages must have a page-level `chart_decision`.
- `icon_policy`: deck-level icon discipline. Use one consistent semantic family and map icons to concepts; do not mix unrelated icon styles as decoration.
- `typography_constraints`: deck-level limits for line count, text box density, and how to handle long text before XML generation.
- `text_surface_contract`: required for SVGlide SVG decks. Defines allowed text-bearing surface types, title exclusion gap, padding, and connector avoidance. Do not generate plain white text panels unless the user explicitly asks for bare wireframes or tables.
- `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 +182,18 @@ 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`.
- `route_private` is only an abstract create-svg marker. Shared plans must not contain exact SVG private recipe ids; route-private selection belongs in the create-svg sidecar.
- `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`.
- `source_refs`: stable ids from top-level `source_pack.items` used by this page. If a page cites data, charts, or numeric claims, those refs must resolve.
- `chart_decision`: required when `chart_type` is present. Include `chart_type`, `reason`, `data_ref`, `anchor_role`, and `bbox_tolerance_px`; the reason must explain the data relationship and page purpose, not just name a template.
- `chart_verification`: required when `chart_type` is present. Point to the receipt that checks visible marks against source data, such as bar heights, line points, stacked proportions, or radar vertices.
## Layout Vocabulary
Use one of these `layout_type` values unless the user explicitly needs a custom structure:
@@ -216,4 +288,5 @@ After creating the PPT, fetch the presentation and verify:
- Pages are not crowded, and any planned `timeline`, `comparison`, or `architecture-diagram` page uses its matching visual structure.
- The actual backgrounds match `visual_system.background_strategy`; any dark, image-led, or emphasis page has an intentional relationship to the rest of the deck.
- Text boxes respect `typography_constraints`; long labels, captions, footer text, and conclusion bars are not squeezed into boxes that are too short for the intended line count.
- Text-bearing cards, callouts, badges, labels and metric tiles respect `text_surface_contract`; they are not naked white rectangles, do not press into `titleBox`, and connector lines do not pass through visible text.
- If real assets are used, the final XML contains renderable asset tokens or supported local placeholders for creation, not http URLs, stale local paths, or blank image boxes.

View File

@@ -0,0 +1,173 @@
{
"schema_version": "1.0.0",
"route_id": "create-svg",
"visibility": "route-private",
"selection_sidecar_schema": "references/routes/create-svg/private/recipe-selection.schema.json",
"blocked_absolute_paths": [
"/Users/bytedance/bd-projects/workspaces/SVGlide/svglide-visual-guidance/visual_recipe_catalog.md"
],
"blocked_research_dotted_recipe_ids": [
"cover.hero",
"section.divider",
"agenda.structured",
"kpi.big-number",
"comparison.two-column",
"timeline.roadmap",
"process.flow",
"architecture.layered",
"data.single-chart",
"dashboard.kpi-grid",
"table.visual-summary",
"image.story",
"quote.insight"
],
"recipes": {
"solar_gallery_hero": {
"base_recipe": "hero_typography",
"required_primitives": [
"typography",
"image_overlay",
"geometric_shape"
],
"required_effects": [
"gradient",
"image_overlay",
"spotlight"
],
"minimum_visible_area_ratio": 0.62,
"source_truth_requirements": [
"Primary image or product evidence must be traceable to the deck query, local asset reference, or cited preview source.",
"Hero title and visual focal point must remain editable or explicitly represented in the SVG source manifest.",
"Decorative solar rays or gallery frames must not be the only evidence for the selected recipe."
],
"fallback_policy": "deny",
"exemption_policy": "deny"
},
"spectral_wave_strata": {
"base_recipe": "gradient_depth",
"required_primitives": [
"path",
"gradient",
"geometric_shape"
],
"required_effects": [
"path",
"gradient",
"texture"
],
"minimum_visible_area_ratio": 0.58,
"source_truth_requirements": [
"Wave strata must encode an explicit concept, timeline, segment, or hierarchy from the page plan.",
"At least three visible strata must be present in the SVG source manifest.",
"Labels must map strata to source-backed page content instead of acting as generic decoration."
],
"fallback_policy": "deny",
"exemption_policy": "deny"
},
"modernist_grid_gallery": {
"base_recipe": "geometric_composition",
"required_primitives": [
"texture",
"image_overlay",
"typography"
],
"required_effects": [
"grid_geometry",
"image_overlay",
"gradient"
],
"minimum_visible_area_ratio": 0.6,
"source_truth_requirements": [
"Every gallery tile must correspond to a source-backed item, image, milestone, or evidence unit.",
"The grid must use stable alignment and must not collapse into ordinary card bullets.",
"Any clipping or mask-like treatment must include a safe rewrite in the SVG source manifest."
],
"fallback_policy": "deny",
"exemption_policy": "deny"
},
"aurora_ribbon_landscape": {
"base_recipe": "gradient_depth",
"required_primitives": [
"path",
"gradient",
"geometric_shape"
],
"required_effects": [
"gradient",
"path",
"connector_flow"
],
"minimum_visible_area_ratio": 0.57,
"source_truth_requirements": [
"Ribbon paths must carry the page narrative, flow, or segmentation rather than only forming a background.",
"The focal ribbon must occupy a meaningful visible area and remain behind readable text.",
"Color bands must map to source-backed categories, stages, or emphasis states when labels are present."
],
"fallback_policy": "deny",
"exemption_policy": "deny"
},
"prism_spectrum_split": {
"base_recipe": "geometric_composition",
"required_primitives": [
"geometric_shape",
"gradient",
"typography"
],
"required_effects": [
"gradient",
"spotlight",
"connector_flow"
],
"minimum_visible_area_ratio": 0.55,
"source_truth_requirements": [
"The split spectrum must represent a source-backed comparison, segmentation, funnel, or transformation.",
"Prism geometry must expose explicit source primitives instead of being a raster-only decoration.",
"Each split region must preserve text contrast and safe-area constraints."
],
"fallback_policy": "deny",
"exemption_policy": "deny"
},
"layered_hill_dawn": {
"base_recipe": "gradient_depth",
"required_primitives": [
"geometric_shape",
"gradient",
"typography"
],
"required_effects": [
"gradient",
"texture",
"spotlight"
],
"minimum_visible_area_ratio": 0.56,
"source_truth_requirements": [
"Layered hills must express a staged build-up, maturity path, or depth hierarchy from the page plan.",
"At least three source-visible depth layers must be represented in the SVG source manifest.",
"Dawn lighting must not reduce title or body text contrast below the route safety threshold."
],
"fallback_policy": "deny",
"exemption_policy": "deny"
},
"curtain_call_orbit": {
"base_recipe": "metaphor_loop",
"required_primitives": [
"path",
"flow",
"geometric_shape"
],
"required_effects": [
"path",
"connector_flow",
"spotlight"
],
"minimum_visible_area_ratio": 0.59,
"source_truth_requirements": [
"Orbiting elements must map to source-backed actors, milestones, capabilities, or closing takeaways.",
"The orbit path must remain visible and must be represented by SVG path or line primitives.",
"The curtain or spotlight motif must support the closing narrative instead of hiding required evidence."
],
"fallback_policy": "deny",
"exemption_policy": "deny"
}
}
}

View File

@@ -0,0 +1,52 @@
# PPE Pure SVG Live Create Notes
This note is private to the `slides +create-svg` route. XML/SXSD generation must not read or rely on it.
## Route Setup
When validating `ppe_pure_svg` with the local worktree CLI, use the worktree-local binary, not a global `lark-cli`:
```bash
/path/to/worktree/./lark-cli slides +create-svg ...
```
Whistle routing needs the pre OpenAPI host plus both headers:
```text
/^https:\/\/open\.feishu\.cn\/(.*)$/ https://open.feishu-pre.cn/$1
https://open.feishu.cn/ reqHeaders://Env=Pre_release
https://open.feishu.cn/ reqHeaders://x-tt-env=ppe_pure_svg
https://open.feishu-pre.cn/ reqHeaders://Env=Pre_release
https://open.feishu-pre.cn/ reqHeaders://x-tt-env=ppe_pure_svg
/^https:\/\/accounts\.feishu\.cn\/(.*)$/ https://accounts.feishu-pre.cn/$1
```
`w2 start` / `w2 add` may require sandbox escalation because Whistle writes user-level runtime files.
## Image Token Boundary
`slides +create-svg` image transport is:
```text
create xml_presentation
-> scan SVG href="@./assets/..."
-> upload local images through /open-apis/drive/v1/medias/upload_all
-> inject <metadata data-svglide-assets="true"><img src="file_token" /></metadata>
-> replace image href with file_token
-> POST the SVG to /slides_ai/v1/xml_presentations/<id>/slide
```
Upload success does not prove the live lane can parse the image token. In the 2026-06-12 `ppe_pure_svg` smoke, pure SVG pages succeeded, but pages with uploaded image tokens failed after upload with `nodeServer internal error [5090000]`. Treat that as a slide/nodeServer image-token compatibility issue, not a local image upload failure.
## Publishing Fallback
If a live deck must be published before the image-token issue is fixed:
1. Keep the rich local HTML/image preview intact.
2. Generate a separate online-pure SVG directory.
3. Remove `<image>` and `@./assets/...` references only in that online-pure directory.
4. Replace photo regions with SVG-native gradients, paths, ribbons, overlays, and texture geometry.
5. Verify no `@./assets`, `<image>`, `uploaded_file_token`, or missing `url(#id)` refs remain.
6. Run preflight, dry-run, live create, and readback page-count verification.
Do not silently remove real images from the authoring preview. State the fallback in the final delivery and keep a follow-up item to repair the image-token lane.

View File

@@ -0,0 +1,36 @@
{
"schema_version": "1.0.0",
"route_id": "create-svg",
"manifest_ref": "references/routes/create-svg/private-recipes.manifest.json",
"manifest_digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"deck_id": "example-deck",
"selections": [
{
"page_index": 1,
"slide_id": "slide-1",
"private_recipe_id": "solar_gallery_hero",
"base_recipe": "hero_typography",
"required_primitives": [
"typography",
"image_overlay",
"geometric_shape"
],
"required_effects": [
"gradient",
"image_overlay",
"spotlight"
],
"minimum_visible_area_ratio": 0.62,
"source_truth_evidence": [
{
"requirement": "Manifest source truth requirement copied into the sidecar.",
"evidence": "Concrete page evidence or asset reference that satisfies the requirement.",
"source_ref": "slide_plan.json#/slides/0"
}
],
"selection_reason": "The private recipe is selected only after matching the page plan to manifest requirements.",
"fallback_policy": "deny",
"exemption_policy": "deny"
}
]
}

View File

@@ -0,0 +1,139 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://lark-cli.local/schemas/lark-slides/create-svg/private/recipe-selection.schema.json",
"title": "SVGlide create-svg private recipe selection sidecar",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"route_id",
"manifest_ref",
"selections"
],
"properties": {
"schema_version": {
"type": "string",
"const": "1.0.0"
},
"route_id": {
"type": "string",
"const": "create-svg"
},
"manifest_ref": {
"type": "string",
"const": "references/routes/create-svg/private-recipes.manifest.json"
},
"manifest_digest": {
"type": "string",
"pattern": "^sha256:[a-f0-9]{64}$"
},
"deck_id": {
"type": "string",
"minLength": 1
},
"selections": {
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/selection"
}
}
},
"$defs": {
"selection": {
"type": "object",
"additionalProperties": false,
"required": [
"page_index",
"private_recipe_id",
"base_recipe",
"required_primitives",
"required_effects",
"minimum_visible_area_ratio",
"source_truth_evidence",
"selection_reason",
"fallback_policy",
"exemption_policy"
],
"properties": {
"page_index": {
"type": "integer",
"minimum": 1
},
"slide_id": {
"type": "string",
"minLength": 1
},
"private_recipe_id": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$"
},
"base_recipe": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$"
},
"required_primitives": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$"
},
"uniqueItems": true
},
"required_effects": {
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$"
},
"uniqueItems": true
},
"minimum_visible_area_ratio": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"source_truth_evidence": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"requirement",
"evidence"
],
"properties": {
"requirement": {
"type": "string",
"minLength": 1
},
"evidence": {
"type": "string",
"minLength": 1
},
"source_ref": {
"type": "string",
"minLength": 1
}
}
}
},
"selection_reason": {
"type": "string",
"minLength": 1
},
"fallback_policy": {
"type": "string",
"const": "deny"
},
"exemption_policy": {
"type": "string",
"const": "deny"
}
}
}
}
}

View File

@@ -0,0 +1,45 @@
{
"schema_version": "1.0.0",
"route_id": "create-svg",
"route_name": "slides +create-svg",
"owner_skill": "lark-slides",
"visibility": "public",
"private_recipe_manifest": "private-recipes.manifest.json",
"allowed_private_recipe_source": "private_recipe_manifest_keys",
"public_entrypoints": [
"SKILL.md",
"references/lark-slides-create-svg.md",
"references/svg-protocol.md",
"references/svglide-project-pipeline.md",
"references/asset-planning.md",
"references/svg-visual-recipes.md",
"references/svg-aesthetic-review.md",
"references/svglide-absorption-matrix.md",
"references/svglide-renderer-registry.json"
],
"private_policy": {
"private_recipe_manifest": "references/routes/create-svg/private-recipes.manifest.json",
"recipe_selection_schema": "references/routes/create-svg/private/recipe-selection.schema.json",
"recipe_selection_example": "references/routes/create-svg/private/recipe-selection.example.json",
"runtime_surface": "route-private",
"fallback_policy": "deny",
"exemption_policy": "deny"
},
"docs_leak_lint": {
"script": "scripts/svg_private_docs_lint.py",
"denylist_source": "references/routes/create-svg/private-recipes.manifest.json",
"allowed_exact_id_paths": [
"references/routes/create-svg/private-recipes.manifest.json",
"references/routes/create-svg/private/**",
"tests/fixtures/routes/create-svg/private/**",
"tests/fixtures/routes/create-svg/internal-reports/**"
],
"allowed_private_doc_links": [
{
"path": "references/lark-slides-create-svg.md",
"target": "references/routes/create-svg/private/ppe-pure-svg-live.md",
"reason": "public workflow may link to PPE troubleshooting, but must not inline private environment details"
}
]
}
}

View File

@@ -0,0 +1,564 @@
{
"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."
],
"text_surface_contract": {
"allowed_surface_kinds": ["accent_rail_card", "tinted_panel", "glass_overlay", "dark_backing", "label_chip", "metric_tile"],
"allow_plain_white_panel": false,
"min_gap_to_title": 24,
"min_card_padding": {"x": 14, "y": 12},
"connector_policy": "connector line/path must terminate at card, node, or chart edges and must not cross visible text boxes"
},
"groups": {
"Restrained": {"expected_count": 10, "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": ["geometric_composition", "path_flow", "spotlight_annotation"],
"avoid_for": ["hero_typography"],
"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": ["geometric_composition", "hero_typography", "path_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": ["path_flow", "geometric_composition", "geometric_composition"],
"avoid_for": ["hero_typography"],
"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": ["geometric_composition", "path_flow", "path_flow"],
"avoid_for": ["hero_typography"],
"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": ["spotlight_annotation", "hero_typography", "geometric_composition"],
"avoid_for": ["fake_ui_dashboard"],
"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": ["geometric_composition", "spotlight_annotation", "geometric_composition"],
"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": "data_journalism_editorial",
"display_name": "Data Journalism Editorial",
"group": "Restrained",
"source_token": "svglide/editorial_ai_capital_2026",
"formality": "high",
"vibe": ["editorial", "data_journalism", "financial", "cinematic"],
"best_for": ["hero_typography", "infographic_scorecard", "path_flow", "geometric_composition", "icon_capability_map"],
"avoid_for": ["mask_clip_showcase"],
"palette": {"background": "#0E1116", "text": "#E8E6E1", "muted": "#8A857E", "accent": "#E63946", "support": ["#1A1F26", "#F4A261", "#52B788", "#2A2F36"]},
"shape_language": {"panel_treatment": "dark editorial chart surfaces with thin rules, sparse grid texture, and one red risk accent", "corner_radius": "low", "border_weight": "hairline", "texture": "newspaper grid, paper grain, restrained infrastructure particles"},
"density": {"text_density": "high", "label_density": "high", "connector_density": "medium", "node_budget": {"source_nodes": 72, "source_text": 34, "source_shapes": 28, "source_connectors": 10}},
"slide_translation": {"recommended_layouts": ["cover", "editor_note", "contents", "chart_takeaway", "chapter", "closing"], "svglide_primitives": ["typography", "geometric_shape", "micro_chart", "path", "annotation", "texture"], "fallback_policy": "Keep the main chart geometry first; if atmosphere assets are unavailable, preserve negative space, thin rules, and red numeric emphasis with SVG-native shapes."},
"quality_oracle": {"expected_style_signals": ["dark graphite ground", "large serif or editorial title hierarchy", "red numeric emphasis", "thin chart rules", "small source/footer text"], "warning_thresholds": {"text_boxes_max": 32, "accent_colors_max": 3}}
},
{
"style_id": "papier_bleu",
"display_name": "Papier Bleu",
"group": "Restrained",
"source_token": "HWi5woaS8h1D4EbKutnulYWdsWc",
"formality": "medium",
"vibe": ["blueprint", "clear", "knowledge"],
"best_for": ["path_flow", "path_flow", "mask_clip_showcase"],
"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": ["spotlight_annotation", "hero_typography", "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": ["hero_typography", "infographic_scorecard", "geometric_composition"],
"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": ["path_flow", "path_flow", "hero_typography"],
"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": ["geometric_composition", "mask_clip_showcase", "fake_ui_dashboard"],
"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": ["hero_typography", "spotlight_annotation", "hero_typography"],
"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": ["fake_ui_dashboard", "icon_capability_map", "geometric_composition"],
"avoid_for": ["spotlight_annotation"],
"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": ["geometric_composition", "fake_ui_dashboard", "hero_typography"],
"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": ["geometric_composition", "geometric_composition", "infographic_scorecard"],
"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", "geometric_composition", "hero_typography"],
"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": ["spotlight_annotation", "geometric_composition", "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": ["hero_typography", "geometric_composition", "path_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": ["geometric_composition", "geometric_composition", "geometric_composition"],
"avoid_for": ["hero_typography"],
"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": ["geometric_composition", "path_flow", "spotlight_annotation"],
"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": ["fake_ui_dashboard", "geometric_composition", "geometric_composition"],
"avoid_for": ["hero_typography"],
"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", "geometric_composition", "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": ["spotlight_annotation", "geometric_composition", "geometric_composition"],
"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": ["hero_typography", "path_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": ["hero_typography", "brand_system", "mask_clip_showcase"],
"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": ["hero_typography", "fake_ui_dashboard", "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": ["hero_typography", "mask_clip_showcase", "spotlight_annotation"],
"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": ["path_flow", "path_flow", "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": ["hero_typography", "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": ["infographic_scorecard", "metaphor_loop", "path_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": ["geometric_composition", "path_flow", "hero_typography"],
"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", "geometric_composition", "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", "hero_typography", "mask_clip_showcase"],
"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", "geometric_composition", "hero_typography"],
"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": ["spotlight_annotation", "path_flow", "geometric_composition"],
"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}}
}
]
}

View File

@@ -0,0 +1,114 @@
# SVGlide Style Presets
`style-presets.json` is the runtime source of truth for the 35 `beautiful-feishu-whiteboard` style presets plus the SVGlide `data_journalism_editorial` preset. 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 Editorial Preset
Use `data_journalism_editorial` when a deck needs a dark data-journalism feel. Translate the preset into SVGlide-safe parts: dark graphite ground, large editorial title hierarchy, thin chart rules, small source/footer text, restrained red numeric emphasis, and real chart geometry. Do not copy raw SVG paths, images, or PPTX export assumptions.
## SVGlide-Safe Translation
Translate style into supported SVG primitives:
- Palette -> explicit `fill`, `stroke`, and text colors.
- Panel treatment -> `rect`, `path`, and grouped layout boxes. Text-bearing panels must be translated into a concrete surface kind, not a naked white rectangle.
- 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 `<pattern>` as the only effect.
- Image overlay -> real `<image slide:role="image">` 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.
## Text Surface Translation
Every style preset has a `shape_language.panel_treatment`. Translate it into one of these SVG-safe text surfaces:
- `accent_rail_card`: tinted card with a 6-10px left/top rail in the preset accent color.
- `tinted_panel`: non-white preset support fill plus visible stroke.
- `glass_overlay`: semi-transparent panel on an image with matching overlay color.
- `dark_backing`: dark rect/card/overlay for light text.
- `label_chip`: short label only; no explanatory sentence.
- `metric_tile`: KPI tile with a role color, separator, rail, or small chart cue.
Rules:
- Do not use bare `fill="#ffffff"` rectangles for user-visible text unless the page is an intentional wireframe/table and the panel has visible stroke or grid structure.
- Keep text surfaces at least 24px away from the title box.
- Connector lines must terminate at card/node/chart edges; they must not run through visible text.
- If a preset uses low contrast or editorial whitespace, improve the text surface with spacing, stroke, role color, and alignment rather than adding more plain boxes.
## Quality Gates
Before calling `slides +create-svg`, run:
```bash
python3 skills/lark-slides/scripts/svg_preflight.py \
--route-manifest skills/lark-slides/references/routes/create-svg/route.manifest.json \
--report-scope public \
--plan .lark-slides/plan/<deck-id>/slide_plan.json \
--input .lark-slides/plan/<deck-id>/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.
- text surfaces avoid `plain_white_text_panel`, `title_surface_pressure`, and `connector_crosses_text` issues.

View File

@@ -0,0 +1,127 @@
# SVGlide 审美 Review
这份文档用于本地 SVG/HTML preview 生成之后、调用 `slides +create-svg` 之前。
它是从以下审美评分标准中提炼出的短版执行清单:
`/Users/bytedance/bd-projects/workspaces/SVGlide/svglide-visual-guidance/svg_aesthetic_rubric.md`.
这份 review 补充 `svg_preflight.py`。Preflight 负责确定性的协议、plan 和
bbox 问题;这份清单负责需要人工或截图判断的渲染后视觉质量问题。
Project runner 中的 `svg_preview_lint.py` 负责缺 preview、破损 SVG、明显重叠、
越界、空图和高置信浅字无底等 objective lint本文件负责人工/截图视角的
审美判断。二者都不能替代 live readback。
## 必须执行的 Review 流程
1. 生成本地 SVG 文件,并在条件允许时生成本地 `preview.html`
2. 运行 `svg_preflight.py --plan ... --input ...`;先修复所有 error。
3. 打开或检查 preview。必须审查所有页面不只看封面。
4. 重复出现的版式问题要修生成器或 source SVG不能只改 `slide_plan.json`
5. live 创建前重新运行 preflight 和 preview。
不要用 preview review 替代 live readback。服务端转换后仍可能改变文本框、
图片 token、path bounds 和不支持的效果。
## 阻断性视觉问题
调用 live API 前必须修复这些问题:
| 问题 | 处理方式 |
|---|---|
| 文本重叠、文本容器溢出、标题被裁切 | 重新生成 layout boxes 或减少文本;不要只是整体缩小 |
| badge、pill、章节标签或页码标签贴住/压住标题 | 把 badge 移出标题块,或保留至少 12-16px 垂直间距 |
| 装饰线或色带压迫标题 | 把线移到标题区上方,或下移标题,保留呼吸感 |
| callout / 卡片 / insight panel 侵入标题区 | 重算 `titleBox``calloutBox`;标题底部到任何文本承载面至少 24px |
| connector line/path 穿过标题、中心文字或卡片文案 | 改成折线、短 leader line 或连接到卡片边缘;背景线降低 opacity 并声明为 decorative |
| 多页出现裸白底黑字文本框 | 按 style preset 改成 tinted panel、accent rail card、glass overlay、dark backing、metric tile 或 label chip |
| 主体内容超出 `960 x 540` 或 safe area | 按 960x540 画布重新计算坐标 |
| 浅色图片/背景上的低对比文本 | 增加实色承载底、overlay或切换文字颜色 |
| 空图片框或 preview 破图 | live 创建前替换资产或使用视觉 fallback |
| 页面缺少视觉焦点 | 围绕一个主导数字、图解、图片、路径或标题重建页面 |
| 页面只是普通卡片/bullet缺少 SVG 优势 | 选择更合适的 `visual_recipe`,或不要走 SVG 路线 |
| 同类版式问题在多页重复出现 | 修共享生成规则,然后重新生成受影响页面 |
## Issue 严重级别
在 preview notes 和最终验证记录中使用这些级别:
| 级别 | 含义 | 处理方式 |
|---|---|---|
| P0 | 不应该 live 创建 | 在 `slides +create-svg` 前修复或重新生成 |
| P1 | 可以渲染,但用户可见质量明显低于目标 | 交付前修复;只有用户明确接受草稿时才继续 |
| P2 | 小幅打磨项或残余风险 | 记录下来,有时间再修 |
默认映射:
- P0preflight error、不安全 SVG、破图/空图、画布裁切、关键文本裁切或重叠、对比度不可读、必需资产缺失、不支持视觉缺少 fallback。
- P1焦点弱、布局骨架重复、装饰/标题拥挤、视觉层级弱、图表/图解意图不匹配、可见 SVG 优势弱。
- P2轻微对齐差异、小的颜色不一致、非关键来源元数据 warning、只影响打磨的间距问题。
## 评分标准
使用 0-100 分。用户可见 deck 的默认目标是 `>= 75`
低于 `65`live 创建前必须重新生成或修复。
| 维度 | 权重 | 好结果 |
|---|---:|---|
| 沟通匹配度 | 15 | 页面类型和视觉形式匹配用户意图 |
| 视觉层级 | 15 | 2 秒内能看到唯一焦点 |
| 布局稳定性 | 15 | 网格、间距、对齐和 safe area 一致 |
| 可读性 | 15 | 字号、行长、对比度和换行可读 |
| 颜色纪律 | 10 | 强调色数量少,且语义一致 |
| 数据/图解完整性 | 10 | 图表、流程和图解诚实表达关系 |
| 风格一致性 | 8 | 图标、圆角、线宽、阴影和 motif 像同一套 deck |
| SVG 优势 | 7 | 页面明显受益于 path、texture、chart geometry、flow 或 overlay |
| 来源/资产可追溯 | 5 | 使用外部参考和 preview 资产时有记录 |
## Review 问题
每页都问这些问题:
- 这一页的一句话 takeaway 是什么?
- 第一眼落点在哪里,是否就是预期的 `visual_focal_point`
- 视线顺序是否符合 title -> focal visual -> evidence -> detail
- 有没有 badge、线条、水印、标签或缩略图挤压文本
- 页面是否使用了 SVG-native 结构,还是只有普通盒子和文本?
- 如果这一页变成普通 XML/PPT 卡片布局,会损失什么?
- 图表/流程/表格选择是否适合它要表达的关系?
- 颜色和强调方式是否和整套 deck 保持一致?
## SVGlide Archetype Review
从 SVGlide archetype 样张和本地多轮 preview 得到的经验要落在渲染后审查,而不是只写进 prompt。
- 第一眼必须看出页型cover、contents、section、bubble chart、donut chart、
sankey/flow、hub-spoke、table、closing 等不能互相退化成同一种卡片页。
- 图表页必须让几何承载信息bubble 用圆形节点大小/位置表达关系donut 用环形
分段和中心 KPI 表达构成flow/sankey 用流线宽度和方向表达转移。
- 白字或浅字必须有足够暗的 backingname-plate、label-back、badge、pill 不能
压住 note、source、正文或图表标签。
- connector、趋势线、轨道线、坐标轴不能穿过标题、中心数字、label 或说明文字;
必要时改成折线、短 leader line 或降低为 decorative background。
- 红色和高饱和强调色只打关键数字、风险或章节标记。不要让每个图形都同等抢眼。
- 如果 preview 分数低,优先修 renderer/source SVG 和资产选择,而不是只改
`slide_plan.json` 的文案描述。
## 修复优先级
1. 布局正确性画布、safe area、重叠、溢出、裁切。
2. 可读性:对比度、字号、行长、文本框高度充足。
3. 层级:一个焦点对象、清晰标题、支撑细节降级。
4. SVG 优势path/flow/chart/icon/texture/image overlay 真实存在。
5. Deck 节奏:避免只换文案却重复同一骨架。
6. 资产/来源治理preview 资产可见,来源元数据存在。
## 可接受的输出记录
报告验证结果时,明确说明检查过什么:
```text
SVG preview review:
- preflight: passed / fixed errors first
- preview_path: .lark-slides/plan/<deck-id>/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
```

View File

@@ -0,0 +1,207 @@
# SVGlide SVG Protocol
最小模板:
```xml
<svg
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"
>
<rect slide:role="shape" x="60" y="60" width="240" height="135" fill="#E8EEF8" />
<foreignObject slide:role="shape" slide:shape-type="text" x="90" y="98" width="240" height="60">
<div xmlns="http://www.w3.org/1999/xhtml" style="font-size:20px;font-weight:700;color:#1F2937;line-height:1.2">SVGlide</div>
</foreignObject>
<image slide:role="image" href="@./hero.png" x="390" y="60" width="240" height="135" />
</svg>
```
## 必须满足
- root 必须是非 namespaced 的 `<svg>`,不能是 `<svg:svg>`
- 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"`
- `<g>` 和嵌套 `<svg>` 可以作为容器,用于继承样式和 transform容器内真正渲染的元素仍必须声明 `slide:role`
- `slide:role="shape"` 目前只支持 `rect``ellipse``circle``line``path``foreignObject`
- 文本使用文本型 shape`<foreignObject slide:role="shape" slide:shape-type="text">...</foreignObject>`
- 图片使用 `<image slide:role="image" href="file_token">`;本地占位符写成 `href="@./image.png"`
- 原生 chart 可使用 root 直系 `<g slide:role="chart" slide:chart-ref="..." x="..." y="..." width="..." height="...">` spec markermarker 内只能有一个 chart metadatametadata payload 是 base64url 编码后的 canonical JSON chart spec不是 SXSD `<chart>` XML也不是 chart snapshot/staticData。
- `<defs>``<style>` 会被服务端解析/跳过输出;支持嵌套在 `g` / 嵌套 `svg` 容器中。
- CLI 注入的 transport metadata `<metadata data-svglide-assets="true">` 会被跳过输出但用于传输图片元数据。
## 坐标系与画布
- 当前 `slides +create-svg` 新建的 Lark Slides presentation 回读画布通常是 `960 x 540`。生成 SVG deck 时默认使用 `width="960" height="540" viewBox="0 0 960 540"`,不要默认用 `1280 x 720`
- 服务端不会保证把 `viewBox="0 0 1280 720"` 自动缩放到 `960 x 540`。如果用 1280x720 设计,必须在提交前整体换算到目标画布,或在回读 XML 后验证没有越界。
- 生成时为所有主体元素预留安全区,建议 `x >= 48``y >= 40``right <= 912``bottom <= 500`。全屏背景可以铺满 `0,0,960,540`,但主体文字、图表和卡片仍应留在安全区内。
- 回读 XML 后必须检查主体元素边界:非背景元素的 `topLeftX + width <= 960``topLeftY + height <= 540`。任何页面越界都视为生成失败,需要重排或缩放后重建。
## 几何必填属性
SVGlide leaf shape 必须显式写出服务端建模所需的几何属性,不依赖 SVG 默认值。缺失这些属性通常会被服务端包装成 `shape missing required attribute` 或 generic invalid param。
| Element | Required attributes |
|---------|---------------------|
| `rect slide:role="shape"` | `x`, `y`, `width`, `height` |
| `foreignObject slide:role="shape" slide:shape-type="text"` | `x`, `y`, `width`, `height` |
| `image slide:role="image"` | `href`, `x`, `y`, `width`, `height` |
| `circle slide:role="shape"` | `cx`, `cy`, `r` |
| `ellipse slide:role="shape"` | `cx`, `cy`, `rx`, `ry` |
| `line slide:role="shape"` | `x1`, `y1`, `x2`, `y2` |
| `path slide:role="shape"` | `d` |
| root 直系 `g slide:role="chart"` | `slide:chart-ref`, `x`, `y`, `width`, `height` |
这些属性即使取值为 `0` 也要写出来。例如背景图必须写成:
```xml
<image slide:role="image" href="@./background.jpg" x="0" y="0" width="960" height="540" />
```
CLI 会把这些几何属性作为生成质量门禁:值只能是数字或 `px` 长度,例如 `0``1280``320.5``80px`。不要使用 `%``em``rem``calc(...)` 或省略单位后依赖 SVG 默认值。服务端可能会对部分非法几何值降级为 `0` 并给 warning但正式生成应在 CLI 侧提前修正。
## 当前支持的 SVG 子集
- Shape: `rect``ellipse``circle``line``path``foreignObject`
- Container: `g`、嵌套 `svg`
- Chart marker: root 直系 `g slide:role="chart"`,用于透传 canonical JSON chart spec。
- Definitions: `defs` 内的 `linearGradient``radialGradient``filter/feDropShadow`;支持嵌套 `defs` 和 gradient `href` / `xlink:href` 继承。
- CSS: tag、`.class``#id``.a.b``tag.class` 等简单 selector支持 specificity 和 source order复杂 selector、media query、伪类会被忽略。
- Paint: `fill``stroke``stroke-width``opacity``fill-opacity``stroke-opacity``stroke-dasharray``stroke-linecap``stroke-linejoin`
- Gradient: `stop-color` / `stop-opacity` 可来自属性、inline style 或 CSS`gradientTransform``spreadMethod`、focal 点等复杂能力会被近似或忽略。
- Effects: 支持 `filter="url(#...)"` 指向的 `feDropShadow`、CSS `filter: drop-shadow(...)`、以及首层 `box-shadow`;多层 shadow、spread、inset 会被近似或忽略。
- Transform: `translate``scale``matrix``rotate`transform 参数应写数字或 `px`,复杂 transform 会被近似或忽略。
- 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 写法 |
|---|---|
| 根级 `<text>` / 普通 SVG text | `foreignObject slide:role="shape" slide:shape-type="text"`,并显式写 `font-size``font-weight``color``line-height` |
| `<polygon>` / `<polyline>` | 改成 `path slide:role="shape"`,只使用 `M/L/H/V/C/Q/Z` |
| `<marker>` 箭头 | 用独立三角形 `path` 或短 line + arrowhead path 显式绘制 |
| `<pattern>` 网格、点阵、纹理 | 用重复的 `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 视为错误 |
| `<image opacity="...">` | 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(...)` 停靠点。
图片透明度当前不是稳定协议面:`<image opacity="...">` 在 SVG 输入中会通过 CLI 传给服务端,但转换后的 Slides XML `<img>` 不一定保留 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 复核。
## Chart Spec Marker
当 SVG 页面需要创建原生 chart 时,使用 root 直系 chart marker。payload 是 canonical JSON bytes先对这段 decoded JSON bytes 计算 `sha256`,再用无 padding base64url 写入 metadata 文本:
```xml
<g slide:role="chart"
slide:chart-ref="chart-sales-001"
x="80" y="96" width="420" height="260">
<metadata
data-svglide-chart="svglide-chart-inline/v1"
data-format="svglide-chart-spec-v1"
data-encoding="base64url-json"
data-payload-hash="sha256:<64 hex>"
>BASE64URL_PAYLOAD</metadata>
</g>
```
Decoded canonical JSON shape:
```json
{"version":"svglide-chart-spec/v1","chartType":"bar","data":{"categories":["Q1","Q2"],"series":[{"name":"Revenue","values":[12.5,18]}]}}
```
约束:
- `g slide:role="chart"` 必须是 root `<svg>` 的直系子节点,不能嵌套在普通 `g` / 嵌套 `svg` 中。
- `slide:chart-ref``x``y``width``height` 必填bbox 数值只能是数字或 `px` 长度。
- marker 内必须且只能有一个 `<metadata>` 子节点。
- metadata 必须声明 `data-svglide-chart="svglide-chart-inline/v1"``data-format="svglide-chart-spec-v1"``data-encoding="base64url-json"``data-payload-hash="sha256:<64 hex>"`
- metadata 文本是无 padding 的 base64url payload解码后必须是 canonical JSON chart spec。不要把 SXSD `<chart>` XML 放入 SVGlide chart marker。
- JSON spec 必须包含 `version="svglide-chart-spec/v1"``chartType``data.categories``data.series[].name``data.series[].values`。MVP 只支持 `chartType="bar"` / `"line"``categories` 和每个 `values` 数组长度必须一致;`values` 只能是有限 JSON number。
- CLI 校验上述结构、hash 和基础数据合法性,不会为 chart 调用任何额外 API。请求体仍是 `{ "slide": { "content": "<svg ...>" } }`
- 不要把 chart marker 当成 SVG 手绘微图表的替代品;如果只是视觉小图、指标条或仪表盘装饰,仍优先用 SVGlide-safe shape/path 直接绘制。
## 不支持
- 不要把普通 SVG 直接交给 `+create-svg`CLI 不会自动补齐 SVGlide 协议。
- 不支持缺少 role 的可渲染元素,例如 `<rect .../>`;必须写成 `<rect slide:role="shape" .../>`
- 不要把 `<g>` 当作可渲染 shape`<g>` 只是容器,实际 `rect``path``foreignObject``image` 等子元素仍需各自声明 `slide:role`
- 不支持根级 `<text slide:role="text">`;用 `foreignObject + slide:shape-type="text"`
- 不要在 `<image>` 上保留 `xlink:href`CLI 会统一输出 canonical `href`
- 不支持 `slide:role="whiteboard"`,也不支持旧的 `data-svglide-whiteboard` SVGlide whiteboard markerwhiteboard 内容必须走 XML/whiteboard 路径。
- 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 冒充配图。Preview 阶段图片是拉开 SVGlide 和 XML 生成差距的关键能力:宣传、产品、品牌、案例、教学和视觉展示型 deck 应优先根据用户 query、deck 主题和页面标题去网络检索并拉取强相关图片,再包含封面/半出血主视觉/案例场景/产品截图/材质纹理/图鉴图等图片使用;只有用户明确要求纯矢量,或图片获取、上传链路完全不可用时,才退回纯矢量方案,并在结果中说明原因。
图片资产采用双模式:
- **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 使用 `<image>` 时,对应 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 只 warningpreview 中可用 `license=preview_unverified` 明确标记,正式交付仍应补齐或替换为来源清晰的图片资产。
`slides +create-svg` 会把 `<image href="@./image.png">` 上传为 file token并注入
```xml
<metadata data-svglide-assets="true">
<img src="boxcn..." />
</metadata>
```
metadata 只用于让现有服务端链路生成 `FileMetaMap`。如果使用 `--assets assets.json` 传入预上传 tokenCLI 也会按同样规则替换和注入。
`assets.json` 格式:
```json
{
"@./image.png": "boxcn...",
"./other.png": "boxcn..."
}
```

View File

@@ -0,0 +1,127 @@
{
"version": "2026-06-15",
"source": "SVGlide public runtime visual recipe registry",
"notes": [
"This file is the runtime source of truth for public SVGlide visual_recipe ids.",
"Human-readable docs such as svg-visual-recipes.md must describe this registry, not redefine it.",
"Research docs may use dotted recipe taxonomy, but slide_plan.json must use these underscore ids or route_private."
],
"recipes": {
"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"]
}
},
"chart_type_contracts": {
"kpi_cards": {
"description": "2x2 or 1x4 KPI scorecard with readable hero numbers and micro chart evidence.",
"min_card_like_rect": 2,
"min_typography_boxes": 4,
"recommended_visual_recipe": "infographic_scorecard"
},
"bar_chart": {
"description": "Single-series vertical bar chart with at least three visible bars.",
"min_bar_like_rect": 3,
"recommended_visual_recipe": "infographic_scorecard"
},
"horizontal_bar_chart": {
"description": "Ranking chart with at least three visible horizontal or bar-like rows.",
"min_bar_like_rect": 3,
"recommended_visual_recipe": "infographic_scorecard"
},
"dumbbell_chart": {
"description": "Before/after comparison with repeated connector lines and endpoint nodes.",
"min_line_or_path": 3,
"min_round_nodes": 4,
"recommended_visual_recipe": "path_flow"
},
"bubble_chart": {
"description": "Multi-axis bubble layout with at least three round data marks.",
"min_round_nodes": 3,
"recommended_visual_recipe": "geometric_composition"
},
"donut_chart": {
"description": "Donut or ring composition with a center KPI and segment evidence.",
"min_round_nodes": 2,
"min_typography_boxes": 3,
"recommended_visual_recipe": "infographic_scorecard"
},
"comparison_table": {
"description": "Dense row/column comparison matrix.",
"min_typography_boxes": 6,
"recommended_visual_recipe": "geometric_composition"
},
"sankey_chart": {
"description": "Magnitude flow from source to nodes to sink.",
"min_path": 3,
"min_typography_boxes": 3,
"recommended_visual_recipe": "path_flow"
},
"pareto_chart": {
"description": "Descending bars plus cumulative or risk line.",
"min_bar_like_rect": 3,
"min_line_or_path": 1,
"recommended_visual_recipe": "infographic_scorecard"
},
"hub_spoke": {
"description": "Central hub with at least four surrounding nodes and spoke evidence.",
"min_round_nodes": 5,
"min_line_or_path": 3,
"recommended_visual_recipe": "icon_capability_map"
},
"dual_axis_line_chart": {
"description": "Two line series showing divergence or comparison over time.",
"min_path": 2,
"recommended_visual_recipe": "path_flow"
},
"quadrant_text_bullets": {
"description": "2x2 framework with four visible cells.",
"min_card_like_rect": 4,
"recommended_visual_recipe": "geometric_composition"
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,111 @@
# SVGlide 视觉 Recipe
这份文档是 `slides +create-svg` 的短版可执行 recipe 指南。
运行时 public recipe 真源是同目录下的 [svg-recipes.json](svg-recipes.json)
`svg_preflight.py` 直接读取该 registry。本文只做人类可读说明和选择指南
不要在本文中重新定义一套与 registry 不一致的 runtime catalog。
更完整的研究资料保留在 CLI skill 外;公开生成上下文只使用
`svg-recipes.json` 中列出的 underscore runtime id。
## 边界
- `visual_recipe` 定义页面结构,以及这一页为什么值得用 SVG。
- `style_preset` 定义视觉语言、配色、纹理、密度和 motif。
- `renderer_id` 定义具体几何渲染器。
- `seed_id` / `layout_skeleton_id` 定义可校验版式骨架和容量预算。
不要用 `style_preset` 替代 `visual_recipe`。不要在 `slide_plan.json`
里发明新的 recipe id。`visual_recipe` 也不能替代 seed skeleton不能放宽
`text_budget_by_role``footer_safe_zone``vertical_text_policy`
## 硬默认值
- 画布:`width="960" height="540" viewBox="0 0 960 540"`
- 安全区:关键文本、标签、图表、卡片、节点和图例保持在
`x=48..912` and `y=40..500`.
- 网格:使用稳定的 12 栏或 8px 步进布局,避免临时手调坐标。
- 文本:中文正文每行控制在约 28 个字;英文正文每行约 62 个字符。
- Role budget标题、正文、callout、label、footer 必须分别遵守 seed 的 `text_budget_by_role`
- Footerfooter/source/legal/page mark 只进入 `footer_safe_zone`正文、图例、chart label 和标签不得侵入。
- 竖排:默认禁用竖排正文、`writing-mode``text-orientation` 和旋转长文本;只有 seed 明确允许的短装饰标签可使用。
- 装饰:装饰线、水印、纹理和背景几何不能抢夺标题/焦点内容的注意力,也不能贴住它们。
- Deck 多样性8 页以上 SVG deck 应至少使用 5 种 visual recipe family。
## Plan 字段
写 SVG 前,每个 SVG 页面 plan 都必须包含这些字段:
```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
`slide_plan.json` 中使用 `svg-recipes.json` 支持的 underscore id。
| 用户意图 | `visual_recipe` | SVG source 中必须体现 |
|---|---|---|
| 封面、章节开场、hero 观点页 | `hero_typography` | 大字、几何承载体、清晰焦点对象 |
| 战略框架、强几何版式 | `geometric_composition` | 非卡片式几何、`path` 或异形区域 |
| 路线图、旅程、流程、路径 | `path_flow` | 显式 path/line 主线、箭头或阶段标记 |
| KPI、战报、数据复盘 | `infographic_scorecard` | 大数字加微图表或仪表几何 |
| 能力地图、模块总览 | `icon_capability_map` | 风格统一的 SVG-safe 图标和标注区域 |
| 层次、氛围、概念强调 | `gradient_depth` | 渐变或分层半透明几何,并保证文字可读 |
| 产品/成果/图片叙事 | `mask_clip_showcase` | 图片区域加安全的 overlay/crop 模拟 |
| 技术系统、网格、编码质感 | `technical_texture` | 重复 line/dot/rect、网格、扫描线或图解纹理 |
| 闭环、飞轮、反馈系统 | `metaphor_loop` | 闭合路径或循环流程,并带输入/输出标签 |
| 诊断、callout、焦点标注 | `spotlight_annotation` | 高亮区域、callout 线、标注目标 |
| Dashboard、控制台、监控界面 | `fake_ui_dashboard` | UI frame、状态栏、指标、微图表/日志行 |
| 品牌或系列身份页 | `brand_system` | 稳定标题系统、motif、配色和重复身份元素 |
## 安全 Effects
优先使用可以由 SVGlide-safe primitives 表达的效果:
- `path`:曲线、波形、路线、自定义形状。
- `gradient`:背景层次和重点强调;关键文字必须有稳定承载底。
- `texture`:重复的 `line``circle``rect`;不要只依赖 `<pattern>`
- `connector_flow`:显式 line/path 加箭头三角或圆点。
- `chart_geometry`:柱、点、线、仪表、坐标轴和标签。
- `grid_geometry`:矩阵、表格式视觉摘要、结构化对齐网格。
- `watermark_text`:低对比大字,不能影响阅读。
- `image_overlay`:真实图片加显式半透明 shape 覆盖层。
- `spotlight`:分层半透明形状,不依赖复杂 filter 光效。
## 高风险 Effects
这些效果只有在 `risk_flags` / `recipe_fallback` 中声明了安全改写或
fallback 时,才允许出现在 visual planning 中:
- `filter`
- `mask_clip`
- `pattern`
- `symbol`
- `stroke_dasharray`
- `image_opacity`
关键视觉在调用 `slides +create-svg` 前,应改写成显式 shape、line、dot、
overlay或预合成图片。
## 反退化规则
- 如果页面主要只是 `rect + foreignObject`,还不足以证明值得走 SVGlide
除非它同时具备真实 SVG-native 结构path、chart geometry、icon system、
texture、spotlight、dashboard frame、connector flow 或 image overlay。
- 第一眼看到的对象应该和该页 `visual_focal_point` 一致。
- 相似页面可以共享 `style_preset`,但不能只替换文案和背景色,布局骨架完全不变。
- 研究笔记里的 dotted recipe 名称不是有效运行时 id。
写入 plan 前必须映射到上面的 underscore id。

View File

@@ -0,0 +1,28 @@
# SVGlide Reference Absorption Matrix
This matrix tracks which high-quality presentation-generation capabilities have been absorbed into the SVGlide SVG route. It is intentionally written in SVGlide terms: the CLI must run independently and must not depend on an external reference project at runtime.
| Capability | Status | CLI Landing Area | Acceptance Evidence |
|---|---|---|---|
| Project state machine | absorbed | `svglide_project_runner.py` | stages emit receipts and `receipts/timings.json` |
| Source pack | absorbed | `source/source_pack.json`, `slide_plan.source_pack` | generation receipt includes `source_pack_digest` and status |
| Design spec lock | planned | `source/design_spec.json`, `slide_plan.strategy_locks` | strategy receipt lists mode, visual style, style preset, chart policy |
| Renderer registry | planned | `svglide-renderer-registry.json` | active renderers validate against seed and recipe catalogs |
| Layout contracts | absorbed | `svg-seeds.json` | active slides carry `seed_id`, `layout_boxes`, budgets, safe zone |
| Visual recipes | absorbed | `svg-recipes.json` | active slides carry `visual_recipe` and required primitives |
| Style system | absorbed | `style-presets.json` | active plan carries `style_preset` and `style_system` |
| Design pattern selection | absorbed | `design_pattern_selection`, `receipts/design-pattern-usage.json` | selected assets are proven by component report geometry |
| Renderer assetization | planned | `svglide_gen_runtime.py`, renderer registry | each active renderer has page kind, seed, recipe, and runtime family |
| Chart geometry verification | planned | `chart_verify` runner stage | chart pages emit `receipts/chart-verify.json` before quality gate |
| Preview lint | absorbed | `svg_preview_lint.py` | `preview_lint` receipt includes score, issues, and validation profile |
| Quality gate | absorbed | `quality_gate` runner stage | gate aggregates preflight, preview lint, components, design usage |
| Timing receipt | absorbed | `receipts/timings.json` | every runner stage records elapsed time and over-budget status |
| Golden smoke suite | absorbed | `svglide_golden_suite.py` | built-in cases cover AI capital, Aksu oasis, runtime smoke |
| Editable PPTX export | not_applicable | outside SVGlide SVG route | SVG route publishes through `slides +create-svg` |
| PowerPoint animation/audio/video | not_applicable | outside SVGlide SVG route | not required for Lark Slides SVG create flow |
## Rules
- Runtime assets must use SVGlide-native names.
- External examples can inform contracts, but raw files are not copied into the CLI runtime path.
- A capability is not `absorbed` unless it has a receipt, validator, test, or golden case that proves it is exercised.

View File

@@ -0,0 +1,166 @@
# SVGlide Craft
这份文档是 `slides +create-svg` 的短版 craft 规则。它只约束 SVG 生成质量,不改变 `svg-protocol.md``lark-slides-create-svg.md` 的硬协议。
## Context Order
生成 SVGlide SVG 时按这个顺序理解上下文:
```text
svg-protocol.md
-> lark-slides-create-svg.md
-> style-presets.json / style-presets.md
-> svg-seeds.json
-> svg-recipes.json / svg-visual-recipes.md
-> svglide-craft.md
-> slide_plan.json
-> SVG source
-> svg_preflight.py
-> preview review
-> dry-run / live create / readback
```
## Open Design Local Adaptation
SVGlide 复制 Open Design 的生成控制体系,不复制 HTML runtime/CSS。不要迁移 `1920x1080` stage、runtime.js、localStorage、keyboard navigation、CSS animation、Chart.js 或 canvas FX。SVGlide 输出仍然是 `960 x 540` protocol SVG再由 Slides 服务转成 slide snapshot。
生成顺序:
```text
choose style_preset
-> choose seed_id from svg-seeds.json
-> keep seed layout_skeleton / layout_boxes / text_budget_by_role / footer_safe_zone / vertical_text_policy
-> replace content inside the existing boxes
-> verify content_budget / text_capacity / role text budget
-> write SVG
-> run svg_preflight.py
```
每页 plan 必须有 `seed_id``layout_skeleton_id``layout_boxes``content_budget``text_capacity``text_budget_by_role``one_idea``key_message``reserved_bands.footer``footer_safe_zone``vertical_text_policy`。如果内容放不进 seed不要从空白画布重画先删内容、拆页或换一个更合适的 seed。
## SVGlide Design Pattern Lessons
SVGlide 内置设计模式的关键是页型先行和节奏先行。生成时只能借鉴流程与结构合同,不复制 PPTX 导出、DrawingML 限制或 raw SVG path。
每页先锁定:
```json
{
"page_rhythm": "anchor | breathing | dense",
"page_type": "cover | editor_note | contents | chart_takeaway | chapter | closing",
"chart_type": "bar_chart | sankey_chart | hub_spoke | ...",
"main_visual_anchor": "the visible chart/scene/motif that makes this page memorable",
"annotation_zone": {"role": "right_observation", "x": 690, "y": 126, "width": 206, "height": 246},
"reference_asset": {"source": "svglide_design_pattern", "asset_id": "chart.bar_chart", "usage": "geometry pattern only"}
}
```
硬规则:
- `page_rhythm` 要有起伏anchor/breathing 页给叙事留气口dense 页才承载图表密度。
- `main_visual_anchor` 必须能在截图里一眼看到;标题、三 bullet、普通卡片不算 anchor。
- `chart_type` 一旦声明SVG source 必须画出对应几何bar 要有多根 barsankey 要有多条 flow pathhub 要有中心节点和 spokesquadrant 要有 2x2 区块。
- `bubble_chart``donut_chart` 不能退化成普通卡片页bubble 至少用多枚圆形节点表达规模/关系donut 至少用环形/圆形结构、分段和中心 KPI 表达构成。
- dense 页的信息密度必须由 chart/table/flow/hub/quadrant 承担,不能靠堆文字或装饰线。
- 图片 atmosphere 只服务 cover/chapter/showcase图片必须无可见文字、预留 SVG 标题负空间,并有 asset_contract。
- 浅字、白字、name-plate、label-back、badge 和 pill 必须有独立承载面;底板不能压住 note、source、正文或图表标签。
- 高饱和红/金等强调色只用于核心数字、风险、章节锚点或极少数对比线,不能把每个组件都染成同等权重。
硬默认:
- 不从空白 SVG 起稿seed skeleton 是版式合同,不是风格灵感。
- `layout_boxes` 只能在 seed 容忍范围内微调;大改结构先换 seed 或新增 seed。
- `text_budget_by_role` 只能收紧不能放宽;局部 title/body/callout/footer 超量时删内容、拆页或换 seed。
- `footer/source/legal/page mark` 只放 `footer_safe_zone`正文、图例、chart label、标签和解释文字不得进入或贴近 footer band。
- 默认禁止竖排正文、`writing-mode``text-orientation` 和旋转长文本;只有 seed 允许的短装饰标签可保留。
## Layout And Typography
- 默认画布 `960 x 540`,关键内容保持在 `x=48..912``y=40..500`
- 先规划 `titleBox``visualBox``textBox``chartBox``calloutBox``imageBox``connectorPath`,再写 SVG。
- `layout_boxes` 必须来自 seed并且在 plan 中显式记录;生成 SVG 时所有标题、正文、图表、callout、footer 坐标都从这些盒子推导。
- footer/source/legal/page mark 只放在 `reserved_bands.footer` / `footer_safe_zone`正文、callout、label、legend、chart label 不能侵入 footer band。
- label / chip / badge / 装饰块不能覆盖可读文字;它们要么有自己的短文本盒,要么离正文和竖排说明保持明确间距。
- badge / pill 到标题至少 12-16px装饰线到标题至少 18-24px标题底部到任何 text surface 至少 24px。
- 中文正文每行约 18-28 字;英文正文每行约 45-62 字符。
- 正文不低于 14px图表标签不低于 11px。
- 修重叠和溢出时重算 layout boxes不要只整体缩小。
## Text Surface Contract
承载可见文字的区域不能默认裸白底黑字。使用一种 preset 派生 surface
- `accent_rail_card`
- `tinted_panel`
- `glass_overlay`
- `dark_backing`
- `label_chip`
- `metric_tile`
禁止 connector line 穿过文字;禁止 label chip 承载长句;禁止多页重复裸白卡片。
禁止把可见文案放进隐藏或裁切容器:`display:none``visibility:hidden`、近零 opacity、`overflow:hidden``clip-path``mask` 都不能作为“塞下文本”的办法。
## SVG Advantage
每页必须声明并实现:
```json
{
"visual_signature": "curved route path plus ownership badges",
"svg_effects": ["path", "connector_flow", "typography"],
"xml_like_risk": "without the route path this becomes ordinary bullets"
}
```
可接受的 SVG advantage
- path / route / flow spine
- chart geometry
- dashboard / grid geometry
- image overlay
- spotlight annotation
- technical texture
- watermark text
- brand motif
不足以证明 SVG advantage
- 标题 + bullets
- 普通白卡片
- 换色背景
- 单个静态 emoji/icon
- plan 声明了效果但 SVG source 中不存在。
## Anti AI Slop
live create 前必须清理:
- lorem、placeholder、`点击添加正文`、demo data
- 编造数字、客户、年份、来源;
- source token、local path、preset 名、prompt、tool 名泄漏到可见文本;
- 默认蓝紫泛科技渐变;
- emoji 当图标系统;
- 连续多页同一三卡片结构;
- 空图片框、破图、未记录 preview-only 来源。
## Asset Lanes
| Lane | 用法 | 门禁 |
|-|-|-|
| `svg_reference_only` | 只参考外部 SVG 构图、线条、留白和配色。 | 必须有 `reference_source_contract`;不得复制 path/symbol/group。 |
| `preview_image` | 预览阶段使用真实图片或截图增强效果。 | 必须记录 `retrieval_query``source_url``license=preview_unverified``replacement_required=true`。 |
| `production_asset` | 正式交付使用的图片、图标、截图。 | 必须使用用户提供、自有、明确授权或可商用资产。 |
不要用纯 SVG lane 成功证明 image-token lane 一定成功;图片页必须单独 smoke/readback。
## Quality Gate
继续 live create 的条件:
- `svg_preflight.py` 无 error
- 本地 preview 已产出并记录 `preview_path``issue_ids``visual_score``action`
- 只有 `action=create_live` 才能继续 live APIP0 或未记录 preview action 时必须 `repair_and_rerun`
- P1 已修复或用户明确接受草稿;
- dry-run 请求结构符合预期;
- readback 风险、图片 token 风险和 fallback 选择被记录。

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,333 @@
# SVGlide Project Pipeline
This document owns local project execution artifacts for `slides +create-svg`.
It does not replace `slide_plan.json`, `svg-protocol.md`, `lark-slides-create-svg.md`,
or `svg_preflight.py`.
## Scope
The pipeline standardizes how a generated SVGlide deck is stored, resumed, timed,
validated, and published.
It owns:
- `.lark-slides/plan/<deck-id>/project_manifest.json`
- `.lark-slides/plan/<deck-id>/state.json`
- `pages/`, `prepared/`, `preview/`, `logs/`, and `receipts/`
- the stage order and runner receipts
It does not own:
- SVG protocol syntax
- chart creation protocol
- seed / recipe / style semantics
- image upload implementation
- Lark Slides server behavior
- PPTX or DrawingML compatibility
## Stage Order
```text
source -> strategy -> generate -> prepare -> preview -> preflight -> preview_lint -> chart_verify -> quality_gate -> dry_run -> ppe_proof -> live_create -> readback
```
`render_contact_sheet` is an optional artifact stage after readback/raster
receipts. It is not part of the default `--until dry_run` authoring path.
`dry_run` and `live_create` must consume `prepared/*.svg`, never the authoring
`pages/*.svg`.
## Directory Layout
```text
.lark-slides/plan/<deck-id>/
project_manifest.json
state.json
source/
brief.md
inputs.json
evidence.json
source_pack.json
design_spec.json
slide_plan.json
assets/
asset_manifest.json
pages/
page-001.svg
prepared/
page-001.svg
preview/
preview.html
logs/
source.log
strategy.log
generate.log
prepare.log
preview.log
preflight.log
preview-lint.log
chart-verify.log
dry-run.log
live-create.log
readback.log
receipts/
timings.json
env.json
source.json
strategy.json
prepare.json
preflight.json
preview-lint.json
chart-verify.json
quality-gate.json
dry-run.json
live-create.json
readback.json
```
## Manifest
`project_manifest.json` indexes execution files and commands. It must not redefine
design truth already owned by `slide_plan.json`.
```json
{
"deck_id": "example-deck",
"title": "Example Deck",
"lane": "pure_svg",
"plan": "slide_plan.json",
"source": {
"status": "user_prompt_only",
"brief": "source/brief.md",
"evidence": "source/evidence.json",
"source_pack": "source/source_pack.json"
},
"pages": [
{
"page": 1,
"source_svg": "pages/page-001.svg",
"prepared_svg": "prepared/page-001.svg"
}
],
"stage_commands": {
"generate": "python3 generate_deck.py",
"prepare": "builtin:copy_and_normalize_svg",
"preview": "python3 generate_preview.py"
},
"live_guard": {
"target_env": "ppe_pure_svg",
"requires_allow_live": true,
"requires_auth_verified": true,
"requires_proxy_receipt": true
}
}
```
Stage commands are parsed as argv and executed with `shell=False`. Project-local
commands run from the project directory. Commands under `skills/lark-slides/`
run from the CLI worktree root.
`slide_plan.json` is still the design source of truth. `project_manifest.json`
is the execution index. Before `prepare`, these counts must agree:
- `slide_plan.page_count`
- `len(slide_plan.slides)` when `slides` is present
- `len(slide_plan.svg_files)` when `svg_files` is present
- `len(project_manifest.pages)`
When the plan changes, regenerate or update the manifest in the same step.
Do not let `prepare`, `preflight`, `dry_run`, or `live_create` consume a stale
manifest after pages were added, deleted, or reordered.
## Source And Strategy Discipline
Pure topic input must first become structured source state before page SVG is
rendered. Store the original brief in `source/brief.md`; store source status,
evidence ids, numeric-claim policy, and missing-source notes in
`source/source_pack.json` or top-level `slide_plan.source_pack`. Research notes
or citation indexes belong in `source/evidence.json` when they exist. The
runner-owned `source` stage writes these files before generation when the
project starts from a prompt or manifest brief.
`slide_plan.json` must keep strategy decisions in the existing plan surface:
- `narrative_mode`: story mode, not visual style.
- `visual_style`: visual language target.
- `strategy_locks`: exactly eight locked decisions with `id`, `decision`, and
`evidence_ref`.
- `asset_strategy`, `chart_policy`, and `icon_policy`: deck-level selection
policy.
- page-level `source_refs`, `asset_selection_reason`,
`rejected_asset_alternatives`, `chart_decision`, and `chart_verification`.
The runner-owned `strategy` stage writes `source/design_spec.json` and refreshes
`slide_plan.json` with the current strategy locks, style system, renderer
selection, source pack reference, and design spec reference. `design_spec.json`
is a receipt-like summary for audit and comparison; `slide_plan.json` remains
the protocol-facing plan.
The runner fingerprints source files, plan, catalogs, generated SVG, prepared
SVG, and receipts. Source pack changes should invalidate receipts rather than
letting old generation or quality evidence be reused silently.
`source`, `strategy`, `preview_lint`, `preflight`, `chart_verify`,
`quality_gate`, `ppe_proof`, `dry_run`, `live_create`, and `readback` are
runner-owned stages. Do not override `preview_lint` through `stage_commands`;
the runner calls the bundled `scripts/svg_preview_lint.py` with a fixed
argument contract.
## Prepare
`prepare` creates deterministic CLI-ready SVG files under `prepared/`.
`dry_run` and `live_create` must consume `prepared/*.svg`, never authoring
`pages/*.svg` directly.
Allowed P0 behavior:
- copy `pages/*.svg` to `prepared/*.svg`
- normalize file placement and receipt metadata
- record input and output digests
Disallowed P0 behavior:
- silently simplify visual effects
- apply PPTX/DrawingML compatibility rewrites
- mutate authoring `pages/*.svg`
- replace images or tokens outside the existing `slides +create-svg` transport path
## SVGlide Design Pattern Receipt
SVGlide design pattern references are allowed only as structure, rhythm, chart geometry,
style, and review inspiration. They are not runtime dependencies and raw SVG/PPTX
assets must not be copied into SVGlide output.
For quality lanes, `selected_assets` means "actually used by the generated
pages", not "interesting candidates found during research". Candidate assets
stay in research notes. Used assets must be proven by
`receipts/design-pattern-usage.json` with page-level trace entries that point
to the SVG evidence. The quality gate must fail when `selected_assets` includes
an asset that is not present in the usage receipt.
Every mutation must be recorded in `receipts/prepare.json`.
Chart pages should additionally write `receipts/chart-verify.json` when data
coordinates are available. This receipt verifies source data against visible SVG
or native chart geometry: expected mark count, bar height/width, line points,
stacked proportions, labels, and plot-area alignment. When data is not available,
the plan must say so and avoid numeric claims.
## Runner
The runner command is:
```bash
python3 skills/lark-slides/scripts/svglide_project_runner.py run \
--project .lark-slides/plan/<deck-id> \
--cli ./lark-cli \
--until dry-run
```
Live creation requires explicit flags:
```bash
python3 skills/lark-slides/scripts/svglide_project_runner.py run \
--project .lark-slides/plan/<deck-id> \
--cli ./lark-cli \
--until readback \
--env ppe_pure_svg \
--env-proof receipts/env-proof.json \
--allow-live
```
P0 only allows `--env ppe_pure_svg` for live creation.
## Quality Gate
The project runner treats preview lint as a hard gate only in the project
quality lane. Manual debugging may continue from preflight to dry-run/readback
without `preview/preview.html`, but it must not proceed to guarded live creation
or production/golden delivery until preview lint and quality gate have passed.
`chart_verify` reads `slide_plan.json` and `prepared/*.svg`. When a slide
declares a required `chart_decision`, it writes `receipts/chart-verify.json`
proving that the expected chart carrier exists in the prepared SVG. This first
pass checks visible geometry and anchors; stricter data-to-coordinate checks can
extend the same receipt.
`quality_gate` reads the latest preflight receipt, preview lint receipt,
chart-verify receipt, raster report, allowlist, asset selection, visual design
contract, and component report evidence. If a slide declares
`visual_design_contract.required_visual_evidence`, the same page in
`receipts/emitted_components.json` must prove those evidence tokens through
component `effects`, `primitives`, `renderer_id`, or component id. During P0
migration, authoring/debug dry-run may use an unexpired legacy component
waiver. Production, golden, and live lanes must not use that waiver.
## PPE Proof
`ppe_proof` normalizes raw environment evidence into
`receipts/env-proof.json`. Raw proof may contain `observed_at_ms + ttl_ms`; the
runner writes a normalized `expires_at_ms` and live creation only reads the
normalized receipt.
## Receipts
Receipts are compact evidence files. Large raw command output goes under `logs/`.
Every receipt should include:
- stage name
- status
- elapsed time
- input digest and expanded `input_fingerprint`
- command argv when applicable
- log path
- parsed summary when available
`receipts/timings.json` aggregates stage elapsed times.
## Live Guard
`live_create` must refuse to run unless:
- `--allow-live` is present
- `--env ppe_pure_svg` is present
- auth verification succeeds
- `--env-proof` points to JSON evidence that `open.feishu.cn` was routed to
`open.feishu-pre.cn` with `Env=Pre_release` and `x-tt-env=ppe_pure_svg`
- `dry_run` passed after the latest `prepare`
- `quality_gate` passed strictly after the latest preflight and preview lint
- `ppe_proof` is fresh for the current CLI path/version, auth subject, target
host/headers, and smoke lane
- proxy/header configuration is recorded in `receipts/env.json`
- duplicate live creation is not already recorded
Proxy/header proof must be explicit for live creation. Local proxy presence alone
is recorded as `configured_not_observed` and is not enough to run `live_create`.
## Validation Profile
`validation_profile` may appear in `slide_plan.json` as a profile over existing
fields:
```json
{
"validation_profile": {
"mode": "svglide_project_pipeline",
"locked_fields": ["canvas", "style_preset", "style_system", "visual_recipe"],
"drift_policy": "warn_first"
}
}
```
It is not a second source of truth. It must not redefine canvas, style, recipe,
asset, or protocol values.
## Boundaries
- Do not add a top-level `svglide_plan_lock`.
- Do not introduce a fourth structure catalog.
- Do not modify Open Design for this rollout.
- Do not rewrite Go CLI code in P0.
- Do not copy external runtime or raw assets.
- Do not treat local preview as a replacement for live readback.

View File

@@ -0,0 +1,185 @@
{
"schema_version": "svglide-renderer-registry/v1",
"source": "SVGlide internal renderer contracts for create-svg authoring",
"rules": [
"Active renderers must map to an existing layout seed and visual recipe.",
"Active renderers must declare the runtime renderer family used by svglide_gen_runtime.py.",
"Candidate renderers may document planned coverage, but strategist must not select them automatically."
],
"renderers": [
{
"id": "cover_hero_statement",
"status": "active",
"page_kind": "cover",
"runtime_renderer_family": "layout.cover",
"layout_seed_id": "cover_hero_statement",
"visual_recipe_id": "hero_typography",
"style_reskin_hooks": ["background", "hero_route", "title_field", "motif"],
"required_primitives": ["typography", "geometric_shape", "path"]
},
{
"id": "agenda_numbered_path",
"status": "active",
"page_kind": "agenda",
"runtime_renderer_family": "chart.flow",
"layout_seed_id": "agenda_numbered_path",
"visual_recipe_id": "path_flow",
"style_reskin_hooks": ["numbered_route", "connector", "section_label"],
"required_primitives": ["typography", "path", "annotation"]
},
{
"id": "section_divider_index",
"status": "active",
"page_kind": "section",
"runtime_renderer_family": "layout.chapter",
"layout_seed_id": "section_divider_index",
"visual_recipe_id": "gradient_depth",
"style_reskin_hooks": ["section_index", "signal_field", "motif"],
"required_primitives": ["typography", "gradient", "geometric_shape"]
},
{
"id": "dashboard_kpi_grid",
"status": "active",
"page_kind": "kpi_cards",
"runtime_renderer_family": "chart.kpi",
"layout_seed_id": "dashboard_kpi_grid",
"visual_recipe_id": "fake_ui_dashboard",
"style_reskin_hooks": ["metric_card", "micro_trend", "insight_strip"],
"required_primitives": ["typography", "dashboard", "micro_chart"]
},
{
"id": "single_chart_takeaway",
"status": "active",
"page_kind": "bar_chart",
"runtime_renderer_family": "chart.bar",
"layout_seed_id": "single_chart_takeaway",
"visual_recipe_id": "infographic_scorecard",
"chart_types": ["bar_chart", "horizontal_bar_chart", "pareto_chart"],
"style_reskin_hooks": ["plot_area", "bars", "axis_label", "insight_strip"],
"required_primitives": ["typography", "micro_chart", "geometric_shape"]
},
{
"id": "timeline_roadmap",
"status": "active",
"page_kind": "timeline",
"runtime_renderer_family": "chart.flow",
"layout_seed_id": "timeline_roadmap",
"visual_recipe_id": "path_flow",
"style_reskin_hooks": ["phase_spine", "milestone", "ownership_label"],
"required_primitives": ["typography", "path", "annotation"]
},
{
"id": "process_pipeline",
"status": "active",
"page_kind": "process_flow",
"runtime_renderer_family": "chart.flow",
"layout_seed_id": "process_pipeline",
"visual_recipe_id": "path_flow",
"style_reskin_hooks": ["flow_lane", "connector", "input_output_anchor"],
"required_primitives": ["typography", "path", "annotation"]
},
{
"id": "comparison_two_column_decision",
"status": "active",
"page_kind": "comparison",
"runtime_renderer_family": "chart.matrix",
"layout_seed_id": "comparison_two_column_decision",
"visual_recipe_id": "geometric_composition",
"chart_types": ["comparison_table", "quadrant_text_bullets"],
"style_reskin_hooks": ["decision_axis", "contrast_panel", "dimension_label"],
"required_primitives": ["typography", "geometric_shape"]
},
{
"id": "capability_icon_map",
"status": "active",
"page_kind": "hub_spoke",
"runtime_renderer_family": "chart.hub",
"layout_seed_id": "capability_icon_map",
"visual_recipe_id": "icon_capability_map",
"chart_types": ["hub_spoke"],
"style_reskin_hooks": ["hub", "spoke", "module_icon", "orbit"],
"required_primitives": ["typography", "icon", "geometric_shape"]
},
{
"id": "spotlight_diagnosis_callout",
"status": "active",
"page_kind": "insight_callout",
"runtime_renderer_family": "contract.annotation",
"layout_seed_id": "spotlight_diagnosis_callout",
"visual_recipe_id": "spotlight_annotation",
"style_reskin_hooks": ["spotlight", "annotation_line", "callout_label"],
"required_primitives": ["typography", "spotlight", "annotation"]
},
{
"id": "closing_summary",
"status": "active",
"page_kind": "closing",
"runtime_renderer_family": "layout.closing",
"layout_seed_id": "closing_summary",
"visual_recipe_id": "brand_system",
"style_reskin_hooks": ["closing_ribbon", "action_card", "final_statement"],
"required_primitives": ["typography", "geometric_shape"]
},
{
"id": "bubble_chart",
"status": "active",
"page_kind": "bubble_chart",
"runtime_renderer_family": "chart.bubble",
"layout_seed_id": "single_chart_takeaway",
"visual_recipe_id": "infographic_scorecard",
"chart_types": ["bubble_chart"],
"style_reskin_hooks": ["plot_area", "bubble", "label_plate", "insight_band"],
"required_primitives": ["typography", "micro_chart", "geometric_shape"]
},
{
"id": "donut_chart",
"status": "active",
"page_kind": "donut_chart",
"runtime_renderer_family": "chart.donut",
"layout_seed_id": "single_chart_takeaway",
"visual_recipe_id": "infographic_scorecard",
"chart_types": ["donut_chart"],
"style_reskin_hooks": ["ring", "segment_label", "center_metric", "legend"],
"required_primitives": ["typography", "micro_chart", "geometric_shape"]
},
{
"id": "sankey_chart",
"status": "active",
"page_kind": "sankey_chart",
"runtime_renderer_family": "chart.sankey",
"layout_seed_id": "process_pipeline",
"visual_recipe_id": "path_flow",
"chart_types": ["sankey_chart"],
"style_reskin_hooks": ["flow_width", "source_node", "target_node", "return_path"],
"required_primitives": ["typography", "path", "geometric_shape"]
},
{
"id": "line_chart",
"status": "candidate",
"page_kind": "line_chart",
"runtime_renderer_family": "chart.line",
"layout_seed_id": "single_chart_takeaway",
"visual_recipe_id": "infographic_scorecard",
"chart_types": ["dual_axis_line_chart"],
"activation_blocker": "needs line data-to-coordinate chart verification fixture"
},
{
"id": "table_editorial",
"status": "candidate",
"page_kind": "table",
"runtime_renderer_family": "chart.table",
"layout_seed_id": "comparison_two_column_decision",
"visual_recipe_id": "geometric_composition",
"activation_blocker": "needs dense table preview lint fixture"
},
{
"id": "regional_image_story",
"status": "candidate",
"page_kind": "image_story",
"runtime_renderer_family": "layout.content",
"layout_seed_id": "image_story_showcase",
"visual_recipe_id": "mask_clip_showcase",
"activation_blocker": "needs asset receipt lane for image-backed story pages"
}
]
}

View File

@@ -44,6 +44,183 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
| `xml_not_well_formed` | XML 语法错误或文本未转义 | 修复标签闭合、属性引号、`&` / `<` / `>` 转义 |
| `bbox_overlap` | 文本元素的估算绘制区域明显重叠 | 拉开文本坐标、缩小文本框/字号,或改成明确的分栏/分组结构 |
## Automated SVGlide Plan And SVG Preflight
`slides +create-svg` 前,必须先运行 SVG plan/source preflight
```bash
python3 skills/lark-slides/scripts/svg_preflight.py \
--route-manifest skills/lark-slides/references/routes/create-svg/route.manifest.json \
--report-scope public \
--plan .lark-slides/plan/<deck-id>/slide_plan.json \
--input .lark-slides/plan/<deck-id>/pages/page-001.svg
```
通过标准:
- `summary.error_count == 0`,任何 error 都必须先修复再调用 live API。
- SVG 生成脚本必须先完整结束,再运行 `svg_preflight.py`;不要让生成和 preflight 并行读写同一 output 目录。
- `style_preset` 必须存在于 `references/style-presets.json`
- `seed_id` 必须存在于 `references/svg-seeds.json`,并且 plan 的 `layout_skeleton_id``layout_family``visual_recipe``layout_boxes``content_budget``text_budget_by_role``footer_safe_zone` 与 seed 一致。
- public `visual_recipe` 必须存在于 `references/svg-recipes.json`;研究文档里的 dotted recipe 名称不能直接写入 `slide_plan.json`
- `style_selection_reason` 必须说明为什么这个 preset 适合当前 deck。
- `style_system` 必须包含 palette、typography、background strategy 和 motif。
- 多页 deck 必须声明 `page_rhythm` / `deck_rhythm`用于检查节奏、密度变化和重复页风险authoring profile 下缺失会给 warning`validation_profile=golden` 或 strict profile 下为 error。
- 每页必须包含 `seed_id``layout_skeleton_id``layout_boxes``content_budget``text_capacity``text_budget_by_role``one_idea``key_message``reserved_bands.footer``footer_safe_zone``vertical_text_policy``visual_recipe``visual_signature``svg_effects``required_primitives``svg_primitives``xml_like_risk``content_density_contract``risk_flags``source_policy`
- Strategist contract 字段必须可检查:每页声明 `page_type`、可定位到 layout box / SVG element / component / bbox 的 `main_visual_anchor``reference_asset` 不能只写描述性文字,必须是 `{source, asset_id/id}` 或带 source/license/path 的资产元数据。
- declared `svg_effects``required_primitives` 必须能在对应 SVG source 中命中。
- 可见 slide 文本不得泄漏 preset 名称、source token、prompt、tool name 或本地文件路径。
常见 code 的处理方向:
| code | 含义 | 处理方式 |
|------|------|----------|
| `plan_style_preset_unknown` | plan 引用了不存在的 35 preset | 从 `style-presets.json` 选择有效 `style_id` |
| `plan_unknown_seed` | plan 引用了不存在的 `seed_id` | 从 `svg-seeds.json` 选择有效 seed |
| `plan_seed_visual_recipe_mismatch` | seed 和 `visual_recipe` 不匹配 | 换 seed 或换 recipe保持结构一致 |
| `plan_seed_layout_skeleton_missing` / `plan_seed_layout_skeleton_mismatch` / `plan_seed_layout_skeleton_drift` | plan 没有继承 seed skeleton或关键 layout box 偏离 seed 容忍范围 | 从 `svg-seeds.json` 复制 skeleton/boxes需要大改结构时新增或更换 seed |
| `plan_seed_content_budget_loosened` | plan 试图放宽 seed 的文本容量预算 | seed budget 是上限,只能收紧;超量时删内容、拆页或换 seed |
| `plan_missing_text_budget_by_role` / `plan_seed_text_budget_loosened` / `plan_text_role_budget_exceeded` | 缺少 role 级文本预算,或局部 title/body/callout/footer 超量 | 按 role 删减、拆页或换 seed不要缩小字号、隐藏文本或改竖排 |
| `plan_missing_layout_boxes` | plan 没有声明 seed 派生 layout boxes或缺必需 box role | 从 seed 复制并调整 title/body/visual/chart/footer boxes |
| `plan_text_box_count_exceeded` / `plan_source_text_box_count_exceeded` | plan 或最终 SVG 的文本盒数量超过 seed 上限 | 减少文字表面或换更高容量 seed |
| `plan_source_text_box_count_below_seed_minimum` | 最终 SVG 没保留 seed 要求的最低文本结构 | 补齐 seed 需要的可读文本盒,或换更稀疏 seed |
| `plan_content_budget_exceeded` / `plan_title_capacity_exceeded` / `plan_body_capacity_exceeded` / `plan_footer_capacity_exceeded` | 文案超过 seed 的容量预算 | 删减文案、拆页或换更合适的 seed |
| `plan_source_content_budget_exceeded` | 最终 SVG 可见文本超过 seed 容量 | 缩短实际渲染文案或拆页 |
| `plan_source_role_text_budget_exceeded` | 最终 SVG 某个 role 的字符数、文本盒、行数或字号违反 seed 预算 | 修 source SVG 对应 role不要只改 plan 字段 |
| `plan_text_box_outside_seed_layout_box` | 最终 SVG 文本盒偏离 seed 派生 layout box | 按 plan box 重排 SVG或先更新 plan box 再渲染 |
| `plan_footer_reserved_band_violation` | footer/source/note 文本不在 footer 保留区,或正文侵入 footer band | 调整 body/footer boxes让 footer 类文本落入 `reserved_bands.footer` |
| `plan_missing_footer_safe_zone` / `footer_safe_zone_intrusion` | 缺少 footer safe-zone或非 footer 文本进入/贴近 footer band | footer/source/legal/page mark 只放 zone 内;正文、图例、标签和 chart label 上移 |
| `plan_vertical_text_policy_missing` / `unsupported_vertical_text` / `vertical_text_disallowed_role` / `vertical_text_budget_exceeded` | 未声明竖排策略,或正文/长文使用竖排、writing-mode、旋转文本 | 默认改回横排;只有 seed 允许的短装饰标签可保留 |
| `label_text_overlap` / `right_title_safe_zone_crowded` | 标签、badge、装饰块或右上标题栏压住可读文本 | 移动标记、扩大文本承载面、拆分标题区或减少 chip 文案 |
| `plan_required_for_create_svg_route` | create-svg route 只传 SVG没传 `--plan` | 必须带 `slide_plan.json`,防止绕过 seed/recipe/layout gate |
| `hidden_visible_text` / `clipped_visible_text` | 可见文案被 hidden/opacity/overflow/clip-path/mask 隐藏或裁切 | 删除隐藏文案、扩大 text box或取消裁切 |
| `plan_unknown_visual_recipe` | plan 引用了不存在的 public recipe或把 dotted research id 当 runtime id | 从 `svg-recipes.json` 选择有效 underscore id或在 create-svg private route 中使用 `visual_recipe=route_private` |
| `plan_missing_visual_signature` | 页面没有声明 SVG 视觉记忆点 | 写清这页相对普通 PPT/XML 模板的独特视觉结构 |
| `plan_missing_svg_effects` | 页面没有声明 SVG 表达能力 | 声明真实会绘制的 `path``connector_flow``gradient``texture``chart_geometry` 等 |
| `plan_svg_effect_not_found` | plan 声明的 effect 没在 SVG source 中出现 | 修改 SVG source或删除不真实的 effect 声明 |
| `plan_missing_page_rhythm` | 多页 deck 没有声明节奏合同 | 添加 deck-level `page_rhythm`,说明封面/章节/内容/总结节奏和重复页约束 |
| `plan_missing_page_type` | 页面缺少可检查页型 | 添加 `page_type`,并让 renderer/layout/visual_recipe 与页型一致 |
| `plan_missing_main_visual_anchor` | 主视觉锚点缺失或只是自然语言 | 指向 layout box role、`#svg-element-id`、component_id或写明确 bbox |
| `plan_main_visual_anchor_not_met` | SVG source 没有在主视觉锚点区域生成可见几何 | 调整 source SVG把主视觉放回声明的锚点区域或先更新 plan anchor |
| `plan_reference_asset_unstructured` | `reference_asset` 是纯文字或缺少 source/id/path | 改成结构化 source metadata没有参考资产时显式写 no_asset |
| `plan_style_preset_visible_leak` | 可见文本泄漏 preset 名/source token | 仅在 plan metadata 中保留 preset 信息,画面只写用户主题内容 |
## SVGlide Aesthetic Preview Review
`svg_preflight.py` 通过后,走 `slides +create-svg` 前还必须做本地预览审查。读取 [svg-aesthetic-review.md](svg-aesthetic-review.md),检查 rendered preview而不是只看 plan 字段或静态 XML。
Project runner quality lane 还必须在 `dry_run` 前运行 `preview_lint`
`quality_gate`。手工排障路径可以不跑 preview lint 继续定位问题,但该路径
不得进入 guarded live creation、production delivery 或 golden regression promotion。
## SVGlide Archetype Drift Checks
SVGlide 项目必须同时检查计划、执行 manifest、SVG source 和 receipts。不要只
验证单个文件。
- `slide_plan.page_count``slide_plan.slides``slide_plan.svg_files`
`project_manifest.pages` 的数量必须一致;少传 SVG input 是 error不允许
preflight 只检查已传入的子集。
- `prepare` receipt 在 plan、manifest 或 source SVG 变化后必须失效;后续
`preflight``preview_lint``quality_gate``dry_run` 不得复用旧 prepare。
- 声明 `chart_type` 或 SVGlide design pattern chart 参考时SVG 必须命中对应几何合同:
`bubble_chart` 至少有多枚圆形节点,`donut_chart` 至少有环形/圆形结构和中心
文本,`bar_chart` 至少有可识别轴/条形/数值区域。不能把图表页退化成普通
卡片、closing 或 bullet list。
- `design_pattern_selection.selected_assets` 只放真正启用并落地的参考资产;
`enabled:false` 可作为候选保留,但不进入 quality gate。启用资产必须由
`receipts/design-pattern-usage.json` 的 page-level trace 证明。
- `visual_design_contract.required_visual_evidence` 必须由
`receipts/emitted_components.json` 的 page-level component `effects`
`primitives``renderer_id` 或 component id 证明。缺少 evidence 时
`quality_gate` 失败;这类问题不能只改 plan 字段,必须修 renderer 或 SVG。
- `quality_gate` 会把 preflight 中的 Strategist contract issue codes 写入
`strategist_contract` 摘要,并把 visual design contract 证明写入
`visual_design_contract` 摘要;`validation_profile=golden` 要求零 warning、结构化
component report以及正式 schema 的 design-pattern usage receipt。
- SVGlide design pattern 参考只允许变成 SVGlide-safe 的页型、图表几何、节奏、色彩纪律
和审查规则;不要复制 raw SVG、图片或 PPTX/DrawingML 导出实现。
通过标准:
- 所有页面都检查过,不只检查封面。
- 无标题、正文、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。
live creation 要求 `quality_gate.status=passed``passed_with_waiver` 只允许
authoring/debug dry-run不得用于 production、golden 或 live lane。
## Chart Data Verification
当页面声明 `chart_type`、chart marker、或图表类 reference asset 时,不能只检查
“有图表几何”。还要检查数据到视觉坐标的映射是否可信。
计划层必须包含:
```json
{
"chart_decision": {
"chart_type": "bar_chart",
"reason": "bar chart fits category comparison and supports one takeaway",
"data_ref": "brief",
"anchor_role": "chart",
"bbox_tolerance_px": 12
},
"chart_verification": {
"status": "required",
"receipt": "receipts/chart-verify.json",
"checks": ["plot_area", "mark_count", "label_alignment", "scale_mapping"]
}
}
```
验证记录建议写入 `receipts/chart-verify.json`,并至少包含:
- `data_source`: source pack id, inline chart spec id, or explicit unavailable marker.
- `chart_type`: normalized chart type.
- `mapping_formula`: how values map to bar height/width, line point y, stacked share, radar radius, or node/flow weight.
- `expected_marks`: expected bars, points, stacks, sectors, vertices, or flows.
- `verification.status`: `passed`, `failed`, or `not_applicable_missing_data`.
最终验证记录要写清:
```text
Chart data: checked N/N chart pages; failed M; missing data K.
```
若没有可信数据源,页面可以保留示意图,但必须在 `source_policy` 中写明 no numeric
claims不得伪造真实数值、排名、比例或来源。
## Live Create And Image Token Gate
`svg_preflight.py` 通过后,仍必须跑 `slides +create-svg --dry-run`。Dry-run 要确认:
- 请求链路是 create presentation 后按 `--file` 顺序追加 SVG 页。
-`@./assets/...` 的 SVG 会先出现 `medias/upload_all`,再在 page content 中出现 transport metadata。
- 纯 SVG 发布版不得残留 `<image>``@./assets``uploaded_file_token`
- 所有 `url(#id)` 引用都有对应 `defs` iddry-run 不一定能拦住未定义渐变。
`ppe_pure_svg` 或其他尚未稳定证明支持 image token 的 live lane先单独 smoke
- 一页纯 SVG验证 lane 支持 SVGlide parser。
- 一页含本地 `@./assets/...` 图片:验证 upload 后的 image token 能被 `/slide` 解析。
如果纯 SVG 页成功、图片页在上传成功后 `/slide``nodeServer internal error`,短期线上发布可切到单独 `online-pure` SVG 目录,用 shape、path、gradient 和 texture geometry 替代图片区域。这个 fallback 只用于 live 发布,不得覆盖带真实图片的 authoring preview并必须在最终交付说明中标注。
Project runner live lane 中,`ppe_proof` 必须把 raw environment evidence
标准化为 `receipts/env-proof.json``live_create` 只读取 normalized receipt。
proxy 仅配置但未观测到实际命中,不足以发布。
这一步和 preflight 分工如下:
- `svg_preflight.py`: 负责协议、plan、枚举、必填字段、bbox、primitive 命中和确定性错误。
- `svg-aesthetic-review.md`: 负责截图/预览视角的层级、节奏、压迫感、重复问题、可读性和 SVG 视觉优势。
## Page Count And Structure
- 实际页数必须等于用户要求或 `slide_plan.json` 的页数。

View File

@@ -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.

View File

@@ -0,0 +1,199 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from typing import Iterable
SVG_NS = "http://www.w3.org/2000/svg"
XLINK_NS = "http://www.w3.org/1999/xlink"
UNSAFE_TAGS = {"script", "iframe", "object", "embed"}
HARD_EFFECT_TAGS = {
"filter",
"mask",
"clipPath",
"pattern",
"symbol",
"use",
"marker",
"animate",
"animateTransform",
"animateMotion",
}
HARD_EFFECT_ATTRS = {"filter", "mask", "clip-path"}
HARD_STYLE_PROPS = {
"filter",
"backdrop-filter",
"mix-blend-mode",
"clip-path",
"mask",
"box-shadow",
}
UNSUPPORTED_PATH_COMMAND_RE = re.compile(r"[AaSsTt]")
DOCTYPE_RE = re.compile(r"<!DOCTYPE|<!ENTITY", re.IGNORECASE)
JAVASCRIPT_URL_RE = re.compile(r"javascript\s*:", re.IGNORECASE)
CSS_IMPORT_RE = re.compile(r"@import|url\(\s*['\"]?\s*https?://", re.IGNORECASE)
HTTP_URL_RE = re.compile(r"^https?://", re.IGNORECASE)
SVG_ROOT_RE = re.compile(r"<svg\b", re.IGNORECASE)
class SvgRasterSafetyError(ValueError):
"""Raised when an SVG is unsafe to parse or render."""
@dataclass(frozen=True)
class EffectDetection:
kind: str
reason: str
element_id: str = ""
tag: str = ""
attribute: str = ""
def as_dict(self) -> dict[str, str]:
out = {"kind": self.kind, "reason": self.reason}
if self.element_id:
out["element_id"] = self.element_id
if self.tag:
out["tag"] = self.tag
if self.attribute:
out["attribute"] = self.attribute
return out
def local_name(name: str) -> str:
if "}" in name:
return name.rsplit("}", 1)[1]
return name
def normalize_style(style: str) -> dict[str, str]:
out: dict[str, str] = {}
for item in style.split(";"):
if ":" not in item:
continue
key, value = item.split(":", 1)
key = key.strip().lower()
if key:
out[key] = value.strip()
return out
def is_hard_style_property(prop: str) -> bool:
prop = prop.strip().lower()
return prop in HARD_STYLE_PROPS or prop.startswith("mask-") or prop.startswith("clip-path")
def parse_svg(svg: str) -> ET.Element:
if not SVG_ROOT_RE.search(svg):
raise SvgRasterSafetyError("input is not an SVG document")
if DOCTYPE_RE.search(svg):
raise SvgRasterSafetyError("SVG contains DTD or external entity markup")
try:
root = ET.fromstring(svg)
except ET.ParseError as error:
raise SvgRasterSafetyError(f"SVG XML parse failed: {error}") from error
if local_name(root.tag) != "svg":
raise SvgRasterSafetyError("input root element must be <svg>")
return root
def _attr_value(attrs: dict[str, str], name: str) -> str:
for raw_name, value in attrs.items():
if local_name(raw_name) == name:
return value
return ""
def _href_value(attrs: dict[str, str]) -> str:
for raw_name, value in attrs.items():
if local_name(raw_name) == "href":
return value.strip()
return ""
def _is_external_stylesheet_or_script(tag: str, attrs: dict[str, str]) -> bool:
href = _href_value(attrs)
src = attrs.get("src", "").strip()
rel = attrs.get("rel", "").strip().lower()
type_value = attrs.get("type", "").strip().lower()
if tag == "script" and (src or href):
return True
if tag == "link" and rel == "stylesheet":
return True
if tag == "style" and type_value in {"text/javascript", "application/javascript"}:
return True
return False
def sanitize_or_reject(svg: str) -> ET.Element:
root = parse_svg(svg)
for elem in root.iter():
tag = local_name(elem.tag)
attrs = {local_name(k): v for k, v in elem.attrib.items()}
if tag in UNSAFE_TAGS:
raise SvgRasterSafetyError(f"unsafe SVG tag <{tag}> is not allowed")
if _is_external_stylesheet_or_script(tag, attrs):
raise SvgRasterSafetyError("external JavaScript or CSS is not allowed")
if tag == "style" and elem.text and CSS_IMPORT_RE.search(elem.text):
raise SvgRasterSafetyError("external CSS imports are not allowed")
for attr_name, value in attrs.items():
normalized_attr = attr_name.lower()
normalized_value = value.strip()
if normalized_attr.startswith("on"):
raise SvgRasterSafetyError(f"event attribute {attr_name} is not allowed")
if JAVASCRIPT_URL_RE.search(normalized_value):
raise SvgRasterSafetyError("javascript: URLs are not allowed")
if normalized_attr in {"href", "src"} and tag != "image" and HTTP_URL_RE.match(normalized_value):
raise SvgRasterSafetyError("non-image external resources are not allowed")
return root
def _detect_element(elem: ET.Element, root: ET.Element, parent: ET.Element | None = None) -> Iterable[EffectDetection]:
tag = local_name(elem.tag)
elem_id = elem.attrib.get("id", "")
if tag in HARD_EFFECT_TAGS:
yield EffectDetection("tag", f"unsupported SVG tag <{tag}>", elem_id, tag)
if parent is root and tag == "text":
yield EffectDetection("text", "root-level text requires raster or safe rewrite", elem_id, tag)
if tag in {"polygon", "polyline"}:
yield EffectDetection("shape", f"<{tag}> requires raster or safe rewrite", elem_id, tag)
if tag == "path" and UNSUPPORTED_PATH_COMMAND_RE.search(elem.attrib.get("d", "")):
yield EffectDetection("path", "path contains unsupported A/S/T commands", elem_id, tag, "d")
if tag == "foreignObject":
style = normalize_style(elem.attrib.get("style", ""))
rich_props = {"display", "position", "overflow", "transform", "background-image"} & set(style)
if style.get("display", "").lower() in {"flex", "grid"}:
yield EffectDetection("foreignObject", "foreignObject uses flex/grid layout", elem_id, tag, "style")
elif style.get("position", "").lower() in {"absolute", "fixed"}:
yield EffectDetection("foreignObject", "foreignObject uses absolute/fixed layout", elem_id, tag, "style")
elif style.get("overflow", "").lower() in {"hidden", "clip"}:
yield EffectDetection("foreignObject", "foreignObject clips HTML content", elem_id, tag, "style")
elif rich_props - {"display", "position", "overflow"}:
yield EffectDetection("foreignObject", "foreignObject uses rich CSS layout effects", elem_id, tag, "style")
for raw_attr, value in elem.attrib.items():
attr = local_name(raw_attr)
if attr in HARD_EFFECT_ATTRS:
yield EffectDetection("attribute", f"unsupported SVG attribute {attr}", elem_id, tag, attr)
if attr == "style":
style = normalize_style(value)
for prop in sorted(style):
if is_hard_style_property(prop):
yield EffectDetection("style", f"unsupported CSS property {prop}", elem_id, tag, "style")
def classify_effects(svg: str) -> list[EffectDetection]:
root = sanitize_or_reject(svg)
parents = {child: parent for parent in root.iter() for child in parent}
detections: list[EffectDetection] = []
for elem in root.iter():
detections.extend(_detect_element(elem, root, parents.get(elem)))
return detections
def detections_as_dicts(detections: Iterable[EffectDetection]) -> list[dict[str, str]]:
return [detection.as_dict() for detection in detections]

View File

@@ -0,0 +1,59 @@
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import unittest
import svg_effect_classifier as classifier
SAFE_SVG = """<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
<defs><linearGradient id="g"><stop offset="0" stop-color="#fff" /></linearGradient></defs>
<path d="M10 10 L100 10 C120 20 140 20 160 10 Q180 0 200 10 Z" fill="url(#g)" />
</svg>"""
class SvgEffectClassifierTest(unittest.TestCase):
def reasons(self, svg: str) -> list[str]:
return [detection.reason for detection in classifier.classify_effects(svg)]
def test_rejects_unsafe_input_before_render(self) -> None:
unsafe_inputs = [
"<!DOCTYPE svg><svg></svg>",
'<svg><script href="https://example.test/a.js" /></svg>',
'<svg><rect onload="alert(1)" /></svg>',
'<svg><image href="javascript:alert(1)" /></svg>',
'<svg><iframe src="https://example.test" /></svg>',
]
for svg in unsafe_inputs:
with self.subTest(svg=svg):
with self.assertRaises(classifier.SvgRasterSafetyError):
classifier.sanitize_or_reject(svg)
def test_detects_rich_svg_effects(self) -> None:
svg = """<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540">
<defs><filter id="glow" /><mask id="m" /><clipPath id="c" /><symbol id="s"><rect /></symbol></defs>
<use href="#s" filter="url(#glow)" />
<path d="M10 10 A40 40 0 0 1 80 80" />
<text x="10" y="20">Title</text>
<polygon points="0,0 10,0 10,10" style="mix-blend-mode:multiply" />
</svg>"""
reasons = "\n".join(self.reasons(svg))
self.assertIn("unsupported SVG tag <filter>", reasons)
self.assertIn("unsupported SVG tag <mask>", reasons)
self.assertIn("unsupported SVG tag <clipPath>", reasons)
self.assertIn("unsupported SVG tag <symbol>", reasons)
self.assertIn("unsupported SVG tag <use>", reasons)
self.assertIn("path contains unsupported A/S/T commands", reasons)
self.assertIn("root-level text requires raster or safe rewrite", reasons)
self.assertIn("unsupported CSS property mix-blend-mode", reasons)
def test_safe_gradient_path_is_not_flagged(self) -> None:
self.assertEqual(classifier.classify_effects(SAFE_SVG), [])
if __name__ == "__main__":
unittest.main()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,439 @@
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import contextlib
import io
import json
import shutil
import tempfile
import unittest
from pathlib import Path
import svg_preview_lint
def write_json(path: Path, data: object) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
class SvgPreviewLintTest(unittest.TestCase):
def make_project(self) -> Path:
root = Path(tempfile.mkdtemp())
self.addCleanup(lambda: shutil.rmtree(root, ignore_errors=True))
project = root / "demo"
(project / "preview").mkdir(parents=True)
(project / "prepared").mkdir()
(project / "assets").mkdir()
write_json(project / "slide_plan.json", {"svg_files": [{"page": 1, "path": "prepared/page-001.svg"}]})
return project
def write_preview(self, project: Path, ref: str = "../prepared/page-001.svg") -> None:
(project / "preview" / "preview.html").write_text(
f"<html><body><img src=\"{ref}\" /></body></html>",
encoding="utf-8",
)
def write_svg(self, project: Path, body: str) -> None:
(project / "prepared" / "page-001.svg").write_text(
f"""
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
{body}
</svg>
""",
encoding="utf-8",
)
def lint(self, project: Path, validation_profile: str = "") -> dict[str, object]:
return svg_preview_lint.lint_project(
project,
project / "preview" / "preview.html",
project / "slide_plan.json",
validation_profile,
)
def write_sparse_pages(self, project: Path, page_count: int, *, validation_profile: object | None = None) -> None:
refs = []
preview_images = []
for page in range(1, page_count + 1):
name = f"page-{page:03d}.svg"
refs.append({"page": page, "path": f"prepared/{name}"})
preview_images.append(f'<img src="../prepared/{name}" />')
(project / "prepared" / name).write_text(
f"""
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
<rect x="0" y="0" width="960" height="540" fill="#f8fafc" />
<path d="M80 420 C240 360 420 470 620 390" fill="none" stroke="#94a3b8" stroke-width="6" />
<text x="80" y="120" font-size="34" fill="#111827">Thin visual idea {page}</text>
<text x="80" y="172" font-size="18" fill="#334155">One short line is not enough structure.</text>
</svg>
""",
encoding="utf-8",
)
plan: dict[str, object] = {"svg_files": refs}
if validation_profile is not None:
plan["validation_profile"] = validation_profile
write_json(project / "slide_plan.json", plan)
(project / "preview" / "preview.html").write_text("<html><body>" + "".join(preview_images) + "</body></html>", encoding="utf-8")
def codes(self, result: dict[str, object]) -> list[str]:
checks = result.get("checks")
self.assertIsInstance(checks, list)
return [str(item.get("code")) for item in checks if isinstance(item, dict)]
def test_missing_preview_fails(self) -> None:
project = self.make_project()
self.write_svg(
project,
"""
<rect x="0" y="0" width="960" height="540" fill="#f8fafc" />
<text x="80" y="90" font-size="28" fill="#111827">Title</text>
<text x="80" y="150" font-size="18" fill="#334155">Body</text>
""",
)
result = self.lint(project)
self.assertEqual(result["status"], "failed")
self.assertEqual(result["action"], "repair_and_rerun")
self.assertIn("preview_missing", result["issue_ids"])
self.assertIn("preview_missing", self.codes(result))
def test_missing_svg_fails(self) -> None:
project = self.make_project()
self.write_preview(project)
result = self.lint(project)
self.assertEqual(result["status"], "failed")
self.assertIn("svg_file_missing", self.codes(result))
def test_svg_parse_failure_fails(self) -> None:
project = self.make_project()
self.write_preview(project)
(project / "prepared" / "page-001.svg").write_text("<svg><text>broken", encoding="utf-8")
result = self.lint(project)
self.assertEqual(result["status"], "failed")
self.assertIn("svg_parse_failed", self.codes(result))
def test_plan_refs_do_not_duplicate_preview_pages(self) -> None:
project = self.make_project()
(project / "pages").mkdir()
svg = """
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
<rect x="0" y="0" width="960" height="540" fill="#f8fafc" />
<rect x="80" y="120" width="220" height="120" fill="#dbeafe" />
<rect x="340" y="120" width="220" height="120" fill="#dcfce7" />
<text x="80" y="90" font-size="30" fill="#111827">Page title</text>
<text x="80" y="290" font-size="18" fill="#334155">Semantic label and supporting copy.</text>
</svg>
"""
preview_images = []
plan_refs = []
for page in (1, 2):
name = f"page-{page:03d}.svg"
(project / "prepared" / name).write_text(svg, encoding="utf-8")
(project / "pages" / name).write_text(svg, encoding="utf-8")
preview_images.append(f'<img src="../prepared/{name}" />')
plan_refs.append({"page": page, "path": f"pages/{name}"})
write_json(project / "slide_plan.json", {"svg_files": plan_refs})
(project / "preview" / "preview.html").write_text("<html><body>" + "".join(preview_images) + "</body></html>", encoding="utf-8")
result = self.lint(project)
self.assertEqual(result["page_count"], 2)
def test_preview_missing_plan_page_fails_even_when_plan_svg_exists(self) -> None:
project = self.make_project()
(project / "pages").mkdir()
svg = """
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
<rect x="0" y="0" width="960" height="540" fill="#f8fafc" />
<rect x="80" y="120" width="220" height="120" fill="#dbeafe" />
<text x="80" y="90" font-size="30" fill="#111827">Page title</text>
<text x="80" y="290" font-size="18" fill="#334155">Semantic label and supporting copy.</text>
</svg>
"""
plan_refs = []
for page in (1, 2):
name = f"page-{page:03d}.svg"
(project / "prepared" / name).write_text(svg, encoding="utf-8")
(project / "pages" / name).write_text(svg, encoding="utf-8")
plan_refs.append({"page": page, "path": f"pages/{name}"})
write_json(project / "slide_plan.json", {"svg_files": plan_refs})
(project / "preview" / "preview.html").write_text('<html><body><img src="../prepared/page-001.svg" /></body></html>', encoding="utf-8")
result = self.lint(project)
self.assertEqual(result["status"], "failed")
self.assertEqual(result["page_count"], 2)
self.assertIn("preview_missing_plan_page", self.codes(result))
def test_detects_obvious_text_overlap(self) -> None:
project = self.make_project()
self.write_preview(project)
self.write_svg(
project,
"""
<rect x="0" y="0" width="960" height="540" fill="#f8fafc" />
<text x="120" y="150" font-size="36" fill="#111827">Overlap text</text>
<text x="126" y="154" font-size="36" fill="#111827">Overlap text</text>
""",
)
result = self.lint(project)
self.assertEqual(result["status"], "failed")
self.assertIn("text_overlap", self.codes(result))
def test_detects_bubble_label_backing_overlapping_note(self) -> None:
project = self.make_project()
self.write_preview(project)
self.write_svg(
project,
"""
<rect x="0" y="0" width="960" height="540" fill="#111827" />
<circle id="bubble-openai" cx="460" cy="260" r="82" fill="#2563eb" />
<rect id="bubble-openai-name-plate" x="382" y="222" width="156" height="52" rx="10" fill="#0f172a" />
<text id="bubble-openai-label" x="408" y="254" font-size="18" fill="#ffffff">OpenAI</text>
<foreignObject id="bubble-openai-note" x="392" y="252" width="180" height="74" color="#e5e7eb">
<div xmlns="http://www.w3.org/1999/xhtml">Large funding round and GPU demand concentration.</div>
</foreignObject>
""",
)
result = self.lint(project)
self.assertEqual(result["status"], "failed")
self.assertIn("shape_text_overlap", self.codes(result))
def test_detects_light_text_without_dark_backing(self) -> None:
project = self.make_project()
self.write_preview(project)
self.write_svg(
project,
"""
<rect x="0" y="0" width="960" height="540" fill="#ffffff" />
<circle cx="720" cy="190" r="64" fill="#dbeafe" />
<text x="120" y="150" font-size="34" fill="#ffffff">Invisible title</text>
""",
)
result = self.lint(project)
self.assertEqual(result["status"], "failed")
self.assertIn("light_text_without_dark_backing", self.codes(result))
def test_normal_preview_passes_and_cli_outputs_schema(self) -> None:
project = self.make_project()
self.write_preview(project)
(project / "assets" / "hero.png").write_bytes(b"not-a-real-png-but-present")
self.write_svg(
project,
"""
<rect x="0" y="0" width="960" height="540" fill="#f8fafc" />
<rect x="76" y="118" width="280" height="150" fill="#e2e8f0" />
<text x="80" y="82" font-size="28" fill="#111827">Strategy review</text>
<text x="96" y="170" font-size="18" fill="#334155">Pipeline status</text>
<image href="@./assets/hero.png" x="560" y="120" width="300" height="180" />
""",
)
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
exit_code = svg_preview_lint.main(
[
"--project",
str(project),
"--preview",
str(project / "preview" / "preview.html"),
"--plan",
str(project / "slide_plan.json"),
]
)
result = json.loads(stdout.getvalue())
self.assertEqual(exit_code, 0)
self.assertEqual(result["schema_version"], "svglide-preview-lint/v1")
self.assertEqual(result["status"], "passed")
self.assertEqual(result["action"], "create_live")
self.assertEqual(result["issue_ids"], [])
self.assertEqual(result["error_count"], 0)
self.assertEqual(result["visual_score_mode"], "advisory")
self.assertEqual(result["validation_profile"], "authoring")
self.assertEqual(result["visual_score_threshold"], 75)
def test_sparse_decorative_page_gets_density_warning(self) -> None:
project = self.make_project()
self.write_preview(project)
self.write_svg(
project,
"""
<rect x="0" y="0" width="960" height="540" fill="#f8fafc" />
<path d="M80 420 C240 360 420 470 620 390" fill="none" stroke="#94a3b8" stroke-width="6" />
<text x="80" y="120" font-size="34" fill="#111827">Thin visual idea</text>
<text x="80" y="172" font-size="18" fill="#334155">One short line is not enough structure.</text>
""",
)
result = self.lint(project)
self.assertEqual(result["status"], "passed")
self.assertIn("low_information_density", self.codes(result))
self.assertLess(result["visual_score"], 100)
def test_placeholder_renderer_copy_gets_warning(self) -> None:
project = self.make_project()
self.write_preview(project)
self.write_svg(
project,
"""
<rect x="0" y="0" width="960" height="540" fill="#f8fafc" />
<rect x="96" y="120" width="260" height="190" fill="#dbeafe" />
<path d="M120 350 C240 260 420 380 620 250" fill="none" stroke="#2563eb" stroke-width="8" />
<text x="80" y="96" font-size="30" fill="#111827">Smoke deck</text>
<text x="80" y="156" font-size="18" fill="#334155">A generated page that still exposes implementation copy.</text>
<text id="footer" x="320" y="512" font-size="10" fill="#64748b">SVGlide contract renderer · 01</text>
""",
)
result = self.lint(project)
self.assertEqual(result["status"], "passed")
self.assertIn("placeholder_renderer_copy", self.codes(result))
self.assertLess(result["visual_score"], 100)
def test_unlabeled_visual_system_gets_density_warning(self) -> None:
project = self.make_project()
self.write_preview(project)
self.write_svg(
project,
"""
<rect x="0" y="0" width="960" height="540" fill="#f8fafc" />
<rect x="80" y="128" width="160" height="96" fill="#dbeafe" />
<rect x="280" y="128" width="160" height="96" fill="#bfdbfe" />
<rect x="480" y="128" width="160" height="96" fill="#93c5fd" />
<rect x="680" y="128" width="160" height="96" fill="#60a5fa" />
<path d="M120 340 L260 300 L420 360 L620 278 L820 330" fill="none" stroke="#2563eb" stroke-width="8" />
<text x="80" y="82" font-size="30" fill="#111827">Shape-only dashboard</text>
<text x="80" y="444" font-size="18" fill="#334155">The message is present but the visual system is not labeled.</text>
""",
)
result = self.lint(project)
self.assertEqual(result["status"], "passed")
self.assertIn("unlabeled_visual_system", self.codes(result))
self.assertLess(result["visual_score"], 100)
def test_repeated_multi_page_layout_gets_variety_warning(self) -> None:
project = self.make_project()
refs = []
preview_images = []
for page in range(1, 5):
name = f"page-{page:03d}.svg"
refs.append({"page": page, "path": f"prepared/{name}"})
preview_images.append(f'<img src="../prepared/{name}" />')
(project / "prepared" / name).write_text(
f"""
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540">
<rect x="0" y="0" width="960" height="540" fill="#f8fafc" />
<rect x="80" y="120" width="260" height="120" fill="#e2e8f0" />
<rect x="380" y="140" width="160" height="24" fill="#2563eb" />
<rect x="380" y="180" width="120" height="24" fill="#60a5fa" />
<circle cx="760" cy="190" r="52" fill="#c7d2fe" />
<text x="80" y="82" font-size="28" fill="#111827">Page {page}</text>
<text x="96" y="170" font-size="18" fill="#334155">Status item</text>
</svg>
""",
encoding="utf-8",
)
write_json(project / "slide_plan.json", {"svg_files": refs})
(project / "preview" / "preview.html").write_text("<html><body>" + "".join(preview_images) + "</body></html>", encoding="utf-8")
result = self.lint(project)
self.assertEqual(result["status"], "passed")
self.assertIn("low_visual_variety", self.codes(result))
def test_authoring_allows_warnings_above_authoring_threshold(self) -> None:
project = self.make_project()
self.write_sparse_pages(project, 4)
result = self.lint(project, "authoring")
self.assertEqual(result["status"], "passed")
self.assertEqual(result["warning_count"], 5)
self.assertEqual(result["visual_score"], 65)
self.assertEqual(result["visual_score_threshold"], 75)
self.assertFalse(result["visual_score_passed"])
self.assertEqual(result["visual_score_mode"], "advisory")
def test_production_fails_when_visual_score_is_below_threshold(self) -> None:
project = self.make_project()
self.write_sparse_pages(project, 3)
result = self.lint(project, "production")
self.assertEqual(result["status"], "failed")
self.assertEqual(result["warning_count"], 3)
self.assertEqual(result["visual_score"], 79)
self.assertEqual(result["visual_score_threshold"], 85)
self.assertFalse(result["visual_score_passed"])
def test_golden_fails_when_warning_count_is_nonzero(self) -> None:
project = self.make_project()
self.write_preview(project)
write_json(
project / "slide_plan.json",
{
"validation_profile": {"profile": "golden"},
"svg_files": [{"page": 1, "path": "prepared/page-001.svg"}],
},
)
self.write_svg(
project,
"""
<rect x="0" y="0" width="960" height="540" fill="#f8fafc" />
<path d="M80 420 C240 360 420 470 620 390" fill="none" stroke="#94a3b8" stroke-width="6" />
<text x="80" y="120" font-size="34" fill="#111827">Thin visual idea</text>
<text x="80" y="172" font-size="18" fill="#334155">One short line is not enough structure.</text>
""",
)
result = self.lint(project)
self.assertEqual(result["status"], "failed")
self.assertEqual(result["validation_profile"], "golden")
self.assertEqual(result["visual_score"], 93)
self.assertEqual(result["visual_score_threshold"], 90)
self.assertFalse(result["warning_gate_passed"])
def test_cli_reads_validation_profile_from_plan(self) -> None:
project = self.make_project()
self.write_sparse_pages(project, 3, validation_profile="production")
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
exit_code = svg_preview_lint.main(
[
"--project",
str(project),
"--preview",
str(project / "preview" / "preview.html"),
"--plan",
str(project / "slide_plan.json"),
]
)
result = json.loads(stdout.getvalue())
self.assertEqual(exit_code, 1)
self.assertEqual(result["validation_profile"], "production")
self.assertEqual(result["visual_score_threshold"], 85)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,294 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import argparse
import hashlib
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable
SCRIPT_PATH = Path(__file__).resolve()
SKILL_ROOT = SCRIPT_PATH.parents[1]
DEFAULT_REPO_ROOT = SCRIPT_PATH.parents[3]
PRIVATE_MANIFEST_REL = Path("references/routes/create-svg/private-recipes.manifest.json")
SKILL_SCAN_TARGETS = [
Path("SKILL.md"),
Path("references"),
Path("assets/templates"),
Path("scripts"),
Path("tests"),
]
REPO_SCAN_TARGETS = [
Path("shortcuts/slides"),
Path("tests"),
Path("tests/cli_e2e/slides/coverage.md"),
Path("README.md"),
Path("README.zh.md"),
Path("docs"),
]
TEXT_FILE_SUFFIXES = {
"",
".cfg",
".css",
".go",
".html",
".js",
".json",
".jsonl",
".md",
".mjs",
".py",
".svg",
".toml",
".ts",
".tsx",
".txt",
".xml",
".yaml",
".yml",
}
@dataclass(frozen=True)
class Issue:
path: str
line: int
column: int
code: str
token: str
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Lint public SVGlide create-svg docs for route-private recipe leaks."
)
parser.add_argument(
"--repo-root",
default=str(DEFAULT_REPO_ROOT),
help="Repository root. Defaults to the root inferred from this script location.",
)
parser.add_argument(
"--json",
action="store_true",
help="Emit JSON instead of human-readable diagnostics.",
)
return parser.parse_args(argv)
def load_json(path: Path) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as error:
raise SystemExit(f"missing private recipe manifest: {path}") from error
except json.JSONDecodeError as error:
raise SystemExit(f"invalid JSON in private recipe manifest: {path}: {error}") from error
if not isinstance(value, dict):
raise SystemExit(f"private recipe manifest must be a JSON object: {path}")
return value
def expect_string_list(value: Any, field: str) -> list[str]:
if not isinstance(value, list) or not all(isinstance(item, str) and item for item in value):
raise SystemExit(f"manifest field must be a non-empty string list: {field}")
return value
def load_manifest_tokens(manifest: dict[str, Any]) -> tuple[list[str], list[str], list[str]]:
recipes = manifest.get("recipes")
if not isinstance(recipes, (dict, list)) or not recipes:
raise SystemExit('manifest field must be a non-empty object or list: recipes')
private_ids: list[str] = []
if isinstance(recipes, dict):
private_ids = [str(recipe_id) for recipe_id in recipes.keys() if str(recipe_id)]
if len(private_ids) != len(recipes):
raise SystemExit("manifest recipes object must not contain empty ids")
else:
for recipe in recipes:
if not isinstance(recipe, dict):
raise SystemExit("manifest recipes must contain JSON objects")
recipe_id = recipe.get("recipe_id")
if not isinstance(recipe_id, str) or not recipe_id:
raise SystemExit('each manifest recipe must include a non-empty "recipe_id"')
private_ids.append(recipe_id)
dotted_ids = expect_string_list(
manifest.get("blocked_research_dotted_recipe_ids"),
"blocked_research_dotted_recipe_ids",
)
absolute_paths = expect_string_list(
manifest.get("blocked_absolute_paths"),
"blocked_absolute_paths",
)
for label, values in (
("private recipe ids", private_ids),
("research dotted recipe ids", dotted_ids),
("blocked absolute paths", absolute_paths),
):
duplicates = sorted({item for item in values if values.count(item) > 1})
if duplicates:
raise SystemExit(f"duplicate {label}: {', '.join(duplicates)}")
return private_ids, dotted_ids, absolute_paths
def normalize_rel(path: Path, root: Path) -> str | None:
try:
return path.resolve().relative_to(root.resolve()).as_posix()
except ValueError:
return None
def is_under(rel_path: str, prefix: str) -> bool:
return rel_path == prefix or rel_path.startswith(f"{prefix}/")
def is_allowed_route_private_path(path: Path, repo_root: Path) -> bool:
skill_rel = normalize_rel(path, SKILL_ROOT)
if skill_rel is not None:
if skill_rel in {
"references/routes/create-svg/private-recipes.manifest.json",
}:
return True
if is_under(skill_rel, "references/routes/create-svg/private"):
return True
if is_under(skill_rel, "tests/fixtures/routes/create-svg/private"):
return True
if is_under(skill_rel, "tests/fixtures/routes/create-svg/internal-reports"):
return True
repo_rel = normalize_rel(path, repo_root)
if repo_rel is not None:
if is_under(repo_rel, "tests/fixtures/routes/create-svg/private"):
return True
if is_under(repo_rel, "tests/fixtures/routes/create-svg/internal-reports"):
return True
return False
def iter_existing_scan_roots(repo_root: Path) -> Iterable[Path]:
yielded: set[Path] = set()
for target in SKILL_SCAN_TARGETS:
path = SKILL_ROOT / target
if path.exists() and path.resolve() not in yielded:
yielded.add(path.resolve())
yield path
for target in REPO_SCAN_TARGETS:
path = repo_root / target
if path.exists() and path.resolve() not in yielded:
yielded.add(path.resolve())
yield path
def iter_text_files(root: Path) -> Iterable[Path]:
if root.is_file():
candidates = [root]
else:
candidates = [path for path in root.rglob("*") if path.is_file()]
for path in candidates:
if path.suffix.lower() in TEXT_FILE_SUFFIXES:
yield path
def line_column(text: str, index: int) -> tuple[int, int]:
line = text.count("\n", 0, index) + 1
line_start = text.rfind("\n", 0, index) + 1
return line, index - line_start + 1
def find_token_issues(path: Path, text: str, tokens: list[str], code: str) -> list[Issue]:
issues: list[Issue] = []
for token in tokens:
pattern = re.compile(rf"(?<![A-Za-z0-9_.-]){re.escape(token)}(?![A-Za-z0-9_.-])")
for match in pattern.finditer(text):
line, column = line_column(text, match.start())
issues.append(
Issue(
path=path.as_posix(),
line=line,
column=column,
code=code,
token=token,
)
)
return issues
def lint_file(
path: Path,
repo_root: Path,
private_ids: list[str],
dotted_ids: list[str],
absolute_paths: list[str],
) -> list[Issue]:
try:
text = path.read_text(encoding="utf-8")
except UnicodeDecodeError:
return []
if is_allowed_route_private_path(path, repo_root):
return []
display_path = Path(normalize_rel(path, repo_root) or path.as_posix())
issues: list[Issue] = []
issues.extend(find_token_issues(display_path, text, private_ids, "private_recipe_id_leak"))
issues.extend(find_token_issues(display_path, text, dotted_ids, "research_dotted_recipe_id_leak"))
issues.extend(find_token_issues(display_path, text, absolute_paths, "research_absolute_path_leak"))
return issues
def lint(repo_root: Path) -> list[Issue]:
manifest_path = SKILL_ROOT / PRIVATE_MANIFEST_REL
manifest = load_json(manifest_path)
private_ids, dotted_ids, absolute_paths = load_manifest_tokens(manifest)
issues: list[Issue] = []
seen_files: set[Path] = set()
for root in iter_existing_scan_roots(repo_root):
for path in iter_text_files(root):
resolved = path.resolve()
if resolved in seen_files:
continue
seen_files.add(resolved)
issues.extend(lint_file(path, repo_root, private_ids, dotted_ids, absolute_paths))
return sorted(issues, key=lambda issue: (issue.path, issue.line, issue.column, issue.code, issue.token))
def issue_to_dict(issue: Issue) -> dict[str, Any]:
return {
"path": issue.path,
"line": issue.line,
"column": issue.column,
"code": issue.code,
"token_hash": hashlib.sha256(issue.token.encode("utf-8")).hexdigest(),
}
def main(argv: list[str]) -> int:
args = parse_args(argv)
repo_root = Path(args.repo_root).resolve()
issues = lint(repo_root)
if args.json:
print(json.dumps({"issue_count": len(issues), "issues": [issue_to_dict(issue) for issue in issues]}, indent=2))
else:
for issue in issues:
token_hash = hashlib.sha256(issue.token.encode("utf-8")).hexdigest()[:12]
print(f"{issue.path}:{issue.line}:{issue.column}: {issue.code}: token_hash={token_hash}")
if issues:
print(f"svg-private-docs-lint: found {len(issues)} issue(s)", file=sys.stderr)
return 1 if issues else 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View File

@@ -0,0 +1,135 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import base64
import time
from pathlib import Path
from typing import Protocol
try:
from PIL import Image
except ImportError: # pragma: no cover - exercised in minimal Python installs.
Image = None # type: ignore[assignment]
CANVAS_WIDTH = 960
CANVAS_HEIGHT = 540
MAX_PNG_BYTES = 20 * 1024 * 1024
TRANSPARENT_1X1_PNG = base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lM6c3QAAAABJRU5ErkJggg=="
)
class RasterRenderError(RuntimeError):
"""Raised when Chromium rasterization or PNG validation fails."""
class RasterRenderer(Protocol):
def render_full_page(self, svg: str, output_png: Path, scale: int) -> dict[str, object]:
...
def viewport_size_from_svg(svg: str) -> tuple[int, int]:
# P0 keeps the SVGlide root contract fixed. Go-side validation enforces the
# full contract later, so the renderer defaults to the canonical canvas.
return CANVAS_WIDTH, CANVAS_HEIGHT
class PlaywrightRasterRenderer:
def render_full_page(self, svg: str, output_png: Path, scale: int) -> dict[str, object]:
if scale < 2:
raise RasterRenderError("svg raster scale must be >= 2")
started = time.monotonic()
output_png.parent.mkdir(parents=True, exist_ok=True)
width, height = viewport_size_from_svg(svg)
html = self._preview_html(svg, width, height)
try:
from playwright.sync_api import sync_playwright
except ImportError as error:
raise RasterRenderError(
"python package playwright is required for SVG rasterization; install it and run `python3 -m playwright install chromium`"
) from error
try:
with sync_playwright() as playwright:
browser = playwright.chromium.launch()
context = browser.new_context(
viewport={"width": width, "height": height},
device_scale_factor=scale,
java_script_enabled=False,
bypass_csp=False,
)
page = context.new_page()
page.route("**/*", lambda route: route.abort())
page.set_content(html, wait_until="load")
page.screenshot(path=str(output_png), clip={"x": 0, "y": 0, "width": width, "height": height}, omit_background=False)
browser.close()
except Exception as error: # pragma: no cover - depends on local Chromium.
raise RasterRenderError(f"Chromium SVG rasterization failed: {error}") from error
validate_png(output_png, require_nontransparent=True)
return {
"output_png": str(output_png),
"bbox": [0.0, 0.0, float(width), float(height)],
"scale": scale,
"bytes": output_png.stat().st_size,
"render_ms": int((time.monotonic() - started) * 1000),
"alpha_crop": False,
}
@staticmethod
def _preview_html(svg: str, width: int, height: int) -> str:
return (
"<!doctype html><html><head><meta charset=\"utf-8\">"
"<style>html,body{margin:0;width:%dpx;height:%dpx;background:#fff;overflow:hidden;}svg{display:block;}</style>"
"</head><body>%s</body></html>"
) % (width, height, svg)
def png_dimensions(path: Path) -> tuple[int, int]:
data = path.read_bytes()
if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n":
raise RasterRenderError(f"not a PNG file: {path}")
width = int.from_bytes(data[16:20], "big")
height = int.from_bytes(data[20:24], "big")
return width, height
def validate_png(path: Path, require_nontransparent: bool = True) -> None:
if not path.exists():
raise RasterRenderError(f"raster PNG does not exist: {path}")
size = path.stat().st_size
if size <= 0:
raise RasterRenderError(f"raster PNG is empty: {path}")
if size > MAX_PNG_BYTES:
raise RasterRenderError(f"raster PNG exceeds {MAX_PNG_BYTES} bytes: {path}")
width, height = png_dimensions(path)
if width <= 0 or height <= 0:
raise RasterRenderError(f"raster PNG has invalid dimensions: {path}")
if require_nontransparent and Image is not None:
with Image.open(path) as image:
rgba = image.convert("RGBA")
if not any(pixel[3] > 0 for pixel in rgba.getdata()):
raise RasterRenderError(f"raster PNG is fully transparent: {path}")
def render_islands(
svg: str,
islands: list[dict[str, object]],
asset_dir: Path,
scale: int,
renderer: RasterRenderer | None = None,
) -> list[dict[str, object]]:
renderer = renderer or PlaywrightRasterRenderer()
rendered: list[dict[str, object]] = []
for index, island in enumerate(islands, start=1):
if island.get("kind") != "full-page":
raise RasterRenderError("only full-page raster islands are implemented in P0")
output_png = asset_dir / f"page-001-island-{index:03d}.png"
result = dict(renderer.render_full_page(svg, output_png, scale))
validate_png(Path(str(result["output_png"])), require_nontransparent=True)
rendered.append(result)
return rendered

View File

@@ -0,0 +1,57 @@
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from PIL import Image
import svg_raster_renderer as renderer
SVG = '<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540"><rect width="960" height="540" fill="#fff"/></svg>'
class FakeRenderer:
def render_full_page(self, svg: str, output_png: Path, scale: int) -> dict[str, object]:
image = Image.new("RGBA", (960 * scale, 540 * scale), (255, 255, 255, 255))
output_png.parent.mkdir(parents=True, exist_ok=True)
image.save(output_png)
return {
"output_png": str(output_png),
"bbox": [0.0, 0.0, 960.0, 540.0],
"scale": scale,
"bytes": output_png.stat().st_size,
"render_ms": 1,
"alpha_crop": False,
}
class SvgRasterRendererTest(unittest.TestCase):
def test_render_islands_validates_nonempty_nontransparent_png(self) -> None:
with tempfile.TemporaryDirectory() as temp:
rendered = renderer.render_islands(
SVG,
[{"kind": "full-page"}],
Path(temp),
2,
FakeRenderer(),
)
output_png = Path(str(rendered[0]["output_png"]))
self.assertTrue(output_png.exists())
self.assertEqual(renderer.png_dimensions(output_png), (1920, 1080))
def test_validate_png_rejects_fully_transparent_image(self) -> None:
with tempfile.TemporaryDirectory() as temp:
path = Path(temp) / "transparent.png"
Image.new("RGBA", (10, 10), (255, 255, 255, 0)).save(path)
with self.assertRaises(renderer.RasterRenderError):
renderer.validate_png(path, require_nontransparent=True)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,199 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
import svg_effect_classifier as classifier
import svg_raster_renderer as renderer_mod
import svg_safe_rewrite
VERSION = "1"
MODES = {"off", "auto", "strict", "force-page"}
class RasterizeError(RuntimeError):
"""Raised when SVG rasterization cannot produce a safe output."""
def load_svg(path: Path) -> str:
try:
text = path.read_text(encoding="utf-8")
except OSError as error:
raise RasterizeError(f"failed to read SVG input {path}: {error}") from error
if not text.strip():
raise RasterizeError(f"SVG input is empty: {path}")
return text
def write_text(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
def whole_page_island(reason: str) -> dict[str, object]:
return {
"id": "page-001-island-001",
"kind": "full-page",
"reason": reason,
"bbox": [0.0, 0.0, 960.0, 540.0],
}
def plan_raster_islands(mode: str, detections: list[classifier.EffectDetection]) -> list[dict[str, object]]:
if mode == "off":
return []
if mode == "force-page":
return [whole_page_island("force-page")]
if not detections:
return []
reasons = sorted({detection.reason for detection in detections})
return [whole_page_island("conservative_full_page:" + "; ".join(reasons[:4]))]
def _asset_rel(path: str, base_dir: Path) -> str:
resolved = Path(path).resolve()
try:
return resolved.relative_to(base_dir.resolve()).as_posix()
except ValueError:
return str(resolved)
def build_report(
*,
mode: str,
input_path: Path,
output_path: Path,
base_dir: Path,
detections: list[classifier.EffectDetection],
islands: list[dict[str, object]],
rendered_assets: list[dict[str, object]],
render_started: float,
) -> dict[str, object]:
generated_assets = [_asset_rel(str(asset["output_png"]), base_dir) for asset in rendered_assets]
island_reports: list[dict[str, object]] = []
for island, asset in zip(islands, rendered_assets):
island_reports.append(
{
"id": island.get("id", "page-001-island-001"),
"reason": island.get("reason", ""),
"source_node_ids": island.get("source_node_ids", []),
"bbox": asset.get("bbox", island.get("bbox", [0.0, 0.0, 960.0, 540.0])),
"output_png": _asset_rel(str(asset["output_png"]), base_dir),
"scale": asset.get("scale", 2),
"bytes": asset.get("bytes", 0),
"render_ms": asset.get("render_ms", 0),
"alpha_crop": asset.get("alpha_crop", False),
}
)
total_bytes = sum(int(asset.get("bytes", 0)) for asset in rendered_assets)
total_ms = sum(int(asset.get("render_ms", 0)) for asset in rendered_assets)
full_page_count = sum(1 for island in islands if island.get("kind") == "full-page")
return {
"version": VERSION,
"mode": mode,
"run_id": Path(output_path).parent.name,
"base_dir": str(base_dir.resolve()),
"native_text_blocks": 0,
"rasterized_text_blocks": 0,
"raster_images": len(rendered_assets),
"raster_total_bytes": total_bytes,
"raster_total_ms": total_ms or int((time.monotonic() - render_started) * 1000),
"full_page_fallback_count": full_page_count,
"generated_assets": generated_assets,
"detections": classifier.detections_as_dicts(detections),
"visual_artifacts": {},
"quality": {"gate_passed": True},
"pages": [
{
"source_path": str(input_path),
"safe_path": str(output_path),
"mode": mode,
"fallback_reason": islands[0].get("reason", "") if islands else "",
"runtime_gate_ok": True,
"pngs": generated_assets,
"islands": island_reports,
}
],
}
def rasterize_svg(
svg: str,
*,
mode: str,
scale: int,
input_path: Path,
output_path: Path,
asset_dir: Path,
base_dir: Path,
report_path: Path,
raster_renderer: renderer_mod.RasterRenderer | None = None,
) -> dict[str, object]:
if mode not in MODES:
raise RasterizeError(f"invalid rasterization mode: {mode}")
if mode != "off" and scale < 2:
raise RasterizeError("svg raster scale must be >= 2")
classifier.sanitize_or_reject(svg)
detections = classifier.classify_effects(svg)
islands = plan_raster_islands(mode, detections)
started = time.monotonic()
rendered_assets = renderer_mod.render_islands(svg, islands, asset_dir, scale, raster_renderer) if islands else []
safe_svg = svg_safe_rewrite.rewrite_svg(svg, islands, rendered_assets, base_dir)
svg_safe_rewrite.validate_safe_subset_lightweight(safe_svg)
write_text(output_path, safe_svg)
report = build_report(
mode=mode,
input_path=input_path,
output_path=output_path,
base_dir=base_dir,
detections=detections,
islands=islands,
rendered_assets=rendered_assets,
render_started=started,
)
write_text(report_path, json.dumps(report, indent=2, sort_keys=True) + "\n")
return report
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Rasterize rich SVG effects into safe SVGlide SVG image assets.")
parser.add_argument("--mode", choices=sorted(MODES), required=True)
parser.add_argument("--scale", type=int, default=2)
parser.add_argument("--input", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--asset-dir", required=True)
parser.add_argument("--base-dir", required=True)
parser.add_argument("--report", required=True)
parser.add_argument("--preview-html", default="")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
try:
rasterize_svg(
load_svg(Path(args.input)),
mode=args.mode,
scale=args.scale,
input_path=Path(args.input),
output_path=Path(args.output),
asset_dir=Path(args.asset_dir),
base_dir=Path(args.base_dir),
report_path=Path(args.report),
)
except (RasterizeError, classifier.SvgRasterSafetyError, svg_safe_rewrite.SafeRewriteError, renderer_mod.RasterRenderError) as error:
print(f"svg_rasterize_effects: {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,105 @@
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import json
import tempfile
import unittest
from pathlib import Path
from PIL import Image
import svg_rasterize_effects as rasterize
RICH_SVG = """<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns"
slide:role="slide" width="960" height="540" viewBox="0 0 960 540">
<defs><filter id="glow"><feGaussianBlur stdDeviation="8" /></filter></defs>
<rect x="0" y="0" width="960" height="540" fill="#fff" />
<circle cx="480" cy="270" r="120" fill="#2563eb" filter="url(#glow)" />
</svg>"""
class FakeRenderer:
def render_full_page(self, svg: str, output_png: Path, scale: int) -> dict[str, object]:
image = Image.new("RGBA", (960 * scale, 540 * scale), (255, 255, 255, 255))
output_png.parent.mkdir(parents=True, exist_ok=True)
image.save(output_png)
return {
"output_png": str(output_png),
"bbox": [0.0, 0.0, 960.0, 540.0],
"scale": scale,
"bytes": output_png.stat().st_size,
"render_ms": 3,
"alpha_crop": False,
}
class SvgRasterizeEffectsTest(unittest.TestCase):
def test_force_page_rasterizes_to_safe_svg_and_report(self) -> None:
with tempfile.TemporaryDirectory() as temp:
base_dir = Path(temp)
output = base_dir / ".lark-slides" / "rasterized" / "run-1" / "page-001.safe.svg"
report_path = base_dir / ".lark-slides" / "rasterized" / "run-1" / "raster-report.json"
report = rasterize.rasterize_svg(
RICH_SVG,
mode="force-page",
scale=2,
input_path=base_dir / "page-001.svg",
output_path=output,
asset_dir=output.parent,
base_dir=base_dir,
report_path=report_path,
raster_renderer=FakeRenderer(),
)
safe_svg = output.read_text(encoding="utf-8")
persisted_report = json.loads(report_path.read_text(encoding="utf-8"))
self.assertEqual(report["mode"], "force-page")
self.assertEqual(persisted_report["raster_images"], 1)
self.assertEqual(persisted_report["full_page_fallback_count"], 1)
self.assertIn('href="@./.lark-slides/rasterized/run-1/page-001-island-001.png"', safe_svg)
self.assertNotIn("<filter", safe_svg)
self.assertTrue((output.parent / "page-001-island-001.png").exists())
def test_auto_uses_conservative_full_page_when_effects_are_detected(self) -> None:
with tempfile.TemporaryDirectory() as temp:
base_dir = Path(temp)
output = base_dir / ".lark-slides" / "rasterized" / "run-1" / "page-001.safe.svg"
report_path = output.parent / "raster-report.json"
report = rasterize.rasterize_svg(
RICH_SVG,
mode="auto",
scale=2,
input_path=base_dir / "page-001.svg",
output_path=output,
asset_dir=output.parent,
base_dir=base_dir,
report_path=report_path,
raster_renderer=FakeRenderer(),
)
self.assertEqual(report["full_page_fallback_count"], 1)
self.assertTrue(str(report["pages"][0]["fallback_reason"]).startswith("conservative_full_page:"))
def test_scale_below_two_is_rejected_for_raster_modes(self) -> None:
with tempfile.TemporaryDirectory() as temp:
base_dir = Path(temp)
with self.assertRaises(rasterize.RasterizeError):
rasterize.rasterize_svg(
RICH_SVG,
mode="force-page",
scale=1,
input_path=base_dir / "page.svg",
output_path=base_dir / "page.safe.svg",
asset_dir=base_dir,
base_dir=base_dir,
report_path=base_dir / "report.json",
raster_renderer=FakeRenderer(),
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,122 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import html
import re
import xml.etree.ElementTree as ET
from pathlib import Path
import svg_effect_classifier as classifier
SLIDE_NS = "https://slides.bytedance.com/ns"
SVG_NS = "http://www.w3.org/2000/svg"
SVG_CONTRACT_VERSION = "svglide-authoring-contract/v1"
DEFAULT_WIDTH = 960.0
DEFAULT_HEIGHT = 540.0
HARD_TAGS = classifier.HARD_EFFECT_TAGS
HARD_ATTRS = classifier.HARD_EFFECT_ATTRS
HARD_STYLE_PROPS = classifier.HARD_STYLE_PROPS
NUMBER_RE = re.compile(r"^[-+]?(?:\d+\.?\d*|\.\d+)(?:px)?$")
class SafeRewriteError(ValueError):
"""Raised when a safe SVG cannot be produced or validated."""
def _number(value: str | None, default: float) -> float:
if not value:
return default
value = value.strip()
if not NUMBER_RE.match(value):
return default
if value.endswith("px"):
value = value[:-2]
try:
return float(value)
except ValueError:
return default
def _format_number(value: float) -> str:
if abs(value - round(value)) < 0.0001:
return str(int(round(value)))
return f"{value:.4f}".rstrip("0").rstrip(".")
def svg_viewport(svg: str) -> tuple[float, float, str]:
root = classifier.sanitize_or_reject(svg)
width = _number(root.attrib.get("width"), DEFAULT_WIDTH)
height = _number(root.attrib.get("height"), DEFAULT_HEIGHT)
view_box = root.attrib.get("viewBox", "").strip()
if not view_box:
view_box = f"0 0 {_format_number(width)} {_format_number(height)}"
return width, height, view_box
def href_for_asset(asset_path: Path, base_dir: Path) -> str:
asset_path = asset_path.resolve()
base_dir = base_dir.resolve()
try:
rel = asset_path.relative_to(base_dir)
except ValueError:
if not str(asset_path).startswith("/private/tmp/"):
raise SafeRewriteError(f"raster asset escapes base directory: {asset_path}")
return str(asset_path)
rel_text = rel.as_posix()
if rel_text.startswith("../") or rel_text == "..":
raise SafeRewriteError(f"raster asset escapes base directory: {asset_path}")
return f"@./{rel_text}"
def full_page_image_svg(original_svg: str, png_path: Path, base_dir: Path) -> str:
width, height, view_box = svg_viewport(original_svg)
href = href_for_asset(png_path, base_dir)
return "\n".join(
[
f'<svg xmlns="{SVG_NS}" xmlns:slide="{SLIDE_NS}" slide:role="slide"',
f' slide:contract-version="{SVG_CONTRACT_VERSION}"',
f' width="{_format_number(width)}" height="{_format_number(height)}" viewBox="{html.escape(view_box)}">',
f' <image slide:role="image" href="{html.escape(href)}" x="0" y="0" width="{_format_number(width)}" height="{_format_number(height)}" />',
"</svg>",
"",
]
)
def _style_has_hard_props(style: str) -> bool:
props = classifier.normalize_style(style)
return any(classifier.is_hard_style_property(prop) for prop in props)
def validate_safe_subset_lightweight(svg: str) -> None:
root = classifier.sanitize_or_reject(svg)
if classifier.local_name(root.tag) != "svg":
raise SafeRewriteError("safe SVG root must be <svg>")
for elem in root.iter():
tag = classifier.local_name(elem.tag)
if tag in HARD_TAGS:
raise SafeRewriteError(f"safe SVG still contains unsupported tag <{tag}>")
if tag in {"text", "polygon", "polyline"}:
raise SafeRewriteError(f"safe SVG still contains unsupported root-safe tag <{tag}>")
for raw_attr, value in elem.attrib.items():
attr = classifier.local_name(raw_attr)
if attr in HARD_ATTRS:
raise SafeRewriteError(f"safe SVG still contains unsupported attribute {attr}")
if attr == "style" and _style_has_hard_props(value):
raise SafeRewriteError("safe SVG still contains unsupported CSS effect")
def rewrite_svg(svg: str, islands: list[dict[str, object]], rendered_assets: list[dict[str, object]], base_dir: Path) -> str:
if not islands:
validate_safe_subset_lightweight(svg)
return svg
if len(islands) == 1 and islands[0].get("kind") == "full-page":
png_path = Path(str(rendered_assets[0]["output_png"]))
safe_svg = full_page_image_svg(svg, png_path, base_dir)
validate_safe_subset_lightweight(safe_svg)
return safe_svg
raise SafeRewriteError("only full-page safe rewrite is implemented in P0")

View File

@@ -0,0 +1,51 @@
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
import svg_safe_rewrite
SVG_WITH_FILTER = """<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns"
slide:role="slide" width="960" height="540" viewBox="0 0 960 540">
<defs><filter id="glow" /></defs>
<rect filter="url(#glow)" x="0" y="0" width="960" height="540" />
</svg>"""
class SvgSafeRewriteTest(unittest.TestCase):
def test_full_page_rewrite_outputs_single_safe_image(self) -> None:
with tempfile.TemporaryDirectory() as temp:
base_dir = Path(temp)
png = base_dir / ".lark-slides" / "rasterized" / "run-1" / "page-001-island-001.png"
png.parent.mkdir(parents=True)
png.write_bytes(b"placeholder")
safe_svg = svg_safe_rewrite.full_page_image_svg(SVG_WITH_FILTER, png, base_dir)
svg_safe_rewrite.validate_safe_subset_lightweight(safe_svg)
self.assertIn('slide:role="slide"', safe_svg)
self.assertIn('href="@./.lark-slides/rasterized/run-1/page-001-island-001.png"', safe_svg)
self.assertIn('x="0" y="0" width="960" height="540"', safe_svg)
self.assertNotIn("<filter", safe_svg)
def test_safe_gate_rejects_residual_rich_effects(self) -> None:
with self.assertRaises(svg_safe_rewrite.SafeRewriteError):
svg_safe_rewrite.validate_safe_subset_lightweight(SVG_WITH_FILTER)
def test_asset_href_rejects_paths_outside_base_dir(self) -> None:
with tempfile.TemporaryDirectory() as temp:
base_dir = Path(temp) / "base"
base_dir.mkdir()
outside = Path(temp) / "outside.png"
outside.write_bytes(b"placeholder")
with self.assertRaises(svg_safe_rewrite.SafeRewriteError):
svg_safe_rewrite.href_for_asset(outside, base_dir)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,308 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import argparse
import hashlib
import json
import re
from pathlib import Path
from typing import Iterable
SELECTION_SCHEMA_VERSION = "svglide-asset-selection/v1"
STRICT_LANES = {"production", "golden"}
DEFAULT_BUDGET = {
"brand": 1,
"layout": 3,
"deck": 1,
"chart": 6,
"icon_style": 1,
"visual_style": 2,
"image_palette": 1,
"image_rendering": 1,
"image_type": 2,
"narrative_mode": 1,
"example": 1,
}
DEFAULT_TOTAL_BUDGET = 12
KIND_BUCKETS = {
"brand_preset": "brand",
"layout_template": "layout",
"deck_template": "deck",
"chart_template": "chart",
"icon_library": "icon_style",
"visual_style": "visual_style",
"image_palette": "image_palette",
"image_rendering": "image_rendering",
"image_type_template": "image_type",
"narrative_mode": "narrative_mode",
"example_project": "example",
}
ACTIVATION_PRIORITY = {"active": 3, "validated": 2, "candidate": 1, "rejected": 0}
TOKEN_RE = re.compile(r"[a-z0-9]+|[\u4e00-\u9fff]+", re.IGNORECASE)
class SelectorError(ValueError):
"""Raised when selector inputs or policies are invalid."""
def script_path() -> Path:
return Path(__file__).resolve()
def default_asset_map_path() -> Path:
return script_path().parents[1] / "references/svglide-design-pattern-map.json"
def load_asset_map(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def tokenize(*values: object) -> list[str]:
tokens: set[str] = set()
for value in values:
if value is None:
continue
if isinstance(value, (list, tuple, set)):
tokens.update(tokenize(*value))
continue
tokens.update(match.group(0).lower() for match in TOKEN_RE.finditer(str(value)))
return sorted(tokens)
def canonical_digest(value: object) -> str:
encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()[:16]
def kind_bucket(kind: str) -> str:
return KIND_BUCKETS.get(kind, kind)
def normalize_budget(budget: dict[str, int] | None = None) -> dict[str, int]:
normalized = dict(DEFAULT_BUDGET)
if budget:
for key, value in budget.items():
if value < 0:
raise SelectorError(f"budget for {key} must be non-negative")
normalized[key] = value
return normalized
def parse_budget_override(raw: str) -> dict[str, int]:
if not raw:
return {}
parsed: dict[str, int] = {}
for item in raw.split(","):
if not item.strip():
continue
if "=" not in item:
raise SelectorError(f"invalid budget override {item!r}; expected kind=count")
key, value = item.split("=", 1)
parsed[key.strip()] = int(value.strip())
return parsed
def has_normalized_fixture(resource: dict) -> bool:
if resource.get("normalized_fixture") or resource.get("normalized_fixture_path"):
return True
metadata = resource.get("metadata", {})
return bool(metadata.get("normalized_fixture") or metadata.get("normalized_fixture_path"))
def strict_policy_violation(resource: dict) -> str:
status = resource.get("activation_status", "")
if status not in {"validated", "active"}:
return "activation_status_not_production_ready"
if resource.get("license_status") in {"unknown", "reference_only", "unknown/reference_only"}:
return "license_not_production_ready"
if resource.get("protocol_compatibility") == "needs_normalization" and not has_normalized_fixture(resource):
return "raw_asset_needs_normalization"
return ""
def resource_policy_violation(resource: dict, lane: str) -> str:
if resource.get("activation_status") == "rejected":
return "rejected_asset"
if lane in STRICT_LANES:
return strict_policy_violation(resource)
return ""
def searchable_tokens(resource: dict) -> set[str]:
metadata = resource.get("metadata", {})
return set(
tokenize(
resource.get("id", ""),
resource.get("kind", ""),
resource.get("source_path", ""),
resource.get("summary", ""),
resource.get("selection_tags", []),
metadata.get("summary", ""),
metadata.get("page_types", []),
metadata.get("sample_icons", []),
metadata.get("page_samples", []),
metadata.get("media_samples", []),
)
)
def score_resource(resource: dict, brief_tokens: set[str], explicit_tags: set[str]) -> tuple[int, list[str]]:
tokens = searchable_tokens(resource)
matched_tags = sorted(explicit_tags & tokens)
matched_brief = sorted(brief_tokens & tokens)
score = len(matched_tags) * 8 + len(matched_brief) * 2
if score:
if resource.get("activation_status") == "active":
score += 3
elif resource.get("activation_status") == "validated":
score += 2
return score, matched_tags + [token for token in matched_brief if token not in matched_tags]
def slim_asset(resource: dict, *, reason: str, score: int = 0, matched_terms: Iterable[str] = ()) -> dict:
output = {
"id": resource["id"],
"kind": resource["kind"],
"reason": reason,
"activation_status": resource.get("activation_status", ""),
}
if resource.get("source_path"):
output["source_path"] = resource["source_path"]
if score:
output["score"] = score
terms = sorted(set(matched_terms))
if terms:
output["matched_terms"] = terms[:12]
return output
def select_assets(
asset_map: dict,
*,
brief: str,
tags: Iterable[str] = (),
lane: str = "authoring",
budget: dict[str, int] | None = None,
max_total_assets: int = DEFAULT_TOTAL_BUDGET,
) -> dict:
if lane not in {"authoring", "research", "production", "golden"}:
raise SelectorError(f"unsupported lane: {lane}")
normalized_budget = normalize_budget(budget)
brief_tokens = set(tokenize(brief))
explicit_tags = set(tokenize(list(tags)))
selected: list[dict] = []
excluded: list[dict] = []
per_kind_counts = {key: 0 for key in normalized_budget}
candidates: list[tuple[int, int, str, dict, list[str]]] = []
for resource in asset_map.get("resources", []):
bucket = kind_bucket(resource.get("kind", ""))
if bucket not in normalized_budget:
continue
score, matched = score_resource(resource, brief_tokens, explicit_tags)
if score <= 0:
continue
violation = resource_policy_violation(resource, lane)
if violation:
excluded.append(slim_asset(resource, reason=violation, score=score, matched_terms=matched))
continue
priority = ACTIVATION_PRIORITY.get(resource.get("activation_status", ""), 0)
candidates.append((score, priority, resource["id"], resource, matched))
for score, _priority, _resource_id, resource, matched in sorted(candidates, key=lambda item: (-item[0], -item[1], item[2])):
bucket = kind_bucket(resource["kind"])
if per_kind_counts[bucket] >= normalized_budget[bucket]:
continue
if len(selected) >= max_total_assets:
break
per_kind_counts[bucket] += 1
reason = f"matches {', '.join(matched[:5])}" if matched else "matches selector query"
selected.append(slim_asset(resource, reason=reason, score=score, matched_terms=matched))
request = {
"brief": brief,
"tags": sorted(explicit_tags),
"lane": lane,
"budget": normalized_budget,
"max_total_assets": max_total_assets,
"asset_map_digest": asset_map.get("summary", {}).get("digests", {}).get("all_source_files", canonical_digest(asset_map)),
}
output = {
"schema_version": SELECTION_SCHEMA_VERSION,
"deck_intent": infer_deck_intent(brief_tokens, explicit_tags),
"lane": lane,
"selected_assets": selected,
"excluded_assets": sorted(excluded, key=lambda item: (-item.get("score", 0), item["id"]))[:50],
"prompt_budget": {
"max_assets_per_kind": normalized_budget,
"max_total_assets": max_total_assets,
"total_selected": len(selected),
"selected_per_kind": per_kind_counts,
"estimated_prompt_tokens": estimate_prompt_tokens(selected),
},
"request_digest": canonical_digest(request),
}
output["selection_digest"] = canonical_digest({"request_digest": output["request_digest"], "selected_assets": selected})
return output
def infer_deck_intent(brief_tokens: set[str], tags: set[str]) -> str:
tokens = brief_tokens | tags
if {"roadmap", "milestone", "timeline"} & tokens:
return "roadmap"
if {"strategy", "business", "market", "growth"} & tokens:
return "business_strategy"
if {"architecture", "system", "technical", "engineering", "ops"} & tokens:
return "technical_architecture"
if {"academic", "research", "thesis"} & tokens:
return "academic_report"
return "general_deck"
def estimate_prompt_tokens(selected_assets: list[dict]) -> int:
if not selected_assets:
return 0
encoded = json.dumps(selected_assets, ensure_ascii=False, sort_keys=True)
return max(1, len(encoded) // 4)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Select a small SVGlide active asset context from svglide-design-pattern-map.json.")
parser.add_argument("--asset-map", type=Path, default=default_asset_map_path())
parser.add_argument("--brief", default="", help="User brief or deck topic.")
parser.add_argument("--tags", default="", help="Comma-separated explicit selector tags.")
parser.add_argument("--lane", default="authoring", choices=["authoring", "research", "production", "golden"])
parser.add_argument("--budget", default="", help="Comma-separated per-kind overrides, e.g. chart=3,layout=2.")
parser.add_argument("--max-total-assets", type=int, default=DEFAULT_TOTAL_BUDGET)
parser.add_argument("--out-json", type=Path)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
asset_map = load_asset_map(args.asset_map)
tags = [tag.strip() for tag in args.tags.split(",") if tag.strip()]
selection = select_assets(
asset_map,
brief=args.brief,
tags=tags,
lane=args.lane,
budget=parse_budget_override(args.budget),
max_total_assets=args.max_total_assets,
)
encoded = json.dumps(selection, ensure_ascii=False, indent=2) + "\n"
if args.out_json:
args.out_json.parent.mkdir(parents=True, exist_ok=True)
args.out_json.write_text(encoded, encoding="utf-8")
else:
print(encoded, end="")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,193 @@
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import sys
import unittest
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
import svglide_asset_selector as selector
def resource(
resource_id: str,
*,
kind: str = "chart_template",
tags: list[str] | None = None,
summary: str = "business strategy roadmap dashboard chart",
activation_status: str = "active",
license_status: str = "clear",
protocol_compatibility: str = "svglide_compatible",
normalized_fixture: str = "fixtures/demo.svg",
) -> dict:
return {
"id": resource_id,
"source_path": f"svglide-design-patterns/{resource_id}.svg",
"kind": kind,
"summary": summary,
"selection_tags": tags or ["business", "strategy", "roadmap", "chart"],
"activation_status": activation_status,
"license_status": license_status,
"protocol_compatibility": protocol_compatibility,
"copy_policy": "derive_contract_only",
"normalized_primitives": ["slide_role_shape"],
"normalized_fixture": normalized_fixture,
}
def asset_map(resources: list[dict]) -> dict:
return {
"schema_version": "svglide-design-pattern-map/v1",
"summary": {"digests": {"all_source_files": "fixture"}},
"resources": resources,
}
class SVGlideAssetSelectorTest(unittest.TestCase):
def test_skill_sources_do_not_reference_external_reference_project_names(self) -> None:
skill_root = SCRIPT_DIR.parent
banned_tokens = [
"ppt" + "-master",
"ppt" + "_master",
"ppt" + " master",
"hugo" + "he3",
"ppt" + "169",
"global" + "_ai" + "_capital",
]
hits: list[str] = []
for path in skill_root.rglob("*"):
if path.suffix not in {".py", ".md", ".json"} or not path.is_file():
continue
text = path.read_text(encoding="utf-8").lower()
for token in banned_tokens:
if token in text:
hits.append(f"{path.relative_to(skill_root)} contains {token}")
self.assertEqual([], hits)
def test_reference_only_is_excluded_from_production(self) -> None:
data = asset_map(
[
resource(
"chart.reference_only",
activation_status="active",
license_status="reference_only",
tags=["business", "strategy"],
),
resource("chart.ready", activation_status="validated", license_status="clear", tags=["business", "strategy"]),
]
)
selected = selector.select_assets(data, brief="business strategy", lane="production")
self.assertEqual([asset["id"] for asset in selected["selected_assets"]], ["chart.ready"])
self.assertIn("license_not_production_ready", {asset["reason"] for asset in selected["excluded_assets"]})
def test_rejected_assets_are_never_selected(self) -> None:
data = asset_map(
[
resource("chart.rejected", activation_status="rejected", tags=["roadmap"]),
resource("chart.ready", activation_status="validated", license_status="clear", tags=["roadmap"]),
]
)
selected = selector.select_assets(data, brief="roadmap", lane="authoring")
self.assertEqual([asset["id"] for asset in selected["selected_assets"]], ["chart.ready"])
self.assertIn("rejected_asset", {asset["reason"] for asset in selected["excluded_assets"]})
def test_raw_unnormalized_assets_are_excluded_from_production(self) -> None:
data = asset_map(
[
resource(
"chart.raw",
activation_status="active",
license_status="clear",
protocol_compatibility="needs_normalization",
normalized_fixture="",
tags=["dashboard"],
),
resource("chart.ready", activation_status="active", license_status="clear", tags=["dashboard"]),
]
)
selected = selector.select_assets(data, brief="dashboard", lane="golden")
self.assertEqual([asset["id"] for asset in selected["selected_assets"]], ["chart.ready"])
self.assertIn("raw_asset_needs_normalization", {asset["reason"] for asset in selected["excluded_assets"]})
def test_candidate_assets_are_allowed_only_outside_strict_lanes(self) -> None:
data = asset_map([resource("chart.candidate", activation_status="candidate", license_status="reference_only", tags=["research"])])
authoring = selector.select_assets(data, brief="research", lane="authoring")
production = selector.select_assets(data, brief="research", lane="production")
self.assertEqual([asset["id"] for asset in authoring["selected_assets"]], ["chart.candidate"])
self.assertEqual(production["selected_assets"], [])
self.assertIn("activation_status_not_production_ready", {asset["reason"] for asset in production["excluded_assets"]})
def test_prompt_budget_caps_per_kind_and_total_context(self) -> None:
resources = [
resource(f"chart.{index}", tags=["business", "chart"], activation_status="validated", license_status="clear")
for index in range(10)
]
resources.extend(
resource(
f"layout.{index}",
kind="layout_template",
tags=["business", "layout"],
activation_status="validated",
license_status="clear",
)
for index in range(5)
)
data = asset_map(resources)
selected = selector.select_assets(
data,
brief="business chart layout",
lane="production",
budget={"chart": 2, "layout": 2},
max_total_assets=3,
)
self.assertLessEqual(selected["prompt_budget"]["total_selected"], 3)
self.assertLessEqual(selected["prompt_budget"]["selected_per_kind"]["chart"], 2)
self.assertLessEqual(selected["prompt_budget"]["selected_per_kind"]["layout"], 2)
self.assertLess(selected["prompt_budget"]["estimated_prompt_tokens"], 500)
self.assertNotIn("resources", selected)
def test_brief_changes_selection_digest(self) -> None:
data = asset_map(
[
resource(
"chart.roadmap",
tags=["roadmap"],
summary="roadmap milestones timeline",
activation_status="validated",
license_status="clear",
),
resource(
"chart.market",
tags=["market"],
summary="market sizing and growth",
activation_status="validated",
license_status="clear",
),
]
)
roadmap = selector.select_assets(data, brief="roadmap", lane="production")
market = selector.select_assets(data, brief="market", lane="production")
self.assertNotEqual(roadmap["request_digest"], market["request_digest"])
self.assertNotEqual(roadmap["selection_digest"], market["selection_digest"])
self.assertEqual([asset["id"] for asset in roadmap["selected_assets"]], ["chart.roadmap"])
self.assertEqual([asset["id"] for asset in market["selected_assets"]], ["chart.market"])
if __name__ == "__main__":
unittest.main()

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,170 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
REGISTRY_SCHEMA_VERSION = "svglide-renderer-registry/v1"
ACTIVE_STATUSES = {"active", "candidate", "blocked", "deprecated"}
def script_path() -> Path:
return Path(__file__).resolve()
def references_dir() -> Path:
return script_path().parents[1] / "references"
def read_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def registry_path(ref_dir: Path | None = None) -> Path:
return (ref_dir or references_dir()) / "svglide-renderer-registry.json"
def load_registry(ref_dir: Path | None = None) -> dict[str, Any]:
data = read_json(registry_path(ref_dir))
if not isinstance(data, dict):
raise ValueError("renderer registry must contain a JSON object")
return data
def load_catalog_ids(ref_dir: Path | None = None) -> dict[str, set[str]]:
root = ref_dir or references_dir()
seeds = read_json(root / "svg-seeds.json")
recipes = read_json(root / "svg-recipes.json")
if not isinstance(seeds, dict) or not isinstance(recipes, dict):
raise ValueError("seed and recipe catalogs must contain JSON objects")
seed_ids = set((seeds.get("seeds") or {}).keys()) if isinstance(seeds.get("seeds"), dict) else set()
recipe_ids = set((recipes.get("recipes") or {}).keys()) if isinstance(recipes.get("recipes"), dict) else set()
chart_type_ids = (
set((recipes.get("chart_type_contracts") or {}).keys())
if isinstance(recipes.get("chart_type_contracts"), dict)
else set()
)
return {"seeds": seed_ids, "recipes": recipe_ids, "chart_types": chart_type_ids}
def text(value: Any) -> str:
return str(value).strip() if value is not None else ""
def list_text(value: Any) -> list[str]:
if not isinstance(value, list):
return []
return [text(item) for item in value if text(item)]
def validate_registry(data: dict[str, Any], catalog_ids: dict[str, set[str]]) -> dict[str, Any]:
issues: list[dict[str, Any]] = []
if data.get("schema_version") != REGISTRY_SCHEMA_VERSION:
issues.append({"level": "error", "code": "invalid_schema_version"})
renderers = data.get("renderers")
if not isinstance(renderers, list) or not renderers:
issues.append({"level": "error", "code": "missing_renderers"})
renderers = []
seen: set[str] = set()
active_count = 0
candidate_count = 0
active_seed_ids: set[str] = set()
active_recipe_ids: set[str] = set()
active_page_kinds: set[str] = set()
for index, item in enumerate(renderers, 1):
if not isinstance(item, dict):
issues.append({"level": "error", "code": "renderer_not_object", "index": index})
continue
renderer_id = text(item.get("id"))
status = text(item.get("status")) or "candidate"
seed_id = text(item.get("layout_seed_id"))
recipe_id = text(item.get("visual_recipe_id"))
page_kind = text(item.get("page_kind"))
if not renderer_id:
issues.append({"level": "error", "code": "missing_renderer_id", "index": index})
continue
if renderer_id in seen:
issues.append({"level": "error", "code": "duplicate_renderer_id", "renderer_id": renderer_id})
seen.add(renderer_id)
if status not in ACTIVE_STATUSES:
issues.append({"level": "error", "code": "invalid_status", "renderer_id": renderer_id, "status": status})
if status == "active":
active_count += 1
active_seed_ids.add(seed_id)
active_recipe_ids.add(recipe_id)
active_page_kinds.add(page_kind)
required = {
"page_kind": page_kind,
"runtime_renderer_family": text(item.get("runtime_renderer_family")),
"layout_seed_id": seed_id,
"visual_recipe_id": recipe_id,
}
for field, value in required.items():
if not value:
issues.append({"level": "error", "code": f"active_renderer_missing_{field}", "renderer_id": renderer_id})
if seed_id and seed_id not in catalog_ids["seeds"]:
issues.append({"level": "error", "code": "unknown_layout_seed", "renderer_id": renderer_id, "layout_seed_id": seed_id})
if recipe_id and recipe_id not in catalog_ids["recipes"]:
issues.append({"level": "error", "code": "unknown_visual_recipe", "renderer_id": renderer_id, "visual_recipe_id": recipe_id})
for chart_type in list_text(item.get("chart_types")):
if chart_type not in catalog_ids["chart_types"]:
issues.append({"level": "error", "code": "unknown_chart_type", "renderer_id": renderer_id, "chart_type": chart_type})
if not list_text(item.get("style_reskin_hooks")):
issues.append({"level": "warning", "code": "missing_style_reskin_hooks", "renderer_id": renderer_id})
if not list_text(item.get("required_primitives")):
issues.append({"level": "warning", "code": "missing_required_primitives", "renderer_id": renderer_id})
elif status == "candidate":
candidate_count += 1
return {
"schema_version": REGISTRY_SCHEMA_VERSION,
"status": "passed" if not any(issue["level"] == "error" for issue in issues) else "failed",
"summary": {
"renderer_count": len(renderers),
"active_count": active_count,
"candidate_count": candidate_count,
"active_seed_count": len({item for item in active_seed_ids if item}),
"active_recipe_count": len({item for item in active_recipe_ids if item}),
"active_page_kind_count": len({item for item in active_page_kinds if item}),
"error_count": sum(1 for issue in issues if issue["level"] == "error"),
"warning_count": sum(1 for issue in issues if issue["level"] == "warning"),
},
"issues": issues,
}
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Validate the SVGlide renderer registry.")
parser.add_argument("--references-dir", default="", help="Override references directory")
parser.add_argument("--json", action="store_true", help="Emit JSON report")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
ref_dir = Path(args.references_dir).expanduser() if args.references_dir else references_dir()
report = validate_registry(load_registry(ref_dir), load_catalog_ids(ref_dir))
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
else:
summary = report["summary"]
print(
"renderer registry: "
f"{report['status']} "
f"({summary['active_count']} active, {summary['candidate_count']} candidate, "
f"{summary['error_count']} errors, {summary['warning_count']} warnings)"
)
return 0 if report["status"] == "passed" else 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,54 @@
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import contextlib
import io
import json
import sys
import unittest
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))
import svglide_renderer_registry as registry
class SVGlideRendererRegistryTest(unittest.TestCase):
def test_registry_validates_active_renderers_against_catalogs(self) -> None:
report = registry.validate_registry(registry.load_registry(), registry.load_catalog_ids())
self.assertEqual("passed", report["status"])
self.assertGreaterEqual(report["summary"]["active_count"], 10)
self.assertGreaterEqual(report["summary"]["active_page_kind_count"], 10)
self.assertEqual(0, report["summary"]["error_count"])
def test_registry_has_no_external_reference_project_words(self) -> None:
encoded = json.dumps(registry.load_registry(), ensure_ascii=False).lower()
banned_tokens = [
"ppt" + "-master",
"ppt" + "_master",
"ppt" + " master",
"hugo" + "he3",
"ppt" + "169",
]
for token in banned_tokens:
with self.subTest(token=token):
self.assertNotIn(token, encoded)
def test_cli_json_report(self) -> None:
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
exit_code = registry.main(["--json"])
self.assertEqual(0, exit_code)
report = json.loads(stdout.getvalue())
self.assertEqual("svglide-renderer-registry/v1", report["schema_version"])
self.assertEqual("passed", report["status"])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,917 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import argparse
import copy
import json
import re
import sys
from pathlib import Path
from typing import Any
CONTRACT_SCHEMA_VERSION = "svglide-strategist-contract/v1"
TOKEN_RE = re.compile(r"[a-z0-9]+|[\u4e00-\u9fff]+", re.IGNORECASE)
HEX_COLOR_RE = re.compile(r"#[0-9A-Fa-f]{6}")
CANVAS = {"width": 960, "height": 540, "viewBox": "0 0 960 540"}
SAFE_AREA = {"x": 48, "y": 40, "width": 864, "height": 460}
DEFAULT_GUARDRAILS = [
"renderer_id must change actual geometry, not only the name",
"visual_recipe must map to SVGlide-safe primitives present in the SVG source",
"main text and chart labels stay inside safe area",
"dense page uses a structured visual carrier, not a long bullet box",
"avoid XML-like card layout unless the page has real SVG-native visual structure",
]
PAGE_PROFILES: dict[str, dict[str, Any]] = {
"cover": {
"seed_id": "cover_hero_statement",
"page_type": "cover",
"composition_archetype": "full_bleed_field",
"required_visual_evidence": ["full_page_archetype", "hero_route", "title_field"],
"primary_motif": "hero_route",
"chart_type": "",
"page_rhythm": "anchor",
"renderer_id": "cover_hero_statement",
"main_visual_anchor": {"layout_box_role": "visual", "description": "large thesis text paired with one abstract SVG motif"},
"svg_effects": ["typography", "path"],
"asset_id": "chart.vertical_list",
"density_contract": "one thesis plus one visual motif",
},
"agenda": {
"seed_id": "agenda_numbered_path",
"page_type": "agenda",
"composition_archetype": "indexed_path",
"required_visual_evidence": ["numbered_path", "section_index", "semantic_labels"],
"primary_motif": "numbered_route",
"chart_type": "",
"page_rhythm": "breathing",
"renderer_id": "agenda_numbered_path",
"main_visual_anchor": {"layout_box_role": "timeline", "description": "numbered agenda route with compact section labels"},
"svg_effects": ["typography", "connector_flow", "path"],
"asset_id": "chart.agenda_list",
"density_contract": "agenda route >= 4 section labels",
},
"section": {
"seed_id": "section_divider_index",
"page_type": "section_divider",
"composition_archetype": "section_signal",
"required_visual_evidence": ["section_index", "hero_signal", "full_page_archetype"],
"primary_motif": "section_index",
"chart_type": "",
"page_rhythm": "anchor",
"renderer_id": "section_divider_index",
"main_visual_anchor": {"layout_box_role": "visual", "description": "oversized chapter index paired with a full-page signal field"},
"svg_effects": ["typography", "gradient"],
"asset_id": "chart.numbered_steps",
"density_contract": "one chapter signal plus one transition sentence",
},
"dashboard": {
"seed_id": "dashboard_kpi_grid",
"page_type": "kpi_overview",
"composition_archetype": "data_stage",
"required_visual_evidence": ["metric_hierarchy", "chart_geometry", "dashboard_grid"],
"primary_motif": "metric_grid",
"chart_type": "",
"page_rhythm": "dense",
"renderer_id": "dashboard_kpi_grid",
"main_visual_anchor": {"layout_box_role": "chart", "description": "KPI dashboard grid with hero metrics and micro trends"},
"svg_effects": ["typography", "chart_geometry"],
"asset_id": "chart.kpi_cards",
"density_contract": "dashboard >= 4 metrics",
},
"roadmap": {
"seed_id": "timeline_roadmap",
"page_type": "roadmap",
"composition_archetype": "layered_timeline",
"required_visual_evidence": ["connector_flow", "phase_spine", "full_page_archetype"],
"primary_motif": "phase_spine",
"chart_type": "",
"page_rhythm": "dense",
"renderer_id": "timeline_roadmap",
"main_visual_anchor": {"layout_box_role": "timeline", "description": "milestone spine with compact phase labels"},
"svg_effects": ["typography", "connector_flow", "path"],
"asset_id": "chart.timeline",
"density_contract": "timeline >= 3 milestones",
},
"process": {
"seed_id": "process_pipeline",
"page_type": "process_flow",
"composition_archetype": "layered_timeline",
"required_visual_evidence": ["connector_flow", "flow_lanes", "full_page_archetype"],
"primary_motif": "flow_route",
"chart_type": "",
"page_rhythm": "dense",
"renderer_id": "process_pipeline",
"main_visual_anchor": {"layout_box_role": "flow", "description": "left-to-right process path with input and output anchors"},
"svg_effects": ["typography", "connector_flow", "path"],
"asset_id": "chart.process_flow",
"density_contract": "process path >= 4 steps",
},
"comparison": {
"seed_id": "comparison_two_column_decision",
"page_type": "comparison",
"composition_archetype": "comparison_matrix",
"required_visual_evidence": ["decision_matrix", "contrast_panels", "semantic_labels"],
"primary_motif": "decision_axis",
"chart_type": "",
"page_rhythm": "dense",
"renderer_id": "comparison_two_column_decision",
"main_visual_anchor": {"layout_box_role": "table", "description": "two-column decision matrix with dimension rail"},
"svg_effects": ["typography", "path"],
"asset_id": "chart.comparison_table",
"density_contract": "comparison table >= 4 cells",
},
"capability": {
"seed_id": "capability_icon_map",
"page_type": "capability_map",
"composition_archetype": "radial_system",
"required_visual_evidence": ["hub_spoke", "sector_field", "semantic_labels"],
"primary_motif": "radial_hub",
"chart_type": "hub_spoke",
"page_rhythm": "dense",
"renderer_id": "capability_icon_map",
"main_visual_anchor": {"layout_box_role": "visual", "description": "central capability node with surrounding module grid"},
"svg_effects": ["typography", "connector_flow"],
"asset_id": "chart.hub_spoke",
"density_contract": "capability map >= 4 nodes",
},
"chart": {
"seed_id": "single_chart_takeaway",
"page_type": "chart_takeaway",
"composition_archetype": "data_stage",
"required_visual_evidence": ["chart_geometry", "insight_strip", "full_page_archetype"],
"primary_motif": "takeaway_chart",
"chart_type": "bar_chart",
"page_rhythm": "dense",
"renderer_id": "single_chart_takeaway",
"main_visual_anchor": {"layout_box_role": "chart", "description": "single chart area with one takeaway annotation"},
"svg_effects": ["typography", "chart_geometry"],
"asset_id": "chart.bar_chart",
"density_contract": "chart >= 3 visible marks",
},
"closing": {
"seed_id": "closing_summary",
"page_type": "closing",
"composition_archetype": "closing_manifesto",
"required_visual_evidence": ["closing_ribbon", "action_cards", "full_page_archetype"],
"primary_motif": "closing_route",
"chart_type": "",
"page_rhythm": "anchor",
"renderer_id": "closing_summary",
"main_visual_anchor": {"layout_box_role": "callout", "description": "closing statement plus next-action callout"},
"svg_effects": ["typography"],
"asset_id": "chart.numbered_steps",
"density_contract": "one closing message plus one next action",
},
"annotation": {
"seed_id": "spotlight_diagnosis_callout",
"page_type": "insight_callout",
"composition_archetype": "annotated_spotlight",
"required_visual_evidence": ["spotlight", "annotation", "semantic_labels"],
"primary_motif": "spotlight_field",
"chart_type": "",
"page_rhythm": "breathing",
"renderer_id": "spotlight_diagnosis_callout",
"main_visual_anchor": {"layout_box_role": "spotlight", "description": "annotated visual field with one spotlight and side note"},
"svg_effects": ["typography", "spotlight"],
"asset_id": "chart.labeled_card",
"density_contract": "spotlight callout <= 2 targets",
},
}
KEYWORD_PROFILES: tuple[tuple[str, tuple[str, ...]], ...] = (
("agenda", ("agenda", "contents", "table of contents", "toc", "目录", "议程")),
("section", ("section", "chapter", "section divider", "divider", "transition", "章节", "过渡", "转场", "第1章", "第2章", "第3章", "01 ", "02 ", "03 ")),
("dashboard", ("dashboard", "kpi", "metric", "metrics", "status", "scorecard", "health", "看板", "指标", "状态")),
("roadmap", ("roadmap", "timeline", "milestone", "phase", "plan", "规划", "里程碑", "阶段")),
("process", ("process", "pipeline", "workflow", "flow", "funnel", "步骤", "流程", "链路")),
("comparison", ("compare", "comparison", "versus", "vs", "matrix", "table", "decision", "对比", "比较", "矩阵")),
("capability", ("capability", "module", "architecture", "system", "hub", "spoke", "能力", "模块", "架构")),
("chart", ("chart", "bar", "line", "trend", "data", "evidence", "数据", "图表", "趋势")),
("closing", ("closing", "summary", "next", "thanks", "q&a", "结尾", "总结", "下一步")),
("cover", ("cover", "opening", "title", "thesis", "封面", "开场", "标题")),
)
STYLE_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = (
("raw_grid", ("dashboard", "kpi", "metric", "technical", "dense", "status", "ops", "operation", "看板", "指标")),
("long_table", ("process", "pipeline", "responsibility", "plan", "workflow", "流程", "责任")),
("riptide_cobalt", ("technology", "architecture", "flow", "system", "tech", "技术", "架构")),
("data_journalism_editorial", ("financial", "market", "report", "data", "analysis", "finance", "数据", "分析")),
("monochrome", ("serious", "formal", "minimal", "decision", "compare", "正式", "决策")),
)
VISUAL_STYLE_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = (
("operational_dashboard", ("dashboard", "kpi", "metric", "status", "ops", "operation", "看板", "指标", "运营")),
("technical_system", ("technology", "architecture", "system", "platform", "infra", "technical", "技术", "架构", "系统", "平台")),
("data_journalism", ("financial", "market", "capital", "report", "analysis", "data", "finance", "资本", "市场", "数据", "分析")),
("premium_regional", ("regional", "city", "residence", "planning", "oasis", "water", "culture", "城市", "居住", "策划", "绿洲", "水系", "地域", "人文", "阿克苏", "新疆")),
("process_playbook", ("process", "pipeline", "workflow", "roadmap", "playbook", "流程", "链路", "路线", "规划")),
("executive_minimal", ("serious", "formal", "minimal", "decision", "board", "strategy", "正式", "决策", "战略")),
)
IMAGE_STRATEGY_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = (
("provided", ("attachment", "provided image", "source image", "uploaded", "附件", "已提供图片", "素材包")),
("web", ("web search", "search image", "website", "online", "网页", "联网", "搜索图片")),
("ai", ("generate image", "ai image", "illustration", "rendering", "生成图片", "插画", "效果图")),
)
NARRATIVE_MODES = {"briefing", "instructional", "narrative", "pyramid", "showcase"}
MODE_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = (
("instructional", ("training", "tutorial", "course", "lesson", "how-to", "playbook", "教学", "培训", "教程")),
("pyramid", ("decision", "board", "strategy", "proposal", "investment", "consulting", "决策", "战略", "提案", "投资")),
("showcase", ("launch", "showcase", "portfolio", "brand", "event", "product reveal", "发布", "展示", "作品集", "品牌")),
("narrative", ("story", "journey", "case", "vision", "future", "体验", "旅程", "故事", "案例", "愿景")),
("briefing", ("briefing", "review", "report", "status", "operations", "weekly", "汇报", "报告", "复盘", "经营", "周报")),
)
def script_path() -> Path:
return Path(__file__).resolve()
def references_dir() -> Path:
return script_path().parents[1] / "references"
def read_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def clone_json(value: Any) -> Any:
return copy.deepcopy(value)
def tokenize(*values: object) -> set[str]:
tokens: set[str] = set()
for value in values:
if value is None:
continue
if isinstance(value, dict):
tokens.update(tokenize(*value.values()))
continue
if isinstance(value, (list, tuple, set)):
tokens.update(tokenize(*value))
continue
tokens.update(match.group(0).lower() for match in TOKEN_RE.finditer(str(value)))
return tokens
def compact_text(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value.strip()
if isinstance(value, dict):
return " ".join(compact_text(item) for item in value.values() if compact_text(item))
if isinstance(value, (list, tuple, set)):
return " ".join(compact_text(item) for item in value if compact_text(item))
return str(value).strip()
def load_catalogs(ref_dir: Path | None = None) -> dict[str, Any]:
root = ref_dir or references_dir()
style_data = read_json(root / "style-presets.json")
seed_data = read_json(root / "svg-seeds.json")
recipe_data = read_json(root / "svg-recipes.json")
pattern_data = read_json(root / "svglide-design-pattern-map.json")
renderer_path = root / "svglide-renderer-registry.json"
renderer_data = read_json(renderer_path) if renderer_path.exists() else {"renderers": []}
renderers = {
item["id"]: item
for item in renderer_data.get("renderers", [])
if isinstance(item, dict) and isinstance(item.get("id"), str) and item.get("id")
}
return {
"style_presets": {item["style_id"]: item for item in style_data.get("presets", []) if isinstance(item, dict) and item.get("style_id")},
"seeds": seed_data.get("seeds", {}),
"recipes": recipe_data.get("recipes", {}),
"chart_type_contracts": recipe_data.get("chart_type_contracts", {}),
"pattern_ids": {item.get("id") for item in pattern_data.get("resources", []) if isinstance(item, dict) and item.get("id")},
"renderers": renderers,
}
def first_present(data: dict[str, Any], keys: tuple[str, ...]) -> str:
for key in keys:
value = compact_text(data.get(key))
if value:
return value
visual_plan = data.get("visual_plan")
if isinstance(visual_plan, dict):
for key in keys:
value = compact_text(visual_plan.get(key))
if value:
return value
return ""
def classify_profile(text: str, *, index: int, total: int) -> str:
lowered = text.lower()
if index == 0 and any(keyword in lowered for keyword in ("cover", "opening", "title", "thesis", "封面", "开场")):
return "cover"
if total > 1 and index == total - 1 and any(keyword in lowered for keyword in ("closing", "summary", "next", "thanks", "q&a", "结尾", "总结", "下一步")):
return "closing"
for profile, keywords in KEYWORD_PROFILES:
if any(keyword in lowered for keyword in keywords):
return profile
if index == 0:
return "cover"
if total > 1 and index == total - 1:
return "closing"
return "annotation"
def style_preset_from_brief(brief: str, catalogs: dict[str, Any]) -> str:
lowered = brief.lower()
for style_id, keywords in STYLE_HINTS:
if style_id in catalogs["style_presets"] and any(keyword in lowered for keyword in keywords):
return style_id
return "raw_grid" if "raw_grid" in catalogs["style_presets"] else sorted(catalogs["style_presets"])[0]
def narrative_mode_from_brief(brief: str, slide_plan: dict[str, Any] | None = None) -> str:
lowered = " ".join([brief, compact_text(slide_plan or {})]).lower()
tokens = tokenize(lowered)
for mode, keywords in MODE_HINTS:
if any((keyword in tokens if re.fullmatch(r"[a-z0-9-]+", keyword) else keyword in lowered) for keyword in keywords):
return mode
return "briefing"
def visual_style_from_brief(brief: str, slide_plan: dict[str, Any] | None = None) -> str:
explicit = normalize_public_token(first_present(slide_plan or {}, ("visual_style", "visualStyle", "style_target")))
if explicit:
return explicit
lowered = " ".join([brief, compact_text(slide_plan or {})]).lower()
tokens = tokenize(lowered)
for style, keywords in VISUAL_STYLE_HINTS:
if any((keyword in tokens if re.fullmatch(r"[a-z0-9-]+", keyword) else keyword in lowered) for keyword in keywords):
return style
return "data_journalism" if "data" in tokens else "executive_minimal"
def image_strategy_from_brief(brief: str, slide_plan: dict[str, Any] | None = None) -> str:
existing = slide_plan.get("asset_strategy") if isinstance(slide_plan, dict) else None
if isinstance(existing, dict):
mode = normalize_public_token(existing.get("mode") or existing.get("image_strategy") or existing.get("imageStrategy"))
if mode in {"provided", "web", "ai", "svg", "none", "authoring_preview_rich", "online_pure_fallback", "production_asset_strict"}:
return mode
lowered = " ".join([brief, compact_text(slide_plan or {})]).lower()
tokens = tokenize(lowered)
for mode, keywords in IMAGE_STRATEGY_HINTS:
if any((keyword in tokens if re.fullmatch(r"[a-z0-9-]+", keyword) else keyword in lowered) for keyword in keywords):
return mode
return "svg"
def normalize_public_token(value: Any) -> str:
return re.sub(r"[^a-z0-9_]+", "_", compact_text(value).strip().lower()).strip("_")
def style_system_from_preset(style_id: str, catalogs: dict[str, Any]) -> dict[str, Any]:
preset = catalogs["style_presets"].get(style_id, {})
palette = preset.get("palette") if isinstance(preset.get("palette"), dict) else {}
shape_language = preset.get("shape_language") if isinstance(preset.get("shape_language"), dict) else {}
density = preset.get("density") if isinstance(preset.get("density"), dict) else {}
return {
"palette": {
"background": palette.get("background", "#F5F5F5"),
"text": palette.get("text", "#0A0A0A"),
"accent": palette.get("accent", "#2563EB"),
"support": clone_json(palette.get("support", [])),
},
"typography": "strong title, readable native text labels",
"background_strategy": shape_language.get("panel_treatment", "structured panels with explicit text surfaces"),
"motif": shape_language.get("texture", "local SVG motif derived from selected page recipe"),
"density": {
"text_density": density.get("text_density", "medium"),
"label_density": density.get("label_density", "medium"),
"connector_density": density.get("connector_density", "medium"),
},
}
def apply_brief_palette(style_system: dict[str, Any], brief: str) -> dict[str, Any]:
colors: list[str] = []
for match in HEX_COLOR_RE.finditer(brief):
color = match.group(0).upper()
if color not in colors:
colors.append(color)
if not colors:
return style_system
output = clone_json(style_system)
palette = output.setdefault("palette", {})
if isinstance(palette, dict):
palette["accent"] = colors[0]
if len(colors) > 1:
palette["support"] = colors[1:]
output["palette_source"] = "brief_hex_colors"
return output
def slide_text(slide: dict[str, Any], fallback_description: str) -> str:
parts = [
first_present(slide, ("title", "headline", "name")),
first_present(slide, ("description", "summary", "body", "key_message", "page_type", "chart_type", "visual_recipe")),
fallback_description,
]
return " ".join(part for part in parts if part)
def seed_for_profile(profile: str, catalogs: dict[str, Any]) -> tuple[str, dict[str, Any]]:
seed_id = PAGE_PROFILES[profile]["seed_id"]
seeds = catalogs["seeds"]
if seed_id not in seeds:
raise ValueError(f"missing SVG seed: {seed_id}")
return seed_id, seeds[seed_id]
def seed_for_slide(slide: dict[str, Any], profile: str, catalogs: dict[str, Any]) -> tuple[str, dict[str, Any], dict[str, Any]]:
explicit_seed = compact_text(slide.get("seed_id"))
seeds = catalogs["seeds"]
if explicit_seed and explicit_seed in seeds:
seed = seeds[explicit_seed]
profile_data = dict(PAGE_PROFILES[profile])
profile_data["seed_id"] = explicit_seed
return explicit_seed, seed, profile_data
chart_type = compact_text(slide.get("chart_type")).replace("-", "_").lower()
visual_recipe = compact_text(slide.get("visual_recipe")).replace("-", "_").lower()
for candidate, data in PAGE_PROFILES.items():
if chart_type and chart_type == data["chart_type"]:
seed_id, seed = seed_for_profile(candidate, catalogs)
return seed_id, seed, data
if visual_recipe and seeds.get(data["seed_id"], {}).get("visual_recipe") == visual_recipe:
seed_id, seed = seed_for_profile(candidate, catalogs)
return seed_id, seed, data
seed_id, seed = seed_for_profile(profile, catalogs)
return seed_id, seed, PAGE_PROFILES[profile]
def list_union(*values: Any) -> list[str]:
out: list[str] = []
for value in values:
if isinstance(value, str):
candidates = [value]
elif isinstance(value, (list, tuple, set)):
candidates = [str(item) for item in value if str(item).strip()]
else:
candidates = []
for item in candidates:
if item not in out:
out.append(item)
return out
def setdefault_clone(target: dict[str, Any], key: str, value: Any) -> None:
if key not in target or target[key] in (None, "", []):
target[key] = clone_json(value)
def normalize_empty_chart_type(value: Any) -> str:
normalized = compact_text(value).replace("-", "_").lower()
return "" if normalized in {"none", "na", "n_a", "not_applicable", "no_chart", "no"} else normalized
def source_pack_from_plan(brief: str, output: dict[str, Any]) -> dict[str, Any]:
existing = output.get("source_pack")
if isinstance(existing, dict):
return clone_json(existing)
status = "user_prompt_only" if compact_text(brief or output) else "missing"
return {
"schema_version": "svglide-source-pack/v1",
"source_status": status,
"numeric_claim_policy": "cite_or_remove",
"items": [
{
"id": "brief",
"type": "user_prompt",
"status": "available" if status != "missing" else "missing",
"source_ref": "source/brief.md",
"usage_pages": "all",
"license": "user_provided",
}
],
}
def source_pack_item_ids(source_pack: dict[str, Any]) -> set[str]:
items = source_pack.get("items")
if not isinstance(items, list):
return set()
return {compact_text(item.get("id")) for item in items if isinstance(item, dict) and compact_text(item.get("id"))}
def source_pack_status(source_pack: dict[str, Any]) -> str:
status = compact_text(source_pack.get("source_status"))
if status:
return status
items = source_pack.get("items")
if isinstance(items, list) and items:
return "user_provided"
return "missing"
def asset_strategy_contract(image_strategy: str) -> dict[str, Any]:
return {
"mode": "authoring_preview_rich" if image_strategy in {"provided", "web", "ai"} else "online_pure_fallback",
"image_strategy": image_strategy,
"fallback": "prefer SVGlide-safe vector primitives when source or license is unavailable",
}
def icon_policy_contract() -> dict[str, Any]:
return {
"style": "single_consistent_family",
"semantic_mapping_required": True,
"consistency_rule": "one deck uses one icon stroke/fill language; icons must label concepts rather than decorate empty space",
}
def chart_policy_contract() -> dict[str, Any]:
return {
"selection_rule": "data_relationship_first",
"requires_data_coordinate_check": True,
"receipt": "receipts/chart-verify.json",
}
def strategy_locks_from_contract(
*,
output: dict[str, Any],
page_count: int,
narrative_mode: str,
visual_style: str,
style_id: str,
image_strategy: str,
) -> list[dict[str, Any]]:
existing = output.get("strategy_locks")
if isinstance(existing, list) and existing:
return clone_json(existing)
audience = compact_text(output.get("audience")) or "inferred_from_brief"
return [
{"id": "canvas", "decision": clone_json(CANVAS), "evidence_ref": "plan.canvas"},
{"id": "page_count", "decision": page_count, "evidence_ref": "plan.page_count"},
{"id": "audience", "decision": audience, "evidence_ref": "plan.audience"},
{"id": "narrative_mode", "decision": narrative_mode, "evidence_ref": "plan.narrative_mode"},
{"id": "visual_style", "decision": visual_style, "evidence_ref": "plan.visual_style"},
{"id": "style_preset", "decision": style_id, "evidence_ref": "plan.style_preset"},
{"id": "asset_strategy", "decision": image_strategy, "evidence_ref": "plan.asset_strategy.mode"},
{"id": "chart_policy", "decision": "data_relationship_first", "evidence_ref": "plan.chart_policy"},
]
def chart_decision_for_slide(completed: dict[str, Any], profile_data: dict[str, Any]) -> dict[str, Any]:
chart_type = compact_text(completed.get("chart_type"))
anchor = completed.get("main_visual_anchor")
anchor_role = ""
if isinstance(anchor, dict):
anchor_role = compact_text(anchor.get("layout_box_role") or anchor.get("role"))
if not chart_type:
return {
"status": "not_required",
"chart_type": "",
"reason": "page visual carrier is not a data chart",
"data_ref": "brief",
"anchor_role": anchor_role,
"bbox_tolerance_px": 12,
}
return {
"status": "required",
"chart_type": chart_type,
"reason": f"{chart_type} fits {profile_data['page_type']} because the page needs {profile_data['density_contract']}",
"data_ref": "brief",
"anchor_role": anchor_role or "chart",
"bbox_tolerance_px": 12,
}
def chart_verification_for_slide(chart_decision: dict[str, Any]) -> dict[str, Any]:
if chart_decision.get("status") != "required":
return {"status": "not_required"}
return {
"status": "required",
"method": "verify data-to-geometry mapping for visible chart marks",
"receipt": "receipts/chart-verify.json",
"checks": ["plot_area", "mark_count", "label_alignment", "scale_mapping"],
}
def asset_selection_reason(profile_data: dict[str, Any]) -> str:
return (
f"{profile_data['asset_id']} is selected for {profile_data['page_type']} because it provides "
f"{profile_data['composition_archetype']} geometry and supports {profile_data['density_contract']}."
)
def rejected_asset_alternatives(profile_data: dict[str, Any]) -> list[dict[str, str]]:
page_type = profile_data["page_type"]
alternatives: list[dict[str, str]] = []
if page_type not in {"kpi_overview", "chart_takeaway"}:
alternatives.append({"asset_id": "chart.kpi_cards", "reason": "rejected because the page is not metric-led"})
if page_type not in {"roadmap", "process_flow", "agenda"}:
alternatives.append({"asset_id": "chart.timeline", "reason": "rejected because a phase spine would weaken the page purpose"})
if page_type not in {"capability_map"}:
alternatives.append({"asset_id": "chart.hub_spoke", "reason": "rejected because the page does not need a central-system relationship"})
return alternatives[:2]
def known_profile_evidence() -> set[str]:
evidence: set[str] = set()
for profile in PAGE_PROFILES.values():
raw = profile.get("required_visual_evidence")
if isinstance(raw, list):
evidence.update(str(item) for item in raw if str(item).strip())
return evidence
def complete_visual_design_contract(completed: dict[str, Any], profile_data: dict[str, Any]) -> dict[str, Any]:
existing = completed.get("visual_design_contract")
contract = clone_json(existing) if isinstance(existing, dict) else {}
thesis = first_present(completed, ("visual_thesis", "key_message", "one_idea", "title", "headline", "description"))
existing_required = list_union(contract.get("required_visual_evidence"))
manual_required = [item for item in existing_required if item not in known_profile_evidence()]
required = list_union(manual_required, profile_data.get("required_visual_evidence"))
pattern_bundle = list_union(contract.get("pattern_bundle"), profile_data.get("asset_id"))
setdefault_clone(contract, "schema_version", "svglide-visual-design-contract/v1")
setdefault_clone(contract, "page_kind", profile_data["page_type"])
setdefault_clone(contract, "visual_thesis", thesis)
setdefault_clone(contract, "composition_archetype", profile_data["composition_archetype"])
contract["pattern_bundle"] = pattern_bundle
setdefault_clone(contract, "density", profile_data["page_rhythm"])
setdefault_clone(contract, "primary_motif", profile_data["primary_motif"])
contract["required_visual_evidence"] = required
setdefault_clone(contract, "renderer_id", profile_data["renderer_id"])
setdefault_clone(contract, "layout_seed_id", completed.get("seed_id"))
setdefault_clone(contract, "visual_recipe", completed.get("visual_recipe"))
return contract
def complete_slide(slide: dict[str, Any], *, brief: str, fallback_description: str, index: int, total: int, catalogs: dict[str, Any]) -> dict[str, Any]:
completed = clone_json(slide)
if "chart_type" in completed:
completed["chart_type"] = normalize_empty_chart_type(completed.get("chart_type"))
text = slide_text(completed, fallback_description) or brief
profile = classify_profile(text, index=index, total=total)
seed_id, seed, profile_data = seed_for_slide(completed, profile, catalogs)
renderer_contract = catalogs.get("renderers", {}).get(profile_data["renderer_id"], {})
recipe = compact_text(completed.get("visual_recipe")) or compact_text(seed.get("visual_recipe"))
recipe_contract = catalogs["recipes"].get(recipe, {})
required_primitives = list_union(recipe_contract.get("required_primitives"), seed.get("required_primitives"), completed.get("required_primitives"))
svg_primitives = list_union(completed.get("svg_primitives"), required_primitives, ["typography", "geometric_shape"])
setdefault_clone(completed, "page", index + 1)
setdefault_clone(completed, "key_message", first_present(completed, ("key_message", "one_idea", "title", "headline", "description")) or text)
setdefault_clone(completed, "renderer_id", profile_data["renderer_id"])
if isinstance(renderer_contract, dict) and renderer_contract:
setdefault_clone(completed, "renderer_registry_status", renderer_contract.get("status"))
setdefault_clone(completed, "runtime_renderer_family", renderer_contract.get("runtime_renderer_family"))
setdefault_clone(completed, "style_reskin_hooks", renderer_contract.get("style_reskin_hooks", []))
setdefault_clone(completed, "page_rhythm", profile_data["page_rhythm"])
setdefault_clone(completed, "page_type", profile_data["page_type"])
setdefault_clone(completed, "chart_type", profile_data["chart_type"])
setdefault_clone(completed, "main_visual_anchor", profile_data["main_visual_anchor"])
setdefault_clone(completed, "seed_id", seed_id)
completed["layout_skeleton_id"] = clone_json(seed.get("layout_skeleton", {}).get("id", f"{seed_id}_skeleton"))
completed["layout_family"] = clone_json(seed.get("layout_family", profile))
setdefault_clone(completed, "visual_recipe", recipe)
setdefault_clone(completed, "visual_signature", f"{profile_data['page_type']} / {profile_data['renderer_id']} / {profile_data['asset_id']}")
completed["svg_effects"] = clone_json(profile_data["svg_effects"])
completed["layout_boxes"] = clone_json(seed.get("layout_boxes", []))
completed["content_budget"] = clone_json(seed.get("content_budget", {}))
completed["text_capacity"] = clone_json(seed.get("default_text_capacity") or seed.get("content_budget", {}))
completed["text_budget_by_role"] = clone_json(seed.get("text_budget_by_role", {}))
completed["reserved_bands"] = clone_json(seed.get("reserved_bands", {}))
completed["footer_safe_zone"] = clone_json(seed.get("footer_safe_zone", {}))
completed["vertical_text_policy"] = clone_json(seed.get("vertical_text_policy", {"mode": "deny", "allowed_roles": [], "max_chars": 0, "max_lines": 0}))
setdefault_clone(
completed,
"reference_asset",
{
"source": "svglide_design_pattern",
"asset_id": profile_data["asset_id"],
"usage": "page-type geometry only; do not copy raw SVG paths",
},
)
setdefault_clone(completed, "asset_selection_reason", asset_selection_reason(profile_data))
setdefault_clone(completed, "rejected_asset_alternatives", rejected_asset_alternatives(profile_data))
setdefault_clone(completed, "visual_intent", f"use {recipe.replace('_', ' ')} structure to make the page readable as SVG-native content")
setdefault_clone(completed, "visual_focal_point", profile_data["main_visual_anchor"])
setdefault_clone(completed, "required_primitives", required_primitives)
setdefault_clone(completed, "svg_primitives", svg_primitives)
setdefault_clone(completed, "xml_like_risk", "without the declared SVG primitives this page would degrade into ordinary text boxes")
setdefault_clone(completed, "content_density_contract", profile_data["density_contract"])
setdefault_clone(completed, "asset_contract", "none_required")
setdefault_clone(completed, "risk_flags", [])
setdefault_clone(completed, "source_refs", ["brief"])
setdefault_clone(completed, "source_status", "user_prompt_only")
setdefault_clone(completed, "source_policy", "when source material is missing, mark missing evidence and avoid numeric claims")
setdefault_clone(completed, "speaker_intent", f"Advance the deck by proving: {completed.get('key_message')}")
chart_decision = chart_decision_for_slide(completed, profile_data)
setdefault_clone(completed, "chart_decision", chart_decision)
setdefault_clone(completed, "chart_verification", chart_verification_for_slide(completed.get("chart_decision", chart_decision)))
setdefault_clone(completed, "layout_guardrails", DEFAULT_GUARDRAILS)
completed["visual_design_contract"] = complete_visual_design_contract(completed, profile_data)
return completed
def normalize_slide_inputs(slide_plan: dict[str, Any] | None, page_descriptions: list[str]) -> list[dict[str, Any]]:
plan = slide_plan or {}
raw_slides = plan.get("slides")
if isinstance(raw_slides, list) and raw_slides:
slides = [clone_json(item) for item in raw_slides if isinstance(item, dict)]
else:
pages = plan.get("pages")
slides = [clone_json(item) for item in pages if isinstance(item, dict)] if isinstance(pages, list) and pages else []
if not slides:
slides = [{"description": description} for description in page_descriptions]
if not slides:
slides = [{"description": "Cover: core message"}, {"description": "Closing: next steps"}]
for index, description in enumerate(page_descriptions):
if index >= len(slides):
slides.append({"description": description})
elif description and not compact_text(slides[index].get("description")):
slides[index]["description"] = description
return slides
def selected_asset_id(slide: dict[str, Any]) -> str:
reference = slide.get("reference_asset")
if isinstance(reference, dict):
asset_id = compact_text(reference.get("asset_id") or reference.get("id"))
if asset_id:
return asset_id
asset_id = compact_text(slide.get("asset_id") or slide.get("design_pattern_id"))
return asset_id
def deck_rhythm_from_slides(slides: list[dict[str, Any]]) -> list[dict[str, Any]]:
rhythm: list[dict[str, Any]] = []
for index, slide in enumerate(slides, 1):
item = {
"page": slide.get("page", index),
"rhythm": compact_text(slide.get("page_rhythm")) or "breathing",
"page_type": compact_text(slide.get("page_type")) or "content",
}
rhythm.append(item)
return rhythm
def build_design_pattern_selection(slides: list[dict[str, Any]], existing: Any, catalogs: dict[str, Any]) -> dict[str, Any]:
selection = clone_json(existing) if isinstance(existing, dict) else {}
raw_assets = selection.get("selected_assets")
selected_assets: list[dict[str, Any]] = [clone_json(item) for item in raw_assets if isinstance(item, dict)] if isinstance(raw_assets, list) else []
for asset in selected_assets:
setdefault_clone(asset, "copy_policy", "derive_contract_only")
setdefault_clone(asset, "selection_reason", "selected because it matches a slide page_type, visual_recipe, and density contract")
seen = {compact_text(asset.get("id") or asset.get("asset_id")) for asset in selected_assets}
pattern_ids = catalogs.get("pattern_ids", set())
for slide in slides:
asset_id = selected_asset_id(slide)
if not asset_id or asset_id in seen or (pattern_ids and asset_id not in pattern_ids):
continue
reason = compact_text(slide.get("asset_selection_reason")) or "selected because it matches the slide page_type, visual_recipe, and density contract"
selected_assets.append(
{
"id": asset_id,
"kind": "chart_template",
"usage": "geometry_contract",
"copy_policy": "derive_contract_only",
"selection_reason": reason,
"rejected_alternatives": clone_json(slide.get("rejected_asset_alternatives", [])),
}
)
seen.add(asset_id)
setdefault_clone(selection, "schema_version", "svglide-design-pattern-selection/v1")
setdefault_clone(selection, "mode", "local_contract")
setdefault_clone(selection, "selected_assets", selected_assets)
setdefault_clone(selection, "proof_status", "pending_component_report")
return selection
def build_contract(
*,
brief: str = "",
slide_plan: dict[str, Any] | None = None,
page_descriptions: list[str] | None = None,
ref_dir: Path | None = None,
) -> dict[str, Any]:
catalogs = load_catalogs(ref_dir)
descriptions = page_descriptions or []
source_plan = clone_json(slide_plan) if isinstance(slide_plan, dict) else {}
output = source_plan
slides = normalize_slide_inputs(source_plan, descriptions)
completed_slides = [
complete_slide(slide, brief=brief, fallback_description=descriptions[index] if index < len(descriptions) else "", index=index, total=len(slides), catalogs=catalogs)
for index, slide in enumerate(slides)
]
style_id = compact_text(output.get("style_preset")) or style_preset_from_brief(brief or compact_text(output), catalogs)
if style_id not in catalogs["style_presets"]:
style_id = style_preset_from_brief(brief or compact_text(output), catalogs)
explicit_mode = compact_text(output.get("narrative_mode") or output.get("mode"))
narrative_mode = explicit_mode if explicit_mode in NARRATIVE_MODES else narrative_mode_from_brief(brief, output)
visual_style = visual_style_from_brief(brief, output)
image_strategy = image_strategy_from_brief(brief, output)
source_pack = source_pack_from_plan(brief, output)
setdefault_clone(output, "schema_version", CONTRACT_SCHEMA_VERSION)
setdefault_clone(output, "output_mode", "svglide-svg")
output["mode"] = narrative_mode
setdefault_clone(output, "narrative_mode", narrative_mode)
output["visual_style"] = visual_style
setdefault_clone(output, "canvas", CANVAS)
setdefault_clone(output, "safe_area", SAFE_AREA)
output["style_preset"] = style_id
setdefault_clone(output, "style_selection_reason", f"{style_id} matches the brief and the selected SVG page recipes")
output["style_system"] = apply_brief_palette(style_system_from_preset(style_id, catalogs), brief)
output["source_pack"] = source_pack
setdefault_clone(output, "input_profile", {"input_type": "topic", "source_status": source_pack_status(source_pack)})
setdefault_clone(output, "source_brief", {"path": "source/brief.md", "evidence_index": "source/evidence.json", "numeric_claim_policy": "cite_or_remove"})
setdefault_clone(output, "asset_strategy", asset_strategy_contract(image_strategy))
setdefault_clone(output, "icon_policy", icon_policy_contract())
setdefault_clone(output, "chart_policy", chart_policy_contract())
output["strategy_locks"] = strategy_locks_from_contract(
output=output,
page_count=len(completed_slides),
narrative_mode=narrative_mode,
visual_style=visual_style,
style_id=style_id,
image_strategy=image_strategy,
)
setdefault_clone(
output,
"acceptance_criteria",
[
{"id": "source_refs_resolved", "status": "planned", "gate": "preflight"},
{"id": "chart_anchor_aligned", "status": "planned", "gate": "preflight"},
{"id": "preview_score_passed", "status": "planned", "gate": "preview_lint"},
{"id": "quality_gate_passed", "status": "planned", "gate": "quality_gate"},
],
)
for slide in completed_slides:
contract = slide.get("visual_design_contract")
if isinstance(contract, dict):
setdefault_clone(contract, "style_preset", style_id)
output["slides"] = completed_slides
output["page_count"] = len(completed_slides)
setdefault_clone(output, "page_rhythm", deck_rhythm_from_slides(completed_slides))
output["design_pattern_selection"] = build_design_pattern_selection(completed_slides, output.get("design_pattern_selection"), catalogs)
return output
def read_text_arg(path: str | None, inline: str | None) -> str:
parts: list[str] = []
if inline:
parts.append(inline)
if path:
parts.append(Path(path).expanduser().read_text(encoding="utf-8"))
return "\n".join(part.strip() for part in parts if part.strip())
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Build or complete a conservative SVGlide plan contract.")
parser.add_argument("--brief", help="path to a brief text file")
parser.add_argument("--brief-text", help="inline brief text")
parser.add_argument("--plan", help="existing slide_plan.json to complete")
parser.add_argument("--page-description", action="append", default=[], help="simple page description; may be repeated")
parser.add_argument("--output", help="output JSON path; defaults to stdout")
parser.add_argument("--in-place", action="store_true", help="write the completed contract back to --plan")
return parser
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.in_place and not args.plan:
parser.error("--in-place requires --plan")
brief = read_text_arg(args.brief, args.brief_text)
slide_plan = read_json(Path(args.plan).expanduser()) if args.plan else None
if slide_plan is not None and not isinstance(slide_plan, dict):
raise ValueError("--plan must point to a JSON object")
result = build_contract(brief=brief, slide_plan=slide_plan, page_descriptions=args.page_description)
output_path = Path(args.output).expanduser() if args.output else (Path(args.plan).expanduser() if args.in_place else None)
if output_path:
write_json(output_path, result)
else:
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
sys.exit(main())

View File

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

View File

@@ -14,17 +14,28 @@ import (
// skillsEmbedFS embeds each skill's agent-readable content (SKILL.md +
// references/, plus lark-whiteboard's routes/ and scenes/) so the CLI serves
// content matching the binary version; machine-resource dirs (assets/, scripts/)
// are excluded, saving ~3.3 MB. It's a whitelist — a new subdirectory type is
// silently omitted until added here.
// content matching the binary version. Machine-resource dirs remain excluded by
// default; lark-slides SVG runtime scripts are explicitly embedded because
// create-svg can execute them in packaged CLI installs.
//
//go:embed skills/*/SKILL.md skills/*/references skills/*/routes skills/*/scenes
//go:embed skills/lark-slides/scripts/svg_rasterize_effects.py
//go:embed skills/lark-slides/scripts/svg_effect_classifier.py
//go:embed skills/lark-slides/scripts/svg_safe_rewrite.py
//go:embed skills/lark-slides/scripts/svg_raster_renderer.py
//go:embed skills/lark-slides/scripts/svglide_project_runner.py
//go:embed skills/lark-slides/scripts/svg_preflight.py
//go:embed skills/lark-slides/scripts/svg_preview_lint.py
//go:embed skills/lark-slides/scripts/svglide_asset_selector.py
//go:embed skills/lark-slides/scripts/svglide_strategist.py
//go:embed skills/lark-slides/scripts/svglide_gen_runtime.py
//go:embed skills/lark-slides/scripts/svglide_golden_suite.py
var skillsEmbedFS embed.FS
// init wires the embedded tree in as the default skill content. It compiles into
// `go build .` but not the single-file preview build (`go build ./main.go`), so
// main.go stays self-contained and that build still compiles (shipping no
// embedded skills). Assembly failure warns on stderr rather than panicking.
// init wires the embedded tree in as the default skill content. Packaged builds
// must compile the package (`go build .`) so this file is included; otherwise
// skill commands have no embedded runtime content. Assembly failure warns on
// stderr rather than panicking.
func init() {
sub, err := fs.Sub(skillsEmbedFS, "skills")
if err != nil {

View File

@@ -1,17 +1,25 @@
# Slides CLI E2E Coverage
## Metrics
- Denominator: 2 leaf commands
- Covered: 1
- Denominator: 4 leaf commands
- Covered: 2
- Coverage: 50.0%
## Summary
- TestSlides_CreateWorkflowAsUser: proves the user slides workflow through `create presentation with slide as user` and `get created presentation xml as user`; creates a fresh presentation, asserts returned IDs, then reads back the XML content to prove the title and slide body persisted.
- TestSlidesCreateSVGDryRunRequestShape: locks the `slides +create-svg --dry-run` request chain and recursive SVGlide validation, including `g` containers, geometry-required leaves, `px` geometry, nested defs/filter, shadow style preservation, and `foreignObject` XHTML `<br />`.
- TestSlidesCreateSVGDryRunRejectsMissingRequiredAttribute: proves CLI blocks leaf shapes that would otherwise reach the server as `shape missing required attribute`.
- TestSlidesCreateSVGDryRunRejectsInvalidNumericGeometry: proves CLI blocks non-absolute geometry before issuing API calls.
- TestSlidesCreateSVGDryRunRejectsUnsupportedPathCommand: proves CLI blocks unsupported path commands before issuing API calls.
- TestSlidesCreateSVGWorkflowAsUser: opt-in live workflow for `slides +create-svg` (`LARKSUITE_CLI_RUN_SVGLIDE_LIVE=1`); creates a two-page SVG deck as user, asserts returned page count and IDs, then reads the presentation back.
- Blocked area: `slides +media-upload` is still uncovered because it needs a deterministic local image fixture plus XML follow-up proof that is separate from the base create/read workflow.
- Blocked area: `slides +replace-slide` has focused unit coverage but no E2E workflow yet.
## Command Table
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | slides +create | shortcut | slides_create_workflow_test.go::TestSlides_CreateWorkflowAsUser/create presentation with slide as user | `--title`; `--slides ["<slide ...>"]` | read back through raw slides API to prove persisted XML |
| ✓ | slides +create-svg | shortcut | slides_create_svg_dryrun_test.go::TestSlidesCreateSVGDryRunRequestShape; slides_create_svg_dryrun_test.go::TestSlidesCreateSVGDryRunRejectsMissingChildRole; slides_create_svg_dryrun_test.go::TestSlidesCreateSVGDryRunRejectsMissingRequiredAttribute; slides_create_svg_dryrun_test.go::TestSlidesCreateSVGDryRunRejectsInvalidNumericGeometry; slides_create_svg_dryrun_test.go::TestSlidesCreateSVGDryRunRejectsUnsupportedPathCommand; slides_create_svg_live_test.go::TestSlidesCreateSVGWorkflowAsUser | repeated `--file`; `--title`; `--dry-run` | dry-run proves existing `/slide` route, `slide.content`, recursive SVGlide validation, server-known hard failures, numeric geometry gates, and path command gates before API call; live is opt-in and depends on the target server lane containing the updated SVGlide parser |
| ✕ | slides +media-upload | shortcut | | none | needs a stable local image fixture plus follow-up slide XML proof |
| ✕ | slides +replace-slide | shortcut | | none | unit-covered shortcut; E2E workflow still pending |

View File

@@ -0,0 +1,159 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"os"
"path/filepath"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func setSlidesDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
func TestSlidesCreateSVGDryRunRequestShape(t *testing.T) {
setSlidesDryRunEnv(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "page1.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" width="1280" height="720" viewBox="0 0 1280 720"><style>.primary{fill:#123456;stroke:#654321;stroke-width:2px;filter:drop-shadow(2px 4px 8px rgba(0,0,0,.2))}</style><g><defs><filter id="shadow"><feDropShadow dx="2" dy="3" stdDeviation="5" flood-color="#000" flood-opacity=".25"/></filter></defs></g><g transform="translate(20 30)"><rect slide:role="shape" class="primary" x="0" y="0" width="320px" height="180px" filter="url(#shadow)"/></g></svg>`), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "page2.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><foreignObject slide:role="shape" slide:shape-type="text" x="80" y="80" width="320" height="80"><div xmlns="http://www.w3.org/1999/xhtml">two<br />lines</div></foreignObject></svg>`), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+create-svg",
"--file", "page1.svg",
"--file", "page2.svg",
"--title", "Dry SVG",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/slides_ai/v1/xml_presentations", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, "POST", gjson.Get(out, "api.1.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>/slide", gjson.Get(out, "api.1.url").String(), "stdout:\n%s", out)
require.Equal(t, "POST", gjson.Get(out, "api.2.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>/slide", gjson.Get(out, "api.2.url").String(), "stdout:\n%s", out)
require.Contains(t, gjson.Get(out, "api.1.body.slide.content").String(), `<rect slide:role="shape"`, "stdout:\n%s", out)
require.Contains(t, gjson.Get(out, "api.1.body.slide.content").String(), `<g transform="translate(20 30)">`, "stdout:\n%s", out)
require.Contains(t, gjson.Get(out, "api.1.body.slide.content").String(), `<style>.primary`, "stdout:\n%s", out)
require.Contains(t, gjson.Get(out, "api.1.body.slide.content").String(), `<feDropShadow`, "stdout:\n%s", out)
require.Contains(t, gjson.Get(out, "api.2.body.slide.content").String(), `slide:shape-type="text"`, "stdout:\n%s", out)
require.Contains(t, gjson.Get(out, "api.2.body.slide.content").String(), `<br />`, "stdout:\n%s", out)
}
func TestSlidesCreateSVGDryRunRejectsMissingChildRole(t *testing.T) {
setSlidesDryRunEnv(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect x="80" y="80" width="320" height="180"/></svg>`), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+create-svg",
"--file", "bad.svg",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Empty(t, gjson.Get(result.Stdout, "api").Array(), "stdout:\n%s", result.Stdout)
require.Contains(t, gjson.Get(result.Stdout, "error").String(), `<rect> must include slide:role="shape" or slide:role="image"`)
}
func TestSlidesCreateSVGDryRunRejectsMissingRequiredAttribute(t *testing.T) {
setSlidesDryRunEnv(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect slide:role="shape" x="80" y="80" height="180"/></svg>`), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+create-svg",
"--file", "bad.svg",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Empty(t, gjson.Get(result.Stdout, "api").Array(), "stdout:\n%s", result.Stdout)
require.Contains(t, gjson.Get(result.Stdout, "error").String(), `<rect slide:role="shape"> missing required attribute "width"`)
}
func TestSlidesCreateSVGDryRunRejectsInvalidNumericGeometry(t *testing.T) {
setSlidesDryRunEnv(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><rect slide:role="shape" x="80" y="80" width="50%" height="180"/></svg>`), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+create-svg",
"--file", "bad.svg",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Empty(t, gjson.Get(result.Stdout, "api").Array(), "stdout:\n%s", result.Stdout)
require.Contains(t, gjson.Get(result.Stdout, "error").String(), `attribute "width" must be a number or px length`)
}
func TestSlidesCreateSVGDryRunRejectsUnsupportedPathCommand(t *testing.T) {
setSlidesDryRunEnv(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "bad.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><path slide:role="shape" d="M0 0 A10 10 0 0 1 20 20"/></svg>`), 0o644))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+create-svg",
"--file", "bad.svg",
"--dry-run",
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Empty(t, gjson.Get(result.Stdout, "api").Array(), "stdout:\n%s", result.Stdout)
require.Contains(t, gjson.Get(result.Stdout, "error").String(), `unsupported path command or character "A"`)
}

View File

@@ -0,0 +1,115 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestSlidesCreateSVGWorkflowAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
if os.Getenv("LARKSUITE_CLI_RUN_SVGLIDE_LIVE") != "1" {
t.Skip("set LARKSUITE_CLI_RUN_SVGLIDE_LIVE=1 to run the live SVGlide service contract test")
}
clie2e.SkipWithoutUserToken(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "page1.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" width="1280" height="720" viewBox="0 0 1280 720"><g fill="#E8EEF8" transform="translate(80 80)"><rect slide:role="shape" x="0" y="0" width="360" height="180"/></g></svg>`), 0o644))
require.NoError(t, os.WriteFile(filepath.Join(dir, "page2.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><foreignObject slide:role="shape" slide:shape-type="text" x="120" y="120" width="420" height="100"><p xmlns="http://www.w3.org/1999/xhtml">SVGlide live E2E</p></foreignObject></svg>`), 0o644))
parentT := t
title := "svglide-e2e-" + clie2e.GenerateSuffix()
var presentationID string
t.Run("create SVG deck as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+create-svg",
"--file", "page1.svg",
"--file", "page2.svg",
"--title", title,
},
DefaultAs: "user",
WorkDir: dir,
})
require.NoError(t, err)
if result.ExitCode != 0 {
if created := extractSVGlideFailurePresentationID(result.Stderr); created != "" {
presentationID = created
registerSlidesCleanup(parentT, presentationID)
}
}
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
presentationID = gjson.Get(result.Stdout, "data.xml_presentation_id").String()
require.NotEmpty(t, presentationID, "stdout:\n%s", result.Stdout)
require.Equal(t, title, gjson.Get(result.Stdout, "data.title").String(), "stdout:\n%s", result.Stdout)
require.Equal(t, int64(2), gjson.Get(result.Stdout, "data.slides_added").Int(), "stdout:\n%s", result.Stdout)
require.Len(t, gjson.Get(result.Stdout, "data.slide_ids").Array(), 2, "stdout:\n%s", result.Stdout)
registerSlidesCleanup(parentT, presentationID)
})
t.Run("read back SVG-created presentation as user", func(t *testing.T) {
require.NotEmpty(t, presentationID, "presentation should be created before readback")
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"api", "get", "/open-apis/slides_ai/v1/xml_presentations/" + presentationID},
DefaultAs: "user",
Params: map[string]any{"revision_id": -1},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
require.Equal(t, presentationID, gjson.Get(result.Stdout, "data.xml_presentation.presentation_id").String(), "stdout:\n%s", result.Stdout)
content := gjson.Get(result.Stdout, "data.xml_presentation.content").String()
require.Contains(t, content, "<title>"+title+"</title>", "stdout:\n%s", result.Stdout)
})
}
func registerSlidesCleanup(t *testing.T, presentationID string) {
t.Helper()
t.Cleanup(func() {
cleanupCtx, cancel := clie2e.CleanupContext()
defer cancel()
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{
"drive", "+delete",
"--file-token", presentationID,
"--type", "slides",
"--yes",
},
DefaultAs: "user",
})
clie2e.ReportCleanupFailure(t, "delete presentation "+presentationID, deleteResult, deleteErr)
})
}
func extractSVGlideFailurePresentationID(stderr string) string {
const marker = "presentation "
idx := strings.Index(stderr, marker)
if idx < 0 {
return ""
}
rest := stderr[idx+len(marker):]
end := strings.IndexByte(rest, ' ')
if end < 0 {
return ""
}
return strings.Trim(rest[:end], ".,;:)")
}