feat: add svglide image resource helpers

This commit is contained in:
songtianyi.theo
2026-07-09 02:40:01 +08:00
parent 6c4db521b7
commit 3f3bf05c26
2 changed files with 432 additions and 0 deletions

View File

@@ -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)<image\b[^>]*>`).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")
}

View File

@@ -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: `<svg xmlns="http://www.w3.org/2000/svg"><image href="../assets/images/a.png"/></svg>`},
{ID: "p2", File: "slides/page-002.svg", Content: `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><image xlink:href="../assets/images/a.png"/></svg>`},
},
}
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: `<svg xmlns="http://www.w3.org/2000/svg"><image href="` + tt.href + `"/></svg>`,
}},
}
_, 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: `<svg xmlns="http://www.w3.org/2000/svg"><image href="../assets/images/a.png"/></svg>`},
{ID: "p2", File: "slides/page-002.svg", SHA256: "sha2", SourceBytes: 100, ContentBytes: 100, Content: `<svg xmlns="http://www.w3.org/2000/svg"><image href="../assets/images/a.png"/></svg>`},
},
}
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
}