@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.
- package/package.json +2 -2
- package/skills/lark-apps/SKILL.md +39 -6
- package/skills/lark-apps/references/lark-apps-cloud-dev.md +5 -4
- package/skills/lark-apps/references/lark-apps-create.md +6 -3
- package/skills/lark-apps/references/lark-apps-get.md +1 -1
- package/skills/lark-apps/references/lark-apps-list.md +1 -1
- package/skills/lark-apps/references/lark-apps-local-dev.md +27 -1
- package/skills/lark-apps/references/lark-apps-release-create.md +1 -1
- package/skills/lark-base/SKILL.md +14 -6
- package/skills/lark-base/references/lark-base-dashboard-block-get-data.md +17 -1
- package/skills/lark-base/references/lark-base-dashboard.md +17 -4
- package/skills/lark-base/references/lark-base-data-query-guide.md +8 -0
- package/skills/lark-base/references/lark-base-field-create.md +19 -8
- package/skills/lark-base/references/lark-base-field-json.md +5 -2
- package/skills/lark-calendar/SKILL.md +1 -1
- package/skills/lark-doc/SKILL.md +26 -61
- package/skills/lark-doc/references/genres/business-analysis.md +30 -0
- package/skills/lark-doc/references/genres/data-report.md +32 -0
- package/skills/lark-doc/references/genres/email.md +38 -0
- package/skills/lark-doc/references/genres/execution-plan.md +27 -0
- package/skills/lark-doc/references/genres/formal-doc.md +37 -0
- package/skills/lark-doc/references/genres/meeting-minutes.md +24 -0
- package/skills/lark-doc/references/genres/memo-brief.md +25 -0
- package/skills/lark-doc/references/genres/official-redhead.md +73 -0
- package/skills/lark-doc/references/genres/prd.md +26 -0
- package/skills/lark-doc/references/genres/proposal.md +24 -0
- package/skills/lark-doc/references/genres/research-report.md +32 -0
- package/skills/lark-doc/references/genres/retrospective.md +25 -0
- package/skills/lark-doc/references/genres/route-consumer.md +37 -0
- package/skills/lark-doc/references/genres/route-creative.md +36 -0
- package/skills/lark-doc/references/genres/route-knowledge.md +39 -0
- package/skills/lark-doc/references/genres/route-marketing.md +40 -0
- package/skills/lark-doc/references/genres/route-media.md +36 -0
- package/skills/lark-doc/references/genres/route-opinion.md +38 -0
- package/skills/lark-doc/references/genres/route-personal-brand.md +36 -0
- package/skills/lark-doc/references/genres/route-platform.md +9 -0
- package/skills/lark-doc/references/genres/route-report.md +10 -0
- package/skills/lark-doc/references/genres/route-workplace.md +17 -0
- package/skills/lark-doc/references/genres/sop-tutorial.md +41 -0
- package/skills/lark-doc/references/genres/technical-doc.md +39 -0
- package/skills/lark-doc/references/genres/wechat.md +39 -0
- package/skills/lark-doc/references/genres/weekly-report.md +24 -0
- package/skills/lark-doc/references/genres/white-paper.md +32 -0
- package/skills/lark-doc/references/genres/xiaohongshu.md +38 -0
- package/skills/lark-doc/references/lark-doc-create-workflow.md +121 -0
- package/skills/lark-doc/references/lark-doc-create.md +22 -48
- package/skills/lark-doc/references/lark-doc-fetch.md +75 -92
- package/skills/lark-doc/references/lark-doc-history.md +16 -15
- package/skills/lark-doc/references/lark-doc-md.md +5 -1
- package/skills/lark-doc/references/lark-doc-media-download.md +2 -1
- package/skills/lark-doc/references/lark-doc-script.md +76 -0
- package/skills/lark-doc/references/lark-doc-update.md +70 -222
- package/skills/lark-doc/references/lark-doc-whiteboard.md +5 -9
- package/skills/lark-doc/references/lark-doc-xml-extended-blocks.md +17 -12
- package/skills/lark-doc/references/lark-doc-xml.md +38 -167
- package/skills/lark-drive/SKILL.md +7 -5
- package/skills/lark-drive/references/lark-drive-apply-permission.md +1 -1
- package/skills/lark-drive/references/lark-drive-copy.md +87 -0
- package/skills/lark-drive/references/lark-drive-download.md +2 -1
- package/skills/lark-drive/references/lark-drive-export.md +3 -0
- package/skills/lark-drive/references/lark-drive-task-result.md +3 -0
- package/skills/lark-drive/references/lark-drive-update-title.md +78 -0
- package/skills/lark-event/SKILL.md +7 -4
- package/skills/lark-event/references/lark-event-vc.md +8 -2
- package/skills/lark-im/SKILL.md +8 -8
- package/skills/lark-im/references/lark-im-chat-list.md +9 -2
- package/skills/lark-im/references/lark-im-chat-members-list.md +7 -4
- package/skills/lark-im/references/lark-im-chat-messages-list.md +10 -3
- package/skills/lark-im/references/lark-im-chat-search.md +9 -2
- package/skills/lark-im/references/lark-im-feed-group-list-item.md +2 -2
- package/skills/lark-im/references/lark-im-feed-group-list.md +2 -2
- package/skills/lark-im/references/lark-im-feed-shortcut-list.md +1 -1
- package/skills/lark-im/references/lark-im-flag-list.md +2 -2
- package/skills/lark-im/references/lark-im-message-enrichment.md +1 -1
- package/skills/lark-im/references/lark-im-messages-resources-download.md +19 -25
- package/skills/lark-im/references/lark-im-messages-search.md +4 -5
- package/skills/lark-im/references/lark-im-threads-messages-list.md +8 -4
- package/skills/lark-mail/references/lark-mail-triage.md +19 -4
- package/skills/lark-minutes/SKILL.md +1 -1
- package/skills/lark-minutes/references/lark-minutes-search.md +6 -7
- package/skills/lark-shared/SKILL.md +3 -3
- package/skills/lark-sheets/SKILL.md +83 -82
- package/skills/lark-sheets/references/lark-sheets-batch-update.md +13 -58
- package/skills/lark-sheets/references/lark-sheets-chart.md +2 -1
- package/skills/lark-sheets/references/lark-sheets-conditional-format.md +1 -1
- package/skills/lark-sheets/references/lark-sheets-range-operations.md +5 -5
- package/skills/lark-sheets/references/lark-sheets-read-data.md +80 -6
- package/skills/lark-sheets/references/lark-sheets-sheet-structure.md +21 -10
- package/skills/lark-sheets/references/lark-sheets-styles-put.md +93 -0
- package/skills/lark-sheets/references/lark-sheets-visual-standards.md +2 -2
- package/skills/lark-sheets/references/lark-sheets-workbook.md +4 -3
- package/skills/lark-sheets/references/lark-sheets-write-cells.md +40 -12
- package/skills/lark-sheets/scripts/lark_detect_subtables.py +593 -0
- package/skills/lark-sheets/scripts/lark_inspect_workbook.py +188 -0
- package/skills/lark-sheets/scripts/lark_profile_table.py +614 -0
- package/skills/lark-sheets/scripts/lark_sheet_range.py +176 -0
- package/skills/lark-sheets/scripts/lark_sheet_read_cli.py +184 -0
- package/skills/lark-sheets/scripts/sheets_df.py +21 -3
- package/skills/lark-slides/SKILL.md +27 -44
- package/skills/lark-slides/references/lark-slides-add-slide.md +92 -0
- package/skills/lark-slides/references/lark-slides-create.md +77 -65
- package/skills/lark-slides/references/lark-slides-delete-slide.md +65 -0
- package/skills/lark-slides/references/lark-slides-edit-workflows.md +6 -7
- package/skills/lark-slides/references/lark-slides-media-upload.md +3 -25
- package/skills/lark-slides/references/lark-slides-replace-slide.md +22 -1
- package/skills/lark-slides/references/lark-slides-screenshot.md +31 -13
- package/skills/lark-slides/references/lark-slides-update-slide.md +146 -0
- package/skills/lark-slides/references/lark-slides-xml-presentations-get.md +31 -8
- package/skills/lark-slides/references/slides_chart_demo.xml +1 -2
- package/skills/lark-slides/references/slides_xml_schema_definition.xml +48 -4
- package/skills/lark-slides/references/troubleshooting.md +7 -8
- package/skills/lark-slides/references/validation-checklist.md +4 -4
- package/skills/lark-slides/references/xml-schema-quick-ref.md +23 -11
- package/skills/lark-slides/scripts/sxsd_validator.py +154 -10
- package/skills/lark-slides/scripts/xml_text_overlap_lint.py +360 -76
- package/skills/lark-slides/scripts/xml_text_overlap_lint_test.py +1138 -214
- package/skills/lark-whiteboard/SKILL.md +15 -8
- package/skills/lark-whiteboard/references/lark-whiteboard-export.md +4 -3
- package/skills/lark-whiteboard/references/lark-whiteboard-update.md +4 -4
- package/skills/lark-whiteboard/references/lark-whiteboard-workflow.md +19 -17
- package/skills/lark-whiteboard/routes/dsl.md +8 -2
- package/skills/lark-whiteboard/routes/mermaid.md +1 -1
- package/skills/lark-whiteboard/routes/svg-edit.md +5 -2
- package/skills/lark-whiteboard/routes/svg.md +3 -1
- package/skills/lark-whiteboard/scenes/mention.md +71 -0
- package/skills/lark-wiki/SKILL.md +5 -3
- package/skills/lark-wiki/references/lark-wiki-delete-space.md +6 -3
- package/skills/lark-doc/references/lark-doc-word-stat.md +0 -93
- package/skills/lark-doc/references/style/lark-doc-create-workflow.md +0 -47
- package/skills/lark-doc/references/style/lark-doc-style.md +0 -68
- package/skills/lark-doc/references/style/lark-doc-update-workflow.md +0 -48
- package/skills/lark-doc/scripts/doc_word_stat.py +0 -1243
- package/skills/lark-slides/references/lark-slides-replace-pages.md +0 -95
- package/skills/lark-slides/references/lark-slides-xml-presentation-slide-create.md +0 -219
- 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 = "
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
|
913
|
-
"message":
|
|
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
|
|
923
|
-
"message":
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
1445
|
-
"message":
|
|
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
|
|
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]],
|
|
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 =
|
|
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(
|
|
1505
|
-
and contains(
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1763
|
-
"message":
|
|
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
|
|
1793
|
-
"message": f
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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":
|
|
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
|
|
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":
|
|
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
|
-
|
|
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": [
|
|
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
|
|
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
|
|
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],
|
|
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 =
|
|
2337
|
-
right =
|
|
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
|
-
|
|
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
|
|
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":
|
|
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
|
-
|
|
2667
|
+
elements_by_ref: dict[str, dict[str, Any]],
|
|
2429
2668
|
) -> dict[str, Any]:
|
|
2430
2669
|
normalized = dict(issue)
|
|
2431
|
-
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
2618
|
-
element
|
|
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
|
-
#
|
|
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
|
-
|
|
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,
|
|
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":
|
|
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
|
-
|
|
2694
|
-
|
|
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)
|