mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
6 Commits
fix/ppe-re
...
feat/svgli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f15da6e95 | ||
|
|
31aa9db726 | ||
|
|
3bbf823ce9 | ||
|
|
e43a57ce14 | ||
|
|
edf7ad81dd | ||
|
|
d98ef05dc7 |
@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
SlidesCreate,
|
||||
SlidesCreateSVG,
|
||||
SlidesMediaUpload,
|
||||
SlidesReplaceSlide,
|
||||
}
|
||||
|
||||
@@ -121,35 +121,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.CallAPI(
|
||||
"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 output.Errorf(output.ExitAPI, "api_error", "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
|
||||
@@ -198,6 +182,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
|
||||
@@ -205,34 +192,7 @@ var SlidesCreate = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch presentation URL via drive meta (best-effort)
|
||||
if metaData, err := runtime.CallAPI(
|
||||
"POST",
|
||||
"/open-apis/drive/v1/metas/batch_query",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"request_docs": []map[string]interface{}{
|
||||
{
|
||||
"doc_token": presentationID,
|
||||
"doc_type": "slides",
|
||||
},
|
||||
},
|
||||
"with_url": true,
|
||||
},
|
||||
); err == nil {
|
||||
metas := common.GetSlice(metaData, "metas")
|
||||
if len(metas) > 0 {
|
||||
if meta, ok := metas[0].(map[string]interface{}); ok {
|
||||
if url := common.GetString(meta, "url"); 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
|
||||
@@ -259,6 +219,41 @@ 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, output.Errorf(output.ExitAPI, "api_error", "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{}) {
|
||||
if url, err := common.FetchDriveMetaURL(runtime, presentationID, "slides"); err == nil && 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
|
||||
|
||||
161
shortcuts/slides/slides_create_svg.go
Normal file
161
shortcuts/slides/slides_create_svg.go
Normal file
@@ -0,0 +1,161 @@
|
||||
// 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"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateSVGFileInputs(runtime, runtime.StrArray("file")); 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"))
|
||||
svgs, err := readSVGFiles(runtime, runtime.StrArray("file"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
assets, err := parseSVGAssets(runtime, runtime.Str("assets"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
pages, uploadPaths := dryRunRewriteSVGImagePlaceholders(svgs, assets)
|
||||
|
||||
dry := common.NewDryRunAPI()
|
||||
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, err := readSVGFiles(runtime, runtime.StrArray("file"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
assets, err := parseSVGAssets(runtime, runtime.Str("assets"))
|
||||
if 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 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
|
||||
},
|
||||
}
|
||||
436
shortcuts/slides/slides_create_svg_test.go
Normal file
436
shortcuts/slides/slides_create_svg_test.go
Normal file
@@ -0,0 +1,436 @@
|
||||
// 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 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)
|
||||
}
|
||||
}
|
||||
745
shortcuts/slides/svg_helpers.go
Normal file
745
shortcuts/slides/svg_helpers.go
Normal file
@@ -0,0 +1,745 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
maxSVGFileSizeBytes int64 = 2 * 1024 * 1024
|
||||
svglideSlideNS = "https://slides.bytedance.com/ns"
|
||||
svglideContractVersion = "svglide-authoring-contract/v1"
|
||||
)
|
||||
|
||||
type RewrittenSVGPage struct {
|
||||
Content string
|
||||
Tokens []string
|
||||
}
|
||||
|
||||
var (
|
||||
svgRootOpenTagRegex = regexp.MustCompile(`(?s)\A(\s*(?:<\?[^?]*(?:\?[^>][^?]*)*\?>\s*)?(?:<!DOCTYPE[^>]*>\s*)?(?:<!--.*?-->\s*)*)<([A-Za-z_][\w.:-]*)((?:\s[^>]*?)?)(/?>)`)
|
||||
svgImageTagRegex = regexp.MustCompile(`(?is)<image\b[^>]*>`)
|
||||
svgImageHrefRegex = regexp.MustCompile(`(?is)(^|\s)(xlink:href|href)\s*=\s*(["'])([^"']*)(["'])`)
|
||||
svgMetadataRegex = regexp.MustCompile(`(?is)<metadata\b[^>]*\bdata-svglide-assets\s*=\s*(["'])true(["'])[^>]*>.*?</metadata>`)
|
||||
svgMetadataEndRegex = regexp.MustCompile(`(?is)</metadata\s*>`)
|
||||
svgMetadataImgRegex = regexp.MustCompile(`(?is)<img\b[^>]*\bsrc\s*=\s*(["'])([^"']+)(["'])`)
|
||||
svgNumberRegex = regexp.MustCompile(`^[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?(?:px)?$`)
|
||||
svgPathNumberRegex = regexp.MustCompile(`[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?`)
|
||||
svgTransformRegex = regexp.MustCompile(`(?is)([a-zA-Z]+)\(([^)]*)\)`)
|
||||
svgShapeTags = map[string]bool{
|
||||
"circle": true,
|
||||
"ellipse": true,
|
||||
"foreignObject": true,
|
||||
"line": true,
|
||||
"path": true,
|
||||
"rect": true,
|
||||
}
|
||||
svgRequiredAttrsByTag = map[string][]string{
|
||||
"circle": {"cx", "cy", "r"},
|
||||
"ellipse": {"cx", "cy", "rx", "ry"},
|
||||
"foreignObject": {"x", "y", "width", "height"},
|
||||
"image": {"x", "y", "width", "height"},
|
||||
"line": {"x1", "y1", "x2", "y2"},
|
||||
"path": {"d"},
|
||||
"rect": {"x", "y", "width", "height"},
|
||||
}
|
||||
svgGeometryAttrsByTag = map[string][]string{
|
||||
"circle": {"cx", "cy", "r"},
|
||||
"ellipse": {"cx", "cy", "rx", "ry"},
|
||||
"foreignObject": {"x", "y", "width", "height"},
|
||||
"image": {"x", "y", "width", "height"},
|
||||
"line": {"x1", "y1", "x2", "y2"},
|
||||
"rect": {"x", "y", "width", "height"},
|
||||
}
|
||||
svgContainerTags = map[string]bool{
|
||||
"g": true,
|
||||
"svg": true,
|
||||
}
|
||||
svgIgnoredSubtreeTags = map[string]bool{
|
||||
"defs": true,
|
||||
"style": true,
|
||||
}
|
||||
)
|
||||
|
||||
type svgValidationMode int
|
||||
|
||||
const (
|
||||
svgValidationDescend svgValidationMode = iota
|
||||
svgValidationSkipSubtree
|
||||
svgValidationStop
|
||||
)
|
||||
|
||||
func validateSVGFileInputs(runtime *common.RuntimeContext, paths []string) error {
|
||||
if len(paths) == 0 {
|
||||
return common.FlagErrorf("--file is required")
|
||||
}
|
||||
for _, path := range paths {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return common.FlagErrorf("--file cannot be empty")
|
||||
}
|
||||
stat, err := runtime.FileIO().Stat(path)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err, fmt.Sprintf("--file %s: file not found", path))
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return output.ErrValidation("--file %s: must be a regular file", path)
|
||||
}
|
||||
if stat.Size() == 0 {
|
||||
return output.ErrValidation("--file %s: SVG file is empty", path)
|
||||
}
|
||||
if stat.Size() > maxSVGFileSizeBytes {
|
||||
return output.ErrValidation("--file %s: SVG file size %s exceeds %s limit",
|
||||
path, common.FormatSize(stat.Size()), common.FormatSize(maxSVGFileSizeBytes))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readSVGFiles(runtime *common.RuntimeContext, paths []string) ([]string, error) {
|
||||
svgs := make([]string, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
data, err := cmdutil.ReadInputFile(runtime.FileIO(), path)
|
||||
if err != nil {
|
||||
return nil, common.WrapInputStatError(err, fmt.Sprintf("--file %s", path))
|
||||
}
|
||||
if strings.TrimSpace(string(data)) == "" {
|
||||
return nil, output.ErrValidation("--file %s: SVG file is empty", path)
|
||||
}
|
||||
svg := string(data)
|
||||
var normalizeErr error
|
||||
svg, normalizeErr = ensureSVGlideRootContractVersion(svg, path)
|
||||
if normalizeErr != nil {
|
||||
return nil, normalizeErr
|
||||
}
|
||||
if err := validateSVGlideSVG(svg, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
svgs = append(svgs, svg)
|
||||
}
|
||||
return svgs, nil
|
||||
}
|
||||
|
||||
func validateSVGlideSVG(svg, path string) error {
|
||||
m := svgRootOpenTagRegex.FindStringSubmatchIndex(svg)
|
||||
if m == nil {
|
||||
return output.ErrValidation("--file %s: SVG root element not found", path)
|
||||
}
|
||||
tagName := svg[m[4]:m[5]]
|
||||
if tagName != "svg" {
|
||||
return output.ErrValidation("--file %s: root element must be non-namespaced <svg>", path)
|
||||
}
|
||||
attrs := svg[m[6]:m[7]]
|
||||
if !hasXMLAttr(attrs, "xmlns:slide", svglideSlideNS) {
|
||||
return output.ErrValidation("--file %s: root <svg> must declare xmlns:slide=\"%s\"", path, svglideSlideNS)
|
||||
}
|
||||
if !hasXMLAttr(attrs, "slide:role", "slide") {
|
||||
return output.ErrValidation("--file %s: root <svg> must include slide:role=\"slide\"", path)
|
||||
}
|
||||
if version := xmlAttrValue(attrs, "slide:contract-version"); version != svglideContractVersion {
|
||||
return output.ErrValidation("--file %s: root <svg> must include slide:contract-version=\"%s\"", path, svglideContractVersion)
|
||||
}
|
||||
if svg[m[8]:m[9]] == "/>" {
|
||||
return nil
|
||||
}
|
||||
return validateSVGlideChildren(svg[m[9]:], path)
|
||||
}
|
||||
|
||||
func ensureSVGlideRootContractVersion(svg, path string) (string, error) {
|
||||
m := svgRootOpenTagRegex.FindStringSubmatchIndex(svg)
|
||||
if m == nil {
|
||||
return svg, nil
|
||||
}
|
||||
tagName := svg[m[4]:m[5]]
|
||||
if tagName != "svg" {
|
||||
return svg, nil
|
||||
}
|
||||
attrs := svg[m[6]:m[7]]
|
||||
version := xmlAttrValue(attrs, "slide:contract-version")
|
||||
if version == svglideContractVersion {
|
||||
return svg, nil
|
||||
}
|
||||
if strings.TrimSpace(version) != "" {
|
||||
return "", output.ErrValidation("--file %s: root <svg> must include slide:contract-version=\"%s\"", path, svglideContractVersion)
|
||||
}
|
||||
return svg[:m[8]] + fmt.Sprintf(` slide:contract-version="%s"`, svglideContractVersion) + svg[m[8]:], nil
|
||||
}
|
||||
|
||||
func hasXMLAttr(attrs, name, want string) bool {
|
||||
return xmlAttrValue(attrs, name) == want
|
||||
}
|
||||
|
||||
func xmlAttrValue(attrs, name string) string {
|
||||
re := regexp.MustCompile(`(?is)(?:^|\s)` + regexp.QuoteMeta(name) + `\s*=\s*(["'])([^"']*)(["'])`)
|
||||
for _, m := range re.FindAllStringSubmatch(attrs, -1) {
|
||||
if len(m) >= 4 && m[1] == m[3] {
|
||||
return m[2]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func validateSVGlideChildren(svgAfterRootOpen, path string) error {
|
||||
depth := 0
|
||||
skipDepth := -1
|
||||
for i := 0; i < len(svgAfterRootOpen); {
|
||||
rel := strings.IndexByte(svgAfterRootOpen[i:], '<')
|
||||
if rel < 0 {
|
||||
return nil
|
||||
}
|
||||
i += rel
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(svgAfterRootOpen[i:], "<!--"):
|
||||
end := strings.Index(svgAfterRootOpen[i+4:], "-->")
|
||||
if end < 0 {
|
||||
return output.ErrValidation("--file %s: malformed SVG comment", path)
|
||||
}
|
||||
i += 4 + end + 3
|
||||
continue
|
||||
case strings.HasPrefix(svgAfterRootOpen[i:], "<![CDATA["):
|
||||
end := strings.Index(svgAfterRootOpen[i+9:], "]]>")
|
||||
if end < 0 {
|
||||
return output.ErrValidation("--file %s: malformed SVG CDATA", path)
|
||||
}
|
||||
i += 9 + end + 3
|
||||
continue
|
||||
case strings.HasPrefix(svgAfterRootOpen[i:], "<?"):
|
||||
end := strings.Index(svgAfterRootOpen[i+2:], "?>")
|
||||
if end < 0 {
|
||||
return output.ErrValidation("--file %s: malformed SVG processing instruction", path)
|
||||
}
|
||||
i += 2 + end + 2
|
||||
continue
|
||||
case strings.HasPrefix(svgAfterRootOpen[i:], "</"):
|
||||
end := findSVGTagEnd(svgAfterRootOpen, i)
|
||||
if end < 0 {
|
||||
return output.ErrValidation("--file %s: malformed SVG closing tag", path)
|
||||
}
|
||||
name := parseSVGClosingTagName(svgAfterRootOpen[i+2 : end])
|
||||
if depth == 0 && name == "svg" {
|
||||
return nil
|
||||
}
|
||||
if depth > 0 {
|
||||
depth--
|
||||
}
|
||||
if skipDepth >= 0 && depth < skipDepth {
|
||||
skipDepth = -1
|
||||
}
|
||||
i = end + 1
|
||||
continue
|
||||
case strings.HasPrefix(svgAfterRootOpen[i:], "<!"):
|
||||
end := findSVGTagEnd(svgAfterRootOpen, i)
|
||||
if end < 0 {
|
||||
return output.ErrValidation("--file %s: malformed SVG declaration", path)
|
||||
}
|
||||
i = end + 1
|
||||
continue
|
||||
}
|
||||
|
||||
end := findSVGTagEnd(svgAfterRootOpen, i)
|
||||
if end < 0 {
|
||||
return output.ErrValidation("--file %s: malformed SVG element", path)
|
||||
}
|
||||
name, attrs, selfClosing := parseSVGStartTag(svgAfterRootOpen[i+1 : end])
|
||||
if name == "" {
|
||||
i = end + 1
|
||||
continue
|
||||
}
|
||||
if skipDepth < 0 {
|
||||
mode, err := validateSVGlideElement(path, name, attrs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mode == svgValidationSkipSubtree && !selfClosing {
|
||||
skipDepth = depth + 1
|
||||
}
|
||||
}
|
||||
if !selfClosing {
|
||||
depth++
|
||||
}
|
||||
i = end + 1
|
||||
}
|
||||
return output.ErrValidation("--file %s: malformed SVG root: missing </svg>", path)
|
||||
}
|
||||
|
||||
func findSVGTagEnd(svg string, start int) int {
|
||||
var quote byte
|
||||
for i := start + 1; i < len(svg); i++ {
|
||||
c := svg[i]
|
||||
if quote != 0 {
|
||||
if c == quote {
|
||||
quote = 0
|
||||
}
|
||||
continue
|
||||
}
|
||||
if c == '"' || c == '\'' {
|
||||
quote = c
|
||||
continue
|
||||
}
|
||||
if c == '>' {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func parseSVGClosingTagName(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
for i, r := range raw {
|
||||
if r == '>' || r == '/' || isXMLSpace(r) {
|
||||
return raw[:i]
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func parseSVGStartTag(raw string) (name, attrs string, selfClosing bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || strings.HasPrefix(raw, "/") {
|
||||
return "", "", false
|
||||
}
|
||||
if strings.HasSuffix(raw, "/") {
|
||||
selfClosing = true
|
||||
raw = strings.TrimSpace(strings.TrimSuffix(raw, "/"))
|
||||
}
|
||||
nameEnd := len(raw)
|
||||
for i, r := range raw {
|
||||
if isXMLSpace(r) || r == '/' {
|
||||
nameEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
name = raw[:nameEnd]
|
||||
attrs = strings.TrimSpace(raw[nameEnd:])
|
||||
return name, attrs, selfClosing
|
||||
}
|
||||
|
||||
func isXMLSpace(r rune) bool {
|
||||
return r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||||
}
|
||||
|
||||
func validateSVGlideElement(path, tagName, attrs string) (svgValidationMode, error) {
|
||||
if svgIgnoredSubtreeTags[tagName] {
|
||||
return svgValidationSkipSubtree, nil
|
||||
}
|
||||
if tagName == "metadata" && hasXMLAttr(attrs, "data-svglide-assets", "true") {
|
||||
return svgValidationSkipSubtree, nil
|
||||
}
|
||||
if err := validateSVGlideTransform(path, tagName, attrs); err != nil {
|
||||
return svgValidationStop, err
|
||||
}
|
||||
if svgContainerTags[tagName] {
|
||||
return svgValidationDescend, nil
|
||||
}
|
||||
|
||||
role := xmlAttrValue(attrs, "slide:role")
|
||||
if role == "" {
|
||||
return svgValidationStop, output.ErrValidation("--file %s: <%s> must include slide:role=\"shape\" or slide:role=\"image\" for SVGlide", path, tagName)
|
||||
}
|
||||
|
||||
switch role {
|
||||
case "shape":
|
||||
if !svgShapeTags[tagName] {
|
||||
return svgValidationStop, output.ErrValidation("--file %s: <%s slide:role=\"shape\"> is not supported by SVGlide; use rect, ellipse, circle, line, path, or foreignObject", path, tagName)
|
||||
}
|
||||
if tagName == "foreignObject" && !hasXMLAttr(attrs, "slide:shape-type", "text") {
|
||||
return svgValidationStop, output.ErrValidation("--file %s: <foreignObject slide:role=\"shape\"> must include slide:shape-type=\"text\"", path)
|
||||
}
|
||||
if err := validateSVGlideRequiredAttrs(path, tagName, role, attrs); err != nil {
|
||||
return svgValidationStop, err
|
||||
}
|
||||
return svgValidationSkipSubtree, nil
|
||||
case "image":
|
||||
if tagName != "image" {
|
||||
return svgValidationStop, output.ErrValidation("--file %s: <%s slide:role=\"image\"> is not supported by SVGlide; use <image>", path, tagName)
|
||||
}
|
||||
href := xmlAttrValue(attrs, "href")
|
||||
if href == "" {
|
||||
href = xmlAttrValue(attrs, "xlink:href")
|
||||
}
|
||||
if href == "" {
|
||||
return svgValidationStop, output.ErrValidation("--file %s: <image slide:role=\"image\"> must include href", path)
|
||||
}
|
||||
if isExternalSVGHref(href) {
|
||||
return svgValidationStop, output.ErrValidation("--file %s: <image slide:role=\"image\"> must not use external http(s) or data href; download the image and use href=\"@./path\" or provide a file token", path)
|
||||
}
|
||||
if err := validateSVGlideRequiredAttrs(path, tagName, role, attrs); err != nil {
|
||||
return svgValidationStop, err
|
||||
}
|
||||
return svgValidationSkipSubtree, nil
|
||||
default:
|
||||
return svgValidationStop, output.ErrValidation("--file %s: <%s> has unsupported slide:role=%q; use \"shape\" or \"image\"", path, tagName, role)
|
||||
}
|
||||
}
|
||||
|
||||
func validateSVGlideRequiredAttrs(path, tagName, role, attrs string) error {
|
||||
for _, attr := range svgRequiredAttrsByTag[tagName] {
|
||||
if strings.TrimSpace(xmlAttrValue(attrs, attr)) == "" {
|
||||
return output.ErrValidation("--file %s: <%s slide:role=\"%s\"> missing required attribute %q for SVGlide", path, tagName, role, attr)
|
||||
}
|
||||
}
|
||||
for _, attr := range svgGeometryAttrsByTag[tagName] {
|
||||
value := xmlAttrValue(attrs, attr)
|
||||
if !isSVGlideNumber(value) {
|
||||
return output.ErrValidation("--file %s: <%s slide:role=\"%s\"> attribute %q must be a number or px length, got %q", path, tagName, role, attr, value)
|
||||
}
|
||||
}
|
||||
if tagName == "path" {
|
||||
if err := validateSVGlidePathData(path, attrs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isSVGlideNumber(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
return value != "" && svgNumberRegex.MatchString(value)
|
||||
}
|
||||
|
||||
func validateSVGlideTransform(path, tagName, attrs string) error {
|
||||
transform := strings.TrimSpace(xmlAttrValue(attrs, "transform"))
|
||||
if transform == "" {
|
||||
return nil
|
||||
}
|
||||
for _, m := range svgTransformRegex.FindAllStringSubmatch(transform, -1) {
|
||||
if len(m) < 3 {
|
||||
continue
|
||||
}
|
||||
fn := strings.TrimSpace(m[1])
|
||||
for _, arg := range strings.FieldsFunc(m[2], func(r rune) bool {
|
||||
return r == ',' || isXMLSpace(r)
|
||||
}) {
|
||||
arg = strings.TrimSpace(arg)
|
||||
if arg == "" {
|
||||
continue
|
||||
}
|
||||
if !isSVGlideNumber(arg) {
|
||||
return output.ErrValidation("--file %s: <%s> transform %s() argument must be a number or px length, got %q", path, tagName, fn, arg)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSVGlidePathData(path, attrs string) error {
|
||||
d := strings.TrimSpace(xmlAttrValue(attrs, "d"))
|
||||
withoutNumbers := svgPathNumberRegex.ReplaceAllString(d, "")
|
||||
hasCommand := false
|
||||
for _, r := range withoutNumbers {
|
||||
switch {
|
||||
case r == ',' || isXMLSpace(r):
|
||||
continue
|
||||
case strings.ContainsRune("MLHVZCQmlhvzcq", r):
|
||||
hasCommand = true
|
||||
default:
|
||||
return output.ErrValidation("--file %s: <path slide:role=\"shape\"> unsupported path command or character %q; use only M/L/H/V/C/Q/Z commands", path, string(r))
|
||||
}
|
||||
}
|
||||
if !hasCommand {
|
||||
return output.ErrValidation("--file %s: <path slide:role=\"shape\"> attribute \"d\" must include at least one M/L/H/V/C/Q/Z path command", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isExternalSVGHref(value string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(value))
|
||||
return strings.HasPrefix(lower, "http://") ||
|
||||
strings.HasPrefix(lower, "https://") ||
|
||||
strings.HasPrefix(lower, "data:")
|
||||
}
|
||||
|
||||
func parseSVGAssets(runtime *common.RuntimeContext, path string) (map[string]string, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
data, err := cmdutil.ReadInputFile(runtime.FileIO(), path)
|
||||
if err != nil {
|
||||
return nil, common.WrapInputStatError(err, fmt.Sprintf("--assets %s", path))
|
||||
}
|
||||
var assets map[string]string
|
||||
if err := json.Unmarshal(data, &assets); err != nil {
|
||||
return nil, output.ErrValidation("--assets %s: invalid JSON object: %v", path, err)
|
||||
}
|
||||
for k, v := range assets {
|
||||
if strings.TrimSpace(k) == "" || strings.TrimSpace(v) == "" {
|
||||
return nil, output.ErrValidation("--assets %s: keys and file tokens must be non-empty strings", path)
|
||||
}
|
||||
}
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
func validateSVGAssetsPath(runtime *common.RuntimeContext, path string) error {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil
|
||||
}
|
||||
stat, err := runtime.FileIO().Stat(path)
|
||||
if err != nil {
|
||||
return common.WrapInputStatError(err, fmt.Sprintf("--assets %s: file not found", path))
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return output.ErrValidation("--assets %s: must be a regular file", path)
|
||||
}
|
||||
if stat.Size() == 0 {
|
||||
return output.ErrValidation("--assets %s: file is empty", path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rewriteSVGImagePlaceholders(runtime *common.RuntimeContext, presentationID string, svgs []string, assets map[string]string) ([]RewrittenSVGPage, int, error) {
|
||||
paths := extractSVGImagePlaceholderPaths(svgs, assets)
|
||||
localTokens, uploaded, err := uploadSlidesPlaceholders(runtime, presentationID, paths)
|
||||
if err != nil {
|
||||
return nil, uploaded, err
|
||||
}
|
||||
tokens := mergedSVGAssetTokens(assets, localTokens)
|
||||
pages := make([]RewrittenSVGPage, 0, len(svgs))
|
||||
for _, svg := range svgs {
|
||||
content, usedTokens := rewriteSVGImagePlaceholdersWithTokens(svg, tokens)
|
||||
pages = append(pages, RewrittenSVGPage{Content: content, Tokens: usedTokens})
|
||||
}
|
||||
return pages, uploaded, nil
|
||||
}
|
||||
|
||||
func dryRunRewriteSVGImagePlaceholders(svgs []string, assets map[string]string) ([]RewrittenSVGPage, []string) {
|
||||
paths := extractSVGImagePlaceholderPaths(svgs, assets)
|
||||
localTokens := make(map[string]string, len(paths))
|
||||
for _, path := range paths {
|
||||
localTokens[path] = "<uploaded_file_token:" + filepath.Base(path) + ">"
|
||||
}
|
||||
tokens := mergedSVGAssetTokens(assets, localTokens)
|
||||
pages := make([]RewrittenSVGPage, 0, len(svgs))
|
||||
for _, svg := range svgs {
|
||||
content, usedTokens := rewriteSVGImagePlaceholdersWithTokens(svg, tokens)
|
||||
pages = append(pages, RewrittenSVGPage{Content: content, Tokens: usedTokens})
|
||||
}
|
||||
return pages, paths
|
||||
}
|
||||
|
||||
func mergedSVGAssetTokens(assets, localTokens map[string]string) map[string]string {
|
||||
tokens := map[string]string{}
|
||||
for k, v := range assets {
|
||||
key := strings.TrimSpace(k)
|
||||
token := strings.TrimSpace(v)
|
||||
if strings.HasPrefix(key, "@") {
|
||||
key = strings.TrimSpace(strings.TrimPrefix(key, "@"))
|
||||
}
|
||||
if key != "" && token != "" {
|
||||
tokens[key] = token
|
||||
}
|
||||
}
|
||||
for k, v := range localTokens {
|
||||
tokens[k] = v
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
func extractSVGImagePlaceholderPaths(svgs []string, assets map[string]string) []string {
|
||||
var paths []string
|
||||
seen := map[string]bool{}
|
||||
for _, svg := range svgs {
|
||||
for _, tag := range svgImageTagRegex.FindAllString(svg, -1) {
|
||||
for _, m := range svgImageHrefRegex.FindAllStringSubmatch(tag, -1) {
|
||||
if len(m) < 6 || m[3] != m[5] || !strings.HasPrefix(m[4], "@") {
|
||||
continue
|
||||
}
|
||||
path := strings.TrimSpace(strings.TrimPrefix(m[4], "@"))
|
||||
if path == "" || seen[path] || svgAssetTokenForPath(assets, path) != "" {
|
||||
continue
|
||||
}
|
||||
seen[path] = true
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func rewriteSVGImagePlaceholdersWithTokens(svg string, tokens map[string]string) (string, []string) {
|
||||
var used []string
|
||||
seen := map[string]bool{}
|
||||
remember := func(token string) {
|
||||
if token == "" || seen[token] {
|
||||
return
|
||||
}
|
||||
seen[token] = true
|
||||
used = append(used, token)
|
||||
}
|
||||
|
||||
out := svgImageTagRegex.ReplaceAllStringFunc(svg, func(tag string) string {
|
||||
return svgImageHrefRegex.ReplaceAllStringFunc(tag, func(attr string) string {
|
||||
m := svgImageHrefRegex.FindStringSubmatch(attr)
|
||||
if len(m) < 6 || m[3] != m[5] {
|
||||
return attr
|
||||
}
|
||||
prefix := m[1]
|
||||
name := m[2]
|
||||
value := strings.TrimSpace(m[4])
|
||||
if strings.HasPrefix(value, "@") {
|
||||
path := strings.TrimSpace(strings.TrimPrefix(value, "@"))
|
||||
token := tokens[path]
|
||||
if token == "" {
|
||||
return attr
|
||||
}
|
||||
remember(token)
|
||||
return fmt.Sprintf(`%shref="%s"`, prefix, xmlEscape(token))
|
||||
}
|
||||
if strings.EqualFold(name, "xlink:href") {
|
||||
if shouldTreatAsFileToken(value) {
|
||||
remember(value)
|
||||
}
|
||||
return fmt.Sprintf(`%shref="%s"`, prefix, xmlEscape(value))
|
||||
}
|
||||
if shouldTreatAsFileToken(value) {
|
||||
remember(value)
|
||||
}
|
||||
return attr
|
||||
})
|
||||
})
|
||||
return out, used
|
||||
}
|
||||
|
||||
func svgAssetTokenForPath(assets map[string]string, path string) string {
|
||||
if len(assets) == 0 {
|
||||
return ""
|
||||
}
|
||||
if token := strings.TrimSpace(assets["@"+path]); token != "" {
|
||||
return token
|
||||
}
|
||||
return strings.TrimSpace(assets[path])
|
||||
}
|
||||
|
||||
func shouldTreatAsFileToken(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || strings.HasPrefix(value, "@") || strings.HasPrefix(value, "#") {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(value)
|
||||
return !strings.HasPrefix(lower, "http://") && !strings.HasPrefix(lower, "https://") && !strings.HasPrefix(lower, "data:")
|
||||
}
|
||||
|
||||
func injectSVGTransportAssetMetadata(svg string, tokens []string) (string, error) {
|
||||
tokens = dedupeStrings(tokens)
|
||||
if len(tokens) == 0 {
|
||||
return svg, nil
|
||||
}
|
||||
m := svgRootOpenTagRegex.FindStringSubmatchIndex(svg)
|
||||
if m == nil {
|
||||
return "", fmt.Errorf("SVG root element not found")
|
||||
}
|
||||
tagName := svg[m[4]:m[5]]
|
||||
if tagName != "svg" {
|
||||
return "", fmt.Errorf("root element must be <svg>")
|
||||
}
|
||||
|
||||
if existing := svgMetadataRegex.FindStringIndex(svg); existing != nil {
|
||||
block := svg[existing[0]:existing[1]]
|
||||
existingTokens := metadataImgTokens(block)
|
||||
var missing []string
|
||||
for _, token := range tokens {
|
||||
if !existingTokens[token] {
|
||||
missing = append(missing, token)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return svg, nil
|
||||
}
|
||||
addition := renderSVGTransportImgs(missing)
|
||||
rewritten := svgMetadataEndRegex.ReplaceAllStringFunc(block, func(end string) string {
|
||||
return addition + end
|
||||
})
|
||||
return svg[:existing[0]] + rewritten + svg[existing[1]:], nil
|
||||
}
|
||||
|
||||
metadata := `<metadata data-svglide-assets="true">` + renderSVGTransportImgs(tokens) + `</metadata>`
|
||||
prefix := svg[:m[8]]
|
||||
closer := svg[m[8]:m[9]]
|
||||
after := svg[m[9]:]
|
||||
if closer == "/>" {
|
||||
return prefix + ">" + metadata + "</svg>" + after, nil
|
||||
}
|
||||
return svg[:m[9]] + metadata + after, nil
|
||||
}
|
||||
|
||||
func metadataImgTokens(metadata string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, m := range svgMetadataImgRegex.FindAllStringSubmatch(metadata, -1) {
|
||||
if len(m) >= 4 && m[1] == m[3] {
|
||||
out[m[2]] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func renderSVGTransportImgs(tokens []string) string {
|
||||
var b strings.Builder
|
||||
for _, token := range tokens {
|
||||
b.WriteString(`<img src="`)
|
||||
b.WriteString(xmlEscape(token))
|
||||
b.WriteString(`" />`)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func dedupeStrings(in []string) []string {
|
||||
var out []string
|
||||
seen := map[string]bool{}
|
||||
for _, item := range in {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" || seen[item] {
|
||||
continue
|
||||
}
|
||||
seen[item] = true
|
||||
out = append(out, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildCreateSVGBody(svg string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"slide": map[string]interface{}{"content": svg},
|
||||
}
|
||||
}
|
||||
|
||||
func extractSVGlideErrorJSON(err error) map[string]interface{} {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
const marker = "SVGLIDE_ERROR_JSON:"
|
||||
msg := err.Error()
|
||||
idx := strings.Index(msg, marker)
|
||||
if idx < 0 {
|
||||
return nil
|
||||
}
|
||||
raw := strings.TrimSpace(msg[idx+len(marker):])
|
||||
if end := strings.IndexAny(raw, "\r\n"); end >= 0 {
|
||||
raw = raw[:end]
|
||||
}
|
||||
var parsed map[string]interface{}
|
||||
if json.Unmarshal([]byte(raw), &parsed) != nil {
|
||||
return nil
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func formatSVGlideErrorSuffix(err error) string {
|
||||
parsed := extractSVGlideErrorJSON(err)
|
||||
if len(parsed) == 0 {
|
||||
return ""
|
||||
}
|
||||
data, jsonErr := json.Marshal(parsed)
|
||||
if jsonErr != nil {
|
||||
return ""
|
||||
}
|
||||
return " svglide_error=" + string(data)
|
||||
}
|
||||
327
shortcuts/slides/svg_helpers_test.go
Normal file
327
shortcuts/slides/svg_helpers_test.go
Normal file
@@ -0,0 +1,327 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"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: "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: "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"`,
|
||||
},
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: lark-slides
|
||||
version: 1.0.0
|
||||
version: 1.0.2
|
||||
description: "飞书幻灯片:创建和编辑幻灯片,接口通过 XML 协议通信。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。"
|
||||
metadata:
|
||||
requires:
|
||||
@@ -15,24 +15,33 @@ 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`、`svg-visual-recipes.md`、`svg-aesthetic-review.md` |
|
||||
| 大幅改写页面 | 先回读现有 XML,写入新 plan,再替换或重建相关页面 | `xml_presentations.get`、`+replace-slide`、`lark-slides-edit-workflows.md` |
|
||||
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide`、`lark-slides-replace-slide.md` |
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,回读全文或单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `xml_presentations.get`、`xml_presentation.slide.get` |
|
||||
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides` 的 `@./path` 占位符 |
|
||||
| 上传或使用图片 | Preview 阶段优先多用真实图片增强视觉冲击;可先用公开可访问 http(s)/data 图片或本地 `@./path`,来源/授权只 warning 不阻断;正式交付再替换为授权清晰的 file token / 本地资产 | `slides +media-upload`,或 `+create --slides` / `+create-svg` 的 `@./path` 占位符 |
|
||||
| 用户提到模板、主题、版式 | 先检索模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
|
||||
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md`、`validation-checklist.md` |
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
**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 SVG:root `<svg>` 声明 `xmlns:slide` 且 `slide:role="slide"`;可渲染 SVG 元素必须用 `slide:role="shape"` 或 `slide:role="image"` 表达;`g` / 嵌套 `svg` 可作为容器,但容器内实际渲染元素仍必须各自声明 role。CLI 只读取文件、上传/替换图片占位符、注入 transport metadata 和调用现有 `/slide` 路由,不会把普通 SVG 自动补齐成协议 SVG。**
|
||||
|
||||
**CRITICAL — SVGlide deck 页数默认值:当用户要求生成 SVG/SVGlide 幻灯片但未说明页数,或使用“一份 slide / 一份 PPT / 做个 slide / 生成一个 slide”这类模糊表达时,默认生成 `10` 页,不要仅因页数缺失而停下来追问。只有用户明确说“一页 / 单页 / onepage / one slide / 只要封面”等单页意图时,才生成 `1` 页;用户给出明确页数时始终服从用户要求。默认 10 页时必须在 `slide_plan.json` 写入 `page_count` 或 `target_slide_count=10`,并包含明确 closing slide。**
|
||||
|
||||
**CRITICAL — 高质量 SVG deck 生成时,MUST 同时读取 [lark-slides-create-svg.md](references/lark-slides-create-svg.md) 和 [svg-visual-recipes.md](references/svg-visual-recipes.md):复用现有 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json` 作为设计状态,先做 deck-level density plan,再为每页选择 `visual_recipe`、声明 `svg_primitives` / `visual_focal_point` / `xml_like_risk`,然后定义布局盒,给 `foreignObject` 文本留足安全高度。生成器必须在写 SVG 前做 preflight-aware 自检:由实际组件 manifest 反推出 primitives,按 `content_density_contract` 计数,检查主体元素 safe area / text bbox,不要只靠最终 `svg_preflight.py` 兜底。Preview 阶段默认必须使用丰富真实图片资产,并 SHOULD 优先根据用户 query / deck 主题 / 章节标题去网络检索和拉取强相关图片;公开图、场景图、产品图、截图、纹理/材质、图鉴图均可作为占位视觉。版权/授权不作为 preview 阻断,但要在 `asset_contract` 里标记 `retrieval_query`、`source_url` 和 `preview_unverified`;正式交付再替换为授权清晰的本地 `@./path` / file token。相邻页面要显著换版式且 8 页以上至少使用 5 种 visual recipe family;如果 agent 支持本地浏览器预览,SHOULD 生成并打开 `preview.html`,并按 [svg-aesthetic-review.md](references/svg-aesthetic-review.md) 检查明显视觉问题;调用 API 前必须跑本地 preflight(优先使用 [`scripts/svg_preflight.py`](scripts/svg_preflight.py)),live 创建后必须 readback 校验。这些是生成技巧,不替代 [svg-protocol.md](references/svg-protocol.md) 的硬协议约束。**
|
||||
|
||||
**CRITICAL — SVGlide 高质量生成必须读取 [style-presets.md](references/style-presets.md),并从 [style-presets.json](references/style-presets.json) 选择一个 deck-level `style_preset`。`style_preset` 只表达视觉语言,不替代 `visual_recipe`;`visual_recipe` 的选择和安全效果边界以 [svg-visual-recipes.md](references/svg-visual-recipes.md) 为准。生成顺序是 semantic plan -> visual_recipe -> style_preset/style_system -> layout boxes -> SVG。每页必须声明 `visual_signature` 和 `svg_effects`,说明这一页相对普通 XML/PPT 模板的 SVG 视觉优势。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan/<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 和布局风险。**
|
||||
|
||||
@@ -77,7 +86,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-visual-recipes.md`](references/svg-visual-recipes.md)、[`svg-aesthetic-review.md`](references/svg-aesthetic-review.md)
|
||||
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)
|
||||
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
|
||||
- 模板:[`template-catalog.md`](references/template-catalog.md)、[`scripts/template_tool.py`](scripts/template_tool.py)
|
||||
@@ -99,7 +108,7 @@ lark-cli auth login --domain slides
|
||||
- **背景一致性**:先确定全 deck 的背景策略,默认保持同一明暗基调和底色体系;只有分节、转场或强调页才有意改变背景,并必须通过相同主色、纹理、边栏或 motif 让变化看起来属于同一套设计。无论深浅,都要保证正文、图标和线条对比充足。
|
||||
- **统一 motif**:选择一个可复用视觉母题贯穿全文,例如粗侧边栏、圆形图标底、半出血图片区、编号节点、卡片左上角色块或大号数字。不要每页换一套装饰语言。
|
||||
|
||||
每页至少要有一个视觉元素:图片、图标、图表、表格、流程、对比结构、大号数字、示意图或由 shape 组成的抽象视觉。文本框本身不算主视觉。
|
||||
每页至少要有一个视觉元素:图片、图标、图表、表格、流程、对比结构、大号数字、示意图或由 shape 组成的抽象视觉。文本框本身不算主视觉。展示型、宣传型、产品型和案例型 deck 不能全程纯矢量,必须包含真实图片资产作为封面、半出血主视觉、案例场景、产品截图或材质背景。
|
||||
|
||||
可优先考虑这些页面形态:
|
||||
|
||||
@@ -123,7 +132,9 @@ 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。
|
||||
- 不要留下模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。
|
||||
|
||||
### 创建方式选择
|
||||
@@ -132,6 +143,7 @@ lark-cli auth login --domain slides
|
||||
|------|----------|
|
||||
| 简单 XML(1-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]
|
||||
@@ -152,7 +164,7 @@ python3 skills/lark-slides/scripts/template_tool.py extract --template <template
|
||||
|
||||
```text
|
||||
Step 1: 需求澄清 & 读取知识
|
||||
- 澄清主题、受众、页数、风格;模板需求按“模板与脚本优先流程”处理
|
||||
- 澄清主题、受众、页数、风格;SVGlide 模糊页数按默认 10 页处理,不因页数缺失单独阻塞;模板需求按“模板与脚本优先流程”处理
|
||||
- 读取 xml-schema-quick-ref.md;新建 / 大幅改写时还要读取 planning-layer.md、visual-planning.md、asset-planning.md
|
||||
|
||||
Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json
|
||||
@@ -160,10 +172,10 @@ Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json
|
||||
- 新建 / 大幅改写必须先创建目录并写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
|
||||
- plan 字段、路径命名、模板边界和 `asset_need` 结构按 planning-layer.md / asset-planning.md 执行
|
||||
|
||||
Step 3: 按 slide_plan.json 生成 XML → 创建
|
||||
Step 3: 按 slide_plan.json 生成 XML 或 SVGlide SVG → 创建
|
||||
- 逐页消费 plan:key_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 文本重叠检查
|
||||
@@ -259,6 +271,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/>`,不改变页序 |
|
||||
|
||||
@@ -272,19 +285,20 @@ lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
## 核心规则
|
||||
|
||||
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
|
||||
2. **创建流程**:简单短 XML(1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加
|
||||
2. **创建流程**:简单短 XML(1-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 不支持分片上传)。
|
||||
|
||||
## 权限速查
|
||||
|
||||
| 方法 | 所需 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` |
|
||||
@@ -293,4 +307,12 @@ lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
| `xml_presentation.slide.get` | `slides:presentation:read` |
|
||||
| `xml_presentation.slide.replace` | `slides:presentation:update` |
|
||||
|
||||
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。
|
||||
> **注意**: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 时不要删除。
|
||||
|
||||
676
skills/lark-slides/references/lark-slides-create-svg.md
Normal file
676
skills/lark-slides/references/lark-slides-create-svg.md
Normal file
@@ -0,0 +1,676 @@
|
||||
# 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。
|
||||
|
||||
不适用:
|
||||
|
||||
- 你只有普通 SVG,且没有 `slide:role` 协议标记。
|
||||
- 复杂普通 SVG 不能直接提交;需要把实际可渲染元素标成 SVGlide role。`g` / 嵌套 `svg` 容器可以保留,但不能代替子元素 role。
|
||||
- 你需要插入到指定页前;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 到服务端的契约。
|
||||
|
||||
## 图片处理
|
||||
|
||||
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>`。
|
||||
|
||||
预上传资源可用 `--assets`:
|
||||
|
||||
```json
|
||||
{
|
||||
"@./hero.png": "boxcn..."
|
||||
}
|
||||
```
|
||||
|
||||
## 生成质量规则
|
||||
|
||||
这些规则用于生成阶段主动规避服务端降级、近似和泛化错误。几何数值、path 命令、role/必填属性、图片 href 等基础约束已由 CLI 强校验;版式、美观和文本溢出仍需要生成器或人工复核。
|
||||
|
||||
### 与现有规划层对齐
|
||||
|
||||
SVG 创建不使用单独的规划目录。新建或大幅改写 SVG deck 时,仍然复用 [planning-layer.md](planning-layer.md) 规定的 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,不要另建 `.lark-slides/svg-plan` 或只保留散落的 `.svg` 文件。
|
||||
|
||||
在通用 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},
|
||||
"style_preset": "raw_grid",
|
||||
"style_selection_reason": "raw_grid fits technical training pages that need dense but readable visual structure",
|
||||
"style_system": {
|
||||
"palette": {
|
||||
"background": "#F5F5F5",
|
||||
"text": "#0A0A0A",
|
||||
"accent": "#F2D4CF"
|
||||
},
|
||||
"typography": "strong title, readable native text labels",
|
||||
"background_strategy": "muted grid panels with one stable background family",
|
||||
"motif": "dense grid panels with restrained accent labels"
|
||||
},
|
||||
"svg_constraints": {
|
||||
"text_element": "foreignObject slide:role=shape slide:shape-type=text",
|
||||
"path_commands": "M/L/H/V/C/Q/Z only",
|
||||
"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 --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"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
模板也复用现有 `template_tool.py search -> summarize -> extract` 路由。模板摘要只用于选择主题、页面流、视觉节奏和布局骨架;生成 SVG 时要把模板结构翻译成 SVG layout boxes / visual recipes,不要照搬模板 XML,也不要读取完整模板 XML。
|
||||
|
||||
SVG deck 的 `slides[]` 还必须包含这些可校验字段,避免生成结果虽然能创建但内容千篇一律、信息量不足或在资料缺失时编造事实:
|
||||
|
||||
```json
|
||||
{
|
||||
"page": 3,
|
||||
"page_type": "content",
|
||||
"renderer_id": "dashboard_scorecard",
|
||||
"layout_family": "dashboard",
|
||||
"visual_recipe": "fake_ui_dashboard",
|
||||
"visual_intent": "use a product-console dashboard surface to make metrics feel operational",
|
||||
"visual_focal_point": "central metric card and trend line",
|
||||
"visual_signature": "fake product console frame + micro chart geometry + status chips",
|
||||
"svg_effects": ["chart_geometry", "connector_flow", "typography"],
|
||||
"required_primitives": ["dashboard", "micro_chart"],
|
||||
"svg_primitives": ["dashboard", "micro_chart", "typography", "geometric_shape"],
|
||||
"xml_like_risk": "without SVG primitives this page would degrade into three metric cards plus bullets",
|
||||
"recipe_fallback": "if dashboard micro charts are too dense, keep the fake UI frame and simplify charts to bar-like rects",
|
||||
"density": "high",
|
||||
"density_structure": "dashboard with four metric cards, trend line, and source note",
|
||||
"content_density_contract": "dashboard >= 4 metrics",
|
||||
"asset_contract": "none_required | {mode: preview|production, retrieval_query, source_type, license, local_path_or_href, usage_page, source_url/generated_by, replacement_required}",
|
||||
"risk_flags": ["text_overflow", "image_license", "conversion_dasharray"],
|
||||
"source_status": "source_verified | attachment_missing | user_prompt_only",
|
||||
"source_policy": "when attachment_missing, show 待从附件补齐 / 来源缺失 and avoid numeric claims",
|
||||
"layout_guardrails": [
|
||||
"renderer_id must change actual geometry, not only the name",
|
||||
"visual_recipe must map to SVGlide-safe primitives present in the SVG source",
|
||||
"main text and chart labels stay inside safe area",
|
||||
"dense page uses a structured visual carrier, not a long bullet box",
|
||||
"avoid XML-like card layout unless the page has real SVG-native visual structure"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Style Preset Catalog
|
||||
|
||||
SVGlide 高质量生成必须先从 [style-presets.json](style-presets.json) 选择一个 deck-level `style_preset`,并把它翻译成 `style_system`。`style_preset` 不替代 `visual_recipe`:前者定义视觉语言,后者定义页面结构和 SVG-native 表达价值。
|
||||
|
||||
生成前还必须读取 [svg-visual-recipes.md](svg-visual-recipes.md)。该文件是当前 CLI 执行链路的短规则入口,负责把研究 catalog 映射成可写入 `slide_plan.json` 的 underscore `visual_recipe` 枚举、安全效果边界和 deck 多样性要求。
|
||||
|
||||
生成顺序:
|
||||
|
||||
```text
|
||||
semantic plan
|
||||
-> visual_recipe
|
||||
-> style_preset + style_system
|
||||
-> layout boxes
|
||||
-> SVG source
|
||||
-> svg_preflight.py --plan
|
||||
```
|
||||
|
||||
`style_system` 至少包含:
|
||||
|
||||
- `palette`: 背景、正文、强调色。
|
||||
- `typography`: 标题、标签、正文的字号/字重策略。
|
||||
- `background_strategy`: 全 deck 背景和例外页规则。
|
||||
- `motif`: 可复用的视觉母题,例如 grid panels、stamp labels、court lanes、riso color plates。
|
||||
|
||||
每页必须声明:
|
||||
|
||||
- `visual_signature`: 这一页相对普通 XML/PPT 模板的独特 SVG 视觉记忆点。
|
||||
- `svg_effects`: 真实使用或计划使用的 SVG 表达能力,例如 `path`、`connector_flow`、`gradient`、`texture`、`chart_geometry`、`image_overlay`。
|
||||
|
||||
`svg_preflight.py` 会校验 preset 是否存在、`style_system` 是否完整、可见文本是否泄漏 preset 名称/source token/tool/path,以及 declared `svg_effects` 是否能在 SVG source 中命中。
|
||||
|
||||
### SVG-native visual recipe catalog
|
||||
|
||||
SVG 不是普通矢量图文件的传输外壳。每页都必须选择一个 `visual_recipe`,并在 `svg_primitives` 中声明真实会绘制的 SVGlide-safe primitives。`renderer_id` 负责几何布局命名;`visual_recipe` 负责说明这页为什么值得走 SVG。
|
||||
|
||||
本节保留协议内置摘要;实际生成前优先读 [svg-visual-recipes.md](svg-visual-recipes.md),避免把研究文档里的 dotted recipe 名称直接写进运行时 plan。
|
||||
|
||||
| `visual_recipe` | 适用页型 | required primitives | forbidden patterns / fallback |
|
||||
|---|---|---|---|
|
||||
| `hero_typography` | 封面、章节页、观点页 | `typography`, `geometric_shape` | 不要只写普通标题;大字用 `foreignObject`,描边/裁切感用大字背板、路径轮廓或分层 shape 模拟 |
|
||||
| `geometric_composition` | 战略框架、阶段划分、版式强分区 | `geometric_shape`, `path` | 不要只堆 3 个矩形卡片;斜切块、多边形全部用 `path` 写,不用 `polygon` |
|
||||
| `path_flow` | 路线、旅程、流程、增长路径 | `path`, `annotation` | 不依赖 `marker` / `stroke-dasharray`;箭头用显式三角 `path`,虚线用短 line/dot 组合 |
|
||||
| `infographic_scorecard` | 数据战报、OKR、业务复盘 | `typography`, `micro_chart` | 不要只放大数字;补环形/条形/标尺等微图表,圆环用双层填充圆或 path |
|
||||
| `icon_capability_map` | 能力地图、模块总览、平台能力 | `icon`, `geometric_shape` | 图标用 SVGlide-safe path/line/rect 组合,不用外链 iconfont 或根级 `<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 式卡片。
|
||||
|
||||
### 生成阶段 Fail-Fast Gate
|
||||
|
||||
`slide_plan.json` 不是说明文档,而是生成阶段的硬契约。生成器必须先通过 plan gate,再渲染 SVG;本地 `svg_preflight.py --plan` 失败时禁止调用 live API。
|
||||
|
||||
每页 SVG plan 必填:
|
||||
|
||||
| Field | 作用 | 失败后处理 |
|
||||
|---|---|---|
|
||||
| `renderer_id` | 标识具体渲染器/几何结构 | 换真实 renderer,不用 `two_column_1` 这类假命名 |
|
||||
| `layout_family` | 做 deck 级版式多样性检查 | 相邻页重复时换阅读方向、主视觉位置或信息结构 |
|
||||
| `visual_recipe` | 说明这页为什么值得走 SVG | 从 recipe catalog 选择,不能自造枚举 |
|
||||
| `required_primitives` | 这页必须在 SVG source 中真实出现的 primitive | 至少覆盖 recipe required primitives |
|
||||
| `svg_primitives` | 实际计划绘制的 primitive | 必须覆盖 `required_primitives` |
|
||||
| `visual_intent` | SVG 视觉表达目的 | 写清楚 SVG-native 价值,不写空泛风格词 |
|
||||
| `visual_focal_point` | 页面视觉焦点 | 用于判断布局是否围绕主视觉组织 |
|
||||
| `xml_like_risk` | 退化成普通 XML 卡片页的风险 | 明确说明不用 SVG 会丢失什么结构 |
|
||||
| `content_density_contract` | 信息密度硬契约 | 高密度页必须量化,例如 `dashboard >= 4 metrics` |
|
||||
| `asset_contract` | 图片/素材来源与许可契约 | 无图写 `none_required`;Preview 网络图必须记录 `retrieval_query` / `source_url`,授权未确认可写 `license=preview_unverified` 且不阻断;正式交付必须补 source/license/local path 或替换 |
|
||||
| `risk_flags` | 生成风险显式登记 | 无风险用空数组;不要省略字段 |
|
||||
| `source_policy` | 缺数据/数字声明处理策略 | 防止自动扩写时编造业务数字 |
|
||||
|
||||
deck 级硬门禁:
|
||||
|
||||
- 用户未说明页数,或只说“一份 slide / 一份 PPT / 做个 slide / 生成一个 slide”这类模糊表达时,默认 `page_count=10`;不要仅因页数缺失而停下来追问。明确“一页 / 单页 / onepage / one slide / 只要封面”才按 `page_count=1`。默认 10 页必须包含 closing slide,并满足 10 页 deck 的 layout / renderer 多样性门禁。
|
||||
- 8 页以上必须有明确 closing slide。
|
||||
- 10 页以上至少 5 种 `layout_family`。
|
||||
- 不允许连续 3 页使用同一 `layout_family`。
|
||||
- 8 页以上至少 5 种 `visual_recipe` family。
|
||||
- 10 页以上至少 5 种真实 `renderer_id`。
|
||||
- 高密度页必须有量化 `content_density_contract`,不能只写“信息丰富”。
|
||||
|
||||
量化密度契约建议:
|
||||
|
||||
```text
|
||||
matrix/table >= 6 cells
|
||||
timeline >= 4 nodes
|
||||
dashboard >= 4 metrics
|
||||
flow >= 4 stages
|
||||
risk_grid >= 4 items
|
||||
comparison >= 4 rows or columns
|
||||
```
|
||||
|
||||
如果 SVG source 无法满足对应数量,`svg_preflight.py` 会报 `plan_content_density_contract_not_met`,生成器必须补真实结构,不要只改字段名。
|
||||
|
||||
### 生成前强约束
|
||||
|
||||
以下规则来自实际 SVGlide live 生成、回读和修复经验,生成器必须先满足这些规则,再追求视觉复杂度。
|
||||
|
||||
- MUST: 默认使用 Lark Slides 当前回读画布 `960 x 540`,即 root 写成 `width="960" height="540" viewBox="0 0 960 540"`。不要默认用 `1280 x 720`,否则服务端回读后可能整页偏大并裁切。
|
||||
- MUST: 主体元素使用安全区,建议 `safe = x:48 y:40 w:864 h:460`。除全屏背景外,文本、卡片、图表、标签、节点和图例都必须落在安全区内。
|
||||
- MUST: 多页 deck 应包含明确的 closing slide。8 页以上讲解/汇报型 deck 不要把 roadmap / next-playbook 当作结束页;最后一页应包含 `closing`、`summary`、`Q&A`、`Thanks` 或下一步联系信息。
|
||||
- MUST: `slides[]` 必须记录 `renderer_id`,且它要对应真实几何结构,而不是 `two-column-1` / `two-column-2` 这种名字变化。10 页以上 deck 至少 5 种 renderer/layout family;不得连续 3 页使用同一 renderer。
|
||||
- MUST: `slides[]` 必须记录 `layout_family`、`visual_recipe`、`visual_intent`、`visual_focal_point`、`required_primitives`、`svg_primitives`、`xml_like_risk`、`content_density_contract`、`risk_flags`、`source_policy`。`asset_contract` 应尽量记录;MVP 阶段缺失只 warning。没有 SVG-native recipe 的页面不应走 `slides +create-svg`,应改用普通 Slides XML 或重新选择 SVG recipe。
|
||||
- MUST: `visual_recipe` 必须来自 catalog,且 `svg_primitives` 必须覆盖该 recipe 的 required primitives。`renderer_id` 不能替代 `visual_recipe`。
|
||||
- MUST: 8 页以上 SVG deck 至少使用 5 种 visual recipe family;不能整套 deck 都是卡片、双栏或普通 dashboard。
|
||||
- MUST: 高密度页必须声明 `density_structure` 和量化 `content_density_contract`,例如 `matrix/table >= 6 cells`、`timeline >= 4 nodes`、`dashboard >= 4 metrics`、`flow >= 4 stages`、`risk_grid >= 4 items`。只有“大标题 + 大图 + 2-3 个短 chip”不算高密度。
|
||||
- MUST: 来源不足、附件缺失、用户未提供数据时,必须在 plan 中写 `source_status` 和 `source_policy`,并在页面上显式表达“待从附件补齐 / 来源缺失 / no numeric claims”。不要编造客户、排名、真实论文数据、金额、占比、链接、logo 或引用。
|
||||
- MUST: `foreignObject` 文本样式使用显式 CSS:`font-size`、`font-weight`、`font-family`、`color`、`line-height`、`text-align`。不要用 `font:` shorthand 表达关键字号和加粗。
|
||||
- MUST: 白色或接近白色的文字必须完整落在深色 shape 承载底上。标题、封面副标题、CTA、页脚等不能跨出深色底,压到浅色图片、白色蒙层或白底上;需要时扩大色块、加深色背板/遮罩,或改用深色文字。
|
||||
- MUST: 圆形/椭圆节点只承载短标签,不承载解释句。节点内 `foreignObject` bbox 必须小于节点 bbox;微解释、指标、下一步和注释放到独立说明卡、图例、机制表或外侧 callout。
|
||||
- MUST: 提交前和 live 回读后都检查边界和重叠:非背景元素不得越过 `960 x 540`,第 2/3 页等信息密集页必须额外检查 text bbox overlap。
|
||||
- SHOULD: 如果本地预览使用更大画布,例如 `1280 x 720`,必须在输出给 `slides +create-svg` 前按比例换算为 `960 x 540`,而不是只改 root viewBox。
|
||||
|
||||
### 生成器实现约束与 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,但应只承担背景/承载作用,不承载关键文本。
|
||||
- SHOULD: 对高风险页面使用更保守的留白:标题与图表标签至少相隔 24px,曲线端点标签不要压在标题/图例区域,卡片内文字与边框至少留 10-14px。
|
||||
- SHOULD: 把每页的 `safe`、`titleBox`、`visualBox`、`textBox` 等布局盒保存为可检查数据,便于自动计算越界和重叠。
|
||||
|
||||
推荐生成顺序:
|
||||
|
||||
```text
|
||||
deck/page plan
|
||||
-> layout boxes
|
||||
-> components with emitted primitive manifest
|
||||
-> generator asserts: recipe/primitives/density/text/safe-area
|
||||
-> write SVG + slide_plan.json from the same manifest
|
||||
-> svg_preflight.py --plan ...
|
||||
-> dry-run / live create / readback
|
||||
```
|
||||
|
||||
### 本地 HTML 预览(建议)
|
||||
|
||||
HTML 预览是生成阶段的轻量质检,不是 SVGlide 协议或 CLI API 的硬依赖。
|
||||
|
||||
- SHOULD: 生成 SVGlide deck 后、调用 `slides +create-svg` 前,生成一个本地 `preview.html`,把每页 SVG 按 16:9 画布嵌入,并展示页码、标题、`renderer_id` / `visual_recipe`、图片资产状态、preview-only 图片来源和明显 warning。
|
||||
- SHOULD: 如果当前 agent、IDE 或浏览器工具支持打开本地文件,打开 `preview.html` 进行人工或截图式预览,优先检查:
|
||||
- 页面是否空白、明显裁切或整体偏大。
|
||||
- 标题、正文、图片和装饰元素是否重叠。
|
||||
- 白色/浅色文字是否压到浅色背景或图片亮部。
|
||||
- 相邻页面是否版式过度重复。
|
||||
- 信息密度是否明显不足,尤其是高密度页是否真的有 matrix/table/timeline/dashboard/flow/risk grid。
|
||||
- 结尾页是否存在。
|
||||
- 图片是否显示,是否有破图、空图片框、图片过少或 preview-only 来源未记录。
|
||||
- SHOULD: 在最终产物目录记录 `preview.html` 路径;如果未生成或无法打开,说明原因,并继续执行 preflight / dry-run / readback。
|
||||
- MUST NOT: 用 HTML 预览替代 `svg_preflight.py`、`slides +create-svg --dry-run` 或 live readback。HTML 预览主要提前发现审美、布局和素材问题;服务端转换后的字体、path bbox、图片 token 和部分 SVG 效果仍必须通过 readback 验证。
|
||||
|
||||
打开预览后必须按 [svg-aesthetic-review.md](svg-aesthetic-review.md) 做一次人工或截图式审查。重点看所有页面的标题区、装饰线、badge、文本框、图片框、safe area、重复版式和 SVG 视觉优势;如果多页出现同类问题,修生成规则后重新生成,不要只逐页微调坐标。
|
||||
|
||||
本地 preflight 必须在 `slides +create-svg` 前执行,失败即停:
|
||||
|
||||
- `python3 skills/lark-slides/scripts/svg_preflight.py --plan .lark-slides/plan/<deck-id>/slide_plan.json --input page-*.svg` 通过;如果脚本不可用,再退回 `xmllint --noout page-*.svg` 加人工检查。
|
||||
- root 是 `width="960" height="540" viewBox="0 0 960 540"`。
|
||||
- root / leaf `slide:role` 完整,所有 leaf 有几何必填属性。
|
||||
- plan 中每页 `layout_family`、`visual_recipe`、`visual_intent`、`visual_focal_point`、`required_primitives`、`svg_primitives`、`xml_like_risk`、`content_density_contract`、`risk_flags`、`source_policy` 完整,且 recipe required primitives 能在对应 SVG source 中命中。`asset_contract` 在 MVP 阶段缺失只 warning;有条件时仍应补全。
|
||||
- 禁止 SVG 退化成 XML-like 卡片页:如果页面基本只有 `rect + foreignObject`,且没有 path、gradient、image overlay、annotation、micro chart、icon、texture、spotlight、flow 等 SVG-native primitive,preflight 必须失败。
|
||||
- 禁止零尺寸元素;文本框、图片、卡片和圆/椭圆必须有正向宽高,不能生成 `height="0"` 的隐藏说明。
|
||||
- `<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。
|
||||
- 禁止 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 内。
|
||||
- 同组元素使用同一个父盒推导坐标。
|
||||
- 图例、标签、指标不能浮在不上不下的位置,必须相对主视觉左/右/下边对齐。
|
||||
- 如果页面有圆、节点、卡片或框体,内容中心应和外框中心基本一致,不靠手调 `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`。
|
||||
|
||||
### 几何与 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 检索;记录图片页 URL;license 可先写 `preview_unverified`,正式交付再确认 |
|
||||
| Openverse / Wikimedia Commons | 百科、历史、技术、公共领域素材 | 记录单图 URL 和作者/页面;preview 可先用,正式交付补 license / attribution |
|
||||
| The Met / Smithsonian / NASA Open Access | 艺术、科学、历史、航天视觉 | 记录条目 URL;preview 可先用,正式交付确认 Open Access / 第三方权利 |
|
||||
| 官网 / 产品页 / 新闻图 / 搜索图 | 产品截图、竞品页、事件背景、真实语境 | Preview 可作为视觉占位;必须标记 `license=preview_unverified`,正式交付替换或删去 |
|
||||
| AI 生成图 / 程序化纹理 | 抽象背景、材质、概念图 | 记录生成方式和提示词摘要;正式交付确认模型/平台授权 |
|
||||
|
||||
素材清单建议字段:
|
||||
|
||||
```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 手绘矩阵、流程、时间线、微柱状、雷达、散点、标尺;不要默认依赖 Slides 原生 chart,也不要把外部图表截图当成唯一方案。
|
||||
- `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` 留足高度,不要按浏览器本地字体做极限排版。
|
||||
|
||||
### 生成后检查
|
||||
|
||||
生成脚本或人工复核必须检查:
|
||||
|
||||
- 是否已执行本地 preflight,且所有 SVG 通过 XML、协议、资产、bbox 和文本重叠检查。
|
||||
- 是否已执行 `slides +create-svg --dry-run`,确认请求链路是创建 presentation + 按页追加 SVG。
|
||||
- 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;高密度页的视觉结构是否承载信息,而不只是装饰。
|
||||
- 内容页是否避免了“大标题 + 大图 + 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
|
||||
验证记录:
|
||||
- Preflight:N/N SVG 通过 root/role/geometry/path/image/bbox 检查。
|
||||
- Dry-run:已确认 create presentation + N 次 /slide。
|
||||
- Readback:实际页数 N / 预期 N;未发现空白页、破图或缺失 closing slide。
|
||||
- 版式:检查 safe area、文本重叠、越界和相邻页版式变化。
|
||||
- 资产:Preview 阶段优先丰富图片和 readback 可见性;若保留 http(s)/data URL 或 `preview_unverified` 来源,必须记录 warning。正式交付再替换为本地 @path 自动上传或 file token,并补齐授权。
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
任一页失败时,错误会包含:
|
||||
|
||||
- `xml_presentation_id`
|
||||
- 失败页序号
|
||||
- 已成功页数
|
||||
- 已创建的 `slide_ids`
|
||||
|
||||
如果服务端 detail 带有 `SVGLIDE_ERROR_JSON:` marker,CLI 会提取并在错误中展示 `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 文件、创建结果和验证记录一致。
|
||||
@@ -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>`。
|
||||
@@ -67,6 +67,18 @@ Exception:
|
||||
"accent": "Used only for key numbers, conclusions, or focus markers."
|
||||
}
|
||||
},
|
||||
"style_preset": "raw_grid",
|
||||
"style_selection_reason": "raw_grid fits technical training pages that need dense but readable visual structure",
|
||||
"style_system": {
|
||||
"palette": {
|
||||
"background": "#F5F5F5",
|
||||
"text": "#0A0A0A",
|
||||
"accent": "#F2D4CF"
|
||||
},
|
||||
"typography": "strong title, readable native text labels",
|
||||
"background_strategy": "muted grid panels with one stable background family",
|
||||
"motif": "dense grid panels with restrained accent labels"
|
||||
},
|
||||
"typography_constraints": {
|
||||
"title_max_lines": 2,
|
||||
"body_max_lines_per_box": 2,
|
||||
@@ -107,6 +119,9 @@ Top-level fields:
|
||||
- `audience`: target readers or listeners and their assumed background.
|
||||
- `theme_style`: visual tone, palette direction, and professional style.
|
||||
- `visual_system`: deck-level visual rules that must stay stable across pages, including background strategy, recurring motif, and color roles.
|
||||
- `style_preset`: required for SVGlide SVG decks. Choose one id from `references/style-presets.json`; omit only for non-SVG XML/SXSD plans.
|
||||
- `style_selection_reason`: required for SVGlide SVG decks. Explain why the preset fits the audience, topic, density, and expected tone.
|
||||
- `style_system`: required for SVGlide SVG decks. Translate the selected preset into concrete palette, typography, background strategy, and motif rules. This is separate from `visual_system`: `visual_system` describes the deck identity, while `style_system` records the executable style preset translation.
|
||||
- `typography_constraints`: deck-level limits for line count, text box density, and how to handle long text before XML generation.
|
||||
- `verification_plan`: explicit checks to perform after creation or major edits; include background consistency, text fit, visual focus, and asset rendering when relevant.
|
||||
- `slides`: ordered page plans.
|
||||
@@ -122,6 +137,14 @@ Each slide must include:
|
||||
- `text_density`: `low`, `medium`, or `high`.
|
||||
- `speaker_intent`: why the speaker needs this page and how it advances the story.
|
||||
|
||||
SVGlide SVG slides must also include:
|
||||
|
||||
- `visual_recipe`: the SVG-native page recipe, such as `path_flow`, `technical_texture`, or `fake_ui_dashboard`.
|
||||
- `visual_signature`: the page's distinctive SVG visual memory point compared with a normal XML/PPT template.
|
||||
- `svg_effects`: canonical effect names actually used or planned, such as `path`, `connector_flow`, `gradient`, `texture`, `chart_geometry`, or `image_overlay`.
|
||||
- `required_primitives` and `svg_primitives`: the planned SVGlide-safe primitives that must be present in the SVG source.
|
||||
- `xml_like_risk`, `content_density_contract`, `risk_flags`, and `source_policy`: quality and source-safety fields consumed by `svg_preflight.py --plan`.
|
||||
|
||||
## Layout Vocabulary
|
||||
|
||||
Use one of these `layout_type` values unless the user explicitly needs a custom structure:
|
||||
|
||||
542
skills/lark-slides/references/style-presets.json
Normal file
542
skills/lark-slides/references/style-presets.json
Normal file
@@ -0,0 +1,542 @@
|
||||
{
|
||||
"version": "2026-06-10",
|
||||
"source": "beautiful-feishu-whiteboard",
|
||||
"canvas": "960x540",
|
||||
"selection_rule": [
|
||||
"Choose intensity first: Restrained for quiet/formal decks, Balanced for most business or training decks, Bold for poster-like or high-energy decks.",
|
||||
"Use preset style tokens to shape palette, panel treatment, connector density, typography scale, and texture.",
|
||||
"Do not copy raw whiteboard nodes, raw coordinates, source prompts, source file paths, tool names, source tokens, or preset names into visible slide content."
|
||||
],
|
||||
"groups": {
|
||||
"Restrained": {"expected_count": 9, "use_when": "Serious, quiet, editorial, institutional, or text-first decks."},
|
||||
"Balanced": {"expected_count": 15, "use_when": "General business, technical, educational, and explanatory decks."},
|
||||
"Bold": {"expected_count": 11, "use_when": "Posters, showcases, events, playful explainers, and high-energy visual impact."}
|
||||
},
|
||||
"presets": [
|
||||
{
|
||||
"style_id": "avocado_press",
|
||||
"display_name": "Avocado Press",
|
||||
"group": "Restrained",
|
||||
"source_token": "TIBNwZ6fLhfPh1bZlAQuFRnFswW",
|
||||
"formality": "high",
|
||||
"vibe": ["editorial", "fresh", "structured"],
|
||||
"best_for": ["agenda.structured", "process.flow", "quote.insight"],
|
||||
"avoid_for": ["cover.hero"],
|
||||
"palette": {"background": "#FFFFFF", "text": "#1F2329", "muted": "#0055A4", "accent": "#DCF4A2", "support": ["#0055A4"]},
|
||||
"shape_language": {"panel_treatment": "editorial blocks with bright accent labels", "corner_radius": "low", "border_weight": "medium", "texture": "clean print-like structure"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 59, "source_text": 39, "source_shapes": 9, "source_connectors": 11}},
|
||||
"slide_translation": {"recommended_layouts": ["agenda", "process", "quote"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Keep text native and use explicit connector lines for process structure."},
|
||||
"quality_oracle": {"expected_style_signals": ["blue structural labels", "avocado accent", "editorial spacing"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 2}}
|
||||
},
|
||||
{
|
||||
"style_id": "grove",
|
||||
"display_name": "Grove",
|
||||
"group": "Restrained",
|
||||
"source_token": "IOCVwTYCYhhUj9bbkAwuncDTslf",
|
||||
"formality": "high",
|
||||
"vibe": ["institutional", "organic", "calm"],
|
||||
"best_for": ["architecture.layered", "section.divider", "process.flow"],
|
||||
"avoid_for": ["brand_system"],
|
||||
"palette": {"background": "#E8E4D6", "text": "#192B1B", "muted": "#D4CFBF", "accent": "#C8524A", "support": ["#DEDAD0"]},
|
||||
"shape_language": {"panel_treatment": "soft editorial panels with deep green hierarchy", "corner_radius": "low", "border_weight": "light", "texture": "warm paper bands"},
|
||||
"density": {"text_density": "medium", "label_density": "high", "connector_density": "low", "node_budget": {"source_nodes": 62, "source_text": 44, "source_shapes": 13, "source_connectors": 2}},
|
||||
"slide_translation": {"recommended_layouts": ["architecture", "section", "process"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use grouped green panels and sparse connectors rather than dense arrows."},
|
||||
"quality_oracle": {"expected_style_signals": ["deep green title mass", "muted paper background", "small warm accent"], "warning_thresholds": {"text_boxes_max": 26, "accent_colors_max": 2}}
|
||||
},
|
||||
{
|
||||
"style_id": "jade_lens",
|
||||
"display_name": "Jade Lens",
|
||||
"group": "Restrained",
|
||||
"source_token": "T0eswEvY1h6uSZbbt1FujZp0sZf",
|
||||
"formality": "high",
|
||||
"vibe": ["research", "clean", "knowledge"],
|
||||
"best_for": ["timeline.roadmap", "architecture.layered", "agenda.structured"],
|
||||
"avoid_for": ["cover.hero"],
|
||||
"palette": {"background": "#F5F1EE", "text": "#0E5A3C", "muted": "#2BA483", "accent": "#2CAE8C", "support": ["#FFFFFF"]},
|
||||
"shape_language": {"panel_treatment": "jade framed cards with layered labels", "corner_radius": "medium", "border_weight": "medium", "texture": "lens-like panels"},
|
||||
"density": {"text_density": "medium", "label_density": "high", "connector_density": "medium", "node_budget": {"source_nodes": 72, "source_text": 44, "source_shapes": 19, "source_connectors": 9}},
|
||||
"slide_translation": {"recommended_layouts": ["timeline", "architecture", "agenda"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Keep jade labels as native text; simplify nested frames when crowded."},
|
||||
"quality_oracle": {"expected_style_signals": ["jade green framing", "white content panels", "medium connector scaffolding"], "warning_thresholds": {"text_boxes_max": 28, "accent_colors_max": 2}}
|
||||
},
|
||||
{
|
||||
"style_id": "long_table",
|
||||
"display_name": "Long Table",
|
||||
"group": "Restrained",
|
||||
"source_token": "VrJhwVUTwhjU2zbBpI7uEjy2szg",
|
||||
"formality": "high",
|
||||
"vibe": ["procedural", "responsibility", "planning"],
|
||||
"best_for": ["table.visual-summary", "process.flow", "timeline.roadmap"],
|
||||
"avoid_for": ["cover.hero"],
|
||||
"palette": {"background": "#FAF1E2", "text": "#1F2329", "muted": "#F2E5CF", "accent": "#B53D2A", "support": ["#FFFFFF"]},
|
||||
"shape_language": {"panel_treatment": "long horizontal table bands with clear separators", "corner_radius": "low", "border_weight": "medium", "texture": "tabular rows"},
|
||||
"density": {"text_density": "high", "label_density": "high", "connector_density": "high", "node_budget": {"source_nodes": 69, "source_text": 40, "source_shapes": 9, "source_connectors": 17}},
|
||||
"slide_translation": {"recommended_layouts": ["table", "process", "timeline"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Prefer table/grid native shapes; connector count can be reduced if slide becomes cramped."},
|
||||
"quality_oracle": {"expected_style_signals": ["long horizontal rows", "red emphasis", "explicit flow connectors"], "warning_thresholds": {"text_boxes_max": 28, "accent_colors_max": 2}}
|
||||
},
|
||||
{
|
||||
"style_id": "macchiato",
|
||||
"display_name": "Macchiato",
|
||||
"group": "Restrained",
|
||||
"source_token": "Jhl9w3gZghgXzeb6WLwu44VXsMg",
|
||||
"formality": "high",
|
||||
"vibe": ["quiet", "editorial", "warm"],
|
||||
"best_for": ["quote.insight", "section.divider", "comparison.two-column"],
|
||||
"avoid_for": ["dashboard.kpi-grid"],
|
||||
"palette": {"background": "#EDE7DD", "text": "#25211B", "muted": "#9A917F", "accent": "#6E6558", "support": ["#FFFFFF"]},
|
||||
"shape_language": {"panel_treatment": "minimal editorial blocks with low contrast", "corner_radius": "low", "border_weight": "light", "texture": "coffee paper tone"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 48, "source_text": 37, "source_shapes": 5, "source_connectors": 6}},
|
||||
"slide_translation": {"recommended_layouts": ["quote", "section", "comparison"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use whitespace and typographic hierarchy instead of many decorative nodes."},
|
||||
"quality_oracle": {"expected_style_signals": ["low contrast warm neutrals", "large quiet text", "few panels"], "warning_thresholds": {"text_boxes_max": 20, "accent_colors_max": 1}}
|
||||
},
|
||||
{
|
||||
"style_id": "monochrome",
|
||||
"display_name": "Monochrome",
|
||||
"group": "Restrained",
|
||||
"source_token": "ApDnwnul9hlwg8b4Jl1uDweAs2c",
|
||||
"formality": "high",
|
||||
"vibe": ["serious", "technical", "minimal"],
|
||||
"best_for": ["architecture.layered", "quote.insight", "agenda.structured"],
|
||||
"avoid_for": ["brand_system"],
|
||||
"palette": {"background": "#FAFADF", "text": "#1A1A16", "muted": "#8A8A80", "accent": "#5E5E54", "support": ["#FFFFFF"]},
|
||||
"shape_language": {"panel_treatment": "monochrome rules and restrained blocks", "corner_radius": "low", "border_weight": "light", "texture": "minimal editorial grid"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "medium", "node_budget": {"source_nodes": 48, "source_text": 37, "source_shapes": 3, "source_connectors": 8}},
|
||||
"slide_translation": {"recommended_layouts": ["architecture", "quote", "agenda"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Rely on black/gray hierarchy and simple connectors; avoid fake color accents."},
|
||||
"quality_oracle": {"expected_style_signals": ["black text hierarchy", "single neutral accent", "minimal blocks"], "warning_thresholds": {"text_boxes_max": 22, "accent_colors_max": 1}}
|
||||
},
|
||||
{
|
||||
"style_id": "papier_bleu",
|
||||
"display_name": "Papier Bleu",
|
||||
"group": "Restrained",
|
||||
"source_token": "HWi5woaS8h1D4EbKutnulYWdsWc",
|
||||
"formality": "medium",
|
||||
"vibe": ["blueprint", "clear", "knowledge"],
|
||||
"best_for": ["process.flow", "timeline.roadmap", "image.story"],
|
||||
"avoid_for": ["brand_system"],
|
||||
"palette": {"background": "#FAF3EB", "text": "#1A3C8F", "muted": "#4FB8D8", "accent": "#72D0E9", "support": ["#FFFFFF"]},
|
||||
"shape_language": {"panel_treatment": "blue paper panels with airy blocks", "corner_radius": "medium", "border_weight": "medium", "texture": "light blueprint geometry"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 64, "source_text": 38, "source_shapes": 22, "source_connectors": 4}},
|
||||
"slide_translation": {"recommended_layouts": ["process", "timeline", "image"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use blue diagram cards and short labels; avoid overusing cyan fills."},
|
||||
"quality_oracle": {"expected_style_signals": ["blue structural panels", "cream paper base", "clear diagram labels"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 2}}
|
||||
},
|
||||
{
|
||||
"style_id": "reading_room",
|
||||
"display_name": "Reading Room",
|
||||
"group": "Restrained",
|
||||
"source_token": "Wx8Ow5ThFhDn5Lb1VFzu5bDEskD",
|
||||
"formality": "high",
|
||||
"vibe": ["paper", "training", "reading"],
|
||||
"best_for": ["quote.insight", "section.divider", "paper.explainer"],
|
||||
"avoid_for": ["fake_ui_dashboard"],
|
||||
"palette": {"background": "#F6EBD8", "text": "#0B0A09", "muted": "#F1DAB1", "accent": "#DE916A", "support": ["#D6C7CC"]},
|
||||
"shape_language": {"panel_treatment": "reading-card panels with warm paper blocks", "corner_radius": "low", "border_weight": "light", "texture": "bookish paper"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "medium", "node_budget": {"source_nodes": 54, "source_text": 38, "source_shapes": 6, "source_connectors": 7}},
|
||||
"slide_translation": {"recommended_layouts": ["quote", "section", "paper"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use warm panels and pull quotes; keep connectors secondary."},
|
||||
"quality_oracle": {"expected_style_signals": ["book paper background", "warm orange accent", "quiet reading hierarchy"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 2}}
|
||||
},
|
||||
{
|
||||
"style_id": "salmon_stamp",
|
||||
"display_name": "Salmon Stamp",
|
||||
"group": "Restrained",
|
||||
"source_token": "IITLwQQ7Vhj0lzbBmhvuvtDWs4c",
|
||||
"formality": "medium",
|
||||
"vibe": ["stamp", "training", "emphasis"],
|
||||
"best_for": ["section.divider", "kpi.big-number", "agenda.structured"],
|
||||
"avoid_for": ["technical_texture"],
|
||||
"palette": {"background": "#FFFFFF", "text": "#000000", "muted": "#F0AE9E", "accent": "#049550", "support": ["#F0AE9E"]},
|
||||
"shape_language": {"panel_treatment": "stamp-like salmon labels with green accents", "corner_radius": "low", "border_weight": "medium", "texture": "print stamp blocks"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 60, "source_text": 40, "source_shapes": 9, "source_connectors": 11}},
|
||||
"slide_translation": {"recommended_layouts": ["section", "kpi", "agenda"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Keep stamp labels editable; use green only for key emphasis."},
|
||||
"quality_oracle": {"expected_style_signals": ["salmon blocks", "black type", "green action accent"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 2}}
|
||||
},
|
||||
{
|
||||
"style_id": "apricot_arc",
|
||||
"display_name": "Apricot Arc",
|
||||
"group": "Balanced",
|
||||
"source_token": "JvQewyVpphPc2MbOD4xuiTD5sbd",
|
||||
"formality": "medium",
|
||||
"vibe": ["warm", "motion", "roadmap"],
|
||||
"best_for": ["timeline.roadmap", "process.flow", "cover.hero"],
|
||||
"avoid_for": ["monochrome.audit"],
|
||||
"palette": {"background": "#FFF8EE", "text": "#7A4A33", "muted": "#F9C2BD", "accent": "#C7561E", "support": ["#F69834"]},
|
||||
"shape_language": {"panel_treatment": "warm arcs and rounded progression panels", "corner_radius": "medium", "border_weight": "medium", "texture": "arc motion geometry"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 76, "source_text": 37, "source_shapes": 23, "source_connectors": 16}},
|
||||
"slide_translation": {"recommended_layouts": ["timeline", "process", "cover"], "svglide_primitives": ["path", "connector_flow", "geometric_shape"], "fallback_policy": "Use explicit arc paths and staged labels; simplify connectors if crowded."},
|
||||
"quality_oracle": {"expected_style_signals": ["apricot arcs", "warm motion path", "stage progression"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 3}}
|
||||
},
|
||||
{
|
||||
"style_id": "berry_pop",
|
||||
"display_name": "Berry Pop",
|
||||
"group": "Balanced",
|
||||
"source_token": "JAcFwmlcIh8NKNbJOzGupeTKscg",
|
||||
"formality": "medium",
|
||||
"vibe": ["business", "soft", "case"],
|
||||
"best_for": ["comparison.two-column", "image.story", "dashboard.kpi-grid"],
|
||||
"avoid_for": ["legal.audit"],
|
||||
"palette": {"background": "#EDF0FA", "text": "#6E1E3A", "muted": "#C7D2F0", "accent": "#9E2B50", "support": ["#9DB0E8"]},
|
||||
"shape_language": {"panel_treatment": "soft berry panels with cool support areas", "corner_radius": "medium", "border_weight": "light", "texture": "soft editorial contrast"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "medium", "node_budget": {"source_nodes": 65, "source_text": 40, "source_shapes": 13, "source_connectors": 12}},
|
||||
"slide_translation": {"recommended_layouts": ["comparison", "image", "dashboard"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use berry for key claims and blue support panels for evidence."},
|
||||
"quality_oracle": {"expected_style_signals": ["berry headline", "cool soft panels", "balanced contrast"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 3}}
|
||||
},
|
||||
{
|
||||
"style_id": "bold_poster",
|
||||
"display_name": "Bold Poster",
|
||||
"group": "Balanced",
|
||||
"source_token": "TOwewbtxrhmxNgbzko3udFBvsBd",
|
||||
"formality": "medium",
|
||||
"vibe": ["poster", "direct", "high-contrast"],
|
||||
"best_for": ["cover.hero", "quote.insight", "section.divider"],
|
||||
"avoid_for": ["dense.table"],
|
||||
"palette": {"background": "#F5F2EF", "text": "#1C1410", "muted": "#FFFFFF", "accent": "#D8000F", "support": ["#1C1410"]},
|
||||
"shape_language": {"panel_treatment": "poster blocks with strong red hits", "corner_radius": "low", "border_weight": "heavy", "texture": "flat poster"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 52, "source_text": 40, "source_shapes": 6, "source_connectors": 6}},
|
||||
"slide_translation": {"recommended_layouts": ["cover", "quote", "section"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use large type and one strong red visual anchor; avoid dense micro text."},
|
||||
"quality_oracle": {"expected_style_signals": ["red poster accent", "black mass", "large type"], "warning_thresholds": {"text_boxes_max": 20, "accent_colors_max": 2}}
|
||||
},
|
||||
{
|
||||
"style_id": "checker_bloom",
|
||||
"display_name": "Checker Bloom",
|
||||
"group": "Balanced",
|
||||
"source_token": "S5tEwSOiAhbuvObGnNkuCAUps8d",
|
||||
"formality": "medium",
|
||||
"vibe": ["grid", "capability", "modular"],
|
||||
"best_for": ["dashboard.kpi-grid", "icon_capability_map", "table.visual-summary"],
|
||||
"avoid_for": ["quote.insight"],
|
||||
"palette": {"background": "#E8F1DD", "text": "#151515", "muted": "#5E9E4A", "accent": "#2C6EE0", "support": ["#FFFFFF"]},
|
||||
"shape_language": {"panel_treatment": "checker grid cells with bloom accents", "corner_radius": "medium", "border_weight": "medium", "texture": "modular checker geometry"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "medium", "node_budget": {"source_nodes": 73, "source_text": 37, "source_shapes": 20, "source_connectors": 12}},
|
||||
"slide_translation": {"recommended_layouts": ["dashboard", "capability_map", "table"], "svglide_primitives": ["geometric_shape", "icon", "micro_chart"], "fallback_policy": "Build true grid modules; do not reduce to three generic cards."},
|
||||
"quality_oracle": {"expected_style_signals": ["checker grid", "blue-green accents", "capability modules"], "warning_thresholds": {"text_boxes_max": 26, "accent_colors_max": 3}}
|
||||
},
|
||||
{
|
||||
"style_id": "cobalt_bloom",
|
||||
"display_name": "Cobalt Bloom",
|
||||
"group": "Balanced",
|
||||
"source_token": "Ts2iwaXOuhOBkYbKD80uLjyCsje",
|
||||
"formality": "medium",
|
||||
"vibe": ["modern", "tech", "status"],
|
||||
"best_for": ["architecture.layered", "fake_ui_dashboard", "cover.hero"],
|
||||
"avoid_for": ["legal.audit"],
|
||||
"palette": {"background": "#F4EFE9", "text": "#171717", "muted": "#DDA8A2", "accent": "#4746C6", "support": ["#CE968F"]},
|
||||
"shape_language": {"panel_treatment": "modern cobalt panels with soft pink supports", "corner_radius": "medium", "border_weight": "medium", "texture": "tech editorial blocks"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 56, "source_text": 40, "source_shapes": 11, "source_connectors": 5}},
|
||||
"slide_translation": {"recommended_layouts": ["architecture", "dashboard", "cover"], "svglide_primitives": ["typography", "geometric_shape", "dashboard"], "fallback_policy": "Use cobalt as structural system color and pink as secondary highlight."},
|
||||
"quality_oracle": {"expected_style_signals": ["cobalt blocks", "modern soft support panels", "technical editorial tone"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 3}}
|
||||
},
|
||||
{
|
||||
"style_id": "coral",
|
||||
"display_name": "Coral",
|
||||
"group": "Balanced",
|
||||
"source_token": "Ez05w4JTahrMIjb6hjcuJRDpsOy",
|
||||
"formality": "medium",
|
||||
"vibe": ["report", "warm", "clear"],
|
||||
"best_for": ["agenda.structured", "comparison.two-column", "kpi.big-number"],
|
||||
"avoid_for": ["monochrome.audit"],
|
||||
"palette": {"background": "#F5F0E8", "text": "#1A1A1A", "muted": "#6B6B6B", "accent": "#E85D5D", "support": ["#D44A4A"]},
|
||||
"shape_language": {"panel_treatment": "coral emphasis cards with neutral body text", "corner_radius": "medium", "border_weight": "light", "texture": "warm report blocks"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 68, "source_text": 37, "source_shapes": 23, "source_connectors": 5}},
|
||||
"slide_translation": {"recommended_layouts": ["agenda", "comparison", "kpi"], "svglide_primitives": ["typography", "geometric_shape", "micro_chart"], "fallback_policy": "Use coral only for claims, values, or section anchors."},
|
||||
"quality_oracle": {"expected_style_signals": ["coral claim blocks", "neutral report structure", "clear emphasis hierarchy"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 2}}
|
||||
},
|
||||
{
|
||||
"style_id": "cut_bloom",
|
||||
"display_name": "Cut Bloom",
|
||||
"group": "Balanced",
|
||||
"source_token": "QHtIwZ4aeha6q8bDkfiuU7TCsgb",
|
||||
"formality": "medium",
|
||||
"vibe": ["geometric", "sectioned", "structured"],
|
||||
"best_for": ["geometric_composition", "architecture.layered", "section.divider"],
|
||||
"avoid_for": ["dense.table"],
|
||||
"palette": {"background": "#FFFFFF", "text": "#2E3566", "muted": "#535D9E", "accent": "#F0CB65", "support": ["#FFFFFF"]},
|
||||
"shape_language": {"panel_treatment": "cut color planes and strong section blocks", "corner_radius": "low", "border_weight": "medium", "texture": "cut-paper geometry"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 68, "source_text": 40, "source_shapes": 26, "source_connectors": 2}},
|
||||
"slide_translation": {"recommended_layouts": ["geometric", "architecture", "section"], "svglide_primitives": ["path", "geometric_shape", "typography"], "fallback_policy": "Translate angled blocks into safe path geometry, not polygons."},
|
||||
"quality_oracle": {"expected_style_signals": ["cut planes", "navy structure", "yellow highlight"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 3}}
|
||||
},
|
||||
{
|
||||
"style_id": "editorial_forest",
|
||||
"display_name": "Editorial Forest",
|
||||
"group": "Balanced",
|
||||
"source_token": "DeJQwotDFhKucHbVvdUufxepsAe",
|
||||
"formality": "high",
|
||||
"vibe": ["research", "editorial", "ecosystem"],
|
||||
"best_for": ["quote.insight", "comparison.two-column", "paper.explainer"],
|
||||
"avoid_for": ["brand_system"],
|
||||
"palette": {"background": "#EFE7D4", "text": "#1A1A17", "muted": "#243A21", "accent": "#E89CB1", "support": ["#2E4A2A"]},
|
||||
"shape_language": {"panel_treatment": "forest editorial blocks with pink accent", "corner_radius": "low", "border_weight": "light", "texture": "journal-like composition"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 51, "source_text": 39, "source_shapes": 6, "source_connectors": 6}},
|
||||
"slide_translation": {"recommended_layouts": ["quote", "comparison", "paper"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Let text hierarchy carry the page; use pink accent sparingly."},
|
||||
"quality_oracle": {"expected_style_signals": ["forest green editorial base", "pink accent", "quiet paper field"], "warning_thresholds": {"text_boxes_max": 22, "accent_colors_max": 2}}
|
||||
},
|
||||
{
|
||||
"style_id": "lime_slab",
|
||||
"display_name": "Lime Slab",
|
||||
"group": "Balanced",
|
||||
"source_token": "T3oLwlQLohw8G7b4qfJuFqw9syd",
|
||||
"formality": "medium",
|
||||
"vibe": ["energetic", "technical", "slab"],
|
||||
"best_for": ["cover.hero", "architecture.layered", "process.flow"],
|
||||
"avoid_for": ["quiet.executive"],
|
||||
"palette": {"background": "#FFFFF2", "text": "#0A0A05", "muted": "#2F2E25", "accent": "#EEFA79", "support": ["#FFFFFF"]},
|
||||
"shape_language": {"panel_treatment": "thick slab panels with lime highlight", "corner_radius": "low", "border_weight": "heavy", "texture": "high-energy slab geometry"},
|
||||
"density": {"text_density": "high", "label_density": "high", "connector_density": "medium", "node_budget": {"source_nodes": 82, "source_text": 43, "source_shapes": 28, "source_connectors": 11}},
|
||||
"slide_translation": {"recommended_layouts": ["cover", "architecture", "process"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Use lime as high-energy focal surface, but keep text boxes large."},
|
||||
"quality_oracle": {"expected_style_signals": ["lime slab title", "heavy black structure", "dense technical labels"], "warning_thresholds": {"text_boxes_max": 28, "accent_colors_max": 2}}
|
||||
},
|
||||
{
|
||||
"style_id": "linen_cut",
|
||||
"display_name": "Linen Cut",
|
||||
"group": "Balanced",
|
||||
"source_token": "WI3Yw4BakhfaoNbiEfRuCwq0slg",
|
||||
"formality": "medium",
|
||||
"vibe": ["business", "linen", "structured"],
|
||||
"best_for": ["agenda.structured", "table.visual-summary", "comparison.two-column"],
|
||||
"avoid_for": ["cover.hero"],
|
||||
"palette": {"background": "#E4D2C4", "text": "#1F1A14", "muted": "#044D99", "accent": "#F61B27", "support": ["#04B24F"]},
|
||||
"shape_language": {"panel_treatment": "linen panels with sharp color tabs", "corner_radius": "low", "border_weight": "medium", "texture": "woven warm base"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 54, "source_text": 37, "source_shapes": 14, "source_connectors": 3}},
|
||||
"slide_translation": {"recommended_layouts": ["agenda", "table", "comparison"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Keep red/green/blue as role colors, not decoration."},
|
||||
"quality_oracle": {"expected_style_signals": ["linen base", "sharp red/green/blue tabs", "business grid"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 3}}
|
||||
},
|
||||
{
|
||||
"style_id": "pin_paper",
|
||||
"display_name": "Pin & Paper",
|
||||
"group": "Balanced",
|
||||
"source_token": "NZ5bwYLs1hAat1bHo29uDdhSsJx",
|
||||
"formality": "medium",
|
||||
"vibe": ["workshop", "action", "paper"],
|
||||
"best_for": ["agenda.structured", "process.flow", "quote.insight"],
|
||||
"avoid_for": ["legal.audit"],
|
||||
"palette": {"background": "#FFFFFF", "text": "#1F2329", "muted": "#2A3C99", "accent": "#F1E84E", "support": ["#FFFFFF"]},
|
||||
"shape_language": {"panel_treatment": "pinned paper notes with blue rails", "corner_radius": "medium", "border_weight": "medium", "texture": "workshop paper"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 66, "source_text": 40, "source_shapes": 21, "source_connectors": 2}},
|
||||
"slide_translation": {"recommended_layouts": ["agenda", "process", "quote"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use pinned note panels and explicit action labels; avoid showing the preset name."},
|
||||
"quality_oracle": {"expected_style_signals": ["pinned paper cards", "blue rails", "yellow highlight"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 3}}
|
||||
},
|
||||
{
|
||||
"style_id": "raw_grid",
|
||||
"display_name": "Raw Grid",
|
||||
"group": "Balanced",
|
||||
"source_token": "Z6CbwF2oPhvXQ8biBRnufsyksqf",
|
||||
"formality": "medium",
|
||||
"vibe": ["technical", "dense", "grid"],
|
||||
"best_for": ["dashboard.kpi-grid", "table.visual-summary", "architecture.layered"],
|
||||
"avoid_for": ["cover.hero"],
|
||||
"palette": {"background": "#F5F5F5", "text": "#0A0A0A", "muted": "#333333", "accent": "#F2D4CF", "support": ["#FFFFFF", "#E5EDD6"]},
|
||||
"shape_language": {"panel_treatment": "grid panels with dense labels", "corner_radius": "low", "border_weight": "medium", "texture": "explicit grid geometry"},
|
||||
"density": {"text_density": "high", "label_density": "high", "connector_density": "low", "node_budget": {"source_nodes": 98, "source_text": 52, "source_shapes": 44, "source_connectors": 2}},
|
||||
"slide_translation": {"recommended_layouts": ["dashboard", "table", "architecture"], "svglide_primitives": ["geometric_shape", "typography", "texture", "annotation"], "fallback_policy": "Keep native text and basic shapes; fallback only for over-dense visual tables."},
|
||||
"quality_oracle": {"expected_style_signals": ["grid geometry", "dense labels", "muted panels"], "warning_thresholds": {"text_boxes_max": 28, "accent_colors_max": 2}}
|
||||
},
|
||||
{
|
||||
"style_id": "riptide_cobalt",
|
||||
"display_name": "Riptide Cobalt",
|
||||
"group": "Balanced",
|
||||
"source_token": "Qpyow1AnZhU763b3d51ur42csZd",
|
||||
"formality": "medium",
|
||||
"vibe": ["technology", "flow", "cobalt"],
|
||||
"best_for": ["path_flow", "architecture.layered", "fake_ui_dashboard"],
|
||||
"avoid_for": ["quiet.executive"],
|
||||
"palette": {"background": "#FDF0E0", "text": "#1A2240", "muted": "#2741C0", "accent": "#375DFE", "support": ["#FFFFFF"]},
|
||||
"shape_language": {"panel_treatment": "cobalt panels with flowing technical emphasis", "corner_radius": "medium", "border_weight": "medium", "texture": "blue flow geometry"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 69, "source_text": 41, "source_shapes": 26, "source_connectors": 2}},
|
||||
"slide_translation": {"recommended_layouts": ["path", "architecture", "dashboard"], "svglide_primitives": ["path", "geometric_shape", "dashboard"], "fallback_policy": "Use cobalt flow paths and layered blocks; do not overdo connectors."},
|
||||
"quality_oracle": {"expected_style_signals": ["cobalt flow", "cream contrast", "layered tech panels"], "warning_thresholds": {"text_boxes_max": 26, "accent_colors_max": 3}}
|
||||
},
|
||||
{
|
||||
"style_id": "soft_editorial",
|
||||
"display_name": "Soft Editorial",
|
||||
"group": "Balanced",
|
||||
"source_token": "T7A4w3ioHhgOjLbuFywuqZwbsUg",
|
||||
"formality": "medium",
|
||||
"vibe": ["soft", "editorial", "summary"],
|
||||
"best_for": ["quote.insight", "agenda.structured", "comparison.two-column"],
|
||||
"avoid_for": ["technical_texture"],
|
||||
"palette": {"background": "#ECE9DC", "text": "#1C1A17", "muted": "#E7C6AD", "accent": "#E2A8CE", "support": ["#C9DA4F"]},
|
||||
"shape_language": {"panel_treatment": "soft editorial fields with small text fragments", "corner_radius": "medium", "border_weight": "light", "texture": "gentle magazine layout"},
|
||||
"density": {"text_density": "medium", "label_density": "high", "connector_density": "low", "node_budget": {"source_nodes": 51, "source_text": 40, "source_shapes": 5, "source_connectors": 6}},
|
||||
"slide_translation": {"recommended_layouts": ["quote", "agenda", "comparison"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Split text into readable editorial fragments; keep labels concise."},
|
||||
"quality_oracle": {"expected_style_signals": ["soft editorial colors", "small text fragments", "gentle accent palette"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 3}}
|
||||
},
|
||||
{
|
||||
"style_id": "violet_marker",
|
||||
"display_name": "Violet Marker",
|
||||
"group": "Balanced",
|
||||
"source_token": "HISlwkosLhVgFVb8y3KucknAslb",
|
||||
"formality": "medium",
|
||||
"vibe": ["marker", "training", "concept"],
|
||||
"best_for": ["section.divider", "process.flow", "icon_capability_map"],
|
||||
"avoid_for": ["legal.audit"],
|
||||
"palette": {"background": "#FFFFFF", "text": "#000000", "muted": "#666463", "accent": "#C5A1FF", "support": ["#CFEE30"]},
|
||||
"shape_language": {"panel_treatment": "marker-like labels with violet and lime accents", "corner_radius": "medium", "border_weight": "medium", "texture": "hand-highlighted marker blocks"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 59, "source_text": 39, "source_shapes": 9, "source_connectors": 11}},
|
||||
"slide_translation": {"recommended_layouts": ["section", "process", "capability_map"], "svglide_primitives": ["typography", "geometric_shape", "connector_flow"], "fallback_policy": "Use violet/lime marker tags for concepts, not all text."},
|
||||
"quality_oracle": {"expected_style_signals": ["violet marker accent", "lime support", "concept labels"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 3}}
|
||||
},
|
||||
{
|
||||
"style_id": "blockframe",
|
||||
"display_name": "BlockFrame",
|
||||
"group": "Bold",
|
||||
"source_token": "Qu3Lwf4VRhyYzWbjI1xuBn5UsEc",
|
||||
"formality": "low",
|
||||
"vibe": ["showcase", "blocky", "colorful"],
|
||||
"best_for": ["cover.hero", "brand_system", "image.story"],
|
||||
"avoid_for": ["legal.audit"],
|
||||
"palette": {"background": "#FFFFFF", "text": "#000000", "muted": "#C0F7FE", "accent": "#F7CB46", "support": ["#FE90E8", "#99E885", "#FFDC8B"]},
|
||||
"shape_language": {"panel_treatment": "strong colored block frames", "corner_radius": "low", "border_weight": "heavy", "texture": "graphic frame system"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 75, "source_text": 41, "source_shapes": 31, "source_connectors": 2}},
|
||||
"slide_translation": {"recommended_layouts": ["cover", "brand", "image"], "svglide_primitives": ["geometric_shape", "typography", "brand_system"], "fallback_policy": "Use block frames as visual identity; keep content hierarchy simple."},
|
||||
"quality_oracle": {"expected_style_signals": ["multi-color frames", "black outlines", "bold block identity"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 5}}
|
||||
},
|
||||
{
|
||||
"style_id": "burst_panel",
|
||||
"display_name": "Burst Panel",
|
||||
"group": "Bold",
|
||||
"source_token": "IUVZwJZirhaIZQbxkfnuj2jPskh",
|
||||
"formality": "low",
|
||||
"vibe": ["poster", "burst", "dense"],
|
||||
"best_for": ["cover.hero", "dashboard.kpi-grid", "brand_system"],
|
||||
"avoid_for": ["quiet.executive"],
|
||||
"palette": {"background": "#FFFAF0", "text": "#1E1E1E", "muted": "#FBD65A", "accent": "#FFA76D", "support": ["#CFACE8", "#AAE4BA"]},
|
||||
"shape_language": {"panel_treatment": "bursting panels with many colored regions", "corner_radius": "medium", "border_weight": "medium", "texture": "poster panel collage"},
|
||||
"density": {"text_density": "high", "label_density": "high", "connector_density": "low", "node_budget": {"source_nodes": 89, "source_text": 50, "source_shapes": 37, "source_connectors": 2}},
|
||||
"slide_translation": {"recommended_layouts": ["cover", "dashboard", "brand"], "svglide_primitives": ["geometric_shape", "typography", "micro_chart"], "fallback_policy": "Preserve high-energy panels but cap text boxes to avoid crowding."},
|
||||
"quality_oracle": {"expected_style_signals": ["burst panels", "warm poster palette", "high information blocks"], "warning_thresholds": {"text_boxes_max": 30, "accent_colors_max": 5}}
|
||||
},
|
||||
{
|
||||
"style_id": "confetti_wedge",
|
||||
"display_name": "Confetti Wedge",
|
||||
"group": "Bold",
|
||||
"source_token": "YDyLwTCiHhKJ0NbuP89uHUV8sNh",
|
||||
"formality": "low",
|
||||
"vibe": ["playful", "light", "wedge"],
|
||||
"best_for": ["section.divider", "image.story", "quote.insight"],
|
||||
"avoid_for": ["dense.table"],
|
||||
"palette": {"background": "#F4F8FB", "text": "#000000", "muted": "#3A3C3E", "accent": "#62C0A5", "support": ["#F8BED4", "#65C8CD"]},
|
||||
"shape_language": {"panel_treatment": "confetti wedges and light motion shapes", "corner_radius": "medium", "border_weight": "light", "texture": "playful wedge accents"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 52, "source_text": 37, "source_shapes": 13, "source_connectors": 2}},
|
||||
"slide_translation": {"recommended_layouts": ["section", "image", "quote"], "svglide_primitives": ["path", "geometric_shape", "typography"], "fallback_policy": "Use a few wedge paths as memory points; avoid confetti over text."},
|
||||
"quality_oracle": {"expected_style_signals": ["wedge accents", "light playful palette", "simple panel structure"], "warning_thresholds": {"text_boxes_max": 22, "accent_colors_max": 4}}
|
||||
},
|
||||
{
|
||||
"style_id": "court_press",
|
||||
"display_name": "Court Press",
|
||||
"group": "Bold",
|
||||
"source_token": "JH9owJl4sh0PeIbUm9SujYCTsTc",
|
||||
"formality": "low",
|
||||
"vibe": ["sports", "team", "operations"],
|
||||
"best_for": ["process.flow", "timeline.roadmap", "metaphor_loop"],
|
||||
"avoid_for": ["legal.audit"],
|
||||
"palette": {"background": "#F2EFE6", "text": "#2F4224", "muted": "#66914C", "accent": "#DA9EB7", "support": ["#FFFFFF"]},
|
||||
"shape_language": {"panel_treatment": "court-like lanes and team panels", "corner_radius": "medium", "border_weight": "medium", "texture": "court lane geometry"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 61, "source_text": 37, "source_shapes": 11, "source_connectors": 13}},
|
||||
"slide_translation": {"recommended_layouts": ["process", "timeline", "loop"], "svglide_primitives": ["connector_flow", "path", "geometric_shape"], "fallback_policy": "Use lanes and flows to encode motion; keep sports metaphor subtle."},
|
||||
"quality_oracle": {"expected_style_signals": ["court lanes", "green team palette", "pink accent"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 3}}
|
||||
},
|
||||
{
|
||||
"style_id": "crayon_stack",
|
||||
"display_name": "Crayon Stack",
|
||||
"group": "Bold",
|
||||
"source_token": "JjKIwig1vhnXq7bbeCfutuKFseh",
|
||||
"formality": "low",
|
||||
"vibe": ["creative", "playful", "stacked"],
|
||||
"best_for": ["cover.hero", "icon_capability_map", "brand_system"],
|
||||
"avoid_for": ["executive.audit"],
|
||||
"palette": {"background": "#FFFFFF", "text": "#222222", "muted": "#8A2E43", "accent": "#FF472B", "support": ["#D3FE79", "#FBB8FD", "#2A8F6D", "#7E90FC"]},
|
||||
"shape_language": {"panel_treatment": "stacked crayon color bars", "corner_radius": "medium", "border_weight": "heavy", "texture": "handmade colorful blocks"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 51, "source_text": 37, "source_shapes": 8, "source_connectors": 3}},
|
||||
"slide_translation": {"recommended_layouts": ["cover", "capability_map", "brand"], "svglide_primitives": ["geometric_shape", "icon", "typography"], "fallback_policy": "Use colorful stacks for categories; avoid making every label a different color."},
|
||||
"quality_oracle": {"expected_style_signals": ["crayon color stack", "playful strong accents", "bold category blocks"], "warning_thresholds": {"text_boxes_max": 22, "accent_colors_max": 6}}
|
||||
},
|
||||
{
|
||||
"style_id": "grove_block",
|
||||
"display_name": "Grove Block",
|
||||
"group": "Bold",
|
||||
"source_token": "Mq2XwYKDEhbnRmbIxAfuZGCQsOb",
|
||||
"formality": "medium",
|
||||
"vibe": ["ecosystem", "bold", "result"],
|
||||
"best_for": ["kpi.big-number", "metaphor_loop", "process.flow"],
|
||||
"avoid_for": ["quiet.editorial"],
|
||||
"palette": {"background": "#FCF6F1", "text": "#01623F", "muted": "#008248", "accent": "#FCC715", "support": ["#F7F1EC", "#F6BDDA"]},
|
||||
"shape_language": {"panel_treatment": "bold green blocks with yellow emphasis", "corner_radius": "medium", "border_weight": "medium", "texture": "organic block system"},
|
||||
"density": {"text_density": "medium", "label_density": "high", "connector_density": "medium", "node_budget": {"source_nodes": 73, "source_text": 44, "source_shapes": 20, "source_connectors": 9}},
|
||||
"slide_translation": {"recommended_layouts": ["kpi", "loop", "process"], "svglide_primitives": ["geometric_shape", "connector_flow", "typography"], "fallback_policy": "Use green blocks for system stages and yellow for outcome emphasis."},
|
||||
"quality_oracle": {"expected_style_signals": ["bold green blocks", "yellow result accent", "ecosystem grouping"], "warning_thresholds": {"text_boxes_max": 28, "accent_colors_max": 3}}
|
||||
},
|
||||
{
|
||||
"style_id": "mint_brut",
|
||||
"display_name": "Mint Brut",
|
||||
"group": "Bold",
|
||||
"source_token": "BMe4wBmwlhGfNCbooCvuShTfs4v",
|
||||
"formality": "low",
|
||||
"vibe": ["brutalist", "technical", "mint"],
|
||||
"best_for": ["architecture.layered", "process.flow", "cover.hero"],
|
||||
"avoid_for": ["quiet.training"],
|
||||
"palette": {"background": "#FFFBF3", "text": "#000000", "muted": "#D0FDE4", "accent": "#70F0A8", "support": ["#F888C8", "#F0DE4E"]},
|
||||
"shape_language": {"panel_treatment": "brutalist mint panels with loud highlights", "corner_radius": "low", "border_weight": "heavy", "texture": "brutalist blocks"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 75, "source_text": 38, "source_shapes": 23, "source_connectors": 11}},
|
||||
"slide_translation": {"recommended_layouts": ["architecture", "process", "cover"], "svglide_primitives": ["geometric_shape", "connector_flow", "typography"], "fallback_policy": "Use heavy frames and mint surfaces; keep text large and sparse."},
|
||||
"quality_oracle": {"expected_style_signals": ["mint brutalist panels", "black outlines", "pink/yellow highlights"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 4}}
|
||||
},
|
||||
{
|
||||
"style_id": "neo_grid_bold",
|
||||
"display_name": "Neo-Grid Bold",
|
||||
"group": "Bold",
|
||||
"source_token": "WwstwfAj1hIZXhbDwdDuwdpDsBh",
|
||||
"formality": "medium",
|
||||
"vibe": ["neo-grid", "technical", "high-contrast"],
|
||||
"best_for": ["technical_texture", "architecture.layered", "fake_ui_dashboard"],
|
||||
"avoid_for": ["paper.explainer"],
|
||||
"palette": {"background": "#F5F4EF", "text": "#0A0A0A", "muted": "#8A8A85", "accent": "#E6FF3D", "support": ["#0A0A0A"]},
|
||||
"shape_language": {"panel_treatment": "black grid rails with neon highlight", "corner_radius": "low", "border_weight": "heavy", "texture": "explicit neo grid"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "high", "node_budget": {"source_nodes": 69, "source_text": 40, "source_shapes": 12, "source_connectors": 17}},
|
||||
"slide_translation": {"recommended_layouts": ["technical_texture", "architecture", "dashboard"], "svglide_primitives": ["texture", "connector_flow", "geometric_shape"], "fallback_policy": "Use explicit grid lines/dots; do not rely on SVG pattern."},
|
||||
"quality_oracle": {"expected_style_signals": ["black grid structure", "neon lime focus", "technical rails"], "warning_thresholds": {"text_boxes_max": 25, "accent_colors_max": 2}}
|
||||
},
|
||||
{
|
||||
"style_id": "riso_brut",
|
||||
"display_name": "Riso Brut",
|
||||
"group": "Bold",
|
||||
"source_token": "Mztnwj2ouhot6VbtbwMuWN4SsRb",
|
||||
"formality": "low",
|
||||
"vibe": ["riso", "poster", "multicolor"],
|
||||
"best_for": ["brand_system", "cover.hero", "image.story"],
|
||||
"avoid_for": ["legal.audit"],
|
||||
"palette": {"background": "#EFE9D9", "text": "#0F0F0F", "muted": "#136636", "accent": "#F5C518", "support": ["#1F8A4C", "#E85A1F", "#F06CA8", "#D14E8B"]},
|
||||
"shape_language": {"panel_treatment": "riso-print blocks and overlapping color plates", "corner_radius": "low", "border_weight": "medium", "texture": "riso poster plates"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 71, "source_text": 38, "source_shapes": 29, "source_connectors": 4}},
|
||||
"slide_translation": {"recommended_layouts": ["brand", "cover", "image"], "svglide_primitives": ["geometric_shape", "typography", "image_overlay"], "fallback_policy": "Use flat color plates; avoid opacity-heavy overlaps unless preflight accepts them."},
|
||||
"quality_oracle": {"expected_style_signals": ["riso color plates", "poster energy", "cream paper"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 6}}
|
||||
},
|
||||
{
|
||||
"style_id": "specimen_bold",
|
||||
"display_name": "Specimen Bold",
|
||||
"group": "Bold",
|
||||
"source_token": "BNFmwKX6OhWRfbb3SX4uRoHesD6",
|
||||
"formality": "medium",
|
||||
"vibe": ["specimen", "concept", "modular"],
|
||||
"best_for": ["icon_capability_map", "comparison.two-column", "section.divider"],
|
||||
"avoid_for": ["paper.explainer"],
|
||||
"palette": {"background": "#F3F3F3", "text": "#2E302E", "muted": "#30A1E5", "accent": "#3EC06A", "support": ["#FFFFFF", "#FBEF4A"]},
|
||||
"shape_language": {"panel_treatment": "specimen cards with multiple focus markers", "corner_radius": "medium", "border_weight": "medium", "texture": "sample-card layout"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "medium", "node_budget": {"source_nodes": 57, "source_text": 37, "source_shapes": 8, "source_connectors": 12}},
|
||||
"slide_translation": {"recommended_layouts": ["capability_map", "comparison", "section"], "svglide_primitives": ["icon", "geometric_shape", "connector_flow"], "fallback_policy": "Use specimen cards for concepts and blue/green markers for relationships."},
|
||||
"quality_oracle": {"expected_style_signals": ["specimen cards", "blue/green markers", "modular concept blocks"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 4}}
|
||||
},
|
||||
{
|
||||
"style_id": "stencil_tablet",
|
||||
"display_name": "Stencil & Tablet",
|
||||
"group": "Bold",
|
||||
"source_token": "J1BKw4SxhhC3kTbD77auLJ5UsRc",
|
||||
"formality": "medium",
|
||||
"vibe": ["stencil", "method", "action"],
|
||||
"best_for": ["quote.insight", "process.flow", "agenda.structured"],
|
||||
"avoid_for": ["quiet.editorial"],
|
||||
"palette": {"background": "#F4EFE0", "text": "#0A0A0A", "muted": "#E2DCC9", "accent": "#D8A93B", "support": ["#EE7A2E", "#C73B7A", "#2D7E73"]},
|
||||
"shape_language": {"panel_treatment": "stencil panels and tablet-like blocks", "corner_radius": "low", "border_weight": "medium", "texture": "stencil print panels"},
|
||||
"density": {"text_density": "medium", "label_density": "medium", "connector_density": "low", "node_budget": {"source_nodes": 51, "source_text": 39, "source_shapes": 7, "source_connectors": 5}},
|
||||
"slide_translation": {"recommended_layouts": ["quote", "process", "agenda"], "svglide_primitives": ["typography", "geometric_shape", "annotation"], "fallback_policy": "Use stencil panels for method/action emphasis; keep decorative colors role-bound."},
|
||||
"quality_oracle": {"expected_style_signals": ["stencil panels", "tablet blocks", "gold/orange/magenta accents"], "warning_thresholds": {"text_boxes_max": 24, "accent_colors_max": 5}}
|
||||
}
|
||||
]
|
||||
}
|
||||
89
skills/lark-slides/references/style-presets.md
Normal file
89
skills/lark-slides/references/style-presets.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# SVGlide Style Presets
|
||||
|
||||
`style-presets.json` is the runtime source of truth for the 35 `beautiful-feishu-whiteboard` style presets. This Markdown file is only a human-readable guide.
|
||||
|
||||
## Boundary
|
||||
|
||||
Style presets are not slide templates. They do not replace `visual_recipe`, `renderer_id`, or the page semantic plan.
|
||||
|
||||
- `visual_recipe`: explains the page structure and SVG-native value, such as `path_flow`, `technical_texture`, or `fake_ui_dashboard`.
|
||||
- `style_preset`: selects the visual language, palette, panel treatment, connector density, label density, and texture.
|
||||
- `style_system`: records how the selected preset is translated into the current deck.
|
||||
|
||||
Do not copy raw whiteboard nodes, raw coordinates, source prompts, source file paths, tool names, source tokens, or preset names into visible slide content.
|
||||
|
||||
## Required Plan Fields
|
||||
|
||||
For `output_mode="svglide-svg"`, the deck plan must include:
|
||||
|
||||
```json
|
||||
{
|
||||
"style_preset": "raw_grid",
|
||||
"style_selection_reason": "raw_grid fits technical training pages that need dense but readable visual structure",
|
||||
"style_system": {
|
||||
"palette": {
|
||||
"background": "#F5F5F5",
|
||||
"text": "#0A0A0A",
|
||||
"accent": "#F2D4CF"
|
||||
},
|
||||
"typography": "strong title, readable native text labels",
|
||||
"background_strategy": "muted grid panels with one stable background family",
|
||||
"motif": "dense grid panels with restrained accent labels"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each slide must also include:
|
||||
|
||||
```json
|
||||
{
|
||||
"visual_recipe": "path_flow",
|
||||
"visual_signature": "curved route path with explicit stage annotations",
|
||||
"svg_effects": ["path", "connector_flow", "typography"],
|
||||
"svg_primitives": ["path", "annotation"],
|
||||
"required_primitives": ["path", "annotation"]
|
||||
}
|
||||
```
|
||||
|
||||
Use `visual_plan` as a nested container when useful. `svg_preflight.py` accepts both the nested shape and the existing flat fields; nested `visual_plan` wins when both are present.
|
||||
|
||||
## Selection Rule
|
||||
|
||||
1. Choose intensity first.
|
||||
- `Restrained`: serious, quiet, institutional, text-first decks.
|
||||
- `Balanced`: default for business, technical, training, and explanatory decks.
|
||||
- `Bold`: posters, showcases, event material, playful explainers, high-energy pages.
|
||||
2. Match the user's tone and topic.
|
||||
3. Keep the semantic plan stable. Switching from `raw_grid` to `reading_room` should change visual treatment, not invent new facts or rearrange the story.
|
||||
4. Pick page-level overrides only for cover, section divider, or poster-like moments. Most slides should inherit the deck-level `style_preset`.
|
||||
|
||||
## SVGlide-Safe Translation
|
||||
|
||||
Translate style into supported SVG primitives:
|
||||
|
||||
- Palette -> explicit `fill`, `stroke`, and text colors.
|
||||
- Panel treatment -> `rect`, `path`, and grouped layout boxes.
|
||||
- Connector density -> explicit `line` or supported `path`; do not rely on `marker` or key-path `stroke-dasharray`.
|
||||
- Texture -> repeated native `line`, `circle`, or `rect`; do not rely on `<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.
|
||||
|
||||
## Quality Gates
|
||||
|
||||
Before calling `slides +create-svg`, run:
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/svg_preflight.py \
|
||||
--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.
|
||||
105
skills/lark-slides/references/svg-aesthetic-review.md
Normal file
105
skills/lark-slides/references/svg-aesthetic-review.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# 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 问题;这份清单负责需要人工或截图判断的渲染后视觉质量问题。
|
||||
|
||||
## 必须执行的 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 垂直间距 |
|
||||
| 装饰线或色带压迫标题 | 把线移到标题区上方,或下移标题,保留呼吸感 |
|
||||
| 主体内容超出 `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 | 小幅打磨项或残余风险 | 记录下来,有时间再修 |
|
||||
|
||||
默认映射:
|
||||
|
||||
- P0:preflight 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 保持一致?
|
||||
|
||||
## 修复优先级
|
||||
|
||||
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
|
||||
```
|
||||
169
skills/lark-slides/references/svg-protocol.md
Normal file
169
skills/lark-slides/references/svg-protocol.md
Normal file
@@ -0,0 +1,169 @@
|
||||
# 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"`。
|
||||
- `<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` |
|
||||
|
||||
这些属性即使取值为 `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`。
|
||||
- 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 复核。
|
||||
|
||||
## 不支持
|
||||
|
||||
- 不要把普通 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`。
|
||||
- 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 只 warning;preview 中可用 `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` 传入预上传 token,CLI 也会按同样规则替换和注入。
|
||||
|
||||
`assets.json` 格式:
|
||||
|
||||
```json
|
||||
{
|
||||
"@./image.png": "boxcn...",
|
||||
"./other.png": "boxcn..."
|
||||
}
|
||||
```
|
||||
103
skills/lark-slides/references/svg-visual-recipes.md
Normal file
103
skills/lark-slides/references/svg-visual-recipes.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# SVGlide 视觉 Recipe
|
||||
|
||||
这份文档是 `slides +create-svg` 的短版可执行 recipe 指南。
|
||||
它把研究目录提炼成生成阶段可放入 agent 上下文的规则。
|
||||
更完整的研究资料仍保留在 CLI skill 外:
|
||||
`/Users/bytedance/bd-projects/workspaces/SVGlide/svglide-visual-guidance/visual_recipe_catalog.md`.
|
||||
|
||||
## 边界
|
||||
|
||||
- `visual_recipe` 定义页面结构,以及这一页为什么值得用 SVG。
|
||||
- `style_preset` 定义视觉语言、配色、纹理、密度和 motif。
|
||||
- `renderer_id` 定义具体几何渲染器。
|
||||
|
||||
不要用 `style_preset` 替代 `visual_recipe`。不要在 `slide_plan.json`
|
||||
里发明新的 recipe id。
|
||||
|
||||
## 硬默认值
|
||||
|
||||
- 画布:`width="960" height="540" viewBox="0 0 960 540"`。
|
||||
- 安全区:关键文本、标签、图表、卡片、节点和图例保持在
|
||||
`x=48..912` and `y=40..500`.
|
||||
- 网格:使用稳定的 12 栏或 8px 步进布局,避免临时手调坐标。
|
||||
- 文本:中文正文每行控制在约 28 个字;英文正文每行约 62 个字符。
|
||||
- 装饰:装饰线、水印、纹理和背景几何不能抢夺标题/焦点内容的注意力,也不能贴住它们。
|
||||
- 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` 中使用这些 CLI 支持的 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 名称,例如 `cover.hero`,不是有效运行时 id。
|
||||
写入 plan 前必须映射到上面的 underscore id。
|
||||
@@ -44,6 +44,56 @@ 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 \
|
||||
--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。
|
||||
- `style_preset` 必须存在于 `references/style-presets.json`。
|
||||
- `style_selection_reason` 必须说明为什么这个 preset 适合当前 deck。
|
||||
- `style_system` 必须包含 palette、typography、background strategy 和 motif。
|
||||
- 每页必须包含 `visual_recipe`、`visual_signature`、`svg_effects`、`required_primitives`、`svg_primitives`、`xml_like_risk`、`content_density_contract`、`risk_flags`、`source_policy`。
|
||||
- declared `svg_effects` 和 `required_primitives` 必须能在对应 SVG source 中命中。
|
||||
- 可见 slide 文本不得泄漏 preset 名称、source token、prompt、tool name 或本地文件路径。
|
||||
|
||||
常见 code 的处理方向:
|
||||
|
||||
| code | 含义 | 处理方式 |
|
||||
|------|------|----------|
|
||||
| `plan_style_preset_unknown` | plan 引用了不存在的 35 preset | 从 `style-presets.json` 选择有效 `style_id` |
|
||||
| `plan_missing_visual_signature` | 页面没有声明 SVG 视觉记忆点 | 写清这页相对普通 PPT/XML 模板的独特视觉结构 |
|
||||
| `plan_missing_svg_effects` | 页面没有声明 SVG 表达能力 | 声明真实会绘制的 `path`、`connector_flow`、`gradient`、`texture`、`chart_geometry` 等 |
|
||||
| `plan_svg_effect_not_found` | plan 声明的 effect 没在 SVG source 中出现 | 修改 SVG source,或删除不真实的 effect 声明 |
|
||||
| `plan_style_preset_visible_leak` | 可见文本泄漏 preset 名/source token | 仅在 plan metadata 中保留 preset 信息,画面只写用户主题内容 |
|
||||
|
||||
## SVGlide Aesthetic Preview Review
|
||||
|
||||
`svg_preflight.py` 通过后,走 `slides +create-svg` 前还必须做本地预览审查。读取 [svg-aesthetic-review.md](svg-aesthetic-review.md),检查 rendered preview,而不是只看 plan 字段或静态 XML。
|
||||
|
||||
通过标准:
|
||||
|
||||
- 所有页面都检查过,不只检查封面。
|
||||
- 无标题、正文、badge、装饰线、图片框、图表标签的明显重叠或裁切。
|
||||
- root 和主要内容遵循 `960 x 540` 画布和 safe area。
|
||||
- 每页有清晰 `visual_focal_point`,视觉焦点对应 `visual_signature`。
|
||||
- 页面不是普通卡片/bullet 页伪装成 SVG;应能看出 path、texture、chart geometry、connector flow、image overlay、icon system、dashboard frame 或其他 SVG-native 结构。
|
||||
- 多页没有重复出现同一个布局错误;如果有,必须修生成规则并重新生成相关页面。
|
||||
- 用户可见交付 deck 的审美目标默认不低于 `75/100`;低于 `65/100` 应重新生成或显式降级为草稿。
|
||||
- 验证记录包含 `preview_path`、`visual_score`、`threshold`、`issue_ids`、`action`。`action=create_live` 才能继续调用 live API;`action=repair_and_rerun` 必须先修 source SVG / plan 并重新跑 preflight。
|
||||
|
||||
这一步和 preflight 分工如下:
|
||||
|
||||
- `svg_preflight.py`: 负责协议、plan、枚举、必填字段、bbox、primitive 命中和确定性错误。
|
||||
- `svg-aesthetic-review.md`: 负责截图/预览视角的层级、节奏、压迫感、重复问题、可读性和 SVG 视觉优势。
|
||||
|
||||
## Page Count And Structure
|
||||
|
||||
- 实际页数必须等于用户要求或 `slide_plan.json` 的页数。
|
||||
|
||||
@@ -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.
|
||||
|
||||
2296
skills/lark-slides/scripts/svg_preflight.py
Normal file
2296
skills/lark-slides/scripts/svg_preflight.py
Normal file
File diff suppressed because it is too large
Load Diff
1064
skills/lark-slides/scripts/svg_preflight_test.py
Normal file
1064
skills/lark-slides/scripts/svg_preflight_test.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 |
|
||||
|
||||
159
tests/cli_e2e/slides/slides_create_svg_dryrun_test.go
Normal file
159
tests/cli_e2e/slides/slides_create_svg_dryrun_test.go
Normal 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"`)
|
||||
}
|
||||
115
tests/cli_e2e/slides/slides_create_svg_live_test.go
Normal file
115
tests/cli_e2e/slides/slides_create_svg_live_test.go
Normal 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], ".,;:)")
|
||||
}
|
||||
Reference in New Issue
Block a user