larksuite/cli已通过检查
SKILL DETAIL
lark-sheets
larksuite/cli/lark-sheets
飞书电子表格技能(lark-sheets)用于创建和操作飞书电子表格。它支持创建表格、管理工作表与行列结构(增删、合并、调整尺寸、隐藏、冻结)、读写单元格(值、公式、样式、批注、单元格图片)、查找替换、多操作批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。 当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)、金融/财务建模(如 DCF、三张表、预算、敏感性分析)等任务时使用。若用户想按名称或关键词搜索云空间中的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL 或 token 时,也应直接使用本技能,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。
安装量 · 980查看来源
Installation
npx skills add https://larksuite/cli/
技能文件
SKILL.md
最近同步 · 2026年10月22日
references/lark-sheets-batch-update.md›
# Lark Sheet Batch Update
## 写入边界 + 回读校验
`+batch-update` 把多次写入打包成单次请求,但每个子操作仍应按编辑类任务的范围和回读建议处理:
1. **目标 range 应落在用户授权范围内**:除用户明示要修改的区域外,子操作避免扩张到无关单元格 / 列 / Sheet。规划 range 时先确认每个子操作的边界。
2. **批次完成后按子操作验证**:单元格写入/清除→`+cells-get`/`+csv-get`;对象 CRUD→对应 `+*-list`;sheet CRUD→`+workbook-info`;尺寸/隐藏/冻结/分组/合并→`+sheet-info`;网格线显隐这类没有回读接口的状态按子操作返回确认即可。至少覆盖首、中、末和用户点名项,不能只做统一 cells 抽样。
3. **预期条数前置断言**:涉及"批量填充 N 行"或"对 M 个区域分别写入"时,建议先把 N、M 硬编码进代码,回读后比较实际与预期;不一致就优先再发一轮 `+batch-update` 补齐,补不齐则在交付说明里列出缺口。
4. **三条工具硬约束**:`--yes` 必带(high-risk-write,不带退出码 10);单次 ≤100 条 operations,超出按批拆分;`+cells-batch-set-style` / `+cells-batch-clear` 等批量类 shortcut 不可嵌入 operations(它们本身就是批量原子操作,直接顶层调用)。
若本次 `+batch-update` 的任一子操作写入了公式、复制了公式模板、或导入了含公式的数据块,回读之外必须对本次公式范围逐段执行 `+formula-verify --exit-on-error`;`partial` 拆分续扫,全部分段 `status='success'` 后才完成。`+batch-update` 只保证写入动作按序执行,不保证公式运行结果 zero-error。AI 公式改走 `+formula-verify --ai-only`,按 `references/lark-sheets-formula-verify.md` 的全区间一次异步状态检查规则交付(`failed` / `unsupported` 先修,只剩 pending 可交付并说明后台仍在计算)。
## 使用场景
写入。把**跨类型、有顺序依赖**的多个写入操作合并为一次请求按序执行(如插列 → 写表头 → 回填数据)。注意:不支持嵌套 `+batch-update`。
**先分流再动手(按操作组合选入口)**:美化收尾(样式 / 合并 / 行高列宽 / 冻结的任意组合)→ 一次 `+styles-put`(声明式规格,见 `references/lark-sheets-styles-put.md`),不要拼 `--operations` 子操作数组;**同一个写操作**打多个区域 → 用该命令自身的复数形态(`+cells-set --writes` / `+cells-batch-clear` / `+dim-delete --ranges` / resize 的 map 形态等);只有跨类型、有顺序依赖的操作链才用本命令。
**⚠️ 优先使用 `+batch-update` 的场景**:
- 需要先插入行列再写入数据时(`+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` + `+cells-set`)
- 需要对多个区域执行**不同类型**的写入操作时(如 `+cells-set` + `+cells-clear` 组合)。同一个写操作打多区域用该命令自身的复数形态、多区域 merge 用 `+styles-put` 的 `cell_merges`、大范围 unmerge 直接单次调用——均见上方分流,不进本命令
**多个互不依赖的图表任务优先使用 `+batch-chart-create` / `+batch-chart-update`**;只有图表与其它写入存在同一批次顺序依赖时,才把图表 shortcut 放进 `+batch-update`。
**不可放进 `--operations` 的写 shortcut**(`shortcut` 枚举不含它们,强行写入会被校验拒):`+cells-set-image`(需本地上传图片)、`+styles-put` / `+dropdown-update` / `+dropdown-delete` / `+cells-batch-clear`(自身已是批量入口,不可再嵌套)、`+dim-move`。这些操作需在 `+batch-update` 之外单独调用。
**行高列宽批量不走这里**:多行 / 多列不同尺寸用 `+styles-put` 的 `row_sizes` / `col_sizes`(可与样式同批),或 `+rows-resize --heights` / `+cols-resize --widths` 的 map 形态(见 `references/lark-sheets-range-operations.md`);map 形态不可作为 `--operations` 子操作嵌入(子操作里仍可用单区间形态 `range` + `height`/`width`)。
**执行语义(fail-fast;失败后哪些已生效取决于批次构成)**:默认首个失败的子操作即中断剩余操作。此前的子操作**是否已落盘不统一**:纯单元格 / 行列结构类写入在提交前只累计在内存,失败时整体不落盘(等效回滚);而图表 / 透视表等对象类子操作执行时会**先把此前累计的写入提交落盘再创建对象**——批次含这类子操作时,失败前完成的部分(含其之前的普通写入)已实际生效、无法回滚。因此失败后**不要假设"全部回滚"或"全部保留"**:先看返回 `results` 里各子操作的状态,再回读现状(行列数 / 目标格 / `+chart-list` 等对象清单)确认已生效集合,只补发未生效部分——盲目整批重发会重复应用已生效操作(如插行 / 建图),盲目只发失败尾可能写到未生效的旧结构上。传 `--continue-on-error` 则遇失败仍继续执行剩余操作,已成功部分保留(返回 "N succeeded, M failed")。
**公式相关批处理的完成流程**:
- 写前:先读 `references/lark-sheets-formula-translation.md`,把公式改写成飞书可执行语义。
- 写时:用 `+batch-update` 一次性完成插行/写公式/复制模板等成套动作。
- 写后:回读关键公式,并对本次公式范围逐段运行 `+formula-verify --exit-on-error`,全部 success 后完成;AI 公式改用 `+formula-verify --ai-only`,按 `references/lark-sheets-formula-verify.md` 的全区间一次异步状态检查规则交付。
**`+dropdown-update` 的选项模式(`--options` / `--source-range` 二选一)+ 配色规则**(更新会重写完整验证规则;需要保留已有配色时先回读并透传 `--colors`)见 [`references/lark-sheets-write-cells.md`](lark-sheets-write-cells.md) 的「Dropdown 选项 + 配色」节,本文不重复。`+dropdown-delete` 不涉及这些 flag。
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+batch-update` | high-risk-write | 批量 |
| `+batch-chart-create` | write | 批量 |
| `+batch-chart-update` | write | 批量 |
| `+dropdown-update` | write | 对象 |
| `+dropdown-delete` | high-risk-write | 对象 |
| `+cells-batch-clear` | high-risk-write | 批量 |
## Flags
### `+batch-update`
_公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--operations` | string + File + Stdin(复合 JSON) | required | JSON 数组:[{"shortcut":"+xxx-yyy","input":{...}}, ...]。shortcut 用 CLI 名;input 是该 shortcut 的入参集——含子表定位 sheet_id(或 sheet_name),但不含 spreadsheet token/url(后者只在顶层 --url/--spreadsheet-token 给一次;+batch-update 顶层没有 --sheet-id);input 的键是该 shortcut 的 flag 展平成 JSON(如 "range":"A11:B12"),不是再套一层嵌套。基础 flag 查 --help,复合 JSON flag 查 --print-schema --flag-name <flag>;不要手填 operation 字段(由 CLI 按 shortcut 自动注入)。默认 fail-fast:首个失败即中断剩余操作;此前子操作是否已落盘**不统一**(纯单元格/结构写入失败时整体不落盘,图表/透视表等对象子操作会提前把累计写入落盘且自身无法回滚),失败后不要假设全回滚或全保留——先看 results 再回读现状确认已生效集合,只补发未生效部分;传 --continue-on-error 遇失败仍继续、已成功部分保留;不支持嵌套;按数组顺序串行执行 |
| `--continue-on-error` | bool | optional | 遇子操作失败时继续执行剩余操作;默认 false(首个失败即整批中断) |
### `+batch-chart-create`
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--operations` | string + File + Stdin(复合 JSON) | required | 图表创建操作 JSON 数组;每项直接填写 `+chart-create-basic` 的 flag 和目标 sheet 定位,不要再套 `shortcut` / `input`。CLI 内部固定使用 `+chart-create-basic`。默认允许部分失败,成功图表保留,只重试失败项 |
| `--continue-on-error` | bool | optional | 单个图表失败后是否继续;默认 true |
### `+batch-chart-update`
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--operations` | string + File + Stdin(复合 JSON) | required | 图表更新操作 JSON 数组;每项使用 `+chart-config-update` 或 `+chart-data-update`,input 传对应命令的 flag 集合和目标 sheet 定位。CLI 会先读取各图表当前快照,再生成 partial properties;默认允许部分失败 |
| `--continue-on-error` | bool | optional | 单个图表失败后是否继续;默认 true |
### `+dropdown-update`
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(最多 100 个,如 `["Sheet1!A2:A100","Sheet1!C2:C100"]`,前缀裸写不加引号),每项必须带 sheet 前缀;前缀必须与 sheet 真实显示名完全一致(含大小写),不接受 sheet reference_id |
| `--options` | string + File + Stdin(复合 JSON) | xor | 下拉选项 JSON 数组,例如 `["opt1","opt2"]`。服务端不限制选项数量,也不限制单个选项长度;含逗号的选项可以接受(写入时会自动转义)。大量选项建议改用 `--source-range`。 |
| `--colors` | string + File + Stdin(简单 JSON) | optional | 下拉胶囊背景色,RGB hex 数组。更新会重写整条验证规则:若用户未要求重置配色,先用 `+dropdown-get` 回读并将现有 `highlight_colors` 作为本 flag 传回;省略会按内置 10 色色板重建。用户明确要求新配色或选项有清晰语义配色时,应选浅色、低饱和度背景以适配黑色文字。长度可短不可长——超长 Validate 拦截(`--colors length (N) must not exceed dropdown source size (M)`),未指定项按内置色板循环补色。单独传即生效;`--highlight=false` 时被忽略。 |
| `--multiple` | bool | optional | 启用多选。本 flag 只更新验证规则,不会写入选中值;后续用 `+cells-set` 写值时必须传 `multiple_values` 数组,不要传逗号拼接的 `value` |
| `--highlight` | bool | optional | 下拉胶囊背景色高亮开关。**不传 = 开**(按内置 10 色色板循环上色);`--highlight=false` 关闭得到纯白下拉。配色用 `--colors` 覆盖。 |
| `--source-range` | string | xor | listFromRange 模式的下拉源 range,A1 表示法 + sheet 前缀(如 `'Sheet1'!T1:T3`)。映射到 server `data_validation.range`,搭配 server `data_validation.type='listFromRange'` 自动生效。跟 `--options` 二选一:传 `--options` 走 inline 列表(type=list),传本 flag 走 range 引用(type=listFromRange)。`--colors` 长度规则不变(≤ 源 range 单元格数),`--highlight` / `--multiple` 行为相同。当 `--highlight` 开启且 source 覆盖单元格数超过 2000 时,服务端会将该下拉判为 option-error(这是不支持的组合);CLI 会在返回结果的 `data.warnings` 中给出 warning。如需取消,传 `--highlight=false`。 |
### `+dropdown-delete`
_公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(最多 100 个,如 `["Sheet1!E2:E6"]`,前缀裸写不加引号),每项必须带 sheet 前缀;前缀必须与 sheet 真实显示名完全一致(含大小写),不接受 sheet reference_id |
### `+cells-batch-clear`
_公共:URL/token(无 sheet 定位) · 系统:`--yes`、`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--ranges` | string + File + Stdin(简单 JSON) | required | 目标范围 JSON 数组(最多 100 个),每项必须带 sheet 前缀(如 `["Sheet1!A2:Z1000","Sheet2!A2:Z1000"]`,前缀裸写不加引号);前缀必须与 sheet 真实显示名完全一致(含大小写),不接受 sheet reference_id;支持跨 sheet;对所有 range 执行同一 scope 的清除 |
| `--scope` | string | optional | 清除范围 enum:`content`(默认,仅清内容)/ `formats`(仅清格式)/ `all`(清内容 + 格式)(可选值:`content` / `formats` / `all`) |
## Schemas
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
### `+batch-update` `--operations`
_要批量执行的 CLI shortcut 操作列表,按声明顺序串行执行;任一失败立即中断_
**数组项**(类型 object):
- `shortcut` (enum) — CLI shortcut 名(不是底层 MCP tool 名) [+cells-set / +cells-set-style / +cells-clear / +cells-merge / +cells-unmerge / +cells-replace / +csv-put / +dropdown-set / +dim-insert / +dim-delete / +dim-hide / +dim-unhide / +dim-freeze / +dim-group / +dim-ungroup / +rows-resize / +cols-resize / +range-move / +range-copy / +range-fill / +range-sort / +sheet-create / +sheet-delete / +sheet-rename / +sheet-move / +sheet-copy / +sheet-hide / +sheet-unhide / +sheet-set-tab-color / +sheet-show-gridline / +sheet-hide-gridline / +chart-create / +chart-update / +chart-delete / +chart-create-basic / +chart-config-update / +chart-data-update / +pivot-create / +pivot-update / +pivot-delete / +cond-format-create / +cond-format-update / +cond-format-delete / +filter-create / +filter-update / +filter-delete / +filter-view-create / +filter-view-update / +filter-view-delete / +sparkline-create / +sparkline-update / +sparkline-delete / +float-image-create / +float-image-update / +float-image-delete]
- `input` (object) — 该 shortcut 的入参集——含子表定位 sheet_id(或 sheet_name)
### `+batch-chart-create` `--operations`
**数组项**(类型 object):
- `sheet_id` (string?) — 目标子表 ID;与 sheet_name 二选一
- `sheet_name` (string?) — 目标子表名;与 sheet_id 二选一
- `chart_type` (enum) [column / bar / line / area / pie / scatter / combo / radar / bubble / waterfall / pareto]
- `data_range` (string)
- `header_range` (string?)
- `data_direction` (enum?) [row / column]
- `dim1_index` (integer?)
- `dim2_indexes` (oneOf?)
- `series_types` (oneOf?)
- `series_y_axes` (oneOf?)
- `key_index` (integer?) — 气泡图标识/名称维度的 1-based 索引;默认 1
- `x_index` (integer?) — 气泡图 X 值维度的 1-based 索引;与 y_index 同时提供
- `y_index` (integer?) — 气泡图 Y 值维度的 1-based 索引;与 x_index 同时提供
- `group_index` (integer?) — 气泡图可选分组维度的 1-based 索引
- `size_index` (integer?) — 气泡图可选气泡大小维度的 1-based 索引
- `title` (string?)
- `anchor_cell` (string?)
### `+batch-chart-update` `--operations`
**数组项**(类型 object):
- `shortcut` (enum) [+chart-config-update / +chart-data-update]
- `input` (object) — 对应图表更新 shortcut 的 flag 集合;包含 sheet_id 或 sheet_name,不包含 spreadsheet token/url
### `+dropdown-update` `--options`
_列表选项_
**数组项**(类型 string):
- 标量:string
## Examples
公共四件套:`--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(前两者 XOR;`+batch-update` 本身不强制 sheet-id,子操作各自携带)。
### `+batch-update`
示例:
```bash
lark-cli sheets +batch-update --url "https://example.feishu.cn/sheets/shtXXX" --yes \
--operations @ops.json
# ops.json (array<{shortcut, input}>,shortcut 用 CLI 名):
# [
# {"shortcut": "+dim-insert", "input": {"sheet_id":"...","position":10,"count":3}},
# {"shortcut": "+cells-set", "input": {"sheet_id":"...","range":"A11:B12","cells":[[{"value":"a"},{"value":"b"}],[{"value":"c"},{"value":"d"}]]}}
# ]
```
> ⚠️ **子操作定位规则**:
> - spreadsheet 定位(`--url` / `--spreadsheet-token`)**只在顶层给一次**;`+batch-update` 顶层**没有** `--sheet-id` / `--sheet-name`,在顶层传不生效。
> - **每个子操作的子表定位 `sheet_id`(或 `sheet_name`)写进它自己的 `input`**(见上方 ops.json 每个 item)。
> - `input` 的键是该 shortcut 的 flag **展平**成 JSON(`"range":"A11:B12"`、`"position":11`),不要把整组 `--operations` 再套一层嵌套 JSON。
> **常见组合:插列 + 写表头 + 整列回填**——一次批量提交,不要拆成 N 次独立调用。批量回填同一列 **只需一次** `+cells-set`(range 写整列范围、cells 写 N×1 矩阵),不需要逐行循环。
>
> ```jsonc
> // 在 C 列前插入新列 → 写表头 C1 → 回填 C2:C100 共 99 行
> [
> {"shortcut": "+dim-insert",
> "input": {"sheet_name": "Sheet1", "position": "C", "count": 1}},
> {"shortcut": "+cells-set",
> "input": {"sheet_name": "Sheet1", "range": "C1:C100",
> "cells": [[{"value":"score"}], [{"value":95}], [{"value":87}], /* ... 97 more rows ... */ ]}}
> ]
> ```
> **多图表组合**:先完成全部辅助数据,再把每张图的输入放进 `+batch-chart-create`;每项同时记录精确表头范围、数据方向和预期系列数。批次完成后,每个受影响的 sheet 各调用一次 `+chart-list`。已有图表的批量修正改用 `+batch-chart-update`。
>
> ```json
> [
> {"sheet_name":"Sheet1","chart_type":"column","data_range":"'Sheet1'!A1:C10","title":"分类对比","anchor_cell":"F2"},
> {"sheet_name":"Sheet1","chart_type":"line","data_range":"'Sheet1'!E1:G10","title":"趋势变化","anchor_cell":"F18"}
> ]
> ```
>
> ```bash
> lark-cli sheets +batch-chart-create --url "..." --operations @ops.json
> ```
### `+cells-batch-clear`
多 range 一次性清除(服务端走 `+batch-update` 批量提交,fail-fast,失败处置见下方「执行语义」);`--scope` 同 `+cells-clear`(`content` / `formats` / `all`,默认 `content`),`high-risk-write` 强制 `--yes`:
```bash
# dry-run 先看清除范围
lark-cli sheets +cells-batch-clear --url "..." \
--ranges '["sheet1!A2:Z1000","sheet2!A2:Z1000"]' --scope all --dry-run
# 执行
lark-cli sheets +cells-batch-clear --url "..." \
--ranges '["sheet1!A2:Z1000","sheet2!A2:Z1000"]' --scope all --yes
```
### Validate / DryRun / Execute 约束
- `Validate`:`+batch-update` 的 `--operations` 必须合法 JSON,且为非空数组;逐个子操作 `shortcut` / `input` 字段必填校验,input 键必须在该 shortcut 的 flag 词汇表内(未知键报错并提示最近似键与完整键契约);**校验错误聚合上报**——所有子操作的首错一次性返回,全部修完再重发一次即可;**禁止嵌套 `+batch-update`**。`+cells-batch-clear` 的 `--ranges` 必须 JSON 数组、每项带 sheet 前缀,`high-risk-write` 强制 `--yes` 或 `--dry-run`(`--scope` 默认 `content`)。
- `DryRun`:按顺序输出每个子操作的目标 API + 请求 body 模板,不发起调用。
- `Execute`:按声明顺序串行执行;默认 fail-fast。失败时已成功子操作不回滚,先按子操作类型回读现状,只重发失败起的剩余子集;成功时也完成上述分流验证。
references/lark-sheets-changeset.md›
# Lark Sheet Changeset
## 使用场景
读取两个版本之间的 **changeset(变更操作清单)**,用于**复核某次编辑(尤其是 AI 编辑)是否真实满足用户诉求**。
典型场景:AI agent 对表格做了一批编辑后,想确认它"说做的"和"真正落到表格上的"是否一致——拉取编辑前版本到编辑后版本之间的 changeset,逐条核对 action 是否覆盖了用户要求的修改、有没有多改 / 漏改。
## 版本(revision)语义
- 这里的"版本"指表格的 **CS revision**(每次提交单调递增的修订号),不是文档历史里的命名版本。
- `--start-revision` 是复核基线,即你认定的"编辑前"版本。
- `--end-revision` 是"编辑后"版本;**省略时默认取最新 revision**,返回从 start 到最新的全部 changeset。
- **版本差上限 20**:`end - start + 1 ≤ 20`,超出会被拒绝(服务端同样以 20 兜底)。复核大跨度变更时请分段拉取。
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+changeset-get` | read | 变更记录 |
## Flags
### `+changeset-get`
_公共:URL/token(无 sheet 定位)_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--start-revision` | int | required | 起始版本(编辑前基线,>= 1) |
| `--end-revision` | int | optional | 结束版本(省略取最新) |
## 返回结构
返回一个 JSON 对象,`changesets` 数组按版本顺序排列,每个元素是一次提交的**原始 action 列表**与元信息:
```json
{
"spreadsheet_token": "shtcnXXXX",
"latest_revision": 142,
"start_revision": 120,
"end_revision": 135,
"changesets": [
{
"revision": 121,
"create_time": "2026-06-12T10:00:00Z",
"actions": [
{ "action": "setCellRange", "sheetId": "...", "value": { /* ... */ } }
],
"is_self_edit": false,
"is_ai_edit": true
}
]
}
```
- 最外层 `latest_revision` 是**当前表格的最新版本号**(与查询区间无关),便于判断表格当前停在哪个版本、`--start-revision` 该取多少。
- `actions` 是**未经语义渲染的原始操作对象**,按提交内的执行顺序排列。复核时逐条比对:每个 action 改了哪个 sheet、哪个区域、改成什么,是否对应用户的诉求。
- `revision` / `create_time` 用于判断"这次改动属于哪个版本、什么时候做的"。
- `is_self_edit` 表示该 changeset 是否由当前请求用户提交(committer 与请求用户相同),即"是不是我自己提交的编辑"。
- `is_ai_edit` 表示该 changeset 是否由 AI 客户端提交(`member_id` 为 10 / 11)。复核时 `is_ai_edit=true` 即为 AI 写入的编辑(而非用户手动编辑),是核对 AI 是否完成诉求的主要对象。
## 复核工作流(判断 AI 是否真实完成诉求)
1. 记下 AI 开始编辑前的 revision(编辑前 `+workbook-info` 或上一次工具返回的 revision 即可作为 `--start-revision`)。
2. AI 编辑完成后,跑 `+changeset-get --url <表格> --start-revision <编辑前版本>`(不传 end → 取到最新)。
3. 遍历 `changesets[].actions`,核对:
- 用户要求的每一处修改是否都有对应 action;
- 有没有越权 / 多余的修改(动了用户没让动的 sheet / 区域);
- action 的目标区域、值是否与诉求一致。
4. 若版本跨度可能 > 20,分段拉取(如 `start..start+19`、`start+20..` …)。
## 注意
- `+changeset-get` 是**只读**操作,不改动表格。
- 大跨度 / 大批量编辑的 changeset 可能体积较大;输出在传输层已 gzip。必要时缩小版本区间。
- 该工具走只读 scope `sheets:spreadsheet:read`,需要对表格有查看权限。
## Examples
### `+changeset-get`
公共:`--url` / `--spreadsheet-token`(二选一,无 sheet 定位)。changeset 是工作簿级历史,不接受 sheet 定位 flag。
示例:
```bash
# 只传起始版本 → 返回从该版本到最新的全部 changeset(最常用:复核 AI 编辑前后的差异)
lark-cli sheets +changeset-get --url "https://example.feishu.cn/sheets/shtXXX" --start-revision 120
# 传起始 + 结束版本(版本差 end-start+1 ≤ 20)
lark-cli sheets +changeset-get --spreadsheet-token shtXXX --start-revision 120 --end-revision 135
```
输出契约(envelope.data):
- `latest_revision` — 当前表格最新版本号(与查询区间无关)
- `start_revision` / `end_revision` — 实际查询区间(省略 `--end-revision` 时 `end_revision` = 最新版本)
- `changesets[]` — 按版本顺序排列;每项含 `revision` / `create_time` / `actions`(原始操作列表)/ `is_self_edit` / `is_ai_edit`
### Validate / DryRun / Execute 约束
- `Validate` 阶段只做 XOR 检查(`--url` / `--spreadsheet-token` 二选一)与版本上限校验(`--start-revision ≥ 1`,传了 `--end-revision` 时 `end ≥ start` 且 `end - start + 1 ≤ 20`);**禁止**联网。
- `DryRun` 输出请求模板,不实际拉取 changeset。
- `Execute` 阶段才发起 changeset 查询;省略 `--end-revision` 时由服务端解析为最新 revision。references/lark-sheets-chart.md›
# Lark Sheet Chart
## 真对象硬约束
当用户要求"画个图 / 数据可视化 / 趋势图 / 对比图 / 占比图"时,**必须**通过图表创建命令创建真实的图表对象。**禁止**用本地脚本调 matplotlib / seaborn 生成图片再插入到表格代替——静态图片无法随源数据更新,且失去交互能力。判断标准:最终对象必须能被 `+chart-list` 返回;基础单图可先用创建调用返回的完整 `snapshot` 验证,批量创建必须按受影响的 sheet 回读列表。
## 使用场景
读写图表对象。基础创建和常用更新优先用语义 shortcut,只在高级配置时使用原始 snapshot:
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 查看已有图表 | `+chart-list` | 获取图表的类型、数据源和样式配置 |
| 按类型和范围创建基础图 | `+chart-create-basic` | 支持 column/bar/line/area/pie/scatter/combo/radar/bubble/waterfall/pareto、行/列方向与整图配色;无需构造 snapshot |
| 更新标题、轴、图例、标签、堆叠、平滑或整图配色 | `+chart-config-update` | CLI 读取当前快照并只回写配置 patch |
| 修正已有图表的数据范围或方向 | `+chart-data-update` | CLI 读取当前快照并只回写 data patch,保留其它配置 |
| 批量创建多个独立图表 | `+batch-chart-create` | 保留成功图表,并逐项返回失败原因;只重试失败项 |
| 批量更新多个独立图表 | `+batch-chart-update` | 逐图读取当前快照并生成 partial properties |
| 高级创建/更新、删除图表 | `+chart-{create\|update\|delete}` | 按系列/数据点精细设置等高级需求才使用原始 properties;更新只提交必要的局部 properties |
## 统一决策顺序
明确目标后,始终按以下顺序选入口,不从原始 snapshot 起步:
1. 普通单图创建 → `+chart-create-basic`;
2. 多张独立图创建 → `+batch-chart-create`;
3. 已有图的数据源 / 方向 / 系列变化 → `+chart-data-update`;
4. 已有图的标题 / 轴 / 图例 / 标签 / 堆叠 / 平滑 / 整图配色变化 → `+chart-config-update`;
5. 只有上述语义 shortcut 无法表达的单系列、单数据点或高级字段,才使用 `+chart-create` / `+chart-update` 的原始 `properties`。
进入高级入口前先写明“哪个用户要求无法由哪个语义参数表达”。答不出来就退回语义 shortcut。不要因为语义调用失败一次就改走原始 snapshot;先根据明确错误修正参数。
普通创建、数据源修正和常用配置更新不要构造原始 snapshot。
典型工作流:先确认表头、精确数据范围和图表配置,运行 `python3 scripts/lark_chart_size_advisor.py` 取得建议尺寸,再将返回的 `data.create_flags.width` / `height` 原样传给 `+chart-create-basic`;创建时尽量在同次调用中带上已知标题/轴/标签内容要求,标签位置只有用户明确指定时才传。创建后用返回的完整 `snapshot` 检查范围、方向与系列,再按需用 `+chart-list` 验证。已有图表的数据范围或方向错误时用 `+chart-data-update`,常用配置修正用 `+chart-config-update`。只有用户要求单个系列、数据点或高级引擎字段时,才读取现有 snapshot 并调 `+chart-update --properties`。不要为了常用配置先输出整份 schema,也不要删除重建已经创建成功的图表。
**多图表工作流**:先完成所有辅助数据和表头,列出每张目标图的类型、精确数据范围、标题和落点;确认清单后,用一次 `+batch-chart-create` 批量创建。它的每个 operation 直接填写 `+chart-create-basic` flags,CLI 内部固定按 `+chart-create-basic` 执行,不要再套 `shortcut` / `input`。图表之间独立时允许部分成功:按返回的逐项结果定位失败图表,只重试失败项。批量 create 的逐项结果不返回完整 snapshot;批次后每个受影响的 sheet 各调用一次 `+chart-list`。已经成功创建的图表有数据源或配置差异时,用 `+batch-chart-update` 批量执行对应的语义更新,不要删除重建。
**图表错误处理工作流(必须按顺序)**:
1. **基础单图走快路径**:sheet、范围、类型和落点都明确时,直接调用 `+chart-create-basic`,并检查返回的完整 `snapshot`;不要为了预览而固定多做一次 `--dry-run`。
2. **以下情况创建前必须 `--dry-run`**:批量创建、多范围或跨子表数据源、包含“每个 / 分别 / 逐一”等数量词、落点不确定,或确实需要原始高级配置。检查数量、sheet、范围、类型和落点;输出中的 `tool_name` / `operation` / `basic_chart` / `properties` 是 CLI 翻译后的内部 MCP body,**只能读,不能复制回 operations**。
3. 批量执行后同时检查 `succeeded`、`failed` 和逐项 `results[index]`;命令退出成功或顶层 `ok=true` 不代表每张图都成功。单图则检查返回的 `snapshot`。
4. 有失败时保留成功图表,按原始 `index` 重新生成只包含失败项的新 operations。禁止复用原始整批 payload,否则会重复创建已经成功的图表。
5. 批量成功后每个受影响 sheet 只调用一次 `+chart-list`,核对总数、标题、范围、方向与系列;基础单图的返回 `snapshot` 完整且符合预期时不再重复 list,只有响应不完整、后续又更新或结果存疑时再 list。
6. 快照不符合预期时原地修复:数据源、方向、维度/系列、分离表头用 `+chart-data-update`;标题、轴、图例、标签、堆叠、平滑、配色用 `+chart-config-update`;只有高级字段才用 `+chart-update --properties` 的最小局部 patch。不要删除重建。
**失败归因与恢复**:
- 参数校验失败:只根据 stderr 指出的未知 flag、缺失字段或 operations 结构修正一次;不要把 `--dry-run` 展示的内部 body 复制回命令。
- 批量部分失败:保留成功项,只重试 `failed` 对应的原始 index;重试前断言新 operations 数量等于失败数。
- 执行成功但结果不符:以返回 snapshot / `+chart-list` 为准,在原图上走语义更新;不要因标题、范围或配色不对就删除重建。
- 返回空输出或无法确认:检查退出码和 stderr,并做一次对象回读;仍无法确认时如实报告,禁止声称已完成。
- 同一种修正再次失败:停止改猜 schema、MCP body 或完整 snapshot。若语义 shortcut 能表达就回到语义入口;否则保留原对象并报告明确错误。
**图片图表 → 真图表迁移(“把截图 / 贴图换成真图表”类任务)**:
1. 先用 `+float-image-list` 读取待替换浮动图片的 ID、位置、尺寸和数量,并确认每张图片与目标真图表的对应关系;不得把 logo、说明图或无法确认对应关系的图片当成待替换图表。
2. 用户要求“配色 / 样式与原图一致”时,必须先使用可用的图像理解能力视觉检查原图,确认图表类型、标题、系列配色、图例、标签、堆叠方式、位置和尺寸;`+float-image-list` 只用于获取对象信息,不能代替视觉检查。对无法确认的样式不得凭空猜测。
3. 优先用 `+chart-create-basic` / `+batch-chart-create` 的语义参数复刻已确认的类型、标题、配色、图例、标签和堆叠方式,并尽量按原图位置与尺寸落图。普通整图配色使用 `--colors` / `--color-palette`;只有原图明确包含语义 shortcut 无法表达的单系列或单数据点样式时,才使用原始 `properties`。
4. 建好真图表后,必须先用创建返回的完整 `snapshot` 或 `+chart-list` 确认图表数量、标题、数据源、系列和位置正确,再按 [Lark Sheet Float Image](./lark-sheets-float-image.md) 的高风险删除流程用 `+float-image-delete` 删除与其一一对应的原浮动图片。
5. 删除后再调用一次 `+float-image-list`,确认被替换图片已消失,其它图片未受影响。
**数量词必须展开**:用户说“每个 / 每天 / 分别 / 逐一 / 各一张图”时,先从数据中数出实体数 `N`,把这 `N` 张图逐项写进清单,再加上其它汇总图得到目标总数 `M`;一个包含全部实体的多系列图不能替代这 `N` 张独立图。批次前断言 operations 中恰有 `M` 个图表创建,批次后 `+chart-list` 断言图表总数、逐图标题与实体集合一致。**图表类型和维度也要逐图断言**:用户点名的图表类型(折线 / 柱状 / 堆积 / 饼)、横轴取哪一列、按哪一列分组,动手前写成清单,画完逐项核回来——多张单维度图不能替代一张按维度分组的图,反之亦然。
**范围与系列前置校验(创建前必做)**:清单中同时记录每张图的表头范围、纳入维度、明确排除维度、数据方向和预期系列数。每张图只支持一个类别 / X 轴维度(`dim1`),不支持把多个字段作为多级横轴;当前每张图**最多 50 个数值系列**;按列组织时通常为“所选数值列数”,按行组织时通常为“所选数值行数”。创建时就用 `+chart-create-basic --dim1-index ... --dim2-indexes ...` 显式选择类别与不超过 50 个数值系列;如果业务要求展示超过 50 个系列,应先建立紧凑汇总表或 Top-N,而不是反复删除重建。创建前根据实际表头确认索引和边界,不凭字母猜范围;创建后范围、方向或系列数不符时,使用 `+chart-data-update` 修正,CLI 会读取当前快照、重建 `refs` / `dim1` / `dim2.series` 并只提交 data patch,不要删除后重建。
**尺寸建议(创建前必做)**:确认 `--chart-type`、`--data-range`、数据方向、dim1/dim2、标题、图例和标签策略后,先运行尺寸建议器。有分离表头时同时传 `--header-range`。
硬下限如下;建议器不可用时也不得低于此值:
| 图表类型 | 最小宽度 × 高度(px) |
|---|---:|
| 柱形图、折线图、面积图及其它默认类型 | `640 × 400` |
| 条形图、组合图 | `720 × 420` |
| 饼图 | `720 × 440` |
```bash
python3 scripts/lark_chart_size_advisor.py "<表格 URL 或 spreadsheet token>" \
--worksheet-id "<reference_id>" \
--chart-type column --data-range "'Sheet1'!A1:C10" \
--dim1-index 1 --dim2-indexes 2,3 \
--data-labels value --legend-position bottom --title "销售额对比"
```
运行建议器时,参数必须与后续创建保持一致:创建命令显式设置 `--aggregate-categories` 时传入同一值,组合图同步传入 `--series-types`;创建命令不传 `--data-labels` 时,建议器也按 `none` 估算,需要标签时两边都显式传入同一值。将返回的 `data.create_flags.width` / `height` 原样用于创建命令(包括 `--dry-run`),不要凭经验改小;`data.minimum_size` 仅表示兜底下限。若 `data.size_alone_is_insufficient=true`,先按 `data.layout_advice` 调整图表结构或标签策略,再用新配置重新计算尺寸。建议器只负责创建前预估,图表创建后仍须运行质量检查器。
**坐标轴语义与范围**:所有带坐标轴的图表都要在清单中记录每条轴对应的字段语义、类别轴 / 连续轴类型、单位以及主副轴归属,不能只核对轴标题。Y 轴显示范围默认交给图表引擎;用户未明确要求固定范围时,不传 `--y-axis-min` / `--y-axis-max`,需要固定范围时必须同时传上下界,重点只处理确有必要收紧的连续数值 X 轴。堆积图的峰值来自同一类别内系列累加,组合图还要按左右轴分别计算;不得直接把数据源单列的最小值 / 最大值当成 Y 轴边界。瀑布图的显示范围取决于逐项累计后的全部中间值、小计和总计,不得主动传 `--y-axis-min` / `--y-axis-max`;只有用户明确指定固定范围时才能例外,且必须覆盖所有累计节点。其它图表只有在用户明确要求或视觉验收证明自动范围不可读时,才按图表类型的实际绘制值计算并设置 Y 轴范围。多图对比时,先判断“范围 / 尺度一致”指绝对边界相同,还是跨度和刻度可比;对比同一指标时保持值轴口径一致,不同单位或量级的指标不强行共用边界。
**横向类别行配方**:当日期/月份等类别横向排列在一行、目标数值在另一行时,把“类别行 + 数值行”一起放进 `--data-range` 并传 `--data-direction row`,例如 `--data-range "'Sheet1'!A1:M1,'Sheet1'!A3:M3" --data-direction row`。此时类别行属于数据映射,**不要**传给 `--header-range`。`--header-range` 仅表示与纯数据分离的“维度/系列名称”:column 方向必须是一行,row 方向必须是一列。row 方向却传入多列表头,通常说明把类别行误当成了分离表头。
**整图配色优先走语义参数**:统一主题或系列配色用 `--color-palette` / `--colors`,已有图用 `+chart-config-update`;优先继承原表主题,同一指标跨图保持同色,组合图用同色系柱形、高对比折线和中性辅助线。`--colors` 会循环复用,明确逐系列配色时颜色数须与系列数一致。颜色过多难以区分时优先 Top-N 或拆图;单系列/数据点配色才使用原始 snapshot。
## 需求→图表类型映射(创建前必查)
| 用户说 | 图表类型 | 备注 |
|--------|---------|------|
| "占比"、"比例"、"各XX占多少" | 饼图(pie) | 单维度占比首选 |
| "对比"、"各XX的YY" | 柱形图(column,纵向) | 多类别数值对比;横向条形用 `bar` |
| "趋势"、"变化"、"走势" | 折线图(line) | 时间序列首选 |
| "趋势与量级"、"累计变化"、"区间规模" | 面积图(area) | 用面积强调趋势与数值量级 |
| "堆积"、"组成构成" | 堆积柱形图(column + stack) | 多系列累加 |
| "簇状堆积柱形图" | 堆积柱形图(column + stack) | 当前不支持原生簇状堆积;将簇状维度拆分到横轴类别,用堆积柱状图实现类似效果 |
| "分布"、"相关性" | 散点图(scatter) | 两变量关系 |
| "气泡大小"、"三变量关系"、"分组散点" | 气泡图(bubble) | x/y 决定位置,size 决定气泡大小,group 决定分组 |
| "逐项增减"、"变动贡献"、"从期初到期末" | 瀑布图(waterfall) | 展示正负变化及总计/小计;通常选一个分类列和一个增减值列 |
| "主要原因"、"累计占比"、"80/20" | 排列图(pareto) | 降序柱形 + 累计百分比曲线;只允许一个数值系列 |
**多图表需求**:当用户同时提到多种分析(如"统计占比 + 对比数量"),必须创建多个图表,每个对应一种类型,不要只做一个。
**常见配置错误(必须注意)**:
- **图表类型选择错误**:用户说"堆积柱形图 / 百分比堆积"时,用 `+chart-create-basic --stack normal|percent` 或 `+chart-config-update --stack normal|percent`;用户说"占比 / 比例"时,优先考虑饼图或百分比堆积图。注意 `column` 是纵向柱形图、`bar` 是横向条形图,"对比 / 各 XX" 类纵向柱默认用 `column`;面积图原生支持 `snapshot.plotArea.plot.type="area"`,别因速查表没列就判"不支持"。
- **数据标签开关**:普通基础图先按拟开启 `--data-labels value` 运行尺寸建议器,再用建议宽高创建;不要仅凭数据点或系列数预先传 `none`。若使用建议尺寸后仍过密,依次改为关键点 / 末值 / 异常值的稀疏标签、Top-N 或拆图;用户明确要求隐藏全部标签时才传 `none`。已有图用 `+chart-config-update --data-labels`,不要为常用标签配置构造原始 `labels` 对象。高级配置中 `plotArea.plot.labels` 对象的存在性即开关:创建时关闭标签应省略该字段,更新时删除已有全局标签传 `labels: null`,不能用全部字段置为 `false` 代替。多个系列的数据标签展示要求不同时,禁止传全局 `--data-labels`,应在创建后读取完整 `plotArea.plot.series`,仅给需要标签的系列设置 `labels`,再用 `+chart-update --properties` 整段回写该数组。
- **辅助线与单点标签**:用户要求基准线、目标线、阈值线、平均线或上下限时,先在源数据旁新增一列重复目标值作为辅助线;如果只需要在线尾或某个关键位置显示一个标签,再新增一列稀疏标点数据,仅在目标行写入同一数值,其余单元格保持真正空白。数据准备完成后创建组合图:辅助值列用 `line`,稀疏标点列用 `scatter`,省略全局 `--data-labels`,并传 `--aggregate-categories=false` 关闭“汇总相同类别”;已有图用 `+chart-config-update --aggregate-categories=false`。随后读取完整系列数组,只给稀疏标点系列设置数值标签,辅助线系列必须省略 `labels`;原数据系列是否设置标签按用户要求决定。不得用重复值辅助线的全系列标签模拟单点标签,也不得用 0 代替空白标点,否则聚合会把空标点物化为每个类别的数据点,导致标签重复出现。
- **常量系列标签**:目标线、阈值线和上下限等重复常量系列默认不显示逐点标签;名称和值放在系列名、图例、标题或单个稀疏标记中。创建后若质量检查器提示“常量系列重复标签”,移除该系列标签或改成只有一个非空点的稀疏标记。
- **数据标签位置**:只有用户明确要求且已有标签时才传 `--data-label-position`;它只调整已有标签的位置,不会单独开启标签。需要同时显示标签时一并传 `--data-labels`;未明确位置时省略,让图表按类型自动选择。标签位置只控制摆放方式,不能实现仅显示末点或关键点。普通非堆叠柱形图显示数据标签位置一般传 `outside`。
- **数据源范围与系列名来源要对齐**:
- 默认让 `--data-range` 包含真正的表头行 / 列;表头上方的合并大标题必须跳过。
- 数据和语义表头分离时,`--data-range` 只传纯数据,`--header-range` 传对应的一行(column)或一列(row)表头。范围可以是不连续多范围,也支持来自多个子表;不要因为跨子表就退回原始 snapshot。
- 横向类别行属于 `--data-range`,不是 `--header-range`;按行组织时传 `--data-direction row`。
- **数据源必须是数值 / 日期型**:图表只渲染数值型单元格。用 `+cells-set` 构造数据源时,给数字 / 日期单元格设 `cell_styles.number_format`,不要留成纯文本,否则该系列渲染为空。
- **数值 / 日期显示异常**:坐标轴沿用源单元格格式。日期显示成序列号、大数值显示成科学计数法时,修正源数据的 `cell_styles.number_format`,不要给图表轴构造未定义的 format 字段。
- **轴口径错误**:用户要"占比 / 比例"时,用饼图或 `--stack percent`,并核对数据源与标签确实表达百分比,不要交付仍以原始计数为纵轴的图。
- **组合图系列被压扁**:创建前比较各系列的单位和典型值 / 峰值量级;单位不同、相差约一个数量级以上,或折线贴近 X 轴时,不得把所有系列都放左轴。用 `--series-y-axes` 将会被压扁的系列(常见为百分比、比率或小量级折线)放到右轴,并用左右轴标题明确各自单位;`--series-types` / `--series-y-axes` 必须与 `--dim2-indexes` 逐项对齐。
- **饼图标签截断**:饼图默认传 `--legend-position bottom`,并使用比普通单图更宽的画布;创建时同时传 `--width` / `--height`。宽度主要为左右两侧最长标签留白,不因类别数量线性增加;类别过多时改用 Top-N 或条形图,不能靠无限加宽或截断标签交付。
- **对象语义验证**:基础单图先核对返回的完整 `snapshot`;批量创建、响应不完整、后续又更新或结果存疑时,再按受影响的 sheet 调一次 `+chart-list`。这里只核对数量、数据源、方向、系列和配置,不能代替交付前的布局检查。
> **⚠️ 硬性规则:当用户通过列标题名称(而非列索引)指定横轴/纵轴系列时,必须先读取表格首行(表头)来确定列名与列索引的对应关系,再设置普通图表的 `--dim1-index` / `--dim2-indexes` 或气泡图的角色索引。**
> 例如用户说"横轴为车型系列,纵轴为 Q1-Q4 的销量",不能猜测列索引;先用 `+cells-get` 读取数据源范围的表头,再将确认后的 1-based 索引传给 `+chart-create-basic`。
## ⚠️ chart 数据源引用 pivot 时必须排除总计行
当 chart 要基于刚创建的 pivot 产物画图时,**禁止凭猜写 `refs`**。pivot 默认启用 `show_row_grand_total` / `show_col_grand_total`,产物最后一行/一列通常是"总计"。如果 `refs` 把总计行一并框进去:
- **柱形图**末尾会多一根天文数字柱子(=所有数据求和),把其他柱子压扁到看不见
- **饼图**会多一个"总计"扇区占 33%+,真实类别的比例完全失真
**正确流程**:
1. `+pivot-create create` 返回 `sheet_id` + `pivot_table_id`
2. 调 `+csv-get(sheet_id, 'A1:E30')` 或 `+pivot-list` 读 pivot 产物的**实际数据范围**
3. 识别并排除"总计"/"小计"行(通常最后一行;嵌套 pivot 还要排除中间层小计)
4. 用 `+chart-create-basic` 创建图表,`--data-range` 精确到数据行(如 pivot 占 A1:D9、总计在 row9 → chart 用 `A1:D8`)
## 图表位置选择(创建前必做)
凭感觉挑列号/行号会被 API 拒(`position is out of sheet range`)。按以下四步走:
1. **查尺寸**:`+workbook-info` 拿该 sheet 的 `row_count` / `column_count`(下文记为 rowCount / columnCount;`+sheet-info` 只返回布局,不含行列总数)。
2. **估跨度**:默认单元格 **105 px 宽 × 27 px 高**,`needCols = ceil(width/105)`,`needRows = ceil(height/27)`。
3. **校验**:`position.row + needRows ≤ rowCount` 且 `col_idx + needCols ≤ columnCount`(`position.row` 为 **0-based**:首行 = `row:0`,与 A1 区间 / `+dim-insert --position` 的 1-based 行号不同;col 按 A=0、B=1、…、Z=25、AA=26… 换算)。
4. **不够就先扩表**,二选一,禁止硬塞越界位置:
- **优先**放数据下方空区:`position = {row: data_end_row + 2, col: "A"}`;
- 否则先调 `+dim-insert`(`references/lark-sheets-sheet-structure.md`)扩行/列,再 create。
⚠️ **图表落点禁止压在已有数据矩形内**——必须落在数据区**右侧或下方的空白**,否则图表浮层会遮挡原始数据被判失败(反例:折线图落在数据区中间,遮挡了下方原始数据)。
**示例**:21 列 sheet 放 600×400 图 → `needCols=6, needRows=15`
- ❌ `{row: 0, col: "W"}` — col=22 越界
- ✅ `{row: 42, col: "A"}` — 放数据下方
- ✅ 先 `+dim-insert --position V --count 6`(在 V 列前插 6 列,即 U 列之后),再放图到 `{row: 0, col: "V"}`
**标题与轴文案**:优先沿用用户明确指定的文案;未指定时,只根据已读取的表头生成简洁自然语言。图表标题概括对象、指标及必要的趋势/对比关系;副标题仅补充已确认的时间范围或统计口径,无必要则省略;X 轴写类别或时间维度,Y 轴写指标名,单位明确时可附单位。禁止把单元格引用、公式、内部 ID、占位符、未解析文字、乱码或空括号写入标题,也不得臆造时间、单位和业务口径。
## 交付前验收(任何图表改动后必做)
完成本次所有图表创建或更新后,再逐图核对以下项;全部通过才算完成:
1. **数量**:图表数 = 用户明确要求的数量("每个 / 分别 / 逐一"等数量词已逐项展开为独立图,不用一张多系列图代替)。
2. **文案与展示项**:回读图表标题、副标题和坐标轴标题,确认语义准确且无乱码、占位符或空括号;图例按用户要求展示或隐藏,普通基础图的数据标签默认展示;密集时按“建议尺寸 → 稀疏标签 → Top-N / 拆图”处理。辅助系列不得用全点重复标签模拟单点或末点。带坐标轴的图表还要回读每条轴的字段语义、类型、单位、最小值 / 最大值、刻度以及主副轴归属;多图对比时再核对边界、跨度和口径是否符合用户的可比性要求。
3. **图表质量**:图表创建、配置更新、数据更新或位置调整后,每个受影响子表运行一次 `python3 scripts/lark_chart_quality_check.py "<表格 URL 或 spreadsheet token>" --worksheet-id "<reference_id>"`,无需先用 `ls` 探测脚本。检查器覆盖几何重叠、遮挡内容、越界、最小尺寸、数值源格式、全零/空系列和常量系列重复标签。动态数值源只采样每系列前 50 点,每张图累计最多读取 2000 个源单元格(含表头和系列间空隙);`numeric_source_samples` 给出实际范围与采样点数,不续读剩余数据。仅采样为全零/常量但未覆盖完整系列时列为不可验证,不能据此修改整个系列。`data.passed=true` 且退出码为 `0` 表示已完成检查范围内无问题,不能视为未采样数据也正常。退出码 `2` 表示检查成功发现问题,按返回的修复建议调整后重跑;退出码 `1`、网络超时或无有效 JSON 时只重试一次,仍失败则明确报告质量检查未完成,禁止用人工估算代替。
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+chart-list` | read | 对象 |
| `+chart-create-basic` | write | 对象 |
| `+chart-config-update` | write | 对象 |
| `+chart-data-update` | write | 对象 |
| `+chart-create` | write | 对象 |
| `+chart-update` | write | 对象 |
| `+chart-delete` | high-risk-write | 对象 |
## Flags
### `+chart-list`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--chart-id` | string | optional | 指定单个图表 reference_id 过滤 |
### `+chart-create-basic`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--chart-type` | string | required | 图表类型(可选值:`column` / `bar` / `line` / `area` / `pie` / `scatter` / `combo` / `radar` / `bubble` / `waterfall` / `pareto`) |
| `--data-range` | string | required | 数据范围;未传 --header-range 时须包含表头,传入时只传纯数据;支持逗号分隔及跨子表多范围 |
| `--header-range` | string | optional | 可选的分离表头范围;column 方向须为一行、row 方向须为一列,表头数须等于数据维度数 |
| `--data-direction` | string | optional | 数据系列方向;column 表示首列为类别,row 表示首行为类别(可选值:`column` / `row`)(默认 `column`) |
| `--aggregate-categories` | bool | optional | 是否汇总相同类别;稀疏标点或需要保留逐行数据点时使用 --aggregate-categories=false,省略时沿用图表默认行为 |
| `--x-axis-numbers-as` | string | optional | 横轴数字的解释方式;text 将数字视为等间距文本类别,values 按连续数值及真实间距绘制(可选值:`text` / `values`)(默认 `text`) |
| `--x-axis-min` | float64 | optional | 连续数值 X 轴的显示范围下界;需同时使用 --x-axis-numbers-as values |
| `--x-axis-max` | float64 | optional | 连续数值 X 轴的显示范围上界;需同时使用 --x-axis-numbers-as values |
| `--y-axis-min` | float64 | optional | 左 Y 轴的显示范围下界;默认省略,仅在用户明确要求固定范围时与 --y-axis-max 同时传;不得直接使用数据源单列最小值,且必须小于上界 |
| `--y-axis-max` | float64 | optional | 左 Y 轴的显示范围上界;默认省略,仅在用户明确要求固定范围时与 --y-axis-min 同时传;须按图表实际绘制值计算,且必须大于下界 |
| `--dim1-index` | int | optional | 唯一类别/X 轴维度在数据范围中的 1-based 索引;默认 1;不支持多个字段组成多级横轴 |
| `--dim2-indexes` | string | optional | 值/Y 轴系列的 1-based 索引列表,逗号分隔;不能包含 dim1,最多 50 个。气泡图旧调用按 `x,y[,group][,size]` 顺序传 2–4 个,新调用优先使用角色索引;饼图和排列图只传 1 个 |
| `--series-types` | string | optional | 仅组合图;按 --dim2-indexes 顺序指定系列类型,逗号分隔,可选 column、line、area、scatter,数量必须与数值系列一致 |
| `--series-y-axes` | string | optional | 仅组合图;先比较系列单位和量级,将会被压扁的系列放到 right 轴;按 --dim2-indexes 顺序传 left 或 right,数量必须与数值系列一致 |
| `--key-index` | int | optional | 仅气泡图:标识/名称维度的 1-based 索引;与 dim1/dim2 索引互斥,默认 1 |
| `--x-index` | int | optional | 仅气泡图:X 值维度的 1-based 索引;须与 --y-index 一起提供 |
| `--y-index` | int | optional | 仅气泡图:Y 值维度的 1-based 索引;须与 --x-index 一起提供 |
| `--group-index` | int | optional | 仅气泡图:可选分组维度的 1-based 索引 |
| `--size-index` | int | optional | 仅气泡图:可选气泡大小维度的 1-based 索引 |
| `--title` | string | optional | 图表标题 |
| `--subtitle` | string | optional | 图表副标题 |
| `--legend-position` | string | optional | 图例位置;饼图默认 bottom,hidden 隐藏图例(可选值:`top` / `bottom` / `left` / `right` / `hidden`) |
| `--x-axis-title` | string | optional | X 轴标题 |
| `--y-axis-title` | string | optional | 左 Y 轴标题 |
| `--secondary-y-axis-title` | string | optional | 右 Y 轴标题 |
| `--x-axis-label-angle` | int | optional | X 轴标签旋转角度(可选值:`-90` / `-45` / `0` / `45` / `90`) |
| `--y-axis-label-angle` | int | optional | 左 Y 轴标签旋转角度(可选值:`-90` / `-45` / `0` / `45` / `90`) |
| `--data-labels` | string | optional | 数据标签内容;普通基础图默认传 value,不要仅因数据点或系列较多而省略,仅用户明确要求隐藏全部标签时传 none;value、category、percentage 可按 value_category_percentage 顺序组成任意非空组合;series 显示系列名称(可选值:`none` / `value` / `category` / `percentage` / `value_category` / `value_percentage` / `category_percentage` / `value_category_percentage` / `series`) |
| `--data-label-position` | string | optional | 普通非堆叠柱形图显示标签时一般传 outside;其它场景仅当用户明确指定时传入;只调整已有数据标签的位置,不会单独开启标签(可选值:`auto` / `top` / `bottom` / `left` / `right` / `center` / `inside` / `outside`) |
| `--stack` | string | optional | 堆叠模式(可选值:`none` / `normal` / `percent`) |
| `--stacked` | bool | optional | 兼容别名;等价于 --stack normal(隐藏 flag:不在 `--help` 列出,但可正常传入) |
| `--smooth` | bool | optional | 是否使用平滑曲线;显式关闭使用 --smooth=false |
| `--color-palette` | string | optional | 预设整图配色主题;与 --colors 互斥(可选值:`brandColorSeries@v2` / `rainbowColorSeries@v2` / `complementaryColorSeries@v2` / `converseColorSeries@v2` / `primaryColorSeries@v2` / `singleColorSeries-B-@v2` / `singleColorSeries-W-@v2` / `singleColorSeries-G-@v2` / `singleColorSeries-Y-@v2` / `singleColorSeries-O-@v2` / `singleColorSeries-R-@v2` / `singleColorSeries-D-@v2`) |
| `--colors` | string_slice | optional | 自定义整图系列颜色,逗号分隔且至少 2 个十六进制色值;与 --color-palette 互斥 |
| `--anchor-cell` | string | optional | 可选图表锚点单元格,如 F2;省略时放到数据范围右侧 |
| `--width` | int | optional | 可选图表宽度;必须与 --height 同时传;饼图及长类别标签场景应适量加宽以避免截断 |
| `--height` | int | optional | 可选图表高度;必须与 --width 同时传 |
### `+chart-config-update`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--chart-id` | string | required | 目标图表 reference_id |
| `--title` | string | optional | 图表标题 |
| `--subtitle` | string | optional | 图表副标题 |
| `--legend-position` | string | optional | 图例位置;hidden 隐藏图例(可选值:`top` / `bottom` / `left` / `right` / `hidden`) |
| `--x-axis-title` | string | optional | X 轴标题 |
| `--y-axis-title` | string | optional | 左 Y 轴标题 |
| `--secondary-y-axis-title` | string | optional | 右 Y 轴标题 |
| `--x-axis-label-angle` | int | optional | X 轴标签旋转角度(可选值:`-90` / `-45` / `0` / `45` / `90`) |
| `--y-axis-label-angle` | int | optional | 左 Y 轴标签旋转角度(可选值:`-90` / `-45` / `0` / `45` / `90`) |
| `--x-axis-min` | float64 | optional | 连续数值 X 轴的显示范围下界;必须小于 --x-axis-max |
| `--x-axis-max` | float64 | optional | 连续数值 X 轴的显示范围上界;必须大于 --x-axis-min |
| `--y-axis-min` | float64 | optional | 左 Y 轴的显示范围下界;默认省略,仅在用户明确要求固定范围时与 --y-axis-max 同时传;不得直接使用数据源单列最小值,且必须小于上界 |
| `--y-axis-max` | float64 | optional | 左 Y 轴的显示范围上界;默认省略,仅在用户明确要求固定范围时与 --y-axis-min 同时传;须按图表实际绘制值计算,且必须大于下界 |
| `--data-labels` | string | optional | 数据标签内容;value、category、percentage 可按 value_category_percentage 顺序组成任意非空组合;series 显示系列名称,none 隐藏标签(可选值:`none` / `value` / `category` / `percentage` / `value_category` / `value_percentage` / `category_percentage` / `value_category_percentage` / `series`) |
| `--data-label-position` | string | optional | 仅当用户明确指定时传入;只调整已有数据标签的位置,不会单独开启标签;省略时按图表类型自动优化数据标签位置(可选值:`auto` / `top` / `bottom` / `left` / `right` / `center` / `inside` / `outside`) |
| `--aggregate-categories` | bool | optional | 是否汇总相同类别;稀疏标点或需要保留逐行数据点时使用 --aggregate-categories=false,省略时保留当前设置 |
| `--stack` | string | optional | 堆叠模式(可选值:`none` / `normal` / `percent`) |
| `--stacked` | bool | optional | 兼容别名;等价于 --stack normal(隐藏 flag:不在 `--help` 列出,但可正常传入) |
| `--smooth` | bool | optional | 是否使用平滑曲线;显式关闭使用 --smooth=false |
| `--color-palette` | string | optional | 预设整图配色主题;与 --colors 互斥(可选值:`brandColorSeries@v2` / `rainbowColorSeries@v2` / `complementaryColorSeries@v2` / `converseColorSeries@v2` / `primaryColorSeries@v2` / `singleColorSeries-B-@v2` / `singleColorSeries-W-@v2` / `singleColorSeries-G-@v2` / `singleColorSeries-Y-@v2` / `singleColorSeries-O-@v2` / `singleColorSeries-R-@v2` / `singleColorSeries-D-@v2`) |
| `--colors` | string_slice | optional | 自定义整图系列颜色,逗号分隔且至少 2 个十六进制色值;与 --color-palette 互斥 |
### `+chart-data-update`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--chart-id` | string | required | 目标图表 reference_id |
| `--data-range` | string | required | 新数据范围;未传 --header-range 时须包含表头,传入或原图已使用分离表头时只传纯数据;支持逗号分隔及跨子表多范围 |
| `--header-range` | string | optional | 可选的分离表头范围;提供后自动使用 detached 表头映射,省略时保留原图已有的 detached 映射 |
| `--data-direction` | string | optional | 数据系列方向;省略时沿用现有图表方向(可选值:`column` / `row`) |
| `--dim1-index` | int | optional | 唯一类别/X 轴维度在数据范围中的 1-based 索引;省略时使用第 1 个维度;不支持多个字段组成多级横轴 |
| `--dim2-indexes` | string | optional | 值/Y 轴系列在数据范围中的 1-based 索引,逗号分隔;省略时使用除 dim1 外的全部维度 |
| `--key-index` | int | optional | 仅气泡图:标识/名称维度的 1-based 索引;与 dim1/dim2 索引互斥,默认 1 |
| `--x-index` | int | optional | 仅气泡图:X 值维度的 1-based 索引;须与 --y-index 一起提供 |
| `--y-index` | int | optional | 仅气泡图:Y 值维度的 1-based 索引;须与 --x-index 一起提供 |
| `--group-index` | int | optional | 仅气泡图:可选分组维度的 1-based 索引 |
| `--size-index` | int | optional | 仅气泡图:可选气泡大小维度的 1-based 索引 |
### `+chart-create`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--properties` | string + File + Stdin(复合 JSON) | required | 图表完整配置 JSON。顶层字段为 `position` / `offset` / `size` / `snapshot`(无顶层 `data`,也无再嵌一层 `properties`);图表数据配置在 `snapshot.data` 下(含 `refs` / `headerMode` / `dim1` / `dim2`);必须至少含 `snapshot.data.dim1.serie.index` 或 `dim2.series[].index` 之一,否则 server 拒。结构嵌套深,完整结构跑 `--print-schema --flag-name properties` |
| `--print-example` | string | optional | 打印指定图表类型的最小可用 `--properties` 模板后直接退出(`area` / `bar` / `bubble` / `column` / `combo` / `line` / `pareto` / `pie` / `radar` / `scatter` / `waterfall`)。纯本地执行,不需要 locator flag、不发网络请求;传入未知类型时列出全部可用类型 |
### `+chart-update`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--chart-id` | string | required | 目标图表 reference_id |
| `--properties` | string + File + Stdin(复合 JSON) | required | 图表配置补丁 JSON;默认只传变化字段,未传字段保持不变;普通对象递归合并,数组整体替换 |
### `+chart-delete`
_公共四件套 · 系统:`--yes`、`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--chart-id` | string | required | 目标图表 reference_id |
## Schemas
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
### `+chart-create` `--properties` / `+chart-update` `--properties`
_创建/更新的图表属性_
**顶层字段**:
- `position` (object?) — 必填 { row: number, col: string }
- `offset` (object?) — 可选 { row_offset?: number, col_offset?: number }
- `size` (object?) — 必填 { width: number, height: number }
- `snapshot` (oneOf?) — 图表快照配置
## Examples
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR 规则同 `+csv-get`)。
### `+chart-list`
输出契约:返回按工作表分组的图表列表,每个图表含 `chart_id` / `position` / `details.snapshot` 等。
### `+chart-create-basic`
默认使用第 1 个维度作为类别/X 轴,其余维度作为数值系列;普通图表可用 1-based 的 `--dim1-index` 和逗号分隔的 `--dim2-indexes` 精确选择。组合图默认首个数值系列为左轴柱、其余为右轴折线;创建前仍要比较各系列单位和量级,避免折线或小量级系列因共用左轴而贴近 X 轴。需要其它组合时,用 `--series-types` 和 `--series-y-axes` 按 `--dim2-indexes` 的顺序逐项指定系列类型与左右轴;系列类型可选 `column`、`line`、`area`、`scatter`,两组参数的数量都必须与最终数值系列数一致。横轴数字默认按等间距文本类别处理;只有数字之间的真实间距需要影响图形位置时,才传 `--x-axis-numbers-as values` 使用连续数轴。气泡图改用 `--key-index`、`--x-index`、`--y-index` 和可选的 `--group-index` / `--size-index`,其中 x/y 必须同时提供,key 默认 1;角色索引不能与 dim1/dim2 索引混用。旧气泡图的 dim1/dim2 位置调用仍兼容。饼图和排列图只允许一个数值系列;组合图至少需要两个数值系列;所有图表最多选择 50 个数值系列。饼图默认将图例放在底部,并根据类别标签长度适量增加 `--width`(同时传 `--height`)。默认让 `--data-range` 包含真实表头;只有“维度/系列名称”与纯数据分离时,才让 `--data-range` 只传纯数据,并用 `--header-range` 传对应的一行(column)或一列(row)表头。类别维度与数值维度不连续时,范围参数可传逗号分隔的多范围,也支持来自多个子表;沿数据点轴对齐的跨子表范围会保留独立引用,同一子表内错行、错列或重叠时合并为最小包围矩形,跨子表范围无法对齐时会报错。单独调用成功后返回完整 `snapshot`,可直接检查创建结果并继续修改。参数名使用 `--anchor-cell` 和 `--data-labels`。兼容调用中,`--type` / `--range` 会分别按 `--chart-type` / `--data-range` 处理,`--x-axis` / `--y-axis` 会按轴标题处理;新调用仍优先使用规范参数名。
**连续数值 X 轴的可读性**:`--x-axis-numbers-as values` 会保留数字的真实间距,但未指定范围时可能自动包含 0。如果数据集中在远离 0 的窄区间,数据点会挤在图表一侧;此时应保留 `values`,创建时用 `--x-axis-min` / `--x-axis-max` 收紧范围,已有图表用 `+chart-config-update` 修正,不要改成 `text` 掩盖问题。两个边界可单独设置;同时设置时 min 必须小于 max。
```bash
# 柱形图:默认放在数据范围右侧
lark-cli sheets +chart-create-basic --url "..." --sheet-name "Sheet1" \
--chart-type column --data-range "'Sheet1'!A1:C10" \
--title "销售额对比" --x-axis-title "品类" --y-axis-title "销售额" \
--legend-position bottom --data-labels value
# 双轴组合图:月度目标、实际完成为左轴柱,完成率为右轴折线
lark-cli sheets +chart-create-basic --url "..." --sheet-name "Sheet1" \
--chart-type combo --data-range "'Sheet1'!A1:D13" \
--dim1-index 1 --dim2-indexes 2,3,4 \
--series-types column,column,line --series-y-axes left,left,right \
--title "价格与效率" --y-axis-title "价格" --secondary-y-axis-title "效率" \
--anchor-cell F2 --width 720 --height 420
# 辅助线只显示一个标签:C 列为重复目标值,D 列仅目标位置有值、其余单元格为空
lark-cli sheets +chart-create-basic --url "..." --sheet-name "Sheet1" \
--chart-type combo --data-range "'Sheet1'!A1:D7" \
--dim1-index 1 --dim2-indexes 2,3,4 \
--series-types line,line,scatter --series-y-axes left,left,left \
--aggregate-categories=false \
--title "趋势与目标线" --anchor-cell F2 --width 720 --height 420
# 先从创建结果或 +chart-list 取得完整 series 数组,再整段回写;辅助线系列不设置 labels
lark-cli sheets +chart-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \
--properties '{"snapshot":{"plotArea":{"plot":{"series":[{"index":2,"comboType":"line","labels":{"value":true}},{"index":3,"comboType":"line"},{"index":4,"comboType":"scatter","labels":{"value":true}}]}}}}'
# 气泡图:x、y 必填,group、size 可选
lark-cli sheets +chart-create-basic --url "..." --sheet-name "Sheet1" \
--chart-type bubble --data-range "'Sheet1'!A1:E20" \
--key-index 1 --x-index 2 --y-index 3 --group-index 4 --size-index 5 \
--title "客户分布"
# 数值散点图:保留真实 X 间距,同时收紧远离 0 的显示范围
lark-cli sheets +chart-create-basic --url "..." --sheet-name "Sheet1" \
--chart-type scatter --data-range "'Sheet1'!A1:B20" \
--x-axis-numbers-as values --x-axis-min 237 --x-axis-max 239
# 表头与数据分离:data-range 只传纯数据,header-range 按相同维度顺序传表头
lark-cli sheets +chart-create-basic --url "..." --sheet-name "Sheet1" \
--chart-type line \
--data-range "'Sheet1'!A2:A10,'Sheet1'!K2:L10" \
--header-range "'Sheet1'!A1,'Sheet1'!K1:L1"
# 横向类别行 + 一行数值:类别行也属于 data-range,不要放进 header-range
lark-cli sheets +chart-create-basic --url "..." --sheet-name "Sheet1" \
--chart-type line \
--data-range "'Sheet1'!A1:M1,'Sheet1'!A3:M3" \
--data-direction row --dim1-index 1 --dim2-indexes 2
```
多张基础图一次创建。先把所有数据准备完成,再生成 `ops.json`:
```json
[
{
"sheet_name": "Sheet1",
"chart_type": "column",
"data_range": "'Sheet1'!A1:C10",
"title": "分类对比",
"anchor_cell": "F2"
},
{
"sheet_name": "Sheet1",
"chart_type": "line",
"data_range": "'Sheet1'!E1:G10",
"title": "趋势变化",
"anchor_cell": "F18"
}
]
```
```bash
lark-cli sheets +batch-chart-create --url "..." --operations @ops.json
lark-cli sheets +chart-list --url "..." --sheet-name "Sheet1"
```
为了兼容旧调用,CLI 仍能读取历史 `{shortcut:"+chart-create-basic",input:{...}}` 结构,但新任务直接填写上面的扁平 `+chart-create-basic` flags。
批量修正已有图表时,operations 只放配置或数据更新;CLI 会先读取每张目标图的当前快照,再把对应 partial properties 合并进一次 `batch_update`:
```json
[
{"shortcut":"+chart-config-update","input":{"sheet_name":"Sheet1","chart_id":"chrA","title":"新标题"}},
{"shortcut":"+chart-data-update","input":{"sheet_name":"Sheet1","chart_id":"chrB","data_range":"'Sheet1'!A1:D10"}}
]
```
```bash
lark-cli sheets +batch-chart-update --url "..." --operations @updates.json
```
### `+chart-data-update`
当创建后发现漏列、范围过宽、辅助分类列发生变化、系列选择错误或数据方向错误时,只更新数据源,保留标题、配色、图例和落点。更新必须指定 `--chart-id`;范围、方向、普通 dim1/dim2 索引及气泡图角色索引的语义与 `+chart-create-basic` 相同。`--data-direction` 省略时沿用现有图表方向。默认让新范围包含表头;原图已经使用 detached 表头且表头不变时可省略 `--header-range`,工具会保留现有映射。工具返回更新后的 `data` 和实际采用的 `normalized_data_ranges`。
```bash
# 把遗漏的最后一列纳入原折线图,保留标题、配色、图例和落点
lark-cli sheets +chart-data-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \
--data-range "'Sheet1'!A1:M6"
```
### `+chart-config-update`
只传需要改的字段,成功后返回更新后的 `viewModel`。`--data-labels` 支持 `value`、`category`、`percentage` 的任意非空组合,组合值按 `value_category_percentage` 顺序拼接;另可用 `series` 显示系列名称、用 `none` 删除数据标签。多个系列需要不同标签策略时不要使用这个全局参数,按上文的辅助列与高级系列配置流程处理。`--legend-position hidden` 隐藏图例;显式关闭平滑曲线时使用 `--smooth=false`。为减少参数重试,`--stacked` 自动按 `--stack normal` 处理,`percentage,value` 或 `value,percentage` 自动按 `value_percentage` 处理,`--x-axis` / `--y-axis` 自动按 `--x-axis-title` / `--y-axis-title` 处理;新调用仍优先使用规范参数。
```bash
lark-cli sheets +chart-config-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \
--title "新标题" --x-axis-label-angle -45 --legend-position right
lark-cli sheets +chart-config-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \
--data-labels value_percentage --stack percent --aggregate-categories=false
```
### `+chart-create`
基础图表优先使用 `+chart-create-basic`。仅当语义 shortcut 无法表达单系列、单数据点或高级引擎字段时,才使用 `+chart-create`。高级创建需要结构完整的 snapshot;先用 `+chart-create --print-example <type>` 取得对应图表类型的最小结构,再只修改任务需要的字段。不要先打印或阅读整份大 schema。
### `+chart-update`
标题、轴、图例、标签、堆叠、平滑、配色和相同类别汇总优先使用 `+chart-config-update`,数据范围和方向使用 `+chart-data-update`。只有高级字段才使用 `+chart-update`;不要为常见修改构造 raw properties。
`+chart-update` 支持真正的局部更新:只传实际变化的字段,未传字段保持不变,不要复制并回写完整 snapshot。
- `snapshot` 内普通对象递归合并;
- `refs` / `axes` / `series` 等数组整体替换。只改数组中的一项时,先从 `+chart-list` 读取当前完整数组,修改后只回写该数组;
- `snapshot.data.isStaticData` 不能通过 update 改变;需要切换静态 / 非静态数据时删除后重建;
- 只调整尺寸时直接传 `size`,不需要传 `snapshot`;
- 执行前用 `--dry-run` 检查目标 sheet、chart_id 和最小 patch,执行后用 `+chart-list --chart-id <id>` 核对实际 snapshot。
```bash
# 只调整尺寸;无需携带 snapshot
lark-cli sheets +chart-update --url "..." --sheet-id "$SID" --chart-id "chrXXX" \
--properties '{"size":{"width":640,"height":400}}'
```
#### 高级 `properties` 边界
- 只查询本次要改的子树,不先打印完整大 schema:
```bash
lark-cli sheets +chart-update --print-schema \
--flag-name properties.snapshot.plotArea.axes
```
- `--dry-run` 输出中的 `tool_name` / `operation` / `basic_chart` / `properties` 是 CLI 翻译后的内部请求,只用于检查,不能复制回 operations 或再次当作 MCP body 提交。
- `--data-range` 本身支持逗号分隔的多个范围和跨子表范围。仅因数据不连续或跨子表,不构成手写 raw data 映射的理由。
- raw data 使用 inline 表头时,`refs` 包含真正表头且不写 `nameRef`;只有 `refs` 只覆盖纯数据、真正表头位于范围外时才用 detached:显式设置 `headerMode='detached'`,并让 `dim1.serie.nameRef` 与每个 `dim2.series[].nameRef` 指向对应表头单元格。
- raw 堆叠字段位于 `snapshot.plotArea.plot.extra.stack`;普通任务仍使用 `--stack normal|percent`。`plotArea.plot.labels` 对象的存在性就是开关,关闭标签时省略整个对象;普通任务使用 `--data-labels none`。
- `axes[].label` 不接受 `format` / `number_format`。日期、百分比和数值格式应修改源单元格的 `cell_styles.number_format`。
### `+chart-delete`
示例:
```bash
# dry-run 先看会删什么(sheet 定位必填)
lark-cli sheets +chart-delete --url "https://example.feishu.cn/sheets/shtXXX" --sheet-id "$SID" \
--chart-id "chrXXX" --dry-run
# 真正执行
lark-cli sheets +chart-delete --url "https://example.feishu.cn/sheets/shtXXX" --sheet-id "$SID" \
--chart-id "chrXXX" --yes
```
### Validate / DryRun / Execute 约束
- `Validate`:XOR 公共四件套;`+chart-data-update` 要求 `--chart-id` 和 `--data-range`,并校验 `--dim1-index` / `--dim2-indexes` 是正整数索引;`+chart-create` / `+chart-update` 的 `--properties` 必须能解析为合法 JSON;`+chart-delete`(high-risk-write)校验 `--yes` 或 `--dry-run` 至少一个。
- `DryRun`:`+chart-data-update` / `+chart-create` / `+chart-update` 输出"将要 POST 的 body 模板";`+chart-delete` 输出"将要删除的 chart_id 及隶属 sheet",零网络副作用。
- `Execute`:`+chart-create-basic` 成功后返回完整 `snapshot`,可直接验证;批量创建、响应不完整、后续更新或结果存疑时,再按受影响 sheet 调用一次 `+chart-list` 比对结果。
> `+chart-create` / `+chart-update` 是 write 级别,按需可用 `--dry-run` 预览,不要求 `--yes`。只有 `+chart-delete`(high-risk-write)必须 `--yes`。
references/lark-sheets-conditional-format.md›
# Lark Sheet Conditional Format
## 真对象硬约束 + 触发词清单
用户出现以下口语指令时,**强制**走 `+cond-format-{create|update|delete}`,**禁止**用 `+cells-set` 写静态背景色 / 字体色代替:
- **颜色动作**:"标红 / 标黄 / 标绿 / 上色 / 染色 / 涂色 / 表红色 / 表黄色"
- **视觉强调**:"高亮 / 突出 / 标记 / 标注 / 区分"——**限带条件语义的**(按值 / 规则决定哪些格上色);纯装饰性无条件上色(斑马纹、整行固定底色)不在强制范围,按视觉规范直接设背景色
- **条件触发**:"重复的标出来 / 异常的圈出来 / 过期的染红 / 大于 X 的标黄 / 不达标的标红"
- **联动语义**:"颜色随数据变 / 联动 / 自动更新 / 改了数据颜色也跟着变"
- **数值可视化**:"数据条 / 色阶 / 渐变色 / 进度条样式"
飞书表格的"颜色标记"语义 = 条件格式规则 ≠ 静态背景色。如果用 `+cells-set` 写静态,源数据变化时颜色不会跟着变(典型反例:用户要求"过期单元格标红"时,模型用静态填充——日期变化后单元格颜色不再准确反映过期状态)。
**判断标准**:交付后 `+cond-format-list` 必须能返回该规则,否则条件格式未生效。
**大数据量首选**:当数据量 > 1000 行时,条件格式是首选——它由飞书自身渲染,比"本地脚本逐行计算 + `+cells-set` 写静态背景色"更高效、更稳(颜色还能随源数据自动联动)。
## 使用场景
读写条件格式对象,并读取条件格式**计算后的单元格样式结果**。本 reference 覆盖这些 shortcut:
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 查看已有条件格式规则 | `+cond-format-list` | 获取规则类型、范围和样式配置;用于确认规则对象已存在 |
| 创建/更新/删除条件格式规则 | `+cond-format-create` / `+cond-format-update` / `+cond-format-delete` | 对条件格式规则执行写入操作 |
| 验证条件格式计算结果 | `+cond-format-result-get` | 读取命中后的 `cell_styles`,确认条件格式是否真的作用到哨兵单元格 |
| 常规读数时临时带上条件格式 | `+cells-get --include conditional_format` | 与 `+cond-format-result-get` 等价地合并条件格式样式,但仍归属普通单元格读取入口 |
典型工作流:先读取现有条件格式了解配置 → 执行创建/更新/删除 → **必须先用 `+cond-format-list` 验证规则对象,再用 `+cond-format-result-get` 抽查计算结果**。
**常见配置错误(必须注意)**:
- **创建后必须两段验证**:条件格式创建后先调用 `+cond-format-list` 验证规则对象(rule_type / ranges / style / attrs)是否存在且配置正确;再调用 `+cond-format-result-get --range "<哨兵范围>"` 读取命中后的 `cell_styles`,验证条件格式是否真的按计算结果作用到单元格。如果任一阶段不符合预期,应立即修复并重试
- **验证要覆盖哨兵格**:不要只确认规则对象存在;还要按用户规则抽查 2-3 个应命中 / 不应命中的单元格/行(含边界行、空值、重复值、非图例状态),用 `+cond-format-result-get` 读取 `cell_styles.background_color` / `font_color` / `font_weight` 等结果,确认公式、范围、颜色语义能解释这些哨兵。若规则存在但哨兵样式/命中逻辑不对,继续修正
- **范围要精确**:条件格式的应用范围必须精确覆盖用户指定的列/行,不要遗漏
- **`style.back_color` vs `style.fore_color` 的中文语义**:用户中文语境下的"**标红/染色/标记**"指**单元格背景色**,用 `back_color`;"**文字红/字体红/把字变红**"才用 `fore_color`。默认无说明时选 `back_color`。用户说"**标红**"用标准红 `back_color: "#FF0000"`;说"**高亮/突出**"才用浅色底(如 `#FFE6E6`)配合可选的 `fore_color` 加深字体——把"标红"做成浅粉会被认为没按要求标色
- **日期/空值比较必须防空**:用户说"过期的标红"时,除了 `TODAY()`,公式必须排除空单元格,否则空白格也会被误判为"早于今天"而全表标红。正确公式:`=AND(E1<>"", E1<=TODAY())`;错误公式:`=E1<=TODAY()`(空值会被当作 0 判为过期)
- **公式条件注意引用方式**:自定义公式条件中的单元格引用需要根据实际场景选择相对/绝对引用(如 `=E1<=TODAY()` 而非 `=$E$1<=TODAY()`,后者只比较一个格)
- **`duplicateValues` 只按单列判重**:用户说的"多字段完全相同才算重复"无法直接表达——先建辅助键列(把参与判重的列用分隔符拼成一个键),再用引用该键列的 `expression`(如 `COUNTIF` 键列 >1)把规则应用到数据区整行;只想标记键列本身时才用 `duplicateValues`
⚠️ **用户明确要求"辅助列+条件格式"两步走时,禁止用 `expression` 绕过**:当用户说以下任意一种表达时,必须按两步走(先建辅助列 → 再基于辅助列做条件格式),**禁止**直接用一个 `rule_type: "expression"` 公式一步完成:
- "**增加辅助列**,再/然后标记……"
- "**先计算/判断** XX **是否** YY,**再**标记……"
- "**新建一列**放结果,再用结果染色"
- 明确要求用 "辅助列"、"辅助字段"、"判断列"、"标记列"
**正确做法(两步走)**:
Step 1 的 `+cells-set` 及 `--copy-to-range` 等 flag 以 `references/lark-sheets-write-cells.md` 为准。
```
Step 1: `+cells-set` 在新列写判断公式(形成"是/否"或布尔辅助列)
range="H2", cells=[[{formula: "=IF(A2>B2, \"是\", \"否\")"}]], --copy-to-range="H2:H100"
Step 2: 基于辅助列值做条件格式(用 cellIs 或引用辅助列的 expression)
`+cond-format-{create|update|delete}` create
rule_type: "expression"
ranges: ["A2:H100"] // 整行高亮
attrs: [{formula: ["=$H2=\"是\""]}] // 引用辅助列
style: {back_color: "#FFECEC"}
```
**错误做法(一步走绕过辅助列)**:
```
`+cond-format-{create|update|delete}` create
rule_type: "expression"
ranges: ["2:145"]
attrs: [{formula: ["=$O2>$H2"]}] ← 虽然逻辑等价,但产物里缺辅助列 → 不满足用户明确要求的"辅助列"诉求
```
为什么禁止一步走:用户明确要求辅助列是有**业务意图**的——让人肉眼能在表里看到"是/否"列;条件格式只是视觉辅助。一步 expression 虽然效果对了,但用户打开表格看不到辅助列,被视为"操作不完整/未采用公式"。
`expression` 单独使用的场景是:用户**没有**明确要求辅助列、只要"标红符合条件的行"时。
⚠️ **创建条件格式前必须读数据行确认列对应**:仅读首行表头(`+csv-get range="A1:Z1"`)不够——如果表头语义含糊(比如"时间"、"日期"这种多列同义词),formula 里引用的列字母可能张冠李戴。必须再读 3-5 行**数据样本**(如 `range="A2:Z6"`)确认:①列名对应的实际值;②字段含义匹配用户描述;③数据类型是日期/数字/文本。特别是比较类条件格式(`=$A2>$B2` 这种),列字母选错整条规则就废了。
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+cond-format-list` | read | 对象 |
| `+cond-format-result-get` | read | 对象 |
| `+cond-format-create` | write | 对象 |
| `+cond-format-update` | write | 对象 |
| `+cond-format-delete` | high-risk-write | 对象 |
## Flags
### `+cond-format-list`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--rule-id` | string | optional | 按规则 id 过滤 |
### `+cond-format-result-get`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | A1 范围,如 `A1:F10`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet) |
| `--max-chars` | int | optional | 单次返回字符上限,默认 500000(兜底防爆) |
### `+cond-format-create`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--properties` | string + File + Stdin(复合 JSON) | required | 规则配置 JSON,含 `style`(命中样式,必填)和 `attrs?`(规则参数列表,因 `rule_type` 不同结构而异)/ `has_ref?`。`rule_type` 和 `ranges` 已拎为独立 flag |
| `--rule-type` | string | required | 条件格式规则类型;优先级高于 `--properties` 中同名字段(可选值:`duplicateValues` / `uniqueValues` / `cellIs` / `containsText` / `timePeriod` / `containsBlanks` / `notContainsBlanks` / `dataBar` / `colorScale` / `rank` / `aboveAverage` / `expression` / `iconSet`) |
| `--ranges` | string + File + Stdin(简单 JSON) | required | 应用条件格式的 A1 范围 JSON 数组(如 `["A1:A100","C2:C50"]`);优先级高于 `--properties` 中同名字段 |
### `+cond-format-update`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--rule-id` | string | required | 目标规则 id |
| `--properties` | string + File + Stdin(复合 JSON) | required | 规则配置 JSON,结构同 `+cond-format-create` 的 `--properties`;update 是整组覆盖式 |
| `--rule-type` | string | required | 条件格式规则类型;优先级高于 `--properties` 中同名字段(可选值:`duplicateValues` / `uniqueValues` / `cellIs` / `containsText` / `timePeriod` / `containsBlanks` / `notContainsBlanks` / `dataBar` / `colorScale` / `rank` / `aboveAverage` / `expression` / `iconSet`) |
| `--ranges` | string + File + Stdin(简单 JSON) | required | 应用条件格式的 A1 范围 JSON 数组(如 `["A1:A100","C2:C50"]`);优先级高于 `--properties` 中同名字段 |
### `+cond-format-delete`
_公共四件套 · 系统:`--yes`、`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--rule-id` | string | required | 目标规则 id |
## Schemas
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
### `+cond-format-create` `--properties` / `+cond-format-update` `--properties`
_创建/更新的条件格式属性_
**顶层字段**:
- `rule_type` (enum) — 条件格式规则类型 [duplicateValues / uniqueValues / cellIs / containsText / timePeriod / containsBlanks / notContainsBlanks / dataBar / colorScale / rank / aboveAverage / expression / iconSet] — ⚠️ 已拎为独立 flag `--rule-type`,请勿在此 JSON 内重复填写(同名以独立 flag 为准)
- `ranges` (array<string>) — 应用条件格式的 A1 范围列表 — ⚠️ 已拎为独立 flag `--ranges`,请勿在此 JSON 内重复填写(同名以独立 flag 为准)
- `style` (object) — 命中规则时应用的单元格样式 { back_color?: string, fore_color?: string, text_decoration?: enum, font?: enum }
- `attrs` (array<oneOf>?) — 规则参数列表
- `has_ref` (boolean?) — 可选
## Examples
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。
### `+cond-format-list`
```bash
# 列出当前 sheet 全部条件格式规则(拿 rule_id 供 update/delete)
lark-cli sheets +cond-format-list --url "..." --sheet-id "$SID"
```
### `+cond-format-create`
`--rule-type` / `--ranges` 是独立 flag(不要再放 `--properties`);`style` / `attrs` 等结构走 `--properties`:
```bash
# 重复值高亮
lark-cli sheets +cond-format-create --url "..." --sheet-id "$SID" \
--rule-type duplicateValues --ranges '["A1:A100"]' \
--properties '{"style":{"back_color":"#FFD7D7"}}'
# 数据条
lark-cli sheets +cond-format-create --url "..." --sheet-id "$SID" \
--rule-type dataBar --ranges '["B2:B100"]' \
--properties @rule.json
# 创建后先确认规则对象存在
lark-cli sheets +cond-format-list --url "..." --sheet-id "$SID"
# 再抽查条件格式计算结果:读取哨兵单元格的命中样式
lark-cli sheets +cond-format-result-get --url "..." --sheet-id "$SID" \
--range "B2:B10"
```
### `+cond-format-result-get`
用于读取条件格式**计算后的样式结果**,不是读取规则对象。创建 / 更新条件格式后必须用它抽查哨兵单元格。
CLI 会对白名单字段做输出裁剪:顶层只保留警告、分页和返回单元格计数;每个 range 只保留请求/实际范围、真实行列坐标、截断标记和二维 `cells`;每个 cell 只保留 `cell_styles`,不返回 `value` / `formula` / `note` / `data_validation` / `border_styles` 等其它单元格数据。`cell_styles` 是底层开启条件格式计算后得到的最终合并样式,不包含 `rule_id` 或独立的命中标记。
```bash
# 读取 B2:B10 的条件格式命中样式,返回 cell_styles.background_color / font_color 等
lark-cli sheets +cond-format-result-get --url "..." --sheet-id "$SID" \
--range "B2:B10"
# 如果只想在普通读取里临时合并条件格式,也可用 +cells-get --include conditional_format
lark-cli sheets +cells-get --url "..." --sheet-id "$SID" \
--range "B2:B10" --include conditional_format
```
### `+cond-format-update`
整组覆盖式:先 `+cond-format-list --rule-id <id>` 拿当前完整配置,改后整组传回。
### `+cond-format-delete`
```bash
lark-cli sheets +cond-format-delete --url "..." --sheet-id "$SID" --rule-id "$RULE_ID" --yes
```
> 一次只删一个 `--rule-id`。要删**多个**条件格式时,先 `+cond-format-list` 拿到各 `rule-id`,再用 `+batch-update` 把多个 `+cond-format-delete` 合并为单次批量提交(fail-fast,失败处置见 `references/lark-sheets-batch-update.md`),不要逐个调用。
### Validate / DryRun / Execute 约束
- `Validate`:XOR 公共四件套;`--rule-type` / `--ranges` 必填;`--properties` 必须能解析为合法 JSON;按 `--rule-type` 检查必填子字段(`cellIs` 需 `attrs.operator` + `attrs.value`、`expression` 需 `attrs.formula`、`colorScale` 需 `min/mid/max` 配色等);`+cond-format-delete` 强制 `--yes` 或 `--dry-run`。
- `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 conditional_format 请求模板"。
- `Execute`:写后不自动回读;create/update 后必须调用 `+cond-format-list --rule-id <id>` 比对规则 / 范围 / 样式,并用 `+cond-format-result-get --range <2–3 个哨兵格>` 核对实际生效的单元格样式;delete 后 list 确认目标 id 不存在。
references/lark-sheets-filter-view.md›
# Lark Sheet Filter View
## 概念回顾
筛选视图是 sheet 内的多份独立筛选配置,每个视图持有自己的 `range` 和 `rules`,由独立 `view_id`(10 位随机字符串)标识。一个 sheet 可有多个视图,视图的隐藏行仅在用户进入该视图时本地生效,不影响其他协作者,也不与该 sheet 上可能并存的筛选器(filter)互相影响。
`+filter-view-{create|update|delete}` 负责视图本身的 CRUD(create / update / delete);视图的"进入 / 退出"(激活态)是本地状态,不在工具语义内。
## 使用场景
读写筛选视图对象。本 reference 覆盖 4 个 shortcut:
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 查看已有筛选视图 | `+filter-view-list` | 获取 sheet 上所有视图(视图名、范围、规则) |
| 创建 / 更新 / 删除筛选视图 | `+filter-view-{create|update|delete}` | create / update / delete 三个独立 shortcut |
典型工作流:先读取现有视图了解配置 → 执行创建 / 更新 / 删除 → **必须再次读取验证结果**。
**常见配置错误(必须注意)**:
- **视图范围必须覆盖表头行**:视图的 range 必须从表头行开始(如 `A1:F100`),不能只包含数据行
- **更新前先读取**:用户说"调整这个视图"时,先用 `+filter-view-list` 拉到目标视图当前 rules,**只改差异列**再回写
- **多次 create 不能复用 view_id**:复用应走 `update`,重复 `create` 会产生新视图
- **筛选不支持正则表达式**:飞书表格筛选器不支持正则表达式,传入正则会当成普通文本处理
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+filter-view-list` | read | 对象 |
| `+filter-view-create` | write | 对象 |
| `+filter-view-update` | write | 对象 |
| `+filter-view-delete` | high-risk-write | 对象 |
## Flags
### `+filter-view-list`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--view-id` | string | optional | 按筛选视图 reference_id 过滤(命中即只返回单个视图) |
### `+filter-view-create`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--properties` | string + File + Stdin(复合 JSON) | required | 筛选视图规则 JSON,含 `rules?`(列级筛选规则数组)和 `filtered_columns?`。`range` 和 `view_name` 是独立 flag |
| `--range` | string | required | 筛选视图作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--properties` 中同名字段;create 必填,必须覆盖表头行 |
| `--view-name` | string | optional | 筛选视图名称;不传时系统自动分配;优先级高于 `--properties` 中同名字段 |
### `+filter-view-update`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--view-id` | string | required | 目标筛选视图 reference_id |
| `--properties` | string + File + Stdin(复合 JSON) | required | 筛选视图规则 JSON,含 `rules?` 和 `filtered_columns?`;update 是整组覆盖式(先 `+filter-view-list` 回读再 patch;传空 `rules: []` 清空)。`range` 和 `view_name` 是独立 flag |
| `--range` | string | optional | 筛选视图作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--properties` 中同名字段;update 时省略表示保留当前 range |
| `--view-name` | string | optional | 筛选视图名称;create 不传时系统自动分配,update 不传时保留原名;优先级高于 `--properties` 中同名字段 |
### `+filter-view-delete`
_公共四件套 · 系统:`--yes`、`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--view-id` | string | required | 目标筛选视图 reference_id |
## Schemas
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
### `+filter-view-create` `--properties` / `+filter-view-update` `--properties`
_create / update 的视图属性_
**顶层字段**:
- `view_name` (string?) — 可选 — ⚠️ 已拎为独立 flag `--view-name`,请勿在此 JSON 内重复填写(同名以独立 flag 为准)
- `range` (string?) — 视图作用的单元格范围(A1 表示法) — ⚠️ 已拎为独立 flag `--range`,请勿在此 JSON 内重复填写(同名以独立 flag 为准)
- `rules` (array<object>?) — 列级筛选规则列表,每一项对应一个具体列的筛选条件 each: { column_index: string, conditions: array<oneOf>, filtered_rows?: array<number> }
- `filtered_columns` (array<string>?) — 可选
## Examples
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。`view_id` 是 10 位随机字符串,每个 sheet 可有多个视图。
### `+filter-view-list`
```bash
# 列出某个 sheet 的全部筛选视图
lark-cli sheets +filter-view-list --url "..." --sheet-id "$SID"
# 按 view_id 精确定位
lark-cli sheets +filter-view-list --url "..." --sheet-id "$SID" --view-id vAbcde1234
```
### `+filter-view-create`
`--range`(必填)/ `--view-name`(可选)是独立 flag;`rules` 走 `--properties`:
```bash
lark-cli sheets +filter-view-create --url "..." --sheet-id "$SID" \
--view-name "活跃用户" --range "A1:F1000" \
--properties '{"rules":[{"column_index":"C","conditions":[{"type":"number","compare_type":"greaterThan","values":[100]}]}]}'
```
**`conditions[].type` × `compare_type` 取值**(`type` 决定可用的 `compare_type`;两者均必填):
| `type` | 可用 `compare_type` | `values` |
|---|---|---|
| `text` | `contains` / `doesNotContain` / `beginsWith` / `doesNotBeginWith` / `endsWith` / `doesNotEndWith` / `equals` / `notEquals` | 字符串数组 |
| `number` | `equal` / `notEqual` / `greaterThan` / `greaterThanOrEqual` / `lessThan` / `lessThanOrEqual` / `between` / `notBetween` | 数值(或数值字符串)数组;`between` / `notBetween` 传两个边界 |
| `multiValue` | `equal` / `notEqual` | 字符串数组(精确匹配其中任一值) |
| `color` | `backgroundColor` / `foregroundColor` | 不传 `values`(按单元格颜色筛选) |
> ⚠️ `text` 用 `equals` / `notEquals`(**带 s**),`number` / `multiValue` 用 `equal` / `notEqual`(**不带 s**)——别混。完整 schema 跑 `+filter-view-create --print-schema --flag-name properties`。
> `--range` **必须覆盖表头行**(如 `A1:F1000`),不能只包含数据行;`--view-name` 重名时服务端自动改名。
### `+filter-view-update`
> ⚠️ update 是整组覆盖(PUT 语义):`--properties` **必传**,未在请求里出现的 rules / filtered_columns 会被清空。如要保留已有 rules,先 `+filter-view-list` 读回再合并写回。`--range` 变更会丢弃已有筛选规则属预期行为(rules 跟当前 range 绑定)。重复 `+filter-view-create` 不会复用 view_id,会产生新视图。
### `+filter-view-delete`
> ⚠️ 删除**已存在**的视图不可逆;目标 view_id **不存在**时按幂等成功返回(不报错)。先 `--dry-run` 看 view_id 确认。
### Validate / DryRun / Execute 约束
- `Validate`:XOR 公共四件套;`+filter-view-create` 校验 `--range` 起始行为表头(第一行);`+filter-view-update` 必须先 `+filter-view-list` 确认 view 存在,`--properties` 必传(整组覆盖式);`+filter-view-delete` 强制 `--yes` 或 `--dry-run`。
- `DryRun`:输出"将要 POST/PATCH/DELETE 的 view 请求模板",零网络副作用;`--sheet-name` 在 dry-run 输出里生成为 `<resolve:Sheet1>` 占位符。
- `Execute`:写后不自动回读;create/update 后必须调用 `+filter-view-list --view-id <id>` 比对 range + rules;delete 后 list 确认目标 view 不存在。
references/lark-sheets-filter.md›
# Lark Sheet Filter
## 真对象硬约束 + 数量校验
1. **先判产物形态,再选做法**:**要裁列 / 要另存结果表**("只保留某几列""筛出来放新表",含用户显式要求把筛选结果做成独立新表的行级筛选)→ 另建结果 sheet 物化符合条件的行与列,**原表原样保留**;**仅行级、不裁列**("筛选 / 只看 / 仅保留符合条件的行")→ **必须**通过 `+filter-{create|update|delete}` 创建真实的筛选器对象,**禁止**用"删除不符合条件的行" / "新建子表只放符合条件的行" / 用 `+cells-set` 覆盖原表来代替——这些做法会让原数据丢失或不可恢复。
2. **筛选数量必校**:执行筛选后**必须**回读,断言 `len(visible_rows) == expected_count`。`expected_count` 来自先用本地脚本在源数据上独立复现该筛选条件得到的结果数。两者不一致时禁止交付,需排查筛选条件 / 数据列类型问题。
3. **混合文本列禁止字面比较**:筛选 key 是公式文本(如 `1000+200=1200`)或带单位的混合文本时,先在辅助列里抽出纯数值再筛选;不能直接用文本比较。
## 使用场景
读写筛选器对象。本 reference 覆盖 4 个 shortcut:
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 查看已有筛选器 | `+filter-list` | 获取筛选器的范围、规则和条件配置 |
| 创建/更新/删除筛选器 | `+filter-{create|update|delete}` | 对筛选器执行写入操作 |
典型工作流:先读取现有筛选器了解配置 → 执行创建/更新/删除 → **必须再次读取验证结果**。
**只读场景例外**:用户只是想知道哪些数据满足条件、并不要求修改表格展示时,可以走 `references/lark-sheets-read-data.md` 读后文本回答,不必创建筛选器。
**常见配置错误(必须注意)**:
- **筛选范围必须覆盖表头行**:筛选器的 range 必须从表头行开始(如 `A1:F100`),不能只包含数据行。缺少表头会导致筛选条件无法正确匹配列
- **更新已有筛选器前先读取**:如果子表上已存在筛选器,直接创建会报错或覆盖原有配置。应先用 `+filter-list` 查看是否存在筛选器,存在时使用 update 而非 create
- **筛选条件的列索引要精确**:筛选条件中的列标识必须与实际数据列精确对应,不要凭猜测填写
- **”调整筛选逻辑”要先读旧配置**:用户说”调整筛选”时,先读取现有筛选器的完整配置,理解当前规则后再修改,不要从零创建
- **创建后必须验证**:调用 `+filter-list` 确认筛选器配置正确且生效
- **筛选不支持正则表达式**:飞书表格筛选器不支持正则表达式,传入正则会当成普通文本处理。
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+filter-list` | read | 对象 |
| `+filter-create` | write | 对象 |
| `+filter-update` | write | 对象 |
| `+filter-delete` | high-risk-write | 对象 |
## Flags
### `+filter-list`
_公共四件套 · 系统:`--dry-run`_
_仅含公共 / 系统 flag。_
### `+filter-create`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | 筛选范围(A1 表示法,含表头行,如 `A1:F1000`);不要重复写入 `--properties` 中的 range 字段 |
| `--properties` | string + File + Stdin(复合 JSON) | optional | 筛选规则 JSON:`rules`(列级筛选规则数组)+ `filtered_columns?`(激活列索引提示)。`--properties` 整体可选——传它时 `rules` 不可为空;不传则只在 `--range` 上建立空筛选器(无列条件)。`range` 是独立 flag(不要再放此 JSON 里) |
### `+filter-update`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--properties` | string + File + Stdin(复合 JSON) | required | 筛选规则 JSON,含 `rules` 和 `filtered_columns?`;update 是整组覆盖式(传空 `rules: []` 清空)。`range` 已拎为独立 flag |
| `--range` | string | required | 筛选作用的单元格范围(A1 表示法,如 `A1:F1000`);优先级高于 `--properties` 中同名字段 |
### `+filter-delete`
_公共四件套 · 系统:`--yes`、`--dry-run`_
_仅含公共 / 系统 flag。_
## Schemas
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
### `+filter-create` `--properties` / `+filter-update` `--properties`
_创建/更新的筛选器属性_
**顶层字段**:
- `range` (string) — 筛选对象作用的单元格范围(A1 表示法) — ⚠️ 已拎为独立 flag `--range`,请勿在此 JSON 内重复填写(同名以独立 flag 为准)
- `rules` (array<object>) — 列级筛选规则列表,每一项对应一个具体列的筛选条件 each: { column_index: string, conditions: array<oneOf>, filtered_rows?: array<number> }
- `filtered_columns` (array<string>?) — 可选
## Examples
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。`filter_id` 等同于 `sheet_id`(每个工作表至多一个筛选器)。
### `+filter-list`
```bash
# 查看当前 sheet 的筛选器配置(filter_id 等于 sheet_id)
lark-cli sheets +filter-list --url "..." --sheet-id "$SID"
```
### `+filter-create`
`--range` 是独立 flag(含表头行);`rules` 走 `--properties`:
```bash
lark-cli sheets +filter-create --url "..." --sheet-id "$SID" \
--range "A1:F1000" \
--properties '{"rules":[{"column_index":"B","conditions":[{"type":"multiValue","compare_type":"equal","values":["北京","上海"]}]}]}'
```
**`conditions[].type` × `compare_type` 取值**(`type` 决定可用的 `compare_type`;两者均必填):
| `type` | 可用 `compare_type` | `values` |
|---|---|---|
| `text` | `contains` / `doesNotContain` / `beginsWith` / `doesNotBeginWith` / `endsWith` / `doesNotEndWith` / `equals` / `notEquals` | 字符串数组 |
| `number` | `equal` / `notEqual` / `greaterThan` / `greaterThanOrEqual` / `lessThan` / `lessThanOrEqual` / `between` / `notBetween` | 数值(或数值字符串)数组;`between` / `notBetween` 传两个边界 |
| `multiValue` | `equal` / `notEqual` | 字符串数组(精确匹配其中任一值) |
| `color` | `backgroundColor` / `foregroundColor` | 不传 `values`(按单元格颜色筛选) |
> ⚠️ `text` 用 `equals` / `notEquals`(**带 s**),`number` / `multiValue` 用 `equal` / `notEqual`(**不带 s**)——别混。完整 schema 跑 `+filter-create --print-schema --flag-name properties`。
### `+filter-update`
> ⚠️ update 是覆盖式:`--properties` 中传新 `rules` 会替换旧组。如只想加一条,要带上已有的全部条件再追加。必填 `--range`。
### `+filter-delete`
```bash
lark-cli sheets +filter-delete --url "..." --sheet-id "$SID" --yes
```
### Validate / DryRun / Execute 约束
- `Validate`:XOR 公共四件套;`+filter-create` 校验 `--range` 至少 2 行(表头 + 至少 1 行数据);`+filter-update` 必须先 `+filter-list` 确认目标存在;`+filter-delete` 强制 `--yes` 或 `--dry-run`。
- `DryRun`:输出"将要 POST/PATCH/DELETE 的 filter 请求模板"。
- `Execute`:写后不自动回读;create/update 后必须调用 `+filter-list` 核对 range、rules 与已过滤行数;delete 后 list 确认筛选器不存在。
references/lark-sheets-float-image.md›
# Lark Sheet Float Image
> **选浮动图还是单元格图?只看一条**:这张图是不是**属于某条记录、要随那行一起排序 / 筛选 / 增删**?
> - **是 → 单元格图片**(不在本 reference):嵌进单元格、随行走。用 `+cells-set-image`(或 `+cells-set` 的 `rich_text` + `type: "embed-image"`,见 lark-sheets-write-cells)。典型:凭证 / 证件照 / 商品图 / 头像 / 二维码 / 每行配图;话里带「对应 / 每行 / 每条 / 这列」等绑定词即属此类。
> - **否 → 浮动图片**(本 reference):自由摆放、不绑数据的装饰 / 标识(logo / 水印 / 封面大图 / banner)。
> - ⚠️ 别凭"浮动图位置尺寸更好控制 / 更熟"就选它——那是按操作便利选,不是按场景选;用浮动图承载"对应某记录"的图会在增删行 / 排序后错位。
## 真对象硬约束
当用户要求"插入图片 / 添加 logo / 放一张图"时,**必须**通过 `+float-image-{create|update|delete}`(浮动图片)或 `+cells-set-image` / `+cells-set` 的 `embed-image`(单元格图片)创建真实的图片对象。**禁止**只在文本回复中给出图片链接 / 描述图片内容代替插入。判断标准:交付后 `+float-image-list` 或单元格 `rich_text` 必须能读到该图片对象。
## 使用场景
读写**浮动图片**对象(悬浮在单元格上方的图片,不属于单元格内容)。本 reference 覆盖 4 个 shortcut:
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 查看已有浮动图片 | `+float-image-list` | 获取浮动图片的位置、大小和层级配置 |
| 创建/更新/删除浮动图片 | `+float-image-{create|update|delete}` | 对浮动图片执行写入操作 |
典型工作流:先读取现有浮动图片了解配置 → 执行创建/更新/删除 → **必须再次读取验证结果**。
**常见配置错误(必须注意)**:
- **单元格图片 vs 浮动图片选择错误**:图与某条记录一一对应、要随行排序 / 筛选 / 增删时,应走 `+cells-set-image`(见顶部判别),用浮动图会错位。
- **图片位置参数要精确**:锚点单元格的行列索引和偏移量决定了图片位置,设置不当会导致图片遮挡数据
- **创建后必须验证**:调用 `+float-image-list` 确认图片位置和大小正确
图片来源有三种方式,`+float-image-create` 上三者 **XOR、必给其一**(`--image` / `--image-token` / `--image-uri`):
- **`--image <本地路径>`(首选,最省事)**:直接给本地图片文件路径(PNG/JPEG/GIF/BMP/HEIC 等)。CLI 会自动把它以 `parent_type=sheet_image` 上传,拿到 file_token 后创建浮动图,**不用你手动上传 / 取 token**。路径规则同其它本地文件 flag:必须是当前工作目录内的相对路径(绝对路径会被 Validate 拒,`--dry-run` 也会拦)。
- `--image-token`:复用**已存在**的图片 file_token。常见来源:① `+float-image-list` 返回的 `image_token`(适合"换皮不换位置"复用同一张图);② `+cells-set-image` 成功返回里的 `file_token`(它也是 `sheet_image` 上传句柄)。适合"同一张图复用到多处",省去重复上传。
- `--image-uri`:图片 URI(上传链路返回的句柄),**非**表内对象 reference_id;由系统自动转 file_token。
> ⚠️ **`--image` 仅 `+float-image-create` 支持**。`+float-image-update` 换图仍只接受 `--image-token` / `--image-uri`,而且**图片源是 update 唯一可省的部分**——三者全不传则保留原图。但 `--image-name` / `--position-{row,col}` / `--size-{width,height}` 在 update 时和 create 一样**必填**(`+float-image-update` 强制要求这套核心字段,且 `+float-image-list` 不回传 `image_name` 供 CLI 回填)。要在 update 里换一张本地新图,先用 `+cells-set-image` 上传到任意临时单元格、从返回取 `file_token`,再把它传给 update 的 `--image-token`;用完清除该临时单元格,避免残留多余图片。
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+float-image-list` | read | 对象 |
| `+float-image-create` | write | 对象 |
| `+float-image-update` | write | 对象 |
| `+float-image-delete` | high-risk-write | 对象 |
## Flags
### `+float-image-list`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--float-image-id` | string | optional | 按 id 过滤;省略时列工作表全部 |
### `+float-image-create`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--image-name` | string | required | 图片名称,含扩展名(如 `logo.png`) |
| `--image-token` | string | xor | 图片 file_token(与 `--image-uri` 二选一)。常见来源:`+float-image-list` 返回的 `image_token` |
| `--image-uri` | string | xor | 图片 URI(上传链路返回的句柄,非表内对象 reference_id;与 `--image-token` 二选一);系统自动转换为 file_token |
| `--position-row` | int | required | 图片左上角所在行(0-based) |
| `--position-col` | string | required | 图片左上角所在列(列字母,如 `A` / `B`) |
| `--size-width` | int | required | 图片宽度(像素) |
| `--size-height` | int | required | 图片高度(像素) |
| `--offset-row` | int | optional | 在 `--position-row` 基础上的行内偏移(像素) |
| `--offset-col` | int | optional | 在 `--position-col` 基础上的列内偏移(像素) |
| `--z-index` | int | optional | 图片 Z 轴层级,控制重叠顺序 |
| `--image` | string | xor | 本地图片路径(PNG/JPEG 等);CLI 自动上传为 sheet_image 并用返回的 file_token,省去手动拿 token(与 --image-token / --image-uri 三选一) |
### `+float-image-update`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--float-image-id` | string | required | 目标图片 id |
| `--image-name` | string | required | 图片名称,含扩展名(如 `logo.png`) |
| `--image-token` | string | optional | 可选图片 file_token;与 `--image-uri` 互斥,二者均省略时保留原图。常见来源:`+float-image-list` 返回的 `image_token` |
| `--image-uri` | string | optional | 可选图片 URI(上传链路返回的句柄,非表内对象 reference_id);与 `--image-token` 互斥,二者均省略时保留原图;系统自动转换为 file_token |
| `--position-row` | int | required | 图片左上角所在行(0-based) |
| `--position-col` | string | required | 图片左上角所在列(列字母,如 `A` / `B`) |
| `--size-width` | int | required | 图片宽度(像素) |
| `--size-height` | int | required | 图片高度(像素) |
| `--offset-row` | int | optional | 在 `--position-row` 基础上的行内偏移(像素) |
| `--offset-col` | int | optional | 在 `--position-col` 基础上的列内偏移(像素) |
| `--z-index` | int | optional | 图片 Z 轴层级,控制重叠顺序 |
### `+float-image-delete`
_公共四件套 · 系统:`--yes`、`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--float-image-id` | string | required | 目标图片 id |
## Examples
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。浮动图片是 sheet 级对象——和单元格内嵌图片不同(后者走 `+cells-set`)。
### `+float-image-list`
```bash
lark-cli sheets +float-image-list --url "..." --sheet-id "$SID"
```
### `+float-image-create`
所有字段拍平为独立 flag:图片来源 `--image` / `--image-token` / `--image-uri`(三选一 XOR)/ `--image-name` / `--position-{row,col}` / `--size-{width,height}` / `--offset-{row,col}` / `--z-index`。
```bash
# 首选:直接给本地图片路径,CLI 自动上传(无需手动拿 token)
# 注意:--image-name 是 required(即使路径 basename 已经是 logo.png 也要显式传)
lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" \
--image ./logo.png --image-name "logo.png" \
--position-row 2 --position-col B --size-width 300 --size-height 200 --z-index 1
# 用已有 file_token(从 +float-image-list 的 image_token 或 +cells-set-image 返回的 file_token)
lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" \
--image-name "logo.png" --image-token "$TOKEN" \
--position-row 0 --position-col A --size-width 200 --size-height 150
# 用 image URI(上传链路返回的句柄,非表内对象 reference_id;与 --image-token 二选一)
lark-cli sheets +float-image-create --url "..." --sheet-id "$SID" \
--image-name "logo.png" --image-uri "$IMAGE_URI" \
--position-row 2 --position-col B --size-width 300 --size-height 200 --z-index 1
```
### `+float-image-update`
> **update ≈ create,只有图片源可省**:`+float-image-update` 的 update 要求和 create 相同的核心字段——`--image-name`、`--position-{row,col}`、`--size-{width,height}` **全部必填**;唯一区别是**图片源(`--image-token` / `--image-uri`)可以全省**,省略即保留原图。这**不是**"只发改动字段"的 patch:缺任一核心字段会被拒绝(`+float-image-list` 不回传 `image_name`,CLI 无法替你回填)。
>
> 推荐流程:先 `+float-image-list --float-image-id <id>` 回读当前 position / size,再带上 `--image-name` 和完整的 position / size 调一次 `+float-image-update`。
```bash
# 调整位置 + 尺寸,保留原图(不传图片源)
lark-cli sheets +float-image-update --url "..." --sheet-id "$SID" \
--float-image-id "$IMG_ID" --image-name "logo.png" \
--position-row 5 --position-col C --size-width 300 --size-height 200
# 换图:额外带 --image-token,核心字段同样要给全
lark-cli sheets +float-image-update --url "..." --sheet-id "$SID" \
--float-image-id "$IMG_ID" --image-name "new-logo.png" --image-token "$NEW_TOKEN" \
--position-row 5 --position-col C --size-width 300 --size-height 200
```
### `+float-image-delete`
```bash
lark-cli sheets +float-image-delete --url "..." --sheet-id "$SID" --float-image-id "$IMG_ID" --yes
```
### Validate / DryRun / Execute 约束
- `Validate`:XOR 公共四件套;`+float-image-create` 要求 `--image` / `--image-token` / `--image-uri` **恰好给一个**,`--position-row/col` 与 `--size-width/height` 必填且为合法整数;传 `--image` 时还会校验路径安全(绝对路径 / 越出工作目录会被拒,`--dry-run` 同样拦)。`+float-image-update` 必须 `--float-image-id`,并和 create 一样必填 `--image-name` / `--position-{row,col}` / `--size-{width,height}`(缺任一核心字段本地直接报错,不会静默发 0);图片源 `--image-token` / `--image-uri` 可省(省略保留原图),给则二选一;`+float-image-delete` 强制 `--yes` 或 `--dry-run`。
- `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 float_image 请求模板";传 `--image` 时会多打印一步本地图片上传(`POST /open-apis/drive/v1/medias/upload_all`,`parent_type=sheet_image`)。
- `Execute`:写后不自动回读;create/update 后必须调用 `+float-image-list --float-image-id <id>` 比对位置与尺寸(它不回传 `image_name`,名称无从核对);delete 后 list 确认目标不存在。
references/lark-sheets-formula-translation.md›
# 飞书表格公式生成规则
> **本文定位**:飞书公式正确性的**唯一权威**——书写任何飞书公式、或把 Excel 公式迁移到飞书前,先读本文。涵盖公式书写约定(绝对引用、范围语法)、投影 vs spill、`ARRAYFORMULA` / 数组语义与逐行填充、高风险引用函数、日期差、不支持函数清单。
> **边界**:本文只讲"公式怎么写对";公式**怎么写入表格**(`+cells-set` / 模板单元格 + `--copy-to-range` / 容错回读)见 `references/lark-sheets-write-cells.md`。公式写入完成后必须用 `references/lark-sheets-formula-verify.md` 对本次公式范围逐段诊断并回读关键公式;不要把"翻译对了"误当成"结果一定正确"。本文不含 shortcut,通用编辑准则见主 SKILL.md「飞书表格编辑准则」。
**核心原则一:飞书不像 Excel 365 那样默认 spill(溢出展开)。** 某个参数要求单值、实际传入的却是区域时,飞书默认取"投影"(按公式所在行/列取对应的那一个值);只有当求值处于**数组公式上下文内部**——最外层套了 `ARRAYFORMULA`,或公式里已有原生数组函数(`FILTER` / `XLOOKUP` / `SORT` 等,见下方清单)——才逐项展开。`ARRAYFORMULA` 与"逐行标量公式 + `--copy-to-range` 填充"导出后都保真,按需要选:前者一条公式覆盖整片、写起来短;后者每格是独立公式,导出后在 Excel 里能单格编辑。
**核心原则二:`LAMBDA` 系高阶函数(`MAP` / `REDUCE` / `SCAN` / `BYROW` / `BYCOL` / `MAKEARRAY`)在飞书内算得对,但导出 `.xlsx` 会静默算错。** 导出时飞书只把 LAMBDA 体内联展开成普通数组表达式,**不保留高阶语义**,全程不报错:
- `REDUCE(0,A2:A6,LAMBDA(acc,x,acc+x))`(归约求和)→ `=0+A2:A6`,归约整个丢失
- `BYROW(A2:B6,LAMBDA(r,SUM(r)))`(逐行求和)→ `=SUM(A2:B6)`,5 个结果塌成 1 个
- `MAP(A2:A6,LAMBDA(x,IF(x>0,x,0)))` → `=A2:A6>0`,`IF` 整个消失
唯一例外是 `MAP` 且 LAMBDA 体为**纯运算符或单参函数**时展开恰好等价(`LAMBDA(a,b,a*b)` → `=A2:A6*B2:B6` ✓)。**除此之外一律改走 `ARRAYFORMULA` / 逐行填充 / 辅助列**——同样的逐项逻辑写成 `=ARRAYFORMULA(IF(A2:A6>0,A2:A6,0))` 导出后完整保留,写成 `MAP(...LAMBDA(...IF...))` 就丢。这类错误公式不报错、飞书里回读也是对的,只有导出后才暴露,靠事后检查发现不了。
## 公式书写约定(写任何公式都先满足)
- **绝对引用 `$`**:向下 / 向右填充前判断哪些引用要锁定——用户指定的固定 cell(`$C$3`)、要固定的数据范围(`$A$2:$B$5`)、锁列不锁行(`$A2`)、锁行不锁列(`B$1`)。填充前检查是否需固定汇率 / 税率 / 查找表 / 权重表,以及同列 / 同行公式结构是否一致。
- **公式字符串用飞书范围语法**:写 `H:H`、`A2:B5`,**禁止** `H2:H` / `2:2`。要在公式里引用整行,用显式范围(如 `$A2:$Z2`)替代禁用的 `2:2`。这与 CLI 工具参数(如 `--range` / `--copy-to-range`)的 A1 表示法写法不同:参数侧合法的 `D3:D`、`1:1`、`3:6` 在公式串里反而非法。**公式串 ≠ CLI 参数**,两套规则别互相照搬,混用会导致调用失败或公式报错。
- **产物要导出 xlsx 交付时优先 Excel 兼容函数**:同一计算能用 Excel 兼容函数(SUMIFS / TEXT / MID / FIND 等)表达就不用飞书特有函数(MAP / REGEXEXTRACT / ARRAYFORMULA 等)——特有函数在导出后的 xlsx 里可能无法重算;确需使用时,导出后核对重算正常再交付。
## 业务语义契约(复杂统计公式写前必做)
公式无错误码不等于业务逻辑正确。写入前把用户要求整理成一张短契约并逐项核对:
1. **字段**:用表头 + 3–5 行真实值确认“姓名/工号、开始/结束、秒/分钟”等列语义,禁止只凭列字母或列名猜测。
2. **阈值**:中文“以上 / 至少 / 不低于”用 `>=`,“超过 / 大于”用 `>`;“以下 / 至多 / 不高于”用 `<=`。阈值恰好相等的记录必须作为哨兵。
3. **单位与时区**:显式记录秒↔分钟、百分比↔小数、Unix 秒/毫秒与 UTC→本地时区转换;日期由时间戳计算时先用一条已知记录手算。
4. **完整范围**:公式范围覆盖源数据真实首末行,不能把探结构的前 N 行样本直接当计算范围;回读/落盘结果出现 `truncated` / `complete:false` 时先续读。
5. **业务哨兵**:写前用本地脚本或手算得到至少一条可核预期;写后同时核首、中、末、空值、阈值边界和该预期。`formula-verify success` 只证明没有公式错误码,不能替代这些结果断言。
## 翻译后建议:代码复现校验
公式语法翻译完之后,建议用本地脚本在源数据上独立复现一份"等价计算结果"再写入。流程:
1. **挑 3-5 个代表性输入行**(首行 / 中段 / 末行 / 含空值 / 含异常格式各一)
2. **用 Python 复现 Excel 原公式的语义**(不是飞书译文的语义,而是用户原本想要的结果)
3. **写入飞书译文公式后回读这几行的实际值**
4. **三方对照**:`Excel 原公式语义 == Python 复现 == 飞书译文回读值`;不一致时优先排查(数组语义?日期差?范围引用?),无法修完时在交付说明标明风险。
**理由**:Excel→飞书的语法翻译很容易在 spill / 数组 / 日期差 / 范围引用上出现等价性偏差,仅靠语法转换通过不足以保证业务结果正确。
## 落表后的默认交接
本文解决的是"公式怎么写对",不是"写进表里后一定能零错误运行"。因此:
1. 按本文完成公式改写后,用 `references/lark-sheets-write-cells.md` / `references/lark-sheets-batch-update.md` 把公式真实写入表格。
2. 公式一旦落表,必须对本次新增 / 修改的公式范围逐段运行 `+formula-verify --exit-on-error`;关键公式区还要回读首、中、末及汇总行的 `formula`。
3. 每段 `status='success'` 后才结束公式任务;`errors_found` 继续修复,`partial` 缩小 `--range` 或按 sheet 拆分续扫,不能用说明替代完整验证;AI 公式不套这条 success 收敛,按下方「AI 公式」的全区间一次异步状态检查规则交付。
**静态值改公式("让统计表跟随源数据变化"类任务)额外一步**:改写前先快照原静态值,公式写完后逐格与快照 diff。不一致时先尝试口径变体(`>` / `>=`、取整方式、匹配列)逼近原值;仍不一致不算失败——原静态值可能对应旧数据或含未声明口径——但必须在交付说明中给出 diff 表与所用口径的解释,禁止不声明差异直接交付。
**首次写计算结果时默认写公式**:在已有表上做统计、汇总、排名、分类计算等——无论源数据是已有单元格还是新读取的数据——默认把计算结果写成引用源数据的公式,不要用 Python 在本地算好数值再硬编码写入。公式优先原则的例外:外部抓取数据、永不变化的常量、循环引用。
## AI 公式(`AI` 函数)
飞书表格提供一个统一的 **`AI` 公式**:用自然语言描述需求,AI 返回文本结果。AI 公式的**写入方式与普通公式完全一致**(复用 `references/lark-sheets-write-cells.md` 的 `+cells-set` / `set_cell_range`,无需特殊接口),只是计算是**异步**的——写入后要等 AI 算完才有结果。
**AI 公式几乎必然含逗号 + 双引号(如 `=AI("翻译成中文", E2)`),默认走 `+cells-set` 的 JSON `formula` 字段,不要走 `+csv-put`**:`+csv-put` 会按逗号把公式拆列、写坏(详见 `references/lark-sheets-write-cells.md`)。`+cells-set` 里公式内部的双引号写成 `\"`。整列填充用模板 + `--copy-to-range`:
```bash
# 种子格写一条 AI 公式(内部引号用 \" 转义),再向下铺满整列
lark-cli sheets +cells-set --url <表URL> --sheet-name <子表名> \
--range D2 --cells '[[{"formula":"=AI(\"翻译成中文\", E2)"}]]' \
--copy-to-range "D2:D107"
# 写完先对种子格 D2 做一次 +cells-get --include formula 核对文本,再用 --ai-only 校验整个写入区间;禁止用 +cells-get 轮询计算结果
lark-cli sheets +formula-verify --url <表URL> --sheet-name <子表名> --range D2:D107 --ai-only
```
**校验纪律**:AI 公式计算是异步的。写完先做**一次性公式文本核对**(对种子格 / 首格做**一次** `+cells-get --include formula`,确认落进去的确实是 `=AI(...)` 而非 `#ERROR` 或残缺字面量)——这一步是**必经**的;被禁止的只是用 `+cells-get` / `+csv-get` 反复**轮询计算结果**。计算状态的**第一校验入口必须是 `+formula-verify --ai-only`**,`--range` 给**整个写入区间**(只读、成本低,不要抽样);注意 `--range` 只透传给后端,AI-only 汇总不保证按它收窄,**认返回里的单元格定位、不认总数**。交付判据(机读):`ai_formula_failed_count == 0`,`failed` / `unsupported` 先修;满足后即使仍有 `ai_formula_pending_count > 0` 也可交付,并告知用户后台仍在计算。若文本核对暴露出 `#ERROR` / 残缺字面量(半截括号 / 全角括号),那是公式串在写入层被转义写坏了、不是 AI 失败,回到 `+cells-set` 用 `\"` 重写(详见 `references/lark-sheets-formula-verify.md`)。
### 语法
```
=AI(prompt)
=AI(prompt, range)
=AI(part1, part2, ...)
```
- `prompt`:提示词,说明要 AI 做什么(可以是字符串常量,也可以引用单元格)。
- `range`:可选,交给 AI 处理的输入数据。可以是单个单元格(如 `A2`),也可以是一段单元格(如整行 `A2:G2` 或几列 `A2:C2`)——这段单元格会作为**这一次计算的输入上下文**一并喂给 AI,公式返回**一个**结果。具体写法见下方「常见用途」。
- **多参数拼接**:`AI` 接受多个参数,会按顺序把字符串常量与单元格 / 区域引用拼成一段完整提示词。可用来把散落在不同位置的值组进一句话,例如 `=AI("结合", A2, "和", A4, "的描述,总结3个关键词")`。
### 常见用途(同一个函数,靠提示词区分)
`range` 既可以是单个单元格,也可以引用整行 / 多列作为一次计算的输入上下文;也可以用多个参数把不同位置的值拼进同一句提示词:
| 场景 | 示例 |
|---|---|
| 翻译 | `=AI("翻译成日语", A2)` |
| 情感分析 | `=AI("判断客户情绪,只返回 Positive、Neutral、Negative", A2)` |
| 分类打标签 | `=AI("判断这封邮件是不是垃圾邮件", D2)`;结合多列辅助信息判断:`=AI("把餐厅归类到它所属的纽约市行政区,可参考街区信息", A2:C2)` |
| 信息提取 | `=AI("提取邮箱", A2)` / `=AI("提取手机号", A2)` |
| 总结 | `=AI("为这位客户的反馈写一句话总结", A2:D2)`;`=AI("用要点列出这段书籍摘要的主要主题", D2)` |
| 多值拼接 | `=AI("结合", A2, "和", A4, "的描述,总结3个关键词")` |
| 润色改写 | `=AI("改写得更正式", A2)` |
| 生成文案 | `=AI("用 10 个字以内为活动生成一句宣传语", A2)`;引用整行回应具体内容:`=AI("给评审写一封邮件,针对评审意见中的具体条目逐条回应", A2:G2)`;`=AI("根据这段岗位职责摘要,为该职位生成一组关键词", A2:C2)` |
| 数据清洗 / 标准化 | `=AI("统一公司名称写法", A2)` |
| 关键词提取 | `=AI("提取 5 个关键词,用逗号分隔", A2)` |
### 提示词最佳实践(写对提示词是结果稳定的关键)
AI 公式的质量高度依赖提示词。推荐:
1. **明确输出格式**:与其写"分析一下",不如写"判断情绪,只返回 Positive / Neutral / Negative"。限定可选值能让结果可机读、可再计算。
2. **指定语言**:写"翻译成中文"比只写"翻译"更稳定。
3. **指定长度**:如"总结成一句话""30 字以内"。
4. **需要结构化时明确要 JSON**:如提示"返回 JSON:{category:'', score:0-100}",AI 能较稳定地输出结构化结果。
### 与普通公式组合
`AI` 可以像普通函数一样嵌进公式链,引用单元格或区域:
```
=IF(B2>90, AI("夸奖一下这位员工"), "")
=IF(A2="", "", AI("翻译成英文", A2))
```
`AI(...)` 返回单个结果(标量),把它嵌进公式链时按标量对待即可,不要套 `TEXTJOIN` / `ARRAYFORMULA` 等按数组语义设计的写法——AI 公式不会 spill 出数组。
### 用 CLI 对一列逐行处理
对整列逐行跑 AI,推荐**模板单元格 + `--copy-to-range` 向下扩展**:在种子单元格写 `=AI("<提示词>", A2)`,再用 `--copy-to-range` 扩展到整列,相对引用会随行自增(`A2` → `A3` → …)。这样每行独立计算、行为可预测,比依赖单条公式一次铺开整列更稳。
**行数多时分批串行**:AI 公式是异步计算,一次扩展的行数越多越容易触发超时。普通公式可以照 `references/lark-sheets-write-cells.md` 的整列 / 到列尾(`H:H`、`D3:D`)用法一次铺开;**AI 公式**行数很多时建议按批(量级参考:每批约几百到一千行)**串行**扩展——写完一批、`+formula-verify --ai-only` 确认这批已进入计算后再铺下一批,不要一次铺极长的列,也不要多批并发。
**写完 AI 公式后的校验与交付**:先对种子格 / 首格做**一次** `+cells-get --include formula` 核对公式文本(**必经步骤**,确认落进去的是 `=AI(...)` 而非 `#ERROR` / 残缺字面量),随后计算状态的第一校验入口必须是 `references/lark-sheets-formula-verify.md` 的 `+formula-verify --ai-only --range <整个写入区间>`(只读、成本低,`--range` 覆盖全区间、不要抽样),**禁止用 `+cells-get` / `+csv-get` 轮询计算结果**。交付判据(机读):`ai_formula_failed_count == 0`(`--range` 不保证收窄汇总口径,按返回的单元格定位核对本次区间,不按总数比对);满足后即使仍有 `ai_formula_pending_count > 0` 也可以交付,飞书会在后台继续计算,交付时告知用户"AI 公式仍在后台运行,结果会陆续完成"。细节见 `references/lark-sheets-formula-verify.md`。
## 决策流程
1. 最终结果是**标量**(单值)→ 直接写普通公式
2. 最终结果是**一维或二维数组**:
- 公式中**包含**飞书原生数组函数(如 FILTER、XLOOKUP、MAP 等)→ 直接写,数组语义会自动传播到整个公式,包括原生数组函数外层接的标量运算(如 `+1`、`*100`)
- 公式中**不包含**任何原生数组函数,只是在对区域做标量计算 → 用 `ARRAYFORMULA` 包住整个表达式,或写成**单行标量公式再向下 / 向右填充**(`--copy-to-range`)
3. Excel 依赖 `ROW(range)` 逐项驱动 `SUBTOTAL/INDIRECT/OFFSET` → 拆成辅助列:辅助列每行写单行标量式(`=SUBTOTAL(103,INDIRECT("E"&ROW(E16)))`)向下填充,再对辅助列做聚合;但结果要随筛选联动时保持单条 `MAP(...LAMBDA(...))`,见下方「Excel 隐式逐项求值」
4. 内层 `INDEX/INDIRECT/OFFSET` 返回范围,外层 `SUMIF/COUNTIF/SUMIFS` 还要继续吃这些范围 → 同样拆辅助列逐行算,再聚合
5. 公式意图是"对多个区域分别计算再汇总"(例如用 INDIRECT/OFFSET 对每行生成一个范围,再对所有范围聚合)→ 飞书不能直接返回"区域的列表",必须明确降维:用 `VSTACK` 垂直合并、`HSTACK` 水平合并、`TOCOL/TOROW` 展平,或先把各段结果落到辅助区域再用普通聚合函数汇总
6. 算日期差 → 不要写 `DAY(end-start)`,用 `DAYS`、`DATEDIF` 或直接 `end-start`
## 飞书的投影行为(不是默认 spill)
触发条件是**参数要求单值、实际传入的却是区域**,此时飞书取"投影"而不是"spill":
- 单列区域 → 按当前公式所在行取值
- 单行区域 → 按当前公式所在列取值
- 二维区域 → 只有当前公式位置能映射到该区域时才取值,否则报错
- 数组常量 `{...}` 或函数返回矩阵,在普通标量上下文里通常只取左上角
**例外是数组公式上下文内部**:最外层套了 `ARRAYFORMULA`、或公式里已有原生数组函数时,同一个区域会逐项展开,不再投影。
因此(以下均指普通公式,即不在数组公式上下文里):
- `=A1:A2` 在飞书普通公式里不会 spill,只会投影到当前行
- `=ABS(A2:B2)` 不会得到一整行,要写 `=ARRAYFORMULA(ABS(A2:B2))`,或在 A、B 两格分别写 `=ABS(A2)` / `=ABS(B2)`
- `=TRUNC({1.1111,2.222},{1,2})` 要得到一整行,写 `=ARRAYFORMULA(TRUNC({1.1111,2.222},{1,2}))`
## 没有原生数组函数时:ARRAYFORMULA 或逐行填充
**前提:本节适用于公式中没有任何原生数组函数的情况。** 若公式中已有原生数组函数(如 FILTER、XLOOKUP、MAP 等),数组语义会自动传播到整个公式的求值过程(见下一节)。
以下运算与函数**只按标量求值**,直接喂整段区域不会逐项展开:
- 算术运算:`+ - * / ^ %`
- 比较运算:`= <> > >= < <=`
- 标量数学函数:`ABS ROUND INT TRUNC MOD LOG LN SQRT SIN COS TAN ...`
- 文本函数:`LEN LEFT RIGHT MID UPPER LOWER TRIM TEXT VALUE ...`
- 日期函数:`YEAR MONTH DAY DATE TIME EDATE EOMONTH ...`
- 条件函数:`IF IFS IFERROR IFNA NOT ISNUMBER ISTEXT ISBLANK ...`
- 引用函数(高风险):`INDEX OFFSET COLUMN ROW MATCH`
**两条等价做法,导出 `.xlsx` 后都保真,按需要选一条:**
- **`ARRAYFORMULA(<整个表达式>)`**:一条公式覆盖整片,写起来短。`=ARRAYFORMULA(A2:A100*B2:B100)` ✓、`=ARRAYFORMULA(IF(A2:A100>0,B2:B100,""))` ✓
- **逐行标量式 + 填充**:首行写 `=A2*B2` / `=IF(A2>0,B2,"")`,再用 `--copy-to-range` 铺到整列,引用随行递增。每格是独立公式,导出后在 Excel 里能单格编辑
`MAP` 只在 LAMBDA 体是**纯运算符或单参函数**时可用(如 `=MAP(A2:A100,B2:B100,LAMBDA(a,b,a*b))`);体内出现 `IF`、多参函数或字符串拼接就改用上面两条路,理由见开头核心原则二。
### 公式中有原生数组函数时,整个公式已进入数组模式
飞书的数组语义会在整个公式求值过程中累积传播:一旦某个原生数组函数运行,后续所有运算符和函数也会自动逐元素处理,无论它们出现在哪一层。
因此以下写法直接成立,不必再包 `ARRAYFORMULA`、也不必拆成逐行填充:
- `=FILTER(A2:A10,B2:B10="x")+1` ✓
- `=XLOOKUP(E2:E10,A2:A10,B2:B10)*100` ✓
- `=ABS(FILTER(A2:A10,B2:B10>0))` ✓
- `=MAP(A2:A10,LAMBDA(x,x*2))-1` ✓
## 原生数组函数清单
以下函数按数组语义工作,可直接返回整片结果,不必拆成逐行填充;且它们在 Excel 侧同样存在,可安全使用:
`CELL` `CHOOSECOLS` `CHOOSEROWS` `DROP` `EXPAND` `FILTER` `FREQUENCY` `GROWTH` `HSTACK` `LINEST` `LOGEST` `LOOKUP` `MINVERSE` `MMULT` `MUNIT` `RANDARRAY` `SEQUENCE` `SORT` `SORTBY` `SUMPRODUCT` `SWITCH` `TAKE` `TEXTSPLIT` `TOCOL` `TOROW` `TRANSPOSE` `TREND` `UNIQUE` `VSTACK` `WRAPCOLS` `WRAPROWS` `XLOOKUP`
`BYCOL` `BYROW` `MAKEARRAY` `MAP` `REDUCE` `SCAN` 同样是原生数组函数,但受核心原则二约束——导出 `.xlsx` 会丢高阶语义,默认改走 `ARRAYFORMULA` / 逐行填充 / 辅助列。
`ARRAYFORMULA` 不在上面这份清单里——它的作用是给**本来只按标量求值**的表达式套上数组语义,而不是自己返回数组。导出 `.xlsx` 时它会被翻译成 Excel 原生数组公式(`=ARRAYFORMULA(IF(A2:A6>2,B2:B6,""))` → `=IF(A2:A6>2,B2:B6,"")`,作用范围覆盖整片),语义完整保留,可安全使用。
> **注意:`SWITCH` 在飞书里被当作原生数组函数处理,这与 Excel 行为不同——把区域喂给它会逐项展开。**
## 跨电子表格取数不要用公式
飞书公式没有跨工作簿引用的通用写法(Excel 的外部链接迁过来也不成立)。需要另一份电子表格的数据时,先把那份数据读出来(`+csv-get` 等)落到本表的一张子表,再在本表内用普通引用计算——既避开跨表引用限制,也保证导出后公式仍可用。
## INDEX / OFFSET / COLUMN / ROW / MATCH 是高风险函数
这组函数容易让人误以为会自动把多值铺开,但在飞书里不能这样假设。
**高风险信号:**
- 行号 / 列号 / 偏移量本身是数组
- 结果本来应该是一行或一块二维区域
- 外层还有算术、比较、`IF` 等继续处理它
更稳的写法:整体包一层 `=ARRAYFORMULA(INDEX(...))` / `=ARRAYFORMULA(ROW(...))`;或退回**当前行的标量式再向下填充**——首行写 `=INDEX($A$2:$A$100,ROW(A1))`,向下填充时 `ROW(A1)` 自动递增为 1、2、3…
**例外:** 如果返回值只是立刻交给聚合函数消费,直接写即可:
- `=SUM(INDEX(A1:B2,0,1))` ✓
## Excel 隐式逐项求值,飞书里要拆辅助列
**典型特征:**
- 外层是 `SUMPRODUCT`、`SUM` 等聚合
- 内层用了 `SUBTOTAL`、`INDIRECT`、`OFFSET` 等更偏"单值/单引用"的函数
- Excel 会把中间结果逐项带进去算
- 飞书里直接照抄,往往不能得到同样的逐项语义
同类本质也包括:`INDEX/INDIRECT/OFFSET` 先返回范围,外层再把这些范围交给 `SUMIF`、`COUNTIF`、`AVERAGEIF`、`SUMIFS` 等范围感知函数 —— 飞书里这些外层函数不会自动二次展开内层范围。
这时要把"遍历"落到**辅助列**上,分两步:
```excel
辅助列首行(如 Z16):=单行计算逻辑 # 例:=SUBTOTAL(103,INDIRECT("E"&ROW(E16)))
用 --copy-to-range 铺满 Z16:Z387(引用随行递增)
汇总格: =SUM(Z16:Z387) # 需要时可隐藏辅助列
```
辅助列全是普通标量公式,导出 `.xlsx` 后逐格原样保留,也避开了 `LAMBDA` 系高阶函数的导出陷阱。
**例外:结果要随筛选联动时,保持单条公式。** `SUBTOTAL` 的意义就在于筛选变化后重新计算,这类需求写成
```excel
=SUMPRODUCT(MAP(ARRAYFORMULA(ROW($E$16:$E$387)),LAMBDA(row,SUBTOTAL(103,INDIRECT("E"&row)))))
```
筛选状态本身导出 `.xlsx` 就不会保留,所以这个场景是飞书内专用,不受核心原则二的导出约束。
其余同类场景走辅助列:
- `INDIRECT("A"&ROW(...))`
- `OFFSET(...,ROW(...)-ROW(...),...)`
- `SUBTOTAL(...)`
- `SUMIF(内层返回范围, ...)`
- `COUNTIF(内层返回范围, ...)`
- `SUMIFS(内层返回范围, ...)`
- 任何"希望对每一行 / 每一列各算一次"的模式
## 多层范围结果与三维以上结果
飞书公式结果只能是二维区域,不能是"数组的数组"。
### 多层范围不能自动二次展开
内层 `INDEX/INDIRECT/OFFSET` 返回的是二维范围,外层还想继续对这些范围做范围计算时,不要假设飞书会"再展开一层"。改用辅助列逐行算再聚合(见上一节),别把二次展开压进单条数组公式。
### 真正的三维或更高维结果不能直接返回
典型触发场景:想把多个不同区域或不同条件的结果合并展示,例如:
- 对 A 列、B 列、C 列分别做 FILTER,想把三列结果并排展示
- 对多个月份分别生成数据行,想把所有月份上下堆叠展示
飞书无法直接返回"多个区域的集合",必须先决定降维方式:
- 上下堆叠:`=VSTACK(slice1, slice2, slice3)`
- 左右拼接:`=HSTACK(slice1, slice2, slice3)`
- 压成单列:`=TOCOL(...)`
- 压成单行:`=TOROW(...)`
- 只保留聚合值:把各 slice 分别落到辅助区域,再用 `SUM` / `SUMPRODUCT` 等普通聚合函数汇总(`REDUCE` 受核心原则二约束,不要用)
不要替用户"偷定"第三维展示方式;如果用户没有明确说明怎么展示,至少先把结果改写成可见的二维形状。
## 不能机械照抄的 Excel 语法
### `@` 隐式交叉
Excel:`=@A1:A10`(强制单值,取当前行对应的值)
飞书没有 `@` 运算符。飞书普通公式对引用区域默认就有投影语义,去掉 `@` 即可:
- Excel: `=@A1:A10`
- 飞书: `=A1:A10`
### `#` spill range
Excel:`=A1#`(引用 A1 公式溢出的整片区域)
飞书没有此语法,迁移方式:
- spill 区域已知 → 改成明确范围
- spill 区域未知 → 回到源公式重写,或用 `TAKE` / `DROP` 截取
### 结构化引用
Excel:`=SUM(Table1[Amount])`
飞书不支持结构化引用,改成显式 A1 区域:`=SUM(A2:A100)`
### 老式 CSE 花括号
Excel:`{=A1:A10*B1:B10}`(Ctrl+Shift+Enter 输入)
飞书改为:`=ARRAYFORMULA(A1:A10*B1:B10)`——导出 `.xlsx` 后正好还原成 Excel 的 CSE 数组公式;或首行写 `=A1*B1` 再向下填充
## 日期序列与日期差
飞书日期序列:`0 = 1899-12-30`,`1 = 1899-12-31`,没有 Excel 的 1900 年闰年兼容问题。
**错误写法(不要用):**
- `=DAY(B2-A2)` ✗ — 差值会被当成日期序列号再拆字段
- `=MONTH(B2-A2)` ✗
- `=YEAR(B2-A2)` ✗
**正确写法:**
- 天数差:`=DAYS(B2,A2)` 或 `=DATEDIF(A2,B2,"D")` 或 `=B2-A2`
- 月份差:`=DATEDIF(A2,B2,"M")`
- 年份差:`=DATEDIF(A2,B2,"Y")`
- 工作日差:`=NETWORKDAYS(A2,B2)`
## 飞书不支持的函数
> 本段是"飞书不支持函数"的**唯一权威清单**。以下函数在飞书里不存在或被禁用,禁止主动使用;用户明确要求时应拒绝并提供替代方案:
- `STOCKHISTORY` — 实时股票数据,飞书无等价函数,需手动导入数据
- `WEBSERVICE` — 外部 HTTP 请求,飞书无等价函数
- CUBE 系列(`CUBEVALUE`、`CUBEMEMBER`、`CUBESET`、`CUBERANK` 等)— OLAP cube 函数,飞书不支持
- `GOOGLEFINANCE`、`GOOGLETRANSLATE` 等 Google 特有函数 — 无等价函数
- `FORECAST.ETS` 系列(`FORECAST.ETS`、`FORECAST.ETS.STAT` 等)— 飞书不支持
- `INFO`、`RTD` — 系统信息 / 实时数据函数,飞书不支持
- `PIVOT` — 用 `+pivot-{create|update|delete}` 透视表对象替代
- `AMORDEGRC`、`PHONETIC`、`DETECTLANGUAGE` — 飞书不支持
- `LET`、命名自定义函数(名称管理器里定义的 LAMBDA)、独立调用的 `LAMBDA`(如 `=LAMBDA(x,x+1)(5)`)— 会报 `#NAME?`;改用嵌套 IF / 辅助列。**例外**:`LAMBDA` 作为 `MAP` / `REDUCE` / `BYROW` / `BYCOL` / `SCAN` / `MAKEARRAY` 的内联参数时飞书**支持**,但受核心原则二约束(导出 `.xlsx` 丢高阶语义),默认仍走逐行填充 / 辅助列
## 代表性改写示例
- 基础逐项计算
- Excel: `=A2:A100*B2:B100`
- 飞书: `=ARRAYFORMULA(A2:A100*B2:B100)`;或首行 `=A2*B2` + `--copy-to-range` 向下填充
- 条件判断
- Excel: `=IF(A2:A100>0,B2:B100,"")`
- 飞书: `=ARRAYFORMULA(IF(A2:A100>0,B2:B100,""))`;或首行 `=IF(A2>0,B2,"")` + 向下填充(LAMBDA 体含 `IF`,不能用 `MAP`)
- 原生数组函数(无需改动)
- Excel: `=FILTER(A2:C100,B2:B100="East")`
- 飞书: `=FILTER(A2:C100,B2:B100="East")`
- 原生数组函数 + 标量运算(无需改动,数组语义自动传播)
- Excel: `=XLOOKUP(E2:E10,A2:A10,B2:B10)*100`
- 飞书: `=XLOOKUP(E2:E10,A2:A10,B2:B10)*100`
- 高风险引用函数
- Excel: `=INDEX(A1:D2,{2,1},0)`
- 飞书: `=ARRAYFORMULA(INDEX(A1:D2,{2,1},0))`(`col_num=0` 取整行必须包在 `ARRAYFORMULA` 里才成立,裸写会报 `#VALUE!`)
- 日期差
- 错误: `=DAY(B2-A2)`
- 推荐: `=DAYS(B2,A2)` 或 `=DATEDIF(A2,B2,"D")` 或 `=B2-A2`
- Excel 隐式逐项求值
- Excel: `=SUMPRODUCT(SUBTOTAL(103,INDIRECT("E"&ROW($E$16:$E$387))))`
- 飞书: `=SUMPRODUCT(MAP(ARRAYFORMULA(ROW($E$16:$E$387)),LAMBDA(row,SUBTOTAL(103,INDIRECT("E"&row)))))`(`SUBTOTAL` 要随筛选联动,保持单条公式)
- 多层范围 / 二次展开
- 错误思路: `=SUMIF(INDIRECT("E"&ROW($E$16:$E$387)),">0")`
- 飞书: 辅助列 `Z16` 写 `=SUMIF(INDIRECT("E"&ROW(E16)),">0")` 向下填充到 `Z387`
- 三维降二维(保留所有层)
- 飞书: `=VSTACK(slice1,slice2,slice3)` 或 `=HSTACK(slice1,slice2,slice3)`
- 三维降二维(只保留聚合值)
- 飞书: 各 slice 落到辅助区域后 `=SUM(辅助区域)`(不要用 `REDUCE`)
references/lark-sheets-formula-verify.md›
# Lark Sheet Formula Verify(+formula-verify)
> **本文定位**:飞书表格"公式写入后是否真的零错误"的诊断入口。公式的书写规则与 Excel→飞书迁移的语义规则一律以 `references/lark-sheets-formula-translation.md` 为唯一权威,本文不重复;本文聚焦"写完之后如何用一次调用发现公式错误"与 AI 公式的全区间一次异步状态检查交付。
>
> **边界**:本文不讲公式怎么写(去 `references/lark-sheets-formula-translation.md`),也不讲公式怎么写入表格(去 `references/lark-sheets-write-cells.md` / `references/lark-sheets-batch-update.md`)。本文只讲两件事:
>
> - **普通公式**:任务里发生公式落表、批量填充公式、`--copy-to-range` 扩展公式、导入含公式 workbook 时,对本次公式范围逐段跑 `+formula-verify --exit-on-error`;`errors_found` 修复、`partial` 拆分续扫,全部分段 `status='success'` 后才算完成。
> - **AI 公式**(`=AI(...)`):不要用普通公式的"轮询到 zero-error"逻辑;改用 `+formula-verify --ai-only --range` 按「AI 公式校验」的全区间一次异步状态检查规则交付。
## 为什么需要自检
飞书表格已经实时算好结果,但"算出来"和"算对了"是两件事。常见缺口:
- 公式编译失败 → 单元格落成文本(写入类 shortcut 返回的 `formula_errors[]` 是**编译失败**信号)。
- 公式编译成功但**运行时错误**:`#REF!` / `#DIV/0!` / `#VALUE!` / `#NAME?` / `#NULL!` / `#NUM!` / `#N/A`——这一类只看 `formula_errors[]` 看不到,必须扫单元格值。
`+formula-verify` 把两路信号合并成一份统一 JSON:一次调用聚合公式错误清单 + 编译失败清单 + 每类错误的定位与样本,调用方可据此定位修复。任务只要发生公式落表,就把它作为公式错误码健康检查;限定本次新增 / 修改的公式范围逐段扫描并带 `--exit-on-error`。`status='success'` 仅表示无编译/运行时错误,不判断字段映射、阈值、单位、口径或业务结果是否正确——业务语义哨兵见 `references/lark-sheets-formula-translation.md`。
## 调用契约
最小调用形态:
| 入参 | 含义 |
|---|---|
| `--url` / `--spreadsheet-token` | 表格定位(XOR 二选一,必填) |
| `--sheet-id` / `--sheet-name` | 限定子表(mutually exclusive;省略则扫全部可见子表) |
| `--range` | 限定 A1 范围;省略则用各 sheet 的 `current_region` |
| `--max-locations` | 每类错误样本上限,默认 20 |
| `--exit-on-error` | `status='errors_found'` 时返回非 0 退出码;`partial` 仍需调用方检查 status 并拆分续扫 |
| `--ai-only` | 只检查 `=AI(...)` 异步计算状态;与普通公式 7 类错误扫描分开使用 |
返回核心字段:
- `status` ∈ `success` / `errors_found` / `partial`——**唯一可机读的健康度判据**。
- `total_errors` / `total_formulas` / `scanned_cells`——本次扫描规模指标。
- `has_more`——为 true 表示扫描被内部上限截断(详见后文「截断与续读」),未覆盖完整范围。
- `error_summary[<错误类型>]`——每类错误的 `count` / `locations[]` / `samples[].{address,formula,depends_on}`。
- `compile_errors[]`——合并最近一次写入留下的编译失败清单,与运行时错误并存时同时出现。
- `warning_message`——仅在 `has_more=true` 时出现,告知调用方需要缩小 `--range` / 拆 `--sheet-id` 续读。
## 写入后诊断规则
任何批量公式 / 含公式列写入完成后,都必须对本次新增 / 修改的公式范围逐段调用 `+formula-verify --exit-on-error`。不要等用户显式说"校验一下公式"才执行;只要任务动作包含写公式,这一步就是完成路径的一部分。AI 公式不套这条:`=AI(...)` 是异步计算,按「AI 公式校验」的全区间一次异步状态检查规则交付,不等 `status='success'`。触发场景:
- `+cells-set` / `+csv-put`
- `+cells-set --copy-to-range` / 模板单元格向整列或整块扩展公式
- `+workbook-import`
- `+batch-update` 中含写入子操作
- `+table-put`(任意列含公式时)
- `+workbook-import`(导入的 xlsx 含公式时)
处置规则:
1. `status='success'` → 当前分段无编译/运行时错误;但还必须按 `references/lark-sheets-formula-translation.md` 的业务语义契约核字段、阈值、单位、完整范围和业务哨兵。全部目标分段均为 success 且哨兵值正确后才完成。
2. `status='partial'` → 扫描被内部上限截断;缩小 `--range` 或拆 `--sheet-id` 续扫,未扫描区域仍未知,不能用交付说明代替验证。
3. `status='errors_found'` 且 `compile_errors[]` 非空 → 根据 `compile_errors[].reason` 修正公式语法(飞书函数名 / 范围语法 / 引用样式);确实无法表达时才降级静态值,并说明原因与不联动风险。
4. `status='errors_found'` 且只剩运行时错误 → 按 `error_summary` 的 `samples[].formula` + `depends_on` 排查根因(零除?空值参与运算?引用越界?日期差写法?数组语义?),修复后重验。
5. 同一处错误连续修复 3 次仍未通过 → 可用 `IFERROR` 兜底或退回纯值,但降级后的目标格已不再是公式;需回读确认没有残留错误公式,并在交付说明写清不随源数据更新。
注意:
- 在 `status='errors_found'` 的状态下调用 `+cells-set --copy-to-range` 继续扩展会把错误复制放大,建议先处理关键错误。
- "编译失败但运行时无报错"不是 zero-error(编译失败的单元格此刻是文本不是公式,源数据一变就再也算不出值)。
- 只靠肉眼读首末 5 行确认不可靠——表中段、隐藏行、合并区里的错误这样根本看不到;`+formula-verify` 可补充这一诊断视角。
- 只验证写入区首行不够:批量填公式后同时抽查首行、中段、尾部和汇总行;目标是发现“只填到前 N 行”“把明细公式写进合计行”“尾部仍是空/错误值”这类问题。
- 修公式时先定位根因格,再看下游链路。不要把被上游错误污染的下游格全部重写;同型公式优先从相邻正确单元格复制/改引用,写完回读下游关键格是否仍有 `#VALUE!` / `#REF!`。
- 查找/匹配公式必须有错误处理:不要裸写 `VLOOKUP` / `XLOOKUP`。未匹配时返回明确文本(如“未匹配到”),不要静默空串,除非用户明确要求空值。
- 排名/排序公式要处理空值、0 值和不参与排名项;这些项应保持空/0,而不是进入通用排名公式得到正整数名次。
## 截断与续读
后端有一个内部硬上限对总扫描单元格数做截断(不暴露给调用方),超过后立即返回 `has_more=true` + `warning_message`,`error_summary` / `compile_errors` 仅覆盖已扫描部分。处理路径:
- 关键输出区优先按 `--sheet-id` / `--sheet-name` 拆成多次调用。
- 同 sheet 内按 `--range` 切片(如先 `A1:Z200` 再 `AA1:AZ200`),逐块诊断。
- 续扫是完成条件的一部分:本次写入的公式范围必须全部拆分扫描到 `success`,不能因时间不足只在交付说明里列未覆盖范围就结束(同处置规则 2)。确实无法在本轮扫完时,按处置规则 5 对未验证公式降级为静态值并声明,而不是留下未验证的活公式。
## AI 公式校验(`--ai-only`)
飞书表格提供一个统一的 **`AI` 公式**(`=AI(prompt, [range])`,用自然语言驱动翻译 / 分类 / 情感分析 / 信息提取 / 总结 / 润色等,写法与清单见 `references/lark-sheets-formula-translation.md`)。AI 公式的写入与普通公式一致(复用 `+cells-set` / `set_cell_range`,无需特殊接口),但**计算是异步的**:写入后要等 AI 算完才有结果。普通的 `+formula-verify` 只扫本地单元格值(7 类 Excel 错误),看不到 AI 公式的计算状态。
`--ai-only` 让 `+formula-verify` 只校验 AI 公式、跳过普通公式的 Excel 错误扫描,专用于写完 AI 公式后的异步状态检查。**它必须是第一校验入口;禁止先用 `+cells-get` / `+csv-get` 轮询 AI 结果。**
- **`--ai-only` 返回字段**(机读判据以这些为准,均为整数):
- `ai_formula_total`——后端返回的 AI 公式汇总计数,**不是本次写入的单元格条数**(同一批写入的多个 AI 公式可能只计为 1),`--range` 也不收窄它——**认返回里的单元格定位,不要拿它和本次预期条数做等值比对**。
- `ai_formula_done`——已算出结果的条数。
- `ai_formula_pending_count`——仍在后台计算(`pending`)的条数。
- `ai_formula_failed_count`——失败 / 不支持的条数。
- **异步预期**:少量 AI 公式通常很快算出结果;批量写入后部分公式仍为 `pending`(计算中)属于正常现象,飞书会在后台持续计算。
- **`--exit-on-error` 兼容**:`--ai-only --exit-on-error` 时,若 `ai_formula_failed_count > 0`,返回非 0 退出码,便于脚本 / CI 收敛。
- 可与 `--sheet-id` / `--sheet-name` / `--range` 共存,表示「只在指定范围里校验 AI 公式」。
- **普通公式不要带 `--ai-only`**:带上会跳过 7 类 Excel 错误扫描,普通公式等于没验。
**`--range` 用整个写入区间,不要抽样**:`--ai-only` 是只读操作、成本低,`--range` 应覆盖本次写入的**全部** AI 公式区间(而非代表性子集)——子集抽检会漏掉「只有列尾那批被写坏」的情况。但别把 `--range` 当过滤器用:它只透传给后端,AI-only 汇总不保证按它收窄,失败项要按返回的单元格定位核对是否落在本次写入区间内。区间过大触发截断(`has_more=true`)时按「截断与续读」拆 `--range` / `--sheet-id`。
**必经步骤:一次性公式文本核对(不是轮询)**。写完 AI 公式后,先对种子格 / 首格做**一次** `+cells-get --include formula`,确认引号 / 括号没在 shell / CSV / JSON 层被破坏、单元格里落进去的确实是 `=AI(...)` 公式而非残缺字面量或 `#ERROR`。这一步只做一次、只看文本,被禁止的只是**用 `+cells-get` 反复轮询计算结果**(结果状态一律走 `--ai-only`)。
交付判据(机读):全写入区间内 `ai_formula_failed_count == 0`;`failed` / `unsupported` 先修完再谈交付。满足后即使仍有 `ai_formula_pending_count > 0` 也可以交付,不必轮询到全部完成;交付时告知用户"AI 公式仍在后台运行,结果会陆续完成"。另外「公式在写入层被破坏、根本没算作 AI 公式」的静默失败不会体现为 `failed`,靠上面那次公式文本核对拦住——不要指望用 `ai_formula_total` 和预期条数对数(该总数未必按 `--range` 收窄)。
`ai_formula_failed_count > 0`,或文本核对暴露出 `#ERROR`、残缺括号(如 `E2)`)、半截函数名、全角括号时,说明公式串在引号层被破坏、没作为公式写进去——不要继续等 pending,回到 `+cells-set` 用 `\"` 转义重写该格(写入范例见 `references/lark-sheets-formula-translation.md` 的 AI 公式章节)。
典型用法:
```bash
# 写入一批 AI 公式后,对整个写入区间校验计算状态
lark-cli sheets +formula-verify --url <表URL> --sheet-name <子表名> --range <整个写入区间> --ai-only
# ai_formula_failed_count==0 即可交付;pending 会在后台继续计算
```
## 常见陷阱
| 坑 | 应对 |
|---|---|
| 错误字符串本地化 | 后端按内部 `error_kind` / `compute_status` 字段识别错误类别,不走字符串匹配;调用方拿到的 7 类英文错误代码由后端统一规范输出,与 locale 无关。 |
| `formatted_value` 可能隐藏错误 | 某些条件格式 / 自定义数字格式会把 `#DIV/0!` 显示成空白。后端直接读 cell `error_kind`,不依赖 `formatted_value`,绕开此类被遮蔽。 |
| 把 `partial` 当全量健康 | `partial` 仅表示**已扫描部分**无错误,剩余区域未知;缩小 ranges 或按 sheet 拆分,直到本次普通公式范围全部 success。 |
| 编译失败 vs 运行时错误 | 同一份报告里 `compile_errors[]` 与 `error_summary` 并存。语义层先解决 `compile_errors[]`、再做运行时自检。 |
references/lark-sheets-history.md›
# Lark Sheet History
## 概念回顾
每张飞书电子表格保留一串历史版本(`minor_histories`)。每个版本由 `history_version_id` 标识,并附带创建时间(`create_time`)、动作(`action`)与块修订信息(`all_block_revision`)。历史是**工作簿级**的(针对整张电子表格,不针对单个子表)。
回滚(revert)把电子表格的当前内容覆盖回某个历史版本——这是一个**高风险写入**操作,且为**异步**:发起后立即返回受理标识,真正的回滚在后台进行,需通过状态查询轮询最终结果(进行中 / 成功 / 失败)。
`+history-list` 读取版本列表以挑选目标;`+history-revert` 发起回滚;`+history-revert-status` 轮询回滚结果。若只是想拿**当前文档版本号(revision)**当作 recover / undo / `+changeset-get` 的起点锚点,直接用 `+revision-get` 更轻量。
## 使用场景
读取历史版本、发起回滚、查询回滚状态。本 reference 覆盖 3 个 shortcut:
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 查看历史版本列表 | `+history-list` | 返回 `minor_histories`,每条含 `history_version_id` / `create_time` / `action` / `all_block_revision` 四个字段;支持向前分页(可选 `--end-version`) |
| 回滚到指定历史版本 | `+history-revert` | 传入 `--history-version-id`;异步受理,返回可查询标识 |
| 查询回滚状态 | `+history-revert-status` | 传入 `--transaction-id`(取自 `+history-revert` 的异步受理标识);轮询某次回滚的进行中 / 成功 / 失败状态 |
典型工作流:`+history-list` 拿到目标版本的 `history_version_id`(必要时翻页拉取更早历史)→ `+history-revert` 发起回滚并取回 `transaction_id` → `+history-revert-status --transaction-id <transaction_id>` 轮询直到成功或失败。
**注意事项(必须了解)**:
- **回滚是高风险写入操作**:会用历史版本内容覆盖当前表格,执行前应明确告知用户影响。
- **回滚是异步的**:`+history-revert` 返回的是 `transaction_id`(受理标识),不代表回滚已完成;必须用 `+history-revert-status --transaction-id <transaction_id>` 确认最终结果。
- **`history_version_id` 与 `transaction_id` 不是同一个**:`history_version_id` 用于 `+history-revert`(取自 `+history-list`);`transaction_id` 用于 `+history-revert-status`(取自 `+history-revert` 的输出)。
- **历史是工作簿级**:定位只需 `--url` / `--spreadsheet-token`(XOR),不需要子表选择器。
- **`+history-list` 倒序分页**:首次查省略 `--end-version`,返回最新一页;若响应里附带 `next_end_version` 与 `has_more=true`,把 `next_end_version` 作为下一次的 `--end-version` 即可继续向更早翻页;当响应**不包含**这两个字段时表示已到最早一页,不必再翻。
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+history-list` | read | 历史版本 |
| `+history-revert` | high-risk-write | 历史版本 |
| `+history-revert-status` | read | 历史版本 |
## Flags
### `+history-list`
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--end-version` | int | optional | 分页查询的最大版本(倒序);首次查询省略,下一页传上一页返回的 next_end_version。 |
### `+history-revert`
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--history-version-id` | string | required | 要回滚到的历史版本(取自 +history-list) |
### `+history-revert-status`
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--transaction-id` | string | required | 异步回滚的受理标识(取自 +history-revert) |
## Examples
公共定位:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token`(XOR,二选一)。`+history-revert` 用 `--history-version-id`(取自 `+history-list`);`+history-revert-status` 用 `--transaction-id`(取自 `+history-revert` 的异步受理标识)。
### `+history-list`
```bash
# 列出某张电子表格的最新一页历史版本
lark-cli sheets +history-list --url "https://sample.feishu.cn/sheets/SHTxxxxxx"
# 用原始 spreadsheet token 定位
lark-cli sheets +history-list --spreadsheet-token "SHTxxxxxx"
# 翻到下一页:把上次响应里的 next_end_version 作为 --end-version 传入
lark-cli sheets +history-list --url "https://sample.feishu.cn/sheets/SHTxxxxxx" --end-version 12345
```
### `+history-revert`
```bash
# 回滚到指定历史版本(异步受理)
lark-cli sheets +history-revert --url "https://sample.feishu.cn/sheets/SHTxxxxxx" --history-version-id "<id-from-history-list>"
```
### `+history-revert-status`
```bash
# 查询某次回滚的当前状态(进行中 / 成功 / 失败)
lark-cli sheets +history-revert-status --url "https://sample.feishu.cn/sheets/SHTxxxxxx" --transaction-id "<transaction-id-from-history-revert>"
```
references/lark-sheets-pivot-table.md›
# Lark Sheet Pivot Table
## 真对象硬约束
当用户要求"透视表 / 分组汇总 / 交叉分析 / 按 X 统计 Y"时,**必须**通过 `+pivot-{create|update|delete}` 创建真实的透视表对象。**禁止**用 `SUMIFS` / `COUNTIFS` 等普通公式 + `+cells-set` 在原表中拼一张"看起来像透视表的汇总表"来代替。判断标准:交付后 `+pivot-list` 必须能返回该对象。
## 使用场景
读写透视表对象。本 reference 覆盖 4 个 shortcut:
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 查看已有透视表 | `+pivot-list` | 获取透视表的结构、数据源和配置 |
| 创建/更新/删除透视表 | `+pivot-{create|update|delete}` | 对透视表执行写入操作 |
典型工作流:先读取现有透视表了解配置 → 执行创建/更新/删除 → **必须再次读取验证结果**。
## 行/值字段映射(创建前必做)
创建透视表前先识别用户需求中的分组维度和聚合指标,**不要搞反**:
- **rows(行字段)** = 分组维度,即"按什么分组"。例:部门、地区、医生、产品类别
- **values(值字段)** = 聚合指标,即"统计什么数值"。例:销售额(聚合方式 `sum`)、订单数(聚合方式 `count`)
- **columns(列字段)** = 交叉维度(可选),即"再按什么横向展开"。例:月份、性别
| 用户说 | rows | values | columns |
|--------|------|--------|---------|
| "按部门统计人数" | 部门 | 姓名(`summarize_by: "count"`) | — |
| "按医生统计费用和结余" | 主管医生 | 费用(`"sum"`)、结余(`"sum"`) | — |
| "各部门男女人数" | 部门 | 姓名(`"count"`) | 性别 |
**常见配置错误(必须注意)**:
- **值字段类型与聚合器匹配**:`sum/average/median/product/stdDev/stdDevp/var/varp` 只用于数值列;数字个数用 `countNums`,非空记录数用 `count`。mixed 列先保留原值并新增清洗结果/失败标记,记录总数、成功、失败、空值和统计分母,再对清洗后的数值列聚合。
- **数据源范围必须精确**:透视表的数据源范围必须包含表头行,且精确覆盖全部数据行列。范围过大(包含空行/空列)或过小(遗漏数据列)都会导致透视表结果错误
- **行列字段选择要匹配用户意图**:用户说"按商品统计金额"→ 行字段=商品,值字段=金额(`summarize_by: "sum"`)。不要把行列字段搞反
- **聚合类型要匹配**:用户说"统计数量"→ `summarize_by: "count"`;"统计总额"→ `"sum"`;"统计平均"→ `"average"`。完整合法值:`sum` / `count` / `average` / `max` / `min` / `product` / `countNums` / `stdDev` / `stdDevp` / `var` / `varp` / `distinct` / `median`。按用户意图选聚合方式,不要拿 `count` 顶替 `sum`
- **`--properties` 还原生支持**:计算字段 `calculated_fields[].summarize_by ∈ {sum, custom}`、重复行标签 `repeat_row_labels: true`——别因速查表没列就判"不支持"绕路
- **参数长度限制**:如果透视表配置 JSON 过长(数据源范围跨越大量行列),可能导致工具调用失败。此时应先确认数据范围的精确边界,避免传入过大的 range
- **落点不能覆盖任何已有数据(不只是 `--source` 范围)**:透视表创建后会向右下**展开**,展开区域哪怕只盖到一个已有单元格(即便已避开源数据),也会报「目标位置不能与数据源重叠」并产生 `#REF!`。创建前无法精确预知展开尺寸,故**强烈优先默认策略**(不传 `--target-sheet-id/-name` 与 `--target-position`/`--range`,后端自动新建空白子表),零覆盖风险;非要落到已有子表,必须挑一片足够大的纯空白区
- **创建后轮询并校验**:调用 `+pivot-list --sheet-id/--sheet-name <落点表> --pivot-table-id <id>`。`Loading` / `ServiceCalcLoading` 是瞬态,继续轮询到 `info.loaded=true` 且 `error_state=None`;`Cover` / `Shrink` 等终态错误再删除重建。随后用 `info.content_range/page_range` 回读展开区,确认非空、尺寸、总计位置和用户点名的指标。
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+pivot-list` | read | 对象 |
| `+pivot-create` | write | 对象 |
| `+pivot-update` | write | 对象 |
| `+pivot-delete` | high-risk-write | 对象 |
## Flags
### `+pivot-list`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--pivot-table-id` | string | optional | 按 id 过滤 |
### `+pivot-create`
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--properties` | string + File + Stdin(复合 JSON) | required | JSON:{"rows":[...],"columns":[...],"values":[...],"filters":[...],"show_row_grand_total":true,"show_col_grand_total":true}(数据源走 --source,不要再放进 properties.source) |
| `--target-position` | string | optional | 透视表落点子表内的起始 cell(A1 格式,如 `A1`),默认 `A1`(值为 A1 时不下发)。它与 `--range` 落在同一 wire 字段 `properties.range`,给非默认值时优先于 `--range`;两者同时给非默认值会被拒绝,只传其一 |
| `--target-sheet-id` | string | xor | 透视表落点目标子表的 reference_id(与 `--target-sheet-name` 互斥,优先于 --target-sheet-name;都不传时自动新建一张子表放置透视表——推荐)。与数据源 sheet 区分:数据源 sheet 写在 --source 的 A1 引用里(带 sheet 前缀,形如 `'Sheet1'!A1:D100`)。 |
| `--target-sheet-name` | string | xor | 透视表落点目标子表的名称(与 `--target-sheet-id` 互斥;都不传时自动新建一张子表放置透视表——推荐)。与数据源 sheet 区分:数据源 sheet 写在 --source 的 A1 引用里(带 sheet 前缀,形如 `'Sheet1'!A1:D100`)。 |
| `--source` | string | required | 透视表源数据区域(A1 表示法,格式 `'SheetName'!StartCell:EndCell`,如 `'Sheet1'!A1:D100`) |
| `--range` | string | optional | 透视表左上角放置位置(A1 单值,如 `F1`,仅 create 生效),映射到 `properties.range`;省略时放在落点子表(默认新建子表)的左上角。它与 `--target-position` 落在同一 wire 字段,两者同时给非默认值会被拒绝,只传其一 |
### `+pivot-update`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--pivot-table-id` | string | required | 目标透视表 id |
| `--properties` | string + File + Stdin(复合 JSON) | required | 完整或足够完整的配置(先 `+pivot-list --pivot-table-id <id>` 回读再 patch) |
### `+pivot-delete`
_公共四件套 · 系统:`--yes`、`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--pivot-table-id` | string | required | 目标透视表 id |
## Schemas
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
### `+pivot-create` `--properties` / `+pivot-update` `--properties`
_创建/更新的透视表属性_
**顶层字段**:
- `range` (string?) — 放置透视表的左上角单元格 A1 地址(例如:'F1')(仅 create 时有效) — ⚠️ 已拎为独立 flag `--range`,请勿在此 JSON 内重复填写(同名以独立 flag 为准)
- `source` (string?) — 源数据区域地址,格式为 'SheetName!StartCell:EndCell'(例如:'Sheet1!A1:D100') — ⚠️ 已拎为独立 flag `--source`,请勿在此 JSON 内重复填写(同名以独立 flag 为准)
- `rows` (array<object>?) — 纵向分组字段(行字段) each: { field: string, display_name?: string, sort?: object, filter?: object, condition_filter?: object, …共 6 项 }
- `columns` (array<object>?) — 横向分组字段(列字段) each: { field: string, display_name?: string, sort?: object, filter?: object, condition_filter?: object, …共 6 项 }
- `filters` (array<object>?) — 筛选区域字段(页字段) each: { field: string, display_name?: string, filter?: object, condition_filter?: object, group?: object }
- `values` (array<object>?) — 要汇总的字段(至少需要 1 个) each: { field: string, display_name?: string, summarize_by?: enum, show_data_as?: enum, base_field?: string }
- `auto_fit_col` (boolean?) — 是否自动调整列宽以适应内容
- `show_row_grand_total` (boolean?) — 是否显示行总计(默认 true)
- `show_col_grand_total` (boolean?) — 是否显示列总计(默认 true)
- `show_subtotals` (boolean?) — 是否显示分类小计(默认 true,应用于所有字段)
- `repeat_row_labels` (boolean?) — 是否显示重复项标签
- `calculated_fields` (array<object>?) — 计算字段列表 each: { name: string, formula: string, summarize_by?: enum }
- `collapse` (object?) — 行字段展开/折叠状态:字段名 -> 要折叠的项目列表
## Examples
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`,其中 `--sheet-id` / `--sheet-name` 在 `+pivot-update` / `+pivot-delete` / `+pivot-list` 上是公共四件套语义(定位透视表所在 sheet,XOR 必传一个)。
**`+pivot-create` 例外**:placement 选择器用 `--target-sheet-id` / `--target-sheet-name`(至多一个、都可省略;省略时后端自动新建子表,推荐)。数据源 sheet 写在 `--source` 的 `'SheetName'!Range` 里。
### `+pivot-list`
```bash
lark-cli sheets +pivot-list --url "..." --sheet-id "$SID"
```
> **返回值含 `info`(展开后的占用区域与状态)**:每个透视表对象除 `position` / `snapshot` 外,还返回 `info`,标明它在 sheet 上的平铺区域与状态——`info.page_range`(筛选/分页区 A1)、`info.content_range`(主体数据区 A1)、`info.span_range`(空表合并区 A1)、`info.error_state`(错误状态,如 `None`/`Cover`/`Shrink`/`Loading`)、`info.is_empty` / `info.is_hidden`、`info.row`/`info.col`(锚点)等。
> **用途 1(判断改值还是改配置)**:当用户描述某个单元格要改动时,先 `+pivot-list` 拿到 `info`,判断该单元格是否落在 `page_range` / `content_range` 内——**落在区域内 = 属于透视表,应走 `+pivot-update` 改配置**(透视表单元格不能直接 `+cells-set` 改值);**落在区域外 = 普通单元格,正常 `+cells-set` 改值**。
> **用途 2(创建后校验覆盖)**:建完后轮询 `info.loaded/error_state`;`Loading` / `ServiceCalcLoading` 继续等待,`Cover` / `Shrink` 等终态错误才表示冲突。成功后用 `content_range/page_range` 核对真实占用区域与原数据边界。
### `+pivot-create`
> 数据源 `--source` 必须从表头行开始;空行 / 汇总行会被当作数据参与聚合,需提前用 `+csv-get` 确认起止边界。`--source` 和 `--range` 是独立 flag(不要再放 `--properties`);`rows` / `columns` / `values` 等数组字段走 `--properties`。
>
> **先理清 `+pivot-create` 上 4 个位置类入参(语义不同,别混)**:
> - `--source`(**必填**):**源数据**区域,须自带 `Sheet!` 前缀(如 `'Sheet1'!A1:D100`,sheet 名按 A1 标准单引号包裹)。源 sheet 的名字在 `--source` 字符串里,**不**通过单独 flag 传。
> - `--target-sheet-id` / `--target-sheet-name`:**透视表的落点 sheet**(即产物放哪张子表)。两个互斥(最多传一个),都不传时后端自动新建子表存放产物(强烈推荐)。
> - `--target-position`(可选,默认 `A1`)与 `--range`(可选)都映射到 `properties.range`,表达同一落点;不要同时给两个非默认值。
>
> **落点 3 种策略(互斥,选其一)**:
> 1. **默认(强烈推荐)**:`--target-sheet-id` / `--target-sheet-name` / `--target-position` / `--range` **全都不传** → 服务端**自动新建子表**存放产物,绝不碰任何已有数据。
> 2. **放进指定的已有子表**:传 `--target-sheet-id <落点子表 id>`(或 `--target-sheet-name`),可选 `--target-position <子表内起点 cell>`。⚠️ **若落点子表就是源数据所在的 sheet**,必须配 `--target-position` 或 `--range` 指向源数据范围**之外**的位置,否则产物默认从 A1 起会盖在源数据上。
> 3. **`--range`**:跟策略 2 等价(同样需要 `--target-sheet-id` / `--target-sheet-name` 指定落点子表,不然落到自动新建子表),只是改用 `--range` 表达同一落点(与 `--target-position` 同一 wire 字段)。同样的覆盖风险,同样需要避开源数据范围。
>
> 一般用策略 1(默认新建子表)即可,零覆盖风险,无需任何 `--target-*` / `--range` flag。
```bash
# 策略 1(强烈推荐):不传任何落点 flag → 后端自动新建子表,零覆盖风险
lark-cli sheets +pivot-create --url "..." \
--source "'Sheet1'!A1:D100" --properties @pivot.json
# 策略 2:落进指定的已有目标子表(注意目标 sheet ≠ 源 sheet,否则要配 --target-position 避开源数据)
lark-cli sheets +pivot-create --url "..." \
--source "'Sheet1'!A1:D100" --target-sheet-id "$DEST_SID" --target-position "A1" --properties @pivot.json
```
### `+pivot-update`
> 不允许改落点 range;更新配置前先 `+pivot-list --sheet-id/--sheet-name <落点表> --pivot-table-id <id>` 回读完整 snapshot,再 patch rows / columns / values / filters。需要切换数据源时,可在 `--properties` 中提供新的 `source`。
### `+pivot-delete`
```bash
lark-cli sheets +pivot-delete --url "..." --sheet-id "$SHEET_ID" --pivot-table-id "$PIVOT_TABLE_ID" --yes
```
### Validate / DryRun / Execute 约束
- `Validate`:`--url` / `--spreadsheet-token` XOR 必填;update/delete/list 的 `--sheet-id` / `--sheet-name` XOR 必填;create 的 target selector 至多一个、可都省略;`--source` 与合法 `--properties` 必填;delete 强制 `--yes` 或 `--dry-run`。schema 校验类型与枚举,但允许创建空壳配置,业务完整性须靠创建后 list/data 验证。
- `DryRun`:输出将发送的 pivot 请求模板和本地 placement_warning;不联网、不预估实际展开尺寸。
- `Execute`:写后不自动回读;create/update 后必须按落点 sheet + pivot id 轮询 `+pivot-list` 到 loaded,核 error_state/content_range 与数据;delete 后 list 确认目标不存在。
> ⚠️ pivot 输出包含总计 / 小计行;后续 chart 引用 pivot 时,`snapshot.data.refs` 必须排除这些行(见 `references/lark-sheets-chart.md` 的「⚠️ chart 数据源引用 pivot 时必须排除总计行」段)。
references/lark-sheets-range-operations.md›
# Lark Sheet Range Operations
## 结构性操作影响面预检(清除 / 合并 / 排序 / 移动前必做)
`+cells-clear`、`+cells-{merge|unmerge}`、`+range-{move|copy|fill|sort}`(移动 / 复制 / 排序 / 自动填充)都会让既有引用关系发生偏移或失效。**操作前必须**先确认以下两点;否则禁止执行:
1. **打印当前合并单元格 + 公式引用 + 数据验证范围**:用 `+sheet-info --include merges` + `+cells-get` 抽样目标区域和它周边的公式 / 透视表 / 图表 / 条件格式 / 筛选器的数据源;评估操作后这些引用是否仍指向正确数据。
2. **`+cells-clear` 不得侵入用户授权范围之外**:清除范围只能是用户明示要清的区域;不要顺手清除"看起来没用"的相邻单元格。
排序场景的存储类型识别 + 辅助列抽数值的细则见下方「sort 操作前必读」章节。
## 使用场景
写入。对指定区域执行结构性操作。本 reference 覆盖 9 个 shortcut,按 4 类用途组织:
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 清除内容/格式 | `+cells-clear` | "清空"、"删除内容"、"去掉格式" |
| 合并/取消合并单元格 | `+cells-{merge|unmerge}` | "合并单元格"、"取消合并" |
| 调整行高/列宽 | `+rows-resize / +cols-resize` | "加宽列"、"调整行高"、"自适应列宽" |
| 移动/复制/填充/排序 | `+range-{move|copy|fill|sort}` | "移动数据"、"复制到"、"自动填充"、"按某列排序" |
注意:
- **`--range` 两种语法别混**:`+cells-clear` / `+cells-{merge|unmerge}` / `+range-*` 用单元格 A1 矩形(如 `A2:A10`);`+rows-resize` / `+cols-resize` 用纯行 / 列区间(行 `2:10`、列 `A:C`),不要给 resize 传 `A2:A10`
- 用户说"这行 / 整行 / 首行"时,优先使用整行范围如 `1:1`;"这列 / 整列"时使用 `J:J`。不要截断为局部矩形
- 合并后只保留左上角单元格的内容,其余清除。写入合并区域用 `+cells-set` 对左上角单元格操作
- 调整行高列宽时,先读取相邻行列尺寸再决定像素值,不要随意猜测
- `--copy-to-range`(`+cells-set` 的参数)复制的是值/公式/样式,不含行高列宽。需要统一尺寸时另行调用 `+rows-resize / +cols-resize`
**排序必须覆盖完整记录宽度**:`+range-sort --range` 是整行记录原子移动的边界,必须从记录第一列覆盖到最后一列;“按 B 列排序”只表示 `--sort-keys` 选 B,不是把 range 写成 `B:B`。范围含表头时加 `--has-header`。排序后回读前几行和末行,确认各列仍保持同行关系。
## 写入后列宽自适应(防内容遮挡)
写入文本 / 数值后**必须**主动检查列宽是否适配,否则会出现"内容被截断 / 长数字显示为科学计数法 / 文本溢出被相邻列遮挡"等用户感知问题:
1. **写入后回读最长内容字符数**:用 `+csv-get` 读目标列的实际写入内容,统计最长单元格的字符数(`max(len(cell) for cell in col)`)。汉字按 2 字符宽度估算,半角字母数字按 1 字符。
2. **判定阈值**:当前列宽(用 `+sheet-info --include row_heights,col_widths` 拿)≥ 最长字符数 × 字体宽度系数 + buffer 才算适配。默认列宽 11 通常只够 11 个半角字符或 5-6 个汉字,写长文本前必扩宽。
3. **修复二选一**:
- **扩列宽**:用 `+rows-resize / +cols-resize` 把目标列宽设为 `max(表头字符数, 内容采样最长字符数) × 8 + 16` 像素(经验值)
- **自动换行**:在 `+cells-set` 时给单元格设置 `cell_styles.word_wrap="auto-wrap"`(可选值:`overflow` / `auto-wrap` / `word-clip`;`cell_styles` 字段见 `references/lark-sheets-write-cells.md`),并用 `+rows-resize / +cols-resize` 调高对应行的行高
4. **新增列默认列宽规则**:新增列宽度 ≥ `max(表头字符数, 内容采样最长字符数) × 8 + 16` 像素,**禁止**用默认 11 直接交付。
**典型反例**:默认列宽 11 但内容含 12+ 字符的中文 / 含单位的数值(如 `109.10μmol/L`)/ 长数字未设 `number_format` 显示为科学计数法 —— 用户在结果表里看不到完整原值。
**打印场景控制总宽(用户说"适合打印 / A4 / 打印范围"时必做)**:扩单列宽防截断的同时,**所有列宽之和要落在纸张可打印宽度内**——A4 横向约 ≤ 102 个半角字符(约 1000px),纵向约 ≤ 70 个字符。超宽时不要无限加宽,改用 `cell_styles.word_wrap="auto-wrap"` + 调高行高,或缩窄非关键列,让整表在一页内(反例:总列宽远超 A4 可打印宽度,且长文本行高不够被截断)。
**只加宽承载新内容的列,不改动原有列的列宽**:列宽自适应**只针对新增 / 真正放不下新内容的列**;原表已有列的列宽**禁止重新计算、禁止缩小**——即便你估算的"理想宽度"与原值不同,只要原内容没被截断就不要动它。无差别地把所有列重设一遍宽度(哪怕只 ±1)都属于破坏原文件视觉格式(反例:填完数据后顺手把原有列的列宽从 16 改成 17,与原附件不一致,破坏了原视觉格式)。
**⚠️ 合并单元格安全操作规则**(`+cells-{merge|unmerge}` 必读):
1. **先读后写**:操作前必须用 `+sheet-info --include merges` 或 `+cells-get` 识别已有合并区域(特征:多个连续单元格中只有左上角有值,其余为空)。
2. **不要对已合并区域重复 merge**:对已合并的区域再次调用 merge 会报错或产生不可预期结果。
3. **修改合并区域的正确顺序**:先 `unmerge` → 修改内容/样式 → 再 `merge`。
4. **对合并区域设置样式**:只对完整 range 设置一次 `cell_styles`(写在左上角单元格),其余位置用 `{}` 占位。
5. **新增合并时数据保护**:合并前确认目标区域只有左上角有数据,其余单元格为空,否则合并会导致非左上角的数据丢失。
6. **批量取消合并一次调用即可**:当一个范围(整列 `A:A`、整行 `3:3`、矩形 `A1:D100`)内存在多个合并区域,直接调一次 `+cells-unmerge` 传入这个大范围,会一次性取消该范围内所有合并区域;**不要**为每个合并区域单独调用 unmerge,也不要用 `+batch-update` 拆成多次 unmerge。
7. **合并 / 取消合并后必须验证**:`+sheet-info --include merges` 核目标范围,再 `+cells-get` 回读左上角值和非左上角清空状态。
**⚠️ 多区域合并不要逐个调用**:对**多个**不同区域执行 `+cells-merge` 时,写成一份 `+styles-put --styles` 的 `cell_merges` 一次交付(合并与样式 / 行高列宽 / 冻结同属一份声明式规格,见 `references/lark-sheets-styles-put.md`);只有当合并夹在**跨类型、有顺序依赖**的操作链里(如插列 → 合并 → 写表头)才用 `+batch-update`(fail-fast,失败处置与入参格式见 `references/lark-sheets-batch-update.md`)。行高列宽同理**不需要** `+batch-update`:多行 / 多列不同尺寸直接用 `+rows-resize --heights` / `+cols-resize --widths` 的 map 形态,一次调用完成。
**唯一例外**:`+cells-unmerge` 原生支持传一个大 range 一次性取消其中所有合并区域,应直接单次调用,**不要**拆进 `+batch-update`。
**⚠️ sort 操作前必读:确认目标列的数据类型**
排序按单元格的**存储类型**比较:纯数字按数值排序;文本字符串按**字典序**(`"1000"` 排在 `"999"` 之前,与数值相反);日期按时间戳排序。
以下形态**看起来像数字但实际是字符串**,直接 sort 会得到错误结果:
| 示例 | 说明 |
|------|------|
| `843688.69+20042.35=863731.04` | 表达式文本(无前导 `=` 不是公式,整串按字典序比较) |
| `¥1,234.56` / `$1,234` | 带货币符号 |
| `1.2万` / `3.5亿` / `100kg` | 带中文 / 英文单位 |
| 前后含空格或不可见字符的数字串 | 被当文本 |
| 同列混文本和数字 | 排序后分块 |
**硬性流程**:
1. sort 前先用 `+csv-get` 抽样目标列的前 3–5 行确认原始值形态,不要只看列名和用户问题就直接排。
2. 若是纯数字或日期 → 直接 sort。
3. 若是带符号 / 表达式 / 单位的文本 → **不要直接排,也不要读值后用 `+csv-put` 覆盖原表来模拟排序**:
- 简单场景(货币、千分位、单位前缀):新增辅助列,用公式提取数值(如 `=VALUE(SUBSTITUTE(SUBSTITUTE(A2,"¥",""),",",""))`),再用 `+range-sort` 按辅助列原子排序;排完可按需删除辅助列。
- 复杂场景(多段表达式、中文单位、混合格式):先写辅助数值列,再用 `+range-sort`;无法可靠提取时保留原顺序并说明,禁止整块覆盖回写。
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+cells-clear` | high-risk-write | 单元格 |
| `+cells-merge` | write | 单元格 |
| `+cells-unmerge` | write | 单元格 |
| `+rows-resize` | write | 工作表 |
| `+cols-resize` | write | 工作表 |
| `+range-move` | write | 区域 |
| `+range-copy` | write | 区域 |
| `+range-fill` | write | 区域 |
| `+range-sort` | write | 区域 |
## Flags
### `+cells-clear`
_公共四件套 · 系统:`--yes`、`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | 清除范围(A1 格式) |
| `--scope` | string | optional | 清除范围 enum:`content`(默认,仅清内容)/ `formats`(仅清格式)/ `all`(清内容 + 格式)(可选值:`content` / `formats` / `all`) |
### `+cells-merge`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | 待合并 / 取消合并的范围(A1 格式) |
| `--merge-type` | string | optional | 合并方向(仅 `+cells-merge`)(可选值:`all` / `rows` / `columns`)(默认 `all`) |
### `+cells-unmerge`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | 待合并 / 取消合并的范围(A1 格式) |
### `+rows-resize`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--height` | int | xor | 统一行高(像素,例:30 / 40 / 60;不是磅/points),配 `--range` 使用。传了 `--height` 就是像素模式,可以省略 `--type`;显式 `--type pixel` 也行(等价)。多行不同高用 `--heights` |
| `--heights` | string + File + Stdin(复合 JSON) | xor | 差异化行高 map,一次调用给多行设置不同高度:键为单行(`"1"`)或行闭区间(`"2:20"`),值为像素高(如 30 / 50)、`"auto"`(自适应内容)或 `"standard"`(重置默认)。⚠️ 单位是像素,不是磅/points。与 `--range` / `--height` / `--type` 互斥 |
| `--type` | string | xor | 尺寸方式 enum:`pixel`(需配 `--height`)/ `standard`(重置为默认行高)/ `auto`(自动适应内容)。常规写法直接给 `--height` 即可省略本 flag;`--type standard` / `--type auto` 不能与 `--height` 同时给(可选值:`pixel` / `standard` / `auto`) |
| `--range` | string | xor | 要调整行高的行闭区间;1-based 行号如 `2:10` 或单行 `5`。统一尺寸形态必填(配 `--height` 或 `--type`);map 形态(`--heights`)不传 |
### `+cols-resize`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--width` | int | xor | 统一列宽(像素,例:80 / 120 / 200;不是 Excel 字符单位),配 `--range` 使用。传了 `--width` 就是像素模式,可以省略 `--type`;显式 `--type pixel` 也行(等价)。多列不同宽用 `--widths` |
| `--widths` | string + File + Stdin(复合 JSON) | xor | 差异化列宽 map,一次调用给多列设置不同宽度:键为单列(`"A"`)或列闭区间(`"C:E"`),值为像素宽(如 80 / 120 / 200)或 `"standard"`(重置默认)。⚠️ 单位是像素,不是 Excel 字符单位(像素 ≈ 字符数×8+16)。与 `--range` / `--width` / `--type` 互斥 |
| `--type` | string | xor | 尺寸方式 enum:`pixel`(需配 `--width`)/ `standard`(重置为默认列宽)。常规写法直接给 `--width` 即可省略本 flag;`--type standard` 不能与 `--width` 同时给(可选值:`pixel` / `standard`) |
| `--range` | string | xor | 要调整列宽的列闭区间;列字母如 `A:E` 或单列 `C`。统一尺寸形态必填(配 `--width` 或 `--type`);map 形态(`--widths`)不传 |
### `+range-move`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--source-range` | string | required | 源 A1 范围 |
| `--target-sheet-id` | string | optional | 目标子表 id;省略时同源 sheet |
| `--target-range` | string | required | 目标 A1 范围(传起点 cell 即可,按源尺寸自动推断) |
### `+range-copy`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--source-range` | string | required | 源 A1 范围 |
| `--target-sheet-id` | string | optional | 目标子表 id;省略时同源 sheet |
| `--target-range` | string | required | 目标 A1 范围(传起点 cell 即可,按源尺寸自动推断) |
| `--paste-type` | string | optional | 粘贴内容(仅 `+range-copy`)(可选值:`values` / `formulas` / `formats` / `all`)(默认 `all`) |
### `+range-fill`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--source-range` | string | required | 填充模板范围(系列起始 cells) |
| `--target-range` | string | required | 目标填充范围(A1 格式) |
| `--series-type` | string | optional | 填充序列类型(可选值:`auto` / `linear` / `growth` / `date` / `copy`)(默认 `auto`) |
### `+range-sort`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | 排序范围(A1 格式;含或不含表头由 `--has-header` 决定) |
| `--sort-keys` | string + File + Stdin(复合 JSON) | required | JSON 数组:`[{"column":"<列字母>","ascending":<bool>}, ...]` |
| `--has-header` | bool | optional | 第一行是表头不参与排序,默认 false |
## Schemas
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
### `+rows-resize` `--heights`
_行 → 高度 map_
- type: object
### `+cols-resize` `--widths`
_列 → 宽度 map_
- type: object
### `+range-sort` `--sort-keys`
_排序条件列表(仅 sort 操作)_
**数组项**(类型 object):
- `column` (string) — 排序依据的列字母(如 "C"、"D"),必须在 range 范围内
- `ascending` (boolean) — 是否升序排序
## Examples
> ⚠️ 本 reference 派生的 shortcut 跨 3 个分组:`+rows-resize` / `+cols-resize` → 工作表,`+cells-*` → 单元格,`+range-*` → 区域。这里统一从区域操作视角讲解。
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。
### `+cells-clear`
> ⚠️ **`--scope all` 清整表是不可逆的大范围破坏**:会一并抹掉该区域的合并单元格、原公式,以及图表 / 透视表引用的数据源列(这类列常在主数据区右侧,视觉上"看着没用"却被图例 / 系列引用)。**"美化 / 规范化一张已有表"永远不需要 clear 原表再重写**——若你打算"清空原表 → 写入重排后的版本",说明走错了路径,应改为原地只刷样式(见 `references/lark-sheets-visual-standards.md` 场景三)。
> **删不掉嵌入对象**:`+cells-clear`(任何 `--scope`,含 `all`)只清单元格的值 / 格式,**删不掉**压在范围内的透视表 / 图表等嵌入对象——后端会报 `can not find embedded block`。删透视表用 `+pivot-delete`、删图表用 `+chart-delete`(先用 `+pivot-list` / `+chart-list` 拿对象 id)。
> 需要一次清除**多个不连续 range**(如把内容搬走后批量去掉散落各处的边框/底色)时,改用 `references/lark-sheets-batch-update.md` 的 `+cells-batch-clear`,避免对 `+cells-clear` 逐个 range 调用。
```bash
# dry-run 先看
lark-cli sheets +cells-clear --url "..." --sheet-id "$SID" --range "A2:Z1000" --scope all --dry-run
# 执行
lark-cli sheets +cells-clear --url "..." --sheet-id "$SID" --range "A2:Z1000" --scope all --yes
```
### `+cells-merge` / `+cells-unmerge`
```bash
# 合并 A1:C1(可选 --merge-type all/rows/columns)
lark-cli sheets +cells-merge --url "..." --sheet-id "$SID" --range "A1:C1"
# 取消合并:传大 range 一次性取消其中所有合并区域
lark-cli sheets +cells-unmerge --url "..." --sheet-id "$SID" --range "A1:C100"
```
### `+rows-resize` / `+cols-resize`
行高列宽分两条 shortcut,避免行 / 列在底层 schema 的差异(行支持 `auto`,列不支持)混在一起。两种形态:
- **统一尺寸**:`--range` + `--height`/`--width <px>`(省略 `--type`,等价于 `--type pixel`)。非像素模式走 `--type standard` / `--type auto`,此时不能再带像素值。
- **差异化尺寸**:`--heights`/`--widths` 一个 JSON map,键为单行/列或闭区间、值为像素或模式字符串,**一次调用完成多行 / 多列不同尺寸**——不要拆多次调用,也不要用 `+batch-update`。
```bash
# 统一尺寸:把第 2-10 行设为固定 30 px
lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" --range "2:10" --height 30
# 统一尺寸:把 A-C 列设为固定 120 px
lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" --range "A:C" --width 120
# 差异化尺寸:多列不同宽,一次调用(值可混用 "standard" 重置某列)
lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" \
--widths '{"A": 100, "B": 358, "C:E": 120, "G": "standard"}'
# 差异化尺寸:多行不同高,值可混用 "auto" / "standard"
lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" \
--heights '{"1": 50, "2:20": 30, "21": "auto"}'
# 第 1 行行高自动适应内容(列宽不支持 auto)
lark-cli sheets +rows-resize --url "..." --sheet-id "$SID" --range "1" --type auto
# 重置 A-E 列为默认列宽
lark-cli sheets +cols-resize --url "..." --sheet-id "$SID" --range "A:E" --type standard
```
**⚠️ 单位是像素,不是 Excel 字符单位 / 磅**:列宽常见 60~400px;如果你按 Excel 字符单位(openpyxl / xlsxwriter 的 `width`)心算,先换算 `px ≈ 字符数 × 8 + 16`——写 `{"A": 10}` 得到的是 10px 的不可用窄列(CLI 会拒绝 < 20px 的列宽并提示换算)。行高是像素不是磅(points),默认行高约 24px。
**列宽没有 auto-fit**:需要"列宽自适应内容"时,按"写入后列宽自适应"一节的公式估算像素值(`max(表头字符数, 内容最长字符数) × 8 + 16`)后用 `--widths` 显式设置。
> 同时出现在 `references/lark-sheets-sheet-structure.md` —— 行高 / 列宽调整也算行列结构层动作。
### `+range-move` / `+range-copy`
> `+range-move` 会**清空源区域**(move = copy + clear_source);`+range-copy` 不动源。
### `+range-fill`
```bash
# 用 A1:A2 的序列规律向下填充到 A3:A100(target 区域不能与 source 重叠,否则后端报 source overlaps destination)
lark-cli sheets +range-fill --url "..." --sheet-id "$SID" --source-range "A1:A2" --target-range "A3:A100" --series-type auto
```
### `+range-sort`
```bash
# 按 C 列降序排 A1:E100(首行为表头不参与)
lark-cli sheets +range-sort --url "..." --sheet-id "$SID" --range "A1:E100" --has-header --sort-keys '[{"column":"C","ascending":false}]'
```
### Validate / DryRun / Execute 约束
- `Validate`:XOR 公共四件套;`+cells-clear` 强制 `--yes` 或 `--dry-run`;`+range-*` 校验源 / 目标 range 在同一 spreadsheet;`+range-sort` 的 `--sort-keys` 必须合法 JSON 数组且 col 都在 `--range` 内;`+rows-resize` / `+cols-resize` 两种形态二选一——统一形态必须给 `--range` 且至少给 `--height`/`--width` 或 `--type` 之一(`--type standard`/`auto` 不能与像素 flag 同给,`--type pixel` 共存 OK),map 形态(`--heights`/`--widths`)不能与 `--range`/`--height`/`--width`/`--type` 混用,map 键必须与命令维度一致(行数字 / 列字母)、不得重复,值为正整数像素或模式字符串;列宽 < 20px 拒绝(疑似 Excel 字符单位);`+cols-resize` 不接受 `auto`(列宽不支持自适应)。map 形态在 `+batch-update` 子操作里不可用(它本身就是批量提交)。
- `DryRun`:所有写操作输出"将要 PATCH 的 range + 受影响 cell 数估算"。
- `Execute`:sort/move/copy/fill 后回读首、中、末记录;merge/unmerge 后 `+sheet-info --include merges` + `+cells-get` 核范围、左上角值与边界;clear 后确认目标 scope 已空;resize 结果用 `+sheet-info` 核尺寸,不能只读 cell 值。
references/lark-sheets-read-data.md›
# Lark Sheet Read Data
## 列格式多样性预探(写公式 / 排序 / 筛选前必做)
> 本节给出"写公式 / 排序 / 筛选前先探清列格式多样性"的正确流程,是主 SKILL.md「飞书表格编辑准则」准则 3(读全再写)在 read_data 工具层的落地。
对参与后续**计算 / 排序 / 筛选 / 公式提取**的列,**必须**先 sample **至少 50 行**(小表则全量),识别该列所有值类型变体后再设计公式 / 条件。只看前 10 行不够,因为下列差异通常潜伏在表尾或中段:
- **日期列同时出现多种格式**:`YYYYMM`、`YYYY-MM-DD`、`YYYY/M/D`、带时间戳、文本"未知"
- **数值列混入公式文本 / 单位 / 注释**:`1000+200=1200`、`100元`、`/(合同未明确)`、`#N/A`
- **空值与 0 / "0" 混杂**
- **大小写 / 全角半角差异**("办公费" vs "办公费 "、"Sales" vs "sales")
预探后必须在公式 / 筛选条件里用 `IFERROR` / `IFS` / 提取数值的辅助列处理所有变体;不能为了通过 head(10) 的样本就直接落地。设计的逻辑只覆盖 sample 中出现的格式,在 sample 外的行必然出错。
⚠️ **大数字(15 位以上的身份证 / 参考号 / 流水号)做去重 / 比较时禁止用 `+csv-get` 的显示值**:`+csv-get` 返回的是**格式化显示值**,15 位以上数字会被显示成 `1.04E+14` 这类科学计数法——多个本不相同的号在显示层全变成同一个 `1.04E+14`,拿去判重会**整列误判为重复**。比较 / 去重 / 匹配大数字时必须改用 `+cells-get`(取原始精确值)或把该列读为文本,禁止用 csv-get 的科学计数显示值(反例:大批长参考号被显示成科学计数后,互不相同的号全变成同一个值,被当成整列重复并错误高亮)。
## 使用场景
读取。从飞书表格中读取单元格数据。本 reference 覆盖 4 个 shortcut,按读取目的选择:
| 读取目的 | 用这个 shortcut | 数据去向 | 说明 |
|---------|----------------|---------|------|
| 快速查看纯值数据、批量处理 | `+csv-get` | 对话上下文 | 返回 CSV 文本(每行带 `[row=N]` 前缀);大表请按 `--range` 行窗口分批读(截断时看 `has_more`) |
| 按列类型结构化读出(喂 DataFrame / round-trip 回 `+table-put`) | `+table-get` | 对话上下文 | 返回 typed 协议(`columns:[列名]` + `data` + `dtypes`/`formats` + `range`),输出形状对齐 pandas split;可一行 `pd.DataFrame(sheet["data"], columns=sheet["columns"]).astype(sheet["dtypes"])` 还原 DataFrame,或直接 round-trip 回 `+table-put`。不带 `--range` 时读**完整 used range**(跨过表中部空行 / 空列),每个子表回传读取范围 `range`;被 `max_chars` 裁掉时**该子表**带 `truncated: true` 与 `truncation_warning`,预算耗尽导致后续整表未读时**顶层**也带同组字段,`--output-path` 落盘模式另看 stdout 回执的 `complete` / `truncated`——**先看截断字段再用数据;三层都没报也不等于逻辑读全**,仍要用返回数据实际行数、关键末行与源数据交叉核对(详见下文)。注意这与下文 `current_region` "遇表中部空行截断"不矛盾:`+table-get` 读的是子表物理 used range(飞书记录的已用矩形,含中间空行),`current_region` 是从锚点连通扩展、遇整行空行就断 |
| 查看公式、样式、批注、数据验证 | `+cells-get` | 对话上下文 | 返回单元格完整信息,token 开销较大 |
| 查看某区域的下拉框(数据验证)配置 | `+dropdown-get` | 对话上下文 | 返回该 A1 范围的下拉选项、多选开关和胶囊配色 |
**选择原则**:
- 只看值或做数据处理 → `+csv-get`;大表分批读取,避免一次拉全表撑爆上下文
- 要按列类型结构化读出(喂 DataFrame / round-trip 回 `+table-put`)→ `+table-get`
- 需要公式/样式/批注 → `+cells-get`
- 查看某区域下拉框的选项、多选开关或胶囊配色 → `+dropdown-get`
## 读表理解脚本(Agent 优先入口)
当目标是"先理解表格内容 / 结构 / 子表边界",且本地存在 `scripts/lark_*.py`(只随仓库版 skill 分发,二进制内嵌版不含 `scripts/`),可优先用这组只读脚本,再决定是否直接调用上述 shortcut。脚本是可选捷径,不是必经入口——脚本不可用时直接按下表右列的 CLI 等价路径执行:如果任务很小,或需要公式 / 样式 / 批注 / 精确原始值等脚本未覆盖的信息,可以直接用 CLI 做等价或更精细读取。
| 脚本 | 底层 shortcut | 适用场景 |
| --- | --- | --- |
| `scripts/lark_inspect_workbook.py` | `+workbook-info` / `+sheet-info` / `+csv-get` | 飞书表格第一步预检:输出所有 sheet summary、布局、预览和 `data.selection`;未点名时仅从 `resource_type=sheet && is_hidden=false` 的 visible_grid 候选中选,唯一才自动使用,多候选不得按 index 猜。 |
| `scripts/lark_detect_subtables.py` | `+workbook-info` / `+sheet-info --include merges,hidden_rows,hidden_cols` / 小窗口 `+csv-get` | 同一 sheet 可能有多个表格区域、汇总块、备注块时,在**已知且未截断的窗口**内识别候选子表 range |
| `scripts/lark_profile_table.py` | `+csv-get` / `+sheet-info --include hidden_rows,hidden_cols`(默认包含隐藏行列时;必要时再手工 `+cells-get` / `+table-get`) | 对**已确认且未截断的候选 range**做表头、数据范围、列类型、特殊行画像,并输出 `summary` / `field_map` / `risk_warnings` / `write_hints` |
`lark_profile_table.py` 是**启发式画像**,不是最终判定器:它能降低手工数行列和漏看特殊行的风险,但表头、多行标题、数据末行、列类型、特殊行和追加列都可能需要二次确认。批量写入、公式、排序、筛选、去重、透视/图表等操作前,不能只凭 profile 结果直接写;必须把 profile 输出与任务语义、样本值、必要的 CLI 补读一起核对。
`lark_profile_table.py` 的使用口径:
| 任务类型 | 建议 |
| --- | --- |
| 只读取或修改用户明确指定的单个单元格 / 很小范围,且不需要理解整表 | 可直接用 CLI |
| 批量写入、公式 / 计算、排序、筛选、删除、仅保留、去重、lookup / 匹配、条件高亮、透视表、图表、汇总 | 优先对目标区域运行 `lark_profile_table.py`;若已用等价 CLI 明确确认表头、数据范围、字段列、列类型和特殊行,可跳过脚本。去重 / lookup 若目标列含 `long_numeric_like_id`、前导 0 或格式化数字,profile 只能定位列,比较值必须改用 `+cells-get` 或 `+table-get` |
| 多块表、表头不确定、存在合并 / 汇总 / 空行 / 备注块、选区是单格但任务语义是整表 | 先 `lark_detect_subtables.py` 或补充 CLI 确认候选范围,再对目标 range 跑 `lark_profile_table.py` |
| 需要公式、样式、批注、数据验证、精确原始值、长数字 ID 精确比较 | 先用脚本形成结构化理解,再按需补 `+cells-get` / `+table-get` / 分批 `+csv-get` |
推荐链路(大表先定窗口,脚本不接受截断结果):
```bash
python3 scripts/lark_inspect_workbook.py --url "<表格URL>"
# 先用 +workbook-info 和小窗口 +csv-get 确认真实 sheet、列边界和起始区域;大表按行窗口推进。
python3 scripts/lark_detect_subtables.py --url "<表格URL>" --sheet-name "<子表名>" --range "A1:H200"
python3 scripts/lark_profile_table.py --url "<表格URL>" --sheet-name "<子表名>" --range "A1:H200"
```
`lark_detect_subtables.py` / `lark_profile_table.py` 的 `+csv-get` 命中 `has_more` 会以错误退出并报告已读取的 `actual_range`,绝不基于半截数据给出候选范围或画像。遇到此错误,以 `actual_range` 为已完成窗口,缩小列数或从其末行之后继续读;跨窗口的候选范围、汇总行和写入落点必须再用 CLI 核对,不能把单个窗口结果当整表结论。
脚本只读,不做任何写入。它们的输出用于降低 token 和定位错误;后续需要公式、样式、批注、精确原始值时,仍按本文件规则直接调用 `+cells-get` / `+table-get` / `+csv-get`。写入前如果使用了 `lark_profile_table.py`,至少读取并使用这些字段:`summary.header_row`、`summary.data_range`、`summary.data_row_segments`、`field_map`、`risk_warnings`、`visibility`、`write_hints.safe_append_col` 和 `special_rows`。仅当 `risk_warnings` 不含 `data_range_has_gaps` 时,才可把 `data_range` 当连续写入范围;有缺口时按 `data_row_segments` 分段读写。
脚本关键 flag:
| Flag | 脚本 / 默认 | 何时调整 |
| --- | --- | --- |
| `--skip-hidden` | profile / detect,关闭(默认包含隐藏行列) | 只分析可见数据时开启;此时必须使用 profile 的 `data_row_segments`,不要把连续 `data_range` 直接用于写入。 |
| `--max-chars` | inspect `8000`;profile / detect `25000` | 输出过大时缩小范围或降低值;profile / detect 若截断会报错并给 `actual_range`,按窗口继续。 |
| `--header-scan-rows` | profile `20` | 表头前有多行标题、说明或空行时提高;过大时结合 `possible_multi_row_header` 补读确认,不要仅凭评分结果写入。 |
| `--max-sheets` | inspect `3` | 未指定 sheet 时仅前 N 个 sheet 带 layout / preview,其余仍返回摘要并在 warnings 说明。 |
| `--max-merge-components` | detect `2000` | 超限会跳过 gap 合并并告警;需缩小窗口或人工复核子表边界。 |
| `--gap-rows` / `--gap-cols` | detect `1` / `0` | 子表被切碎或粘连时调整;每次调整后复核候选范围。 |
detect 最多确认 10 个跨窗口合并锚点;超限会在 `warnings` 中说明跳过的数量。遇到该 warning,缩小扫描窗口后再复核受影响的子表边界。
`lark_profile_table.py` 输出触发补读的规则:
- `risk_warnings` 非空时,不要把画像当最终事实;按下表补读或调整,不在表内的 warning 也先保守复核。
| Warning | 必做动作 |
| --- | --- |
| `mixed_value_types` / `long_numeric_like_id` / `formula_or_value_errors` | 补 `+cells-get` 或 `+table-get`,确认原始值、类型和公式。 |
| `duplicate_headers` / `unnamed_columns` / `header_not_detected` / `header_row_not_first` / `many_empty_cells` | 补 `+csv-get` 读取表头附近和空值样本,确认真正表头与字段列。 |
| `data_range_not_detected` / `special_rows_present` / `empty_rows_present` | 补 `+csv-get` 读取尾部和特殊行样本,确认有效数据末行。 |
| `possible_multi_row_header` | 补读表头上下各 1-2 行;必要时 `+sheet-info --include merges` 核对跨列合并。 |
| `hidden_rows_in_range` / `hidden_columns_in_range` | 写入前用 `+sheet-info --include hidden_rows,hidden_cols` 确认是覆盖还是跳过隐藏内容。 |
| `data_range_has_gaps` | 不按连续 `data_range` 写;用 `summary.data_row_segments` 对每个实际读取行段单独读写。 |
| `data_range_has_col_gaps` | 返回的列不连续(`--skip-hidden` 跳过了隐藏列);不要把 `data_range` 当连续列区写回,按 `summary.data_col_segments` 分列段处理,否则缺口右侧的值会整体错位。 |
- `write_hints.safe_append_col` 只是候选追加列,不代表绝对安全。新增列或覆盖区域前,必须用 `+csv-get` / `+cells-get` / `+sheet-info` 核对该列为空、没有隐藏列/公式/样式/对象依赖,且符合用户要求的落点。该字段已自动跳过隐藏列(跳过的列名列在 `write_hints.skipped_hidden_cols`)——注意 `--skip-hidden` 下隐藏列根本不出现在返回网格里,若它们正好都贴在数据右边缘,`data_range_has_col_gaps` 也不会告警,所以这层跳过是唯一的保护,别绕过它自己按「最后一列 +1」推落点。
⚠️ **解析 CLI 输出只读 stdout**:数据走 stdout、诊断与警告走 stderr,解析 JSON 时别用 `2>&1` 合流(警告混进去会解析失败),用管道或单独重定向 stdout。命令失败先读 stderr 再调整,别原样重发。
⚠️ **大数据优先落盘、别灌进上下文**:`+csv-get` / `+cells-get` 都受调用方 Bash / 终端的单命令 stdout 输出上限约束(常见默认约 30000 字符,超过会被截断或转存为文件)。纯值分析优先用 `+csv-get` 按 `--range` 行窗口(`A1:Z500` / `A501:Z1000` …)分批重定向到文件 + 本地脚本处理 + `+csv-put` 分批回写;若确实要让结果直接进上下文又不想触发转存,给任一命令把 `--max-chars`(默认 500000)调小到略低于该上限(如 `25000`),CLI 改为优雅截断 + `has_more` 分页。
> **落盘不等于读全**:`--output-path` 只是把上限从 stdout 口径放宽到有界的 2000 万字符(读取链路非流式,该上限是内存保护),不是无限。stdout 回执带 `complete` 字段——`complete:false` 时另有 `truncated` 与提示,文件里只有半截数据;多子表读取还会给 `unread_sheets` 列出预算耗尽前没读到的子表。**拿到回执先看 `complete`,不要默认整表已落全。**
**`+csv-get` 返回值核心设计**:
- `annotated_csv` — **CSV 数据唯一入口**。每一逻辑行前加 `[row=N] ` 前缀(N = 真实表格行号)。任何需要行号的下游操作(合并、写入、清空、格式化、插入/删除、条件格式、筛选、图表/透视表范围、搜索替换等),**行号一律直接从 `[row=N]` 读取**。若需要纯 CSV(如喂给本地脚本做解析),去前缀即可:`line.replace(/^\[row=\d+\] /, '')`。
- `col_indices` — **定位列字母唯一入口**。在表头中找到目标字段是第 j 个(0-based),用 `col_indices[j]` 取列字母。**禁止手数逗号**——列数超过 10 时极易 off-by-one(例如把 W 误判为 X)。
- `row_indices` — 程序化引用的备用数组。LLM 推理请用 `annotated_csv` 的前缀,不要查这个数组里的 index(把行号当数值用容易心算出错)。
- `current_region` — 从请求范围扩展到被空行空列包围的连续数据区域(等价于 Excel Ctrl+Shift+*),适合先读少量行探表头。⚠️ 它**遇表中部整行空行 / 整列空列就截断**,可能小于真实数据范围(漏掉空行之后的行);**不能**直接当整表末行用,判断整表是否读全要拿 `+workbook-info` 的物理 `row_count` / `column_count` 当上界交叉核对(见下方「按 row_count 盲读空行」与「确定数据范围的正确流程」)。
注意:
- `+csv-get` 和 `+cells-get` 支持分页/截断,注意检查 `has_more` / `truncated` 标志;两者在处理返回数据之前都必须先读 `warning_message`(上游 schema 要求先读它再用其它字段,内含定位与截断续读提示),`+cells-get` 还要用每个 range 的 `actual_range` / `row_indices` / `col_indices` 判断真实位置
- 隐藏行列默认包含在返回结果中(`--skip-hidden=false`),如需只看可见数据设为 `true`。读取原语本身不标注哪些行列被隐藏:若要识别隐藏区间(以决定是否过滤、或如何解读混入的隐藏数据),用 `+sheet-info --include hidden_rows,hidden_cols` 取隐藏行列集合,再结合 `+csv-get` / `+cells-get` 返回的 `row_indices` / `col_indices` 判断每行 / 每列是否隐藏
- 要判断单元格内容是否被行高列宽挤到显示不全(排版检查、调整行高列宽前),给 `+cells-get` 加 `--include truncation`:会按字号 / 自动换行 / 行高列宽估算并返回被截断单元格的 `isRowTruncated` / `isColTruncated`(未返回视为未截断)。有额外计算开销,仅需要时才开
**常见配置错误(必须注意)**:
- **全量读取导致上下文溢出**:不要对大表(数百行以上)直接用 `+csv-get` 或 `+cells-get` 读取全部数据到上下文。大表场景必须分批读取:用 `--range` 切行窗口逐块读(`+csv-get` / `+cells-get` 单次返回量由 `--max-chars` 自动兜底,截断时返回 `has_more`);过大时考虑导出到本地文件后用脚本处理再分批回写
- **了解结构 ≠ 读取全量数据**:探表不用读全表,但必须同时探两个方向的表头:
- **横向(列头)**:先读前几行,且**列范围必须覆盖所有列**——用 `+workbook-info` 拿总列数,`range` 末列填到最后一列(例如总列数是 N,则 `range: "A1:[列N]10"`)。列范围截短会遗漏右侧字段、后续写入列定位错误。
- **纵向(行标)**:若左侧 1-2 列是行标签(日期/类别/编号枚举每行含义,典型交叉表/透视布局),**必须再读 `A:A` 或 `A:B` 把行标列读到底**,拿全部行标。只读前几行会看不全表尾的行,导致批量写入漏改——这是"只改前 N 行、其余未更新"的主要成因。扁平列表(每行独立记录、列是字段)可跳过这一步,但仍要按下方「确定数据范围的正确流程」用 `+workbook-info` 的物理 `row_count` 交叉核对末行(`current_region` 遇空行会截断,不能单独兜底)。
- 数据量大或会进入上下文上限时,分批读 + 本地处理 + 分批回写,不要一口气拉全表到上下文。
- **`+cells-get` 滥用**:当只需要数据值时,使用 `+csv-get`(token 开销约为 `+cells-get` 的 1/5)。只有确实需要公式、样式或批注时才用 `+cells-get`
- **忽略分页标志**:读取返回 `has_more=true` 时,说明还有更多数据。如果任务需要完整数据,必须继续分页读取,不能只处理第一页就开始写入
- **直接按 `+cells-get` 返回二维数组下标推导真实位置**:`ranges[n].cells[i][j]` 里的 `i/j` 只是返回数组下标,不等于真实表格行列。定位真实行号必须用 `ranges[n].row_indices[i]`,定位真实列字母必须用 `ranges[n].col_indices[j]`;若 `--skip-hidden=true`、请求范围越界被裁剪,或最后一行是部分返回,错误地自己数下标会立刻错位
- **CSV 行号计数错误**:`+csv-get` 返回的 CSV 遵循 RFC 4180 标准,被双引号 `"..."` 包裹的字段中的换行符属于**字段内容的一部分**(即单元格内换行),不代表新的一行。计算行号时必须按**逻辑记录**计数,而非按物理换行符 `\n` 计数
- **手动数列确定列号**:禁止通过在 CSV 表头中手动数逗号/字段来确定目标列的列字母。当列数超过 10 时,手动计数极易产生 off-by-one 偏移(例如把 W 列误判为 X 列)。**必须使用 `col_indices`**:先在 CSV 表头中找到目标字段名是第 j 个字段(0-based),再用 `col_indices[j]` 获取该列的实际列字母
- **用数据列的值推导行号(常被巧合掩盖)**:CSV 中常见"序号 / ID / 编号 / No."等形似行号的列,其值与实际表格行号**没有任何绑定关系**——序号可能跳号(1,2,3,5,6...)、可能从非 1 开始、可能有重复或被中途重置。此规则适用于**所有需要行号的下游操作**:合并单元格、区间写入/清空/格式化、插入/删除行、条件格式范围、筛选器范围、图表数据源、透视表范围、搜索替换范围等等——**凡是要把行号填进任何工具参数的场景,行号一律从 `annotated_csv` 中目标行开头的 `[row=N]` 前缀直接读取**,禁止用"序号=行号"、"表头占 1 行所以数据从第 2 行开始"、"第 N 个序号就在第 N+1 行"等心算,也禁止先心算再"事后核对"。**危险特征**:前几十行中序号恰好等于表格行号(典型成因:表头 +1 与一次跳号 -1 的偏移互相抵消形成巧合),模型一旦把这个巧合当作规律,会在后续所有行沿用;而中间再出现跳号时,从该行起整块区域全部错位,且错位不自查很难发现。**正确工作流**:①在 `annotated_csv` 里定位目标逻辑行(按字段内容匹配);②直接读取该行开头的 `[row=N]` 前缀得到真实表格行号;③把这个行号填进下游工具参数。区间操作时,起始行用 start 行的 `[row=N]`、结束行用 end 行的 `[row=N]`。**自检**:动手前,在 `annotated_csv` 靠后位置再抽 1~2 行,核对 `[row=N]` 是否与首列"序号"一致——不一致(典型:`[row=57] 58,...`)即说明有跳号/隐藏行,更要严格从 `[row=N]` 取值,不要被序号列迷惑
- **`row_count` 与 `current_region` 都不能单独定末行**:`+workbook-info` 的 `row_count` 是 sheet 的**网格物理行数**(常是 200 / 1000 等默认值),通常**大于**真实数据末行——直接按它把 `--range` 拉到 `S200` 会读回大片空行,浪费上下文。反过来,`+csv-get` 返回的 `current_region` 是从锚点扩展、被空行空列围住的连续块,**遇表中部整行空行就截断**,可能**小于**真实数据范围(漏掉空行之后的行,典型反例:1–80 行有数据、81 行空、82 行起还有数据,`current_region` 只到 80,82 行起整段被漏读)。正确做法:把 `row_count` 当**上界**、`current_region` 当**起点参考**,在二者之间按下方「确定数据范围的正确流程」确认真实末行(含跨过中间空行的核对),不要只信其一。
- **current_region 当作纯数据范围**:`current_region` 返回的是从请求范围向四周扩展到被空行空列包围的**连续非空区域**,等价于 Excel 的 Ctrl+Shift+\*。它包含该区域内**所有非空行**——不仅包含数据行,还可能包含标题行、汇总行(如"总计")、签名行(如"编制人/审批人")、脚注等非数据内容。**严禁直接将 `current_region` 的末尾行作为数据范围的结束行**。正确做法见下方「确定数据范围的正确流程」
### 确定数据范围的正确流程(排序、筛选、批量写入等操作前必做)
当后续操作需要精确的数据范围(如排序、筛选、删除、批量写入)时,仅靠 `current_region` 探测到的范围是不够的——它**两头都可能不准**:表中部有整行空行时会被截断(末行偏小、漏数据),表尾有汇总 / 签名行时又会偏大。必须同时确认数据的**起始行**和**结束行**。具体步骤:
1. **确认起始行**:读取前 5~10 行,识别表头行位置,数据起始行 = 表头行 + 1
2. **确认结束行**(关键步骤,不可跳过):
- **先防截断(漏数据)**:拿 `+workbook-info` 的物理 `row_count` 当上界,与 `current_region` 末行对比。若 `current_region` 末行 **远小于** `row_count`(差出很多空间),不要直接采信——在 `current_region` 末行之后再探一段(如往下读到 `row_count`,或分段扫到首个连续空白区),确认空行之后确实没有数据;典型反例:`row_count=327`、`current_region` 只到第 80 行,第 81 行空、82 行起还有数据,只读到 80 就漏了一大段。
- **再排尾部非数据行**:读取确认到的末行附近若干行(建议末尾 5~10 行),逐行排除:
- **汇总行**:内容为"合计"、"总计"、"小计"、"总计:"等
- **签名/审批行**:内容为"编制人"、"审核人"、"部门负责人"等
- **空行或分隔行**:整行为空或仅有边框
- **备注/脚注行**:注释性文字、说明文字等
3. **最终数据范围** = 起始行 ~ 最后一条有效数据行(跨过中间空行、排除尾部非数据行)
**示例**:`current_region` 返回 `A1:N51`,读取 Row 48~51 发现:
- Row 49: 序号=47, 姓名=xxx, 有正常数据 → ✅ 数据行
- Row 50: "总计", 有合并单元格 → ❌ 汇总行
- Row 51: "总经理:...", "编制人:..." → ❌ 签名行
- **正确数据范围 = A3:N49**(而非 A3:N51)
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+cells-get` | read | 单元格 |
| `+dropdown-get` | read | 对象 |
| `+csv-get` | read | 单元格 |
| `+table-get` | read | 单元格 |
## Flags
### `+cells-get`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | A1 范围,如 `A1:F10`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet) |
| `--include` | string_slice | optional | 要返回的信息类别,逗号分隔多个。`truncation` 会额外按行高列宽 / 字号 / 自动换行估算每个单元格是否被截断显示,返回 `isRowTruncated` / `isColTruncated`(有额外计算开销,仅排版检查 / 调整行高列宽前才开)(可选值:`value` / `formula` / `style` / `comment` / `data_validation` / `conditional_format` / `truncation`) |
| `--max-chars` | int | optional | 单次返回字符上限,默认 500000(兜底防爆)。要整表无截断直接用 --output-path 落盘(上限自动放宽到 2000 万字符——读取链路非流式,此上限是内存保护;更大就显式给 --max-chars);仅当要让结果直接进上下文、又不落盘时才调小(如 25000),按 has_more 分页。 传 0 表示「不自设上限」,等价于不传(仍是 500000 / 落盘时 2000 万),不会退回底层工具那个更小的默认截断。 |
| `--output-path` | string | optional | 把完整读取结果写入本地路径(如 `./out.json`),文件内容为 data 载荷的 JSON;stdout 只回一个含 output_path/字节数的确认信息。**一旦设置,字符上限自动放宽到有界的 2000 万字符**(覆盖 --max-chars 默认),并非无限——读取链路非流式,该上限是内存保护;显式 --max-chars 优先。stdout 回执带 `complete` 字段(命中上限时另有 `truncated` 与提示),据此判断文件是否完整,不要默认整表已落全。省略时按常规把结果打到 stdout。 |
| `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` |
### `+dropdown-get`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | A1 范围,如 `A2:A100`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet) |
### `+csv-get`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | optional | A1 范围,如 `A1:F30`(不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet)。**可省略:缺省读取整个子表**(按表格实际边界裁剪,返回的 actual_range 标注实际读取范围);大表配合 --max-chars / --output-path 控制体量 |
| `--max-chars` | int | optional | 单次返回字符上限,默认 500000(兜底防爆)。要整表无截断直接用 --output-path 落盘(上限自动放宽到 2000 万字符——读取链路非流式,此上限是内存保护;更大就显式给 --max-chars);仅当要让结果直接进上下文、又不落盘时才调小(如 25000),按 has_more 分页。 传 0 表示「不自设上限」,等价于不传(仍是 500000 / 落盘时 2000 万),不会退回底层工具那个更小的默认截断。 |
| `--output-path` | string | optional | 把完整读取结果写入本地路径(如 `./out.json`),文件内容为 data 载荷的 JSON;stdout 只回一个含 output_path/字节数的确认信息。**一旦设置,字符上限自动放宽到有界的 2000 万字符**(覆盖 --max-chars 默认),并非无限——读取链路非流式,该上限是内存保护;显式 --max-chars 优先。stdout 回执带 `complete` 字段(命中上限时另有 `truncated` 与提示),据此判断文件是否完整,不要默认整表已落全。⚠️ 落盘的是 data 载荷的 **JSON**(`+csv-get` 也一样,CSV 文本是 JSON 里的一个字段),不是直接可用的 .csv 文件;要纯 CSV 文件请把 stdout 重定向到文件。 省略时按常规把结果打到 stdout。 |
| `--include-row-prefix` | bool | optional | 是否在每行前加 `[row=N]` 前缀,默认 `true` |
| `--skip-hidden` | bool | optional | 跳过隐藏行列,默认 `false` |
### `+table-get`
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--sheet-id` | string | optional | 只读该子表(按 id);省略则读所有子表 |
| `--sheet-name` | string | optional | 只读该子表(按名);省略则读所有子表 |
| `--range` | string | optional | 读取的 A1 范围;省略则读每个子表的完整 used range(会跨过表中部的整行空行 / 整列空列,不会被截断) |
| `--max-chars` | int | optional | 单次返回字符上限,默认 500000(兜底防爆)。底层工具即使不传也有约 50000 的默认截断,故此处显式发送以放宽;要整表读取请用 --output-path 落盘(上限自动放宽到有界的 2000 万字符,非无限;回执 complete 字段说明是否完整)。 传 0 表示「不自设上限」,等价于不传(仍是 500000 / 落盘时 2000 万),不会退回底层工具那个更小的默认截断。 |
| `--output-path` | string | optional | 把完整读取结果写入本地路径(如 `./out.json`),文件内容为 data 载荷的 JSON;stdout 只回一个含 output_path/字节数的确认信息。**一旦设置,字符上限自动放宽到有界的 2000 万字符**(覆盖 --max-chars 默认),并非无限——读取链路非流式,该上限是内存保护;显式 --max-chars 优先。stdout 回执带 `complete` 字段(命中上限时另有 `truncated` 与提示),据此判断文件是否完整,不要默认整表已落全。省略时按常规把结果打到 stdout。 |
| `--no-header` | bool | optional | 把第一行当数据而非表头(列名取 col1/col2 …) |
## Examples
### `+csv-get`
公共四件套:`--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(前两者 XOR,后两者 XOR)。
示例:
```bash
# 简单读(sheet 定位必填:--sheet-name 或 --sheet-id 必给一个;range 的 Sheet1! 前缀不能替代它)
lark-cli sheets +csv-get --url "https://example.feishu.cn/sheets/shtXXX" --sheet-name "Sheet1" --range "A1:F30"
# 用 sheet-name 模糊定位(运行时框架会先解析到 sheet-id)
lark-cli sheets +csv-get --spreadsheet-token shtXXX --sheet-name "销售明细" --range "A1:F30"
# 全量读:省略 --range 即读整个子表(按实际边界裁剪,返回 actual_range 标注实读范围),
# 无需先 +workbook-info 探行列再拼 range;大表配合 --max-chars / --output-path
lark-cli sheets +csv-get --spreadsheet-token shtXXX --sheet-name "销售明细"
```
输出契约(envelope.data):
- `annotated_csv` — 含 `[row=N]` 前缀的 CSV 主入口
- `col_indices` / `row_indices` — 列字母 / 行号映射数组
- `current_region` — 从锚点扩展到被空行空列包围的连续区域的 A1 范围。⚠️ **它不是整表真实边界**:遇表中部整行空行 / 整列空列会截断、可能小于真实数据范围;表尾的汇总 / 签名 / 脚注又可能让它大于纯数据范围。判断整表是否读全须拿 `+workbook-info` 的物理 `row_count` 当上界交叉核对(见上方「`row_count` 与 `current_region` 都不能单独定末行」)
- `actual_range` — **本次实际读到的 A1 范围**。续读 / 校验覆盖度一律以它为准:`actual_range` 小于请求范围时,哪怕 `has_more=false` 也说明只拿到部分窗口,不能把 `row_count` 当成"已读全"
- `row_count` / `col_count` — **本次返回的行 / 列数**(= `actual_range` 的尺寸,随 `--range` 变),**不是整表物理总行列数**;整表物理尺寸取 `+workbook-info`
- `has_more` — 当前 `--range` 是否因 `--max-chars` 被截断(截断后续读接着用 `--range`);它**只反映本次 range 内是否还有后续页**,`has_more=false` **不代表整表或该窗口已读全**——仍要结合 `actual_range` 看实际覆盖到哪里
> 要按列类型结构化读出(喂 DataFrame、或 round-trip 回 `+table-put`)用 `+table-get`(见下);`+csv-get` 给的是带 `[row=N]` 前缀的纯值快照,下游需要行号/列坐标时直接从前缀与 `col_indices` 取。
### `+cells-get`
示例:
```bash
# 读 A1:F10 的公式 + 样式(sheet 定位必填)
lark-cli sheets +cells-get --url "https://example.feishu.cn/sheets/shtXXX" --sheet-name "Sheet1" \
--range "A1:F10" --include formula,style
```
> ⚠️ 调用方在 `cells[i][j]` 中**不能**用下标推真实行列:必须读 `ranges[n].row_indices[i]` / `ranges[n].col_indices[j]`。
### `+table-get`(飞书 → DataFrame,类型保真读出)
`+table-put`(写入侧,见 write-cells reference)的镜像:把表格读回与 `--sheets` 完全同构的 typed 协议(`sheets[]` + `columns:[列名]` + `data:[[行]]` + `dtypes:{列名:pandas_dtype}` + `formats?:{列名:number_format}` + `range`),可直接喂回 `+table-put` 或一行还原 DataFrame。
**默认(不带 `--range`)先按整张子表物理网格探测 used range**:可跨过表中部空行 / 空列定位真实数据边界,再读取该区域。仍受 `--max-chars` 上限约束;返回 `truncated=true` 或 `complete=false` 时,文件/响应只有部分数据,改用 `--output-path`、提高上限或按 sheet/range 续读。每个子表的 `range` 只表示本次目标区域,不能单独证明内容已完整返回。
列类型从每列 `number_format` 推断(日期格式→`date`/`datetime64[ns]`、数值→`number`/`float64`、bool→`bool`),`date` 列的序列号转回 ISO `yyyy-mm-dd`——日期、数字往返不丢类型。**列类型只在该列所有非空值一致时才定(`number` / `date` / `bool`);一列混了类型(如数字列混入「暂无」、日期列混入裸数字)会降为 `string`(dtypes 输出 `object`),让 `dtypes` 与 `data` 里每个值自洽——能 round-trip 回 `+table-put`、不让 pandas `astype` 崩。降级是无损的(脏值原样保留为文本);若要把零星脏值转成数值列,交给调用方在 pandas 侧做(`to_numeric(errors='coerce')`),那里原始值仍在、可追溯。** 默认读所有子表、第一行当表头(`--no-header` 把首行当数据、列名取 `col1` / `col2` …)。
```bash
# 默认读所有子表 → sheets[](与 +table-put 的 --sheets 同构,可喂回或转 DataFrame)
lark-cli sheets +table-get --url "<表URL>"
# 可选:--sheet-name / --sheet-id 限定只读某一个子表(不给则读全部)
lark-cli sheets +table-get --url "<表URL>" --sheet-name "销售"
```
#### 输出 → DataFrame(用 `sheet_to_df` helper)
输出形状对齐 pandas split:`columns` 是列名数组、`data` 是二维数据、`dtypes` 是 `{列名: pandas_dtype_str}` 映射;`truncated/complete/truncation_warning` 说明覆盖度。未截断时可直接喂给 `pd.DataFrame(...).astype(...)`。本 skill 提供 [`scripts/lark_sheets_df.py`](../scripts/lark_sheets_df.py):
```python
import sys; sys.path.insert(0, "scripts") # cwd 不在 skill 根时改成 scripts/ 的实际路径
from lark_sheets_df import sheet_to_df
# 单 sheet
df = sheet_to_df(out["data"]["sheets"][0])
# 多 sheet——按名字取
sheets = {s["name"]: sheet_to_df(s) for s in out["data"]["sheets"]}
df_sales = sheets["销售"]
```
> 显示格式(千分位、百分比、自定义日期)在 `sheet["formats"]`,pandas 不消费;改完数据 round-trip 回去时透传给 `+table-put` 即可,飞书侧显示不变。
#### round-trip:读 → 改 → 写回(写读对偶)
`sheet_to_df` 和 `df_to_sheet` 一对镜像 helper([`scripts/lark_sheets_df.py`](../scripts/lark_sheets_df.py))让 round-trip 三段读 / 改 / 写各一行:
```python
import json, subprocess
import sys; sys.path.insert(0, "scripts") # cwd 不在 skill 根时改成 scripts/ 的实际路径
from lark_sheets_df import df_to_sheet, sheet_to_df
# 1. 读
out = json.loads(subprocess.check_output(
["lark-cli","sheets","+table-get","--url",URL,"--sheet-name","销售"]))
sheet = out["data"]["sheets"][0]
df = sheet_to_df(sheet)
# 2. 改(pandas 操作)
df["营收"] = df["营收"] * 1.1
# 3. 写回(formats 是飞书侧显示格式,pandas 不消费,透传保留显示)
payload = {"sheets": [df_to_sheet(df, sheet["name"], formats=sheet.get("formats"))]}
subprocess.run(["lark-cli","sheets","+table-put","--url",URL,"--sheets","-"],
input=json.dumps(payload).encode(), check=True)
```
`sheet_to_df(sheet)` 消费 `(columns, data, dtypes)`,`df_to_sheet(df, name, formats=...)` 重新生成同样三个字段——读 / 写完全对偶,只有 `formats` 需要手工透传一次。
### Validate / DryRun / Execute 约束
- `Validate` 阶段只做 XOR 检查、Enum 合法性、防爆参数上限校验;**禁止**联网(如不能用 `--sheet-name` 提前去查 `sheet-id`)。
- `DryRun` 输出请求模板:`--sheet-name` 在 dry-run 输出里生成为 `<resolve:销售明细>` 占位符,不实际解析。
- `Execute` 阶段才进行 sheet-name → sheet-id 解析与 API 调用。
references/lark-sheets-search-replace.md›
# Lark Sheet Search & Replace
## 替换前 dry-run + 范围明确(替换前建议)
`+cells-replace` 的副作用是不可逆的(除非另写代码回滚)。执行前必须:
1. **明确替换范围**:建议显式说明"只替换 X 列 / X 区域,还是全表替换"。避免默认全表替换——容易误改无关列。范围应由用户指令决定,模糊时主动询问。
2. **dry-run 命中数量**:先用 `+cells-search` 在同一范围、同一关键词、同一匹配选项(大小写 / 精确 / 正则)下统计命中数量。把数量和**期望命中数**(用户明示的或基于业务理解推断的)对照;不一致先排查(关键词太宽?范围太大?)。
3. **替换后全量校验**:执行后再次 `+cells-search` 旧关键词,预期为 0;指定了完整 range 与旧值枚举时逐项搜索,随机抽样不能替代。**例外**:新值本身包含旧值时(如 `v1`→`v1.1`,或子串替换后新值仍含关键词),子串搜索仍会命中,此时零命中判据不成立——改用整格精确匹配(`--match-entire-cell` 类选项)核对,或直接回读代表性单元格确认已是新值,别据非零命中判未替换而重复执行(会得到 `v1.1.1`)。只有用户明确要求本地 xlsx / 下载 / 打印,或正在验证导入前的本地 Excel 文件时,才运行本地产物检查脚本。
## 使用场景
读写。在飞书表格中搜索和替换文本。本 reference 覆盖 2 个 shortcut:
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 搜索/定位文本 | `+cells-search` | 返回匹配的单元格位置,支持正则、精确匹配等 |
| 查找并替换文本 | `+cells-replace` | 批量替换文本;`--regex` 模式下 `--replacement` 可用 `$1`、`$2` 引用 `--find` 的捕获组 |
**常见配置错误(注意)**:
- **不要把操作动词当搜索词**:用户说"汇总金额"是一个操作动作(求和),不是要搜索"汇总金额"这个文本。只有当确实需要定位某个文本值的位置时才用 `+cells-search`
- **不要用搜索来了解表格结构**:要了解表头和数据结构时,应使用 `+csv-get` 读取前几行,而不是用 `+cells-search` 逐个猜测字段名
- **注意正则特殊字符**:使用正则匹配时,`.`、`*`、`(`、`)` 等特殊字符需要转义
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+cells-search` | read | 单元格 |
| `+cells-replace` | write | 单元格 |
## Flags
### `+cells-search`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--find` | string | required | 待查找文本(与 `--regex` 配合时按正则解释) |
| `--range` | string | optional | 查找范围(A1 格式);省略时整表 |
| `--match-case` | bool | optional | 大小写敏感 |
| `--match-entire-cell` | bool | optional | 完全匹配整个单元格 |
| `--regex` | bool | optional | 把 `--find` 按正则解释 |
| `--include-formulas` | bool | optional | 也在公式文本中搜索 |
| `--max-matches` | int | optional | 防爆,默认 5000(隐藏 flag:不在 `--help` 列出,但可正常传入) |
| `--offset` | int | optional | 跳过前 N 个匹配(分页用),默认 0 |
### `+cells-replace`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--find` | string | required | 待替换文本 |
| `--replacement` | string | required | 替换为;传空字符串 `""` 等价于「删除内容」 |
| `--range` | string | optional | 替换范围(A1 格式);省略时整表 |
| `--match-case` | bool | optional | 大小写敏感 |
| `--match-entire-cell` | bool | optional | 完全匹配整个单元格 |
| `--regex` | bool | optional | 把 `--find` 按正则解释 |
| `--include-formulas` | bool | optional | 也在公式文本中替换 |
## Examples
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR 规则)。
### `+cells-search`
示例:
```bash
# 普通查找
lark-cli sheets +cells-search --url "https://example.feishu.cn/sheets/shtXXX" \
--sheet-name "Sheet1" --find "张三"
# 正则 + 范围限定
lark-cli sheets +cells-search --spreadsheet-token shtXXX --sheet-id "$SID" \
--find "^[A-Z]{2}-\\d{4}$" --regex --range "A2:A1000"
```
输出契约(envelope.data):
- `matches` — 命中 cell 列表,每条含 `address`(A1)+ `value` + `sheet_id`
- `total_matches` — 匹配总数
- `has_more` / `next_offset` — 分页游标(命中数超过单页上限时用于继续读取)
### `+cells-replace`
示例:
```bash
# 先 dry-run 预览
lark-cli sheets +cells-replace --url "https://example.feishu.cn/sheets/shtXXX" \
--sheet-name "Sheet1" --find "v1" --replacement "v2" --dry-run
# 确认后执行
lark-cli sheets +cells-replace --url "https://example.feishu.cn/sheets/shtXXX" \
--sheet-name "Sheet1" --find "v1" --replacement "v2"
# 正则捕获组:把 "2026-03" 重排成 "03/2026"($1/$2 引用 --find 的捕获组)
lark-cli sheets +cells-replace --url "https://example.feishu.cn/sheets/shtXXX" \
--sheet-name "Sheet1" --regex --find "(\\d{4})-(\\d{2})" --replacement "$2/$1" --dry-run
```
> `+cells-replace` 虽然 Risk = write,但范围大或正则写错可能批量修改大量非目标单元格。**建议工作流**:先 `+cells-search` 看匹配数,再 `+cells-replace --dry-run` 预览,最后真正执行。
### Validate / DryRun / Execute 约束
- `Validate`:XOR 公共四件套;`--find` 非空;正则模式下 `--find` 必须是合法正则。
- `DryRun`:`+cells-search` 输出请求模板;`+cells-replace` 额外返回预估替换数(`would_replace_count`)。
- `Execute`:替换后必须用 `+cells-search` 复查旧值剩余命中,并回读首、中、末代表性单元格;目标是旧值命中归零或明确列出未替换项。
references/lark-sheets-sheet-structure.md›
# Lark Sheet Sheet Structure
## 结构性操作影响面预检(插入 / 删除行列前必做)
插入 / 删除行列、隐藏 / 取消隐藏、冻结、行列分组都会让原表的引用关系发生偏移。**操作前必须**先打印以下三类信息,并评估操作是否会让它们失效;否则禁止执行:
1. **当前合并单元格范围**(来自 `+sheet-info` 的 `merged_cells`):插入行 / 列时,跨过插入位置的合并区域可能扩张或断裂;删除行 / 列时合并区域可能直接消失。
2. **现有公式的引用范围**(用 `+cells-get` 抽样附近行 + 跨表引用 + 透视表 / 图表 / 条件格式 / 筛选器的数据源 range):插入 / 删除会导致 `=SUM(B4:B13)` 这种相对引用偏移;如果操作发生在引用范围内部,可能产生 `#REF!`。
3. **数据验证(下拉列表)规则的应用范围**:列表来源是某个区域时,区域被部分删除会让规则失效。
不可逆的影响必须先在回复中告知用户,得到确认再执行。
## 合并安全契约(按模块 / 分组展示)
合并前先读目标列的完整连续区域;只有同值且连续、且非左上角单元格没有值 / 公式 / 批注 / 数据验证或需保留的独立样式时,才可合并。空值、值变化、上级模块变化或上述有效内容立即断组。先读取既有 merges,禁止与现有合并区交叠或跨组扩张;执行前记录每组 `range + 左上角原文`,从下往上或一次批量提交。完成后用 `+sheet-info --include merges` 核范围,并用 `+cells-get` 确认左上角文本未丢、组外边界未合并。
## 使用场景
读写。管理子表结构与布局。本 reference 覆盖 9 个 shortcut(按用途分两类):
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 查看子表布局 | `+sheet-info` | 获取行高、列宽、隐藏行列、行列分组、合并单元格等信息 |
| 变更子表结构 | `+dim-{insert|delete|hide|unhide|freeze|group|ungroup|move}` | 插入/删除/隐藏/取消隐藏/冻结/分组/移动行列 |
注意:
- 当表格存在合并单元格时,应结合返回的 `merged_cells` 判断表头、分组标题和区域语义
- 不要把合并区域中非左上角的空白单元格理解为"无内容";通常应将左上角单元格的内容视为整个合并区域的语义内容
- 插入用 `+dim-insert`:`--position`(插入位置;行用 1-based 行号如 `3`,列用字母如 `C`,新行/列插在此位置**之前**)+ `--count`(插入数量,>0)。新行/列样式继承用 `--inherit-style`(`before` 继承前一行/列 / `after` 继承后一行/列);它只决定继承哪一侧的样式,**插入位置始终在 `--position` 之前,不改变插入方向**。⚠️ 不传时默认继承**后一行/列**(同 `after`);底层无法插入"无格式"行/列,要真正的纯空白行/列,插入后再用 `+cells-clear --scope formats` 清除新行/列的格式。
- 例如"在第 20 行后新增 116 行":`--position 21 --count 116`("第 20 行后"即 1-based 行号 21)
**区间表达统一为 A1 风格**:所有涉及"一段连续行/列"的 shortcut 都用同一套 A1 闭区间字符串语法,**不存在 inclusive / exclusive / 0-based / 1-based 跨命令差异**:
| 命令 | 用什么 flag 表达区间 / 位置 | 例子 |
| --- | --- | --- |
| `+dim-insert` | `--position` + `--count` | `--position 3 --count 5`(在第 3 行前插 5 行)/ `--position C --count 2`(在 C 列前插 2 列) |
| `+dim-delete` / `+dim-hide` / `+dim-unhide` / `+dim-group` / `+dim-ungroup` / `+rows-resize` / `+cols-resize` | `--range` | `"3:7"`(第 3-7 行,闭区间)/ `"C:F"`(C-F 列,闭区间)/ `"5"` 或 `"C"`(单行/列) |
| `+dim-move` | `--source-range`(源区间)+ `--target`(目标位置) | `--source-range "3:7" --target 12`(把第 3-7 行移到第 12 行前)/ `--source-range "C:F" --target H` |
行用 1-based 数字、列用字母——跟 Excel / 飞书 UI 看到的行号、列字母完全一致。
**常见配置错误(必须注意)**:
- **插入列直接用字母**:`+dim-insert` 的 `--position` 在列场景直接传字母(如 `C`),不要把列字母换算成 0-based 索引
- **插入后引用偏移**:插入行/列后,原有数据的行号 / 列字母会发生偏移。如果插入后还需要对原有区域执行写入操作,必须重新计算偏移后的位置
- **删除行列前先确认范围**:删除操作不可逆,执行前应确认 `--range` 精确无误。可先用 `+csv-get` 读取目标区域验证内容(`+csv-get` / `+cells-get` 见 `references/lark-sheets-read-data.md`)
- **"在 D 列左侧新增一列"的正确写法**:`--position D --count 1`(新列插在 D 列之前);要继承左侧列样式加 `--inherit-style before`。不要把 `--inherit-style after` 当成“插到 D 列右侧”,它不是插入方向参数。
- **`+dim-move` 同维度约束**:`--source-range` 是行区间时 `--target` 必须是行号(数字),是列区间时 `--target` 必须是列字母——不可一行一列混用
- **插入列后必须检查多行表头合并区域**:很多表格有 2-3 行的合并表头。插入列后,原有的合并区域不会自动扩展到新列。必须先用 `+sheet-info --include merges` 读取合并区域,插入后将跨越插入位置的合并区域重新设置(用 `+cells-{merge|unmerge}`),否则新列的表头会是空的、格式不连续
- **公式写入范围跳过表头行**:写入公式时从数据行开始(不是第 1 行)。先确认表头占几行(可能 1-3 行),公式的起始行 = 表头行数 + 1
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+sheet-info` | read | 工作表 |
| `+dim-insert` | write | 工作表 |
| `+dim-delete` | high-risk-write | 工作表 |
| `+dim-hide` | write | 工作表 |
| `+dim-unhide` | write | 工作表 |
| `+dim-freeze` | write | 工作表 |
| `+dim-group` | write | 工作表 |
| `+dim-ungroup` | write | 工作表 |
| `+dim-move` | write | 工作表 |
## Flags
### `+sheet-info`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--include` | string_slice | optional | 要返回的结构信息类别,逗号分隔多个(可选值:`merges` / `row_heights` / `col_widths` / `hidden_rows` / `hidden_cols` / `groups` / `frozen`) |
| `--range` | string | optional | 限定只返回该 A1 范围的结构信息;省略时返回整表 |
### `+dim-insert`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--inherit-style` | string | optional | 新行/列样式继承 enum:`before`(继承前一行/列)/ `after`(继承后一行/列);不传时默认继承后一行/列(同 `after`),底层无法插入无格式行/列。只决定继承哪侧样式、不改变插入方向(始终插在 `--position` 之前);要纯空白行/列请插入后用 `+cells-clear --scope formats`(可选值:`before` / `after`) |
| `--position` | string | required | 插入位置(在此行/列**之前**插入):行用 1-based 行号如 `3`;列用字母如 `C` |
| `--count` | int | required | 插入数量(>0) |
### `+dim-delete`
_公共四件套 · 系统:`--yes`、`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | xor | 要删除的行/列闭区间;行用 1-based 数字如 `3:7` 或单行 `5`,列用字母如 `C:F` 或单列 `C`。与 `--ranges` 二选一 |
| `--ranges` | string + File + Stdin(简单 JSON) | xor | 要删除的多个行/列区间 JSON 数组(最多 100 个,如 `["5:5","8:8","11:13"]` 或 `["C:C","F:G"]`),全行或全列不可混用,区间不可重叠;与 `--range` 二选一。CLI 按位置**从大到小逆序**合成一次批量删除(fail-fast,失败后先回读再补发)——正序删除会因前面的行/列被删导致后续索引前移错位,逆序由 CLI 代劳,无需自行排序 |
### `+dim-hide`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | 要隐藏的行/列闭区间;行如 `3:7`,列如 `C:F` |
### `+dim-unhide`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | 要取消隐藏的行/列闭区间;行如 `3:7`,列如 `C:F` |
### `+dim-freeze`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--rows` | int | optional | 冻结前 N 行;与 --cols 一起描述完整冻结状态,省略的轴即为不冻结(0 表示不冻结行) |
| `--cols` | int | optional | 冻结前 N 列;与 --rows 一起描述完整冻结状态,省略的轴即为不冻结(0 表示不冻结列) |
### `+dim-group`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--depth` | int | optional | 嵌套分组的层级(创建到第几层),默认 1 |
| `--group-state` | string | optional | 分组初始展开状态(可选值:`expand` / `fold`)(默认 `expand`) |
| `--range` | string | required | 要创建分组的行/列闭区间;行如 `3:7`,列如 `C:F` |
### `+dim-ungroup`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--depth` | int | optional | 要取消的分组层级,默认 1(1=最外层,数字越大越内层) |
| `--range` | string | required | 要取消分组的行/列闭区间;行如 `3:7`,列如 `C:F` |
### `+dim-move`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--source-range` | string | required | 要移动的源行/列闭区间;行如 `3:7`,列如 `C:F` |
| `--target` | string | required | 目标位置(移到此行/列**之前**):行用 1-based 行号如 `12`,列用字母如 `H`。必须与 `--source-range` 同维度(行/列) |
## Examples
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。
### `+sheet-info`
输出契约:返回子表的行高 / 列宽 / 隐藏 / 合并 / 分组等布局元信息。
### `+dim-insert`
```bash
# 在第 10 行前插 3 行,继承上方样式
lark-cli sheets +dim-insert --url "https://example.feishu.cn/sheets/shtXXX" \
--sheet-id "$SID" --position 10 --count 3 --inherit-style before
# 在 C 列前插 2 列
lark-cli sheets +dim-insert --url "..." --sheet-id "$SID" --position C --count 2
```
### `+dim-delete`
```bash
# 删除第 5-7 行
lark-cli sheets +dim-delete --url "..." --sheet-id "$SID" --range "5:7" --yes
# 删除 D-F 列
lark-cli sheets +dim-delete --url "..." --sheet-id "$SID" --range "D:F" --yes
# 删除多个散布区间(如按查重结果删行):--ranges 一次批量交付(fail-fast,失败后先回读再补发;CLI 逆序保索引)。
# CLI 自动按位置从大到小逆序执行——正序会因前面的行被删导致后续索引前移错位;
# 无需自行排序,也不要为此拼 +batch-update 的子操作数组
lark-cli sheets +dim-delete --url "..." --sheet-id "$SID" --ranges '["5:5","8:8","11:13"]' --yes
```
### `+dim-hide` / `+dim-unhide`
```bash
lark-cli sheets +dim-hide --url "..." --sheet-id "$SID" --range "5:7"
lark-cli sheets +dim-unhide --url "..." --sheet-id "$SID" --range "5:7"
lark-cli sheets +dim-hide --url "..." --sheet-id "$SID" --range "C:F"
```
### `+dim-move`
```bash
# 把第 3-7 行移到第 12 行前
lark-cli sheets +dim-move --url "..." --sheet-id "$SID" --source-range "3:7" --target 12
# 把 C-F 列移到 H 列前
lark-cli sheets +dim-move --url "..." --sheet-id "$SID" --source-range "C:F" --target H
```
### `+rows-resize` / `+cols-resize`
> ⚠️ 这两条 shortcut 来自 `references/lark-sheets-range-operations.md` 的 `+rows-resize / +cols-resize` tool(分组在"工作表"是为了发现性)。详细参数和示例在 `references/lark-sheets-range-operations.md`。
>
> 常规写法:行高走 `--range` + `--height <px>`、列宽走 `--range` + `--width <px>`,无需再传 `--type`(等价于 `--type pixel`);多行 / 多列不同尺寸用 map 形态 `--heights` / `--widths`(如 `--widths '{"A":100,"C:E":120}'`)一次调用完成,不要拆多次调用或走 `+batch-update`。`--type standard` / `--type auto` 用于非像素模式,不能与像素 flag 同给。`+cols-resize.--type` 不接受 `auto`(列宽不支持自动适应)。⚠️ 单位是像素(不是 Excel 字符单位 / 磅)。
### `+dim-freeze`
冻结是**整份状态覆盖**、不是按轴叠加:`--rows` / `--cols` 一起描述完整的目标状态,没写的轴即为不冻结。所以要同时冻住行和列必须一次给全,拆成两次调用只会剩下最后一次的那个轴。
```bash
# 冻结前 1 行 + 前 2 列(一次给全)
lark-cli sheets +dim-freeze --url "..." --sheet-id "$SID" --rows 1 --cols 2
# 解除行冻结但保住列:把要保留的轴一并写出
lark-cli sheets +dim-freeze --url "..." --sheet-id "$SID" --rows 0 --cols 2
```
### `+dim-group` / `+dim-ungroup`(大纲)
> 仅当用户明确说"行分组 / 列分组 / 大纲 / outline"时触发;按字段做数据分组用 `+pivot-create`。
### Validate / DryRun / Execute 约束
- `Validate`:XOR 公共四件套;`--range` / `--source-range` 必须是合法 A1 闭区间(行用数字、列用字母,不可混用);`+dim-insert` 的 `--count` > 0;`+dim-freeze` 至少给 `--rows` / `--cols` 之一;`+dim-move` 的 `--target` 必须与 `--source-range` 同维度(行 vs 列);`+dim-delete` 强制 `--yes` 或 `--dry-run`,`--range` 与 `--ranges` 二选一、`--ranges` 各区间同维度且不可重叠(≤100 个);`+rows-resize` / `+cols-resize` 的统一形态(`--range` + `--height`/`--width` 或 `--type`)与 map 形态(`--heights`/`--widths`)二选一、不可混用;详见 `references/lark-sheets-range-operations.md`。
- `DryRun`:写操作输出"将要 PATCH 的目标范围 + 目标参数"。
- `Execute`:写后必须调用 `+sheet-info --include row_heights,col_widths,hidden_rows,hidden_cols,groups,frozen,merges`,按本次结构动作核对受影响范围。
references/lark-sheets-sparkline.md›
# Lark Sheet Sparkline
## 真对象硬约束
当用户要求"迷你图 / 趋势线 / 单元格内图表"时,**必须**通过 `+sparkline-{create|update|delete}` 创建真实的迷你图对象。**禁止**用文本字符(如 `▁▂▃▅▇`)拼接在单元格里、或用 `SPARKLINE()` 公式函数(已禁用)代替。判断标准:交付后 `+sparkline-list` 必须能返回该对象。
## 使用场景
读写迷你图对象。本 reference 覆盖 4 个 shortcut:
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 查看已有迷你图 | `+sparkline-list` | 获取迷你图的类型、数据源和样式配置 |
| 创建/更新/删除迷你图 | `+sparkline-{create|update|delete}` | 对迷你图执行写入操作 |
典型工作流:先读取现有迷你图了解配置 → 执行创建/更新/删除 → **必须再次读取验证结果**。
**常见配置错误(必须注意)**:
- **数据源范围要精确**:迷你图的数据源范围必须与实际数据行列精确对应,范围偏移会导致图形展示错误
- **不要与 SPARKLINE() 公式混淆**:飞书表格的 `SPARKLINE()` 公式函数已被禁用,迷你图只能通过 `+sparkline-{create|update|delete}` 的对象方式创建
- **胜负 / count 迷你图原生支持**:`config.type="win_loss"`——别因速查表没列就判"不支持"绕路
- **创建后必须验证**:调用 `+sparkline-list` 确认迷你图配置正确
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+sparkline-list` | read | 对象 |
| `+sparkline-create` | write | 对象 |
| `+sparkline-update` | write | 对象 |
| `+sparkline-delete` | high-risk-write | 对象 |
## Flags
### `+sparkline-list`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--group-id` | string | optional | 按 group_id 过滤 |
### `+sparkline-create`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--properties` | string + File + Stdin(复合 JSON) | required | JSON:`{config(共享样式配置), sparklines(迷你图数组)}`;完整字段结构跑 `--print-schema` |
### `+sparkline-update`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--group-id` | string | required | 目标组 id |
| `--properties` | string + File + Stdin(复合 JSON) | required | JSON:`{config, sparklines}`;先 `+sparkline-list --group-id <id>` 回读再 patch;完整字段结构跑 `--print-schema` |
### `+sparkline-delete`
_公共四件套 · 系统:`--yes`、`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--group-id` | string | required | 目标组 id |
## Schemas
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
### `+sparkline-create` `--properties` / `+sparkline-update` `--properties`
_创建/更新/部分删除的迷你图属性_
**顶层字段**:
- `config` (object?) — 迷你图样式配置, 相同 groupId 的迷你图共享相同的样式 { theme_type?: enum, non_num_show_as?: enum, empty_show_as?: enum, contain_hidden_cells?: boolean, series_color?: string, …共 13 项 }
- `sparklines` (array<object>?) — 迷你图项列表 each: { sparkline_id?: string, position?: object, source?: string, source_range?: object }
## Examples
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。迷你图用 **两层 id** 管理——`group_id` 选组(一组同形态的迷你图共享类型 / 样式 / 数据源映射),`sparkline_id` 在组内选具体某一项。注意:不等同于已禁用的 `SPARKLINE()` 公式函数。
> **何时需要先 `+sparkline-list`:**
> - `+sparkline-update`:**总是**需要——拿到组内每一项的 `sparkline_id`,回填到 `properties.sparklines[i]`,server 用它做映射。
> - `+sparkline-delete`:**不需要** `sparkline_id`——CLI 仅支持按 `--group-id` 整组删除(该 shortcut 没有 `--properties`)。
### `+sparkline-list`
```bash
# 列出整张子表的所有迷你图组
lark-cli sheets +sparkline-list --url "..." --sheet-id "$SID"
# 钉到单组:返回该组每一项的 sparkline_id(update 必需)
lark-cli sheets +sparkline-list --url "..." --sheet-id "$SID" --group-id "grpA"
```
### `+sparkline-create`
> `--properties` 顶层只有 `config`(同组共享样式,如 `line_width` / `points` / `extremum_max` / `extremum_min`)和 `sparklines`(迷你图项数组)两个字段。`sparklines[i]` 每项必须含 `position`(落点 cell,`row` + `col`)+ `source`(数据 A1 范围,与 `source_range` 二选一);create 时 `sparkline_id` 可省略,由系统生成。
```bash
lark-cli sheets +sparkline-create --url "..." --sheet-id "$SID" --properties @sparkline.json
```
`sparkline.json` 示例(在 F 列嵌入两行折线迷你图,数据分别来自 A2:E2 和 A3:E3):
```jsonc
{
"config": { "line_width": 2 },
"sparklines": [
{"position": {"row": 1, "col": "F"}, "source": "'Sheet1'!A2:E2"},
{"position": {"row": 2, "col": "F"}, "source": "'Sheet1'!A3:E3"}
]
}
```
### `+sparkline-update`
> 两步式:先 `+sparkline-list --group-id <id>` 拿当前组的 `sparkline_id` 列表,再构造 `properties.sparklines[]`——**每项必须带 `sparkline_id`**。只改样式可只传 `properties.config`(不带 `sparklines`,整组样式覆盖式更新)。
```bash
# 假设 +sparkline-list 已返回 group_id=grpA,组内 sparkline_id=sl_1 / sl_2
lark-cli sheets +sparkline-update --url "..." --sheet-id "$SID" --group-id "grpA" --properties '{
"sparklines": [
{"sparkline_id":"sl_1","source":"'Sheet1'!A2:A20"},
{"sparkline_id":"sl_2","source":"'Sheet1'!B2:B20"}
]
}'
```
### `+sparkline-delete`
> CLI 仅支持**整组删除**:传 `--group-id` 删掉该组全部迷你图。该 shortcut **没有** `--properties`,无法只删组内单项(需求上要"留一部分"时,改用 `+sparkline-update` 重写该组的 `sparklines` 列表,而不是 delete)。强制 `--yes` 或 `--dry-run`;先 `--dry-run` 确认要删的目标组。
```bash
# 删整组
lark-cli sheets +sparkline-delete --url "..." --sheet-id "$SID" --group-id "grpA" --yes
```
### Validate / DryRun / Execute 约束
- `Validate`:
- XOR 公共四件套;`+sparkline-{update,delete}` 必须 `--group-id`。
- **`+sparkline-update`**:当 `properties.sparklines` 非空时,每一项必须含 `sparkline_id`(CLI 预检,错误信息会指回 `+sparkline-list`,避免命中服务端的不可读拒绝);只传 `properties.config`(config-only update)合法、不触发 sparkline_id 检查。
- **`+sparkline-delete`**:只接 `--group-id`(整组删除),**没有** `--properties`,无法删组内单项。
- `--properties`(仅 `+sparkline-create` / `+sparkline-update`)顶层只接 `config`(同组共享样式)和 `sparklines`(迷你图项数组);`+sparkline-create` 要求每个 `sparklines[i]` 含 `position` 与 `source`(或 `source_range`,二选一)。
- `+sparkline-delete` 强制 `--yes` 或 `--dry-run`。
- `DryRun`:写操作输出"将要 POST/PATCH/DELETE 的 sparkline group 请求模板"。
- `Execute`:create/update 后必须调用 `+sparkline-list --group-id <id>` 核对 config、项目数量、source 与 position;delete 后 list 确认目标组不存在。
references/lark-sheets-styles-put.md›
# Lark Sheet Styles Put(+styles-put)
> **本文定位**:对**已有**表格做美化收尾的默认入口——样式 / 边框 / 合并 / 行高列宽 / 冻结写成一份声明式规格,一次调用交付。样式**取什么值**(配色 / 字号 / 对齐 / 数字格式标准)以 `references/lark-sheets-visual-standards.md` 为唯一权威,本文只讲**怎么落地**。
>
> **边界(三分流判定,按操作组合选入口)**:目标是**样式 / 合并 / 行高列宽 / 冻结**的任意组合 → 本命令;**同一个写操作**打多个区域(如多区域清除、批量下拉)→ 用该命令自身的复数形态(`--ranges` / map 入参);操作链**跨类型且有顺序依赖**(如插列 → 写表头 → 回填数据)→ `+batch-update`。美化收尾不需要也不应该拼 `--operations` 子操作数组。
## 使用场景
写入。对存量表格的多个子表批量应用视觉规格:新表美化、加汇总行后统一版式、按分组合并同类单元格、调列宽行高、冻结表头。整份规格展开为一次批量提交按序执行,与 `+batch-update` 同为 **fail-fast**——失败后哪些子操作已生效不做统一假设,先回读确认再补发(语义同 `references/lark-sheets-batch-update.md`「执行语义」)。
⚠️ **失败后不要照抄报错里的 `operations[N]` 去续发**:那个数组是 CLI 从 `--styles` 展开出来的(相邻同样式的 `cell_styles` 还会被合并成更大的矩形),下标与你写的 spec 项没有对应关系,也不是你能直接重发的东西。正确做法:回读受影响区域(`+cells-get --include style` / `+sheet-info`)确认哪些已生效,再重发没落上的部分。样式 / 行高列宽 / 冻结是幂等盖章(整份重发无副作用,这通常就是最省事的解法),只有 `cell_merges` 需要挑出未生效的部分单独发。
**词汇三处同构**:`--styles` 的字段词汇与 `+workbook-create --styles`(建新表同步美化)、`+table-put --styles`(写数据同步美化)完全一致——`cell_styles` / `cell_merges` / `row_sizes` / `col_sizes` / `freeze` 学一次三处通用。区别只有两点:本命令作用于**已有**表格(顶层 `--url` / `--spreadsheet-token` 定位),且 `cell_styles` 的 range 不受「本次写入区域」限制、可指向表内任意区域。
**规格要点**:
- 顶层 `{styles:[...]}`,每项对应一个目标子表,`name` 必须是真实子表名(不确定先 `+workbook-info` 查,禁止猜 `Sheet1`)。
- 每个子表项按固定顺序执行:`cell_merges` → `cell_styles` → `row_sizes` → `col_sizes` → `freeze`;样式盖章允许覆盖含合并区的区域(合并区限制只针对值写入,样式不受限)。
- `row_sizes` / `col_sizes` 只需 `{range, size}`(px,即像素尺寸;`standard` / 行的 `auto` 才需显式 `type`)。尺寸键统一是 `size`。
- 加边框用 `border` 简写:`{"style":"solid","color":"#DDDDDD"}` 应用到四边;只有分侧不同样式才用 `border_styles` 完整形态。
- `freeze` 用 `{rows:N, cols:N}` 冻结前 N 行 / 列,0 或省略表示该维度不冻结;freeze 是整份状态覆盖,全 0(如 `{"rows":0}`)= 两轴全部解冻,与 `+dim-freeze --rows 0 --cols 0` 等价(仅 `+workbook-create` 建新表时全 0 无意义、会被校验拒绝)。
**回读校验**:整份规格执行成功后按编辑准则抽样回读受影响区域(`+cells-get --include style` 或 `+sheet-info` 看合并 / 行高列宽 / 冻结),确认关键样式实际生效。
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+styles-put` | write | 批量 |
## Flags
### `+styles-put`
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--styles` | string + File + Stdin(复合 JSON) | required | 对**已有**表格应用的视觉规格 JSON:顶层 `{styles:[...]}`,每项对应一个目标子表(`name` 用真实子表名),并至少给 `cell_styles` / `cell_merges` / `row_sizes` / `col_sizes` / `freeze` 之一。字段词汇与 `+workbook-create` / `+table-put` 的 `--styles` 完全同构(cell_styles 用 A1 range + 扁平样式字段,边框用 `border` 简写 {style,weight,color} 四边同款、分侧才用 border_styles;row/col sizes 用行/列范围 + size(px 即像素,standard/auto 才需 type);merges 用单元格 range;freeze 用 `{rows:N, cols:N}` 冻结前 N 行/列)。整份规格展开为一次批量提交(fail-fast:失败后哪些已生效不做统一假设,先回读确认再补发);range 不受「本次写入区域」限制,可指向表内任意区域 |
## Schemas
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
### `+styles-put` `--styles`
**数组项**(类型 object):
- `cell_merges` (array<object>?) — 单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all each: { merge_type?: enum, range: string }
- `cell_styles` (array<object>?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border?: object, border_styles?: object, font_color?: string, font_family?: string, …共 14 项 }
- `col_sizes` (array<object>?) — 列宽操作数组;range 使用列范围如 A:C,给 size(px)即像素列宽(type 可省略);type 为 standard 时不带 size each: { range: string, size?: number, type?: enum }
- `freeze` (object?) — 冻结行列:rows = 冻结前 N 行,cols = 冻结前 N 列(0 或省略 = 该维度不冻结) { cols?: integer, rows?: integer }
- `name` (string) — 子表名
- `row_sizes` (array<object>?) — 行高操作数组;range 使用行范围如 1:3,给 size(px)即像素行高(type 可省略);type 为 standard/auto 时不带 size each: { range: string, size?: number, type?: enum }
## Examples
### `+styles-put`
表头美化 + 按组合并 + 列宽 + 冻结首行,一次交付:
```bash
lark-cli sheets +styles-put --url "https://example.feishu.cn/sheets/shtXXX" --styles - <<'JSON'
{"styles":[{
"name": "Sheet1",
"cell_merges": [{"range":"A5:A8"},{"range":"A9:A12"}],
"cell_styles": [
{"range":"A1:F1","font_weight":"bold","background_color":"#1E5BC6","font_color":"#FFFFFF","horizontal_alignment":"center"},
{"range":"A2:F30","border":{"style":"solid","color":"#DDDDDD"}}
],
"row_sizes": [{"range":"1:1","size":36}],
"col_sizes": [{"range":"A:C","size":120}],
"freeze": {"rows":1}
}]}
JSON
```
多子表同一批交付(每个子表一个 styles 项):
```bash
lark-cli sheets +styles-put --url "..." --styles - <<'JSON'
{"styles":[
{"name":"明细","cell_styles":[{"range":"A1:H1","font_weight":"bold","background_color":"#F0F0F0"}],"freeze":{"rows":1}},
{"name":"汇总","cell_styles":[{"range":"A1:D1","font_weight":"bold"}],"col_sizes":[{"range":"A:D","type":"pixel","size":140}]}
]}
JSON
```
### Validate / DryRun / Execute 约束
- `Validate`:`--styles` 必须是合法 JSON、`styles` 非空数组;每项 `name` 必填、至少给 `cell_merges` / `cell_styles` / `row_sizes` / `col_sizes` / `freeze` 之一;`cell_styles` 每项至少一个样式字段;展开后受子操作数(100)与总格数预算约束,超限报错给拆分建议。
- `DryRun`:输出展开后每个子操作的请求模板,不发起调用。
- `Execute`:整份规格合成一次批量请求按序执行;fail-fast。报错会列出失败的子操作及原因,但其中的 `operations[N]` 是 CLI 展开后的内部下标(含 `cell_styles` 合并),不对应 `--styles` 里的项,也不能直接按下标续发——报错会明说这一点并让你先回读再补发。
references/lark-sheets-visual-standards.md›
# 飞书表格样式与配色规范
> **本文定位**:飞书表格"正确视觉输出"的取值标准与美化决策流——配色、表头、对齐、数值格式、斑马纹、列宽行高、图表展示,以及新增 / 继承 / 美化已有区域三类场景的做法。
> **边界**:本文只讲"样式长什么样、怎么决策";**怎么调用工具写入样式**(`cell_styles` / `border_styles` 字段、合并、resize 等参数)见 `references/lark-sheets-write-cells.md` / `references/lark-sheets-range-operations.md` / `references/lark-sheets-batch-update.md`。**条件格式**(高亮 / 标红 / 数据条 / 色阶)见 `references/lark-sheets-conditional-format.md`。本文不含 shortcut,通用编辑准则见主 SKILL.md「飞书表格编辑准则」。
## 最高优先级原则
- **用户指令优先**:用户明确提出的格式要求(如"使用红色背景")具有最高权重,即使与通用审美冲突。
- **继承原表风格**:编辑前先采样原文件视觉特征(色系、边框、对齐、数字格式),新增内容必须与之对齐。严禁对已有风格的文件强行施加通用标准化格式。
- **扩展而非覆盖**:新增行列或追加数据时,目标是"扩展原模板"——继承邻近区域的表头风格、条纹节奏、边框层级、对齐方式、数字格式和列宽/行高策略。
- **美化只动样式属性,不动数据**:对**已有区域**做美化时,**只能**修改 `font` / `fill` / `border` / `alignment` / `number_format` 这 5 类样式属性。**禁止**改动原始单元格的 `value` / `formula`、合并区域、行列结构、Sheet 名称。如果美化需求需要改变数据布局(例如"汇总行加进表里"),必须把"加汇总行"和"美化"拆成两步,前者属于编辑动作、需另行得到用户授权。
- **不可见视觉属性也属保护对象**:原表的**合并范围、对齐方式(H-Align/V-Align)、行高列宽、数字格式**是用户能感知但不一定会明示的视觉属性。即使用户没说"保留这些",**禁止**因写入新内容而修改它们;写公式 / 写值 / 写新列时只传 `value` / `formula`,不要重置 `alignment` / `number_format` 等字段为默认值(重置等同于改动)。**例外**:用户明示要修改这些属性时(如"调整对齐 / 合并 / 列宽")才能动;用户**点名美化**("美化 / 让表清晰 / 适合打印")视同授权下节 checklist 的全部 5 个维度(含列宽行高)。
- **标红 / 高亮默认用背景色**:用户说"标红 / 标出来 / 高亮"时,默认改**背景色**(可叠加字体色)——背景色在人工核对与导出后都更醒目;仅当用户明确说"字体标红"才只改字体色。
- **打印 / 下载类任务的完成标准是导出后也无遮挡**:涉及"适合打印 / 下载 / 导出"时,飞书表格调整完行高列宽后,导出 xlsx 再检查一次无截断、无 `####`、无溢出;长文本列给足列宽并设明确行高兜底值,不要只依赖 auto。
- **美化范围必须覆盖所有用户语义目标**:用户说"给表格加边框 / 美化整个表"时,范围 = 实际数据区域**含所有数据行**(含汇总行、总计行、表尾备注行),不能停在"看起来主体内容结束"的地方。落地前先用 `current_region` + 末尾 5~10 行核对真实末行(同 `references/lark-sheets-read-data.md` 的「确定数据范围的正确流程」),再设置美化范围。范围漏掉用户提到的目标行 / 列视为未完成,需补齐后再交付。
## 美化任务 5 维度 checklist(用户**点名美化**——"美化 / 让表更清晰 / 适合打印"时必做;"整理"默认指数据整理,不触发本节)
当用户**点名美化**("美化 / 让表清晰 / 适合打印 / 调整样式"——"整理"不算,那是数据整理)时,**必须**遍历以下 5 个维度逐一落地,只做一项(如只加边框)就交付是不完整的。**已有表点名美化时,5 个维度的取值先沿用原表色系 / 对齐(继承原则优先),checklist 只补原表缺失的维度**:
1. **表头格式区分**:表头行加粗 + 背景色填充(与数据行有色差)+ 居中对齐;多行表头时全部行同步处理
2. **对齐方式**:文本列左对齐、数值 / 货币 / 百分比列右对齐、日期 / 分类列居中;垂直方向统一居中
3. **数值格式**:每列统一小数位 + 千分位(用 `number_format`);金额列统一货币符号;同一列内**禁止**出现 0 位 / 1 位 / 2 位小数混杂
4. **边框**:覆盖范围按上方「美化范围必须覆盖所有用户语义目标」规则(含汇总 / 总计 / 表尾说明行),内外框线清晰
5. **列宽 + 行高 + 自动换行**:详细规则见 `references/lark-sheets-range-operations.md` 的「写入后列宽自适应」章节(按最长字符数扩列宽 / 长文本设置 `cell_styles.word_wrap="auto-wrap"` + 调高行高 / 长数字设置 `number_format` 防科学计数法)
**差异化标注场景**:用户要求"重复行 / 异常值 / 重要项视觉区分"时,标注列 / 行必须设置与普通数据**显著不同**的 `cell_styles`(背景色 + 加粗 + 字体色至少改一项),不能与普通数据格式完全一致。
**显式要求边框 / 表头 / 对齐时同样按上面标准落地**(不必等用户说"美化"):① 用户说"给某矩形区域加边框"必须**整个矩形含表头行、数据行、汇总行全部加内外框**,落地后核起 / 末行、末列三边界(反例:要求加边框的区域实际无任何边框);② **新建表头前先确认哪一行才是表头**——别把已有的第一行数据误当表头刷成蓝底白字,真正该加的表头列也要建出来(反例:把第一行数据误设成了表头样式);③ 新增 / 编辑区域的字号必须与原表一致,禁止 13 号与 14 号、10 号与 11 号混杂(反例:新列字号与原表不一致)。
## 通用样式规范
> 以下取值标准都在「最高优先级原则」的**继承原表风格 / 扩展而非覆盖**前提下生效:凡涉及"沿用原表"的条目,遵循该原则即可,本节不再逐条复述。
### 1. 表头样式
- 表头/汇总行须与数据区域有明确视觉区分。
- 使用低饱和度背景色搭配字体颜色(如深蓝 + 白字,浅蓝 + 黑字),文字加粗、水平居中。
- 表头覆盖多列时使用合并单元格。
### 2. 数据区域样式
- 减少垂直线条,优先使用水平浅灰细线。
- **对齐方式**:文本左对齐,数值/货币/百分比右对齐,日期或分类居中,所有内容垂直居中。
- 次要信息(备注、次要日期等)使用缩小字号或浅灰色。
- **Zebra Stripes**:数据行 > 10 行时可使用交替背景色引导视线。
- 设置前先清理原区域背景色为白色(#FFFFFF),再设置斑马纹色,避免新旧混杂。
- 优先直接设置单元格背景色,而非条件格式(除非用户要求)。
- 推荐配色:奇数行 #FFFFFF,偶数行 #F3F4F6 或 #EBF1F8。
### 3. 数值格式
- 百分比使用 `%` 符号,适当注明单位和货币符号(¥、$)。
- 大于 1000 的数字使用千分位符,保留一致的小数位数(1–2 位)。
- 涉及数据检索的须注明数据来源。
- 可使用数据条/色阶/条件格式增强可视化。
### 4. 整体结构
- 数据行超过一屏的长表 / 宽表,收尾冻住表头(表头上方还有标题 / 说明行时一并冻住):`+dim-freeze` 或 `+styles-put` 的 `freeze` 一次给全行列(整份状态覆盖,拆两次只留最后一次的轴),再 `+sheet-info` 回读确认。原表已有冻结设置的不动。
- **长文本处理**:启用自动换行,行高合理调整以确保阅读舒适,添加适当垂直留白,目标是清晰、专业、不拥挤的布局。
- 保持表格简洁,合理分组(可用合并单元格展示分组),在适当位置添加合计或汇总行。
- **区域分隔**:多阶段或多类别时,使用柔和背景色块进行逻辑分区,而非简单边框。
- **增删行列的样式规则**:
- 新增整列继承同组列的表头样式、列宽、对齐和数字格式;新增整行继承同层级数据行或汇总行风格,避免写成表头风格。追加列时需判断是否应加入已有合并单元格(常见于顶部标题行)。
- 若追加位置紧邻汇总行、说明区或空白分隔区,先判断真实数据区域边界再操作,避免破坏原有结构。
- **Zebra Stripes 维护**:插入或删除行后若影响后续行奇偶性,须从受影响行往后重建条纹(先清理再重设)。少量增删用局部重建,大量变动用全局清理+统一重建。
- 具体采样与复制流程见下方「场景二:从已有区域继承美化」。
- **列宽 / 行高调整**(飞书 `+cols-resize` / `+rows-resize` 直接给像素值:统一尺寸用 `--range` + `--width`/`--height <px>`,多列 / 多行不同尺寸用 `--widths`/`--heights` map 一次调用完成,如 `--widths '{"A":100,"C:E":120}'`):
- 禁止硬编码固定列宽,须根据该列实际内容长度估算像素。
- 经验估算:中文每字约 15-18px,英文/数字每字约 7-9px,外加 10-16px padding。
- 上下限建议 80~400px;超上限启用自动换行(`word_wrap: auto-wrap`)+ 调整行高,而非无限加宽。
- 合并单元格不参与列宽计算,避免撑宽单列。
- 复制自原文件的列优先沿用原列宽,不重新计算覆盖。
### 5. 配色
- 优先沿用原表色板与明暗层级(见「继承原表风格」),新增区域不凭空换色,确保视觉连续。
- 背景填充选择柔和色(如浅蓝 `#DDEBF7`),区分颜色时优先同一主题色不同深浅,避免超过 3 种主题色。
### 6. 图表展示
- 遵循用户指令选择图表类型,或匹配用户意图(饼图 → 占比,折线图 → 趋势)。
- 包含必要元素:标题、坐标轴标题、多系列图例;普通基础图先按开启数值标签运行尺寸建议器并默认展示,密集时依次采用建议尺寸、稀疏标签、Top-N 或拆图;目标线等常量系列不显示逐点重复标签。
- Y 轴显示范围默认交给图表引擎,不按数据源单列的最小值 / 最大值主动设限;组合图先比较系列单位和量级,把会被压扁的系列放到右轴。
- 饼图默认将图例放在底部;尺寸建议器保持相对固定的饼区,主要按最长标签增加两侧留白。类别过多或数值高度偏斜时优先 Top-N 或条形图,避免靠无限加宽解决。
- 创建前运行 `scripts/lark_chart_size_advisor.py`,使用其 `create_flags`;若提示仅放大无法解决,则改用条形图、Top-N 或拆图。创建后运行 `scripts/lark_chart_quality_check.py`。
- 优先继承原表配色,同一指标跨图保持同色;组合图使用同色系柱形和高对比折线,辅助系列使用中性色。分类色过多时优先精简数据,不依靠更多相近颜色区分。
- **图表放置防重叠**:新增图表前须计算放置区域,避免与已有图表重叠。具体步骤:
1. 调用 `+chart-list` 获取当前工作表所有已有图表的 `position`(锚点单元格:`col` 是列字母如 "A"/"B"、`row` 是 1-based 行号;以 `+chart-list` 实际返回字段为准)、`offset`(锚点内偏移:`row_offset`、`col_offset`,单位像素)以及 `size`(`width`、`height`,单位像素)。
2. 获取工作表的行高和列宽信息(像素)。
3. 根据每个图表的锚点 `position.row`/`position.col` + 偏移 `offset.row_offset`/`offset.col_offset` + 尺寸 `size.width`/`size.height`,结合行高列宽,计算出每个已有图表覆盖的像素矩形区域 `(x_min, y_min, x_max, y_max)`。
4. 为新图表选定大小后,候选放置位置应避开所有已有矩形区域;若存在重叠则向下或向右偏移,直至找到无冲突位置。
5. 若工作表已无足够空间,优先向下方空白区域放置,保持图表间至少 1 行或 1 列的间距。
> 飞书表格中颜色需带 `#` 前缀(如 `#0070C0`),与 openpyxl 的无前缀写法不同。
> 具体工具调用参数格式,请读取对应工具 skill(`references/lark-sheets-write-cells.md`、`references/lark-sheets-conditional-format.md`、`references/lark-sheets-range-operations.md` 等)。
---
## 场景化操作指南
### 场景一:新增独立样式
> 适用情况:在表格中创建全新的、具有独立视觉特征的区域,如汇总行、新表头、独立数据表等。
#### 1A. 添加汇总行 / 表头行
**决策流程:**
1. 先用 `+cells-get` 读取目标位置上方的数据区域,确认数据边界和已有样式(背景色、字体大小等)
2. 如果需要新增空行,先用 `+dim-{insert|delete|hide|unhide|freeze|group|ungroup}` 插入行
3. 用 `+cells-set` 写入汇总公式 + 特殊样式(背景色区分 + 加粗 + 边框)
4. 如果汇总行标题需要跨列显示,追加 `+cells-{merge|unmerge}` 合并标题区域
**样式要点:**
- 汇总行使用比数据区域更深的同色系背景(如数据区 #EBF1F8 → 汇总行 #D6E4F0 或 #4472C4 + 白字)
- 必须加粗,水平对齐方式与数据列一致(数值列右对齐,文本列左对齐)
- 上方加一条较粗的边框线,与数据区域形成视觉分隔
#### 1B. 添加独立数据表/独立区域
**决策流程:**
1. 新建 sheet,或用 `+cells-get` 或 `+workbook-info` 确认已有表格的占用范围,找到空闲区域
2. 用 `+cells-get` 采样已有表格的表头样式(背景色、字体大小、字重、对齐方式)和数据区域样式
3. 新表头复用已有表头的配色和字体参数(保持风格统一),但内容和列宽可独立
4. 新数据区域复用已有数据区域的对齐规则、边框风格、数字格式
5. 用 `+cells-set` 一次性写入新表头 + 数据
**样式要点:**
- 必须复用:背景色色系、字体大小、字重、边框风格
- 可以独立:列宽、行高、具体数字格式(根据新数据的类型调整)
- 新旧表格之间至少留 1~2 行空白作为视觉分隔
### 场景二:从已有区域继承美化
> 适用情况:新增的行/列/区域与已有内容性质相同(数据类型、层级一致),需要无缝衔接已有格式。
#### 2A. 继续补充行/列(数据性质与已有内容一致)
**核心规则**:采样紧邻 2 行 → 判断并延续 Zebra Stripes 奇偶性 → 按 write-cells 的继承清单带齐样式写入。
**斑马纹延续要点**(本节只管"奇偶判断"这一标准,"带哪些样式字段写入"的机制见下方指针):
- 至少读 2 行(末行 + 倒数第二行)才能判断是否有斑马纹交替色
- 若倒数两行背景色不同(如 #FFFFFF 与 #F3F4F6),新行按奇偶延续,不要固定一个色
> 具体继承哪些字段、怎么采样与写入(`+cells-get` 读源行 `cell_styles` + `border_styles`、`+sheet-info --include row_heights,merges` 读行高合并、带齐 6 类样式写入)见 `references/lark-sheets-write-cells.md` 的「新增列 / 新增行的样式继承」章节——`border_styles` 四边易遗漏,以那里为准。
#### 2B. 基于模板区域的修改(copy 保留所有格式)
**核心思路:三步分层法**
```
Step 1 — 格式铺开:`+range-copy --paste-type formats`
└── 将模板行/区域的 **全部格式**(样式、边框、数字格式、数据验证等)复制到目标区域
└── 即"格式刷"——只复制格式,目标值/公式保留
└── 若需连带公式平移填充(如公式列结构一致),改用 `+range-fill --series-type copy`
Step 2 — 内容覆写:`+cells-set`(仅传 value/formula,不传任何样式)
└── 将每行实际数据写入,cell_styles 全部省略,因为格式已在 Step 1 中就位
Step 3 — 微调收尾:`+rows-resize --heights` / `+cols-resize --widths`(行高列宽 map 一次调用完成)、`+cells-{merge|unmerge}` 等
└── 调整行高列宽、处理合并单元格、扩展条件格式范围等边缘情况
```
**关键注意事项:**
- Step 1 用 `+range-copy --paste-type formats` 时只铺格式、不动值/公式,Step 2 再用 `+cells-set` 写值即可(`+cells-set` 默认覆盖,无需额外 flag);若 Step 1 用 `--paste-type all` 连带复制了值/公式,Step 2 写入同样会覆盖(默认行为)
- `+range-fill --series-type auto`(或 `linear`/`date`)会自动递增数字序列(1→2→3)和日期序列,`+range-fill --series-type copy` 则原样复制值但公式引用会自动平移
- 如果模板区域存在合并单元格,copy/fill 不会复制合并状态,必须在 Step 3 中用 `+cells-{merge|unmerge}` 补全
- 如果模板区域有条件格式,需要在 Step 3 中通过 `+cond-format-update` 扩展 ranges
**场景:纯"格式刷"(用户说"把 A 列样式应用到 B 列"、"格式复制过去"、"只刷格式不改数据")**
单步即可,无需三步分层:调用 `+range-copy --paste-type formats`,`--source-range` 为样式来源、`--target-range` 为目标起点。参数细节见 `references/lark-sheets-range-operations.md`。
### 场景三:已有区域格式美化
> 适用情况:对已存在数据的区域进行格式美化(不改变数据内容),重点处理表头、汇总行等特殊行的识别与格式设置,需特别注意合并单元格的安全操作。
#### 整体操作流程
```
1. 探查阶段
├── `+workbook-info` → 获取子表列表、行列数、冻结位置
├── `+sheet-info --include merges` → 获取合并区域
├── `+cells-get`(前几行 + 末尾几行,`--include style`)→ 采样表头/数据区/汇总行样式
└── 分析结果 → 建立区域地图(表头行号、数据起止行号、汇总行号、合并区域列表)
2. 规划阶段
├── 判断表头行:通常第 1 行或前 2 行,特征为加粗/背景色/合并/居中
├── 判断汇总行:通常最后 1~2 行,特征为加粗/SUM/AVERAGE 公式/更深背景色
├── 判断合并区域:从 `+cells-get` 返回中识别(多个单元格同值且样式相同通常暗示合并)
└── 制定美化方案:按区域分别设置样式
3. 执行阶段(按顺序)
├── 先处理合并单元格(如需取消合并再重新合并,必须先 unmerge 再 merge)
├── 设置表头样式
├── 设置数据区域样式
├── 设置汇总行样式
└── 调整列宽行高
```
#### 美化中的合并单元格要点
- 编辑前先识别已有合并区域(见探查阶段),避免破坏原有语义分区。
- 美化表头/分组标题时,若需修改合并区域的范围或样式,遵循"先 `unmerge` → 修改 → 再 `merge`"顺序。
- 合并区域样式只写左上角,不要对合并内的其他单元格重复写入样式。
> 合并单元格完整的安全操作规则(含数据保护、样式占位等 5 条)见 `references/lark-sheets-range-operations.md` 的 `+cells-{merge|unmerge}` 章节。
references/lark-sheets-workbook.md›
# Lark Sheet Workbook
## Sheet 结构变更保守化(编辑类任务必做)
`+sheet-{create|delete|rename|move|copy|hide|unhide|set-tab-color}` 会改变原表的物理结构,是高副作用动作。执行前必须遵守:
1. **删除 / 重命名 / 隐藏 / 移动原 Sheet 需用户明示**:除非用户明示要这些操作,**禁止**擅自对**已存在**的 Sheet 执行 delete / rename / hide / move。新建 Sheet 是允许的(用于承载中间结果或透视表 / 图表对象),但应优先在原表右侧加列;只有当中间结果数量较大或会与原数据混淆时,才新建空白 Sheet(同 R1)。
2. **Sheet 级操作前先列清单**:调用 `+sheet-{create|delete|rename|move|copy|hide|unhide|set-tab-color}` 之前,必须先调用 `+workbook-info`,把"当前所有 Sheet 名 + 可见性 + 行列数"列出来,再决定是否操作。禁止跳过列清单直接 create / delete / rename。
3. **删除 / 重命名前向用户确认**:删除是不可逆的,重命名会让其他公式 / 透视表 / 图表的数据源失效——执行前必须在回复里确认"将删除 / 改名 X,影响 Y 个引用"。
## 使用场景
读写。管理工作簿结构。本 reference 覆盖 14 个 shortcut:
| 操作需求 | 使用工具 | 说明 |
|---------|---------|------|
| 查看工作簿结构 | `+workbook-info` | 获取子表列表、名称、行列数、冻结位置等元数据 |
| 获取当前 revision | `+revision-get` | 获取当前文档 revision(版本号),可作为 recover / undo / changeset 复核的版本锚点 |
| 新建工作簿(可预填数据) | `+workbook-create` | 从内存数据建一张新表(`--values` / `--sheets` typed) |
| 导入本地文件为新表 | `+workbook-import` | 把本地 `.xlsx` / `.xls` / `.csv` 导入为新的飞书电子表格 |
| 导出工作簿到本地 | `+workbook-export` | 导出为本地 `.xlsx`(整簿)或单子表 `.csv` |
| 变更工作簿结构 | `+sheet-{create|delete|rename|move|copy|hide|unhide|set-tab-color}` | 新建/删除/移动/重命名/复制/隐藏子表、修改标签颜色 |
| 切换子表网格线显隐 | `+sheet-show-gridline` / `+sheet-hide-gridline` | 显示 / 隐藏单个子表的网格线 |
注意:
- 如果用户请求包含多个动作,例如"先重命名,再新建工作表",请按顺序发起多次调用,覆盖全部动作
- `create` 时若用户指定了工作表名称,应显式传入 `sheet_name`;不要省略后依赖默认命名
- 若 `+workbook-info` 返回包含 `warning_message`,说明部分 `sheet_id` 已失效(被删除/改名或输入错误),应停止复用这些 id,重新不带 `sheet_ids` 全量获取结构后再继续操作
**常见配置错误(必须注意)**:
- **获取结构是第一步**:任何表格操作前必须先调用 `+workbook-info`,不要跳过直接操作。返回的行列数、子表列表是后续所有操作的基础
- **sheet_id 不要写错**:从 `+workbook-info` 返回值中精确获取 `sheet_id`,不要手动拼写或从 URL 中猜测
- **未点名网格目标**:默认候选仅 `resource_type=sheet && is_hidden=false` 的可见普通网格;唯一候选才自动选,多候选按用户给的表名/表头/内容匹配,仍不唯一则询问。禁止按 `index` 或猜 `Sheet1`;用户显式点名 hidden sheet 可操作,bitable / `#UNSUPPORTED_TYPE` 改走对应产品 API。
- **xlsx 验收触发边界**:普通在线交付不导出。只有用户明确要求本地 xlsx / 下载 / 打印时,才在 `--output-path` 导出后验收;本地 Excel 输入则直接验证导入前已有的本地文件,导入在线后不再导出回验。允许触发时确认文件存在、可重开,并核对公式错误值、样式和对象。
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+workbook-info` | read | 工作簿 |
| `+sheet-list` | read | 工作簿 |
| `+revision-get` | read | 工作簿 |
| `+sheet-create` | write | 工作簿 |
| `+sheet-delete` | high-risk-write | 工作簿 |
| `+sheet-rename` | write | 工作簿 |
| `+sheet-move` | write | 工作簿 |
| `+sheet-copy` | write | 工作簿 |
| `+sheet-hide` | write | 工作簿 |
| `+sheet-unhide` | write | 工作簿 |
| `+sheet-set-tab-color` | write | 工作簿 |
| `+sheet-hide-gridline` | write | 工作簿 |
| `+sheet-show-gridline` | write | 工作簿 |
| `+workbook-create` | write | 工作簿 |
| `+workbook-export` | read | 工作簿 |
| `+workbook-import` | write | 工作簿 |
## Flags
### `+workbook-info`
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
_仅含公共 / 系统 flag。_
### `+sheet-list`
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
_仅含公共 / 系统 flag。_
### `+revision-get`
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
_仅含公共 / 系统 flag。_
### `+sheet-create`
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--title` | string | required | 新工作表名称 |
| `--index` | int | optional | 插入位置(0-based);省略时附加到末尾 |
| `--row-count` | int | optional | 初始行数(默认 200,上限 50000) |
| `--col-count` | int | optional | 初始列数(默认 20,上限 200) |
| `--type` | string | optional | 新子表类型:sheet(电子表格);默认 sheet(可选值:`sheet`) |
### `+sheet-delete`
_公共四件套 · 系统:`--yes`、`--dry-run`_
_仅含公共 / 系统 flag。_
### `+sheet-rename`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--title` | string | required | 新名称 |
### `+sheet-move`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--index` | int | required | 目标位置(0-based) |
| `--source-index` | int | optional | 源位置(0-based);standalone 调用时可选,未传时由 CLI runtime 根据 `--sheet-id` / `--sheet-name` 当前在工作簿中的 index 自动派生。但在 `+batch-update` 内不可省(须显式传)——batch 中途无法发起结构查询自动派生 |
### `+sheet-copy`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--title` | string | optional | 副本名称;省略时由服务端生成 |
| `--index` | int | optional | 副本插入位置(0-based);省略时附加到末尾 |
### `+sheet-hide`
_公共四件套 · 系统:`--dry-run`_
_仅含公共 / 系统 flag。_
### `+sheet-unhide`
_公共四件套 · 系统:`--dry-run`_
_仅含公共 / 系统 flag。_
### `+sheet-set-tab-color`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--color` | string | required | Hex 色值如 `#FF0000`,传空 `""` 清除 |
### `+sheet-hide-gridline`
_公共四件套 · 系统:`--dry-run`_
_仅含公共 / 系统 flag。_
### `+sheet-show-gridline`
_公共四件套 · 系统:`--dry-run`_
_仅含公共 / 系统 flag。_
### `+workbook-create`
_系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--title` | string | required | 新 spreadsheet 标题 |
| `--folder-token` | string | optional | 目标文件夹 token;省略时放在云空间根目录 |
| `--values` | string + File + Stdin(简单 JSON) | optional | untyped 初始数据,一个 JSON 二维数组(表头并入第一行):`[["列A","列B"],["alice",95]]`;值原样写入、类型由飞书自动识别(日期 / 数字会落成文本,需类型保真改用 --sheets),走与 --sheets 相同的分批 `+cells-set`;配 --styles 控制格式/颜色/合并/行列尺寸 |
| `--sheets` | string + File + Stdin(复合 JSON) | optional | 建表后写入的 typed 表格协议 JSON(同 +table-put):顶层 `{"sheets":[...]}`,每个数组项是一张子表 `{name, start_cell?, mode?, header?, allow_overwrite?, columns:["colA","colB",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` —— `name` 与外层 `sheets` 数组都不可省。Agents 用 `scripts/lark_sheets_df.py` 的 `df_to_sheet(df, name)` 把 DataFrame 转成一项再包 `{"sheets":[...]}`。与 --values 互斥;新表默认子表复用为第一个子表,日期/数字类型保真。 |
| `--styles` | string + File + Stdin(复合 JSON) | optional | 建表时同时写入的视觉处理操作 JSON:顶层 `{styles:[...]}`,每项对应一个目标子表、含 `name`,并至少给 `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges` 之一。`cell_styles` 用 A1 单元格 range + 扁平样式字段(字段同 +cells-set-style,含 number_format / 颜色 / 对齐 / border_styles);row/col sizes 用行/列范围 + type/size;merges 用单元格 range + 可选 merge_type。与 --sheets 搭配时 styles 数组长度/顺序/name 必须与 --sheets.sheets 对应;与 --values 搭配时只给一个 styles 项(其 name 忽略)。完整 cell_styles 字段结构跑 `+workbook-create --print-schema --flag-name styles`。 |
### `+workbook-export`
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--file-extension` | string | optional | 导出文件格式;`csv` 模式必须配 `--sheet-id`(可选值:`xlsx` / `csv`)(默认 `xlsx`) |
| `--sheet-id` | string | optional | 仅 csv 模式必填:指定要导出哪张 sheet 为 CSV。这是 `+workbook-export` 专有 flag,与公共四件套的 sheet 定位无关(本 shortcut 不接受公共 sheet 定位) |
| `--output-path` | string | optional | 本地保存路径;省略时**只触发并轮询导出任务、不下载文件**(返回 file_token / status,便于稍后续传)。要落盘传具体路径(如 `./out.xlsx`)或目录(如 `.`,服务端给的文件名落在该目录下)。注意:对应的 `lark-cli drive +export --doc-type sheet` 走 `--output-dir` / `--file-name` / `--overwrite` 三 flag 且默认下载到当前目录——本 wrapper 把它们合成单一 `--output-path` 简化常见用例,但默认不下载,需要的话也可改用 `drive +export`。 |
### `+workbook-import`
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--file` | string | required | 本地文件路径(.xlsx / .xls / .csv) |
| `--folder-token` | string | optional | 目标文件夹 token;省略则导入到云空间根目录 |
| `--name` | string | optional | 导入后表格名称;省略则用本地文件名(去掉扩展名) |
## Schemas
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
### `+workbook-create` `--sheets`
_一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入_
**数组项**(类型 object):
- `name` (string) — 目标子表名
- `start_cell` (string?) — 写入起点单元格(A1 记法,如 "B2"),默认 "A1"
- `mode` (enum?) — overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头) [overwrite / append]
- `header` (boolean?) — 是否写一行列名表头
- `allow_overwrite` (boolean?) — 为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)
- `columns` (array<string>) — 列名字符串数组,顺序与 `data` 中每行取值一一对应
- `data` (array<array<string|number|boolean|null>>) — 数据行;每行是一个数组,长度必须等于 `columns` 数
- `dtypes` (object?) — 可选
- `formats` (object?) — 可选
### `+workbook-create` `--styles`
**数组项**(类型 object):
- `cell_merges` (array<object>?) — 单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all each: { merge_type?: enum, range: string }
- `cell_styles` (array<object>?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border?: object, border_styles?: object, font_color?: string, font_family?: string, …共 14 项 }
- `col_sizes` (array<object>?) — 列宽操作数组;range 使用列范围如 A:C,给 size(px)即像素列宽(type 可省略);type 为 standard 时不带 size each: { range: string, size?: number, type?: enum }
- `freeze` (object?) — 冻结行列:rows = 冻结前 N 行,cols = 冻结前 N 列(0 或省略 = 该维度不冻结) { cols?: integer, rows?: integer }
- `name` (string) — 子表名
- `row_sizes` (array<object>?) — 行高操作数组;range 使用行范围如 1:3,给 size(px)即像素行高(type 可省略);type 为 standard/auto 时不带 size each: { range: string, size?: number, type?: enum }
## Examples
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。`+workbook-info` 只用前两者;`+sheet-*` 系列对单个工作表操作,需 `--sheet-id` 或 `--sheet-name`。
### `+workbook-info`
输出契约:返回 `sheets[]`,每个含 `sheet_id` / `title`(工作表显示名;旧 payload 用 `sheet_name`,读取时优先取 `title`、缺失再回退 `sheet_name`)/ `index` / `resource_type` / `row_count` / `column_count` / `is_hidden`,以及计数字段 `merged_cells_count` / `chart_count` / `pivot_table_count` / `float_image_count`(无 `frozen_*` 字段,冻结信息请用 `+sheet-info` 读取)。是操作飞书表格的第一步——任何后续 sheet 级动作都需要先拿这里的 sheet_id。
> **子表类型 `resource_type`**:`sheet`(普通网格子表)/ `bitable`(内嵌的多维表格子表)/ `#UNSUPPORTED_TYPE`(其它暂不支持的嵌入子表)。
> - 网格类操作(读写单元格 / 区域 / 样式 / CSV / 筛选 / 透视 / 图表等)**仅适用于 `sheet`**。对 `bitable` / `#UNSUPPORTED_TYPE` 子表执行网格操作会被直接拒绝并返回明确报错,不再静默出错。
> - 要操作 `bitable` 子表里的数据:该子表条目会附带 `bitable_app_token` + `bitable_table_id` 两个字段,直接用多维表格命令操作,例如 `lark-cli base +record-list --base-token <bitable_app_token> --table-id <bitable_table_id>`(记录增删改查、字段、视图等整套 `lark-cli base` 命令均可用)。不要走 sheets 网格命令。
> - `bitable` / `#UNSUPPORTED_TYPE` 子表条目**只含** `sheet_id` / `sheet_name` / `index` / `resource_type`(bitable 另加上述两个 token)以及 `is_hidden` / `tab_color`;**不输出** `row_count` / `column_count` / `merged_cells_count` / `chart_count` / `pivot_table_count` / `float_image_count` / `frozen_*` 等网格指标(对非网格子表无意义)。
> - tab 管理类操作(`+sheet-rename` / `+sheet-move` / `+sheet-delete` / `+sheet-hide` 等)对任意 `resource_type` 的子表都合法,不受此限制。
### `+revision-get`
输出契约:返回单个 `revision` 字段,即当前文档版本号。它是 recover / undo / `+changeset-get` 的版本锚点:如果刚执行过一次读写操作,也可以直接复用那次响应里的 `revision`;当只想单独取当前版本号、且不需要其它结构信息时,用 `+revision-get` 最直接。
### `+workbook-create`
新建电子表格,可选预填数据。两种数据入口(untyped `--values` / typed `--sheets` JSON)**互斥**,按需选一——两者都走同一条分批写入:
> ⚠️ **`--title` 必填,且不会从数据里推断**:它是这张表在云空间里的名字,漏了会在建表之前就失败(`required flag(s) "title" not set`),数据一行都不会写。子表名写在 `--sheets` 的 `name` 字段里,两者是两码事——`--title "2026年Q3销售分析"` 配 `--sheets` 里的 `"name": "明细"`。
```bash
# 1) untyped:--values(一个二维数组,表头并入第一行;值原样写、类型由飞书自动识别,
# 日期会落成文本,配 --styles 控制格式)
lark-cli sheets +workbook-create --title "销售" \
--values '[["门店","销售额"],["北京",259874]]'
# 2) typed JSON:--sheets(一步建表 + 类型保真)。date 列落成真日期(可排序/透视)、
# number 不丢精度、string 列保前导零(如订单号 00123);多子表一次建。
lark-cli sheets +workbook-create --title "交易" --sheets '{
"sheets":[
{"name":"明细",
"columns":["日期","金额","单号"],
"dtypes":{"日期":"datetime64[ns]","金额":"float64","单号":"object"},
"formats":{"金额":"#,##0.00"},
"data":[["2024-01-15",1234.5,"00123"]]}
]}'
```
`--sheets` 协议与 `+table-put` 完全同构(字段含义见 lark-sheets-write-cells 的 `+table-put`,大 payload 走 stdin / `@file`)。关键差异:**新建工作簿的默认子表会被复用为第一个子表**(重命名后承载数据),不会残留空 `Sheet1`;其余子表按需新建。它把 `+table-put` 单独做不到的"建表 + typed 写入"合到一条命令,是「pandas 算完直接落地一张带真日期的新表」的首选。回读校验用 `+table-get`(与 `--sheets` 同构、可 round-trip)。
> 💡 pandas DataFrame 走 `--sheets` 时直接 `from lark_sheets_df import df_to_sheet`([`scripts/lark_sheets_df.py`](../scripts/lark_sheets_df.py),与 `+table-put` 共用同一份 helper),多子表场景 helper 优势更明显:
> ```python
> import sys; sys.path.insert(0, "scripts") # cwd 不在 skill 根时改成 scripts/ 的实际路径
> from lark_sheets_df import df_to_sheet
>
> payload = {"sheets": [df_to_sheet(income, "Income Statement"),
> df_to_sheet(balance, "Balance Sheet"),
> df_to_sheet(cashflow, "Cash Flow")]}
> ```
`--styles` 可在建表写入时同时写视觉处理。它和 `--sheets` 一样只有一种外层写法:顶层对象里放 `styles` 数组;数组每项对应一个子表,含 `name`,并按能力拆成四类可选数组:
- `cell_styles`:像 `+cells-set-style`,用 A1 单元格 `range` 加扁平样式字段(`font_weight` / `background_color` / `horizontal_alignment` / `vertical_alignment` / `number_format` 等)和可选 `border_styles`;这些样式会随内容在同一次写入里一并应用。完整字段跑 `+workbook-create --print-schema --flag-name styles`。
- `cell_merges`:用 A1 单元格 `range` 设置合并,`merge_type` 默认为 `all`,可选 `rows` / `columns`。
- `row_sizes`:用行范围(如 `1:3`)设置行高,`type` 为 `pixel` / `standard` / `auto`;`pixel` 需要 `size`。
- `col_sizes`:用列范围(如 `A:C`)设置列宽,`type` 为 `pixel` / `standard`;`pixel` 需要 `size`。
同一单元格命中多个 `cell_styles` 项时,后面的操作继续合并覆盖已传字段。`cell_merges` / `row_sizes` / `col_sizes` 在内容写入后顺序执行。
```bash
# 3) untyped:仍用 {"styles":[...]},只有一个子表样式项(name 忽略);range 覆盖 --values 初始区域
lark-cli sheets +workbook-create --title "销售" \
--values '[["门店","销售额"],["北京",259874],["上海",198320]]' \
--styles '{
"styles":[
{"name":"Sheet1","cell_styles":[
{"range":"A1:B1","font_weight":"bold","background_color":"#f5f5f5","horizontal_alignment":"center","vertical_alignment":"middle"},
{"range":"B2:B3","number_format":"#,##0"}
]}
]
}'
# 4) typed 单子表:--styles.styles[0].name 必须对应 --sheets.sheets[0].name
lark-cli sheets +workbook-create --title "交易" --sheets '{
"sheets":[
{"name":"明细",
"columns":["日期","金额"],
"dtypes":{"日期":"datetime64[ns]","金额":"float64"},
"formats":{"金额":"#,##0.00"},
"data":[["2024-01-15",1234.5]]}
]}' --styles '{
"styles":[
{"name":"明细",
"cell_styles":[
{"range":"A1:B1","font_weight":"bold","background_color":"#f5f5f5",
"border_styles":{"bottom":{"style":"solid","weight":"thin","color":"#000000"}}},
{"range":"A2:A2","number_format":"yyyy-mm-dd"},
{"range":"B2:B2","number_format":"#,##0.00","font_color":"#0f7b0f"}
],
"cell_merges":[{"range":"A1:B1"}],
"col_sizes":[{"range":"A:B","type":"pixel","size":120}],
"row_sizes":[{"range":"1:1","type":"pixel","size":28}]}
]
}'
# 5) typed 多子表:styles 数组和 sheets 数组长度、顺序、name 都必须一致
lark-cli sheets +workbook-create --title "经营看板" --sheets '{
"sheets":[
{"name":"收入","columns":["月份","收入"],"dtypes":{"收入":"int64"},"formats":{"收入":"#,##0"},"data":[["2026-05",1200000]]},
{"name":"成本","columns":["月份","成本"],"dtypes":{"成本":"int64"},"formats":{"成本":"#,##0"},"data":[["2026-05",730000]]}
]}' --styles '{
"styles":[
{"name":"收入","cell_styles":[
{"range":"A1:B1","font_weight":"bold","background_color":"#f0f7ff"},
{"range":"B2:B2","font_color":"#0f7b0f"}
]},
{"name":"成本","cell_styles":[
{"range":"A1:B1","font_weight":"bold","background_color":"#fff7ed"},
{"range":"B2:B2","font_color":"#b42318"}
]}
]
}'
```
> ⚠️ **`+workbook-create` 是把内存里的数据写成新表;要把已有的本地 Excel/CSV 文件原样导入成新表,用 `+workbook-import`**(见下),不要先在本地读出文件再 `+workbook-create` 重灌。
### `+workbook-import`
把已有的本地 `.xlsx` / `.xls` / `.csv` 文件导入为一个**新的**飞书电子表格(异步任务 + 内置轮询),与 `+workbook-export`(导出)对称,固定导入为电子表格类型。
```bash
# 导入到云空间根目录;表格名默认取本地文件名(去掉扩展名)
lark-cli sheets +workbook-import --file ./data.xlsx
# 指定目标文件夹与导入后表格名
lark-cli sheets +workbook-import --file ./report.csv --folder-token <FOLDER_TOKEN> --name "月度报表"
```
- **不接受任何 spreadsheet / sheet 定位 flag**(它是新建,不操作已有表):只有 `--file`(必填)/ `--folder-token` / `--name`。
- **`--file` 只接受当前工作目录内的相对路径**:先 `cd` 到文件所在目录(或 workspace),再传 `./file.xlsx` / `data/file.xlsx`;传 `/home/.../file.xlsx`、`C:\...\file.xlsx` 这类绝对路径会被判定 `unsafe file path` 拒绝。
- 导入成功后把新表链接通过宿主的产物交付工具交出去,并确认这次调用返回成功;只写进回复正文不算交付。
- 本地表格文件 → 飞书电子表格一律用本命令,**不要**用 `drive +import` 导电子表格——它是 sheets 之外的通用导入、还需额外指定 `--type`,绕路且更易错。只有要把本地表格导入成**多维表格**(bitable)时,才改用 `lark-cli drive +import --type bitable`。
- 返回 `token` / `url` / `ticket` / `ready` / `job_status`。只有 `ready=true` 且 `job_status=0` 才算导入完成;随后用新 URL 调 `+workbook-info`,有点名内容契约时再回读关键 sheet/range。`timed_out=true` 时按 `next_command` 续查,不能交付为成功。
- **值与公式保真,版式不保真**:走一圈后值、公式、数字格式、合并区、冻结、下拉校验都原样保留;行高列宽、边框、主题色填充(`fgColor theme=N`)、以及**跟随工作簿默认字体的格**(源表默认字体是中文字体时,这类格会回落到系统默认)则会变,与本轮做了什么无关。要求保留原版式时,导入后按源文件的值用 `+rows-resize` / `+cols-resize` 回写尺寸(飞书用像素、Excel 行高用磅,换算约 `px ≈ pt × 4/3`),其余版式差异回写不了,在交付说明里写明。
- 轮询是命令自己做的:本命令与其它异步 shortcut 都内置轮询,返回时状态已是最新,`next_command` 直接重跑即可,不必也不要加 `sleep` 等待。
### `+workbook-export`
把飞书电子表格导出为本地 `.xlsx`(整工作簿)或单子表 `.csv`(异步任务 + 内置轮询 + 可选下载)。
```bash
# 1) 只创建并轮询导出任务,不下载(默认):返回 file_token / status 便于稍后续传
lark-cli sheets +workbook-export --url "https://example.feishu.cn/sheets/shtXXX"
# 2) 下载到具体文件名
lark-cli sheets +workbook-export --url "..." --output-path ./report.xlsx
# 3) 下载到目录(保留服务端给的文件名)
lark-cli sheets +workbook-export --url "..." --output-path ./downloads/
# 4) csv 模式必须传 --sheet-id(API 一次只导一张子表)
lark-cli sheets +workbook-export --url "..." --file-extension csv --sheet-id "$SID" --output-path ./sheet.csv
```
> ⚠️ **默认不下载**:省略 `--output-path` 时只创建并轮询导出任务。普通在线交付不得为了内部验证主动导出;只有用户明确要求本地 xlsx / 下载 / 打印时才给 `--output-path` 并验收。验收时确认文件存在、可重开,并核对公式错误值、样式和对象。
>
> **与 `drive +export --doc-type sheet` 的关系**:本 wrapper 是它的特化封装,固定 `--doc-type sheet`,并把 drive 的 `--output-dir` / `--file-name` / `--overwrite` 三 flag 折叠成单一 `--output-path` 简化常见用例。代价是默认值不同:`drive +export` 默认下载到当前目录、本 wrapper 默认不下载。需要细控目录/文件名/是否覆盖的,回退到 `drive +export --doc-type sheet`。
### `+sheet-create`
示例:
```bash
lark-cli sheets +sheet-create --url "https://example.feishu.cn/sheets/shtXXX" \
--title "汇总" --index 0
```
> 💡 `+sheet-create` 只建一张**空子表**。要在已有工作簿里建子表并一步写入 typed 数据和/或样式,用 `+table-put`(payload 里命名的子表缺则自动新建)配合它的 `--sheets` / `--styles`,省掉先建表再 `+cells-set` / `+cells-set-style` 的二次往返。
### `+sheet-delete`
> ⚠️ 工作表删除不可逆;先 `--dry-run` 看输出 sheet_id + title 确认是要删的那张。
### `+sheet-rename`
```bash
lark-cli sheets +sheet-rename --url "..." --sheet-id "$SID" --title "汇总"
```
### `+sheet-move`
standalone 路径在缺 `--source-index` / 只给 `--sheet-name` 时会自动发起一次 `+workbook-info` 读把它们解出来。
> ⚠️ **在 `+batch-update` 内调用 `+sheet-move`**:必须同时显式传 `--sheet-id`、`--source-index` 和 `--index`(目标位置)。batch 中途无法发起结构查询,且 `--index` 不显式给会静默落到默认位置 0,所以 batch translator 强制要求三者都显式。
### `+sheet-copy`
```bash
# --title 省略时由服务端生成副本名
lark-cli sheets +sheet-copy --url "..." --sheet-id "$SID" --title "副本"
```
> 💡 `+sheet-copy` 连**公式 / 合并 / 分组底色 / 列宽 / 条件格式**一起整表复制。"照一张现成子表批量造结构相同的新子表"(如参考模板给每份数据各建一张同构子表)时,先 `+sheet-copy` 复制模板再用 `+cells-*` 只改数据,比从零 `+sheet-create` + 重建公式 / 样式省一大截,也天然满足"公式 / 分组 / 颜色照搬"。要把本地文件 / 数据并入**已有工作簿**当子表时走它(或 `+sheet-create`),别用 `+workbook-import` / `+workbook-create`——那两条只会新建独立表。
### `+sheet-hide` / `+sheet-unhide`
```bash
lark-cli sheets +sheet-hide --url "..." --sheet-id "$SID"
lark-cli sheets +sheet-unhide --url "..." --sheet-id "$SID"
```
### `+sheet-set-tab-color`
```bash
# Hex 色值;传空字符串 "" 清除标签色
lark-cli sheets +sheet-set-tab-color --url "..." --sheet-id "$SID" --color "#FF0000"
```
### `+sheet-show-gridline` / `+sheet-hide-gridline`
```bash
# 切换子表网格线显隐;二态语义在命令名里,无需额外参数(同 +sheet-hide/+sheet-unhide)
lark-cli sheets +sheet-show-gridline --url "..." --sheet-id "$SID"
lark-cli sheets +sheet-hide-gridline --url "..." --sheet-id "$SID"
```
### Validate / DryRun / Execute 约束
- `Validate`:XOR 公共四件套;`+sheet-create` 校验 `--title` 非空、`--row-count` ≤ 50000、`--col-count` ≤ 200;`+sheet-delete` 必须 `--yes` 或 `--dry-run`;`+workbook-create` 的 `--sheets` 与 `--values` **互斥**,给了 `--sheets` 则按 typed 协议校验 payload(其余约束同 `+table-put`)。
- `DryRun`:`+sheet-*` 写操作输出"将要 PATCH 的 sheet metadata";`--sheet-name` 在 dry-run 输出里生成为 `<resolve:Sheet1>` 占位符,不实际解析为 sheet-id。
- `Execute`:sheet create/rename/move/copy/hide/unhide/delete 后必须调用 `+workbook-info`,按稳定的 sheet_id 核对名称、顺序、可见性与数量;import 按上方 ready/job_status + workbook-info 闭环;需要本地文件的 export 按 output-path + 文件存在/可重开闭环。
references/lark-sheets-write-cells.md›
# Lark Sheet Write Cells
## 写入边界 + 回读诊断(编辑类任务建议)
1. **明确写入边界**:写入前建议能回答"目标 range 的起止行列号是多少?是否落在用户授权范围内?"。除用户明示要修改的区域外,避免扩张到原数据列以外或新建 Sheet。
2. **完整性断言**:批量写入前建议把"预期写入条数"硬编码到代码里(如要填 106 条翻译 → `expected = 106`),写完后回读比较 `actual == expected`。少于预期时优先补齐,补不齐则在交付说明里列出缺口。
3. **回读抽样校验**:写完关键值 / 普通公式后,用 `+csv-get` 或 `+cells-get` 重新读取写入区域,至少抽样 3-5 个代表性单元格(首 / 中 / 末),核对值与预期一致(与本地脚本计算的预期值对照)。AI 公式的计算状态不要先用 `+cells-get` 轮询,直接按 `references/lark-sheets-formula-verify.md` 的 `+formula-verify --ai-only --range` 全区间一次异步状态检查规则处理。公式特定的"先验证模板再 --copy-to-range / 修完再读回"细则见下方相关章节。
4. **护原表 · 派生产物落点(写排名 / 标记 / 汇总 / 改写列时易丢数据)**:派生结果优先写到**真实末列 +1 的全新空列**或新建子表,避免复用任何已有原数据列——哪怕该列看起来"空",也要先 `+csv-get` 回读确认整列无原始数据再写。三条准则:① 尽量不把新公式 / 新值写进原数据列(典型反例:把新算的排名公式写进了原本存放另一份原始数据的列,整列原始数据被覆盖丢失);② 尽量不改写、不合并原表头字段名(典型反例:把几个独立表头字段合并成一列,原字段名丢失);③ 慎用 `--allow-overwrite`:它一旦让写入区盖到相邻原始列 / 行就是不可逆数据丢失,加它之前建议用 `+sheet-info` / `+csv-get` 核清目标 range 不含任何原始数据。
## 新增列 / 新增行的样式继承(防止视觉风格不一致)
新增列 / 新增行时,先铺样式再写值,分两步——避免只传 `value` 期望默认样式与原表一致(飞书新单元格默认对齐通常是 `H:right, V:bottom`,与多数原表的 `H:center, V:middle` 不一致)。
**推荐做法(一步到位)**:用 `+range-copy --paste-type formats` 把相邻原列/原行的样式复制到目标区域,再写值。`--paste-type formats` 只复制样式不动值。目标区域尺寸由**源区域**推断,`--target-range` 只给目标左上角锚点单元格;要铺满整列,源区域就要覆盖整列(如 `C1:C100`)。命令要带完整定位(`--url`/`--spreadsheet-token` 与 `--sheet-id`/`--sheet-name` 各一):
```bash
# 新列 D — 把 C 列的样式铺到 D 列(源 100 行 → 目标从 D1 起 100 行)
lark-cli sheets +range-copy --url "<表格URL>" --sheet-name "<真实表名>" \
--source-range "C1:C100" --target-range "D1" --paste-type formats
# 新行 20 — 把第 19 行的样式铺到第 20 行
lark-cli sheets +range-copy --url "<表格URL>" --sheet-name "<真实表名>" \
--source-range "A19:Z19" --target-range "A20" --paste-type formats
```
目标锚点 + 源尺寸决定落区,无需在 `--target-range` 里写出完整范围。参数细节见 `references/lark-sheets-range-operations.md`。
**手动做法(需要精确控制时)**:先用 `+cells-get --include style` 读相邻单元格的完整样式作为模板,再在 `+cells-set` 的 `--cells` 里逐格携带 `cell_styles`。需要继承的字段清单:
1. `cell_styles.font_family` / `cell_styles.font_size` / `cell_styles.font_weight` / `cell_styles.font_color` / `cell_styles.font_style`
2. `cell_styles.horizontal_alignment` / `cell_styles.vertical_alignment` — 漏继承会导致新列对齐与原列不一致(常见)
3. `cell_styles.number_format` — 漏继承会导致同列数值格式混乱
4. `cell_styles.background_color`
5. `border_styles`
6. **`merged_cells`(合并范围)**——续写场景必查:用 `+sheet-info --include merges` 读原数据区域的合并信息。原行有跨列合并时,新行用 `+cells-{merge|unmerge}` 复制相同合并模式。仅传 cells 数组的样式不够——合并范围要单独靠 `+cells-{merge|unmerge}` 落地。
**反模式**(风险):
- 只传 `{"value": "四级菜单"}` 给 D1,不传 `cell_styles` → D1 默认非加粗、非居中,与 A1/B1/C1 风格断裂
- 新列 M5 写入 `=SUM(F5:L5)` 时只传 `formula`,不传样式 → M 列对齐变 `H:right`,数字格式变默认
## 长数字防科学计数法(数值列写入必查)
写入或计算结果可能产生长数字(≥ 12 位整数 / 高精度小数)的列,建议在 `cell_styles.number_format` 显式设置非通用格式,否则飞书会自动用科学计数法显示,用户看到的就是"内容被截断 / 看不清原值"。
| 场景 | 必加的 `number_format` |
|---|---|
| 长整数(订单号 / 身份证 / 单据号) | `"0"` 或 `"@"`(强制文本,避免精度丢失) |
| 金额 / 千分位 | `"#,##0.00"` |
| 百分比 | `"0.00%"` |
| 数量 / 计数 | `"0"`(整数) |
| 日期 | `"yyyy-mm-dd"` 或 `"yyyy/m/d"` |
**典型反例**:长数字列(如审批单号、流水号)未设 `number_format`,飞书显示为 `1.23E+15`,用户复制出来已经丢失精度。
> **数字还是文本,按"数据本质是量值还是标识符"二选一 —— 不看当下要不要计算**:金额 / 百分比 / 比率 / 计数 / 度量这类**本质是量值**的数据,优先以**数字类型**写入(百分比存小数 `0.54` 配 `number_format:"0%"`),避免设 `@` 文本格式。**这与"用户当下是否要排序 / 求和"无关**——数据类型由数据本质决定、不由当下用途决定:表格数据几乎总会被后续排序 / 图表 / 二次计算复用,`"54%"` 文本与数值列混排本就破坏一致性,且数字 + `number_format` 显示效果与文本**完全相同**,没有任何理由选文本。**最常见的误判就是"这只是 leaderboard / 报表 / 看板展示,又不用算,写成 `54%` 字符串就行"——这是错的,展示用途不改变"百分比是数值"的事实。**(`+table-put` 用 `dtypes` 声明 `int64` / `float64`;版式 `+table-put` 装不下时用 `+cells-set` 传数字 + `number_format`;都别在本地拼成带 `$` / `%` 的字符串走 `+csv-put`。)反过来,编号 `001`、规格 `3-1`、身份证 / 电话 / 单据号等**本质是标识符 / 标签**、要原样保留不被飞书自动解释的内容(否则 `001`→`1`、`3-1`→日期、点分日期 `12.10`→`12.1`(尾零丢失)、长号→科学计数),才以**字符串类型**写入(`dtypes` 设 `object`)并把 `number_format` 设为 `"@"`(文本格式),字面保真。
**typed 列必须显式声明类型**:金额、百分比、日期、布尔、计数,以及后续参与排序 / 聚合 / 图表的量值列,在 `+table-put` 的 `dtypes` 中逐列声明类型,并用 `formats` 控制显示;不要依赖字符串外观或自动猜型。纯文本/标识符表可全部声明 `object`。mixed 列先保留原值并新增清洗结果/失败标记,统计总数、成功、失败和空值;均值/比例必须说明分母,禁止静默把失败值丢掉后改变口径。
**追加数据**:普通表尾追加直接用 `+table-put` 的 `mode:"append"`,它会自动定位末行;只有用户明确要求在中间物理插行、继承模板区块或扩展合并结构时,才先用 `+dim-insert`。
## 使用场景
写入。向飞书表格的单元格区域写入值、公式、样式、批注、图片或下拉,也可批量写入 CSV / DataFrame。本 reference 覆盖 6 个 shortcut,按数据来源 + 内容形态选:
> ⚠️ **计算结果默认写公式,不写静态值**:需要计算得出的数字 / 统计量 / 排名 / 占比,默认写公式到单元格,不要用 Python 算好数值再硬编码写入。公式优先原则的例外:外部抓取数据、永不变化的常量、循环引用。
| 场景 | 用这个 shortcut | 原因 |
|------|----------------|------|
| 模型手里已经有 CSV 文本(小规模手动构造、从 `+csv-get` 取到后简单加工) | `+csv-put` | 直接传 CSV 文本 + `--start-cell`,不用自己拼二维 cells 数组;必要时自动扩容行列 |
| 列里有数值语义的数据(数字 / 金额 / 百分比 / 日期 / 计数)→ 飞书,要类型保真(来源不限:DataFrame、Counter、dict、list 都算) | `+table-put` | typed 协议每项必含 `name/columns/data`,可带 `start_cell/mode/header/allow_overwrite/dtypes/formats`;数值语义列显式声明 `dtypes`,`formats` 控制千分位 / 百分比 / 日期。date 落真日期、数值列可排序 / 求和 / 入图表,string 保前导零,多 sheet 一次写 |
| 写入含样式、批注、图片、数据校验等任意富写入 | `+cells-set` | 唯一支持完整富字段的 shortcut(公式 `+csv-put` 也能写) |
| 只改已有 cell 的样式,不动 value/formula | `+cells-set-style` | 拍平 10 个样式字段为独立 flag;不触发不必要的值写入 |
| 单 cell 嵌入图片 | `+cells-set-image` | 比 `+cells-set` 参数更简短 |
| 在**已有区域**局部补表头样式/边框 | 先用 `+csv-put` 写值,再用 `+cells-set-style` 补样式 | 分工配合,入参最短 |
| **新建子表 / 整表成套美化**(哪怕全是纯文本) | `+table-put --sheets … --styles …` 一步带值 + 全套样式(区域底色 / 边框 / 列宽 / 行高 / 合并;payload 里不存在的 sheet 名自动建子表) | `--styles` 与列是否 typed 无关,纯文本同样适用;比「写值 + 多次刷样式」少好几次调用 |
**选命令按内容形态分流(不设"默认首选")**:① 列有数值语义(金额 / 百分比 / 日期 / 计数)→ `+table-put`(`dtypes` 声明类型 + `formats` 设展示格式),版式装不下时 → `+cells-set` 传数字 + `number_format`;② 要样式 / 批注 / 图片 / 富文本 → `+cells-set`;③ **仅**全文本、无数值语义的内容平铺 → `+csv-put`(入参最短)。判据详见上方「数字还是文本」。
⚠️ `+csv-put` 可写值或公式:以 `=` 开头的单元格会被当作公式计算(读回时 `formula` 字段保留、`value` 为计算结果)。**公式内部含逗号 / 引号 / 换行时建议按 RFC 4180 转义**——含逗号的字段整格用双引号包裹、字段内部的引号再翻倍:如 `=COUNTIF(D5:D22,"及格")` 建议写成 `"=COUNTIF(D5:D22,""及格"")"`。漏转义会被 CSV 解析器按逗号拆列、整块写入区域错位。**因此含逗号 / 引号 / 换行的公式优先用 `+cells-set`**;`+table-put` 不支持公式字段。
⚠️ **`+csv-put` 会把数值落成文本**:把金额 / 百分比 / 计数等在本地拼成带 `$` / `%` / 千分位的字符串(如 `"$1,234.50"` / `"+30.5%"`)再 `+csv-put` 灌进去,单元格就是**文本**——丢失排序 / 求和 / 图表能力,且与数值列混排无法参与计算。数值该怎么写、何时 `+table-put`、版式装不下时何时退 `+cells-set` 传数字 + `number_format`,判据与分流见上方「数字还是文本」;核心一句:**准备把数字 format 成字符串再写时就是走错了路,数值一律以数字写入 + `number_format` 控制显示。**
⚠️ **`+csv-put` 也会把「看着像数字」的字段静默数值化**(与上一条相反的另一半坑):CSV 里语义是**日期标签 / 编号 / 标识符**、内容却全是数字字符的列,会被按数值解析——`12.10`→`12.1`(点分日期尾零丢失)、`3.0`→`3`、`001`→`1`、长号→科学计数。**这类列即使已攒好 CSV 文本也不建议裸走 `+csv-put`**:优先 `+table-put` 把该列 `dtypes` 声明为 `object`(无年份的点分标签如 `12.10` / `3-1` 字面保真)或 `datetime64[ns]`(完整真日期),版式装不下再退 `+cells-set` + `number_format:"@"`。此类失真在「抽样首 / 中 / 末」回读时易被掩盖(`12.10` / `12.20` 等尾零行常不落在抽样窗口),日期 / 编号列回读要专挑带尾零 / 前导零的代表值核对。
⚠️ 大数据回写走"`+csv-get` 按 `--range` 行窗口分批读到本地 + 本地脚本处理 + `+csv-put` 分批回写"。
## `+cells-set` 写入要点(常用模式 / 公式 / 样式)
> 以下是用 `+cells-set`(及 `+cells-set-style`)做富写入时的常用模式与准则;选哪个 shortcut 见上方「使用场景」。
`+cells-set` 为一块区域设置值 / 公式 / 批注 / 样式,也支持 `rich_text` 的 `type: "embed-image"` 嵌入单元格图片。**关键:`--cells` 恒为二维数组(行 × 格),单格也是 `[[{"value":…}]]`;裸 `--range A1`(或 `--start-cell`)是左上角**锚点**,落区由 `--cells` 自身的行列数决定;写成矩形(`A1:B2`)则是**边界**——数组比它小会收窄,比它大会被拒绝,而不是写到范围之外**。
> **单元格图片 vs 浮动图片**:图若**属于某条记录、要随那行排序 / 筛选 / 增删**(凭证 / 证件照 / 每行配图,话里带「对应 / 每行 / 这列」等绑定词)→ **单元格图片**(本工具):用 `+cells-set-image`(最短)或 `+cells-set` 的 `rich_text` + `type: "embed-image"`。只是自由摆放的装饰(logo / 水印 / 封面)→ 浮动图片,见 lark-sheets-float-image。别因「浮动图更好控制 / 更熟」默认选浮动图——它承载"对应某记录"的图会随增删行 / 排序错位。
常用模式(推荐,避免逐行写入替代):
- 整列公式:先在 `H2` 写一个公式,再用 `--copy-to-range "H2:H100"` 或 `--copy-to-range "H:H"` 向下填充。避免对每一行单独调用 `+cells-set` 写入相同结构的公式
- 整列样式:使用 `+cells-set-style` 或 `+styles-put` 指定目标 range;不要用 `--copy-to-range` 纯刷样式
- 首行样式:同上,直接对 `1:1` 或实际表头 range 设置样式
- 用户说”这列 / 整列 / 这行 / 首行 / 向下复制公式”时,值/公式填充用模板格 + `--copy-to-range`;只改样式用 `+cells-set-style` / `+styles-put`
- 多区域写入相同值/公式结构时,优先写一个模板,再用 `--copy-to-range` 复制;仅样式相同仍走样式命令
⚠️ **`--copy-to-range` 复制的是模板格的全部内容(值 + 公式 + 样式),不是只复制样式**:目标区域**已有值**时不要用它"刷样式"——会把整个区域的值覆盖成模板格的值(65 个格子全变成同一个数的事故就是这么来的)。只改样式、值 / 公式不动,用 `+cells-set-style` / `+cells-batch-set-style`;`--copy-to-range` 只用于目标区为空或本就要写同构公式 / 值的场景。
⚠️ **模板 `--range` 从数据行起算、别把表头圈进去**:`--copy-to-range` 会把 `--range` 模板按目标区尺寸周期性平铺,模板里若含了表头行,表头会每隔几行重复铺进数据区。整列填充时模板只取一格数据样式(如 `H2`),不要取成 `H1:H2`。
⚠️ **逐行写入公式是常见低效写法**:对每一行单独调用 `+cells-set` 写公式(如 26 次)既慢又易错,且不会自动平移公式引用。正确做法是 1 次模板写入 + 1 次 `--copy-to-range`(公式引用自动平移)。
💡 **多个不连续区域写入(批量修公式的正解)**:散布多处(可跨 sheet)的值 / 公式写入,用 `--writes` 一次批量交付(fail-fast,失败后先回读再补发)——每项 `{sheet_name, range, cells}`(跨 sheet 的项把 sheet 定位写在项里,项内没写则取顶层 `--sheet-name` / `--sheet-id`),不要为此拼 `+batch-update` 的 `--operations`,也不要逐区域多次调用(多次往返、中途失败难恢复):
```bash
lark-cli sheets +cells-set --url "..." --writes - <<'JSON'
[
{"sheet_name":"明细","range":"D5","cells":[[{"formula":"=IFERROR(C5/B5,0)"}]]},
{"sheet_name":"汇总","range":"B3","cells":[[{"formula":"=SUM(明细!C:C)"}]]}
]
JSON
```
范围级统一样式不在 `--writes` 里做(cells 逐格 `cell_styles` 仅用于逐格差异化),写完接 `+styles-put`。
💡 **写入公式前先按迁移规则改写**:如果公式来自 Excel 或包含数组场景,先读取并遵循 `references/lark-sheets-formula-translation.md` 的规则完成改写,再把最终公式写入 `formula` 字段。
💡 **内容与样式分离写入**:当同一区域样式高度重复时,先按正确类型写内容,再用 `+cells-set-style` 或 `+styles-put` 对目标范围补样式;不要用 `--copy-to-range` 纯刷样式,它会连模板值 / 公式一起复制。只有目标区为空或本就要复制同构公式 / 值时,才使用模板单元格 + `--copy-to-range`。
💡 **样式更新是「部分合并」,不是整体覆盖**:`+cells-set-style` / `+styles-put`(以及 `+cells-set` 的 `cell_styles` / `border_styles`)只改你**显式传入**的样式属性,未传的属性保留原值。两个实用推论:
- **可分层叠加**:对同一区域先刷字体色、再单独刷背景色、再单独刷边框,后一步不会清掉前一步——美化已有区域时无需一次带齐所有字段,可拆成多次窄调用。
- **`border_styles` 按边合并**:只传 `{"top":{...}}` 只更新上边框,`bottom` / `left` / `right` 保留原状;不必为了「只改一条边」而把四边全部重传。(例外见上方「新增行的边框/样式避免用 `{}` 跳过」:**全新行**底子里没有边框,仍需把要显示的边都显式传出。)
💡 **大批量数据分批写入(推荐)**:当需要写入大量行(如几十行以上)时,不要试图在一次调用中生成全部 `cells` 数据——`cells` 数组过大会让单次生成的内容过长,容易出错或被截断。应将数据拆分为多批,每批 20-50 行,分多次调用 `+cells-set` 逐批生成并写入(如先写 `A2:D21`,再写 `A22:D41`,依此类推)。每次只生成当前批次的数据,控制单次生成量。
注意:
- 不要把 `cells` 写成字符串化 JSON
- `+cells-set` 默认即覆盖非空 cell(`--allow-overwrite` 默认 true);若要**保护**非空 cell 不被覆盖,显式传 `--allow-overwrite=false`(遇非空 cell 报错)
- 若目标区域涉及合并单元格,不要向合并区域中的非左上角单元格写入数据;如需写入,应改写合并区域左上角单元格,或先调整/取消合并区域
- **构造 `range` 时行号建议基于逻辑行号**:如果之前通过 `+csv-get` 读取了数据,CSV 中被双引号包裹的多行字段(如 `"2026年3月2日\n星期一"`)是**一个单元格**,不是两行。写入时的行号建议按逻辑记录计算,不能按物理换行符计数,否则 `range` 会整体偏移导致写入到错误位置
> 用户说"样式和原表一致 / 保持原表格式 / 边框继承"时同理:`cell_styles` 只覆盖字体和对齐、**不含边框**,边框建议用独立 `border_styles` 字段传——完整继承清单见上方「新增列 / 新增行的样式继承」。
⚠️ **公式写入后必须完成验证(后端不会报全部语法 / 运行错误)**:`+cells-set` 写公式时,即便公式有括号不配对(如 `=IFERROR(VALUE(MID(D5,3,4))), 0)` 比 IFERROR 多一个 `)`)或用了飞书不支持的函数(如 `GOOGLETRANSLATE` / `CUBEVALUE`),**后端工具也可能返回 `updated_cells_count=N, rc=0` 的"成功"**——错误会静默写进单元格显示为 `#VALUE!` / `#NAME?` / `#REF!`。因此:
1. **写完立即回读**:`+cells-set` 后紧跟 `+csv-get`(或 `+cells-get`)读目标范围首、中、末及汇总行,检查错误值并核对 `formula`;AI 公式在这一步只做**一次**公式文本核对(`+cells-get --include formula` 看种子格 / 首格,确认引号 / 括号没在 shell / CSV / JSON 层被破坏、落进去的确实是 `=AI(...)`),不要用它轮询计算结果——计算状态走 `+formula-verify --ai-only`
2. **逐段运行诊断**:对本次新增 / 修改的公式范围调用 `+formula-verify --exit-on-error`;`partial` 拆小续扫,全部分段 `status='success'` 后才完成。AI 公式不套这条:改用 `+formula-verify --ai-only` 按全区间一次异步状态检查规则交付(`failed` / `unsupported` 先修,只剩 pending 可交付并说明后台仍在计算,详见 `references/lark-sheets-formula-verify.md`)
3. **看到 `#` 开头的错误值**立即修公式:`#NAME?` 多半是函数名拼错或用了飞书不支持的函数(如 `GOOGLETRANSLATE` / CUBE 系列;注意 `UNIQUE` / `FILTER` 飞书是支持的);`#VALUE!` 多半是类型不匹配或括号错位;`#REF!` 是引用错误;`~CIRCULAR~REF~` 是循环引用(公式引用了自身或会闭环)
4. **`--copy-to-range` 扩展前先验证模板**:模板单元格公式自己都算错,`--copy-to-range` 复制到 100 行就是 100 个错误
5. **去重 / 筛选函数**:飞书**支持** `UNIQUE` / `FILTER`(原生数组函数,详见 `references/lark-sheets-formula-translation.md`),可直接用;`DISTINCT` 不是飞书函数,去重用 `UNIQUE`。大数据量去重 / 分组也可用透视表(`+pivot-{create|update|delete}`,值字段聚合方式选 count)
6. **循环引用预检**:写聚合公式(SUM / AVERAGE / COUNT 等)前建议明确**引用范围不包含目标单元格自身或其传递依赖**。典型反例:在 C3 写 `=SUMIF(B:B,LEFT(B3,9)&"*",C:C)`,B 列匹配 B3 前 9 位时 C3 自己也命中,导致 C3 自引用 → `~CIRCULAR~REF~`。修法:用辅助列 / 显式排除自身(`SUMIFS(C:C, B:B, ..., A:A, "<>"&A3)`)/ 缩小范围避开自己
7. **文本提取公式的覆盖率验证**:用 `LEFT` / `MID` / `FIND` / `SUBSTITUTE` / `TEXTSPLIT` 等从文本里抠数据前,建议用本地脚本在**整列源数据**上跑一遍命中率统计(`df[col].str.contains(pattern).mean()`);命中率 < 100% 时优先补分支(IFS / 多个 IFERROR 串联)兜底,或改用本地脚本算好写静态值,**避免**只覆盖样本前 N 行就交付(典型反例:按"长123"这种带前缀的尺寸文本取数,对"宽×高"、"×"、"*"等其它写法直接漏匹配)
8. **公式范围与用户指令字面对齐**:用户说"对 F 至 L 列求和"优先写 `SUM(F2:L2)` 或 `F2+G2+H2+I2+J2+K2+L2`,**不能漏列、多列、错列**。写完用 `+cells-get` 拿回 `formula` 字符串,与用户原话逐字对照(参与求和的列名一致 / 起止列号一致 / 运算符一致),不一致就是风险
9. **量纲 / 单位换算 / 数量乘项预检(公式不报错但结果整体偏倍数)**:从文本提取数字做计算前,先核对**单位是否统一、是否漏乘数量、口径是否一致**——这类错误公式能跑通、无 `#` 报错,回读也看不出(值"像对的")。建议用本地脚本对 3–5 个代表行**离线手算一遍预期值**,与公式结果逐格比对量级:① 单位不一致先统一再算(典型反例:尺寸 `320CM*337CM` 直接取数相乘除以 1e6 得 0.11,正确是 CM→MM 换算后得 10.78,**差 100 倍**);② 按"单件×数量"的量建议乘数量列(典型反例:侧面板面积漏乘 F 列数量,F=2 的行只算了一半);③ 标准值口径对齐(典型反例:营养成分 mg/kg 与 g/100g 口径混用,整列放大 100 倍)。**口径 / 单位 / 数量任一项错,整列计算结果就是错的;这类错误公式不报错、回读也不易看出,建议靠离线手算对照。**
**公式写入后的诊断入口是 `+formula-verify`**:`+csv-get` / `+cells-get` 的抽样回读只能快速发现明显错误,覆盖不到整列中段、隐藏行、被条件格式遮蔽的错误,也看不到 `partial` 截断。只要本次 `+cells-set` / `--copy-to-range` / `+csv-put` 实际写入了公式,就按上方流程对目标范围逐段运行 `+formula-verify --exit-on-error`。AI 公式改走 `--ai-only` 的全区间一次异步状态检查,不按 `status='success'` 收敛。
⚠️ **收到 `formula_errors` 反馈后不建议只打补丁**:`+cells-set` 返回值里若出现 `formula_errors: [{cell, formula, error_type, detail}]`,说明某些 cell 公式编译失败(`error_type=compile_failed` 通常是函数语法错,如对数组结果直接写 `[1]` 下标取值——飞书不支持这种写法,取第 N 项要用 `INDEX(<数组表达式>, N)`;`non_formula` 是 `=` 开头但解析不通过)。此时**避免只聚焦修报错点的局部语法**(如仅把 `[1]` 换成 `INDEX(..,1)`),建议:
1. **重新审视整条公式的完整性**:被 formula_errors 标出的那一行,公式除了下标语法错,还可能有其他先天缺陷(字符清洗不全、IFERROR 兜底漏条件、引用列写错),修完语法错后立即整体复核
2. **同步对称修复所有相似列**:如果同一任务涉及多列相似处理(如"算 H 列面积"用 D 列尺寸、"算 I 列面积"用 E 列尺寸),**修完一列建议把同样的清洗/兜底逻辑同步到所有相似列**,避免出现 H 列用 `SUBSTITUTE(长)+SUBSTITUTE(高)+SUBSTITUTE(×)` 而 I 列只用 `SUBSTITUTE(×)` 这种不对称处理——会导致一列编译通过有值、另一列编译通过但 IFERROR 全返回空,用户看到的是"数据为空"而非"公式错"
3. **修完再读回验证**:不只看 `formula_errors` 为空(这只证明编译通过,不证明运行时有值),建议 `+csv-get` 读目标列前 3-5 行,确认**非空源数据对应的目标列有非空计算结果**
4. **核心心智**:`formula_errors` 是"帮你暴露编译错"的工具,不是"修掉它就收工"的通行证。编译通过 + 运行时 IFERROR 兜底空 = 用户视角的"没算出来"
⚠️ **新增行的边框/样式避免用 `{}` 跳过**:`cells` 数组里 `{}` 的语义是"**此单元格不做任何修改、保留原状态**"。这在写入**已有行**时是安全的(原有边框/样式保持不变),但在写入**新行**(比如表尾追加汇总行、扩展行)时是灾难:新行底子里本来就没边框,`{}` 不修改 = 保留无边框状态,导致该 cell 视觉断裂。
⚠️ **"汇总行"识别 → 读 `references/lark-sheets-visual-standards.md` 拿完整样式规范**:下述双重条件**同时满足**才是汇总行,避免仅凭"有 AVERAGE"就判定:
- **语义信号**(二选一):用户 prompt 含"合计/汇总/总计/统计/各科平均分/最下面加一行算…/底部总计"等意图词;或上下文明确是"表尾追加一行做聚合"
- **结构信号**:新行全行都在做聚合(含 `=SUM/AVERAGE/COUNT/MAX/MIN/SUBTOTAL(...)`,支持 IFERROR 包裹),**不是**单个 cell 算个参考值或每行都算的派生列
满足上述时,**不要在本文里猜样式**,直接去读 `references/lark-sheets-visual-standards.md` 的「场景一 → 1A. 添加汇总行 / 表头行」章节,按那里的样式要点配齐 `font.bold / horizontal_alignment / background_color / border_styles`。
反例(**不是**汇总行,避免自动加粗):
- 用户说"在 H5 帮我算个 AVERAGE 参考"→ 单 cell 计算
- 每行都有 `=AVERAGE(本行区间)` 的派生列 → 属数据列
- 用户明确说"不要加粗/样式和数据行保持一致"→ 遵循用户意图
**正确做法**(二选一):
- **做法 A(推荐)**:先按正确类型写 value / formula,再用 `+cells-set-style` 或 `+styles-put` 对整行补齐 `cell_styles` + `border_styles`;不要用 `--copy-to-range` 纯刷样式。汇总行的 bold / 背景色 / 上边框见 `references/lark-sheets-visual-standards.md` 的「场景一 → 1A. 添加汇总行 / 表头行」。
- **做法 B**:一次写入,但每个 cell(含空白格)都显式带 `cell_styles` + `border_styles`,**不能用 `{}`**。
**判断是不是"新行"**:写入 range 超出 `+csv-get` 返回的 `current_region` 右 / 下边界(如 `current_region=A1:H10`、写 `A11:H11`)即新行,建议按上述做法补边框。
## 富文本单元格:超链接 / @人 / @文档(`rich_text`)
带显示文本的超链接、@人、@文档这类富内容**建议**走 `+cells-set` 的 `rich_text` 字段(`cells[].rich_text` 数组,每段一个对象、带 `type`),**不能**直接传普通字符串——纯字符串只会被当作纯文本存进单元格。完整字段跑 `lark-cli sheets +cells-set --print-schema --flag-name cells`,常用段类型:
- **超链接(带显示文本)**:`{"type":"link","text":"飞书","link":"https://www.feishu.cn"}`。纯 URL 不需要 `rich_text`,直接写普通字符串即可。
- **@人**:`{"type":"mention","mention_token":"<userId>","notify":false}`。**仅支持同租户用户,单次写入最多 50 人。** `notify` **默认 `true`**(会给被 @ 的人发通知),不想发务必显式传 `false`。
- **@文档**:同样 `"type":"mention"`,`mention_token` 传文档 token(如 `shtXXX`)。
`mention_type`(类型编号)等可选字段以 `--print-schema` 输出为准。
> ⚠️ `rich_text` 一旦设置会**忽略**同一 cell 的 `value`;它与 `formula` / `multiple_values` 三者只能选其一作为内容字段(可叠加 `cell_styles` / `note` 等)。
## Dropdown 选项 + 配色(`+dropdown-set` / `+dropdown-update`)
### 选项怎么来:`--options` 与 `--source-range` 二选一
| flag | 选项来源 | 适用场景 |
|---|---|---|
| `--options '["a","b","c"]'` | 写在命令里的固定列表 | 选项集是常量、不需要事后维护 |
| `--source-range ''\''Sheet1'\''!T1:T3'` | 已有单元格里的值 | 选项要跟数据动态同步;想维护一张「枚举值」列后多处引用 |
两个 flag **必须传一个、且只能传一个**——同时传或都不传,CLI 会立刻报错。`--source-range` 用 A1 + sheet 前缀写法(如 `'Sheet1'!T1:T3`,sheet 名按 A1 标准单引号包裹),可以指同 sheet 也可以指其它 sheet(如 `'Refs'!A1:A10`)。
### 多选下拉:验证规则与选中值是两层数据
`+dropdown-set --multiple` 只把目标单元格的验证规则设为“允许多选”,**不会写入当前选中值**。后续用 `+cells-set` 填充多选结果时,必须把每个选项写成 `multiple_values` 数组项;不要把多个选项用逗号拼成一个普通 `value`,否则整串会被当成单值并触发数据验证失败。
```bash
# 先创建允许多选的下拉规则
lark-cli sheets +dropdown-set \
--spreadsheet-token shtXXX --sheet-id "$SID" \
--range "E2:E15" \
--options '["A","B","C","D"]' \
--multiple
# 再向 E6 写入 3 个选中值
lark-cli sheets +cells-set \
--spreadsheet-token shtXXX --sheet-id "$SID" \
--range "E6" \
--cells '[[{"multiple_values":[{"value":"A"},{"value":"B"},{"value":"C"}]}]]'
```
### 配色:新建默认用内置色板,更新保留已有配色
下拉**默认带胶囊高亮**——新建时不传 `--highlight` / `--colors`,所有选项按内置 10 色色板循环上色,跟 UI 手动配下拉的默认行为对齐。只有用户明确指定自定义颜色,或选项具有清晰的语义配色(如状态、风险、优先级,且颜色有助于理解)时才传 `--colors`。
下拉胶囊文字默认是黑色。自定义颜色时应使用**浅色、低饱和度背景**,避免鲜艳或偏深的色值影响可读性。
`+dropdown-update` 会重写整条验证规则。若用户没有要求重置已有配色,先用 `+dropdown-get` 回读,并把现有 `highlight_colors` 作为 `--colors` 传回;省略会按内置色板重建。
| 想要的效果 | 怎么传 |
|---|---|
| 新建时使用默认色板 | 都不传 `--highlight` / `--colors` |
| 更新时保留已有配色 | 先 `+dropdown-get` 回读,再将 `highlight_colors` 作为 `--colors` 传回 |
| 用户明确要求或选项有语义配色 | 只传浅色、低饱和度的 `--colors '["#hex",...]'`(不需要再传 `--highlight`) |
| 纯白下拉、不要高亮 | 传 `--highlight=false`(注意 `=false` 不能省,单写 `--highlight` 在 cobra 里等价于 true) |
`--colors` 长度**可以短于**选项数(list 模式短于 `--options` 长度,listFromRange 模式短于 `--source-range` 的单元格数),未指定的选项按内置色板循环补色;但**不能长于**——CLI 在 Validate 阶段就会拦截,错误形如 `--colors length (4) must not exceed dropdown source size (3)`。
当 `--highlight=false` 显式关闭高亮时,`--colors` 即使传了也会被忽略(语义自相矛盾,但不报错)。
### 最小用例
**`--options` 模式 — 默认色板(最常见)**:
```bash
lark-cli sheets +dropdown-set \
--url https://... --sheet-id <id> \
--range A2:A100 \
--options '["待开始","进行中","已完成","已取消"]'
```
**用户明确要求语义配色时**:
```bash
lark-cli sheets +dropdown-set \
--url https://... --sheet-id <id> \
--range A2:A100 \
--options '["待开始","进行中","已完成"]' \
--colors '["#E8F3FF","#FFF3D6","#E8F8E8"]'
```
**`--source-range` 模式**(先在 `'Sheet1'!T1:T3` 维护「男/女/保密」三行,再让 `B2:B21` 引用它):
```bash
lark-cli sheets +dropdown-set \
--url https://... --sheet-id <id> \
--range B2:B21 \
--source-range ''\''Sheet1'\''!T1:T3'
```
**纯白下拉**(明确告诉用户"不要彩色"时才用):
```bash
lark-cli sheets +dropdown-set \
--url https://... --sheet-id <id> \
--range A2:A100 \
--options '["低","中","高"]' \
--highlight=false
```
> ⚠️ **`--source-range` 必须带 sheet 前缀**(即使跟 `--range` 同 sheet)。注意一个坑:回读这种 listFromRange 下拉单元格时,`data_validation.range` 看起来不带 sheet 前缀(形如 `$T$1:$T$3`),如果要把读出来的 range 反过来写回 `--source-range`,**必须自己重新补上 sheet 前缀**,否则会被拒。
>
> ⚠️ **`--ranges` 类批量 flag 的 sheet 前缀必须「裸写」**——`+cells-batch-clear` / `+dropdown-update` / `+dropdown-delete` 的 `--ranges` 解析器不接受引号:表名含点或空格(如 `2025.9`、`一月份`)也直接写 `2025.9!A1`,写成 `'2025.9'!A1` 会被当成表名一部分、报 `sheet not found`。**但 `--source-range`、透视表 `--source`、`--range` 走 A1 标准**:sheet 名带单引号(如 `'Sheet1'!A1:B2`)是标准写法、裸写也接受,回读统一返回带引号形式——别把 `--ranges` 的裸写要求套到这些 flag 上。
`+dropdown-update`(多 range 批量更新)的目标 `--ranges` 是 JSON 数组(每项带 sheet 前缀),同一份选项 + 配色应用到所有 range。它会替换完整验证规则;需要保留现有配色时,按上文先回读并透传 `--colors`。
## Shortcuts
| Shortcut | Risk | 分组 |
| --- | --- | --- |
| `+cells-set` | write | 单元格 |
| `+cells-set-style` | write | 单元格 |
| `+cells-set-image` | write | 单元格 |
| `+dropdown-set` | write | 对象 |
| `+csv-put` | write | 单元格 |
| `+table-put` | write | 单元格 |
## Flags
### `+cells-set`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | xor | 写入区域(A1 格式)。与 `--writes` 二选一(单区域用 --range+--cells,多区域用 --writes) |
| `--start-cell` | string | optional | `--range` 的别名(与 `+csv-put` 一致,用 --start-cell 定左上角锚点);传区间时按区间左上角起写(隐藏 flag:不在 `--help` 列出,但可正常传入) |
| `--cells` | string + File + Stdin(复合 JSON) | xor | JSON:2D 数组 `[[{cell},...],...]`;裸 `--range A1`(或 `--start-cell`)是左上角锚点,写入范围按本数组的行列数推断;`--range` 写成矩形(`A1:B2`)则是边界——本数组比它小会收窄,比它大会被拒绝。每个 cell 可含 `value` / `formula` / `multiple_values` / `cell_styles` / `note` / `rich_text`(含 `type="embed-image"` 单元格嵌图)等。向启用多选的下拉单元格写入选中值时,必须用 `multiple_values:[{"value":...}]`,不要把多个选项用逗号拼成一个 `value`;完整字段跑 `--print-schema` |
| `--writes` | string + File + Stdin(复合 JSON) | xor | 多区域写入 JSON 数组(最多 100 项),每项 `{sheet_name\|sheet_id, range, cells}`——**跨 sheet 的项把 sheet 定位写在项里**(与 +batch-update 子操作、+styles-put 项同惯例),项内没写则取顶层 `--sheet-name` / `--sheet-id`,cells 结构同 `--cells`(二维数组,可逐格带 cell_styles/border_styles)。整批展开为**单次批量提交**(fail-fast,失败后先回读再补发),支持跨 sheet;典型场景:批量修复散布多处的公式、跨表同构写入——不要为此拼 +batch-update 的 --operations。与 `--range`+`--cells` 二选一;范围级统一样式不在此做,写完接 +styles-put |
| `--allow-overwrite` | bool | optional | 允许覆盖非空 cell(默认 true);设为 false 时遇非空 cell 报错 |
| `--max-cells` | int | optional | 防爆,默认 50000(隐藏 flag:不在 `--help` 列出,但可正常传入) |
| `--copy-to-range` | string | optional | 复制范围(A1 表示法):把 --range 中 --cells 写入的内容(值/公式/样式,取决于实际传入字段)复制到该区域,公式引用自动平移(如 C2=B2 → C3=B3)。适合先写一行/一块模板再扩展填充整列/整区域(如 --range A1:G1 写模板、--copy-to-range A1:G100 填充 100 行)。支持整行 3:6、整列 C:E、到列尾 D3:D、到行尾 D3:3;支持英文逗号分隔多个目标区域,如 C1:D2,E5:F6 |
### `+cells-set-style`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | 目标范围(A1 格式,如 `A1:B2`) |
| `--background-color` | string | optional | 背景颜色(十六进制,如 `#ffffff`) |
| `--font-color` | string | optional | 字体颜色(十六进制,如 `#000000`) |
| `--font-family` | string | optional | 字体名称(如 `Arial`、`微软雅黑`) |
| `--font-size` | float64 | optional | 字体大小(px,例:10、12、14) |
| `--font-style` | string | optional | 字体样式(可选值:`normal` / `italic`) |
| `--font-weight` | string | optional | 字重(可选值:`normal` / `bold`) |
| `--font-line` | string | optional | 字体线条样式(可选值:`none` / `underline` / `line-through`) |
| `--horizontal-alignment` | string | optional | 水平对齐(可选值:`left` / `center` / `right`) |
| `--vertical-alignment` | string | optional | 垂直对齐(可选值:`top` / `middle` / `bottom`) |
| `--word-wrap` | string | optional | 换行策略(可选值:`overflow` / `auto-wrap` / `word-clip`) |
| `--number-format` | string | optional | 数字格式(例:文本 `@`、数字 `0.00`、货币 `$#,##0.00`、日期 `mm/dd/yyyy`) |
| `--border-styles` | string + File + Stdin(复合 JSON) | optional | 边框配置 JSON:`{ top: {style,weight,color}, bottom: ..., left: ..., right: ... }`;4 方向结构相同。style = 线型(solid\|dashed\|dotted\|double\|none);weight = 粗细(thin\|medium\|thick —— 字符串,不是像素数字);color = 十六进制如 #000000。`{ all: {...} }` 一次设置四边。边框只有这一个 flag:不存在 --border-all / --border-top / --border-color |
### `+cells-set-image`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | 目标单元格(A1 格式,必须单 cell,如 `A1`;起止 cell 须相同) |
| `--image` | string | required | 本地图片路径(支持 PNG / JPEG / JPG / GIF / BMP / JFIF / EXIF / TIFF / BPG / HEIC) |
| `--name` | string | optional | 图片文件名(含扩展名);省略时取 `--image` 的 basename |
### `+dropdown-set`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--range` | string | required | 目标范围(A1 格式,如 `A2:A100`) |
| `--options` | string + File + Stdin(复合 JSON) | xor | 下拉选项 JSON 数组,例如 `["opt1","opt2"]`。服务端不限制选项数量,也不限制单个选项长度;含逗号的选项可以接受(写入时会自动转义)。大量选项建议改用 `--source-range`。 |
| `--colors` | string + File + Stdin(简单 JSON) | optional | 下拉胶囊背景色,RGB hex 数组。新建时默认不要传:省略即使用内置 10 色色板;仅在用户明确要求或选项有清晰语义配色时传。胶囊文字默认黑色,应选浅色、低饱和度背景。长度可短不可长——超长 Validate 拦截(`--colors length (N) must not exceed dropdown source size (M)`),未指定项按内置色板循环补色。单独传即生效;`--highlight=false` 时被忽略。 |
| `--multiple` | bool | optional | 启用多选;默认 `false`。本 flag 只设置验证规则,不会写入选中值;后续用 `+cells-set` 写值时必须传 `multiple_values` 数组,不要传逗号拼接的 `value` |
| `--highlight` | bool | optional | 下拉胶囊背景色高亮开关。**不传 = 开**(按内置 10 色色板循环上色);`--highlight=false` 关闭得到纯白下拉。配色用 `--colors` 覆盖。 |
| `--source-range` | string | xor | listFromRange 模式的下拉源 range,A1 表示法 + sheet 前缀(如 `'Sheet1'!T1:T3`)。映射到 server `data_validation.range`,搭配 server `data_validation.type='listFromRange'` 自动生效。跟 `--options` 二选一:传 `--options` 走 inline 列表(type=list),传本 flag 走 range 引用(type=listFromRange)。`--colors` 长度规则不变(≤ 源 range 单元格数),`--highlight` / `--multiple` 行为相同。当 `--highlight` 开启且 source 覆盖单元格数超过 2000 时,服务端会将该下拉判为 option-error(这是不支持的组合);CLI 会在返回结果的 `data.warnings` 中给出 warning。如需取消,传 `--highlight=false`。 |
### `+csv-put`
_公共四件套 · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--start-cell` | string | required | 目标区域起点 A1(如 `A1`、`B5`,不带 sheet 前缀;用 `--sheet-id` / `--sheet-name` 指定 sheet);必须是单个单元格,不接受范围写法;终点按 CSV 实际行列数自动推断 |
| `--csv` | string + File + Stdin(非 JSON 文本) | required | RFC 4180 CSV 文本;可写值或公式(以 = 开头的单元格按公式计算);不带样式 / 批注 / 图片,需要这些用 +cells-set。 |
| `--allow-overwrite` | bool | optional | 允许覆盖(默认 true);设为 false 时若目标非空报错 |
| `--range` | string | optional | --start-cell 的别名(与 +csv-get / +cells-set 一致,用 --range 定位);传区间(如 A1:H17)时自动取其左上角单元格(隐藏 flag:不在 `--help` 列出,但可正常传入) |
### `+table-put`
_公共:URL/token(无 sheet 定位) · 系统:`--dry-run`_
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--sheets` | string + File + Stdin(复合 JSON) | required | Typed 表格协议(pandas-DataFrame-shaped)JSON:顶层 `{"sheets":[...]}`,每个数组项是一张子表 `{name, start_cell?, mode?, header?, allow_overwrite?, columns:["colA","colB",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` —— `name` 与外层 `sheets` 数组都不可省。Agents 用 `scripts/lark_sheets_df.py` 的 `df_to_sheet(df, name)` 一行把 DataFrame 转成一项(多子表就 list 拼起来再包 `{"sheets":[...]}`)。`dtypes` 值是 pandas dtype 字符串(`int64`、`float64`、`Int64`、`bool`、`boolean`、`datetime64[ns]`、`object`、...),CLI 端映射成内部 string/number/date/bool —— 省略 `dtypes` 时该列按文本写入(适合原始 CSV-shaped 数据)。`formats[col]` 是 Excel number_format 字符串(如 `#,##0.00`、`0.0%`、`yyyy-mm`);缺省时 date 列用 `yyyy-mm-dd`,string 列用文本格式 `@`。 |
| `--styles` | string + File + Stdin(复合 JSON) | optional | 类型保真写入后再应用的视觉处理操作 JSON:顶层 `{styles:[...]}`,每项对应一个被写入的子表、含 `name`,并至少给 `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges` 之一。`cell_styles` 用 A1 单元格 range + 扁平样式字段(字段同 +cells-set-style,含 number_format / 颜色 / 对齐 / border_styles);row/col sizes 用行/列范围 + type/size;merges 用单元格 range + 可选 merge_type。styles 数组的长度/顺序/name 必须与被写入的子表对应(与 --sheets.sheets 一一对应)。完整 cell_styles 字段结构跑 `+table-put --print-schema --flag-name styles`。 |
## Schemas
> 复合 JSON flag 字段速查(只列顶层 + 一层嵌套)。深层结构看下方 `## Examples`,或用 `--print-schema` 读完整 JSON Schema(用法见 SKILL.md「公共 flag 速查」与「Agent 使用提示」)。
### `+cells-set` `--cells`
_【维度】行列数必须与 range 完全一致:'A1:C2'→[[_,_,_],[_,_,_]](2行×3列),'B5:B7'→[[_],[_],[_]](3行×1列),'A1'→[[_]](1×1)_
**二维数组项**(类型 object):
- `value` (oneOf?) — 静态单元格值(文本、数字、布尔)
- `formula` (string?) — 以 '=' 开头的单元格公式(例如:'=SUM(A1:A10)')
- `note` (string?) — 单元格批注/备注
- `cell_styles` (object?) — 单元格样式属性,包括字体、颜色、对齐方式和数字格式 { font_color?: string, font_family?: string, font_size?: number, font_weight?: enum, font_style?: enum, …共 11 项 }
- `border_styles` (object?) — 单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top) { top?: object, bottom?: object, left?: object, right?: object }
- `rich_text` (array<object>?) — 富文本内容 each: { type: enum, text: string, style?: object, link?: string, mention_token?: string, …共 17 项 }
- `multiple_values` (array<object>?) — 多值内容,用于支持多选的列表验证单元格 each: { value: oneOf, format?: string }
- `data_validation` (object?) — 数据验证配置 { type: enum, items?: array<string>, range?: string, operator?: enum, values?: array<oneOf>, …共 9 项 }
### `+cells-set` `--writes`
_多区域写入项数组(最多 100 项),整批单次批量提交(fail-fast,失败后先回读再补发);支持跨 sheet_
**数组项**(类型 object):
- `sheet_id` (string?) — 目标子表 reference_id;与 sheet_name 二选一,必须写在每一项里(不认顶层 sheet 定位)
- `sheet_name` (string?) — 目标子表名;与 sheet_id 二选一,必须写在每一项里
- `range` (string) — A1 矩形范围,行列维度必须与 cells 严格一致(同 --range)
- `cells` (array) — 二维单元格数组,结构同 --cells(value / formula / cell_styles / border_styles 等,见 set_cell_…
### `+cells-set-style` `--border-styles`
_单元格边框配置,含 top/bottom/left/right 四个方向,每个方向的结构相同(见 top)_
**顶层字段**:
- `top` (object?) { style?: enum, weight?: enum, color?: string }
- `bottom` (object?) { style?: enum, weight?: enum, color?: string }
- `left` (object?) { style?: enum, weight?: enum, color?: string }
- `right` (object?) { style?: enum, weight?: enum, color?: string }
### `+dropdown-set` `--options`
_列表选项_
**数组项**(类型 string):
- 标量:string
### `+table-put` `--sheets`
_一个或多个子表的 typed 数据,每个数组元素写入一张子表;支持多 DataFrame → 多子表一次写入_
**数组项**(类型 object):
- `name` (string) — 目标子表名
- `start_cell` (string?) — 写入起点单元格(A1 记法,如 "B2"),默认 "A1"
- `mode` (enum?) — overwrite(默认):从 start_cell 起写「表头 + 数据」块;append:把数据追加到子表已有数据下方(默认不重复表头) [overwrite / append]
- `header` (boolean?) — 是否写一行列名表头
- `allow_overwrite` (boolean?) — 为 false 时,若写入会落在非空单元格则拒写以保护原数据(返回 partial_success)
- `columns` (array<string>) — 列名字符串数组,顺序与 `data` 中每行取值一一对应
- `data` (array<array<string|number|boolean|null>>) — 数据行;每行是一个数组,长度必须等于 `columns` 数
- `dtypes` (object?) — 可选
- `formats` (object?) — 可选
### `+table-put` `--styles`
**数组项**(类型 object):
- `cell_merges` (array<object>?) — 单元格合并操作数组;range 使用 A1 单元格范围,merge_type 默认 all each: { merge_type?: enum, range: string }
- `cell_styles` (array<object>?) — 单元格样式操作数组;每项用 A1 单元格 range 指定范围,字段名与 +cells-set-style 对齐 each: { background_color?: string, border?: object, border_styles?: object, font_color?: string, font_family?: string, …共 14 项 }
- `col_sizes` (array<object>?) — 列宽操作数组;range 使用列范围如 A:C,给 size(px)即像素列宽(type 可省略);type 为 standard 时不带 size each: { range: string, size?: number, type?: enum }
- `freeze` (object?) — 冻结行列:rows = 冻结前 N 行,cols = 冻结前 N 列(0 或省略 = 该维度不冻结) { cols?: integer, rows?: integer }
- `name` (string) — 子表名
- `row_sizes` (array<object>?) — 行高操作数组;range 使用行范围如 1:3,给 size(px)即像素行高(type 可省略);type 为 standard/auto 时不带 size each: { range: string, size?: number, type?: enum }
## Examples
公共四件套:所有 shortcut 顶部排列 `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`(XOR)。
### `+cells-set` 的拆分与转介绍
"工具选择"段已讲清纯值(`+csv-put`)vs 富写入(`+cells-set`)。下表补 CLI 侧的 `+cells-set` **兄弟拆分**,以及不属于本 reference 的**跨 reference 转介绍**——避免 agent 用 `+cells-set` 硬扛所有写入场景。
| 写入场景 | 用这个 | 不要用 |
|---------|--------|--------|
| 只改**已有 cell 的样式**,不动 value/formula | `+cells-set-style` | `+cells-set`(会触发不必要的值写入) |
| 把**单张图片嵌入**到某个 cell | `+cells-set-image` | `+cells-set`(参数更繁琐) |
| **插行/列 + 写入** 这种多步组合,且要一次交付 | `+batch-update`(见 lark-sheets-batch-update) | 多次独立 `+cells-set`(插入会扰动后续调用的 range) |
| 在**多个不连续 range** 上应用同一组样式 | `+styles-put`(cell_styles 多项即多区域,见 lark-sheets-styles-put) | 多次 `+cells-set-style`(多次往返) |
### `+cells-set`
示例:
```bash
# 纯值(数组形态);默认即覆盖非空 cell,无需显式传 --allow-overwrite
lark-cli sheets +cells-set --url "https://example.feishu.cn/sheets/shtXXX" \
--sheet-name "Sheet1" --range "A1:B2" \
--cells '[[{"value":"name"},{"value":"score"}],[{"value":"alice"},{"value":95}]]'
# 富 cell(公式 + 样式,cells 是二维矩阵每元素一个 cell schema)
lark-cli sheets +cells-set --spreadsheet-token shtXXX --sheet-id "$SID" \
--range "C2:C10" --cells @rich-cells.json
```
`--cells` 富格式见 `## Schemas` 段(cells 元素含 value / formula / cell_styles / border_styles / data_validation / multiple_values / note / rich_text);值 / 公式 / 样式 / 批注 / 嵌入图片可同一次写入混合提交。
> 中间想跳过的 cell 用空对象 `{}` 占位(底层语义为"保留原值不变")。例:`--range A1:A5 --cells '[[{"value":1}],[{}],[{}],[{}],[{"value":5}]]'` 只写 A1 和 A5。
>
> 跨多个不连续区域散点写入(如 `D2` + `F7` + `J15`)超出单次 `--range` + `--cells` 的范围,但**仍在 `+cells-set` 之内**:用本命令的 `--writes` 复数形态一次批量交付(每项 `{sheet_name, range, cells}`,可跨 sheet,见上方「多个不连续区域写入」)。**不要为此拼 `+batch-update` 的 `--operations`**——那是给跨类型、有顺序依赖的操作链用的。
### `+cells-set-style`
只改样式,不动 value / formula。10 个 cell_styles 字段拍平为独立 flag,边框走 `--border-styles` JSON。
```bash
# 加粗 + 黄底
lark-cli sheets +cells-set-style --url "..." --sheet-name "Sheet1" \
--range "A1:B2" --font-weight bold --background-color "#FFFF00"
# 配套边框
lark-cli sheets +cells-set-style --url "..." --sheet-id "$SID" \
--range "A1:D10" --font-size 12 --horizontal-alignment center \
--border-styles '{"top":{"style":"solid","color":"#000","weight":"thin"},"bottom":{"style":"solid","color":"#000","weight":"thin"}}'
```
### `+cells-set-image`
把单张图片嵌入 cell(必须单 cell 范围):
```bash
lark-cli sheets +cells-set-image --url "..." --sheet-name "Sheet1" \
--range "A1" --image ./logo.png
```
### `+csv-put`
示例:
```bash
# 内联 CSV
lark-cli sheets +csv-put --url "https://example.feishu.cn/sheets/shtXXX" \
--sheet-name "Sheet1" --start-cell "A1" \
--csv $'name,score\nalice,95\nbob,87'
# 从文件
lark-cli sheets +csv-put --spreadsheet-token shtXXX --sheet-id "$SID" \
--start-cell "A1" --csv @data.csv
```
> `+csv-put` 比 `+cells-set` 短得多——批量灌值或公式时优先用它。需要样式/批注/图片才换 `+cells-set`。
>
> ✅ `=` 开头的单元格会被当作公式计算(不是字面量文本):
>
> ```bash
> lark-cli sheets +csv-put --url "..." --sheet-name "Sheet1" \
> --start-cell "A1" \
> --csv $'name,score\nalice,=SUM(B2:B10)'
> # ↑ B2 写入公式 =SUM(B2:B10),读回 formula 保留、value 为计算结果。
> # 反过来:无法用 +csv-put 写「= 开头的字面量文本」(会被当公式);样式/批注/图片仍用 +cells-set。
> ```
>
> ⚠️ **公式内部含逗号 / 引号建议 RFC 4180 转义**:CSV 用逗号分隔字段,公式里的逗号(如 `COUNTIF(D5:D22,"及格")` 的参数分隔逗号)会被解析器当成字段分隔符,把一格拆成多格、整块二维结构压扁错位。规则:**含逗号的字段整格用双引号包裹,字段内部的引号再翻倍**:
>
> ```bash
> # 从 G4 写一个 2 列 3 行的统计块;=COUNTIF 含逗号 + 内部引号,建议转义
> lark-cli sheets +csv-put --url "..." --sheet-name "Sheet1" \
> --start-cell "G4" \
> --csv $'统计项,结果\n成绩总和,=SUM(C5:C22)\n及格人数,"=COUNTIF(D5:D22,""及格"")"'
> # ↑ "=COUNTIF(D5:D22,""及格"")":外层双引号包裹整格,内部 "及格" 的引号翻倍成 ""及格""。
> # 裸写 =COUNTIF(D5:D22,"及格") 会被 CSV 按逗号拆成两格、写入区域从 G4:H6 错位成 G4:K4。
> ```
>
> 💡 **含逗号 / 引号 / 换行的公式优先用 `+cells-set`(JSON 二维数组)写入**——`cells[r][c].formula` 直接放公式串,没有 CSV 转义负担。`+table-put` 的 typed payload 可带 `name/start_cell/mode/header/allow_overwrite/columns/data/dtypes/formats`,但没有公式字段;公式写入用 `+cells-set` 或转义后的 `+csv-put`:
>
> ```bash
> # 同样的统计块,结构化写入无需任何转义
> lark-cli sheets +cells-set --url "..." --sheet-name "Sheet1" --range "G4:H6" \
> --cells '[[{"value":"统计项"},{"value":"结果"}],[{"value":"成绩总和"},{"formula":"=SUM(C5:C22)"}],[{"value":"及格人数"},{"formula":"=COUNTIF(D5:D22,\"及格\")"}]]'
> ```
> **定位 + 写入边界(关键,避免误覆盖)**:
> - 定位用 `--start-cell`(锚点 = 左上角单元格);也接受 `--range` 别名(与 `+csv-get` / `+cells-set` 一致,传区间会自动取左上角)。
> - ⚠️ `--start-cell` / `--range` **只定左上角、不限制写入大小**:CSV 从锚点按自身行列数 auto-expand 铺开。给一个"小 range"**不会**截断数据——超出部分照写,且默认覆盖。`+cells-set` 只有裸 `--range A1` 是这个语义;`--range` 一旦写成矩形就是边界,`--cells` 超出它会被拒绝。
> - dry-run 与成功响应都回显 `writes_range`(实际落区,如 `B2:D4`):**写前先 `--dry-run` 看一眼落区**,确认不会盖到相邻数据。
> - 要保护非空 cell:`--allow-overwrite=false`(落区内出现非空 cell 即报错)。
### `+table-put`(DataFrame → 飞书,类型保真写入)
把结构化数据(DataFrame、list of dict、Counter)类型保真写入**已有**表(写入语义同 `+cells-set`)。协议形状**对齐 pandas `to_json(orient="split")`**:`columns:[列名]` + `data:[[行...]]`,可选 `dtypes:{列名:pandas_dtype}` 决定每列类型(number 保精度、date 落真日期),可选 `formats:{列名:number_format}` 覆盖显示格式(千分位 / 百分比 / 自定义日期)。dtypes 缺失时整张表按 string 写入(带 `@` 文本格式,邮编 / 订单号等含前导零的 id 保真)。
只写入**已有**表(`--url` / `--spreadsheet-token` 二选一必填),不新建工作簿——**要新建表格直接用 `+workbook-create --sheets`**(同协议、一步建表 + 类型保真写入,详见 workbook reference)。读回用镜像命令 `+table-get`(见 read-data reference),输出与 `--sheets` 同构、可 round-trip。
```bash
# sheet 按 name 匹配、缺则新建;多 DataFrame 经 stdin 一次写多 sheet
python export.py | lark-cli sheets +table-put --url "<表URL>" --sheets -
# 某 sheet 带 "mode":"append" 追加到已有数据末尾、默认不重复表头
lark-cli sheets +table-put --spreadsheet-token "<token>" --sheets @payload.json
# --sheets 与 --styles 都是大 JSON 时:stdin 每次调用只能给一个 flag,一个走 -、另一个走 @cwd 相对路径
lark-cli sheets +table-put --url "<表URL>" --sheets - --styles @styles.json < sheets.json
```
每个 sheet 还可带 `"allow_overwrite": false`(遇非空拒写、保护原数据)、`"header": false`(只写数据不写表头)。完整字段跑 `+table-put --print-schema --flag-name sheets`。
#### DataFrame → 协议(用 `df_to_sheet` helper)
pandas 的 `df.to_json(orient="split", date_format="iso")` 一步完成所有清洗(NaN→null、Timestamp→ISO 字符串、numpy 标量→原生数字),把 dtypes 拼上即可。本 skill 把这段 5 行 helper 打包成可 import 的 [`scripts/lark_sheets_df.py`](../scripts/lark_sheets_df.py)(含 `df_to_sheet` 和 `sheet_to_df`,写入 / 读回成对):
```python
import sys; sys.path.insert(0, "scripts") # cwd 不在 skill 根时改成 scripts/ 的实际路径
from lark_sheets_df import df_to_sheet
# 单 sheet(显式 format 覆盖默认显示)
payload = {"sheets": [df_to_sheet(df, "销售", {"营收": "#,##0.00", "毛利率": "0.0%"})]}
# 多 sheet——helper 让每个 sheet 一行,不再重复 boilerplate
payload = {"sheets": [df_to_sheet(df1, "销售"),
df_to_sheet(df2, "成本"),
df_to_sheet(df3, "利润")]}
```
> **CSV-shaped 全文本数据**(不需要类型保真、含前导零的 id 也要保留)省掉 dtypes 即可,inline 一行写完,不必走 helper(注意保留 `date_format="iso"`,否则 datetime 列会被序列化成 epoch 毫秒数字,CLI 拒绝):
> ```python
> payload = {"sheets": [{"name": "原始",
> **json.loads(df.to_json(orient="split", date_format="iso"))}]}
> ```
> **别把 `to_json + json.loads` 换成 `df.to_dict(orient="split")`**:会留 `numpy.int64` 让 `json.dumps` 后续报 "not serializable"——这一步是清洗的关键。
> **列名必须是字符串**:整数列名(如未指定表头时 pandas 默认的 0/1/2)会以 JSON 数字进入 `columns` 被 CLI 拒收;inline 写法要先 `df.columns = df.columns.map(str)`。`df_to_sheet` 已自动完成这一步。
不用 pandas 也行——typed 协议就是纯 JSON。手写场景:
```python
# Counter / dict / 手拼数据:直接写 columns + data,按需加 dtypes/formats
payload = {"sheets": [{
"name": "渠道",
"columns": ["channel", "count", "rate"],
"data": [["app", 1240, 0.62], ["web", 760, 0.38]],
"dtypes": {"count": "int64", "rate": "float64"},
"formats": {"rate": "0.0%"},
}]}
```
> **dtype 速查**:`int64`/`float64`(数值)、`Int64`(含空值的整数,nullable)、`bool`/`boolean`、`datetime64[ns]`(date,默认 `yyyy-mm-dd`)、`object`(string)。pandas dtype 字符串原样塞进 dtypes 即可,CLI 端按前缀匹配(`int*`/`uint*`/`Int*`/`float*` → number 等)。未识别 dtype 兜底为 string。
#### `--styles`(写入时同时套样式)
`--styles` 在 typed 写入后顺带应用视觉处理,省掉一次 `+cells-set-style` 往返。协议与 `+workbook-create --styles` **完全同构**(详见 workbook reference):顶层 `{styles:[...]}`,数组每项对应一个被写入的子表、含 `name`,并按能力拆成四类可选数组——`cell_styles`(A1 单元格 range + 扁平样式字段,含 `number_format` / 颜色 / 对齐 / `border_styles`,随内容在同一次写入里一并应用)、`cell_merges`、`row_sizes`、`col_sizes`。styles 数组的长度 / 顺序 / name 必须与被写入的子表对应(与 `--sheets.sheets` 一一对应)。
```bash
lark-cli sheets +table-put --url "<表URL>" \
--sheets '{"sheets":[{"name":"明细","columns":["日期","金额"],"dtypes":{"日期":"datetime64[ns]","金额":"float64"},"formats":{"金额":"#,##0.00"},"data":[["2024-01-15",1234.5]]}]}' \
--styles '{"styles":[{"name":"明细",
"cell_styles":[{"range":"A1:B1","font_weight":"bold","background_color":"#f5f5f5","horizontal_alignment":"center"}],
"cell_merges":[{"range":"A1:B1"}],
"col_sizes":[{"range":"A:B","type":"pixel","size":120}]}]}'
```
完整字段跑 `+table-put --print-schema --flag-name styles`。
### Validate / DryRun / Execute 约束
- `Validate`:XOR 公共四件套;`+cells-set` 的 `--cells` 必须能解析为各行等宽的 JSON 二维矩阵(落区按其行列数推断);`+cells-set-style` 的样式 flag 至少一个非空(或带 `--border-styles`);`+cells-set-image` 的 `--range` 必须是单 cell(起止 cell 相同);`+csv-put` 的 `--csv` 必须能按 RFC 4180 解析;`+table-put` 给了 `--styles` 则按子表名 / 顺序 / 数量与 `--sheets.sheets` 对齐校验;防爆参数上限校验。
- `DryRun`:输出目标 range + 推断尺寸 + 是否覆盖非空 cell 警告,零网络副作用。
- `Execute`:写后必须按写入范围回读首、中、末及用户点名项;公式同时读取 formula,并按公式完成流程验证。
scripts/lark_chart_quality_check.py›
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
"""Check Lark Sheet chart quality, placement, and numeric source-data issues.
The single required argument is a spreadsheet URL or spreadsheet token. By
default every worksheet is checked; pass --worksheet-id to restrict the check
to one worksheet reference_id.
Numeric source checks sample at most 50 data points per series and request at
most 2000 source cells per chart across the style and typed-value reads,
including headers and gaps between series.
Sampled zero/constant values do not establish a whole-series issue.
Exit codes:
0: check completed and no issue was found
1: the check could not be completed (CLI/read/response error)
2: check completed and at least one chart-quality issue was found
"""
from __future__ import annotations
import argparse
import json
import re
from typing import Any, Dict, Tuple
from lark_sheet_read_cli import (
LarkCliError,
emit_error,
envelope_data,
resolve_target_sheets,
run_sheets,
sheet_identifier,
sheet_title,
)
from lark_chart_size_rules import MAX_ASPECT_RATIO, MAX_CHART_WIDTH, minimum_chart_size
ACTION = "chart_quality_check"
DEFAULT_COLUMN_WIDTH = 105.0
DEFAULT_ROW_HEIGHT = 27.0
MAX_CELL_READ_SIZE = 2_000
MAX_SOURCE_SAMPLE_POINTS = 50
# 类型别名是运行期求值的,__future__ annotations 豁免不了它们;
# 内置泛型下标要 3.9,CI 的解释器是 3.7,只能走 typing 里的等价物。
CellBounds = Tuple[int, int, int, int]
CellCache = Dict[Tuple[str, str, str, bool], Dict[str, Any]]
SeriesProfile = Dict[str, Any]
def _parse_a1_bounds(cell_range: str) -> CellBounds:
value = str(cell_range).rsplit("!", 1)[-1].replace("$", "")
match = re.fullmatch(r"([A-Za-z]+)(\d+)(?::([A-Za-z]+)(\d+))?", value)
if not match:
raise ValueError(f"Invalid A1 range: {cell_range!r}")
start_column = column_to_index(match.group(1))
start_row = int(match.group(2))
end_column = column_to_index(match.group(3) or match.group(1))
end_row = int(match.group(4) or match.group(2))
if end_row < start_row or end_column < start_column:
raise ValueError(f"Invalid A1 range: {cell_range!r}")
return start_row, end_row, start_column, end_column
def _format_a1_bounds(bounds: CellBounds) -> str:
start_row, end_row, start_column, end_column = bounds
return (
f"{index_to_column(start_column)}{start_row}:"
f"{index_to_column(end_column)}{end_row}"
)
def _bounds_area(bounds: CellBounds) -> int:
start_row, end_row, start_column, end_column = bounds
return (end_row - start_row + 1) * (end_column - start_column + 1)
def _merge_bounds(first: CellBounds, second: CellBounds) -> CellBounds:
return (
min(first[0], second[0]),
max(first[1], second[1]),
min(first[2], second[2]),
max(first[3], second[3]),
)
def _cluster_cell_reads(
items: list[tuple[dict[str, Any], CellBounds]],
) -> list[dict[str, Any]]:
clusters: list[dict[str, Any]] = []
for rectangle, bounds in sorted(items, key=lambda item: (item[1][0], item[1][2])):
if clusters:
merged = _merge_bounds(clusters[-1]["bounds"], bounds)
if _bounds_area(merged) <= MAX_CELL_READ_SIZE:
clusters[-1]["bounds"] = merged
clusters[-1]["members"].append((rectangle, bounds))
continue
clusters.append({"bounds": bounds, "members": [(rectangle, bounds)]})
return clusters
def column_to_index(column: str) -> int:
value = 0
text = str(column).strip().upper()
if not text or not text.isalpha():
raise ValueError(f"Invalid column: {column!r}")
for char in text:
value = value * 26 + ord(char) - ord("A") + 1
return value - 1
def index_to_column(index: int) -> str:
if index < 0:
raise ValueError(f"Invalid column index: {index}")
chars: list[str] = []
value = index + 1
while value:
value, remainder = divmod(value - 1, 26)
chars.append(chr(ord("A") + remainder))
return "".join(reversed(chars))
def _span_bounds(span: str, *, columns: bool) -> tuple[int, int]:
start, separator, end = str(span).partition(":")
end = end if separator else start
if columns:
return column_to_index(start), column_to_index(end)
return int(start) - 1, int(end) - 1
def _size_edges(
groups: Any,
*,
count: int,
span_key: str,
size_key: str,
columns: bool,
default_size: float,
) -> tuple[list[float], bool]:
sizes: list[float | None] = [None] * count
if isinstance(groups, list):
for group in groups:
if not isinstance(group, dict) or group.get(span_key) is None:
continue
start, end = _span_bounds(str(group[span_key]), columns=columns)
size = float(group.get(size_key, default_size))
for index in range(max(0, start), min(count - 1, end) + 1):
sizes[index] = max(0.0, size)
used_default = any(size is None for size in sizes)
resolved = [default_size if size is None else size for size in sizes]
edges = [0.0]
for size in resolved:
edges.append(edges[-1] + size)
return edges, used_default
def build_layout(
structure: dict[str, Any], row_count: int, column_count: int
) -> tuple[list[float], list[float], list[str]]:
row_groups = structure.get("row_heights")
column_groups = structure.get("col_widths", structure.get("column_widths"))
row_edges, row_defaulted = _size_edges(
row_groups,
count=row_count,
span_key="rows",
size_key="height",
columns=False,
default_size=DEFAULT_ROW_HEIGHT,
)
column_edges, column_defaulted = _size_edges(
column_groups,
count=column_count,
span_key="cols",
size_key="width",
columns=True,
default_size=DEFAULT_COLUMN_WIDTH,
)
warnings: list[str] = []
if row_defaulted:
warnings.append("部分行缺少高度信息,按 27 px 估算")
if column_defaulted:
warnings.append("部分列缺少宽度信息,按 105 px 估算")
return row_edges, column_edges, warnings
def _first_dict(value: Any) -> dict[str, Any] | None:
if isinstance(value, dict):
return value
if isinstance(value, list):
return next((item for item in value if isinstance(item, dict)), None)
return None
def extract_sheet_structure(data: dict[str, Any]) -> dict[str, Any]:
sheet = _first_dict(data.get("sheets")) or _first_dict(data.get("sheet"))
return sheet or data
def extract_charts(data: dict[str, Any], sheet_id: str, title: str) -> list[dict[str, Any]]:
sheets = data.get("sheets")
if isinstance(sheets, list):
for sheet in sheets:
if not isinstance(sheet, dict):
continue
if sheet_identifier(sheet) == sheet_id or sheet_title(sheet) == title:
charts = sheet.get("charts")
return [chart for chart in charts if isinstance(chart, dict)] if isinstance(charts, list) else []
charts = data.get("charts")
return [chart for chart in charts if isinstance(chart, dict)] if isinstance(charts, list) else []
def chart_rectangle(
chart: dict[str, Any], row_edges: list[float], column_edges: list[float]
) -> dict[str, Any]:
details = chart.get("details") if isinstance(chart.get("details"), dict) else chart
position = details.get("position") if isinstance(details.get("position"), dict) else {}
offset = details.get("offset") if isinstance(details.get("offset"), dict) else {}
size = details.get("size") if isinstance(details.get("size"), dict) else {}
row = int(position["row"])
column = column_to_index(str(position["col"]))
if row < 0 or column < 0 or row >= len(row_edges) - 1 or column >= len(column_edges) - 1:
raise ValueError(f"anchor outside sheet: {position!r}")
width = float(size["width"])
height = float(size["height"])
if width <= 0 or height <= 0:
raise ValueError(f"invalid chart size: {size!r}")
left = column_edges[column] + float(offset.get("col_offset", 0) or 0)
top = row_edges[row] + float(offset.get("row_offset", 0) or 0)
return {
"chart_id": str(chart.get("chart_id") or chart.get("id") or ""),
"anchor_cell": f"{index_to_column(column)}{row + 1}",
"left": left,
"top": top,
"right": left + width,
"bottom": top + height,
"width": width,
"height": height,
}
def intersection(first: dict[str, Any], second: dict[str, Any]) -> dict[str, float] | None:
left = max(float(first["left"]), float(second["left"]))
top = max(float(first["top"]), float(second["top"]))
right = min(float(first["right"]), float(second["right"]))
bottom = min(float(first["bottom"]), float(second["bottom"]))
if right <= left or bottom <= top:
return None
return {
"width": round(right - left, 2),
"height": round(bottom - top, 2),
"area": round((right - left) * (bottom - top), 2),
}
def chart_context(rectangle: dict[str, Any]) -> dict[str, Any]:
return {
"chart_id": rectangle["chart_id"],
"anchor_cell": rectangle["anchor_cell"],
"rectangle_px": {
"left": round(rectangle["left"], 2),
"top": round(rectangle["top"], 2),
"right": round(rectangle["right"], 2),
"bottom": round(rectangle["bottom"], 2),
"width": round(rectangle["width"], 2),
"height": round(rectangle["height"], 2),
},
}
def _covered_indexes(edges: list[float], start: float, end: float) -> list[int]:
return [
index
for index in range(len(edges) - 1)
if edges[index + 1] > start and edges[index] < end
]
def rectangle_cell_range(
rectangle: dict[str, Any], row_edges: list[float], column_edges: list[float]
) -> str | None:
rows = _covered_indexes(row_edges, max(0.0, rectangle["top"]), rectangle["bottom"])
columns = _covered_indexes(column_edges, max(0.0, rectangle["left"]), rectangle["right"])
if not rows or not columns:
return None
return f"{index_to_column(columns[0])}{rows[0] + 1}:{index_to_column(columns[-1])}{rows[-1] + 1}"
def _has_content(cell: Any) -> bool:
if not isinstance(cell, dict):
return False
for key in ("value", "formula", "note"):
value = cell.get(key)
if value not in (None, ""):
return True
return bool(cell.get("rich_text") or cell.get("multiple_values"))
def _iter_cells(data: dict[str, Any]):
ranges = data.get("ranges")
if not isinstance(ranges, list):
return
for result_range in ranges:
if not isinstance(result_range, dict):
continue
cells = result_range.get("cells")
rows = result_range.get("row_indices")
columns = result_range.get("col_indices")
if not isinstance(cells, list):
continue
for row_offset, row in enumerate(cells):
if not isinstance(row, list):
continue
row_number = int(rows[row_offset]) if isinstance(rows, list) and row_offset < len(rows) else row_offset + 1
for column_offset, cell in enumerate(row):
column = columns[column_offset] if isinstance(columns, list) and column_offset < len(columns) else index_to_column(column_offset)
yield row_number, column_to_index(str(column)), cell
def non_empty_cells(
data: dict[str, Any], sample_limit: int, bounds: CellBounds | None = None
) -> tuple[int, list[str], bool]:
count = 0
samples: list[str] = []
truncated = bool(data.get("has_more"))
ranges = data.get("ranges")
if not isinstance(ranges, list):
return 0, [], truncated
for result_range in ranges:
if not isinstance(result_range, dict):
continue
truncated = truncated or bool(result_range.get("truncated"))
for row_number, column_index, cell in _iter_cells(data):
if bounds and not (
bounds[0] <= row_number <= bounds[1]
and bounds[2] <= column_index <= bounds[3]
):
continue
if not _has_content(cell):
continue
count += 1
if len(samples) < sample_limit:
samples.append(f"{index_to_column(column_index)}{row_number}")
return count, samples, truncated
def _read_cells(
cache: CellCache,
locator: dict[str, str],
*,
sheet_id: str | None,
sheet_name: str | None,
cell_range: str,
include: str,
skip_hidden: bool = False,
timeout: int,
) -> dict[str, Any]:
selector = f"id:{sheet_id}" if sheet_id else f"name:{sheet_name}"
key = (selector, cell_range, include, skip_hidden)
if key not in cache:
flags: dict[str, Any] = {"range": cell_range, "include": include}
if skip_hidden:
flags["skip-hidden"] = True
cache[key] = envelope_data(
run_sheets(
"+cells-get",
**locator,
**({"sheet_id": sheet_id} if sheet_id else {"sheet_name": sheet_name}),
flags=flags,
timeout=timeout,
)
)
return cache[key]
def _read_typed_table(
cache: CellCache,
locator: dict[str, str],
*,
sheet_id: str | None,
sheet_name: str | None,
cell_range: str,
timeout: int,
) -> tuple[list[list[Any]], list[str], bool]:
selector = f"id:{sheet_id}" if sheet_id else f"name:{sheet_name}"
key = (selector, cell_range, "typed_table", False)
if key not in cache:
cache[key] = envelope_data(
run_sheets(
"+table-get",
**locator,
**({"sheet_id": sheet_id} if sheet_id else {"sheet_name": sheet_name}),
flags={"range": cell_range, "no-header": True},
timeout=timeout,
)
)
data = cache[key]
sheets = data.get("sheets")
if not isinstance(sheets, list) or len(sheets) != 1 or not isinstance(sheets[0], dict):
return [], [], True
table = sheets[0]
rows = table.get("data")
columns = table.get("columns")
dtypes = table.get("dtypes")
if not isinstance(rows, list) or not isinstance(columns, list) or not isinstance(dtypes, dict):
return [], [], True
def value_kind(dtype: Any) -> str:
value = str(dtype or "").lower()
if value in {"number", "int64", "float64"}:
return "number"
if value in {"bool", "boolean"}:
return "bool"
if value.startswith("datetime"):
return "date"
return "string"
types = [value_kind(dtypes.get(str(column))) for column in columns]
truncated = bool(data.get("truncated")) or bool(table.get("truncated"))
return rows, types, truncated
def _typed_cell(
rows: list[list[Any]],
column_types: list[str],
row_offset: int,
column_offset: int,
visible_offsets: set[tuple[int, int]],
) -> tuple[Any, str]:
if (
row_offset < 0
or row_offset >= len(rows)
or not isinstance(rows[row_offset], list)
or column_offset < 0
or column_offset >= len(rows[row_offset])
):
return None, ""
value = rows[row_offset][column_offset]
if isinstance(value, bool):
return value, "bool"
if isinstance(value, (int, float)):
return value, "number"
kind = column_types[column_offset] if column_offset < len(column_types) else ""
if kind != "string" or not isinstance(value, str) or not _looks_numeric(value):
return value, kind
# table-get infers one dtype per physical column. A hidden cell or another
# row-series can widen that dtype and stringify otherwise numeric cells.
# Treat such cells as unknown instead of reporting a false storage issue.
numeric_string_count = 0
for other_row_offset, row in enumerate(rows):
if not isinstance(row, list) or column_offset >= len(row):
continue
other = row[column_offset]
if other not in (None, "") and (other_row_offset, column_offset) not in visible_offsets:
return value, "unknown"
if other in (None, "") or (
isinstance(other, (int, float)) and not isinstance(other, bool)
):
continue
if isinstance(other, str) and _looks_numeric(other):
numeric_string_count += 1
continue
return value, "unknown"
return value, "string" if numeric_string_count == 1 else "ambiguous_string"
def _numeric_source_format_reason(
value: Any,
raw_type: str,
number_format: Any,
) -> tuple[str, bool]:
if not _looks_numeric(str(value or "")):
return "", False
if str(number_format or "").strip() == "@":
return "numeric_value_uses_text_format", False
if raw_type == "string":
return "numeric_value_stored_as_text", False
return "", raw_type in {"ambiguous_string", "unknown"}
def _chart_snapshot(chart: dict[str, Any]) -> dict[str, Any]:
details = chart.get("details") if isinstance(chart.get("details"), dict) else chart
snapshot = details.get("snapshot")
return snapshot if isinstance(snapshot, dict) else {}
def _chart_type(snapshot: dict[str, Any]) -> str:
plot_area = snapshot.get("plotArea")
plot = plot_area.get("plot") if isinstance(plot_area, dict) else None
return str(plot.get("type") or "").lower() if isinstance(plot, dict) else ""
def _plot(snapshot: dict[str, Any]) -> dict[str, Any]:
plot_area = snapshot.get("plotArea")
plot = plot_area.get("plot") if isinstance(plot_area, dict) else None
return plot if isinstance(plot, dict) else {}
def _static_series_profiles(snapshot: dict[str, Any]) -> list[SeriesProfile]:
data = snapshot.get("data")
dim2 = data.get("dim2") if isinstance(data, dict) else None
fields = dim2.get("fields") if isinstance(dim2, dict) else None
if not isinstance(fields, list):
return []
profiles: list[SeriesProfile] = []
for offset, field in enumerate(fields, start=1):
if not isinstance(field, dict):
continue
values = []
for value in field.get("parsedValues") or []:
numeric = (
float(value)
if isinstance(value, (int, float)) and not isinstance(value, bool)
else _numeric_text_value(value) if isinstance(value, str) else None
)
if numeric is not None:
values.append(numeric)
profiles.append(
{
"dimension_index": offset,
"series_name": str(field.get("name") or f"Series {offset}"),
"point_count": len(field.get("parsedValues") or []),
"numeric_value_count": len(values),
"unique_numeric_values": list(dict.fromkeys(values))[:2],
"source_sheet": "",
"source_range": "",
"series_range": "",
}
)
return profiles
def _labeled_series_indexes(
snapshot: dict[str, Any], profiles: list[SeriesProfile]
) -> set[int]:
plot = _plot(snapshot)
available = {
int(profile["dimension_index"])
for profile in profiles
if profile.get("dimension_index") is not None
}
labeled = set(available) if isinstance(plot.get("labels"), dict) else set()
series = plot.get("series")
if isinstance(series, list):
labeled.update(
int(item["index"])
for item in series
if isinstance(item, dict)
and item.get("index") is not None
and isinstance(item.get("labels"), dict)
)
return labeled & available
def _constant_labeled_series(
chart: dict[str, Any], profiles: list[SeriesProfile]
) -> list[dict[str, Any]]:
chart_id = str(chart.get("chart_id") or chart.get("id") or "")
snapshot = _chart_snapshot(chart)
labeled = _labeled_series_indexes(snapshot, profiles)
return [
{
"chart_id": chart_id,
"dimension_index": profile["dimension_index"],
"series_name": profile["series_name"],
"source_sheet": profile["source_sheet"],
"source_range": profile["source_range"],
"series_range": profile["series_range"],
"reason": "constant_labeled_series",
"data_point_count": profile["point_count"],
"constant_value": profile["unique_numeric_values"][0],
"suggested_fix": "remove_series_labels_or_use_one_sparse_marker",
}
for profile in profiles
if int(profile.get("dimension_index", -1)) in labeled
and profile.get("constant_check_unverifiable") is not True
and int(profile.get("numeric_value_count", 0)) >= 2
and len(profile.get("unique_numeric_values") or []) == 1
]
def _unbound_secondary_axis(chart: dict[str, Any]) -> dict[str, Any] | None:
snapshot = _chart_snapshot(chart)
plot_area = snapshot.get("plotArea")
if not isinstance(plot_area, dict):
return None
plot = plot_area.get("plot")
if not isinstance(plot, dict) or str(plot.get("type") or "").lower() != "combo":
return None
axes = plot_area.get("axes")
has_right_axis = isinstance(axes, list) and any(
isinstance(axis, dict)
and str(axis.get("type") or "").lower() == "y"
and str(axis.get("position") or axis.get("axisPosition") or "").lower() == "right"
for axis in axes
)
series = plot.get("series")
configured_series = (
[item for item in series if isinstance(item, dict)]
if isinstance(series, list)
else []
)
if not has_right_axis or not configured_series:
return None
if str(plot.get("yAxisPosition") or "").lower() == "right" or any(
str(item.get("yAxisPosition") or "").lower() == "right"
for item in configured_series
):
return None
return {
"chart_id": str(chart.get("chart_id") or chart.get("id") or ""),
"reason": "secondary_axis_has_no_bound_series",
"series_indexes": [
int(item["index"])
for item in configured_series
if isinstance(item.get("index"), (int, float))
],
"suggested_fix": "bind_the_intended_combo_series_to_the_right_axis",
}
def _undersized_chart(chart: dict[str, Any]) -> dict[str, Any] | None:
details = chart.get("details") if isinstance(chart.get("details"), dict) else chart
snapshot = _chart_snapshot(chart)
chart_type = _chart_type(snapshot)
size = details.get("size")
if not isinstance(size, dict):
return None
width = size.get("width")
height = size.get("height")
if (
not isinstance(width, (int, float))
or isinstance(width, bool)
or width <= 0
or not isinstance(height, (int, float))
or isinstance(height, bool)
or height <= 0
):
return None
actual = {
"width": float(width),
"height": float(height),
}
minimum = minimum_chart_size(chart_type)
if actual["width"] >= minimum["width"] and actual["height"] >= minimum["height"]:
return None
return {
"chart_id": str(chart.get("chart_id") or chart.get("id") or ""),
"reason": "chart_below_minimum_size",
"chart_type": chart_type,
"actual_size": actual,
"minimum_size": minimum,
"suggested_fix": "run_lark_chart_size_advisor_before_resizing",
}
def _overwide_chart(chart: dict[str, Any]) -> dict[str, Any] | None:
details = chart.get("details") if isinstance(chart.get("details"), dict) else chart
snapshot = _chart_snapshot(chart)
chart_type = _chart_type(snapshot)
size = details.get("size") if isinstance(details.get("size"), dict) else {}
width = size.get("width")
height = size.get("height")
if (
not isinstance(width, (int, float))
or isinstance(width, bool)
or width <= 0
or not isinstance(height, (int, float))
or isinstance(height, bool)
or height <= 0
):
return None
width = float(width)
height = float(height)
minimum = minimum_chart_size(chart_type)
aspect_ratio = width / height if height > 0 else 0
width_exceeded = width > MAX_CHART_WIDTH
aspect_ratio_exceeded = (
width >= minimum["width"]
and height >= minimum["height"]
and aspect_ratio > MAX_ASPECT_RATIO
)
if not width_exceeded and not aspect_ratio_exceeded:
return None
return {
"chart_id": str(chart.get("chart_id") or chart.get("id") or ""),
"reason": "chart_too_wide",
"chart_type": chart_type,
"actual_size": {"width": width, "height": height},
"actual_aspect_ratio": round(aspect_ratio, 2),
"maximum_width": MAX_CHART_WIDTH,
"maximum_aspect_ratio": MAX_ASPECT_RATIO,
"suggested_fix": "use_size_advisor_create_flags_or_change_chart_structure",
}
def _numeric_dimensions(snapshot: dict[str, Any]) -> list[tuple[int, str]]:
data = snapshot.get("data")
if not isinstance(data, dict):
return []
dim2 = data.get("dim2")
series = dim2.get("series") if isinstance(dim2, dict) else None
chart_type = _chart_type(snapshot)
dimensions: list[tuple[int, str]] = []
if isinstance(series, list):
for offset, serie in enumerate(series):
if not isinstance(serie, dict) or serie.get("index") is None:
continue
if str(serie.get("aggregateType") or "").lower() == "counta":
continue
role = str(serie.get("role") or "").lower()
if chart_type == "bubble":
role = role or ("x", "y", "group", "size")[min(offset, 3)]
if role not in {"x", "y", "size"}:
continue
dimensions.append((int(serie["index"]), role or "value"))
plot_area = snapshot.get("plotArea")
axes = plot_area.get("axes") if isinstance(plot_area, dict) else None
continuous_x = chart_type == "scatter"
if isinstance(axes, list):
for axis in axes:
if not isinstance(axis, dict) or str(axis.get("type") or "").lower() != "x":
continue
position = axis.get("position")
if (
(position is None or str(position).lower() in {"bottom", "x"})
and str(axis.get("valueType") or "").lower() == "linear"
):
continuous_x = True
break
dim1 = data.get("dim1")
serie = dim1.get("serie") if isinstance(dim1, dict) else None
if continuous_x and chart_type != "bubble" and isinstance(serie, dict) and serie.get("index") is not None:
dimensions.append((int(serie["index"]), "x"))
return list(dict.fromkeys(dimensions))
def _series_aggregate_type(data: dict[str, Any], dimension_index: int) -> str:
dim2 = data.get("dim2")
value_series = dim2.get("series") if isinstance(dim2, dict) else None
source_series = next(
(
item
for item in (value_series if isinstance(value_series, list) else [])
if isinstance(item, dict)
and item.get("index") is not None
and int(item["index"]) == dimension_index
),
{},
)
return str(source_series.get("aggregateType") or "sum").lower()
def _aggregation_can_change_constant(data: dict[str, Any], dimension_index: int) -> bool:
dim1 = data.get("dim1")
category_series = dim1.get("serie") if isinstance(dim1, dict) else None
if not isinstance(category_series, dict) or category_series.get("aggregate") is False:
return False
return _series_aggregate_type(data, dimension_index) in {"sum", "count", "counta"}
def _parse_chart_ref(value: str, default_sheet: str) -> tuple[str, str, CellBounds]:
raw = str(value).strip()
sheet_name = default_sheet
cell_range = raw
if "!" in raw:
sheet_name, cell_range = raw.rsplit("!", 1)
sheet_name = sheet_name.strip()
if len(sheet_name) >= 2 and sheet_name[0] == sheet_name[-1] == "'":
sheet_name = sheet_name[1:-1].replace("''", "'")
return sheet_name, cell_range.replace("$", ""), _parse_a1_bounds(cell_range)
def _looks_numeric(value: str) -> bool:
return _numeric_text_value(value) is not None
def _numeric_text_value(value: str) -> float | None:
text = value.strip()
if not text:
return None
text = re.sub(r"^([+-]?)[\$\u00a5\uffe5\u20ac\u00a3]", r"\1", text)
if text.endswith("%"):
text = text[:-1]
# Group separators must be consistent and separate groups of three digits.
if not re.fullmatch(
r"[+-]?(?:(?:\d+|\d{1,3}([, ])\d{3}(?:\1\d{3})*)(?:\.\d*)?|\.\d+)"
r"(?:[eE][+-]?\d+)?",
text,
):
return None
return float(text.replace(" ", "").replace(",", ""))
def _zero_state(value: Any) -> str:
if value in (None, ""):
return "empty"
if isinstance(value, (int, float)) and not isinstance(value, bool):
return "zero" if value == 0 else "nonzero"
if isinstance(value, str):
numeric_value = _numeric_text_value(value)
if numeric_value is not None:
return "zero" if numeric_value == 0 else "nonzero"
return "nonzero"
def _update_series_state(state: dict[str, Any], value: Any) -> None:
state[_zero_state(value)] = True
numeric = (
float(value)
if isinstance(value, (int, float)) and not isinstance(value, bool)
else _numeric_text_value(value) if isinstance(value, str) else None
)
if numeric is None:
return
state["numeric_value_count"] += 1
if len(state["unique_numeric_values"]) < 2:
state["unique_numeric_values"].add(numeric)
def _cells_truncated(data: dict[str, Any]) -> bool:
ranges = data.get("ranges")
return bool(data.get("has_more")) or any(
isinstance(item, dict) and item.get("truncated")
for item in (ranges if isinstance(ranges, list) else [])
)
def _numeric_source_issues(
chart: dict[str, Any],
*,
owner_sheet_id: str,
owner_sheet_name: str,
cache: CellCache,
locator: dict[str, str],
timeout: int,
sample_limit: int,
) -> tuple[
list[dict[str, Any]],
list[dict[str, Any]],
list[dict[str, str]],
list[SeriesProfile],
]:
chart_id = str(chart.get("chart_id") or chart.get("id") or "")
snapshot = _chart_snapshot(chart)
data = snapshot.get("data")
if not isinstance(data, dict):
return [], [], [{"chart_id": chart_id, "reason": "chart snapshot.data is missing"}], []
if data.get("isStaticData") is True:
return [], [], [], _static_series_profiles(snapshot)
dimensions = _numeric_dimensions(snapshot)
if not dimensions:
return [], [], [], []
refs = data.get("refs")
if not isinstance(refs, list) or not refs:
return [], [], [{"chart_id": chart_id, "reason": "chart data.refs is missing"}], []
parsed_refs: list[tuple[str, str, CellBounds]] = []
unverifiable: list[dict[str, str]] = []
for ref in refs:
raw_ref = ref.get("value") if isinstance(ref, dict) else ref
try:
parsed_refs.append(_parse_chart_ref(str(raw_ref), owner_sheet_name))
except (TypeError, ValueError) as exc:
unverifiable.append({"chart_id": chart_id, "reason": str(exc)})
return [], [], unverifiable, []
direction = str(data.get("direction") or "column").lower()
skip_hidden = data.get("includeHiddenOrFilter") is not True
mapped: list[tuple[int, str, int, str, str, CellBounds]] = []
for dimension_index, role in dimensions:
offset = 0
for source_sheet, source_range, bounds in parsed_refs:
dimension_count = (
bounds[3] - bounds[2] + 1 if direction == "column" else bounds[1] - bounds[0] + 1
)
if offset < dimension_index <= offset + dimension_count:
mapped.append(
(
dimension_index,
role,
dimension_index - offset,
source_sheet,
source_range,
bounds,
)
)
break
offset += dimension_count
else:
unverifiable.append(
{
"chart_id": chart_id,
"reason": f"numeric dimension index {dimension_index} is outside data.refs",
}
)
detached = str(data.get("headerMode") or "").lower() == "detached"
mapped_by_ref: dict[
tuple[str, str, CellBounds], list[tuple[int, str, int]]
] = {}
for dimension_index, role, local_index, source_sheet, source_range, bounds in mapped:
mapped_by_ref.setdefault((source_sheet, source_range, bounds), []).append(
(dimension_index, role, local_index)
)
issue_groups: dict[tuple[int, str, str, str, str, str], list[str]] = {}
issue_counts: dict[tuple[int, str, str, str, str, str], int] = {}
degenerate_series: list[dict[str, Any]] = []
series_profiles: list[SeriesProfile] = []
remaining_source_cells = MAX_CELL_READ_SIZE
remaining_dimension_spans = sum(
max(local_index for _, _, local_index in ref_dimensions)
- min(local_index for _, _, local_index in ref_dimensions)
+ 1
for ref_dimensions in mapped_by_ref.values()
)
for (source_sheet, source_range, bounds), ref_dimensions in mapped_by_ref.items():
dimension_start = bounds[2] if direction == "column" else bounds[0]
selected = {
dimension_start + local_index - 1: (dimension_index, role)
for dimension_index, role, local_index in ref_dimensions
}
dimension_span = max(selected) - min(selected) + 1
header_points = 0 if detached else 1
# Each sampled rectangle is read twice: cells-get supplies coordinates,
# styles, and hidden/filter semantics; table-get supplies typed values.
cells_per_dimension = remaining_source_cells // remaining_dimension_spans
remaining_dimension_spans -= dimension_span
point_axis_size = (
bounds[1] - bounds[0] + 1
if direction == "column"
else bounds[3] - bounds[2] + 1
)
if point_axis_size <= header_points:
unverifiable.append(
{
"chart_id": chart_id,
"reason": f"source has no data points: {source_sheet}!{source_range}",
}
)
continue
sample_points = min(MAX_SOURCE_SAMPLE_POINTS, max(0, (cells_per_dimension - header_points) // 2))
if sample_points == 0:
unverifiable.append({
"chart_id": chart_id,
"reason": f"source sampling 2000-cell budget cannot cover {source_sheet}!{source_range}",
})
continue
point_count = sample_points + header_points
if direction == "column":
checked_bounds = (
bounds[0],
min(bounds[1], bounds[0] + point_count - 1),
min(selected),
max(selected),
)
else:
checked_bounds = (
min(selected),
max(selected),
bounds[2],
min(bounds[3], bounds[2] + point_count - 1),
)
typed_bounds = (
(checked_bounds[0] + header_points, checked_bounds[1], checked_bounds[2], checked_bounds[3])
if direction == "column"
else (checked_bounds[0], checked_bounds[1], checked_bounds[2] + header_points, checked_bounds[3])
)
remaining_source_cells -= _bounds_area(checked_bounds) + _bounds_area(typed_bounds)
checked_range = _format_a1_bounds(checked_bounds)
same_sheet = source_sheet == owner_sheet_name
cells_data = _read_cells(
cache,
locator,
sheet_id=owner_sheet_id if same_sheet else None,
sheet_name=None if same_sheet else source_sheet,
cell_range=checked_range,
include="value,style",
skip_hidden=skip_hidden,
timeout=timeout,
)
cells_truncated = _cells_truncated(cells_data)
typed_rows, typed_columns, table_truncated = _read_typed_table(
cache,
locator,
sheet_id=owner_sheet_id if same_sheet else None,
sheet_name=None if same_sheet else source_sheet,
cell_range=_format_a1_bounds(typed_bounds),
timeout=timeout,
)
if cells_truncated:
unverifiable.append(
{"chart_id": chart_id, "reason": f"cells-get truncated for {source_sheet}!{checked_range}"}
)
if table_truncated:
unverifiable.append(
{
"chart_id": chart_id,
"reason": (
"table-get truncated or returned invalid typed data for "
f"{source_sheet}!{_format_a1_bounds(typed_bounds)}"
),
}
)
truncated = cells_truncated or table_truncated
states = {
coordinate: {
"zero": False,
"empty": False,
"nonzero": False,
"sample_point_count": 0,
"numeric_value_count": 0,
"unique_numeric_values": set(),
}
for coordinate in selected
}
type_unverifiable_dimensions: set[int] = set()
visible_offsets = {
(row_number - typed_bounds[0], column_index - typed_bounds[2])
for row_number, column_index, _ in _iter_cells(cells_data)
if typed_bounds[0] <= row_number <= typed_bounds[1]
and typed_bounds[2] <= column_index <= typed_bounds[3]
}
for row_number, column_index, cell in _iter_cells(cells_data):
coordinate = column_index if direction == "column" else row_number
dimension = selected.get(coordinate)
if dimension is None:
continue
if not detached and (
(direction == "column" and row_number == bounds[0])
or (direction != "column" and column_index == bounds[2])
):
continue
states[coordinate]["sample_point_count"] += 1
row_offset = row_number - typed_bounds[0]
column_offset = column_index - typed_bounds[2]
value, raw_type = _typed_cell(
typed_rows,
typed_columns,
row_offset,
column_offset,
visible_offsets,
)
_update_series_state(states[coordinate], value)
number_format = (
cell.get("cell_styles", {}).get("number_format")
if isinstance(cell, dict) and isinstance(cell.get("cell_styles"), dict)
else None
)
reason, type_unverifiable = _numeric_source_format_reason(
value,
raw_type,
number_format,
)
if type_unverifiable:
type_unverifiable_dimensions.add(dimension[0])
if not reason:
continue
dimension_index, role = dimension
key = (
dimension_index,
role,
source_sheet,
source_range,
checked_range,
reason,
)
issue_counts[key] = issue_counts.get(key, 0) + 1
samples = issue_groups.setdefault(key, [])
if len(samples) < sample_limit:
samples.append(f"{index_to_column(column_index)}{row_number}")
if truncated:
continue
for dimension_index in sorted(type_unverifiable_dimensions):
unverifiable.append(
{
"chart_id": chart_id,
"reason": (
"numeric storage type cannot be attributed to individual cells after "
f"typed column coercion for {source_sheet}!{checked_range}, "
f"dimension {dimension_index}"
),
}
)
zero_candidates = {
coordinate
for coordinate, state in states.items()
if not state["nonzero"]
and _series_aggregate_type(data, selected[coordinate][0]) != "count"
}
data_start = bounds[0] + (0 if detached else 1)
data_column = bounds[2] + (0 if detached else 1)
dim2 = data.get("dim2")
value_series = dim2.get("series") if isinstance(dim2, dict) else None
for coordinate, state in states.items():
dimension_index, role = selected[coordinate]
if direction == "column":
point_total = max(0, bounds[1] - data_start + 1)
series_range = (
f"{index_to_column(coordinate)}{data_start}:"
f"{index_to_column(coordinate)}{bounds[1]}"
if point_total
else ""
)
else:
point_total = max(0, bounds[3] - data_column + 1)
series_range = (
f"{index_to_column(data_column)}{coordinate}:"
f"{index_to_column(bounds[3])}{coordinate}"
if point_total
else ""
)
source_series = next(
(
item
for item in (value_series if isinstance(value_series, list) else [])
if isinstance(item, dict)
and item.get("index") is not None
and int(item["index"]) == dimension_index
),
{},
)
profile = {
"dimension_index": dimension_index,
"series_name": str(
source_series.get("name")
or source_series.get("nameRef")
or f"Series {dimension_index}"
),
"point_count": point_total,
"sample_point_count": state["sample_point_count"],
"checked_range": checked_range,
"sampled": (
checked_bounds[1] < bounds[1]
if direction == "column"
else checked_bounds[3] < bounds[3]
),
"numeric_value_count": state["numeric_value_count"],
"unique_numeric_values": list(state["unique_numeric_values"]),
"source_sheet": source_sheet,
"source_range": source_range,
"series_range": series_range,
}
aggregate_type = _series_aggregate_type(data, dimension_index)
if aggregate_type == "count":
profile["constant_check_unverifiable"] = True
constant_labeled = (
aggregate_type != "count"
and state["numeric_value_count"] >= 2
and len(state["unique_numeric_values"]) == 1
and dimension_index
in _labeled_series_indexes(snapshot, [{"dimension_index": dimension_index}])
)
if profile["sampled"]:
profile["constant_check_unverifiable"] = True
if coordinate in zero_candidates or constant_labeled:
unverifiable.append({
"chart_id": chart_id,
"reason": (
"zero/constant-series check is unverifiable outside sampled source "
f"{source_sheet}!{checked_range} for dimension {dimension_index}"
),
})
elif constant_labeled and _aggregation_can_change_constant(data, dimension_index):
profile["constant_check_unverifiable"] = True
unverifiable.append(
{
"chart_id": chart_id,
"reason": (
"constant-series check is unverifiable after category "
f"aggregation for dimension {dimension_index}"
),
}
)
series_profiles.append(profile)
if coordinate not in zero_candidates or profile["sampled"]:
continue
degenerate_series.append(
{
"chart_id": chart_id,
"dimension_index": dimension_index,
"role": role,
"source_sheet": source_sheet,
"source_range": source_range,
"series_range": series_range,
"reason": (
"numeric_series_all_zero_or_empty"
if states[coordinate]["zero"]
else "numeric_series_all_empty"
),
"data_point_count": point_total,
}
)
issues = [
{
"chart_id": chart_id,
"dimension_index": key[0],
"role": key[1],
"source_sheet": key[2],
"source_range": key[3],
"checked_range": key[4],
"reason": key[5],
"suggested_fix": (
"set_numeric_number_format"
if key[5] == "numeric_value_uses_text_format"
else "rewrite_as_number_and_set_numeric_number_format"
),
"affected_sample_cell_count": issue_counts[key],
"sample_cells": samples,
}
for key, samples in issue_groups.items()
]
return issues, degenerate_series, unverifiable, series_profiles
def _locator(target: str) -> dict[str, str]:
return {"url": target} if target.startswith(("http://", "https://")) else {"spreadsheet_token": target}
def _sheet_counts(sheet: dict[str, Any]) -> tuple[int, int]:
row_count = int(sheet.get("row_count") or sheet.get("rowCount") or 0)
column_count = int(sheet.get("column_count") or sheet.get("columnCount") or 0)
if row_count <= 0 or column_count <= 0:
raise LarkCliError(f"Missing row_count/column_count for sheet {sheet_title(sheet)!r}")
return row_count, column_count
def check_sheet(
locator: dict[str, str],
sheet: dict[str, Any],
*,
timeout: int,
sample_limit: int,
cell_cache: CellCache | None = None,
) -> dict[str, Any]:
cell_cache = cell_cache if cell_cache is not None else {}
sheet_id = sheet_identifier(sheet)
title = sheet_title(sheet)
if not sheet_id:
raise LarkCliError(f"Missing sheet_id for sheet {title!r}")
chart_data = envelope_data(
run_sheets("+chart-list", **locator, sheet_id=sheet_id, timeout=timeout)
)
charts = extract_charts(chart_data, sheet_id, title)
unverifiable: list[dict[str, str]] = []
expected_chart_count = sheet.get("chart_count")
if expected_chart_count is not None and int(expected_chart_count) != len(charts):
unverifiable.append(
{
"chart_id": "",
"reason": (
f"chart-list returned {len(charts)} charts, "
f"but workbook-info reported {int(expected_chart_count)}"
),
}
)
if not charts:
return {
"sheet_id": sheet_id,
"sheet_name": title,
"chart_count": 0,
"sheet_size_px": None,
"chart_overlaps": [],
"cell_content_overlaps": [],
"numeric_source_format_issues": [],
"numeric_source_samples": [],
"degenerate_numeric_series": [],
"constant_labeled_series": [],
"unbound_secondary_axes": [],
"undersized_charts": [],
"overwide_charts": [],
"out_of_visible_range": [],
"unverifiable_charts": unverifiable,
"issue_count": 0,
"unverifiable_count": len(unverifiable),
"warnings": [],
}
row_count, column_count = _sheet_counts(sheet)
structure_data = envelope_data(
run_sheets(
"+sheet-info",
**locator,
sheet_id=sheet_id,
flags={"include": "row_heights,col_widths"},
timeout=timeout,
)
)
row_edges, column_edges, warnings = build_layout(
extract_sheet_structure(structure_data), row_count, column_count
)
rectangles: list[dict[str, Any]] = []
for chart in charts:
chart_id = str(chart.get("chart_id") or chart.get("id") or "")
if not chart_id:
unverifiable.append({"chart_id": "", "reason": "chart is missing chart_id"})
continue
try:
rectangles.append(chart_rectangle(chart, row_edges, column_edges))
except (KeyError, TypeError, ValueError) as exc:
unverifiable.append({"chart_id": chart_id, "reason": str(exc)})
overlaps: list[dict[str, Any]] = []
for index, first in enumerate(rectangles):
for second in rectangles[index + 1 :]:
overlap = intersection(first, second)
if overlap:
overlaps.append(
{
"chart_ids": [first["chart_id"], second["chart_id"]],
"charts": [chart_context(first), chart_context(second)],
"intersection": overlap,
}
)
sheet_width = column_edges[-1]
sheet_height = row_edges[-1]
out_of_bounds: list[dict[str, Any]] = []
content_overlaps: list[dict[str, Any]] = []
covered_items: list[tuple[dict[str, Any], CellBounds]] = []
for rectangle in rectangles:
overflow = {
"left": round(max(0.0, -rectangle["left"]), 2),
"top": round(max(0.0, -rectangle["top"]), 2),
"right": round(max(0.0, rectangle["right"] - sheet_width), 2),
"bottom": round(max(0.0, rectangle["bottom"] - sheet_height), 2),
}
if any(overflow.values()):
out_of_bounds.append({**chart_context(rectangle), "overflow_px": overflow})
covered_range = rectangle_cell_range(rectangle, row_edges, column_edges)
if not covered_range:
continue
covered_items.append((rectangle, _parse_a1_bounds(covered_range)))
for cluster in _cluster_cell_reads(covered_items):
read_range = _format_a1_bounds(cluster["bounds"])
cells_data = _read_cells(
cell_cache,
locator,
sheet_id=sheet_id,
sheet_name=None,
cell_range=read_range,
include="value,formula,comment",
timeout=timeout,
)
for rectangle, bounds in cluster["members"]:
covered_range = _format_a1_bounds(bounds)
count, samples, truncated = non_empty_cells(cells_data, sample_limit, bounds)
if truncated:
unverifiable.append(
{
"chart_id": rectangle["chart_id"],
"reason": f"cells-get truncated for {read_range}",
}
)
if count:
content_overlaps.append(
{
**chart_context(rectangle),
"covered_range": covered_range,
"non_empty_cell_count": count,
"sample_cells": samples,
}
)
numeric_source_issues: list[dict[str, Any]] = []
numeric_source_samples: list[dict[str, Any]] = []
degenerate_numeric_series: list[dict[str, Any]] = []
constant_series_issues: list[dict[str, Any]] = []
unbound_secondary_axes: list[dict[str, Any]] = []
undersized_charts: list[dict[str, Any]] = []
overwide_charts: list[dict[str, Any]] = []
for chart in charts:
issues, degenerate, source_unverifiable, profiles = _numeric_source_issues(
chart,
owner_sheet_id=sheet_id,
owner_sheet_name=title,
cache=cell_cache,
locator=locator,
timeout=timeout,
sample_limit=sample_limit,
)
numeric_source_issues.extend(issues)
numeric_source_samples.extend(
{"chart_id": str(chart.get("chart_id") or chart.get("id") or ""), **profile}
for profile in profiles
if "checked_range" in profile
)
degenerate_numeric_series.extend(degenerate)
unverifiable.extend(source_unverifiable)
constant_series_issues.extend(_constant_labeled_series(chart, profiles))
unbound_secondary_axis = _unbound_secondary_axis(chart)
if unbound_secondary_axis:
unbound_secondary_axes.append(unbound_secondary_axis)
undersized = _undersized_chart(chart)
if undersized:
undersized_charts.append(undersized)
overwide = _overwide_chart(chart)
if overwide:
overwide_charts.append(overwide)
issue_count = (
len(overlaps)
+ len(out_of_bounds)
+ len(content_overlaps)
+ len(numeric_source_issues)
+ len(degenerate_numeric_series)
+ len(constant_series_issues)
+ len(unbound_secondary_axes)
+ len(undersized_charts)
+ len(overwide_charts)
)
return {
"sheet_id": sheet_id,
"sheet_name": title,
"chart_count": len(charts),
"sheet_size_px": {"width": round(sheet_width, 2), "height": round(sheet_height, 2)},
"chart_overlaps": overlaps,
"cell_content_overlaps": content_overlaps,
"numeric_source_format_issues": numeric_source_issues,
"numeric_source_samples": numeric_source_samples,
"degenerate_numeric_series": degenerate_numeric_series,
"constant_labeled_series": constant_series_issues,
"unbound_secondary_axes": unbound_secondary_axes,
"undersized_charts": undersized_charts,
"overwide_charts": overwide_charts,
"out_of_visible_range": out_of_bounds,
"unverifiable_charts": unverifiable,
"issue_count": issue_count,
"unverifiable_count": len(unverifiable),
"warnings": warnings,
}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=(
"Check chart overlap, covered cell content, worksheet boundary overflow, "
"minimum size, excessive width, constant labeled series, numeric source-cell "
"formats, all-zero/empty numeric series, and unbound combo-chart secondary axes."
)
)
parser.add_argument("sheet_id", help="Spreadsheet URL or spreadsheet token")
parser.add_argument("--worksheet-id", help="Only check this worksheet reference_id")
parser.add_argument("--timeout", type=int, default=60)
parser.add_argument("--sample-limit", type=int, default=10)
return parser.parse_args()
def success_envelope(results: list[dict[str, Any]]) -> dict[str, Any]:
issue_count = sum(result["issue_count"] for result in results)
unverifiable_count = sum(result["unverifiable_count"] for result in results)
warnings = [
f"{result['sheet_name'] or result['sheet_id']}: {warning}"
for result in results
for warning in result["warnings"]
]
return {
"ok": True,
"engine": "lark",
"action": ACTION,
"data": {
"passed": issue_count == 0 and unverifiable_count == 0,
"scope_note": (
"out_of_visible_range checks worksheet drawable bounds, not a device-specific browser viewport; "
"numeric source checks sample at most the first 50 data points of each chart value dimension "
"for formats, all-zero/empty, and constant-value checks; zero/constant results are conclusive "
"only when that sample covers the complete series, otherwise they are marked unverifiable"
),
"summary": {
"worksheet_count": len(results),
"chart_count": sum(result["chart_count"] for result in results),
"issue_count": issue_count,
"unverifiable_count": unverifiable_count,
},
"sheets": results,
},
"warnings": warnings,
}
def report_exit_code(report: dict[str, Any]) -> int:
if report["data"]["passed"]:
return 0
if report["data"]["summary"]["issue_count"] > 0:
return 2
return 1
def main() -> None:
args = parse_args()
locator = _locator(args.sheet_id)
cell_cache: CellCache = {}
try:
workbook_data = envelope_data(
run_sheets("+workbook-info", **locator, timeout=args.timeout)
)
sheets = resolve_target_sheets(workbook_data, sheet_id=args.worksheet_id)
if not args.worksheet_id:
sheets = [sheet for sheet in sheets if not bool(sheet.get("is_hidden"))]
if not sheets:
raise LarkCliError("No visible worksheet matched")
results = [
check_sheet(
locator,
sheet,
timeout=args.timeout,
sample_limit=args.sample_limit,
cell_cache=cell_cache,
)
for sheet in sheets
]
except (LarkCliError, KeyError, TypeError, ValueError) as exc:
emit_error(ACTION, str(exc))
raise SystemExit(1) from exc
report = success_envelope(results)
print(json.dumps(report, ensure_ascii=False, indent=2))
exit_code = report_exit_code(report)
if exit_code:
raise SystemExit(exit_code)
if __name__ == "__main__":
main()
scripts/lark_chart_size_advisor.py›
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
"""Recommend a Lark Sheet chart size before creating the chart object."""
from __future__ import annotations
import argparse
import json
import re
from typing import Any
from lark_chart_size_rules import SUPPORTED_CHART_TYPES, recommend_chart_size
from lark_sheet_read_cli import (
LarkCliError,
emit_error,
emit_success,
envelope_data,
extract_sheets,
run_sheets,
sheet_identifier,
sheet_title,
)
ACTION = "chart_size_advisor"
def _locator(target: str) -> dict[str, str]:
return {"url": target} if target.startswith(("http://", "https://")) else {"spreadsheet_token": target}
def _split_range_refs(value: str) -> list[str]:
refs: list[str] = []
start = 0
quoted = False
index = 0
while index < len(value):
char = value[index]
if char == "'":
if quoted and index + 1 < len(value) and value[index + 1] == "'":
index += 2
continue
quoted = not quoted
elif char == "," and not quoted:
ref = value[start:index].strip()
if ref:
refs.append(ref)
start = index + 1
index += 1
tail = value[start:].strip()
if tail:
refs.append(tail)
if not refs or quoted:
raise ValueError(f"Invalid data range: {value!r}")
return refs
def _parse_ref(value: str) -> tuple[str | None, str]:
raw = value.strip()
sheet_name = None
cell_range = raw
if "!" in raw:
sheet_name, cell_range = raw.rsplit("!", 1)
sheet_name = sheet_name.strip()
if len(sheet_name) >= 2 and sheet_name[0] == sheet_name[-1] == "'":
sheet_name = sheet_name[1:-1].replace("''", "'")
cell_range = cell_range.replace("$", "")
if not re.fullmatch(r"[A-Za-z]+\d+:[A-Za-z]+\d+", cell_range):
raise ValueError(f"Invalid A1 range: {value!r}")
return sheet_name, cell_range
def _cell_value(cell: Any) -> Any:
if not isinstance(cell, dict):
return None
return cell.get("value", cell.get("raw_value"))
def _matrix(data: dict[str, Any]) -> list[list[Any]]:
ranges = data.get("ranges")
if not isinstance(ranges, list) or not ranges:
return []
result = ranges[0]
cells = result.get("cells") if isinstance(result, dict) else None
if not isinstance(cells, list):
return []
return [
[_cell_value(cell) for cell in row]
for row in cells
if isinstance(row, list)
]
def _is_network_timeout(exc: LarkCliError) -> bool:
text = str(exc).strip()
try:
payload = json.loads(text)
except json.JSONDecodeError:
payload = None
if isinstance(payload, dict):
error = payload.get("error")
if isinstance(error, dict) and str(error.get("subtype") or "").lower() == "timeout":
return True
lowered = text.lower()
return "server time out" in lowered or "timed out" in lowered
def _run_read(
shortcut: str,
*,
stage: str,
timeout: int,
**kwargs: Any,
) -> dict[str, Any]:
retried = False
while True:
try:
return run_sheets(shortcut, timeout=timeout, **kwargs)
except LarkCliError as exc:
if not retried and _is_network_timeout(exc):
retried = True
continue
suffix = " after one retry" if retried else ""
raise LarkCliError(f"{stage} failed{suffix}: {exc}", cmd=exc.cmd) from exc
def _sheet_selector(
sheets: list[dict[str, Any]],
*,
explicit_name: str | None,
worksheet_id: str | None,
worksheet_name: str | None,
) -> dict[str, str]:
if explicit_name:
return {"sheet_name": explicit_name}
if worksheet_id:
return {"sheet_id": worksheet_id}
if worksheet_name:
return {"sheet_name": worksheet_name}
if len(sheets) != 1:
raise LarkCliError("Unqualified data ranges require --worksheet-id or --worksheet-name")
sheet_id = sheet_identifier(sheets[0])
if sheet_id:
return {"sheet_id": sheet_id}
return {"sheet_name": sheet_title(sheets[0])}
def _needs_workbook_metadata(
ranges: list[str | None],
*,
worksheet_id: str | None,
worksheet_name: str | None,
) -> bool:
if worksheet_id or worksheet_name:
return False
return any(
_parse_ref(ref)[0] is None
for value in ranges
if value
for ref in _split_range_refs(value)
)
def _read_ranges(
locator: dict[str, str],
sheets: list[dict[str, Any]],
value: str,
*,
worksheet_id: str | None,
worksheet_name: str | None,
timeout: int,
stage_prefix: str = "data range",
) -> list[list[list[Any]]]:
matrices: list[list[list[Any]]] = []
for ref in _split_range_refs(value):
explicit_name, cell_range = _parse_ref(ref)
selector = _sheet_selector(
sheets,
explicit_name=explicit_name,
worksheet_id=worksheet_id,
worksheet_name=worksheet_name,
)
data = envelope_data(
_run_read(
"+cells-get",
stage=f"{stage_prefix} {ref}",
**locator,
**selector,
flags={"range": cell_range, "include": "value"},
timeout=timeout,
)
)
matrix = _matrix(data)
if not matrix:
raise LarkCliError(f"No cells returned for {ref}")
matrices.append(matrix)
return matrices
def _combine(matrices: list[list[list[Any]]], direction: str) -> list[list[Any]]:
if direction == "column":
row_count = len(matrices[0])
if any(len(matrix) != row_count for matrix in matrices):
raise ValueError("Column-direction ranges must contain the same number of rows")
return [sum((matrix[row] for matrix in matrices), []) for row in range(row_count)]
column_count = max((len(row) for row in matrices[0]), default=0)
if any(max((len(row) for row in matrix), default=0) != column_count for matrix in matrices):
raise ValueError("Row-direction ranges must contain the same number of columns")
return sum(matrices, [])
def _parse_indexes(value: str | None, *, dimension_count: int, dim1_index: int) -> list[int]:
indexes = (
[int(item.strip()) for item in value.split(",") if item.strip()]
if value
else [index for index in range(1, dimension_count + 1) if index != dim1_index]
)
if not indexes or any(index < 1 or index > dimension_count for index in indexes):
raise ValueError("--dim2-indexes contains an out-of-range dimension index")
if dim1_index in indexes:
raise ValueError("--dim1-index cannot also appear in --dim2-indexes")
return indexes
def _numeric(value: Any) -> float | None:
if isinstance(value, (int, float)) and not isinstance(value, bool):
return float(value)
if isinstance(value, str):
text = value.strip().replace(",", "")
if text.endswith("%"):
text = text[:-1]
try:
return float(text)
except ValueError:
return None
return None
def profile_matrix(
matrix: list[list[Any]],
*,
direction: str,
dim1_index: int,
dim2_indexes: str | None,
detached_headers: list[Any] | None = None,
) -> dict[str, Any]:
if not matrix:
raise ValueError("Data range is empty")
dimension_count = max((len(row) for row in matrix), default=0) if direction == "column" else len(matrix)
if dim1_index < 1 or dim1_index > dimension_count:
raise ValueError("--dim1-index is outside the data range")
selected = _parse_indexes(
dim2_indexes,
dimension_count=dimension_count,
dim1_index=dim1_index,
)
detached = detached_headers is not None
if direction == "column":
data_rows = matrix if detached else matrix[1:]
categories = [row[dim1_index - 1] if len(row) >= dim1_index else None for row in data_rows]
headers = detached_headers or matrix[0]
series_names = [str(headers[index - 1]) if len(headers) >= index else f"Series {index}" for index in selected]
first_values = [row[selected[0] - 1] if len(row) >= selected[0] else None for row in data_rows]
else:
category_row = matrix[dim1_index - 1]
categories = category_row if detached else category_row[1:]
headers = detached_headers or [row[0] if row else None for row in matrix]
series_names = [str(headers[index - 1]) if len(headers) >= index else f"Series {index}" for index in selected]
first_row = matrix[selected[0] - 1]
first_values = first_row if detached else first_row[1:]
nonempty_categories = [value for value in categories if value not in (None, "")]
return {
"categories": nonempty_categories,
"series_names": series_names,
# 海象运算符要 3.8,CI 的解释器是 3.7:先映射再滤空,语义等价。
"values": [n for n in (_numeric(v) for v in first_values) if n is not None],
"dim2_indexes": selected,
}
def _header_values(matrices: list[list[list[Any]]], direction: str) -> list[Any]:
matrix = _combine(matrices, direction)
if direction == "column":
if len(matrix) != 1:
raise ValueError("Column-direction --header-range must contain one row")
return matrix[0]
if any(len(row) != 1 for row in matrix):
raise ValueError("Row-direction --header-range must contain one column")
return [row[0] for row in matrix]
def _boolean_argument(value: str) -> bool:
normalized = str(value).strip().lower()
if normalized == "true":
return True
if normalized == "false":
return False
raise argparse.ArgumentTypeError("expected true or false")
def _comma_separated_values(value: str) -> list[str]:
values = [item.strip() for item in str(value).split(",")]
if not values or any(not item for item in values):
raise argparse.ArgumentTypeError("expected a comma-separated list")
return values
def _series_y_axes_argument(value: str) -> list[str]:
values = _comma_separated_values(value)
invalid = [item for item in values if item not in {"left", "right"}]
if invalid:
raise argparse.ArgumentTypeError(
f"unsupported series Y axis {invalid[0]!r}; expected left or right"
)
return values
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Recommend chart width and height before +chart-create-basic")
parser.add_argument("target", help="Spreadsheet URL or spreadsheet token")
worksheet = parser.add_mutually_exclusive_group()
worksheet.add_argument("--worksheet-id")
worksheet.add_argument("--worksheet-name")
parser.add_argument("--chart-type", choices=sorted(SUPPORTED_CHART_TYPES), required=True)
parser.add_argument("--data-range", required=True)
parser.add_argument("--header-range")
parser.add_argument("--data-direction", choices=("column", "row"), default="column")
parser.add_argument("--dim1-index", type=int, default=1)
parser.add_argument("--dim2-indexes")
parser.add_argument("--series-types", type=_comma_separated_values)
parser.add_argument("--series-y-axes", type=_series_y_axes_argument)
parser.add_argument("--data-labels", default="none")
parser.add_argument("--aggregate-categories", type=_boolean_argument, default=True)
parser.add_argument("--legend-position", default="bottom")
parser.add_argument("--title", default="")
parser.add_argument("--timeout", type=int, default=60)
return parser.parse_args(argv)
def main() -> None:
args = parse_args()
locator = _locator(args.target)
try:
sheets: list[dict[str, Any]] = []
if _needs_workbook_metadata(
[args.data_range, args.header_range],
worksheet_id=args.worksheet_id,
worksheet_name=args.worksheet_name,
):
workbook = envelope_data(
_run_read(
"+workbook-info",
stage="workbook metadata",
**locator,
timeout=args.timeout,
)
)
sheets = extract_sheets(workbook)
matrices = _read_ranges(
locator,
sheets,
args.data_range,
worksheet_id=args.worksheet_id,
worksheet_name=args.worksheet_name,
timeout=args.timeout,
)
headers = None
if args.header_range:
header_matrices = _read_ranges(
locator,
sheets,
args.header_range,
worksheet_id=args.worksheet_id,
worksheet_name=args.worksheet_name,
timeout=args.timeout,
stage_prefix="header range",
)
headers = _header_values(header_matrices, args.data_direction)
profile = profile_matrix(
_combine(matrices, args.data_direction),
direction=args.data_direction,
dim1_index=args.dim1_index,
dim2_indexes=args.dim2_indexes,
detached_headers=headers,
)
result = recommend_chart_size(
chart_type=args.chart_type,
categories=profile["categories"],
series_names=profile["series_names"],
data_labels=args.data_labels,
legend_position=args.legend_position,
title=args.title,
values=profile["values"],
aggregate_categories=args.aggregate_categories,
series_types=args.series_types,
series_y_axes=args.series_y_axes,
)
result["data_profile"] = {
"dim2_indexes": profile["dim2_indexes"],
**result.pop("evidence"),
}
except (LarkCliError, KeyError, TypeError, ValueError) as exc:
emit_error(ACTION, str(exc))
raise SystemExit(1) from exc
emit_success(ACTION, result)
if __name__ == "__main__":
main()
scripts/lark_chart_size_rules.py›
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
"""Pure sizing heuristics shared by Lark chart helper scripts."""
from __future__ import annotations
import math
import unicodedata
from typing import Any
MINIMUM_SIZES = {
"column": (640, 400),
"line": (640, 400),
"area": (640, 400),
"bar": (720, 420),
"combo": (720, 420),
"pie": (720, 440),
}
SUPPORTED_CHART_TYPES = {
"column",
"bar",
"line",
"area",
"pie",
"scatter",
"combo",
"radar",
"bubble",
"waterfall",
"pareto",
}
DEFAULT_MINIMUM_SIZE = (640, 400)
MAX_CHART_WIDTH = 1600
MAX_CHART_HEIGHT = 720
MAX_ASPECT_RATIO = 2.6
COMBO_SERIES_TYPES = {"column", "line", "area", "scatter"}
COMBO_SERIES_Y_AXES = {"left", "right"}
def display_units(value: Any) -> int:
"""Estimate visible text width; CJK/full-width characters count double."""
lines = str(value if value is not None else "").splitlines() or [""]
return max(
sum(2 if unicodedata.east_asian_width(char) in {"W", "F", "A"} else 1 for char in line)
for line in lines
)
def _round_up(value: float, step: int = 40) -> int:
return int(math.ceil(value / step) * step)
def _round_down(value: float, step: int = 40) -> int:
return int(math.floor(value / step) * step)
def _percentile(values: list[int], ratio: float) -> int:
if not values:
return 0
ordered = sorted(values)
return ordered[max(0, math.ceil(len(ordered) * ratio) - 1)]
def _has_clustered_small_slices(values: list[float]) -> bool:
positive = [value for value in values if value > 0]
total = sum(positive)
if not total:
return False
shares = [value / total for value in positive]
return max(shares, default=0) >= 0.75 and sum(share < 0.05 for share in shares) >= 3
def minimum_chart_size(chart_type: str) -> dict[str, int]:
width, height = MINIMUM_SIZES.get(str(chart_type).lower(), DEFAULT_MINIMUM_SIZE)
return {"width": width, "height": height}
def estimate_legend_rows(items: list[str], width: int) -> int:
if not items:
return 0
available = max(240, width - 80)
used = 0
rows = 1
for item in items:
item_width = min(320, 34 + display_units(item) * 7)
if used and used + item_width > available:
rows += 1
used = 0
used += item_width
return rows
def effective_category_labels(
categories: list[Any], *, aggregate_categories: bool = True
) -> list[str]:
labels = [str(value if value is not None else "") for value in categories]
if aggregate_categories:
return list(dict.fromkeys(labels))
return labels
def effective_series_types(
chart_type: str,
series_count: int,
series_types: list[str] | None = None,
) -> list[str]:
chart_type = str(chart_type).lower()
if chart_type != "combo":
if series_types:
raise ValueError("series_types is only valid for combo charts")
return [chart_type] * series_count
if series_types is None:
return ["column", *(["line"] * max(0, series_count - 1))]
normalized = [str(value).strip().lower() for value in series_types]
if len(normalized) != series_count:
raise ValueError("series_types length must match series_names")
invalid = [value for value in normalized if value not in COMBO_SERIES_TYPES]
if invalid:
raise ValueError(f"unsupported combo series type: {invalid[0]}")
return normalized
def effective_series_y_axes(
chart_type: str,
series_count: int,
series_y_axes: list[str] | None = None,
) -> list[str]:
chart_type = str(chart_type).lower()
if chart_type != "combo":
if series_y_axes:
raise ValueError("series_y_axes is only valid for combo charts")
return ["left"] * series_count
if series_y_axes is None:
return ["left", *(["right"] * max(0, series_count - 1))]
normalized = [str(value).strip().lower() for value in series_y_axes]
if len(normalized) != series_count:
raise ValueError("series_y_axes length must match series_names")
invalid = [value for value in normalized if value not in COMBO_SERIES_Y_AXES]
if invalid:
raise ValueError(f"unsupported combo series Y axis: {invalid[0]}")
return normalized
def recommend_chart_size(
*,
chart_type: str,
categories: list[Any],
series_names: list[str],
data_labels: str = "none",
legend_position: str = "bottom",
title: str = "",
values: list[float] | None = None,
aggregate_categories: bool = True,
series_types: list[str] | None = None,
series_y_axes: list[str] | None = None,
) -> dict[str, Any]:
chart_type = str(chart_type).lower()
if chart_type not in SUPPORTED_CHART_TYPES:
raise ValueError(f"unsupported chart type: {chart_type}")
category_text = effective_category_labels(
categories,
aggregate_categories=aggregate_categories,
)
category_count = len(category_text)
series_count = max(1, len(series_names))
normalized_series_types = effective_series_types(
chart_type,
series_count,
series_types,
)
normalized_series_y_axes = effective_series_y_axes(
chart_type,
series_count,
series_y_axes,
)
column_series_count = sum(value == "column" for value in normalized_series_types)
line_like_series_count = series_count - column_series_count
label_units = [display_units(value) for value in category_text]
max_units = max(label_units, default=0)
p75_units = _percentile(label_units, 0.75)
max_lines = max((len(value.splitlines()) for value in category_text), default=1)
labels_enabled = str(data_labels or "").lower() not in {"", "none"}
minimum = minimum_chart_size(chart_type)
width = float(minimum["width"])
height = float(minimum["height"])
reasons: list[str] = []
advice: list[str] = []
if chart_type == "pie":
label_reserve = max(150, min(360, max_units * 7 + 60))
width = max(width, 420 + 2 * label_reserve)
if labels_enabled:
reasons.append("outside_slice_labels")
if values and _has_clustered_small_slices(values):
height += 40
reasons.append("clustered_small_slices")
if category_count > 8:
advice.append("prefer_bar_or_top_n")
size_alone_is_insufficient = category_count > 12
elif chart_type == "bar":
width = max(width, 420 + max_units * 7)
height = max(height, 190 + category_count * 36)
height_limited = _round_up(height) > MAX_CHART_HEIGHT
size_alone_is_insufficient = category_count > 24
if height_limited:
reasons.append("maximum_height_limited")
if size_alone_is_insufficient:
advice.extend(["use_top_n", "split_chart"])
else:
reserve = (
230
if chart_type == "combo" and "right" in normalized_series_y_axes
else 170
)
line_dominant_combo = chart_type == "combo" and column_series_count == 0
base_slot = 44 if chart_type in {"line", "area"} or line_dominant_combo else 52
text_slot = 20 + p75_units * 7 * 0.72
slot = max(base_slot, min(180, text_slot))
if column_series_count <= 1 and category_count >= 10:
# With many categories, Sheet rotates X-axis labels. Reserving each
# label's full horizontal text width makes single-series charts
# disproportionately wide; density checks below still expand when
# data labels would actually collide.
slot = min(slot, 68)
if column_series_count > 1:
slot = max(slot, 44 + 12 * min(column_series_count - 1, 4))
if chart_type == "combo" and line_like_series_count > 1:
slot += min(12, 4 * (line_like_series_count - 1))
if labels_enabled:
slot += min(24, 4 * series_count)
width = max(width, reserve + category_count * slot)
if p75_units > 12:
height += 40
reasons.append("long_category_labels")
if max_lines > 1:
height += min(120, 40 * (max_lines - 1))
reasons.append("multiline_category_labels")
size_alone_is_insufficient = (
category_count > 20
and (p75_units > 12 or series_count > 3 or labels_enabled)
)
if size_alone_is_insufficient:
advice.extend(["prefer_bar_or_top_n", "split_chart"])
width = min(MAX_CHART_WIDTH, _round_up(width))
aspect_width_limit = max(minimum["width"], _round_down(height * MAX_ASPECT_RATIO))
if width > aspect_width_limit:
width = aspect_width_limit
reasons.append("aspect_ratio_limited")
legend_items = category_text if chart_type == "pie" else series_names
legend_rows = 0
if str(legend_position).lower() != "hidden":
legend_rows = estimate_legend_rows(legend_items, width)
if legend_rows > 1:
height += (legend_rows - 1) * 32
reasons.append("multi_row_legend")
if title:
reasons.append("chart_title")
height = min(MAX_CHART_HEIGHT, _round_up(height))
if size_alone_is_insufficient:
if chart_type == "pie":
height = max(height, 520)
elif chart_type != "bar":
width = max(width, 1200)
height = max(height, 520)
return {
"minimum_size": minimum,
"recommended_size": {"width": width, "height": height},
"create_flags": {"width": width, "height": height},
"evidence": {
"chart_type": chart_type,
"series_count": series_count,
"series_types": normalized_series_types,
"series_y_axes": normalized_series_y_axes,
"column_series_count": column_series_count,
"max_category_display_units": max_units,
"p75_category_display_units": p75_units,
"max_category_line_count": max_lines,
"legend_rows": legend_rows,
"data_labels": data_labels,
"aggregate_categories": aggregate_categories,
"recommended_aspect_ratio": round(width / height, 2),
},
"reasons": list(dict.fromkeys(reasons)),
"layout_advice": list(dict.fromkeys(advice)),
"size_alone_is_insufficient": size_alone_is_insufficient,
}
scripts/lark_detect_subtables.py›
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
"""Detect occupied subtable regions in a Lark sheet."""
from __future__ import annotations
import argparse
import csv
import io
import re
from collections import deque
from dataclasses import dataclass
from typing import Any
from lark_sheet_range import RangeBounds, col_to_index, format_range, index_to_col, parse_range, range_union
from lark_sheet_read_cli import (
LarkCliError,
add_spreadsheet_args,
emit_error,
emit_success,
envelope_data,
resolve_target_sheets,
run_sheets,
sheet_identifier,
sheet_locator,
sheet_title,
)
ACTION = "detect_subtables"
ROW_PREFIX_RE = re.compile(r"^\[row=(\d+)\]\s?(.*)$")
MAX_EXTERNAL_MERGE_ANCHOR_CHECKS = 10
def _inside_quoted_field(lines: list[str]) -> bool:
"""True when the accumulated record has an unterminated quoted field.
RFC 4180 escapes a literal quote by doubling it, so both halves of a `""`
pair count and parity still tracks whether a field is left open. A record
that is still open must swallow the next physical line verbatim — even one
that looks like a `[row=N]` prefix, because inside quotes that text is
ordinary cell content, not a new record.
"""
return sum(line.count('"') for line in lines) % 2 == 1
@dataclass
class CsvGrid:
row_numbers: list[int]
col_letters: list[str]
values: list[list[str]]
row_numbers_inferred: bool = False
@dataclass
class Component:
bounds: RangeBounds
occupied_count: int
def parse_annotated_csv(
text: str,
col_indices: list[str] | None = None,
row_indices: list[int] | None = None,
source_range: str | None = None,
) -> CsvGrid:
row_numbers: list[int] = []
values: list[list[str]] = []
max_cols = 0
lines = (text or "").splitlines()
row_numbers_inferred = False
has_authoritative_rows = isinstance(row_indices, list) and len(row_indices) > 0
first_meaningful = next((line for line in lines if line.strip()), "")
if ROW_PREFIX_RE.match(first_meaningful):
records = []
current_lines: list[str] | None = None
current_row_number: int | None = None
for line in lines:
match = ROW_PREFIX_RE.match(line)
if match and current_lines is not None and _inside_quoted_field(current_lines):
# Prefix-looking text inside an open quoted field is content.
current_lines.append(line)
continue
if match:
if current_lines is not None and current_row_number is not None:
records.append("\n".join(current_lines))
row_numbers.append(current_row_number)
current_row_number = int(match.group(1))
current_lines = [match.group(2)]
elif current_lines is not None:
current_lines.append(line)
if current_lines is not None and current_row_number is not None:
records.append("\n".join(current_lines))
row_numbers.append(current_row_number)
# Cross-check against the row numbers the server itself reported.
# _inside_quoted_field decides whether a "[row=N]" line starts a new
# record or is content inside an open quoted field; when the payload's
# quoting is malformed (a lone unescaped quote in a cell), that call
# goes the wrong way and every following line is swallowed into the
# previous cell — the rows simply vanish, and everything downstream
# (data_range, last data row, column profiles) is quietly computed from
# a short grid. row_indices is authoritative and already in hand, so
# refuse rather than profile a grid that does not match it.
if has_authoritative_rows and row_numbers != [int(r) for r in row_indices]:
raise ValueError(
"annotated_csv did not parse into the rows the server reported "
f"(parsed {len(row_numbers)} rows {row_numbers[:5]}…, expected "
f"{len(row_indices)} rows {list(row_indices)[:5]}…) — most likely "
"an unbalanced quote in a cell. Re-read a narrower --range, or use "
"+cells-get for this region instead of the CSV path."
)
for record in records:
parsed = next(csv.reader([record]))
values.append(parsed)
max_cols = max(max_cols, len(parsed))
else:
reader = csv.reader(io.StringIO(text or ""))
fallback_start = 1
if source_range:
fallback_start = parse_range(
source_range,
max_row=1_048_576,
max_col=18_278,
).start_row
for offset, row in enumerate(reader):
row_num = None
if has_authoritative_rows and offset < len(row_indices):
try:
row_num = int(row_indices[offset])
except (TypeError, ValueError):
# Non-numeric row index: leave row_num as None so the
# inferred fallback numbering below takes over.
pass
if row_num is None:
row_numbers_inferred = True
row_numbers.append(row_num if row_num is not None else fallback_start + offset)
values.append(row)
max_cols = max(max_cols, len(row))
if col_indices:
col_letters = [str(col) for col in col_indices[:max_cols]]
while len(col_letters) < max_cols:
next_col = col_to_index(col_letters[-1]) + 1 if col_letters else len(col_letters) + 1
col_letters.append(index_to_col(next_col))
else:
col_letters = [index_to_col(i) for i in range(1, max_cols + 1)]
for row in values:
row.extend([""] * (len(col_letters) - len(row)))
inferred = bool(values) and not ROW_PREFIX_RE.match(first_meaningful) and (
row_numbers_inferred or not has_authoritative_rows
)
return CsvGrid(
row_numbers=row_numbers,
col_letters=col_letters,
values=values,
row_numbers_inferred=inferred,
)
def _merged_ranges(layout: dict[str, Any]) -> list[str]:
merges = layout.get("merged_cells") or layout.get("merges") or []
result = []
for item in merges:
if isinstance(item, str):
result.append(item)
elif isinstance(item, dict):
value = item.get("range") or item.get("a1_range") or item.get("range_ref")
if isinstance(value, str):
result.append(value)
return result
def _scan_bounds(grid: CsvGrid) -> RangeBounds | None:
col_numbers = [col_to_index(col) for col in grid.col_letters]
if grid.row_numbers and grid.col_letters:
return RangeBounds(
min(grid.row_numbers),
min(col_numbers),
max(grid.row_numbers),
max(col_numbers),
)
return None
def _external_merge_anchors(grid: CsvGrid, layout: dict[str, Any]) -> dict[str, str]:
scan_bounds = _scan_bounds(grid)
if scan_bounds is None:
return {}
result = {}
for merge_ref in _merged_ranges(layout):
try:
bounds = parse_range(merge_ref)
except ValueError:
continue
intersects = not (
bounds.end_row < scan_bounds.start_row
or bounds.start_row > scan_bounds.end_row
or bounds.end_col < scan_bounds.start_col
or bounds.start_col > scan_bounds.end_col
)
anchor_in_scan = (
scan_bounds.start_row <= bounds.start_row <= scan_bounds.end_row
and scan_bounds.start_col <= bounds.start_col <= scan_bounds.end_col
)
if intersects and not anchor_in_scan:
result[merge_ref] = format_range(
bounds.start_row, bounds.start_col, bounds.start_row, bounds.start_col
)
return result
def _has_value(grid: CsvGrid) -> bool:
return any(value.strip() for row in grid.values for value in row)
def build_occupancy(
grid: CsvGrid,
layout: dict[str, Any],
*,
confirmed_external_merges: set[str] | None = None,
) -> set[tuple[int, int]]:
col_numbers = [col_to_index(col) for col in grid.col_letters]
occupied: set[tuple[int, int]] = set()
for row_idx, row_num in enumerate(grid.row_numbers):
for col_idx, value in enumerate(grid.values[row_idx]):
if value.strip():
occupied.add((row_num, col_numbers[col_idx]))
scan_bounds = _scan_bounds(grid)
for merge_ref in _merged_ranges(layout):
try:
bounds = parse_range(merge_ref)
except ValueError:
continue
if scan_bounds is None:
continue
anchor_in_scan = (
scan_bounds.start_row <= bounds.start_row <= scan_bounds.end_row
and scan_bounds.start_col <= bounds.start_col <= scan_bounds.end_col
)
if anchor_in_scan and (bounds.start_row, bounds.start_col) not in occupied:
continue
if not anchor_in_scan and merge_ref not in (confirmed_external_merges or set()):
continue
sr = max(bounds.start_row, scan_bounds.start_row)
er = min(bounds.end_row, scan_bounds.end_row)
sc = max(bounds.start_col, scan_bounds.start_col)
ec = min(bounds.end_col, scan_bounds.end_col)
if sr > er or sc > ec:
continue
for row in range(sr, er + 1):
for col in range(sc, ec + 1):
occupied.add((row, col))
return occupied
def _raw_components(
occupied: set[tuple[int, int]],
*,
adjacent_rows: dict[int, set[int]] | None = None,
adjacent_cols: dict[int, set[int]] | None = None,
) -> list[Component]:
remaining = set(occupied)
components = []
while remaining:
start = remaining.pop()
queue = deque([start])
cells = [start]
while queue:
row, col = queue.popleft()
vertical_rows = adjacent_rows.get(row, set()) if adjacent_rows else {row - 1, row + 1}
neighbors = [(neighbor_row, col) for neighbor_row in vertical_rows]
# Columns get the same treatment as rows: with --skip-hidden the
# returned columns can be non-consecutive (A, C when B is hidden),
# so col±1 would split visually adjacent data into two components.
horizontal_cols = adjacent_cols.get(col, set()) if adjacent_cols else {col - 1, col + 1}
neighbors.extend((row, neighbor_col) for neighbor_col in horizontal_cols)
for neighbor in neighbors:
if neighbor in remaining:
remaining.remove(neighbor)
queue.append(neighbor)
cells.append(neighbor)
rows = [cell[0] for cell in cells]
cols = [cell[1] for cell in cells]
components.append(
Component(
RangeBounds(min(rows), min(cols), max(rows), max(cols)),
len(cells),
)
)
return components
def _box_gap_mergeable(a: RangeBounds, b: RangeBounds, gap_rows: int, gap_cols: int) -> bool:
cols_overlap = not (a.end_col < b.start_col or b.end_col < a.start_col)
rows_overlap = not (a.end_row < b.start_row or b.end_row < a.start_row)
vertical_gap = max(b.start_row - a.end_row - 1, a.start_row - b.end_row - 1, 0)
horizontal_gap = max(b.start_col - a.end_col - 1, a.start_col - b.end_col - 1, 0)
return (cols_overlap and vertical_gap <= gap_rows) or (
rows_overlap and horizontal_gap <= gap_cols
)
def merge_components(
components: list[Component], *, gap_rows: int = 1, gap_cols: int = 0
) -> list[Component]:
merged = components[:]
changed = True
while changed:
changed = False
next_components: list[Component] = []
used = [False] * len(merged)
for i, comp in enumerate(merged):
if used[i]:
continue
current = Component(comp.bounds, comp.occupied_count)
used[i] = True
for j in range(i + 1, len(merged)):
if used[j]:
continue
other = merged[j]
if _box_gap_mergeable(current.bounds, other.bounds, gap_rows, gap_cols):
current = Component(
range_union(current.bounds, other.bounds),
current.occupied_count + other.occupied_count,
)
used[j] = True
changed = True
next_components.append(current)
merged = next_components
return merged
def _row_values(grid: CsvGrid, bounds: RangeBounds, row_num: int) -> list[str]:
if row_num not in grid.row_numbers:
return []
row = grid.values[grid.row_numbers.index(row_num)]
values = []
for col_idx, col_letter in enumerate(grid.col_letters):
col_num = col_to_index(col_letter)
if bounds.start_col <= col_num <= bounds.end_col:
values.append(row[col_idx] if col_idx < len(row) else "")
return values
def header_candidates(grid: CsvGrid, bounds: RangeBounds) -> list[int]:
candidates = []
for row in range(bounds.start_row, min(bounds.end_row, bounds.start_row + 4) + 1):
values = _row_values(grid, bounds, row)
non_empty = [value for value in values if value.strip()]
if len(non_empty) >= max(1, min(2, bounds.col_count)):
candidates.append(row)
return candidates
def kind_guess(bounds: RangeBounds, density: float) -> str:
if bounds.row_count >= 3 and bounds.col_count >= 2 and density >= 0.25:
return "data_table"
if bounds.row_count <= 2 or bounds.col_count <= 1:
return "note_or_label"
if density < 0.25:
return "sparse_block"
return "summary_block"
def summarize_components(grid: CsvGrid, components: list[Component], min_cells: int) -> list[dict[str, Any]]:
result = []
for idx, comp in enumerate(
sorted(components, key=lambda item: (item.bounds.start_row, item.bounds.start_col)),
start=1,
):
if comp.occupied_count < min_cells:
continue
area = comp.bounds.row_count * comp.bounds.col_count
density = comp.occupied_count / area if area else 0
samples = []
for row in range(comp.bounds.start_row, min(comp.bounds.end_row, comp.bounds.start_row + 2) + 1):
samples.append(_row_values(grid, comp.bounds, row))
result.append(
{
"id": f"T{idx}",
"range": format_range(
comp.bounds.start_row,
comp.bounds.start_col,
comp.bounds.end_row,
comp.bounds.end_col,
),
"rows": comp.bounds.row_count,
"cols": comp.bounds.col_count,
"occupied_cells": comp.occupied_count,
"density": round(density, 4),
"header_candidates": header_candidates(grid, comp.bounds),
"kind_guess": kind_guess(comp.bounds, density),
"sample": samples,
}
)
return result
def detect_subtables(args) -> tuple[dict[str, Any], list[str]]:
warnings: list[str] = []
workbook = envelope_data(
run_sheets(
"+workbook-info",
url=args.url,
spreadsheet_token=args.spreadsheet_token,
timeout=args.timeout,
)
)
sheet = resolve_target_sheets(
workbook,
sheet_id=args.sheet_id,
sheet_name=args.sheet_name,
require_one=True,
)[0]
sid = sheet_identifier(sheet)
title = sheet_title(sheet)
locator = sheet_locator(sheet)
col_count = min(int(sheet.get("column_count") or args.max_scan_cols), args.max_scan_cols)
row_count = min(int(sheet.get("row_count") or args.max_scan_rows), args.max_scan_rows)
scan_range = args.range or f"A1:{index_to_col(max(1, col_count))}{max(1, row_count)}"
if not args.range:
if int(sheet.get("column_count") or 0) > args.max_scan_cols:
warnings.append(f"scan clipped to first {args.max_scan_cols} columns")
if int(sheet.get("row_count") or 0) > args.max_scan_rows:
warnings.append(f"scan clipped to first {args.max_scan_rows} rows")
layout = envelope_data(
run_sheets(
"+sheet-info",
url=args.url,
spreadsheet_token=args.spreadsheet_token,
**locator,
flags={"include": "merges,hidden_rows,hidden_cols"},
timeout=args.timeout,
)
)
csv_data = envelope_data(
run_sheets(
"+csv-get",
url=args.url,
spreadsheet_token=args.spreadsheet_token,
**locator,
flags={
"range": scan_range,
"max_chars": args.max_chars,
"skip_hidden": True if args.skip_hidden else None,
},
timeout=args.timeout,
)
)
actual_range = str(csv_data.get("actual_range") or scan_range)
if csv_data.get("has_more"):
raise LarkCliError(
f"+csv-get truncated the scan range at {actual_range}; narrow --range before detecting subtables"
)
grid = parse_annotated_csv(
csv_data.get("annotated_csv", ""),
csv_data.get("col_indices"),
csv_data.get("row_indices"),
actual_range,
)
if grid.row_numbers_inferred:
warnings.append("CSV row numbers were inferred from the requested range")
hidden_rows_raw = layout.get("hidden_rows") or []
hidden_row_indexes = {
int(value) + 1
for value in hidden_rows_raw
if isinstance(value, (int, str)) and str(value).isdigit()
}
hidden_columns_raw = layout.get("hidden_cols") or layout.get("hidden_columns") or []
hidden_col_letters = set()
for value in hidden_columns_raw if isinstance(hidden_columns_raw, list) else []:
if isinstance(value, str) and value.isalpha():
hidden_col_letters.add(value.upper())
elif isinstance(value, (int, str)) and str(value).isdigit():
hidden_col_letters.add(index_to_col(int(value) + 1))
hidden_rows = sorted(row for row in grid.row_numbers if row in hidden_row_indexes)
hidden_columns = [col for col in grid.col_letters if col.upper() in hidden_col_letters]
if hidden_rows or hidden_columns:
warnings.append("scan includes hidden rows or columns; pass --skip-hidden to exclude them")
confirmed_external_merges = set()
external_merge_anchors = list(_external_merge_anchors(grid, layout).items())
if len(external_merge_anchors) > MAX_EXTERNAL_MERGE_ANCHOR_CHECKS:
warnings.append(
f"skipped confirmation for {len(external_merge_anchors) - MAX_EXTERNAL_MERGE_ANCHOR_CHECKS} "
f"external merge anchors (limit: {MAX_EXTERNAL_MERGE_ANCHOR_CHECKS})"
)
for merge_ref, anchor in external_merge_anchors[:MAX_EXTERNAL_MERGE_ANCHOR_CHECKS]:
try:
anchor_data = envelope_data(
run_sheets(
"+csv-get",
url=args.url,
spreadsheet_token=args.spreadsheet_token,
**locator,
flags={
"range": anchor,
"max_chars": 1024,
"skip_hidden": True if args.skip_hidden else None,
},
timeout=args.timeout,
)
)
anchor_grid = parse_annotated_csv(
anchor_data.get("annotated_csv", ""),
anchor_data.get("col_indices"),
anchor_data.get("row_indices"),
anchor,
)
if _has_value(anchor_grid):
confirmed_external_merges.add(merge_ref)
except (LarkCliError, ValueError) as exc:
warnings.append(f"could not confirm merge anchor {anchor}: {exc}")
occupied = build_occupancy(
grid,
layout,
confirmed_external_merges=confirmed_external_merges,
)
adjacent_rows = None
adjacent_cols = None
if args.skip_hidden:
adjacent_rows = {}
for previous, current in zip(grid.row_numbers, grid.row_numbers[1:]):
adjacent_rows.setdefault(previous, set()).add(current)
adjacent_rows.setdefault(current, set()).add(previous)
col_numbers = [col_to_index(col) for col in grid.col_letters]
adjacent_cols = {}
for previous, current in zip(col_numbers, col_numbers[1:]):
adjacent_cols.setdefault(previous, set()).add(current)
adjacent_cols.setdefault(current, set()).add(previous)
raw_components = _raw_components(
occupied,
adjacent_rows=adjacent_rows,
adjacent_cols=adjacent_cols,
)
if len(raw_components) > args.max_merge_components:
warnings.append(
f"skipped gap-based component merging for {len(raw_components)} components "
f"(limit: {args.max_merge_components})"
)
components = raw_components
else:
components = merge_components(
raw_components,
gap_rows=args.gap_rows,
gap_cols=args.gap_cols,
)
subtables = summarize_components(grid, components, args.min_cells)
return {
"sheet_id": sid,
"sheet": title,
"scan_range": scan_range,
"actual_range": actual_range,
"visibility": {
"skip_hidden": args.skip_hidden,
"hidden_rows_in_range": hidden_rows,
"hidden_columns_in_range": hidden_columns,
},
"subtables": subtables,
}, warnings
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
add_spreadsheet_args(parser, require_sheet=True, allow_sheet=True)
parser.add_argument("--range")
parser.add_argument("--max-scan-rows", type=int, default=5000)
parser.add_argument("--max-scan-cols", type=int, default=200)
parser.add_argument("--gap-rows", type=int, default=1)
parser.add_argument("--gap-cols", type=int, default=0)
parser.add_argument("--min-cells", type=int, default=2)
parser.add_argument("--max-merge-components", type=int, default=2000)
parser.add_argument("--max-chars", type=int, default=25000)
parser.add_argument("--skip-hidden", action="store_true")
parser.add_argument("--timeout", type=int, default=60)
args = parser.parse_args()
try:
data, warnings = detect_subtables(args)
except (LarkCliError, ValueError, TypeError) as exc:
emit_error(ACTION, str(exc))
emit_success(ACTION, data, warnings)
if __name__ == "__main__":
main()
scripts/lark_inspect_workbook.py›
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
"""Inspect a Lark spreadsheet and emit a compact workbook profile."""
from __future__ import annotations
import argparse
from typing import Any
from lark_sheet_range import index_to_col
from lark_sheet_read_cli import (
LarkCliError,
add_spreadsheet_args,
emit_error,
emit_success,
envelope_data,
is_grid_sheet,
resolve_target_sheets,
run_sheets,
sheet_identifier,
sheet_locator,
sheet_resource_type,
sheet_title,
visible_grid_selection,
)
ACTION = "inspect_workbook"
LAYOUT_INCLUDE = "merges,row_heights,col_widths,hidden_rows,hidden_cols,groups,frozen"
def _sheet_summary(sheet: dict[str, Any]) -> dict[str, Any]:
return {
"sheet_id": sheet_identifier(sheet),
"title": sheet_title(sheet),
"index": sheet.get("index"),
"resource_type": sheet_resource_type(sheet),
"row_count": sheet.get("row_count"),
"column_count": sheet.get("column_count"),
"is_hidden": sheet.get("is_hidden"),
"merged_cells_count": sheet.get("merged_cells_count"),
"chart_count": sheet.get("chart_count"),
"pivot_table_count": sheet.get("pivot_table_count"),
"float_image_count": sheet.get("float_image_count"),
}
def _list_count(value: Any) -> int:
return len(value) if isinstance(value, list) else 0
def _layout_summary(layout: dict[str, Any]) -> dict[str, Any]:
"""Retain layout signals without serializing unbounded per-cell metadata."""
merges = layout.get("merged_cells") or layout.get("merges") or []
groups = layout.get("groups") if isinstance(layout.get("groups"), dict) else {}
row_groups = layout.get("row_groups") or groups.get("rows", [])
col_groups = layout.get("column_groups") or groups.get("columns", [])
return {
"merge_count": _list_count(merges),
"row_heights_count": _list_count(layout.get("row_heights")),
"column_widths_count": _list_count(layout.get("col_widths")),
"hidden_rows_count": _list_count(layout.get("hidden_rows")),
"hidden_columns_count": _list_count(
layout.get("hidden_cols") or layout.get("hidden_columns")
),
"row_groups_count": _list_count(row_groups),
"column_groups_count": _list_count(col_groups),
"frozen": layout.get("frozen"),
}
def inspect_workbook(args) -> tuple[dict[str, Any], list[str]]:
warnings: list[str] = []
workbook = envelope_data(
run_sheets(
"+workbook-info",
url=args.url,
spreadsheet_token=args.spreadsheet_token,
timeout=args.timeout,
)
)
# An explicit selector must resolve: without require_one a typo'd
# --sheet-id/--sheet-name silently yields sheet_count 0, which reads as
# "empty workbook" instead of a locator error.
selection = visible_grid_selection(workbook, sheet_id=args.sheet_id, sheet_name=args.sheet_name)
all_sheets = resolve_target_sheets(workbook)
target_sheets = resolve_target_sheets(
workbook,
sheet_id=args.sheet_id,
sheet_name=args.sheet_name,
require_one=bool(args.sheet_id or args.sheet_name),
)
preview_targets = target_sheets
if not args.sheet_id and not args.sheet_name:
# is_hidden 缺失不等于隐藏:预览只是只读预检,除显式 is_hidden=true 外一律纳入,
# 否则旧 payload 会静默退化成「只有 summary、零预览」。写入侧的保守可见性判定
# 仍由 selection 负责(visibility_unknown 在那里照样排除)。
preview_targets = [sheet for sheet in all_sheets if is_grid_sheet(sheet) and sheet.get("is_hidden") is not True]
if args.max_sheets < 1:
raise LarkCliError("--max-sheets must be at least 1")
inspect_count = min(len(preview_targets), args.max_sheets)
if inspect_count < len(preview_targets):
warnings.append(
f"layout and preview skipped for {len(preview_targets) - inspect_count} visible grid sheets; "
"pass --sheet-id or --sheet-name to inspect one"
)
if not preview_targets and all_sheets:
if any(is_grid_sheet(sheet) for sheet in all_sheets):
warnings.append(
"no previewable grid sheet: every grid sheet is explicitly hidden; "
"pass --sheet-id or --sheet-name to inspect one anyway"
)
else:
# 非网格子表在 visible_grid_selection 里会被显式拒绝,别提示「带 selector 重跑」
# ——照做必然拿到 "is not a grid sheet",恢复动作等于死路。
warnings.append(
"no grid sheet in this workbook: every sheet is non-grid; "
"read it through the matching product API instead of the grid read/write path"
)
# 按对象身份而非 sheet_id 圈定预览集合:payload 缺 sheet_id 时 sheet_identifier 全为
# 空串,用 id 集合会把所有子表折叠成同一个 key,--max-sheets 直接失效(照样逐表发
# +sheet-info / +csv-get)。all_sheets 与 preview_targets 取自同一份 workbook,元素是
# 同一批 dict 实例,id() 比较安全。
preview_marks = {id(sheet) for sheet in preview_targets[:inspect_count]}
profiles = []
for sheet in all_sheets:
sid = sheet_identifier(sheet)
title = sheet_title(sheet)
profile = _sheet_summary(sheet)
if id(sheet) not in preview_marks:
profiles.append(profile)
continue
locator = sheet_locator(sheet)
col_count = int(sheet.get("column_count") or args.max_preview_cols)
preview_cols = min(col_count, args.max_preview_cols)
if col_count > args.max_preview_cols:
warnings.append(
f"{title or sid}: preview clipped to first {args.max_preview_cols} columns"
)
end_col = index_to_col(max(1, preview_cols))
preview_range = f"A1:{end_col}{args.preview_rows}"
# Per-sheet, not fail-the-run: this is the first-step pre-flight, and
# one unreadable sheet (odd type, transient error, a locator that does
# not resolve) must not throw away the summaries already collected for
# every other sheet. The basic summary comes from +workbook-info and is
# already in hand, so a failure here degrades detail, not correctness —
# same call the sibling profile_table downgrades to a warning.
try:
layout = envelope_data(
run_sheets(
"+sheet-info",
url=args.url,
spreadsheet_token=args.spreadsheet_token,
**locator,
flags={"include": LAYOUT_INCLUDE},
timeout=args.timeout,
)
)
preview = envelope_data(
run_sheets(
"+csv-get",
url=args.url,
spreadsheet_token=args.spreadsheet_token,
**locator,
flags={"range": preview_range, "max_chars": args.max_chars},
timeout=args.timeout,
)
)
except LarkCliError as exc:
warnings.append(
f"{title or sid}: layout/preview unavailable ({exc}); "
"re-read this sheet on its own with --sheet-name, or use +sheet-info / +csv-get directly"
)
profiles.append(profile)
continue
if preview.get("has_more"):
warnings.append(f"{title or sid}: preview range {preview_range} was truncated")
profiles.append(
{
**profile,
"layout": _layout_summary(layout),
"preview": {
"range": preview_range,
"current_region": preview.get("current_region"),
"row_indices": preview.get("row_indices"),
"col_indices": preview.get("col_indices"),
"annotated_csv": preview.get("annotated_csv"),
"has_more": preview.get("has_more"),
},
}
)
return {"sheet_count": len(all_sheets), "sheets": profiles, "selection": selection}, warnings
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
add_spreadsheet_args(parser, require_sheet=False, allow_sheet=True)
parser.add_argument("--preview-rows", type=int, default=15)
parser.add_argument("--max-preview-cols", type=int, default=100)
parser.add_argument("--max-chars", type=int, default=8000)
parser.add_argument("--max-sheets", type=int, default=3)
parser.add_argument("--timeout", type=int, default=60)
args = parser.parse_args()
try:
data, warnings = inspect_workbook(args)
except (LarkCliError, ValueError, TypeError) as exc:
emit_error(ACTION, str(exc))
emit_success(ACTION, data, warnings)
if __name__ == "__main__":
main()
scripts/lark_profile_table.py›
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
"""Profile a candidate table range in a Lark sheet."""
from __future__ import annotations
import argparse
import re
from typing import Any
from lark_sheet_range import col_to_index, format_range, index_to_col, parse_range
from lark_sheet_read_cli import (
LarkCliError,
add_spreadsheet_args,
emit_error,
emit_success,
envelope_data,
run_sheets,
)
from lark_detect_subtables import CsvGrid, parse_annotated_csv
ACTION = "profile_table"
ERROR_VALUES = ("#VALUE!", "#DIV/0!", "#REF!", "#NAME?", "#NULL!", "#NUM!", "#N/A")
TOTAL_KEYWORDS = ("合计", "总计", "小计", "汇总", "累计")
TOTAL_EN_RE = re.compile(r"\b(?:grand total|subtotal|total)\b", re.IGNORECASE)
SIGNATURE_KEYWORDS = ("编制人", "审核人", "审批人", "负责人", "经理", "签名")
def _is_number(value: str) -> bool:
text = value.strip().replace(",", "")
if text.endswith("%"):
text = text[:-1]
try:
float(text)
return True
except ValueError:
return False
def _is_date_like(value: str) -> bool:
text = value.strip()
if re.match(r"^\d{4}[-/]\d{1,2}[-/]\d{1,2}(?:\s+\d{1,2}:\d{2}(:\d{2})?)?$", text):
return True
if re.match(r"^\d{8}$", text):
year = int(text[:4])
month = int(text[4:6])
day = int(text[6:8])
return 1900 <= year <= 2100 and 1 <= month <= 12 and 1 <= day <= 31
if re.match(r"^\d{6}$", text):
year = int(text[:4])
month = int(text[4:6])
return 1900 <= year <= 2100 and 1 <= month <= 12
return False
def _classify_value(value: str) -> str:
text = value.strip()
if not text:
return "empty"
if any(err in text for err in ERROR_VALUES):
return "error"
if re.match(r"^0\d+$", text) or re.match(r"^\d{11,}$", text):
return "string_id"
if _is_date_like(text):
return "date"
if _is_number(text):
return "number"
if text.lower() in {"true", "false", "yes", "no"}:
return "bool"
return "string"
def _row_values(grid: CsvGrid, row_index: int) -> list[str]:
return grid.values[row_index]
def _header_score(row: list[str]) -> tuple[int, int]:
non_empty = [value for value in row if value.strip()]
if not non_empty:
return 0, 0
strings = sum(1 for value in non_empty if _classify_value(value) in {"string", "string_id"})
data_like = sum(
1 for value in non_empty if _classify_value(value) in {"number", "date", "bool"}
)
return strings * 2 + len(non_empty) - data_like * 3, len(non_empty)
def _header_candidate_score(grid: CsvGrid, index: int) -> int:
score, _ = _header_score(grid.values[index])
following = grid.values[index + 1 : index + 4]
following_non_empty = [value for row in following for value in row if value.strip()]
following_data_like = sum(
1 for value in following_non_empty if _classify_value(value) in {"number", "date", "bool"}
)
# A schema row is usually followed by data-shaped values; use this as a
# small tie-breaker while preferring earlier candidates with equal evidence.
score += min(3, following_data_like)
return score - index // 2
def detect_header_row(grid: CsvGrid, scan_rows: int = 20) -> int | None:
best_row = None
best_score = None
for idx, row in enumerate(grid.values[:scan_rows]):
_, non_empty_count = _header_score(row)
if not non_empty_count:
continue
score = _header_candidate_score(grid, idx)
if best_score is None or score > best_score:
best_score = score
best_row = grid.row_numbers[idx]
return best_row
def _possible_multi_row_header(grid: CsvGrid, header_row: int | None) -> bool:
if header_row is None or header_row not in grid.row_numbers:
return False
header_index = grid.row_numbers.index(header_row)
if header_index + 1 >= len(grid.values):
return False
header_score, header_non_empty = _header_score(grid.values[header_index])
next_score, next_non_empty = _header_score(grid.values[header_index + 1])
if header_non_empty < 2 or next_non_empty < 2:
return False
return next_score >= max(4, (header_score * 3 + 3) // 4)
def _header_row_not_first(grid: CsvGrid, header_row: int | None) -> bool:
if header_row is None:
return False
first_non_empty = next(
(
row_num
for row_num, row in zip(grid.row_numbers, grid.values)
if any(value.strip() for value in row)
),
None,
)
return first_non_empty is not None and first_non_empty != header_row
def detect_special_rows(grid: CsvGrid, header_row: int | None) -> list[dict[str, Any]]:
specials = []
for idx, row in enumerate(grid.values):
row_num = grid.row_numbers[idx]
if header_row is not None and row_num <= header_row:
continue
joined = " ".join(value.strip() for value in row if value.strip())
if not joined:
specials.append({"row": row_num, "reason": "empty_row", "sample": ""})
continue
if any(keyword in joined for keyword in TOTAL_KEYWORDS) or TOTAL_EN_RE.search(joined):
specials.append({"row": row_num, "reason": "total_row", "sample": joined[:120]})
elif any(keyword in joined for keyword in SIGNATURE_KEYWORDS):
specials.append({"row": row_num, "reason": "signature_row", "sample": joined[:120]})
return specials
def _last_data_row(grid: CsvGrid, header_row: int | None, specials: list[dict[str, Any]]) -> int | None:
special_rows = {item["row"] for item in specials if item["reason"] != "empty_row"}
for idx in range(len(grid.row_numbers) - 1, -1, -1):
row_num = grid.row_numbers[idx]
if header_row is not None and row_num <= header_row:
continue
if row_num in special_rows:
continue
if any(value.strip() for value in grid.values[idx]):
return row_num
return None
def _column_values(grid: CsvGrid, col_index: int, start_row: int, end_row: int | None) -> list[str]:
values = []
for idx, row_num in enumerate(grid.row_numbers):
if row_num < start_row:
continue
if end_row is not None and row_num > end_row:
continue
row = grid.values[idx]
values.append(row[col_index] if col_index < len(row) else "")
return values
def _type_guess(type_counts: dict[str, int]) -> str:
non_empty_counts = {key: value for key, value in type_counts.items() if key != "empty" and value}
if not non_empty_counts:
return "empty"
if len(non_empty_counts) == 1:
return next(iter(non_empty_counts))
if "error" in non_empty_counts:
return "mixed_with_errors"
if "string_id" in non_empty_counts and set(non_empty_counts) <= {"string_id", "string"}:
return "string_id"
return "mixed"
def profile_columns(
grid: CsvGrid,
*,
header_row: int | None,
data_start_row: int,
data_end_row: int | None,
) -> list[dict[str, Any]]:
header_values = []
if header_row in grid.row_numbers:
header_values = _row_values(grid, grid.row_numbers.index(header_row))
profiles = []
for idx, col in enumerate(grid.col_letters):
name = ""
if idx < len(header_values):
name = header_values[idx].strip()
if not name:
name = f"unnamed_{col}"
values = _column_values(grid, idx, data_start_row, data_end_row)
type_counts: dict[str, int] = {}
examples = []
error_count = 0
for value in values:
value_type = _classify_value(value)
type_counts[value_type] = type_counts.get(value_type, 0) + 1
if value_type == "error":
error_count += 1
if value.strip() and len(examples) < 5:
examples.append(value)
non_empty = len(values) - type_counts.get("empty", 0)
warnings = []
if type_counts.get("string_id"):
warnings.append("long_numeric_like_id")
if error_count:
warnings.append("formula_or_value_errors")
if non_empty and type_counts.get("empty", 0) / len(values) > 0.5:
warnings.append("many_empty_cells")
if _type_guess(type_counts) == "mixed":
warnings.append("mixed_value_types")
profiles.append(
{
"name": name,
"col": col,
"non_empty": non_empty,
"empty": type_counts.get("empty", 0),
"type_guess": _type_guess(type_counts),
"type_distribution": type_counts,
"examples": examples,
"warnings": warnings,
}
)
return profiles
def _field_map(columns: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
result: dict[str, list[dict[str, Any]]] = {}
for column in columns:
name = column["name"]
result.setdefault(name, []).append(
{
"col": column["col"],
"type_guess": column["type_guess"],
"warnings": column["warnings"],
}
)
return result
def _risk_warnings(
*,
columns: list[dict[str, Any]],
special_rows: list[dict[str, Any]],
header_row: int | None,
data_range: str | None,
possible_multi_row_header: bool,
header_row_not_first: bool,
data_range_has_gaps: bool,
data_range_has_col_gaps: bool,
hidden_rows: list[int],
hidden_columns: list[str],
) -> list[str]:
warnings = {warning for column in columns for warning in column["warnings"]}
names = [column["name"] for column in columns]
if len(names) != len(set(names)):
warnings.add("duplicate_headers")
if any(name.startswith("unnamed_") for name in names):
warnings.add("unnamed_columns")
if header_row is None:
warnings.add("header_not_detected")
if data_range is None:
warnings.add("data_range_not_detected")
if any(item["reason"] != "empty_row" for item in special_rows):
warnings.add("special_rows_present")
if any(item["reason"] == "empty_row" for item in special_rows):
warnings.add("empty_rows_present")
if possible_multi_row_header:
warnings.add("possible_multi_row_header")
if header_row_not_first:
warnings.add("header_row_not_first")
if data_range_has_gaps:
warnings.add("data_range_has_gaps")
if data_range_has_col_gaps:
warnings.add("data_range_has_col_gaps")
if hidden_rows:
warnings.add("hidden_rows_in_range")
if hidden_columns:
warnings.add("hidden_columns_in_range")
return sorted(warnings)
def _col_segments(col_letters: list[str]) -> list[list[str]]:
"""Group column letters into contiguous runs of real column indices.
With --skip-hidden the returned columns can be non-consecutive (A, C when B
is hidden). Reporting a single data_range across that gap would let a caller
write the hidden-column-free data back as if it were contiguous, shifting
every value right of the gap.
"""
if not col_letters:
return []
indices = [col_to_index(col) for col in col_letters]
segments = []
start = previous = indices[0]
for current in indices[1:]:
if current == previous + 1:
previous = current
continue
segments.append([index_to_col(start), index_to_col(previous)])
start = previous = current
segments.append([index_to_col(start), index_to_col(previous)])
return segments
def _row_segments(row_numbers: list[int]) -> list[list[int]]:
if not row_numbers:
return []
segments = []
start = end = row_numbers[0]
for row in row_numbers[1:]:
if row == end + 1:
end = row
continue
segments.append([start, end])
start = end = row
segments.append([start, end])
return segments
def _write_hints(
grid: CsvGrid,
*,
header_row: int | None,
data_start: int,
bounds,
hidden_columns: list[str] | None = None,
skip_hidden: bool = False,
) -> dict[str, Any]:
last_non_empty_col = None
for idx, col in enumerate(grid.col_letters):
if any(idx < len(row) and row[idx].strip() for row in grid.values):
last_non_empty_col = col_to_index(col)
safe_col_num = (last_non_empty_col + 1) if last_non_empty_col else bounds.start_col
# Step over hidden columns. Under --skip-hidden they are absent from the
# grid entirely, so "one past the last visible column" can land ON a hidden
# column that holds data — and when the hidden columns sit at the right edge
# there is no gap in the returned letters either, so data_range_has_col_gaps
# stays silent too. Appending there would overwrite data nobody can see.
hidden_indices = {col_to_index(col) for col in (hidden_columns or [])}
skipped_hidden: list[str] = []
while safe_col_num in hidden_indices:
skipped_hidden.append(index_to_col(safe_col_num))
safe_col_num += 1
safe_col = index_to_col(safe_col_num)
hints = {
"last_non_empty_col": index_to_col(last_non_empty_col) if last_non_empty_col else None,
}
if not (skip_hidden and hidden_columns is None):
hints.update(
{
"safe_append_col": safe_col,
"safe_append_header_cell": f"{safe_col}{header_row}" if header_row else None,
"safe_append_data_start_cell": f"{safe_col}{data_start}",
}
)
if skipped_hidden:
hints["skipped_hidden_cols"] = skipped_hidden
return hints
def profile_grid(
grid: CsvGrid,
source_range: str,
*,
skip_hidden: bool = False,
hidden_rows: list[int] | None = None,
hidden_columns: list[str] | None = None,
all_hidden_columns: list[str] | None = None,
header_scan_rows: int = 20,
) -> dict[str, Any]:
max_row = max(grid.row_numbers, default=1)
max_col = max((col_to_index(col) for col in grid.col_letters), default=1)
bounds = parse_range(source_range, max_row=max_row, max_col=max_col)
header_row = detect_header_row(grid, header_scan_rows)
data_start = (header_row + 1) if header_row else bounds.start_row
specials = detect_special_rows(grid, header_row)
data_end = _last_data_row(grid, header_row, specials)
data_range = None
if data_end is not None and data_end >= data_start:
data_range = format_range(data_start, bounds.start_col, data_end, bounds.end_col)
columns = profile_columns(
grid,
header_row=header_row,
data_start_row=data_start,
data_end_row=data_end,
)
data_row_numbers = []
if data_end is not None and data_end >= data_start:
data_row_numbers = [
row for row in grid.row_numbers if data_start <= row <= data_end
]
data_row_segments = _row_segments(data_row_numbers)
data_range_has_gaps = bool(data_row_numbers) and (
data_row_numbers[0] != data_start or len(data_row_segments) > 1
)
data_rows = len(data_row_numbers)
data_col_segments = _col_segments(grid.col_letters)
data_range_has_col_gaps = len(data_col_segments) > 1
hidden_rows = hidden_rows or []
hidden_columns = hidden_columns or []
possible_multi_row_header = _possible_multi_row_header(grid, header_row)
header_row_not_first = _header_row_not_first(grid, header_row)
return {
"summary": {
"range": source_range,
"header_row": header_row,
"data_range": data_range,
"data_rows": data_rows,
"data_row_segments": data_row_segments,
"data_col_segments": data_col_segments,
"column_count": len(columns),
"special_rows_count": len(specials),
},
"range": source_range,
"header_row": header_row,
"data_range": data_range,
"data_row_segments": data_row_segments,
"data_col_segments": data_col_segments,
"columns": columns,
"field_map": _field_map(columns),
"risk_warnings": _risk_warnings(
columns=columns,
special_rows=specials,
header_row=header_row,
data_range=data_range,
possible_multi_row_header=possible_multi_row_header,
header_row_not_first=header_row_not_first,
data_range_has_gaps=data_range_has_gaps,
data_range_has_col_gaps=data_range_has_col_gaps,
hidden_rows=hidden_rows,
hidden_columns=hidden_columns,
),
"visibility": {
"skip_hidden": skip_hidden,
"hidden_rows_in_range": hidden_rows,
"hidden_columns_in_range": hidden_columns,
},
"write_hints": _write_hints(
grid,
header_row=header_row,
data_start=data_start,
bounds=bounds,
hidden_columns=all_hidden_columns,
skip_hidden=skip_hidden,
),
"special_rows": specials,
}
def _hidden_rows_and_columns(grid: CsvGrid, layout: dict[str, Any]) -> tuple[list[int], list[str]]:
def indexes(key: str) -> set[int]:
values = layout.get(key)
if not isinstance(values, list):
return set()
result = set()
for value in values:
try:
result.add(int(value) + 1) # +sheet-info uses zero-based indices.
except (TypeError, ValueError):
continue
return result
hidden_row_indexes = indexes("hidden_rows")
hidden_columns = layout.get("hidden_cols") or layout.get("hidden_columns") or []
hidden_column_letters = set()
for value in hidden_columns if isinstance(hidden_columns, list) else []:
if isinstance(value, str) and value.isalpha():
hidden_column_letters.add(value.upper())
else:
try:
hidden_column_letters.add(index_to_col(int(value) + 1))
except (TypeError, ValueError):
continue
rows = sorted(row for row in grid.row_numbers if row in hidden_row_indexes)
columns = [col for col in grid.col_letters if col.upper() in hidden_column_letters]
return rows, columns
def _all_hidden_columns(layout: dict[str, Any]) -> list[str]:
"""Every hidden column on the sheet, not just those inside the grid.
_hidden_rows_and_columns intersects with the returned grid, which is the
right scope for the "there are hidden rows/columns in what you read"
warnings. The append hint needs the opposite: the columns that are NOT in
the grid precisely because they are hidden.
"""
letters: list[str] = []
raw = layout.get("hidden_cols") or layout.get("hidden_columns") or []
for value in raw if isinstance(raw, list) else []:
if isinstance(value, str) and value.isalpha():
letters.append(value.upper())
continue
try:
letters.append(index_to_col(int(value) + 1))
except (TypeError, ValueError):
continue
return letters
def profile_table(args) -> tuple[dict[str, Any], list[str]]:
warnings = []
if args.header_scan_rows < 1:
raise ValueError("--header-scan-rows must be at least 1")
csv_data = envelope_data(
run_sheets(
"+csv-get",
url=args.url,
spreadsheet_token=args.spreadsheet_token,
sheet_id=args.sheet_id,
sheet_name=args.sheet_name,
flags={
"range": args.range,
"max_chars": args.max_chars,
"skip_hidden": True if args.skip_hidden else None,
},
timeout=args.timeout,
)
)
source_range = str(csv_data.get("actual_range") or args.range)
if csv_data.get("has_more"):
raise LarkCliError(
f"+csv-get truncated the requested range at {source_range}; narrow --range before profiling"
)
grid = parse_annotated_csv(
csv_data.get("annotated_csv", ""),
csv_data.get("col_indices"),
csv_data.get("row_indices"),
source_range,
)
if grid.row_numbers_inferred:
warnings.append("CSV row numbers were inferred from the requested range")
hidden_rows: list[int] = []
hidden_columns: list[str] = []
all_hidden_columns: list[str] | None = None
# Fetched in BOTH modes. Under --skip-hidden the hidden rows/columns are
# absent from the grid, so hidden_rows/hidden_columns (which are scoped to
# what the grid contains) come back empty and no warning fires — but the
# write hints still need to know where the hidden columns are, or
# safe_append_col can point at one that holds data. all_hidden_columns is
# the unscoped list used for exactly that.
try:
layout = envelope_data(
run_sheets(
"+sheet-info",
url=args.url,
spreadsheet_token=args.spreadsheet_token,
sheet_id=args.sheet_id,
sheet_name=args.sheet_name,
flags={"include": "hidden_rows,hidden_cols"},
timeout=args.timeout,
)
)
hidden_rows, hidden_columns = _hidden_rows_and_columns(grid, layout)
all_hidden_columns = _all_hidden_columns(layout)
except LarkCliError as exc:
warnings.append(f"hidden row/column detection unavailable: {exc}")
return profile_grid(
grid,
source_range,
skip_hidden=args.skip_hidden,
hidden_rows=hidden_rows,
hidden_columns=hidden_columns,
all_hidden_columns=all_hidden_columns,
header_scan_rows=args.header_scan_rows,
), warnings
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
add_spreadsheet_args(parser, require_sheet=True, allow_sheet=True)
parser.add_argument("--range", required=True)
parser.add_argument("--max-chars", type=int, default=25000)
parser.add_argument("--header-scan-rows", type=int, default=20)
parser.add_argument("--skip-hidden", action="store_true")
parser.add_argument("--timeout", type=int, default=60)
args = parser.parse_args()
try:
data, warnings = profile_table(args)
except (LarkCliError, ValueError, TypeError) as exc:
emit_error(ACTION, str(exc))
emit_success(ACTION, data, warnings)
if __name__ == "__main__":
main()
scripts/lark_sheet_range.py›
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
"""Range and coordinate helpers for Lark Sheet scripts."""
from __future__ import annotations
import re
from dataclasses import dataclass
CELL_RE = re.compile(r"^\$?([A-Za-z]+)\$?([1-9][0-9]*)$")
ROW_RE = re.compile(r"^\$?([1-9][0-9]*)$")
COLUMN_RE = re.compile(r"^\$?([A-Za-z]+)$")
@dataclass(frozen=True)
class RangeBounds:
start_row: int
start_col: int
end_row: int
end_col: int
@property
def row_count(self) -> int:
return self.end_row - self.start_row + 1
@property
def col_count(self) -> int:
return self.end_col - self.start_col + 1
def col_to_index(col: str) -> int:
value = 0
for char in col.strip().upper():
if not ("A" <= char <= "Z"):
raise ValueError(f"Invalid column: {col}")
value = value * 26 + (ord(char) - ord("A") + 1)
if value <= 0:
raise ValueError(f"Invalid column: {col}")
return value
def index_to_col(index: int) -> str:
if index < 1:
raise ValueError(f"Column index must be >= 1: {index}")
chars = []
n = index
while n:
n, rem = divmod(n - 1, 26)
chars.append(chr(ord("A") + rem))
return "".join(reversed(chars))
def parse_cell(cell_ref: str) -> tuple[int, int]:
match = CELL_RE.match(cell_ref.strip())
if not match:
raise ValueError(f"Invalid cell reference: {cell_ref}")
col, row = match.groups()
return int(row), col_to_index(col)
def _parse_endpoint(endpoint: str) -> tuple[str, int, int] | tuple[str, int]:
cell = CELL_RE.match(endpoint)
if cell:
col, row = cell.groups()
return "cell", int(row), col_to_index(col)
row = ROW_RE.match(endpoint)
if row:
return "row", int(row.group(1))
column = COLUMN_RE.match(endpoint)
if column:
return "column", col_to_index(column.group(1))
raise ValueError(f"Invalid A1 range endpoint: {endpoint}")
def parse_range(
range_ref: str,
*,
max_row: int | None = None,
max_col: int | None = None,
) -> RangeBounds:
"""Parse the A1 range forms accepted by ``+csv-get``.
Open-ended forms need the caller's actual returned grid dimensions. This
keeps generated ranges finite without guessing a spreadsheet-wide limit.
"""
ref = range_ref.strip()
if "!" in ref:
_, ref = ref.rsplit("!", 1)
if not ref:
raise ValueError(f"Invalid A1 range: {range_ref}")
parts = ref.split(":")
if len(parts) > 2:
raise ValueError(f"Invalid A1 range: {range_ref}")
start = _parse_endpoint(parts[0])
end = _parse_endpoint(parts[-1])
if len(parts) == 1:
if start[0] != "cell":
raise ValueError(f"A1 range must include a cell or ':' separator: {range_ref}")
_, row, col = start
return RangeBounds(row, col, row, col)
if start[0] == end[0] == "cell":
_, start_row, start_col = start
_, end_row, end_col = end
elif start[0] == end[0] == "row":
_, start_row = start
_, end_row = end
start_col = 1
if max_col is None:
raise ValueError(f"Range needs a maximum column: {range_ref}")
end_col = max_col
elif start[0] == end[0] == "column":
_, start_col = start
_, end_col = end
start_row = 1
if max_row is None:
raise ValueError(f"Range needs a maximum row: {range_ref}")
end_row = max_row
elif start[0] == "cell" and end[0] == "column":
_, start_row, start_col = start
_, end_col = end
if max_row is None:
raise ValueError(f"Range needs a maximum row: {range_ref}")
end_row = max(start_row, max_row)
elif start[0] == "cell" and end[0] == "row":
_, start_row, start_col = start
_, end_row = end
if max_col is None:
raise ValueError(f"Range needs a maximum column: {range_ref}")
end_col = max(start_col, max_col)
else:
raise ValueError(f"Invalid A1 range: {range_ref}")
return RangeBounds(min(start_row, end_row), min(start_col, end_col), max(start_row, end_row), max(start_col, end_col))
def format_cell(row: int, col: int) -> str:
if row < 1:
raise ValueError(f"Row must be >= 1: {row}")
return f"{index_to_col(col)}{row}"
def format_range(start_row: int, start_col: int, end_row: int, end_col: int) -> str:
bounds = RangeBounds(
min(start_row, end_row),
min(start_col, end_col),
max(start_row, end_row),
max(start_col, end_col),
)
start = format_cell(bounds.start_row, bounds.start_col)
end = format_cell(bounds.end_row, bounds.end_col)
return start if start == end else f"{start}:{end}"
def iter_cells(bounds: RangeBounds):
for row in range(bounds.start_row, bounds.end_row + 1):
for col in range(bounds.start_col, bounds.end_col + 1):
yield row, col
def ranges_intersect(a: RangeBounds, b: RangeBounds) -> bool:
return not (
a.end_row < b.start_row
or b.end_row < a.start_row
or a.end_col < b.start_col
or b.end_col < a.start_col
)
def range_union(a: RangeBounds, b: RangeBounds) -> RangeBounds:
return RangeBounds(
min(a.start_row, b.start_row),
min(a.start_col, b.start_col),
max(a.end_row, b.end_row),
max(a.end_col, b.end_col),
)
scripts/lark_sheet_read_cli.py›
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
"""Read-only Lark Sheet subset wrapper for the helper scripts."""
from __future__ import annotations
import json
import subprocess
import sys
from typing import Any
class LarkCliError(RuntimeError):
def __init__(self, message: str, *, cmd: list[str] | None = None):
super().__init__(message)
self.cmd = cmd or []
def add_spreadsheet_args(
parser,
*,
require_sheet: bool = False,
allow_sheet: bool = True,
) -> None:
spreadsheet = parser.add_mutually_exclusive_group(required=True)
spreadsheet.add_argument("--url")
spreadsheet.add_argument("--spreadsheet-token")
if allow_sheet:
sheet = parser.add_mutually_exclusive_group(required=require_sheet)
sheet.add_argument("--sheet-id")
sheet.add_argument("--sheet-name")
def _append_flag(cmd: list[str], name: str, value: Any) -> None:
flag = f"--{name.replace('_', '-')}"
if value is None:
return
if isinstance(value, bool):
cmd.append(flag if value else f"{flag}=false")
return
cmd.extend([flag, str(value)])
def run_sheets(
shortcut: str,
*,
url: str | None = None,
spreadsheet_token: str | None = None,
sheet_id: str | None = None,
sheet_name: str | None = None,
flags: dict[str, Any] | None = None,
timeout: int = 60,
) -> dict[str, Any]:
if bool(url) == bool(spreadsheet_token):
raise LarkCliError("Pass exactly one of --url or --spreadsheet-token")
if sheet_id and sheet_name:
raise LarkCliError("Pass only one of --sheet-id or --sheet-name")
cmd = ["lark-cli", "sheets", shortcut]
_append_flag(cmd, "url", url)
_append_flag(cmd, "spreadsheet_token", spreadsheet_token)
_append_flag(cmd, "sheet_id", sheet_id)
_append_flag(cmd, "sheet_name", sheet_name)
for key, value in (flags or {}).items():
_append_flag(cmd, key, value)
try:
completed = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
except FileNotFoundError as exc:
raise LarkCliError("lark-cli not found", cmd=cmd) from exc
except subprocess.TimeoutExpired as exc:
raise LarkCliError(f"lark-cli timed out after {timeout}s", cmd=cmd) from exc
if completed.returncode != 0:
detail = (completed.stderr or completed.stdout or "").strip()
raise LarkCliError(detail or f"lark-cli exited with {completed.returncode}", cmd=cmd)
try:
envelope = json.loads(completed.stdout)
except json.JSONDecodeError as exc:
snippet = completed.stdout[:500].replace("\n", "\\n")
raise LarkCliError(f"lark-cli stdout was not JSON: {snippet}", cmd=cmd) from exc
if isinstance(envelope, dict) and envelope.get("ok") is False:
raise LarkCliError(json.dumps(envelope, ensure_ascii=False), cmd=cmd)
if not isinstance(envelope, dict):
raise LarkCliError("lark-cli returned a non-object JSON payload", cmd=cmd)
return envelope
def envelope_data(envelope: dict[str, Any]) -> dict[str, Any]:
data = envelope.get("data")
return data if isinstance(data, dict) else envelope
def emit_success(action: str, data: dict[str, Any], warnings: list[str] | None = None) -> None:
print(
json.dumps(
{
"ok": True,
"engine": "lark",
"action": action,
"data": data,
"warnings": warnings or [],
},
ensure_ascii=False,
indent=2,
)
)
def emit_error(action: str, message: str, warnings: list[str] | None = None) -> None:
print(
json.dumps(
{
"ok": False,
"engine": "lark",
"action": action,
"error": message,
"warnings": warnings or [],
},
ensure_ascii=False,
indent=2,
)
)
sys.exit(1)
def sheet_title(sheet: dict[str, Any]) -> str:
return str(sheet.get("title") or sheet.get("sheet_name") or sheet.get("name") or "")
def sheet_identifier(sheet: dict[str, Any]) -> str:
return str(sheet.get("sheet_id") or sheet.get("id") or "")
def sheet_locator(sheet: dict[str, Any]) -> dict[str, str]:
sid = sheet_identifier(sheet)
if sid:
return {"sheet_id": sid}
title = sheet_title(sheet)
if title:
return {"sheet_name": title}
return {}
def extract_sheets(workbook_data: dict[str, Any]) -> list[dict[str, Any]]:
sheets = workbook_data.get("sheets")
if isinstance(sheets, list):
return [sheet for sheet in sheets if isinstance(sheet, dict)]
workbook = workbook_data.get("workbook")
if isinstance(workbook, dict) and isinstance(workbook.get("sheets"), list):
return [sheet for sheet in workbook["sheets"] if isinstance(sheet, dict)]
return []
def sheet_resource_type(sheet: dict[str, Any]) -> str:
return str(sheet.get("resource_type") or "")
def is_grid_sheet(sheet: dict[str, Any]) -> bool:
resource_type = sheet_resource_type(sheet)
if resource_type == "sheet":
return True
# Legacy responses may omit resource_type but still include grid dimensions.
return not resource_type and sheet.get("row_count") is not None and sheet.get("column_count") is not None
def visible_grid_selection(
workbook_data: dict[str, Any],
*,
sheet_id: str | None = None,
sheet_name: str | None = None,
) -> dict[str, Any]:
"""Resolve explicit targets or publish a safe default-selection decision.
Unspecified targets are never selected by index: only visible ordinary grids
participate, and multiple candidates remain ambiguous for the caller to match
by task wording or ask the user.
"""
sheets = extract_sheets(workbook_data)
specified = {"sheet_id": sheet_id, "sheet_name": sheet_name}
if sheet_id or sheet_name:
matches = resolve_target_sheets(workbook_data, sheet_id=sheet_id, sheet_name=sheet_name, require_one=True)
sheet = matches[0]
if not is_grid_sheet(sheet):
raise LarkCliError(f"Sheet {sheet_title(sheet) or sheet_identifier(sheet)} is not a grid sheet; use the matching product API")
warnings = ["explicit_hidden_sheet"] if sheet.get("is_hidden") is True else []
return {"policy": "visible_grid_v1", "specified": specified, "selected": _selection_sheet(sheet), "candidates": [_selection_sheet(sheet)], "excluded": [], "ambiguous": False, "warnings": warnings, "next_action": "use selected sheet_id for grid read/write"}
candidates, excluded = [], []
for sheet in sheets:
if not is_grid_sheet(sheet):
excluded.append({**_selection_sheet(sheet), "reason": "non_grid"})
elif sheet.get("is_hidden") is True:
excluded.append({**_selection_sheet(sheet), "reason": "hidden"})
elif sheet.get("is_hidden") is not False:
excluded.append({**_selection_sheet(sheet), "reason": "visibility_unknown"})
else:
candidates.append(_selection_sheet(sheet))
candidates.sort(key=lambda item: item.get("index") if isinstance(item.get("index"), int) else 10**9)
selected = candidates[0] if len(candidates) == 1 else None
return {"policy": "visible_grid_v1", "specified": specified, "selected": selected, "candidates": candidates, "excluded": excluded, "ambiguous": len(candidates) > 1, "warnings": [], "next_action": "use selected sheet_id for grid read/write" if selected else "match task wording/title/header; do not choose by index"}
def _selection_sheet(sheet: dict[str, Any]) -> dict[str, Any]:
return {"sheet_id": sheet_identifier(sheet), "title": sheet_title(sheet), "index": sheet.get("index"), "resource_type": sheet_resource_type(sheet) or "legacy_grid", "is_hidden": sheet.get("is_hidden"), "row_count": sheet.get("row_count"), "column_count": sheet.get("column_count")}
def resolve_target_sheets(
workbook_data: dict[str, Any],
*,
sheet_id: str | None = None,
sheet_name: str | None = None,
require_one: bool = False,
) -> list[dict[str, Any]]:
sheets = extract_sheets(workbook_data)
if sheet_id:
matches = [sheet for sheet in sheets if sheet_identifier(sheet) == sheet_id]
elif sheet_name:
matches = [sheet for sheet in sheets if sheet_title(sheet) == sheet_name]
else:
matches = sheets
if require_one:
if len(matches) == 1:
return matches
if not matches:
raise LarkCliError("No matching sheet found")
raise LarkCliError("Multiple sheets matched; pass --sheet-id or --sheet-name")
return matches
scripts/lark_sheets_df.py›
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
"""DataFrame ↔ Feishu Sheet typed-JSON helpers.
This is the same 7-line snippet the skill docs already inline (see
`lark-sheets-write-cells` "DataFrame → 协议(5 行 helper)" and
`lark-sheets-read-data` "输出 → DataFrame(2 行 helper)"), pulled out
so callers can `import` it instead of copy-pasting:
from lark_sheets_df import df_to_sheet, sheet_to_df
Callers run lark-cli themselves; this file is a library, not a CLI.
"""
import json
import pandas as pd
def df_to_sheet(df, name, formats=None):
"""Pack one DataFrame into one entry of a `+table-put --sheets` payload."""
packed = json.loads(df.to_json(orient="split", date_format="iso"))
# The protocol requires string column names. pandas keeps integer labels
# (e.g. the default RangeIndex columns 0/1/2) as JSON numbers, while the
# dtypes dict keys get stringified during JSON serialization — the CLI
# then rejects `columns` ("cannot unmarshal number into … type string")
# and the dtype lookup would miss anyway. Stringify every key once, and
# refuse to continue when that conversion silently merges two columns.
normalized_labels = [str(c) for c in df.columns]
columns = [str(c) for c in packed["columns"]]
if normalized_labels != columns:
columns = normalized_labels
if len(set(columns)) != len(columns):
raise ValueError(
"column labels collide after str() conversion; "
"rename the DataFrame columns before packing"
)
packed["columns"] = columns
dtype_values = list(df.dtypes)
return {
"name": name,
**packed,
"dtypes": {key: str(dtype) for key, dtype in zip(columns, dtype_values)},
**({"formats": {str(k): v for k, v in formats.items()}} if formats else {}),
}
def sheet_to_df(sheet):
"""Restore one `+table-get` sheet dict into a typed DataFrame."""
return pd.DataFrame(sheet["data"], columns=sheet["columns"]).astype(sheet["dtypes"])
SKILL.md›
---
name: lark-sheets
version: 3.5.2
description: "飞书电子表格:创建和操作电子表格。支持工作表与行列结构(增删/合并/尺寸/隐藏/冻结/分组)、单元格读写(值/公式/样式/批注/单元格图片)、区域复制移动排序填充、查找替换、批量更新,图表、透视表、条件格式、筛选器与筛选视图、下拉列表、迷你图、浮动图片等对象的创建与维护,以及公式校验、历史版本回滚、本地 Excel/CSV 与飞书表格的导入导出。当用户需要创建或编辑表格、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)、金融/财务建模(DCF、三张表、预算、Sensitivity 等)时使用。多维表格(Base/bitable)请改用 lark-base;若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive +search 先定位资源。当用户给出 doubao.com 的 /sheets/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。"
metadata:
requires:
bins: ["lark-cli"]
siblings: ["lark-shared"]
cliHelp: "lark-cli sheets --help"
---
# sheets
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理。**
## 场景 → 命令速查
> 按当前动作选行;下一步必须 Read 该行 reference,读取完成前不得执行命令。只读命中的文档;含公式 / 样式等横切动作时再读对应规范,禁止用目录枚举代替 Read。
| 你要做的事 | ✅ 正确写法 | 动手前读(先 Read 再动手) |
| --- | --- | --- |
| 读数据 | `+csv-get`(纯值/CSV)、`+cells-get`(公式/样式/批注) | 读 `references/lark-sheets-read-data.md` |
| 写入数据 | `+csv-put`(无类型歧义纯文本)、`+table-put`(typed;量值/真日期;标签/编号/前导零/文本数字用 object,禁裸 csv-put)、`+cells-set`(公式/富写入)、`+cells-set-style`(样式)、`+cells-set-image`(单元格图片) | 读 `references/lark-sheets-write-cells.md` |
| 格式继承(新列/新行) | 物理插行 / 插列用 `+dim-insert --inherit-style before\|after`;往已有空白区域扩写用 `+range-copy --paste-type formats` 先铺样式再写值 | 读 `references/lark-sheets-range-operations.md`;插行插列再读 `references/lark-sheets-sheet-structure.md` |
| 工作簿操作 | `+workbook-create`、`+workbook-info`、`+workbook-import`、`+sheet-copy`、`+revision-get`、`+workbook-export` | 读 `references/lark-sheets-workbook.md` |
| 行列操作 | 排序用 `+range-sort` 原子移动整行;合并 / 取消合并用 `+cells-merge` / `+cells-unmerge`;清空内容才用 `+cells-clear`;尺寸用 `+cols-resize` / `+rows-resize` | 读 `references/lark-sheets-range-operations.md`;涉结构布局再读 `references/lark-sheets-sheet-structure.md` |
| 美化收尾 | `+styles-put` | 读 `references/lark-sheets-styles-put.md` |
| 子表结构 | `+sheet-info`、`+dim-insert`;删整行 / 列用 `+dim-delete`,不能用 clear 代替 | 读 `references/lark-sheets-sheet-structure.md` |
| 画图表 / 可视化 / 柱状图 / 折线图 / 饼图 / 趋势 / 占比 | 单图用 `+chart-create-basic`,多图用扁平输入的 `+batch-chart-create`;改已有图的数据源用 `+chart-data-update`、配置用 `+chart-config-update`;只有语义 shortcut 表达不了的单系列 / 单数据点 / 高级字段才用 `+chart-create` / `+chart-update`,且只提交必要的局部 properties。动手前先断言每张图的类型、横轴字段、分组字段和目标张数,画完 `+chart-list` 逐项核;图片迁移成真图表后删除并复查原浮动图片 | 读 `references/lark-sheets-chart.md`;含透视 / 分组汇总再读 `references/lark-sheets-pivot-table.md` |
| 分组汇总 / 透视 | `+pivot-create` | 读 `references/lark-sheets-pivot-table.md` |
| 筛选 / 只看符合条件的行 | `+filter-create` | 读 `references/lark-sheets-filter.md` |
| 查找 / 替换文本 | `+cells-search`、`+cells-replace` | 读 `references/lark-sheets-search-replace.md` |
| 条件格式 / 条件高亮 / 数据条 / 色阶 | 随数据变化的标色用 `+cond-format-create`;固定刷色只用于用户点名要静态着色 | 读 `references/lark-sheets-conditional-format.md` |
| 插图:自由摆放的装饰 | `+float-image-create` | 读 `references/lark-sheets-float-image.md` |
| 迷你图 / 单元格内趋势线 | `+sparkline-create` | 读 `references/lark-sheets-sparkline.md` |
| 批量清除多区域 | `+cells-batch-clear` | 读 `references/lark-sheets-batch-update.md`(high-risk) |
| 复核编辑变更 / 取版本间差异 | `+changeset-get` | 读 `references/lark-sheets-changeset.md` |
| 保存多份筛选状态 / 命名筛选视图 | `+filter-view-create`;视图与 `+filter-create` 相互独立、可在同一子表共存 | 读 `references/lark-sheets-filter-view.md` |
| 查编辑历史 / 回滚到历史版本 | `+history-list` 取版本,`+history-revert`(high-risk,异步)回滚后用 `+history-revert-status` 轮询 | 读 `references/lark-sheets-history.md` |
> ⚠️ 金额 / 百分比 / 比率 / 计数及参与运算的真日期写数字(百分比传 `0.4` + `number_format`);日期标签、编号、前导零、身份证 / 单据号写文本。`--range` 只写 `A1:B2`,子表另传 `--sheet-id` / `--sheet-name`。
## 飞书表格编辑准则
1. **最小改动**:用户没点名要删 / 改名 / 隐藏时,已有 Sheet 一张不动;补齐只写空格,未要求调整的值 / 结构 / 格式不动。
2. **目标子表与回读断言**:先确认真实末行与目标区域;未点名子表时只从 `resource_type=sheet && is_hidden=false` 的可见网格候选里选,唯一才自动使用,多张不得按 index 猜。涉及"所有 / 每个 sheet"(跨表汇总、批量清洗、合并多张子表)时先 `+workbook-info` 列全再逐个处理,别只做前几张。写后用 `+csv-get` / `+cells-get` / `+<对象>-list` 验首、中、末及用户点名项——返回 `ok` 只表示请求成功。纯 CSV 回写前去掉 `annotated_csv` 的 `[row=N] ` 前缀,`cells-get` 的样式字段与值分开处理,公式必须回读 `formula`。**样式同样要回读**:写过边框 / 底色 / 字体色 / 数字格式 / 行高列宽 / 冻结的,收尾用 `+cells-get --include style` 或 `+sheet-info` 抽查目标区域首、中、末格确认属性真的在——写入返回 `ok` 不代表样式落上了;缺的整份重发(样式是幂等盖章,重发无副作用)。
3. **公式闭环**:可推导值写落格公式,不用静态值代替——用 Python 算好数值再写进单元格,交付的是改输入不重算的死表;Python 只用于推导和验证,落进单元格的必须是引用其他格的公式。写前确认字段语义、阈值边界(以上/至少=`>=`,超过/大于=`>`)、单位/时区和完整源范围,选首中末、空值、边界及一条可手算记录作哨兵;写后逐段 `+formula-verify --exit-on-error`,各段 `status='success'` 且哨兵值正确才算完成(AI 公式例外:异步计算,改用 `+formula-verify --ai-only` 对整个写入区间做一次异步状态检查,不用 `+cells-get` 轮询结果,`failed` 清零后即使仍有 pending 也可交付并说明);试错 3 次仍失败可降级静态值,交付说明写明「静态值 + 失败原因 + 不随源数据更新」。
4. **完整继承样式**:新增行列时禁止只读值只写值——原表字体、对齐、底色(含奇偶行交替)、四边框都延续到新区域。**物理插入行 / 列**用 `+dim-insert --inherit-style before|after`(原生继承,比补刷可靠);**往已有空白区域扩写**(如在数据右侧加新列)用 `+range-copy --paste-type formats` 先铺样式再写值;两者都表达不了的非规则样式,才用 `+cells-get --include style` 读源区样式随值写回。无论走哪条路径,插入后都另查行高列宽(行高不随样式继承,插行填长文本前补 `+rows-resize`)、合并与跨列标题并补齐。详见 `references/lark-sheets-write-cells.md`。
5. **原子操作**:排序用 `+range-sort`,`--range` 覆盖完整记录宽度,排序列只写进 `--sort-keys`;删除记录用 `+dim-delete`,清空内容 / 格式才用 `+cells-clear`;禁止读值后用 `+csv-put` 覆盖来模拟排序 / 删除。仅跨类型且有顺序依赖时才用 high-risk `+batch-update`。
6. **标色分流**:数据变化后应自动重算的高亮 / 标红用条件格式,已确定结果的固定标注用静态样式,装饰性美化按视觉规范。两条路径取色字段用同一判据:用户中文语境下的"标红 / 染色 / 标记"指**单元格背景色**,"文字红 / 字体红 / 把字变红"才用字体色,默认无说明时选背景色。条件格式建完先 `+cond-format-list` 验规则与范围,再 `+cond-format-result-get` 抽查哨兵格命中样式。
7. **产物可核对**:用户点名的 sheet 名与数量、表头、标题、图例、文件名、口径逐字保留;回复中每项“已完成”都能定位到产物,缺口逐项声明。
8. **替换与新增**:批量替换 / 删除后搜索确认无残留;新增列要有表头,单位 / 口径另置,不占原表头或数据格。
9. **不编造**:表外数据须有可核验来源,不用常识或名称推断伪造公司、标准值、行情或法规参数;**没有来源就留空**——凭记忆填的数值大概率与真实值对不上,比留空更糟。留空的格在交付说明里逐项列出格址与缺的来源,不要只写一句"部分数据缺失"。
> 🤖 **文本类 NLP 任务首选 AI 公式,别默认退回手工 / Python**:只要对文本列做**翻译 / 情感 / 分类打标签 / 信息提取 / 总结 / 润色**等 NLP,飞书在线表格上优先用原生 `=AI(prompt, range)` 逐列铺开(写法与普通公式一致,见 `references/lark-sheets-formula-translation.md`),一次落表随行自动计算,比逐条读 → 手工判断 → 回写 / Python 调模型再写静态值都更省事。**判定标准是「逐行独立」**:每个目标单元格只依赖同一行输入即为逐行独立,**数据量(哪怕 1 万 +)、分批、判断复杂度都不改变该判定**——大数据量下 AI 公式仍是首选,分批只改公式铺设的批次大小(行数很多时按批串行,量级参考每批几百到一千行),不得改为「用 Python 或规则脚本生成语义结果后静态写回」;Python 只能做清洗 / 行号映射 / 构造公式批次,不得读源文本生成目标语义值。只有单个结果依赖多行输入的跨行任务才走非公式路线。AI 公式异步计算,写完先对种子格 / 首格做**一次** `+cells-get --include formula` 核对文本,随后**第一校验入口必须是** `+formula-verify --ai-only --range <整个写入区间>`,禁止用 `+cells-get` 轮询计算结果;判据为 `ai_formula_failed_count == 0`(`--range` 只透传给后端、不保证收窄汇总口径,按返回的单元格定位核对本次区间,别拿总数对预期条数),满足后即使仍有 pending 也可交付,并告知用户"AI 公式仍在后台运行"。
> 流程:了解结构 →(未点名时先按 visible_grid selection 定位)→ 读数据 → 原生工具写入 → 按用户点名项回读验证 → 在线交付。整理 / 美化 / 加汇总行这类会改变表长或版式的任务,收尾把表头行冻住(原表已有冻结设置的不动)。xlsx 验收只在处理本地 xlsx、或用户点名要本地 xlsx / 下载 / 打印时跑。
## References
reference 分两组:先读**通用方法与规范**(横切所有任务的样式 / 公式规则),再按操作对象进入**工具参考**查具体 shortcut。编辑类任务务必先过通用方法与规范,连同上方「飞书表格编辑准则」对所有工具参考一律生效。
### 通用方法与规范(先读,横切所有任务,不含具体 shortcut)
| Reference | 描述 |
| --- | --- |
| [飞书表格样式与配色规范](references/lark-sheets-visual-standards.md) | 飞书表格样式与配色规范:表头/数据区/汇总行的颜色、字号、对齐、边框、数字格式等取值标准,以及从零新建表格的版式美化、新增汇总行、追加行列继承原表风格、已有区域美化等典型场景的决策流程与样式要点。工具调用参数细节请参考对应的 lark-sheets-write-cells / lark-sheets-range-operations / lark-sheets-batch-update。条件格式(高亮、标红、数据条、色阶)请使用 lark-sheets-conditional-format。 |
| [飞书表格公式生成规则](references/lark-sheets-formula-translation.md) | Excel 公式到飞书表格公式的迁移与生成规则。核心目标不是保留 Excel 原语法,而是按飞书表格可执行规则重写公式,并在结果上尽量对齐 Excel。当用户要求把 Excel 公式改写成飞书表格公式,或需要生成飞书公式(尤其涉及 ARRAYFORMULA、数组语义与逐行填充、原生数组函数、INDEX/OFFSET、MAP/LAMBDA、日期差、多层范围结果与二次展开)时使用。本文负责把公式写对;落表后必须用 `references/lark-sheets-formula-verify.md` 对本次公式范围逐段诊断。 |
### 按对象的工具参考(含 shortcut)
| Reference | 描述 |
| --- | --- |
| [Lark Sheet Formula Verify](references/lark-sheets-formula-verify.md) | 公式写入 / 批量填充 / `--copy-to-range` 扩展 / 导入含公式工作簿后的完成检查。普通公式按本次新增或修改范围逐段扫描,合并编译失败与 7 类运行错误;`partial` 继续拆分,全部 `status='success'` 后完成。AI 公式用 `--ai-only` 对整个写入区间做一次异步状态检查,pending 可说明后交付。 |
| [Lark Sheet Workbook](references/lark-sheets-workbook.md) | 管理飞书表格的工作簿结构(子表列表及元数据)。当用户提到"看看这个表格有什么"、"表格结构"、"有哪些 sheet"、"新建一个 sheet"、"删除这个工作表"、"重命名"、"复制一份"、"移动到前面"时使用。 |
| [Lark Sheet Sheet Structure](references/lark-sheets-sheet-structure.md) | 管理飞书表格的子表结构与布局:查看行高列宽、隐藏、合并、冻结与分组,并执行插入/删除/移动行列等物理结构操作。数据分组统计走 lark-sheets-pivot-table。普通表尾追加优先用 lark-sheets-write-cells 的 `+table-put --mode append` 自动定位末行;只有用户明确要求物理插入行列、继承模板结构或扩容布局时才先用本 reference。 |
| [Lark Sheet Read Data](references/lark-sheets-read-data.md) | 读取飞书表格中的单元格数据。当用户需要"看看数据"、"分析数据"、"统计/汇总"时使用;也适用于需要查看公式、样式、批注等详细信息的场景。 |
| [Lark Sheet Search & Replace](references/lark-sheets-search-replace.md) | 在飞书表格中搜索和替换文本,支持限定范围、大小写匹配、精确匹配、正则表达式。当用户需要"查找"、"搜索"、"定位"某个值,或"替换"、"批量修改文本"、"把 A 改成 B"时使用。不要用于理解表格结构(应读取数据)、不要用于数据分析(应读取数据后计算)、不要把用户操作动作中的关键词(如"汇总金额""统计数量")当作搜索词。 |
| [Lark Sheet Write Cells](references/lark-sheets-write-cells.md) | 向飞书表格指定区域批量写入值、公式、样式、批注或单元格图片。纯文本可用 `+csv-put`;金额、百分比、日期、布尔、计数和后续参与聚合的列用 `+table-put` 并显式声明 dtypes/formats;公式或富字段用 `+cells-set`。追加数据可直接使用 `+table-put --mode append`;只有明确需要物理插行/列时才先走 lark-sheets-sheet-structure。公式落表后必须运行 lark-sheets-formula-verify。 |
| [Lark Sheet Range Operations](references/lark-sheets-range-operations.md) | 对飞书表格中指定区域执行结构性操作(不涉及写入单元格数据值)。适用场景:清除内容或格式("清空"、"删除内容"、"去掉格式")、合并/取消合并单元格、调整行高列宽("加宽列"、"自适应列宽")、移动/复制/填充/排序数据("移动数据"、"复制到"、"自动填充"、"按某列排序")。写入单元格数据请使用 lark-sheets-write-cells。 |
| [Lark Sheet Styles Put](references/lark-sheets-styles-put.md) | 把一份声明式视觉规格(样式/边框/合并/行高列宽/冻结)一次性应用到已有飞书表格的多个子表,整份规格一次提交。当任务是对存量表做美化收尾、批量刷样式、统一版式时使用。样式取值标准见 lark-sheets-visual-standards;建新表带样式走 lark-sheets-workbook(+workbook-create --styles)、写数据同步带样式走 lark-sheets-write-cells(+table-put --styles),三者共用同一份 --styles 词汇。仅针对飞书表格。 |
| [Lark Sheet Batch Update](references/lark-sheets-batch-update.md) | 将多个飞书表格写入操作合并为一次批量执行,按顺序依次完成。适合需要连续执行多个写入操作的场景(如先修改结构再写入数据)。 |
| [Lark Sheet Chart](references/lark-sheets-chart.md) | 管理飞书表格中的图表(柱形图、折线图、饼图、条形图、面积图、散点图、组合图、雷达图等)。当用户需要创建图表、修改图表样式或数据源、查看已有图表配置、删除图表时使用。也适用于用户提到"数据可视化"、"画个图"、"趋势分析"、"对比图"、"占比分析"、"做个图表"等数据可视化相关场景。 |
| [Lark Sheet Pivot Table](references/lark-sheets-pivot-table.md) | 管理飞书表格中的数据透视表。当用户需要创建透视表、修改透视表的行列字段/聚合方式/筛选条件、查看已有透视表配置、删除透视表时使用。也适用于用户提到"分组汇总"、"交叉分析"、"按XXX统计"、"按字段分组"、"再分下组"、"多维分析"、"数据透视"等场景。 |
| [Lark Sheet Conditional Format](references/lark-sheets-conditional-format.md) | 管理飞书表格中的条件格式规则(重复值高亮、单元格值比较、数据条、色阶、排名、自定义公式等)。当用户需要创建条件格式、修改已有规则的范围或样式、查看当前条件格式配置、删除规则时使用。也适用于用户提到"高亮"、"标红"、"颜色标记"、"数据条"、"色阶"、"条件样式"等场景。 |
| [Lark Sheet Filter](references/lark-sheets-filter.md) | 管理飞书表格中的筛选器(filter)。当用户需要筛选数据(按文本/数值/颜色/日期条件过滤行)、查看已有筛选配置、修改或删除筛选器时使用。也适用于"只看"、"筛选出"、"仅保留符合条件的"等场景。 |
| [Lark Sheet Filter View](references/lark-sheets-filter-view.md) | 管理飞书表格中的筛选视图(filter view)。当用户需要"建一个 XX 视图"、"保存这个筛选状态"、"切换不同筛选"、维护一个 sheet 上多份独立筛选配置时使用。视图与筛选器(filter)相互独立,可在同一 sheet 共存;视图的隐藏行仅在用户进入该视图时本地生效,不影响其他协作者。 |
| [Lark Sheet Sparkline](references/lark-sheets-sparkline.md) | 管理飞书表格中的迷你图(折线迷你图、柱形迷你图、胜负迷你图)。当用户需要在单元格内嵌入小型图表来展示数据趋势时使用。也适用于"趋势线"、"单元格内图表"、"迷你图"等场景。注意:不等同于被禁用的 SPARKLINE() 公式函数。 |
| [Lark Sheet Float Image](references/lark-sheets-float-image.md) | 管理飞书表格中的浮动图片。当用户需要在表格中插入浮动图片、调整图片位置和大小、查看已有浮动图片、删除图片时使用。也适用于"插入图片"、"添加 logo"、"放一张图"等场景。注意:如果用户需要将图片嵌入到某个单元格内部(单元格图片),请阅读 lark-sheets-write-cells。 |
| [Lark Sheet History](references/lark-sheets-history.md) | 查询飞书表格的历史版本并回滚到指定版本。当用户需要查看一张表的编辑历史版本列表、回滚到某个历史版本、或查询回滚的异步状态(进行中/成功/失败)时使用。回滚为异步操作,发起后通过状态查询轮询结果。仅针对飞书表格。 |
| [Lark Sheet Changeset](references/lark-sheets-changeset.md) | 读取两个版本(CS revision)之间的 changeset(原始变更操作清单),用于复核某次编辑——尤其是 AI 编辑——是否真实满足用户诉求。传入起始版本(编辑前基线),可选结束版本(省略取最新),版本差上限 20;返回里最外层带当前表格最新版本号。当用户需要"看看这次改了什么"、"核对 AI 改动"、"对比两个版本的变更"时使用。 |
## 公共 flag 速查
各 reference 的 shortcut 标题下用一行徽章标注支持的公共 / 系统 flag(如 `_公共四件套 · 系统:--dry-run_`)。type / 必填 / 描述在本段统一声明:
### 公共 flag(定位资源)
**公共四件套** = `--url` / `--spreadsheet-token` / `--sheet-id` / `--sheet-name`,分成两组 XOR,**每组都必须给且只能给一个**(XOR = 二选一必填,不是"可选")——`spreadsheet` 指工作簿、`sheet` 指子表;条件格式 / 图表 / 筛选视图 / 透视表 / 迷你图 / 浮动图片这类对象在四件套之外另用各自的 `--*-id` 定位:
1. **spreadsheet 定位(必填)**:`--url`(解析 `/sheets/`、`/spreadsheets/`、`/wiki/` 三种链接;wiki 链接自动定位背后的电子表格)与 `--spreadsheet-token`(裸 token)二选一。**例外**:`+workbook-create` / `+workbook-import` 产出**还不存在**的表,不接受任何定位 flag。
2. **sheet 定位(公共四件套 shortcut 必填)**:`--sheet-id` 与 `--sheet-name` 二选一。
- ⚠️ **不确定 sheet 名时禁止猜 `Sheet1`**:除非对话或上下文已出现具体值,第一步先 `+workbook-info` 拿 `sheets[].sheet_id/title` 再选——中文表的子表常叫"数据"/"工作表 1"/业务名,猜名大概率撞 `sheet not found`。
- ⚠️ **`--range` 里的 `Sheet1!` 前缀不能替代 sheet 定位**:仍必须传 `--sheet-id` / `--sheet-name`。
- ⚠️ **A1 引用含 `!` 时整段用单引号包裹**(`--range 'Sheet1!A1:B2'`,挡 bash history expansion;别用 `set +H`,sh/dash 下非法)。sheet 名要在 A1 里内层再包单引号时用 `'\''` 转义。
- **例外**:徽章标 `_公共:URL/token(无 sheet 定位)…_` 的 shortcut 不接受 sheet 定位——工作簿级(`+workbook-info` / `+sheet-list` / `+sheet-create` / `+revision-get` / `+changeset-get` / `+history-list|revert|revert-status`)、批量与整表级(`+batch-update` / `+batch-chart-create|update` / `+cells-batch-clear` / `+styles-put` / `+dropdown-update|delete`),以及子表名写在 payload 里的 `+table-put`。`+workbook-export` 只接 `--sheet-id`(无 `--sheet-name`),`+pivot-create` 用 `--target-sheet-id/name`(XOR,可都不传)。徽章是判据,本行只是速记。
```bash
# 统一调用范式:两组定位缺一不可(占位符别原样填;表名先 +workbook-info 查)
lark-cli sheets +csv-get --url "https://.../sheets/shtXXX" --sheet-name "<真实表名>" --range "A1:F30"
```
### 系统 flag
| Flag | Type | 必填 | 说明 |
| --- | --- | --- | --- |
| `--dry-run` | bool | 否 | 零副作用:仅打印请求路径与参数模板,不发起调用 |
| `--yes` | bool | 是(仅 `high-risk-write`) | 二次确认;不带时退出码 10。详见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 高风险审批协议 |
| `--print-schema` | bool | 否 | 写复合 JSON flag 前结构不确定就先跑它:本地打印 Schema 并退出(不发起调用、不需要其它 required flag),搭配 `--flag-name` 指定查哪个 flag,省略时列出该 shortcut 可查的 flag。只有含复合 JSON flag 的 shortcut 支持。 |
| `--flag-name` | string | 否 | 配合 `--print-schema`:flag 名不带 `--` 前缀(`cells` / `properties`)。**支持点分路径切片**:`--flag-name properties.snapshot.plotArea.axes` 只打印该子树,大 schema(chart 的 properties 约 1700 行)按需取,别整篇翻页。 |
> **bool flag 语法**:开启可用裸 `--flag`;显式值只用 `--flag=true` 或 `--flag=false`,不得用空格分隔。
> ⚠️ **high-risk-write 命令清单(exit 10 强确认门禁)**:`+batch-update`、`+cells-clear`、`+cells-batch-clear`、`+sheet-delete`、`+dim-delete`、`+dropdown-delete`、`+history-revert`(整表回滚到历史版本),以及各对象删除 `+chart-delete` / `+pivot-delete` / `+cond-format-delete` / `+filter-delete` / `+filter-view-delete` / `+sparkline-delete` / `+float-image-delete`。
>
> **审批协议**:先 `--dry-run` 预览、向用户展示将执行的操作与影响范围,**获得用户明确同意后**再在原命令追加 `--yes` 执行。未经用户同意不得带 `--yes`,也不得在 exit 10 后静默补 `--yes` 重试——那等于禁用门禁。完整协议见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md)。
**Schema 的边界**:`--print-schema` 打印的是 flag 值的内部结构,flag 描述要求外层信封时(如 `--sheets` 的 `{"sheets":[…]}`)schema 里看不到那层,按描述补上;reference 的 `## Schemas` 段也只给一层。图表直接 `+chart-create --print-example <type>` 拿最小可用模板改参。
### flag 内容类型与输出约定(术语速记)
- JSON 类入参分三类:**复合 JSON** = 深层嵌套对象(`--print-schema` 可查);**简单 JSON** = 一二维标量数组;**非 JSON 文本** = 原样文本(如 CSV)。
- **envelope**:所有 shortcut 返回统一外层 `{ok, identity, data, ...}`;写操作不会自动回读,校验自行调用 `+*-list` / `+*-get` / `+cells-get`。
- **大 payload 走文件 / stdin,不在命令行内联**:Type 标 `File + Stdin` 的 flag 支持 `--flag "@./x.json"`(`@file` 只接受 cwd 下相对路径,绝对路径被拒)与 `--flag -`(stdin);payload 含换行 / 引号或体量大时一律落文件。**stdin 每次调用只能给一个 flag**——`+table-put` 的 `--sheets` 与 `--styles` 都是大 JSON 时,一个走 `-`、另一个走 `@./x.json`。临时文件不要落进用户项目目录。
- **非 POSIX shell(PowerShell / cmd.exe)适配**:本 skill 全部 `bash` 代码块(heredoc `<<'JSON'`、单引号转义 `'\''`)只适用于 bash / zsh,动手前先判断当前 shell,非 POSIX 环境按下表改写,**不要试错式改引号**——`@file`(cwd 相对路径)是全平台无引号问题的兜底形态:
| 形态 | bash / zsh | PowerShell | cmd.exe |
| --- | --- | --- | --- |
| 大 / 多行 JSON | `--flag - <<'JSON' … JSON` | 先写 UTF-8 无 BOM 文件再 `--flag '@./x.json'`,或 `Get-Content -Raw ./x.json \| lark-cli … --flag -` | 先写文件再 `--flag @./x.json`(cmd 无 heredoc / 管道读文件不可靠) |
| 单行 inline JSON | `--flag '{"a":1}'` | `--flag '{"a":1}'`(PS 单引号同为字面量) | 不要 inline——cmd 会吃掉内层双引号,一律走 `@file` |