@amaster.ai/pi-lark 0.1.2-beta.44 → 0.1.2-beta.46

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 (130) hide show
  1. package/package.json +2 -2
  2. package/skills/lark-apps/SKILL.md +23 -12
  3. package/skills/lark-apps/creative-design/agents/assets/vision-probe.png +0 -0
  4. package/skills/lark-apps/creative-design/agents/fork-verifier-agent.md +71 -0
  5. package/skills/lark-apps/creative-design/agents/vision-probe-agent.md +41 -0
  6. package/skills/lark-apps/creative-design/assets/index.html +27 -0
  7. package/skills/lark-apps/creative-design/creative-design.md +239 -0
  8. package/skills/lark-apps/creative-design/references/aily.md +39 -0
  9. package/skills/lark-apps/creative-design/references/animated-video.md +34 -0
  10. package/skills/lark-apps/creative-design/references/charts.md +165 -0
  11. package/skills/lark-apps/creative-design/references/claude.md +36 -0
  12. package/skills/lark-apps/creative-design/references/codex.md +32 -0
  13. package/skills/lark-apps/creative-design/references/data-report.md +108 -0
  14. package/skills/lark-apps/creative-design/references/frontend-design.md +71 -0
  15. package/skills/lark-apps/creative-design/references/hi-fi-design.md +32 -0
  16. package/skills/lark-apps/creative-design/references/interactive-prototype.md +24 -0
  17. package/skills/lark-apps/creative-design/references/make-a-deck.md +133 -0
  18. package/skills/lark-apps/creative-design/references/visual-exposure.md +82 -0
  19. package/skills/lark-apps/creative-design/references/wireframe.md +14 -0
  20. package/skills/lark-apps/creative-design/starter-components/android-frame.jsx +188 -0
  21. package/skills/lark-apps/creative-design/starter-components/animations.jsx +773 -0
  22. package/skills/lark-apps/creative-design/starter-components/browser-window.jsx +122 -0
  23. package/skills/lark-apps/creative-design/starter-components/deck-stage.js +2483 -0
  24. package/skills/lark-apps/creative-design/starter-components/design-canvas.jsx +1432 -0
  25. package/skills/lark-apps/creative-design/starter-components/ios-frame.jsx +270 -0
  26. package/skills/lark-apps/creative-design/starter-components/macos-window.jsx +197 -0
  27. package/skills/lark-apps/creative-design/starter-components/tweaks-panel.jsx +752 -0
  28. package/skills/lark-apps/references/lark-apps-automation.md +80 -2
  29. package/skills/lark-apps/references/lark-apps-cloud-dev.md +0 -1
  30. package/skills/lark-apps/references/lark-apps-create.md +1 -2
  31. package/skills/lark-apps/references/lark-apps-db.md +1 -1
  32. package/skills/lark-apps/references/lark-apps-env-pull.md +1 -1
  33. package/skills/lark-apps/references/lark-apps-file.md +2 -2
  34. package/skills/lark-apps/references/lark-apps-git-credential.md +1 -1
  35. package/skills/lark-apps/references/lark-apps-html-publish.md +4 -8
  36. package/skills/lark-apps/references/lark-apps-init.md +1 -1
  37. package/skills/lark-apps/references/lark-apps-list.md +1 -1
  38. package/skills/lark-apps/references/lark-apps-local-dev.md +54 -11
  39. package/skills/lark-apps/references/lark-apps-openapi-key.md +1 -1
  40. package/skills/lark-apps/references/lark-apps-release-create.md +2 -2
  41. package/skills/lark-apps/references/lark-apps-release-get.md +3 -3
  42. package/skills/lark-base/SKILL.md +4 -6
  43. package/skills/lark-base/references/lark-base-cell-value.md +3 -3
  44. package/skills/lark-base/references/lark-base-field-create.md +4 -0
  45. package/skills/lark-base/references/lark-base-field-json.md +4 -4
  46. package/skills/lark-base/references/lark-base-field-update.md +17 -1
  47. package/skills/lark-base/references/lark-base-form-submit.md +16 -7
  48. package/skills/lark-base/references/lark-base-record-batch-create.md +12 -10
  49. package/skills/lark-base/references/lark-base-record-batch-update.md +11 -9
  50. package/skills/lark-base/references/lark-base-record-upsert.md +1 -1
  51. package/skills/lark-calendar/references/lark-calendar-create.md +1 -0
  52. package/skills/lark-calendar/references/lark-calendar-update.md +3 -0
  53. package/skills/lark-doc/references/lark-doc-fetch.md +10 -2
  54. package/skills/lark-doc/references/lark-doc-whiteboard.md +9 -8
  55. package/skills/lark-doc/references/lark-doc-xml-extended-blocks.md +41 -0
  56. package/skills/lark-doc/references/lark-doc-xml.md +4 -3
  57. package/skills/lark-drive/SKILL.md +4 -1
  58. package/skills/lark-drive/references/lark-drive-comment-location.md +2 -2
  59. package/skills/lark-drive/references/lark-drive-search.md +1 -0
  60. package/skills/lark-drive/references/lark-drive-upload.md +1 -0
  61. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-execute.md +273 -0
  62. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-recall.md +202 -0
  63. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-resolve-verify.md +231 -0
  64. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-review-plan.md +248 -0
  65. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-setup.md +174 -0
  66. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector.md +202 -0
  67. package/skills/lark-drive/references/lark-drive-workflow.md +5 -4
  68. package/skills/lark-event/SKILL.md +1 -0
  69. package/skills/lark-event/references/lark-event-application.md +38 -0
  70. package/skills/lark-im/SKILL.md +1 -1
  71. package/skills/lark-im/references/card/card-2.0-schema.md +1 -1
  72. package/skills/lark-im/references/card/lark-im-card-style.md +4 -4
  73. package/skills/lark-im/references/card/resource/icons.md +14 -0
  74. package/skills/lark-im/references/lark-im-flag-list.md +8 -7
  75. package/skills/lark-okr/SKILL.md +71 -26
  76. package/skills/lark-okr/references/lark-okr-batch-create.md +19 -18
  77. package/skills/lark-okr/references/lark-okr-create.md +173 -0
  78. package/skills/lark-okr/references/lark-okr-cycle-list.md +17 -7
  79. package/skills/lark-okr/references/lark-okr-entities.md +1 -0
  80. package/skills/lark-okr/references/lark-okr-indicator-update.md +3 -1
  81. package/skills/lark-okr/references/lark-okr-indicators.md +61 -12
  82. package/skills/lark-okr/references/lark-okr-progress-list.md +21 -9
  83. package/skills/lark-slides/SKILL.md +104 -47
  84. package/skills/lark-slides/references/asset-planning.md +6 -4
  85. package/skills/lark-slides/references/iconpark.md +2 -2
  86. package/skills/lark-slides/references/lark-slides-create.md +2 -3
  87. package/skills/lark-slides/references/lark-slides-history.md +132 -0
  88. package/skills/lark-slides/references/lark-slides-media-upload.md +1 -2
  89. package/skills/lark-slides/references/lark-slides-pptx-template-workflows.md +7 -11
  90. package/skills/lark-slides/references/lark-slides-replace-slide.md +0 -3
  91. package/skills/lark-slides/references/lark-slides-screenshot.md +4 -4
  92. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-create.md +219 -0
  93. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-delete.md +6 -5
  94. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-get.md +2 -2
  95. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-replace.md +2 -3
  96. package/skills/lark-slides/references/lark-slides-xml-presentations-get.md +65 -30
  97. package/skills/lark-slides/references/planning-layer.md +11 -10
  98. package/skills/lark-slides/references/slides_chart_demo.xml +1416 -1
  99. package/skills/lark-slides/references/slides_xml_schema_definition.xml +1 -45
  100. package/skills/lark-slides/references/troubleshooting.md +25 -7
  101. package/skills/lark-slides/references/validation-checklist.md +53 -16
  102. package/skills/lark-slides/references/visual-planning.md +25 -22
  103. package/skills/lark-slides/references/xml-schema-quick-ref.md +243 -46
  104. package/skills/lark-slides/scripts/xml_text_overlap_lint.py +1054 -78
  105. package/skills/lark-slides/scripts/xml_text_overlap_lint_test.py +957 -150
  106. package/skills/lark-task/SKILL.md +7 -0
  107. package/skills/lark-task/references/lark-task-complete.md +6 -2
  108. package/skills/lark-task/references/lark-task-update.md +6 -2
  109. package/skills/lark-whiteboard/SKILL.md +13 -12
  110. package/skills/lark-whiteboard/elements/layout.md +1 -1
  111. package/skills/lark-whiteboard/elements/schema.md +2 -2
  112. package/skills/lark-whiteboard/references/{lark-whiteboard-query.md → lark-whiteboard-export.md} +15 -15
  113. package/skills/lark-whiteboard/references/lark-whiteboard-update.md +3 -3
  114. package/skills/lark-whiteboard/references/lark-whiteboard-workflow.md +7 -17
  115. package/skills/lark-whiteboard/routes/dsl.md +3 -3
  116. package/skills/lark-whiteboard/routes/mermaid.md +2 -2
  117. package/skills/lark-whiteboard/routes/svg-edit.md +4 -4
  118. package/skills/lark-whiteboard/routes/svg.md +11 -6
  119. package/skills/lark-whiteboard/scenes/bar-chart.md +1 -1
  120. package/skills/lark-whiteboard/scenes/fishbone.md +1 -1
  121. package/skills/lark-whiteboard/scenes/flywheel.md +1 -1
  122. package/skills/lark-whiteboard/scenes/line-chart.md +1 -1
  123. package/skills/lark-whiteboard/scenes/treemap.md +1 -1
  124. package/skills/lark-wiki/SKILL.md +1 -0
  125. package/skills/lark-slides/references/examples.md +0 -91
  126. package/skills/lark-slides/references/lark-slides-whiteboard.md +0 -331
  127. package/skills/lark-slides/references/lark-slides-xml-get.md +0 -100
  128. package/skills/lark-slides/references/slide-templates.md +0 -201
  129. package/skills/lark-slides/references/slides_demo.xml +0 -226
  130. package/skills/lark-slides/references/xml-format-guide.md +0 -433
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  # Copyright (c) 2026 Lark Technologies Pte. Ltd.
3
3
  # SPDX-License-Identifier: MIT
