@amaster.ai/pi-lark 0.1.2-beta.47 → 0.1.2-beta.49

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 (63) hide show
  1. package/README.md +5 -1
  2. package/dist/config.d.ts +1 -1
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +2 -2
  5. package/dist/config.js.map +1 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +2 -1
  8. package/dist/index.js.map +1 -1
  9. package/package.json +3 -3
  10. package/skills/lark-apps/SKILL.md +1 -0
  11. package/skills/lark-apps/references/lark-apps-cache.md +61 -0
  12. package/skills/lark-base/SKILL.md +16 -7
  13. package/skills/lark-base/references/lark-base-data-query.md +11 -4
  14. package/skills/lark-base/references/lark-base-filter-condition.md +179 -0
  15. package/skills/lark-base/references/lark-base-form-questions-create.md +40 -7
  16. package/skills/lark-base/references/lark-base-form-questions-update.md +73 -20
  17. package/skills/lark-base/references/lark-base-role-guide.md +11 -0
  18. package/skills/lark-base/references/lark-base-view-set-filter.md +11 -137
  19. package/skills/lark-base/references/role-config.md +31 -5
  20. package/skills/lark-calendar/SKILL.md +14 -8
  21. package/skills/lark-calendar/references/lark-calendar-create.md +6 -6
  22. package/skills/lark-calendar/references/lark-calendar-recurring.md +1 -0
  23. package/skills/lark-calendar/references/lark-calendar-room-find.md +2 -1
  24. package/skills/lark-calendar/references/lark-calendar-schedule-clear-time.md +1 -0
  25. package/skills/lark-calendar/references/lark-calendar-suggestion.md +1 -1
  26. package/skills/lark-calendar/references/lark-calendar-update.md +7 -4
  27. package/skills/lark-contact/SKILL.md +19 -3
  28. package/skills/lark-contact/references/lark-contact-search-bot.md +60 -0
  29. package/skills/lark-drive/SKILL.md +21 -44
  30. package/skills/lark-drive/references/lark-drive-add-comment.md +2 -4
  31. package/skills/lark-drive/references/lark-drive-add-reply.md +47 -0
  32. package/skills/lark-drive/references/lark-drive-apply-permission.md +2 -2
  33. package/skills/lark-drive/references/lark-drive-batch-query-comments.md +46 -0
  34. package/skills/lark-drive/references/lark-drive-comment-content.md +50 -0
  35. package/skills/lark-drive/references/lark-drive-comment-location.md +7 -13
  36. package/skills/lark-drive/references/lark-drive-delete-reply.md +48 -0
  37. package/skills/lark-drive/references/lark-drive-download.md +5 -1
  38. package/skills/lark-drive/references/lark-drive-list-comments.md +25 -68
  39. package/skills/lark-drive/references/lark-drive-list-replies.md +54 -0
  40. package/skills/lark-drive/references/lark-drive-member-add.md +2 -2
  41. package/skills/lark-drive/references/lark-drive-member-list.md +65 -0
  42. package/skills/lark-drive/references/lark-drive-permission-get-setting.md +48 -0
  43. package/skills/lark-drive/references/lark-drive-preview.md +11 -1
  44. package/skills/lark-drive/references/lark-drive-react-reply.md +51 -0
  45. package/skills/lark-drive/references/lark-drive-reactions.md +27 -25
  46. package/skills/lark-drive/references/lark-drive-resolve-comment.md +45 -0
  47. package/skills/lark-drive/references/lark-drive-restore-comment.md +46 -0
  48. package/skills/lark-drive/references/lark-drive-search.md +6 -1
  49. package/skills/lark-drive/references/lark-drive-secure-label.md +1 -1
  50. package/skills/lark-drive/references/lark-drive-update-reply.md +46 -0
  51. package/skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md +38 -8
  52. package/skills/lark-drive/references/lark-drive-workflow-permission-governance-outputs.md +10 -10
  53. package/skills/lark-drive/references/lark-drive-workflow-permission-governance.md +22 -20
  54. package/skills/lark-slides/SKILL.md +16 -26
  55. package/skills/lark-slides/references/lark-slides-create.md +14 -5
  56. package/skills/lark-slides/references/lark-slides-media-upload.md +1 -1
  57. package/skills/lark-slides/references/slides_xml_schema_definition.xml +491 -31
  58. package/skills/lark-slides/references/xml-schema-quick-ref.md +39 -0
  59. package/skills/lark-slides/scripts/sxsd_validator.py +908 -0
  60. package/skills/lark-slides/scripts/xml_text_overlap_lint.py +633 -124
  61. package/skills/lark-slides/scripts/xml_text_overlap_lint_test.py +2038 -219
  62. package/skills/lark-task/references/lark-task-create.md +9 -0
  63. package/skills/lark-drive/references/lark-drive-comments-guide.md +0 -80
@@ -5,6 +5,7 @@
5
5
 
6
6
  from __future__ import annotations
7
7
 
8
+ import copy
8
9
  import json
9
10
  import math
10
11
  import re
@@ -16,6 +17,8 @@ from difflib import SequenceMatcher, get_close_matches
16
17
  from pathlib import Path
17
18
  from typing import Any
18
19
 
20
+ import sxsd_validator
21
+
19
22
 
20
23
  XS_NS = "{http://www.w3.org/2001/XMLSchema}"
21
24
  XML_NS = "{http://www.w3.org/XML/1998/namespace}"
@@ -44,11 +47,28 @@ ROUNDTRIP_SXSD_ATTRS = {
44
47
  ("chartData", "isStaticData"),
45
48
  }
46
49
  # 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"}
50
+ # it is server-emitted and absent from the write schema, so it must not block page linting.
51
+ ROUNDTRIP_SXSD_TAGS = {("chartField", "chartParsedValues")}
50
52
  DEFAULT_TABLE_COLUMN_WIDTH = 110
51
53
  DEFAULT_TABLE_ROW_HEIGHT = 37
54
+ DEFAULT_TEXT_LINE_SPACING_MULTIPLE = 1.5
55
+ TEXT_WRAP_WIDTH_TOLERANCE_PX = 1.0
56
+ TEXT_HEIGHT_OVERFLOW_TOLERANCE_PX = 0.5
57
+ SINGLE_LINE_METRIC_WIDTH_RATIO = 1.18
58
+ CENTERED_SHORT_LABEL_WIDTH_RATIO = 1.12
59
+ HEADLINE_NEAR_FIT_WIDTH_RATIO = 1.04
60
+ DENSE_BODY_LINE_SPACING_MAX_MULTIPLE = 1.6
61
+ GHOST_TEXT_MIN_FONT_SIZE = 96
62
+ GHOST_TEXT_MAX_ALPHA = 0.5
63
+ GHOST_TEXT_FAINT_MIN_FONT_SIZE = 36
64
+ GHOST_TEXT_FAINT_MAX_ALPHA = 0.35
65
+ # A <line> crossing text glyphs is a legibility defect (see line_crosses_text_glyphs). We erode the
66
+ # glyph box by this margin before testing intersection so a line that only skims a glyph edge or the
67
+ # padding-only text frame -- but does not actually cut through the letterforms -- is not flagged.
68
+ LINE_TEXT_GRAZE_MIN_PX = 2.0
69
+ LINE_TEXT_GRAZE_FONT_RATIO = 0.12
70
+ # A line whose effective stroke alpha is below this is not visibly rendered, so it cannot occlude text.
71
+ LINE_MIN_VISIBLE_ALPHA = 0.08
52
72
  # Sub-pixel canvas overflow is floating-point rounding noise (e.g. rotated-bbox math), not a
53
73
  # visible defect; keep this well under 1px so real overflow is still always caught.
54
74
  CANVAS_OVERFLOW_TOLERANCE = 0.5
