@amaster.ai/pi-lark 0.1.7 → 0.1.8

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 (135) hide show
  1. package/package.json +2 -2
  2. package/skills/lark-apps/SKILL.md +39 -6
  3. package/skills/lark-apps/references/lark-apps-cloud-dev.md +5 -4
  4. package/skills/lark-apps/references/lark-apps-create.md +6 -3
  5. package/skills/lark-apps/references/lark-apps-get.md +1 -1
  6. package/skills/lark-apps/references/lark-apps-list.md +1 -1
  7. package/skills/lark-apps/references/lark-apps-local-dev.md +27 -1
  8. package/skills/lark-apps/references/lark-apps-release-create.md +1 -1
  9. package/skills/lark-base/SKILL.md +14 -6
  10. package/skills/lark-base/references/lark-base-dashboard-block-get-data.md +17 -1
  11. package/skills/lark-base/references/lark-base-dashboard.md +17 -4
  12. package/skills/lark-base/references/lark-base-data-query-guide.md +8 -0
  13. package/skills/lark-base/references/lark-base-field-create.md +19 -8
  14. package/skills/lark-base/references/lark-base-field-json.md +5 -2
  15. package/skills/lark-calendar/SKILL.md +1 -1
  16. package/skills/lark-doc/SKILL.md +26 -61
  17. package/skills/lark-doc/references/genres/business-analysis.md +30 -0
  18. package/skills/lark-doc/references/genres/data-report.md +32 -0
  19. package/skills/lark-doc/references/genres/email.md +38 -0
  20. package/skills/lark-doc/references/genres/execution-plan.md +27 -0
  21. package/skills/lark-doc/references/genres/formal-doc.md +37 -0
  22. package/skills/lark-doc/references/genres/meeting-minutes.md +24 -0
  23. package/skills/lark-doc/references/genres/memo-brief.md +25 -0
  24. package/skills/lark-doc/references/genres/official-redhead.md +73 -0
  25. package/skills/lark-doc/references/genres/prd.md +26 -0
  26. package/skills/lark-doc/references/genres/proposal.md +24 -0
  27. package/skills/lark-doc/references/genres/research-report.md +32 -0
  28. package/skills/lark-doc/references/genres/retrospective.md +25 -0
  29. package/skills/lark-doc/references/genres/route-consumer.md +37 -0
  30. package/skills/lark-doc/references/genres/route-creative.md +36 -0
  31. package/skills/lark-doc/references/genres/route-knowledge.md +39 -0
  32. package/skills/lark-doc/references/genres/route-marketing.md +40 -0
  33. package/skills/lark-doc/references/genres/route-media.md +36 -0
  34. package/skills/lark-doc/references/genres/route-opinion.md +38 -0
  35. package/skills/lark-doc/references/genres/route-personal-brand.md +36 -0
  36. package/skills/lark-doc/references/genres/route-platform.md +9 -0
  37. package/skills/lark-doc/references/genres/route-report.md +10 -0
  38. package/skills/lark-doc/references/genres/route-workplace.md +17 -0
  39. package/skills/lark-doc/references/genres/sop-tutorial.md +41 -0
  40. package/skills/lark-doc/references/genres/technical-doc.md +39 -0
  41. package/skills/lark-doc/references/genres/wechat.md +39 -0
  42. package/skills/lark-doc/references/genres/weekly-report.md +24 -0
  43. package/skills/lark-doc/references/genres/white-paper.md +32 -0
  44. package/skills/lark-doc/references/genres/xiaohongshu.md +38 -0
  45. package/skills/lark-doc/references/lark-doc-create-workflow.md +121 -0
  46. package/skills/lark-doc/references/lark-doc-create.md +22 -48
  47. package/skills/lark-doc/references/lark-doc-fetch.md +75 -92
  48. package/skills/lark-doc/references/lark-doc-history.md +16 -15
  49. package/skills/lark-doc/references/lark-doc-md.md +5 -1
  50. package/skills/lark-doc/references/lark-doc-media-download.md +2 -1
  51. package/skills/lark-doc/references/lark-doc-script.md +76 -0
  52. package/skills/lark-doc/references/lark-doc-update.md +70 -222
  53. package/skills/lark-doc/references/lark-doc-whiteboard.md +5 -9
  54. package/skills/lark-doc/references/lark-doc-xml-extended-blocks.md +17 -12
  55. package/skills/lark-doc/references/lark-doc-xml.md +38 -167
  56. package/skills/lark-drive/SKILL.md +7 -5
  57. package/skills/lark-drive/references/lark-drive-apply-permission.md +1 -1
  58. package/skills/lark-drive/references/lark-drive-copy.md +87 -0
  59. package/skills/lark-drive/references/lark-drive-download.md +2 -1
  60. package/skills/lark-drive/references/lark-drive-export.md +3 -0
  61. package/skills/lark-drive/references/lark-drive-task-result.md +3 -0
  62. package/skills/lark-drive/references/lark-drive-update-title.md +78 -0
  63. package/skills/lark-event/SKILL.md +7 -4
  64. package/skills/lark-event/references/lark-event-vc.md +8 -2
  65. package/skills/lark-im/SKILL.md +8 -8
  66. package/skills/lark-im/references/lark-im-chat-list.md +9 -2
  67. package/skills/lark-im/references/lark-im-chat-members-list.md +7 -4
  68. package/skills/lark-im/references/lark-im-chat-messages-list.md +10 -3
  69. package/skills/lark-im/references/lark-im-chat-search.md +9 -2
  70. package/skills/lark-im/references/lark-im-feed-group-list-item.md +2 -2
  71. package/skills/lark-im/references/lark-im-feed-group-list.md +2 -2
  72. package/skills/lark-im/references/lark-im-feed-shortcut-list.md +1 -1
  73. package/skills/lark-im/references/lark-im-flag-list.md +2 -2
  74. package/skills/lark-im/references/lark-im-message-enrichment.md +1 -1
  75. package/skills/lark-im/references/lark-im-messages-resources-download.md +19 -25
  76. package/skills/lark-im/references/lark-im-messages-search.md +4 -5
  77. package/skills/lark-im/references/lark-im-threads-messages-list.md +8 -4
  78. package/skills/lark-mail/references/lark-mail-triage.md +19 -4
  79. package/skills/lark-minutes/SKILL.md +1 -1
  80. package/skills/lark-minutes/references/lark-minutes-search.md +6 -7
  81. package/skills/lark-shared/SKILL.md +3 -3
  82. package/skills/lark-sheets/SKILL.md +83 -82
  83. package/skills/lark-sheets/references/lark-sheets-batch-update.md +13 -58
  84. package/skills/lark-sheets/references/lark-sheets-chart.md +2 -1
  85. package/skills/lark-sheets/references/lark-sheets-conditional-format.md +1 -1
  86. package/skills/lark-sheets/references/lark-sheets-range-operations.md +5 -5
  87. package/skills/lark-sheets/references/lark-sheets-read-data.md +80 -6
  88. package/skills/lark-sheets/references/lark-sheets-sheet-structure.md +21 -10
  89. package/skills/lark-sheets/references/lark-sheets-styles-put.md +93 -0
  90. package/skills/lark-sheets/references/lark-sheets-visual-standards.md +2 -2
  91. package/skills/lark-sheets/references/lark-sheets-workbook.md +4 -3
  92. package/skills/lark-sheets/references/lark-sheets-write-cells.md +40 -12
  93. package/skills/lark-sheets/scripts/lark_detect_subtables.py +593 -0
  94. package/skills/lark-sheets/scripts/lark_inspect_workbook.py +188 -0
  95. package/skills/lark-sheets/scripts/lark_profile_table.py +614 -0
  96. package/skills/lark-sheets/scripts/lark_sheet_range.py +176 -0
  97. package/skills/lark-sheets/scripts/lark_sheet_read_cli.py +184 -0
  98. package/skills/lark-sheets/scripts/sheets_df.py +21 -3
  99. package/skills/lark-slides/SKILL.md +27 -44
  100. package/skills/lark-slides/references/lark-slides-add-slide.md +92 -0
  101. package/skills/lark-slides/references/lark-slides-create.md +77 -65
  102. package/skills/lark-slides/references/lark-slides-delete-slide.md +65 -0
  103. package/skills/lark-slides/references/lark-slides-edit-workflows.md +6 -7
  104. package/skills/lark-slides/references/lark-slides-media-upload.md +3 -25
  105. package/skills/lark-slides/references/lark-slides-replace-slide.md +22 -1
  106. package/skills/lark-slides/references/lark-slides-screenshot.md +31 -13
  107. package/skills/lark-slides/references/lark-slides-update-slide.md +146 -0
  108. package/skills/lark-slides/references/lark-slides-xml-presentations-get.md +31 -8
  109. package/skills/lark-slides/references/slides_chart_demo.xml +1 -2
  110. package/skills/lark-slides/references/slides_xml_schema_definition.xml +48 -4
  111. package/skills/lark-slides/references/troubleshooting.md +7 -8
  112. package/skills/lark-slides/references/validation-checklist.md +4 -4
  113. package/skills/lark-slides/references/xml-schema-quick-ref.md +23 -11
  114. package/skills/lark-slides/scripts/sxsd_validator.py +154 -10
  115. package/skills/lark-slides/scripts/xml_text_overlap_lint.py +360 -76
  116. package/skills/lark-slides/scripts/xml_text_overlap_lint_test.py +1138 -214
  117. package/skills/lark-whiteboard/SKILL.md +15 -8
  118. package/skills/lark-whiteboard/references/lark-whiteboard-export.md +4 -3
  119. package/skills/lark-whiteboard/references/lark-whiteboard-update.md +4 -4
  120. package/skills/lark-whiteboard/references/lark-whiteboard-workflow.md +19 -17
  121. package/skills/lark-whiteboard/routes/dsl.md +8 -2
  122. package/skills/lark-whiteboard/routes/mermaid.md +1 -1
  123. package/skills/lark-whiteboard/routes/svg-edit.md +5 -2
  124. package/skills/lark-whiteboard/routes/svg.md +3 -1
  125. package/skills/lark-whiteboard/scenes/mention.md +71 -0
  126. package/skills/lark-wiki/SKILL.md +5 -3
  127. package/skills/lark-wiki/references/lark-wiki-delete-space.md +6 -3
  128. package/skills/lark-doc/references/lark-doc-word-stat.md +0 -93
  129. package/skills/lark-doc/references/style/lark-doc-create-workflow.md +0 -47
  130. package/skills/lark-doc/references/style/lark-doc-style.md +0 -68
  131. package/skills/lark-doc/references/style/lark-doc-update-workflow.md +0 -48
  132. package/skills/lark-doc/scripts/doc_word_stat.py +0 -1243
  133. package/skills/lark-slides/references/lark-slides-replace-pages.md +0 -95
  134. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-create.md +0 -219
  135. package/skills/lark-slides/references/lark-slides-xml-presentation-slide-delete.md +0 -126
