Compare commits

...

3 Commits

Author SHA1 Message Date
cl900811
38065cd0c9 fix: whiteboard-cli version 2026-06-09 11:45:46 +08:00
chenliang.0811
37b7a3bf05 feat(whiteboard): pin whiteboard-cli to v0.1.1-beta in lark-whiteboard skill 2026-06-04 18:00:26 +08:00
chenliang.0811
d954832505 feat(whiteboard): add export svg shortcut 2026-06-04 17:59:12 +08:00
16 changed files with 380 additions and 34 deletions

View File

@@ -5,6 +5,7 @@ package whiteboard
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
@@ -23,6 +24,7 @@ import (
const (
WhiteboardQueryAsImage = "image"
WhiteboardQueryAsSvg = "svg"
WhiteboardQueryAsCode = "code"
WhiteboardQueryAsRaw = "raw"
)
@@ -65,8 +67,8 @@ var WhiteboardQuery = common.Shortcut{
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard. You will need read permission to download preview image.", Required: true},
{Name: "output_as", Desc: "output whiteboard as: image | code | raw.", Required: true},
{Name: "output", Desc: "output directory. It is required when output as image. If not specified when --output_as code/raw, it will output directly.", Required: false},
{Name: "output_as", Desc: "output whiteboard as: image | svg | code | raw.", Required: true},
{Name: "output", Desc: "output directory. It is required when output as image. If not specified when --output_as svg/code/raw, it will output directly.", Required: false},
{Name: "overwrite", Desc: "overwrite existing file if it exists", Required: false, Type: "bool"},
},
HasFormat: true,
@@ -87,8 +89,8 @@ var WhiteboardQuery = common.Shortcut{
}
as := runtime.Str("output_as")
if as != WhiteboardQueryAsImage && as != WhiteboardQueryAsCode && as != WhiteboardQueryAsRaw {
return common.FlagErrorf("--output_as flag must be one of: image | code | raw")
if as != WhiteboardQueryAsImage && as != WhiteboardQueryAsSvg && as != WhiteboardQueryAsCode && as != WhiteboardQueryAsRaw {
return common.FlagErrorf("--output_as flag must be one of: image | svg | code | raw")
}
return nil
},
@@ -108,8 +110,13 @@ var WhiteboardQuery = common.Shortcut{
return common.NewDryRunAPI().
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
Desc("Extract raw nodes structure from given whiteboard")
case WhiteboardQueryAsSvg:
return common.NewDryRunAPI().
POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", common.MaskToken(url.PathEscape(token)))).
Body(map[string]string{"export_type": "svg"}).
Desc("Export SVG of given whiteboard")
default:
return common.NewDryRunAPI().Desc("invalid --output_as flag, must be one of: image | code | raw")
return common.NewDryRunAPI().Desc("invalid --output_as flag, must be one of: image | svg | code | raw")
}
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
@@ -120,17 +127,86 @@ var WhiteboardQuery = common.Shortcut{
switch as {
case WhiteboardQueryAsImage:
return exportWhiteboardPreview(ctx, runtime, token, outDir)
case WhiteboardQueryAsSvg:
return exportWhiteboardSvg(runtime, token, outDir)
case WhiteboardQueryAsCode:
return exportWhiteboardCode(runtime, token, outDir)
case WhiteboardQueryAsRaw:
return exportWhiteboardRaw(runtime, token, outDir)
default:
return output.ErrValidation("--as flag must be one of: image | code | raw")
return output.ErrValidation("--output_as flag must be one of: image | svg | code | raw")
}
},
}
type exportReq struct {
ExportType string `json:"export_type"`
}
type exportResp struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
Content string `json:"content"`
MimeType string `json:"mime_type"`
} `json:"data"`
}
func exportWhiteboardSvg(runtime *common.RuntimeContext, wbToken, outDir string) error {
reqBody := exportReq{ExportType: "svg"}
req := &larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", url.PathEscape(wbToken)),
Body: reqBody,
}
resp, err := runtime.DoAPI(req)
if err != nil {
return output.ErrNetwork(fmt.Sprintf("export whiteboard svg failed: %v", err))
}
if resp.StatusCode != http.StatusOK {
return output.ErrAPI(resp.StatusCode, string(resp.RawBody), nil)
}
var exportData exportResp
if err := json.Unmarshal(resp.RawBody, &exportData); err != nil {
return output.Errorf(output.ExitInternal, "parsing", "parse export response failed: %v", err)
}
if exportData.Code != 0 {
return output.ErrAPI(exportData.Code, "export whiteboard svg failed", exportData.Msg)
}
svgBytes, err := base64.StdEncoding.DecodeString(exportData.Data.Content)
if err != nil {
return output.Errorf(output.ExitInternal, "parsing", "decode svg base64 failed: %v", err)
}
if outDir == "" {
runtime.OutFormat(map[string]interface{}{
"svg_content": string(svgBytes),
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "%s\n", string(svgBytes))
})
return nil
}
finalPath, size, err := saveOutputFile(outDir, ".svg", wbToken, runtime, bytes.NewReader(svgBytes))
if err != nil {
return err
}
runtime.OutFormat(map[string]interface{}{
"svg_path": finalPath,
"size_bytes": size,
}, nil, func(w io.Writer) {
fmt.Fprintf(w, "SVG saved to %s\n", finalPath)
fmt.Fprintf(w, "File size: %d bytes", size)
})
return nil
}
func exportWhiteboardPreview(ctx context.Context, runtime *common.RuntimeContext, wbToken, outDir string) error {
req := &larkcore.ApiReq{
HttpMethod: http.MethodGet,
@@ -359,6 +435,8 @@ func saveOutputFile(outPath, ext, token string, runtime *common.RuntimeContext,
switch ext {
case ".png":
contentType = "image/png"
case ".svg":
contentType = "image/svg+xml"
case ".json":
contentType = "application/json"
case ".mmd", ".puml":

View File

@@ -6,6 +6,7 @@ package whiteboard
import (
"bytes"
"context"
"encoding/base64"
"os"
"path/filepath"
"strings"
@@ -108,6 +109,14 @@ func TestWhiteboardQuery_Validate(t *testing.T) {
},
wantErr: false,
},
{
name: "valid: svg without output",
flags: map[string]string{
"whiteboard-token": "test-token-123",
"output_as": "svg",
},
wantErr: false,
},
{
name: "invalid: image without output",
flags: map[string]string{
@@ -188,6 +197,15 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
wantMethod: "GET",
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
},
{
name: "dry run svg",
flags: map[string]string{
"whiteboard-token": "test-token-123",
"output_as": "svg",
},
wantMethod: "POST",
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/export",
},
}
for _, tt := range tests {
@@ -711,6 +729,138 @@ func TestFetchWhiteboardNodes_APIError(t *testing.T) {
}
}
func TestExportWhiteboardSvg_DirectOutput(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
svgContent := `<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect fill="red"/></svg>`
encoded := base64.StdEncoding.EncodeToString([]byte(svgContent))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg/export",
Body: map[string]interface{}{
"code": 0,
"msg": "",
"data": map[string]interface{}{
"content": encoded,
"mime_type": "image/svg+xml",
},
},
})
args := []string{"+query", "--whiteboard-token", "test-token-svg", "--output_as", "svg"}
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if !strings.Contains(stdout.String(), "svg_content") {
t.Fatalf("stdout missing svg_content key: %s", stdout.String())
}
if !strings.Contains(stdout.String(), `width=\"100\"`) && !strings.Contains(stdout.String(), `width="100"`) {
t.Fatalf("stdout missing svg attributes: %s", stdout.String())
}
}
func TestExportWhiteboardSvg_SaveToFile(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
chdirTemp(t)
svgContent := `<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><circle r="50"/></svg>`
encoded := base64.StdEncoding.EncodeToString([]byte(svgContent))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-file/export",
Body: map[string]interface{}{
"code": 0,
"msg": "",
"data": map[string]interface{}{
"content": encoded,
"mime_type": "image/svg+xml",
},
},
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-file", "--output_as", "svg", "--output", "output", "--overwrite"}
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
data, err := os.ReadFile("output.svg")
if err != nil {
t.Fatalf("ReadFile() error: %v", err)
}
if string(data) != svgContent {
t.Fatalf("svg file content = %q, want %q", string(data), svgContent)
}
}
func TestExportWhiteboardSvg_APIError(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-err/export",
Status: 500,
Body: map[string]interface{}{
"code": 99999,
"msg": "internal error",
},
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-err", "--output_as", "svg"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatalf("Expected API error, but got none")
}
}
func TestExportWhiteboardSvg_BusinessError(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-biz-err/export",
Body: map[string]interface{}{
"code": 10002,
"msg": "whiteboard not found",
"data": map[string]interface{}{
"content": "",
"mime_type": "",
},
},
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-biz-err", "--output_as", "svg"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatalf("Expected business error, but got none")
}
}
func TestExportWhiteboardSvg_InvalidBase64(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-token-svg-bad-b64/export",
Body: map[string]interface{}{
"code": 0,
"msg": "",
"data": map[string]interface{}{
"content": "!!!not-valid-base64!!!",
"mime_type": "image/svg+xml",
},
},
})
args := []string{"+query", "--whiteboard-token", "test-token-svg-bad-b64", "--output_as", "svg"}
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
if err == nil {
t.Fatalf("Expected base64 decode error, but got none")
}
}
// newTestRuntime creates a RuntimeContext with string flags for testing.
func newTestRuntime(flags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
cmd := &cobra.Command{Use: "test"}

View File

@@ -13,7 +13,7 @@ metadata:
> [!IMPORTANT]
> - 运行 `lark-cli --version`,确认可用,无需询问用户。
> - 运行 `npx -y @larksuite/whiteboard-cli@^0.2.11 -v`,确认可用,无需询问用户。
> - 运行 `npx -y @larksuite/whiteboard-cli@0.1.1-beta -v`,确认可用,无需询问用户。
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
@@ -24,6 +24,7 @@ metadata:
| 用户需求 | 行动 |
|---|---|
| 查看画板内容 / 导出图片 | [`+query --output_as image`](references/lark-whiteboard-query.md) |
| 导出画板并编辑后回写(有损) | [`routes/svg-edit.md`](routes/svg-edit.md) |
| 获取画板的 Mermaid/PlantUML 代码 | [`+query --output_as code`](references/lark-whiteboard-query.md) |
| 检查画板是否由代码绘制 | [`+query --output_as code`](references/lark-whiteboard-query.md) |
| 修改节点文字/颜色(简单改动)| `+query --output_as raw` → 手动改 JSON → `+update --input_format raw` |
@@ -69,9 +70,12 @@ metadata:
+query --output_as code
├─ 返回 Mermaid/PlantUML 代码
│ → 在原代码上修改 → +update --input_format mermaid/plantuml
├─ 无代码DSL 或其他方式绘制的画板)
│ ├─ 只改文字/颜色 → +query --output_as raw → 手动改 JSON → +update --input_format raw
重绘/结构调整 → +query --output_as image → 看图后进入 [§ 渲染 & 写入画板]
├─ 无代码(SVG/DSL 或其他方式绘制的画板)
│ ├─ 仅限文字/颜色且不涉及布局变更 → +query --output_as raw → 手动改 JSON → +update --input_format raw(无损)
需要保留/重建语义结构(如思维导图关系、连线绑定)
│ │ → +query --output_as image → 看图后进入 [§ 渲染 & 写入画板]
│ └─ 其他改动(几何变动/增删元素/结构调整/混合编辑等)
│ → [routes/svg-edit.md](routes/svg-edit.md)(视觉高保真还原,部分语义有损,需告知用户)
└─ 用户有明确要求 → 以用户要求优先
```
@@ -88,8 +92,8 @@ metadata:
| 图表类型 | 身份 | 路径 |
|------------------------|-------------------------------------|------------------------------------------|
| 思维导图、流程图、时序图、类图、饼图、甘特图 | 任何身份 | [`routes/mermaid.md`](routes/mermaid.md) |
| 其他图表 | `Claude` / `Gemini` / `GPT` / `GLM` | [`routes/svg.md`](routes/svg.md) |
| 其他图表 | `Doubao` / `Seed` / `Other` | [`routes/dsl.md`](routes/dsl.md) |
| 其他图表 | `Claude` / `Gemini` / `GPT` / `GLM` / `Doubao` / `Seed` | [`routes/svg.md`](routes/svg.md) |
| 其他图表 | `Other` | [`routes/dsl.md`](routes/dsl.md) |
> **⚠️ SVG 路径失败回退**:走 `routes/svg.md` 时,碰到以下情况之一 → **丢弃当前 SVG改读 `routes/dsl.md` 从零重画,不要逐行修补**
> - 渲染命令直接报错(语法级崩溃,不是 `--check` 的 warn/error
@@ -119,7 +123,7 @@ diagram.png ← 渲染结果
> 因此,若需要整体更新画板内容,需携带 --overwrite flag 覆盖式更新。
```bash
npx -y @larksuite/whiteboard-cli@^0.2.11 -i <产物文件> --to openapi --format json \
npx -y @larksuite/whiteboard-cli@0.1.1-beta -i <产物文件> --to openapi --format json \
| lark-cli whiteboard +update \
--whiteboard-token <Token> \
--source - --input_format raw \