@@ -106,6 +126,52 @@ def extract_numeric_attribute(tag_source: str, name: str) -> int | float | None:
106
126
  return int(value) if value.is_integer() else value
107
127
 
108
128
 
129
+ def extract_bool_attribute(tag_source: str, name: str) -> bool:
130
+ value = extract_attribute(tag_source, name)
131
+ return value in {"true", "1", "yes"}
132
+
133
+
134
+ def extract_color_alpha(color: str | None) -> int | float | None:
135
+ if color is None:
136
+ return None
137
+ normalized = re.sub(r"\s+", "", color).lower()
138
+ if normalized == "transparent":
139
+ return 0
140
+ rgba_match = re.fullmatch(
141
+ r"rgba\([^,]+,[^,]+,[^,]+,([+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+))\)",
142
+ normalized,
143
+ )
144
+ if rgba_match is None:
145
+ return None
146
+ try:
147
+ alpha = float(rgba_match.group(1))
148
+ except ValueError:
149
+ return None
150
+ return int(alpha) if alpha.is_integer() else alpha
151
+
152
+
153
+ def effective_text_alpha(shape_alpha: int | float | None, text_color: str | None) -> int | float:
154
+ base_alpha = shape_alpha if isinstance(shape_alpha, (int, float)) else 1
155
+ color_alpha = extract_color_alpha(text_color)
156
+ if not isinstance(color_alpha, (int, float)):
157
+ return base_alpha
158
+ return base_alpha * color_alpha
159
+
160
+
161
+ def detect_inline_style_presence(content_xml: str, style_tags: set[str]) -> bool:
162
+ for tag_name in style_tags:
163
+ if re.search(fr"<{re.escape(tag_name)}\b[\s>]", content_xml) is not None:
164
+ return True
165
+ return False
166
+
167
+
168
+ def detect_any_span_bool_attribute(content_xml: str, attr_name: str) -> bool:
169
+ for attrs in re.findall(r"<span\b([^>]*)>", content_xml):
170
+ if extract_bool_attribute(attrs, attr_name):
171
+ return True
172
+ return False
173
+
174
+
109
175
  def sum_sizes(sizes: list[int | float]) -> int | float:
110
176
  return sum(sizes)
111
177
 
@@ -218,6 +284,7 @@ def extract_text_paragraphs(value: str, default_font_size: int | float) -> list[
218
284
  "lineSpacing": extract_attribute(attrs, "lineSpacing"),
219
285
  "beforeLineSpacing": extract_attribute(attrs, "beforeLineSpacing"),
220
286
  "afterLineSpacing": extract_attribute(attrs, "afterLineSpacing"),
287
+ "letterSpacing": extract_numeric_attribute(attrs, "letterSpacing"),
221
288
  }
222
289
  )
223
290
  return paragraphs
@@ -245,77 +312,13 @@ def xml_namespace(tag: str) -> str | None:
245
312
  return tag.split("}", 1)[0] + "}" if tag.startswith("{") else None
246
313
 
247
314
 
248
- def strip_xsd_prefix(value: str | None) -> str | None:
249
- if value is None:
250
- return None
251
- return value.rsplit(":", 1)[-1]
252
-
253
-
254
- def iter_direct_xsd_children(element: ET.Element, local_name: str) -> list[ET.Element]:
255
- return [child for child in element if child.tag == f"{XS_NS}{local_name}"]
256
-
257
-
258
315
  def load_sxsd_tag_attributes() -> dict[str, set[str]]:
259
316
  global _SXSD_TAG_ATTRIBUTES_CACHE
260
317
  if _SXSD_TAG_ATTRIBUTES_CACHE is not None:
261
318
  return _SXSD_TAG_ATTRIBUTES_CACHE
262
319
 
263
- schema_root = ET.parse(SXSD_SCHEMA_PATH).getroot()
264
- named_complex_types = {
265
- complex_type.attrib["name"]: complex_type
266
- for complex_type in schema_root.findall(f"{XS_NS}complexType")
267
- if complex_type.attrib.get("name")
268
- }
269
- resolving: set[str] = set()
270
-
271
- def attributes_for_complex_type(complex_type: ET.Element) -> set[str]:
272
- attrs: set[str] = {
273
- attribute.attrib["name"]
274
- for attribute in iter_direct_xsd_children(complex_type, "attribute")
275
- if attribute.attrib.get("name")
276
- }
277
- for content_name in ("simpleContent", "complexContent"):
278
- for complex_content in iter_direct_xsd_children(complex_type, content_name):
279
- for extension in iter_direct_xsd_children(complex_content, "extension"):
280
- base_type = strip_xsd_prefix(extension.attrib.get("base"))
281
- if base_type:
282
- attrs.update(attributes_for_type(base_type))
283
- attrs.update(
284
- attribute.attrib["name"]
285
- for attribute in iter_direct_xsd_children(extension, "attribute")
286
- if attribute.attrib.get("name")
287
- )
288
- return attrs
289
-
290
- def attributes_for_type(type_name: str) -> set[str]:
291
- if type_name in resolving:
292
- return set()
293
- complex_type = named_complex_types.get(type_name)
294
- if complex_type is None:
295
- return set()
296
- resolving.add(type_name)
297
- try:
298
- return attributes_for_complex_type(complex_type)
299
- finally:
300
- resolving.remove(type_name)
301
-
302
- tag_attributes: dict[str, set[str]] = {}
303
- for element in schema_root.iter(f"{XS_NS}element"):
304
- tag_name = element.attrib.get("name")
305
- if not tag_name:
306
- continue
307
-
308
- attrs: set[str] = set()
309
- type_name = strip_xsd_prefix(element.attrib.get("type"))
310
- if type_name:
311
- attrs.update(attributes_for_type(type_name))
312
- for complex_type in iter_direct_xsd_children(element, "complexType"):
313
- attrs.update(attributes_for_complex_type(complex_type))
314
-
315
- tag_attributes.setdefault(tag_name, set()).update(attrs)
316
-
317
- _SXSD_TAG_ATTRIBUTES_CACHE = tag_attributes
318
- return tag_attributes
320
+ _SXSD_TAG_ATTRIBUTES_CACHE = sxsd_validator.load_tag_attributes(SXSD_SCHEMA_PATH)
321
+ return _SXSD_TAG_ATTRIBUTES_CACHE
319
322
 
320
323
 
321
324
  def load_iconpark_icon_types() -> set[str]:
@@ -352,13 +355,19 @@ def build_sxsd_tag_hint(tag_name: str, supported_tags: set[str]) -> str:
352
355
  return "Unsupported SXSD tag. Use only tags defined in slides_xml_schema_definition.xml."
353
356
 
354
357
 
355
- def build_sxsd_attr_hint(tag_name: str, attr_name: str, allowed_attrs: set[str]) -> str:
358
+ def suggest_sxsd_attrs(attr_name: str, allowed_attrs: set[str]) -> list[str]:
356
359
  alias = SXSD_ATTR_ALIASES.get(attr_name)
357
360
  if alias and alias in allowed_attrs:
358
- return f'Use "{alias}" on <{tag_name}> instead of "{attr_name}".'
359
- close_matches = get_close_matches(attr_name, sorted(allowed_attrs), n=3, cutoff=0.68)
360
- if close_matches:
361
- return "Unsupported SXSD attribute. Did you mean " + ", ".join(f'"{match}"' for match in close_matches) + "?"
361
+ return [alias]
362
+ return get_close_matches(attr_name, sorted(allowed_attrs), n=3, cutoff=0.68)
363
+
364
+
365
+ def build_sxsd_attr_hint(tag_name: str, attr_name: str, allowed_attrs: set[str]) -> str:
366
+ suggestions = suggest_sxsd_attrs(attr_name, allowed_attrs)
367
+ if suggestions:
368
+ if SXSD_ATTR_ALIASES.get(attr_name) == suggestions[0]:
369
+ return f'Use "{suggestions[0]}" on <{tag_name}> instead of "{attr_name}".'
370
+ return "Unsupported SXSD attribute. Did you mean " + ", ".join(f'"{match}"' for match in suggestions) + "?"
362
371
  allowed_summary = ", ".join(sorted(allowed_attrs)[:8])
