feat: add iconpark lookup for lark slides (#1123)

This commit is contained in:
ethan-zhx
2026-06-08 12:28:04 +08:00
committed by GitHub
parent e40ef66912
commit 8b39f7243c
7 changed files with 42491 additions and 1 deletions

View File

@@ -21,6 +21,7 @@ metadata:
| 上传或使用图片 | 先上传为 `file_token`,禁止直接写 http(s) 外链 | `slides +media-upload`,或 `+create --slides``@./path` 占位符 |
| 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `<chart>` 元素 | `xml-schema-quick-ref.md` |
| 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `<whiteboard>` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) |
| 使用语义图标 | 先检索 IconPark再写 `<icon iconType="...">` | `iconpark_tool.py search → resolve``iconpark.md` |
| 用户提到模板、主题、版式 | 先检索模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` |
| 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md``validation-checklist.md` |
@@ -83,6 +84,7 @@ lark-cli auth login --domain slides
- 编辑:[`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)、[`lark-slides-replace-slide.md`](references/lark-slides-replace-slide.md)
- 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md)
- 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md)
- 图标:[`iconpark.md`](references/iconpark.md)、[`scripts/iconpark_tool.py`](scripts/iconpark_tool.py)
- 模板:[`template-catalog.md`](references/template-catalog.md)、[`scripts/template_tool.py`](scripts/template_tool.py)
- 排障:[`troubleshooting.md`](references/troubleshooting.md)
- 完整协议:[`slides_xml_schema_definition.xml`](references/slides_xml_schema_definition.xml)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
# IconPark 图标
IconPark 图标通过 `<icon>` 写入 slides XML`iconType` 必须来自本 skill 的离线索引或已验证模板,避免凭记忆拼路径。
## 机器优先流程
```bash
python3 skills/lark-slides/scripts/iconpark_tool.py search --query "增长趋势" --limit 8
python3 skills/lark-slides/scripts/iconpark_tool.py resolve --name chart-line
python3 skills/lark-slides/scripts/iconpark_tool.py list-categories
```
`search` 返回 JSON 数组,每项包含 `iconType``category``name``tags``score`。直接把选中的 `iconType` 写入 XML并为图标指定可见颜色
```xml
<icon iconType="iconpark/Charts/chart-line.svg" topLeftX="80" topLeftY="120" width="32" height="32">
<fill>
<fillColor color="rgba(37, 99, 235, 1)"/>
</fill>
</icon>
```
## 使用规则
- 默认先检索:语义图标需求必须先用 `iconpark_tool.py search --limit 8``--limit 10`,让 agent 从候选里结合版面语义二次判断;不要阅读全文索引,也不要编造不存在的 `iconType`
- 图标用于概念提示、步骤、状态、指标、角色和导航;不要用无关装饰图标填充版面。
- 常用尺寸:行内状态图标 16-24px卡片标题图标 28-40px主视觉图标 56-96px。
- 图标必须显式指定颜色并和背景有足够对比;深色背景优先放在浅色圆形/方形底上,或使用 `rgba(255, 255, 255, 1)` 作为图标填充色。
- 查不到合适图标时,用 shape、line、text 画 XML-native fallback不留空图标位。
## 高频示例
| 语义 | iconType |
|---|---|
| 设置/配置 | `iconpark/Base/setting.svg` |
| 目标 | `iconpark/Base/aiming.svg` |
| 增长趋势 | `iconpark/Charts/positive-dynamics.svg` |
| 折线趋势 | `iconpark/Charts/chart-line.svg` |
| 占比 | `iconpark/Charts/chart-proportion.svg` |
| 数据看板 | `iconpark/Charts/data-screen.svg` |
| 成功 | `iconpark/Character/check-one.svg` |
| 失败/风险 | `iconpark/Character/close-one.svg` |
| 团队/用户 | `iconpark/Peoples/peoples.svg` |
| 安全防护 | `iconpark/Safe/protect.svg` |
| 全球/市场 | `iconpark/Travel/world.svg` |
| 邮件/联系 | `iconpark/Office/envelope-one.svg` |

View File

@@ -84,7 +84,7 @@ lark-cli slides +replace-slide --as user \
| `<line>` | 直线 | 需 `startX/startY/endX/endY` |
| `<polyline>` | 折线 | `points` 读回时被服务端规整丢弃(几何已入库) |
| `<img>` | 图片 | `src` 必须是 [`+media-upload`](lark-slides-media-upload.md) 返回的 `file_token`,不能是 URL |
| `<icon>` | 图标 | `iconType` 取自 iconpark 资源 |
| `<icon>` | 图标 | `iconType` 取自 iconpark 资源;语义图标先用 `scripts/iconpark_tool.py search` 检索 |
| `<table>` | 表格 | 整表替换会**重建内部 td id**,旧 td block_id 立即失效 |
| `<td>` | 单元格局部替换 | 只能 `block_replace`,不能 `block_insert``block_id` 必须是最新 `slide.get` 拿到的 td id |
| `<chart>` | 图表line/bar/column/pie/area/radar/combo | 必须嵌 `<chartPlotArea>` + `<chartData>` + `<dim1>/<dim2>/<chartField>` |

View File

@@ -142,6 +142,8 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
<icon iconType="iconpark/Base/setting.svg" topLeftX="80" topLeftY="120" width="32" height="32"/>
```
`iconType` 必须来自已验证的 IconPark 路径。需要语义图标时,先运行 `scripts/iconpark_tool.py search --query "<语义>"`,不要凭记忆拼路径。更多规则见 [iconpark.md](iconpark.md)。
### whiteboard
```xml

View File

@@ -0,0 +1,362 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
from typing import Any
SKILL_ROOT = Path(__file__).resolve().parent.parent
REFERENCES_DIR = SKILL_ROOT / "references"
DEFAULT_INDEX_PATH = REFERENCES_DIR / "iconpark-index.json"
DEFAULT_LIMIT = 8
CURATED_ICON_BOOSTS = {
"设置": {"iconpark/Base/setting.svg"},
"配置": {"iconpark/Base/setting.svg", "iconpark/Base/config.svg"},
"目标": {"iconpark/Base/aiming.svg", "iconpark/Sports/target-one.svg"},
"增长": {"iconpark/Charts/positive-dynamics.svg"},
"趋势": {"iconpark/Charts/chart-line.svg", "iconpark/Charts/positive-dynamics.svg"},
"占比": {"iconpark/Charts/chart-proportion.svg"},
"数据": {"iconpark/Charts/data-screen.svg"},
"看板": {"iconpark/Charts/data-screen.svg"},
"成功": {"iconpark/Character/check-one.svg"},
"完成": {"iconpark/Character/check-one.svg"},
"失败": {"iconpark/Character/close-one.svg"},
"风险": {"iconpark/Character/close-one.svg"},
"团队": {"iconpark/Peoples/peoples.svg"},
"用户": {"iconpark/Peoples/peoples.svg", "iconpark/Peoples/user.svg"},
"安全": {"iconpark/Safe/protect.svg"},
"防护": {"iconpark/Safe/protect.svg"},
"全球": {"iconpark/Travel/world.svg"},
"市场": {"iconpark/Travel/world.svg"},
"邮件": {"iconpark/Office/envelope-one.svg"},
"联系": {"iconpark/Office/envelope-one.svg"},
"会议": {"iconpark/Office/schedule.svg"},
"日程": {"iconpark/Office/schedule.svg"},
"飞书": {"iconpark/Brand/bydesign.svg"},
}
CURATED_BOOST_SCORE = 40
class IconParkToolError(Exception):
pass
def fail(message: str) -> None:
raise IconParkToolError(message)
def normalize_whitespace(value: str) -> str:
return re.sub(r"\s+", " ", value).strip()
def normalize_token(value: str) -> str:
return normalize_whitespace(value.lower().replace("_", "-"))
def append_unique(target: list[str], token: str) -> None:
normalized = normalize_token(token)
if normalized and normalized not in target:
target.append(normalized)
def tokenize_query(value: str) -> list[str]:
normalized = normalize_token(value)
if not normalized:
return []
tokens: list[str] = []
for item in re.split(r"[\s,/|,。;;:()【】\[\]《》<>]+", normalized):
append_unique(tokens, item)
for phrase in re.findall(r"[\u3400-\u9fff]+", normalized):
if len(phrase) < 2:
continue
max_size = min(6, len(phrase))
for size in range(max_size, 1, -1):
for start in range(0, len(phrase) - size + 1):
append_unique(tokens, phrase[start : start + size])
synonym_tokens = {
"目标": ["aim", "target", "goal"],
"聚焦": ["focus", "target"],
"增长": ["growth", "trend", "positive"],
"趋势": ["trend", "chart", "line"],
"数据": ["data", "analytics", "chart"],
"指标": ["metric", "data"],
"看板": ["dashboard", "screen", "data"],
"成功": ["success", "check", "done"],
"完成": ["done", "success", "check"],
"失败": ["fail", "close", "risk"],
"风险": ["risk", "fail", "protect"],
"安全": ["safe", "security", "protect"],
"配置": ["config", "setting", "system"],
"设置": ["setting", "config"],
"团队": ["team", "people", "users"],
"用户": ["user", "people"],
"全球": ["global", "world", "earth"],
"市场": ["market", "world", "business"],
"邮件": ["mail", "message"],
"mail": ["message", "envelope", "envelope-one"],
"计划": ["plan", "schedule"],
"时间": ["time", "schedule"],
"学习": ["learning", "education", "book"],
"培训": ["training", "education"],
"自动化": ["automation", "ai"],
"ai": ["ai", "automation", "magic"],
}
for token in list(tokens):
for keyword, aliases in synonym_tokens.items():
if is_ascii_token(keyword):
matches = token == keyword
else:
matches = keyword in token
if matches:
for alias in aliases:
append_unique(tokens, alias)
return tokens
def is_ascii_token(value: str) -> bool:
return bool(re.fullmatch(r"[a-z0-9-]+", value))
def allows_substring_match(value: str) -> bool:
return not is_ascii_token(value) or len(value) >= 3
def field_tokens(*values: str) -> set[str]:
tokens: set[str] = set()
for value in values:
normalized = normalize_token(value)
if not normalized:
continue
tokens.add(normalized)
for part in re.split(r"[-\s]+", normalized):
if part:
tokens.add(part)
return tokens
def load_index(path: str | Path = DEFAULT_INDEX_PATH) -> dict[str, Any]:
index_path = Path(path)
if not index_path.exists():
fail(f"iconpark index not found: {index_path}")
try:
index_data = json.loads(index_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as error:
fail(f"invalid iconpark index JSON: {error}")
if not isinstance(index_data.get("icons"), list):
fail("iconpark index must contain an icons array")
return index_data
def icon_search_text(entry: dict[str, Any]) -> str:
parts = [
entry.get("iconType", ""),
entry.get("category", ""),
entry.get("name", ""),
" ".join(entry.get("tags") or []),
]
return normalize_token(" ".join(parts))
def score_icon(entry: dict[str, Any], query: str, tokens: list[str]) -> int:
raw_icon_type = entry.get("iconType", "")
icon_type = normalize_token(raw_icon_type)
category = normalize_token(entry.get("category", ""))
name = normalize_token(entry.get("name", ""))
tags = [normalize_token(tag) for tag in entry.get("tags") or []]
name_tokens = field_tokens(name)
category_tokens = field_tokens(category)
tag_tokens = field_tokens(*tags)
icon_type_tokens = field_tokens(icon_type)
search_text = icon_search_text(entry)
normalized_query = normalize_token(query)
score = 0
boosted_keywords: set[str] = set()
if normalized_query:
if normalized_query == icon_type or normalized_query == name:
score += 200
elif normalized_query in tag_tokens:
score += 120
elif normalized_query in icon_type_tokens:
score += 60
elif allows_substring_match(normalized_query) and normalized_query in search_text:
score += 30
for token in tokens:
for keyword, boosted_icon_types in CURATED_ICON_BOOSTS.items():
if keyword in boosted_keywords:
continue
if keyword in token and raw_icon_type in boosted_icon_types:
score += CURATED_BOOST_SCORE
boosted_keywords.add(keyword)
if token == name:
score += 80
elif token in name_tokens:
score += 55
elif allows_substring_match(token) and token in name:
score += 45
if token == category:
score += 35
elif token in category_tokens:
score += 25
elif allows_substring_match(token) and token in category:
score += 15
for tag in tags:
if token == tag:
score += 60
elif token in field_tokens(tag):
score += 45
elif allows_substring_match(token) and token in tag:
score += 20
if token in icon_type_tokens:
score += 20
elif allows_substring_match(token) and token in icon_type:
score += 15
return score
def parse_limit(value: Any) -> int:
if value is None or value is False:
return DEFAULT_LIMIT
if value is True:
fail("limit requires an integer value")
try:
return int(value)
except (TypeError, ValueError):
fail(f"limit must be an integer: {value}")
def public_icon(entry: dict[str, Any], score: int | None = None) -> dict[str, Any]:
result = {
"iconType": entry["iconType"],
"category": entry["category"],
"name": entry["name"],
"tags": entry.get("tags") or [],
}
if score is not None:
result["score"] = score
return result
def search_icons(index_data: dict[str, Any], options: dict[str, Any]) -> list[dict[str, Any]]:
query = str(options.get("query") or "")
if not normalize_whitespace(query):
fail("query is required")
limit = parse_limit(options.get("limit"))
category_filter = normalize_token(str(options.get("category") or ""))
tokens = tokenize_query(query)
ranked: list[dict[str, Any]] = []
for entry in index_data["icons"]:
if category_filter and normalize_token(entry.get("category", "")) != category_filter:
continue
score = score_icon(entry, query, tokens)
if query and score == 0:
continue
ranked.append(public_icon(entry, score))
ranked.sort(key=lambda item: (-int(item["score"]), item["category"], item["name"]))
return ranked[: max(limit, 0)]
def resolve_icon(index_data: dict[str, Any], name_or_type: str | None) -> dict[str, Any]:
if not name_or_type:
fail("name is required")
target = normalize_token(name_or_type)
matches = []
for entry in index_data["icons"]:
candidates = {
normalize_token(entry["iconType"]),
normalize_token(entry["name"]),
normalize_token(f'{entry["category"]}/{entry["name"]}.svg'),
}
if target in candidates:
matches.append(entry)
if not matches:
fail(f"icon not found: {name_or_type}")
if len(matches) > 1:
names = ", ".join(entry["iconType"] for entry in matches)
fail(f"ambiguous icon name: {name_or_type}; matches: {names}")
return public_icon(matches[0])
def list_categories(index_data: dict[str, Any]) -> list[dict[str, Any]]:
counts: dict[str, int] = {}
for entry in index_data["icons"]:
counts[entry["category"]] = counts.get(entry["category"], 0) + 1
return [{"category": category, "count": counts[category]} for category in sorted(counts)]
def parse_cli_args(argv: list[str]) -> tuple[str | None, dict[str, Any]]:
if not argv:
return None, {}
command, *rest = argv
options: dict[str, Any] = {}
index = 0
while index < len(rest):
token = rest[index]
if not token.startswith("--"):
fail(f"unexpected argument: {token}")
key = token[2:]
next_token = rest[index + 1] if index + 1 < len(rest) else None
if next_token is None or next_token.startswith("--"):
options[key] = True
index += 1
continue
options[key] = next_token
index += 2
return command, options
def print_usage() -> None:
usage = [
"Usage:",
" python3 iconpark_tool.py search --query <text> [--category <Category>] [--limit 8]",
" python3 iconpark_tool.py resolve --name <name|iconType>",
" python3 iconpark_tool.py list-categories",
]
print("\n".join(usage), file=sys.stderr)
def write_json(value: Any) -> None:
print(json.dumps(value, ensure_ascii=False, indent=2))
def run_cli(argv: list[str] | None = None) -> None:
command, options = parse_cli_args(argv or sys.argv[1:])
if not command or command in {"--help", "help"}:
print_usage()
raise SystemExit(0)
index_data = load_index()
if command == "search":
write_json(search_icons(index_data, options))
return
if command == "resolve":
write_json(resolve_icon(index_data, options.get("name")))
return
if command == "list-categories":
write_json(list_categories(index_data))
return
print_usage()
fail(f"unknown command: {command}")
if __name__ == "__main__":
try:
run_cli()
except IconParkToolError as error:
print(f"iconpark-tool error: {error}", file=sys.stderr)
raise SystemExit(1) from error

View File

@@ -0,0 +1,177 @@
# Copyright (c) 2026 Lark Technologies Pte. Ltd.
# SPDX-License-Identifier: MIT
from __future__ import annotations
import json
import subprocess
import sys
import unittest
from pathlib import Path
import iconpark_tool
SCRIPT_PATH = Path(__file__).resolve().with_name("iconpark_tool.py")
class IconParkToolTest(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
cls.index_data = iconpark_tool.load_index()
def test_search_icons_finds_growth_trend(self) -> None:
results = iconpark_tool.search_icons(self.index_data, {"query": "增长趋势", "limit": 5})
self.assertTrue(results)
self.assertTrue(
any(entry["iconType"] == "iconpark/Charts/positive-dynamics.svg" for entry in results)
)
def test_search_icons_supports_english_query(self) -> None:
results = iconpark_tool.search_icons(self.index_data, {"query": "security protect", "limit": 3})
self.assertTrue(results)
self.assertEqual(results[0]["iconType"], "iconpark/Safe/protect.svg")
def test_search_icons_supports_category_filter(self) -> None:
results = iconpark_tool.search_icons(
self.index_data,
{"query": "data", "category": "Charts", "limit": 10},
)
self.assertTrue(results)
self.assertTrue(all(entry["category"] == "Charts" for entry in results))
def test_search_icons_does_not_expand_ai_inside_words(self) -> None:
mail_results = iconpark_tool.search_icons(self.index_data, {"query": "mail", "limit": 5})
self.assertEqual(mail_results[0]["iconType"], "iconpark/Office/envelope-one.svg")
self.assertNotEqual(mail_results[0]["iconType"], "iconpark/Others/magic.svg")
fail_results = iconpark_tool.search_icons(self.index_data, {"query": "fail", "limit": 5})
self.assertNotEqual(fail_results[0]["iconType"], "iconpark/Others/magic.svg")
def test_search_icons_supports_template_icon_queries(self) -> None:
cases = [
("arrow", "iconpark/Arrows/arrow-right.svg"),
("right", "iconpark/Arrows/right.svg"),
("PPT", "iconpark/Music/ppt.svg"),
("table", "iconpark/Office/table.svg"),
("会议", "iconpark/Office/schedule.svg"),
("飞书", "iconpark/Brand/bydesign.svg"),
]
for query, icon_type in cases:
with self.subTest(query=query):
results = iconpark_tool.search_icons(self.index_data, {"query": query, "limit": 10})
self.assertTrue(
any(entry["iconType"] == icon_type for entry in results),
f"{icon_type} not found in {results}",
)
def test_search_icons_defaults_to_wider_candidate_set(self) -> None:
results = iconpark_tool.search_icons(self.index_data, {"query": "data"})
self.assertEqual(len(results), 8)
def test_search_icons_boosts_common_slide_terms(self) -> None:
results = iconpark_tool.search_icons(self.index_data, {"query": "会议", "limit": 3})
self.assertTrue(
any(entry["iconType"] == "iconpark/Office/schedule.svg" for entry in results),
f"iconpark/Office/schedule.svg not found in {results}",
)
def test_search_icons_keeps_high_value_top_results(self) -> None:
cases = [
("安全", "iconpark/Safe/protect.svg"),
("邮件", "iconpark/Office/mail-open.svg"),
("会议", "iconpark/Office/schedule.svg"),
("增长趋势", "iconpark/Charts/chart-line.svg"),
("飞书", "iconpark/Brand/bydesign.svg"),
]
for query, icon_type in cases:
with self.subTest(query=query):
results = iconpark_tool.search_icons(self.index_data, {"query": query, "limit": 3})
self.assertTrue(results)
self.assertEqual(results[0]["iconType"], icon_type)
def test_search_icons_requires_query(self) -> None:
with self.assertRaises(iconpark_tool.IconParkToolError):
iconpark_tool.search_icons(self.index_data, {"limit": 5})
def test_search_icons_rejects_invalid_limit(self) -> None:
with self.assertRaises(iconpark_tool.IconParkToolError):
iconpark_tool.search_icons(self.index_data, {"query": "data", "limit": "abc"})
def test_resolve_icon_accepts_name_and_icon_type(self) -> None:
by_name = iconpark_tool.resolve_icon(self.index_data, "chart-line")
by_type = iconpark_tool.resolve_icon(self.index_data, "iconpark/Charts/chart-line.svg")
self.assertEqual(by_name["iconType"], "iconpark/Charts/chart-line.svg")
self.assertEqual(by_name, by_type)
def test_resolve_icon_accepts_template_icon_type(self) -> None:
result = iconpark_tool.resolve_icon(self.index_data, "iconpark/Arrows/arrow-right.svg")
self.assertEqual(result["iconType"], "iconpark/Arrows/arrow-right.svg")
def test_resolve_icon_rejects_unknown_name(self) -> None:
with self.assertRaises(iconpark_tool.IconParkToolError):
iconpark_tool.resolve_icon(self.index_data, "not-a-real-icon")
def test_list_categories_counts_index(self) -> None:
categories = iconpark_tool.list_categories(self.index_data)
self.assertTrue(any(entry["category"] == "Charts" and entry["count"] > 0 for entry in categories))
class IconParkToolCLITest(unittest.TestCase):
def run_tool(self, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(SCRIPT_PATH), *args],
capture_output=True,
check=False,
text=True,
)
def test_cli_search_writes_json_to_stdout(self) -> None:
result = self.run_tool("search", "--query", "增长趋势", "--limit", "5")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stderr, "")
output = json.loads(result.stdout)
self.assertTrue(output)
self.assertTrue(
any(entry["iconType"] == "iconpark/Charts/positive-dynamics.svg" for entry in output)
)
def test_cli_resolve_writes_json_to_stdout(self) -> None:
result = self.run_tool("resolve", "--name", "chart-line")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stderr, "")
output = json.loads(result.stdout)
self.assertEqual(output["iconType"], "iconpark/Charts/chart-line.svg")
def test_cli_list_categories_writes_json_to_stdout(self) -> None:
result = self.run_tool("list-categories")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stderr, "")
output = json.loads(result.stdout)
self.assertTrue(any(entry["category"] == "Charts" and entry["count"] > 0 for entry in output))
def test_cli_help_writes_usage_to_stderr(self) -> None:
result = self.run_tool("--help")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, "")
self.assertIn("Usage:", result.stderr)
self.assertIn("python3 iconpark_tool.py search", result.stderr)
def test_cli_invalid_argument_writes_error_to_stderr(self) -> None:
result = self.run_tool("search", "增长趋势")
self.assertEqual(result.returncode, 1)
self.assertEqual(result.stdout, "")
self.assertIn("iconpark-tool error: unexpected argument: 增长趋势", result.stderr)
def test_cli_unknown_command_writes_usage_and_error_to_stderr(self) -> None:
result = self.run_tool("unknown")
self.assertEqual(result.returncode, 1)
self.assertEqual(result.stdout, "")
self.assertIn("Usage:", result.stderr)
self.assertIn("iconpark-tool error: unknown command: unknown", result.stderr)
if __name__ == "__main__":
unittest.main()