mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
1 Commits
main
...
fix/slides
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa9c288b8d |
@@ -33,7 +33,7 @@ lark-cli slides +xml-get --as user \
|
||||
python3 "<lark-slides-skill-dir>/scripts/xml_text_overlap_lint.py" --input <presentation.xml>
|
||||
```
|
||||
|
||||
它一次检查 XML/SXSD 合法性、元素越界、文本重叠、空白页、文本高度风险、整页内容稀疏和大卡片内容覆盖率。大卡片自身 `<content>` 的估算文本面积与卡片内平级元素一起参与覆盖率并集计算。
|
||||
它一次检查 XML/SXSD 合法性、元素越界、文本重叠、线条穿字、文本越出候选卡片、空白页、文本高度风险、整页内容稀疏和大卡片内容覆盖率。大卡片自身 `<content>` 的估算文本面积与卡片内平级元素一起参与覆盖率并集计算。
|
||||
|
||||
准出规则:
|
||||
|
||||
@@ -64,6 +64,8 @@ python3 "<lark-slides-skill-dir>/scripts/xml_text_overlap_lint.py" --input <pres
|
||||
| `icon_missing_fill_color` | 视觉规范要求 `<icon>` 设置 `<fill><fillColor color="..."/></fill>`,避免图标不可见 | 给 `<icon>` 添加显式非透明填充色,例如 `rgba(37, 99, 235, 1)` |
|
||||
| `icon_transparent_fill_color` | `<icon>` 的 `fillColor` 是透明色,不满足视觉可见性要求 | 改成与背景有足够对比的非透明颜色 |
|
||||
| `bbox_overlap` | 文本元素的估算绘制区域明显重叠 | 拉开文本坐标、缩小文本框/字号,或改成明确的分栏/分组结构 |
|
||||
| `line_crosses_text` | 线条与非空文本框的声明边界相交 | 结合截图确认是否为有意删除线;否则缩短/移动线条或移动文本 |
|
||||
| `text_outside_container` | 文本框属于候选矩形卡片,且估算的实际文字区域触碰或越过卡片底边 | 结合截图确认卡片归属与实际字形;增高卡片、上移文本或调整文字排版 |
|
||||
| `*_out_of_canvas` | 元素边界超出页面画布 | 根据 `measurement.overflow` 移回画布或缩小尺寸 |
|
||||
| `blank_slide` | 页面没有画布内可见内容 | 补充主体内容;仅有空背景或空形状不能准出 |
|
||||
| `sparse_container_content` | 大卡片内容覆盖率低于阈值 | 按元素 ID 定位卡片,结合截图判断是否补充或放大内容 |
|
||||
|
||||
@@ -1644,6 +1644,146 @@ def line_stroke_bbox(element: dict[str, Any]) -> dict[str, Any]:
|
||||
return {**element, "width": max(element["width"], 1), "height": max(element["height"], 1)}
|
||||
|
||||
|
||||
def detect_lines_crossing_text(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
issues: list[dict[str, Any]] = []
|
||||
lines = [
|
||||
element
|
||||
for element in elements
|
||||
if element["kind"] == "line"
|
||||
and is_visually_rendered(element)
|
||||
and (element["width"] == 0 or element["height"] == 0)
|
||||
]
|
||||
texts = [
|
||||
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)
|
||||
]
|
||||
for line in lines:
|
||||
stroke_bbox = line_stroke_bbox(line)
|
||||
for text in texts:
|
||||
width = intersection_width(stroke_bbox, text)
|
||||
height = intersection_height(stroke_bbox, text)
|
||||
if width <= 0 or height <= 0:
|
||||
continue
|
||||
font_size = text["fontSize"] if isinstance(text.get("fontSize"), (int, float)) else 16
|
||||
if max(width, height) < max(8, font_size * 0.5):
|
||||
continue
|
||||
issues.append(
|
||||
{
|
||||
"level": "warning",
|
||||
"code": "line_crosses_text",
|
||||
"elements": [line["id"], text["id"]],
|
||||
"measurement": {
|
||||
"intersection_width": round(width, 3),
|
||||
"intersection_height": round(height, 3),
|
||||
"intersection_area": round(width * height, 3),
|
||||
},
|
||||
"message": f'line {line["id"]} crosses the declared bounds of text shape {text["id"]}',
|
||||
"hint": (
|
||||
"Inspect the rendered slide. If the line is not an intentional strike-through, shorten or "
|
||||
"move the line, or move the text so their bounds no longer intersect."
|
||||
),
|
||||
}
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
TEXT_CONTAINER_ASSOCIATION_TOLERANCE = 2
|
||||
MIN_TEXT_CONTAINER_AREA = 4_000
|
||||
|
||||
|
||||
def text_container_bottom_overflow(
|
||||
container: dict[str, Any], text: dict[str, Any]
|
||||
) -> 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"]
|
||||
horizontally_contained = (
|
||||
text["x"] >= container["x"] - TEXT_CONTAINER_ASSOCIATION_TOLERANCE
|
||||
and text_right <= container_right + TEXT_CONTAINER_ASSOCIATION_TOLERANCE
|
||||
)
|
||||
starts_inside = (
|
||||
text["y"] >= container["y"] - TEXT_CONTAINER_ASSOCIATION_TOLERANCE
|
||||
and text["y"] < container_bottom
|
||||
)
|
||||
declared_overflow = text_bottom - container_bottom
|
||||
max_plausible_overflow = max(24, text["height"] * 0.75)
|
||||
if not horizontally_contained or not starts_inside:
|
||||
return None
|
||||
if declared_overflow < 0 or declared_overflow > max_plausible_overflow:
|
||||
return None
|
||||
|
||||
visual_bbox = estimate_text_visual_bbox(text)
|
||||
if visual_bbox is None:
|
||||
return None
|
||||
visual_overflow = visual_bbox["y"] + visual_bbox["height"] - container_bottom
|
||||
if visual_overflow < 0:
|
||||
return None
|
||||
return {
|
||||
"visual": visual_overflow,
|
||||
"declared": declared_overflow,
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
):
|
||||
candidates = [
|
||||
(container, text_container_bottom_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"]
|
||||
issues.append(
|
||||
{
|
||||
"level": "warning",
|
||||
"code": "text_outside_container",
|
||||
"elements": [container["id"], text["id"]],
|
||||
"measurement": {
|
||||
"overflow": {"bottom": round(visual_overflow, 3)},
|
||||
"declared_overflow": {"bottom": round(declared_overflow, 3)},
|
||||
},
|
||||
"message": (
|
||||
f'estimated text in shape {text["id"]} '
|
||||
+ (
|
||||
f'touches the bottom edge of candidate container {container["id"]}'
|
||||
if math.isclose(visual_overflow, 0, abs_tol=1e-9)
|
||||
else f'extends {visual_overflow:g}px below candidate container {container["id"]}'
|
||||
)
|
||||
),
|
||||
"hint": (
|
||||
"Inspect the rendered slide to confirm the card relationship. Increase the container height, "
|
||||
"move the text upward, or reduce the text height so it stays inside the card."
|
||||
),
|
||||
}
|
||||
)
|
||||
return issues
|
||||
|
||||
|
||||
def is_slide_content_present(
|
||||
element: dict[str, Any], slide_bbox: dict[str, int | float]
|
||||
) -> bool:
|
||||
@@ -1843,6 +1983,14 @@ RULE_METADATA: dict[str, dict[str, Any]] = {
|
||||
"name": "text_visual_bounds_do_not_overlap",
|
||||
"comparison": "intersection_area == 0",
|
||||
},
|
||||
"line_crosses_text": {
|
||||
"name": "line_does_not_cross_text_bounds",
|
||||
"comparison": "intersection_area == 0",
|
||||
},
|
||||
"text_outside_container": {
|
||||
"name": "text_stays_inside_candidate_container",
|
||||
"comparison": "estimated_visual_bottom_overflow < 0",
|
||||
},
|
||||
"text_may_overflow_shape": {
|
||||
"name": "estimated_text_fits_declared_shape",
|
||||
"comparison": "estimated_height <= available_height",
|
||||
@@ -2125,6 +2273,8 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
|
||||
raw_issues = [
|
||||
*geometry["issues"],
|
||||
*extra_overflow_issues,
|
||||
*detect_lines_crossing_text(density_elements),
|
||||
*detect_text_outside_containers(density_elements),
|
||||
*detect_blank_slide(
|
||||
density_elements,
|
||||
slide_number,
|
||||
|
||||
@@ -622,6 +622,175 @@ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
|
||||
self.assertGreater(issue["measurement"]["intersection_area"], 0)
|
||||
self.assertIsNotNone(issue.get("hint"))
|
||||
|
||||
def test_lint_xml_warns_when_timeline_line_crosses_text(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<line id="bAi" startX="60" startY="360" endX="900" endY="360">
|
||||
<border color="rgba(82, 82, 91, 1)" width="2"/>
|
||||
</line>
|
||||
<shape id="bAR" type="rect" topLeftX="780" topLeftY="330" width="140" height="100"/>
|
||||
<shape id="bAw" type="text" topLeftX="790" topLeftY="345" width="120" height="20">
|
||||
<content fontSize="16"><p>阶段总结</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
issues = [issue for issue in result["slides"][0]["issues"] if issue["code"] == "line_crosses_text"]
|
||||
self.assertEqual(len(issues), 1)
|
||||
self.assertEqual(issues[0]["level"], "warning")
|
||||
self.assertEqual(issues[0]["elements"], ["bAi", "bAw"])
|
||||
self.assertGreater(issues[0]["measurement"]["intersection_width"], 0)
|
||||
|
||||
def test_lint_xml_allows_line_outside_text_bounds(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="heading" type="text" topLeftX="60" topLeftY="80" width="300" height="40">
|
||||
<content fontSize="24"><p>章节标题</p></content>
|
||||
</shape>
|
||||
<line id="underline" startX="60" startY="125" endX="160" endY="125"/>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
self.assertFalse(
|
||||
any(issue["code"] == "line_crosses_text" for issue in result["slides"][0]["issues"])
|
||||
)
|
||||
|
||||
def test_lint_xml_does_not_approximate_diagonal_line_as_filled_bbox(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<line id="diagonal" startX="60" startY="80" endX="360" endY="280"/>
|
||||
<shape id="label" type="text" topLeftX="60" topLeftY="240" width="120" height="20">
|
||||
<content fontSize="16"><p>不在线段上</p></content>
|
||||
</shape>
|
||||
</data>
|
||||
</slide>
|
||||
"""
|
||||
)
|
||||
self.assertFalse(
|
||||
any(issue["code"] == "line_crosses_text" for issue in result["slides"][0]["issues"])
|
||||
)
|
||||
|
||||
def test_lint_xml_warns_when_estimated_text_exceeds_card_bottom(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="bPo" type="rect" topLeftX="60" topLeftY="370" width="280" height="100"/>
|
||||
<shape id="bxb" 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"], "warning")
|
||||
self.assertEqual(issues[0]["elements"], ["bPo", "bxb"])
|
||||
self.assertEqual(issues[0]["measurement"]["overflow"], {"bottom": 2.8})
|
||||
self.assertEqual(issues[0]["measurement"]["declared_overflow"], {"bottom": 5})
|
||||
|
||||
def test_lint_xml_warns_when_estimated_text_exceeds_smallest_card_bottom(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="bWD" type="rect" topLeftX="60" topLeftY="170" width="400" height="310"/>
|
||||
<shape id="bWa" type="text" topLeftX="80" topLeftY="470" width="360" 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]["elements"], ["bWD", "bWa"])
|
||||
self.assertEqual(issues[0]["measurement"]["overflow"], {"bottom": 7.8})
|
||||
self.assertEqual(issues[0]["measurement"]["declared_overflow"], {"bottom": 10})
|
||||
|
||||
def test_lint_xml_warns_when_estimated_text_exceeds_card_by_less_than_two_pixels(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="340.5" width="300" height="30">
|
||||
<content fontSize="19" textAlign="center" verticalAlign="middle">
|
||||
<p>GLYPH OVERFLOW</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": 1.9})
|
||||
|
||||
def test_lint_xml_warns_when_estimated_text_touches_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="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]["measurement"]["overflow"], {"bottom": 0})
|
||||
|
||||
def test_lint_xml_allows_centered_glyphs_inside_card_when_text_box_extends_five_pixels(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
<slide xmlns="http://www.larkoffice.com/sml/2.0">
|
||||
<data>
|
||||
<shape id="case4-card" type="rect" topLeftX="300" topLeftY="245" width="360" height="120"/>
|
||||
<shape id="case4-text" 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_allows_text_inside_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="170" width="400" height="310"/>
|
||||
<shape id="caption" type="text" topLeftX="80" topLeftY="455" width="360" height="20">
|
||||
<content fontSize="13"><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_allows_horizontal_text_with_default_wrap(self) -> None:
|
||||
result = xml_text_overlap_lint.lint_xml(
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user