Compare commits

...

11 Commits

Author SHA1 Message Date
zhanghuanxu
20c396b404 fix(slides): restrict canvas overflow checks 2026-07-25 01:22:06 +08:00
zhanghuanxu
a01279011e fix(slides): reduce text overflow and container overlap false positives
Two calibration fixes to the slides XML lint:

- text_may_overflow_shape: absorb CJK glyph-width estimation noise at the
  wrap boundary (WRAP_LINE_COUNT_TOLERANCE) so a single line only marginally
  wider than its box no longer rounds up to a spurious wrapped line and a
  gate-blocking error.
- text_container_overlap_risk: require the vertical overlap to reach a
  meaningful fraction of the shorter container (MIN_CONTAINER_OVERLAP_RATIO)
  before warning, so title/subtitle stacks that touch by a sliver no longer
  fire.

Adds regression tests pinning both behaviors.
2026-07-24 23:17:47 +08:00
zhanghuanxu
c205954000 feat(slides): detect opaque shapes covering text
Add detect_shape_text_occlusions to flag when a later-drawn, opaque
non-text shape covers an earlier text shape visual bbox, mirroring the
existing image-occlusion detector. A shape counts as an occluder when
it has a <fill> and effective opacity (alpha times fillColor alpha) is
at least 0.5; an empty <fillColor/> renders as a default opaque fill so
it also qualifies. Horizontal text hits report shape_covers_text as
error, vertical text reports shape_may_cover_vertical_text as info.
2026-07-24 19:19:13 +08:00
zhanghuanxu
48b7ec70b2 fix: reduce chart overlay false positives 2026-07-24 18:51:22 +08:00
zhanghuanxu
adf1ac4326 fix(slides): warn on overlapping text containers 2026-07-24 18:50:08 +08:00
zhanghuanxu
dd84702b40 fix(slides):chart external overlay 2026-07-24 18:47:51 +08:00
zhanghuanxu
e4db9630f3 fix(slides): allow chartParsedValues roundtrip tag
chartParsedValues is a server-injected roundtrip child tag under
chartField, not an attribute. Move it from ROUNDTRIP_SXSD_ATTRS to a
new ROUNDTRIP_SXSD_TAGS set and skip the tag (and its subtree) in the
SXSD tag whitelist check.
2026-07-24 18:47:48 +08:00
zhanghuanxu
ac4637f8ce fix(slides): downgrade background-decoration text overflow to info
Large low-alpha text underneath other text shapes is typically a
background design element; treat text_may_overflow_shape as info in
that case instead of warning/error.
2026-07-24 18:46:56 +08:00
zhanghuanxu
266ec5455b fix(slides): detect letterSpacing-driven text overflow
Extract letterSpacing from content/paragraph attrs and factor it into
width and line-count estimates, and stop short-circuiting the shape
overflow check for autoFit shapes so that letterSpacing-heavy captions
under normal-auto-fit no longer escape detection.
2026-07-24 18:46:56 +08:00
zhanghuanxu
3074cdb2b8 fix(slides): upgrade text overflow to error above 10px threshold
Text-shape overflow was always reported as a warning, which let clearly
broken pages pass the lint gate. Overflow > 10px now upgrades to error;
smaller overflows stay as warning to avoid flagging near-fit cases.
2026-07-24 18:46:56 +08:00
zhanghuanxu
8f916dd561 fix(slides): declare screenshot scope 2026-07-24 12:04:51 +08:00
6 changed files with 1161 additions and 102 deletions

View File

