mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
11 Commits
feat/plugi
...
feat/slide
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20c396b404 | ||
|
|
a01279011e | ||
|
|
c205954000 | ||
|
|
48b7ec70b2 | ||
|
|
adf1ac4326 | ||
|
|
dd84702b40 | ||
|
|
e4db9630f3 | ||
|
|
ac4637f8ce | ||
|
|
266ec5455b | ||
|
|
3074cdb2b8 | ||
|
|
8f916dd561 |
@@ -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"},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
获取幻灯片页面截图并保存为本地图片文件。默认用于已存在 PPT 页面截图;传入 `--content` 时用于直接渲染单个 `<slide>` XML 片段预览。本 shortcut 会在 CLI 进程内解码并写入文件,stdout 只返回文件路径、大小、页面 ID 等元信息,避免把图片 Base64 输出给模型。
|
||||
|
||||
注意:该截图能力受应用白名单限制,绝大多数应用不可用。截图失败时不要引导用户申请 `slides:presentation:screenshot` 权限;记录错误后降级到 XML 读回、结构 lint、文本重叠检查等非截图检查路径。
|
||||
截图失败则降级到 XML 读回、结构 lint等非截图检查路径。
|
||||
|
||||
## 命令
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user