mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
37 Commits
feat/bot-v
...
feat/slide
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d79b2b499d | ||
|
|
e0bc29a648 | ||
|
|
68a77eee5c | ||
|
|
29a97dbde8 | ||
|
|
29a6a7b600 | ||
|
|
c167163d70 | ||
|
|
7988515e1c | ||
|
|
c7adff7a3b | ||
|
|
59237f3104 | ||
|
|
358cd06838 | ||
|
|
b0b1ca4b5d | ||
|
|
781d188a60 | ||
|
|
2e0fb9a880 | ||
|
|
927b37cd63 | ||
|
|
d2e22c5fca | ||
|
|
fdae560014 | ||
|
|
1b173e1953 | ||
|
|
57db1b3a8d | ||
|
|
4c1c5f5287 | ||
|
|
3d2c10cd0b | ||
|
|
03de81c5f3 | ||
|
|
7abcaa7f68 | ||
|
|
8fb2476985 | ||
|
|
56c9a2afd8 | ||
|
|
2029189809 | ||
|
|
ee427979a8 | ||
|
|
545abcbbde | ||
|
|
4a73e83f1e | ||
|
|
7496420fa8 | ||
|
|
43fabdf524 | ||
|
|
8c46c74105 | ||
|
|
70777c86c3 | ||
|
|
38e8806d91 | ||
|
|
a7865cd0a7 | ||
|
|
f77b7eea68 | ||
|
|
dd7f741b62 | ||
|
|
e7d5ecdd01 |
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
@@ -1,4 +1,7 @@
|
||||
/go.mod @liangshuo-1
|
||||
/go.sum @liangshuo-1
|
||||
/internal/ @liangshuo-1
|
||||
/shortcuts/common/ @liangshuo-1
|
||||
|
||||
# Last match wins: existing domains below are exempt, only new skills/ entries need review.
|
||||
/skills/ @liangshuo-1
|
||||
|
||||
46
.github/workflows/semantic-review.yml
vendored
46
.github/workflows/semantic-review.yml
vendored
@@ -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");
|
||||
|
||||
1
.lark-slides-on9-readback.xml
Normal file
1
.lark-slides-on9-readback.xml
Normal file
File diff suppressed because one or more lines are too long
88
CHANGELOG.md
88
CHANGELOG.md
@@ -2,6 +2,90 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.80] - 2026-07-29
|
||||
|
||||
### Features
|
||||
|
||||
- **drive**: add +member-list shortcut (#1795)
|
||||
- **drive**: add +permission-get-setting shortcut (#1738)
|
||||
- propagate invocation metadata (#2097)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **slides**: 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
|
||||
- **slides**: +create 的参数下沉到 create.md,主 skill 只留路由 (#2096)
|
||||
|
||||
### Tests
|
||||
|
||||
- **e2e**: wait for base role update visibility (#2087)
|
||||
|
||||
### Misc
|
||||
|
||||
- Feat/detect line text overlap (#2069)
|
||||
|
||||
## [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 +1722,10 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.80]: https://github.com/larksuite/cli/releases/tag/v1.0.80
|
||||
[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
|
||||
|
||||
23
README.md
23
README.md
@@ -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
|
||||
|
||||
23
README.zh.md
23
README.zh.md
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
80
cmd/config/risk_control.go
Normal file
80
cmd/config/risk_control.go
Normal 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"
|
||||
}
|
||||
130
cmd/config/risk_control_test.go
Normal file
130
cmd/config/risk_control_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,7 @@ func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
fmt.Fprintf(&b, "\n\nDomain guide (concepts, command choice, conventions): lark-cli skills read %s", skill)
|
||||
}
|
||||
}
|
||||
appendSlidesXMLQuickReference(&b, cmd, skillFS)
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
}
|
||||
@@ -116,6 +117,90 @@ const (
|
||||
shortcutBaseAnnotation = "affordance-shortcut-base"
|
||||
)
|
||||
|
||||
const slidesXMLQuickReferencePath = "lark-slides/references/xml-schema-quick-ref.md"
|
||||
|
||||
// slidesShortcutReferencePaths maps each Slides shortcut to its primary
|
||||
// command guide. XML-consuming shortcuts also include the shared schema
|
||||
// reference because their command guide accepts XML but does not repeat the
|
||||
// complete element grammar.
|
||||
var slidesShortcutReferencePaths = map[string][]string{
|
||||
"+create": {
|
||||
"lark-slides/references/lark-slides-create.md",
|
||||
slidesXMLQuickReferencePath,
|
||||
},
|
||||
"+xml-get": {
|
||||
"lark-slides/references/lark-slides-xml-presentations-get.md",
|
||||
},
|
||||
"+screenshot": {
|
||||
"lark-slides/references/lark-slides-screenshot.md",
|
||||
},
|
||||
"+media-upload": {
|
||||
"lark-slides/references/lark-slides-media-upload.md",
|
||||
},
|
||||
"+replace-slide": {
|
||||
"lark-slides/references/lark-slides-replace-slide.md",
|
||||
"lark-slides/references/lark-slides-edit-workflows.md",
|
||||
slidesXMLQuickReferencePath,
|
||||
},
|
||||
"+replace-pages": {
|
||||
"lark-slides/references/lark-slides-replace-pages.md",
|
||||
"lark-slides/references/lark-slides-edit-workflows.md",
|
||||
slidesXMLQuickReferencePath,
|
||||
},
|
||||
"+history-list": {
|
||||
"lark-slides/references/lark-slides-history.md",
|
||||
},
|
||||
"+history-revert": {
|
||||
"lark-slides/references/lark-slides-history.md",
|
||||
},
|
||||
"+history-revert-status": {
|
||||
"lark-slides/references/lark-slides-history.md",
|
||||
},
|
||||
}
|
||||
|
||||
// appendSlidesXMLQuickReference adds the embedded XML schema summary to the
|
||||
// slides domain help. The reference file is already shipped in the skill
|
||||
// content tree, so help and the standalone skill reader share one source of
|
||||
// truth instead of maintaining a second, drifting copy in Go.
|
||||
func appendSlidesXMLQuickReference(b *strings.Builder, cmd *cobra.Command, skillFS fs.FS) {
|
||||
if cmd.Name() != "slides" || skillFS == nil {
|
||||
return
|
||||
}
|
||||
content, err := fs.ReadFile(skillFS, slidesXMLQuickReferencePath)
|
||||
if err != nil || len(content) == 0 {
|
||||
return
|
||||
}
|
||||
b.WriteString("\n\nEmbedded XML syntax quick reference:\n")
|
||||
b.Write(content)
|
||||
}
|
||||
|
||||
func readSlidesShortcutReferences(cmd *cobra.Command, skillFS fs.FS) ([]string, bool) {
|
||||
if cmdmeta.Domain(cmd) != "slides" || skillFS == nil {
|
||||
return nil, false
|
||||
}
|
||||
paths, ok := slidesShortcutReferencePaths[cmd.Name()]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var contents []string
|
||||
for _, path := range paths {
|
||||
content, err := fs.ReadFile(skillFS, path)
|
||||
if err != nil || len(content) == 0 {
|
||||
continue
|
||||
}
|
||||
contents = append(contents, fmt.Sprintf("Embedded command reference: %s\n%s", path, content))
|
||||
}
|
||||
return contents, len(contents) > 0
|
||||
}
|
||||
|
||||
func appendSlidesShortcutReferences(b *strings.Builder, contents []string) {
|
||||
for _, content := range contents {
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(content)
|
||||
}
|
||||
}
|
||||
|
||||
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
|
||||
// few strings is the only build-time cost; the overlay stays untouched).
|
||||
func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, paramsOnly string) {
|
||||
@@ -171,11 +256,11 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
}
|
||||
|
||||
// PrepareShortcutHelp composes a +-prefixed shortcut's Long from its affordance
|
||||
// overlay — the same top layout as method help (description, Risk, guidance
|
||||
// block, related skills) minus the schema pointer, which shortcuts have none
|
||||
// of. Returns false when the command is not a shortcut or carries no overlay
|
||||
// entry, so shortcuts without guidance keep the default help plus the bottom
|
||||
// risk/tips append.
|
||||
// overlay and any embedded command references — the same top layout as method
|
||||
// help (description, Risk, guidance block, related skills) minus the schema
|
||||
// pointer, which shortcuts have none of. Returns false when the command is not
|
||||
// a shortcut, or when it has neither an overlay nor an embedded reference, so
|
||||
// ordinary shortcuts keep the default help plus the bottom risk/tips append.
|
||||
//
|
||||
// The lead is the command's pristine base (captureHelpBase): a shortcut that
|
||||
// set a hand-authored Long in PostMount (e.g. the docs shortcuts' "agents MUST
|
||||
@@ -191,12 +276,17 @@ func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceShortcut {
|
||||
return false
|
||||
}
|
||||
raw, ok := affordanceRaw(cmd)
|
||||
if !ok {
|
||||
return false
|
||||
references, hasReferences := readSlidesShortcutReferences(cmd, skillFS)
|
||||
|
||||
var a meta.Affordance
|
||||
hasAffordance := false
|
||||
if raw, ok := affordanceRaw(cmd); ok {
|
||||
if parsed, parsedOK := (meta.Method{Affordance: raw}).ParsedAffordance(); parsedOK {
|
||||
a = parsed
|
||||
hasAffordance = true
|
||||
}
|
||||
}
|
||||
a, ok := (meta.Method{Affordance: raw}).ParsedAffordance()
|
||||
if !ok {
|
||||
if !hasAffordance && !hasReferences {
|
||||
return false
|
||||
}
|
||||
if len(a.Tips) == 0 {
|
||||
@@ -211,6 +301,7 @@ func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
|
||||
b.WriteString(block)
|
||||
}
|
||||
writeRelatedSkills(&b, a.Skills, skillFS)
|
||||
appendSlidesShortcutReferences(&b, references)
|
||||
|
||||
cmd.Long = b.String()
|
||||
return true
|
||||
|
||||
@@ -264,6 +264,94 @@ func TestPrepareShortcutHelp_PreservesPostMountLong(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareShortcutHelp_SlidesReferenceWithoutAffordance(t *testing.T) {
|
||||
sc := &cobra.Command{Use: "+xml-get", Short: "Fetch presentation XML"}
|
||||
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
|
||||
cmdmeta.SetDomain(sc, "slides")
|
||||
cmdmeta.SetAffordanceRef(sc, "slides", "+xml-get")
|
||||
cmdutil.SetRisk(sc, "read")
|
||||
|
||||
skillFS := fstest.MapFS{
|
||||
"lark-slides/references/lark-slides-xml-presentations-get.md": {
|
||||
Data: []byte("# slides +xml-get\n\nRead the presentation XML."),
|
||||
},
|
||||
}
|
||||
if !PrepareShortcutHelp(sc, skillFS) {
|
||||
t.Fatal("PrepareShortcutHelp returned false for a Slides shortcut with an embedded reference")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Fetch presentation XML",
|
||||
"Risk: read",
|
||||
"Embedded command reference: lark-slides/references/lark-slides-xml-presentations-get.md",
|
||||
"Read the presentation XML.",
|
||||
} {
|
||||
if !strings.Contains(sc.Long, want) {
|
||||
t.Errorf("Slides shortcut help missing %q:\n%s", want, sc.Long)
|
||||
}
|
||||
}
|
||||
PrepareShortcutHelp(sc, skillFS)
|
||||
if got := strings.Count(sc.Long, "Embedded command reference:"); got != 1 {
|
||||
t.Fatalf("embedded reference appended %d times after re-render, want 1:\n%s", got, sc.Long)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesShortcutReferenceMapping(t *testing.T) {
|
||||
want := map[string]string{
|
||||
"+create": "lark-slides/references/lark-slides-create.md",
|
||||
"+xml-get": "lark-slides/references/lark-slides-xml-presentations-get.md",
|
||||
"+screenshot": "lark-slides/references/lark-slides-screenshot.md",
|
||||
"+media-upload": "lark-slides/references/lark-slides-media-upload.md",
|
||||
"+replace-slide": "lark-slides/references/lark-slides-replace-slide.md",
|
||||
"+replace-pages": "lark-slides/references/lark-slides-replace-pages.md",
|
||||
"+history-list": "lark-slides/references/lark-slides-history.md",
|
||||
"+history-revert": "lark-slides/references/lark-slides-history.md",
|
||||
"+history-revert-status": "lark-slides/references/lark-slides-history.md",
|
||||
}
|
||||
|
||||
for command, path := range want {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
sc := &cobra.Command{Use: command, Short: command}
|
||||
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
|
||||
cmdmeta.SetDomain(sc, "slides")
|
||||
skillFS := fstest.MapFS{
|
||||
path: {Data: []byte("reference content")},
|
||||
}
|
||||
contents, ok := readSlidesShortcutReferences(sc, skillFS)
|
||||
if !ok || len(contents) == 0 {
|
||||
t.Fatalf("shortcut %q has no mapped reference", command)
|
||||
}
|
||||
if !strings.Contains(contents[0], "Embedded command reference: "+path) {
|
||||
t.Fatalf("shortcut %q mapped content does not include %q:\n%s", command, path, contents[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotHelpDoesNotIncludeXMLQuickReference(t *testing.T) {
|
||||
sc := &cobra.Command{Use: "+screenshot", Short: "Save screenshots"}
|
||||
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
|
||||
cmdmeta.SetDomain(sc, "slides")
|
||||
skillFS := fstest.MapFS{
|
||||
"lark-slides/references/lark-slides-screenshot.md": {
|
||||
Data: []byte("# slides +screenshot\n\nSave screenshots."),
|
||||
},
|
||||
slidesXMLQuickReferencePath: {
|
||||
Data: []byte("# XML Schema Quick Reference"),
|
||||
},
|
||||
}
|
||||
|
||||
contents, ok := readSlidesShortcutReferences(sc, skillFS)
|
||||
if !ok {
|
||||
t.Fatal("screenshot shortcut should have a primary reference")
|
||||
}
|
||||
if len(contents) != 1 {
|
||||
t.Fatalf("screenshot reference count = %d, want 1: %#v", len(contents), contents)
|
||||
}
|
||||
if strings.Contains(contents[0], "XML Schema Quick Reference") {
|
||||
t.Fatalf("screenshot help must not include the XML quick reference:\n%s", contents[0])
|
||||
}
|
||||
}
|
||||
|
||||
// domainCmd wires a domain-tagged command with a subcommand under a root, the
|
||||
// shape PrepareDomainHelp expects.
|
||||
func domainCmd(short, long string) *cobra.Command {
|
||||
@@ -306,3 +394,51 @@ func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) {
|
||||
t.Errorf("Short should seed Long when no hand-authored Long exists; got:\n%s", dom.Long)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareDomainHelp_SlidesIncludesEmbeddedXMLReference(t *testing.T) {
|
||||
root := &cobra.Command{Use: "root"}
|
||||
dom := &cobra.Command{Use: "slides", Short: "Slides"}
|
||||
cmdmeta.SetDomain(dom, "slides")
|
||||
dom.AddCommand(&cobra.Command{Use: "+create", Short: "Create", Run: func(*cobra.Command, []string) {}})
|
||||
root.AddCommand(dom)
|
||||
|
||||
const quickReference = `# XML Schema Quick Reference
|
||||
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<slide>
|
||||
<data>
|
||||
<shape type="text" topLeftX="80" topLeftY="80" width="800" height="120">
|
||||
<content textType="title"><p>Title</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
</presentation>
|
||||
|
||||
<table><colgroup><col/></colgroup><tr><td><content><p>A</p></content></td></tr></table>
|
||||
<chart><chartPlotArea/><chartData/></chart>`
|
||||
skillFS := fstest.MapFS{
|
||||
"lark-slides/SKILL.md": {Data: []byte("# slides")},
|
||||
"lark-slides/references/xml-schema-quick-ref.md": {Data: []byte(quickReference)},
|
||||
}
|
||||
|
||||
if !PrepareDomainHelp(dom, skillFS) {
|
||||
t.Fatal("PrepareDomainHelp returned false for slides domain")
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Embedded XML syntax quick reference:",
|
||||
`<presentation xmlns="http://www.larkoffice.com/sml/2.0"`,
|
||||
"<shape type=\"text\"",
|
||||
"<content",
|
||||
"topLeftX",
|
||||
"<table>",
|
||||
"<chart>",
|
||||
} {
|
||||
if !strings.Contains(dom.Long, want) {
|
||||
t.Errorf("slides help missing XML reference marker %q:\n%s", want, dom.Long)
|
||||
}
|
||||
}
|
||||
PrepareDomainHelp(dom, skillFS)
|
||||
if got := strings.Count(dom.Long, "Embedded XML syntax quick reference:"); got != 1 {
|
||||
t.Fatalf("slides XML reference appended %d times after re-render, want 1:\n%s", got, dom.Long)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
28
internal/cmdutil/risk_control.go
Normal file
28
internal/cmdutil/risk_control.go
Normal 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()
|
||||
}
|
||||
45
internal/cmdutil/risk_control_test.go
Normal file
45
internal/cmdutil/risk_control_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ const (
|
||||
HeaderShortcut = "X-Cli-Shortcut"
|
||||
HeaderExecutionId = "X-Cli-Execution-Id"
|
||||
HeaderAgentTrace = "X-Agent-Trace"
|
||||
HeaderAgentName = "X-Agent-Name"
|
||||
|
||||
SourceValue = "lark-cli"
|
||||
|
||||
@@ -55,6 +56,9 @@ func BaseSecurityHeaders() http.Header {
|
||||
if v := envvars.AgentTrace(); v != "" {
|
||||
h.Set(HeaderAgentTrace, v)
|
||||
}
|
||||
if v := envvars.AgentName(); v != "" {
|
||||
h.Set(HeaderAgentName, v)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
|
||||
@@ -263,9 +263,34 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HeaderAgentTrace injection (via BaseSecurityHeaders)
|
||||
// Agent headers injected via BaseSecurityHeaders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentName, "")
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != "" {
|
||||
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent when env unset", HeaderAgentName, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_IncludesAgentNameHeaderWhenEnvSet(t *testing.T) {
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(envvars.CliAgentName, agentName)
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != agentName {
|
||||
t.Fatalf("BaseSecurityHeaders()[%s] = %q, want %q", HeaderAgentName, v, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentNameHeaderWhenEnvInvalid(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentName, "agent\r\nX-Evil: attack")
|
||||
h := BaseSecurityHeaders()
|
||||
if v := h.Get(HeaderAgentName); v != "" {
|
||||
t.Fatalf("BaseSecurityHeaders() included %s = %q, want absent for invalid input", HeaderAgentName, v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
||||
t.Setenv(envvars.CliAgentTrace, "")
|
||||
h := BaseSecurityHeaders()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
37
internal/core/config_snapshot.go
Normal file
37
internal/core/config_snapshot.go
Normal 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()
|
||||
}
|
||||
58
internal/core/config_snapshot_test.go
Normal file
58
internal/core/config_snapshot_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -16,16 +16,18 @@ func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAgentName_ReturnsCleanValue(t *testing.T) {
|
||||
t.Setenv(CliAgentName, "claude-code")
|
||||
if got := AgentName(); got != "claude-code" {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(CliAgentName, agentName)
|
||||
if got := AgentName(); got != agentName {
|
||||
t.Fatalf("AgentName() = %q, want %q", got, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentName_TrimsWhitespace(t *testing.T) {
|
||||
t.Setenv(CliAgentName, " cursor ")
|
||||
if got := AgentName(); got != "cursor" {
|
||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
|
||||
const agentName = "sample-agent"
|
||||
t.Setenv(CliAgentName, " "+agentName+" ")
|
||||
if got := AgentName(); got != agentName {
|
||||
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, agentName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
142
internal/riskcontrol/osmodel.go
Normal file
142
internal/riskcontrol/osmodel.go
Normal 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
|
||||
}
|
||||
}
|
||||
27
internal/riskcontrol/osmodel_darwin.go
Normal file
27
internal/riskcontrol/osmodel_darwin.go
Normal 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 ""
|
||||
}
|
||||
48
internal/riskcontrol/osmodel_darwin_test.go
Normal file
48
internal/riskcontrol/osmodel_darwin_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
17
internal/riskcontrol/osmodel_linux.go
Normal file
17
internal/riskcontrol/osmodel_linux.go
Normal 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"
|
||||
}
|
||||
20
internal/riskcontrol/osmodel_linux_test.go
Normal file
20
internal/riskcontrol/osmodel_linux_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
11
internal/riskcontrol/osmodel_other.go
Normal file
11
internal/riskcontrol/osmodel_other.go
Normal 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 ""
|
||||
}
|
||||
143
internal/riskcontrol/osmodel_test.go
Normal file
143
internal/riskcontrol/osmodel_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
44
internal/riskcontrol/osmodel_windows.go
Normal file
44
internal/riskcontrol/osmodel_windows.go
Normal 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 ""
|
||||
}
|
||||
78
internal/riskcontrol/osmodel_windows_test.go
Normal file
78
internal/riskcontrol/osmodel_windows_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
138
internal/riskcontrol/transport.go
Normal file
138
internal/riskcontrol/transport.go
Normal 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
|
||||
}
|
||||
124
internal/riskcontrol/transport_test.go
Normal file
124
internal/riskcontrol/transport_test.go
Normal 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
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.76",
|
||||
"version": "1.0.80",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.76",
|
||||
"version": "1.0.80",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.76",
|
||||
"version": "1.0.80",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package base
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -250,7 +251,8 @@ func TestBaseFormQuestionsExecuteList(t *testing.T) {
|
||||
"total": 2,
|
||||
"questions": []interface{}{
|
||||
map[string]interface{}{"id": "q_001", "title": "您的姓名", "required": true, "description": nil},
|
||||
map[string]interface{}{"id": "q_002", "title": "您的年龄", "required": false, "description": nil},
|
||||
map[string]interface{}{"id": "q_002", "title": "发票抬头", "required": false, "description": nil,
|
||||
"visible_rule": map[string]interface{}{"logic": "and", "conditions": []interface{}{[]interface{}{"q_001", "==", "是"}}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -258,9 +260,14 @@ func TestBaseFormQuestionsExecuteList(t *testing.T) {
|
||||
if err := runShortcut(t, BaseFormQuestionsList, []string{"+form-questions-list", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"q_001"`) || !strings.Contains(got, `"total": 2`) {
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"q_001"`) || !strings.Contains(got, `"total": 2`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
// The list output must forward visible_rule verbatim so agents can read existing display conditions.
|
||||
if !strings.Contains(got, `"visible_rule"`) {
|
||||
t.Fatalf("visible_rule missing from list output: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
|
||||
@@ -296,11 +303,49 @@ func TestBaseFormQuestionsExecuteCreate(t *testing.T) {
|
||||
t.Fatalf("expected error for invalid questions JSON")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("visible_rule passthrough", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"questions": []interface{}{
|
||||
map[string]interface{}{"id": "q_new1", "title": "发票抬头"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
args := []string{"+form-questions-create", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
|
||||
"--questions", `[{"type":"text","title":"发票抬头","visible_rule":{"logic":"and","conditions":[["是否需要发票","==","是"]]}}]`}
|
||||
if err := runShortcut(t, BaseFormQuestionsCreate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
var body struct {
|
||||
Questions []map[string]interface{} `json:"questions"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
if len(body.Questions) != 1 {
|
||||
t.Fatalf("questions=%#v", body.Questions)
|
||||
}
|
||||
rule, ok := body.Questions[0]["visible_rule"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("visible_rule not forwarded verbatim: body=%s", string(stub.CapturedBody))
|
||||
}
|
||||
if rule["logic"] != "and" {
|
||||
t.Fatalf("visible_rule logic not preserved: %#v", rule)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsExecuteUpdate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PATCH",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_form1/questions",
|
||||
Body: map[string]interface{}{
|
||||
@@ -311,15 +356,29 @@ func TestBaseFormQuestionsExecuteUpdate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
reg.Register(stub)
|
||||
args := []string{"+form-questions-update", "--base-token", "app_x", "--table-id", "tbl_x", "--form-id", "vew_form1",
|
||||
"--questions", `[{"id":"q_001","title":"更新后的问题","required":true}]`}
|
||||
"--questions", `[{"id":"q_001","title":"更新后的问题","required":true,"visible_rule":{"logic":"and","conditions":[["q_002","==","是"]]}}]`}
|
||||
if err := runShortcut(t, BaseFormQuestionsUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"questions"`) || !strings.Contains(got, `"q_001"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
// visible_rule must be forwarded verbatim to the API (transcribe faithfully).
|
||||
var body struct {
|
||||
Questions []map[string]interface{} `json:"questions"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("captured body json err=%v body=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
if len(body.Questions) != 1 {
|
||||
t.Fatalf("questions=%#v", body.Questions)
|
||||
}
|
||||
if _, ok := body.Questions[0]["visible_rule"].(map[string]interface{}); !ok {
|
||||
t.Fatalf("visible_rule not forwarded verbatim: body=%s", string(stub.CapturedBody))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsExecuteDelete(t *testing.T) {
|
||||
|
||||
@@ -25,14 +25,21 @@ var BaseFormQuestionsCreate = common.Shortcut{
|
||||
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id, empty/absent = always shown). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
api := common.NewDryRunAPI().
|
||||
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", runtime.Str("table-id")).
|
||||
Set("form_id", runtime.Str("form-id"))
|
||||
// Transcribe the questions body verbatim so the preview shows exactly
|
||||
// what would be sent (including optional fields like visible_rule).
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
|
||||
api.Body(map[string]interface{}{"questions": questions})
|
||||
}
|
||||
return api
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
|
||||
@@ -25,14 +25,26 @@ var BaseFormQuestionsUpdate = common.Shortcut{
|
||||
{Name: "base-token", Desc: "Base token (base_token)", Required: true},
|
||||
{Name: "table-id", Desc: "table ID", Required: true},
|
||||
{Name: "form-id", Desc: "form ID", Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items, each item must include "id". Supported fields: "id"(required),"title","description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown,1=vertical,2=horizontal,select only). E.g. '[{"id":"q_001","title":"Updated?","required":true}]'`, Required: true},
|
||||
{Name: "questions", Desc: `questions JSON array, max 10 items, each item must include "id". Update uses full question overwrite semantics: omitted/empty fields are written as defaults/empty, so run +form-questions-list first and include existing values you want to keep. Supported fields: "id"(required),"title","description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown,1=vertical,2=horizontal,select only),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id; pass null or omit to clear). E.g. '[{"id":"q_001","title":"Updated?","required":true}]'`, Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
"Update uses full question overwrite semantics, not a patch.",
|
||||
"Run +form-questions-list first and include existing title/description/required/option_display_mode/visible_rule values you want to keep.",
|
||||
"Omitted fields reset to defaults; empty strings, null, and empty arrays are written as empty/clear when accepted by the API.",
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
api := common.NewDryRunAPI().
|
||||
PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", runtime.Str("table-id")).
|
||||
Set("form_id", runtime.Str("form-id"))
|
||||
// Transcribe the questions body verbatim so the preview shows exactly
|
||||
// what would be sent (including optional fields like visible_rule).
|
||||
var questions []interface{}
|
||||
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
|
||||
api.Body(map[string]interface{}{"questions": questions})
|
||||
}
|
||||
return api
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
|
||||
@@ -783,6 +783,20 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
|
||||
`JSON array of question IDs to delete, max 10 items, e.g. '["q_001","q_002"]'`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "form question create visible_rule",
|
||||
shortcut: BaseFormQuestionsCreate,
|
||||
wantHelp: []string{
|
||||
`"visible_rule"(display condition; same shape as view filter`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "form question update visible_rule",
|
||||
shortcut: BaseFormQuestionsUpdate,
|
||||
wantHelp: []string{
|
||||
`"visible_rule"(display condition; same shape as view filter`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "record search json",
|
||||
shortcut: BaseRecordSearch,
|
||||
@@ -1028,6 +1042,39 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFormQuestionsUpdateHelpGuidesFullOverwrite(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
BaseFormQuestionsUpdate.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
help := cmd.Flags().FlagUsages()
|
||||
wantHelp := []string{
|
||||
"Update uses full question overwrite semantics",
|
||||
"run +form-questions-list first",
|
||||
"include existing values you want to keep",
|
||||
"pass null or omit to clear",
|
||||
}
|
||||
for _, want := range wantHelp {
|
||||
if !strings.Contains(help, want) {
|
||||
t.Fatalf("flag help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
wantTips := []string{
|
||||
"full question overwrite semantics, not a patch",
|
||||
"Run +form-questions-list first",
|
||||
"title/description/required/option_display_mode/visible_rule",
|
||||
"Omitted fields reset to defaults",
|
||||
"empty strings, null, and empty arrays are written as empty/clear",
|
||||
}
|
||||
for _, want := range wantTips {
|
||||
if !strings.Contains(tips, want) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAttachmentHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -250,6 +250,8 @@ var CalendarAgenda = common.Shortcut{
|
||||
}
|
||||
}
|
||||
|
||||
collapseDescription(e)
|
||||
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (``; 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`, `<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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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 (``; 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`, `<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
|
||||
|
||||
172
shortcuts/calendar/description_rich_images.go
Normal file
172
shortcuts/calendar/description_rich_images.go
Normal 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
|
||||
}
|
||||
279
shortcuts/calendar/description_rich_images_test.go
Normal file
279
shortcuts/calendar/description_rich_images_test.go
Normal 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  more "
|
||||
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", "",
|
||||
"--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", "",
|
||||
"--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", "",
|
||||
"--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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
325
shortcuts/drive/drive_member_list.go
Normal file
325
shortcuts/drive/drive_member_list.go
Normal file
@@ -0,0 +1,325 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type driveMemberListSpec struct {
|
||||
Token string
|
||||
Type string
|
||||
Fields string
|
||||
PermType string
|
||||
}
|
||||
|
||||
var driveMemberListTypes = []string{
|
||||
"doc", "sheet", "file", "wiki", "bitable", "docx",
|
||||
"mindnote", "minutes", "slides", "folder",
|
||||
}
|
||||
|
||||
var driveMemberListFields = []string{"name", "type", "avatar", "external_label"}
|
||||
var driveMemberListPermTypes = []string{"container", "single_page"}
|
||||
|
||||
var driveMemberListURLPathToType = []struct {
|
||||
Prefix string
|
||||
Type string
|
||||
}{
|
||||
{"/drive/folder/", "folder"},
|
||||
{"/docx/", "docx"},
|
||||
{"/doc/", "doc"},
|
||||
{"/sheets/", "sheet"},
|
||||
{"/base/", "bitable"},
|
||||
{"/bitable/", "bitable"},
|
||||
{"/wiki/", "wiki"},
|
||||
{"/file/", "file"},
|
||||
{"/mindnotes/", "mindnote"},
|
||||
{"/slides/", "slides"},
|
||||
{"/minutes/", "minutes"},
|
||||
}
|
||||
|
||||
func readDriveMemberListSpec(runtime *common.RuntimeContext) (driveMemberListSpec, error) {
|
||||
token, resourceType, err := resolveDriveMemberListTarget(runtime.Str("token"), runtime.Str("type"))
|
||||
if err != nil {
|
||||
return driveMemberListSpec{}, err
|
||||
}
|
||||
fields, err := normalizeDriveMemberListFields(runtime.Str("fields"), runtime.Changed("fields"))
|
||||
if err != nil {
|
||||
return driveMemberListSpec{}, err
|
||||
}
|
||||
permType, err := normalizeDriveMemberListPermType(runtime.Str("perm-type"), resourceType, runtime.Changed("perm-type"))
|
||||
if err != nil {
|
||||
return driveMemberListSpec{}, err
|
||||
}
|
||||
return driveMemberListSpec{
|
||||
Token: token,
|
||||
Type: resourceType,
|
||||
Fields: fields,
|
||||
PermType: permType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func resolveDriveMemberListTarget(raw, explicitType string) (token, resourceType string, err error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token")
|
||||
}
|
||||
|
||||
explicitType, err = normalizeDriveMemberListEnumValue(explicitType, driveMemberListTypes, "--type")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if strings.Contains(raw, "://") {
|
||||
parsed, parseErr := url.Parse(raw)
|
||||
if parseErr != nil || parsed.Hostname() == "" {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token URL is malformed: %q", raw).WithParam("--token")
|
||||
}
|
||||
ref, ok := parseDriveMemberListResourceURLPath(parsed.Path)
|
||||
if !ok {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported --token URL %q: pass a recognized Lark Drive document/folder URL or a bare token with --type",
|
||||
raw,
|
||||
).WithParam("--token")
|
||||
}
|
||||
if explicitType != "" && explicitType != ref.Type {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
explicitType,
|
||||
ref.Type,
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validate.ResourceName(ref.Token, "--token"); err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return ref.Token, ref.Type, nil
|
||||
}
|
||||
|
||||
if explicitType == "" {
|
||||
return "", "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type is required when --token is a bare token; accepted values: %s",
|
||||
strings.Join(driveMemberListTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validate.ResourceName(raw, "--token"); err != nil {
|
||||
return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return raw, explicitType, nil
|
||||
}
|
||||
|
||||
func parseDriveMemberListResourceURLPath(path string) (common.ResourceRef, bool) {
|
||||
for _, mapping := range driveMemberListURLPathToType {
|
||||
if !strings.HasPrefix(path, mapping.Prefix) {
|
||||
continue
|
||||
}
|
||||
token := path[len(mapping.Prefix):]
|
||||
token = strings.TrimRight(token, "/")
|
||||
if idx := strings.IndexByte(token, '/'); idx >= 0 {
|
||||
token = token[:idx]
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
return common.ResourceRef{Type: mapping.Type, Token: token}, true
|
||||
}
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
func normalizeDriveMemberListFields(raw string, changed bool) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
if changed {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields cannot be blank; allowed: %s, *", strings.Join(driveMemberListFields, ", ")).WithParam("--fields")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
parts := strings.Split(raw, ",")
|
||||
fields := make([]string, 0, len(parts))
|
||||
seen := make(map[string]bool, len(parts))
|
||||
for _, part := range parts {
|
||||
field := strings.ToLower(strings.TrimSpace(part))
|
||||
if field == "" {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields contains an empty field; allowed: %s, *", strings.Join(driveMemberListFields, ", ")).WithParam("--fields")
|
||||
}
|
||||
if field == "*" {
|
||||
if len(parts) != 1 {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields=* cannot be combined with other fields").WithParam("--fields")
|
||||
}
|
||||
return "*", nil
|
||||
}
|
||||
if !driveMemberListFieldAllowed(field) {
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid value %q for --fields, allowed: %s, *",
|
||||
strings.TrimSpace(part),
|
||||
strings.Join(driveMemberListFields, ", "),
|
||||
).WithParam("--fields")
|
||||
}
|
||||
if !seen[field] {
|
||||
fields = append(fields, field)
|
||||
seen[field] = true
|
||||
}
|
||||
}
|
||||
return strings.Join(fields, ","), nil
|
||||
}
|
||||
|
||||
func driveMemberListFieldAllowed(field string) bool {
|
||||
for _, allowed := range driveMemberListFields {
|
||||
if field == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeDriveMemberListPermType(raw, resourceType string, changed bool) (string, error) {
|
||||
permType, err := normalizeDriveMemberListEnumValue(raw, driveMemberListPermTypes, "--perm-type")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resourceType != "wiki" && changed {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--perm-type only applies when resource type is wiki; got %q", resourceType).WithParam("--perm-type")
|
||||
}
|
||||
return permType, nil
|
||||
}
|
||||
|
||||
func normalizeDriveMemberListEnumValue(raw string, allowed []string, flagName string) (string, error) {
|
||||
value := strings.TrimSpace(raw)
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
for _, candidate := range allowed {
|
||||
if strings.EqualFold(value, candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid value %q for %s, allowed: %s",
|
||||
value,
|
||||
flagName,
|
||||
strings.Join(allowed, ", "),
|
||||
).WithParam(flagName)
|
||||
}
|
||||
|
||||
func (s driveMemberListSpec) apiPath() string {
|
||||
return fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(s.Token))
|
||||
}
|
||||
|
||||
func (s driveMemberListSpec) params() map[string]interface{} {
|
||||
params := map[string]interface{}{"type": s.Type}
|
||||
if s.Fields != "" {
|
||||
params["fields"] = s.Fields
|
||||
}
|
||||
if s.PermType != "" {
|
||||
params["perm_type"] = s.PermType
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// DriveMemberList lists collaborator/member permissions on a Drive resource.
|
||||
var DriveMemberList = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+member-list",
|
||||
Description: "List collaborator/member permissions on a Drive document, file, folder, or wiki node",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docs:permission.member:retrieve"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder)", Required: true},
|
||||
{Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens"},
|
||||
{Name: "fields", Desc: "optional collaborator fields to return: name,type,avatar,external_label or *"},
|
||||
{Name: "perm-type", Desc: "wiki permission scope filter; one of container|single_page"},
|
||||
},
|
||||
Tips: []string{
|
||||
"--token accepts a Lark URL or bare token; pass --type when using a bare token.",
|
||||
"Use --type folder for Drive folders.",
|
||||
"--fields is omitted by default; pass --fields '*' or a comma-separated subset when extra collaborator fields are needed.",
|
||||
"--perm-type only applies to wiki nodes.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDriveMemberListSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("List Drive collaborator/member permissions").
|
||||
GET(spec.apiPath()).
|
||||
Params(spec.params())
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive members for %s %s...\n", spec.Type, common.MaskToken(spec.Token))
|
||||
data, err := runtime.CallAPITyped("GET", spec.apiPath(), spec.params(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if items, ok := data["items"].([]interface{}); ok {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Found %d Drive member(s)\n", len(items))
|
||||
}
|
||||
runtime.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderDriveMemberListPretty(w, data)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func renderDriveMemberListPretty(w io.Writer, data map[string]interface{}) {
|
||||
items, _ := data["items"].([]interface{})
|
||||
if len(items) == 0 {
|
||||
fmt.Fprintln(w, "No Drive members found.")
|
||||
return
|
||||
}
|
||||
for i, raw := range items {
|
||||
member, _ := raw.(map[string]interface{})
|
||||
fmt.Fprintf(w, "[%d] %s\n", i+1, driveMemberListValue(member["member_id"]))
|
||||
fmt.Fprintf(w, " member_type: %s\n", driveMemberListValue(member["member_type"]))
|
||||
fmt.Fprintf(w, " perm: %s\n", driveMemberListValue(member["perm"]))
|
||||
if permType := driveMemberListValue(member["perm_type"]); permType != "-" {
|
||||
fmt.Fprintf(w, " perm_type: %s\n", permType)
|
||||
}
|
||||
if memberType := driveMemberListValue(member["type"]); memberType != "-" {
|
||||
fmt.Fprintf(w, " type: %s\n", memberType)
|
||||
}
|
||||
if name := driveMemberListValue(member["name"]); name != "-" {
|
||||
fmt.Fprintf(w, " name: %s\n", name)
|
||||
}
|
||||
if avatar := driveMemberListValue(member["avatar"]); avatar != "-" {
|
||||
fmt.Fprintf(w, " avatar: %s\n", avatar)
|
||||
}
|
||||
if label, ok := member["external_label"]; ok {
|
||||
fmt.Fprintf(w, " external_label: %v\n", label)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
func driveMemberListValue(v interface{}) string {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
return s
|
||||
}
|
||||
return "-"
|
||||
}
|
||||
426
shortcuts/drive/drive_member_list_test.go
Normal file
426
shortcuts/drive/drive_member_list_test.go
Normal file
@@ -0,0 +1,426 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newDriveMemberListRuntime(t *testing.T, token, docType, fields, permType string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +member-list"}
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
cmd.Flags().String("fields", "", "")
|
||||
cmd.Flags().String("perm-type", "", "")
|
||||
for name, value := range map[string]string{
|
||||
"token": token,
|
||||
"type": docType,
|
||||
"fields": fields,
|
||||
"perm-type": permType,
|
||||
} {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return common.TestNewRuntimeContext(cmd, driveTestConfig())
|
||||
}
|
||||
|
||||
func TestDriveMemberListSpecResolvesTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantTok string
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "folder URL",
|
||||
token: "https://example.feishu.cn/drive/folder/fldTok?from=share",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "docx URL",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
wantTok: "doxTok",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "bare folder token",
|
||||
token: " fldTok ",
|
||||
docType: " folder ",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "mindnotes URL",
|
||||
token: "https://example.feishu.cn/mindnotes/mndTok",
|
||||
wantTok: "mndTok",
|
||||
wantType: "mindnote",
|
||||
},
|
||||
{
|
||||
name: "minutes URL",
|
||||
token: "https://example.feishu.cn/minutes/obTok",
|
||||
wantTok: "obTok",
|
||||
wantType: "minutes",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, "", "")
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("read spec: %v", err)
|
||||
}
|
||||
if spec.Token != tt.wantTok || spec.Type != tt.wantType {
|
||||
t.Fatalf("spec token/type = %q/%q, want %q/%q", spec.Token, spec.Type, tt.wantTok, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListSpecValidationErrorsAreTyped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
fields string
|
||||
permType string
|
||||
wantParam string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "missing token",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token is required",
|
||||
},
|
||||
{
|
||||
name: "bare token without type",
|
||||
token: "doxTok",
|
||||
wantParam: "--type",
|
||||
wantMessage: "--type is required",
|
||||
},
|
||||
{
|
||||
name: "unsupported URL",
|
||||
token: "https://example.feishu.cn/calendar/calTok",
|
||||
wantParam: "--token",
|
||||
wantMessage: "unsupported --token URL",
|
||||
},
|
||||
{
|
||||
name: "URL type conflict",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
docType: "folder",
|
||||
wantParam: "--type",
|
||||
wantMessage: "conflicts with URL path type",
|
||||
},
|
||||
{
|
||||
name: "invalid bare token",
|
||||
token: "../bad",
|
||||
docType: "folder",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token",
|
||||
},
|
||||
{
|
||||
name: "invalid type",
|
||||
token: "doxTok",
|
||||
docType: "comment",
|
||||
wantParam: "--type",
|
||||
wantMessage: "invalid value",
|
||||
},
|
||||
{
|
||||
name: "invalid fields",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
fields: "name,unknown",
|
||||
wantParam: "--fields",
|
||||
wantMessage: "invalid value",
|
||||
},
|
||||
{
|
||||
name: "star mixed with fields",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
fields: "*,name",
|
||||
wantParam: "--fields",
|
||||
wantMessage: "cannot be combined",
|
||||
},
|
||||
{
|
||||
name: "perm type rejected for non-wiki",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
permType: "single_page",
|
||||
wantParam: "--perm-type",
|
||||
wantMessage: "only applies when resource type is wiki",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, tt.fields, tt.permType)
|
||||
_, err := readDriveMemberListSpec(runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not typed: %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if validationErr.Param != tt.wantParam {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, tt.wantParam)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantMessage) {
|
||||
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListSpecParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
fields string
|
||||
permType string
|
||||
want map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "default omits optional params",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
want: map[string]interface{}{"type": "docx"},
|
||||
},
|
||||
{
|
||||
name: "fields canonicalized and deduplicated",
|
||||
token: "doxTok",
|
||||
docType: "docx",
|
||||
fields: "Name,avatar,name",
|
||||
want: map[string]interface{}{"type": "docx", "fields": "name,avatar"},
|
||||
},
|
||||
{
|
||||
name: "wiki accepts perm type",
|
||||
token: "wikTok",
|
||||
docType: "WIKI",
|
||||
fields: "*",
|
||||
permType: "SINGLE_PAGE",
|
||||
want: map[string]interface{}{"type": "wiki", "fields": "*", "perm_type": "single_page"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, tt.fields, tt.permType)
|
||||
spec, err := readDriveMemberListSpec(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("read spec: %v", err)
|
||||
}
|
||||
if got := spec.params(); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("params = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListDryRunIncludesGETRequest(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveMemberList, []string{
|
||||
"+member-list",
|
||||
"--token", "https://example.feishu.cn/drive/folder/fldTok",
|
||||
"--fields", "*",
|
||||
"--dry-run",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Data struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(got.Data.API) != 1 {
|
||||
t.Fatalf("api count = %d, want 1", len(got.Data.API))
|
||||
}
|
||||
api := got.Data.API[0]
|
||||
if api.Method != "GET" || api.URL != "/open-apis/drive/v1/permissions/fldTok/members" {
|
||||
t.Fatalf("api = %#v", api)
|
||||
}
|
||||
if api.Params["type"] != "folder" || api.Params["fields"] != "*" {
|
||||
t.Fatalf("params = %#v", api.Params)
|
||||
}
|
||||
if _, ok := api.Params["perm_type"]; ok {
|
||||
t.Fatalf("perm_type should be omitted for folder: %#v", api.Params)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListExecutePreservesRawData(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
var capturedQuery string
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/permissions/doxTok/members",
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.RawQuery
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"member_id": "ou_x",
|
||||
"member_type": "openid",
|
||||
"perm": "view",
|
||||
"type": "user",
|
||||
"name": "zhangsan",
|
||||
"server_future": "preserved",
|
||||
"external_label": true,
|
||||
},
|
||||
},
|
||||
"server_top_level": "preserved",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMemberList, []string{
|
||||
"+member-list",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--fields", "name,type,external_label",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(capturedQuery, "type=docx") ||
|
||||
!strings.Contains(capturedQuery, "fields=name%2Ctype%2Cexternal_label") {
|
||||
t.Fatalf("captured query = %q", capturedQuery)
|
||||
}
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
if data["server_top_level"] != "preserved" {
|
||||
t.Fatalf("server_top_level = %#v", data["server_top_level"])
|
||||
}
|
||||
for _, key := range []string{"token", "type", "count"} {
|
||||
if _, ok := data[key]; ok {
|
||||
t.Fatalf("data[%s] = %#v, want omitted", key, data[key])
|
||||
}
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items = %#v, want one item", data["items"])
|
||||
}
|
||||
item, _ := items[0].(map[string]interface{})
|
||||
if item["server_future"] != "preserved" || item["external_label"] != true {
|
||||
t.Fatalf("item future fields not preserved: %#v", item)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "Found 1 Drive member") {
|
||||
t.Fatalf("stderr = %q, want count log", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListDeclaresScopeAndIdentities(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !reflect.DeepEqual(DriveMemberList.Scopes, []string{"docs:permission.member:retrieve"}) {
|
||||
t.Fatalf("Scopes = %v, want docs:permission.member:retrieve", DriveMemberList.Scopes)
|
||||
}
|
||||
if !reflect.DeepEqual(DriveMemberList.AuthTypes, []string{"user", "bot"}) {
|
||||
t.Fatalf("AuthTypes = %v, want [user bot]", DriveMemberList.AuthTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDriveMemberListPrettyOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/permissions/wikTok/members",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"member_id": "ou_x",
|
||||
"member_type": "openid",
|
||||
"perm": "view",
|
||||
"perm_type": "single_page",
|
||||
"type": "user",
|
||||
"name": "zhangsan",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveMemberList, []string{
|
||||
"+member-list",
|
||||
"--token", "wikTok",
|
||||
"--type", "wiki",
|
||||
"--perm-type", "single_page",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"[1] ou_x", "member_type: openid", "perm_type: single_page", "name: zhangsan"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("pretty output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
241
shortcuts/drive/drive_permission_get_setting.go
Normal file
241
shortcuts/drive/drive_permission_get_setting.go
Normal file
@@ -0,0 +1,241 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type drivePermissionGetSettingSpec struct {
|
||||
Token string
|
||||
Type string
|
||||
}
|
||||
|
||||
var drivePermissionGetSettingTypes = []string{
|
||||
"doc", "sheet", "file", "wiki", "bitable", "docx",
|
||||
"mindnote", "minutes", "slides", "folder",
|
||||
}
|
||||
|
||||
var drivePermissionGetSettingURLPathToType = []struct {
|
||||
Prefix string
|
||||
Type string
|
||||
}{
|
||||
{"/drive/folder/", "folder"},
|
||||
{"/docx/", "docx"},
|
||||
{"/doc/", "doc"},
|
||||
{"/sheets/", "sheet"},
|
||||
{"/base/", "bitable"},
|
||||
{"/bitable/", "bitable"},
|
||||
{"/wiki/", "wiki"},
|
||||
{"/file/", "file"},
|
||||
{"/mindnotes/", "mindnote"},
|
||||
{"/slides/", "slides"},
|
||||
{"/minutes/", "minutes"},
|
||||
}
|
||||
|
||||
func readDrivePermissionGetSettingSpec(runtime *common.RuntimeContext) (drivePermissionGetSettingSpec, error) {
|
||||
rawToken := strings.TrimSpace(runtime.Str("token"))
|
||||
explicitType := strings.ToLower(strings.TrimSpace(runtime.Str("type")))
|
||||
|
||||
if rawToken == "" {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--token is required",
|
||||
).WithParam("--token")
|
||||
}
|
||||
|
||||
if explicitType != "" && !drivePermissionGetSettingTypeAllowed(explicitType) {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid --type %q: allowed values are %s",
|
||||
explicitType,
|
||||
strings.Join(drivePermissionGetSettingTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
|
||||
if strings.Contains(rawToken, "://") {
|
||||
ref, ok := parseDrivePermissionGetSettingResourceURL(rawToken)
|
||||
if !ok {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"unsupported --token URL %q: pass a recognized Lark Drive document/folder URL or a bare token with --type",
|
||||
rawToken,
|
||||
).WithParam("--token")
|
||||
}
|
||||
if explicitType != "" && explicitType != ref.Type {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
|
||||
explicitType,
|
||||
ref.Type,
|
||||
).WithParam("--type")
|
||||
}
|
||||
if err := validate.ResourceName(ref.Token, "--token"); err != nil {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return drivePermissionGetSettingSpec{Token: ref.Token, Type: ref.Type}, nil
|
||||
}
|
||||
|
||||
if explicitType == "" {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--type is required when --token is a bare token (allowed: %s)",
|
||||
strings.Join(drivePermissionGetSettingTypes, ", "),
|
||||
).WithParam("--type")
|
||||
}
|
||||
|
||||
if err := validate.ResourceName(rawToken, "--token"); err != nil {
|
||||
return drivePermissionGetSettingSpec{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token")
|
||||
}
|
||||
return drivePermissionGetSettingSpec{Token: rawToken, Type: explicitType}, nil
|
||||
}
|
||||
|
||||
func parseDrivePermissionGetSettingResourceURL(rawURL string) (common.ResourceRef, bool) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || parsed.Hostname() == "" {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
for _, mapping := range drivePermissionGetSettingURLPathToType {
|
||||
if !strings.HasPrefix(parsed.Path, mapping.Prefix) {
|
||||
continue
|
||||
}
|
||||
token := parsed.Path[len(mapping.Prefix):]
|
||||
token = strings.TrimRight(token, "/")
|
||||
if idx := strings.IndexByte(token, '/'); idx >= 0 {
|
||||
token = token[:idx]
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
return common.ResourceRef{Type: mapping.Type, Token: token}, true
|
||||
}
|
||||
|
||||
return common.ResourceRef{}, false
|
||||
}
|
||||
|
||||
func drivePermissionGetSettingTypeAllowed(docType string) bool {
|
||||
for _, allowed := range drivePermissionGetSettingTypes {
|
||||
if docType == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s drivePermissionGetSettingSpec) url(runtime *common.RuntimeContext) string {
|
||||
if runtime != nil && runtime.Config != nil {
|
||||
if u := common.BuildResourceURL(runtime.Config.Brand, s.Type, s.Token); u != "" {
|
||||
return u
|
||||
}
|
||||
}
|
||||
return common.BuildResourceURL("", s.Type, s.Token)
|
||||
}
|
||||
|
||||
func (s drivePermissionGetSettingSpec) params() map[string]interface{} {
|
||||
return map[string]interface{}{"type": s.Type}
|
||||
}
|
||||
|
||||
func (s drivePermissionGetSettingSpec) apiPath() string {
|
||||
return drivePermissionPublicV2Path(s.Token)
|
||||
}
|
||||
|
||||
func drivePermissionPublicV2Path(token string) string {
|
||||
return fmt.Sprintf("/open-apis/drive/v2/permissions/%s/public", validate.EncodePathSegment(token))
|
||||
}
|
||||
|
||||
func drivePermissionGetSettingPermissionPublic(data map[string]interface{}) (map[string]interface{}, error) {
|
||||
permissionPublic := common.GetMap(data, "permission_public")
|
||||
if permissionPublic == nil {
|
||||
return nil, errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"drive permission get response missing data.permission_public",
|
||||
)
|
||||
}
|
||||
return permissionPublic, nil
|
||||
}
|
||||
|
||||
// DrivePermissionGetSetting queries permission_public settings for a Drive
|
||||
// document, file, wiki node, or folder.
|
||||
var DrivePermissionGetSetting = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+permission-get-setting",
|
||||
Description: "Get public access, sharing, collaborator management, security, and comment permission settings",
|
||||
Risk: "read",
|
||||
Scopes: []string{"docs:permission.setting:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder)", Required: true},
|
||||
{Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens", Enum: drivePermissionGetSettingTypes},
|
||||
},
|
||||
Tips: []string{
|
||||
"--token accepts a Lark URL or bare token; pass --type when using a bare token.",
|
||||
"Use --type folder for Drive folders. This shortcut reads the target's own permission settings; it does not recurse into child documents.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
_, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
spec, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Get Drive permission settings").
|
||||
GET(spec.apiPath()).
|
||||
Params(spec.params())
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
spec, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Getting permission settings for %s %s...\n", spec.Type, common.MaskToken(spec.Token))
|
||||
data, err := runtime.CallAPITyped(
|
||||
"GET",
|
||||
spec.apiPath(),
|
||||
spec.params(),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
permissionPublic, err := drivePermissionGetSettingPermissionPublic(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
permissionPublicPretty, err := json.MarshalIndent(permissionPublic, "", " ")
|
||||
if err != nil {
|
||||
return errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"encode drive permission settings for pretty output",
|
||||
).WithCause(err)
|
||||
}
|
||||
|
||||
out := map[string]interface{}{"permission_public": permissionPublic}
|
||||
runtime.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Type: %s\n", spec.Type)
|
||||
fmt.Fprintf(w, "Token: %s\n", spec.Token)
|
||||
if url := spec.url(runtime); url != "" {
|
||||
fmt.Fprintf(w, "URL: %s\n", url)
|
||||
}
|
||||
fmt.Fprintf(w, "Permission settings:\n%s\n", permissionPublicPretty)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
438
shortcuts/drive/drive_permission_get_setting_test.go
Normal file
438
shortcuts/drive/drive_permission_get_setting_test.go
Normal file
@@ -0,0 +1,438 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func newDrivePermissionGetSettingRuntime(t *testing.T, token, docType string) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "drive +permission-get-setting"}
|
||||
cmd.Flags().String("token", "", "")
|
||||
cmd.Flags().String("type", "", "")
|
||||
if token != "" {
|
||||
if err := cmd.Flags().Set("token", token); err != nil {
|
||||
t.Fatalf("set --token: %v", err)
|
||||
}
|
||||
}
|
||||
if docType != "" {
|
||||
if err := cmd.Flags().Set("type", docType); err != nil {
|
||||
t.Fatalf("set --type: %v", err)
|
||||
}
|
||||
}
|
||||
return common.TestNewRuntimeContext(cmd, driveTestConfig())
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingSpecResolvesTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantTok string
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "folder URL",
|
||||
token: "https://example.feishu.cn/drive/folder/fldTok?from=share",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "docx URL",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
wantTok: "doxTok",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "file URL",
|
||||
token: "https://example.feishu.cn/file/boxTok",
|
||||
wantTok: "boxTok",
|
||||
wantType: "file",
|
||||
},
|
||||
{
|
||||
name: "wiki URL",
|
||||
token: "https://example.feishu.cn/wiki/wikTok",
|
||||
wantTok: "wikTok",
|
||||
wantType: "wiki",
|
||||
},
|
||||
{
|
||||
name: "minutes URL",
|
||||
token: "https://example.feishu.cn/minutes/obTok",
|
||||
wantTok: "obTok",
|
||||
wantType: "minutes",
|
||||
},
|
||||
{
|
||||
name: "mindnotes URL",
|
||||
token: "https://example.feishu.cn/mindnotes/mndTok",
|
||||
wantTok: "mndTok",
|
||||
wantType: "mindnote",
|
||||
},
|
||||
{
|
||||
name: "bare folder token",
|
||||
token: " fldTok ",
|
||||
docType: " folder ",
|
||||
wantTok: "fldTok",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "bare file token",
|
||||
token: "boxTok",
|
||||
docType: "file",
|
||||
wantTok: "boxTok",
|
||||
wantType: "file",
|
||||
},
|
||||
{
|
||||
name: "bare wiki token",
|
||||
token: "wikTok",
|
||||
docType: "wiki",
|
||||
wantTok: "wikTok",
|
||||
wantType: "wiki",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
|
||||
spec, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("read spec: %v", err)
|
||||
}
|
||||
if spec.Token != tt.wantTok {
|
||||
t.Fatalf("Token = %q, want %q", spec.Token, tt.wantTok)
|
||||
}
|
||||
if spec.Type != tt.wantType {
|
||||
t.Fatalf("Type = %q, want %q", spec.Type, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingSpecValidationErrorsAreTyped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantParam string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "missing token",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token is required",
|
||||
},
|
||||
{
|
||||
name: "bare token without type",
|
||||
token: "doxTok",
|
||||
wantParam: "--type",
|
||||
wantMessage: "--type is required",
|
||||
},
|
||||
{
|
||||
name: "unsupported URL",
|
||||
token: "https://example.feishu.cn/calendar/calTok",
|
||||
wantParam: "--token",
|
||||
wantMessage: "unsupported --token URL",
|
||||
},
|
||||
{
|
||||
name: "URL type conflict",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
docType: "sheet",
|
||||
wantParam: "--type",
|
||||
wantMessage: "conflicts with URL path type",
|
||||
},
|
||||
{
|
||||
name: "invalid bare token",
|
||||
token: "../bad",
|
||||
docType: "folder",
|
||||
wantParam: "--token",
|
||||
wantMessage: "--token",
|
||||
},
|
||||
{
|
||||
name: "invalid type",
|
||||
token: "doxTok",
|
||||
docType: "comment",
|
||||
wantParam: "--type",
|
||||
wantMessage: "invalid --type",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
|
||||
_, err := readDrivePermissionGetSettingSpec(runtime)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not typed: %T %v", err, err)
|
||||
}
|
||||
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
|
||||
}
|
||||
if validationErr, ok := err.(*errs.ValidationError); ok {
|
||||
if validationErr.Param != tt.wantParam {
|
||||
t.Fatalf("param = %q, want %q", validationErr.Param, tt.wantParam)
|
||||
}
|
||||
} else {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantMessage) {
|
||||
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantMessage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingDryRunIncludesGETRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
docType string
|
||||
wantURL string
|
||||
wantType string
|
||||
}{
|
||||
{
|
||||
name: "folder URL",
|
||||
token: "https://example.feishu.cn/drive/folder/fldTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/fldTok/public",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "bare folder token",
|
||||
token: "fldTok",
|
||||
docType: "folder",
|
||||
wantURL: "/open-apis/drive/v2/permissions/fldTok/public",
|
||||
wantType: "folder",
|
||||
},
|
||||
{
|
||||
name: "docx URL",
|
||||
token: "https://example.feishu.cn/docx/doxTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/doxTok/public",
|
||||
wantType: "docx",
|
||||
},
|
||||
{
|
||||
name: "bare wiki token",
|
||||
token: "wikTok",
|
||||
docType: "wiki",
|
||||
wantURL: "/open-apis/drive/v2/permissions/wikTok/public",
|
||||
wantType: "wiki",
|
||||
},
|
||||
{
|
||||
name: "file URL",
|
||||
token: "https://example.feishu.cn/file/boxTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/boxTok/public",
|
||||
wantType: "file",
|
||||
},
|
||||
{
|
||||
name: "minutes URL",
|
||||
token: "https://example.feishu.cn/minutes/obTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/obTok/public",
|
||||
wantType: "minutes",
|
||||
},
|
||||
{
|
||||
name: "mindnotes URL",
|
||||
token: "https://example.feishu.cn/mindnotes/mndTok",
|
||||
wantURL: "/open-apis/drive/v2/permissions/mndTok/public",
|
||||
wantType: "mindnote",
|
||||
},
|
||||
}
|
||||
|
||||
for _, temp := range tests {
|
||||
tt := temp
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
runtime := newDrivePermissionGetSettingRuntime(t, tt.token, tt.docType)
|
||||
dry := DrivePermissionGetSetting.DryRun(context.Background(), runtime)
|
||||
if dry == nil {
|
||||
t.Fatal("DryRun returned nil")
|
||||
}
|
||||
data, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry-run: %v", err)
|
||||
}
|
||||
out := string(data)
|
||||
for _, want := range []string{
|
||||
`"` + tt.wantURL + `"`,
|
||||
`"GET"`,
|
||||
`"type":"` + tt.wantType + `"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry-run output missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, `"folder_token"`) {
|
||||
t.Fatalf("dry-run output contains folder_token, want omitted:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingExecutePreservesPermissionPublic(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"permission_public": map[string]interface{}{
|
||||
"link_share_entity": "closed",
|
||||
"external_access_entity": "closed",
|
||||
"security_entity": "anyone_can_view",
|
||||
"comment_entity": "anyone_can_view",
|
||||
"share_entity": "anyone",
|
||||
"manage_collaborator_entity": "collaborator_can_view",
|
||||
"lock_switch": false,
|
||||
"server_future_field": "preserved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
||||
"+permission-get-setting",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeDriveEnvelope(t, stdout)
|
||||
for _, key := range []string{"type", "token", "url"} {
|
||||
if _, ok := data[key]; ok {
|
||||
t.Fatalf("data[%s] = %#v, want field omitted", key, data[key])
|
||||
}
|
||||
}
|
||||
permissionPublic, _ := data["permission_public"].(map[string]interface{})
|
||||
if permissionPublic == nil {
|
||||
t.Fatalf("permission_public missing in output: %#v", data)
|
||||
}
|
||||
for key, want := range map[string]interface{}{
|
||||
"link_share_entity": "closed",
|
||||
"external_access_entity": "closed",
|
||||
"security_entity": "anyone_can_view",
|
||||
"comment_entity": "anyone_can_view",
|
||||
"share_entity": "anyone",
|
||||
"manage_collaborator_entity": "collaborator_can_view",
|
||||
"lock_switch": false,
|
||||
"server_future_field": "preserved",
|
||||
} {
|
||||
if permissionPublic[key] != want {
|
||||
t.Fatalf("permission_public[%s] = %#v, want %#v", key, permissionPublic[key], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingExecuteRejectsMissingPermissionPublic(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{"unexpected": "response"},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
||||
"+permission-get-setting",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid response error, got nil")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("problem = %#v, want internal/invalid_response", problem)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout should be empty on invalid response, got %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingExecutePrettyFormatIncludesPermissionPublic(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v2/permissions/doxTok/public?type=docx",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"permission_public": map[string]interface{}{
|
||||
"link_share_entity": "closed",
|
||||
"server_future_field": "preserved",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePermissionGetSetting, []string{
|
||||
"+permission-get-setting",
|
||||
"--token", "doxTok",
|
||||
"--type", "docx",
|
||||
"--format", "pretty",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Permission settings:",
|
||||
`"link_share_entity": "closed"`,
|
||||
`"server_future_field": "preserved"`,
|
||||
} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("pretty output missing %q:\n%s", want, stdout.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePermissionGetSettingDeclaresScopeAndIdentities(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if !reflect.DeepEqual(DrivePermissionGetSetting.Scopes, []string{"docs:permission.setting:read"}) {
|
||||
t.Fatalf("Scopes = %v, want docs:permission.setting:read", DrivePermissionGetSetting.Scopes)
|
||||
}
|
||||
if !reflect.DeepEqual(DrivePermissionGetSetting.AuthTypes, []string{"user", "bot"}) {
|
||||
t.Fatalf("AuthTypes = %v, want [user bot]", DrivePermissionGetSetting.AuthTypes)
|
||||
}
|
||||
for _, flag := range DrivePermissionGetSetting.Flags {
|
||||
if flag.Name == "token" && !flag.Required {
|
||||
t.Fatal("--token must be declared required")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ func Shortcuts() []common.Shortcut {
|
||||
DriveTaskResult,
|
||||
DriveApplyPermission,
|
||||
DriveMemberAdd,
|
||||
DriveMemberList,
|
||||
DrivePermissionGetSetting,
|
||||
DriveSecureLabelList,
|
||||
DriveSecureLabelUpdate,
|
||||
DriveSearch,
|
||||
|
||||
@@ -39,6 +39,8 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
"+task_result",
|
||||
"+apply-permission",
|
||||
"+member-add",
|
||||
"+member-list",
|
||||
"+permission-get-setting",
|
||||
"+secure-label-list",
|
||||
"+secure-label-update",
|
||||
"+search",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -43,7 +43,7 @@ var SlidesScreenshot = common.Shortcut{
|
||||
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"},
|
||||
@@ -55,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") {
|
||||
@@ -71,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
|
||||
@@ -96,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())
|
||||
@@ -146,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
|
||||
@@ -198,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") {
|
||||
@@ -217,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") {
|
||||
|
||||
@@ -185,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)
|
||||
@@ -387,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, ""))
|
||||
|
||||
|
||||
@@ -57,12 +57,12 @@ metadata:
|
||||
| 写记录 | `+record-upsert` / `+record-batch-create` / `+record-batch-update` | 必读 [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) 和 [lark-base-cell-value.md](references/lark-base-cell-value.md) |
|
||||
| 附件字段 | `+record-upload-attachment` / `+record-download-attachment` / `+record-remove-attachment` | 附件不要伪造成普通 CellValue;上传走本地文件,下载/删除按 file token 或字段定位 |
|
||||
| 删除记录 / 分享记录链接 / 历史 | `+record-delete` / `+record-share-link-create` / `+record-history-list` | 删除前确认 record;分享链接最多 100 条;历史读 [lark-base-record-history-list.md](references/lark-base-record-history-list.md),只查单条记录,不做整表审计 |
|
||||
| 管理视图 | `+view-*` | `+view-set-filter` 读 [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md);其余配置先 get 现状,再按返回结构更新 |
|
||||
| 管理视图 | `+view-*` | `+view-set-filter` 读 [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md)(filter 条件结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md));其余配置先 get 现状,再按返回结构更新 |
|
||||
| 一次性聚合统计 | `+data-query` | 必读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) 和入口 [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md);完整 DSL 再读 [lark-base-data-query.md](references/lark-base-data-query.md) |
|
||||
| 公式字段 | `+field-create/update --json '{"type":"formula",...}'` | 必读 [formula-field-guide.md](references/formula-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
|
||||
| Lookup 字段 | `+field-create/update --json '{"type":"lookup",...}'` | 必读 [lookup-field-guide.md](references/lookup-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
|
||||
| 表单提交 | `+form-submit` | 先读 [lark-base-form-detail.md](references/lark-base-form-detail.md) 获取题目、filter 和附件所需 `base_token`;提交 JSON 读 [lark-base-form-submit.md](references/lark-base-form-submit.md) |
|
||||
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md) |
|
||||
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md);题目显隐条件 `visible_rule` 结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md) |
|
||||
| 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail` 读 [lark-base-form-detail.md](references/lark-base-form-detail.md);删除前确认目标表单 |
|
||||
| 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 [lark-base-dashboard.md](references/lark-base-dashboard.md);组件 `data_config` 读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md);读取图表计算结果用 `+dashboard-block-get-data` |
|
||||
| Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);list/get/enable/disable 只处理 workflow ID 与启停状态 |
|
||||
@@ -116,6 +116,7 @@ metadata:
|
||||
## 表单与视图细节
|
||||
|
||||
- `+form-submit` 是高风险写操作,必须带 `--yes` 确认;调用前必须先跑 `+form-detail`,读取 `questions[].type`、`required`、`filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
|
||||
- `+form-questions-update` 是题目配置全量覆盖,不是 patch;未传字段会回落默认值,传空字符串 / `null` / 空数组会直接写入空或清空。更新前先 `+form-questions-list` 读取当前题目,把要保留的 `title` / `description` / `required` / `option_display_mode` / `visible_rule` 等字段带回请求。
|
||||
- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`。
|
||||
- `+view-set-filter` 是唯一保留的 view reference;sort/group/card/timebar/visible-fields 这类配置先用对应 get 命令读现状,保留未修改字段,只替换用户要求变更的配置。
|
||||
- 视图适合持久化、共享和 UI 复用;一次性筛选/排序可先用 `+record-list` / `+record-search` 的 filter/sort 验证结果,再按需要沉淀为持久视图。
|
||||
@@ -146,13 +147,14 @@ metadata:
|
||||
## 保留 Reference
|
||||
|
||||
- [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md):查询/统计/全局结论的选路 SOP
|
||||
- [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md) / [lark-base-data-query.md](references/lark-base-data-query.md):聚合查询入口 fewshot 与 DSL SSOT
|
||||
- [lark-base-data-query-guide.md](references/lark-base-data-query-guide.md) / [lark-base-data-query.md](references/lark-base-data-query.md):聚合查询入口 fewshot 与 DSL SSOT;`+data-query` 的 `filters` 结构是独立对象 DSL,不使用公共 tuple filter 协议
|
||||
- [lark-base-cell-value.md](references/lark-base-cell-value.md):记录 CellValue 构造
|
||||
- [lark-base-field-json.md](references/lark-base-field-json.md):字段 JSON 构造
|
||||
- [formula-field-guide.md](references/formula-field-guide.md) / [lookup-field-guide.md](references/lookup-field-guide.md):公式与 lookup 字段
|
||||
- [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md):字段创建/更新命令级补充
|
||||
- [lark-base-record-upsert.md](references/lark-base-record-upsert.md) / [lark-base-record-batch-create.md](references/lark-base-record-batch-create.md) / [lark-base-record-batch-update.md](references/lark-base-record-batch-update.md) / [lark-base-record-history-list.md](references/lark-base-record-history-list.md):记录写入 JSON 与历史返回解释
|
||||
- [lark-base-view-set-filter.md](references/lark-base-view-set-filter.md):视图筛选 JSON
|
||||
- [lark-base-filter-condition.md](references/lark-base-filter-condition.md):视图 filter、记录 `--filter-json`、表单 `visible_rule` 的 tuple 条件结构公共协议 SSOT;不适用于 `+data-query`
|
||||
- [lark-base-form-detail.md](references/lark-base-form-detail.md) / [lark-base-form-submit.md](references/lark-base-form-submit.md) / [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md):表单详情、提交和复杂 JSON
|
||||
- [lark-base-dashboard.md](references/lark-base-dashboard.md) / [dashboard-block-data-config.md](references/dashboard-block-data-config.md) / [lark-base-dashboard-block-get-data.md](references/lark-base-dashboard-block-get-data.md):仪表盘、组件配置与图表结果协议
|
||||
- [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) / [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md):workflow 入口与 steps JSON SSOT
|
||||
|
||||
179
skills/lark-base/references/lark-base-filter-condition.md
Normal file
179
skills/lark-base/references/lark-base-filter-condition.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Base Filter 条件结构(公共协议)
|
||||
|
||||
Filter 是一组「字段/操作符/值」条件的组合,用 `logic`(`and` / `or`)把多条 `conditions` 连接起来,用于描述「满足什么条件」。视图筛选 `filter`、记录读取/搜索的 `--filter-json`、表单题目显隐条件 `visible_rule` 复用同一套 tuple 结构,本文件是其公共协议(SSOT)。
|
||||
|
||||
## 0. 适用范围
|
||||
|
||||
本协议只适用于以下场景:
|
||||
|
||||
- `+view-set-filter` / `+view-get-filter` 的视图筛选配置。
|
||||
- `+record-list --filter-json` / `+record-search --filter-json` 的结构化记录筛选。
|
||||
- `+form-questions-create` / `+form-questions-update` 中的 `visible_rule` 显隐条件。
|
||||
|
||||
本协议**不适用于 `+data-query`**。`+data-query` 支持过滤,但使用的是 LiteQuery DSL 的 `filters` 对象结构:`{"type":1,"conjunction":"and","conditions":[{"field_name":"状态","operator":"is","value":["有效"]}]}`,不是这里的 tuple 条件 `["状态","==","有效"]`。构造 `+data-query --dsl` 时请阅读 [lark-base-data-query.md](lark-base-data-query.md) 的 FilterGroup / Condition 章节。
|
||||
|
||||
## 1. 顶层结构
|
||||
|
||||
- 必须是 JSON 对象。
|
||||
- 顶层结构是 `{logic?, conditions?}`。
|
||||
- `logic` 默认 `and`;推荐只用 canonical 值 `and` / `or`。
|
||||
- `conditions` 默认空数组。
|
||||
- 每条条件写成 tuple:`[field, operator, value?]`。
|
||||
- `empty` / `non_empty` 可写成 2 项:`[field, "empty"]`、`[field, "non_empty"]`。
|
||||
|
||||
```json
|
||||
{
|
||||
"logic": "and",
|
||||
"conditions": [
|
||||
["状态", "intersects", ["Doing"]],
|
||||
["负责人", "intersects", [{ "id": "ou_xxx" }]],
|
||||
["截止时间", "empty"]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
清空写法:
|
||||
|
||||
```json
|
||||
{
|
||||
"conditions": []
|
||||
}
|
||||
```
|
||||
|
||||
## 2. operator
|
||||
|
||||
可用 operator:
|
||||
- `==`
|
||||
- `!=`
|
||||
- `>`
|
||||
- `>=`
|
||||
- `<`
|
||||
- `<=`
|
||||
- `intersects`
|
||||
- `disjoint`
|
||||
- `empty`
|
||||
- `non_empty`
|
||||
|
||||
## 3. value 写法
|
||||
|
||||
value 类型取决于条件引用对象(字段 / 题目)的类型。
|
||||
|
||||
### `text`
|
||||
|
||||
用字符串:
|
||||
|
||||
```json
|
||||
["标题", "intersects", "发布"]
|
||||
```
|
||||
|
||||
### `location`
|
||||
|
||||
location 筛选只按 `full_address` 字符串匹配,不能直接按经纬度筛选;优先使用 `intersects` 做包含匹配,例如查深圳:
|
||||
|
||||
```json
|
||||
["位置", "intersects", "深圳"]
|
||||
```
|
||||
|
||||
不推荐写 `["位置", "==", "深圳"]` 这类精确匹配,除非确保筛选值与完整 `full_address` 完全一致。
|
||||
|
||||
### `number` / `auto_number`
|
||||
|
||||
用数字:
|
||||
|
||||
```json
|
||||
["工时", ">=", 3.5]
|
||||
```
|
||||
|
||||
### `select`
|
||||
|
||||
用选项名数组:
|
||||
|
||||
```json
|
||||
["状态", "intersects", ["Doing", "Blocked"]]
|
||||
```
|
||||
|
||||
### `user` / `created_by` / `updated_by`
|
||||
|
||||
用对象数组:
|
||||
|
||||
> **人员筛选:不要猜 ID。** 不知道 `open_id` 时,先用 `lark-contact` 查 id:`lark-cli contact +search-user --query "<姓名/邮箱/手机号>" --as user`。
|
||||
|
||||
```json
|
||||
["负责人", "intersects", [{ "id": "ou_xxx" }]]
|
||||
```
|
||||
|
||||
### `group_chat`
|
||||
|
||||
用对象数组:
|
||||
|
||||
> **群组筛选:不要猜 ID。** 不知道 `chat_id` 时,先用 `lark-im` 搜群:`lark-cli im +chat-search --query "<群名关键词>" --as user`;取结果里的 `oc_xxx`。
|
||||
|
||||
```json
|
||||
["负责群", "intersects", [{ "id": "oc_xxx" }]]
|
||||
```
|
||||
|
||||
### `link`
|
||||
|
||||
用记录 id 对象数组:
|
||||
|
||||
```json
|
||||
["关联任务", "intersects", [{ "id": "rec_xxx" }]]
|
||||
```
|
||||
|
||||
### `checkbox`
|
||||
|
||||
用布尔值:
|
||||
|
||||
```json
|
||||
["完成", "==", true]
|
||||
```
|
||||
|
||||
### `datetime` / `created_at` / `updated_at`
|
||||
|
||||
用相对时间关键字或 `ExactDate(...)`:
|
||||
|
||||
```json
|
||||
["截止时间", "==", "ExactDate(2026-01-01)"]
|
||||
```
|
||||
|
||||
```json
|
||||
["截止时间", "==", "ExactDate(2026-01-01 11:30)"]
|
||||
```
|
||||
|
||||
```json
|
||||
["截止时间", "==", "Today"]
|
||||
```
|
||||
|
||||
可用关键字:
|
||||
- `Today`
|
||||
- `Yesterday`
|
||||
- `Tomorrow`
|
||||
|
||||
### `formula` / `lookup`
|
||||
|
||||
- 筛选值类型由字段计算结果类型动态决定。
|
||||
- 拿不准时,先把 `value` 当作单个字符串填入做一次尝试。
|
||||
- 如果报错,再按错误提示把 `value` 改成对应类型。
|
||||
|
||||
字符串示例:
|
||||
|
||||
```json
|
||||
["风险说明", "intersects", "高风险"]
|
||||
```
|
||||
|
||||
数字示例:
|
||||
|
||||
```json
|
||||
["汇总分", ">=", 80]
|
||||
```
|
||||
|
||||
## 4. 易错点
|
||||
|
||||
- 不要再写旧对象风格:`{"field_name":...,"operator":...}`。
|
||||
- `user` / `group_chat` / `link` 不要写成单个标量。
|
||||
- `empty` / `non_empty` 不要硬塞无意义的 value。
|
||||
- 日期条件稳定写法用 `ExactDate(...)` 或 `Today` / `Yesterday` / `Tomorrow`。
|
||||
- `formula` / `lookup` 的 value 形状不固定;拿不准时先读当前配置或字段定义,或根据错误提示修正类型。
|
||||
|
||||
## 5. 参考
|
||||
- [lookup-field-guide.md](lookup-field-guide.md)
|
||||
@@ -19,10 +19,7 @@ lark-cli base +form-questions-create \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[
|
||||
{"type":"text","title":"您的姓名是?","required":true},
|
||||
{"type":"text","title":"您的联系方式是?","required":false}
|
||||
]'
|
||||
--questions '[{"type":"text","title":"您的姓名是?","required":true},{"type":"text","title":"您的联系方式是?","required":false}]'
|
||||
|
||||
# 添加单选题(带选项)
|
||||
lark-cli base +form-questions-create \
|
||||
@@ -50,6 +47,13 @@ lark-cli base +form-questions-create \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"type":"text","title":"反馈建议","description":"更多详情请查看[帮助文档](https://example.com/help)"}]'
|
||||
|
||||
# 添加带显隐条件(visible_rule)的问题:当「是否需要发票」选择「是」时才显示「发票抬头」
|
||||
lark-cli base +form-questions-create \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"type":"select","title":"是否需要发票","required":true,"options":[{"name":"是","hue":"Blue"},{"name":"否","hue":"Gray"}]},{"type":"text","title":"发票抬头","visible_rule":{"logic":"and","conditions":[["是否需要发票","==","是"]]}}]'
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -78,6 +82,7 @@ lark-cli base +form-questions-create \
|
||||
| `multiple` | 否 | 是否多选(`select`/`user` 类型有效,bool) |
|
||||
| `options` | 否 | 选项列表(仅 `select` 有效):`[{"name":"选项1","hue":"Blue"}]`,hue 可选:`Red`/`Orange`/`Yellow`/`Green`/`Blue`/`Purple`/`Gray` |
|
||||
| `style` | 否 | 字段样式配置(见下方说明) |
|
||||
| `visible_rule` | 否 | 题目显隐条件(见下方「`visible_rule` 显隐条件」) |
|
||||
|
||||
### `style` 字段说明
|
||||
|
||||
@@ -88,6 +93,30 @@ lark-cli base +form-questions-create \
|
||||
| `number`(评分) | `{"type":"rating","icon":"star","min":1,"max":5}` | icon 可选:`star`/`heart`/`thumbsup`/`fire`/`smile`/`lightning`/`flower`/`number` |
|
||||
| `datetime` | `{"format":"yyyy/MM/dd"}` | format 可选:`yyyy/MM/dd`、`yyyy/MM/dd HH:mm`、`MM-dd`、`MM/dd/yyyy`、`dd/MM/yyyy` |
|
||||
|
||||
### `visible_rule` 显隐条件
|
||||
|
||||
> **仅当用户明确要求为题目设置显隐条件(显示/隐藏逻辑)时,才需要读下面的结构说明;否则忽略本节。**
|
||||
|
||||
`visible_rule` 控制题目在表单中的显示/隐藏:当条件满足时题目显示,不满足时隐藏;不传或 `conditions` 为空数组则题目始终显示。
|
||||
|
||||
- **结构与视图筛选 `filter` 完全一致**,即 `{logic?, conditions?}`,共用同一套公共协议。
|
||||
- 与视图 `filter` 唯一的区别:`conditions` 中的 `field` 引用的是**同一表单内其他题目的题目名称或题目 ID**(推荐用题目 ID 以避免重名歧义),而不是数据表字段。
|
||||
- **只能引用前序题目**:条件只能引用排在当前题目之前的题目——创建时按 `questions` 数组顺序判定(可引用同批次更靠前的新题目或表单中已有题目),不支持循环引用。
|
||||
- 引用的题目必须真实存在,否则会报错。
|
||||
- 列出题目(`+form-questions-list`)会在每个题目对象中**原样返回** `visible_rule`;未设置显隐条件的题目返回 `null` 或 `conditions` 为空数组。
|
||||
|
||||
```json
|
||||
{
|
||||
"logic": "and",
|
||||
"conditions": [
|
||||
["是否需要发票", "==", "是"],
|
||||
["报销金额", ">=", 1000]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
详细的 `visible_rule` 结构(顶层规则、operator 列表、各题目类型的 value 写法)请阅读 [lark-base-filter-condition.md](lark-base-filter-condition.md)。
|
||||
|
||||
## 输出格式
|
||||
|
||||
返回创建成功的问题列表:
|
||||
@@ -115,4 +144,5 @@ lark-cli base +form-questions-create \
|
||||
## 参考
|
||||
|
||||
- [lark-base](../SKILL.md) — 多维表格全部命令
|
||||
- [lark-base-filter-condition.md](lark-base-filter-condition.md) — `visible_rule` / `filter` 条件结构公共协议
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -2,40 +2,60 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
批量更新多维表格表单/问卷中的问题(标题、描述、是否必填)。
|
||||
批量更新多维表格表单/问卷中的问题配置(标题、描述、是否必填、显隐条件等)。
|
||||
|
||||
> [!CAUTION]
|
||||
> `+form-questions-update` 是**题目配置全量覆盖**,不是 patch。对每个传入的题目,未携带的属性会回落为默认值,显式传空字符串 / `null` / 空数组会直接写入空或清空;如果要保留现有属性,必须先用 `+form-questions-list` 查出现状,再把要保留的字段一起带回 `--questions`。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 更新一个问题的标题
|
||||
lark-cli base +form-questions-update \
|
||||
# 先读取现有题目配置,作为 read-modify-write 的基线
|
||||
lark-cli base +form-questions-list \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_001","title":"您的真实姓名是?"}]'
|
||||
--form-id <form_id>
|
||||
|
||||
# 同时更新多个问题
|
||||
# 更新一个问题的标题,同时带回要保留的 required / description / visible_rule 等字段
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[
|
||||
{"id":"q_001","title":"姓名(必填)","required":true},
|
||||
{"id":"q_002","title":"联系方式","required":false}
|
||||
]'
|
||||
--questions '[{"id":"q_001","title":"您的真实姓名是?","description":"请填写真实姓名","required":true,"visible_rule":null}]'
|
||||
|
||||
# 同时更新多个问题;每个对象都应是该题目的目标完整配置
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_001","title":"姓名(必填)","required":true},{"id":"q_002","title":"联系方式","required":false}]'
|
||||
|
||||
# 更新问题描述(纯文本)
|
||||
# 更新问题描述(纯文本),同时带回要保留的 title / required / visible_rule
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_001","description":"请填写您的真实姓名"}]'
|
||||
# 更新问题描述(含链接)
|
||||
--questions '[{"id":"q_001","title":"您的姓名","description":"请填写您的真实姓名","required":true,"visible_rule":null}]'
|
||||
# 更新问题描述(含链接),同时带回要保留的 title / required / visible_rule
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_001","description":"更多说明请参考[帮助文档](https://example.com/help)"}]'
|
||||
--questions '[{"id":"q_001","title":"反馈建议","description":"更多说明请参考[帮助文档](https://example.com/help)","required":false,"visible_rule":null}]'
|
||||
|
||||
# 更新题目显隐条件(visible_rule),同时带回要保留的 title / description / required
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_002","title":"发票抬头","description":"","required":false,"visible_rule":{"logic":"and","conditions":[["q_001","==","是"]]}}]'
|
||||
|
||||
# 清空题目显隐条件(使题目始终显示),同时带回要保留的 title / description / required
|
||||
lark-cli base +form-questions-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--form-id <form_id> \
|
||||
--questions '[{"id":"q_002","title":"发票抬头","description":"","required":false,"visible_rule":null}]'
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -52,15 +72,46 @@ lark-cli base +form-questions-update \
|
||||
|
||||
## `--questions` 格式
|
||||
|
||||
每个问题对象必须包含 `id`,其余字段按需传入:
|
||||
每个问题对象必须包含 `id`。注意:对象不是增量 patch,而是该题目的目标完整配置;未携带字段会按服务端默认值重建。
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | **是** | 问题 ID(field_id),不可修改 |
|
||||
| `title` | 否 | 新的问题标题 |
|
||||
| `description` | 否 | 新的问题描述(纯文本或 Markdown 链接,如 `[文本](https://example.com)`) |
|
||||
| `required` | 否 | 是否必填 |
|
||||
| `option_display_mode` | 否 | 选项展示方式(仅 `select` 有效):`0`=下拉,`1`=纵向(默认),`2`=横向 |
|
||||
| `title` | 否 | 目标问题标题;省略会回落为字段名,传空字符串会写入空标题(若服务端允许) |
|
||||
| `description` | 否 | 目标问题描述(纯文本或 Markdown 链接,如 `[文本](https://example.com)`);省略或传空字符串都会清空描述 |
|
||||
| `required` | 否 | 目标是否必填;省略会回落为 `false` |
|
||||
| `option_display_mode` | 否 | 目标选项展示方式(仅 `select` 有效):`0`=下拉,`1`=纵向(默认),`2`=横向;省略会回落默认展示方式 |
|
||||
| `visible_rule` | 否 | 目标题目显隐条件;传完整 `{logic, conditions}` 对象覆盖,传 `null` 或省略都会清空(见下方说明) |
|
||||
|
||||
## 全量覆盖语义
|
||||
|
||||
- 先执行 `+form-questions-list`,读取被更新题目的当前 `id`、`title`、`description`、`required`、`option_display_mode`、`visible_rule`。
|
||||
- 构造 `--questions` 时,只改用户明确要求变化的字段;所有仍要保留的字段必须按当前值一并传回。
|
||||
- 不要用“只传要改的字段”的方式更新题目。比如只传 `{"id":"q_002","title":"新标题"}` 会让 `description` 清空、`required` 回落为 `false`、`visible_rule` 清空。
|
||||
- 用户明确要求清空时才传空值:`description:""` 清空描述,`visible_rule:null` 清空显隐条件,`conditions:[]` 也表示无条件显示。
|
||||
|
||||
### `visible_rule` 显隐条件
|
||||
|
||||
> **仅当用户明确要求为题目设置或修改显隐条件(显示/隐藏逻辑)时,才需要读下面的结构说明;否则忽略本节。**
|
||||
|
||||
`visible_rule` 控制题目显示/隐藏,**结构与视图筛选 `filter` 完全一致**(`{logic?, conditions?}`),共用同一套公共协议。
|
||||
|
||||
- `conditions` 中的 `field` 引用**同一表单内其他题目的题目名称或题目 ID**(推荐用题目 ID)。
|
||||
- 更新时按表单中题目的**实际顺序**判定,只能引用排在当前题目之前的题目;不支持循环引用。
|
||||
- 更新 `visible_rule` 需传**完整**的 `{logic, conditions}` 对象(整体覆盖);要保留现有显隐条件就必须把当前 `visible_rule` 原样带回;传 `null`、省略 `visible_rule` 或传空 `conditions` 都会使题目始终显示。
|
||||
- 列出题目(`+form-questions-list`)会在每个题目对象中**原样返回** `visible_rule`;未设置显隐条件的题目返回 `null` 或 `conditions` 为空数组。
|
||||
|
||||
```json
|
||||
{
|
||||
"logic": "and",
|
||||
"conditions": [
|
||||
["q_001", "==", "是"],
|
||||
["q_003", ">=", 1000]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
详细的 `visible_rule` 结构(顶层规则、operator 列表、各题目类型的 value 写法)请阅读 [lark-base-filter-condition.md](lark-base-filter-condition.md)。
|
||||
|
||||
## 输出格式
|
||||
|
||||
@@ -82,11 +133,13 @@ lark-cli base +form-questions-update \
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** — 执行前必须向用户确认。
|
||||
|
||||
1. 先用 `+form-questions-list` 获取现有问题及其 `id`
|
||||
2. 构造包含 `id` 的更新数组
|
||||
3. 执行命令并报告更新结果
|
||||
1. 先用 `+form-questions-list` 获取现有问题及其 `id` 和完整配置。
|
||||
2. 以现有配置为基线,只修改用户明确要求变化的字段;要保留的字段必须原样带回。
|
||||
3. 构造包含 `id` 和目标完整配置的更新数组。
|
||||
4. 执行命令并报告更新结果。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-base](../SKILL.md) — 多维表格全部命令
|
||||
- [lark-base-filter-condition.md](lark-base-filter-condition.md) — `visible_rule` / `filter` 条件结构公共协议
|
||||
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||
|
||||
@@ -4,142 +4,13 @@
|
||||
|
||||
更新视图筛选配置。
|
||||
|
||||
## 1. 顶层规则
|
||||
## 1. filter 结构
|
||||
|
||||
`--json` 就是一个 filter 条件对象,结构见公共协议 SSOT [lark-base-filter-condition.md](lark-base-filter-condition.md),即 `{logic?, conditions?}`。此处 `conditions` 中的 `field` 引用**数据表字段名或字段 id**。
|
||||
|
||||
- `--json` 必须是 JSON 对象。
|
||||
- 顶层结构是 `{logic?, conditions?}`。
|
||||
- `logic` 默认 `and`;推荐只用 canonical 值 `and` / `or`。
|
||||
- `conditions` 默认空数组。
|
||||
- 每条条件写成 tuple:`[field, operator, value?]`。
|
||||
- `empty` / `non_empty` 可写成 2 项:`[field, "empty"]`、`[field, "non_empty"]`。
|
||||
- 支持 `filter` 的视图类型:`grid`、`kanban`、`gallery`、`calendar`、`gantt`。
|
||||
|
||||
## 2. operator
|
||||
|
||||
可用 operator:
|
||||
- `==`
|
||||
- `!=`
|
||||
- `>`
|
||||
- `>=`
|
||||
- `<`
|
||||
- `<=`
|
||||
- `intersects`
|
||||
- `disjoint`
|
||||
- `empty`
|
||||
- `non_empty`
|
||||
|
||||
## 3. value 写法
|
||||
|
||||
### `text`
|
||||
|
||||
用字符串:
|
||||
|
||||
```json
|
||||
["标题", "intersects", "发布"]
|
||||
```
|
||||
|
||||
### `location`
|
||||
|
||||
location 筛选只按 `full_address` 字符串匹配,不能直接按经纬度筛选;优先使用 `intersects` 做包含匹配,例如查深圳:
|
||||
|
||||
```json
|
||||
["位置", "intersects", "深圳"]
|
||||
```
|
||||
|
||||
不推荐写 `["位置", "==", "深圳"]` 这类精确匹配,除非确保筛选值与完整 `full_address` 完全一致。
|
||||
|
||||
### `number` / `auto_number`
|
||||
|
||||
用数字:
|
||||
|
||||
```json
|
||||
["工时", ">=", 3.5]
|
||||
```
|
||||
|
||||
### `select`
|
||||
|
||||
用选项名数组:
|
||||
|
||||
```json
|
||||
["状态", "intersects", ["Doing", "Blocked"]]
|
||||
```
|
||||
|
||||
### `user` / `created_by` / `updated_by`
|
||||
|
||||
用对象数组:
|
||||
|
||||
> **人员筛选:不要猜 ID。** 不知道 `open_id` 时,先用 `lark-contact` 查 id:`lark-cli contact +search-user --query "<姓名/邮箱/手机号>" --as user`。
|
||||
|
||||
```json
|
||||
["负责人", "intersects", [{ "id": "ou_xxx" }]]
|
||||
```
|
||||
|
||||
### `group_chat`
|
||||
|
||||
用对象数组:
|
||||
|
||||
> **群组筛选:不要猜 ID。** 不知道 `chat_id` 时,先用 `lark-im` 搜群:`lark-cli im +chat-search --query "<群名关键词>" --as user`;取结果里的 `oc_xxx`。
|
||||
|
||||
```json
|
||||
["负责群", "intersects", [{ "id": "oc_xxx" }]]
|
||||
```
|
||||
|
||||
### `link`
|
||||
|
||||
用记录 id 对象数组:
|
||||
|
||||
```json
|
||||
["关联任务", "intersects", [{ "id": "rec_xxx" }]]
|
||||
```
|
||||
|
||||
### `checkbox`
|
||||
|
||||
用布尔值:
|
||||
|
||||
```json
|
||||
["完成", "==", true]
|
||||
```
|
||||
|
||||
### `datetime` / `created_at` / `updated_at`
|
||||
|
||||
用相对时间关键字或 `ExactDate(...)`:
|
||||
|
||||
```json
|
||||
["截止时间", "==", "ExactDate(2026-01-01)"]
|
||||
```
|
||||
|
||||
```json
|
||||
["截止时间", "==", "ExactDate(2026-01-01 11:30)"]
|
||||
```
|
||||
|
||||
```json
|
||||
["截止时间", "==", "Today"]
|
||||
```
|
||||
|
||||
可用关键字:
|
||||
- `Today`
|
||||
- `Yesterday`
|
||||
- `Tomorrow`
|
||||
|
||||
### `formula` / `lookup`
|
||||
|
||||
- 筛选值类型由字段计算结果类型动态决定。
|
||||
- 拿不准时,先把 `value` 当作单个字符串填入做一次尝试。
|
||||
- 如果报错,再按错误提示把 `value` 改成对应类型。
|
||||
|
||||
字符串示例:
|
||||
|
||||
```json
|
||||
["风险说明", "intersects", "高风险"]
|
||||
```
|
||||
|
||||
数字示例:
|
||||
|
||||
```json
|
||||
["汇总分", ">=", 80]
|
||||
```
|
||||
|
||||
## 4. 推荐命令
|
||||
## 2. 推荐命令
|
||||
|
||||
```bash
|
||||
lark-cli base +view-set-filter \
|
||||
@@ -149,7 +20,7 @@ lark-cli base +view-set-filter \
|
||||
--json '{"logic":"and","conditions":[["状态","intersects",["Doing"]],["负责人","intersects",[{"id":"ou_xxx"}]],["截止时间","empty"]]}'
|
||||
```
|
||||
|
||||
## 5. JSON 写法
|
||||
## 3. JSON 写法
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -170,14 +41,16 @@ lark-cli base +view-set-filter \
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 使用建议
|
||||
完整的 operator 列表与各字段类型的 value 写法(`text` / `number` / `select` / `user` / `datetime` / `formula` / `lookup` 等),见 [lark-base-filter-condition.md](lark-base-filter-condition.md)。
|
||||
|
||||
## 4. 使用建议
|
||||
|
||||
- 先读取当前筛选配置,理解现有 `logic` 和 `conditions` 的组合关系;只替换用户要求变更的条件,未提到的条件默认保留。
|
||||
- 优先传字段 id,不要依赖字段名。
|
||||
- 拿不准字段 type 或真实取值时,先用 `+field-list` / `+record-list` 确认,再按对应字段类型的 value 写法构造条件;别按字段名猜 type、凭印象猜枚举取值。
|
||||
- 需要清空全部筛选时,直接传 `{"conditions":[]}`。
|
||||
|
||||
## 7. 易错点
|
||||
## 5. 易错点
|
||||
|
||||
- 本 tuple DSL 由 `+view-set-filter` 与 `+record-list` / `+record-search` 的 `--filter-json` 共用;不要写成 `+data-query` 的对象风格 `{"field_name":...,"operator":...}`(会报校验失败)。
|
||||
- 标量类字段(`text` / `number` / `datetime` 等)的 value 用标量、别包成数组(各类型详见 value 写法一节)。
|
||||
@@ -186,6 +59,7 @@ lark-cli base +view-set-filter \
|
||||
- 日期条件稳定写法用 `ExactDate(...)` 或 `Today` / `Yesterday` / `Tomorrow`。
|
||||
- `formula` / `lookup` 的 value 形状不固定;拿不准时先读当前 filter 或字段定义,或根据错误提示修正类型。
|
||||
|
||||
## 8. 参考
|
||||
## 6. 参考
|
||||
|
||||
- [lark-base-filter-condition.md](lark-base-filter-condition.md):filter/visible_rule 条件结构公共协议 SSOT
|
||||
- [lookup-field-guide.md](lookup-field-guide.md)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 \|` + 分隔行 `\| --- \| --- \|`)、以及图片 ``(标准 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 命令)
|
||||
|
||||
@@ -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` 对应的重复范围。
|
||||
|
||||
@@ -39,6 +39,7 @@ lark-cli calendar +freebusy --start "<start>" --end "<end>"
|
||||
```
|
||||
|
||||
规则:
|
||||
- 参与人含 **bot**:无需为 bot 查询忙闲。bot 是虚拟身份,可并行多个会议、无忙闲语义,检查它没有意义。
|
||||
- 参与人过多(超过 5 人):仅查询**当前用户**及少数核心人员忙闲即可
|
||||
- 参与人含**群组**:无需展开群组成员查询忙闲
|
||||
- 如果用户是从 `+suggestion` 确认了时间块后进入本分支的,**无需再调用 `+freebusy`**
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
@@ -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 \|` + 分隔行 `\| --- \| --- \|`、以及图片 ``(标准 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) 执行。
|
||||
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-drive
|
||||
version: 1.0.0
|
||||
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本、飞书文档密级标签(secure labels)和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token、判断链接类型/真实 token/标题,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用;doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责:文档内容编辑(走 lark-doc)、表格/Base 表内数据操作(走 lark-sheets/lark-base)、知识空间节点/成员管理(走 lark-wiki)、原生 Markdown 文件读写/patch/diff(走 lark-markdown)。"
|
||||
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、查询权限设置、评论/权限/订阅、标题、版本、飞书文档密级标签(secure labels)和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token、判断链接类型/真实 token/标题,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用;doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责:文档内容编辑(走 lark-doc)、表格/Base 表内数据操作(走 lark-sheets/lark-base)、知识空间节点/成员管理(走 lark-wiki)、原生 Markdown 文件读写/patch/diff(走 lark-markdown)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -27,6 +27,7 @@ metadata:
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要”权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户要为指定飞书文档**设置 / 修改密级标签(secure label)**,或查询当前用户可用的密级标签,直接读取 [`references/lark-drive-secure-label.md`](references/lark-drive-secure-label.md);这是 Drive 文件治理能力。
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户要**查询文件、文件夹或云文档自身的公开访问、分享、协作者管理、安全与评论权限设置**,优先使用 `lark-cli drive +permission-get-setting`;它只读取目标自身设置,不递归审计文件夹子文档权限。裸 token 必须显式传 `--type`。
|
||||
- 用户要**按特定主题、关键词或内容线索跨容器查找资料,并统一收集到 Drive 文件夹或 Wiki 节点**,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`topic_move_collector`](references/lark-drive-workflow-topic-move-collector.md) workflow。该 workflow 负责搜索召回、内容验证、相关性分类、移动计划、写前确认和结果验证;禁止直接从 `drive +search` 或 `drive +move` 开始。
|
||||
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
|
||||
- 按主题跨范围查找并集中归档,进入 `topic_move_collector`;对已知文件夹、文档库或知识库做目录盘点和结构重组,进入 `knowledge_organize`;只移动一个已明确资源时仍使用原子移动命令。
|
||||
@@ -120,6 +121,7 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
|
||||
### 权限能力入口
|
||||
|
||||
- 用户要管理 Drive 文档/文件协作者、公开权限、授权当前应用访问文档,或处理 `permission.public.patch` 的 `91009` / `91010` / `91011` / `91012` 错误时,先读 [`lark-drive-permission-guide.md`](references/lark-drive-permission-guide.md)。
|
||||
- 用户要查询文件、文件夹或云文档自身的公开访问、分享、协作者管理、安全与评论权限设置,使用 [`+permission-get-setting`](references/lark-drive-permission-get-setting.md);如果要递归审计文件夹下子文档权限,再进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户只是没有访问权限并希望向 owner 申请访问,优先使用 [`+apply-permission`](references/lark-drive-apply-permission.md)。
|
||||
- 普通 scope、身份或登录问题仍按 [`lark-shared`](../lark-shared/SKILL.md) 处理;不要把租户安全策略、对外分享、密级拦截简单归类为缺 scope。
|
||||
|
||||
@@ -163,6 +165,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| [`+inspect`](references/lark-drive-inspect.md) | 检视 URL 的类型、标题和 canonical token;wiki URL 会自动解包到底层文档。 |
|
||||
| [`+apply-permission`](references/lark-drive-apply-permission.md) | 以 user 身份向文档 owner 申请访问权限。 |
|
||||
| [`+member-add`](references/lark-drive-member-add.md) | 添加一个或最多 10 个 Drive 文档、文件、文件夹或 wiki 节点协作者/授权成员;封装 Drive permission member create/batch_create,真实写入需要 `--yes`。 |
|
||||
| [`+member-list`](references/lark-drive-member-list.md) | 查询 Drive 文档、文件、文件夹或 wiki 节点的协作者/授权成员列表。 |
|
||||
| [`+permission-get-setting`](references/lark-drive-permission-get-setting.md) | 查询文件、文件夹或云文档自身的公开访问、分享、协作者管理、安全与评论权限设置;支持 URL 或裸 token + `--type`;不递归读取文件夹子文档权限。 |
|
||||
| [`+secure-label-list`](references/lark-drive-secure-label.md) | 列出当前用户可用的密级标签。 |
|
||||
| [`+secure-label-update`](references/lark-drive-secure-label.md) | 更新 Drive 文件或文档的密级标签。 |
|
||||
|
||||
|
||||
65
skills/lark-drive/references/lark-drive-member-list.md
Normal file
65
skills/lark-drive/references/lark-drive-member-list.md
Normal file
@@ -0,0 +1,65 @@
|
||||
# drive +member-list(查询协作者/授权成员列表)
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli drive +member-list`。它读取 Drive 文档、文件、文件夹或 wiki 节点的协作者/授权成员列表。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# URL 自动推断 type
|
||||
lark-cli drive +member-list \
|
||||
--token 'https://example.feishu.cn/drive/folder/<folder_token>' \
|
||||
--as user --format json
|
||||
|
||||
# 查询附加字段
|
||||
lark-cli drive +member-list \
|
||||
--token '<token>' \
|
||||
--type docx \
|
||||
--fields 'name,type,external_label' \
|
||||
--as user --format json
|
||||
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--token` | 是 | 裸 token 或完整 URL。URL 路径支持 `/folder/`、`/docx/`、`/doc/`、`/sheets/`、`/base/`、`/bitable/`、`/wiki/`、`/file/`、`/mindnotes/`、`/slides/`、`/minutes/`。 |
|
||||
| `--type` | 裸 token 必填 | 目标类型:`doc` / `sheet` / `file` / `wiki` / `bitable` / `docx` / `mindnote` / `minutes` / `slides` / `folder`。URL 可自动推断;如果同时传 URL 和冲突的 `--type`,CLI 会拒绝。 |
|
||||
| `--fields` | 否 | 默认不传。可取 `name` / `type` / `avatar` / `external_label`,支持逗号分隔;也可传 `*` 请求当前支持的所有附加字段。该参数只声明期望返回的字段,不授予字段级权限。 |
|
||||
| `--perm-type` | 否 | 仅 `--type wiki` 有效;取值 `container` / `single_page`。 |
|
||||
| `--dry-run` | 否 | 只打印请求,不调用 API。 |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON 输出原样透传 API 的 `data` :
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"member_type": "openid",
|
||||
"member_id": "ou_xxx",
|
||||
"perm": "view",
|
||||
"perm_type": "container",
|
||||
"type": "user",
|
||||
"name": "zhangsan",
|
||||
"external_label": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`--format pretty` 会轻量展示成员 ID、成员类型、权限、wiki `perm_type` 和已返回的附加字段。机器读取优先使用 `--format json`。
|
||||
|
||||
## 行为说明
|
||||
|
||||
- **身份支持**:`--as user` 和 `--as bot` 均可用;缺 scope 或目标权限时按统一 permission 错误路径处理。
|
||||
- **接口 scope**:查询成员列表需要 `docs:permission.member:retrieve`。
|
||||
- **fields 默认**:不传 `--fields` 时按官方 API 默认,不请求姓名、头像、外部标签等附加字段;需要时显式指定。
|
||||
- **字段级权限**:`--fields` 只控制请求哪些附加字段,不保证服务端一定返回。请求用户的 `name` / `avatar` 时,应用还需开通 `contact:user.base:readonly`(“获取用户基本信息”;已具备官方兼容的历史通讯录权限也可满足要求)。
|
||||
- **缺字段语义**:字段级权限或数据可见性不足时,接口仍可能成功,但会省略相应敏感字段。响应中缺少已请求字段表示“服务端未返回”,不能解释为字段值为空,也不能据此认定成员信息完整。
|
||||
- **folder 支持**:CLI 支持 `--type folder` 并会按需求发送 `type=folder`;部分环境的后端如果尚未放开 folder 枚举,可能返回 `99992402 field validation failed`。
|
||||
@@ -0,0 +1,48 @@
|
||||
# drive +permission-get-setting(查询权限设置)
|
||||
|
||||
本 skill 对应 shortcut:`lark-cli drive +permission-get-setting`。它读取单个 Drive 资源自身的公开访问、分享、协作者管理、安全与评论权限设置,不递归读取文件夹中的子资源。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 通过 URL 自动推断 type
|
||||
lark-cli drive +permission-get-setting \
|
||||
--token 'https://example.feishu.cn/drive/folder/<folder_token>' \
|
||||
--as user --format json
|
||||
|
||||
# 通过 bare token 显式指定 type
|
||||
lark-cli drive +permission-get-setting \
|
||||
--token '<folder_token>' \
|
||||
--type folder \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--token` | 是 | bare token 或完整 URL。URL 路径支持 `/folder/`、`/docx/`、`/doc/`、`/sheets/`、`/base/`、`/bitable/`、`/wiki/`、`/file/`、`/mindnotes/`、`/slides/`、`/minutes/`。 |
|
||||
| `--type` | bare token 必填 | 目标类型:`doc` / `sheet` / `file` / `wiki` / `bitable` / `docx` / `mindnote` / `minutes` / `slides` / `folder`。URL 可自动推断;如果同时传 URL 和冲突的 `--type`,CLI 会拒绝。 |
|
||||
| `--dry-run` | 否 | 只打印请求,不调用 API。 |
|
||||
|
||||
## 输出
|
||||
|
||||
JSON 输出中的 `data.permission_public` 是目标当前的权限设置;服务端未返回该字段时,命令会报响应结构错误,而不会把其他字段伪装成权限设置。
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": {
|
||||
"permission_public": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`--format pretty` 会展示完整的 `permission_public` 对象,包括服务端将来新增的字段。
|
||||
|
||||
## 行为说明
|
||||
|
||||
- **身份支持**:`--as user` 和 `--as bot` 均可用。
|
||||
- **所需 scope**:`docs:permission.setting:read`。
|
||||
- **单目标读取**:命令只读取 `--token` 指向资源自身的权限设置;`--type folder` 不会递归读取子资源。
|
||||
@@ -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`,不做客户端精确过滤或摘要重写。
|
||||
|
||||
## 权限
|
||||
|
||||
@@ -28,7 +28,7 @@ lark-cli drive +secure-label-list --page-size 10 --lang zh
|
||||
```bash
|
||||
lark-cli drive +secure-label-update \
|
||||
--token "https://example.feishu.cn/docx/doxcnxxxx" \
|
||||
--label-id "7217780879644737539"
|
||||
--label-id '<label-id>' # replace $LABEL_ID before running
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
lark-cli drive +inspect --url '<url>' --as user --format json
|
||||
```
|
||||
|
||||
`drive +inspect` 支持 Drive folder,并且是受支持 Drive URL 的统一解析入口。对文件夹自身权限设置,先通过 `+inspect` 解析 URL,或直接使用 `drive +permission-get-setting --token '<folder_url>'`;传 bare folder token 时必须显式传 `--type folder`。
|
||||
|
||||
`/wiki/space/<space_id>` URL 是 Wiki space 范围,不要用 `drive +inspect` 当作单文档解析;直接提取 `space_id` 后进入 `DISCOVER_TARGETS`。
|
||||
|
||||
## 目标发现
|
||||
@@ -25,16 +27,16 @@ lark-cli drive +inspect --url '<url>' --as user --format json
|
||||
lark-cli wiki +node-list \
|
||||
--space-id '<space_id>' --page-size 50 \
|
||||
--page-all --page-limit 0 \
|
||||
--as user --format json
|
||||
--as user --format json # replace $SPACE_ID before running
|
||||
|
||||
lark-cli wiki +node-list \
|
||||
--space-id '<space_id>' --parent-node-token '<node_token>' --page-size 50 \
|
||||
--page-all --page-limit 0 \
|
||||
--as user --format json
|
||||
--as user --format json # replace $SPACE_ID before running
|
||||
|
||||
lark-cli wiki +node-list \
|
||||
--space-id '<space_id>' --page-token '<PAGE_TOKEN>' --page-size 50 \
|
||||
--as user --format json
|
||||
--as user --format json # replace $SPACE_ID before running
|
||||
```
|
||||
|
||||
解析返回时使用 `data.nodes`,不要读取顶层 `items`。`--page-limit 0` 表示当前层分页不设页数上限;`--page-all` 只覆盖当前 `space-id` / `parent-node-token` 范围内的分页,不会递归子节点。节点 `has_child=true` 时,必须继续以该节点的 `node_token` 作为 `--parent-node-token` 递归读取。
|
||||
@@ -61,14 +63,42 @@ lark-cli drive metas batch_query \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
读取 public permission:
|
||||
读取权限设置:
|
||||
|
||||
```bash
|
||||
lark-cli drive permission.public get \
|
||||
--params '{"token":"<token>","type":"<type>"}' \
|
||||
lark-cli drive +permission-get-setting \
|
||||
--token '<url-or-token>' --type '<type>' \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
裸 folder token 必须显式传 `--type folder`:
|
||||
|
||||
```bash
|
||||
lark-cli drive +permission-get-setting \
|
||||
--token '<folder_token>' --type folder \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
通过 URL 读取权限设置时可以省略 `--type`:
|
||||
|
||||
```bash
|
||||
lark-cli drive +permission-get-setting \
|
||||
--token '<url>' \
|
||||
--as user --format json # replace $LARK_DRIVE_URL before running
|
||||
```
|
||||
|
||||
按需读取直接协作者/授权成员列表:
|
||||
|
||||
```bash
|
||||
lark-cli drive +member-list \
|
||||
--token '<token_or_url>' \
|
||||
--type '<type>' \
|
||||
--fields 'name,type,external_label' \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
`--fields` 默认不传;只有需要名称、协作者类型、头像或外部标签时才显式传。它只声明期望返回的字段,不授予字段级权限:请求用户的 `name` / `avatar` 时还需 `contact:user.base:readonly`(“获取用户基本信息”)。字段权限或数据可见性不足时,接口仍可能成功但省略相应字段;缺字段不能解释为空值。
|
||||
|
||||
按需读取访问统计:
|
||||
|
||||
```bash
|
||||
@@ -160,9 +190,9 @@ lark-cli drive +secure-label-list \
|
||||
```bash
|
||||
lark-cli drive +secure-label-update \
|
||||
--token '<url>' \
|
||||
--label-id '<label-id>' --as user --format json
|
||||
--label-id '<label-id>' --as user --format json # replace $LABEL_ID before running
|
||||
|
||||
lark-cli drive +secure-label-update \
|
||||
--token '<bare-token>' --type '<type>' \
|
||||
--label-id '<label-id>' --as user --format json
|
||||
--label-id '<label-id>' --as user --format json # replace $LABEL_ID before running
|
||||
```
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
- 多目标明确列表默认输出逐目标诊断摘要;不要因为目标数大于 1 就套用容器递归发现报告。
|
||||
- 用户可见结论默认跟随用户当前语言。用户用中文提问时输出中文,用户用英文提问时输出英文;混合语言时跟随主要语言。
|
||||
- 单目标公开性判断默认输出业务表达,不直接展示 `link_share_entity`、`external_access_entity`、`external_access` 等底层字段名;只有用户要求 raw evidence、排障,或完整清单 / artifact 场景才展示底层字段。
|
||||
- 中文用户可见输出中,`permission_public` / `public permission` 默认译为“文档公共访问和协作权限设置”;可在摘要里简称“公共访问与协作设置”。它在官方语义中包含链接分享、对外分享、协作者管理、复制内容、创建副本、打印、下载和评论;具体可判断字段以当前 CLI schema 和实际响应为准。只有命令名、schema 字段、raw evidence、排障信息和完整 artifact 字段名保留英文原文。
|
||||
- 中文用户可见输出中,`permission_public` / `public permission` 默认译为“目标公共访问和协作权限设置”;可在摘要里简称“公共访问与协作设置”。优先按实际返回字段解释公开访问、分享、协作者管理、安全与评论设置;复制内容、创建副本、打印、下载等字段只有在当前 CLI schema 和实际响应返回时才可判断。只有命令名、schema 字段、raw evidence、排障信息和完整 artifact 字段名保留英文原文。
|
||||
- 容器目标默认输出安全诊断报告摘要:一句话结论、覆盖情况、风险分级、优先处理对象、建议下一步和剩余限制。
|
||||
- 容器目标不要把风险按数量机械排序;外部公开、允许对外分享、缺失密级标签优先于复制 / 下载 / 评论这类依赖策略的候选项。
|
||||
- 用户没有提供明确 policy 时,使用“候选风险 / 待复核 / 待策略确认”,不要写“违规 / 已泄露 / 已外部访问”。
|
||||
@@ -36,7 +36,7 @@
|
||||
- 当摘要未展示全部风险对象时,必须明确“完整清单包含 <count> 条”,并提供生成 Markdown / CSV / 飞书文档风险清单或整改 dry-run 的下一步。
|
||||
- 只要发现需要处理的对象,最终回复必须给出可执行下一步 CTA。不能因为默认只读,就只报告风险后结束。
|
||||
- 完整风险清单是后续治理选择的输入;Markdown / CSV / 飞书文档报告必须使用同一套字段和稳定 `risk_id`。
|
||||
- 写入前必须使用确认模板;权限申请、文档公共访问和协作权限设置修改、owner 转移、密级标签更新分别确认。
|
||||
- 写入前必须使用确认模板;权限申请、目标公共访问和协作权限设置修改、owner 转移、密级标签更新分别确认。
|
||||
- 最终回复必须包含已完成事项、验证结果和剩余限制;异步权限申请审批不能表述为已完成授权。
|
||||
|
||||
## Semantic Rendering
|
||||
@@ -75,7 +75,7 @@
|
||||
| `lock_switch=true` | `lock_state=locked_not_inheriting` | 已限制权限,不再继承父级页面权限 | The node is locked and no longer inherits parent-page permissions |
|
||||
| `lock_switch=false` | `lock_state=not_locked_or_inheriting` | 未限制权限,可能继承父级页面权限 | The node is not locked and may inherit parent-page permissions |
|
||||
| field absent / unsupported | `<state>=unknown` | 当前 schema 未返回,无法判断 | The current schema did not return this field, so it is unknown |
|
||||
| `check_scope=current_public_permission_only` | `check_scope=current_public_permission_only` | 本次判断的是当前文档公共访问和协作权限设置,不是协作者名单或历史权限变更审计 | This check covers current public access and collaboration settings, not collaborator-list or historical permission-change auditing |
|
||||
| `check_scope=current_public_permission_only` | `check_scope=current_public_permission_only` | 本次判断的是当前目标公共访问和协作权限设置,不是协作者名单或历史权限变更审计 | This check covers the target's current public access and collaboration settings, not collaborator-list or historical permission-change auditing |
|
||||
| `sec_label_name` missing | `sec_label=missing` | 缺少密级标签 | Security label is missing |
|
||||
|
||||
## 定位与治理动作
|
||||
@@ -165,7 +165,7 @@ Evidence fields:
|
||||
|
||||
覆盖情况:
|
||||
- 用户提供目标:<input_target_count>;成功解析:<resolved_count>
|
||||
- 成功读取文档公共访问和协作权限设置:<permission_checked_count>;读取失败 / 不支持 / 无权限:<failed_or_unsupported_count>
|
||||
- 成功读取目标公共访问和协作权限设置:<permission_checked_count>;读取失败 / 不支持 / 无权限:<failed_or_unsupported_count>
|
||||
|
||||
逐目标结果(1-10 个目标默认全部展示;超过 10 个时按 `摘要清单展开规则` 展示,并提示生成完整风险清单):
|
||||
|
||||
@@ -233,7 +233,7 @@ URL:<url-or-token-if-url-unavailable>
|
||||
|
||||
覆盖情况:
|
||||
- 当前身份可见目标:<visible_count>
|
||||
- 已成功检查文档公共访问和协作权限设置:<permission_checked_count>
|
||||
- 已成功检查目标公共访问和协作权限设置:<permission_checked_count>
|
||||
- 读取失败 / 已删除 / 无权限:<failed_count>
|
||||
- 未覆盖能力:<collaborator_list / inheritance / audit_log / view_records / none>
|
||||
|
||||
@@ -355,8 +355,8 @@ Agent 必须回复:
|
||||
- 字段变更:
|
||||
- <risk_id> <path> (<url-or-token>): <field> <old> -> <new>
|
||||
- 跳过项:<unsupported / no manage_public / unsupported type / missing policy>
|
||||
- 验证方式:执行后重新读取 <元数据 / 文档公共访问和协作权限设置>
|
||||
- 有限回滚范围:<文档公共访问和协作权限设置快照字段 / 不适用>
|
||||
- 验证方式:执行后重新读取 <元数据 / 目标公共访问和协作权限设置>
|
||||
- 有限回滚范围:<目标公共访问和协作权限设置快照字段 / 不适用>
|
||||
|
||||
请确认是否进入写入确认。
|
||||
```
|
||||
@@ -407,8 +407,8 @@ Agent 必须回复:
|
||||
- 风险:<risk_level>
|
||||
- 字段变更:
|
||||
- <field>: <old> -> <new>
|
||||
- 验证方式:执行后重新读取 <元数据 / 文档公共访问和协作权限设置>
|
||||
- 有限回滚材料:<文档公共访问和协作权限设置快照 / 不适用>
|
||||
- 验证方式:执行后重新读取 <元数据 / 目标公共访问和协作权限设置>
|
||||
- 有限回滚材料:<目标公共访问和协作权限设置快照 / 不适用>
|
||||
|
||||
请确认是否执行。
|
||||
```
|
||||
@@ -419,6 +419,6 @@ Agent 必须回复:
|
||||
已完成:<read checks / writes>
|
||||
验证:<fresh read result or async permission-request approval note>
|
||||
清单状态:<risk_id status updates / not applicable>
|
||||
回滚材料:<文档公共访问和协作权限设置快照 / 不适用>
|
||||
回滚材料:<目标公共访问和协作权限设置快照 / 不适用>
|
||||
剩余限制:<unsupported_checks / partial facts / approvals>
|
||||
```
|
||||
|
||||
@@ -38,11 +38,11 @@ Risk / Structure: `R2` / `S2`
|
||||
- 目录组织、迁移、归档或清理;这类需求应使用知识整理 workflow。
|
||||
- 内容审查、过期内容判断或知识质量评分。
|
||||
- backup owner 补充、部门 / 项目负责人绑定、协作者创建 / 撤销、成员列表审计;本 workflow 只支持把 owner 转移给每个目标明确指定的新 owner,不建模 backup owner 或负责人绑定关系。
|
||||
- 文件夹自身公开权限审计或修复。`drive permission.public get` / `patch` 不支持 `type=folder`;必须记录到 `unsupported_checks`,然后继续读取文件夹下其他支持的文档事实。
|
||||
- 文件夹自身公开权限审计或修复。文件夹自身权限设置可以用 `drive +permission-get-setting` 读取;写入是否支持必须以运行时 schema 和明确需求为准,不能猜测执行 `patch type=folder`。
|
||||
- 当前身份无法枚举到的不可见文档的完整发现;只能处理已发现目标,或用户显式提供的 URL / token。
|
||||
- 未按范围确认的批量写入。
|
||||
|
||||
不要声称已完成协作者列表验证:当前 CLI surface 没有 `permission.members list` shortcut。
|
||||
协作者列表读取只覆盖当前目标的直接协作者/授权成员:可使用 `drive +member-list` 。
|
||||
|
||||
## Progressive Load Map
|
||||
|
||||
@@ -53,7 +53,7 @@ Risk / Structure: `R2` / `S2`
|
||||
| `PARSE_INTENT` | 本文件、[`lark-drive-workflow.md`](lark-drive-workflow.md)、[`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) |
|
||||
| `TARGET_INSPECT` | [`lark-drive-inspect.md`](lark-drive-inspect.md) |
|
||||
| `DISCOVER_TARGETS` | 容器范围时读取 [`../../lark-wiki/references/lark-wiki-node-list.md`](../../lark-wiki/references/lark-wiki-node-list.md) 或 [`lark-drive-files-list.md`](lark-drive-files-list.md) |
|
||||
| `FACT_READ` | `lark-cli schema drive.metas.batch_query`;涉及公开权限时再读取 `lark-cli schema drive.permission.public.get`;涉及活跃度、访问复核或生命周期判断时再读取 `lark-cli schema drive.file.statistics.get` 和 `lark-cli schema drive.file.view_records.list` |
|
||||
| `FACT_READ` | `lark-cli schema drive.metas.batch_query`;涉及权限设置读取时使用 `drive +permission-get-setting`;涉及活跃度、访问复核或生命周期判断时再读取 `lark-cli schema drive.file.statistics.get` 和 `lark-cli schema drive.file.view_records.list` |
|
||||
| `RISK_ASSESS` | 本文件的 `Risk Classification` |
|
||||
| `EXEC_CONFIRM` | 只为用户选择的动作读取 [`lark-drive-apply-permission.md`](lark-drive-apply-permission.md)、[`lark-drive-secure-label.md`](lark-drive-secure-label.md),或 `lark-cli schema drive.permission.public.patch` / `lark-cli schema drive.permission.members.transfer_owner`;需要确认模板时读取 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md) |
|
||||
| `EXECUTE` | 复用 `EXEC_CONFIRM` 已加载且已确认的写命令上下文 |
|
||||
@@ -76,9 +76,9 @@ Risk / Structure: `R2` / `S2`
|
||||
| State | Protocol Step | Agent MUST Do | User-Facing Output | wait_for_user | Next State |
|
||||
|-------|---------------|---------------|--------------------|---------------|------------|
|
||||
| `PARSE_INTENT` | `route` / `scope` | 解析 intent、target scope、desired policy,以及只读审计、单目标公开性判断、权限申请、owner 转移还是修复模式;单目标公开性判断设置 `intent=public_exposure_check`、`target_scope=single_resource` | 范围确认;如果缺少目标、新 owner 或期望动作,只问一个澄清问题 | 缺少 target / new owner / action,或容器范围需要用户确认时为 `true` | `TARGET_INSPECT` |
|
||||
| `TARGET_INSPECT` | `scope` | 解析单资源、明确列表、Wiki space / node、Drive folder;保留原始 URL、scope type、canonical token/type | 目标范围表,包含 scope、title/type/token status | 除非解析失败,否则为 `false` | `DISCOVER_TARGETS` or `FACT_READ` |
|
||||
| `TARGET_INSPECT` | `scope` | 解析单资源、明确列表、Wiki space / node、Drive folder;Drive folder 直接从 URL 路径或显式 `type=folder` 解析,不调用 `drive +inspect`;保留原始 URL、scope type、canonical token/type | 目标范围表,包含 scope、title/type/token status | 除非解析失败,否则为 `false` | `DISCOVER_TARGETS` or `FACT_READ` |
|
||||
| `DISCOVER_TARGETS` | `scope` / `read` | 对 Wiki space / node 或 Drive folder 递归只读枚举,归一化为 `discovered_targets`;记录 `discovery_blockers` | 发现进度和覆盖摘要;不展示内部 cursor/token,除非用户要求 | 除非发现范围无法确认或全部被阻断,否则为 `false` | `FACT_READ` |
|
||||
| `FACT_READ` | `read` | 对直接目标或 `discovered_targets` 执行 `drive metas batch_query`;对支持的非 folder 目标执行 `drive permission.public get`;当 `intent=public_exposure_check` 且 `target_scope=single_resource` 时,可复用 `drive +inspect` 返回的 title / URL / type,只补读文档公共访问和协作权限设置;在用户要求活跃度 / 访问复核 / 生命周期判断时读取访问统计和访问记录 | 权限事实摘要、coverage summary、activity facts 和 unsupported checks | 除非所有目标都被 auth 阻断,否则为 `false` | `RISK_ASSESS` |
|
||||
| `FACT_READ` | `read` | 对直接目标或 `discovered_targets` 执行 `drive metas batch_query`;对支持的文件、文件夹或云文档目标执行 `drive +permission-get-setting` 读取自身权限设置;当 `intent=public_exposure_check` 且 `target_scope=single_resource` 时,可复用 `drive +inspect` 返回的 title / URL / type,只补读目标公共访问和协作权限设置;在用户要求活跃度 / 访问复核 / 生命周期判断时读取访问统计和访问记录 | 权限事实摘要、coverage summary、activity facts 和 unsupported checks | 除非所有目标都被 auth 阻断,否则为 `false` | `RISK_ASSESS` |
|
||||
| `RISK_ASSESS` | `assess/plan` | 对每个可审计目标生成 `per_target_permission_assessment` 并分类证据;如用户提供 policy,则对照 policy;`public_exposure_check + single_resource` 只渲染单目标结论,不生成 `risk_id`;owner 转移路径生成 `owner_transfer_candidates` / `owner_transfer_plan`;治理路径构建可定位风险清单、访问复核清单、dry-run 整改计划或候选修复计划,完整清单必须生成稳定 `risk_id` | 带 priority、URL、risk_id、owner、sec_label 的 findings、confidence、review items、建议动作和下一步 CTA;单目标公开性判断只输出结论和关键字段 | 治理路径为 `true`,单目标公开性判断为 `false` | `EXEC_CONFIRM` or `DONE` |
|
||||
| `EXEC_CONFIRM` | `confirm` | 展示准确写入范围、command family、target count、risk、verification method | 确认请求 | `true` | `EXECUTE` or `DONE` |
|
||||
| `EXECUTE` | `execute` | 只执行 `Command Map` 中已确认的写入 | 进度 / 结果摘要 | 除非被阻断,否则为 `false` | `VERIFY` |
|
||||
@@ -91,21 +91,23 @@ Risk / Structure: `R2` / `S2`
|
||||
|
||||
| State | Allowed Command Families | Purpose |
|
||||
|-------|--------------------------|---------|
|
||||
| `TARGET_INSPECT` | `drive +inspect` | 解析 URL、type、canonical token、title 和 wiki unwrap data |
|
||||
| `TARGET_INSPECT` | `drive +inspect` | 解析非 folder URL、type、canonical token、title 和 wiki unwrap data;Drive folder 不支持 `+inspect`,必须从 URL 路径或显式 `type=folder` 直接解析 |
|
||||
| `DISCOVER_TARGETS` | `wiki +node-list` | 递归发现 Wiki space / node 下当前身份可见的节点 |
|
||||
| `DISCOVER_TARGETS` | `drive files list` | 递归发现 Drive folder 下当前身份可见的文件和子文件夹 |
|
||||
| `FACT_READ` | `drive metas batch_query` | 读取 title、URL、owner 和 secure-label metadata |
|
||||
| `FACT_READ` | `drive permission.public get` | 读取支持类型的文档公共访问和协作权限设置,包括链接分享、对外分享、协作者管理、复制内容、创建副本、打印、下载和评论 |
|
||||
| `FACT_READ` | `drive +member-list` | 读取用户显式要求的单目标直接协作者/授权成员列表;不代表完整继承链或历史权限审计 |
|
||||
| `FACT_READ` | `drive +permission-get-setting` | 读取支持类型的文件、文件夹或云文档自身权限设置,包括公开访问、分享、协作者管理、安全与评论 |
|
||||
| `FACT_READ` | `drive file.statistics get` | 在用户要求活跃度、闲置暴露、生命周期或访问复核时读取文件访问统计 |
|
||||
| `FACT_READ` | `drive file.view_records list` | 在用户要求最近访问人、访问复核或低活跃证据时读取访问记录 |
|
||||
| `EXEC_CONFIRM` | `drive +secure-label-list` | 提议 label update 前解析可用 secure-label IDs |
|
||||
| `EXEC_CONFIRM` | `drive permission.members auth` | 文档公共访问和协作权限设置修改前检查 `action=manage_public` |
|
||||
| `EXEC_CONFIRM` | `drive permission.members auth` | 目标公共访问和协作权限设置修改前检查 `action=manage_public` |
|
||||
| `EXEC_CONFIRM` | `lark-cli schema drive.permission.members.transfer_owner` | owner 转移前读取当前字段、支持类型和高风险写入门禁 |
|
||||
| `EXECUTE` | `drive +apply-permission` | 向 owner 提交 view/edit access request;只允许单目标、小列表或已明确确认的候选列表逐个执行 |
|
||||
| `EXECUTE` | `drive permission.public patch` | 修改已确认的 public/link settings;必须传 `--yes` |
|
||||
| `EXECUTE` | `drive permission.members transfer_owner` | 转移已确认目标的 owner;必须传 `--yes` |
|
||||
| `EXECUTE` | `drive +secure-label-update` | 设置已确认的 secure-label ID |
|
||||
| `VERIFY` | `drive metas batch_query`, `drive permission.public get` | 验证支持的 metadata,包括 owner、secure-label 和文档公共访问与协作权限设置变更;权限申请只能表述为已发起 |
|
||||
| `VERIFY` | `drive metas batch_query`, `drive +permission-get-setting` | 验证支持的 metadata,包括 owner、secure-label 和目标公共访问与协作权限设置变更;权限申请只能表述为已发起 |
|
||||
|
||||
## Command Patterns
|
||||
|
||||
@@ -119,9 +121,9 @@ Risk / Structure: `R2` / `S2`
|
||||
|
||||
1. "所有文档"只表示当前身份在确认范围内可枚举到的文档。不可见、无权限、API 不返回或工具预算不足的部分必须进入 `discovery_blockers` 或 `unsupported_checks`。
|
||||
2. 发现阶段必须生成稳定 `path`。不要只保存 title;同名文档必须能通过 path 或 token 区分。
|
||||
3. 只把 `drive.permission.public.get` 当前 schema 支持的类型加入公开权限可审计目标。已知支持包括 `doc`、`sheet`、`file`、`wiki`、`bitable`、`docx`、`mindnote`、`minutes`、`slides`;未来新增类型以运行时 schema 为准。
|
||||
3. 权限设置读取使用 `drive +permission-get-setting`,目标类型包括 `doc`、`sheet`、`file`、`wiki`、`bitable`、`docx`、`mindnote`、`minutes`、`slides`、`folder`;未来新增类型以 shortcut 和 OpenAPI 元数据为准。
|
||||
4. `minutes` 只能作为 `partial_public_permission` 目标:可读取 / 修改公开权限和 owner 转移能力以运行时 schema 为准,但 `drive metas batch_query` 当前不支持 `minutes`,URL、owner、密级等 metadata 可能进入 `unsupported_checks`。
|
||||
5. `folder` 只作为递归容器,不执行 `permission.public get` / `patch`。如果用户明确要求 owner 转移且 schema 支持 `folder`,必须按 owner-transfer 写入规则单独确认。`shortcut`、`catalog` 或缺少 stable token/type 的条目必须记录为 unsupported,除非后续 API 明确解析出支持目标。
|
||||
5. `folder` 作为递归容器时先枚举子资源;如用户明确要查询文件夹自身权限设置,可对该文件夹单独执行 `drive +permission-get-setting --token <folder_token> --type folder`。不要执行 raw `permission.public patch type=folder`,除非 schema 和需求都明确支持。`shortcut`、`catalog` 或缺少 stable token/type 的条目必须记录为 unsupported,除非后续 API 明确解析出支持目标。
|
||||
6. 对大范围目标输出进度时,只展示已扫描容器数、已发现目标数、已审计目标数、剩余队列或 blocker;不要默认展示内部 page token / cursor。
|
||||
|
||||
Wiki space / node 发现:
|
||||
@@ -133,7 +135,7 @@ Wiki space / node 发现:
|
||||
|
||||
Drive folder 发现:
|
||||
|
||||
1. `/drive/folder/<folder_token>` 解析为 `target_scope=drive_folder`。文件夹自身公开权限不支持;继续枚举其子文档。
|
||||
1. `/drive/folder/<folder_token>` 解析为 `target_scope=drive_folder`。默认继续枚举其子文档;只有用户明确要求文件夹自身权限设置时,才额外调用 `drive +permission-get-setting --token <folder_token> --type folder` 读取该文件夹自身设置。
|
||||
2. 按 [`lark-drive-files-list.md`](lark-drive-files-list.md) 递归处理 `data.files`、`has_more` 和 `next_page_token`。不要把第一页数量当作完整范围。
|
||||
3. 只对返回项中的 `folder` 继续递归;对子文档按 `type + token` 归一化为 `discovered_targets`。
|
||||
4. 如果某个目录分页失败、无 continuation token、权限不足或 API 报错,只阻断该目录分支,并在 `discovery_blockers` 中记录;继续处理其他可枚举分支。
|
||||
@@ -141,11 +143,11 @@ Drive folder 发现:
|
||||
## Fact Read Rules
|
||||
|
||||
1. `drive metas batch_query` 单次最多 200 个 `request_docs`;当 `targets` 或 `discovered_targets` 超过 200 个时,必须分批读取并合并结果。
|
||||
2. `drive permission.public get` 没有批量读取接口;对支持目标逐个读取。单个目标失败时记录 `unsupported_checks` 或 `partial`,不要阻断其他目标。
|
||||
2. `drive +permission-get-setting` 没有批量读取接口;对支持目标逐个读取。单个目标失败时记录 `unsupported_checks` 或 `partial`,不要阻断其他目标。
|
||||
3. 对 Wiki 发现目标,公开权限读取优先使用 `type=wiki` + `node_token`;metadata 可使用 `obj_type` + `obj_token` 补充 title、owner、URL 和 `sec_label_name`。
|
||||
4. 当 intent 是 `list_permission_settings` 时,只输出权限设置清单和覆盖限制,不主动生成修复计划。
|
||||
5. 单目标、多目标明确列表和容器发现目标都必须复用同一套逐目标事实读取与语义归一逻辑;差异只体现在目标来源、coverage summary 和输出聚合。
|
||||
6. `permission_public` 用户可见含义是“文档公共访问和协作权限设置”,语义以官方 OpenAPI 字段说明为准,同时兼容当前 CLI schema 返回的字段:优先使用 `external_access_entity`,缺失时才用 `external_access` boolean 映射为 `open` / `closed`;`manage_collaborator_entity`、`copy_entity`、`lock_switch` 等字段缺失时标记为 unknown,不要伪造;未识别字段保留在 raw evidence / partial note 中。
|
||||
6. `permission_public` 用户可见含义是“目标公共访问和协作权限设置”,语义以官方 OpenAPI 字段说明为准,同时兼容当前 CLI schema 返回的字段:优先使用 `external_access_entity`,缺失时才用 `external_access` boolean 映射为 `open` / `closed`;`manage_collaborator_entity`、`copy_entity`、`lock_switch` 等字段缺失时标记为 unknown,不要伪造;未识别字段保留在 raw evidence / partial note 中。
|
||||
7. `drive file.statistics get` 和 `drive file.view_records list` 只在用户要求最近访问、活跃度、闲置暴露、访问复核,或用户提供的 policy 明确依赖活跃度时执行;不要为普通权限审计默认读取访问记录。
|
||||
8. 访问统计 / 访问记录当前只对 `doc`、`docx`、`sheet`、`bitable`、`mindnote`、`wiki`、`file` 作为支持类型处理。其他类型必须进入 `unsupported_checks`,不能推断活跃度。
|
||||
9. `view_records` 是访问证据,不是权限列表。没有返回访问记录只能表述为“未获得最近访问证据”或“低活跃候选”,不能表述为“无人有权限”。
|
||||
@@ -162,17 +164,17 @@ Drive folder 发现:
|
||||
- `PolicyReview`:复制、创建副本、打印、下载、评论等依赖 policy 的设置;没有明确 policy 时不要称为高风险。
|
||||
- `Unknown`:读取失败、已删除、无权限、API 不支持、协作者名单 / 继承链 / DLP / AI 索引 / 审计日志未覆盖。
|
||||
|
||||
每个可审计目标都必须先归一化为 `per_target_permission_assessment`,再按 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md) 的 `Semantic Rendering` 渲染。`public_exposure_check` 只是 `target_count=1` 的轻量渲染模式;它和多目标、容器诊断复用同一套语义字段与风险分类。该判断只覆盖当前文档公共访问和协作权限设置,不审计协作者名单、历史权限变更、完整继承链或审计日志。
|
||||
每个可审计目标都必须先归一化为 `per_target_permission_assessment`,再按 [`lark-drive-workflow-permission-governance-outputs.md`](lark-drive-workflow-permission-governance-outputs.md) 的 `Semantic Rendering` 渲染。`public_exposure_check` 只是 `target_count=1` 的轻量渲染模式;它和多目标、容器诊断复用同一套语义字段与风险分类。该判断只覆盖当前目标公共访问和协作权限设置,不审计协作者名单、历史权限变更、完整继承链或审计日志。
|
||||
|
||||
`AI 检索暴露候选风险` 只是基于权限和标签的代理标签。除非另有工具明确返回索引状态,否则不要声称某个文档已经被 Agent、Copilot 或 RAG 索引。
|
||||
|
||||
## 写入规则
|
||||
|
||||
- 文档公共访问和协作权限设置修改(`drive permission.public patch`)属于高风险写入。请求确认前,必须展示 target title、token、current setting、desired setting 和准确 field changes。
|
||||
- 目标公共访问和协作权限设置修改(`drive permission.public patch`)属于高风险写入。请求确认前,必须展示 target title、token、current setting、desired setting 和准确 field changes。
|
||||
- 如果 `manage_public_auth.auth_result=false`,禁止 patch。告诉用户需要具备 manage-public 权限的用户,或由 owner 操作。
|
||||
- `drive permission.public get` 只用于 `drive +inspect` 或 `DISCOVER_TARGETS` 可解析且运行时 schema 支持的目标类型;类型集合不要硬编码,执行时以 `lark-cli schema drive.permission.public.get` 为准。
|
||||
- 权限设置读取使用 `drive +permission-get-setting`;裸 token 必须传 `--type`,URL 可以自动推断。写入仍使用 `drive permission.public patch`,只 patch 已解析且 schema 明确支持的类型和字段,不要把读取支持的 `folder` 自动外推为可写入。
|
||||
- 不要 patch 已解析类型不支持的字段。对于 wiki 目标,必须省略 schema 明确标注为 wiki 不支持的字段。
|
||||
- 不要在同一个写入确认中合并密级标签更新和文档公共访问与协作权限设置修改;必须分别确认。
|
||||
- 不要在同一个写入确认中合并密级标签更新和目标公共访问与协作权限设置修改;必须分别确认。
|
||||
- `drive +apply-permission` 默认不批量执行;每次调用都会向 owner 发送通知。
|
||||
- `permission_request_candidates` 可以来自用户直接提供的目标、明确列表或容器发现目标;只要能构造 token、type、权限类型和申请理由,就可以进入候选。不要因为目标不在 `discovered_targets` 中而拒绝单目标 / 小列表权限申请。
|
||||
- 容器范围内的"统一申请权限"必须先产出 `permission_request_candidates`。未展示候选目标、数量、权限类型和 owner 通知影响前,禁止调用 `drive +apply-permission`。
|
||||
@@ -182,8 +184,8 @@ Drive folder 发现:
|
||||
- 批量 owner 转移必须逐个顺序执行;失败项进入结果清单,不要重复执行已成功目标。`remove_old_owner=true` 或 `old_owner_perm` 降权必须单独在确认中高亮。
|
||||
- 用户要求“生成整改方案 / dry-run / 先看看会改什么”时,只生成 `remediation_plan`,不执行任何写命令。dry-run 必须包含 target count、field changes、跳过原因、验证方式和有限回滚范围。
|
||||
- 用户基于完整风险清单选择对象时,必须先解析 `risk_id`、风险分组、URL 或 artifact 中 `selected=true` 的行,生成 `selected_risk_items`。无法匹配到当前 `risk_manifest` 的选择必须要求用户重新确认或重新读取清单。
|
||||
- 针对 `selected_risk_items` 生成 dry-run 前,必须重新读取所选目标的 `drive permission.public get`;如果当前设置和清单快照不同,标记为 `changed_since_report` 并跳过或要求用户确认更新后的计划。
|
||||
- 执行 `drive permission.public patch` 前,必须把当前 `public_permission_facts` 中会被改动的字段保存为 `public_permission_snapshots`。该快照只用于文档公共访问和协作权限设置字段的有限回滚说明,不覆盖协作者、owner、继承权限或密级标签。
|
||||
- 针对 `selected_risk_items` 生成 dry-run 前,必须重新读取所选目标的 `drive +permission-get-setting`;如果当前设置和清单快照不同,标记为 `changed_since_report` 并跳过或要求用户确认更新后的计划。
|
||||
- 执行 `drive permission.public patch` 前,必须把当前 `public_permission_facts` 中会被改动的字段保存为 `public_permission_snapshots`。该快照只用于目标公共访问和协作权限设置字段的有限回滚说明,不覆盖协作者、owner、继承权限或密级标签。
|
||||
- 如果用户要求批量收紧权限,必须按风险分层和目标顺序逐个执行;失败项进入结果清单,不要因为单个失败而重复执行已成功目标。
|
||||
- 遇到 secure-label downgrade error `1063013` 时,停止重试,并告诉用户需要在文档 UI 中完成审批。
|
||||
|
||||
@@ -194,7 +196,7 @@ Drive folder 发现:
|
||||
- `drive permission.members create` 可创建协作者权限,但当前 workflow 不做协作者 grant / update / revoke;未来需要单独定义授权对象解析、最小权限、确认模板和验证方式。
|
||||
- backup owner、部门 / 项目负责人绑定没有当前 workflow 可执行写入面;如用户要落地为 owner 转移,必须先给出明确目标和新 owner,并走本 workflow 的 owner-transfer 确认。
|
||||
- `wiki +member-list` 可作为 Wiki space 成员治理的读侧事实来源;当前 workflow 只治理文档 / 节点 / 文件夹下可发现文档的权限,不做 space member governance。
|
||||
- 当前 CLI 没有 `permission.members list`、完整继承链、DLP 扫描、AI 索引状态、审计日志和跨平台权限事实。遇到这些需求必须记录为 `unsupported_checks` 或建议新增独立 workflow。
|
||||
- `drive +member-list` 可读取单目标直接协作者/授权成员;当前 CLI 仍没有完整继承链、DLP 扫描、AI 索引状态、审计日志和跨平台权限事实。遇到这些需求必须记录为 `unsupported_checks` 或建议新增独立 workflow。
|
||||
|
||||
## 输出策略
|
||||
|
||||
|
||||
@@ -75,15 +75,17 @@ metadata:
|
||||
|
||||
## Quick Reference
|
||||
|
||||
**本表只定位「场景 → 用哪条命令、读哪份文档」。参数以「执行前必做」里对应的文档和 `lark-cli slides +<verb> --help` 为准,不要凭记忆或按别的命令类比补参数。**
|
||||
|
||||
| 用户需求 | 优先动作 | 关键文档 / 命令 |
|
||||
|----------|----------|-----------------|
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`slides +create` |
|
||||
| 用户要求使用模板 | 将模板导入为 Slides 再编辑 | `lark-slides-pptx-template-workflows.md` |
|
||||
| 新建 PPT | 先规划 `slide_plan.json`,再按复杂度选择一步或两步创建 | `planning-layer.md`、`visual-planning.md`、`asset-planning.md`、`lark-slides-create.md`、`slides +create` |
|
||||
| 用户要求使用模板,或提供 PPTX 文件要求修改、美化 | 将模板导入为 Slides 再编辑 | `lark-slides-pptx-template-workflows.md` |
|
||||
| 编辑单个标题、文本块、图片或局部元素 | 优先块级替换/插入,不改页序 | `slides +replace-slide`、`lark-slides-replace-slide.md` |
|
||||
| 读取或分析已有 PPT | 解析 slides/wiki token,用 shortcut 回读全文 XML 或读取单页 XML,保存 `xml_presentation_id`、`slide_id`、`revision_id` | `slides +xml-get`、`xml_presentation.slide.get`、`lark-slides-xml-presentations-get.md` |
|
||||
| 查看或回滚历史版本 | 先用 `+history-list` 找 `history_version_id`,再 `+history-revert`,必要时 `+history-revert-status` 轮询 | [`lark-slides-history.md`](references/lark-slides-history.md) |
|
||||
| 获取幻灯片页面截图 | 用 `slide_id` 或页号指定页面,一次不超过 10 页 | `slides +screenshot`、`lark-slides-screenshot.md` |
|
||||
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`、`lark-slides-media-upload.md`,或 `+create --slides` 的 `@./path` 占位符 |
|
||||
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`、`lark-slides-media-upload.md`,或 `+create --slides` 的 XML 里写 `<img src="@./path">` 占位符 |
|
||||
| 绘制图表 | 原生图表(柱状、条形、折线、面积、饼(环)、雷达、组合图)用 `<chart>`,其他(漏斗图、金字塔图、象限图、矩阵图等)用 `<shape>` + `<line>` 模拟 | `xml-schema-quick-ref.md`、`slides_chart_demo.xml` |
|
||||
| 绘制表格 | 优先用 `rect` 和 `text` 模拟,其他用 `<table>` | `xml-schema-quick-ref.md` |
|
||||
| 使用图标 | 禁止盲猜 iconType,必须先检索 IconPark,再写 `<icon iconType="...">`,图标必须填充颜色并和背景有足够对比,禁止使用 emoji 图标 | `iconpark_tool.py search → resolve`、`iconpark.md` |
|
||||
@@ -141,9 +143,9 @@ lark-cli auth login --domain slides
|
||||
- [asset-planning.md](references/asset-planning.md)(新建 / 大幅改写)
|
||||
- [validation-checklist.md](references/validation-checklist.md)(创建 / 大幅改写后)
|
||||
|
||||
按需再读:
|
||||
调用相关命令前必须读取相关的文档以了解命令的使用方式:
|
||||
|
||||
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
|
||||
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)、[`lark-slides-xml-presentation-slide-create.md`](references/lark-slides-xml-presentation-slide-create.md)(逐页添加)
|
||||
- 阅读:[`lark-slides-xml-presentations-get.md`](references/lark-slides-xml-presentations-get.md)
|
||||
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)、[`lark-slides-replace-pages.md`](references/lark-slides-replace-pages.md)
|
||||
- 历史版本:[`lark-slides-history.md`](references/lark-slides-history.md)
|
||||
@@ -189,20 +191,6 @@ lark-cli auth login --domain slides
|
||||
- 不要在任何位置使用 emoji 图标。
|
||||
|
||||
|
||||
### 创建方式选择
|
||||
|
||||
| 场景 | 推荐方式 |
|
||||
|------|----------|
|
||||
| 简单 XML(1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 |
|
||||
| 复杂 XML(多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多) | **两步创建**:先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide create` 逐页添加 |
|
||||
| 已有 PPT 继续追加或插入页面 | 使用 `xml_presentation.slide create`,必要时配合 `before_slide_id` |
|
||||
|
||||
> [!WARNING]
|
||||
> `--slides '[...]'` 的风险点主要在 shell 参数传递,而不是单纯页数。即使只有 1 页,只要 XML 足够复杂,也建议使用两步创建法。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。
|
||||
|
||||
### 生成流程
|
||||
|
||||
```text
|
||||
@@ -220,17 +208,18 @@ Step 2: 生成大纲 → 写入 slide_plan.json
|
||||
Step 3: 按 slide_plan.json 生成 XML → 创建
|
||||
- 逐页消费 plan:key_message 定主结论,layout_type 定几何,visual_focus 定主视觉,text_density 定文本量
|
||||
- 缺少真实素材时必须用 `fallback_if_missing` 生成替代图片,不要留空
|
||||
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
|
||||
- 读 lark-slides-create.md 定一步创建还是两步创建,并据此构造 `slides +create`;两步创建再读 lark-slides-xml-presentation-slide-create.md 逐页添加
|
||||
- 图片按 lark-slides-media-upload.md 处理;复杂 XML、转义和 3350001 排查按 troubleshooting.md 执行
|
||||
|
||||
Step 4: 审查 & 交付
|
||||
- 创建完成后,必须用 `slides +xml-get` 读取全文 XML,并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
|
||||
- 创建完成后,必须用 `slides +xml-get --presentation <xml_presentation_id>` 读取全文 XML,并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
|
||||
- 失败或部分成功按 troubleshooting.md 处理;局部问题优先用 `+replace-slide` 修正
|
||||
- 没问题 → 交付:使用 NotifyHuman 工具交付 PPT 链接
|
||||
```
|
||||
|
||||
### jq 命令模板(编辑已有 PPT 时使用)
|
||||
|
||||
新建 PPT 推荐用 `+create --slides`。以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
|
||||
以下 jq 模板适用于向已有演示文稿追加页面的场景,可以避免手动转义双引号:
|
||||
|
||||
```bash
|
||||
# 追加到末尾
|
||||
@@ -313,8 +302,9 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+create`](references/lark-slides-create.md) | 创建 PPT(可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
|
||||
| [`+xml-get`](references/lark-slides-xml-presentations-get.md) | 读取全文 XML 并保存到本地文件,避免终端输出被截断 |
|
||||
| [`+create`](references/lark-slides-create.md) | 创建 PPT,可选一步添加页面 |
|
||||
| [`+xml-get`](references/lark-slides-xml-presentations-get.md) | 读取全文 XML,用 `--presentation` 指定演示文稿的 `xml_presentation_id`,用 `--output` 把 XML 存到本地文件(必须是 CWD 内的相对路径,如 `.lark-slides/plan/<deck>/readback.xml`) |
|
||||
| [`+screenshot`](references/lark-slides-screenshot.md) | 把幻灯片页面截图保存为本地图片,用 `--slide-number` 指定页号(从 1 开始,多页重复传入,一次最多 10 页),用 `--output-dir` 指定保存目录(必须是 CWD 内的相对路径,默认 `.lark-slides/screenshots`),失败时降级到 XML 回读等非截图检查 |
|
||||
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
|
||||
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
|
||||
| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 |
|
||||
@@ -331,12 +321,12 @@ lark-cli slides <resource> <method> [flags] # 调用 API
|
||||
## 核心规则
|
||||
|
||||
1. **先规划再写 XML**:新建演示文稿或大幅改写页面时,必须先写入 `.lark-slides/plan/<deck-or-task-id>/slide_plan.json`;模板、风格和大纲只能作为规划输入,不能绕过规划层
|
||||
2. **创建流程**:简单短 XML(1-3 页、结构简单、特殊字符少)可用 `slides +create --slides '[...]'` 一步创建;复杂内容、含图片/中文大段文本/嵌套引号/较多特殊字符,或超过 10 页时,默认先 `slides +create` 创建空白 PPT,再用 `xml_presentation.slide.create` 逐页添加
|
||||
2. **创建流程**:新建演示文稿用 `slides +create`,一步创建还是两步创建按 [`lark-slides-create.md`](references/lark-slides-create.md) 判断
|
||||
3. **`<slide>` 直接子元素只有 `<style>`、`<data>`、`<note>`**:文本和图形必须放在 `<data>` 内
|
||||
4. **文本通过 `<content>` 表达**:必须用 `<content><p>...</p></content>`,不能把文字直接写在 shape 内
|
||||
5. **保存关键 ID**:后续操作需要 `xml_presentation_id`、`slide_id`、`revision_id`
|
||||
6. **删除谨慎**:删除操作不可逆,且至少保留一页幻灯片
|
||||
7. **编辑已有页面优先原链接更新**:修改单个 shape/img 用 `+replace-slide`(`block_replace` / `block_insert`),不要整页重建;已有 Slides 的多页整页重建用 `+replace-pages`,不要用 `slides +create` 新建整份 PPT;只有没有 shortcut 覆盖的特殊单页整页操作才手动 `slide.create` + `slide.delete`
|
||||
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传或 `+create --slides` 的 `@./path` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。
|
||||
8. **`<img src>` 只能用上传到飞书 drive 的 `file_token`,禁止使用 http(s) 外链 URL**:飞书 slides 渲染端不会代理外链图片,外链 src 在 PPT 里通常不显示或显示破图。流程必须是「先把图存到本地 → 用 `slides +media-upload` 上传,或在 `+create --slides` 的 XML 里写 `<img src="@./path">` 占位符自动上传 → 拿 `file_token` 写进 `<img src>`」。如果用户给了网图链接,先 `curl`/下载到 CWD 内再走上传流程,不要直接把外链 URL 塞进 `src`。**图片最大 20 MB**(slides upload API 不支持分片上传)。
|
||||
|
||||
> **注意**:如果 md 内容与 `slides_xml_schema_definition.xml` 或 `lark-cli schema slides.<resource>.<method>` 输出不一致,以后两者为准。
|
||||
|
||||
63
skills/lark-slides/pie_chart_demo.xml
Normal file
63
skills/lark-slides/pie_chart_demo.xml
Normal file
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
|
||||
<title>资产处置类型分布</title>
|
||||
<theme>
|
||||
<textStyles>
|
||||
<headline fontColor="rgba(31, 35, 41, 1)"/>
|
||||
<body fontColor="rgba(31, 35, 41, 1)"/>
|
||||
<caption fontColor="rgba(155, 158, 162, 1)" fontSize="14"/>
|
||||
</textStyles>
|
||||
</theme>
|
||||
<slide>
|
||||
<style>
|
||||
<fill>
|
||||
<fillColor color="rgba(250, 248, 242, 1)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<shape width="800" height="40" topLeftX="80" topLeftY="40" type="text">
|
||||
<content textType="headline" fontSize="24" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)" bold="true">
|
||||
<p>资产处置类型分布</p>
|
||||
</content>
|
||||
</shape>
|
||||
<chart width="700" height="440" topLeftX="130" topLeftY="80">
|
||||
<chartPlotArea>
|
||||
<chartPlot type="pie" yAxisPosition="right">
|
||||
<chartExtra/>
|
||||
<chartLabels position="outside" category="false" value="true" percentage="true" fontSize="14" color="rgba(60, 60, 60, 1)"/>
|
||||
<chartSeriesList>
|
||||
<chartSeries index="1">
|
||||
<chartSectors innerRadius="0.5" offsetRadius="0" startAngle="90"/>
|
||||
</chartSeries>
|
||||
</chartSeriesList>
|
||||
</chartPlot>
|
||||
</chartPlotArea>
|
||||
<chartLegend position="right" fontSize="16" color="rgba(60, 60, 60, 1)"/>
|
||||
<chartData>
|
||||
<dim1>
|
||||
<chartField name="类型">股权处置,土地经营权,停车泊位,供水污水,房产处置,保障房,林水经营</chartField>
|
||||
</dim1>
|
||||
<dim2>
|
||||
<chartField name="规模">24,19,8.5,7.5,5,4.15,2.7</chartField>
|
||||
</dim2>
|
||||
</chartData>
|
||||
<chartStyle>
|
||||
<chartBackground color="rgba(0, 0, 0, 0)"/>
|
||||
<chartBorder color="rgb(222, 224, 227)" width="0"/>
|
||||
<chartColorTheme>
|
||||
<color value="rgb(178, 34, 34)"/>
|
||||
<color value="rgb(205, 50, 50)"/>
|
||||
<color value="rgb(224, 80, 80)"/>
|
||||
<color value="rgb(238, 110, 110)"/>
|
||||
<color value="rgb(218, 165, 32)"/>
|
||||
<color value="rgb(238, 195, 55)"/>
|
||||
<color value="rgb(248, 222, 100)"/>
|
||||
</chartColorTheme>
|
||||
</chartStyle>
|
||||
</chart>
|
||||
</data>
|
||||
<note>
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
</presentation>
|
||||
52
skills/lark-slides/pie_chart_slide.xml
Normal file
52
skills/lark-slides/pie_chart_slide.xml
Normal file
@@ -0,0 +1,52 @@
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<style>
|
||||
<fill>
|
||||
<fillColor color="rgba(250, 248, 242, 1)"/>
|
||||
</fill>
|
||||
</style>
|
||||
<data>
|
||||
<shape width="800" height="40" topLeftX="80" topLeftY="40" type="text">
|
||||
<content textType="headline" fontSize="24" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)" bold="true">
|
||||
<p>资产处置类型分布</p>
|
||||
</content>
|
||||
</shape>
|
||||
<chart width="700" height="440" topLeftX="130" topLeftY="80">
|
||||
<chartPlotArea>
|
||||
<chartPlot type="pie" yAxisPosition="right">
|
||||
<chartExtra/>
|
||||
<chartLabels position="outside" category="false" value="true" percentage="true" fontSize="14" color="rgba(60, 60, 60, 1)"/>
|
||||
<chartSeriesList>
|
||||
<chartSeries index="1">
|
||||
<chartSectors innerRadius="0.5" offsetRadius="0" startAngle="90"/>
|
||||
</chartSeries>
|
||||
</chartSeriesList>
|
||||
</chartPlot>
|
||||
</chartPlotArea>
|
||||
<chartLegend position="right" fontSize="16" color="rgba(60, 60, 60, 1)"/>
|
||||
<chartData>
|
||||
<dim1>
|
||||
<chartField name="类型">股权处置,土地经营权,停车泊位,供水污水,房产处置,保障房,林水经营</chartField>
|
||||
</dim1>
|
||||
<dim2>
|
||||
<chartField name="规模">24,19,8.5,7.5,5,4.15,2.7</chartField>
|
||||
</dim2>
|
||||
</chartData>
|
||||
<chartStyle>
|
||||
<chartBackground color="rgba(0, 0, 0, 0)"/>
|
||||
<chartBorder color="rgb(222, 224, 227)" width="0"/>
|
||||
<chartColorTheme>
|
||||
<color value="rgb(178, 34, 34)"/>
|
||||
<color value="rgb(205, 50, 50)"/>
|
||||
<color value="rgb(224, 80, 80)"/>
|
||||
<color value="rgb(238, 110, 110)"/>
|
||||
<color value="rgb(218, 165, 32)"/>
|
||||
<color value="rgb(238, 195, 55)"/>
|
||||
<color value="rgb(248, 222, 100)"/>
|
||||
</chartColorTheme>
|
||||
</chartStyle>
|
||||
</chart>
|
||||
</data>
|
||||
<note>
|
||||
<content/>
|
||||
</note>
|
||||
</slide>
|
||||
@@ -3,13 +3,22 @@
|
||||
|
||||
创建一个新的飞书幻灯片演示文稿,可选一步添加页面内容。
|
||||
|
||||
- 禁止:从完整 <presentation> XML 解析/拆分/重序列化生成提交 payload。
|
||||
- 推荐:提交源直接就是单页 <slide> XML;+create --slides 只接受已经人工/程序直接生成的 slide 数组,不接受由
|
||||
presentation 动态拆出来的数组。
|
||||
提交源必须是直接生成的单页 `<slide>` XML。禁止从完整 `<presentation>` XML 解析、拆分、重序列化出 slide 数组再提交。
|
||||
|
||||
- 最稳:复杂 deck 默认空 deck + 单页 slide create,每次只提交一个 <slide>。
|
||||
本命令只从零创建演示文稿,没有导入本地 PPT 文件的参数。要把已有 PPTX 变成 Slides,用 `drive +import --file <x.pptx> --type slides`,再在导入结果上编辑,流程见 [lark-slides-pptx-template-workflows.md](lark-slides-pptx-template-workflows.md)。
|
||||
|
||||
- 注意:复杂 XML 不适合直接塞命令行,中文、引号、特殊字符较多时,直接拼接 --slides 容易发生 shell 转义或截断。建议将每页 XML 保存为独立文件,使用 `jq --rawfile` 组装 JSON 数组,避免手动处理 XML 引号和换行。
|
||||
## 创建方式选择
|
||||
|
||||
| 场景 | 推荐方式 |
|
||||
|------|----------|
|
||||
| 简单 XML(1-3 页、结构简单、几乎无复杂中文和特殊字符) | `slides +create --slides '[...]'` 一步创建 |
|
||||
| 复杂 XML(多页、含中文、大段文本、复杂布局、嵌套引号、特殊字符较多) | **两步创建**:先 `slides +create` 创建空白 PPT,再用 [`xml_presentation.slide create`](lark-slides-xml-presentation-slide-create.md) 逐页添加 |
|
||||
| 已有 PPT 继续追加或插入页面 | 使用 [`xml_presentation.slide create`](lark-slides-xml-presentation-slide-create.md),必要时配合 `before_slide_id` |
|
||||
|
||||
> [!WARNING]
|
||||
> `--slides '[...]'` 的风险点主要在 shell 参数传递,而不是单纯页数。即使只有 1 页,只要 XML 足够复杂,也建议使用两步创建法。
|
||||
> [!IMPORTANT]
|
||||
> `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。
|
||||
|
||||
## 命令
|
||||
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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]; 作为后代段落的初始计数器, 子元素 <p>/<ol> 可通过自身 autoStartAt 重置
|
||||
- bulletChar: 自定义列表符号字符, 可选, 如 "★", "→" 等, 设置后覆盖 listStyle 的符号
|
||||
- anchorCenter: 控制文本对齐方式, 优先级高于 textAlign
|
||||
- autoFit: 控制文本编辑溢出时处理策略
|
||||
- baseline: 上标/下标, 相较于文本基线的偏移量
|
||||
@@ -1049,6 +1170,13 @@
|
||||
|
||||
注意:如果content子元素不指定属性, 默认继承content的属性值, 如果局部子元素指定了属性, 则使用局部属性值
|
||||
|
||||
autoStartAt 运行计数器示例(显式指定重置, 未指定沿用前序计数器):
|
||||
<content autoStartAt="5">
|
||||
<p list="number">A</p> <!-- A=5, 继承 content 初始值 -->
|
||||
<p list="number" autoStartAt="10">B</p> <!-- B=10, 本段显式重置 -->
|
||||
<p list="number">C</p> <!-- C=11, 沿用前序计数器递增 -->
|
||||
</content>
|
||||
|
||||
子元素:
|
||||
- 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]; 作用于本列表组的计数器初始值, 子元素 <li@index> 可覆盖单项编号
|
||||
</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>
|
||||
通用公式元素。
|
||||
用于展示各类科学公式。
|
||||
|
||||
结构说明:
|
||||
- 必须从支持的公式格式中选择且仅选择一种作为子元素。
|
||||
- 当前版本支持格式:<latex>
|
||||
|
||||
示例:
|
||||
- 基础公式:
|
||||
<formula>
|
||||
<latex><![CDATA[ E = mc^2 ]]></latex>
|
||||
</formula>
|
||||
</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 转义(如 &lt;, &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>1.2.3.- 全角数字带点</xs:documentation></xs:annotation></xs:enumeration>
|
||||
<xs:enumeration value="arabic-db-plain"><xs:annotation><xs:documentation>1 2 3 - 全角纯数字</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"/>
|
||||
|
||||
@@ -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)。
|
||||
|
||||
186
skills/lark-slides/scripts/chart_overlay_harness.py
Normal file
186
skills/lark-slides/scripts/chart_overlay_harness.py
Normal file
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
# SPDX-License-Identifier: MIT
|
||||
"""Black-box harness for the `chart_external_overlay` lint rule.
|
||||
|
||||
Runs xml_text_overlap_lint over every fixture named in a manifest and compares
|
||||
the observed `chart_external_overlay` issues against each page's expectation.
|
||||
|
||||
Usage:
|
||||
python3 chart_overlay_harness.py --plan <plan-dir> [--input-xml <file>] [--out <dir>]
|
||||
|
||||
--plan directory holding manifest.json and slides/ (the fixtures).
|
||||
--input-xml optional single presentation XML to lint instead of per-fixture
|
||||
files (used to lint a server readback deck); pages are matched to
|
||||
the manifest by slide order.
|
||||
--out output directory for results.json and report.md (default: --plan).
|
||||
|
||||
Exit code is 0 when false-negatives == 0 and false-positives == 0, else 1.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
import xml_text_overlap_lint as lint # noqa: E402
|
||||
|
||||
CODE = "chart_external_overlay"
|
||||
|
||||
|
||||
def chart_issues(slide_result: dict) -> list[dict]:
|
||||
return [i for i in slide_result.get("issues", []) if i.get("code") == CODE]
|
||||
|
||||
|
||||
def other_error_codes(slide_result: dict) -> list[str]:
|
||||
return sorted(
|
||||
{
|
||||
i.get("code")
|
||||
for i in slide_result.get("issues", [])
|
||||
if i.get("level") == "error" and i.get("code") != CODE
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def slides_from_presentation(xml: str) -> list[str]:
|
||||
return re.findall(r"<slide\b[\s\S]*?</slide>", xml)
|
||||
|
||||
|
||||
def lint_page(slide_xml: str) -> dict:
|
||||
wrapped = (
|
||||
'<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">'
|
||||
+ slide_xml
|
||||
+ "</presentation>"
|
||||
)
|
||||
result = lint.lint_xml(wrapped)
|
||||
return result["slides"][0] if result.get("slides") else {"issues": []}
|
||||
|
||||
|
||||
def evaluate(manifest: dict, plan_dir: Path, readback_xml: str | None) -> dict:
|
||||
pages = manifest["pages"]
|
||||
readback_slides = slides_from_presentation(readback_xml) if readback_xml else None
|
||||
rows = []
|
||||
fn = fp = unrelated = 0
|
||||
for idx, page in enumerate(pages):
|
||||
if readback_slides is not None:
|
||||
if idx >= len(readback_slides):
|
||||
rows.append({**_row_base(page), "status": "MISSING_IN_READBACK",
|
||||
"observed_issue_count": None})
|
||||
fn += 1 if page["expected_issue_count"] else 0
|
||||
continue
|
||||
slide_result = lint_page(readback_slides[idx])
|
||||
else:
|
||||
slide_xml = (plan_dir / page["fixture"]).read_text(encoding="utf-8")
|
||||
slide_result = lint_page(slide_xml)
|
||||
|
||||
issues = chart_issues(slide_result)
|
||||
observed = len(issues)
|
||||
expected = page["expected_issue_count"]
|
||||
others = other_error_codes(slide_result)
|
||||
if others:
|
||||
unrelated += 1
|
||||
|
||||
# A page is "correct" when observed presence matches expectation.
|
||||
expected_present = expected > 0
|
||||
observed_present = observed > 0
|
||||
if expected_present and not observed_present:
|
||||
status = "FALSE_NEGATIVE"
|
||||
fn += 1
|
||||
elif not expected_present and observed_present:
|
||||
status = "FALSE_POSITIVE"
|
||||
fp += 1
|
||||
elif expected_present and observed != expected:
|
||||
status = "COUNT_MISMATCH" # right presence, wrong number of chart issues
|
||||
else:
|
||||
status = "OK"
|
||||
|
||||
rows.append({
|
||||
**_row_base(page),
|
||||
"observed_issue_count": observed,
|
||||
"observed_elements": [i.get("elements") for i in issues],
|
||||
"unrelated_error_codes": others,
|
||||
"status": status,
|
||||
})
|
||||
|
||||
return {
|
||||
"mode": "readback" if readback_xml else "fixture",
|
||||
"summary": {
|
||||
"page_count": len(pages),
|
||||
"false_negatives": fn,
|
||||
"false_positives": fp,
|
||||
"pages_with_unrelated_errors": unrelated,
|
||||
"pass": fn == 0 and fp == 0,
|
||||
},
|
||||
"pages": rows,
|
||||
}
|
||||
|
||||
|
||||
def _row_base(page: dict) -> dict:
|
||||
return {
|
||||
"case_id": page["case_id"],
|
||||
"slide_number": page["slide_number"],
|
||||
"chart_type": page.get("chart_type"),
|
||||
"rendered_occlusion": page.get("rendered_occlusion"),
|
||||
"rendered_occlusion_source": page.get("rendered_occlusion_source"),
|
||||
"expected_issue_count": page["expected_issue_count"],
|
||||
}
|
||||
|
||||
|
||||
def render_markdown(results: dict) -> str:
|
||||
s = results["summary"]
|
||||
lines = [
|
||||
f"# chart_external_overlay black-box results ({results['mode']} mode)",
|
||||
"",
|
||||
f"- pages: **{s['page_count']}**",
|
||||
f"- false negatives: **{s['false_negatives']}**",
|
||||
f"- false positives: **{s['false_positives']}**",
|
||||
f"- pages with unrelated errors: **{s['pages_with_unrelated_errors']}**",
|
||||
f"- suite pass: **{s['pass']}**",
|
||||
"",
|
||||
"| # | case | type | occ | occ_src | exp | obs | unrelated | status |",
|
||||
"|--:|------|------|:---:|:-------:|:---:|:---:|-----------|--------|",
|
||||
]
|
||||
for r in results["pages"]:
|
||||
lines.append(
|
||||
f"| {r['slide_number']} | {r['case_id']} | {r.get('chart_type','')} | "
|
||||
f"{r.get('rendered_occlusion')} | {r.get('rendered_occlusion_source','')} | "
|
||||
f"{r['expected_issue_count']} | {r.get('observed_issue_count')} | "
|
||||
f"{','.join(r.get('unrelated_error_codes') or []) or '-'} | {r['status']} |"
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--plan", required=True)
|
||||
ap.add_argument("--input-xml")
|
||||
ap.add_argument("--out")
|
||||
args = ap.parse_args()
|
||||
|
||||
plan_dir = Path(args.plan).resolve()
|
||||
manifest = json.loads((plan_dir / "manifest.json").read_text(encoding="utf-8"))
|
||||
readback_xml = Path(args.input_xml).read_text(encoding="utf-8") if args.input_xml else None
|
||||
|
||||
results = evaluate(manifest, plan_dir, readback_xml)
|
||||
|
||||
out_dir = Path(args.out).resolve() if args.out else plan_dir
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
suffix = "_readback" if readback_xml else ""
|
||||
(out_dir / f"results{suffix}.json").write_text(
|
||||
json.dumps(results, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
(out_dir / f"report{suffix}.md").write_text(render_markdown(results), encoding="utf-8")
|
||||
|
||||
s = results["summary"]
|
||||
print(f"[{results['mode']}] pages={s['page_count']} FN={s['false_negatives']} "
|
||||
f"FP={s['false_positives']} unrelated={s['pages_with_unrelated_errors']} "
|
||||
f"pass={s['pass']}")
|
||||
return 0 if s["pass"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
642
skills/lark-slides/scripts/collect_width_data.py
Normal file
642
skills/lark-slides/scripts/collect_width_data.py
Normal file
@@ -0,0 +1,642 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Collect text width measurement data from slides presentation for character width estimation optimization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import unicodedata
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
PRESENTATION_ID = "BUFBsLX2ZlzyMTdprLicd7rpneg"
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
CSV_OUTPUT = SCRIPT_DIR / "width_measurement_data.csv"
|
||||
MD_OUTPUT = SCRIPT_DIR / "width_measurement_report.md"
|
||||
|
||||
|
||||
def estimate_character_width(character: str, font_size: int | float) -> int | float:
|
||||
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
|
||||
|
||||
|
||||
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 extract_attribute(tag_source: str, name: str) -> str | None:
|
||||
match = re.search(
|
||||
fr"(?:^|\s){re.escape(name)}\s*=\s*(?:\"([^\"]+)\"|'([^']+)')", tag_source
|
||||
)
|
||||
if not match:
|
||||
return None
|
||||
return match.group(1) if match.group(1) is not None else match.group(2)
|
||||
|
||||
|
||||
def extract_numeric_attribute(tag_source: str, name: str) -> int | float | None:
|
||||
raw = extract_attribute(tag_source, name)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
return 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 strip_xml(value: str, preserve_line_breaks: bool = False) -> str:
|
||||
stripped = re.sub(r"<!\[CDATA\[([\s\S]*?)\]\]>", r"\1", value)
|
||||
if preserve_line_breaks:
|
||||
stripped = re.sub(r"<br\b[^>]*>", "\n", stripped)
|
||||
stripped = re.sub(r"<[^>]+>", " ", stripped)
|
||||
stripped = stripped.replace(" ", " ")
|
||||
stripped = stripped.replace("&", "&")
|
||||
stripped = stripped.replace("<", "<")
|
||||
stripped = stripped.replace(">", ">")
|
||||
stripped = stripped.replace(""", '"')
|
||||
stripped = stripped.replace("'", "'")
|
||||
if preserve_line_breaks:
|
||||
return "\n".join(re.sub(r"\s+", " ", line).strip() for line in stripped.split("\n"))
|
||||
return re.sub(r"\s+", " ", stripped).strip()
|
||||
|
||||
|
||||
def strip_xml_paragraphs(value: str) -> str:
|
||||
paragraphs = re.findall(r"<p\b[^>]*>([\s\S]*?)</p\s*>", value)
|
||||
if paragraphs:
|
||||
return "\n".join(strip_xml(paragraph, preserve_line_breaks=True) for paragraph in paragraphs)
|
||||
return strip_xml(value, preserve_line_breaks=True)
|
||||
|
||||
|
||||
def normalize_text_no_whitespace(text: str) -> str:
|
||||
return re.sub(r"\s+", "", text)
|
||||
|
||||
|
||||
def classify_text_type(text: str) -> str:
|
||||
has_chinese = bool(re.search(r"[\u4e00-\u9fff]", text))
|
||||
has_english = bool(re.search(r"[A-Za-z]", text))
|
||||
has_digit = bool(re.search(r"[0-9]", text))
|
||||
has_punctuation = bool(re.search(r"[^\u4e00-\u9fffA-Za-z0-9\s]", text))
|
||||
|
||||
categories = []
|
||||
if has_chinese:
|
||||
categories.append("中文")
|
||||
if has_english:
|
||||
categories.append("英文")
|
||||
if has_digit:
|
||||
categories.append("数字")
|
||||
if has_punctuation and not categories:
|
||||
categories.append("标点")
|
||||
if not categories:
|
||||
return "其他"
|
||||
return "+".join(categories)
|
||||
|
||||
|
||||
def extract_text_spans(content: str, default_font_size: int | float, default_font_family: str | None,
|
||||
default_bold: bool, default_italic: bool) -> list[dict[str, Any]]:
|
||||
spans = []
|
||||
|
||||
p_matches = list(re.finditer(r"<p\b([^>]*)>([\s\S]*?)</p\s*>", content))
|
||||
if not p_matches:
|
||||
p_matches = [re.match(r"()([\s\S]*)", content)]
|
||||
|
||||
for p_match in p_matches:
|
||||
p_attrs, p_body = p_match.groups()
|
||||
p_text = strip_xml(p_body, preserve_line_breaks=True)
|
||||
if not p_text:
|
||||
continue
|
||||
|
||||
span_matches = list(re.finditer(r"<span\b([^>]*)>([\s\S]*?)</span\s*>", p_body))
|
||||
if not span_matches:
|
||||
span_text = strip_xml(p_body, preserve_line_breaks=True)
|
||||
if span_text:
|
||||
font_size = default_font_size
|
||||
if extract_numeric_attribute(p_attrs, "fontSize") is not None:
|
||||
font_size = extract_numeric_attribute(p_attrs, "fontSize")
|
||||
font_family = extract_attribute(p_attrs, "fontFamily") or default_font_family
|
||||
latin_font = extract_attribute(p_attrs, "latinFont")
|
||||
ea_font = extract_attribute(p_attrs, "eaFont")
|
||||
cs_font = extract_attribute(p_attrs, "csFont")
|
||||
bold = extract_bool_attribute(p_attrs, "bold") or default_bold
|
||||
italic = extract_bool_attribute(p_attrs, "italic") or default_italic
|
||||
letter_spacing = extract_numeric_attribute(p_attrs, "letterSpacing")
|
||||
|
||||
spans.append({
|
||||
"text": span_text,
|
||||
"font_size": font_size,
|
||||
"font_family": font_family,
|
||||
"latin_font": latin_font,
|
||||
"ea_font": ea_font,
|
||||
"cs_font": cs_font,
|
||||
"bold": bold,
|
||||
"italic": italic,
|
||||
"letter_spacing": letter_spacing,
|
||||
})
|
||||
else:
|
||||
last_end = 0
|
||||
for span_match in span_matches:
|
||||
span_attrs, span_body = span_match.groups()
|
||||
span_text = strip_xml(span_body, preserve_line_breaks=True)
|
||||
if not span_text:
|
||||
last_end = span_match.end()
|
||||
continue
|
||||
|
||||
font_size = default_font_size
|
||||
if extract_numeric_attribute(p_attrs, "fontSize") is not None:
|
||||
font_size = extract_numeric_attribute(p_attrs, "fontSize")
|
||||
if extract_numeric_attribute(span_attrs, "fontSize") is not None:
|
||||
font_size = extract_numeric_attribute(span_attrs, "fontSize")
|
||||
|
||||
font_family = extract_attribute(span_attrs, "fontFamily") or \
|
||||
extract_attribute(p_attrs, "fontFamily") or default_font_family
|
||||
latin_font = extract_attribute(span_attrs, "latinFont") or extract_attribute(p_attrs, "latinFont")
|
||||
ea_font = extract_attribute(span_attrs, "eaFont") or extract_attribute(p_attrs, "eaFont")
|
||||
cs_font = extract_attribute(span_attrs, "csFont") or extract_attribute(p_attrs, "csFont")
|
||||
bold = extract_bool_attribute(span_attrs, "bold") or \
|
||||
extract_bool_attribute(p_attrs, "bold") or default_bold
|
||||
italic = extract_bool_attribute(span_attrs, "italic") or \
|
||||
extract_bool_attribute(p_attrs, "italic") or default_italic
|
||||
letter_spacing = extract_numeric_attribute(span_attrs, "letterSpacing") or \
|
||||
extract_numeric_attribute(p_attrs, "letterSpacing")
|
||||
|
||||
spans.append({
|
||||
"text": span_text,
|
||||
"font_size": font_size,
|
||||
"font_family": font_family,
|
||||
"latin_font": latin_font,
|
||||
"ea_font": ea_font,
|
||||
"cs_font": cs_font,
|
||||
"bold": bold,
|
||||
"italic": italic,
|
||||
"letter_spacing": letter_spacing,
|
||||
})
|
||||
last_end = span_match.end()
|
||||
|
||||
tail_text = strip_xml(p_body[last_end:], preserve_line_breaks=True)
|
||||
if tail_text:
|
||||
font_size = default_font_size
|
||||
if extract_numeric_attribute(p_attrs, "fontSize") is not None:
|
||||
font_size = extract_numeric_attribute(p_attrs, "fontSize")
|
||||
font_family = extract_attribute(p_attrs, "fontFamily") or default_font_family
|
||||
latin_font = extract_attribute(p_attrs, "latinFont")
|
||||
ea_font = extract_attribute(p_attrs, "eaFont")
|
||||
cs_font = extract_attribute(p_attrs, "csFont")
|
||||
bold = extract_bool_attribute(p_attrs, "bold") or default_bold
|
||||
italic = extract_bool_attribute(p_attrs, "italic") or default_italic
|
||||
letter_spacing = extract_numeric_attribute(p_attrs, "letterSpacing")
|
||||
spans.append({
|
||||
"text": tail_text,
|
||||
"font_size": font_size,
|
||||
"font_family": font_family,
|
||||
"latin_font": latin_font,
|
||||
"ea_font": ea_font,
|
||||
"cs_font": cs_font,
|
||||
"bold": bold,
|
||||
"italic": italic,
|
||||
"letter_spacing": letter_spacing,
|
||||
})
|
||||
return spans
|
||||
|
||||
|
||||
def extract_text_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
elements = []
|
||||
|
||||
for match in re.finditer(r"<(shape)\b([^>]*)>", slide_xml):
|
||||
kind, attrs = match.group(1), match.group(2)
|
||||
is_self_closing = attrs.rstrip().endswith("/")
|
||||
content = ""
|
||||
if kind in {"shape"} and not is_self_closing:
|
||||
close_index = slide_xml.find(f"</{kind}>", match.end())
|
||||
if close_index != -1:
|
||||
content = slide_xml[match.end() : close_index]
|
||||
|
||||
element_type = extract_attribute(attrs, "type")
|
||||
if element_type != "text":
|
||||
continue
|
||||
|
||||
element_id = extract_attribute(attrs, "id") or f"{kind}-{len(elements) + 1}"
|
||||
x = extract_numeric_attribute(attrs, "topLeftX")
|
||||
y = extract_numeric_attribute(attrs, "topLeftY")
|
||||
width = extract_numeric_attribute(attrs, "width")
|
||||
height = extract_numeric_attribute(attrs, "height")
|
||||
if any(v is None for v in [x, y, width, height]):
|
||||
continue
|
||||
|
||||
content_attrs_match = re.search(r"<content\b([^>]*)>", content)
|
||||
content_attrs = content_attrs_match.group(1) if content_attrs_match else ""
|
||||
|
||||
font_size = extract_numeric_attribute(content_attrs, "fontSize")
|
||||
if font_size is None:
|
||||
font_size = extract_numeric_attribute(attrs, "fontSize")
|
||||
if font_size is None:
|
||||
font_size = 16
|
||||
|
||||
font_family = extract_attribute(content_attrs, "fontFamily") or extract_attribute(attrs, "fontFamily")
|
||||
latin_font = extract_attribute(content_attrs, "latinFont")
|
||||
ea_font = extract_attribute(content_attrs, "eaFont")
|
||||
cs_font = extract_attribute(content_attrs, "csFont")
|
||||
bold = extract_bool_attribute(content_attrs, "bold") or extract_bool_attribute(attrs, "bold")
|
||||
italic = extract_bool_attribute(content_attrs, "italic") or extract_bool_attribute(attrs, "italic")
|
||||
letter_spacing = extract_numeric_attribute(content_attrs, "letterSpacing")
|
||||
|
||||
wrap = extract_attribute(content_attrs, "wrap")
|
||||
if wrap is None:
|
||||
wrap = "true"
|
||||
auto_fit = extract_attribute(content_attrs, "autoFit")
|
||||
if auto_fit is None:
|
||||
auto_fit = "none"
|
||||
text_align = extract_attribute(content_attrs, "textAlign")
|
||||
vertical_align = extract_attribute(content_attrs, "verticalAlign") or "middle"
|
||||
|
||||
padding_left = extract_numeric_attribute(content_attrs, "paddingLeft") or 0
|
||||
padding_right = extract_numeric_attribute(content_attrs, "paddingRight") or 0
|
||||
padding_top = extract_numeric_attribute(content_attrs, "paddingTop") or 0
|
||||
padding_bottom = extract_numeric_attribute(content_attrs, "paddingBottom") or 0
|
||||
|
||||
full_text = strip_xml_paragraphs(content)
|
||||
clean_text = normalize_text_no_whitespace(full_text)
|
||||
if not clean_text:
|
||||
continue
|
||||
|
||||
content_inner = ""
|
||||
if content_attrs_match:
|
||||
content_start = content_attrs_match.end()
|
||||
content_end = content.find("</content>", content_start)
|
||||
if content_end != -1:
|
||||
content_inner = content[content_start:content_end]
|
||||
|
||||
spans = extract_text_spans(content_inner, font_size, font_family, bold, italic)
|
||||
|
||||
hard_lines = full_text.split("\n")
|
||||
max_line_estimated_width = 0
|
||||
max_line_text = ""
|
||||
for line in hard_lines:
|
||||
line_clean = normalize_text_no_whitespace(line)
|
||||
if not line_clean:
|
||||
continue
|
||||
line_width = 0
|
||||
for span in spans:
|
||||
if span["text"] in line or line in span["text"]:
|
||||
line_width += estimate_text_width(normalize_text_no_whitespace(span["text"]), span["font_size"])
|
||||
if line_width == 0:
|
||||
line_width = estimate_text_width(line_clean, font_size)
|
||||
if line_width > max_line_estimated_width:
|
||||
max_line_estimated_width = line_width
|
||||
max_line_text = line_clean
|
||||
|
||||
available_width = width - padding_left - padding_right
|
||||
|
||||
elements.append({
|
||||
"id": element_id,
|
||||
"x": x,
|
||||
"y": y,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"padding_left": padding_left,
|
||||
"padding_right": padding_right,
|
||||
"padding_top": padding_top,
|
||||
"padding_bottom": padding_bottom,
|
||||
"available_width": available_width,
|
||||
"font_size": font_size,
|
||||
"font_family": font_family,
|
||||
"latin_font": latin_font,
|
||||
"ea_font": ea_font,
|
||||
"cs_font": cs_font,
|
||||
"bold": bold,
|
||||
"italic": italic,
|
||||
"letter_spacing": letter_spacing,
|
||||
"wrap": wrap,
|
||||
"auto_fit": auto_fit,
|
||||
"text_align": text_align,
|
||||
"vertical_align": vertical_align,
|
||||
"text_raw": full_text,
|
||||
"text_clean": clean_text,
|
||||
"max_line_text": max_line_text,
|
||||
"estimated_width": max_line_estimated_width,
|
||||
"width_ratio": max_line_estimated_width / available_width if available_width > 0 else None,
|
||||
"spans": spans,
|
||||
})
|
||||
|
||||
return elements
|
||||
|
||||
|
||||
def run_lark_cli(args: list[str], env: dict[str, str]) -> subprocess.CompletedProcess:
|
||||
cmd = ["lark-cli"] + args
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=str(SCRIPT_DIR),
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
|
||||
def get_presentation_xml(env: dict[str, str]) -> str:
|
||||
result = run_lark_cli(
|
||||
["slides", "+xml-get", "--presentation", PRESENTATION_ID, "--raw"],
|
||||
env,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"Error getting presentation XML: {result.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def get_slide_ids(xml: str) -> list[tuple[int, str]]:
|
||||
slides = []
|
||||
for index, match in enumerate(re.finditer(r"<slide\b([^>]*)>", xml)):
|
||||
attrs = match.group(1)
|
||||
slide_id = extract_attribute(attrs, "id")
|
||||
if slide_id:
|
||||
slides.append((index + 1, slide_id))
|
||||
return slides
|
||||
|
||||
|
||||
def get_slide_xml(slide_id: str, env: dict[str, str]) -> str:
|
||||
result = run_lark_cli(
|
||||
["slides", "+xml-get", "--presentation", PRESENTATION_ID, "--slide-id", slide_id, "--raw"],
|
||||
env,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"Error getting slide {slide_id} XML: {result.stderr}", file=sys.stderr)
|
||||
return ""
|
||||
return result.stdout
|
||||
|
||||
|
||||
def take_screenshots(slide_ids: list[str], output_dir: Path, env: dict[str, str]) -> None:
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
batch_size = 10
|
||||
for i in range(0, len(slide_ids), batch_size):
|
||||
batch = slide_ids[i:i + batch_size]
|
||||
args = [
|
||||
"slides", "+screenshot",
|
||||
"--presentation", PRESENTATION_ID,
|
||||
"--output-dir", str(output_dir.relative_to(SCRIPT_DIR)),
|
||||
]
|
||||
for sid in batch:
|
||||
args.extend(["--slide-id", sid])
|
||||
result = run_lark_cli(args, env)
|
||||
if result.returncode != 0:
|
||||
print(f"Warning: screenshot failed for batch {i//batch_size + 1}: {result.stderr}", file=sys.stderr)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
env = os.environ.copy()
|
||||
env["LARKSUITE_CLI_NO_UPDATE_NOTIFIER"] = "1"
|
||||
env["LARKSUITE_CLI_NO_SKILLS_NOTIFIER"] = "1"
|
||||
|
||||
print(f"Fetching presentation {PRESENTATION_ID}...")
|
||||
full_xml = get_presentation_xml(env)
|
||||
slides_info = get_slide_ids(full_xml)
|
||||
print(f"Found {len(slides_info)} slides")
|
||||
|
||||
screenshot_dir = SCRIPT_DIR / ".lark-slides" / "screenshots"
|
||||
print(f"Taking screenshots (this may take a while)...")
|
||||
take_screenshots([sid for _, sid in slides_info], screenshot_dir, env)
|
||||
|
||||
all_samples = []
|
||||
|
||||
for slide_num, slide_id in slides_info:
|
||||
print(f"Processing slide {slide_num} ({slide_id})...")
|
||||
slide_xml = get_slide_xml(slide_id, env)
|
||||
if not slide_xml:
|
||||
continue
|
||||
|
||||
elements = extract_text_elements(slide_xml)
|
||||
for elem in elements:
|
||||
text_type = classify_text_type(elem["text_clean"])
|
||||
is_single_line_hard = "\n" not in elem["text_raw"]
|
||||
wrap_enabled = elem["wrap"] not in {"false", "0"}
|
||||
has_auto_fit = elem["auto_fit"] in {"normal-auto-fit", "shape-auto-fit"}
|
||||
|
||||
sample = {
|
||||
"slide_number": slide_num,
|
||||
"slide_id": slide_id,
|
||||
"element_id": elem["id"],
|
||||
"x": round(elem["x"], 2),
|
||||
"y": round(elem["y"], 2),
|
||||
"shape_width": round(elem["width"], 2),
|
||||
"shape_height": round(elem["height"], 2),
|
||||
"padding_left": elem["padding_left"],
|
||||
"padding_right": elem["padding_right"],
|
||||
"available_width": round(elem["available_width"], 2),
|
||||
"font_size": elem["font_size"],
|
||||
"font_family": elem["font_family"] or "",
|
||||
"bold": str(elem["bold"]).lower(),
|
||||
"italic": str(elem["italic"]).lower(),
|
||||
"wrap": elem["wrap"] or "",
|
||||
"auto_fit": elem["auto_fit"] or "",
|
||||
"text_align": elem["text_align"] or "",
|
||||
"text_clean": elem["text_clean"],
|
||||
"text_length": len(elem["text_clean"]),
|
||||
"text_type": text_type,
|
||||
"is_single_line_hard": str(is_single_line_hard).lower(),
|
||||
"estimated_width": round(elem["estimated_width"], 2),
|
||||
"width_ratio": round(elem["width_ratio"], 4) if elem["width_ratio"] is not None else "",
|
||||
"likely_wraps_actual": "",
|
||||
"notes": "",
|
||||
}
|
||||
if elem["width_ratio"] is not None and not has_auto_fit:
|
||||
ratio = elem["width_ratio"]
|
||||
if not wrap_enabled:
|
||||
sample["likely_wraps_actual"] = "no_wrap"
|
||||
sample["notes"] = "wrap=false,不自动换行"
|
||||
elif ratio < 0.7:
|
||||
sample["likely_wraps_actual"] = "no_slack"
|
||||
sample["notes"] = f"估算宽度 < 70% 可用宽度({ratio:.2f}),明显有留白"
|
||||
elif ratio < 0.85:
|
||||
sample["likely_wraps_actual"] = "no"
|
||||
sample["notes"] = f"估算宽度 {ratio:.2f}x 可用宽度,大概率单行"
|
||||
elif ratio <= 1.0:
|
||||
sample["likely_wraps_actual"] = "tight_single"
|
||||
sample["notes"] = f"估算宽度 {ratio:.2f}x 可用宽度,接近填满,需确认是否单行"
|
||||
elif ratio <= 1.2:
|
||||
sample["likely_wraps_actual"] = "borderline"
|
||||
sample["notes"] = f"估算宽度 {ratio:.2f}x 可用宽度,边界情况,需截图确认是否换行"
|
||||
elif ratio <= 1.5:
|
||||
sample["likely_wraps_actual"] = "likely_wrap"
|
||||
sample["notes"] = f"估算宽度 {ratio:.2f}x 可用宽度,大概率换2行"
|
||||
else:
|
||||
sample["likely_wraps_actual"] = "yes"
|
||||
sample["notes"] = f"估算宽度 {ratio:.2f}x 可用宽度,肯定换行(多行)"
|
||||
elif has_auto_fit:
|
||||
sample["likely_wraps_actual"] = "autofit"
|
||||
sample["notes"] = "autoFit开启,字体会自动缩放"
|
||||
else:
|
||||
sample["likely_wraps_actual"] = "unknown"
|
||||
sample["notes"] = "无法判断"
|
||||
|
||||
all_samples.append(sample)
|
||||
|
||||
print(f"\nCollected {len(all_samples)} text shape samples")
|
||||
|
||||
csv_fields = [
|
||||
"slide_number", "slide_id", "element_id", "x", "y",
|
||||
"shape_width", "shape_height", "padding_left", "padding_right", "available_width",
|
||||
"font_size", "font_family", "bold", "italic", "wrap", "auto_fit", "text_align",
|
||||
"text_clean", "text_length", "text_type", "is_single_line_hard",
|
||||
"estimated_width", "width_ratio", "likely_wraps_actual", "notes",
|
||||
]
|
||||
|
||||
with open(CSV_OUTPUT, "w", encoding="utf-8", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=csv_fields)
|
||||
writer.writeheader()
|
||||
writer.writerows(all_samples)
|
||||
print(f"CSV saved to: {CSV_OUTPUT}")
|
||||
|
||||
key_categories = {"tight_single", "borderline", "likely_wrap", "yes"}
|
||||
tight_samples = [s for s in all_samples if s["likely_wraps_actual"] == "tight_single"]
|
||||
borderline_samples = [s for s in all_samples if s["likely_wraps_actual"] == "borderline"]
|
||||
likely_wrap_samples = [s for s in all_samples if s["likely_wraps_actual"] == "likely_wrap"]
|
||||
yes_wrap_samples = [s for s in all_samples if s["likely_wraps_actual"] == "yes"]
|
||||
|
||||
type_stats = defaultdict(list)
|
||||
for s in all_samples:
|
||||
if s["width_ratio"] != "":
|
||||
type_stats[s["text_type"]].append(s)
|
||||
|
||||
md_lines = [
|
||||
"# 字符宽度测量数据报告",
|
||||
"",
|
||||
f"**Presentation**: {PRESENTATION_ID}",
|
||||
f"**总样本数**: {len(all_samples)}",
|
||||
f"**关键测量样本(比值≥0.85)**: {len(tight_samples) + len(borderline_samples) + len(likely_wrap_samples) + len(yes_wrap_samples)}",
|
||||
f" - 接近填满(0.85-1.0): {len(tight_samples)}",
|
||||
f" - 边界情况(1.0-1.2): {len(borderline_samples)}",
|
||||
f" - 大概率换行(1.2-1.5): {len(likely_wrap_samples)}",
|
||||
f" - 肯定换行(>1.5): {len(yes_wrap_samples)}",
|
||||
"",
|
||||
"## 按文本类型统计(所有样本)",
|
||||
"",
|
||||
"| 文本类型 | 样本数 | 平均估算/可用比 | 最小比值 | 最大比值 |",
|
||||
"|----------|--------|------------------|----------|----------|",
|
||||
]
|
||||
|
||||
for text_type in sorted(type_stats.keys()):
|
||||
samples = type_stats[text_type]
|
||||
ratios = [float(s["width_ratio"]) for s in samples if s["width_ratio"] != ""]
|
||||
if not ratios:
|
||||
continue
|
||||
avg_ratio = sum(ratios) / len(ratios)
|
||||
min_ratio = min(ratios)
|
||||
max_ratio = max(ratios)
|
||||
md_lines.append(
|
||||
f"| {text_type} | {len(samples)} | {avg_ratio:.4f} | {min_ratio:.4f} | {max_ratio:.4f} |"
|
||||
)
|
||||
|
||||
def add_sample_table(title: str, samples: list[dict], max_rows: int = 50):
|
||||
md_lines.extend([
|
||||
"",
|
||||
f"## {title}",
|
||||
"",
|
||||
"| 页码 | 元素ID | 字体 | 字号 | Bold | 文本类型 | 硬换行 | 文本 | 估算宽度 | 可用宽度 | 比值 |",
|
||||
"|------|--------|------|------|------|----------|--------|------|----------|----------|------|",
|
||||
])
|
||||
for idx, s in enumerate(samples[:max_rows]):
|
||||
text_preview = s["text_clean"][:50] + ("..." if len(s["text_clean"]) > 50 else "")
|
||||
md_lines.append(
|
||||
f"| {s['slide_number']} | {s['element_id']} | {s['font_family']} | {s['font_size']} | "
|
||||
f"{s['bold']} | {s['text_type']} | {s['is_single_line_hard']} | {text_preview} | {s['estimated_width']} | "
|
||||
f"{s['available_width']} | {s['width_ratio']} |"
|
||||
)
|
||||
if len(samples) > max_rows:
|
||||
md_lines.append(f"| ... | ... | ... | ... | ... | ... | ... | (共 {len(samples)} 个样本) | ... | ... | ... |")
|
||||
|
||||
add_sample_table("接近填满样本(估算比值 0.85-1.0,最适合校准单行宽度)", tight_samples)
|
||||
add_sample_table("边界换行样本(估算比值 1.0-1.2,需截图确认是否换行)", borderline_samples)
|
||||
add_sample_table("大概率换行样本(估算比值 1.2-1.5)", likely_wrap_samples)
|
||||
add_sample_table("肯定换行样本(估算比值 > 1.5)", yes_wrap_samples, max_rows=20)
|
||||
|
||||
md_lines.extend([
|
||||
"",
|
||||
"## 字体统计",
|
||||
"",
|
||||
"| 字体 | 样本数 |",
|
||||
"|------|--------|",
|
||||
])
|
||||
|
||||
font_stats = defaultdict(int)
|
||||
for s in all_samples:
|
||||
font = s["font_family"] or "(default)"
|
||||
font_stats[font] += 1
|
||||
for font, count in sorted(font_stats.items(), key=lambda x: -x[1]):
|
||||
md_lines.append(f"| {font} | {count} |")
|
||||
|
||||
md_lines.extend([
|
||||
"",
|
||||
"## 字号+粗体统计",
|
||||
"",
|
||||
"| 字号 | Bold | 样本数 | 平均比值 |",
|
||||
"|------|------|--------|----------|",
|
||||
])
|
||||
|
||||
size_bold_stats = defaultdict(list)
|
||||
for s in all_samples:
|
||||
if s["width_ratio"] != "":
|
||||
key = (s["font_size"], s["bold"])
|
||||
size_bold_stats[key].append(float(s["width_ratio"]))
|
||||
for (font_size, bold), ratios in sorted(size_bold_stats.items()):
|
||||
avg = sum(ratios) / len(ratios)
|
||||
md_lines.append(f"| {font_size} | {bold} | {len(ratios)} | {avg:.4f} |")
|
||||
|
||||
md_lines.extend([
|
||||
"",
|
||||
"## 说明",
|
||||
"",
|
||||
"- **估算宽度**: 使用当前 `estimate_character_width` 函数计算(中文=1em,西文=0.55em,空格=0.33em)",
|
||||
"- **可用宽度**: shape.width - paddingLeft - paddingRight",
|
||||
"- **width_ratio**: 估算宽度 / 可用宽度(针对最长硬换行段落计算)",
|
||||
"- **硬换行**: 文本中是否包含显式 \\n 分段",
|
||||
f"- 截图保存在: {screenshot_dir}",
|
||||
"",
|
||||
"### 比值解读建议",
|
||||
"- ratio < 0.7: 明显留白,估算宽度可能偏宽,或文本确实很短",
|
||||
"- 0.7-0.85: 大概率单行,有少量留白",
|
||||
"- 0.85-1.0: 接近填满,是校准西文/中文字符宽度系数的最佳样本",
|
||||
"- 1.0-1.2: 边界情况,需要看截图确认:是刚好填满单行还是换行了",
|
||||
"- 1.2-1.5: 大概率换2行",
|
||||
"- >1.5: 肯定换行(多行文本)",
|
||||
"",
|
||||
"### 校准建议",
|
||||
"1. 先看 ratio 0.85-1.0 的样本:如果截图中这些文本**确实单行且接近填满**,说明当前估算大致准确;如果有较多留白,说明估算偏宽,需要减小西文字符系数",
|
||||
"2. 再看 ratio 1.0-1.2 的样本:结合截图判断实际是单行还是换行,反推合理系数",
|
||||
"3. 重点关注纯英文、纯数字、纯中文、中英混合这几类分别统计",
|
||||
"4. Bold 字体通常比常规字体稍宽,Italic 稍窄,需要分别考虑",
|
||||
"",
|
||||
"请人工核对截图确认边界样本的实际换行情况,用于校准字符宽度系数。",
|
||||
])
|
||||
|
||||
with open(MD_OUTPUT, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(md_lines))
|
||||
print(f"Markdown report saved to: {MD_OUTPUT}")
|
||||
|
||||
print("\n=== Summary ===")
|
||||
print(f"Total samples: {len(all_samples)}")
|
||||
print(f" Tight single (0.85-1.0): {len(tight_samples)}")
|
||||
print(f" Borderline (1.0-1.2): {len(borderline_samples)}")
|
||||
print(f" Likely wrap (1.2-1.5): {len(likely_wrap_samples)}")
|
||||
print(f" Definite wrap (>1.5): {len(yes_wrap_samples)}")
|
||||
print()
|
||||
for text_type in sorted(type_stats.keys()):
|
||||
samples = type_stats[text_type]
|
||||
ratios = [float(s["width_ratio"]) for s in samples if s["width_ratio"] != ""]
|
||||
if ratios:
|
||||
avg_ratio = sum(ratios) / len(ratios)
|
||||
print(f" {text_type}: {len(samples)} samples, avg ratio = {avg_ratio:.4f}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
265
skills/lark-slides/scripts/text-measure/cli.ts
Normal file
265
skills/lark-slides/scripts/text-measure/cli.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
#!/usr/bin/env -S npx tsx
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// CLI: measure single-line text width via CanvasKit, reusing ee/slide's core
|
||||
// measure function so widths match the on-line renderer.
|
||||
//
|
||||
// Input is a Slide XML string (or an explicit --text). XML paragraph/style
|
||||
// extraction mirrors xml_text_overlap_lint.py (extract_text_paragraphs /
|
||||
// extract_max_span_font_size / style attribute names).
|
||||
//
|
||||
// Usage:
|
||||
// tsx cli.ts --font "Noto Sans SC=/abs/NotoSansSC.ttf" [--font ...] --file slide.xml
|
||||
// tsx cli.ts --font "Arial=/abs/Arial.ttf" --text "Hello" --font-size 16 [--bold] [--italic] [--letter-spacing 0]
|
||||
// cat slide.xml | tsx cli.ts --font "Arial=/abs/Arial.ttf"
|
||||
//
|
||||
// Output: JSON to stdout. Progress/warnings to stderr.
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { StandaloneMeasureRuntime, type FontSpec } from './runtime.ts';
|
||||
import { measureSingleLineText, type SingleLineStyle } from './measure.ts';
|
||||
|
||||
interface CliArgs {
|
||||
fonts: FontSpec[];
|
||||
file?: string;
|
||||
xml?: string;
|
||||
text?: string;
|
||||
fontFamily: string;
|
||||
fontSize: string;
|
||||
letterSpacing: string;
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
}
|
||||
|
||||
interface Segment {
|
||||
shapeId: string | null;
|
||||
text: string;
|
||||
style: SingleLineStyle;
|
||||
}
|
||||
|
||||
function parseArgs(argv: string[]): CliArgs {
|
||||
const args: CliArgs = {
|
||||
fonts: [],
|
||||
fontFamily: '',
|
||||
fontSize: '16px',
|
||||
letterSpacing: '0px',
|
||||
bold: false,
|
||||
italic: false,
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
const next = () => {
|
||||
const value = argv[++i];
|
||||
if (value === undefined) {
|
||||
throw new Error(`Missing value for ${arg}`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
switch (arg) {
|
||||
case '--font': {
|
||||
// "Family Name=/abs/path.ttf" — split on the FIRST '=' so family names
|
||||
// may not contain '=', which is fine for font-family strings.
|
||||
const spec = next();
|
||||
const eq = spec.indexOf('=');
|
||||
if (eq < 0) {
|
||||
throw new Error(`--font expects "family=path", got: ${spec}`);
|
||||
}
|
||||
args.fonts.push({ family: spec.slice(0, eq).trim(), path: spec.slice(eq + 1).trim() });
|
||||
break;
|
||||
}
|
||||
case '--file':
|
||||
args.file = next();
|
||||
break;
|
||||
case '--xml':
|
||||
args.xml = next();
|
||||
break;
|
||||
case '--text':
|
||||
args.text = next();
|
||||
break;
|
||||
case '--font-family':
|
||||
args.fontFamily = next();
|
||||
break;
|
||||
case '--font-size':
|
||||
args.fontSize = next();
|
||||
break;
|
||||
case '--letter-spacing':
|
||||
args.letterSpacing = next();
|
||||
break;
|
||||
case '--bold':
|
||||
args.bold = true;
|
||||
break;
|
||||
case '--italic':
|
||||
args.italic = true;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
async function readStdin(): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8');
|
||||
}
|
||||
|
||||
// --- XML extraction (mirrors xml_text_overlap_lint.py) ---
|
||||
|
||||
function stripXml(value: string, preserveLineBreaks: boolean): string {
|
||||
let stripped = value.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '');
|
||||
stripped = stripped
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
if (preserveLineBreaks) {
|
||||
return stripped
|
||||
.split('\n')
|
||||
.map(line => line.replace(/\s+/g, ' ').trim())
|
||||
.join('\n');
|
||||
}
|
||||
return stripped.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function extractAttribute(attrs: string, name: string): string | null {
|
||||
const match = attrs.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`, 'i'));
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function extractNumericAttribute(attrs: string, name: string): number | null {
|
||||
const raw = extractAttribute(attrs, name);
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
const value = parseFloat(raw);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function extractBoolAttribute(attrs: string, name: string): boolean {
|
||||
return extractAttribute(attrs, name) === 'true';
|
||||
}
|
||||
|
||||
// Largest span fontSize wins, falling back to the content default (mirrors
|
||||
// extract_max_span_font_size).
|
||||
function extractMaxSpanFontSize(body: string, defaultFontSize: number): number {
|
||||
const sizes = [defaultFontSize];
|
||||
for (const [, spanAttrs] of body.matchAll(/<span\b([^>]*)>/gi)) {
|
||||
const size = extractNumericAttribute(spanAttrs, 'fontSize');
|
||||
if (size !== null) {
|
||||
sizes.push(size);
|
||||
}
|
||||
}
|
||||
return Math.max(...sizes);
|
||||
}
|
||||
|
||||
function detectAnySpanBool(body: string, name: string): boolean {
|
||||
for (const [, spanAttrs] of body.matchAll(/<span\b([^>]*)>/gi)) {
|
||||
if (extractBoolAttribute(spanAttrs, name)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract one segment per <p> inside each <shape ...><content ...>. Each hard
|
||||
// paragraph is measured independently (single-line scope).
|
||||
function extractSegments(xml: string): Segment[] {
|
||||
const segments: Segment[] = [];
|
||||
for (const [, shapeAttrs, shapeBody] of xml.matchAll(/<shape\b([^>]*)>([\s\S]*?)<\/shape\s*>/gi)) {
|
||||
const shapeId = extractAttribute(shapeAttrs, 'id');
|
||||
const contentMatch = shapeBody.match(/<content\b([^>]*)>([\s\S]*?)<\/content\s*>/i);
|
||||
if (!contentMatch) {
|
||||
continue;
|
||||
}
|
||||
const [, contentAttrs, contentBody] = contentMatch;
|
||||
const contentFontSize = extractNumericAttribute(contentAttrs, 'fontSize') ?? 16;
|
||||
const contentFontFamily = extractAttribute(contentAttrs, 'fontFamily') ?? '';
|
||||
const contentLetterSpacing = extractNumericAttribute(contentAttrs, 'letterSpacing');
|
||||
const contentBold = extractBoolAttribute(contentAttrs, 'bold');
|
||||
const contentItalic = extractBoolAttribute(contentAttrs, 'italic');
|
||||
|
||||
const paragraphs = [...contentBody.matchAll(/<p\b([^>]*)>([\s\S]*?)<\/p\s*>/gi)];
|
||||
const iter = paragraphs.length > 0 ? paragraphs : [['', '', contentBody] as unknown as RegExpMatchArray];
|
||||
|
||||
for (const [, , body] of iter) {
|
||||
const text = stripXml(body, true);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
const fontSize = extractMaxSpanFontSize(body, contentFontSize);
|
||||
const bold = contentBold || detectAnySpanBool(body, 'bold');
|
||||
const italic = contentItalic || detectAnySpanBool(body, 'italic');
|
||||
const letterSpacing = contentLetterSpacing ?? 0;
|
||||
segments.push({
|
||||
shapeId,
|
||||
text,
|
||||
style: {
|
||||
fontSize: `${fontSize}px`,
|
||||
fontFamily: contentFontFamily,
|
||||
letterSpacing: `${letterSpacing}px`,
|
||||
bold,
|
||||
italic,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
const runtime = new StandaloneMeasureRuntime();
|
||||
await runtime.ensureReady(args.fonts);
|
||||
|
||||
// Each segment may itself contain hard line breaks; measure each visual line
|
||||
// and report the max width (single-line scope: no wrapping).
|
||||
const measureSegment = (text: string, style: SingleLineStyle) => {
|
||||
const lines = text.split('\n');
|
||||
const widths = lines.map(line => measureSingleLineText(line, style, runtime));
|
||||
return { widthPx: Math.max(...widths, 0), lineWidths: widths };
|
||||
};
|
||||
|
||||
let output: unknown;
|
||||
|
||||
if (args.text !== undefined) {
|
||||
const style: SingleLineStyle = {
|
||||
fontSize: args.fontSize,
|
||||
fontFamily: args.fontFamily,
|
||||
letterSpacing: args.letterSpacing,
|
||||
bold: args.bold,
|
||||
italic: args.italic,
|
||||
};
|
||||
const { widthPx, lineWidths } = measureSegment(args.text, style);
|
||||
output = { mode: 'text', text: args.text, style, widthPx, lineWidths };
|
||||
} else {
|
||||
const xml = args.xml ?? (args.file ? await readFile(args.file, 'utf8') : await readStdin());
|
||||
if (!xml.trim()) {
|
||||
throw new Error('No XML input provided (use --file, --xml, --text, or pipe via stdin).');
|
||||
}
|
||||
const segments = extractSegments(xml);
|
||||
output = {
|
||||
mode: 'xml',
|
||||
segmentCount: segments.length,
|
||||
segments: segments.map(seg => {
|
||||
const { widthPx, lineWidths } = measureSegment(seg.text, seg.style);
|
||||
return { shapeId: seg.shapeId, text: seg.text, style: seg.style, widthPx, lineWidths };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
runtime.dispose();
|
||||
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
95
skills/lark-slides/scripts/text-measure/measure.ts
Normal file
95
skills/lark-slides/scripts/text-measure/measure.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// Core single-line text measurement, transcribed verbatim from ee/slide
|
||||
// modules/text-measure-module/src/util/canvaskit/text-layout.ts
|
||||
// (function measureSingleLineText, lines 1717-1776) so that the width produced
|
||||
// here is identical to what the Slides renderer computes on-line.
|
||||
//
|
||||
// Only the single-line path is reproduced (per the agreed scope). The multi-run
|
||||
// measureTextWithCanvasKit path — which needs an editor-kit ZoneDelta — is not
|
||||
// included here.
|
||||
|
||||
import type { CanvasKit, FontCollection, TypefaceFontProvider } from 'canvaskit-wasm';
|
||||
|
||||
/**
|
||||
* Read-only runtime view the measure function depends on. Mirrors
|
||||
* CanvasKitMeasureRuntime in text-measure-module/src/type/canvaskit.type.ts.
|
||||
* Initialization (WASM load, font registration) is the runtime's job, not the
|
||||
* measure function's.
|
||||
*/
|
||||
export interface CanvasKitMeasureRuntime {
|
||||
readonly getCanvasKit: () => CanvasKit | null;
|
||||
readonly getFontProvider: () => TypefaceFontProvider | null;
|
||||
readonly getFontCollection: () => FontCollection | null;
|
||||
readonly getDefaultFontFamily: () => string;
|
||||
readonly isFontRegistered: (fontFamily: string) => boolean;
|
||||
readonly hasWghtAxis: (fontFamily: string) => boolean;
|
||||
}
|
||||
|
||||
export interface SingleLineStyle {
|
||||
fontSize: string;
|
||||
fontFamily: string;
|
||||
letterSpacing: string;
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbatim transcription of measureSingleLineText from text-layout.ts:1717.
|
||||
* Returns the natural (unwrapped) width in px via Skia's getMaxIntrinsicWidth.
|
||||
*/
|
||||
export function measureSingleLineText(
|
||||
text: string,
|
||||
style: SingleLineStyle,
|
||||
runtime: CanvasKitMeasureRuntime,
|
||||
): number {
|
||||
const ck = runtime.getCanvasKit();
|
||||
const fontProvider = runtime.getFontProvider();
|
||||
if (!ck || !fontProvider) {
|
||||
throw new Error('CanvasKit runtime not ready, call ensureReady() before measuring');
|
||||
}
|
||||
|
||||
const fontSize = parseInt(style.fontSize, 10) || 16;
|
||||
const letterSpacing = parseFloat(style.letterSpacing) || 0;
|
||||
const rawFontFamily = style.fontFamily.split(',')[0].trim().replace(/['"]/g, '');
|
||||
const defaultFont = runtime.getDefaultFontFamily();
|
||||
const fontFamilies = runtime.isFontRegistered(rawFontFamily) ? [rawFontFamily, defaultFont] : [defaultFont];
|
||||
|
||||
const textStyle = new ck.TextStyle({
|
||||
fontSize,
|
||||
fontFamilies,
|
||||
letterSpacing,
|
||||
fontStyle: {
|
||||
weight: style.bold ? ck.FontWeight.Bold : ck.FontWeight.Normal,
|
||||
slant: style.italic ? ck.FontSlant.Italic : ck.FontSlant.Upright,
|
||||
},
|
||||
});
|
||||
|
||||
const paraStyle = new ck.ParagraphStyle({
|
||||
textStyle: {
|
||||
fontSize,
|
||||
fontFamilies,
|
||||
},
|
||||
});
|
||||
|
||||
const builder = ck.ParagraphBuilder.MakeFromFontProvider(paraStyle, fontProvider);
|
||||
|
||||
try {
|
||||
builder.pushStyle(textStyle);
|
||||
builder.addText(text);
|
||||
builder.pop();
|
||||
|
||||
const paragraph = builder.build();
|
||||
|
||||
try {
|
||||
// Unbounded width: measure the full natural width of a single line.
|
||||
paragraph.layout(Infinity);
|
||||
return paragraph.getMaxIntrinsicWidth();
|
||||
} finally {
|
||||
paragraph.delete();
|
||||
}
|
||||
} finally {
|
||||
builder.delete();
|
||||
}
|
||||
}
|
||||
21
skills/lark-slides/scripts/text-measure/package.json
Normal file
21
skills/lark-slides/scripts/text-measure/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "lark-slides-text-measure",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Standalone CanvasKit single-line text measurement, reusing ee/slide text-measure-module's core measure function.",
|
||||
"bin": {
|
||||
"measure-text": "./cli.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"measure": "tsx cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"canvaskit-wasm": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "*",
|
||||
"tsx": "*",
|
||||
"typescript": "*"
|
||||
}
|
||||
}
|
||||
133
skills/lark-slides/scripts/text-measure/runtime.ts
Normal file
133
skills/lark-slides/scripts/text-measure/runtime.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// Standalone CanvasKit runtime that satisfies CanvasKitMeasureRuntime without
|
||||
// the @slide/modular DI container. The CanvasKit init and font-provider setup
|
||||
// are distilled from ee/slide text-measure-module:
|
||||
// - createCanvasKit() <- provide/canvaskit/canvaskit.service.implement.ts:64
|
||||
// - TypefaceFontProvider <- provide/canvaskit-font/canvaskit-font.service.base.ts:67
|
||||
// - registerFont() <- canvaskit-font.service.base.ts:108
|
||||
// - Node wasm path resolve <- provide/canvaskit/canvaskit.node.util.ts:6
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import type { CanvasKit, FontCollection, TypefaceFontProvider } from 'canvaskit-wasm';
|
||||
import type { CanvasKitMeasureRuntime } from './measure.ts';
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url);
|
||||
|
||||
function resolveNodeCanvasKitWasmPath(): string {
|
||||
return nodeRequire.resolve('canvaskit-wasm/bin/canvaskit.wasm');
|
||||
}
|
||||
|
||||
export interface FontSpec {
|
||||
/** font-family name the XML references (matched by measureSingleLineText). */
|
||||
family: string;
|
||||
/** absolute path to the font file (.ttf/.otf/.ttc). */
|
||||
path: string;
|
||||
/** true for variable fonts exposing a `wght` axis. */
|
||||
hasWghtAxis?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal runtime: loads CanvasKit WASM, creates one TypefaceFontProvider, and
|
||||
* registers caller-supplied font files. The first registered font becomes the
|
||||
* default family (mirrors registerDefaultFont in the base service).
|
||||
*/
|
||||
export class StandaloneMeasureRuntime implements CanvasKitMeasureRuntime {
|
||||
private canvasKit: CanvasKit | null = null;
|
||||
private fontProvider: TypefaceFontProvider | null = null;
|
||||
private fontCollection: FontCollection | null = null;
|
||||
private readonly registeredFonts = new Set<string>();
|
||||
private readonly wghtAxisFonts = new Set<string>();
|
||||
private defaultFontFamily = 'sans-serif';
|
||||
|
||||
async ensureReady(fonts: readonly FontSpec[]): Promise<void> {
|
||||
if (!this.canvasKit) {
|
||||
const canvasKitModule = await import('canvaskit-wasm');
|
||||
const wasmUrl = resolveNodeCanvasKitWasmPath();
|
||||
this.canvasKit = await canvasKitModule.default({
|
||||
locateFile: (file: string) => (file.endsWith('.wasm') ? wasmUrl : file),
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.fontProvider) {
|
||||
this.fontProvider = this.canvasKit.TypefaceFontProvider.Make();
|
||||
}
|
||||
|
||||
if (!this.fontCollection) {
|
||||
this.fontCollection = this.canvasKit.FontCollection.Make();
|
||||
this.fontCollection.setDefaultFontManager(this.fontProvider);
|
||||
this.fontCollection.enableFontFallback();
|
||||
}
|
||||
|
||||
for (const [index, font] of fonts.entries()) {
|
||||
await this.registerFont(font, index === 0);
|
||||
}
|
||||
|
||||
if (!this.hasAnyRegisteredFont()) {
|
||||
// CanvasKit embeds no fonts; with none registered Skia finds no glyphs and
|
||||
// measures 0 width while resolving successfully — worse than failing.
|
||||
throw new Error('No font registered; measurement would be unreliable. Pass at least one --font family=path.');
|
||||
}
|
||||
}
|
||||
|
||||
private async registerFont(font: FontSpec, isDefault: boolean): Promise<void> {
|
||||
if (!this.fontProvider) {
|
||||
throw new Error('Font provider not initialized');
|
||||
}
|
||||
const data = await readFile(font.path);
|
||||
const buffer = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
||||
const result = this.fontProvider.registerFont(buffer, font.family) as null | undefined;
|
||||
if (result === null) {
|
||||
console.warn(`[CanvasKit] registerFont failed for ${font.family} (${font.path})`);
|
||||
return;
|
||||
}
|
||||
this.registeredFonts.add(font.family);
|
||||
if (font.hasWghtAxis) {
|
||||
this.wghtAxisFonts.add(font.family);
|
||||
}
|
||||
if (isDefault) {
|
||||
this.defaultFontFamily = font.family;
|
||||
}
|
||||
}
|
||||
|
||||
hasAnyRegisteredFont(): boolean {
|
||||
return this.registeredFonts.size > 0;
|
||||
}
|
||||
|
||||
getCanvasKit(): CanvasKit | null {
|
||||
return this.canvasKit;
|
||||
}
|
||||
|
||||
getFontProvider(): TypefaceFontProvider | null {
|
||||
return this.fontProvider;
|
||||
}
|
||||
|
||||
getFontCollection(): FontCollection | null {
|
||||
return this.fontCollection;
|
||||
}
|
||||
|
||||
getDefaultFontFamily(): string {
|
||||
return this.defaultFontFamily;
|
||||
}
|
||||
|
||||
isFontRegistered(fontFamily: string): boolean {
|
||||
return this.registeredFonts.has(fontFamily);
|
||||
}
|
||||
|
||||
hasWghtAxis(fontFamily: string): boolean {
|
||||
return this.wghtAxisFonts.has(fontFamily);
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.fontCollection?.delete();
|
||||
this.fontProvider?.delete();
|
||||
this.fontCollection = null;
|
||||
this.fontProvider = null;
|
||||
this.canvasKit = null;
|
||||
this.registeredFonts.clear();
|
||||
this.wghtAxisFonts.clear();
|
||||
}
|
||||
}
|
||||
15
skills/lark-slides/scripts/text-measure/tsconfig.json
Normal file
15
skills/lark-slides/scripts/text-measure/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2020"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["*.ts"]
|
||||
}
|
||||
354
skills/lark-slides/scripts/verify_width_calibration.py
Normal file
354
skills/lark-slides/scripts/verify_width_calibration.py
Normal file
@@ -0,0 +1,354 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify character width calibration by comparing new estimates against visual evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import sys
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
import xml_text_overlap_lint as lint
|
||||
|
||||
|
||||
def old_estimate_character_width(character: str, font_size: float) -> float:
|
||||
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
|
||||
|
||||
|
||||
def old_estimate_text_width(text: str, font_size: float) -> float:
|
||||
return sum(old_estimate_character_width(c, font_size) for c in text)
|
||||
|
||||
|
||||
def make_element(
|
||||
width: float,
|
||||
height: float,
|
||||
text: str,
|
||||
font_size: float,
|
||||
*,
|
||||
padding_left: float = 0,
|
||||
padding_right: float = 0,
|
||||
padding_top: float = 0,
|
||||
padding_bottom: float = 0,
|
||||
bold: bool = False,
|
||||
wrap: str = "true",
|
||||
auto_fit: str | None = None,
|
||||
font_family: str = "",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": "test",
|
||||
"kind": "shape",
|
||||
"type": "text",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"paddingLeft": padding_left,
|
||||
"paddingRight": padding_right,
|
||||
"paddingTop": padding_top,
|
||||
"paddingBottom": padding_bottom,
|
||||
"fontSize": font_size,
|
||||
"fontFamily": font_family,
|
||||
"bold": bold,
|
||||
"italic": False,
|
||||
"text": text,
|
||||
"wrap": wrap,
|
||||
"autoFit": auto_fit,
|
||||
"letterSpacing": 0,
|
||||
"paragraphs": [
|
||||
{
|
||||
"text": p,
|
||||
"fontSize": font_size,
|
||||
"lineSpacing": None,
|
||||
"beforeLineSpacing": None,
|
||||
"afterLineSpacing": None,
|
||||
"letterSpacing": None,
|
||||
}
|
||||
for p in text.split("\n")
|
||||
if p
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def count_chars(text: str) -> dict[str, int]:
|
||||
counts = {"cjk": 0, "upper": 0, "lower": 0, "digit": 0, "punct": 0, "space": 0}
|
||||
for c in text:
|
||||
if c.isspace():
|
||||
counts["space"] += 1
|
||||
elif unicodedata.east_asian_width(c) in {"F", "W"}:
|
||||
counts["cjk"] += 1
|
||||
elif c.isupper():
|
||||
counts["upper"] += 1
|
||||
elif c.islower():
|
||||
counts["lower"] += 1
|
||||
elif c.isdigit():
|
||||
counts["digit"] += 1
|
||||
else:
|
||||
counts["punct"] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def test_case(
|
||||
name: str,
|
||||
text: str,
|
||||
available_width: float,
|
||||
font_size: float,
|
||||
*,
|
||||
expected_single_line: bool | None = None,
|
||||
note: str = "",
|
||||
) -> dict[str, Any]:
|
||||
elem = make_element(available_width, 1000, text, font_size)
|
||||
new_width = lint.estimate_text_max_line_width(elem)
|
||||
new_ratio = new_width / available_width
|
||||
new_lines = lint.estimate_text_line_count_for_text(elem, text)
|
||||
|
||||
old_width = max(old_estimate_text_width(p, font_size) for p in text.split("\n") if p)
|
||||
old_ratio = old_width / available_width
|
||||
|
||||
counts = count_chars(text)
|
||||
|
||||
result = {
|
||||
"name": name,
|
||||
"text_preview": text[:60] + ("..." if len(text) > 60 else ""),
|
||||
"font_size": font_size,
|
||||
"available_width": available_width,
|
||||
"char_counts": counts,
|
||||
"new_estimated_width": round(new_width, 2),
|
||||
"new_ratio": round(new_ratio, 4),
|
||||
"new_line_count": new_lines,
|
||||
"old_estimated_width": round(old_width, 2),
|
||||
"old_ratio": round(old_ratio, 4),
|
||||
"expected_single_line": expected_single_line,
|
||||
"note": note,
|
||||
}
|
||||
|
||||
if expected_single_line is True:
|
||||
result["new_correct"] = new_ratio <= 1.02
|
||||
result["old_correct"] = old_ratio <= 1.02
|
||||
elif expected_single_line is False:
|
||||
result["new_correct"] = new_ratio > 1.0
|
||||
result["old_correct"] = old_ratio > 1.0
|
||||
else:
|
||||
result["new_correct"] = None
|
||||
result["old_correct"] = None
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def print_result(r: dict[str, Any]) -> None:
|
||||
status_new = ""
|
||||
status_old = ""
|
||||
if r["expected_single_line"] is not None:
|
||||
status_new = "✓" if r["new_correct"] else "✗"
|
||||
status_old = "✓" if r["old_correct"] else "✗"
|
||||
|
||||
print(f"--- {r['name']} {status_new} ---")
|
||||
print(f" Text: {r['text_preview']}")
|
||||
print(f" Font: {r['font_size']}pt, Available: {r['available_width']}px")
|
||||
cc = r["char_counts"]
|
||||
print(f" Chars: CJK={cc['cjk']} Upper={cc['upper']} Lower={cc['lower']} "
|
||||
f"Digit={cc['digit']} Punct={cc['punct']} Space={cc['space']}")
|
||||
print(f" NEW: width={r['new_estimated_width']}px ratio={r['new_ratio']:.4f} lines={r['new_line_count']} {status_new}")
|
||||
print(f" OLD: width={r['old_estimated_width']}px ratio={r['old_ratio']:.4f} {status_old}")
|
||||
if r["note"]:
|
||||
print(f" Note: {r['note']}")
|
||||
print()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("=" * 70)
|
||||
print("Character Width Calibration Verification")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
results = []
|
||||
|
||||
# Test cases based on visual evidence from screenshots
|
||||
# Page 17 line 2: "在 Natural Language Processing 领域,大语言模型 LLM 的出现彻底改变了人机交互方式。"
|
||||
# Visually: single line, fills ~93-95% of 800px
|
||||
results.append(test_case(
|
||||
"p17 bbR line2 (mixed CJK+English, 18pt, 800px, expected single)",
|
||||
"在 Natural Language Processing 领域,大语言模型 LLM 的出现彻底改变了人机交互方式。",
|
||||
available_width=800,
|
||||
font_size=18,
|
||||
expected_single_line=True,
|
||||
note="Visually single line, ~93% fill"
|
||||
))
|
||||
|
||||
# Page 17 line 3: "从 ChatGPT 到 文心一言,从 GPT-4 到 Claude 3,AI 助手正在成为人们工作生活的标配。"
|
||||
# Visually: single line
|
||||
results.append(test_case(
|
||||
"p17 bbR line3 (mixed CJK+English, 18pt, 800px, expected single)",
|
||||
"从 ChatGPT 到 文心一言,从 GPT-4 到 Claude 3,AI 助手正在成为人们工作生活的标配。",
|
||||
available_width=800,
|
||||
font_size=18,
|
||||
expected_single_line=True,
|
||||
note="Visually single line"
|
||||
))
|
||||
|
||||
# Page 6 paragraph 3: "中文与英文混排测试:The quick brown fox jumps over the lazy dog. 敏捷的棕色狐狸跳过了懒狗。"
|
||||
# Visually: single line, very tight (was borderline with old model)
|
||||
results.append(test_case(
|
||||
"p6 bNf para3 (mixed tight line, 18pt, 800px, expected single)",
|
||||
"中文与英文混排测试:The quick brown fox jumps over the lazy dog. 敏捷的棕色狐狸跳过了懒狗。",
|
||||
available_width=800,
|
||||
font_size=18,
|
||||
expected_single_line=True,
|
||||
note="Borderline case - visually fits on one line"
|
||||
))
|
||||
|
||||
# Page 6 paragraph 2 (English): "Typography is the art and technique of arranging type to make written language legible, readable, and appealing when displayed."
|
||||
# Visually: wraps to 2 lines
|
||||
results.append(test_case(
|
||||
"p6 bNf para2 (English paragraph, 18pt, 800px, wraps)",
|
||||
"Typography is the art and technique of arranging type to make written language legible, readable, and appealing when displayed.",
|
||||
available_width=800,
|
||||
font_size=18,
|
||||
expected_single_line=False,
|
||||
note="Visually wraps to 2 lines"
|
||||
))
|
||||
|
||||
# Page 28 right column line 1: "This is the right column body text in 15pt size."
|
||||
# Visually: single line, ~92% fill of 400px
|
||||
results.append(test_case(
|
||||
"p28 bZQ line1 (English, 15pt, 400px, expected single)",
|
||||
"This is the right column body text in 15pt size.",
|
||||
available_width=400,
|
||||
font_size=15,
|
||||
expected_single_line=True,
|
||||
note="Visually single line, ~92% fill"
|
||||
))
|
||||
|
||||
# Page 28 right column paragraph: "Multi-column layout optimizes space and information density."
|
||||
# Visually: wraps to 2 lines ("density." on its own line)
|
||||
results.append(test_case(
|
||||
"p28 bZQ para (English, 15pt, 400px, wraps)",
|
||||
"Multi-column layout optimizes space and information density.",
|
||||
available_width=400,
|
||||
font_size=15,
|
||||
expected_single_line=False,
|
||||
note="Visually wraps - 'density.' on next line"
|
||||
))
|
||||
|
||||
# Page 28 left column line 1: "这是左栏的正文内容,使用 15pt 字号,1.7 倍行间距。"
|
||||
# Visually: single line in 420px column
|
||||
results.append(test_case(
|
||||
"p28 bZm line1 (CJK+digits, 15pt, 420px, expected single)",
|
||||
"这是左栏的正文内容,使用 15pt 字号,1.7 倍行间距。",
|
||||
available_width=420,
|
||||
font_size=15,
|
||||
expected_single_line=True,
|
||||
note="Visually single line"
|
||||
))
|
||||
|
||||
# Page 23: 2.0 line spacing body (14pt, 270px) - wraps to multiple lines
|
||||
# "这是一段测试文字,用于展示2.0倍行间距的效果。行间距较大时,文字显得疏朗透气,阅读体验轻松。适合需要留白感的设计。"
|
||||
results.append(test_case(
|
||||
"p23 bZc body (CJK+digits, 14pt, 270px, wraps)",
|
||||
"这是一段测试文字,用于展示2.0倍行间距的效果。行间距较大时,文字显得疏朗透气,阅读体验轻松。适合需要留白感的设计。",
|
||||
available_width=270,
|
||||
font_size=14,
|
||||
expected_single_line=False,
|
||||
note="Visually wraps to ~4 lines"
|
||||
))
|
||||
|
||||
# Page 21 English line: "Underline is used for links and key annotations, strikethrough for deleted content."
|
||||
# Visually: single line at 18pt in 800px, fills ~92%
|
||||
results.append(test_case(
|
||||
"p21 bbT English line (18pt, 800px, expected single)",
|
||||
"Underline is used for links and key annotations, strikethrough for deleted content.",
|
||||
available_width=800,
|
||||
font_size=18,
|
||||
expected_single_line=True,
|
||||
note="Visually single line, ~92% fill"
|
||||
))
|
||||
|
||||
# Page 12 Helvetica paragraph: "Sans-serif fonts are clean, modern, and highly legible. Widely used in UI design, branding, and digital media."
|
||||
# 16pt, 800px, wraps to 2 lines
|
||||
results.append(test_case(
|
||||
"p12 bak paragraph (English, 16pt, 800px, wraps)",
|
||||
"Sans-serif fonts are clean, modern, and highly legible. Widely used in UI design, branding, and digital media.",
|
||||
available_width=800,
|
||||
font_size=16,
|
||||
expected_single_line=False,
|
||||
note="Visually wraps to 2 lines"
|
||||
))
|
||||
|
||||
# Pure uppercase test: "ABCDEFGHIJKLMNOPQRSTUVWXYZ" at 28pt bold, should be ~50% of 800px
|
||||
results.append(test_case(
|
||||
"p12 uppercase alphabet (28pt bold, 800px, single)",
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
||||
available_width=800,
|
||||
font_size=28,
|
||||
expected_single_line=True,
|
||||
note="Centered test string, ~50% width"
|
||||
))
|
||||
|
||||
# Pure lowercase test: "abcdefghijklmnopqrstuvwxyz" at 28pt
|
||||
results.append(test_case(
|
||||
"p12 lowercase alphabet (28pt bold, 800px, single)",
|
||||
"abcdefghijklmnopqrstuvwxyz",
|
||||
available_width=800,
|
||||
font_size=28,
|
||||
expected_single_line=True,
|
||||
note="Centered test string, similar width to uppercase"
|
||||
))
|
||||
|
||||
# Digits+symbols: "0123456789!@#$%^&*()" at 28pt, shorter than alphabets
|
||||
results.append(test_case(
|
||||
"p12 digits+symbols (28pt bold, 800px, single)",
|
||||
"0123456789!@#$%^&*()",
|
||||
available_width=800,
|
||||
font_size=28,
|
||||
expected_single_line=True,
|
||||
note="Shorter than alphabet lines"
|
||||
))
|
||||
|
||||
for r in results:
|
||||
print_result(r)
|
||||
|
||||
# Summary statistics
|
||||
print("=" * 70)
|
||||
print("SUMMARY")
|
||||
print("=" * 70)
|
||||
new_correct = sum(1 for r in results if r["new_correct"] is True)
|
||||
old_correct = sum(1 for r in results if r["old_correct"] is True)
|
||||
total_evaluated = sum(1 for r in results if r["expected_single_line"] is not None)
|
||||
print(f"Test cases with known expectation: {total_evaluated}")
|
||||
print(f"New model correct: {new_correct}/{total_evaluated}")
|
||||
print(f"Old model correct: {old_correct}/{total_evaluated}")
|
||||
print()
|
||||
|
||||
# Show ratio distribution for single-line cases
|
||||
print("Single-line cases (ratio should be 0.85-1.02):")
|
||||
for r in results:
|
||||
if r["expected_single_line"] is True:
|
||||
print(f" {r['name'][:50]:50s} new_ratio={r['new_ratio']:.4f} old_ratio={r['old_ratio']:.4f}")
|
||||
print()
|
||||
|
||||
print("Wrapping cases (ratio should be >1.0):")
|
||||
for r in results:
|
||||
if r["expected_single_line"] is False:
|
||||
print(f" {r['name'][:50]:50s} new_ratio={r['new_ratio']:.4f} old_ratio={r['old_ratio']:.4f}")
|
||||
print()
|
||||
|
||||
# Check for tight_single samples (0.85-1.0) ratio target
|
||||
tight_single = [r for r in results if r["expected_single_line"] is True and r["new_ratio"] >= 0.80]
|
||||
if tight_single:
|
||||
avg_new_ratio = sum(r["new_ratio"] for r in tight_single) / len(tight_single)
|
||||
avg_old_ratio = sum(r["old_ratio"] for r in tight_single) / len(tight_single)
|
||||
print(f"Average ratio for near-full single lines:")
|
||||
print(f" New model: {avg_new_ratio:.4f}")
|
||||
print(f" Old model: {avg_old_ratio:.4f}")
|
||||
print(f" Target: ~0.90-0.98 (some margin but mostly filled)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
159
skills/lark-slides/scripts/width_measurement_data.csv
Normal file
159
skills/lark-slides/scripts/width_measurement_data.csv
Normal file
@@ -0,0 +1,159 @@
|
||||
slide_number,slide_id,element_id,x,y,shape_width,shape_height,padding_left,padding_right,available_width,font_size,font_family,bold,italic,wrap,auto_fit,text_align,text_clean,text_length,text_type,is_single_line_hard,estimated_width,width_ratio,likely_wraps_actual,notes
|
||||
1,pmm,bNA,80,160,800,120,0,0,800,64,思源黑体,true,false,true,none,center,字体排版测试样张,8,中文,true,512,0.64,no_slack,估算宽度 < 70% 可用宽度(0.64),明显有留白
|
||||
1,pmm,bNj,80,300,800,60,0,0,800,24,思源黑体,true,false,true,none,center,TypographyTestSample·30Pages,28,英文+数字,true,369.6,0.462,no_slack,估算宽度 < 70% 可用宽度(0.46),明显有留白
|
||||
1,pmm,bNV,80,420,800,40,0,0,800,16,思源黑体,false,false,true,none,center,包含字号/字体/中英文/数字/样式/间距/颜色等全面测试,28,中文,true,404.8,0.506,no_slack,估算宽度 < 70% 可用宽度(0.51),明显有留白
|
||||
2,pmZ,bNt,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,02·超大字号测试72pt,13,中文+英文+数字,true,275.8,0.3283,no_slack,估算宽度 < 70% 可用宽度(0.33),明显有留白
|
||||
2,pmZ,bNh,60,140,840,110,0,0,840,72,思源黑体,true,false,true,none,center,汉字测试ABC123,10,中文+英文+数字,true,525.6,0.6257,no_slack,估算宽度 < 70% 可用宽度(0.63),明显有留白
|
||||
2,pmZ,bNn,60,270,840,100,0,0,840,72,思源黑体,true,false,true,none,center,排版设计Typography,14,中文+英文,true,684.0,0.8143,no,估算宽度 0.81x 可用宽度,大概率单行
|
||||
2,pmZ,bNk,60,400,840,60,0,0,840,14,思源黑体,false,false,true,none,center,72pt超大字号·用于标题展示·测试字重与字间距,24,中文+英文+数字,true,298.2,0.355,no_slack,估算宽度 < 70% 可用宽度(0.35),明显有留白
|
||||
3,pmY,bNw,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,03·大字号测试48pt,12,中文+英文+数字,true,247.8,0.295,no_slack,估算宽度 < 70% 可用宽度(0.30),明显有留白
|
||||
3,pmY,bNv,60,130,840,80,0,0,840,48,思源黑体,true,false,true,none,center,中华人民共和国2024,11,中文+数字,true,441.6,0.5257,no_slack,估算宽度 < 70% 可用宽度(0.53),明显有留白
|
||||
3,pmY,bNe,60,230,840,80,0,0,840,48,思源黑体,true,false,true,none,center,TheQuickBrownFox,16,英文,true,422.4,0.5029,no_slack,估算宽度 < 70% 可用宽度(0.50),明显有留白
|
||||
3,pmY,bNz,60,330,840,80,0,0,840,48,思源黑体,true,false,true,none,center,0123456789数字测试,14,中文+数字,true,456.0,0.5429,no_slack,估算宽度 < 70% 可用宽度(0.54),明显有留白
|
||||
3,pmY,bNp,60,440,840,50,0,0,840,14,思源黑体,false,false,true,none,center,48pt大字号·常用于主标题·中英文数字混排测试,24,中文+英文+数字,true,298.2,0.355,no_slack,估算宽度 < 70% 可用宽度(0.35),明显有留白
|
||||
4,pma,bNc,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,04·中大号字号测试36pt,14,中文+英文+数字,true,303.8,0.3617,no_slack,估算宽度 < 70% 可用宽度(0.36),明显有留白
|
||||
4,pma,bNZ,80,130,800,60,0,0,800,36,思源黑体,true,false,true,none,,科技创新驱动未来发展Innovation,20,中文+英文,true,558.0,0.6975,no_slack,估算宽度 < 70% 可用宽度(0.70),明显有留白
|
||||
4,pma,bNd,80,210,800,60,0,0,800,36,思源黑体,true,false,true,none,,人工智能改变生活方式AI2024,16,中文+英文+数字,true,478.8,0.5985,no_slack,估算宽度 < 70% 可用宽度(0.60),明显有留白
|
||||
4,pma,bNB,80,290,800,60,0,0,800,36,思源黑体,true,false,true,none,,数据可视化DataVisualization,22,中文+英文,true,516.6,0.6458,no_slack,估算宽度 < 70% 可用宽度(0.65),明显有留白
|
||||
4,pma,bNN,80,370,800,60,0,0,800,36,思源黑体,true,false,true,none,,云计算CloudComputing99%,20,中文+英文+数字,true,444.6,0.5558,no_slack,估算宽度 < 70% 可用宽度(0.56),明显有留白
|
||||
4,pma,bNb,80,450,800,40,0,0,800,14,思源黑体,false,false,true,none,center,36pt中大号·副标题级·多行对比测试,19,中文+英文+数字,true,228.2,0.2853,no_slack,估算宽度 < 70% 可用宽度(0.29),明显有留白
|
||||
5,pmW,bNs,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,05·中号字号测试28pt,13,中文+英文+数字,true,275.8,0.3283,no_slack,估算宽度 < 70% 可用宽度(0.33),明显有留白
|
||||
5,pmW,bNT,80,120,800,50,0,0,800,28,思源黑体,true,false,true,none,,一、项目背景与目标Background,19,中文+英文,true,406.0,0.5075,no_slack,估算宽度 < 70% 可用宽度(0.51),明显有留白
|
||||
5,pmW,bNI,80,185,800,50,0,0,800,28,思源黑体,true,false,true,none,,二、市场分析与调研Market2024,19,中文+英文+数字,true,406.0,0.5075,no_slack,估算宽度 < 70% 可用宽度(0.51),明显有留白
|
||||
5,pmW,bNY,80,250,800,50,0,0,800,28,思源黑体,true,false,true,none,,三、技术方案与架构Technology,19,中文+英文,true,406.0,0.5075,no_slack,估算宽度 < 70% 可用宽度(0.51),明显有留白
|
||||
5,pmW,bNq,80,315,800,50,0,0,800,28,思源黑体,true,false,true,none,,四、实施计划与时间表Plan12月,17,中文+英文+数字,true,400.4,0.5005,no_slack,估算宽度 < 70% 可用宽度(0.50),明显有留白
|
||||
5,pmW,bNr,80,380,800,50,0,0,800,28,思源黑体,true,false,true,none,,五、预期效果与收益BenefitROI,19,中文+英文,true,406.0,0.5075,no_slack,估算宽度 < 70% 可用宽度(0.51),明显有留白
|
||||
5,pmW,bNM,80,450,800,40,0,0,800,14,思源黑体,false,false,true,none,center,28pt中号·章节标题级·目录式排列测试,20,中文+英文+数字,true,242.2,0.3027,no_slack,估算宽度 < 70% 可用宽度(0.30),明显有留白
|
||||
6,pmL,bNO,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,06·正文字号测试18pt,13,中文+英文+数字,true,275.8,0.3283,no_slack,估算宽度 < 70% 可用宽度(0.33),明显有留白
|
||||
6,pmL,bNf,80,120,800,320,0,0,800,18,思源黑体,false,false,true,none,,"这是一段正文测试文字,使用18pt字号,是幻灯片中最常用的正文字号。Typographyistheartandtechniqueofarrangingtypetomakewrittenlanguagelegible,readable,andappealingwhendisplayed.中文与英文混排测试:Thequickbrownfoxjumpsoverthelazydog.敏捷的棕色狐狸跳过了懒狗。数字测试:2024年12月25日,增长率12.5%,用户数1,234,567人。",242,中文+英文+数字,false,1079.1,1.3489,likely_wrap,估算宽度 1.35x 可用宽度,大概率换2行
|
||||
6,pmL,bNE,80,460,800,30,0,0,800,14,思源黑体,false,false,true,none,center,18pt正文·行间距1.8倍·中英文数字混排,22,中文+英文+数字,true,251.3,0.3141,no_slack,估算宽度 < 70% 可用宽度(0.31),明显有留白
|
||||
7,pmy,bNK,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,07·小号字号测试14pt,13,中文+英文+数字,true,275.8,0.3283,no_slack,估算宽度 < 70% 可用宽度(0.33),明显有留白
|
||||
7,pmy,bNU,80,120,800,340,0,0,800,14,思源黑体,false,false,true,none,,"小号正文测试14pt这是一段使用14pt字号的正文文字,适合用于内容较多的页面或说明性文字。在信息密度较高的幻灯片中,14pt是一个兼顾可读性与信息量的选择。Thisisaparagraphofbodytextin14ptsize.Itiscommonlyusedfordetaileddescriptions,footnotes,orcontent-heavyslideswhereinformationdensitymatters.中英文数字混排:2024年度报告显示,公司营收达到1,234.56万元,同比增长23.45%,用户满意度98.6%。常用标点符号测试:逗号,句号。感叹号!问号?冒号:分号;引号""""括号()省略号……破折号——",322,中文+英文+数字,false,1070.3,1.3379,likely_wrap,估算宽度 1.34x 可用宽度,大概率换2行
|
||||
7,pmy,bNF,80,475,800,30,0,0,800,12,思源黑体,false,false,true,none,center,14pt小号正文·行间距1.6倍·高密度信息展示,24,中文+英文+数字,true,239.4,0.2992,no_slack,估算宽度 < 70% 可用宽度(0.30),明显有留白
|
||||
8,pmX,bNX,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,08·极小字号测试10pt,13,中文+英文+数字,true,275.8,0.3283,no_slack,估算宽度 < 70% 可用宽度(0.33),明显有留白
|
||||
8,pmX,bNm,80,120,800,360,0,0,800,10,思源黑体,false,false,true,none,,"极小字号测试10pt—用于脚注、注释、数据来源说明等本页测试10pt极小字号的可读性。在正式演示中,10pt通常仅用于数据来源标注、脚注说明、版权信息等非核心内容,不建议用于正文。Datasource:NationalBureauofStatistics,2024AnnualReport.AllfiguresareinRMB10,000unlessotherwisenoted.Growthratesarecalculatedyear-over-year.数据来源:国家统计局2024年度报告。所有金额单位为万元,另有说明除外。增长率按同比计算。样本量n=10,234,置信区间95%。©2024TypographyTestLab.Allrightsreserved.版权所有,翻印必究。",345,中文+英文+数字,false,764.5,0.9556,tight_single,估算宽度 0.96x 可用宽度,接近填满,需确认是否单行
|
||||
8,pmX,bNl,80,490,800,30,0,0,800,10,思源黑体,false,false,true,none,center,10pt极小字号·脚注/注释级·测试极限可读性,23,中文+英文+数字,true,198.5,0.2481,no_slack,估算宽度 < 70% 可用宽度(0.25),明显有留白
|
||||
9,pmE,baa,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,09·字号阶梯对比测试,11,中文+数字,true,270.2,0.3217,no_slack,估算宽度 < 70% 可用宽度(0.32),明显有留白
|
||||
9,pmE,bao,100,115,760,70,0,0,760,60,思源黑体,true,false,true,none,,60pt标题字号Title,13,中文+英文+数字,true,537.0,0.7066,no,估算宽度 0.71x 可用宽度,大概率单行
|
||||
9,pmE,bac,100,185,760,55,0,0,760,44,思源黑体,true,false,true,none,,44pt大标题Headline,15,中文+英文+数字,true,422.4,0.5558,no_slack,估算宽度 < 70% 可用宽度(0.56),明显有留白
|
||||
9,pmE,bNg,100,245,760,45,0,0,760,32,思源黑体,true,false,true,none,,32pt副标题Sub-headline,19,中文+英文+数字,true,377.6,0.4968,no_slack,估算宽度 < 70% 可用宽度(0.50),明显有留白
|
||||
9,pmE,bNL,100,295,760,38,0,0,760,24,思源黑体,false,false,true,none,,24pt小标题Section,14,中文+英文+数字,true,217.2,0.2858,no_slack,估算宽度 < 70% 可用宽度(0.29),明显有留白
|
||||
9,pmE,baN,100,340,760,32,0,0,760,18,思源黑体,false,false,true,none,,18pt正文字号BodyText,16,中文+英文+数字,true,190.8,0.2511,no_slack,估算宽度 < 70% 可用宽度(0.25),明显有留白
|
||||
9,pmE,bab,100,380,760,28,0,0,760,14,思源黑体,false,false,true,none,,14pt小号正文SmallBody,17,中文+英文+数字,true,156.1,0.2054,no_slack,估算宽度 < 70% 可用宽度(0.21),明显有留白
|
||||
9,pmE,bad,100,415,760,24,0,0,760,11,思源黑体,false,false,true,none,,11pt注释字号Caption/Footnote,24,中文+英文+数字,true,165.0,0.2171,no_slack,估算宽度 < 70% 可用宽度(0.22),明显有留白
|
||||
9,pmE,baB,100,455,760,30,0,0,760,13,思源黑体,false,false,true,none,center,从60pt到11pt·七级字号阶梯对比·一目了然,24,中文+英文+数字,true,253.5,0.3336,no_slack,估算宽度 < 70% 可用宽度(0.33),明显有留白
|
||||
10,pms,baw,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,10·中文字体测试·黑体,12,中文+数字,true,285.6,0.34,no_slack,估算宽度 < 70% 可用宽度(0.34),明显有留白
|
||||
10,pms,bav,80,120,800,80,0,0,800,48,思源黑体,true,false,true,none,center,思源黑体SourceHanSans,17,中文+英文,true,535.2,0.669,no_slack,估算宽度 < 70% 可用宽度(0.67),明显有留白
|
||||
10,pms,bae,80,220,800,60,0,0,800,32,思源黑体,true,false,true,none,center,现代简洁清晰易读专业稳重,12,中文,true,384,0.48,no_slack,估算宽度 < 70% 可用宽度(0.48),明显有留白
|
||||
10,pms,baz,80,300,800,150,0,0,800,18,思源黑体,false,false,true,none,center,黑体字笔画均匀、结构方正,具有现代感和力量感。广泛应用于标题、标语、UI界面等场景。Thequickbrownfoxjumpsoverthelazydog.0123456789,88,中文+英文+数字,false,455.4,0.5692,no_slack,估算宽度 < 70% 可用宽度(0.57),明显有留白
|
||||
10,pms,bap,80,470,800,30,0,0,800,12,思源黑体,false,false,true,none,center,思源黑体·无衬线中文字体·现代商务风格首选,21,中文,true,241.2,0.3015,no_slack,估算宽度 < 70% 可用宽度(0.30),明显有留白
|
||||
11,pmV,baA,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,11·中文字体测试·宋体,12,中文+数字,true,285.6,0.34,no_slack,估算宽度 < 70% 可用宽度(0.34),明显有留白
|
||||
11,pmV,baj,80,120,800,80,0,0,800,48,思源宋体,true,false,true,none,center,思源宋体SourceHanSerif,18,中文+英文,true,561.6,0.702,no,估算宽度 0.70x 可用宽度,大概率单行
|
||||
11,pmV,bau,80,220,800,60,0,0,800,32,思源宋体,true,false,true,none,center,典雅端庄文化底蕴传统韵味,12,中文,true,384,0.48,no_slack,估算宽度 < 70% 可用宽度(0.48),明显有留白
|
||||
11,pmV,baW,80,300,800,150,0,0,800,18,思源宋体,false,false,true,none,center,宋体字横细竖粗,笔画末端有装饰性衬线,具有传统文化气息,适合正式、庄重的场合。Thequickbrownfoxjumpsoverthelazydog.0123456789,85,中文+英文+数字,false,455.4,0.5692,no_slack,估算宽度 < 70% 可用宽度(0.57),明显有留白
|
||||
11,pmV,baJ,80,470,800,30,0,0,800,12,思源黑体,false,false,true,none,center,思源宋体·衬线中文字体·学术文化风格首选,20,中文,true,229.2,0.2865,no_slack,估算宽度 < 70% 可用宽度(0.29),明显有留白
|
||||
12,pmD,ban,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,12·英文字体测试·无衬线体,14,中文+数字,true,341.6,0.4067,no_slack,估算宽度 < 70% 可用宽度(0.41),明显有留白
|
||||
12,pmD,bay,80,120,800,80,0,0,800,52,Helvetica,true,false,true,none,center,HelveticaNeue,13,英文,true,371.8,0.4648,no_slack,估算宽度 < 70% 可用宽度(0.46),明显有留白
|
||||
12,pmD,baf,80,210,800,50,0,0,800,28,Helvetica,true,false,true,none,center,ABCDEFGHIJKLMNOPQRSTUVWXYZ,26,英文,true,400.4,0.5005,no_slack,估算宽度 < 70% 可用宽度(0.50),明显有留白
|
||||
12,pmD,baU,80,265,800,50,0,0,800,28,Helvetica,true,false,true,none,center,abcdefghijklmnopqrstuvwxyz,26,英文,true,400.4,0.5005,no_slack,估算宽度 < 70% 可用宽度(0.50),明显有留白
|
||||
12,pmD,bah,80,320,800,50,0,0,800,28,Helvetica,true,false,true,none,center,0123456789!@#$%^&*(),20,数字,true,308.0,0.385,no_slack,估算宽度 < 70% 可用宽度(0.39),明显有留白
|
||||
12,pmD,bak,80,390,800,60,0,0,800,16,Helvetica,false,false,true,none,center,"Sans-seriffontsareclean,modern,andhighlylegible.WidelyusedinUIdesign,branding,anddigitalmedia.",94,英文,false,422.4,0.528,no_slack,估算宽度 < 70% 可用宽度(0.53),明显有留白
|
||||
12,pmD,baO,80,475,800,30,0,0,800,12,思源黑体,false,false,true,none,center,无衬线体Sans-serif·现代简洁·数字界面首选,26,中文+英文,true,247.2,0.309,no_slack,估算宽度 < 70% 可用宽度(0.31),明显有留白
|
||||
13,pmA,bai,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,13·英文字体测试·衬线体,13,中文+数字,true,313.6,0.3733,no_slack,估算宽度 < 70% 可用宽度(0.37),明显有留白
|
||||
13,pmA,bam,80,120,800,80,0,0,800,52,Georgia,true,false,true,none,center,GeorgiaSerif,12,英文,true,343.2,0.429,no_slack,估算宽度 < 70% 可用宽度(0.43),明显有留白
|
||||
13,pmA,bal,80,210,800,50,0,0,800,28,Georgia,true,false,true,none,center,ABCDEFGHIJKLMNOPQRSTUVWXYZ,26,英文,true,400.4,0.5005,no_slack,估算宽度 < 70% 可用宽度(0.50),明显有留白
|
||||
13,pmA,baq,80,265,800,50,0,0,800,28,Georgia,true,false,true,none,center,abcdefghijklmnopqrstuvwxyz,26,英文,true,400.4,0.5005,no_slack,估算宽度 < 70% 可用宽度(0.50),明显有留白
|
||||
13,pmA,bar,80,320,800,50,0,0,800,28,Georgia,true,false,true,none,center,0123456789!@#$%^&*(),20,数字,true,308.0,0.385,no_slack,估算宽度 < 70% 可用宽度(0.39),明显有留白
|
||||
13,pmA,baM,80,390,800,60,0,0,800,16,Georgia,false,false,true,none,center,"Seriffontshavesmalllinesattheendsofcharacters,conveyingtradition,elegance,andauthority.",87,英文,false,404.8,0.506,no_slack,估算宽度 < 70% 可用宽度(0.51),明显有留白
|
||||
13,pmA,baC,80,475,800,30,0,0,800,12,思源黑体,false,false,true,none,center,衬线体Serif·优雅传统·印刷出版首选,20,中文+英文,true,202.2,0.2527,no_slack,估算宽度 < 70% 可用宽度(0.25),明显有留白
|
||||
14,pmi,baY,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,14·数字测试·阿拉伯数字,13,中文+数字,true,313.6,0.3733,no_slack,估算宽度 < 70% 可用宽度(0.37),明显有留白
|
||||
14,pmi,baK,80,120,800,100,0,0,800,72,思源黑体,true,false,true,none,center,0123456789,10,数字,true,396.0,0.495,no_slack,估算宽度 < 70% 可用宽度(0.50),明显有留白
|
||||
14,pmi,baP,80,240,800,60,0,0,800,36,思源黑体,true,false,true,none,center,0123456789零壹贰叁肆伍陆柒捌玖,20,中文+数字,true,558.0,0.6975,no_slack,估算宽度 < 70% 可用宽度(0.70),明显有留白
|
||||
14,pmi,bax,80,320,800,120,0,0,800,20,思源黑体,false,false,true,none,center,"金额:¥12,345,678.90元百分比:99.99%增长率:+23.45%日期:2024-12-25时间:14:30:00",63,中文+数字,false,318.0,0.3975,no_slack,估算宽度 < 70% 可用宽度(0.40),明显有留白
|
||||
14,pmi,baD,80,460,800,30,0,0,800,12,思源黑体,false,false,true,none,center,阿拉伯数字·等宽比例·金额/百分比/日期格式测试,24,中文,true,266.4,0.333,no_slack,估算宽度 < 70% 可用宽度(0.33),明显有留白
|
||||
15,pmK,bag,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,15·数字测试·中文数字,12,中文+数字,true,285.6,0.34,no_slack,估算宽度 < 70% 可用宽度(0.34),明显有留白
|
||||
15,pmK,bba,80,120,800,80,0,0,800,48,思源黑体,true,false,true,none,center,小写:〇一二三四五六七八九十,14,中文,true,672,0.84,no,估算宽度 0.84x 可用宽度,大概率单行
|
||||
15,pmK,baX,80,210,800,80,0,0,800,48,思源黑体,true,false,true,none,center,大写:零壹贰叁肆伍陆柒捌玖拾,14,中文,true,672,0.84,no,估算宽度 0.84x 可用宽度,大概率单行
|
||||
15,pmK,baG,80,310,800,130,0,0,800,20,思源黑体,false,false,true,none,center,人民币壹佰贰拾叁万肆仟伍佰陆拾柒元捌角玖分二千零二十四年十二月二十五日第一百二十届第三季度百分之八十五,51,中文,false,420,0.525,no_slack,估算宽度 < 70% 可用宽度(0.53),明显有留白
|
||||
15,pmK,bbN,80,465,800,30,0,0,800,12,思源黑体,false,false,true,none,center,中文数字·大小写·财务/正式文书场景,18,中文,true,199.8,0.2497,no_slack,估算宽度 < 70% 可用宽度(0.25),明显有留白
|
||||
16,pmQ,bbc,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,16·数字测试·罗马数字,12,中文+数字,true,285.6,0.34,no_slack,估算宽度 < 70% 可用宽度(0.34),明显有留白
|
||||
16,pmQ,bbd,80,130,800,80,0,0,800,48,思源黑体,true,false,true,none,center,IIIIIIIVVVIVIIVIIIIXX,21,英文,true,554.4,0.693,no_slack,估算宽度 < 70% 可用宽度(0.69),明显有留白
|
||||
16,pmQ,bbB,80,225,800,60,0,0,800,36,思源黑体,true,false,true,none,center,XLLXCCCDDCMM,12,英文,true,237.6,0.297,no_slack,估算宽度 < 70% 可用宽度(0.30),明显有留白
|
||||
16,pmQ,bbv,80,300,800,140,0,0,800,20,思源黑体,false,false,true,none,center,ChapterXXIV·VolumeIII·EditionIXKingHenryVIII·PopeJohnPaulII第XXI届冬季奥林匹克运动会·第III季度报告MMXXIV年·MCMLXXI年,98,中文+英文,false,397.0,0.4963,no_slack,估算宽度 < 70% 可用宽度(0.50),明显有留白
|
||||
16,pmQ,bbb,80,465,800,30,0,0,800,12,思源黑体,false,false,true,none,center,罗马数字·古典风格·章节编号/正式命名场景,21,中文,true,235.8,0.2947,no_slack,估算宽度 < 70% 可用宽度(0.29),明显有留白
|
||||
17,pme,bbe,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,17·中英文混排测试,10,中文+数字,true,242.2,0.2883,no_slack,估算宽度 < 70% 可用宽度(0.29),明显有留白
|
||||
17,pme,bbz,80,120,800,60,0,0,800,32,思源黑体,true,false,true,none,center,人工智能ArtificialIntelligence技术,28,中文+英文,true,579.2,0.724,no,估算宽度 0.72x 可用宽度,大概率单行
|
||||
17,pme,bbR,80,200,800,200,0,0,800,18,思源黑体,false,false,true,none,,随着AI技术的快速发展,MachineLearning与DeepLearning已经渗透到各行各业。在NaturalLanguageProcessing领域,大语言模型LLM的出现彻底改变了人机交互方式。从ChatGPT到文心一言,从GPT-4到Claude3,AI助手正在成为人们工作生活的标配。2024年被称为AI应用元年,GenerativeAI创造了无限可能。,184,中文+英文+数字,false,709.2,0.8865,tight_single,估算宽度 0.89x 可用宽度,接近填满,需确认是否单行
|
||||
17,pme,bbp,80,420,800,60,0,0,800,16,思源黑体,false,false,true,none,center,"测试中英文之间的间距、基线对齐、字号协调等混排效果Testingspacing,baselinealignment,andsizeharmonybetweenCJKandLatinscripts",97,中文+英文,false,633.6,0.792,no,估算宽度 0.79x 可用宽度,大概率单行
|
||||
18,pmF,bbj,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,18·中英文数字混排测试,12,中文+数字,true,298.2,0.355,no_slack,估算宽度 < 70% 可用宽度(0.35),明显有留白
|
||||
18,pmF,bbu,80,115,800,55,0,0,800,28,思源黑体,true,false,true,none,,2024Q4季度业绩报告QuarterlyReport,27,中文+英文+数字,true,491.4,0.6142,no_slack,估算宽度 < 70% 可用宽度(0.61),明显有留白
|
||||
18,pmF,bbA,80,180,800,250,0,0,800,17,思源黑体,false,false,true,none,,"核心数据KeyMetrics:总营收Revenue:¥1,234.56万元,同比增长+23.45%用户数Users:567,890人,月活MAU达89%净利润NetProfit:¥234.56万元,利润率19.0%客户满意度CSAT:4.8/5.0分,NPS达72产品迭代Version:v3.2.1,发布于2024-12-15",164,中文+英文+数字,false,402.05,0.5026,no_slack,估算宽度 < 70% 可用宽度(0.50),明显有留白
|
||||
18,pmF,bbW,80,450,800,40,0,0,800,14,思源黑体,false,false,true,none,center,中文+英文+数字+符号+列表·综合混排测试,21,中文,true,262.5,0.3281,no_slack,估算宽度 < 70% 可用宽度(0.33),明显有留白
|
||||
19,pmz,bbD,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,19·字重测试·粗体Bold,14,中文+英文+数字,true,291.2,0.3467,no_slack,估算宽度 < 70% 可用宽度(0.35),明显有留白
|
||||
19,pmz,bbs,80,120,380,60,0,0,380,32,思源黑体,true,false,true,none,,常规Regular,9,中文+英文,true,187.2,0.4926,no_slack,估算宽度 < 70% 可用宽度(0.49),明显有留白
|
||||
19,pmz,bbF,500,120,380,60,0,0,380,32,思源黑体,true,false,true,none,,粗体Bold,6,中文+英文,true,134.4,0.3537,no_slack,估算宽度 < 70% 可用宽度(0.35),明显有留白
|
||||
19,pmz,bbK,80,200,380,60,0,0,380,32,思源黑体,true,false,true,none,,正常字重400,7,中文+数字,true,180.8,0.4758,no_slack,估算宽度 < 70% 可用宽度(0.48),明显有留白
|
||||
19,pmz,bbI,500,200,380,60,0,0,380,32,思源黑体,true,false,true,none,,加粗字重700,7,中文+数字,true,180.8,0.4758,no_slack,估算宽度 < 70% 可用宽度(0.48),明显有留白
|
||||
19,pmz,bbY,80,290,800,150,0,0,800,18,思源黑体,false,false,true,none,,"在正文中,粗体文字用于强调关键信息,引导读者视线。Inbodytext,boldtexthighlightskeyinformationandguidesthereader.数字加粗:1234567890对比常规:1234567890",117,中文+英文+数字,false,613.8,0.7672,no,估算宽度 0.77x 可用宽度,大概率单行
|
||||
19,pmz,bbU,80,460,800,30,0,0,800,12,思源黑体,false,false,true,none,center,粗体vs常规·字重对比·强调效果测试,18,中文+英文,true,194.4,0.243,no_slack,估算宽度 < 70% 可用宽度(0.24),明显有留白
|
||||
20,pmd,bbE,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,20·字体样式测试·斜体Italic,18,中文+英文+数字,true,378.0,0.45,no_slack,估算宽度 < 70% 可用宽度(0.45),明显有留白
|
||||
20,pmd,bbS,80,120,380,60,0,0,380,32,思源黑体,true,false,true,none,,正体Upright,9,中文+英文,true,187.2,0.4926,no_slack,估算宽度 < 70% 可用宽度(0.49),明显有留白
|
||||
20,pmd,bbk,500,120,380,60,0,0,380,32,思源黑体,true,true,true,none,,斜体Italic,8,中文+英文,true,169.6,0.4463,no_slack,估算宽度 < 70% 可用宽度(0.45),明显有留白
|
||||
20,pmd,bbO,80,200,380,60,0,0,380,32,思源黑体,true,false,true,none,,NormalText,10,英文,true,176.0,0.4632,no_slack,估算宽度 < 70% 可用宽度(0.46),明显有留白
|
||||
20,pmd,bbf,500,200,380,60,0,0,380,32,思源黑体,true,true,true,none,,ItalicText,10,英文,true,176.0,0.4632,no_slack,估算宽度 < 70% 可用宽度(0.46),明显有留白
|
||||
20,pmd,bbJ,80,290,800,150,0,0,800,18,思源黑体,false,false,true,none,,"斜体常用于引用、书名、外来词等场景,增添文字的韵律感。Italictextisoftenusedforquotes,booktitles,andforeignwords.数字斜体:1234567890对比正体:1234567890",115,中文+英文+数字,false,574.2,0.7177,no,估算宽度 0.72x 可用宽度,大概率单行
|
||||
20,pmd,bbn,80,460,800,30,0,0,800,12,思源黑体,false,false,true,none,center,斜体vs正体·引用/书名/强调场景,17,中文+英文,true,177.0,0.2213,no_slack,估算宽度 < 70% 可用宽度(0.22),明显有留白
|
||||
21,pmG,bbM,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,21·文字装饰测试·下划线/删除线,17,中文+数字,true,413.0,0.4917,no_slack,估算宽度 < 70% 可用宽度(0.49),明显有留白
|
||||
21,pmG,bbq,80,120,800,60,0,0,800,32,思源黑体,true,false,true,none,center,下划线文字UnderlineText,18,中文+英文,true,388.8,0.486,no_slack,估算宽度 < 70% 可用宽度(0.49),明显有留白
|
||||
21,pmG,bbi,80,200,800,60,0,0,800,32,思源黑体,true,false,true,none,center,删除线文字StrikethroughText,22,中文+英文,true,459.2,0.574,no_slack,估算宽度 < 70% 可用宽度(0.57),明显有留白
|
||||
21,pmG,bbT,80,290,800,150,0,0,800,18,思源黑体,false,false,true,none,,"下划线常用于链接、重点标注等场景,删除线用于表示已删除或作废的内容。Underlineisusedforlinksandkeyannotations,strikethroughfordeletedcontent.组合效果:加粗加下划线·斜体加下划线",124,中文+英文,false,712.8,0.891,tight_single,估算宽度 0.89x 可用宽度,接近填满,需确认是否单行
|
||||
21,pmG,bbC,80,460,800,30,0,0,800,12,思源黑体,false,false,true,none,center,下划线·删除线·组合样式测试,14,中文,true,157.2,0.1965,no_slack,估算宽度 < 70% 可用宽度(0.20),明显有留白
|
||||
22,pmq,bZa,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,22·字间距测试LetterSpacing,21,中文+英文+数字,true,386.4,0.46,no_slack,估算宽度 < 70% 可用宽度(0.46),明显有留白
|
||||
22,pmq,bbg,80,120,800,50,0,0,800,28,思源黑体,true,false,true,none,center,紧凑字间距-2pxTight,14,中文+英文+数字,true,278.6,0.3483,no_slack,估算宽度 < 70% 可用宽度(0.35),明显有留白
|
||||
22,pmq,bbG,80,185,800,50,0,0,800,28,思源黑体,true,false,true,none,center,正常字间距0pxNormal,14,中文+英文+数字,true,278.6,0.3483,no_slack,估算宽度 < 70% 可用宽度(0.35),明显有留白
|
||||
22,pmq,bZN,80,250,800,50,0,0,800,28,思源黑体,true,false,true,none,center,宽松字间距3pxLoose,13,中文+英文+数字,true,263.2,0.329,no_slack,估算宽度 < 70% 可用宽度(0.33),明显有留白
|
||||
22,pmq,bZb,80,315,800,50,0,0,800,28,思源黑体,true,false,true,none,center,超宽字间距8pxWide,12,中文+英文+数字,true,247.8,0.3098,no_slack,估算宽度 < 70% 可用宽度(0.31),明显有留白
|
||||
22,pmq,bbl,80,390,800,80,0,0,800,16,思源黑体,false,false,true,none,center,字间距影响文字的呼吸感和阅读节奏标题常用宽松字间距营造高级感,正文用正常字间距保证可读性,44,中文,false,448,0.56,no_slack,估算宽度 < 70% 可用宽度(0.56),明显有留白
|
||||
22,pmq,bbX,80,485,800,25,0,0,800,11,思源黑体,false,false,true,none,center,从-2px到8px·四级字间距对比,17,中文+英文+数字,true,147.4,0.1842,no_slack,估算宽度 < 70% 可用宽度(0.18),明显有留白
|
||||
23,pmS,bZB,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,23·行间距测试LineSpacing,19,中文+英文+数字,true,355.6,0.4233,no_slack,估算宽度 < 70% 可用宽度(0.42),明显有留白
|
||||
23,pmS,bZZ,60,115,270,340,0,0,270,14,思源黑体,false,false,true,none,,1.0倍行距这是一段测试文字,用于展示1.0倍行间距的效果。行间距较小时,文字显得紧凑,但可能影响可读性。紧凑排版适合空间有限的场景。,67,中文+数字,false,639.1,2.367,yes,估算宽度 2.37x 可用宽度,肯定换行(多行)
|
||||
23,pmS,bZo,345,115,270,340,0,0,270,14,思源黑体,false,false,true,none,,1.5倍行距这是一段测试文字,用于展示1.5倍行间距的效果。这是最常用的行间距设置,兼顾可读性和信息密度。适合大多数正文排版场景。,65,中文+数字,false,639.1,2.367,yes,估算宽度 2.37x 可用宽度,肯定换行(多行)
|
||||
23,pmS,bZc,630,115,270,340,0,0,270,14,思源黑体,false,false,true,none,,2.0倍行距这是一段测试文字,用于展示2.0倍行间距的效果。行间距较大时,文字显得疏朗透气,阅读体验轻松。适合需要留白感的设计。,64,中文+数字,false,322,1.1926,borderline,估算宽度 1.19x 可用宽度,边界情况,需截图确认是否换行
|
||||
23,pmS,bZH,60,475,840,30,0,0,840,12,思源黑体,false,false,true,none,center,1.0/1.5/2.0倍行距对比·三列并排展示,23,中文+数字,true,211.2,0.2514,no_slack,估算宽度 < 70% 可用宽度(0.25),明显有留白
|
||||
24,pmM,bZR,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,24·对齐方式测试Alignment,18,中文+英文+数字,true,352.8,0.42,no_slack,估算宽度 < 70% 可用宽度(0.42),明显有留白
|
||||
24,pmM,bZu,80,115,800,50,0,0,800,24,思源黑体,true,false,true,none,left,←左对齐LeftAlign,13,中文+英文,true,204.0,0.255,no_slack,估算宽度 < 70% 可用宽度(0.25),明显有留白
|
||||
24,pmM,bZe,80,180,800,50,0,0,800,24,思源黑体,true,false,true,none,center,居中对齐CenterAlign,15,中文+英文,true,241.2,0.3015,no_slack,估算宽度 < 70% 可用宽度(0.30),明显有留白
|
||||
24,pmM,bZz,80,245,800,50,0,0,800,24,思源黑体,true,false,true,none,right,右对齐RightAlign→,14,中文+英文,true,217.2,0.2715,no_slack,估算宽度 < 70% 可用宽度(0.27),明显有留白
|
||||
24,pmM,bZp,80,310,800,120,0,0,800,16,思源黑体,false,false,true,none,justify,两端对齐Justify:这是一段用于测试两端对齐效果的较长文字,文字的左右两边都会对齐,形成整齐的文字块边缘,适合报纸、杂志等正式排版。Thequickbrownfoxjumpsoverthelazydog.Typographyistheartandtechniqueofarrangingtype.,150,中文+英文,true,1759.2,2.199,yes,估算宽度 2.20x 可用宽度,肯定换行(多行)
|
||||
24,pmM,bZV,80,455,800,30,0,0,800,12,思源黑体,false,false,true,none,center,左对齐·居中·右对齐·两端对齐·四种对齐方式,22,中文,true,242.4,0.303,no_slack,估算宽度 < 70% 可用宽度(0.30),明显有留白
|
||||
25,pmP,bZt,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,25·文字颜色测试Colors,15,中文+英文+数字,true,306.6,0.365,no_slack,估算宽度 < 70% 可用宽度(0.36),明显有留白
|
||||
25,pmP,bZk,80,115,800,45,0,0,800,26,思源黑体,true,false,true,none,center,深蓝DarkBlue·主色Primary,20,中文+英文,true,332.8,0.416,no_slack,估算宽度 < 70% 可用宽度(0.42),明显有留白
|
||||
25,pmP,bZO,80,170,800,45,0,0,800,26,思源黑体,true,false,true,none,center,亮蓝BrightBlue·辅色Secondary,24,中文+英文,true,390.0,0.4875,no_slack,估算宽度 < 70% 可用宽度(0.49),明显有留白
|
||||
25,pmP,bZA,80,225,800,45,0,0,800,26,思源黑体,true,false,true,none,center,青绿Teal·成功色Success,17,中文+英文,true,301.6,0.377,no_slack,估算宽度 < 70% 可用宽度(0.38),明显有留白
|
||||
25,pmP,bZJ,80,280,800,45,0,0,800,26,思源黑体,true,false,true,none,center,琥珀Amber·警告色Warning,18,中文+英文,true,315.9,0.3949,no_slack,估算宽度 < 70% 可用宽度(0.39),明显有留白
|
||||
25,pmP,bZn,80,335,800,45,0,0,800,26,思源黑体,true,false,true,none,center,红色Red·危险色Danger,15,中文+英文,true,273.0,0.3413,no_slack,估算宽度 < 70% 可用宽度(0.34),明显有留白
|
||||
25,pmP,bZS,80,390,800,45,0,0,800,26,思源黑体,true,false,true,none,center,灰色Gray·辅助色Neutral,17,中文+英文,true,301.6,0.377,no_slack,估算宽度 < 70% 可用宽度(0.38),明显有留白
|
||||
25,pmP,bZy,80,455,800,30,0,0,800,12,思源黑体,false,false,true,none,center,六色系统·主/辅/成功/警告/危险/中性·色彩对比测试,27,中文,true,286.2,0.3577,no_slack,估算宽度 < 70% 可用宽度(0.36),明显有留白
|
||||
26,pmh,bZU,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,26·特殊符号测试Symbols,16,中文+英文+数字,true,322.0,0.3833,no_slack,估算宽度 < 70% 可用宽度(0.38),明显有留白
|
||||
26,pmh,bZD,80,115,800,50,0,0,800,28,思源黑体,true,false,true,none,center,"标点符号:,。!?:;""""''()【】",29,中文+英文,true,635.6,0.7945,no,估算宽度 0.79x 可用宽度,大概率单行
|
||||
26,pmh,bZE,80,180,800,50,0,0,800,28,思源黑体,true,false,true,none,center,数学符号:+-×÷=≈≠≤≥±%,16,中文,true,347.2,0.434,no_slack,估算宽度 < 70% 可用宽度(0.43),明显有留白
|
||||
26,pmh,bZF,80,245,800,50,0,0,800,28,思源黑体,true,false,true,none,center,货币符号:¥$€£₩₹¢,12,中文,true,247.8,0.3098,no_slack,估算宽度 < 70% 可用宽度(0.31),明显有留白
|
||||
26,pmh,bZK,80,310,800,50,0,0,800,28,思源黑体,true,false,true,none,center,单位符号:℃℉°‰㎡kgms,14,中文+英文,true,291.2,0.364,no_slack,估算宽度 < 70% 可用宽度(0.36),明显有留白
|
||||
26,pmh,bZI,80,375,800,50,0,0,800,28,思源黑体,true,false,true,none,center,其他符号:@#&*§¶©®™,14,中文,true,278.6,0.3483,no_slack,估算宽度 < 70% 可用宽度(0.35),明显有留白
|
||||
26,pmh,bZP,80,450,800,30,0,0,800,12,思源黑体,false,false,true,none,center,标点·数学·货币·单位·特殊符号·全面测试,21,中文,true,225.0,0.2812,no_slack,估算宽度 < 70% 可用宽度(0.28),明显有留白
|
||||
27,pml,bZY,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,27·段落与列表测试Lists,15,中文+英文+数字,true,319.2,0.38,no_slack,估算宽度 < 70% 可用宽度(0.38),明显有留白
|
||||
27,pml,bZq,80,115,380,340,0,0,380,16,思源黑体,false,false,true,none,,无序列表UnorderedList第一项ItemOne第二项ItemTwo第三项ItemThree第四项ItemFour第五项ItemFive,71,中文+英文,false,178.4,0.4695,no_slack,估算宽度 < 70% 可用宽度(0.47),明显有留白
|
||||
27,pml,bZi,500,115,380,340,0,0,380,16,思源黑体,false,false,true,none,,有序列表OrderedList第一步StepOne第二步StepTwo第三步StepThree第四步StepFour第五步StepFive,69,中文+英文,false,160.8,0.4232,no_slack,估算宽度 < 70% 可用宽度(0.42),明显有留白
|
||||
27,pml,bZr,80,470,800,30,0,0,800,12,思源黑体,false,false,true,none,center,无序列表·有序列表·中英文列表项对比,18,中文,true,205.2,0.2565,no_slack,估算宽度 < 70% 可用宽度(0.26),明显有留白
|
||||
28,pmB,bZl,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,28·综合排版测试Layout,15,中文+英文+数字,true,306.6,0.365,no_slack,估算宽度 < 70% 可用宽度(0.36),明显有留白
|
||||
28,pmB,bZG,60,110,420,50,0,0,420,26,思源黑体,true,false,true,none,,左栏标题LeftColumn,14,中文+英文,true,247.0,0.5881,no_slack,估算宽度 < 70% 可用宽度(0.59),明显有留白
|
||||
28,pmB,bZM,500,110,400,50,0,0,400,26,思源黑体,true,false,true,none,,右栏标题RightColumn,15,中文+英文,true,261.3,0.6533,no_slack,估算宽度 < 70% 可用宽度(0.65),明显有留白
|
||||
28,pmB,bZm,60,170,420,280,0,0,420,15,思源黑体,false,false,true,none,,"这是左栏的正文内容,使用15pt字号,1.7倍行间距。多栏排版可以有效利用页面空间,提升信息密度。要点一:重点信息加粗显示要点二:引用内容斜体标注要点三:关键数据下划线强调数字测试:123,456,789·百分比:99.9%",112,中文+英文+数字,false,357.75,0.8518,tight_single,估算宽度 0.85x 可用宽度,接近填满,需确认是否单行
|
||||
28,pmB,bZQ,500,170,400,280,0,0,400,15,思源黑体,false,false,true,none,,"Thisistherightcolumnbodytextin15ptsize.Multi-columnlayoutoptimizesspaceandinformationdensity.Point1:BoldforemphasisPoint2:ItalicforquotesPoint3:UnderlineforkeydataNumbers:123,456,789·Percent:99.9%",196,英文+数字,false,445.5,1.1138,borderline,估算宽度 1.11x 可用宽度,边界情况,需截图确认是否换行
|
||||
28,pmB,bZg,60,470,840,30,0,0,840,12,思源黑体,false,false,true,none,center,双栏布局·中英对照·列表+强调+数字·综合测试,23,中文,true,249.0,0.2964,no_slack,估算宽度 < 70% 可用宽度(0.30),明显有留白
|
||||
29,pmU,bdZ,60,40,840,50,0,0,840,28,思源黑体,true,false,true,none,,29·字符密度测试Density,16,中文+英文+数字,true,322.0,0.3833,no_slack,估算宽度 < 70% 可用宽度(0.38),明显有留白
|
||||
29,pmU,bdN,60,110,840,360,0,0,840,13,思源黑体,false,false,true,none,,"高密度文字排版测试·小字号大信息量场景排版设计(Typography)是一门关于文字排列和视觉呈现的艺术与技术。它涉及字体选择、字号大小、行间距、字间距、对齐方式、颜色搭配等多个方面。好的排版设计不仅能让文字更易读,还能传递情感和品牌调性。Typographyistheartandtechniqueofarrangingtypetomakewrittenlanguagelegible,readable,andappealingwhendisplayed.Thearrangementoftypeinvolvesselectingtypefaces,pointsizes,linelengths,line-spacing,andletter-spacing,andadjustingthespacebetweenpairsofletters.中文排版与英文排版有许多不同之处:中文是方块字,每个字符宽度基本一致;英文是比例字体,字符宽度各不相同。中文排版需要考虑标点符号的位置、中英文混排的间距等问题。数字0123456789在中英文混排时也需要特别注意基线对齐和宽度协调。数据:2024年全球字体市场规模达$12.34亿美元,年增长率8.5%。Top5字体:Helvetica,Arial,TimesNewRoman,Calibri,Garamond。",578,中文+英文+数字,false,1801.8,2.145,yes,估算宽度 2.15x 可用宽度,肯定换行(多行)
|
||||
29,pmU,bda,60,485,840,25,0,0,840,11,思源黑体,false,false,true,none,center,13pt高密度·多段落·中英文数字混排·信息密度测试,26,中文+英文+数字,true,251.35,0.2992,no_slack,估算宽度 < 70% 可用宽度(0.30),明显有留白
|
||||
30,pmv,bdB,80,180,800,100,0,0,800,56,思源黑体,true,false,true,none,center,测试样张·完,6,中文,true,310.8,0.3885,no_slack,估算宽度 < 70% 可用宽度(0.39),明显有留白
|
||||
30,pmv,bdc,80,300,800,50,0,0,800,22,思源黑体,true,false,true,none,center,EndofTypographyTestSample,25,英文,true,302.5,0.3781,no_slack,估算宽度 < 70% 可用宽度(0.38),明显有留白
|
||||
30,pmv,bdd,80,380,800,80,0,0,800,14,思源黑体,false,false,true,none,center,共30页·涵盖字号/字体/中英文/数字/样式/间距/颜色/排版30Pages·Size/Font/CJK&Latin/Numbers/Style/Spacing/Color/Layout,93,中文+英文+数字,false,477.4,0.5967,no_slack,估算宽度 < 70% 可用宽度(0.60),明显有留白
|
||||
|
115
skills/lark-slides/scripts/width_measurement_report.md
Normal file
115
skills/lark-slides/scripts/width_measurement_report.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# 字符宽度测量数据报告
|
||||
|
||||
**Presentation**: BUFBsLX2ZlzyMTdprLicd7rpneg
|
||||
**总样本数**: 158
|
||||
**关键测量样本(比值≥0.85)**: 12
|
||||
- 接近填满(0.85-1.0): 4
|
||||
- 边界情况(1.0-1.2): 2
|
||||
- 大概率换行(1.2-1.5): 2
|
||||
- 肯定换行(>1.5): 4
|
||||
|
||||
## 按文本类型统计(所有样本)
|
||||
|
||||
| 文本类型 | 样本数 | 平均估算/可用比 | 最小比值 | 最大比值 |
|
||||
|----------|--------|------------------|----------|----------|
|
||||
| 中文 | 24 | 0.4099 | 0.1965 | 0.8400 |
|
||||
| 中文+数字 | 21 | 0.6316 | 0.2514 | 2.3670 |
|
||||
| 中文+英文 | 38 | 0.5405 | 0.2213 | 2.1990 |
|
||||
| 中文+英文+数字 | 56 | 0.4992 | 0.1842 | 2.1450 |
|
||||
| 数字 | 3 | 0.4217 | 0.3850 | 0.4950 |
|
||||
| 英文 | 14 | 0.4805 | 0.2970 | 0.6930 |
|
||||
| 英文+数字 | 2 | 0.7879 | 0.4620 | 1.1138 |
|
||||
|
||||
## 接近填满样本(估算比值 0.85-1.0,最适合校准单行宽度)
|
||||
|
||||
| 页码 | 元素ID | 字体 | 字号 | Bold | 文本类型 | 硬换行 | 文本 | 估算宽度 | 可用宽度 | 比值 |
|
||||
|------|--------|------|------|------|----------|--------|------|----------|----------|------|
|
||||
| 8 | bNm | 思源黑体 | 10 | false | 中文+英文+数字 | false | 极小字号测试10pt—用于脚注、注释、数据来源说明等本页测试10pt极小字号的可读性。在正式演示中,... | 764.5 | 800 | 0.9556 |
|
||||
| 17 | bbR | 思源黑体 | 18 | false | 中文+英文+数字 | false | 随着AI技术的快速发展,MachineLearning与DeepLearning已经渗透到各行各业。... | 709.2 | 800 | 0.8865 |
|
||||
| 21 | bbT | 思源黑体 | 18 | false | 中文+英文 | false | 下划线常用于链接、重点标注等场景,删除线用于表示已删除或作废的内容。Underlineisusedf... | 712.8 | 800 | 0.891 |
|
||||
| 28 | bZm | 思源黑体 | 15 | false | 中文+英文+数字 | false | 这是左栏的正文内容,使用15pt字号,1.7倍行间距。多栏排版可以有效利用页面空间,提升信息密度。要... | 357.75 | 420 | 0.8518 |
|
||||
|
||||
## 边界换行样本(估算比值 1.0-1.2,需截图确认是否换行)
|
||||
|
||||
| 页码 | 元素ID | 字体 | 字号 | Bold | 文本类型 | 硬换行 | 文本 | 估算宽度 | 可用宽度 | 比值 |
|
||||
|------|--------|------|------|------|----------|--------|------|----------|----------|------|
|
||||
| 23 | bZc | 思源黑体 | 14 | false | 中文+数字 | false | 2.0倍行距这是一段测试文字,用于展示2.0倍行间距的效果。行间距较大时,文字显得疏朗透气,阅读体验... | 322 | 270 | 1.1926 |
|
||||
| 28 | bZQ | 思源黑体 | 15 | false | 英文+数字 | false | Thisistherightcolumnbodytextin15ptsize.Multi-colum... | 445.5 | 400 | 1.1138 |
|
||||
|
||||
## 大概率换行样本(估算比值 1.2-1.5)
|
||||
|
||||
| 页码 | 元素ID | 字体 | 字号 | Bold | 文本类型 | 硬换行 | 文本 | 估算宽度 | 可用宽度 | 比值 |
|
||||
|------|--------|------|------|------|----------|--------|------|----------|----------|------|
|
||||
| 6 | bNf | 思源黑体 | 18 | false | 中文+英文+数字 | false | 这是一段正文测试文字,使用18pt字号,是幻灯片中最常用的正文字号。Typographyisthea... | 1079.1 | 800 | 1.3489 |
|
||||
| 7 | bNU | 思源黑体 | 14 | false | 中文+英文+数字 | false | 小号正文测试14pt这是一段使用14pt字号的正文文字,适合用于内容较多的页面或说明性文字。在信息密... | 1070.3 | 800 | 1.3379 |
|
||||
|
||||
## 肯定换行样本(估算比值 > 1.5)
|
||||
|
||||
| 页码 | 元素ID | 字体 | 字号 | Bold | 文本类型 | 硬换行 | 文本 | 估算宽度 | 可用宽度 | 比值 |
|
||||
|------|--------|------|------|------|----------|--------|------|----------|----------|------|
|
||||
| 23 | bZZ | 思源黑体 | 14 | false | 中文+数字 | false | 1.0倍行距这是一段测试文字,用于展示1.0倍行间距的效果。行间距较小时,文字显得紧凑,但可能影响可... | 639.1 | 270 | 2.367 |
|
||||
| 23 | bZo | 思源黑体 | 14 | false | 中文+数字 | false | 1.5倍行距这是一段测试文字,用于展示1.5倍行间距的效果。这是最常用的行间距设置,兼顾可读性和信息... | 639.1 | 270 | 2.367 |
|
||||
| 24 | bZp | 思源黑体 | 16 | false | 中文+英文 | true | 两端对齐Justify:这是一段用于测试两端对齐效果的较长文字,文字的左右两边都会对齐,形成整齐的文... | 1759.2 | 800 | 2.199 |
|
||||
| 29 | bdN | 思源黑体 | 13 | false | 中文+英文+数字 | false | 高密度文字排版测试·小字号大信息量场景排版设计(Typography)是一门关于文字排列和视觉呈现的... | 1801.8 | 840 | 2.145 |
|
||||
|
||||
## 字体统计
|
||||
|
||||
| 字体 | 样本数 |
|
||||
|------|--------|
|
||||
| 思源黑体 | 145 |
|
||||
| Helvetica | 5 |
|
||||
| Georgia | 5 |
|
||||
| 思源宋体 | 3 |
|
||||
|
||||
## 字号+粗体统计
|
||||
|
||||
| 字号 | Bold | 样本数 | 平均比值 |
|
||||
|------|------|--------|----------|
|
||||
| 10 | false | 2 | 0.6018 |
|
||||
| 11 | false | 3 | 0.2335 |
|
||||
| 12 | false | 17 | 0.2784 |
|
||||
| 13 | false | 2 | 1.2393 |
|
||||
| 14 | false | 12 | 0.8339 |
|
||||
| 15 | false | 2 | 0.9828 |
|
||||
| 16 | false | 8 | 0.7480 |
|
||||
| 17 | false | 1 | 0.5026 |
|
||||
| 18 | false | 8 | 0.7501 |
|
||||
| 20 | false | 3 | 0.4729 |
|
||||
| 22 | true | 1 | 0.3781 |
|
||||
| 24 | false | 1 | 0.2858 |
|
||||
| 24 | true | 4 | 0.3225 |
|
||||
| 26 | true | 8 | 0.4544 |
|
||||
| 28 | true | 49 | 0.4030 |
|
||||
| 32 | true | 14 | 0.4931 |
|
||||
| 36 | true | 6 | 0.5820 |
|
||||
| 44 | true | 1 | 0.5558 |
|
||||
| 48 | true | 8 | 0.6644 |
|
||||
| 52 | true | 2 | 0.4469 |
|
||||
| 56 | true | 1 | 0.3885 |
|
||||
| 60 | true | 1 | 0.7066 |
|
||||
| 64 | true | 1 | 0.6400 |
|
||||
| 72 | true | 3 | 0.6450 |
|
||||
|
||||
## 说明
|
||||
|
||||
- **估算宽度**: 使用当前 `estimate_character_width` 函数计算(中文=1em,西文=0.55em,空格=0.33em)
|
||||
- **可用宽度**: shape.width - paddingLeft - paddingRight
|
||||
- **width_ratio**: 估算宽度 / 可用宽度(针对最长硬换行段落计算)
|
||||
- **硬换行**: 文本中是否包含显式 \n 分段
|
||||
- 截图保存在: /Users/bytedance/go/src/github.com/larksuite/cli/skills/lark-slides/scripts/.lark-slides/screenshots
|
||||
|
||||
### 比值解读建议
|
||||
- ratio < 0.7: 明显留白,估算宽度可能偏宽,或文本确实很短
|
||||
- 0.7-0.85: 大概率单行,有少量留白
|
||||
- 0.85-1.0: 接近填满,是校准西文/中文字符宽度系数的最佳样本
|
||||
- 1.0-1.2: 边界情况,需要看截图确认:是刚好填满单行还是换行了
|
||||
- 1.2-1.5: 大概率换2行
|
||||
- >1.5: 肯定换行(多行文本)
|
||||
|
||||
### 校准建议
|
||||
1. 先看 ratio 0.85-1.0 的样本:如果截图中这些文本**确实单行且接近填满**,说明当前估算大致准确;如果有较多留白,说明估算偏宽,需要减小西文字符系数
|
||||
2. 再看 ratio 1.0-1.2 的样本:结合截图判断实际是单行还是换行,反推合理系数
|
||||
3. 重点关注纯英文、纯数字、纯中文、中英混合这几类分别统计
|
||||
4. Bold 字体通常比常规字体稍宽,Italic 稍窄,需要分别考虑
|
||||
|
||||
请人工核对截图确认边界样本的实际换行情况,用于校准字符宽度系数。
|
||||
@@ -49,6 +49,24 @@ 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
|
||||
# A <line> crossing text glyphs is a legibility defect (see line_crosses_text_glyphs). We erode the
|
||||
# glyph box by this margin before testing intersection so a line that only skims a glyph edge or the
|
||||
# padding-only text frame -- but does not actually cut through the letterforms -- is not flagged.
|
||||
LINE_TEXT_GRAZE_MIN_PX = 2.0
|
||||
LINE_TEXT_GRAZE_FONT_RATIO = 0.12
|
||||
# A line whose effective stroke alpha is below this is not visibly rendered, so it cannot occlude text.
|
||||
LINE_MIN_VISIBLE_ALPHA = 0.08
|
||||
# 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 +124,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 +282,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 +759,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 +784,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 +830,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 +871,235 @@ 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 is_cjk_char(character: str) -> bool:
|
||||
"""CJK-like characters may wrap between any two adjacent glyphs.
|
||||
|
||||
Mirrors the isCJKLike ranges used by ee/slide text-measure-module so that
|
||||
line-count estimation matches DOM/Skia wrapping: CJK breaks per glyph while
|
||||
latin words stay atomic.
|
||||
"""
|
||||
code = ord(character)
|
||||
return (
|
||||
0x2E80 <= code <= 0x9FFF
|
||||
or 0x3000 <= code <= 0xD7AF
|
||||
or 0xF900 <= code <= 0xFAFF
|
||||
or 0xFE30 <= code <= 0xFE4F
|
||||
or 0xFF01 <= code <= 0xFF60
|
||||
or 0xFFE0 <= code <= 0xFFE6
|
||||
)
|
||||
|
||||
|
||||
def tokenize_for_wrap(text: str) -> list[tuple[str, str]]:
|
||||
"""Split a hard line into wrap tokens: latin words are atomic, CJK glyphs
|
||||
are individually breakable, whitespace runs are collapse points."""
|
||||
tokens: list[tuple[str, str]] = []
|
||||
index = 0
|
||||
length = len(text)
|
||||
while index < length:
|
||||
character = text[index]
|
||||
if character.isspace():
|
||||
start = index
|
||||
while index < length and text[index].isspace():
|
||||
index += 1
|
||||
tokens.append(("space", text[start:index]))
|
||||
elif is_cjk_char(character):
|
||||
tokens.append(("cjk", character))
|
||||
index += 1
|
||||
else:
|
||||
start = index
|
||||
while index < length and not text[index].isspace() and not is_cjk_char(text[index]):
|
||||
index += 1
|
||||
tokens.append(("word", text[start:index]))
|
||||
return tokens
|
||||
|
||||
|
||||
def count_wrapped_lines(
|
||||
text: str,
|
||||
font_size: int | float,
|
||||
letter_spacing: int | float,
|
||||
bold: bool,
|
||||
font_family: str | None,
|
||||
available_width: int | float,
|
||||
) -> int:
|
||||
"""Greedy word-aware wrapped line count.
|
||||
|
||||
Unlike ceil(width / available), latin words are never split mid-word (unless
|
||||
a single word is wider than the line, in which case it breaks like DOM
|
||||
overflow-wrap:break-word). This avoids under-counting lines for word-heavy
|
||||
text and matches how ee/slide reconciles Skia wrapping with the DOM.
|
||||
"""
|
||||
tokens = tokenize_for_wrap(text)
|
||||
if not tokens:
|
||||
return 1
|
||||
lines = 1
|
||||
current = 0.0
|
||||
|
||||
def token_width(token: str) -> int | float:
|
||||
return estimate_text_width(token, font_size, letter_spacing, bold, font_family)
|
||||
|
||||
for kind, token in tokens:
|
||||
width = token_width(token)
|
||||
# letter-spacing applies at every glyph boundary, including the seam
|
||||
# between two tokens on the same line; add it back so a packed line
|
||||
# matches estimate_text_width of the concatenated run.
|
||||
junction = letter_spacing if current > 0 else 0
|
||||
if kind == "space":
|
||||
if current > 0:
|
||||
current += junction + width
|
||||
continue
|
||||
if current > 0 and current + junction + width <= available_width:
|
||||
current += junction + width
|
||||
continue
|
||||
if current > 0:
|
||||
lines += 1
|
||||
if kind == "word" and width > available_width:
|
||||
extra = math.ceil(width / available_width) - 1
|
||||
lines += extra
|
||||
current = width - extra * available_width
|
||||
else:
|
||||
current = width
|
||||
return lines
|
||||
|
||||
|
||||
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_labeled_short_metric_text(text: str) -> bool:
|
||||
"""Return whether a short metric contains a separate label before its value."""
|
||||
return is_short_metric_text(text) and re.search(r"[A-Za-z]+\s+[+\-–—]?\d", text) 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) and not is_labeled_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
|
||||
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])
|
||||
bold = element.get("bold", False)
|
||||
font_family = element.get("fontFamily", "")
|
||||
letter_spacing = resolve_letter_spacing(element)
|
||||
# Visual width ignores trailing whitespace: like Skia (which trims line-end
|
||||
# spaces), a run's rightmost visible glyph bounds the box. Counting trailing
|
||||
# spaces inflates the right edge and manufactures overlap false positives.
|
||||
paragraphs = [
|
||||
stripped for paragraph in re.split(r"\n+", element["text"]) if (stripped := paragraph.rstrip())
|
||||
]
|
||||
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 +1112,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 +1128,14 @@ 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 += count_wrapped_lines(
|
||||
hard_line, font_size, letter_spacing, bold, font_family, effective_width
|
||||
)
|
||||
return line_count
|
||||
|
||||
|
||||
@@ -831,7 +1145,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 +1154,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 +1190,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 +1204,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 +1215,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 +1240,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 +1251,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 +1394,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 +1412,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 +1433,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 +1450,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 +1498,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 +1576,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 +1603,12 @@ 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"})
|
||||
):
|
||||
bbox = element_canvas_bbox(element)
|
||||
overflow = {
|
||||
"left": max(-bbox["x"], 0),
|
||||
@@ -1329,6 +1718,93 @@ def detect_table_layout_size_mismatches(elements: list[dict[str, Any]]) -> list[
|
||||
return issues
|
||||
|
||||
|
||||
def segment_intersects_rect(
|
||||
x1: float, y1: float, x2: float, y2: float, rect: dict[str, int | float]
|
||||
) -> bool:
|
||||
"""True when segment (x1,y1)-(x2,y2) enters the axis-aligned rect (Liang-Barsky clip)."""
|
||||
left = rect["x"]
|
||||
top = rect["y"]
|
||||
right = rect["x"] + rect["width"]
|
||||
bottom = rect["y"] + rect["height"]
|
||||
if right <= left or bottom <= top:
|
||||
return False
|
||||
dx = x2 - x1
|
||||
dy = y2 - y1
|
||||
if dx == 0 and dy == 0:
|
||||
return left <= x1 <= right and top <= y1 <= bottom
|
||||
t_enter, t_exit = 0.0, 1.0
|
||||
for delta, distance in ((-dx, x1 - left), (dx, right - x1), (-dy, y1 - top), (dy, bottom - y1)):
|
||||
if delta == 0:
|
||||
if distance < 0:
|
||||
return False
|
||||
continue
|
||||
t = distance / delta
|
||||
if delta < 0:
|
||||
t_enter = max(t_enter, t)
|
||||
else:
|
||||
t_exit = min(t_exit, t)
|
||||
if t_enter > t_exit:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def line_text_graze_margin(text_element: dict[str, Any]) -> float:
|
||||
font_size = text_element["fontSize"] if isinstance(text_element.get("fontSize"), (int, float)) else 16
|
||||
return max(font_size * LINE_TEXT_GRAZE_FONT_RATIO, LINE_TEXT_GRAZE_MIN_PX)
|
||||
|
||||
|
||||
def erode_rect(rect: dict[str, int | float], margin: float) -> dict[str, int | float] | None:
|
||||
width = rect["width"] - 2 * margin
|
||||
height = rect["height"] - 2 * margin
|
||||
if width <= 0 or height <= 0:
|
||||
return None
|
||||
return {"x": rect["x"] + margin, "y": rect["y"] + margin, "width": width, "height": height}
|
||||
|
||||
|
||||
def line_crosses_text(line: dict[str, Any], text_element: dict[str, Any]) -> bool:
|
||||
if not is_visually_rendered(line) or line.get("alpha", 1) < LINE_MIN_VISIBLE_ALPHA:
|
||||
return False
|
||||
if not is_text_element(text_element) or not has_text_content(text_element):
|
||||
return False
|
||||
if is_ghost_text(text_element) or is_decorative_text(text_element):
|
||||
return False
|
||||
glyph_bbox = estimate_text_visual_bbox(text_element)
|
||||
if glyph_bbox is None:
|
||||
return False
|
||||
# Erode the glyph box so a line skimming the letter edge or only clipping the padding-only text
|
||||
# frame is exempt; only a line that actually cuts through the letterforms is a crossing.
|
||||
target = erode_rect(glyph_bbox, line_text_graze_margin(text_element))
|
||||
if target is None:
|
||||
return False
|
||||
return segment_intersects_rect(
|
||||
line["startX"], line["startY"], line["endX"], line["endY"], target
|
||||
)
|
||||
|
||||
|
||||
def detect_line_text_crossings(
|
||||
slide_xml: str, elements: list[dict[str, Any]]
|
||||
) -> list[dict[str, Any]]:
|
||||
lines = extract_line_elements(slide_xml)
|
||||
if not lines:
|
||||
return []
|
||||
text_elements = [element for element in elements if is_text_element(element)]
|
||||
issues: list[dict[str, Any]] = []
|
||||
for line in lines:
|
||||
for text_element in text_elements:
|
||||
if not line_crosses_text(line, text_element):
|
||||
continue
|
||||
issues.append(
|
||||
{
|
||||
"level": "error",
|
||||
"code": "bbox_overlap",
|
||||
"elements": [line["id"], text_element["id"]],
|
||||
"message": f'line {line["id"]} crosses text {text_element["id"]}',
|
||||
"hint": "Move the line off the text glyphs so it no longer cuts through the letterforms.",
|
||||
}
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def lint_slide(
|
||||
slide_xml: str, slide_number: int, slide_width: int | float = 960, slide_height: int | float = 540
|
||||
) -> dict[str, Any]:
|
||||
@@ -1339,6 +1815,7 @@ def lint_slide(
|
||||
*detect_table_layout_size_mismatches(elements),
|
||||
*detect_text_may_overflow_shapes(elements),
|
||||
*detect_image_text_occlusions(elements),
|
||||
*detect_line_text_crossings(slide_xml, elements),
|
||||
]
|
||||
|
||||
for index, left in enumerate(elements):
|
||||
@@ -1944,7 +2421,7 @@ def related_object(element: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
elements: list[dict[str, Any]] = []
|
||||
for match in re.finditer(r"<line\b([^>]*)>", slide_xml):
|
||||
for match in re.finditer(r"<line\b([^>]*?)(/?)>", slide_xml):
|
||||
attrs = match.group(1)
|
||||
start_x = extract_numeric_attribute(attrs, "startX")
|
||||
start_y = extract_numeric_attribute(attrs, "startY")
|
||||
@@ -1953,6 +2430,15 @@ def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
if any(value is None for value in (start_x, start_y, end_x, end_y)):
|
||||
continue
|
||||
line_alpha = extract_numeric_attribute(attrs, "alpha")
|
||||
base_alpha = line_alpha if line_alpha is not None else 1
|
||||
border_alpha = 1
|
||||
if match.group(2) != "/":
|
||||
close_index = slide_xml.find("</line>", match.end())
|
||||
body = slide_xml[match.end() : close_index] if close_index != -1 else ""
|
||||
border_attrs = extract_tag_attributes(body, "border")
|
||||
color_alpha = extract_color_alpha(extract_attribute(border_attrs, "color"))
|
||||
if isinstance(color_alpha, (int, float)):
|
||||
border_alpha = color_alpha
|
||||
elements.append(
|
||||
{
|
||||
"id": extract_attribute(attrs, "id") or f"line-{len(elements) + 1}",
|
||||
@@ -1962,8 +2448,12 @@ def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
|
||||
"y": min(start_y, end_y),
|
||||
"width": abs(end_x - start_x),
|
||||
"height": abs(end_y - start_y),
|
||||
"startX": start_x,
|
||||
"startY": start_y,
|
||||
"endX": end_x,
|
||||
"endY": end_y,
|
||||
"rotation": 0,
|
||||
"alpha": line_alpha if line_alpha is not None else 1,
|
||||
"alpha": base_alpha * border_alpha,
|
||||
"order": len(elements),
|
||||
}
|
||||
)
|
||||
@@ -1976,8 +2466,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 +2527,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 +2543,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 +2551,7 @@ def build_result(
|
||||
"document": {
|
||||
"errors": document_errors,
|
||||
"warnings": document_warnings,
|
||||
"infos": document_infos,
|
||||
},
|
||||
"slides": slides,
|
||||
}
|
||||
@@ -2150,6 +2642,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 +2650,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,
|
||||
}
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user