From 9d4ae94394dfc59a609eebf6500594e51ad9f664 Mon Sep 17 00:00:00 2001 From: zhanghuanxu Date: Mon, 18 May 2026 21:01:14 +0800 Subject: [PATCH] feat(slides):slide screenshot --- shortcuts/common/runner.go | 8 + shortcuts/common/runner_args_test.go | 29 + shortcuts/common/types.go | 2 +- shortcuts/slides/shortcuts.go | 1 + shortcuts/slides/slides_screenshot.go | 537 ++++++++++++++++++ shortcuts/slides/slides_screenshot_test.go | 506 +++++++++++++++++ skills/lark-slides/SKILL.md | 2 + .../references/lark-slides-screenshot.md | 94 +++ 8 files changed, 1178 insertions(+), 1 deletion(-) create mode 100644 shortcuts/slides/slides_screenshot.go create mode 100644 shortcuts/slides/slides_screenshot_test.go create mode 100644 skills/lark-slides/references/lark-slides-screenshot.md diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 75d3325a..ae7e3bc5 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -223,6 +223,12 @@ func (ctx *RuntimeContext) Float64(name string) float64 { return v } +// IntArray returns an int-array flag value (repeated flag, also supports CSV splitting). +func (ctx *RuntimeContext) IntArray(name string) []int { + v, _ := ctx.Cmd.Flags().GetIntSlice(name) + return v +} + // StrArray returns a string-array flag value (repeated flag, no CSV splitting). func (ctx *RuntimeContext) StrArray(name string) []string { v, _ := ctx.Cmd.Flags().GetStringArray(name) @@ -1176,6 +1182,8 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f var d float64 fmt.Sscanf(fl.Default, "%g", &d) cmd.Flags().Float64(fl.Name, d, desc) + case "int_array": + cmd.Flags().IntSlice(fl.Name, nil, desc) case "string_array": cmd.Flags().StringArray(fl.Name, nil, desc) case "string_slice": diff --git a/shortcuts/common/runner_args_test.go b/shortcuts/common/runner_args_test.go index f04999da..81e5ba23 100644 --- a/shortcuts/common/runner_args_test.go +++ b/shortcuts/common/runner_args_test.go @@ -4,9 +4,12 @@ package common import ( + "context" + "reflect" "strings" "testing" + "github.com/larksuite/cli/internal/cmdutil" "github.com/spf13/cobra" ) @@ -56,3 +59,29 @@ func TestRejectPositionalArgs_NoArgs(t *testing.T) { t.Fatalf("expected no error for empty args, got: %v", err) } } + +func TestShortcutFlagIntArray(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + parent := &cobra.Command{Use: "root"} + var got []int + shortcut := Shortcut{ + Service: "slides", + Command: "+screenshot", + Description: "capture screenshots", + Flags: []Flag{ + {Name: "slide-number", Type: "int_array"}, + }, + Execute: func(ctx context.Context, runtime *RuntimeContext) error { + got = runtime.IntArray("slide-number") + return nil + }, + } + shortcut.Mount(parent, f) + parent.SetArgs([]string{"+screenshot", "--as", "user", "--slide-number", "1", "--slide-number", "2,3"}) + if err := parent.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if want := []int{1, 2, 3}; !reflect.DeepEqual(got, want) { + t.Fatalf("slide-number = %#v, want %#v", got, want) + } +} diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 86eb80b7..afc4f1a8 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -18,7 +18,7 @@ const ( // Flag describes a CLI flag for a shortcut. type Flag struct { Name string // flag name (e.g. "calendar-id") - Type string // "string" (default) | "bool" | "int" | "float64" | "string_array" | "string_slice" + Type string // "string" (default) | "bool" | "int" | "float64" | "int_array" | "string_array" | "string_slice" Default string // default value as string Desc string // help text Hidden bool // hidden from --help, still readable at runtime diff --git a/shortcuts/slides/shortcuts.go b/shortcuts/slides/shortcuts.go index 2ef3650e..f665810c 100644 --- a/shortcuts/slides/shortcuts.go +++ b/shortcuts/slides/shortcuts.go @@ -11,5 +11,6 @@ func Shortcuts() []common.Shortcut { SlidesCreate, SlidesMediaUpload, SlidesReplaceSlide, + SlidesScreenshot, } } diff --git a/shortcuts/slides/slides_screenshot.go b/shortcuts/slides/slides_screenshot.go new file mode 100644 index 00000000..a265facd --- /dev/null +++ b/shortcuts/slides/slides_screenshot.go @@ -0,0 +1,537 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const defaultSlidesScreenshotDir = ".lark-slides/screenshots" + +var unsafeScreenshotFileCharRegex = regexp.MustCompile(`[^A-Za-z0-9._-]+`) + +// SlidesScreenshot fetches server-rendered slide screenshots and writes them to +// local files. The raw API returns Base64 image payloads; this shortcut keeps +// those payloads out of stdout so agents only see small file metadata. +var SlidesScreenshot = common.Shortcut{ + Service: "slides", + Command: "+screenshot", + Description: "Save slide screenshots to local files without printing Base64 image data", + Risk: "read", + Scopes: []string{"slides:presentation:screenshot"}, + // wiki:node:read is required only when --presentation is a wiki URL. + ConditionalScopes: []string{"wiki:node:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides; list mode only"}, + {Name: "slide-id", Type: "string_array", Desc: "slide page identifier (repeat for multiple slides)"}, + {Name: "slide-number", Type: "int_array", Desc: "slide page number (repeat for multiple slides)"}, + {Name: "content", Desc: "slide XML content to render directly instead of fetching existing slides", Input: []string{common.File, common.Stdin}}, + {Name: "output-dir", Default: defaultSlidesScreenshotDir, Desc: "relative directory for saved screenshots"}, + {Name: "output-name", Desc: "file name stem for --content render output"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + renderMode := runtime.Changed("content") + if renderMode { + if strings.TrimSpace(runtime.Str("content")) == "" { + return slidesScreenshotFlagErrorf("--content cannot be empty") + } + if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 { + return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number") + } + if runtime.Changed("presentation") { + return slidesScreenshotFlagErrorf("--presentation cannot be used with --content") + } + } else { + ref, err := parsePresentationRef(runtime.Str("presentation")) + if err != nil { + return err + } + if ref.Kind == "wiki" { + if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil { + return err + } + } + if _, err := normalizeSlideNumbers(runtime.IntArray("slide-number")); err != nil { + return err + } + if !hasSlideScreenshotSelector(runtime) { + return slidesScreenshotFlagErrorf("--slide-id or --slide-number is required") + } + } + if _, err := validateScreenshotOutputDir(runtime, runtime.Str("output-dir")); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + if runtime.Changed("content") { + return dryRunRenderScreenshot(runtime) + } + ref, err := parsePresentationRef(runtime.Str("presentation")) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id")) + slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number")) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + if len(slideIDs) == 0 && len(slideNumbers) == 0 { + return common.NewDryRunAPI().Set("error", "--slide-id or --slide-number is required") + } + + presentationID := ref.Token + dry := common.NewDryRunAPI() + if ref.Kind == "wiki" { + presentationID = "" + dry.Desc("2-step orchestration: resolve wiki → fetch slide screenshot(s)"). + GET("/open-apis/wiki/v2/spaces/get_node"). + Desc("[1] Resolve wiki node to slides presentation"). + Params(map[string]interface{}{"token": ref.Token}) + } else { + dry.Desc(fmt.Sprintf("Fetch %d slide screenshot(s) and save files under %s", len(slideIDs)+len(slideNumbers), runtime.Str("output-dir"))) + } + body := map[string]interface{}{} + if len(slideIDs) > 0 { + body["slide_ids"] = slideIDs + } + if len(slideNumbers) > 0 { + body["slide_numbers"] = slideNumbers + } + dry.POST(fmt.Sprintf( + "/open-apis/slides_ai/v1/xml_presentations/%s/slide_images", + validate.EncodePathSegment(presentationID), + )). + Body(body) + return dry.Set("output_dir", runtime.Str("output-dir")).Set("base64_output", "suppressed; decoded to local files during execution") + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + if runtime.Changed("content") { + return executeRenderScreenshot(runtime) + } + ref, err := parsePresentationRef(runtime.Str("presentation")) + if err != nil { + return err + } + presentationID, err := resolvePresentationID(runtime, ref) + if err != nil { + return err + } + + slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id")) + slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number")) + if err != nil { + return err + } + if len(slideIDs) == 0 && len(slideNumbers) == 0 { + return slidesScreenshotFlagErrorf("--slide-id or --slide-number is required") + } + outputDir := runtime.Str("output-dir") + safeOutputDir, err := ensureScreenshotOutputDir(runtime, outputDir) + if err != nil { + return err + } + + url := fmt.Sprintf( + "/open-apis/slides_ai/v1/xml_presentations/%s/slide_images", + validate.EncodePathSegment(presentationID), + ) + query := larkcore.QueryParams{} + body := map[string]interface{}{} + if len(slideIDs) > 0 { + body["slide_ids"] = slideIDs + } + if len(slideNumbers) > 0 { + body["slide_numbers"] = slideNumbers + } + data, err := doSlidesScreenshotAPIJSONWithLogID(runtime, "POST", url, query, body) + if err != nil { + return enrichSlidesScreenshotSelectorError(err, slideNumbers) + } + + saved, err := saveSlideScreenshots(runtime, data, safeOutputDir, presentationID) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{ + "xml_presentation_id": presentationID, + "output_dir": outputDir, + "screenshots": saved, + }, nil) + return nil + }, +} + +func dryRunRenderScreenshot(runtime *common.RuntimeContext) *common.DryRunAPI { + content := runtime.Str("content") + if strings.TrimSpace(content) == "" { + return common.NewDryRunAPI().Set("error", "--content cannot be empty") + } + if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 { + return common.NewDryRunAPI().Set("error", "--content cannot be used with --slide-id or --slide-number") + } + if runtime.Changed("presentation") { + return common.NewDryRunAPI().Set("error", "--presentation cannot be used with --content") + } + dry := common.NewDryRunAPI().Desc("Render slide XML content to a screenshot file") + dry.POST("/open-apis/slides_ai/v1/slide_image/render"). + Body(map[string]interface{}{ + "content": fmt.Sprintf("", len(content)), + }) + return dry.Set("output_dir", runtime.Str("output-dir")).Set("base64_output", "suppressed; decoded to local file during execution") +} + +func executeRenderScreenshot(runtime *common.RuntimeContext) error { + content := runtime.Str("content") + if strings.TrimSpace(content) == "" { + return slidesScreenshotFlagErrorf("--content cannot be empty") + } + if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 { + return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number") + } + if runtime.Changed("presentation") { + return slidesScreenshotFlagErrorf("--presentation cannot be used with --content") + } + outputDir := runtime.Str("output-dir") + safeOutputDir, err := ensureScreenshotOutputDir(runtime, outputDir) + if err != nil { + return err + } + + data, err := doSlidesScreenshotAPIJSONWithLogID(runtime, "POST", "/open-apis/slides_ai/v1/slide_image/render", larkcore.QueryParams{}, map[string]interface{}{ + "content": content, + }) + if err != nil { + return err + } + saved, err := saveRenderedSlideScreenshot(runtime, data, safeOutputDir, runtime.Str("output-name")) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{ + "output_dir": outputDir, + "screenshots": saved, + }, nil) + return nil +} + +func normalizeSlideIDs(values []string) []string { + out := make([]string, 0, len(values)) + seen := map[string]struct{}{} + for _, v := range values { + s := strings.TrimSpace(v) + if s == "" { + continue + } + if _, ok := seen[s]; ok { + continue + } + seen[s] = struct{}{} + out = append(out, s) + } + return out +} + +func normalizeSlideNumbers(values []int) ([]int, error) { + out := make([]int, 0, len(values)) + seen := map[int]struct{}{} + for _, n := range values { + if n < 1 { + return nil, slidesScreenshotFlagErrorf("--slide-number must be a positive integer") + } + if _, ok := seen[n]; ok { + continue + } + seen[n] = struct{}{} + out = append(out, n) + } + return out, nil +} + +func hasSlideScreenshotSelector(runtime *common.RuntimeContext) bool { + return len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 +} + +func slidesScreenshotFlagErrorf(format string, args ...interface{}) error { + return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...) +} + +func validateScreenshotOutputDir(runtime *common.RuntimeContext, outputDir string) (string, error) { + if _, err := runtime.ResolveSavePath(filepath.Join(outputDir, "probe.png")); err != nil { + return "", slidesScreenshotFlagErrorf("--output-dir invalid: %v", err) + } + return outputDir, nil +} + +func ensureScreenshotOutputDir(runtime *common.RuntimeContext, outputDir string) (string, error) { + return validateScreenshotOutputDir(runtime, outputDir) +} + +func saveSlideScreenshots(runtime *common.RuntimeContext, data map[string]interface{}, outputDir string, presentationID string) ([]map[string]interface{}, error) { + items := common.GetSlice(data, "slide_images") + if len(items) == 0 { + return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned no slide_images") + } + saved := make([]map[string]interface{}, 0, len(items)) + for i, item := range items { + m, ok := item.(map[string]interface{}) + if !ok { + return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned invalid slide_images[%d]", i) + } + item, err := saveSlideScreenshotImage(runtime, m, outputDir, slideScreenshotListFileBase(presentationID, m, i), "") + if err != nil { + if isSlidesScreenshotPassthroughError(err) { + return nil, err + } + return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned invalid slide_images[%d]: %v", i, err) + } + saved = append(saved, item) + } + return saved, nil +} + +func saveRenderedSlideScreenshot(runtime *common.RuntimeContext, data map[string]interface{}, outputDir string, outputName string) ([]map[string]interface{}, error) { + item := common.GetMap(data, "slide_image") + if item == nil { + return nil, slidesScreenshotAPIDataError(data, "slides render screenshot returned no slide_image") + } + saved, err := saveSlideScreenshotImage(runtime, item, outputDir, outputName, "rendered-slide") + if err != nil { + if isSlidesScreenshotPassthroughError(err) { + return nil, err + } + return nil, slidesScreenshotAPIDataError(data, "slides render screenshot returned invalid slide_image: %v", err) + } + return []map[string]interface{}{saved}, nil +} + +func saveSlideScreenshotImage(runtime *common.RuntimeContext, item map[string]interface{}, outputDir string, outputName string, fallbackName string) (map[string]interface{}, error) { + slideID := strings.TrimSpace(common.GetString(item, "slide_id")) + ext, label, err := slideScreenshotFormat(item) + if err != nil { + return nil, slidesScreenshotImageDataError(slideID, "%s", err) + } + encoded := strings.TrimSpace(common.GetString(item, "data")) + if encoded == "" { + return nil, slidesScreenshotImageDataError(slideID, "empty image data") + } + imageBytes, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, slidesScreenshotImageDataCauseError(slideID, err, "decode screenshot: %s", err) + } + fileBase := strings.TrimSpace(outputName) + if fileBase == "" { + fileBase = slideID + } + if fileBase == "" { + fileBase = fallbackName + } + path, err := writeUniqueScreenshotFile(runtime, outputDir, fileBase, ext, imageBytes) + if err != nil { + return nil, err + } + return map[string]interface{}{ + "slide_id": slideID, + "slide_number": slideScreenshotInt(item, "slide_number"), + "format": label, + "path": path, + "size": len(imageBytes), + }, nil +} + +func slideScreenshotListFileBase(presentationID string, item map[string]interface{}, index int) string { + presentationID = strings.TrimSpace(presentationID) + slideID := strings.TrimSpace(common.GetString(item, "slide_id")) + slideNumber := slideScreenshotInt(item, "slide_number") + if presentationID != "" { + switch { + case slideNumber > 0 && slideID != "": + return fmt.Sprintf("%s_p%03d_%s", presentationID, slideNumber, slideID) + case slideNumber > 0: + return fmt.Sprintf("%s_p%03d", presentationID, slideNumber) + case slideID != "": + return fmt.Sprintf("%s_%s", presentationID, slideID) + } + } + if slideID != "" { + return slideID + } + if slideNumber := slideScreenshotInt(item, "slide_number"); slideNumber > 0 { + return fmt.Sprintf("slide-%d", slideNumber) + } + return fmt.Sprintf("slide-%d", index+1) +} + +func slideScreenshotFormat(item map[string]interface{}) (string, string, error) { + format := slideScreenshotInt(item, "format") + switch format { + case 1: + return "png", "png", nil + case 2: + return "jpg", "jpeg", nil + default: + return "", "", errs.NewAPIError(errs.SubtypeInvalidResponse, "unsupported screenshot format %d", format) + } +} + +func slidesScreenshotImageDataError(slideID string, format string, args ...interface{}) error { + msg := fmt.Sprintf(format, args...) + if slideID != "" { + msg = fmt.Sprintf("%s for slide %s", msg, slideID) + } + return errs.NewAPIError(errs.SubtypeInvalidResponse, "%s", msg) +} + +func slidesScreenshotImageDataCauseError(slideID string, cause error, format string, args ...interface{}) error { + msg := fmt.Sprintf(format, args...) + if slideID != "" { + msg = fmt.Sprintf("%s for slide %s", msg, slideID) + } + return errs.NewAPIError(errs.SubtypeInvalidResponse, "%s", msg).WithCause(cause) +} + +func slideScreenshotInt(item map[string]interface{}, key string) int { + n, ok := util.ToFloat64(item[key]) + if !ok { + return 0 + } + return int(n) +} + +func doSlidesScreenshotAPIJSONWithLogID(runtime *common.RuntimeContext, method string, apiPath string, query larkcore.QueryParams, body interface{}) (map[string]interface{}, error) { + req := &larkcore.ApiReq{ + HttpMethod: method, + ApiPath: apiPath, + QueryParams: query, + } + if body != nil { + req.Body = body + } + resp, err := runtime.DoAPI(req) + if err != nil { + return nil, errs.WrapInternal(err) + } + data, err := runtime.ClassifyAPIResponse(resp) + if err != nil { + return data, err + } + if data == nil { + data = map[string]interface{}{} + } + if logID := strings.TrimSpace(resp.Header.Get("x-tt-logid")); logID != "" { + data["log_id"] = logID + } + return data, nil +} + +func enrichSlidesScreenshotSelectorError(err error, slideNumbers []int) error { + if len(slideNumbers) == 0 { + return err + } + p, ok := errs.ProblemOf(err) + if !ok { + return err + } + if p.Hint == "" { + p.Hint = "slide_numbers was rejected by the server; verify the page number exists in this presentation, or retry with --slide-id." + } + return err +} + +func slidesScreenshotAPIDataError(data map[string]interface{}, format string, args ...interface{}) error { + msg := fmt.Sprintf(format, args...) + err := errs.NewAPIError(errs.SubtypeInvalidResponse, "%s; raw_data=%v", msg, summarizeScreenshotAPIData(data)) + if logID := strings.TrimSpace(common.GetString(data, "log_id")); logID != "" { + err = err.WithLogID(logID) + } + return err +} + +func isSlidesScreenshotPassthroughError(err error) bool { + _, ok := errs.ProblemOf(err) + return ok +} + +func summarizeScreenshotAPIData(v interface{}) interface{} { + switch x := v.(type) { + case map[string]interface{}: + out := make(map[string]interface{}, len(x)) + for k, val := range x { + out[k] = summarizeScreenshotAPIData(val) + } + return out + case []interface{}: + out := make([]interface{}, 0, len(x)) + for i, val := range x { + if i >= 20 { + out = append(out, fmt.Sprintf("", len(x)-i)) + break + } + out = append(out, summarizeScreenshotAPIData(val)) + } + return out + case string: + if len(x) > 512 { + return fmt.Sprintf("", len(x), x[:64]) + } + return x + default: + return x + } +} + +func safeScreenshotFileBase(base string) string { + name := unsafeScreenshotFileCharRegex.ReplaceAllString(base, "_") + name = strings.Trim(name, "._-") + if name == "" { + name = "slide" + } + return name +} + +func writeUniqueScreenshotFile(runtime *common.RuntimeContext, outputDir string, fileBase string, ext string, imageBytes []byte) (string, error) { + base := safeScreenshotFileBase(fileBase) + for i := 0; i < 1000; i++ { + candidateBase := base + if i > 0 { + candidateBase = fmt.Sprintf("%s_%d", base, i+1) + } + path := filepath.Join(outputDir, candidateBase+"."+ext) + if _, err := runtime.FileIO().Stat(path); err == nil { + continue + } else if !isScreenshotFileNotExist(err) { + return "", errs.NewInternalError(errs.SubtypeFileIO, "write screenshot %s: %v", path, err).WithCause(err) + } + if _, err := runtime.FileIO().Save(path, fileio.SaveOptions{}, bytes.NewReader(imageBytes)); err != nil { + return "", common.WrapSaveErrorTyped(err) + } + resolvedPath, err := runtime.ResolveSavePath(path) + if err != nil { + return "", errs.NewInternalError(errs.SubtypeFileIO, "resolve saved screenshot path %s: %v", path, err).WithCause(err) + } + return resolvedPath, nil + } + path := filepath.Join(outputDir, base+"."+ext) + return "", errs.NewInternalError(errs.SubtypeFileIO, "write screenshot %s: too many duplicate file names", path) +} + +func isScreenshotFileNotExist(err error) bool { + return os.IsNotExist(err) +} diff --git a/shortcuts/slides/slides_screenshot_test.go b/shortcuts/slides/slides_screenshot_test.go new file mode 100644 index 00000000..8c477b68 --- /dev/null +++ b/shortcuts/slides/slides_screenshot_test.go @@ -0,0 +1,506 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package slides + +import ( + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestSlidesScreenshotDeclaredScopes(t *testing.T) { + got := SlidesScreenshot.DeclaredScopesForIdentity("user") + want := []string{"slides:presentation:screenshot", "wiki:node:read"} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("declared scopes = %#v, want %#v", got, want) + } +} + +func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + + imageBytes := []byte("png-bytes") + jpegBytes := []byte("jpeg-bytes") + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "slide_images": []map[string]interface{}{ + { + "slide_id": "slide_1", + "format": 1, + "data": base64.StdEncoding.EncodeToString(imageBytes), + }, + { + "slide_id": "slide_2", + "slide_number": 2, + "format": 2, + "data": base64.StdEncoding.EncodeToString(jpegBytes), + }, + }, + }, + }, + } + reg.Register(stub) + + err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{ + "+screenshot", + "--presentation", "pres_abc", + "--slide-id", "slide_1", + "--output-dir", "shots", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + path := filepath.Join(dir, "shots", "pres_abc_slide_1.png") + gotBytes, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read screenshot: %v", err) + } + if string(gotBytes) != string(imageBytes) { + t.Fatalf("written bytes = %q, want %q", gotBytes, imageBytes) + } + jpegPath := filepath.Join(dir, "shots", "pres_abc_p002_slide_2.jpg") + gotJPEGBytes, err := os.ReadFile(jpegPath) + if err != nil { + t.Fatalf("read jpeg screenshot: %v", err) + } + if string(gotJPEGBytes) != string(jpegBytes) { + t.Fatalf("written jpeg bytes = %q, want %q", gotJPEGBytes, jpegBytes) + } + if strings.Contains(stdout.String(), base64.StdEncoding.EncodeToString(imageBytes)) { + t.Fatalf("stdout leaked base64 image data: %s", stdout.String()) + } + + data := decodeShortcutData(t, stdout) + if data["xml_presentation_id"] != "pres_abc" { + t.Fatalf("xml_presentation_id = %v", data["xml_presentation_id"]) + } + items, ok := data["screenshots"].([]interface{}) + if !ok || len(items) != 2 { + t.Fatalf("screenshots = %#v, want two items", data["screenshots"]) + } + item, _ := items[0].(map[string]interface{}) + if item["slide_id"] != "slide_1" { + t.Fatalf("slide_id = %v, want slide_1", item["slide_id"]) + } + gotPath := item["path"].(string) + if !filepath.IsAbs(gotPath) { + t.Fatalf("path = %v, want absolute path", gotPath) + } + if !strings.HasSuffix(gotPath, filepath.Join("shots", "pres_abc_slide_1.png")) { + t.Fatalf("path = %v, want shots/pres_abc_slide_1.png suffix", item["path"]) + } + item2, _ := items[1].(map[string]interface{}) + if item2["format"] != "jpeg" { + t.Fatalf("format = %v, want jpeg", item2["format"]) + } + gotPath2 := item2["path"].(string) + if !filepath.IsAbs(gotPath2) { + t.Fatalf("path = %v, want absolute path", gotPath2) + } + if !strings.HasSuffix(gotPath2, filepath.Join("shots", "pres_abc_p002_slide_2.jpg")) { + t.Fatalf("path = %v, want shots/pres_abc_p002_slide_2.jpg suffix", item2["path"]) + } + + var body struct { + SlideIDs []string `json:"slide_ids"` + } + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("decode request body: %v", err) + } + if len(body.SlideIDs) != 1 || body.SlideIDs[0] != "slide_1" { + t.Fatalf("slide_ids = %#v, want [slide_1]", body.SlideIDs) + } +} + +func TestSlidesScreenshotListBySlideNumber(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "slide_images": []map[string]interface{}{ + { + "slide_number": 2, + "format": 1, + "data": base64.StdEncoding.EncodeToString([]byte("png-bytes")), + }, + }, + }, + }, + } + reg.Register(stub) + + err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{ + "+screenshot", + "--presentation", "pres_abc", + "--slide-number", "2", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var body struct { + SlideNumbers []int `json:"slide_numbers"` + } + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("decode request body: %v", err) + } + if len(body.SlideNumbers) != 1 || body.SlideNumbers[0] != 2 { + t.Fatalf("slide_numbers = %#v, want [2]", body.SlideNumbers) + } + path := filepath.Join(dir, defaultSlidesScreenshotDir, "pres_abc_p002.png") + if _, err := os.ReadFile(path); err != nil { + t.Fatalf("read screenshot without slide_id: %v", err) + } +} + +func TestSlidesScreenshotAvoidsOverwritingExistingFile(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + outputDir := filepath.Join(dir, "shots") + if err := os.MkdirAll(outputDir, 0o755); err != nil { + t.Fatalf("create output dir: %v", err) + } + existingPath := filepath.Join(outputDir, "pres_abc_p002.png") + if err := os.WriteFile(existingPath, []byte("existing"), 0o644); err != nil { + t.Fatalf("write existing screenshot: %v", err) + } + + imageBytes := []byte("new-png") + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "slide_images": []map[string]interface{}{ + { + "slide_number": 2, + "format": 1, + "data": base64.StdEncoding.EncodeToString(imageBytes), + }, + }, + }, + }, + }) + + err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{ + "+screenshot", + "--presentation", "pres_abc", + "--slide-number", "2", + "--output-dir", "shots", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + gotExisting, err := os.ReadFile(existingPath) + if err != nil { + t.Fatalf("read existing screenshot: %v", err) + } + if string(gotExisting) != "existing" { + t.Fatalf("existing screenshot = %q, want unchanged", gotExisting) + } + newPath := filepath.Join(outputDir, "pres_abc_p002_2.png") + gotNew, err := os.ReadFile(newPath) + if err != nil { + t.Fatalf("read deduplicated screenshot: %v", err) + } + if string(gotNew) != string(imageBytes) { + t.Fatalf("deduplicated screenshot = %q, want %q", gotNew, imageBytes) + } + data := decodeShortcutData(t, stdout) + items, ok := data["screenshots"].([]interface{}) + if !ok || len(items) != 1 { + t.Fatalf("screenshots = %#v, want one item", data["screenshots"]) + } + item, _ := items[0].(map[string]interface{}) + if !strings.HasSuffix(item["path"].(string), filepath.Join("shots", "pres_abc_p002_2.png")) { + t.Fatalf("path = %v, want shots/pres_abc_p002_2.png suffix", item["path"]) + } +} + +func TestSlidesScreenshotListRequiresSelector(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + + err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{ + "+screenshot", + "--presentation", "pres_abc", + "--as", "user", + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "--slide-id or --slide-number is required") { + t.Fatalf("error = %v, want missing selector error", err) + } +} + +func TestSlidesScreenshotRenderContentWritesFile(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + + content := `` + if err := os.WriteFile(filepath.Join(dir, "slide.xml"), []byte(content), 0o644); err != nil { + t.Fatalf("write input xml: %v", err) + } + imageBytes := []byte("rendered-png") + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/slide_image/render", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "slide_image": map[string]interface{}{ + "slide_id": "render_slide", + "slide_number": 1, + "format": 1, + "data": base64.StdEncoding.EncodeToString(imageBytes), + }, + }, + }, + } + reg.Register(stub) + + err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{ + "+screenshot", + "--content", "@slide.xml", + "--output-dir", "shots", + "--output-name", "preview", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + path := filepath.Join(dir, "shots", "preview.png") + gotBytes, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read rendered screenshot: %v", err) + } + if string(gotBytes) != string(imageBytes) { + t.Fatalf("written bytes = %q, want %q", gotBytes, imageBytes) + } + if strings.Contains(stdout.String(), base64.StdEncoding.EncodeToString(imageBytes)) { + t.Fatalf("stdout leaked base64 image data: %s", stdout.String()) + } + + var body struct { + Content string `json:"content"` + } + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("decode request body: %v", err) + } + if body.Content != content { + t.Fatalf("content = %q, want input XML", body.Content) + } + + data := decodeShortcutData(t, stdout) + items, ok := data["screenshots"].([]interface{}) + if !ok || len(items) != 1 { + t.Fatalf("screenshots = %#v, want one item", data["screenshots"]) + } + item, _ := items[0].(map[string]interface{}) + if !strings.HasSuffix(item["path"].(string), filepath.Join("shots", "preview.png")) { + t.Fatalf("path = %v, want shots/preview.png suffix", item["path"]) + } +} + +func TestSlidesScreenshotRenderRejectsSlideSelectors(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + + err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{ + "+screenshot", + "--content", ``, + "--slide-id", "slide_1", + "--as", "user", + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "--content cannot be used with --slide-id or --slide-number") { + t.Fatalf("error = %v, want content/slide selector conflict", err) + } +} + +func TestSlidesScreenshotRenderRejectsListOnlyFlags(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + + err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{ + "+screenshot", + "--content", ``, + "--presentation", "pres_abc", + "--as", "user", + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "--presentation cannot be used with --content") { + t.Fatalf("error = %v, want presentation/content conflict", err) + } +} + +func TestSlidesScreenshotDryRunSelectsListOrRenderAPI(t *testing.T) { + t.Run("list", func(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{ + "+screenshot", + "--presentation", "pres_abc", + "--slide-number", "2", + "--dry-run", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "/xml_presentations/pres_abc/slide_images") { + t.Fatalf("dry-run missing list endpoint: %s", out) + } + if !strings.Contains(out, "slide_numbers") { + t.Fatalf("dry-run missing slide_numbers body: %s", out) + } + }) + + t.Run("render", func(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{ + "+screenshot", + "--content", ``, + "--dry-run", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "/slide_image/render") { + t.Fatalf("dry-run missing render endpoint: %s", out) + } + if !strings.Contains(out, "base64_output") { + t.Fatalf("dry-run missing base64 suppression note: %s", out) + } + }) +} + +func TestSlidesScreenshotRejectsBadOutputDir(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + + err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{ + "+screenshot", + "--presentation", "pres_abc", + "--slide-id", "slide_1", + "--output-dir", "../outside", + "--as", "user", + }) + if err == nil { + t.Fatal("expected error for unsafe output dir") + } + if !strings.Contains(err.Error(), "--output-dir invalid") { + t.Fatalf("error = %v, want output-dir validation", err) + } +} + +func TestSlidesScreenshotNoImagesErrorIncludesRawDataAndLogID(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images", + Headers: map[string][]string{ + "Content-Type": {"application/json"}, + "X-Tt-Logid": {"log-123"}, + }, + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "unexpected": "shape", + }, + }, + }) + + err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{ + "+screenshot", + "--presentation", "pres_abc", + "--slide-id", "pJJ", + "--as", "user", + }) + if err == nil { + t.Fatal("expected error") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("error type = %T, want typed problem", err) + } + if p.LogID != "log-123" { + t.Fatalf("log_id = %v, want log-123", p.LogID) + } + if !strings.Contains(p.Message, "unexpected:shape") { + t.Fatalf("message = %q, want raw_data summary", p.Message) + } +} + +func TestSlidesScreenshotSlideNumberAPIErrorAddsHint(t *testing.T) { + dir := t.TempDir() + withSlidesTestWorkingDir(t, dir) + + f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "")) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images", + Headers: map[string][]string{ + "Content-Type": {"application/json"}, + "X-Tt-Logid": {"log-slide-number"}, + }, + Body: map[string]interface{}{ + "code": 99992402, + "msg": "field validation failed", + }, + }) + + err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{ + "+screenshot", + "--presentation", "pres_abc", + "--slide-number", "25", + "--as", "user", + }) + if err == nil { + t.Fatal("expected error") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("error type = %T, want typed problem", err) + } + if p.LogID != "log-slide-number" { + t.Fatalf("log_id = %v, want log-slide-number", p.LogID) + } + if !strings.Contains(p.Hint, "--slide-id") { + t.Fatalf("hint = %q, want --slide-id guidance", p.Hint) + } +} diff --git a/skills/lark-slides/SKILL.md b/skills/lark-slides/SKILL.md index 0a1ca3a9..c0b6dac2 100644 --- a/skills/lark-slides/SKILL.md +++ b/skills/lark-slides/SKILL.md @@ -18,6 +18,7 @@ metadata: | 大幅改写页面 | 先回读现有 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` | +| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面 | `slides +screenshot`、`lark-slides-screenshot.md` | | 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides` 的 `@./path` 占位符 | | 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `` 元素 | `xml-schema-quick-ref.md` | | 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) | @@ -82,6 +83,7 @@ lark-cli auth login --domain slides - 创建:[`lark-slides-create.md`](references/lark-slides-create.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-screenshot.md`](references/lark-slides-screenshot.md) - 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md) - 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) - 图标:[`iconpark.md`](references/iconpark.md)、[`scripts/iconpark_tool.py`](scripts/iconpark_tool.py) diff --git a/skills/lark-slides/references/lark-slides-screenshot.md b/skills/lark-slides/references/lark-slides-screenshot.md new file mode 100644 index 00000000..ec4f29c4 --- /dev/null +++ b/skills/lark-slides/references/lark-slides-screenshot.md @@ -0,0 +1,94 @@ +# slides +screenshot + +## 用途 + +获取幻灯片页面截图并保存为本地图片文件。默认用于已存在 PPT 页面截图;传入 `--content` 时用于直接渲染单个 `` XML 片段预览。本 shortcut 会在 CLI 进程内解码并写入文件,stdout 只返回文件路径、大小、页面 ID 等元信息,避免把图片 Base64 输出给模型。 + +注意:该截图能力对应的权限受白名单控制。只有在白名单内的应用才能申请该权限;不在白名单内的应用即使命令和参数正确,服务端仍可能返回权限或能力不可用相关错误。 + +## 命令 + +```bash +lark-cli slides +screenshot --as user \ + --presentation '' \ + --slide-number 1 +``` + +渲染本地 XML 内容: + +```bash +lark-cli slides +screenshot --as user \ + --content @slide.xml +``` + +## 参数 + +| 参数 | 必需 | 说明 | +|------|------|------| +| `--presentation` | list 模式必需 | `xml_presentation_id`、`/slides/` URL,或解析后为 slides 的 `/wiki/` URL。传 `--content` 时不能使用 | +| `--slide-id` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面 short ID;多页截图时重复传入 | +| `--slide-number` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面页号;多页截图时重复传入 | +| `--content` | render 模式必需 | 要直接渲染的 `` XML 片段;支持直接传值、`@file`、`-` stdin。传入后不能同时传 `--slide-id` / `--slide-number` | +| `--output-dir` | 否 | 输出目录,默认 `.lark-slides/screenshots`;必须是当前目录内的相对路径 | +| `--output-name` | 否 | render 模式的输出文件名 stem;未指定时优先用返回的 `slide_id`,否则用 `rendered-slide`。若目标文件已存在,会自动追加递增后缀避免覆盖 | + +## 示例 + +### 单页截图 + +```bash +lark-cli slides +screenshot --as user \ + --presentation slides_example_presentation_id \ + --slide-number 1 +``` + +### 多页截图 + +```bash +lark-cli slides +screenshot --as user \ + --presentation slides_example_presentation_id \ + --slide-number 1 \ + --slide-number 2 \ + --output-dir .lark-slides/screenshots/demo +``` + +### 渲染 XML 预览 + +```bash +lark-cli slides +screenshot --as user \ + --content @.lark-slides/out/demo/slide.xml \ + --output-name preview +``` + +## 返回值 + +返回 JSON 不包含 Base64 图片内容: + +```json +{ + "code": 0, + "data": { + "xml_presentation_id": "slides_example_presentation_id", + "output_dir": ".lark-slides/screenshots", + "screenshots": [ + { + "slide_id": "slide_example_id", + "slide_number": 1, + "format": "png", + "path": "/abs/path/.lark-slides/screenshots/slides_example_presentation_id_p001_slide_example_id.png", + "size": 12345 + } + ] + }, + "msg": "success" +} +``` + +## 注意事项 + +1. 优先使用 `slides +screenshot` 保存本地图片,不要把图片 Base64 打到 stdout。 +2. 已存在 PPT 页面截图时,不传 `--content`,用 `--presentation` + `--slide-id` 或 `--slide-number`。 +3. 本地 XML 预览时,传 `--content @file` 或 `--content -`,内容应为单个 `` XML 片段;此时不要传 `--presentation` / `--slide-id` / `--slide-number`。 +4. `slide_id` 是页面 short ID,页码请用 `--slide-number`。 +5. list 模式默认文件名包含 presentation ID、页码和/或 slide ID;文件已存在时自动追加 `_2`、`_3` 等后缀,避免覆盖旧截图。 +6. 截图来自服务端渲染结果,适合创建/替换后验证页面是否为空白、破图或布局明显异常。