@amaster.ai/pi-lark 0.1.2-beta.43 → 0.1.2-beta.45

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 (131) hide show
  1. package/package.json +2 -2
  2. package/skills/lark-approval/references/lark-approval-initiate.md +2 -5
  3. package/skills/lark-approval/references/lark-approval-instances-initiated.md +6 -0
  4. package/skills/lark-approval/references/lark-approval-tasks-query.md +9 -0
  5. package/skills/lark-approval/references/lark-approval-tasks-rollback.md +8 -2
  6. package/skills/lark-apps/SKILL.md +23 -12
  7. package/skills/lark-apps/creative-design/agents/assets/vision-probe.png +0 -0
  8. package/skills/lark-apps/creative-design/agents/fork-verifier-agent.md +71 -0
  9. package/skills/lark-apps/creative-design/agents/vision-probe-agent.md +41 -0
  10. package/skills/lark-apps/creative-design/assets/index.html +27 -0
  11. package/skills/lark-apps/creative-design/creative-design.md +239 -0
  12. package/skills/lark-apps/creative-design/references/aily.md +39 -0
  13. package/skills/lark-apps/creative-design/references/animated-video.md +34 -0
  14. package/skills/lark-apps/creative-design/references/charts.md +165 -0
  15. package/skills/lark-apps/creative-design/references/claude.md +36 -0
  16. package/skills/lark-apps/creative-design/references/codex.md +32 -0
  17. package/skills/lark-apps/creative-design/references/data-report.md +108 -0
  18. package/skills/lark-apps/creative-design/references/frontend-design.md +71 -0
  19. package/skills/lark-apps/creative-design/references/hi-fi-design.md +32 -0
  20. package/skills/lark-apps/creative-design/references/interactive-prototype.md +24 -0
  21. package/skills/lark-apps/creative-design/references/make-a-deck.md +133 -0
  22. package/skills/lark-apps/creative-design/references/visual-exposure.md +82 -0
  23. package/skills/lark-apps/creative-design/references/wireframe.md +14 -0
  24. package/skills/lark-apps/creative-design/starter-components/android-frame.jsx +188 -0
  25. package/skills/lark-apps/creative-design/starter-components/animations.jsx +773 -0
  26. package/skills/lark-apps/creative-design/starter-components/browser-window.jsx +122 -0
  27. package/skills/lark-apps/creative-design/starter-components/deck-stage.js +2483 -0
  28. package/skills/lark-apps/creative-design/starter-components/design-canvas.jsx +1432 -0
  29. package/skills/lark-apps/creative-design/starter-components/ios-frame.jsx +270 -0
  30. package/skills/lark-apps/creative-design/starter-components/macos-window.jsx +197 -0
  31. package/skills/lark-apps/creative-design/starter-components/tweaks-panel.jsx +752 -0
  32. package/skills/lark-apps/references/lark-apps-automation.md +80 -2
  33. package/skills/lark-apps/references/lark-apps-cloud-dev.md +0 -1
  34. package/skills/lark-apps/references/lark-apps-create.md +1 -2
  35. package/skills/lark-apps/references/lark-apps-db.md +1 -1
  36. package/skills/lark-apps/references/lark-apps-env-pull.md +1 -1
  37. package/skills/lark-apps/references/lark-apps-file.md +1 -1
  38. package/skills/lark-apps/references/lark-apps-git-credential.md +1 -1
  39. package/skills/lark-apps/references/lark-apps-html-publish.md +4 -8
  40. package/skills/lark-apps/references/lark-apps-init.md +1 -1
  41. package/skills/lark-apps/references/lark-apps-list.md +1 -1
  42. package/skills/lark-apps/references/lark-apps-local-dev.md +54 -11
  43. package/skills/lark-apps/references/lark-apps-openapi-key.md +1 -1
  44. package/skills/lark-apps/references/lark-apps-release-create.md +2 -2
  45. package/skills/lark-apps/references/lark-apps-release-get.md +3 -3
  46. package/skills/lark-base/SKILL.md +6 -3
  47. package/skills/lark-base/references/lark-base-cell-value.md +3 -3
  48. package/skills/lark-base/references/lark-base-field-create.md +4 -0
  49. package/skills/lark-base/references/lark-base-field-json.md +4 -4
  50. package/skills/lark-base/references/lark-base-field-update.md +17 -1
  51. package/skills/lark-base/references/lark-base-record-batch-create.md +12 -10
  52. package/skills/lark-base/references/lark-base-record-batch-update.md +11 -9
  53. package/skills/lark-base/references/lark-base-record-upsert.md +1 -1
  54. package/skills/lark-base/references/lark-base-view-set-filter.md +3 -1
  55. package/skills/lark-calendar/references/lark-calendar-create.md +1 -0
  56. package/skills/lark-calendar/references/lark-calendar-update.md +3 -0
  57. package/skills/lark-doc/references/lark-doc-fetch.md +10 -2
  58. package/skills/lark-doc/references/lark-doc-whiteboard.md +9 -8
  59. package/skills/lark-doc/references/lark-doc-xml-extended-blocks.md +41 -0
  60. package/skills/lark-doc/references/lark-doc-xml.md +3 -2
  61. package/skills/lark-drive/SKILL.md +4 -1
  62. package/skills/lark-drive/references/lark-drive-comment-location.md +2 -2
  63. package/skills/lark-drive/references/lark-drive-upload.md +1 -0
  64. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-execute.md +273 -0
  65. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-recall.md +202 -0
  66. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-resolve-verify.md +231 -0
  67. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-review-plan.md +248 -0
  68. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector-setup.md +174 -0
  69. package/skills/lark-drive/references/lark-drive-workflow-topic-move-collector.md +202 -0
  70. package/skills/lark-drive/references/lark-drive-workflow.md +5 -4
  71. package/skills/lark-event/SKILL.md +3 -1
  72. package/skills/lark-event/references/lark-event-application.md +38 -0
  73. package/skills/lark-event/references/lark-event-approval.md +170 -0
  74. package/skills/lark-im/SKILL.md +1 -1
  75. package/skills/lark-im/references/lark-im-flag-list.md +8 -7
  76. package/skills/lark-okr/SKILL.md +71 -26
  77. package/skills/lark-okr/references/lark-okr-batch-create.md +19 -18
  78. package/skills/lark-okr/references/lark-okr-create.md +173 -0
  79. package/skills/lark-okr/references/lark-okr-cycle-list.md +17 -7
  80. package/skills/lark-okr/references/lark-okr-entities.md +1 -0
  81. package/skills/lark-okr/references/lark-okr-indicator-update.md +3 -1
  82. package/skills/lark-okr/references/lark-okr-indicators.md +61 -12
  83. package/skills/lark-okr/references/lark-okr-progress-list.md +21 -9
  84. package/skills/lark-slides/SKILL.md +103 -46
  85. package/skills/lark-slides/references/asset-planning.md +6 -4
  86. package/skills/lark-slides/references/iconpark.md +2 -2
  87. package/skills/lark-slides/references/lark-slides-create.md +2 -3
  88. package/skills/lark-slides/references/lark-slides-history.md +132 -0
  89. package/skills/lark-slides/references/lark-slides-media-upload.md +1 -2
  90. package/skills/lark-slides/references/lark-slides-pptx-template-workflows.md +7 -11
  91. package/skills/lark-slides/references/lark-slides-replace-slide.md +0 -3
  92. package/skills/lark-slides/references/lark-slides-screenshot.md +1 -1
  93. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-create.md +219 -0
  94. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-delete.md +6 -5
  95. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-get.md +2 -2
  96. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-replace.md +2 -3
  97. package/skills/lark-slides/references/lark-slides-xml-presentations-get.md +65 -30
  98. package/skills/lark-slides/references/planning-layer.md +11 -10
  99. package/skills/lark-slides/references/slides_chart_demo.xml +1416 -1
  100. package/skills/lark-slides/references/slides_xml_schema_definition.xml +1 -45
  101. package/skills/lark-slides/references/troubleshooting.md +25 -7
  102. package/skills/lark-slides/references/validation-checklist.md +33 -13
  103. package/skills/lark-slides/references/visual-planning.md +25 -22
  104. package/skills/lark-slides/references/xml-schema-quick-ref.md +225 -46
  105. package/skills/lark-slides/scripts/xml_text_overlap_lint.py +487 -28
  106. package/skills/lark-slides/scripts/xml_text_overlap_lint_test.py +477 -76
  107. package/skills/lark-vc/SKILL.md +6 -3
  108. package/skills/lark-vc/references/vc-domain-boundaries.md +9 -1
  109. package/skills/lark-vc-agent/SKILL.md +1 -1
  110. package/skills/lark-whiteboard/SKILL.md +13 -12
  111. package/skills/lark-whiteboard/elements/layout.md +1 -1
  112. package/skills/lark-whiteboard/elements/schema.md +2 -2
  113. package/skills/lark-whiteboard/references/{lark-whiteboard-query.md → lark-whiteboard-export.md} +15 -15
  114. package/skills/lark-whiteboard/references/lark-whiteboard-update.md +3 -3
  115. package/skills/lark-whiteboard/references/lark-whiteboard-workflow.md +7 -17
  116. package/skills/lark-whiteboard/routes/dsl.md +3 -3
  117. package/skills/lark-whiteboard/routes/mermaid.md +2 -2
  118. package/skills/lark-whiteboard/routes/svg-edit.md +4 -4
  119. package/skills/lark-whiteboard/routes/svg.md +11 -6
  120. package/skills/lark-whiteboard/scenes/bar-chart.md +1 -1
  121. package/skills/lark-whiteboard/scenes/fishbone.md +1 -1
  122. package/skills/lark-whiteboard/scenes/flywheel.md +1 -1
  123. package/skills/lark-whiteboard/scenes/line-chart.md +1 -1
  124. package/skills/lark-whiteboard/scenes/treemap.md +1 -1
  125. package/skills/lark-wiki/SKILL.md +1 -0
  126. package/skills/lark-slides/references/examples.md +0 -91
  127. package/skills/lark-slides/references/lark-slides-whiteboard.md +0 -331
  128. package/skills/lark-slides/references/lark-slides-xml-get.md +0 -100
  129. package/skills/lark-slides/references/slide-templates.md +0 -201
  130. package/skills/lark-slides/references/slides_demo.xml +0 -226
  131. package/skills/lark-slides/references/xml-format-guide.md +0 -438
