Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion skills/lark-slides/references/validation-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ lark-cli slides +xml-get --as user \
python3 "<lark-slides-skill-dir>/scripts/xml_text_overlap_lint.py" --input <presentation.xml>
```

它一次检查 XML/SXSD 合法性、元素越界、文本重叠、空白页、文本高度风险、整页内容稀疏和大卡片内容覆盖率。大卡片自身 `<content>` 的估算文本面积与卡片内平级元素一起参与覆盖率并集计算。
它一次检查 XML/SXSD 合法性、元素越界、文本重叠、线条穿字、文本越出候选卡片、空白页、文本高度风险、整页内容稀疏和大卡片内容覆盖率。大卡片自身 `<content>` 的估算文本面积与卡片内平级元素一起参与覆盖率并集计算。

准出规则:

Expand Down Expand Up @@ -64,6 +64,8 @@ python3 "<lark-slides-skill-dir>/scripts/xml_text_overlap_lint.py" --input <pres
| `icon_missing_fill_color` | 视觉规范要求 `<icon>` 设置 `<fill><fillColor color="..."/></fill>`,避免图标不可见 | 给 `<icon>` 添加显式非透明填充色,例如 `rgba(37, 99, 235, 1)` |
| `icon_transparent_fill_color` | `<icon>` 的 `fillColor` 是透明色,不满足视觉可见性要求 | 改成与背景有足够对比的非透明颜色 |
| `bbox_overlap` | 文本元素的估算绘制区域明显重叠 | 拉开文本坐标、缩小文本框/字号,或改成明确的分栏/分组结构 |
| `line_crosses_text` | 线条与非空文本框的声明边界相交 | 结合截图确认是否为有意删除线;否则缩短/移动线条或移动文本 |
| `text_outside_container` | 文本框属于候选矩形卡片,且估算的实际文字区域触碰或越过卡片底边 | 结合截图确认卡片归属与实际字形;增高卡片、上移文本或调整文字排版 |
| `*_out_of_canvas` | 元素边界超出页面画布 | 根据 `measurement.overflow` 移回画布或缩小尺寸 |
| `blank_slide` | 页面没有画布内可见内容 | 补充主体内容;仅有空背景或空形状不能准出 |
| `sparse_container_content` | 大卡片内容覆盖率低于阈值 | 按元素 ID 定位卡片,结合截图判断是否补充或放大内容 |
Expand Down
150 changes: 150 additions & 0 deletions skills/lark-slides/scripts/xml_text_overlap_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -1644,6 +1644,146 @@ def line_stroke_bbox(element: dict[str, Any]) -> dict[str, Any]:
return {**element, "width": max(element["width"], 1), "height": max(element["height"], 1)}


def detect_lines_crossing_text(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
lines = [
element
for element in elements
if element["kind"] == "line"
and is_visually_rendered(element)
and (element["width"] == 0 or element["height"] == 0)
]
Comment on lines +1652 to +1655

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Detect diagonal line crossings instead of excluding them.

Lines 1652–1655 discard every diagonal <line>, so a diagonal connector that actually crosses text can never warn. Use segment-vs-visual-text-bounds intersection; that avoids the filled-AABB false positive covered by the existing negative test while detecting real crossings.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@skills/lark-slides/scripts/xml_text_overlap_lint.py` around lines 1652 -
1655, Update the visual line filtering logic in the overlap-lint collection to
retain diagonal lines instead of excluding them based on zero width or height.
Use segment-versus-visual-text-bounds intersection when evaluating line/text
overlap, preserving the existing negative-test behavior that rejects filled-AABB
false positives while warning on actual diagonal crossings.

texts = [
element
for element in elements
if is_text_element(element)
and is_visually_rendered(element)
and has_text_content(element)
and not is_decorative_text(element)
]
for line in lines:
stroke_bbox = line_stroke_bbox(line)
for text in texts:
width = intersection_width(stroke_bbox, text)
height = intersection_height(stroke_bbox, text)
Comment on lines +1656 to +1668

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Use glyph bounds and XML draw order for line_crosses_text.

The detector intersects every line with the full declared text box and never checks stacking order. This warns for background lines rendered beneath text and for lines passing only through unused text-box whitespace, contrary to the glyph-bound/z-order objective.

  • skills/lark-slides/scripts/xml_text_overlap_lint.py#L1656-L1668: preserve XML order for lines, skip lines behind text, and intersect estimate_text_visual_bbox(text) rather than text.
  • skills/lark-slides/scripts/xml_text_overlap_lint_test.py#L625-L645: put the warning line above the text in draw order; add negative coverage for background lines and glyph-whitespace intersections.
  • skills/lark-slides/references/validation-checklist.md#L67-L67: describe intersection with estimated glyph bounds rather than declared text-box bounds.

Based on supplied PR objectives: the detector must use estimated glyph bounds and z-order.

📍 Affects 3 files
  • skills/lark-slides/scripts/xml_text_overlap_lint.py#L1656-L1668 (this comment)
  • skills/lark-slides/scripts/xml_text_overlap_lint_test.py#L625-L645
  • skills/lark-slides/references/validation-checklist.md#L67-L67
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@skills/lark-slides/scripts/xml_text_overlap_lint.py` around lines 1656 -
1668, Update line_crosses_text in
skills/lark-slides/scripts/xml_text_overlap_lint.py (1656-1668) to preserve XML
draw order, skip lines rendered behind text, and intersect each line with
estimate_text_visual_bbox(text) instead of the full text box. In
skills/lark-slides/scripts/xml_text_overlap_lint_test.py (625-645), place the
warning line above the text and add negative cases for background lines and
glyph-whitespace-only intersections. Update
skills/lark-slides/references/validation-checklist.md (67-67) to describe
estimated glyph-bound intersections.

if width <= 0 or height <= 0:
continue
font_size = text["fontSize"] if isinstance(text.get("fontSize"), (int, float)) else 16
if max(width, height) < max(8, font_size * 0.5):
continue
issues.append(
{
"level": "warning",
"code": "line_crosses_text",
"elements": [line["id"], text["id"]],
"measurement": {
"intersection_width": round(width, 3),
"intersection_height": round(height, 3),
"intersection_area": round(width * height, 3),
},
"message": f'line {line["id"]} crosses the declared bounds of text shape {text["id"]}',
"hint": (
"Inspect the rendered slide. If the line is not an intentional strike-through, shorten or "
"move the line, or move the text so their bounds no longer intersect."
),
}
)
return issues


TEXT_CONTAINER_ASSOCIATION_TOLERANCE = 2
MIN_TEXT_CONTAINER_AREA = 4_000


def text_container_bottom_overflow(
container: dict[str, Any], text: dict[str, Any]
) -> dict[str, int | float] | None:
container_right = container["x"] + container["width"]
container_bottom = container["y"] + container["height"]
text_right = text["x"] + text["width"]
text_bottom = text["y"] + text["height"]
horizontally_contained = (
text["x"] >= container["x"] - TEXT_CONTAINER_ASSOCIATION_TOLERANCE
and text_right <= container_right + TEXT_CONTAINER_ASSOCIATION_TOLERANCE
)
starts_inside = (
text["y"] >= container["y"] - TEXT_CONTAINER_ASSOCIATION_TOLERANCE
and text["y"] < container_bottom
)
declared_overflow = text_bottom - container_bottom
max_plausible_overflow = max(24, text["height"] * 0.75)
if not horizontally_contained or not starts_inside:
return None
if declared_overflow < 0 or declared_overflow > max_plausible_overflow:
return None

visual_bbox = estimate_text_visual_bbox(text)
if visual_bbox is None:
return None
visual_overflow = visual_bbox["y"] + visual_bbox["height"] - container_bottom
if visual_overflow < 0:
return None
return {
"visual": visual_overflow,
"declared": declared_overflow,
}


def detect_text_outside_containers(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
containers = [
element
for element in elements
if element["kind"] == "shape"
and element["type"] == "rect"
and is_visually_rendered(element)
and element_area(element) >= MIN_TEXT_CONTAINER_AREA
]
for text in (
element
for element in elements
if is_text_element(element)
and is_visually_rendered(element)
and has_text_content(element)
and not is_decorative_text(element)
):
candidates = [
(container, text_container_bottom_overflow(container, text))
for container in containers
if container["order"] < text["order"]
]
candidates = [(container, overflow) for container, overflow in candidates if overflow is not None]
if not candidates:
continue
container, overflow = min(candidates, key=lambda candidate: element_area(candidate[0]))
visual_overflow = overflow["visual"]
declared_overflow = overflow["declared"]
issues.append(
{
"level": "warning",
"code": "text_outside_container",
"elements": [container["id"], text["id"]],
"measurement": {
"overflow": {"bottom": round(visual_overflow, 3)},
"declared_overflow": {"bottom": round(declared_overflow, 3)},
},
"message": (
f'estimated text in shape {text["id"]} '
+ (
f'touches the bottom edge of candidate container {container["id"]}'
if math.isclose(visual_overflow, 0, abs_tol=1e-9)
else f'extends {visual_overflow:g}px below candidate container {container["id"]}'
)
),
"hint": (
"Inspect the rendered slide to confirm the card relationship. Increase the container height, "
"move the text upward, or reduce the text height so it stays inside the card."
),
}
)
return issues


def is_slide_content_present(
element: dict[str, Any], slide_bbox: dict[str, int | float]
) -> bool:
Expand Down Expand Up @@ -1843,6 +1983,14 @@ def detect_blank_slide(
"name": "text_visual_bounds_do_not_overlap",
"comparison": "intersection_area == 0",
},
"line_crosses_text": {
"name": "line_does_not_cross_text_bounds",
"comparison": "intersection_area == 0",
},
"text_outside_container": {
"name": "text_stays_inside_candidate_container",
"comparison": "estimated_visual_bottom_overflow < 0",
},
"text_may_overflow_shape": {
"name": "estimated_text_fits_declared_shape",
"comparison": "estimated_height <= available_height",
Expand Down Expand Up @@ -2125,6 +2273,8 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
raw_issues = [
*geometry["issues"],
*extra_overflow_issues,
*detect_lines_crossing_text(density_elements),
*detect_text_outside_containers(density_elements),
*detect_blank_slide(
density_elements,
slide_number,
Expand Down
169 changes: 169 additions & 0 deletions skills/lark-slides/scripts/xml_text_overlap_lint_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,175 @@ def test_lint_xml_detects_horizontal_text_overflow_across_declared_box_gap(self)
self.assertGreater(issue["measurement"]["intersection_area"], 0)
self.assertIsNotNone(issue.get("hint"))

def test_lint_xml_warns_when_timeline_line_crosses_text(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<line id="bAi" startX="60" startY="360" endX="900" endY="360">
<border color="rgba(82, 82, 91, 1)" width="2"/>
</line>
<shape id="bAR" type="rect" topLeftX="780" topLeftY="330" width="140" height="100"/>
<shape id="bAw" type="text" topLeftX="790" topLeftY="345" width="120" height="20">
<content fontSize="16"><p>阶段总结</p></content>
</shape>
</data>
</slide>
"""
)
issues = [issue for issue in result["slides"][0]["issues"] if issue["code"] == "line_crosses_text"]
self.assertEqual(len(issues), 1)
self.assertEqual(issues[0]["level"], "warning")
self.assertEqual(issues[0]["elements"], ["bAi", "bAw"])
self.assertGreater(issues[0]["measurement"]["intersection_width"], 0)

def test_lint_xml_allows_line_outside_text_bounds(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="heading" type="text" topLeftX="60" topLeftY="80" width="300" height="40">
<content fontSize="24"><p>章节标题</p></content>
</shape>
<line id="underline" startX="60" startY="125" endX="160" endY="125"/>
</data>
</slide>
"""
)
self.assertFalse(
any(issue["code"] == "line_crosses_text" for issue in result["slides"][0]["issues"])
)

def test_lint_xml_does_not_approximate_diagonal_line_as_filled_bbox(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<line id="diagonal" startX="60" startY="80" endX="360" endY="280"/>
<shape id="label" type="text" topLeftX="60" topLeftY="240" width="120" height="20">
<content fontSize="16"><p>不在线段上</p></content>
</shape>
</data>
</slide>
"""
)
self.assertFalse(
any(issue["code"] == "line_crosses_text" for issue in result["slides"][0]["issues"])
)

def test_lint_xml_warns_when_estimated_text_exceeds_card_bottom(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="bPo" type="rect" topLeftX="60" topLeftY="370" width="280" height="100"/>
<shape id="bxb" type="text" topLeftX="80" topLeftY="455" width="240" height="20">
<content fontSize="13"><p>接种流感疫苗,做好日常防护</p></content>
</shape>
</data>
</slide>
"""
)
issues = [issue for issue in result["slides"][0]["issues"] if issue["code"] == "text_outside_container"]
self.assertEqual(len(issues), 1)
self.assertEqual(issues[0]["level"], "warning")
self.assertEqual(issues[0]["elements"], ["bPo", "bxb"])
self.assertEqual(issues[0]["measurement"]["overflow"], {"bottom": 2.8})
self.assertEqual(issues[0]["measurement"]["declared_overflow"], {"bottom": 5})

def test_lint_xml_warns_when_estimated_text_exceeds_smallest_card_bottom(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="background" type="rect" topLeftX="0" topLeftY="0" width="960" height="540"/>
<shape id="bWD" type="rect" topLeftX="60" topLeftY="170" width="400" height="310"/>
<shape id="bWa" type="text" topLeftX="80" topLeftY="470" width="360" height="20">
<content fontSize="13"><p>保持规律作息,增强身体抵抗力</p></content>
</shape>
</data>
</slide>
"""
)
issues = [issue for issue in result["slides"][0]["issues"] if issue["code"] == "text_outside_container"]
self.assertEqual(len(issues), 1)
self.assertEqual(issues[0]["elements"], ["bWD", "bWa"])
self.assertEqual(issues[0]["measurement"]["overflow"], {"bottom": 7.8})
self.assertEqual(issues[0]["measurement"]["declared_overflow"], {"bottom": 10})

def test_lint_xml_warns_when_estimated_text_exceeds_card_by_less_than_two_pixels(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="300" topLeftY="245" width="360" height="120"/>
<shape id="caption" type="text" topLeftX="330" topLeftY="340.5" width="300" height="30">
<content fontSize="19" textAlign="center" verticalAlign="middle">
<p>GLYPH OVERFLOW</p>
</content>
</shape>
</data>
</slide>
"""
)
issues = [issue for issue in result["slides"][0]["issues"] if issue["code"] == "text_outside_container"]
self.assertEqual(len(issues), 1)
self.assertEqual(issues[0]["elements"], ["card", "caption"])
self.assertEqual(issues[0]["measurement"]["overflow"], {"bottom": 1.9})

def test_lint_xml_warns_when_estimated_text_touches_card_bottom(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="60" topLeftY="100" width="280" height="100"/>
<shape id="caption" type="text" topLeftX="80" topLeftY="184" width="240" height="20">
<content fontSize="10" verticalAlign="middle"><p>边界接触</p></content>
</shape>
</data>
</slide>
"""
)
issues = [issue for issue in result["slides"][0]["issues"] if issue["code"] == "text_outside_container"]
self.assertEqual(len(issues), 1)
self.assertEqual(issues[0]["measurement"]["overflow"], {"bottom": 0})

def test_lint_xml_allows_centered_glyphs_inside_card_when_text_box_extends_five_pixels(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="case4-card" type="rect" topLeftX="300" topLeftY="245" width="360" height="120"/>
<shape id="case4-text" type="text" topLeftX="330" topLeftY="285" width="300" height="85">
<content fontSize="19" bold="true" textAlign="center" verticalAlign="middle">
<p>OVERFLOW 5 px</p>
</content>
</shape>
</data>
</slide>
"""
)
self.assertFalse(
any(issue["code"] == "text_outside_container" for issue in result["slides"][0]["issues"])
)

def test_lint_xml_allows_text_inside_card_bottom(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="60" topLeftY="170" width="400" height="310"/>
<shape id="caption" type="text" topLeftX="80" topLeftY="455" width="360" height="20">
<content fontSize="13"><p>完整位于卡片内部</p></content>
</shape>
</data>
</slide>
"""
)
self.assertFalse(
any(issue["code"] == "text_outside_container" for issue in result["slides"][0]["issues"])
)

def test_lint_xml_allows_horizontal_text_with_default_wrap(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
Expand Down
Loading