Compare commits

...

1 Commits

Author SHA1 Message Date
dengzilong.zero
8a5b87949f fix: detect text overflow across container boundaries 2026-07-30 20:48:17 +08:00
2 changed files with 390 additions and 1 deletions

View File

@@ -69,6 +69,8 @@ LINE_TEXT_GRAZE_MIN_PX = 2.0
LINE_TEXT_GRAZE_FONT_RATIO = 0.12
# A line whose effective stroke alpha is below this is not visibly rendered, so it cannot occlude text.
LINE_MIN_VISIBLE_ALPHA = 0.08
TEXT_CONTAINER_ASSOCIATION_TOLERANCE = 2
MIN_TEXT_CONTAINER_AREA = 4_000
# 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
@@ -2042,6 +2044,160 @@ def is_visually_rendered(element: dict[str, Any]) -> bool:
return element.get("alpha", 1) > 0
def text_container_overflow(
container: dict[str, Any], text: dict[str, Any]
) -> dict[str, dict[str, int | float]] | None:
container_right = container["x"] + container["width"]
container_bottom = container["y"] + container["height"]
text_right = text["x"] + text["width"]
text_bottom = text["y"] + text["height"]
text_visual_bbox = estimate_text_visual_bbox(text)
if text_visual_bbox is None:
return None
visual_right = text_visual_bbox["x"] + text_visual_bbox["width"]
visual_bottom = text_visual_bbox["y"] + text_visual_bbox["height"]
declared_overflows = {
"left": container["x"] - text["x"],
"top": container["y"] - text["y"],
"right": text_right - container_right,
"bottom": text_bottom - container_bottom,
}
horizontally_associated = (
text_right > container["x"]
and text["x"] < container_right
and not (
declared_overflows["left"] > TEXT_CONTAINER_ASSOCIATION_TOLERANCE
and declared_overflows["right"] > TEXT_CONTAINER_ASSOCIATION_TOLERANCE
)
)
vertically_associated = (
text_bottom > container["y"]
and text["y"] < container_bottom
and not (
declared_overflows["top"] > TEXT_CONTAINER_ASSOCIATION_TOLERANCE
and declared_overflows["bottom"] > TEXT_CONTAINER_ASSOCIATION_TOLERANCE
)
)
visual_overflows = {
"left": container["x"] - text_visual_bbox["x"],
"top": container["y"] - text_visual_bbox["y"],
"right": visual_right - container_right,
"bottom": visual_bottom - container_bottom,
}
visual_crosses_boundary = {
"left": text_visual_bbox["x"] <= container["x"] <= visual_right,
"top": text_visual_bbox["y"] <= container["y"] <= visual_bottom,
"right": text_visual_bbox["x"] <= container_right <= visual_right,
"bottom": text_visual_bbox["y"] <= container_bottom <= visual_bottom,
}
max_horizontal_overflow = max(24, text["width"] * 0.75)
max_vertical_overflow = max(24, text["height"] * 0.75)
side_is_associated = {
"left": (
vertically_associated
and text_right > container["x"]
and text_right <= container_right + TEXT_CONTAINER_ASSOCIATION_TOLERANCE
),
"top": (
horizontally_associated
and text_bottom > container["y"]
and text_bottom <= container_bottom + TEXT_CONTAINER_ASSOCIATION_TOLERANCE
),
"right": (
vertically_associated
and text["x"] >= container["x"] - TEXT_CONTAINER_ASSOCIATION_TOLERANCE
and text["x"] < container_right
),
"bottom": (
horizontally_associated
and text["y"] >= container["y"] - TEXT_CONTAINER_ASSOCIATION_TOLERANCE
and text["y"] < container_bottom
),
}
max_overflow = {
"left": max_horizontal_overflow,
"top": max_vertical_overflow,
"right": max_horizontal_overflow,
"bottom": max_vertical_overflow,
}
reported_sides = [
side
for side in ("left", "top", "right", "bottom")
if side_is_associated[side]
and 0 <= declared_overflows[side] <= max_overflow[side]
and visual_overflows[side] >= 0
and visual_crosses_boundary[side]
]
if not reported_sides:
return None
return {
"visual": {side: visual_overflows[side] for side in reported_sides},
"declared": {side: declared_overflows[side] for side in reported_sides},
}
def detect_text_outside_containers(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
issues: list[dict[str, Any]] = []
containers = [
element
for element in elements
if element["kind"] == "shape"
and element["type"] == "rect"
and is_visually_rendered(element)
and element_area(element) >= MIN_TEXT_CONTAINER_AREA
]
for text in (
element
for element in elements
if is_text_element(element)
and is_visually_rendered(element)
and has_text_content(element)
and not is_decorative_text(element)
and not is_vertical_text(element)
):
candidates = [
(container, text_container_overflow(container, text))
for container in containers
if container["order"] < text["order"]
]
candidates = [(container, overflow) for container, overflow in candidates if overflow is not None]
if not candidates:
continue
container, overflow = min(candidates, key=lambda candidate: element_area(candidate[0]))
visual_overflow = overflow["visual"]
declared_overflow = overflow["declared"]
touched_sides = [side for side, value in visual_overflow.items() if math.isclose(value, 0, abs_tol=1e-9)]
crossed_sides = [side for side, value in visual_overflow.items() if value > 0]
boundary_description = []
if touched_sides:
boundary_description.append(f'touches the {"/".join(touched_sides)} edge')
if crossed_sides:
boundary_description.append(f'extends outside the {"/".join(crossed_sides)} edge')
issues.append(
{
"level": "error",
"code": "text_outside_container",
"elements": [container["id"], text["id"]],
"measurement": {
"overflow": {side: round(value, 3) for side, value in visual_overflow.items()},
"declared_overflow": {side: round(value, 3) for side, value in declared_overflow.items()},
},
"message": (
f'estimated text in shape {text["id"]} {" and ".join(boundary_description)} '
f'of candidate container {container["id"]}'
),
"hint": (
"Resize the container, move the text, or reduce/reflow the text so it stays inside the card. "
"Inspect the rendered slide after fixing the static error."
),
}
)
return issues
def visual_bbox(element: dict[str, Any], container: dict[str, Any]) -> dict[str, int | float] | None:
if not is_visually_rendered(element):
return None
@@ -2282,6 +2438,10 @@ RULE_METADATA: dict[str, dict[str, Any]] = {
"name": "text_visual_bounds_do_not_overlap",
"comparison": "intersection_area == 0",
},
"text_outside_container": {
"name": "text_stays_inside_candidate_container",
"comparison": "estimated_visual_overflow[left,top,right,bottom] < 0",
},
"text_may_overflow_shape": {
"name": "estimated_text_fits_declared_shape",
"comparison": "estimated_height <= available_height",
@@ -2629,6 +2789,7 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
raw_issues = [
*geometry["issues"],
*extra_overflow_issues,
*detect_text_outside_containers(density_elements),
*detect_blank_slide(
density_elements,
slide_number,

View File

@@ -1052,6 +1052,230 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
self.assertEqual(result["slides"][0]["issues"][0]["level"], "error")
self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["source"])
def test_lint_xml_errors_when_estimated_text_crosses_card_bottom(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="60" topLeftY="370" width="280" height="100"/>
<shape id="caption" type="text" topLeftX="80" topLeftY="455" width="240" height="20">
<content fontSize="13"><p>接种流感疫苗,做好日常防护</p></content>
</shape>
</data>
</slide>
"""
)
issues = [issue for issue in result["slides"][0]["issues"] if issue["code"] == "text_outside_container"]
self.assertEqual(len(issues), 1)
self.assertEqual(issues[0]["level"], "error")
self.assertEqual(issues[0]["elements"], ["card", "caption"])
self.assertEqual(issues[0]["measurement"]["overflow"], {"bottom": 2.8})
self.assertEqual(issues[0]["measurement"]["declared_overflow"], {"bottom": 5})
self.assertFalse(result["summary"]["release_ready"])
def test_lint_xml_errors_when_estimated_text_crosses_card_top(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="100" topLeftY="100" width="200" height="100"/>
<shape id="caption" type="text" topLeftX="130" topLeftY="95" width="140" height="20">
<content fontSize="20"><p>顶部溢出</p></content>
</shape>
</data>
</slide>
"""
)
issues = [issue for issue in result["slides"][0]["issues"] if issue["code"] == "text_outside_container"]
self.assertEqual(len(issues), 1)
self.assertEqual(issues[0]["elements"], ["card", "caption"])
self.assertEqual(issues[0]["measurement"]["overflow"], {"top": 5})
self.assertEqual(issues[0]["measurement"]["declared_overflow"], {"top": 5})
def test_lint_xml_errors_when_estimated_text_crosses_card_left(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="100" topLeftY="100" width="200" height="100"/>
<shape id="caption" type="text" topLeftX="95" topLeftY="140" width="40" height="20">
<content fontSize="20" wrap="false"><p>左</p></content>
</shape>
</data>
</slide>
"""
)
issues = [issue for issue in result["slides"][0]["issues"] if issue["code"] == "text_outside_container"]
self.assertEqual(len(issues), 1)
self.assertEqual(issues[0]["elements"], ["card", "caption"])
self.assertEqual(issues[0]["measurement"]["overflow"], {"left": 5})
self.assertEqual(issues[0]["measurement"]["declared_overflow"], {"left": 5})
def test_lint_xml_errors_when_estimated_text_crosses_card_right(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="100" topLeftY="100" width="200" height="100"/>
<shape id="caption" type="text" topLeftX="285" topLeftY="140" width="20" height="20">
<content fontSize="20" wrap="false"><p>右</p></content>
</shape>
</data>
</slide>
"""
)
issues = [issue for issue in result["slides"][0]["issues"] if issue["code"] == "text_outside_container"]
self.assertEqual(len(issues), 1)
self.assertEqual(issues[0]["elements"], ["card", "caption"])
self.assertEqual(issues[0]["measurement"]["overflow"], {"right": 5})
self.assertEqual(issues[0]["measurement"]["declared_overflow"], {"right": 5})
def test_lint_xml_errors_when_estimated_text_crosses_card_corners(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide><data>
<shape id="card-tl" type="rect" topLeftX="100" topLeftY="100" width="200" height="100"/>
<shape id="caption-tl" type="text" topLeftX="95" topLeftY="95" width="20" height="20">
<content fontSize="20" wrap="false"><p>角</p></content>
</shape>
</data></slide>
<slide><data>
<shape id="card-tr" type="rect" topLeftX="100" topLeftY="100" width="200" height="100"/>
<shape id="caption-tr" type="text" topLeftX="285" topLeftY="95" width="20" height="20">
<content fontSize="20" wrap="false"><p>角</p></content>
</shape>
</data></slide>
<slide><data>
<shape id="card-bl" type="rect" topLeftX="100" topLeftY="100" width="200" height="100"/>
<shape id="caption-bl" type="text" topLeftX="95" topLeftY="185" width="20" height="20">
<content fontSize="20" wrap="false"><p>角</p></content>
</shape>
</data></slide>
<slide><data>
<shape id="card-br" type="rect" topLeftX="100" topLeftY="100" width="200" height="100"/>
<shape id="caption-br" type="text" topLeftX="285" topLeftY="185" width="20" height="20">
<content fontSize="20" wrap="false"><p>角</p></content>
</shape>
</data></slide>
</presentation>
"""
)
overflow_sides = [
set(next(issue for issue in slide["issues"] if issue["code"] == "text_outside_container")["measurement"]["overflow"])
for slide in result["slides"]
]
self.assertEqual(
overflow_sides,
[{"left", "top"}, {"top", "right"}, {"left", "bottom"}, {"right", "bottom"}],
)
def test_lint_xml_ignores_frame_contact_when_glyphs_do_not_reach_container(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
<slide><data>
<shape id="card-left" type="rect" topLeftX="100" topLeftY="100" width="200" height="100"/>
<shape id="caption-left" type="text" topLeftX="80" topLeftY="140" width="21" height="20">
<content fontSize="10" wrap="false"><p>A</p></content>
</shape>
</data></slide>
<slide><data>
<shape id="card-top" type="rect" topLeftX="100" topLeftY="100" width="200" height="100"/>
<shape id="caption-top" type="text" topLeftX="140" topLeftY="80" width="20" height="21">
<content fontSize="10"><p>A</p></content>
</shape>
</data></slide>
<slide><data>
<shape id="card-right" type="rect" topLeftX="100" topLeftY="100" width="200" height="100"/>
<shape id="caption-right" type="text" topLeftX="299" topLeftY="140" width="21" height="20">
<content fontSize="10" wrap="false" textAlign="right"><p>A</p></content>
</shape>
</data></slide>
<slide><data>
<shape id="card-bottom" type="rect" topLeftX="100" topLeftY="100" width="200" height="100"/>
<shape id="caption-bottom" type="text" topLeftX="140" topLeftY="199" width="20" height="21">
<content fontSize="10" verticalAlign="bottom"><p>A</p></content>
</shape>
</data></slide>
</presentation>
"""
)
self.assertTrue(
all(
not any(issue["code"] == "text_outside_container" for issue in slide["issues"])
for slide in result["slides"]
)
)
def test_lint_xml_errors_on_card_bottom_contact_and_chooses_smallest_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="0" topLeftY="0" width="960" height="540"/>
<shape id="card" type="rect" topLeftX="60" topLeftY="100" width="280" height="100"/>
<shape id="caption" type="text" topLeftX="80" topLeftY="184" width="240" height="20">
<content fontSize="10" verticalAlign="middle"><p>边界接触</p></content>
</shape>
</data>
</slide>
"""
)
issues = [issue for issue in result["slides"][0]["issues"] if issue["code"] == "text_outside_container"]
self.assertEqual(len(issues), 1)
self.assertEqual(issues[0]["elements"], ["card", "caption"])
self.assertEqual(issues[0]["measurement"]["overflow"], {"bottom": 0})
def test_lint_xml_allows_crossing_text_box_when_centered_glyphs_stay_inside_card(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="card" type="rect" topLeftX="300" topLeftY="245" width="360" height="120"/>
<shape id="caption" type="text" topLeftX="330" topLeftY="285" width="300" height="85">
<content fontSize="19" bold="true" textAlign="center" verticalAlign="middle">
<p>OVERFLOW 5 px</p>
</content>
</shape>
</data>
</slide>
"""
)
self.assertFalse(
any(issue["code"] == "text_outside_container" for issue in result["slides"][0]["issues"])
)
def test_lint_xml_does_not_infer_container_from_wrong_order_or_horizontal_mismatch(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
<slide xmlns="http://www.larkoffice.com/sml/2.0">
<data>
<shape id="before-card-text" type="text" topLeftX="80" topLeftY="184" width="240" height="20">
<content fontSize="10"><p>卡片在文字上层</p></content>
</shape>
<shape id="later-card" type="rect" topLeftX="60" topLeftY="100" width="280" height="100"/>
<shape id="narrow-card" type="rect" topLeftX="400" topLeftY="100" width="200" height="100"/>
<shape id="wide-text" type="text" topLeftX="380" topLeftY="184" width="240" height="20">
<content fontSize="10"><p>没有被水平包住</p></content>
</shape>
</data>
</slide>
"""
)
self.assertFalse(
any(issue["code"] == "text_outside_container" for issue in result["slides"][0]["issues"])
)
def test_lint_xml_reports_text_out_of_canvas_and_warns_for_text_height(self) -> None:
result = xml_text_overlap_lint.lint_xml(
"""
@@ -2737,7 +2961,11 @@ class XmlTextOverlapLintDensityTest(unittest.TestCase):
)
self_issue = self_only["slides"][0]["issues"][0]
mixed_issue = with_overlapping_child["slides"][0]["issues"][0]
mixed_issue = next(
issue
for issue in with_overlapping_child["slides"][0]["issues"]
if issue["code"] == "sparse_container_content"
)
self.assertEqual(
mixed_issue["measurement"]["visible_content_area"],
self_issue["measurement"]["visible_content_area"],