@@ -23,7 +23,7 @@ import sxsd_validator
23
23
  XS_NS = "{http://www.w3.org/2001/XMLSchema}"
24
24
  XML_NS = "{http://www.w3.org/XML/1998/namespace}"
25
25
  SVG_NS = "{http://www.w3.org/2000/svg}"
26
- SML_NAMESPACE = "http://www.larkoffice.com/sml/2.0"
26
+ SML_NAMESPACE = "https://www.larkoffice.com/sml/2.0"
27
27
  SXSD_SCHEMA_PATH = Path(__file__).resolve().parents[1] / "references" / "slides_xml_schema_definition.xml"
28
28
  ICONPARK_INDEX_PATH = Path(__file__).resolve().parents[1] / "references" / "iconpark-index.json"
29
29
  SXSD_TAG_ALIASES = {
@@ -72,6 +72,7 @@ LINE_MIN_VISIBLE_ALPHA = 0.08
72
72
  # Sub-pixel canvas overflow is floating-point rounding noise (e.g. rotated-bbox math), not a
73
73
  # visible defect; keep this well under 1px so real overflow is still always caught.
74
74
  CANVAS_OVERFLOW_TOLERANCE = 0.5
75
+ XML_PATH_HINT_PREFIX = "Locate via related_objects[].xml_path."
75
76
  _SXSD_TAG_ATTRIBUTES_CACHE: dict[str, set[str]] | None = None
76
77
  _ICONPARK_ICON_TYPES_CACHE: set[str] | None = None
77
78
 
@@ -348,7 +349,7 @@ def build_sxsd_tag_hint(tag_name: str, supported_tags: set[str]) -> str:
348
349
  if alias:
349
350
  return f"Use {alias} instead of <{tag_name}>."
350
351
  if tag_name == "svg":
351
- return 'Inside <whiteboard>, write SVG as <svg xmlns="http://www.w3.org/2000/svg">...</svg>.'
352
+ return 'Inside <embed> or <whiteboard>, write SVG as <svg xmlns="http://www.w3.org/2000/svg">...</svg>.'
352
353
  close_matches = get_close_matches(tag_name, sorted(supported_tags), n=3, cutoff=0.72)
353
354
  if close_matches:
354
355
  return "Unsupported SXSD tag. Did you mean " + ", ".join(f"<{match}>" for match in close_matches) + "?"
@@ -375,7 +376,7 @@ def build_sxsd_attr_hint(tag_name: str, attr_name: str, allowed_attrs: set[str])
375
376
 
376
377
 
377
378
  def should_skip_sxsd_subtree(element: ET.Element, ancestors: list[str]) -> bool:
378
- return "whiteboard" in ancestors and xml_namespace(element.tag) == SVG_NS
379
+ return ("whiteboard" in ancestors or "embed" in ancestors) and xml_namespace(element.tag) == SVG_NS
379
380
 
380
381
 
381
382
  def should_skip_sxsd_attribute(tag_name: str, attr_name: str) -> bool:
@@ -532,6 +533,53 @@ def _validate_sxsd_schema_constraints(xml: str, root: ET.Element) -> list[dict[s
532
533
  SXSD_SCHEMA_PATH,
533
534
  )
534
535
  )
536
+ issues.extend(validate_embed_svg_roots(root))
537
+ return issues
538
+
539
+
540
+ def validate_embed_svg_roots(root: ET.Element) -> list[dict[str, Any]]:
541
+ issues: list[dict[str, Any]] = []
542
+ document_namespace = sxsd_validator.element_namespace(root.tag)
543
+ is_bare_slide_fragment = (
544
+ xml_local_name(root.tag) == "slide" and document_namespace is None
545
+ )
546
+ if (
547
+ document_namespace not in sxsd_validator.ACCEPTED_SML_NAMESPACES
548
+ and not is_bare_slide_fragment
549
+ ):
550
+ return issues
551
+
552
+ def visit(element: ET.Element, ancestors: list[str], parent_path: str) -> None:
553
+ if should_skip_sxsd_subtree(element, ancestors):
554
+ return
555
+
556
+ tag_name = xml_local_name(element.tag)
557
+ path = f"{parent_path}/{tag_name}" if parent_path else tag_name
558
+ if (
559
+ tag_name == "embed"
560
+ and sxsd_validator.element_namespace(element.tag) == document_namespace
561
+ ):
562
+ for child in element:
563
+ if xml_namespace(child.tag) != SVG_NS or xml_local_name(child.tag) == "svg":
564
+ continue
565
+ child_name = xml_local_name(child.tag)
566
+ child_path = f"{path}/{child_name}"
567
+ issues.append(
568
+ {
569
+ "level": "error",
570
+ "code": "sxsd_unexpected_child",
571
+ "path": child_path,
572
+ "tag": child_name,
573
+ "expected": '<svg xmlns="http://www.w3.org/2000/svg">',
574
+ "actual": child_name,
575
+ "message": f"embedded SVG content must use an <svg> root at {child_path}",
576
+ "hint": 'Wrap the SVG content in <svg xmlns="http://www.w3.org/2000/svg">...</svg>.',
577
+ }
578
+ )
579
+ for child in element:
580
+ visit(child, [*ancestors, tag_name], path)
581
+
582
+ visit(root, [], "")
535
583
  return issues
536
584
 
537
585
 
@@ -577,8 +625,11 @@ def validate_iconpark_icon_types(root: ET.Element) -> list[dict[str, Any]]:
577
625
  }
578
626
  )
579
627
 
580
- def visit(element: ET.Element, path: str) -> None:
628
+ def visit(element: ET.Element, ancestors: list[str], path: str) -> None:
581
629
  nonlocal supported_icon_types
630
+ if should_skip_sxsd_subtree(element, ancestors):
631
+ return
632
+
582
633
  tag_name = xml_local_name(element.tag)
583
634
  current_path = f"{path}/{tag_name}" if path else tag_name
584
635
  if tag_name == "icon":
@@ -618,9 +669,9 @@ def validate_iconpark_icon_types(root: ET.Element) -> list[dict[str, Any]]:
618
669
  }
619
670
  )
620
671
  for child in element:
621
- visit(child, current_path)
672
+ visit(child, [*ancestors, tag_name], current_path)
622
673
 
623
- visit(root, "")
674
+ visit(root, [], "")
624
675
  return issues
625
676
 
626
677
 
@@ -683,7 +734,8 @@ def validate_sml_tag_prefixes(xml: str) -> list[dict[str, Any]]:
683
734
  if not prefix:
684
735
  return
685
736
 
686
- if namespace_map.get(prefix) != SML_NAMESPACE:
737
+ actual_namespace = namespace_map.get(prefix)
738
+ if actual_namespace not in sxsd_validator.ACCEPTED_SML_NAMESPACES:
687
739
  return
688
740
  path = "/".join(element_stack)
689
741
  issues.append(
@@ -691,7 +743,7 @@ def validate_sml_tag_prefixes(xml: str) -> list[dict[str, Any]]:
691
743
  "level": "error",
692
744
  "code": "sml_prefixed_tag",
693
745
  "tag": element_name,
694
- "namespace": SML_NAMESPACE,
746
+ "namespace": actual_namespace,
695
747
  "path": path,
696
748
  "line": parser.CurrentLineNumber,
697
749
  "column": parser.CurrentColumnNumber,
@@ -770,11 +822,106 @@ def parse_presentation(root: ET.Element) -> dict[str, Any]:
770
822
  }
771
823
 
772
824
 
825
+ def build_source_xml_paths(slide_xml: str, slide_number: int) -> dict[str, list[str]]:
826
+ root = ET.fromstring(slide_xml)
827
+ data = next((child for child in root if xml_local_name(child.tag) == "data"), None)
828
+ paths: dict[str, list[str]] = {}
829
+ if data is None:
830
+ return paths
831
+ counts: dict[str, int] = {}
832
+ for child in data:
833
+ kind = xml_local_name(child.tag)
834
+ counts[kind] = counts.get(kind, 0) + 1
835
+ paths.setdefault(kind, []).append(
836
+ f"slide[{slide_number}]/data/{kind}[{counts[kind]}]"
837
+ )
838
+ return paths
839
+
840
+
841
+ def attach_source_xml_paths(
842
+ elements: list[dict[str, Any]], source_paths: dict[str, list[str]]
843
+ ) -> None:
844
+ offsets: dict[str, int] = {}
845
+ for element in elements:
846
+ kind = element["kind"]
847
+ source_kind_index = element.get("_source_kind_index")
848
+ offset = (
849
+ source_kind_index - 1
850
+ if isinstance(source_kind_index, int) and source_kind_index > 0
851
+ else offsets.get(kind, 0)
852
+ )
853
+ kind_paths = source_paths.get(kind, [])
854
+ if offset < len(kind_paths):
855
+ element["xml_path"] = kind_paths[offset]
856
+ element["_ref"] = kind_paths[offset]
857
+ offsets[kind] = max(offsets.get(kind, 0), offset + 1)
858
+
859
+
860
+ def extract_source_id_elements(slide_xml: str, slide_number: int) -> list[dict[str, Any]]:
861
+ root = ET.fromstring(slide_xml)
862
+ elements: list[dict[str, Any]] = []
863
+ root_path = f"slide[{slide_number}]"
864
+
865
+ def walk(parent: ET.Element, parent_path: str) -> None:
866
+ child_counts: dict[str, int] = {}
867
+ for child in parent:
868
+ kind = xml_local_name(child.tag)
869
+ child_counts[kind] = child_counts.get(kind, 0) + 1
870
+ xml_path = (
871
+ f"{parent_path}/data"
872
+ if parent is root and kind == "data"
873
+ else f"{parent_path}/{kind}[{child_counts[kind]}]"
874
+ )
875
+ source_id = extract_attribute(
876
+ ET.tostring(child, encoding="unicode").split(">", 1)[0], "id"
877
+ )
878
+ if source_id:
879
+ elements.append(
880
+ {
881
+ "id": source_id,
882
+ "_source_id": source_id,
883
+ "kind": kind,
884
+ "type": child.attrib.get("type") or kind,
885
+ "xml_path": xml_path,
886
+ "_ref": xml_path,
887
+ "_slide_number": slide_number,
888
+ }
889
+ )
890
+ walk(child, xml_path)
891
+
892
+ walk(root, root_path)
893
+ return elements
894
+
895
+
896
+ def element_ref(element: dict[str, Any]) -> str:
897
+ ref = element.get("_ref") or element.get("xml_path")
898
+ if isinstance(ref, str) and ref:
899
+ return ref
900
+ # Low-level detector tests and external callers may pass already-extracted objects.
901
+ # The lint_xml pipeline always attaches the source path before issue detection.
902
+ fallback = element.get("id")
903
+ if isinstance(fallback, str) and fallback:
904
+ return fallback
905
+ raise AssertionError("lint element must have a source xml path or fallback id")
906
+
907
+
908
+ def source_element_id(element: dict[str, Any]) -> str | None:
909
+ value = element.get("_source_id")
910
+ return value if isinstance(value, str) and value else None
911
+
912
+
913
+ def element_label(element: dict[str, Any]) -> str:
914
+ return source_element_id(element) or element_ref(element)
915
+
916
+
773
917
  def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
