1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct AXTree {
12 pub nodes: HashMap<String, AXNode>,
14 pub root_id: Option<String>,
16 #[serde(default)]
30 order: Vec<String>,
31}
32
33impl AXTree {
34 pub fn new() -> Self {
36 Self {
37 nodes: HashMap::new(),
38 root_id: None,
39 order: Vec::new(),
40 }
41 }
42
43 pub fn from_nodes(nodes: Vec<AXNode>) -> Self {
45 let mut tree = Self::new();
46
47 for node in nodes {
48 if tree.root_id.is_none() {
50 tree.root_id = Some(node.node_id.clone());
51 }
52 if tree
53 .nodes
54 .insert(node.node_id.clone(), node.clone())
55 .is_none()
56 {
57 tree.order.push(node.node_id);
58 }
59 }
60
61 tree
62 }
63
64 fn ordered_nodes(&self) -> Vec<&AXNode> {
73 if self.order.len() == self.nodes.len() {
74 return self
75 .order
76 .iter()
77 .filter_map(|id| self.nodes.get(id))
78 .collect();
79 }
80 self.reconstruct_order()
81 }
82
83 fn reconstruct_order(&self) -> Vec<&AXNode> {
84 let mut out: Vec<&AXNode> = Vec::with_capacity(self.nodes.len());
85 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
86
87 if let Some(root) = self.root_id.as_deref() {
88 let mut stack = vec![root];
89 while let Some(id) = stack.pop() {
90 if !seen.insert(id) {
91 continue;
92 }
93 let Some(node) = self.nodes.get(id) else {
94 continue;
95 };
96 out.push(node);
97 for child in node.child_ids.iter().rev() {
99 stack.push(child.as_str());
100 }
101 }
102 }
103
104 let mut rest: Vec<&AXNode> = self
105 .nodes
106 .values()
107 .filter(|n| !seen.contains(n.node_id.as_str()))
108 .collect();
109 rest.sort_by(|a, b| a.node_id.cmp(&b.node_id));
110 out.extend(rest);
111 out
112 }
113
114 pub fn get_node(&self, node_id: &str) -> Option<&AXNode> {
116 self.nodes.get(node_id)
117 }
118
119 pub fn node_by_backend_id(&self, backend_id: i64) -> Option<&AXNode> {
126 self.iter_all()
127 .find(|n| n.backend_dom_node_id == Some(backend_id))
128 }
129
130 pub fn is_within(&self, backend_id: i64, ancestor_backend_id: i64) -> bool {
137 let Some(mut node) = self.node_by_backend_id(backend_id) else {
138 return false;
139 };
140 for _ in 0..self.nodes.len() {
143 if node.backend_dom_node_id == Some(ancestor_backend_id) {
144 return true;
145 }
146 match node.parent_id.as_ref().and_then(|id| self.get_node(id)) {
147 Some(parent) => node = parent,
148 None => return false,
149 }
150 }
151 false
152 }
153
154 pub fn root(&self) -> Option<&AXNode> {
156 self.root_id.as_ref().and_then(|id| self.nodes.get(id))
157 }
158
159 pub fn iter(&self) -> impl Iterator<Item = &AXNode> {
171 let media_ui = self.media_controls();
172 self.ordered_nodes()
173 .into_iter()
174 .filter(move |n| !n.is_browser_generated() && !media_ui.contains(n.node_id.as_str()))
175 }
176
177 fn media_controls(&self) -> std::collections::HashSet<&str> {
180 let mut out = std::collections::HashSet::new();
181 let mut stack: Vec<&str> = self
182 .nodes
183 .values()
184 .filter(|n| matches!(n.role.as_deref(), Some("Video") | Some("Audio")))
185 .flat_map(|n| n.child_ids.iter().map(String::as_str))
186 .collect();
187 while let Some(id) = stack.pop() {
188 if !out.insert(id) {
189 continue;
190 }
191 if let Some(node) = self.nodes.get(id) {
192 stack.extend(node.child_ids.iter().map(String::as_str));
193 }
194 }
195 out
196 }
197
198 pub fn iter_all(&self) -> impl Iterator<Item = &AXNode> {
201 self.ordered_nodes().into_iter()
202 }
203
204 pub fn text_nodes(&self) -> impl Iterator<Item = &AXNode> {
223 self.ordered_nodes().into_iter().filter(|n| n.is_text())
224 }
225
226 pub fn visible_text_len(&self) -> usize {
228 self.text_nodes()
229 .filter_map(|n| n.name.as_ref())
230 .map(|name| name.len())
231 .sum()
232 }
233
234 pub fn nodes_with_role(&self, role: &str) -> Vec<&AXNode> {
236 self.iter()
237 .filter(|n| n.role.as_deref() == Some(role))
238 .collect()
239 }
240
241 pub fn images(&self) -> Vec<&AXNode> {
243 self.iter()
244 .filter(|n| matches!(n.role.as_deref(), Some("image") | Some("img")))
245 .collect()
246 }
247
248 pub fn headings(&self) -> Vec<&AXNode> {
250 self.iter()
251 .filter(|n| matches!(n.role.as_deref(), Some("heading")))
252 .collect()
253 }
254
255 pub fn form_controls(&self) -> Vec<&AXNode> {
257 self.iter()
258 .filter(|n| {
259 matches!(
260 n.role.as_deref(),
261 Some("textbox")
262 | Some("checkbox")
263 | Some("radio")
264 | Some("combobox")
265 | Some("listbox")
266 | Some("spinbutton")
267 | Some("slider")
268 | Some("searchbox")
269 )
270 })
271 .collect()
272 }
273
274 pub fn links(&self) -> Vec<&AXNode> {
276 self.nodes_with_role("link")
277 }
278
279 pub fn len(&self) -> usize {
281 self.nodes.len()
282 }
283
284 pub fn is_empty(&self) -> bool {
286 self.nodes.is_empty()
287 }
288}
289
290impl Default for AXTree {
291 fn default() -> Self {
292 Self::new()
293 }
294}
295
296#[derive(Debug, Clone, Default, Serialize, Deserialize)]
298pub struct AXNode {
299 pub node_id: String,
301 #[serde(default)]
303 pub ignored: bool,
304 #[serde(default)]
306 pub ignored_reasons: Vec<AXProperty>,
307 pub role: Option<String>,
309 pub name: Option<String>,
311 pub name_source: Option<NameSource>,
313 pub description: Option<String>,
315 pub value: Option<String>,
317 #[serde(default)]
319 pub properties: Vec<AXProperty>,
320 #[serde(default)]
322 pub child_ids: Vec<String>,
323 pub parent_id: Option<String>,
325 pub backend_dom_node_id: Option<i64>,
327}
328
329impl AXNode {
330 pub fn has_name(&self) -> bool {
332 self.name.as_ref().is_some_and(|n| !n.trim().is_empty())
333 }
334
335 pub fn is_focusable(&self) -> bool {
337 self.get_property_bool("focusable").unwrap_or(false)
338 }
339
340 pub fn is_interactive(&self) -> bool {
342 matches!(
343 self.role.as_deref(),
344 Some("button")
345 | Some("link")
346 | Some("textbox")
347 | Some("checkbox")
348 | Some("radio")
349 | Some("combobox")
350 | Some("menuitem")
351 | Some("tab")
352 ) || self.is_focusable()
353 }
354
355 pub fn heading_level(&self) -> Option<u8> {
357 if self.role.as_deref() != Some("heading") {
358 return None;
359 }
360
361 self.get_property_int("level").map(|l| l.clamp(1, 6) as u8)
362 }
363
364 pub fn get_property_bool(&self, name: &str) -> Option<bool> {
366 self.properties
367 .iter()
368 .find(|p| p.name == name)
369 .and_then(|p| p.value.as_bool())
370 }
371
372 pub fn get_property_int(&self, name: &str) -> Option<i64> {
374 self.properties
375 .iter()
376 .find(|p| p.name == name)
377 .and_then(|p| p.value.as_int())
378 }
379
380 pub fn get_property_str(&self, name: &str) -> Option<&str> {
382 self.properties
383 .iter()
384 .find(|p| p.name == name)
385 .and_then(|p| p.value.as_str())
386 }
387
388 pub fn haspopup(&self) -> Option<&str> {
399 self.get_property_str("hasPopup")
400 }
401
402 pub fn property_value_str(&self, name: &str) -> Option<String> {
413 let value = &self.properties.iter().find(|p| p.name == name)?.value;
414 match value {
415 AXValue::Bool(b) => Some(b.to_string()),
416 AXValue::Int(i) => Some(i.to_string()),
417 AXValue::Float(f) => Some(f.to_string()),
418 AXValue::String(s) => Some(s.clone()),
419 AXValue::Node { .. } | AXValue::List(_) => None,
420 }
421 }
422
423 pub fn has_property(&self, name: &str) -> bool {
427 self.properties.iter().any(|p| p.name == name)
428 }
429
430 pub fn get_property_idref(&self, name: &str) -> Option<&str> {
434 self.properties
435 .iter()
436 .find(|p| p.name == name)
437 .and_then(|p| match &p.value {
438 AXValue::String(s) => Some(s.as_str()),
439 AXValue::Node { related_nodes } => related_nodes.first()?.idref.as_deref(),
440 _ => None,
441 })
442 }
443
444 pub fn get_property_idrefs(&self, name: &str) -> Vec<&str> {
447 self.properties
448 .iter()
449 .find(|p| p.name == name)
450 .map(|p| match &p.value {
451 AXValue::String(s) => s.split_whitespace().collect(),
452 AXValue::Node { related_nodes } => related_nodes
453 .iter()
454 .filter_map(|n| n.idref.as_deref())
455 .collect(),
456 _ => vec![],
457 })
458 .unwrap_or_default()
459 }
460
461 pub fn has_role(&self, role: &str) -> bool {
463 self.role.as_deref() == Some(role)
464 }
465
466 pub fn is_text(&self) -> bool {
476 self.role.as_deref() == Some("StaticText")
477 && self.name.as_deref().is_some_and(|name| !name.is_empty())
478 }
479
480 pub fn is_browser_generated(&self) -> bool {
481 matches!(
482 self.role.as_deref(),
483 Some("StaticText")
484 | Some("InlineTextBox")
485 | Some("LineBreak")
486 | Some("ListMarker")
487 | Some("LayoutTable")
488 | Some("LayoutTableRow")
489 | Some("LayoutTableCell")
490 )
491 }
492
493 pub fn is_required(&self) -> bool {
495 self.get_property_bool("required").unwrap_or(false)
496 }
497
498 pub fn is_invalid(&self) -> bool {
508 self.get_property_str("invalid")
509 .map(|v| v != "false")
510 .unwrap_or(false)
511 }
512}
513
514#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
516#[serde(rename_all = "camelCase")]
517pub enum NameSource {
518 Attribute,
520 RelatedElement,
522 Contents,
524 Placeholder,
526 Title,
528}
529
530#[derive(Debug, Clone, Serialize, Deserialize)]
532pub struct AXProperty {
533 pub name: String,
535 pub value: AXValue,
537}
538
539#[derive(Debug, Clone, Serialize, Deserialize)]
541#[serde(untagged)]
542pub enum AXValue {
543 Bool(bool),
545 Int(i64),
547 Float(f64),
549 String(String),
551 Node { related_nodes: Vec<RelatedNode> },
553 List(Vec<AXValue>),
555}
556
557impl AXValue {
558 pub fn as_bool(&self) -> Option<bool> {
560 match self {
561 AXValue::Bool(b) => Some(*b),
562 _ => None,
563 }
564 }
565
566 pub fn as_int(&self) -> Option<i64> {
568 match self {
569 AXValue::Int(i) => Some(*i),
570 AXValue::Float(f) => Some(*f as i64),
571 _ => None,
572 }
573 }
574
575 pub fn as_str(&self) -> Option<&str> {
577 match self {
578 AXValue::String(s) => Some(s),
579 _ => None,
580 }
581 }
582}
583
584#[derive(Debug, Clone, Serialize, Deserialize)]
586pub struct RelatedNode {
587 pub backend_dom_node_id: Option<i64>,
589 pub idref: Option<String>,
591 pub text: Option<String>,
593}
594
595#[cfg(test)]
596mod tests {
597 use super::*;
598
599 fn create_test_node(id: &str, role: &str, name: Option<&str>) -> AXNode {
600 AXNode {
601 node_id: id.to_string(),
602 ignored: false,
603 ignored_reasons: vec![],
604 role: Some(role.to_string()),
605 name: name.map(String::from),
606 name_source: None,
607 description: None,
608 value: None,
609 properties: vec![],
610 child_ids: vec![],
611 parent_id: None,
612 backend_dom_node_id: None,
613 }
614 }
615
616 #[test]
621 fn iteration_follows_document_order_not_hash_order() {
622 let ids: Vec<String> = (0..64).map(|i| format!("n{i}")).collect();
623 let build = || {
624 AXTree::from_nodes(
625 ids.iter()
626 .map(|id| create_test_node(id, "heading", Some("x")))
627 .collect(),
628 )
629 };
630
631 let first = build();
632 let second = build();
633 let seq = |t: &AXTree| -> Vec<String> { t.iter_all().map(|n| n.node_id.clone()).collect() };
634
635 assert_eq!(seq(&first), ids, "construction order is document order");
636 assert_eq!(seq(&first), seq(&second), "two builds must agree");
637 assert_eq!(seq(&first), seq(&first), "one tree must agree with itself");
638 }
639
640 #[test]
644 fn a_tree_without_stored_order_is_rebuilt_from_its_children() {
645 let mut root = create_test_node("root", "WebArea", Some("Page"));
646 root.child_ids = vec!["a".into(), "b".into(), "c".into()];
647 let nodes = vec![
648 root,
649 create_test_node("a", "heading", Some("A")),
650 create_test_node("b", "heading", Some("B")),
651 create_test_node("c", "heading", Some("C")),
652 ];
653
654 let mut tree = AXTree::from_nodes(nodes);
655 tree.order.clear();
657 let seq: Vec<String> = tree.iter_all().map(|n| n.node_id.clone()).collect();
658 assert_eq!(seq, vec!["root", "a", "b", "c"]);
659
660 tree.nodes.insert(
662 "zz".to_string(),
663 create_test_node("zz", "heading", Some("Z")),
664 );
665 tree.order.clear();
666 let seq: Vec<String> = tree.iter_all().map(|n| n.node_id.clone()).collect();
667 assert_eq!(seq, vec!["root", "a", "b", "c", "zz"]);
668 }
669
670 #[test]
675 fn text_is_counted_once_per_run() {
676 let mut heading = create_test_node("h", "heading", Some("Ueberschrift"));
677 heading.child_ids = vec!["h-text".into()];
678 let mut link = create_test_node("l", "link", Some("Mehr erfahren"));
679 link.child_ids = vec!["l-text".into()];
680
681 let tree = AXTree::from_nodes(vec![
682 create_test_node("root", "RootWebArea", Some("Seite")),
683 heading,
684 create_test_node("h-text", "StaticText", Some("Ueberschrift")),
685 create_test_node("h-box", "InlineTextBox", Some("Ueberschrift")),
686 link,
687 create_test_node("l-text", "StaticText", Some("Mehr erfahren")),
688 create_test_node("p-text", "StaticText", Some("Fliesstext.")),
689 ]);
690
691 let texts: Vec<&str> = tree
692 .text_nodes()
693 .map(|n| n.name.as_deref().unwrap_or(""))
694 .collect();
695 assert_eq!(texts, vec!["Ueberschrift", "Mehr erfahren", "Fliesstext."]);
696 assert_eq!(
697 tree.visible_text_len(),
698 "Ueberschrift".len() + "Mehr erfahren".len() + "Fliesstext.".len(),
699 );
700 }
701
702 #[test]
705 fn an_unnamed_static_text_node_is_not_text() {
706 let tree = AXTree::from_nodes(vec![
707 create_test_node("root", "RootWebArea", Some("Seite")),
708 create_test_node("empty", "StaticText", Some("")),
709 create_test_node("none", "StaticText", None),
710 ]);
711 assert_eq!(tree.text_nodes().count(), 0);
712 assert_eq!(tree.visible_text_len(), 0);
713 }
714
715 #[test]
716 fn test_axtree_from_nodes() {
717 let nodes = vec![
718 create_test_node("1", "WebArea", Some("Page")),
719 create_test_node("2", "heading", Some("Title")),
720 create_test_node("3", "image", None),
721 ];
722
723 let tree = AXTree::from_nodes(nodes);
724 assert_eq!(tree.len(), 3);
725 assert_eq!(tree.root_id, Some("1".to_string()));
726 }
727
728 #[test]
729 fn test_axtree_images() {
730 let nodes = vec![
731 create_test_node("1", "WebArea", Some("Page")),
732 create_test_node("2", "image", Some("Logo")),
733 create_test_node("3", "image", None),
734 create_test_node("4", "heading", Some("Title")),
735 ];
736
737 let tree = AXTree::from_nodes(nodes);
738 let images = tree.images();
739 assert_eq!(images.len(), 2);
740 }
741
742 #[test]
743 fn test_axtree_headings() {
744 let nodes = vec![
745 create_test_node("1", "WebArea", Some("Page")),
746 create_test_node("2", "heading", Some("Title")),
747 create_test_node("3", "heading", Some("Subtitle")),
748 ];
749
750 let tree = AXTree::from_nodes(nodes);
751 let headings = tree.headings();
752 assert_eq!(headings.len(), 2);
753 }
754
755 #[test]
756 fn test_axnode_has_name() {
757 let node_with_name = create_test_node("1", "image", Some("Logo"));
758 let node_without_name = create_test_node("2", "image", None);
759 let node_empty_name = create_test_node("3", "image", Some(" "));
760
761 assert!(node_with_name.has_name());
762 assert!(!node_without_name.has_name());
763 assert!(!node_empty_name.has_name());
764 }
765
766 #[test]
767 fn test_axnode_heading_level() {
768 let mut heading = create_test_node("1", "heading", Some("Title"));
769 heading.properties.push(AXProperty {
770 name: "level".to_string(),
771 value: AXValue::Int(2),
772 });
773
774 assert_eq!(heading.heading_level(), Some(2));
775
776 let non_heading = create_test_node("2", "paragraph", Some("Text"));
777 assert_eq!(non_heading.heading_level(), None);
778 }
779
780 fn with_children(mut node: AXNode, children: &[&str]) -> AXNode {
781 node.child_ids = children.iter().map(|c| c.to_string()).collect();
782 node
783 }
784
785 #[test]
789 fn media_player_controls_are_not_auditable() {
790 let tree = AXTree::from_nodes(vec![
791 with_children(create_test_node("1", "RootWebArea", None), &["2", "3", "7"]),
792 create_test_node("2", "heading", Some("Clip")),
793 with_children(create_test_node("3", "Video", None), &["4"]),
794 with_children(create_test_node("4", "generic", None), &["5", "6"]),
795 create_test_node("5", "button", Some("Wiedergeben")),
796 create_test_node("6", "slider", Some("Video-Zeitachse")),
797 create_test_node("7", "slider", Some("Author volume")),
798 ]);
799 let roles: Vec<(&str, &str)> = tree
800 .iter()
801 .map(|n| (n.node_id.as_str(), n.role.as_deref().unwrap_or("")))
802 .collect();
803 assert_eq!(
804 roles,
805 vec![
806 ("1", "RootWebArea"),
807 ("2", "heading"),
808 ("3", "Video"),
809 ("7", "slider")
810 ]
811 );
812 assert_eq!(tree.form_controls().len(), 1, "only the author's slider");
813 assert_eq!(tree.iter_all().count(), 7);
815 }
816
817 #[test]
818 fn audio_player_controls_are_not_auditable() {
819 let tree = AXTree::from_nodes(vec![
820 with_children(create_test_node("1", "RootWebArea", None), &["2"]),
821 with_children(create_test_node("2", "Audio", None), &["3"]),
822 create_test_node("3", "button", Some("Stumm")),
823 ]);
824 assert_eq!(tree.iter().count(), 2);
825 }
826}