@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.
- package/README.md +5 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -2
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/skills/lark-base/SKILL.md +5 -3
- package/skills/lark-base/references/lark-base-filter-condition.md +179 -0
- package/skills/lark-base/references/lark-base-form-questions-create.md +34 -4
- package/skills/lark-base/references/lark-base-form-questions-update.md +73 -20
- package/skills/lark-base/references/lark-base-view-set-filter.md +11 -137
- package/skills/lark-calendar/SKILL.md +12 -6
- package/skills/lark-calendar/references/lark-calendar-create.md +6 -6
- package/skills/lark-calendar/references/lark-calendar-room-find.md +2 -1
- package/skills/lark-calendar/references/lark-calendar-schedule-clear-time.md +1 -0
- package/skills/lark-calendar/references/lark-calendar-suggestion.md +1 -1
- package/skills/lark-calendar/references/lark-calendar-update.md +7 -4
- package/skills/lark-contact/SKILL.md +18 -2
- package/skills/lark-contact/references/lark-contact-search-bot.md +60 -0
- package/skills/lark-drive/SKILL.md +5 -1
- package/skills/lark-drive/references/lark-drive-member-list.md +65 -0
- package/skills/lark-drive/references/lark-drive-permission-get-setting.md +48 -0
- package/skills/lark-drive/references/lark-drive-search.md +6 -1
- package/skills/lark-drive/references/lark-drive-secure-label.md +1 -1
- package/skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md +38 -8
- package/skills/lark-drive/references/lark-drive-workflow-permission-governance-outputs.md +10 -10
- package/skills/lark-drive/references/lark-drive-workflow-permission-governance.md +22 -20
- package/skills/lark-slides/SKILL.md +16 -26
- package/skills/lark-slides/references/lark-slides-create.md +14 -5
- package/skills/lark-slides/references/slides_xml_schema_definition.xml +491 -31
- package/skills/lark-slides/references/xml-schema-quick-ref.md +39 -0
- package/skills/lark-slides/scripts/xml_text_overlap_lint.py +424 -32
- package/skills/lark-slides/scripts/xml_text_overlap_lint_test.py +840 -58
- 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 = [
|
|
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
|
-
|
|
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
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
-
|
|
794
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 <=
|
|
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":
|
|
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 -
|
|
1043
|
-
min_overflow = max(font_size * 1.5,
|
|
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
|
|
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([^>]
|
|
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":
|
|
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
|
)
|