View File

@@ -9,13 +9,14 @@
| 参数 | 必填 | 说明 |
|----------------------|----|------------------------------------------------------------------------|
| `--whiteboard-token` | 是 | 画板 token需要拥有画板的读权限 |
| `--output_as` | 是 | 输出格式:`image`(预览图片)、`code`PlantUML/Mermaid 代码)、`raw`OpenAPI 原生画板节点格式) |
| `--output_as` | 是 | 输出格式:`image`(预览图片)、`svg`SVG 矢量图)、`code`PlantUML/Mermaid 代码)、`raw`OpenAPI 原生画板节点格式) |
| `--output` | 否 | 输出路径。当 `--output_as image` 时必填;当 `--output_as code/raw` 时可选,不填则直接输出到终端 |
| `--overwrite` | 否 | 覆盖已存在的文件,默认为 false |
## 输出格式
- `image`:预览图片
- `svg`:导出画板为标准 SVG 矢量图。可用于 SVG 编辑后回写画板(见 [`routes/svg-edit.md`](../routes/svg-edit.md))。注意:导出为纯视觉快照,思维导图层级、表格结构、连接器绑定等语义信息会丢失。
- `code`PlantUML/Mermaid 代码。仅限画板内有且仅有一个 PlantUML/Mermaid 图时,才可导出代码,否则会在返回值中告知不存在/有多个节点。
- `raw`:飞书 OpenAPI 原生画板节点格式。这一 json 格式不适合直接编辑复杂布局或内容,建议仅限于需要修改简单的文本内容/颜色等细节时使用。需要进行更复杂的设计/修改时,建议参考 [§ 渲染 & 写入画板](../SKILL.md#渲染--写入画板)。
@@ -38,7 +39,17 @@ lark-cli whiteboard +query \
--output_as code
```
### 示例 3导出画板原始节点结构到文件
### 示例 3导出画板为 SVG 矢量图
```bash
lark-cli whiteboard +query \
--whiteboard-token "wbcnxxxxxxxx" \
--output_as svg \
--output ./whiteboard.svg \
--as user
```
### 示例 4导出画板原始节点结构到文件
```bash
lark-cli whiteboard +query \

View File

@@ -74,7 +74,7 @@ whiteboard-cli 工具的具体用法请参考 [§ 渲染 & 写入画板](../SKIL
```bash
# 使用 whiteboard-cli 生成 OpenAPI 格式并通过管道传递
npx -y @larksuite/whiteboard-cli@^0.2.11 -i <产物文件> --to openapi --format json \
npx -y @larksuite/whiteboard-cli@0.1.1-beta -i <产物文件> --to openapi --format json \
| lark-cli whiteboard +update \
--whiteboard-token <画板Token> \
--source - --input_format raw \
@@ -88,7 +88,7 @@ whiteboard-cli 工具的具体用法请参考 [§ 渲染 & 写入画板](../SKIL
```bash
# 生成 OpenAPI 格式到文件
npx -y @larksuite/whiteboard-cli@^0.2.11 -i <DSL 文件> --to openapi --format json -o ./temp.json
npx -y @larksuite/whiteboard-cli@0.1.1-beta -i <DSL 文件> --to openapi --format json -o ./temp.json
# 从文件读取并更新
lark-cli whiteboard +update \

View File

@@ -336,7 +336,7 @@ DSL 的语法是严格白名单,不能写原生 CSS 属性(不支持 `alignS
先出骨架图导出坐标,再基于坐标补充连线和注解:
```bash
npx -y @larksuite/whiteboard-cli@^0.2.11 -i skeleton.json -o step1.png -l coords.json
npx -y @larksuite/whiteboard-cli@0.1.1-beta -i skeleton.json -o step1.png -l coords.json
```
`coords.json` 包含每个带 id 节点的精确坐标absX, absY, width, height

View File

@@ -272,14 +272,14 @@ SVG 通过 `image/svg+xml` Blob 加载到画布,**不在 HTML DOM 中**,因
x?: number; y?: number;
width?: WBSizeValue; // 默认 48
height?: WBSizeValue; // 默认 48保持正方形
name: string; // 图标名称,从 npx -y @larksuite/whiteboard-cli@^0.2.11 --icons 输出中选取
name: string; // 图标名称,从 npx -y @larksuite/whiteboard-cli@0.1.1-beta --icons 输出中选取
color?: string; // 可选颜色覆盖hex 格式如 '#FF6600'
}
```
**获取可用图标**:规划好内容和布局后,运行以下命令查看所有可用图标名,从中选取:
```bash
npx -y @larksuite/whiteboard-cli@^0.2.11 --icons
npx -y @larksuite/whiteboard-cli@0.1.1-beta --icons
```
用法:

View File

@@ -13,7 +13,7 @@ Step 1: 路由 & 读取知识
Step 2: 生成完整 DSL含颜色
- 按 content.md 规划信息量和分组
- 按 layout.md 选择布局模式和间距
- 推荐使用图标让图表更直观,运行 `npx -y @larksuite/whiteboard-cli@^0.2.11 --icons` 查看可用图标
- 推荐使用图标让图表更直观,运行 `npx -y @larksuite/whiteboard-cli@0.1.1-beta --icons` 查看可用图标
- 按 style.md 上色(用户没指定时用默认经典色板)
- 按 schema.md 语法输出完整 JSON
- 连线参考 connectors.md排版参考 typography.md
@@ -25,12 +25,12 @@ Step 2: 生成完整 DSL含颜色
Step 3: 渲染 & 审查 → 交付
- 渲染前自查(见下方检查清单)
- 渲染 PNG仅用于预览验证不是最终产物npx -y @larksuite/whiteboard-cli@^0.2.11 -i diagram.json -o diagram.png
- 渲染 PNG仅用于预览验证不是最终产物npx -y @larksuite/whiteboard-cli@0.1.1-beta -i diagram.json -o diagram.png
- 检查:信息完整?布局合理?配色协调?文字无截断?连线无交叉?
- 有问题 → 按症状表修复 → 重新渲染(最多 2 轮)
- 2 轮后仍有严重问题 → 考虑走 Mermaid 路径兜底
- 写入画板:用 whiteboard-cli 将 diagram.json 转换为 OpenAPI 格式并 pipe 给 +update
npx -y @larksuite/whiteboard-cli@^0.2.11 -i diagram.json --to openapi --format json \
npx -y @larksuite/whiteboard-cli@0.1.1-beta -i diagram.json --to openapi --format json \
| lark-cli whiteboard +update --whiteboard-token <board_token> \
--source - --input_format raw --idempotent-token <时间戳+标识> --as user
→ 完整 dry-run / 确认流程见 SKILL.md [§ 写入画板](../SKILL.md#写入画板)

View File

@@ -16,10 +16,10 @@ Step 3: 渲染验证 & 写入画板 & 交付
1. 创建产物目录 ./diagrams/YYYY-MM-DDTHHMMSS/
2. 保存为 diagram.mmd
3. 渲染仅用于预览验证PNG 不是最终产物):
npx -y @larksuite/whiteboard-cli@^0.2.11 -i diagram.mmd -o diagram.png
npx -y @larksuite/whiteboard-cli@0.1.1-beta -i diagram.mmd -o diagram.png
4. 审查 PNG有问题修改后重新渲染最多 2 轮)
5. 写入画板:用 whiteboard-cli 将 diagram.mmd 转换为 OpenAPI 格式并 pipe 给 +update
npx -y @larksuite/whiteboard-cli@^0.2.11 -i diagram.mmd --to openapi --format json \
npx -y @larksuite/whiteboard-cli@0.1.1-beta -i diagram.mmd --to openapi --format json \
| lark-cli whiteboard +update --whiteboard-token <board_token> \
--source - --input_format raw --idempotent-token <时间戳+标识> --as user
→ 完整 dry-run / 确认流程见 SKILL.md [§ 写入画板](../SKILL.md#写入画板)

View File

@@ -0,0 +1,103 @@
# SVG 编辑路径
通过导出画板的 SVG → 编辑 SVG → 回写画板,实现对已有画板的可视化编辑。
---
## ⚠️ 有损性警告 & 适用判断
SVG 导出是**纯视觉快照**,再次导入后画板语义(思维导图层级/表格结构/连线绑定/容器类型/mention/节点 ID/锁定/评论/历史)**不可恢复**。
**保留的信息**:形状几何(位置/大小/路径)、文本内容与基本格式(字号/粗体/斜体/对齐)、填充色/描边色/透明度(线性渐变降级为第一个 stop-color 纯色)、连接器路径形状与箭头样式、`<g>` 嵌套的基本分组关系≥2 子元素时重建为 DirectFocusGroup
> **注意**`<path>` 元素会被 path-analyzer 尝试识别为标准形状rect/圆角矩形/椭圆/菱形/三角形/平行四边形);无法识别的复杂路径降级为 SvgIcon视觉保真但不可编辑
| 操作意图 | 可行性 | 说明 |
|---|---|---|
| 修改文字内容 | ✅ | 编辑 `<text>` 元素 |
| 修改颜色/填充 | ✅ | fill/stroke 属性(线性渐变降级为第一个 stop-color |
| 调整位置/大小 | ✅ | 坐标和尺寸属性 |
| 增删独立形状/装饰 | ✅ | 添加或移除 SVG 元素 |
| 修改连线路径走向 | ✅ | path/polyline 路径数据 |
| 修改箭头样式 | ✅ | `<marker>` 定义connector-transformer 恢复箭头类型 |
| 修改描边虚线样式 | ⚠️ | stroke-dasharray 只映射 3 种固定样式(实线/短划线/点线),自定义模式会被近似 |
| 修改字体 | ❌ | 画板硬编码 Noto Sans SCfont-family 导入后无效 |
| 添加 mention/hyperlink | ❌ | 文本语义丢失,无法通过 SVG 注入 |
| 添加外部图片 | ❌ | 仅支持内置 iconSource URL其他 URL 和 data URI 降级为 SvgIcon |
| 调整思维导图层级 | ❌ | 父子层级/布局类型/折叠状态丢失,走[重绘路径](../SKILL.md#修改-workflow) |
| 增减表格行列 | ❌ | 行列/合并单元格结构丢失,暂无可用路径 |
| 重建连接器端点绑定 | ❌ | `startObject`/`endObject` 丢失,回写后连线自由浮动 |
| 修改容器从属关系 | ❌ | Frame/Section/Container 退化为 DirectFocusGroup走[重绘路径](../SKILL.md#修改-workflow) |
---
## Workflow
### 0. 用户确认(强制)
在执行任何编辑前,**必须**向用户说明:
> SVG 编辑只保证视觉层面对齐,画板语义(层级/节点类型/思维导图结构/表格结构/连线绑定/容器类型/mention 等)将不可恢复,是否继续?
**用户未确认前不得执行后续步骤。**
### 1. 导出当前画板 SVG
```bash
lark-cli whiteboard +query \
--whiteboard-token <TOKEN> \
--output_as svg \
--output <dir>/original.svg \
--as user
```
### 2. 编辑 SVG
在导出的 SVG 上进行修改。参考 [`svg.md` § 画板怎么处理 SVG](./svg.md#画板怎么处理-svg) 了解可识别元素与不支持的装饰特性。
**技术约束**
- 新增文字必须用 `<text>`(不是 `<path>`容器宽度留够CJK ≈ 1em / Latin ≈ 0.6em
- 避免 `skewX` / `skewY` / `matrix(...)` 变换
- 禁止使用 `<radialGradient>` / `<filter>` / `<pattern>` / `<clipPath>` / `<mask>`
**编辑原则**(区别于从零创作):
- **风格一致**:新增/修改元素应匹配导出 SVG 中已有的配色、字号、线宽、间距风格,不引入突兀的视觉差异
- **最小改动**:只修改用户要求的部分,不主动"优化"或重排无关区域
- **结构稳定**:尽量保留原有 `<g>` 层级结构,避免不必要的重组导致分组关系变化
- **连线协调**:连接器端点绑定已丢失,若移动了形状,必须手动同步调整视觉上连接到该形状的 connector path 端点坐标,否则连线会"断开"
- **内部引用完整性**:不要随意删改 `<defs>` 中被 `url(#id)` 引用的元素(`<marker>`/`<linearGradient>` 等)或修改其 `id`,否则引用方会失效
### 3. 渲染审查
```bash
# 渲染 PNG 预览
npx -y @larksuite/whiteboard-cli@0.1.1-beta -i <dir>/edited.svg -o <dir>/edited.png -f svg
# 几何检查text-overflow / node-overlap
npx -y @larksuite/whiteboard-cli@0.1.1-beta -i <dir>/edited.svg -f svg --check
```
结合 PNG 视觉效果和 `--check` 报告进行调整,有问题则修改 SVG 后重新渲染(最多 2 轮)。
### 4. 写回画板
```bash
# dry-run 探测(输出含 "XX nodes will be deleted" 时需再次向用户确认)
npx -y @larksuite/whiteboard-cli@0.1.1-beta -i <dir>/edited.svg -f svg --to openapi --format json \
| lark-cli whiteboard +update \
--whiteboard-token <TOKEN> \
--source - --input_format raw \
--idempotent-token <10+字符唯一串> \
--overwrite --dry-run --as user
# 用户确认后执行
npx -y @larksuite/whiteboard-cli@0.1.1-beta -i <dir>/edited.svg -f svg --to openapi --format json \
| lark-cli whiteboard +update \
--whiteboard-token <TOKEN> \
--source - --input_format raw \
--idempotent-token <10+字符唯一串> \
--overwrite --as user
```
> `--overwrite` 必须携带,否则会增量叠加导致内容重复。

View File

@@ -30,12 +30,12 @@
```
建目录 ./diagrams/YYYY-MM-DDTHHMMSS/ (例:./diagrams/2026-04-15T143022/)
写文件 <dir>/diagram.svg
渲染 npx -y @larksuite/whiteboard-cli@^0.2.11 -i <dir>/diagram.svg -o <dir>/diagram.png -f svg
检查 npx -y @larksuite/whiteboard-cli@^0.2.11 -i <dir>/diagram.svg -f svg --check
导出 npx -y @larksuite/whiteboard-cli@^0.2.11 -i <dir>/diagram.svg -f svg --to openapi --format json > <dir>/diagram.json
渲染 npx -y @larksuite/whiteboard-cli@0.1.1-beta -i <dir>/diagram.svg -o <dir>/diagram.png -f svg
检查 npx -y @larksuite/whiteboard-cli@0.1.1-beta -i <dir>/diagram.svg -f svg --check
导出 npx -y @larksuite/whiteboard-cli@0.1.1-beta -i <dir>/diagram.svg -f svg --to openapi --format json > <dir>/diagram.json
```
`npx -y @larksuite/whiteboard-cli@^0.2.11 --check` 检测 `text-overflow``node-overlap`, 并结合视觉效果(查看 PNG)进行调整
`npx -y @larksuite/whiteboard-cli@0.1.1-beta --check` 检测 `text-overflow``node-overlap`, 并结合视觉效果(查看 PNG)进行调整
## 画板怎么处理 SVG

View File

@@ -8,7 +8,7 @@
## Layout 选型
- **脚本生成坐标**(推荐):用 .cjs 脚本计算柱体位置和高度,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
- **脚本生成坐标**(推荐):用 .cjs 脚本计算柱体位置和高度,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@0.1.1-beta` 渲染
- **绝对定位手写**:简单柱状图(≤ 5 个柱)可手写坐标
## Layout 规则

View File

@@ -10,7 +10,7 @@
## Layout 选型
- **脚本生成坐标**(必须):用 .cjs 脚本通过三角函数计算鱼骨坐标,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
- **脚本生成坐标**(必须):用 .cjs 脚本通过三角函数计算鱼骨坐标,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@0.1.1-beta` 渲染
## Layout 规则

View File

@@ -9,7 +9,7 @@
## Layout 选型
- **脚本生成坐标**(必须):用 .cjs 脚本极坐标计算阶段标签位置、SVG 圆环切割,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
- **脚本生成坐标**(必须):用 .cjs 脚本极坐标计算阶段标签位置、SVG 圆环切割,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@0.1.1-beta` 渲染
## Layout 规则

View File

@@ -8,7 +8,7 @@
## Layout 选型
- **脚本生成坐标**(推荐):用 .cjs 脚本计算数据点坐标和折线路径,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
- **脚本生成坐标**(推荐):用 .cjs 脚本计算数据点坐标和折线路径,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@0.1.1-beta` 渲染
## Layout 规则

View File

@@ -8,7 +8,7 @@
## Layout 选型
- **脚本生成坐标**推荐Treemap 需要精确的面积比例计算,用 .cjs 脚本递归切分矩形,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@^0.2.11` 渲染
- **脚本生成坐标**推荐Treemap 需要精确的面积比例计算,用 .cjs 脚本递归切分矩形,脚本输出 JSON 文件后调用 `npx -y @larksuite/whiteboard-cli@0.1.1-beta` 渲染
- 不适合手动心算坐标
## Layout 规则