774
918
  elements: list[dict[str, Any]] = []
919
+ source_kind_counts: dict[str, int] = {}
775
920
 
776
- for match in re.finditer(r"<(shape|img|table|chart|whiteboard)\b([^>]*)>", slide_xml):
921
+ for match in re.finditer(r"<(shape|img|table|chart|whiteboard|embed)\b([^>]*)>", slide_xml):
777
922
  kind, attrs = match.group(1), match.group(2)
923
+ source_kind_counts[kind] = source_kind_counts.get(kind, 0) + 1
924
+ source_kind_index = source_kind_counts[kind]
778
925
  is_self_closing = attrs.rstrip().endswith("/")
779
926
  content = ""
780
927
  if kind in {"shape", "table"} and not is_self_closing:
@@ -782,7 +929,8 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
782
929
  if close_index != -1:
783
930
  content = slide_xml[match.end() : close_index]
784
931
 
785
- element_id = extract_attribute(attrs, "id") or f"{kind}-{len(elements) + 1}"
932
+ source_id = extract_attribute(attrs, "id") or None
933
+ element_id = source_id or f"{kind}-{len(elements) + 1}"
786
934
  x = extract_numeric_attribute(attrs, "topLeftX")
787
935
  y = extract_numeric_attribute(attrs, "topLeftY")
788
936
  width = extract_numeric_attribute(attrs, "width")
@@ -800,6 +948,7 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
800
948
  if all(value is not None for value in [x, y, width, height]):
801
949
  element = {
802
950
  "id": element_id,
951
+ "_source_id": source_id,
803
952
  "kind": kind,
804
953
  "type": extract_attribute(attrs, "type") or kind,
805
954
  "x": x,
@@ -809,6 +958,7 @@ def extract_elements(slide_xml: str) -> list[dict[str, Any]]:
809
958
  "rotation": rotation,
810
959
  "alpha": alpha if alpha is not None else 1,
811
960
  "order": len(elements),
961
+ "_source_kind_index": source_kind_index,
812
962
  }
813
963
  if kind == "table":
