From 3c848cb36885d4c3e581c7e65db7c995462e4993 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Mon, 13 Oct 2025 13:13:33 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20[html]=20A=C3=B1ade=20soporte=20par?= =?UTF-8?q?a=20unidades=20CSS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 9 +- Cargo.toml | 1 + src/html.rs | 5 + src/html/unit.rs | 259 ++++++++++++++++++++++++++++++++++ src/prelude.rs | 2 +- tests/{html.rs => html_pm.rs} | 0 tests/html_unit.rs | 223 +++++++++++++++++++++++++++++ 7 files changed, 494 insertions(+), 5 deletions(-) create mode 100644 src/html/unit.rs rename tests/{html.rs => html_pm.rs} (100%) create mode 100644 tests/html_unit.rs diff --git a/Cargo.lock b/Cargo.lock index ebcebd3..f1b09f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1568,6 +1568,7 @@ dependencies = [ "parking_lot", "pastey", "serde", + "serde_json", "substring", "tempfile", "terminal_size", @@ -1914,9 +1915,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.3" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c" +checksum = "4a52d8d02cacdb176ef4678de6c052efb4b3da14b78e4db683a4252762be5433" dependencies = [ "aho-corasick", "memchr", @@ -1926,9 +1927,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad" +checksum = "722166aa0d7438abbaa4d5cc2c649dac844e8c56d82fb3d33e9c34b5cd268fc6" dependencies = [ "aho-corasick", "memchr", diff --git a/Cargo.toml b/Cargo.toml index 103f6a9..0d17082 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ testing = [] [dev-dependencies] tempfile = "3.23" +serde_json = "1.0" pagetop-aliner.workspace = true pagetop-bootsier.workspace = true diff --git a/src/html.rs b/src/html.rs index abc8e8c..f85c24e 100644 --- a/src/html.rs +++ b/src/html.rs @@ -84,6 +84,11 @@ use crate::{core, AutoDefault}; #[allow(type_alias_bounds)] pub type OptionComponent = core::component::Typed; +mod unit; +pub use unit::UnitValue; + +// **< HTML PrepareMarkup >************************************************************************* + /// Prepara contenido HTML para su conversión a [`Markup`]. /// /// Este tipo encapsula distintos orígenes de contenido HTML (texto plano, HTML sin escapar o diff --git a/src/html/unit.rs b/src/html/unit.rs new file mode 100644 index 0000000..b6a9bc7 --- /dev/null +++ b/src/html/unit.rs @@ -0,0 +1,259 @@ +use crate::AutoDefault; + +use serde::{Deserialize, Deserializer}; + +use std::fmt; +use std::str::FromStr; + +/// Representa una **unidad CSS** lista para formatear o deserializar. +/// +/// ## Unidades soportadas +/// +/// - **Absolutas** *(valores enteros, `isize`)*: +/// - `Cm(isize)` - `cm` (centímetros) +/// - `In(isize)` - `in` (pulgadas; `1in = 96px = 2.54cm`) +/// - `Mm(isize)` - `mm` (milímetros) +/// - `Pc(isize)` - `pc` (picas; `1pc = 12pt`) +/// - `Pt(isize)` - `pt` (puntos; `1pt = 1/72in`) +/// - `Px(isize)` - `px` (píxeles; `1px = 1/96in`) +/// +/// - **Relativas** *(valores decimales, `f32`)*: +/// - `RelEm(f32)` - `em` (relativa al tamaño de fuente del elemento) +/// - `RelRem(f32)` - `rem` (relativa al tamaño de fuente de `:root`) +/// - `RelPct(f32)` - `%` (porcentaje relativo al elemento padre) +/// - `RelVh(f32)` - `vh` (1% de la **altura** del viewport) +/// - `RelVw(f32)` - `vw` (1% del **ancho** del viewport) +/// +/// ## Valores especiales +/// +/// - `None` - equivale a un texto vacío (`""`), útil para atributos opcionales. +/// - `Auto` - equivale a `"auto"`. +/// - `Zero` - equivale a `"0"` (cero sin unidad). +/// +/// ## Características +/// +/// - Soporta unidades **absolutas** (`cm`, `in`, `mm`, `pc`, `pt`, `px`) y **relativas** (`em`, +/// `rem`, `%`, `vh`, `vw`). +/// - `FromStr` para convertir desde texto (p.ej. `"12px"`, `"1.25rem"`, `"auto"`). +/// - `Display` para formatear a cadena (p.ej. `UnitValue::Px(12)` genera `"12px"`). +/// - `Deserialize` delega en `FromStr`, garantizando una gramática única. +/// +/// ## Ejemplos +/// +/// ```rust +/// # use pagetop::prelude::*; +/// use std::str::FromStr; +/// +/// assert_eq!(UnitValue::from_str("16px").unwrap(), UnitValue::Px(16)); +/// assert_eq!(UnitValue::from_str("1.25rem").unwrap(), UnitValue::RelRem(1.25)); +/// assert_eq!(UnitValue::from_str("33%").unwrap(), UnitValue::RelPct(33.0)); +/// assert_eq!(UnitValue::from_str("auto").unwrap(), UnitValue::Auto); +/// assert_eq!(UnitValue::from_str("").unwrap(), UnitValue::None); +/// assert_eq!(UnitValue::from_str("0").unwrap(), UnitValue::Zero); +/// ``` +/// +/// ## Notas +/// +/// - Las absolutas **no aceptan** decimales (p.ej., `"1.5px"` sería erróneo). +/// - Se aceptan signos `+`/`-` en todas las unidades (p.ej., `"-12px"`, `"+0.5em"`). +/// - La comparación de unidad es *case-insensitive* al interpretar el texto (`"PX"`, `"Px"`, …). +/// - **Sobre píxeles**: Los píxeles (px) son relativos al dispositivo de visualización. En +/// dispositivos con baja densidad de píxeles (dpi), 1px equivale a un píxel (punto) del +/// dispositivo. En impresoras y pantallas de alta resolución, 1px implica múltiples píxeles del +/// dispositivo. +/// - **Sobre `em` y `rem`**: +/// - `em` es **relativo al tamaño de fuente *del propio elemento***. Si el elemento hereda o +/// cambia su `font-size`, todos los valores en `em` dentro de él **se escalan en cascada**. +/// - `rem` es **relativo al tamaño de fuente del elemento raíz** (`:root`/`html`), **no se verá +/// afectado** por cambios de `font-size` en elementos anidados. +/// - Ejemplo: si `:root { font-size: 16px }` y un contenedor tiene `font-size: 20px`, entonces +/// dentro del contenedor `1em == 20px` pero `1rem == 16px`. +/// - Uso típico: `rem` para tipografía y espaciados globales (consistencia al cambiar la base del +/// sitio); `em` para tamaños que deban escalar **con el propio componente** (p.ej., +/// `padding: 0.5em` que crece si el componente aumenta su `font-size`). +/// - **Sobre el viewport**: Si el ancho de la ventana del navegador es de 50cm, 1vw equivale a +/// 0.5cm (1vw siempre es 1% del ancho del viewport, independientemente del zoom del navegador o +/// la densidad de píxeles del dispositivo). +#[rustfmt::skip] +#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] +pub enum UnitValue { + #[default] + None, + Auto, + /// Cero sin unidad. + Zero, + /// Centímetros. + Cm(isize), + /// Pulgadas (1in = 96px = 2.54cm). + In(isize), + /// Milímetros. + Mm(isize), + /// Picas (1pc = 12pt). + Pc(isize), + /// Puntos (1pt = 1/72in). + Pt(isize), + /// Píxeles (1px = 1/96in). + Px(isize), + /// Relativo al tamaño de la fuente del elemento. + RelEm(f32), + /// Relativo al tamaño de la fuente del elemento raíz. + RelRem(f32), + /// Porcentaje relativo al elemento padre. + RelPct(f32), + /// Relativo al 1% de la altura del viewport. + RelVh(f32), + /// Relativo al 1% del ancho del viewport. + RelVw(f32), +} + +/// Formatea la unidad como cadena CSS. +/// +/// Reglas: +/// +/// - `None` - `""` (cadena vacía). +/// - `Auto` - `"auto"`. +/// - `Zero` - `"0"` (cero sin unidad). +/// - Absolutas - entero con su unidad: `Px(12)` a `"12px"`. +/// - Relativas - número en punto flotante; si es entero, se imprime sin decimales: +/// - `RelEm(2.0)` a `"2em"` +/// - `RelPct(33.5)` a `"33.5%"` +#[rustfmt::skip] +impl fmt::Display for UnitValue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + UnitValue::None => write!(f, ""), + UnitValue::Auto => write!(f, "auto"), + UnitValue::Zero => write!(f, "0"), + // Valor absoluto. + UnitValue::Cm(av) => write!(f, "{av}cm"), + UnitValue::In(av) => write!(f, "{av}in"), + UnitValue::Mm(av) => write!(f, "{av}mm"), + UnitValue::Pc(av) => write!(f, "{av}pc"), + UnitValue::Pt(av) => write!(f, "{av}pt"), + UnitValue::Px(av) => write!(f, "{av}px"), + // Valor relativo. + UnitValue::RelEm(rv) => write!(f, "{rv}em"), + UnitValue::RelRem(rv) => write!(f, "{rv}rem"), + UnitValue::RelPct(rv) => write!(f, "{rv}%"), + UnitValue::RelVh(rv) => write!(f, "{rv}vh"), + UnitValue::RelVw(rv) => write!(f, "{rv}vw"), + } + } +} + +/// Convierte una cadena a [`UnitValue`] siguiendo una gramática CSS acotada. +/// +/// ## Acepta +/// +/// - `""` para `UnitValue::None` +/// - `"auto"` +/// - **Cero sin unidad**: `"0"`, `"+0"`, `"-0"`, `"0.0"`, `"0."`, `".0"` para `UnitValue::Zero` +/// - Porcentaje: `"%"` (p.ej. `"33%"`, `"33 %"`) +/// - Absolutas enteras: `""`, p.ej. `"12px"`, `"-5pt"` +/// - Relativas decimales: `""`, p.ej. `"1.25rem"`, `"-0.5vh"`, `".5em"`, `"1.rem"` +/// +/// (Se toleran espacios entre número y unidad: `"12 px"`, `"1.5 rem"`). +/// +/// ## Ejemplo +/// +/// ```rust +/// # use pagetop::prelude::*; +/// use std::str::FromStr; +/// +/// assert_eq!(UnitValue::from_str("12px").unwrap(), UnitValue::Px(12)); +/// assert!(UnitValue::from_str("12").is_err()); +/// ``` +/// +/// ## Errores de interpretación +/// +/// - Falta la unidad cuando es necesaria (p.ej. `"12"`, excepto para el valor cero). +/// - Decimales en valores que deben ser absolutos (p.ej. `"1.5px"`). +/// - Unidades desconocidas (p.ej. `"10ch"`, no soportada aún). +/// - Notación científica o bases no decimales: `"1e3vw"`, `"0x10px"` (no soportadas). Los ceros a +/// la izquierda (p. ej. `"020px"`) se interpretan en **base 10** (`20px`). +/// +/// La comparación de la unidad es *case-insensitive*. +impl FromStr for UnitValue { + type Err = String; + + fn from_str(input: &str) -> Result { + let s = input.trim(); + if s.is_empty() { + return Ok(UnitValue::None); + } + if s.eq_ignore_ascii_case("auto") { + return Ok(UnitValue::Auto); + } + + match s.find(|c: char| c.is_ascii_alphabetic() || c == '%') { + None => { + let n: f32 = s + .parse() + .map_err(|e| format!("Invalid number `{s}`: {e}"))?; + if n == 0.0 { + Ok(UnitValue::Zero) + } else { + Err( + "Missing unit (expected one of cm,in,mm,pc,pt,px,em,rem,vh,vw, or %)" + .to_string(), + ) + } + } + Some(split_pos) => { + let (num_str, unit_str) = s.split_at(split_pos); + let u = unit_str.trim(); + let n = num_str.trim(); + + let parse_abs = |n_s: &str| -> Result { + n_s.parse::() + .map_err(|e| format!("Invalid integer `{n_s}`: {e}")) + }; + let parse_rel = |n_s: &str| -> Result { + n_s.parse::() + .map_err(|e| format!("Invalid float `{n_s}`: {e}")) + }; + + match u.to_ascii_lowercase().as_str() { + // Unidades absolutas. + "cm" => Ok(UnitValue::Cm(parse_abs(n)?)), + "in" => Ok(UnitValue::In(parse_abs(n)?)), + "mm" => Ok(UnitValue::Mm(parse_abs(n)?)), + "pc" => Ok(UnitValue::Pc(parse_abs(n)?)), + "pt" => Ok(UnitValue::Pt(parse_abs(n)?)), + "px" => Ok(UnitValue::Px(parse_abs(n)?)), + // Unidades relativas. + "em" => Ok(UnitValue::RelEm(parse_rel(n)?)), + "rem" => Ok(UnitValue::RelRem(parse_rel(n)?)), + "vh" => Ok(UnitValue::RelVh(parse_rel(n)?)), + "vw" => Ok(UnitValue::RelVw(parse_rel(n)?)), + // Porcentaje como unidad. + "%" => Ok(UnitValue::RelPct(parse_rel(n)?)), + // Unidad desconocida. + _ => Err(format!("Unknown unit: `{u}`")), + } + } + } + } +} + +/// Deserializa desde una cadena usando la misma gramática que [`FromStr`]. +/// +/// ### Ejemplo con `serde_json` +/// ```rust +/// # use pagetop::prelude::*; +/// use serde::Deserialize; +/// +/// #[derive(Deserialize)] +/// struct Style { width: UnitValue } +/// +/// // "{\"width\":\"12px\"}" deserializa como `Style { width: UnitValue::Px(12) }` +/// ``` +impl<'de> Deserialize<'de> for UnitValue { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + raw.parse().map_err(serde::de::Error::custom) + } +} diff --git a/src/prelude.rs b/src/prelude.rs index a71375e..cd3191f 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -34,7 +34,7 @@ pub use crate::trace; pub use crate::html::{ display, html_private, Asset, Assets, AttrClasses, AttrId, AttrL10n, AttrName, AttrValue, ClassesOp, Escaper, Favicon, JavaScript, Markup, PreEscaped, PrepareMarkup, StyleSheet, - TargetMedia, DOCTYPE, + TargetMedia, UnitValue, DOCTYPE, }; pub use crate::locale::*; diff --git a/tests/html.rs b/tests/html_pm.rs similarity index 100% rename from tests/html.rs rename to tests/html_pm.rs diff --git a/tests/html_unit.rs b/tests/html_unit.rs new file mode 100644 index 0000000..9316af8 --- /dev/null +++ b/tests/html_unit.rs @@ -0,0 +1,223 @@ +use pagetop::prelude::*; + +use std::str::FromStr; + +#[pagetop::test] +async fn unit_value_empty_and_auto_and_zero_without_unit() { + assert_eq!(UnitValue::from_str("").unwrap(), UnitValue::None); + assert_eq!(UnitValue::from_str("auto").unwrap(), UnitValue::Auto); + assert_eq!(UnitValue::from_str("AUTO").unwrap(), UnitValue::Auto); + + // Cero sin unidad. + assert_eq!(UnitValue::from_str("0").unwrap(), UnitValue::Zero); + assert_eq!(UnitValue::from_str("+0").unwrap(), UnitValue::Zero); + assert_eq!(UnitValue::from_str("-0").unwrap(), UnitValue::Zero); +} + +#[pagetop::test] +async fn unit_value_absolute_integers_with_signs_and_spaces_and_case() { + // Positivos, negativos y con espacios. + assert_eq!(UnitValue::from_str("12px").unwrap(), UnitValue::Px(12)); + assert_eq!(UnitValue::from_str("-5pt").unwrap(), UnitValue::Pt(-5)); + assert_eq!(UnitValue::from_str(" 7 cm ").unwrap(), UnitValue::Cm(7)); + assert_eq!(UnitValue::from_str("+9 in").unwrap(), UnitValue::In(9)); + assert_eq!(UnitValue::from_str(" 13 mm ").unwrap(), UnitValue::Mm(13)); + assert_eq!(UnitValue::from_str("4 pc").unwrap(), UnitValue::Pc(4)); + + // Insensibilidad a mayúsculas. + assert_eq!(UnitValue::from_str("10PX").unwrap(), UnitValue::Px(10)); + assert_eq!(UnitValue::from_str("15Pt").unwrap(), UnitValue::Pt(15)); +} + +#[pagetop::test] +async fn unit_value_relative_floats_with_signs_and_spaces_and_case() { + assert_eq!( + UnitValue::from_str("1.25rem").unwrap(), + UnitValue::RelRem(1.25) + ); + assert_eq!( + UnitValue::from_str("-0.5em").unwrap(), + UnitValue::RelEm(-0.5) + ); + assert_eq!( + UnitValue::from_str(" 33% ").unwrap(), + UnitValue::RelPct(33.0) + ); + assert_eq!( + UnitValue::from_str(" -12.5 vh").unwrap(), + UnitValue::RelVh(-12.5) + ); + assert_eq!( + UnitValue::from_str(" 8.0 VW ").unwrap(), + UnitValue::RelVw(8.0) + ); +} + +#[pagetop::test] +async fn unit_value_whitespace_between_number_and_unit_is_allowed() { + // Hay espacio entre número y unidad (la implementación actual lo admite). + assert_eq!(UnitValue::from_str("12 px").unwrap(), UnitValue::Px(12)); + assert_eq!( + UnitValue::from_str("1.5 rem").unwrap(), + UnitValue::RelRem(1.5) + ); + assert_eq!( + UnitValue::from_str("25 %").unwrap(), + UnitValue::RelPct(25.0) + ); +} + +#[pagetop::test] +async fn unit_value_roundtrip_display_keeps_expected_format() { + let cases = [ + ("", UnitValue::None, ""), + ("auto", UnitValue::Auto, "auto"), + ("0", UnitValue::Zero, "0"), + ("12px", UnitValue::Px(12), "12px"), + ("-5pt", UnitValue::Pt(-5), "-5pt"), + ("7cm", UnitValue::Cm(7), "7cm"), + ("33%", UnitValue::RelPct(33.0), "33%"), + ("1.25rem", UnitValue::RelRem(1.25), "1.25rem"), + ("2em", UnitValue::RelEm(2.0), "2em"), + ("-0.5vh", UnitValue::RelVh(-0.5), "-0.5vh"), + ("8vw", UnitValue::RelVw(8.0), "8vw"), + ]; + + for (input, expected_value, expected_display) in cases { + let parsed = UnitValue::from_str(input).unwrap(); + assert_eq!( + parsed, expected_value, + "parsed mismatch for input `{input}`" + ); + assert_eq!( + parsed.to_string(), + expected_display, + "display mismatch for input `{input}`" + ); + } +} + +#[pagetop::test] +async fn unit_value_percentage_trimming_and_signs() { + assert_eq!( + UnitValue::from_str(" 12.5 % ").unwrap(), + UnitValue::RelPct(12.5) + ); + assert_eq!( + UnitValue::from_str("-0.0%").unwrap(), + UnitValue::RelPct(-0.0) + ); + assert_eq!( + UnitValue::from_str("+15%").unwrap(), + UnitValue::RelPct(15.0) + ); +} + +// ERRORES ESPERADOS (no cambiar los mensajes; con is_err() basta). + +#[pagetop::test] +async fn unit_value_errors_missing_unit_for_non_zero() { + assert!( + UnitValue::from_str("12").is_err(), + "non-zero without unit must error" + ); + assert!( + UnitValue::from_str(" -3 ").is_err(), + "non-zero without unit must error" + ); +} + +#[pagetop::test] +async fn unit_value_errors_decimals_in_absolute_units() { + assert!(UnitValue::from_str("1.5px").is_err()); + assert!(UnitValue::from_str("-2.0pt").is_err()); + assert!(UnitValue::from_str("+0.1cm").is_err()); +} + +#[pagetop::test] +async fn unit_value_errors_unknown_units_or_bad_percentages() { + // Unidad no soportada. + assert!(UnitValue::from_str("10ch").is_err()); + assert!(UnitValue::from_str("2q").is_err()); + // Falta número. + assert!(UnitValue::from_str("%").is_err()); + assert!(UnitValue::from_str(" % ").is_err()); +} + +#[pagetop::test] +async fn unit_value_errors_non_numeric_numbers() { + assert!(UnitValue::from_str("NaNem").is_err()); + // Decimal no permitido por FromStr. + assert!(UnitValue::from_str("1,5rem").is_err()); +} + +#[pagetop::test] +async fn unit_value_serde_deserialize_struct_and_array() { + use serde::Deserialize; + + #[derive(Deserialize, Debug, PartialEq)] + struct BoxStyle { + width: UnitValue, + height: UnitValue, + margin: UnitValue, + } + + let json = r#"{ "width": "12px", "height": "1.5rem", "margin": "0" }"#; + let s: BoxStyle = serde_json::from_str(json).unwrap(); + assert_eq!(s.width, UnitValue::Px(12)); + assert_eq!(s.height, UnitValue::RelRem(1.5)); + assert_eq!(s.margin, UnitValue::Zero); + + #[derive(Deserialize, Debug, PartialEq)] + struct Many { + values: Vec, + } + + let json_arr = r#"{ "values": ["", "auto", "33%", "8vw", "7 cm", "-5pt"] }"#; + let m: Many = serde_json::from_str(json_arr).unwrap(); + assert_eq!( + m.values, + vec![ + UnitValue::None, + UnitValue::Auto, + UnitValue::RelPct(33.0), + UnitValue::RelVw(8.0), + UnitValue::Cm(7), + UnitValue::Pt(-5), + ] + ); +} + +#[pagetop::test] +async fn unit_value_accepts_dot5_and_1dot_shorthand_for_relatives() { + // `.5` y `1.` se parsean correctamente en relativas. + assert_eq!(UnitValue::from_str(".5em").unwrap(), UnitValue::RelEm(0.5)); + assert_eq!( + UnitValue::from_str("1.rem").unwrap(), + UnitValue::RelRem(1.0) + ); + assert_eq!(UnitValue::from_str("1.vh").unwrap(), UnitValue::RelVh(1.0)); + // Sin unidad debe seguir fallando. + assert!(UnitValue::from_str("1.").is_err()); +} + +#[pagetop::test] +async fn unit_value_display_keeps_minus_zero_for_relatives() { + // Comportamiento actual: f32 Display muestra "-0" si el valor es -0.0. + let v = UnitValue::RelEm(-0.0); + // Se acepta cualquiera de los dos formatos como válidos. + let s = v.to_string(); + assert!( + s == "-0em" || s == "0em", + "current Display prints `{s}` for -0.0; both are acceptable in tests" + ); +} + +#[pagetop::test] +async fn unit_value_rejects_non_decimal_notations() { + // Octal, los ceros a la izquierda (p.ej. `"020px"`) se interpretan en **base 10** (`20px`). + assert_eq!(UnitValue::from_str("020px").unwrap(), UnitValue::Px(20)); + // Notación científica y bases no decimales (p.ej. `"1e3vw"`, `"0x10px"`) no están soportadas. + assert!(UnitValue::from_str("1e3vw").is_err()); + assert!(UnitValue::from_str("0x10px").is_err()); +}