4
+ """Validate Slides XML structure and page layout through one release gate."""
4
5
 
5
6
  from __future__ import annotations
6
7
 
@@ -38,18 +39,29 @@ SXSD_ATTR_ALIASES = {
38
39
  "fontColor": "color",
39
40
  }
40
41
  SERVER_FILLED_SXSD_ATTRS = {"id"}
42
+ ROUNDTRIP_SXSD_ATTRS = {
43
+ ("chart", "updated"),
44
+ ("chartData", "isStaticData"),
45
+ }
46
+ # Slides readback echoes each chartField's CSV text as per-value <chartParsedValues> children;
47
+ # it's server-emitted, absent from the write schema, and appears on virtually every chart-bearing
48
+ # deck, so treating it as an unsupported tag would block per-slide linting document-wide.
49
+ ROUNDTRIP_SXSD_TAGS = {"chartParsedValues"}
41
50
  DEFAULT_TABLE_COLUMN_WIDTH = 110
42
51
  DEFAULT_TABLE_ROW_HEIGHT = 37
52
+ # Sub-pixel canvas overflow is floating-point rounding noise (e.g. rotated-bbox math), not a
53
+ # visible defect; keep this well under 1px so real overflow is still always caught.
54
+ CANVAS_OVERFLOW_TOLERANCE = 0.5
43
55
  _SXSD_TAG_ATTRIBUTES_CACHE: dict[str, set[str]] | None = None
44
56
  _ICONPARK_ICON_TYPES_CACHE: set[str] | None = None
45
57
 
46
58
 
47
- class XmlTextOverlapLintError(Exception):
59
+ class XmlLayoutLintError(Exception):
48
60
  pass
49
61
 
50
62
 
51
63
  def fail(message: str) -> None:
52
- raise XmlTextOverlapLintError(message)
64
+ raise XmlLayoutLintError(message)
53
65
 
54
66
 
55
67
  def read_file(file_path: str | Path) -> str:
@@ -62,7 +74,7 @@ def parse_args(argv: list[str]) -> dict[str, Any]:
62
74
  while index < len(argv):
63
75
  token = argv[index]
64
76
  if not token.startswith("--"):
65
- fail(f"unexpected argument: {token}")
77
+ fail(f"unexpected argument: {token}, need --input")
66
78
  key = token[2:]
67
79
  next_token = argv[index + 1] if index + 1 < len(argv) else None
68
80
  if next_token is None or next_token.startswith("--"):
@@ -75,8 +87,12 @@ def parse_args(argv: list[str]) -> dict[str, Any]:
75
87
 
76
88
 
77
89
  def extract_attribute(tag_source: str, name: str) -> str | None:
78
- match = re.search(fr'{re.escape(name)}="([^"]+)"', tag_source)
79
- return match.group(1) if match else None
90
+ match = re.search(
91
+ fr"(?:^|\s){re.escape(name)}\s*=\s*(?:\"([^\"]+)\"|'([^']+)')", tag_source
92
+ )
93
+ if not match:
94
+ return None
95
+ return match.group(1) if match.group(1) is not None else match.group(2)
80
96
 
81
97
 
82
98
  def extract_numeric_attribute(tag_source: str, name: str) -> int | float | None:
@@ -168,8 +184,10 @@ def solve_weighted_min_layout(
168
184
  return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": ratio}
169
185
 
170
186
 
171
- def strip_xml(value: str) -> str:
187
+ def strip_xml(value: str, preserve_line_breaks: bool = False) -> str:
172
188
  stripped = re.sub(r"<!\[CDATA\[([\s\S]*?)\]\]>", r"\1", value)
189
+ if preserve_line_breaks:
190
+ stripped = re.sub(r"<br\b[^>]*>", "\n", stripped)
173
191
  stripped = re.sub(r"<[^>]+>", " ", stripped)
174
192
  stripped = stripped.replace("&nbsp;", " ")
175
193
  stripped = stripped.replace("&amp;", "&")
@@ -177,14 +195,46 @@ def strip_xml(value: str) -> str:
177
195
  stripped = stripped.replace("&gt;", ">")
178
196
  stripped = stripped.replace("&quot;", '"')
179
197
  stripped = stripped.replace("&#39;", "'")
198
+ if preserve_line_breaks:
199
+ return "\n".join(re.sub(r"\s+", " ", line).strip() for line in stripped.split("\n"))
180
200
  return re.sub(r"\s+", " ", stripped).strip()
181
201
 
182
202
 
183
203
  def strip_xml_paragraphs(value: str) -> str:
184
204
  paragraphs = re.findall(r"<p\b[^>]*>([\s\S]*?)</p\s*>", value)
185
205
  if paragraphs:
186
- return "\n".join(strip_xml(paragraph) for paragraph in paragraphs)
187
- return strip_xml(value)
206
+ return "\n".join(strip_xml(paragraph, preserve_line_breaks=True) for paragraph in paragraphs)
207
+ return strip_xml(value, preserve_line_breaks=True)
208
+
209
+
210
+ def extract_text_paragraphs(value: str, default_font_size: int | float) -> list[dict[str, Any]]:
211
+ paragraphs = []
212
+ for attrs, body in re.findall(r"<p\b([^>]*)>([\s\S]*?)</p\s*>", value):
213
+ paragraphs.append(
214
+ {
215
+ "text": strip_xml(body, preserve_line_breaks=True),
216
+ "fontSize": extract_max_span_font_size(body, default_font_size),
217
+ "textAlign": extract_attribute(attrs, "textAlign"),
218
+ "lineSpacing": extract_attribute(attrs, "lineSpacing"),
219
+ "beforeLineSpacing": extract_attribute(attrs, "beforeLineSpacing"),
220
+ "afterLineSpacing": extract_attribute(attrs, "afterLineSpacing"),
221
+ }
222
+ )
223
+ return paragraphs
224
+
225
+
226
+ def extract_max_span_font_size(value: str, default_font_size: int | float) -> int | float:
227
+ font_sizes = [
228
+ font_size
229
+ for attrs in re.findall(r"<span\b([^>]*)>", value)
230
+ if (font_size := extract_numeric_attribute(attrs, "fontSize")) is not None
231
+ ]
232
+ return max([default_font_size, *font_sizes])
233
+
234
+
235
+ def extract_tag_attributes(value: str, tag: str) -> str:
236
+ match = re.search(fr"<{re.escape(tag)}\b([^>]*)>", value)
237
+ return match.group(1) if match else ""
188
238
 
189
239
 
190
240
  def xml_local_name(tag: str) -> str:
@@ -319,8 +369,8 @@ def should_skip_sxsd_subtree(element: ET.Element, ancestors: list[str]) -> bool:
319
369
  return "whiteboard" in ancestors and xml_namespace(element.tag) == SVG_NS
320
370
 
321
371
 
322
- def should_skip_sxsd_attribute(attr_name: str) -> bool:
323
- return attr_name in SERVER_FILLED_SXSD_ATTRS
372
+ def should_skip_sxsd_attribute(tag_name: str, attr_name: str) -> bool:
373
+ return attr_name in SERVER_FILLED_SXSD_ATTRS or (tag_name, attr_name) in ROUNDTRIP_SXSD_ATTRS
324
374
 
325
375
 
326
376
  def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
@@ -334,6 +384,8 @@ def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
334
384
 
335
385
  tag_name = xml_local_name(element.tag)
336
386
  current_path = f"{path}/{tag_name}" if path else tag_name
387
+ if tag_name in ROUNDTRIP_SXSD_TAGS:
388
+ return
337
389
  if tag_name not in supported_tags:
338
390
  issues.append(
339
391
  {
@@ -352,7 +404,7 @@ def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
352
404
  if raw_attr_name.startswith(XML_NS):
353
405
  continue
354
406
  attr_name = xml_local_name(raw_attr_name)
355
- if should_skip_sxsd_attribute(attr_name):
407
+ if should_skip_sxsd_attribute(tag_name, attr_name):
356
408
  continue
357
409
  if attr_name in allowed_attrs:
358
410
  continue
@@ -594,8 +646,9 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
594
646
 
595
647
  for match in re.finditer(r"<(shape|img|table|chart|whiteboard)\b([^>]*)>", slide_xml):
596
648
  kind, attrs = match.group(1), match.group(2)
649
+ is_self_closing = attrs.rstrip().endswith("/")
597
650
  content = ""
598
- if kind in {"shape", "table"}:
651
+ if kind in {"shape", "table"} and not is_self_closing:
599
652
  close_index = slide_xml.find(f"</{kind}>", match.end())
600
653
  if close_index != -1:
601
654
  content = slide_xml[match.end() : close_index]
@@ -606,6 +659,7 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
606
659
  width = extract_numeric_attribute(attrs, "width")
607
660
  height = extract_numeric_attribute(attrs, "height")
608
661
  rotation = extract_numeric_attribute(attrs, "rotation") or 0
662
+ alpha = extract_numeric_attribute(attrs, "alpha")
609
663
  table_layouts: dict[str, dict[str, Any] | None] = {}
610
664
  if kind == "table":
611
665
  width, table_layouts["width"] = resolve_table_dimension(
@@ -624,6 +678,7 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
624
678
  "width": width,
625
679
  "height": height,
626
680
  "rotation": rotation,
681
+ "alpha": alpha if alpha is not None else 1,
627
682
  "order": len(elements),
628
683
  }
629
684
  if kind == "table":
@@ -635,15 +690,28 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
635
690
  }
636
691
  )
637
692
  if kind == "shape":
693
+ content_attrs = extract_tag_attributes(content, "content")
694
+ font_size = extract_numeric_attribute(content_attrs, "fontSize")
695
+ if font_size is None:
696
+ font_size = extract_numeric_attribute(attrs, "fontSize")
638
697
  element.update(
639
698
  {
640
- "textType": extract_attribute(content, "textType"),
641
- "textAlign": extract_attribute(content, "textAlign"),
642
- "autoFit": extract_attribute(content, "autoFit"),
643
- "fontSize": float(
644
- extract_attribute(content, "fontSize") or extract_attribute(attrs, "fontSize") or 16
645
- ),
699
+ "textType": extract_attribute(content_attrs, "textType"),
700
+ "textAlign": extract_attribute(content_attrs, "textAlign"),
701
+ "verticalAlign": extract_attribute(content_attrs, "verticalAlign") or "middle",
702
+ "vert": extract_attribute(attrs, "vert") or "horz",
703
+ "autoFit": extract_attribute(content_attrs, "autoFit"),
704
+ "wrap": extract_attribute(content_attrs, "wrap"),
705
+ "lineSpacing": extract_attribute(content_attrs, "lineSpacing"),
706
+ "beforeLineSpacing": extract_attribute(content_attrs, "beforeLineSpacing"),
707
+ "afterLineSpacing": extract_attribute(content_attrs, "afterLineSpacing"),
708
+ "paddingTop": extract_numeric_attribute(content_attrs, "paddingTop") or 0,
709
+ "paddingRight": extract_numeric_attribute(content_attrs, "paddingRight") or 0,
710
+ "paddingBottom": extract_numeric_attribute(content_attrs, "paddingBottom") or 0,
711
+ "paddingLeft": extract_numeric_attribute(content_attrs, "paddingLeft") or 0,
712
+ "fontSize": font_size if font_size is not None else 16,
646
713
  "text": strip_xml_paragraphs(content),
714
+ "paragraphs": extract_text_paragraphs(content, font_size if font_size is not None else 16),
647
715
  }
648
716
  )
649
717
  elements.append(element)
@@ -671,6 +739,40 @@ def has_text_content(element: dict[str, Any]) -> bool:
671
739
  return bool(element.get("text"))
672
740
 
673
741
 
742
+ def is_vertical_text(element: dict[str, Any]) -> bool:
743
+ return element.get("vert") in {"vert", "vert270", "word-art-vert", "word-art-vert-rtl", "ea-vert"}
744
+
745
+
746
+ def detect_image_text_occlusions(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
747
+ issues: list[dict[str, Any]] = []
748
+ text_elements = [element for element in elements if is_text_element(element) and has_text_content(element)]
749
+ image_elements = [element for element in elements if element["kind"] == "img" and element["alpha"] > 0]
750
+ for text_element in text_elements:
751
+ for image_element in image_elements:
752
+ if image_element["order"] <= text_element["order"]:
753
+ continue
754
+ if is_vertical_text(text_element):
755
+ if intersects(image_element, text_element):
756
+ issues.append({
757
+ "level": "info",
758
+ "code": "image_may_cover_vertical_text",
759
+ "elements": [image_element["id"], text_element["id"]],
760
+ "message": f'image {image_element["id"]} may cover vertical text shape {text_element["id"]}',
761
+ "hint": "Inspect the rendered slide because vertical text layout is not statically modeled.",
762
+ })
763
+ continue
764
+ text_visual_bbox = estimate_text_visual_bbox(text_element)
765
+ if text_visual_bbox is not None and intersects(image_element, text_visual_bbox):
766
+ issues.append({
767
+ "level": "error",
768
+ "code": "image_covers_text",
769
+ "elements": [image_element["id"], text_element["id"]],
770
+ "message": f'image {image_element["id"]} covers text shape {text_element["id"]}',
771
+ "hint": "Move the image before the text shape in XML order, or adjust the image and text shape coordinates or dimensions.",
772
+ })
773
+ return issues
774
+
775
+
674
776
  def is_decorative_text(element: dict[str, Any]) -> bool:
675
777
  text = element.get("text") or ""
676
778
  return bool(text) and re.search(r"[A-Za-z0-9\u4e00-\u9fff]", text) is None
@@ -708,27 +810,135 @@ def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool
708
810
  return SequenceMatcher(None, left_text, right_text).ratio() >= 0.75
709
811
 
710
812
 
711
- def estimate_text_line_count(element: dict[str, Any]) -> int:
813
+ def estimate_text_line_count_for_text(element: dict[str, Any], text: str) -> int:
712
814
  font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
713
- paragraphs = [paragraph for paragraph in re.split(r"\n+", element["text"]) if paragraph]
815
+ hard_lines = text.split("\n")
816
+ if not text:
817
+ return 0
714
818
  line_count = 0
715
- for paragraph in paragraphs:
716
- logical_width = max(estimate_text_width(paragraph, font_size), 1)
819
+ for hard_line in hard_lines:
820
+ if element.get("wrap") in {"false", "0"}:
821
+ line_count += 1
822
+ continue
823
+ logical_width = max(estimate_text_width(hard_line, font_size), 1)
717
824
  line_count += max(1, math.ceil(logical_width / max(element["width"], 1)))
718
- return max(line_count, 1)
825
+ return line_count
826
+
827
+
828
+ def estimate_text_line_count(element: dict[str, Any]) -> int:
829
+ return max(estimate_text_line_count_for_text(element, element["text"]), 1)
830
+
831
+
832
+ def estimate_text_line_height(element: dict[str, Any], line_spacing: str | None = None) -> int | float | None:
833
+ font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
834
+ line_spacing = line_spacing or "multiple:1.5"
835
+ match = re.fullmatch(r"(multiple|fixed):([0-9]+(?:\.[0-9]+)?)", line_spacing)
836
+ if match is None:
837
+ return None
838
+ spacing_type, value = match.groups()
839
+ return font_size * float(value) if spacing_type == "multiple" else float(value)
840
+
841
+
842
+ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
843
+ issues: list[dict[str, Any]] = []
844
+ for element in elements:
845
+ if not is_text_element(element) or not has_text_content(element):
846
+ continue
847
+ if element.get("autoFit") in {"normal-auto-fit", "shape-auto-fit"}:
848
+ continue
849
+
850
+ font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
851
+ paragraphs = element.get("paragraphs") or [
852
+ {
853
+ "text": element["text"],
854
+ "lineSpacing": None,
855
+ "beforeLineSpacing": None,
856
+ "afterLineSpacing": None,
857
+ }
858
+ ]
859
+ line_count = 0
860
+ estimated_height = 0.0
861
+ line_heights: list[int | float] = []
862
+ for paragraph in paragraphs:
863
+ paragraph_line_count = estimate_text_line_count_for_text(element, paragraph["text"])
864
+ if paragraph_line_count == 0:
865
+ continue
866
+ line_height = estimate_text_line_height(element, paragraph["lineSpacing"] or element["lineSpacing"])
867
+ before_spacing = estimate_text_line_height(
868
+ element, paragraph["beforeLineSpacing"] or element["beforeLineSpacing"] or "fixed:0"
869
+ )
870
+ after_spacing = estimate_text_line_height(
871
+ element, paragraph["afterLineSpacing"] or element["afterLineSpacing"] or "fixed:0"
872
+ )
873
+ if line_height is None or before_spacing is None or after_spacing is None:
874
+ line_count = 0
875
+ break
876
+ first_line_height = font_size if line_count == 0 else line_height
877
+ line_count += paragraph_line_count
878
+ line_heights.append(line_height)
879
+ estimated_height += (
880
+ before_spacing + first_line_height + max(paragraph_line_count - 1, 0) * line_height + after_spacing
881
+ )
882
+ if line_count == 0:
883
+ continue
884
+ available_height = max(element["height"] - element["paddingTop"] - element["paddingBottom"], 0)
885
+ overflow = estimated_height - available_height
886
+ if overflow <= 0:
887
+ continue
888
+
889
+ issues.append(
890
+ {
891
+ "level": "warning",
892
+ "code": "text_may_overflow_shape",
893
+ "elements": [element["id"]],
894
+ "line_count": line_count,
895
+ "line_height": max(line_heights),
896
+ "estimated_height": estimated_height,
897
+ "available_height": available_height,
898
+ "overflow": overflow,
899
+ "message": (
900
+ f'text shape {element["id"]} may overflow its own content box '
901
+ f'(estimated {estimated_height:g}px, available {available_height:g}px); '
902
+ 'consider setting content wrap="true" autoFit="normal-auto-fit"'
903
+ ),
904
+ "hint": (
905
+ "Increase shape.height, reduce the text, or set content wrap=\"true\" "
906
+ "autoFit=\"normal-auto-fit\". "
907
+ "This is an estimate based on font size, line spacing, and wrapped line count."
908
+ ),
909
+ }
910
+ )
911
+ return issues
719
912
 
720
913
 
721
914
  def estimate_text_visual_bbox(element: dict[str, Any]) -> dict[str, int | float] | None:
722
915
  if not is_text_element(element) or not has_text_content(element) or is_decorative_text(element):
723
916
  return None
724
917
 
918
+ padding_left = element.get("paddingLeft", 0)
919
+ padding_right = element.get("paddingRight", 0)
920
+ padding_top = element.get("paddingTop", 0)
921
+ padding_bottom = element.get("paddingBottom", 0)
922
+ content_width = max(element["width"] - padding_left - padding_right, 0)
923
+ content_height = max(element["height"] - padding_top - padding_bottom, 0)
725
924
  font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
726
925
  line_count = estimate_text_line_count(element)
727
- visual_width = min(element["width"], max(1, estimate_text_max_line_width(element)))
728
- visual_height = min(element["height"], max(1, line_count * font_size * 1.2))
926
+ estimated_width = max(1, estimate_text_max_line_width(element))
927
+ visual_width = estimated_width if element.get("wrap") in {"false", "0"} else min(content_width, estimated_width)
928
+ visual_height = min(content_height, max(1, line_count * font_size * 1.2))
929
+ x = element["x"] + padding_left
930
+ if element.get("textAlign") == "center":
931
+ x += (content_width - visual_width) / 2
932
+ elif element.get("textAlign") == "right":
933
+ x += content_width - visual_width
934
+ y = element["y"] + padding_top
935
+ if element.get("verticalAlign") == "middle":
936
+ y += (content_height - visual_height) / 2
937
+ elif element.get("verticalAlign") == "bottom":
938
+ y += content_height - visual_height
729
939
  return {
730
- "x": element["x"],
731
- "y": element["y"],
940
+ "x": x,
941
+ "y": y,
732
942
  "width": visual_width,
733
943
  "height": visual_height,
734
944
  }
@@ -818,6 +1028,10 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
818
1028
  source, target = sorted([left, right], key=lambda element: element["x"])
819
1029
  if source["x"] == target["x"]:
820
1030
  return False
1031
+ wrap_enabled = source.get("wrap") not in {"false", "0"}
1032
+ has_horizontal_gap = source["x"] + source["width"] <= target["x"]
1033
+ if wrap_enabled and has_horizontal_gap:
1034
+ return False
821
1035
  if source.get("autoFit") == "normal-auto-fit":
822
1036
  return False
823
1037
  if source.get("textAlign") in {"center", "right"}:
@@ -840,6 +1054,19 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
840
1054
  return vertical_overlap >= min_vertical_overlap
841
1055
 
842
1056
 
1057
+ def horizontal_text_overflow_measurement(left: dict[str, Any], right: dict[str, Any]) -> dict[str, int | float]:
1058
+ source, target = sorted([left, right], key=lambda element: element["x"])
1059
+ visual_width = estimate_text_max_line_width(source)
1060
+ source_visual_bbox = {"x": source["x"], "y": source["y"], "width": visual_width, "height": source["height"]}
1061
+ width = intersection_width(source_visual_bbox, target)
1062
+ height = intersection_height(source_visual_bbox, target)
1063
+ return {
1064
+ "intersection_width": round(width, 3),
1065
+ "intersection_height": round(height, 3),
1066
+ "intersection_area": round(width * height, 3),
1067
+ }
1068
+
1069
+
843
1070
  def should_flag_overlap(left: dict[str, Any], right: dict[str, Any]) -> bool:
844
1071
  if is_text_element(left) and not has_text_content(left):
845
1072
  return False
@@ -967,9 +1194,6 @@ def detect_whiteboard_external_overlaps(
967
1194
 
968
1195
  def element_canvas_bbox(element: dict[str, Any]) -> dict[str, int | float]:
969
1196
  bbox = {key: element[key] for key in ("x", "y", "width", "height")}
970
- if element["kind"] != "chart" and not (element["kind"] == "shape" and element["type"] == "text"):
971
- return bbox
972
-
973
1197
  rotation = element["rotation"]
974
1198
  if not isinstance(rotation, (int, float)) or not math.isfinite(rotation):
975
1199
  rotation = 0
@@ -995,12 +1219,7 @@ def detect_elements_out_of_canvas(
995
1219
  elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
996
1220
  ) -> list[dict[str, Any]]:
997
1221
  issues: list[dict[str, Any]] = []
998
- for element in (
999
- element
1000
- for element in elements
1001
- if element["kind"] in {"table", "chart"}
1002
- or (element["kind"] == "shape" and element["type"] == "text")
1003
- ):
1222
+ for element in elements:
1004
1223
  bbox = element_canvas_bbox(element)
1005
1224
  overflow = {
1006
1225
  "left": max(-bbox["x"], 0),
@@ -1009,7 +1228,9 @@ def detect_elements_out_of_canvas(
1009
1228
  "bottom": max(bbox["y"] + bbox["height"] - slide_height, 0),
1010
1229
  }
1011
1230
  overflow_details = [
1012
- f"{side} by {amount:g}px" for side, amount in overflow.items() if amount > 0
1231
+ f"{side} by {amount:g}px"
1232
+ for side, amount in overflow.items()
1233
+ if amount > CANVAS_OVERFLOW_TOLERANCE
1013
1234
  ]
1014
1235
  if not overflow_details:
1015
1236
  continue
@@ -1116,6 +1337,8 @@ def lint_slide(
1116
1337
  *detect_whiteboard_external_overlaps(elements, slide_width, slide_height),
1117
1338
  *detect_elements_out_of_canvas(elements, slide_width, slide_height),
1118
1339
  *detect_table_layout_size_mismatches(elements),
1340
+ *detect_text_may_overflow_shapes(elements),
1341
+ *detect_image_text_occlusions(elements),
1119
1342
  ]
1120
1343
 
1121
1344
  for index, left in enumerate(elements):
@@ -1129,62 +1352,714 @@ def lint_slide(
1129
1352
  "code": "bbox_overlap",
1130
1353
  "elements": [left["id"], right["id"]],
1131
1354
  "message": f'{left["id"]} overlaps {right["id"]}',
1355
+ "hint": "Move or resize the elements so their visual bounds no longer intersect.",
1356
+ **(
1357
+ {"measurement": horizontal_text_overflow_measurement(left, right)}
1358
+ if horizontal_overflow
1359
+ else {}
1360
+ ),
1132
1361
  }
1133
1362
  )
1134
1363
 
1135
- return {"slide_number": slide_number, "element_count": len(elements), "issues": issues}
1364
+ return {
1365
+ "slide_number": slide_number,
1366
+ "element_count": len(elements),
1367
+ "elements": elements,
1368
+ "issues": issues,
1369
+ }
1136
1370
 
1137
1371
 
1138
- def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
1139
- root, xml_error = parse_xml_root(xml)
1140
- if xml_error:
1372
+
1373
+ MIN_CONTAINER_WIDTH = 140
1374
+ MIN_CONTAINER_HEIGHT = 160
1375
+ MIN_SHORT_CARD_HEIGHT = 80
1376
+ MIN_CONTAINER_AREA = 20_000
1377
+ MIN_CONTENT_COVERAGE_RATIO = 0.15
1378
+ MIN_SLIDE_CONTENT_COVERAGE_RATIO = 0.035
1379
+ MIN_SLIDE_CONTENT_ELEMENT_COUNT = 4
1380
+ SHORT_CARD_SIZE_TOLERANCE_RATIO = 0.10
1381
+ MIN_SIMILAR_SHORT_CARD_COUNT = 2
1382
+ LARGE_VISUAL_CHILD_RATIO = 0.35
1383
+ LAYOUT_PANEL_SPAN_RATIO = 0.90
1384
+ IMAGE_OVERLAY_MATCH_RATIO = 0.90
1385
+ DENSITY_CONTAINMENT_TOLERANCE = 8
1386
+
1387
+
1388
+ def clipped_bbox(element: dict[str, Any], container: dict[str, Any]) -> dict[str, int | float] | None:
1389
+ left = max(element["x"], container["x"])
1390
+ top = max(element["y"], container["y"])
1391
+ right = min(element["x"] + element["width"], container["x"] + container["width"])
1392
+ bottom = min(element["y"] + element["height"], container["y"] + container["height"])
1393
+ if right <= left or bottom <= top:
1394
+ return None
1395
+ return {"x": left, "y": top, "width": right - left, "height": bottom - top}
1396
+
1397
+
1398
+ def rectangle_union_area(rectangles: list[dict[str, int | float]]) -> int | float:
1399
+ x_coordinates = sorted({coordinate for rect in rectangles for coordinate in (rect["x"], rect["x"] + rect["width"])})
1400
+ area = 0
1401
+ for left, right in zip(x_coordinates, x_coordinates[1:]):
1402
+ intervals = sorted(
1403
+ (rect["y"], rect["y"] + rect["height"])
1404
+ for rect in rectangles
1405
+ if rect["x"] < right and rect["x"] + rect["width"] > left
1406
+ )
1407
+ covered_height = 0
1408
+ interval_end: int | float | None = None
1409
+ for top, bottom in intervals:
1410
+ if interval_end is None:
1411
+ covered_height += bottom - top
1412
+ interval_end = bottom
1413
+ elif bottom > interval_end:
1414
+ covered_height += bottom - max(top, interval_end)
1415
+ interval_end = bottom
1416
+ area += (right - left) * covered_height
1417
+ return area
1418
+
1419
+
1420
+ def has_similar_short_card_peer(element: dict[str, Any], elements: list[dict[str, Any]]) -> bool:
1421
+ return sum(
1422
+ other is not element
1423
+ and is_visually_rendered(other)
1424
+ and other["kind"] == "shape"
1425
+ and other["type"] == "rect"
1426
+ and other["width"] >= MIN_CONTAINER_WIDTH
1427
+ and other["height"] >= MIN_SHORT_CARD_HEIGHT
1428
+ and element_area(other) >= MIN_CONTAINER_AREA
1429
+ and abs(other["width"] - element["width"]) / max(other["width"], element["width"])
1430
+ <= SHORT_CARD_SIZE_TOLERANCE_RATIO
1431
+ and abs(other["height"] - element["height"]) / max(other["height"], element["height"])
1432
+ <= SHORT_CARD_SIZE_TOLERANCE_RATIO
1433
+ for other in elements
1434
+ ) >= MIN_SIMILAR_SHORT_CARD_COUNT
1435
+
1436
+
1437
+ def is_layout_container(
1438
+ element: dict[str, Any],
1439
+ slide_width: int | float,
1440
+ slide_height: int | float,
1441
+ elements: list[dict[str, Any]] | None = None,
1442
+ ) -> bool:
1443
+ has_supported_height = element["height"] >= MIN_CONTAINER_HEIGHT or (
1444
+ elements is not None
1445
+ and element["height"] >= MIN_SHORT_CARD_HEIGHT
1446
+ and has_similar_short_card_peer(element, elements)
1447
+ )
1448
+ return (
1449
+ element["kind"] == "shape"
1450
+ and element["type"] == "rect"
1451
+ and is_visually_rendered(element)
1452
+ and element["width"] >= MIN_CONTAINER_WIDTH
1453
+ and has_supported_height
1454
+ and element_area(element) >= MIN_CONTAINER_AREA
1455
+ and not (
1456
+ element["x"] <= 2
1457
+ and element["y"] <= 2
1458
+ and element["width"] >= slide_width - 4
1459
+ and element["height"] >= slide_height - 4
1460
+ )
1461
+ )
1462
+
1463
+
1464
+ def is_edge_spanning_layout_panel(
1465
+ element: dict[str, Any], slide_width: int | float, slide_height: int | float
1466
+ ) -> bool:
1467
+ touches_horizontal_edge = element["x"] <= 2 or element["x"] + element["width"] >= slide_width - 2
1468
+ touches_vertical_edge = element["y"] <= 2 or element["y"] + element["height"] >= slide_height - 2
1469
+ return (touches_horizontal_edge and element["height"] >= slide_height * LAYOUT_PANEL_SPAN_RATIO) or (
1470
+ touches_vertical_edge and element["width"] >= slide_width * LAYOUT_PANEL_SPAN_RATIO
1471
+ )
1472
+
1473
+
1474
+ def has_matching_image_overlay(container: dict[str, Any], elements: list[dict[str, Any]]) -> bool:
1475
+ container_area = element_area(container)
1476
+ return any(
1477
+ element["kind"] == "img"
1478
+ and is_visually_rendered(element)
1479
+ and intersection_area(container, element) / max(1, container_area) >= IMAGE_OVERLAY_MATCH_RATIO
1480
+ for element in elements
1481
+ )
1482
+
1483
+
1484
+ def is_nested_in_layout_panel(
1485
+ container: dict[str, Any], elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
1486
+ ) -> bool:
1487
+ return any(
1488
+ element is not container
1489
+ and element["kind"] == "shape"
1490
+ and element["type"] == "rect"
1491
+ and is_visually_rendered(element)
1492
+ and is_edge_spanning_layout_panel(element, slide_width, slide_height)
1493
+ and contains(element, container, tolerance=DENSITY_CONTAINMENT_TOLERANCE)
1494
+ for element in elements
1495
+ )
1496
+
1497
+
1498
+ def extract_density_elements(slide_xml: str) -> list[dict[str, Any]]:
1499
+ elements = extract_elements(slide_xml)
1500
+ elements_by_id = {element["id"]: element for element in elements}
1501
+ root = ET.fromstring(slide_xml)
1502
+ for node in root.iter():
1503
+ if xml_local_name(node.tag) != "shape":
1504
+ continue
1505
+ element = elements_by_id.get(node.attrib.get("id", ""))
1506
+ if element is None:
1507
+ continue
1508
+ content_node = next(
1509
+ (child for child in node if xml_local_name(child.tag) == "content"),
1510
+ None,
1511
+ )
1512
+ paragraphs = (
1513
+ [
1514
+ " ".join("".join(paragraph.itertext()).split())
1515
+ for paragraph in content_node.iter()
1516
+ if xml_local_name(paragraph.tag) == "p"
1517
+ ]
1518
+ if content_node is not None
1519
+ else []
1520
+ )
1521
+ raw_font_size = (
1522
+ content_node.attrib.get("fontSize") if content_node is not None else None
1523
+ ) or node.attrib.get("fontSize")
1524
+ try:
1525
+ base_font_size = float(raw_font_size or 16)
1526
+ except ValueError:
1527
+ base_font_size = 16.0
1528
+ element.update(
1529
+ {
1530
+ "textType": content_node.attrib.get("textType") if content_node is not None else None,
1531
+ "textAlign": content_node.attrib.get("textAlign") if content_node is not None else None,
1532
+ "autoFit": content_node.attrib.get("autoFit") if content_node is not None else None,
1533
+ "fontSize": base_font_size,
1534
+ "text": "\n".join(paragraph for paragraph in paragraphs if paragraph),
1535
+ }
1536
+ )
1537
+ if not has_text_content(element):
1538
+ continue
1539
+ declared_font_sizes = []
1540
+ for descendant in node.iter():
1541
+ raw_declared_font_size = descendant.attrib.get("fontSize")
1542
+ if raw_declared_font_size is None:
1543
+ continue
1544
+ try:
1545
+ declared_font_sizes.append(float(raw_declared_font_size))
1546
+ except ValueError:
1547
+ continue
1548
+ if declared_font_sizes:
1549
+ element["fontSize"] = max(declared_font_sizes)
1550
+ for match in re.finditer(r"<icon\b([^>]*)>", slide_xml):
1551
+ attrs = match.group(1)
1552
+ x = extract_numeric_attribute(attrs, "topLeftX")
1553
+ y = extract_numeric_attribute(attrs, "topLeftY")
1554
+ width = extract_numeric_attribute(attrs, "width")
1555
+ height = extract_numeric_attribute(attrs, "height")
1556
+ if any(value is None for value in (x, y, width, height)):
1557
+ continue
1558
+ icon_alpha = extract_numeric_attribute(attrs, "alpha")
1559
+ elements.append(
1560
+ {
1561
+ "id": extract_attribute(attrs, "id") or f"icon-{len(elements) + 1}",
1562
+ "kind": "icon",
1563
+ "type": "icon",
1564
+ "x": x,
1565
+ "y": y,
1566
+ "width": width,
1567
+ "height": height,
1568
+ "rotation": extract_numeric_attribute(attrs, "rotation") or 0,
1569
+ "alpha": icon_alpha if icon_alpha is not None else 1,
1570
+ "order": len(elements),
1571
+ }
1572
+ )
1573
+ for match in re.finditer(r"<polyline\b([^>]*)>", slide_xml):
1574
+ attrs = match.group(1)
1575
+ x = extract_numeric_attribute(attrs, "topLeftX")
1576
+ y = extract_numeric_attribute(attrs, "topLeftY")
1577
+ width = extract_numeric_attribute(attrs, "width")
1578
+ height = extract_numeric_attribute(attrs, "height")
1579
+ if any(value is None for value in (x, y, width, height)):
1580
+ continue
1581
+ polyline_alpha = extract_numeric_attribute(attrs, "alpha")
1582
+ elements.append(
1583
+ {
1584
+ "id": extract_attribute(attrs, "id") or f"polyline-{len(elements) + 1}",
1585
+ "kind": "polyline",
1586
+ "type": "polyline",
1587
+ "x": x,
1588
+ "y": y,
1589
+ "width": width,
1590
+ "height": height,
1591
+ "rotation": extract_numeric_attribute(attrs, "rotation") or 0,
1592
+ "alpha": polyline_alpha if polyline_alpha is not None else 1,
1593
+ "order": len(elements),
1594
+ }
1595
+ )
1596
+ for line_element in extract_line_elements(slide_xml):
1597
+ line_element["order"] = len(elements)
1598
+ elements.append(line_element)
1599
+ return elements
1600
+
1601
+
1602
+ def is_visually_rendered(element: dict[str, Any]) -> bool:
1603
+ return element.get("alpha", 1) > 0
1604
+
1605
+
1606
+ def visual_bbox(element: dict[str, Any], container: dict[str, Any]) -> dict[str, int | float] | None:
1607
+ if not is_visually_rendered(element):
1608
+ return None
1609
+ if is_text_element(element):
1610
+ estimated = estimate_text_visual_bbox(element)
1611
+ return clipped_bbox(estimated, container) if estimated else None
1612
+ return clipped_bbox(element, container)
1613
+
1614
+
1615
+ def own_text_visual_bbox(container: dict[str, Any]) -> dict[str, int | float] | None:
1616
+ if container["kind"] != "shape" or not has_text_content(container):
1617
+ return None
1618
+ text_proxy = {**container, "type": "text"}
1619
+ estimated = estimate_text_visual_bbox(text_proxy)
1620
+ return clipped_bbox(estimated, container) if estimated else None
1621
+
1622
+
1623
+ def slide_content_visual_bbox(
1624
+ element: dict[str, Any], slide_bbox: dict[str, int | float]
1625
+ ) -> dict[str, int | float] | None:
1626
+ if not is_visually_rendered(element):
1627
+ return None
1628
+ if is_text_element(element):
1629
+ estimated = estimate_text_visual_bbox(element)
1630
+ return clipped_bbox(estimated, slide_bbox) if estimated else None
1631
+ if element["kind"] == "shape" and has_text_content(element):
1632
+ estimated = own_text_visual_bbox(element)
1633
+ return clipped_bbox(estimated, slide_bbox) if estimated else None
1634
+ if element["kind"] == "line":
1635
+ # a straight horizontal/vertical line has zero width or height in one axis; clipped_bbox
1636
+ # treats zero-area rects as invisible, so pad to its rendered stroke thickness instead.
1637
+ return clipped_bbox(line_stroke_bbox(element), slide_bbox)
1638
+ if element["kind"] in {"img", "chart", "table", "whiteboard", "icon", "polyline"}:
1639
+ return clipped_bbox(element, slide_bbox)
1640
+ return None
1641
+
1642
+
1643
+ def line_stroke_bbox(element: dict[str, Any]) -> dict[str, Any]:
1644
+ return {**element, "width": max(element["width"], 1), "height": max(element["height"], 1)}
1645
+
1646
+
1647
+ def is_slide_content_present(
1648
+ element: dict[str, Any], slide_bbox: dict[str, int | float]
1649
+ ) -> bool:
1650
+ # Deliberately permissive, unlike slide_content_visual_bbox: blank_slide is asking "is
1651
+ # *anything* rendered here", not the richer "counts toward meaningful content density" bar
1652
+ # that sparse_slide_content/sparse_container_content apply. A plain shape with no text (a
1653
+ # decorative rect/ellipse/etc.), <undefined>, or any future SXSD data element should all
1654
+ # count here — deny-list only what's actually invisible (alpha<=0 or zero on-canvas area)
1655
+ # instead of maintaining an allow-list that silently treats unlisted kinds as blank.
1656
+ if not is_visually_rendered(element):
1657
+ return False
1658
+ if (
1659
+ element["kind"] == "shape"
1660
+ and element["type"] == "rect"
1661
+ and not has_text_content(element)
1662
+ and element["x"] <= 2
1663
+ and element["y"] <= 2
1664
+ and element["width"] >= slide_bbox["width"] - 4
1665
+ and element["height"] >= slide_bbox["height"] - 4
1666
+ ):
1667
+ # A full-canvas plain rect is a background panel, not content -- same reasoning as
1668
+ # is_layout_container's existing background exclusion. A slide with nothing else on it
1669
+ # is still effectively blank.
1670
+ return False
1671
+ bbox = line_stroke_bbox(element) if element["kind"] == "line" else element
1672
+ return clipped_bbox(bbox, slide_bbox) is not None
1673
+
1674
+
1675
+ def is_large_visual_child(element: dict[str, Any], container: dict[str, Any]) -> bool:
1676
+ if element["kind"] not in {"img", "chart", "table", "whiteboard"}:
1677
+ return False
1678
+ if not is_visually_rendered(element):
1679
+ return False
1680
+ return element_area(element) / element_area(container) >= LARGE_VISUAL_CHILD_RATIO
1681
+
1682
+
1683
+ def detect_sparse_container_content(
1684
+ elements: list[dict[str, Any]], slide_number: int, slide_width: int | float, slide_height: int | float
1685
+ ) -> list[dict[str, Any]]:
1686
+ issues: list[dict[str, Any]] = []
1687
+ for container in (
1688
+ element for element in elements if is_layout_container(element, slide_width, slide_height, elements)
1689
+ ):
1690
+ if (
1691
+ is_edge_spanning_layout_panel(container, slide_width, slide_height)
1692
+ or is_nested_in_layout_panel(container, elements, slide_width, slide_height)
1693
+ or has_matching_image_overlay(container, elements)
1694
+ ):
1695
+ continue
1696
+ children = [
1697
+ element
1698
+ for element in elements
1699
+ if element is not container
1700
+ and contains(container, element, tolerance=DENSITY_CONTAINMENT_TOLERANCE)
1701
+ ]
1702
+ if any(is_large_visual_child(child, container) for child in children):
1703
+ continue
1704
+ own_text_bbox = own_text_visual_bbox(container)
1705
+ rectangles = ([own_text_bbox] if own_text_bbox else []) + [
1706
+ bbox for child in children if (bbox := visual_bbox(child, container)) is not None
1707
+ ]
1708
+ content_area = rectangle_union_area(rectangles) if rectangles else 0
1709
+ coverage_ratio = content_area / element_area(container)
1710
+ if coverage_ratio >= MIN_CONTENT_COVERAGE_RATIO:
1711
+ continue
1712
+ issues.append(
1713
+ {
1714
+ "level": "warning",
1715
+ "code": "sparse_container_content",
1716
+ "target": {
1717
+ "slide_number": slide_number,
1718
+ "container_id": container["id"],
1719
+ "container_type": container["type"],
1720
+ "bbox": {key: container[key] for key in ("x", "y", "width", "height")},
1721
+ },
1722
+ "rule": {
1723
+ "name": "large_container_visible_content_coverage",
1724
+ "threshold": MIN_CONTENT_COVERAGE_RATIO,
1725
+ "comparison": "content_coverage_ratio < threshold",
1726
+ },
1727
+ "measurement": {
1728
+ "container_area": element_area(container),
1729
+ "visible_content_area": round(content_area, 3),
1730
+ "content_coverage_ratio": round(coverage_ratio, 3),
1731
+ "content_element_count": len(children) + (1 if own_text_bbox else 0),
1732
+ },
1733
+ "elements": [container["id"], *[child["id"] for child in children]],
1734
+ }
1735
+ )
1736
+ return issues
1737
+
1738
+
1739
+ def detect_sparse_slide_content(
1740
+ elements: list[dict[str, Any]], slide_number: int, slide_width: int | float, slide_height: int | float
1741
+ ) -> list[dict[str, Any]]:
1742
+ slide_bbox = {"x": 0, "y": 0, "width": slide_width, "height": slide_height}
1743
+ content = [
1744
+ (element, bbox)
1745
+ for element in elements
1746
+ if (bbox := slide_content_visual_bbox(element, slide_bbox)) is not None
1747
+ ]
1748
+ if len(content) < MIN_SLIDE_CONTENT_ELEMENT_COUNT:
1749
+ return []
1750
+ content_area = rectangle_union_area([bbox for _, bbox in content])
1751
+ slide_area = slide_width * slide_height
1752
+ coverage_ratio = content_area / slide_area
1753
+ if coverage_ratio >= MIN_SLIDE_CONTENT_COVERAGE_RATIO:
1754
+ return []
1755
+ return [
1756
+ {
1757
+ "level": "warning",
1758
+ "code": "sparse_slide_content",
1759
+ "target": {
1760
+ "slide_number": slide_number,
1761
+ "bbox": slide_bbox,
1762
+ },
1763
+ "rule": {
1764
+ "name": "slide_visible_content_coverage",
1765
+ "threshold": MIN_SLIDE_CONTENT_COVERAGE_RATIO,
1766
+ "comparison": "content_coverage_ratio < threshold",
1767
+ },
1768
+ "measurement": {
1769
+ "slide_area": slide_area,
1770
+ "visible_content_area": round(content_area, 3),
1771
+ "content_coverage_ratio": round(coverage_ratio, 3),
1772
+ "content_element_count": len(content),
1773
+ },
1774
+ "elements": [element["id"] for element, _ in content],
1775
+ }
1776
+ ]
1777
+
1778
+
1779
+ def detect_blank_slide(
1780
+ elements: list[dict[str, Any]],
1781
+ slide_number: int,
1782
+ slide_width: int | float,
1783
+ slide_height: int | float,
1784
+ ) -> list[dict[str, Any]]:
1785
+ slide_bbox = {"x": 0, "y": 0, "width": slide_width, "height": slide_height}
1786
+ visible_elements = [
1787
+ element for element in elements if is_slide_content_present(element, slide_bbox)
1788
+ ]
1789
+ if visible_elements:
1790
+ return []
1791
+ return [
1792
+ {
1793
+ "level": "error",
1794
+ "code": "blank_slide",
1795
+ "schema_version": "2.0",
1796
+ "target": {"slide_number": slide_number},
1797
+ "rule": {
1798
+ "name": "slide_has_visible_content",
1799
+ "comparison": "visible_element_count == 0",
1800
+ },
1801
+ "measurement": {
1802
+ "visible_element_count": 0,
1803
+ "declared_element_count": len(elements),
1804
+ },
1805
+ "elements": [element["id"] for element in elements],
1806
+ "message": "slide has no visible content beyond empty layout shapes",
1807
+ "hint": "Add visible text, an image, a chart, a table, a whiteboard, or an icon before creating the slide.",
1808
+ }
1809
+ ]
1810
+
1811
+
1812
+
1813
+ RULE_METADATA: dict[str, dict[str, Any]] = {
1814
+ "xml_not_well_formed": {
1815
+ "name": "xml_is_well_formed",
1816
+ "comparison": "xml_parse_error == false",
1817
+ },
1818
+ "sml_prefixed_tag": {
1819
+ "name": "sml_uses_default_namespace",
1820
+ "comparison": "prefixed_sml_tag_count == 0",
1821
+ },
1822
+ "sxsd_unsupported_tag": {
1823
+ "name": "tag_is_supported_by_slides_xml_schema",
1824
+ "comparison": "unsupported_tag_count == 0",
1825
+ },
1826
+ "sxsd_unsupported_attr": {
1827
+ "name": "attribute_is_supported_by_slides_xml_schema",
1828
+ "comparison": "unsupported_attribute_count == 0",
1829
+ },
1830
+ "icon_missing_fill_color": {
1831
+ "name": "icon_has_visible_fill_color",
1832
+ "comparison": "fill_color_present == true",
1833
+ },
1834
+ "icon_transparent_fill_color": {
1835
+ "name": "icon_has_visible_fill_color",
1836
+ "comparison": "fill_alpha > 0",
1837
+ },
1838
+ "iconpark_unsupported_icon_type": {
1839
+ "name": "iconpark_type_is_supported",
1840
+ "comparison": "icon_type in iconpark_index",
1841
+ },
1842
+ "bbox_overlap": {
1843
+ "name": "text_visual_bounds_do_not_overlap",
1844
+ "comparison": "intersection_area == 0",
1845
+ },
1846
+ "text_may_overflow_shape": {
1847
+ "name": "estimated_text_fits_declared_shape",
1848
+ "comparison": "estimated_height <= available_height",
1849
+ },
1850
+ "whiteboard_external_overlap": {
1851
+ "name": "whiteboard_does_not_cross_sibling_content",
1852
+ "comparison": "external_overlap_count == 0",
1853
+ },
1854
+ "image_covers_text": {
1855
+ "name": "image_does_not_cover_text",
1856
+ "comparison": "intersection_area == 0",
1857
+ },
1858
+ "image_may_cover_vertical_text": {
1859
+ "name": "image_vertical_text_occlusion_requires_review",
1860
+ "comparison": "intersection_area == 0",
1861
+ },
1862
+ "table_resolved_size_mismatch": {
1863
+ "name": "table_declared_size_matches_resolved_grid",
1864
+ "comparison": "declared_size == resolved_size",
1865
+ },
1866
+ "blank_slide": {
1867
+ "name": "slide_has_visible_content",
1868
+ "comparison": "visible_element_count > 0",
1869
+ },
1870
+ }
1871
+
1872
+
1873
+ def issue_rule(issue: dict[str, Any]) -> dict[str, Any]:
1874
+ if issue.get("rule"):
1875
+ return {**issue["rule"], "id": issue["code"]}
1876
+ if issue["code"].endswith("_out_of_canvas"):
1141
1877
  return {
1142
- "file": source_path,
1143
- "slide_size": {"width": 960, "height": 540},
1144
- "summary": {"slide_count": 0, "error_count": 1, "warning_count": 0, "info_count": 0},
1145
- "issues": [xml_error],
1146
- "slides": [],
1878
+ "id": issue["code"],
1879
+ "name": "element_stays_within_slide_canvas",
1880
+ "comparison": "max(left, top, right, bottom overflow) == 0",
1147
1881
  }
1882
+ return {
1883
+ "id": issue["code"],
1884
+ **RULE_METADATA.get(
1885
+ issue["code"],
1886
+ {"name": issue["code"], "comparison": "violation_count == 0"},
1887
+ ),
1888
+ }
1148
1889
 
1149
- namespace_issues = validate_sml_tag_prefixes(xml)
1150
- sxsd_issues = validate_sxsd_tag_attributes(root) if root is not None else []
1151
- iconpark_issues = validate_iconpark_icon_types(root) if root is not None else []
1152
- top_level_issues = [*namespace_issues, *sxsd_issues, *iconpark_issues]
1153
- if namespace_issues:
1154
- error_count = sum(1 for issue in top_level_issues if issue["level"] == "error")
1155
- warning_count = sum(1 for issue in top_level_issues if issue["level"] == "warning")
1156
- info_count = sum(1 for issue in top_level_issues if issue["level"] == "info")
1890
+
1891
+ def issue_measurement(
1892
+ issue: dict[str, Any], elements_by_id: dict[str, dict[str, Any]]
1893
+ ) -> dict[str, Any]:
1894
+ if issue.get("measurement") is not None:
1895
+ return issue["measurement"]
1896
+ if issue["code"] == "bbox_overlap" and len(issue.get("elements", [])) == 2:
1897
+ left = elements_by_id.get(issue["elements"][0])
1898
+ right = elements_by_id.get(issue["elements"][1])
1899
+ if left and right:
1900
+ left_box = (estimate_text_visual_bbox(left) if is_text_element(left) else None) or left
1901
+ right_box = (estimate_text_visual_bbox(right) if is_text_element(right) else None) or right
1902
+ width = intersection_width(left_box, right_box)
1903
+ height = intersection_height(left_box, right_box)
1904
+ return {
1905
+ "intersection_width": round(width, 3),
1906
+ "intersection_height": round(height, 3),
1907
+ "intersection_area": round(width * height, 3),
1908
+ }
1909
+ if issue["code"].endswith("_out_of_canvas"):
1157
1910
  return {
1158
- "file": source_path,
1159
- "slide_size": {"width": 960, "height": 540},
1160
- "summary": {
1161
- "slide_count": 0,
1162
- "error_count": error_count,
1163
- "warning_count": warning_count,
1164
- "info_count": info_count,
1165
- },
1166
- "issues": top_level_issues,
1167
- "slides": [],
1911
+ "canvas": issue.get("canvas"),
1912
+ "bbox": issue.get("bbox"),
1913
+ "overflow": issue.get("overflow"),
1168
1914
  }
1169
- presentation = parse_presentation(xml)
1170
- slides = [
1171
- lint_slide(slide_xml, index + 1, presentation["width"], presentation["height"])
1172
- for index, slide_xml in enumerate(presentation["slides"])
1915
+ measurement_keys = (
1916
+ "line",
1917
+ "column",
1918
+ "tag",
1919
+ "attr",
1920
+ "iconType",
1921
+ "line_count",
1922
+ "line_height",
1923
+ "estimated_height",
1924
+ "available_height",
1925
+ "overflow",
1926
+ "dimension",
1927
+ "declared_size",
1928
+ "resolved_size",
1929
+ "resolved_sizes",
1930
+ "overlaps",
1931
+ )
1932
+ measured = {key: issue[key] for key in measurement_keys if key in issue}
1933
+ return measured or {"violation_count": 1}
1934
+
1935
+
1936
+ def related_object(element: dict[str, Any]) -> dict[str, Any]:
1937
+ return {
1938
+ "element_id": element["id"],
1939
+ "kind": element["kind"],
1940
+ "type": element["type"],
1941
+ "bbox": {key: element[key] for key in ("x", "y", "width", "height")},
1942
+ }
1943
+
1944
+
1945
+ def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
1946
+ elements: list[dict[str, Any]] = []
1947
+ for match in re.finditer(r"<line\b([^>]*)>", slide_xml):
1948
+ attrs = match.group(1)
1949
+ start_x = extract_numeric_attribute(attrs, "startX")
1950
+ start_y = extract_numeric_attribute(attrs, "startY")
1951
+ end_x = extract_numeric_attribute(attrs, "endX")
1952
+ end_y = extract_numeric_attribute(attrs, "endY")
1953
+ if any(value is None for value in (start_x, start_y, end_x, end_y)):
1954
+ continue
1955
+ line_alpha = extract_numeric_attribute(attrs, "alpha")
1956
+ elements.append(
1957
+ {
1958
+ "id": extract_attribute(attrs, "id") or f"line-{len(elements) + 1}",
1959
+ "kind": "line",
1960
+ "type": "line",
1961
+ "x": min(start_x, end_x),
1962
+ "y": min(start_y, end_y),
1963
+ "width": abs(end_x - start_x),
1964
+ "height": abs(end_y - start_y),
1965
+ "rotation": 0,
1966
+ "alpha": line_alpha if line_alpha is not None else 1,
1967
+ "order": len(elements),
1968
+ }
1969
+ )
1970
+ return elements
1971
+
1972
+
1973
+ def normalize_issue(
1974
+ issue: dict[str, Any],
1975
+ slide_number: int | None,
1976
+ elements_by_id: dict[str, dict[str, Any]],
1977
+ ) -> dict[str, Any]:
1978
+ normalized = dict(issue)
1979
+ if normalized.get("level") == "info":
1980
+ normalized["level"] = "warning"
1981
+ element_ids = list(dict.fromkeys(normalized.get("elements", [])))
1982
+ normalized["schema_version"] = "2.0"
1983
+ normalized["element_ids"] = element_ids
1984
+ normalized["target"] = {
1985
+ **({"slide_number": slide_number} if slide_number is not None else {}),
1986
+ **normalized.get("target", {}),
1987
+ }
1988
+ normalized["rule"] = issue_rule(normalized)
1989
+ normalized["measurement"] = issue_measurement(normalized, elements_by_id)
1990
+ normalized["related_objects"] = [
1991
+ related_object(elements_by_id[element_id])
1992
+ for element_id in element_ids
1993
+ if element_id in elements_by_id
1173
1994
  ]
1174
- error_count = sum(1 for issue in top_level_issues if issue["level"] == "error")
1175
- error_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "error")
1176
- warning_count = sum(1 for issue in top_level_issues if issue["level"] == "warning")
1177
- warning_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "warning")
1178
- info_count = sum(1 for issue in top_level_issues if issue["level"] == "info")
1179
- info_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "info")
1180
- result = {
1995
+ if normalized["code"] == "sparse_container_content":
1996
+ ratio = normalized["measurement"]["content_coverage_ratio"]
1997
+ threshold = normalized["rule"]["threshold"]
1998
+ container_id = normalized["target"].get("container_id", "unknown")
1999
+ normalized.setdefault(
2000
+ "message",
2001
+ f"large card {container_id} content coverage {ratio:.1%} is below {threshold:.1%}",
2002
+ )
2003
+ normalized.setdefault(
2004
+ "hint",
2005
+ "Review the rendered screenshot; add or enlarge meaningful content if the whitespace is not intentional.",
2006
+ )
2007
+ elif normalized["code"] == "sparse_slide_content":
2008
+ ratio = normalized["measurement"]["content_coverage_ratio"]
2009
+ threshold = normalized["rule"]["threshold"]
2010
+ normalized.setdefault(
2011
+ "message",
2012
+ f"slide visible content coverage {ratio:.1%} is below {threshold:.1%}",
2013
+ )
2014
+ normalized.setdefault(
2015
+ "hint",
2016
+ "Review the rendered screenshot to decide whether the page is intentionally sparse.",
2017
+ )
2018
+ else:
2019
+ normalized.setdefault("message", normalized["code"].replace("_", " "))
2020
+ normalized.setdefault(
2021
+ "hint", "Inspect the reported elements and adjust them to satisfy the rule comparison."
2022
+ )
2023
+ return normalized
2024
+
2025
+
2026
+ def slide_status(errors: list[dict[str, Any]], warnings: list[dict[str, Any]]) -> str:
2027
+ if errors:
2028
+ return "blocked"
2029
+ if warnings:
2030
+ return "needs_screenshot_review"
2031
+ return "passed"
2032
+
2033
+
2034
+ def build_result(
2035
+ source_path: str | None,
2036
+ slide_size: dict[str, int | float],
2037
+ top_level_issues: list[dict[str, Any]],
2038
+ slides: list[dict[str, Any]],
2039
+ ) -> dict[str, Any]:
2040
+ document_errors = [issue for issue in top_level_issues if issue["level"] == "error"]
2041
+ document_warnings = [issue for issue in top_level_issues if issue["level"] == "warning"]
2042
+ error_count = len(document_errors) + sum(len(slide["errors"]) for slide in slides)
2043
+ warning_count = len(document_warnings) + sum(len(slide["warnings"]) for slide in slides)
2044
+ all_errors = document_errors + [issue for slide in slides for issue in slide["errors"]]
2045
+ all_warnings = document_warnings + [issue for slide in slides for issue in slide["warnings"]]
2046
+ status = slide_status(all_errors, all_warnings)
2047
+ result: dict[str, Any] = {
2048
+ "schema_version": "2.0",
2049
+ "tool": "xml_text_overlap_lint",
1181
2050
  "file": source_path,
1182
- "slide_size": {"width": presentation["width"], "height": presentation["height"]},
2051
+ "slide_size": slide_size,
1183
2052
  "summary": {
1184
2053
  "slide_count": len(slides),
1185
2054
  "error_count": error_count,
1186
2055
  "warning_count": warning_count,
1187
- "info_count": info_count,
2056
+ "status": status,
2057
+ "release_ready": error_count == 0,
2058
+ "screenshot_review_required": warning_count > 0,
2059
+ },
2060
+ "document": {
2061
+ "errors": document_errors,
2062
+ "warnings": document_warnings,
1188
2063
  },
1189
2064
  "slides": slides,
1190
2065
  }
@@ -1193,6 +2068,107 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
1193
2068
  return result
1194
2069
 
1195
2070
 
2071
+ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
2072
+ root, xml_error = parse_xml_root(xml)
2073
+ if xml_error:
2074
+ issue = normalize_issue(xml_error, None, {})
2075
+ return build_result(
2076
+ source_path,
2077
+ {"width": 960, "height": 540},
2078
+ [issue],
2079
+ [],
2080
+ )
2081
+ if root is None:
2082
+ raise AssertionError("parse_xml_root must return a root or error")
2083
+
2084
+ namespace_issues = validate_sml_tag_prefixes(xml)
2085
+ sxsd_issues = validate_sxsd_tag_attributes(root)
2086
+ iconpark_issues = validate_iconpark_icon_types(root)
2087
+ top_level_issues = [
2088
+ normalize_issue(issue, None, {})
2089
+ for issue in [*namespace_issues, *sxsd_issues, *iconpark_issues]
2090
+ ]
2091
+ if any(issue["level"] == "error" for issue in top_level_issues):
2092
+ return build_result(
2093
+ source_path,
2094
+ {"width": 960, "height": 540},
2095
+ top_level_issues,
2096
+ [],
2097
+ )
2098
+
2099
+ presentation = parse_presentation(xml)
2100
+ slides: list[dict[str, Any]] = []
2101
+ for index, slide_xml in enumerate(presentation["slides"]):
2102
+ slide_number = index + 1
2103
+ geometry = lint_slide(
2104
+ slide_xml,
2105
+ slide_number,
2106
+ presentation["width"],
2107
+ presentation["height"],
2108
+ )
2109
+ density_elements = extract_density_elements(slide_xml)
2110
+ extra_elements = [
2111
+ element for element in density_elements if element["kind"] in {"icon", "polyline", "line"}
2112
+ ]
2113
+ elements_by_id = {
2114
+ element["id"]: element for element in [*density_elements, *extra_elements]
2115
+ }
2116
+ # geometry["elements"] are the exact objects should_flag_overlap/detect_elements_out_of_canvas
2117
+ # decided with inside lint_slide; prefer them so measurement/related_objects stay consistent
2118
+ # with whatever actually triggered the issue, instead of density_elements' separate re-parse.
2119
+ elements_by_id.update({element["id"]: element for element in geometry["elements"]})
2120
+ extra_overflow_issues = detect_elements_out_of_canvas(
2121
+ extra_elements,
2122
+ presentation["width"],
2123
+ presentation["height"],
2124
+ )
2125
+ raw_issues = [
2126
+ *geometry["issues"],
2127
+ *extra_overflow_issues,
2128
+ *detect_blank_slide(
2129
+ density_elements,
2130
+ slide_number,
2131
+ presentation["width"],
2132
+ presentation["height"],
2133
+ ),
2134
+ *detect_sparse_container_content(
2135
+ density_elements,
2136
+ slide_number,
2137
+ presentation["width"],
2138
+ presentation["height"],
2139
+ ),
2140
+ *detect_sparse_slide_content(
2141
+ density_elements,
2142
+ slide_number,
2143
+ presentation["width"],
2144
+ presentation["height"],
2145
+ ),
2146
+ ]
2147
+ issues = [
2148
+ normalize_issue(issue, slide_number, elements_by_id)
2149
+ for issue in raw_issues
2150
+ ]
2151
+ errors = [issue for issue in issues if issue["level"] == "error"]
2152
+ warnings = [issue for issue in issues if issue["level"] == "warning"]
2153
+ slides.append(
2154
+ {
2155
+ "slide_number": slide_number,
2156
+ "status": slide_status(errors, warnings),
2157
+ "element_count": len(elements_by_id),
2158
+ "errors": errors,
2159
+ "warnings": warnings,
2160
+ "issues": issues,
2161
+ }
2162
+ )
2163
+
2164
+ return build_result(
2165
+ source_path,
2166
+ {"width": presentation["width"], "height": presentation["height"]},
2167
+ top_level_issues,
2168
+ slides,
2169
+ )
2170
+
2171
+
1196
2172
  def print_usage() -> None:
1197
2173
  print("Usage:\n python3 xml_text_overlap_lint.py --input <presentation.xml>", file=sys.stderr)
1198
2174
 
@@ -1215,6 +2191,6 @@ def run_cli(argv: list[str] | None = None) -> None:
1215
2191
  if __name__ == "__main__":
1216
2192
  try:
1217
2193
  run_cli()
1218
- except XmlTextOverlapLintError as error:
2194
+ except XmlLayoutLintError as error:
1219
2195
  print(f"xml-text-overlap-lint error: {error}", file=sys.stderr)
1220
2196
  raise SystemExit(1) from error