use serde_json::Value;
use std::collections::HashMap;
const INTERESTING_ROLES: &[&str] = &[
"alert", "alertdialog", "application", "article", "banner", "blockquote", "button", "caption",
"cell", "checkbox", "columnheader", "combobox", "complementary", "contentinfo", "date",
"definition", "dialog", "directory", "document", "figure", "form", "generic", "grid",
"gridcell", "group", "heading", "img", "image", "link", "list", "listbox", "listitem", "main",
"menu", "menubar", "menuitem", "menuitemcheckbox", "menuitemradio", "meter", "navigation",
"note", "option", "paragraph", "progressbar", "radio", "radiogroup", "region", "row",
"rowgroup", "rowheader", "scrollbar", "search", "searchbox", "separator", "slider",
"spinbutton", "status", "switch", "tab", "table", "tablist", "tabpanel", "term", "textbox",
"time", "timer", "toolbar", "tooltip", "tree", "treegrid", "treeitem",
];
struct Node {
role: String,
name: String,
attrs: Vec<String>,
children: Vec<usize>,
}
fn is_interesting_role(role: &str) -> bool {
let r = role.to_ascii_lowercase();
INTERESTING_ROLES.iter().any(|&x| x == r)
}
fn prop_value<'a>(props: Option<&'a Value>, name: &str) -> Option<&'a Value> {
let arr = props.and_then(|v| v.as_array())?;
for p in arr {
if p.get("name").and_then(|v| v.as_str()) == Some(name) {
return p.get("value").and_then(|v| v.get("value"));
}
}
None
}
fn prop_bool(props: Option<&Value>, name: &str) -> bool {
prop_value(props, name).and_then(|v| v.as_bool()).unwrap_or(false)
}
fn parse_node(raw: &Value) -> Option<Node> {
let ignored = raw.get("ignored").and_then(|v| v.as_bool()).unwrap_or(false);
if ignored {
return None;
}
let role = raw
.get("role")
.and_then(|r| r.get("value"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let name = raw
.get("name")
.and_then(|n| n.get("value"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let props = raw.get("properties");
let role_lc = role.to_ascii_lowercase();
if (role_lc == "none" || role_lc == "presentation") && name.trim().is_empty() {
return None;
}
let attrs = build_attrs(&role_lc, name.as_str(), props);
Some(Node {
role,
name,
attrs,
children: Vec::new(),
})
}
fn build_attrs(role_lc: &str, _name: &str, props: Option<&Value>) -> Vec<String> {
let mut attrs = Vec::new();
if matches!(
role_lc,
"heading" | "row" | "treeitem" | "listitem" | "tab" | "option" | "cell"
) {
if let Some(lvl) = prop_value(props, "level").and_then(|v| v.as_i64()) {
if role_lc == "heading" || lvl > 1 {
attrs.push(format!("level={lvl}"));
}
}
}
let mut bool_attr = |n: &str| {
if prop_bool(props, n) {
attrs.push(n.to_string());
}
};
bool_attr("disabled");
bool_attr("expanded");
bool_attr("collapsed");
bool_attr("selected");
bool_attr("readonly");
bool_attr("required");
bool_attr("focused");
bool_attr("modal");
bool_attr("multiline");
bool_attr("hidden");
if let Some(c) = prop_value(props, "checked") {
match c.as_str() {
Some("mixed") => attrs.push("mixed".to_string()),
_ => {
if c.as_bool().unwrap_or(false) {
attrs.push("checked".to_string());
}
}
}
}
if let Some(p) = prop_value(props, "pressed") {
match p.as_str() {
Some("mixed") => {
if !attrs.contains(&"mixed".to_string()) {
attrs.push("mixed".to_string());
}
}
_ => {
if p.as_bool().unwrap_or(false) {
attrs.push("pressed".to_string());
}
}
}
}
match role_lc {
"slider" | "spinbutton" | "scrollbar" | "meter" | "progressbar" => {
if let Some(vmin) = numeric_value(props, "valuemin") {
attrs.push(format!("valuemin={vmin}"));
}
if let Some(vmax) = numeric_value(props, "valuemax") {
attrs.push(format!("valuemax={vmax}"));
}
if let Some(vt) = prop_value(props, "valuetext").and_then(|v| v.as_str()) {
attrs.push(format!("valuetext={vt}"));
}
}
_ => {}
}
attrs
}
fn numeric_value(props: Option<&Value>, name: &str) -> Option<String> {
let v = prop_value(props, name)?;
if let Some(i) = v.as_i64() {
return Some(i.to_string());
}
v.as_f64().map(|f| format!("{f}"))
}
pub(crate) fn serialize(nodes: &[Value], root_backend_dom_node_id: Option<i64>) -> String {
let mut parsed: Vec<Node> = Vec::with_capacity(nodes.len());
let mut id_to_idx: HashMap<String, usize> = HashMap::new();
let mut parent_links: Vec<(usize, Option<String>)> = Vec::new();
for raw in nodes {
let Some(node) = parse_node(raw) else {
continue;
};
let idx = parsed.len();
let id = raw
.get("nodeId")
.and_then(|v| v.as_str())
.map(String::from);
if let Some(id) = &id {
id_to_idx.insert(id.clone(), idx);
}
let parent = raw
.get("parentId")
.and_then(|v| v.as_str())
.map(String::from);
parent_links.push((idx, parent.or(id)));
parsed.push(node);
}
let be_id: Vec<Option<i64>> = nodes
.iter()
.filter_map(|raw| {
if parse_node(raw).is_some() {
Some(
raw.get("backendDOMNodeId")
.and_then(|v| v.as_i64()),
)
} else {
None
}
})
.collect();
let mut edges: Vec<(usize, usize)> = Vec::new();
for raw in nodes {
let Some(child_id) = raw.get("nodeId").and_then(|v| v.as_str()) else {
continue;
};
let Some(&child_idx) = id_to_idx.get(child_id) else {
continue;
};
if let Some(child_ids) = raw.get("childIds").and_then(|v| v.as_array()) {
for cid in child_ids {
if let Some(cid_s) = cid.as_str() {
if let Some(&cidx) = id_to_idx.get(cid_s) {
edges.push((child_idx, cidx));
}
}
}
} else if let Some(pid) = raw.get("parentId").and_then(|v| v.as_str()) {
if let Some(&pidx) = id_to_idx.get(pid) {
edges.push((pidx, child_idx));
}
}
}
{
let mut seen: HashMap<(usize, usize), ()> = HashMap::new();
for (p, c) in edges {
if p == c {
continue;
}
if seen.insert((p, c), ()).is_none() {
parsed[p].children.push(c);
}
}
}
let orig_children: Vec<Vec<usize>> = parsed.iter().map(|n| n.children.clone()).collect();
let kept: Vec<bool> = parsed
.iter()
.map(|n| is_kept(n))
.collect();
let in_subtree: Option<Vec<bool>> = if let Some(root_be) = root_backend_dom_node_id {
let root_idx = be_id.iter().position(|b| *b == Some(root_be));
if let Some(root_idx) = root_idx {
let mut mask = vec![false; parsed.len()];
let mut stack = vec![root_idx];
mask[root_idx] = true;
while let Some(i) = stack.pop() {
if let Some(kids) = orig_children.get(i) {
for &c in kids {
if !mask[c] {
mask[c] = true;
stack.push(c);
}
}
}
}
Some(mask)
} else {
return String::new();
}
} else {
None
};
let in_subtree = in_subtree.unwrap_or_else(|| vec![true; parsed.len()]);
let subtree_contains = |i: usize| in_subtree.get(i).copied().unwrap_or(false);
let mut roots: Vec<usize> = Vec::new();
let mut attachments: Vec<(usize, usize)> = Vec::new();
for idx in 0..parsed.len() {
if !kept[idx] || !subtree_contains(idx) {
continue;
}
match nearest_kept_ancestor(idx, &kept, &parent_links, &id_to_idx) {
Some(parent) if subtree_contains(parent) => attachments.push((parent, idx)),
_ => {
if !roots.contains(&idx) {
roots.push(idx);
}
}
}
}
for (parent, child) in attachments {
if !parsed[parent].children.contains(&child) {
parsed[parent].children.push(child);
}
}
for n in parsed.iter_mut() {
n.children.retain(|&c| kept[c]);
}
for idx in 0..parsed.len() {
if !kept[idx] {
continue;
}
if !parsed[idx].name.trim().is_empty() {
continue;
}
if let Some(text) = collect_text(idx, &parsed, &orig_children) {
let t = text.trim();
if !t.is_empty() {
parsed[idx].name = t.to_string();
}
}
}
let mut out = String::new();
for (i, &root) in roots.iter().enumerate() {
if i > 0 {
out.push('\n');
}
render(&parsed, root, 0, &mut out);
}
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
out
}
fn is_kept(n: &Node) -> bool {
let role_lc = n.role.to_ascii_lowercase();
if is_interesting_role(&role_lc) {
return true;
}
if (role_lc == "none" || role_lc == "presentation") && !n.name.trim().is_empty() {
return true;
}
false
}
fn collect_text(
root: usize,
parsed: &[Node],
orig_children: &[Vec<usize>],
) -> Option<String> {
let mut out = String::new();
let mut stack: Vec<usize> = orig_children
.get(root)
.cloned()
.unwrap_or_default();
while let Some(idx) = stack.pop() {
let role_lc = parsed[idx].role.to_ascii_lowercase();
if is_kept(&parsed[idx]) {
continue;
}
let is_text = matches!(
role_lc.as_str(),
"statictext" | "inlinetextbox" | "text" | "caption"
);
if is_text {
if !parsed[idx].name.is_empty() {
if !out.is_empty() && !out.ends_with(' ') {
out.push(' ');
}
out.push_str(&parsed[idx].name);
}
continue;
}
if let Some(kids) = orig_children.get(idx) {
for &c in kids.iter().rev() {
stack.push(c);
}
}
}
if out.is_empty() {
None
} else {
Some(out)
}
}
fn nearest_kept_ancestor(
idx: usize,
kept: &[bool],
parent_links: &[(usize, Option<String>)],
id_to_idx: &HashMap<String, usize>,
) -> Option<usize> {
let mut cur_id = parent_links
.iter()
.find(|(i, _)| *i == idx)
.and_then(|(_, pid)| pid.clone());
while let Some(pid_str) = cur_id {
let Some(&pidx) = id_to_idx.get(&pid_str) else { return None };
if kept[pidx] {
return Some(pidx);
}
cur_id = parent_links
.iter()
.find(|(i, _)| *i == pidx)
.and_then(|(_, pid)| pid.clone());
}
None
}
fn escape_name(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
c => out.push(c),
}
}
out
}
fn render(parsed: &[Node], idx: usize, depth: usize, out: &mut String) {
let node = &parsed[idx];
let indent = " ".repeat(depth);
out.push_str(&indent);
out.push_str("- ");
out.push_str(&node.role.to_ascii_lowercase());
if !node.name.is_empty() {
out.push_str(" \"");
out.push_str(&escape_name(&node.name));
out.push('"');
}
if !node.attrs.is_empty() {
out.push_str(" [");
out.push_str(&node.attrs.join(" "));
out.push(']');
}
let has_children = !node.children.is_empty();
if has_children {
out.push(':');
}
out.push('\n');
for &child in &node.children {
render(parsed, child, depth + 1, out);
}
}