814
964
  element.update(
@@ -909,8 +1059,11 @@ def detect_image_text_occlusions(elements: list[dict[str, Any]]) -> list[dict[st
909
1059
  issues.append({
910
1060
  "level": "info",
911
1061
  "code": "image_may_cover_vertical_text",
912
- "elements": [image_element["id"], text_element["id"]],
913
- "message": f'image {image_element["id"]} may cover vertical text shape {text_element["id"]}',
1062
+ "elements": [element_ref(image_element), element_ref(text_element)],
1063
+ "message": (
1064
+ f"image {element_label(image_element)} may cover vertical text shape "
1065
+ f"{element_label(text_element)}"
1066
+ ),
914
1067
  "hint": "Inspect the rendered slide because vertical text layout is not statically modeled.",
915
1068
  })
916
1069
  continue
@@ -919,8 +1072,11 @@ def detect_image_text_occlusions(elements: list[dict[str, Any]]) -> list[dict[st
919
1072
  issues.append({
920
1073
  "level": "error",
921
1074
  "code": "image_covers_text",
922
- "elements": [image_element["id"], text_element["id"]],
923
- "message": f'image {image_element["id"]} covers text shape {text_element["id"]}',
1075
+ "elements": [element_ref(image_element), element_ref(text_element)],
1076
+ "message": (
1077
+ f"image {element_label(image_element)} covers text shape "
1078
+ f"{element_label(text_element)}"
1079
+ ),
924
1080
  "hint": "Move the image before the text shape in XML order, or adjust the image and text shape coordinates or dimensions.",
925
1081
  })
926
1082
  return issues
@@ -1186,7 +1342,7 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
1186
1342
  else:
1187
1343
  level = "error" if overflow > 10 else "warning"
1188
1344
  message = (
1189
- f'text shape {element["id"]} may overflow its own content box '
1345
+ f"text shape {element_label(element)} may overflow its own content box "
1190
1346
  f'(estimated {estimated_height:g}px, available {available_height:g}px); '
1191
1347
  'consider setting content wrap="true" autoFit="normal-auto-fit"'
1192
1348
  )
@@ -1196,7 +1352,7 @@ def detect_text_may_overflow_shapes(elements: list[dict[str, Any]]) -> list[dict
1196
1352
  {
1197
1353
  "level": level,
1198
1354
  "code": "text_may_overflow_shape",
1199
- "elements": [element["id"]],
1355
+ "elements": [element_ref(element)],
1200
1356
  "line_count": line_count,
1201
1357
  "line_height": max(line_heights),
1202
1358
  "estimated_height": estimated_height,
@@ -1437,12 +1593,15 @@ def should_flag_overlap(left: dict[str, Any], right: dict[str, Any]) -> bool:
1437
1593
  def build_whiteboard_external_overlap_issue(
1438
1594
  whiteboard: dict[str, Any], overlap_details: list[dict[str, Any]]
1439
1595
  ) -> dict[str, Any]:
1440
- element_ids = [detail["element"] for detail in overlap_details]
1596
+ element_refs = [detail["element"] for detail in overlap_details]
1441
1597
  return {
1442
1598
  "level": "warning",
1443
1599
  "code": "whiteboard_external_overlap",
1444
- "elements": [whiteboard["id"], *element_ids],
1445
- "message": f'whiteboard {whiteboard["id"]} overlaps {len(element_ids)} sibling elements across its boundary',
1600
+ "elements": [element_ref(whiteboard), *element_refs],
1601
+ "message": (
1602
+ f"whiteboard {element_label(whiteboard)} overlaps {len(element_refs)} "
1603
+ "sibling elements across its boundary"
1604
+ ),
1446
1605
  "hint": (
1447
1606
  "Treat this as a static whiteboard container-bbox risk, not final visual proof. "
1448
1607
  "After moving or accepting the overlap, use screenshot QA or equivalent rendered visual inspection as "
@@ -1483,7 +1642,7 @@ def should_report_whiteboard_overlap(
1483
1642
  return None
1484
1643
 
1485
1644
  return {
1486
- "element": other["id"],
1645
+ "element": element_ref(other),
1487
1646
  "kind": other["kind"],
1488
1647
  "type": other.get("type"),
1489
1648
  "overlap_width": overlap_width,
@@ -1493,16 +1652,16 @@ def should_report_whiteboard_overlap(
1493
1652
 
1494
1653
 
1495
1654
  def prune_contained_text_overlap_details(
1496
- overlap_details: list[dict[str, Any]], elements_by_id: dict[str, dict[str, Any]]
1655
+ overlap_details: list[dict[str, Any]], elements_by_ref: dict[str, dict[str, Any]]
1497
1656
  ) -> list[dict[str, Any]]:
1498
1657
  pruned: list[dict[str, Any]] = []
1499
1658
  for detail in overlap_details:
1500
- element = elements_by_id[detail["element"]]
1659
+ element = elements_by_ref[detail["element"]]
1501
1660
  if is_text_element(element):
1502
1661
  has_reported_container = any(
1503
1662
  detail["element"] != other_detail["element"]
1504
- and not is_text_element(elements_by_id[other_detail["element"]])
1505
- and contains(elements_by_id[other_detail["element"]], element)
1663
+ and not is_text_element(elements_by_ref[other_detail["element"]])
1664
+ and contains(elements_by_ref[other_detail["element"]], element)
1506
1665
  for other_detail in overlap_details
1507
1666
  )
1508
1667
  if has_reported_container:
@@ -1515,7 +1674,7 @@ def detect_whiteboard_external_overlaps(
1515
1674
  elements: list[dict[str, Any]], slide_width: int | float, slide_height: int | float
1516
1675
  ) -> list[dict[str, Any]]:
1517
1676
  issues: list[dict[str, Any]] = []
1518
- elements_by_id = {element["id"]: element for element in elements}
1677
+ elements_by_ref = {element_ref(element): element for element in elements}
1519
1678
  for whiteboard in [element for element in elements if is_whiteboard_element(element)]:
1520
1679
  overlap_details = [
1521
1680
  detail
@@ -1530,7 +1689,7 @@ def detect_whiteboard_external_overlaps(
1530
1689
  )
1531
1690
  is not None
1532
1691
  ]
1533
- overlap_details = prune_contained_text_overlap_details(overlap_details, elements_by_id)
1692
+ overlap_details = prune_contained_text_overlap_details(overlap_details, elements_by_ref)
1534
1693
  if overlap_details:
1535
1694
  issues.append(build_whiteboard_external_overlap_issue(whiteboard, overlap_details))
1536
1695
  return issues
@@ -1589,12 +1748,12 @@ def detect_elements_out_of_canvas(
1589
1748
  {
1590
1749
  "level": "error",
1591
1750
  "code": f'{element["kind"]}_out_of_canvas',
1592
- "elements": [element["id"]],
1751
+ "elements": [element_ref(element)],
1593
1752
  "canvas": {"width": slide_width, "height": slide_height},
1594
1753
  "bbox": bbox,
1595
1754
  "overflow": overflow,
1596
1755
  "message": (
1597
- f'{element["kind"]} {element["id"]} exceeds the {slide_width:g}x{slide_height:g} canvas '
1756
+ f'{element["kind"]} {element_label(element)} exceeds the {slide_width:g}x{slide_height:g} canvas '
1598
1757
  f'({", ".join(overflow_details)})'
1599
1758
  ),
1600
1759
  "hint": (
@@ -1662,13 +1821,13 @@ def detect_table_layout_size_mismatches(elements: list[dict[str, Any]]) -> list[
1662
1821
  {
1663
1822
  "level": "info",
1664
1823
  "code": "table_resolved_size_mismatch",
1665
- "elements": [table["id"]],
1824
+ "elements": [element_ref(table)],
1666
1825
  "dimension": dimension,
1667
1826
  "declared_size": target_size,
1668
1827
  "resolved_size": actual_size,
1669
1828
  "resolved_sizes": layout["final_sizes"],
1670
1829
  "message": (
1671
- f'table {table["id"]} declares {dimension}={format_size(target_size)}px, but its '
1830
+ f'table {element_label(table)} declares {dimension}={format_size(target_size)}px, but its '
1672
1831
  f"{child_description} resolve to {format_size(actual_size)}px"
1673
1832
  ),
1674
1833
  "hint": (
@@ -1744,11 +1903,12 @@ def line_crosses_text(line: dict[str, Any], text_element: dict[str, Any]) -> boo
1744
1903
 
1745
1904
 
1746
1905
  def detect_line_text_crossings(
1747
- slide_xml: str, elements: list[dict[str, Any]]
1906
+ slide_xml: str, elements: list[dict[str, Any]], slide_number: int
1748
1907
  ) -> list[dict[str, Any]]:
1749
1908
  lines = extract_line_elements(slide_xml)
1750
1909
  if not lines:
1751
1910
  return []
1911
+ attach_source_xml_paths(lines, build_source_xml_paths(slide_xml, slide_number))
1752
1912
  text_elements = [element for element in elements if is_text_element(element)]
1753
1913
  issues: list[dict[str, Any]] = []
1754
1914
  for line in lines:
@@ -1759,8 +1919,10 @@ def detect_line_text_crossings(
1759
1919
  {
1760
1920
  "level": "error",
1761
1921
  "code": "bbox_overlap",
1762
- "elements": [line["id"], text_element["id"]],
1763
- "message": f'line {line["id"]} crosses text {text_element["id"]}',
1922
+ "elements": [element_ref(line), element_ref(text_element)],
1923
+ "message": (
1924
+ f"line {element_label(line)} crosses text {element_label(text_element)}"
1925
+ ),
1764
1926
  "hint": "Move the line off the text glyphs so it no longer cuts through the letterforms.",
1765
1927
  }
1766
1928
  )
@@ -1771,13 +1933,14 @@ def lint_slide(
1771
1933
  slide_xml: str, slide_number: int, slide_width: int | float = 960, slide_height: int | float = 540
1772
1934
  ) -> dict[str, Any]:
1773
1935
  elements = extract_elements(slide_xml)
1936
+ attach_source_xml_paths(elements, build_source_xml_paths(slide_xml, slide_number))
1774
1937
  issues: list[dict[str, Any]] = [
1775
1938
  *detect_whiteboard_external_overlaps(elements, slide_width, slide_height),
1776
1939
  *detect_elements_out_of_canvas(elements, slide_width, slide_height),
1777
1940
  *detect_table_layout_size_mismatches(elements),
1778
1941
  *detect_text_may_overflow_shapes(elements),
1779
1942
  *detect_image_text_occlusions(elements),
1780
- *detect_line_text_crossings(slide_xml, elements),
1943
+ *detect_line_text_crossings(slide_xml, elements, slide_number),
1781
1944
  ]
1782
1945
 
1783
1946
  for index, left in enumerate(elements):
@@ -1789,8 +1952,8 @@ def lint_slide(
1789
1952
  {
1790
1953
  "level": "error",
1791
1954
  "code": "bbox_overlap",
1792
- "elements": [left["id"], right["id"]],
1793
- "message": f'{left["id"]} overlaps {right["id"]}',
1955
+ "elements": [element_ref(left), element_ref(right)],
1956
+ "message": f"{element_label(left)} overlaps {element_label(right)}",
1794
1957
  "hint": "Move or resize the elements so their visual bounds no longer intersect.",
1795
1958
  **(
1796
1959
  {"measurement": horizontal_text_overflow_measurement(left, right)}
@@ -1934,14 +2097,22 @@ def is_nested_in_layout_panel(
1934
2097
  )
1935
2098
 
1936
2099
 
1937
- def extract_density_elements(slide_xml: str) -> list[dict[str, Any]]:
2100
+ def extract_density_elements(slide_xml: str, slide_number: int = 1) -> list[dict[str, Any]]:
1938
2101
  elements = extract_elements(slide_xml)
1939
- elements_by_id = {element["id"]: element for element in elements}
2102
+ source_paths = build_source_xml_paths(slide_xml, slide_number)
2103
+ attach_source_xml_paths(elements, source_paths)
2104
+ shape_elements_by_index = {
2105
+ element["_source_kind_index"]: element
2106
+ for element in elements
2107
+ if element["kind"] == "shape"
2108
+ }
1940
2109
  root = ET.fromstring(slide_xml)
2110
+ shape_index = 0
1941
2111
  for node in root.iter():
1942
2112
  if xml_local_name(node.tag) != "shape":
1943
2113
  continue
1944
- element = elements_by_id.get(node.attrib.get("id", ""))
2114
+ shape_index += 1
2115
+ element = shape_elements_by_index.get(shape_index)
1945
2116
  if element is None:
1946
2117
  continue
1947
2118
  content_node = next(
@@ -1986,8 +2157,11 @@ def extract_density_elements(slide_xml: str) -> list[dict[str, Any]]:
1986
2157
  continue
1987
2158
  if declared_font_sizes:
1988
2159
  element["fontSize"] = max(declared_font_sizes)
1989
- for match in re.finditer(r"<icon\b([^>]*)>", slide_xml):
2160
+ for source_kind_index, match in enumerate(
2161
+ re.finditer(r"<icon\b([^>]*)>", slide_xml), start=1
2162
+ ):
1990
2163
  attrs = match.group(1)
2164
+ source_id = extract_attribute(attrs, "id") or None
1991
2165
  x = extract_numeric_attribute(attrs, "topLeftX")
1992
2166
  y = extract_numeric_attribute(attrs, "topLeftY")
1993
2167
  width = extract_numeric_attribute(attrs, "width")
@@ -1997,7 +2171,8 @@ def extract_density_elements(slide_xml: str) -> list[dict[str, Any]]:
1997
2171
  icon_alpha = extract_numeric_attribute(attrs, "alpha")
1998
2172
  elements.append(
1999
2173
  {
2000
- "id": extract_attribute(attrs, "id") or f"icon-{len(elements) + 1}",
2174
+ "id": source_id or f"icon-{len(elements) + 1}",
2175
+ "_source_id": source_id,
2001
2176
  "kind": "icon",
2002
2177
  "type": "icon",
2003
2178
  "x": x,
@@ -2007,9 +2182,12 @@ def extract_density_elements(slide_xml: str) -> list[dict[str, Any]]:
2007
2182
  "rotation": extract_numeric_attribute(attrs, "rotation") or 0,
2008
2183
  "alpha": icon_alpha if icon_alpha is not None else 1,
2009
2184
  "order": len(elements),
2185
+ "_source_kind_index": source_kind_index,
2010
2186
  }
2011
2187
  )
2012
- for match in re.finditer(r"<polyline\b([^>]*)>", slide_xml):
2188
+ for source_kind_index, match in enumerate(
2189
+ re.finditer(r"<polyline\b([^>]*)>", slide_xml), start=1
2190
+ ):
2013
2191
  attrs = match.group(1)
2014
2192
  x = extract_numeric_attribute(attrs, "topLeftX")
2015
2193
  y = extract_numeric_attribute(attrs, "topLeftY")
@@ -2018,9 +2196,11 @@ def extract_density_elements(slide_xml: str) -> list[dict[str, Any]]:
2018
2196
  if any(value is None for value in (x, y, width, height)):
2019
2197
  continue
2020
2198
  polyline_alpha = extract_numeric_attribute(attrs, "alpha")
2199
+ source_id = extract_attribute(attrs, "id") or None
2021
2200
  elements.append(
2022
2201
  {
2023
- "id": extract_attribute(attrs, "id") or f"polyline-{len(elements) + 1}",
2202
+ "id": source_id or f"polyline-{len(elements) + 1}",
2203
+ "_source_id": source_id,
2024
2204
  "kind": "polyline",
2025
2205
  "type": "polyline",
2026
2206
  "x": x,
@@ -2030,11 +2210,15 @@ def extract_density_elements(slide_xml: str) -> list[dict[str, Any]]:
2030
2210
  "rotation": extract_numeric_attribute(attrs, "rotation") or 0,
2031
2211
  "alpha": polyline_alpha if polyline_alpha is not None else 1,
2032
2212
  "order": len(elements),
2213
+ "_source_kind_index": source_kind_index,
2033
2214
  }
2034
2215
  )
2035
2216
  for line_element in extract_line_elements(slide_xml):
2036
2217
  line_element["order"] = len(elements)
2037
2218
  elements.append(line_element)
2219
+ attach_source_xml_paths(elements, source_paths)
2220
+ for element in elements:
2221
+ element["_slide_number"] = slide_number
2038
2222
  return elements
2039
2223
 
2040
2224
 
@@ -2074,7 +2258,7 @@ def slide_content_visual_bbox(
2074
2258
  # a straight horizontal/vertical line has zero width or height in one axis; clipped_bbox
2075
2259
  # treats zero-area rects as invisible, so pad to its rendered stroke thickness instead.
2076
2260
  return clipped_bbox(line_stroke_bbox(element), slide_bbox)
2077
- if element["kind"] in {"img", "chart", "table", "whiteboard", "icon", "polyline"}:
2261
+ if element["kind"] in {"img", "chart", "table", "whiteboard", "embed", "icon", "polyline"}:
2078
2262
  return clipped_bbox(element, slide_bbox)
2079
2263
  return None
2080
2264
 
@@ -2112,7 +2296,7 @@ def is_slide_content_present(
2112
2296
 
2113
2297
 
2114
2298
  def is_large_visual_child(element: dict[str, Any], container: dict[str, Any]) -> bool:
2115
- if element["kind"] not in {"img", "chart", "table", "whiteboard"}:
2299
+ if element["kind"] not in {"img", "chart", "table", "whiteboard", "embed"}:
2116
2300
  return False
2117
2301
  if not is_visually_rendered(element):
2118
2302
  return False
@@ -2154,7 +2338,12 @@ def detect_sparse_container_content(
2154
2338
  "code": "sparse_container_content",
2155
2339
  "target": {
2156
2340
  "slide_number": slide_number,
2157
- "container_id": container["id"],
2341
+ **(
2342
+ {"container_id": source_element_id(container)}
2343
+ if source_element_id(container) is not None
2344
+ else {}
2345
+ ),
2346
+ "container_xml_path": element_ref(container),
2158
2347
  "container_type": container["type"],
2159
2348
  "bbox": {key: container[key] for key in ("x", "y", "width", "height")},
2160
2349
  },
@@ -2169,7 +2358,10 @@ def detect_sparse_container_content(
2169
2358
  "content_coverage_ratio": round(coverage_ratio, 3),
2170
2359
  "content_element_count": len(children) + (1 if own_text_bbox else 0),
2171
2360
  },
2172
- "elements": [container["id"], *[child["id"] for child in children]],
2361
+ "elements": [
2362
+ element_ref(container),
2363
+ *[element_ref(child) for child in children],
2364
+ ],
2173
2365
  }
2174
2366
  )
2175
2367
  return issues
@@ -2210,7 +2402,7 @@ def detect_sparse_slide_content(
2210
2402
  "content_coverage_ratio": round(coverage_ratio, 3),
2211
2403
  "content_element_count": len(content),
2212
2404
  },
2213
- "elements": [element["id"] for element, _ in content],
2405
+ "elements": [element_ref(element) for element, _ in content],
2214
2406
  }
2215
2407
  ]
2216
2408
 
@@ -2241,13 +2433,45 @@ def detect_blank_slide(
2241
2433
  "visible_element_count": 0,
2242
2434
  "declared_element_count": len(elements),
2243
2435
  },
2244
- "elements": [element["id"] for element in elements],
2436
+ "elements": [element_ref(element) for element in elements],
2245
2437
  "message": "slide has no visible content beyond empty layout shapes",
2246
2438
  "hint": "Add visible text, an image, a chart, a table, a whiteboard, or an icon before creating the slide.",
2247
2439
  }
2248
2440
  ]
2249
2441
 
2250
2442
 
2443
+ def detect_duplicate_element_ids(
2444
+ elements: list[dict[str, Any]], *, cross_slide_only: bool = False
2445
+ ) -> list[dict[str, Any]]:
2446
+ elements_by_source_id: dict[str, list[dict[str, Any]]] = {}
2447
+ for element in elements:
2448
+ source_id = source_element_id(element)
2449
+ if source_id is not None:
2450
+ elements_by_source_id.setdefault(source_id, []).append(element)
2451
+ return [
2452
+ {
2453
+ "level": "error",
2454
+ "code": "duplicate_element_id",
2455
+ "elements": [element_ref(element) for element in duplicates],
2456
+ "measurement": {
2457
+ "element_id": source_id,
2458
+ "duplicate_count": len(duplicates),
2459
+ },
2460
+ "message": f'element id "{source_id}" is used by {len(duplicates)} elements',
2461
+ "hint": (
2462
+ "Do not invent replacement IDs. For newly authored elements, remove the id attribute. "
2463
+ "When updating read-back XML, keep the server ID on the original element only and remove it "
2464
+ "from copied or new elements."
2465
+ ),
2466
+ }
2467
+ for source_id, duplicates in elements_by_source_id.items()
2468
+ if len(duplicates) > 1
2469
+ and (
2470
+ not cross_slide_only
2471
+ or len({element.get("_slide_number") for element in duplicates}) > 1
2472
+ )
2473
+ ]
2474
+
2251
2475
 
2252
2476
  RULE_METADATA: dict[str, dict[str, Any]] = {
2253
2477
  "xml_not_well_formed": {
@@ -2306,6 +2530,10 @@ RULE_METADATA: dict[str, dict[str, Any]] = {
2306
2530
  "name": "slide_has_visible_content",
2307
2531
  "comparison": "visible_element_count > 0",
2308
2532
  },
2533
+ "duplicate_element_id": {
2534
+ "name": "element_ids_are_unique",
2535
+ "comparison": "duplicate_count == 0",
2536
+ },
2309
2537
  }
2310
2538
 
2311
2539
 
@@ -2328,13 +2556,13 @@ def issue_rule(issue: dict[str, Any]) -> dict[str, Any]:
2328
2556
 
2329
2557
 
2330
2558
  def issue_measurement(
2331
- issue: dict[str, Any], elements_by_id: dict[str, dict[str, Any]]
2559
+ issue: dict[str, Any], elements_by_ref: dict[str, dict[str, Any]]
2332
2560
  ) -> dict[str, Any]:
2333
2561
  if issue.get("measurement") is not None:
2334
2562
  return issue["measurement"]
2335
2563
  if issue["code"] == "bbox_overlap" and len(issue.get("elements", [])) == 2:
2336
- left = elements_by_id.get(issue["elements"][0])
2337
- right = elements_by_id.get(issue["elements"][1])
2564
+ left = elements_by_ref.get(issue["elements"][0])
2565
+ right = elements_by_ref.get(issue["elements"][1])
2338
2566
  if left and right:
2339
2567
  left_box = (estimate_text_visual_bbox(left) if is_text_element(left) else None) or left
2340
2568
  right_box = (estimate_text_visual_bbox(right) if is_text_element(right) else None) or right
@@ -2373,18 +2601,27 @@ def issue_measurement(
2373
2601
 
2374
2602
 
2375
2603
  def related_object(element: dict[str, Any]) -> dict[str, Any]:
2376
- return {
2377
- "element_id": element["id"],
2604
+ related = {
2378
2605
  "kind": element["kind"],
2379
2606
  "type": element["type"],
2380
- "bbox": {key: element[key] for key in ("x", "y", "width", "height")},
2381
2607
  }
2608
+ bbox_keys = ("x", "y", "width", "height")
2609
+ if all(key in element for key in bbox_keys):
2610
+ related["bbox"] = {key: element[key] for key in bbox_keys}
2611
+ if source_element_id(element) is not None:
2612
+ related["element_id"] = source_element_id(element)
2613
+ if element.get("xml_path"):
2614
+ related["xml_path"] = element["xml_path"]
2615
+ return related
2382
2616
 
2383
2617
 
2384
2618
  def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
2385
2619
  elements: list[dict[str, Any]] = []
2386
- for match in re.finditer(r"<line\b([^>]*?)(/?)>", slide_xml):
2620
+ for source_kind_index, match in enumerate(
2621
+ re.finditer(r"<line\b([^>]*?)(/?)>", slide_xml), start=1
2622
+ ):
2387
2623
  attrs = match.group(1)
2624
+ source_id = extract_attribute(attrs, "id") or None
2388
2625
  start_x = extract_numeric_attribute(attrs, "startX")
2389
2626
  start_y = extract_numeric_attribute(attrs, "startY")
2390
2627
  end_x = extract_numeric_attribute(attrs, "endX")
@@ -2403,7 +2640,8 @@ def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
2403
2640
  border_alpha = color_alpha
2404
2641
  elements.append(
2405
2642
  {
2406
- "id": extract_attribute(attrs, "id") or f"line-{len(elements) + 1}",
2643
+ "id": source_id or f"line-{len(elements) + 1}",
2644
+ "_source_id": source_id,
2407
2645
  "kind": "line",
2408
2646
  "type": "line",
2409
2647
  "x": min(start_x, end_x),
@@ -2417,6 +2655,7 @@ def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
2417
2655
  "rotation": 0,
2418
2656
  "alpha": base_alpha * border_alpha,
2419
2657
  "order": len(elements),
2658
+ "_source_kind_index": source_kind_index,
2420
2659
  }
2421
2660
  )
2422
2661
  return elements
@@ -2425,30 +2664,47 @@ def extract_line_elements(slide_xml: str) -> list[dict[str, Any]]:
2425
2664
  def normalize_issue(
2426
2665
  issue: dict[str, Any],
2427
2666
  slide_number: int | None,
2428
- elements_by_id: dict[str, dict[str, Any]],
2667
+ elements_by_ref: dict[str, dict[str, Any]],
2429
2668
  ) -> dict[str, Any]:
2430
2669
  normalized = dict(issue)
2431
- element_ids = list(dict.fromkeys(normalized.get("elements", [])))
2670
+ element_refs = list(dict.fromkeys(normalized.get("elements", [])))
2671
+ resolved_elements = [
2672
+ elements_by_ref[element_ref]
2673
+ for element_ref in element_refs
2674
+ if element_ref in elements_by_ref
2675
+ ]
2676
+ element_locators = [
2677
+ source_element_id(elements_by_ref[element_ref]) or element_ref
2678
+ if element_ref in elements_by_ref
2679
+ else element_ref
2680
+ for element_ref in element_refs
2681
+ ]
2682
+ element_ids = [
2683
+ source_id
2684
+ for element in resolved_elements
2685
+ if (source_id := source_element_id(element)) is not None
2686
+ ]
2432
2687
  normalized["schema_version"] = "2.0"
2688
+ normalized["elements"] = element_locators
2433
2689
  normalized["element_ids"] = element_ids
2434
2690
  normalized["target"] = {
2435
2691
  **({"slide_number": slide_number} if slide_number is not None else {}),
2436
2692
  **normalized.get("target", {}),
2437
2693
  }
2438
2694
  normalized["rule"] = issue_rule(normalized)
2439
- normalized["measurement"] = issue_measurement(normalized, elements_by_id)
2440
- normalized["related_objects"] = [
2441
- related_object(elements_by_id[element_id])
2442
- for element_id in element_ids
2443
- if element_id in elements_by_id
2444
- ]
2695
+ normalized["measurement"] = issue_measurement(issue, elements_by_ref)
2696
+ normalized["related_objects"] = [related_object(element) for element in resolved_elements]
2445
2697
  if normalized["code"] == "sparse_container_content":
2446
2698
  ratio = normalized["measurement"]["content_coverage_ratio"]
2447
2699
  threshold = normalized["rule"]["threshold"]
2448
- container_id = normalized["target"].get("container_id", "unknown")
2700
+ container_locator = (
2701
+ normalized["target"].get("container_id")
2702
+ or normalized["target"].get("container_xml_path")
2703
+ or "unknown"
2704
+ )
2449
2705
  normalized.setdefault(
2450
2706
  "message",
2451
- f"large card {container_id} content coverage {ratio:.1%} is below {threshold:.1%}",
2707
+ f"large card {container_locator} content coverage {ratio:.1%} is below {threshold:.1%}",
2452
2708
  )
2453
2709
  normalized.setdefault(
2454
2710
  "hint",
@@ -2470,6 +2726,10 @@ def normalize_issue(
2470
2726
  normalized.setdefault(
2471
2727
  "hint", "Inspect the reported elements and adjust them to satisfy the rule comparison."
2472
2728
  )
2729
+ if any(related.get("xml_path") for related in normalized["related_objects"]):
2730
+ hint = normalized["hint"]
2731
+ if not hint.startswith(XML_PATH_HINT_PREFIX):
2732
+ normalized["hint"] = f"{XML_PATH_HINT_PREFIX} {hint}"
2473
2733
  return normalized
2474
2734
 
2475
2735
 
@@ -2577,6 +2837,8 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
2577
2837
  presentation = parse_presentation(root)
2578
2838
  slide_roots = presentation["slide_roots"]
2579
2839
  slides: list[dict[str, Any]] = []
2840
+ presentation_id_elements: list[dict[str, Any]] = []
2841
+ presentation_elements_by_ref: dict[str, dict[str, Any]] = {}
2580
2842
  for index, slide_xml in enumerate(presentation["slides"]):
2581
2843
  slide_number = index + 1
2582
2844
  slide_root = slide_roots[index]
@@ -2610,17 +2872,30 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
2610
2872
  presentation["width"],
2611
2873
  presentation["height"],
2612
2874
  )
2613
- density_elements = extract_density_elements(slide_xml)
2875
+ density_elements = extract_density_elements(slide_xml, slide_number)
2876
+ id_elements = extract_source_id_elements(slide_xml, slide_number)
2877
+ presentation_id_elements.extend(id_elements)
2614
2878
  extra_elements = [
2615
2879
  element for element in density_elements if element["kind"] in {"icon", "polyline", "line"}
2616
2880
  ]
2617
- elements_by_id = {
2618
- element["id"]: element for element in [*density_elements, *extra_elements]
2881
+ elements_by_ref = {
2882
+ element_ref(element): element for element in density_elements
2619
2883
  }
2884
+ visible_element_count = len(elements_by_ref)
2885
+ for element in id_elements:
2886
+ elements_by_ref.setdefault(element_ref(element), element)
2620
2887
  # geometry["elements"] are the exact objects should_flag_overlap/detect_elements_out_of_canvas
2621
- # decided with inside lint_slide; prefer them so measurement/related_objects stay consistent
2888
+ # selected inside lint_slide; prefer them so measurement/related_objects stay consistent
2622
2889
  # with whatever actually triggered the issue, instead of density_elements' separate re-parse.
2623
- elements_by_id.update({element["id"]: element for element in geometry["elements"]})
2890
+ elements_by_ref.update(
2891
+ {element_ref(element): element for element in geometry["elements"]}
2892
+ )
2893
+ presentation_elements_by_ref.update(
2894
+ {
2895
+ element_ref(element): elements_by_ref[element_ref(element)]
2896
+ for element in id_elements
2897
+ }
2898
+ )
2624
2899
  extra_overflow_issues = detect_elements_out_of_canvas(
2625
2900
  extra_elements,
2626
2901
  presentation["width"],
@@ -2629,6 +2904,7 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
2629
2904
  raw_issues = [
2630
2905
  *geometry["issues"],
2631
2906
  *extra_overflow_issues,
2907
+ *detect_duplicate_element_ids(id_elements),
2632
2908
  *detect_blank_slide(
2633
2909
  density_elements,
2634
2910
  slide_number,
@@ -2651,7 +2927,7 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
2651
2927
  issues = [
2652
2928
  *slide_sxsd_issues,
2653
2929
  *[
2654
- normalize_issue(issue, slide_number, elements_by_id)
2930
+ normalize_issue(issue, slide_number, elements_by_ref)
2655
2931
  for issue in raw_issues
2656
2932
  ],
2657
2933
  ]
@@ -2662,7 +2938,7 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
2662
2938
  {
2663
2939
  "slide_number": slide_number,
2664
2940
  "status": slide_status(errors, warnings),
2665
- "element_count": len(elements_by_id),
2941
+ "element_count": visible_element_count,
2666
2942
  "errors": errors,
2667
2943
  "warnings": warnings,
2668
2944
  "infos": infos,
@@ -2670,6 +2946,13 @@ def lint_xml(xml: str, source_path: str | None = None) -> dict[str, Any]:
2670
2946
  }
2671
2947
  )
2672
2948
 
2949
+ top_level_issues.extend(
2950
+ normalize_issue(issue, None, presentation_elements_by_ref)
2951
+ for issue in detect_duplicate_element_ids(
2952
+ presentation_id_elements, cross_slide_only=True
2953
+ )
2954
+ )
2955
+
2673
2956
  return build_result(
2674
2957
  source_path,
2675
2958
  {"width": presentation["width"], "height": presentation["height"]},
@@ -2690,8 +2973,9 @@ def run_cli(argv: list[str] | None = None) -> None:
2690
2973
  if not options.get("input"):
2691
2974
  print_usage()
2692
2975
  fail("--input is required")
2693
- input_path = Path(options["input"]).resolve()
2694
- result = lint_xml(read_file(input_path), str(input_path))
2976
+ requested_path = options["input"]
2977
+ resolved_path = Path(requested_path).resolve()
2978
+ result = lint_xml(read_file(resolved_path), requested_path)
2695
2979
  print(json.dumps(result, ensure_ascii=False, indent=2))
2696
2980
  if result["summary"]["error_count"] > 0:
2697
2981
  raise SystemExit(1)