mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
6 Commits
main
...
feat/batch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7c7f9f390 | ||
|
|
3f993ea772 | ||
|
|
461b4a7e80 | ||
|
|
d6b235aaa2 | ||
|
|
d6dfd1e043 | ||
|
|
3a33794aec |
@@ -260,6 +260,15 @@ func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectScopesForDomains_SlidesDoesNotAdvertiseScreenshotScope(t *testing.T) {
|
||||
scopes := collectScopesForDomains([]string{"slides"}, "user", "")
|
||||
for _, scope := range scopes {
|
||||
if scope == "slides:presentation:screenshot" {
|
||||
t.Fatalf("slides domain scopes must not advertise allowlist-gated screenshot scope: %#v", scopes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainMetadata_IncludesFromMeta(t *testing.T) {
|
||||
domains := getDomainMetadata("zh")
|
||||
nameSet := make(map[string]bool)
|
||||
|
||||
@@ -11,6 +11,8 @@ func Shortcuts() []common.Shortcut {
|
||||
SlidesCreate,
|
||||
SlidesMediaUpload,
|
||||
SlidesReplaceSlide,
|
||||
SlidesReplacePages,
|
||||
SlidesScreenshot,
|
||||
SlidesXMLGet,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,13 +204,11 @@ var SlidesCreate = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
// Build the presentation URL locally from the token. The brand-standard
|
||||
// host transparently redirects to the tenant domain (same fallback used by
|
||||
// drive +upload / wiki +node-create). This avoids the prior best-effort
|
||||
// drive metas/batch_query call, which needed an extra drive scope and 403'd
|
||||
// for users who only authorized slides scopes — without ever blocking an
|
||||
// otherwise-successful creation.
|
||||
if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
|
||||
// Prefer the URL returned by presentation.create. Fall back to a local
|
||||
// brand-standard URL only when the API omits it.
|
||||
if url := common.GetString(data, "url"); url != "" {
|
||||
result["url"] = url
|
||||
} else if url := common.BuildResourceURL(runtime.Config.Brand, "slides", presentationID); url != "" {
|
||||
result["url"] = url
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ func TestSlidesCreateBasic(t *testing.T) {
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_abc123",
|
||||
"revision_id": 1,
|
||||
"url": "https://tenant.example.com/slides/pres_abc123",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -54,10 +55,8 @@ func TestSlidesCreateBasic(t *testing.T) {
|
||||
if data["title"] != "项目汇报" {
|
||||
t.Fatalf("title = %v, want 项目汇报", data["title"])
|
||||
}
|
||||
// URL is built locally from the token (brand-standard host), not fetched from
|
||||
// drive metas, so it is deterministic and needs no drive scope.
|
||||
if data["url"] != "https://www.feishu.cn/slides/pres_abc123" {
|
||||
t.Fatalf("url = %v, want https://www.feishu.cn/slides/pres_abc123", data["url"])
|
||||
if data["url"] != "https://tenant.example.com/slides/pres_abc123" {
|
||||
t.Fatalf("url = %v, want https://tenant.example.com/slides/pres_abc123", data["url"])
|
||||
}
|
||||
if _, ok := data["permission_grant"]; ok {
|
||||
t.Fatalf("did not expect permission_grant in user mode")
|
||||
@@ -647,12 +646,12 @@ func TestSlidesCreateWithoutSlidesUnchanged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesCreateURLBuiltLocally verifies the presentation URL is constructed
|
||||
// locally from the token — no drive metas/batch_query call is made, so creation
|
||||
// works for users who only authorized slides scopes. The httpmock registry has no
|
||||
// batch_query stub registered; if the shortcut tried to call it, the request would
|
||||
// fail the test (unregistered stub), proving the URL is built without a drive call.
|
||||
func TestSlidesCreateURLBuiltLocally(t *testing.T) {
|
||||
// TestSlidesCreateURLFallsBackToLocalBuild verifies the presentation URL is
|
||||
// constructed locally from the token when presentation.create omits url — no
|
||||
// drive metas/batch_query call is made, so creation works for users who only
|
||||
// authorized slides scopes. The httpmock registry has no batch_query stub
|
||||
// registered; if the shortcut tried to call it, the request would fail the test.
|
||||
func TestSlidesCreateURLFallsBackToLocalBuild(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
@@ -665,6 +664,7 @@ func TestSlidesCreateURLBuiltLocally(t *testing.T) {
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_local_url",
|
||||
"revision_id": 1,
|
||||
"url": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
@@ -34,7 +34,8 @@ var SlidesScreenshot = common.Shortcut{
|
||||
Command: "+screenshot",
|
||||
Description: "Save slide screenshots to local files without printing Base64 image data",
|
||||
Risk: "read",
|
||||
Scopes: []string{"slides:presentation:screenshot"},
|
||||
// The screenshot API is allowlist-gated for only a few apps, so do not
|
||||
// advertise/preflight its scope. Let the API fail and let callers degrade.
|
||||
// wiki:node:read is required only when --presentation is a wiki URL.
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
|
||||
@@ -17,11 +17,23 @@ import (
|
||||
)
|
||||
|
||||
func TestSlidesScreenshotDeclaredScopes(t *testing.T) {
|
||||
if got := SlidesScreenshot.ScopesForIdentity("user"); len(got) != 0 {
|
||||
t.Fatalf("user preflight scopes = %#v, want empty", got)
|
||||
}
|
||||
if got := SlidesScreenshot.ScopesForIdentity("bot"); len(got) != 0 {
|
||||
t.Fatalf("bot preflight scopes = %#v, want empty", got)
|
||||
}
|
||||
|
||||
got := SlidesScreenshot.DeclaredScopesForIdentity("user")
|
||||
want := []string{"slides:presentation:screenshot", "wiki:node:read"}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
want := []string{"wiki:node:read"}
|
||||
if len(got) != len(want) || got[0] != want[0] {
|
||||
t.Fatalf("declared scopes = %#v, want %#v", got, want)
|
||||
}
|
||||
for _, scope := range got {
|
||||
if scope == "slides:presentation:screenshot" {
|
||||
t.Fatalf("declared scopes must not advertise screenshot scope: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) {
|
||||
|
||||
147
shortcuts/slides/slides_xml_get.go
Normal file
147
shortcuts/slides/slides_xml_get.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// 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 url := common.GetString(presentation, "url"); url != "" {
|
||||
out["url"] = url
|
||||
}
|
||||
if runtime.Bool("remove-attr-id") {
|
||||
out["remove_attr_id"] = true
|
||||
}
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
161
shortcuts/slides/slides_xml_get_test.go
Normal file
161
shortcuts/slides/slides_xml_get_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
// 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,
|
||||
"url": "https://example.feishu.cn/slides/pres_abc",
|
||||
"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["url"] != "https://example.feishu.cn/slides/pres_abc" {
|
||||
t.Fatalf("url = %v, want presentation URL", data["url"])
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ metadata:
|
||||
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|
||||
|----------|----------|-----------------|
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` |
|
||||
| 大幅改写页面 | 先回读现有 XML,写入新 plan,再替换或重建相关页面 | `xml_presentations.get`、`+replace-slide`、`lark-slides-edit-workflows.md` |
|
||||
| 已有 PPT 大幅改写 | 多页整页重建用 `+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` |
|
||||
@@ -36,7 +36,7 @@ metadata:
|
||||
|
||||
**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 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险;XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py),不得交付 `double_escaped_entity` 问题。**
|
||||
|
||||
**CRITICAL — 创建前自检或失败排障时,MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。**
|
||||
|
||||
@@ -47,7 +47,7 @@ metadata:
|
||||
|
||||
**CRITICAL — 使用模板生成或改写页面时,MUST 先 `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 +82,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)
|
||||
@@ -268,6 +268,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` 管理单页。
|
||||
|
||||
@@ -286,7 +287,7 @@ lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
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`
|
||||
7. **编辑已有页面优先原链接更新**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;已有 Slides 的多页整页重建用 `+replace-pages`,不要用 `slides +create` 新建整份 PPT;只有没有 shortcut 覆盖的特殊单页整页操作才手动 `slide.create` + `slide.delete`
|
||||
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 不支持分片上传)。
|
||||
|
||||
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。
|
||||
|
||||
@@ -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`。这不是"微调",是块级重写。
|
||||
|
||||
@@ -45,7 +46,7 @@ REV=$(lark-cli slides xml_presentation.slide get --as user \
|
||||
# 写时传该版本号,服务端以此为 base
|
||||
lark-cli slides +replace-slide --as user \
|
||||
--presentation "$PID" --slide-id "$SID" --revision-id "$REV" \
|
||||
--parts '[{"action":"block_replace","block_id":"bUn","replacement":"<shape type=\"rect\" topLeftX=\"100\" topLeftY=\"100\" width=\"200\" height=\"100\"/>"}]'
|
||||
--parts '[{"action":"block_replace","block_id":"bUn","replacement":"<shape type=\"rect\" topLeftX=\"100\" topLeftY=\"100\" width=\"200\" height=\"100\"><content/></shape>"}]'
|
||||
```
|
||||
|
||||
注意:传不存在的版本号(超过当前 revision)会返回 3350002 not found;不确定时用 `-1` 即可。
|
||||
@@ -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 并截图检查,确认页序、视觉和文本没有破损。
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
获取幻灯片页面截图并保存为本地图片文件。默认用于已存在 PPT 页面截图;传入 `--content` 时用于直接渲染单个 `<slide>` XML 片段预览。本 shortcut 会在 CLI 进程内解码并写入文件,stdout 只返回文件路径、大小、页面 ID 等元信息,避免把图片 Base64 输出给模型。
|
||||
|
||||
注意:该截图能力对应的权限受白名单控制。只有在白名单内的应用才能申请该权限;不在白名单内的应用即使命令和参数正确,服务端仍可能返回权限或能力不可用相关错误。
|
||||
注意:该截图能力受应用白名单限制,绝大多数应用不可用。截图失败时不要引导用户申请 `slides:presentation:screenshot` 权限;记录错误后降级到 XML 读回、结构 lint、文本重叠检查等非截图检查路径。
|
||||
|
||||
## 命令
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ lark-cli slides xml_presentation.slide create --as user --params '{
|
||||
"xml_presentation_id": "slides_example_presentation_id"
|
||||
}' --data '{
|
||||
"slide": {
|
||||
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"520\" height=\"120\"><content textType=\"title\"><p>数据展示</p></content></shape><shape type=\"rect\" topLeftX=\"700\" topLeftY=\"100\" width=\"200\" height=\"150\"><fill><fillColor color=\"rgb(100, 149, 237)\"/></fill></shape></data></slide>"
|
||||
"content": "<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data><shape type=\"text\" topLeftX=\"80\" topLeftY=\"80\" width=\"520\" height=\"120\"><content textType=\"title\"><p>数据展示</p></content></shape><shape type=\"rect\" topLeftX=\"700\" topLeftY=\"100\" width=\"200\" height=\"150\"><fill><fillColor color=\"rgb(100, 149, 237)\"/></fill><content/></shape></data></slide>"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
@@ -61,6 +61,7 @@ lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id"
|
||||
"xml_presentation": {
|
||||
"presentation_id": "slides_example_presentation_id",
|
||||
"revision_id": 1,
|
||||
"url": "https://example.feishu.cn/slides/slides_example_presentation_id",
|
||||
"content": "<presentation xmlns=\"http://www.larkoffice.com/sml/2.0\" height=\"540\" width=\"960\">...</presentation>"
|
||||
}
|
||||
},
|
||||
@@ -74,6 +75,7 @@ lark-cli slides xml_presentations get --as user --params '{"xml_presentation_id"
|
||||
|------|------|------|
|
||||
| `data.xml_presentation.presentation_id` | string | 演示文稿唯一标识 |
|
||||
| `data.xml_presentation.revision_id` | integer | 版本号 |
|
||||
| `data.xml_presentation.url` | string | 对应 Slides 的访问地址 |
|
||||
| `data.xml_presentation.content` | string | XML 格式的完整内容 |
|
||||
|
||||
## 常见错误
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
在真正创建或替换前,至少检查:
|
||||
|
||||
- 特殊字符已转义:正文和标题里的 `&`、`<`、`>` 不能裸写;属性值里的裸 `&` 也必须写成 `&`。
|
||||
- 普通可见符号直接写 Unicode,不要输出 HTML/XML entity 后再转义:`«姓名»`、`●`、`✓` 是正确文本;`&#171;姓名&#187;`、`&#9679;`、`&nbsp;` 会在页面中泄漏成字面量。
|
||||
- 属性引号安全:XML 属性、shell 引号、JSON 字符串包装之间没有互相打断。
|
||||
- 结构合法:`<slide>` 下只放 `<style>`、`<data>`、`<note>`,文本都在 `<content>` 内。
|
||||
- 图片路径正确:`<img src="@...">` 只在 `+create --slides` 的支持链路中使用;直接调用 `xml_presentation.slide.create` 必须先拿到 `file_token`。
|
||||
@@ -17,9 +18,9 @@
|
||||
|
||||
1. 记录 `xml_presentation_id`,不要假设失败代表什么都没创建。
|
||||
2. 用 `xml_presentations.get` 回读,确认是否已有部分页面写入。
|
||||
3. 检查失败页是否含未转义字符:`Q&A -> Q&A`,文本 `<` / `>` 写成 `<` / `>`,属性 URL `a=1&b=2 -> a=1&b=2`。
|
||||
3. 检查失败页是否含未转义字符:`Q&A -> Q&A`,文本 `<` / `>` 写成 `<` / `>`,属性 URL `a=1&b=2 -> a=1&b=2`;同时检查是否有 `double_escaped_entity`,如 `&#9679;`、`&nbsp;`、`&lt;`。
|
||||
4. 检查标签闭合、属性引号、`<content>` 结构,以及 `<slide>` 直接子元素。
|
||||
5. 页面空白、溢出、重叠或越界时,按 [validation-checklist.md](validation-checklist.md) 运行 XML 文本重叠检查,并人工核对越界、截断、图文压盖等视觉风险;工具当前只会报告 `xml_not_well_formed` / `bbox_overlap`。
|
||||
5. 页面空白、溢出、重叠、乱码或越界时,按 [validation-checklist.md](validation-checklist.md) 运行 XML 文本重叠检查,并人工核对越界、截断、图文压盖等视觉风险;工具会报告 XML 语法、二次转义实体、文本重叠和部分异常换行风险。
|
||||
6. 如果使用 `--slides '[...]'`,怀疑 shell 截断时直接切到两步创建:先 `slides +create`,再用 `xml_presentation.slide.create` 逐页添加。
|
||||
7. 局部问题用 `+replace-slide` 块级修正;整页结构要改时再用 `slide.delete` 旧页 + `slide.create` 新页。
|
||||
|
||||
@@ -52,7 +53,7 @@
|
||||
| 400 无法删除唯一幻灯片 | 演示文稿至少保留一页 | 先创建新页,再删除旧页 |
|
||||
| 1061002 媒体上传 params error | slides 媒体上传参数不符合约定 | 用 `slides +media-upload`,不要手拼原生 `medias/upload_all`;slides 唯一可用 `parent_type` 是 `slide_file` |
|
||||
| 1061004 forbidden | 当前身份对演示文稿无编辑权限 | 确认 user/bot 对目标 PPT 有编辑权限;bot 常见于 PPT 非该 bot 创建 |
|
||||
| 3350001 | XML 非 well-formed、XML 结构不符合服务端要求,或 replace 片段问题 | 优先检查未转义字符;replace 场景再看 `block_id` 和 `<content/>` |
|
||||
| 3350001 | XML 非 well-formed、XML 结构不符合服务端要求,或 replace 片段问题 | 优先检查未转义字符和二次转义实体;replace 场景再看 `block_id` 和 `<content/>` |
|
||||
| 3350002 | `revision_id` 大于当前版本 | 用 `-1` 取当前版本,或重新读 `xml_presentations.get` 取最新 `revision_id` |
|
||||
| validation: unsafe file path | `--file` 给了绝对路径或上层路径 | `--file` 必须是 CWD 内相对路径;先 `cd` 到素材目录再执行 |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Validation Checklist
|
||||
|
||||
创建或大幅改写演示文稿后,必须做一次显式验证。目标是发现空白页、XML 损坏、内容截断、明显溢出、弱视觉层级和未验证输出。
|
||||
创建或大幅改写演示文稿后,必须做一次显式验证。目标是发现空白页、XML 损坏、内容截断、异常换行、明显溢出、弱视觉层级和未验证输出。
|
||||
|
||||
小型已有页编辑也要做对应范围的验证:至少读取被改页面或全文 XML,确认目标元素已更新且未破坏周边结构。
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
5. 检查没有明显空白页、破损页、缺失标题或缺失主视觉。
|
||||
6. 检查页面不是全部退化为标题加 bullet list。
|
||||
7. 检查视觉层级:标题、主视觉、支撑信息三者可区分。
|
||||
8. 检查明显溢出和布局风险:重叠、越界、底部拥挤、长文本框。
|
||||
8. 检查明显溢出和布局风险:重叠、越界、底部拥挤、长文本框、异常换行。
|
||||
9. 在最终回复中给出简短验证记录。
|
||||
|
||||
回读命令:
|
||||
@@ -34,7 +34,9 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
通过标准:
|
||||
|
||||
- `summary.error_count == 0`。任何 error 都必须先修复再交付。
|
||||
- 当前工具只检查 XML well-formed 和文本元素之间的明显重叠;它不检查越界、文本高度不足、图文压盖、表格/图表压盖或底部拥挤。
|
||||
- `double_escaped_entity` warning 必须先修复再交付;它通常表示 HTML/XML 实体被二次转义,页面会显示 `&#...;` / ` ` / `<` 这类字面量。
|
||||
- 对异常换行、文本框高度不足等 wrap quality warning,默认也应修复后再交付;仅当它是普通正文的自然换行且用户明确允许时,才可在验证记录中说明豁免原因。
|
||||
- 当前工具检查 XML well-formed、文本元素之间的明显重叠,以及部分规则化异常换行;它不检查越界、图文压盖、表格/图表压盖或底部拥挤。
|
||||
- 该工具不能替代页数核对、关键内容核对或真实视觉验收。
|
||||
|
||||
常见 code 的处理方向:
|
||||
@@ -42,7 +44,13 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
| code | 含义 | 处理方式 |
|
||||
|------|------|----------|
|
||||
| `xml_not_well_formed` | XML 语法错误或文本未转义 | 修复标签闭合、属性引号、`&` / `<` / `>` 转义 |
|
||||
| `double_escaped_entity` | 文本中含二次转义实体,如 `&#9679;`、`&nbsp;`、`&lt;` | 改成目标 Unicode 文本,如 `●`、空格、`<`;只对 XML 保留字符做一层必要转义 |
|
||||
| `bbox_overlap` | 文本元素的估算绘制区域明显重叠 | 拉开文本坐标、缩小文本框/字号,或改成明确的分栏/分组结构 |
|
||||
| `text_word_split` / `text_phrase_split` | 中文词语或高频短语被异常拆行 | 增宽文本框、降低字号、改写短语或调整换行点,避免把词语/短语拆开 |
|
||||
| `text_orphan_line` | 最后一行只有极短中文尾巴 | 增宽文本框、缩小字号或重排文本,让尾行形成可读短句 |
|
||||
| `text_unnecessary_wrap` | 短标题或强调文本本应单行却换行 | 增宽文本框或缩小字号,优先保持单行 |
|
||||
| `text_center_wrapped` | 非封面/金句场景的多行文本居中 | 改为左对齐,或调整为真正的封面/金句元素 |
|
||||
| `text_box_too_short` | 文本框高度低于字号所需高度 | 增加文本框高度、降低字号或减少文本量 |
|
||||
|
||||
## Page Count And Structure
|
||||
|
||||
@@ -89,6 +97,7 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input <presentatio
|
||||
优先修复这些明显风险:
|
||||
|
||||
- 正文或标签框高度不足,文本很可能被截断。
|
||||
- 标题、标签、卡片标题或强调文本出现异常换行,例如拆词、拆短语、短尾行或本应单行却换行。
|
||||
- 多个主体元素在同一区域重叠,而不是有意叠加背景。
|
||||
- 重要内容越过画布边界,或贴近底部超过 `y=500`。
|
||||
- 高密度页使用单个长 bullet list,没有分栏、表格或分组。
|
||||
|
||||
@@ -9,6 +9,7 @@ import re
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
from difflib import SequenceMatcher
|
||||
from html import unescape
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -17,6 +18,13 @@ class XmlTextOverlapLintError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
TITLE_LIKE_TEXT_TYPES = {"title", "headline", "sub-headline", "card_title", "callout"}
|
||||
CENTER_ALLOWED_TEXT_TYPES = {"title", "quote", "hero"}
|
||||
DOUBLE_ESCAPED_ENTITY_PATTERN = re.compile(
|
||||
r"&#(?:[0-9]+|x[0-9A-Fa-f]+);|&(?:lt|gt|quot|apos|nbsp);"
|
||||
)
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise XmlTextOverlapLintError(message)
|
||||
|
||||
@@ -71,10 +79,95 @@ 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 preview_double_escaped_entity(text: str) -> str:
|
||||
return unescape(text).replace("\xa0", " ")
|
||||
|
||||
|
||||
def lint_double_escaped_entities(slide_xml: str) -> list[dict[str, Any]]:
|
||||
try:
|
||||
root = ET.fromstring(slide_xml)
|
||||
except ET.ParseError:
|
||||
return []
|
||||
|
||||
issues: list[dict[str, Any]] = []
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for node in root.iter():
|
||||
if xml_local_name(node.tag) != "content":
|
||||
continue
|
||||
for text in node.itertext():
|
||||
if not text:
|
||||
continue
|
||||
for match in DOUBLE_ESCAPED_ENTITY_PATTERN.finditer(text):
|
||||
entity = match.group(0)
|
||||
context = collapse_space(text)
|
||||
key = (entity, context)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
is_numeric = entity.startswith("&#")
|
||||
raw_entity = entity.replace("&", "&", 1)
|
||||
issues.append(
|
||||
{
|
||||
"level": "warning",
|
||||
"code": "double_escaped_entity",
|
||||
"message": f"Text contains a likely double-escaped XML/HTML entity: {raw_entity}",
|
||||
"entity": raw_entity,
|
||||
"context": context,
|
||||
"preview": preview_double_escaped_entity(text),
|
||||
"confidence": "high" if is_numeric else "medium",
|
||||
"hint": (
|
||||
"Use the intended literal Unicode text in slide XML, and only XML-escape reserved "
|
||||
"characters once. For example, write «姓名», ●, or ✓ directly instead of "
|
||||
"&#171;姓名&#187;, &#9679;, or &#10003;."
|
||||
),
|
||||
}
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
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 +232,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,9 +392,222 @@ 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]] = []
|
||||
issues: list[dict[str, Any]] = lint_double_escaped_entities(slide_xml)
|
||||
|
||||
for element in elements:
|
||||
issues.extend(lint_wrap_quality(element))
|
||||
|
||||
for index, left in enumerate(elements):
|
||||
for right in elements[index + 1 :]:
|
||||
|
||||
@@ -96,6 +96,65 @@ class XmlTextOverlapLintTest(unittest.TestCase):
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertNotIn("issues", result)
|
||||
|
||||
def test_lint_xml_reports_double_escaped_numeric_entities(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape type="text" topLeftX="80" topLeftY="80" width="420" height="90">
|
||||
<content textType="body"><p>&#171;姓名&#187;</p><p>&#9679; 占位符</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
issues = result["slides"][0]["issues"]
|
||||
self.assertEqual(result["summary"]["warning_count"], 3)
|
||||
self.assertTrue(all(issue["code"] == "double_escaped_entity" for issue in issues))
|
||||
self.assertEqual(issues[0]["entity"], "&#171;")
|
||||
self.assertEqual(issues[0]["preview"], "«姓名»")
|
||||
self.assertEqual(issues[0]["confidence"], "high")
|
||||
self.assertEqual(issues[2]["entity"], "&#9679;")
|
||||
self.assertEqual(issues[2]["preview"], "● 占位符")
|
||||
|
||||
def test_lint_xml_reports_double_escaped_named_entities(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape type="text" topLeftX="80" topLeftY="80" width="420" height="90">
|
||||
<content textType="body"><p>&lt;字段&gt;</p><p>A&nbsp;B</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
issues = result["slides"][0]["issues"]
|
||||
self.assertEqual(result["summary"]["warning_count"], 3)
|
||||
self.assertEqual([issue["entity"] for issue in issues], ["&lt;", "&gt;", "&nbsp;"])
|
||||
self.assertEqual(issues[0]["preview"], "<字段>")
|
||||
self.assertEqual(issues[2]["preview"], "A B")
|
||||
self.assertEqual(issues[0]["confidence"], "medium")
|
||||
|
||||
def test_lint_xml_does_not_report_regular_ampersands_urls_or_space_entities(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape type="text" topLeftX="80" topLeftY="80" width="640" height="120">
|
||||
<content textType="body">
|
||||
<p>Q&A</p>
|
||||
<p><a href="https://example.com/?a=1&b=2">link</a></p>
|
||||
<p>A B	C</p>
|
||||
</content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
self.assertEqual(result["summary"]["error_count"], 0)
|
||||
self.assertEqual(result["summary"]["warning_count"], 0)
|
||||
|
||||
def test_lint_xml_accepts_chinese_full_width_punctuation(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user