@amaster.ai/pi-lark 0.1.2-beta.47 → 0.1.2-beta.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +5 -1
  2. package/dist/config.d.ts +1 -1
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +2 -2
  5. package/dist/config.js.map +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +2 -1
  8. package/dist/index.js.map +1 -1
  9. package/package.json +3 -3
  10. package/skills/lark-base/SKILL.md +5 -3
  11. package/skills/lark-base/references/lark-base-filter-condition.md +179 -0
  12. package/skills/lark-base/references/lark-base-form-questions-create.md +34 -4
  13. package/skills/lark-base/references/lark-base-form-questions-update.md +73 -20
  14. package/skills/lark-base/references/lark-base-view-set-filter.md +11 -137
  15. package/skills/lark-calendar/SKILL.md +12 -6
  16. package/skills/lark-calendar/references/lark-calendar-create.md +6 -6
  17. package/skills/lark-calendar/references/lark-calendar-room-find.md +2 -1
  18. package/skills/lark-calendar/references/lark-calendar-schedule-clear-time.md +1 -0
  19. package/skills/lark-calendar/references/lark-calendar-suggestion.md +1 -1
  20. package/skills/lark-calendar/references/lark-calendar-update.md +7 -4
  21. package/skills/lark-contact/SKILL.md +18 -2
  22. package/skills/lark-contact/references/lark-contact-search-bot.md +60 -0
  23. package/skills/lark-drive/SKILL.md +5 -1
  24. package/skills/lark-drive/references/lark-drive-member-list.md +65 -0
  25. package/skills/lark-drive/references/lark-drive-permission-get-setting.md +48 -0
  26. package/skills/lark-drive/references/lark-drive-search.md +6 -1
  27. package/skills/lark-drive/references/lark-drive-secure-label.md +1 -1
  28. package/skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md +38 -8
  29. package/skills/lark-drive/references/lark-drive-workflow-permission-governance-outputs.md +10 -10
  30. package/skills/lark-drive/references/lark-drive-workflow-permission-governance.md +22 -20
  31. package/skills/lark-slides/SKILL.md +16 -26
  32. package/skills/lark-slides/references/lark-slides-create.md +14 -5
  33. package/skills/lark-slides/references/slides_xml_schema_definition.xml +491 -31
  34. package/skills/lark-slides/references/xml-schema-quick-ref.md +39 -0
  35. package/skills/lark-slides/scripts/xml_text_overlap_lint.py +424 -32
  36. package/skills/lark-slides/scripts/xml_text_overlap_lint_test.py +840 -58
  37. package/skills/lark-task/references/lark-task-create.md +9 -0
@@ -49,6 +49,24 @@ ROUNDTRIP_SXSD_ATTRS = {
49
49
  ROUNDTRIP_SXSD_TAGS = {"chartParsedValues"}
50
50
  DEFAULT_TABLE_COLUMN_WIDTH = 110
51
51
  DEFAULT_TABLE_ROW_HEIGHT = 37
52
+ DEFAULT_TEXT_LINE_SPACING_MULTIPLE = 1.5
53
+ TEXT_WRAP_WIDTH_TOLERANCE_PX = 1.0
54
+ TEXT_HEIGHT_OVERFLOW_TOLERANCE_PX = 0.5
55
+ SINGLE_LINE_METRIC_WIDTH_RATIO = 1.18
56
+ CENTERED_SHORT_LABEL_WIDTH_RATIO = 1.12
57
+ HEADLINE_NEAR_FIT_WIDTH_RATIO = 1.04
58
+ DENSE_BODY_LINE_SPACING_MAX_MULTIPLE = 1.6
59
+ GHOST_TEXT_MIN_FONT_SIZE = 96
60
+ GHOST_TEXT_MAX_ALPHA = 0.5
61
+ GHOST_TEXT_FAINT_MIN_FONT_SIZE = 36
62
+ GHOST_TEXT_FAINT_MAX_ALPHA = 0.35
63
+ # A <line> crossing text glyphs is a legibility defect (see line_crosses_text_glyphs). We erode the
64
+ # glyph box by this margin before testing intersection so a line that only skims a glyph edge or the
65
+ # padding-only text frame -- but does not actually cut through the letterforms -- is not flagged.
66
+ LINE_TEXT_GRAZE_MIN_PX = 2.0
67
+ LINE_TEXT_GRAZE_FONT_RATIO = 0.12
68
+ # A line whose effective stroke alpha is below this is not visibly rendered, so it cannot occlude text.
69
+ LINE_MIN_VISIBLE_ALPHA = 0.08
52
70
  # Sub-pixel canvas overflow is floating-point rounding noise (e.g. rotated-bbox math), not a
53
71
  # visible defect; keep this well under 1px so real overflow is still always caught.
54
72
  CANVAS_OVERFLOW_TOLERANCE = 0.5
@@ -106,6 +124,52 @@ def extract_numeric_attribute(tag_source: str, name: str) -> int | float | None:
106
124
  return int(value) if value.is_integer() else value
107
125
 
108
126
 
127
+ def extract_bool_attribute(tag_source: str, name: str) -> bool:
128
+ value = extract_attribute(tag_source, name)
129
+ return value in {"true", "1", "yes"}
130
+
131
+
132
+ def extract_color_alpha(color: str | None) -> int | float | None:
133
+ if color is None:
134
+ return None
135
+ normalized = re.sub(r"\s+", "", color).lower()
136
+ if normalized == "transparent":
137
+ return 0
138
+ rgba_match = re.fullmatch(
139
+ r"rgba\([^,]+,[^,]+,[^,]+,([+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+))\)",
140
+ normalized,
141
+ )
142
+ if rgba_match is None:
143
+ return None
144
+ try:
145
+ alpha = float(rgba_match.group(1))
146
+ except ValueError:
147
+ return None
148
+ return int(alpha) if alpha.is_integer() else alpha
149
+
150
+
151
+ def effective_text_alpha(shape_alpha: int | float | None, text_color: str | None) -> int | float:
152
+ base_alpha = shape_alpha if isinstance(shape_alpha, (int, float)) else 1
153
+ color_alpha = extract_color_alpha(text_color)
154
+ if not isinstance(color_alpha, (int, float)):
155
+ return base_alpha
156
+ return base_alpha * color_alpha
157
+
158
+
159
+ def detect_inline_style_presence(content_xml: str, style_tags: set[str]) -> bool:
160
+ for tag_name in style_tags:
161
+ if re.search(fr"<{re.escape(tag_name)}\b[\s>]", content_xml) is not None:
162
+ return True
163
+ return False
164
+
165
+
166
+ def detect_any_span_bool_attribute(content_xml: str, attr_name: str) -> bool:
167
+ for attrs in re.findall(r"<span\b([^>]*)>", content_xml):
168
+ if extract_bool_attribute(attrs, attr_name):
169
+ return True
170
+ return False
171
+
172
+
109
173
  def sum_sizes(sizes: list[int | float]) -> int | float:
110
174
  return sum(sizes)
111
175
 
@@ -218,6 +282,7 @@ def extract_text_paragraphs(value: str, default_font_size: int | float) -> list[
218
282
  "lineSpacing": extract_attribute(attrs, "lineSpacing"),
219
283
  "beforeLineSpacing": extract_attribute(attrs, "beforeLineSpacing"),
220
284
  "afterLineSpacing": extract_attribute(attrs, "afterLineSpacing"),
285
+ "letterSpacing": extract_numeric_attribute(attrs, "letterSpacing"),
221
286
  }
222
287
  )
