Compare commits

..

6 Commits

Author SHA1 Message Date
zhanghuanxu
b7c7f9f390 feat: expose slides presentation url 2026-06-25 13:59:28 +08:00
zhanghuanxu
3f993ea772 fix(lark-slides): detect double escaped entities 2026-06-24 18:05:14 +08:00
zhanghuanxu
461b4a7e80 fix: stop advertising slides screenshot scope 2026-06-24 16:00:27 +08:00
zhanghuanxu
d6b235aaa2 feat: add slide text wrap lint 2026-06-24 15:05:44 +08:00
zhanghuanxu
d6dfd1e043 feat: add slides xml get shortcut 2026-06-24 11:51:31 +08:00
zhanghuanxu
3a33794aec feat: add slides replace-pages shortcut 2026-06-24 11:37:31 +08:00
21 changed files with 1795 additions and 36 deletions

View File

@@ -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)

View File

@@ -11,6 +11,8 @@ func Shortcuts() []common.Shortcut {
SlidesCreate,
SlidesMediaUpload,
SlidesReplaceSlide,
SlidesReplacePages,
SlidesScreenshot,
SlidesXMLGet,
}
}

View File

@@ -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
}

View File

@@ -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": "",
},
},
})

View 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
}

View 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
}

View File

@@ -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"},

View File

@@ -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) {

View 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
},
}

View 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)
}
}

View File

@@ -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>` 输出不一致,以后两者为准。

View File

@@ -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

View 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 并截图检查,确认页序、视觉和文本没有破损。

View File

@@ -4,7 +4,7 @@
获取幻灯片页面截图并保存为本地图片文件。默认用于已存在 PPT 页面截图;传入 `--content` 时用于直接渲染单个 `<slide>` XML 片段预览。本 shortcut 会在 CLI 进程内解码并写入文件stdout 只返回文件路径、大小、页面 ID 等元信息,避免把图片 Base64 输出给模型。
注意:该截图能力对应的权限受白名单控制。只有在白名单内的应用才能申请该权限;不在白名单内的应用即使命令和参数正确,服务端仍可能返回权限或能力不可用相关错误
注意:该截图能力受应用白名单限制,绝大多数应用不可用。截图失败时不要引导用户申请 `slides:presentation:screenshot` 权限;记录错误后降级到 XML 读回、结构 lint、文本重叠检查等非截图检查路径
## 命令

View File

@@ -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>"
}
}'
```

View File

@@ -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 格式的完整内容 |
## 常见错误

View File

@@ -7,6 +7,7 @@
在真正创建或替换前,至少检查:
- 特殊字符已转义:正文和标题里的 `&``<``>` 不能裸写;属性值里的裸 `&` 也必须写成 `&amp;`
- 普通可见符号直接写 Unicode不要输出 HTML/XML entity 后再转义:`«姓名»``●``✓` 是正确文本;`&amp;#171;姓名&amp;#187;``&amp;#9679;``&amp;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&amp;A`,文本 `<` / `>` 写成 `&lt;` / `&gt;`,属性 URL `a=1&b=2 -> a=1&amp;b=2`
3. 检查失败页是否含未转义字符:`Q&A -> Q&amp;A`,文本 `<` / `>` 写成 `&lt;` / `&gt;`,属性 URL `a=1&b=2 -> a=1&amp;b=2`;同时检查是否有 `double_escaped_entity`,如 `&amp;#9679;``&amp;nbsp;``&amp;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` 到素材目录再执行 |

View File

@@ -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 实体被二次转义,页面会显示 `&#...;` / `&nbsp;` / `&lt;` 这类字面量
- 对异常换行、文本框高度不足等 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` | 文本中含二次转义实体,如 `&amp;#9679;``&amp;nbsp;``&amp;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没有分栏、表格或分组。

View File

@@ -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("&", "&amp;", 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 "
"&amp;#171;姓名&amp;#187;, &amp;#9679;, or &amp;#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 :]:

View File

@@ -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>&amp;#171;姓名&amp;#187;</p><p>&amp;#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"], "&amp;#171;")
self.assertEqual(issues[0]["preview"], "«姓名»")
self.assertEqual(issues[0]["confidence"], "high")
self.assertEqual(issues[2]["entity"], "&amp;#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>&amp;lt;字段&amp;gt;</p><p>A&amp;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], ["&amp;lt;", "&amp;gt;", "&amp;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&amp;A</p>
<p><a href="https://example.com/?a=1&amp;b=2">link</a></p>
<p>A&#32;B&#9;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(
"""

View 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()