mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Compare commits
12 Commits
v1.0.63
...
feat/slide
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b0864d8e5 | ||
|
|
c43b231200 | ||
|
|
6f79b98fc3 | ||
|
|
50d5b38b18 | ||
|
|
2c302810f7 | ||
|
|
808e1e3787 | ||
|
|
dec18bc9fa | ||
|
|
9d53770590 | ||
|
|
6fec103ac9 | ||
|
|
bc470c8c17 | ||
|
|
a48dc3cef8 | ||
|
|
ff23931da1 |
@@ -347,6 +347,89 @@ func boolIntQueryMethod(required bool) meta.Method {
|
||||
})
|
||||
}
|
||||
|
||||
func slidesSpec() meta.Service {
|
||||
return meta.ServiceFromMap(map[string]interface{}{
|
||||
"name": "slides",
|
||||
"servicePath": "/open-apis/slides_ai/v1",
|
||||
})
|
||||
}
|
||||
|
||||
func slidesXMLPresentationSlideGetMethod() meta.Method {
|
||||
return meta.FromMap(map[string]interface{}{
|
||||
"path": "xml_presentations/{xml_presentation_id}/slide",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"xml_presentation_id": map[string]interface{}{"type": "string", "location": "path", "required": true},
|
||||
"slide_id": map[string]interface{}{"type": "string", "location": "query"},
|
||||
"slide_number": map[string]interface{}{"type": "integer", "location": "query"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func slidesXMLPresentationsGetMethod() meta.Method {
|
||||
return meta.FromMap(map[string]interface{}{
|
||||
"path": "xml_presentations/{xml_presentation_id}",
|
||||
"httpMethod": "GET",
|
||||
"parameters": map[string]interface{}{
|
||||
"xml_presentation_id": map[string]interface{}{"type": "string", "location": "path", "required": true},
|
||||
"revision_id": map[string]interface{}{"type": "integer", "location": "query"},
|
||||
"remove_attr_id": map[string]interface{}{"type": "boolean", "location": "query"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestSlidesXMLPresentationsGet_RemoveAttrID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, slidesSpec(), slidesXMLPresentationsGetMethod(), "get", "xml_presentations", nil)
|
||||
cmd.SetArgs([]string{"--xml-presentation-id", "pid_123", "--remove-attr-id", "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"/open-apis/slides_ai/v1/xml_presentations/pid_123", `"remove_attr_id": true`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("expected dry-run output to contain %q, got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLPresentationSlideGet_BySlideNumber(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, slidesSpec(), slidesXMLPresentationSlideGetMethod(), "get", "xml_presentation.slide", nil)
|
||||
cmd.SetArgs([]string{"--xml-presentation-id", "pid_123", "--slide-number", "2", "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"/open-apis/slides_ai/v1/xml_presentations/pid_123/slide", `"slide_number": 2`} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("expected dry-run output to contain %q, got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "slide_id") {
|
||||
t.Errorf("slide_number-only request should not include slide_id, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesXMLPresentationSlideGet_SlideIDWinsOverSlideNumber(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
cmd := NewCmdServiceMethod(f, slidesSpec(), slidesXMLPresentationSlideGetMethod(), "get", "xml_presentation.slide", nil)
|
||||
cmd.SetArgs([]string{"--xml-presentation-id", "pid_123", "--slide-id", "sld_123", "--slide-number", "2", "--dry-run"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"slide_id": "sld_123"`) {
|
||||
t.Errorf("expected slide_id in dry-run output, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "slide_number") {
|
||||
t.Errorf("slide_id should take precedence over slide_number, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Presence is intent: a typed flag is only overlaid when explicitly Changed,
|
||||
// so --flag=false / --flag 0 are real values and must be sent — not silently
|
||||
// dropped as "empty", which would let the API default win over an explicit
|
||||
|
||||
@@ -566,6 +566,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
for name, value := range params {
|
||||
queryParams[name] = value
|
||||
}
|
||||
normalizeServiceQueryParams(opts.SchemaPath, queryParams)
|
||||
|
||||
request := client.RawApiRequest{
|
||||
Method: httpMethod,
|
||||
@@ -623,6 +624,15 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
return request, nil, nil
|
||||
}
|
||||
|
||||
func normalizeServiceQueryParams(schemaPath string, queryParams map[string]interface{}) {
|
||||
if schemaPath != "slides.xml_presentation.slide.get" {
|
||||
return
|
||||
}
|
||||
if slideID, ok := queryParams["slide_id"]; ok && !unusableParamValue(slideID) {
|
||||
delete(queryParams, "slide_number")
|
||||
}
|
||||
}
|
||||
|
||||
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, format string) error {
|
||||
return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format)
|
||||
}
|
||||
|
||||
@@ -75,6 +75,8 @@ func BaseSecurityHeaders() http.Header {
|
||||
h.Set(HeaderVersion, build.Version)
|
||||
h.Set(HeaderBuild, DetectBuildKind())
|
||||
h.Set(HeaderUserAgent, UserAgentValue())
|
||||
h.Set("x-tt-env", "ppe_pdf2slide")
|
||||
h.Set("x-use-ppe", "1")
|
||||
if v := AgentTraceValue(); v != "" {
|
||||
h.Set(HeaderAgentTrace, v)
|
||||
}
|
||||
|
||||
@@ -30,10 +30,10 @@ const OAuthTokenV3Path = "/oauth/v3/token"
|
||||
|
||||
// Endpoints holds resolved endpoint URLs for different Lark services.
|
||||
type Endpoints struct {
|
||||
Open string // e.g. "https://open.feishu.cn"
|
||||
Accounts string // e.g. "https://accounts.feishu.cn"
|
||||
Open string // e.g. "https://open.feishu-pre.cn"
|
||||
Accounts string // e.g. "https://accounts.feishu-pre.cn"
|
||||
MCP string // e.g. "https://mcp.feishu.cn"
|
||||
AppLink string // e.g. "https://applink.feishu.cn"
|
||||
AppLink string // e.g. "https://applink.feishu-pre.cn"
|
||||
}
|
||||
|
||||
// ResolveEndpoints resolves endpoint URLs based on brand.
|
||||
@@ -48,10 +48,10 @@ func ResolveEndpoints(brand LarkBrand) Endpoints {
|
||||
}
|
||||
default:
|
||||
return Endpoints{
|
||||
Open: "https://open.feishu.cn",
|
||||
Accounts: "https://accounts.feishu.cn",
|
||||
Open: "https://open.feishu-pre.cn",
|
||||
Accounts: "https://accounts.feishu-pre.cn",
|
||||
MCP: "https://mcp.feishu.cn",
|
||||
AppLink: "https://applink.feishu.cn",
|
||||
AppLink: "https://applink.feishu-pre.cn",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
413
shortcuts/slides/slides_replace_pages.go
Normal file
413
shortcuts/slides/slides_replace_pages.go
Normal file
@@ -0,0 +1,413 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"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"},
|
||||
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 {
|
||||
if _, err := parsePresentationRef(runtime.Str("presentation")); 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 err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
if depth == 0 {
|
||||
if seenRoot {
|
||||
return fmt.Errorf("multiple root elements")
|
||||
}
|
||||
if t.Name.Local != "slide" {
|
||||
return fmt.Errorf("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 fmt.Errorf("non-whitespace text outside root element")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !seenRoot {
|
||||
return fmt.Errorf("missing root element")
|
||||
}
|
||||
if depth != 0 {
|
||||
return fmt.Errorf("unclosed XML element")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
306
shortcuts/slides/slides_replace_pages_test.go
Normal file
306
shortcuts/slides/slides_replace_pages_test.go
Normal file
@@ -0,0 +1,306 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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 TestReplacePagesCreatesBeforeThenDeletesOld(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
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},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
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},
|
||||
},
|
||||
}
|
||||
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)
|
||||
}
|
||||
deleteURL := string(deleteStub.CapturedBody)
|
||||
if deleteURL != "" {
|
||||
t.Fatalf("delete body = %q, want empty", deleteURL)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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
|
||||
},
|
||||
}
|
||||
157
shortcuts/slides/slides_xml_get_test.go
Normal file
157
shortcuts/slides/slides_xml_get_test.go
Normal file
@@ -0,0 +1,157 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"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.Param != "--output" {
|
||||
t.Fatalf("param = %q, want --output", problem.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。PDF 导入 Slides 通常可按每页约 10 秒预估处理时间,轮询或续查时不要过早放弃。
|
||||
- 用户要在 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;PDF 可按每页约 10 秒预估导入处理时间)
|
||||
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 "导入数据表"
|
||||
@@ -78,6 +79,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
|
||||
3. 自动轮询查询导入任务状态;如果在内置轮询窗口内完成,则直接返回导入结果;如果仍未完成,则返回 `ticket`、当前状态和后续查询命令
|
||||
- **默认根目录行为**:不传 `--folder-token` 时,shortcut 会保留空的 `point.mount_key`,Lark Import API 会将其视为"导入到调用者根目录"。
|
||||
- **导入到已有 bitable**:当 `--type bitable` 且传了 `--target-token` 时,请求 body 中会增加一个 `token` 字段指向目标多维表格的 token,point 挂载点逻辑不变。数据会挂载到该已有多维表格中,而非创建新文档。
|
||||
- **PDF 导入 Slides 耗时预估**:PDF 导入 Slides 是长耗时异步任务,通常按每页约 10 秒估算总处理时间。`drive +import` 内置轮询超时只表示本地等待窗口结束,不代表任务失败。看到 `ready=false` / `timed_out=true` 时,必须继续执行返回的 `next_command` 查询同一个 `ticket`,不要重试导入、不要提前放弃;至少等待到 `页数 * 10 秒` 的预估处理时间后,再结合任务状态判断是否异常。
|
||||
|
||||
### 支持的文件类型转换
|
||||
|
||||
@@ -94,6 +96,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 +106,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 +140,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: "飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。不负责:本地 `.pptx` / `.pdf` 导入为 slides(走 `lark-drive` 的 `drive +import --type slides`)、云文档内容编辑(走 lark-doc)、云文档里的独立画板对象(走 lark-whiteboard,注意 slide 内嵌的流程图/架构图仍属本 skill)、上传或下载普通文件(走 lark-drive)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -14,8 +14,9 @@ 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` |
|
||||
| 用户提供 PDF/PPTX/slides 材料并要求生成或改写 PPT | 必须先导入/回读材料,并以导入后的 presentation 作为目标底稿二次创作;即使材料“只作为模板/视觉线索”,也要在导入后的 presentation 内替换内容模块 | `drive +import --type slides`、`planning-layer.md`、`asset-planning.md` |
|
||||
| 新建 PPT | 仅在没有用户提供可导入材料、用户明确要求另建,或导入失败/回读失败时,先解析并盘点用户附件,规划 `slide_plan.json`,再按复杂度创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` |
|
||||
| 已有 PPT 大幅改写 | 多页整页重建用 `+replace-pages`,单页局部编辑用 `+replace-slide` | `xml_presentations.get`、`lark-slides-replace-pages.md`、`lark-slides-edit-workflows.md` |
|
||||
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide`、`lark-slides-replace-slide.md` |
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,回读全文或单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `xml_presentations.get`、`xml_presentation.slide.get` |
|
||||
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面 | `slides +screenshot`、`lark-slides-screenshot.md` |
|
||||
@@ -23,31 +24,23 @@ 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 — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。先创建对应目录;规划层、素材处理层、视觉规划和验证分别遵循 [planning-layer.md](references/planning-layer.md)、[asset-planning.md](references/asset-planning.md)、[visual-planning.md](references/visual-planning.md)、[validation-checklist.md](references/validation-checklist.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。**
|
||||
|
||||
**CRITICAL — 新建演示文稿或大幅改写页面时,生成 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 — 创建或大幅改写后,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。**
|
||||
创建前自检或失败排障时,按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。
|
||||
|
||||
> [!NOTE]
|
||||
> `scripts/template_tool.py` 需要 Python 3。`references/template-index.json` 是脚本缓存/轻量路由索引,不是默认给 agent 阅读的文档;`assets/templates/*.xml` 是机器资源,只应通过脚本摘要或裁切,不要全文读取。
|
||||
|
||||
**CRITICAL — 使用模板生成或改写页面时,MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。**
|
||||
使用内置模板生成或改写页面时,先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。如果用户已经提供本地/在线模板材料,优先按用户材料导入二创,不走内置模板检索。
|
||||
|
||||
**编辑已有幻灯片页面**:优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
|
||||
**编辑已有幻灯片页面**:单个标题、文本块、图片或局部元素优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);已有 Slides 的多页大改优先用 [`+replace-pages`](references/lark-slides-replace-pages.md) 在原 presentation 内批量重建页面,避免 `slides +create` 生成新链接。选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。
|
||||
|
||||
## 身份选择
|
||||
|
||||
@@ -82,7 +75,7 @@ lark-cli auth login --domain slides
|
||||
按需再读:
|
||||
|
||||
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
|
||||
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)
|
||||
- 编辑:[`lark-slides-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)
|
||||
@@ -93,8 +86,6 @@ lark-cli auth login --domain slides
|
||||
|
||||
## Workflow
|
||||
|
||||
> **这是演示文稿,不是文档。** 每页 slide 是独立的视觉画面,信息密度要低,排版要留白。
|
||||
|
||||
### Design Ideas
|
||||
|
||||
不要生成无设计感的幻灯片。纯白背景 + 标题 + bullets 只能作为极简临时稿,不能作为正式交付。
|
||||
@@ -133,7 +124,9 @@ lark-cli auth login --domain slides
|
||||
- 不要把素材缺失表现为空白图片框;必须按 `fallback_if_missing` 生成 XML-native 视觉。
|
||||
- 不要留下模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。
|
||||
|
||||
### 创建方式选择
|
||||
### 无用户导入材料时的创建方式
|
||||
|
||||
以下创建方式仅适用于没有用户提供可导入材料、用户明确要求另建 deck,或导入失败/`xml_presentations.get` 无法回读的异常场景。
|
||||
|
||||
| 场景 | 推荐方式 |
|
||||
|------|----------|
|
||||
@@ -149,7 +142,7 @@ lark-cli auth login --domain slides
|
||||
|
||||
### 模板与脚本优先流程
|
||||
|
||||
模板细则见 [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,18 +152,19 @@ 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
|
||||
- 如果用户提供附件、文件路径、素材目录或类似“附件文件路径:...”的文本,先按 asset-planning.md 解析路径、枚举文件、读取/导入/上传可用素材;不要直接跳到大纲或 XML
|
||||
|
||||
Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json
|
||||
- 生成结构化大纲供用户确认;如使用模板,标明基于哪个模板改写
|
||||
- 生成结构化大纲供用户确认;如使用用户附件、导入后的 slides 或模板材料,标明每类素材如何参与二次创作
|
||||
- 新建 / 大幅改写必须先创建目录并写入 `.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 → 创建
|
||||
- 逐页消费 plan:key_message 定主结论,layout_type 定几何,visual_focus 定主视觉,text_density 定文本量
|
||||
- 缺少真实素材时必须用 `fallback_if_missing` 生成 XML-native 兜底视觉;不要留空
|
||||
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
|
||||
- 如果 plan 有 `target_xml_presentation_id`,默认在该 presentation 内创建、替换或删除页面;只有无导入材料、用户明确要求另建,或导入失败/回读失败时,才按“无用户导入材料时的创建方式”新建 deck
|
||||
|
||||
Step 4: 审查 & 交付
|
||||
- 创建完成后,必须用 xml_presentations.get 读取全文 XML,并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
|
||||
@@ -180,7 +174,7 @@ Step 4: 审查 & 交付
|
||||
|
||||
### jq 命令模板(编辑已有 PPT 时使用)
|
||||
|
||||
新建 PPT 推荐用 `+create --slides`。以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
|
||||
无用户导入材料的新建 PPT 可用 `+create --slides`。以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
|
||||
|
||||
```bash
|
||||
# 追加到末尾
|
||||
@@ -268,6 +262,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) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 |
|
||||
|
||||
没有 Shortcut 覆盖时使用原生 API。高频资源:`xml_presentations.get` 读取全文;`xml_presentation.slide.create/delete/get/replace` 管理单页。
|
||||
|
||||
@@ -280,13 +275,14 @@ 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. **用户材料默认作为二创底稿**:用户提供 PDF/PPTX/slides 材料并要求生成、改写、二创、压缩页数或保留材料风格/资产时,必须先导入或回读为 slides;默认 `target_xml_presentation_id` 等于导入或已有材料的 `xml_presentation_id`,在该 presentation 内继续创作。“只作为模板/视觉线索”只表示不复制原文案,不表示可以跳过导入或新建脱离材料的 deck
|
||||
2. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
|
||||
3. **创建流程**:仅在没有用户提供可导入材料、用户明确要求新建 deck,或导入失败/`xml_presentations.get` 无法回读时,才使用 `slides +create` 新建目标 deck;页数多、内容不可用、只参考风格、布局复杂或 PDF 是正文资料都不是新建理由
|
||||
4. **`<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`**:文本和图形必须放在 `<data>` 内
|
||||
5. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
|
||||
6. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id`
|
||||
7. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
|
||||
8. **编辑已有页面优先块级替换**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;已有 Slides 的多页整页重建用 `+replace-pages`,不要用 `slides +create` 新建整份 PPT;只有没有 shortcut 覆盖的特殊单页整页操作才手动 `slide.create` + `slide.delete`
|
||||
9. **`<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 不支持分片上传)。
|
||||
|
||||
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。
|
||||
|
||||
@@ -1,28 +1,208 @@
|
||||
# Asset Planning
|
||||
# Material And Asset Planning
|
||||
|
||||
新建演示文稿或大幅改写页面时,在写入 `slide_plan.json` 前后都可以参考本文件。目标是让 agent 主动识别有价值的图、图标、图表、流程图、时序图、架构图、装饰图案、截图或示意图需求,同时保持 deck 在没有真实素材时也能完整执行。
|
||||
新建演示文稿或大幅改写页面时,planning layer 必须先激活本素材处理层,再写入或更新 `slide_plan.json`。目标是让 agent 先利用用户已经提供的本地素材和上下文,再决定是否需要内置模板、联网搜索或 XML-native 兜底。
|
||||
|
||||
本文件只定义轻量资产规划。不要把它理解成素材采集流程。
|
||||
本文件覆盖两类对象:
|
||||
|
||||
- `material_inventory`:deck 级素材盘点,记录本地素材、链接、缺口、用途和搜索策略。
|
||||
- `asset_need`:page 级视觉资产需求,记录每页是否需要图片、图表、截图、图标、文案来源或兜底视觉。
|
||||
|
||||
## 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.
|
||||
- Every planned asset must include a fallback visual plan so the slide can be generated with XML shapes, text, arrows, tables, simple charts, whiteboard diagrams, or placeholder regions.
|
||||
- Asset needs must serve the page's `key_message` and `visual_focus`. Do not add decorative assets that do not clarify the page.
|
||||
- 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.
|
||||
- 本地素材优先。用户给了文件、目录、截图、PDF、PPTX、文案、数据表、链接或已有 slides 时,先按 Attachment Resolution 解析并盘点用途;禁止忽略附件直接生成大纲或 XML。
|
||||
- 没有合适本地素材时,才考虑联网搜索、内置模板、IconPark 或 XML-native 兜底。用户已提供模板材料时,这些能力只能补缺,不能替代导入后的目标 presentation 或主视觉系统。
|
||||
- 素材进入 plan 前必须分类并写清用途;不要把“有文件”直接等同于“应出现在页面上”。
|
||||
- 页面不能依赖不可获得素材才能完成。每个 `asset_need` 都必须有 `fallback_if_missing`。
|
||||
- 真实图片进入 slides 前必须走支持的上传路径:`slides +media-upload` 或 `+create --slides` 的 `@./path` 占位符。禁止把 http(s) 外链直接写进 `<img src>`。
|
||||
- `.pptx` / `.pdf` / online slides 参与制作或改写 PPT 时,按 Local Material Handling 先判断 `rewrite_source` / `copy_source`;可作为视觉底稿的材料必须导入或回读为 slides。
|
||||
|
||||
## JSON Shape
|
||||
## Attachment Resolution
|
||||
|
||||
Use an object for one planned asset, or an array when a page genuinely needs multiple assets. Keep each item compact.
|
||||
在写 `slide_plan.json` 前,先把用户提供的附件文本转成可操作的本地路径清单。
|
||||
|
||||
1. 从 prompt 提取所有附件线索:`附件文件路径:...`、`文件:...`、`上传文件:...`、相对路径、绝对路径、目录路径,以及结构化输入中单独列出的文件名。
|
||||
2. 逐个解析路径:
|
||||
- 已是绝对路径:直接检查是否存在。
|
||||
- 相对路径:先按当前工作目录解析;如果用户或调用方另行说明了素材根目录,也要按该根目录解析同一相对路径。
|
||||
- 只有文件名:先查用户明确给出的素材目录;没有目录时,只在当前工作目录和任务上下文明确给出的附件目录中查找。
|
||||
- 包含 URL 或在线 slides/wiki/drive/doc 链接:按对应 skill/API 获取内容或 token;不要当成本地路径。
|
||||
3. 如果路径文本和真实文件名只有轻微差异(例如 `-` 与 `_`、`.` 与 `_`、URL 转义、空格、中文括号、图片平台后缀中的 `~` 被本地保存为 `_`),在同目录用文件名相似匹配找候选;只有唯一高置信候选时使用,并在 `material_inventory.inputs[].notes` 记录映射。
|
||||
4. 对目录路径必须先列出直接子文件并按扩展名分组;不要只记录目录本身。
|
||||
5. 找不到的附件写入 `material_inventory.missing`,并说明会用什么 XML-native 或内置能力兜底。找不到附件不能成为空白页的理由。
|
||||
|
||||
最小盘点动作:
|
||||
|
||||
- 文档/表格/图片/PPT/PDF 至少要记录 `source`、`resolved_path`、`kind`、`usage`、`status`。
|
||||
- 如果素材会进入页面,`asset_need.candidate_sources` 必须使用解析后的可用路径,而不是原始不可用的路径文本。
|
||||
- 如果素材只用于理解或提供视觉线索,也必须在 `material_inventory.inputs` 中出现,避免后续 XML 生成阶段遗忘。
|
||||
|
||||
## Material Roles
|
||||
|
||||
将每个输入素材归入以下角色之一;一个素材可以有多个角色,但要分别说明用途。
|
||||
|
||||
| Role | 用途 | 常见来源 | 默认处理 |
|
||||
|------|------|----------|----------|
|
||||
| `background_reference` | 补充主题背景、事实、约束、术语、受众信息 | 文档、网页、PRD、报告、会议纪要 | 读取/摘要后影响叙事和页面重点 |
|
||||
| `visual_asset` | 可直接进入页面的视觉素材 | 图片、截图、logo、图表、论文 figure | 上传后放入计划区域;不合适则重绘或兜底 |
|
||||
| `copy_source` | 文案、标题、大纲、讲稿、卖点、结论来源 | Markdown、TXT、Docx、用户 prompt、会议纪要 | 改写成低密度 slide 文案 |
|
||||
| `data_source` | 生成表格、指标卡、图表的数据来源 | CSV、XLSX、表格截图、结构化数据 | 转成 chart/table/数字卡 |
|
||||
| `brand_asset` | 品牌识别和视觉约束 | logo、VI 色板、品牌手册 | 影响 `theme_style` / `visual_system` |
|
||||
| `rewrite_source` | 导入后承载二次创作的目标底稿 | 用户已有 PPTX/PDF/slides、背景模板、旧版汇报、待美化稿 | 导入/回读后作为 target presentation,规划保留、替换、删除和重排 |
|
||||
|
||||
PDF/PPTX/slides 的主角色只能是 `rewrite_source` 或 `copy_source`。如需表达背景、视觉或品牌信息,只写进 `usage`,不能改变目标 presentation。
|
||||
|
||||
## Source Priority
|
||||
|
||||
1. 用户显式提供的本地素材。
|
||||
2. 当前工作区内与任务明确相关的素材。
|
||||
3. 用户给出的在线 slides/wiki/drive/doc 链接。
|
||||
4. 内置模板库和 IconPark。
|
||||
5. 联网搜索公开素材或背景信息。
|
||||
6. XML-native 兜底视觉。
|
||||
|
||||
如果多个来源冲突,用户显式提供的素材优先;无法判断时,在 plan 的 `open_issues` 里记录需要用户确认。
|
||||
|
||||
## Local Material Handling
|
||||
|
||||
- `.pptx` / `.pdf` / online slides 若承担模板、背景模板、旧稿、待美化稿、品牌视觉或页面结构角色,默认是 `rewrite_source`:先导入或回读为 slides,`target_xml_presentation_id` 默认等于导入/已有 presentation,并在同一个 presentation 内创建、替换、删除或重排页面。
|
||||
- “内容不可用”“只作为背景模板”“不要使用模板文字”“只参考风格”只表示该材料不是 `copy_source`;它仍默认是 `rewrite_source`,用于保留或重绘背景、版式、图片资产和页面结构。
|
||||
- `.pdf` 若只是论文、报告、PRD、教案正文等内容资料,可以作为 `copy_source` 读取/摘要;但 `copy_source` 不得替代已有 `rewrite_source`,也不得成为新建 deck 的理由。
|
||||
- 用户明确说“只参考风格”时,不新增单独角色;仍把 PDF/PPTX/slides 作为 `rewrite_source` 导入/回读,只在 `usage` 中说明“不复制模板文案,仅沿用或重绘风格、版式和页面结构”。
|
||||
- 已有 online slides:直接回读 XML,默认把它作为 `rewrite_source`;不要再走导入。
|
||||
- `.png` / `.jpg` / `.jpeg` 等图片:判断是可直接展示、需要裁切/缩放、还是只用于理解。进入 XML 前必须上传或使用 `@./path` 占位符。
|
||||
- `.md` / `.txt` / 文档类内容:作为 `copy_source` 或 `background_reference`,提炼为低密度页面文案,不要整段搬进 slide。
|
||||
- `.docx` / `.doc`:通常是 `copy_source` 和 `background_reference`。先读取或转换提取正文、标题层级、表格和内嵌图片线索,再改写为 slide 叙事。用户要求“不要杜撰数据”时,数值和图表只能来自这类源文件或表格源;缺数据则在 plan 中标注缺口。
|
||||
- `.xlsx` / `.xls` / `.csv`:通常是 `data_source`。先识别工作表、列名、时间范围、指标和关键数值,再规划 `<chart>` / 表格 / 数字卡。用户明确要求精准图表时,必须让图表数据来自表格,不要手工编造。
|
||||
|
||||
## PDF Template Pre-slicing
|
||||
|
||||
大 PDF 模板用于二次创作时,可以先切出关键模板页生成小 PDF,再用 `drive +import --type slides` 导入小 PDF。这样减少导入和回读成本,同时仍保留在导入后的 presentation 内二创的主路径。
|
||||
|
||||
仅在同时满足这些条件时才切割:
|
||||
|
||||
- 任务是制作、改写、二创、压缩页数或替换内容模块,而不是单纯导入 PDF。
|
||||
- PDF 是 `rewrite_source`,只需要其中的视觉骨架、背景、版式、图片资产或页面结构。
|
||||
- 已能明确选择关键页,例如封面、目录、章节页、图谱/流程页、表格页、结尾页,通常保留 6-10 页或用户指定页。
|
||||
|
||||
禁止切割的场景:
|
||||
|
||||
- 用户只要求“导入 PDF 为 slides”“转换格式”“保留完整 PDF”“检查导入效果”。
|
||||
- 用户要求保留全部页序、全部页面内容或逐页迁移。
|
||||
- 无法判断关键页,且切割会丢失用户可能需要的视觉结构。
|
||||
|
||||
切割后在 `material_inventory.inputs[]` 中记录原 PDF 和压缩 PDF 的关系:
|
||||
|
||||
- 原 PDF 仍记录为 `source`,`usage` 说明只取关键视觉页。
|
||||
- 压缩 PDF 记录为实际导入对象,`status: "preprocessed"` 或 `"imported"`。
|
||||
- 记录 `selected_pages`、`preprocessed_path`、选择理由,以及后续导入得到的 `imported_xml_presentation_id` / `target_xml_presentation_id`。
|
||||
- 生成新页并回读成功后,再按计划删除或替换导入模板中的旧内容页。
|
||||
|
||||
## Image Asset Migration
|
||||
|
||||
导入的 PPTX/PDF 页面里可能已有 `<img src="file_token">`、背景图或品牌图。这些图片 token 可以在同一个导入后的 presentation 内复用;只有跨到另一个新建 presentation 时,才不能直接复制旧 XML 里的 `<img src>`。
|
||||
|
||||
在 `slide_plan.json` 中为模板或原稿记录 `template_asset_strategy`:
|
||||
|
||||
- `preserve_imported_page`:模板页含需要复用的背景图、装饰图、品牌图或复杂图片版式。优先在导入页上用 `+replace-slide` / `block_insert` / `block_replace` 做局部编辑,保留现有 `<img>` token。
|
||||
- `rebuild_in_imported_presentation`:需要重做 XML-native 页面,但仍在同一个导入后的 presentation 内创建/替换页面。可以复用该 presentation 内已有图片 token,无需下载再上传;删除旧页前先确认新页已创建成功。
|
||||
- `mixed`:同一 deck 中部分页面保留导入页图片资产,部分页面重建。每页在 plan 里说明来源页、目标 presentation、是否复用同一 presentation 的图片 token、是否需要重新上传。
|
||||
|
||||
首次运行时先判断目标页是否仍在导入后的同一个 presentation 内。只要同一 presentation,就可以复用回读 XML 里的图片 token;不要静默复制图片 token 到新文稿。异常新建只能由用户明确要求另建,或导入失败/回读失败触发;若需要复用导入页图片,必须记录下载/重新上传方案。
|
||||
|
||||
如果选择 `preserve_imported_page`、`rebuild_in_imported_presentation` 或 `mixed`,且导入后的 slides 会作为最终交付物继续编辑,完成后必须把在线文件标题改成用户任务对应的新标题,避免仍保留导入时的模板/原附件名称。标题修改走 `lark-drive` 的 `drive files patch`,使用 `new_title` 字段;不要为了改标题重建整份 PPT。
|
||||
|
||||
## Imported Material As Draft
|
||||
|
||||
当用户提供 PDF/PPTX/slides 材料并要求制作或改写 PPT 时,先在 plan 中定两类来源:
|
||||
|
||||
- `rewrite_source`:导入/回读后的目标视觉底稿,记录 `imported_xml_presentation_id`、`target_xml_presentation_id`、`template_asset_strategy` 和 `target_title`;默认策略是 `preserve_imported_page`、`rebuild_in_imported_presentation` 或 `mixed`。
|
||||
- `copy_source`:真正提供文案、事实、标题层级、数据或案例的材料。
|
||||
|
||||
只有用户明确要求新建,或导入失败/`xml_presentations.get` 无法回读时,才允许新建 deck,并说明原因和图片资产迁移策略。“页数不超过 N 页”、内容不可用、只参考风格、PDF 是正文资料或布局复杂,都不是新建 deck 的理由。如果附件里有素材但 plan 没有使用,必须在 `material_inventory.inputs[].usage` 说明原因。
|
||||
|
||||
## Search Policy
|
||||
|
||||
联网搜索不是默认动作。只有出现以下情况才搜索:
|
||||
|
||||
- 用户要求查找公开事实、行业背景、竞品、logo、截图、图片或最新资料。
|
||||
- plan 中存在关键视觉缺口,且用户模板材料无法满足;搜索结果只能补缺,不能替代用户模板的主视觉系统。
|
||||
- 用户给出的主题需要真实世界背景才能避免空泛表达。
|
||||
|
||||
搜索结果使用规则:
|
||||
|
||||
- 图片素材必须先落到本地,再按 Core Rules 的上传路径进入 XML。
|
||||
- 背景信息必须转化为 `background_reference` 摘要和页面结论,不要把长文本塞进页面。
|
||||
- 无法确认版权或来源可靠性时,只作为风格/信息参考,不直接作为可展示图片。
|
||||
- 如果搜索失败或不适合使用,按 `fallback_if_missing` 生成 XML-native 视觉。
|
||||
|
||||
## Plan Shape
|
||||
|
||||
Deck 级 `material_inventory` 片段示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"asset_type": "architecture_diagram",
|
||||
"purpose": "Show how API gateway, planner, XML generator, and Slides API interact.",
|
||||
"suggested_query": "agent native slides runtime architecture diagram",
|
||||
"fallback_if_missing": "Draw grouped boxes connected by arrows with short labels."
|
||||
"material_inventory": {
|
||||
"local_first": true,
|
||||
"inputs": [
|
||||
{
|
||||
"source": "./template.pdf",
|
||||
"resolved_path": "./template.pdf",
|
||||
"kind": "rewrite_source",
|
||||
"usage": "Import key visual pages as slides and use as the target visual draft; do not copy placeholder text.",
|
||||
"preprocessed_path": "./template_key_pages.pdf",
|
||||
"selected_pages": [1, 2, 5, 8, 12, 20],
|
||||
"status": "imported",
|
||||
"imported_xml_presentation_id": "SOURCE_PRESENTATION_ID",
|
||||
"target_xml_presentation_id": "SOURCE_PRESENTATION_ID",
|
||||
"template_asset_strategy": "rebuild_in_imported_presentation",
|
||||
"target_title": "New deck title"
|
||||
},
|
||||
{
|
||||
"source": "./notes.md",
|
||||
"resolved_path": "./notes.md",
|
||||
"kind": "copy_source",
|
||||
"usage": "Condense into slide headlines and speaker intent.",
|
||||
"status": "available"
|
||||
},
|
||||
{
|
||||
"source": "./draft.pptx",
|
||||
"resolved_path": "./draft.pptx",
|
||||
"kind": "rewrite_source",
|
||||
"usage": "Import and read back XML; preserve visual structure and restyle each page.",
|
||||
"status": "available"
|
||||
}
|
||||
],
|
||||
"missing": [
|
||||
{
|
||||
"need": "Product screenshot for workflow page",
|
||||
"search_policy": "Use local screenshot if provided; otherwise search only if user wants real UI imagery.",
|
||||
"fallback_if_missing": "Draw a simplified UI wireframe with labeled panels."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For a slide derived from a `rewrite_source`, add source-page mapping inside that slide plan, for example:
|
||||
|
||||
```json
|
||||
{
|
||||
"source_page": 3,
|
||||
"source_operation": "restyle",
|
||||
"preserve_content": "Keep the original claim, metrics, and section role; improve layout hierarchy and redraw the chart."
|
||||
}
|
||||
```
|
||||
|
||||
Page 级 `asset_need` 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"asset_type": "screenshot",
|
||||
"source_preference": "local_first",
|
||||
"purpose": "Show the target workflow state instead of describing it with bullets.",
|
||||
"candidate_sources": ["./assets/workflow.png"],
|
||||
"suggested_query": "product workflow screenshot",
|
||||
"fallback_if_missing": "Draw a simplified UI wireframe with three panels and callout labels."
|
||||
}
|
||||
```
|
||||
|
||||
@@ -31,7 +211,9 @@ For a page without a meaningful asset need, use:
|
||||
```json
|
||||
{
|
||||
"asset_type": "none",
|
||||
"source_preference": "none",
|
||||
"purpose": "No external or simulated asset needed; the page is text-led.",
|
||||
"candidate_sources": [],
|
||||
"suggested_query": "",
|
||||
"fallback_if_missing": "Use typography, spacing, and simple accent shapes only."
|
||||
}
|
||||
@@ -43,7 +225,7 @@ For a page without a meaningful asset need, use:
|
||||
- `architecture_diagram`: system components, data flow, dependency map, or model structure.
|
||||
- `icon`: small semantic symbol for a concept, step, role, or status.
|
||||
- `logo`: brand, product, team, or customer mark.
|
||||
- `chart`: line, bar, pie, radar, area, or combo data visual. Note: `<chart>` does not support funnel or scatter — map those to `<whiteboard>` SVG at generation time.
|
||||
- `chart`: line, bar, pie, radar, area, or combo data visual. Note: `<chart>` does not support funnel or scatter; map those to `<whiteboard>` at generation time.
|
||||
- `infographic`: composed visual explanation, usually combining labels, numbers, and simple shapes.
|
||||
- `screenshot`: product UI, terminal output, workflow state, or page capture.
|
||||
- `flow_diagram`: process, sequence, decision tree, or mechanism diagram.
|
||||
@@ -53,23 +235,23 @@ Do not invent new asset types unless the user asks for a special visual format.
|
||||
|
||||
## Planning Guidance
|
||||
|
||||
Match asset type to slide role:
|
||||
Match source type to slide role. Detailed geometry belongs in `visual-planning.md`; this mapping only helps choose `asset_need`.
|
||||
|
||||
- `architecture-diagram` layout usually pairs with `architecture_diagram` or `flow_diagram`.
|
||||
- `process-flow` layout usually pairs with `flow_diagram`, `icon`, or `infographic`.
|
||||
- `comparison` layout often works with `icon`, `chart`, or `infographic`.
|
||||
- `timeline` layout often works with `icon`, `chart`, or shape-based milestone markers.
|
||||
- `big-number` layout often works with `chart` or `infographic`, but only if it supports the metric.
|
||||
- `image-left-text-right` and `image-right-text-left` can use `screenshot`, `paper_figure`, `logo`, or `infographic`; if missing, use a large placeholder diagram or stylized panel.
|
||||
- `big-number` layout often works with `chart`, `data_source`, or `infographic`.
|
||||
- `image-left-text-right` and `image-right-text-left` can use `screenshot`, `paper_figure`, `logo`, `infographic`, or a layout derived from imported material.
|
||||
|
||||
`suggested_query` is only a future lookup hint. Write it as a short phrase a human or later workflow could search, but do not execute the search unless the user separately requests real assets.
|
||||
`suggested_query` is a future lookup hint. Execute the search only when the search policy says remote material is needed and local sources are insufficient.
|
||||
|
||||
`fallback_if_missing` must be concrete enough to turn into XML, for example:
|
||||
|
||||
- "Draw a simplified attention matrix with 5 token labels, semi-transparent cells, and arrows to output token."
|
||||
- "Use three grouped boxes with arrows from client to gateway to service; add small protocol labels."
|
||||
- "Render a mini bar chart with 4 bars using shapes and value labels."
|
||||
- "Use a bordered placeholder panel with product area labels, not an empty image."
|
||||
- "Use a bordered UI wireframe with product area labels, not an empty image."
|
||||
|
||||
Weak fallbacks to avoid:
|
||||
|
||||
@@ -78,47 +260,14 @@ Weak fallbacks to avoid:
|
||||
- "Leave blank if unavailable."
|
||||
- "Use generic decoration."
|
||||
|
||||
## Examples
|
||||
|
||||
Transformer Self-Attention page:
|
||||
|
||||
```json
|
||||
{
|
||||
"asset_type": "paper_figure",
|
||||
"purpose": "Explain token-to-token attention and why each output token mixes context.",
|
||||
"suggested_query": "Transformer self attention attention matrix diagram",
|
||||
"fallback_if_missing": "Draw a simplified attention matrix with token labels, colored weights, and arrows from input tokens to one highlighted output token."
|
||||
}
|
||||
```
|
||||
|
||||
System architecture page:
|
||||
|
||||
```json
|
||||
{
|
||||
"asset_type": "architecture_diagram",
|
||||
"purpose": "Show the runtime path from user prompt to plan, XML generation, Slides API creation, and fetch verification.",
|
||||
"suggested_query": "slides generation runtime architecture planner XML API verification",
|
||||
"fallback_if_missing": "Draw four grouped boxes connected left-to-right with arrows; put verification as a return arrow from Slides API to agent."
|
||||
}
|
||||
```
|
||||
|
||||
Business comparison page:
|
||||
|
||||
```json
|
||||
{
|
||||
"asset_type": "infographic",
|
||||
"purpose": "Make before/after differences scannable without dense bullet lists.",
|
||||
"suggested_query": "before after product workflow comparison infographic",
|
||||
"fallback_if_missing": "Use two side-by-side panels with matching icon circles and three parallel rows of concise labels."
|
||||
}
|
||||
```
|
||||
|
||||
## Plan To XML Contract
|
||||
|
||||
When generating XML:
|
||||
|
||||
1. If an asset exists and the workflow supports it, place it in the planned visual region.
|
||||
2. If no asset exists, immediately render `fallback_if_missing` with XML-native shapes, text, lines, arrows, tables, whiteboard diagrams, or chart-like elements.
|
||||
3. Size the fallback to satisfy `visual_focus`; it should be a real page element, not a tiny decoration.
|
||||
4. Keep text-density limits. Do not compensate for missing assets by adding long bullet text.
|
||||
5. After creation, fetch the presentation and verify asset pages are not blank and that each planned fallback is visible when no real asset was used.
|
||||
1. Apply `material_inventory` first: imported target material, copy sources, data sources, and visual assets decide what each page can use.
|
||||
2. If `target_xml_presentation_id` points to imported user material, create, replace, reorder, or delete pages in that presentation; do not call `slides +create` for a detached new deck unless the plan records an explicit user request to create a new deck or an import/readback failure.
|
||||
3. If a real visual asset exists and the workflow supports it, place it in the planned visual region.
|
||||
4. If no asset exists, immediately render `fallback_if_missing` with XML-native shapes, text, lines, arrows, tables, whiteboard diagrams, or chart-like elements.
|
||||
5. Size the fallback to satisfy `visual_focus`; it should be a real page element, not a tiny decoration.
|
||||
6. Keep text-density limits. Do not compensate for missing assets by adding long bullet text.
|
||||
7. After creation, fetch the presentation and verify asset pages are not blank and that each planned fallback is visible when no real asset was used.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 编辑已有 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 链接不变。
|
||||
|
||||
> 生成 XML 前**必读** [xml-schema-quick-ref.md](xml-schema-quick-ref.md)。
|
||||
|
||||
@@ -11,6 +11,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,不生成新 Slides 链接 |
|
||||
|
||||
> **没有字段级 patch**:即便只想改一个 `shape` 的 `topLeftX`,也得把整个块的新 XML 写出来用 `block_replace`。这不是"微调",是块级重写。
|
||||
|
||||
@@ -136,6 +137,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
|
||||
|
||||
95
skills/lark-slides/references/lark-slides-replace-pages.md
Normal file
95
skills/lark-slides/references/lark-slides-replace-pages.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# slides +replace-pages(多页整页重建)
|
||||
|
||||
批量替换已有演示文稿里的多个页面,保持原 `xml_presentation_id` 和原 Slides 链接不变。适合多页版式大改、坐标重排、整页视觉重建;单个文本框、图片或 shape 的局部编辑仍优先用 [`+replace-slide`](lark-slides-replace-slide.md)。
|
||||
|
||||
> 重要:这是多步编排,不是后端原子事务。CLI 对每页执行“先创建新页到旧页前,再删除旧页”;创建失败时旧页会保留。删除失败时可能出现新旧页同时存在,需要按返回结果继续处理。
|
||||
|
||||
## 命令
|
||||
|
||||
```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。
|
||||
- 同一批次不能重复 `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. 替换后再回读全文 XML 并截图检查,确认页序、视觉和文本没有破损。
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 用途
|
||||
|
||||
按 `slide_id` 拉取指定演示文稿单页的 XML 内容(可指定历史版本)。常用于"读-改-写"编辑闭环的第一步。
|
||||
按 `slide_id` 或 1-based `slide_number` 拉取指定演示文稿单页的 XML 内容(可指定历史版本)。常用于"读-改-写"编辑闭环的第一步。
|
||||
|
||||
## 命令
|
||||
|
||||
@@ -22,6 +22,7 @@ lark-cli slides xml_presentation.slide get --as user --params '<json_params>'
|
||||
{
|
||||
"xml_presentation_id": "slides_example_presentation_id",
|
||||
"slide_id": "slide_example_id",
|
||||
"slide_number": 1,
|
||||
"revision_id": -1
|
||||
}
|
||||
```
|
||||
@@ -29,12 +30,13 @@ lark-cli slides xml_presentation.slide get --as user --params '<json_params>'
|
||||
| 字段 | 类型 | 必需 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `xml_presentation_id` | string | 是 | 目标演示文稿唯一标识 |
|
||||
| `slide_id` | string | 是 | 目标页面唯一标识 |
|
||||
| `slide_id` | string | 否 | 目标页面唯一标识;与 `slide_number` 同时传时优先使用 `slide_id` |
|
||||
| `slide_number` | integer | 否 | 目标页码,从 1 开始;未传 `slide_id` 时可用 |
|
||||
| `revision_id` | integer | 否 | 版本号,`-1` 表示最新版(默认)|
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 读最新版本
|
||||
### 按 slide_id 读最新版本
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentation.slide get --as user --params '{
|
||||
@@ -43,6 +45,15 @@ lark-cli slides xml_presentation.slide get --as user --params '{
|
||||
}'
|
||||
```
|
||||
|
||||
### 按页码读取
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentation.slide get --as user --params '{
|
||||
"xml_presentation_id": "slides_example_presentation_id",
|
||||
"slide_number": 2
|
||||
}'
|
||||
```
|
||||
|
||||
### 只提取 XML 内容
|
||||
|
||||
```bash
|
||||
@@ -87,14 +98,15 @@ lark-cli slides xml_presentation.slide get --as user --params '{
|
||||
|
||||
| 错误码 | 含义 | 解决方案 |
|
||||
|--------|------|----------|
|
||||
| 404 | 演示文稿或页面不存在 | 检查 `xml_presentation_id` / `slide_id` |
|
||||
| 404 | 演示文稿或页面不存在 | 检查 `xml_presentation_id` / `slide_id` / `slide_number` |
|
||||
| 403 | 权限不足 | 需要 `slides:presentation:read` scope,并对该 PPT 有访问权限 |
|
||||
| 400 | `revision_id` 不存在 | 传了无效版本号,用 `-1` 或真实存在的版本号 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **执行前必做**:`lark-cli schema slides.xml_presentation.slide.get` 查看最新参数结构
|
||||
2. **block_id 提取**:返回 XML 里每个顶层块(shape、img、table、chart、whiteboard 等)的 `id` 属性即为 `block_id`,通常是 3 字符短码,例如 `<shape id="bUn" ...>`。用以下命令列出当前页所有 block_id:
|
||||
2. **选择器优先级**:`slide_id` 和 `slide_number` 至少传一个;如果同时传,CLI 会以 `slide_id` 为准。
|
||||
3. **block_id 提取**:返回 XML 里每个顶层块(shape、img、table、chart、whiteboard 等)的 `id` 属性即为 `block_id`,通常是 3 字符短码,例如 `<shape id="bUn" ...>`。用以下命令列出当前页所有 block_id:
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentation.slide get --as user \
|
||||
|
||||
@@ -21,7 +21,8 @@ lark-cli slides xml_presentations get --as user --params '<json_params>'
|
||||
```json
|
||||
{
|
||||
"xml_presentation_id": "slides_example_presentation_id",
|
||||
"revision_id": -1
|
||||
"revision_id": -1,
|
||||
"remove_attr_id": false
|
||||
}
|
||||
```
|
||||
|
||||
@@ -29,6 +30,7 @@ lark-cli slides xml_presentations get --as user --params '<json_params>'
|
||||
|------|------|------|------|
|
||||
| `xml_presentation_id` | string | 是 | 演示文稿的唯一标识符 |
|
||||
| `revision_id` | integer | 否 | 版本号,`-1` 表示最新版本 |
|
||||
| `remove_attr_id` | boolean | 否 | 是否移除返回 XML 中的 `id` 属性;默认 `false` |
|
||||
|
||||
## 使用示例
|
||||
|
||||
@@ -44,6 +46,15 @@ lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id"
|
||||
lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id":"slides_example_presentation_id"}' | jq -r '.data.xml_presentation.content'
|
||||
```
|
||||
|
||||
### 移除 XML id 属性读取
|
||||
|
||||
```bash
|
||||
lark-cli slides xml_presentations get --as user --params '{
|
||||
"xml_presentation_id": "slides_example_presentation_id",
|
||||
"remove_attr_id": true
|
||||
}'
|
||||
```
|
||||
|
||||
### 保存到文件
|
||||
|
||||
```bash
|
||||
@@ -88,8 +99,9 @@ lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id"
|
||||
|
||||
1. **执行前必做**: 使用 `lark-cli schema slides.xml_presentations.get` 查看最新的参数结构
|
||||
2. 返回的 XML 在 `data.xml_presentation.content` 字段中
|
||||
3. 如果只需要部分信息,可以使用 `jq` 等工具过滤返回结果
|
||||
4. 建议将获取的 XML 保存为文件,便于后续编辑或备份
|
||||
3. `remove_attr_id=true` 会移除返回 XML 中的元素 `id` 属性,适合在重复 id 导致全文读取失败时兜底;但返回内容无法直接用于依赖 `block_id` 的精确替换、插入、评论定位等操作
|
||||
4. 如果只需要部分信息,可以使用 `jq` 等工具过滤返回结果
|
||||
5. 建议将获取的 XML 保存为文件,便于后续编辑或备份
|
||||
|
||||
## 相关命令
|
||||
|
||||
|
||||
@@ -1,77 +1,106 @@
|
||||
# Planning Layer
|
||||
# 规划层
|
||||
|
||||
新建演示文稿或大幅改写页面时,必须先写 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。这个文件是 deck 的设计中间层,用来把叙事、页面角色、布局、视觉重点和文字密度固定下来,避免从用户提示直接跳到 XML。
|
||||
新建演示文稿或大幅改写页面时,必须先写 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`,再生成 XML。这个文件是演示文稿的设计中间层,用来把叙事、页面角色、布局、视觉重点和文字密度固定下来,避免从用户提示直接跳到 XML。
|
||||
|
||||
小型已有页编辑可豁免,例如只替换一个标题、改一个数字、插入一个块、上传并插入一张图。只要任务会重排多页、生成新 deck、替换整页结构,仍然需要规划层。
|
||||
小型已有页编辑可豁免,例如只替换一个标题、改一个数字、插入一个块、上传并插入一张图。只要任务会重排多页、生成新演示文稿、替换整页结构,仍然需要规划层。
|
||||
|
||||
## Required Flow
|
||||
## 必需流程
|
||||
|
||||
1. 理解用户需求,必要时澄清主题、受众、页数、风格。
|
||||
2. 如果适合模板,先用 `template_tool.py search` 检索,锁定模板后用 `summarize` 获取主题和页型信息。
|
||||
3. 选择唯一 plan 目录:`.lark-slides/plan/<deck-or-task-id>/`。
|
||||
2. 激活素材处理层:按 `asset-planning.md` 解析提示词中的附件路径、素材目录、上传文件名和链接,盘点用户提供的本地素材、可引用链接和缺口素材;本地素材优先,没有合适本地素材时再使用内置模板或联网搜索。
|
||||
3. 选择唯一规划目录:`.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`。
|
||||
6. 读取 `xml-schema-quick-ref.md`、`visual-planning.md` 和 `asset-planning.md`。
|
||||
7. 按 plan、visual planning 和 asset planning 规则逐页生成 XML,把 `layout_type`、`visual_focus`、`text_density` 转成具体页面几何和文本量约束,并把缺失素材转成可执行兜底视觉。
|
||||
8. 创建 PPT 后用 `xml_presentations.get` 回读,核对页面数量、关键元素和 plan 到 XML 的对应关系。
|
||||
7. 按规划文件、视觉规划和素材规划规则逐页生成 XML,把 `layout_type`、`visual_focus`、`text_density` 转成具体页面几何和文本量约束,并把缺失素材转成可执行兜底视觉。
|
||||
8. 创建或大幅改写后,按 `validation-checklist.md` 做显式验证;本文件只要求验证记录能说明规划到 XML 的对应关系。
|
||||
|
||||
模板不能代替 plan。模板搜索和摘要只能影响 `theme_style`、页面流、布局选择和局部布局骨架;最终仍必须有 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`。
|
||||
素材和模板不能代替规划文件,但素材处理层可以决定材料角色、目标 presentation、改写方式和资产复用策略;最终仍必须有 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`。
|
||||
|
||||
## Plan Path
|
||||
可导入为 slides 的用户材料(`.pptx`、`.pdf` 或已有 online slides)参与制作/改写 PPT 时,默认是 `rewrite_source`:先导入或回读为 slides,`target_xml_presentation_id` 默认等于导入/已有 presentation,并在该 presentation 内继续创作。
|
||||
|
||||
Use a separate plan directory per deck or task so multiple presentations in the same workspace cannot overwrite each other.
|
||||
“内容不可用”“只作为背景模板”“不要使用模板文字”“只参考风格”只排除 `copy_source`,不排除 `rewrite_source`。这类材料仍要导入/回读,并在导入后的 presentation 内替换、插入或删除页面内容。只有用户明确要求另建,或导入失败/`xml_presentations.get` 无法回读时,才新建演示文稿,并在 plan 中写明原因。
|
||||
|
||||
Recommended IDs:
|
||||
如果大 PDF 模板只用于二次创作的视觉底稿,可先按 `asset-planning.md` 选择关键模板页生成压缩版 PDF,再导入这个小 PDF。纯导入、归档、格式转换或用户要求保留完整 PDF 页序的任务,不做切割。
|
||||
|
||||
- New deck before creation: title slug plus date/time, such as `q3-review-20260507-1805`.
|
||||
- Existing PPT rewrite: the `xml_presentation_id`.
|
||||
- Ambiguous or untitled task: short task slug plus date/time.
|
||||
不要把附件路径只当作提示词文本。像 `附件文件路径:path/to/report.docx` 这样的路径必须解析到真实文件;相对路径按当前工作目录和用户明确给出的素材根目录解析,不能硬编码某个固定附件目录。
|
||||
|
||||
Rules:
|
||||
## 规划路径
|
||||
|
||||
- Do not reuse `.lark-slides/plan/slide_plan.json` as a shared path.
|
||||
- Create the directory before writing the file.
|
||||
- Reuse the same plan path for XML generation and post-create verification for that deck.
|
||||
每个演示文稿或任务使用独立的规划目录,避免同一工作区内多个演示文稿相互覆盖。
|
||||
|
||||
## Artifact Lifecycle
|
||||
推荐 ID:
|
||||
|
||||
`.lark-slides/` is local agent state. It supports recovery, iteration, and later edits, but it should not be treated as source code or committed by default.
|
||||
- 新建演示文稿:标题短标识加日期时间,例如 `q3-review-20260507-1805`。
|
||||
- 改写已有 PPT:使用 `xml_presentation_id`。
|
||||
- 主题不明确或未命名任务:短任务标识加日期时间。
|
||||
|
||||
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.
|
||||
- 不要复用 `.lark-slides/plan/slide_plan.json` 作为共享路径。
|
||||
- 写文件前先创建目录。
|
||||
- 同一个演示文稿的 XML 生成和创建后验证必须复用同一个规划路径。
|
||||
|
||||
Clean or avoid keeping:
|
||||
## 产物生命周期
|
||||
|
||||
- Transient XML payloads after successful creation and verification. Prefer `/tmp` for throwaway XML, or delete generated XML files after success.
|
||||
- Stale XML drafts that no longer match the current presentation state.
|
||||
`.lark-slides/` 是本地智能体状态,用于恢复、迭代和后续编辑;默认不要把它当作源码提交。
|
||||
|
||||
Exception:
|
||||
保留:
|
||||
|
||||
- If creation fails or partially succeeds, keep the relevant XML/debug payloads until recovery is complete. Record `xml_presentation_id` first, then fetch current state before retrying.
|
||||
- 创建成功或完成大幅改写后,保留 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`。规划文件是该演示文稿后续可编辑的设计状态。
|
||||
- 对后续工作有帮助时,保留小型清单,例如 `xml_presentation_id`、页面 ID、`revision_id`、规划路径和验证状态。
|
||||
|
||||
## JSON Shape
|
||||
清理或避免保留:
|
||||
|
||||
- 创建和验证成功后的临时 XML 请求内容。一次性 XML 优先放在 `/tmp`,或成功后删除生成的 XML 文件。
|
||||
- 已不匹配当前演示文稿状态的陈旧 XML 草稿。
|
||||
|
||||
例外:
|
||||
|
||||
- 如果创建失败或部分成功,保留相关 XML 或调试请求内容,直到恢复完成。先记录 `xml_presentation_id`,再拉取当前状态后重试。
|
||||
|
||||
## JSON 结构
|
||||
|
||||
```json
|
||||
{
|
||||
"presentation_goal": "Explain the proposal and secure approval for the next phase.",
|
||||
"audience": "Product and engineering leaders who know the domain but need a concise decision narrative.",
|
||||
"theme_style": "Clean business style, light background, restrained blue accent, strong visual hierarchy.",
|
||||
"presentation_goal": "说明方案并争取下一阶段批准。",
|
||||
"audience": "了解领域背景、但需要简洁决策叙事的产品和工程负责人。",
|
||||
"theme_style": "清爽商务风格,浅色背景,克制蓝色强调,视觉层级清晰。",
|
||||
"material_inventory": {
|
||||
"local_first": true,
|
||||
"inputs": [
|
||||
{
|
||||
"source": "./reference.pdf",
|
||||
"resolved_path": "./reference.pdf",
|
||||
"kind": "rewrite_source",
|
||||
"usage": "导入为在线幻灯片后,在导入 presentation 内二次创作。",
|
||||
"status": "imported",
|
||||
"imported_xml_presentation_id": "SOURCE_PRESENTATION_ID",
|
||||
"target_xml_presentation_id": "SOURCE_PRESENTATION_ID",
|
||||
"template_asset_strategy": "rebuild_in_imported_presentation"
|
||||
}
|
||||
],
|
||||
"missing": [
|
||||
{
|
||||
"need": "产品界面截图",
|
||||
"search_policy": "仅在没有本地截图时搜索;否则使用 XML 原生兜底视觉。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"visual_system": {
|
||||
"background_strategy": "Content pages use one light base; cover and closing may use a related dark treatment with the same accent system.",
|
||||
"motif": "A reusable left accent bar and consistent card/header treatments.",
|
||||
"background_strategy": "内容页使用统一浅色基底;封面和结尾页可使用同一强调色体系下的深色处理。",
|
||||
"motif": "可复用的左侧强调条,以及一致的卡片和页眉处理。",
|
||||
"color_roles": {
|
||||
"primary": "Used for the dominant structural motif and about 60-70% of visual weight.",
|
||||
"secondary": "Used for grouped regions, comparison panels, or supporting categories.",
|
||||
"accent": "Used only for key numbers, conclusions, or focus markers."
|
||||
"primary": "用于主要结构母题,占约 60-70% 的视觉权重。",
|
||||
"secondary": "用于分组区域、对比面板或辅助类别。",
|
||||
"accent": "仅用于关键数字、结论或焦点标记。"
|
||||
}
|
||||
},
|
||||
"typography_constraints": {
|
||||
"title_max_lines": 2,
|
||||
"body_max_lines_per_box": 2,
|
||||
"footer_max_lines": 1,
|
||||
"long_text_handling": "Shorten, split into multiple boxes, or move detail to speaker notes instead of shrinking into a tight box."
|
||||
"long_text_handling": "先缩短、拆分到多个文本框,或把细节移到演讲者备注;不要靠极小字号硬塞。"
|
||||
},
|
||||
"verification_plan": {
|
||||
"check_background_consistency": true,
|
||||
@@ -82,49 +111,50 @@ Exception:
|
||||
"slides": [
|
||||
{
|
||||
"page": 1,
|
||||
"title": "Proposal Title",
|
||||
"key_message": "The initiative is ready for a focused pilot.",
|
||||
"title": "方案标题",
|
||||
"key_message": "该方向已经具备小范围试点条件。",
|
||||
"layout_type": "title-cover",
|
||||
"visual_focus": "Large title area with one concise supporting statement.",
|
||||
"visual_focus": "大标题区域配一条简洁支撑语。",
|
||||
"asset_need": {
|
||||
"asset_type": "logo",
|
||||
"purpose": "Signal product or team identity on the opening page.",
|
||||
"suggested_query": "product logo",
|
||||
"fallback_if_missing": "Use a small text badge and abstract shape motif instead of a real logo."
|
||||
"purpose": "在开场页传达产品或团队身份。",
|
||||
"suggested_query": "产品标志",
|
||||
"fallback_if_missing": "使用小型文字徽标和抽象形状母题替代真实标志。"
|
||||
},
|
||||
"text_density": "low",
|
||||
"speaker_intent": "Frame the decision and establish the deck's point of view."
|
||||
"speaker_intent": "界定本次决策问题,并建立整份演示文稿的观点。"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Required Fields
|
||||
## 必需字段
|
||||
|
||||
Top-level fields:
|
||||
顶层字段:
|
||||
|
||||
- `presentation_goal`: what the whole deck is trying to achieve.
|
||||
- `audience`: target readers or listeners and their assumed background.
|
||||
- `theme_style`: visual tone, palette direction, and professional style.
|
||||
- `visual_system`: deck-level visual rules that must stay stable across pages, including background strategy, recurring motif, and color roles.
|
||||
- `typography_constraints`: deck-level limits for line count, text box density, and how to handle long text before XML generation.
|
||||
- `verification_plan`: explicit checks to perform after creation or major edits; include background consistency, text fit, visual focus, and asset rendering when relevant.
|
||||
- `slides`: ordered page plans.
|
||||
- `presentation_goal`:整份演示文稿要达成的目标。
|
||||
- `audience`:目标读者或听众,以及他们默认具备的背景。
|
||||
- `theme_style`:视觉语气、配色方向和专业风格。
|
||||
- `material_inventory`:规划层对本地输入、链接参考、选定用途、缺失素材、搜索策略和兜底方案的记录。遵循 `asset-planning.md`。
|
||||
- `visual_system`:演示文稿级视觉规则,必须跨页稳定,包括背景策略、复用母题和颜色角色。
|
||||
- `typography_constraints`:演示文稿级文字行数、文本框密度和长文本处理限制,用于约束 XML 生成前的文案。
|
||||
- `verification_plan`:最终验证记录必须覆盖的规划专属检查项。创建后的详细验证见 `validation-checklist.md`。
|
||||
- `slides`:有序页面计划。
|
||||
|
||||
Each slide must include:
|
||||
每一页必须包含:
|
||||
|
||||
- `page`: 1-based page number.
|
||||
- `title`: slide title.
|
||||
- `key_message`: the one idea this page must land.
|
||||
- `layout_type`: planned page structure.
|
||||
- `visual_focus`: dominant visual object or region.
|
||||
- `asset_need`: planning-only structured asset metadata; no search, download, or upload required. Follow `asset-planning.md`.
|
||||
- `text_density`: `low`, `medium`, or `high`.
|
||||
- `speaker_intent`: why the speaker needs this page and how it advances the story.
|
||||
- `page`:从 1 开始的页码。
|
||||
- `title`:页面标题。
|
||||
- `key_message`:本页必须传达的单一核心观点。
|
||||
- `layout_type`:计划使用的页面结构。
|
||||
- `visual_focus`:主导视觉对象或区域。
|
||||
- `asset_need`:仅用于规划的结构化素材元数据;不要求立即搜索、下载或上传。遵循 `asset-planning.md`。
|
||||
- `text_density`:`low`、`medium` 或 `high`。
|
||||
- `speaker_intent`:演讲者为什么需要这一页,以及它如何推进叙事。
|
||||
|
||||
## Layout Vocabulary
|
||||
## 布局词表
|
||||
|
||||
Use one of these `layout_type` values unless the user explicitly needs a custom structure:
|
||||
除非用户明确需要自定义结构,否则使用以下 `layout_type` 值之一:
|
||||
|
||||
- `title-cover`
|
||||
- `section-divider`
|
||||
@@ -139,81 +169,101 @@ Use one of these `layout_type` values unless the user explicitly needs a custom
|
||||
- `quote-highlight`
|
||||
- `conclusion`
|
||||
|
||||
The value must affect XML geometry, not just appear as a label. For example, `timeline` should create a horizontal or vertical sequence, `comparison` should create distinct side-by-side regions, and `big-number` should reserve dominant space for a large metric.
|
||||
该值必须影响 XML 几何结构,不能只是标签。例如,`timeline` 应生成横向或纵向序列,`comparison` 应生成清晰并排区域,`big-number` 应为大指标保留主导空间。
|
||||
|
||||
## Text Density Rules
|
||||
## 文字密度规则
|
||||
|
||||
- `low`: title plus 1 short statement, or 1-3 very short labels.
|
||||
- `medium`: title plus 2-4 concise bullets or labeled regions.
|
||||
- `high`: allowed only when the user needs detail; use tables, columns, or grouped regions instead of a long bullet list.
|
||||
- `low`:标题加 1 条短陈述,或 1-3 个很短的标签。
|
||||
- `medium`:标题加 2-4 条简洁要点,或 2-4 个带标签区域。
|
||||
- `high`:仅在用户确实需要细节时使用;优先用表格、分栏或分组区域,不要使用长项目符号列表。
|
||||
|
||||
Do not let all pages become title + bullet slides. For decks of 4 or more pages, aim for at least 4 different `layout_type` values when the content allows it.
|
||||
不要让所有页面都变成“标题 + 项目符号”。对于 4 页及以上的演示文稿,在内容允许时尽量使用至少 4 种不同的 `layout_type`。
|
||||
|
||||
Text density must be realistic for the planned geometry. If a page needs long titles, bilingual labels, paper figure captions, legal disclaimers, or dense technical wording, record how the text will be shortened, split, or moved to speaker notes. Do not rely on small font sizes or tight boxes to make text fit.
|
||||
文字密度必须匹配计划中的几何结构。如果页面需要长标题、双语标签、论文图注、法律声明或密集技术表述,必须记录如何缩短、拆分或移到演讲者备注。不要依赖小字号或紧凑文本框来塞下内容。
|
||||
|
||||
## Visual System Planning
|
||||
## 视觉系统规划
|
||||
|
||||
Before generating XML, define a visual system that can survive the whole deck:
|
||||
生成 XML 前,定义能贯穿整份演示文稿的视觉系统:
|
||||
|
||||
- `background_strategy`: specify the default background for normal content pages, and which page roles may intentionally differ. Do not let pages drift through near-identical but inconsistent background colors.
|
||||
- `motif`: choose one or two reusable structural devices, such as a side bar, header rail, numbered node, card treatment, diagram lane, or section band. The motif should appear consistently enough that pages feel related.
|
||||
- `color_roles`: assign primary, secondary, and accent roles. The same color must not mean unrelated things across pages.
|
||||
- `cover_content_relationship`: if the cover uses a different dark or image-led treatment, state how it connects to content pages through shared colors, motifs, or geometry.
|
||||
- `closing_relationship`: if the closing page mirrors the cover, state that explicitly so it looks intentional rather than like a new theme.
|
||||
- `background_strategy`:说明普通内容页的默认背景,以及哪些页面角色可以有意不同。不要让页面使用接近但不一致的背景色。
|
||||
- `motif`:选择一到两个可复用结构装置,例如侧边栏、页眉轨道、编号节点、卡片处理、图示泳道或章节色带。母题要足够一致,让页面看起来属于同一套设计。
|
||||
- `color_roles`:分配主色、辅助色和强调色角色。同一种颜色不能在不同页面表达无关含义。
|
||||
- `cover_content_relationship`:如果封面使用不同的深色或图片主导处理,说明它如何通过共享颜色、母题或几何关系连接内容页。
|
||||
- `closing_relationship`:如果结尾页呼应封面,明确写出,避免看起来像临时换了主题。
|
||||
|
||||
These are planning constraints, not decoration notes. They must affect coordinates, background fills, shape styles, and text placement in generated XML.
|
||||
这些是规划约束,不是装饰备注。它们必须影响生成 XML 中的坐标、背景填充、形状样式和文本位置。
|
||||
|
||||
## Iterative Deck State
|
||||
## 迭代状态
|
||||
|
||||
When continuing an existing deck, update the same plan path rather than creating a new disconnected plan. Keep the plan aligned with what has actually been created.
|
||||
继续编辑已有演示文稿时,更新同一个规划路径,不要创建脱节的新规划文件。规划文件必须和已经实际创建的内容保持一致。
|
||||
|
||||
Recommended optional fields for long-running work:
|
||||
长任务推荐使用的可选字段:
|
||||
|
||||
- `deck_status`: current slide count, target slide count if known, and last verified revision or timestamp.
|
||||
- `created_slides`: page number, slide id when known, and the page role.
|
||||
- `assets_used`: source, local path when applicable, uploaded token when known, and which page uses it.
|
||||
- `open_issues`: known layout, text fit, asset, or consistency risks that still need correction.
|
||||
- `deck_status`:当前页数、已知目标页数,以及最后验证的版本或时间戳。
|
||||
- `created_slides`:页码、已知页面 ID 和页面角色。
|
||||
- `assets_used`:来源、适用时的本地路径、已知上传令牌,以及使用它的页面。
|
||||
- `open_issues`:仍需修正的布局、文本适配、素材或一致性风险。
|
||||
|
||||
Do not hard-code a page number just because a previous deck used that pattern. Plan by page role and evidence need, such as "method overview pages should use a figure when the source has a readable figure" instead of binding screenshots, charts, or diagrams to a fixed page index. The plan should describe decision rules, not a rigid template sequence.
|
||||
不要因为之前的演示文稿用过某个模式就硬编码页码。应按页面角色和证据需求规划,例如“来源中有可读图时,方法概览页应使用图”,而不是把截图、图表或示意图绑定到固定页码。规划文件应描述决策规则,而不是僵硬模板序列。
|
||||
|
||||
## Asset Planning
|
||||
## 素材规划
|
||||
|
||||
`asset_need` is metadata. It can describe a desired figure, diagram, chart, icon, logo, screenshot, or fallback shape-based visual, but it must not require web search, local download, or media upload.
|
||||
`material_inventory` 记录 XML 生成前的演示文稿级来源处理;`asset_need` 记录页面级视觉需求。两者都遵循 `asset-planning.md`。
|
||||
|
||||
Use an object for one planned asset, an array for multiple real needs, or `asset_type: "none"` when no asset is useful. Each planned asset must include:
|
||||
本地素材优先于远程搜索。常见素材角色:
|
||||
|
||||
- `asset_type`: one of `paper_figure`, `architecture_diagram`, `icon`, `logo`, `chart`, `infographic`, `screenshot`, `flow_diagram`, or `none`.
|
||||
- `purpose`: why this asset helps the page's key message.
|
||||
- `suggested_query`: short future lookup hint only; do not execute it unless separately requested.
|
||||
- `fallback_if_missing`: concrete XML-native visual plan using shapes, labels, tables, whiteboard diagrams, or placeholder panels.
|
||||
- `background_reference`:用于理解主题、事实、受众或约束的非模板文档或链接。
|
||||
- `visual_asset`:可能出现在页面中的图片、截图、标志、图标、图表、示意图或论文图。
|
||||
- `copy_source`:作为内容输入的正文、提纲、笔记、PRD、报告或转写稿。
|
||||
- `data_source`:用于图表或表格的数据表、CSV/XLSX、指标或结构化数值。
|
||||
- `rewrite_source`:导入后承载二次创作的目标底稿,可提供背景、版式、图片资产和页面结构;即使其文案不可用,也不应只当作宽泛视觉线索。
|
||||
|
||||
For detailed rules and examples, read `asset-planning.md`.
|
||||
规划页面前,先解析所有附件路径,并写入 `material_inventory.inputs`。每个可用本地输入应包含:
|
||||
|
||||
Good examples:
|
||||
- `source`:用户原始提供的路径或文件标签。
|
||||
- `resolved_path`:实际存在的本地路径;能被 `lark-cli` 使用时,优先记录相对当前工作目录的路径。
|
||||
- `kind`:一个或多个素材角色。
|
||||
- `usage`:它将如何影响叙事、视觉、数据或风格。
|
||||
- `status`:`available`、`imported`、`uploaded`、`read`、`skipped` 或 `missing`。
|
||||
- `notes`:可选映射细节,例如“用户提供的相对路径已按指定素材目录解析”。
|
||||
|
||||
- `{"asset_type":"architecture_diagram","purpose":"Explain component relationships.","suggested_query":"service architecture diagram","fallback_if_missing":"Draw a component diagram with grouped boxes, connector arrows, and short labels."}`
|
||||
- `{"asset_type":"logo","purpose":"Identify the customer context.","suggested_query":"customer logo","fallback_if_missing":"Use a text label in a small badge."}`
|
||||
- `{"asset_type":"chart","purpose":"Show adoption trend.","suggested_query":"monthly adoption trend chart","fallback_if_missing":"Draw a simple trend line chart with axis labels and data points."}`
|
||||
如果附件是可导入为 slides 的用户材料,并已导入为在线幻灯片,在已知时记录 `imported_xml_presentation_id` 或 `import_ticket`。按 `asset-planning.md` 判断角色;默认把可作为视觉底稿的导入 XML 作为 `rewrite_source` 使用,并记录 `target_xml_presentation_id`。
|
||||
|
||||
## XML Generation Contract
|
||||
对导入的用户材料,还要按 `asset-planning.md` 记录 `template_asset_strategy`:`preserve_imported_page`、`rebuild_in_imported_presentation` 或 `mixed`。在同一个已导入演示文稿内创建或替换页面时,可以复用导入页的图片令牌;不要把 `<img src>` 令牌直接复制到另一个新演示文稿。如果导入的在线幻灯片文件会成为编辑后的最终交付物,记录 `target_title`,并在编辑完成后重命名在线文件,避免仍保留源材料或附件标题。异常新建只能由用户明确要求另建,或导入失败/回读失败触发,并必须记录原因和图片资产迁移方式。
|
||||
|
||||
Before writing each slide XML, map the plan fields to concrete decisions:
|
||||
如果附件是 `rewrite_source`,每个受影响页面计划都应说明来源页或来源章节,以及预期操作:`preserve`、`condense`、`expand`、`reorder`、`restyle`、`replace_visual_only` 或 `delete`。对于“内容不用改”类请求,默认使用 `replace_visual_only` 或 `restyle`,并保持原始论断、数字、名称和页面意图不变。对于“材料页数多于目标页数”的任务,先规划要保留或改写的来源页,再删除无关页;不要因为需要压缩页数就另建脱离材料的新 deck。
|
||||
|
||||
- `key_message` determines the headline, dominant claim, or main takeaway.
|
||||
- `layout_type` determines the coordinate structure and element types. Use `visual-planning.md` for concrete layout rules.
|
||||
- `visual_focus` determines the largest visual region or emphasized object.
|
||||
- `text_density` caps visible text volume.
|
||||
- `asset_need` informs placeholder diagrams, icons, charts, screenshots, or shape-based fallback visuals only. Missing real assets must use `fallback_if_missing`, not blank regions.
|
||||
用户提供的 PDF/PPTX/slides 即使只参考风格,也不能脱离导入后的 presentation 新建;除非用户明确要求另建,或导入失败/回读失败。
|
||||
|
||||
After creating the PPT, fetch the presentation and verify:
|
||||
单个计划素材使用对象,多个真实需求使用数组;没有有用素材时使用 `asset_type: "none"`。每个计划素材必须包含:
|
||||
|
||||
- Page count matches the plan.
|
||||
- Every page has the planned title and key message represented.
|
||||
- At least several pages have visibly different XML layout structures.
|
||||
- Planned `visual_focus` appears as a dominant visual region or object.
|
||||
- Asset planning is proportional to the deck topic and length: technical, research, product, and analytical decks should include meaningful planned visuals where they clarify the story, and each planned asset has a visible fallback if no real asset was used.
|
||||
- `text_density` is reflected in the amount of visible text.
|
||||
- Pages are not crowded, and any planned `timeline`, `comparison`, or `architecture-diagram` page uses its matching visual structure.
|
||||
- The actual backgrounds match `visual_system.background_strategy`; any dark, image-led, or emphasis page has an intentional relationship to the rest of the deck.
|
||||
- Text boxes respect `typography_constraints`; long labels, captions, footer text, and conclusion bars are not squeezed into boxes that are too short for the intended line count.
|
||||
- If real assets are used, the final XML contains renderable asset tokens or supported local placeholders for creation, not http URLs, stale local paths, or blank image boxes.
|
||||
- `asset_type`:`paper_figure`、`architecture_diagram`、`icon`、`logo`、`chart`、`infographic`、`screenshot`、`flow_diagram` 或 `none`。
|
||||
- `purpose`:该素材为什么能帮助传达本页核心观点。
|
||||
- `suggested_query`:仅作为后续查找提示的短查询;除非另有要求,否则不要执行。
|
||||
- `fallback_if_missing`:使用形状、标签、表格、白板图或占位面板构成的具体 XML 原生兜底视觉方案。
|
||||
|
||||
详细规则和示例见 `asset-planning.md`。
|
||||
|
||||
合格示例:
|
||||
|
||||
- `{"asset_type":"architecture_diagram","purpose":"解释组件关系。","suggested_query":"服务架构图","fallback_if_missing":"用分组方框、连接箭头和短标签绘制组件图。"}`
|
||||
- `{"asset_type":"logo","purpose":"标识客户场景。","suggested_query":"客户标志","fallback_if_missing":"使用小徽标中的文字标签。"}`
|
||||
- `{"asset_type":"chart","purpose":"展示采用率趋势。","suggested_query":"月度采用率趋势图","fallback_if_missing":"绘制带轴标签和数据点的简单趋势折线图。"}`
|
||||
|
||||
## XML 生成约定
|
||||
|
||||
写每页页面 XML 前,把规划字段映射成具体决策:
|
||||
|
||||
- `key_message` 决定标题、主导论断或主要结论。
|
||||
- `material_inventory` 决定哪些本地素材、导入材料、文案来源、远程搜索结果或兜底方案允许影响页面。
|
||||
- `layout_type` 决定坐标结构和元素类型。具体布局规则见 `visual-planning.md`。
|
||||
- `visual_focus` 决定最大视觉区域或被强调对象。
|
||||
- `text_density` 限制可见文字量。
|
||||
- `asset_need` 只用于指导占位图、图标、图表、截图或基于形状的兜底视觉。缺失真实素材时必须使用 `fallback_if_missing`,不能留下空白区域。
|
||||
|
||||
创建或改写 PPT 后,以 `validation-checklist.md` 作为验证依据。验证记录还应说明规划专属映射:
|
||||
|
||||
- 页数与规划文件一致。
|
||||
- 计划中的 `key_message`、`layout_type`、`visual_focus`、`text_density` 和 `asset_need` 已体现在 XML 中。
|
||||
- `visual_system` 和 `typography_constraints` 明显影响了背景、结构、层级和文本位置。
|
||||
- 维护的 `deck_status`、`created_slides`、`assets_used` 或 `open_issues` 字段已更新为当前演示文稿状态。
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
<xs:simpleType name="FontFamilyType">
|
||||
<xs:annotation>
|
||||
<xs:documentation>
|
||||
字体族名称, 支持任意字体。
|
||||
字体族名称, 支持下列字体。
|
||||
|
||||
常用中文字体:
|
||||
思源宋体、寒蝉德黑体、标小智无界黑、寒蝉锦书宋、站酷小薇体、
|
||||
|
||||
@@ -23,6 +23,11 @@
|
||||
6. 如果使用 `--slides '[...]'`,怀疑 shell 截断时直接切到两步创建:先 `slides +create`,再用 `xml_presentation.slide.create` 逐页添加。
|
||||
7. 局部问题用 `+replace-slide` 块级修正;整页结构要改时再用 `slide.delete` 旧页 + `slide.create` 新页。
|
||||
|
||||
## Imported Slides And Media Tokens
|
||||
|
||||
- 由 `drive +import --type slides` 导入生成的页面,可能可读、可截图,但不保证支持后续 `+replace-slide` / `xml_presentation.slide.replace` 的块级编辑。遇到导入页块级编辑失败时,先回读确认内容;需要重做结构时,优先新建 XML-native 页面或重建对应页,而不是反复尝试块级替换。
|
||||
- 一个演示文稿中的图片 `file_token` 不能直接复用于另一个新建演示文稿。跨文稿复用图片时必须在目标演示文稿重新上传,或在 `+create --slides` 中使用 `@./relative/path` 占位符让 CLI 为新文稿上传并替换 token。
|
||||
|
||||
## Symptom Fixes
|
||||
|
||||
| 看到的问题 | 处理方式 |
|
||||
@@ -35,7 +40,9 @@
|
||||
| 图表没有显示 | 检查 `chartPlotArea` 和 `chartData` 是否都包含,`dim1` / `dim2` 数据数量是否匹配 |
|
||||
| 图片被裁掉一部分 | `<img>` 的 `width` / `height` 是裁剪后尺寸;要整图显示就让 `width:height` 对齐原图比例 |
|
||||
| 图片不显示 / `<img src>` 仍是 `@path` | `@` 占位符只在 `+create --slides` 中替换;直接调 `xml_presentation.slide.create` 必须先用 `+media-upload` 拿 `file_token` |
|
||||
| 新文稿里复用旧文稿的图片 `file_token` 后图片不显示 | `file_token` 不能跨演示文稿复用;在目标文稿重新 `+media-upload`,或新建时使用 `@./relative/path` 占位符 |
|
||||
| 新插入的 `<img>` 挡住原有元素 | `slide.get` 读原页,对照已有块坐标挑空白位置;空间不够就在同一批 `--parts` 里先移动/缩小现有块再插图 |
|
||||
| 导入的 PPTX/PDF 页面可读但 `+replace-slide` / `slide.replace` 失败 | 导入页不保证支持块级编辑;改为重建该页或新建 XML-native 页面,再删除/替换旧页 |
|
||||
| 渐变背景变成白色 | 渐变必须用 `rgba()` 格式 + 百分比停靠点,如 `linear-gradient(135deg,rgba(30,60,114,1) 0%,rgba(59,130,246,1) 100%)` |
|
||||
| 整体风格不统一 | 封面页和结尾页用同一背景,内容页保持一致的配色和字号体系 |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Validation Checklist
|
||||
|
||||
创建或大幅改写演示文稿后,必须做一次显式验证。目标是发现空白页、XML 损坏、内容截断、明显溢出、弱视觉层级和未验证输出。
|
||||
创建或大幅改写演示文稿后,必须做一次显式验证。目标是发现空白页、XML 损坏、内容截断、异常换行、明显溢出、弱视觉层级和未验证输出。
|
||||
|
||||
小型已有页编辑也要做对应范围的验证:至少读取被改页面或全文 XML,确认目标元素已更新且未破坏周边结构。
|
||||
|
||||
@@ -9,12 +9,15 @@
|
||||
1. 记录创建或编辑返回的 `xml_presentation_id`,以及已知的 `slide_id` / `revision_id`。
|
||||
2. 用 `xml_presentations.get` 回读全文 XML。
|
||||
3. 检查实际页数是否符合计划或用户要求。
|
||||
4. 检查每页 `<data>` 内是否有预期主要元素。
|
||||
5. 检查没有明显空白页、破损页、缺失标题或缺失主视觉。
|
||||
6. 检查页面不是全部退化为标题加 bullet list。
|
||||
7. 检查视觉层级:标题、主视觉、支撑信息三者可区分。
|
||||
8. 检查明显溢出和布局风险:重叠、越界、底部拥挤、长文本框。
|
||||
9. 在最终回复中给出简短验证记录。
|
||||
4. 如果计划基于导入 PDF/PPTX/slides 材料二次创作,检查最终 `xml_presentation_id` 是否等于 `target_xml_presentation_id`;如果另建了 presentation,必须在验证记录中说明用户明确要求另建或导入/回读失败原因。
|
||||
5. 如果用户材料只用于模板风格或视觉线索,检查它是否仍作为 `rewrite_source` 导入/回读,并确认最终没有交付脱离该材料的新 deck。
|
||||
6. 检查每页 `<data>` 内是否有预期主要元素。
|
||||
7. 检查没有明显空白页、破损页、缺失标题或缺失主视觉。
|
||||
8. 检查页面不是全部退化为标题加 bullet list。
|
||||
9. 检查视觉层级:标题、主视觉、支撑信息三者可区分。
|
||||
10. 检查没有遗留模板占位文案、示例公司名、示例日期或与用户主题无关的源模板文字。
|
||||
11. 检查明显溢出和布局风险:重叠、越界、底部拥挤、长文本框。
|
||||
12. 在最终回复中给出简短验证记录。
|
||||
|
||||
回读命令:
|
||||
|
||||
@@ -34,7 +37,8 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
通过标准:
|
||||
|
||||
- `summary.error_count == 0`。任何 error 都必须先修复再交付。
|
||||
- 当前工具只检查 XML well-formed 和文本元素之间的明显重叠;它不检查越界、文本高度不足、图文压盖、表格/图表压盖或底部拥挤。
|
||||
- 对异常换行、文本框高度不足等 wrap quality warning,默认也应修复后再交付;仅当它是普通正文的自然换行且用户明确允许时,才可在验证记录中说明豁免原因。
|
||||
- 当前工具检查 XML well-formed、文本元素之间的明显重叠,以及部分规则化异常换行;它不检查越界、图文压盖、表格/图表压盖或底部拥挤。
|
||||
- 该工具不能替代页数核对、关键内容核对或真实视觉验收。
|
||||
|
||||
常见 code 的处理方向:
|
||||
@@ -43,6 +47,15 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
|------|------|----------|
|
||||
| `xml_not_well_formed` | XML 语法错误或文本未转义 | 修复标签闭合、属性引号、`&` / `<` / `>` 转义 |
|
||||
| `bbox_overlap` | 文本元素的估算绘制区域明显重叠 | 拉开文本坐标、缩小文本框/字号,或改成明确的分栏/分组结构 |
|
||||
| `text_word_split` / `text_phrase_split` | 中文词语或高频短语被异常拆行 | 增宽文本框、降低字号、改写短语或调整换行点,避免把词语/短语拆开 |
|
||||
| `text_orphan_line` | 最后一行只有极短中文尾巴 | 增宽文本框、缩小字号或重排文本,让尾行形成可读短句 |
|
||||
| `text_unnecessary_wrap` | 短标题或强调文本本应单行却换行 | 增宽文本框或缩小字号,优先保持单行 |
|
||||
| `text_center_wrapped` | 非封面/金句场景的多行文本居中 | 改为左对齐,或调整为真正的封面/金句元素 |
|
||||
| `text_box_too_short` | 文本框高度低于字号所需高度 | 增加文本框高度、降低字号或减少文本量 |
|
||||
|
||||
## Optional Screenshot Upgrade
|
||||
|
||||
如果截图或可视化预览能力可用,优先获取页面截图辅助判断最终效果;截图不能替代 XML 回读和页数核对。
|
||||
|
||||
## Page Count And Structure
|
||||
|
||||
@@ -61,6 +74,8 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
- `visual_focus` 是页面中最醒目或最大的信息区域之一。
|
||||
- `text_density` 影响了文本量,没有用长 bullet 框替代规划。
|
||||
- `asset_need` 有真实素材时已放入正确区域;没有真实素材时,`fallback_if_missing` 已用 XML 形状、线条、标签、表格或图表兜底。
|
||||
- `template_asset_strategy` 为 `preserve_imported_page`、`rebuild_in_imported_presentation` 或 `mixed` 时,不能交付一份脱离导入材料的新 deck。
|
||||
- 如果计划声明保留材料背景、装饰图、品牌图或复杂图片版式,回读 XML 中应仍存在对应的 `<img src>`、背景图片或等效重绘结构;如果未保留,验证记录必须说明原因。
|
||||
|
||||
如果用户指定了关键页,例如“架构解释”“Self-Attention 机制解释”“对比或演进视角”“总结页”,最终验证记录必须逐项说明这些页已存在。
|
||||
|
||||
@@ -89,6 +104,7 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
优先修复这些明显风险:
|
||||
|
||||
- 正文或标签框高度不足,文本很可能被截断。
|
||||
- 标题、标签、卡片标题或强调文本出现异常换行,例如拆词、拆短语、短尾行或本应单行却换行。
|
||||
- 多个主体元素在同一区域重叠,而不是有意叠加背景。
|
||||
- 重要内容越过画布边界,或贴近底部超过 `y=500`。
|
||||
- 高密度页使用单个长 bullet list,没有分栏、表格或分组。
|
||||
@@ -102,9 +118,14 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
```text
|
||||
验证记录:
|
||||
- 回读:已执行 xml_presentations.get,实际页数 N / 预期 N。
|
||||
- 导入底稿:如使用导入材料二次创作,最终 presentation 与 target_xml_presentation_id 一致;如不一致,已说明用户明确要求另建或导入/回读失败原因。
|
||||
- 视觉底稿:如用户材料只提供模板风格或视觉线索,已确认它仍作为 rewrite_source 导入/回读并承载最终二创。
|
||||
- 内容来源:如模板材料内容不可用,已确认未复制其占位文案;正文来自计划中的 copy_source 或用户输入。
|
||||
- 截图:截图能力可用时,已用截图辅助判断最终效果。
|
||||
- 关键页:架构解释 / Self-Attention / 对比或演进 / 总结页均存在。
|
||||
- 结构:检查了主要 shape/img/table/chart 元素,无明显空白页或破损页。
|
||||
- 模板清理:未发现模板占位文案、示例公司名、示例日期或无关模板文字。
|
||||
- 布局:检查了标题层级、主视觉、重叠/越界/文本溢出风险。
|
||||
```
|
||||
|
||||
不要声称完成了人工视觉验收,除非确实打开或获取了可视化结果。仅从 XML 静态检查得出的结论,应表述为“静态检查未发现明显问题”。
|
||||
不要声称完成了截图或人工视觉验收,除非确实打开、获取截图或拿到了可视化结果。仅从 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
|
||||
|
||||
|
||||
TITLE_LIKE_TEXT_TYPES = {"title", "headline", "sub-headline", "card_title", "callout"}
|
||||
CENTER_ALLOWED_TEXT_TYPES = {"title", "quote", "hero"}
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise XmlTextOverlapLintError(message)
|
||||
|
||||
@@ -71,10 +75,49 @@ def strip_xml(value: str) -> str:
|
||||
return re.sub(r"\s+", " ", stripped).strip()
|
||||
|
||||
|
||||
def collapse_space(value: str) -> str:
|
||||
return re.sub(r"\s+", " ", value).strip()
|
||||
|
||||
|
||||
def chinese_char_count(value: str) -> int:
|
||||
return len(re.findall(r"[\u4e00-\u9fff]", value))
|
||||
|
||||
|
||||
def chinese_text(value: str) -> str:
|
||||
return "".join(re.findall(r"[\u4e00-\u9fff]", value))
|
||||
|
||||
|
||||
def xml_local_name(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1] if tag.startswith("{") else tag
|
||||
|
||||
|
||||
def extract_content_lines(content_xml: str) -> list[str]:
|
||||
try:
|
||||
root = ET.fromstring(f"<root>{content_xml}</root>")
|
||||
except ET.ParseError:
|
||||
text = strip_xml(content_xml)
|
||||
return [text] if text else []
|
||||
|
||||
lines: list[str] = []
|
||||
for content_node in root.iter():
|
||||
if xml_local_name(content_node.tag) != "content":
|
||||
continue
|
||||
paragraph_lines: list[str] = []
|
||||
for node in content_node.iter():
|
||||
if xml_local_name(node.tag) != "p":
|
||||
continue
|
||||
line = collapse_space("".join(node.itertext()))
|
||||
if line:
|
||||
paragraph_lines.append(line)
|
||||
if paragraph_lines:
|
||||
lines.extend(paragraph_lines)
|
||||
else:
|
||||
line = collapse_space("".join(content_node.itertext()))
|
||||
if line:
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
|
||||
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
|
||||
@@ -139,18 +182,23 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
height = extract_numeric_attribute(attrs, "height")
|
||||
if all(value is not None for value in [x, y, width, height]):
|
||||
font_size = float(extract_attribute(content, "fontSize") or extract_attribute(attrs, "fontSize") or 16)
|
||||
lines = extract_content_lines(content)
|
||||
raw_text = "\n".join(lines)
|
||||
elements.append(
|
||||
{
|
||||
"id": f"shape-{len(elements) + 1}",
|
||||
"kind": "shape",
|
||||
"type": extract_attribute(attrs, "type") or "shape",
|
||||
"textType": extract_attribute(content, "textType"),
|
||||
"textAlign": extract_attribute(content, "textAlign") or extract_attribute(attrs, "textAlign"),
|
||||
"x": x,
|
||||
"y": y,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"fontSize": font_size,
|
||||
"text": strip_xml(content),
|
||||
"rawText": raw_text,
|
||||
"lines": lines,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -294,10 +342,223 @@ def should_flag_overlap(left: dict[str, Any], right: dict[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def estimate_text_width(text: str, font_size: float) -> float:
|
||||
width = 0.0
|
||||
for char in text:
|
||||
if re.match(r"[\u4e00-\u9fff]", char):
|
||||
width += font_size
|
||||
elif char.isspace():
|
||||
width += font_size * 0.32
|
||||
else:
|
||||
width += font_size * 0.55
|
||||
return width
|
||||
|
||||
|
||||
def estimated_rendered_line_count(element: dict[str, Any]) -> int:
|
||||
return len(estimate_rendered_lines(element))
|
||||
|
||||
|
||||
def estimate_rendered_lines(element: dict[str, Any]) -> list[str]:
|
||||
lines = [line for line in element.get("lines", []) if line]
|
||||
if not lines:
|
||||
return []
|
||||
font_size = float(element.get("fontSize") or 16)
|
||||
usable_width = max(float(element["width"]) - 6, 1)
|
||||
rendered_lines: list[str] = []
|
||||
for line in lines:
|
||||
current = ""
|
||||
current_width = 0.0
|
||||
for char in line:
|
||||
char_width = estimate_text_width(char, font_size)
|
||||
if current and current_width + char_width > usable_width:
|
||||
rendered_lines.append(current)
|
||||
current = char
|
||||
current_width = char_width
|
||||
continue
|
||||
current += char
|
||||
current_width += char_width
|
||||
if current:
|
||||
rendered_lines.append(current)
|
||||
return rendered_lines
|
||||
|
||||
|
||||
def has_insufficient_height_for_estimated_wrap(element: dict[str, Any], estimated_line_count: int) -> bool:
|
||||
if estimated_line_count < 2:
|
||||
return False
|
||||
font_size = float(element.get("fontSize") or 16)
|
||||
required_height = estimated_line_count * font_size * 1.12
|
||||
return float(element["height"]) < required_height
|
||||
|
||||
|
||||
def has_too_short_text_box(element: dict[str, Any]) -> bool:
|
||||
text = element.get("text") or ""
|
||||
if chinese_char_count(text) < 6:
|
||||
return False
|
||||
font_size = float(element.get("fontSize") or 16)
|
||||
return float(element["height"]) < font_size * 0.95
|
||||
|
||||
|
||||
def is_slash_separated_short_label(text: str) -> bool:
|
||||
if "/" not in text:
|
||||
return False
|
||||
parts = [part.strip() for part in text.split("/") if part.strip()]
|
||||
if len(parts) < 2:
|
||||
return False
|
||||
return chinese_char_count(text) <= 14 and all(chinese_char_count(part) <= 4 for part in parts)
|
||||
|
||||
|
||||
def is_short_display_text_auto_wrapped(element: dict[str, Any], rendered_lines: list[str]) -> bool:
|
||||
if len(element.get("lines", [])) != 1 or len(rendered_lines) != 2:
|
||||
return False
|
||||
if element.get("textType") in {"title", "caption"}:
|
||||
return False
|
||||
text = element.get("text") or ""
|
||||
chinese_count = chinese_char_count(text)
|
||||
if not (4 <= chinese_count <= 20):
|
||||
return False
|
||||
font_size = float(element.get("fontSize") or 16)
|
||||
if font_size < 20:
|
||||
return False
|
||||
if not has_insufficient_height_for_estimated_wrap(element, len(rendered_lines)):
|
||||
return False
|
||||
return chinese_count / max(len(text), 1) >= 0.6
|
||||
|
||||
|
||||
def build_wrap_issue(
|
||||
code: str,
|
||||
element: dict[str, Any],
|
||||
message: str,
|
||||
reason: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"level": "warning",
|
||||
"code": code,
|
||||
"element": element["id"],
|
||||
"message": message,
|
||||
"reason": reason,
|
||||
"repair": {
|
||||
"prefer_single_line": True,
|
||||
"allow_font_shrink": True,
|
||||
"max_shrink_ratio": 0.9,
|
||||
"avoid_center_align": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def is_probable_cover_center_title(element: dict[str, Any]) -> bool:
|
||||
text_type = element.get("textType")
|
||||
if text_type == "quote":
|
||||
return True
|
||||
if text_type not in CENTER_ALLOWED_TEXT_TYPES:
|
||||
return False
|
||||
return element["x"] >= 120 and element["y"] >= 150 and element["width"] >= 300 and element["height"] >= 80
|
||||
|
||||
|
||||
def lint_wrap_quality(element: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
if not is_text_element(element) or not has_text_content(element):
|
||||
return []
|
||||
|
||||
lines = [line for line in element.get("lines", []) if line]
|
||||
rendered_lines = estimate_rendered_lines(element)
|
||||
estimated_line_count = len(rendered_lines)
|
||||
if len(lines) < 2 and estimated_line_count < 2 and not has_too_short_text_box(element):
|
||||
return []
|
||||
|
||||
issues: list[dict[str, Any]] = []
|
||||
raw_text = element.get("rawText") or "\n".join(lines)
|
||||
joined_chinese = chinese_text("".join(lines))
|
||||
joined_chinese_count = chinese_char_count(joined_chinese)
|
||||
font_size = float(element.get("fontSize") or 16)
|
||||
|
||||
last_line_chinese_count = chinese_char_count(lines[-1])
|
||||
previous_text_chinese_count = chinese_char_count("".join(lines[:-1]))
|
||||
if (
|
||||
len(lines) == 2
|
||||
and 1 <= last_line_chinese_count <= 3
|
||||
and previous_text_chinese_count >= 10
|
||||
):
|
||||
issues.append(
|
||||
build_wrap_issue(
|
||||
"text_orphan_line",
|
||||
element,
|
||||
f"Last line is very short: {lines[-1]}",
|
||||
"最后一行是过短尾行",
|
||||
)
|
||||
)
|
||||
|
||||
if has_too_short_text_box(element):
|
||||
issues.append(
|
||||
build_wrap_issue(
|
||||
"text_box_too_short",
|
||||
element,
|
||||
f"Text box height is too short for font size: height={element['height']}, fontSize={font_size:g}",
|
||||
"文本框高度低于字号所需高度,渲染后容易截断或压缩显示",
|
||||
)
|
||||
)
|
||||
|
||||
text_type = element.get("textType")
|
||||
estimated_single_line_width = joined_chinese_count * font_size * 0.62
|
||||
if (
|
||||
text_type in TITLE_LIKE_TEXT_TYPES
|
||||
and len(lines) >= 2
|
||||
and 1 <= joined_chinese_count <= 20
|
||||
and font_size >= 20
|
||||
and font_size < 40
|
||||
and chinese_char_count("".join(lines)) == len("".join(lines))
|
||||
and element["width"] >= estimated_single_line_width
|
||||
):
|
||||
issues.append(
|
||||
build_wrap_issue(
|
||||
"text_unnecessary_wrap",
|
||||
element,
|
||||
f"Short title-like text wraps unnecessarily: {joined_chinese}",
|
||||
"短标题或强调文本不超过 20 个中文字符却出现换行",
|
||||
)
|
||||
)
|
||||
|
||||
if is_short_display_text_auto_wrapped(element, rendered_lines):
|
||||
issues.append(
|
||||
build_wrap_issue(
|
||||
"text_unnecessary_wrap",
|
||||
element,
|
||||
f"Short display text is likely to wrap in a one-line box: {strip_xml(raw_text)}",
|
||||
"短展示文本被放入过窄且只够一行高度的文本框,渲染后容易异常换行",
|
||||
)
|
||||
)
|
||||
|
||||
if (
|
||||
(element.get("textAlign") or "").lower() == "center"
|
||||
and (
|
||||
(len(lines) >= 2 and font_size >= 22)
|
||||
or (
|
||||
len(lines) == 1
|
||||
and joined_chinese_count >= 8
|
||||
and has_insufficient_height_for_estimated_wrap(element, estimated_line_count)
|
||||
)
|
||||
)
|
||||
and text_type not in {"title", "sub-headline", "quote", "hero"}
|
||||
and not is_probable_cover_center_title(element)
|
||||
and not is_slash_separated_short_label(raw_text)
|
||||
):
|
||||
issues.append(
|
||||
build_wrap_issue(
|
||||
"text_center_wrapped",
|
||||
element,
|
||||
f"Centered multi-line text is hard to scan: {strip_xml(raw_text)}",
|
||||
"非封面、非金句场景的多行文本使用居中对齐",
|
||||
)
|
||||
)
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def lint_slide(slide_xml: str, slide_number: int) -> dict[str, Any]:
|
||||
elements = extract_elements(slide_xml)
|
||||
issues: list[dict[str, Any]] = []
|
||||
|
||||
for element in elements:
|
||||
issues.extend(lint_wrap_quality(element))
|
||||
|
||||
for index, left in enumerate(elements):
|
||||
for right in elements[index + 1 :]:
|
||||
if not intersects(left, right) or not should_flag_overlap(left, right):
|
||||
|
||||
230
skills/lark-slides/scripts/xml_text_overlap_lint_wrap_test.py
Normal file
230
skills/lark-slides/scripts/xml_text_overlap_lint_wrap_test.py
Normal file
@@ -0,0 +1,230 @@
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
import xml_text_overlap_lint
|
||||
|
||||
|
||||
def make_slide(shapes: str) -> str:
|
||||
return f"""
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>{shapes}</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
"""
|
||||
|
||||
|
||||
def text_shape(
|
||||
lines: list[str],
|
||||
*,
|
||||
text_type: str = "body",
|
||||
align: str = "left",
|
||||
x: int = 120,
|
||||
y: int = 120,
|
||||
width: int = 360,
|
||||
height: int = 120,
|
||||
font_size: int = 28,
|
||||
) -> str:
|
||||
paragraphs = "".join(f"<p>{line}</p>" for line in lines)
|
||||
return f"""
|
||||
<shape type="text" topLeftX="{x}" topLeftY="{y}" width="{width}" height="{height}">
|
||||
<content textType="{text_type}" textAlign="{align}" fontSize="{font_size}">
|
||||
{paragraphs}
|
||||
</content>
|
||||
</shape>
|
||||
"""
|
||||
|
||||
|
||||
class XmlTextOverlapWrapLintTest(unittest.TestCase):
|
||||
def lint_one(self, shape_xml: str) -> dict:
|
||||
result = xml_text_overlap_lint.lint_xml(make_slide(shape_xml))
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
return result
|
||||
|
||||
def issue_codes(self, result: dict) -> list[str]:
|
||||
return [
|
||||
issue["code"]
|
||||
for slide in result["slides"]
|
||||
for issue in slide["issues"]
|
||||
]
|
||||
|
||||
def assertWarnsCode(self, shape_xml: str, code: str) -> None:
|
||||
result = self.lint_one(shape_xml)
|
||||
self.assertIn(code, self.issue_codes(result))
|
||||
self.assertGreaterEqual(result["summary"]["warning_count"], 1)
|
||||
|
||||
def assertDoesNotWarnCode(self, shape_xml: str, code: str) -> None:
|
||||
result = self.lint_one(shape_xml)
|
||||
self.assertNotIn(code, self.issue_codes(result))
|
||||
|
||||
def test_wrap_lint_detects_orphan_line(self) -> None:
|
||||
cases = [
|
||||
["把排版看成一套可维护的规则", "系统"],
|
||||
["为什么大多数企业知识库最终都会", "失效"],
|
||||
["让内容生产流程持续保持稳定的", "质量"],
|
||||
["复杂协作权限需要清晰可读的继承", "边界"],
|
||||
["自动化检查应该优先发现低级排版", "问题"],
|
||||
]
|
||||
for lines in cases:
|
||||
with self.subTest(lines=lines):
|
||||
self.assertWarnsCode(text_shape(lines, width=520), "text_orphan_line")
|
||||
|
||||
def test_wrap_lint_allows_orphan_line_controls(self) -> None:
|
||||
cases = [
|
||||
["把排版看成", "一套可维护的规则系统"],
|
||||
["为什么大多数企业知识库", "最终都会失效"],
|
||||
["复杂协作权限需要", "清晰可读的继承边界"],
|
||||
["自动化检查应该", "优先发现低级排版问题"],
|
||||
["标题换行质量", "直接影响读者理解效率"],
|
||||
]
|
||||
for lines in cases:
|
||||
with self.subTest(lines=lines):
|
||||
self.assertDoesNotWarnCode(text_shape(lines, width=520), "text_orphan_line")
|
||||
|
||||
def test_wrap_lint_allows_multiline_body_with_short_final_line(self) -> None:
|
||||
shape_xml = text_shape(
|
||||
["按行业、阶段、投资年份分层;剔除信息不可得或标签不完整样本。"],
|
||||
align="left",
|
||||
width=146,
|
||||
height=42,
|
||||
font_size=10,
|
||||
)
|
||||
self.assertDoesNotWarnCode(shape_xml, "text_orphan_line")
|
||||
|
||||
def test_wrap_lint_detects_unnecessary_wrap_in_title_like_text(self) -> None:
|
||||
cases = [
|
||||
["减少手工", "格式"],
|
||||
["内容", "生产"],
|
||||
["智能", "生成"],
|
||||
["质量", "检查"],
|
||||
["边界", "规则"],
|
||||
]
|
||||
for lines in cases:
|
||||
with self.subTest(lines=lines):
|
||||
self.assertWarnsCode(text_shape(lines, text_type="headline", width=420), "text_unnecessary_wrap")
|
||||
|
||||
def test_wrap_lint_allows_unnecessary_wrap_controls(self) -> None:
|
||||
cases = [
|
||||
["减少手工格式"],
|
||||
["内容生产"],
|
||||
["智能生成"],
|
||||
["质量检查"],
|
||||
["边界规则"],
|
||||
]
|
||||
for lines in cases:
|
||||
with self.subTest(lines=lines):
|
||||
self.assertDoesNotWarnCode(text_shape(lines, text_type="headline", width=420), "text_unnecessary_wrap")
|
||||
|
||||
def test_wrap_lint_detects_short_display_text_that_will_auto_wrap(self) -> None:
|
||||
cases = [
|
||||
"模型、平台、数据、研究",
|
||||
"产业协同能力研究",
|
||||
"接口边界安全研究",
|
||||
"投后监测策略研究",
|
||||
"评分稳定性复盘研究",
|
||||
]
|
||||
for text in cases:
|
||||
with self.subTest(text=text):
|
||||
self.assertWarnsCode(
|
||||
text_shape([text], width=190, height=26, font_size=26),
|
||||
"text_unnecessary_wrap",
|
||||
)
|
||||
|
||||
def test_wrap_lint_allows_body_text_that_will_auto_wrap(self) -> None:
|
||||
shape_xml = text_shape(
|
||||
["按行业、阶段、投资年份分层;剔除信息不可得或标签不完整样本。"],
|
||||
width=146,
|
||||
height=42,
|
||||
font_size=10,
|
||||
)
|
||||
self.assertDoesNotWarnCode(shape_xml, "text_unnecessary_wrap")
|
||||
|
||||
def test_wrap_lint_detects_center_wrapped_text(self) -> None:
|
||||
cases = [
|
||||
["下一代智能", "办公系统"],
|
||||
["企业知识库", "治理方案"],
|
||||
["自动化排版", "质量基线"],
|
||||
["协作权限", "继承模型"],
|
||||
["内容生产", "智能流程"],
|
||||
]
|
||||
for lines in cases:
|
||||
with self.subTest(lines=lines):
|
||||
self.assertWarnsCode(text_shape(lines, align="center", y=150), "text_center_wrapped")
|
||||
|
||||
def test_wrap_lint_detects_center_text_that_will_auto_wrap(self) -> None:
|
||||
shape_xml = text_shape(
|
||||
["平台价值:让数据、模型和流程在同一界面被调用、解释和追踪。"],
|
||||
align="center",
|
||||
width=248,
|
||||
height=12,
|
||||
font_size=10,
|
||||
)
|
||||
self.assertWarnsCode(shape_xml, "text_center_wrapped")
|
||||
|
||||
def test_wrap_lint_allows_center_wrapped_controls(self) -> None:
|
||||
cases = [
|
||||
text_shape(["下一代智能办公系统"], align="center"),
|
||||
text_shape(["企业知识库治理方案"], align="center"),
|
||||
text_shape(["自动化排版质量基线"], align="left"),
|
||||
text_shape(["封面主标题", "副标题"], text_type="title", align="center", y=210),
|
||||
text_shape(["金句内容", "保持居中"], text_type="quote", align="center"),
|
||||
text_shape(["企业筛选 / 排序 / 尽调建议"], align="center", width=132, height=20, font_size=10),
|
||||
text_shape(["经营异动 / 风险预警 / 里程碑"], align="center", width=136, height=12, font_size=10),
|
||||
text_shape(
|
||||
["建议采用 Top-N 命中率、风险预警召回率和评分稳定性三类指标,不只看单一准确率。"],
|
||||
align="left",
|
||||
width=146,
|
||||
height=42,
|
||||
font_size=10,
|
||||
),
|
||||
]
|
||||
for shape_xml in cases:
|
||||
with self.subTest(shape=shape_xml):
|
||||
self.assertDoesNotWarnCode(shape_xml, "text_center_wrapped")
|
||||
|
||||
def test_wrap_lint_detects_text_box_too_short(self) -> None:
|
||||
cases = [
|
||||
"REST API / 批量文件 / 定时同步",
|
||||
"鉴权、审计、脱敏与最小权限",
|
||||
"优先适配现有系统,减少重复建设",
|
||||
"服务化部署、权限隔离、日志留痕",
|
||||
"试运行三个月,终验后三年维保",
|
||||
]
|
||||
for text in cases:
|
||||
with self.subTest(text=text):
|
||||
self.assertWarnsCode(
|
||||
text_shape([text], width=280, height=2, font_size=18),
|
||||
"text_box_too_short",
|
||||
)
|
||||
|
||||
def test_wrap_lint_allows_text_box_with_sufficient_height(self) -> None:
|
||||
cases = [
|
||||
"REST API / 批量文件 / 定时同步",
|
||||
"鉴权、审计、脱敏与最小权限",
|
||||
"优先适配现有系统,减少重复建设",
|
||||
"11",
|
||||
"KR1",
|
||||
]
|
||||
for text in cases:
|
||||
with self.subTest(text=text):
|
||||
self.assertDoesNotWarnCode(
|
||||
text_shape([text], width=450, height=48, font_size=18),
|
||||
"text_box_too_short",
|
||||
)
|
||||
|
||||
def test_wrap_lint_keeps_bbox_overlap_detection(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
make_slide(
|
||||
text_shape(["Title"], text_type="title", x=80, y=80, width=300, height=60)
|
||||
+ text_shape(["Body"], text_type="body", x=80, y=80, width=300, height=80)
|
||||
)
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 1)
|
||||
self.assertIn("bbox_overlap", self.issue_codes(result))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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