223
288
  return paragraphs
@@ -694,6 +759,20 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
694
759
  font_size = extract_numeric_attribute(content_attrs, "fontSize")
695
760
  if font_size is None:
696
761
  font_size = extract_numeric_attribute(attrs, "fontSize")
762
+ font_family = extract_attribute(content_attrs, "fontFamily") or extract_attribute(attrs, "fontFamily")
763
+ text_color = extract_attribute(content_attrs, "color") or extract_attribute(attrs, "color")
764
+ bold = (
765
+ extract_bool_attribute(content_attrs, "bold")
766
+ or extract_bool_attribute(attrs, "bold")
767
+ or detect_inline_style_presence(content, {"strong", "b"})
768
+ or detect_any_span_bool_attribute(content, "bold")
769
+ )
770
+ italic = (
771
+ extract_bool_attribute(content_attrs, "italic")
772
+ or extract_bool_attribute(attrs, "italic")
773
+ or detect_inline_style_presence(content, {"i", "em"})
774
+ or detect_any_span_bool_attribute(content, "italic")
775
+ )
697
776
  element.update(
698
777
  {
699
778
  "textType": extract_attribute(content_attrs, "textType"),
@@ -705,11 +784,17 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
705
784
  "lineSpacing": extract_attribute(content_attrs, "lineSpacing"),
706
785
  "beforeLineSpacing": extract_attribute(content_attrs, "beforeLineSpacing"),
707
786
  "afterLineSpacing": extract_attribute(content_attrs, "afterLineSpacing"),
787
+ "letterSpacing": extract_numeric_attribute(content_attrs, "letterSpacing"),
708
788
  "paddingTop": extract_numeric_attribute(content_attrs, "paddingTop") or 0,
709
789
  "paddingRight": extract_numeric_attribute(content_attrs, "paddingRight") or 0,
710
790
  "paddingBottom": extract_numeric_attribute(content_attrs, "paddingBottom") or 0,
711
791
  "paddingLeft": extract_numeric_attribute(content_attrs, "paddingLeft") or 0,
712
792
  "fontSize": font_size if font_size is not None else 16,
793
+ "fontFamily": font_family or "",
794
+ "color": text_color,
795
+ "textAlpha": effective_text_alpha(alpha, text_color),
796
+ "bold": bold,
797
+ "italic": italic,
713
798
  "text": strip_xml_paragraphs(content),
714
799
  "paragraphs": extract_text_paragraphs(content, font_size if font_size is not None else 16),
715
800
  }
@@ -745,7 +830,11 @@ def is_vertical_text(element: dict[str, Any]) -> bool:
745
830
 
746
831
  def detect_image_text_occlusions(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
747
832
  issues: list[dict[str, Any]] = []
748
- text_elements = [element for element in elements if is_text_element(element) and has_text_content(element)]
833
+ text_elements = [
834
+ element
835
+ for element in elements
836
+ if is_text_element(element) and has_text_content(element) and not is_ghost_text(element)
837
+ ]
749
838
  image_elements = [element for element in elements if element["kind"] == "img" and element["alpha"] > 0]
750
839
  for text_element in text_elements:
751
840
  for image_element in image_elements:
@@ -782,22 +871,135 @@ def normalize_text_for_overlap(text: str) -> str:
782
871
  return re.sub(r"\s+", "", text)
783
872
 
784
873
 
785
- def estimate_character_width(character: str, font_size: int | float) -> int | float:
874
+ SERIF_FONT_PATTERNS = {
875
+ "song", "songti", "simsun", "ming", "mincho",
876
+ "georgia", "times", "caslon", "garamond", "sourcehan-serif",
877
+ "source han serif", "思源宋体", "宋体", "明体",
878
+ }
879
+
880
+ SANS_EXPLICIT_MARKERS = {"sans", "sans-serif", "sans serif", "sourcehan-sans", "source han sans", "思源黑体", "黑体",
881
+ "helvetica", "arial", "inter", "roboto", "verdana", "tahoma", "calibri", "open sans"}
882
+
883
+
884
+ def classify_font_family(font_family: str | None) -> str:
885
+ if not font_family:
886
+ return "sans"
887
+ family_lower = font_family.lower()
888
+ for marker in SANS_EXPLICIT_MARKERS:
889
+ if marker in family_lower:
890
+ return "sans"
891
+ serif_keywords = SERIF_FONT_PATTERNS | {"serif"}
892
+ for pattern in serif_keywords:
893
+ if pattern in family_lower:
894
+ return "serif"
895
+ return "sans"
896
+
897
+
898
+ _FONT_CATEGORY_MULTIPLIERS: dict[str, dict[str, float]] = {
899
+ "sans": {"upper": 0.57, "lower": 0.51, "digit": 0.58, "punct": 0.50},
900
+ "serif": {"upper": 0.57, "lower": 0.53, "digit": 0.58, "punct": 0.50},
901
+ }
902
+
903
+
904
+ def estimate_character_width(
905
+ character: str,
906
+ font_size: int | float,
907
+ bold: bool = False,
908
+ font_family: str | None = None,
909
+ ) -> int | float:
910
+ bold_multiplier = 1.05 if bold else 1.0
786
911
  if character.isspace():
787
- return font_size * 0.33
788
- if unicodedata.east_asian_width(character) in {"F", "W"}:
789
- return font_size
790
- return font_size * 0.55
912
+ return font_size * 0.33 * bold_multiplier
913
+ ea_width = unicodedata.east_asian_width(character)
914
+ if ea_width in {"F", "W"}:
915
+ return font_size * bold_multiplier
916
+ category = classify_font_family(font_family)
917
+ coeffs = _FONT_CATEGORY_MULTIPLIERS[category]
918
+ if character.isupper():
919
+ return font_size * coeffs["upper"] * bold_multiplier
920
+ if character.islower():
921
+ return font_size * coeffs["lower"] * bold_multiplier
922
+ if character.isdigit():
923
+ return font_size * coeffs["digit"] * bold_multiplier
924
+ return font_size * coeffs["punct"] * bold_multiplier
925
+
926
+
927
+ def estimate_text_width(
928
+ text: str,
929
+ font_size: int | float,
930
+ letter_spacing: int | float = 0,
931
+ bold: bool = False,
932
+ font_family: str | None = None,
933
+ ) -> int | float:
934
+ base = sum(estimate_character_width(character, font_size, bold, font_family) for character in text)
935
+ return base + max(len(text) - 1, 0) * letter_spacing
936
+
937
+
938
+ def resolve_letter_spacing(element: dict[str, Any], paragraph: dict[str, Any] | None = None) -> int | float:
939
+ if paragraph is not None:
940
+ value = paragraph.get("letterSpacing")
941
+ if isinstance(value, (int, float)):
942
+ return value
943
+ value = element.get("letterSpacing")
944
+ return value if isinstance(value, (int, float)) else 0
945
+
946
+
947
+ def text_wrap_width_tolerance() -> int | float:
948
+ return TEXT_WRAP_WIDTH_TOLERANCE_PX
949
+
950
+
951
+ def text_height_overflow_tolerance() -> int | float:
952
+ return TEXT_HEIGHT_OVERFLOW_TOLERANCE_PX
953
+
954
+
955
+ def has_explicit_height_auto_fit(element: dict[str, Any]) -> bool:
956
+ return element.get("autoFit") in {"normal-auto-fit", "shape-auto-fit"}
957
+
958
+
959
+ def is_short_metric_text(text: str) -> bool:
960
+ compact = re.sub(r"\s+", "", text)
961
+ if not compact or len(compact) > 16 or re.search(r"\d", compact) is None:
962
+ return False
963
+ if re.fullmatch(r"[+\-–—]?[0-9,.,]+[\u4e00-\u9fffA-Za-z]{1,4}", compact):
964
+ return True
965
+ if re.search(r"[,.,+\-–—/%%]", compact) is None:
966
+ return False
967
+ return re.fullmatch(r"[+\-–—]?[0-9A-Za-z,.,/%%\-–—\u4e00-\u9fff]+", compact) is not None
968
+
969
+
970
+ def is_single_line_visual_candidate(
971
+ element: dict[str, Any],
972
+ paragraph: dict[str, Any] | None,
973
+ text: str,
974
+ logical_width: int | float,
975
+ effective_width: int | float,
976
+ ) -> bool:
977
+ if "\n" in text or logical_width <= effective_width:
978
+ return False
979
+ if is_short_metric_text(text):
980
+ return logical_width <= effective_width * SINGLE_LINE_METRIC_WIDTH_RATIO
791
981
 
982
+ text_align = (paragraph or {}).get("textAlign") or element.get("textAlign")
983
+ compact_len = len(re.sub(r"\s+", "", text))
984
+ if text_align == "center" and compact_len <= 32:
985
+ return logical_width <= effective_width * CENTERED_SHORT_LABEL_WIDTH_RATIO
792
986
 
793
- def estimate_text_width(text: str, font_size: int | float) -> int | float:
794
- return sum(estimate_character_width(character, font_size) for character in text)
987
+ font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
988
+ if element.get("textType") in {"headline", "title"} and font_size <= 30 and compact_len <= 40:
989
+ return logical_width <= effective_width * HEADLINE_NEAR_FIT_WIDTH_RATIO
990
+ return False
795
991
 
796
992
 
797
993
  def estimate_text_max_line_width(element: dict[str, Any]) -> int | float:
798
994
  font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
995
+ bold = element.get("bold", False)
996
+ font_family = element.get("fontFamily", "")
997
+ letter_spacing = resolve_letter_spacing(element)
799
998
  paragraphs = [paragraph for paragraph in re.split(r"\n+", element["text"]) if paragraph]
800
- return max([estimate_text_width(paragraph, font_size) for paragraph in paragraphs] or [1])
999
+ return max(
1000
+ [estimate_text_width(paragraph, font_size, letter_spacing, bold, font_family) for paragraph in paragraphs]
1001
+ or [1]
1002
+ )
801
1003
 
802
1004
 
803
1005
  def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool:
@@ -810,8 +1012,14 @@ def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool
810
1012
  return SequenceMatcher(None, left_text, right_text).ratio() >= 0.75
811
1013
 
812
1014
 
813
- def estimate_text_line_count_for_text(element: dict[str, Any], text: str) -> int:
1015
+ def estimate_text_line_count_for_text(
1016
+ element: dict[str, Any], text: str, paragraph: dict[str, Any] | None = None
1017
+ ) -> int:
814
1018
  font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
1019
+ bold = element.get("bold", False)
1020
+ font_family = element.get("fontFamily", "")
1021
+ letter_spacing = resolve_letter_spacing(element, paragraph)
1022
+ available_width = max(element["width"] - element.get("paddingLeft", 0) - element.get("paddingRight", 0), 1)
815
1023
  hard_lines = text.split("\n")
816
1024
  if not text:
817
1025
  return 0
@@ -820,8 +1028,12 @@ def estimate_text_line_count_for_text(element: dict[str, Any], text: str) -> int
820
1028
  if element.get("wrap") in {"false", "0"}:
821
1029
  line_count += 1
822
1030
  continue
823
- logical_width = max(estimate_text_width(hard_line, font_size), 1)
824
- line_count += max(1, math.ceil(logical_width / max(element["width"], 1)))
1031
+ logical_width = max(estimate_text_width(hard_line, font_size, letter_spacing, bold, font_family), 1)
1032
+ effective_width = available_width + text_wrap_width_tolerance()
1033
+ if is_single_line_visual_candidate(element, paragraph, hard_line, logical_width, effective_width):
1034
+ line_count += 1
1035
+ continue
1036
+ line_count += max(1, math.ceil(logical_width / effective_width))
825
1037
  return line_count
826
1038
 
827
1039
 
@@ -831,7 +1043,8 @@ def estimate_text_line_count(element: dict[str, Any]) -> int:
831
1043
 
832
1044
  def estimate_text_line_height(element: dict[str, Any], line_spacing: str | None = None) -> int | float | None:
833
1045
  font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
834
- line_spacing = line_spacing or "multiple:1.5"
1046
+ if line_spacing is None:
1047
+ return font_size * DEFAULT_TEXT_LINE_SPACING_MULTIPLE
835
1048
  match = re.fullmatch(r"(multiple|fixed):([0-9]+(?:\.[0-9]+)?)", line_spacing)
836
1049
  if match is None:
837
1050
  return None
@@ -839,12 +1052,27 @@ def estimate_text_line_height(element: dict[str, Any], line_spacing: str | None
839
1052
  return font_size * float(value) if spacing_type == "multiple" else float(value)
840
1053
 
841
1054
 
1055
+ def adjust_dense_body_line_height(
1056
+ element: dict[str, Any],
1057
+ line_spacing: str | None,
1058
+ line_height: int | float,
1059
+ paragraph_count: int,
1060
+ ) -> int | float:
1061
+ font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
1062
+ if paragraph_count < 4 or font_size > 14 or not line_spacing:
1063
+ return line_height
1064
+ match = re.fullmatch(r"multiple:([0-9]+(?:\.[0-9]+)?)", line_spacing)
1065
+ if match is None:
1066
+ return line_height
1067
+ return min(line_height, font_size * min(float(match.group(1)), DENSE_BODY_LINE_SPACING_MAX_MULTIPLE))
1068
+
1069
+
842
1070
  def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
843
1071
  issues: list[dict[str, Any]] = []
844
1072
  for element in elements:
845
1073
  if not is_text_element(element) or not has_text_content(element):
846
1074
  continue
847
- if element.get("autoFit") in {"normal-auto-fit", "shape-auto-fit"}:
1075
+ if has_explicit_height_auto_fit(element):
848
1076
  continue
849
1077
 
850
1078
  font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
@@ -860,10 +1088,11 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
860
1088
  estimated_height = 0.0
861
1089
  line_heights: list[int | float] = []
862
1090
  for paragraph in paragraphs:
863
- paragraph_line_count = estimate_text_line_count_for_text(element, paragraph["text"])
1091
+ paragraph_line_count = estimate_text_line_count_for_text(element, paragraph["text"], paragraph)
864
1092
  if paragraph_line_count == 0:
865
1093
  continue
866
- line_height = estimate_text_line_height(element, paragraph["lineSpacing"] or element["lineSpacing"])
1094
+ resolved_line_spacing = paragraph["lineSpacing"] or element["lineSpacing"]
1095
+ line_height = estimate_text_line_height(element, resolved_line_spacing)
867
1096
  before_spacing = estimate_text_line_height(
868
1097
  element, paragraph["beforeLineSpacing"] or element["beforeLineSpacing"] or "fixed:0"
869
1098
  )
@@ -873,6 +1102,7 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
873
1102
  if line_height is None or before_spacing is None or after_spacing is None:
874
1103
  line_count = 0
875
1104
  break
1105
+ line_height = adjust_dense_body_line_height(element, resolved_line_spacing, line_height, len(paragraphs))
876
1106
  first_line_height = font_size if line_count == 0 else line_height
877
1107
  line_count += paragraph_line_count
878
1108
  line_heights.append(line_height)
@@ -883,12 +1113,24 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
883
1113
  continue
884
1114
  available_height = max(element["height"] - element["paddingTop"] - element["paddingBottom"], 0)
885
1115
  overflow = estimated_height - available_height
886
- if overflow <= 0:
1116
+ if overflow <= text_height_overflow_tolerance():
887
1117
  continue
888
1118
 
1119
+ is_background = is_background_decorative_text(element, elements)
1120
+ if is_background:
1121
+ level = "info"
1122
+ else:
1123
+ level = "error" if overflow > 10 else "warning"
1124
+ message = (
1125
+ f'text shape {element["id"]} may overflow its own content box '
1126
+ f'(estimated {estimated_height:g}px, available {available_height:g}px); '
1127
+ 'consider setting content wrap="true" autoFit="normal-auto-fit"'
1128
+ )
1129
+ if is_background:
1130
+ message += " (likely background decoration: large font, low alpha, underneath other text)"
889
1131
  issues.append(
890
1132
  {
891
- "level": "warning",
1133
+ "level": level,
892
1134
  "code": "text_may_overflow_shape",
893
1135
  "elements": [element["id"]],
894
1136
  "line_count": line_count,
@@ -896,11 +1138,7 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
896
1138
  "estimated_height": estimated_height,
897
1139
  "available_height": available_height,
898
1140
  "overflow": overflow,
899
- "message": (
900
- f'text shape {element["id"]} may overflow its own content box '
901
- f'(estimated {estimated_height:g}px, available {available_height:g}px); '
902
- 'consider setting content wrap="true" autoFit="normal-auto-fit"'
903
- ),
1141
+ "message": message,
904
1142
  "hint": (
905
1143
  "Increase shape.height, reduce the text, or set content wrap=\"true\" "
906
1144
  "autoFit=\"normal-auto-fit\". "
@@ -911,6 +1149,38 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
911
1149
  return issues
912
1150
 
913
1151
 
1152
+ def is_background_decorative_text(
1153
+ element: dict[str, Any], elements: list[dict[str, Any]]
1154
+ ) -> bool:
1155
+ if not is_ghost_text(element):
1156
+ return False
1157
+ for other in elements:
1158
+ if other is element:
1159
+ continue
1160
+ if not is_text_element(other) or not has_text_content(other):
1161
+ continue
1162
+ foreground_alpha = other.get("textAlpha", other.get("alpha", 1))
1163
+ if not isinstance(foreground_alpha, (int, float)) or foreground_alpha <= 0:
1164
+ continue
1165
+ if other["order"] <= element["order"]:
1166
+ continue
1167
+ if intersects(element, other):
1168
+ return True
1169
+ return False
1170
+
1171
+
1172
+ def is_ghost_text(element: dict[str, Any]) -> bool:
1173
+ if not is_text_element(element) or not has_text_content(element):
1174
+ return False
1175
+ font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
1176
+ text_alpha = element.get("textAlpha", element.get("alpha", 1))
1177
+ if not isinstance(text_alpha, (int, float)):
1178
+ return False
1179
+ if font_size > GHOST_TEXT_MIN_FONT_SIZE and text_alpha < GHOST_TEXT_MAX_ALPHA:
1180
+ return True
1181
+ return font_size >= GHOST_TEXT_FAINT_MIN_FONT_SIZE and text_alpha < GHOST_TEXT_FAINT_MAX_ALPHA
1182
+
1183
+
914
1184
  def estimate_text_visual_bbox(element: dict[str, Any]) -> dict[str, int | float] | None:
915
1185
  if not is_text_element(element) or not has_text_content(element) or is_decorative_text(element):
916
1186
  return None
@@ -1022,6 +1292,8 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
1022
1292
  return False
1023
1293
  if not (has_text_content(left) and has_text_content(right)):
1024
1294
  return False
1295
+ if is_ghost_text(left) or is_ghost_text(right):
1296
+ return False
1025
1297
  if is_template_text_stack(left, right) or is_similar_text_overlay(left, right):
1026
1298
  return False
1027
1299
 
@@ -1038,13 +1310,16 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
1038
1310
  return False
1039
1311
 
1040
1312
  font_size = source["fontSize"] if isinstance(source["fontSize"], (int, float)) else 16
1313
+ padding_left = source.get("paddingLeft", 0)
1314
+ padding_right = source.get("paddingRight", 0)
1315
+ available_width = max(source["width"] - padding_left - padding_right, 1)
1041
1316
  visual_width = estimate_text_max_line_width(source)
1042
- overflow_width = visual_width - source["width"]
1043
- min_overflow = max(font_size * 1.5, source["width"] * 0.08)
1317
+ overflow_width = visual_width - available_width
1318
+ min_overflow = max(font_size * 1.5, available_width * 0.08)
1044
1319
  if overflow_width < min_overflow:
1045
1320
  return False
1046
1321
 
1047
- intrusion_width = source["x"] + visual_width - target["x"]
1322
+ intrusion_width = source["x"] + padding_left + visual_width - target["x"]
1048
1323
  min_intrusion = max(font_size * 1.5, target["width"] * 0.08)
1049
1324
  if intrusion_width < min_intrusion:
1050
1325
  return False
@@ -1056,8 +1331,9 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
1056
1331
 
1057
1332
  def horizontal_text_overflow_measurement(left: dict[str, Any], right: dict[str, Any]) -> dict[str, int | float]:
1058
1333
  source, target = sorted([left, right], key=lambda element: element["x"])
1334
+ padding_left = source.get("paddingLeft", 0)
1059
1335
  visual_width = estimate_text_max_line_width(source)
1060
- source_visual_bbox = {"x": source["x"], "y": source["y"], "width": visual_width, "height": source["height"]}
1336
+ source_visual_bbox = {"x": source["x"] + padding_left, "y": source["y"], "width": visual_width, "height": source["height"]}
1061
1337
  width = intersection_width(source_visual_bbox, target)
1062
1338
  height = intersection_height(source_visual_bbox, target)
1063
1339
  return {
@@ -1072,6 +1348,8 @@ def should_flag_overlap(left: dict[str, Any], right: dict[str, Any]) -> bool:
1072
1348
  return False
1073
1349
  if is_text_element(right) and not has_text_content(right):
1074
1350
  return False
1351
+ if is_ghost_text(left) or is_ghost_text(right):
1352
+ return False
1075
1353
  if is_template_text_stack(left, right):
1076
1354
  return False
1077
1355
  if is_text_element(left) and is_text_element(right):
@@ -1118,6 +1396,8 @@ def should_report_whiteboard_overlap(
1118
1396
  ) -> dict[str, Any] | None:
1119
1397
  if other is whiteboard or not intersects(whiteboard, other):
1120
1398
  return None
1399
+ if is_ghost_text(other):
1400
+ return None
1121
1401
  if contains(whiteboard, other):
1122
1402
  return None
1123
1403
  if is_bottom_layer_full_slide_whiteboard(whiteboard, other, slide_width, slide_height):
@@ -1194,6 +1474,8 @@ def detect_whiteboard_external_overlaps(
1194
1474
 
1195
1475
  def element_canvas_bbox(element: dict[str, Any]) -> dict[str, int | float]:
1196
1476
  bbox = {key: element[key] for key in ("x", "y", "width", "height")}
1477
+ if element["kind"] != "chart" and not (element["kind"] == "shape" and element["type"] == "text"):
1478
+ return bbox
1197
1479
  rotation = element["rotation"]
1198
1480
  if not isinstance(rotation, (int, float)) or not math.isfinite(rotation):
1199
1481
  rotation = 0
@@ -1219,7 +1501,12 @@ def detect_elements_out_of_canvas(
1219
1501
  elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
1220
1502
  ) -> list[dict[str, Any]]:
1221
1503
  issues: list[dict[str, Any]] = []
1222
- for element in elements:
1504
+ for element in (
1505
+ element
1506
+ for element in elements
1507
+ if element["kind"] in {"table", "chart"}
1508
+ or (element["kind"] == "shape" and element["type"] in {"rect", "text"})
1509
+ ):
1223
1510
  bbox = element_canvas_bbox(element)
1224
1511
  overflow = {
1225
1512
  "left": max(-bbox["x"], 0),
@@ -1329,6 +1616,93 @@ def detect_table_layout_size_mismatches(elements: list[dict[str, Any]]) -> list[
1329
1616
  return issues
1330
1617
 
1331
1618
 
1619
+ def segment_intersects_rect(
1620
+ x1: float, y1: float, x2: float, y2: float, rect: dict[str, int | float]
1621
+ ) -> bool:
1622
+ """True when segment (x1,y1)-(x2,y2) enters the axis-aligned rect (Liang-Barsky clip)."""
1623
+ left = rect["x"]
1624
+ top = rect["y"]
1625
+ right = rect["x"] + rect["width"]
1626
+ bottom = rect["y"] + rect["height"]
1627
+ if right <= left or bottom <= top:
1628
+ return False
1629
+ dx = x2 - x1
1630
+ dy = y2 - y1
1631
+ if dx == 0 and dy == 0:
1632
+ return left <= x1 <= right and top <= y1 <= bottom
1633
+ t_enter, t_exit = 0.0, 1.0
1634
+ for delta, distance in ((-dx, x1 - left), (dx, right - x1), (-dy, y1 - top), (dy, bottom - y1)):
1635
+ if delta == 0:
1636
+ if distance < 0:
1637
+ return False
1638
+ continue
1639
+ t = distance / delta
1640
+ if delta < 0:
1641
+ t_enter = max(t_enter, t)
1642
+ else:
1643
+ t_exit = min(t_exit, t)
1644
+ if t_enter > t_exit:
1645
+ return False
1646
+ return True
1647
+
1648
+
1649
+ def line_text_graze_margin(text_element: dict[str, Any]) -> float:
1650
+ font_size = text_element["fontSize"] if isinstance(text_element.get("fontSize"), (int, float)) else 16
1651
+ return max(font_size * LINE_TEXT_GRAZE_FONT_RATIO, LINE_TEXT_GRAZE_MIN_PX)
1652
+
1653
+
1654
+ def erode_rect(rect: dict[str, int | float], margin: float) -> dict[str, int | float] | None:
1655
+ width = rect["width"] - 2 * margin
1656
+ height = rect["height"] - 2 * margin
1657
+ if width <= 0 or height <= 0:
1658
+ return None
1659
+ return {"x": rect["x"] + margin, "y": rect["y"] + margin, "width": width, "height": height}
1660
+
1661
+
1662
+ def line_crosses_text(line: dict[str, Any], text_element: dict[str, Any]) -> bool:
1663
+ if not is_visually_rendered(line) or line.get("alpha", 1) < LINE_MIN_VISIBLE_ALPHA:
1664
+ return False
1665
+ if not is_text_element(text_element) or not has_text_content(text_element):
1666
+ return False
1667
+ if is_ghost_text(text_element) or is_decorative_text(text_element):
1668
+ return False
1669
+ glyph_bbox = estimate_text_visual_bbox(text_element)
1670
+ if glyph_bbox is None:
1671
+ return False
1672
+ # Erode the glyph box so a line skimming the letter edge or only clipping the padding-only text
1673
+ # frame is exempt; only a line that actually cuts through the letterforms is a crossing.
1674
+ target = erode_rect(glyph_bbox, line_text_graze_margin(text_element))
1675
+ if target is None:
1676
+ return False
1677
+ return segment_intersects_rect(
1678
+ line["startX"], line["startY"], line["endX"], line["endY"], target
1679
+ )
1680
+
1681
+
1682
+ def detect_line_text_crossings(
1683
+ slide_xml: str, elements: list[dict[str, Any]]
1684
+ ) -> list[dict[str, Any]]:
1685
+ lines = extract_line_elements(slide_xml)
1686
+ if not lines:
1687
+ return []
1688
+ text_elements = [element for element in elements if is_text_element(element)]
1689
+ issues: list[dict[str, Any]] = []
1690
+ for line in lines:
1691
+ for text_element in text_elements:
1692
+ if not line_crosses_text(line, text_element):
1693
+ continue
1694
+ issues.append(
1695
+ {
1696
+ "level": "error",
1697
+ "code": "bbox_overlap",
1698
+ "elements": [line["id"], text_element["id"]],
1699
+ "message": f'line {line["id"]} crosses text {text_element["id"]}',
1700
+ "hint": "Move the line off the text glyphs so it no longer cuts through the letterforms.",
1701
+ }
1702
+ )
1703
+ return issues
1704
+
1705
+
1332
1706
  def lint_slide(
1333
1707
  slide_xml: str, slide_number: int, slide_width: int | float = 960, slide_height: int | float = 540
1334
1708
  ) -> dict[str, Any]:
@@ -1339,6 +1713,7 @@ def lint_slide(
1339
1713
  *detect_table_layout_size_mismatches(elements),
1340
1714
  *detect_text_may_overflow_shapes(elements),
1341
1715
  *detect_image_text_occlusions(elements),
1716
+ *detect_line_text_crossings(slide_xml, elements),
1342
1717
  ]
1343
1718
 
1344
1719
  for index, left in enumerate(elements):
@@ -1944,7 +2319,7 @@ def related_object(element: dict[str, Any]) -> dict[str, Any]:
1944
2319
 
1945
2320
  def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
1946
2321
  elements: list[dict[str, Any]] = []
1947
- for match in re.finditer(r"<line\b([^>]*)>", slide_xml):
2322
+ for match in re.finditer(r"<line\b([^>]*?)(/?)>", slide_xml):
1948
2323
  attrs = match.group(1)
1949
2324
  start_x = extract_numeric_attribute(attrs, "startX")
1950
2325
  start_y = extract_numeric_attribute(attrs, "startY")
@@ -1953,6 +2328,15 @@ def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
1953
2328
  if any(value is None for value in (start_x, start_y, end_x, end_y)):
1954
2329
  continue
1955
2330
  line_alpha = extract_numeric_attribute(attrs, "alpha")
2331
+ base_alpha = line_alpha if line_alpha is not None else 1
2332
+ border_alpha = 1
2333
+ if match.group(2) != "/":
2334
+ close_index = slide_xml.find("</line>", match.end())
2335
+ body = slide_xml[match.end() : close_index] if close_index != -1 else ""
2336
+ border_attrs = extract_tag_attributes(body, "border")
2337
+ color_alpha = extract_color_alpha(extract_attribute(border_attrs, "color"))
2338
+ if isinstance(color_alpha, (int, float)):
2339
+ border_alpha = color_alpha
1956
2340
  elements.append(
1957
2341
  {
1958
2342
  "id": extract_attribute(attrs, "id") or f"line-{len(elements) + 1}",
@@ -1962,8 +2346,12 @@ def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
1962
2346
  "y": min(start_y, end_y),
1963
2347
  "width": abs(end_x - start_x),
1964
2348
  "height": abs(end_y - start_y),
2349
+ "startX": start_x,
2350
+ "startY": start_y,
2351
+ "endX": end_x,
2352
+ "endY": end_y,
1965
2353
  "rotation": 0,
1966
- "alpha": line_alpha if line_alpha is not None else 1,
2354
+ "alpha": base_alpha * border_alpha,
1967
2355
  "order": len(elements),
1968
2356
  }
1969
2357
  )
@@ -1976,8 +2364,6 @@ def normalize_issue(
1976
2364
  elements_by_id: dict[str, dict[str, Any]],
1977
2365
  ) -> dict[str, Any]:
1978
2366
  normalized = dict(issue)
1979
- if normalized.get("level") == "info":
1980
- normalized["level"] = "warning"
1981
2367
  element_ids = list(dict.fromkeys(normalized.get("elements", [])))
1982
2368
  normalized["schema_version"] = "2.0"
1983
2369
  normalized["element_ids"] = element_ids
@@ -2039,8 +2425,10 @@ def build_result(
2039
2425
  ) -> dict[str, Any]:
2040
2426
  document_errors = [issue for issue in top_level_issues if issue["level"] == "error"]
2041
2427
  document_warnings = [issue for issue in top_level_issues if issue["level"] == "warning"]
2428
+ document_infos = [issue for issue in top_level_issues if issue["level"] == "info"]
2042
2429
  error_count = len(document_errors) + sum(len(slide["errors"]) for slide in slides)
2043
2430
  warning_count = len(document_warnings) + sum(len(slide["warnings"]) for slide in slides)
2431
+ info_count = len(document_infos) + sum(len(slide["infos"]) for slide in slides)
2044
2432
  all_errors = document_errors + [issue for slide in slides for issue in slide["errors"]]
2045
2433
  all_warnings = document_warnings + [issue for slide in slides for issue in slide["warnings"]]
2046
2434
  status = slide_status(all_errors, all_warnings)
@@ -2053,6 +2441,7 @@ def build_result(
2053
2441
  "slide_count": len(slides),
2054
2442
  "error_count": error_count,
2055
2443
  "warning_count": warning_count,
2444
+ "info_count": info_count,
2056
2445
  "status": status,
2057
2446
  "release_ready": error_count == 0,
2058
2447
  "screenshot_review_required": warning_count > 0,
@@ -2060,6 +2449,7 @@ def build_result(
2060
2449
  "document": {
2061
2450
  "errors": document_errors,
2062
2451
  "warnings": document_warnings,
2452
+ "infos": document_infos,
2063
2453
  },
2064
2454
  "slides": slides,
2065
2455
  }
@@ -2150,6 +2540,7 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
2150
2540
  ]
2151
2541
  errors = [issue for issue in issues if issue["level"] == "error"]
2152
2542
  warnings = [issue for issue in issues if issue["level"] == "warning"]
2543
+ infos = [issue for issue in issues if issue["level"] == "info"]
2153
2544
  slides.append(
2154
2545
  {
2155
2546
  "slide_number": slide_number,
@@ -2157,6 +2548,7 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
2157
2548
  "element_count": len(elements_by_id),
2158
2549
  "errors": errors,
2159
2550
  "warnings": warnings,
2551
+ "infos": infos,
2160
2552
  "issues": issues,
2161
2553
  }
2162
2554
  )