@@ -38,6 +38,12 @@ SXSD_ATTR_ALIASES = {
38
38
  "fontColor": "color",
39
39
  }
40
40
  SERVER_FILLED_SXSD_ATTRS = {"id"}
41
+ ROUNDTRIP_SXSD_ATTRS = {
42
+ ("chart", "updated"),
43
+ ("chartData", "isStaticData"),
44
+ }
45
+ DEFAULT_TABLE_COLUMN_WIDTH = 110
46
+ DEFAULT_TABLE_ROW_HEIGHT = 37
41
47
  _SXSD_TAG_ATTRIBUTES_CACHE: dict[str, set[str]] | None = None
42
48
  _ICONPARK_ICON_TYPES_CACHE: set[str] | None = None
43
49
 
@@ -60,7 +66,7 @@ def parse_args(argv: list[str]) -> dict[str, Any]:
60
66
  while index < len(argv):
61
67
  token = argv[index]
62
68
  if not token.startswith("--"):
63
- fail(f"unexpected argument: {token}")
69
+ fail(f"unexpected argument: {token}, need --input")
64
70
  key = token[2:]
65
71
  next_token = argv[index + 1] if index + 1 < len(argv) else None
66
72
  if next_token is None or next_token.startswith("--"):
@@ -88,8 +94,88 @@ def extract_numeric_attribute(tag_source: str, name: str) -> int | float | None:
88
94
  return int(value) if value.is_integer() else value
