Compare commits

...

37 Commits

Author SHA1 Message Date
zhanghuanxu
d79b2b499d feat: inline slides reference docs into help output
Embed the lark-slides reference docs (xml-schema-quick-ref.md and each shortcut's command guide) directly into the CLI help output so that agents can discover XML syntax and shortcut usage without reading separate markdown files.

- PrepareDomainHelp: append XML schema quick reference for slides domain
- PrepareShortcutHelp: embed shortcut-specific reference docs for +create, +xml-get, +screenshot, +media-upload, +replace-slide, +replace-pages, +history-list, +history-revert, +history-revert-status
- Add contract tests for all reference mappings and re-render idempotency
- +screenshot excludes the shared XML reference (user feedback)
2026-08-02 21:56:52 +08:00
zhanghuanxu
e0bc29a648 fix: detect labeled metric text overflow 2026-07-30 15:08:08 +08:00
yballul-bytedance
68a77eee5c feat: support visible_rule for form questions (#1891)
Form questions can now carry a visible_rule (display condition) so a question shows only when earlier questions match the rule. The rule shares the exact same structure as the view filter, so extract that structure into a single shared reference (lark-base-filter-condition.md) that both view-set-filter and visible_rule point to.

- create/update shortcuts: document visible_rule in --questions help and transcribe the questions body (including visible_rule) into dry-run output
- document that form question updates use full overwrite semantics and must preserve existing fields via read-modify-write
- skill refs: add visible_rule sections to form-questions create/update, note it is only needed when the user asks for a display condition, and clarify that the shared tuple filter protocol does not apply to data-query filters
- tests: pin flag help, verbatim visible_rule passthrough on create/update/list, and add dry-run E2E coverage

Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
Co-authored-by: TRAE CLI <noreply@bytedance.com>
2026-07-30 12:37:24 +08:00
liangshuo-1
29a97dbde8 chore: release v1.0.80 (#2101) 2026-07-29 21:37:15 +08:00
R0bynZhu
29a6a7b600 docs(slides): +create 的参数下沉到 create.md,主 skill 只留路由 (#2096)
* docs(slides): +create 的参数下沉到 create.md,主 skill 只留路由

trace 里 +create 的三类高频错误(--yes、--name、--slides 塞文件路径)
共同点是调用前没读 lark-slides-create.md。原因不是文档缺内容,而是
SKILL.md 里 +create 的信息「够又不够」:给了半截参数描述,模型觉得
够用就直接拼命令,不再打开文档。

- 删掉「创建方式选择」整节(表格 + 两条 WARNING),下沉到 create.md,
  由生成流程 Step 3 和核心规则 2 指向那份文档
- Shortcuts 表 +create 行、核心规则 2 不再复述参数
- Quick Reference 顶部说明参数以文档和 --help 为准,「新建 PPT」行补上
  create.md
- PPTX 一行改写为 drive +import 导入路径;create.md 里写明本命令不读
  本地文件
- create.md 增加「--slides 不接受的形态」对照表,并合并开头零散的
  禁止/推荐/最稳/注意条目
- @ 占位符统一写成 <img src="@./path">,消除「--slides 支持 @ 路径」的歧义

* docs(slides): 去掉 create.md 里的「--slides 不接受的形态」对照表

* docs(slides): 模板一行的触发条件补上「已有 PPTX 要改」

* docs(slides): create.md 澄清「不读取本地文件」的歧义

原句「本命令只从零创建演示文稿,不读取本地文件」与本文档
「本地图片:@<path> 占位符」一节自相矛盾——@ 占位符恰恰会读
本地图片并自动上传。改为只否定「导入本地 PPT 文件的参数」,
不波及图片占位符能力。

* docs(slides): 两步创建的第二步补上 slide create 文档路由

生成流程 Step 3 和「执行前必做」的创建一行原来只指向
lark-slides-create.md,而两步创建的第二步用的是
xml_presentation.slide create,文档没被路由到,模型只能凭
记忆拼参数。
2026-07-29 20:50:24 +08:00
liangshuo-1
c167163d70 feat: propagate invocation metadata (#2097) 2026-07-29 19:39:53 +08:00
zhaojiaxing-coding
7988515e1c feat(drive): add +permission-get-setting shortcut (#1738)
* feat(drive): add +permission-get-setting shortcut

Add a Drive shortcut for reading public permission settings across supported documents, files, folders, and wiki nodes. Resolve URLs into typed resources, preserve permission_public output for machine consumers, and document the shortcut in the permission-governance workflow.

Key features:

- Infer resource type and token from supported Drive URLs while requiring --type for bare tokens

- Query the Drive v2 public permission endpoint with typed validation and user or bot identity

- Support folder permission inspection without recursing into child resources

- Add unit, dry-run E2E, live workflow, output, and skill guidance coverage

* fix(drive): harden permission get setting contract

Harden +permission-get-setting after review findings so callers receive only the documented permission payload and folder support is verified against the live workflow. This prevents malformed responses from being presented as permission settings and keeps the command guidance aligned with the shortcut contract.

Key fixes:
- Reject responses without data.permission_public instead of projecting arbitrary payload fields
- Render complete permission settings in pretty output and mark --token required
- Exercise a created Drive folder in the live workflow and add the command reference
- Correct folder resolution guidance while retaining the shortcut's documented URL forms

* feat/drive-folder-permission-get
2026-07-29 17:57:24 +08:00
zhaojiaxing-coding
c7adff7a3b feat(drive): add +member-list shortcut (#1795)
* feat(drive): add +member-list shortcut

Add a Drive shortcut for listing collaborators on documents, files, folders, and wiki nodes. Resolve supported resource URLs into typed permission requests, preserve raw API data for machine consumers, and keep invalid flag combinations on typed validation paths.

Key features:

- Infer resource type and token from supported Drive URLs while requiring --type for bare tokens

- Validate optional member fields and wiki-only permission type filters

- Provide pretty output, skill guidance, unit coverage, and dry-run/live E2E workflows

- Read dry-run assertions from the standard data.api success envelope

* feat/drive-member-list
2026-07-29 17:04:59 +08:00
ethan-zhx
59237f3104 Feat/detect line text overlap (#2069)
* fix: report ghost text canvas overflow

* fix(slides): detect text-line overlap in xml_text_overlap_lint
2026-07-29 16:20:59 +08:00
R0bynZhu
358cd06838 docs(slides): 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
* docs(slides): consolidate CWD-relative path rule into one global rule

State the "all local file path args must be CWD-relative (absolute
rejected)" rule once in SKILL.md 权威经验, and trim the per-command
repetitions in media-upload / create / screenshot / xml-presentations-get.
Also fix the stale xml-presentations-get param table: --output is optional
(relative), not required.

* feat: try common solution

* chore: 优化措辞

* feat: 优化措辞

* feat: 优化措辞

* docs(slides): 强调调用命令前必读对应命令文档

- 「调用命令前再读」改为「调用相关命令前必须读取相关的文档以了解命令的使用方式」,
  并把原「按需再读」列表合并进来,去掉可选语义
- 移除 lark-shared 的 CRITICAL 前置阅读要求
- Step 4 回读示例补全 `--presentation <xml_presentation_id>` 参数

* docs(slides): Shortcuts 表补充 +screenshot 并写明本地路径参数

- 新增 +screenshot 行:--slide-number 页号(从 1 开始,可重复,一次最多 10 页)、
  --output-dir 保存目录(CWD 内相对路径,默认 .lark-slides/screenshots)
- +xml-get 行补上 --presentation 和 --output(CWD 内相对路径),
  并说明省略 --output 时 XML 返回在 JSON 信封里

* revert(slides): 回退 references 下的文档改动,只保留 SKILL.md

把 lark-slides-create.md、lark-slides-media-upload.md、lark-slides-screenshot.md、
lark-slides-xml-presentations-get.md 还原为 main 的版本,本分支只改 SKILL.md。

* docs(slides): 恢复开始前必读 lark-shared 的 CRITICAL 要求

认证、权限和全局参数以 lark-shared 为准,这条前置阅读不该在本分支被删掉。

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

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

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

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

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

---------

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

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

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

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

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

3
.github/CODEOWNERS vendored
View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

4
package-lock.json generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

@@ -32,6 +32,8 @@ func Shortcuts() []common.Shortcut {
DriveTaskResult,
DriveApplyPermission,
DriveMemberAdd,
DriveMemberList,
DrivePermissionGetSetting,
DriveSecureLabelList,
DriveSecureLabelUpdate,
DriveSearch,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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) — 认证和全局参数

View File

@@ -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` | **是** | 问题 IDfield_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) — 认证和全局参数

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 tokenwiki 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 文件或文档的密级标签。 |

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

View File

@@ -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` 不会递归读取子资源。

View File

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

View File

@@ -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
```
参数:

View File

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

View File

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

View File

@@ -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 folderDrive 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 dataDrive 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。
## 输出策略

View File

@@ -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 图标。
### 创建方式选择
| 场景 | 推荐方式 |
|------|----------|
| 简单 XML1-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 → 创建
- 逐页消费 plankey_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. **创建流程**简单短 XML1-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>` 输出不一致,以后两者为准。

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

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

View File

@@ -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 引号和换行。
## 创建方式选择
| 场景 | 推荐方式 |
|------|----------|
| 简单 XML1-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`,回读确认当前状态,再继续修复或追加。
## 命令

View File

@@ -26,8 +26,8 @@ lark-cli slides +screenshot --as user \
| 参数 | 必需 | 说明 |
|------|------|------|
| `--presentation` | list 模式必需 | `xml_presentation_id``/slides/` URL或解析后为 slides 的 `/wiki/` URL。传 `--content` 时不能使用 |
| `--slide-id` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面 short ID多页截图时重复传入一次最多 10 页(`--slide-id` + `--slide-number` 合计小于等于 10 |
| `--slide-number` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面页号;多页截图时重复传入;一次最多 10 页(`--slide-id` + `--slide-number` 合计小于等于 10 |
| `--slide-id` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面 short ID多页截图时重复传入,或用逗号分隔一次传多个(如 `--slide-id slide_1,slide_2`;一次最多 10 页(`--slide-id` + `--slide-number` 合计小于等于 10 |
| `--slide-number` | list 模式至少提供 `--slide-id` / `--slide-number` 之一 | 页面页号;多页截图时重复传入,或用逗号分隔一次传多个(如 `--slide-number 1,2,3`;一次最多 10 页(`--slide-id` + `--slide-number` 合计小于等于 10 |
| `--content` | render 模式必需 | 要直接渲染的 `<slide>` XML 片段;支持直接传值、`@file``-` stdin。传入后不能同时传 `--slide-id` / `--slide-number` |
| `--output-dir` | 否 | 输出目录,默认 `.lark-slides/screenshots`;必须是当前目录内的相对路径 |
| `--output-name` | 否 | render 模式的输出文件名 stem未指定时优先用返回的 `slide_id`,否则用 `rendered-slide`。若目标文件已存在,会自动追加递增后缀避免覆盖 |
@@ -44,7 +44,7 @@ lark-cli slides +screenshot --as user \
### 多页截图
一次不要超过 10 页;如需更多页面,分批调用。
一次不要超过 10 页;如需更多页面,分批调用。可以重复传参,也可以用逗号分隔一次传多个:
```bash
lark-cli slides +screenshot --as user \

View File

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

View File

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

View File

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

View 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("&nbsp;", " ")
stripped = stripped.replace("&amp;", "&")
stripped = stripped.replace("&lt;", "<")
stripped = stripped.replace("&gt;", ">")
stripped = stripped.replace("&quot;", '"')
stripped = stripped.replace("&#39;", "'")
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()

View 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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/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);
});

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

View 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": "*"
}
}

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

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

View 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 3AI 助手正在成为人们工作生活的标配。"
# Visually: single line
results.append(test_case(
"p17 bbR line3 (mixed CJK+English, 18pt, 800px, expected single)",
"从 ChatGPT 到 文心一言,从 GPT-4 到 Claude 3AI 助手正在成为人们工作生活的标配。",
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()

View 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到Claude3AI助手正在成为人们工作生活的标配。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%用户数Users567,890人月活MAU达89%净利润NetProfit¥234.56万元利润率19.0%客户满意度CSAT4.8/5.0分NPS达72产品迭代Versionv3.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,"标点符号:,。!?:;""""&apos;&apos;()【】",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),明显有留白
1 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
2 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),明显有留白
3 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),明显有留白
4 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),明显有留白
5 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),明显有留白
6 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),明显有留白
7 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 可用宽度,大概率单行
8 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),明显有留白
9 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),明显有留白
10 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),明显有留白
11 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),明显有留白
12 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),明显有留白
13 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),明显有留白
14 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),明显有留白
15 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),明显有留白
16 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),明显有留白
17 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),明显有留白
18 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),明显有留白
19 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),明显有留白
20 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),明显有留白
21 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),明显有留白
22 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),明显有留白
23 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),明显有留白
24 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),明显有留白
25 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),明显有留白
26 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),明显有留白
27 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),明显有留白
28 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行
29 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),明显有留白
30 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),明显有留白
31 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行
32 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),明显有留白
33 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),明显有留白
34 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 可用宽度,接近填满,需确认是否单行
35 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),明显有留白
36 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),明显有留白
37 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 可用宽度,大概率单行
38 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),明显有留白
39 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),明显有留白
40 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),明显有留白
41 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),明显有留白
42 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),明显有留白
43 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),明显有留白
44 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),明显有留白
45 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),明显有留白
46 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),明显有留白
47 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),明显有留白
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),明显有留白
49 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),明显有留白
50 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),明显有留白
51 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 可用宽度,大概率单行
52 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),明显有留白
53 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),明显有留白
54 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),明显有留白
55 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),明显有留白
56 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),明显有留白
57 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),明显有留白
58 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),明显有留白
59 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),明显有留白
60 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),明显有留白
61 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),明显有留白
62 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),明显有留白
63 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),明显有留白
64 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),明显有留白
65 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),明显有留白
66 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),明显有留白
67 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),明显有留白
68 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),明显有留白
69 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),明显有留白
70 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),明显有留白
71 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),明显有留白
72 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),明显有留白
73 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),明显有留白
74 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),明显有留白
75 15 pmK bba 80 120 800 80 0 0 800 48 思源黑体 true false true none center 小写:〇一二三四五六七八九十 14 中文 true 672 0.84 no 估算宽度 0.84x 可用宽度,大概率单行
76 15 pmK baX 80 210 800 80 0 0 800 48 思源黑体 true false true none center 大写:零壹贰叁肆伍陆柒捌玖拾 14 中文 true 672 0.84 no 估算宽度 0.84x 可用宽度,大概率单行
77 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),明显有留白
78 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),明显有留白
79 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),明显有留白
80 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),明显有留白
81 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),明显有留白
82 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),明显有留白
83 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),明显有留白
84 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),明显有留白
85 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 可用宽度,大概率单行
86 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 可用宽度,接近填满,需确认是否单行
87 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 可用宽度,大概率单行
88 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),明显有留白
89 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),明显有留白
90 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),明显有留白
91 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),明显有留白
92 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),明显有留白
93 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),明显有留白
94 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),明显有留白
95 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),明显有留白
96 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),明显有留白
97 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 可用宽度,大概率单行
98 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),明显有留白
99 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),明显有留白
100 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),明显有留白
101 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),明显有留白
102 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),明显有留白
103 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),明显有留白
104 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 可用宽度,大概率单行
105 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),明显有留白
106 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),明显有留白
107 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),明显有留白
108 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),明显有留白
109 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 可用宽度,接近填满,需确认是否单行
110 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),明显有留白
111 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),明显有留白
112 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),明显有留白
113 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),明显有留白
114 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),明显有留白
115 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),明显有留白
116 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),明显有留白
117 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),明显有留白
118 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),明显有留白
119 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 可用宽度,肯定换行(多行)
120 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 可用宽度,肯定换行(多行)
121 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 可用宽度,边界情况,需截图确认是否换行
122 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),明显有留白
123 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),明显有留白
124 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),明显有留白
125 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),明显有留白
126 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),明显有留白
127 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 可用宽度,肯定换行(多行)
128 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),明显有留白
129 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),明显有留白
130 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),明显有留白
131 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),明显有留白
132 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),明显有留白
133 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),明显有留白
134 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),明显有留白
135 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),明显有留白
136 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),明显有留白
137 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),明显有留白
138 26 pmh bZD 80 115 800 50 0 0 800 28 思源黑体 true false true none center 标点符号:,。!?:;""&apos;&apos;()【】 29 中文+英文 true 635.6 0.7945 no 估算宽度 0.79x 可用宽度,大概率单行
139 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),明显有留白
140 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),明显有留白
141 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),明显有留白
142 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),明显有留白
143 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),明显有留白
144 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),明显有留白
145 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),明显有留白
146 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),明显有留白
147 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),明显有留白
148 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),明显有留白
149 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),明显有留白
150 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),明显有留白
151 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 可用宽度,接近填满,需确认是否单行
152 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 可用宽度,边界情况,需截图确认是否换行
153 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),明显有留白
154 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),明显有留白
155 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 可用宽度,肯定换行(多行)
156 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),明显有留白
157 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),明显有留白
158 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),明显有留白
159 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),明显有留白

View 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 稍窄,需要分别考虑
请人工核对截图确认边界样本的实际换行情况,用于校准字符宽度系数。

View File

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