mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
3 Commits
sun/remove
...
feat/white
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38065cd0c9 | ||
|
|
37b7a3bf05 | ||
|
|
d954832505 |
@@ -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":
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
用法:
|
||||
|
||||
@@ -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#写入画板)
|
||||
|
||||
@@ -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#写入画板)
|
||||
|
||||
103
skills/lark-whiteboard/routes/svg-edit.md
Normal file
103
skills/lark-whiteboard/routes/svg-edit.md
Normal 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 SC,font-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` 必须携带,否则会增量叠加导致内容重复。
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 规则
|
||||
|
||||
@@ -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 规则
|
||||
|
||||
|
||||
@@ -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 规则
|
||||
|
||||
|
||||
@@ -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 规则
|
||||
|
||||
|
||||
@@ -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 规则
|
||||
|
||||
Reference in New Issue
Block a user