@amaster.ai/pi-lark 0.1.5 → 0.1.6

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 (144) hide show
  1. package/package.json +3 -3
  2. package/skills/lark-approval/references/lark-approval-initiate.md +2 -5
  3. package/skills/lark-approval/references/lark-approval-instances-initiated.md +6 -0
  4. package/skills/lark-approval/references/lark-approval-tasks-query.md +9 -0
  5. package/skills/lark-approval/references/lark-approval-tasks-rollback.md +8 -2
  6. package/skills/lark-apps/SKILL.md +25 -7
  7. package/skills/lark-apps/references/lark-apps-access-scope-set.md +1 -1
  8. package/skills/lark-apps/references/lark-apps-automation.md +164 -0
  9. package/skills/lark-apps/references/lark-apps-db-execute.md +186 -2
  10. package/skills/lark-apps/references/lark-apps-db.md +3 -3
  11. package/skills/lark-apps/references/lark-apps-get.md +43 -0
  12. package/skills/lark-apps/references/lark-apps-html-publish.md +7 -2
  13. package/skills/lark-apps/references/lark-apps-init.md +1 -2
  14. package/skills/lark-apps/references/lark-apps-openapi-key.md +1 -1
  15. package/skills/lark-apps/references/lark-apps-release-create.md +3 -1
  16. package/skills/lark-apps/references/lark-apps-role.md +133 -0
  17. package/skills/lark-base/SKILL.md +7 -3
  18. package/skills/lark-base/references/dashboard-block-data-config.md +28 -2
  19. package/skills/lark-base/references/lark-base-cell-value.md +9 -4
  20. package/skills/lark-base/references/lark-base-dashboard-block-get-data.md +7 -7
  21. package/skills/lark-base/references/lark-base-dashboard.md +11 -2
  22. package/skills/lark-base/references/lark-base-data-query.md +9 -7
  23. package/skills/lark-base/references/lark-base-field-create.md +4 -2
  24. package/skills/lark-base/references/lark-base-field-json.md +52 -15
  25. package/skills/lark-base/references/lark-base-field-update.md +4 -2
  26. package/skills/lark-base/references/lark-base-view-set-filter.md +3 -1
  27. package/skills/lark-calendar/SKILL.md +89 -31
  28. package/skills/lark-calendar/references/lark-calendar-create.md +8 -39
  29. package/skills/lark-calendar/references/lark-calendar-room-find.md +5 -9
  30. package/skills/lark-calendar/references/lark-calendar-rsvp.md +1 -5
  31. package/skills/lark-calendar/references/lark-calendar-schedule-clear-time.md +59 -0
  32. package/skills/lark-calendar/references/lark-calendar-schedule-fuzzy-time.md +88 -0
  33. package/skills/lark-calendar/references/lark-calendar-schedule-meeting.md +67 -210
  34. package/skills/lark-calendar/references/lark-calendar-suggestion.md +1 -5
  35. package/skills/lark-calendar/references/lark-calendar-update.md +2 -7
  36. package/skills/lark-doc/SKILL.md +1 -1
  37. package/skills/lark-doc/references/lark-doc-fetch.md +4 -2
  38. package/skills/lark-doc/references/lark-doc-mindnote.md +17 -2
  39. package/skills/lark-doc/references/lark-doc-whiteboard.md +4 -0
  40. package/skills/lark-doc/references/lark-doc-xml-extended-blocks.md +35 -0
  41. package/skills/lark-doc/references/lark-doc-xml.md +3 -2
  42. package/skills/lark-drive/SKILL.md +20 -8
  43. package/skills/lark-drive/references/lark-drive-comment-location.md +16 -4
  44. package/skills/lark-drive/references/lark-drive-comments-guide.md +16 -8
  45. package/skills/lark-drive/references/lark-drive-delete.md +35 -11
  46. package/skills/lark-drive/references/lark-drive-export.md +39 -10
  47. package/skills/lark-drive/references/lark-drive-files-list.md +27 -2
  48. package/skills/lark-drive/references/lark-drive-inspect.md +2 -0
  49. package/skills/lark-drive/references/lark-drive-list-comments.md +125 -0
  50. package/skills/lark-drive/references/lark-drive-member-add.md +1 -1
  51. package/skills/lark-drive/references/lark-drive-move.md +5 -3
  52. package/skills/lark-drive/references/lark-drive-permission-guide.md +12 -0
  53. package/skills/lark-drive/references/lark-drive-pull.md +3 -3
  54. package/skills/lark-drive/references/lark-drive-push.md +33 -6
  55. package/skills/lark-drive/references/lark-drive-status.md +12 -14
  56. package/skills/lark-drive/references/lark-drive-task-result.md +58 -5
  57. package/skills/lark-drive/references/lark-drive-workflow-knowledge-organize.md +26 -20
  58. package/skills/lark-drive/references/lark-drive-workflow.md +2 -1
  59. package/skills/lark-event/SKILL.md +2 -1
  60. package/skills/lark-event/references/lark-event-approval.md +170 -0
  61. package/skills/lark-im/SKILL.md +5 -4
  62. package/skills/lark-im/references/lark-im-messages-reply.md +1 -1
  63. package/skills/lark-im/references/lark-im-messages-send.md +1 -1
  64. package/skills/lark-mail/SKILL.md +12 -9
  65. package/skills/lark-mail/references/lark-mail-forward.md +1 -1
  66. package/skills/lark-mail/references/lark-mail-message-modify.md +48 -0
  67. package/skills/lark-mail/references/lark-mail-message-trash.md +41 -0
  68. package/skills/lark-mail/references/lark-mail-reply-all.md +1 -1
  69. package/skills/lark-mail/references/lark-mail-reply.md +1 -1
  70. package/skills/lark-mail/references/lark-mail-watch.md +1 -1
  71. package/skills/lark-markdown/SKILL.md +3 -2
  72. package/skills/lark-markdown/references/lark-markdown-create.md +22 -2
  73. package/skills/lark-minutes/SKILL.md +19 -4
  74. package/skills/lark-minutes/references/lark-minutes-download.md +0 -2
  75. package/skills/lark-minutes/references/lark-minutes-search.md +0 -2
  76. package/skills/lark-minutes/references/lark-minutes-speaker-replace.md +0 -2
  77. package/skills/lark-minutes/references/lark-minutes-summary.md +0 -2
  78. package/skills/lark-minutes/references/lark-minutes-todo.md +2 -4
  79. package/skills/lark-minutes/references/lark-minutes-update.md +0 -2
  80. package/skills/lark-minutes/references/lark-minutes-upload.md +10 -10
  81. package/skills/lark-shared/SKILL.md +26 -8
  82. package/skills/lark-sheets/SKILL.md +98 -29
  83. package/skills/lark-sheets/references/lark-sheets-batch-update.md +18 -9
  84. package/skills/lark-sheets/references/lark-sheets-changeset.md +105 -0
  85. package/skills/lark-sheets/references/lark-sheets-chart.md +4 -2
  86. package/skills/lark-sheets/references/lark-sheets-conditional-format.md +2 -0
  87. package/skills/lark-sheets/references/lark-sheets-filter-view.md +1 -1
  88. package/skills/lark-sheets/references/lark-sheets-float-image.md +6 -6
  89. package/skills/lark-sheets/references/lark-sheets-formula-translation.md +12 -3
  90. package/skills/lark-sheets/references/lark-sheets-formula-verify.md +77 -0
  91. package/skills/lark-sheets/references/lark-sheets-history.md +93 -0
  92. package/skills/lark-sheets/references/lark-sheets-pivot-table.md +7 -2
  93. package/skills/lark-sheets/references/lark-sheets-range-operations.md +44 -14
  94. package/skills/lark-sheets/references/lark-sheets-read-data.md +3 -3
  95. package/skills/lark-sheets/references/lark-sheets-sheet-structure.md +4 -4
  96. package/skills/lark-sheets/references/lark-sheets-visual-standards.md +4 -4
  97. package/skills/lark-sheets/references/lark-sheets-workbook.md +29 -4
  98. package/skills/lark-sheets/references/lark-sheets-write-cells.md +21 -11
  99. package/skills/lark-slides/SKILL.md +29 -18
  100. package/skills/lark-slides/references/asset-planning.md +16 -5
  101. package/skills/lark-slides/references/examples.md +57 -227
  102. package/skills/lark-slides/references/iconpark.md +2 -2
  103. package/skills/lark-slides/references/lark-slides-create.md +21 -2
  104. package/skills/lark-slides/references/lark-slides-media-upload.md +0 -1
  105. package/skills/lark-slides/references/lark-slides-pptx-template-workflows.md +89 -0
  106. package/skills/lark-slides/references/lark-slides-replace-pages.md +1 -1
  107. package/skills/lark-slides/references/lark-slides-replace-slide.md +1 -1
  108. package/skills/lark-slides/references/lark-slides-screenshot.md +11 -8
  109. package/skills/lark-slides/references/lark-slides-whiteboard.md +31 -30
  110. package/skills/lark-slides/references/lark-slides-xml-get.md +100 -0
  111. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-delete.md +9 -7
  112. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-get.md +4 -4
  113. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-replace.md +12 -10
  114. package/skills/lark-slides/references/lark-slides-xml-presentations-get.md +14 -13
  115. package/skills/lark-slides/references/planning-layer.md +32 -2
  116. package/skills/lark-slides/references/slides_chart_demo.xml +1 -0
  117. package/skills/lark-slides/references/slides_xml_schema_definition.xml +8 -3
  118. package/skills/lark-slides/references/troubleshooting.md +7 -25
  119. package/skills/lark-slides/references/validation-checklist.md +18 -9
  120. package/skills/lark-slides/references/visual-planning.md +4 -3
  121. package/skills/lark-slides/references/xml-format-guide.md +65 -1
  122. package/skills/lark-slides/references/xml-schema-quick-ref.md +7 -3
  123. package/skills/lark-slides/scripts/xml_text_overlap_lint.py +907 -54
  124. package/skills/lark-slides/scripts/xml_text_overlap_lint_test.py +876 -5
  125. package/skills/lark-task/SKILL.md +1 -0
  126. package/skills/lark-task/references/lark-task-create.md +14 -1
  127. package/skills/lark-vc/SKILL.md +6 -3
  128. package/skills/lark-vc/references/lark-vc-recording.md +0 -2
  129. package/skills/lark-vc/references/vc-domain-boundaries.md +9 -1
  130. package/skills/lark-vc-agent/SKILL.md +25 -15
  131. package/skills/lark-vc-agent/references/lark-vc-agent-meeting-events.md +65 -37
  132. package/skills/lark-vc-agent/references/lark-vc-agent-meeting-leave.md +1 -1
  133. package/skills/lark-vc-agent/references/lark-vc-agent-meeting-list-active.md +8 -8
  134. package/skills/lark-whiteboard/references/lark-whiteboard-workflow.md +5 -2
  135. package/skills/lark-wiki/SKILL.md +7 -3
  136. package/skills/lark-wiki/references/lark-wiki-move-to-drive.md +122 -0
  137. package/skills/lark-wiki/references/lark-wiki-move.md +5 -3
  138. package/skills/lark-wiki/references/lark-wiki-node-get.md +1 -1
  139. package/skills/lark-wiki/references/lark-wiki-node-list.md +9 -2
  140. package/skills/lark-calendar/references/lark-calendar-agenda.md +0 -78
  141. package/skills/lark-calendar/references/lark-calendar-freebusy.md +0 -124
  142. package/skills/lark-calendar/references/lark-calendar-search-event.md +0 -29
  143. package/skills/lark-sheets/references/lark-sheets-core-operations.md +0 -103
  144. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-create.md +0 -220
