diff --git a/skills/lark-slides/references/xml-schema-quick-ref.md b/skills/lark-slides/references/xml-schema-quick-ref.md index 11a21e58d5..d2943f5d45 100644 --- a/skills/lark-slides/references/xml-schema-quick-ref.md +++ b/skills/lark-slides/references/xml-schema-quick-ref.md @@ -17,7 +17,7 @@ - +

标题

@@ -133,7 +133,7 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出 示例: ```xml - +

正文内容 加粗 斜体 链接

  • 列表项 1

  • @@ -351,7 +351,7 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出 ```xml - +

    这是演讲者备注。

    diff --git a/skills/lark-slides/scripts/xml_text_overlap_lint.py b/skills/lark-slides/scripts/xml_text_overlap_lint.py index e87ecee247..811a6f71ca 100644 --- a/skills/lark-slides/scripts/xml_text_overlap_lint.py +++ b/skills/lark-slides/scripts/xml_text_overlap_lint.py @@ -38,6 +38,10 @@ "fontColor": "color", } SERVER_FILLED_SXSD_ATTRS = {"id"} +ROUNDTRIP_SXSD_ATTRS = { + ("chart", "updated"), + ("chartData", "isStaticData"), +} DEFAULT_TABLE_COLUMN_WIDTH = 110 DEFAULT_TABLE_ROW_HEIGHT = 37 _SXSD_TAG_ATTRIBUTES_CACHE: dict[str, set[str]] | None = None @@ -168,8 +172,10 @@ def solve_weighted_min_layout( return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": ratio} -def strip_xml(value: str) -> str: +def strip_xml(value: str, preserve_line_breaks: bool = False) -> str: stripped = re.sub(r"", r"\1", value) + if preserve_line_breaks: + stripped = re.sub(r"]*>", "\n", stripped) stripped = re.sub(r"<[^>]+>", " ", stripped) stripped = stripped.replace(" ", " ") stripped = stripped.replace("&", "&") @@ -177,14 +183,35 @@ def strip_xml(value: str) -> str: stripped = stripped.replace(">", ">") stripped = stripped.replace(""", '"') stripped = stripped.replace("'", "'") + if preserve_line_breaks: + return "\n".join(re.sub(r"\s+", " ", line).strip() for line in stripped.split("\n")) return re.sub(r"\s+", " ", stripped).strip() def strip_xml_paragraphs(value: str) -> str: paragraphs = re.findall(r"]*>([\s\S]*?)", value) if paragraphs: - return "\n".join(strip_xml(paragraph) for paragraph in paragraphs) - return strip_xml(value) + return "\n".join(strip_xml(paragraph, preserve_line_breaks=True) for paragraph in paragraphs) + return strip_xml(value, preserve_line_breaks=True) + + +def extract_text_paragraphs(value: str) -> list[dict[str, str | None]]: + paragraphs = [] + for attrs, body in re.findall(r"]*)>([\s\S]*?)", value): + paragraphs.append( + { + "text": strip_xml(body, preserve_line_breaks=True), + "lineSpacing": extract_attribute(attrs, "lineSpacing"), + "beforeLineSpacing": extract_attribute(attrs, "beforeLineSpacing"), + "afterLineSpacing": extract_attribute(attrs, "afterLineSpacing"), + } + ) + return paragraphs + + +def extract_tag_attributes(value: str, tag: str) -> str: + match = re.search(fr"<{re.escape(tag)}\b([^>]*)>", value) + return match.group(1) if match else "" def xml_local_name(tag: str) -> str: @@ -319,8 +346,8 @@ def should_skip_sxsd_subtree(element: ET.Element, ancestors: list[str]) -> bool: return "whiteboard" in ancestors and xml_namespace(element.tag) == SVG_NS -def should_skip_sxsd_attribute(attr_name: str) -> bool: - return attr_name in SERVER_FILLED_SXSD_ATTRS +def should_skip_sxsd_attribute(tag_name: str, attr_name: str) -> bool: + return attr_name in SERVER_FILLED_SXSD_ATTRS or (tag_name, attr_name) in ROUNDTRIP_SXSD_ATTRS def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]: @@ -352,7 +379,7 @@ def visit(element: ET.Element, ancestors: list[str], path: str) -> None: if raw_attr_name.startswith(XML_NS): continue attr_name = xml_local_name(raw_attr_name) - if should_skip_sxsd_attribute(attr_name): + if should_skip_sxsd_attribute(tag_name, attr_name): continue if attr_name in allowed_attrs: continue @@ -635,15 +662,24 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]: } ) if kind == "shape": + content_attrs = extract_tag_attributes(content, "content") + font_size = extract_numeric_attribute(content_attrs, "fontSize") + if font_size is None: + font_size = extract_numeric_attribute(attrs, "fontSize") element.update( { - "textType": extract_attribute(content, "textType"), - "textAlign": extract_attribute(content, "textAlign"), - "autoFit": extract_attribute(content, "autoFit"), - "fontSize": float( - extract_attribute(content, "fontSize") or extract_attribute(attrs, "fontSize") or 16 - ), + "textType": extract_attribute(content_attrs, "textType"), + "textAlign": extract_attribute(content_attrs, "textAlign"), + "autoFit": extract_attribute(content_attrs, "autoFit"), + "wrap": extract_attribute(content_attrs, "wrap"), + "lineSpacing": extract_attribute(content_attrs, "lineSpacing"), + "beforeLineSpacing": extract_attribute(content_attrs, "beforeLineSpacing"), + "afterLineSpacing": extract_attribute(content_attrs, "afterLineSpacing"), + "paddingTop": extract_numeric_attribute(content_attrs, "paddingTop") or 0, + "paddingBottom": extract_numeric_attribute(content_attrs, "paddingBottom") or 0, + "fontSize": font_size if font_size is not None else 16, "text": strip_xml_paragraphs(content), + "paragraphs": extract_text_paragraphs(content), } ) elements.append(element) @@ -708,14 +744,105 @@ 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(element: dict[str, Any]) -> int: +def estimate_text_line_count_for_text(element: dict[str, Any], text: str) -> int: font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16 - paragraphs = [paragraph for paragraph in re.split(r"\n+", element["text"]) if paragraph] + hard_lines = text.split("\n") + if not text: + return 0 line_count = 0 - for paragraph in paragraphs: - logical_width = max(estimate_text_width(paragraph, font_size), 1) + for hard_line in hard_lines: + 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))) - return max(line_count, 1) + return line_count + + +def estimate_text_line_count(element: dict[str, Any]) -> int: + return max(estimate_text_line_count_for_text(element, element["text"]), 1) + + +def estimate_text_line_height(element: dict[str, Any], line_spacing: str | None = None) -> int | float | None: + font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16 + line_spacing = line_spacing or "multiple:1.5" + match = re.fullmatch(r"(multiple|fixed):([0-9]+(?:\.[0-9]+)?)", line_spacing) + if match is None: + return None + spacing_type, value = match.groups() + return font_size * float(value) if spacing_type == "multiple" else float(value) + + +def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict[str, Any]]: + issues: list[dict[str, Any]] = [] + 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 [ + { + "text": element["text"], + "lineSpacing": None, + "beforeLineSpacing": None, + "afterLineSpacing": None, + } + ] + line_count = 0 + 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"]) + if paragraph_line_count == 0: + continue + line_height = estimate_text_line_height(element, paragraph["lineSpacing"] or element["lineSpacing"]) + before_spacing = estimate_text_line_height( + element, paragraph["beforeLineSpacing"] or element["beforeLineSpacing"] or "fixed:0" + ) + after_spacing = estimate_text_line_height( + element, paragraph["afterLineSpacing"] or element["afterLineSpacing"] or "fixed:0" + ) + if line_height is None or before_spacing is None or after_spacing is None: + line_count = 0 + break + first_line_height = font_size if line_count == 0 else line_height + line_count += paragraph_line_count + line_heights.append(line_height) + estimated_height += ( + before_spacing + first_line_height + max(paragraph_line_count - 1, 0) * line_height + after_spacing + ) + if line_count == 0: + continue + available_height = max(element["height"] - element["paddingTop"] - element["paddingBottom"], 0) + overflow = estimated_height - available_height + if overflow <= 0: + continue + + issues.append( + { + "level": "warning", + "code": "text_may_overflow_shape", + "elements": [element["id"]], + "line_count": line_count, + "line_height": max(line_heights), + "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"' + ), + "hint": ( + "Increase shape.height, reduce the text, or set content wrap=\"true\" " + "autoFit=\"normal-auto-fit\". " + "This is an estimate based on font size, line spacing, and wrapped line count." + ), + } + ) + return issues def estimate_text_visual_bbox(element: dict[str, Any]) -> dict[str, int | float] | None: @@ -1116,6 +1243,7 @@ def lint_slide( *detect_whiteboard_external_overlaps(elements, slide_width, slide_height), *detect_elements_out_of_canvas(elements, slide_width, slide_height), *detect_table_layout_size_mismatches(elements), + *detect_text_may_overflow_shapes(elements), ] for index, left in enumerate(elements): diff --git a/skills/lark-slides/scripts/xml_text_overlap_lint_test.py b/skills/lark-slides/scripts/xml_text_overlap_lint_test.py index 87eb0a830f..131170c43a 100644 --- a/skills/lark-slides/scripts/xml_text_overlap_lint_test.py +++ b/skills/lark-slides/scripts/xml_text_overlap_lint_test.py @@ -311,6 +311,42 @@ def test_lint_xml_ignores_server_filled_id_attrs(self) -> None: self.assertEqual(issue["tag"], "fill") self.assertEqual(issue["attr"], "unexpected") + def test_lint_xml_ignores_chart_roundtrip_attrs(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + + + + + """ + ) + + 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( + """ + + + + + + + + """ + ) + + self.assertEqual(result["summary"]["error_count"], 2) + self.assertEqual( + {(issue["tag"], issue["attr"]) for issue in result["issues"]}, + {("chart", "isStaticData"), ("chartData", "updated")}, + ) + self.assertTrue(all(issue["code"] == "sxsd_unsupported_attr" for issue in result["issues"])) + def test_lint_xml_reports_gradient_shorthand_attrs_on_fill_color(self) -> None: result = xml_text_overlap_lint.lint_xml( """ @@ -523,6 +559,7 @@ def test_lint_xml_detects_overlapping_text_boxes(self) -> None: """ ) self.assertEqual(result["summary"]["error_count"], 1) + self.assertEqual(result["summary"]["warning_count"], 0) self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap") def test_lint_xml_detects_current_itinerary_cjk_caption_occlusion(self) -> None: @@ -599,10 +636,28 @@ def test_lint_xml_detects_horizontal_text_overflow_across_declared_box_gap(self) """ ) self.assertEqual(result["summary"]["error_count"], 1) + self.assertEqual(result["summary"]["warning_count"], 0) self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap") self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["source", "target"]) - def test_lint_xml_reports_text_out_of_canvas_but_not_text_height(self) -> None: + def test_lint_xml_allows_horizontal_text_with_default_wrap(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + +

    这是一个足够长的中文文本用于检测默认自动换行

    +
    + +

    目标

    +
    +
    +
    + """ + ) + self.assertEqual(result["summary"]["error_count"], 0) + + def test_lint_xml_reports_text_out_of_canvas_and_warns_for_text_height(self) -> None: result = xml_text_overlap_lint.lint_xml( """ @@ -621,10 +676,98 @@ def test_lint_xml_reports_text_out_of_canvas_but_not_text_height(self) -> None: ) issue = result["slides"][0]["issues"][0] self.assertEqual(result["summary"]["error_count"], 1) - self.assertEqual(result["summary"]["warning_count"], 0) + self.assertEqual(result["summary"]["warning_count"], 1) self.assertEqual(issue["code"], "shape_out_of_canvas") self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 160, "bottom": 40}) + def test_lint_xml_warns_when_text_may_overflow_its_own_shape(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + +

    第一段

    第二段

    第三段

    第四段

    +
    +
    + + +

    第一段

    第二段

    第三段

    第四段

    +
    +
    + + +

    第一段

    第二段

    第三段

    第四段

    +
    +
    +
    +
    + """ + ) + 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"]) + + def test_lint_xml_uses_fixed_line_spacing_for_text_height_warning(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + +

    第一段

    第二段

    第三段

    +
    +
    +
    +
    + """ + ) + issue = result["slides"][0]["issues"][0] + self.assertEqual(result["summary"]["warning_count"], 1) + self.assertEqual(issue["line_height"], 20) + self.assertEqual(issue["estimated_height"], 60) + self.assertEqual(issue["overflow"], 10) + + def test_lint_xml_uses_paragraph_spacing_overrides_for_text_height_warning(self) -> None: + result = xml_text_overlap_lint.lint_xml( + """ + + + + +

    第一行
    第二行

    +
    +
    + + +

    第一行
    第二行
    第三行

    +
    +
    +
    +
    + """ + ) + issues = result["slides"][0]["issues"] + self.assertEqual(result["summary"]["warning_count"], 1) + self.assertEqual(issues[0]["elements"], ["paragraph-overflow"]) + self.assertEqual(issues[0]["line_count"], 2) + self.assertEqual(issues[0]["line_height"], 10) + self.assertEqual(issues[0]["estimated_height"], 40) + self.assertEqual(issues[0]["overflow"], 5) + + def test_strip_xml_paragraphs_preserves_br_as_hard_line_break(self) -> None: + self.assertEqual( + xml_text_overlap_lint.strip_xml_paragraphs("

    第一行
    第二行
    第三行

    "), + "第一行\n第二行\n第三行", + ) + def test_lint_xml_allows_template_style_bleed_and_text_over_images(self) -> None: result = xml_text_overlap_lint.lint_xml( """