From 3f3bf05c263fa5ededa142d8dddeb04722a4fd1e Mon Sep 17 00:00:00 2001 From: "songtianyi.theo" Date: Thu, 9 Jul 2026 02:40:01 +0800 Subject: [PATCH] feat: add svglide image resource helpers --- .../slides/slides_create_svglide_assets.go | 275 ++++++++++++++++++ .../slides/slides_create_svglide_test.go | 157 ++++++++++ 2 files changed, 432 insertions(+) create mode 100644 shortcuts/slides/slides_create_svglide_assets.go diff --git a/shortcuts/slides/slides_create_svglide_assets.go b/shortcuts/slides/slides_create_svglide_assets.go new file mode 100644 index 000000000..3cbcf21e9 --- /dev/null +++ b/shortcuts/slides/slides_create_svglide_assets.go @@ -0,0 +1,275 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "bytes" + "encoding/xml" + "fmt" + "io" + "net/url" + "path/filepath" + "regexp" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/vfs" + "github.com/larksuite/cli/shortcuts/common" +) + +type svglideImageRef struct { + PageID string + PageFile string + AttrName string + Href string +} + +type svglideImageFile struct { + FilePath string + FileName string + Size int64 +} + +type svglideResolvedImageRef struct { + Ref svglideImageRef + FilePath string + FileName string + Size int64 +} + +type svglideImagePlan struct { + Refs []svglideResolvedImageRef + UniqueFiles []svglideImageFile +} + +type svglideImageRewriteEntry struct { + PageID string `json:"page_id"` + PageFile string `json:"page_file"` + AttrName string `json:"attr_name"` + OriginalHref string `json:"original_href"` + ResolvedFile string `json:"resolved_file"` + FileToken string `json:"file_token"` + Size int64 `json:"size"` +} + +type svglideImageRewriteResult struct { + UploadedCount int `json:"uploaded_count"` + Entries []svglideImageRewriteEntry `json:"entries"` +} + +func planSVGlideImageRewrites(runtime *common.RuntimeContext, spec *svglidePublishSpec) (svglideImagePlan, error) { + var plan svglideImagePlan + seenFiles := map[string]bool{} + for _, page := range spec.Pages { + refs, err := collectSVGlideImageRefs(page) + if err != nil { + return svglideImagePlan{}, err + } + for _, ref := range refs { + resolved, err := resolveSVGlideImageRef(runtime, spec, page, ref) + if err != nil { + return svglideImagePlan{}, err + } + plan.Refs = append(plan.Refs, resolved) + if !seenFiles[resolved.FilePath] { + seenFiles[resolved.FilePath] = true + plan.UniqueFiles = append(plan.UniqueFiles, svglideImageFile{ + FilePath: resolved.FilePath, + FileName: resolved.FileName, + Size: resolved.Size, + }) + } + } + } + return plan, nil +} + +func collectSVGlideImageRefs(page svglidePublishPage) ([]svglideImageRef, error) { + decoder := xml.NewDecoder(bytes.NewReader([]byte(page.Content))) + var refs []svglideImageRef + for { + token, err := decoder.Token() + if err != nil { + if err == io.EOF { + break + } + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest page file invalid XML while scanning image hrefs: %s: %v", page.File, err).WithParam("--manifest").WithCause(err) + } + start, ok := token.(xml.StartElement) + if !ok || start.Name.Local != "image" { + continue + } + for _, attr := range start.Attr { + if attr.Name.Local != "href" { + continue + } + attrName := "href" + if attr.Name.Space != "" { + attrName = "xlink:href" + } + refs = append(refs, svglideImageRef{ + PageID: page.ID, + PageFile: page.File, + AttrName: attrName, + Href: attr.Value, + }) + } + } + return refs, nil +} + +func resolveSVGlideImageRef(runtime *common.RuntimeContext, spec *svglidePublishSpec, page svglidePublishPage, ref svglideImageRef) (svglideResolvedImageRef, error) { + href := strings.TrimSpace(ref.Href) + if href == "" { + return svglideResolvedImageRef{}, svglideImageHrefError("empty image href", ref) + } + lower := strings.ToLower(href) + switch { + case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"): + return svglideResolvedImageRef{}, svglideImageHrefError("remote image href is not supported", ref) + case strings.HasPrefix(lower, "data:"): + return svglideResolvedImageRef{}, svglideImageHrefError("data image href is not supported", ref) + case strings.HasPrefix(href, "#"): + return svglideResolvedImageRef{}, svglideImageHrefError("fragment image href is not supported", ref) + case strings.HasPrefix(href, "@"): + return svglideResolvedImageRef{}, svglideImageHrefError("@path image href is not an SVGlide protocol", ref) + } + + resolved, err := resolveSVGlideImagePath(spec, page, href) + if err != nil { + return svglideResolvedImageRef{}, err + } + stat, err := runtime.FileIO().Stat(resolved) + if err != nil { + return svglideResolvedImageRef{}, slidesInputStatError(err, "--manifest", fmt.Sprintf("--manifest image href %q: file not found", href)) + } + if stat.IsDir() { + return svglideResolvedImageRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest image href %q resolves to a directory", href).WithParam("--manifest") + } + if stat.Size() > common.MaxDriveMediaUploadSinglePartSize { + return svglideResolvedImageRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest image href %q file size %s exceeds 20 MB limit for slides image upload", href, common.FormatSize(stat.Size())).WithParam("--manifest") + } + + return svglideResolvedImageRef{ + Ref: ref, + FilePath: resolved, + FileName: filepath.Base(resolved), + Size: stat.Size(), + }, nil +} + +func uploadAndRewriteSVGlideImages(runtime *common.RuntimeContext, spec *svglidePublishSpec, plan svglideImagePlan, presentationID string) ([]svglidePublishPage, svglideImageRewriteResult, error) { + tokens := map[string]string{} + for i, file := range plan.UniqueFiles { + fmt.Fprintf(runtime.IO().ErrOut, "Uploading SVGlide image %d/%d: %s (%s)\n", i+1, len(plan.UniqueFiles), file.FileName, common.FormatSize(file.Size)) + token, err := uploadSlidesMedia(runtime, file.FilePath, file.FileName, file.Size, presentationID) + if err != nil { + return nil, svglideImageRewriteResult{UploadedCount: i}, err + } + tokens[file.FilePath] = token + } + + pages := make([]svglidePublishPage, len(spec.Pages)) + copy(pages, spec.Pages) + result := svglideImageRewriteResult{UploadedCount: len(plan.UniqueFiles)} + for i := range pages { + for _, ref := range plan.Refs { + if ref.Ref.PageID != pages[i].ID || ref.Ref.PageFile != pages[i].File { + continue + } + token := tokens[ref.FilePath] + rewritten, err := rewriteSVGlideImageHref(pages[i].Content, ref, token) + if err != nil { + return nil, result, err + } + pages[i].Content = rewritten + pages[i].ContentBytes = len([]byte(rewritten)) + result.Entries = append(result.Entries, svglideImageRewriteEntry{ + PageID: ref.Ref.PageID, + PageFile: ref.Ref.PageFile, + AttrName: ref.Ref.AttrName, + OriginalHref: ref.Ref.Href, + ResolvedFile: ref.FilePath, + FileToken: token, + Size: ref.Size, + }) + } + } + return pages, result, nil +} + +func rewriteSVGlideImageHref(content string, ref svglideResolvedImageRef, fileToken string) (string, error) { + imageTags := regexp.MustCompile(`(?is)]*>`).FindAllStringIndex(content, -1) + for _, loc := range imageTags { + tag := content[loc[0]:loc[1]] + rewrittenTag, ok := rewriteSVGlideImageHrefInTag(tag, ref.Ref.AttrName, ref.Ref.Href, fileToken) + if !ok { + continue + } + return content[:loc[0]] + rewrittenTag + content[loc[1]:], nil + } + return "", errs.NewInternalError(errs.SubtypeUnknown, "failed to rewrite SVGlide image href %q in %s", ref.Ref.Href, ref.Ref.PageFile) +} + +func rewriteSVGlideImageHrefInTag(tag, attrName, oldHref, newHref string) (string, bool) { + attr := regexp.QuoteMeta(attrName) + old := regexp.QuoteMeta(oldHref) + doubleQuoted := regexp.MustCompile(`(?s)(\b` + attr + `\s*=\s*)"` + old + `"`) + if doubleQuoted.MatchString(tag) { + return doubleQuoted.ReplaceAllString(tag, `${1}"`+newHref+`"`), true + } + singleQuoted := regexp.MustCompile(`(?s)(\b` + attr + `\s*=\s*)'` + old + `'`) + if singleQuoted.MatchString(tag) { + return singleQuoted.ReplaceAllString(tag, `${1}'`+newHref+`'`), true + } + return tag, false +} + +func resolveSVGlideImagePath(spec *svglidePublishSpec, page svglidePublishPage, href string) (string, error) { + var candidate string + if strings.HasPrefix(strings.ToLower(href), "file://") { + u, err := url.Parse(href) + if err != nil || u.Host != "" || u.Path == "" { + return "", svglideImageHrefError("file URL image href is invalid", svglideImageRef{PageID: page.ID, PageFile: page.File, Href: href}) + } + cwd, err := vfs.Getwd() + if err != nil { + return "", errs.NewInternalError(errs.SubtypeFileIO, "resolve SVGlide image href: cannot determine working directory: %v", err).WithCause(err) + } + rel, err := filepath.Rel(cwd, filepath.Clean(u.Path)) + if err != nil { + return "", svglideImageHrefError("file URL image href cannot be made relative to the working directory", svglideImageRef{PageID: page.ID, PageFile: page.File, Href: href}) + } + candidate = rel + } else { + candidate = filepath.Join(filepath.Dir(page.File), href) + } + candidate = filepath.Clean(candidate) + if candidate == "." || hasParentDirSegment(candidate) { + return "", svglideImageHrefError("image href resolves outside the manifest root", svglideImageRef{PageID: page.ID, PageFile: page.File, Href: href}) + } + if !svglidePathUnderRoot(candidate, spec.BaseDir) { + return "", svglideImageHrefError("image href resolves outside the manifest root", svglideImageRef{PageID: page.ID, PageFile: page.File, Href: href}) + } + return candidate, nil +} + +func svglidePathUnderRoot(path string, root string) bool { + root = filepath.Clean(strings.TrimSpace(root)) + if root == "." { + root = "" + } + if root == "" { + return !hasParentDirSegment(path) + } + rel, err := filepath.Rel(root, path) + if err != nil { + return false + } + return rel != "." && !hasParentDirSegment(rel) +} + +func svglideImageHrefError(reason string, ref svglideImageRef) error { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--manifest %s: %s in %s", reason, ref.Href, ref.PageFile).WithParam("--manifest") +} diff --git a/shortcuts/slides/slides_create_svglide_test.go b/shortcuts/slides/slides_create_svglide_test.go index 74ee6fc01..b3aa59d00 100644 --- a/shortcuts/slides/slides_create_svglide_test.go +++ b/shortcuts/slides/slides_create_svglide_test.go @@ -4,11 +4,21 @@ package slides import ( + "context" + "errors" "fmt" "os" "path/filepath" "strings" "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" ) func TestPrepareSVGlidePublishSpecReadsManifestAndPages(t *testing.T) { @@ -179,3 +189,150 @@ func mustWriteSVGlideTestFile(t *testing.T, path string, body string) { t.Fatalf("write %s: %v", path, err) } } + +func TestPlanSVGlideImageRewritesResolvesAndDedupes(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + mustWriteSVGlideTestFile(t, "assets/images/a.png", "png") + runtime, _ := newSVGlideTestRuntime(t) + spec := &svglidePublishSpec{ + BaseDir: "", + Pages: []svglidePublishPage{ + {ID: "p1", File: "slides/page-001.svg", Content: ``}, + {ID: "p2", File: "slides/page-002.svg", Content: ``}, + }, + } + + plan, err := planSVGlideImageRewrites(runtime, spec) + if err != nil { + t.Fatalf("planSVGlideImageRewrites() error = %v", err) + } + if len(plan.Refs) != 2 { + t.Fatalf("Refs len = %d, want 2", len(plan.Refs)) + } + if len(plan.UniqueFiles) != 1 { + t.Fatalf("UniqueFiles len = %d, want 1", len(plan.UniqueFiles)) + } + if got := filepath.ToSlash(plan.UniqueFiles[0].FilePath); got != "assets/images/a.png" { + t.Fatalf("Unique file = %q, want assets/images/a.png", got) + } + if plan.Refs[0].Ref.AttrName != "href" || plan.Refs[1].Ref.AttrName != "xlink:href" { + t.Fatalf("attr names = %q/%q", plan.Refs[0].Ref.AttrName, plan.Refs[1].Ref.AttrName) + } +} + +func TestPlanSVGlideImageRewritesRejectsUnsupportedHrefs(t *testing.T) { + tests := []struct { + name string + href string + }{ + {name: "remote", href: "https://example.com/a.png"}, + {name: "data url", href: "data:image/png;base64,xxx"}, + {name: "fragment", href: "#asset"}, + {name: "at path", href: "@assets/a.png"}, + {name: "missing local", href: "../assets/missing.png"}, + {name: "outside root", href: "../../outside.png"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + runtime, _ := newSVGlideTestRuntime(t) + spec := &svglidePublishSpec{ + BaseDir: "", + Pages: []svglidePublishPage{{ + ID: "p1", + File: "slides/page-001.svg", + Content: ``, + }}, + } + + _, err := planSVGlideImageRewrites(runtime, spec) + if err == nil { + t.Fatal("expected validation error") + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected validation error, got %T (%v)", err, err) + } + if validationErr.Param != "--manifest" { + t.Fatalf("Param = %q, want --manifest", validationErr.Param) + } + }) + } +} + +func TestUploadAndRewriteSVGlideImagesUploadsOnceAndRewrites(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + mustWriteSVGlideTestFile(t, "assets/images/a.png", "png") + runtime, reg := newSVGlideTestRuntime(t) + 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": "file_tok_1"}, + }, + } + reg.Register(uploadStub) + spec := &svglidePublishSpec{ + BaseDir: "", + Pages: []svglidePublishPage{ + {ID: "p1", File: "slides/page-001.svg", SHA256: "sha1", SourceBytes: 100, ContentBytes: 100, Content: ``}, + {ID: "p2", File: "slides/page-002.svg", SHA256: "sha2", SourceBytes: 100, ContentBytes: 100, Content: ``}, + }, + } + plan, err := planSVGlideImageRewrites(runtime, spec) + if err != nil { + t.Fatalf("planSVGlideImageRewrites() error = %v", err) + } + + pages, result, err := uploadAndRewriteSVGlideImages(runtime, spec, plan, "pres_sg") + if err != nil { + t.Fatalf("uploadAndRewriteSVGlideImages() error = %v", err) + } + if result.UploadedCount != 1 { + t.Fatalf("UploadedCount = %d, want 1", result.UploadedCount) + } + if len(result.Entries) != 2 { + t.Fatalf("Entries len = %d, want 2", len(result.Entries)) + } + for _, page := range pages { + if strings.Contains(page.Content, "../assets/images/a.png") { + t.Fatalf("page content still contains local href: %s", page.Content) + } + if !strings.Contains(page.Content, `href="file_tok_1"`) { + t.Fatalf("page content missing token href: %s", page.Content) + } + if page.SHA256 == "" || page.SourceBytes != 100 || page.ContentBytes == 100 { + t.Fatalf("page hash/source/submitted bytes not preserved/updated: %#v", page) + } + } + + body := decodeMultipartBody(t, uploadStub) + if got := body.Fields["parent_type"]; got != slidesMediaParentType { + t.Fatalf("parent_type = %q, want %q", got, slidesMediaParentType) + } + if got := body.Fields["parent_node"]; got != "pres_sg" { + t.Fatalf("parent_node = %q, want pres_sg", got) + } + if got := len(body.Files["file"]); got != 3 { + t.Fatalf("uploaded file size = %d, want 3", got) + } + reg.Verify(t) +} + +func newSVGlideTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) { + t.Helper() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + cfg := &core.CliConfig{ + AppID: "svglide-test", + AppSecret: "test-secret", + Brand: core.BrandFeishu, + } + f, _, _, reg := cmdutil.TestFactory(t, cfg) + runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+create-svglide"}, cfg, f, core.AsUser) + return runtime, reg +}