mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
1 Commits
codex/base
...
fix/slides
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a5b87949f |
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user