diff --git a/skills/lark-slides/references/validation-checklist.md b/skills/lark-slides/references/validation-checklist.md index 30f1ec9a9f..8e308dd4bc 100644 --- a/skills/lark-slides/references/validation-checklist.md +++ b/skills/lark-slides/references/validation-checklist.md @@ -33,7 +33,7 @@ lark-cli slides +xml-get --as user \ python3 "/scripts/xml_text_overlap_lint.py" --input ``` -它一次检查 XML/SXSD 合法性、元素越界、文本重叠、空白页、文本高度风险、整页内容稀疏和大卡片内容覆盖率。大卡片自身 `` 的估算文本面积与卡片内平级元素一起参与覆盖率并集计算。 +它一次检查 XML/SXSD 合法性、元素越界、文本重叠、线条穿字、文本越出候选卡片、空白页、文本高度风险、整页内容稀疏和大卡片内容覆盖率。大卡片自身 `` 的估算文本面积与卡片内平级元素一起参与覆盖率并集计算。 准出规则: @@ -64,6 +64,8 @@ python3 "/scripts/xml_text_overlap_lint.py" --input ` 设置 ``,避免图标不可见 | 给 `` 添加显式非透明填充色,例如 `rgba(37, 99, 235, 1)` | | `icon_transparent_fill_color` | `` 的 `fillColor` 是透明色,不满足视觉可见性要求 | 改成与背景有足够对比的非透明颜色 | | `bbox_overlap` | 文本元素的估算绘制区域明显重叠 | 拉开文本坐标、缩小文本框/字号,或改成明确的分栏/分组结构 | +| `line_crosses_text` | 线条与非空文本框的声明边界相交 | 结合截图确认是否为有意删除线;否则缩短/移动线条或移动文本 | +| `text_outside_container` | 文本框属于候选矩形卡片,且估算的实际文字区域触碰或越过卡片底边 | 结合截图确认卡片归属与实际字形;增高卡片、上移文本或调整文字排版 | | `*_out_of_canvas` | 元素边界超出页面画布 | 根据 `measurement.overflow` 移回画布或缩小尺寸 | | `blank_slide` | 页面没有画布内可见内容 | 补充主体内容;仅有空背景或空形状不能准出 | | `sparse_container_content` | 大卡片内容覆盖率低于阈值 | 按元素 ID 定位卡片,结合截图判断是否补充或放大内容 | diff --git a/skills/lark-slides/scripts/xml_text_overlap_lint.py b/skills/lark-slides/scripts/xml_text_overlap_lint.py index 20ba9649aa..9c6b1ec914 100644 --- a/skills/lark-slides/scripts/xml_text_overlap_lint.py +++ b/skills/lark-slides/scripts/xml_text_overlap_lint.py @@ -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) + ] + 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) + 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: @@ -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", @@ -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, diff --git a/skills/lark-slides/scripts/xml_text_overlap_lint_test.py b/skills/lark-slides/scripts/xml_text_overlap_lint_test.py index 0e7dea0605..8fb9c34390 100644 --- a/skills/lark-slides/scripts/xml_text_overlap_lint_test.py +++ b/skills/lark-slides/scripts/xml_text_overlap_lint_test.py @@ -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( + """ + + + + + + + +

阶段总结

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

章节标题

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

不在线段上

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

接种流感疫苗,做好日常防护

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

保持规律作息,增强身体抵抗力

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

GLYPH OVERFLOW

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

边界接触

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

OVERFLOW 5 px

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

完整位于卡片内部

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