mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
7 Commits
feat/lark-
...
feat/slide
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7be4205d0 | ||
|
|
9b05a71de3 | ||
|
|
c906fcac7e | ||
|
|
6ddbbafc4f | ||
|
|
bf9264c901 | ||
|
|
e9f8d1d94b | ||
|
|
a520b7ca93 |
@@ -28,7 +28,7 @@ var DriveImport = common.Shortcut{
|
||||
ConditionalScopes: []string{"wiki:node:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md, .base, .pptx; large files auto use multipart upload; .base is capped at 20MB, .pptx at 500MB)", Required: true},
|
||||
{Name: "file", Desc: "local file path (e.g. .docx, .xlsx, .md, .base, .pptx, .pdf; large files auto use multipart upload; .base is capped at 20MB, .pptx/.pdf at 500MB)", Required: true},
|
||||
{Name: "type", Desc: "target document type (docx, sheet, bitable, slides)", Required: true},
|
||||
{Name: "folder-token", Desc: "target folder token (omit for root folder; API accepts empty mount_key as root)"},
|
||||
{Name: "name", Desc: "imported file name (default: local file name without extension)"},
|
||||
|
||||
@@ -45,6 +45,7 @@ var driveImportExtToDocTypes = map[string][]string{
|
||||
"csv": {"sheet", "bitable"},
|
||||
"base": {"bitable"},
|
||||
"pptx": {"slides"},
|
||||
"pdf": {"slides"},
|
||||
}
|
||||
|
||||
// driveImportSpec contains the user-facing import inputs after normalization.
|
||||
@@ -153,7 +154,7 @@ func driveImportFileSizeLimit(filePath, docType string) (int64, bool) {
|
||||
switch strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".") {
|
||||
case "docx", "doc":
|
||||
return driveImport600MBFileSizeLimit, true
|
||||
case "pptx":
|
||||
case "pptx", "pdf":
|
||||
return driveImport500MBFileSizeLimit, true
|
||||
case "txt", "md", "mark", "markdown", "html", "xls", "base":
|
||||
return driveImport20MBFileSizeLimit, true
|
||||
@@ -199,7 +200,7 @@ func validateDriveImportFileSize(filePath, docType string, fileSize int64) error
|
||||
func validateDriveImportSpec(spec driveImportSpec) error {
|
||||
ext := spec.FileExtension()
|
||||
if ext == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must have an extension (e.g. .md, .docx, .xlsx, .pptx)").WithParam("--file")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must have an extension (e.g. .md, .docx, .xlsx, .pptx, .pdf)").WithParam("--file")
|
||||
}
|
||||
|
||||
switch spec.DocType {
|
||||
@@ -210,7 +211,7 @@ func validateDriveImportSpec(spec driveImportSpec) error {
|
||||
|
||||
supportedTypes, ok := driveImportExtToDocTypes[ext]
|
||||
if !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx", ext).WithParam("--file")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported file extension: %s. Supported extensions are: docx, doc, txt, md, mark, markdown, html, xlsx, xls, csv, base, pptx, pdf", ext).WithParam("--file")
|
||||
}
|
||||
|
||||
typeAllowed := false
|
||||
@@ -231,8 +232,8 @@ func validateDriveImportSpec(spec driveImportSpec) error {
|
||||
hint = fmt.Sprintf(".xls files can only be imported as 'sheet', not '%s'", spec.DocType)
|
||||
case "base":
|
||||
hint = fmt.Sprintf(".base files can only be imported as 'bitable', not '%s'", spec.DocType)
|
||||
case "pptx":
|
||||
hint = fmt.Sprintf(".pptx files can only be imported as 'slides', not '%s'", spec.DocType)
|
||||
case "pptx", "pdf":
|
||||
hint = fmt.Sprintf(".%s files can only be imported as 'slides', not '%s'", ext, spec.DocType)
|
||||
default:
|
||||
hint = fmt.Sprintf(".%s files can only be imported as 'docx', not '%s'", ext, spec.DocType)
|
||||
}
|
||||
|
||||
@@ -41,6 +41,10 @@ func TestValidateDriveImportSpec(t *testing.T) {
|
||||
name: "pptx slides ok",
|
||||
spec: driveImportSpec{FilePath: "./deck.pptx", DocType: "slides"},
|
||||
},
|
||||
{
|
||||
name: "pdf slides ok",
|
||||
spec: driveImportSpec{FilePath: "./deck.pdf", DocType: "slides"},
|
||||
},
|
||||
{
|
||||
name: "base non bitable rejected",
|
||||
spec: driveImportSpec{FilePath: "./snapshot.base", DocType: "sheet"},
|
||||
@@ -51,6 +55,11 @@ func TestValidateDriveImportSpec(t *testing.T) {
|
||||
spec: driveImportSpec{FilePath: "./deck.pptx", DocType: "docx"},
|
||||
wantErr: ".pptx files can only be imported as 'slides'",
|
||||
},
|
||||
{
|
||||
name: "pdf non slides rejected",
|
||||
spec: driveImportSpec{FilePath: "./deck.pdf", DocType: "docx"},
|
||||
wantErr: ".pdf files can only be imported as 'slides'",
|
||||
},
|
||||
{
|
||||
name: "unknown extension rejected",
|
||||
spec: driveImportSpec{FilePath: "./data.rtf", DocType: "docx"},
|
||||
@@ -138,6 +147,19 @@ func TestValidateDriveImportFileSize(t *testing.T) {
|
||||
docType: "slides",
|
||||
fileSize: driveImport500MBFileSizeLimit,
|
||||
},
|
||||
{
|
||||
name: "pdf exceeds 500mb limit",
|
||||
filePath: "./deck.pdf",
|
||||
docType: "slides",
|
||||
fileSize: driveImport500MBFileSizeLimit + 1,
|
||||
wantText: "exceeds 500.0 MB import limit for .pdf",
|
||||
},
|
||||
{
|
||||
name: "pdf within 500mb limit",
|
||||
filePath: "./deck.pdf",
|
||||
docType: "slides",
|
||||
fileSize: driveImport500MBFileSizeLimit,
|
||||
},
|
||||
{
|
||||
name: "base exceeds 20mb limit",
|
||||
filePath: "./snapshot.base",
|
||||
|
||||
@@ -11,6 +11,8 @@ func Shortcuts() []common.Shortcut {
|
||||
SlidesCreate,
|
||||
SlidesMediaUpload,
|
||||
SlidesReplaceSlide,
|
||||
SlidesReplacePages,
|
||||
SlidesScreenshot,
|
||||
SlidesXMLGet,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,13 +204,11 @@ var SlidesCreate = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
// Build the presentation URL locally from the token. The brand-standard
|
||||
// host transparently redirects to the tenant domain (same fallback used by
|
||||
// drive +upload / wiki +node-create). This avoids the prior best-effort
|
||||
// drive metas/batch_query call, which needed an extra drive scope and 403'd
|
||||
// for users who only authorized slides scopes — without ever blocking an
|
||||
// otherwise-successful creation.
|
||||
if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
|
||||
// Prefer the URL returned by presentation.create. Fall back to a local
|
||||
// brand-standard URL only when the API omits it.
|
||||
if url := common.GetString(data, "url"); url != "" {
|
||||
result["url"] = url
|
||||
} else if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
|
||||
result["url"] = url
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ func TestSlidesCreateBasic(t *testing.T) {
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_abc123",
|
||||
"revision_id": 1,
|
||||
"url": "https://tenant.example.com/slides/pres_abc123",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -54,10 +55,8 @@ func TestSlidesCreateBasic(t *testing.T) {
|
||||
if data["title"] != "项目汇报" {
|
||||
t.Fatalf("title = %v, want 项目汇报", data["title"])
|
||||
}
|
||||
// URL is built locally from the token (brand-standard host), not fetched from
|
||||
// drive metas, so it is deterministic and needs no drive scope.
|
||||
if data["url"] != "https://www.feishu.cn/slides/pres_abc123" {
|
||||
t.Fatalf("url = %v, want https://www.feishu.cn/slides/pres_abc123", data["url"])
|
||||
if data["url"] != "https://tenant.example.com/slides/pres_abc123" {
|
||||
t.Fatalf("url = %v, want https://tenant.example.com/slides/pres_abc123", data["url"])
|
||||
}
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode")
|
||||
@@ -647,12 +646,12 @@ func TestSlidesCreateWithoutSlidesUnchanged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesCreateURLBuiltLocally verifies the presentation URL is constructed
|
||||
// locally from the token — no drive metas/batch_query call is made, so creation
|
||||
// works for users who only authorized slides scopes. The httpmock registry has no
|
||||
// batch_query stub registered; if the shortcut tried to call it, the request would
|
||||
// fail the test (unregistered stub), proving the URL is built without a drive call.
|
||||
func TestSlidesCreateURLBuiltLocally(t *testing.T) {
|
||||
// TestSlidesCreateURLFallsBackToLocalBuild verifies the presentation URL is
|
||||
// constructed locally from the token when presentation.create omits url — no
|
||||
// drive metas/batch_query call is made, so creation works for users who only
|
||||
// authorized slides scopes. The httpmock registry has no batch_query stub
|
||||
// registered; if the shortcut tried to call it, the request would fail the test.
|
||||
func TestSlidesCreateURLFallsBackToLocalBuild(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
@@ -665,6 +664,7 @@ func TestSlidesCreateURLBuiltLocally(t *testing.T) {
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_local_url",
|
||||
"revision_id": 1,
|
||||
"url": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
426
shortcuts/slides/slides_replace_pages.go
Normal file
426
shortcuts/slides/slides_replace_pages.go
Normal file
@@ -0,0 +1,426 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlidesReplacePages rebuilds multiple pages inside an existing presentation.
|
||||
// It deliberately creates the new page before deleting the old one so a create
|
||||
// failure cannot remove existing user content. The operation is not atomic.
|
||||
const replacePagesInitialRevisionID = -1
|
||||
|
||||
var SlidesReplacePages = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+replace-pages",
|
||||
Description: "Batch rebuild pages inside an existing Slides presentation (create before old page, then delete old page; not atomic)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only"},
|
||||
// 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", Required: true},
|
||||
{Name: "pages", Desc: "JSON array of page replacements (each: {slide_id, content}); supports @file or -", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "continue-on-error", Type: "bool", Desc: "continue with later pages after a create/delete failure; default false"},
|
||||
{Name: "validate-only", Type: "bool", Desc: "validate input and build the create/delete plan without write calls"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
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
|
||||
}
|
||||
}
|
||||
pages, err := parseReplacePages(runtime.Str("pages"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return validateReplacePagesInput(pages)
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
dry := common.NewDryRunAPI()
|
||||
resolved, err := prepareReplacePages(runtime)
|
||||
if err != nil {
|
||||
return dry.Set("error", err.Error())
|
||||
}
|
||||
appendReplacePagesDryRunCalls(dry, resolved)
|
||||
return dry.
|
||||
Set("xml_presentation_id", resolved.PresentationID).
|
||||
Set("pages_count", len(resolved.Plan)).
|
||||
Set("plan", replacePagesPlanOutput(resolved.Plan)).
|
||||
Set("note", "dry-run built a create/delete plan from slide_id inputs; no Slides presentation get/create/delete calls were executed")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
resolved, err := prepareReplacePages(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if runtime.Bool("validate-only") {
|
||||
runtime.Out(map[string]interface{}{
|
||||
"xml_presentation_id": resolved.PresentationID,
|
||||
"pages_count": len(resolved.Plan),
|
||||
"plan": replacePagesPlanOutput(resolved.Plan),
|
||||
"status": "validated",
|
||||
"note": "validate-only checked input and built the create/delete plan; no Slides presentation get/create/delete calls were executed",
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
revisionID := replacePagesInitialRevisionID
|
||||
results := make([]replacePageResult, 0, len(resolved.Plan))
|
||||
for i, item := range resolved.Plan {
|
||||
result, err := replaceOnePage(runtime, resolved.PresentationID, item, revisionID)
|
||||
results = append(results, result)
|
||||
if result.RevisionID != nil {
|
||||
revisionID = *result.RevisionID
|
||||
}
|
||||
if err != nil {
|
||||
if runtime.Bool("continue-on-error") {
|
||||
continue
|
||||
}
|
||||
return appendSlidesProgressHint(err, fmt.Sprintf("slides +replace-pages stopped at item %d/%d; %d page(s) completed before failure; old page is kept when create failed", i+1, len(resolved.Plan), countReplacedPages(results)))
|
||||
}
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"xml_presentation_id": resolved.PresentationID,
|
||||
"pages_count": len(resolved.Plan),
|
||||
"results": replacePageResultsOutput(results),
|
||||
"status": "completed",
|
||||
"summary": replacePagesSummaryOutput(results),
|
||||
"note": "batch replace is not atomic; each page was created before its old page was deleted",
|
||||
}
|
||||
if revisionID != replacePagesInitialRevisionID {
|
||||
out["revision_id"] = revisionID
|
||||
}
|
||||
if hasReplacePageFailures(results) {
|
||||
out["status"] = "partial_failure"
|
||||
return runtime.OutPartialFailure(out, nil)
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
type replacePageInput struct {
|
||||
SlideID string
|
||||
Content string
|
||||
}
|
||||
|
||||
type replacePagePlanItem struct {
|
||||
OldSlideID string
|
||||
Content string
|
||||
Locator string
|
||||
}
|
||||
|
||||
type replacePagesPrepared struct {
|
||||
PresentationID string
|
||||
Plan []replacePagePlanItem
|
||||
}
|
||||
|
||||
type replacePageResult struct {
|
||||
OldSlideID string
|
||||
NewSlideID string
|
||||
Status string
|
||||
Error string
|
||||
RevisionID *int
|
||||
}
|
||||
|
||||
func prepareReplacePages(runtime *common.RuntimeContext) (*replacePagesPrepared, error) {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
presentationID, err := resolvePresentationID(runtime, ref)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pages, err := parseReplacePages(runtime.Str("pages"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateReplacePagesInput(pages); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plan, err := buildReplacePagesPlan(pages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &replacePagesPrepared{PresentationID: presentationID, Plan: plan}, nil
|
||||
}
|
||||
|
||||
func parseReplacePages(raw string) ([]replacePageInput, error) {
|
||||
s := strings.TrimSpace(raw)
|
||||
if s == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages cannot be empty").WithParam("--pages")
|
||||
}
|
||||
var decoded []map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(s), &decoded); err != nil {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages invalid JSON, must be an array of objects: %v", err).WithParam("--pages").WithCause(err)
|
||||
}
|
||||
out := make([]replacePageInput, 0, len(decoded))
|
||||
for i, m := range decoded {
|
||||
p := replacePageInput{}
|
||||
if v, ok := m["slide_number"]; ok {
|
||||
_ = v
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_number is no longer supported; use slide_id", i).WithParam("--pages").WithHint("read current slide IDs first, then pass slide_id for each page replacement")
|
||||
}
|
||||
if v, ok := m["slide_id"]; ok {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_id must be a string", i).WithParam("--pages")
|
||||
}
|
||||
p.SlideID = s
|
||||
}
|
||||
if v, ok := m["content"]; ok {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content must be a string", i).WithParam("--pages")
|
||||
}
|
||||
p.Content = s
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateReplacePagesInput(pages []replacePageInput) error {
|
||||
if len(pages) == 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages must contain at least 1 item").WithParam("--pages")
|
||||
}
|
||||
seenIDs := map[string]bool{}
|
||||
for i, p := range pages {
|
||||
id := strings.TrimSpace(p.SlideID)
|
||||
if id == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].slide_id is required", i).WithParam("--pages")
|
||||
}
|
||||
if seenIDs[id] {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages contains duplicate slide_id %q", id).WithParam("--pages")
|
||||
}
|
||||
seenIDs[id] = true
|
||||
if strings.TrimSpace(p.Content) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content cannot be empty", i).WithParam("--pages")
|
||||
}
|
||||
if err := validateCompleteSlideXML(p.Content); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--pages[%d].content must be a complete <slide> XML element: %v", i, err).WithParam("--pages").WithCause(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCompleteSlideXML(content string) error {
|
||||
dec := xml.NewDecoder(strings.NewReader(content))
|
||||
depth := 0
|
||||
seenRoot := false
|
||||
for {
|
||||
tok, err := dec.Token()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if depth == 0 {
|
||||
if seenRoot {
|
||||
return invalidSlideXMLStructureError("multiple root elements")
|
||||
}
|
||||
if t.Name.Local != "slide" {
|
||||
return invalidSlideXMLStructureError("root element is <%s>, want <slide>", t.Name.Local)
|
||||
}
|
||||
seenRoot = true
|
||||
}
|
||||
depth++
|
||||
case xml.EndElement:
|
||||
depth--
|
||||
case xml.CharData:
|
||||
if depth == 0 && strings.TrimSpace(string(t)) != "" {
|
||||
return invalidSlideXMLStructureError("non-whitespace text outside root element")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !seenRoot {
|
||||
return invalidSlideXMLStructureError("missing root element")
|
||||
}
|
||||
if depth != 0 {
|
||||
return invalidSlideXMLStructureError("unclosed XML element")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func invalidSlideXMLStructureError(format string, args ...interface{}) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
|
||||
}
|
||||
|
||||
func buildReplacePagesPlan(pages []replacePageInput) ([]replacePagePlanItem, error) {
|
||||
plan := make([]replacePagePlanItem, 0, len(pages))
|
||||
for _, page := range pages {
|
||||
id := strings.TrimSpace(page.SlideID)
|
||||
plan = append(plan, replacePagePlanItem{
|
||||
OldSlideID: id,
|
||||
Content: page.Content,
|
||||
Locator: "slide_id",
|
||||
})
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func appendReplacePagesDryRunCalls(dry *common.DryRunAPI, resolved *replacePagesPrepared) {
|
||||
dry.Desc("Batch replace pages in-place: create each new page before old page, then delete old page (not atomic)")
|
||||
for i, item := range resolved.Plan {
|
||||
dry.POST(fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(resolved.PresentationID))).
|
||||
Desc(fmt.Sprintf("[%d/%d] Create replacement before old slide %s", i*2+1, len(resolved.Plan)*2, item.OldSlideID)).
|
||||
Params(map[string]interface{}{"revision_id": "<latest_or_revision_returned_by_previous_step>"}).
|
||||
Body(map[string]interface{}{
|
||||
"slide": map[string]interface{}{"content": item.Content},
|
||||
"before_slide_id": item.OldSlideID,
|
||||
})
|
||||
dry.DELETE(fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(resolved.PresentationID))).
|
||||
Desc(fmt.Sprintf("[%d/%d] Delete old slide %s after create succeeds", i*2+2, len(resolved.Plan)*2, item.OldSlideID)).
|
||||
Params(map[string]interface{}{
|
||||
"slide_id": item.OldSlideID,
|
||||
"revision_id": "<revision_returned_by_create>",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func replaceOnePage(runtime *common.RuntimeContext, presentationID string, item replacePagePlanItem, revisionID int) (replacePageResult, error) {
|
||||
result := replacePageResult{
|
||||
OldSlideID: item.OldSlideID,
|
||||
Status: "pending",
|
||||
}
|
||||
slideURL := fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/slide", validate.EncodePathSegment(presentationID))
|
||||
createData, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
slideURL,
|
||||
map[string]interface{}{"revision_id": revisionID},
|
||||
map[string]interface{}{
|
||||
"slide": map[string]interface{}{"content": item.Content},
|
||||
"before_slide_id": item.OldSlideID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
result.Status = "create_failed"
|
||||
result.Error = err.Error()
|
||||
return result, err
|
||||
}
|
||||
newSlideID := common.GetString(createData, "slide_id")
|
||||
if newSlideID == "" {
|
||||
err := errs.NewInternalError(errs.SubtypeInvalidResponse, "slide.create returned no slide_id for replacement of slide_id %q", item.OldSlideID)
|
||||
result.Status = "create_failed"
|
||||
result.Error = err.Error()
|
||||
return result, err
|
||||
}
|
||||
result.NewSlideID = newSlideID
|
||||
if rev, ok := revisionFromData(createData); ok {
|
||||
revisionID = rev
|
||||
result.RevisionID = &rev
|
||||
}
|
||||
|
||||
deleteData, err := runtime.CallAPITyped(
|
||||
"DELETE",
|
||||
slideURL,
|
||||
map[string]interface{}{
|
||||
"slide_id": item.OldSlideID,
|
||||
"revision_id": revisionID,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
result.Status = "delete_failed"
|
||||
result.Error = err.Error()
|
||||
return result, err
|
||||
}
|
||||
if rev, ok := revisionFromData(deleteData); ok {
|
||||
result.RevisionID = &rev
|
||||
}
|
||||
result.Status = "replaced"
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func revisionFromData(data map[string]interface{}) (int, bool) {
|
||||
if _, ok := data["revision_id"]; !ok {
|
||||
return 0, false
|
||||
}
|
||||
return int(common.GetFloat(data, "revision_id")), true
|
||||
}
|
||||
|
||||
func replacePagesPlanOutput(plan []replacePagePlanItem) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, 0, len(plan))
|
||||
for _, item := range plan {
|
||||
out = append(out, map[string]interface{}{
|
||||
"old_slide_id": item.OldSlideID,
|
||||
"insert_before_slide_id": item.OldSlideID,
|
||||
"locator": item.Locator,
|
||||
"action": "create_before_then_delete_old",
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func replacePageResultsOutput(results []replacePageResult) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, 0, len(results))
|
||||
for _, result := range results {
|
||||
m := map[string]interface{}{
|
||||
"old_slide_id": result.OldSlideID,
|
||||
"status": result.Status,
|
||||
}
|
||||
if result.NewSlideID != "" {
|
||||
m["new_slide_id"] = result.NewSlideID
|
||||
}
|
||||
if result.Error != "" {
|
||||
m["error"] = result.Error
|
||||
}
|
||||
if result.RevisionID != nil {
|
||||
m["revision_id"] = *result.RevisionID
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func replacePagesSummaryOutput(results []replacePageResult) map[string]interface{} {
|
||||
replaced := countReplacedPages(results)
|
||||
return map[string]interface{}{
|
||||
"replaced": replaced,
|
||||
"failed": len(results) - replaced,
|
||||
"total": len(results),
|
||||
}
|
||||
}
|
||||
|
||||
func countReplacedPages(results []replacePageResult) int {
|
||||
n := 0
|
||||
for _, result := range results {
|
||||
if result.Status == "replaced" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func hasReplacePageFailures(results []replacePageResult) bool {
|
||||
for _, result := range results {
|
||||
if result.Status == "create_failed" || result.Status == "delete_failed" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
341
shortcuts/slides/slides_replace_pages_test.go
Normal file
341
shortcuts/slides/slides_replace_pages_test.go
Normal file
@@ -0,0 +1,341 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestReplacePagesDeclaredScopes(t *testing.T) {
|
||||
if got := SlidesReplacePages.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
|
||||
t.Fatalf("user preflight scopes = %#v, want slides update/write_only only", got)
|
||||
}
|
||||
if got := SlidesReplacePages.ScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
|
||||
t.Fatalf("bot preflight scopes = %#v, want slides update/write_only only", got)
|
||||
}
|
||||
|
||||
got := SlidesReplacePages.DeclaredScopesForIdentity("user")
|
||||
want := []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("declared scopes = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesCreatesBeforeThenDeletesOld(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
var requestOrder []string
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"slide_id": "new2", "revision_id": 11},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
requestOrder = append(requestOrder, req.Method)
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
var deleteQuery map[string][]string
|
||||
deleteStub := &httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"revision_id": 12},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
requestOrder = append(requestOrder, req.Method)
|
||||
deleteQuery = req.URL.Query()
|
||||
},
|
||||
}
|
||||
reg.Register(deleteStub)
|
||||
|
||||
pages := `[{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", pages,
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var createBody struct {
|
||||
Slide struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"slide"`
|
||||
BeforeSlideID string `json:"before_slide_id"`
|
||||
}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &createBody); err != nil {
|
||||
t.Fatalf("decode create body: %v\nraw=%s", err, createStub.CapturedBody)
|
||||
}
|
||||
if createBody.BeforeSlideID != "old2" {
|
||||
t.Fatalf("before_slide_id = %q, want old2", createBody.BeforeSlideID)
|
||||
}
|
||||
if !strings.Contains(createBody.Slide.Content, "<slide") {
|
||||
t.Fatalf("create content = %q", createBody.Slide.Content)
|
||||
}
|
||||
if !reflect.DeepEqual(requestOrder, []string{"POST", "DELETE"}) {
|
||||
t.Fatalf("request order = %#v, want POST then DELETE", requestOrder)
|
||||
}
|
||||
deleteURL := string(deleteStub.CapturedBody)
|
||||
if deleteURL != "" {
|
||||
t.Fatalf("delete body = %q, want empty", deleteURL)
|
||||
}
|
||||
if got := deleteQuery["slide_id"]; !reflect.DeepEqual(got, []string{"old2"}) {
|
||||
t.Fatalf("delete slide_id = %#v, want old2", got)
|
||||
}
|
||||
if got := deleteQuery["revision_id"]; !reflect.DeepEqual(got, []string{"11"}) {
|
||||
t.Fatalf("delete revision_id = %#v, want 11 from create response", got)
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v", data["xml_presentation_id"])
|
||||
}
|
||||
if data["revision_id"] != float64(12) {
|
||||
t.Fatalf("revision_id = %v, want 12", data["revision_id"])
|
||||
}
|
||||
summary, _ := data["summary"].(map[string]interface{})
|
||||
if summary["failed"] != float64(0) {
|
||||
t.Fatalf("summary.failed = %v, want 0", summary["failed"])
|
||||
}
|
||||
results, _ := data["results"].([]interface{})
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("results len = %d, want 1", len(results))
|
||||
}
|
||||
first, _ := results[0].(map[string]interface{})
|
||||
if first["old_slide_id"] != "old2" || first["new_slide_id"] != "new2" || first["status"] != "replaced" {
|
||||
t.Fatalf("result = %#v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesContinueOnErrorReturnsPartialFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
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",
|
||||
Body: map[string]interface{}{
|
||||
"code": 3350001,
|
||||
"msg": "invalid param",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"slide_id": "new2", "revision_id": 11},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"revision_id": 12},
|
||||
},
|
||||
})
|
||||
|
||||
pages := `[
|
||||
{"slide_id":"old1","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"},
|
||||
{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}
|
||||
]`
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", pages,
|
||||
"--continue-on-error",
|
||||
"--as", "user",
|
||||
})
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("err = %T %v, want *output.PartialFailureError", err, err)
|
||||
}
|
||||
|
||||
env := decodeReplacePagesEnvelope(t, stdout)
|
||||
if env.OK {
|
||||
t.Fatalf("stdout ok = true, want false for partial failure")
|
||||
}
|
||||
data := env.Data
|
||||
if data["status"] != "partial_failure" {
|
||||
t.Fatalf("status = %v, want partial_failure", data["status"])
|
||||
}
|
||||
summary, _ := data["summary"].(map[string]interface{})
|
||||
if summary["replaced"] != float64(1) || summary["failed"] != float64(1) || summary["total"] != float64(2) {
|
||||
t.Fatalf("summary = %#v, want replaced=1 failed=1 total=2", summary)
|
||||
}
|
||||
results, _ := data["results"].([]interface{})
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("results len = %d, want 2", len(results))
|
||||
}
|
||||
first, _ := results[0].(map[string]interface{})
|
||||
second, _ := results[1].(map[string]interface{})
|
||||
if first["status"] != "create_failed" {
|
||||
t.Fatalf("first status = %v, want create_failed", first["status"])
|
||||
}
|
||||
if second["status"] != "replaced" || second["new_slide_id"] != "new2" {
|
||||
t.Fatalf("second result = %#v, want replaced with new2", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesContinueOnErrorDeleteFailureIncludesNewSlideID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
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",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"slide_id": "new1", "revision_id": 11},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "DELETE",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 3350001,
|
||||
"msg": "invalid param",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
pages := `[{"slide_id":"old1","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", pages,
|
||||
"--continue-on-error",
|
||||
"--as", "user",
|
||||
})
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("err = %T %v, want *output.PartialFailureError", err, err)
|
||||
}
|
||||
|
||||
env := decodeReplacePagesEnvelope(t, stdout)
|
||||
if env.OK {
|
||||
t.Fatalf("stdout ok = true, want false for partial failure")
|
||||
}
|
||||
results, _ := env.Data["results"].([]interface{})
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("results len = %d, want 1", len(results))
|
||||
}
|
||||
first, _ := results[0].(map[string]interface{})
|
||||
if first["status"] != "delete_failed" {
|
||||
t.Fatalf("status = %v, want delete_failed", first["status"])
|
||||
}
|
||||
if first["new_slide_id"] != "new1" {
|
||||
t.Fatalf("new_slide_id = %v, want new1", first["new_slide_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesDryRunPlansOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
pages := `[{"slide_id":"old2","content":"<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"}]`
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", pages,
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var out map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
if out["xml_presentation_id"] != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v", out["xml_presentation_id"])
|
||||
}
|
||||
plan, _ := out["plan"].([]interface{})
|
||||
if len(plan) != 1 {
|
||||
t.Fatalf("plan len = %d, want 1", len(plan))
|
||||
}
|
||||
item, _ := plan[0].(map[string]interface{})
|
||||
if item["old_slide_id"] != "old2" || item["action"] != "create_before_then_delete_old" {
|
||||
t.Fatalf("plan item = %#v", item)
|
||||
}
|
||||
api, _ := out["api"].([]interface{})
|
||||
if len(api) != 2 {
|
||||
t.Fatalf("api len = %d, want create/delete plan", len(api))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesValidationParam(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pages string
|
||||
}{
|
||||
{"empty pages", `[]`},
|
||||
{"slide number no longer supported", `[{"slide_number":1,"content":"<slide/>"}]`},
|
||||
{"no locator", `[{"content":"<slide/>"}]`},
|
||||
{"empty content", `[{"slide_id":"s1","content":" "}]`},
|
||||
{"not slide XML", `[{"slide_id":"s1","content":"<shape/>"}]`},
|
||||
{"duplicate id", `[{"slide_id":"s1","content":"<slide/>"},{"slide_id":"s1","content":"<slide/>"}]`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesReplacePages, []string{
|
||||
"+replace-pages",
|
||||
"--presentation", "pres_abc",
|
||||
"--pages", tt.pages,
|
||||
"--as", "user",
|
||||
})
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %v, want *errs.ValidationError", err)
|
||||
}
|
||||
if ve.Param != "--pages" {
|
||||
t.Fatalf("Param = %q, want --pages", ve.Param)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type replacePagesEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
func decodeReplacePagesEnvelope(t *testing.T, stdout interface{ Bytes() []byte }) replacePagesEnvelope {
|
||||
t.Helper()
|
||||
var env replacePagesEnvelope
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode output: %v\nraw=%s", err, string(stdout.Bytes()))
|
||||
}
|
||||
if env.Data == nil {
|
||||
t.Fatalf("missing data: %#v", env)
|
||||
}
|
||||
return env
|
||||
}
|
||||
@@ -43,8 +43,10 @@ var SlidesReplaceSlide = common.Shortcut{
|
||||
Command: "+replace-slide",
|
||||
Description: "Replace elements on a slide via block_replace / block_insert parts (auto-injects id + <content/> on shape elements)",
|
||||
Risk: "write",
|
||||
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only"},
|
||||
// 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", Required: true},
|
||||
{Name: "slide-id", Desc: "slide page identifier (slide_id)", Required: true},
|
||||
@@ -53,9 +55,15 @@ var SlidesReplaceSlide = common.Shortcut{
|
||||
{Name: "tid", Desc: "transaction id for concurrent-edit locking (usually empty)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parsePresentationRef(runtime.Str("presentation")); err != nil {
|
||||
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 strings.TrimSpace(runtime.Str("slide-id")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--slide-id cannot be empty").WithParam("--slide-id")
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -15,6 +16,21 @@ import (
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestReplaceSlideDeclaredScopes(t *testing.T) {
|
||||
if got := SlidesReplaceSlide.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
|
||||
t.Fatalf("user preflight scopes = %#v, want slides update/write_only only", got)
|
||||
}
|
||||
if got := SlidesReplaceSlide.ScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{"slides:presentation:update", "slides:presentation:write_only"}) {
|
||||
t.Fatalf("bot preflight scopes = %#v, want slides update/write_only only", got)
|
||||
}
|
||||
|
||||
got := SlidesReplaceSlide.DeclaredScopesForIdentity("user")
|
||||
want := []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("declared scopes = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReplaceSlideBlockReplaceInjectsID is the core regression: users write
|
||||
// <shape>…</shape> as replacement and the CLI must stitch id="<block_id>"
|
||||
// onto the root before sending. The backend returns 3350001 otherwise.
|
||||
|
||||
@@ -34,7 +34,9 @@ var SlidesScreenshot = common.Shortcut{
|
||||
Command: "+screenshot",
|
||||
Description: "Save slide screenshots to local files without printing Base64 image data",
|
||||
Risk: "read",
|
||||
Scopes: []string{"slides:presentation:screenshot"},
|
||||
Scopes: []string{},
|
||||
// The screenshot API is allowlist-gated for only a few apps, so do not
|
||||
// advertise/preflight its scope. Let the API fail and let callers degrade.
|
||||
// wiki:node:read is required only when --presentation is a wiki URL.
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
|
||||
@@ -17,11 +17,23 @@ import (
|
||||
)
|
||||
|
||||
func TestSlidesScreenshotDeclaredScopes(t *testing.T) {
|
||||
if got := SlidesScreenshot.ScopesForIdentity("user"); len(got) != 0 {
|
||||
t.Fatalf("user preflight scopes = %#v, want empty", got)
|
||||
}
|
||||
if got := SlidesScreenshot.ScopesForIdentity("bot"); len(got) != 0 {
|
||||
t.Fatalf("bot preflight scopes = %#v, want empty", got)
|
||||
}
|
||||
|
||||
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] {
|
||||
want := []string{"wiki:node:read"}
|
||||
if len(got) != len(want) || got[0] != want[0] {
|
||||
t.Fatalf("declared scopes = %#v, want %#v", got, want)
|
||||
}
|
||||
for _, scope := range got {
|
||||
if scope == "slides:presentation:screenshot" {
|
||||
t.Fatalf("declared scopes must not advertise screenshot scope: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) {
|
||||
|
||||
144
shortcuts/slides/slides_xml_get.go
Normal file
144
shortcuts/slides/slides_xml_get.go
Normal file
@@ -0,0 +1,144 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// SlidesXMLGet fetches the full XML presentation content and writes it to a
|
||||
// local file, keeping the terminal output small for large decks.
|
||||
var SlidesXMLGet = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+xml-get",
|
||||
Description: "Fetch full presentation XML and save it to a local file",
|
||||
Risk: "read",
|
||||
Scopes: []string{"slides:presentation:read"},
|
||||
// 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", Required: true},
|
||||
{Name: "output", Desc: "local XML output path; existing file is overwritten", Required: true},
|
||||
{Name: "revision-id", Type: "int", Default: "-1", Desc: "presentation revision_id; -1 means latest"},
|
||||
{Name: "remove-attr-id", Type: "bool", Desc: "remove XML id attributes in the returned content; useful for read-only inspection, not precise block editing"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
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 strings.TrimSpace(runtime.Str("output")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output cannot be empty").WithParam("--output")
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(runtime.Str("output")); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output invalid: %v", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
if runtime.Int("revision-id") < -1 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--revision-id must be -1 or a non-negative integer").WithParam("--revision-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
presentationID := ref.Token
|
||||
dry := common.NewDryRunAPI()
|
||||
if ref.Kind == "wiki" {
|
||||
presentationID = "<resolved_slides_token>"
|
||||
dry.Desc("2-step orchestration: resolve wiki → fetch full presentation XML").
|
||||
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("Fetch full presentation XML and save it to a local file")
|
||||
}
|
||||
params := map[string]interface{}{
|
||||
"revision_id": runtime.Int("revision-id"),
|
||||
}
|
||||
if runtime.Bool("remove-attr-id") {
|
||||
params["remove_attr_id"] = true
|
||||
}
|
||||
dry.GET(fmt.Sprintf(
|
||||
"/open-apis/slides_ai/v1/xml_presentations/%s",
|
||||
validate.EncodePathSegment(presentationID),
|
||||
)).
|
||||
Params(params)
|
||||
return dry.Set("output", runtime.Str("output")).Set("stdout_content", "suppressed; XML content is saved to --output during execution")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
presentationID, err := resolvePresentationID(runtime, ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"revision_id": runtime.Int("revision-id"),
|
||||
}
|
||||
if runtime.Bool("remove-attr-id") {
|
||||
params["remove_attr_id"] = true
|
||||
}
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s", validate.EncodePathSegment(presentationID)),
|
||||
params,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
presentation := common.GetMap(data, "xml_presentation")
|
||||
content := common.GetString(presentation, "content")
|
||||
if content == "" {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "slides xml get returned empty xml_presentation.content")
|
||||
}
|
||||
outputPath := runtime.Str("output")
|
||||
result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{
|
||||
ContentType: "application/xml",
|
||||
ContentLength: int64(len(content)),
|
||||
}, bytes.NewReader([]byte(content)))
|
||||
if err != nil {
|
||||
return common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
resolvedPath, err := runtime.ResolveSavePath(outputPath)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "resolve saved XML path %s: %v", outputPath, err).WithCause(err)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"xml_presentation_id": presentationID,
|
||||
"path": resolvedPath,
|
||||
"size": result.Size(),
|
||||
"content_saved": true,
|
||||
}
|
||||
if revisionID := common.GetFloat(presentation, "revision_id"); revisionID > 0 {
|
||||
out["revision_id"] = int(revisionID)
|
||||
}
|
||||
if runtime.Bool("remove-attr-id") {
|
||||
out["remove_attr_id"] = true
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
165
shortcuts/slides/slides_xml_get_test.go
Normal file
165
shortcuts/slides/slides_xml_get_test.go
Normal file
@@ -0,0 +1,165 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestSlidesXMLGetWritesContentToFileAndSuppressesXML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
xml := `<presentation><slide id="s1"><shape id="a">hello</shape></slide></presentation>`
|
||||
var capturedQuery url.Values
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"presentation_id": "pres_abc",
|
||||
"revision_id": 7,
|
||||
"content": xml,
|
||||
},
|
||||
},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.Query()
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--output", "readback.xml",
|
||||
"--revision-id", "7",
|
||||
"--remove-attr-id",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "readback.xml")
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read saved XML: %v", err)
|
||||
}
|
||||
if string(got) != xml {
|
||||
t.Fatalf("saved XML = %q, want %q", got, xml)
|
||||
}
|
||||
if strings.Contains(stdout.String(), xml) {
|
||||
t.Fatalf("stdout leaked full XML content: %s", stdout.String())
|
||||
}
|
||||
if got := capturedQuery.Get("revision_id"); got != "7" {
|
||||
t.Fatalf("revision_id query = %q, want 7", got)
|
||||
}
|
||||
if got := capturedQuery.Get("remove_attr_id"); got != "true" {
|
||||
t.Fatalf("remove_attr_id query = %q, want true", got)
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_abc", data["xml_presentation_id"])
|
||||
}
|
||||
if data["revision_id"] != float64(7) {
|
||||
t.Fatalf("revision_id = %v, want 7", data["revision_id"])
|
||||
}
|
||||
if data["size"] != float64(len(xml)) {
|
||||
t.Fatalf("size = %v, want %d", data["size"], len(xml))
|
||||
}
|
||||
gotPath, _ := data["path"].(string)
|
||||
if !filepath.IsAbs(gotPath) {
|
||||
t.Fatalf("path = %v, want absolute path", gotPath)
|
||||
}
|
||||
if !strings.HasSuffix(gotPath, "readback.xml") {
|
||||
t.Fatalf("path = %v, want readback.xml suffix", gotPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetResolvesWikiPresentation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "slides",
|
||||
"obj_token": "pres_real",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_real",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation": map[string]interface{}{
|
||||
"content": `<presentation/>`,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "https://example.feishu.cn/wiki/wikcn123",
|
||||
"--output", "wiki.xml",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_real" {
|
||||
t.Fatalf("xml_presentation_id = %v, want pres_real", data["xml_presentation_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLGetRejectsUnsafeOutputPath(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesXMLGet, []string{
|
||||
"+xml-get",
|
||||
"--presentation", "pres_abc",
|
||||
"--output", "../readback.xml",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unsafe output path error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryValidation)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
|
||||
}
|
||||
if validationErr.Param != "--output" {
|
||||
t.Fatalf("param = %q, want --output", validationErr.Param)
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ metadata:
|
||||
- 用户给出 doubao.com 的云空间资源 URL/token,或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时,仍按资源类型、URL 路径和 token 路由到本 skill;不要因为域名不是飞书而回退到 WebFetch。
|
||||
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
|
||||
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。
|
||||
- 用户要把本地 `.pptx` 导入成飞书幻灯片,使用 `lark-cli drive +import --type slides`;当前 PPTX 导入上限是 500MB。
|
||||
- 用户要把本地 `.pptx` / `.pdf` 导入成飞书幻灯片,使用 `lark-cli drive +import --type slides`;当前 PPTX/PDF 导入上限是 500MB。
|
||||
- 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx),切到 [`lark-markdown`](../lark-markdown/SKILL.md)。
|
||||
- 用户要比较原生 `.md` 文件的**历史版本差异**,或比较远端 Markdown 与本地草稿,切到 [`lark-markdown`](../lark-markdown/SKILL.md) 的 `lark-cli markdown +diff`;需要版本号时先用 `drive +version-history`。
|
||||
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history`、`drive +version-get`、`drive +version-revert`、`drive +version-delete`;这组命令同时支持 `--as user` 和 `--as bot`,自动化场景优先 `--as bot`。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
将本地文件(如 Word、TXT、Markdown、Excel、PPTX 等)导入并转换为飞书在线云文档(docx、sheet、bitable、slides)。底层统一通过 `POST /open-apis/drive/v1/import_tasks` 接口创建导入任务,并在 shortcut 内做有限次数轮询 `GET /open-apis/drive/v1/import_tasks/:ticket`。
|
||||
将本地文件(如 Word、TXT、Markdown、Excel、PPTX、PDF 等)导入并转换为飞书在线云文档(docx、sheet、bitable、slides)。底层统一通过 `POST /open-apis/drive/v1/import_tasks` 接口创建导入任务,并在 shortcut 内做有限次数轮询 `GET /open-apis/drive/v1/import_tasks/:ticket`。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 当用户说“把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable 文档”时,第一步必须使用 `drive +import --type bitable`。
|
||||
@@ -45,8 +45,9 @@ lark-cli drive +import --file ./crm.xlsx --type bitable --name "客户台账"
|
||||
# 导入 .base 快照为多维表格 / Base (bitable)(文件不能超过 20MB)
|
||||
lark-cli drive +import --file ./snapshot.base --type bitable --name "快照还原"
|
||||
|
||||
# 导入 PPTX 为飞书幻灯片 (slides)(文件不能超过 500MB)
|
||||
# 导入 PPTX / PDF 为飞书幻灯片 (slides)(文件不能超过 500MB)
|
||||
lark-cli drive +import --file ./deck.pptx --type slides --name "项目汇报"
|
||||
lark-cli drive +import --file ./deck.pdf --type slides --name "项目汇报"
|
||||
|
||||
# 导入到指定文件夹,并指定导入后的文件名
|
||||
lark-cli drive +import --file ./data.csv --type bitable --folder-token <FOLDER_TOKEN> --name "导入数据表"
|
||||
@@ -94,6 +95,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
|
||||
| `.csv` | `sheet`, `bitable` | CSV 数据文件 |
|
||||
| `.base` | `bitable` | 多维表格快照文件 |
|
||||
| `.pptx` | `slides` | Microsoft PowerPoint 演示文稿 |
|
||||
| `.pdf` | `slides` | PDF 文档 |
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 用户口头说的 “Base” / “多维表格” / “bitable”,在命令里统一对应 `--type bitable`。
|
||||
@@ -103,7 +105,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
|
||||
> - `.xlsx` / `.csv` 文件**只能**导入为 `sheet` 或 `bitable`
|
||||
> - `.xls` 文件**只能**导入为 `sheet`
|
||||
> - `.base` 文件**只能**导入为 `bitable`
|
||||
> - `.pptx` 文件**只能**导入为 `slides`
|
||||
> - `.pptx` / `.pdf` 文件**只能**导入为 `slides`
|
||||
> - 例如:`.csv` 文件不能导入为 `docx`,`.md` 文件不能导入为 `sheet`
|
||||
|
||||
> [!IMPORTANT]
|
||||
@@ -137,7 +139,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
|
||||
| `.csv` | `bitable` | 100MB |
|
||||
| `.xls` | `sheet` | 20MB |
|
||||
| `.base` | `bitable` | 20MB |
|
||||
| `.pptx` | `slides` | 500MB |
|
||||
| `.pptx`, `.pdf` | `slides` | 500MB |
|
||||
|
||||
- 如果文件超出对应上限,shortcut 会在真正上传前直接返回验证错误。
|
||||
- “超过 20MB 自动切换分片上传”只表示上传链路会切到 multipart,不代表所有格式都允许导入超过 20MB 的文件。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-slides
|
||||
version: 1.0.0
|
||||
description: "飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。不负责:云文档内容编辑(走 lark-doc)、云文档里的独立画板对象(走 lark-whiteboard,注意 slide 内嵌的流程图/架构图仍属本 skill)、上传或下载普通文件(走 lark-drive)。"
|
||||
description: "飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用;当用户给定 PPTX/PDF/existing Slides 作为模板、底稿或二创对象时,也用本 skill 统筹导入后的二次创作(导入命令本身走 `lark-drive` 的 `drive +import --type slides`)。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。不负责:云文档内容编辑(走 lark-doc)、云文档里的独立画板对象(走 lark-whiteboard,注意 slide 内嵌的流程图/架构图仍属本 skill)、上传或下载普通文件(走 lark-drive)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -14,8 +14,8 @@ metadata:
|
||||
|
||||
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|
||||
|----------|----------|-----------------|
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` |
|
||||
| 大幅改写页面 | 先回读现有 XML,写入新 plan,再替换或重建相关页面 | `xml_presentations.get`、`+replace-slide`、`lark-slides-edit-workflows.md` |
|
||||
| 新建 PPT、从空白生成、明确重设计 | 走 Create Workflow:先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` |
|
||||
| 用户提供 PPTX/PDF/slides、existing Slides、模板/底稿/原 PPT 二创 | 走 Template Rewrite Workflow:导入/回读成 `source.xml`,从源 XML 生成 replacement slides,经 `pages.json` 执行 `+replace-pages`,再保存 `readback.xml` 验证 | `template-rewrite-workflow.md`、`xml_presentations.get`、`lark-slides-replace-pages.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` |
|
||||
@@ -23,31 +23,37 @@ metadata:
|
||||
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
|
||||
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) |
|
||||
| 使用语义图标 | 先检索 IconPark,再写 `<icon iconType="...">` | `iconpark_tool.py search → resolve`、`iconpark.md` |
|
||||
| 用户提到模板、主题、版式 | 先检索模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
|
||||
| 用户提到模板、主题、版式但没有提供本地/在线模板材料 | 先检索内置模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
|
||||
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md`、`validation-checklist.md` |
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),认证、权限和全局参数均以 lark-shared 为准。**
|
||||
|
||||
**CRITICAL — 生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录,规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
|
||||
**CRITICAL — Create Workflow(新建演示文稿、从空白生成、用户明确要求重设计、没有模板保留诉求)MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录,规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,生成 XML 前 MUST 读取 [visual-planning.md](references/visual-planning.md),确保 `layout_type`、`visual_focus`、`text_density` 实际改变页面几何、主视觉和文本量。**
|
||||
**CRITICAL — Create Workflow 生成 XML 前 MUST 读取 [visual-planning.md](references/visual-planning.md),确保 `layout_type`、`visual_focus`、`text_density` 实际改变页面几何、主视觉和文本量。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**
|
||||
**CRITICAL — Create Workflow 规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。**
|
||||
|
||||
**CRITICAL — 创建或大幅改写后,MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险;XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)。**
|
||||
**CRITICAL — Template Rewrite Workflow(用户提供 PPTX/PDF/slides、existing Slides、要求基于模板/底稿/原 PPT 二创或保留原版式)MUST 读取 [template-rewrite-workflow.md](references/template-rewrite-workflow.md)。不要读取 `planning-layer.md`、`visual-planning.md`、`asset-planning.md` 来生成二创 plan;不要生成 `slide_plan.json`、`page_rewrite_plan.json`、`rewrite_manifest.json`。固定数据流是 `source.xml -> pages.json -> slides +replace-pages -> readback.xml validation`。**
|
||||
|
||||
**CRITICAL — Template Rewrite 不允许用 `python-pptx` / PowerPoint 自动化清空模板页后从 blank layout 重画,也不允许生成一个只继承模板尺寸/主题色的本地 PPTX 再导入作为最终产物。模板二创必须以 `source.xml` 的每页真实 XML 为骨架;如果 `source.xml` 不可得,停止并说明该工作流被阻塞,不能伪装成模板二创。**
|
||||
|
||||
**CRITICAL — Template Rewrite 必须做 source-connected rewrite:新内容要进入源页已有 text container、图形标签、节点、箭头、时间线、图表/table 或注释容器。不要把模板当背景,再覆盖通用顶栏、三卡片、2x2 卡片、大白卡或重复组件系统;源页 dominant structure 必须继续承载内容。**
|
||||
|
||||
**CRITICAL — 创建、Template Rewrite 或大幅改写后,MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险;XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)。**
|
||||
|
||||
**CRITICAL — 创建前自检或失败排障时,MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
|
||||
|
||||
**CRITICAL — 如果用户提到“模板”“套用模板”“参考某种主题/风格/版式”,或用户需求明显落在已有场景模板内(如工作汇报、产品介绍、商业计划书、培训、晋升汇报等),MUST 先用 [`scripts/template_tool.py`](scripts/template_tool.py) 的 `search` 做模板检索;默认给出 2-3 个最匹配模板候选供用户选择。锁定模板后用 `summarize` 获取主题和布局摘要;只有需要布局骨架时才用 `extract` 裁切目标页型 XML。不要直接读取完整模板 XML。**
|
||||
**CRITICAL — 如果用户提到“模板”“套用模板”“参考某种主题/风格/版式”,但没有提供本地/在线模板材料,MUST 先用 [`scripts/template_tool.py`](scripts/template_tool.py) 的 `search` 做内置模板检索;默认给出 2-3 个最匹配模板候选供用户选择。锁定模板后用 `summarize` 获取主题和布局摘要;只有需要布局骨架时才用 `extract` 裁切目标页型 XML。不要直接读取完整模板 XML。**
|
||||
|
||||
> [!NOTE]
|
||||
> `scripts/template_tool.py` 需要 Python 3。`references/template-index.json` 是脚本缓存/轻量路由索引,不是默认给 agent 阅读的文档;`assets/templates/*.xml` 是机器资源,只应通过脚本摘要或裁切,不要全文读取。
|
||||
|
||||
**CRITICAL — 使用模板生成或改写页面时,MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。**
|
||||
**CRITICAL — 使用内置模板生成或改写页面时,MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。用户提供真实 PPTX/PDF/slides/existing Slides 时不要走内置模板工具,走 Template Rewrite Workflow。**
|
||||
|
||||
**编辑已有幻灯片页面**:优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
|
||||
**编辑已有幻灯片页面**:模板二创、页面级重写、导入底稿改写优先用 [`+replace-pages`](references/lark-slides-replace-pages.md) 在原 presentation 内重建页面,避免 `slides +create` 生成新链接;单个标题、文本块、图片或局部元素才用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序)。选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
|
||||
|
||||
## 身份选择
|
||||
|
||||
@@ -74,24 +80,77 @@ lark-cli auth login --domain slides
|
||||
高频只读:
|
||||
|
||||
- [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md)
|
||||
- [planning-layer.md](references/planning-layer.md)(新建 / 大幅改写)
|
||||
- [visual-planning.md](references/visual-planning.md)(新建 / 大幅改写)
|
||||
- [asset-planning.md](references/asset-planning.md)(新建 / 大幅改写)
|
||||
- [validation-checklist.md](references/validation-checklist.md)(创建 / 大幅改写后)
|
||||
- [planning-layer.md](references/planning-layer.md)(Create Workflow:新建 / 从空白生成 / 明确重设计)
|
||||
- [visual-planning.md](references/visual-planning.md)(Create Workflow)
|
||||
- [asset-planning.md](references/asset-planning.md)(Create Workflow)
|
||||
- [template-rewrite-workflow.md](references/template-rewrite-workflow.md)(Template Rewrite Workflow:PPTX/PDF/slides/existing Slides 二创)
|
||||
- [validation-checklist.md](references/validation-checklist.md)(创建 / Template Rewrite / 大幅改写后)
|
||||
|
||||
按需再读:
|
||||
|
||||
- 创建:[`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-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)、[`lark-slides-replace-pages.md`](references/lark-slides-replace-pages.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)
|
||||
- 模板:[`template-catalog.md`](references/template-catalog.md)、[`scripts/template_tool.py`](scripts/template_tool.py)
|
||||
- 内置模板(仅无用户材料时):[`template-catalog.md`](references/template-catalog.md)、[`scripts/template_tool.py`](scripts/template_tool.py)
|
||||
- 排障:[`troubleshooting.md`](references/troubleshooting.md)
|
||||
- 完整协议:[`slides_xml_schema_definition.xml`](references/slides_xml_schema_definition.xml)
|
||||
|
||||
## Workflow
|
||||
## Workflow Routing
|
||||
|
||||
### A. Create Workflow
|
||||
|
||||
适用:
|
||||
|
||||
- 新建 PPT。
|
||||
- 从空白生成。
|
||||
- 用户明确要求重新设计。
|
||||
- 没有模板保留诉求。
|
||||
|
||||
路由:
|
||||
|
||||
- 读取 `planning-layer.md`。
|
||||
- 可读取 `visual-planning.md`。
|
||||
- 可读取 `asset-planning.md`。
|
||||
- 生成 `slide_plan.json`。
|
||||
- 使用 `slides +create` 或对应创建流程。
|
||||
|
||||
### B. Template Rewrite Workflow
|
||||
|
||||
适用:
|
||||
|
||||
- 用户上传 PPTX / PDF / slides。
|
||||
- 用户给 existing Slides。
|
||||
- 用户说“基于这个模板”。
|
||||
- 用户说“保留原版式”。
|
||||
- 用户说“根据这个底稿生成”。
|
||||
- 用户说“二次创作 / 改写这个 PPT”。
|
||||
|
||||
路由:
|
||||
|
||||
- 读取 `template-rewrite-workflow.md`。
|
||||
- 不读取 `planning-layer.md`。
|
||||
- 不读取 `visual-planning.md`。
|
||||
- 不读取 `asset-planning.md`。
|
||||
- 不生成 `slide_plan.json`。
|
||||
- 不生成任何 rewrite plan / manifest。
|
||||
- 固定执行:import/readback -> `source.xml` -> `pages.json` -> `replace-pages` -> readback validation。
|
||||
- 不用 `python-pptx` 清空模板页、`add_slide(blank)` 重画,或导入新生成的本地 PPTX 作为最终产物。
|
||||
- 逐页从源页骨架向外改写,把新内容贴回源页已有容器和视觉节点;不能用一套通用卡片层覆盖模板结构。
|
||||
|
||||
### C. Switch Back To Create Workflow
|
||||
|
||||
只有用户明确表达以下意图,才允许从 Template Rewrite 切回 Create Workflow:
|
||||
|
||||
- “不要保留模板素材”
|
||||
- “只参考风格重做”
|
||||
- “重新设计整套 PPT”
|
||||
- “原模板只是灵感”
|
||||
- “完全换一种版式”
|
||||
|
||||
## Create Workflow
|
||||
|
||||
> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要低,排版要留白。
|
||||
|
||||
@@ -99,7 +158,7 @@ lark-cli auth login --domain slides
|
||||
|
||||
不要生成无设计感的幻灯片。纯白背景 + 标题 + bullets 只能作为极简临时稿,不能作为正式交付。
|
||||
|
||||
开始写 XML 前,先在 `slide_plan.json` 里确定 deck 级视觉策略:
|
||||
以下设计规划只适用于 Create Workflow。开始写 XML 前,先在 `slide_plan.json` 里确定 deck 级视觉策略:
|
||||
|
||||
- **主题化配色**:配色必须服务本次主题、行业和受众,不要默认蓝色商务风。如果把同一套颜色换到另一个完全不同主题仍然成立,说明配色不够具体。
|
||||
- **主次比例**:选择 1 个主色承担约 60-70% 视觉权重,1-2 个辅助色承担结构和分区,1 个强调色只用于关键数字、结论或行动点。不要让所有颜色权重相同。
|
||||
@@ -133,7 +192,7 @@ lark-cli auth login --domain slides
|
||||
- 不要把素材缺失表现为空白图片框;必须按 `fallback_if_missing` 生成 XML-native 视觉。
|
||||
- 不要留下模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。
|
||||
|
||||
### 创建方式选择
|
||||
### Create Workflow 创建方式选择
|
||||
|
||||
| 场景 | 推荐方式 |
|
||||
|------|----------|
|
||||
@@ -147,9 +206,9 @@ lark-cli auth login --domain slides
|
||||
> [!IMPORTANT]
|
||||
> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。
|
||||
|
||||
### 模板与脚本优先流程
|
||||
### 内置模板与脚本优先流程
|
||||
|
||||
模板细则见 [template-catalog.md](references/template-catalog.md)。主流程只记住:先 `search`,锁定后 `summarize`,需要骨架时才 `extract`;不要直接读取完整模板 XML 或照搬占位文案。
|
||||
仅在用户没有提供本地/在线模板材料时使用内置模板流程。模板细则见 [template-catalog.md](references/template-catalog.md)。主流程只记住:先 `search`,锁定后 `summarize`,需要骨架时才 `extract`;不要直接读取完整模板 XML 或照搬占位文案。
|
||||
|
||||
```bash
|
||||
python3 skills/lark-slides/scripts/template_tool.py search --query "<用户需求原文>" --limit 3
|
||||
@@ -159,12 +218,12 @@ python3 skills/lark-slides/scripts/template_tool.py extract --template <template
|
||||
|
||||
```text
|
||||
Step 1: 需求澄清 & 读取知识
|
||||
- 澄清主题、受众、页数、风格;模板需求按“模板与脚本优先流程”处理
|
||||
- 读取 xml-schema-quick-ref.md;新建 / 大幅改写时还要读取 planning-layer.md、visual-planning.md、asset-planning.md
|
||||
- 澄清主题、受众、页数、风格;没有用户提供模板材料时,模板需求按“内置模板与脚本优先流程”处理
|
||||
- 读取 xml-schema-quick-ref.md;Create Workflow 还要读取 planning-layer.md、visual-planning.md、asset-planning.md
|
||||
|
||||
Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json
|
||||
- 生成结构化大纲供用户确认;如使用模板,标明基于哪个模板改写
|
||||
- 新建 / 大幅改写必须先创建目录并写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
|
||||
- 生成结构化大纲供用户确认;如使用内置模板,标明基于哪个模板改写
|
||||
- Create Workflow 必须先创建目录并写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`
|
||||
- plan 字段、路径命名、模板边界和 `asset_need` 结构按 planning-layer.md / asset-planning.md 执行
|
||||
|
||||
Step 3: 按 slide_plan.json 生成 XML → 创建
|
||||
@@ -174,7 +233,7 @@ Step 3: 按 slide_plan.json 生成 XML → 创建
|
||||
|
||||
Step 4: 审查 & 交付
|
||||
- 创建完成后,必须用 xml_presentations.get 读取全文 XML,并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
|
||||
- 失败或部分成功按 troubleshooting.md 处理;局部问题优先用 `+replace-slide` 修正
|
||||
- 失败或部分成功按 troubleshooting.md 处理;局部问题可用 `+replace-slide` 修正,页面级问题用对应页面流程修正
|
||||
- 没问题 → 交付:告知用户演示文稿 ID 和访问方式
|
||||
```
|
||||
|
||||
@@ -268,6 +327,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
|
||||
| [`+create`](references/lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
|
||||
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
|
||||
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
|
||||
| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建一个或多个页面:先创建新页到旧页前,再删除旧页;适合模板二创、页面级重写和素材保留,不新建链接 |
|
||||
|
||||
没有 Shortcut 覆盖时使用原生 API。高频资源:`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。
|
||||
|
||||
@@ -280,13 +340,17 @@ lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
|
||||
## 核心规则
|
||||
|
||||
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
|
||||
2. **创建流程**:简单短 XML(1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加
|
||||
3. **`<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`**:文本和图形必须放在 `<data>` 内
|
||||
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
|
||||
5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id`
|
||||
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
|
||||
7. **编辑已有页面优先块级替换**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;只有需要替换整页结构时才用 `slide.delete` + `slide.create`
|
||||
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。
|
||||
1. **先判定工作流**:新建/空白生成/明确重设计走 Create Workflow;PPTX/PDF/slides/existing Slides 模板二创走 Template Rewrite Workflow。不要把二创塞回 `slide_plan.json` 工作流。
|
||||
2. **Create Workflow 先规划再写 XML**:必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;内置模板、风格和大纲只能作为规划输入,不能绕过规划层。
|
||||
3. **Template Rewrite 不生成 plan artifact**:唯一事实源是 `source.xml`,唯一执行输入是 `pages.json`,替换后用 `readback.xml` 验证。默认 preserve source, replace content, local adjustment only。
|
||||
4. **Template Rewrite 不能本地清空重画**:禁止用 `python-pptx` 删除模板页、从 blank layout 重建、只借用尺寸/主题色后生成本地 PPTX 再导入。`source.xml` 不可得时停止,不要伪装成模板二创。
|
||||
5. **Template Rewrite 不能通用卡片覆盖模板**:新内容必须映射到源页已有文本框、图形标签、节点、箭头、时间线、图表/table 或注释容器。源页 dominant structure 仍要承担表达,不允许只把它留在背景里。
|
||||
6. **创建流程**:简单短 XML(1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加
|
||||
7. **`<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`**:文本和图形必须放在 `<data>` 内
|
||||
8. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
|
||||
9. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id`
|
||||
10. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
|
||||
11. **编辑已有页面优先原链接更新**:模板二创、页面级重写、素材保留用 `+replace-pages`;修改单个 shape/img/text block 才用 `+replace-slide`(`block_replace` / `block_insert`)。不要用 `slides +create` 新建脱离模板的 deck。
|
||||
12. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果 `file_token` 来自同一个 `xml_presentation_id` 的旧页,可以在 Template Rewrite 的新页 XML 中直接复用;如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。
|
||||
|
||||
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# Asset Planning
|
||||
|
||||
新建演示文稿或大幅改写页面时,在写入 `slide_plan.json` 前后都可以参考本文件。目标是让 agent 主动识别有价值的图、图标、图表、流程图、时序图、架构图、装饰图案、截图或示意图需求,同时保持 deck 在没有真实素材时也能完整执行。
|
||||
本文件默认只供 Create Workflow / `planning-layer.md` 使用。新建演示文稿、从零大幅改写或用户明确要求重设计时,在写入 `slide_plan.json` 前后都可以参考本文件。目标是让 agent 主动识别有价值的图、图标、图表、流程图、时序图、架构图、装饰图案、截图或示意图需求,同时保持 deck 在没有真实素材时也能完整执行。
|
||||
|
||||
本文件只定义轻量资产规划。不要把它理解成素材采集流程。
|
||||
|
||||
模板二创不要为了 `asset_need` 重新规划或替换旧素材。模板二创中的旧素材、旧容器、旧样式以 `source.xml` 为准,由 `template-rewrite-workflow.md` 处理。
|
||||
|
||||
## Core Rules
|
||||
|
||||
- `asset_need` is metadata only. It can guide page design, but it must not require web search, local download, media upload, or external tools.
|
||||
@@ -12,6 +14,8 @@
|
||||
- Prefer a few high-value asset plans over one asset on every page. For a 6-page technical or business deck, plan assets on at least 3 pages when the content allows.
|
||||
- If a real local asset already exists or the user provides one, it can be used through the normal media-upload workflow. Still keep `fallback_if_missing` in the plan.
|
||||
- Do not leave blank image boxes in final XML. If the asset is missing, render the fallback visual.
|
||||
- In Template Rewrite Workflow, `fallback_if_missing` cannot cover, replace, or obscure existing source img/table/chart/whiteboard/shape/motif/text containers.
|
||||
- In Template Rewrite Workflow, reference fallback ideas only when the source page has no usable carrying area and a new visual element is truly required.
|
||||
|
||||
## JSON Shape
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# 编辑已有 PPT:读-改-写闭环
|
||||
|
||||
编辑走 **shortcut [`+replace-slide`](lark-slides-replace-slide.md)**(块级替换 / 插入),配合 `xml_presentation.slide.get` 读原页拿 `block_id`。
|
||||
局部编辑走 **shortcut [`+replace-slide`](lark-slides-replace-slide.md)**(块级替换 / 插入),配合 `xml_presentation.slide.get` 读原页拿 `block_id`。已有 Slides 的多页整页重建走 **[`+replace-pages`](lark-slides-replace-pages.md)**,保持原 presentation 链接不变。
|
||||
|
||||
模板/底稿/PPTX/PDF/existing Slides 二创必须先读 [`template-rewrite-workflow.md`](template-rewrite-workflow.md):以 `source.xml` 为源页骨架生成 replacement slide,不允许用 `python-pptx` 清空模板页、从 blank layout 重画、再导入新本地 PPTX 作为最终产物;也不允许把模板当背景后覆盖一套通用卡片层。
|
||||
|
||||
> 生成 XML 前**必读** [xml-schema-quick-ref.md](xml-schema-quick-ref.md)。
|
||||
|
||||
@@ -11,6 +13,7 @@
|
||||
| 已知某块的 `block_id`,要换这块内容(改标题、换图、挪坐标) | `block_replace` | 精准替换,原子性好;`replacement` 根 `id` 由 CLI 自动注入为 `block_id` |
|
||||
| 只加 1~N 个元素、不动现有布局 | `block_insert` | 新增不覆盖,可选 `insert_before_block_id` 指定位置 |
|
||||
| 一次动多个元素(如:换标题 + 加图) | 单次 `--parts` 里拼多条 | 整批作为原子事务,任一失败整批不生效;`block_replace` 和 `block_insert` 可混用 |
|
||||
| 模板二创、页面级改写、整页坐标调整 | `+replace-pages` | 原 presentation 内批量 create-before/delete-old;模板场景必须从源页 XML 骨架向外改,把新内容贴回源页已有容器、节点、箭头、时间线、图表/table 或注释,不生成新 Slides 链接 |
|
||||
|
||||
> **没有字段级 patch**:即便只想改一个 `shape` 的 `topLeftX`,也得把整个块的新 XML 写出来用 `block_replace`。这不是"微调",是块级重写。
|
||||
|
||||
@@ -136,6 +139,7 @@ cat parts.json | lark-cli slides +replace-slide --as user --presentation "$PID"
|
||||
## 相关文档
|
||||
|
||||
- [lark-slides-replace-slide.md](lark-slides-replace-slide.md) — +replace-slide shortcut 参数详情
|
||||
- [lark-slides-replace-pages.md](lark-slides-replace-pages.md) — 多页整页重建 shortcut
|
||||
- [lark-slides-xml-presentation-slide-get.md](lark-slides-xml-presentation-slide-get.md) — slide.get 参考(拿 `block_id` / `revision_id`)
|
||||
- [lark-slides-xml-presentation-slide-replace.md](lark-slides-xml-presentation-slide-replace.md) — 底层 replace API 参考(一般直接用 shortcut 即可)
|
||||
- [lark-slides-media-upload.md](lark-slides-media-upload.md) — 上传图片拿 file_token
|
||||
|
||||
100
skills/lark-slides/references/lark-slides-replace-pages.md
Normal file
100
skills/lark-slides/references/lark-slides-replace-pages.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# slides +replace-pages(多页整页重建)
|
||||
|
||||
批量替换已有演示文稿里的多个页面,保持原 `xml_presentation_id` 和原 Slides 链接不变。适合模板二创、页面级改写、坐标调整和素材保留;单个文本框、图片或 shape 的局部编辑仍优先用 [`+replace-slide`](lark-slides-replace-slide.md)。
|
||||
|
||||
> 重要:这是多步编排,不是后端原子事务。CLI 对每页执行“先创建新页到旧页前,再删除旧页”;创建失败时旧页会保留。删除失败时可能出现新旧页同时存在,需要按返回结果继续处理。
|
||||
|
||||
> 模板二创重要边界:`+replace-pages` 消费完整 replacement slide XML,但这个 XML 应以 `source.xml` 的源页结构为骨架。不要用 `python-pptx` 清空模板页、从 blank layout 重画、生成新本地 PPTX 再导入来替代本命令;也不要把模板当背景,再覆盖一套通用卡片系统。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
lark-cli slides +replace-pages \
|
||||
--as user \
|
||||
--presentation <slides_url_or_xml_presentation_id> \
|
||||
--pages @pages.json
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必需 | 说明 |
|
||||
|------|------|------|
|
||||
| `--presentation` | 是 | `xml_presentation_id`、`/slides/` URL 或 `/wiki/` URL |
|
||||
| `--pages` | 是 | JSON 数组,每项包含 `slide_id` 和 `content`;支持 literal、`@file`、stdin `-` |
|
||||
| `--dry-run` | 否 | 基于 `slide_id` 输入输出替换计划,不执行 create/delete |
|
||||
| `--continue-on-error` | 否 | 默认失败即停;开启后继续处理后续页,并在结果中标记失败项 |
|
||||
| `--validate-only` | 否 | 只校验输入并生成替换计划,不执行 Slides get/create/delete |
|
||||
|
||||
## pages.json
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"slide_id": "slide_short_id_1",
|
||||
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"
|
||||
},
|
||||
{
|
||||
"slide_id": "slide_short_id_2",
|
||||
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 每项必须提供 `slide_id`;不支持 `slide_number`。
|
||||
- `content` 必须是完整 `<slide>...</slide>` XML。
|
||||
- 模板二创时,`content` 应复用源页 `<style>`、`<img src>`、chart/table/whiteboard、shape、line/icon、文本容器等结构,只替换必要文本或局部元素。
|
||||
- 模板二创时,新内容应贴回源页已有 text container、图形标签、节点、箭头、时间线、chart/table 或注释容器;源页的 dominant structure 不能只留作背景装饰。
|
||||
- 同一批次不能重复 `slide_id`。
|
||||
- CLI 不会回读整份 presentation;如果 `slide_id` 已失效,create/delete 阶段会返回对应错误。
|
||||
|
||||
## Dry Run
|
||||
|
||||
```bash
|
||||
lark-cli slides +replace-pages --as user \
|
||||
--presentation "$PID" \
|
||||
--pages @pages.json \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
输出包含 `xml_presentation_id`、`pages_count`、`plan`,以及每页的 `old_slide_id`、`insert_before_slide_id` 和动作 `create_before_then_delete_old`。Dry-run 只基于输入的 `slide_id` 构造计划,不会调用 `xml_presentations.get`,也不会执行 create/delete。
|
||||
|
||||
## 成功输出
|
||||
|
||||
```json
|
||||
{
|
||||
"xml_presentation_id": "xxx",
|
||||
"pages_count": 2,
|
||||
"status": "completed",
|
||||
"summary": {
|
||||
"replaced": 2,
|
||||
"failed": 0,
|
||||
"total": 2
|
||||
},
|
||||
"results": [
|
||||
{
|
||||
"old_slide_id": "old3",
|
||||
"new_slide_id": "new3",
|
||||
"status": "replaced"
|
||||
}
|
||||
],
|
||||
"revision_id": 123
|
||||
}
|
||||
```
|
||||
|
||||
如果使用 `--continue-on-error` 且任一页面失败,CLI 会继续处理后续页,但最终以 partial failure 非零退出;stdout 仍保留完整 `results`,顶层 `ok` 为 `false`,`status` 为 `partial_failure`。
|
||||
|
||||
`status` 可能为:
|
||||
|
||||
- `replaced`:新页创建成功,旧页删除成功。
|
||||
- `create_failed`:新页创建失败,旧页保留。
|
||||
- `delete_failed`:新页已创建,但旧页删除失败。
|
||||
|
||||
## 使用建议
|
||||
|
||||
1. 大幅改写前先 `xml_presentations.get` 保存当前 XML,并记录要替换页面的 `slide_id`。
|
||||
2. 生成只含 `slide_id` 的 `pages.json` 后先跑 `--dry-run` 或 `--validate-only`。
|
||||
3. 默认不要开 `--continue-on-error`,除非能接受部分页面已替换。
|
||||
4. 模板二创不要把源页改成通用两卡、三卡、2x2 卡片,也不要用大白卡或大色块覆盖模板主体素材。源页如果有箭头、节点、时间线、图表、表格、几何结构、设备图或人物图,优先替换这些结构上的标签、数字和注释。
|
||||
5. 替换后再回读全文 XML,并按 `validation-checklist.md` 对比 `source.xml` 和 `readback.xml`;需要确认视觉一致性时,用 `slides +screenshot` 抽查封面、典型内容页、复杂结构页和结尾页。
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
获取幻灯片页面截图并保存为本地图片文件。默认用于已存在 PPT 页面截图;传入 `--content` 时用于直接渲染单个 `<slide>` XML 片段预览。本 shortcut 会在 CLI 进程内解码并写入文件,stdout 只返回文件路径、大小、页面 ID 等元信息,避免把图片 Base64 输出给模型。
|
||||
|
||||
注意:该截图能力对应的权限受白名单控制。只有在白名单内的应用才能申请该权限;不在白名单内的应用即使命令和参数正确,服务端仍可能返回权限或能力不可用相关错误。
|
||||
注意:该截图能力受应用白名单限制,绝大多数应用不可用。截图失败时不要引导用户申请 `slides:presentation:screenshot` 权限;记录错误后降级到 XML 读回、结构 lint、文本重叠检查等非截图检查路径。
|
||||
|
||||
## 命令
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
# Planning Layer
|
||||
|
||||
新建演示文稿或大幅改写页面时,必须先写 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。这个文件是 deck 的设计中间层,用来把叙事、页面角色、布局、视觉重点和文字密度固定下来,避免从用户提示直接跳到 XML。
|
||||
本文件只适用于 Create Workflow:新建演示文稿、从零大幅改写、用户明确要求重设计,或没有模板保留诉求的场景。进入本工作流时,必须先写 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。这个文件是 deck 的设计中间层,用来把叙事、页面角色、布局、视觉重点和文字密度固定下来,避免从用户提示直接跳到 XML。
|
||||
|
||||
小型已有页编辑可豁免,例如只替换一个标题、改一个数字、插入一个块、上传并插入一张图。只要任务会重排多页、生成新 deck、替换整页结构,仍然需要规划层。
|
||||
如果用户提供 PPTX/PDF/slides 作为模板、底稿或二创对象,或要求保留原版式/素材/结构,请走 `template-rewrite-workflow.md`。不要在 `slide_plan.json` 工作流中处理模板二创。
|
||||
|
||||
小型已有页编辑可豁免,例如只替换一个标题、改一个数字、插入一个块、上传并插入一张图。模板二创也不使用本规划层;它以 `source.xml` 为事实源、`pages.json` 为执行输入。
|
||||
|
||||
## Required Flow
|
||||
|
||||
1. 理解用户需求,必要时澄清主题、受众、页数、风格。
|
||||
2. 如果适合模板,先用 `template_tool.py search` 检索,锁定模板后用 `summarize` 获取主题和页型信息。
|
||||
2. 如果没有用户提供本地/在线模板材料且适合内置模板,先用 `template_tool.py search` 检索,锁定模板后用 `summarize` 获取主题和页型信息。
|
||||
3. 选择唯一 plan 目录:`.lark-slides/plan/<deck-or-task-id>/`。
|
||||
4. 先创建目录:`mkdir -p .lark-slides/plan/<deck-or-task-id>`。
|
||||
5. 写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`。
|
||||
@@ -15,7 +17,9 @@
|
||||
7. 按 plan、visual planning 和 asset planning 规则逐页生成 XML,把 `layout_type`、`visual_focus`、`text_density` 转成具体页面几何和文本量约束,并把缺失素材转成可执行兜底视觉。
|
||||
8. 创建 PPT 后用 `xml_presentations.get` 回读,核对页面数量、关键元素和 plan 到 XML 的对应关系。
|
||||
|
||||
模板不能代替 plan。模板搜索和摘要只能影响 `theme_style`、页面流、布局选择和局部布局骨架;最终仍必须有 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`。
|
||||
内置模板不能代替 plan。模板搜索和摘要只能影响 `theme_style`、页面流、布局选择和局部布局骨架;最终仍必须有 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`。
|
||||
|
||||
如果用户提供 PPTX/PDF/slides 作为模板、底稿或二创对象,请走 `references/template-rewrite-workflow.md`。不要在本工作流中复制 `source.xml` 的素材清单、bbox、层级或样式,也不要生成 `page_rewrite_plan.json` / `rewrite_manifest.json`。
|
||||
|
||||
## Plan Path
|
||||
|
||||
@@ -24,7 +28,7 @@ Use a separate plan directory per deck or task so multiple presentations in the
|
||||
Recommended IDs:
|
||||
|
||||
- New deck before creation: title slug plus date/time, such as `q3-review-20260507-1805`.
|
||||
- Existing PPT rewrite: the `xml_presentation_id`.
|
||||
- Existing PPT redesign after the user explicitly abandons template preservation: the `xml_presentation_id` plus a short redesign slug.
|
||||
- Ambiguous or untitled task: short task slug plus date/time.
|
||||
|
||||
Rules:
|
||||
@@ -40,7 +44,7 @@ Rules:
|
||||
Keep:
|
||||
|
||||
- `.lark-slides/plan/<deck-or-task-id>/slide_plan.json` after successful creation or major rewrite. The plan is the editable design state for the deck.
|
||||
- A small manifest when useful for follow-up work, such as `xml_presentation_id`, slide IDs, `revision_id`, plan path, and verification status.
|
||||
- A small creation status note when useful for follow-up work, such as `xml_presentation_id`, slide IDs, `revision_id`, plan path, and verification status. Do not create a rewrite manifest for Template Rewrite Workflow.
|
||||
|
||||
Clean or avoid keeping:
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
<xs:simpleType name="FontFamilyType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
字体族名称, 支持任意字体。
|
||||
字体族名称, 支持下列字体。
|
||||
|
||||
常用中文字体:
|
||||
思源宋体、寒蝉德黑体、标小智无界黑、寒蝉锦书宋、站酷小薇体、
|
||||
|
||||
218
skills/lark-slides/references/template-rewrite-workflow.md
Normal file
218
skills/lark-slides/references/template-rewrite-workflow.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# Template Rewrite Workflow
|
||||
|
||||
本工作流只服务基于真实模板或底稿的二次创作。适用场景:
|
||||
|
||||
- 用户上传 PPTX / PDF / slides。
|
||||
- 用户给 existing Slides。
|
||||
- 用户说“基于这个模板生成”。
|
||||
- 用户说“保留这个版式 / 底稿 / 原 PPT / 模板风格和结构”。
|
||||
- 用户要对已有 PPT 做二次创作、改写、替换内容。
|
||||
|
||||
禁止默认行为:
|
||||
|
||||
- 不默认走 `planning-layer.md`。
|
||||
- 不默认读取 `visual-planning.md`。
|
||||
- 不默认读取 `asset-planning.md`。
|
||||
- 不生成 `slide_plan.json`。
|
||||
- 不生成 `page_rewrite_plan.json`。
|
||||
- 不生成 `rewrite_manifest.json`。
|
||||
- 不默认用 `slides +create` 新建脱离模板的 deck。
|
||||
- 不通过 `python-pptx` / PowerPoint 自动化把模板页全部删除后从空白 layout 重画。
|
||||
- 不生成一个“借用模板尺寸/主题色”的本地 PPTX 再导入,并声称它是模板二创。
|
||||
- 不把模板页当成背景板,再在上面覆盖一套通用标题栏、两卡、三卡、2x2 卡片或大白卡系统。
|
||||
- 不把每页内容粘贴进一套重复组件,而让源页的箭头、节点、时间线、图形、图表、留白关系失去表达作用。
|
||||
|
||||
模板二创的数据流固定为:
|
||||
|
||||
```text
|
||||
source.xml
|
||||
-> generate replacement slide XML from each source slide skeleton
|
||||
-> pages.json
|
||||
-> slides +replace-pages
|
||||
-> readback.xml validation
|
||||
```
|
||||
|
||||
## 1. Import / Readback
|
||||
|
||||
- PPTX 必须先导入为 Slides。导入命令本身走 `lark-drive` 的 `drive +import --type slides`,但导入后的二创由本工作流负责。
|
||||
- PDF 如果作为模板、底稿、原 PPT 或视觉参考使用,也先导入为 Slides;只有明显是长文档资料而非演示稿时才不进入本工作流。
|
||||
- Existing Slides 用 `xml_presentations.get` 回读。
|
||||
- 保存回读 XML 为:
|
||||
|
||||
```text
|
||||
.lark-slides/plan/<xml_presentation_id>/source.xml
|
||||
```
|
||||
|
||||
如果无法得到 `source.xml`,Template Rewrite Workflow 不能继续。不要退化为:
|
||||
|
||||
- 用 `python-pptx` 打开模板后删除所有原 slides。
|
||||
- `prs.part.drop_rel(...)` / `del prs.slides._sldIdLst[...]` 清空模板页。
|
||||
- 从 `prs.slide_layouts[...]` 或 blank layout 重新 `add_slide(...)`。
|
||||
- 只保留画布尺寸、少量主题色、少量母版占位符后重画整套内容页。
|
||||
- 输出一个新的本地 PPTX,再用 `drive +import --type slides` 当最终产物。
|
||||
|
||||
正确处理是:停止 Template Rewrite,说明 `source.xml` 不可用,并让用户选择导入失败排障、只交付原导入 deck,或明确切换到 Create Workflow / 只参考风格重做。
|
||||
|
||||
## 2. Treat source.xml As Truth
|
||||
|
||||
`source.xml` 是唯一布局和素材事实源。
|
||||
|
||||
- 不要把 `source.xml` 里的素材 token、bbox、层级、样式再复制到新的 plan 文件。
|
||||
- 不要让模型手写素材清单来替代 `source.xml`。
|
||||
- 所有保留判断以 `source.xml` 为准。
|
||||
- 不要用 `layout_type`、`visual_focus`、`visual_system` 来驱动模板二创。
|
||||
- 可以在上下文中临时分析每页的源结构,但不要把它保存成新的 JSON / Markdown plan artifact。
|
||||
|
||||
## 3. Rewrite From Source Outward
|
||||
|
||||
以源页 XML 为骨架生成 replacement slide。默认顺序:
|
||||
|
||||
1. 先复制源页的 `<style>`。
|
||||
2. 复制源页的 `<img src="...">`、`<chart>`、`<table>`、`<whiteboard>`。
|
||||
3. 复制 recurring shapes / motifs、line / icon / separator、card container、reusable text container。
|
||||
4. 识别源页中承载表达的 dominant structure,例如箭头流、节点关系、时间线、漏斗、三角形、圆环、曲线、坐标/表格、左右对照、设备图、人物/场景分组。
|
||||
5. 把新内容映射到源页已有文本容器、图形标签、数字标签、节点标签或图表/table 数据上。
|
||||
6. 替换旧文案所在文本容器里的 `<content>`。
|
||||
7. 最后只在必要时添加局部新元素。
|
||||
|
||||
不要把源页改成通用两卡、三卡、2x2 卡片。不要把“保留模板”简化成“保留背景图 + 重新画业务卡片”。
|
||||
|
||||
生成 replacement slide 时,页面级结构必须来自源页 XML。可以替换或缩短文字、更新图表数据、局部补充元素;不能把源页删除后按自定义 `rect()` / `circle()` / `line()` / `add_text()` helper 重新搭一套卡片、流程、指标版式。
|
||||
|
||||
### Source-Connected Rewrite
|
||||
|
||||
每一页必须先在工作上下文中做源页结构判断。这个判断不是新 artifact,不写入文件;它只用于约束生成:
|
||||
|
||||
- page role:封面、目录、过渡、数据页、流程页、对比页、总结页等。
|
||||
- dominant source structure:源页最主要的视觉结构,例如图、表、箭头、节点、时间线、几何结构、产品图、人物图、设备图、曲线或对比版式。
|
||||
- content-bearing containers:真正承载文字和数字的源文本框、图形标签、图表标签、表格单元格。
|
||||
- source visual hierarchy:标题、核心结论、主视觉、支撑信息、脚注的原始层级。
|
||||
- safe insertion zones:只有在源页没有合适容器且用户内容必须出现时,才可使用的局部空白区域。
|
||||
|
||||
生成 replacement slide 时必须满足:
|
||||
|
||||
1. 新文案优先进入已有 text container、图形标签、节点标签、数字标签、表格单元格或 chart labels / data。
|
||||
2. 如果源页有三角形、箭头、节点、时间线、曲线、地图、设备图、产品图或人物分组,新内容必须贴到这些源结构的对应标签/节点/注释上,而不是覆盖一组三张新卡片。
|
||||
3. 如果源页是数据图形页,优先更新原图表、数字标签、曲线节点、坐标标签和注释;不要另造一个白色数据卡片区遮住原图。
|
||||
4. 如果源页是流程/关系页,优先替换每个步骤、箭头、节点、关系说明;不要把流程压在背景里,再另起 bullet 卡片。
|
||||
5. 如果源页是封面或章节页,保留原图片、标题容器、logo / slogan / 装饰关系;不要把标题挪进不相干的新色块。
|
||||
6. 如果原文本容器空间不足,先缩短文案、降低层级、拆到邻近源容器或用源页已有注释容器承载;不要默认新增大卡片。
|
||||
7. 新增元素只能补足源结构的局部空缺,不能成为覆盖源结构的主版式。
|
||||
8. 多页之间应保留源模板原本的页型差异;不要把整套 deck 归一成同一套顶栏 + 三卡片。
|
||||
|
||||
### Source-Connectedness Gate
|
||||
|
||||
生成 `pages.json` 前,对每个 replacement slide 做一次失败门检查。出现以下任一情况,必须重写该页:
|
||||
|
||||
- 页面主体内容主要落在新增 shape/card 中,而不是源页已有容器或源结构节点里。
|
||||
- 源页的箭头、节点、时间线、图形、图表、设备图、人物图仍在,但已经只是背景装饰,没有承载新内容。
|
||||
- 新增卡片、白板、大色块或信息面板覆盖了源页 dominant structure。
|
||||
- 多个源页被改成同一套顶栏、三卡、2x2 卡片或大段 bullet 容器。
|
||||
- 页内关键源容器还在,但其 bbox、层级、字号、颜色、对齐关系被无理由改写。
|
||||
- 源页明明有图文关系、箭头关系或坐标关系,却把内容独立堆放到空白区域,导致互相错位或遮挡。
|
||||
|
||||
## 4. Generate pages.json
|
||||
|
||||
`pages.json` 是唯一执行输入。结构只保留 `slides +replace-pages` 需要的字段:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"slide_id": "<old slide id>",
|
||||
"content": "<full replacement slide XML>"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
不要把 planning metadata 放进 `pages.json`。
|
||||
|
||||
## 5. Execute replace-pages
|
||||
|
||||
- 默认用 `slides +replace-pages`。
|
||||
- `replace-pages` 消费 `pages.json`,不消费 `slide_plan.json`。
|
||||
- `replace-slide` 只用于小型块级编辑,例如改一个标题、插入一个图、替换已知 block。
|
||||
|
||||
## 6. Readback Validation
|
||||
|
||||
替换后必须用 `xml_presentations.get` 回读,保存为:
|
||||
|
||||
```text
|
||||
.lark-slides/plan/<xml_presentation_id>/readback.xml
|
||||
```
|
||||
|
||||
用 `readback.xml` 和 `source.xml` 对比验证模板结构没有被破坏。验证细则见 `validation-checklist.md` 的 Template Rewrite validation 小节。
|
||||
|
||||
## Preservation Rules
|
||||
|
||||
除非用户明确要求重设计,否则模板二创必须:
|
||||
|
||||
- preserve source layout
|
||||
- preserve source assets
|
||||
- preserve source style
|
||||
- preserve source text containers
|
||||
- preserve source visual hierarchy
|
||||
- replace content only
|
||||
- local adjustment only
|
||||
|
||||
具体规则:
|
||||
|
||||
1. `<style>` 默认保留。
|
||||
2. `<img src="...">` 默认保留,尤其是背景图、截图、装饰图、产品图、logo、模板视觉。
|
||||
3. 同一个 `xml_presentation_id` 内复用 `<img src>` 时,直接复制原 `src` / token,不要重新上传,不要替换成外部 URL。
|
||||
4. `<chart>` / `<table>` 默认保留;除非用户要求更新数据,才改 labels / data。
|
||||
5. `<whiteboard>` 默认保留其位置和外层结构;注意 readback XML 未必包含内部 SVG / Mermaid。
|
||||
6. shape / line / icon / separator / card container / motif 默认保留。
|
||||
7. 旧文案所在 text container 默认保留 bbox、layer、textType、fontFamily、fontSize、color、alignment,只替换 `<content>`。
|
||||
8. 如果源页已有卡片容器,优先复用源容器。
|
||||
9. 如果源页已有图文结构,优先替换原文本。
|
||||
10. 如果必须新增元素,新增元素必须局部且不破坏源页主要视觉结构。
|
||||
11. 不允许以“模板文件只是内容容器”为由清空原页;模板页本身就是必须保留的设计资产。
|
||||
12. 不允许把模板当成 wallpaper。源页的 dominant structure 必须继续承载内容和语义。
|
||||
|
||||
## Local PPTX Is Not A Rewrite Target
|
||||
|
||||
Template Rewrite 的写入目标是导入后或已有的 Slides presentation。默认最终写入动作是 `slides +replace-pages`,不是创建一个新的本地 PPTX。
|
||||
|
||||
禁止的本地 PPTX 生成模式:
|
||||
|
||||
```python
|
||||
while len(prs.slides._sldIdLst):
|
||||
r_id = prs.slides._sldIdLst[0].rId
|
||||
prs.part.drop_rel(r_id)
|
||||
del prs.slides._sldIdLst[0]
|
||||
|
||||
blank = prs.slide_layouts[...]
|
||||
slide = prs.slides.add_slide(blank)
|
||||
```
|
||||
|
||||
上面这种模式会删除背景图、截图、装饰图、产品图、logo、shape、文本框、层级和页内结构。它最多是 Create Workflow 的“新建 PPT”,不是模板二创。
|
||||
|
||||
## No Full-Page Wash / Mask
|
||||
|
||||
禁止默认添加:
|
||||
|
||||
- full-page wash
|
||||
- near-full-page overlay
|
||||
- 全页半透明白色蒙版
|
||||
- 全页半透明黑色蒙版
|
||||
- 覆盖页面主体区域的大矩形
|
||||
- `rgba(255,255,255,0.x)` 大面积遮罩
|
||||
- `rgba(0,0,0,0.x)` 大面积遮罩
|
||||
|
||||
原因:模板二创时,模板素材是优先保留对象。全页 wash 会视觉遮盖模板素材,即使 token 仍然存在,也等同于破坏模板。
|
||||
|
||||
允许的可读性增强仅包括:
|
||||
|
||||
- 局部 text backing
|
||||
- 局部 card backing
|
||||
- 调整文字颜色
|
||||
- 调整字重
|
||||
- 文字阴影
|
||||
- 缩短文案
|
||||
- 复用源页已有文本容器
|
||||
- 复制 `source.xml` 中原本存在的 overlay
|
||||
|
||||
如果新增 overlay 覆盖了大部分画布,应判定为失败,除非:
|
||||
|
||||
- 该 overlay 来自 `source.xml` 原有元素;或
|
||||
- 用户明确要求统一加蒙版 / 遮罩。
|
||||
@@ -23,6 +23,37 @@ lark-cli slides xml_presentations get --as user \
|
||||
--params '{"xml_presentation_id":"YOUR_ID"}'
|
||||
```
|
||||
|
||||
## Template Rewrite Validation
|
||||
|
||||
模板二创用 `slides +replace-pages` 后必须回读全文 XML,并保存:
|
||||
|
||||
```text
|
||||
.lark-slides/plan/<xml_presentation_id>/readback.xml
|
||||
```
|
||||
|
||||
同时对比同目录的 `source.xml`。通过标准:
|
||||
|
||||
1. `readback.xml` 中仍存在 `source.xml` 的关键 `<img src>` token。
|
||||
2. `readback.xml` 中仍存在关键 `<style>`、chart、table、whiteboard、shape motif、card container。
|
||||
3. 旧 text container 的 bbox、layer、font、color、alignment 没有无理由变化。
|
||||
4. 没有新增 full-page / near-full-page overlay、wash、mask。
|
||||
5. 没有把多页改成同质化两卡、三卡、2x2 卡片。
|
||||
6. 没有用大白卡、大色块覆盖模板主体素材。
|
||||
7. 没有把 source img 重新上传或替换成外部 URL。
|
||||
8. `pages.json` item 只包含 `slide_id` / `content`。
|
||||
9. `replace-pages` 使用 create-before-delete 语义时,确认最终页数正确。
|
||||
10. 如果发现模板素材 token 存在但被新增遮罩视觉遮盖,应判定为失败。
|
||||
11. 没有出现 `python-pptx` 清空模板页、blank layout 重建、本地生成 PPTX 再导入的路径。
|
||||
12. 源模板的媒体资产数量、关键图片 token、主要 shape/table/chart/whiteboard/text container 没有断崖式丢失。若原模板有大量媒体资产而结果只剩极少数媒体资产,应判定为失败,除非用户明确要求移除这些素材。
|
||||
13. 每页的 dominant source structure 仍然存在并承载内容,例如三角形、箭头、节点、时间线、曲线、图表、表格、设备图、人物图、产品图或左右对照结构没有退化成背景装饰。
|
||||
14. 新内容主要落在源页已有 text container、图形标签、节点标签、数字标签、chart/table 数据或源页注释容器里,而不是新增的通用卡片层里。
|
||||
15. 没有把多页改成同一套“顶栏 + 三卡片 / 2x2 卡片 / 大 bullet 面板”的重复版式。
|
||||
16. 源页有箭头流、节点关系、时间线、图形结构或坐标关系时,新文案与这些结构对齐,没有漂浮在不相关空白区域或互相遮挡。
|
||||
17. 能获取截图时,至少抽查封面、典型内容页、复杂结构页和结尾页;如果总页数不超过 8 页,逐页截图检查。截图验收重点是源模板视觉结构是否仍可见且承载内容。
|
||||
18. 验证记录必须说明“与 source.xml 的模板结构/素材保留对比结果”。只记录页数、关键词存在、`xml_text_overlap_lint.py error_count=0` 不足以通过 Template Rewrite validation。
|
||||
|
||||
允许的可读性增强仅限局部 text backing、局部 card backing、文字颜色/字重调整、文字阴影、缩短文案、复用源页已有文本容器,或复制 `source.xml` 中原本存在的 overlay。
|
||||
|
||||
## Automated XML Text Overlap Lint
|
||||
|
||||
回读 XML 保存到本地文件后,优先运行 XML 语法和文本重叠静态检查:
|
||||
@@ -36,6 +67,7 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
- `summary.error_count == 0`。任何 error 都必须先修复再交付。
|
||||
- 当前工具只检查 XML well-formed 和文本元素之间的明显重叠;它不检查越界、文本高度不足、图文压盖、表格/图表压盖或底部拥挤。
|
||||
- 该工具不能替代页数核对、关键内容核对或真实视觉验收。
|
||||
- 该工具不能验证模板视觉一致性。Template Rewrite Workflow 中,即使 `error_count == 0`,只要源页背景、图片、shape、文本框、结构层级或主要媒体资产被清空/重画/遮挡,仍然必须判定失败。
|
||||
|
||||
常见 code 的处理方向:
|
||||
|
||||
@@ -46,7 +78,7 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
|
||||
## Page Count And Structure
|
||||
|
||||
- 实际页数必须等于用户要求或 `slide_plan.json` 的页数。
|
||||
- 实际页数必须等于用户要求。Create Workflow 对照 `slide_plan.json`;Template Rewrite Workflow 对照 `source.xml` / `pages.json` 和 replace-pages 结果。
|
||||
- 如果创建过程部分失败,先记录已创建的 `xml_presentation_id`,再回读确认哪些页已写入。
|
||||
- 每页都应包含 `<data>`,且 `<data>` 内至少有一个非背景主体元素。
|
||||
- 封面、章节页、总结页可以文字较少,但不能只有空背景。
|
||||
@@ -54,7 +86,7 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
|
||||
## Expected Elements
|
||||
|
||||
按 `slide_plan.json` 和用户要求逐页核对:
|
||||
Create Workflow 按 `slide_plan.json` 和用户要求逐页核对:
|
||||
|
||||
- 标题或主结论存在,并能对应 `key_message`。
|
||||
- `layout_type` 对应的主要结构已生成。
|
||||
@@ -62,6 +94,15 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
- `text_density` 影响了文本量,没有用长 bullet 框替代规划。
|
||||
- `asset_need` 有真实素材时已放入正确区域;没有真实素材时,`fallback_if_missing` 已用 XML 形状、线条、标签、表格或图表兜底。
|
||||
|
||||
Template Rewrite Workflow 按 `source.xml`、`pages.json` 和用户替换要求逐页核对:
|
||||
|
||||
- 标题或主结论存在,并写入源页对应的标题/结论容器。
|
||||
- 源页 dominant structure 仍是页面中最醒目或最大的信息区域之一。
|
||||
- 新内容映射到源页已有文本容器、图形标签、节点、箭头、时间线、图表/table 或注释容器。
|
||||
- 源页原有图文关系、分组关系、层级关系仍然可读,没有被新增卡片层覆盖或挤散。
|
||||
- 多页之间保留源模板的页型差异,没有被统一改成同质化卡片页。
|
||||
- 当源容器装不下时,优先缩短文本、降低层级或复用邻近源容器;不能用大白卡、大色块或新面板覆盖模板主体。
|
||||
|
||||
如果用户指定了关键页,例如“架构解释”“Self-Attention 机制解释”“对比或演进视角”“总结页”,最终验证记录必须逐项说明这些页已存在。
|
||||
|
||||
## Blank Or Broken Page Signals
|
||||
@@ -72,6 +113,11 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
- 关键文本没有出现在回读 XML 中。
|
||||
- 图片仍是 `@./path`,或 `<img src>` 是 http(s) 外链。
|
||||
- 页面依赖的图片区域为空,且没有 fallback visual。
|
||||
- Template Rewrite 结果只保留模板尺寸/主题色,丢失大部分源页图片、背景、shape、文本容器或媒体资产。
|
||||
- Template Rewrite 结果由新生成本地 PPTX 导入,而不是对导入/已有 Slides 使用 `+replace-pages`。
|
||||
- Template Rewrite 结果把内容贴进新增通用卡片层,源页的箭头、节点、时间线、图表、几何结构、设备图或人物图只剩背景作用。
|
||||
- Template Rewrite 结果多页出现重复的顶栏、三卡片、2x2 卡片或大 bullet 面板,压过源模板原有页型差异。
|
||||
- Template Rewrite 结果中源页关键结构仍存在,但新内容没有贴回对应标签、节点、数字、表格或注释位置。
|
||||
- 返回 XML 缺页、页序明显错误,或某页内容被 shell 截断。
|
||||
- 大量形状坐标完全相同,导致主体内容重叠。
|
||||
- 渐变背景回退成空白或白底,导致文字不可读。
|
||||
@@ -94,6 +140,8 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
- 高密度页使用单个长 bullet list,没有分栏、表格或分组。
|
||||
- 标题、主视觉、正文的字号和颜色差异太弱,视觉层级不清。
|
||||
- 所有内容页都是同一套标题加 bullets 坐标。
|
||||
- Template Rewrite 中,新增元素漂浮在源结构上方,没有和源页图形、节点、表格、图片或注释形成对应关系。
|
||||
- Template Rewrite 中,源页主体视觉被新增白卡、色块、面板或文字框切断。
|
||||
|
||||
## Verification Record
|
||||
|
||||
@@ -105,6 +153,7 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
- 关键页:架构解释 / Self-Attention / 对比或演进 / 总结页均存在。
|
||||
- 结构:检查了主要 shape/img/table/chart 元素,无明显空白页或破损页。
|
||||
- 布局:检查了标题层级、主视觉、重叠/越界/文本溢出风险。
|
||||
- 模板二创:逐页或抽样说明 source.xml 的 dominant structure 是否仍承载内容,是否存在通用卡片层覆盖源结构;如已截图,说明抽查页范围。
|
||||
```
|
||||
|
||||
不要声称完成了人工视觉验收,除非确实打开或获取了可视化结果。仅从 XML 静态检查得出的结论,应表述为“静态检查未发现明显问题”。
|
||||
|
||||
@@ -74,7 +74,7 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
|
||||
| `textAlign` | 文本对齐方式 |
|
||||
| `lineSpacing` | 行间距,schema 默认 `multiple:1.5` |
|
||||
| `fontSize` | 字号 |
|
||||
| `fontFamily` | 字体 |
|
||||
| `fontFamily` | 字体,必须来自 `slides_xml_schema_definition.xml` 的 `FontFamilyType` 清单 |
|
||||
| `color` | 字体颜色 |
|
||||
| `bold` / `italic` / `underline` / `strikethrough` | 文本样式 |
|
||||
|
||||
|
||||
@@ -17,6 +17,10 @@ class XmlTextOverlapLintError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
FONT_FAMILY_PLACEHOLDER_VALUES = {"undefined"}
|
||||
_SUPPORTED_FONT_FAMILIES: set[str] | None = None
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise XmlTextOverlapLintError(message)
|
||||
|
||||
@@ -75,6 +79,81 @@ def xml_local_name(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1] if tag.startswith("{") else tag
|
||||
|
||||
|
||||
def schema_definition_path() -> Path:
|
||||
return Path(__file__).resolve().parents[1] / "references" / "slides_xml_schema_definition.xml"
|
||||
|
||||
|
||||
def extract_supported_font_families(schema_xml: str) -> set[str]:
|
||||
simple_type_match = re.search(
|
||||
r'<xs:simpleType\s+name="FontFamilyType">([\s\S]*?)</xs:simpleType>',
|
||||
schema_xml,
|
||||
)
|
||||
if simple_type_match is None:
|
||||
fail("FontFamilyType definition not found in slides XML schema")
|
||||
|
||||
documentation_match = re.search(
|
||||
r"<xs:documentation>([\s\S]*?)</xs:documentation>",
|
||||
simple_type_match.group(1),
|
||||
)
|
||||
if documentation_match is None:
|
||||
fail("FontFamilyType documentation not found in slides XML schema")
|
||||
|
||||
font_families: set[str] = set()
|
||||
for raw_line in documentation_match.group(1).splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("字体族名称") or line.endswith(":"):
|
||||
continue
|
||||
for font_family in re.split(r"[、,,]", line):
|
||||
font_family = font_family.strip()
|
||||
if font_family:
|
||||
font_families.add(font_family)
|
||||
|
||||
if not font_families:
|
||||
fail("FontFamilyType supported font list is empty")
|
||||
return font_families
|
||||
|
||||
|
||||
def supported_font_families() -> set[str]:
|
||||
global _SUPPORTED_FONT_FAMILIES
|
||||
if _SUPPORTED_FONT_FAMILIES is None:
|
||||
_SUPPORTED_FONT_FAMILIES = extract_supported_font_families(read_file(schema_definition_path()))
|
||||
return _SUPPORTED_FONT_FAMILIES
|
||||
|
||||
|
||||
def line_column_at_offset(source: str, offset: int) -> tuple[int, int]:
|
||||
line = source.count("\n", 0, offset) + 1
|
||||
line_start = source.rfind("\n", 0, offset)
|
||||
column = offset + 1 if line_start == -1 else offset - line_start
|
||||
return line, column
|
||||
|
||||
|
||||
def lint_font_families(xml: str) -> list[dict[str, Any]]:
|
||||
issues: list[dict[str, Any]] = []
|
||||
allowed_font_families = supported_font_families()
|
||||
for match in re.finditer(r"\bfontFamily\s*=\s*([\"'])(.*?)\1", xml):
|
||||
font_family = match.group(2).strip()
|
||||
if not font_family or font_family in FONT_FAMILY_PLACEHOLDER_VALUES:
|
||||
continue
|
||||
if font_family in allowed_font_families:
|
||||
continue
|
||||
line, column = line_column_at_offset(xml, match.start())
|
||||
issues.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "unsupported_font_family",
|
||||
"message": f'fontFamily "{font_family}" is not supported',
|
||||
"line": line,
|
||||
"column": column,
|
||||
"fontFamily": font_family,
|
||||
"hint": (
|
||||
"Use a FontFamilyType value from slides_xml_schema_definition.xml, "
|
||||
"or omit fontFamily to use the default font."
|
||||
),
|
||||
}
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def extract_error_context(xml: str, line: int | None, column: int | None, radius: int = 40) -> str | None:
|
||||
if line is None or column is None:
|
||||
return None
|
||||
@@ -326,18 +405,24 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
presentation = parse_presentation(xml)
|
||||
global_issues = lint_font_families(xml)
|
||||
slides = [
|
||||
lint_slide(slide_xml, index + 1)
|
||||
for index, slide_xml in enumerate(presentation["slides"])
|
||||
]
|
||||
error_count = sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "error")
|
||||
warning_count = sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "warning")
|
||||
return {
|
||||
error_count = sum(1 for issue in global_issues if issue["level"] == "error")
|
||||
error_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "error")
|
||||
warning_count = sum(1 for issue in global_issues if issue["level"] == "warning")
|
||||
warning_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "warning")
|
||||
result = {
|
||||
"file": source_path,
|
||||
"slide_size": {"width": presentation["width"], "height": presentation["height"]},
|
||||
"summary": {"slide_count": len(slides), "error_count": error_count, "warning_count": warning_count},
|
||||
"slides": slides,
|
||||
}
|
||||
if global_issues:
|
||||
result["issues"] = global_issues
|
||||
return result
|
||||
|
||||
|
||||
def print_usage() -> None:
|
||||
|
||||
@@ -110,6 +110,61 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
|
||||
def test_lint_xml_accepts_supported_font_family(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<theme>
|
||||
<textStyles>
|
||||
<body fontFamily="思源黑体"/>
|
||||
</textStyles>
|
||||
</theme>
|
||||
<slide>
|
||||
<data>
|
||||
<shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
|
||||
<content textType="body" fontFamily="Inter"><p>Body text</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertNotIn("issues", result)
|
||||
|
||||
def test_lint_xml_allows_legacy_undefined_font_family_placeholder(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
|
||||
<content textType="body" fontFamily="undefined"><p>Body text</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertNotIn("issues", result)
|
||||
|
||||
def test_lint_xml_reports_unsupported_font_family(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
|
||||
<content textType="body" fontFamily="微软雅黑"><p>Body text</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
issue = result["issues"][0]
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertEqual(issue["code"], "unsupported_font_family")
|
||||
self.assertEqual(issue["fontFamily"], "微软雅黑")
|
||||
self.assertIn("FontFamilyType", issue["hint"])
|
||||
|
||||
def test_lint_xml_single_slide_uses_default_canvas_without_bounds_checks(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
## Metrics
|
||||
- Denominator: 31 leaf commands
|
||||
- Covered: 10
|
||||
- Coverage: 32.3%
|
||||
- Covered: 11
|
||||
- Coverage: 35.5%
|
||||
|
||||
## Summary
|
||||
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
|
||||
@@ -15,6 +15,7 @@
|
||||
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`.
|
||||
- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows.
|
||||
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
|
||||
- TestDriveImportDryRun_PDFToSlides: dry-run coverage for `drive +import`; asserts PDF-to-slides request shape across media upload `extra` and import task body without calling live APIs.
|
||||
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
|
||||
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
|
||||
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
|
||||
@@ -31,7 +32,7 @@
|
||||
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
|
||||
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_BitableBaseOnlySchema | `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema` | dry-run only; no live export workflow yet |
|
||||
| ✕ | drive +export-download | shortcut | | none | no export-download workflow yet |
|
||||
| ✕ | drive +import | shortcut | | none | no import workflow yet |
|
||||
| ✓ | drive +import | shortcut | drive_import_dryrun_test.go::TestDriveImportDryRun_PDFToSlides | `.pdf` source with `--type slides`; media upload `extra.file_extension=pdf`; import task `file_extension=pdf`, `type=slides`, `file_name` | dry-run only; no live import workflow yet |
|
||||
| ✕ | drive +move | shortcut | | none | no move workflow yet |
|
||||
| ✓ | drive +pull | shortcut | drive_pull_dryrun_test.go::TestDrive_PullDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--on-duplicate-remote=rename\|newest\|oldest`; `--delete-local --yes` guard | dry-run locks flag/validate shape; live workflow proves duplicate fail-fast and rename recovery |
|
||||
| ✓ | drive +push | shortcut | drive_push_dryrun_test.go::TestDrive_PushDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--if-exists`; `--on-duplicate-remote=newest\|oldest`; `--delete-remote --yes` | dry-run locks flag/validate shape; live workflow proves overwrite + duplicate cleanup converges status |
|
||||
|
||||
Reference in New Issue
Block a user