mirror of
https://github.com/larksuite/cli.git
synced 2026-07-11 03:43:42 +08:00
feat: add svglide image resource helpers
This commit is contained in:
275
shortcuts/slides/slides_create_svglide_assets.go
Normal file
275
shortcuts/slides/slides_create_svglide_assets.go
Normal 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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user