89
95
 
90
96
 
91
- def strip_xml(value: str) -> str:
97
+ def sum_sizes(sizes: list[int | float]) -> int | float:
98
+ return sum(sizes)
99
+
100
+
101
+ def is_filled_size(size: int | float | None) -> bool:
102
+ return isinstance(size, (int, float)) and math.isfinite(size) and size > 0
103
+
104
+
105
+ def fill_last_size_gap(sizes: list[int | float], target_size: int | float) -> list[int | float]:
106
+ if not sizes:
107
+ return sizes
108
+ final_sizes = [
109
+ size if index == len(sizes) - 1 else max(1, math.floor(size + 0.5))
110
+ for index, size in enumerate(sizes)
111
+ ]
112
+ remaining_size = target_size - sum_sizes(final_sizes[:-1])
113
+ if remaining_size >= 1:
114
+ final_sizes[-1] = remaining_size
115
+ return final_sizes
116
+
117
+ size_to_redistribute = 1 - remaining_size
118
+ for index in range(len(final_sizes) - 2, -1, -1):
119
+ reduction = min(final_sizes[index] - 1, size_to_redistribute)
120
+ final_sizes[index] -= reduction
121
+ size_to_redistribute -= reduction
122
+ if size_to_redistribute == 0:
123
+ final_sizes[-1] = 1
124
+ return final_sizes
125
+
126
+ final_sizes[-1] = 1
127
+ return final_sizes
128
+
129
+
130
+ def solve_weighted_min_layout(
131
+ input_sizes: list[int | float | None], default_size: int | float, target_min_size: int | float | None
132
+ ) -> dict[str, Any]:
133
+ filled_indexes: list[int] = []
134
+ empty_indexes: list[int] = []
135
+ base_sizes: list[int | float] = []
136
+ for index, size in enumerate(input_sizes):
137
+ if is_filled_size(size):
138
+ filled_indexes.append(index)
139
+ base_sizes.append(size)
140
+ else:
141
+ empty_indexes.append(index)
142
+ base_sizes.append(0)
143
+ filled_sum = sum_sizes(base_sizes)
144
+
145
+ if target_min_size is None:
146
+ final_sizes = [default_size if index in empty_indexes else size for index, size in enumerate(base_sizes)]
147
+ return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": 1}
148
+
149
+ if not filled_indexes:
150
+ average_size = target_min_size / len(input_sizes)
151
+ final_sizes = fill_last_size_gap([average_size] * len(input_sizes), target_min_size)
152
+ return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": 1}
153
+
154
+ if empty_indexes:
155
+ remaining_size = target_min_size - filled_sum
156
+ final_sizes = [*base_sizes]
157
+ if remaining_size > 0:
158
+ average_size = remaining_size / len(empty_indexes)
159
+ empty_sizes = fill_last_size_gap([average_size] * len(empty_indexes), remaining_size)
160
+ for index, empty_size in zip(empty_indexes, empty_sizes):
161
+ final_sizes[index] = empty_size
162
+ else:
163
+ for index in empty_indexes:
164
+ final_sizes[index] = default_size
165
+ return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": 1}
166
+
167
+ ratio = max(1, target_min_size / filled_sum)
168
+ actual_size = max(target_min_size, filled_sum)
169
+ if ratio == 1:
170
+ return {"final_sizes": [*base_sizes], "actual_size": actual_size, "ratio": ratio}
171
+ final_sizes = fill_last_size_gap([size * ratio for size in base_sizes], actual_size)
172
+ return {"final_sizes": final_sizes, "actual_size": sum_sizes(final_sizes), "ratio": ratio}
173
+
174
+
175
+ def strip_xml(value: str, preserve_line_breaks: bool = False) -> str:
92
176
  stripped = re.sub(r"<!\[CDATA\[([\s\S]*?)\]\]>", r"\1", value)
177
+ if preserve_line_breaks:
178
+ stripped = re.sub(r"<br\b[^>]*>", "\n", stripped)
93
179
  stripped = re.sub(r"<[^>]+>", " ", stripped)
94
180
  stripped = stripped.replace("&nbsp;", " ")
95
181
  stripped = stripped.replace("&amp;", "&")
@@ -97,14 +183,46 @@ def strip_xml(value: str) -> str:
97
183
  stripped = stripped.replace("&gt;", ">")
98
184
  stripped = stripped.replace("&quot;", '"')
