use serde::{Deserialize, Serialize};
use crate::bbox::Bbox;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Element {
pub id: usize,
pub class: String,
pub height: i32,
pub width: i32,
pub position: ElementPosition,
#[serde(skip_serializing_if = "Option::is_none")]
pub text_content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prominence: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub children: Option<Vec<usize>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<usize>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct ElementPosition {
pub column_min: i32,
pub row_min: i32,
pub column_max: i32,
pub row_max: i32,
}
impl Element {
pub fn new(id: usize, bbox: &Bbox, class: &str, text_content: Option<String>) -> Self {
Self {
id,
class: class.to_string(),
height: bbox.height(),
width: bbox.width(),
position: ElementPosition {
column_min: bbox.col_min,
row_min: bbox.row_min,
column_max: bbox.col_max,
row_max: bbox.row_max,
},
text_content,
color: None,
prominence: None,
children: None,
parent: None,
}
}
pub fn from_parts(id: usize, col_min: i32, row_min: i32, col_max: i32, row_max: i32, class: &str) -> Self {
let bbox = Bbox::new(col_min, row_min, col_max, row_max);
Self::new(id, &bbox, class, None)
}
pub fn put_bbox(&self) -> (i32, i32, i32, i32) {
(self.position.column_min, self.position.row_min, self.position.column_max, self.position.row_max)
}
pub fn area(&self) -> i64 {
self.width as i64 * self.height as i64
}
pub fn calc_intersection(&self, other: &Element, bias: (i32, i32)) -> (i64, f64, f64, f64) {
let (c1, r1, c2, r2) = self.put_bbox();
let (c3, r3, c4, r4) = other.put_bbox();
let inter_col_min = c1.max(c3) - bias.0;
let inter_row_min = r1.max(r3) - bias.1;
let inter_col_max = c2.min(c4);
let inter_row_max = r2.min(r4);
let w = (inter_col_max - inter_col_min).max(0);
let h = (inter_row_max - inter_row_min).max(0);
let inter = w as i64 * h as i64;
if inter == 0 {
return (0, 0.0, 0.0, 0.0);
}
let union = self.area() + other.area() - inter;
let iou = if union > 0 { inter as f64 / union as f64 } else { 0.0 };
let ioa = inter as f64 / self.area() as f64;
let iob = inter as f64 / other.area() as f64;
(inter, iou, ioa, iob)
}
pub fn element_relation(&self, other: &Element, bias: (i32, i32)) -> i32 {
let (_, _, ioa, iob) = self.calc_intersection(other, bias);
if ioa == 0.0 && iob == 0.0 {
return 0;
}
if ioa >= 1.0 {
return -1;
}
if iob >= 1.0 {
return 1;
}
2
}
pub fn element_merge(&mut self, other: &Element) {
let (c1, r1, c2, r2) = self.put_bbox();
let (c3, r3, c4, r4) = other.put_bbox();
self.position.column_min = c1.min(c3);
self.position.row_min = r1.min(r3);
self.position.column_max = c2.max(c4);
self.position.row_max = r2.max(r4);
self.width = self.position.column_max - self.position.column_min;
self.height = self.position.row_max - self.position.row_min;
if other.text_content.is_some() {
let other_text = other.text_content.as_deref().unwrap_or("");
match &self.text_content {
Some(t) if !t.is_empty() => {
self.text_content = Some(format!("{}\n{}", t, other_text));
}
_ => {
self.text_content = other.text_content.clone();
}
}
}
}
}
fn parse_color_str(s: &str) -> Option<(u8, u8, u8, bool)> {
if s.len() < 11 {
return None;
}
let is_fg = s.starts_with("fg(");
let is_bg = s.starts_with("bg(");
if !is_fg && !is_bg {
return None;
}
let hex_start = if is_fg { 4 } else { 4 };
let hex = &s[hex_start..hex_start + 6];
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
Some((r, g, b, is_fg))
}
fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
let srgb = |c: u8| -> f64 {
let c = c as f64 / 255.0;
if c <= 0.03928 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
};
0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b)
}
pub fn compute_prominence(elements: &mut [Element]) {
if elements.is_empty() {
return;
}
let max_area = elements
.iter()
.map(|e| e.area())
.max()
.unwrap_or(1)
.max(1) as f64;
let max_height = elements
.iter()
.map(|e| e.height as f64)
.max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or(1.0)
.max(1.0);
let class_base = |class: &str| -> f64 {
match class {
"Image" => 0.30, "Button" => 0.25, "Block" => 0.20, "Compo" => 0.20, "Text" => 0.15, "Icon" => 0.10, _ => 0.15,
}
};
for element in elements.iter_mut() {
let area_ratio = if element.class == "Text" {
(element.height as f64 / max_height).min(1.0)
} else {
(element.area() as f64 / max_area).min(1.0)
};
let area_score = area_ratio * 0.5;
let type_score = class_base(&element.class) * 0.3 / 0.30;
let contrast_score = if let Some(ref color_str) = element.color {
if let Some((r, g, b, is_fg)) = parse_color_str(color_str) {
if is_fg {
0.10
} else {
let lum = relative_luminance(r, g, b);
let contrast = 1.0 - lum; contrast * 0.2
}
} else {
0.10 }
} else {
0.10 };
let raw = area_score + type_score + contrast_score;
element.prominence = Some((raw * 100.0).round() / 100.0);
}
}
pub fn prominence_label(p: f64) -> &'static str {
if p >= 0.50 {
" [Primary]"
} else if p >= 0.40 {
" [Secondary]"
} else {
""
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutputResult {
pub comps: Vec<Element>,
pub img_shape: (u32, u32),
}
fn class_abbr(class: &str) -> String {
match class {
"Button" => "Btn".to_string(),
"Text" => "Txt".to_string(),
"Image" => "Img".to_string(),
"Compo" => "Cmp".to_string(),
"Block" => "Blk".to_string(),
"Icon" => "Icn".to_string(),
_ => class.chars().take(3).collect(),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompactElement {
pub c: String,
pub x: i32,
pub y: i32,
pub w: i32,
pub h: i32,
#[serde(skip_serializing_if = "Option::is_none")]
pub t: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cl: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub p: Option<f64>,
}
impl From<&Element> for CompactElement {
fn from(e: &Element) -> Self {
Self {
c: class_abbr(&e.class),
x: e.position.column_min,
y: e.position.row_min,
w: e.width,
h: e.height,
t: e.text_content.clone(),
cl: e.color.clone(),
p: e.prominence,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AiOutput {
pub shape: (u32, u32),
pub elements: Vec<CompactElement>,
}
impl AiOutput {
pub fn from_elements(elements: &[Element], img_shape: (u32, u32)) -> Self {
let (img_w, img_h) = (img_shape.1 as f64, img_shape.0 as f64);
let norm = |val: i32, max: f64| -> i32 {
if max <= 0.0 { return 0; }
((val as f64 / max) * 1000.0).round() as i32
};
Self {
shape: (1000, 1000),
elements: elements.iter().map(|e| CompactElement {
c: class_abbr(&e.class),
x: norm(e.position.column_min, img_w),
y: norm(e.position.row_min, img_h),
w: norm(e.width, img_w),
h: norm(e.height, img_h),
t: e.text_content.clone(),
cl: e.color.clone(),
p: e.prominence,
}).collect(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TreeNode {
pub c: String,
pub x: i32,
pub y: i32,
pub w: i32,
pub h: i32,
#[serde(skip_serializing_if = "Option::is_none")]
pub t: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cl: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub p: Option<f64>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub children: Vec<TreeNode>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TreeOutput {
pub img_shape: (u32, u32),
pub root: TreeNode,
}
impl TreeOutput {
pub fn from_elements(elements: &[Element], img_shape: (u32, u32)) -> Self {
let map: std::collections::HashMap<usize, &Element> =
elements.iter().map(|e| (e.id, e)).collect();
fn build_node(id: usize, map: &std::collections::HashMap<usize, &Element>) -> TreeNode {
let e = map.get(&id).expect("Element id should exist");
let mut node = TreeNode {
c: class_abbr(&e.class),
x: e.position.column_min,
y: e.position.row_min,
w: e.width,
h: e.height,
t: e.text_content.clone(),
cl: e.color.clone(),
p: e.prominence,
children: Vec::new(),
};
if let Some(child_ids) = &e.children {
for &child_id in child_ids {
node.children.push(build_node(child_id, map));
}
}
node
}
let root_ids: Vec<usize> = elements.iter()
.filter(|e| e.parent.is_none())
.map(|e| e.id)
.collect();
let mut root = TreeNode {
c: "Root".to_string(),
x: 0,
y: 0,
w: img_shape.1 as i32,
h: img_shape.0 as i32,
t: None,
cl: None,
p: None,
children: Vec::new(),
};
for &id in &root_ids {
root.children.push(build_node(id, &map));
}
Self { img_shape, root }
}
pub fn to_text(&self) -> String {
let mut lines = Vec::new();
lines.push(format!("UI Layout ({}×{})", self.img_shape.1, self.img_shape.0));
fn render_node(node: &TreeNode, prefix: &str, is_last: bool, is_root: bool, lines: &mut Vec<String>) {
let connector = if is_root { "" } else if is_last { "└─ " } else { "├─ " };
let text = match &node.t {
Some(t) if node.c == "Icn" => format!(" {}", t),
Some(t) => format!(" \"{}\"", t),
None => String::new(),
};
let color = node.cl.as_ref().map(|c| format!(" {}", c)).unwrap_or_default();
let prominence = node.p.and_then(|p| {
let label = prominence_label(p);
if label.is_empty() { None } else { Some(label.to_string()) }
}).unwrap_or_default();
let line = format!("{}{}[{:>3},{:>3} {:>3}×{:>3}] {}{}{}{}",
prefix, connector, node.x, node.y, node.w, node.h, node.c, text, color, prominence);
lines.push(line);
let child_prefix = if is_root { "" } else if is_last { " " } else { "│ " };
let new_prefix = format!("{}{}", prefix, child_prefix);
let count = node.children.len();
for (i, child) in node.children.iter().enumerate() {
render_node(child, &new_prefix, i == count - 1, false, lines);
}
}
render_node(&self.root, "", true, true, &mut lines);
lines.join("\n")
}
}
pub fn elements_to_text(elements: &[Element], img_shape: (u32, u32)) -> String {
let ai = AiOutput::from_elements(elements, img_shape);
let mut lines = Vec::new();
lines.push(format!("UI layout ({}x{})", img_shape.1, img_shape.0));
for e in &ai.elements {
let text = match &e.t {
Some(t) if e.c == "Icn" => format!(" {}", t),
Some(t) => format!(" \"{}\"", t),
None => String::new(),
};
let color = e.cl.as_ref().map(|c| format!(" {}", c)).unwrap_or_default();
let prominence = e.p.and_then(|p| {
let label = prominence_label(p);
if label.is_empty() { None } else { Some(label.to_string()) }
}).unwrap_or_default();
lines.push(format!(" [{:>3},{:>3} {:>3}x{:>3}] {}{}{}{}",
e.x, e.y, e.w, e.h, e.c, text, color, prominence));
}
lines.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bbox::Bbox;
#[test]
fn test_compact_conversion() {
let bbox = Bbox::new(10, 20, 100, 200);
let el = Element::new(0, &bbox, "Button", Some("OK".to_string()));
let ce = CompactElement::from(&el);
assert_eq!(ce.c, "Btn");
assert_eq!(ce.x, 10);
assert_eq!(ce.t, Some("OK".to_string()));
}
#[test]
fn test_ai_output_normalized() {
let bbox = Bbox::new(0, 0, 500, 500);
let el = Element::new(0, &bbox, "Block", None);
let ai = AiOutput::from_elements(&[el], (1000, 1000));
assert_eq!(ai.elements[0].x, 0);
assert_eq!(ai.elements[0].w, 500);
}
#[test]
fn test_class_abbr_icon() {
assert_eq!(class_abbr("Icon"), "Icn");
}
#[test]
fn test_elements_to_text() {
let bbox = Bbox::new(10, 20, 100, 60);
let el = Element::new(0, &bbox, "Button", Some("登录".to_string()));
let text = elements_to_text(&[el], (200, 400));
assert!(text.contains("登录"));
assert!(text.contains("Btn"));
}
#[test]
fn test_tree_output_basic() {
let parent_bbox = Bbox::new(0, 0, 200, 200);
let child_bbox = Bbox::new(10, 10, 50, 50);
let mut parent = Element::new(0, &parent_bbox, "Block", None);
let mut child = Element::new(1, &child_bbox, "Button", Some("Click".to_string()));
parent.children = Some(vec![1]);
child.parent = Some(0);
let tree = TreeOutput::from_elements(&[parent, child], (200, 200));
assert_eq!(tree.root.children.len(), 1);
assert_eq!(tree.root.children[0].c, "Blk");
assert_eq!(tree.root.children[0].children.len(), 1);
assert_eq!(tree.root.children[0].children[0].c, "Btn");
assert_eq!(tree.root.children[0].children[0].t.as_deref(), Some("Click"));
}
#[test]
fn test_tree_output_flat_text() {
let bbox = Bbox::new(0, 0, 100, 100);
let el = Element::new(0, &bbox, "Block", None);
let tree = TreeOutput::from_elements(&[el], (100, 100));
let text = tree.to_text();
assert!(text.contains("Blk"));
assert!(text.contains("100×100"));
}
}