@@ -5,14 +5,45 @@
5
5
  from __future__ import annotations
6
6
 
7
7
  import json
8
+ import math
8
9
  import re
9
10
  import sys
11
+ import unicodedata
12
+ import xml.parsers.expat as expat
10
13
  import xml.etree.ElementTree as ET
11
- from difflib import SequenceMatcher
14
+ from difflib import SequenceMatcher, get_close_matches
12
15
  from pathlib import Path
13
16
  from typing import Any
14
17
 
15
18
 
19
+ XS_NS = "{http://www.w3.org/2001/XMLSchema}"
20
+ XML_NS = "{http://www.w3.org/XML/1998/namespace}"
21
+ SVG_NS = "{http://www.w3.org/2000/svg}"
22
+ SML_NAMESPACE = "http://www.larkoffice.com/sml/2.0"
23
+ SXSD_SCHEMA_PATH = Path(__file__).resolve().parents[1] / "references" / "slides_xml_schema_definition.xml"
24
+ ICONPARK_INDEX_PATH = Path(__file__).resolve().parents[1] / "references" / "iconpark-index.json"
25
+ SXSD_TAG_ALIASES = {
26
+ "textbox": "<shape type=\"text\">",
27
+ "textBox": "<shape type=\"text\">",
28
+ "image": "<img>",
29
+ "picture": "<img>",
30
+ }
31
+ SXSD_ATTR_ALIASES = {
32
+ "x": "topLeftX",
33
+ "left": "topLeftX",
34
+ "y": "topLeftY",
35
+ "top": "topLeftY",
36
+ "w": "width",
37
+ "h": "height",
38
+ "fontColor": "color",
39
+ }
40
+ SERVER_FILLED_SXSD_ATTRS = {"id"}
41
+ DEFAULT_TABLE_COLUMN_WIDTH = 110
42
+ DEFAULT_TABLE_ROW_HEIGHT = 37
43
+ _SXSD_TAG_ATTRIBUTES_CACHE: dict[str, set[str]] | None = None
44
+ _ICONPARK_ICON_TYPES_CACHE: set[str] | None = None
45
+
46
+
16
47
  class XmlTextOverlapLintError(Exception):
17
48
  pass
18
49
 
@@ -59,6 +90,84 @@ def extract_numeric_attribute(tag_source: str, name: str) -> int | float | None:
59
90
  return int(value) if value.is_integer() else value
60
91
 
61
92
 
93
+ def sum_sizes(sizes: list[int | float]) -> int | float:
94
+ return sum(sizes)
95
+
96
+
97
+ def is_filled_size(size: int | float | None) -> bool:
98
+ return isinstance(size, (int, float)) and math.isfinite(size) and size > 0
99
+
100
+
101
+ def fill_last_size_gap(sizes: list[int | float], target_size: int | float) -> list[int | float]:
102
+ if not sizes:
103
+ return sizes
104
+ final_sizes = [
105
+ size if index == len(sizes) - 1 else max(1, math.floor(size + 0.5))
106
+ for index, size in enumerate(sizes)
107
+ ]
108
+ remaining_size = target_size - sum_sizes(final_sizes[:-1])
109
+ if remaining_size >= 1:
110
+ final_sizes[-1] = remaining_size
111
+ return final_sizes
112
+
113
+ size_to_redistribute = 1 - remaining_size
114
+ for index in range(len(final_sizes) - 2, -1, -1):
115
+ reduction = min(final_sizes[index] - 1, size_to_redistribute)
116
+ final_sizes[index] -= reduction
117
+ size_to_redistribute -= reduction
118
+ if size_to_redistribute == 0:
119
+ final_sizes[-1] = 1
120
+ return final_sizes
121
+
122
+ final_sizes[-1] = 1
123
+ return final_sizes
124
+
125
+
126
+ def solve_weighted_min_layout(
127
+ input_sizes: list[int | float | None], default_size: int | float, target_min_size: int | float | None
128
+ ) -> dict[str, Any]:
129
+ filled_indexes: list[int] = []
130
+ empty_indexes: list[int] = []
131
+ base_sizes: list[int | float] = []
132
+ for index, size in enumerate(input_sizes):
133
+ if is_filled_size(size):
134
+ filled_indexes.append(index)
135
+ base_sizes.append(size)
136
+ else:
137
+ empty_indexes.append(index)
138
+ base_sizes.append(0)
139
+ filled_sum = sum_sizes(base_sizes)
140
+
141
+ if target_min_size is None:
142
+ final_sizes = [default_size if index in empty_indexes else size for index, size in enumerate(base_sizes)]
143
+ return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": 1}
144
+
145
+ if not filled_indexes:
146
+ average_size = target_min_size / len(input_sizes)
147
+ final_sizes = fill_last_size_gap([average_size] * len(input_sizes), target_min_size)
148
+ return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": 1}
149
+
150
+ if empty_indexes:
151
+ remaining_size = target_min_size - filled_sum
152
+ final_sizes = [*base_sizes]
153
+ if remaining_size > 0:
154
+ average_size = remaining_size / len(empty_indexes)
155
+ empty_sizes = fill_last_size_gap([average_size] * len(empty_indexes), remaining_size)
156
+ for index, empty_size in zip(empty_indexes, empty_sizes):
157
+ final_sizes[index] = empty_size
158
+ else:
159
+ for index in empty_indexes:
160
+ final_sizes[index] = default_size
161
+ return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": 1}
162
+
163
+ ratio = max(1, target_min_size / filled_sum)
164
+ actual_size = max(target_min_size, filled_sum)
165
+ if ratio == 1:
166
+ return {"final_sizes": [*base_sizes], "actual_size": actual_size, "ratio": ratio}
167
+ final_sizes = fill_last_size_gap([size * ratio for size in base_sizes], actual_size)
168
+ return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": ratio}
169
+
170
+
62
171
  def strip_xml(value: str) -> str:
63
172
  stripped = re.sub(r"<!\[CDATA\[([\s\S]*?)\]\]>", r"\1", value)
64
173
  stripped = re.sub(r"<[^>]+>", " ", stripped)
@@ -71,10 +180,290 @@ def strip_xml(value: str) -> str:
71
180
  return re.sub(r"\s+", " ", stripped).strip()
72
181
 
73
182
 
183
+ def strip_xml_paragraphs(value: str) -> str:
184
+ paragraphs = re.findall(r"<p\b[^>]*>([\s\S]*?)</p\s*>", value)
185
+ if paragraphs:
186
+ return "\n".join(strip_xml(paragraph) for paragraph in paragraphs)
187
+ return strip_xml(value)
188
+
189
+
74
190
  def xml_local_name(tag: str) -> str:
75
191
  return tag.rsplit("}", 1)[-1] if tag.startswith("{") else tag
76
192
 
77
193
 
194
+ def xml_namespace(tag: str) -> str | None:
195
+ return tag.split("}", 1)[0] + "}" if tag.startswith("{") else None
196
+
197
+
198
+ def strip_xsd_prefix(value: str | None) -> str | None:
199
+ if value is None:
200
+ return None
201
+ return value.rsplit(":", 1)[-1]
202
+
203
+
204
+ def iter_direct_xsd_children(element: ET.Element, local_name: str) -> list[ET.Element]:
205
+ return [child for child in element if child.tag == f"{XS_NS}{local_name}"]
206
+
207
+
208
+ def load_sxsd_tag_attributes() -> dict[str, set[str]]:
209
+ global _SXSD_TAG_ATTRIBUTES_CACHE
210
+ if _SXSD_TAG_ATTRIBUTES_CACHE is not None:
211
+ return _SXSD_TAG_ATTRIBUTES_CACHE
212
+
213
+ schema_root = ET.parse(SXSD_SCHEMA_PATH).getroot()
214
+ named_complex_types = {
215
+ complex_type.attrib["name"]: complex_type
216
+ for complex_type in schema_root.findall(f"{XS_NS}complexType")
217
+ if complex_type.attrib.get("name")
218
+ }
219
+ resolving: set[str] = set()
220
+
221
+ def attributes_for_complex_type(complex_type: ET.Element) -> set[str]:
222
+ attrs: set[str] = {
223
+ attribute.attrib["name"]
224
+ for attribute in iter_direct_xsd_children(complex_type, "attribute")
225
+ if attribute.attrib.get("name")
226
+ }
227
+ for content_name in ("simpleContent", "complexContent"):
228
+ for complex_content in iter_direct_xsd_children(complex_type, content_name):
229
+ for extension in iter_direct_xsd_children(complex_content, "extension"):
230
+ base_type = strip_xsd_prefix(extension.attrib.get("base"))
231
+ if base_type:
232
+ attrs.update(attributes_for_type(base_type))
233
+ attrs.update(
234
+ attribute.attrib["name"]
235
+ for attribute in iter_direct_xsd_children(extension, "attribute")
236
+ if attribute.attrib.get("name")
237
+ )
238
+ return attrs
239
+
240
+ def attributes_for_type(type_name: str) -> set[str]:
241
+ if type_name in resolving:
242
+ return set()
243
+ complex_type = named_complex_types.get(type_name)
244
+ if complex_type is None:
245
+ return set()
246
+ resolving.add(type_name)
247
+ try:
248
+ return attributes_for_complex_type(complex_type)
249
+ finally:
250
+ resolving.remove(type_name)
251
+
252
+ tag_attributes: dict[str, set[str]] = {}
253
+ for element in schema_root.iter(f"{XS_NS}element"):
254
+ tag_name = element.attrib.get("name")
255
+ if not tag_name:
256
+ continue
257
+
258
+ attrs: set[str] = set()
259
+ type_name = strip_xsd_prefix(element.attrib.get("type"))
260
+ if type_name:
261
+ attrs.update(attributes_for_type(type_name))
262
+ for complex_type in iter_direct_xsd_children(element, "complexType"):
263
+ attrs.update(attributes_for_complex_type(complex_type))
264
+
265
+ tag_attributes.setdefault(tag_name, set()).update(attrs)
266
+
267
+ _SXSD_TAG_ATTRIBUTES_CACHE = tag_attributes
268
+ return tag_attributes
269
+
270
+
271
+ def load_iconpark_icon_types() -> set[str]:
272
+ global _ICONPARK_ICON_TYPES_CACHE
273
+ if _ICONPARK_ICON_TYPES_CACHE is not None:
274
+ return _ICONPARK_ICON_TYPES_CACHE
275
+
276
+ try:
277
+ index_data = json.loads(ICONPARK_INDEX_PATH.read_text(encoding="utf-8"))
278
+ except json.JSONDecodeError as error:
279
+ fail(f"invalid iconpark index JSON: {error}")
280
+ icons = index_data.get("icons")
281
+ if not isinstance(icons, list):
282
+ fail("iconpark index must contain an icons array")
283
+
284
+ icon_types = {
285
+ icon["iconType"]
286
+ for icon in icons
287
+ if isinstance(icon, dict) and isinstance(icon.get("iconType"), str) and icon["iconType"]
288
+ }
289
+ _ICONPARK_ICON_TYPES_CACHE = icon_types
290
+ return icon_types
291
+
292
+
293
+ def build_sxsd_tag_hint(tag_name: str, supported_tags: set[str]) -> str:
294
+ alias = SXSD_TAG_ALIASES.get(tag_name)
295
+ if alias:
296
+ return f"Use {alias} instead of <{tag_name}>."
297
+ if tag_name == "svg":
298
+ return 'Inside <whiteboard>, write SVG as <svg xmlns="http://www.w3.org/2000/svg">...</svg>.'
299
+ close_matches = get_close_matches(tag_name, sorted(supported_tags), n=3, cutoff=0.72)
300
+ if close_matches:
301
+ return "Unsupported SXSD tag. Did you mean " + ", ".join(f"<{match}>" for match in close_matches) + "?"
302
+ return "Unsupported SXSD tag. Use only tags defined in slides_xml_schema_definition.xml."
303
+
304
+
305
+ def build_sxsd_attr_hint(tag_name: str, attr_name: str, allowed_attrs: set[str]) -> str:
306
+ alias = SXSD_ATTR_ALIASES.get(attr_name)
307
+ if alias and alias in allowed_attrs:
308
+ return f'Use "{alias}" on <{tag_name}> instead of "{attr_name}".'
309
+ close_matches = get_close_matches(attr_name, sorted(allowed_attrs), n=3, cutoff=0.68)
310
+ if close_matches:
311
+ return "Unsupported SXSD attribute. Did you mean " + ", ".join(f'"{match}"' for match in close_matches) + "?"
312
+ allowed_summary = ", ".join(sorted(allowed_attrs)[:8])
313
+ if len(allowed_attrs) > 8:
314
+ allowed_summary += ", ..."
315
+ return f"Unsupported SXSD attribute for <{tag_name}>. Allowed attributes include: {allowed_summary}."
316
+
317
+
318
+ def should_skip_sxsd_subtree(element: ET.Element, ancestors: list[str]) -> bool:
319
+ return "whiteboard" in ancestors and xml_namespace(element.tag) == SVG_NS
320
+
321
+
322
+ def should_skip_sxsd_attribute(attr_name: str) -> bool:
323
+ return attr_name in SERVER_FILLED_SXSD_ATTRS
324
+
325
+
326
+ def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
327
+ tag_attributes = load_sxsd_tag_attributes()
328
+ supported_tags = set(tag_attributes)
329
+ issues: list[dict[str, Any]] = []
330
+
331
+ def visit(element: ET.Element, ancestors: list[str], path: str) -> None:
332
+ if should_skip_sxsd_subtree(element, ancestors):
333
+ return
334
+
335
+ tag_name = xml_local_name(element.tag)
336
+ current_path = f"{path}/{tag_name}" if path else tag_name
337
+ if tag_name not in supported_tags:
338
+ issues.append(
339
+ {
340
+ "level": "error",
341
+ "code": "sxsd_unsupported_tag",
342
+ "tag": tag_name,
343
+ "path": current_path,
344
+ "message": f"unsupported SXSD tag <{tag_name}> at {current_path}",
345
+ "hint": build_sxsd_tag_hint(tag_name, supported_tags),
346
+ }
347
+ )
348
+ return
349
+ else:
350
+ allowed_attrs = tag_attributes[tag_name]
351
+ for raw_attr_name in element.attrib:
352
+ if raw_attr_name.startswith(XML_NS):
353
+ continue
354
+ attr_name = xml_local_name(raw_attr_name)
355
+ if should_skip_sxsd_attribute(attr_name):
356
+ continue
357
+ if attr_name in allowed_attrs:
358
+ continue
359
+ issues.append(
360
+ {
361
+ "level": "error",
362
+ "code": "sxsd_unsupported_attr",
363
+ "tag": tag_name,
364
+ "attr": attr_name,
365
+ "path": current_path,
366
+ "message": f'unsupported SXSD attribute "{attr_name}" on <{tag_name}> at {current_path}',
367
+ "hint": build_sxsd_attr_hint(tag_name, attr_name, allowed_attrs),
368
+ }
369
+ )
370
+
371
+ for child in element:
372
+ visit(child, [*ancestors, tag_name], current_path)
373
+
374
+ visit(root, [], "")
375
+ return issues
376
+
377
+
378
+ def build_iconpark_icon_type_hint(icon_type: str, supported_icon_types: set[str]) -> str:
379
+ close_matches = get_close_matches(icon_type, sorted(supported_icon_types), n=3, cutoff=0.58)
380
+ if close_matches:
381
+ return (
382
+ "iconType must exist in iconpark-index.json. Did you mean "
383
+ + ", ".join(f'"{match}"' for match in close_matches)
384
+ + "?"
385
+ )
386
+ return "iconType must exist in iconpark-index.json. Use scripts/iconpark_tool.py to search supported icons."
387
+
388
+
389
+ def validate_iconpark_icon_types(root: ET.Element) -> list[dict[str, Any]]:
390
+ supported_icon_types: set[str] | None = None
391
+ issues: list[dict[str, Any]] = []
392
+
393
+ def direct_child(element: ET.Element, local_name: str) -> ET.Element | None:
394
+ return next((child for child in element if xml_local_name(child.tag) == local_name), None)
395
+
396
+ def is_transparent_color(color: str) -> bool:
397
+ normalized = re.sub(r"\s+", "", color).lower()
398
+ if normalized == "transparent":
399
+ return True
400
+ rgba_match = re.fullmatch(r"rgba\([^,]+,[^,]+,[^,]+,([0-9.]+)\)", normalized)
401
+ if not rgba_match:
402
+ return False
403
+ try:
404
+ return float(rgba_match.group(1)) <= 0
405
+ except ValueError:
406
+ return False
407
+
408
+ def append_missing_fill_color_issue(current_path: str) -> None:
409
+ issues.append(
410
+ {
411
+ "level": "error",
412
+ "code": "icon_missing_fill_color",
413
+ "tag": "icon",
414
+ "path": current_path,
415
+ "message": f"<icon> must set explicit non-transparent fillColor for visual visibility at {current_path}",
416
+ "hint": 'Add <fill><fillColor color="rgba(R, G, B, 1)"/></fill> inside <icon>. This is a visual lint rule, not an SXSD required field.',
417
+ }
418
+ )
419
+
420
+ def visit(element: ET.Element, path: str) -> None:
421
+ nonlocal supported_icon_types
422
+ tag_name = xml_local_name(element.tag)
423
+ current_path = f"{path}/{tag_name}" if path else tag_name
424
+ if tag_name == "icon":
425
+ icon_type = element.attrib.get("iconType")
426
+ if icon_type is not None:
427
+ if supported_icon_types is None:
428
+ supported_icon_types = load_iconpark_icon_types()
429
+ if icon_type not in supported_icon_types:
430
+ issues.append(
431
+ {
432
+ "level": "error",
433
+ "code": "iconpark_unsupported_icon_type",
434
+ "tag": "icon",
435
+ "attr": "iconType",
436
+ "iconType": icon_type,
437
+ "path": current_path,
438
+ "message": f'unsupported iconpark iconType "{icon_type}" at {current_path}',
439
+ "hint": build_iconpark_icon_type_hint(icon_type, supported_icon_types),
440
+ }
441
+ )
442
+ fill = direct_child(element, "fill")
443
+ fill_color = direct_child(fill, "fillColor") if fill is not None else None
444
+ color = fill_color.attrib.get("color") if fill_color is not None else None
445
+ if not color:
446
+ append_missing_fill_color_issue(current_path)
447
+ elif is_transparent_color(color):
448
+ issues.append(
449
+ {
450
+ "level": "error",
451
+ "code": "icon_transparent_fill_color",
452
+ "tag": "icon",
453
+ "attr": "fillColor",
454
+ "path": current_path,
455
+ "color": color,
456
+ "message": f'<icon> fillColor must not be transparent for visual visibility at {current_path}: "{color}"',
457
+ "hint": 'Use an opaque visible color, for example <fillColor color="rgba(37, 99, 235, 1)"/>.',
458
+ }
459
+ )
460
+ for child in element:
461
+ visit(child, current_path)
462
+
463
+ visit(root, "")
464
+ return issues
465
+
466
+
78
467
  def extract_error_context(xml: str, line: int | None, column: int | None, radius: int = 40) -> str | None:
79
468
  if line is None or column is None:
80
469
  return None
@@ -103,16 +492,87 @@ def build_xml_error_issue(error: ET.ParseError, xml: str) -> dict[str, Any]:
103
492
  }
104
493
 
105
494
 
106
- def validate_xml_well_formed(xml: str) -> dict[str, Any] | None:
495
+ def validate_sml_tag_prefixes(xml: str) -> list[dict[str, Any]]:
496
+ namespace_map: dict[str, str] = {}
497
+ pending_declarations: list[tuple[str, str | None]] = []
498
+ declarations_by_element: list[list[tuple[str, str | None]]] = []
499
+ element_stack: list[str] = []
500
+ issues: list[dict[str, Any]] = []
501
+
502
+ parser = expat.ParserCreate(namespace_separator="|")
503
+ parser.namespace_prefixes = True
504
+
505
+ def handle_namespace_decl(prefix: str | None, namespace: str) -> None:
506
+ normalized_prefix = prefix or ""
507
+ previous_namespace = namespace_map.get(normalized_prefix)
508
+ namespace_map[normalized_prefix] = namespace
509
+ pending_declarations.append((normalized_prefix, previous_namespace))
510
+
511
+ def handle_start_element(name: str, _attrs: dict[str, str]) -> None:
512
+ declarations_by_element.append(pending_declarations.copy())
513
+ pending_declarations.clear()
514
+ name_parts = name.rsplit("|", 2)
515
+ if len(name_parts) == 3:
516
+ _namespace, local_name, prefix = name_parts
517
+ element_name = f"{prefix}:{local_name}"
518
+ else:
519
+ prefix = ""
520
+ local_name = name_parts[-1]
521
+ element_name = local_name
522
+ element_stack.append(element_name)
523
+ if not prefix:
524
+ return
525
+
526
+ if namespace_map.get(prefix) != SML_NAMESPACE:
527
+ return
528
+ path = "/".join(element_stack)
529
+ issues.append(
530
+ {
531
+ "level": "error",
532
+ "code": "sml_prefixed_tag",
533
+ "tag": element_name,
534
+ "namespace": SML_NAMESPACE,
535
+ "path": path,
536
+ "line": parser.CurrentLineNumber,
537
+ "column": parser.CurrentColumnNumber,
538
+ "message": f"SML tag <{element_name}> must not use a namespace prefix at {path}",
539
+ "hint": (
540
+ f'Use <{local_name}> under the default namespace '
541
+ f'<{local_name} xmlns="{SML_NAMESPACE}">, or use an unprefixed SML tag.'
542
+ ),
543
+ }
544
+ )
545
+
546
+ def handle_end_element(_name: str) -> None:
547
+ for prefix, previous_namespace in reversed(declarations_by_element.pop()):
548
+ if previous_namespace is None:
549
+ namespace_map.pop(prefix, None)
550
+ else:
551
+ namespace_map[prefix] = previous_namespace
552
+ element_stack.pop()
553
+
554
+ parser.StartNamespaceDeclHandler = handle_namespace_decl
555
+ parser.StartElementHandler = handle_start_element
556
+ parser.EndElementHandler = handle_end_element
557
+ parser.Parse(xml, True)
558
+ return issues
559
+
560
+
561
+ def parse_xml_root(xml: str) -> tuple[ET.Element | None, dict[str, Any] | None]:
107
562
  try:
108
563
  root = ET.fromstring(xml)
109
564
  except ET.ParseError as error:
110
- return build_xml_error_issue(error, xml)
565
+ return None, build_xml_error_issue(error, xml)
111
566
 
112
567
  root_name = xml_local_name(root.tag)
113
568
  if root_name not in {"presentation", "slide"}:
114
569
  fail("input must contain a <presentation> or <slide> root")
115
- return None
570
+ return root, None
571
+
572
+
573
+ def validate_xml_well_formed(xml: str) -> dict[str, Any] | None:
574
+ _, xml_error = parse_xml_root(xml)
575
+ return xml_error
116
576
 
117
577
 
118
578
  def parse_presentation(xml: str) -> dict[str, Any]:
@@ -131,47 +591,62 @@ def parse_presentation(xml: str) -> dict[str, Any]:
131
591
 
132
592
  def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
133
593
  elements: list[dict[str, Any]] = []
134
- for match in re.finditer(r"<shape\b([^>]*)>([\s\S]*?)</shape>", slide_xml):
135
- attrs, content = match.group(1), match.group(2)
136
- x = extract_numeric_attribute(attrs, "topLeftX")
137
- y = extract_numeric_attribute(attrs, "topLeftY")
138
- width = extract_numeric_attribute(attrs, "width")
139
- height = extract_numeric_attribute(attrs, "height")
140
- if all(value is not None for value in [x, y, width, height]):
141
- font_size = float(extract_attribute(content, "fontSize") or extract_attribute(attrs, "fontSize") or 16)
142
- elements.append(
143
- {
144
- "id": f"shape-{len(elements) + 1}",
145
- "kind": "shape",
146
- "type": extract_attribute(attrs, "type") or "shape",
147
- "textType": extract_attribute(content, "textType"),
148
- "x": x,
149
- "y": y,
150
- "width": width,
151
- "height": height,
152
- "fontSize": font_size,
153
- "text": strip_xml(content),
154
- }
155
- )
156
594
 
157
- for match in re.finditer(r"<(img|table|chart)\b([^>]*)/?>", slide_xml):
158
- attrs = match.group(2)
595
+ for match in re.finditer(r"<(shape|img|table|chart|whiteboard)\b([^>]*)>", slide_xml):
596
+ kind, attrs = match.group(1), match.group(2)
597
+ content = ""
598
+ if kind in {"shape", "table"}:
599
+ close_index = slide_xml.find(f"</{kind}>", match.end())
600
+ if close_index != -1:
601
+ content = slide_xml[match.end() : close_index]
602
+
603
+ element_id = extract_attribute(attrs, "id") or f"{kind}-{len(elements) + 1}"
159
604
  x = extract_numeric_attribute(attrs, "topLeftX")
160
605
  y = extract_numeric_attribute(attrs, "topLeftY")
161
606
  width = extract_numeric_attribute(attrs, "width")
162
607
  height = extract_numeric_attribute(attrs, "height")
163
- if all(value is not None for value in [x, y, width, height]):
164
- elements.append(
165
- {
166
- "id": f"{match.group(1)}-{len(elements) + 1}",
167
- "kind": match.group(1),
168
- "type": match.group(1),
169
- "x": x,
170
- "y": y,
171
- "width": width,
172
- "height": height,
173
- }
608
+ rotation = extract_numeric_attribute(attrs, "rotation") or 0
609
+ table_layouts: dict[str, dict[str, Any] | None] = {}
610
+ if kind == "table":
611
+ width, table_layouts["width"] = resolve_table_dimension(
612
+ content, width, extract_table_column_sizes, DEFAULT_TABLE_COLUMN_WIDTH
613
+ )
614
+ height, table_layouts["height"] = resolve_table_dimension(
615
+ content, height, extract_table_row_sizes, DEFAULT_TABLE_ROW_HEIGHT
174
616
  )
617
+ if all(value is not None for value in [x, y, width, height]):
618
+ element = {
619
+ "id": element_id,
620
+ "kind": kind,
621
+ "type": extract_attribute(attrs, "type") or kind,
622
+ "x": x,
623
+ "y": y,
624
+ "width": width,
625
+ "height": height,
626
+ "rotation": rotation,
627
+ "order": len(elements),
628
+ }
629
+ if kind == "table":
630
+ element.update(
631
+ {
632
+ "declared_width": extract_numeric_attribute(attrs, "width"),
633
+ "declared_height": extract_numeric_attribute(attrs, "height"),
634
+ "table_layouts": table_layouts,
635
+ }
636
+ )
637
+ if kind == "shape":
638
+ element.update(
639
+ {
640
+ "textType": extract_attribute(content, "textType"),
641
+ "textAlign": extract_attribute(content, "textAlign"),
642
+ "autoFit": extract_attribute(content, "autoFit"),
643
+ "fontSize": float(
644
+ extract_attribute(content, "fontSize") or extract_attribute(attrs, "fontSize") or 16
645
+ ),
646
+ "text": strip_xml_paragraphs(content),
647
+ }
648
+ )
649
+ elements.append(element)
175
650
  return elements
176
651
 
177
652
 
@@ -188,6 +663,10 @@ def is_text_element(element: dict[str, Any]) -> bool:
188
663
  return element["kind"] == "shape" and element["type"] == "text"
189
664
 
190
665
 
666
+ def is_whiteboard_element(element: dict[str, Any]) -> bool:
667
+ return element["kind"] == "whiteboard"
668
+
669
+
191
670
  def has_text_content(element: dict[str, Any]) -> bool:
192
671
  return bool(element.get("text"))
193
672
 
@@ -201,6 +680,24 @@ def normalize_text_for_overlap(text: str) -> str:
201
680
  return re.sub(r"\s+", "", text)
202
681
 
203
682
 
683
+ def estimate_character_width(character: str, font_size: int | float) -> int | float:
684
+ if character.isspace():
685
+ return font_size * 0.33
686
+ if unicodedata.east_asian_width(character) in {"F", "W"}:
687
+ return font_size
688
+ return font_size * 0.55
689
+
690
+
691
+ def estimate_text_width(text: str, font_size: int | float) -> int | float:
692
+ return sum(estimate_character_width(character, font_size) for character in text)
693
+
694
+
695
+ def estimate_text_max_line_width(element: dict[str, Any]) -> int | float:
696
+ font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
697
+ paragraphs = [paragraph for paragraph in re.split(r"\n+", element["text"]) if paragraph]
698
+ return max([estimate_text_width(paragraph, font_size) for paragraph in paragraphs] or [1])
699
+
700
+
204
701
  def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool:
205
702
  left_text = normalize_text_for_overlap(left.get("text") or "")
206
703
  right_text = normalize_text_for_overlap(right.get("text") or "")
@@ -213,12 +710,11 @@ def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool
213
710
 
214
711
  def estimate_text_line_count(element: dict[str, Any]) -> int:
215
712
  font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
216
- chars_per_line = max(1, int(element["width"] // max(font_size * 0.55, 1)))
217
713
  paragraphs = [paragraph for paragraph in re.split(r"\n+", element["text"]) if paragraph]
218
714
  line_count = 0
219
715
  for paragraph in paragraphs:
220
- logical_length = max(len(paragraph), 1)
221
- line_count += max(1, -(-logical_length // chars_per_line))
716
+ logical_width = max(estimate_text_width(paragraph, font_size), 1)
717
+ line_count += max(1, math.ceil(logical_width / max(element["width"], 1)))
222
718
  return max(line_count, 1)
223
719
 
224
720
 
@@ -227,9 +723,8 @@ def estimate_text_visual_bbox(element: dict[str, Any]) -> dict[str, int | float]
227
723
  return None
228
724
 
229
725
  font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
230
- char_width = max(font_size * 0.55, 1)
231
726
  line_count = estimate_text_line_count(element)
232
- visual_width = min(element["width"], max(1, len(element["text"]) * char_width))
727
+ visual_width = min(element["width"], max(1, estimate_text_max_line_width(element)))
233
728
  visual_height = min(element["height"], max(1, line_count * font_size * 1.2))
234
729
  return {
235
730
  "x": element["x"],
@@ -247,6 +742,49 @@ def intersection_area(left: dict[str, Any], right: dict[str, Any]) -> int | floa
247
742
  return width * height
248
743
 
249
744
 
745
+ def intersection_height(left: dict[str, Any], right: dict[str, Any]) -> int | float:
746
+ height = min(left["y"] + left["height"], right["y"] + right["height"]) - max(left["y"], right["y"])
747
+ return max(height, 0)
748
+
749
+
750
+ def intersection_width(left: dict[str, Any], right: dict[str, Any]) -> int | float:
751
+ width = min(left["x"] + left["width"], right["x"] + right["width"]) - max(left["x"], right["x"])
752
+ return max(width, 0)
753
+
754
+
755
+ def element_area(element: dict[str, Any]) -> int | float:
756
+ return max(element["width"], 0) * max(element["height"], 0)
757
+
758
+
759
+ def contains(outer: dict[str, Any], inner: dict[str, Any], tolerance: int | float = 2) -> bool:
760
+ return (
761
+ inner["x"] >= outer["x"] - tolerance
762
+ and inner["y"] >= outer["y"] - tolerance
763
+ and inner["x"] + inner["width"] <= outer["x"] + outer["width"] + tolerance
764
+ and inner["y"] + inner["height"] <= outer["y"] + outer["height"] + tolerance
765
+ )
766
+
767
+
768
+ def is_bottom_layer_full_slide_whiteboard(
769
+ whiteboard: dict[str, Any], other: dict[str, Any], slide_width: int | float, slide_height: int | float
770
+ ) -> bool:
771
+ return (
772
+ whiteboard["order"] < other["order"]
773
+ and whiteboard["x"] <= 2
774
+ and whiteboard["y"] <= 2
775
+ and whiteboard["width"] >= slide_width - 4
776
+ and whiteboard["height"] >= slide_height - 4
777
+ )
778
+
779
+
780
+ def is_background_container_for_whiteboard(container: dict[str, Any], whiteboard: dict[str, Any]) -> bool:
781
+ if container["order"] > whiteboard["order"]:
782
+ return False
783
+ if is_text_element(container):
784
+ return False
785
+ return contains(container, whiteboard)
786
+
787
+
250
788
  def is_template_text_stack(left: dict[str, Any], right: dict[str, Any]) -> bool:
251
789
  if not (is_text_element(left) and is_text_element(right)):
252
790
  return False
@@ -269,6 +807,39 @@ def is_template_text_stack(left: dict[str, Any], right: dict[str, Any]) -> bool:
269
807
  return same_column and vertical_offset >= top_font_size * 0.75
270
808
 
271
809
 
810
+ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str, Any]) -> bool:
811
+ if not (is_text_element(left) and is_text_element(right)):
812
+ return False
813
+ if not (has_text_content(left) and has_text_content(right)):
814
+ return False
815
+ if is_template_text_stack(left, right) or is_similar_text_overlay(left, right):
816
+ return False
817
+
818
+ source, target = sorted([left, right], key=lambda element: element["x"])
819
+ if source["x"] == target["x"]:
820
+ return False
821
+ if source.get("autoFit") == "normal-auto-fit":
822
+ return False
823
+ if source.get("textAlign") in {"center", "right"}:
824
+ return False
825
+
826
+ font_size = source["fontSize"] if isinstance(source["fontSize"], (int, float)) else 16
827
+ visual_width = estimate_text_max_line_width(source)
828
+ overflow_width = visual_width - source["width"]
829
+ min_overflow = max(font_size * 1.5, source["width"] * 0.08)
830
+ if overflow_width < min_overflow:
831
+ return False
832
+
833
+ intrusion_width = source["x"] + visual_width - target["x"]
834
+ min_intrusion = max(font_size * 1.5, target["width"] * 0.08)
835
+ if intrusion_width < min_intrusion:
836
+ return False
837
+
838
+ vertical_overlap = intersection_height(source, target)
839
+ min_vertical_overlap = min(source["height"], target["height"]) * 0.40
840
+ return vertical_overlap >= min_vertical_overlap
841
+
842
+
272
843
  def should_flag_overlap(left: dict[str, Any], right: dict[str, Any]) -> bool:
273
844
  if is_text_element(left) and not has_text_content(left):
274
845
  return False
@@ -294,13 +865,263 @@ def should_flag_overlap(left: dict[str, Any], right: dict[str, Any]) -> bool:
294
865
  return False
295
866
 
296
867
 
297
- def lint_slide(slide_xml: str, slide_number: int) -> dict[str, Any]:
298
- elements = extract_elements(slide_xml)
868
+ def build_whiteboard_external_overlap_issue(
869
+ whiteboard: dict[str, Any], overlap_details: list[dict[str, Any]]
870
+ ) -> dict[str, Any]:
871
+ element_ids = [detail["element"] for detail in overlap_details]
872
+ return {
873
+ "level": "warning",
874
+ "code": "whiteboard_external_overlap",
875
+ "elements": [whiteboard["id"], *element_ids],
876
+ "message": f'whiteboard {whiteboard["id"]} overlaps {len(element_ids)} sibling elements across its boundary',
877
+ "hint": (
878
+ "Treat this as a static whiteboard container-bbox risk, not final visual proof. "
879
+ "After moving or accepting the overlap, use screenshot QA or equivalent rendered visual inspection as "
880
+ "the final authority because XML readback does not include whiteboard SVG/Mermaid internals."
881
+ ),
882
+ "overlaps": overlap_details,
883
+ }
884
+
885
+
886
+ def should_report_whiteboard_overlap(
887
+ whiteboard: dict[str, Any],
888
+ other: dict[str, Any],
889
+ slide_width: int | float,
890
+ slide_height: int | float,
891
+ ) -> dict[str, Any] | None:
892
+ if other is whiteboard or not intersects(whiteboard, other):
893
+ return None
894
+ if contains(whiteboard, other):
895
+ return None
896
+ if is_bottom_layer_full_slide_whiteboard(whiteboard, other, slide_width, slide_height):
897
+ return None
898
+ if is_background_container_for_whiteboard(other, whiteboard):
899
+ return None
900
+
901
+ overlap_width = intersection_width(whiteboard, other)
902
+ overlap_height = intersection_height(whiteboard, other)
903
+ if overlap_width < 8 or overlap_height < 8:
904
+ return None
905
+
906
+ other_area = element_area(other)
907
+ if other_area <= 0:
908
+ return None
909
+ overlap_area = overlap_width * overlap_height
910
+ overlap_ratio = overlap_area / other_area
911
+ if overlap_ratio < 0.15:
912
+ return None
913
+
914
+ return {
915
+ "element": other["id"],
916
+ "kind": other["kind"],
917
+ "type": other.get("type"),
918
+ "overlap_width": overlap_width,
919
+ "overlap_height": overlap_height,
920
+ "target_overlap_ratio": round(overlap_ratio, 3),
921
+ }
922
+
923
+
924
+ def prune_contained_text_overlap_details(
925
+ overlap_details: list[dict[str, Any]], elements_by_id: dict[str, dict[str, Any]]
926
+ ) -> list[dict[str, Any]]:
927
+ pruned: list[dict[str, Any]] = []
928
+ for detail in overlap_details:
929
+ element = elements_by_id[detail["element"]]
930
+ if is_text_element(element):
931
+ has_reported_container = any(
932
+ detail["element"] != other_detail["element"]
933
+ and not is_text_element(elements_by_id[other_detail["element"]])
934
+ and contains(elements_by_id[other_detail["element"]], element)
935
+ for other_detail in overlap_details
936
+ )
937
+ if has_reported_container:
938
+ continue
939
+ pruned.append(detail)
940
+ return pruned
941
+
942
+
943
+ def detect_whiteboard_external_overlaps(
944
+ elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
945
+ ) -> list[dict[str, Any]]:
946
+ issues: list[dict[str, Any]] = []
947
+ elements_by_id = {element["id"]: element for element in elements}
948
+ for whiteboard in [element for element in elements if is_whiteboard_element(element)]:
949
+ overlap_details = [
950
+ detail
951
+ for element in elements
952
+ if (
953
+ detail := should_report_whiteboard_overlap(
954
+ whiteboard,
955
+ element,
956
+ slide_width,
957
+ slide_height,
958
+ )
959
+ )
960
+ is not None
961
+ ]
962
+ overlap_details = prune_contained_text_overlap_details(overlap_details, elements_by_id)
963
+ if overlap_details:
964
+ issues.append(build_whiteboard_external_overlap_issue(whiteboard, overlap_details))
965
+ return issues
966
+
967
+
968
+ def element_canvas_bbox(element: dict[str, Any]) -> dict[str, int | float]:
969
+ bbox = {key: element[key] for key in ("x", "y", "width", "height")}
970
+ if element["kind"] != "chart" and not (element["kind"] == "shape" and element["type"] == "text"):
971
+ return bbox
972
+
973
+ rotation = element["rotation"]
974
+ if not isinstance(rotation, (int, float)) or not math.isfinite(rotation):
975
+ rotation = 0
976
+ rotation %= 360
977
+ if math.isclose(rotation, 0, abs_tol=1e-9):
978
+ return bbox
979
+ radians = math.radians(rotation)
980
+ sine = abs(math.sin(radians))
981
+ cosine = abs(math.cos(radians))
982
+ sine = 0 if math.isclose(sine, 0, abs_tol=1e-12) else sine
983
+ cosine = 0 if math.isclose(cosine, 0, abs_tol=1e-12) else cosine
984
+ rotated_width = element["width"] * cosine + element["height"] * sine
985
+ rotated_height = element["width"] * sine + element["height"] * cosine
986
+ return {
987
+ "x": element["x"] - (rotated_width - element["width"]) / 2,
988
+ "y": element["y"] - (rotated_height - element["height"]) / 2,
989
+ "width": rotated_width,
990
+ "height": rotated_height,
991
+ }
992
+
993
+
994
+ def detect_elements_out_of_canvas(
995
+ elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
996
+ ) -> list[dict[str, Any]]:
299
997
  issues: list[dict[str, Any]] = []
998
+ for element in (
999
+ element
1000
+ for element in elements
1001
+ if element["kind"] in {"table", "chart"}
1002
+ or (element["kind"] == "shape" and element["type"] == "text")
1003
+ ):
1004
+ bbox = element_canvas_bbox(element)
1005
+ overflow = {
1006
+ "left": max(-bbox["x"], 0),
1007
+ "top": max(-bbox["y"], 0),
1008
+ "right": max(bbox["x"] + bbox["width"] - slide_width, 0),
1009
+ "bottom": max(bbox["y"] + bbox["height"] - slide_height, 0),
1010
+ }
1011
+ overflow_details = [
1012
+ f"{side} by {amount:g}px" for side, amount in overflow.items() if amount > 0
1013
+ ]
1014
+ if not overflow_details:
1015
+ continue
1016
+ issues.append(
1017
+ {
1018
+ "level": "error",
1019
+ "code": f'{element["kind"]}_out_of_canvas',
1020
+ "elements": [element["id"]],
1021
+ "canvas": {"width": slide_width, "height": slide_height},
1022
+ "bbox": bbox,
1023
+ "overflow": overflow,
1024
+ "message": (
1025
+ f'{element["kind"]} {element["id"]} exceeds the {slide_width:g}x{slide_height:g} canvas '
1026
+ f'({", ".join(overflow_details)})'
1027
+ ),
1028
+ "hint": (
1029
+ "Move the table inside the canvas, reduce table.width/table.height, or split the table across "
1030
+ "slides."
1031
+ if element["kind"] == "table"
1032
+ else f'Move the {element["kind"]} inside the canvas or reduce its width/height.'
1033
+ ),
1034
+ }
1035
+ )
1036
+ return issues
1037
+
1038
+
1039
+ def extract_table_column_sizes(table_xml: str) -> list[int | float | None]:
1040
+ sizes: list[int | float | None] = []
1041
+ for match in re.finditer(r"<col\b([^>]*)/?>", table_xml):
1042
+ attrs = match.group(1)
1043
+ span = extract_numeric_attribute(attrs, "span") or 1
1044
+ span_count = int(span) if math.isfinite(span) and span > 0 and float(span).is_integer() else 1
1045
+ sizes.extend([extract_numeric_attribute(attrs, "width")] * span_count)
1046
+ return sizes
1047
+
1048
+
1049
+ def extract_table_row_sizes(table_xml: str) -> list[int | float | None]:
1050
+ return [extract_numeric_attribute(match.group(1), "height") for match in re.finditer(r"<tr\b([^>]*)>", table_xml)]
1051
+
1052
+
1053
+ def resolve_table_dimension(
1054
+ table_xml: str,
1055
+ declared_size: int | float | None,
1056
+ extract_sizes: Any,
1057
+ default_size: int | float,
1058
+ ) -> tuple[int | float | None, dict[str, Any] | None]:
1059
+ input_sizes = extract_sizes(table_xml)
1060
+ if not input_sizes:
1061
+ return declared_size, None
1062
+ layout = solve_weighted_min_layout(
1063
+ input_sizes, default_size, declared_size if is_filled_size(declared_size) else None
1064
+ )
1065
+ return layout["actual_size"], layout
1066
+
1067
+
1068
+ def format_size(size: int | float) -> str:
1069
+ return f"{size:g}"
1070
+
1071
+
1072
+ def detect_table_layout_size_mismatches(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
1073
+ issues: list[dict[str, Any]] = []
1074
+ dimensions = {
1075
+ "width": ("col", "column widths"),
1076
+ "height": ("tr", "row heights"),
1077
+ }
1078
+ for table in (element for element in elements if element["kind"] == "table"):
1079
+ for dimension, (child_tag, child_description) in dimensions.items():
1080
+ target_size = table[f"declared_{dimension}"]
1081
+ if not is_filled_size(target_size):
1082
+ continue
1083
+ layout = table["table_layouts"][dimension]
1084
+ if layout is None:
1085
+ continue
1086
+ actual_size = layout["actual_size"]
1087
+ if math.isclose(actual_size, target_size, rel_tol=1e-9, abs_tol=1e-9):
1088
+ continue
1089
+ issues.append(
1090
+ {
1091
+ "level": "info",
1092
+ "code": "table_resolved_size_mismatch",
1093
+ "elements": [table["id"]],
1094
+ "dimension": dimension,
1095
+ "declared_size": target_size,
1096
+ "resolved_size": actual_size,
1097
+ "resolved_sizes": layout["final_sizes"],
1098
+ "message": (
1099
+ f'table {table["id"]} declares {dimension}={format_size(target_size)}px, but its '
1100
+ f"{child_description} resolve to {format_size(actual_size)}px"
1101
+ ),
1102
+ "hint": (
1103
+ f"Set table.{dimension} to {format_size(actual_size)}px, or adjust <{child_tag}> sizes "
1104
+ f"so their resolved total matches {format_size(target_size)}px."
1105
+ ),
1106
+ }
1107
+ )
1108
+ return issues
1109
+
1110
+
1111
+ def lint_slide(
1112
+ slide_xml: str, slide_number: int, slide_width: int | float = 960, slide_height: int | float = 540
1113
+ ) -> dict[str, Any]:
1114
+ elements = extract_elements(slide_xml)
1115
+ issues: list[dict[str, Any]] = [
1116
+ *detect_whiteboard_external_overlaps(elements, slide_width, slide_height),
1117
+ *detect_elements_out_of_canvas(elements, slide_width, slide_height),
1118
+ *detect_table_layout_size_mismatches(elements),
1119
+ ]
300
1120
 
301
1121
  for index, left in enumerate(elements):
302
1122
  for right in elements[index + 1 :]:
303
- if not intersects(left, right) or not should_flag_overlap(left, right):
1123
+ horizontal_overflow = should_flag_horizontal_text_overflow(left, right)
1124
+ if not horizontal_overflow and (not intersects(left, right) or not should_flag_overlap(left, right)):
304
1125
  continue
305
1126
  issues.append(
306
1127
  {
@@ -315,29 +1136,61 @@ def lint_slide(slide_xml: str, slide_number: int) -> dict[str, Any]:
315
1136
 
316
1137
 
317
1138
  def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
318
- xml_error = validate_xml_well_formed(xml)
1139
+ root, xml_error = parse_xml_root(xml)
319
1140
  if xml_error:
320
1141
  return {
321
1142
  "file": source_path,
322
1143
  "slide_size": {"width": 960, "height": 540},
323
- "summary": {"slide_count": 0, "error_count": 1, "warning_count": 0},
1144
+ "summary": {"slide_count": 0, "error_count": 1, "warning_count": 0, "info_count": 0},
324
1145
  "issues": [xml_error],
325
1146
  "slides": [],
326
1147
  }
327
1148
 
1149
+ namespace_issues = validate_sml_tag_prefixes(xml)
1150
+ sxsd_issues = validate_sxsd_tag_attributes(root) if root is not None else []
1151
+ iconpark_issues = validate_iconpark_icon_types(root) if root is not None else []
1152
+ top_level_issues = [*namespace_issues, *sxsd_issues, *iconpark_issues]
1153
+ if namespace_issues:
1154
+ error_count = sum(1 for issue in top_level_issues if issue["level"] == "error")
1155
+ warning_count = sum(1 for issue in top_level_issues if issue["level"] == "warning")
1156
+ info_count = sum(1 for issue in top_level_issues if issue["level"] == "info")
1157
+ return {
1158
+ "file": source_path,
1159
+ "slide_size": {"width": 960, "height": 540},
1160
+ "summary": {
1161
+ "slide_count": 0,
1162
+ "error_count": error_count,
1163
+ "warning_count": warning_count,
1164
+ "info_count": info_count,
1165
+ },
1166
+ "issues": top_level_issues,
1167
+ "slides": [],
1168
+ }
328
1169
  presentation = parse_presentation(xml)
329
1170
  slides = [
330
- lint_slide(slide_xml, index + 1)
1171
+ lint_slide(slide_xml, index + 1, presentation["width"], presentation["height"])
331
1172
  for index, slide_xml in enumerate(presentation["slides"])
332
1173
  ]
333
- error_count = sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "error")
334
- warning_count = sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "warning")
335
- return {
1174
+ error_count = sum(1 for issue in top_level_issues if issue["level"] == "error")
1175
+ error_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "error")
1176
+ warning_count = sum(1 for issue in top_level_issues if issue["level"] == "warning")
1177
+ warning_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "warning")
1178
+ info_count = sum(1 for issue in top_level_issues if issue["level"] == "info")
1179
+ info_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "info")
1180
+ result = {
336
1181
  "file": source_path,
337
1182
  "slide_size": {"width": presentation["width"], "height": presentation["height"]},
338
- "summary": {"slide_count": len(slides), "error_count": error_count, "warning_count": warning_count},
1183
+ "summary": {
1184
+ "slide_count": len(slides),
1185
+ "error_count": error_count,
1186
+ "warning_count": warning_count,
1187
+ "info_count": info_count,
1188
+ },
339
1189
  "slides": slides,
340
1190
  }
1191
+ if top_level_issues:
1192
+ result["issues"] = top_level_issues
1193
+ return result
341
1194
 
342
1195
 
343
1196
  def print_usage() -> None: