mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
feat(slides):slide screenshot
This commit is contained in:
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,5 +11,6 @@ func Shortcuts() []common.Shortcut {
|
||||
SlidesCreate,
|
||||
SlidesMediaUpload,
|
||||
SlidesReplaceSlide,
|
||||
SlidesScreenshot,
|
||||
}
|
||||
}
|
||||
|
||||
537
shortcuts/slides/slides_screenshot.go
Normal file
537
shortcuts/slides/slides_screenshot.go
Normal file
@@ -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 = "<resolved_slides_token>"
|
||||
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("<xml omitted; length=%d>", 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("<omitted %d more items>", len(x)-i))
|
||||
break
|
||||
}
|
||||
out = append(out, summarizeScreenshotAPIData(val))
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
if len(x) > 512 {
|
||||
return fmt.Sprintf("<omitted string length=%d prefix=%q>", 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)
|
||||
}
|
||||
506
shortcuts/slides/slides_screenshot_test.go
Normal file
506
shortcuts/slides/slides_screenshot_test.go
Normal file
@@ -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 := `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`
|
||||
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 xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
|
||||
"--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", `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
|
||||
"--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", `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
|
||||
"--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)
|
||||
}
|
||||
}
|
||||
@@ -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 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
|
||||
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素 | [`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)
|
||||
|
||||
94
skills/lark-slides/references/lark-slides-screenshot.md
Normal file
94
skills/lark-slides/references/lark-slides-screenshot.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# slides +screenshot
|
||||
|
||||
## 用途
|
||||
|
||||
获取幻灯片页面截图并保存为本地图片文件。默认用于已存在 PPT 页面截图;传入 `--content` 时用于直接渲染单个 `<slide>` XML 片段预览。本 shortcut 会在 CLI 进程内解码并写入文件,stdout 只返回文件路径、大小、页面 ID 等元信息,避免把图片 Base64 输出给模型。
|
||||
|
||||
注意:该截图能力对应的权限受白名单控制。只有在白名单内的应用才能申请该权限;不在白名单内的应用即使命令和参数正确,服务端仍可能返回权限或能力不可用相关错误。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli slides +screenshot --as user \
|
||||
--presentation '<xml_presentation_id 或 slides/wiki URL>' \
|
||||
--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 模式必需 | 要直接渲染的 `<slide>` 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 -`,内容应为单个 `<slide>` XML 片段;此时不要传 `--presentation` / `--slide-id` / `--slide-number`。
|
||||
4. `slide_id` 是页面 short ID,页码请用 `--slide-number`。
|
||||
5. list 模式默认文件名包含 presentation ID、页码和/或 slide ID;文件已存在时自动追加 `_2`、`_3` 等后缀,避免覆盖旧截图。
|
||||
6. 截图来自服务端渲染结果,适合创建/替换后验证页面是否为空白、破图或布局明显异常。
|
||||
Reference in New Issue
Block a user