use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn process_to_json(html: &str, css: &str, vw: f32, vh: f32) -> String {
let elements = process(html, css, vw, vh);
to_json(&elements)
}
#[derive(Clone, Copy)]
struct Length { value: f32, is_auto: bool, is_pct: bool }
impl Default for Length {
fn default() -> Self { Self { value: 0.0, is_auto: true, is_pct: false } }
}
impl Length {
fn px(v: f32) -> Self { Self { value: v, is_auto: false, is_pct: false } }
fn auto() -> Self { Self { value: 0.0, is_auto: true, is_pct: false } }
fn parse(s: &str) -> Self {
let s = s.trim();
if s == "auto" { return Self::auto(); }
let is_pct = s.ends_with('%');
let v = s.trim_end_matches("px").trim_end_matches('%').parse().unwrap_or(0.0);
Self { value: v, is_auto: false, is_pct }
}
fn to_px(&self, parent: f32) -> f32 {
if self.is_auto { 0.0 } else if self.is_pct { self.value / 100.0 * parent } else { self.value }
}
}
#[derive(Clone, Copy, Default)]
struct Color { r: u8, g: u8, b: u8, a: f32 }
impl Color {
fn parse(s: &str) -> Self {
let s = s.trim();
match s {
"transparent" => Self { r: 0, g: 0, b: 0, a: 0.0 },
"black" => Self { r: 0, g: 0, b: 0, a: 1.0 },
"white" => Self { r: 255, g: 255, b: 255, a: 1.0 },
"red" => Self { r: 255, g: 0, b: 0, a: 1.0 },
"green" => Self { r: 0, g: 128, b: 0, a: 1.0 },
"blue" => Self { r: 0, g: 0, b: 255, a: 1.0 },
"gray" | "grey" => Self { r: 128, g: 128, b: 128, a: 1.0 },
_ if s.starts_with('#') && s.len() == 7 => Self {
r: u8::from_str_radix(&s[1..3], 16).unwrap_or(0),
g: u8::from_str_radix(&s[3..5], 16).unwrap_or(0),
b: u8::from_str_radix(&s[5..7], 16).unwrap_or(0),
a: 1.0,
},
_ if s.starts_with("rgba(") => {
let inner = s.trim_start_matches("rgba(").trim_end_matches(')');
let p: Vec<&str> = inner.split(',').collect();
if p.len() == 4 {
Self {
r: p[0].trim().parse().unwrap_or(0),
g: p[1].trim().parse().unwrap_or(0),
b: p[2].trim().parse().unwrap_or(0),
a: p[3].trim().parse().unwrap_or(1.0),
}
} else { Self::default() }
}
_ => Self::default(),
}
}
fn to_gl(&self) -> [f32; 4] {
[self.r as f32 / 255.0, self.g as f32 / 255.0, self.b as f32 / 255.0, self.a]
}
}
#[derive(Clone, Copy, Default, PartialEq)]
enum Display { #[default] Block, Flex, Grid, None }
#[derive(Clone, Copy, Default, PartialEq)]
enum Position { #[default] Static, Relative, Absolute }
#[derive(Clone, Copy, Default, PartialEq)]
enum FlexDir { #[default] Row, Column }
#[derive(Clone, Copy, Default, PartialEq)]
enum Justify { #[default] Start, End, Center, Between, Around }
#[derive(Clone, Copy, Default, PartialEq)]
enum Align { #[default] Stretch, Start, End, Center }
#[derive(Clone, Copy, Default, PartialEq)]
enum TimingFn { #[default] Linear, Ease, EaseIn, EaseOut, EaseInOut }
#[derive(Clone, Copy, Default, PartialEq)]
enum AnimDirection { #[default] Normal, Reverse, Alternate, AlternateReverse }
#[derive(Clone, Copy, Default, PartialEq)]
enum FillMode { #[default] None, Forwards, Backwards, Both }
#[derive(Clone, Default)]
struct Animation {
name: String,
duration: f32, delay: f32, timing: TimingFn,
iteration: f32, direction: AnimDirection,
fill: FillMode,
}
#[derive(Clone, Default)]
struct Transition {
property: String, duration: f32,
delay: f32,
timing: TimingFn,
}
#[derive(Clone, Copy, Default)]
struct Transform {
translate_x: f32,
translate_y: f32,
translate_z: f32,
rotate_x: f32, rotate_y: f32,
rotate_z: f32,
scale_x: f32,
scale_y: f32,
scale_z: f32,
}
impl Transform {
fn identity() -> Self {
Self {
scale_x: 1.0, scale_y: 1.0, scale_z: 1.0,
..Default::default()
}
}
}
#[derive(Clone, Copy, Default, PartialEq)]
enum Cursor { #[default] Default, Pointer, Move, Text, NotAllowed }
#[derive(Clone, Copy, Default)]
struct Style {
display: Display, position: Position,
width: Length, height: Length,
padding: [Length; 4], margin: [Length; 4],
pos_offset: [Length; 4],
flex_dir: FlexDir, justify: Justify, align: Align, gap: Length,
flex_grow: f32, flex_shrink: f32,
grid_cols: Vec4, grid_rows: Vec4,
grid_col: (i32, i32), grid_row: (i32, i32),
bg: Color, color: Color, opacity: f32, radius: f32, font_size: f32,
z_index: i32,
transform: Transform,
cursor: Cursor,
pointer_events: bool, }
#[derive(Clone, Default)]
struct StyleExt {
animation: Option<Animation>,
transition: Option<Transition>,
}
#[derive(Clone, Copy, Default)]
struct Vec4 {
values: [f32; 4],
count: usize,
}
impl Style {
fn apply(&mut self, p: &str, v: &str) {
match p {
"display" => self.display = match v.trim() {
"flex" => Display::Flex,
"grid" => Display::Grid,
"none" => Display::None,
_ => Display::Block
},
"position" => self.position = match v.trim() {
"relative" => Position::Relative,
"absolute" => Position::Absolute,
_ => Position::Static
},
"width" => self.width = Length::parse(v),
"height" => self.height = Length::parse(v),
"padding" => { let l = Length::parse(v); self.padding = [l; 4]; }
"padding-top" => self.padding[0] = Length::parse(v),
"padding-right" => self.padding[1] = Length::parse(v),
"padding-bottom" => self.padding[2] = Length::parse(v),
"padding-left" => self.padding[3] = Length::parse(v),
"margin" => { let l = Length::parse(v); self.margin = [l; 4]; }
"margin-top" => self.margin[0] = Length::parse(v),
"margin-right" => self.margin[1] = Length::parse(v),
"margin-bottom" => self.margin[2] = Length::parse(v),
"margin-left" => self.margin[3] = Length::parse(v),
"top" => self.pos_offset[0] = Length::parse(v),
"right" => self.pos_offset[1] = Length::parse(v),
"bottom" => self.pos_offset[2] = Length::parse(v),
"left" => self.pos_offset[3] = Length::parse(v),
"z-index" => self.z_index = v.trim().parse().unwrap_or(0),
"flex-direction" => self.flex_dir = if v.contains("column") { FlexDir::Column } else { FlexDir::Row },
"justify-content" => self.justify = match v.trim() {
"flex-end" | "end" => Justify::End, "center" => Justify::Center,
"space-between" => Justify::Between, "space-around" => Justify::Around, _ => Justify::Start
},
"align-items" => self.align = match v.trim() {
"flex-start" | "start" => Align::Start, "flex-end" | "end" => Align::End,
"center" => Align::Center, _ => Align::Stretch
},
"gap" => self.gap = Length::parse(v),
"flex-grow" => self.flex_grow = v.trim().parse().unwrap_or(0.0),
"flex-shrink" => self.flex_shrink = v.trim().parse().unwrap_or(1.0),
"flex" => {
let parts: Vec<&str> = v.trim().split_whitespace().collect();
if let Some(g) = parts.get(0) { self.flex_grow = g.parse().unwrap_or(0.0); }
if let Some(s) = parts.get(1) { self.flex_shrink = s.parse().unwrap_or(1.0); }
}
"grid-template-columns" => self.grid_cols = Self::parse_tracks(v),
"grid-template-rows" => self.grid_rows = Self::parse_tracks(v),
"grid-column" => self.grid_col = Self::parse_grid_placement(v),
"grid-row" => self.grid_row = Self::parse_grid_placement(v),
"background-color" | "background" => self.bg = Color::parse(v),
"color" => self.color = Color::parse(v),
"opacity" => self.opacity = v.trim().parse().unwrap_or(1.0),
"border-radius" => self.radius = v.trim().trim_end_matches("px").parse().unwrap_or(0.0),
"font-size" => self.font_size = v.trim().trim_end_matches("px").parse().unwrap_or(16.0),
"transform" => self.transform = Self::parse_transform(v),
"cursor" => self.cursor = match v.trim() {
"pointer" => Cursor::Pointer,
"move" => Cursor::Move,
"text" => Cursor::Text,
"not-allowed" => Cursor::NotAllowed,
_ => Cursor::Default,
},
"pointer-events" => self.pointer_events = v.trim() != "none",
_ => {}
}
}
fn parse_transform(v: &str) -> Transform {
let mut t = Transform::identity();
let v = v.trim();
let mut i = 0;
let chars: Vec<char> = v.chars().collect();
while i < chars.len() {
let fn_start = i;
while i < chars.len() && chars[i] != '(' { i += 1; }
if i >= chars.len() { break; }
let fn_name: String = chars[fn_start..i].iter().collect();
let fn_name = fn_name.trim();
i += 1;
let arg_start = i;
let mut paren_depth = 1;
while i < chars.len() && paren_depth > 0 {
if chars[i] == '(' { paren_depth += 1; }
if chars[i] == ')' { paren_depth -= 1; }
i += 1;
}
let args: String = chars[arg_start..i-1].iter().collect();
let args: Vec<f32> = args.split(',')
.map(|s| s.trim().trim_end_matches("px").trim_end_matches("deg").parse().unwrap_or(0.0))
.collect();
match fn_name {
"translateX" => if let Some(&x) = args.get(0) { t.translate_x = x; }
"translateY" => if let Some(&y) = args.get(0) { t.translate_y = y; }
"translateZ" => if let Some(&z) = args.get(0) { t.translate_z = z; }
"translate" => {
if let Some(&x) = args.get(0) { t.translate_x = x; }
if let Some(&y) = args.get(1) { t.translate_y = y; }
}
"translate3d" => {
if let Some(&x) = args.get(0) { t.translate_x = x; }
if let Some(&y) = args.get(1) { t.translate_y = y; }
if let Some(&z) = args.get(2) { t.translate_z = z; }
}
"rotateX" => if let Some(&x) = args.get(0) { t.rotate_x = x; }
"rotateY" => if let Some(&y) = args.get(0) { t.rotate_y = y; }
"rotateZ" | "rotate" => if let Some(&z) = args.get(0) { t.rotate_z = z; }
"rotate3d" => {
if let Some(&angle) = args.get(3) {
t.rotate_z = angle; }
}
"scaleX" => if let Some(&x) = args.get(0) { t.scale_x = x; }
"scaleY" => if let Some(&y) = args.get(0) { t.scale_y = y; }
"scaleZ" => if let Some(&z) = args.get(0) { t.scale_z = z; }
"scale" => {
if let Some(&s) = args.get(0) {
t.scale_x = s;
t.scale_y = args.get(1).copied().unwrap_or(s);
}
}
"scale3d" => {
if let Some(&x) = args.get(0) { t.scale_x = x; }
if let Some(&y) = args.get(1) { t.scale_y = y; }
if let Some(&z) = args.get(2) { t.scale_z = z; }
}
_ => {}
}
while i < chars.len() && chars[i].is_whitespace() { i += 1; }
}
t
}
fn parse_tracks(v: &str) -> Vec4 {
let mut result = Vec4::default();
for (i, part) in v.split_whitespace().enumerate() {
if i >= 4 { break; }
let val = if part.ends_with("fr") {
part.trim_end_matches("fr").parse().unwrap_or(1.0) * -1.0 } else {
part.trim_end_matches("px").parse().unwrap_or(0.0)
};
result.values[i] = val;
result.count = i + 1;
}
result
}
fn parse_grid_placement(v: &str) -> (i32, i32) {
let parts: Vec<&str> = v.split('/').map(|s| s.trim()).collect();
let start: i32 = parts.get(0).and_then(|s| s.parse().ok()).unwrap_or(1);
let end_or_span = parts.get(1).unwrap_or(&"");
if end_or_span.starts_with("span") {
let span: i32 = end_or_span.trim_start_matches("span").trim().parse().unwrap_or(1);
(start, span)
} else if let Ok(end) = end_or_span.parse::<i32>() {
(start, end - start)
} else {
(start, 1)
}
}
}
#[derive(Clone, Default)]
struct EventHandlers {
onclick: Option<String>,
onmouseenter: Option<String>,
onmouseleave: Option<String>,
onpointerdown: Option<String>,
onpointerup: Option<String>,
}
struct Node {
tag: String, classes: Vec<String>, id: Option<String>,
attrs: Vec<(String, String)>,
inline: Vec<(String, String)>, children: Vec<usize>, text: Option<String>,
events: EventHandlers,
}
fn parse_html(html: &str) -> Vec<Node> {
let mut nodes: Vec<Node> = Vec::new();
let mut stack: Vec<usize> = Vec::new();
let c: Vec<char> = html.chars().collect();
let (mut i, n) = (0, c.len());
while i < n {
if c[i] == '<' {
if i + 1 < n && c[i + 1] == '/' {
while i < n && c[i] != '>' { i += 1; }
i += 1; stack.pop(); continue;
}
if i + 3 < n && c[i+1] == '!' && c[i+2] == '-' {
while i + 2 < n && !(c[i] == '-' && c[i+1] == '-' && c[i+2] == '>') { i += 1; }
i += 3; continue;
}
i += 1;
let ts = i;
while i < n && !c[i].is_whitespace() && c[i] != '>' && c[i] != '/' { i += 1; }
let tag: String = c[ts..i].iter().collect::<String>().to_lowercase();
let mut node = Node {
tag: tag.clone(), classes: vec![], id: None, attrs: vec![],
inline: vec![], children: vec![], text: None,
events: EventHandlers::default(),
};
while i < n && c[i] != '>' && c[i] != '/' {
while i < n && c[i].is_whitespace() { i += 1; }
if i >= n || c[i] == '>' || c[i] == '/' { break; }
let as_ = i;
while i < n && c[i] != '=' && c[i] != '>' && !c[i].is_whitespace() { i += 1; }
let aname: String = c[as_..i].iter().collect();
let aval = if i < n && c[i] == '=' {
i += 1;
if i < n && (c[i] == '"' || c[i] == '\'') {
let q = c[i]; i += 1;
let vs = i;
while i < n && c[i] != q { i += 1; }
let v: String = c[vs..i].iter().collect();
i += 1; v
} else { String::new() }
} else { String::new() };
node.attrs.push((aname.clone(), aval.clone()));
match aname.to_lowercase().as_str() {
"class" => node.classes = aval.split_whitespace().map(|s| s.into()).collect(),
"id" => node.id = Some(aval),
"style" => {
for d in aval.split(';') {
if let Some(p) = d.find(':') {
node.inline.push((d[..p].trim().into(), d[p+1..].trim().into()));
}
}
}
"onclick" => node.events.onclick = Some(aval),
"onmouseenter" => node.events.onmouseenter = Some(aval),
"onmouseleave" => node.events.onmouseleave = Some(aval),
"onpointerdown" => node.events.onpointerdown = Some(aval),
"onpointerup" => node.events.onpointerup = Some(aval),
"data-onclick" => node.events.onclick = Some(aval),
"data-action" => node.events.onclick = Some(aval),
_ => {}
}
}
while i < n && c[i] != '>' { i += 1; }
let sc = i > 0 && c[i-1] == '/';
i += 1;
let nid = nodes.len();
if let Some(&p) = stack.last() { nodes[p].children.push(nid); }
nodes.push(node);
if !sc && !["br","hr","img","input","meta","link"].contains(&tag.as_str()) { stack.push(nid); }
} else {
let ts = i;
while i < n && c[i] != '<' { i += 1; }
let t: String = c[ts..i].iter().collect::<String>().trim().into();
if !t.is_empty() && !stack.is_empty() {
nodes[*stack.last().unwrap()].text = Some(t);
}
}
}
nodes
}
struct Rule { sel: String, decls: Vec<(String, String)>, spec: u32 }
#[derive(Clone, Default)]
struct Keyframe {
percent: f32, props: Vec<(String, String)>,
}
#[derive(Clone, Default)]
struct KeyframeAnimation {
name: String,
keyframes: Vec<Keyframe>,
}
fn parse_css(css: &str) -> (Vec<Rule>, Vec<KeyframeAnimation>) {
let mut rules = Vec::new();
let mut keyframe_anims = Vec::new();
let css = css.split("/*").map(|s| s.split("*/").last().unwrap_or("")).collect::<String>();
let c: Vec<char> = css.chars().collect();
let (mut i, n) = (0, c.len());
while i < n {
while i < n && c[i].is_whitespace() { i += 1; }
if i >= n { break; }
if i + 10 < n && c[i..i+10].iter().collect::<String>() == "@keyframes" {
i += 10;
while i < n && c[i].is_whitespace() { i += 1; }
let name_start = i;
while i < n && c[i] != '{' && !c[i].is_whitespace() { i += 1; }
let anim_name: String = c[name_start..i].iter().collect();
while i < n && c[i] != '{' { i += 1; }
i += 1;
let mut keyframes = Vec::new();
while i < n {
while i < n && c[i].is_whitespace() { i += 1; }
if i >= n || c[i] == '}' { i += 1; break; }
let percent_start = i;
while i < n && c[i] != '{' { i += 1; }
let percent_str: String = c[percent_start..i].iter().collect();
let percent_str = percent_str.trim();
let percent = if percent_str == "from" {
0.0
} else if percent_str == "to" {
1.0
} else {
percent_str.trim_end_matches('%').parse::<f32>().unwrap_or(0.0) / 100.0
};
i += 1;
let props_start = i;
while i < n && c[i] != '}' { i += 1; }
let props_str: String = c[props_start..i].iter().collect();
i += 1;
let props: Vec<_> = props_str.split(';').filter_map(|d| {
let d = d.trim();
d.find(':').map(|p| (d[..p].trim().into(), d[p+1..].trim().into()))
}).collect();
if !props.is_empty() {
keyframes.push(Keyframe { percent, props });
}
}
keyframes.sort_by(|a, b| a.percent.partial_cmp(&b.percent).unwrap());
keyframe_anims.push(KeyframeAnimation { name: anim_name.trim().into(), keyframes });
continue;
}
if c[i] == '@' {
while i < n && c[i] != '{' { i += 1; }
i += 1;
let mut depth = 1;
while i < n && depth > 0 {
if c[i] == '{' { depth += 1; }
if c[i] == '}' { depth -= 1; }
i += 1;
}
continue;
}
let ss = i;
while i < n && c[i] != '{' { i += 1; }
let sel: String = c[ss..i].iter().collect::<String>().trim().into();
i += 1;
let ds = i;
while i < n && c[i] != '}' { i += 1; }
let decl: String = c[ds..i].iter().collect();
i += 1;
let decls: Vec<_> = decl.split(';').filter_map(|d| {
let d = d.trim();
d.find(':').map(|p| (d[..p].trim().into(), d[p+1..].trim().into()))
}).collect();
if !decls.is_empty() {
let spec = sel.chars().fold(0u32, |a, c| a + if c == '#' { 100 } else if c == '.' { 10 } else { 0 });
for s in sel.split(',') { rules.push(Rule { sel: s.trim().into(), decls: decls.clone(), spec }); }
}
}
(rules, keyframe_anims)
}
fn matches(sel: &str, node: &Node) -> bool {
let last = sel.split_whitespace().last().unwrap_or(sel);
let mut remaining = last.to_string();
let mut required_id: Option<String> = None;
if let Some(hash_pos) = remaining.find('#') {
let after = &remaining[hash_pos + 1..];
let end = after.find(|c: char| c == '.' || c == '[' || c == '#').unwrap_or(after.len());
required_id = Some(after[..end].to_string());
remaining = format!("{}{}", &remaining[..hash_pos], &after[end..]);
}
let mut required_attrs: Vec<(String, Option<String>)> = vec![];
while let Some(start) = remaining.find('[') {
if let Some(rel_end) = remaining[start..].find(']') {
let end = start + rel_end;
let content = &remaining[start + 1..end];
if let Some(eq) = content.find('=') {
let name = content[..eq].to_string();
let val = content[eq + 1..].trim_matches('"').trim_matches('\'').to_string();
required_attrs.push((name, Some(val)));
} else {
required_attrs.push((content.to_string(), None));
}
remaining = format!("{}{}", &remaining[..start], &remaining[end + 1..]);
} else {
break;
}
}
let mut required_classes: Vec<String> = vec![];
let mut tag_name: Option<String> = None;
if !remaining.is_empty() && !remaining.starts_with('.') {
if let Some(dot_pos) = remaining.find('.') {
let t = remaining[..dot_pos].to_string();
if !t.is_empty() {
tag_name = Some(t);
}
remaining = remaining[dot_pos..].to_string();
} else {
if !remaining.is_empty() {
tag_name = Some(remaining.clone());
}
remaining.clear();
}
}
for part in remaining.split('.') {
if !part.is_empty() {
required_classes.push(part.to_string());
}
}
if let Some(ref t) = tag_name {
if t != "*" && t != &node.tag {
return false;
}
}
if let Some(ref id) = required_id {
if node.id.as_ref() != Some(id) {
return false;
}
}
for c in &required_classes {
if !node.classes.iter().any(|nc| nc == c) {
return false;
}
}
for (name, val) in &required_attrs {
let found = node.attrs.iter().any(|(k, v)| {
k == name && (val.is_none() || val.as_ref() == Some(v))
});
if !found {
return false;
}
}
true
}
#[derive(Clone, Copy, Default)]
struct Layout { x: f32, y: f32, w: f32, h: f32 }
fn compute(nodes: &[Node], styles: &[Style], idx: usize, aw: f32, _ah: f32, out: &mut [Layout]) -> (f32, f32) {
let s = &styles[idx];
if s.display == Display::None { return (0.0, 0.0); }
let p = [s.padding[0].to_px(aw), s.padding[1].to_px(aw), s.padding[2].to_px(aw), s.padding[3].to_px(aw)];
let m = [s.margin[0].to_px(aw), s.margin[1].to_px(aw), s.margin[2].to_px(aw), s.margin[3].to_px(aw)];
let explicit_w = if s.width.is_auto { None } else { Some(s.width.to_px(aw)) };
let content_w = explicit_w.unwrap_or((aw - p[1] - p[3] - m[1] - m[3]).max(0.0));
let node = &nodes[idx];
let mut csz: Vec<(usize, f32, f32, f32)> = vec![]; for &c in &node.children {
let (w, h) = compute(nodes, styles, c, content_w, 10000.0, out);
let child_m = &styles[c].margin;
let mw = child_m[1].to_px(content_w) + child_m[3].to_px(content_w);
let mh = child_m[0].to_px(content_w) + child_m[2].to_px(content_w);
csz.push((c, w + mw, h + mh, styles[c].flex_grow));
}
let tw = node.text.as_ref().map(|t| t.len() as f32 * s.font_size * 0.6).unwrap_or(0.0);
let th = if node.text.is_some() { s.font_size * 1.4 } else { 0.0 };
let (iw, ih) = if s.display == Display::Flex {
let g = s.gap.to_px(content_w);
let gap_total = g * csz.len().saturating_sub(1) as f32;
if s.flex_dir == FlexDir::Row {
let children_w: f32 = csz.iter().map(|(_, w, _, _)| w).sum();
let children_h: f32 = csz.iter().map(|(_, _, h, _)| *h).fold(0.0f32, |a, b| a.max(b));
(children_w + gap_total + tw, children_h.max(th))
} else {
let children_w: f32 = csz.iter().map(|(_, w, _, _)| *w).fold(0.0f32, |a, b| a.max(b));
let children_h: f32 = csz.iter().map(|(_, _, h, _)| *h).sum();
(children_w.max(tw), children_h + gap_total + th)
}
} else {
let children_w: f32 = csz.iter().map(|(_, w, _, _)| *w).fold(0.0f32, |a, b| a.max(b));
let children_h: f32 = csz.iter().map(|(_, _, h, _)| *h).sum();
(children_w.max(tw), children_h + th)
};
let fw = if let Some(w) = explicit_w {
w + p[1] + p[3]
} else {
if iw > 0.0 || tw > 0.0 {
(iw + p[1] + p[3]).min(aw - m[1] - m[3])
} else {
aw - m[1] - m[3] }
};
let fh = if s.height.is_auto {
ih + p[0] + p[2]
} else {
s.height.to_px(aw) + p[0] + p[2]
};
out[idx] = Layout { x: 0.0, y: 0.0, w: fw, h: fh };
(fw, fh)
}
fn position(nodes: &[Node], styles: &[Style], idx: usize, x: f32, y: f32, parent_layout: Option<&Layout>, out: &mut [Layout]) {
let s = &styles[idx];
if s.display == Display::None { return; }
let m = [s.margin[0].to_px(out[idx].w), s.margin[1].to_px(out[idx].w),
s.margin[2].to_px(out[idx].w), s.margin[3].to_px(out[idx].w)];
let (final_x, final_y) = match s.position {
Position::Absolute => {
if let Some(pl) = parent_layout {
let top = if !s.pos_offset[0].is_auto { s.pos_offset[0].to_px(pl.h) } else { 0.0 };
let left = if !s.pos_offset[3].is_auto { s.pos_offset[3].to_px(pl.w) } else { 0.0 };
let right = if !s.pos_offset[1].is_auto { s.pos_offset[1].to_px(pl.w) } else { 0.0 };
let bottom = if !s.pos_offset[2].is_auto { s.pos_offset[2].to_px(pl.h) } else { 0.0 };
let ax = if !s.pos_offset[3].is_auto {
pl.x + left
} else if !s.pos_offset[1].is_auto {
pl.x + pl.w - out[idx].w - right
} else {
x + m[3]
};
let ay = if !s.pos_offset[0].is_auto {
pl.y + top
} else if !s.pos_offset[2].is_auto {
pl.y + pl.h - out[idx].h - bottom
} else {
y + m[0]
};
(ax, ay)
} else {
(x + m[3], y + m[0])
}
}
Position::Relative => {
let top = s.pos_offset[0].to_px(out[idx].h);
let left = s.pos_offset[3].to_px(out[idx].w);
(x + m[3] + left, y + m[0] + top)
}
Position::Static => (x + m[3], y + m[0])
};
out[idx].x = final_x;
out[idx].y = final_y;
let l = out[idx];
let p = [s.padding[0].to_px(l.h), s.padding[1].to_px(l.w), s.padding[2].to_px(l.h), s.padding[3].to_px(l.w)];
let (cw, ch) = (l.w - p[1] - p[3], l.h - p[0] - p[2]);
let node = &nodes[idx];
let g = s.gap.to_px(l.w);
let mut normal_kids: Vec<_> = vec![];
let mut absolute_kids: Vec<_> = vec![];
for &c in &node.children {
let cs = &styles[c];
let cm = [cs.margin[0].to_px(cw), cs.margin[1].to_px(cw), cs.margin[2].to_px(cw), cs.margin[3].to_px(cw)];
let w_with_m = out[c].w + cm[1] + cm[3];
let h_with_m = out[c].h + cm[0] + cm[2];
if cs.position == Position::Absolute {
absolute_kids.push((c, w_with_m, h_with_m, cs.flex_grow, cs.flex_shrink, cs.grid_col, cs.grid_row));
} else {
normal_kids.push((c, w_with_m, h_with_m, cs.flex_grow, cs.flex_shrink, cs.grid_col, cs.grid_row));
}
}
if s.display == Display::Grid && !normal_kids.is_empty() {
let col_tracks = compute_grid_tracks(&s.grid_cols, cw, g);
let row_tracks = compute_grid_tracks(&s.grid_rows, ch, g);
let mut grid_cursor_col = 0;
let mut grid_cursor_row = 0;
for (c, _kw, _kh, _, _, grid_col, grid_row) in &normal_kids {
let cs = &styles[*c];
let cm = [cs.margin[0].to_px(cw), cs.margin[1].to_px(cw), cs.margin[2].to_px(cw), cs.margin[3].to_px(cw)];
let (col_start, col_span) = if grid_col.0 > 0 { (grid_col.0 as usize - 1, grid_col.1.max(1) as usize) } else { (grid_cursor_col, 1) };
let (row_start, row_span) = if grid_row.0 > 0 { (grid_row.0 as usize - 1, grid_row.1.max(1) as usize) } else { (grid_cursor_row, 1) };
let cx = l.x + p[3] + col_tracks.iter().take(col_start).map(|(_, pos)| pos).sum::<f32>() + cm[3];
let cy = l.y + p[0] + row_tracks.iter().take(row_start).map(|(_, pos)| pos).sum::<f32>() + cm[0];
let cell_w: f32 = col_tracks.iter().skip(col_start).take(col_span).map(|(size, _)| size).sum::<f32>()
+ g * (col_span.saturating_sub(1)) as f32 - cm[1] - cm[3];
let cell_h: f32 = row_tracks.iter().skip(row_start).take(row_span).map(|(size, _)| size).sum::<f32>()
+ g * (row_span.saturating_sub(1)) as f32 - cm[0] - cm[2];
out[*c].w = cell_w.max(0.0);
out[*c].h = cell_h.max(0.0);
position(nodes, styles, *c, cx - cm[3], cy - cm[0], Some(&l), out);
grid_cursor_col = col_start + col_span;
if grid_cursor_col >= col_tracks.len().max(1) {
grid_cursor_col = 0;
grid_cursor_row += 1;
}
}
} else if s.display == Display::Flex && !normal_kids.is_empty() {
let kids = &normal_kids;
let tot: f32 = if s.flex_dir == FlexDir::Row {
kids.iter().map(|(_, w, _, _, _, _, _)| w).sum()
} else {
kids.iter().map(|(_, _, h, _, _, _, _)| h).sum()
};
let gaps = g * kids.len().saturating_sub(1) as f32;
let main = if s.flex_dir == FlexDir::Row { cw } else { ch };
let rem = main - tot - gaps;
let total_grow: f32 = kids.iter().map(|(_, _, _, fg, _, _, _)| fg).sum();
let total_shrink: f32 = kids.iter().map(|(_, _, _, _, fs, _, _)| fs).sum();
let adjusted_sizes: Vec<f32> = kids.iter().map(|(_, w, h, fg, fs, _, _)| {
let base = if s.flex_dir == FlexDir::Row { *w } else { *h };
if rem > 0.0 && total_grow > 0.0 {
base + (rem * fg / total_grow)
} else if rem < 0.0 && total_shrink > 0.0 {
(base + rem * fs / total_shrink).max(0.0)
} else {
base
}
}).collect();
let (mut pos, extra) = if total_grow > 0.0 || rem < 0.0 {
(0.0, 0.0)
} else {
match s.justify {
Justify::Start => (0.0, 0.0),
Justify::End => (rem.max(0.0), 0.0),
Justify::Center => (rem.max(0.0) / 2.0, 0.0),
Justify::Between if kids.len() > 1 => (0.0, rem.max(0.0) / (kids.len() - 1) as f32),
Justify::Between => (0.0, 0.0),
Justify::Around => (rem.max(0.0) / kids.len() as f32 / 2.0, rem.max(0.0) / kids.len() as f32),
}
};
for (i, (c, _kw, kh, _, _, _, _)) in kids.iter().enumerate() {
let cs = &styles[*c];
let cm = [cs.margin[0].to_px(cw), cs.margin[1].to_px(cw), cs.margin[2].to_px(cw), cs.margin[3].to_px(cw)];
let adj_size = adjusted_sizes[i];
let (cx, cy) = if s.flex_dir == FlexDir::Row {
let cr = match s.align {
Align::Start => 0.0,
Align::End => ch - kh,
Align::Center => (ch - kh) / 2.0,
Align::Stretch => 0.0
};
(l.x + p[3] + pos + cm[3], l.y + p[0] + cr + cm[0])
} else {
let kw = out[*c].w + cm[1] + cm[3];
let cr = match s.align {
Align::Start => 0.0,
Align::End => cw - kw,
Align::Center => (cw - kw) / 2.0,
Align::Stretch => 0.0
};
(l.x + p[3] + cr + cm[3], l.y + p[0] + pos + cm[0])
};
if total_grow > 0.0 && rem > 0.0 {
if s.flex_dir == FlexDir::Row {
out[*c].w = adj_size - cm[1] - cm[3];
} else {
out[*c].h = adj_size - cm[0] - cm[2];
}
}
position(nodes, styles, *c, cx - cm[3], cy - cm[0], Some(&l), out);
pos += adj_size + g + extra;
}
} else if !normal_kids.is_empty() {
let mut cur_y = l.y + p[0];
for (c, _kw, kh, _, _, _, _) in normal_kids {
position(nodes, styles, c, l.x + p[3], cur_y, Some(&l), out);
cur_y += kh;
}
}
for (c, _, _, _, _, _, _) in absolute_kids {
position(nodes, styles, c, l.x, l.y, Some(&l), out);
}
}
fn compute_grid_tracks(tracks: &Vec4, available: f32, gap: f32) -> Vec<(f32, f32)> {
if tracks.count == 0 {
return vec![(available, available + gap)];
}
let mut result = Vec::with_capacity(tracks.count);
let mut total_fr = 0.0;
let mut fixed_size = 0.0;
for i in 0..tracks.count {
let v = tracks.values[i];
if v < 0.0 {
total_fr += -v; } else {
fixed_size += v;
}
}
let gap_total = gap * (tracks.count.saturating_sub(1)) as f32;
let fr_space = (available - fixed_size - gap_total).max(0.0);
let fr_unit = if total_fr > 0.0 { fr_space / total_fr } else { 0.0 };
let mut pos = 0.0;
for i in 0..tracks.count {
let v = tracks.values[i];
let size = if v < 0.0 { -v * fr_unit } else { v };
result.push((size, size + gap));
pos += size + gap;
}
result
}
#[derive(Debug, Clone, Default)]
pub struct XrTransform {
pub translate: [f32; 3], pub rotate: [f32; 3], pub scale: [f32; 3], }
#[derive(Debug, Clone)]
pub struct XrKeyframe {
pub percent: f32,
pub opacity: Option<f32>,
pub transform: Option<XrTransform>,
pub bg: Option<[f32; 4]>,
}
#[derive(Debug, Clone)]
pub struct XrAnimation {
pub name: String,
pub duration: f32,
pub delay: f32,
pub iteration: f32, pub direction: String, pub timing: String, pub keyframes: Vec<XrKeyframe>,
}
#[derive(Debug, Clone, Default)]
pub struct XrEvents {
pub click: Option<String>, pub hover: Option<String>, pub pointer_down: Option<String>,
pub pointer_up: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct XrHitBox {
pub x: f32, pub y: f32, pub z: f32, pub w: f32, pub h: f32, pub d: f32, }
#[derive(Debug, Clone)]
pub struct XrElement {
pub id: usize,
pub x: f32, pub y: f32, pub w: f32, pub h: f32,
pub bg: [f32; 4], pub color: [f32; 4],
pub opacity: f32, pub radius: f32, pub font_size: f32,
pub text: Option<String>,
pub transform: XrTransform,
pub animation: Option<XrAnimation>,
pub events: XrEvents,
pub interactive: bool, pub cursor: String, }
pub fn process(html: &str, css: &str, vw: f32, vh: f32) -> Vec<XrElement> {
let nodes = parse_html(html);
if nodes.is_empty() { return vec![]; }
let (rules, keyframe_anims) = parse_css(css);
let mut styles: Vec<Style> = nodes.iter().map(|_| Style {
opacity: 1.0,
font_size: 16.0,
transform: Transform::identity(),
..Default::default()
}).collect();
let mut style_exts: Vec<StyleExt> = nodes.iter().map(|_| StyleExt::default()).collect();
for (i, node) in nodes.iter().enumerate() {
let mut m: Vec<_> = rules.iter().filter(|r| matches(&r.sel, node)).collect();
m.sort_by_key(|r| r.spec);
for r in m {
for (p, v) in &r.decls {
styles[i].apply(p, v);
if p == "animation" || p == "animation-name" {
style_exts[i].animation = Some(parse_animation_shorthand(v));
}
}
}
for (p, v) in &node.inline {
styles[i].apply(p, v);
if p == "animation" || p == "animation-name" {
style_exts[i].animation = Some(parse_animation_shorthand(v));
}
}
}
let mut layouts = vec![Layout::default(); nodes.len()];
let mut is_child = vec![false; nodes.len()];
for node in &nodes {
for &c in &node.children {
is_child[c] = true;
}
}
let mut cur_y = 0.0;
for i in 0..nodes.len() {
if !is_child[i] {
compute(&nodes, &styles, i, vw, vh, &mut layouts);
position(&nodes, &styles, i, 0.0, cur_y, None, &mut layouts);
cur_y += layouts[i].h;
}
}
nodes.iter().enumerate().filter_map(|(i, node)| {
let s = &styles[i];
let l = &layouts[i];
let ext = &style_exts[i];
if s.display == Display::None || (s.bg.a <= 0.0 && node.text.is_none()) { return None; }
let animation = ext.animation.as_ref().and_then(|anim| {
let kf_anim = keyframe_anims.iter().find(|k| k.name == anim.name)?;
let keyframes: Vec<XrKeyframe> = kf_anim.keyframes.iter().map(|kf| {
let mut opacity = None;
let mut transform = None;
let mut bg = None;
for (prop, val) in &kf.props {
match prop.as_str() {
"opacity" => opacity = val.parse().ok(),
"transform" => {
let t = Style::parse_transform(val);
transform = Some(XrTransform {
translate: [t.translate_x, t.translate_y, t.translate_z],
rotate: [t.rotate_x, t.rotate_y, t.rotate_z],
scale: [t.scale_x, t.scale_y, t.scale_z],
});
}
"background" | "background-color" => {
let c = Color::parse(val);
bg = Some(c.to_gl());
}
_ => {}
}
}
XrKeyframe { percent: kf.percent, opacity, transform, bg }
}).collect();
Some(XrAnimation {
name: anim.name.clone(),
duration: anim.duration,
delay: anim.delay,
iteration: anim.iteration,
direction: match anim.direction {
AnimDirection::Normal => "normal",
AnimDirection::Reverse => "reverse",
AnimDirection::Alternate => "alternate",
AnimDirection::AlternateReverse => "alternate-reverse",
}.into(),
timing: match anim.timing {
TimingFn::Linear => "linear",
TimingFn::Ease => "ease",
TimingFn::EaseIn => "ease-in",
TimingFn::EaseOut => "ease-out",
TimingFn::EaseInOut => "ease-in-out",
}.into(),
keyframes,
})
});
let has_events = node.events.onclick.is_some()
|| node.events.onmouseenter.is_some()
|| node.events.onpointerdown.is_some();
let events = XrEvents {
click: node.events.onclick.clone(),
hover: node.events.onmouseenter.clone(),
pointer_down: node.events.onpointerdown.clone(),
pointer_up: node.events.onpointerup.clone(),
};
let cursor_str = match s.cursor {
Cursor::Pointer => "pointer",
Cursor::Move => "move",
Cursor::Text => "text",
Cursor::NotAllowed => "not-allowed",
Cursor::Default => "default",
}.to_string();
Some(XrElement {
id: i, x: l.x, y: l.y, w: l.w, h: l.h,
bg: s.bg.to_gl(), color: s.color.to_gl(),
opacity: s.opacity, radius: s.radius, font_size: s.font_size,
text: node.text.clone(),
transform: XrTransform {
translate: [s.transform.translate_x, s.transform.translate_y, s.transform.translate_z],
rotate: [s.transform.rotate_x, s.transform.rotate_y, s.transform.rotate_z],
scale: [s.transform.scale_x, s.transform.scale_y, s.transform.scale_z],
},
animation,
events,
interactive: has_events || s.cursor == Cursor::Pointer,
cursor: cursor_str,
})
}).collect()
}
fn parse_animation_shorthand(v: &str) -> Animation {
let mut anim = Animation::default();
let parts: Vec<&str> = v.split_whitespace().collect();
for (i, part) in parts.iter().enumerate() {
if i == 0 {
anim.name = part.to_string();
} else if part.ends_with('s') || part.ends_with("ms") {
let val = if part.ends_with("ms") {
part.trim_end_matches("ms").parse::<f32>().unwrap_or(0.0) / 1000.0
} else {
part.trim_end_matches('s').parse::<f32>().unwrap_or(0.0)
};
if anim.duration == 0.0 {
anim.duration = val;
} else {
anim.delay = val;
}
} else if *part == "infinite" {
anim.iteration = f32::INFINITY;
} else if let Ok(n) = part.parse::<f32>() {
anim.iteration = n;
} else {
match *part {
"linear" => anim.timing = TimingFn::Linear,
"ease" => anim.timing = TimingFn::Ease,
"ease-in" => anim.timing = TimingFn::EaseIn,
"ease-out" => anim.timing = TimingFn::EaseOut,
"ease-in-out" => anim.timing = TimingFn::EaseInOut,
"reverse" => anim.direction = AnimDirection::Reverse,
"alternate" => anim.direction = AnimDirection::Alternate,
"alternate-reverse" => anim.direction = AnimDirection::AlternateReverse,
"forwards" => anim.fill = FillMode::Forwards,
"backwards" => anim.fill = FillMode::Backwards,
"both" => anim.fill = FillMode::Both,
_ => {}
}
}
}
if anim.iteration == 0.0 { anim.iteration = 1.0; }
anim
}
pub fn to_json(elements: &[XrElement]) -> String {
let mut o = String::from("[");
for (i, e) in elements.iter().enumerate() {
if i > 0 { o.push(','); }
let transform_str = format!(
r#","transform":{{"translate":[{:.1},{:.1},{:.1}],"rotate":[{:.1},{:.1},{:.1}],"scale":[{:.2},{:.2},{:.2}]}}"#,
e.transform.translate[0], e.transform.translate[1], e.transform.translate[2],
e.transform.rotate[0], e.transform.rotate[1], e.transform.rotate[2],
e.transform.scale[0], e.transform.scale[1], e.transform.scale[2]
);
let anim_str = e.animation.as_ref().map(|a| {
let keyframes_str: Vec<String> = a.keyframes.iter().map(|kf| {
let mut props = vec![format!(r#""percent":{:.2}"#, kf.percent)];
if let Some(op) = kf.opacity {
props.push(format!(r#""opacity":{:.2}"#, op));
}
if let Some(ref t) = kf.transform {
props.push(format!(
r#""transform":{{"translate":[{:.1},{:.1},{:.1}],"rotate":[{:.1},{:.1},{:.1}],"scale":[{:.2},{:.2},{:.2}]}}"#,
t.translate[0], t.translate[1], t.translate[2],
t.rotate[0], t.rotate[1], t.rotate[2],
t.scale[0], t.scale[1], t.scale[2]
));
}
if let Some(ref bg) = kf.bg {
props.push(format!(r#""bg":[{:.3},{:.3},{:.3},{:.3}]"#, bg[0], bg[1], bg[2], bg[3]));
}
format!("{{{}}}", props.join(","))
}).collect();
format!(
r#","animation":{{"name":"{}","duration":{:.2},"delay":{:.2},"iteration":{},"direction":"{}","timing":"{}","keyframes":[{}]}}"#,
a.name, a.duration, a.delay,
if a.iteration.is_infinite() { "\"infinite\"".to_string() } else { format!("{:.1}", a.iteration) },
a.direction, a.timing,
keyframes_str.join(",")
)
}).unwrap_or_default();
o.push_str(&format!(
r#"{{"id":{},"x":{:.1},"y":{:.1},"w":{:.1},"h":{:.1},"bg":[{:.3},{:.3},{:.3},{:.3}],"color":[{:.3},{:.3},{:.3},{:.3}],"opacity":{:.2},"radius":{:.1},"fontSize":{:.1}{}{}{}"#,
e.id, e.x, e.y, e.w, e.h, e.bg[0], e.bg[1], e.bg[2], e.bg[3],
e.color[0], e.color[1], e.color[2], e.color[3], e.opacity, e.radius, e.font_size,
e.text.as_ref().map(|t| format!(r#","text":"{}""#, t.replace('"', "\\\""))).unwrap_or_default(),
transform_str,
anim_str
));
if e.interactive {
o.push_str(&format!(r#","interactive":true,"cursor":"{}""#, e.cursor));
let mut events_parts = vec![];
if let Some(ref c) = e.events.click {
events_parts.push(format!(r#""click":"{}""#, c.replace('"', "\\\"")));
}
if let Some(ref h) = e.events.hover {
events_parts.push(format!(r#""hover":"{}""#, h.replace('"', "\\\"")));
}
if let Some(ref pd) = e.events.pointer_down {
events_parts.push(format!(r#""pointerDown":"{}""#, pd.replace('"', "\\\"")));
}
if let Some(ref pu) = e.events.pointer_up {
events_parts.push(format!(r#""pointerUp":"{}""#, pu.replace('"', "\\\"")));
}
if !events_parts.is_empty() {
o.push_str(&format!(r#","events":{{{}}}"#, events_parts.join(",")));
}
}
o.push('}');
}
o.push(']'); o
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic() {
let html = r#"<div class="box">Hello</div>"#;
let css = r#".box { background: red; width: 100px; height: 50px; }"#;
let els = process(html, css, 200.0, 200.0);
assert_eq!(els.len(), 1);
assert_eq!(els[0].w, 100.0);
assert_eq!(els[0].h, 50.0);
}
#[test]
fn test_flex_column() {
let html = r#"<div class="outer"><div class="inner">A</div><div class="inner">B</div></div>"#;
let css = r#".outer { display: flex; flex-direction: column; width: 200px; background: red; padding: 10px; } .inner { height: 30px; background: blue; }"#;
let els = process(html, css, 400.0, 400.0);
println!("Elements: {:?}", els.len());
for e in &els {
println!(" id={} w={} h={} bg={:?}", e.id, e.w, e.h, e.bg);
}
let outer = els.iter().find(|e| e.id == 0).unwrap();
assert!(outer.h >= 80.0, "outer height {} should be >= 80", outer.h);
}
#[test]
fn test_nested_flex() {
let html = r#"<div class="col"><div class="row"><div class="box">A</div></div><div class="row"><div class="box">B</div></div></div>"#;
let css = r#".col { display: flex; flex-direction: column; gap: 10px; background: gray; padding: 10px; } .row { display: flex; background: blue; } .box { width: 50px; height: 50px; background: red; }"#;
let els = process(html, css, 400.0, 400.0);
let rows: Vec<_> = els.iter().filter(|e| e.bg[2] > 0.9).collect(); if rows.len() >= 2 {
assert!(rows[0].y != rows[1].y, "rows should have different Y positions");
}
}
#[test]
fn test_color() {
let c = Color::parse("#ff0000");
assert_eq!(c.r, 255);
assert_eq!(c.g, 0);
let c2 = Color::parse("rgba(100, 150, 200, 0.5)");
assert_eq!(c2.r, 100);
assert!((c2.a - 0.5).abs() < 0.01);
}
#[test]
fn test_margin() {
let html = r#"<div class="outer"><div class="inner">A</div></div>"#;
let css = r#".outer { width: 200px; height: 100px; background: red; } .inner { margin: 10px; width: 50px; height: 30px; background: blue; }"#;
let els = process(html, css, 300.0, 200.0);
let inner = els.iter().find(|e| e.id == 1).unwrap();
assert_eq!(inner.x, 10.0, "inner x should be 10 (margin-left)");
assert_eq!(inner.y, 10.0, "inner y should be 10 (margin-top)");
}
#[test]
fn test_flex_grow() {
let html = r#"<div class="row"><div class="a">A</div><div class="b">B</div></div>"#;
let css = r#".row { display: flex; width: 300px; background: gray; } .a { flex-grow: 1; height: 30px; background: red; } .b { width: 100px; height: 30px; background: blue; }"#;
let els = process(html, css, 400.0, 200.0);
let a = els.iter().find(|e| e.text.as_deref() == Some("A")).unwrap();
let b = els.iter().find(|e| e.text.as_deref() == Some("B")).unwrap();
assert!(a.w >= 190.0, "A width {} should be ~200 (flex-grow)", a.w);
assert_eq!(b.w, 100.0, "B width should be 100");
}
#[test]
fn test_multi_class_selector() {
let node = Node {
tag: "div".into(),
classes: vec!["box".into(), "red".into(), "large".into()],
id: None,
attrs: vec![],
inline: vec![],
children: vec![],
text: Some("C".into()),
events: EventHandlers::default(),
};
assert!(matches(".box", &node), ".box should match");
assert!(matches(".box.red", &node), ".box.red should match");
assert!(matches(".box.red.large", &node), ".box.red.large should match");
assert!(!matches(".blue", &node), ".blue should NOT match");
let html = r#"<div class="box red">A</div><div class="box blue">B</div><div class="box red large">C</div>"#;
let css = r#".box { width: 50px; height: 50px; } .box.red { background: red; } .box.blue { background: blue; } .box.red.large { width: 100px; }"#;
let els = process(html, css, 400.0, 200.0);
let a = els.iter().find(|e| e.text.as_deref() == Some("A")).unwrap();
let b = els.iter().find(|e| e.text.as_deref() == Some("B")).unwrap();
let c = els.iter().find(|e| e.text.as_deref() == Some("C")).unwrap();
assert!(a.bg[0] > 0.9, "A should be red");
assert_eq!(a.w, 50.0, "A width should be 50");
assert!(b.bg[2] > 0.9, "B should be blue");
assert_eq!(b.w, 50.0, "B width should be 50");
assert_eq!(c.w, 100.0, "C width should be 100");
}
#[test]
fn test_attribute_selector() {
let html = r#"<div data-xr="panel">A</div><div>B</div><div data-xr="button">C</div>"#;
let css = r#"[data-xr] { background: green; width: 100px; height: 50px; } [data-xr="button"] { background: blue; }"#;
let els = process(html, css, 400.0, 200.0);
let a = els.iter().find(|e| e.text.as_deref() == Some("A")).unwrap();
let c = els.iter().find(|e| e.text.as_deref() == Some("C")).unwrap();
assert!(a.bg[1] > 0.4, "A should be green");
assert_eq!(a.w, 100.0, "A width should be 100");
assert!(c.bg[2] > 0.9, "C should be blue");
}
#[test]
fn test_css_grid() {
let html = r#"<div class="grid"><div class="a">A</div><div class="b">B</div><div class="c">C</div><div class="d">D</div></div>"#;
let css = r#".grid { display: grid; grid-template-columns: 100px 100px; grid-template-rows: 50px 50px; gap: 10px; width: 210px; height: 110px; background: gray; } .a, .b, .c, .d { background: blue; }"#;
let els = process(html, css, 400.0, 300.0);
let a = els.iter().find(|e| e.text.as_deref() == Some("A")).unwrap();
let b = els.iter().find(|e| e.text.as_deref() == Some("B")).unwrap();
let c = els.iter().find(|e| e.text.as_deref() == Some("C")).unwrap();
let d = els.iter().find(|e| e.text.as_deref() == Some("D")).unwrap();
assert_eq!(a.w, 100.0, "A width should be 100");
assert_eq!(a.h, 50.0, "A height should be 50");
assert!(b.x > a.x, "B should be right of A");
assert!(c.y > a.y, "C should be below A");
assert!(d.x > c.x && d.y > b.y, "D should be at (1,1)");
}
#[test]
fn test_position_absolute() {
let html = r#"<div class="container"><div class="box">A</div><div class="abs">B</div></div>"#;
let css = r#".container { position: relative; width: 200px; height: 150px; background: gray; } .box { width: 50px; height: 50px; background: blue; } .abs { position: absolute; top: 10px; right: 10px; width: 40px; height: 40px; background: red; }"#;
let els = process(html, css, 400.0, 300.0);
let container = els.iter().find(|e| e.id == 0).unwrap();
let abs = els.iter().find(|e| e.text.as_deref() == Some("B")).unwrap();
let expected_x = container.x + container.w - 40.0 - 10.0; let expected_y = container.y + 10.0;
assert!((abs.x - expected_x).abs() < 1.0, "abs x={} should be near {}", abs.x, expected_x);
assert!((abs.y - expected_y).abs() < 1.0, "abs y={} should be near {}", abs.y, expected_y);
}
#[test]
fn test_position_relative() {
let html = r#"<div class="box">A</div>"#;
let css = r#".box { position: relative; top: 20px; left: 30px; width: 50px; height: 50px; background: blue; }"#;
let els = process(html, css, 400.0, 300.0);
let a = &els[0];
assert_eq!(a.x, 30.0, "x should be 30 (left offset)");
assert_eq!(a.y, 20.0, "y should be 20 (top offset)");
}
#[test]
fn test_transform() {
let html = r#"<div class="box">A</div>"#;
let css = r#".box { width: 50px; height: 50px; background: blue; transform: translateX(10px) translateY(20px) rotate(45deg) scale(1.5); }"#;
let els = process(html, css, 400.0, 300.0);
let a = &els[0];
assert_eq!(a.transform.translate[0], 10.0, "translateX should be 10");
assert_eq!(a.transform.translate[1], 20.0, "translateY should be 20");
assert_eq!(a.transform.rotate[2], 45.0, "rotateZ should be 45");
assert_eq!(a.transform.scale[0], 1.5, "scaleX should be 1.5");
assert_eq!(a.transform.scale[1], 1.5, "scaleY should be 1.5");
}
#[test]
fn test_animation() {
let html = r#"<div class="box">A</div>"#;
let css = r#"
@keyframes fadeIn {
0% { opacity: 0; }
100% { opacity: 1; }
}
.box {
width: 50px; height: 50px; background: blue;
animation: fadeIn 2s ease-in-out infinite;
}
"#;
let els = process(html, css, 400.0, 300.0);
let a = &els[0];
assert!(a.animation.is_some(), "should have animation");
let anim = a.animation.as_ref().unwrap();
assert_eq!(anim.name, "fadeIn");
assert_eq!(anim.duration, 2.0);
assert!(anim.iteration.is_infinite(), "iteration should be infinite");
assert_eq!(anim.timing, "ease-in-out");
assert_eq!(anim.keyframes.len(), 2);
assert_eq!(anim.keyframes[0].percent, 0.0);
assert_eq!(anim.keyframes[0].opacity, Some(0.0));
assert_eq!(anim.keyframes[1].percent, 1.0);
assert_eq!(anim.keyframes[1].opacity, Some(1.0));
}
#[test]
fn test_events() {
let html = r#"<div class="btn" onclick="handleClick()">Click me</div>"#;
let css = r#".btn { width: 100px; height: 40px; background: blue; cursor: pointer; }"#;
let els = process(html, css, 400.0, 300.0);
let btn = &els[0];
assert!(btn.interactive, "button should be interactive");
assert_eq!(btn.cursor, "pointer");
assert_eq!(btn.events.click, Some("handleClick()".to_string()));
}
#[test]
fn test_data_action() {
let html = r#"<div class="btn" data-action="navigate('home')">Home</div>"#;
let css = r#".btn { width: 100px; height: 40px; background: green; }"#;
let els = process(html, css, 400.0, 300.0);
let btn = &els[0];
assert!(btn.interactive, "button should be interactive");
assert_eq!(btn.events.click, Some("navigate('home')".to_string()));
}
}