@@ -37,9 +37,7 @@ var SlidesScreenshot = common.Shortcut{
Command: "+screenshot",
Description: "Save up to 10 slide screenshots to local files without printing Base64 image data",
Risk: "read",
Scopes: []string{},
// The screenshot API is allowlist-gated for only a few apps, so do not
// advertise/preflight its scope. Let the API fail and let callers degrade.
Scopes: []string{"slides:presentation:screenshot"},
// wiki:node:read is required only when --presentation is a wiki URL.
ConditionalScopes: []string{"wiki:node:read"},
AuthTypes: []string{"user", "bot"},

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
@@ -17,23 +18,19 @@ import (
)
func TestSlidesScreenshotDeclaredScopes(t *testing.T) {
if got := SlidesScreenshot.ScopesForIdentity("user"); len(got) != 0 {
t.Fatalf("user preflight scopes = %#v, want empty", got)
base := []string{"slides:presentation:screenshot"}
if got := SlidesScreenshot.ScopesForIdentity("user"); !reflect.DeepEqual(got, base) {
t.Fatalf("user preflight scopes = %#v, want %#v", got, base)
}
if got := SlidesScreenshot.ScopesForIdentity("bot"); len(got) != 0 {
t.Fatalf("bot preflight scopes = %#v, want empty", got)
if got := SlidesScreenshot.ScopesForIdentity("bot"); !reflect.DeepEqual(got, base) {
t.Fatalf("bot preflight scopes = %#v, want %#v", got, base)
}
got := SlidesScreenshot.DeclaredScopesForIdentity("user")
want := []string{"wiki:node:read"}
if len(got) != len(want) || got[0] != want[0] {
want := []string{"slides:presentation:screenshot", "wiki:node:read"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("declared scopes = %#v, want %#v", got, want)
}
for _, scope := range got {
if scope == "slides:presentation:screenshot" {
t.Fatalf("declared scopes must not advertise screenshot scope: %#v", got)
}
}
}
func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) {

View File

@@ -4,7 +4,7 @@
获取幻灯片页面截图并保存为本地图片文件。默认用于已存在 PPT 页面截图;传入 `--content` 时用于直接渲染单个 `<slide>` XML 片段预览。本 shortcut 会在 CLI 进程内解码并写入文件stdout 只返回文件路径、大小、页面 ID 等元信息,避免把图片 Base64 输出给模型。
注意:该截图能力受应用白名单限制,绝大多数应用不可用。截图失败时不要引导用户申请 `slides:presentation:screenshot` 权限;记录错误后降级到 XML 读回、结构 lint、文本重叠检查等非截图检查路径。
截图失败则降级到 XML 读回、结构 lint等非截图检查路径。
## 命令

View File

@@ -68,6 +68,7 @@ python3 "<lark-slides-skill-dir>/scripts/xml_text_overlap_lint.py" --input <pres
| `blank_slide` | 页面没有画布内可见内容 | 补充主体内容;仅有空背景或空形状不能准出 |
| `sparse_container_content` | 大卡片内容覆盖率低于阈值 | 按元素 ID 定位卡片,结合截图判断是否补充或放大内容 |
| `sparse_slide_content` | 全页有效内容覆盖率偏低 | 复核截图,确认是否为有意留白 |
| `text_container_overlap_risk` | 垂直堆叠的文本容器边界相交,实际文字可能进入相邻文本容器 | 优先拉开文本容器边界;若要保留重叠,必须用服务端截图确认真实渲染结果 |
## Screenshot QA

View File

@@ -52,6 +52,16 @@ 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
# The CJK glyph-width estimator approximates each wide character as exactly 1.0*fontSize, so a
# single line that is only marginally wider than its box (e.g. "4.16万亿" at 151.2px in a 150px
# box) rounds up to a spurious extra wrapped line. Absorb that boundary noise: a line only counts
# as wrapping when it exceeds the box by more than this fraction of one box width.
WRAP_LINE_COUNT_TOLERANCE = 0.05
# Two text containers whose bboxes touch by only a sliver (common for title/subtitle stacks with
# generous vertical padding) are not a real bleed risk. Require the vertical overlap to be a
# meaningful fraction of the shorter container before warning, matching the 0.40 vertical-overlap
# factor used by should_flag_horizontal_text_overflow.
MIN_CONTAINER_OVERLAP_RATIO = 0.40
_SXSD_TAG_ATTRIBUTES_CACHE: dict[str, set[str]] | None = None
_ICONPARK_ICON_TYPES_CACHE: set[str] | None = None
@@ -106,6 +116,34 @@ def extract_numeric_attribute(tag_source: str, name: str) -> int | float | None:
return int(value) if value.is_integer() else value
def is_transparent_color(color: str) -> bool:
normalized = re.sub(r"\s+", "", color).lower()
if normalized == "transparent":
return True
rgba_match = re.fullmatch(r"rgba\([^,]+,[^,]+,[^,]+,([+-]?(?:\d+(?:\.\d*)?|\.\d+))\)", normalized)
if not rgba_match:
return False
try:
return float(rgba_match.group(1)) <= 0
except ValueError:
return False
def color_alpha(color: str | None) -> int | float:
if not color:
return 1
normalized = re.sub(r"\s+", "", color).lower()
if normalized == "transparent":
return 0
rgba_match = re.fullmatch(r"rgba\([^,]+,[^,]+,[^,]+,([+-]?(?:\d+(?:\.\d*)?|\.\d+))\)", normalized)
if not rgba_match:
return 1
try:
return float(rgba_match.group(1))
except ValueError:
return 1
def sum_sizes(sizes: list[int | float]) -> int | float:
return sum(sizes)
@@ -218,6 +256,7 @@ def extract_text_paragraphs(value: str, default_font_size: int | float) -> list[
"lineSpacing": extract_attribute(attrs, "lineSpacing"),
"beforeLineSpacing": extract_attribute(attrs, "beforeLineSpacing"),
"afterLineSpacing": extract_attribute(attrs, "afterLineSpacing"),
"letterSpacing": extract_numeric_attribute(attrs, "letterSpacing"),
}
)
return paragraphs
@@ -445,18 +484,6 @@ def validate_iconpark_icon_types(root: ET.Element) -> list[dict[str, Any]]:
def direct_child(element: ET.Element, local_name: str) -> ET.Element | None:
return next((child for child in element if xml_local_name(child.tag) == local_name), None)
def is_transparent_color(color: str) -> bool:
normalized = re.sub(r"\s+", "", color).lower()
if normalized == "transparent":
return True
rgba_match = re.fullmatch(r"rgba\([^,]+,[^,]+,[^,]+,([0-9.]+)\)", normalized)
if not rgba_match:
return False
try:
return float(rgba_match.group(1)) <= 0
except ValueError:
return False
def append_missing_fill_color_issue(current_path: str) -> None:
issues.append(
{
@@ -648,10 +675,11 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
kind, attrs = match.group(1), match.group(2)
is_self_closing = attrs.rstrip().endswith("/")
content = ""
if kind in {"shape", "table"} and not is_self_closing:
if kind in {"shape", "table", "chart"} and not is_self_closing:
close_index = slide_xml.find(f"</{kind}>", match.end())
if close_index != -1:
content = slide_xml[match.end() : close_index]
own_content = re.split(r"<(?:shape|img|table|chart|whiteboard)\b", content, maxsplit=1)[0]
element_id = extract_attribute(attrs, "id") or f"{kind}-{len(elements) + 1}"
x = extract_numeric_attribute(attrs, "topLeftX")
@@ -690,10 +718,17 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
}
)
if kind == "shape":
content_attrs = extract_tag_attributes(content, "content")
content_attrs = extract_tag_attributes(own_content, "content")
font_size = extract_numeric_attribute(content_attrs, "fontSize")
if font_size is None:
font_size = extract_numeric_attribute(attrs, "fontSize")
fill_attrs = extract_tag_attributes(own_content, "fillColor")
fill_color = extract_attribute(fill_attrs, "color")
fill_style_attrs = extract_tag_attributes(own_content, "fill")
border_attrs = extract_tag_attributes(own_content, "border")
border_color = extract_attribute(border_attrs, "color")
border_width = extract_numeric_attribute(border_attrs, "width")
own_tags = set(re.findall(r"<([A-Za-z_][\w.-]*)\b", own_content))
element.update(
{
"textType": extract_attribute(content_attrs, "textType"),
@@ -705,13 +740,33 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
"lineSpacing": extract_attribute(content_attrs, "lineSpacing"),
"beforeLineSpacing": extract_attribute(content_attrs, "beforeLineSpacing"),
"afterLineSpacing": extract_attribute(content_attrs, "afterLineSpacing"),
"letterSpacing": extract_numeric_attribute(content_attrs, "letterSpacing"),
"paddingTop": extract_numeric_attribute(content_attrs, "paddingTop") or 0,
"paddingRight": extract_numeric_attribute(content_attrs, "paddingRight") or 0,
"paddingBottom": extract_numeric_attribute(content_attrs, "paddingBottom") or 0,
"paddingLeft": extract_numeric_attribute(content_attrs, "paddingLeft") or 0,
"fontSize": font_size if font_size is not None else 16,
"text": strip_xml_paragraphs(content),
"paragraphs": extract_text_paragraphs(content, font_size if font_size is not None else 16),
"text": strip_xml_paragraphs(own_content),
"paragraphs": extract_text_paragraphs(
own_content, font_size if font_size is not None else 16
),
"hasFill": bool(re.search(r"<fill\b", own_content)),
"fillColor": fill_color,
"hasUnknownFillStyle": bool(fill_style_attrs.strip()),
"borderColor": border_color,
"borderWidth": border_width,
"hasOtherDrawingContent": bool(
own_tags - {"fill", "fillColor", "border", "content", "p", "span", "br"}
),
}
)
if kind == "chart":
chart_plot_attrs = extract_tag_attributes(content, "chartPlot")
chart_sectors_attrs = extract_tag_attributes(content, "chartSectors")
element.update(
{
"chartPlotType": extract_attribute(chart_plot_attrs, "type"),
"innerRadius": extract_numeric_attribute(chart_sectors_attrs, "innerRadius"),
}
)
elements.append(element)
@@ -773,6 +828,47 @@ def detect_image_text_occlusions(elements: list[dict[str, Any]]) -> list[dict[st
return issues
def is_opaque_shape_occluder(element: dict[str, Any]) -> bool:
if element["kind"] != "shape" or element["type"] == "text":
return False
if not element.get("hasFill"):
return False
# A declared <fill> with no explicit color still paints a default opaque fill,
# so an absent fillColor (color_alpha -> 1) counts as opaque.
effective_alpha = element["alpha"] * color_alpha(element.get("fillColor"))
return effective_alpha >= 0.5
def detect_shape_text_occlusions(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
text_elements = [element for element in elements if is_text_element(element) and has_text_content(element)]
shape_elements = [element for element in elements if is_opaque_shape_occluder(element)]
for text_element in text_elements:
for shape_element in shape_elements:
if shape_element["order"] <= text_element["order"]:
continue
if is_vertical_text(text_element):
if intersects(shape_element, text_element):
issues.append({
"level": "info",
"code": "shape_may_cover_vertical_text",
"elements": [shape_element["id"], text_element["id"]],
"message": f'shape {shape_element["id"]} may cover vertical text shape {text_element["id"]}',
"hint": "Inspect the rendered slide because vertical text layout is not statically modeled.",
})
continue
text_visual_bbox = estimate_text_visual_bbox(text_element)
if text_visual_bbox is not None and intersects(shape_element, text_visual_bbox):
issues.append({
"level": "error",
"code": "shape_covers_text",
"elements": [shape_element["id"], text_element["id"]],
"message": f'shape {shape_element["id"]} covers text shape {text_element["id"]}',
"hint": "Move the shape before the text shape in XML order, or adjust the shape and text shape coordinates or dimensions.",
})
return issues
def is_decorative_text(element: dict[str, Any]) -> bool:
text = element.get("text") or ""
return bool(text) and re.search(r"[A-Za-z0-9\u4e00-\u9fff]", text) is None
@@ -790,14 +886,27 @@ def estimate_character_width(character: str, font_size: int | float) -> int | fl
return font_size * 0.55
def estimate_text_width(text: str, font_size: int | float) -> int | float:
return sum(estimate_character_width(character, font_size) for character in text)
def estimate_text_width(text: str, font_size: int | float, letter_spacing: int | float = 0) -> int | float:
base = sum(estimate_character_width(character, font_size) for character in text)
return base + max(len(text) - 1, 0) * letter_spacing
def resolve_letter_spacing(element: dict[str, Any], paragraph: dict[str, Any] | None = None) -> int | float:
if paragraph is not None:
value = paragraph.get("letterSpacing")
if isinstance(value, (int, float)):
return value
value = element.get("letterSpacing")
return value if isinstance(value, (int, float)) else 0
def estimate_text_max_line_width(element: dict[str, Any]) -> int | float:
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
letter_spacing = resolve_letter_spacing(element)
paragraphs = [paragraph for paragraph in re.split(r"\n+", element["text"]) if paragraph]
return max([estimate_text_width(paragraph, font_size) for paragraph in paragraphs] or [1])
return max(
[estimate_text_width(paragraph, font_size, letter_spacing) for paragraph in paragraphs] or [1]
)
def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool:
@@ -810,8 +919,11 @@ def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool
return SequenceMatcher(None, left_text, right_text).ratio() >= 0.75
def estimate_text_line_count_for_text(element: dict[str, Any], text: str) -> int:
def estimate_text_line_count_for_text(
element: dict[str, Any], text: str, paragraph: dict[str, Any] | None = None
) -> int:
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
letter_spacing = resolve_letter_spacing(element, paragraph)
hard_lines = text.split("\n")
if not text:
return 0
@@ -820,8 +932,9 @@ def estimate_text_line_count_for_text(element: dict[str, Any], text: str) -> int
if element.get("wrap") in {"false", "0"}:
line_count += 1
continue
logical_width = max(estimate_text_width(hard_line, font_size), 1)
line_count += max(1, math.ceil(logical_width / max(element["width"], 1)))
logical_width = max(estimate_text_width(hard_line, font_size, letter_spacing), 1)
box_width = max(element["width"], 1)
line_count += max(1, math.ceil(logical_width / box_width - WRAP_LINE_COUNT_TOLERANCE))
return line_count
@@ -844,8 +957,6 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
for element in elements:
if not is_text_element(element) or not has_text_content(element):
continue
if element.get("autoFit") in {"normal-auto-fit", "shape-auto-fit"}:
continue
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
paragraphs = element.get("paragraphs") or [
@@ -860,7 +971,7 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
estimated_height = 0.0
line_heights: list[int | float] = []
for paragraph in paragraphs:
paragraph_line_count = estimate_text_line_count_for_text(element, paragraph["text"])
paragraph_line_count = estimate_text_line_count_for_text(element, paragraph["text"], paragraph)
if paragraph_line_count == 0:
continue
line_height = estimate_text_line_height(element, paragraph["lineSpacing"] or element["lineSpacing"])
@@ -886,9 +997,21 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
if overflow <= 0:
continue
is_background = is_background_decorative_text(element, elements)
if is_background:
level = "info"
else:
level = "error" if overflow > 10 else "warning"
message = (
f'text shape {element["id"]} may overflow its own content box '
f'(estimated {estimated_height:g}px, available {available_height:g}px); '
'consider setting content wrap="true" autoFit="normal-auto-fit"'
)
if is_background:
message += " (likely background decoration: large font, low alpha, underneath other text)"
issues.append(
{
"level": "warning",
"level": level,
"code": "text_may_overflow_shape",
"elements": [element["id"]],
"line_count": line_count,
@@ -896,11 +1019,7 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
"estimated_height": estimated_height,
"available_height": available_height,
"overflow": overflow,
"message": (
f'text shape {element["id"]} may overflow its own content box '
f'(estimated {estimated_height:g}px, available {available_height:g}px); '
'consider setting content wrap="true" autoFit="normal-auto-fit"'
),
"message": message,
"hint": (
"Increase shape.height, reduce the text, or set content wrap=\"true\" "
"autoFit=\"normal-auto-fit\". "
@@ -911,6 +1030,27 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
return issues
def is_background_decorative_text(
element: dict[str, Any], elements: list[dict[str, Any]]
) -> bool:
font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
if font_size <= 96:
return False
alpha = element.get("alpha", 1)
if not isinstance(alpha, (int, float)) or alpha >= 0.5:
return False
for other in elements:
if other is element:
continue
if not is_text_element(other) or not has_text_content(other):
continue
if other["order"] <= element["order"]:
continue
if intersects(element, other):
return True
return False
def estimate_text_visual_bbox(element: dict[str, Any]) -> dict[str, int | float] | None:
if not is_text_element(element) or not has_text_content(element) or is_decorative_text(element):
return None
@@ -1092,6 +1232,44 @@ def should_flag_overlap(left: dict[str, Any], right: dict[str, Any]) -> bool:
return False
def horizontal_contains(
outer: dict[str, Any], inner: dict[str, Any], tolerance: int | float = 2
) -> bool:
return (
inner["x"] >= outer["x"] - tolerance
and inner["x"] + inner["width"] <= outer["x"] + outer["width"] + tolerance
)
def text_container_overlap_risk(
left: dict[str, Any], right: dict[str, Any]
) -> dict[str, int | float] | None:
if not (is_text_element(left) and is_text_element(right)):
return None
if not (has_text_content(left) and has_text_content(right)):
return None
if is_template_text_stack(left, right) or is_similar_text_overlay(left, right):
return None
if not intersects(left, right):
return None
top, bottom = sorted([left, right], key=lambda element: element["y"])
if top["y"] == bottom["y"]:
return None
if not (horizontal_contains(top, bottom) or horizontal_contains(bottom, top)):
return None
overlap_height = intersection_height(top, bottom)
shorter_height = min(top["height"], bottom["height"])
if shorter_height <= 0 or overlap_height < shorter_height * MIN_CONTAINER_OVERLAP_RATIO:
return None
return {
"overlap_width": intersection_width(top, bottom),
"overlap_height": overlap_height,
}
def build_whiteboard_external_overlap_issue(
whiteboard: dict[str, Any], overlap_details: list[dict[str, Any]]
) -> dict[str, Any]:
@@ -1192,8 +1370,155 @@ def detect_whiteboard_external_overlaps(
return issues
def chart_external_overlay_detail(
chart: dict[str, Any], overlay: dict[str, Any]
) -> dict[str, Any] | None:
if overlay["kind"] == "chart" or overlay["order"] <= chart["order"] or not is_effectively_visible(overlay):
return None
if not intersects(chart, overlay):
return None
if is_inside_donut_hole(chart, overlay):
return None
overlap_width = intersection_width(chart, overlay)
overlap_height = intersection_height(chart, overlay)
if overlap_width < 8 or overlap_height < 8:
return None
overlap_area = overlap_width * overlap_height
overlay_area = element_area(overlay)
chart_area = element_area(chart)
if overlay_area <= 0 or chart_area <= 0 or overlap_area / overlay_area < 0.15:
return None
contained_in_chart = contains(chart, overlay)
if not contained_in_chart and overlap_area / chart_area < 0.15:
return None
return {
"element": overlay["id"],
"kind": overlay["kind"],
"type": overlay.get("type"),
"bbox": {key: overlay[key] for key in ("x", "y", "width", "height")},
"contained_in_chart": contained_in_chart,
"overlap_width": overlap_width,
"overlap_height": overlap_height,
"overlay_overlap_ratio": round(overlap_area / overlay_area, 3),
"chart_overlap_ratio": round(overlap_area / chart_area, 3),
}
def is_effectively_visible(element: dict[str, Any]) -> bool:
if element["alpha"] <= 0:
return False
if element["kind"] != "shape":
return True
if has_text_content(element):
return True
if element.get("hasFill"):
fill_color = element.get("fillColor")
if (fill_color is not None and not is_transparent_color(fill_color)) or element.get(
"hasUnknownFillStyle"
):
return True
border_width = element.get("borderWidth")
if isinstance(border_width, (int, float)) and border_width > 0:
border_color = element.get("borderColor")
if border_color is None or not is_transparent_color(border_color):
return True
return bool(element.get("hasOtherDrawingContent"))
def is_inside_donut_hole(chart: dict[str, Any], overlay: dict[str, Any]) -> bool:
inner_radius = chart.get("innerRadius")
if (
chart.get("chartPlotType") != "pie"
or not isinstance(inner_radius, (int, float))
or not 0 < inner_radius <= 1
or overlay.get("rotation", 0) % 360 != 0
):
return False
chart_radius = min(chart["width"], chart["height"]) / 2
conservative_hole_radius = chart_radius * inner_radius * 0.8
center_x = chart["x"] + chart["width"] / 2
center_y = chart["y"] + chart["height"] / 2
corners = (
(overlay["x"], overlay["y"]),
(overlay["x"] + overlay["width"], overlay["y"]),
(overlay["x"], overlay["y"] + overlay["height"]),
(overlay["x"] + overlay["width"], overlay["y"] + overlay["height"]),
)
return max(math.hypot(x - center_x, y - center_y) for x, y in corners) <= conservative_hole_radius
def prune_chart_overlay_text_details(
chart: dict[str, Any],
overlap_details: list[dict[str, Any]],
elements: list[dict[str, Any]],
) -> list[dict[str, Any]]:
elements_by_id = {element["id"]: element for element in elements}
reported_ids = {detail["element"] for detail in overlap_details}
pruned: list[dict[str, Any]] = []
for detail in overlap_details:
overlay = elements_by_id[detail["element"]]
if not is_text_element(overlay):
pruned.append(detail)
continue
containers = [
element
for element in elements
if (
chart["order"] < element["order"] < overlay["order"]
and not is_text_element(element)
and element["kind"] != "chart"
and is_effectively_visible(element)
and intersects(chart, element)
and contains(element, overlay)
)
]
if containers and not any(container["id"] in reported_ids for container in containers):
continue
pruned.append(detail)
return pruned
def detect_chart_external_overlays(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
for chart in [element for element in elements if element["kind"] == "chart"]:
overlap_details = [
detail
for overlay in elements
if (detail := chart_external_overlay_detail(chart, overlay)) is not None
]
overlap_details = prune_chart_overlay_text_details(chart, overlap_details, elements)
if not overlap_details:
continue
overlay_ids = [detail["element"] for detail in overlap_details]
issues.append(
{
"level": "error",
"code": "chart_external_overlay",
"elements": [chart["id"], *overlay_ids],
"chart_bbox": {key: chart[key] for key in ("x", "y", "width", "height")},
"overlays": overlap_details,
"message": (
f'chart {chart["id"]} is covered by {len(overlay_ids)} later sibling element(s): '
f'{", ".join(overlay_ids)}'
),
"hint": (
"Move the covering elements before the chart in XML order or outside the chart bbox. "
"For an intentional centered KPI, resize or reposition the chart and KPI so their declared "
"bboxes do not overlap, then verify the rendered slide."
),
}
)
return issues
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
@@ -1219,7 +1544,12 @@ 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 elements:
for element in (
element
for element in elements
if element["kind"] in {"table", "chart"}
or (element["kind"] == "shape" and element["type"] in {"rect", "text"})
):
bbox = element_canvas_bbox(element)
overflow = {
"left": max(-bbox["x"], 0),
@@ -1335,28 +1665,46 @@ def lint_slide(
elements = extract_elements(slide_xml)
issues: list[dict[str, Any]] = [
*detect_whiteboard_external_overlaps(elements, slide_width, slide_height),
*detect_chart_external_overlays(elements),
*detect_elements_out_of_canvas(elements, slide_width, slide_height),
*detect_table_layout_size_mismatches(elements),
*detect_text_may_overflow_shapes(elements),
*detect_image_text_occlusions(elements),
*detect_shape_text_occlusions(elements),
]
for index, left in enumerate(elements):
for right in elements[index + 1 :]:
horizontal_overflow = should_flag_horizontal_text_overflow(left, right)
if not horizontal_overflow and (not intersects(left, right) or not should_flag_overlap(left, right)):
visual_overlap = intersects(left, right) and should_flag_overlap(left, right)
if horizontal_overflow or visual_overlap:
issues.append(
{
"level": "error",
"code": "bbox_overlap",
"elements": [left["id"], right["id"]],
"message": f'{left["id"]} overlaps {right["id"]}',
}
)
continue
container_risk = text_container_overlap_risk(left, right)
if container_risk is None:
continue
issues.append(
{
"level": "error",
"code": "bbox_overlap",
"level": "warning",
"code": "text_container_overlap_risk",
"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 {}
**container_risk,
"message": (
f'text containers {left["id"]} and {right["id"]} overlap; '
"rendered text may enter the adjacent text container"
),
"hint": (
"Separate the text container bounds, or inspect a rendered screenshot before accepting "
"the overlap. This warning uses container geometry because static text metrics may "
"underestimate the rendered text area."
),
}
)

View File

@@ -343,6 +343,26 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(result["summary"]["error_count"], 0)
self.assertNotIn("issues", result)
def test_lint_xml_ignores_chart_parsed_values_roundtrip_tag(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<chart topLeftX="80" topLeftY="80" width="300" height="160">
<chartData>
<chartField>
<chartParsedValues>Africa</chartParsedValues>
</chartField>
</chartData>
</chart>
</data>
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertNotIn("issues", result)
def test_lint_xml_limits_chart_roundtrip_attrs_to_matching_tags(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -541,6 +561,85 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap")
def test_lint_xml_warns_for_overlapping_stacked_text_containers(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="bAX" type="text" topLeftX="120" topLeftY="260" width="720" height="210">
<content fontSize="12.4" lineSpacing="multiple:1.9" letterSpacing="2"
textAlign="center" autoFit="normal-auto-fit">
<p lineSpacing="multiple:1.4">李白的诗歌,以其豪放飘逸的风格、丰富奇特的想象、</p>
<p lineSpacing="multiple:1.4">清新自然的语言,达到了中国古代浪漫主义诗歌的巅峰。</p>
<p lineSpacing="multiple:1.4"/>
<p lineSpacing="multiple:1.4">他的诗中,有黄河之水天上来的气势,</p>
<p lineSpacing="multiple:1.4">有举杯邀明月的孤寂,有天生我材必有用的自信,</p>
<p lineSpacing="multiple:1.4">也有轻舟已过万重山的畅快。</p>
<p lineSpacing="multiple:1.4"/>
<p lineSpacing="multiple:1.4">千百年后,读其诗,仍能感受到那股</p>
<p lineSpacing="multiple:1.4">穿越时空的豪情与浪漫。</p>
</content>
</shape>
<shape id="bAj" type="text" topLeftX="120" topLeftY="450" width="720" height="30">
<content fontSize="14" letterSpacing="1" textAlign="center">
<p>【思考】你最喜欢李白的哪首诗?为什么?</p>
</content>
</shape>
</data>
</slide>
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 1)
issue = result["slides"][0]["issues"][0]
self.assertEqual(issue["code"], "text_container_overlap_risk")
self.assertEqual(issue["elements"], ["bAX", "bAj"])
self.assertEqual(issue["overlap_width"], 720)
self.assertEqual(issue["overlap_height"], 20)
def test_lint_xml_ignores_sliver_overlap_between_title_and_subtitle(self) -> None:
# Title (h100) and subtitle (h60) in the same column touch by only 10px, i.e. 17% of the
# shorter container. A generous-padding title/subtitle stack is not a real bleed risk and
# must not warn.
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="title" type="text" topLeftX="80" topLeftY="180" width="700" height="100">
<content fontSize="52"><p>Ozon跨境电商</p></content>
</shape>
<shape id="subtitle" type="text" topLeftX="80" topLeftY="270" width="700" height="60">
<content fontSize="32"><p>普通人的财富新机遇</p></content>
</shape>
</data>
</slide>
</presentation>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertNotIn("text_container_overlap_risk", codes)
def test_lint_xml_does_not_flag_single_line_marginally_over_box_width(self) -> None:
# "4.16万亿" at 36pt estimates to ~151px in a 150px box (0.8% over). That boundary noise
# must not round up to a spurious 2nd wrapped line and gate-blocking overflow error.
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="metric" type="text" topLeftX="100" topLeftY="225" width="150" height="50">
<content textType="title" fontSize="36" bold="true"><p>4.16万亿</p></content>
</shape>
</data>
</slide>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertNotIn("text_may_overflow_shape", codes)
def test_lint_xml_detects_current_itinerary_cjk_caption_occlusion(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -595,9 +694,11 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"""
)
overlap_pairs = {tuple(issue["elements"]) for issue in result["slides"][0]["issues"]}
self.assertEqual(result["summary"]["error_count"], 2)
self.assertEqual(result["summary"]["error_count"], 4)
self.assertIn(("blY", "blV"), overlap_pairs)
self.assertIn(("blQ", "blS"), overlap_pairs)
self.assertIn(("blH", "blY"), overlap_pairs)
self.assertIn(("blw", "blQ"), overlap_pairs)
def test_lint_xml_detects_horizontal_text_overflow_across_declared_box_gap(self) -> None:
result = xml_text_overlap_lint.lint_xml(
@@ -637,9 +738,10 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
</slide>
"""
)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 1)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(result["slides"][0]["issues"][0]["code"], "text_may_overflow_shape")
self.assertEqual(result["slides"][0]["issues"][0]["level"], "error")
self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["source"])
def test_lint_xml_reports_text_out_of_canvas_and_warns_for_text_height(self) -> None:
@@ -660,8 +762,8 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"""
)
issue = result["slides"][0]["issues"][0]
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["summary"]["warning_count"], 1)
self.assertEqual(result["summary"]["error_count"], 2)
self.assertEqual(result["summary"]["warning_count"], 0)
self.assertEqual(issue["code"], "shape_out_of_canvas")
self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 160, "bottom": 40})
@@ -690,15 +792,18 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"""
)
issues = result["slides"][0]["issues"]
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["summary"]["warning_count"], 1)
self.assertEqual(issues[0]["code"], "text_may_overflow_shape")
self.assertEqual(issues[0]["elements"], ["overflowing"])
self.assertEqual(issues[0]["line_count"], 4)
self.assertEqual(issues[0]["estimated_height"], 110)
self.assertEqual(issues[0]["available_height"], 80)
self.assertEqual(issues[0]["overflow"], 30)
self.assertIn('wrap="true" autoFit="normal-auto-fit"', issues[0]["message"])
overflow_issues = [issue for issue in issues if issue["code"] == "text_may_overflow_shape"]
self.assertEqual(result["summary"]["error_count"], 2)
overflow_ids = {issue["elements"][0] for issue in overflow_issues}
self.assertIn("overflowing", overflow_ids)
self.assertIn("auto-fit", overflow_ids)
self.assertNotIn("fitting", overflow_ids)
overflowing_issue = next(issue for issue in overflow_issues if issue["elements"] == ["overflowing"])
self.assertEqual(overflowing_issue["line_count"], 4)
self.assertEqual(overflowing_issue["estimated_height"], 110)
self.assertEqual(overflowing_issue["available_height"], 80)
self.assertEqual(overflowing_issue["overflow"], 30)
self.assertIn('wrap="true" autoFit="normal-auto-fit"', overflowing_issue["message"])
def test_lint_xml_uses_fixed_line_spacing_for_text_height_warning(self) -> None:
result = xml_text_overlap_lint.lint_xml(
@@ -716,10 +821,125 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
)
issue = result["slides"][0]["issues"][0]
self.assertEqual(result["summary"]["warning_count"], 1)
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(issue["level"], "warning")
self.assertEqual(issue["line_height"], 20)
self.assertEqual(issue["estimated_height"], 60)
self.assertEqual(issue["overflow"], 10)
def test_lint_xml_text_may_overflow_shape_upgrades_to_error_above_threshold(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="just-warning" type="text" topLeftX="80" topLeftY="80" width="360" height="50">
<content fontSize="20" lineSpacing="fixed:20">
<p>第一段</p><p>第二段</p><p>第三段</p>
</content>
</shape>
<shape id="error-overflow" type="text" topLeftX="80" topLeftY="200" width="360" height="30">
<content fontSize="20" lineSpacing="fixed:20">
<p>第一段</p><p>第二段</p><p>第三段</p>
</content>
</shape>
</data>
</slide>
"""
)
issues = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
self.assertEqual(issues["just-warning"]["level"], "warning")
self.assertEqual(issues["just-warning"]["overflow"], 10)
self.assertEqual(issues["error-overflow"]["level"], "error")
self.assertEqual(issues["error-overflow"]["overflow"], 30)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["summary"]["warning_count"], 1)
def test_lint_xml_text_may_overflow_shape_downgrades_background_decoration_to_info(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="bg-deco" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
<content fontSize="120" lineSpacing="fixed:120"><p>2026</p></content>
</shape>
<shape id="foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60">
<content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
</shape>
</data>
</slide>
"""
)
issues = {
issue["elements"][0]: issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape"
}
self.assertEqual(issues["bg-deco"]["level"], "info")
self.assertIn("background decoration", issues["bg-deco"]["message"])
def test_lint_xml_text_may_overflow_shape_keeps_error_when_alpha_not_low(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="opaque-big" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.9">
<content fontSize="120" lineSpacing="fixed:120"><p>2026</p></content>
</shape>
<shape id="foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60">
<content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
</shape>
</data>
</slide>
"""
)
issue = next(
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["opaque-big"]
)
self.assertEqual(issue["level"], "error")
def test_lint_xml_text_may_overflow_shape_keeps_error_when_no_foreground_text(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="lonely-big" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
<content fontSize="120" lineSpacing="fixed:120"><p>2026</p></content>
</shape>
</data>
</slide>
"""
)
issue = next(
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["lonely-big"]
)
self.assertEqual(issue["level"], "error")
def test_lint_xml_text_may_overflow_shape_keeps_error_when_foreground_is_below_in_order(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60">
<content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
</shape>
<shape id="top-big" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
<content fontSize="120" lineSpacing="fixed:120"><p>2026</p></content>
</shape>
</data>
</slide>
"""
)
issue = next(
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["top-big"]
)
self.assertEqual(issue["level"], "error")
def test_lint_xml_uses_paragraph_spacing_overrides_for_text_height_warning(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -747,13 +967,42 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(issues[0]["estimated_height"], 40)
self.assertEqual(issues[0]["overflow"], 5)
def test_lint_xml_uses_letter_spacing_for_text_overflow_warning(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="baseline" type="text" topLeftX="0" topLeftY="0" width="120" height="30">
<content fontSize="20" lineSpacing="multiple:1.5"><p>一二三四五六</p></content>
</shape>
<shape id="content-spaced" type="text" topLeftX="200" topLeftY="0" width="120" height="30">
<content fontSize="20" lineSpacing="multiple:1.5" letterSpacing="2"><p>一二三四五六</p></content>
</shape>
<shape id="paragraph-spaced" type="text" topLeftX="400" topLeftY="0" width="120" height="30">
<content fontSize="20" lineSpacing="multiple:1.5"><p letterSpacing="2">一二三四五六</p></content>
</shape>
</data>
</slide>
"""
)
issues = result["slides"][0]["issues"]
overflow_ids = [issue["elements"][0] for issue in issues if issue["code"] == "text_may_overflow_shape"]
self.assertNotIn("baseline", overflow_ids)
self.assertIn("content-spaced", overflow_ids)
self.assertIn("paragraph-spaced", overflow_ids)
by_id = {issue["elements"][0]: issue for issue in issues if issue["code"] == "text_may_overflow_shape"}
self.assertEqual(by_id["content-spaced"]["line_count"], 2)
self.assertEqual(by_id["content-spaced"]["estimated_height"], 50)
self.assertEqual(by_id["content-spaced"]["overflow"], 20)
self.assertEqual(by_id["paragraph-spaced"]["line_count"], 2)
def test_strip_xml_paragraphs_preserves_br_as_hard_line_break(self) -> None:
self.assertEqual(
xml_text_overlap_lint.strip_xml_paragraphs("<p>第一行<br/>第二行<br />第三行</p>"),
"第一行\n第二行\n第三行",
)
def test_lint_xml_blocks_template_style_bleed_outside_canvas(self) -> None:
def test_lint_xml_allows_template_style_images_outside_canvas(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -771,9 +1020,8 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["summary"]["error_count"], 0)
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(
@@ -807,7 +1055,7 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(elements[1]["fontSize"], 28)
self.assertEqual(elements[1]["text"], "Growth & scale\nFocused execution")
def test_lint_xml_blocks_small_out_of_bounds_images(self) -> None:
def test_lint_xml_ignores_small_out_of_bounds_images(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -819,10 +1067,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["slides"][0]["errors"][0]["code"], "img_out_of_canvas")
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_blocks_out_of_canvas_images(self) -> None:
def test_lint_xml_ignores_out_of_canvas_images(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -835,13 +1082,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
</presentation>
"""
)
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"],
)
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_blocks_full_bleed_images_outside_canvas(self) -> None:
def test_lint_xml_ignores_full_bleed_images_outside_canvas(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -853,10 +1096,9 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
</presentation>
"""
)
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["slides"][0]["errors"][0]["code"], "img_out_of_canvas")
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_reports_text_and_chart_out_of_canvas(self) -> None:
def test_lint_xml_reports_text_and_chart_but_not_image_out_of_canvas(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
@@ -871,17 +1113,16 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"""
)
issues = result["slides"][0]["issues"]
self.assertEqual(result["summary"]["error_count"], 3)
self.assertEqual(result["summary"]["error_count"], 2)
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:
def test_lint_xml_ignores_line_out_of_canvas(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
@@ -895,11 +1136,8 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"""
)
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")
self.assertEqual(result["summary"]["error_count"], 0)
self.assertEqual(result["slides"][0]["issues"], [])
def test_lint_xml_uses_rotated_text_and_chart_bounds_for_canvas_validation(self) -> None:
result = xml_text_overlap_lint.lint_xml(
@@ -922,13 +1160,13 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
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:
def test_lint_xml_uses_declared_bounds_for_rect_and_ignores_images(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="rotated-rect" type="rect" topLeftX="0" topLeftY="0" width="100" height="100" rotation="45"/>
<shape id="rotated-rect" type="rect" topLeftX="900" topLeftY="0" width="100" height="100" rotation="45"/>
<img id="rotated-image" topLeftX="860" topLeftY="200" width="100" height="100" rotation="45"/>
</data>
</slide>
@@ -936,12 +1174,54 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
"""
)
issues_by_element = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
self.assertEqual(result["summary"]["error_count"], 2)
self.assertEqual(result["summary"]["error_count"], 1)
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)
self.assertEqual(issues_by_element["rotated-rect"]["overflow"], {"left": 0, "top": 0, "right": 40, "bottom": 0})
self.assertNotIn("rotated-image", issues_by_element)
def test_detect_elements_out_of_canvas_limits_detection_to_whitelist(self) -> None:
issues = xml_text_overlap_lint.detect_elements_out_of_canvas(
[
{"id": "table", "kind": "table", "x": 95, "y": 0, "width": 10, "height": 10, "rotation": 45},
{"id": "chart", "kind": "chart", "x": 95, "y": 0, "width": 10, "height": 10, "rotation": 0},
{
"id": "text",
"kind": "shape",
"type": "text",
"x": 95,
"y": 0,
"width": 10,
"height": 10,
"rotation": 0,
},
{
"id": "rect",
"kind": "shape",
"type": "rect",
"x": 95,
"y": 0,
"width": 10,
"height": 10,
"rotation": 45,
},
{"id": "image", "kind": "img", "x": 95, "y": 0, "width": 10, "height": 10, "rotation": 0},
{
"id": "ellipse",
"kind": "shape",
"type": "ellipse",
"x": 95,
"y": 0,
"width": 10,
"height": 10,
"rotation": 0,
},
],
100,
100,
)
self.assertEqual([issue["elements"] for issue in issues], [["table"], ["chart"], ["text"], ["rect"]])
self.assertEqual(issues[-1]["bbox"], {"x": 95, "y": 0, "width": 10, "height": 10})
def test_lint_xml_treats_non_finite_rotations_as_zero(self) -> None:
result = xml_text_overlap_lint.lint_xml(
@@ -1255,8 +1535,326 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(result["summary"]["error_count"], 1)
self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap")
def test_lint_xml_reports_later_shape_and_text_covering_chart(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<chart id="chart" topLeftX="100" topLeftY="100" width="400" height="300"/>
<shape id="card" type="rect" topLeftX="210" topLeftY="190" width="180" height="70">
<fill><fillColor color="rgba(255, 255, 255, 1)"/></fill>
</shape>
<shape id="label" type="text" topLeftX="220" topLeftY="200" width="160" height="20">
<content textAlign="center"><p>KPI</p></content>
</shape>
</data></slide>
"""
)
issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "chart_external_overlay"
]
self.assertEqual(len(issues), 1)
self.assertEqual(issues[0]["level"], "error")
self.assertEqual(issues[0]["elements"], ["chart", "card", "label"])
def test_lint_xml_reports_vertical_text_image_overlap_as_warning(self) -> None:
def test_lint_xml_reports_export_kpi_overlay_with_geometry_details(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<chart id="bCd" topLeftX="505" topLeftY="130" width="400" height="320">
<chartPlotArea><chartPlot type="pie"><chartSeriesList><chartSeries>
<chartSectors innerRadius="0.55"/>
</chartSeries></chartSeriesList></chartPlot></chartPlotArea>
</chart>
<shape id="bCo" type="rect" topLeftX="530" topLeftY="250" width="180" height="60">
<fill><fillColor color="rgba(255, 255, 255, 1)"/></fill>
</shape>
<shape id="bCn" type="text" topLeftX="540" topLeftY="258" width="160" height="20">
<content textAlign="center"><p>2025年出口额</p></content>
</shape>
<shape id="bCe" type="text" topLeftX="540" topLeftY="278" width="160" height="30">
<content textAlign="center"><p>324.7亿$</p></content>
</shape>
</data></slide>
"""
)
issue = next(
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "chart_external_overlay"
)
self.assertEqual(issue["level"], "error")
self.assertEqual(issue["elements"], ["bCd", "bCo", "bCn", "bCe"])
self.assertEqual(issue["chart_bbox"], {"x": 505, "y": 130, "width": 400, "height": 320})
self.assertEqual(
[
(overlay["element"], overlay["overlap_width"], overlay["overlap_height"])
for overlay in issue["overlays"]
],
[("bCo", 180, 60), ("bCn", 160, 20), ("bCe", 160, 30)],
)
self.assertTrue(
all(overlay["overlay_overlap_ratio"] == 1 for overlay in issue["overlays"])
)
def test_lint_xml_allows_chart_with_earlier_background_container(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<shape id="background" type="rect" topLeftX="80" topLeftY="80" width="440" height="340"/>
<chart id="chart" topLeftX="100" topLeftY="100" width="400" height="300"/>
</data></slide>
"""
)
self.assertFalse(
any(
issue["code"] == "chart_external_overlay"
for issue in result["slides"][0]["issues"]
)
)
def test_lint_xml_allows_later_shape_with_minor_chart_edge_intersection(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<chart id="chart" topLeftX="100" topLeftY="100" width="400" height="300"/>
<shape id="edge" type="rect" topLeftX="80" topLeftY="370" width="440" height="60">
<fill><fillColor color="rgba(255, 255, 255, 1)"/></fill>
</shape>
<shape id="edge-label" type="text" topLeftX="100" topLeftY="375" width="400" height="20">
<content><p>Chart note</p></content>
</shape>
</data></slide>
"""
)
self.assertFalse(
any(
issue["code"] == "chart_external_overlay"
for issue in result["slides"][0]["issues"]
)
)
def test_lint_xml_allows_shape_disjoint_from_chart(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<chart id="chart" topLeftX="100" topLeftY="100" width="400" height="300"/>
<shape id="outside" type="rect" topLeftX="520" topLeftY="180" width="100" height="80"/>
</data></slide>
"""
)
self.assertFalse(
any(
issue["code"] == "chart_external_overlay"
for issue in result["slides"][0]["issues"]
)
)
def test_lint_xml_groups_kpi_card_background_and_text_into_one_chart_issue(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<chart id="chart" topLeftX="100" topLeftY="100" width="400" height="300"/>
<shape id="card" type="rect" topLeftX="210" topLeftY="190" width="180" height="70">
<fill><fillColor color="rgba(255, 255, 255, 1)"/></fill>
</shape>
<shape id="caption" type="text" topLeftX="220" topLeftY="198" width="160" height="22">
<content textAlign="center"><p>Caption</p></content>
</shape>
<shape id="value" type="text" topLeftX="220" topLeftY="220" width="160" height="34">
<content textAlign="center"><p>324.7</p></content>
</shape>
</data></slide>
"""
)
issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "chart_external_overlay"
]
self.assertEqual(len(issues), 1)
self.assertEqual(issues[0]["level"], "error")
self.assertEqual(issues[0]["elements"], ["chart", "card", "caption", "value"])
def test_lint_xml_allows_small_donut_center_overlays(self) -> None:
overlays = {
"text": """
<shape id="kpi" type="text" topLeftX="310" topLeftY="230" width="80" height="40">
<content textAlign="center"><p>+28%</p></content>
</shape>
""",
"card": """
<shape id="card" type="rect" topLeftX="300" topLeftY="220" width="100" height="60">
<fill><fillColor color="rgba(255, 255, 255, 1)"/></fill>
</shape>
""",
}
for name, overlay_xml in overlays.items():
with self.subTest(name=name):
result = xml_text_overlap_lint.lint_xml(
f"""
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<chart id="chart" topLeftX="100" topLeftY="90" width="500" height="320">
<chartPlotArea><chartPlot type="pie"><chartSeriesList><chartSeries>
<chartSectors innerRadius="0.6"/>
</chartSeries></chartSeriesList></chartPlot></chartPlotArea>
</chart>
{overlay_xml}
</data></slide>
"""
)
issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "chart_external_overlay"
]
self.assertEqual(issues, [])
def test_lint_xml_reports_donut_overlays_that_reach_the_ring(self) -> None:
overlays = {
"too-large": (
'<shape id="kpi" type="text" topLeftX="275" topLeftY="200" width="150" height="100">'
"<content><p>+28%</p></content></shape>"
),
"off-center": (
'<shape id="kpi" type="text" topLeftX="365" topLeftY="225" width="80" height="40">'
"<content><p>+28%</p></content></shape>"
),
}
for name, overlay_xml in overlays.items():
with self.subTest(name=name):
result = xml_text_overlap_lint.lint_xml(
f"""
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<chart id="chart" topLeftX="100" topLeftY="90" width="500" height="320">
<chartPlotArea><chartPlot type="pie"><chartSeriesList><chartSeries>
<chartSectors innerRadius="0.6"/>
</chartSeries></chartSeriesList></chartPlot></chartPlotArea>
</chart>
{overlay_xml}
</data></slide>
"""
)
issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "chart_external_overlay"
]
self.assertEqual(len(issues), 1)
self.assertEqual(issues[0]["code"], "chart_external_overlay")
self.assertEqual(issues[0]["level"], "error")
self.assertEqual(issues[0]["elements"], ["chart", "kpi"])
def test_lint_xml_reports_text_in_center_of_regular_pie(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<chart id="chart" topLeftX="100" topLeftY="90" width="500" height="320">
<chartPlotArea><chartPlot type="pie"/></chartPlotArea>
</chart>
<shape id="kpi" type="text" topLeftX="310" topLeftY="230" width="80" height="40">
<content><p>+28%</p></content>
</shape>
</data></slide>
"""
)
issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "chart_external_overlay"
]
self.assertEqual(len(issues), 1)
self.assertEqual(issues[0]["code"], "chart_external_overlay")
self.assertEqual(issues[0]["level"], "error")
self.assertEqual(issues[0]["elements"], ["chart", "kpi"])
def test_lint_xml_skips_only_definitely_invisible_shapes_over_chart(self) -> None:
shapes = {
"empty": '<shape id="overlay" type="rect" topLeftX="210" topLeftY="190" width="180" height="70"/>',
"transparent": (
'<shape id="overlay" type="rect" topLeftX="210" topLeftY="190" width="180" height="70">'
'<fill><fillColor color="transparent"/></fill></shape>'
),
"rgba-zero": (
'<shape id="overlay" type="rect" topLeftX="210" topLeftY="190" width="180" height="70">'
'<fill><fillColor color="rgba(255, 255, 255, 0)"/></fill></shape>'
),
}
for name, shape_xml in shapes.items():
with self.subTest(name=name):
result = xml_text_overlap_lint.lint_xml(
f"""
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<chart id="chart" topLeftX="100" topLeftY="100" width="400" height="300"/>
{shape_xml}
</data></slide>
"""
)
issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "chart_external_overlay"
]
self.assertEqual(issues, [])
def test_lint_xml_reports_visible_fill_or_border_over_chart(self) -> None:
shapes = {
"fill": (
'<shape id="overlay" type="rect" topLeftX="210" topLeftY="190" width="180" height="70">'
'<fill><fillColor color="rgba(255, 255, 255, 1)"/></fill></shape>'
),
"border": (
'<shape id="overlay" type="rect" topLeftX="210" topLeftY="190" width="180" height="70">'
'<border color="rgba(31, 35, 41, 1)" width="1"/></shape>'
),
}
for name, shape_xml in shapes.items():
with self.subTest(name=name):
result = xml_text_overlap_lint.lint_xml(
f"""
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<chart id="chart" topLeftX="100" topLeftY="100" width="400" height="300"/>
{shape_xml}
</data></slide>
"""
)
issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "chart_external_overlay"
]
self.assertEqual(len(issues), 1)
self.assertEqual(issues[0]["code"], "chart_external_overlay")
self.assertEqual(issues[0]["level"], "error")
self.assertEqual(issues[0]["elements"], ["chart", "overlay"])
def test_lint_xml_reports_visible_text_inside_transparent_wrapper_over_chart(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<chart id="chart" topLeftX="100" topLeftY="100" width="400" height="300">
<chartPlotArea><chartPlot type="column"/></chartPlotArea>
</chart>
<shape id="wrapper" type="rect" alpha="0" topLeftX="190" topLeftY="175" width="220" height="100">
<shape id="label" type="text" topLeftX="210" topLeftY="200" width="180" height="40">
<content><p>Visible KPI</p></content>
</shape>
</shape>
</data></slide>
"""
)
issues = [
issue
for issue in result["slides"][0]["issues"]
if issue["code"] == "chart_external_overlay"
]
self.assertEqual(len(issues), 1)
self.assertEqual(issues[0]["code"], "chart_external_overlay")
self.assertEqual(issues[0]["level"], "error")
self.assertEqual(issues[0]["elements"], ["chart", "label"])
def test_lint_xml_reports_vertical_text_image_overlap_as_info(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
@@ -1271,6 +1869,123 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(issue["level"], "warning")
self.assertEqual(result["summary"]["error_count"], 0)
def test_lint_xml_reports_opaque_shape_covering_earlier_text(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<shape id="caption" type="text" topLeftX="60" topLeftY="100" width="400" height="30">
<content fontSize="14"><p>静谧沉稳的灰蓝色调</p></content>
</shape>
<shape id="swatch" type="rect" topLeftX="60" topLeftY="90" width="180" height="180">
<fill><fillColor color="rgba(74, 92, 106, 1)"/></fill>
</shape>
</data></slide>
"""
)
issue = next(issue for issue in result["slides"][0]["issues"] if issue["code"] == "shape_covers_text")
self.assertEqual(issue["level"], "error")
self.assertEqual(issue["elements"], ["swatch", "caption"])
self.assertEqual(result["summary"]["error_count"], 1)
def test_lint_xml_ignores_shape_behind_text_in_xml_order(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<shape id="swatch" type="rect" topLeftX="60" topLeftY="90" width="180" height="180">
<fill><fillColor color="rgba(74, 92, 106, 1)"/></fill>
</shape>
<shape id="caption" type="text" topLeftX="60" topLeftY="100" width="160" height="30">
<content fontSize="14"><p>主色</p></content>
</shape>
</data></slide>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertNotIn("shape_covers_text", codes)
def test_lint_xml_ignores_translucent_shape_over_text(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<shape id="caption" type="text" topLeftX="60" topLeftY="100" width="400" height="30">
<content fontSize="14"><p>静谧沉稳的灰蓝色调</p></content>
</shape>
<shape id="tint" type="rect" topLeftX="60" topLeftY="90" width="180" height="180">
<fill><fillColor color="rgba(74, 92, 106, 0.2)"/></fill>
</shape>
</data></slide>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertNotIn("shape_covers_text", codes)
def test_lint_xml_ignores_border_only_shape_over_text(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<shape id="caption" type="text" topLeftX="60" topLeftY="100" width="400" height="30">
<content fontSize="14"><p>静谧沉稳的灰蓝色调</p></content>
</shape>
<shape id="outline" type="rect" topLeftX="60" topLeftY="90" width="180" height="180">
<border color="rgba(74, 92, 106, 1)" width="1"/>
</shape>
</data></slide>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertNotIn("shape_covers_text", codes)
def test_lint_xml_reports_default_colored_fill_shape_covering_text(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<shape id="caption" type="text" topLeftX="256" topLeftY="130" width="224" height="24">
<content fontSize="16" wrap="false"><p>这是正文,应该不要被遮挡才对</p></content>
</shape>
<shape id="rect" type="rect" topLeftX="176" topLeftY="142" width="238" height="140">
<fill><fillColor/></fill>
</shape>
</data></slide>
"""
)
issue = next(issue for issue in result["slides"][0]["issues"] if issue["code"] == "shape_covers_text")
self.assertEqual(issue["level"], "error")
self.assertEqual(issue["elements"], ["rect", "caption"])
self.assertEqual(result["summary"]["error_count"], 1)
def test_lint_xml_ignores_explicitly_transparent_fill_shape_over_text(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<shape id="caption" type="text" topLeftX="256" topLeftY="130" width="224" height="24">
<content fontSize="16" wrap="false"><p>这是正文,应该不要被遮挡才对</p></content>
</shape>
<shape id="rect" type="rect" topLeftX="176" topLeftY="142" width="238" height="140">
<fill><fillColor color="rgba(74, 92, 106, 0)"/></fill>
</shape>
</data></slide>
"""
)
codes = [issue["code"] for issue in result["slides"][0]["issues"]]
self.assertNotIn("shape_covers_text", codes)
def test_lint_xml_reports_opaque_shape_over_vertical_text_as_info(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
<shape id="text" type="text" vert="vert" topLeftX="100" topLeftY="100" width="100" height="100">
<content><p>Vertical</p></content>
</shape>
<shape id="cover" type="rect" topLeftX="120" topLeftY="120" width="60" height="60">
<fill><fillColor color="rgba(74, 92, 106, 1)"/></fill>
</shape>
</data></slide>
"""
)
issue = next(issue for issue in result["slides"][0]["issues"] if issue["code"] == "shape_may_cover_vertical_text")
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: