feat(sheets): styles 接受 halign/valign 等对齐字段别名

把模型常幻觉的 horizontal_align / halign / vertical_align / valign 映射到
规范字段 horizontal_alignment / vertical_alignment,覆盖 --styles 与 typed
--cells;与规范字段冲突时报错而非静默择一。同步 lark-sheets skill 文档补
对齐字段说明 + --print-schema --flag-name styles 提示。
This commit is contained in:
xiongyuanwen-byted
2026-06-22 18:03:45 +08:00
parent aa545083b6
commit b6da950be3
7 changed files with 278 additions and 6 deletions

View File

@@ -2095,7 +2095,7 @@
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Visual operations applied after the typed write, as JSON: top-level `{styles:[...]}`. Each item corresponds to one written sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. The styles array length/order/name must match the written sheets: with --sheets, match --sheets.sheets; with --dataframe (single sheet named Sheet1), pass exactly one styles item with name `Sheet1`.",
"desc": "Visual operations applied after the typed write, as JSON: top-level `{styles:[...]}`. Each item corresponds to one written sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. The styles array length/order/name must match the written sheets: with --sheets, match --sheets.sheets; with --dataframe (single sheet named Sheet1), pass exactly one styles item with name `Sheet1`. Run `+table-put --print-schema --flag-name styles` for the full cell_styles field schema.",
"input": [
"file",
"stdin"

View File

@@ -10,6 +10,7 @@ package sheets
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
@@ -335,6 +336,72 @@ func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
return style
}
// cellStyleAliases maps shorthand cell_styles field names that models commonly
// hallucinate (Excel / openpyxl / CSS conventions) onto the canonical field
// names the backend expects. Only the unambiguous alignment shorthands are
// aliased — they are the high-frequency miss; ambiguous guesses (e.g. "color",
// "bg_color", "text_align") are intentionally left out so a wrong guess still
// surfaces as an error rather than being silently reinterpreted.
var cellStyleAliases = []struct{ alias, canonical string }{
{"horizontal_align", "horizontal_alignment"},
{"halign", "horizontal_alignment"},
{"vertical_align", "vertical_alignment"},
{"valign", "vertical_alignment"},
}
// normalizeCellStyleAliases renames known shorthand keys in a single
// cell_styles map to their canonical equivalents, in place, so a model that
// writes e.g. "horizontal_align" instead of "horizontal_alignment" still
// applies the style instead of hitting an "unsupported field" error (--styles)
// or having the field silently dropped by the backend (typed --cells). If both
// the shorthand and its canonical key are present it returns a validation error
// rather than picking one. path labels the map for the error message.
func normalizeCellStyleAliases(style map[string]interface{}, path string) error {
if len(style) == 0 {
return nil
}
for _, a := range cellStyleAliases {
v, ok := style[a.alias]
if !ok {
continue
}
if _, exists := style[a.canonical]; exists {
return common.ValidationErrorf("%s.%s conflicts with %s; pass only %s", path, a.alias, a.canonical, a.canonical)
}
style[a.canonical] = v
delete(style, a.alias)
}
return nil
}
// normalizeTypedCellsStyleAliases walks a typed --cells 2D array and applies
// normalizeCellStyleAliases to every cell's inline cell_styles object, so the
// alignment shorthands are accepted on +cells-set the same as on --styles.
// Structure is checked leniently to match the pass-through contract: any
// element that isn't the expected shape is skipped, not rejected.
func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
for r, rowRaw := range cells {
row, ok := rowRaw.([]interface{})
if !ok {
continue
}
for c, cellRaw := range row {
cell, ok := cellRaw.(map[string]interface{})
if !ok {
continue
}
st, ok := cell["cell_styles"].(map[string]interface{})
if !ok {
continue
}
if err := normalizeCellStyleAliases(st, fmt.Sprintf("%s[%d][%d].cell_styles", path, r, c)); err != nil {
return err
}
}
}
return nil
}
// borderStylesFromFlag parses --border-styles as a JSON object (top/bottom/
// left/right with style sub-objects). Returns nil when the flag is empty.
func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {

View File

@@ -0,0 +1,199 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"strings"
"testing"
)
// TestNormalizeCellStyleAliases pins the shorthand → canonical renaming for a
// single cell_styles map: the alignment shorthands models commonly hallucinate
// are rewritten in place, values are preserved, and a shorthand colliding with
// its canonical key is a hard error rather than a silent pick.
func TestNormalizeCellStyleAliases(t *testing.T) {
t.Parallel()
t.Run("renames *_align shorthands, keeps values and other fields", func(t *testing.T) {
t.Parallel()
style := map[string]interface{}{
"horizontal_align": "center",
"vertical_align": "middle",
"font_weight": "bold",
}
if err := normalizeCellStyleAliases(style, "x"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if style["horizontal_alignment"] != "center" || style["vertical_alignment"] != "middle" {
t.Errorf("alignment not renamed: %#v", style)
}
if _, ok := style["horizontal_align"]; ok {
t.Errorf("shorthand horizontal_align should be removed: %#v", style)
}
if _, ok := style["vertical_align"]; ok {
t.Errorf("shorthand vertical_align should be removed: %#v", style)
}
if style["font_weight"] != "bold" {
t.Errorf("unrelated field font_weight dropped: %#v", style)
}
})
t.Run("renames halign/valign shorthands", func(t *testing.T) {
t.Parallel()
style := map[string]interface{}{"halign": "left", "valign": "top"}
if err := normalizeCellStyleAliases(style, "x"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if style["horizontal_alignment"] != "left" || style["vertical_alignment"] != "top" {
t.Errorf("halign/valign not renamed: %#v", style)
}
})
t.Run("shorthand colliding with canonical is an error", func(t *testing.T) {
t.Parallel()
style := map[string]interface{}{
"horizontal_align": "center",
"horizontal_alignment": "left",
}
err := normalizeCellStyleAliases(style, "cell_styles[0]")
if err == nil {
t.Fatalf("expected conflict error, got nil")
}
if !strings.Contains(err.Error(), "conflicts with horizontal_alignment") {
t.Errorf("error should name the conflict: %v", err)
}
})
t.Run("no shorthand leaves the map untouched", func(t *testing.T) {
t.Parallel()
style := map[string]interface{}{"font_weight": "bold", "horizontal_alignment": "center"}
if err := normalizeCellStyleAliases(style, "x"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(style) != 2 || style["font_weight"] != "bold" || style["horizontal_alignment"] != "center" {
t.Errorf("map should be unchanged: %#v", style)
}
})
t.Run("empty map is a no-op", func(t *testing.T) {
t.Parallel()
if err := normalizeCellStyleAliases(map[string]interface{}{}, "x"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
// TestNormalizeTypedCellsStyleAliases pins the 2D --cells walk: every cell's
// inline cell_styles is normalized, malformed shapes are skipped (matching the
// pass-through contract) rather than rejected, and a conflict propagates.
func TestNormalizeTypedCellsStyleAliases(t *testing.T) {
t.Parallel()
t.Run("normalizes inline cell_styles across the grid", func(t *testing.T) {
t.Parallel()
cells := []interface{}{
[]interface{}{
map[string]interface{}{
"value": "x",
"cell_styles": map[string]interface{}{"horizontal_align": "center"},
},
map[string]interface{}{"value": "y"}, // no cell_styles → untouched
},
}
if err := normalizeTypedCellsStyleAliases(cells, "--cells"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
row := cells[0].([]interface{})
st := row[0].(map[string]interface{})["cell_styles"].(map[string]interface{})
if st["horizontal_alignment"] != "center" {
t.Errorf("cell_styles not normalized: %#v", st)
}
if _, ok := st["horizontal_align"]; ok {
t.Errorf("shorthand should be removed: %#v", st)
}
})
t.Run("malformed shapes are skipped, not rejected", func(t *testing.T) {
t.Parallel()
cells := []interface{}{
"not-a-row",
[]interface{}{
"not-a-cell",
map[string]interface{}{"cell_styles": "not-a-map"},
},
}
if err := normalizeTypedCellsStyleAliases(cells, "--cells"); err != nil {
t.Fatalf("lenient walk should not error on odd shapes: %v", err)
}
})
t.Run("conflict inside a cell propagates", func(t *testing.T) {
t.Parallel()
cells := []interface{}{
[]interface{}{
map[string]interface{}{
"cell_styles": map[string]interface{}{
"valign": "top",
"vertical_alignment": "middle",
},
},
},
}
err := normalizeTypedCellsStyleAliases(cells, "--cells")
if err == nil {
t.Fatalf("expected conflict error, got nil")
}
if !strings.Contains(err.Error(), "--cells[0][0].cell_styles") {
t.Errorf("error should carry the cell path: %v", err)
}
})
}
// TestCellsSet_StyleAliasesNormalized is the end-to-end guard for +cells-set:
// a typed --cells payload using alignment shorthands reaches set_cell_range
// with canonical field names so the backend doesn't silently drop them.
func TestCellsSet_StyleAliasesNormalized(t *testing.T) {
t.Parallel()
cells := `[[{"value":"Header","cell_styles":{"horizontal_align":"center","vertical_align":"middle","font_weight":"bold"}}]]`
body := parseDryRunBody(t, CellsSet, []string{
"--url", testURL, "--sheet-id", testSheetID,
"--range", "A1", "--cells", cells,
})
input := decodeToolInput(t, body, "set_cell_range")
raw, _ := json.Marshal(input["cells"])
s := string(raw)
if !strings.Contains(s, `"horizontal_alignment":"center"`) || !strings.Contains(s, `"vertical_alignment":"middle"`) {
t.Errorf("alignment shorthands not normalized in cells: %s", s)
}
if strings.Contains(s, `"horizontal_align":`) || strings.Contains(s, `"vertical_align":`) {
t.Errorf("shorthand keys leaked through to backend payload: %s", s)
}
}
// TestWorkbookCreate_StyleAliasesNormalized is the end-to-end guard for
// +workbook-create --styles: alignment shorthands in a cell_styles op are
// accepted (no "unsupported style field" error) and emitted as canonical
// field names merged into the fill cells.
func TestWorkbookCreate_StyleAliasesNormalized(t *testing.T) {
t.Parallel()
calls := parseDryRunAPI(t, WorkbookCreate, []string{
"--title", "Sales",
"--values", `[["Name","Score"],["alice",95]]`,
"--styles", `{"styles":[{"name":"Sheet1","cell_styles":[{"range":"A1:B2","horizontal_align":"center","vertical_align":"middle"}]}]}`,
})
if len(calls) != 2 {
t.Fatalf("api calls = %d, want 2 (create + fill)", len(calls))
}
body, _ := calls[1].(map[string]interface{})["body"].(map[string]interface{})
input := decodeToolInput(t, body, "set_cell_range")
raw, _ := json.Marshal(input["cells"])
s := string(raw)
if c := strings.Count(s, `"horizontal_alignment":"center"`); c != 4 {
t.Errorf("horizontal_alignment occurrences = %d, want 4 in 2x2 range; cells=%s", c, s)
}
if strings.Contains(s, `"horizontal_align":`) || strings.Contains(s, `"vertical_align":`) {
t.Errorf("shorthand keys leaked through after normalization: %s", s)
}
}

View File

@@ -1192,6 +1192,9 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
if len(in) == 0 {
return nil, nil
}
if err := normalizeCellStyleAliases(in, path); err != nil {
return nil, err
}
out := map[string]interface{}{}
cellStyle := map[string]interface{}{}
for k, v := range in {

View File

@@ -88,6 +88,9 @@ func cellsSetInput(runtime flagView, token, sheetID, sheetName string) (map[stri
if err != nil {
return nil, err
}
if err := normalizeTypedCellsStyleAliases(cells, "--cells"); err != nil {
return nil, err
}
input := map[string]interface{}{
"excel_id": token,
"range": strings.TrimSpace(runtime.Str("range")),

View File

@@ -140,7 +140,7 @@ _系统`--dry-run`_
| `--folder-token` | string | optional | 目标文件夹 token省略时放在云空间根目录 |
| `--values` | string + File + Stdin简单 JSON | optional | untyped 初始数据,一个 JSON 二维数组(表头并入第一行):`[["列A","列B"],["alice",95]]`;值原样写入、类型由飞书自动识别,走与 --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, ...}}`。Agents 通常用 `{**json.loads(df.to_json(orient="split")), "dtypes": df.dtypes.astype(str).to_dict()}` 一行构造。与 --values、--dataframe 互斥;新表默认子表复用为第一个子表,日期/数字类型保真。 |
| `--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_stylesrow/col sizes 用行/列范围 + type/sizemerges 用单元格 range + 可选 merge_type。与 --sheets 搭配时 styles 数组长度/顺序/name 必须与 --sheets.sheets 对应;与 --values 搭配时只给一个 styles 项(其 name 忽略)。 |
| `--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_stylesrow/col sizes 用行/列范围 + type/sizemerges 用单元格 range + 可选 merge_type。与 --sheets 搭配时 styles 数组长度/顺序/name 必须与 --sheets.sheets 对应;与 --values 搭配时只给一个 styles 项(其 name 忽略)。完整 cell_styles 字段结构跑 `+workbook-create --print-schema --flag-name styles` |
| `--dataframe` | string | optional | 单 sheet 类型保真表格的二进制入口,从一个 Arrow IPC 文件Feather v2pandas `df.to_feather()` 直接写出)读入,与 --values / --sheets 互斥。用 `@<path>` 传文件或 `-` 读二进制 stdin同其他输入 flag 的约定。Arrow 字节按原样读 —— 不做 TrimSpace / BOM stripIPC magic 字节完整保留(区别于文本类输入 flag。列类型从 Arrow schema 推导;每列的 `number_format` 可写在 Arrow Field metadata 里。建表后写入默认子表(`Sheet1` —— 直接复用,不残留空 Sheet1。要多子表或换落点请改用 `--sheets`。 |
### `+workbook-export`
@@ -231,7 +231,7 @@ python prepare.py | lark-cli sheets +workbook-create --title "交易" --datafram
`--styles` 可在建表写入时同时写视觉处理。它和 `--sheets` 一样只有一种外层写法:顶层对象里放 `styles` 数组;数组每项对应一个子表,含 `name`,并按能力拆成四类可选数组:
- `cell_styles`:像 `+cells-set-style`,用 A1 单元格 `range` 加扁平样式字段(`font_weight` / `background_color` / `number_format` 等)和可选 `border_styles`;这些样式会随内容在同一次写入里一并应用。
- `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`
@@ -245,7 +245,7 @@ lark-cli sheets +workbook-create --title "销售" \
--styles '{
"styles":[
{"name":"Sheet1","cell_styles":[
{"range":"A1:B1","font_weight":"bold","background_color":"#f5f5f5"},
{"range":"A1:B1","font_weight":"bold","background_color":"#f5f5f5","horizontal_alignment":"center","vertical_alignment":"middle"},
{"range":"B2:B3","number_format":"#,##0"}
]}
]

View File

@@ -317,7 +317,7 @@ _公共URL/token无 sheet 定位) · 系统:`--dry-run`_
| --- | --- | --- | --- |
| `--sheets` | string + File + Stdin复合 JSON | xor | Typed 表格协议pandas-DataFrame-shapedJSON`--dataframe` 互斥:顶层 sheets 数组,每项 `{name, start_cell?, mode?, header?, allow_overwrite?, columns:["colA","colB",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}`。Agents 通常用 `{**json.loads(df.to_json(orient="split")), "dtypes": df.dtypes.astype(str).to_dict()}` 一行构造。`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 列用文本格式 `@`。 |
| `--dataframe` | string | xor | 单 sheet 类型保真表格的二进制入口,从一个 Arrow IPC 文件(即 Feather v2pandas `df.to_feather()` 直接写出)读入,与 `--sheets` 互斥。用 `@<path>` 传文件或 `-` 读二进制 stdin同其他输入 flag 的约定。Arrow 字节按原样读 —— 不做 TrimSpace / BOM stripIPC magic 字节完整保留(区别于文本类输入 flag。列类型从 Arrow schema 推导int*/uint*/float* → numberdate32/date64/timestamp → dateutf8/large_utf8 → stringbool → bool每列的 `number_format` 可写在 Arrow Field metadata 里(`pa.field("price", pa.float64(), metadata={b"number_format": b"$#,##0.00"})`)。子表走默认落点:名为 `Sheet1`(缺则新建),从 A1 起覆盖写并带表头。要换子表名 / 起始位置 / 写入方式,或要写多子表,请改用 `--sheets`。 |
| `--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_stylesrow/col sizes 用行/列范围 + type/sizemerges 用单元格 range + 可选 merge_type。styles 数组的长度/顺序/name 必须与被写入的子表对应:配 --sheets 时与 --sheets.sheets 对应;配 --dataframe单子表名为 Sheet1时只给一个 name 为 `Sheet1` 的 styles 项。 |
| `--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_stylesrow/col sizes 用行/列范围 + type/sizemerges 用单元格 range + 可选 merge_type。styles 数组的长度/顺序/name 必须与被写入的子表对应:配 --sheets 时与 --sheets.sheets 对应;配 --dataframe单子表名为 Sheet1时只给一个 name 为 `Sheet1` 的 styles 项。完整 cell_styles 字段结构跑 `+table-put --print-schema --flag-name styles` |
## Schemas
@@ -592,7 +592,7 @@ subprocess.run(["lark-cli","sheets","+table-put","--url",URL,"--dataframe","-"],
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"}],
"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}]}]}'
```