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 ffd337369c..30f1ec9a9f 100644 --- a/skills/lark-slides/references/validation-checklist.md +++ b/skills/lark-slides/references/validation-checklist.md @@ -25,19 +25,32 @@ lark-cli slides +xml-get --as user \ --json ``` -## Automated XML Text Overlap Lint +## Automated XML Layout Lint -`slides +xml-get` 保存 XML 到本地文件后,优先运行 XML 语法和文本重叠静态检查: +`slides +xml-get` 保存 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 ``` -通过标准: +它一次检查 XML/SXSD 合法性、元素越界、文本重叠、空白页、文本高度风险、整页内容稀疏和大卡片内容覆盖率。大卡片自身 `` 的估算文本面积与卡片内平级元素一起参与覆盖率并集计算。 -- `summary.error_count == 0`。任何 error 都必须先修复再交付。 -- 当前工具只检查 XML well-formed 和文本元素之间的明显重叠;它不检查越界、文本高度不足、图文压盖、表格/图表压盖或底部拥挤。 -- 该工具不能替代页数核对、关键内容核对或真实视觉验收。 +准出规则: + +- `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,供截图复核链路继续执行。 + +每条 `error` / `warning` 都包含: + +- `element_ids`:相关 XML 元素 ID; +- `rule`:规则 ID、名称、阈值和比较关系; +- `measurement`:越界量、交叠面积、覆盖率等实测值; +- `related_objects`:相关对象的类型与坐标框; +- `target`、`message`、`hint`:页码、语义说明和处理建议。 + +当 `sparse_container_content.measurement.content_coverage_ratio < rule.threshold` 时,需要结合同页截图判断留白是否有意设计;不要仅凭 warning 自动扩充内容。 常见 code 的处理方向: @@ -51,6 +64,10 @@ python3 skills/lark-slides/scripts/xml_text_overlap_lint.py --input ` 设置 ``,避免图标不可见 | 给 `` 添加显式非透明填充色,例如 `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_text_overlap_lint.py b/skills/lark-slides/scripts/xml_text_overlap_lint.py index eb80fcc988..20ba9649aa 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 @@ -42,18 +43,25 @@ ("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 -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: @@ -79,8 +87,12 @@ 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"(?:^|\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) def extract_numeric_attribute(tag_source: str, name: str) -> int | float | None: @@ -372,6 +384,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( { @@ -632,8 +646,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] @@ -1039,6 +1054,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 @@ -1166,9 +1194,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 @@ -1194,12 +1219,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), @@ -1208,7 +1228,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 @@ -1330,62 +1352,714 @@ 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 {} + ), } ) - return {"slide_number": slide_number, "element_count": len(elements), "issues": issues} + return { + "slide_number": slide_number, + "element_count": len(elements), + "elements": 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 is not element + and is_visually_rendered(other) + and 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 is_visually_rendered(element) + 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 is_visually_rendered(element) + and intersection_area(container, element) / max(1, container_area) >= 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_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 + ) + + +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 = [] + 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): + 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 + icon_alpha = extract_numeric_attribute(attrs, "alpha") + 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, + "alpha": icon_alpha if icon_alpha is not None else 1, + "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), + } + ) + for line_element in extract_line_elements(slide_xml): + line_element["order"] = len(elements) + elements.append(line_element) + 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 + 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 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 + 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 + 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 + + +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 + + +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", + "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", + "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 is_slide_content_present(element, slide_bbox) + ] + 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: + 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), + "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 + line_alpha = extract_numeric_attribute(attrs, "alpha") + 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, + "alpha": line_alpha if line_alpha is not None else 1, + "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("_", " ")) + normalized.setdefault( + "hint", "Inspect the reported elements and adjust them to satisfy the rule comparison." + ) + 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 +2068,107 @@ 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"] 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"], + 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 +2191,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..0e7dea0605 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( @@ -221,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( """ @@ -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( @@ -618,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( @@ -752,7 +753,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 +771,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 +807,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 +819,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 +835,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 +853,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 +871,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( """ @@ -893,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( """ @@ -1057,9 +1107,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 +1133,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 +1201,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 +1221,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 +1256,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,8 +1268,709 @@ 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_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( + """ + + + + +

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

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_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( + """ + + + + + + + """ + ) + + 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_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( + """ + + + + + + + + + """ + ) + + 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"], []) + + 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) + + 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__":