363
372
  if len(allowed_attrs) > 8:
364
373
  allowed_summary += ", ..."
@@ -373,10 +382,33 @@ def should_skip_sxsd_attribute(tag_name: str, attr_name: str) -> bool:
373
382
  return attr_name in SERVER_FILLED_SXSD_ATTRS or (tag_name, attr_name) in ROUNDTRIP_SXSD_ATTRS
374
383
 
375
384
 
376
- def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
385
+ def should_skip_sxsd_tag(parent_name: str | None, tag_name: str) -> bool:
386
+ return (parent_name, tag_name) in ROUNDTRIP_SXSD_TAGS
387
+
388
+
389
+ def without_server_filled_sxsd_fields(root: ET.Element) -> ET.Element:
390
+ sanitized_root = copy.deepcopy(root)
391
+
392
+ def sanitize(element: ET.Element) -> None:
393
+ tag_name = xml_local_name(element.tag)
394
+ for raw_attr_name in list(element.attrib):
395
+ if should_skip_sxsd_attribute(tag_name, xml_local_name(raw_attr_name)):
396
+ del element.attrib[raw_attr_name]
397
+ for child in list(element):
398
+ if should_skip_sxsd_tag(tag_name, xml_local_name(child.tag)):
399
+ element.remove(child)
400
+ continue
401
+ sanitize(child)
402
+
403
+ sanitize(sanitized_root)
404
+ return sanitized_root
405
+
406
+
407
+ def validate_sxsd_document(xml: str, root: ET.Element) -> list[dict[str, Any]]:
377
408
  tag_attributes = load_sxsd_tag_attributes()
378
409
  supported_tags = set(tag_attributes)
379
410
  issues: list[dict[str, Any]] = []
411
+ suggested_attr_candidates: dict[tuple[str, str], list[set[str]]] = {}
380
412
 
381
413
  def visit(element: ET.Element, ancestors: list[str], path: str) -> None:
382
414
  if should_skip_sxsd_subtree(element, ancestors):
@@ -384,7 +416,8 @@ def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
384
416
 
385
417
  tag_name = xml_local_name(element.tag)
386
418
  current_path = f"{path}/{tag_name}" if path else tag_name
387
- if tag_name in ROUNDTRIP_SXSD_TAGS:
419
+ parent_name = ancestors[-1] if ancestors else None
420
+ if should_skip_sxsd_tag(parent_name, tag_name):
388
421
  return
389
422
  if tag_name not in supported_tags:
390
423
  issues.append(
@@ -408,6 +441,11 @@ def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
408
441
  continue
409
442
  if attr_name in allowed_attrs:
410
443
  continue
444
+ suggestions = suggest_sxsd_attrs(attr_name, allowed_attrs)
445
+ if suggestions:
446
+ suggested_attr_candidates.setdefault((current_path, tag_name), []).append(
447
+ set(suggestions)
448
+ )
411
449
  issues.append(
412
450
  {
413
451
  "level": "error",
@@ -424,6 +462,76 @@ def validate_sxsd_tag_attributes(root: ET.Element) -> list[dict[str, Any]]:
424
462
  visit(child, [*ancestors, tag_name], current_path)
425
463
 
426
464
  visit(root, [], "")
465
+ existing = {
466
+ (issue.get("code"), issue.get("path"), issue.get("tag"), issue.get("attr"))
467
+ for issue in issues
468
+ }
469
+ unsupported_tag_locations = {
470
+ (issue.get("path"), issue.get("tag"))
471
+ for issue in issues
472
+ if issue.get("code") == "sxsd_unsupported_tag"
473
+ }
474
+ schema_issues = _validate_sxsd_schema_constraints(xml, root)
475
+ missing_attrs_by_location: dict[tuple[str, str], set[str]] = {}
476
+ for schema_issue in schema_issues:
477
+ if schema_issue.get("code") != "sxsd_missing_required_attr":
478
+ continue
479
+ location = (schema_issue.get("path"), schema_issue.get("tag"))
480
+ missing_attrs_by_location.setdefault(location, set()).add(schema_issue.get("attr"))
481
+
482
+ suggested_attrs: set[tuple[str, str, str]] = set()
483
+ for location, candidate_groups in suggested_attr_candidates.items():
484
+ missing_attrs = missing_attrs_by_location.get(location, set())
485
+ for candidates in candidate_groups:
486
+ matching_missing_attrs = candidates & missing_attrs
487
+ if len(matching_missing_attrs) == 1:
488
+ suggested_attrs.add((*location, next(iter(matching_missing_attrs))))
489
+
490
+ for schema_issue in schema_issues:
491
+ if schema_issue.get("code") == "sxsd_unexpected_child" and (
492
+ schema_issue.get("path"),
493
+ schema_issue.get("tag"),
494
+ ) in unsupported_tag_locations:
495
+ continue
496
+ if schema_issue.get("code") == "sxsd_missing_required_attr" and (
497
+ schema_issue.get("path"),
498
+ schema_issue.get("tag"),
499
+ schema_issue.get("attr"),
500
+ ) in suggested_attrs:
501
+ continue
502
+ key = (
503
+ schema_issue.get("code"),
504
+ schema_issue.get("path"),
505
+ schema_issue.get("tag"),
506
+ schema_issue.get("attr"),
507
+ )
508
+ if key not in existing:
509
+ issues.append(schema_issue)
510
+ return issues
511
+
512
+
513
+ def _validate_sxsd_schema_constraints(xml: str, root: ET.Element) -> list[dict[str, Any]]:
514
+ issues: list[dict[str, Any]] = []
515
+ if re.match(r"^\s*<\?xml\b", xml):
516
+ issues.append(
517
+ {
518
+ "level": "error",
519
+ "code": "sxsd_unsupported_declaration",
520
+ "path": xml_local_name(root.tag),
521
+ "tag": xml_local_name(root.tag),
522
+ "expected": "SXSD document without an XML declaration",
523
+ "actual": "<?xml ...?>",
524
+ "message": "XML declarations are not supported by the Slides SXSD write format",
525
+ "hint": "Remove the <?xml ...?> declaration and keep the SXSD root element.",
526
+ }
527
+ )
528
+
529
+ issues.extend(
530
+ sxsd_validator.validate_sxsd(
531
+ without_server_filled_sxsd_fields(root),
532
+ SXSD_SCHEMA_PATH,
533
+ )
534
+ )
427
535
  return issues
428
536
 
429
537
 
@@ -627,18 +735,39 @@ def validate_xml_well_formed(xml: str) -> dict[str, Any] | None:
627
735
  return xml_error
628
736
 
629
737
 
630
- def parse_presentation(xml: str) -> dict[str, Any]:
631
- presentation_match = re.search(r"<presentation\b([^>]*)>", xml)
632
- if presentation_match:
633
- return {
634
- "width": int(float(extract_attribute(presentation_match.group(1), "width") or 960)),
635
- "height": int(float(extract_attribute(presentation_match.group(1), "height") or 540)),
636
- "slides": re.findall(r"<slide\b[\s\S]*?</slide>", xml),
738
+ def serialize_slide_for_layout(slide_root: ET.Element) -> str:
739
+ slide_copy = copy.deepcopy(slide_root)
740
+ for element in slide_copy.iter():
741
+ if not isinstance(element.tag, str):
742
+ continue
743
+ element.tag = xml_local_name(element.tag)
744
+ attributes = {
745
+ xml_local_name(attribute_name): value
746
+ for attribute_name, value in element.attrib.items()
637
747
  }
638
- slide_match = re.findall(r"<slide\b[\s\S]*?</slide>", xml)
639
- if slide_match:
640
- return {"width": 960, "height": 540, "slides": slide_match}
641
- fail("input must contain a <presentation> or <slide> root")
748
+ element.attrib.clear()
749
+ element.attrib.update(attributes)
750
+ return ET.tostring(slide_copy, encoding="unicode")
751
+
752
+
753
+ def parse_presentation(root: ET.Element) -> dict[str, Any]:
754
+ root_name = xml_local_name(root.tag)
755
+ if root_name == "slide":
756
+ slide_roots = [root]
757
+ width = 960
758
+ height = 540
759
+ elif root_name == "presentation":
760
+ slide_roots = [child for child in root if xml_local_name(child.tag) == "slide"]
761
+ width = int(float(root.attrib.get("width", 960)))
762
+ height = int(float(root.attrib.get("height", 540)))
763
+ else:
764
+ fail("input must contain a <presentation> or <slide> root")
765
+ return {
766
+ "width": width,
767
+ "height": height,
768
+ "slides": [serialize_slide_for_layout(slide_root) for slide_root in slide_roots],
769
+ "slide_roots": slide_roots,
770
+ }
642
771
 
643
772
 
644
773
  def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
@@ -694,6 +823,20 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
694
823
  font_size = extract_numeric_attribute(content_attrs, "fontSize")
695
824
  if font_size is None:
696
825
  font_size = extract_numeric_attribute(attrs, "fontSize")
826
+ font_family = extract_attribute(content_attrs, "fontFamily") or extract_attribute(attrs, "fontFamily")
827
+ text_color = extract_attribute(content_attrs, "color") or extract_attribute(attrs, "color")
828
+ bold = (
829
+ extract_bool_attribute(content_attrs, "bold")
830
+ or extract_bool_attribute(attrs, "bold")
831
+ or detect_inline_style_presence(content, {"strong", "b"})
832
+ or detect_any_span_bool_attribute(content, "bold")
833
+ )
834
+ italic = (
835
+ extract_bool_attribute(content_attrs, "italic")
836
+ or extract_bool_attribute(attrs, "italic")
837
+ or detect_inline_style_presence(content, {"i", "em"})
838
+ or detect_any_span_bool_attribute(content, "italic")
839
+ )
697
840
  element.update(
698
841
  {
699
842
  "textType": extract_attribute(content_attrs, "textType"),
@@ -705,11 +848,17 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
705
848
  "lineSpacing": extract_attribute(content_attrs, "lineSpacing"),
706
849
  "beforeLineSpacing": extract_attribute(content_attrs, "beforeLineSpacing"),
707
850
  "afterLineSpacing": extract_attribute(content_attrs, "afterLineSpacing"),
851
+ "letterSpacing": extract_numeric_attribute(content_attrs, "letterSpacing"),
708
852
  "paddingTop": extract_numeric_attribute(content_attrs, "paddingTop") or 0,
709
853
  "paddingRight": extract_numeric_attribute(content_attrs, "paddingRight") or 0,
710
854
  "paddingBottom": extract_numeric_attribute(content_attrs, "paddingBottom") or 0,
711
855
  "paddingLeft": extract_numeric_attribute(content_attrs, "paddingLeft") or 0,
712
856
  "fontSize": font_size if font_size is not None else 16,
857
+ "fontFamily": font_family or "",
858
+ "color": text_color,
859
+ "textAlpha": effective_text_alpha(alpha, text_color),
860
+ "bold": bold,
861
+ "italic": italic,
713
862
  "text": strip_xml_paragraphs(content),
714
863
  "paragraphs": extract_text_paragraphs(content, font_size if font_size is not None else 16),
715
864
  }
@@ -745,7 +894,11 @@ def is_vertical_text(element: dict[str, Any]) -> bool:
745
894
 
746
895
  def detect_image_text_occlusions(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
747
896
  issues: list[dict[str, Any]] = []
748
- text_elements = [element for element in elements if is_text_element(element) and has_text_content(element)]
897
+ text_elements = [
898
+ element
899
+ for element in elements
900
+ if is_text_element(element) and has_text_content(element) and not is_ghost_text(element)
901
+ ]
749
902
  image_elements = [element for element in elements if element["kind"] == "img" and element["alpha"] > 0]
750
903
  for text_element in text_elements:
751
904
  for image_element in image_elements:
@@ -782,22 +935,135 @@ def normalize_text_for_overlap(text: str) -> str:
782
935
  return re.sub(r"\s+", "", text)
783
936
 
784
937
 
785
- def estimate_character_width(character: str, font_size: int | float) -> int | float:
938
+ SERIF_FONT_PATTERNS = {
939
+ "song", "songti", "simsun", "ming", "mincho",
940
+ "georgia", "times", "caslon", "garamond", "sourcehan-serif",
941
+ "source han serif", "思源宋体", "宋体", "明体",
942
+ }
943
+
944
+ SANS_EXPLICIT_MARKERS = {"sans", "sans-serif", "sans serif", "sourcehan-sans", "source han sans", "思源黑体", "黑体",
945
+ "helvetica", "arial", "inter", "roboto", "verdana", "tahoma", "calibri", "open sans"}
946
+
947
+
948
+ def classify_font_family(font_family: str | None) -> str:
949
+ if not font_family:
950
+ return "sans"
951
+ family_lower = font_family.lower()
952
+ for marker in SANS_EXPLICIT_MARKERS:
953
+ if marker in family_lower:
954
+ return "sans"
955
+ serif_keywords = SERIF_FONT_PATTERNS | {"serif"}
956
+ for pattern in serif_keywords:
957
+ if pattern in family_lower:
958
+ return "serif"
959
+ return "sans"
960
+
961
+
962
+ _FONT_CATEGORY_MULTIPLIERS: dict[str, dict[str, float]] = {
963
+ "sans": {"upper": 0.57, "lower": 0.51, "digit": 0.58, "punct": 0.50},
964
+ "serif": {"upper": 0.57, "lower": 0.53, "digit": 0.58, "punct": 0.50},
965
+ }
966
+
967
+
968
+ def estimate_character_width(
969
+ character: str,
970
+ font_size: int | float,
971
+ bold: bool = False,
972
+ font_family: str | None = None,
973
+ ) -> int | float:
974
+ bold_multiplier = 1.05 if bold else 1.0
786
975
  if character.isspace():
787
- return font_size * 0.33
788
- if unicodedata.east_asian_width(character) in {"F", "W"}:
789
- return font_size
790
- return font_size * 0.55
976
+ return font_size * 0.33 * bold_multiplier
977
+ ea_width = unicodedata.east_asian_width(character)
978
+ if ea_width in {"F", "W"}:
979
+ return font_size * bold_multiplier
980
+ category = classify_font_family(font_family)
981
+ coeffs = _FONT_CATEGORY_MULTIPLIERS[category]
982
+ if character.isupper():
983
+ return font_size * coeffs["upper"] * bold_multiplier
984
+ if character.islower():
985
+ return font_size * coeffs["lower"] * bold_multiplier
986
+ if character.isdigit():
987
+ return font_size * coeffs["digit"] * bold_multiplier
988
+ return font_size * coeffs["punct"] * bold_multiplier
989
+
990
+
991
+ def estimate_text_width(
992
+ text: str,
993
+ font_size: int | float,
994
+ letter_spacing: int | float = 0,
995
+ bold: bool = False,
996
+ font_family: str | None = None,
997
+ ) -> int | float:
998
+ base = sum(estimate_character_width(character, font_size, bold, font_family) for character in text)
999
+ return base + max(len(text) - 1, 0) * letter_spacing
1000
+
1001
+
1002
+ def resolve_letter_spacing(element: dict[str, Any], paragraph: dict[str, Any] | None = None) -> int | float:
1003
+ if paragraph is not None:
1004
+ value = paragraph.get("letterSpacing")
1005
+ if isinstance(value, (int, float)):
1006
+ return value
1007
+ value = element.get("letterSpacing")
1008
+ return value if isinstance(value, (int, float)) else 0
1009
+
791
1010
 
1011
+ def text_wrap_width_tolerance() -> int | float:
1012
+ return TEXT_WRAP_WIDTH_TOLERANCE_PX
792
1013
 
793
- def estimate_text_width(text: str, font_size: int | float) -> int | float:
794
- return sum(estimate_character_width(character, font_size) for character in text)
1014
+
1015
+ def text_height_overflow_tolerance() -> int | float:
1016
+ return TEXT_HEIGHT_OVERFLOW_TOLERANCE_PX
1017
+
1018
+
1019
+ def has_explicit_height_auto_fit(element: dict[str, Any]) -> bool:
1020
+ return element.get("autoFit") in {"normal-auto-fit", "shape-auto-fit"}
1021
+
1022
+
1023
+ def is_short_metric_text(text: str) -> bool:
1024
+ compact = re.sub(r"\s+", "", text)
1025
+ if not compact or len(compact) > 16 or re.search(r"\d", compact) is None:
1026
+ return False
1027
+ if re.fullmatch(r"[+\-–—]?[0-9,.,]+[\u4e00-\u9fffA-Za-z]{1,4}", compact):
1028
+ return True
1029
+ if re.search(r"[,.,+\-–—/%%]", compact) is None:
1030
+ return False
1031
+ return re.fullmatch(r"[+\-–—]?[0-9A-Za-z,.,/%%\-–—\u4e00-\u9fff]+", compact) is not None
1032
+
1033
+
1034
+ def is_single_line_visual_candidate(
1035
+ element: dict[str, Any],
1036
+ paragraph: dict[str, Any] | None,
1037
+ text: str,
1038
+ logical_width: int | float,
1039
+ effective_width: int | float,
1040
+ ) -> bool:
1041
+ if "\n" in text or logical_width <= effective_width:
1042
+ return False
1043
+ if is_short_metric_text(text):
1044
+ return logical_width <= effective_width * SINGLE_LINE_METRIC_WIDTH_RATIO
1045
+
1046
+ text_align = (paragraph or {}).get("textAlign") or element.get("textAlign")
1047
+ compact_len = len(re.sub(r"\s+", "", text))
1048
+ if text_align == "center" and compact_len <= 32:
1049
+ return logical_width <= effective_width * CENTERED_SHORT_LABEL_WIDTH_RATIO
1050
+
1051
+ font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
1052
+ if element.get("textType") in {"headline", "title"} and font_size <= 30 and compact_len <= 40:
1053
+ return logical_width <= effective_width * HEADLINE_NEAR_FIT_WIDTH_RATIO
1054
+ return False
795
1055
 
796
1056
 
797
1057
  def estimate_text_max_line_width(element: dict[str, Any]) -> int | float:
798
1058
  font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
1059
+ bold = element.get("bold", False)
1060
+ font_family = element.get("fontFamily", "")
1061
+ letter_spacing = resolve_letter_spacing(element)
799
1062
  paragraphs = [paragraph for paragraph in re.split(r"\n+", element["text"]) if paragraph]
800
- return max([estimate_text_width(paragraph, font_size) for paragraph in paragraphs] or [1])
1063
+ return max(
1064
+ [estimate_text_width(paragraph, font_size, letter_spacing, bold, font_family) for paragraph in paragraphs]
1065
+ or [1]
1066
+ )
801
1067
 
802
1068
 
803
1069
  def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool:
@@ -810,8 +1076,14 @@ def is_similar_text_overlay(left: dict[str, Any], right: dict[str, Any]) -> bool
810
1076
  return SequenceMatcher(None, left_text, right_text).ratio() >= 0.75
811
1077
 
812
1078
 
813
- def estimate_text_line_count_for_text(element: dict[str, Any], text: str) -> int:
1079
+ def estimate_text_line_count_for_text(
1080
+ element: dict[str, Any], text: str, paragraph: dict[str, Any] | None = None
1081
+ ) -> int:
814
1082
  font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
1083
+ bold = element.get("bold", False)
1084
+ font_family = element.get("fontFamily", "")
1085
+ letter_spacing = resolve_letter_spacing(element, paragraph)
1086
+ available_width = max(element["width"] - element.get("paddingLeft", 0) - element.get("paddingRight", 0), 1)
815
1087
  hard_lines = text.split("\n")
816
1088
  if not text:
817
1089
  return 0
@@ -820,8 +1092,12 @@ def estimate_text_line_count_for_text(element: dict[str, Any], text: str) -> int
820
1092
  if element.get("wrap") in {"false", "0"}:
821
1093
  line_count += 1
822
1094
  continue
823
- logical_width = max(estimate_text_width(hard_line, font_size), 1)
824
- line_count += max(1, math.ceil(logical_width / max(element["width"], 1)))
1095
+ logical_width = max(estimate_text_width(hard_line, font_size, letter_spacing, bold, font_family), 1)
1096
+ effective_width = available_width + text_wrap_width_tolerance()
1097
+ if is_single_line_visual_candidate(element, paragraph, hard_line, logical_width, effective_width):
1098
+ line_count += 1
1099
+ continue
1100
+ line_count += max(1, math.ceil(logical_width / effective_width))
825
1101
  return line_count
826
1102
 
827
1103
 
@@ -831,7 +1107,8 @@ def estimate_text_line_count(element: dict[str, Any]) -> int:
831
1107
 
832
1108
  def estimate_text_line_height(element: dict[str, Any], line_spacing: str | None = None) -> int | float | None:
833
1109
  font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
834
- line_spacing = line_spacing or "multiple:1.5"
1110
+ if line_spacing is None:
1111
+ return font_size * DEFAULT_TEXT_LINE_SPACING_MULTIPLE
835
1112
  match = re.fullmatch(r"(multiple|fixed):([0-9]+(?:\.[0-9]+)?)", line_spacing)
836
1113
  if match is None:
837
1114
  return None
@@ -839,12 +1116,27 @@ def estimate_text_line_height(element: dict[str, Any], line_spacing: str | None
839
1116
  return font_size * float(value) if spacing_type == "multiple" else float(value)
840
1117
 
841
1118
 
1119
+ def adjust_dense_body_line_height(
1120
+ element: dict[str, Any],
1121
+ line_spacing: str | None,
1122
+ line_height: int | float,
1123
+ paragraph_count: int,
1124
+ ) -> int | float:
1125
+ font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
1126
+ if paragraph_count < 4 or font_size > 14 or not line_spacing:
1127
+ return line_height
1128
+ match = re.fullmatch(r"multiple:([0-9]+(?:\.[0-9]+)?)", line_spacing)
1129
+ if match is None:
1130
+ return line_height
1131
+ return min(line_height, font_size * min(float(match.group(1)), DENSE_BODY_LINE_SPACING_MAX_MULTIPLE))
1132
+
1133
+
842
1134
  def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict[str, Any]]:
843
1135
  issues: list[dict[str, Any]] = []
844
1136
  for element in elements:
845
1137
  if not is_text_element(element) or not has_text_content(element):
846
1138
  continue
847
- if element.get("autoFit") in {"normal-auto-fit", "shape-auto-fit"}:
1139
+ if has_explicit_height_auto_fit(element):
848
1140
  continue
849
1141
 
850
1142
  font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
@@ -860,10 +1152,11 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
860
1152
  estimated_height = 0.0
861
1153
  line_heights: list[int | float] = []
862
1154
  for paragraph in paragraphs:
863
- paragraph_line_count = estimate_text_line_count_for_text(element, paragraph["text"])
1155
+ paragraph_line_count = estimate_text_line_count_for_text(element, paragraph["text"], paragraph)
864
1156
  if paragraph_line_count == 0:
865
1157
  continue
866
- line_height = estimate_text_line_height(element, paragraph["lineSpacing"] or element["lineSpacing"])
1158
+ resolved_line_spacing = paragraph["lineSpacing"] or element["lineSpacing"]
1159
+ line_height = estimate_text_line_height(element, resolved_line_spacing)
867
1160
  before_spacing = estimate_text_line_height(
868
1161
  element, paragraph["beforeLineSpacing"] or element["beforeLineSpacing"] or "fixed:0"
869
1162
  )
@@ -873,6 +1166,7 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
873
1166
  if line_height is None or before_spacing is None or after_spacing is None:
874
1167
  line_count = 0
875
1168
  break
1169
+ line_height = adjust_dense_body_line_height(element, resolved_line_spacing, line_height, len(paragraphs))
876
1170
  first_line_height = font_size if line_count == 0 else line_height
877
1171
  line_count += paragraph_line_count
878
1172
  line_heights.append(line_height)
@@ -883,12 +1177,24 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
883
1177
  continue
884
1178
  available_height = max(element["height"] - element["paddingTop"] - element["paddingBottom"], 0)
885
1179
  overflow = estimated_height - available_height
886
- if overflow <= 0:
1180
+ if overflow <= text_height_overflow_tolerance():
887
1181
  continue
888
1182
 
1183
+ is_background = is_background_decorative_text(element, elements)
1184
+ if is_background:
1185
+ level = "info"
1186
+ else:
1187
+ level = "error" if overflow > 10 else "warning"
1188
+ message = (
1189
+ f'text shape {element["id"]} may overflow its own content box '
1190
+ f'(estimated {estimated_height:g}px, available {available_height:g}px); '
1191
+ 'consider setting content wrap="true" autoFit="normal-auto-fit"'
1192
+ )
1193
+ if is_background:
1194
+ message += " (likely background decoration: large font, low alpha, underneath other text)"
889
1195
  issues.append(
890
1196
  {
891
- "level": "warning",
1197
+ "level": level,
892
1198
  "code": "text_may_overflow_shape",
893
1199
  "elements": [element["id"]],
894
1200
  "line_count": line_count,
@@ -896,11 +1202,7 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
896
1202
  "estimated_height": estimated_height,
897
1203
  "available_height": available_height,
898
1204
  "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
- ),
1205
+ "message": message,
904
1206
  "hint": (
905
1207
  "Increase shape.height, reduce the text, or set content wrap=\"true\" "
906
1208
  "autoFit=\"normal-auto-fit\". "
@@ -911,6 +1213,38 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
911
1213
  return issues
912
1214
 
913
1215
 
1216
+ def is_background_decorative_text(
1217
+ element: dict[str, Any], elements: list[dict[str, Any]]
1218
+ ) -> bool:
1219
+ if not is_ghost_text(element):
1220
+ return False
1221
+ for other in elements:
1222
+ if other is element:
1223
+ continue
1224
+ if not is_text_element(other) or not has_text_content(other):
1225
+ continue
1226
+ foreground_alpha = other.get("textAlpha", other.get("alpha", 1))
1227
+ if not isinstance(foreground_alpha, (int, float)) or foreground_alpha <= 0:
1228
+ continue
1229
+ if other["order"] <= element["order"]:
1230
+ continue
1231
+ if intersects(element, other):
1232
+ return True
1233
+ return False
1234
+
1235
+
1236
+ def is_ghost_text(element: dict[str, Any]) -> bool:
1237
+ if not is_text_element(element) or not has_text_content(element):
1238
+ return False
1239
+ font_size = element["fontSize"] if isinstance(element["fontSize"], (int, float)) else 16
1240
+ text_alpha = element.get("textAlpha", element.get("alpha", 1))
1241
+ if not isinstance(text_alpha, (int, float)):
1242
+ return False
1243
+ if font_size > GHOST_TEXT_MIN_FONT_SIZE and text_alpha < GHOST_TEXT_MAX_ALPHA:
1244
+ return True
1245
+ return font_size >= GHOST_TEXT_FAINT_MIN_FONT_SIZE and text_alpha < GHOST_TEXT_FAINT_MAX_ALPHA
1246
+
1247
+
914
1248
  def estimate_text_visual_bbox(element: dict[str, Any]) -> dict[str, int | float] | None:
915
1249
  if not is_text_element(element) or not has_text_content(element) or is_decorative_text(element):
916
1250
  return None
@@ -1022,6 +1356,8 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
1022
1356
  return False
1023
1357
  if not (has_text_content(left) and has_text_content(right)):
1024
1358
  return False
1359
+ if is_ghost_text(left) or is_ghost_text(right):
1360
+ return False
1025
1361
  if is_template_text_stack(left, right) or is_similar_text_overlay(left, right):
1026
1362
  return False
1027
1363
 
@@ -1038,13 +1374,16 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
1038
1374
  return False
1039
1375
 
1040
1376
  font_size = source["fontSize"] if isinstance(source["fontSize"], (int, float)) else 16
1377
+ padding_left = source.get("paddingLeft", 0)
1378
+ padding_right = source.get("paddingRight", 0)
1379
+ available_width = max(source["width"] - padding_left - padding_right, 1)
1041
1380
  visual_width = estimate_text_max_line_width(source)
1042
- overflow_width = visual_width - source["width"]
1043
- min_overflow = max(font_size * 1.5, source["width"] * 0.08)
1381
+ overflow_width = visual_width - available_width
1382
+ min_overflow = max(font_size * 1.5, available_width * 0.08)
1044
1383
  if overflow_width < min_overflow:
1045
1384
  return False
1046
1385
 
1047
- intrusion_width = source["x"] + visual_width - target["x"]
1386
+ intrusion_width = source["x"] + padding_left + visual_width - target["x"]
1048
1387
  min_intrusion = max(font_size * 1.5, target["width"] * 0.08)
1049
1388
  if intrusion_width < min_intrusion:
1050
1389
  return False
@@ -1056,8 +1395,9 @@ def should_flag_horizontal_text_overflow(left: dict[str, Any], right: dict[str,
1056
1395
 
1057
1396
  def horizontal_text_overflow_measurement(left: dict[str, Any], right: dict[str, Any]) -> dict[str, int | float]:
1058
1397
  source, target = sorted([left, right], key=lambda element: element["x"])
1398
+ padding_left = source.get("paddingLeft", 0)
1059
1399
  visual_width = estimate_text_max_line_width(source)
1060
- source_visual_bbox = {"x": source["x"], "y": source["y"], "width": visual_width, "height": source["height"]}
1400
+ source_visual_bbox = {"x": source["x"] + padding_left, "y": source["y"], "width": visual_width, "height": source["height"]}
1061
1401
  width = intersection_width(source_visual_bbox, target)
1062
1402
  height = intersection_height(source_visual_bbox, target)
1063
1403
  return {
@@ -1072,6 +1412,8 @@ def should_flag_overlap(left: dict[str, Any], right: dict[str, Any]) -> bool:
1072
1412
  return False
1073
1413
  if is_text_element(right) and not has_text_content(right):
1074
1414
  return False
1415
+ if is_ghost_text(left) or is_ghost_text(right):
1416
+ return False
1075
1417
  if is_template_text_stack(left, right):
1076
1418
  return False
1077
1419
  if is_text_element(left) and is_text_element(right):
@@ -1118,6 +1460,8 @@ def should_report_whiteboard_overlap(
1118
1460
  ) -> dict[str, Any] | None:
1119
1461
  if other is whiteboard or not intersects(whiteboard, other):
1120
1462
  return None
1463
+ if is_ghost_text(other):
1464
+ return None
1121
1465
  if contains(whiteboard, other):
1122
1466
  return None
1123
1467
  if is_bottom_layer_full_slide_whiteboard(whiteboard, other, slide_width, slide_height):
@@ -1194,6 +1538,8 @@ def detect_whiteboard_external_overlaps(
1194
1538
 
1195
1539
  def element_canvas_bbox(element: dict[str, Any]) -> dict[str, int | float]:
1196
1540
  bbox = {key: element[key] for key in ("x", "y", "width", "height")}
1541
+ if element["kind"] != "chart" and not (element["kind"] == "shape" and element["type"] == "text"):
1542
+ return bbox
1197
1543
  rotation = element["rotation"]
1198
1544
  if not isinstance(rotation, (int, float)) or not math.isfinite(rotation):
1199
1545
  rotation = 0
@@ -1219,7 +1565,12 @@ def detect_elements_out_of_canvas(
1219
1565
  elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
1220
1566
  ) -> list[dict[str, Any]]:
1221
1567
  issues: list[dict[str, Any]] = []
1222
- for element in elements:
1568
+ for element in (
1569
+ element
1570
+ for element in elements
1571
+ if element["kind"] in {"table", "chart"}
1572
+ or (element["kind"] == "shape" and element["type"] in {"rect", "text"})
1573
+ ):
1223
1574
  bbox = element_canvas_bbox(element)
1224
1575
  overflow = {
1225
1576
  "left": max(-bbox["x"], 0),
@@ -1329,6 +1680,93 @@ def detect_table_layout_size_mismatches(elements: list[dict[str, Any]]) -> list[
1329
1680
  return issues
1330
1681
 
1331
1682
 
1683
+ def segment_intersects_rect(
1684
+ x1: float, y1: float, x2: float, y2: float, rect: dict[str, int | float]
1685
+ ) -> bool:
1686
+ """True when segment (x1,y1)-(x2,y2) enters the axis-aligned rect (Liang-Barsky clip)."""
1687
+ left = rect["x"]
1688
+ top = rect["y"]
1689
+ right = rect["x"] + rect["width"]
1690
+ bottom = rect["y"] + rect["height"]
1691
+ if right <= left or bottom <= top:
1692
+ return False
1693
+ dx = x2 - x1
1694
+ dy = y2 - y1
1695
+ if dx == 0 and dy == 0:
1696
+ return left <= x1 <= right and top <= y1 <= bottom
1697
+ t_enter, t_exit = 0.0, 1.0
1698
+ for delta, distance in ((-dx, x1 - left), (dx, right - x1), (-dy, y1 - top), (dy, bottom - y1)):
1699
+ if delta == 0:
1700
+ if distance < 0:
1701
+ return False
1702
+ continue
1703
+ t = distance / delta
1704
+ if delta < 0:
1705
+ t_enter = max(t_enter, t)
1706
+ else:
1707
+ t_exit = min(t_exit, t)
1708
+ if t_enter > t_exit:
1709
+ return False
1710
+ return True
1711
+
1712
+
1713
+ def line_text_graze_margin(text_element: dict[str, Any]) -> float:
1714
+ font_size = text_element["fontSize"] if isinstance(text_element.get("fontSize"), (int, float)) else 16
1715
+ return max(font_size * LINE_TEXT_GRAZE_FONT_RATIO, LINE_TEXT_GRAZE_MIN_PX)
1716
+
1717
+
1718
+ def erode_rect(rect: dict[str, int | float], margin: float) -> dict[str, int | float] | None:
1719
+ width = rect["width"] - 2 * margin
1720
+ height = rect["height"] - 2 * margin
1721
+ if width <= 0 or height <= 0:
1722
+ return None
1723
+ return {"x": rect["x"] + margin, "y": rect["y"] + margin, "width": width, "height": height}
1724
+
1725
+
1726
+ def line_crosses_text(line: dict[str, Any], text_element: dict[str, Any]) -> bool:
1727
+ if not is_visually_rendered(line) or line.get("alpha", 1) < LINE_MIN_VISIBLE_ALPHA:
1728
+ return False
1729
+ if not is_text_element(text_element) or not has_text_content(text_element):
1730
+ return False
1731
+ if is_ghost_text(text_element) or is_decorative_text(text_element):
1732
+ return False
1733
+ glyph_bbox = estimate_text_visual_bbox(text_element)
1734
+ if glyph_bbox is None:
1735
+ return False
1736
+ # Erode the glyph box so a line skimming the letter edge or only clipping the padding-only text
1737
+ # frame is exempt; only a line that actually cuts through the letterforms is a crossing.
1738
+ target = erode_rect(glyph_bbox, line_text_graze_margin(text_element))
1739
+ if target is None:
1740
+ return False
1741
+ return segment_intersects_rect(
1742
+ line["startX"], line["startY"], line["endX"], line["endY"], target
1743
+ )
1744
+
1745
+
1746
+ def detect_line_text_crossings(
1747
+ slide_xml: str, elements: list[dict[str, Any]]
1748
+ ) -> list[dict[str, Any]]:
1749
+ lines = extract_line_elements(slide_xml)
1750
+ if not lines:
1751
+ return []
1752
+ text_elements = [element for element in elements if is_text_element(element)]
1753
+ issues: list[dict[str, Any]] = []
1754
+ for line in lines:
1755
+ for text_element in text_elements:
1756
+ if not line_crosses_text(line, text_element):
1757
+ continue
1758
+ issues.append(
1759
+ {
1760
+ "level": "error",
1761
+ "code": "bbox_overlap",
1762
+ "elements": [line["id"], text_element["id"]],
1763
+ "message": f'line {line["id"]} crosses text {text_element["id"]}',
1764
+ "hint": "Move the line off the text glyphs so it no longer cuts through the letterforms.",
1765
+ }
1766
+ )
1767
+ return issues
1768
+
1769
+
1332
1770
  def lint_slide(
1333
1771
  slide_xml: str, slide_number: int, slide_width: int | float = 960, slide_height: int | float = 540
1334
1772
  ) -> dict[str, Any]:
@@ -1339,6 +1777,7 @@ def lint_slide(
1339
1777
  *detect_table_layout_size_mismatches(elements),
1340
1778
  *detect_text_may_overflow_shapes(elements),
1341
1779
  *detect_image_text_occlusions(elements),
1780
+ *detect_line_text_crossings(slide_xml, elements),
1342
1781
  ]
1343
1782
 
1344
1783
  for index, left in enumerate(elements):
@@ -1944,7 +2383,7 @@ def related_object(element: dict[str, Any]) -> dict[str, Any]:
1944
2383
 
1945
2384
  def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
1946
2385
  elements: list[dict[str, Any]] = []
1947
- for match in re.finditer(r"<line\b([^>]*)>", slide_xml):
2386
+ for match in re.finditer(r"<line\b([^>]*?)(/?)>", slide_xml):
1948
2387
  attrs = match.group(1)
1949
2388
  start_x = extract_numeric_attribute(attrs, "startX")
1950
2389
  start_y = extract_numeric_attribute(attrs, "startY")
@@ -1953,6 +2392,15 @@ def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
1953
2392
  if any(value is None for value in (start_x, start_y, end_x, end_y)):
1954
2393
  continue
1955
2394
  line_alpha = extract_numeric_attribute(attrs, "alpha")
2395
+ base_alpha = line_alpha if line_alpha is not None else 1
2396
+ border_alpha = 1
2397
+ if match.group(2) != "/":
2398
+ close_index = slide_xml.find("</line>", match.end())
2399
+ body = slide_xml[match.end() : close_index] if close_index != -1 else ""
2400
+ border_attrs = extract_tag_attributes(body, "border")
2401
+ color_alpha = extract_color_alpha(extract_attribute(border_attrs, "color"))
2402
+ if isinstance(color_alpha, (int, float)):
2403
+ border_alpha = color_alpha
1956
2404
  elements.append(
1957
2405
  {
1958
2406
  "id": extract_attribute(attrs, "id") or f"line-{len(elements) + 1}",
@@ -1962,8 +2410,12 @@ def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
1962
2410
  "y": min(start_y, end_y),
1963
2411
  "width": abs(end_x - start_x),
1964
2412
  "height": abs(end_y - start_y),
2413
+ "startX": start_x,
2414
+ "startY": start_y,
2415
+ "endX": end_x,
2416
+ "endY": end_y,
1965
2417
  "rotation": 0,
1966
- "alpha": line_alpha if line_alpha is not None else 1,
2418
+ "alpha": base_alpha * border_alpha,
1967
2419
  "order": len(elements),
1968
2420
  }
1969
2421
  )
@@ -1976,8 +2428,6 @@ def normalize_issue(
1976
2428
  elements_by_id: dict[str, dict[str, Any]],
1977
2429
  ) -> dict[str, Any]:
1978
2430
  normalized = dict(issue)
1979
- if normalized.get("level") == "info":
1980
- normalized["level"] = "warning"
1981
2431
  element_ids = list(dict.fromkeys(normalized.get("elements", [])))
1982
2432
  normalized["schema_version"] = "2.0"
1983
2433
  normalized["element_ids"] = element_ids
@@ -2031,6 +2481,21 @@ def slide_status(errors: list[dict[str, Any]], warnings: list[dict[str, Any]]) -
2031
2481
  return "passed"
2032
2482
 
2033
2483
 
2484
+ def is_slide_scoped_sxsd_issue(issue: dict[str, Any], root_name: str) -> bool:
2485
+ if issue.get("code") == "sxsd_unsupported_declaration":
2486
+ return False
2487
+ if root_name == "slide":
2488
+ return True
2489
+ path = issue.get("path")
2490
+ if not isinstance(path, str):
2491
+ return False
2492
+ if path.startswith("presentation/slide/"):
2493
+ return True
2494
+ return path == "presentation/slide" and (
2495
+ issue.get("attr") is not None or issue.get("code") == "sxsd_invalid_namespace"
2496
+ )
2497
+
2498
+
2034
2499
  def build_result(
2035
2500
  source_path: str | None,
2036
2501
  slide_size: dict[str, int | float],
@@ -2039,8 +2504,10 @@ def build_result(
2039
2504
  ) -> dict[str, Any]:
2040
2505
  document_errors = [issue for issue in top_level_issues if issue["level"] == "error"]
2041
2506
  document_warnings = [issue for issue in top_level_issues if issue["level"] == "warning"]
2507
+ document_infos = [issue for issue in top_level_issues if issue["level"] == "info"]
2042
2508
  error_count = len(document_errors) + sum(len(slide["errors"]) for slide in slides)
2043
2509
  warning_count = len(document_warnings) + sum(len(slide["warnings"]) for slide in slides)
2510
+ info_count = len(document_infos) + sum(len(slide["infos"]) for slide in slides)
2044
2511
  all_errors = document_errors + [issue for slide in slides for issue in slide["errors"]]
2045
2512
  all_warnings = document_warnings + [issue for slide in slides for issue in slide["warnings"]]
2046
2513
  status = slide_status(all_errors, all_warnings)
@@ -2053,6 +2520,7 @@ def build_result(
2053
2520
  "slide_count": len(slides),
2054
2521
  "error_count": error_count,
2055
2522
  "warning_count": warning_count,
2523
+ "info_count": info_count,
2056
2524
  "status": status,
2057
2525
  "release_ready": error_count == 0,
2058
2526
  "screenshot_review_required": warning_count > 0,
@@ -2060,6 +2528,7 @@ def build_result(
2060
2528
  "document": {
2061
2529
  "errors": document_errors,
2062
2530
  "warnings": document_warnings,
2531
+ "infos": document_infos,
2063
2532
  },
2064
2533
  "slides": slides,
2065
2534
  }
@@ -2082,11 +2551,20 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
2082
2551
  raise AssertionError("parse_xml_root must return a root or error")
2083
2552
 
2084
2553
  namespace_issues = validate_sml_tag_prefixes(xml)
2085
- sxsd_issues = validate_sxsd_tag_attributes(root)
2554
+ root_name = xml_local_name(root.tag)
2555
+ sxsd_issues = validate_sxsd_document(xml, root)
2086
2556
  iconpark_issues = validate_iconpark_icon_types(root)
2087
2557
  top_level_issues = [
2088
2558
  normalize_issue(issue, None, {})
2089
- for issue in [*namespace_issues, *sxsd_issues, *iconpark_issues]
2559
+ for issue in [
2560
+ *namespace_issues,
2561
+ *[
2562
+ issue
2563
+ for issue in sxsd_issues
2564
+ if not is_slide_scoped_sxsd_issue(issue, root_name)
2565
+ ],
2566
+ *iconpark_issues,
2567
+ ]
2090
2568
  ]
2091
2569
  if any(issue["level"] == "error" for issue in top_level_issues):
2092
2570
  return build_result(
@@ -2096,10 +2574,36 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
2096
2574
  [],
2097
2575
  )
2098
2576
 
2099
- presentation = parse_presentation(xml)
2577
+ presentation = parse_presentation(root)
2578
+ slide_roots = presentation["slide_roots"]
2100
2579
  slides: list[dict[str, Any]] = []
2101
2580
  for index, slide_xml in enumerate(presentation["slides"]):
2102
2581
  slide_number = index + 1
2582
+ slide_root = slide_roots[index]
2583
+ slide_sxsd_issues = [
2584
+ normalize_issue(issue, slide_number, {})
2585
+ for issue in validate_sxsd_document(slide_xml, slide_root)
2586
+ ]
2587
+ slide_sxsd_errors = [
2588
+ issue for issue in slide_sxsd_issues if issue["level"] == "error"
2589
+ ]
2590
+ if slide_sxsd_errors:
2591
+ slide_sxsd_warnings = [
2592
+ issue for issue in slide_sxsd_issues if issue["level"] == "warning"
2593
+ ]
2594
+ slides.append(
2595
+ {
2596
+ "slide_number": slide_number,
2597
+ "status": slide_status(slide_sxsd_errors, slide_sxsd_warnings),
2598
+ "element_count": 0,
2599
+ "errors": slide_sxsd_errors,
2600
+ "warnings": slide_sxsd_warnings,
2601
+ "infos": [],
2602
+ "issues": slide_sxsd_issues,
2603
+ }
2604
+ )
2605
+ continue
2606
+
2103
2607
  geometry = lint_slide(
2104
2608
  slide_xml,
2105
2609
  slide_number,
@@ -2145,11 +2649,15 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
2145
2649
  ),
2146
2650
  ]
2147
2651
  issues = [
2148
- normalize_issue(issue, slide_number, elements_by_id)
2149
- for issue in raw_issues
2652
+ *slide_sxsd_issues,
2653
+ *[
2654
+ normalize_issue(issue, slide_number, elements_by_id)
2655
+ for issue in raw_issues
2656
+ ],
2150
2657
  ]
2151
2658
  errors = [issue for issue in issues if issue["level"] == "error"]
2152
2659
  warnings = [issue for issue in issues if issue["level"] == "warning"]
2660
+ infos = [issue for issue in issues if issue["level"] == "info"]
2153
2661
  slides.append(
2154
2662
  {
2155
2663
  "slide_number": slide_number,
@@ -2157,6 +2665,7 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
2157
2665
  "element_count": len(elements_by_id),
2158
2666
  "errors": errors,
2159
2667
  "warnings": warnings,
2668
+ "infos": infos,
2160
2669
  "issues": issues,
2161
2670
  }
2162
2671
  )