@amaster.ai/pi-lark 0.1.5 → 0.1.7

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 (258) hide show
  1. package/README.md +5 -1
  2. package/dist/config.d.ts +1 -1
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +2 -2
  5. package/dist/config.js.map +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +2 -1
  8. package/dist/index.js.map +1 -1
  9. package/package.json +4 -4
  10. package/skills/lark-approval/references/lark-approval-initiate.md +2 -5
  11. package/skills/lark-approval/references/lark-approval-instances-initiated.md +6 -0
  12. package/skills/lark-approval/references/lark-approval-tasks-query.md +9 -0
  13. package/skills/lark-approval/references/lark-approval-tasks-rollback.md +8 -2
  14. package/skills/lark-apps/SKILL.md +46 -16
  15. package/skills/lark-apps/creative-design/agents/assets/vision-probe.png +0 -0
  16. package/skills/lark-apps/creative-design/agents/fork-verifier-agent.md +71 -0
  17. package/skills/lark-apps/creative-design/agents/vision-probe-agent.md +41 -0
  18. package/skills/lark-apps/creative-design/assets/index.html +27 -0
  19. package/skills/lark-apps/creative-design/creative-design.md +239 -0
  20. package/skills/lark-apps/creative-design/references/aily.md +39 -0
  21. package/skills/lark-apps/creative-design/references/animated-video.md +34 -0
  22. package/skills/lark-apps/creative-design/references/charts.md +165 -0
  23. package/skills/lark-apps/creative-design/references/claude.md +36 -0
  24. package/skills/lark-apps/creative-design/references/codex.md +32 -0
  25. package/skills/lark-apps/creative-design/references/data-report.md +108 -0
  26. package/skills/lark-apps/creative-design/references/frontend-design.md +71 -0
  27. package/skills/lark-apps/creative-design/references/hi-fi-design.md +32 -0
  28. package/skills/lark-apps/creative-design/references/interactive-prototype.md +24 -0
  29. package/skills/lark-apps/creative-design/references/make-a-deck.md +133 -0
  30. package/skills/lark-apps/creative-design/references/visual-exposure.md +82 -0
  31. package/skills/lark-apps/creative-design/references/wireframe.md +14 -0
  32. package/skills/lark-apps/creative-design/starter-components/android-frame.jsx +188 -0
  33. package/skills/lark-apps/creative-design/starter-components/animations.jsx +773 -0
  34. package/skills/lark-apps/creative-design/starter-components/browser-window.jsx +122 -0
  35. package/skills/lark-apps/creative-design/starter-components/deck-stage.js +2483 -0
  36. package/skills/lark-apps/creative-design/starter-components/design-canvas.jsx +1432 -0
  37. package/skills/lark-apps/creative-design/starter-components/ios-frame.jsx +270 -0
  38. package/skills/lark-apps/creative-design/starter-components/macos-window.jsx +197 -0
  39. package/skills/lark-apps/creative-design/starter-components/tweaks-panel.jsx +752 -0
  40. package/skills/lark-apps/references/lark-apps-access-scope-set.md +1 -1
  41. package/skills/lark-apps/references/lark-apps-automation.md +242 -0
  42. package/skills/lark-apps/references/lark-apps-cache.md +61 -0
  43. package/skills/lark-apps/references/lark-apps-cloud-dev.md +0 -1
  44. package/skills/lark-apps/references/lark-apps-create.md +1 -2
  45. package/skills/lark-apps/references/lark-apps-db-execute.md +186 -2
  46. package/skills/lark-apps/references/lark-apps-db.md +4 -4
  47. package/skills/lark-apps/references/lark-apps-env-pull.md +1 -1
  48. package/skills/lark-apps/references/lark-apps-file.md +2 -2
  49. package/skills/lark-apps/references/lark-apps-get.md +43 -0
  50. package/skills/lark-apps/references/lark-apps-git-credential.md +1 -1
  51. package/skills/lark-apps/references/lark-apps-html-publish.md +5 -4
  52. package/skills/lark-apps/references/lark-apps-init.md +2 -3
  53. package/skills/lark-apps/references/lark-apps-list.md +1 -1
  54. package/skills/lark-apps/references/lark-apps-local-dev.md +54 -11
  55. package/skills/lark-apps/references/lark-apps-openapi-key.md +1 -1
  56. package/skills/lark-apps/references/lark-apps-release-create.md +5 -3
  57. package/skills/lark-apps/references/lark-apps-release-get.md +3 -3
  58. package/skills/lark-apps/references/lark-apps-role.md +133 -0
  59. package/skills/lark-base/SKILL.md +26 -15
  60. package/skills/lark-base/references/dashboard-block-data-config.md +28 -2
  61. package/skills/lark-base/references/lark-base-cell-value.md +12 -7
  62. package/skills/lark-base/references/lark-base-dashboard-block-get-data.md +7 -7
  63. package/skills/lark-base/references/lark-base-dashboard.md +11 -2
  64. package/skills/lark-base/references/lark-base-data-query.md +20 -11
  65. package/skills/lark-base/references/lark-base-field-create.md +8 -2
  66. package/skills/lark-base/references/lark-base-field-json.md +56 -19
  67. package/skills/lark-base/references/lark-base-field-update.md +21 -3
  68. package/skills/lark-base/references/lark-base-filter-condition.md +179 -0
  69. package/skills/lark-base/references/lark-base-form-questions-create.md +40 -7
  70. package/skills/lark-base/references/lark-base-form-questions-update.md +73 -20
  71. package/skills/lark-base/references/lark-base-form-submit.md +16 -7
  72. package/skills/lark-base/references/lark-base-record-batch-create.md +12 -10
  73. package/skills/lark-base/references/lark-base-record-batch-update.md +11 -9
  74. package/skills/lark-base/references/lark-base-record-upsert.md +1 -1
  75. package/skills/lark-base/references/lark-base-role-guide.md +11 -0
  76. package/skills/lark-base/references/lark-base-view-set-filter.md +14 -138
  77. package/skills/lark-base/references/role-config.md +31 -5
  78. package/skills/lark-calendar/SKILL.md +101 -37
  79. package/skills/lark-calendar/references/lark-calendar-create.md +13 -43
  80. package/skills/lark-calendar/references/lark-calendar-recurring.md +1 -0
  81. package/skills/lark-calendar/references/lark-calendar-room-find.md +7 -10
  82. package/skills/lark-calendar/references/lark-calendar-rsvp.md +1 -5
  83. package/skills/lark-calendar/references/lark-calendar-schedule-clear-time.md +60 -0
  84. package/skills/lark-calendar/references/lark-calendar-schedule-fuzzy-time.md +88 -0
  85. package/skills/lark-calendar/references/lark-calendar-schedule-meeting.md +67 -210
  86. package/skills/lark-calendar/references/lark-calendar-suggestion.md +2 -6
  87. package/skills/lark-calendar/references/lark-calendar-update.md +12 -11
  88. package/skills/lark-contact/SKILL.md +19 -3
  89. package/skills/lark-contact/references/lark-contact-search-bot.md +60 -0
  90. package/skills/lark-doc/SKILL.md +1 -1
  91. package/skills/lark-doc/references/lark-doc-fetch.md +14 -4
  92. package/skills/lark-doc/references/lark-doc-mindnote.md +17 -2
  93. package/skills/lark-doc/references/lark-doc-whiteboard.md +13 -8
  94. package/skills/lark-doc/references/lark-doc-xml-extended-blocks.md +76 -0
  95. package/skills/lark-doc/references/lark-doc-xml.md +6 -4
  96. package/skills/lark-drive/SKILL.md +35 -43
  97. package/skills/lark-drive/references/lark-drive-add-comment.md +2 -4
  98. package/skills/lark-drive/references/lark-drive-add-reply.md +47 -0
  99. package/skills/lark-drive/references/lark-drive-apply-permission.md +2 -2
  100. package/skills/lark-drive/references/lark-drive-batch-query-comments.md +46 -0
  101. package/skills/lark-drive/references/lark-drive-comment-content.md +50 -0
  102. package/skills/lark-drive/references/lark-drive-comment-location.md +18 -12
  103. package/skills/lark-drive/references/lark-drive-delete-reply.md +48 -0
  104. package/skills/lark-drive/references/lark-drive-delete.md +35 -11
  105. package/skills/lark-drive/references/lark-drive-download.md +5 -1
  106. package/skills/lark-drive/references/lark-drive-export.md +39 -10
  107. package/skills/lark-drive/references/lark-drive-files-list.md +27 -2
  108. package/skills/lark-drive/references/lark-drive-inspect.md +2 -0
  109. package/skills/lark-drive/references/lark-drive-list-comments.md +82 -0
  110. package/skills/lark-drive/references/lark-drive-list-replies.md +54 -0
  111. package/skills/lark-drive/references/lark-drive-member-add.md +3 -3
  112. package/skills/lark-drive/references/lark-drive-member-list.md +65 -0
  113. package/skills/lark-drive/references/lark-drive-move.md +5 -3
  114. package/skills/lark-drive/references/lark-drive-permission-get-setting.md +48 -0
  115. package/skills/lark-drive/references/lark-drive-permission-guide.md +12 -0
  116. package/skills/lark-drive/references/lark-drive-preview.md +11 -1
  117. package/skills/lark-drive/references/lark-drive-pull.md +3 -3
  118. package/skills/lark-drive/references/lark-drive-push.md +33 -6
  119. package/skills/lark-drive/references/lark-drive-react-reply.md +51 -0
  120. package/skills/lark-drive/references/lark-drive-reactions.md +27 -25
  121. package/skills/lark-drive/references/lark-drive-resolve-comment.md +45 -0
  122. package/skills/lark-drive/references/lark-drive-restore-comment.md +46 -0
  123. package/skills/lark-drive/references/lark-drive-search.md +7 -1
  124. package/skills/lark-drive/references/lark-drive-secure-label.md +1 -1
  125. package/skills/lark-drive/references/lark-drive-status.md +12 -14
  126. package/skills/lark-drive/references/lark-drive-task-result.md +58 -5
  127. package/skills/lark-drive/references/lark-drive-update-reply.md +46 -0
  128. package/skills/lark-drive/references/lark-drive-upload.md +1 -0
  129. package/skills/lark-drive/references/lark-drive-workflow-knowledge-organize.md +26 -20
  130. package/skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md +38 -8
  131. package/skills/lark-drive/references/lark-drive-workflow-permission-governance-outputs.md +10 -10
  132. package/skills/lark-drive/references/lark-drive-workflow-permission-governance.md +22 -20
  133. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-execute.md +273 -0
  134. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-recall.md +202 -0
  135. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-resolve-verify.md +231 -0
  136. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-review-plan.md +248 -0
  137. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-setup.md +174 -0
  138. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector.md +202 -0
  139. package/skills/lark-drive/references/lark-drive-workflow.md +5 -3
  140. package/skills/lark-event/SKILL.md +3 -1
  141. package/skills/lark-event/references/lark-event-application.md +38 -0
  142. package/skills/lark-event/references/lark-event-approval.md +170 -0
  143. package/skills/lark-im/SKILL.md +6 -5
  144. package/skills/lark-im/references/card/card-2.0-schema.md +1 -1
  145. package/skills/lark-im/references/card/lark-im-card-style.md +4 -4
  146. package/skills/lark-im/references/card/resource/icons.md +14 -0
  147. package/skills/lark-im/references/lark-im-flag-list.md +8 -7
  148. package/skills/lark-im/references/lark-im-messages-reply.md +1 -1
  149. package/skills/lark-im/references/lark-im-messages-send.md +1 -1
  150. package/skills/lark-mail/SKILL.md +12 -9
  151. package/skills/lark-mail/references/lark-mail-forward.md +1 -1
  152. package/skills/lark-mail/references/lark-mail-message-modify.md +48 -0
  153. package/skills/lark-mail/references/lark-mail-message-trash.md +41 -0
  154. package/skills/lark-mail/references/lark-mail-reply-all.md +1 -1
  155. package/skills/lark-mail/references/lark-mail-reply.md +1 -1
  156. package/skills/lark-mail/references/lark-mail-watch.md +1 -1
  157. package/skills/lark-markdown/SKILL.md +3 -2
  158. package/skills/lark-markdown/references/lark-markdown-create.md +22 -2
  159. package/skills/lark-minutes/SKILL.md +19 -4
  160. package/skills/lark-minutes/references/lark-minutes-download.md +0 -2
  161. package/skills/lark-minutes/references/lark-minutes-search.md +0 -2
  162. package/skills/lark-minutes/references/lark-minutes-speaker-replace.md +0 -2
  163. package/skills/lark-minutes/references/lark-minutes-summary.md +0 -2
  164. package/skills/lark-minutes/references/lark-minutes-todo.md +2 -4
  165. package/skills/lark-minutes/references/lark-minutes-update.md +0 -2
  166. package/skills/lark-minutes/references/lark-minutes-upload.md +10 -10
  167. package/skills/lark-okr/SKILL.md +71 -26
  168. package/skills/lark-okr/references/lark-okr-batch-create.md +19 -18
  169. package/skills/lark-okr/references/lark-okr-create.md +173 -0
  170. package/skills/lark-okr/references/lark-okr-cycle-list.md +17 -7
  171. package/skills/lark-okr/references/lark-okr-entities.md +1 -0
  172. package/skills/lark-okr/references/lark-okr-indicator-update.md +3 -1
  173. package/skills/lark-okr/references/lark-okr-indicators.md +61 -12
  174. package/skills/lark-okr/references/lark-okr-progress-list.md +21 -9
  175. package/skills/lark-shared/SKILL.md +26 -8
  176. package/skills/lark-sheets/SKILL.md +98 -29
  177. package/skills/lark-sheets/references/lark-sheets-batch-update.md +18 -9
  178. package/skills/lark-sheets/references/lark-sheets-changeset.md +105 -0
  179. package/skills/lark-sheets/references/lark-sheets-chart.md +4 -2
  180. package/skills/lark-sheets/references/lark-sheets-conditional-format.md +2 -0
  181. package/skills/lark-sheets/references/lark-sheets-filter-view.md +1 -1
  182. package/skills/lark-sheets/references/lark-sheets-float-image.md +6 -6
  183. package/skills/lark-sheets/references/lark-sheets-formula-translation.md +12 -3
  184. package/skills/lark-sheets/references/lark-sheets-formula-verify.md +77 -0
  185. package/skills/lark-sheets/references/lark-sheets-history.md +93 -0
  186. package/skills/lark-sheets/references/lark-sheets-pivot-table.md +7 -2
  187. package/skills/lark-sheets/references/lark-sheets-range-operations.md +44 -14
  188. package/skills/lark-sheets/references/lark-sheets-read-data.md +3 -3
  189. package/skills/lark-sheets/references/lark-sheets-sheet-structure.md +4 -4
  190. package/skills/lark-sheets/references/lark-sheets-visual-standards.md +4 -4
  191. package/skills/lark-sheets/references/lark-sheets-workbook.md +29 -4
  192. package/skills/lark-sheets/references/lark-sheets-write-cells.md +21 -11
  193. package/skills/lark-slides/SKILL.md +121 -63
  194. package/skills/lark-slides/references/asset-planning.md +18 -5
  195. package/skills/lark-slides/references/iconpark.md +3 -3
  196. package/skills/lark-slides/references/lark-slides-create.md +30 -3
  197. package/skills/lark-slides/references/lark-slides-history.md +132 -0
  198. package/skills/lark-slides/references/lark-slides-media-upload.md +1 -3
  199. package/skills/lark-slides/references/lark-slides-pptx-template-workflows.md +85 -0
  200. package/skills/lark-slides/references/lark-slides-replace-pages.md +1 -1
  201. package/skills/lark-slides/references/lark-slides-replace-slide.md +1 -4
  202. package/skills/lark-slides/references/lark-slides-screenshot.md +11 -8
  203. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-create.md +5 -6
  204. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-delete.md +5 -2
  205. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-get.md +5 -5
  206. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-replace.md +14 -13
  207. package/skills/lark-slides/references/lark-slides-xml-presentations-get.md +67 -31
  208. package/skills/lark-slides/references/planning-layer.md +41 -10
  209. package/skills/lark-slides/references/slides_chart_demo.xml +1416 -0
  210. package/skills/lark-slides/references/slides_xml_schema_definition.xml +499 -78
  211. package/skills/lark-slides/references/troubleshooting.md +5 -5
  212. package/skills/lark-slides/references/validation-checklist.md +65 -19
  213. package/skills/lark-slides/references/visual-planning.md +26 -22
  214. package/skills/lark-slides/references/xml-schema-quick-ref.md +285 -45
  215. package/skills/lark-slides/scripts/sxsd_validator.py +908 -0
  216. package/skills/lark-slides/scripts/xml_text_overlap_lint.py +2429 -91
  217. package/skills/lark-slides/scripts/xml_text_overlap_lint_test.py +3567 -70
  218. package/skills/lark-task/SKILL.md +8 -0
  219. package/skills/lark-task/references/lark-task-complete.md +6 -2
  220. package/skills/lark-task/references/lark-task-create.md +23 -1
  221. package/skills/lark-task/references/lark-task-update.md +6 -2
  222. package/skills/lark-vc/SKILL.md +6 -3
  223. package/skills/lark-vc/references/lark-vc-recording.md +0 -2
  224. package/skills/lark-vc/references/vc-domain-boundaries.md +9 -1
  225. package/skills/lark-vc-agent/SKILL.md +25 -15
  226. package/skills/lark-vc-agent/references/lark-vc-agent-meeting-events.md +65 -37
  227. package/skills/lark-vc-agent/references/lark-vc-agent-meeting-leave.md +1 -1
  228. package/skills/lark-vc-agent/references/lark-vc-agent-meeting-list-active.md +8 -8
  229. package/skills/lark-whiteboard/SKILL.md +13 -12
  230. package/skills/lark-whiteboard/elements/layout.md +1 -1
  231. package/skills/lark-whiteboard/elements/schema.md +2 -2
  232. package/skills/lark-whiteboard/references/{lark-whiteboard-query.md → lark-whiteboard-export.md} +15 -15
  233. package/skills/lark-whiteboard/references/lark-whiteboard-update.md +3 -3
  234. package/skills/lark-whiteboard/references/lark-whiteboard-workflow.md +12 -19
  235. package/skills/lark-whiteboard/routes/dsl.md +3 -3
  236. package/skills/lark-whiteboard/routes/mermaid.md +2 -2
  237. package/skills/lark-whiteboard/routes/svg-edit.md +4 -4
  238. package/skills/lark-whiteboard/routes/svg.md +11 -6
  239. package/skills/lark-whiteboard/scenes/bar-chart.md +1 -1
  240. package/skills/lark-whiteboard/scenes/fishbone.md +1 -1
  241. package/skills/lark-whiteboard/scenes/flywheel.md +1 -1
  242. package/skills/lark-whiteboard/scenes/line-chart.md +1 -1
  243. package/skills/lark-whiteboard/scenes/treemap.md +1 -1
  244. package/skills/lark-wiki/SKILL.md +8 -3
  245. package/skills/lark-wiki/references/lark-wiki-move-to-drive.md +122 -0
  246. package/skills/lark-wiki/references/lark-wiki-move.md +5 -3
  247. package/skills/lark-wiki/references/lark-wiki-node-get.md +1 -1
  248. package/skills/lark-wiki/references/lark-wiki-node-list.md +9 -2
  249. package/skills/lark-calendar/references/lark-calendar-agenda.md +0 -78
  250. package/skills/lark-calendar/references/lark-calendar-freebusy.md +0 -124
  251. package/skills/lark-calendar/references/lark-calendar-search-event.md +0 -29
  252. package/skills/lark-drive/references/lark-drive-comments-guide.md +0 -72
  253. package/skills/lark-sheets/references/lark-sheets-core-operations.md +0 -103
  254. package/skills/lark-slides/references/examples.md +0 -261
  255. package/skills/lark-slides/references/lark-slides-whiteboard.md +0 -330
  256. package/skills/lark-slides/references/slide-templates.md +0 -201
  257. package/skills/lark-slides/references/slides_demo.xml +0 -226
  258. package/skills/lark-slides/references/xml-format-guide.md +0 -369
@@ -2,13 +2,22 @@
2
2
  # SPDX-License-Identifier: MIT
3
3
  from __future__ import annotations
4
4
 
5
+ import itertools
6
+ import json
7
+ import subprocess
8
+ import sys
9
+ import tempfile
5
10
  import unittest
11
+ import xml.etree.ElementTree as ET
12
+ from pathlib import Path
13
+ from unittest import mock
6
14
 
15
+ import sxsd_validator
7
16
  import xml_text_overlap_lint
8
17
 
9
18
 
10
- class XmlTextOverlapLintTest(unittest.TestCase):
11
- def assertNoXmlTextOverlapLintIssues(self, result: dict, sample_name: str) -> None:
19
+ class XmlTextOverlapLintGeometryTest(unittest.TestCase):
20
+ def assertNoXmlTextOverlapLintErrors(self, result: dict, sample_name: str) -> None:
12
21
  issue_summaries = []
13
22
  for slide in result.get("slides", []):
14
23
  for issue in slide.get("issues", []):
@@ -23,12 +32,56 @@ class XmlTextOverlapLintTest(unittest.TestCase):
23
32
  0,
24
33
  f"{sample_name} has XML text overlap lint errors:\n" + "\n".join(issue_summaries),
25
34
  )
35
+
36
+ def test_cli_suggests_input_flag_for_positional_argument(self) -> None:
37
+ script_path = Path(xml_text_overlap_lint.__file__).resolve()
38
+ input_path = "/sandboxdata/workspace/file/full_presentation.xml"
39
+
40
+ completed = subprocess.run(
41
+ [sys.executable, str(script_path), input_path],
42
+ capture_output=True,
43
+ check=False,
44
+ text=True,
45
+ )
46
+
47
+ self.assertEqual(completed.returncode, 1)
48
+ self.assertEqual(completed.stdout, "")
26
49
  self.assertEqual(
27
- result["summary"]["warning_count"],
28
- 0,
29
- f"{sample_name} has XML text overlap lint warnings:\n" + "\n".join(issue_summaries),
50
+ completed.stderr,
51
+ f"xml-text-overlap-lint error: unexpected argument: {input_path}, need --input\n",
30
52
  )
31
53
 
54
+ def test_cli_reports_structured_slide_sxsd_error_outside_skill_directory(self) -> None:
55
+ script_path = Path(xml_text_overlap_lint.__file__).resolve()
56
+ with tempfile.TemporaryDirectory() as temp_dir:
57
+ input_path = Path(temp_dir) / "invalid-slide.xml"
58
+ input_path.write_text(
59
+ """
60
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
61
+ <data><shape type="text" topLeftX="10" topLeftY="20" width="300"/></data>
62
+ </slide>
63
+ """,
64
+ encoding="utf-8",
65
+ )
66
+
67
+ completed = subprocess.run(
68
+ [sys.executable, str(script_path), "--input", str(input_path)],
69
+ cwd=temp_dir,
70
+ capture_output=True,
71
+ check=False,
72
+ text=True,
73
+ )
74
+
75
+ result = json.loads(completed.stdout)
76
+ issue = result["slides"][0]["errors"][0]
77
+ self.assertEqual(completed.returncode, 1)
78
+ self.assertEqual(completed.stderr, "")
79
+ self.assertEqual(issue["code"], "sxsd_missing_required_attr")
80
+ self.assertEqual(issue["path"], "slide/data/shape")
81
+ self.assertEqual(issue["attr"], "height")
82
+ self.assertEqual(issue["target"]["slide_number"], 1)
83
+ self.assertTrue(issue["hint"])
84
+
32
85
  def test_xml_text_overlap_lint_accepts_inline_fixture_xml_samples(self) -> None:
