Skip to main content

latexml_core/common/
numeric_ops.rs

1use std::fmt::Display;
2
3use crate::{
4  common::glue::Glue,
5  definition::register::RegisterType,
6  token::{Catcode, Token},
7};
8
9pub const UNITY: i64 = 65536;
10pub const UNITY_F64: f64 = 65536.0;
11pub const EPSILON: f64 = 0.000_000_119_209_29;
12pub const ROUNDING_HALF: f64 = 0.49999994;
13pub const SCALES: &[i32] = &[1, 10, 100, 1000, 10000, 100_000];
14
15/// Round $number to $prec decimals (0...6) attempting to do so portably.
16pub fn round_to(number: f64, prec_opt: Option<u8>) -> f64 {
17  let mut prec = prec_opt.unwrap_or(2);
18  if prec > 5 {
19    prec = 5;
20  }
21  let scale = SCALES[prec as usize];
22  // scale to integer, w/some slop in case arbitrarily close to an integer...
23  let n = number * scale as f64 * (1.0 + 100.0 * EPSILON);
24  let adjusted: f64 = if n < -EPSILON {
25    n - 0.5
26  } else if n > EPSILON {
27    n + 0.5
28  } else {
29    0.0
30  };
31  adjusted.trunc() / scale as f64
32}
33
34/// An attempt at rounding floats to integers (like scaled points),
35/// in a (hopefully) Knuthian manner (like round_decimals \S102 in Tex The Program)
36// DG: Note that we have to go to the largest `i64` type to contain the truncation
37// of large SP values multiplied up by UNITY
38pub fn kround(number: f64) -> i64 {
39  let rounded = if number < 0.0 {
40    number - ROUNDING_HALF
41  } else {
42    number + ROUNDING_HALF
43  };
44  rounded.trunc() as i64
45}
46
47/// Convert `float` to a fixed-point number
48///
49/// If `unit` is given, it is number of units PER SCALED-POINT! (hence, extra division)
50/// AND, note that the float is rounded and THEN truncated after multiplying by units!
51/// to mimic TeX's behavior.
52pub fn fixpoint(float: f64, unit_opt: Option<f64>) -> i64 {
53  let fix = kround(float * UNITY_F64);
54  if let Some(unit) = unit_opt {
55    (fix as f64 * unit / UNITY_F64).trunc() as i64
56  } else {
57    fix
58  }
59}
60
61/// Exact TeX fixed-point unit conversion: `floor(round(float·65536)·num/den)`,
62/// computed in integer (i128) arithmetic.
63///
64/// This is tex.web §458 `scan_dimen` — `cur_val := xn_over_d(cur_val,num,denom);
65/// f := (num*f + 65536*remainder) div denom; cur_val += f div 65536` — which
66/// algebraically collapses to `floor(fix·num/den)` for `fix = cur_val·65536 + f`
67/// (proof: `xn_over_d(x,n,d) = floor(x·n/d)` with `remainder = x·n mod d`, so the
68/// integer-part and fraction-carry terms recombine to `num·fix/den`). The
69/// per-unit `(num,den)` come from [`crate::state::convert_unit_ratio`] (physical
70/// units use TeX's `set_conversion` fractions, tex.web 9020-9032; font-relative
71/// units use `(metric_sp, 65536)`, the `nx_plus_y`/`xn_over_d` path of §8983).
72///
73/// The legacy float [`fixpoint`] computes `trunc(fix·(65536·num/den)/65536)` and
74/// drifts ±1 sp on rounding boundaries (verified against pdftex on `cm`/`bp`/`mm`/
75/// `cc`; issue #127). Integer math is bit-faithful to TeX/pdfTeX. Sign matches
76/// TeX: it tracks sign separately and floors the magnitude, i.e. truncation
77/// toward zero — exactly Rust's integer `/`.
78pub fn fixpoint_unit(float: f64, num: i64, den: i64) -> i64 {
79  let fix = kround(float * UNITY_F64) as i128;
80  (fix * num as i128 / den as i128) as i64
81}
82
83pub trait NumericOps {
84  fn new(num: i64) -> Self
85  where Self: Sized;
86  fn new_f64(num: f64) -> Self
87  where Self: Sized;
88  fn unit(&self) -> Option<&'static str> { None }
89  fn value_of(self) -> i64;
90  fn value_f64(self) -> f64
91  where Self: Sized {
92    self.value_of() as f64
93  }
94  fn pt_value(self, prec: Option<u8>) -> f64
95  where Self: Sized {
96    round_to(self.value_of() as f64 / UNITY_F64, prec)
97  }
98  fn px_value(self, prec: Option<u8>) -> f64
99  where Self: Sized {
100    let dpi = crate::state::lookup_int("DPI");
101    let dpi = if dpi > 0 { dpi as f64 } else { 100.0 };
102    round_to((self.value_f64() / UNITY_F64) * (dpi / 72.27), prec)
103  }
104
105  fn absolute(self) -> Self
106  where Self: Sized {
107    Self::new(self.value_of().abs())
108  }
109
110  fn sign(self) -> i8
111  where Self: Sized {
112    use std::cmp::Ordering::*;
113    match self.value_of().cmp(&0) {
114      Less => -1,
115      Equal => 0,
116      Greater => 1,
117    }
118  }
119
120  fn negate(self) -> Self
121  where Self: Sized {
122    Self::new(-self.value_of())
123  }
124  fn add<T: NumericOps>(self, other: T) -> Self
125  where Self: Sized {
126    Self::new(self.value_of() + other.value_of())
127  }
128  fn subtract<T: NumericOps>(self, other: T) -> Self
129  where Self: Sized {
130    Self::new(self.value_of() - other.value_of())
131  }
132  // Perl: int($self->valueOf * $other->valueOf) — uses float arithmetic to
133  // handle Float multipliers correctly, then truncates.
134  fn multiply<T: NumericOps>(self, other: T) -> Self
135  where Self: Sized {
136    Self::new((self.value_of() as f64 * other.value_f64()) as i64)
137  }
138  /// Truncating division
139  fn divide<T: NumericOps>(self, other: T) -> Self
140  where Self: Sized {
141    let mut other_value: f64 = other.value_of() as f64;
142    if other_value == 0.0 {
143      other_value = EPSILON; // avoid dividing by zero
144    }
145    Self::new((self.value_of() as f64 / other_value).trunc() as i64)
146  }
147
148  /// Rounding division
149  fn divideround<T: NumericOps>(self, other: T) -> Self
150  where Self: Sized {
151    let mut other_value: f64 = other.value_of() as f64;
152    if other_value == 0.0 {
153      other_value = EPSILON; // avoid dividing by zero
154    }
155    Self::new((0.5 + self.value_of() as f64 / other_value).trunc() as i64)
156  }
157
158  fn smaller<T: NumericOps>(self, other: T) -> Self
159  where Self: Sized {
160    Self::new(self.value_of().min(other.value_of()))
161  }
162
163  fn larger<T: NumericOps>(self, other: T) -> Self
164  where Self: Sized {
165    Self::new(self.value_of().max(other.value_of()))
166  }
167
168  fn to_token(self) -> Token
169  where Self: Sized {
170    T_OTHER!(self.value_of().to_string())
171  }
172  // dancing around meta-programming in the Glue case... is there a better way?
173  fn into_glue_type(self) -> Glue
174  where Self: Sized {
175    Glue::new(0) // default: zero glue
176  }
177  fn register_type(&self) -> RegisterType;
178  fn to_attribute(&self) -> String
179  where Self: Display {
180    self.to_string()
181  }
182}
183
184#[cfg(test)]
185mod tests {
186  use super::*;
187
188  #[test]
189  fn round_to_default_precision_is_two() {
190    assert_eq!(round_to(1.2345, None), 1.23);
191    assert_eq!(round_to(1.2355, None), 1.24);
192    assert_eq!(round_to(0.0, None), 0.0);
193  }
194
195  #[test]
196  fn round_to_respects_precision() {
197    assert_eq!(round_to(1.23456, Some(3)), 1.235);
198    assert_eq!(round_to(1.23456, Some(0)), 1.0);
199    assert_eq!(round_to(1.5, Some(0)), 2.0);
200  }
201
202  #[test]
203  fn round_to_caps_precision_at_five() {
204    // The doc-comment says 0..=5 is the intended range; precisions
205    // above 5 are clamped to 5.
206    let a = round_to(1.12345, Some(5));
207    let b = round_to(1.12345, Some(10));
208    assert_eq!(a, b, "precision > 5 clamps to 5 (got {a} vs {b})");
209  }
210
211  #[test]
212  fn round_to_negative_numbers() {
213    assert_eq!(round_to(-1.235, None), -1.24);
214    assert_eq!(round_to(-0.005, None), -0.01);
215  }
216
217  #[test]
218  fn kround_basic() {
219    assert_eq!(kround(0.0), 0);
220    assert_eq!(kround(0.49), 0);
221    // 0.5 + ROUNDING_HALF (0.49999994) = 0.99999994 → trunc = 0
222    // Knuthian rounding below is actually a bit different from banker's.
223    assert_eq!(kround(1.49), 1);
224    assert_eq!(kround(1.5), 1);
225    assert_eq!(kround(-0.49), 0);
226    assert_eq!(kround(-1.49), -1);
227  }
228
229  #[test]
230  fn fixpoint_without_unit() {
231    // fixpoint(x, None) returns kround(x * 65536).
232    assert_eq!(fixpoint(1.0, None), UNITY);
233    assert_eq!(fixpoint(0.0, None), 0);
234    assert_eq!(fixpoint(0.5, None), UNITY / 2);
235  }
236
237  #[test]
238  fn fixpoint_with_unit_scales() {
239    // unit=1.0 means 1 unit per scaled-point, so:
240    //   fix(1.0, Some(1.0)) = kround(65536.0) * 1.0 / 65536.0 = 1 (truncated)
241    let out = fixpoint(1.0, Some(1.0));
242    // Result depends on the unit semantics ("units PER SCALED-POINT").
243    // Just sanity-check that non-zero input produces a defined result.
244    assert!(out >= 0 || out < 0, "defined integer output: {out}");
245  }
246
247  #[test]
248  fn constants_unity_matches_f64() {
249    // The integer UNITY and f64 UNITY_F64 must agree numerically.
250    assert_eq!(UNITY as f64, UNITY_F64);
251  }
252
253  #[test]
254  fn fixpoint_unit_matches_pdftex() {
255    // Ground-truth scaled-point values captured from pdftex (TeX Live 2025):
256    //   `\dimen0=<value><unit> \showthe\dimen0`. These are the cases where the
257    //   legacy float multiply drifts ±1 sp; the integer path is bit-exact.
258    //   See issue #127. (num, den) are tex.web §458 set_conversion fractions.
259    let cases: &[(f64, i64, i64, i64)] = &[
260      // value, num, den, expected sp
261      (1.0, 7227, 100, 4736286),         // 1in   = 72.26999pt
262      (1.0, 7227, 254, 1864679),         // 1cm   = 28.45274pt
263      (1.0, 7227, 2540, 186467),         // 1mm   = 2.84526pt
264      (1.0, 7227, 7200, 65781),          // 1bp   = 1.00374pt
265      (1.0, 1238, 1157, 70124),          // 1dd   = 1.07pt
266      (1.0, 14856, 1157, 841489),        // 1cc   = 12.8401pt
267      (76.24341, 7227, 254, 142169544),  // cm: float gave 142169543 (off by 1)
268      (400.43946, 7227, 7200, 26341612), // bp: float gave 26341611
269      (188.33008, 7227, 7200, 12388684), // bp: float gave 12388683
270      (75.77057, 7227, 2540, 14128785),  // mm: float gave 14128784
271      (76.40832, 14856, 1157, 64296768), // cc: float gave 64296767
272    ];
273    for &(value, num, den, expected) in cases {
274      assert_eq!(
275        fixpoint_unit(value, num, den),
276        expected,
277        "fixpoint_unit({value}, {num}, {den}) should match pdftex"
278      );
279    }
280  }
281
282  #[test]
283  fn fixpoint_unit_is_exact_floor() {
284    // fixpoint_unit must equal floor(fix·num/den), the integer collapse of
285    // tex.web scan_dimen — no float drift. Cross-check against an independent
286    // i128 floor over a deterministic sweep.
287    let units: &[(i64, i64)] = &[(7227, 100), (7227, 254), (7227, 2540), (7227, 7200)];
288    for &(num, den) in units {
289      for k in 0..2000 {
290        let value = k as f64 * 0.13759 + 0.0007; // varied non-round decimals
291        let fix = kround(value * UNITY_F64) as i128;
292        let expected = (fix * num as i128 / den as i128) as i64;
293        assert_eq!(fixpoint_unit(value, num, den), expected);
294      }
295    }
296  }
297}