Compare commits

...

2 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
4 changed files with 110 additions and 9 deletions

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(
"""