Compare commits

...

28 Commits

Author SHA1 Message Date
liangshuo-1
781d188a60 chore: release v1.0.79 (#2082) 2026-07-28 21:02:37 +08:00
calendar-assistant
2e0fb9a880 docs(calendar): refine attendee guidance for bots and user-search identity (#2086)
Consolidate the user-search identity note into SKILL.md, and clarify bot
handling across attendee flows: bots are virtual identities with no
free/busy semantics, no meeting-room seat, and no room preference, so
they must be excluded from +suggestion, +room-find, and the scheduling
free/busy check. Note in create/update that bots remain valid attendees.
2026-07-28 20:34:09 +08:00
ILUO
927b37cd63 docs(task): document create data passthrough (#2080) 2026-07-28 20:26:35 +08:00
zhangjun-bytedance
d2e22c5fca feat: 0728 fix url (#2079) 2026-07-28 19:05:47 +08:00
ethan-zhx
fdae560014 docs(slides): add formula inline element syntax to quick-ref (#2077)
* docs(slides): add formula inline element syntax to quick-ref

* docs(slides): add chart gradient syntax to quick-ref
2026-07-28 17:40:54 +08:00
zhengzhijiej-tech
1b173e1953 fix(sheets): recognize OFL0X local office tokens (#2063) 2026-07-28 15:09:42 +08:00
ethan-zhx
57db1b3a8d feat(slides):update xsd (#2067) 2026-07-28 14:43:15 +08:00
calendar-assistant
4c1c5f5287 docs(calendar): clarify identity selection by event ownership (#2071)
Reframe the identity section around event ownership: use `--as user`
for the logged-in user's own events and `--as bot` for events the bot
creates or participates in, with matching `+agenda` examples.
2026-07-28 14:05:21 +08:00
liangshuo-1
3d2c10cd0b fix(ci): validate static workflow identity (#2015) 2026-07-27 19:39:11 +08:00
liangshuo-1
03de81c5f3 chore: release v1.0.78 (#2061) 2026-07-27 19:17:53 +08:00
yballul-bytedance
7abcaa7f68 feat(drive): add title+body joint search guidance and Top N pagination rules (#2059)
* feat(drive): add title+body joint search guidance and pagination rules for Top N results

- Add new blockquote explaining combined title+body search: use a single
  --query with both keywords instead of splitting into two searches
- Add rule for Top N results: N is an output cap, not --page-size; scan
  up to 3 pages filtering by title and summary_highlighted, read body
  only for title-matched candidates, stop early at N confirmed results
- Add quick-reference table row for folder-scoped title+body search
- Update pagination strategy rule to cover the 3-page cap for joint
  search in addition to the existing 5-page limit for other scenarios

* feat(drive): clarify Top N search output limit

* feat(drive): clarify search filters share one call

---------

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-27 17:19:48 +08:00
zhangjun-bytedance
8fb2476985 0727 fix rich text (#2062) 2026-07-27 16:17:08 +08:00
zhanghuanxu
56c9a2afd8 fix: exempt ghost text from slides lint 2026-07-27 11:59:04 +08:00
zhanghuanxu
2029189809 fix(slides):text may over flow shape 2026-07-27 11:59:04 +08:00
zhanghuanxu
ee427979a8 fix(slides): preserve info lint severity 2026-07-27 11:59:04 +08:00
zhanghuanxu
545abcbbde fix: refine character width estimation for lark-slides text lint
Replace the uniform 0.55em half-width coefficient with per-character-type
coefficients, add font-family awareness (sans/serif), bold multiplier,
letter-spacing support, and fix padding-aware line wrapping.

- Split half-width chars into uppercase (0.57), lowercase (0.51 sans / 0.53
  serif), digits (0.58), and punctuation (0.50)
- Add classify_font_family() to apply slightly wider lowercase widths for
  serif fonts (Georgia, Source Han Serif/思源宋体, Times, etc.)
- Add 5% width multiplier for bold text; detect <strong>/<b>/<i>/<em> tags
  and span-level bold/italic attributes in addition to content attrs
- Fix estimate_text_line_count_for_text to subtract paddingLeft/paddingRight
  from available width before computing wrap lines
- Add resolve_letter_spacing and wire letterSpacing through estimate_text_width
- Extract fontFamily/bold/italic/letterSpacing into element dict during parse
2026-07-27 11:59:04 +08:00
zhanghuanxu
4a73e83f1e fix(slides): allow chartParsedValues roundtrip tag
chartParsedValues is a server-injected roundtrip child tag under
chartField, not an attribute. Move it from ROUNDTRIP_SXSD_ATTRS to a
new ROUNDTRIP_SXSD_TAGS set and skip the tag (and its subtree) in the
SXSD tag whitelist check.
2026-07-27 11:59:04 +08:00
zhanghuanxu
7496420fa8 fix(slides): downgrade background-decoration text overflow to info
Large low-alpha text underneath other text shapes is typically a
background design element; treat text_may_overflow_shape as info in
that case instead of warning/error.
2026-07-27 11:59:04 +08:00
zhanghuanxu
43fabdf524 fix(slides): detect letterSpacing-driven text overflow
Extract letterSpacing from content/paragraph attrs and factor it into
width and line-count estimates, and stop short-circuiting the shape
overflow check for autoFit shapes so that letterSpacing-heavy captions
under normal-auto-fit no longer escape detection.
2026-07-27 11:59:04 +08:00
zhanghuanxu
8c46c74105 fix(slides): upgrade text overflow to error above 10px threshold
Text-shape overflow was always reported as a warning, which let clearly
broken pages pass the lint gate. Overflow > 10px now upgrades to error;
smaller overflows stay as warning to avoid flagging near-fit cases.
2026-07-27 11:59:04 +08:00
zhanghuanxu
70777c86c3 fix(slides): restrict canvas overflow checks 2026-07-27 11:59:04 +08:00
zhangjun-bytedance
38e8806d91 feat: event description support rich text (#1975)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:48:01 +08:00
liangshuo-1
a7865cd0a7 chore: release v1.0.77 (#2051) 2026-07-24 19:20:52 +08:00
BD-ZERO
f77b7eea68 fix(slides): support CSV multi-value for --slide-id in screenshot (#2047)
--slide-id used the cobra StringArray flag type, which only accepts
repeated flags and does not split comma-separated values, unlike
--slide-number (int_array -> cobra IntSlice) which already supported
CSV input. This made the two selector flags inconsistent.

Switch --slide-id to the string_slice flag type (cobra StringSlice),
which natively supports both comma-separated and repeated values, and
update the flag readers from StrArray to StrSlice. normalizeSlideIDs
already trims/dedupes/filters blanks, and
validateSlidesScreenshotSelectorLimit already caps the combined
selector count, so both continue to apply unchanged to CSV input.

Add tests covering --slide-id CSV parsing, whitespace/duplicate
normalization, and the >10 selector limit via CSV, mirroring the
existing --slide-number coverage.

Address review feedback:
- Fix "comma-separate" -> "comma-separated" wording in the --slide-id
  flag description (CodeRabbit).
- Set LARKSUITE_CLI_CONFIG_DIR to t.TempDir() in the new screenshot
  tests, per the AGENTS.md testing convention, so local configuration
  state cannot leak into or be modified by the suite.
- Add a dry-run E2E test (tests/cli_e2e/slides) that pins --slide-id
  CSV parsing through the built CLI binary and asserts the emitted
  slide_ids request body, per the AGENTS.md dry-run E2E requirement
  for shortcut flag/param changes.
- Update the lark-slides skill reference to document that --slide-id
  and --slide-number both accept comma-separated values, not just
  repeated flags, so agents can discover the new syntax.
2026-07-24 18:32:36 +08:00
fangshuyu-768
dd7f741b62 docs(skills): clarify callout child rules (#2048) 2026-07-24 18:18:32 +08:00
kiraWangRuilong
e7d5ecdd01 feat: add risk-control protection (#1910)
1. Add baseline safe protection for Feishu/Lark API endpoints.
2. Add lark-cli config risk-control on|off|default command for workspace-level safety protection control.
2026-07-24 17:12:10 +08:00
zhanghuanxu
4807283368 fix(slides): declare screenshot scope 2026-07-24 15:25:11 +08:00
ILUO
d2bb36591f fix/task search pagination (#2041)
* fix: send task search page token in query

* test: assert task search dry-run pagination contract
2026-07-24 14:28:54 +08:00
70 changed files with 4318 additions and 302 deletions

View File

@@ -25,19 +25,16 @@ jobs:
with:
script: |
const run = context.payload.workflow_run;
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");
if (run.repository.full_name !== context.payload.repository.full_name) throw new Error("repository name mismatch");
@@ -253,19 +250,16 @@ jobs:
with:
script: |
const run = context.payload.workflow_run;
if (run.name !== "CI") throw new Error(`unexpected workflow name: ${run.name}`);
let workflowPath = run.path || "";
if (!workflowPath) {
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
workflowPath = workflow.path || "";
}
if (workflowPath !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflowPath}`);
const workflowId = Number(run.workflow_id || 0);
if (!Number.isInteger(workflowId) || workflowId <= 0) throw new Error("missing workflow id");
const { data: workflow } = await github.rest.actions.getWorkflow({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: workflowId,
});
if (workflow.name !== "CI") throw new Error(`unexpected workflow name: ${workflow.name}`);
if (workflow.path !== ".github/workflows/ci.yml") throw new Error(`unexpected workflow path: ${workflow.path}`);
if (run.path && run.path !== workflow.path) throw new Error(`workflow path mismatch: ${run.path}`);
if (run.event !== "pull_request") throw new Error(`unexpected event: ${run.event}`);
if (run.conclusion !== "success") throw new Error(`unexpected conclusion: ${run.conclusion}`);
if (run.repository.id !== context.payload.repository.id) throw new Error("repository id mismatch");

View File

@@ -2,6 +2,69 @@
All notable changes to this project will be documented in this file.
## [v1.0.79] - 2026-07-28
### Features
- **slides**: update xsd (#2067)
### Bug Fixes
- **ci**: validate static workflow identity (#2015)
- **sheets**: recognize OFL0X local office tokens (#2063)
### Documentation
- **calendar**: clarify identity selection by event ownership (#2071)
- **slides**: add formula inline element syntax to quick-ref (#2077)
## [v1.0.78] - 2026-07-27
### Features
- event description support rich text (#1975)
### Bug Fixes
- **slides**: restrict canvas overflow checks
- **slides**: upgrade text overflow to error above 10px threshold
- **slides**: detect letterSpacing-driven text overflow
- **slides**: downgrade background-decoration text overflow to info
- **slides**: allow chartParsedValues roundtrip tag
- refine character width estimation for lark-slides text lint
- **slides**: preserve info lint severity
- **slides**: text may over flow shape
- exempt ghost text from slides lint
## [v1.0.77] - 2026-07-24
### Features
- introducing official card icon (#1973)
- **apps**: validate +file-list --page-size against server (0, 200] range (#2007)
- **apps**: support absolute and relative upload paths (#2005)
- **slides**: fill xml-schema-quick-ref gaps that forced XSD fallback (#2026)
- **slides**: add layout density lint for sparse/empty containers (#2022)
- add risk-control protection (#1910)
### Bug Fixes
- **slides**: normalize presentation flag aliases (#2032)
- **base**: classify +form-submit as high-risk-write (#1969)
- **slides**: declare screenshot scope
- **slides**: support CSV multi-value for --slide-id in screenshot (#2047)
### Documentation
- **skill**: clarify scope handling for query expansion (#2030)
- **base**: clarify complete and partial updates (#1993)
- **skills**: clarify callout child rules (#2048)
### Misc
- fix/task id handling (#2023)
- fix/task search pagination (#2041)
## [v1.0.75] - 2026-07-22
### Features
@@ -1638,6 +1701,9 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.79]: https://github.com/larksuite/cli/releases/tag/v1.0.79
[v1.0.78]: https://github.com/larksuite/cli/releases/tag/v1.0.78
[v1.0.77]: https://github.com/larksuite/cli/releases/tag/v1.0.77
[v1.0.75]: https://github.com/larksuite/cli/releases/tag/v1.0.75
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73

View File

@@ -285,6 +285,29 @@ To reduce these risks, the tool enables default security protections at multiple
We recommend using the Lark/Feishu bot integrated with this tool as a private conversational assistant. Do not add it to group chats or allow other users to interact with it, to avoid abuse of permissions or data leakage.
To reduce the security risks associated with access token theft, the CLI sends a minimal set of risk-control signals with OpenAPI requests made to exact official Feishu/Lark HTTPS domains. These signals are used to help identify anomalous API activity. This protection is enabled by default. The information sent is limited to:
- Operating system type: macOS, Windows, or Linux
- Device hardware model: for example, Mac17,9
To disable this protection for the current workspace, run:
```bash
lark-cli config risk-control off
```
To enable this protection for the current workspace, run:
```bash
lark-cli config risk-control on
```
To restore the default policy for the current workspace, run:
```bash
lark-cli config risk-control default
```
Please fully understand all usage risks. By using this tool, you are deemed to voluntarily assume all related responsibilities.
## Star History

View File

@@ -286,6 +286,29 @@ lark-cli schema im.messages.delete
我们建议您将对接本工具的飞书机器人作为私人对话助手使用,请勿将其拉入群聊或允许其他用户与其交互,以避免权限被滥用或数据泄露。
为降低访问令牌被盗用后的安全风险CLI 在向飞书/Lark 官方 HTTPS 精确域名发起 OpenAPI 请求时,会随请求发送一组最小化的风控信号,用于辅助识别异常调用行为。该保护默认开启,发送的信息仅包括:
- 操作系统类型macOS、Windows 或 Linux
- 设备的硬件产品型号:例如 Mac17,9
如需让当前 workspace 退出该保护,可执行以下命令:
```bash
lark-cli config risk-control off
```
如需开启当前 workspace 的保护,可执行以下命令:
```bash
lark-cli config risk-control on
```
恢复当前 workspace 默认策略可执行:
```bash
lark-cli config risk-control default
```
请您充分知悉全部使用风险,使用本工具即视为您自愿承担相关所有责任。
## Star History

View File

@@ -31,6 +31,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(NewCmdConfigShow(f, nil))
cmd.AddCommand(NewCmdConfigDefaultAs(f))
cmd.AddCommand(NewCmdConfigStrictMode(f))
cmd.AddCommand(NewCmdConfigRiskControl(f))
cmd.AddCommand(NewCmdConfigPolicy(f))
cmd.AddCommand(NewCmdConfigPlugins(f))
cmd.AddCommand(NewCmdConfigKeychainDowngrade(f))

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"fmt"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// NewCmdConfigRiskControl creates the workspace risk-control policy command.
func NewCmdConfigRiskControl(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "risk-control [on|off|default]",
Short: "Manage workspace account-protection policy",
Long: `View or set the account-protection risk-control policy for this workspace.
Account protection is on by default. Use off to opt this workspace out, on to
opt it back in explicitly, or default to remove the explicit preference.`,
Args: cobra.MaximumNArgs(1),
// This is persistent workspace policy, not credential management.
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
cmd.SilenceUsage = true
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
config, err := core.LoadOrNotConfigured()
if err != nil {
return err
}
if len(args) == 0 {
printRiskControl(f, config)
return nil
}
switch args[0] {
case "on":
enabled := true
config.RiskControl = &enabled
case "off":
enabled := false
config.RiskControl = &enabled
case "default":
config.RiskControl = nil
default:
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid risk-control value %q, valid values: on | off | default", args[0])
}
if err := core.SaveMultiAppConfig(config); err != nil {
return errs.NewInternalError(errs.SubtypeStorage,
"failed to save risk-control policy: %v", err).WithCause(err)
}
fmt.Fprintf(f.IOStreams.ErrOut, "Risk control set to %s (workspace)\n", args[0])
return nil
},
}
cmdutil.SetRisk(cmd, cmdutil.RiskWrite)
return cmd
}
func printRiskControl(f *cmdutil.Factory, config *core.MultiAppConfig) {
source := "default"
if config.RiskControl != nil {
source = "workspace"
}
fmt.Fprintf(f.IOStreams.Out, "risk-control: %s (source: %s)\n", riskControlState(config.RiskControlEnabled()), source)
}
func riskControlState(enabled bool) string {
if enabled {
return "on"
}
return "off"
}

View File

@@ -0,0 +1,130 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
func TestRiskControlWorkspacePolicy(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
f, stdout, stderr, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off: %v", err)
}
loaded, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
if !strings.Contains(stderr.String(), "set to off") {
t.Fatalf("stderr = %q", stderr.String())
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show: %v", err)
}
if got := stdout.String(); got != "risk-control: off (source: workspace)\n" {
t.Fatalf("stdout = %q", got)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"on"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set on: %v", err)
}
loaded, err = core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || !*loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit true", loaded.RiskControl)
}
cmd = NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"default"})
if err := cmd.Execute(); err != nil {
t.Fatalf("reset default: %v", err)
}
loaded, err = core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl != nil {
t.Fatalf("RiskControl = %v, want nil", loaded.RiskControl)
}
stdout.Reset()
cmd = NewCmdConfigRiskControl(f)
if err := cmd.Execute(); err != nil {
t.Fatalf("show default: %v", err)
}
if got := stdout.String(); got != "risk-control: on (source: default)\n" {
t.Fatalf("stdout = %q", got)
}
}
func TestRiskControlWorkspacePolicyRejectsInvalidValue(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
if err := core.SaveMultiAppConfig(&core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}); err != nil {
t.Fatal(err)
}
f, _, _, _ := cmdutil.TestFactory(t, nil)
cmd := NewCmdConfigRiskControl(f)
cmd.SetArgs([]string{"invalid"})
err := cmd.Execute()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T %v, want *errs.ValidationError", err, err)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
}
func TestRiskControlWorkspacePolicyAllowedWithExternalCredentials(t *testing.T) {
f := newConfigFactoryWithExternalProvider(t)
config := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_test", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(config); err != nil {
t.Fatal(err)
}
cmd := NewCmdConfig(f)
cmd.SetArgs([]string{"risk-control", "off"})
if err := cmd.Execute(); err != nil {
t.Fatalf("set off with external credentials: %v", err)
}
loaded, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if loaded.RiskControl == nil || *loaded.RiskControl {
t.Fatalf("RiskControl = %v, want explicit false", loaded.RiskControl)
}
}

View File

@@ -22,6 +22,7 @@ import (
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/keychain"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/internal/riskcontrol"
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
"github.com/larksuite/cli/internal/transport"
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
@@ -33,7 +34,7 @@ import (
// Phase 1: HttpClient (no credential dependency)
// Phase 2: Credential (sole data source for account info)
// Phase 3: Config derived from Credential
// Phase 4: LarkClient derived from Credential
// Phase 4: LarkClient derived from Credential and workspace policy
func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
streams = normalizeStreams(streams)
f := &Factory{
@@ -54,9 +55,10 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
// Phase 0: FileIO provider (no dependency)
f.FileIOProvider = fileio.GetProvider()
workspaceConfig := core.NewConfigSnapshot()
// Phase 1: HttpClient (no credential dependency)
f.HttpClient = cachedHttpClientFunc(f)
f.HttpClient = cachedHttpClientFunc(f, workspaceConfig)
// Phase 2: Credential (sole data source)
// Keychain is read via closure so callers can replace f.Keychain after construction.
@@ -67,7 +69,7 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
ErrOut: f.IOStreams.ErrOut,
})
// Phase 3: Config derived from Credential via an explicit conversion boundary.
// Phase 3: Runtime config contains resolved account data only.
f.Config = sync.OnceValues(func() (*core.CliConfig, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -78,8 +80,9 @@ func NewDefault(streams *IOStreams, inv InvocationContext) *Factory {
return cfg, nil
})
// Phase 4: LarkClient from Credential (placeholder AppSecret)
f.LarkClient = cachedLarkClientFunc(f)
// Phase 4: LarkClient composes account data and workspace policy at the SDK
// transport boundary.
f.LarkClient = cachedLarkClientFunc(f, workspaceConfig)
return f
}
@@ -108,13 +111,16 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
// .StderrIsTerminal field, which tests set directly.
var warnIfProxied = transport.WarnIfProxied
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
func cachedHttpClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*http.Client, error) {
return sync.OnceValues(func() (*http.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
var rt http.RoundTripper = transport.Shared()
rt = riskcontrol.NewTransport(rt, hostSignalSource)
rt = &RetryTransport{Base: rt}
rt = &SecurityHeaderTransport{Base: rt}
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
@@ -128,7 +134,7 @@ func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
})
}
func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) func() (*lark.Client, error) {
return sync.OnceValues(func() (*lark.Client, error) {
acct, err := f.Credential.ResolveAccount(context.Background())
if err != nil {
@@ -142,8 +148,15 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
if f.IOStreams.StderrIsTerminal {
warnIfProxied(f.IOStreams.ErrOut)
}
hostSignalSource := resolveSDKHostSignalSource(workspaceConfig)
var sdkBase http.RoundTripper = transport.Shared()
// The innermost SDK boundary always strips reserved host-signal headers;
// a nil source makes it strip-only when workspace policy disables signal
// collection.
sdkBase = riskcontrol.NewTransport(sdkBase, hostSignalSource)
sdkTransport := wrapSDKTransport(sdkBase)
opts = append(opts, lark.WithHttpClient(&http.Client{
Transport: buildSDKTransport(),
Transport: sdkTransport,
CheckRedirect: safeRedirectPolicy,
}))
ep := core.ResolveEndpoints(acct.Brand)
@@ -152,9 +165,8 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
})
}
func buildSDKTransport() http.RoundTripper {
var sdkTransport http.RoundTripper = transport.Shared()
sdkTransport = &RetryTransport{Base: sdkTransport}
func wrapSDKTransport(next http.RoundTripper) http.RoundTripper {
var sdkTransport http.RoundTripper = &RetryTransport{Base: next}
sdkTransport = &UserAgentTransport{Base: sdkTransport}
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
sdkTransport = &auth.SecurityPolicyTransport{Base: sdkTransport}

View File

@@ -6,10 +6,15 @@ package cmdutil
import (
"io"
"testing"
"github.com/larksuite/cli/internal/core"
)
func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c1, err := fn()
if err != nil {
@@ -29,7 +34,10 @@ func TestCachedHttpClientFunc_ReturnsSameInstance(t *testing.T) {
}
func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c, _ := fn()
if c.Timeout == 0 {
t.Error("expected non-zero timeout")
@@ -37,7 +45,10 @@ func TestCachedHttpClientFunc_HasTimeout(t *testing.T) {
}
func TestCachedHttpClientFunc_HasRedirectPolicy(t *testing.T) {
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ErrOut: io.Discard}})
isEnabled := false
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
c, _ := fn()
if c.CheckRedirect == nil {
t.Error("expected CheckRedirect to be set (safeRedirectPolicy)")

View File

@@ -8,6 +8,7 @@ import (
"testing"
_ "github.com/larksuite/cli/extension/credential/env" // registers the env-backed account provider
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars"
)
@@ -36,13 +37,15 @@ var proxyWarnGateCases = []struct {
// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path
// invokes WarnIfProxied only when stderr is an interactive terminal.
func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) {
isEnabled := false
for _, tc := range proxyWarnGateCases {
t.Run(tc.name, func(t *testing.T) {
calls := installProxyWarnSpy(t)
fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{
ErrOut: io.Discard, StderrIsTerminal: tc.terminal,
}})
f, _, _, _ := TestFactory(t, &core.CliConfig{AppID: "test-app"})
f.IOStreams.ErrOut = io.Discard
f.IOStreams.StderrIsTerminal = tc.terminal
fn := cachedHttpClientFunc(f, staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &isEnabled}})
if _, err := fn(); err != nil {
t.Fatalf("http client init: %v", err)
}
@@ -73,7 +76,7 @@ func TestCachedLarkClientFunc_ProxyWarnGate(t *testing.T) {
// normalizeStreams copies the struct (out := *s), so the
// StderrIsTerminal field survives into f.IOStreams.
f := NewDefault(&IOStreams{ErrOut: io.Discard, StderrIsTerminal: tc.terminal}, InvocationContext{})
if _, err := cachedLarkClientFunc(f)(); err != nil {
if _, err := cachedLarkClientFunc(f, nil)(); err != nil {
t.Fatalf("lark client init: %v", err)
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/riskcontrol"
)
type workspaceConfigSource interface {
MultiAppConfig() (*core.MultiAppConfig, error)
}
// resolveSDKHostSignalSource applies workspace policy at the SDK transport
// boundary.
func resolveSDKHostSignalSource(config workspaceConfigSource) riskcontrol.Source {
if config == nil {
return nil
}
workspace, configErr := config.MultiAppConfig()
// Default-on means an existing config with no explicit preference. Absent
// or unreadable config cannot authorize host-signal collection.
if configErr != nil || !workspace.RiskControlEnabled() {
return nil
}
return riskcontrol.NewHostSource()
}

View File

@@ -0,0 +1,45 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package cmdutil
import (
"errors"
"testing"
"github.com/larksuite/cli/internal/core"
)
type staticWorkspaceConfig struct {
config *core.MultiAppConfig
err error
}
func (s staticWorkspaceConfig) MultiAppConfig() (*core.MultiAppConfig, error) {
return s.config, s.err
}
func TestResolveSDKHostSignalSource(t *testing.T) {
disabled := false
tests := []struct {
name string
config workspaceConfigSource
wantSource bool
}{
{name: "workspace default on", config: staticWorkspaceConfig{config: &core.MultiAppConfig{}}, wantSource: true},
{name: "workspace opt-out", config: staticWorkspaceConfig{config: &core.MultiAppConfig{RiskControl: &disabled}}},
{name: "missing config", config: staticWorkspaceConfig{err: errors.New("file does not exist")}},
{name: "unreadable config", config: staticWorkspaceConfig{err: errors.New("permission denied")}},
{name: "nil config value", config: staticWorkspaceConfig{}},
{name: "nil config source"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := resolveSDKHostSignalSource(test.config)
if (got != nil) != test.wantSource {
t.Fatalf("resolveSDKHostSignalSource() = %T, wantSource %t", got, test.wantSource)
}
})
}
}

View File

@@ -15,6 +15,7 @@ import (
exttransport "github.com/larksuite/cli/extension/transport"
internalauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/riskcontrol"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
@@ -91,13 +92,13 @@ func TestRetryTransport_DefaultNoRetry(t *testing.T) {
}
// ---------------------------------------------------------------------------
// buildSDKTransport chain composition
// wrapSDKTransport chain composition
// ---------------------------------------------------------------------------
func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := buildSDKTransport()
func TestWrapSDKTransport_IncludesRetryTransport(t *testing.T) {
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
@@ -110,18 +111,23 @@ func TestBuildSDKTransport_IncludesRetryTransport(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
func TestBuildSDKTransport_WithExtension(t *testing.T) {
func TestWrapSDKTransport_WithExtension(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(&stubTransportProvider{})
t.Cleanup(func() { exttransport.Register(nil) })
t.Cleanup(func() { exttransport.Register(previous) })
transport := buildSDKTransport()
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → Base
// Chain: extensionMiddleware → SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
mid, ok := transport.(*extensionMiddleware)
if !ok {
t.Fatalf("outer transport type = %T, want *extensionMiddleware", transport)
@@ -138,17 +144,23 @@ func TestBuildSDKTransport_WithExtension(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("innermost transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
func TestWrapSDKTransport_WithoutExtension(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(nil)
t.Cleanup(func() { exttransport.Register(previous) })
transport := buildSDKTransport()
transport := wrapSDKTransport(riskcontrol.NewTransport(http.DefaultTransport, nil))
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → Base
// Chain: SecurityPolicy → BuildHeader → UserAgent → Retry → RiskControl → Base
sec, ok := transport.(*internalauth.SecurityPolicyTransport)
if !ok {
t.Fatalf("outer transport type = %T, want *auth.SecurityPolicyTransport", transport)
@@ -161,9 +173,13 @@ func TestBuildSDKTransport_WithoutExtension(t *testing.T) {
if !ok {
t.Fatalf("layer after BuildHeader = %T, want *UserAgentTransport", bh.Base)
}
if _, ok := ua.Base.(*RetryTransport); !ok {
retry, ok := ua.Base.(*RetryTransport)
if !ok {
t.Fatalf("inner transport type = %T, want *RetryTransport", ua.Base)
}
if _, ok := retry.Base.(*riskcontrol.Transport); !ok {
t.Fatalf("layer after Retry = %T, want *riskcontrol.Transport", retry.Base)
}
}
// ---------------------------------------------------------------------------
@@ -261,6 +277,40 @@ func (buildTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Resp
return nil
}
type riskHeaderTamperingInterceptor struct{}
func (riskHeaderTamperingInterceptor) PreRoundTrip(req *http.Request) func(*http.Response, error) {
req.Header.Set(riskcontrol.HeaderOSType, "extension-value")
req.Header.Set(riskcontrol.HeaderProductModel, "extension-value")
return nil
}
func TestWrapSDKTransport_StripsExtensionRiskHeaders(t *testing.T) {
previous := exttransport.GetProvider()
exttransport.Register(&stubTransportProvider{interceptor: riskHeaderTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(previous) })
var received http.Header
network := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer token")
resp, err := wrapSDKTransport(riskcontrol.NewTransport(network, nil)).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if received.Get(riskcontrol.HeaderOSType) != "" || received.Get(riskcontrol.HeaderProductModel) != "" {
t.Fatalf("extension risk headers reached network: %v", received)
}
}
// TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader verifies that the
// X-Cli-Build header is force-written by BuildHeaderTransport in the SDK
// transport chain, even when an extension tries to delete or spoof it. This
@@ -277,7 +327,7 @@ func TestBuildHeaderTransport_SDKChain_OverridesTamperedHeader(t *testing.T) {
exttransport.Register(&stubTransportProvider{interceptor: buildTamperingInterceptor{}})
t.Cleanup(func() { exttransport.Register(nil) })
// Replicate the SDK chain layering used by buildSDKTransport.
// Replicate the SDK chain layering used by wrapSDKTransport.
var base http.RoundTripper = http.DefaultTransport
base = &RetryTransport{Base: base}
base = &UserAgentTransport{Base: base}

View File

@@ -60,11 +60,18 @@ func (a *AppConfig) ProfileName() string {
// MultiAppConfig is the multi-app config file format.
type MultiAppConfig struct {
StrictMode StrictMode `json:"strictMode,omitempty"`
RiskControl *bool `json:"riskControl,omitempty"`
CurrentApp string `json:"currentApp,omitempty"`
PreviousApp string `json:"previousApp,omitempty"`
Apps []AppConfig `json:"apps"`
}
// RiskControlEnabled resolves the workspace policy. An omitted preference
// keeps the default-on account-protection behavior.
func (m *MultiAppConfig) RiskControlEnabled() bool {
return m != nil && (m.RiskControl == nil || *m.RiskControl)
}
// CurrentAppConfig returns the currently active app config.
// Resolution priority: profileOverride > CurrentApp field > Apps[0].
func (m *MultiAppConfig) CurrentAppConfig(profileOverride string) *AppConfig {

View File

@@ -0,0 +1,37 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"io/fs"
"sync"
)
// ConfigSnapshot lazily captures one stable view of config.json for a CLI
// invocation. All runtime consumers share the same load result so account and
// workspace policy resolution cannot observe different file revisions. Callers
// must treat the returned config as read-only.
type ConfigSnapshot struct {
load func() (*MultiAppConfig, error)
}
// NewConfigSnapshot creates a lazily loaded invocation-scoped config snapshot.
func NewConfigSnapshot() *ConfigSnapshot {
return newConfigSnapshot(LoadMultiAppConfig)
}
func newConfigSnapshot(load func() (*MultiAppConfig, error)) *ConfigSnapshot {
if load == nil {
return &ConfigSnapshot{}
}
return &ConfigSnapshot{load: sync.OnceValues(load)}
}
// MultiAppConfig returns the captured persistent config and load error.
func (s *ConfigSnapshot) MultiAppConfig() (*MultiAppConfig, error) {
if s == nil || s.load == nil {
return nil, fs.ErrNotExist
}
return s.load()
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package core
import (
"errors"
"io/fs"
"testing"
)
func TestConfigSnapshotLoadsOnce(t *testing.T) {
calls := 0
want := &MultiAppConfig{}
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
calls++
return want, nil
})
for range 2 {
config, err := snapshot.MultiAppConfig()
if err != nil {
t.Fatal(err)
}
if config != want {
t.Fatal("snapshot returned a different config instance")
}
}
if calls != 1 {
t.Fatalf("config loads = %d, want 1", calls)
}
}
func TestConfigSnapshotZeroValueIsMissing(t *testing.T) {
config, err := (&ConfigSnapshot{}).MultiAppConfig()
if config != nil || !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, fs.ErrNotExist)", config, err)
}
}
func TestConfigSnapshotCachesError(t *testing.T) {
calls := 0
want := errors.New("load failed")
snapshot := newConfigSnapshot(func() (*MultiAppConfig, error) {
calls++
return nil, want
})
for range 2 {
config, err := snapshot.MultiAppConfig()
if config != nil || !errors.Is(err, want) {
t.Fatalf("MultiAppConfig() = (%v, %v), want (nil, %v)", config, err, want)
}
}
if calls != 1 {
t.Fatalf("config loads = %d, want 1", calls)
}
}

View File

@@ -60,7 +60,9 @@ func TestAppConfig_LangOmitEmpty(t *testing.T) {
}
func TestMultiAppConfig_RoundTrip(t *testing.T) {
disabled := false
config := &MultiAppConfig{
RiskControl: &disabled,
Apps: []AppConfig{{
AppId: "cli_test", AppSecret: PlainSecret("s"),
Brand: BrandLark, Lang: "zh", Users: []AppUser{},
@@ -84,6 +86,9 @@ func TestMultiAppConfig_RoundTrip(t *testing.T) {
if got.Apps[0].Brand != BrandLark {
t.Errorf("Brand = %q, want %q", got.Apps[0].Brand, BrandLark)
}
if got.RiskControl == nil || *got.RiskControl {
t.Errorf("RiskControl = %v, want explicit false", got.RiskControl)
}
}
func TestResolveConfigFromMulti_RejectsSecretKeyMismatch(t *testing.T) {

View File

@@ -0,0 +1,142 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package deviceinfo collects the platform hardware product model and the
// platform values used by device-related risk-control headers.
package riskcontrol
import (
"runtime"
"strings"
"sync"
"unicode"
"unicode/utf8"
"golang.org/x/net/http/httpguts"
)
// OSType is the server-side risk-control operating-system enum.
type OSType string
// OS type enum values for X-Agent-Os-Type.
const (
OSTypeUnknown = "0"
OSTypeWindows = "1"
OSTypeLinux = "2"
OSTypeMacOS = "3"
)
const (
// TerminalTypePC is the fixed X-Agent-Terminal-Type value for the CLI.
TerminalTypePC = "1"
// Unknown is used when the hardware product model cannot be collected.
Unknown = "Unknown"
// deviceModelMaxBytes bounds the value added to X-Agent-Device-Type.
// Device models are short identifiers; a larger value is treated as
// malformed rather than truncated so the header never misrepresents it.
deviceModelMaxBytes = 256
)
// Snapshot contains the deliberately small risk-control signal set.
// ProductModel is omitted when the platform cannot provide a safe value.
type Snapshot struct {
OSType OSType
ProductModel string
}
// Source supplies one immutable process-level snapshot.
type Source interface {
Snapshot() Snapshot
}
// HostSource lazily reads host signals once, after outbound policy authorizes
// the first request. Failed probes are cached and are not retried per request.
type HostSource struct {
once sync.Once
value Snapshot
readModel func() string
}
// NewHostSource creates the production host signal source.
func NewHostSource() *HostSource {
return &HostSource{readModel: readDeviceModel}
}
// Snapshot returns the cached host signal snapshot.
func (s *HostSource) Snapshot() Snapshot {
if s == nil {
return Snapshot{}
}
s.once.Do(func() {
readModel := s.readModel
if readModel == nil {
readModel = readDeviceModel
}
s.value = Snapshot{
OSType: GetOSType(OSName()),
ProductModel: normalizeDeviceModel(readModel()),
}
})
return s.value
}
// normalizeModel removes non-printable characters and returns a model only
// when the remaining text is safe to use as an HTTP header value. Input that
// cannot produce a valid model is rejected so Get can fall back to Unknown.
func normalizeDeviceModel(model string) string {
if !utf8.ValidString(model) {
return ""
}
model = strings.Map(func(r rune) rune {
switch {
case r == '\r' || r == '\n' || r == '\x00':
return -1
case unicode.IsSpace(r):
return ' '
case unicode.IsPrint(r):
return r
default:
return -1
}
}, model)
model = strings.Join(strings.Fields(model), " ")
if model == "" || len(model) > deviceModelMaxBytes {
return ""
}
if !httpguts.ValidHeaderFieldValue(model) {
return ""
}
return model
}
// GetOSType maps a platform name to the X-Agent-Os-Type enum.
func GetOSType(osName string) OSType {
switch osName {
case "Windows":
return OSTypeWindows
case "Linux":
return OSTypeLinux
case "MacOS":
return OSTypeMacOS
default:
return OSTypeUnknown
}
}
// OSName returns the platform name used by GetOSType.
func OSName() string {
switch runtime.GOOS {
case "darwin":
return "MacOS"
case "windows":
return "Windows"
case "linux":
return "Linux"
default:
return runtime.GOOS
}
}

View File

@@ -0,0 +1,27 @@
//go:build darwin
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "golang.org/x/sys/unix"
// readDeviceModel reads the current product key first and falls back to the
// legacy model key. Trying both keys is more robust than branching on a macOS
// version because virtualized or restricted environments may expose only one.
func readDeviceModel() string {
return readDarwinDeviceModel(unix.Sysctl)
}
func readDarwinDeviceModel(readSysctl func(string) (string, error)) string {
for _, key := range [...]string{"hw.product", "hw.model"} {
model, err := readSysctl(key)
if err == nil {
if model = normalizeDeviceModel(model); model != "" {
return model
}
}
}
return ""
}

View File

@@ -0,0 +1,48 @@
//go:build darwin
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"errors"
"reflect"
"testing"
)
func TestReadDarwinDeviceModelPrefersProductAndFallsBackToModel(t *testing.T) {
t.Run("product available", func(t *testing.T) {
var keys []string
got := readDarwinDeviceModel(func(key string) (string, error) {
keys = append(keys, key)
if key == "hw.product" {
return "Mac16,1", nil
}
return "", errors.New("unexpected fallback")
})
if got != "Mac16,1" {
t.Fatalf("model = %q, want %q", got, "Mac16,1")
}
if want := []string{"hw.product"}; !reflect.DeepEqual(keys, want) {
t.Fatalf("sysctl keys = %v, want %v", keys, want)
}
})
t.Run("product unavailable", func(t *testing.T) {
var keys []string
got := readDarwinDeviceModel(func(key string) (string, error) {
keys = append(keys, key)
if key == "hw.model" {
return "MacBookPro18,3", nil
}
return "", errors.New("not available")
})
if got != "MacBookPro18,3" {
t.Fatalf("model = %q, want %q", got, "MacBookPro18,3")
}
if want := []string{"hw.product", "hw.model"}; !reflect.DeepEqual(keys, want) {
t.Fatalf("sysctl keys = %v, want %v", keys, want)
}
})
}

View File

@@ -0,0 +1,17 @@
//go:build linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
// readDeviceModel returns a stable device model for Linux. DMI and device-tree
// values vary widely and can expose the host or virtualization platform when
// the CLI runs in a container or sandbox.
func readDeviceModel() string {
return readLinuxDeviceModel()
}
func readLinuxDeviceModel() string {
return "linux"
}

View File

@@ -0,0 +1,20 @@
//go:build linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "testing"
func TestReadDeviceModelReturnsLinux(t *testing.T) {
if got := readDeviceModel(); got != "linux" {
t.Fatalf("readDeviceModel() = %q, want %q", got, "linux")
}
}
func TestReadLinuxDeviceModel(t *testing.T) {
if got := readLinuxDeviceModel(); got != "linux" {
t.Fatalf("readLinuxDeviceModel() = %q, want %q", got, "linux")
}
}

View File

@@ -0,0 +1,11 @@
//go:build !darwin && !windows && !linux
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
// readDeviceModel returns an empty model on unsupported platforms.
func readDeviceModel() string {
return ""
}

View File

@@ -0,0 +1,143 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
"unicode"
)
func TestHostSourceCachesNonEmptyModel(t *testing.T) {
calls := 0
s := &HostSource{readModel: func() string {
calls++
return " MacBookPro18,3\n"
}}
if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
t.Fatalf("first Snapshot().ProductModel = %q, want %q", got.ProductModel, "MacBookPro18,3")
}
if got := s.Snapshot(); got.ProductModel != "MacBookPro18,3" {
t.Fatalf("second Snapshot().ProductModel = %q, want cached model", got.ProductModel)
}
if calls != 1 {
t.Fatalf("read called %d times, want 1", calls)
}
}
func TestHostSourceCachesEmptyModel(t *testing.T) {
calls := 0
s := &HostSource{readModel: func() string {
calls++
return ""
}}
if got := s.Snapshot(); got.ProductModel != "" {
t.Fatalf("first Snapshot().ProductModel = %q, want empty", got.ProductModel)
}
if got := s.Snapshot(); got.ProductModel != "" {
t.Fatalf("second Snapshot().ProductModel = %q, want cached empty result", got.ProductModel)
}
if calls != 1 {
t.Fatalf("read called %d times, want 1", calls)
}
}
func TestHostSourceReadsOnceAcrossConcurrentCalls(t *testing.T) {
var calls atomic.Int32
s := &HostSource{readModel: func() string {
calls.Add(1)
return "ThinkPad X1 Carbon"
}}
const goroutines = 32
var wg sync.WaitGroup
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
snapshot := s.Snapshot()
if snapshot.ProductModel != "ThinkPad X1 Carbon" {
t.Errorf("Snapshot().ProductModel = %q, want %q", snapshot.ProductModel, "ThinkPad X1 Carbon")
}
}()
}
wg.Wait()
if got := calls.Load(); got != 1 {
t.Fatalf("read called %d times, want 1", got)
}
}
func TestNormalizeDeviceModel(t *testing.T) {
tests := []struct {
name string
model string
want string
}{
{name: "trims surrounding whitespace", model: " MacBookPro18,3\n", want: "MacBookPro18,3"},
{name: "trims device tree terminator", model: "Raspberry Pi 5\x00", want: "Raspberry Pi 5"},
{name: "allows printable Unicode", model: "联想 ThinkPad X1", want: "联想 ThinkPad X1"},
{name: "rejects empty", model: " \t\r\n"},
{name: "rejects invalid UTF-8", model: string([]byte{'M', 0xff, '1'})},
{name: "removes CRLF", model: "model\r\nname", want: "modelname"},
{name: "normalizes tab", model: "model\tname", want: "model name"},
{name: "removes NUL", model: "model\x00name", want: "modelname"},
{name: "removes control character", model: "model\x1fname", want: "modelname"},
{name: "removes DEL", model: "model\x7fname", want: "modelname"},
{name: "normalizes Unicode line separator", model: "model\u2028name", want: "model name"},
{name: "collapses whitespace", model: " model\t \u00a0 name ", want: "model name"},
{name: "accepts maximum byte length", model: strings.Repeat("a", deviceModelMaxBytes), want: strings.Repeat("a", deviceModelMaxBytes)},
{name: "rejects overlong value", model: strings.Repeat("a", deviceModelMaxBytes+1)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeDeviceModel(tt.model); got != tt.want {
t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", tt.model, got, tt.want)
}
})
}
}
func TestNormalizeDeviceModelRemovesHTTPControlBytes(t *testing.T) {
for value := 0; value <= 0x7f; value++ {
if value >= 0x20 && value < 0x7f {
continue
}
t.Run(fmt.Sprintf("0x%02x", value), func(t *testing.T) {
model := "model" + string(rune(value)) + "name"
want := "modelname"
if value != '\r' && value != '\n' && value != '\x00' && unicode.IsSpace(rune(value)) {
want = "model name"
}
if got := normalizeDeviceModel(model); got != want {
t.Fatalf("normalizeDeviceModel(%q) = %q, want %q", model, got, want)
}
})
}
}
func TestGetOSType(t *testing.T) {
tests := []struct {
name string
want OSType
}{
{name: "Windows", want: OSTypeWindows},
{name: "Linux", want: OSTypeLinux},
{name: "MacOS", want: OSTypeMacOS},
{name: "unknown", want: OSTypeUnknown},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := GetOSType(tt.name); got != tt.want {
t.Errorf("GetOSType(%q) = %q, want %q", tt.name, got, tt.want)
}
})
}
}

View File

@@ -0,0 +1,44 @@
//go:build windows
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import "golang.org/x/sys/windows/registry"
// systemInfoRegistryPaths lists registry locations in device-model lookup order.
var systemInfoRegistryPaths = [...]string{
`HARDWARE\DESCRIPTION\System\BIOS`,
`SYSTEM\CurrentControlSet\Control\SystemInformation`,
`SYSTEM\HardwareConfig\Current`,
}
// readDeviceModel returns the first product name found in the Windows registry.
func readDeviceModel() string {
return readWindowsDeviceModel(readWindowsRegistryModel)
}
func readWindowsRegistryModel(path string) (string, error) {
key, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.READ)
if err != nil {
return "", err
}
defer key.Close()
model, _, err := key.GetStringValue("SystemProductName")
return model, err
}
func readWindowsDeviceModel(readRegistryModel func(string) (string, error)) string {
for _, path := range systemInfoRegistryPaths {
model, err := readRegistryModel(path)
if err != nil {
continue
}
if model = normalizeDeviceModel(model); model != "" {
return model
}
}
return ""
}

View File

@@ -0,0 +1,78 @@
//go:build windows
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"errors"
"reflect"
"testing"
)
func TestReadWindowsDeviceModelFallback(t *testing.T) {
readError := errors.New("registry read failed")
tests := []struct {
name string
values map[string]string
errors map[string]error
want string
wantPaths []string
}{
{
name: "first path wins",
values: map[string]string{systemInfoRegistryPaths[0]: "Surface Laptop"},
want: "Surface Laptop",
wantPaths: []string{systemInfoRegistryPaths[0]},
},
{
name: "read failure falls back",
errors: map[string]error{
systemInfoRegistryPaths[0]: readError,
},
values: map[string]string{
systemInfoRegistryPaths[1]: "ThinkPad X1 Carbon",
},
want: "ThinkPad X1 Carbon",
wantPaths: systemInfoRegistryPaths[:2],
},
{
name: "empty normalized value falls back",
values: map[string]string{
systemInfoRegistryPaths[0]: " \r\n\x00",
systemInfoRegistryPaths[1]: "Latitude 7450",
},
want: "Latitude 7450",
wantPaths: systemInfoRegistryPaths[:2],
},
{
name: "all paths fail",
errors: map[string]error{
systemInfoRegistryPaths[0]: readError,
systemInfoRegistryPaths[1]: readError,
systemInfoRegistryPaths[2]: readError,
},
wantPaths: systemInfoRegistryPaths[:],
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var paths []string
got := readWindowsDeviceModel(func(path string) (string, error) {
paths = append(paths, path)
if err := tt.errors[path]; err != nil {
return "", err
}
return tt.values[path], nil
})
if got != tt.want {
t.Fatalf("model = %q, want %q", got, tt.want)
}
if !reflect.DeepEqual(paths, tt.wantPaths) {
t.Fatalf("registry paths = %v, want %v", paths, tt.wantPaths)
}
})
}
}

View File

@@ -0,0 +1,138 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/internal/core"
internaltransport "github.com/larksuite/cli/internal/transport"
)
const (
HeaderProductModel = "X-Agent-Device-Type"
HeaderOSType = "X-Agent-Os-Type"
)
var restrictedHeaders = [...]string{HeaderProductModel, HeaderOSType}
// Transport is the feature's final outbound boundary. It removes caller- or
// extension-supplied signal headers first and writes trusted values only after
// authorizing an official SDK origin and authentication state.
type Transport struct {
next http.RoundTripper
source Source
}
// NewTransport creates the final SDK outbound policy boundary. A nil source
// disables collection and injection while preserving restricted-header
// stripping for opt-out and extension-credential requests.
func NewTransport(next http.RoundTripper, source Source) *Transport {
if next == nil {
next = internaltransport.Fallback()
}
return &Transport{
next: next,
source: source,
}
}
// RoundTrip implements http.RoundTripper.
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
req = req.Clone(req.Context())
if req.Header == nil {
req.Header = make(http.Header)
}
stripRestrictedHeaders(req.Header)
if t.source != nil && t.routeAllowsSignals(req) {
snapshot := t.source.Snapshot()
if isSupportedOSType(snapshot.OSType) {
req.Header.Set(HeaderOSType, string(snapshot.OSType))
}
if model := normalizeDeviceModel(snapshot.ProductModel); model != "" {
req.Header.Set(HeaderProductModel, model)
}
}
return t.next.RoundTrip(req)
}
func isSupportedOSType(value OSType) bool {
switch value {
case OSTypeWindows, OSTypeLinux, OSTypeMacOS:
return true
default:
return false
}
}
func stripRestrictedHeaders(header http.Header) {
for name := range header {
for _, restricted := range restrictedHeaders {
if strings.EqualFold(name, restricted) {
delete(header, name)
break
}
}
}
}
type origin struct {
scheme string
host string
port string
}
var officialFeishuOrigins = [...]origin{
apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Open),
apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Open),
apiOrigin(core.BrandFeishu, core.ResolveEndpoints(core.BrandFeishu).Accounts),
apiOrigin(core.BrandLark, core.ResolveEndpoints(core.BrandLark).Accounts),
}
func (t *Transport) routeAllowsSignals(req *http.Request) bool {
if req == nil || req.URL == nil {
return false
}
return isOfficialFeishuOrigin(originOf(req.URL))
}
func originOf(value *url.URL) origin {
if value == nil {
return origin{}
}
scheme := strings.ToLower(value.Scheme)
port := value.Port()
if port == "" {
switch scheme {
case "https":
port = "443"
case "http":
port = "80"
}
}
return origin{scheme: scheme, host: strings.ToLower(value.Hostname()), port: port}
}
func apiOrigin(brand core.LarkBrand, endpointURL string) origin {
endpoint, err := url.Parse(endpointURL)
if err != nil {
return origin{}
}
return originOf(endpoint)
}
func isOfficialFeishuOrigin(candidate origin) bool {
if candidate.scheme != "https" || candidate.port != "443" {
return false
}
for _, official := range officialFeishuOrigins {
if candidate == official {
return true
}
}
return false
}

View File

@@ -0,0 +1,124 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package riskcontrol
import (
"net/http"
"strings"
"sync/atomic"
"testing"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
type countingSource struct {
calls atomic.Int32
}
func (s *countingSource) Snapshot() Snapshot {
s.calls.Add(1)
return Snapshot{OSType: OSTypeMacOS, ProductModel: "Mac16,1"}
}
type staticSource Snapshot
func (s staticSource) Snapshot() Snapshot { return Snapshot(s) }
func TestTransportAuthorizesBeforeCollecting(t *testing.T) {
tests := []struct {
name string
requestURL string
authorization string
wantSignals bool
}{
{name: "authenticated official HTTPS", requestURL: "https://open.feishu.cn/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "Lark official HTTPS", requestURL: "https://open.larksuite.com/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "official explicit HTTPS port", requestURL: "https://OPEN.FEISHU.CN:443/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "unauthenticated", requestURL: "https://open.feishu.cn/open-apis/test", wantSignals: true},
{name: "official non-OpenAPI origin", requestURL: "https://accounts.feishu.cn/open-apis/test", authorization: "Bearer token", wantSignals: true},
{name: "off domain", requestURL: "https://example.com/test", authorization: "Bearer token", wantSignals: false},
{name: "lookalike", requestURL: "https://open.feishu.cn.evil.example/test", authorization: "Bearer token", wantSignals: false},
{name: "plain HTTP", requestURL: "http://open.feishu.cn/test", authorization: "Bearer token", wantSignals: false},
{name: "non-default port", requestURL: "https://open.feishu.cn:8443/test", authorization: "Bearer token", wantSignals: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
source := &countingSource{}
var received http.Header
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, test.requestURL, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", test.authorization)
req.Header.Set(HeaderOSType, "caller-value")
req.Header.Set(HeaderProductModel, "caller-value")
req.Header["x-agent-device-type"] = []string{"non-canonical-caller-value"}
resp, err := NewTransport(base, source).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
gotSignals := received.Get(HeaderOSType) != ""
if gotSignals != test.wantSignals {
t.Fatalf("signals present = %t, want %t; headers=%v", gotSignals, test.wantSignals, received)
}
wantCalls := int32(0)
if test.wantSignals {
wantCalls = 1
}
if got := source.calls.Load(); got != wantCalls {
t.Fatalf("Snapshot calls = %d, want %d", got, wantCalls)
}
if got := req.Header.Get(HeaderOSType); got != "caller-value" {
t.Fatalf("caller request OS header = %q, want unchanged", got)
}
if got := req.Header.Get(HeaderProductModel); got != "caller-value" {
t.Fatalf("caller request product-model header = %q, want unchanged", got)
}
if !test.wantSignals {
for name := range received {
if strings.EqualFold(name, HeaderProductModel) || strings.EqualFold(name, HeaderOSType) {
t.Fatalf("restricted header leaked as %q", name)
}
}
}
})
}
}
func TestTransportValidatesSourceSnapshot(t *testing.T) {
var received http.Header
base := roundTripFunc(func(req *http.Request) (*http.Response, error) {
received = req.Header.Clone()
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil
})
req, err := http.NewRequest(http.MethodGet, "https://open.feishu.cn/open-apis/test", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer token")
resp, err := NewTransport(base, staticSource{
OSType: OSType("unsupported"),
ProductModel: "unsafe\nvalue",
}).RoundTrip(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if received.Get(HeaderOSType) == "" && received.Get(HeaderProductModel) == "" {
t.Fatalf("no signals collected: %v", received)
}
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@larksuite/cli",
"version": "1.0.76",
"version": "1.0.79",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@larksuite/cli",
"version": "1.0.76",
"version": "1.0.79",
"cpu": [
"x64",
"arm64",

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.76",
"version": "1.0.79",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -176,7 +176,15 @@ if ! grep -Fq "if: always() && github.event.workflow_run.conclusion == 'success'
exit 1
fi
require_in_step "$summary_verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "PR quality summary must verify the triggering workflow path"
if grep -Fq 'run.name !== "CI"' "$workflow"; then
echo "semantic-review must not use the dynamic workflow run name as workflow identity" >&2
exit 1
fi
require_in_step "$summary_verify_step" 'github.rest.actions.getWorkflow' "PR quality summary must resolve static workflow metadata"
require_in_step "$summary_verify_step" 'workflow.name !== "CI"' "PR quality summary must verify the static workflow name"
require_in_step "$summary_verify_step" 'workflow.path !== ".github/workflows/ci.yml"' "PR quality summary must verify the static workflow path"
require_in_step "$summary_verify_step" 'run.path && run.path !== workflow.path' "PR quality summary must reject workflow path metadata mismatches"
require_in_step "$summary_verify_step" 'run.event !== "pull_request"' "PR quality summary must only handle pull_request workflow_run events"
require_in_step "$summary_verify_step" 'run.repository.id !== context.payload.repository.id' "PR quality summary must verify workflow_run repository id"
require_in_step "$summary_verify_step" 'const targetHeadSha = run.head_sha' "PR quality summary must use the CI run head SHA as the verified PR head"
@@ -201,7 +209,10 @@ require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_BASE_SHA' "PR qualit
require_in_step "$summary_publish_step" 'CI_QUALITY_SUMMARY_RUN_ID' "PR quality summary publisher must receive verified workflow run id"
require_in_step "$summary_publish_step" 'require("./scripts/ci-quality-summary-publish.js")' "PR quality summary publisher must use the shared CI publisher script"
require_in_step "$verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "semantic-review must verify the triggering workflow path"
require_in_step "$verify_step" 'github.rest.actions.getWorkflow' "semantic-review must resolve static workflow metadata"
require_in_step "$verify_step" 'workflow.name !== "CI"' "semantic-review must verify the static workflow name"
require_in_step "$verify_step" 'workflow.path !== ".github/workflows/ci.yml"' "semantic-review must verify the static workflow path"
require_in_step "$verify_step" 'run.path && run.path !== workflow.path' "semantic-review must reject workflow path metadata mismatches"
require_in_step "$verify_step" 'run.repository.id !== context.payload.repository.id' "semantic-review must verify workflow_run repository id"
require_in_step "$verify_step" 'run.event !== "pull_request"' "semantic-review must only handle pull_request workflow_run events"
require_in_step "$verify_step" 'run.conclusion !== "success"' "semantic-review must only consume successful CI runs"

View File

@@ -250,6 +250,8 @@ var CalendarAgenda = common.Shortcut{
}
}
collapseDescription(e)
filtered = append(filtered, e)
}
}

View File

@@ -20,7 +20,6 @@ import (
func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[string]interface{} {
eventData := map[string]interface{}{
"summary": runtime.Str("summary"),
"description": runtime.Str("description"),
"start_time": map[string]string{"timestamp": startTs},
"end_time": map[string]string{"timestamp": endTs},
"attendee_ability": "can_modify_event",
@@ -33,6 +32,9 @@ func buildEventData(runtime *common.RuntimeContext, startTs, endTs string) map[s
if rrule := runtime.Str("rrule"); rrule != "" {
eventData["recurrence"] = rrule
}
if description := descriptionToSend(runtime); description != "" {
eventData["description_rich"] = description
}
return eventData
}
@@ -118,7 +120,7 @@ var CalendarCreate = common.Shortcut{
{Name: "summary", Desc: "event title"},
{Name: "start", Desc: "start time (ISO 8601)", Required: true},
{Name: "end", Desc: "end time (ISO 8601)", Required: true},
{Name: "description", Desc: "event description"},
{Name: "description", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (`![name](url)`; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `![p](url)<br>**bold**`).", Input: []string{common.File, common.Stdin}},
{Name: "attendee-ids", Desc: "attendee IDs, comma-separated (supports user ou_, chat oc_, room omm_)"},
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
@@ -231,6 +233,9 @@ var CalendarCreate = common.Shortcut{
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--end: %v", err).WithParam("--end")
}
if err := resolveDescriptionImages(runtime, calendarId); err != nil {
return err
}
eventData := buildEventData(runtime, startTs, endTs)

View File

@@ -81,6 +81,7 @@ type calendarEvent struct {
OrganizerCalendarID string `json:"organizer_calendar_id,omitempty"`
Summary string `json:"summary,omitempty"`
Description string `json:"description,omitempty"`
DescriptionRich string `json:"description_rich,omitempty"`
StartTime *calendarEventTime `json:"start_time,omitempty"`
EndTime *calendarEventTime `json:"end_time,omitempty"`
VChat *calendarEventVChat `json:"vchat,omitempty"`
@@ -169,7 +170,7 @@ func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, err
if status, _ := out["status"].(string); status != "cancelled" {
delete(out, "status")
}
collapseDescription(out)
return out, nil
}

View File

@@ -988,9 +988,15 @@ func TestUpdate_PatchEventOnly(t *testing.T) {
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured patch body: %v", err)
}
if body["summary"] != "Updated Meeting" || body["description"] != "Updated description" {
// --description is the unified field, treated as rich text and sent as
// description_rich; the CLI never sends the plain description field
// (mutually exclusive downstream).
if body["summary"] != "Updated Meeting" || body["description_rich"] != "Updated description" {
t.Fatalf("unexpected patch body: %#v", body)
}
if _, ok := body["description"]; ok {
t.Fatalf("plain description must not be sent, got: %#v", body)
}
if body["need_notification"] != false {
t.Fatalf("need_notification = %#v, want false", body["need_notification"])
}
@@ -1364,6 +1370,62 @@ func TestAgenda_Success(t *testing.T) {
}
}
func TestAgenda_UnifiesDescriptionRich(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/events/instance_view",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"event_id": "evt_rich",
"summary": "Rich",
"status": "confirmed",
"description": "[测试]\n友情提醒",
"description_rich": "友情提醒",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
map[string]interface{}{
"event_id": "evt_plain",
"summary": "Plain",
"status": "confirmed",
"description": "just text",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
},
},
},
})
err := mountAndRun(t, CalendarAgenda, []string{
"+agenda",
"--start", "2025-03-21",
"--end", "2025-03-21",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// Read exposes a single unified description field: it carries the rich
// (Markdown) value when present, and the plain text otherwise. The internal
// description_rich key is never surfaced.
if !strings.Contains(out, "\"description\": \"友情提醒\"") {
t.Errorf("expected rich value surfaced under description, got: %s", out)
}
if !strings.Contains(out, "\"description\": \"just text\"") {
t.Errorf("expected plain description surfaced for plain-only event, got: %s", out)
}
if strings.Contains(out, "description_rich") {
t.Errorf("description_rich must not appear in output, got: %s", out)
}
}
func TestAgenda_EmptyResult(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
@@ -3375,6 +3437,72 @@ func TestGet_Success_FlattensAndConvertsTimes(t *testing.T) {
}
}
func TestGet_UnifiesDescriptionRich(t *testing.T) {
// Read exposes a single unified description field carrying the rich value
// when present, and the plain text otherwise; description_rich is dropped.
t.Run("rich present", func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_rich",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_rich",
"summary": "Rich",
"description": "[表格]",
"description_rich": "| a | b |\n| --- | --- |\n| c | d |",
"start_time": map[string]interface{}{"timestamp": "1742515200", "timezone": "Asia/Shanghai"},
"end_time": map[string]interface{}{"timestamp": "1742518800", "timezone": "Asia/Shanghai"},
},
},
},
})
if err := mountAndRun(t, CalendarGet, []string{"+get", "--calendar-id", "cal_test123", "--event-id", "evt_rich", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "| a | b |") {
t.Errorf("expected rich value surfaced under description, got: %s", out)
}
if strings.Contains(out, "description_rich") {
t.Errorf("description_rich must not appear in output, got: %s", out)
}
})
// When only a plain description exists, it is surfaced under description.
t.Run("only plain surfaces under description", func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_plain",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_plain",
"summary": "Plain",
"description": "just text",
"start_time": map[string]interface{}{"timestamp": "1742515200", "timezone": "Asia/Shanghai"},
"end_time": map[string]interface{}{"timestamp": "1742518800", "timezone": "Asia/Shanghai"},
},
},
},
})
if err := mountAndRun(t, CalendarGet, []string{"+get", "--calendar-id", "cal_test123", "--event-id", "evt_plain", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "\"description\": \"just text\"") {
t.Errorf("expected plain description surfaced, got: %s", out)
}
if strings.Contains(out, "description_rich") {
t.Errorf("description_rich must not appear in output, got: %s", out)
}
})
}
func TestGet_CancelledStatus_PreservesStatus(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())

View File

@@ -29,7 +29,7 @@ var CalendarUpdate = common.Shortcut{
{Name: "event-id", Desc: "event ID to update", Required: true},
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "summary", Desc: "event title"},
{Name: "description", Desc: "event description"},
{Name: "description", Desc: "event description as Markdown (@file or - for stdin); the unified description field. Supports bold/italic/underline/strikethrough, links, headings (`#`..`###`), blockquotes (`>`), ordered/unordered lists, horizontal rules (`---`), GFM tables, and images (`![name](url)`; a remote URL is used as-is, and a local image path relative to and inside the current working directory is auto-uploaded to Lark drive and rendered inline — absolute/out-of-cwd paths are rejected). A Lark doc URL (bare or as a Markdown link) is auto-resolved to an inline doc-mention chip showing its title. Inside a GFM table cell, stack multiple lines with `<br>`; each line may itself be an ordered/unordered list item, image or styled text (e.g. `1. a<br>2. b`, `- x<br>- y`, `![p](url)<br>**bold**`). Passing an empty string clears the description.", Input: []string{common.File, common.Stdin}},
{Name: "start", Desc: "new start time (ISO 8601); requires --end"},
{Name: "end", Desc: "new end time (ISO 8601); requires --start"},
{Name: "rrule", Desc: "recurrence rule (rfc5545)"},
@@ -109,11 +109,13 @@ func buildCalendarUpdateEventData(runtime *common.RuntimeContext) (map[string]in
body := map[string]interface{}{}
hasFields := false
for _, field := range []string{"summary", "description"} {
if runtime.Cmd.Flags().Changed(field) {
body[field] = runtime.Str(field)
hasFields = true
}
if runtime.Cmd.Flags().Changed("summary") {
body["summary"] = runtime.Str("summary")
hasFields = true
}
if runtime.Cmd.Flags().Changed("description") {
body["description_rich"] = runtime.Str("description")
hasFields = true
}
if runtime.Cmd.Flags().Changed("rrule") {
rrule := strings.TrimSpace(runtime.Str("rrule"))
@@ -356,6 +358,12 @@ func executeCalendarUpdate(ctx context.Context, runtime *common.RuntimeContext)
return errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --event-id").WithParam("--event-id")
}
if runtime.Cmd.Flags().Changed("description") {
if err := resolveDescriptionImages(runtime, calendarID); err != nil {
return err
}
}
body, hasEventFields, err := buildCalendarUpdateEventData(runtime)
if err != nil {
return err
@@ -428,8 +436,10 @@ func calendarUpdateResult(eventID string, event map[string]interface{}, addedCou
if summary, _ := event["summary"].(string); summary != "" {
result["summary"] = summary
}
if description, _ := event["description"].(string); description != "" {
result["description"] = description
if rich, _ := event["description_rich"].(string); rich != "" {
result["description"] = rich
} else if plain, _ := event["description"].(string); plain != "" {
result["description"] = plain
}
if start := formatCalendarEventTime(event["start_time"]); start != "" {
result["start"] = start

View File

@@ -0,0 +1,172 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"fmt"
"image"
// Register the common image decoders so DecodeConfig can read intrinsic
// dimensions for PNG/JPEG/GIF sources.
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"net/url"
"path/filepath"
"regexp"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const calendarMediaParentType = "calendar"
var markdownImageRe = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]*)\)`)
func resolveDescriptionImages(runtime *common.RuntimeContext, calendarID string) error {
md := runtime.Str("description")
if md == "" || !strings.Contains(md, "![") {
return nil
}
rewritten, changed, err := uploadLocalDescriptionImages(runtime, calendarID, md)
if err != nil {
return err
}
if changed {
if err := runtime.Cmd.Flags().Set("description", rewritten); err != nil {
return errs.NewInternalError(errs.SubtypeUnknown, "failed to update --description after image upload: %v", err).WithCause(err)
}
}
return nil
}
func uploadLocalDescriptionImages(runtime *common.RuntimeContext, calendarID, md string) (string, bool, error) {
matches := markdownImageRe.FindAllStringSubmatchIndex(md, -1)
if len(matches) == 0 {
return md, false, nil
}
var out strings.Builder
last := 0
changed := false
cache := map[string]string{}
for _, m := range matches {
altStart, altEnd, srcStart, srcEnd := m[2], m[3], m[4], m[5]
src := strings.TrimSpace(md[srcStart:srcEnd])
if !isLocalImageSrc(src) {
continue
}
alt := md[altStart:altEnd]
uploadedURL, err := resolveLocalImage(runtime, calendarID, src, alt, cache)
if err != nil {
return "", false, err
}
out.WriteString(md[last:srcStart])
out.WriteString(uploadedURL)
last = srcEnd
changed = true
}
if !changed {
return md, false, nil
}
out.WriteString(md[last:])
return out.String(), true, nil
}
func resolveLocalImage(runtime *common.RuntimeContext, calendarID, src, alt string, cache map[string]string) (string, error) {
localPath := localImagePath(src)
if cached, ok := cache[localPath]; ok {
return cached, nil
}
safePath, err := validate.SafeInputPath(localPath)
if err != nil {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
"--description image %q could not be read: %v", src, err).
WithParam("--description").
WithHint("reference local images by a path inside the current working directory (e.g. ./images/pic.png; cd there first), or use an already-uploaded Lark image URL").
WithCause(err)
}
info, err := runtime.FileIO().Stat(localPath)
if err != nil {
return "", common.WrapInputStatErrorTyped(err)
}
fileToken, err := common.UploadDriveMediaAllTyped(runtime, common.DriveMediaUploadAllConfig{
FilePath: localPath,
FileName: filepath.Base(safePath),
FileSize: info.Size(),
ParentType: calendarMediaParentType,
ParentNode: &calendarID,
})
if err != nil {
return "", err
}
width, height := decodeImageDimensions(runtime, localPath)
uploadedURL := buildCalendarImagePreviewURL(runtime.Config.Brand, fileToken, width, height, info.Size())
cache[localPath] = uploadedURL
return uploadedURL, nil
}
func decodeImageDimensions(runtime *common.RuntimeContext, path string) (int, int) {
f, err := runtime.FileIO().Open(path)
if err != nil {
return 0, 0
}
defer f.Close()
cfg, _, err := image.DecodeConfig(f)
if err != nil {
return 0, 0
}
return cfg.Width, cfg.Height
}
func isLocalImageSrc(src string) bool {
if src == "" {
return false
}
lower := strings.ToLower(src)
switch {
case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"), strings.HasPrefix(lower, "data:"):
return false
case strings.HasPrefix(lower, "file://"):
return true
}
if i := strings.Index(src, "://"); i > 0 {
return false
}
return true
}
func localImagePath(src string) string {
s := strings.TrimSpace(src)
if strings.HasPrefix(strings.ToLower(s), "file://") {
if u, err := url.Parse(s); err == nil && u.Path != "" {
s = u.Path
}
}
if decoded, err := url.PathUnescape(s); err == nil {
return decoded
}
return s
}
func buildCalendarImagePreviewURL(brand core.LarkBrand, fileToken string, width, height int, size int64) string {
host := "internal-api-drive-stream.feishu.cn"
if brand == core.BrandLark {
host = "internal-api-drive-stream.larksuite.com"
}
u := fmt.Sprintf("https://%s/space/api/box/stream/download/preview/%s?preview_type=16", host, fileToken)
if width > 0 && height > 0 {
u += fmt.Sprintf("&im_w=%d&im_h=%d", width, height)
}
if size > 0 {
u += fmt.Sprintf("&im_size=%d", size)
}
return u
}

View File

@@ -0,0 +1,279 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"bytes"
"encoding/json"
"errors"
"image"
"image/png"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
)
func TestIsLocalImageSrc(t *testing.T) {
cases := []struct {
src string
want bool
}{
{"./images/pic.png", true},
{"images/pic.png", true},
{"../assets/a.png", true},
{"/Users/me/Desktop/a.png", true},
{`C:\Users\me\a.png`, true},
{"file:///Users/me/a.png", true},
{"图片和附件/测试图片.png", true},
{"https://example.com/a.png", false},
{"http://example.com/a.png", false},
{"HTTPS://EXAMPLE.com/a.png", false},
{"data:image/png;base64,iVBOR", false},
{"ftp://host/a.png", false},
{"", false},
}
for _, c := range cases {
if got := isLocalImageSrc(c.src); got != c.want {
t.Errorf("isLocalImageSrc(%q) = %v, want %v", c.src, got, c.want)
}
}
}
func TestLocalImagePath(t *testing.T) {
cases := []struct{ in, want string }{
{"images/pic.png", "images/pic.png"},
{"images/my%20pic.png", "images/my pic.png"},
{"file:///Users/me/a.png", "/Users/me/a.png"},
}
for _, c := range cases {
if got := localImagePath(c.in); got != c.want {
t.Errorf("localImagePath(%q) = %q, want %q", c.in, got, c.want)
}
}
}
// TestBuildCalendarImagePreviewURL guards the contract the OpenAPI service
// relies on: a Lark host (so token extraction triggers) whose final path
// segment is exactly the uploaded file token.
func TestBuildCalendarImagePreviewURL(t *testing.T) {
for _, tc := range []struct {
brand core.LarkBrand
hostFrag string
}{
{core.BrandFeishu, "feishu.cn"},
{core.BrandLark, "larksuite"},
} {
raw := buildCalendarImagePreviewURL(tc.brand, "boxcnTOKEN123", 416, 306, 142568)
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("built URL not parseable: %v", err)
}
if !strings.Contains(u.Host, tc.hostFrag) {
t.Errorf("brand %s host = %q, want fragment %q", tc.brand, u.Host, tc.hostFrag)
}
segs := strings.Split(strings.Trim(u.Path, "/"), "/")
if last := segs[len(segs)-1]; last != "boxcnTOKEN123" {
t.Errorf("last path segment = %q, want token", last)
}
q := u.Query()
if q.Get("im_w") != "416" || q.Get("im_h") != "306" || q.Get("im_size") != "142568" {
t.Errorf("dimension params missing: im_w=%q im_h=%q im_size=%q", q.Get("im_w"), q.Get("im_h"), q.Get("im_size"))
}
}
// With unknown dimensions the helper params are omitted entirely.
raw := buildCalendarImagePreviewURL(core.BrandFeishu, "boxcnTOKEN123", 0, 0, 0)
if strings.Contains(raw, "im_w") || strings.Contains(raw, "im_size") {
t.Errorf("expected no dimension params for unknown size, got %q", raw)
}
}
// TestUploadLocalDescriptionImages_RemoteUntouched verifies remote/data images
// pass through unchanged and never trigger an upload (runtime unused → nil).
func TestUploadLocalDescriptionImages_RemoteUntouched(t *testing.T) {
md := "text ![a](https://example.com/a.png) more ![b](data:image/png;base64,xx)"
got, changed, err := uploadLocalDescriptionImages(nil, "cal", md)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if changed {
t.Errorf("changed = true, want false")
}
if got != md {
t.Errorf("markdown mutated: %q", got)
}
}
// TestCreate_UploadsLocalDescriptionImage runs +create with a local image path,
// mocks the drive upload, and asserts the create body's description_rich carries
// the uploaded token (not the local path).
func TestCreate_UploadsLocalDescriptionImage(t *testing.T) {
dir := t.TempDir()
orig, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
defer os.Chdir(orig)
if err := os.WriteFile(filepath.Join(dir, "pic.png"), []byte("PNGDATA"), 0600); err != nil {
t.Fatal(err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"file_token": "boxcnTOKEN123"}},
}
reg.Register(uploadStub)
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_001",
"summary": "Pic",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
}},
}
reg.Register(createStub)
runErr := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Pic",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--description", "![pic](./pic.png)",
"--as", "bot",
}, f, stdout)
if runErr != nil {
t.Fatalf("unexpected error: %v", runErr)
}
if uploadStub.CapturedBody == nil {
t.Fatalf("expected drive upload to be called")
}
if createStub.CapturedBody == nil {
t.Fatalf("expected create event to be called")
}
var body map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &body); err != nil {
t.Fatalf("create body unmarshal: %v", err)
}
dr, _ := body["description_rich"].(string)
if !strings.Contains(dr, "boxcnTOKEN123") {
t.Fatalf("description_rich should contain uploaded token, got %q", dr)
}
if strings.Contains(dr, "./pic.png") {
t.Fatalf("local path should be rewritten away, got %q", dr)
}
}
// TestCreate_LocalImageCarriesDimensions verifies a real decodable image's
// intrinsic width/height and byte size are appended to the rewritten drive URL
// (so the facade can populate originalWidth/originalHeight and the client can
// render the image inline).
func TestCreate_LocalImageCarriesDimensions(t *testing.T) {
dir := t.TempDir()
orig, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
defer os.Chdir(orig)
var buf bytes.Buffer
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 5, 7))); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "pic.png"), buf.Bytes(), 0600); err != nil {
t.Fatal(err)
}
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_all",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"file_token": "boxcnTOKEN123"}},
})
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_001",
"summary": "Pic",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
}},
}
reg.Register(createStub)
runErr := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Pic",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--description", "![pic](./pic.png)",
"--as", "bot",
}, f, stdout)
if runErr != nil {
t.Fatalf("unexpected error: %v", runErr)
}
var body map[string]interface{}
if err := json.Unmarshal(createStub.CapturedBody, &body); err != nil {
t.Fatalf("create body unmarshal: %v", err)
}
dr, _ := body["description_rich"].(string)
if !strings.Contains(dr, "im_w=5") || !strings.Contains(dr, "im_h=7") {
t.Fatalf("description_rich should carry image dimensions, got %q", dr)
}
if !strings.Contains(dr, "im_size=") {
t.Fatalf("description_rich should carry image byte size, got %q", dr)
}
}
// TestCreate_LocalImageAbsolutePathRejected verifies an out-of-cwd absolute path
// yields a typed --description validation error before any API call.
func TestCreate_LocalImageAbsolutePathRejected(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
runErr := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Pic",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--description", "![p](/etc/hosts)",
"--as", "bot",
}, f, stdout)
if runErr == nil {
t.Fatalf("expected error for absolute image path")
}
var ve *errs.ValidationError
if !errors.As(runErr, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", runErr, runErr)
}
if ve.Param != "--description" {
t.Errorf("param = %q, want --description", ve.Param)
}
}

View File

@@ -30,6 +30,26 @@ func resolveStartEnd(runtime *common.RuntimeContext) (string, string) {
return startInput, endInput
}
func collapseDescription(event map[string]interface{}) {
if event == nil {
return
}
rich, _ := event["description_rich"].(string)
plain, _ := event["description"].(string)
delete(event, "description_rich")
switch {
case rich != "":
event["description"] = rich
case plain != "":
event["description"] = plain
default:
delete(event, "description")
}
}
func descriptionToSend(runtime *common.RuntimeContext) string {
return runtime.Str("description")
}
func hasExplicitBotFlag(cmd *cobra.Command) bool {
if cmd == nil {
return false

View File

@@ -17,9 +17,10 @@ import (
)
// Drive media parent_type values for uploading an image into a spreadsheet.
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
// synthetic token prefixed with "fake_office_" (being renamed to
// "local_office_") and the backend requires "office_sheet_file" instead.
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets use a
// legacy synthetic-token prefix or a 28-character token whose interleaved
// product/region marker is "OFL0X". The backend requires
// "office_sheet_file" for those imported spreadsheets.
const (
sheetImageParentType = "sheet_image"
officeSheetFileParentType = "office_sheet_file"
@@ -27,22 +28,37 @@ const (
localOfficePrefix = "local_office_"
)
// officePrefixes are the synthetic token prefixes an imported "office"
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
// "local_office_"; accept either so image uploads keep working across the
// rename.
// officePrefixes are the legacy synthetic token prefixes an imported "office"
// spreadsheet may carry.
var officePrefixes = []string{fakeOfficePrefix, localOfficePrefix}
// sheetMediaParentType returns the drive media parent_type to use when
// uploading an image whose parent_node is spreadsheetToken, mapping either the
// "fake_office_" or "local_office_" imported-spreadsheet token prefix to
// "office_sheet_file".
func sheetMediaParentType(spreadsheetToken string) string {
func isOfficeSpreadsheet(spreadsheetToken string) bool {
for _, prefix := range officePrefixes {
if strings.HasPrefix(spreadsheetToken, prefix) {
return officeSheetFileParentType
return true
}
}
if len(spreadsheetToken) != 28 {
return false
}
// The five-character marker occupies positions 5, 10, 15, 20, and 25
// (1-based) in the interleaved token.
marker := []byte{
spreadsheetToken[4],
spreadsheetToken[9],
spreadsheetToken[14],
spreadsheetToken[19],
spreadsheetToken[24],
}
return string(marker) == "OFL0X"
}
// sheetMediaParentType returns the drive media parent_type to use when
// uploading an image whose parent_node is spreadsheetToken.
func sheetMediaParentType(spreadsheetToken string) string {
if isOfficeSpreadsheet(spreadsheetToken) {
return officeSheetFileParentType
}
return sheetImageParentType
}

View File

@@ -105,7 +105,7 @@ func TestSheetMediaUploadDryRunSmallFileOfficeParentType(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
"--spreadsheet-token", "fake_office_abc123",
"--spreadsheet-token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
"--file", "img.png",
"--dry-run", "--as", "user",
}, f, stdout)
@@ -117,10 +117,10 @@ func TestSheetMediaUploadDryRunSmallFileOfficeParentType(t *testing.T) {
t.Fatalf("dry-run should use upload_all for small file, got: %s", out)
}
if !strings.Contains(out, `"office_sheet_file"`) {
t.Fatalf("dry-run should include parent_type=office_sheet_file for fake_office_ token, got: %s", out)
t.Fatalf("dry-run should include parent_type=office_sheet_file for interleaved OFL0X token, got: %s", out)
}
if strings.Contains(out, `"sheet_image"`) {
t.Fatalf("dry-run must not emit sheet_image for fake_office_ token, got: %s", out)
t.Fatalf("dry-run must not emit sheet_image for interleaved OFL0X token, got: %s", out)
}
}
@@ -239,7 +239,7 @@ func TestSheetMediaUploadExecuteSuccess(t *testing.T) {
}
// TestSheetMediaUploadExecuteOfficeParentType confirms that an imported
// "office" spreadsheet (token prefixed with "fake_office_") uploads with
// "office" spreadsheet (token carrying the interleaved "OFL0X" marker) uploads with
// parent_type=office_sheet_file instead of the native sheet_image.
func TestSheetMediaUploadExecuteOfficeParentType(t *testing.T) {
dir := t.TempDir()
@@ -259,7 +259,7 @@ func TestSheetMediaUploadExecuteOfficeParentType(t *testing.T) {
}
reg.Register(stub)
const officeToken = "fake_office_abc123"
const officeToken = "aaaaOaaaaFaaaaLaaaa0aaaaXaaa"
err := mountAndRunSheets(t, SheetMediaUpload, []string{
"+media-upload",
"--spreadsheet-token", officeToken,

View File

@@ -53,9 +53,10 @@ func sheetsInputStatError(flag string, err error) error {
}
// Drive media parent_type values for uploading an image into a spreadsheet.
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
// synthetic token prefixed with "fake_office_" (being renamed to
// "local_office_") and the backend requires "office_sheet_file" instead.
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets use a
// legacy synthetic-token prefix or a 28-character token whose interleaved
// product/region marker is "OFL0X". The backend requires
// "office_sheet_file" for those imported spreadsheets.
const (
sheetImageParentType = "sheet_image"
officeSheetFileParentType = "office_sheet_file"
@@ -63,21 +64,38 @@ const (
localOfficePrefix = "local_office_"
)
// officePrefixes are the synthetic token prefixes an imported "office"
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
// "local_office_"; accept either so image uploads keep working across the
// rename.
// officePrefixes are the legacy synthetic token prefixes an imported "office"
// spreadsheet may carry.
var officePrefixes = []string{fakeOfficePrefix, localOfficePrefix}
func isOfficeSpreadsheet(spreadsheetToken string) bool {
for _, prefix := range officePrefixes {
if strings.HasPrefix(spreadsheetToken, prefix) {
return true
}
}
if len(spreadsheetToken) != 28 {
return false
}
// The five-character marker occupies positions 5, 10, 15, 20, and 25
// (1-based) in the interleaved token.
marker := []byte{
spreadsheetToken[4],
spreadsheetToken[9],
spreadsheetToken[14],
spreadsheetToken[19],
spreadsheetToken[24],
}
return string(marker) == "OFL0X"
}
// sheetMediaParentType returns the drive media parent_type to use when
// uploading an image whose parent_node is spreadsheetToken. It is the single
// place that maps a spreadsheet token to its parent_type so every image-upload
// entry point (and its dry-run preview) stays consistent.
func sheetMediaParentType(spreadsheetToken string) string {
for _, prefix := range officePrefixes {
if strings.HasPrefix(spreadsheetToken, prefix) {
return officeSheetFileParentType
}
if isOfficeSpreadsheet(spreadsheetToken) {
return officeSheetFileParentType
}
return sheetImageParentType
}

View File

@@ -25,8 +25,9 @@ import (
// TestSheetMediaParentType pins the token→parent_type mapping that every
// sheets image-upload entry point funnels through. Native spreadsheet tokens
// use "sheet_image"; imported "office" spreadsheets carry a "fake_office_" or
// "local_office_" synthetic token and must upload with "office_sheet_file".
// use "sheet_image"; imported "office" spreadsheets use either a legacy
// prefix or the interleaved "OFL0X" marker and must upload with
// "office_sheet_file".
func TestSheetMediaParentType(t *testing.T) {
t.Parallel()
cases := []struct {
@@ -40,6 +41,13 @@ func TestSheetMediaParentType(t *testing.T) {
{"fake_office token, only the prefix", fakeOfficePrefix, officeSheetFileParentType},
{"local_office imported token", "local_office_abc123", officeSheetFileParentType},
{"local_office token, only the prefix", localOfficePrefix, officeSheetFileParentType},
{"interleaved OFL0X office token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa", officeSheetFileParentType},
{"interleaved exlcn token", "abcdeefghxijkllmnopcqrstnuv", sheetImageParentType},
{"interleaved shtcn native token", "abcdsefghhijkltmnopcqrstnuv", sheetImageParentType},
{"interleaved pptcn token", "abcdpefghpijkltmnopcqrstnuv", sheetImageParentType},
{"interleaved wodcn token", "abcdwefghoijkldmnopcqrstnuv", sheetImageParentType},
{"interleaved OFL0X marker with short length", "aaaaOaaaaFaaaaLaaaa0aaaaXaa", sheetImageParentType},
{"interleaved OFL0X marker with long length", "aaaaOaaaaFaaaaLaaaa0aaaaXaaaa", sheetImageParentType},
{"fake_office prefix mid-string is not matched", "shtfake_office_abc", sheetImageParentType},
{"local_office prefix mid-string is not matched", "shtlocal_office_abc", sheetImageParentType},
}
@@ -57,7 +65,7 @@ func TestSheetMediaParentType(t *testing.T) {
// to end (the Execute path the dry-run tests don't reach), asserting the
// parent_type that actually goes out on the wire is derived from the token: a
// native spreadsheet uploads as sheet_image, an imported "office" spreadsheet
// (fake_office_-prefixed token) as office_sheet_file.
// (legacy prefix or interleaved OFL0X marker) as office_sheet_file.
func TestUploadSheetImage_ParentType(t *testing.T) {
cases := []struct {
name string
@@ -67,6 +75,7 @@ func TestUploadSheetImage_ParentType(t *testing.T) {
{"native spreadsheet", "shtcnTOK123", sheetImageParentType},
{"fake_office imported spreadsheet", "fake_office_abc123", officeSheetFileParentType},
{"local_office imported spreadsheet", "local_office_abc123", officeSheetFileParentType},
{"interleaved OFL0X imported spreadsheet", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa", officeSheetFileParentType},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {

View File

@@ -37,15 +37,13 @@ var SlidesScreenshot = common.Shortcut{
Command: "+screenshot",
Description: "Save up to 10 slide screenshots to local files without printing Base64 image data",
Risk: "read",
Scopes: []string{},
// The screenshot API is allowlist-gated for only a few apps, so do not
// advertise/preflight its scope. Let the API fail and let callers degrade.
Scopes: []string{"slides:presentation:screenshot"},
// wiki:node:read is required only when --presentation is a wiki URL.
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides; list mode only"},
{Name: "slide-id", Type: "string_array", Desc: "slide page identifier (repeat for multiple slides; max 10 pages per request)"},
{Name: "slide-id", Type: "string_slice", Desc: "slide page identifier (repeat or comma-separated for multiple slides; max 10 pages per request)"},
{Name: "slide-number", Type: "int_array", Desc: "slide page number (repeat for multiple slides; max 10 pages per request)"},
{Name: "content", Desc: "slide XML content to render directly instead of fetching existing slides", Input: []string{common.File, common.Stdin}},
{Name: "output-dir", Default: defaultSlidesScreenshotDir, Desc: "relative directory for saved screenshots"},
@@ -57,7 +55,7 @@ var SlidesScreenshot = common.Shortcut{
if strings.TrimSpace(runtime.Str("content")) == "" {
return slidesScreenshotFlagErrorf("--content cannot be empty")
}
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
if len(normalizeSlideIDs(runtime.StrSlice("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number")
}
if runtime.Changed("presentation") {
@@ -73,7 +71,7 @@ var SlidesScreenshot = common.Shortcut{
return err
}
}
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
slideIDs := normalizeSlideIDs(runtime.StrSlice("slide-id"))
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
if err != nil {
return err
@@ -98,7 +96,7 @@ var SlidesScreenshot = common.Shortcut{
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
slideIDs := normalizeSlideIDs(runtime.StrSlice("slide-id"))
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
@@ -148,7 +146,7 @@ var SlidesScreenshot = common.Shortcut{
return err
}
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
slideIDs := normalizeSlideIDs(runtime.StrSlice("slide-id"))
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
if err != nil {
return err
@@ -200,7 +198,7 @@ func dryRunRenderScreenshot(runtime *common.RuntimeContext) *common.DryRunAPI {
if strings.TrimSpace(content) == "" {
return common.NewDryRunAPI().Set("error", "--content cannot be empty")
}
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
if len(normalizeSlideIDs(runtime.StrSlice("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
return common.NewDryRunAPI().Set("error", "--content cannot be used with --slide-id or --slide-number")
}
if runtime.Changed("presentation") {
@@ -219,7 +217,7 @@ func executeRenderScreenshot(runtime *common.RuntimeContext) error {
if strings.TrimSpace(content) == "" {
return slidesScreenshotFlagErrorf("--content cannot be empty")
}
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
if len(normalizeSlideIDs(runtime.StrSlice("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number")
}
if runtime.Changed("presentation") {

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
@@ -17,23 +18,19 @@ import (
)
func TestSlidesScreenshotDeclaredScopes(t *testing.T) {
if got := SlidesScreenshot.ScopesForIdentity("user"); len(got) != 0 {
t.Fatalf("user preflight scopes = %#v, want empty", got)
base := []string{"slides:presentation:screenshot"}
if got := SlidesScreenshot.ScopesForIdentity("user"); !reflect.DeepEqual(got, base) {
t.Fatalf("user preflight scopes = %#v, want %#v", got, base)
}
if got := SlidesScreenshot.ScopesForIdentity("bot"); len(got) != 0 {
t.Fatalf("bot preflight scopes = %#v, want empty", got)
if got := SlidesScreenshot.ScopesForIdentity("bot"); !reflect.DeepEqual(got, base) {
t.Fatalf("bot preflight scopes = %#v, want %#v", got, base)
}
got := SlidesScreenshot.DeclaredScopesForIdentity("user")
want := []string{"wiki:node:read"}
if len(got) != len(want) || got[0] != want[0] {
want := []string{"slides:presentation:screenshot", "wiki:node:read"}
if !reflect.DeepEqual(got, want) {
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) {
@@ -188,6 +185,139 @@ func TestSlidesScreenshotListBySlideNumber(t *testing.T) {
}
}
func TestSlidesScreenshotListBySlideIDCSV(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"slide_images": []map[string]interface{}{
{
"slide_id": "slide_1",
"format": 1,
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes-1")),
},
{
"slide_id": "slide_2",
"format": 1,
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes-2")),
},
},
},
},
}
reg.Register(stub)
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-id", "slide_1,slide_2",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body struct {
SlideIDs []string `json:"slide_ids"`
}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode request body: %v", err)
}
if len(body.SlideIDs) != 2 || body.SlideIDs[0] != "slide_1" || body.SlideIDs[1] != "slide_2" {
t.Fatalf("slide_ids = %#v, want [slide_1 slide_2]", body.SlideIDs)
}
path1 := filepath.Join(dir, defaultSlidesScreenshotDir, "pres_abc_slide_1.png")
if _, err := os.ReadFile(path1); err != nil {
t.Fatalf("read first CSV slide screenshot: %v", err)
}
path2 := filepath.Join(dir, defaultSlidesScreenshotDir, "pres_abc_slide_2.png")
if _, err := os.ReadFile(path2); err != nil {
t.Fatalf("read second CSV slide screenshot: %v", err)
}
}
func TestSlidesScreenshotListBySlideIDCSVDeduplicatesAndTrims(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"slide_images": []map[string]interface{}{
{
"slide_id": "slide_1",
"format": 1,
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes-1")),
},
{
"slide_id": "slide_2",
"format": 1,
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes-2")),
},
},
},
},
}
reg.Register(stub)
// CSV with a duplicate and blank segments should normalize the same way
// normalizeSlideIDs already does for repeated --slide-id flags.
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-id", "slide_1, slide_2,slide_1,",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body struct {
SlideIDs []string `json:"slide_ids"`
}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode request body: %v", err)
}
if len(body.SlideIDs) != 2 || body.SlideIDs[0] != "slide_1" || body.SlideIDs[1] != "slide_2" {
t.Fatalf("slide_ids = %#v, want deduplicated [slide_1 slide_2]", body.SlideIDs)
}
}
func TestSlidesScreenshotListRejectsMoreThanTenSlideIDsCSV(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--presentation", "pres_abc",
"--slide-id", "s1,s2,s3,s4,s5,s6,s7,s8,s9,s10,s11",
"--as", "user",
})
if err == nil {
t.Fatal("expected error")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %v, want typed validation error", err)
}
if problem.Hint != "request at most 10 pages at a time" {
t.Fatalf("hint = %q, want max 10 pages guidance", problem.Hint)
}
}
func TestSlidesScreenshotAvoidsOverwritingExistingFile(t *testing.T) {
dir := t.TempDir()
withSlidesTestWorkingDir(t, dir)
@@ -390,6 +520,27 @@ func TestSlidesScreenshotRenderRejectsSlideSelectors(t *testing.T) {
}
}
func TestSlidesScreenshotRenderRejectsSlideNumberSelector(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
// Exercises the --slide-number-only side of the --content conflict check
// (TestSlidesScreenshotRenderRejectsSlideSelectors above only covers the
// --slide-id side of that same `||` condition).
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
"+screenshot",
"--content", `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
"--slide-number", "1",
"--as", "user",
})
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "--content cannot be used with --slide-id or --slide-number") {
t.Fatalf("error = %v, want content/slide selector conflict", err)
}
}
func TestSlidesScreenshotRenderRejectsListOnlyFlags(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))

View File

@@ -24,6 +24,14 @@ func splitAndTrimCSV(input string) []string {
return out
}
func buildSearchPageParams(pageToken string) map[string]interface{} {
params := map[string]interface{}{}
if pageToken != "" {
params["page_token"] = pageToken
}
return params
}
func parseTimeRangeMillis(input string) (string, string, error) {
if strings.TrimSpace(input) == "" {
return "", "", nil

View File

@@ -37,6 +37,31 @@ func TestSplitAndTrimCSV(t *testing.T) {
}
}
func TestBuildSearchPageParams(t *testing.T) {
tests := []struct {
name string
pageToken string
wantToken string
wantKey bool
}{
{name: "first page omits token"},
{name: "subsequent page includes token", pageToken: "pt_123", wantToken: "pt_123", wantKey: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
params := buildSearchPageParams(tt.pageToken)
got, present := params["page_token"]
if present != tt.wantKey {
t.Fatalf("page_token present = %v, want %v; params = %#v", present, tt.wantKey, params)
}
if tt.wantKey && got != tt.wantToken {
t.Fatalf("page_token = %v, want %q", got, tt.wantToken)
}
})
}
}
func TestOutputTaskSummary(t *testing.T) {
tests := []struct {
name string

View File

@@ -44,8 +44,10 @@ var SearchTask = common.Shortcut{
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
params := buildSearchPageParams(runtime.Str("page-token"))
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasks/search").
Params(params).
Body(body).
Desc("Then GET /open-apis/task/v2/tasks/:guid for each search hit to render standard output")
},
@@ -74,9 +76,9 @@ var SearchTask = common.Shortcut{
var lastPageToken string
var lastHasMore bool
var notice string
currentBody := body
params := buildSearchPageParams(runtime.Str("page-token"))
for page := 0; page < pageLimit; page++ {
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/search", nil, currentBody)
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/search", params, body)
if err != nil {
return err
}
@@ -90,7 +92,7 @@ var SearchTask = common.Shortcut{
if !lastHasMore || lastPageToken == "" {
break
}
currentBody["page_token"] = lastPageToken
params["page_token"] = lastPageToken
}
enriched := make([]map[string]interface{}, 0, len(rawItems))
@@ -183,9 +185,6 @@ func buildTaskSearchBody(runtime *common.RuntimeContext) (map[string]interface{}
if len(filter) > 0 {
body["filter"] = filter
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
body["page_token"] = pageToken
}
return body, nil
}

View File

@@ -0,0 +1,129 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"encoding/json"
"io"
"net/http"
"reflect"
"testing"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestSearchPaginationUsesQueryToken(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
command string
url string
}{
{
name: "tasks",
shortcut: SearchTask,
command: "+search",
url: "/open-apis/task/v2/tasks/search",
},
{
name: "tasklists",
shortcut: SearchTasklist,
command: "+tasklist-search",
url: "/open-apis/task/v2/tasklists/search",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
var pageTokens []string
reg.Register(searchPaginationStub(t, tt.url, "next_pt", true, &pageTokens))
reg.Register(searchPaginationStub(t, tt.url, "", false, &pageTokens))
shortcut := tt.shortcut
shortcut.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, shortcut, []string{
tt.command,
"--query", "pagination",
"--page-token", "initial_pt",
"--page-limit", "2",
"--as", "bot",
"--format", "json",
}, f, stdout)
if err != nil {
t.Fatalf("search command failed: %v", err)
}
want := []string{"initial_pt", "next_pt"}
if !reflect.DeepEqual(pageTokens, want) {
t.Fatalf("search page tokens = %#v, want %#v", pageTokens, want)
}
})
}
}
func assertSearchDryRunPageToken(t *testing.T, preview *common.DryRunAPI, want string) {
t.Helper()
data, err := preview.MarshalJSON()
if err != nil {
t.Fatalf("marshal search dry-run preview: %v", err)
}
var envelope struct {
API []struct {
Params map[string]interface{} `json:"params"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &envelope); err != nil {
t.Fatalf("decode search dry-run preview: %v", err)
}
if len(envelope.API) != 1 {
t.Fatalf("search dry-run API call count = %d, want 1; preview = %s", len(envelope.API), data)
}
call := envelope.API[0]
if got, _ := call.Params["page_token"].(string); got != want {
t.Fatalf("search dry-run params.page_token = %q, want %q; preview = %s", got, want, data)
}
if _, present := call.Body["page_token"]; present {
t.Fatalf("search dry-run body unexpectedly contains page_token; preview = %s", data)
}
}
func searchPaginationStub(t *testing.T, endpoint, responseToken string, hasMore bool, capturedTokens *[]string) *httpmock.Stub {
t.Helper()
return &httpmock.Stub{
Method: http.MethodPost,
URL: endpoint,
OnMatch: func(req *http.Request) {
*capturedTokens = append(*capturedTokens, req.URL.Query().Get("page_token"))
body, err := io.ReadAll(req.Body)
if err != nil {
t.Errorf("read search request body: %v", err)
return
}
var payload map[string]interface{}
if err := json.Unmarshal(body, &payload); err != nil {
t.Errorf("decode search request body: %v", err)
return
}
if _, present := payload["page_token"]; present {
t.Errorf("search request body unexpectedly contains page_token: %s", body)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": hasMore,
"page_token": responseToken,
"items": []interface{}{},
},
},
}
}

View File

@@ -37,9 +37,12 @@ func TestBuildTaskSearchBody(t *testing.T) {
check: func(t *testing.T, body map[string]interface{}) {
filter := body["filter"].(map[string]interface{})
dueTime := filter["due_time"].(map[string]interface{})
if body["query"] != "release" || body["page_token"] != "pt_123" {
if body["query"] != "release" {
t.Fatalf("unexpected body: %#v", body)
}
if _, present := body["page_token"]; present {
t.Fatalf("body unexpectedly contains page_token: %#v", body)
}
if len(filter["creator_ids"].([]string)) != 2 || filter["is_completed"] != true {
t.Fatalf("unexpected filter: %#v", filter)
}
@@ -104,9 +107,10 @@ func TestBuildTaskSearchBody(t *testing.T) {
func TestSearchTask_DryRun(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantParts []string
name string
setup func(*cobra.Command)
wantPageToken string
wantParts []string
}{
{
name: "valid dry run",
@@ -114,7 +118,8 @@ func TestSearchTask_DryRun(t *testing.T) {
_ = cmd.Flags().Set("query", "demo")
_ = cmd.Flags().Set("page-token", "pt_demo")
},
wantParts: []string{"POST /open-apis/task/v2/tasks/search", `"query":"demo"`},
wantPageToken: "pt_demo",
wantParts: []string{`"query":"demo"`},
},
{
name: "dry run error on invalid due",
@@ -143,7 +148,11 @@ func TestSearchTask_DryRun(t *testing.T) {
t.Fatalf("Validate() error = %v", err)
}
}
out := SearchTask.DryRun(nil, runtime).Format()
preview := SearchTask.DryRun(nil, runtime)
if tt.wantPageToken != "" {
assertSearchDryRunPageToken(t, preview, tt.wantPageToken)
}
out := preview.Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)

View File

@@ -41,8 +41,10 @@ var SearchTasklist = common.Shortcut{
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
params := buildSearchPageParams(runtime.Str("page-token"))
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasklists/search").
Params(params).
Body(body).
Desc("Then GET /open-apis/task/v2/tasklists/:guid for each search hit to render standard output")
},
@@ -71,9 +73,9 @@ var SearchTasklist = common.Shortcut{
var lastPageToken string
var lastHasMore bool
var notice string
currentBody := body
params := buildSearchPageParams(runtime.Str("page-token"))
for page := 0; page < pageLimit; page++ {
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/search", nil, currentBody)
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/search", params, body)
if err != nil {
return err
}
@@ -87,7 +89,7 @@ var SearchTasklist = common.Shortcut{
if !lastHasMore || lastPageToken == "" {
break
}
currentBody["page_token"] = lastPageToken
params["page_token"] = lastPageToken
}
tasklists := make([]map[string]interface{}, 0, len(rawItems))
@@ -170,9 +172,6 @@ func buildTasklistSearchBody(runtime *common.RuntimeContext) (map[string]interfa
if len(filter) > 0 {
body["filter"] = filter
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
body["page_token"] = pageToken
}
return body, nil
}

View File

@@ -33,8 +33,8 @@ func TestBuildTasklistSearchBody(t *testing.T) {
check: func(t *testing.T, body map[string]interface{}) {
filter := body["filter"].(map[string]interface{})
createTime := filter["create_time"].(map[string]interface{})
if body["page_token"] != "pt_tl" {
t.Fatalf("unexpected body: %#v", body)
if _, present := body["page_token"]; present {
t.Fatalf("body unexpectedly contains page_token: %#v", body)
}
if filter["user_id"].([]string)[0] != "ou_creator" {
t.Fatalf("unexpected filter: %#v", filter)
@@ -80,9 +80,10 @@ func TestBuildTasklistSearchBody(t *testing.T) {
func TestSearchTasklist_DryRun(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantParts []string
name string
setup func(*cobra.Command)
wantPageToken string
wantParts []string
}{
{
name: "valid dry run",
@@ -90,7 +91,8 @@ func TestSearchTasklist_DryRun(t *testing.T) {
_ = cmd.Flags().Set("query", "Q2")
_ = cmd.Flags().Set("page-token", "pt_tl")
},
wantParts: []string{"POST /open-apis/task/v2/tasklists/search", `"query":"Q2"`},
wantPageToken: "pt_tl",
wantParts: []string{`"query":"Q2"`},
},
{
name: "dry run error on invalid create time",
@@ -116,7 +118,11 @@ func TestSearchTasklist_DryRun(t *testing.T) {
t.Fatalf("Validate() error = %v", err)
}
}
out := SearchTasklist.DryRun(nil, runtime).Format()
preview := SearchTasklist.DryRun(nil, runtime)
if tt.wantPageToken != "" {
assertSearchDryRunPageToken(t, preview, tt.wantPageToken)
}
out := preview.Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)

View File

@@ -16,14 +16,16 @@ metadata:
## 身份
日程操作默认使用 `--as user`(查看和管理当前用户的日程)。`--as bot` 只能访问 bot 自己的(空)日历,会拿到空结果——不要用 bot 身份查用户日程。
按**日程归属**选身份:
- 查看/管理登录用户本人的日程 → `--as user`(默认,绝大多数场景)。
- 查看/管理 bot 自己创建/拥有的日程 → `--as bot`
```bash
# BAD — bot 身份查用户日程,返回空列表
lark-cli calendar +agenda --as bot
# GOOD — user 身份查日程
# 用户本人日程 → user
lark-cli calendar +agenda --as user
# bot 自建或参与的日程 → bot
lark-cli calendar +agenda --as bot
```
## Shortcuts
@@ -48,6 +50,8 @@ lark-cli calendar +agenda --as user
lark-cli calendar +get --calendar-id <calendar_id> --event-id <event_id>
```
日程描述统一使用 `description` 一个字段,按 **Markdown** 富文本处理。读取日程时 `description` 返回 Markdown 富文本(仅有纯文本描述时返回该纯文本);创建/更新日程时也通过 `--description` 传入 Markdown。
### `+search-event` — 按关键词、时间范围和参会人搜索日程
仅返回基础字段(`event_id`/`summary`/`start`/`end` 等),需要详情请走 `+get`
@@ -186,6 +190,8 @@ lark-cli contact +search-user --query <query> --as user
lark-cli im +chat-search --query <query> --as user
```
> 搜索用户接口不支持 bot 身份,必须用 `--as user`;搜到的 `ou_` open_id 用于日程参与人操作(如添加日程参与人)。
## 不在本 skill 范围
- 查询过去的视频会议记录 → [lark-vc](../lark-vc/SKILL.md)

View File

@@ -32,19 +32,19 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
| `--summary <text>` | 否 | 日程标题。注意:标题中不应该出现时间、地点、人物信息 |
| `--start <time>` | 是 | 开始时间ISO 8601`2026-03-12T14:00+08:00` |
| `--end <time>` | 是 | 结束时间ISO 8601 |
| `--description <text>` | 否 | 日程详细描述。提供会议议程、活动内容、注意事项或链接等。与 summary 配合使用,仅关注当前日程信息 |
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`。AI 提取时请务必保留对应前缀 |
| `--description <markdown>` | 否 | 日程描述,统一使用此字段,格式为 **Markdown**。提供会议议程、活动内容、注意事项或链接等。支持加粗、斜体、下划线(`<u>...</u>`)、删除线、链接 `[文本](url)`、标题(`# ``### `,最多三级)、引用(`> `)、有序/无序列表、GFM 表格(`\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`)、以及图片 `![图片名](图片URL)`(标准 Markdown 图片语法:远程 URL 原样使用;**本地图片路径**(相对路径、且位于当前工作目录内)会自动上传到云盘并在端上内联渲染——绝对路径或工作目录之外的路径会报错;端上已有图片读回为 Markdown 图片)。飞书文档 URL直接粘贴裸链接或写成 `[文本](url)`)会自动解析为内联文档,端上展示文档标题而非裸链接。支持 `@文件路径``-`stdin读取。**禁止**用 `***文本***` 同时表示加粗+斜体(端上会残留 `*`);应嵌套书写,如 `**<u>*~~文本~~*</u>**``*<u>**~~文本~~**</u>*`|
| `--attendee-ids <id_list>` | 否 | 参与人 ID 列表(逗号分隔)。支持用户(`ou_`)、群组(`oc_`)和会议室(`omm_`。AI 提取时请务必保留对应前缀。bot 可作为合法参会人,无需剔除 |
| `--calendar-id <id>` | 否 | 日历 ID省略则使用主日历 |
| `--rrule <rrule>` | 否 | 重复日程的重复性规则规则设置方式参考rfc5545。示例值"FREQ=DAILY;INTERVAL=1;UNTIL=<具体日期>" |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
> 当用户表达'每周 X'、'每周重复'、'连续 N 周'时,必须使用 rrule 创建重复性日程,而非创建多个独立日程
> `--description` 行内同时加粗和斜体时,**禁止**写 `***文本***`(端上会残留 `*`);必须让 `**` 与 `*` 各自成对嵌套,例如 `**<u>*~~文本~~*</u>**` 或 `*<u>**~~文本~~**</u>*`。
> 自动设置 `attendee_ability: "can_modify_event"`,参会人可查看彼此并编辑日程。
> 自动设置 `free_busy_status: "busy"`,默认日程忙闲状态为忙碌。
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
> 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。
> 失败保护:若添加参会人失败(如 open_id 错误CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。
> 搜索用户接口不支持 bot 身份,需用 `--as user` 进行搜索。
> 审批会议室:`+create` 不暴露低频字段 `attendees[].approval_reason`。如果会议室要求审批,请使用用户身份先创建日程,再用完整 API `calendar event.attendees create --as user` 添加会议室并传 `approval_reason`。
## 高级用法(完整 API 命令)

View File

@@ -50,12 +50,13 @@ lark-cli calendar +room-find \
| `--room-name <text>` | 否 | 会议室名称约束,支持以**英文逗号**分隔传入多个名称。仅当用户明确提到会议室专名、会议室号或编号区间时使用。 |
| `--min-capacity <n>` | 否 | 会议室最小容纳人数。当用户明确参会人数或提出“至少容纳N人”等要求时提取数字放入此参数必须为正整数。 |
| `--max-capacity <n>` | 否 | 会议室最大容纳人数。用于过滤过大空间,必须为正整数。 |
| `--attendee-ids <id_list>` | 否 | 参会对象 ID 列表。支持用户 ID`ou_` 前缀)和群组 ID`oc_` 前缀),多个 ID 以逗号分隔。 |
| `--attendee-ids <id_list>` | 否 | 参会对象 ID 列表。支持用户 ID`ou_` 前缀)和群组 ID`oc_` 前缀),多个 ID 以逗号分隔。**不要传入 bot 的 open_id**bot 是虚拟身份,不占会议室席位、无会议室偏好,传入只会干扰推荐结果。 |
| `--event-rrule <rrule>` | 否 | 重复日程的重复性规则规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT如需限制重复次数必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" |
| `--timezone <tz>` | 否 | 对话中明确提及的预约日程所使用的时区(默认取用户设备时区,例如 `Asia/Shanghai` |
## 规则
- 构造 `--attendee-ids` 前,先剔除 bot 参会人bot 不占席位、无偏好,不应参与会议室推荐。
- 多个 `--slot` 会由 CLI 内部并发调用单时间块接口,再聚合成一次输出
- `+room-find` 的时间输入必须是**确定时间块**,不是时间区间搜索。
- 如果是重复性日程,必须校验返回中的 `reserve_until_time`(该会议室最晚可预约时间)是否覆盖 `event-rrule` 对应的重复范围。

View File

@@ -39,6 +39,7 @@ lark-cli calendar +freebusy --start "<start>" --end "<end>"
```
规则:
- 参与人含 **bot**:无需为 bot 查询忙闲。bot 是虚拟身份,可并行多个会议、无忙闲语义,检查它没有意义。
- 参与人过多(超过 5 人):仅查询**当前用户**及少数核心人员忙闲即可
- 参与人含**群组**:无需展开群组成员查询忙闲
- 如果用户是从 `+suggestion` 确认了时间块后进入本分支的,**无需再调用 `+freebusy`**

View File

@@ -45,7 +45,7 @@ lark-cli calendar +suggestion \
| ------------------------------- | ----- | ------------------------------------------------------------------- |
| `--start <time>` | 否 | 搜索区间开始时间(支持日期/ISO 8601等格式默认**当前时间** |
| `--end <time>` | 否 | 搜索区间结束时间(默认与 `--start` 属于同一天,自动取当天结束时间) |
| `--attendee-ids <id_list>` | 否 | 目标参与人 ID 列表。提取对应实体的 ID。支持用户`ou_` 前缀)和群组(`oc_` 前缀)。多个 ID 使用英文逗号分隔 |
| `--attendee-ids <id_list>` | 否 | 目标参与人 ID 列表。提取对应实体的 ID。支持用户`ou_` 前缀)和群组(`oc_` 前缀)。多个 ID 使用英文逗号分隔。**不要传入 bot 的 open_id**bot 是虚拟身份,可并行多个会议、无忙闲语义,传入会干扰推荐时段的忙闲计算。 |
| `--event-rrule <rrule>` | 否 | 重复日程的重复性规则规则设置方式参考rfc5545。**【⚠️注意:系统绝对不支持 COUNT如需限制重复次数必须转为 UNTIL】**。示例值:"FREQ=DAILY;INTERVAL=1" |
| `--duration-minutes <min>` | 否 | 会议时长(分钟)。优先使用用户显式指定的值,若未指定则尝试根据上下文推断,推断失败则不传 |
| `--timezone <tz>` | 否 | 对话中明确提及的预约日程所使用的时区(默认取用户设备时区,例如 `Asia/Shanghai` |

View File

@@ -43,7 +43,7 @@ lark-cli calendar +update \
| `--event-id <id>` | 是 | 要更新的日程 ID。重复性日程请根据操作范围选择 ID详见 [重复性日程操作规范](lark-calendar-recurring.md) |
| `--calendar-id <id>` | 否 | 日历 ID省略则使用 `primary` |
| `--summary <text>` | 否 | 新日程标题。仅在显式传入 `--summary` 时更新;若传空字符串,会把标题清空 |
| `--description <text>` | 否 | 新日程描述。目前 API 方式不支持编辑富文本描述;如果日程描述通过客户端编辑为富文本内容,则使用 API 更新描述会导致富文本格式丢失。仅在显式传入 `--description` 时更新;传空字符串,会把描述清空 |
| `--description <markdown>` | 否 | 新日程描述,统一使用此字段,格式为 **Markdown**(加粗、斜体、下划线 `<u>...</u>`、删除线、链接 `[文本](url)`、标题 `# `~`### `(最多三级)、引用 `> `、有序/无序列表、GFM 表格 `\| 列1 \| 列2 \|` + 分隔行 `\| --- \| --- \|`、以及图片 `![图片名](图片URL)`(标准 Markdown 图片语法:远程 URL 原样使用;**本地图片路径**(相对路径、且位于当前工作目录内)会自动上传到云盘并在端上内联渲染——绝对路径或工作目录之外的路径会报错;端上已有图片读回为 Markdown 图片)。飞书文档 URL裸链接或 `[文本](url)`)会自动解析为内联文档,端上展示文档标题。支持 `@文件路径``-`stdin读取。仅在显式传入时更新;传空字符串 `""` 会清空描述。**禁止**用 `***文本***` 同时表示加粗+斜体(端上会残留 `*`);应嵌套书写,如 `**<u>*~~文本~~*</u>**``*<u>**~~文本~~**</u>*` |
| `--start <time>` | 否 | 新开始时间ISO 8601`2026-03-12T14:00+08:00`)。更新日程时间时必须同时传 `--end` |
| `--end <time>` | 否 | 新结束时间ISO 8601。更新日程时间时必须同时传 `--start` |
| `--rrule <rrule>` | 否 | 新重复规则RFC5545。**不要使用 COUNT如需限制次数推算后转为 UNTIL** |
@@ -58,9 +58,12 @@ lark-cli calendar +update \
- `--add-attendee-ids` 是**增量添加**,不是替换最终参与人列表。不要用它表达“只保留这些人”。
-`--summary``--description`CLI 以“是否显式传入该 flag”判断是否更新而不是以“值是否为空”判断如果显式传入空字符串会把对应字段清空。
- 日程描述统一走 `--description`(按 Markdown 富文本处理)。
- 行内同时加粗和斜体时,**禁止**写 `***文本***`(端上会残留 `*`);必须让 `**``*` 各自成对嵌套,例如 `**<u>*~~文本~~*</u>**``*<u>**~~文本~~**</u>*`
- 只想增删参会人或会议室时,不需要同时传 `--summary``--start``--end` 等日程字段。
- 只想修改标题、描述、时间或重复规则时,不需要同时传 `--add-attendee-ids``--remove-attendee-ids`
- 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`
- bot 可作为合法参会人添加,无需剔除。
- 会议室是 resource attendee必须使用 `omm_` ID 添加到参会人列表,不能脱离日程单独预定。
- 更新重复性日程时,必须先确定操作范围(仅此次/全部/此次及后续),然后按 [重复性日程操作规范](lark-calendar-recurring.md) 执行。
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。

View File

@@ -13,7 +13,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
## 容器标签
|标签|说明|关键属性|
|-|-|-|
| `<callout>` | 高亮框,子块仅支持文本、标题、列表、待办、引用 | `emoji`(默认 bulb), `background-color`, `border-color`, `text-color` |
| `<callout>` | 高亮框,子块仅支持文本块(如 `<p>`)、标题、列表、待办、引用;禁止裸文本及 `<table>``<img>``<pre>``<hr>``<grid>``<whiteboard>``<sheet>` 等其他块级标签或资源块 | `emoji`(默认 bulb), `background-color`, `border-color`, `text-color` |
| `<grid>` + `<column>` | 分栏布局,各列 width-ratio 之和为 1 | `width-ratio` |
| `<whiteboard>` | 嵌入画板 | `type`: `blank` \| `mermaid` \| `plantuml` \| `svg` |
| `<pre>` | (代码块,内含 `code`| `lang`, `caption` |

View File

@@ -26,11 +26,16 @@
> **`--query` 最长 30 个字符**按字符数Unicode 码点)算,中文每字算 1 个,与 ASCII 同口径;超过 30 会被服务端拒绝(`99992402 field validation failed`**是报错不是截断**)。长关键词必须先压缩成核心实体 + 主题词(如把整句问题压成「项目名 + 主题」再搜),不要把整句原问塞进 `--query`。
>
> **列表型请求不要硬塞关键词**:如果用户只是要求"我这月创建的所有文档"、"最近半年我编辑过的文档"、"按类型分类统计"这类范围浏览 / 汇总请求,且没有给出标题片段或业务关键词,应使用 `--query ""` 搭配 `--created-by-me`、`--mine`、`--created-*`、`--edited-*`、`--doc-types` 等过滤条件。不要把"查找"、"所有文档"、"最近更新过"、"按类型分类统计"这类动作词或统计意图放进 `--query`,否则会把本来应靠 filter 命中的结果过度收窄。
>
> **标题词 + 正文词联合搜索**:如果用户同时给出标题关键词和正文关键词,并要求同一资源同时满足两项条件,优先执行一条普通联合搜索:`lark-cli drive +search --query "标题词 正文词"`,并在同一条命令中叠加用户指定的 `--folder-tokens`、`--doc-types` 等过滤条件。不要把这种联合搜索拆成“标题搜索 + 正文搜索”后自行拼交集;也不要把 `--only-title` 或 `intitle:` 用作主候选路径。只有用户明确只查标题时,才使用 `--only-title` 或 `intitle:`。
>
> 用户要求最终返回 N 条时N 是输出上限,不等于 `--page-size N`。逐页根据 `title` 和 `summary_highlighted` 保留同时满足两项条件的候选;有效候选不足 N 且 `has_more=true` 时,保持同一 query 和过滤条件,使用 `--page-token` 继续,最多检查 3 页。摘要不足以判断正文条件时,只对标题已匹配的候选串行读取正文,确认一个再处理下一个,找到 N 条后停止;不要并发拉取正文。检查 3 页后仍不足时,返回已确认结果并建议用户调整标题词、正文词或搜索范围,不要无界扫描。
### 自然语言 → 命令映射速查
| 用户说 | 命令 |
|---|---|
| 标题含某词且正文含某词,限定文件夹内最多 N 个结果N 为最终输出上限;按上文规则分页筛选,勿作为 `--page-size` | `lark-cli drive +search --query "标题词 正文词" --folder-tokens <FOLDER_TOKEN>` |
| 我这月创建的所有文档,按类型分类统计 | `lark-cli drive +search --query "" --created-by-me --created-since "<YYYY-MM-DD>" --created-until "<YYYY-MM-DD>"` |
| 最近半年我编辑过的文档,看看哪些最近更新过 | `lark-cli drive +search --query "" --edited-since 6m --sort edit_time` |
| 最近一个月我编辑过的文档 | `lark-cli drive +search --query "" --edited-since 1m` |
@@ -217,7 +222,7 @@ stdout 的 JSON 输出不受影响。`open_time` / `create_time` 不做 snap。
- **日历表达**"上个月"、"上周"、"本月"、"前年"、"今年 3 月"等明确日历单位)→ **必须算出绝对 `YYYY-MM-DD` 边界**(如"上个月" = 上一个日历月的 1 号 → 当月 1 号),**不要近似成 `1m`/`2m`**CLI 里 `m` 是固定 30 天、`y` 固定 365 天,跟日历差 0-3 天,月末月初尤其容易偏出去
- 文档中的 `"<YYYY-MM-DD>"` 是运行时占位符:执行命令前按当前日期计算并替换。例如"本月"应替换为本月第一天和下月第一天,不要把示例生成时的月份硬编码进答案
- 绝对日期 → 直接 `YYYY-MM-DD` 或 RFC3339
- **分页策略**:默认只返回第一页,并说明 `has_more` 和下一页命令。只有用户明确要"全部 / 全量 / 继续翻"继续单轮翻页上限 5 页。
- **分页策略**:默认只返回第一页,并说明 `has_more` 和下一页命令。用户明确要"全部 / 全量 / 继续翻"继续;标题词 + 正文词联合搜索尚未找到足够的有效 Top N 候选时,按上文规则最多检查 3 页。其他场景单轮翻页上限 5 页。
- **原始返回**:用户要求"原始数据"、"接口返回"时用 `--format json`,不做客户端精确过滤或摘要重写。
## 权限

View File

@@ -4,7 +4,7 @@
获取幻灯片页面截图并保存为本地图片文件。默认用于已存在 PPT 页面截图;传入 `--content` 时用于直接渲染单个 `<slide>` XML 片段预览。本 shortcut 会在 CLI 进程内解码并写入文件stdout 只返回文件路径、大小、页面 ID 等元信息,避免把图片 Base64 输出给模型。
注意:该截图能力受应用白名单限制,绝大多数应用不可用。截图失败时不要引导用户申请 `slides:presentation:screenshot` 权限;记录错误后降级到 XML 读回、结构 lint、文本重叠检查等非截图检查路径。
截图失败则降级到 XML 读回、结构 lint等非截图检查路径。
## 命令
@@ -26,8 +26,8 @@ lark-cli slides +screenshot --as user \
| 参数 | 必需 | 说明 |
|------|------|------|
| `--presentation` | list 模式必需 | `xml_presentation_id``/slides/` URL或解析后为 slides 的 `/wiki/` URL。传 `--content` 时不能使用 |
| `--slide-id` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面 short ID多页截图时重复传入一次最多 10 页(`--slide-id` + `--slide-number` 合计小于等于 10 |
| `--slide-number` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面页号;多页截图时重复传入;一次最多 10 页(`--slide-id` + `--slide-number` 合计小于等于 10 |
| `--slide-id` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面 short ID多页截图时重复传入,或用逗号分隔一次传多个(如 `--slide-id slide_1,slide_2`;一次最多 10 页(`--slide-id` + `--slide-number` 合计小于等于 10 |
| `--slide-number` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面页号;多页截图时重复传入,或用逗号分隔一次传多个(如 `--slide-number 1,2,3`;一次最多 10 页(`--slide-id` + `--slide-number` 合计小于等于 10 |
| `--content` | render 模式必需 | 要直接渲染的 `<slide>` XML 片段;支持直接传值、`@file``-` stdin。传入后不能同时传 `--slide-id` / `--slide-number` |
| `--output-dir` | 否 | 输出目录,默认 `.lark-slides/screenshots`;必须是当前目录内的相对路径 |
| `--output-name` | 否 | render 模式的输出文件名 stem未指定时优先用返回的 `slide_id`,否则用 `rendered-slide`。若目标文件已存在,会自动追加递增后缀避免覆盖 |
@@ -44,7 +44,7 @@ lark-cli slides +screenshot --as user \
### 多页截图
一次不要超过 10 页;如需更多页面,分批调用。
一次不要超过 10 页;如需更多页面,分批调用。可以重复传参,也可以用逗号分隔一次传多个:
```bash
lark-cli slides +screenshot --as user \

View File

@@ -194,14 +194,13 @@
<xs:simpleType name="FontSizeType">
<xs:annotation>
<xs:documentation>
字体大小, 使用正整数, 单位px
示例12, 14, 16, 18, 20, 24, 28, 32 等
字体大小, 浮点数, 范围 [1, 4000], 单位px
示例:10, 10.5, 12, 14, 16, 18, 20, 24, 28, 32 等
</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:positiveInteger">
<xs:minInclusive value="6"/>
<xs:maxInclusive value="400"/>
<xs:pattern value="[0-9]+"/>
<xs:restriction base="xs:double">
<xs:minInclusive value="1"/>
<xs:maxInclusive value="4000"/>
</xs:restriction>
</xs:simpleType>
@@ -211,6 +210,52 @@
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="AutoStartAtType">
<xs:annotation>
<xs:documentation>
有序列表起始编号, 取值范围 [1, 32767]
</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:positiveInteger">
<xs:minInclusive value="1"/>
<xs:maxInclusive value="32767"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="BulletSizeType">
<xs:annotation>
<xs:documentation>
列表符号大小, 二选一:
- 百分比字符串(相对于文本字号), 取值范围 25%-400%, 如 "100%"
- 绝对像素值, 取值范围 6-400, 如 "14"
</xs:documentation>
</xs:annotation>
<xs:union>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="(2[5-9]|[3-9][0-9]|[1-3][0-9]{2}|400)%"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType>
<xs:restriction base="xs:string">
<xs:pattern value="[6-9]|[1-9][0-9]|[1-3][0-9]{2}|400"/>
</xs:restriction>
</xs:simpleType>
</xs:union>
</xs:simpleType>
<xs:simpleType name="BulletCharType">
<xs:annotation>
<xs:documentation>
自定义列表符号, 如 "★", "→", "✓", "◆", 也支持 emoji
</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:string">
<xs:minLength value="1"/>
<xs:maxLength value="8"/>
</xs:restriction>
</xs:simpleType>
<!-- 文本类型枚举 -->
<xs:simpleType name="TextType">
<xs:annotation>
@@ -232,6 +277,35 @@
</xs:restriction>
</xs:simpleType>
<!-- 动态文本字段类型枚举 -->
<xs:simpleType name="FieldType">
<xs:annotation>
<xs:documentation>
动态文本字段类型:
- slidenum: 当前幻灯片页码
- datetime: 默认日期时间格式
- datetime1-datetime13: 预定义日期时间格式
</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:string">
<xs:enumeration value="slidenum"><xs:annotation><xs:documentation>当前幻灯片页码</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime"><xs:annotation><xs:documentation>浏览器默认日期格式, 例如 2026/7/14</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime1"><xs:annotation><xs:documentation>日期格式 M/D/YYYY, 例如 10/12/2007</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime2"><xs:annotation><xs:documentation>日期格式 dddd, MMMM D, YYYY, 例如 Friday, October 12, 2007</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime3"><xs:annotation><xs:documentation>日期格式 D MMMM YYYY, 例如 12 October 2007</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime4"><xs:annotation><xs:documentation>日期格式 MMMM D, YYYY, 例如 October 12, 2007</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime5"><xs:annotation><xs:documentation>日期格式 D-MMM-YY, 例如 12-Oct-07</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime6"><xs:annotation><xs:documentation>日期格式 MMMM YY, 例如 October 07</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime7"><xs:annotation><xs:documentation>日期格式 MMM-YY, 例如 Oct-07</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime8"><xs:annotation><xs:documentation>日期时间格式 M/D/YYYY h:mm A, 例如 10/12/2007 4:28 PM</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime9"><xs:annotation><xs:documentation>日期时间格式 M/D/YYYY h:mm:ss A, 例如 10/12/2007 4:28:34 PM</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime10"><xs:annotation><xs:documentation>时间格式 HH:mm, 例如 16:28</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime11"><xs:annotation><xs:documentation>时间格式 HH:mm:ss, 例如 16:28:34</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime12"><xs:annotation><xs:documentation>时间格式 h:mm A, 例如 4:28 PM</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="datetime13"><xs:annotation><xs:documentation>时间格式 h:mm:ss A, 例如 4:28:34 PM</xs:documentation></xs:annotation></xs:enumeration>
</xs:restriction>
</xs:simpleType>
<!-- 文本对齐 -->
<xs:simpleType name="TextAlignType">
<xs:restriction base="xs:string">
@@ -781,21 +855,64 @@
<xs:attribute name="heightScale" type="sml:ArrowScaleType" use="optional"/>
</xs:complexType>
<!-- 裁剪方位枚举类型 -->
<xs:simpleType name="CropAnchorType">
<xs:annotation>
<xs:documentation>
裁剪方位枚举, 用于指定保留原图的哪个区域
- top: 保留顶部, 裁掉底部多余部分
- bottom: 保留底部, 裁掉顶部多余部分
- left: 保留左侧, 裁掉右侧多余部分
- right: 保留右侧, 裁掉左侧多余部分
居中场景不需要设置 anchor, 不设置 offset 即为默认居中裁剪
</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:string">
<xs:enumeration value="top"><xs:annotation><xs:documentation>保留顶部, 裁掉底部多余部分</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="bottom"><xs:annotation><xs:documentation>保留底部, 裁掉顶部多余部分</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="left"><xs:annotation><xs:documentation>保留左侧, 裁掉右侧多余部分</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="right"><xs:annotation><xs:documentation>保留右侧, 裁掉左侧多余部分</xs:documentation></xs:annotation></xs:enumeration>
</xs:restriction>
</xs:simpleType>
<!-- 裁剪类型定义 -->
<xs:complexType name="CropType">
<xs:annotation>
<xs:documentation>
裁剪配置: 原图填充到预裁剪区域再根据offset裁出最终尺寸
裁剪配置: 原图裁剪到目标尺寸 (img 元素的 width × height)
可选属性:
type: 裁剪形状默认rect
leftOffset, rightOffset, topOffset, bottomOffset: 边缘偏移量(px)。正值向内裁剪负值向外扩展留白0值对齐边缘
presetHandlers: 控制点配置对应ECMA预设形状的控制点。单个或多个数字多个用逗号分隔。示例: type="rect"且presetHandlers="60"时为圆角矩形圆角半径60px
type: 裁剪形状, 默认 rect
anchor: 裁剪方位 (top/bottom/left/right), 参见 CropAnchorType
leftOffset / rightOffset / topOffset / bottomOffset: 四向偏移量 (px), 正值向内裁剪、负值向外扩展留白、0对齐边缘
presetHandlers: 控制点配置, 对应 ECMA 预设形状的控制点。单个或多个数字, 多个用逗号分隔。
示例: type="rect" 且 presetHandlers="60" 时为圆角矩形, 圆角半径 60px
说明: 指定offset时若预裁剪尺寸与原图比例不一致会产生拉伸变形。无法确定原图比例时不要指定offset
【推荐用法】使用 anchor 指定裁剪方位:
- 不设置 anchor 时: 默认按图片居中裁剪
- 设置 anchor 时: 按指定方位裁剪, 例如 anchor="top" 表示保留顶部、裁掉底部多余部分
- 使用 anchor 后, 不需要再设置 offset
- anchor 模式下原图按等比缩放后裁剪, 不会发生拉伸或压缩
【进阶用法】使用 offset 精细控制裁剪边界:
- 适用于用户在编辑器中手动调整裁剪、或从外部协议导入的场景
- 原图先填充到预裁剪区域, 再根据 offset 从四边裁出最终尺寸
- 注意: 若预裁剪尺寸与原图比例不一致会产生拉伸变形; 无法确定原图比例时, 不要指定 offset
【优先级】
如果同时设置了 anchor 和 offset, 以 anchor 为准, offset 被忽略
典型用法:
<crop/> 居中裁剪 (默认行为)
<crop anchor="top"/> 保留顶部
<crop anchor="left"/> 保留左侧
<crop type="rect" presetHandlers="60"/> 圆角矩形裁剪, 默认居中
</xs:documentation>
</xs:annotation>
<xs:attribute name="type" type="sml:ShapeType" use="optional" default="rect"/>
<xs:attribute name="anchor" type="sml:CropAnchorType" use="optional"/>
<xs:attribute name="leftOffset" type="xs:double" use="optional"/>
<xs:attribute name="rightOffset" type="xs:double" use="optional"/>
<xs:attribute name="topOffset" type="xs:double" use="optional"/>
@@ -1042,6 +1159,10 @@
- underline: content 级别是否下划线
- list: content 级别列表类型 bullet/number
- listStyle: content 级别列表样式
- bulletColor: 列表符号颜色(纯色), 可选
- bulletSize: 列表符号大小, 可选, 百分比字符串(相对于文本字号, 取值范围 25%-400%)如 "100%", 或绝对像素值(取值范围 6-400如 "14"
- autoStartAt: 有序列表起始编号, 可选, 取值范围 [1, 32767]; 作为后代段落的初始计数器, 子元素 &lt;p&gt;/&lt;ol&gt; 可通过自身 autoStartAt 重置
- bulletChar: 自定义列表符号字符, 可选, 如 "★", "→" 等, 设置后覆盖 listStyle 的符号
- anchorCenter: 控制文本对齐方式, 优先级高于 textAlign
- autoFit: 控制文本编辑溢出时处理策略
- baseline: 上标/下标, 相较于文本基线的偏移量
@@ -1049,6 +1170,13 @@
注意如果content子元素不指定属性, 默认继承content的属性值, 如果局部子元素指定了属性, 则使用局部属性值
autoStartAt 运行计数器示例(显式指定重置, 未指定沿用前序计数器):
&lt;content autoStartAt="5"&gt;
&lt;p list="number"&gt;A&lt;/p&gt; &lt;!-- A=5, 继承 content 初始值 --&gt;
&lt;p list="number" autoStartAt="10"&gt;B&lt;/p&gt; &lt;!-- B=10, 本段显式重置 --&gt;
&lt;p list="number"&gt;C&lt;/p&gt; &lt;!-- C=11, 沿用前序计数器递增 --&gt;
&lt;/content&gt;
子元素:
- p: 段落元素
- ul: 无序列表元素
@@ -1085,6 +1213,10 @@
<xs:attribute name="underline" type="xs:boolean" />
<xs:attribute name="list" type="sml:ListType" default="none"/>
<xs:attribute name="listStyle" type="sml:ListStyleType" />
<xs:attribute name="bulletColor" type="sml:SolidColor" use="optional"/>
<xs:attribute name="bulletSize" type="sml:BulletSizeType" use="optional"/>
<xs:attribute name="autoStartAt" type="sml:AutoStartAtType" use="optional"/>
<xs:attribute name="bulletChar" type="sml:BulletCharType" use="optional"/>
<xs:attribute name="anchorCenter" type="xs:boolean" default="false" /> <!-- 控制竖排文字是否在垂直方向保持居中 -->
<xs:attribute name="autoFit" type="sml:AutoFitType" default="no-auto-fit" />
<xs:attribute name="wrap" type="xs:boolean" default="true" />
@@ -1096,9 +1228,9 @@
<xs:annotation>
<xs:documentation>
段落容器, 支持富文本内容
可包含纯文本和内联格式元素(br/strong/em/u/span/del/a/shadow/outline)
可包含纯文本和内联格式元素(br/strong/em/u/span/del/a/shadow/outline/formula/field)
内联元素嵌套:所有内联元素均可包含纯文本或其他内联元素,以实现复杂的格式组合
元素自嵌套除a元素外其余内联元素支持自身嵌套当shadow和outline自嵌套时渲染效果遵循就近原则以内层定义的样式为准
元素自嵌套除a/formula元素外其余内联元素支持自身嵌套当shadow和outline自嵌套时渲染效果遵循就近原则以内层定义的样式为准
空格处理规则:
- 文本内的连续空格会被合并为单个空格
@@ -1119,6 +1251,8 @@
- a: 超链接
- shadow: 文本阴影
- outline: 文本轮廓
- formula: 科学公式(支持数学、物理等)
- field: 动态文本字段,元素内容作为不支持动态字段时的降级文本
属性说明:
- textAlign: 文本对齐方式
- lineSpacing: 行间距
@@ -1127,6 +1261,10 @@
- level: 段落级别, 取值范围 [1,10]
- list: 列表类型(bullet/number)
- listStyle: 列表样式
- bulletColor: 列表符号颜色(纯色), 可选
- bulletSize: 列表符号大小, 可选, 百分比字符串(相对于文本字号, 取值范围 25%-400%)或绝对像素值(取值范围 6-400
- autoStartAt: 有序列表起始编号, 可选, 取值范围 [1, 32767]; 显式指定时从当前段落起重置计数器, 未指定时沿用同一 content 内的前序计数器
- bulletChar: 自定义列表符号字符, 可选
- marginLeft: 段落左侧缩进宽度
- indent: 首行缩进宽度
</xs:documentation>
@@ -1134,6 +1272,7 @@
<xs:complexType mixed="true">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1142,6 +1281,7 @@
<xs:element ref="sml:a"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
<xs:attribute name="textAlign" type="sml:TextAlignType" />
<xs:attribute name="lineSpacing" type="sml:LineSpacingType" default="multiple:1.5"/>
@@ -1151,6 +1291,10 @@
<xs:attribute name="level" type="sml:LevelType" default="1"/>
<xs:attribute name="list" type="sml:ListType" default="none"/>
<xs:attribute name="listStyle" type="sml:ListStyleType"/>
<xs:attribute name="bulletColor" type="sml:SolidColor" use="optional"/>
<xs:attribute name="bulletSize" type="sml:BulletSizeType" use="optional"/>
<xs:attribute name="autoStartAt" type="sml:AutoStartAtType" use="optional"/>
<xs:attribute name="bulletChar" type="sml:BulletCharType" use="optional"/>
<xs:attribute name="marginLeft" type="xs:double" use="optional" />
<xs:attribute name="indent" type="sml:NonNegativeDouble" use="optional"/>
</xs:complexType>
@@ -1160,7 +1304,14 @@
<!-- 无序列表 -->
<xs:element name="ul">
<xs:annotation>
<xs:documentation>无序列表</xs:documentation>
<xs:documentation>
无序列表
属性说明:
- listStyle: 列表样式
- bulletColor: 列表符号颜色(纯色), 可选
- bulletSize: 列表符号大小, 可选, 百分比字符串(相对于文本字号, 取值范围 25%-400%)或绝对像素值(取值范围 6-400
- bulletChar: 自定义列表符号字符, 可选, 设置后覆盖 listStyle 的符号
</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:sequence>
@@ -1173,13 +1324,23 @@
</xs:element>
</xs:sequence>
<xs:attribute name="listStyle" type="sml:UnorderedListStyle" default="circle-hollow-square"/>
<xs:attribute name="bulletColor" type="sml:SolidColor" use="optional"/>
<xs:attribute name="bulletSize" type="sml:BulletSizeType" use="optional"/>
<xs:attribute name="bulletChar" type="sml:BulletCharType" use="optional"/>
</xs:complexType>
</xs:element>
<!-- 有序列表 -->
<xs:element name="ol">
<xs:annotation>
<xs:documentation>有序列表, 可指定序号</xs:documentation>
<xs:documentation>
有序列表, 可指定序号
属性说明:
- listStyle: 列表样式
- bulletColor: 列表符号颜色(纯色), 可选
- bulletSize: 列表符号大小, 可选, 百分比字符串(相对于文本字号, 取值范围 25%-400%)或绝对像素值(取值范围 6-400
- autoStartAt: 有序列表起始编号, 可选, 取值范围 [1, 32767]; 作用于本列表组的计数器初始值, 子元素 &lt;li@index&gt; 可覆盖单项编号
</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:sequence>
@@ -1193,6 +1354,9 @@
</xs:element>
</xs:sequence>
<xs:attribute name="listStyle" type="sml:OrderedListStyle" default="number-lower-alpha-lower-roman"/>
<xs:attribute name="bulletColor" type="sml:SolidColor" use="optional"/>
<xs:attribute name="bulletSize" type="sml:BulletSizeType" use="optional"/>
<xs:attribute name="autoStartAt" type="sml:AutoStartAtType" use="optional"/>
</xs:complexType>
</xs:element>
@@ -1383,7 +1547,7 @@
alpha: 不透明度[0, 1]
可选子元素:
crop: 裁剪。无标签或所有offset未设置时从左上角自适应裁到width×height
crop: 裁剪。无标签 / 空标签 / 仅设 anchor 时按等比缩放后裁剪到 width×height; anchor 指定保留方位 (top/bottom/left/right), 不设 anchor 即居中裁剪; offset 用于精细控制
reflection: 倒影。无标签代表无倒影,空标签代表使用默认样式
shadow: 阴影。无标签代表无阴影,空标签代表使用默认样式
border: 边框。无标签代表无边框,空标签代表使用默认样式(颜色: rgba(43, 47, 54, 1), 宽度: 2)
@@ -1500,7 +1664,7 @@
td 子元素:
- borderTop/borderRight/borderBottom/borderLeft: 单元格边框样式, 无border标签代表无边框, 空border标签代表使用默认样式(实线边框, 颜色为rgba(221, 222, 223, 1), 宽度为1)
- fill: 单元格填充样式, 无fill标签代表不填充, 空fill标签代表使用默认样式(默认颜色填充, 颜色为rgba(255, 255, 255, 1))
- content: 单元格内容
- content: 单元格内容。内容默认不反向修改表格几何尺寸; 当内容高度大于当前行高时, 需要手动修改行高
</xs:documentation>
</xs:annotation>
<xs:complexType>
@@ -1558,13 +1722,15 @@
<xs:complexType/>
</xs:element>
<xs:element name="strong">
<xs:element name="field">
<xs:annotation>
<xs:documentation>粗体/加重文本</xs:documentation>
<xs:documentation>
动态文本字段。
type 属性描述动态语义,元素内容是静态降级文本,可包含行内样式元素。
</xs:documentation>
</xs:annotation>
<xs:complexType mixed="true">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1574,6 +1740,70 @@
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
</xs:choice>
<xs:attribute name="type" type="sml:FieldType" use="required"/>
</xs:complexType>
</xs:element>
<xs:element name="formula">
<xs:annotation>
<xs:documentation>
通用公式元素。
用于展示各类科学公式。
结构说明:
- 必须从支持的公式格式中选择且仅选择一种作为子元素。
- 当前版本支持格式:&lt;latex&gt;
示例:
- 基础公式:
&lt;formula&gt;
&lt;latex&gt;&lt;![CDATA[ E = mc^2 ]]&gt;&lt;/latex&gt;
&lt;/formula&gt;
</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:choice minOccurs="1" maxOccurs="1">
<xs:element name="latex">
<xs:annotation>
<xs:documentation>
LaTeX 格式的公式内容。
本元素包含的 LaTeX 字符串必须严格符合附件中定义的宏集范围。
内容语法:
- 语法范围:仅使用附件白名单中明确支持的宏。
- 表达建议:优先使用基础运算符、分式(\frac)、根号(\sqrt)、矩阵(matrix)等标准数学环境。
- 格式要求:必须使用 CDATA 包裹内容,且 CDATA 内部严禁进行 XML 转义(如 &amp;lt;, &amp;amp;)。
- 空白处理:解析器将保留 CDATA 内的所有换行和缩进,建议利用此特性保持 LaTeX 源码的结构化和可读性。
</xs:documentation>
</xs:annotation>
<xs:simpleType>
<xs:restriction base="xs:string"/>
</xs:simpleType>
</xs:element>
</xs:choice>
</xs:complexType>
</xs:element>
<xs:element name="strong">
<xs:annotation>
<xs:documentation>粗体/加重文本</xs:documentation>
</xs:annotation>
<xs:complexType mixed="true">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
<xs:element ref="sml:span"/>
<xs:element ref="sml:del"/>
<xs:element ref="sml:a"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
</xs:complexType>
</xs:element>
@@ -1590,6 +1820,7 @@
<xs:extension base="sml:ShadowType">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1598,6 +1829,7 @@
<xs:element ref="sml:a"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
</xs:extension>
</xs:complexContent>
@@ -1617,6 +1849,7 @@
<xs:extension base="sml:OutlineType">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1625,6 +1858,7 @@
<xs:element ref="sml:a"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
</xs:extension>
</xs:complexContent>
@@ -1638,6 +1872,7 @@
<xs:complexType mixed="true">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1646,6 +1881,7 @@
<xs:element ref="sml:a"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
</xs:complexType>
</xs:element>
@@ -1657,6 +1893,7 @@
<xs:complexType mixed="true">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1665,6 +1902,7 @@
<xs:element ref="sml:a"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
</xs:complexType>
</xs:element>
@@ -1676,6 +1914,7 @@
<xs:complexType mixed="true">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1684,6 +1923,7 @@
<xs:element ref="sml:a"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
</xs:complexType>
</xs:element>
@@ -1698,6 +1938,7 @@
<xs:complexType mixed="true">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1706,6 +1947,7 @@
<xs:element ref="sml:a"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
<xs:attribute name="color" type="sml:Color" use="optional"/>
<xs:attribute name="backgroundColor" type="sml:Color" use="optional"/>
@@ -1729,6 +1971,7 @@
<xs:complexType mixed="true">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element ref="sml:br"/>
<xs:element ref="sml:formula"/>
<xs:element ref="sml:strong"/>
<xs:element ref="sml:em"/>
<xs:element ref="sml:u"/>
@@ -1736,6 +1979,7 @@
<xs:element ref="sml:del"/>
<xs:element ref="sml:shadow"/>
<xs:element ref="sml:outline"/>
<xs:element ref="sml:field"/>
</xs:choice>
<xs:attribute name="href" use="required">
<xs:simpleType>
@@ -1823,36 +2067,116 @@
<!-- 有序列表样式枚举 -->
<xs:simpleType name="OrderedListStyle">
<xs:annotation>
<xs:documentation>有序列表样式</xs:documentation>
<xs:documentation>
有序列表样式
分为两类:
1. 复合样式(按层级循环不同格式):如 number-lower-alpha-lower-roman 表示第1级用数字、第2级用小写字母、第3级用小写罗马超过层级数后循环
2. 单一样式(所有层级使用同一格式,不循环):以 PPTX 标准 scheme 命名,如 alpha-lc-paren-both 表示所有层级都用 (a)(b)(c) 格式
</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:string">
<!-- 复合样式(按层级循环) -->
<xs:enumeration value="number-lower-alpha-lower-roman"><xs:annotation><xs:documentation>1. a. i. - 数字/小写字母/小写罗马</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="number-lower-alpha-lower-roman-paren"><xs:annotation><xs:documentation>1) a) i) - 带括号版本</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="hierarchical-number"><xs:annotation><xs:documentation>1. 1.1. 1.1.1. - 多级数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="upper-alpha-lower-alpha-lower-roman"><xs:annotation><xs:documentation>A. a. i. - 大写字母/小写字母/小写罗马</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="upper-roman-upper-alpha-number"><xs:annotation><xs:documentation>I. A. 1. - 大写罗马/大写字母/数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="zero-padded-lower-alpha-lower-roman"><xs:annotation><xs:documentation>01. a. i. - 补零数字/小写字母/小写罗马</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="circle-number"><xs:annotation><xs:documentation> 圆圈数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="circle-number"><xs:annotation><xs:documentation>圆圈数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="lower-alpha-paren"><xs:annotation><xs:documentation>a) b) c) - 小写字母带括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="lower-alpha-dot"><xs:annotation><xs:documentation>a. b. c. - 小写字母带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="chinese-formal"><xs:annotation><xs:documentation>一、二、三、 - 中文数字</xs:documentation></xs:annotation></xs:enumeration>
<!-- 单一样式(所有层级使用同一格式,不随层级循环) -->
<!-- 拉丁字母 Latin -->
<xs:enumeration value="alpha-lc-paren-both"><xs:annotation><xs:documentation>(a) (b) (c) - 小写字母带双括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="alpha-uc-paren-both"><xs:annotation><xs:documentation>(A) (B) (C) - 大写字母带双括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="alpha-lc-paren-r"><xs:annotation><xs:documentation>a) b) c) - 小写字母带右括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="alpha-uc-paren-r"><xs:annotation><xs:documentation>A) B) C) - 大写字母带右括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="alpha-lc-period"><xs:annotation><xs:documentation>a. b. c. - 小写字母带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="alpha-uc-period"><xs:annotation><xs:documentation>A. B. C. - 大写字母带点</xs:documentation></xs:annotation></xs:enumeration>
<!-- 阿拉伯数字 Arabic Numeral -->
<xs:enumeration value="arabic-paren-both"><xs:annotation><xs:documentation>(1) (2) (3) - 数字带双括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arabic-paren-r"><xs:annotation><xs:documentation>1) 2) 3) - 数字带右括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arabic-period"><xs:annotation><xs:documentation>1. 2. 3. - 数字带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arabic-plain"><xs:annotation><xs:documentation>1 2 3 - 纯数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arabic-db-period"><xs:annotation><xs:documentation>- 全角数字带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arabic-db-plain"><xs:annotation><xs:documentation> - 全角纯数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arabic1-minus"><xs:annotation><xs:documentation>أ- ب- ت- - 阿拉伯语字母(现代序)带后横线</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arabic2-minus"><xs:annotation><xs:documentation>-أ- -ب- -ج- - 阿拉伯语字母(Abjadi序)带双横线</xs:documentation></xs:annotation></xs:enumeration>
<!-- 罗马数字 Roman -->
<xs:enumeration value="roman-lc-paren-both"><xs:annotation><xs:documentation>(i) (ii) (iii) - 小写罗马带双括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="roman-uc-paren-both"><xs:annotation><xs:documentation>(I) (II) (III) - 大写罗马带双括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="roman-lc-paren-r"><xs:annotation><xs:documentation>i) ii) iii) - 小写罗马带右括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="roman-uc-paren-r"><xs:annotation><xs:documentation>I) II) III) - 大写罗马带右括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="roman-lc-period"><xs:annotation><xs:documentation>i. ii. iii. - 小写罗马带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="roman-uc-period"><xs:annotation><xs:documentation>I. II. III. - 大写罗马带点</xs:documentation></xs:annotation></xs:enumeration>
<!-- 圆圈数字 Circle -->
<xs:enumeration value="circle-num-db-plain"><xs:annotation><xs:documentation>① ② ③ - 圆圈数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="circle-num-wd-black-plain"><xs:annotation><xs:documentation>❶ ❷ ❸ - 实心圆圈数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="circle-num-wd-white-plain"><xs:annotation><xs:documentation>① ② ③ - 圆圈数字1-10 循环, 字形与 circle-num-db-plain 相同但超过 10 后不降级为纯数字)</xs:documentation></xs:annotation></xs:enumeration>
<!-- 东亚 East Asian -->
<xs:enumeration value="ea1-chs-period"><xs:annotation><xs:documentation>一. 二. 三. - 简体中文带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="ea1-chs-plain"><xs:annotation><xs:documentation>一 二 三 - 简体中文</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="ea1-cht-period"><xs:annotation><xs:documentation>一. 二. 三. - 繁体中文带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="ea1-cht-plain"><xs:annotation><xs:documentation>一 二 三 - 繁体中文</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="ea1-jpn-chs-db-period"><xs:annotation><xs:documentation>一.二.三.- CJK汉字数字带全角点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="ea1-jpn-kor-plain"><xs:annotation><xs:documentation>一 二 三 - CJK汉字数字</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="ea1-jpn-kor-period"><xs:annotation><xs:documentation>一. 二. 三. - CJK汉字数字带半角点</xs:documentation></xs:annotation></xs:enumeration>
<!-- 希伯来语 Hebrew -->
<xs:enumeration value="hebrew2-minus"><xs:annotation><xs:documentation>א- ב- ג- - 希伯来字母带横线</xs:documentation></xs:annotation></xs:enumeration>
<!-- 泰语 Thai -->
<xs:enumeration value="thai-alpha-period"><xs:annotation><xs:documentation>ก. ข. ค. - 泰语字母带点(跳过 ฃ/ฅ/ฆ)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="thai-alpha-paren-r"><xs:annotation><xs:documentation>ก) ข) ค) - 泰语字母带右括号(跳过 ฃ/ฅ/ฆ)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="thai-alpha-paren-both"><xs:annotation><xs:documentation>(ก) (ข) (ค) - 泰语字母带双括号(跳过 ฃ/ฅ/ฆ)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="thai-num-period"><xs:annotation><xs:documentation>๑. ๒. ๓. - 泰语数字带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="thai-num-paren-r"><xs:annotation><xs:documentation>๑) ๒) ๓) - 泰语数字带右括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="thai-num-paren-both"><xs:annotation><xs:documentation>(๑) (๒) (๓) - 泰语数字带双括号</xs:documentation></xs:annotation></xs:enumeration>
<!-- 印地语 Hindi -->
<xs:enumeration value="hindi-alpha-period"><xs:annotation><xs:documentation>अ. आ. इ. - 印地语元音字母带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="hindi-num-period"><xs:annotation><xs:documentation>१. २. ३. - 印地语数字带点</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="hindi-num-paren-r"><xs:annotation><xs:documentation>१) २) ३) - 印地语数字带右括号</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="hindi-alpha1-period"><xs:annotation><xs:documentation>क. ख. ग. - 印地语辅音字母带点</xs:documentation></xs:annotation></xs:enumeration>
</xs:restriction>
</xs:simpleType>
<!-- 无序列表样式枚举 -->
<xs:simpleType name="UnorderedListStyle">
<xs:annotation>
<xs:documentation>无序列表样式</xs:documentation>
<xs:documentation>
无序列表样式
分为两类:
1. 复合样式(按层级循环不同图标):如 circle-hollow-square 表示第1级实心圆、第2级空心圆、第3级实心方形超过层级数后循环
2. 单一样式(所有层级使用同一图标,不循环):以 pptx- 前缀命名,如 pptx-circle 表示所有层级都用 ● 实心圆
</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:string">
<!-- 复合样式(按层级循环) -->
<xs:enumeration value="circle-hollow-square"><xs:annotation><xs:documentation>实心圆 空心圆 实心方形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="diamond-triangle-square"><xs:annotation><xs:documentation>形 三角形 实心方形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="diamond-triangle-square"><xs:annotation><xs:documentation>形 三角形 实心方形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="hollow-square-all"><xs:annotation><xs:documentation>空心方形 空心方形 空心方形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arrow-diamond-circle"><xs:annotation><xs:documentation>右箭头 实心形 实心圆形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="arrow-diamond-circle"><xs:annotation><xs:documentation>右箭头 实心形 实心圆形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="star-hollow-circle-square"><xs:annotation><xs:documentation>实心五角星 空心圆形 实心方形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="triangle-hollow-circle-square"><xs:annotation><xs:documentation>三角形 空心圆形 实心方形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="solid-square-all"><xs:annotation><xs:documentation>实心方形 实心方形 实心方形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="solid-diamond-all"><xs:annotation><xs:documentation>实心菱形 实心菱形 实心菱形</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="check-all"><xs:annotation><xs:documentation>对勾 对勾 对勾</xs:documentation></xs:annotation></xs:enumeration>
<!-- 单一样式(所有层级使用同一图标,不随层级循环) -->
<xs:enumeration value="pptx-circle"><xs:annotation><xs:documentation>● 实心圆(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-square"><xs:annotation><xs:documentation>■ 方块(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-diamond"><xs:annotation><xs:documentation>◆ 菱形(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-square-empty"><xs:annotation><xs:documentation>□ 空心方框(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-check"><xs:annotation><xs:documentation>✓ 对勾(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-triangle"><xs:annotation><xs:documentation>► 右三角(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-bullet"><xs:annotation><xs:documentation>• 小圆点(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-circle-empty"><xs:annotation><xs:documentation>○ 空心圆(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-diamond-empty"><xs:annotation><xs:documentation>◇ 空心菱形(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-arrow-right"><xs:annotation><xs:documentation>➔ 右箭头(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-star"><xs:annotation><xs:documentation>★ 星形(所有层级)</xs:documentation></xs:annotation></xs:enumeration>
<xs:enumeration value="pptx-square-shadow"><xs:annotation><xs:documentation>❑ 带右下阴影的 3D 方框(所有层级,对应 PPTX Wingdings 'q'</xs:documentation></xs:annotation></xs:enumeration>
</xs:restriction>
</xs:simpleType>
@@ -2096,6 +2420,19 @@
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="ChartGradientKindType">
<xs:annotation>
<xs:documentation>
图表渐变类型
可选值: linear(线性渐变) | radial(径向渐变)
</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:string">
<xs:enumeration value="linear"/>
<xs:enumeration value="radial"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="ChartRadarShapeType">
<xs:annotation>
<xs:documentation>
@@ -2217,6 +2554,7 @@
属性:
- textAlign: 文本对齐方式(left|center|right), 默认left
- fontFamily: 字体族名称,仅图表根级主标题/副标题支持;坐标轴标题不支持
- fontSize: 字号大小
- bold: 是否加粗
- italic: 是否斜体, 默认false
@@ -2230,6 +2568,7 @@
<xs:complexContent>
<xs:extension base="sml:ChartFontStyleType">
<xs:attribute name="textAlign" type="sml:ChartTextAlignType" use="optional" default="left"/>
<xs:attribute name="fontFamily" type="sml:FontFamilyType" use="optional"/>
</xs:extension>
</xs:complexContent>
</xs:complexType>
@@ -2298,10 +2637,10 @@
图表背景配置
属性:
- color: 背景颜色, 默认透明 rgba(0,0,0,0)
- color: 背景颜色,省略时使用图表默认背景;无填充可使用透明 rgba(0,0,0,0)
</xs:documentation>
</xs:annotation>
<xs:attribute name="color" type="sml:SolidColor" use="optional" default="rgb(255, 255, 255)"/>
<xs:attribute name="color" type="sml:SolidColor" use="optional"/>
</xs:complexType>
<xs:complexType name="ChartBorderType">
@@ -2311,7 +2650,7 @@
属性:
- color: 边框颜色,默认 rgb(222, 224, 227)
- width: 边框宽度(像素), 默认 1
- width: 边框宽度(像素), 默认 1无边框可设置为0或不设置chartBorder
- style: 边框样式(solid|dashed|dotted), 默认 solid
- radius: 圆角半径(像素), 默认 6
</xs:documentation>
@@ -2322,6 +2661,61 @@
<xs:attribute name="radius" type="xs:nonNegativeInteger" use="optional" />
</xs:complexType>
<xs:complexType name="ChartGradientStopType">
<xs:annotation>
<xs:documentation>
图表渐变色标
属性:
- offset: 色标位置比例[0,1]
- color: 色标颜色
- opacity: 色标透明度[0,1]
</xs:documentation>
</xs:annotation>
<xs:attribute name="offset" type="sml:RatioType" use="required"/>
<xs:attribute name="color" type="sml:SolidColor" use="required"/>
<xs:attribute name="opacity" type="sml:RatioType" use="optional"/>
</xs:complexType>
<xs:complexType name="ChartGradientStopsType">
<xs:annotation>
<xs:documentation>
图表渐变色标列表至少需要2个色标
</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="stop" type="sml:ChartGradientStopType" minOccurs="2" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ChartGradientType">
<xs:annotation>
<xs:documentation>
图表渐变配置
属性:
- type: 渐变类型(linear|radial)
- x0/y0/x1/y1: 线性渐变起止点坐标
- r0/r1: 径向渐变半径
- gradientMethod: 渐变算法/插值方式
子元素:
- stops: 渐变色标列表
</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="stops" type="sml:ChartGradientStopsType" minOccurs="1"/>
</xs:sequence>
<xs:attribute name="type" type="sml:ChartGradientKindType" use="required"/>
<xs:attribute name="x0" type="xs:double" use="optional"/>
<xs:attribute name="y0" type="xs:double" use="optional"/>
<xs:attribute name="x1" type="xs:double" use="optional"/>
<xs:attribute name="y1" type="xs:double" use="optional"/>
<xs:attribute name="r0" type="xs:double" use="optional"/>
<xs:attribute name="r1" type="xs:double" use="optional"/>
<xs:attribute name="gradientMethod" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="ChartColorThemeType">
<xs:annotation>
<xs:documentation>
@@ -2431,12 +2825,16 @@
- size: 该系列所有点的大小
子元素:
- fillGradient: 该系列所有点的填充渐变(可选)
- strokeGradient: 该系列所有点的边框/描边渐变(可选)
- chartPoint: 单个数据点配置(可选, 多个), 用于覆盖特定点的样式
</xs:documentation>
</xs:annotation>
<xs:complexContent>
<xs:extension base="sml:ChartGlobalPointsType">
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
<xs:element name="strokeGradient" type="sml:ChartGradientType" minOccurs="0"/>
<xs:element name="chartPoint" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
<xs:annotation>
@@ -2448,8 +2846,14 @@
- color: 该点的颜色
- shape: 该点的形状(circle|square|triangle|diamond|rect)
- size: 该点的大小(像素)
子元素:
- fillGradient: 该点填充渐变(可选)
</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
</xs:sequence>
<xs:attribute name="index" type="xs:positiveInteger" use="required"/>
<xs:attribute name="color" type="sml:SolidColor" use="optional"/>
<xs:attribute name="shape" type="sml:ChartPointShapeType" use="optional"/>
@@ -2462,7 +2866,7 @@
</xs:complexType>
<!-- 线条配置 -->
<xs:complexType name="ChartLineType">
<xs:complexType name="ChartGlobalLineType">
<xs:annotation>
<xs:documentation>
图表全局线条配置(第一层:所有系列的默认样式)
@@ -2479,8 +2883,27 @@
<xs:attribute name="style" type="sml:ChartLineStyleType" use="optional" default="solid"/>
</xs:complexType>
<xs:complexType name="ChartSeriesLineType">
<xs:annotation>
<xs:documentation>
图表系列线条配置(第二层:单系列统一配置)
继承ChartGlobalLineType的所有属性
子元素:
- strokeGradient: 该系列线条渐变(可选)
</xs:documentation>
</xs:annotation>
<xs:complexContent>
<xs:extension base="sml:ChartGlobalLineType">
<xs:sequence>
<xs:element name="strokeGradient" type="sml:ChartGradientType" minOccurs="0"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<!-- 面积配置 -->
<xs:complexType name="ChartAreaType">
<xs:complexType name="ChartGlobalAreaType">
<xs:annotation>
<xs:documentation>
图表全局面积配置(第一层:所有系列的默认填充样式)
@@ -2493,6 +2916,25 @@
<xs:attribute name="color" type="sml:SolidColor" use="optional"/>
</xs:complexType>
<xs:complexType name="ChartSeriesAreaType">
<xs:annotation>
<xs:documentation>
图表系列面积配置(第二层:单系列统一配置)
继承ChartGlobalAreaType的所有属性
子元素:
- fillGradient: 该系列面积填充渐变(可选)
</xs:documentation>
</xs:annotation>
<xs:complexContent>
<xs:extension base="sml:ChartGlobalAreaType">
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<!-- 柱子配置 -->
<xs:complexType name="ChartGlobalBarsType">
<xs:annotation>
@@ -2533,12 +2975,16 @@
- borderStyle: 该系列所有柱子的边框样式
子元素:
- fillGradient: 该系列所有柱子的填充渐变(可选)
- strokeGradient: 该系列所有柱子的边框渐变(可选)
- chartBar: 单个柱子配置(可选, 多个), 用于覆盖特定柱子的样式
</xs:documentation>
</xs:annotation>
<xs:complexContent>
<xs:extension base="sml:ChartGlobalBarsType">
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
<xs:element name="strokeGradient" type="sml:ChartGradientType" minOccurs="0"/>
<xs:element name="chartBar" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
<xs:annotation>
@@ -2551,8 +2997,14 @@
- borderColor: 该柱子的边框颜色
- borderWidth: 该柱子的边框宽度(像素)
- borderStyle: 该柱子的边框样式(solid|dashed|dotted)
子元素:
- fillGradient: 该柱子的填充渐变(可选)
</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
</xs:sequence>
<xs:attribute name="index" type="xs:positiveInteger" use="required"/>
<xs:attribute name="color" type="sml:SolidColor" use="optional"/>
<xs:attribute name="borderColor" type="sml:SolidColor" use="optional"/>
@@ -2577,8 +3029,14 @@
- offsetRadius: 扇区径向偏移比例[0,1], 用于突出显示
- borderColor: 扇区边框颜色
- color: 扇区填充颜色
子元素:
- fillGradient: 扇区填充渐变(可选)
</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
</xs:sequence>
<xs:attribute name="index" type="xs:positiveInteger" use="required"/>
<xs:attribute name="offsetRadius" type="sml:RatioType" use="optional"/>
<xs:attribute name="borderColor" type="sml:SolidColor" use="optional"/>
@@ -2598,10 +3056,12 @@
- startAngle: 起始角度[0,360), 控制第一个扇区的起始位置, 默认0
子元素:
- fillGradient: 所有扇区的统一填充渐变(可选)
- chartSector: 单个扇区配置(可选, 多个), 用于定制特定扇区
</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
<xs:element name="chartSector" type="sml:ChartSectorType" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="borderColor" type="sml:SolidColor" use="optional"/>
@@ -2648,8 +3108,8 @@
</xs:annotation>
<xs:sequence>
<xs:element name="chartPoints" type="sml:ChartSeriesPointsType" minOccurs="0"/>
<xs:element name="chartLine" type="sml:ChartLineType" minOccurs="0"/>
<xs:element name="chartArea" type="sml:ChartAreaType" minOccurs="0"/>
<xs:element name="chartLine" type="sml:ChartSeriesLineType" minOccurs="0"/>
<xs:element name="chartArea" type="sml:ChartSeriesAreaType" minOccurs="0"/>
<xs:element name="chartBars" type="sml:ChartSeriesBarsType" minOccurs="0"/>
<xs:element name="chartSectors" type="sml:ChartSectorsType" minOccurs="0"/>
<xs:element name="chartLabels" type="sml:ChartDataLabelsType" minOccurs="0"/>
@@ -2850,8 +3310,8 @@
</xs:annotation>
<xs:all>
<xs:element name="chartPoints" type="sml:ChartGlobalPointsType" minOccurs="0"/>
<xs:element name="chartLines" type="sml:ChartLineType" minOccurs="0"/>
<xs:element name="chartAreas" type="sml:ChartAreaType" minOccurs="0"/>
<xs:element name="chartLines" type="sml:ChartGlobalLineType" minOccurs="0"/>
<xs:element name="chartAreas" type="sml:ChartGlobalAreaType" minOccurs="0"/>
<xs:element name="chartBars" type="sml:ChartGlobalBarsType" minOccurs="0"/>
<xs:element name="chartLabels" type="sml:ChartDataLabelsType" minOccurs="0"/>
<xs:element name="chartSeriesList" type="sml:ChartSeriesListType" minOccurs="0"/>

View File

@@ -129,6 +129,15 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
- `<a>`
- `<shadow>`
- `<outline>`
- `<formula>`
公式写法:
```xml
<p>公式:<formula><latex><![CDATA[ E = mc^2 ]]></latex></formula></p>
```
`<formula>` 是内联元素;当前只支持一个 `<latex>` 子元素。LaTeX 内容必须放在 `CDATA` 中,且 `CDATA` 内不要写 XML 转义;宏只使用服务端支持范围内的写法,优先用基础运算符、`\frac``\sqrt``matrix`
示例:
@@ -312,6 +321,36 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
`<chart>` 直接子元素必须有 `<chartPlotArea>`(绘图区)和 `<chartData>`(数据);`<chartTitle>``<chartSubTitle>``<chartStyle>``<chartLegend>``<chartTooltip>` 可选,如果想不展示标题、副标题、图例或悬浮提示,省略相应元素标签即可。
`<chartStyle>` 常用子元素:
- `<chartBackground>``color` 省略时由渲染端决定默认背景;需要完全透明请显式写 `color="rgba(0, 0, 0, 0)"`
- `<chartBorder>`:无边框可写 `width="0"`,或直接不写 `<chartBorder>` 元素
#### 图表渐变 `<fillGradient>` / `<strokeGradient>`
图表支持渐变填充/描边,`<fillGradient>` 用于面积、柱子、数据点、扇区填充,`<strokeGradient>` 用于线条、数据点边框、柱子边框。渐变只能挂在系列级或单元素级,不要挂在 `<chartPlot>` 全局层。
可挂载位置:
- 系列级:`<chartBars>` / `<chartPoints>` 支持 `<fillGradient>``<strokeGradient>``<chartLine>` 只支持 `<strokeGradient>``<chartArea>` / `<chartSectors>` 只支持 `<fillGradient>`
- 单元素级:`<chartBar index="...">` / `<chartPoint index="...">` / `<chartSector index="...">` 只支持 `<fillGradient>`
- 全局级:`<chartPlot>` 下的 `<chartLines>` / `<chartAreas>` / `<chartBars>` / `<chartPoints>` 不支持渐变
结构要点:`type` 必填,可为 `linear``radial``linear``x0` / `y0` / `x1` / `y1``radial``r0` / `r1``<stops>` 至少包含 2 个 `<stop>``offset``opacity` 取值均为 `[0, 1]`
```xml
<chartSeries index="1">
<chartBars>
<fillGradient type="linear" x0="0" y0="0" x1="0" y1="1">
<stops>
<stop offset="0" color="rgb(28, 71, 120)"/>
<stop offset="1" color="rgb(28, 71, 120)" opacity="0.3"/>
</stops>
</fillGradient>
</chartBars>
</chartSeries>
```
隐藏 `<chart>` 的图例只能通过不写或删除 `<chartLegend>` 实现,`<chartLegend>` 不支持 `position="none"`
详细用法见 [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml)。

View File

@@ -49,6 +49,17 @@ ROUNDTRIP_SXSD_ATTRS = {
ROUNDTRIP_SXSD_TAGS = {"chartParsedValues"}
DEFAULT_TABLE_COLUMN_WIDTH = 110
DEFAULT_TABLE_ROW_HEIGHT = 37
DEFAULT_TEXT_LINE_SPACING_MULTIPLE = 1.5
TEXT_WRAP_WIDTH_TOLERANCE_PX = 1.0
TEXT_HEIGHT_OVERFLOW_TOLERANCE_PX = 0.5
SINGLE_LINE_METRIC_WIDTH_RATIO = 1.18
CENTERED_SHORT_LABEL_WIDTH_RATIO = 1.12
HEADLINE_NEAR_FIT_WIDTH_RATIO = 1.04
DENSE_BODY_LINE_SPACING_MAX_MULTIPLE = 1.6
GHOST_TEXT_MIN_FONT_SIZE = 96
GHOST_TEXT_MAX_ALPHA = 0.5
GHOST_TEXT_FAINT_MIN_FONT_SIZE = 36
GHOST_TEXT_FAINT_MAX_ALPHA = 0.35
# Sub-pixel canvas overflow is floating-point rounding noise (e.g. rotated-bbox math), not a
# visible defect; keep this well under 1px so real overflow is still always caught.
CANVAS_OVERFLOW_TOLERANCE = 0.5
@@ -106,6 +117,52 @@ def extract_numeric_attribute(tag_source: str, name: str) -> int | float | None:
return int(value) if value.is_integer() else value
def extract_bool_attribute(tag_source: str, name: str) -> bool:
value = extract_attribute(tag_source, name)
return value in {"true", "1", "yes"}
def extract_color_alpha(color: str | None) -> int | float | None:
if color is None:
return None
normalized = re.sub(r"\s+", "", color).lower()
if normalized == "transparent":
return 0
rgba_match = re.fullmatch(
r"rgba\([^,]+,[^,]+,[^,]+,([+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+))\)",
normalized,
)
if rgba_match is None:
return None
try:
alpha = float(rgba_match.group(1))
except ValueError:
return None
return int(alpha) if alpha.is_integer() else alpha
def effective_text_alpha(shape_alpha: int | float | None, text_color: str | None) -> int | float:
base_alpha = shape_alpha if isinstance(shape_alpha, (int, float)) else 1
color_alpha = extract_color_alpha(text_color)
if not isinstance(color_alpha, (int, float)):
return base_alpha
return base_alpha * color_alpha
def detect_inline_style_presence(content_xml: str, style_tags: set[str]) -> bool:
for tag_name in style_tags:
if re.search(fr"<{re.escape(tag_name)}\b[\s>]", content_xml) is not None:
return True
return False
def detect_any_span_bool_attribute(content_xml: str, attr_name: str) -> bool:
for attrs in re.findall(r"<span\b([^>]*)>", content_xml):
if extract_bool_attribute(attrs, attr_name):
return True
return False
def sum_sizes(sizes: list[int | float]) -> int | float:
return sum(sizes)
@@ -218,6 +275,7 @@ def extract_text_paragraphs(value: str, default_font_size: int | float) -> list[
"lineSpacing": extract_attribute(attrs, "lineSpacing"),
"beforeLineSpacing": extract_attribute(attrs, "beforeLineSpacing"),
"afterLineSpacing": extract_attribute(attrs, "afterLineSpacing"),
"letterSpacing": extract_numeric_attribute(attrs, "letterSpacing"),
}
)
return paragraphs
@@ -694,6 +752,20 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
font_size = extract_numeric_attribute(content_attrs, "fontSize")
if font_size is None:
font_size = extract_numeric_attribute(attrs, "fontSize")
font_family = extract_attribute(content_attrs, "fontFamily") or extract_attribute(attrs, "fontFamily")
text_color = extract_attribute(content_attrs, "color") or extract_attribute(attrs, "color")
bold = (
extract_bool_attribute(content_attrs, "bold")
or extract_bool_attribute(attrs, "bold")
or detect_inline_style_presence(content, {"strong", "b"})
or detect_any_span_bool_attribute(content, "bold")
)
italic = (
extract_bool_attribute(content_attrs, "italic")
or extract_bool_attribute(attrs, "italic")
or detect_inline_style_presence(content, {"i", "em"})
or detect_any_span_bool_attribute(content, "italic")
)
element.update(
{
"textType": extract_attribute(content_attrs, "textType"),
@@ -705,11 +777,17 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
"lineSpacing": extract_attribute(content_attrs, "lineSpacing"),
"beforeLineSpacing": extract_attribute(content_attrs, "beforeLineSpacing"),
"afterLineSpacing": extract_attribute(content_attrs, "afterLineSpacing"),
"letterSpacing": extract_numeric_attribute(content_attrs, "letterSpacing"),
"paddingTop": extract_numeric_attribute(content_attrs, "paddingTop") or 0,
"paddingRight": extract_numeric_attribute(content_attrs, "paddingRight") or 0,
"paddingBottom": extract_numeric_attribute(content_attrs, "paddingBottom") or 0,
"paddingLeft": extract_numeric_attribute(content_attrs, "paddingLeft") or 0,
"fontSize": font_size if font_size is not None else 16,
"fontFamily": font_family or "",
"color": text_color,
"textAlpha": effective_text_alpha(alpha, text_color),
"bold": bold,
"italic": italic,
"text": strip_xml_paragraphs(content),
"paragraphs": extract_text_paragraphs(content, font_size if font_size is not None else 16),
}
@@ -745,7 +823,11 @@ def is_vertical_text(element: dict[str, Any]) -> bool:
def detect_image_text_occlusions(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
text_elements = [element for element in elements if is_text_element(element) and has_text_content(element)]
text_elements = [
element
for element in elements
if is_text_element(element) and has_text_content(element) and not is_ghost_text(element)
]
image_elements = [element for element in elements if element["kind"] == "img" and element["alpha"] > 0]
for text_element in text_elements:
for image_element in image_elements:
@@ -782,22 +864,135 @@ def normalize_text_for_overlap(text: str) -> str:
return re.sub(r"\s+", "", text)
def estimate_character_width(character: str, font_size: int | float) -> int | float:
SERIF_FONT_PATTERNS = {
"song", "songti", "simsun", "ming", "mincho",
"georgia", "times", "caslon", "garamond", "sourcehan-serif",
"source han serif", "思源宋体", "宋体", "明体",
}
SANS_EXPLICIT_MARKERS = {"sans", "sans-serif", "sans serif", "sourcehan-sans", "source han sans", "思源黑体", "黑体",
"helvetica", "arial", "inter", "roboto", "verdana", "tahoma", "calibri", "open sans"}
def classify_font_family(font_family: str | None) -> str:
if not font_family:
return "sans"
family_lower = font_family.lower()
for marker in SANS_EXPLICIT_MARKERS:
if marker in family_lower:
return "sans"
serif_keywords = SERIF_FONT_PATTERNS | {"serif"}
for pattern in serif_keywords:
if pattern in family_lower:
return "serif"
return "sans"
_FONT_CATEGORY_MULTIPLIERS: dict[str, dict[str, float]] = {
"sans": {"upper": 0.57, "lower": 0.51, "digit": 0.58, "punct": 0.50},
"serif": {"upper": 0.57, "lower": 0.53, "digit": 0.58, "punct": 0.50},
}
def estimate_character_width(
character: str,
font_size: int | float,
bold: bool = False,
font_family: str | None = None,
) -> int | float:
bold_multiplier = 1.05 if bold else 1.0
if character.isspace():
return font_size * 0.33
if unicodedata.east_asian_width(character) in {"F", "W"}:
return font_size
return font_size * 0.55
return font_size * 0.33 * bold_multiplier
ea_width = unicodedata.east_asian_width(character)
if ea_width in {"F", "W"}:
return font_size * bold_multiplier
category = classify_font_family(font_family)
coeffs = _FONT_CATEGORY_MULTIPLIERS[category]
if character.isupper():
return font_size * coeffs["upper"] * bold_multiplier
if character.islower():
return font_size * coeffs["lower"] * bold_multiplier
if character.isdigit():
return font_size * coeffs["digit"] * bold_multiplier
return font_size * coeffs["punct"] * bold_multiplier
def estimate_text_width(text: str, font_size: int | float) -> int | float:
return sum(estimate_character_width(character, font_size) for character in text)
def estimate_text_width(
text: str,
font_size: int | float,
letter_spacing: int | float = 0,
bold: bool = False,
font_family: str | None = None,
) -> int | float:
base = sum(estimate_character_width(character, font_size, bold, font_family) for character in text)
return base + max(len(text) - 1, 0) * letter_spacing
def resolve_letter_spacing(element: dict[str, Any], paragraph: dict[str, Any] | None = None) -> int | float:
if paragraph is not None:
value = paragraph.get("letterSpacing")
if isinstance(value, (int, float)):
return value
value = element.get("letterSpacing")
return value if isinstance(value, (int, float)) else 0
def text_wrap_width_tolerance() -> int | float:
return TEXT_WRAP_WIDTH_TOLERANCE_PX
def text_height_overflow_tolerance() -> int | float:
return TEXT_HEIGHT_OVERFLOW_TOLERANCE_PX
def has_explicit_height_auto_fit(element: dict[str, Any]) -> bool:
return element.get("autoFit") in {"normal-auto-fit", "shape-auto-fit"}
def is_short_metric_text(text: str) -> bool:
compact = re.sub(r"\s+", "", text)
if not compact or len(compact) > 16 or re.search(r"\d", compact) is None:
return False
if re.fullmatch(r"[+\-–—]?[0-9,.]+[\u4e00-\u9fffA-Za-z]{1,4}", compact):
return True
if re.search(r"[,.+\-–—/%]", compact) is None:
return False
return re.fullmatch(r"[+\-–—]?[0-9A-Za-z,./%\-–—\u4e00-\u9fff]+", compact) is not None
def is_single_line_visual_candidate(
element: dict[str, Any],
paragraph: dict[str, Any] | None,
text: str,
logical_width: int | float,
effective_width: int | float,
) -> bool:
if "\n" in text or logical_width <= effective_width:
return False
if is_short_metric_text(text):
return logical_width <= effective_width * SINGLE_LINE_METRIC_WIDTH_RATIO
text_align = (paragraph or {}).get("textAlign") or element.get("textAlign")
compact_len = len(re.sub(r"\s+", "", text))
if text_align == "center" and compact_len <= 32:
return logical_width <= effective_width * CENTERED_SHORT_LABEL_WIDTH_RATIO
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
if element.get("textType") in {"headline", "title"} and font_size <= 30 and compact_len <= 40:
return logical_width <= effective_width * HEADLINE_NEAR_FIT_WIDTH_RATIO
return False
def estimate_text_max_line_width(element: dict[str, Any]) -> int | float:
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
bold = element.get("bold", False)
font_family = element.get("fontFamily", "")
letter_spacing = resolve_letter_spacing(element)
paragraphs = [paragraph for paragraph in re.split(r"\n+", element["text"]) if paragraph]
return max([estimate_text_width(paragraph, font_size) for paragraph in paragraphs] or [1])
return max(
[estimate_text_width(paragraph, font_size, letter_spacing, bold, font_family) for paragraph in paragraphs]
or [1]
)
def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool:
@@ -810,8 +1005,14 @@ def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool
return SequenceMatcher(None, left_text, right_text).ratio() >= 0.75
def estimate_text_line_count_for_text(element: dict[str, Any], text: str) -> int:
def estimate_text_line_count_for_text(
element: dict[str, Any], text: str, paragraph: dict[str, Any] | None = None
) -> int:
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
bold = element.get("bold", False)
font_family = element.get("fontFamily", "")
letter_spacing = resolve_letter_spacing(element, paragraph)
available_width = max(element["width"] - element.get("paddingLeft", 0) - element.get("paddingRight", 0), 1)
hard_lines = text.split("\n")
if not text:
return 0
@@ -820,8 +1021,12 @@ def estimate_text_line_count_for_text(element: dict[str, Any], text: str) -> int
if element.get("wrap") in {"false", "0"}:
line_count += 1
continue
logical_width = max(estimate_text_width(hard_line, font_size), 1)
line_count += max(1, math.ceil(logical_width / max(element["width"], 1)))
logical_width = max(estimate_text_width(hard_line, font_size, letter_spacing, bold, font_family), 1)
effective_width = available_width + text_wrap_width_tolerance()
if is_single_line_visual_candidate(element, paragraph, hard_line, logical_width, effective_width):
line_count += 1
continue
line_count += max(1, math.ceil(logical_width / effective_width))
return line_count
@@ -831,7 +1036,8 @@ def estimate_text_line_count(element: dict[str, Any]) -> int:
def estimate_text_line_height(element: dict[str, Any], line_spacing: str | None = None) -> int | float | None:
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
line_spacing = line_spacing or "multiple:1.5"
if line_spacing is None:
return font_size * DEFAULT_TEXT_LINE_SPACING_MULTIPLE
match = re.fullmatch(r"(multiple|fixed):([0-9]+(?:\.[0-9]+)?)", line_spacing)
if match is None:
return None
@@ -839,12 +1045,27 @@ def estimate_text_line_height(element: dict[str, Any], line_spacing: str | None
return font_size * float(value) if spacing_type == "multiple" else float(value)
def adjust_dense_body_line_height(
element: dict[str, Any],
line_spacing: str | None,
line_height: int | float,
paragraph_count: int,
) -> int | float:
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
if paragraph_count < 4 or font_size > 14 or not line_spacing:
return line_height
match = re.fullmatch(r"multiple:([0-9]+(?:\.[0-9]+)?)", line_spacing)
if match is None:
return line_height
return min(line_height, font_size * min(float(match.group(1)), DENSE_BODY_LINE_SPACING_MAX_MULTIPLE))
def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
for element in elements:
if not is_text_element(element) or not has_text_content(element):
continue
if element.get("autoFit") in {"normal-auto-fit", "shape-auto-fit"}:
if has_explicit_height_auto_fit(element):
continue
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
@@ -860,10 +1081,11 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
estimated_height = 0.0
line_heights: list[int | float] = []
for paragraph in paragraphs:
paragraph_line_count = estimate_text_line_count_for_text(element, paragraph["text"])
paragraph_line_count = estimate_text_line_count_for_text(element, paragraph["text"], paragraph)
if paragraph_line_count == 0:
continue
line_height = estimate_text_line_height(element, paragraph["lineSpacing"] or element["lineSpacing"])
resolved_line_spacing = paragraph["lineSpacing"] or element["lineSpacing"]
line_height = estimate_text_line_height(element, resolved_line_spacing)
before_spacing = estimate_text_line_height(
element, paragraph["beforeLineSpacing"] or element["beforeLineSpacing"] or "fixed:0"
)
@@ -873,6 +1095,7 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
if line_height is None or before_spacing is None or after_spacing is None:
line_count = 0
break
line_height = adjust_dense_body_line_height(element, resolved_line_spacing, line_height, len(paragraphs))
first_line_height = font_size if line_count == 0 else line_height
line_count += paragraph_line_count
line_heights.append(line_height)
@@ -883,12 +1106,24 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
continue
available_height = max(element["height"] - element["paddingTop"] - element["paddingBottom"], 0)
overflow = estimated_height - available_height
if overflow <= 0:
if overflow <= text_height_overflow_tolerance():
continue
is_background = is_background_decorative_text(element, elements)
if is_background:
level = "info"
else:
level = "error" if overflow > 10 else "warning"
message = (
f'text shape {element["id"]} may overflow its own content box '
f'(estimated {estimated_height:g}px, available {available_height:g}px); '
'consider setting content wrap="true" autoFit="normal-auto-fit"'
)
if is_background:
message += " (likely background decoration: large font, low alpha, underneath other text)"
issues.append(
{
"level": "warning",
"level": level,
"code": "text_may_overflow_shape",
"elements": [element["id"]],
"line_count": line_count,
@@ -896,11 +1131,7 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
"estimated_height": estimated_height,
"available_height": available_height,
"overflow": overflow,
"message": (
f'text shape {element["id"]} may overflow its own content box '
f'(estimated {estimated_height:g}px, available {available_height:g}px); '
'consider setting content wrap="true" autoFit="normal-auto-fit"'
),
"message": message,
"hint": (
"Increase shape.height, reduce the text, or set content wrap=\"true\" "
"autoFit=\"normal-auto-fit\". "
@@ -911,6 +1142,38 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
return issues
def is_background_decorative_text(
element: dict[str, Any], elements: list[dict[str, Any]]
) -> bool:
if not is_ghost_text(element):
return False
for other in elements:
if other is element:
continue
if not is_text_element(other) or not has_text_content(other):
continue
foreground_alpha = other.get("textAlpha", other.get("alpha", 1))
if not isinstance(foreground_alpha, (int, float)) or foreground_alpha <= 0:
continue
if other["order"] <= element["order"]:
continue
if intersects(element, other):
return True
return False
def is_ghost_text(element: dict[str, Any]) -> bool:
if not is_text_element(element) or not has_text_content(element):
return False
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
text_alpha = element.get("textAlpha", element.get("alpha", 1))
if not isinstance(text_alpha, (int, float)):
return False
if font_size > GHOST_TEXT_MIN_FONT_SIZE and text_alpha < GHOST_TEXT_MAX_ALPHA:
return True
return font_size >= GHOST_TEXT_FAINT_MIN_FONT_SIZE and text_alpha < GHOST_TEXT_FAINT_MAX_ALPHA
def estimate_text_visual_bbox(element: dict[str, Any]) -> dict[str, int | float] | None:
if not is_text_element(element) or not has_text_content(element) or is_decorative_text(element):
return None
@@ -1022,6 +1285,8 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
return False
if not (has_text_content(left) and has_text_content(right)):
return False
if is_ghost_text(left) or is_ghost_text(right):
return False
if is_template_text_stack(left, right) or is_similar_text_overlay(left, right):
return False
@@ -1038,13 +1303,16 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
return False
font_size = source["fontSize"] if isinstance(source["fontSize"], (int, float)) else 16
padding_left = source.get("paddingLeft", 0)
padding_right = source.get("paddingRight", 0)
available_width = max(source["width"] - padding_left - padding_right, 1)
visual_width = estimate_text_max_line_width(source)
overflow_width = visual_width - source["width"]
min_overflow = max(font_size * 1.5, source["width"] * 0.08)
overflow_width = visual_width - available_width
min_overflow = max(font_size * 1.5, available_width * 0.08)
if overflow_width < min_overflow:
return False
intrusion_width = source["x"] + visual_width - target["x"]
intrusion_width = source["x"] + padding_left + visual_width - target["x"]
min_intrusion = max(font_size * 1.5, target["width"] * 0.08)
if intrusion_width < min_intrusion:
return False
@@ -1056,8 +1324,9 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
def horizontal_text_overflow_measurement(left: dict[str, Any], right: dict[str, Any]) -> dict[str, int | float]:
source, target = sorted([left, right], key=lambda element: element["x"])
padding_left = source.get("paddingLeft", 0)
visual_width = estimate_text_max_line_width(source)
source_visual_bbox = {"x": source["x"], "y": source["y"], "width": visual_width, "height": source["height"]}
source_visual_bbox = {"x": source["x"] + padding_left, "y": source["y"], "width": visual_width, "height": source["height"]}
width = intersection_width(source_visual_bbox, target)
height = intersection_height(source_visual_bbox, target)
return {
@@ -1072,6 +1341,8 @@ def should_flag_overlap(left: dict[str, Any], right: dict[str, Any]) -> bool:
return False
if is_text_element(right) and not has_text_content(right):
return False
if is_ghost_text(left) or is_ghost_text(right):
return False
if is_template_text_stack(left, right):
return False
if is_text_element(left) and is_text_element(right):
@@ -1118,6 +1389,8 @@ def should_report_whiteboard_overlap(
) -> dict[str, Any] | None:
if other is whiteboard or not intersects(whiteboard, other):
return None
if is_ghost_text(other):
return None
if contains(whiteboard, other):
return None
if is_bottom_layer_full_slide_whiteboard(whiteboard, other, slide_width, slide_height):
@@ -1194,6 +1467,8 @@ def detect_whiteboard_external_overlaps(
def element_canvas_bbox(element: dict[str, Any]) -> dict[str, int | float]:
bbox = {key: element[key] for key in ("x", "y", "width", "height")}
if element["kind"] != "chart" and not (element["kind"] == "shape" and element["type"] == "text"):
return bbox
rotation = element["rotation"]
if not isinstance(rotation, (int, float)) or not math.isfinite(rotation):
rotation = 0
@@ -1219,7 +1494,14 @@ def detect_elements_out_of_canvas(
elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
for element in elements:
for element in (
element
for element in elements
if element["kind"] in {"table", "chart"}
or (element["kind"] == "shape" and element["type"] in {"rect", "text"})
):
if is_ghost_text(element):
continue
bbox = element_canvas_bbox(element)
overflow = {
"left": max(-bbox["x"], 0),
@@ -1976,8 +2258,6 @@ def normalize_issue(
elements_by_id: dict[str, dict[str, Any]],
) -> dict[str, Any]:
normalized = dict(issue)
if normalized.get("level") == "info":
normalized["level"] = "warning"
element_ids = list(dict.fromkeys(normalized.get("elements", [])))
normalized["schema_version"] = "2.0"
normalized["element_ids"] = element_ids
@@ -2039,8 +2319,10 @@ def build_result(
) -> dict[str, Any]:
document_errors = [issue for issue in top_level_issues if issue["level"] == "error"]
document_warnings = [issue for issue in top_level_issues if issue["level"] == "warning"]
document_infos = [issue for issue in top_level_issues if issue["level"] == "info"]
error_count = len(document_errors) + sum(len(slide["errors"]) for slide in slides)
warning_count = len(document_warnings) + sum(len(slide["warnings"]) for slide in slides)
info_count = len(document_infos) + sum(len(slide["infos"]) for slide in slides)
all_errors = document_errors + [issue for slide in slides for issue in slide["errors"]]
all_warnings = document_warnings + [issue for slide in slides for issue in slide["warnings"]]
status = slide_status(all_errors, all_warnings)
@@ -2053,6 +2335,7 @@ def build_result(
"slide_count": len(slides),
"error_count": error_count,
"warning_count": warning_count,
"info_count": info_count,
"status": status,
"release_ready": error_count == 0,
"screenshot_review_required": warning_count > 0,
@@ -2060,6 +2343,7 @@ def build_result(
"document": {
"errors": document_errors,
"warnings": document_warnings,
"infos": document_infos,
},
"slides": slides,
}
@@ -2150,6 +2434,7 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
]
errors = [issue for issue in issues if issue["level"] == "error"]
warnings = [issue for issue in issues if issue["level"] == "warning"]
infos = [issue for issue in issues if issue["level"] == "info"]
slides.append(
{
"slide_number": slide_number,
@@ -2157,6 +2442,7 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
"element_count": len(elements_by_id),
"errors": errors,
"warnings": warnings,
"infos": infos,
"issues": issues,
}
)

View File

@@ -343,6 +343,26 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(result["summary"]["error_count"], 0)
self.assertNotIn("issues", result)
def test_lint_xml_ignores_chart_parsed_values_roundtrip_tag(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<chart topLeftX="80" topLeftY="80" width="300" height="160">
<chartData>
<chartField>
<chartParsedValues>Africa</chartParsedValues>
</chartField>
</chartData>
</chart>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertNotIn("issues", result)
def test_lint_xml_limits_chart_roundtrip_attrs_to_matching_tags(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -637,9 +657,10 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 1)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["slides"][0]["issues"][0]["code"], "text_may_overflow_shape")
self.assertEqual(result["slides"][0]["issues"][0]["level"], "error")
self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["source"])
def test_lint_xml_reports_text_out_of_canvas_and_warns_for_text_height(self) -> None:
@@ -660,8 +681,8 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"""
)
issue = result["slides"][0]["issues"][0]
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["summary"]["warning_count"], 1)
self.assertEqual(result["summary"]["error_count"], 2)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(issue["code"], "shape_out_of_canvas")
self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 160, "bottom": 40})
@@ -671,7 +692,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="overflowing" type="text" topLeftX="80" topLeftY="80" width="360" height="80">
<content fontSize="20" lineSpacing="multiple:1.5">
<content fontSize="20" lineSpacing="multiple:1.5" autoFit="no-auto-fit">
<p>第一段</p><p>第二段</p><p>第三段</p><p>第四段</p>
</content>
</shape>
@@ -685,20 +706,29 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
<p>第一段</p><p>第二段</p><p>第三段</p><p>第四段</p>
</content>
</shape>
<shape id="shape-auto-fit" type="text" topLeftX="480" topLeftY="240" width="360" height="30">
<content fontSize="20" lineSpacing="multiple:1.5" autoFit="shape-auto-fit">
<p>第一段</p><p>第二段</p><p>第三段</p>
</content>
</shape>
</data>
</slide>
"""
)
issues = result["slides"][0]["issues"]
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 1)
self.assertEqual(issues[0]["code"], "text_may_overflow_shape")
self.assertEqual(issues[0]["elements"], ["overflowing"])
self.assertEqual(issues[0]["line_count"], 4)
self.assertEqual(issues[0]["estimated_height"], 110)
self.assertEqual(issues[0]["available_height"], 80)
self.assertEqual(issues[0]["overflow"], 30)
self.assertIn('wrap="true" autoFit="normal-auto-fit"', issues[0]["message"])
overflow_issues = [issue for issue in issues if issue["code"] == "text_may_overflow_shape"]
self.assertEqual(result["summary"]["error_count"], 1)
overflow_ids = {issue["elements"][0] for issue in overflow_issues}
self.assertIn("overflowing", overflow_ids)
self.assertNotIn("auto-fit", overflow_ids)
self.assertNotIn("shape-auto-fit", overflow_ids)
self.assertNotIn("fitting", overflow_ids)
overflowing_issue = next(issue for issue in overflow_issues if issue["elements"] == ["overflowing"])
self.assertEqual(overflowing_issue["line_count"], 4)
self.assertEqual(overflowing_issue["estimated_height"], 110)
self.assertEqual(overflowing_issue["available_height"], 80)
self.assertEqual(overflowing_issue["overflow"], 30)
self.assertIn('wrap="true" autoFit="normal-auto-fit"', overflowing_issue["message"])
def test_lint_xml_uses_fixed_line_spacing_for_text_height_warning(self) -> None:
result = xml_text_overlap_lint.lint_xml(
@@ -706,7 +736,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="fixed-overflow" type="text" topLeftX="80" topLeftY="80" width="360" height="50">
<content fontSize="20" lineSpacing="fixed:20">
<content fontSize="20" lineSpacing="fixed:20" autoFit="no-auto-fit">
<p>第一段</p><p>第二段</p><p>第三段</p>
</content>
</shape>
@@ -716,17 +746,525 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
)
issue = result["slides"][0]["issues"][0]
self.assertEqual(result["summary"]["warning_count"], 1)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(issue["level"], "warning")
self.assertEqual(issue["line_height"], 20)
self.assertEqual(issue["estimated_height"], 60)
self.assertEqual(issue["overflow"], 10)
def test_lint_xml_ignores_subpixel_text_height_overflow_tolerance(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="minor-overflow" type="text" topLeftX="80" topLeftY="80" width="360" height="39.8">
<content fontSize="20" lineSpacing="fixed:20" autoFit="no-auto-fit">
<p>第一段</p><p>第二段</p>
</content>
</shape>
</data>
</slide>
"""
)
overflow_issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
]
self.assertEqual(overflow_issues, [])
def test_lint_xml_allows_single_line_width_estimation_jitter(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="metric" type="text" topLeftX="80" topLeftY="80" width="152" height="54">
<content fontSize="36" lineSpacing="multiple:1.2" autoFit="no-auto-fit"><p>4.16万亿</p></content>
</shape>
</data>
</slide>
"""
)
overflow_issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
]
self.assertEqual(overflow_issues, [])
def test_lint_xml_allows_short_metric_text_with_separators_as_single_line(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="metric" type="text" topLeftX="80" topLeftY="80" width="150" height="50">
<content textType="title" fontSize="36" autoFit="no-auto-fit"><p>4.16万亿</p></content>
</shape>
<shape id="table-number" type="text" topLeftX="80" topLeftY="160" width="25" height="20">
<content fontSize="10" textAlign="center" autoFit="no-auto-fit"><p>1,380</p></content>
</shape>
</data>
</slide>
"""
)
overflow_issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
]
self.assertEqual(overflow_issues, [])
def test_lint_xml_reports_plain_short_metric_when_it_wraps(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="plain-age" type="text" topLeftX="80" topLeftY="80" width="50" height="80">
<content textType="title" fontSize="36" bold="true" autoFit="no-auto-fit"><p>82岁</p></content>
</shape>
</data>
</slide>
"""
)
overflow_issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
]
self.assertEqual(len(overflow_issues), 1)
self.assertEqual(overflow_issues[0]["elements"], ["plain-age"])
def test_lint_xml_allows_centered_short_label_near_fit_as_single_line(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="centered-label" type="text" topLeftX="80" topLeftY="80" width="200" height="30">
<content fontSize="14" bold="true" textAlign="center" autoFit="no-auto-fit">
<p>参数服务器 (Parameter Server)</p>
</content>
</shape>
</data>
</slide>
"""
)
overflow_issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
]
self.assertEqual(overflow_issues, [])
def test_lint_xml_allows_headline_near_fit_as_single_line(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="headline" type="text" topLeftX="80" topLeftY="80" width="700" height="50">
<content textType="headline" fontSize="26" bold="true" lineSpacing="multiple:1.3" autoFit="no-auto-fit">
<p>全球半导体市场规模持续高速增长AI驱动新一轮景气周期</p>
</content>
</shape>
</data>
</slide>
"""
)
overflow_issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
]
self.assertEqual(overflow_issues, [])
def test_lint_xml_allows_dense_body_line_spacing_estimation_slack(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="dense-body" type="text" topLeftX="80" topLeftY="80" width="360" height="140">
<content fontSize="13" bold="true" lineSpacing="multiple:1.7" autoFit="no-auto-fit">
<p>总体目标:</p>
<p>建立深度神经网络高效训练的统一理论框架,实现训练效率与模型性能的协同优化。</p>
<p>具体目标:</p>
<p>提出自适应优化算法,收敛速度提升 2-3 倍</p>
<p>实现结构化压缩方法,模型体积减少 10 倍以上</p>
<p>构建分布式训练策略64 GPU 加速比 &gt; 50x</p>
</content>
</shape>
</data>
</slide>
"""
)
overflow_issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
]
self.assertEqual(overflow_issues, [])
def test_lint_xml_reports_dense_body_when_adjusted_height_still_overflows(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="dense-body" type="text" topLeftX="80" topLeftY="80" width="360" height="100">
<content fontSize="13" bold="true" lineSpacing="multiple:1.7" autoFit="no-auto-fit">
<p>总体目标:</p>
<p>建立深度神经网络高效训练的统一理论框架,实现训练效率与模型性能的协同优化。</p>
<p>具体目标:</p>
<p>提出自适应优化算法,收敛速度提升 2-3 倍</p>
<p>实现结构化压缩方法,模型体积减少 10 倍以上</p>
<p>构建分布式训练策略64 GPU 加速比 &gt; 50x</p>
</content>
</shape>
</data>
</slide>
"""
)
overflow_issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
]
self.assertEqual(len(overflow_issues), 1)
self.assertEqual(overflow_issues[0]["elements"], ["dense-body"])
def test_lint_xml_reports_letter_spaced_caption_near_fit(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="caption" type="text" topLeftX="80" topLeftY="80" width="120" height="20">
<content textType="caption" fontSize="11" letterSpacing="1" autoFit="no-auto-fit">
<p>RISKS &amp; CHALLENGES</p>
</content>
</shape>
</data>
</slide>
"""
)
overflow_issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
]
self.assertEqual(len(overflow_issues), 1)
self.assertEqual(overflow_issues[0]["elements"], ["caption"])
def test_lint_xml_reports_micro_caption_when_wrapping_overflows(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="micro-caption" type="text" topLeftX="80" topLeftY="60" width="200" height="16">
<content textType="caption" fontSize="3" lineSpacing="multiple:1.3" letterSpacing="160" autoFit="no-auto-fit">
<p>MARKET INSIGHT · 市场洞察</p>
</content>
</shape>
</data>
</slide>
"""
)
overflow_issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
]
self.assertEqual(len(overflow_issues), 1)
self.assertEqual(overflow_issues[0]["elements"], ["micro-caption"])
def test_lint_xml_text_may_overflow_shape_upgrades_to_error_above_threshold(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="just-warning" type="text" topLeftX="80" topLeftY="80" width="360" height="50">
<content fontSize="20" lineSpacing="fixed:20" autoFit="no-auto-fit">
<p>第一段</p><p>第二段</p><p>第三段</p>
</content>
</shape>
<shape id="error-overflow" type="text" topLeftX="80" topLeftY="200" width="360" height="30">
<content fontSize="20" lineSpacing="fixed:20" autoFit="no-auto-fit">
<p>第一段</p><p>第二段</p><p>第三段</p>
</content>
</shape>
</data>
</slide>
"""
)
issues = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
self.assertEqual(issues["just-warning"]["level"], "warning")
self.assertEqual(issues["just-warning"]["overflow"], 10)
self.assertEqual(issues["error-overflow"]["level"], "error")
self.assertEqual(issues["error-overflow"]["overflow"], 30)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["summary"]["warning_count"], 1)
def test_lint_xml_text_may_overflow_shape_downgrades_background_decoration_to_info(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="bg-deco" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
<content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
</shape>
<shape id="foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60">
<content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
</shape>
</data>
</slide>
"""
)
issues = {
issue["elements"][0]: issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
}
self.assertEqual(issues["bg-deco"]["level"], "info")
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["summary"]["info_count"], 1)
self.assertEqual(result["slides"][0]["infos"], [issues["bg-deco"]])
self.assertIn("background decoration", issues["bg-deco"]["message"])
def test_lint_xml_allows_shape_alpha_ghost_text_out_of_canvas_and_overlap(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="ghost-number" type="text" topLeftX="-60" topLeftY="30" width="360" height="180" alpha="0.2">
<content fontSize="160" lineSpacing="fixed:160" wrap="false"><p>01</p></content>
</shape>
<shape id="title" type="text" topLeftX="80" topLeftY="80" width="360" height="80">
<content fontSize="30" lineSpacing="fixed:36"><p>Annual Review</p></content>
</shape>
</data>
</slide>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertEqual(result["summary"]["error_count"], 0)
self.assertNotIn("shape_out_of_canvas", codes)
self.assertNotIn("bbox_overlap", codes)
def test_lint_xml_allows_content_color_alpha_ghost_text_out_of_canvas_and_overlap(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="ghost-year" type="text" topLeftX="760" topLeftY="20" width="260" height="160">
<content fontSize="140" color="rgba(0,0,0,0.2)" lineSpacing="fixed:140" wrap="false"><p>2026</p></content>
</shape>
<shape id="headline" type="text" topLeftX="700" topLeftY="70" width="220" height="80">
<content fontSize="28" lineSpacing="fixed:34"><p>Forecast</p></content>
</shape>
</data>
</slide>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertEqual(result["summary"]["error_count"], 0)
self.assertNotIn("shape_out_of_canvas", codes)
self.assertNotIn("bbox_overlap", codes)
def test_lint_xml_allows_faint_medium_ghost_text_out_of_canvas_and_overlap(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="medium-ghost" type="text" topLeftX="820" topLeftY="300" width="270" height="72" alpha="0.32">
<content fontSize="40" lineSpacing="fixed:40" wrap="false"><p>OFF EDGE</p></content>
</shape>
<shape id="caption" type="text" topLeftX="760" topLeftY="315" width="180" height="36">
<content fontSize="16" lineSpacing="fixed:20"><p>Readable caption</p></content>
</shape>
</data>
</slide>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertEqual(result["summary"]["error_count"], 0)
self.assertNotIn("shape_out_of_canvas", codes)
self.assertNotIn("bbox_overlap", codes)
def test_lint_xml_allows_ghost_text_image_overlap(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="ghost-label" type="text" topLeftX="100" topLeftY="40" width="560" height="160" alpha="0.2">
<content fontSize="120" lineSpacing="fixed:120" wrap="false"><p>2026</p></content>
</shape>
<img id="photo" src="token" topLeftX="160" topLeftY="70" width="260" height="160"/>
<shape id="title" type="text" topLeftX="610" topLeftY="95" width="320" height="60">
<content fontSize="28" lineSpacing="fixed:34"><p>Annual Review</p></content>
</shape>
</data>
</slide>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertNotIn("image_covers_text", codes)
self.assertNotIn("bbox_overlap", codes)
def test_lint_slide_allows_ghost_text_whiteboard_overlap(self) -> None:
result = xml_text_overlap_lint.lint_slide(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<whiteboard id="board" topLeftX="180" topLeftY="70" width="420" height="300"/>
<shape id="ghost-label" type="text" topLeftX="100" topLeftY="40" width="560" height="160" alpha="0.2">
<content fontSize="120" lineSpacing="fixed:120" wrap="false"><p>2026</p></content>
</shape>
<shape id="title" type="text" topLeftX="610" topLeftY="95" width="220" height="60">
<content fontSize="28" lineSpacing="fixed:34"><p>Annual Review</p></content>
</shape>
</data>
</slide>
""",
1,
)
codes = [issue["code"] for issue in result["issues"]]
self.assertNotIn("whiteboard_external_overlap", codes)
def test_lint_xml_allows_faint_ghost_text_without_area_threshold(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="small-ghost" type="text" topLeftX="940" topLeftY="300" width="40" height="40" alpha="0.32">
<content fontSize="36" lineSpacing="fixed:36" wrap="false"><p>土</p></content>
</shape>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["slides"][0]["issues"], [])
def test_lint_xml_keeps_out_of_canvas_error_for_medium_text_without_faint_alpha(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="medium-not-ghost" type="text" topLeftX="820" topLeftY="300" width="270" height="72" alpha="0.36">
<content fontSize="54" lineSpacing="fixed:54" wrap="false"><p>OFF EDGE</p></content>
</shape>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["slides"][0]["issues"][0]["code"], "shape_out_of_canvas")
self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["medium-not-ghost"])
def test_lint_xml_keeps_out_of_canvas_error_for_half_alpha_large_text(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="half-alpha" type="text" topLeftX="760" topLeftY="20" width="260" height="160">
<content fontSize="140" color="rgba(0,0,0,0.5)" lineSpacing="fixed:140" wrap="false"><p>2026</p></content>
</shape>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["slides"][0]["issues"][0]["code"], "shape_out_of_canvas")
self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["half-alpha"])
def test_lint_xml_text_may_overflow_shape_keeps_error_when_alpha_not_low(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="opaque-big" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.9">
<content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
</shape>
<shape id="foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60">
<content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
</shape>
</data>
</slide>
"""
)
issue = next(
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["opaque-big"]
)
self.assertEqual(issue["level"], "error")
def test_lint_xml_text_may_overflow_shape_keeps_error_when_no_foreground_text(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="lonely-big" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
<content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
</shape>
</data>
</slide>
"""
)
issue = next(
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["lonely-big"]
)
self.assertEqual(issue["level"], "error")
def test_lint_xml_text_may_overflow_shape_keeps_error_when_foreground_alpha_zero(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="bg-deco" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
<content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
</shape>
<shape id="transparent-foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60" alpha="0">
<content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
</shape>
</data>
</slide>
"""
)
issue = next(
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["bg-deco"]
)
self.assertEqual(issue["level"], "error")
def test_lint_xml_text_may_overflow_shape_keeps_error_when_foreground_is_below_in_order(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60">
<content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
</shape>
<shape id="top-big" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
<content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
</shape>
</data>
</slide>
"""
)
issue = next(
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["top-big"]
)
self.assertEqual(issue["level"], "error")
def test_lint_xml_uses_paragraph_spacing_overrides_for_text_height_warning(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="paragraph-overflow" type="text" topLeftX="80" topLeftY="80" width="360" height="35">
<content fontSize="20" lineSpacing="multiple:1.5">
<shape id="paragraph-overflow" type="text" topLeftX="80" topLeftY="80" width="360" height="30">
<content fontSize="20" lineSpacing="multiple:1.5" autoFit="no-auto-fit">
<p lineSpacing="fixed:10" beforeLineSpacing="fixed:5" afterLineSpacing="fixed:5">第一行<br/>第二行</p>
</content>
</shape>
@@ -745,7 +1283,36 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(issues[0]["line_count"], 2)
self.assertEqual(issues[0]["line_height"], 10)
self.assertEqual(issues[0]["estimated_height"], 40)
self.assertEqual(issues[0]["overflow"], 5)
self.assertEqual(issues[0]["overflow"], 10)
def test_lint_xml_uses_letter_spacing_for_text_overflow_warning(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="baseline" type="text" topLeftX="0" topLeftY="0" width="120" height="30">
<content fontSize="20" lineSpacing="multiple:1.5" autoFit="no-auto-fit"><p>一二三四五六</p></content>
</shape>
<shape id="content-spaced" type="text" topLeftX="200" topLeftY="0" width="120" height="30">
<content fontSize="20" lineSpacing="multiple:1.5" letterSpacing="2" autoFit="no-auto-fit"><p>一二三四五六</p></content>
</shape>
<shape id="paragraph-spaced" type="text" topLeftX="400" topLeftY="0" width="120" height="30">
<content fontSize="20" lineSpacing="multiple:1.5" autoFit="no-auto-fit"><p letterSpacing="2">一二三四五六</p></content>
</shape>
</data>
</slide>
"""
)
issues = result["slides"][0]["issues"]
overflow_ids = [issue["elements"][0] for issue in issues if issue["code"] == "text_may_overflow_shape"]
self.assertNotIn("baseline", overflow_ids)
self.assertIn("content-spaced", overflow_ids)
self.assertIn("paragraph-spaced", overflow_ids)
by_id = {issue["elements"][0]: issue for issue in issues if issue["code"] == "text_may_overflow_shape"}
self.assertEqual(by_id["content-spaced"]["line_count"], 2)
self.assertEqual(by_id["content-spaced"]["estimated_height"], 50)
self.assertEqual(by_id["content-spaced"]["overflow"], 20)
self.assertEqual(by_id["paragraph-spaced"]["line_count"], 2)
def test_strip_xml_paragraphs_preserves_br_as_hard_line_break(self) -> None:
self.assertEqual(
@@ -753,7 +1320,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"第一行\n第二行\n第三行",
)
def test_lint_xml_blocks_template_style_bleed_outside_canvas(self) -> None:
def test_lint_xml_allows_template_style_images_outside_canvas(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -771,9 +1338,8 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["slides"][0]["errors"][0]["code"], "img_out_of_canvas")
def test_extract_elements_preserves_supported_element_geometry_order_and_text_metadata(self) -> None:
elements = xml_text_overlap_lint.extract_elements(
@@ -807,7 +1373,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(elements[1]["fontSize"], 28)
self.assertEqual(elements[1]["text"], "Growth & scale\nFocused execution")
def test_lint_xml_blocks_small_out_of_bounds_images(self) -> None:
def test_lint_xml_ignores_small_out_of_bounds_images(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -819,10 +1385,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["slides"][0]["errors"][0]["code"], "img_out_of_canvas")
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_blocks_out_of_canvas_images(self) -> None:
def test_lint_xml_ignores_out_of_canvas_images(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -835,13 +1400,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 2)
self.assertEqual(
[issue["code"] for issue in result["slides"][0]["errors"]],
["img_out_of_canvas", "img_out_of_canvas"],
)
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_blocks_full_bleed_images_outside_canvas(self) -> None:
def test_lint_xml_ignores_full_bleed_images_outside_canvas(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -853,10 +1414,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["slides"][0]["errors"][0]["code"], "img_out_of_canvas")
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_reports_text_and_chart_out_of_canvas(self) -> None:
def test_lint_xml_reports_text_and_chart_but_not_image_out_of_canvas(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -871,17 +1431,16 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"""
)
issues = result["slides"][0]["issues"]
self.assertEqual(result["summary"]["error_count"], 3)
self.assertEqual(result["summary"]["error_count"], 2)
self.assertEqual(
[(issue["code"], issue["elements"], issue["overflow"]) for issue in issues],
[
("shape_out_of_canvas", ["outside-shape"], {"left": 10, "top": 0, "right": 0, "bottom": 0}),
("img_out_of_canvas", ["outside-img"], {"left": 0, "top": 20, "right": 0, "bottom": 0}),
("chart_out_of_canvas", ["outside-chart"], {"left": 0, "top": 0, "right": 40, "bottom": 0}),
],
)
def test_lint_xml_reports_line_out_of_canvas_with_structured_geometry(self) -> None:
def test_lint_xml_ignores_line_out_of_canvas(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
@@ -895,11 +1454,8 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"""
)
issue = result["slides"][0]["errors"][0]
self.assertEqual(issue["code"], "line_out_of_canvas")
self.assertEqual(issue["element_ids"], ["connector"])
self.assertEqual(issue["measurement"]["overflow"]["right"], 20)
self.assertEqual(issue["related_objects"][0]["kind"], "line")
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["slides"][0]["issues"], [])
def test_lint_xml_uses_rotated_text_and_chart_bounds_for_canvas_validation(self) -> None:
result = xml_text_overlap_lint.lint_xml(
@@ -922,13 +1478,13 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(issues_by_element["rotated-chart"]["code"], "chart_out_of_canvas")
self.assertAlmostEqual(issues_by_element["rotated-chart"]["overflow"]["right"], 20.710678, places=5)
def test_lint_xml_uses_rotated_bounds_for_rect_and_image_canvas_validation(self) -> None:
def test_lint_xml_uses_declared_bounds_for_rect_and_ignores_images(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="rotated-rect" type="rect" topLeftX="0" topLeftY="0" width="100" height="100" rotation="45"/>
<shape id="rotated-rect" type="rect" topLeftX="900" topLeftY="0" width="100" height="100" rotation="45"/>
<img id="rotated-image" topLeftX="860" topLeftY="200" width="100" height="100" rotation="45"/>
</data>
</slide>
@@ -936,12 +1492,54 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"""
)
issues_by_element = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
self.assertEqual(result["summary"]["error_count"], 2)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(issues_by_element["rotated-rect"]["code"], "shape_out_of_canvas")
self.assertAlmostEqual(issues_by_element["rotated-rect"]["overflow"]["left"], 20.710678, places=5)
self.assertAlmostEqual(issues_by_element["rotated-rect"]["overflow"]["top"], 20.710678, places=5)
self.assertEqual(issues_by_element["rotated-image"]["code"], "img_out_of_canvas")
self.assertAlmostEqual(issues_by_element["rotated-image"]["overflow"]["right"], 20.710678, places=5)
self.assertEqual(issues_by_element["rotated-rect"]["overflow"], {"left": 0, "top": 0, "right": 40, "bottom": 0})
self.assertNotIn("rotated-image", issues_by_element)
def test_detect_elements_out_of_canvas_limits_detection_to_whitelist(self) -> None:
issues = xml_text_overlap_lint.detect_elements_out_of_canvas(
[
{"id": "table", "kind": "table", "x": 95, "y": 0, "width": 10, "height": 10, "rotation": 45},
{"id": "chart", "kind": "chart", "x": 95, "y": 0, "width": 10, "height": 10, "rotation": 0},
{
"id": "text",
"kind": "shape",
"type": "text",
"x": 95,
"y": 0,
"width": 10,
"height": 10,
"rotation": 0,
},
{
"id": "rect",
"kind": "shape",
"type": "rect",
"x": 95,
"y": 0,
"width": 10,
"height": 10,
"rotation": 45,
},
{"id": "image", "kind": "img", "x": 95, "y": 0, "width": 10, "height": 10, "rotation": 0},
{
"id": "ellipse",
"kind": "shape",
"type": "ellipse",
"x": 95,
"y": 0,
"width": 10,
"height": 10,
"rotation": 0,
},
],
100,
100,
)
self.assertEqual([issue["elements"] for issue in issues], [["table"], ["chart"], ["text"], ["rect"]])
self.assertEqual(issues[-1]["bbox"], {"x": 95, "y": 0, "width": 10, "height": 10})
def test_lint_xml_treats_non_finite_rotations_as_zero(self) -> None:
result = xml_text_overlap_lint.lint_xml(
@@ -1107,8 +1705,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
)
issues_by_dimension = {issue["dimension"]: issue for issue in result["slides"][0]["issues"]}
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 2)
self.assertEqual(issues_by_dimension["width"]["level"], "warning")
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["summary"]["info_count"], 2)
self.assertEqual(issues_by_dimension["width"]["level"], "info")
self.assertEqual(issues_by_dimension["width"]["code"], "table_resolved_size_mismatch")
self.assertEqual(issues_by_dimension["width"]["resolved_sizes"], [100, 100, 50])
self.assertEqual(issues_by_dimension["width"]["resolved_size"], 250)
@@ -1201,7 +1800,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
}
script_path = Path(xml_text_overlap_lint.__file__).resolve()
with tempfile.TemporaryDirectory() as temp_dir:
for name, (table_xml, expected_warning_count) in cases.items():
for name, (table_xml, expected_info_count) in cases.items():
with self.subTest(case=name):
input_path = Path(temp_dir) / f"{name}.xml"
input_path.write_text(
@@ -1221,9 +1820,10 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
result = json.loads(completed.stdout)
self.assertEqual(completed.returncode, 0, completed.stderr)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], expected_warning_count)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["summary"]["info_count"], expected_info_count)
self.assertTrue(
all(issue["level"] == "warning" for issue in result["slides"][0]["issues"]),
all(issue["level"] == "info" for issue in result["slides"][0]["issues"]),
result["slides"][0]["issues"],
)
@@ -1268,8 +1868,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"""
)
issue = next(issue for issue in result["slides"][0]["issues"] if issue["code"] == "image_may_cover_vertical_text")
self.assertEqual(issue["level"], "warning")
self.assertEqual(issue["level"], "info")
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["info_count"], 1)
class XmlTextOverlapLintDensityTest(unittest.TestCase):
@@ -1357,7 +1958,7 @@ class XmlTextOverlapLintDensityTest(unittest.TestCase):
"id": "sparse_container_content",
})
self.assertEqual(issue["measurement"]["container_area"], 151700)
self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0.032)
self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0.03)
self.assertEqual(issue["elements"], ["trend-card", "trend-title", "trend-copy"])
self.assertEqual(issue["element_ids"], ["trend-card", "trend-title", "trend-copy"])
self.assertEqual(
@@ -1842,9 +2443,9 @@ class XmlTextOverlapLintDensityTest(unittest.TestCase):
# Must match the visual bbox that should_flag_overlap actually decided with (fontSize=14
# from extract_elements), not the fontSize=96 max-descendant value that
# extract_density_elements computes for the same "left" element id.
self.assertEqual(issue["measurement"]["intersection_width"], 117.04)
self.assertEqual(issue["measurement"]["intersection_width"], 109.2)
self.assertEqual(issue["measurement"]["intersection_height"], 6.8)
self.assertEqual(issue["measurement"]["intersection_area"], 795.872)
self.assertEqual(issue["measurement"]["intersection_area"], 742.56)
def test_has_similar_short_card_peer_excludes_the_element_itself(self) -> None:
card_a = {"kind": "shape", "type": "rect", "x": 0, "y": 0, "width": 300, "height": 100}

View File

@@ -24,6 +24,12 @@ lark-cli task +create \
lark-cli task +create \
--summary "Buy milk"
# Create a milestone by passing an API field without a named flag
lark-cli task +create \
--summary "Release v2.0" \
--due "2026-08-15" \
--data '{"is_milestone":true}'
# Preview the API call without executing
lark-cli task +create --summary "Test Task" --dry-run
```
@@ -39,8 +45,11 @@ lark-cli task +create --summary "Test Task" --dry-run
| `--due <time>` | No | Due date. Supports ISO 8601, `YYYY-MM-DD`, relative time (e.g., `+2d`), or ms timestamp. `YYYY-MM-DD` and relative time will automatically set it as an all-day task. |
| `--tasklist-id <id>` | No | The GUID of the tasklist, or a full AppLink URL (the CLI will automatically extract the `guid` parameter from the URL). |
| `--idempotency-key <key>` | No | Client token to ensure idempotency of the request. |
| `--data <json>` | No | JSON object merged into the task create request for API fields without dedicated flags, such as `{"is_milestone":true}`. Explicit named flags override same-named fields in this object. |
| `--dry-run` | No | Preview the API call (JSON payload) without actually creating the task. |
Use `lark-cli schema task.tasks.create` to confirm that an extra field is supported before passing it through `--data`. Prefer this shortcut over the raw `tasks create` command when `--data` can express the request. Do not assume that other shortcuts support `--data`; check each shortcut's `--help` output first.
## Workflow
1. Confirm with the user: task summary, due date, assignee, and tasklist if necessary.

View File

@@ -0,0 +1,117 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package calendar
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
// richTextMarkdown is a Markdown rich-text payload carrying a doc link and
// styling. The CLI forwards it verbatim in description_rich; the OpenAPI service
// converts Markdown <-> ClientVars.
const richTextMarkdown = "见 [设计文档](https://bytedance.feishu.cn/docx/abc) 和 **重点**"
// TestCalendar_CreateDescriptionRichDryRun verifies that +create treats
// --description as Markdown rich text and forwards it as-is under the
// description_rich body field, omitting the plain description field — the
// service treats the two as mutually exclusive.
func TestCalendar_CreateDescriptionRichDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"calendar", "+create",
"--calendar-id", "cal_dry",
"--summary", "rich dry-run",
"--start", "2026-04-25T10:00:00+08:00",
"--end", "2026-04-25T11:00:00+08:00",
"--description", richTextMarkdown,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/calendar/v4/calendars/cal_dry/events", clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, richTextMarkdown, clie2e.DryRunGet(out, "api.0.body.description_rich").String(), "stdout:\n%s", out)
require.False(t, clie2e.DryRunGet(out, "api.0.body.description").Exists(), "plain description must not be sent; stdout:\n%s", out)
}
// TestCalendar_CreateDescriptionRichOnlyDryRun verifies that +create forwards
// --description under description_rich and omits the plain description body
// field entirely. Sending an empty description would suppress the server's
// plain-preview backfill and break first-load rendering.
func TestCalendar_CreateDescriptionRichOnlyDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"calendar", "+create",
"--calendar-id", "cal_dry",
"--summary", "rich only dry-run",
"--start", "2026-04-25T10:00:00+08:00",
"--end", "2026-04-25T11:00:00+08:00",
"--description", richTextMarkdown,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, richTextMarkdown, clie2e.DryRunGet(out, "api.0.body.description_rich").String(), "stdout:\n%s", out)
require.False(t, clie2e.DryRunGet(out, "api.0.body.description").Exists(), "description must be omitted when not set; stdout:\n%s", out)
}
// TestCalendar_UpdateDescriptionRichDryRun verifies that +update forwards the
// rich-text payload as-is under the description_rich body field.
func TestCalendar_UpdateDescriptionRichDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"calendar", "+update",
"--calendar-id", "cal_dry",
"--event-id", "evt_dry",
"--description", richTextMarkdown,
"--notify=false",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "PATCH", clie2e.DryRunGet(out, "api.0.method").String(), "stdout:\n%s", out)
require.Equal(t, "/open-apis/calendar/v4/calendars/cal_dry/events/evt_dry", clie2e.DryRunGet(out, "api.0.url").String(), "stdout:\n%s", out)
require.Equal(t, richTextMarkdown, clie2e.DryRunGet(out, "api.0.body.description_rich").String(), "stdout:\n%s", out)
}

View File

@@ -17,11 +17,10 @@ import (
// TestSheets_ImageUploadDryRunParentType pins the parent_type the sheets
// image-upload shortcuts emit in --dry-run output for native vs. imported
// "office" spreadsheets. For native tokens parent_type must be "sheet_image";
// for tokens prefixed with "fake_office_" (the synthetic token an imported
// office spreadsheet carries) the backend requires "office_sheet_file". The
// three covered entries — sheets +media-upload (backward), sheets
// +cells-set-image, and sheets +create-float-image — are every image-upload
// surface that the office/native split fans out to.
// for tokens carrying the interleaved "OFL0X" marker the backend requires
// "office_sheet_file". The covered entries — sheets +media-upload (backward),
// sheets +cells-set-image, and sheets +float-image-create — are every
// image-upload surface that the office/native split fans out to.
func TestSheets_ImageUploadDryRunParentType(t *testing.T) {
setSheetsDryRunEnv(t)
@@ -50,11 +49,11 @@ func TestSheets_ImageUploadDryRunParentType(t *testing.T) {
name: "media-upload office",
args: []string{
"sheets", "+media-upload",
"--spreadsheet-token", "fake_office_dryrun",
"--spreadsheet-token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
"--file", "img.png",
"--dry-run",
},
token: "fake_office_dryrun",
token: "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
wantParentType: "office_sheet_file",
},
{
@@ -74,13 +73,30 @@ func TestSheets_ImageUploadDryRunParentType(t *testing.T) {
name: "cells-set-image office",
args: []string{
"sheets", "+cells-set-image",
"--spreadsheet-token", "fake_office_dryrun",
"--spreadsheet-token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
"--sheet-id", "sheet1",
"--range", "A1",
"--image", "img.png",
"--dry-run",
},
token: "fake_office_dryrun",
token: "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
wantParentType: "office_sheet_file",
},
{
name: "float-image-create office",
args: []string{
"sheets", "+float-image-create",
"--spreadsheet-token", "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
"--sheet-id", "sheet1",
"--image-name", "img.png",
"--image", "img.png",
"--position-row", "0",
"--position-col", "A",
"--size-width", "100",
"--size-height", "100",
"--dry-run",
},
token: "aaaaOaaaaFaaaaLaaaa0aaaaXaaa",
wantParentType: "office_sheet_file",
},
}

View File

@@ -0,0 +1,49 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestSlidesScreenshotSlideIDCSVDryRunE2E pins the CSV multi-value parsing for
// --slide-id through the built CLI: a single comma-separated flag value must
// expand into the same slide_ids request body that repeating the flag would
// produce.
func TestSlidesScreenshotSlideIDCSVDryRunE2E(t *testing.T) {
setSlidesDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"slides", "+screenshot",
"--presentation", "presScreenshotDryRun",
"--slide-id", "slide_1,slide_2",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Equal(t, "POST", gjson.Get(result.Stdout, "data.api.0.method").String(), result.Stdout)
require.Equal(t,
"/open-apis/slides_ai/v1/xml_presentations/presScreenshotDryRun/slide_images",
gjson.Get(result.Stdout, "data.api.0.url").String(),
result.Stdout,
)
slideIDs := gjson.Get(result.Stdout, "data.api.0.body.slide_ids").Array()
require.Len(t, slideIDs, 2, result.Stdout)
require.Equal(t, "slide_1", slideIDs[0].String(), result.Stdout)
require.Equal(t, "slide_2", slideIDs[1].String(), result.Stdout)
}

View File

@@ -0,0 +1,63 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
func TestTask_SearchPaginationDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "task_search_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "task_search_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
tests := []struct {
name string
command string
url string
}{
{
name: "tasks",
command: "+search",
url: "/open-apis/task/v2/tasks/search",
},
{
name: "tasklists",
command: "+tasklist-search",
url: "/open-apis/task/v2/tasklists/search",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"task", tt.command,
"--query", "pagination",
"--page-token", "initial_pt",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out)
require.Equal(t, tt.url, clie2e.DryRunGet(out, "api.0.url").String(), out)
require.Equal(t, "initial_pt", clie2e.DryRunGet(out, "api.0.params.page_token").String(), out)
require.Equal(t, "pagination", clie2e.DryRunGet(out, "api.0.body.query").String(), out)
require.False(t, clie2e.DryRunGet(out, "api.0.body.page_token").Exists(), out)
})
}
}