99
185
  stripped = stripped.replace("&#39;", "'")
186
+ if preserve_line_breaks:
187
+ return "\n".join(re.sub(r"\s+", " ", line).strip() for line in stripped.split("\n"))
100
188
  return re.sub(r"\s+", " ", stripped).strip()
101
189
 
102
190
 
103
191
  def strip_xml_paragraphs(value: str) -> str:
104
192
  paragraphs = re.findall(r"<p\b[^>]*>([\s\S]*?)</p\s*>", value)
105
193
  if paragraphs:
106
- return "\n".join(strip_xml(paragraph) for paragraph in paragraphs)
107
- return strip_xml(value)
194
+ return "\n".join(strip_xml(paragraph, preserve_line_breaks=True) for paragraph in paragraphs)
195
+ return strip_xml(value, preserve_line_breaks=True)
196
+
197
+
198
+ def extract_text_paragraphs(value: str, default_font_size: int | float) -> list[dict[str, Any]]:
199
+ paragraphs = []
200
+ for attrs, body in re.findall(r"<p\b([^>]*)>([\s\S]*?)</p\s*>", value):
201
+ paragraphs.append(
202
+ {
203
+ "text": strip_xml(body, preserve_line_breaks=True),
204
+ "fontSize": extract_max_span_font_size(body, default_font_size),
205
+ "textAlign": extract_attribute(attrs, "textAlign"),
206
+ "lineSpacing": extract_attribute(attrs, "lineSpacing"),
207
+ "beforeLineSpacing": extract_attribute(attrs, "beforeLineSpacing"),
208
+ "afterLineSpacing": extract_attribute(attrs, "afterLineSpacing"),
209
+ }
210
+ )
211
+ return paragraphs
212
+
213
+
214
+ def extract_max_span_font_size(value: str, default_font_size: int | float) -> int | float:
215
+ font_sizes = [
216
+ font_size
217
+ for attrs in re.findall(r"<span\b([^>]*)>", value)
218
+ if (font_size := extract_numeric_attribute(attrs, "fontSize")) is not None
219
+ ]
220
+ return max([default_font_size, *font_sizes])
221
+
222
+
223
+ def extract_tag_attributes(value: str, tag: str) -> str:
224
+ match = re.search(fr"<{re.escape(tag)}\b([^>]*)>", value)
225
+ return match.group(1) if match else ""
108
226
 
109
227
 
110
228
  def xml_local_name(tag: str) -> str:
@@ -239,8 +357,8 @@ def should_skip_sxsd_subtree(element: ET.Element, ancestors: list[str]) -> bool:
239
357
  return "whiteboard" in ancestors and xml_namespace(element.tag) == SVG_NS
240
358
 
241
359
 
242
- def should_skip_sxsd_attribute(attr_name: str) -> bool:
243
- return attr_name in SERVER_FILLED_SXSD_ATTRS
360
+ def should_skip_sxsd_attribute(tag_name: str, attr_name: str) -> bool:
361
+ return attr_name in SERVER_FILLED_SXSD_ATTRS or (tag_name, attr_name) in ROUNDTRIP_SXSD_ATTRS
244
362
 
245
363
 
246
364
  def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
@@ -272,7 +390,7 @@ def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
272
390
  if raw_attr_name.startswith(XML_NS):
273
391
  continue
274
392
  attr_name = xml_local_name(raw_attr_name)
275
- if should_skip_sxsd_attribute(attr_name):
393
+ if should_skip_sxsd_attribute(tag_name, attr_name):
276
394
  continue
277
395
  if attr_name in allowed_attrs:
278
396
  continue
@@ -515,8 +633,8 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
515
633
  for match in re.finditer(r"<(shape|img|table|chart|whiteboard)\b([^>]*)>", slide_xml):
516
634
  kind, attrs = match.group(1), match.group(2)
517
635
  content = ""
518
- if kind == "shape":
519
- close_index = slide_xml.find("</shape>", match.end())
636
+ if kind in {"shape", "table"}:
637
+ close_index = slide_xml.find(f"</{kind}>", match.end())
520
638
  if close_index != -1:
521
639
  content = slide_xml[match.end() : close_index]
522
640
 
@@ -525,6 +643,16 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
525
643
  y = extract_numeric_attribute(attrs, "topLeftY")
526
644
  width = extract_numeric_attribute(attrs, "width")
527
645
  height = extract_numeric_attribute(attrs, "height")
646
+ rotation = extract_numeric_attribute(attrs, "rotation") or 0
647
+ alpha = extract_numeric_attribute(attrs, "alpha")
648
+ table_layouts: dict[str, dict[str, Any] | None] = {}
649
+ if kind == "table":
650
+ width, table_layouts["width"] = resolve_table_dimension(
651
+ content, width, extract_table_column_sizes, DEFAULT_TABLE_COLUMN_WIDTH
652
+ )
653
+ height, table_layouts["height"] = resolve_table_dimension(
654
+ content, height, extract_table_row_sizes, DEFAULT_TABLE_ROW_HEIGHT
655
+ )
528
656
  if all(value is not None for value in [x, y, width, height]):
529
657
  element = {
530
658
  "id": element_id,
@@ -534,18 +662,41 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
534
662
  "y": y,
535
663
  "width": width,
536
664
  "height": height,
665
+ "rotation": rotation,
666
+ "alpha": alpha if alpha is not None else 1,
537
667
  "order": len(elements),
538
668
  }
