Compare commits

...

4 Commits

Author SHA1 Message Date
niezhiwei
f2b77acfdb fix(slides): lint 放行并识别 embed 嵌入元素
schema 已支持 <embed> 嵌入 SVG 协议,但版式准出 lint 未同步:

- SXSD 标签校验仅在 <whiteboard> 祖先下放行 SVG 命名空间子树,
  <embed> 内的合法 <svg> 会被判 sxsd_unsupported_tag 并 block
- extract_elements 不提取 embed,仅含 embed 的页面被误报
  blank_slide,也不参与重叠/密度检查

修复:SVG 子树白名单加入 embed 祖先并更新 svg 报错 hint;embed
进入元素提取以及 slide_content_visual_bbox、is_large_visual_child
的视觉元素集合。新增 embed 内 svg 放行、embed-only 不报空白页
两个测试,extract_elements 测试覆盖 embed。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 11:59:13 +08:00
niezhiwei
91edc443e8 feat(slides): 支持嵌入 SVG 内容协议
同步 ai_xsd MR 62(feat-vector-svg-protocol)到 CLI 的 SML schema 副本:

- schema 新增 embed 元素:嵌入内容容器,外层承载 Slides 摆放和效果
  属性(topLeftX/topLeftY/width/height 必填,rotation/flipX/flipY/alpha
  可选),内层为标准 SVG(http://www.w3.org/2000/svg 命名空间),
  可选 reflection/shadow 子元素
- data 容器 choice 列表注册 sml:embed
- xml-schema-quick-ref.md 同步 data 元素列表并新增 embed 小节与示例

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 11:30:29 +08:00
R0bynZhu
358cd06838 docs(slides): 补齐 shortcut 参数说明,修正 +xml-get --output 必填标注 (#2088)
* docs(slides): consolidate CWD-relative path rule into one global rule

State the "all local file path args must be CWD-relative (absolute
rejected)" rule once in SKILL.md 权威经验, and trim the per-command
repetitions in media-upload / create / screenshot / xml-presentations-get.
Also fix the stale xml-presentations-get param table: --output is optional
(relative), not required.

* feat: try common solution

* chore: 优化措辞

* feat: 优化措辞

* feat: 优化措辞

* docs(slides): 强调调用命令前必读对应命令文档

- 「调用命令前再读」改为「调用相关命令前必须读取相关的文档以了解命令的使用方式」,
  并把原「按需再读」列表合并进来,去掉可选语义
- 移除 lark-shared 的 CRITICAL 前置阅读要求
- Step 4 回读示例补全 `--presentation <xml_presentation_id>` 参数

* docs(slides): Shortcuts 表补充 +screenshot 并写明本地路径参数

- 新增 +screenshot 行:--slide-number 页号(从 1 开始,可重复,一次最多 10 页)、
  --output-dir 保存目录(CWD 内相对路径,默认 .lark-slides/screenshots)
- +xml-get 行补上 --presentation 和 --output(CWD 内相对路径),
  并说明省略 --output 时 XML 返回在 JSON 信封里

* revert(slides): 回退 references 下的文档改动,只保留 SKILL.md

把 lark-slides-create.md、lark-slides-media-upload.md、lark-slides-screenshot.md、
lark-slides-xml-presentations-get.md 还原为 main 的版本,本分支只改 SKILL.md。

* docs(slides): 恢复开始前必读 lark-shared 的 CRITICAL 要求

认证、权限和全局参数以 lark-shared 为准,这条前置阅读不该在本分支被删掉。

* chore: 移除output省略的说明
2026-07-29 10:55:05 +08:00
Yuxuan Zhao
b0b1ca4b5d test(e2e): wait for base role update visibility (#2087) 2026-07-28 21:45:00 +08:00
6 changed files with 136 additions and 23 deletions

View File

@@ -141,7 +141,7 @@ lark-cli auth login --domain slides
- [asset-planning.md](references/asset-planning.md)(新建 / 大幅改写)
- [validation-checklist.md](references/validation-checklist.md)(创建 / 大幅改写后)
按需再读
调用相关命令前必须读取相关的文档以了解命令的使用方式
- 创建:[`lark-slides-create.md`](references/lark-slides-create.md)
- 阅读:[`lark-slides-xml-presentations-get.md`](references/lark-slides-xml-presentations-get.md)
@@ -223,7 +223,7 @@ Step 3: 按 slide_plan.json 生成 XML → 创建
- 创建方式按“创建方式选择”判断;图片、复杂 XML、转义和 3350001 排查按 lark-slides-create.md、media-upload.md、troubleshooting.md 执行
Step 4: 审查 & 交付
- 创建完成后,必须用 `slides +xml-get` 读取全文 XML并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
- 创建完成后,必须用 `slides +xml-get --presentation <xml_presentation_id>` 读取全文 XML并按 validation-checklist.md 做显式验证记录,包括 XML 文本重叠检查
- 失败或部分成功按 troubleshooting.md 处理;局部问题优先用 `+replace-slide` 修正
- 没问题 → 交付:使用 NotifyHuman 工具交付 PPT 链接
```
@@ -314,7 +314,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli slides +<verb> [flags]`
| Shortcut | 说明 |
|----------|------|
| [`+create`](references/lark-slides-create.md) | 创建 PPT可选 `--slides` 一步添加页面,支持 `<img src="@./local.png">` 占位符自动上传) |
| [`+xml-get`](references/lark-slides-xml-presentations-get.md) | 读取全文 XML 并保存到本地文件,避免终端输出被截断 |
| [`+xml-get`](references/lark-slides-xml-presentations-get.md) | 读取全文 XML,用 `--presentation` 指定演示文稿的 `xml_presentation_id`,用 `--output` 把 XML 存到本地文件(必须是 CWD 内的相对路径,如 `.lark-slides/plan/<deck>/readback.xml` |
| [`+screenshot`](references/lark-slides-screenshot.md) | 把幻灯片页面截图保存为本地图片,用 `--slide-number` 指定页号(从 1 开始,多页重复传入,一次最多 10 页),用 `--output-dir` 指定保存目录(必须是 CWD 内的相对路径,默认 `.lark-slides/screenshots`),失败时降级到 XML 回读等非截图检查 |
| [`+media-upload`](references/lark-slides-media-upload.md) | 上传本地图片到指定演示文稿,返回 `file_token`(用作 `<img src="...">`),最大 20 MB |
| [`+replace-slide`](references/lark-slides-replace-slide.md) | 对已有幻灯片页面进行块级替换/插入(`block_replace` / `block_insert`),自动注入 id 和 `<content/>`,不改变页序 |
| [`+replace-pages`](references/lark-slides-replace-pages.md) | 在原演示文稿内批量重建多个页面:先创建新页到旧页前,再删除旧页;适合已有 Slides 的多页大改,不新建链接 |

View File

@@ -1076,6 +1076,7 @@
<xs:element ref="sml:img"/>
<xs:element ref="sml:table"/>
<xs:element ref="sml:icon"/>
<xs:element ref="sml:embed"/>
<xs:element ref="sml:chart"/>
<xs:element ref="sml:undefined"/>
</xs:choice>
@@ -1622,6 +1623,49 @@
</xs:complexType>
</xs:element>
<!-- 嵌入内容元素 -->
<xs:element name="embed">
<xs:annotation>
<xs:documentation>
嵌入内容容器, 外层承载 Slides 摆放和效果属性, 内层承载外部标准内容。
当前内层内容为标准 SVG, 必须使用 http://www.w3.org/2000/svg 命名空间。
后续可扩展承载 HTML 等其他外部内容。
必需属性:
- topLeftX/topLeftY: 左上角坐标
- width/height: 显示尺寸
可选属性:
- id: 唯一标识符
- rotation: 旋转角度[0, 360)
- flipX/flipY: 水平/垂直翻转
- alpha: 不透明度[0,1]
子元素:
- svg: 标准 SVG 根元素, 仅描述嵌入内容, 不承载 Slides 布局属性
- reflection: 倒影样式, 无reflection标签代表无倒影, 空reflection标签代表使用默认样式
- shadow: 阴影样式, 无shadow标签代表无阴影, 空shadow标签代表使用默认样式
</xs:documentation>
</xs:annotation>
<xs:complexType>
<xs:sequence>
<xs:any namespace="http://www.w3.org/2000/svg" processContents="lax" minOccurs="1" maxOccurs="1"/>
<xs:element name="reflection" type="sml:ReflectionType" minOccurs="0"/>
<xs:element name="shadow" type="sml:ShadowType" minOccurs="0"/>
</xs:sequence>
<xs:attribute name="id" type="xs:string" use="optional"/>
<xs:attribute name="topLeftX" type="sml:XType" use="required"/>
<xs:attribute name="topLeftY" type="sml:YType" use="required"/>
<xs:attribute name="width" type="sml:PositiveSize" use="required"/>
<xs:attribute name="height" type="sml:PositiveSize" use="required"/>
<xs:attribute name="rotation" type="sml:RotationType" use="optional" default="0"/>
<xs:attribute name="flipX" type="xs:boolean" use="optional" default="false"/>
<xs:attribute name="flipY" type="xs:boolean" use="optional" default="false"/>
<xs:attribute name="alpha" type="sml:AlphaType" use="optional" default="1"/>
</xs:complexType>
</xs:element>
<!-- 表格元素 -->
<xs:element name="table">
<xs:annotation>

View File

@@ -81,7 +81,7 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
**子元素:**
- `<style>?` - 页面样式,目前可放 `<fill>`
- `<data>?` - 页面元素容器,可放 `shape``line``polyline``img``table``icon``chart``undefined`
- `<data>?` - 页面元素容器,可放 `shape``line``polyline``img``table``icon``embed``chart``undefined`
- `<note>?` - 演讲者备注,内部可放 `<content>`
这意味着 `<title>``<headline>``<body>``<caption>` 不能直接放在 `<slide>` 下。
@@ -355,6 +355,20 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
详细用法见 [slides_xml_schema_definition.xml](slides_xml_schema_definition.xml)。
### embed
嵌入内容容器:外层 `<embed>` 承载 Slides 的摆放和效果属性(`topLeftX`/`topLeftY`/`width`/`height` 必填,`rotation`/`flipX`/`flipY`/`alpha` 可选),内层承载外部标准内容。当前内层内容为标准 SVG`<svg>` 必须使用 `http://www.w3.org/2000/svg` 命名空间,且仅描述嵌入内容本身,不承载 Slides 布局属性。
```xml
<embed topLeftX="80" topLeftY="120" width="200" height="120">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 120">
<circle cx="100" cy="60" r="40" fill="rgba(37, 99, 235, 1)"/>
</svg>
</embed>
```
`<embed>` 直接子元素为一个 `<svg>`(必需),以及可选的 `<reflection>`(倒影)与 `<shadow>`(阴影)。
## 颜色与样式
### fill

View File

@@ -403,7 +403,7 @@ def build_sxsd_tag_hint(tag_name: str, supported_tags: set[str]) -> str:
if alias:
return f"Use {alias} instead of <{tag_name}>."
if tag_name == "svg":
return 'Inside <whiteboard>, write SVG as <svg xmlns="http://www.w3.org/2000/svg">...</svg>.'
return 'Inside <embed> or <whiteboard>, write SVG as <svg xmlns="http://www.w3.org/2000/svg">...</svg>.'
close_matches = get_close_matches(tag_name, sorted(supported_tags), n=3, cutoff=0.72)
if close_matches:
return "Unsupported SXSD tag. Did you mean " + ", ".join(f"<{match}>" for match in close_matches) + "?"
@@ -424,7 +424,7 @@ def build_sxsd_attr_hint(tag_name: str, attr_name: str, allowed_attrs: set[str])
def should_skip_sxsd_subtree(element: ET.Element, ancestors: list[str]) -> bool:
return "whiteboard" in ancestors and xml_namespace(element.tag) == SVG_NS
return ("whiteboard" in ancestors or "embed" in ancestors) and xml_namespace(element.tag) == SVG_NS
def should_skip_sxsd_attribute(tag_name: str, attr_name: str) -> bool:
@@ -702,7 +702,7 @@ def parse_presentation(xml: str) -> dict[str, Any]:
def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
elements: list[dict[str, Any]] = []
for match in re.finditer(r"<(shape|img|table|chart|whiteboard)\b([^>]*)>", slide_xml):
for match in re.finditer(r"<(shape|img|table|chart|whiteboard|embed)\b([^>]*)>", slide_xml):
kind, attrs = match.group(1), match.group(2)
is_self_closing = attrs.rstrip().endswith("/")
content = ""
@@ -1917,7 +1917,7 @@ def slide_content_visual_bbox(
# a straight horizontal/vertical line has zero width or height in one axis; clipped_bbox
# treats zero-area rects as invisible, so pad to its rendered stroke thickness instead.
return clipped_bbox(line_stroke_bbox(element), slide_bbox)
if element["kind"] in {"img", "chart", "table", "whiteboard", "icon", "polyline"}:
if element["kind"] in {"img", "chart", "table", "whiteboard", "embed", "icon", "polyline"}:
return clipped_bbox(element, slide_bbox)
return None
@@ -1955,7 +1955,7 @@ def is_slide_content_present(
def is_large_visual_child(element: dict[str, Any], container: dict[str, Any]) -> bool:
if element["kind"] not in {"img", "chart", "table", "whiteboard"}:
if element["kind"] not in {"img", "chart", "table", "whiteboard", "embed"}:
return False
if not is_visually_rendered(element):
return False

View File

@@ -265,6 +265,27 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 0)
def test_lint_xml_allows_svg_subtree_inside_embed(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<embed topLeftX="80" topLeftY="120" width="240" height="140">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 140">
<rect x="10" y="10" width="220" height="120" rx="12" fill="#EFF6FF"/>
<circle cx="70" cy="70" r="34" fill="#2563EB"/>
<text x="130" y="76" font-size="18" fill="#1E3A8A">SVG OK</text>
</svg>
</embed>
</data>
</slide>
"""
)
codes = [issue["code"] for issue in result.get("issues", [])]
self.assertNotIn("sxsd_unsupported_tag", codes)
self.assertNotIn("sxsd_unsupported_attr", codes)
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_reports_sxsd_unsupported_tag_with_alias_hint(self) -> None:
cases = [
("textbox", '<textbox topLeftX="80" topLeftY="80" width="300" height="60">Text</textbox>', '<shape type="text">'),
@@ -1356,6 +1377,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
<table id="table" topLeftX="400" topLeftY="60" width="220" height="120"></table>
<chart id="chart" topLeftX="640" topLeftY="60" width="220" height="120"/>
<whiteboard id="wb" topLeftX="80" topLeftY="220" width="760" height="240"/>
<embed id="emb" topLeftX="600" topLeftY="320" width="240" height="140">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 140"><rect x="0" y="0" width="240" height="140"/></svg>
</embed>
<shape id="missing-height" type="text" topLeftX="80" topLeftY="480" width="320">
<content><p>Skipped</p></content>
</shape>
@@ -1363,9 +1387,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
</slide>
"""
)
self.assertEqual([element["id"] for element in elements], ["photo", "headline", "table", "chart", "wb"])
self.assertEqual([element["kind"] for element in elements], ["img", "shape", "table", "chart", "whiteboard"])
self.assertEqual([element["order"] for element in elements], [0, 1, 2, 3, 4])
self.assertEqual([element["id"] for element in elements], ["photo", "headline", "table", "chart", "wb", "emb"])
self.assertEqual([element["kind"] for element in elements], ["img", "shape", "table", "chart", "whiteboard", "embed"])
self.assertEqual([element["order"] for element in elements], [0, 1, 2, 3, 4, 5])
self.assertEqual(elements[1]["type"], "text")
self.assertEqual(elements[1]["textType"], "headline")
self.assertEqual(elements[1]["textAlign"], "center")
@@ -2404,6 +2428,25 @@ class XmlTextOverlapLintDensityTest(unittest.TestCase):
self.assertEqual(result["slides"][0]["issues"], [])
def test_lint_xml_does_not_report_blank_slide_for_embed_only_content(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<embed id="emb" topLeftX="280" topLeftY="130" width="400" height="280">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 280">
<circle cx="200" cy="140" r="100" fill="#2563EB"/>
</svg>
</embed>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertNotIn("blank_slide", codes)
def test_lint_xml_does_not_report_blank_slide_for_line_only_content(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""

View File

@@ -111,18 +111,29 @@ func TestBase_RoleWorkflow(t *testing.T) {
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
getResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+role-get", "--base-token", baseToken, "--role-id", roleID},
DefaultAs: "bot",
})
require.NoError(t, err)
getResult.AssertExitCode(t, 0)
getResult.AssertStdoutStatus(t, true)
pollTimeout := 30 * time.Second
pollCtx, pollCancel := context.WithTimeout(ctx, pollTimeout)
defer pollCancel()
rolePayload := gjson.Get(getResult.Stdout, "data.data").String()
require.NotEmpty(t, rolePayload, "stdout:\n%s", getResult.Stdout)
require.True(t, gjson.Valid(rolePayload), "stdout:\n%s", getResult.Stdout)
assert.Equal(t, updatedRoleName, gjson.Get(rolePayload, "role_name").String())
err = clie2e.WaitForCondition(pollCtx, clie2e.WaitOptions{
Timeout: pollTimeout,
Interval: 3 * time.Second,
}, func() (bool, error) {
getResult, getErr := clie2e.RunCmd(pollCtx, clie2e.Request{
Args: []string{"base", "+role-get", "--base-token", baseToken, "--role-id", roleID},
DefaultAs: "bot",
})
if getErr != nil {
return false, getErr
}
if getResult.ExitCode != 0 {
return false, getResult.RunErr
}
rolePayload := gjson.Get(getResult.Stdout, "data.data").String()
return gjson.Valid(rolePayload) && gjson.Get(rolePayload, "role_name").String() == updatedRoleName, nil
})
require.NoError(t, err, "role name should converge to %q", updatedRoleName)
})
}