33
86
  samples = {
34
87
  "image-led-cover": """
@@ -78,7 +131,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
78
131
  sample_xml,
79
132
  sample_name,
80
133
  )
81
- self.assertNoXmlTextOverlapLintIssues(result, sample_name)
134
+ self.assertNoXmlTextOverlapLintErrors(result, sample_name)
82
135
 
83
136
  def test_lint_xml_reports_unescaped_ampersand_in_text(self) -> None:
84
137
  result = xml_text_overlap_lint.lint_xml(
@@ -117,6 +170,142 @@ class XmlTextOverlapLintTest(unittest.TestCase):
117
170
  self.assertIn("attribute", issue["hint"])
118
171
  self.assertIn("a=1&amp;b=2", issue["hint"])
119
172
 
173
+ def test_lint_xml_reports_mismatched_xml_tag(self) -> None:
174
+ result = xml_text_overlap_lint.lint_xml(
175
+ """
176
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
177
+ <data>
178
+ <shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
179
+ <content textType="body"><p>Broken XML</content>
180
+ </shape>
181
+ </data>
182
+ </slide>
183
+ """
184
+ )
185
+ issue = result["issues"][0]
186
+ self.assertEqual(result["summary"]["slide_count"], 0)
187
+ self.assertEqual(result["summary"]["error_count"], 1)
188
+ self.assertEqual(issue["code"], "xml_not_well_formed")
189
+ self.assertIsInstance(issue["line"], int)
190
+ self.assertIsInstance(issue["column"], int)
191
+ self.assertIn("Broken XML", issue["context"])
192
+
193
+ def test_lint_xml_rejects_prefixed_sml_tags(self) -> None:
194
+ result = xml_text_overlap_lint.lint_xml(
195
+ """
196
+ <ns0:slide xmlns:ns0="http://www.larkoffice.com/sml/2.0">
197
+ <ns0:data>
198
+ <sml:shape xmlns:sml="http://www.larkoffice.com/sml/2.0" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
199
+ <sml:content textType="body"><sml:p>Prefixed SML</sml:p></sml:content>
200
+ </sml:shape>
201
+ </ns0:data>
202
+ </ns0:slide>
203
+ """
204
+ )
205
+ issues = result["issues"]
206
+ self.assertEqual(result["summary"]["error_count"], 5)
207
+ self.assertEqual([issue["code"] for issue in issues], ["sml_prefixed_tag"] * 5)
208
+ self.assertEqual(issues[0]["tag"], "ns0:slide")
209
+ self.assertIn("default namespace", issues[0]["hint"])
210
+
211
+ def test_lint_xml_accepts_server_readback_slide_without_namespace(self) -> None:
212
+ result = xml_text_overlap_lint.lint_xml(
213
+ """
214
+ <slide id="server-slide-id">
215
+ <data>
216
+ <shape id="server-shape-id" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
217
+ <content textType="body"><p>Unprefixed SML</p></content>
218
+ </shape>
219
+ </data>
220
+ </slide>
221
+ """
222
+ )
223
+ self.assertEqual(result["summary"]["error_count"], 0)
224
+
225
+ def test_lint_xml_accepts_server_readback_presentation_short_namespace(self) -> None:
226
+ result = xml_text_overlap_lint.lint_xml(
227
+ """
228
+ <presentation xmlns="/sml/2.0" width="960" height="540" id="server-presentation-id">
229
+ <slide id="server-slide-id">
230
+ <data>
231
+ <shape id="server-shape-id" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
232
+ <content textType="body"><p>Server readback presentation</p></content>
233
+ </shape>
234
+ </data>
235
+ </slide>
236
+ </presentation>
237
+ """
238
+ )
239
+
240
+ self.assertEqual(result["summary"]["slide_count"], 1)
241
+ self.assertEqual(result["summary"]["error_count"], 0)
242
+
243
+ def test_lint_xml_accepts_server_readback_presentation_https_namespace(self) -> None:
244
+ result = xml_text_overlap_lint.lint_xml(
245
+ """
246
+ <presentation xmlns="https://www.larkoffice.com/sml/2.0" width="960" height="540" id="server-presentation-id">
247
+ <slide id="server-slide-id">
248
+ <data>
249
+ <shape id="server-shape-id" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
250
+ <content textType="body"><p>Server readback presentation</p></content>
251
+ </shape>
252
+ </data>
253
+ </slide>
254
+ </presentation>
255
+ """
256
+ )
257
+
258
+ self.assertEqual(result["summary"]["slide_count"], 1)
259
+ self.assertEqual(result["summary"]["error_count"], 0)
260
+
261
+ def test_lint_xml_rejects_server_readback_presentation_wrong_namespace(self) -> None:
262
+ result = xml_text_overlap_lint.lint_xml(
263
+ """
264
+ <presentation xmlns="https://example.com/not-sml" width="960" height="540">
265
+ <slide/>
266
+ </presentation>
267
+ """
268
+ )
269
+
270
+ self.assertEqual(result["summary"]["error_count"], 1)
271
+ self.assertEqual(result["issues"][0]["code"], "sxsd_invalid_namespace")
272
+
273
+ def test_lint_xml_rejects_xml_declaration(self) -> None:
274
+ result = xml_text_overlap_lint.lint_xml(
275
+ '<?xml version="1.0"?><slide xmlns="http://www.larkoffice.com/sml/2.0"><data/></slide>'
276
+ )
277
+
278
+ self.assertEqual(result["summary"]["error_count"], 1)
279
+ self.assertEqual(result["issues"][0]["code"], "sxsd_unsupported_declaration")
280
+
281
+ def test_lint_xml_reports_missing_required_sxsd_attribute(self) -> None:
282
+ result = xml_text_overlap_lint.lint_xml(
283
+ """
284
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
285
+ <data><shape type="text" topLeftX="80" topLeftY="80" width="300"/></data>
286
+ </slide>
287
+ """
288
+ )
289
+
290
+ self.assertEqual(result["summary"]["error_count"], 1)
291
+ self.assertNotIn("issues", result)
292
+ issue = result["slides"][0]["issues"][0]
293
+ self.assertEqual(issue["code"], "sxsd_missing_required_attr")
294
+ self.assertEqual(issue["attr"], "height")
295
+
296
+ def test_lint_xml_rejects_child_order_that_violates_xsd_sequence(self) -> None:
297
+ result = xml_text_overlap_lint.lint_xml(
298
+ """
299
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
300
+ <slide/>
301
+ <title>Late title</title>
302
+ </presentation>
303
+ """
304
+ )
305
+
306
+ self.assertEqual(result["summary"]["error_count"], 1)
307
+ self.assertEqual(result["issues"][0]["code"], "sxsd_invalid_child_order")
308
+
120
309
  def test_lint_xml_accepts_escaped_entities_without_suspicious_entity_warning(self) -> None:
121
310
  result = xml_text_overlap_lint.lint_xml(
122
311
  """
@@ -146,7 +335,7 @@ class XmlTextOverlapLintTest(unittest.TestCase):
146
335
  )
147
336
  self.assertEqual(result["summary"]["error_count"], 0)
148
337
 
149
- def test_lint_xml_single_slide_uses_default_canvas_without_bounds_checks(self) -> None:
338
+ def test_lint_xml_single_slide_reports_out_of_canvas_and_blank_slide_errors(self) -> None:
150
339
  result = xml_text_overlap_lint.lint_xml(
151
340
  """
152
341
  <slide xmlns="http://www.larkoffice.com/sml/2.0">
@@ -160,139 +349,3447 @@ class XmlTextOverlapLintTest(unittest.TestCase):
160
349
  )
161
350
  self.assertEqual(result["slide_size"], {"width": 960, "height": 540})
162
351
  self.assertEqual(result["summary"]["slide_count"], 1)
163
- self.assertEqual(result["summary"]["error_count"], 0)
352
+ self.assertEqual(result["summary"]["error_count"], 2)
353
+ self.assertEqual(
354
+ [issue["code"] for issue in result["slides"][0]["errors"]],
355
+ ["shape_out_of_canvas", "blank_slide"],
356
+ )
164
357
 
165
- def test_lint_xml_detects_overlapping_text_boxes(self) -> None:
358
+ def test_lint_xml_preserves_presentation_canvas_and_slide_order(self) -> None:
166
359
  result = xml_text_overlap_lint.lint_xml(
167
360
  """
168
- <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
361
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="1280" height="720">
169
362
  <slide xmlns="http://www.larkoffice.com/sml/2.0">
170
363
  <data>
171
- <shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
172
- <content textType="title"><p>Title</p></content>
364
+ <shape id="first" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
365
+ <content textType="body"><p>First slide</p></content>
173
366
  </shape>
174
- <shape type="text" topLeftX="80" topLeftY="80" width="300" height="80">
175
- <content textType="body"><p>Body</p></content>
367
+ </data>
368
+ </slide>
369
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
370
+ <data>
371
+ <img id="second" src="tok" topLeftX="100" topLeftY="120" width="240" height="160"/>
372
+ <shape id="second-shape" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
373
+ <content textType="body"><p>Second slide</p></content>
176
374
  </shape>
177
375
  </data>
178
376
  </slide>
179
377
  </presentation>
180
378
  """
181
379
  )
182
- self.assertEqual(result["summary"]["error_count"], 1)
183
- self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap")
380
+ self.assertEqual(result["slide_size"], {"width": 1280, "height": 720})
381
+ self.assertEqual(result["summary"]["slide_count"], 2)
382
+ self.assertEqual([slide["slide_number"] for slide in result["slides"]], [1, 2])
383
+ self.assertEqual([slide["element_count"] for slide in result["slides"]], [1, 2])
384
+ self.assertEqual(result["summary"]["error_count"], 0)
385
+ self.assertEqual(result["summary"]["warning_count"], 0)
184
386
 
185
- def test_lint_xml_does_not_check_bounds_or_text_height(self) -> None:
387
+ def test_lint_xml_handles_self_closing_slide_before_normal_slide(self) -> None:
186
388
  result = xml_text_overlap_lint.lint_xml(
187
389
  """
188
390
  <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
189
- <slide xmlns="http://www.larkoffice.com/sml/2.0">
391
+ <slide/>
392
+ <slide>
190
393
  <data>
191
- <shape type="text" topLeftX="80" topLeftY="80" width="180" height="20">
192
- <content textType="body" fontSize="18"><p>This paragraph is intentionally much longer than the box can safely contain.</p></content>
193
- </shape>
194
- <shape type="text" topLeftX="1000" topLeftY="500" width="120" height="80">
195
- <content textType="body"><p>Body text outside the canvas</p></content>
394
+ <shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
395
+ <content textType="body"><p>Second slide</p></content>
196
396
  </shape>
197
397
  </data>
198
398
  </slide>
199
399
  </presentation>
200
400
  """
201
401
  )
202
- self.assertEqual(result["summary"]["error_count"], 0)
203
- self.assertEqual(result["summary"]["warning_count"], 0)
204
402
 
205
- def test_lint_xml_allows_template_style_bleed_and_text_over_images(self) -> None:
403
+ self.assertEqual(result["summary"]["slide_count"], 2)
404
+ self.assertEqual(
405
+ [issue["code"] for issue in result["slides"][0]["errors"]],
406
+ ["blank_slide"],
407
+ )
408
+ self.assertEqual(result["slides"][1]["status"], "passed")
409
+
410
+ def test_lint_xml_keeps_trailing_self_closing_slide(self) -> None:
206
411
  result = xml_text_overlap_lint.lint_xml(
207
412
  """
208
413
  <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
209
- <slide xmlns="http://www.larkoffice.com/sml/2.0">
414
+ <slide>
210
415
  <data>
211
- <img src="tok" topLeftX="-120" topLeftY="20" width="360" height="360"/>
212
- <shape type="text" topLeftX="40" topLeftY="80" width="180" height="80">
213
- <content textType="title" fontSize="44"><p>Title</p></content>
214
- </shape>
215
- <shape type="text" topLeftX="40" topLeftY="120" width="180" height="40">
216
- <content textType="sub-headline" fontSize="20"><p>Subtitle</p></content>
416
+ <shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
417
+ <content textType="body"><p>First slide</p></content>
217
418
  </shape>
218
419
  </data>
219
420
  </slide>
421
+ <slide/>
220
422
  </presentation>
221
423
  """
222
424
  )
223
- self.assertEqual(result["summary"]["error_count"], 0)
224
- self.assertEqual(result["summary"]["warning_count"], 0)
225
425
 
226
- def test_lint_xml_does_not_check_small_out_of_bounds_elements(self) -> None:
426
+ self.assertEqual(result["summary"]["slide_count"], 2)
427
+ self.assertEqual(
428
+ [issue["code"] for issue in result["slides"][1]["errors"]],
429
+ ["blank_slide"],
430
+ )
431
+
432
+ def test_lint_xml_ignores_slide_markup_inside_xml_comments(self) -> None:
227
433
  result = xml_text_overlap_lint.lint_xml(
228
434
  """
229
435
  <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
230
- <slide xmlns="http://www.larkoffice.com/sml/2.0">
436
+ <slide>
231
437
  <data>
232
- <img src="tok" topLeftX="-20" topLeftY="20" width="120" height="120"/>
438
+ <shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
439
+ <content textType="body"><p>Real slide</p></content>
440
+ </shape>
233
441
  </data>
234
442
  </slide>
443
+ <!-- Old draft: <slide id="ghost"/> -->
235
444
  </presentation>
236
445
  """
237
446
  )
238
- self.assertEqual(result["summary"]["error_count"], 0)
239
447
 
240
- def test_lint_xml_ignores_obviously_misplaced_large_visuals(self) -> None:
448
+ self.assertEqual(result["summary"]["slide_count"], 1)
449
+ self.assertEqual(result["slides"][0]["status"], "passed")
450
+
451
+ def test_lint_xml_ignores_invalid_slide_markup_inside_xml_comments(self) -> None:
241
452
  result = xml_text_overlap_lint.lint_xml(
242
453
  """
243
454
  <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
244
- <slide xmlns="http://www.larkoffice.com/sml/2.0">
455
+ <slide><data/></slide>
456
+ <!-- Old draft: <slide bogus="x"/> -->
457
+ </presentation>
458
+ """
459
+ )
460
+
461
+ self.assertEqual(result["summary"]["slide_count"], 1)
462
+ self.assertEqual(
463
+ [issue["code"] for issue in result["slides"][0]["errors"]],
464
+ ["blank_slide"],
465
+ )
466
+
467
+ def test_lint_xml_skips_only_invalid_slide_and_continues_geometry_checks(self) -> None:
468
+ result = xml_text_overlap_lint.lint_xml(
469
+ """
470
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
471
+ <slide>
245
472
  <data>
246
- <img src="right" topLeftX="780" topLeftY="0" width="500" height="540"/>
247
- <img src="bottom" topLeftX="0" topLeftY="430" width="900" height="280"/>
473
+ <shape id="invalid" type="text" topLeftX="1000" topLeftY="80" width="300">
474
+ <content textType="body"><p>Missing height</p></content>
475
+ </shape>
476
+ </data>
477
+ </slide>
478
+ <slide>
479
+ <data>
480
+ <shape id="outside" type="text" topLeftX="1000" topLeftY="80" width="120" height="60">
481
+ <content textType="body"><p>Outside canvas</p></content>
482
+ </shape>
248
483
  </data>
249
484
  </slide>
250
485
  </presentation>
251
486
  """
252
487
  )
253
- self.assertEqual(result["summary"]["error_count"], 0)
254
488
 
255
- def test_lint_xml_allows_reasonable_large_visual_bleed(self) -> None:
489
+ self.assertEqual(result["summary"]["slide_count"], 2)
490
+ self.assertEqual(result["slides"][0]["element_count"], 0)
491
+ self.assertEqual(
492
+ [issue["code"] for issue in result["slides"][0]["errors"]],
493
+ ["sxsd_missing_required_attr"],
494
+ )
495
+ self.assertNotIn(
496
+ "shape_out_of_canvas",
497
+ [issue["code"] for issue in result["slides"][0]["issues"]],
498
+ )
499
+ self.assertIn(
500
+ "shape_out_of_canvas",
501
+ [issue["code"] for issue in result["slides"][1]["errors"]],
502
+ )
503
+
504
+ def test_lint_xml_scopes_invalid_slide_root_attribute_to_that_slide(self) -> None:
256
505
  result = xml_text_overlap_lint.lint_xml(
257
506
  """
258
507
  <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
259
- <slide xmlns="http://www.larkoffice.com/sml/2.0">
260
- <data>
261
- <img src="tok" topLeftX="-80" topLeftY="-20" width="1080" height="600"/>
262
- </data>
508
+ <slide bogusAttr="oops">
509
+ <data><shape type="rect" topLeftX="80" topLeftY="80" width="100" height="100"/></data>
510
+ </slide>
511
+ <slide>
512
+ <data><shape type="rect" topLeftX="1000" topLeftY="80" width="100" height="100"/></data>
263
513
  </slide>
264
514
  </presentation>
265
515
  """
266
516
  )
267
- self.assertEqual(result["summary"]["error_count"], 0)
268
517
 
269
- def test_lint_xml_detects_invalid_template_text_stack_overlap(self) -> None:
518
+ self.assertEqual(result["summary"]["slide_count"], 2)
519
+ self.assertEqual(
520
+ [issue["code"] for issue in result["slides"][0]["errors"]],
521
+ ["sxsd_unsupported_attr"],
522
+ )
523
+ self.assertIn(
524
+ "shape_out_of_canvas",
525
+ [issue["code"] for issue in result["slides"][1]["errors"]],
526
+ )
527
+
528
+ def test_lint_xml_reports_sxsd_unsupported_tag_with_alias_hint(self) -> None:
270
529
  cases = [
271
- (
272
- "subtitle-too-high",
273
- """
274
- <shape type="text" topLeftX="40" topLeftY="80" width="240" height="90">
275
- <content textType="title" fontSize="44"><p>Title</p></content>
276
- </shape>
277
- <shape type="text" topLeftX="40" topLeftY="90" width="240" height="80">
278
- <content textType="sub-headline" fontSize="20"><p>Subtitle</p></content>
279
- </shape>
280
- """,
281
- ),
530
+ ("textbox", '<textbox topLeftX="80" topLeftY="80" width="300" height="60">Text</textbox>', '<shape type="text">'),
531
+ ("image", '<image src="tok" topLeftX="80" topLeftY="80" width="300" height="180"/>', "<img>"),
282
532
  ]
283
- for name, shapes in cases:
284
- with self.subTest(name=name):
533
+ for tag_name, element_xml, expected_hint in cases:
534
+ with self.subTest(tag=tag_name):
285
535
  result = xml_text_overlap_lint.lint_xml(
286
536
  f"""
287
- <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
288
- <slide xmlns="http://www.larkoffice.com/sml/2.0">
289
- <data>{shapes}</data>
290
- </slide>
291
- </presentation>
537
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
538
+ <data>{element_xml}</data>
539
+ </slide>
292
540
  """
293
541
  )
542
+ slide_issues = result["slides"][0]["issues"]
543
+ issue = next(
544
+ issue for issue in slide_issues if issue["code"] == "sxsd_unsupported_tag"
545
+ )
294
546
  self.assertEqual(result["summary"]["error_count"], 1)
295
- self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap")
547
+ self.assertEqual(
548
+ [reported["code"] for reported in slide_issues],
549
+ ["sxsd_unsupported_tag"],
550
+ )
551
+ self.assertEqual(issue["code"], "sxsd_unsupported_tag")
552
+ self.assertEqual(issue["tag"], tag_name)
553
+ self.assertIn(expected_hint, issue["hint"])
554
+
555
+ def test_lint_xml_reports_sxsd_unsupported_attr_with_alias_hint(self) -> None:
556
+ cases = [
557
+ ("shape", "x", "topLeftX", '<shape type="text" x="80" topLeftY="80" width="300" height="60"><content><p>Text</p></content></shape>'),
558
+ ("shape", "heigth", "height", '<shape type="text" topLeftX="80" topLeftY="80" width="300" heigth="60"><content><p>Text</p></content></shape>'),
559
+ ("content", "fontColor", "color", '<shape type="text" topLeftX="80" topLeftY="80" width="300" height="60"><content fontColor="rgba(0, 0, 0, 1)"><p>Text</p></content></shape>'),
560
+ ]
561
+ for tag_name, attr_name, expected_attr, element_xml in cases:
562
+ with self.subTest(attr=attr_name):
563
+ result = xml_text_overlap_lint.lint_xml(
564
+ f"""
565
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
566
+ <data>{element_xml}</data>
567
+ </slide>
568
+ """
569
+ )
570
+ slide_issues = result["slides"][0]["issues"]
571
+ issue = next(
572
+ issue for issue in slide_issues if issue["code"] == "sxsd_unsupported_attr"
573
+ )
574
+ self.assertEqual(result["summary"]["error_count"], 1)
575
+ self.assertEqual(
576
+ [reported["code"] for reported in slide_issues],
577
+ ["sxsd_unsupported_attr"],
578
+ )
579
+ self.assertEqual(issue["code"], "sxsd_unsupported_attr")
580
+ self.assertEqual(issue["tag"], tag_name)
581
+ self.assertEqual(issue["attr"], attr_name)
582
+ self.assertIn(expected_attr, issue["hint"])
583
+
584
+ def test_lint_xml_keeps_unrelated_unsupported_and_missing_attrs(self) -> None:
585
+ result = xml_text_overlap_lint.lint_xml(
586
+ """
587
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
588
+ <data>
589
+ <shape type="text" bogus="x" topLeftX="80" topLeftY="80" width="300"/>
590
+ </data>
591
+ </slide>
592
+ """
593
+ )
594
+
595
+ self.assertEqual(
596
+ [issue["code"] for issue in result["slides"][0]["issues"]],
597
+ ["sxsd_unsupported_attr", "sxsd_missing_required_attr"],
598
+ )
599
+
600
+ def test_lint_xml_keeps_missing_attrs_when_suggestion_is_ambiguous(self) -> None:
601
+ result = xml_text_overlap_lint.lint_xml(
602
+ """
603
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
604
+ <data>
605
+ <shape type="text" topLeft="80" width="300" height="60"/>
606
+ </data>
607
+ </slide>
608
+ """
609
+ )
610
+
611
+ self.assertEqual(
612
+ [issue["code"] for issue in result["slides"][0]["issues"]],
613
+ [
614
+ "sxsd_unsupported_attr",
615
+ "sxsd_missing_required_attr",
616
+ "sxsd_missing_required_attr",
617
+ ],
618
+ )
619
+
620
+ def test_lint_xml_suppresses_only_missing_attr_that_resolves_ambiguous_suggestion(self) -> None:
621
+ result = xml_text_overlap_lint.lint_xml(
622
+ """
623
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
624
+ <data>
625
+ <shape type="text" topLeftXX="80" topLeftY="80" width="300" height="60"/>
626
+ </data>
627
+ </slide>
628
+ """
629
+ )
630
+
631
+ self.assertEqual(
632
+ [
633
+ (issue["code"], issue.get("attr"))
634
+ for issue in result["slides"][0]["issues"]
635
+ ],
636
+ [("sxsd_unsupported_attr", "topLeftXX")],
637
+ )
638
+
639
+ def test_lint_xml_ignores_server_filled_id_attrs(self) -> None:
640
+ result = xml_text_overlap_lint.lint_xml(
641
+ """
642
+ <slide id="server-slide-id" xmlns="http://www.larkoffice.com/sml/2.0">
643
+ <data>
644
+ <shape id="server-shape-id" type="rect" topLeftX="80" topLeftY="80" width="300" height="160">
645
+ <fill id="server-fill-id" unexpected="value">
646
+ <fillColor color="rgba(255, 255, 255, 1)"/>
647
+ </fill>
648
+ </shape>
649
+ </data>
650
+ </slide>
651
+ """
652
+ )
653
+
654
+ self.assertEqual(result["summary"]["error_count"], 1)
655
+ self.assertEqual(
656
+ [(issue["tag"], issue["attr"]) for issue in result["slides"][0]["issues"]],
657
+ [("fill", "unexpected")],
658
+ )
659
+
660
+ def test_lint_xml_ignores_chart_roundtrip_attrs(self) -> None:
661
+ result = xml_text_overlap_lint.lint_xml(
662
+ """
663
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
664
+ <data>
665
+ <chart updated="true" topLeftX="80" topLeftY="80" width="300" height="160">
666
+ <chartPlotArea><chartPlot type="line"/></chartPlotArea>
667
+ <chartData isStaticData="true">
668
+ <dim1><chartField name="category" valueType="string">A</chartField></dim1>
669
+ <dim2><chartField name="value" valueType="number">1</chartField></dim2>
670
+ </chartData>
671
+ </chart>
672
+ </data>
673
+ </slide>
674
+ """
675
+ )
676
+
677
+ self.assertEqual(result["summary"]["error_count"], 0)
678
+
679
+ def test_lint_xml_ignores_chart_parsed_values_roundtrip_tags(self) -> None:
680
+ result = xml_text_overlap_lint.lint_xml(
681
+ """
682
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
683
+ <data>
684
+ <chart topLeftX="80" topLeftY="80" width="300" height="160">
685
+ <chartPlotArea><chartPlot type="line"/></chartPlotArea>
686
+ <chartData isStaticData="true">
687
+ <dim1>
688
+ <chartField name="category" valueType="string">
689
+ A<chartParsedValues>A</chartParsedValues>
690
+ </chartField>
691
+ </dim1>
692
+ <dim2><chartField name="value" valueType="number">1</chartField></dim2>
693
+ </chartData>
694
+ </chart>
695
+ </data>
696
+ </slide>
697
+ """
698
+ )
699
+
700
+ self.assertEqual(result["summary"]["error_count"], 0)
701
+
702
+ def test_lint_xml_ignores_chart_parsed_values_roundtrip_tag(self) -> None:
703
+ result = xml_text_overlap_lint.lint_xml(
704
+ """
705
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
706
+ <data>
707
+ <chart topLeftX="80" topLeftY="80" width="300" height="160">
708
+ <chartPlotArea><chartPlot type="line"/></chartPlotArea>
709
+ <chartData>
710
+ <dim1>
711
+ <chartField name="category" valueType="string">
712
+ Africa<chartParsedValues>Africa</chartParsedValues>
713
+ </chartField>
714
+ </dim1>
715
+ <dim2><chartField name="value" valueType="number">1</chartField></dim2>
716
+ </chartData>
717
+ </chart>
718
+ </data>
719
+ </slide>
720
+ """
721
+ )
722
+
723
+ self.assertEqual(result["summary"]["error_count"], 0)
724
+ self.assertNotIn("issues", result)
725
+
726
+ def test_lint_xml_rejects_chart_parsed_values_outside_chart_field(self) -> None:
727
+ result = xml_text_overlap_lint.lint_xml(
728
+ """
729
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
730
+ <data>
731
+ <shape type="rect" topLeftX="80" topLeftY="80" width="300" height="160">
732
+ <chartParsedValues>unexpected</chartParsedValues>
733
+ </shape>
734
+ </data>
735
+ </slide>
736
+ """
737
+ )
738
+
739
+ self.assertEqual(result["summary"]["error_count"], 1)
740
+ self.assertEqual(
741
+ [issue["code"] for issue in result["slides"][0]["issues"]],
742
+ ["sxsd_unsupported_tag"],
743
+ )
744
+ self.assertEqual(
745
+ result["slides"][0]["issues"][0]["path"],
746
+ "slide/data/shape/chartParsedValues",
747
+ )
748
+
749
+ def test_lint_xml_limits_chart_roundtrip_attrs_to_matching_tags(self) -> None:
750
+ result = xml_text_overlap_lint.lint_xml(
751
+ """
752
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
753
+ <data>
754
+ <chart isStaticData="true" topLeftX="80" topLeftY="80" width="300" height="160">
755
+ <chartPlotArea><chartPlot type="line"/></chartPlotArea>
756
+ <chartData updated="true">
757
+ <dim1><chartField name="category" valueType="string">A</chartField></dim1>
758
+ <dim2><chartField name="value" valueType="number">1</chartField></dim2>
759
+ </chartData>
760
+ </chart>
761
+ </data>
762
+ </slide>
763
+ """
764
+ )
765
+
766
+ self.assertEqual(result["summary"]["error_count"], 2)
767
+ slide_issues = result["slides"][0]["issues"]
768
+ self.assertEqual(
769
+ {(issue["tag"], issue["attr"]) for issue in slide_issues},
770
+ {("chart", "isStaticData"), ("chartData", "updated")},
771
+ )
772
+ self.assertTrue(all(issue["code"] == "sxsd_unsupported_attr" for issue in slide_issues))
773
+
774
+ def test_lint_xml_reports_gradient_shorthand_attrs_on_fill_color(self) -> None:
775
+ result = xml_text_overlap_lint.lint_xml(
776
+ """
777
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
778
+ <data>
779
+ <shape type="rect" topLeftX="80" topLeftY="80" width="300" height="160">
780
+ <fill>
781
+ <fillColor
782
+ type="gradient"
783
+ color1="rgba(255, 0, 0, 1)"
784
+ color2="rgba(0, 0, 255, 1)"
785
+ angle="45"
786
+ stop1="0%"
787
+ stop2="100%"/>
788
+ </fill>
789
+ </shape>
790
+ </data>
791
+ </slide>
792
+ """
793
+ )
794
+ slide_issues = result["slides"][0]["issues"]
795
+ unsupported_attrs = {issue["attr"] for issue in slide_issues}
796
+ self.assertEqual(result["summary"]["error_count"], 6)
797
+ self.assertEqual(
798
+ unsupported_attrs,
799
+ {"type", "color1", "color2", "angle", "stop1", "stop2"},
800
+ )
801
+ self.assertTrue(all(issue["code"] == "sxsd_unsupported_attr" for issue in slide_issues))
802
+ self.assertTrue(all(issue["tag"] == "fillColor" for issue in slide_issues))
803
+
804
+ def test_lint_xml_accepts_chart_field_simple_content_attrs(self) -> None:
805
+ result = xml_text_overlap_lint.lint_xml(
806
+ """
807
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
808
+ <data>
809
+ <chart topLeftX="80" topLeftY="80" width="520" height="320">
810
+ <chartPlotArea>
811
+ <chartPlot type="line"/>
812
+ </chartPlotArea>
813
+ <chartData>
814
+ <dim1>
815
+ <chartField name="month" valueType="string">Jan, Feb</chartField>
816
+ </dim1>
817
+ <dim2>
818
+ <chartField name="value" valueType="number">1, 2</chartField>
819
+ </dim2>
820
+ </chartData>
821
+ </chart>
822
+ </data>
823
+ </slide>
824
+ """
825
+ )
826
+ self.assertEqual(result["summary"]["error_count"], 0)
827
+ self.assertNotIn("issues", result)
828
+
829
+ def test_lint_xml_does_not_load_iconpark_index_without_icons(self) -> None:
830
+ original_loader = xml_text_overlap_lint.load_iconpark_icon_types
831
+
832
+ def fail_if_loaded() -> set[str]:
833
+ raise AssertionError("iconpark index should not be loaded without <icon iconType>")
834
+
835
+ xml_text_overlap_lint.load_iconpark_icon_types = fail_if_loaded
836
+ try:
837
+ result = xml_text_overlap_lint.lint_xml(
838
+ """
839
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
840
+ <data>
841
+ <shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
842
+ <content><p>No icons here</p></content>
843
+ </shape>
844
+ </data>
845
+ </slide>
846
+ """
847
+ )
848
+ finally:
849
+ xml_text_overlap_lint.load_iconpark_icon_types = original_loader
850
+
851
+ self.assertEqual(result["summary"]["error_count"], 0)
852
+ self.assertNotIn("issues", result)
853
+
854
+ def test_lint_xml_accepts_iconpark_icon_type_from_index(self) -> None:
855
+ result = xml_text_overlap_lint.lint_xml(
856
+ """
857
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
858
+ <data>
859
+ <icon iconType="iconpark/Base/setting.svg" topLeftX="80" topLeftY="80" width="48" height="48">
860
+ <fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
861
+ </icon>
862
+ </data>
863
+ </slide>
864
+ """
865
+ )
866
+ self.assertEqual(result["summary"]["error_count"], 0)
867
+ self.assertNotIn("issues", result)
868
+
869
+ def test_lint_xml_reports_icon_missing_fill_color(self) -> None:
870
+ cases = [
871
+ '<icon iconType="iconpark/Base/setting.svg" topLeftX="80" topLeftY="80" width="48" height="48"/>',
872
+ '<icon iconType="iconpark/Base/setting.svg" topLeftX="80" topLeftY="80" width="48" height="48"><fill/></icon>',
873
+ (
874
+ '<icon iconType="iconpark/Base/setting.svg" topLeftX="80" topLeftY="80" width="48" height="48">'
875
+ "<fill><fillColor/></fill></icon>"
876
+ ),
877
+ ]
878
+ for icon_xml in cases:
879
+ with self.subTest(icon=icon_xml):
880
+ result = xml_text_overlap_lint.lint_xml(
881
+ f"""
882
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
883
+ <data>{icon_xml}</data>
884
+ </slide>
885
+ """
886
+ )
887
+
888
+ issue = result["issues"][0]
889
+ self.assertEqual(result["summary"]["error_count"], 1)
890
+ self.assertEqual(issue["code"], "icon_missing_fill_color")
891
+ self.assertEqual(issue["tag"], "icon")
892
+
893
+ def test_lint_xml_reports_icon_transparent_fill_color(self) -> None:
894
+ result = xml_text_overlap_lint.lint_xml(
895
+ """
896
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
897
+ <data>
898
+ <icon iconType="iconpark/Base/setting.svg" topLeftX="80" topLeftY="80" width="48" height="48">
899
+ <fill><fillColor color="rgba(37, 99, 235, 0)"/></fill>
900
+ </icon>
901
+ </data>
902
+ </slide>
903
+ """
904
+ )
905
+ issue = result["issues"][0]
906
+ self.assertEqual(result["summary"]["error_count"], 1)
907
+ self.assertEqual(issue["code"], "icon_transparent_fill_color")
908
+ self.assertEqual(issue["tag"], "icon")
909
+ self.assertEqual(issue["attr"], "fillColor")
910
+
911
+ def test_lint_xml_reports_iconpark_icon_type_outside_index(self) -> None:
912
+ result = xml_text_overlap_lint.lint_xml(
913
+ """
914
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
915
+ <data>
916
+ <icon iconType="iconpark/Base/settng.svg" topLeftX="80" topLeftY="80" width="48" height="48">
917
+ <fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
918
+ </icon>
919
+ </data>
920
+ </slide>
921
+ """
922
+ )
923
+ issue = result["issues"][0]
924
+ self.assertEqual(result["summary"]["error_count"], 1)
925
+ self.assertEqual(issue["code"], "iconpark_unsupported_icon_type")
926
+ self.assertEqual(issue["tag"], "icon")
927
+ self.assertEqual(issue["attr"], "iconType")
928
+ self.assertEqual(issue["iconType"], "iconpark/Base/settng.svg")
929
+ self.assertIn("iconpark-index.json", issue["hint"])
930
+ self.assertIn("iconpark/Base/setting.svg", issue["hint"])
931
+
932
+ def test_lint_xml_detects_overlapping_text_boxes(self) -> None:
933
+ result = xml_text_overlap_lint.lint_xml(
934
+ """
935
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
936
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
937
+ <data>
938
+ <shape type="text" topLeftX="80" topLeftY="80" width="300" height="60">
939
+ <content textType="title"><p>Title</p></content>
940
+ </shape>
941
+ <shape type="text" topLeftX="80" topLeftY="80" width="300" height="80">
942
+ <content textType="body"><p>Body</p></content>
943
+ </shape>
944
+ </data>
945
+ </slide>
946
+ </presentation>
947
+ """
948
+ )
949
+ self.assertEqual(result["summary"]["error_count"], 1)
950
+ self.assertEqual(result["summary"]["warning_count"], 0)
951
+ self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap")
952
+
953
+ def test_lint_xml_detects_current_itinerary_cjk_caption_occlusion(self) -> None:
954
+ result = xml_text_overlap_lint.lint_xml(
955
+ """
956
+ <slide id="pQO" xmlns="http://www.larkoffice.com/sml/2.0">
957
+ <data>
958
+ <shape width="190" height="80" topLeftX="580" topLeftY="170" presetHandlers="0" type="rect" id="blI">
959
+ <fill><fillColor color="rgba(255, 255, 255, 0.9)"/></fill>
960
+ <border color="rgba(220, 205, 185, 1)" width="1"/>
961
+ <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
962
+ </shape>
963
+ <shape width="160" height="25" topLeftX="595" topLeftY="180" type="text" id="blX">
964
+ <content fontSize="14" fontFamily="思源黑体" color="rgba(120, 80, 40, 1)" bold="true"><p>日照金山</p></content>
965
+ </shape>
966
+ <shape width="160" height="40" topLeftX="595" topLeftY="205" type="text" id="blY">
967
+ <content textType="caption" fontSize="11" fontFamily="思源黑体" color="rgba(130, 100, 70, 1)"><p>清晨躺在床上看玉龙雪山日照金山奇观</p></content>
968
+ </shape>
969
+ <shape width="180" height="80" topLeftX="730" topLeftY="170" presetHandlers="0" type="rect" id="blH">
970
+ <fill><fillColor color="rgba(255, 255, 255, 0.9)"/></fill>
971
+ <border color="rgba(220, 205, 185, 1)" width="1"/>
972
+ <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
973
+ </shape>
974
+ <shape width="150" height="25" topLeftX="745" topLeftY="180" type="text" id="blp">
975
+ <content fontSize="14" fontFamily="思源黑体" color="rgba(120, 80, 40, 1)" bold="true"><p>午餐返程</p></content>
976
+ </shape>
977
+ <shape width="150" height="40" topLeftX="745" topLeftY="205" type="text" id="blV">
978
+ <content textType="caption" fontSize="11" fontFamily="思源黑体" color="rgba(130, 100, 70, 1)"><p>享用特色午餐,带着美好回忆返程</p></content>
979
+ </shape>
980
+ <shape width="190" height="80" topLeftX="580" topLeftY="310" presetHandlers="0" type="rect" id="blP">
981
+ <fill><fillColor color="rgba(255, 255, 255, 0.9)"/></fill>
982
+ <border color="rgba(220, 205, 185, 1)" width="1"/>
983
+ <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
984
+ </shape>
985
+ <shape width="160" height="25" topLeftX="595" topLeftY="320" type="text" id="blG">
986
+ <content fontSize="14" fontFamily="思源黑体" color="rgba(120, 80, 40, 1)" bold="true"><p>高路徒步</p></content>
987
+ </shape>
988
+ <shape width="160" height="40" topLeftX="595" topLeftY="345" type="text" id="blQ">
989
+ <content textType="caption" fontSize="11" fontFamily="思源黑体" color="rgba(130, 100, 70, 1)"><p>经典高路徒步,28道拐,龙洞瀑布,中虎跳峡</p></content>
990
+ </shape>
991
+ <shape width="180" height="80" topLeftX="730" topLeftY="310" presetHandlers="0" type="rect" id="blw">
992
+ <fill><fillColor color="rgba(255, 255, 255, 0.9)"/></fill>
993
+ <border color="rgba(220, 205, 185, 1)" width="1"/>
994
+ <content fontSize="16" fontFamily="思源黑体" color="rgba(31, 35, 41, 1)"/>
995
+ </shape>
996
+ <shape width="150" height="25" topLeftX="745" topLeftY="320" type="text" id="blZ">
997
+ <content fontSize="14" fontFamily="思源黑体" color="rgba(120, 80, 40, 1)" bold="true"><p>伴手礼</p></content>
998
+ </shape>
999
+ <shape width="150" height="40" topLeftX="745" topLeftY="345" type="text" id="blS">
1000
+ <content textType="caption" fontSize="11" fontFamily="思源黑体" color="rgba(130, 100, 70, 1)"><p>酒店精心准备的归途伴手礼,留下难忘纪念</p></content>
1001
+ </shape>
1002
+ </data>
1003
+ </slide>
1004
+ """
1005
+ )
1006
+ overlap_pairs = {tuple(issue["elements"]) for issue in result["slides"][0]["issues"]}
1007
+ self.assertEqual(result["summary"]["error_count"], 2)
1008
+ self.assertIn(("blY", "blV"), overlap_pairs)
1009
+ self.assertIn(("blQ", "blS"), overlap_pairs)
1010
+
1011
+ def test_lint_xml_detects_horizontal_text_overflow_across_declared_box_gap(self) -> None:
1012
+ result = xml_text_overlap_lint.lint_xml(
1013
+ """
1014
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1015
+ <data>
1016
+ <shape id="source" type="text" topLeftX="80" topLeftY="100" width="160" height="40">
1017
+ <content fontSize="18" wrap="false"><p>这是一个足够长的中文文本用于检测跨越间隙的横向溢出</p></content>
1018
+ </shape>
1019
+ <shape id="target" type="text" topLeftX="260" topLeftY="100" width="160" height="40">
1020
+ <content fontSize="18"><p>目标</p></content>
1021
+ </shape>
1022
+ </data>
1023
+ </slide>
1024
+ """
1025
+ )
1026
+ self.assertEqual(result["summary"]["error_count"], 1)
1027
+ self.assertEqual(result["summary"]["warning_count"], 0)
1028
+ issue = result["slides"][0]["issues"][0]
1029
+ self.assertEqual(issue["code"], "bbox_overlap")
1030
+ self.assertEqual(issue["elements"], ["source", "target"])
1031
+ self.assertGreater(issue["measurement"]["intersection_area"], 0)
1032
+ self.assertIsNotNone(issue.get("hint"))
1033
+
1034
+ def test_lint_xml_allows_horizontal_text_with_default_wrap(self) -> None:
1035
+ result = xml_text_overlap_lint.lint_xml(
1036
+ """
1037
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1038
+ <data>
1039
+ <shape id="source" type="text" topLeftX="80" topLeftY="100" width="160" height="40">
1040
+ <content fontSize="18"><p>这是一个足够长的中文文本用于检测默认自动换行</p></content>
1041
+ </shape>
1042
+ <shape id="target" type="text" topLeftX="260" topLeftY="100" width="160" height="40">
1043
+ <content fontSize="18"><p>目标</p></content>
1044
+ </shape>
1045
+ </data>
1046
+ </slide>
1047
+ """
1048
+ )
1049
+ self.assertEqual(result["summary"]["error_count"], 1)
1050
+ self.assertEqual(result["summary"]["warning_count"], 0)
1051
+ self.assertEqual(result["slides"][0]["issues"][0]["code"], "text_may_overflow_shape")
1052
+ self.assertEqual(result["slides"][0]["issues"][0]["level"], "error")
1053
+ self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["source"])
1054
+
1055
+ def test_lint_xml_reports_text_out_of_canvas_and_warns_for_text_height(self) -> None:
1056
+ result = xml_text_overlap_lint.lint_xml(
1057
+ """
1058
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
1059
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1060
+ <data>
1061
+ <shape type="text" topLeftX="80" topLeftY="80" width="180" height="20">
1062
+ <content textType="body" fontSize="18"><p>This paragraph is intentionally much longer than the box can safely contain.</p></content>
1063
+ </shape>
1064
+ <shape type="text" topLeftX="1000" topLeftY="500" width="120" height="80">
1065
+ <content textType="body"><p>Body text outside the canvas</p></content>
1066
+ </shape>
1067
+ </data>
1068
+ </slide>
1069
+ </presentation>
1070
+ """
1071
+ )
1072
+ issue = result["slides"][0]["issues"][0]
1073
+ self.assertEqual(result["summary"]["error_count"], 2)
1074
+ self.assertEqual(result["summary"]["warning_count"], 0)
1075
+ self.assertEqual(issue["code"], "shape_out_of_canvas")
1076
+ self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 160, "bottom": 40})
1077
+
1078
+ def test_lint_xml_warns_when_text_may_overflow_its_own_shape(self) -> None:
1079
+ result = xml_text_overlap_lint.lint_xml(
1080
+ """
1081
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1082
+ <data>
1083
+ <shape id="overflowing" type="text" topLeftX="80" topLeftY="80" width="360" height="80">
1084
+ <content fontSize="20" lineSpacing="multiple:1.5" autoFit="no-auto-fit">
1085
+ <p>第一段</p><p>第二段</p><p>第三段</p><p>第四段</p>
1086
+ </content>
1087
+ </shape>
1088
+ <shape id="fitting" type="text" topLeftX="480" topLeftY="80" width="360" height="120">
1089
+ <content fontSize="20" lineSpacing="multiple:1.5">
1090
+ <p>第一段</p><p>第二段</p><p>第三段</p><p>第四段</p>
1091
+ </content>
1092
+ </shape>
1093
+ <shape id="auto-fit" type="text" topLeftX="80" topLeftY="240" width="360" height="80">
1094
+ <content fontSize="20" lineSpacing="multiple:1.5" autoFit="normal-auto-fit">
1095
+ <p>第一段</p><p>第二段</p><p>第三段</p><p>第四段</p>
1096
+ </content>
1097
+ </shape>
1098
+ <shape id="shape-auto-fit" type="text" topLeftX="480" topLeftY="240" width="360" height="30">
1099
+ <content fontSize="20" lineSpacing="multiple:1.5" autoFit="shape-auto-fit">
1100
+ <p>第一段</p><p>第二段</p><p>第三段</p>
1101
+ </content>
1102
+ </shape>
1103
+ </data>
1104
+ </slide>
1105
+ """
1106
+ )
1107
+ issues = result["slides"][0]["issues"]
1108
+ overflow_issues = [issue for issue in issues if issue["code"] == "text_may_overflow_shape"]
1109
+ self.assertEqual(result["summary"]["error_count"], 1)
1110
+ overflow_ids = {issue["elements"][0] for issue in overflow_issues}
1111
+ self.assertIn("overflowing", overflow_ids)
1112
+ self.assertNotIn("auto-fit", overflow_ids)
1113
+ self.assertNotIn("shape-auto-fit", overflow_ids)
1114
+ self.assertNotIn("fitting", overflow_ids)
1115
+ overflowing_issue = next(issue for issue in overflow_issues if issue["elements"] == ["overflowing"])
1116
+ self.assertEqual(overflowing_issue["line_count"], 4)
1117
+ self.assertEqual(overflowing_issue["estimated_height"], 110)
1118
+ self.assertEqual(overflowing_issue["available_height"], 80)
1119
+ self.assertEqual(overflowing_issue["overflow"], 30)
1120
+ self.assertIn('wrap="true" autoFit="normal-auto-fit"', overflowing_issue["message"])
1121
+
1122
+ def test_lint_xml_uses_fixed_line_spacing_for_text_height_warning(self) -> None:
1123
+ result = xml_text_overlap_lint.lint_xml(
1124
+ """
1125
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1126
+ <data>
1127
+ <shape id="fixed-overflow" type="text" topLeftX="80" topLeftY="80" width="360" height="50">
1128
+ <content fontSize="20" lineSpacing="fixed:20" autoFit="no-auto-fit">
1129
+ <p>第一段</p><p>第二段</p><p>第三段</p>
1130
+ </content>
1131
+ </shape>
1132
+ </data>
1133
+ </slide>
1134
+ """
1135
+ )
1136
+ issue = result["slides"][0]["issues"][0]
1137
+ self.assertEqual(result["summary"]["warning_count"], 1)
1138
+ self.assertEqual(result["summary"]["error_count"], 0)
1139
+ self.assertEqual(issue["level"], "warning")
1140
+ self.assertEqual(issue["line_height"], 20)
1141
+ self.assertEqual(issue["estimated_height"], 60)
1142
+ self.assertEqual(issue["overflow"], 10)
1143
+
1144
+ def test_lint_xml_ignores_subpixel_text_height_overflow_tolerance(self) -> None:
1145
+ result = xml_text_overlap_lint.lint_xml(
1146
+ """
1147
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1148
+ <data>
1149
+ <shape id="minor-overflow" type="text" topLeftX="80" topLeftY="80" width="360" height="39.8">
1150
+ <content fontSize="20" lineSpacing="fixed:20" autoFit="no-auto-fit">
1151
+ <p>第一段</p><p>第二段</p>
1152
+ </content>
1153
+ </shape>
1154
+ </data>
1155
+ </slide>
1156
+ """
1157
+ )
1158
+ overflow_issues = [
1159
+ issue
1160
+ for issue in result["slides"][0]["issues"]
1161
+ if issue["code"] == "text_may_overflow_shape"
1162
+ ]
1163
+ self.assertEqual(overflow_issues, [])
1164
+
1165
+ def test_lint_xml_allows_single_line_width_estimation_jitter(self) -> None:
1166
+ result = xml_text_overlap_lint.lint_xml(
1167
+ """
1168
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1169
+ <data>
1170
+ <shape id="metric" type="text" topLeftX="80" topLeftY="80" width="152" height="54">
1171
+ <content fontSize="36" lineSpacing="multiple:1.2" autoFit="no-auto-fit"><p>4.16万亿</p></content>
1172
+ </shape>
1173
+ </data>
1174
+ </slide>
1175
+ """
1176
+ )
1177
+ overflow_issues = [
1178
+ issue
1179
+ for issue in result["slides"][0]["issues"]
1180
+ if issue["code"] == "text_may_overflow_shape"
1181
+ ]
1182
+ self.assertEqual(overflow_issues, [])
1183
+
1184
+ def test_lint_xml_allows_short_metric_text_with_separators_as_single_line(self) -> None:
1185
+ result = xml_text_overlap_lint.lint_xml(
1186
+ """
1187
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1188
+ <data>
1189
+ <shape id="metric" type="text" topLeftX="80" topLeftY="80" width="150" height="50">
1190
+ <content textType="title" fontSize="36" autoFit="no-auto-fit"><p>4.16万亿</p></content>
1191
+ </shape>
1192
+ <shape id="table-number" type="text" topLeftX="80" topLeftY="160" width="25" height="20">
1193
+ <content fontSize="10" textAlign="center" autoFit="no-auto-fit"><p>1,380</p></content>
1194
+ </shape>
1195
+ </data>
1196
+ </slide>
1197
+ """
1198
+ )
1199
+ overflow_issues = [
1200
+ issue
1201
+ for issue in result["slides"][0]["issues"]
1202
+ if issue["code"] == "text_may_overflow_shape"
1203
+ ]
1204
+ self.assertEqual(overflow_issues, [])
1205
+
1206
+ def test_lint_xml_reports_plain_short_metric_when_it_wraps(self) -> None:
1207
+ result = xml_text_overlap_lint.lint_xml(
1208
+ """
1209
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1210
+ <data>
1211
+ <shape id="plain-age" type="text" topLeftX="80" topLeftY="80" width="50" height="80">
1212
+ <content textType="title" fontSize="36" bold="true" autoFit="no-auto-fit"><p>82岁</p></content>
1213
+ </shape>
1214
+ </data>
1215
+ </slide>
1216
+ """
1217
+ )
1218
+ overflow_issues = [
1219
+ issue
1220
+ for issue in result["slides"][0]["issues"]
1221
+ if issue["code"] == "text_may_overflow_shape"
1222
+ ]
1223
+ self.assertEqual(len(overflow_issues), 1)
1224
+ self.assertEqual(overflow_issues[0]["elements"], ["plain-age"])
1225
+
1226
+ def test_lint_xml_allows_centered_short_label_near_fit_as_single_line(self) -> None:
1227
+ result = xml_text_overlap_lint.lint_xml(
1228
+ """
1229
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1230
+ <data>
1231
+ <shape id="centered-label" type="text" topLeftX="80" topLeftY="80" width="200" height="30">
1232
+ <content fontSize="14" bold="true" textAlign="center" autoFit="no-auto-fit">
1233
+ <p>参数服务器 (Parameter Server)</p>
1234
+ </content>
1235
+ </shape>
1236
+ </data>
1237
+ </slide>
1238
+ """
1239
+ )
1240
+ overflow_issues = [
1241
+ issue
1242
+ for issue in result["slides"][0]["issues"]
1243
+ if issue["code"] == "text_may_overflow_shape"
1244
+ ]
1245
+ self.assertEqual(overflow_issues, [])
1246
+
1247
+ def test_lint_xml_allows_headline_near_fit_as_single_line(self) -> None:
1248
+ result = xml_text_overlap_lint.lint_xml(
1249
+ """
1250
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1251
+ <data>
1252
+ <shape id="headline" type="text" topLeftX="80" topLeftY="80" width="700" height="50">
1253
+ <content textType="headline" fontSize="26" bold="true" lineSpacing="multiple:1.3" autoFit="no-auto-fit">
1254
+ <p>全球半导体市场规模持续高速增长,AI驱动新一轮景气周期</p>
1255
+ </content>
1256
+ </shape>
1257
+ </data>
1258
+ </slide>
1259
+ """
1260
+ )
1261
+ overflow_issues = [
1262
+ issue
1263
+ for issue in result["slides"][0]["issues"]
1264
+ if issue["code"] == "text_may_overflow_shape"
1265
+ ]
1266
+ self.assertEqual(overflow_issues, [])
1267
+
1268
+ def test_lint_xml_allows_dense_body_line_spacing_estimation_slack(self) -> None:
1269
+ result = xml_text_overlap_lint.lint_xml(
1270
+ """
1271
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1272
+ <data>
1273
+ <shape id="dense-body" type="text" topLeftX="80" topLeftY="80" width="360" height="140">
1274
+ <content fontSize="13" bold="true" lineSpacing="multiple:1.7" autoFit="no-auto-fit">
1275
+ <p>总体目标:</p>
1276
+ <p>建立深度神经网络高效训练的统一理论框架,实现训练效率与模型性能的协同优化。</p>
1277
+ <p>具体目标:</p>
1278
+ <p>提出自适应优化算法,收敛速度提升 2-3 倍</p>
1279
+ <p>实现结构化压缩方法,模型体积减少 10 倍以上</p>
1280
+ <p>构建分布式训练策略,64 GPU 加速比 &gt; 50x</p>
1281
+ </content>
1282
+ </shape>
1283
+ </data>
1284
+ </slide>
1285
+ """
1286
+ )
1287
+ overflow_issues = [
1288
+ issue
1289
+ for issue in result["slides"][0]["issues"]
1290
+ if issue["code"] == "text_may_overflow_shape"
1291
+ ]
1292
+ self.assertEqual(overflow_issues, [])
1293
+
1294
+ def test_lint_xml_reports_dense_body_when_adjusted_height_still_overflows(self) -> None:
1295
+ result = xml_text_overlap_lint.lint_xml(
1296
+ """
1297
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1298
+ <data>
1299
+ <shape id="dense-body" type="text" topLeftX="80" topLeftY="80" width="360" height="100">
1300
+ <content fontSize="13" bold="true" lineSpacing="multiple:1.7" autoFit="no-auto-fit">
1301
+ <p>总体目标:</p>
1302
+ <p>建立深度神经网络高效训练的统一理论框架,实现训练效率与模型性能的协同优化。</p>
1303
+ <p>具体目标:</p>
1304
+ <p>提出自适应优化算法,收敛速度提升 2-3 倍</p>
1305
+ <p>实现结构化压缩方法,模型体积减少 10 倍以上</p>
1306
+ <p>构建分布式训练策略,64 GPU 加速比 &gt; 50x</p>
1307
+ </content>
1308
+ </shape>
1309
+ </data>
1310
+ </slide>
1311
+ """
1312
+ )
1313
+ overflow_issues = [
1314
+ issue
1315
+ for issue in result["slides"][0]["issues"]
1316
+ if issue["code"] == "text_may_overflow_shape"
1317
+ ]
1318
+ self.assertEqual(len(overflow_issues), 1)
1319
+ self.assertEqual(overflow_issues[0]["elements"], ["dense-body"])
1320
+
1321
+ def test_lint_xml_reports_letter_spaced_caption_near_fit(self) -> None:
1322
+ result = xml_text_overlap_lint.lint_xml(
1323
+ """
1324
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1325
+ <data>
1326
+ <shape id="caption" type="text" topLeftX="80" topLeftY="80" width="120" height="20">
1327
+ <content textType="caption" fontSize="11" letterSpacing="1" autoFit="no-auto-fit">
1328
+ <p>RISKS &amp; CHALLENGES</p>
1329
+ </content>
1330
+ </shape>
1331
+ </data>
1332
+ </slide>
1333
+ """
1334
+ )
1335
+ overflow_issues = [
1336
+ issue
1337
+ for issue in result["slides"][0]["issues"]
1338
+ if issue["code"] == "text_may_overflow_shape"
1339
+ ]
1340
+ self.assertEqual(len(overflow_issues), 1)
1341
+ self.assertEqual(overflow_issues[0]["elements"], ["caption"])
1342
+
1343
+ def test_lint_xml_reports_micro_caption_when_wrapping_overflows(self) -> None:
1344
+ result = xml_text_overlap_lint.lint_xml(
1345
+ """
1346
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1347
+ <data>
1348
+ <shape id="micro-caption" type="text" topLeftX="80" topLeftY="60" width="200" height="16">
1349
+ <content textType="caption" fontSize="3" lineSpacing="multiple:1.3" letterSpacing="160" autoFit="no-auto-fit">
1350
+ <p>MARKET INSIGHT · 市场洞察</p>
1351
+ </content>
1352
+ </shape>
1353
+ </data>
1354
+ </slide>
1355
+ """
1356
+ )
1357
+ overflow_issues = [
1358
+ issue
1359
+ for issue in result["slides"][0]["issues"]
1360
+ if issue["code"] == "text_may_overflow_shape"
1361
+ ]
1362
+ self.assertEqual(len(overflow_issues), 1)
1363
+ self.assertEqual(overflow_issues[0]["elements"], ["micro-caption"])
1364
+
1365
+ def test_lint_xml_text_may_overflow_shape_upgrades_to_error_above_threshold(self) -> None:
1366
+ result = xml_text_overlap_lint.lint_xml(
1367
+ """
1368
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1369
+ <data>
1370
+ <shape id="just-warning" type="text" topLeftX="80" topLeftY="80" width="360" height="50">
1371
+ <content fontSize="20" lineSpacing="fixed:20" autoFit="no-auto-fit">
1372
+ <p>第一段</p><p>第二段</p><p>第三段</p>
1373
+ </content>
1374
+ </shape>
1375
+ <shape id="error-overflow" type="text" topLeftX="80" topLeftY="200" width="360" height="30">
1376
+ <content fontSize="20" lineSpacing="fixed:20" autoFit="no-auto-fit">
1377
+ <p>第一段</p><p>第二段</p><p>第三段</p>
1378
+ </content>
1379
+ </shape>
1380
+ </data>
1381
+ </slide>
1382
+ """
1383
+ )
1384
+ issues = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
1385
+ self.assertEqual(issues["just-warning"]["level"], "warning")
1386
+ self.assertEqual(issues["just-warning"]["overflow"], 10)
1387
+ self.assertEqual(issues["error-overflow"]["level"], "error")
1388
+ self.assertEqual(issues["error-overflow"]["overflow"], 30)
1389
+ self.assertEqual(result["summary"]["error_count"], 1)
1390
+ self.assertEqual(result["summary"]["warning_count"], 1)
1391
+
1392
+ def test_lint_xml_text_may_overflow_shape_downgrades_background_decoration_to_info(self) -> None:
1393
+ result = xml_text_overlap_lint.lint_xml(
1394
+ """
1395
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1396
+ <data>
1397
+ <shape id="bg-deco" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
1398
+ <content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
1399
+ </shape>
1400
+ <shape id="foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60">
1401
+ <content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
1402
+ </shape>
1403
+ </data>
1404
+ </slide>
1405
+ """
1406
+ )
1407
+ issues = {
1408
+ issue["elements"][0]: issue
1409
+ for issue in result["slides"][0]["issues"]
1410
+ if issue["code"] == "text_may_overflow_shape"
1411
+ }
1412
+ self.assertEqual(issues["bg-deco"]["level"], "info")
1413
+ self.assertEqual(result["summary"]["warning_count"], 0)
1414
+ self.assertEqual(result["summary"]["info_count"], 1)
1415
+ self.assertEqual(result["slides"][0]["infos"], [issues["bg-deco"]])
1416
+ self.assertIn("background decoration", issues["bg-deco"]["message"])
1417
+
1418
+ def test_lint_xml_reports_shape_alpha_ghost_text_out_of_canvas_but_allows_overlap(self) -> None:
1419
+ result = xml_text_overlap_lint.lint_xml(
1420
+ """
1421
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1422
+ <data>
1423
+ <shape id="ghost-number" type="text" topLeftX="-60" topLeftY="30" width="360" height="180" alpha="0.2">
1424
+ <content fontSize="160" lineSpacing="fixed:160" wrap="false"><p>01</p></content>
1425
+ </shape>
1426
+ <shape id="title" type="text" topLeftX="80" topLeftY="80" width="360" height="80">
1427
+ <content fontSize="30" lineSpacing="fixed:36"><p>Annual Review</p></content>
1428
+ </shape>
1429
+ </data>
1430
+ </slide>
1431
+ """
1432
+ )
1433
+ codes = [issue["code"] for issue in result["slides"][0]["issues"]]
1434
+ self.assertEqual(result["summary"]["error_count"], 1)
1435
+ self.assertIn("shape_out_of_canvas", codes)
1436
+ self.assertNotIn("bbox_overlap", codes)
1437
+
1438
+ def test_lint_xml_reports_content_color_alpha_ghost_text_out_of_canvas_but_allows_overlap(self) -> None:
1439
+ result = xml_text_overlap_lint.lint_xml(
1440
+ """
1441
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1442
+ <data>
1443
+ <shape id="ghost-year" type="text" topLeftX="760" topLeftY="20" width="260" height="160">
1444
+ <content fontSize="140" color="rgba(0,0,0,0.2)" lineSpacing="fixed:140" wrap="false"><p>2026</p></content>
1445
+ </shape>
1446
+ <shape id="headline" type="text" topLeftX="700" topLeftY="70" width="220" height="80">
1447
+ <content fontSize="28" lineSpacing="fixed:34"><p>Forecast</p></content>
1448
+ </shape>
1449
+ </data>
1450
+ </slide>
1451
+ """
1452
+ )
1453
+ codes = [issue["code"] for issue in result["slides"][0]["issues"]]
1454
+ self.assertEqual(result["summary"]["error_count"], 1)
1455
+ self.assertIn("shape_out_of_canvas", codes)
1456
+ self.assertNotIn("bbox_overlap", codes)
1457
+
1458
+ def test_lint_xml_reports_faint_medium_ghost_text_out_of_canvas_but_allows_overlap(self) -> None:
1459
+ result = xml_text_overlap_lint.lint_xml(
1460
+ """
1461
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1462
+ <data>
1463
+ <shape id="medium-ghost" type="text" topLeftX="820" topLeftY="300" width="270" height="72" alpha="0.32">
1464
+ <content fontSize="40" lineSpacing="fixed:40" wrap="false"><p>OFF EDGE</p></content>
1465
+ </shape>
1466
+ <shape id="caption" type="text" topLeftX="760" topLeftY="315" width="180" height="36">
1467
+ <content fontSize="16" lineSpacing="fixed:20"><p>Readable caption</p></content>
1468
+ </shape>
1469
+ </data>
1470
+ </slide>
1471
+ """
1472
+ )
1473
+ codes = [issue["code"] for issue in result["slides"][0]["issues"]]
1474
+ self.assertEqual(result["summary"]["error_count"], 1)
1475
+ self.assertIn("shape_out_of_canvas", codes)
1476
+ self.assertNotIn("bbox_overlap", codes)
1477
+
1478
+ def test_lint_xml_allows_ghost_text_image_overlap(self) -> None:
1479
+ result = xml_text_overlap_lint.lint_xml(
1480
+ """
1481
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1482
+ <data>
1483
+ <shape id="ghost-label" type="text" topLeftX="100" topLeftY="40" width="560" height="160" alpha="0.2">
1484
+ <content fontSize="120" lineSpacing="fixed:120" wrap="false"><p>2026</p></content>
1485
+ </shape>
1486
+ <img id="photo" src="token" topLeftX="160" topLeftY="70" width="260" height="160"/>
1487
+ <shape id="title" type="text" topLeftX="610" topLeftY="95" width="320" height="60">
1488
+ <content fontSize="28" lineSpacing="fixed:34"><p>Annual Review</p></content>
1489
+ </shape>
1490
+ </data>
1491
+ </slide>
1492
+ """
1493
+ )
1494
+ codes = [issue["code"] for issue in result["slides"][0]["issues"]]
1495
+ self.assertNotIn("image_covers_text", codes)
1496
+ self.assertNotIn("bbox_overlap", codes)
1497
+
1498
+ def test_lint_slide_allows_ghost_text_whiteboard_overlap(self) -> None:
1499
+ result = xml_text_overlap_lint.lint_slide(
1500
+ """
1501
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1502
+ <data>
1503
+ <whiteboard id="board" topLeftX="180" topLeftY="70" width="420" height="300"/>
1504
+ <shape id="ghost-label" type="text" topLeftX="100" topLeftY="40" width="560" height="160" alpha="0.2">
1505
+ <content fontSize="120" lineSpacing="fixed:120" wrap="false"><p>2026</p></content>
1506
+ </shape>
1507
+ <shape id="title" type="text" topLeftX="610" topLeftY="95" width="220" height="60">
1508
+ <content fontSize="28" lineSpacing="fixed:34"><p>Annual Review</p></content>
1509
+ </shape>
1510
+ </data>
1511
+ </slide>
1512
+ """,
1513
+ 1,
1514
+ )
1515
+ codes = [issue["code"] for issue in result["issues"]]
1516
+ self.assertNotIn("whiteboard_external_overlap", codes)
1517
+
1518
+ def test_lint_xml_reports_faint_ghost_text_out_of_canvas_without_area_threshold(self) -> None:
1519
+ result = xml_text_overlap_lint.lint_xml(
1520
+ """
1521
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1522
+ <data>
1523
+ <shape id="small-ghost" type="text" topLeftX="940" topLeftY="300" width="40" height="40" alpha="0.32">
1524
+ <content fontSize="36" lineSpacing="fixed:36" wrap="false"><p>土</p></content>
1525
+ </shape>
1526
+ </data>
1527
+ </slide>
1528
+ """
1529
+ )
1530
+ self.assertEqual(result["summary"]["error_count"], 1)
1531
+ self.assertEqual(result["slides"][0]["issues"][0]["code"], "shape_out_of_canvas")
1532
+
1533
+ def test_lint_xml_keeps_out_of_canvas_error_for_medium_text_without_faint_alpha(self) -> None:
1534
+ result = xml_text_overlap_lint.lint_xml(
1535
+ """
1536
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1537
+ <data>
1538
+ <shape id="medium-not-ghost" type="text" topLeftX="820" topLeftY="300" width="270" height="72" alpha="0.36">
1539
+ <content fontSize="54" lineSpacing="fixed:54" wrap="false"><p>OFF EDGE</p></content>
1540
+ </shape>
1541
+ </data>
1542
+ </slide>
1543
+ """
1544
+ )
1545
+ self.assertEqual(result["summary"]["error_count"], 1)
1546
+ self.assertEqual(result["slides"][0]["issues"][0]["code"], "shape_out_of_canvas")
1547
+ self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["medium-not-ghost"])
1548
+
1549
+ def test_lint_xml_keeps_out_of_canvas_error_for_half_alpha_large_text(self) -> None:
1550
+ result = xml_text_overlap_lint.lint_xml(
1551
+ """
1552
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1553
+ <data>
1554
+ <shape id="half-alpha" type="text" topLeftX="760" topLeftY="20" width="260" height="160">
1555
+ <content fontSize="140" color="rgba(0,0,0,0.5)" lineSpacing="fixed:140" wrap="false"><p>2026</p></content>
1556
+ </shape>
1557
+ </data>
1558
+ </slide>
1559
+ """
1560
+ )
1561
+ self.assertEqual(result["summary"]["error_count"], 1)
1562
+ self.assertEqual(result["slides"][0]["issues"][0]["code"], "shape_out_of_canvas")
1563
+ self.assertEqual(result["slides"][0]["issues"][0]["elements"], ["half-alpha"])
1564
+
1565
+ def test_lint_xml_text_may_overflow_shape_keeps_error_when_alpha_not_low(self) -> None:
1566
+ result = xml_text_overlap_lint.lint_xml(
1567
+ """
1568
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1569
+ <data>
1570
+ <shape id="opaque-big" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.9">
1571
+ <content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
1572
+ </shape>
1573
+ <shape id="foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60">
1574
+ <content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
1575
+ </shape>
1576
+ </data>
1577
+ </slide>
1578
+ """
1579
+ )
1580
+ issue = next(
1581
+ issue
1582
+ for issue in result["slides"][0]["issues"]
1583
+ if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["opaque-big"]
1584
+ )
1585
+ self.assertEqual(issue["level"], "error")
1586
+
1587
+ def test_lint_xml_text_may_overflow_shape_keeps_error_when_no_foreground_text(self) -> None:
1588
+ result = xml_text_overlap_lint.lint_xml(
1589
+ """
1590
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1591
+ <data>
1592
+ <shape id="lonely-big" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
1593
+ <content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
1594
+ </shape>
1595
+ </data>
1596
+ </slide>
1597
+ """
1598
+ )
1599
+ issue = next(
1600
+ issue
1601
+ for issue in result["slides"][0]["issues"]
1602
+ if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["lonely-big"]
1603
+ )
1604
+ self.assertEqual(issue["level"], "error")
1605
+
1606
+ def test_lint_xml_text_may_overflow_shape_keeps_error_when_foreground_alpha_zero(self) -> None:
1607
+ result = xml_text_overlap_lint.lint_xml(
1608
+ """
1609
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1610
+ <data>
1611
+ <shape id="bg-deco" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
1612
+ <content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
1613
+ </shape>
1614
+ <shape id="transparent-foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60" alpha="0">
1615
+ <content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
1616
+ </shape>
1617
+ </data>
1618
+ </slide>
1619
+ """
1620
+ )
1621
+ issue = next(
1622
+ issue
1623
+ for issue in result["slides"][0]["issues"]
1624
+ if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["bg-deco"]
1625
+ )
1626
+ self.assertEqual(issue["level"], "error")
1627
+
1628
+ def test_lint_xml_text_may_overflow_shape_keeps_error_when_foreground_is_below_in_order(self) -> None:
1629
+ result = xml_text_overlap_lint.lint_xml(
1630
+ """
1631
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1632
+ <data>
1633
+ <shape id="foreground" type="text" topLeftX="40" topLeftY="20" width="400" height="60">
1634
+ <content fontSize="20" lineSpacing="fixed:24"><p>Annual Report</p></content>
1635
+ </shape>
1636
+ <shape id="top-big" type="text" topLeftX="0" topLeftY="0" width="600" height="80" alpha="0.3">
1637
+ <content fontSize="120" lineSpacing="fixed:120" autoFit="no-auto-fit"><p>2026</p></content>
1638
+ </shape>
1639
+ </data>
1640
+ </slide>
1641
+ """
1642
+ )
1643
+ issue = next(
1644
+ issue
1645
+ for issue in result["slides"][0]["issues"]
1646
+ if issue["code"] == "text_may_overflow_shape" and issue["elements"] == ["top-big"]
1647
+ )
1648
+ self.assertEqual(issue["level"], "error")
1649
+
1650
+ def test_lint_xml_uses_paragraph_spacing_overrides_for_text_height_warning(self) -> None:
1651
+ result = xml_text_overlap_lint.lint_xml(
1652
+ """
1653
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1654
+ <data>
1655
+ <shape id="paragraph-overflow" type="text" topLeftX="80" topLeftY="80" width="360" height="30">
1656
+ <content fontSize="20" lineSpacing="multiple:1.5" autoFit="no-auto-fit">
1657
+ <p lineSpacing="fixed:10" beforeLineSpacing="fixed:5" afterLineSpacing="fixed:5">第一行<br/>第二行</p>
1658
+ </content>
1659
+ </shape>
1660
+ <shape id="paragraph-fitting" type="text" topLeftX="480" topLeftY="80" width="360" height="40">
1661
+ <content fontSize="20" lineSpacing="multiple:1.5">
1662
+ <p lineSpacing="fixed:10">第一行<br/>第二行<br/>第三行</p>
1663
+ </content>
1664
+ </shape>
1665
+ </data>
1666
+ </slide>
1667
+ """
1668
+ )
1669
+ issues = result["slides"][0]["issues"]
1670
+ self.assertEqual(result["summary"]["warning_count"], 1)
1671
+ self.assertEqual(issues[0]["elements"], ["paragraph-overflow"])
1672
+ self.assertEqual(issues[0]["line_count"], 2)
1673
+ self.assertEqual(issues[0]["line_height"], 10)
1674
+ self.assertEqual(issues[0]["estimated_height"], 40)
1675
+ self.assertEqual(issues[0]["overflow"], 10)
1676
+
1677
+ def test_lint_xml_uses_letter_spacing_for_text_overflow_warning(self) -> None:
1678
+ result = xml_text_overlap_lint.lint_xml(
1679
+ """
1680
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1681
+ <data>
1682
+ <shape id="baseline" type="text" topLeftX="0" topLeftY="0" width="120" height="30">
1683
+ <content fontSize="20" lineSpacing="multiple:1.5" autoFit="no-auto-fit"><p>一二三四五六</p></content>
1684
+ </shape>
1685
+ <shape id="content-spaced" type="text" topLeftX="200" topLeftY="0" width="120" height="30">
1686
+ <content fontSize="20" lineSpacing="multiple:1.5" letterSpacing="2" autoFit="no-auto-fit"><p>一二三四五六</p></content>
1687
+ </shape>
1688
+ <shape id="paragraph-spaced" type="text" topLeftX="400" topLeftY="0" width="120" height="30">
1689
+ <content fontSize="20" lineSpacing="multiple:1.5" autoFit="no-auto-fit"><p letterSpacing="2">一二三四五六</p></content>
1690
+ </shape>
1691
+ </data>
1692
+ </slide>
1693
+ """
1694
+ )
1695
+ issues = result["slides"][0]["issues"]
1696
+ overflow_ids = [issue["elements"][0] for issue in issues if issue["code"] == "text_may_overflow_shape"]
1697
+ self.assertNotIn("baseline", overflow_ids)
1698
+ self.assertIn("content-spaced", overflow_ids)
1699
+ self.assertIn("paragraph-spaced", overflow_ids)
1700
+ by_id = {issue["elements"][0]: issue for issue in issues if issue["code"] == "text_may_overflow_shape"}
1701
+ self.assertEqual(by_id["content-spaced"]["line_count"], 2)
1702
+ self.assertEqual(by_id["content-spaced"]["estimated_height"], 50)
1703
+ self.assertEqual(by_id["content-spaced"]["overflow"], 20)
1704
+ self.assertEqual(by_id["paragraph-spaced"]["line_count"], 2)
1705
+
1706
+ def test_strip_xml_paragraphs_preserves_br_as_hard_line_break(self) -> None:
1707
+ self.assertEqual(
1708
+ xml_text_overlap_lint.strip_xml_paragraphs("<p>第一行<br/>第二行<br />第三行</p>"),
1709
+ "第一行\n第二行\n第三行",
1710
+ )
1711
+
1712
+ def test_lint_xml_allows_template_style_images_outside_canvas(self) -> None:
1713
+ result = xml_text_overlap_lint.lint_xml(
1714
+ """
1715
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
1716
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1717
+ <data>
1718
+ <img src="tok" topLeftX="-120" topLeftY="20" width="360" height="360"/>
1719
+ <shape type="text" topLeftX="40" topLeftY="80" width="180" height="80">
1720
+ <content textType="title" fontSize="44"><p>Title</p></content>
1721
+ </shape>
1722
+ <shape type="text" topLeftX="40" topLeftY="120" width="180" height="40">
1723
+ <content textType="sub-headline" fontSize="20"><p>Subtitle</p></content>
1724
+ </shape>
1725
+ </data>
1726
+ </slide>
1727
+ </presentation>
1728
+ """
1729
+ )
1730
+ self.assertEqual(result["summary"]["error_count"], 0)
1731
+ self.assertEqual(result["summary"]["warning_count"], 0)
1732
+
1733
+ def test_extract_elements_preserves_supported_element_geometry_order_and_text_metadata(self) -> None:
1734
+ elements = xml_text_overlap_lint.extract_elements(
1735
+ """
1736
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1737
+ <data>
1738
+ <img id="photo" src="tok" topLeftX="10" topLeftY="20" width="100" height="80"/>
1739
+ <shape id="headline" type="text" topLeftX="40" topLeftY="60" width="320" height="90">
1740
+ <content textType="headline" textAlign="center" autoFit="normal-auto-fit" fontSize="28">
1741
+ <p><![CDATA[Growth & scale]]></p>
1742
+ <p>Focused execution</p>
1743
+ </content>
1744
+ </shape>
1745
+ <table id="table" topLeftX="400" topLeftY="60" width="220" height="120"></table>
1746
+ <chart id="chart" topLeftX="640" topLeftY="60" width="220" height="120"/>
1747
+ <whiteboard id="wb" topLeftX="80" topLeftY="220" width="760" height="240"/>
1748
+ <shape id="missing-height" type="text" topLeftX="80" topLeftY="480" width="320">
1749
+ <content><p>Skipped</p></content>
1750
+ </shape>
1751
+ </data>
1752
+ </slide>
1753
+ """
1754
+ )
1755
+ self.assertEqual([element["id"] for element in elements], ["photo", "headline", "table", "chart", "wb"])
1756
+ self.assertEqual([element["kind"] for element in elements], ["img", "shape", "table", "chart", "whiteboard"])
1757
+ self.assertEqual([element["order"] for element in elements], [0, 1, 2, 3, 4])
1758
+ self.assertEqual(elements[1]["type"], "text")
1759
+ self.assertEqual(elements[1]["textType"], "headline")
1760
+ self.assertEqual(elements[1]["textAlign"], "center")
1761
+ self.assertEqual(elements[1]["autoFit"], "normal-auto-fit")
1762
+ self.assertEqual(elements[1]["fontSize"], 28)
1763
+ self.assertEqual(elements[1]["text"], "Growth & scale\nFocused execution")
1764
+
1765
+ def test_lint_xml_ignores_small_out_of_bounds_images(self) -> None:
1766
+ result = xml_text_overlap_lint.lint_xml(
1767
+ """
1768
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
1769
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1770
+ <data>
1771
+ <img src="tok" topLeftX="-20" topLeftY="20" width="120" height="120"/>
1772
+ </data>
1773
+ </slide>
1774
+ </presentation>
1775
+ """
1776
+ )
1777
+ self.assertEqual(result["summary"]["error_count"], 0)
1778
+
1779
+ def test_lint_xml_ignores_out_of_canvas_images(self) -> None:
1780
+ result = xml_text_overlap_lint.lint_xml(
1781
+ """
1782
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
1783
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1784
+ <data>
1785
+ <img src="right" topLeftX="780" topLeftY="0" width="500" height="540"/>
1786
+ <img src="bottom" topLeftX="0" topLeftY="430" width="900" height="280"/>
1787
+ </data>
1788
+ </slide>
1789
+ </presentation>
1790
+ """
1791
+ )
1792
+ self.assertEqual(result["summary"]["error_count"], 0)
1793
+
1794
+ def test_lint_xml_ignores_full_bleed_images_outside_canvas(self) -> None:
1795
+ result = xml_text_overlap_lint.lint_xml(
1796
+ """
1797
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
1798
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1799
+ <data>
1800
+ <img src="tok" topLeftX="-80" topLeftY="-20" width="1080" height="600"/>
1801
+ </data>
1802
+ </slide>
1803
+ </presentation>
1804
+ """
1805
+ )
1806
+ self.assertEqual(result["summary"]["error_count"], 0)
1807
+
1808
+ def test_lint_xml_reports_text_and_chart_but_not_image_out_of_canvas(self) -> None:
1809
+ result = xml_text_overlap_lint.lint_xml(
1810
+ """
1811
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
1812
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1813
+ <data>
1814
+ <shape id="outside-shape" type="text" topLeftX="-10" topLeftY="40" width="50" height="50"/>
1815
+ <img id="outside-img" src="token" topLeftX="120" topLeftY="-20" width="50" height="50"/>
1816
+ <chart id="outside-chart" topLeftX="900" topLeftY="100" width="100" height="100">
1817
+ <chartPlotArea><chartPlot type="line"/></chartPlotArea>
1818
+ <chartData>
1819
+ <dim1><chartField name="category" valueType="string">A</chartField></dim1>
1820
+ <dim2><chartField name="value" valueType="number">1</chartField></dim2>
1821
+ </chartData>
1822
+ </chart>
1823
+ </data>
1824
+ </slide>
1825
+ </presentation>
1826
+ """
1827
+ )
1828
+ issues = result["slides"][0]["issues"]
1829
+ self.assertEqual(result["summary"]["error_count"], 2)
1830
+ self.assertEqual(
1831
+ [(issue["code"], issue["elements"], issue["overflow"]) for issue in issues],
1832
+ [
1833
+ ("shape_out_of_canvas", ["outside-shape"], {"left": 10, "top": 0, "right": 0, "bottom": 0}),
1834
+ ("chart_out_of_canvas", ["outside-chart"], {"left": 0, "top": 0, "right": 40, "bottom": 0}),
1835
+ ],
1836
+ )
1837
+
1838
+ def test_lint_xml_ignores_line_out_of_canvas(self) -> None:
1839
+ result = xml_text_overlap_lint.lint_xml(
1840
+ """
1841
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1842
+ <data>
1843
+ <shape id="body" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
1844
+ <content fontSize="18"><p>Visible content</p></content>
1845
+ </shape>
1846
+ <line id="connector" startX="80" startY="120" endX="980" endY="120"><border/></line>
1847
+ </data>
1848
+ </slide>
1849
+ """
1850
+ )
1851
+
1852
+ self.assertEqual(result["summary"]["error_count"], 0)
1853
+ self.assertEqual(result["slides"][0]["issues"], [])
1854
+
1855
+ def test_lint_xml_reports_horizontal_line_crossing_headline_glyphs(self) -> None:
1856
+ result = xml_text_overlap_lint.lint_xml(
1857
+ """
1858
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1859
+ <data>
1860
+ <shape id="title" type="text" topLeftX="80" topLeftY="200" width="500" height="90">
1861
+ <content fontSize="60"><p>测试文字 ABC</p></content>
1862
+ </shape>
1863
+ <line id="strike" startX="80" startY="245" endX="560" endY="245">
1864
+ <border color="rgb(255, 0, 0)" width="4"/>
1865
+ </line>
1866
+ </data>
1867
+ </slide>
1868
+ """
1869
+ )
1870
+ crossing = [
1871
+ issue for issue in result["slides"][0]["errors"] if set(issue["elements"]) == {"strike", "title"}
1872
+ ]
1873
+ self.assertEqual(len(crossing), 1)
1874
+ self.assertEqual(crossing[0]["code"], "bbox_overlap")
1875
+
1876
+ def test_lint_xml_reports_vertical_line_crossing_multiline_text(self) -> None:
1877
+ result = xml_text_overlap_lint.lint_xml(
1878
+ """
1879
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1880
+ <data>
1881
+ <shape id="col" type="text" topLeftX="700" topLeftY="180" width="240" height="180">
1882
+ <content fontSize="20"><p>第一行文字内容</p><p>第二行文字内容</p><p>第三行文字内容</p></content>
1883
+ </shape>
1884
+ <line id="vbar" startX="740" startY="170" endX="740" endY="360">
1885
+ <border color="rgb(0, 0, 255)" width="3"/>
1886
+ </line>
1887
+ </data>
1888
+ </slide>
1889
+ """
1890
+ )
1891
+ crossing = [
1892
+ issue for issue in result["slides"][0]["errors"] if set(issue["elements"]) == {"vbar", "col"}
1893
+ ]
1894
+ self.assertEqual(len(crossing), 1)
1895
+
1896
+ def test_lint_xml_reports_diagonal_line_crossing_text_block(self) -> None:
1897
+ result = xml_text_overlap_lint.lint_xml(
1898
+ """
1899
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1900
+ <data>
1901
+ <shape id="para" type="text" topLeftX="80" topLeftY="400" width="420" height="140">
1902
+ <content fontSize="18"><p>这是一段测试文字用于验证线条穿过</p></content>
1903
+ </shape>
1904
+ <line id="diag" startX="80" startY="410" endX="500" endY="530">
1905
+ <border color="rgb(255, 0, 0)" width="3"/>
1906
+ </line>
1907
+ </data>
1908
+ </slide>
1909
+ """
1910
+ )
1911
+ crossing = [
1912
+ issue for issue in result["slides"][0]["errors"] if set(issue["elements"]) == {"diag", "para"}
1913
+ ]
1914
+ self.assertEqual(len(crossing), 1)
1915
+
1916
+ def test_lint_xml_ignores_diagonal_line_whose_bbox_but_not_segment_crosses_text(self) -> None:
1917
+ # The diagonal's axis-aligned bounding box overlaps the text, but the segment itself passes
1918
+ # through empty space in the opposite corner -- a naive bbox test would false-positive here.
1919
+ result = xml_text_overlap_lint.lint_xml(
1920
+ """
1921
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1922
+ <data>
1923
+ <shape id="corner-text" type="text" topLeftX="80" topLeftY="80" width="120" height="40">
1924
+ <content fontSize="18"><p>corner</p></content>
1925
+ </shape>
1926
+ <line id="far-diag" startX="700" startY="80" endX="90" endY="500">
1927
+ <border color="rgb(255, 0, 0)" width="3"/>
1928
+ </line>
1929
+ </data>
1930
+ </slide>
1931
+ """
1932
+ )
1933
+ crossing = [
1934
+ issue for issue in result["slides"][0]["errors"] if set(issue["elements"]) == {"far-diag", "corner-text"}
1935
+ ]
1936
+ self.assertEqual(crossing, [])
1937
+
1938
+ def test_lint_xml_ignores_line_touching_text_frame_but_not_glyphs(self) -> None:
1939
+ result = xml_text_overlap_lint.lint_xml(
1940
+ """
1941
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1942
+ <data>
1943
+ <shape id="lbl" type="text" topLeftX="80" topLeftY="80" width="300" height="200">
1944
+ <content fontSize="18" verticalAlign="top"><p>短标签</p></content>
1945
+ </shape>
1946
+ <line id="below" startX="80" startY="270" endX="380" endY="270">
1947
+ <border color="rgb(255, 0, 0)" width="2"/>
1948
+ </line>
1949
+ </data>
1950
+ </slide>
1951
+ """
1952
+ )
1953
+ self.assertEqual(result["summary"]["error_count"], 0)
1954
+
1955
+ def test_lint_xml_ignores_invisible_line_crossing_text(self) -> None:
1956
+ result = xml_text_overlap_lint.lint_xml(
1957
+ """
1958
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1959
+ <data>
1960
+ <shape id="title" type="text" topLeftX="80" topLeftY="200" width="500" height="90">
1961
+ <content fontSize="60"><p>测试文字 ABC</p></content>
1962
+ </shape>
1963
+ <line id="ghost-line" startX="80" startY="245" endX="560" endY="245">
1964
+ <border color="rgba(255, 0, 0, 0.03)" width="4"/>
1965
+ </line>
1966
+ </data>
1967
+ </slide>
1968
+ """
1969
+ )
1970
+ self.assertEqual(result["summary"]["error_count"], 0)
1971
+
1972
+ def test_lint_xml_ignores_vertical_line_grazing_text_left_edge(self) -> None:
1973
+ # Verbatim from deck GpGusGCwplQyK8dFN9LczmBXnwQ slide 4: a vertical line sitting on the text
1974
+ # frame's left edge renders before the first glyph, so it must not be flagged.
1975
+ result = xml_text_overlap_lint.lint_xml(
1976
+ """
1977
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1978
+ <data>
1979
+ <shape width="240" height="60" topLeftX="120" topLeftY="100" type="text" id="bmm">
1980
+ <content fontSize="20" fontFamily="Arial" color="rgba(31, 35, 41, 1)" lineSpacing="fixed:24">
1981
+ <p>Vertical edge graze</p>
1982
+ </content>
1983
+ </shape>
1984
+ <line id="bmX" startX="120.00000000000001" startY="90" endX="120.00000000000001" endY="150.00833275470998">
1985
+ <border color="rgba(0, 0, 0, 1)"/>
1986
+ </line>
1987
+ </data>
1988
+ </slide>
1989
+ """
1990
+ )
1991
+ self.assertEqual(result["summary"]["error_count"], 0)
1992
+
1993
+ def test_lint_xml_ignores_polyline_crossing_text(self) -> None:
1994
+ # Verbatim from deck GpGusGCwplQyK8dFN9LczmBXnwQ slide 6: the crossing check is scoped to
1995
+ # <line> only, so a <polyline> over text is not flagged.
1996
+ result = xml_text_overlap_lint.lint_xml(
1997
+ """
1998
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
1999
+ <data>
2000
+ <shape width="240" height="60" topLeftX="120" topLeftY="100" type="text" id="bmr">
2001
+ <content fontSize="20" fontFamily="Arial" color="rgba(31, 35, 41, 1)" lineSpacing="fixed:24">
2002
+ <p>Polyline target</p>
2003
+ </content>
2004
+ </shape>
2005
+ <polyline id="bmH" width="270" height="55" topLeftX="110" topLeftY="95">
2006
+ <border color="rgba(0, 0, 0, 1)"/>
2007
+ </polyline>
2008
+ </data>
2009
+ </slide>
2010
+ """
2011
+ )
2012
+ self.assertEqual(result["summary"]["error_count"], 0)
2013
+
2014
+ def test_lint_xml_ignores_line_below_visual_glyph_height(self) -> None:
2015
+ # Verbatim from deck GpGusGCwplQyK8dFN9LczmBXnwQ slide 7: the shape frame is 80px tall but the
2016
+ # single 20px line of glyphs occupies only its top; a line at the frame's lower region grazes
2017
+ # under the visual glyph box (underline look) and must not be flagged.
2018
+ result = xml_text_overlap_lint.lint_xml(
2019
+ """
2020
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2021
+ <data>
2022
+ <shape width="240" height="80" topLeftX="120" topLeftY="100" type="text" id="bmB">
2023
+ <content fontSize="20" fontFamily="Arial" color="rgba(31, 35, 41, 1)" lineSpacing="fixed:24">
2024
+ <p>Visual height target</p>
2025
+ </content>
2026
+ </shape>
2027
+ <line id="bmQ" startX="110" startY="150" endX="380.00185184550116" endY="150">
2028
+ <border color="rgba(0, 0, 0, 1)"/>
2029
+ </line>
2030
+ </data>
2031
+ </slide>
2032
+ """
2033
+ )
2034
+ self.assertEqual(result["summary"]["error_count"], 0)
2035
+
2036
+ def test_lint_xml_uses_rotated_text_and_chart_bounds_for_canvas_validation(self) -> None:
2037
+ result = xml_text_overlap_lint.lint_xml(
2038
+ """
2039
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
2040
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2041
+ <data>
2042
+ <shape id="rotated-text" type="text" topLeftX="0" topLeftY="0" width="100" height="100" rotation="45"/>
2043
+ <chart id="rotated-chart" topLeftX="860" topLeftY="200" width="100" height="100" rotation="45">
2044
+ <chartPlotArea><chartPlot type="line"/></chartPlotArea>
2045
+ <chartData>
2046
+ <dim1><chartField name="category" valueType="string">A</chartField></dim1>
2047
+ <dim2><chartField name="value" valueType="number">1</chartField></dim2>
2048
+ </chartData>
2049
+ </chart>
2050
+ </data>
2051
+ </slide>
2052
+ </presentation>
2053
+ """
2054
+ )
2055
+ issues_by_element = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
2056
+ self.assertEqual(result["summary"]["error_count"], 2)
2057
+ self.assertEqual(issues_by_element["rotated-text"]["code"], "shape_out_of_canvas")
2058
+ self.assertAlmostEqual(issues_by_element["rotated-text"]["overflow"]["left"], 20.710678, places=5)
2059
+ self.assertAlmostEqual(issues_by_element["rotated-text"]["overflow"]["top"], 20.710678, places=5)
2060
+ self.assertEqual(issues_by_element["rotated-chart"]["code"], "chart_out_of_canvas")
2061
+ self.assertAlmostEqual(issues_by_element["rotated-chart"]["overflow"]["right"], 20.710678, places=5)
2062
+
2063
+ def test_lint_xml_uses_declared_bounds_for_rect_and_ignores_images(self) -> None:
2064
+ result = xml_text_overlap_lint.lint_xml(
2065
+ """
2066
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
2067
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2068
+ <data>
2069
+ <shape id="rotated-rect" type="rect" topLeftX="900" topLeftY="0" width="100" height="100" rotation="45"/>
2070
+ <img id="rotated-image" src="token" topLeftX="860" topLeftY="200" width="100" height="100" rotation="45"/>
2071
+ </data>
2072
+ </slide>
2073
+ </presentation>
2074
+ """
2075
+ )
2076
+ issues_by_element = {issue["elements"][0]: issue for issue in result["slides"][0]["issues"]}
2077
+ self.assertEqual(result["summary"]["error_count"], 1)
2078
+ self.assertEqual(issues_by_element["rotated-rect"]["code"], "shape_out_of_canvas")
2079
+ self.assertEqual(issues_by_element["rotated-rect"]["overflow"], {"left": 0, "top": 0, "right": 40, "bottom": 0})
2080
+ self.assertNotIn("rotated-image", issues_by_element)
2081
+
2082
+ def test_detect_elements_out_of_canvas_limits_detection_to_whitelist(self) -> None:
2083
+ issues = xml_text_overlap_lint.detect_elements_out_of_canvas(
2084
+ [
2085
+ {"id": "table", "kind": "table", "x": 95, "y": 0, "width": 10, "height": 10, "rotation": 45},
2086
+ {"id": "chart", "kind": "chart", "x": 95, "y": 0, "width": 10, "height": 10, "rotation": 0},
2087
+ {
2088
+ "id": "text",
2089
+ "kind": "shape",
2090
+ "type": "text",
2091
+ "x": 95,
2092
+ "y": 0,
2093
+ "width": 10,
2094
+ "height": 10,
2095
+ "rotation": 0,
2096
+ },
2097
+ {
2098
+ "id": "rect",
2099
+ "kind": "shape",
2100
+ "type": "rect",
2101
+ "x": 95,
2102
+ "y": 0,
2103
+ "width": 10,
2104
+ "height": 10,
2105
+ "rotation": 45,
2106
+ },
2107
+ {"id": "image", "kind": "img", "x": 95, "y": 0, "width": 10, "height": 10, "rotation": 0},
2108
+ {
2109
+ "id": "ellipse",
2110
+ "kind": "shape",
2111
+ "type": "ellipse",
2112
+ "x": 95,
2113
+ "y": 0,
2114
+ "width": 10,
2115
+ "height": 10,
2116
+ "rotation": 0,
2117
+ },
2118
+ ],
2119
+ 100,
2120
+ 100,
2121
+ )
2122
+
2123
+ self.assertEqual([issue["elements"] for issue in issues], [["table"], ["chart"], ["text"], ["rect"]])
2124
+ self.assertEqual(issues[-1]["bbox"], {"x": 95, "y": 0, "width": 10, "height": 10})
2125
+
2126
+ def test_lint_xml_rejects_non_finite_rotation_values_from_xsd(self) -> None:
2127
+ result = xml_text_overlap_lint.lint_xml(
2128
+ """
2129
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
2130
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2131
+ <data>
2132
+ <shape id="infinite" type="text" topLeftX="-10" topLeftY="0" width="20" height="20" rotation="inf"/>
2133
+ <shape id="negative-infinite" type="text" topLeftX="0" topLeftY="-10" width="20" height="20" rotation="-inf"/>
2134
+ <chart id="not-a-number" topLeftX="950" topLeftY="0" width="20" height="20" rotation="nan">
2135
+ <chartPlotArea><chartPlot type="line"/></chartPlotArea>
2136
+ <chartData>
2137
+ <dim1><chartField name="category" valueType="string">A</chartField></dim1>
2138
+ <dim2><chartField name="value" valueType="number">1</chartField></dim2>
2139
+ </chartData>
2140
+ </chart>
2141
+ </data>
2142
+ </slide>
2143
+ </presentation>
2144
+ """
2145
+ )
2146
+ self.assertEqual(result["summary"]["error_count"], 3)
2147
+ slide_issues = result["slides"][0]["issues"]
2148
+ self.assertTrue(all(issue["code"] == "sxsd_invalid_scalar" for issue in slide_issues))
2149
+ self.assertEqual({issue["actual"] for issue in slide_issues}, {"inf", "-inf", "nan"})
2150
+
2151
+ def test_lint_xml_reports_table_bottom_overflow_from_declared_bounds(self) -> None:
2152
+ result = xml_text_overlap_lint.lint_xml(
2153
+ """
2154
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
2155
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2156
+ <data>
2157
+ <table id="score-table" topLeftX="54" topLeftY="238" width="414" height="385">
2158
+ <tr><td><content><p>Score</p></content></td></tr>
2159
+ </table>
2160
+ </data>
2161
+ </slide>
2162
+ </presentation>
2163
+ """
2164
+ )
2165
+ issue = result["slides"][0]["issues"][0]
2166
+ self.assertEqual(result["summary"]["error_count"], 1)
2167
+ self.assertEqual(issue["code"], "table_out_of_canvas")
2168
+ self.assertEqual(issue["elements"], ["score-table"])
2169
+ self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 0, "bottom": 83})
2170
+ self.assertEqual(issue["bbox"], {"x": 54, "y": 238, "width": 414, "height": 385})
2171
+
2172
+ def test_lint_xml_reports_table_right_overflow_from_declared_bounds(self) -> None:
2173
+ result = xml_text_overlap_lint.lint_xml(
2174
+ """
2175
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
2176
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2177
+ <data>
2178
+ <table id="wide-table" topLeftX="850" topLeftY="80" width="180" height="120">
2179
+ <tr><td><content><p>Score</p></content></td></tr>
2180
+ </table>
2181
+ </data>
2182
+ </slide>
2183
+ </presentation>
2184
+ """
2185
+ )
2186
+ issue = result["slides"][0]["issues"][0]
2187
+ self.assertEqual(result["summary"]["error_count"], 1)
2188
+ self.assertEqual(issue["code"], "table_out_of_canvas")
2189
+ self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 70, "bottom": 0})
2190
+
2191
+ def test_lint_xml_allows_table_with_declared_bounds_inside_canvas(self) -> None:
2192
+ result = xml_text_overlap_lint.lint_xml(
2193
+ """
2194
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
2195
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2196
+ <data>
2197
+ <table id="inside-table" topLeftX="40" topLeftY="120" width="880" height="360">
2198
+ <tr><td><content><p>Score</p></content></td></tr>
2199
+ </table>
2200
+ </data>
2201
+ </slide>
2202
+ </presentation>
2203
+ """
2204
+ )
2205
+ self.assertEqual(result["summary"]["error_count"], 0)
2206
+ self.assertEqual(result["summary"]["warning_count"], 0)
2207
+
2208
+ def test_lint_xml_reports_resolved_table_bounds_when_declared_sizes_are_missing(self) -> None:
2209
+ result = xml_text_overlap_lint.lint_xml(
2210
+ """
2211
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
2212
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2213
+ <data>
2214
+ <table id="implicit-size-table" topLeftX="850" topLeftY="480">
2215
+ <colgroup><col/><col/></colgroup>
2216
+ <tr><td/><td/></tr>
2217
+ <tr><td/><td/></tr>
2218
+ </table>
2219
+ </data>
2220
+ </slide>
2221
+ </presentation>
2222
+ """
2223
+ )
2224
+ issue = result["slides"][0]["issues"][0]
2225
+ self.assertEqual(result["summary"]["error_count"], 1)
2226
+ self.assertEqual(issue["code"], "table_out_of_canvas")
2227
+ self.assertEqual(issue["bbox"], {"x": 850, "y": 480, "width": 220, "height": 74})
2228
+ self.assertEqual(issue["overflow"], {"left": 0, "top": 0, "right": 110, "bottom": 14})
2229
+
2230
+ def test_lint_xml_uses_resolved_table_bounds_for_canvas_validation(self) -> None:
2231
+ result = xml_text_overlap_lint.lint_xml(
2232
+ """
2233
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
2234
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2235
+ <data>
2236
+ <table id="resolved-overflow-table" topLeftX="800" topLeftY="80" width="100" height="40">
2237
+ <colgroup><col width="100"/><col width="100"/></colgroup>
2238
+ <tr height="40"><td/><td/></tr>
2239
+ </table>
2240
+ </data>
2241
+ </slide>
2242
+ </presentation>
2243
+ """
2244
+ )
2245
+ issues = result["slides"][0]["issues"]
2246
+ canvas_issue = next(issue for issue in issues if issue["code"] == "table_out_of_canvas")
2247
+ mismatch_issue = next(issue for issue in issues if issue["code"] == "table_resolved_size_mismatch")
2248
+ self.assertEqual(result["summary"]["error_count"], 1)
2249
+ self.assertEqual(canvas_issue["bbox"], {"x": 800, "y": 80, "width": 200, "height": 40})
2250
+ self.assertEqual(canvas_issue["overflow"]["right"], 40)
2251
+ self.assertEqual(mismatch_issue["dimension"], "width")
2252
+ self.assertEqual(mismatch_issue["resolved_size"], canvas_issue["bbox"]["width"])
2253
+
2254
+ def test_lint_xml_uses_the_same_anonymous_table_id_for_all_table_diagnostics(self) -> None:
2255
+ result = xml_text_overlap_lint.lint_xml(
2256
+ """
2257
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
2258
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2259
+ <data>
2260
+ <shape id="title" type="text" topLeftX="40" topLeftY="40" width="200" height="40"/>
2261
+ <img id="logo" src="token" topLeftX="40" topLeftY="100" width="40" height="40"/>
2262
+ <table topLeftX="900" topLeftY="80" width="100" height="40">
2263
+ <colgroup><col width="100"/><col width="100"/></colgroup>
2264
+ <tr height="40"><td/><td/></tr>
2265
+ </table>
2266
+ </data>
2267
+ </slide>
2268
+ </presentation>
2269
+ """
2270
+ )
2271
+ issues = result["slides"][0]["issues"]
2272
+ canvas_issue = next(issue for issue in issues if issue["code"] == "table_out_of_canvas")
2273
+ mismatch_issue = next(issue for issue in issues if issue["code"] == "table_resolved_size_mismatch")
2274
+ self.assertEqual(canvas_issue["elements"], ["table-3"])
2275
+ self.assertEqual(mismatch_issue["elements"], ["table-3"])
2276
+
2277
+ def test_lint_xml_reports_info_when_table_target_size_resolves_larger_than_declared(self) -> None:
2278
+ result = xml_text_overlap_lint.lint_xml(
2279
+ """
2280
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
2281
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2282
+ <data>
2283
+ <table id="size-mismatch" topLeftX="40" topLeftY="120" width="200" height="80">
2284
+ <colgroup><col span="2" width="100"/><col width="50"/></colgroup>
2285
+ <tr height="40"><td/><td/><td/></tr>
2286
+ <tr height="60"><td/><td/><td/></tr>
2287
+ </table>
2288
+ </data>
2289
+ </slide>
2290
+ </presentation>
2291
+ """
2292
+ )
2293
+ issues_by_dimension = {issue["dimension"]: issue for issue in result["slides"][0]["issues"]}
2294
+ self.assertEqual(result["summary"]["error_count"], 0)
2295
+ self.assertEqual(result["summary"]["warning_count"], 0)
2296
+ self.assertEqual(result["summary"]["info_count"], 2)
2297
+ self.assertEqual(issues_by_dimension["width"]["level"], "info")
2298
+ self.assertEqual(issues_by_dimension["width"]["code"], "table_resolved_size_mismatch")
2299
+ self.assertEqual(issues_by_dimension["width"]["resolved_sizes"], [100, 100, 50])
2300
+ self.assertEqual(issues_by_dimension["width"]["resolved_size"], 250)
2301
+ self.assertEqual(issues_by_dimension["height"]["resolved_sizes"], [40, 60])
2302
+ self.assertEqual(issues_by_dimension["height"]["resolved_size"], 100)
2303
+
2304
+ def test_lint_xml_does_not_report_info_when_table_target_size_is_resolved_exactly(self) -> None:
2305
+ result = xml_text_overlap_lint.lint_xml(
2306
+ """
2307
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
2308
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2309
+ <data>
2310
+ <table id="size-match" topLeftX="40" topLeftY="120" width="300" height="100">
2311
+ <colgroup><col width="100"/><col/></colgroup>
2312
+ <tr height="40"><td/><td/></tr>
2313
+ <tr><td/><td/></tr>
2314
+ </table>
2315
+ </data>
2316
+ </slide>
2317
+ </presentation>
2318
+ """
2319
+ )
2320
+ self.assertEqual(result["summary"]["error_count"], 0)
2321
+ self.assertEqual(result["summary"]["warning_count"], 0)
2322
+ self.assertEqual(result["slides"][0]["issues"], [])
2323
+
2324
+ def test_lint_xml_keeps_resolved_table_sizes_positive_when_target_is_too_small(self) -> None:
2325
+ result = xml_text_overlap_lint.lint_xml(
2326
+ """
2327
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
2328
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2329
+ <data>
2330
+ <table id="narrow-table" topLeftX="40" topLeftY="120" width="1">
2331
+ <colgroup><col/><col/></colgroup>
2332
+ <tr><td/><td/></tr>
2333
+ </table>
2334
+ </data>
2335
+ </slide>
2336
+ </presentation>
2337
+ """
2338
+ )
2339
+ issue = result["slides"][0]["issues"][0]
2340
+ self.assertEqual(issue["dimension"], "width")
2341
+ self.assertEqual(issue["resolved_sizes"], [1, 1])
2342
+ self.assertEqual(issue["resolved_size"], 2)
2343
+
2344
+ def test_fill_last_size_gap_preserves_target_when_positive_sizes_are_possible(self) -> None:
2345
+ final_sizes = xml_text_overlap_lint.fill_last_size_gap([10, 10], 3)
2346
+ self.assertEqual(final_sizes, [2, 1])
2347
+ self.assertEqual(sum(final_sizes), 3)
2348
+
2349
+ def test_cli_reports_table_layout_size_info_for_weighted_min_layout_cases(self) -> None:
2350
+ cases = {
2351
+ "target-exact": (
2352
+ """
2353
+ <table topLeftX="40" topLeftY="120" width="360" height="150">
2354
+ <colgroup><col width="100"/><col width="200"/></colgroup>
2355
+ <tr height="40"><td/><td/></tr><tr height="60"><td/><td/></tr>
2356
+ </table>
2357
+ """,
2358
+ 0,
2359
+ ),
2360
+ "declared-size-exceeds-target": (
2361
+ """
2362
+ <table topLeftX="40" topLeftY="120" width="200" height="80">
2363
+ <colgroup><col span="2" width="100"/><col width="50"/></colgroup>
2364
+ <tr height="40"><td/><td/><td/></tr><tr height="60"><td/><td/><td/></tr>
2365
+ </table>
2366
+ """,
2367
+ 2,
2368
+ ),
2369
+ "remaining-space-insufficient": (
2370
+ """
2371
+ <table topLeftX="40" topLeftY="120" width="80" height="30">
2372
+ <colgroup><col width="80"/><col/></colgroup>
2373
+ <tr height="40"><td/><td/></tr><tr><td/><td/></tr>
2374
+ </table>
2375
+ """,
2376
+ 2,
2377
+ ),
2378
+ "no-target-size": (
2379
+ """
2380
+ <table topLeftX="40" topLeftY="120">
2381
+ <colgroup><col width="80"/><col/></colgroup>
2382
+ <tr height="40"><td/><td/></tr><tr><td/><td/></tr>
2383
+ </table>
2384
+ """,
2385
+ 0,
2386
+ ),
2387
+ }
2388
+ script_path = Path(xml_text_overlap_lint.__file__).resolve()
2389
+ with tempfile.TemporaryDirectory() as temp_dir:
2390
+ for name, (table_xml, expected_info_count) in cases.items():
2391
+ with self.subTest(case=name):
2392
+ input_path = Path(temp_dir) / f"{name}.xml"
2393
+ input_path.write_text(
2394
+ f"""
2395
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
2396
+ <slide xmlns="http://www.larkoffice.com/sml/2.0"><data>{table_xml}</data></slide>
2397
+ </presentation>
2398
+ """,
2399
+ encoding="utf-8",
2400
+ )
2401
+ completed = subprocess.run(
2402
+ [sys.executable, str(script_path), "--input", str(input_path)],
2403
+ capture_output=True,
2404
+ check=False,
2405
+ text=True,
2406
+ )
2407
+ result = json.loads(completed.stdout)
2408
+ self.assertEqual(completed.returncode, 0, completed.stderr)
2409
+ self.assertEqual(result["summary"]["error_count"], 0)
2410
+ self.assertEqual(result["summary"]["warning_count"], 0)
2411
+ self.assertEqual(result["summary"]["info_count"], expected_info_count)
2412
+ self.assertTrue(
2413
+ all(issue["level"] == "info" for issue in result["slides"][0]["issues"]),
2414
+ result["slides"][0]["issues"],
2415
+ )
2416
+
2417
+ def test_lint_xml_detects_invalid_template_text_stack_overlap(self) -> None:
2418
+ cases = [
2419
+ (
2420
+ "subtitle-too-high",
2421
+ """
2422
+ <shape type="text" topLeftX="40" topLeftY="80" width="240" height="90">
2423
+ <content textType="title" fontSize="44"><p>Title</p></content>
2424
+ </shape>
2425
+ <shape type="text" topLeftX="40" topLeftY="90" width="240" height="80">
2426
+ <content textType="sub-headline" fontSize="20"><p>Subtitle</p></content>
2427
+ </shape>
2428
+ """,
2429
+ ),
2430
+ ]
2431
+ for name, shapes in cases:
2432
+ with self.subTest(name=name):
2433
+ result = xml_text_overlap_lint.lint_xml(
2434
+ f"""
2435
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
2436
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2437
+ <data>{shapes}</data>
2438
+ </slide>
2439
+ </presentation>
2440
+ """
2441
+ )
2442
+ self.assertEqual(result["summary"]["error_count"], 1)
2443
+ self.assertEqual(result["slides"][0]["issues"][0]["code"], "bbox_overlap")
2444
+
2445
+
2446
+ def test_lint_xml_reports_vertical_text_image_overlap_as_warning(self) -> None:
2447
+ result = xml_text_overlap_lint.lint_xml(
2448
+ """
2449
+ <slide xmlns="http://www.larkoffice.com/sml/2.0"><data>
2450
+ <shape id="text" type="text" vert="vert" topLeftX="100" topLeftY="100" width="100" height="100">
2451
+ <content><p>Vertical</p></content>
2452
+ </shape>
2453
+ <img id="image" src="token" topLeftX="120" topLeftY="120" width="20" height="20"/>
2454
+ </data></slide>
2455
+ """
2456
+ )
2457
+ issue = next(issue for issue in result["slides"][0]["issues"] if issue["code"] == "image_may_cover_vertical_text")
2458
+ self.assertEqual(issue["level"], "info")
2459
+ self.assertEqual(result["summary"]["error_count"], 0)
2460
+ self.assertEqual(result["summary"]["info_count"], 1)
2461
+
2462
+
2463
+ class XmlTextOverlapLintDensityTest(unittest.TestCase):
2464
+ def test_lint_xml_blocks_blank_slide(self) -> None:
2465
+ result = xml_text_overlap_lint.lint_xml(
2466
+ """
2467
+ <presentation xmlns="http://www.larkoffice.com/sml/2.0" width="960" height="540">
2468
+ <slide id="content-slide">
2469
+ <data>
2470
+ <shape id="title" type="text" topLeftX="60" topLeftY="60" width="400" height="50">
2471
+ <content fontSize="28"><p>Investment report</p></content>
2472
+ </shape>
2473
+ </data>
2474
+ </slide>
2475
+ <slide id="blank-slide">
2476
+ <style><fill><fillColor color="rgba(255, 255, 255, 1)"/></fill></style>
2477
+ <data/>
2478
+ <note><content/></note>
2479
+ </slide>
2480
+ </presentation>
2481
+ """
2482
+ )
2483
+
2484
+ self.assertEqual(result["summary"]["slide_count"], 2)
2485
+ self.assertEqual(result["summary"]["warning_count"], 0)
2486
+ self.assertEqual(result["summary"]["error_count"], 1)
2487
+ self.assertEqual(result["summary"]["status"], "blocked")
2488
+ self.assertFalse(result["summary"]["release_ready"])
2489
+ self.assertEqual(result["slides"][0]["issues"], [])
2490
+ self.assertEqual(result["slides"][1]["element_count"], 0)
2491
+ issue = result["slides"][1]["errors"][0]
2492
+ self.assertEqual(issue["level"], "error")
2493
+ self.assertEqual(issue["code"], "blank_slide")
2494
+ self.assertEqual(issue["element_ids"], [])
2495
+ self.assertEqual(issue["rule"]["id"], "blank_slide")
2496
+ self.assertEqual(issue["measurement"]["visible_element_count"], 0)
2497
+ self.assertEqual(issue["related_objects"], [])
2498
+
2499
+ def test_lint_xml_blocks_blank_slide_with_only_transparent_image(self) -> None:
2500
+ result = xml_text_overlap_lint.lint_xml(
2501
+ """
2502
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2503
+ <data>
2504
+ <img id="ghost" src="token" topLeftX="60" topLeftY="60" width="200" height="200" alpha="0"/>
2505
+ </data>
2506
+ </slide>
2507
+ """
2508
+ )
2509
+
2510
+ self.assertEqual(result["summary"]["error_count"], 1)
2511
+ issue = result["slides"][0]["errors"][0]
2512
+ self.assertEqual(issue["code"], "blank_slide")
2513
+
2514
+ def test_lint_xml_warns_when_large_container_is_mostly_empty(self) -> None:
2515
+ result = xml_text_overlap_lint.lint_xml(
2516
+ """
2517
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2518
+ <data>
2519
+ <shape id="trend-card" type="rect" topLeftX="500" topLeftY="135" width="410" height="370"/>
2520
+ <shape id="trend-title" type="text" topLeftX="515" topLeftY="147" width="380" height="28">
2521
+ <content fontSize="15"><p>Core trends</p></content>
2522
+ </shape>
2523
+ <shape id="trend-copy" type="text" topLeftX="515" topLeftY="177" width="380" height="315">
2524
+ <content fontSize="12"><p>First point</p><p>Second point</p><p>Third point</p></content>
2525
+ </shape>
2526
+ </data>
2527
+ </slide>
2528
+ """
2529
+ )
2530
+
2531
+ issue = result["slides"][0]["issues"][0]
2532
+ self.assertEqual(issue["code"], "sparse_container_content")
2533
+ self.assertEqual(issue["target"]["container_id"], "trend-card")
2534
+ self.assertEqual(issue["target"], {
2535
+ "slide_number": 1,
2536
+ "container_id": "trend-card",
2537
+ "container_type": "rect",
2538
+ "bbox": {"x": 500, "y": 135, "width": 410, "height": 370},
2539
+ })
2540
+ self.assertLess(issue["measurement"]["content_coverage_ratio"], 0.15)
2541
+ self.assertEqual(issue["rule"], {
2542
+ "name": "large_container_visible_content_coverage",
2543
+ "threshold": 0.15,
2544
+ "comparison": "content_coverage_ratio < threshold",
2545
+ "id": "sparse_container_content",
2546
+ })
2547
+ self.assertEqual(issue["measurement"]["container_area"], 151700)
2548
+ self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0.03)
2549
+ self.assertEqual(issue["elements"], ["trend-card", "trend-title", "trend-copy"])
2550
+ self.assertEqual(issue["element_ids"], ["trend-card", "trend-title", "trend-copy"])
2551
+ self.assertEqual(
2552
+ [obj["element_id"] for obj in issue["related_objects"]],
2553
+ ["trend-card", "trend-title", "trend-copy"],
2554
+ )
2555
+ self.assertEqual(result["slides"][0]["status"], "needs_screenshot_review")
2556
+ self.assertEqual(result["slides"][0]["warnings"], result["slides"][0]["issues"])
2557
+
2558
+ def test_lint_xml_warns_for_sparse_short_cards(self) -> None:
2559
+ result = xml_text_overlap_lint.lint_xml(
2560
+ """
2561
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2562
+ <data>
2563
+ <shape id="card-1" type="rect" topLeftX="60" topLeftY="180" width="400" height="105"/>
2564
+ <shape id="text-1" type="text" topLeftX="80" topLeftY="220" width="360" height="30">
2565
+ <content fontSize="14"><p>期待认识大家</p></content>
2566
+ </shape>
2567
+ <shape id="card-2" type="rect" topLeftX="490" topLeftY="180" width="400" height="105"/>
2568
+ <shape id="text-2" type="text" topLeftX="510" topLeftY="220" width="360" height="30">
2569
+ <content fontSize="14"><p>化学一起讨论</p></content>
2570
+ </shape>
2571
+ <shape id="card-3" type="rect" topLeftX="60" topLeftY="310" width="400" height="105"/>
2572
+ <shape id="text-3" type="text" topLeftX="80" topLeftY="350" width="360" height="30">
2573
+ <content fontSize="14"><p>吉他随时交流</p></content>
2574
+ </shape>
2575
+ <shape id="card-4" type="rect" topLeftX="490" topLeftY="310" width="400" height="105"/>
2576
+ <shape id="text-4" type="text" topLeftX="510" topLeftY="350" width="360" height="30">
2577
+ <content fontSize="14"><p>共度美好四年</p></content>
2578
+ </shape>
2579
+ </data>
2580
+ </slide>
2581
+ """
2582
+ )
2583
+
2584
+ container_issues = [
2585
+ issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content"
2586
+ ]
2587
+ self.assertEqual(
2588
+ [issue["target"]["container_id"] for issue in container_issues],
2589
+ ["card-1", "card-2", "card-3", "card-4"],
2590
+ )
2591
+ self.assertTrue(all(issue["target"]["bbox"]["height"] == 105 for issue in container_issues))
2592
+ self.assertTrue(all(issue["measurement"]["content_coverage_ratio"] < 0.15 for issue in container_issues))
2593
+ self.assertEqual(
2594
+ [issue["code"] for issue in result["slides"][0]["issues"]],
2595
+ [
2596
+ "sparse_container_content",
2597
+ "sparse_container_content",
2598
+ "sparse_container_content",
2599
+ "sparse_container_content",
2600
+ "sparse_slide_content",
2601
+ ],
2602
+ )
2603
+
2604
+ def test_lint_xml_warns_when_whole_slide_has_too_little_effective_content(self) -> None:
2605
+ result = xml_text_overlap_lint.lint_xml(
2606
+ """
2607
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2608
+ <data>
2609
+ <shape id="background" type="rect" topLeftX="0" topLeftY="0" width="960" height="540"/>
2610
+ <shape id="text-1" type="text" topLeftX="60" topLeftY="80" width="200" height="30">
2611
+ <content fontSize="14"><p>One short line</p></content>
2612
+ </shape>
2613
+ <shape id="text-2" type="text" topLeftX="500" topLeftY="180" width="200" height="30">
2614
+ <content fontSize="14"><p>Another line</p></content>
2615
+ </shape>
2616
+ <shape id="text-3" type="text" topLeftX="60" topLeftY="310" width="200" height="30">
2617
+ <content fontSize="14"><p>Third line</p></content>
2618
+ </shape>
2619
+ <shape id="text-4" type="text" topLeftX="500" topLeftY="410" width="200" height="30">
2620
+ <content fontSize="14"><p>Fourth line</p></content>
2621
+ </shape>
2622
+ </data>
2623
+ </slide>
2624
+ """
2625
+ )
2626
+
2627
+ issues = [issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_slide_content"]
2628
+ self.assertEqual(len(issues), 1)
2629
+ issue = issues[0]
2630
+ self.assertEqual(issue["target"]["bbox"], {"x": 0, "y": 0, "width": 960, "height": 540})
2631
+ self.assertEqual(issue["rule"]["threshold"], 0.035)
2632
+ self.assertLess(issue["measurement"]["content_coverage_ratio"], 0.035)
2633
+ self.assertEqual(issue["measurement"]["content_element_count"], 4)
2634
+ self.assertNotIn("background", issue["elements"])
2635
+
2636
+ def test_lint_xml_ignores_isolated_short_layout_bar(self) -> None:
2637
+ result = xml_text_overlap_lint.lint_xml(
2638
+ """
2639
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2640
+ <data>
2641
+ <shape id="summary-bar" type="rect" topLeftX="52" topLeftY="82" width="856" height="105"/>
2642
+ <shape id="summary" type="text" topLeftX="72" topLeftY="115" width="816" height="30">
2643
+ <content fontSize="14"><p>One concise summary</p></content>
2644
+ </shape>
2645
+ </data>
2646
+ </slide>
2647
+ """
2648
+ )
2649
+
2650
+ self.assertEqual(result["slides"][0]["issues"], [])
2651
+
2652
+ def test_lint_xml_counts_rect_own_content_as_visible_content(self) -> None:
2653
+ result = xml_text_overlap_lint.lint_xml(
2654
+ """
2655
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2656
+ <data>
2657
+ <shape id="load-card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
2658
+ <content fontSize="18">
2659
+ <p>被吊物</p>
2660
+ <p><span fontSize="36">32.0 t</span></p>
2661
+ <p>钢结构模块</p>
2662
+ </content>
2663
+ </shape>
2664
+ </data>
2665
+ </slide>
2666
+ """
2667
+ )
2668
+
2669
+ self.assertEqual(result["slides"][0]["issues"], [])
2670
+
2671
+ def test_lint_xml_reports_nonzero_coverage_for_rect_own_content_reproduction(self) -> None:
2672
+ result = xml_text_overlap_lint.lint_xml(
2673
+ """
2674
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2675
+ <data>
2676
+ <shape id="load-card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
2677
+ <content fontSize="18">
2678
+ <p>被吊物</p>
2679
+ <p>32.0 t</p>
2680
+ <p>钢结构模块</p>
2681
+ </content>
2682
+ </shape>
2683
+ </data>
2684
+ </slide>
2685
+ """
2686
+ )
2687
+
2688
+ issue = result["slides"][0]["issues"][0]
2689
+ self.assertGreater(issue["measurement"]["visible_content_area"], 0)
2690
+ self.assertEqual(issue["measurement"]["content_element_count"], 1)
2691
+ self.assertGreater(issue["measurement"]["content_coverage_ratio"], 0)
2692
+
2693
+ def test_lint_xml_still_warns_for_sparse_rect_own_content(self) -> None:
2694
+ result = xml_text_overlap_lint.lint_xml(
2695
+ """
2696
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2697
+ <data>
2698
+ <shape id="sparse-card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
2699
+ <content fontSize="12"><p>A</p></content>
2700
+ </shape>
2701
+ </data>
2702
+ </slide>
2703
+ """
2704
+ )
2705
+
2706
+ issue = result["slides"][0]["issues"][0]
2707
+ self.assertEqual(issue["target"]["container_id"], "sparse-card")
2708
+ self.assertGreater(issue["measurement"]["visible_content_area"], 0)
2709
+ self.assertEqual(issue["measurement"]["content_element_count"], 1)
2710
+ self.assertEqual(issue["elements"], ["sparse-card"])
2711
+
2712
+ def test_lint_xml_unions_rect_own_content_with_child_content(self) -> None:
2713
+ self_only = xml_text_overlap_lint.lint_xml(
2714
+ """
2715
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2716
+ <data>
2717
+ <shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
2718
+ <content fontSize="12"><p>A</p></content>
2719
+ </shape>
2720
+ </data>
2721
+ </slide>
2722
+ """
2723
+ )
2724
+ with_overlapping_child = xml_text_overlap_lint.lint_xml(
2725
+ """
2726
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2727
+ <data>
2728
+ <shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
2729
+ <content fontSize="12"><p>A</p></content>
2730
+ </shape>
2731
+ <shape id="child" type="text" topLeftX="60" topLeftY="140" width="220" height="184">
2732
+ <content fontSize="12"><p>A</p></content>
2733
+ </shape>
2734
+ </data>
2735
+ </slide>
2736
+ """
2737
+ )
2738
+
2739
+ self_issue = self_only["slides"][0]["issues"][0]
2740
+ mixed_issue = with_overlapping_child["slides"][0]["issues"][0]
2741
+ self.assertEqual(
2742
+ mixed_issue["measurement"]["visible_content_area"],
2743
+ self_issue["measurement"]["visible_content_area"],
2744
+ )
2745
+ self.assertEqual(mixed_issue["measurement"]["content_element_count"], 2)
2746
+
2747
+ def test_extract_density_elements_reads_nested_font_size_from_rect_content(self) -> None:
2748
+ elements = xml_text_overlap_lint.extract_density_elements(
2749
+ """
2750
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2751
+ <data>
2752
+ <shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184">
2753
+ <content fontSize="12"><p><span fontSize="36">32.0 t</span></p></content>
2754
+ </shape>
2755
+ </data>
2756
+ </slide>
2757
+ """
2758
+ )
2759
+
2760
+ self.assertEqual(elements[0]["fontSize"], 36)
2761
+
2762
+ def test_extract_density_elements_does_not_attach_following_text_to_self_closing_rect(self) -> None:
2763
+ elements = xml_text_overlap_lint.extract_density_elements(
2764
+ """
2765
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2766
+ <data>
2767
+ <shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184"/>
2768
+ <shape id="title" type="text" topLeftX="80" topLeftY="160" width="180" height="30">
2769
+ <content fontSize="18"><p>Following title</p></content>
2770
+ </shape>
2771
+ </data>
2772
+ </slide>
2773
+ """
2774
+ )
2775
+
2776
+ self.assertEqual(elements[0]["text"], "")
2777
+ self.assertEqual(elements[1]["text"], "Following title")
2778
+
2779
+ def test_lint_xml_allows_container_with_large_visual_child(self) -> None:
2780
+ result = xml_text_overlap_lint.lint_xml(
2781
+ """
2782
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2783
+ <data>
2784
+ <shape id="chart-card" type="rect" topLeftX="500" topLeftY="135" width="410" height="300"/>
2785
+ <chart id="chart" topLeftX="525" topLeftY="170" width="350" height="220">
2786
+ <chartPlotArea><chartPlot type="line"/></chartPlotArea>
2787
+ <chartData>
2788
+ <dim1><chartField name="category" valueType="string">A</chartField></dim1>
2789
+ <dim2><chartField name="value" valueType="number">1</chartField></dim2>
2790
+ </chartData>
2791
+ </chart>
2792
+ </data>
2793
+ </slide>
2794
+ """
2795
+ )
2796
+
2797
+ self.assertEqual(result["summary"]["warning_count"], 0)
2798
+
2799
+ def test_lint_xml_does_not_let_transparent_visual_child_suppress_sparse_warning(self) -> None:
2800
+ result = xml_text_overlap_lint.lint_xml(
2801
+ """
2802
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2803
+ <data>
2804
+ <shape id="title" type="text" topLeftX="40" topLeftY="40" width="300" height="40">
2805
+ <content fontSize="20"><p>Section title</p></content>
2806
+ </shape>
2807
+ <shape id="chart-card" type="rect" topLeftX="500" topLeftY="135" width="410" height="300"/>
2808
+ <chart id="chart" topLeftX="525" topLeftY="170" width="350" height="220" alpha="0">
2809
+ <chartPlotArea><chartPlot type="line"/></chartPlotArea>
2810
+ <chartData>
2811
+ <dim1><chartField name="category" valueType="string">A</chartField></dim1>
2812
+ <dim2><chartField name="value" valueType="number">1</chartField></dim2>
2813
+ </chartData>
2814
+ </chart>
2815
+ </data>
2816
+ </slide>
2817
+ """
2818
+ )
2819
+
2820
+ issue = next(
2821
+ issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content"
2822
+ )
2823
+ self.assertEqual(issue["target"]["container_id"], "chart-card")
2824
+
2825
+ def test_lint_xml_warns_for_small_empty_visual_placeholder_cards(self) -> None:
2826
+ result = xml_text_overlap_lint.lint_xml(
2827
+ """
2828
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2829
+ <data>
2830
+ <shape id="letter-placeholder" type="rect" topLeftX="520" topLeftY="180" width="200" height="200"/>
2831
+ <shape id="letter" type="text" topLeftX="540" topLeftY="250" width="160" height="70">
2832
+ <content fontSize="46"><p>Z</p></content>
2833
+ </shape>
2834
+ <shape id="empty-placeholder" type="rect" topLeftX="744" topLeftY="180" width="144" height="200"/>
2835
+ </data>
2836
+ </slide>
2837
+ """
2838
+ )
2839
+
2840
+ issues = result["slides"][0]["issues"]
2841
+ self.assertEqual(
2842
+ [issue["target"]["container_id"] for issue in issues],
2843
+ ["letter-placeholder", "empty-placeholder"],
2844
+ )
2845
+ self.assertEqual(issues[1]["measurement"]["content_element_count"], 0)
2846
+
2847
+ def test_lint_xml_applies_global_threshold_to_normal_text_card(self) -> None:
2848
+ result = xml_text_overlap_lint.lint_xml(
2849
+ """
2850
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2851
+ <data>
2852
+ <shape id="card" type="rect" topLeftX="70" topLeftY="184" width="260" height="288"/>
2853
+ <shape id="title" type="text" topLeftX="90" topLeftY="215" width="220" height="30">
2854
+ <content fontSize="18"><p>梦境与现实</p></content>
2855
+ </shape>
2856
+ <shape id="copy" type="text" topLeftX="90" topLeftY="330" width="220" height="70">
2857
+ <content fontSize="13"><p>边界溶解,逻辑失效。观众被拽入潜意识的迷宫。</p></content>
2858
+ </shape>
2859
+ </data>
2860
+ </slide>
2861
+ """
2862
+ )
2863
+
2864
+ issue = result["slides"][0]["issues"][0]
2865
+ self.assertEqual(issue["target"]["container_id"], "card")
2866
+ self.assertEqual(issue["rule"]["threshold"], 0.15)
2867
+
2868
+ def test_lint_xml_allows_image_overlay_rect(self) -> None:
2869
+ result = xml_text_overlap_lint.lint_xml(
2870
+ """
2871
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2872
+ <data>
2873
+ <img id="hero" topLeftX="560" topLeftY="0" width="400" height="540"/>
2874
+ <shape id="tint" type="rect" topLeftX="560" topLeftY="0" width="400" height="540"/>
2875
+ </data>
2876
+ </slide>
2877
+ """
2878
+ )
2879
+
2880
+ self.assertEqual(result["summary"]["warning_count"], 0)
2881
+
2882
+ def test_lint_xml_does_not_let_transparent_image_overlay_suppress_sparse_warning(self) -> None:
2883
+ result = xml_text_overlap_lint.lint_xml(
2884
+ """
2885
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2886
+ <data>
2887
+ <shape id="title" type="text" topLeftX="40" topLeftY="40" width="300" height="40">
2888
+ <content fontSize="20"><p>Section title</p></content>
2889
+ </shape>
2890
+ <shape id="card" type="rect" topLeftX="330" topLeftY="120" width="300" height="300"/>
2891
+ <img id="ghost-overlay" src="token" topLeftX="330" topLeftY="120" width="300" height="300" alpha="0"/>
2892
+ </data>
2893
+ </slide>
2894
+ """
2895
+ )
2896
+
2897
+ issue = next(
2898
+ issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content"
2899
+ )
2900
+ self.assertEqual(issue["target"]["container_id"], "card")
2901
+
2902
+ def test_lint_xml_allows_edge_spanning_layout_panel_and_nested_decoration(self) -> None:
2903
+ result = xml_text_overlap_lint.lint_xml(
2904
+ """
2905
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2906
+ <data>
2907
+ <shape id="panel" type="rect" topLeftX="600" topLeftY="0" width="360" height="540"/>
2908
+ <shape id="decoration" type="rect" topLeftX="660" topLeftY="150" width="240" height="240"/>
2909
+ </data>
2910
+ </slide>
2911
+ """
2912
+ )
2913
+
2914
+ self.assertEqual(result["summary"]["warning_count"], 0)
2915
+
2916
+ def test_lint_xml_counts_icons_as_visible_content(self) -> None:
2917
+ result = xml_text_overlap_lint.lint_xml(
2918
+ """
2919
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2920
+ <data>
2921
+ <shape id="card" type="rect" topLeftX="80" topLeftY="140" width="320" height="240"/>
2922
+ <icon id="visual" iconType="iconpark/Safe/shield.svg" topLeftX="100" topLeftY="160" width="180" height="180">
2923
+ <fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
2924
+ </icon>
2925
+ </data>
2926
+ </slide>
2927
+ """
2928
+ )
2929
+
2930
+ self.assertEqual(result["summary"]["warning_count"], 0)
2931
+
2932
+ def test_lint_xml_does_not_count_transparent_icon_as_visible_content(self) -> None:
2933
+ result = xml_text_overlap_lint.lint_xml(
2934
+ """
2935
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2936
+ <data>
2937
+ <shape id="title" type="text" topLeftX="40" topLeftY="40" width="300" height="40">
2938
+ <content fontSize="20"><p>Section title</p></content>
2939
+ </shape>
2940
+ <shape id="card" type="rect" topLeftX="80" topLeftY="140" width="320" height="240"/>
2941
+ <icon id="visual" iconType="iconpark/Safe/shield.svg" topLeftX="100" topLeftY="160" width="180" height="180" alpha="0">
2942
+ <fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
2943
+ </icon>
2944
+ </data>
2945
+ </slide>
2946
+ """
2947
+ )
2948
+
2949
+ issue = next(
2950
+ issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content"
2951
+ )
2952
+ self.assertEqual(issue["target"]["container_id"], "card")
2953
+ self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0)
2954
+
2955
+ def test_lint_xml_warns_when_coverage_is_below_global_threshold(self) -> None:
2956
+ result = xml_text_overlap_lint.lint_xml(
2957
+ """
2958
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2959
+ <data>
2960
+ <shape id="card" type="rect" topLeftX="80" topLeftY="140" width="200" height="200"/>
2961
+ <icon id="visual" iconType="iconpark/Safe/shield.svg" topLeftX="100" topLeftY="160" width="70" height="70">
2962
+ <fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
2963
+ </icon>
2964
+ </data>
2965
+ </slide>
2966
+ """
2967
+ )
2968
+
2969
+ issue = result["slides"][0]["issues"][0]
2970
+ self.assertEqual(issue["target"]["container_id"], "card")
2971
+ self.assertEqual(issue["measurement"]["content_coverage_ratio"], 0.122)
2972
+ self.assertEqual(issue["rule"]["threshold"], 0.15)
2973
+
2974
+ def test_lint_xml_allows_quarter_coverage_under_lower_threshold(self) -> None:
2975
+ result = xml_text_overlap_lint.lint_xml(
2976
+ """
2977
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2978
+ <data>
2979
+ <shape id="card" type="rect" topLeftX="80" topLeftY="140" width="200" height="200"/>
2980
+ <icon id="visual" iconType="iconpark/Safe/shield.svg" topLeftX="100" topLeftY="160" width="100" height="100">
2981
+ <fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
2982
+ </icon>
2983
+ </data>
2984
+ </slide>
2985
+ """
2986
+ )
2987
+
2988
+ self.assertEqual(result["slides"][0]["issues"], [])
2989
+
2990
+ def test_lint_xml_allows_large_metric_card_above_lower_threshold(self) -> None:
2991
+ result = xml_text_overlap_lint.lint_xml(
2992
+ """
2993
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
2994
+ <data>
2995
+ <shape id="metric-card" type="rect" topLeftX="80" topLeftY="140" width="360" height="300"/>
2996
+ <shape id="metric" type="text" topLeftX="104" topLeftY="190" width="340" height="90">
2997
+ <content fontSize="12"><p><strong><span fontSize="62">400</span></strong>+ 项</p></content>
2998
+ </shape>
2999
+ </data>
3000
+ </slide>
3001
+ """
3002
+ )
3003
+
3004
+ self.assertEqual(result["slides"][0]["issues"], [])
3005
+
3006
+ def test_lint_xml_does_not_report_blank_slide_for_line_only_content(self) -> None:
3007
+ result = xml_text_overlap_lint.lint_xml(
3008
+ """
3009
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
3010
+ <data>
3011
+ <line id="l1" startX="100" startY="100" endX="800" endY="100"><border/></line>
3012
+ <line id="l2" startX="100" startY="200" endX="800" endY="200"><border/></line>
3013
+ <line id="l3" startX="100" startY="300" endX="800" endY="300"><border/></line>
3014
+ <line id="l4" startX="100" startY="400" endX="800" endY="400"><border/></line>
3015
+ </data>
3016
+ </slide>
3017
+ """
3018
+ )
3019
+
3020
+ self.assertEqual(result["summary"]["error_count"], 0)
3021
+ codes = [issue["code"] for issue in result["slides"][0]["issues"]]
3022
+ self.assertNotIn("blank_slide", codes)
3023
+
3024
+ def test_lint_xml_reports_bbox_overlap_measurement_from_decision_time_visual_bbox(self) -> None:
3025
+ result = xml_text_overlap_lint.lint_xml(
3026
+ """
3027
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
3028
+ <data>
3029
+ <shape id="left" type="text" topLeftX="80" topLeftY="80" width="300" height="60">
3030
+ <content fontSize="14"><p>overlap text <span fontSize="96">big</span></p></content>
3031
+ </shape>
3032
+ <shape id="right" type="text" topLeftX="80" topLeftY="80" width="300" height="80">
3033
+ <content fontSize="14"><p>other overlap text</p></content>
3034
+ </shape>
3035
+ </data>
3036
+ </slide>
3037
+ """
3038
+ )
3039
+
3040
+ issue = result["slides"][0]["issues"][0]
3041
+ self.assertEqual(issue["code"], "bbox_overlap")
3042
+ # Must match the visual bbox that should_flag_overlap actually decided with (fontSize=14
3043
+ # from extract_elements), not the fontSize=96 max-descendant value that
3044
+ # extract_density_elements computes for the same "left" element id.
3045
+ self.assertEqual(issue["measurement"]["intersection_width"], 109.2)
3046
+ self.assertEqual(issue["measurement"]["intersection_height"], 6.8)
3047
+ self.assertEqual(issue["measurement"]["intersection_area"], 742.56)
3048
+
3049
+ def test_has_similar_short_card_peer_excludes_the_element_itself(self) -> None:
3050
+ card_a = {"kind": "shape", "type": "rect", "x": 0, "y": 0, "width": 300, "height": 100}
3051
+ card_b = {"kind": "shape", "type": "rect", "x": 400, "y": 0, "width": 300, "height": 100}
3052
+ card_c = {"kind": "shape", "type": "rect", "x": 0, "y": 200, "width": 300, "height": 100}
3053
+
3054
+ self.assertFalse(
3055
+ xml_text_overlap_lint.has_similar_short_card_peer(card_a, [card_a, card_b])
3056
+ )
3057
+ self.assertTrue(
3058
+ xml_text_overlap_lint.has_similar_short_card_peer(card_a, [card_a, card_b, card_c])
3059
+ )
3060
+
3061
+ def test_lint_xml_reports_schema_version_2_for_sparse_issues(self) -> None:
3062
+ result = xml_text_overlap_lint.lint_xml(
3063
+ """
3064
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
3065
+ <data>
3066
+ <shape id="card" type="rect" topLeftX="60" topLeftY="140" width="220" height="184"/>
3067
+ </data>
3068
+ </slide>
3069
+ """
3070
+ )
3071
+
3072
+ issue = next(
3073
+ issue for issue in result["slides"][0]["issues"] if issue["code"] == "sparse_container_content"
3074
+ )
3075
+ self.assertEqual(issue["schema_version"], "2.0")
3076
+
3077
+ def test_lint_xml_does_not_report_blank_slide_for_textless_decorative_shapes(self) -> None:
3078
+ result = xml_text_overlap_lint.lint_xml(
3079
+ """
3080
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
3081
+ <data>
3082
+ <shape id="deco1" type="ellipse" topLeftX="60" topLeftY="60" width="300" height="300">
3083
+ <fill><fillColor color="rgba(37, 99, 235, 1)"/></fill>
3084
+ </shape>
3085
+ <shape id="deco2" type="triangle" topLeftX="500" topLeftY="200" width="200" height="200">
3086
+ <fill><fillColor color="rgba(220, 38, 38, 1)"/></fill>
3087
+ </shape>
3088
+ </data>
3089
+ </slide>
3090
+ """
3091
+ )
3092
+
3093
+ self.assertEqual(result["summary"]["error_count"], 0)
3094
+ codes = [issue["code"] for issue in result["slides"][0]["issues"]]
3095
+ self.assertNotIn("blank_slide", codes)
3096
+
3097
+ def test_lint_xml_still_warns_for_sparse_slide_content_despite_full_bleed_background(self) -> None:
3098
+ # A plain textless shape now counts as "not blank" (see the test above), but a
3099
+ # full-bleed background rect must still NOT count toward sparse_slide_content's
3100
+ # meaningful-content coverage ratio -- otherwise every slide with a background would
3101
+ # trivially "pass" that density check.
3102
+ result = xml_text_overlap_lint.lint_xml(
3103
+ """
3104
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
3105
+ <data>
3106
+ <shape id="background" type="rect" topLeftX="0" topLeftY="0" width="960" height="540"/>
3107
+ <shape id="text-1" type="text" topLeftX="60" topLeftY="80" width="200" height="30">
3108
+ <content fontSize="14"><p>One short line</p></content>
3109
+ </shape>
3110
+ <shape id="text-2" type="text" topLeftX="500" topLeftY="180" width="200" height="30">
3111
+ <content fontSize="14"><p>Another line</p></content>
3112
+ </shape>
3113
+ <shape id="text-3" type="text" topLeftX="60" topLeftY="310" width="200" height="30">
3114
+ <content fontSize="14"><p>Third line</p></content>
3115
+ </shape>
3116
+ <shape id="text-4" type="text" topLeftX="500" topLeftY="410" width="200" height="30">
3117
+ <content fontSize="14"><p>Fourth line</p></content>
3118
+ </shape>
3119
+ </data>
3120
+ </slide>
3121
+ """
3122
+ )
3123
+
3124
+ codes = [issue["code"] for issue in result["slides"][0]["issues"]]
3125
+ self.assertIn("sparse_slide_content", codes)
3126
+
3127
+ def test_lint_xml_accepts_whitespace_around_attribute_equals_sign(self) -> None:
3128
+ result = xml_text_overlap_lint.lint_xml(
3129
+ """
3130
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
3131
+ <data>
3132
+ <shape id="visible" type="text" topLeftX = "80" topLeftY = "80" width = "300" height = "60">
3133
+ <content><p>hello</p></content>
3134
+ </shape>
3135
+ </data>
3136
+ </slide>
3137
+ """
3138
+ )
3139
+
3140
+ self.assertEqual(result["slides"][0]["element_count"], 1)
3141
+ codes = [issue["code"] for issue in result["slides"][0]["issues"]]
3142
+ self.assertNotIn("blank_slide", codes)
3143
+
3144
+ def test_lint_xml_reports_blank_slide_for_full_canvas_background_only(self) -> None:
3145
+ result = xml_text_overlap_lint.lint_xml(
3146
+ """
3147
+ <slide xmlns="http://www.larkoffice.com/sml/2.0">
3148
+ <data>
3149
+ <shape id="background" type="rect" topLeftX="0" topLeftY="0" width="960" height="540">
3150
+ <fill><fillColor color="rgba(240, 235, 220, 1)"/></fill>
3151
+ </shape>
3152
+ </data>
3153
+ </slide>
3154
+ """
3155
+ )
3156
+
3157
+ codes = [issue["code"] for issue in result["slides"][0]["issues"]]
3158
+ self.assertIn("blank_slide", codes)
3159
+
3160
+ def test_has_similar_short_card_peer_ignores_invisible_peers(self) -> None:
3161
+ visible_card = {"kind": "shape", "type": "rect", "x": 0, "y": 0, "width": 300, "height": 100}
3162
+ ghost_1 = {
3163
+ "kind": "shape", "type": "rect", "x": 400, "y": 0, "width": 300, "height": 100, "alpha": 0,
3164
+ }
3165
+ ghost_2 = {
3166
+ "kind": "shape", "type": "rect", "x": 800, "y": 0, "width": 300, "height": 100, "alpha": 0,
3167
+ }
3168
+
3169
+ self.assertFalse(
3170
+ xml_text_overlap_lint.has_similar_short_card_peer(
3171
+ visible_card, [visible_card, ghost_1, ghost_2]
3172
+ )
3173
+ )
3174
+
3175
+
3176
+ SML_NAMESPACE = "http://www.larkoffice.com/sml/2.0"
3177
+
3178
+
3179
+ class SxsdSyntaxTestCase(unittest.TestCase):
3180
+ def validate(self, xml: str) -> list[dict[str, object]]:
3181
+ result = xml_text_overlap_lint.lint_xml(xml)
3182
+ return [
3183
+ *result.get("issues", []),
3184
+ *(issue for slide in result["slides"] for issue in slide["issues"]),
3185
+ ]
3186
+
3187
+ def assert_issue(
3188
+ self,
3189
+ issues: list[dict[str, object]],
3190
+ code: str,
3191
+ *,
3192
+ path: str | None = None,
3193
+ attr: str | None = None,
3194
+ ) -> dict[str, object]:
3195
+ for issue in issues:
3196
+ if issue.get("code") != code:
3197
+ continue
3198
+ if path is not None and issue.get("path") != path:
3199
+ continue
3200
+ if attr is not None and issue.get("attr") != attr:
3201
+ continue
3202
+ return issue
3203
+ self.fail(f"missing issue code={code!r} path={path!r} attr={attr!r}: {issues!r}")
3204
+
3205
+ def assert_no_issue(self, issues: list[dict[str, object]], code: str) -> None:
3206
+ self.assertNotIn(code, [issue.get("code") for issue in issues])
3207
+
3208
+
3209
+ class SxsdSyntaxAttributeTest(SxsdSyntaxTestCase):
3210
+
3211
+ def test_xsd_pattern_translation_only_expands_whitespace_classes(self) -> None:
3212
+ self.assertEqual(
3213
+ sxsd_validator.python_pattern_for_xsd(r"\s+\S+\w+\d+"),
3214
+ "[ \\t\\n\\r]+[^ \\t\\n\\r]+\\w+\\d+",
3215
+ )
3216
+
3217
+ def test_href_domain_pattern_does_not_use_backtracking_regex(self) -> None:
3218
+ pattern = r"[\w.-]+[.:]\S*"
3219
+ adversarial_value = ("a." * 20_000) + " "
3220
+ original_fullmatch = sxsd_validator.re.fullmatch
3221
+ translated_pattern = sxsd_validator.python_pattern_for_xsd(pattern)
3222
+
3223
+ def reject_unsafe_pattern(candidate: str, value: str):
3224
+ if candidate == translated_pattern:
3225
+ raise AssertionError("href domain pattern must not use re.fullmatch")
3226
+ return original_fullmatch(candidate, value)
3227
+
3228
+ with mock.patch.object(sxsd_validator.re, "fullmatch", side_effect=reject_unsafe_pattern):
3229
+ self.assertFalse(sxsd_validator.xsd_pattern_matches(pattern, adversarial_value))
3230
+
3231
+ def test_href_domain_pattern_keeps_xsd_matching_behavior(self) -> None:
3232
+ pattern = r"[\w.-]+[.:]\S*"
3233
+ reference_pattern = sxsd_validator.re.compile(
3234
+ sxsd_validator.python_pattern_for_xsd(pattern)
3235
+ )
3236
+
3237
+ for length in range(5):
3238
+ for characters in itertools.product("a.:-/ ©", repeat=length):
3239
+ value = "".join(characters)
3240
+ with self.subTest(value=value):
3241
+ self.assertEqual(
3242
+ sxsd_validator.xsd_pattern_matches(pattern, value),
3243
+ reference_pattern.fullmatch(value) is not None,
3244
+ )
3245
+
3246
+ def test_accepts_valid_shape_attributes(self) -> None:
3247
+ issues = self.validate(
3248
+ f"""
3249
+ <slide xmlns="{SML_NAMESPACE}">
3250
+ <data>
3251
+ <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
3252
+ <content textType="body"><p>Valid</p></content>
3253
+ </shape>
3254
+ </data>
3255
+ </slide>
3256
+ """
3257
+ )
3258
+
3259
+ self.assertEqual(issues, [])
3260
+
3261
+ def test_reports_missing_required_shape_attribute(self) -> None:
3262
+ issues = self.validate(
3263
+ f"""
3264
+ <slide xmlns="{SML_NAMESPACE}">
3265
+ <data><shape type="text" topLeftX="10" topLeftY="20" width="300"/></data>
3266
+ </slide>
3267
+ """
3268
+ )
3269
+
3270
+ issue = self.assert_issue(
3271
+ issues,
3272
+ "sxsd_missing_required_attr",
3273
+ path="slide/data/shape",
3274
+ attr="height",
3275
+ )
3276
+ self.assertEqual(issue["expected"], "required attribute of type PositiveSize")
3277
+ self.assertIsNone(issue["actual"])
3278
+
3279
+ def test_reports_invalid_scalar_value(self) -> None:
3280
+ issues = self.validate(
3281
+ f"""
3282
+ <slide xmlns="{SML_NAMESPACE}">
3283
+ <data>
3284
+ <shape type="text" topLeftX="NaN" topLeftY="20" width="300" height="80"/>
3285
+ </data>
3286
+ </slide>
3287
+ """
3288
+ )
3289
+
3290
+ issue = self.assert_issue(issues, "sxsd_invalid_scalar", attr="topLeftX")
3291
+ self.assertEqual(issue["actual"], "NaN")
3292
+
3293
+ def test_rejects_python_only_numeric_separator(self) -> None:
3294
+ issues = self.validate(
3295
+ f"""
3296
+ <slide xmlns="{SML_NAMESPACE}">
3297
+ <data>
3298
+ <shape type="rect" topLeftX="1_0" topLeftY="20" width="300" height="80"/>
3299
+ </data>
3300
+ </slide>
3301
+ """
3302
+ )
3303
+
3304
+ self.assert_issue(issues, "sxsd_invalid_scalar", attr="topLeftX")
3305
+
3306
+ def test_accepts_xsd_double_lexical_forms(self) -> None:
3307
+ for top_left_x in ("10", "-0.5", ".5", "1.", "1e2"):
3308
+ with self.subTest(top_left_x=top_left_x):
3309
+ issues = self.validate(
3310
+ f"""
3311
+ <slide xmlns="{SML_NAMESPACE}">
3312
+ <data>
3313
+ <shape type="rect" topLeftX="{top_left_x}" topLeftY="20" width="300" height="80"/>
3314
+ </data>
3315
+ </slide>
3316
+ """
3317
+ )
3318
+
3319
+ self.assertEqual(issues, [])
3320
+
3321
+ def test_accepts_bullet_char_length_boundaries(self) -> None:
3322
+ for bullet_char in ("A", "12345678"):
3323
+ with self.subTest(bullet_char=bullet_char):
3324
+ issues = self.validate(
3325
+ f"""
3326
+ <slide xmlns="{SML_NAMESPACE}">
3327
+ <data>
3328
+ <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
3329
+ <content bulletChar="{bullet_char}"><p>Text</p></content>
3330
+ </shape>
3331
+ </data>
3332
+ </slide>
3333
+ """
3334
+ )
3335
+
3336
+ self.assertEqual(issues, [])
3337
+
3338
+ def test_rejects_bullet_char_outside_length_boundaries(self) -> None:
3339
+ for bullet_char in ("", "123456789"):
3340
+ with self.subTest(bullet_char=bullet_char):
3341
+ issues = self.validate(
3342
+ f"""
3343
+ <slide xmlns="{SML_NAMESPACE}">
3344
+ <data>
3345
+ <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
3346
+ <content bulletChar="{bullet_char}"><p>Text</p></content>
3347
+ </shape>
3348
+ </data>
3349
+ </slide>
3350
+ """
3351
+ )
3352
+
3353
+ self.assert_issue(issues, "sxsd_value_out_of_range", attr="bulletChar")
3354
+
3355
+ def test_rejects_zero_size_that_violates_xsd(self) -> None:
3356
+ issues = self.validate(
3357
+ f"""
3358
+ <slide xmlns="{SML_NAMESPACE}">
3359
+ <data>
3360
+ <shape type="text" topLeftX="10" topLeftY="20" width="0" height="80"/>
3361
+ </data>
3362
+ </slide>
3363
+ """
3364
+ )
3365
+
3366
+ self.assert_issue(issues, "sxsd_value_out_of_range", attr="width")
3367
+
3368
+ def test_reports_negative_size_rejected_by_xsd(self) -> None:
3369
+ issues = self.validate(
3370
+ f"""
3371
+ <slide xmlns="{SML_NAMESPACE}">
3372
+ <data>
3373
+ <shape type="text" topLeftX="10" topLeftY="20" width="-1" height="80"/>
3374
+ </data>
3375
+ </slide>
3376
+ """
3377
+ )
3378
+
3379
+ self.assert_issue(issues, "sxsd_value_out_of_range", attr="width")
3380
+
3381
+ def test_rejects_shape_enum_that_violates_xsd(self) -> None:
3382
+ issues = self.validate(
3383
+ f"""
3384
+ <slide xmlns="{SML_NAMESPACE}">
3385
+ <data>
3386
+ <shape type="not-a-shape" topLeftX="10" topLeftY="20" width="300" height="80"/>
3387
+ </data>
3388
+ </slide>
3389
+ """
3390
+ )
3391
+
3392
+ issue = self.assert_issue(issues, "sxsd_invalid_enum", attr="type")
3393
+ self.assertLess(len(str(issue["message"])), 300)
3394
+ self.assertEqual(issue["actual"], "not-a-shape")
3395
+
3396
+ def test_rejects_rotation_upper_bound_that_violates_xsd(self) -> None:
3397
+ issues = self.validate(
3398
+ f"""
3399
+ <slide xmlns="{SML_NAMESPACE}">
3400
+ <data>
3401
+ <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80" rotation="360"/>
3402
+ </data>
3403
+ </slide>
3404
+ """
3405
+ )
3406
+
3407
+ self.assert_issue(issues, "sxsd_value_out_of_range", attr="rotation")
3408
+
3409
+ def test_rejects_fill_color_that_violates_xsd(self) -> None:
3410
+ issues = self.validate(
3411
+ f"""
3412
+ <slide xmlns="{SML_NAMESPACE}">
3413
+ <style><fill><fillColor color="red"/></fill></style>
3414
+ <data/>
3415
+ </slide>
3416
+ """
3417
+ )
3418
+
3419
+ self.assert_issue(issues, "sxsd_pattern_mismatch", attr="color")
3420
+
3421
+ def test_reports_missing_required_image_src(self) -> None:
3422
+ issues = self.validate(
3423
+ f"""
3424
+ <slide xmlns="{SML_NAMESPACE}">
3425
+ <data><img topLeftX="10" topLeftY="20" width="300" height="80"/></data>
3426
+ </slide>
3427
+ """
3428
+ )
3429
+
3430
+ self.assert_issue(issues, "sxsd_missing_required_attr", attr="src")
3431
+
3432
+ def test_accepts_inline_attribute_simple_type(self) -> None:
3433
+ issues = self.validate(
3434
+ f"""
3435
+ <slide xmlns="{SML_NAMESPACE}">
3436
+ <data>
3437
+ <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
3438
+ <content><p><a href="https://example.com">Link</a></p></content>
3439
+ </shape>
3440
+ </data>
3441
+ </slide>
3442
+ """
3443
+ )
3444
+
3445
+ self.assertEqual(issues, [])
3446
+
3447
+ def test_reports_inline_attribute_pattern_mismatch(self) -> None:
3448
+ issues = self.validate(
3449
+ f"""
3450
+ <slide xmlns="{SML_NAMESPACE}">
3451
+ <data>
3452
+ <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
3453
+ <content><p><a href="not a uri">Link</a></p></content>
3454
+ </shape>
3455
+ </data>
3456
+ </slide>
3457
+ """
3458
+ )
3459
+
3460
+ self.assert_issue(issues, "sxsd_pattern_mismatch", attr="href")
3461
+
3462
+ def test_accepts_values_matching_inline_union_members(self) -> None:
3463
+ for bullet_size in ("25%", "100%", "400%", "6", "14", "400"):
3464
+ with self.subTest(bullet_size=bullet_size):
3465
+ issues = self.validate(
3466
+ f"""
3467
+ <slide xmlns="{SML_NAMESPACE}">
3468
+ <data>
3469
+ <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
3470
+ <content bulletSize="{bullet_size}"><p>Text</p></content>
3471
+ </shape>
3472
+ </data>
3473
+ </slide>
3474
+ """
3475
+ )
3476
+
3477
+ self.assertEqual(issues, [])
3478
+
3479
+ def test_rejects_values_outside_inline_union_members(self) -> None:
3480
+ for bullet_size in ("24%", "401%", "5", "401", "abc"):
3481
+ with self.subTest(bullet_size=bullet_size):
3482
+ issues = self.validate(
3483
+ f"""
3484
+ <slide xmlns="{SML_NAMESPACE}">
3485
+ <data>
3486
+ <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
3487
+ <content bulletSize="{bullet_size}"><p>Text</p></content>
3488
+ </shape>
3489
+ </data>
3490
+ </slide>
3491
+ """
3492
+ )
3493
+
3494
+ self.assert_issue(issues, "sxsd_pattern_mismatch", attr="bulletSize")
3495
+
3496
+ def test_rejects_symbol_outside_python_word_semantics_in_href(self) -> None:
3497
+ issues = self.validate(
3498
+ f"""
3499
+ <slide xmlns="{SML_NAMESPACE}">
3500
+ <data>
3501
+ <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
3502
+ <content><p><a href="©:resource">Link</a></p></content>
3503
+ </shape>
3504
+ </data>
3505
+ </slide>
3506
+ """
3507
+ )
3508
+
3509
+ self.assert_issue(issues, "sxsd_pattern_mismatch", attr="href")
3510
+
3511
+ def test_accepts_common_email_href_with_python_regex_semantics(self) -> None:
3512
+ issues = self.validate(
3513
+ f"""
3514
+ <slide xmlns="{SML_NAMESPACE}">
3515
+ <data>
3516
+ <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
3517
+ <content><p><a href="mailto:user@example.com">Email</a></p></content>
3518
+ </shape>
3519
+ </data>
3520
+ </slide>
3521
+ """
3522
+ )
3523
+
3524
+ self.assertEqual(issues, [])
3525
+
3526
+ def test_accepts_common_gradient_with_python_regex_semantics(self) -> None:
3527
+ issues = self.validate(
3528
+ f"""
3529
+ <slide xmlns="{SML_NAMESPACE}">
3530
+ <style>
3531
+ <fill>
3532
+ <fillColor color="linear-gradient(90deg, rgb(255, 0, 0) 0%, rgb(0, 0, 255) 100%)"/>
3533
+ </fill>
3534
+ </style>
3535
+ <data>
3536
+ <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
3537
+ <content><p>Gradient</p></content>
3538
+ </shape>
3539
+ </data>
3540
+ </slide>
3541
+ """
3542
+ )
3543
+
3544
+ self.assertEqual(issues, [])
3545
+
3546
+ def test_rejects_non_xsd_whitespace_in_color_pattern(self) -> None:
3547
+ issues = self.validate(
3548
+ f"""
3549
+ <slide xmlns="{SML_NAMESPACE}">
3550
+ <style><fill><fillColor color="rgb(1,\u00a02,3)"/></fill></style>
3551
+ <data/>
3552
+ </slide>
3553
+ """
3554
+ )
3555
+
3556
+ self.assert_issue(issues, "sxsd_pattern_mismatch", attr="color")
3557
+
3558
+ class SxsdSyntaxStructureTest(SxsdSyntaxTestCase):
3559
+ def test_accepts_nested_content_in_referenced_rich_text_shadow(self) -> None:
3560
+ issues = self.validate(
3561
+ f"""
3562
+ <slide xmlns="{SML_NAMESPACE}">
3563
+ <data>
3564
+ <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">
3565
+ <content>
3566
+ <p><span><shadow color="rgba(0, 0, 0, 1)"><strong>Text</strong></shadow></span></p>
3567
+ </content>
3568
+ </shape>
3569
+ </data>
3570
+ </slide>
3571
+ """
3572
+ )
3573
+
3574
+ self.assertEqual(issues, [])
3575
+
3576
+ def test_keeps_shape_effect_shadow_as_childless_local_type(self) -> None:
3577
+ issues = self.validate(
3578
+ f"""
3579
+ <slide xmlns="{SML_NAMESPACE}">
3580
+ <data>
3581
+ <shape type="rect" topLeftX="10" topLeftY="20" width="300" height="80">
3582
+ <shadow><strong>Not rich text</strong></shadow>
3583
+ </shape>
3584
+ </data>
3585
+ </slide>
3586
+ """
3587
+ )
3588
+
3589
+ self.assert_issue(
3590
+ issues,
3591
+ "sxsd_unexpected_child",
3592
+ path="slide/data/shape/shadow/strong",
3593
+ )
3594
+
3595
+ def test_accepts_standalone_slide_fragment_without_namespace(self) -> None:
3596
+ issues = self.validate(
3597
+ '<slide><data><shape type="text" topLeftX="10" topLeftY="20" width="300" height="80">'
3598
+ '<content><p>Text</p></content></shape></data></slide>'
3599
+ )
3600
+
3601
+ self.assertEqual(issues, [])
3602
+
3603
+ def test_rejects_presentation_without_namespace(self) -> None:
3604
+ issues = self.validate(
3605
+ '<presentation width="960" height="540"><slide/></presentation>'
3606
+ )
3607
+
3608
+ self.assert_issue(issues, "sxsd_invalid_namespace", path="presentation")
3609
+
3610
+ def test_rejects_wrong_namespace_that_violates_xsd(self) -> None:
3611
+ issues = self.validate('<slide xmlns="https://example.com/not-sml"><data/></slide>')
3612
+
3613
+ self.assert_issue(issues, "sxsd_invalid_namespace", path="slide")
3614
+
3615
+ def test_rejects_descendant_outside_document_namespace(self) -> None:
3616
+ issues = self.validate(
3617
+ f"""
3618
+ <slide xmlns="{SML_NAMESPACE}">
3619
+ <data xmlns="">
3620
+ <shape xmlns="{SML_NAMESPACE}" type="rect" topLeftX="10" topLeftY="20" width="300" height="80"/>
3621
+ </data>
3622
+ </slide>
3623
+ """
3624
+ )
3625
+
3626
+ self.assert_issue(issues, "sxsd_invalid_namespace", path="slide/data")
3627
+
3628
+ def test_rejects_unexpected_child_that_violates_xsd(self) -> None:
3629
+ issues = self.validate(
3630
+ f"""
3631
+ <slide xmlns="{SML_NAMESPACE}">
3632
+ <shape type="text" topLeftX="10" topLeftY="20" width="300" height="80"/>
3633
+ </slide>
3634
+ """
3635
+ )
3636
+
3637
+ self.assert_issue(issues, "sxsd_unexpected_child", path="slide/shape")
3638
+
3639
+ def test_rejects_child_order_that_violates_xsd(self) -> None:
3640
+ issues = self.validate(
3641
+ f"""
3642
+ <presentation xmlns="{SML_NAMESPACE}" width="1920" height="1080">
3643
+ <slide/>
3644
+ <title>Late title</title>
3645
+ </presentation>
3646
+ """
3647
+ )
3648
+
3649
+ self.assert_issue(issues, "sxsd_invalid_child_order", path="presentation/title")
3650
+
3651
+ def test_enforces_presentation_slide_minimum_from_xsd(self) -> None:
3652
+ issues = self.validate(
3653
+ f'<presentation xmlns="{SML_NAMESPACE}" width="1920" height="1080"/>'
3654
+ )
3655
+
3656
+ self.assert_issue(issues, "sxsd_missing_required_child", path="presentation")
3657
+
3658
+ def test_enforces_presentation_slide_maximum_from_xsd(self) -> None:
3659
+ slides = "".join("<slide/>" for _ in range(101))
3660
+ issues = self.validate(
3661
+ f'<presentation xmlns="{SML_NAMESPACE}" width="1920" height="1080">{slides}</presentation>'
3662
+ )
3663
+
3664
+ self.assert_issue(issues, "sxsd_too_many_children", path="presentation/slide")
3665
+
3666
+ def test_rejects_multiple_choice_children_that_violate_xsd(self) -> None:
3667
+ issues = self.validate(
3668
+ f"""
3669
+ <slide xmlns="{SML_NAMESPACE}">
3670
+ <style>
3671
+ <fill><fillColor/><fillImg src="token"/></fill>
3672
+ </style>
3673
+ </slide>
3674
+ """
3675
+ )
3676
+
3677
+ self.assert_issue(issues, "sxsd_too_many_children", path="slide/style/fill")
3678
+
3679
+ def test_rejects_line_without_required_border_from_xsd(self) -> None:
3680
+ issues = self.validate(
3681
+ f"""
3682
+ <slide xmlns="{SML_NAMESPACE}">
3683
+ <data><line startX="0" startY="0" endX="100" endY="100"/></data>
3684
+ </slide>
3685
+ """
3686
+ )
3687
+
3688
+ self.assert_issue(issues, "sxsd_missing_required_child", path="slide/data/line")
3689
+
3690
+ def test_reports_missing_required_chart_structure(self) -> None:
3691
+ issues = self.validate(
3692
+ f"""
3693
+ <slide xmlns="{SML_NAMESPACE}">
3694
+ <data><chart topLeftX="0" topLeftY="0" width="300" height="200"/></data>
3695
+ </slide>
3696
+ """
3697
+ )
3698
+
3699
+ self.assert_issue(issues, "sxsd_missing_required_child", path="slide/data/chart")
3700
+
3701
+ def test_reports_missing_required_nested_sequence_child(self) -> None:
3702
+ issues = self.validate(
3703
+ f"""
3704
+ <slide xmlns="{SML_NAMESPACE}">
3705
+ <data><table topLeftX="0" topLeftY="0"><tr/></table></data>
3706
+ </slide>
3707
+ """
3708
+ )
3709
+
3710
+ issue = self.assert_issue(issues, "sxsd_missing_required_child", path="slide/data/table/tr")
3711
+ self.assertEqual(issue["expected"], "td (at least 1)")
3712
+
3713
+
3714
+ class SxsdSchemaModelTest(unittest.TestCase):
3715
+ def test_reports_unsupported_xsd_pattern_without_crashing(self) -> None:
3716
+ schema = rf"""
3717
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
3718
+ xmlns:sml="{SML_NAMESPACE}"
3719
+ targetNamespace="{SML_NAMESPACE}"
3720
+ elementFormDefault="qualified">
3721
+ <xs:simpleType name="UnsupportedPatternType">
3722
+ <xs:union>
3723
+ <xs:simpleType>
3724
+ <xs:restriction base="xs:string"><xs:pattern value="[\S]"/></xs:restriction>
3725
+ </xs:simpleType>
3726
+ <xs:simpleType>
3727
+ <xs:restriction base="xs:string"><xs:pattern value="z+"/></xs:restriction>
3728
+ </xs:simpleType>
3729
+ </xs:union>
3730
+ </xs:simpleType>
3731
+ <xs:complexType name="SlideType">
3732
+ <xs:attribute name="value" type="sml:UnsupportedPatternType"/>
3733
+ </xs:complexType>
3734
+ </xs:schema>
3735
+ """
3736
+ with tempfile.TemporaryDirectory() as temp_dir:
3737
+ schema_path = Path(temp_dir) / "schema.xsd"
3738
+ schema_path.write_text(schema, encoding="utf-8")
3739
+ try:
3740
+ issues = sxsd_validator.validate_sxsd(
3741
+ ET.fromstring(f'<slide xmlns="{SML_NAMESPACE}" value="A"/>'),
3742
+ schema_path,
3743
+ )
3744
+ except (ValueError, sxsd_validator.re.error) as error:
3745
+ self.fail(f"SXSD pattern capability errors must be reported, not raised: {error}")
3746
+
3747
+ self.assertEqual([issue["code"] for issue in issues], ["sxsd_unsupported_pattern"])
3748
+ self.assertEqual(issues[0]["attr"], "value")
3749
+ self.assertIn("pattern interpreter", str(issues[0]["hint"]).lower())
3750
+
3751
+ def test_standalone_slide_uses_slide_type_without_global_element(self) -> None:
3752
+ schema = f"""
3753
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
3754
+ xmlns:sml="{SML_NAMESPACE}"
3755
+ targetNamespace="{SML_NAMESPACE}"
3756
+ elementFormDefault="qualified">
3757
+ <xs:complexType name="SlideType"><xs:sequence/></xs:complexType>
3758
+ <xs:complexType name="PresentationType">
3759
+ <xs:sequence><xs:element name="slide" type="sml:SlideType"/></xs:sequence>
3760
+ </xs:complexType>
3761
+ <xs:element name="presentation" type="sml:PresentationType"/>
3762
+ </xs:schema>
3763
+ """
3764
+ with tempfile.TemporaryDirectory() as temp_dir:
3765
+ schema_path = Path(temp_dir) / "schema.xsd"
3766
+ schema_path.write_text(schema, encoding="utf-8")
3767
+ issues = sxsd_validator.validate_sxsd(
3768
+ ET.fromstring(f'<slide xmlns="{SML_NAMESPACE}"/>'),
3769
+ schema_path,
3770
+ )
3771
+
3772
+ self.assertEqual(issues, [])
3773
+
3774
+ def test_standalone_slide_requires_slide_type_in_xsd(self) -> None:
3775
+ schema = f"""
3776
+ <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"
3777
+ xmlns:sml="{SML_NAMESPACE}"
3778
+ targetNamespace="{SML_NAMESPACE}"
3779
+ elementFormDefault="qualified">
3780
+ <xs:complexType name="PresentationType"><xs:sequence/></xs:complexType>
3781
+ <xs:element name="presentation" type="sml:PresentationType"/>
3782
+ </xs:schema>
3783
+ """
3784
+ with tempfile.TemporaryDirectory() as temp_dir:
3785
+ schema_path = Path(temp_dir) / "schema.xsd"
3786
+ schema_path.write_text(schema, encoding="utf-8")
3787
+ issues = sxsd_validator.validate_sxsd(
3788
+ ET.fromstring(f'<slide xmlns="{SML_NAMESPACE}"/>'),
3789
+ schema_path,
3790
+ )
3791
+
3792
+ self.assertEqual([issue["code"] for issue in issues], ["sxsd_unexpected_root"])
296
3793
 
297
3794
 
298
3795
  if __name__ == "__main__":