669
+ if kind == "table":
670
+ element.update(
671
+ {
672
+ "declared_width": extract_numeric_attribute(attrs, "width"),
673
+ "declared_height": extract_numeric_attribute(attrs, "height"),
674
+ "table_layouts": table_layouts,
675
+ }
676
+ )
539
677
  if kind == "shape":
678
+ content_attrs = extract_tag_attributes(content, "content")
679
+ font_size = extract_numeric_attribute(content_attrs, "fontSize")
680
+ if font_size is None:
681
+ font_size = extract_numeric_attribute(attrs, "fontSize")
540
682
  element.update(
541
683
  {
542
- "textType": extract_attribute(content, "textType"),
543
- "textAlign": extract_attribute(content, "textAlign"),
544
- "autoFit": extract_attribute(content, "autoFit"),
545
- "fontSize": float(
546
- extract_attribute(content, "fontSize") or extract_attribute(attrs, "fontSize") or 16
547
- ),
684
+ "textType": extract_attribute(content_attrs, "textType"),
685
+ "textAlign": extract_attribute(content_attrs, "textAlign"),
686
+ "verticalAlign": extract_attribute(content_attrs, "verticalAlign") or "middle",
687
+ "vert": extract_attribute(attrs, "vert") or "horz",
688
+ "autoFit": extract_attribute(content_attrs, "autoFit"),
689
+ "wrap": extract_attribute(content_attrs, "wrap"),
690
+ "lineSpacing": extract_attribute(content_attrs, "lineSpacing"),
691
+ "beforeLineSpacing": extract_attribute(content_attrs, "beforeLineSpacing"),
692
+ "afterLineSpacing": extract_attribute(content_attrs, "afterLineSpacing"),
693
+ "paddingTop": extract_numeric_attribute(content_attrs, "paddingTop") or 0,
694
+ "paddingRight": extract_numeric_attribute(content_attrs, "paddingRight") or 0,
695
+ "paddingBottom": extract_numeric_attribute(content_attrs, "paddingBottom") or 0,
696
+ "paddingLeft": extract_numeric_attribute(content_attrs, "paddingLeft") or 0,
697
+ "fontSize": font_size if font_size is not None else 16,
548
698
  "text": strip_xml_paragraphs(content),
699
+ "paragraphs": extract_text_paragraphs(content, font_size if font_size is not None else 16),
549
700
  }
550
701
  )
551
702
  elements.append(element)
@@ -573,6 +724,40 @@ def has_text_content(element: dict[str, Any]) -> bool:
573
724
  return bool(element.get("text"))
574
725
 
575
726
 
