Skip to main content

latexml_core/common/
float.rs

1use std::fmt;
2
3use once_cell::sync::Lazy;
4use regex::Regex;
5
6use crate::{
7  common::{error::Result, numeric_ops::NumericOps, object::Object},
8  definition::register::RegisterType,
9  mouth,
10  tokens::{TeXString, Tokens},
11};
12
13static TRAILING_ZEROS: Lazy<Regex> = Lazy::new(|| Regex::new(r"0+$").unwrap());
14
15//======================================================================
16// Strictly speaking, Float isn't part of TeX, but it's handy.
17
18#[derive(Debug, Copy, Clone, PartialEq)]
19pub struct Float(pub f64);
20
21impl Default for Float {
22  fn default() -> Self { Float(0.0) }
23}
24
25impl Object for Float {
26  fn revert(&self) -> Result<Tokens> { Ok(Tokens::new(ExplodeText!(&self.to_string()))) }
27  fn stringify(&self) -> String { s!("Float[{}]", self.0) }
28  fn be_digested(self) -> Result<crate::Digested> {
29    // Float can be digested as a text box containing its string representation
30    let s = self.to_string();
31    Ok(
32      crate::Tbox::new(
33        // `pin` takes `AsRef<str>` — `&String` borrows cleanly without
34        // an extra `.to_string()` clone that `into_pin` would force.
35        crate::common::arena::pin(&s),
36        None,
37        None,
38        Tokens::new(ExplodeText!(&s)),
39        crate::common::arena::SymHashMap::default(),
40      )
41      .into(),
42    )
43  }
44}
45
46impl NumericOps for Float {
47  fn new(number: i64) -> Self { Float(number as f64) }
48  fn new_f64(number: f64) -> Self { Float(number) }
49  fn value_of(self) -> i64 { self.0 as i64 }
50  fn value_f64(self) -> f64 { self.0 }
51  fn negate(self) -> Self { Float(-self.0) }
52  fn register_type(&self) -> RegisterType { RegisterType::Number }
53  fn add<T: NumericOps>(self, other: T) -> Self { Float::new_f64(self.0 + other.value_f64()) }
54  fn subtract<T: NumericOps>(self, other: T) -> Self { Float::new_f64(self.0 - other.value_f64()) }
55  fn multiply<T: NumericOps>(self, other: T) -> Self { Float::new_f64(self.0 * other.value_f64()) }
56  fn divide<T: NumericOps>(self, other: T) -> Self { Float::new_f64(self.0 / other.value_f64()) }
57}
58
59impl From<Float> for Tokens {
60  fn from(v: Float) -> Tokens { mouth::tokenize_internal(TeXString::assembled(v.to_string())) }
61}
62
63impl From<Float> for Option<Tokens> {
64  fn from(v: Float) -> Option<Tokens> { Some(v.into()) }
65}
66
67impl fmt::Display for Float {
68  fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", floatformat(self.0)) }
69}
70
71impl Float {
72  /// Tight formatting of floats, where we emit them as integers when they do not have a decimal
73  /// part used in e.g. the multido.sty binding and test
74  pub fn to_tight_string(&self) -> String { custom_float_format(self.0, true) }
75}
76
77/// Utility for formatting sane numbers.
78pub fn floatformat(n: f64) -> String { custom_float_format(n, false) }
79pub fn custom_float_format(n: f64, tight: bool) -> String {
80  let mut s = format!("{:.5}", n);
81  if s.contains('.') {
82    s = TRAILING_ZEROS.replace(&s, "").to_string();
83  }
84  if s.ends_with('.') {
85    if tight {
86      // tight format does not need the trailing dot
87      s.pop();
88    } else {
89      s.push('0'); //  Seems TeX prints .0 which in odd corner cases, people use?
90    }
91  }
92  s
93}
94
95impl From<&str> for Float {
96  /// Non-numeric input silently becomes 0.0 (Perl parity — see the
97  /// `From<String>` impl).
98  fn from(spec: &str) -> Self { Float(spec.trim().parse::<f64>().unwrap_or(0.0)) }
99}
100impl From<String> for Float {
101  /// Parse a string into a Float. Non-numeric input silently becomes 0.0
102  /// to match Perl's implicit numeric coercion — `Float("abc")` + x in
103  /// Perl yields x, not a panic.
104  fn from(spec: String) -> Self { Float(spec.trim().parse::<f64>().unwrap_or(0.0)) }
105}
106
107#[cfg(test)]
108mod tests {
109  use super::*;
110
111  #[test]
112  fn floatformat_integer_gets_dot_zero() {
113    assert_eq!(floatformat(1.0), "1.0");
114    assert_eq!(floatformat(0.0), "0.0");
115    assert_eq!(floatformat(-5.0), "-5.0");
116  }
117
118  #[test]
119  fn floatformat_trims_trailing_zeros() {
120    assert_eq!(floatformat(1.5), "1.5");
121    assert_eq!(floatformat(1.25), "1.25");
122    assert_eq!(floatformat(0.10000), "0.1");
123  }
124
125  #[test]
126  fn tight_format_drops_dot_for_integers() {
127    assert_eq!(Float(1.0).to_tight_string(), "1");
128    assert_eq!(Float(0.0).to_tight_string(), "0");
129    assert_eq!(Float(1.5).to_tight_string(), "1.5");
130  }
131
132  #[test]
133  fn custom_float_format_precision() {
134    let out = custom_float_format(0.123456789, false);
135    assert!(
136      out.starts_with("0.12346") || out.starts_with("0.12345"),
137      "got {out:?}"
138    );
139  }
140
141  #[test]
142  fn from_str_nonnumeric_is_zero() {
143    assert_eq!(Float::from("abc").0, 0.0);
144    assert_eq!(Float::from("  xyz  ").0, 0.0);
145    assert_eq!(Float::from("").0, 0.0);
146  }
147
148  #[test]
149  fn from_str_numeric_parses() {
150    assert_eq!(Float::from("1.5").0, 1.5);
151    assert_eq!(Float::from("  -3.125  ").0, -3.125);
152    assert_eq!(Float::from("42").0, 42.0);
153  }
154
155  #[test]
156  fn from_string_matches_from_str() {
157    for s in &["1", "1.5", "", "abc", "-0.0001"] {
158      let a = Float::from(*s).0;
159      let b = Float::from(s.to_string()).0;
160      assert_eq!(a, b, "divergence on {s:?}: {a} vs {b}");
161    }
162  }
163
164  #[test]
165  fn float_arithmetic_roundtrip() {
166    let a = Float::new_f64(1.5);
167    let b = Float::new_f64(2.5);
168    assert_eq!(a.add(b).value_f64(), 4.0);
169    assert_eq!(b.subtract(a).value_f64(), 1.0);
170    assert_eq!(a.multiply(b).value_f64(), 3.75);
171    assert_eq!(b.divide(a).value_f64(), 2.5 / 1.5);
172  }
173
174  #[test]
175  fn float_negate() {
176    assert_eq!(Float::new_f64(1.5).negate().value_f64(), -1.5);
177    assert_eq!(Float::new_f64(0.0).negate().value_f64(), 0.0);
178  }
179}