use chromiumoxide::Page;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tracing::{info, warn};
use crate::error::Result;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComputedStyles {
pub node_id: i64,
pub selector: Option<String>,
pub html_snippet: Option<String>,
pub properties: HashMap<String, String>,
}
impl ComputedStyles {
pub fn get(&self, property: &str) -> Option<&str> {
self.properties.get(property).map(|s| s.as_str())
}
pub fn color(&self) -> Option<&str> {
self.get("color")
}
pub fn background_color(&self) -> Option<&str> {
self.get("background-color")
}
pub fn font_size(&self) -> Option<&str> {
self.get("font-size")
}
pub fn font_weight(&self) -> Option<&str> {
self.get("font-weight")
}
pub fn is_bold(&self) -> bool {
if let Some(weight) = self.font_weight() {
if let Ok(weight_num) = weight.parse::<u32>() {
return weight_num >= 700;
}
return matches!(weight, "bold" | "bolder" | "700" | "800" | "900");
}
false
}
pub fn is_large_text(&self) -> bool {
if let Some(size_str) = self.font_size() {
if let Some(size_px) = parse_font_size_px(size_str) {
if self.is_bold() {
return size_px >= 18.66;
} else {
return size_px >= 24.0;
}
}
}
false
}
}
const STYLES_EXTRACT_JS: &str = r#"
function getEffectiveBackgroundColor(el) {
let current = el;
while (current && current !== document.documentElement) {
const bg = window.getComputedStyle(current).backgroundColor;
if (bg && bg !== 'transparent' && bg !== 'rgba(0, 0, 0, 0)') {
const match = bg.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
if (match) {
const alpha = match[4] !== undefined ? parseFloat(match[4]) : 1;
if (alpha > 0) return bg;
} else {
return bg;
}
}
current = current.parentElement;
}
const htmlBg = window.getComputedStyle(document.documentElement).backgroundColor;
if (htmlBg && htmlBg !== 'transparent' && htmlBg !== 'rgba(0, 0, 0, 0)') return htmlBg;
return 'rgb(255, 255, 255)';
}
const selectors = ['p', 'span', 'div', 'a', 'button', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'td', 'th', 'label', 'strong', 'em'];
const results = [];
const seen = new Set();
selectors.forEach(selector => {
const elements = document.querySelectorAll(selector);
elements.forEach((el, idx) => {
if (seen.has(el)) return;
seen.add(el);
// Skip elements hidden from assistive technology (self or ancestor)
if (el.closest('[aria-hidden="true"]') !== null) return;
// Skip visually-hidden / .sr-only text — WCAG 1.4.3 does not apply
if (__amsIsVisuallyHidden(el)) return;
let hasDirectText = false;
for (const child of el.childNodes) {
if (child.nodeType === 3 && child.textContent.trim().length > 0) {
hasDirectText = true;
break;
}
}
if (!hasDirectText) return;
const styles = window.getComputedStyle(el);
if (styles.display === 'none' || styles.visibility === 'hidden') return;
results.push({
cssPath: __amsCssSelector(el),
snippet: el.outerHTML.substring(0, 200),
index: idx,
color: styles.color,
backgroundColor: getEffectiveBackgroundColor(el),
fontSize: styles.fontSize,
fontWeight: styles.fontWeight,
visibility: styles.visibility,
display: styles.display
});
});
});
return results;
"#;
pub async fn extract_text_styles(page: &Page) -> Result<Vec<ComputedStyles>> {
info!("Extracting computed styles via JavaScript...");
let js_code = [
"(() => {",
crate::accessibility::js_helpers::CSS_SELECTOR_JS,
crate::accessibility::js_helpers::IS_VISUALLY_HIDDEN_JS,
STYLES_EXTRACT_JS,
"})();",
]
.concat();
let eval_result = page.evaluate(js_code.as_str()).await;
let styles_vec: Vec<ComputedStyles> = match eval_result {
Ok(result) => {
if let Some(value) = result.value() {
match serde_json::from_value::<Vec<serde_json::Value>>(value.clone()) {
Ok(items) => {
let parsed: Vec<ComputedStyles> = items
.iter()
.enumerate()
.map(|(idx, item)| {
let mut properties = HashMap::new();
if let Some(color) = item.get("color").and_then(|v| v.as_str()) {
properties.insert("color".to_string(), color.to_string());
}
if let Some(bg) =
item.get("backgroundColor").and_then(|v| v.as_str())
{
properties
.insert("background-color".to_string(), bg.to_string());
}
if let Some(size) = item.get("fontSize").and_then(|v| v.as_str()) {
properties.insert("font-size".to_string(), size.to_string());
}
if let Some(weight) =
item.get("fontWeight").and_then(|v| v.as_str())
{
properties
.insert("font-weight".to_string(), weight.to_string());
}
if let Some(vis) = item.get("visibility").and_then(|v| v.as_str()) {
properties.insert("visibility".to_string(), vis.to_string());
}
if let Some(disp) = item.get("display").and_then(|v| v.as_str()) {
properties.insert("display".to_string(), disp.to_string());
}
let selector = item
.get("cssPath")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from);
let html_snippet = item
.get("snippet")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from);
ComputedStyles {
node_id: idx as i64,
selector,
html_snippet,
properties,
}
})
.collect();
info!("Parsed {} style objects from JavaScript", parsed.len());
parsed
}
Err(e) => {
warn!("Failed to parse styles JSON: {}", e);
Vec::new()
}
}
} else {
warn!("No value returned from JavaScript evaluation");
Vec::new()
}
}
Err(e) => {
warn!("Failed to evaluate JavaScript for styles: {}", e);
Vec::new()
}
};
info!(
"Extracted computed styles for {} elements",
styles_vec.len()
);
Ok(styles_vec)
}
fn parse_font_size_px(size_str: &str) -> Option<f64> {
let size_str = size_str.trim();
if size_str.ends_with("px") {
size_str.trim_end_matches("px").parse::<f64>().ok()
} else if size_str.ends_with("pt") {
size_str
.trim_end_matches("pt")
.parse::<f64>()
.ok()
.map(|pt| pt * 1.333)
} else if size_str.ends_with("rem") {
size_str
.trim_end_matches("rem")
.parse::<f64>()
.ok()
.map(|em| em * 16.0)
} else if size_str.ends_with("em") {
size_str
.trim_end_matches("em")
.parse::<f64>()
.ok()
.map(|em| em * 16.0)
} else if size_str.ends_with('%') {
size_str
.trim_end_matches('%')
.parse::<f64>()
.ok()
.map(|pct| (pct / 100.0) * 16.0)
} else {
size_str.parse::<f64>().ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_font_size_px() {
assert_eq!(parse_font_size_px("16px"), Some(16.0));
assert_eq!(parse_font_size_px("24px"), Some(24.0));
}
#[test]
fn test_parse_font_size_pt() {
let size = parse_font_size_px("18pt").unwrap();
assert!((size - 24.0).abs() < 0.1); }
#[test]
fn test_parse_font_size_em() {
assert_eq!(parse_font_size_px("1.5em"), Some(24.0)); assert_eq!(parse_font_size_px("1rem"), Some(16.0));
}
#[test]
fn test_parse_font_size_percent() {
assert_eq!(parse_font_size_px("150%"), Some(24.0)); }
#[test]
fn test_is_large_text() {
let mut styles = ComputedStyles {
node_id: 1,
selector: None,
html_snippet: None,
properties: HashMap::new(),
};
styles
.properties
.insert("font-size".to_string(), "24px".to_string());
styles
.properties
.insert("font-weight".to_string(), "400".to_string());
assert!(styles.is_large_text());
styles
.properties
.insert("font-size".to_string(), "20px".to_string());
styles
.properties
.insert("font-weight".to_string(), "700".to_string());
assert!(styles.is_large_text());
styles
.properties
.insert("font-size".to_string(), "16px".to_string());
styles
.properties
.insert("font-weight".to_string(), "400".to_string());
assert!(!styles.is_large_text());
}
#[test]
fn test_is_bold() {
let mut styles = ComputedStyles {
node_id: 1,
selector: None,
html_snippet: None,
properties: HashMap::new(),
};
styles
.properties
.insert("font-weight".to_string(), "700".to_string());
assert!(styles.is_bold());
styles
.properties
.insert("font-weight".to_string(), "bold".to_string());
assert!(styles.is_bold());
styles
.properties
.insert("font-weight".to_string(), "400".to_string());
assert!(!styles.is_bold());
}
}