Compare commits

..

6 Commits

Author SHA1 Message Date
songtianyi.theo
5c6459966c fix(slides): align SVGlide native role classifier 2026-06-09 18:26:52 +08:00
songtianyi.theo
37986331f4 feat(slides): add SVGlide SVG fallback classifier 2026-06-09 17:03:20 +08:00
songtianyi.theo
3bbf823ce9 docs: harden svglide workflow guidance 2026-06-08 02:37:38 +08:00
songtianyi.theo
e43a57ce14 docs: strengthen svglide generation guidance 2026-06-08 00:42:25 +08:00
songtianyi.theo
edf7ad81dd docs: add svglide deck density planning 2026-06-05 15:20:24 +08:00
songtianyi.theo
d98ef05dc7 feat: add svglide create-svg shortcut 2026-06-05 14:12:09 +08:00
32 changed files with 4328 additions and 99 deletions

View File

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

View File

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

View File

@@ -0,0 +1,189 @@
// 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"))
filePaths := runtime.StrArray("file")
svgs, err := readSVGFiles(runtime, filePaths)
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())
}
classified, err := classifySVGlideSVGPages(filePaths, svgs)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
rewriteResult, uploadPaths, err := dryRunRewriteClassifiedSVGPages(classified, assets)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
pages := rewriteResult.Pages
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"))
filePaths := runtime.StrArray("file")
svgs, err := readSVGFiles(runtime, filePaths)
if err != nil {
return err
}
assets, err := parseSVGAssets(runtime, runtime.Str("assets"))
if err != nil {
return err
}
classified, err := classifySVGlideSVGPages(filePaths, svgs)
if err != nil {
return err
}
if hasFallbackPages(classified) {
if err := svgFallbackRasterizer.CheckAvailable(ctx); err != nil {
return err
}
}
renderedFallbacks, err := renderSVGFallbackPages(ctx, classified, svgFallbackRasterizer)
if err != nil {
return err
}
defer cleanupRenderedSVGFallbacks(renderedFallbacks)
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
}
rewriteResult, err := rewriteClassifiedSVGPages(runtime, presentationID, classified, assets, renderedFallbacks)
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, rewriteResult.ImagesUploaded)
}
if rewriteResult.ImagesUploaded > 0 {
result["images_uploaded"] = rewriteResult.ImagesUploaded
}
if rewriteResult.FallbackPages > 0 {
result["fallback_pages"] = rewriteResult.FallbackPages
}
pages := rewriteResult.Pages
slideURL := fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide",
validate.EncodePathSegment(presentationID),
)
var slideIDs []string
for i, page := range pages {
content, err := injectSVGTransportAssetMetadata(page.Content, page.Tokens)
if err != nil {
return output.Errorf(output.ExitValidation, "validation",
"page %d/%d failed before API call: %v (presentation %s was created; %d slide(s) added; slide_ids=%s)",
i+1, len(pages), err, presentationID, len(slideIDs), strings.Join(slideIDs, ","))
}
slideData, err := runtime.CallAPI(
"POST",
slideURL,
map[string]interface{}{"revision_id": -1},
buildCreateSVGBody(content),
)
if err != nil {
return output.Errorf(output.ExitAPI, "api_error",
"page %d/%d failed: %v%s (presentation %s was created; %d slide(s) added; slide_ids=%s)",
i+1, len(pages), err, formatSVGlideErrorSuffix(err), presentationID, len(slideIDs), strings.Join(slideIDs, ","))
}
if sid := common.GetString(slideData, "slide_id"); sid != "" {
slideIDs = append(slideIDs, sid)
}
if latest := common.GetFloat(slideData, "revision_id"); latest > 0 {
result["revision_id"] = int(latest)
}
}
result["slide_ids"] = slideIDs
result["slides_added"] = len(slideIDs)
fillPresentationResult(runtime, presentationID, result)
runtime.Out(result, nil)
return nil
},
}

View File

@@ -0,0 +1,649 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"bytes"
"context"
"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 TestSlidesCreateSVGFallbackRendersUploadsAndAddsImageOnlySVG(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"><text x="80" y="120">render me</text></svg>`
if err := os.WriteFile("fallback.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write fallback.svg: %v", err)
}
fake := &fakeSVGFallbackRasterizer{pngPath: "fallback.png", pngBytes: []byte("png-bytes")}
restore := setTestSVGFallbackRasterizer(fake)
defer restore()
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_fallback", "revision_id": 1}},
})
uploadStub := &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_fallback"}},
}
reg.Register(uploadStub)
slideStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_fallback/slide",
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"slide_id": "slide_fallback", "revision_id": 2}},
}
reg.Register(slideStub)
registerBatchQueryStub(reg, "pres_fallback", "https://x.feishu.cn/slides/pres_fallback")
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "fallback.svg",
"--title", "fallback",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(fake.calls) != 1 || fake.calls[0] != "fallback.svg" {
t.Fatalf("rasterizer calls = %v, want [fallback.svg]", fake.calls)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["fallback_pages"] != float64(1) {
t.Fatalf("fallback_pages = %v, want 1", data["fallback_pages"])
}
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{
`slide:contract-version="svglide-authoring-contract/v1"`,
`<image slide:role="image" href="boxcn_fallback" x="0" y="0" width="1280" height="720" preserveAspectRatio="none"/>`,
`<metadata data-svglide-assets="true"><img src="boxcn_fallback" /></metadata>`,
} {
if !strings.Contains(content, want) {
t.Fatalf("fallback slide content missing %s: %s", want, content)
}
}
if strings.Contains(content, "<text") {
t.Fatalf("fallback slide content should not contain original text node: %s", content)
}
}
func TestSlidesCreateSVGRejectsUnsafeBeforePresentationCreate(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"><script>alert(1)</script></svg>`
if err := os.WriteFile("unsafe.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write unsafe.svg: %v", err)
}
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "unsafe.svg",
"--title", "unsafe",
"--as", "user",
})
if err == nil {
t.Fatal("expected preflight reject")
}
for _, want := range []string{"disallowed_script", "unsafe.svg"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("err = %v, want %q", err, want)
}
}
}
func TestSlidesCreateSVGRendererUnavailableBeforePresentationCreate(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"><text x="80" y="120">needs fallback</text></svg>`
if err := os.WriteFile("fallback.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write fallback.svg: %v", err)
}
fake := &fakeSVGFallbackRasterizer{
availableErr: newSVGlideDiagnosticsError("renderer unavailable", []SVGlideDiagnostic{{
Code: svgDiagRendererUnavailable,
Severity: svgDiagSeverityError,
Path: "fallback.svg",
Message: "renderer missing",
}}),
}
restore := setTestSVGFallbackRasterizer(fake)
defer restore()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "fallback.svg",
"--title", "renderer unavailable",
"--as", "user",
})
if err == nil {
t.Fatal("expected renderer unavailable error")
}
if !strings.Contains(err.Error(), svgDiagRendererUnavailable) {
t.Fatalf("err = %v, want renderer_unavailable", err)
}
if len(fake.calls) != 0 {
t.Fatalf("rasterizer should not render when availability check fails, calls=%v", fake.calls)
}
if fake.checkCalls != 1 {
t.Fatalf("renderer availability checks = %d, want 1", fake.checkCalls)
}
}
func TestSlidesCreateSVGRasterFailureBeforePresentationCreate(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"><text x="80" y="120">needs fallback</text></svg>`
if err := os.WriteFile("fallback.svg", []byte(svg), 0o644); err != nil {
t.Fatalf("write fallback.svg: %v", err)
}
fake := &fakeSVGFallbackRasterizer{
renderErr: newSVGlideDiagnosticsError("render failed", []SVGlideDiagnostic{{
Code: svgDiagRendererFailed,
Severity: svgDiagSeverityError,
Path: "fallback.svg",
Message: "render failed",
}}),
}
restore := setTestSVGFallbackRasterizer(fake)
defer restore()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateSVGShortcut(t, f, stdout, []string{
"+create-svg",
"--file", "fallback.svg",
"--title", "render failure",
"--as", "user",
})
if err == nil {
t.Fatal("expected raster failure error")
}
if !strings.Contains(err.Error(), svgDiagRendererFailed) {
t.Fatalf("err = %v, want renderer_failed", err)
}
if len(fake.calls) != 1 {
t.Fatalf("rasterizer calls = %v, want one render attempt", fake.calls)
}
}
type fakeSVGFallbackRasterizer struct {
availableErr error
renderErr error
pngPath string
pngBytes []byte
checkCalls int
calls []string
}
func (f *fakeSVGFallbackRasterizer) CheckAvailable(context.Context) error {
f.checkCalls++
return f.availableErr
}
func (f *fakeSVGFallbackRasterizer) Rasterize(_ context.Context, svgPath string) (string, int64, error) {
f.calls = append(f.calls, svgPath)
if f.renderErr != nil {
return "", 0, f.renderErr
}
if f.pngPath == "" {
f.pngPath = "fallback.png"
}
if len(f.pngBytes) == 0 {
f.pngBytes = []byte("png")
}
if err := os.WriteFile(f.pngPath, f.pngBytes, 0o644); err != nil {
return "", 0, err
}
return f.pngPath, int64(len(f.pngBytes)), nil
}
func setTestSVGFallbackRasterizer(r svgRasterizer) func() {
old := svgFallbackRasterizer
svgFallbackRasterizer = r
return func() {
svgFallbackRasterizer = old
}
}
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)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,513 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
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 TestClassifySVGlideSVGPageRoutes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
svg string
wantMode svgClassifyMode
wantCode string
}{
{
name: "native supported shape",
svg: `<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="0" y="0" width="100" height="60"/></svg>`,
wantMode: svgClassifyNative,
},
{
name: "native supported server line role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><line slide:role="line" x1="0" y1="0" x2="100" y2="60" stroke="#112233"/></svg>`,
wantMode: svgClassifyNative,
},
{
name: "native supported server text role",
svg: `<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="text" x="0" y="0" width="300" height="80"><p xmlns="http://www.w3.org/1999/xhtml">SVGlide</p></foreignObject></svg>`,
wantMode: svgClassifyNative,
},
{
name: "marked svg text still falls back",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><text slide:role="text" x="20" y="40">render me</text></svg>`,
wantMode: svgClassifyFallback,
wantCode: svgDiagNativeUnsupported,
},
{
name: "wrong contract native rejects",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" slide:contract-version="svglide-authoring-contract/v0" viewBox="0 0 1280 720"><rect slide:role="shape" x="0" y="0" width="100" height="60"/></svg>`,
wantMode: svgClassifyReject,
wantCode: svgDiagContractVersion,
},
{
name: "wrong contract server text role rejects",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" slide:contract-version="svglide-authoring-contract/v0" viewBox="0 0 1280 720"><foreignObject slide:role="text" x="0" y="0" width="300" height="80"><p xmlns="http://www.w3.org/1999/xhtml">SVGlide</p></foreignObject></svg>`,
wantMode: svgClassifyReject,
wantCode: svgDiagContractVersion,
},
{
name: "unsupported but renderable text falls back",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><text x="20" y="40">render me</text></svg>`,
wantMode: svgClassifyFallback,
wantCode: svgDiagNativeUnsupported,
},
{
name: "wrong contract fallback-only svg still falls back",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" slide:contract-version="svglide-authoring-contract/v0" viewBox="0 0 1280 720"><text x="20" y="40">render me</text></svg>`,
wantMode: svgClassifyFallback,
wantCode: svgDiagNativeUnsupported,
},
{
name: "table defaults to fallback",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><foreignObject x="20" y="40" width="400" height="240"><table xmlns="http://www.w3.org/1999/xhtml"><tr><td>a</td></tr></table></foreignObject></svg>`,
wantMode: svgClassifyFallback,
wantCode: svgDiagNativeUnsupported,
},
{
name: "script rejects before create",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><script>alert(1)</script></svg>`,
wantMode: svgClassifyReject,
wantCode: svgDiagDisallowedScript,
},
{
name: "external href rejects before create",
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 href="https://example.com/a.png" x="0" y="0" width="10" height="10"/></svg>`,
wantMode: svgClassifyReject,
wantCode: svgDiagExternalReference,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := classifySVGlideSVGPage(tt.svg, "page.svg", 0)
if got.Mode != tt.wantMode {
t.Fatalf("mode = %s, want %s; diagnostics=%v", got.Mode, tt.wantMode, got.Diagnostics)
}
if tt.wantCode == "" {
return
}
if len(got.Diagnostics) == 0 || got.Diagnostics[0].Code != tt.wantCode {
t.Fatalf("diagnostics = %v, want first code %s", got.Diagnostics, tt.wantCode)
}
})
}
}
func TestBuildSVGFallbackImageOnlyPage(t *testing.T) {
t.Parallel()
source := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide" viewBox="0 0 1280 720"><text x="20" y="40">fallback</text></svg>`
got, err := buildSVGFallbackImageOnlyPage(source, "boxcn_full_page")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, want := range []string{
`xmlns:slide="https://slides.bytedance.com/ns"`,
`slide:role="slide"`,
`slide:contract-version="svglide-authoring-contract/v1"`,
`viewBox="0 0 1280 720"`,
`<image slide:role="image" href="boxcn_full_page" x="0" y="0" width="1280" height="720" preserveAspectRatio="none"/>`,
} {
if !strings.Contains(got, want) {
t.Fatalf("image-only SVG missing %s: %s", want, got)
}
}
if err := validateSVGlideSVG(got, "fallback.svg"); err != nil {
t.Fatalf("image-only SVG should be native-valid: %v", err)
}
}
func TestEnsureSVGlideContractRootAttrsInjectsMissingVersion(t *testing.T) {
t.Parallel()
source := `<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="0" y="0" width="100" height="60"/></svg>`
got, err := ensureSVGlideContractRootAttrs(source)
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: %s", got)
}
if strings.Contains(got, `slide:contract-version="svglide-authoring-contract/v1" slide:contract-version`) {
t.Fatalf("contract version duplicated: %s", got)
}
}
func TestCommandSVGRasterizerUnavailableDiagnostic(t *testing.T) {
t.Parallel()
r := commandSVGRasterizer{
command: "missing-svglide-renderer",
lookPath: func(string) (string, error) {
return "", os.ErrNotExist
},
}
err := r.CheckAvailable(context.Background())
if err == nil {
t.Fatal("expected renderer unavailable error")
}
diags := svglideDiagnosticsFromError(err)
if len(diags) != 1 || diags[0].Code != svgDiagRendererUnavailable {
t.Fatalf("diagnostics = %v, want renderer_unavailable", diags)
}
}
func TestCommandSVGRasterizerArgvAndOutputSize(t *testing.T) {
dir := t.TempDir()
script := filepath.Join(dir, "fake-resvg")
argvFile := filepath.Join(dir, "argv.txt")
if err := os.WriteFile(script, []byte("#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$ARGV_FILE\"\nprintf png > \"$2\"\n"), 0o755); err != nil {
t.Fatalf("write fake renderer: %v", err)
}
in := filepath.Join(dir, "page.svg")
if err := os.WriteFile(in, []byte(`<svg/>`), 0o644); err != nil {
t.Fatalf("write svg: %v", err)
}
r := commandSVGRasterizer{
command: script,
timeout: time.Second,
maxOutputSize: 20,
env: []string{"ARGV_FILE=" + argvFile},
}
out, size, err := r.Rasterize(context.Background(), in)
if err != nil {
t.Fatalf("unexpected rasterize error: %v", err)
}
if size != int64(len("png")) {
t.Fatalf("size = %d, want %d", size, len("png"))
}
if _, err := os.Stat(out); err != nil {
t.Fatalf("output file missing: %v", err)
}
argv, err := os.ReadFile(argvFile)
if err != nil {
t.Fatalf("read argv: %v", err)
}
lines := strings.Split(strings.TrimSpace(string(argv)), "\n")
if len(lines) != 2 || lines[0] != in || lines[1] != out {
t.Fatalf("argv = %q, want input and output path", string(argv))
}
r.maxOutputSize = 2
_, _, err = r.Rasterize(context.Background(), in)
if err == nil {
t.Fatal("expected output-size validation error")
}
diags := svglideDiagnosticsFromError(err)
if len(diags) == 0 || diags[0].Code != svgDiagRasterOutputTooLarge {
t.Fatalf("diagnostics = %v, want raster_output_too_large", diags)
}
}
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 server text foreignObject",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><foreignObject slide:role="text" x="0" y="0" width="200" height="80"><p xmlns="http://www.w3.org/1999/xhtml">hello</p></foreignObject></svg>`,
},
{
name: "supported server line role",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><line slide:role="line" x1="0" y1="0" x2="100" y2="60"/></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", "image", "line", or "text"`,
},
{
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", "image", "line", or "text"`,
},
{
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", "image", "line", or "text"`,
},
{
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: "line role must be line tag",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="line" x="0" y="0" width="100" height="60"/></svg>`,
wantErr: `<rect slide:role="line"> is not supported`,
},
{
name: "text role must be foreignObject tag",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><rect slide:role="text" x="0" y="0" width="100" height="60"/></svg>`,
wantErr: `<rect slide:role="text"> is not supported`,
},
{
name: "svg text role is not native yet",
svg: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:slide="https://slides.bytedance.com/ns" slide:role="slide"><text slide:role="text" x="0" y="20">later</text></svg>`,
wantErr: `<text slide:role="text"> is not supported`,
},
{
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(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 TestExtractSVGlideErrorJSON(t *testing.T) {
t.Parallel()
err := errors.New(`api error: SVGLIDE_ERROR_JSON:{"type":"svg_validation_error","page_index":0,"tag_name":"foreignObject","hint":"Use supported elements"}`)
got := extractSVGlideErrorJSON(err)
if got["type"] != "svg_validation_error" {
t.Fatalf("type = %v", got["type"])
}
if got["tag_name"] != "foreignObject" {
t.Fatalf("tag_name = %v", got["tag_name"])
}
suffix := formatSVGlideErrorSuffix(err)
for _, want := range []string{"svglide_error=", "svg_validation_error", "foreignObject"} {
if !strings.Contains(suffix, want) {
t.Fatalf("suffix = %q, want %q", suffix, want)
}
}
}

View File

@@ -18,10 +18,8 @@ metadata:
Shortcut 是对常用操作的高级封装(`lark-cli {{service}} +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 | Reference |
|----------|------|-----------|
| Shortcut | 说明 |
|----------|------|
{{shortcut_rows}}
{{/shortcuts}}
{{#actions}}

View File

@@ -96,8 +96,6 @@ lark-cli auth login --domain apps
Shortcut 是对常用操作的高级封装(`lark-cli apps +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-apps-create.md) | 创建妙搭应用name / description / icon-url |

View File

@@ -70,8 +70,6 @@ Calendar (日历)
Shortcut 是对常用操作的高级封装(`lark-cli calendar +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+agenda`](references/lark-calendar-agenda.md) | 查看日程安排(默认今天) |

View File

@@ -14,8 +14,6 @@ metadata:
**user 身份和 bot 身份是两条完全独立的路径**。先确定当前身份,再按下表选命令:
**CRITICAL — 执行任何命令之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| 想做什么 | user 身份 | bot 身份 |
|---|---|---|
| 按姓名 / 邮箱搜员工拿 open_id | [`+search-user`](references/lark-contact-search-user.md) | 不支持 |

View File

@@ -59,8 +59,6 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |

View File

@@ -260,8 +260,6 @@ lark-cli drive permission.members create \
Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [`+search`](references/lark-drive-search.md) | Search Lark docs, Wiki, and spreadsheet files with flat filter flags. Natural-language-friendly: `--edited-since`, `--mine`, `--doc-types`, etc. |

View File

@@ -70,8 +70,6 @@ Item types for feed-layer flags:
Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager |

View File

@@ -464,8 +464,6 @@ lark-cli mail user_mailbox.folders create \
Shortcut 是对常用操作的高级封装(`lark-cli mail +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+message`](references/lark-mail-message.md) | Use when reading full content for a single email by message ID. Returns normalized body content plus attachments metadata, including inline images. |

View File

@@ -41,8 +41,6 @@ metadata:
Shortcut 是对常用操作的高级封装(`lark-cli markdown +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-markdown-create.md) | Create a Markdown file in Drive |

View File

@@ -111,8 +111,6 @@ Minutes (妙记) ← minute_token 标识
Shortcut 是对常用操作的高级封装(`lark-cli minutes +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
| -------------------------------------------------- | --------------------------------------------------------------- |
| [`+search`](references/lark-minutes-search.md) | Search minutes by keyword, owners, participants, and time range |

View File

@@ -16,8 +16,6 @@ metadata:
Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|--------------------------------------------------------------|--------------------------|
| [`+cycle-list`](references/lark-okr-cycle-list.md) | 获取特定用户的 OKR 周期列表,可以按时间筛选 |

View File

@@ -123,7 +123,6 @@ lark-cli 命令执行后如果检测到新版本JSON 输出中会包含 `_
## 安全规则
- **文件路径只接受相对路径**`--file`、`--output`、`--output-dir`、`@file` 等路径参数只接受 cwd 下的相对路径,传绝对路径会报 `unsafe file path`。数据输入(`@file`、大 JSON优先用 stdin 传入,避免路径和转义问题。
- **禁止输出密钥**appSecret、accessToken到终端明文。
- **写入/删除操作前必须确认用户意图**。
- 用 `--dry-run` 预览危险请求。

View File

@@ -179,8 +179,6 @@ lark-cli sheets +write --url "URL" --sheet-id "sheetId" --range "C6" \
Shortcut 是对常用操作的高级封装(`lark-cli sheets +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
### Spreadsheet Management
对应参考文档:[spreadsheet-management](references/lark-sheets-spreadsheet-management.md)

View File

@@ -15,6 +15,7 @@ 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` |
| 大幅改写页面 | 先回读现有 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` |
@@ -24,15 +25,19 @@ metadata:
**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 SVGroot `<svg>` 声明 `xmlns:slide` 且 `slide:role="slide"`;可渲染 SVG 元素必须用 `slide:role="shape"` 或 `slide:role="image"` 表达;`g` / 嵌套 `svg` 可作为容器,但容器内实际渲染元素仍必须各自声明 role。CLI 只读取文件、上传/替换图片占位符、注入 transport metadata 和调用现有 `/slide` 路由,不会把普通 SVG 自动补齐成协议 SVG**
**CRITICAL — 高质量 SVG deck 生成时MUST 同时读取 [lark-slides-create-svg.md](references/lark-slides-create-svg.md):复用现有 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json` 作为设计状态,先做 deck-level density plan再定义布局盒给 `foreignObject` 文本留足安全高度,默认必须使用真实图片资产(本地 `@./path` 或 file token相邻页面要显著换版式调用 API 前必须跑本地 preflight优先使用 [`scripts/svg_preflight.py`](scripts/svg_preflight.py)live 创建后必须 readback 校验。这些是生成技巧,不替代 [svg-protocol.md](references/svg-protocol.md) 的硬协议约束。**
**CRITICAL — 新建演示文稿或大幅改写页面时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)**
**CRITICAL — 创建前自检或失败排障时MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
@@ -77,7 +82,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)
- 编辑:[`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 +104,7 @@ lark-cli auth login --domain slides
- **背景一致性**:先确定全 deck 的背景策略,默认保持同一明暗基调和底色体系;只有分节、转场或强调页才有意改变背景,并必须通过相同主色、纹理、边栏或 motif 让变化看起来属于同一套设计。无论深浅,都要保证正文、图标和线条对比充足。
- **统一 motif**:选择一个可复用视觉母题贯穿全文,例如粗侧边栏、圆形图标底、半出血图片区、编号节点、卡片左上角色块或大号数字。不要每页换一套装饰语言。
每页至少要有一个视觉元素:图片、图标、图表、表格、流程、对比结构、大号数字、示意图或由 shape 组成的抽象视觉。文本框本身不算主视觉。
每页至少要有一个视觉元素:图片、图标、图表、表格、流程、对比结构、大号数字、示意图或由 shape 组成的抽象视觉。文本框本身不算主视觉。展示型、宣传型、产品型和案例型 deck 不能全程纯矢量,必须包含真实图片资产作为封面、半出血主视觉、案例场景、产品截图或材质背景。
可优先考虑这些页面形态:
@@ -123,7 +128,8 @@ lark-cli auth login --domain slides
- 不要所有页面复用同一种标题 + 三 bullets 版式。
- 不要用低对比文字或低对比图标,例如浅灰字压在浅色背景上。
- 不要让装饰线穿过文字,或让页脚、来源、编号挤压主体内容。
- 不要把素材缺失表现为空白图片框;必须按 `fallback_if_missing` 生成 XML-native 视觉
- 不要使用版权状态不明的图片、logo、截图或素材图片必须来自用户提供、公司/项目自有、明确可商用授权图库,或授权条件清晰的 AI 生成资产,并在产物说明或素材清单中记录来源、授权/许可类型、原始 URL 和是否需要署名
- 不要把素材缺失表现为空白图片框;必须先尝试获取或生成可用图片资产。只有用户明确要求纯矢量、网络/权限不可用,或主题确实不适合图片时,才按 `fallback_if_missing` 生成 XML-native 视觉,并在结果中说明。
- 不要留下模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。
### 创建方式选择
@@ -132,6 +138,7 @@ lark-cli auth login --domain slides
|------|----------|
| 简单 XML1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 |
| 复杂 XML多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多 | **两步创建**:先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide create` 逐页添加 |
| AI 生成 SVGlide SVG希望减少 shell XML 转义、按文件逐页创建) | `slides +create-svg --file page1.svg --file page2.svg --title "<标题>"` |
| 已有 PPT 继续追加或插入页面 | 使用 `xml_presentation.slide create`,必要时配合 `before_slide_id` |
> [!WARNING]
@@ -160,10 +167,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 → 创建
- 逐页消费 plankey_message 定主结论layout_type 定几何visual_focus 定主视觉text_density 定文本量
- 缺少真实素材时必须用 `fallback_if_missing` 生成 XML-native 兜底视觉;不要留空
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
- XML 路径按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行SVG 路径按 lark-slides-create-svg.md 和 svg-protocol.md 执行,产物是 `.svg` 文件而不是 Slides XML仍复用同一个 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
Step 4: 审查 & 交付
- 创建完成后,必须用 xml_presentations.get 读取全文 XML并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
@@ -256,11 +263,10 @@ Slides (演示文稿)
Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| 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/>`,不改变页序 |
@@ -274,19 +280,20 @@ lark-cli slides <resource> <method> [flags] # 调用 API
## 核心规则
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
2. **创建流程**:简单短 XML1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide.create` 逐页添加
2. **创建流程**:简单短 XML1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT再用 `xml_presentation.slide.create` 逐页添加AI SVG 路径使用 `slides +create-svg`,不要把 SVG 塞进 `--slides`
3. **`<slide>` 直接子元素只有 `<style>``<data>``<note>`**:文本和图形必须放在 `<data>`
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
5. **保存关键 ID**:后续操作需要 `xml_presentation_id``slide_id``revision_id`
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
7. **编辑已有页面优先块级替换**:修改单个 shape/img 用 `+replace-slide``block_replace` / `block_insert`),不要整页重建;只有需要替换整页结构时才用 `slide.delete` + `slide.create`
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides``@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**slides upload API 不支持分片上传)。
8. **图片只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**XML 路径使用 `<img src="...">`SVG 路径使用 `<image slide:role="image" href="...">`。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传`+create --slides` / `+create-svg``@./path` 占位符自动上传 → 拿 `file_token` 写进图片引用」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src` / `href`。**图片最大 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` |
@@ -295,4 +302,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 时不要删除。

View File

@@ -0,0 +1,450 @@
# 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},
"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 --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。
### 生成前强约束
以下规则来自实际 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: `foreignObject` 文本样式使用显式 CSS`font-size``font-weight``font-family``color``line-height``text-align`。不要用 `font:` shorthand 表达关键字号和加粗。
- 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。
- SHOULD: 对高风险页面使用更保守的留白:标题与图表标签至少相隔 24px曲线端点标签不要压在标题/图例区域,卡片内文字与边框至少留 10-14px。
- SHOULD: 把每页的 `safe``titleBox``visualBox``textBox` 等布局盒保存为可检查数据,便于自动计算越界和重叠。
本地 preflight 必须在 `slides +create-svg` 前执行,失败即停:
- `python3 skills/lark-slides/scripts/svg_preflight.py --input page-*.svg` 通过;如果脚本不可用,再退回 `xmllint --noout page-*.svg` 加人工检查。
- root 是 `width="960" height="540" viewBox="0 0 960 540"`
- root / leaf `slide:role` 完整,所有 leaf 有几何必填属性。
- 禁止 `font:` shorthand、http(s) / data URL 图片、未下载的远程图片、空图片框。
- 禁止 unsupported path command`path d` 只含 `M/L/H/V/C/Q/Z`
- 非背景元素不得越界;主体元素应在 safe area 内。
- 文本框做 bbox overlap 近似检查,尤其是目录、痛点、竞品表、案例图表和总结页。
- 图片资产文件存在、大小合理、授权来源清单完整。
创建顺序:
```text
generate deck plan -> generate assets -> generate SVG files
-> local preflight -> 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 real images are needed, where they will be used, copyright/license source, and fallback if unavailable",
"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 冒充“配图”。展示型、宣传型、产品型、品牌型和案例型 deck 至少包含 3 处图片使用,其中至少 1 页使用全幅或半出血图片主视觉。
- MUST: 高密度页必须有承载信息的视觉结构,例如矩阵、流程、地图、时间线、标注图、案例卡或手绘微图表,不能只有装饰图形。
- 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。为了服务端转换到 SXSD/XML 后保留样式,字号、加粗、颜色、行距和对齐必须写成独立属性;不要把关键样式藏在 `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。
- 服务端支持 `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 总结矩阵页。
- 数据仪表页、流程页、对比页或案例页。
相邻页面至少改变一个主结构维度:主视觉位置、网格列数、图片用法、文本密度或阅读方向。
### 图片使用
默认必须规划和使用图片资产,并规避版权风险。图片必须来自用户提供、公司/项目自有、明确可商用授权图库,或授权条件清晰的 AI 生成资产不要使用版权状态不明的图片、logo、截图、新闻配图、竞品官网图或搜索引擎随手抓取的素材。正确流程是先下载或生成到本地再写成本地占位符
```xml
<image slide:role="image" href="@./assets/hero.jpg" x="0" y="0" width="960" height="540" />
```
图片不只用于局部卡片背景,也可以作为整页背景、半出血主视觉、材质纹理、案例示例、产品截图、数据仪表截图或图鉴封面。作为整页背景时,必须叠加半透明遮罩或暗角,保证标题和正文对比度。
图片数量与用法建议:
- MUST: 在 `asset_strategy` 或产物 README 中记录图片来源、授权/许可类型、下载 URL 或生成方式;无法确认授权时不得使用。
- MUST: 5 页以上 deck 至少使用 1 张真实图片8 页以上 deck 至少使用 2 张;宣传/产品/品牌/案例型 deck 至少使用 3 张。
- MUST: 封面优先使用图片或图片+抽象图形混合主视觉,不要只用网格、光效和几何背景。
- MUST: 案例页优先使用行业场景图、产品截图、仪表盘截图或真实质感背景,并叠加数据 callout。
- SHOULD: 同一 deck 中混用全幅背景、半出血图片、卡片图、纹理/材质背景和标注型截图,避免所有图片都只是小卡片背景。
- MUST NOT: 保留空图片框、破图、http(s) 外链或 data URL。素材不可用时要重新获取/生成,或在最终说明中明确为什么退回矢量。
优先使用这些来源,但每张图仍必须检查并记录具体页面上的授权信息:
| Source | 适合用途 | 规则 |
|--------|----------|------|
| Unsplash | 高质量摄影、封面背景、场景图 | 可商用图库;记录图片页 URL 和 license |
| Pexels | 商务、科技、生活类配图 | 可商用图库;记录图片页 URL 和 license |
| Pixabay | 图片、插画、视频、音频 | 可商用图库;避开人物/品牌/商标误导 |
| Openverse | CC / Public Domain 搜索 | 每张图 license 不同;按单图要求署名 |
| Wikimedia Commons | 百科、历史、技术、公共领域素材 | 每张图 license 不同;常见需要署名 |
| The Met Open Access | 艺术品、历史图像、文化视觉 | 仅使用 Open Access / CC0 条目 |
| Smithsonian Open Access | 博物馆、科学、历史、2D/3D 资产 | 仅使用 Open Access / CC0 条目 |
| NASA Image and Video Library | 太空、科技、地球、航天视觉 | 避开 NASA 标识商业背书、人物肖像和第三方权利 |
素材清单建议字段:
```json
{
"local_path": "./assets/hero.jpg",
"source": "Unsplash",
"source_url": "https://...",
"license": "Unsplash License",
"commercial_use": true,
"attribution_required": false,
"notes": "No recognizable trademark or misleading endorsement"
}
```
### 信息密度与图鉴感
短 note 不要占一个很宽胶囊。优先写成“编号/标签 + 主句 + 微解释/数值”:
```text
03 GRID ENERGY 86% | storage demand peaks before grid balancing
```
内容页可以用三种方式提高密度,不要把高密度等同于堆文字:
- `text-dense`: 多解释、多证据、多注释,适合背景分析和概念讲解。
- `chart-dense`: SVG shape 手绘矩阵、流程、时间线、微柱状、雷达、散点、标尺;不要默认依赖 Slides 原生 chart也不要把外部图表截图当成唯一方案。
- `visual-dense`: 高级视觉图案或图片上叠加标注层、数据 callout、局部标签、对比线和图例。
视觉区要补足可读细节,避免只有装饰符号:
- 局部标注、刻度、坐标轴、图例。
- 行业标签、材料纹理、指标卡。
- 路线节点、连接线、层级分区。
- 小型表格、雷达/柱状/散点等微图表。
### 生成后检查
生成脚本或人工复核必须检查:
- 是否已执行本地 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 或案例时,是否有来源;没有来源时是否改为定性或假设表达。
- 图片是否已变成本地 `@./path` 或 file token不能保留 http(s) / data URL。
验证记录建议写回 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json``readback_verification` 字段,并在最终回复中简述:
```text
验证记录:
- PreflightN/N SVG 通过 root/role/geometry/path/image/bbox 检查。
- Dry-run已确认 create presentation + N 次 /slide。
- Readback实际页数 N / 预期 N未发现空白页、破图或缺失 closing slide。
- 版式:检查 safe area、文本重叠、越界和相邻页版式变化。
- 资产:图片均为本地 @path 自动上传或 file token无 http(s)/data URL。
```
## 错误处理
任一页失败时,错误会包含:
- `xml_presentation_id`
- 失败页序号
- 已成功页数
- 已创建的 `slide_ids`
如果服务端 detail 带有 `SVGLIDE_ERROR_JSON:` markerCLI 会提取并在错误中展示 `svglide_error`,用于定位 `type``page_index``tag_name``element_id``role``hint`
失败后不要假设没有创建任何资源。先把恢复状态写回 plan 的 `recovery` 字段:
```json
{
"xml_presentation_id": "slides...",
"failed_page": 3,
"failed_svg_file": ".lark-slides/plan/<deck-id>/pages/page-003.svg",
"successful_slide_ids": ["abc", "def"],
"svglide_error": {"type": "svg_validation_error", "tag_name": "foreignObject"},
"next_action": "fix source SVG and rerun preflight before retry"
}
```
恢复顺序:
1. 本地 preflight 已失败:修对应 SVG 文件,不要调用 live API。
2. live 添加页失败且带 `svglide_error`:按 `type` / `tag_name` / `hint` 收敛 SVG 子集,例如降级复杂 filter、path、CSS 或文本结构。
3. plain XML 在同一路由成功但 SVG 失败:优先确认目标 server lane 是否部署了 SVGlide parser不要盲目重写整套 deck。
4. 已创建 presentation 或部分页面时,默认保留现场并回读确认;是否删除空 presentation 必须单独由用户确认。
### 编辑已创建的 SVG deck
SVG deck 后续编辑走双轨,不承诺 source SVG id 能稳定映射到 readback XML block id
| 修改类型 | 推荐路径 | 说明 |
|----------|----------|------|
| 小改标题、文本、图片或坐标 | `xml_presentation.slide.get` 读回 XML -> 找当前 block_id -> `slides +replace-slide` | 使用转换后的 XML 做块级编辑,页序和 slide_id 不变 |
| 大幅换版式、重画图表、调整视觉系统 | 修改 source SVG -> 重新 preflight -> 重新创建或替换目标页 | 保持 SVG 的视觉表达优势,避免在转换后 XML 上手搓复杂 SVG 结构 |
| 无法定位 block_id 或映射不可信 | 回 source SVG 修改 | 不生成 `edit-map.json`,除非服务端或转换结果能证明 source id 可稳定保留 |
小改前必须重新 `slide.get` 拿最新 block id 和 revision大改后必须更新同一个 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,保持 plan、SVG 文件、创建结果和验证记录一致。

View File

@@ -0,0 +1,114 @@
# SVGlide SVG Protocol
最小模板:
```xml
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:slide="https://slides.bytedance.com/ns"
slide:role="slide"
width="960"
height="540"
viewBox="0 0 960 540"
>
<rect slide:role="shape" x="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"`
- 可渲染元素必须有对应 `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` 和基础文字样式。
文本样式应使用 parser 友好的显式 CSS 属性,例如 `font-size``font-weight``font-family``color``line-height``text-align``letter-spacing`。不要依赖 `font:` shorthand、复杂 flex 布局或浏览器默认样式来表达关键字号、加粗和行距;这些在转换到 SXSD/XML 时可能降级为默认样式。
## 不支持
- 不要把普通 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`
- 不要用 http(s) 或 data URL 外链图片;先下载到本地并让 CLI 上传,或用 `--assets` 提供已上传 file token。
- `slides +create-svg` MVP 不支持指定 `beforeSlideBlockID` 插入到某一页前;它创建新 presentation 后按 `--file` 顺序追加。
这些能力依赖 slide server SVGlide parser 新版本。如果 BOE/线上未部署对应 server 分支CLI 放行后仍可能收到服务端 `SVGLIDE_ERROR_JSON` 或 generic invalid param。
## 图片与 Metadata
SVG deck 默认应使用真实图片资产,不要为了规避上传链路而全程用纯矢量 shape 冒充配图。宣传、产品、品牌、案例和视觉展示型 deck 至少应包含封面/半出血主视觉/案例场景/产品截图等图片使用;只有用户明确要求纯矢量,或图片获取、上传链路不可用时,才退回纯矢量方案,并在结果中说明原因。
图片资产还必须规避版权风险:只使用用户提供、公司/项目自有、明确可商用授权图库,或授权条件清晰的 AI 生成资产。推荐来源包括 Unsplash、Pexels、Pixabay、Openverse、Wikimedia Commons、The Met Open Access、Smithsonian Open Access 和 NASA Image and Video Library但每张图都必须检查具体 license。不要使用版权状态不明的搜索图片、新闻配图、第三方 logo、竞品官网截图或素材站预览图。生成 deck 时应在素材清单或 README 中记录图片来源、授权/许可类型、下载 URL 或生成方式、是否需要署名;无法确认授权时不得使用该图片。
`slides +create-svg` 会把 `<image href="@./image.png">` 上传为 file token并注入
```xml
<metadata data-svglide-assets="true">
<img src="boxcn..." />
</metadata>
```
metadata 只用于让现有服务端链路生成 `FileMetaMap`。如果使用 `--assets assets.json` 传入预上传 tokenCLI 也会按同样规则替换和注入。
`assets.json` 格式:
```json
{
"@./image.png": "boxcn...",
"./other.png": "boxcn..."
}
```

View File

@@ -0,0 +1,486 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import json
import re
import sys
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Any
SLIDE_NS = "https://slides.bytedance.com/ns"
XLINK_NS = "http://www.w3.org/1999/xlink"
SVG_NS = "http://www.w3.org/2000/svg"
CANVAS_WIDTH = 960.0
CANVAS_HEIGHT = 540.0
SAFE_AREA = {"x": 48.0, "y": 40.0, "width": 864.0, "height": 460.0}
NUMBER_RE = re.compile(r"^[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?(?:px)?$")
PATH_NUMBER_RE = re.compile(r"[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?")
FONT_SHORTHAND_RE = re.compile(r"(^|;)\s*font\s*:", re.IGNORECASE)
SUPPORTED_SHAPES = {"rect", "ellipse", "circle", "line", "path", "foreignObject"}
RENDERABLE_TAGS = SUPPORTED_SHAPES | {"image", "text", "polygon", "polyline"}
IGNORED_SUBTREES = {"defs", "style"}
class SvgPreflightError(Exception):
pass
def fail(message: str) -> None:
raise SvgPreflightError(message)
def parse_args(argv: list[str]) -> dict[str, Any]:
inputs: list[str] = []
index = 0
while index < len(argv):
token = argv[index]
if token in {"--input", "-i"}:
if index + 1 >= len(argv):
fail(f"{token} requires a file path")
inputs.append(argv[index + 1])
index += 2
continue
if token.startswith("--"):
fail(f"unexpected argument: {token}")
inputs.append(token)
index += 1
if not inputs:
fail("at least one --input <svg-file> is required")
return {"inputs": inputs}
def local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1] if tag.startswith("{") else tag
def get_attr(element: ET.Element, name: str, namespace: str | None = None) -> str | None:
if namespace:
value = element.attrib.get(f"{{{namespace}}}{name}")
if value is not None:
return value
value = element.attrib.get(name)
if value is not None:
return value
for key, candidate in element.attrib.items():
if key.endswith("}" + name):
return candidate
return None
def svg_role(element: ET.Element) -> str | None:
return get_attr(element, "role", SLIDE_NS)
def svg_shape_type(element: ET.Element) -> str | None:
return get_attr(element, "shape-type", SLIDE_NS)
def parse_number(value: str | None) -> float | None:
if value is None:
return None
value = value.strip()
if not NUMBER_RE.match(value):
return None
if value.lower().endswith("px"):
value = value[:-2]
try:
return float(value)
except ValueError:
return None
def parse_required_number(element: ET.Element, name: str) -> float | None:
return parse_number(get_attr(element, name))
def issue(level: str, code: str, message: str, element: ET.Element | None = None, hint: str | None = None) -> dict[str, Any]:
out: dict[str, Any] = {"level": level, "code": code, "message": message}
if element is not None:
elem_id = get_attr(element, "id")
if elem_id:
out["element_id"] = elem_id
out["tag"] = local_name(element.tag)
if hint:
out["hint"] = hint
return out
def parse_viewbox(value: str | None) -> list[float] | None:
if value is None:
return None
parts = [part for part in re.split(r"[\s,]+", value.strip()) if part]
if len(parts) != 4:
return None
try:
return [float(part) for part in parts]
except ValueError:
return None
def is_external_href(value: str | None) -> bool:
if value is None:
return False
lower = value.strip().lower()
return lower.startswith("http://") or lower.startswith("https://") or lower.startswith("data:")
def href_value(element: ET.Element) -> str | None:
return get_attr(element, "href") or get_attr(element, "href", XLINK_NS)
def walk_renderable(root: ET.Element) -> list[ET.Element]:
out: list[ET.Element] = []
def walk(element: ET.Element) -> None:
name = local_name(element.tag)
if name in IGNORED_SUBTREES:
return
if name in RENDERABLE_TAGS or name == "foreignObject" or name == "image":
out.append(element)
for child in list(element):
walk(child)
for child in list(root):
walk(child)
return out
def validate_root(root: ET.Element) -> tuple[list[dict[str, Any]], float, float]:
issues: list[dict[str, Any]] = []
if local_name(root.tag) != "svg":
issues.append(issue("error", "root_not_svg", "root element must be <svg>"))
if svg_role(root) != "slide":
issues.append(issue("error", "missing_root_role", 'root <svg> must include slide:role="slide"', root))
width = parse_number(get_attr(root, "width"))
height = parse_number(get_attr(root, "height"))
viewbox = parse_viewbox(get_attr(root, "viewBox"))
if width != CANVAS_WIDTH or height != CANVAS_HEIGHT:
issues.append(
issue(
"error",
"root_canvas_mismatch",
'root must use width="960" height="540"',
root,
"Scale coordinates to the Lark Slides 960x540 canvas before calling slides +create-svg.",
)
)
if viewbox != [0.0, 0.0, CANVAS_WIDTH, CANVAS_HEIGHT]:
issues.append(
issue(
"error",
"root_viewbox_mismatch",
'root must use viewBox="0 0 960 540"',
root,
"Do not submit a 1280x720 viewBox and rely on server-side scaling.",
)
)
return issues, width or CANVAS_WIDTH, height or CANVAS_HEIGHT
def validate_roles_and_attrs(elements: list[ET.Element]) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
for element in elements:
name = local_name(element.tag)
role = svg_role(element)
if name == "text":
issues.append(
issue(
"error",
"unsupported_text_element",
'root-level <text> is not supported; use foreignObject slide:role="shape" slide:shape-type="text"',
element,
)
)
continue
if name in {"polygon", "polyline"}:
issues.append(
issue(
"error",
"unsupported_shape_element",
f"<{name}> is not supported by SVGlide MVP",
element,
"Use path with M/L/H/V/C/Q/Z commands, or use rect/line/circle/ellipse.",
)
)
continue
if name not in {"image"} | SUPPORTED_SHAPES:
continue
if role is None:
issues.append(issue("error", "missing_leaf_role", '<%s> must include slide:role="shape" or "image"' % name, element))
continue
if role == "shape":
if name not in SUPPORTED_SHAPES:
issues.append(issue("error", "unsupported_shape_role", f'<{name} slide:role="shape"> is not supported', element))
continue
if name == "foreignObject" and svg_shape_type(element) != "text":
issues.append(
issue(
"error",
"missing_text_shape_type",
'<foreignObject slide:role="shape"> must include slide:shape-type="text"',
element,
)
)
elif role == "image":
if name != "image":
issues.append(issue("error", "unsupported_image_role", f'<{name} slide:role="image"> is not supported', element))
image_href = href_value(element)
if not image_href:
issues.append(issue("error", "missing_image_href", '<image slide:role="image"> must include href', element))
if is_external_href(image_href):
issues.append(
issue(
"error",
"external_image_href",
"<image> must not use http(s) or data href",
element,
'Download or generate the image locally and use href="@./path", or provide a file token.',
)
)
else:
issues.append(issue("error", "unsupported_role", f'unsupported slide:role="{role}"', element))
return issues
def bbox_for_element(element: ET.Element) -> dict[str, float] | None:
name = local_name(element.tag)
if name in {"rect", "foreignObject", "image"}:
x = parse_required_number(element, "x")
y = parse_required_number(element, "y")
width = parse_required_number(element, "width")
height = parse_required_number(element, "height")
if None in {x, y, width, height}:
return None
return {"x": x or 0.0, "y": y or 0.0, "width": width or 0.0, "height": height or 0.0}
if name == "circle":
cx = parse_required_number(element, "cx")
cy = parse_required_number(element, "cy")
r = parse_required_number(element, "r")
if None in {cx, cy, r}:
return None
return {"x": (cx or 0.0) - (r or 0.0), "y": (cy or 0.0) - (r or 0.0), "width": 2 * (r or 0.0), "height": 2 * (r or 0.0)}
if name == "ellipse":
cx = parse_required_number(element, "cx")
cy = parse_required_number(element, "cy")
rx = parse_required_number(element, "rx")
ry = parse_required_number(element, "ry")
if None in {cx, cy, rx, ry}:
return None
return {"x": (cx or 0.0) - (rx or 0.0), "y": (cy or 0.0) - (ry or 0.0), "width": 2 * (rx or 0.0), "height": 2 * (ry or 0.0)}
if name == "line":
x1 = parse_required_number(element, "x1")
y1 = parse_required_number(element, "y1")
x2 = parse_required_number(element, "x2")
y2 = parse_required_number(element, "y2")
if None in {x1, y1, x2, y2}:
return None
min_x = min(x1 or 0.0, x2 or 0.0)
min_y = min(y1 or 0.0, y2 or 0.0)
return {"x": min_x, "y": min_y, "width": abs((x2 or 0.0) - (x1 or 0.0)), "height": abs((y2 or 0.0) - (y1 or 0.0))}
return None
def is_background_bbox(bbox: dict[str, float], canvas_width: float, canvas_height: float) -> bool:
return bbox["x"] <= 0 and bbox["y"] <= 0 and bbox["x"] + bbox["width"] >= canvas_width and bbox["y"] + bbox["height"] >= canvas_height
def bbox_outside(bbox: dict[str, float], rect: dict[str, float]) -> bool:
return (
bbox["x"] < rect["x"]
or bbox["y"] < rect["y"]
or bbox["x"] + bbox["width"] > rect["x"] + rect["width"]
or bbox["y"] + bbox["height"] > rect["y"] + rect["height"]
)
def intersects(left: dict[str, float], right: dict[str, float]) -> bool:
return (
left["x"] < right["x"] + right["width"]
and left["x"] + left["width"] > right["x"]
and left["y"] < right["y"] + right["height"]
and left["y"] + left["height"] > right["y"]
)
def validate_geometry(elements: list[ET.Element], canvas_width: float, canvas_height: float) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
issues: list[dict[str, Any]] = []
text_boxes: list[dict[str, Any]] = []
canvas = {"x": 0.0, "y": 0.0, "width": canvas_width, "height": canvas_height}
for element in elements:
name = local_name(element.tag)
bbox = bbox_for_element(element)
if bbox is None:
continue
if is_background_bbox(bbox, canvas_width, canvas_height):
continue
if bbox_outside(bbox, canvas):
issues.append(
issue(
"error",
"canvas_bounds",
f"<{name}> is outside the 960x540 canvas",
element,
"Non-background elements must fit inside the slide canvas.",
)
)
elif bbox_outside(bbox, SAFE_AREA):
issues.append(
issue(
"warning",
"safe_area",
f"<{name}> is outside the recommended safe area",
element,
"Keep text, labels, cards, legends, and key visuals within x>=48 y>=40 right<=912 bottom<=500 unless it is an intentional full-bleed background.",
)
)
if name == "foreignObject" and svg_role(element) == "shape" and svg_shape_type(element) == "text":
text = "".join(element.itertext()).strip()
if text:
text_boxes.append({"element": element, "bbox": bbox, "text": text})
return issues, text_boxes
def validate_text_overlap(text_boxes: list[dict[str, Any]]) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
for left_index, left in enumerate(text_boxes):
for right in text_boxes[left_index + 1 :]:
if intersects(left["bbox"], right["bbox"]):
left_id = get_attr(left["element"], "id") or local_name(left["element"].tag)
right_id = get_attr(right["element"], "id") or local_name(right["element"].tag)
issues.append(
{
"level": "error",
"code": "text_bbox_overlap",
"message": f"text boxes overlap: {left_id} and {right_id}",
"left_element_id": get_attr(left["element"], "id"),
"right_element_id": get_attr(right["element"], "id"),
"hint": "Move text boxes apart, reduce text density, or split the page into clearer layout boxes.",
}
)
return issues
def validate_styles(root: ET.Element) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
for element in root.iter():
style = get_attr(element, "style") or ""
if FONT_SHORTHAND_RE.search(style):
issues.append(
issue(
"error",
"font_shorthand",
'style must not use "font:" shorthand',
element,
"Use explicit font-size, font-weight, font-family, color, line-height, and text-align properties.",
)
)
return issues
def validate_paths(elements: list[ET.Element]) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
for element in elements:
if local_name(element.tag) != "path" or svg_role(element) != "shape":
continue
data = get_attr(element, "d") or ""
without_numbers = PATH_NUMBER_RE.sub("", data)
has_command = False
for char in without_numbers:
if char in ", \t\r\n":
continue
if char in "MLHVZCQmlhvzcq":
has_command = True
continue
issues.append(
issue(
"error",
"unsupported_path_command",
f'unsupported path command or character "{char}"',
element,
"Use only M/L/H/V/C/Q/Z path commands.",
)
)
break
if not has_command:
issues.append(issue("error", "missing_path_command", 'path attribute "d" must include M/L/H/V/C/Q/Z commands', element))
return issues
def lint_svg(svg: str, path: str = "<svg>") -> dict[str, Any]:
result: dict[str, Any] = {"path": path, "issues": []}
try:
root = ET.fromstring(svg)
except ET.ParseError as error:
result["issues"].append(
{
"level": "error",
"code": "xml_not_well_formed",
"message": f"SVG is not well-formed: {error}",
"hint": "Fix tag closure, attribute quotes, namespaces, and XML escaping before calling slides +create-svg.",
}
)
result["summary"] = {"error_count": 1, "warning_count": 0}
return result
root_issues, width, height = validate_root(root)
elements = walk_renderable(root)
role_issues = validate_roles_and_attrs(elements)
geometry_issues, text_boxes = validate_geometry(elements, width, height)
issues = root_issues + role_issues + validate_styles(root) + validate_paths(elements) + geometry_issues + validate_text_overlap(text_boxes)
result["width"] = width
result["height"] = height
result["element_count"] = len(elements)
result["text_box_count"] = len(text_boxes)
result["issues"] = issues
result["summary"] = {
"error_count": sum(1 for item in issues if item["level"] == "error"),
"warning_count": sum(1 for item in issues if item["level"] == "warning"),
}
if not issues:
result.pop("issues")
return result
def lint_files(paths: list[str]) -> dict[str, Any]:
files: list[dict[str, Any]] = []
for path in paths:
svg = Path(path).read_text(encoding="utf-8")
files.append(lint_svg(svg, path))
return {
"summary": {
"file_count": len(files),
"error_count": sum(file["summary"]["error_count"] for file in files),
"warning_count": sum(file["summary"]["warning_count"] for file in files),
},
"files": files,
}
def main(argv: list[str]) -> int:
try:
options = parse_args(argv)
result = lint_files(options["inputs"])
except SvgPreflightError as error:
print(f"svg_preflight: {error}", file=sys.stderr)
return 2
except OSError as error:
print(f"svg_preflight: {error}", file=sys.stderr)
return 2
print(json.dumps(result, ensure_ascii=False, indent=2))
return 1 if result["summary"]["error_count"] else 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View File

@@ -0,0 +1,97 @@
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import unittest
import svg_preflight
VALID_SVG = """
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:slide="https://slides.bytedance.com/ns"
slide:role="slide"
width="960" height="540" viewBox="0 0 960 540">
<rect slide:role="shape" x="0" y="0" width="960" height="540" fill="#f8fafc" />
<foreignObject id="title" slide:role="shape" slide:shape-type="text" x="64" y="56" width="420" height="72">
<div xmlns="http://www.w3.org/1999/xhtml"
style="font-size:32px;font-weight:800;font-family:Arial;color:#111827;line-height:1.15;text-align:left;">
Strategy review
</div>
</foreignObject>
<image id="hero" slide:role="image" href="@./assets/hero.jpg" x="560" y="96" width="320" height="220" />
<path id="trend" slide:role="shape" d="M64 360 L180 330 C260 300 340 340 420 300 Q500 260 580 290" fill="none" stroke="#2563eb" />
</svg>
"""
class SvgPreflightTest(unittest.TestCase):
def test_lint_svg_accepts_valid_svglide(self) -> None:
result = svg_preflight.lint_svg(VALID_SVG)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 0)
def test_lint_svg_reports_canvas_mismatch(self) -> None:
result = svg_preflight.lint_svg(
VALID_SVG.replace('width="960" height="540" viewBox="0 0 960 540"', 'width="1280" height="720" viewBox="0 0 1280 720"')
)
codes = [issue["code"] for issue in result["issues"]]
self.assertIn("root_canvas_mismatch", codes)
self.assertIn("root_viewbox_mismatch", codes)
self.assertEqual(result["summary"]["error_count"], 2)
def test_lint_svg_reports_external_image_and_font_shorthand(self) -> None:
svg = """
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:slide="https://slides.bytedance.com/ns"
slide:role="slide"
width="960" height="540" viewBox="0 0 960 540">
<foreignObject id="title" slide:role="shape" slide:shape-type="text" x="64" y="56" width="420" height="72">
<div xmlns="http://www.w3.org/1999/xhtml" style="font: 700 24px Arial;color:#111827;">Title</div>
</foreignObject>
<image id="hero" slide:role="image" href="https://example.com/hero.jpg" x="560" y="96" width="320" height="220" />
</svg>
"""
result = svg_preflight.lint_svg(svg)
codes = [issue["code"] for issue in result["issues"]]
self.assertIn("external_image_href", codes)
self.assertIn("font_shorthand", codes)
def test_lint_svg_reports_canvas_error_and_safe_area_warning(self) -> None:
svg = """
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:slide="https://slides.bytedance.com/ns"
slide:role="slide"
width="960" height="540" viewBox="0 0 960 540">
<rect id="badge" slide:role="shape" x="12" y="20" width="80" height="40" />
<rect id="overflow" slide:role="shape" x="920" y="500" width="120" height="80" />
</svg>
"""
result = svg_preflight.lint_svg(svg)
codes = [issue["code"] for issue in result["issues"]]
self.assertIn("safe_area", codes)
self.assertIn("canvas_bounds", codes)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["summary"]["warning_count"], 1)
def test_lint_svg_reports_text_bbox_overlap(self) -> None:
svg = """
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:slide="https://slides.bytedance.com/ns"
slide:role="slide"
width="960" height="540" viewBox="0 0 960 540">
<foreignObject id="a" slide:role="shape" slide:shape-type="text" x="80" y="80" width="240" height="80">
<div xmlns="http://www.w3.org/1999/xhtml" style="font-size:24px;font-weight:700;color:#111;">A</div>
</foreignObject>
<foreignObject id="b" slide:role="shape" slide:shape-type="text" x="120" y="100" width="240" height="80">
<div xmlns="http://www.w3.org/1999/xhtml" style="font-size:18px;font-weight:400;color:#111;">B</div>
</foreignObject>
</svg>
"""
result = svg_preflight.lint_svg(svg)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["issues"][0]["code"], "text_bbox_overlap")
if __name__ == "__main__":
unittest.main()

View File

@@ -33,8 +33,6 @@ metadata:
> Task OpenAPI 中用于更新/操作任务的 `guid` 是任务的全局唯一标识GUID不是客户端展示的任务编号例如 `t104121` / `suite_entity_num`)。
> 对于 Feishu 的任务 applink例如 `.../client/todo/task?guid=...`),必须使用 URL query 里的 `guid` 参数作为 task guid。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-task-create.md) | create a task |

View File

@@ -94,8 +94,6 @@ lark-cli vc +notes --meeting-ids "$MID"
Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 类型 | 说明 |
| --------------------------------------------------------------- | -- | -------------------------------------------------------------------------- |
| [`+meeting-join`](references/lark-vc-agent-meeting-join.md) | 写 | Join an in-progress meeting by 9-digit meeting number |

View File

@@ -137,8 +137,6 @@ Meeting (视频会议)
Shortcut 是对常用操作的高级封装(`lark-cli vc +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+search`](references/lark-vc-search.md) | Search meeting records (requires at least one filter) |

View File

@@ -33,8 +33,6 @@ metadata:
## Shortcuts
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|---|---|
| [`+query`](references/lark-whiteboard-query.md) | 查询画板,导出为预览图片、代码或原始节点结构 |

View File

@@ -59,8 +59,6 @@ metadata:
Shortcut 是对常用操作的高级封装(`lark-cli wiki +<verb> [flags]`)。有 Shortcut 的操作优先使用。
**CRITICAL — 执行任何 Shortcut 之前MUST 先用 Read 工具读取下表中对应的说明文档;没有文档链接的命令须执行 -h 了解用法。禁止盲调。**
| Shortcut | 说明 |
|----------|------|
| [`+move`](references/lark-wiki-move.md) | Move a wiki node, or move a Drive document into Wiki |

View File

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

View File

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

View File

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