727
+ def is_vertical_text(element: dict[str, Any]) -> bool:
728
+ return element.get("vert") in {"vert", "vert270", "word-art-vert", "word-art-vert-rtl", "ea-vert"}
729
+
730
+
731
+ def detect_image_text_occlusions(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
732
+ issues: list[dict[str, Any]] = []
733
+ text_elements = [element for element in elements if is_text_element(element) and has_text_content(element)]
734
+ image_elements = [element for element in elements if element["kind"] == "img" and element["alpha"] > 0]
735
+ for text_element in text_elements:
736
+ for image_element in image_elements:
737
+ if image_element["order"] <= text_element["order"]:
738
+ continue
739
+ if is_vertical_text(text_element):
740
+ if intersects(image_element, text_element):
741
+ issues.append({
742
+ "level": "info",
743
+ "code": "image_may_cover_vertical_text",
744
+ "elements": [image_element["id"], text_element["id"]],
745
+ "message": f'image {image_element["id"]} may cover vertical text shape {text_element["id"]}',
746
+ "hint": "Inspect the rendered slide because vertical text layout is not statically modeled.",
747
+ })
748
+ continue
749
+ text_visual_bbox = estimate_text_visual_bbox(text_element)
750
+ if text_visual_bbox is not None and intersects(image_element, text_visual_bbox):
751
+ issues.append({
752
+ "level": "error",
753
+ "code": "image_covers_text",
754
+ "elements": [image_element["id"], text_element["id"]],
755
+ "message": f'image {image_element["id"]} covers text shape {text_element["id"]}',
756
+ "hint": "Move the image before the text shape in XML order, or adjust the image and text shape coordinates or dimensions.",
757
+ })
758
+ return issues
759
+
760
+
576
761
  def is_decorative_text(element: dict[str, Any]) -> bool:
577
762
  text = element.get("text") or ""
578
763
  return bool(text) and re.search(r"[A-Za-z0-9\u4e00-\u9fff]", text) is None
@@ -610,27 +795,135 @@ def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool
610
795
  return SequenceMatcher(None, left_text, right_text).ratio() >= 0.75
611
796
 
612
797
 
613
- def estimate_text_line_count(element: dict[str, Any]) -> int:
798
+ def estimate_text_line_count_for_text(element: dict[str, Any], text: str) -> int:
614
799
  font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
615
- paragraphs = [paragraph for paragraph in re.split(r"\n+", element["text"]) if paragraph]
800
+ hard_lines = text.split("\n")
801
+ if not text:
802
+ return 0
616
803
  line_count = 0
617
- for paragraph in paragraphs:
618
- logical_width = max(estimate_text_width(paragraph, font_size), 1)
804
+ for hard_line in hard_lines:
805
+ if element.get("wrap") in {"false", "0"}:
806
+ line_count += 1
807
+ continue
808
+ logical_width = max(estimate_text_width(hard_line, font_size), 1)
619
809
  line_count += max(1, math.ceil(logical_width / max(element["width"], 1)))
620
- return max(line_count, 1)
810
+ return line_count
811
+
812
+
813
+ def estimate_text_line_count(element: dict[str, Any]) -> int:
814
+ return max(estimate_text_line_count_for_text(element, element["text"]), 1)
815
+
816
+
817
+ def estimate_text_line_height(element: dict[str, Any], line_spacing: str | None = None) -> int | float | None:
818
+ font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
819
+ line_spacing = line_spacing or "multiple:1.5"
820
+ match = re.fullmatch(r"(multiple|fixed):([0-9]+(?:\.[0-9]+)?)", line_spacing)
821
+ if match is None:
822
+ return None
823
+ spacing_type, value = match.groups()
824
+ return font_size * float(value) if spacing_type == "multiple" else float(value)
825
+
826
+
827
+ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
828
+ issues: list[dict[str, Any]] = []
829
+ for element in elements:
830
+ if not is_text_element(element) or not has_text_content(element):
831
+ continue
832
+ if element.get("autoFit") in {"normal-auto-fit", "shape-auto-fit"}:
833
+ continue
834
+
835
+ font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
836
+ paragraphs = element.get("paragraphs") or [
837
+ {
838
+ "text": element["text"],
839
+ "lineSpacing": None,
840
+ "beforeLineSpacing": None,
841
+ "afterLineSpacing": None,
842
+ }
843
+ ]
844
+ line_count = 0
845
+ estimated_height = 0.0
846
+ line_heights: list[int | float] = []
847
+ for paragraph in paragraphs:
848
+ paragraph_line_count = estimate_text_line_count_for_text(element, paragraph["text"])
849
+ if paragraph_line_count == 0:
850
+ continue
851
+ line_height = estimate_text_line_height(element, paragraph["lineSpacing"] or element["lineSpacing"])
852
+ before_spacing = estimate_text_line_height(
853
+ element, paragraph["beforeLineSpacing"] or element["beforeLineSpacing"] or "fixed:0"
854
+ )
855
+ after_spacing = estimate_text_line_height(
856
+ element, paragraph["afterLineSpacing"] or element["afterLineSpacing"] or "fixed:0"
857
+ )
858
+ if line_height is None or before_spacing is None or after_spacing is None:
859
+ line_count = 0
860
+ break
861
+ first_line_height = font_size if line_count == 0 else line_height
862
+ line_count += paragraph_line_count
863
+ line_heights.append(line_height)
864
+ estimated_height += (
865
+ before_spacing + first_line_height + max(paragraph_line_count - 1, 0) * line_height + after_spacing
866
+ )
867
+ if line_count == 0:
868
+ continue
869
+ available_height = max(element["height"] - element["paddingTop"] - element["paddingBottom"], 0)
870
+ overflow = estimated_height - available_height
871
+ if overflow <= 0:
872
+ continue
873
+
874
+ issues.append(
875
+ {
876
+ "level": "warning",
877
+ "code": "text_may_overflow_shape",
878
+ "elements": [element["id"]],
879
+ "line_count": line_count,
880
+ "line_height": max(line_heights),
881
+ "estimated_height": estimated_height,
882
+ "available_height": available_height,
883
+ "overflow": overflow,
884
+ "message": (
885
+ f'text shape {element["id"]} may overflow its own content box '
886
+ f'(estimated {estimated_height:g}px, available {available_height:g}px); '
887
+ 'consider setting content wrap="true" autoFit="normal-auto-fit"'
888
+ ),
889
+ "hint": (
890
+ "Increase shape.height, reduce the text, or set content wrap=\"true\" "
891
+ "autoFit=\"normal-auto-fit\". "
892
+ "This is an estimate based on font size, line spacing, and wrapped line count."
893
+ ),
894
+ }
895
+ )
896
+ return issues
621
897
 
622
898
 
623
899
  def estimate_text_visual_bbox(element: dict[str, Any]) -> dict[str, int | float] | None:
624
900
  if not is_text_element(element) or not has_text_content(element) or is_decorative_text(element):
625
901
  return None
626
902
 
903
+ padding_left = element.get("paddingLeft", 0)
904
+ padding_right = element.get("paddingRight", 0)
905
+ padding_top = element.get("paddingTop", 0)
906
+ padding_bottom = element.get("paddingBottom", 0)
907
+ content_width = max(element["width"] - padding_left - padding_right, 0)
908
+ content_height = max(element["height"] - padding_top - padding_bottom, 0)
627
909
  font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
628
910
  line_count = estimate_text_line_count(element)
629
- visual_width = min(element["width"], max(1, estimate_text_max_line_width(element)))
630
- visual_height = min(element["height"], max(1, line_count * font_size * 1.2))
911
+ estimated_width = max(1, estimate_text_max_line_width(element))
912
+ visual_width = estimated_width if element.get("wrap") in {"false", "0"} else min(content_width, estimated_width)
913
+ visual_height = min(content_height, max(1, line_count * font_size * 1.2))
914
+ x = element["x"] + padding_left
915
+ if element.get("textAlign") == "center":
916
+ x += (content_width - visual_width) / 2
917
+ elif element.get("textAlign") == "right":
918
+ x += content_width - visual_width
919
+ y = element["y"] + padding_top
920
+ if element.get("verticalAlign") == "middle":
921
+ y += (content_height - visual_height) / 2
922
+ elif element.get("verticalAlign") == "bottom":
923
+ y += content_height - visual_height
631
924
  return {
632
- "x": element["x"],
633
- "y": element["y"],
925
+ "x": x,
926
+ "y": y,
634
927
  "width": visual_width,
635
928
  "height": visual_height,
636
929
  }
@@ -720,6 +1013,10 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
720
1013
  source, target = sorted([left, right], key=lambda element: element["x"])
721
1014
  if source["x"] == target["x"]:
722
1015
  return False
1016
+ wrap_enabled = source.get("wrap") not in {"false", "0"}
1017
+ has_horizontal_gap = source["x"] + source["width"] <= target["x"]
1018
+ if wrap_enabled and has_horizontal_gap:
1019
+ return False
723
1020
  if source.get("autoFit") == "normal-auto-fit":
724
1021
  return False
725
1022
  if source.get("textAlign") in {"center", "right"}:
@@ -867,11 +1164,160 @@ def detect_whiteboard_external_overlaps(
867
1164
  return issues
868
1165
 
869
1166
 
1167
+ def element_canvas_bbox(element: dict[str, Any]) -> dict[str, int | float]:
1168
+ bbox = {key: element[key] for key in ("x", "y", "width", "height")}
1169
+ if element["kind"] != "chart" and not (element["kind"] == "shape" and element["type"] == "text"):
1170
+ return bbox
1171
+
1172
+ rotation = element["rotation"]
1173
+ if not isinstance(rotation, (int, float)) or not math.isfinite(rotation):
1174
+ rotation = 0
1175
+ rotation %= 360
1176
+ if math.isclose(rotation, 0, abs_tol=1e-9):
1177
+ return bbox
1178
+ radians = math.radians(rotation)
1179
+ sine = abs(math.sin(radians))
1180
+ cosine = abs(math.cos(radians))
1181
+ sine = 0 if math.isclose(sine, 0, abs_tol=1e-12) else sine
1182
+ cosine = 0 if math.isclose(cosine, 0, abs_tol=1e-12) else cosine
1183
+ rotated_width = element["width"] * cosine + element["height"] * sine
1184
+ rotated_height = element["width"] * sine + element["height"] * cosine
1185
+ return {
1186
+ "x": element["x"] - (rotated_width - element["width"]) / 2,
1187
+ "y": element["y"] - (rotated_height - element["height"]) / 2,
1188
+ "width": rotated_width,
1189
+ "height": rotated_height,
1190
+ }
1191
+
1192
+
1193
+ def detect_elements_out_of_canvas(
1194
+ elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
1195
+ ) -> list[dict[str, Any]]:
1196
+ issues: list[dict[str, Any]] = []
1197
+ for element in (
1198
+ element
1199
+ for element in elements
1200
+ if element["kind"] in {"table", "chart"}
1201
+ or (element["kind"] == "shape" and element["type"] == "text")
1202
+ ):
1203
+ bbox = element_canvas_bbox(element)
1204
+ overflow = {
1205
+ "left": max(-bbox["x"], 0),
1206
+ "top": max(-bbox["y"], 0),
1207
+ "right": max(bbox["x"] + bbox["width"] - slide_width, 0),
1208
+ "bottom": max(bbox["y"] + bbox["height"] - slide_height, 0),
1209
+ }
1210
+ overflow_details = [
1211
+ f"{side} by {amount:g}px" for side, amount in overflow.items() if amount > 0
1212
+ ]
1213
+ if not overflow_details:
1214
+ continue
1215
+ issues.append(
1216
+ {
1217
+ "level": "error",
1218
+ "code": f'{element["kind"]}_out_of_canvas',
1219
+ "elements": [element["id"]],
1220
+ "canvas": {"width": slide_width, "height": slide_height},
1221
+ "bbox": bbox,
1222
+ "overflow": overflow,
1223
+ "message": (
1224
+ f'{element["kind"]} {element["id"]} exceeds the {slide_width:g}x{slide_height:g} canvas '
1225
+ f'({", ".join(overflow_details)})'
1226
+ ),
1227
+ "hint": (
1228
+ "Move the table inside the canvas, reduce table.width/table.height, or split the table across "
1229
+ "slides."
1230
+ if element["kind"] == "table"
1231
+ else f'Move the {element["kind"]} inside the canvas or reduce its width/height.'
1232
+ ),
1233
+ }
1234
+ )
1235
+ return issues
1236
+
1237
+
1238
+ def extract_table_column_sizes(table_xml: str) -> list[int | float | None]:
1239
+ sizes: list[int | float | None] = []
1240
+ for match in re.finditer(r"<col\b([^>]*)/?>", table_xml):
1241
+ attrs = match.group(1)
1242
+ span = extract_numeric_attribute(attrs, "span") or 1
1243
+ span_count = int(span) if math.isfinite(span) and span > 0 and float(span).is_integer() else 1
1244
+ sizes.extend([extract_numeric_attribute(attrs, "width")] * span_count)
1245
+ return sizes
1246
+
1247
+
1248
+ def extract_table_row_sizes(table_xml: str) -> list[int | float | None]:
1249
+ return [extract_numeric_attribute(match.group(1), "height") for match in re.finditer(r"<tr\b([^>]*)>", table_xml)]
1250
+
1251
+
1252
+ def resolve_table_dimension(
1253
+ table_xml: str,
1254
+ declared_size: int | float | None,
1255
+ extract_sizes: Any,
1256
+ default_size: int | float,
1257
+ ) -> tuple[int | float | None, dict[str, Any] | None]:
1258
+ input_sizes = extract_sizes(table_xml)
1259
+ if not input_sizes:
1260
+ return declared_size, None
1261
+ layout = solve_weighted_min_layout(
1262
+ input_sizes, default_size, declared_size if is_filled_size(declared_size) else None
1263
+ )
1264
+ return layout["actual_size"], layout
1265
+
1266
+
1267
+ def format_size(size: int | float) -> str:
1268
+ return f"{size:g}"
1269
+
1270
+
1271
+ def detect_table_layout_size_mismatches(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
1272
+ issues: list[dict[str, Any]] = []
1273
+ dimensions = {
1274
+ "width": ("col", "column widths"),
1275
+ "height": ("tr", "row heights"),
1276
+ }
1277
+ for table in (element for element in elements if element["kind"] == "table"):
1278
+ for dimension, (child_tag, child_description) in dimensions.items():
1279
+ target_size = table[f"declared_{dimension}"]
1280
+ if not is_filled_size(target_size):
1281
+ continue
1282
+ layout = table["table_layouts"][dimension]
1283
+ if layout is None:
1284
+ continue
1285
+ actual_size = layout["actual_size"]
1286
+ if math.isclose(actual_size, target_size, rel_tol=1e-9, abs_tol=1e-9):
1287
+ continue
1288
+ issues.append(
1289
+ {
1290
+ "level": "info",
1291
+ "code": "table_resolved_size_mismatch",
1292
+ "elements": [table["id"]],
1293
+ "dimension": dimension,
1294
+ "declared_size": target_size,
1295
+ "resolved_size": actual_size,
1296
+ "resolved_sizes": layout["final_sizes"],
1297
+ "message": (
1298
+ f'table {table["id"]} declares {dimension}={format_size(target_size)}px, but its '
1299
+ f"{child_description} resolve to {format_size(actual_size)}px"
1300
+ ),
1301
+ "hint": (
1302
+ f"Set table.{dimension} to {format_size(actual_size)}px, or adjust <{child_tag}> sizes "
1303
+ f"so their resolved total matches {format_size(target_size)}px."
1304
+ ),
1305
+ }
1306
+ )
1307
+ return issues
1308
+
1309
+
870
1310
  def lint_slide(
871
1311
  slide_xml: str, slide_number: int, slide_width: int | float = 960, slide_height: int | float = 540
872
1312
  ) -> dict[str, Any]:
873
1313
  elements = extract_elements(slide_xml)
874
- issues: list[dict[str, Any]] = detect_whiteboard_external_overlaps(elements, slide_width, slide_height)
1314
+ issues: list[dict[str, Any]] = [
1315
+ *detect_whiteboard_external_overlaps(elements, slide_width, slide_height),
1316
+ *detect_elements_out_of_canvas(elements, slide_width, slide_height),
1317
+ *detect_table_layout_size_mismatches(elements),
1318
+ *detect_text_may_overflow_shapes(elements),
1319
+ *detect_image_text_occlusions(elements),
1320
+ ]
875
1321
 
876
1322
  for index, left in enumerate(elements):
877
1323
  for right in elements[index + 1 :]:
@@ -896,7 +1342,7 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
896
1342
  return {
897
1343
  "file": source_path,
898
1344
  "slide_size": {"width": 960, "height": 540},
899
- "summary": {"slide_count": 0, "error_count": 1, "warning_count": 0},
1345
+ "summary": {"slide_count": 0, "error_count": 1, "warning_count": 0, "info_count": 0},
900
1346
  "issues": [xml_error],
901
1347
  "slides": [],
902
1348
  }
@@ -908,10 +1354,16 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
908
1354
  if namespace_issues:
909
1355
  error_count = sum(1 for issue in top_level_issues if issue["level"] == "error")
910
1356
  warning_count = sum(1 for issue in top_level_issues if issue["level"] == "warning")
1357
+ info_count = sum(1 for issue in top_level_issues if issue["level"] == "info")
911
1358
  return {
912
1359
  "file": source_path,
913
1360
  "slide_size": {"width": 960, "height": 540},
914
- "summary": {"slide_count": 0, "error_count": error_count, "warning_count": warning_count},
1361
+ "summary": {
1362
+ "slide_count": 0,
1363
+ "error_count": error_count,
1364
+ "warning_count": warning_count,
1365
+ "info_count": info_count,
1366
+ },
915
1367
  "issues": top_level_issues,
916
1368
  "slides": [],
917
1369
  }
@@ -924,10 +1376,17 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
924
1376
  error_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "error")
925
1377
  warning_count = sum(1 for issue in top_level_issues if issue["level"] == "warning")
926
1378
  warning_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "warning")
1379
+ info_count = sum(1 for issue in top_level_issues if issue["level"] == "info")
1380
+ info_count += sum(1 for slide in slides for issue in slide["issues"] if issue["level"] == "info")
927
1381
  result = {
928
1382
  "file": source_path,
929
1383
  "slide_size": {"width": presentation["width"], "height": presentation["height"]},
930
- "summary": {"slide_count": len(slides), "error_count": error_count, "warning_count": warning_count},
1384
+ "summary": {
1385
+ "slide_count": len(slides),
1386
+ "error_count": error_count,
1387
+ "warning_count": warning_count,
1388
+ "info_count": info_count,
1389
+ },
931
1390
  "slides": slides,
932
1391
  }
933
1392
  if top_level_issues: