From 5a79d56648554c99f408852ea8ea4a9a6f4b5c2c Mon Sep 17 00:00:00 2001 From: "dengzilong.zero" Date: Tue, 21 Jul 2026 14:07:51 +0800 Subject: [PATCH 01/10] feat: add layout density lint for sparse slide containers - add XML-based sparse container detection with coverage metrics - return structured, actionable lint JSON - document lint usage and visual-review workflow - add regression tests for density-lint behavior --- .../references/validation-checklist.md | 28 ++ .../scripts/xml_layout_density_lint.py | 252 ++++++++++++++++++ .../scripts/xml_layout_density_lint_test.py | 147 ++++++++++ 3 files changed, 427 insertions(+) create mode 100644 skills/lark-slides/scripts/xml_layout_density_lint.py create mode 100644 skills/lark-slides/scripts/xml_layout_density_lint_test.py diff --git a/skills/lark-slides/references/validation-checklist.md b/skills/lark-slides/references/validation-checklist.md index ffd337369c..1b1604bb79 100644 --- a/skills/lark-slides/references/validation-checklist.md +++ b/skills/lark-slides/references/validation-checklist.md @@ -39,6 +39,34 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input +``` + +它的用途是找出大型 `rect` 容器内的可见内容面积偏小的候选区域,常见于大卡片只放少量文字、空图片占位卡、单字符伪主视觉或目录卡内容过空。 + +调用顺序: + +```text +slides +xml-get 回读 XML +→ xml_text_overlap_lint.py 检查 XML / 重叠等结构风险 +→ xml_layout_density_lint.py 输出内容覆盖率事实 +→ 截图 QA 判断留白是否有意设计 +``` + +`xml_layout_density_lint.py` 的 warning 只陈述可复算的几何事实: + +- `target`:页码、容器 ID、坐标和尺寸; +- `rule`:阈值与比较条件; +- `measurement`:容器面积、可见内容面积、覆盖率和参与元素数量; +- `elements`:容器及参与计算的 XML 元素 ID。 + +当 `measurement.content_coverage_ratio < rule.threshold` 时输出 `code = sparse_container_content`。这只是静态几何命中,不自动说明页面难看、留白错误或必须修改;必须结合同页截图进行视觉判断。 + 常见 code 的处理方向: | code | 含义 | 处理方式 | diff --git a/skills/lark-slides/scripts/xml_layout_density_lint.py b/skills/lark-slides/scripts/xml_layout_density_lint.py new file mode 100644 index 0000000000..364e80f1de --- /dev/null +++ b/skills/lark-slides/scripts/xml_layout_density_lint.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +"""Warn when a large layout container has too little visible content inside it.""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path +from typing import Any + +import xml_text_overlap_lint as xml_lint + + +MIN_CONTAINER_WIDTH = 140 +MIN_CONTAINER_HEIGHT = 160 +MIN_CONTAINER_AREA = 20_000 +MIN_CONTENT_COVERAGE_RATIO = 0.10 +LARGE_VISUAL_CHILD_RATIO = 0.35 +LAYOUT_PANEL_SPAN_RATIO = 0.90 +IMAGE_OVERLAY_MATCH_RATIO = 0.90 + + +def clipped_bbox(element: dict[str, Any], container: dict[str, Any]) -> dict[str, int | float] | None: + left = max(element["x"], container["x"]) + top = max(element["y"], container["y"]) + right = min(element["x"] + element["width"], container["x"] + container["width"]) + bottom = min(element["y"] + element["height"], container["y"] + container["height"]) + if right <= left or bottom <= top: + return None + return {"x": left, "y": top, "width": right - left, "height": bottom - top} + + +def rectangle_union_area(rectangles: list[dict[str, int | float]]) -> int | float: + x_coordinates = sorted({coordinate for rect in rectangles for coordinate in (rect["x"], rect["x"] + rect["width"])}) + area = 0 + for left, right in zip(x_coordinates, x_coordinates[1:]): + intervals = sorted( + (rect["y"], rect["y"] + rect["height"]) + for rect in rectangles + if rect["x"] < right and rect["x"] + rect["width"] > left + ) + covered_height = 0 + interval_end: int | float | None = None + for top, bottom in intervals: + if interval_end is None: + covered_height += bottom - top + interval_end = bottom + elif bottom > interval_end: + covered_height += bottom - max(top, interval_end) + interval_end = bottom + area += (right - left) * covered_height + return area + + +def is_layout_container(element: dict[str, Any], slide_width: int | float, slide_height: int | float) -> bool: + return ( + element["kind"] == "shape" + and element["type"] == "rect" + and element["width"] >= MIN_CONTAINER_WIDTH + and element["height"] >= MIN_CONTAINER_HEIGHT + and xml_lint.element_area(element) >= MIN_CONTAINER_AREA + and not ( + element["x"] <= 2 + and element["y"] <= 2 + and element["width"] >= slide_width - 4 + and element["height"] >= slide_height - 4 + ) + ) + + +def is_edge_spanning_layout_panel( + element: dict[str, Any], slide_width: int | float, slide_height: int | float +) -> bool: + touches_horizontal_edge = element["x"] <= 2 or element["x"] + element["width"] >= slide_width - 2 + touches_vertical_edge = element["y"] <= 2 or element["y"] + element["height"] >= slide_height - 2 + return (touches_horizontal_edge and element["height"] >= slide_height * LAYOUT_PANEL_SPAN_RATIO) or ( + touches_vertical_edge and element["width"] >= slide_width * LAYOUT_PANEL_SPAN_RATIO + ) + + +def has_matching_image_overlay(container: dict[str, Any], elements: list[dict[str, Any]]) -> bool: + container_area = xml_lint.element_area(container) + return any( + element["kind"] == "img" + and xml_lint.intersection_area(container, element) + / max(1, min(container_area, xml_lint.element_area(element))) + >= IMAGE_OVERLAY_MATCH_RATIO + for element in elements + ) + + +def is_nested_in_layout_panel( + container: dict[str, Any], elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float +) -> bool: + return any( + element is not container + and element["kind"] == "shape" + and element["type"] == "rect" + and is_edge_spanning_layout_panel(element, slide_width, slide_height) + and xml_lint.contains(element, container) + for element in elements + ) + + +def extract_density_elements(slide_xml: str) -> list[dict[str, Any]]: + elements = xml_lint.extract_elements(slide_xml) + for match in re.finditer(r"]*)>", slide_xml): + attrs = match.group(1) + x = xml_lint.extract_numeric_attribute(attrs, "topLeftX") + y = xml_lint.extract_numeric_attribute(attrs, "topLeftY") + width = xml_lint.extract_numeric_attribute(attrs, "width") + height = xml_lint.extract_numeric_attribute(attrs, "height") + if any(value is None for value in (x, y, width, height)): + continue + elements.append( + { + "id": xml_lint.extract_attribute(attrs, "id") or f"icon-{len(elements) + 1}", + "kind": "icon", + "type": "icon", + "x": x, + "y": y, + "width": width, + "height": height, + "rotation": xml_lint.extract_numeric_attribute(attrs, "rotation") or 0, + "order": len(elements), + } + ) + return elements + + +def visual_bbox(element: dict[str, Any], container: dict[str, Any]) -> dict[str, int | float] | None: + if xml_lint.is_text_element(element): + estimated = xml_lint.estimate_text_visual_bbox(element) + return clipped_bbox(estimated, container) if estimated else None + return clipped_bbox(element, container) + + +def is_large_visual_child(element: dict[str, Any], container: dict[str, Any]) -> bool: + if element["kind"] not in {"img", "chart", "table", "whiteboard"}: + return False + return xml_lint.element_area(element) / xml_lint.element_area(container) >= LARGE_VISUAL_CHILD_RATIO + + +def detect_sparse_container_content( + elements: list[dict[str, Any]], slide_number: int, slide_width: int | float, slide_height: int | float +) -> list[dict[str, Any]]: + issues: list[dict[str, Any]] = [] + for container in (element for element in elements if is_layout_container(element, slide_width, slide_height)): + if ( + is_edge_spanning_layout_panel(container, slide_width, slide_height) + or is_nested_in_layout_panel(container, elements, slide_width, slide_height) + or has_matching_image_overlay(container, elements) + ): + continue + children = [ + element + for element in elements + if element is not container and xml_lint.contains(container, element) + ] + if any(is_large_visual_child(child, container) for child in children): + continue + rectangles = [bbox for child in children if (bbox := visual_bbox(child, container)) is not None] + content_area = rectangle_union_area(rectangles) if rectangles else 0 + coverage_ratio = content_area / xml_lint.element_area(container) + if coverage_ratio >= MIN_CONTENT_COVERAGE_RATIO: + continue + issues.append( + { + "level": "warning", + "code": "sparse_container_content", + "schema_version": "1.0", + "target": { + "slide_number": slide_number, + "container_id": container["id"], + "container_type": container["type"], + "bbox": {key: container[key] for key in ("x", "y", "width", "height")}, + }, + "rule": { + "name": "large_container_visible_content_coverage", + "threshold": MIN_CONTENT_COVERAGE_RATIO, + "comparison": "content_coverage_ratio < threshold", + }, + "measurement": { + "container_area": xml_lint.element_area(container), + "visible_content_area": round(content_area, 3), + "content_coverage_ratio": round(coverage_ratio, 3), + "content_element_count": len(children), + }, + "elements": [container["id"], *[child["id"] for child in children]], + } + ) + return issues + + +def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]: + root, xml_error = xml_lint.parse_xml_root(xml) + if xml_error: + return { + "file": source_path, + "summary": {"slide_count": 0, "warning_count": 0, "error_count": 1}, + "issues": [xml_error], + "slides": [], + } + if root is None: + raise AssertionError("parse_xml_root must return a root or error") + presentation = xml_lint.parse_presentation(xml) + slides = [] + for index, slide_xml in enumerate(presentation["slides"]): + elements = extract_density_elements(slide_xml) + slides.append( + { + "slide_number": index + 1, + "element_count": len(elements), + "issues": detect_sparse_container_content( + elements, index + 1, presentation["width"], presentation["height"] + ), + } + ) + warning_count = sum(len(slide["issues"]) for slide in slides) + return { + "file": source_path, + "slide_size": {"width": presentation["width"], "height": presentation["height"]}, + "summary": {"slide_count": len(slides), "warning_count": warning_count, "error_count": 0}, + "slides": slides, + } + + +def print_usage() -> None: + print("Usage:\n python3 xml_layout_density_lint.py --input ", file=sys.stderr) + + +def run_cli(argv: list[str] | None = None) -> None: + options = xml_lint.parse_args(argv or sys.argv[1:]) + if options.get("help") or options.get("--help"): + print_usage() + raise SystemExit(0) + if not options.get("input"): + print_usage() + raise xml_lint.XmlTextOverlapLintError("--input is required") + input_path = Path(options["input"]).resolve() + print(json.dumps(lint_xml(xml_lint.read_file(input_path), str(input_path)), ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + try: + run_cli() + except xml_lint.XmlTextOverlapLintError as error: + print(f"xml-layout-density-lint error: {error}", file=sys.stderr) + raise SystemExit(1) from error diff --git a/skills/lark-slides/scripts/xml_layout_density_lint_test.py b/skills/lark-slides/scripts/xml_layout_density_lint_test.py new file mode 100644 index 0000000000..606b2492c3 --- /dev/null +++ b/skills/lark-slides/scripts/xml_layout_density_lint_test.py @@ -0,0 +1,147 @@ +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +from __future__ import annotations + +import unittest + +import xml_layout_density_lint + + +class XmlLayoutDensityLintTest(unittest.TestCase): + def test_lint_xml_warns_when_large_container_is_mostly_empty(self) -> None: + result = xml_layout_density_lint.lint_xml( + """ + + + + +

Core trends

+
+ +

First point

Second point

Third point

+
+
+
+ """ + ) + + issue = result["slides"][0]["issues"][0] + self.assertEqual(issue["code"], "sparse_container_content") + self.assertEqual(issue["target"]["container_id"], "trend-card") + self.assertEqual(issue["target"], { + "slide_number": 1, + "container_id": "trend-card", + "container_type": "rect", + "bbox": {"x": 500, "y": 135, "width": 410, "height": 370}, + }) + self.assertLess(issue["measurement"]["content_coverage_ratio"], 0.10) + self.assertEqual(issue["rule"], { + "name": "large_container_visible_content_coverage", + "threshold": 0.10, + "comparison": "content_coverage_ratio < threshold", + }) + self.assertEqual(issue["measurement"]["container_area"], 151700) + self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0.032) + self.assertEqual(issue["elements"], ["trend-card", "trend-title", "trend-copy"]) + self.assertEqual(set(issue), {"level", "code", "schema_version", "target", "rule", "measurement", "elements"}) + + def test_lint_xml_allows_container_with_large_visual_child(self) -> None: + result = xml_layout_density_lint.lint_xml( + """ + + + + + + + """ + ) + + self.assertEqual(result["summary"]["warning_count"], 0) + + def test_lint_xml_warns_for_small_empty_visual_placeholder_cards(self) -> None: + result = xml_layout_density_lint.lint_xml( + """ + + + + +

Z

+
+ +
+
+ """ + ) + + issues = result["slides"][0]["issues"] + self.assertEqual( + [issue["target"]["container_id"] for issue in issues], + ["letter-placeholder", "empty-placeholder"], + ) + self.assertEqual(issues[1]["measurement"]["content_element_count"], 0) + + def test_lint_xml_allows_normal_card_below_legacy_threshold(self) -> None: + result = xml_layout_density_lint.lint_xml( + """ + + + + +

梦境与现实

+
+ +

边界溶解,逻辑失效。观众被拽入潜意识的迷宫。

+
+
+
+ """ + ) + + self.assertEqual(result["summary"]["warning_count"], 0) + + def test_lint_xml_allows_image_overlay_rect(self) -> None: + result = xml_layout_density_lint.lint_xml( + """ + + + + + + + """ + ) + + self.assertEqual(result["summary"]["warning_count"], 0) + + def test_lint_xml_allows_edge_spanning_layout_panel_and_nested_decoration(self) -> None: + result = xml_layout_density_lint.lint_xml( + """ + + + + + + + """ + ) + + self.assertEqual(result["summary"]["warning_count"], 0) + + def test_lint_xml_counts_icons_as_visible_content(self) -> None: + result = xml_layout_density_lint.lint_xml( + """ + + + + + + + """ + ) + + self.assertEqual(result["summary"]["warning_count"], 0) + + +if __name__ == "__main__": + unittest.main() From c6ff53e52a056710a5fa26589a6641f9a067dddd Mon Sep 17 00:00:00 2001 From: "dengzilong.zero" Date: Tue, 21 Jul 2026 16:23:20 +0800 Subject: [PATCH 02/10] feat: add layout density lint for sparse slide containers - add XML-based sparse container detection with coverage metrics - return structured, actionable lint JSON - document lint usage and visual-review workflow - add regression tests for density-lint behavior --- .../scripts/xml_layout_density_lint.py | 97 +++++++- .../scripts/xml_layout_density_lint_test.py | 225 +++++++++++++++++- 2 files changed, 309 insertions(+), 13 deletions(-) diff --git a/skills/lark-slides/scripts/xml_layout_density_lint.py b/skills/lark-slides/scripts/xml_layout_density_lint.py index 364e80f1de..6345984043 100644 --- a/skills/lark-slides/scripts/xml_layout_density_lint.py +++ b/skills/lark-slides/scripts/xml_layout_density_lint.py @@ -8,6 +8,7 @@ import json import re import sys +from xml.etree import ElementTree as ET from pathlib import Path from typing import Any @@ -17,10 +18,11 @@ MIN_CONTAINER_WIDTH = 140 MIN_CONTAINER_HEIGHT = 160 MIN_CONTAINER_AREA = 20_000 -MIN_CONTENT_COVERAGE_RATIO = 0.10 +MIN_CONTENT_COVERAGE_RATIO = 0.15 LARGE_VISUAL_CHILD_RATIO = 0.35 LAYOUT_PANEL_SPAN_RATIO = 0.90 IMAGE_OVERLAY_MATCH_RATIO = 0.90 +DENSITY_CONTAINMENT_TOLERANCE = 8 def clipped_bbox(element: dict[str, Any], container: dict[str, Any]) -> dict[str, int | float] | None: @@ -100,13 +102,59 @@ def is_nested_in_layout_panel( and element["kind"] == "shape" and element["type"] == "rect" and is_edge_spanning_layout_panel(element, slide_width, slide_height) - and xml_lint.contains(element, container) + and xml_lint.contains(element, container, tolerance=DENSITY_CONTAINMENT_TOLERANCE) for element in elements ) def extract_density_elements(slide_xml: str) -> list[dict[str, Any]]: elements = xml_lint.extract_elements(slide_xml) + elements_by_id = {element["id"]: element for element in elements} + root = ET.fromstring(slide_xml) + for node in root.iter(): + if xml_lint.xml_local_name(node.tag) != "shape": + continue + element = elements_by_id.get(node.attrib.get("id", "")) + if element is None: + continue + content_node = next( + (child for child in node if xml_lint.xml_local_name(child.tag) == "content"), + None, + ) + paragraphs = ( + [ + " ".join("".join(paragraph.itertext()).split()) + for paragraph in content_node.iter() + if xml_lint.xml_local_name(paragraph.tag) == "p" + ] + if content_node is not None + else [] + ) + raw_font_size = ( + content_node.attrib.get("fontSize") if content_node is not None else None + ) or node.attrib.get("fontSize") + try: + base_font_size = float(raw_font_size or 16) + except ValueError: + base_font_size = 16.0 + element.update( + { + "textType": content_node.attrib.get("textType") if content_node is not None else None, + "textAlign": content_node.attrib.get("textAlign") if content_node is not None else None, + "autoFit": content_node.attrib.get("autoFit") if content_node is not None else None, + "fontSize": base_font_size, + "text": "\n".join(paragraph for paragraph in paragraphs if paragraph), + } + ) + if not xml_lint.has_text_content(element): + continue + declared_font_sizes = [ + float(descendant.attrib["fontSize"]) + for descendant in node.iter() + if descendant.attrib.get("fontSize") is not None + ] + if declared_font_sizes: + element["fontSize"] = max(declared_font_sizes) for match in re.finditer(r"]*)>", slide_xml): attrs = match.group(1) x = xml_lint.extract_numeric_attribute(attrs, "topLeftX") @@ -138,6 +186,14 @@ def visual_bbox(element: dict[str, Any], container: dict[str, Any]) -> dict[str, return clipped_bbox(element, container) +def own_text_visual_bbox(container: dict[str, Any]) -> dict[str, int | float] | None: + if container["kind"] != "shape" or not xml_lint.has_text_content(container): + return None + text_proxy = {**container, "type": "text"} + estimated = xml_lint.estimate_text_visual_bbox(text_proxy) + return clipped_bbox(estimated, container) if estimated else None + + def is_large_visual_child(element: dict[str, Any], container: dict[str, Any]) -> bool: if element["kind"] not in {"img", "chart", "table", "whiteboard"}: return False @@ -158,11 +214,15 @@ def detect_sparse_container_content( children = [ element for element in elements - if element is not container and xml_lint.contains(container, element) + if element is not container + and xml_lint.contains(container, element, tolerance=DENSITY_CONTAINMENT_TOLERANCE) ] if any(is_large_visual_child(child, container) for child in children): continue - rectangles = [bbox for child in children if (bbox := visual_bbox(child, container)) is not None] + own_text_bbox = own_text_visual_bbox(container) + rectangles = ([own_text_bbox] if own_text_bbox else []) + [ + bbox for child in children if (bbox := visual_bbox(child, container)) is not None + ] content_area = rectangle_union_area(rectangles) if rectangles else 0 coverage_ratio = content_area / xml_lint.element_area(container) if coverage_ratio >= MIN_CONTENT_COVERAGE_RATIO: @@ -187,7 +247,7 @@ def detect_sparse_container_content( "container_area": xml_lint.element_area(container), "visible_content_area": round(content_area, 3), "content_coverage_ratio": round(coverage_ratio, 3), - "content_element_count": len(children), + "content_element_count": len(children) + (1 if own_text_bbox else 0), }, "elements": [container["id"], *[child["id"] for child in children]], } @@ -195,6 +255,25 @@ def detect_sparse_container_content( return issues +def detect_blank_slide(elements: list[dict[str, Any]], slide_number: int) -> list[dict[str, Any]]: + if elements: + return [] + return [ + { + "level": "warning", + "code": "blank_slide", + "schema_version": "1.0", + "target": {"slide_number": slide_number}, + "rule": { + "name": "slide_has_visible_content", + "comparison": "visible_element_count == 0", + }, + "measurement": {"visible_element_count": 0}, + "elements": [], + } + ] + + def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]: root, xml_error = xml_lint.parse_xml_root(xml) if xml_error: @@ -210,13 +289,13 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]: slides = [] for index, slide_xml in enumerate(presentation["slides"]): elements = extract_density_elements(slide_xml) + slide_number = index + 1 slides.append( { - "slide_number": index + 1, + "slide_number": slide_number, "element_count": len(elements), - "issues": detect_sparse_container_content( - elements, index + 1, presentation["width"], presentation["height"] - ), + "issues": detect_blank_slide(elements, slide_number) + + detect_sparse_container_content(elements, slide_number, presentation["width"], presentation["height"]), } ) warning_count = sum(len(slide["issues"]) for slide in slides) diff --git a/skills/lark-slides/scripts/xml_layout_density_lint_test.py b/skills/lark-slides/scripts/xml_layout_density_lint_test.py index 606b2492c3..8475177add 100644 --- a/skills/lark-slides/scripts/xml_layout_density_lint_test.py +++ b/skills/lark-slides/scripts/xml_layout_density_lint_test.py @@ -8,6 +8,47 @@ class XmlLayoutDensityLintTest(unittest.TestCase): + def test_lint_xml_warns_for_blank_slide(self) -> None: + result = xml_layout_density_lint.lint_xml( + """ + + + + +

Investment report

+
+
+
+ + + + + +
+ """ + ) + + self.assertEqual(result["summary"], {"slide_count": 2, "warning_count": 1, "error_count": 0}) + self.assertEqual(result["slides"][0]["issues"], []) + self.assertEqual(result["slides"][1]["element_count"], 0) + self.assertEqual( + result["slides"][1]["issues"], + [ + { + "level": "warning", + "code": "blank_slide", + "schema_version": "1.0", + "target": {"slide_number": 2}, + "rule": { + "name": "slide_has_visible_content", + "comparison": "visible_element_count == 0", + }, + "measurement": {"visible_element_count": 0}, + "elements": [], + } + ], + ) + def test_lint_xml_warns_when_large_container_is_mostly_empty(self) -> None: result = xml_layout_density_lint.lint_xml( """ @@ -34,10 +75,10 @@ def test_lint_xml_warns_when_large_container_is_mostly_empty(self) -> None: "container_type": "rect", "bbox": {"x": 500, "y": 135, "width": 410, "height": 370}, }) - self.assertLess(issue["measurement"]["content_coverage_ratio"], 0.10) + self.assertLess(issue["measurement"]["content_coverage_ratio"], 0.15) self.assertEqual(issue["rule"], { "name": "large_container_visible_content_coverage", - "threshold": 0.10, + "threshold": 0.15, "comparison": "content_coverage_ratio < threshold", }) self.assertEqual(issue["measurement"]["container_area"], 151700) @@ -45,6 +86,133 @@ def test_lint_xml_warns_when_large_container_is_mostly_empty(self) -> None: self.assertEqual(issue["elements"], ["trend-card", "trend-title", "trend-copy"]) self.assertEqual(set(issue), {"level", "code", "schema_version", "target", "rule", "measurement", "elements"}) + def test_lint_xml_counts_rect_own_content_as_visible_content(self) -> None: + result = xml_layout_density_lint.lint_xml( + """ + + + + +

被吊物

+

32.0 t

+

钢结构模块

+
+
+
+
+ """ + ) + + self.assertEqual(result["slides"][0]["issues"], []) + + def test_lint_xml_reports_nonzero_coverage_for_rect_own_content_reproduction(self) -> None: + result = xml_layout_density_lint.lint_xml( + """ + + + + +

被吊物

+

32.0 t

+

钢结构模块

+
+
+
+
+ """ + ) + + issue = result["slides"][0]["issues"][0] + self.assertGreater(issue["measurement"]["visible_content_area"], 0) + self.assertEqual(issue["measurement"]["content_element_count"], 1) + self.assertGreater(issue["measurement"]["content_coverage_ratio"], 0) + + def test_lint_xml_still_warns_for_sparse_rect_own_content(self) -> None: + result = xml_layout_density_lint.lint_xml( + """ + + + +

A

+
+
+
+ """ + ) + + issue = result["slides"][0]["issues"][0] + self.assertEqual(issue["target"]["container_id"], "sparse-card") + self.assertGreater(issue["measurement"]["visible_content_area"], 0) + self.assertEqual(issue["measurement"]["content_element_count"], 1) + self.assertEqual(issue["elements"], ["sparse-card"]) + + def test_lint_xml_unions_rect_own_content_with_child_content(self) -> None: + self_only = xml_layout_density_lint.lint_xml( + """ + + + +

A

+
+
+
+ """ + ) + with_overlapping_child = xml_layout_density_lint.lint_xml( + """ + + + +

A

+
+ +

A

+
+
+
+ """ + ) + + self_issue = self_only["slides"][0]["issues"][0] + mixed_issue = with_overlapping_child["slides"][0]["issues"][0] + self.assertEqual( + mixed_issue["measurement"]["visible_content_area"], + self_issue["measurement"]["visible_content_area"], + ) + self.assertEqual(mixed_issue["measurement"]["content_element_count"], 2) + + def test_extract_density_elements_reads_nested_font_size_from_rect_content(self) -> None: + elements = xml_layout_density_lint.extract_density_elements( + """ + + + +

32.0 t

+
+
+
+ """ + ) + + self.assertEqual(elements[0]["fontSize"], 36) + + def test_extract_density_elements_does_not_attach_following_text_to_self_closing_rect(self) -> None: + elements = xml_layout_density_lint.extract_density_elements( + """ + + + + +

Following title

+
+
+
+ """ + ) + + self.assertEqual(elements[0]["text"], "") + self.assertEqual(elements[1]["text"], "Following title") + def test_lint_xml_allows_container_with_large_visual_child(self) -> None: result = xml_layout_density_lint.lint_xml( """ @@ -81,7 +249,7 @@ def test_lint_xml_warns_for_small_empty_visual_placeholder_cards(self) -> None: ) self.assertEqual(issues[1]["measurement"]["content_element_count"], 0) - def test_lint_xml_allows_normal_card_below_legacy_threshold(self) -> None: + def test_lint_xml_applies_global_threshold_to_normal_text_card(self) -> None: result = xml_layout_density_lint.lint_xml( """ @@ -98,7 +266,9 @@ def test_lint_xml_allows_normal_card_below_legacy_threshold(self) -> None: """ ) - self.assertEqual(result["summary"]["warning_count"], 0) + issue = result["slides"][0]["issues"][0] + self.assertEqual(issue["target"]["container_id"], "card") + self.assertEqual(issue["rule"]["threshold"], 0.15) def test_lint_xml_allows_image_overlay_rect(self) -> None: result = xml_layout_density_lint.lint_xml( @@ -142,6 +312,53 @@ def test_lint_xml_counts_icons_as_visible_content(self) -> None: self.assertEqual(result["summary"]["warning_count"], 0) + def test_lint_xml_warns_when_coverage_is_below_global_threshold(self) -> None: + result = xml_layout_density_lint.lint_xml( + """ + + + + + + + """ + ) + + issue = result["slides"][0]["issues"][0] + self.assertEqual(issue["target"]["container_id"], "card") + self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0.122) + self.assertEqual(issue["rule"]["threshold"], 0.15) + + def test_lint_xml_allows_quarter_coverage_under_lower_threshold(self) -> None: + result = xml_layout_density_lint.lint_xml( + """ + + + + + + + """ + ) + + self.assertEqual(result["slides"][0]["issues"], []) + + def test_lint_xml_allows_large_metric_card_above_lower_threshold(self) -> None: + result = xml_layout_density_lint.lint_xml( + """ + + + + +

400+ 项

+
+
+
+ """ + ) + + self.assertEqual(result["slides"][0]["issues"], []) + if __name__ == "__main__": unittest.main() From 2fe80f92c1f4fa053117b976c016c6ae7f43bd92 Mon Sep 17 00:00:00 2001 From: "dengzilong.zero" Date: Tue, 21 Jul 2026 17:29:09 +0800 Subject: [PATCH 03/10] feat(slides): detect sparse short cards and slide-level density --- .../scripts/xml_layout_density_lint.py | 96 ++++++++++++++++++- .../scripts/xml_layout_density_lint_test.py | 94 ++++++++++++++++++ 2 files changed, 186 insertions(+), 4 deletions(-) diff --git a/skills/lark-slides/scripts/xml_layout_density_lint.py b/skills/lark-slides/scripts/xml_layout_density_lint.py index 6345984043..f38ae32ef8 100644 --- a/skills/lark-slides/scripts/xml_layout_density_lint.py +++ b/skills/lark-slides/scripts/xml_layout_density_lint.py @@ -17,8 +17,13 @@ MIN_CONTAINER_WIDTH = 140 MIN_CONTAINER_HEIGHT = 160 +MIN_SHORT_CARD_HEIGHT = 80 MIN_CONTAINER_AREA = 20_000 MIN_CONTENT_COVERAGE_RATIO = 0.15 +MIN_SLIDE_CONTENT_COVERAGE_RATIO = 0.035 +MIN_SLIDE_CONTENT_ELEMENT_COUNT = 4 +SHORT_CARD_SIZE_TOLERANCE_RATIO = 0.10 +MIN_SIMILAR_SHORT_CARD_COUNT = 2 LARGE_VISUAL_CHILD_RATIO = 0.35 LAYOUT_PANEL_SPAN_RATIO = 0.90 IMAGE_OVERLAY_MATCH_RATIO = 0.90 @@ -57,12 +62,37 @@ def rectangle_union_area(rectangles: list[dict[str, int | float]]) -> int | floa return area -def is_layout_container(element: dict[str, Any], slide_width: int | float, slide_height: int | float) -> bool: +def has_similar_short_card_peer(element: dict[str, Any], elements: list[dict[str, Any]]) -> bool: + return sum( + other["kind"] == "shape" + and other["type"] == "rect" + and other["width"] >= MIN_CONTAINER_WIDTH + and other["height"] >= MIN_SHORT_CARD_HEIGHT + and xml_lint.element_area(other) >= MIN_CONTAINER_AREA + and abs(other["width"] - element["width"]) / max(other["width"], element["width"]) + <= SHORT_CARD_SIZE_TOLERANCE_RATIO + and abs(other["height"] - element["height"]) / max(other["height"], element["height"]) + <= SHORT_CARD_SIZE_TOLERANCE_RATIO + for other in elements + ) >= MIN_SIMILAR_SHORT_CARD_COUNT + + +def is_layout_container( + element: dict[str, Any], + slide_width: int | float, + slide_height: int | float, + elements: list[dict[str, Any]] | None = None, +) -> bool: + has_supported_height = element["height"] >= MIN_CONTAINER_HEIGHT or ( + elements is not None + and element["height"] >= MIN_SHORT_CARD_HEIGHT + and has_similar_short_card_peer(element, elements) + ) return ( element["kind"] == "shape" and element["type"] == "rect" and element["width"] >= MIN_CONTAINER_WIDTH - and element["height"] >= MIN_CONTAINER_HEIGHT + and has_supported_height and xml_lint.element_area(element) >= MIN_CONTAINER_AREA and not ( element["x"] <= 2 @@ -194,6 +224,20 @@ def own_text_visual_bbox(container: dict[str, Any]) -> dict[str, int | float] | return clipped_bbox(estimated, container) if estimated else None +def slide_content_visual_bbox( + element: dict[str, Any], slide_bbox: dict[str, int | float] +) -> dict[str, int | float] | None: + if xml_lint.is_text_element(element): + estimated = xml_lint.estimate_text_visual_bbox(element) + return clipped_bbox(estimated, slide_bbox) if estimated else None + if element["kind"] == "shape" and xml_lint.has_text_content(element): + estimated = own_text_visual_bbox(element) + return clipped_bbox(estimated, slide_bbox) if estimated else None + if element["kind"] in {"img", "chart", "table", "whiteboard", "icon"}: + return clipped_bbox(element, slide_bbox) + return None + + def is_large_visual_child(element: dict[str, Any], container: dict[str, Any]) -> bool: if element["kind"] not in {"img", "chart", "table", "whiteboard"}: return False @@ -204,7 +248,9 @@ def detect_sparse_container_content( elements: list[dict[str, Any]], slide_number: int, slide_width: int | float, slide_height: int | float ) -> list[dict[str, Any]]: issues: list[dict[str, Any]] = [] - for container in (element for element in elements if is_layout_container(element, slide_width, slide_height)): + for container in ( + element for element in elements if is_layout_container(element, slide_width, slide_height, elements) + ): if ( is_edge_spanning_layout_panel(container, slide_width, slide_height) or is_nested_in_layout_panel(container, elements, slide_width, slide_height) @@ -255,6 +301,47 @@ def detect_sparse_container_content( return issues +def detect_sparse_slide_content( + elements: list[dict[str, Any]], slide_number: int, slide_width: int | float, slide_height: int | float +) -> list[dict[str, Any]]: + slide_bbox = {"x": 0, "y": 0, "width": slide_width, "height": slide_height} + content = [ + (element, bbox) + for element in elements + if (bbox := slide_content_visual_bbox(element, slide_bbox)) is not None + ] + if len(content) < MIN_SLIDE_CONTENT_ELEMENT_COUNT: + return [] + content_area = rectangle_union_area([bbox for _, bbox in content]) + slide_area = slide_width * slide_height + coverage_ratio = content_area / slide_area + if coverage_ratio >= MIN_SLIDE_CONTENT_COVERAGE_RATIO: + return [] + return [ + { + "level": "warning", + "code": "sparse_slide_content", + "schema_version": "1.0", + "target": { + "slide_number": slide_number, + "bbox": slide_bbox, + }, + "rule": { + "name": "slide_visible_content_coverage", + "threshold": MIN_SLIDE_CONTENT_COVERAGE_RATIO, + "comparison": "content_coverage_ratio < threshold", + }, + "measurement": { + "slide_area": slide_area, + "visible_content_area": round(content_area, 3), + "content_coverage_ratio": round(coverage_ratio, 3), + "content_element_count": len(content), + }, + "elements": [element["id"] for element, _ in content], + } + ] + + def detect_blank_slide(elements: list[dict[str, Any]], slide_number: int) -> list[dict[str, Any]]: if elements: return [] @@ -295,7 +382,8 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]: "slide_number": slide_number, "element_count": len(elements), "issues": detect_blank_slide(elements, slide_number) - + detect_sparse_container_content(elements, slide_number, presentation["width"], presentation["height"]), + + detect_sparse_container_content(elements, slide_number, presentation["width"], presentation["height"]) + + detect_sparse_slide_content(elements, slide_number, presentation["width"], presentation["height"]), } ) warning_count = sum(len(slide["issues"]) for slide in slides) diff --git a/skills/lark-slides/scripts/xml_layout_density_lint_test.py b/skills/lark-slides/scripts/xml_layout_density_lint_test.py index 8475177add..1c1d4efa4c 100644 --- a/skills/lark-slides/scripts/xml_layout_density_lint_test.py +++ b/skills/lark-slides/scripts/xml_layout_density_lint_test.py @@ -86,6 +86,100 @@ def test_lint_xml_warns_when_large_container_is_mostly_empty(self) -> None: self.assertEqual(issue["elements"], ["trend-card", "trend-title", "trend-copy"]) self.assertEqual(set(issue), {"level", "code", "schema_version", "target", "rule", "measurement", "elements"}) + def test_lint_xml_warns_for_sparse_short_cards(self) -> None: + result = xml_layout_density_lint.lint_xml( + """ + + + + +

期待认识大家

+
+ + +

化学一起讨论

+
+ + +

吉他随时交流

+
+ + +

共度美好四年

+
+
+
+ """ + ) + + container_issues = [ + issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content" + ] + self.assertEqual( + [issue["target"]["container_id"] for issue in container_issues], + ["card-1", "card-2", "card-3", "card-4"], + ) + self.assertTrue(all(issue["target"]["bbox"]["height"] == 105 for issue in container_issues)) + self.assertTrue(all(issue["measurement"]["content_coverage_ratio"] < 0.15 for issue in container_issues)) + self.assertEqual( + [issue["code"] for issue in result["slides"][0]["issues"]], + [ + "sparse_container_content", + "sparse_container_content", + "sparse_container_content", + "sparse_container_content", + "sparse_slide_content", + ], + ) + + def test_lint_xml_warns_when_whole_slide_has_too_little_effective_content(self) -> None: + result = xml_layout_density_lint.lint_xml( + """ + + + + +

One short line

+
+ +

Another line

+
+ +

Third line

+
+ +

Fourth line

+
+
+
+ """ + ) + + issues = [issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_slide_content"] + self.assertEqual(len(issues), 1) + issue = issues[0] + self.assertEqual(issue["target"]["bbox"], {"x": 0, "y": 0, "width": 960, "height": 540}) + self.assertEqual(issue["rule"]["threshold"], 0.035) + self.assertLess(issue["measurement"]["content_coverage_ratio"], 0.035) + self.assertEqual(issue["measurement"]["content_element_count"], 4) + self.assertNotIn("background", issue["elements"]) + + def test_lint_xml_ignores_isolated_short_layout_bar(self) -> None: + result = xml_layout_density_lint.lint_xml( + """ + + + + +

One concise summary

+
+
+
+ """ + ) + + self.assertEqual(result["slides"][0]["issues"], []) + def test_lint_xml_counts_rect_own_content_as_visible_content(self) -> None: result = xml_layout_density_lint.lint_xml( """ From 3bf852c912bfd577e93a69847e4a136ac843c144 Mon Sep 17 00:00:00 2001 From: "dengzilong.zero" Date: Tue, 21 Jul 2026 20:07:13 +0800 Subject: [PATCH 04/10] docs: resolve slide lint scripts from loaded skill directory --- skills/lark-slides/references/validation-checklist.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/skills/lark-slides/references/validation-checklist.md b/skills/lark-slides/references/validation-checklist.md index 1b1604bb79..08aa5db6bc 100644 --- a/skills/lark-slides/references/validation-checklist.md +++ b/skills/lark-slides/references/validation-checklist.md @@ -29,8 +29,10 @@ lark-cli slides +xml-get --as user \ `slides +xml-get` 保存 XML 到本地文件后,优先运行 XML 语法和文本重叠静态检查: +先取得当前已加载 `lark-slides/SKILL.md` 的父目录,记为 ``;不要猜测全局安装路径。下面命令中的脚本路径相对于该目录。 + ```bash -python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input +python3 "/scripts/xml_text_overlap_lint.py" --input ``` 通过标准: @@ -44,7 +46,7 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input +python3 "/scripts/xml_layout_density_lint.py" --input ``` 它的用途是找出大型 `rect` 容器内的可见内容面积偏小的候选区域,常见于大卡片只放少量文字、空图片占位卡、单字符伪主视觉或目录卡内容过空。 From 3a47de34e4939ddeec7945f4ac318aa20c2a7575 Mon Sep 17 00:00:00 2001 From: "dengzilong.zero" Date: Wed, 22 Jul 2026 20:32:19 +0800 Subject: [PATCH 05/10] feat(slides): unify XML layout lint gate --- skills/lark-slides/SKILL.md | 4 +- .../lark-slides/references/troubleshooting.md | 2 +- .../references/validation-checklist.md | 51 +- .../scripts/xml_layout_density_lint.py | 419 ---------- .../scripts/xml_layout_density_lint_test.py | 458 ----------- .../scripts/xml_text_overlap_lint.py | 747 ++++++++++++++++-- .../scripts/xml_text_overlap_lint_test.py | 537 ++++++++++++- 7 files changed, 1228 insertions(+), 990 deletions(-) delete mode 100644 skills/lark-slides/scripts/xml_layout_density_lint.py delete mode 100644 skills/lark-slides/scripts/xml_layout_density_lint_test.py diff --git a/skills/lark-slides/SKILL.md b/skills/lark-slides/SKILL.md index f59a800fe9..60c165ea53 100644 --- a/skills/lark-slides/SKILL.md +++ b/skills/lark-slides/SKILL.md @@ -101,9 +101,9 @@ metadata: **CRITICAL — 新建演示文稿或大幅改写页面时,规划 `asset_need` MUST 遵循 [asset-planning.md](references/asset-planning.md):只做元数据规划,必须有 `fallback_if_missing`,不得要求真实搜索、下载或上传素材。** -**CRITICAL — 将完整 `` XML 提交给 `slides +create --slides`、`xml_presentation.slide create` 或 `slides +replace-pages` 之前,MUST 先把待提交 XML 保存到本地文件并运行 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py);`summary.error_count` 必须为 0 才能调用接口。** +**CRITICAL — 将完整 `` XML 提交给 `slides +create --slides`、`xml_presentation.slide create` 或 `slides +replace-pages` 之前,MUST 先把待提交 XML 保存到本地文件并运行唯一版式准出入口 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py);`summary.error_count` 必须为 0 才能调用接口,`summary.warning_count > 0` 时必须先做对应页面的截图复核。** -**CRITICAL — 创建或大幅改写后,MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素、检查空白/破损页、明显溢出、布局风险;XML 语法和文本重叠静态检查优先使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py)。** +**CRITICAL — 创建或大幅改写后,MUST 按 [validation-checklist.md](references/validation-checklist.md) 做显式验证:回读全文 XML、核对页数和关键元素,并使用 [`scripts/xml_text_overlap_lint.py`](scripts/xml_text_overlap_lint.py) 统一检查 XML、越界、重叠、空白页和内容稀疏风险。** **CRITICAL — 创建前自检或失败排障时,MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。** diff --git a/skills/lark-slides/references/troubleshooting.md b/skills/lark-slides/references/troubleshooting.md index 62ea6f3f16..b8007a6f40 100644 --- a/skills/lark-slides/references/troubleshooting.md +++ b/skills/lark-slides/references/troubleshooting.md @@ -19,7 +19,7 @@ 2. 用 `slides +xml-get` 回读,确认是否已有部分页面写入。 3. 检查失败页是否含未转义字符:`Q&A -> Q&A`,文本 `<` / `>` 写成 `<` / `>`,属性 URL `a=1&b=2 -> a=1&b=2`。 4. 检查标签闭合、属性引号、`` 结构,以及 `` 直接子元素。 -5. 页面空白、溢出、重叠或越界时,按 [validation-checklist.md](validation-checklist.md) 运行 XML 文本重叠检查,并人工核对越界、截断、图文压盖等视觉风险;工具当前只会报告 `xml_not_well_formed` / `bbox_overlap`。 +5. 页面空白、溢出、重叠或越界时,按 [validation-checklist.md](validation-checklist.md) 运行 `xml_text_overlap_lint.py`;先修复所有 `error`,再对 `warning` 指向的页面和元素做截图复核。 6. 如果使用 `--slides '[...]'`,怀疑 shell 截断时直接切到两步创建:先 `slides +create`,再用 `xml_presentation.slide.create` 逐页添加。 7. 局部问题用 `+replace-slide` 块级修正;整页结构要改时再用 `slide.delete` 旧页 + `slide.create` 新页。 diff --git a/skills/lark-slides/references/validation-checklist.md b/skills/lark-slides/references/validation-checklist.md index 08aa5db6bc..30f1ec9a9f 100644 --- a/skills/lark-slides/references/validation-checklist.md +++ b/skills/lark-slides/references/validation-checklist.md @@ -25,49 +25,32 @@ lark-cli slides +xml-get --as user \ --json ``` -## Automated XML Text Overlap Lint +## Automated XML Layout Lint -`slides +xml-get` 保存 XML 到本地文件后,优先运行 XML 语法和文本重叠静态检查: - -先取得当前已加载 `lark-slides/SKILL.md` 的父目录,记为 ``;不要猜测全局安装路径。下面命令中的脚本路径相对于该目录。 +`slides +xml-get` 保存 XML 后,只运行统一版式准出入口。先取得当前已加载 `lark-slides/SKILL.md` 的父目录,记为 ``;不要猜测全局安装路径。 ```bash python3 "/scripts/xml_text_overlap_lint.py" --input ``` -通过标准: - -- `summary.error_count == 0`。任何 error 都必须先修复再交付。 -- 当前工具只检查 XML well-formed 和文本元素之间的明显重叠;它不检查越界、文本高度不足、图文压盖、表格/图表压盖或底部拥挤。 -- 该工具不能替代页数核对、关键内容核对或真实视觉验收。 - -## Automated Layout Density Lint - -在 XML 结构检查通过后、截图视觉验收前,可运行布局密度静态检查: - -```bash -python3 "/scripts/xml_layout_density_lint.py" --input -``` - -它的用途是找出大型 `rect` 容器内的可见内容面积偏小的候选区域,常见于大卡片只放少量文字、空图片占位卡、单字符伪主视觉或目录卡内容过空。 +它一次检查 XML/SXSD 合法性、元素越界、文本重叠、空白页、文本高度风险、整页内容稀疏和大卡片内容覆盖率。大卡片自身 `` 的估算文本面积与卡片内平级元素一起参与覆盖率并集计算。 -调用顺序: +准出规则: -```text -slides +xml-get 回读 XML -→ xml_text_overlap_lint.py 检查 XML / 重叠等结构风险 -→ xml_layout_density_lint.py 输出内容覆盖率事实 -→ 截图 QA 判断留白是否有意设计 -``` +- `summary.error_count > 0` 或 `summary.release_ready == false`:阻断创建、替换或交付,必须先修复。 +- `summary.warning_count > 0`:静态检查不直接阻断,但 `summary.screenshot_review_required == true`,必须复核对应页面截图。 +- `slides[].status` 为 `blocked`、`needs_screenshot_review` 或 `passed`,可直接决定逐页后续动作。 +- CLI 在存在 `error` 时退出码为 1;只有 `warning` 时仍输出 JSON 并退出 0,供截图复核链路继续执行。 -`xml_layout_density_lint.py` 的 warning 只陈述可复算的几何事实: +每条 `error` / `warning` 都包含: -- `target`:页码、容器 ID、坐标和尺寸; -- `rule`:阈值与比较条件; -- `measurement`:容器面积、可见内容面积、覆盖率和参与元素数量; -- `elements`:容器及参与计算的 XML 元素 ID。 +- `element_ids`:相关 XML 元素 ID; +- `rule`:规则 ID、名称、阈值和比较关系; +- `measurement`:越界量、交叠面积、覆盖率等实测值; +- `related_objects`:相关对象的类型与坐标框; +- `target`、`message`、`hint`:页码、语义说明和处理建议。 -当 `measurement.content_coverage_ratio < rule.threshold` 时输出 `code = sparse_container_content`。这只是静态几何命中,不自动说明页面难看、留白错误或必须修改;必须结合同页截图进行视觉判断。 +当 `sparse_container_content.measurement.content_coverage_ratio < rule.threshold` 时,需要结合同页截图判断留白是否有意设计;不要仅凭 warning 自动扩充内容。 常见 code 的处理方向: @@ -81,6 +64,10 @@ slides +xml-get 回读 XML | `icon_missing_fill_color` | 视觉规范要求 `` 设置 ``,避免图标不可见 | 给 `` 添加显式非透明填充色,例如 `rgba(37, 99, 235, 1)` | | `icon_transparent_fill_color` | `` 的 `fillColor` 是透明色,不满足视觉可见性要求 | 改成与背景有足够对比的非透明颜色 | | `bbox_overlap` | 文本元素的估算绘制区域明显重叠 | 拉开文本坐标、缩小文本框/字号,或改成明确的分栏/分组结构 | +| `*_out_of_canvas` | 元素边界超出页面画布 | 根据 `measurement.overflow` 移回画布或缩小尺寸 | +| `blank_slide` | 页面没有画布内可见内容 | 补充主体内容;仅有空背景或空形状不能准出 | +| `sparse_container_content` | 大卡片内容覆盖率低于阈值 | 按元素 ID 定位卡片,结合截图判断是否补充或放大内容 | +| `sparse_slide_content` | 全页有效内容覆盖率偏低 | 复核截图,确认是否为有意留白 | ## Screenshot QA diff --git a/skills/lark-slides/scripts/xml_layout_density_lint.py b/skills/lark-slides/scripts/xml_layout_density_lint.py deleted file mode 100644 index f38ae32ef8..0000000000 --- a/skills/lark-slides/scripts/xml_layout_density_lint.py +++ /dev/null @@ -1,419 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) 2026 Lark Technologies Pte. Ltd. -# SPDX-License-Identifier: MIT -"""Warn when a large layout container has too little visible content inside it.""" - -from __future__ import annotations - -import json -import re -import sys -from xml.etree import ElementTree as ET -from pathlib import Path -from typing import Any - -import xml_text_overlap_lint as xml_lint - - -MIN_CONTAINER_WIDTH = 140 -MIN_CONTAINER_HEIGHT = 160 -MIN_SHORT_CARD_HEIGHT = 80 -MIN_CONTAINER_AREA = 20_000 -MIN_CONTENT_COVERAGE_RATIO = 0.15 -MIN_SLIDE_CONTENT_COVERAGE_RATIO = 0.035 -MIN_SLIDE_CONTENT_ELEMENT_COUNT = 4 -SHORT_CARD_SIZE_TOLERANCE_RATIO = 0.10 -MIN_SIMILAR_SHORT_CARD_COUNT = 2 -LARGE_VISUAL_CHILD_RATIO = 0.35 -LAYOUT_PANEL_SPAN_RATIO = 0.90 -IMAGE_OVERLAY_MATCH_RATIO = 0.90 -DENSITY_CONTAINMENT_TOLERANCE = 8 - - -def clipped_bbox(element: dict[str, Any], container: dict[str, Any]) -> dict[str, int | float] | None: - left = max(element["x"], container["x"]) - top = max(element["y"], container["y"]) - right = min(element["x"] + element["width"], container["x"] + container["width"]) - bottom = min(element["y"] + element["height"], container["y"] + container["height"]) - if right <= left or bottom <= top: - return None - return {"x": left, "y": top, "width": right - left, "height": bottom - top} - - -def rectangle_union_area(rectangles: list[dict[str, int | float]]) -> int | float: - x_coordinates = sorted({coordinate for rect in rectangles for coordinate in (rect["x"], rect["x"] + rect["width"])}) - area = 0 - for left, right in zip(x_coordinates, x_coordinates[1:]): - intervals = sorted( - (rect["y"], rect["y"] + rect["height"]) - for rect in rectangles - if rect["x"] < right and rect["x"] + rect["width"] > left - ) - covered_height = 0 - interval_end: int | float | None = None - for top, bottom in intervals: - if interval_end is None: - covered_height += bottom - top - interval_end = bottom - elif bottom > interval_end: - covered_height += bottom - max(top, interval_end) - interval_end = bottom - area += (right - left) * covered_height - return area - - -def has_similar_short_card_peer(element: dict[str, Any], elements: list[dict[str, Any]]) -> bool: - return sum( - other["kind"] == "shape" - and other["type"] == "rect" - and other["width"] >= MIN_CONTAINER_WIDTH - and other["height"] >= MIN_SHORT_CARD_HEIGHT - and xml_lint.element_area(other) >= MIN_CONTAINER_AREA - and abs(other["width"] - element["width"]) / max(other["width"], element["width"]) - <= SHORT_CARD_SIZE_TOLERANCE_RATIO - and abs(other["height"] - element["height"]) / max(other["height"], element["height"]) - <= SHORT_CARD_SIZE_TOLERANCE_RATIO - for other in elements - ) >= MIN_SIMILAR_SHORT_CARD_COUNT - - -def is_layout_container( - element: dict[str, Any], - slide_width: int | float, - slide_height: int | float, - elements: list[dict[str, Any]] | None = None, -) -> bool: - has_supported_height = element["height"] >= MIN_CONTAINER_HEIGHT or ( - elements is not None - and element["height"] >= MIN_SHORT_CARD_HEIGHT - and has_similar_short_card_peer(element, elements) - ) - return ( - element["kind"] == "shape" - and element["type"] == "rect" - and element["width"] >= MIN_CONTAINER_WIDTH - and has_supported_height - and xml_lint.element_area(element) >= MIN_CONTAINER_AREA - and not ( - element["x"] <= 2 - and element["y"] <= 2 - and element["width"] >= slide_width - 4 - and element["height"] >= slide_height - 4 - ) - ) - - -def is_edge_spanning_layout_panel( - element: dict[str, Any], slide_width: int | float, slide_height: int | float -) -> bool: - touches_horizontal_edge = element["x"] <= 2 or element["x"] + element["width"] >= slide_width - 2 - touches_vertical_edge = element["y"] <= 2 or element["y"] + element["height"] >= slide_height - 2 - return (touches_horizontal_edge and element["height"] >= slide_height * LAYOUT_PANEL_SPAN_RATIO) or ( - touches_vertical_edge and element["width"] >= slide_width * LAYOUT_PANEL_SPAN_RATIO - ) - - -def has_matching_image_overlay(container: dict[str, Any], elements: list[dict[str, Any]]) -> bool: - container_area = xml_lint.element_area(container) - return any( - element["kind"] == "img" - and xml_lint.intersection_area(container, element) - / max(1, min(container_area, xml_lint.element_area(element))) - >= IMAGE_OVERLAY_MATCH_RATIO - for element in elements - ) - - -def is_nested_in_layout_panel( - container: dict[str, Any], elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float -) -> bool: - return any( - element is not container - and element["kind"] == "shape" - and element["type"] == "rect" - and is_edge_spanning_layout_panel(element, slide_width, slide_height) - and xml_lint.contains(element, container, tolerance=DENSITY_CONTAINMENT_TOLERANCE) - for element in elements - ) - - -def extract_density_elements(slide_xml: str) -> list[dict[str, Any]]: - elements = xml_lint.extract_elements(slide_xml) - elements_by_id = {element["id"]: element for element in elements} - root = ET.fromstring(slide_xml) - for node in root.iter(): - if xml_lint.xml_local_name(node.tag) != "shape": - continue - element = elements_by_id.get(node.attrib.get("id", "")) - if element is None: - continue - content_node = next( - (child for child in node if xml_lint.xml_local_name(child.tag) == "content"), - None, - ) - paragraphs = ( - [ - " ".join("".join(paragraph.itertext()).split()) - for paragraph in content_node.iter() - if xml_lint.xml_local_name(paragraph.tag) == "p" - ] - if content_node is not None - else [] - ) - raw_font_size = ( - content_node.attrib.get("fontSize") if content_node is not None else None - ) or node.attrib.get("fontSize") - try: - base_font_size = float(raw_font_size or 16) - except ValueError: - base_font_size = 16.0 - element.update( - { - "textType": content_node.attrib.get("textType") if content_node is not None else None, - "textAlign": content_node.attrib.get("textAlign") if content_node is not None else None, - "autoFit": content_node.attrib.get("autoFit") if content_node is not None else None, - "fontSize": base_font_size, - "text": "\n".join(paragraph for paragraph in paragraphs if paragraph), - } - ) - if not xml_lint.has_text_content(element): - continue - declared_font_sizes = [ - float(descendant.attrib["fontSize"]) - for descendant in node.iter() - if descendant.attrib.get("fontSize") is not None - ] - if declared_font_sizes: - element["fontSize"] = max(declared_font_sizes) - for match in re.finditer(r"]*)>", slide_xml): - attrs = match.group(1) - x = xml_lint.extract_numeric_attribute(attrs, "topLeftX") - y = xml_lint.extract_numeric_attribute(attrs, "topLeftY") - width = xml_lint.extract_numeric_attribute(attrs, "width") - height = xml_lint.extract_numeric_attribute(attrs, "height") - if any(value is None for value in (x, y, width, height)): - continue - elements.append( - { - "id": xml_lint.extract_attribute(attrs, "id") or f"icon-{len(elements) + 1}", - "kind": "icon", - "type": "icon", - "x": x, - "y": y, - "width": width, - "height": height, - "rotation": xml_lint.extract_numeric_attribute(attrs, "rotation") or 0, - "order": len(elements), - } - ) - return elements - - -def visual_bbox(element: dict[str, Any], container: dict[str, Any]) -> dict[str, int | float] | None: - if xml_lint.is_text_element(element): - estimated = xml_lint.estimate_text_visual_bbox(element) - return clipped_bbox(estimated, container) if estimated else None - return clipped_bbox(element, container) - - -def own_text_visual_bbox(container: dict[str, Any]) -> dict[str, int | float] | None: - if container["kind"] != "shape" or not xml_lint.has_text_content(container): - return None - text_proxy = {**container, "type": "text"} - estimated = xml_lint.estimate_text_visual_bbox(text_proxy) - return clipped_bbox(estimated, container) if estimated else None - - -def slide_content_visual_bbox( - element: dict[str, Any], slide_bbox: dict[str, int | float] -) -> dict[str, int | float] | None: - if xml_lint.is_text_element(element): - estimated = xml_lint.estimate_text_visual_bbox(element) - return clipped_bbox(estimated, slide_bbox) if estimated else None - if element["kind"] == "shape" and xml_lint.has_text_content(element): - estimated = own_text_visual_bbox(element) - return clipped_bbox(estimated, slide_bbox) if estimated else None - if element["kind"] in {"img", "chart", "table", "whiteboard", "icon"}: - return clipped_bbox(element, slide_bbox) - return None - - -def is_large_visual_child(element: dict[str, Any], container: dict[str, Any]) -> bool: - if element["kind"] not in {"img", "chart", "table", "whiteboard"}: - return False - return xml_lint.element_area(element) / xml_lint.element_area(container) >= LARGE_VISUAL_CHILD_RATIO - - -def detect_sparse_container_content( - elements: list[dict[str, Any]], slide_number: int, slide_width: int | float, slide_height: int | float -) -> list[dict[str, Any]]: - issues: list[dict[str, Any]] = [] - for container in ( - element for element in elements if is_layout_container(element, slide_width, slide_height, elements) - ): - if ( - is_edge_spanning_layout_panel(container, slide_width, slide_height) - or is_nested_in_layout_panel(container, elements, slide_width, slide_height) - or has_matching_image_overlay(container, elements) - ): - continue - children = [ - element - for element in elements - if element is not container - and xml_lint.contains(container, element, tolerance=DENSITY_CONTAINMENT_TOLERANCE) - ] - if any(is_large_visual_child(child, container) for child in children): - continue - own_text_bbox = own_text_visual_bbox(container) - rectangles = ([own_text_bbox] if own_text_bbox else []) + [ - bbox for child in children if (bbox := visual_bbox(child, container)) is not None - ] - content_area = rectangle_union_area(rectangles) if rectangles else 0 - coverage_ratio = content_area / xml_lint.element_area(container) - if coverage_ratio >= MIN_CONTENT_COVERAGE_RATIO: - continue - issues.append( - { - "level": "warning", - "code": "sparse_container_content", - "schema_version": "1.0", - "target": { - "slide_number": slide_number, - "container_id": container["id"], - "container_type": container["type"], - "bbox": {key: container[key] for key in ("x", "y", "width", "height")}, - }, - "rule": { - "name": "large_container_visible_content_coverage", - "threshold": MIN_CONTENT_COVERAGE_RATIO, - "comparison": "content_coverage_ratio < threshold", - }, - "measurement": { - "container_area": xml_lint.element_area(container), - "visible_content_area": round(content_area, 3), - "content_coverage_ratio": round(coverage_ratio, 3), - "content_element_count": len(children) + (1 if own_text_bbox else 0), - }, - "elements": [container["id"], *[child["id"] for child in children]], - } - ) - return issues - - -def detect_sparse_slide_content( - elements: list[dict[str, Any]], slide_number: int, slide_width: int | float, slide_height: int | float -) -> list[dict[str, Any]]: - slide_bbox = {"x": 0, "y": 0, "width": slide_width, "height": slide_height} - content = [ - (element, bbox) - for element in elements - if (bbox := slide_content_visual_bbox(element, slide_bbox)) is not None - ] - if len(content) < MIN_SLIDE_CONTENT_ELEMENT_COUNT: - return [] - content_area = rectangle_union_area([bbox for _, bbox in content]) - slide_area = slide_width * slide_height - coverage_ratio = content_area / slide_area - if coverage_ratio >= MIN_SLIDE_CONTENT_COVERAGE_RATIO: - return [] - return [ - { - "level": "warning", - "code": "sparse_slide_content", - "schema_version": "1.0", - "target": { - "slide_number": slide_number, - "bbox": slide_bbox, - }, - "rule": { - "name": "slide_visible_content_coverage", - "threshold": MIN_SLIDE_CONTENT_COVERAGE_RATIO, - "comparison": "content_coverage_ratio < threshold", - }, - "measurement": { - "slide_area": slide_area, - "visible_content_area": round(content_area, 3), - "content_coverage_ratio": round(coverage_ratio, 3), - "content_element_count": len(content), - }, - "elements": [element["id"] for element, _ in content], - } - ] - - -def detect_blank_slide(elements: list[dict[str, Any]], slide_number: int) -> list[dict[str, Any]]: - if elements: - return [] - return [ - { - "level": "warning", - "code": "blank_slide", - "schema_version": "1.0", - "target": {"slide_number": slide_number}, - "rule": { - "name": "slide_has_visible_content", - "comparison": "visible_element_count == 0", - }, - "measurement": {"visible_element_count": 0}, - "elements": [], - } - ] - - -def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]: - root, xml_error = xml_lint.parse_xml_root(xml) - if xml_error: - return { - "file": source_path, - "summary": {"slide_count": 0, "warning_count": 0, "error_count": 1}, - "issues": [xml_error], - "slides": [], - } - if root is None: - raise AssertionError("parse_xml_root must return a root or error") - presentation = xml_lint.parse_presentation(xml) - slides = [] - for index, slide_xml in enumerate(presentation["slides"]): - elements = extract_density_elements(slide_xml) - slide_number = index + 1 - slides.append( - { - "slide_number": slide_number, - "element_count": len(elements), - "issues": detect_blank_slide(elements, slide_number) - + detect_sparse_container_content(elements, slide_number, presentation["width"], presentation["height"]) - + detect_sparse_slide_content(elements, slide_number, presentation["width"], presentation["height"]), - } - ) - warning_count = sum(len(slide["issues"]) for slide in slides) - return { - "file": source_path, - "slide_size": {"width": presentation["width"], "height": presentation["height"]}, - "summary": {"slide_count": len(slides), "warning_count": warning_count, "error_count": 0}, - "slides": slides, - } - - -def print_usage() -> None: - print("Usage:\n python3 xml_layout_density_lint.py --input ", file=sys.stderr) - - -def run_cli(argv: list[str] | None = None) -> None: - options = xml_lint.parse_args(argv or sys.argv[1:]) - if options.get("help") or options.get("--help"): - print_usage() - raise SystemExit(0) - if not options.get("input"): - print_usage() - raise xml_lint.XmlTextOverlapLintError("--input is required") - input_path = Path(options["input"]).resolve() - print(json.dumps(lint_xml(xml_lint.read_file(input_path), str(input_path)), ensure_ascii=False, indent=2)) - - -if __name__ == "__main__": - try: - run_cli() - except xml_lint.XmlTextOverlapLintError as error: - print(f"xml-layout-density-lint error: {error}", file=sys.stderr) - raise SystemExit(1) from error diff --git a/skills/lark-slides/scripts/xml_layout_density_lint_test.py b/skills/lark-slides/scripts/xml_layout_density_lint_test.py deleted file mode 100644 index 1c1d4efa4c..0000000000 --- a/skills/lark-slides/scripts/xml_layout_density_lint_test.py +++ /dev/null @@ -1,458 +0,0 @@ -# Copyright (c) 2026 Lark Technologies Pte. Ltd. -# SPDX-License-Identifier: MIT -from __future__ import annotations - -import unittest - -import xml_layout_density_lint - - -class XmlLayoutDensityLintTest(unittest.TestCase): - def test_lint_xml_warns_for_blank_slide(self) -> None: - result = xml_layout_density_lint.lint_xml( - """ - - - - -

Investment report

-
-
-
- - - - - -
- """ - ) - - self.assertEqual(result["summary"], {"slide_count": 2, "warning_count": 1, "error_count": 0}) - self.assertEqual(result["slides"][0]["issues"], []) - self.assertEqual(result["slides"][1]["element_count"], 0) - self.assertEqual( - result["slides"][1]["issues"], - [ - { - "level": "warning", - "code": "blank_slide", - "schema_version": "1.0", - "target": {"slide_number": 2}, - "rule": { - "name": "slide_has_visible_content", - "comparison": "visible_element_count == 0", - }, - "measurement": {"visible_element_count": 0}, - "elements": [], - } - ], - ) - - def test_lint_xml_warns_when_large_container_is_mostly_empty(self) -> None: - result = xml_layout_density_lint.lint_xml( - """ - - - - -

Core trends

-
- -

First point

Second point

Third point

-
-
-
- """ - ) - - issue = result["slides"][0]["issues"][0] - self.assertEqual(issue["code"], "sparse_container_content") - self.assertEqual(issue["target"]["container_id"], "trend-card") - self.assertEqual(issue["target"], { - "slide_number": 1, - "container_id": "trend-card", - "container_type": "rect", - "bbox": {"x": 500, "y": 135, "width": 410, "height": 370}, - }) - self.assertLess(issue["measurement"]["content_coverage_ratio"], 0.15) - self.assertEqual(issue["rule"], { - "name": "large_container_visible_content_coverage", - "threshold": 0.15, - "comparison": "content_coverage_ratio < threshold", - }) - self.assertEqual(issue["measurement"]["container_area"], 151700) - self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0.032) - self.assertEqual(issue["elements"], ["trend-card", "trend-title", "trend-copy"]) - self.assertEqual(set(issue), {"level", "code", "schema_version", "target", "rule", "measurement", "elements"}) - - def test_lint_xml_warns_for_sparse_short_cards(self) -> None: - result = xml_layout_density_lint.lint_xml( - """ - - - - -

期待认识大家

-
- - -

化学一起讨论

-
- - -

吉他随时交流

-
- - -

共度美好四年

-
-
-
- """ - ) - - container_issues = [ - issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content" - ] - self.assertEqual( - [issue["target"]["container_id"] for issue in container_issues], - ["card-1", "card-2", "card-3", "card-4"], - ) - self.assertTrue(all(issue["target"]["bbox"]["height"] == 105 for issue in container_issues)) - self.assertTrue(all(issue["measurement"]["content_coverage_ratio"] < 0.15 for issue in container_issues)) - self.assertEqual( - [issue["code"] for issue in result["slides"][0]["issues"]], - [ - "sparse_container_content", - "sparse_container_content", - "sparse_container_content", - "sparse_container_content", - "sparse_slide_content", - ], - ) - - def test_lint_xml_warns_when_whole_slide_has_too_little_effective_content(self) -> None: - result = xml_layout_density_lint.lint_xml( - """ - - - - -

One short line

-
- -

Another line

-
- -

Third line

-
- -

Fourth line

-
-
-
- """ - ) - - issues = [issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_slide_content"] - self.assertEqual(len(issues), 1) - issue = issues[0] - self.assertEqual(issue["target"]["bbox"], {"x": 0, "y": 0, "width": 960, "height": 540}) - self.assertEqual(issue["rule"]["threshold"], 0.035) - self.assertLess(issue["measurement"]["content_coverage_ratio"], 0.035) - self.assertEqual(issue["measurement"]["content_element_count"], 4) - self.assertNotIn("background", issue["elements"]) - - def test_lint_xml_ignores_isolated_short_layout_bar(self) -> None: - result = xml_layout_density_lint.lint_xml( - """ - - - - -

One concise summary

-
-
-
- """ - ) - - self.assertEqual(result["slides"][0]["issues"], []) - - def test_lint_xml_counts_rect_own_content_as_visible_content(self) -> None: - result = xml_layout_density_lint.lint_xml( - """ - - - - -

被吊物

-

32.0 t

-

钢结构模块

-
-
-
-
- """ - ) - - self.assertEqual(result["slides"][0]["issues"], []) - - def test_lint_xml_reports_nonzero_coverage_for_rect_own_content_reproduction(self) -> None: - result = xml_layout_density_lint.lint_xml( - """ - - - - -

被吊物

-

32.0 t

-

钢结构模块

-
-
-
-
- """ - ) - - issue = result["slides"][0]["issues"][0] - self.assertGreater(issue["measurement"]["visible_content_area"], 0) - self.assertEqual(issue["measurement"]["content_element_count"], 1) - self.assertGreater(issue["measurement"]["content_coverage_ratio"], 0) - - def test_lint_xml_still_warns_for_sparse_rect_own_content(self) -> None: - result = xml_layout_density_lint.lint_xml( - """ - - - -

A

-
-
-
- """ - ) - - issue = result["slides"][0]["issues"][0] - self.assertEqual(issue["target"]["container_id"], "sparse-card") - self.assertGreater(issue["measurement"]["visible_content_area"], 0) - self.assertEqual(issue["measurement"]["content_element_count"], 1) - self.assertEqual(issue["elements"], ["sparse-card"]) - - def test_lint_xml_unions_rect_own_content_with_child_content(self) -> None: - self_only = xml_layout_density_lint.lint_xml( - """ - - - -

A

-
-
-
- """ - ) - with_overlapping_child = xml_layout_density_lint.lint_xml( - """ - - - -

A

-
- -

A

-
-
-
- """ - ) - - self_issue = self_only["slides"][0]["issues"][0] - mixed_issue = with_overlapping_child["slides"][0]["issues"][0] - self.assertEqual( - mixed_issue["measurement"]["visible_content_area"], - self_issue["measurement"]["visible_content_area"], - ) - self.assertEqual(mixed_issue["measurement"]["content_element_count"], 2) - - def test_extract_density_elements_reads_nested_font_size_from_rect_content(self) -> None: - elements = xml_layout_density_lint.extract_density_elements( - """ - - - -

32.0 t

-
-
-
- """ - ) - - self.assertEqual(elements[0]["fontSize"], 36) - - def test_extract_density_elements_does_not_attach_following_text_to_self_closing_rect(self) -> None: - elements = xml_layout_density_lint.extract_density_elements( - """ - - - - -

Following title

-
-
-
- """ - ) - - self.assertEqual(elements[0]["text"], "") - self.assertEqual(elements[1]["text"], "Following title") - - def test_lint_xml_allows_container_with_large_visual_child(self) -> None: - result = xml_layout_density_lint.lint_xml( - """ - - - - - - - """ - ) - - self.assertEqual(result["summary"]["warning_count"], 0) - - def test_lint_xml_warns_for_small_empty_visual_placeholder_cards(self) -> None: - result = xml_layout_density_lint.lint_xml( - """ - - - - -

Z

-
- -
-
- """ - ) - - issues = result["slides"][0]["issues"] - self.assertEqual( - [issue["target"]["container_id"] for issue in issues], - ["letter-placeholder", "empty-placeholder"], - ) - self.assertEqual(issues[1]["measurement"]["content_element_count"], 0) - - def test_lint_xml_applies_global_threshold_to_normal_text_card(self) -> None: - result = xml_layout_density_lint.lint_xml( - """ - - - - -

梦境与现实

-
- -

边界溶解,逻辑失效。观众被拽入潜意识的迷宫。

-
-
-
- """ - ) - - issue = result["slides"][0]["issues"][0] - self.assertEqual(issue["target"]["container_id"], "card") - self.assertEqual(issue["rule"]["threshold"], 0.15) - - def test_lint_xml_allows_image_overlay_rect(self) -> None: - result = xml_layout_density_lint.lint_xml( - """ - - - - - - - """ - ) - - self.assertEqual(result["summary"]["warning_count"], 0) - - def test_lint_xml_allows_edge_spanning_layout_panel_and_nested_decoration(self) -> None: - result = xml_layout_density_lint.lint_xml( - """ - - - - - - - """ - ) - - self.assertEqual(result["summary"]["warning_count"], 0) - - def test_lint_xml_counts_icons_as_visible_content(self) -> None: - result = xml_layout_density_lint.lint_xml( - """ - - - - - - - """ - ) - - self.assertEqual(result["summary"]["warning_count"], 0) - - def test_lint_xml_warns_when_coverage_is_below_global_threshold(self) -> None: - result = xml_layout_density_lint.lint_xml( - """ - - - - - - - """ - ) - - issue = result["slides"][0]["issues"][0] - self.assertEqual(issue["target"]["container_id"], "card") - self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0.122) - self.assertEqual(issue["rule"]["threshold"], 0.15) - - def test_lint_xml_allows_quarter_coverage_under_lower_threshold(self) -> None: - result = xml_layout_density_lint.lint_xml( - """ - - - - - - - """ - ) - - self.assertEqual(result["slides"][0]["issues"], []) - - def test_lint_xml_allows_large_metric_card_above_lower_threshold(self) -> None: - result = xml_layout_density_lint.lint_xml( - """ - - - - -

400+ 项

-
-
-
- """ - ) - - self.assertEqual(result["slides"][0]["issues"], []) - - -if __name__ == "__main__": - unittest.main() diff --git a/skills/lark-slides/scripts/xml_text_overlap_lint.py b/skills/lark-slides/scripts/xml_text_overlap_lint.py index eb80fcc988..5fdb30fc5a 100644 --- a/skills/lark-slides/scripts/xml_text_overlap_lint.py +++ b/skills/lark-slides/scripts/xml_text_overlap_lint.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 # Copyright (c) 2026 Lark Technologies Pte. Ltd. # SPDX-License-Identifier: MIT +"""Validate Slides XML structure and page layout through one release gate.""" from __future__ import annotations @@ -48,12 +49,12 @@ _ICONPARK_ICON_TYPES_CACHE: set[str] | None = None -class XmlTextOverlapLintError(Exception): +class XmlLayoutLintError(Exception): pass def fail(message: str) -> None: - raise XmlTextOverlapLintError(message) + raise XmlLayoutLintError(message) def read_file(file_path: str | Path) -> str: @@ -1194,12 +1195,7 @@ def detect_elements_out_of_canvas( elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float ) -> list[dict[str, Any]]: issues: list[dict[str, Any]] = [] - for element in ( - element - for element in elements - if element["kind"] in {"table", "chart"} - or (element["kind"] == "shape" and element["type"] == "text") - ): + for element in elements: bbox = element_canvas_bbox(element) overflow = { "left": max(-bbox["x"], 0), @@ -1336,56 +1332,613 @@ def lint_slide( return {"slide_number": slide_number, "element_count": len(elements), "issues": issues} -def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]: - root, xml_error = parse_xml_root(xml) - if xml_error: + +MIN_CONTAINER_WIDTH = 140 +MIN_CONTAINER_HEIGHT = 160 +MIN_SHORT_CARD_HEIGHT = 80 +MIN_CONTAINER_AREA = 20_000 +MIN_CONTENT_COVERAGE_RATIO = 0.15 +MIN_SLIDE_CONTENT_COVERAGE_RATIO = 0.035 +MIN_SLIDE_CONTENT_ELEMENT_COUNT = 4 +SHORT_CARD_SIZE_TOLERANCE_RATIO = 0.10 +MIN_SIMILAR_SHORT_CARD_COUNT = 2 +LARGE_VISUAL_CHILD_RATIO = 0.35 +LAYOUT_PANEL_SPAN_RATIO = 0.90 +IMAGE_OVERLAY_MATCH_RATIO = 0.90 +DENSITY_CONTAINMENT_TOLERANCE = 8 + + +def clipped_bbox(element: dict[str, Any], container: dict[str, Any]) -> dict[str, int | float] | None: + left = max(element["x"], container["x"]) + top = max(element["y"], container["y"]) + right = min(element["x"] + element["width"], container["x"] + container["width"]) + bottom = min(element["y"] + element["height"], container["y"] + container["height"]) + if right <= left or bottom <= top: + return None + return {"x": left, "y": top, "width": right - left, "height": bottom - top} + + +def rectangle_union_area(rectangles: list[dict[str, int | float]]) -> int | float: + x_coordinates = sorted({coordinate for rect in rectangles for coordinate in (rect["x"], rect["x"] + rect["width"])}) + area = 0 + for left, right in zip(x_coordinates, x_coordinates[1:]): + intervals = sorted( + (rect["y"], rect["y"] + rect["height"]) + for rect in rectangles + if rect["x"] < right and rect["x"] + rect["width"] > left + ) + covered_height = 0 + interval_end: int | float | None = None + for top, bottom in intervals: + if interval_end is None: + covered_height += bottom - top + interval_end = bottom + elif bottom > interval_end: + covered_height += bottom - max(top, interval_end) + interval_end = bottom + area += (right - left) * covered_height + return area + + +def has_similar_short_card_peer(element: dict[str, Any], elements: list[dict[str, Any]]) -> bool: + return sum( + other["kind"] == "shape" + and other["type"] == "rect" + and other["width"] >= MIN_CONTAINER_WIDTH + and other["height"] >= MIN_SHORT_CARD_HEIGHT + and element_area(other) >= MIN_CONTAINER_AREA + and abs(other["width"] - element["width"]) / max(other["width"], element["width"]) + <= SHORT_CARD_SIZE_TOLERANCE_RATIO + and abs(other["height"] - element["height"]) / max(other["height"], element["height"]) + <= SHORT_CARD_SIZE_TOLERANCE_RATIO + for other in elements + ) >= MIN_SIMILAR_SHORT_CARD_COUNT + + +def is_layout_container( + element: dict[str, Any], + slide_width: int | float, + slide_height: int | float, + elements: list[dict[str, Any]] | None = None, +) -> bool: + has_supported_height = element["height"] >= MIN_CONTAINER_HEIGHT or ( + elements is not None + and element["height"] >= MIN_SHORT_CARD_HEIGHT + and has_similar_short_card_peer(element, elements) + ) + return ( + element["kind"] == "shape" + and element["type"] == "rect" + and element["width"] >= MIN_CONTAINER_WIDTH + and has_supported_height + and element_area(element) >= MIN_CONTAINER_AREA + and not ( + element["x"] <= 2 + and element["y"] <= 2 + and element["width"] >= slide_width - 4 + and element["height"] >= slide_height - 4 + ) + ) + + +def is_edge_spanning_layout_panel( + element: dict[str, Any], slide_width: int | float, slide_height: int | float +) -> bool: + touches_horizontal_edge = element["x"] <= 2 or element["x"] + element["width"] >= slide_width - 2 + touches_vertical_edge = element["y"] <= 2 or element["y"] + element["height"] >= slide_height - 2 + return (touches_horizontal_edge and element["height"] >= slide_height * LAYOUT_PANEL_SPAN_RATIO) or ( + touches_vertical_edge and element["width"] >= slide_width * LAYOUT_PANEL_SPAN_RATIO + ) + + +def has_matching_image_overlay(container: dict[str, Any], elements: list[dict[str, Any]]) -> bool: + container_area = element_area(container) + return any( + element["kind"] == "img" + and intersection_area(container, element) + / max(1, min(container_area, element_area(element))) + >= IMAGE_OVERLAY_MATCH_RATIO + for element in elements + ) + + +def is_nested_in_layout_panel( + container: dict[str, Any], elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float +) -> bool: + return any( + element is not container + and element["kind"] == "shape" + and element["type"] == "rect" + and is_edge_spanning_layout_panel(element, slide_width, slide_height) + and contains(element, container, tolerance=DENSITY_CONTAINMENT_TOLERANCE) + for element in elements + ) + + +def extract_density_elements(slide_xml: str) -> list[dict[str, Any]]: + elements = extract_elements(slide_xml) + elements_by_id = {element["id"]: element for element in elements} + root = ET.fromstring(slide_xml) + for node in root.iter(): + if xml_local_name(node.tag) != "shape": + continue + element = elements_by_id.get(node.attrib.get("id", "")) + if element is None: + continue + content_node = next( + (child for child in node if xml_local_name(child.tag) == "content"), + None, + ) + paragraphs = ( + [ + " ".join("".join(paragraph.itertext()).split()) + for paragraph in content_node.iter() + if xml_local_name(paragraph.tag) == "p" + ] + if content_node is not None + else [] + ) + raw_font_size = ( + content_node.attrib.get("fontSize") if content_node is not None else None + ) or node.attrib.get("fontSize") + try: + base_font_size = float(raw_font_size or 16) + except ValueError: + base_font_size = 16.0 + element.update( + { + "textType": content_node.attrib.get("textType") if content_node is not None else None, + "textAlign": content_node.attrib.get("textAlign") if content_node is not None else None, + "autoFit": content_node.attrib.get("autoFit") if content_node is not None else None, + "fontSize": base_font_size, + "text": "\n".join(paragraph for paragraph in paragraphs if paragraph), + } + ) + if not has_text_content(element): + continue + declared_font_sizes = [ + float(descendant.attrib["fontSize"]) + for descendant in node.iter() + if descendant.attrib.get("fontSize") is not None + ] + if declared_font_sizes: + element["fontSize"] = max(declared_font_sizes) + for match in re.finditer(r"]*)>", slide_xml): + attrs = match.group(1) + x = extract_numeric_attribute(attrs, "topLeftX") + y = extract_numeric_attribute(attrs, "topLeftY") + width = extract_numeric_attribute(attrs, "width") + height = extract_numeric_attribute(attrs, "height") + if any(value is None for value in (x, y, width, height)): + continue + elements.append( + { + "id": extract_attribute(attrs, "id") or f"icon-{len(elements) + 1}", + "kind": "icon", + "type": "icon", + "x": x, + "y": y, + "width": width, + "height": height, + "rotation": extract_numeric_attribute(attrs, "rotation") or 0, + "order": len(elements), + } + ) + return elements + + +def visual_bbox(element: dict[str, Any], container: dict[str, Any]) -> dict[str, int | float] | None: + if is_text_element(element): + estimated = estimate_text_visual_bbox(element) + return clipped_bbox(estimated, container) if estimated else None + return clipped_bbox(element, container) + + +def own_text_visual_bbox(container: dict[str, Any]) -> dict[str, int | float] | None: + if container["kind"] != "shape" or not has_text_content(container): + return None + text_proxy = {**container, "type": "text"} + estimated = estimate_text_visual_bbox(text_proxy) + return clipped_bbox(estimated, container) if estimated else None + + +def slide_content_visual_bbox( + element: dict[str, Any], slide_bbox: dict[str, int | float] +) -> dict[str, int | float] | None: + if is_text_element(element): + estimated = estimate_text_visual_bbox(element) + return clipped_bbox(estimated, slide_bbox) if estimated else None + if element["kind"] == "shape" and has_text_content(element): + estimated = own_text_visual_bbox(element) + return clipped_bbox(estimated, slide_bbox) if estimated else None + if element["kind"] in {"img", "chart", "table", "whiteboard", "icon"}: + return clipped_bbox(element, slide_bbox) + return None + + +def is_large_visual_child(element: dict[str, Any], container: dict[str, Any]) -> bool: + if element["kind"] not in {"img", "chart", "table", "whiteboard"}: + return False + return element_area(element) / element_area(container) >= LARGE_VISUAL_CHILD_RATIO + + +def detect_sparse_container_content( + elements: list[dict[str, Any]], slide_number: int, slide_width: int | float, slide_height: int | float +) -> list[dict[str, Any]]: + issues: list[dict[str, Any]] = [] + for container in ( + element for element in elements if is_layout_container(element, slide_width, slide_height, elements) + ): + if ( + is_edge_spanning_layout_panel(container, slide_width, slide_height) + or is_nested_in_layout_panel(container, elements, slide_width, slide_height) + or has_matching_image_overlay(container, elements) + ): + continue + children = [ + element + for element in elements + if element is not container + and contains(container, element, tolerance=DENSITY_CONTAINMENT_TOLERANCE) + ] + if any(is_large_visual_child(child, container) for child in children): + continue + own_text_bbox = own_text_visual_bbox(container) + rectangles = ([own_text_bbox] if own_text_bbox else []) + [ + bbox for child in children if (bbox := visual_bbox(child, container)) is not None + ] + content_area = rectangle_union_area(rectangles) if rectangles else 0 + coverage_ratio = content_area / element_area(container) + if coverage_ratio >= MIN_CONTENT_COVERAGE_RATIO: + continue + issues.append( + { + "level": "warning", + "code": "sparse_container_content", + "schema_version": "1.0", + "target": { + "slide_number": slide_number, + "container_id": container["id"], + "container_type": container["type"], + "bbox": {key: container[key] for key in ("x", "y", "width", "height")}, + }, + "rule": { + "name": "large_container_visible_content_coverage", + "threshold": MIN_CONTENT_COVERAGE_RATIO, + "comparison": "content_coverage_ratio < threshold", + }, + "measurement": { + "container_area": element_area(container), + "visible_content_area": round(content_area, 3), + "content_coverage_ratio": round(coverage_ratio, 3), + "content_element_count": len(children) + (1 if own_text_bbox else 0), + }, + "elements": [container["id"], *[child["id"] for child in children]], + } + ) + return issues + + +def detect_sparse_slide_content( + elements: list[dict[str, Any]], slide_number: int, slide_width: int | float, slide_height: int | float +) -> list[dict[str, Any]]: + slide_bbox = {"x": 0, "y": 0, "width": slide_width, "height": slide_height} + content = [ + (element, bbox) + for element in elements + if (bbox := slide_content_visual_bbox(element, slide_bbox)) is not None + ] + if len(content) < MIN_SLIDE_CONTENT_ELEMENT_COUNT: + return [] + content_area = rectangle_union_area([bbox for _, bbox in content]) + slide_area = slide_width * slide_height + coverage_ratio = content_area / slide_area + if coverage_ratio >= MIN_SLIDE_CONTENT_COVERAGE_RATIO: + return [] + return [ + { + "level": "warning", + "code": "sparse_slide_content", + "schema_version": "1.0", + "target": { + "slide_number": slide_number, + "bbox": slide_bbox, + }, + "rule": { + "name": "slide_visible_content_coverage", + "threshold": MIN_SLIDE_CONTENT_COVERAGE_RATIO, + "comparison": "content_coverage_ratio < threshold", + }, + "measurement": { + "slide_area": slide_area, + "visible_content_area": round(content_area, 3), + "content_coverage_ratio": round(coverage_ratio, 3), + "content_element_count": len(content), + }, + "elements": [element["id"] for element, _ in content], + } + ] + + +def detect_blank_slide( + elements: list[dict[str, Any]], + slide_number: int, + slide_width: int | float, + slide_height: int | float, +) -> list[dict[str, Any]]: + slide_bbox = {"x": 0, "y": 0, "width": slide_width, "height": slide_height} + visible_elements = [ + element + for element in elements + if slide_content_visual_bbox(element, slide_bbox) is not None + ] + if visible_elements: + return [] + return [ + { + "level": "error", + "code": "blank_slide", + "schema_version": "2.0", + "target": {"slide_number": slide_number}, + "rule": { + "name": "slide_has_visible_content", + "comparison": "visible_element_count == 0", + }, + "measurement": { + "visible_element_count": 0, + "declared_element_count": len(elements), + }, + "elements": [element["id"] for element in elements], + "message": "slide has no visible content beyond empty layout shapes", + "hint": "Add visible text, an image, a chart, a table, a whiteboard, or an icon before creating the slide.", + } + ] + + + +RULE_METADATA: dict[str, dict[str, Any]] = { + "xml_not_well_formed": { + "name": "xml_is_well_formed", + "comparison": "xml_parse_error == false", + }, + "sml_prefixed_tag": { + "name": "sml_uses_default_namespace", + "comparison": "prefixed_sml_tag_count == 0", + }, + "sxsd_unsupported_tag": { + "name": "tag_is_supported_by_slides_xml_schema", + "comparison": "unsupported_tag_count == 0", + }, + "sxsd_unsupported_attr": { + "name": "attribute_is_supported_by_slides_xml_schema", + "comparison": "unsupported_attribute_count == 0", + }, + "icon_missing_fill_color": { + "name": "icon_has_visible_fill_color", + "comparison": "fill_color_present == true", + }, + "icon_transparent_fill_color": { + "name": "icon_has_visible_fill_color", + "comparison": "fill_alpha > 0", + }, + "iconpark_unsupported_icon_type": { + "name": "iconpark_type_is_supported", + "comparison": "icon_type in iconpark_index", + }, + "bbox_overlap": { + "name": "text_visual_bounds_do_not_overlap", + "comparison": "intersection_area == 0", + }, + "text_may_overflow_shape": { + "name": "estimated_text_fits_declared_shape", + "comparison": "estimated_height <= available_height", + }, + "whiteboard_external_overlap": { + "name": "whiteboard_does_not_cross_sibling_content", + "comparison": "external_overlap_count == 0", + }, + "image_covers_text": { + "name": "image_does_not_cover_text", + "comparison": "intersection_area == 0", + }, + "image_may_cover_vertical_text": { + "name": "image_vertical_text_occlusion_requires_review", + "comparison": "intersection_area == 0", + }, + "table_resolved_size_mismatch": { + "name": "table_declared_size_matches_resolved_grid", + "comparison": "declared_size == resolved_size", + }, + "blank_slide": { + "name": "slide_has_visible_content", + "comparison": "visible_element_count > 0", + }, +} + + +def issue_rule(issue: dict[str, Any]) -> dict[str, Any]: + if issue.get("rule"): + return {**issue["rule"], "id": issue["code"]} + if issue["code"].endswith("_out_of_canvas"): return { - "file": source_path, - "slide_size": {"width": 960, "height": 540}, - "summary": {"slide_count": 0, "error_count": 1, "warning_count": 0, "info_count": 0}, - "issues": [xml_error], - "slides": [], + "id": issue["code"], + "name": "element_stays_within_slide_canvas", + "comparison": "max(left, top, right, bottom overflow) == 0", } + return { + "id": issue["code"], + **RULE_METADATA.get( + issue["code"], + {"name": issue["code"], "comparison": "violation_count == 0"}, + ), + } - namespace_issues = validate_sml_tag_prefixes(xml) - sxsd_issues = validate_sxsd_tag_attributes(root) if root is not None else [] - iconpark_issues = validate_iconpark_icon_types(root) if root is not None else [] - top_level_issues = [*namespace_issues, *sxsd_issues, *iconpark_issues] - if namespace_issues: - error_count = sum(1 for issue in top_level_issues if issue["level"] == "error") - warning_count = sum(1 for issue in top_level_issues if issue["level"] == "warning") - info_count = sum(1 for issue in top_level_issues if issue["level"] == "info") + +def issue_measurement( + issue: dict[str, Any], elements_by_id: dict[str, dict[str, Any]] +) -> dict[str, Any]: + if issue.get("measurement") is not None: + return issue["measurement"] + if issue["code"] == "bbox_overlap" and len(issue.get("elements", [])) == 2: + left = elements_by_id.get(issue["elements"][0]) + right = elements_by_id.get(issue["elements"][1]) + if left and right: + width = intersection_width(left, right) + height = intersection_height(left, right) + return { + "intersection_width": round(width, 3), + "intersection_height": round(height, 3), + "intersection_area": round(width * height, 3), + } + if issue["code"].endswith("_out_of_canvas"): return { - "file": source_path, - "slide_size": {"width": 960, "height": 540}, - "summary": { - "slide_count": 0, - "error_count": error_count, - "warning_count": warning_count, - "info_count": info_count, - }, - "issues": top_level_issues, - "slides": [], + "canvas": issue.get("canvas"), + "bbox": issue.get("bbox"), + "overflow": issue.get("overflow"), } - presentation = parse_presentation(xml) - slides = [ - lint_slide(slide_xml, index + 1, presentation["width"], presentation["height"]) - for index, slide_xml in enumerate(presentation["slides"]) + measurement_keys = ( + "line", + "column", + "tag", + "attr", + "iconType", + "line_count", + "line_height", + "estimated_height", + "available_height", + "overflow", + "dimension", + "declared_size", + "resolved_size", + "resolved_sizes", + "overlaps", + ) + measured = {key: issue[key] for key in measurement_keys if key in issue} + return measured or {"violation_count": 1} + + +def related_object(element: dict[str, Any]) -> dict[str, Any]: + return { + "element_id": element["id"], + "kind": element["kind"], + "type": element["type"], + "bbox": {key: element[key] for key in ("x", "y", "width", "height")}, + } + + +def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]: + elements: list[dict[str, Any]] = [] + for match in re.finditer(r"]*)>", slide_xml): + attrs = match.group(1) + start_x = extract_numeric_attribute(attrs, "startX") + start_y = extract_numeric_attribute(attrs, "startY") + end_x = extract_numeric_attribute(attrs, "endX") + end_y = extract_numeric_attribute(attrs, "endY") + if any(value is None for value in (start_x, start_y, end_x, end_y)): + continue + elements.append( + { + "id": extract_attribute(attrs, "id") or f"line-{len(elements) + 1}", + "kind": "line", + "type": "line", + "x": min(start_x, end_x), + "y": min(start_y, end_y), + "width": abs(end_x - start_x), + "height": abs(end_y - start_y), + "rotation": 0, + "order": len(elements), + } + ) + return elements + + +def normalize_issue( + issue: dict[str, Any], + slide_number: int | None, + elements_by_id: dict[str, dict[str, Any]], +) -> dict[str, Any]: + normalized = dict(issue) + if normalized.get("level") == "info": + normalized["level"] = "warning" + element_ids = list(dict.fromkeys(normalized.get("elements", []))) + normalized["schema_version"] = "2.0" + normalized["element_ids"] = element_ids + normalized["target"] = { + **({"slide_number": slide_number} if slide_number is not None else {}), + **normalized.get("target", {}), + } + normalized["rule"] = issue_rule(normalized) + normalized["measurement"] = issue_measurement(normalized, elements_by_id) + normalized["related_objects"] = [ + related_object(elements_by_id[element_id]) + for element_id in element_ids + if element_id in elements_by_id ] - error_count = sum(1 for issue in top_level_issues if issue["level"] == "error") - error_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "error") - warning_count = sum(1 for issue in top_level_issues if issue["level"] == "warning") - warning_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "warning") - info_count = sum(1 for issue in top_level_issues if issue["level"] == "info") - info_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "info") - result = { + if normalized["code"] == "sparse_container_content": + ratio = normalized["measurement"]["content_coverage_ratio"] + threshold = normalized["rule"]["threshold"] + container_id = normalized["target"].get("container_id", "unknown") + normalized.setdefault( + "message", + f"large card {container_id} content coverage {ratio:.1%} is below {threshold:.1%}", + ) + normalized.setdefault( + "hint", + "Review the rendered screenshot; add or enlarge meaningful content if the whitespace is not intentional.", + ) + elif normalized["code"] == "sparse_slide_content": + ratio = normalized["measurement"]["content_coverage_ratio"] + threshold = normalized["rule"]["threshold"] + normalized.setdefault( + "message", + f"slide visible content coverage {ratio:.1%} is below {threshold:.1%}", + ) + normalized.setdefault( + "hint", + "Review the rendered screenshot to decide whether the page is intentionally sparse.", + ) + else: + normalized.setdefault("message", normalized["code"].replace("_", " ")) + return normalized + + +def slide_status(errors: list[dict[str, Any]], warnings: list[dict[str, Any]]) -> str: + if errors: + return "blocked" + if warnings: + return "needs_screenshot_review" + return "passed" + + +def build_result( + source_path: str | None, + slide_size: dict[str, int | float], + top_level_issues: list[dict[str, Any]], + slides: list[dict[str, Any]], +) -> dict[str, Any]: + document_errors = [issue for issue in top_level_issues if issue["level"] == "error"] + document_warnings = [issue for issue in top_level_issues if issue["level"] == "warning"] + error_count = len(document_errors) + sum(len(slide["errors"]) for slide in slides) + warning_count = len(document_warnings) + sum(len(slide["warnings"]) for slide in slides) + all_errors = document_errors + [issue for slide in slides for issue in slide["errors"]] + all_warnings = document_warnings + [issue for slide in slides for issue in slide["warnings"]] + status = slide_status(all_errors, all_warnings) + result: dict[str, Any] = { + "schema_version": "2.0", + "tool": "xml_text_overlap_lint", "file": source_path, - "slide_size": {"width": presentation["width"], "height": presentation["height"]}, + "slide_size": slide_size, "summary": { "slide_count": len(slides), "error_count": error_count, "warning_count": warning_count, - "info_count": info_count, + "status": status, + "release_ready": error_count == 0, + "screenshot_review_required": warning_count > 0, + }, + "document": { + "errors": document_errors, + "warnings": document_warnings, }, "slides": slides, } @@ -1394,6 +1947,104 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]: return result +def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]: + root, xml_error = parse_xml_root(xml) + if xml_error: + issue = normalize_issue(xml_error, None, {}) + return build_result( + source_path, + {"width": 960, "height": 540}, + [issue], + [], + ) + if root is None: + raise AssertionError("parse_xml_root must return a root or error") + + namespace_issues = validate_sml_tag_prefixes(xml) + sxsd_issues = validate_sxsd_tag_attributes(root) + iconpark_issues = validate_iconpark_icon_types(root) + top_level_issues = [ + normalize_issue(issue, None, {}) + for issue in [*namespace_issues, *sxsd_issues, *iconpark_issues] + ] + if any(issue["level"] == "error" for issue in top_level_issues): + return build_result( + source_path, + {"width": 960, "height": 540}, + top_level_issues, + [], + ) + + presentation = parse_presentation(xml) + slides: list[dict[str, Any]] = [] + for index, slide_xml in enumerate(presentation["slides"]): + slide_number = index + 1 + geometry = lint_slide( + slide_xml, + slide_number, + presentation["width"], + presentation["height"], + ) + density_elements = extract_density_elements(slide_xml) + extra_elements = [ + *[element for element in density_elements if element["kind"] == "icon"], + *extract_line_elements(slide_xml), + ] + elements_by_id = { + element["id"]: element for element in [*density_elements, *extra_elements] + } + extra_overflow_issues = detect_elements_out_of_canvas( + extra_elements, + presentation["width"], + presentation["height"], + ) + raw_issues = [ + *geometry["issues"], + *extra_overflow_issues, + *detect_blank_slide( + density_elements, + slide_number, + presentation["width"], + presentation["height"], + ), + *detect_sparse_container_content( + density_elements, + slide_number, + presentation["width"], + presentation["height"], + ), + *detect_sparse_slide_content( + density_elements, + slide_number, + presentation["width"], + presentation["height"], + ), + ] + issues = [ + normalize_issue(issue, slide_number, elements_by_id) + for issue in raw_issues + ] + errors = [issue for issue in issues if issue["level"] == "error"] + warnings = [issue for issue in issues if issue["level"] == "warning"] + slides.append( + { + "slide_number": slide_number, + "status": slide_status(errors, warnings), + "element_count": len(elements_by_id), + "errors": errors, + "warnings": warnings, + "issues": issues, + } + ) + + return build_result( + source_path, + {"width": presentation["width"], "height": presentation["height"]}, + top_level_issues, + slides, + ) + + def print_usage() -> None: print("Usage:\n python3 xml_text_overlap_lint.py --input ", file=sys.stderr) @@ -1416,6 +2067,6 @@ def run_cli(argv: list[str] | None = None) -> None: if __name__ == "__main__": try: run_cli() - except XmlTextOverlapLintError as error: + except XmlLayoutLintError as error: print(f"xml-text-overlap-lint error: {error}", file=sys.stderr) raise SystemExit(1) from error 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 3eceb7e1be..4386f8e23f 100644 --- a/skills/lark-slides/scripts/xml_text_overlap_lint_test.py +++ b/skills/lark-slides/scripts/xml_text_overlap_lint_test.py @@ -12,8 +12,8 @@ import xml_text_overlap_lint -class XmlTextOverlapLintTest(unittest.TestCase): - def assertNoXmlTextOverlapLintIssues(self, result: dict, sample_name: str) -> None: +class XmlTextOverlapLintGeometryTest(unittest.TestCase): + def assertNoXmlTextOverlapLintErrors(self, result: dict, sample_name: str) -> None: issue_summaries = [] for slide in result.get("slides", []): for issue in slide.get("issues", []): @@ -28,11 +28,6 @@ def assertNoXmlTextOverlapLintIssues(self, result: dict, sample_name: str) -> No 0, f"{sample_name} has XML text overlap lint errors:\n" + "\n".join(issue_summaries), ) - self.assertEqual( - result["summary"]["warning_count"], - 0, - f"{sample_name} has XML text overlap lint warnings:\n" + "\n".join(issue_summaries), - ) def test_cli_suggests_input_flag_for_positional_argument(self) -> None: script_path = Path(xml_text_overlap_lint.__file__).resolve() @@ -49,7 +44,7 @@ def test_cli_suggests_input_flag_for_positional_argument(self) -> None: self.assertEqual(completed.stdout, "") self.assertEqual( completed.stderr, - f"xml-text-overlap-lint error: unexpected argument: {input_path},need --input\n", + f"xml-text-overlap-lint error: unexpected argument: {input_path}, need --input\n", ) def test_xml_text_overlap_lint_accepts_inline_fixture_xml_samples(self) -> None: @@ -101,7 +96,7 @@ def test_xml_text_overlap_lint_accepts_inline_fixture_xml_samples(self) -> None: sample_xml, sample_name, ) - self.assertNoXmlTextOverlapLintIssues(result, sample_name) + self.assertNoXmlTextOverlapLintErrors(result, sample_name) def test_lint_xml_reports_unescaped_ampersand_in_text(self) -> None: result = xml_text_overlap_lint.lint_xml( @@ -235,8 +230,11 @@ def test_lint_xml_single_slide_uses_default_canvas_without_bounds_checks(self) - ) self.assertEqual(result["slide_size"], {"width": 960, "height": 540}) self.assertEqual(result["summary"]["slide_count"], 1) - self.assertEqual(result["summary"]["error_count"], 1) - self.assertEqual(result["slides"][0]["issues"][0]["code"], "shape_out_of_canvas") + self.assertEqual(result["summary"]["error_count"], 2) + self.assertEqual( + [issue["code"] for issue in result["slides"][0]["errors"]], + ["shape_out_of_canvas", "blank_slide"], + ) def test_lint_xml_preserves_presentation_canvas_and_slide_order(self) -> None: result = xml_text_overlap_lint.lint_xml( @@ -752,7 +750,7 @@ def test_strip_xml_paragraphs_preserves_br_as_hard_line_break(self) -> None: "第一行\n第二行\n第三行", ) - def test_lint_xml_allows_template_style_bleed_and_text_over_images(self) -> None: + def test_lint_xml_blocks_template_style_bleed_outside_canvas(self) -> None: result = xml_text_overlap_lint.lint_xml( """ @@ -770,8 +768,9 @@ def test_lint_xml_allows_template_style_bleed_and_text_over_images(self) -> None """ ) - self.assertEqual(result["summary"]["error_count"], 0) + self.assertEqual(result["summary"]["error_count"], 1) self.assertEqual(result["summary"]["warning_count"], 0) + self.assertEqual(result["slides"][0]["errors"][0]["code"], "img_out_of_canvas") def test_extract_elements_preserves_supported_element_geometry_order_and_text_metadata(self) -> None: elements = xml_text_overlap_lint.extract_elements( @@ -805,7 +804,7 @@ def test_extract_elements_preserves_supported_element_geometry_order_and_text_me self.assertEqual(elements[1]["fontSize"], 28) self.assertEqual(elements[1]["text"], "Growth & scale\nFocused execution") - def test_lint_xml_allows_small_out_of_bounds_images(self) -> None: + def test_lint_xml_blocks_small_out_of_bounds_images(self) -> None: result = xml_text_overlap_lint.lint_xml( """ @@ -817,9 +816,10 @@ def test_lint_xml_allows_small_out_of_bounds_images(self) -> None: """ ) - self.assertEqual(result["summary"]["error_count"], 0) + self.assertEqual(result["summary"]["error_count"], 1) + self.assertEqual(result["slides"][0]["errors"][0]["code"], "img_out_of_canvas") - def test_lint_xml_allows_out_of_canvas_images(self) -> None: + def test_lint_xml_blocks_out_of_canvas_images(self) -> None: result = xml_text_overlap_lint.lint_xml( """ @@ -832,9 +832,13 @@ def test_lint_xml_allows_out_of_canvas_images(self) -> None: """ ) - self.assertEqual(result["summary"]["error_count"], 0) + self.assertEqual(result["summary"]["error_count"], 2) + self.assertEqual( + [issue["code"] for issue in result["slides"][0]["errors"]], + ["img_out_of_canvas", "img_out_of_canvas"], + ) - def test_lint_xml_allows_full_bleed_images(self) -> None: + def test_lint_xml_blocks_full_bleed_images_outside_canvas(self) -> None: result = xml_text_overlap_lint.lint_xml( """ @@ -846,7 +850,8 @@ def test_lint_xml_allows_full_bleed_images(self) -> None: """ ) - self.assertEqual(result["summary"]["error_count"], 0) + self.assertEqual(result["summary"]["error_count"], 1) + self.assertEqual(result["slides"][0]["errors"][0]["code"], "img_out_of_canvas") def test_lint_xml_reports_text_and_chart_out_of_canvas(self) -> None: result = xml_text_overlap_lint.lint_xml( @@ -863,15 +868,36 @@ def test_lint_xml_reports_text_and_chart_out_of_canvas(self) -> None: """ ) issues = result["slides"][0]["issues"] - self.assertEqual(result["summary"]["error_count"], 2) + self.assertEqual(result["summary"]["error_count"], 3) self.assertEqual( [(issue["code"], issue["elements"], issue["overflow"]) for issue in issues], [ ("shape_out_of_canvas", ["outside-shape"], {"left": 10, "top": 0, "right": 0, "bottom": 0}), + ("img_out_of_canvas", ["outside-img"], {"left": 0, "top": 20, "right": 0, "bottom": 0}), ("chart_out_of_canvas", ["outside-chart"], {"left": 0, "top": 0, "right": 40, "bottom": 0}), ], ) + def test_lint_xml_reports_line_out_of_canvas_with_structured_geometry(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + +

Visible content

+
+ +
+
+ """ + ) + + issue = result["slides"][0]["errors"][0] + self.assertEqual(issue["code"], "line_out_of_canvas") + self.assertEqual(issue["element_ids"], ["connector"]) + self.assertEqual(issue["measurement"]["overflow"]["right"], 20) + self.assertEqual(issue["related_objects"][0]["kind"], "line") + def test_lint_xml_uses_rotated_text_and_chart_bounds_for_canvas_validation(self) -> None: result = xml_text_overlap_lint.lint_xml( """ @@ -1057,9 +1083,8 @@ def test_lint_xml_reports_info_when_table_target_size_resolves_larger_than_decla ) issues_by_dimension = {issue["dimension"]: issue for issue in result["slides"][0]["issues"]} self.assertEqual(result["summary"]["error_count"], 0) - self.assertEqual(result["summary"]["warning_count"], 0) - self.assertEqual(result["summary"]["info_count"], 2) - self.assertEqual(issues_by_dimension["width"]["level"], "info") + self.assertEqual(result["summary"]["warning_count"], 2) + self.assertEqual(issues_by_dimension["width"]["level"], "warning") self.assertEqual(issues_by_dimension["width"]["code"], "table_resolved_size_mismatch") self.assertEqual(issues_by_dimension["width"]["resolved_sizes"], [100, 100, 50]) self.assertEqual(issues_by_dimension["width"]["resolved_size"], 250) @@ -1084,7 +1109,6 @@ def test_lint_xml_does_not_report_info_when_table_target_size_is_resolved_exactl ) self.assertEqual(result["summary"]["error_count"], 0) self.assertEqual(result["summary"]["warning_count"], 0) - self.assertEqual(result["summary"]["info_count"], 0) self.assertEqual(result["slides"][0]["issues"], []) def test_lint_xml_keeps_resolved_table_sizes_positive_when_target_is_too_small(self) -> None: @@ -1153,7 +1177,7 @@ def test_cli_reports_table_layout_size_info_for_weighted_min_layout_cases(self) } script_path = Path(xml_text_overlap_lint.__file__).resolve() with tempfile.TemporaryDirectory() as temp_dir: - for name, (table_xml, expected_info_count) in cases.items(): + for name, (table_xml, expected_warning_count) in cases.items(): with self.subTest(case=name): input_path = Path(temp_dir) / f"{name}.xml" input_path.write_text( @@ -1173,10 +1197,9 @@ def test_cli_reports_table_layout_size_info_for_weighted_min_layout_cases(self) result = json.loads(completed.stdout) self.assertEqual(completed.returncode, 0, completed.stderr) self.assertEqual(result["summary"]["error_count"], 0) - self.assertEqual(result["summary"]["warning_count"], 0) - self.assertEqual(result["summary"]["info_count"], expected_info_count) + self.assertEqual(result["summary"]["warning_count"], expected_warning_count) self.assertTrue( - all(issue["level"] == "info" for issue in result["slides"][0]["issues"]), + all(issue["level"] == "warning" for issue in result["slides"][0]["issues"]), result["slides"][0]["issues"], ) @@ -1209,7 +1232,7 @@ def test_lint_xml_detects_invalid_template_text_stack_overlap(self) -> None: self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap") - def test_lint_xml_reports_vertical_text_image_overlap_as_info(self) -> None: + def test_lint_xml_reports_vertical_text_image_overlap_as_warning(self) -> None: result = xml_text_overlap_lint.lint_xml( """ @@ -1221,9 +1244,463 @@ def test_lint_xml_reports_vertical_text_image_overlap_as_info(self) -> None: """ ) issue = next(issue for issue in result["slides"][0]["issues"] if issue["code"] == "image_may_cover_vertical_text") - self.assertEqual(issue["level"], "info") + self.assertEqual(issue["level"], "warning") self.assertEqual(result["summary"]["error_count"], 0) +class XmlTextOverlapLintDensityTest(unittest.TestCase): + def test_lint_xml_blocks_blank_slide(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + +

Investment report

+
+
+
+ + + + + +
+ """ + ) + + self.assertEqual(result["summary"]["slide_count"], 2) + self.assertEqual(result["summary"]["warning_count"], 0) + self.assertEqual(result["summary"]["error_count"], 1) + self.assertEqual(result["summary"]["status"], "blocked") + self.assertFalse(result["summary"]["release_ready"]) + self.assertEqual(result["slides"][0]["issues"], []) + self.assertEqual(result["slides"][1]["element_count"], 0) + issue = result["slides"][1]["errors"][0] + self.assertEqual(issue["level"], "error") + self.assertEqual(issue["code"], "blank_slide") + self.assertEqual(issue["element_ids"], []) + self.assertEqual(issue["rule"]["id"], "blank_slide") + self.assertEqual(issue["measurement"]["visible_element_count"], 0) + self.assertEqual(issue["related_objects"], []) + + def test_lint_xml_warns_when_large_container_is_mostly_empty(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + +

Core trends

+
+ +

First point

Second point

Third point

+
+
+
+ """ + ) + + issue = result["slides"][0]["issues"][0] + self.assertEqual(issue["code"], "sparse_container_content") + self.assertEqual(issue["target"]["container_id"], "trend-card") + self.assertEqual(issue["target"], { + "slide_number": 1, + "container_id": "trend-card", + "container_type": "rect", + "bbox": {"x": 500, "y": 135, "width": 410, "height": 370}, + }) + self.assertLess(issue["measurement"]["content_coverage_ratio"], 0.15) + self.assertEqual(issue["rule"], { + "name": "large_container_visible_content_coverage", + "threshold": 0.15, + "comparison": "content_coverage_ratio < threshold", + "id": "sparse_container_content", + }) + self.assertEqual(issue["measurement"]["container_area"], 151700) + self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0.032) + self.assertEqual(issue["elements"], ["trend-card", "trend-title", "trend-copy"]) + self.assertEqual(issue["element_ids"], ["trend-card", "trend-title", "trend-copy"]) + self.assertEqual( + [obj["element_id"] for obj in issue["related_objects"]], + ["trend-card", "trend-title", "trend-copy"], + ) + self.assertEqual(result["slides"][0]["status"], "needs_screenshot_review") + self.assertEqual(result["slides"][0]["warnings"], result["slides"][0]["issues"]) + + def test_lint_xml_warns_for_sparse_short_cards(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + +

期待认识大家

+
+ + +

化学一起讨论

+
+ + +

吉他随时交流

+
+ + +

共度美好四年

+
+
+
+ """ + ) + + container_issues = [ + issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content" + ] + self.assertEqual( + [issue["target"]["container_id"] for issue in container_issues], + ["card-1", "card-2", "card-3", "card-4"], + ) + self.assertTrue(all(issue["target"]["bbox"]["height"] == 105 for issue in container_issues)) + self.assertTrue(all(issue["measurement"]["content_coverage_ratio"] < 0.15 for issue in container_issues)) + self.assertEqual( + [issue["code"] for issue in result["slides"][0]["issues"]], + [ + "sparse_container_content", + "sparse_container_content", + "sparse_container_content", + "sparse_container_content", + "sparse_slide_content", + ], + ) + + def test_lint_xml_warns_when_whole_slide_has_too_little_effective_content(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + +

One short line

+
+ +

Another line

+
+ +

Third line

+
+ +

Fourth line

+
+
+
+ """ + ) + + issues = [issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_slide_content"] + self.assertEqual(len(issues), 1) + issue = issues[0] + self.assertEqual(issue["target"]["bbox"], {"x": 0, "y": 0, "width": 960, "height": 540}) + self.assertEqual(issue["rule"]["threshold"], 0.035) + self.assertLess(issue["measurement"]["content_coverage_ratio"], 0.035) + self.assertEqual(issue["measurement"]["content_element_count"], 4) + self.assertNotIn("background", issue["elements"]) + + def test_lint_xml_ignores_isolated_short_layout_bar(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + +

One concise summary

+
+
+
+ """ + ) + + self.assertEqual(result["slides"][0]["issues"], []) + + def test_lint_xml_counts_rect_own_content_as_visible_content(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + +

被吊物

+

32.0 t

+

钢结构模块

+
+
+
+
+ """ + ) + + self.assertEqual(result["slides"][0]["issues"], []) + + def test_lint_xml_reports_nonzero_coverage_for_rect_own_content_reproduction(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + +

被吊物

+

32.0 t

+

钢结构模块

+
+
+
+
+ """ + ) + + issue = result["slides"][0]["issues"][0] + self.assertGreater(issue["measurement"]["visible_content_area"], 0) + self.assertEqual(issue["measurement"]["content_element_count"], 1) + self.assertGreater(issue["measurement"]["content_coverage_ratio"], 0) + + def test_lint_xml_still_warns_for_sparse_rect_own_content(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + +

A

+
+
+
+ """ + ) + + issue = result["slides"][0]["issues"][0] + self.assertEqual(issue["target"]["container_id"], "sparse-card") + self.assertGreater(issue["measurement"]["visible_content_area"], 0) + self.assertEqual(issue["measurement"]["content_element_count"], 1) + self.assertEqual(issue["elements"], ["sparse-card"]) + + def test_lint_xml_unions_rect_own_content_with_child_content(self) -> None: + self_only = xml_text_overlap_lint.lint_xml( + """ + + + +

A

+
+
+
+ """ + ) + with_overlapping_child = xml_text_overlap_lint.lint_xml( + """ + + + +

A

+
+ +

A

+
+
+
+ """ + ) + + self_issue = self_only["slides"][0]["issues"][0] + mixed_issue = with_overlapping_child["slides"][0]["issues"][0] + self.assertEqual( + mixed_issue["measurement"]["visible_content_area"], + self_issue["measurement"]["visible_content_area"], + ) + self.assertEqual(mixed_issue["measurement"]["content_element_count"], 2) + + def test_extract_density_elements_reads_nested_font_size_from_rect_content(self) -> None: + elements = xml_text_overlap_lint.extract_density_elements( + """ + + + +

32.0 t

+
+
+
+ """ + ) + + self.assertEqual(elements[0]["fontSize"], 36) + + def test_extract_density_elements_does_not_attach_following_text_to_self_closing_rect(self) -> None: + elements = xml_text_overlap_lint.extract_density_elements( + """ + + + + +

Following title

+
+
+
+ """ + ) + + self.assertEqual(elements[0]["text"], "") + self.assertEqual(elements[1]["text"], "Following title") + + def test_lint_xml_allows_container_with_large_visual_child(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + + + + """ + ) + + self.assertEqual(result["summary"]["warning_count"], 0) + + def test_lint_xml_warns_for_small_empty_visual_placeholder_cards(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + +

Z

+
+ +
+
+ """ + ) + + issues = result["slides"][0]["issues"] + self.assertEqual( + [issue["target"]["container_id"] for issue in issues], + ["letter-placeholder", "empty-placeholder"], + ) + self.assertEqual(issues[1]["measurement"]["content_element_count"], 0) + + def test_lint_xml_applies_global_threshold_to_normal_text_card(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + +

梦境与现实

+
+ +

边界溶解,逻辑失效。观众被拽入潜意识的迷宫。

+
+
+
+ """ + ) + + issue = result["slides"][0]["issues"][0] + self.assertEqual(issue["target"]["container_id"], "card") + self.assertEqual(issue["rule"]["threshold"], 0.15) + + def test_lint_xml_allows_image_overlay_rect(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + + + + """ + ) + + self.assertEqual(result["summary"]["warning_count"], 0) + + def test_lint_xml_allows_edge_spanning_layout_panel_and_nested_decoration(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + + + + """ + ) + + self.assertEqual(result["summary"]["warning_count"], 0) + + def test_lint_xml_counts_icons_as_visible_content(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + + + + + + """ + ) + + self.assertEqual(result["summary"]["warning_count"], 0) + + def test_lint_xml_warns_when_coverage_is_below_global_threshold(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + + + + + + """ + ) + + issue = result["slides"][0]["issues"][0] + self.assertEqual(issue["target"]["container_id"], "card") + self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0.122) + self.assertEqual(issue["rule"]["threshold"], 0.15) + + def test_lint_xml_allows_quarter_coverage_under_lower_threshold(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + + + + + + """ + ) + + self.assertEqual(result["slides"][0]["issues"], []) + + def test_lint_xml_allows_large_metric_card_above_lower_threshold(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + +

400+ 项

+
+
+
+ """ + ) + + self.assertEqual(result["slides"][0]["issues"], []) + + if __name__ == "__main__": unittest.main() From 1ad2a9f50e6c2439e7dfb2897117e242f5128767 Mon Sep 17 00:00:00 2001 From: "dengzilong.zero" Date: Thu, 23 Jul 2026 12:35:59 +0800 Subject: [PATCH 06/10] fix(slides): guard descendant font-size parsing and fix stale test name Skip non-numeric fontSize attributes on descendant nodes instead of letting float() raise uncaught, matching the existing base_font_size guard. Also rename a test whose name claimed no bounds checks occur while its own assertions verify shape_out_of_canvas and blank_slide errors are reported. --- .../lark-slides/scripts/xml_text_overlap_lint.py | 14 +++++++++----- .../scripts/xml_text_overlap_lint_test.py | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/skills/lark-slides/scripts/xml_text_overlap_lint.py b/skills/lark-slides/scripts/xml_text_overlap_lint.py index 5fdb30fc5a..84a14baafd 100644 --- a/skills/lark-slides/scripts/xml_text_overlap_lint.py +++ b/skills/lark-slides/scripts/xml_text_overlap_lint.py @@ -1496,11 +1496,15 @@ def extract_density_elements(slide_xml: str) -> list[dict[str, Any]]: ) if not has_text_content(element): continue - declared_font_sizes = [ - float(descendant.attrib["fontSize"]) - for descendant in node.iter() - if descendant.attrib.get("fontSize") is not None - ] + declared_font_sizes = [] + for descendant in node.iter(): + raw_declared_font_size = descendant.attrib.get("fontSize") + if raw_declared_font_size is None: + continue + try: + declared_font_sizes.append(float(raw_declared_font_size)) + except ValueError: + continue if declared_font_sizes: element["fontSize"] = max(declared_font_sizes) for match in re.finditer(r"]*)>", slide_xml): 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 4386f8e23f..552712a5ce 100644 --- a/skills/lark-slides/scripts/xml_text_overlap_lint_test.py +++ b/skills/lark-slides/scripts/xml_text_overlap_lint_test.py @@ -216,7 +216,7 @@ def test_lint_xml_accepts_chinese_full_width_punctuation(self) -> None: ) self.assertEqual(result["summary"]["error_count"], 0) - def test_lint_xml_single_slide_uses_default_canvas_without_bounds_checks(self) -> None: + def test_lint_xml_single_slide_reports_out_of_canvas_and_blank_slide_errors(self) -> None: result = xml_text_overlap_lint.lint_xml( """ From 3d3e7984374fd51191db9afb5938e27362bb3e19 Mon Sep 17 00:00:00 2001 From: "dengzilong.zero" Date: Thu, 23 Jul 2026 17:24:48 +0800 Subject: [PATCH 07/10] fix(slides): fix rotation, alpha visibility, and hint gaps in layout lint --- .../scripts/xml_text_overlap_lint.py | 38 ++++++- .../scripts/xml_text_overlap_lint_test.py | 106 +++++++++++++++++- 2 files changed, 139 insertions(+), 5 deletions(-) diff --git a/skills/lark-slides/scripts/xml_text_overlap_lint.py b/skills/lark-slides/scripts/xml_text_overlap_lint.py index 84a14baafd..526143dbbc 100644 --- a/skills/lark-slides/scripts/xml_text_overlap_lint.py +++ b/skills/lark-slides/scripts/xml_text_overlap_lint.py @@ -1040,6 +1040,19 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str, return vertical_overlap >= min_vertical_overlap +def horizontal_text_overflow_measurement(left: dict[str, Any], right: dict[str, Any]) -> dict[str, int | float]: + source, target = sorted([left, right], key=lambda element: element["x"]) + visual_width = estimate_text_max_line_width(source) + source_visual_bbox = {"x": source["x"], "y": source["y"], "width": visual_width, "height": source["height"]} + width = intersection_width(source_visual_bbox, target) + height = intersection_height(source_visual_bbox, target) + return { + "intersection_width": round(width, 3), + "intersection_height": round(height, 3), + "intersection_area": round(width * height, 3), + } + + def should_flag_overlap(left: dict[str, Any], right: dict[str, Any]) -> bool: if is_text_element(left) and not has_text_content(left): return False @@ -1167,9 +1180,6 @@ def detect_whiteboard_external_overlaps( def element_canvas_bbox(element: dict[str, Any]) -> dict[str, int | float]: bbox = {key: element[key] for key in ("x", "y", "width", "height")} - if element["kind"] != "chart" and not (element["kind"] == "shape" and element["type"] == "text"): - return bbox - rotation = element["rotation"] if not isinstance(rotation, (int, float)) or not math.isfinite(rotation): rotation = 0 @@ -1326,6 +1336,12 @@ def lint_slide( "code": "bbox_overlap", "elements": [left["id"], right["id"]], "message": f'{left["id"]} overlaps {right["id"]}', + "hint": "Move or resize the elements so their visual bounds no longer intersect.", + **( + {"measurement": horizontal_text_overflow_measurement(left, right)} + if horizontal_overflow + else {} + ), } ) @@ -1435,6 +1451,7 @@ def has_matching_image_overlay(container: dict[str, Any], elements: list[dict[st container_area = element_area(container) return any( element["kind"] == "img" + and is_visually_rendered(element) and intersection_area(container, element) / max(1, min(container_area, element_area(element))) >= IMAGE_OVERLAY_MATCH_RATIO @@ -1515,6 +1532,7 @@ def extract_density_elements(slide_xml: str) -> list[dict[str, Any]]: height = extract_numeric_attribute(attrs, "height") if any(value is None for value in (x, y, width, height)): continue + icon_alpha = extract_numeric_attribute(attrs, "alpha") elements.append( { "id": extract_attribute(attrs, "id") or f"icon-{len(elements) + 1}", @@ -1525,13 +1543,20 @@ def extract_density_elements(slide_xml: str) -> list[dict[str, Any]]: "width": width, "height": height, "rotation": extract_numeric_attribute(attrs, "rotation") or 0, + "alpha": icon_alpha if icon_alpha is not None else 1, "order": len(elements), } ) return elements +def is_visually_rendered(element: dict[str, Any]) -> bool: + return element.get("alpha", 1) > 0 + + def visual_bbox(element: dict[str, Any], container: dict[str, Any]) -> dict[str, int | float] | None: + if not is_visually_rendered(element): + return None if is_text_element(element): estimated = estimate_text_visual_bbox(element) return clipped_bbox(estimated, container) if estimated else None @@ -1549,6 +1574,8 @@ def own_text_visual_bbox(container: dict[str, Any]) -> dict[str, int | float] | def slide_content_visual_bbox( element: dict[str, Any], slide_bbox: dict[str, int | float] ) -> dict[str, int | float] | None: + if not is_visually_rendered(element): + return None if is_text_element(element): estimated = estimate_text_visual_bbox(element) return clipped_bbox(estimated, slide_bbox) if estimated else None @@ -1563,6 +1590,8 @@ def slide_content_visual_bbox( def is_large_visual_child(element: dict[str, Any], container: dict[str, Any]) -> bool: if element["kind"] not in {"img", "chart", "table", "whiteboard"}: return False + if not is_visually_rendered(element): + return False return element_area(element) / element_area(container) >= LARGE_VISUAL_CHILD_RATIO @@ -1903,6 +1932,9 @@ def normalize_issue( ) else: normalized.setdefault("message", normalized["code"].replace("_", " ")) + normalized.setdefault( + "hint", "Inspect the reported elements and adjust them to satisfy the rule comparison." + ) return normalized 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 552712a5ce..390e768f26 100644 --- a/skills/lark-slides/scripts/xml_text_overlap_lint_test.py +++ b/skills/lark-slides/scripts/xml_text_overlap_lint_test.py @@ -616,8 +616,11 @@ def test_lint_xml_detects_horizontal_text_overflow_across_declared_box_gap(self) ) self.assertEqual(result["summary"]["error_count"], 1) self.assertEqual(result["summary"]["warning_count"], 0) - self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap") - self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["source", "target"]) + issue = result["slides"][0]["issues"][0] + self.assertEqual(issue["code"], "bbox_overlap") + self.assertEqual(issue["elements"], ["source", "target"]) + self.assertGreater(issue["measurement"]["intersection_area"], 0) + self.assertIsNotNone(issue.get("hint")) def test_lint_xml_allows_horizontal_text_with_default_wrap(self) -> None: result = xml_text_overlap_lint.lint_xml( @@ -919,6 +922,27 @@ def test_lint_xml_uses_rotated_text_and_chart_bounds_for_canvas_validation(self) self.assertEqual(issues_by_element["rotated-chart"]["code"], "chart_out_of_canvas") self.assertAlmostEqual(issues_by_element["rotated-chart"]["overflow"]["right"], 20.710678, places=5) + def test_lint_xml_uses_rotated_bounds_for_rect_and_image_canvas_validation(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + + + + + + """ + ) + issues_by_element = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]} + self.assertEqual(result["summary"]["error_count"], 2) + self.assertEqual(issues_by_element["rotated-rect"]["code"], "shape_out_of_canvas") + self.assertAlmostEqual(issues_by_element["rotated-rect"]["overflow"]["left"], 20.710678, places=5) + self.assertAlmostEqual(issues_by_element["rotated-rect"]["overflow"]["top"], 20.710678, places=5) + self.assertEqual(issues_by_element["rotated-image"]["code"], "img_out_of_canvas") + self.assertAlmostEqual(issues_by_element["rotated-image"]["overflow"]["right"], 20.710678, places=5) + def test_lint_xml_treats_non_finite_rotations_as_zero(self) -> None: result = xml_text_overlap_lint.lint_xml( """ @@ -1284,6 +1308,21 @@ def test_lint_xml_blocks_blank_slide(self) -> None: self.assertEqual(issue["measurement"]["visible_element_count"], 0) self.assertEqual(issue["related_objects"], []) + def test_lint_xml_blocks_blank_slide_with_only_transparent_image(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + + + """ + ) + + self.assertEqual(result["summary"]["error_count"], 1) + issue = result["slides"][0]["errors"][0] + self.assertEqual(issue["code"], "blank_slide") + def test_lint_xml_warns_when_large_container_is_mostly_empty(self) -> None: result = xml_text_overlap_lint.lint_xml( """ @@ -1563,6 +1602,26 @@ def test_lint_xml_allows_container_with_large_visual_child(self) -> None: self.assertEqual(result["summary"]["warning_count"], 0) + def test_lint_xml_does_not_let_transparent_visual_child_suppress_sparse_warning(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + +

Section title

+
+ + +
+
+ """ + ) + + issue = next( + issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content" + ) + self.assertEqual(issue["target"]["container_id"], "chart-card") + def test_lint_xml_warns_for_small_empty_visual_placeholder_cards(self) -> None: result = xml_text_overlap_lint.lint_xml( """ @@ -1620,6 +1679,26 @@ def test_lint_xml_allows_image_overlay_rect(self) -> None: self.assertEqual(result["summary"]["warning_count"], 0) + def test_lint_xml_does_not_let_transparent_image_overlay_suppress_sparse_warning(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + +

Section title

+
+ + +
+
+ """ + ) + + issue = next( + issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content" + ) + self.assertEqual(issue["target"]["container_id"], "card") + def test_lint_xml_allows_edge_spanning_layout_panel_and_nested_decoration(self) -> None: result = xml_text_overlap_lint.lint_xml( """ @@ -1650,6 +1729,29 @@ def test_lint_xml_counts_icons_as_visible_content(self) -> None: self.assertEqual(result["summary"]["warning_count"], 0) + def test_lint_xml_does_not_count_transparent_icon_as_visible_content(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + +

Section title

+
+ + + + +
+
+ """ + ) + + issue = next( + issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content" + ) + self.assertEqual(issue["target"]["container_id"], "card") + self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0) + def test_lint_xml_warns_when_coverage_is_below_global_threshold(self) -> None: result = xml_text_overlap_lint.lint_xml( """ From 2297fd4a99e5ad309582be3ebd849fea93303f87 Mon Sep 17 00:00:00 2001 From: "dengzilong.zero" Date: Thu, 23 Jul 2026 18:29:49 +0800 Subject: [PATCH 08/10] fix(slides): fix polyline canvas checks, quoted attrs, and visibility gaps in layout lint Support single-quoted XML attributes and stop self-closing shape/table tags from absorbing a later element's content in extract_elements. Extract polyline geometry so it participates in out-of-canvas and blank/sparse content checks. Fix has_matching_image_overlay to divide by container area instead of min(container, image), so small images no longer suppress sparse-container warnings. Require layout containers and nested-panel exemptions to be visually rendered (alpha > 0). Measure bbox_overlap against the visual text bbox instead of the declared shape box. --- .../scripts/xml_text_overlap_lint.py | 48 +++++++++++++++---- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/skills/lark-slides/scripts/xml_text_overlap_lint.py b/skills/lark-slides/scripts/xml_text_overlap_lint.py index 526143dbbc..97ab79acc8 100644 --- a/skills/lark-slides/scripts/xml_text_overlap_lint.py +++ b/skills/lark-slides/scripts/xml_text_overlap_lint.py @@ -80,8 +80,10 @@ def parse_args(argv: list[str]) -> dict[str, Any]: def extract_attribute(tag_source: str, name: str) -> str | None: - match = re.search(fr'{re.escape(name)}="([^"]+)"', tag_source) - return match.group(1) if match else None + match = re.search(fr"{re.escape(name)}=(?:\"([^\"]+)\"|'([^']+)')", tag_source) + if not match: + return None + return match.group(1) if match.group(1) is not None else match.group(2) def extract_numeric_attribute(tag_source: str, name: str) -> int | float | None: @@ -633,8 +635,9 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]: for match in re.finditer(r"<(shape|img|table|chart|whiteboard)\b([^>]*)>", slide_xml): kind, attrs = match.group(1), match.group(2) + is_self_closing = attrs.rstrip().endswith("/") content = "" - if kind in {"shape", "table"}: + if kind in {"shape", "table"} and not is_self_closing: close_index = slide_xml.find(f"", match.end()) if close_index != -1: content = slide_xml[match.end() : close_index] @@ -1425,6 +1428,7 @@ def is_layout_container( return ( element["kind"] == "shape" and element["type"] == "rect" + and is_visually_rendered(element) and element["width"] >= MIN_CONTAINER_WIDTH and has_supported_height and element_area(element) >= MIN_CONTAINER_AREA @@ -1452,9 +1456,7 @@ def has_matching_image_overlay(container: dict[str, Any], elements: list[dict[st return any( element["kind"] == "img" and is_visually_rendered(element) - and intersection_area(container, element) - / max(1, min(container_area, element_area(element))) - >= IMAGE_OVERLAY_MATCH_RATIO + and intersection_area(container, element) / max(1, container_area) >= IMAGE_OVERLAY_MATCH_RATIO for element in elements ) @@ -1466,6 +1468,7 @@ def is_nested_in_layout_panel( element is not container and element["kind"] == "shape" and element["type"] == "rect" + and is_visually_rendered(element) and is_edge_spanning_layout_panel(element, slide_width, slide_height) and contains(element, container, tolerance=DENSITY_CONTAINMENT_TOLERANCE) for element in elements @@ -1547,6 +1550,29 @@ def extract_density_elements(slide_xml: str) -> list[dict[str, Any]]: "order": len(elements), } ) + for match in re.finditer(r"]*)>", slide_xml): + attrs = match.group(1) + x = extract_numeric_attribute(attrs, "topLeftX") + y = extract_numeric_attribute(attrs, "topLeftY") + width = extract_numeric_attribute(attrs, "width") + height = extract_numeric_attribute(attrs, "height") + if any(value is None for value in (x, y, width, height)): + continue + polyline_alpha = extract_numeric_attribute(attrs, "alpha") + elements.append( + { + "id": extract_attribute(attrs, "id") or f"polyline-{len(elements) + 1}", + "kind": "polyline", + "type": "polyline", + "x": x, + "y": y, + "width": width, + "height": height, + "rotation": extract_numeric_attribute(attrs, "rotation") or 0, + "alpha": polyline_alpha if polyline_alpha is not None else 1, + "order": len(elements), + } + ) return elements @@ -1582,7 +1608,7 @@ def slide_content_visual_bbox( if element["kind"] == "shape" and has_text_content(element): estimated = own_text_visual_bbox(element) return clipped_bbox(estimated, slide_bbox) if estimated else None - if element["kind"] in {"img", "chart", "table", "whiteboard", "icon"}: + if element["kind"] in {"img", "chart", "table", "whiteboard", "icon", "polyline"}: return clipped_bbox(element, slide_bbox) return None @@ -1816,8 +1842,10 @@ def issue_measurement( left = elements_by_id.get(issue["elements"][0]) right = elements_by_id.get(issue["elements"][1]) if left and right: - width = intersection_width(left, right) - height = intersection_height(left, right) + left_box = (estimate_text_visual_bbox(left) if is_text_element(left) else None) or left + right_box = (estimate_text_visual_bbox(right) if is_text_element(right) else None) or right + width = intersection_width(left_box, right_box) + height = intersection_height(left_box, right_box) return { "intersection_width": round(width, 3), "intersection_height": round(height, 3), @@ -2023,7 +2051,7 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]: ) density_elements = extract_density_elements(slide_xml) extra_elements = [ - *[element for element in density_elements if element["kind"] == "icon"], + *[element for element in density_elements if element["kind"] in {"icon", "polyline"}], *extract_line_elements(slide_xml), ] elements_by_id = { From d191fc95d005006b45a3e0a437d04010dfc52f24 Mon Sep 17 00:00:00 2001 From: "dengzilong.zero" Date: Thu, 23 Jul 2026 20:07:34 +0800 Subject: [PATCH 09/10] fix(slides): treat plain shapes as content, keep overlap measurement in sync with the decision - Count a line's rendered stroke instead of its declared (often zero-area) bounding box - Report bbox_overlap measurements using the same element objects should_flag_overlap decided with, instead of a separately re-parsed set whose fontSize can diverge - Exclude the element itself from has_similar_short_card_peer's peer count - Drop the dead schema_version "1.0" literals that normalize_issue always overwrites to "2.0" - Split blank_slide's visibility check into its own permissive is_slide_content_present (any rendered element counts, including textless decorative shapes), keeping slide_content_visual_bbox's stricter density bar for sparse_slide_content/sparse_container_content so a full-bleed background rect still can't trivially satisfy them - Exempt the server-echoed roundtrip tag from sxsd_unsupported_tag - Tolerate sub-pixel canvas overflow from rotated-bbox floating-point rounding --- .../scripts/xml_text_overlap_lint.py | 64 +++++++-- .../scripts/xml_text_overlap_lint_test.py | 121 ++++++++++++++++++ 2 files changed, 175 insertions(+), 10 deletions(-) diff --git a/skills/lark-slides/scripts/xml_text_overlap_lint.py b/skills/lark-slides/scripts/xml_text_overlap_lint.py index 97ab79acc8..f1e2f0cfac 100644 --- a/skills/lark-slides/scripts/xml_text_overlap_lint.py +++ b/skills/lark-slides/scripts/xml_text_overlap_lint.py @@ -43,8 +43,15 @@ ("chart", "updated"), ("chartData", "isStaticData"), } +# Slides readback echoes each chartField's CSV text as per-value children; +# it's server-emitted, absent from the write schema, and appears on virtually every chart-bearing +# deck, so treating it as an unsupported tag would block per-slide linting document-wide. +ROUNDTRIP_SXSD_TAGS = {"chartParsedValues"} DEFAULT_TABLE_COLUMN_WIDTH = 110 DEFAULT_TABLE_ROW_HEIGHT = 37 +# Sub-pixel canvas overflow is floating-point rounding noise (e.g. rotated-bbox math), not a +# visible defect; keep this well under 1px so real overflow is still always caught. +CANVAS_OVERFLOW_TOLERANCE = 0.5 _SXSD_TAG_ATTRIBUTES_CACHE: dict[str, set[str]] | None = None _ICONPARK_ICON_TYPES_CACHE: set[str] | None = None @@ -375,6 +382,8 @@ def visit(element: ET.Element, ancestors: list[str], path: str) -> None: tag_name = xml_local_name(element.tag) current_path = f"{path}/{tag_name}" if path else tag_name + if tag_name in ROUNDTRIP_SXSD_TAGS: + return if tag_name not in supported_tags: issues.append( { @@ -1217,7 +1226,9 @@ def detect_elements_out_of_canvas( "bottom": max(bbox["y"] + bbox["height"] - slide_height, 0), } overflow_details = [ - f"{side} by {amount:g}px" for side, amount in overflow.items() if amount > 0 + f"{side} by {amount:g}px" + for side, amount in overflow.items() + if amount > CANVAS_OVERFLOW_TOLERANCE ] if not overflow_details: continue @@ -1348,7 +1359,12 @@ def lint_slide( } ) - return {"slide_number": slide_number, "element_count": len(elements), "issues": issues} + return { + "slide_number": slide_number, + "element_count": len(elements), + "elements": elements, + "issues": issues, + } @@ -1401,7 +1417,8 @@ def rectangle_union_area(rectangles: list[dict[str, int | float]]) -> int | floa def has_similar_short_card_peer(element: dict[str, Any], elements: list[dict[str, Any]]) -> bool: return sum( - other["kind"] == "shape" + other is not element + and other["kind"] == "shape" and other["type"] == "rect" and other["width"] >= MIN_CONTAINER_WIDTH and other["height"] >= MIN_SHORT_CARD_HEIGHT @@ -1573,6 +1590,9 @@ def extract_density_elements(slide_xml: str) -> list[dict[str, Any]]: "order": len(elements), } ) + for line_element in extract_line_elements(slide_xml): + line_element["order"] = len(elements) + elements.append(line_element) return elements @@ -1608,11 +1628,34 @@ def slide_content_visual_bbox( if element["kind"] == "shape" and has_text_content(element): estimated = own_text_visual_bbox(element) return clipped_bbox(estimated, slide_bbox) if estimated else None + if element["kind"] == "line": + # 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"}: return clipped_bbox(element, slide_bbox) return None +def line_stroke_bbox(element: dict[str, Any]) -> dict[str, Any]: + return {**element, "width": max(element["width"], 1), "height": max(element["height"], 1)} + + +def is_slide_content_present( + element: dict[str, Any], slide_bbox: dict[str, int | float] +) -> bool: + # Deliberately permissive, unlike slide_content_visual_bbox: blank_slide is asking "is + # *anything* rendered here", not the richer "counts toward meaningful content density" bar + # that sparse_slide_content/sparse_container_content apply. A plain shape with no text (a + # decorative rect/ellipse/etc.), , or any future SXSD data element should all + # count here — deny-list only what's actually invisible (alpha<=0 or zero on-canvas area) + # instead of maintaining an allow-list that silently treats unlisted kinds as blank. + if not is_visually_rendered(element): + return False + bbox = line_stroke_bbox(element) if element["kind"] == "line" else element + return clipped_bbox(bbox, slide_bbox) is not None + + def is_large_visual_child(element: dict[str, Any], container: dict[str, Any]) -> bool: if element["kind"] not in {"img", "chart", "table", "whiteboard"}: return False @@ -1654,7 +1697,6 @@ def detect_sparse_container_content( { "level": "warning", "code": "sparse_container_content", - "schema_version": "1.0", "target": { "slide_number": slide_number, "container_id": container["id"], @@ -1698,7 +1740,6 @@ def detect_sparse_slide_content( { "level": "warning", "code": "sparse_slide_content", - "schema_version": "1.0", "target": { "slide_number": slide_number, "bbox": slide_bbox, @@ -1727,9 +1768,7 @@ def detect_blank_slide( ) -> list[dict[str, Any]]: slide_bbox = {"x": 0, "y": 0, "width": slide_width, "height": slide_height} visible_elements = [ - element - for element in elements - if slide_content_visual_bbox(element, slide_bbox) is not None + element for element in elements if is_slide_content_present(element, slide_bbox) ] if visible_elements: return [] @@ -1897,6 +1936,7 @@ def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]: end_y = extract_numeric_attribute(attrs, "endY") if any(value is None for value in (start_x, start_y, end_x, end_y)): continue + line_alpha = extract_numeric_attribute(attrs, "alpha") elements.append( { "id": extract_attribute(attrs, "id") or f"line-{len(elements) + 1}", @@ -1907,6 +1947,7 @@ def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]: "width": abs(end_x - start_x), "height": abs(end_y - start_y), "rotation": 0, + "alpha": line_alpha if line_alpha is not None else 1, "order": len(elements), } ) @@ -2051,12 +2092,15 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]: ) density_elements = extract_density_elements(slide_xml) extra_elements = [ - *[element for element in density_elements if element["kind"] in {"icon", "polyline"}], - *extract_line_elements(slide_xml), + element for element in density_elements if element["kind"] in {"icon", "polyline", "line"} ] elements_by_id = { element["id"]: element for element in [*density_elements, *extra_elements] } + # geometry["elements"] are the exact objects should_flag_overlap/detect_elements_out_of_canvas + # decided with inside lint_slide; prefer them so measurement/related_objects stay consistent + # with whatever actually triggered the issue, instead of density_elements' separate re-parse. + elements_by_id.update({element["id"]: element for element in geometry["elements"]}) extra_overflow_issues = detect_elements_out_of_canvas( extra_elements, presentation["width"], 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 390e768f26..34e5f02bf5 100644 --- a/skills/lark-slides/scripts/xml_text_overlap_lint_test.py +++ b/skills/lark-slides/scripts/xml_text_overlap_lint_test.py @@ -1803,6 +1803,127 @@ def test_lint_xml_allows_large_metric_card_above_lower_threshold(self) -> None: self.assertEqual(result["slides"][0]["issues"], []) + def test_lint_xml_does_not_report_blank_slide_for_line_only_content(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + + + + + + """ + ) + + 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_reports_bbox_overlap_measurement_from_decision_time_visual_bbox(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + +

overlap text big

+
+ +

other overlap text

+
+
+
+ """ + ) + + issue = result["slides"][0]["issues"][0] + self.assertEqual(issue["code"], "bbox_overlap") + # Must match the visual bbox that should_flag_overlap actually decided with (fontSize=14 + # from extract_elements), not the fontSize=96 max-descendant value that + # extract_density_elements computes for the same "left" element id. + self.assertEqual(issue["measurement"]["intersection_width"], 117.04) + self.assertEqual(issue["measurement"]["intersection_height"], 6.8) + self.assertEqual(issue["measurement"]["intersection_area"], 795.872) + + def test_has_similar_short_card_peer_excludes_the_element_itself(self) -> None: + card_a = {"kind": "shape", "type": "rect", "x": 0, "y": 0, "width": 300, "height": 100} + card_b = {"kind": "shape", "type": "rect", "x": 400, "y": 0, "width": 300, "height": 100} + card_c = {"kind": "shape", "type": "rect", "x": 0, "y": 200, "width": 300, "height": 100} + + self.assertFalse( + xml_text_overlap_lint.has_similar_short_card_peer(card_a, [card_a, card_b]) + ) + self.assertTrue( + xml_text_overlap_lint.has_similar_short_card_peer(card_a, [card_a, card_b, card_c]) + ) + + def test_lint_xml_reports_schema_version_2_for_sparse_issues(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + + + """ + ) + + issue = next( + issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content" + ) + self.assertEqual(issue["schema_version"], "2.0") + + def test_lint_xml_does_not_report_blank_slide_for_textless_decorative_shapes(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + + + + + + + + """ + ) + + 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_still_warns_for_sparse_slide_content_despite_full_bleed_background(self) -> None: + # A plain textless shape now counts as "not blank" (see the test above), but a + # full-bleed background rect must still NOT count toward sparse_slide_content's + # meaningful-content coverage ratio -- otherwise every slide with a background would + # trivially "pass" that density check. + result = xml_text_overlap_lint.lint_xml( + """ + + + + +

One short line

+
+ +

Another line

+
+ +

Third line

+
+ +

Fourth line

+
+
+
+ """ + ) + + codes = [issue["code"] for issue in result["slides"][0]["issues"]] + self.assertIn("sparse_slide_content", codes) + if __name__ == "__main__": unittest.main() From d87ea4a6bab8e412eb7d7a73697abab0237f35dd Mon Sep 17 00:00:00 2001 From: "dengzilong.zero" Date: Thu, 23 Jul 2026 21:10:29 +0800 Subject: [PATCH 10/10] fix(slides): accept spaced attributes, reject background-only and ghost-peer false negatives - Accept optional whitespace around attribute "=" in extract_attribute (e.g. `topLeftX = "80"`), which previously dropped the element's geometry entirely and could misfire blank_slide - Treat a full-canvas plain rect with no text as a background panel, not content, so a slide with only a background fill still reports blank_slide instead of bypassing it - Ignore invisible (alpha=0) rects when counting has_similar_short_card_peer, so ghost siblings can no longer promote a real card into an unwanted sparse_container_content warning --- .../scripts/xml_text_overlap_lint.py | 18 ++++++- .../scripts/xml_text_overlap_lint_test.py | 48 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/skills/lark-slides/scripts/xml_text_overlap_lint.py b/skills/lark-slides/scripts/xml_text_overlap_lint.py index f1e2f0cfac..20ba9649aa 100644 --- a/skills/lark-slides/scripts/xml_text_overlap_lint.py +++ b/skills/lark-slides/scripts/xml_text_overlap_lint.py @@ -87,7 +87,9 @@ def parse_args(argv: list[str]) -> dict[str, Any]: def extract_attribute(tag_source: str, name: str) -> str | None: - match = re.search(fr"{re.escape(name)}=(?:\"([^\"]+)\"|'([^']+)')", tag_source) + match = re.search( + fr"(?:^|\s){re.escape(name)}\s*=\s*(?:\"([^\"]+)\"|'([^']+)')", tag_source + ) if not match: return None return match.group(1) if match.group(1) is not None else match.group(2) @@ -1418,6 +1420,7 @@ def rectangle_union_area(rectangles: list[dict[str, int | float]]) -> int | floa def has_similar_short_card_peer(element: dict[str, Any], elements: list[dict[str, Any]]) -> bool: return sum( other is not element + and is_visually_rendered(other) and other["kind"] == "shape" and other["type"] == "rect" and other["width"] >= MIN_CONTAINER_WIDTH @@ -1652,6 +1655,19 @@ def is_slide_content_present( # instead of maintaining an allow-list that silently treats unlisted kinds as blank. if not is_visually_rendered(element): return False + if ( + element["kind"] == "shape" + and element["type"] == "rect" + and not has_text_content(element) + and element["x"] <= 2 + and element["y"] <= 2 + and element["width"] >= slide_bbox["width"] - 4 + and element["height"] >= slide_bbox["height"] - 4 + ): + # A full-canvas plain rect is a background panel, not content -- same reasoning as + # is_layout_container's existing background exclusion. A slide with nothing else on it + # is still effectively blank. + return False bbox = line_stroke_bbox(element) if element["kind"] == "line" else element return clipped_bbox(bbox, slide_bbox) is not None 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 34e5f02bf5..0e7dea0605 100644 --- a/skills/lark-slides/scripts/xml_text_overlap_lint_test.py +++ b/skills/lark-slides/scripts/xml_text_overlap_lint_test.py @@ -1924,6 +1924,54 @@ def test_lint_xml_still_warns_for_sparse_slide_content_despite_full_bleed_backgr codes = [issue["code"] for issue in result["slides"][0]["issues"]] self.assertIn("sparse_slide_content", codes) + def test_lint_xml_accepts_whitespace_around_attribute_equals_sign(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + +

hello

+
+
+
+ """ + ) + + self.assertEqual(result["slides"][0]["element_count"], 1) + codes = [issue["code"] for issue in result["slides"][0]["issues"]] + self.assertNotIn("blank_slide", codes) + + def test_lint_xml_reports_blank_slide_for_full_canvas_background_only(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + + + + + """ + ) + + codes = [issue["code"] for issue in result["slides"][0]["issues"]] + self.assertIn("blank_slide", codes) + + def test_has_similar_short_card_peer_ignores_invisible_peers(self) -> None: + visible_card = {"kind": "shape", "type": "rect", "x": 0, "y": 0, "width": 300, "height": 100} + ghost_1 = { + "kind": "shape", "type": "rect", "x": 400, "y": 0, "width": 300, "height": 100, "alpha": 0, + } + ghost_2 = { + "kind": "shape", "type": "rect", "x": 800, "y": 0, "width": 300, "height": 100, "alpha": 0, + } + + self.assertFalse( + xml_text_overlap_lint.has_similar_short_card_peer( + visible_card, [visible_card, ghost_1, ghost_2] + ) + ) + if __name__ == "__main__": unittest.main()