1use serde::{Deserialize, Serialize};
8use serde_json::Value;
9use std::collections::{HashMap, HashSet};
10
11pub const COMPACT_AX_MAX_NODES: usize = 128;
13pub const COMPACT_AX_MAX_INTERACTIVE: usize = 32;
14pub const COMPACT_AX_TEXT_MAX_BYTES: usize = 4 * 1024;
15pub const COMPACT_AX_ROLE_MAX_BYTES: usize = 64;
16const COMPACT_AX_INTERACTIVE_TEXT_MAX_BYTES: usize = COMPACT_AX_TEXT_MAX_BYTES / 2;
17const COMPACT_AX_OUTLINE_TEXT_MAX_BYTES: usize =
18 COMPACT_AX_TEXT_MAX_BYTES - COMPACT_AX_INTERACTIVE_TEXT_MAX_BYTES;
19const COMPACT_AX_TRUNCATION_MARKER: &str = "…";
20
21pub const MAX_SHADOW_DEPTH: u8 = 3;
23pub const MAX_SHADOW_HOSTS: usize = 8;
24pub const MAX_SHADOW_PATH_ENTRIES: usize = 3;
25pub const MAX_SHADOW_PATH_ENTRY_BYTES: usize = 64;
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct AxNode {
30 pub ax_node_id: String,
31 pub backend_dom_node_id: Option<i64>,
32 pub role: String,
33 pub name: String,
34 pub description: String,
35 pub value: Option<String>,
36 pub children: Vec<AxNode>,
37 pub bounds: Option<[f64; 4]>,
39 pub interactive: bool,
40 pub input_type: Option<String>,
42}
43
44#[derive(Debug, Clone, Serialize)]
46pub struct CompactAxNode {
47 pub role: String,
48 #[serde(skip_serializing_if = "String::is_empty")]
49 pub name: String,
50 #[serde(skip_serializing_if = "Vec::is_empty")]
51 pub children: Vec<CompactAxNode>,
52 #[serde(skip_serializing_if = "is_false")]
53 pub interactive: bool,
54}
55
56#[derive(Debug, Clone, Serialize)]
58pub struct CompactInteractiveElement {
59 pub reference: String,
60 pub role: String,
61 #[serde(skip_serializing_if = "String::is_empty")]
62 pub name: String,
63 pub backend_dom_node_id: i64,
64 #[serde(skip)]
67 pub(crate) ancestor_path: Vec<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub shadow_host_path: Option<Vec<String>>,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub input_type: Option<String>,
74 #[serde(skip_serializing_if = "Option::is_none")]
77 pub value: Option<String>,
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub checked: Option<bool>,
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub selected_option: Option<String>,
84 #[serde(skip_serializing_if = "is_false")]
86 pub empty: bool,
87 #[serde(skip_serializing_if = "is_false")]
89 pub read_only: bool,
90 #[serde(skip_serializing_if = "is_false")]
92 pub required: bool,
93}
94
95pub const FORM_VALUE_MAX_BYTES: usize = 256;
97pub const SELECT_OPTION_MAX_BYTES: usize = 128;
99pub const FORM_VALUE_MAX_FIELDS: usize = 16;
101
102#[derive(Debug, Clone)]
104pub struct CompactAccessibilityProjection {
105 pub roots: Vec<CompactAxNode>,
106 pub interactive: Vec<CompactInteractiveElement>,
107 pub truncated: bool,
108 pub nodes_truncated: bool,
109 pub labels_truncated: bool,
110 pub controls_truncated: bool,
111 pub interactive_discovered: usize,
113 pub omitted_count: usize,
115 pub ranking_applied: bool,
117 pub shadow_pierced: ShadowPiercedSummary,
119}
120
121#[derive(Debug, Clone, Default)]
123pub struct ShadowPiercedSummary {
124 pub hosts_visited: usize,
126 pub controls_found: usize,
128 pub truncated: bool,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum CompactRanking {
134 Relevance,
135 DocumentOrder,
136}
137
138struct CompactProjectionState {
139 node_count: usize,
140 interactive_text_remaining: usize,
141 outline_text_remaining: usize,
142 interactive: Vec<CompactInteractiveElement>,
143 truncated: bool,
144 nodes_truncated: bool,
145 labels_truncated: bool,
146 controls_truncated: bool,
147}
148
149impl CompactProjectionState {
150 fn new() -> Self {
151 Self {
152 node_count: 0,
153 interactive_text_remaining: COMPACT_AX_INTERACTIVE_TEXT_MAX_BYTES,
154 outline_text_remaining: COMPACT_AX_OUTLINE_TEXT_MAX_BYTES,
155 interactive: Vec::new(),
156 truncated: false,
157 nodes_truncated: false,
158 labels_truncated: false,
159 controls_truncated: false,
160 }
161 }
162}
163
164fn is_false(value: &bool) -> bool {
165 !*value
166}
167
168pub fn parse_accessibility_tree(raw: &Value) -> Vec<AxNode> {
170 let Some(entries) = raw["nodes"].as_array() else {
171 return Vec::new();
172 };
173
174 let mut by_id = HashMap::new();
175 for entry in entries {
176 if let Some(id) = entry["nodeId"].as_str() {
177 by_id.insert(id.to_string(), entry.clone());
178 }
179 }
180
181 let mut child_ids = HashSet::new();
182 for entry in entries {
183 if let Some(children) = entry["childIds"].as_array() {
184 for child in children.iter().filter_map(Value::as_str) {
185 child_ids.insert(child.to_string());
186 }
187 }
188 }
189
190 let mut roots = Vec::new();
191 for entry in entries {
192 let Some(id) = entry["nodeId"].as_str() else {
193 continue;
194 };
195 let has_parent = entry["parentId"].as_str().is_some() || child_ids.contains(id);
196 if !has_parent {
197 roots.push(id.to_string());
198 }
199 }
200 if roots.is_empty() {
201 roots.extend(
202 entries
203 .iter()
204 .filter_map(|entry| entry["nodeId"].as_str().map(String::from)),
205 );
206 }
207
208 let mut visiting = HashSet::new();
209 roots
210 .iter()
211 .filter_map(|id| build_ax_node(id, &by_id, &mut visiting))
212 .collect()
213}
214
215fn build_ax_node(
216 id: &str,
217 by_id: &HashMap<String, Value>,
218 visiting: &mut HashSet<String>,
219) -> Option<AxNode> {
220 if !visiting.insert(id.to_string()) {
221 return None;
222 }
223 let raw = by_id.get(id)?;
224 let children = raw["childIds"]
225 .as_array()
226 .into_iter()
227 .flatten()
228 .filter_map(Value::as_str)
229 .filter_map(|child_id| build_ax_node(child_id, by_id, visiting))
230 .collect();
231 visiting.remove(id);
232
233 let role = property_text(raw, "role").unwrap_or_else(|| "unknown".to_string());
234 let interactive = is_interactive_role(&role)
235 || raw["properties"].as_array().is_some_and(|properties| {
236 properties.iter().any(|property| {
237 property["name"].as_str() == Some("focusable")
238 && property["value"]["value"].as_bool() == Some(true)
239 })
240 });
241
242 Some(AxNode {
243 ax_node_id: id.to_string(),
244 backend_dom_node_id: raw["backendDOMNodeId"].as_i64(),
245 role,
246 name: property_text(raw, "name").unwrap_or_default(),
247 description: property_text(raw, "description").unwrap_or_default(),
248 value: property_text(raw, "value"),
249 children,
250 bounds: None,
251 interactive,
252 input_type: extract_input_type(raw),
253 })
254}
255
256fn property_text(raw: &Value, property: &str) -> Option<String> {
257 raw[property]["value"]
258 .as_str()
259 .or_else(|| raw[property].as_str())
260 .map(String::from)
261}
262
263fn is_interactive_role(role: &str) -> bool {
264 matches!(
265 role,
266 "button"
267 | "link"
268 | "textbox"
269 | "checkbox"
270 | "radio"
271 | "combobox"
272 | "menuitem"
273 | "tab"
274 | "option"
275 | "searchbox"
276 | "spinbutton"
277 | "slider"
278 | "switch"
279 )
280}
281
282pub fn find_interactive_elements(nodes: &[AxNode]) -> Vec<&AxNode> {
285 let mut result = Vec::new();
286 for node in nodes {
287 if node.interactive {
288 result.push(node);
289 }
290 result.extend(find_interactive_elements(&node.children));
291 }
292 result
293}
294
295fn control_relevance_score(control: &CompactInteractiveElement) -> i32 {
298 let role_score = match control.role.as_str() {
299 "button" | "link" => 36,
300 "textbox" | "combobox" | "searchbox" | "spinbutton" => 34,
301 "checkbox" | "radio" | "switch" | "menuitem" | "option" => 30,
302 "slider" => 28,
303 "listbox" | "menu" | "tab" | "treeitem" => 24,
304 _ => 12,
305 };
306 let form_score = if control.input_type.is_some() { 8 } else { 0 }
307 + i32::from(control.required) * 5
308 + i32::from(control.checked.is_some() || control.selected_option.is_some()) * 3;
309 let name_score = if control.name.is_empty() { 0 } else { 6 };
310 let shadow_score = if control.shadow_host_path.is_some() {
311 -2
312 } else {
313 0
314 };
315 role_score + form_score + name_score + shadow_score
316}
317
318fn rank_interactive_controls(controls: &mut [CompactInteractiveElement]) {
321 controls.sort_by(|a, b| {
322 let score_a = control_relevance_score(a);
323 let score_b = control_relevance_score(b);
324 score_b.cmp(&score_a)
325 });
326}
327
328pub fn project_compact_accessibility(
333 nodes: &[AxNode],
334 revision: u64,
335) -> CompactAccessibilityProjection {
336 project_compact_accessibility_with_ranking(nodes, revision, CompactRanking::Relevance)
337}
338
339pub fn project_compact_accessibility_with_ranking(
343 nodes: &[AxNode],
344 revision: u64,
345 ranking: CompactRanking,
346) -> CompactAccessibilityProjection {
347 let mut state = CompactProjectionState::new();
348 let roots = nodes
349 .iter()
350 .filter_map(|node| project_compact_node(node, revision, &mut state, &[]))
351 .collect();
352 let total_discovered = state.interactive.len();
353 let ranking_applied =
354 total_discovered > COMPACT_AX_MAX_INTERACTIVE && ranking == CompactRanking::Relevance;
355
356 if total_discovered > COMPACT_AX_MAX_INTERACTIVE {
357 if ranking == CompactRanking::Relevance {
358 rank_interactive_controls(&mut state.interactive);
359 }
360 let omitted = total_discovered.saturating_sub(COMPACT_AX_MAX_INTERACTIVE);
361 state.interactive.truncate(COMPACT_AX_MAX_INTERACTIVE);
362 state.controls_truncated = true;
363
364 CompactAccessibilityProjection {
365 roots,
366 interactive: state.interactive,
367 truncated: state.truncated,
368 nodes_truncated: state.nodes_truncated,
369 labels_truncated: state.labels_truncated,
370 controls_truncated: true,
371 interactive_discovered: total_discovered,
372 omitted_count: omitted.min(999),
373 ranking_applied,
374 shadow_pierced: ShadowPiercedSummary::default(),
375 }
376 } else {
377 CompactAccessibilityProjection {
378 roots,
379 interactive: state.interactive,
380 truncated: state.truncated,
381 nodes_truncated: state.nodes_truncated,
382 labels_truncated: state.labels_truncated,
383 controls_truncated: state.controls_truncated,
384 interactive_discovered: total_discovered,
385 omitted_count: 0,
386 ranking_applied: false,
387 shadow_pierced: ShadowPiercedSummary::default(),
388 }
389 }
390}
391
392fn project_compact_node(
393 node: &AxNode,
394 revision: u64,
395 state: &mut CompactProjectionState,
396 ancestors: &[String],
397) -> Option<CompactAxNode> {
398 if state.node_count >= COMPACT_AX_MAX_NODES {
399 state.truncated = true;
400 state.nodes_truncated = true;
401 return None;
402 }
403 state.node_count += 1;
404
405 let (role, role_truncated) = truncate_utf8(&node.role, COMPACT_AX_ROLE_MAX_BYTES);
406 state.truncated |= role_truncated;
407 state.labels_truncated |= role_truncated;
408 let mut name = String::new();
409 if node.interactive {
410 let control_name = take_compact_text(
411 &node.name,
412 &mut state.interactive_text_remaining,
413 &mut state.truncated,
414 );
415 state.controls_truncated |= control_name.len() < node.name.len();
416 if let Some(backend_dom_node_id) = node.backend_dom_node_id {
417 state.interactive.push(CompactInteractiveElement {
418 reference: backend_node_reference(revision, backend_dom_node_id),
419 role: role.clone(),
420 name: control_name,
421 backend_dom_node_id,
422 ancestor_path: ancestors.to_vec(),
423 shadow_host_path: None,
424 input_type: node.input_type.clone(),
425 value: None,
426 checked: None,
427 selected_option: None,
428 empty: false,
429 read_only: false,
430 required: false,
431 });
432 } else {
433 state.truncated = true;
434 state.controls_truncated = true;
435 }
436 } else {
437 name = take_compact_text(
438 &node.name,
439 &mut state.outline_text_remaining,
440 &mut state.truncated,
441 );
442 state.labels_truncated |= name.len() < node.name.len();
443 }
444
445 let mut child_ancestors = ancestors.to_vec();
446 let ancestor_label = format!("{}:{}", role, name);
447 if !ancestor_label.trim_end_matches(':').is_empty() {
448 let (label, _) = truncate_utf8(&ancestor_label, 96);
449 child_ancestors.push(label);
450 if child_ancestors.len() > 4 {
451 child_ancestors.remove(0);
452 }
453 }
454 let children = node
455 .children
456 .iter()
457 .filter_map(|child| project_compact_node(child, revision, state, &child_ancestors))
458 .collect();
459 Some(CompactAxNode {
460 role,
461 name,
462 children,
463 interactive: node.interactive,
464 })
465}
466
467pub fn backend_node_reference(revision: u64, backend_dom_node_id: i64) -> String {
471 format!("r{revision}:b{backend_dom_node_id}")
472}
473
474fn take_compact_text(text: &str, remaining: &mut usize, truncated: &mut bool) -> String {
475 if text.is_empty() {
476 return String::new();
477 }
478 let (value, was_truncated) = truncate_utf8(text, *remaining);
479 *remaining = remaining.saturating_sub(value.len());
480 *truncated |= was_truncated;
481 value
482}
483
484pub(crate) fn truncate_utf8(text: &str, max_bytes: usize) -> (String, bool) {
485 if text.len() <= max_bytes {
486 return (text.to_string(), false);
487 }
488
489 let marker_len = COMPACT_AX_TRUNCATION_MARKER.len();
490 let mut end = max_bytes.saturating_sub(marker_len);
491 while end > 0 && !text.is_char_boundary(end) {
492 end -= 1;
493 }
494 let mut value = text[..end].to_string();
495 if max_bytes >= marker_len {
496 value.push_str(COMPACT_AX_TRUNCATION_MARKER);
497 }
498 (value, true)
499}
500
501pub fn extract_text_content(nodes: &[AxNode]) -> String {
504 let mut parts = Vec::new();
505 extract_text_recursive(nodes, &mut parts);
506 parts.join("\n")
507}
508
509fn extract_text_recursive(nodes: &[AxNode], parts: &mut Vec<String>) {
510 for node in nodes {
511 if !node.name.is_empty() && node.role != "presentation" && node.role != "none" {
512 parts.push(node.name.clone());
513 }
514 extract_text_recursive(&node.children, parts);
515 }
516}
517
518pub fn format_tree(nodes: &[AxNode], indent: usize) -> String {
521 let mut output = String::new();
522 for node in nodes {
523 let prefix = " ".repeat(indent);
524 let interactive_marker = if node.interactive {
525 " [interactive]"
526 } else {
527 ""
528 };
529 let value_preview = node
530 .value
531 .as_ref()
532 .map(|value| {
533 let preview: String = value.chars().take(50).collect();
534 format!(" = \"{preview}\"")
535 })
536 .unwrap_or_default();
537
538 output.push_str(&format!(
539 "{}[{}] \"{}\"{}{}\n",
540 prefix, node.role, node.name, value_preview, interactive_marker
541 ));
542 output.push_str(&format_tree(&node.children, indent + 1));
543 }
544 output
545}
546
547pub fn build_shadow_host_paths(flattened_doc: &Value) -> HashMap<i64, Vec<String>> {
550 let mut paths: HashMap<i64, Vec<String>> = HashMap::new();
551 let Some(nodes) = flattened_doc["nodes"].as_array() else {
552 return paths;
553 };
554
555 struct NodeMeta {
557 parent_id: Option<i64>,
558 is_shadow_root: bool,
559 label: String,
560 }
561 let mut node_meta: HashMap<i64, NodeMeta> = HashMap::new();
562
563 for node in nodes {
564 let Some(node_id) = node["nodeId"].as_i64() else {
565 continue;
566 };
567 if node["backendNodeId"].as_i64().is_none() {
569 continue;
570 };
571
572 let is_shadow_root = node["shadowRootType"].as_str() == Some("open");
573 let parent_id = node["parentId"].as_i64();
574
575 let tag = node["localName"]
577 .as_str()
578 .or_else(|| node["nodeName"].as_str())
579 .filter(|n| !n.starts_with('#'))
580 .unwrap_or("");
581 let role_attr = node["attributes"]
582 .as_array()
583 .iter()
584 .flat_map(|a| a.iter())
585 .collect::<Vec<_>>()
586 .windows(2)
587 .find(|w| w[0].as_str() == Some("role"))
588 .and_then(|w| w[1].as_str());
589 let label = if let Some(role) = role_attr {
590 truncate_utf8(role, MAX_SHADOW_PATH_ENTRY_BYTES).0
591 } else if !tag.is_empty() {
592 truncate_utf8(tag, MAX_SHADOW_PATH_ENTRY_BYTES).0
593 } else {
594 "shadow-host".to_string()
595 };
596
597 node_meta.insert(
598 node_id,
599 NodeMeta {
600 parent_id,
601 is_shadow_root,
602 label,
603 },
604 );
605 }
606
607 if node_meta.is_empty() {
608 return paths;
609 }
610
611 for node in nodes {
613 let Some(backend_id) = node["backendNodeId"].as_i64() else {
614 continue;
615 };
616 let Some(mut current_parent) = node["parentId"].as_i64() else {
617 continue;
618 };
619
620 let mut breadcrumbs: Vec<String> = Vec::new();
621 let mut visited = HashSet::new();
622 let mut hosts_found = 0usize;
623
624 loop {
625 if !visited.insert(current_parent) || breadcrumbs.len() >= MAX_SHADOW_PATH_ENTRIES {
626 break;
627 }
628
629 let Some(meta) = node_meta.get(¤t_parent) else {
630 if let Some(parent_of_parent) =
632 node_meta.get(¤t_parent).and_then(|m| m.parent_id)
633 {
634 current_parent = parent_of_parent;
635 continue;
636 }
637 break;
638 };
639
640 if meta.is_shadow_root {
641 hosts_found += 1;
642 breadcrumbs.push(meta.label.clone());
643 if let Some(next) = meta.parent_id {
645 current_parent = next;
646 } else {
647 break;
648 }
649 } else if hosts_found > 0 {
650 if let Some(next) = meta.parent_id {
652 current_parent = next;
653 } else {
654 break;
655 }
656 } else {
657 break; }
659 }
660
661 if !breadcrumbs.is_empty() {
662 breadcrumbs.reverse();
663 paths.insert(backend_id, breadcrumbs);
664 }
665 }
666
667 paths
668}
669
670pub fn count_pierced_shadow_hosts(shadow_paths: &HashMap<i64, Vec<String>>) -> usize {
673 let mut hosts = HashSet::new();
675 for path in shadow_paths.values() {
676 if let Some(first) = path.last() {
677 hosts.insert(first.clone());
678 }
679 }
680 hosts.len().min(MAX_SHADOW_HOSTS)
681}
682
683pub fn extract_input_type(raw_ax_node: &Value) -> Option<String> {
685 raw_ax_node["properties"]
686 .as_array()?
687 .iter()
688 .find(|prop| prop["name"].as_str() == Some("inputType"))
689 .and_then(|prop| prop["value"]["value"].as_str())
690 .map(String::from)
691}
692#[derive(Debug, Clone, Serialize, Deserialize)]
693pub struct DomNode {
694 pub node_id: i64,
695 pub node_name: String,
696 pub node_value: String,
697 pub children: Vec<DomNode>,
698 pub attributes: Vec<String>,
699 pub bounding_box: Option<[f64; 4]>,
700}
701
702pub fn parse_dom_tree(raw: &Value) -> Option<DomNode> {
705 raw.get("root").and_then(parse_dom_node)
706}
707
708fn parse_dom_node(raw: &Value) -> Option<DomNode> {
709 let node_id = raw["nodeId"].as_i64()?;
710 let node_name = raw["nodeName"].as_str().unwrap_or_default().to_string();
711 let node_value = raw["nodeValue"].as_str().unwrap_or_default().to_string();
712 let attributes = raw["attributes"]
713 .as_array()
714 .map(|values| {
715 values
716 .iter()
717 .filter_map(|value| value.as_str().map(String::from))
718 .collect()
719 })
720 .unwrap_or_default();
721 let children = raw["children"]
722 .as_array()
723 .map(|values| values.iter().filter_map(parse_dom_node).collect())
724 .unwrap_or_default();
725
726 Some(DomNode {
727 node_id,
728 node_name,
729 node_value,
730 children,
731 attributes,
732 bounding_box: None,
733 })
734}
735
736#[cfg(test)]
737mod tests {
738 use super::*;
739
740 #[test]
741 fn builds_accessibility_children_and_interactive_index() {
742 let raw = serde_json::json!({
743 "nodes": [
744 {"nodeId": "root", "role": {"value": "RootWebArea"}, "childIds": ["button"]},
745 {"nodeId": "button", "parentId": "root", "backendDOMNodeId": 42,
746 "role": {"value": "button"}, "name": {"value": "Save"}, "childIds": []}
747 ]
748 });
749 let tree = parse_accessibility_tree(&raw);
750 assert_eq!(tree.len(), 1);
751 assert_eq!(tree[0].children[0].name, "Save");
752 assert_eq!(
753 find_interactive_elements(&tree)[0].backend_dom_node_id,
754 Some(42)
755 );
756 assert!(format_tree(&tree, 0).contains("[interactive]"));
757 }
758
759 #[test]
760 fn formats_unicode_values_without_slicing_bytes() {
761 let node = AxNode {
762 ax_node_id: "1".to_string(),
763 backend_dom_node_id: None,
764 role: "textbox".to_string(),
765 name: "入力".to_string(),
766 description: String::new(),
767 value: Some("日本語".to_string()),
768 children: Vec::new(),
769 bounds: None,
770 interactive: true,
771 input_type: Some("text".to_string()),
772 };
773 assert!(format_tree(&[node], 0).contains("日本語"));
774 }
775
776 #[test]
777 fn compact_projection_publishes_revisioned_backend_references_only() {
778 let nodes = vec![AxNode {
779 ax_node_id: "root".to_string(),
780 backend_dom_node_id: None,
781 role: "RootWebArea".to_string(),
782 name: String::new(),
783 description: String::new(),
784 value: None,
785 children: vec![
786 AxNode {
787 ax_node_id: "save".to_string(),
788 backend_dom_node_id: Some(42),
789 role: "button".to_string(),
790 name: "Save".to_string(),
791 description: String::new(),
792 value: None,
793 children: Vec::new(),
794 bounds: None,
795 interactive: true,
796 input_type: None,
797 },
798 AxNode {
799 ax_node_id: "unresolved".to_string(),
800 backend_dom_node_id: None,
801 role: "button".to_string(),
802 name: "Unresolved".to_string(),
803 description: String::new(),
804 value: None,
805 children: Vec::new(),
806 bounds: None,
807 interactive: true,
808 input_type: None,
809 },
810 ],
811 bounds: None,
812 interactive: false,
813 input_type: None,
814 }];
815
816 let projection = project_compact_accessibility(&nodes, 5);
817 assert_eq!(projection.interactive.len(), 1);
818 assert_eq!(projection.interactive[0].reference, "r5:b42");
819 assert_eq!(projection.interactive[0].backend_dom_node_id, 42);
820 assert!(projection.truncated);
821 }
822}