diff --git a/.isu/issues.json b/.isu/issues.json index 219b277..96e47ce 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -2809,7 +2809,7 @@ ], "assigned": [], "author": "piefev", - "state": "open", + "state": "closed", "created_at": "2026-05-17T18:30:59Z" }, { diff --git a/crates/browser/src/img_loader.rs b/crates/browser/src/img_loader.rs index 86b81b4..f62d0a2 100644 --- a/crates/browser/src/img_loader.rs +++ b/crates/browser/src/img_loader.rs @@ -22,6 +22,7 @@ pub enum ImageFormat { Png, Jpeg, Gif, + Svg, Unknown, } @@ -87,6 +88,9 @@ pub fn detect_format(data: &[u8]) -> ImageFormat { if data.len() >= 6 && &data[..3] == b"GIF" { return ImageFormat::Gif; } + if looks_like_svg(data) { + return ImageFormat::Svg; + } ImageFormat::Unknown } @@ -254,12 +258,52 @@ fn decode_image_data(data: &[u8], url: &str) -> Result { ImageFormat::Png => Ok(decode_png(data)?), ImageFormat::Jpeg => Ok(decode_jpeg(data)?), ImageFormat::Gif => Ok(decode_gif(data)?), + ImageFormat::Svg => decode_svg(data), ImageFormat::Unknown => Err(ImgLoadError::UnknownFormat { url: url.to_string(), }), } } +fn looks_like_svg(data: &[u8]) -> bool { + let len = data.len().min(512); + let Ok(prefix) = std::str::from_utf8(&data[..len]) else { + return false; + }; + let trimmed = prefix.trim_start(); + trimmed.starts_with(" Result { + let text = std::str::from_utf8(data) + .map_err(|e| ImgLoadError::Decode(ImageError::Decode(format!("invalid SVG UTF-8: {e}"))))?; + let doc = we_html::parse_html(text); + let svg = find_svg_node(&doc, doc.root()).ok_or_else(|| { + ImgLoadError::Decode(ImageError::Decode( + "SVG image did not contain an root".to_string(), + )) + })?; + let (width, height, data) = we_svg::render_svg(&doc, svg, None).ok_or_else(|| { + ImgLoadError::Decode(ImageError::Decode( + "SVG image could not be rasterized".to_string(), + )) + })?; + Ok(Image::new(width, height, data)?) +} + +fn find_svg_node(doc: &Document, node: NodeId) -> Option { + if doc.tag_name(node) == Some("svg") { + return Some(node); + } + for child in doc.children(node) { + if let Some(svg) = find_svg_node(doc, child) { + return Some(svg); + } + } + None +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -294,6 +338,18 @@ mod tests { assert_eq!(detect_format(b"GIF89a..."), ImageFormat::Gif); } + #[test] + fn detect_svg() { + assert_eq!( + detect_format(br#" "#), + ImageFormat::Svg + ); + assert_eq!( + detect_format(br#""#), + ImageFormat::Svg + ); + } + #[test] fn detect_unknown_empty() { assert_eq!(detect_format(&[]), ImageFormat::Unknown); @@ -604,6 +660,19 @@ mod tests { assert!(result.is_err()); } + #[test] + fn decode_valid_svg() { + let svg = br#""#; + let image = decode_image_data(svg, "inline.svg").expect("SVG should decode"); + assert_eq!(image.width, 2); + assert_eq!(image.height, 2); + assert_eq!(image.data.len(), 16); + assert!( + image.data.chunks_exact(4).any(|px| px[0] > 0 && px[3] > 0), + "SVG raster should contain visible red pixels" + ); + } + // ----------------------------------------------------------------------- // decode_image_data — valid minimal images // ----------------------------------------------------------------------- diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 66e7d77..da32654 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -116,6 +116,8 @@ pub struct FormControlInfo { pub control_type: FormControlType, /// Display text (input value, button label, or selected option text). pub value: String, + /// Placeholder text for empty editable controls. + pub placeholder: String, /// Whether the control is checked (checkboxes, radio buttons). pub checked: bool, /// Whether the control is disabled. @@ -742,6 +744,7 @@ fn build_form_control_info(node: NodeId, doc: &Document) -> Option Some(FormControlInfo { control_type: FormControlType::Checkbox, value: String::new(), + placeholder: String::new(), checked, disabled, focused, @@ -757,6 +760,7 @@ fn build_form_control_info(node: NodeId, doc: &Document) -> Option Some(FormControlInfo { control_type: FormControlType::Radio, value: String::new(), + placeholder: String::new(), checked, disabled, focused, @@ -777,6 +781,7 @@ fn build_form_control_info(node: NodeId, doc: &Document) -> Option Option Option Option Option Option Option Option String { + std::env::var(USER_AGENT_ENV) + .ok() + .and_then(|ua| { + let ua = ua.trim(); + if ua.is_empty() || ua.contains('\r') || ua.contains('\n') { + None + } else { + Some(ua.to_string()) + } + }) + .unwrap_or_else(|| DEFAULT_USER_AGENT.to_string()) +} // --------------------------------------------------------------------------- // HTTP Method @@ -275,7 +292,8 @@ pub fn serialize_request( // User-Agent if !headers.contains("User-Agent") { buf.extend_from_slice(b"User-Agent: "); - buf.extend_from_slice(USER_AGENT.as_bytes()); + let ua = user_agent(); + buf.extend_from_slice(ua.as_bytes()); buf.extend_from_slice(CRLF.as_bytes()); } @@ -344,7 +362,8 @@ pub fn serialize_request_head_chunked( } if !headers.contains("User-Agent") { buf.extend_from_slice(b"User-Agent: "); - buf.extend_from_slice(USER_AGENT.as_bytes()); + let ua = user_agent(); + buf.extend_from_slice(ua.as_bytes()); buf.extend_from_slice(CRLF.as_bytes()); } if !headers.contains("Accept") { @@ -779,7 +798,7 @@ mod tests { assert!(req_str.starts_with("GET / HTTP/1.1\r\n")); assert!(req_str.contains("Host: example.com\r\n")); - assert!(req_str.contains("User-Agent: we-browser/0.1\r\n")); + assert!(req_str.contains(&format!("User-Agent: {DEFAULT_USER_AGENT}\r\n"))); assert!(req_str.contains("Accept: */*\r\n")); assert!(req_str.contains("Connection: keep-alive\r\n")); assert!(req_str.ends_with("\r\n\r\n")); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 4cb2e28..f0f303f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -983,6 +983,12 @@ const FC_TEXT_COLOR: Color = Color { b: 0, a: 255, }; +const FC_PLACEHOLDER_TEXT: Color = Color { + r: 117, + g: 117, + b: 117, + a: 255, +}; /// Focus ring color (blue outline, matching typical browser defaults). const FC_FOCUS_RING_COLOR: Color = Color { r: 0, @@ -1147,8 +1153,11 @@ fn paint_text_input( } else { FC_BG_COLOR }; + let is_placeholder = fc.value.is_empty() && !fc.placeholder.is_empty(); let text_color = if fc.disabled { FC_DISABLED_TEXT + } else if is_placeholder { + FC_PLACEHOLDER_TEXT } else { FC_TEXT_COLOR }; @@ -1204,7 +1213,9 @@ fn paint_text_input( let content_h = layout_box.rect.height; // For password fields, map byte offsets through the bullet-character mapping. - let display_text = if fc.control_type == FormControlType::Password { + let display_text = if is_placeholder { + fc.placeholder.clone() + } else if fc.control_type == FormControlType::Password { "\u{2022}".repeat(fc.value.chars().count()) } else { fc.value.clone() @@ -4207,6 +4218,52 @@ body { margin: 0; } ); } + #[test] + fn empty_text_input_renders_placeholder() { + let html_str = r#" + +"#; + let doc = we_html::parse_html(html_str); + let tree = layout_doc(&doc); + let list = build_display_list(&tree); + + let placeholder = list.iter().find_map(|c| match c { + PaintCommand::DrawGlyphs { line, .. } if line.text == "you@example.com" => { + Some(line.color) + } + _ => None, + }); + assert!( + matches!(placeholder, Some(FC_PLACEHOLDER_TEXT)), + "empty text input should render placeholder text in placeholder color" + ); + } + + #[test] + fn empty_password_input_renders_placeholder_not_bullets() { + let html_str = r#" + +"#; + let doc = we_html::parse_html(html_str); + let tree = layout_doc(&doc); + let list = build_display_list(&tree); + + let has_placeholder = list + .iter() + .any(|c| matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text == "Password")); + let has_bullets = list.iter().any(|c| { + matches!(c, PaintCommand::DrawGlyphs { line, .. } if line.text.contains('\u{2022}')) + }); + assert!( + has_placeholder, + "empty password input should render placeholder text" + ); + assert!( + !has_bullets, + "empty password input should not render placeholder as bullets" + ); + } + #[test] fn password_input_renders_bullets() { let html_str = r#"