diff --git a/src/base/component/image/component.rs b/src/base/component/image/component.rs index 2142173f..d40b0f5f 100644 --- a/src/base/component/image/component.rs +++ b/src/base/component/image/component.rs @@ -44,18 +44,11 @@ impl Component for Image { fn setup(&mut self, _cx: &mut Context) { self.alter_prop(PropsOp::prepend_classes(match self.source() { - image::Source::Logo(_) => "image image-fluid", - image::Source::Responsive(_) => "image image-fluid", + image::Source::Logo(_) | image::Source::Responsive(_) => "image image-fluid", image::Source::Thumbnail(_) => "image image-thumbnail", image::Source::Plain(_) => "image", })); - // Se asigna un tamaño predefinido para el logotipo que `with_size()` puede sobrescribir. - if matches!(self.source(), image::Source::Logo(_)) { - self.alter_prop(PropsOp::add_style("width", "1.25em")); - self.alter_prop(PropsOp::add_style("height", "1.25em")); - } - // El tamaño se aplica como declaraciones `style` individuales sobre `Props`. match *self.size() { image::Size::Auto => {} @@ -74,6 +67,15 @@ impl Component for Image { self.alter_prop(PropsOp::add_style("height", v.to_string())); } } + + // El logotipo es cuadrado. Sin `width`/`height` toma un tamaño predefinido. + if matches!(self.source(), image::Source::Logo(_)) + && self.props().get_style("width").is_none() + && self.props().get_style("height").is_none() + { + self.alter_prop(PropsOp::add_style("width", "1.25rem")); + self.alter_prop(PropsOp::add_style("height", "1.25rem")); + } } async fn prepare(&self, cx: &mut Context) -> Result { @@ -81,9 +83,9 @@ impl Component for Image { image::Source::Logo(svg) => { return Ok(svg.markup_with(cx, self.props(), self.alternative().clone())); } - image::Source::Responsive(source) => Some(source), - image::Source::Thumbnail(source) => Some(source), - image::Source::Plain(source) => Some(source), + image::Source::Responsive(source) + | image::Source::Thumbnail(source) + | image::Source::Plain(source) => Some(source), }; Ok(html! { img @@ -121,7 +123,7 @@ impl Image { self } - /// Establece el origen de la imagen, influyendo en su disposición en el contenido. + /// Establece el origen de la imagen. pub fn with_source(mut self, source: image::Source) -> Self { self.source = source; self @@ -146,8 +148,8 @@ impl From for Image { impl From for Option { /// Permite pasar un [`image::Source`] directamente donde se espera `impl Into>` - /// (p. ej. [`Brand::with_image()`](super::super::Brand::with_image)), sin construir la - /// [`Image`] a mano. + /// (p. ej. [`Brand::with_image()`](crate::base::component::Brand::with_image)), sin construir + /// la [`Image`] a mano. fn from(source: image::Source) -> Self { Some(Image::with(source)) } diff --git a/src/base/component/image/props.rs b/src/base/component/image/props.rs index d84d6380..6390166b 100644 --- a/src/base/component/image/props.rs +++ b/src/base/component/image/props.rs @@ -7,7 +7,9 @@ use crate::prelude::*; pub enum Size { /// Ajuste automático por defecto. /// - /// La imagen usa su tamaño natural o se ajusta al contenedor donde se publica. + /// La imagen usa su tamaño natural o se ajusta al contenedor donde se publica. El logotipo + /// ([`Source::Logo`]) toma un tamaño predefinido de `1.25rem` de ancho y alto, salvo que se + /// haya fijado alguna de sus dimensiones. #[default] Auto, /// Establece explícitamente el **ancho y alto** de la imagen. @@ -40,6 +42,10 @@ pub enum Size { #[derive(AutoDefault, Clone, Debug, PartialEq)] pub enum Source { /// Imagen con el logotipo de PageTop. + /// + /// Se renderiza como un `` en línea, siempre cuadrado. Sin dimensiones explícitas + /// ([`Size::Auto`]) mide `1.25rem` de ancho y alto; si se fija sólo una, la otra se ajusta + /// proporcionalmente. #[default] Logo(PageTopSvg), /// Imagen que se adapta automáticamente a su contenedor. diff --git a/src/html/logo.rs b/src/html/logo.rs index 61196e5c..55128bd7 100644 --- a/src/html/logo.rs +++ b/src/html/logo.rs @@ -3,6 +3,11 @@ use crate::core::component::Context; use crate::html::{Markup, Props, html}; use crate::locale::Lc; +// Color oscuro del logotipo para `LineDark` y el fondo predeterminado de `Tile`. +const DARK: (u8, u8, u8) = (10, 11, 9); +// Color claro del logotipo para `LineLight` y línea blanca de `Tile`. +const LIGHT: (u8, u8, u8) = (255, 255, 255); + /// Representación SVG del **logotipo de PageTop** para incrustar en HTML. /// /// # Ejemplo @@ -23,10 +28,15 @@ use crate::locale::Lc; /// div class="line_red" { /// (PageTopSvg::LineRGB(255, 0, 0).markup()) /// } +/// div class="tile" { +/// (PageTopSvg::Tile.markup()) +/// } +/// div class="tile_blue" { +/// (PageTopSvg::TileRGB(0, 82, 204).markup()) +/// } /// } /// }; /// ``` - #[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] pub enum PageTopSvg { /// Versión por defecto con el logotipo a color. @@ -38,6 +48,12 @@ pub enum PageTopSvg { LineLight, /// Versión monocroma configurable por RGB. LineRGB(u8, u8, u8), + /// Versión icono de aplicación. Muestra un recuadro de fondo oscuro y esquinas redondeadas + /// (salvo la inferior derecha), con el logotipo ampliado y recortado en línea clara. + Tile, + /// Igual que [`Tile`](Self::Tile), aunque el color del recuadro de fondo se configura por RGB. + /// El logotipo se muestra con línea clara u oscura según su contraste con el fondo. + TileRGB(u8, u8, u8), } impl PageTopSvg { @@ -94,9 +110,11 @@ impl PageTopSvg { fn path_fills(&self) -> Markup { match self { Self::Color => self.logo_color(), - Self::LineDark => self.logo_line(10, 11, 9), - Self::LineLight => self.logo_line(255, 255, 255), + Self::LineDark => self.logo_line(DARK.0, DARK.1, DARK.2), + Self::LineLight => self.logo_line(LIGHT.0, LIGHT.1, LIGHT.2), Self::LineRGB(r, g, b) => self.logo_line(*r, *g, *b), + Self::Tile => self.logo_tile(DARK.0, DARK.1, DARK.2), + Self::TileRGB(r, g, b) => self.logo_tile(*r, *g, *b), } } @@ -125,4 +143,37 @@ impl PageTopSvg { path fill=(logo_rgb) d="M 1071,648 C 998,648 939,707 939,780 939,853 998,912 1071,912 1144,912 1203,853 1203,780 1203,707 1144,648 1071,648 L 1071,648 1071,648 Z M 1071,859 C 1027,859 992,824 992,780 992,736 1027,701 1071,701 1115,701 1150,736 1150,780 1150,824 1115,859 1071,859 L 1071,859 1071,859 Z" {} } } + + fn logo_tile(&self, r: u8, g: u8, b: u8) -> Markup { + // Luminancia relativa (WCAG 2.x), a partir de los canales sRGB. + let luminance = |(r, g, b): (u8, u8, u8)| { + let linear = |c: u8| { + let c = f32::from(c) / 255.0; + if c <= 0.03928 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } + }; + 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b) + }; + // Elige la línea, oscura o clara, con más contraste sobre el fondo. El contraste entre dos + // luminancias es `(L_mayor + 0.05) / (L_menor + 0.05)`. + let bg = luminance((r, g, b)) + 0.05; + let (lr, lg, lb) = if bg / (luminance(DARK) + 0.05) > (luminance(LIGHT) + 0.05) / bg { + DARK + } else { + LIGHT + }; + + // El logotipo se amplía y desplaza para recortar todo salvo la parte superior izquierda. + let fill = format!("rgb({r},{g},{b})"); + html! { + rect width="1614" height="1614" rx="200" fill=(fill) {} + rect x="1414" y="1414" width="200" height="200" fill=(fill) {} + g transform="translate(161.4 161.4) scale(1.375)" { + (self.logo_line(lr, lg, lb)) + } + } + } } diff --git a/tests/component_image.rs b/tests/component_image.rs new file mode 100644 index 00000000..eafcdd12 --- /dev/null +++ b/tests/component_image.rs @@ -0,0 +1,133 @@ +use pagetop::prelude::*; + +async fn render(mut image: Image) -> String { + image.render(&mut Context::default()).await.into_string() +} + +fn logo() -> Image { + Image::with(image::Source::logo(PageTopSvg::Color)) +} + +// **< Logo size >********************************************************************************** + +#[pagetop::test] +async fn logo_without_size_gets_the_default_dimensions() { + let html = render(logo()).await; + + assert!(html.contains(r#"style="width: 1.25rem; height: 1.25rem""#)); +} + +#[pagetop::test] +async fn logo_with_only_width_does_not_get_a_default_height() { + let html = render(logo().with_size(image::Size::Width(UnitValue::Px(320)))).await; + + assert!(html.contains(r#"style="width: 320px""#)); + assert!(!html.contains("height")); +} + +#[pagetop::test] +async fn logo_with_only_height_does_not_get_a_default_width() { + let html = render(logo().with_size(image::Size::Height(UnitValue::Px(40)))).await; + + assert!(html.contains(r#"style="height: 40px""#)); + assert!(!html.contains("width:")); +} + +#[pagetop::test] +async fn logo_with_a_percentage_keeps_a_single_dimension() { + // A percentage height would refer to the container height, not to the width. + let html = render(logo().with_size(image::Size::Width(UnitValue::RelPct(33.0)))).await; + + assert!(html.contains(r#"style="width: 33%""#)); + assert!(!html.contains("height")); +} + +#[pagetop::test] +async fn logo_with_both_dimensions_uses_them() { + let both = render(logo().with_size(image::Size::Both(UnitValue::Px(36)))).await; + let different = render(logo().with_size(image::Size::Dimensions( + UnitValue::Px(10), + UnitValue::Px(20), + ))) + .await; + + assert!(both.contains(r#"style="width: 36px; height: 36px""#)); + // Both dimensions are set explicitly, so the default is not applied. + assert!(different.contains(r#"style="width: 10px; height: 20px""#)); +} + +#[pagetop::test] +async fn logo_keeps_a_custom_style_instead_of_the_default_dimensions() { + let width = render(logo().with_prop(PropsOp::add_style("width", "3em"))).await; + let both = render( + logo() + .with_prop(PropsOp::add_style("width", "3em")) + .with_prop(PropsOp::add_style("height", "4em")), + ) + .await; + + assert!(width.contains(r#"style="width: 3em""#)); + assert!(!width.contains("height")); + assert!(both.contains(r#"style="width: 3em; height: 4em""#)); + assert!(!both.contains("1.25rem")); +} + +#[pagetop::test] +async fn logo_with_a_size_without_value_gets_the_default_dimensions() { + // `UnitValue::None` writes no declaration, so no dimension is actually set. + for size in [ + image::Size::Width(UnitValue::None), + image::Size::Height(UnitValue::None), + image::Size::Both(UnitValue::None), + image::Size::Dimensions(UnitValue::None, UnitValue::None), + ] { + let html = render(logo().with_size(size)).await; + + assert!(html.contains(r#"style="width: 1.25rem; height: 1.25rem""#)); + } +} + +// **< Other sources >****************************************************************************** + +#[pagetop::test] +async fn responsive_image_without_size_has_no_style() { + let html = render(Image::with(image::Source::responsive("/photo.jpg"))).await; + + assert!(html.starts_with(r#" &'static str { + let svg = PageTopSvg::TileRGB(bg.0, bg.1, bg.2).markup().into_string(); + let dark = svg.contains("************************************************************************ + +#[pagetop::test] +async fn tile_rgb_uses_a_white_line_on_dark_backgrounds() { + assert_eq!(line_color((0, 82, 204)), "white"); + assert_eq!(line_color((0, 0, 0)), "white"); + assert_eq!(line_color((220, 20, 60)), "white"); +} + +#[pagetop::test] +async fn tile_rgb_uses_a_dark_line_on_light_backgrounds() { + assert_eq!(line_color((255, 184, 75)), "dark"); + assert_eq!(line_color((0, 255, 0)), "dark"); + assert_eq!(line_color((255, 255, 255)), "dark"); +} + +#[pagetop::test] +async fn tile_rgb_switches_line_color_around_the_contrast_threshold() { + // Relative luminance ~0.181 and ~0.188 respectively; with the default dark and light lines the + // contrast with both is equal at ~0.186. + assert_eq!(line_color((118, 118, 118)), "white"); + assert_eq!(line_color((120, 120, 120)), "dark"); +} + +#[pagetop::test] +async fn tile_keeps_a_white_line_on_its_default_dark_background() { + let svg = PageTopSvg::Tile.markup().into_string(); + + assert!(svg.contains("