Expressions: a mixed number such as 2 1/8" is 2.125" (#1802) (#1820)

Thanks @SychPL.
This commit is contained in:
Mateusz Sych authored and GitHub committed 2026-10-17 04:07:19 -04:00
1 parent 64966bab88
commit f350c35414
2 files changed
+72 -1

No files matched your search

+69
View File
@@ -359,6 +359,15 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
}
let t = s.get(at(st)..at(i)).unwrap_or_default();
let v: f64 = t.replace(',', ".").parse().map_err(|_| DocError::Expr(format!("bad number `{t}`")))?;
// A mixed number, as imperial sizes are written (`2 1/8"` is 2.125 in, #1802): a whole
// number, then a proper fraction of whole numbers, is one value, not `2 × 1/8`.
if t.chars().all(|c| c.is_ascii_digit())
&& let Some((fraction, end)) = mixed_fraction(&cs, i, depth)
{
out.push((Tok::Num(v + fraction), at(st), at(end)));
i = end;
continue;
}
out.push((Tok::Num(v), at(st), at(i)));
} else if c.is_alphabetic() || c == '_' || c == 'µ' {
let st = i;
@@ -404,6 +413,43 @@ fn lex_spans(s: &str) -> Result<Vec<(Tok, usize, usize)>, DocError> {
Ok(out)
}
/// The proper fraction of a mixed number whose whole part ends at `i`: spaces, digits, `/`,
/// digits, with nothing after them that would continue the number (`2 1/8.5`, `2 1/8e3`, a
/// decimal comma outside parentheses: `2 1/8,5`). Its value and where it ends. `depth`: the
/// parentheses open there (inside them a comma separates arguments: `max(2 1/8,1)`).
fn mixed_fraction(cs: &[(usize, char)], mut i: usize, depth: usize) -> Option<(f64, usize)> {
let digits = |from: usize| cs.get(from..).map_or(0, |rest| rest.iter().take_while(|(_, c)| c.is_ascii_digit()).count());
let space = i;
while cs.get(i).is_some_and(|(_, c)| *c == ' ' || *c == '\t') {
i += 1;
}
let numerator = digits(i);
if i == space || numerator == 0 || !cs.get(i + numerator).is_some_and(|(_, c)| *c == '/') {
return None;
}
let denominator = digits(i + numerator + 1);
let end = i + numerator + 1 + denominator;
let next = |k: usize| cs.get(end + k).map(|(_, c)| *c);
let continues = matches!(next(0), Some('.'))
|| (depth == 0 && matches!(next(0), Some(',')) && next(1).is_some_and(|c| c.is_ascii_digit()))
|| (matches!(next(0), Some('e' | 'E')) && next(1).is_some_and(|c| c.is_ascii_digit() || c == '-' || c == '+'));
if denominator == 0 || continues {
return None;
}
// Compared as digits, not as floats: long ones round to the same float (#1802 review).
let digits_of = |from: usize, len: usize| -> Option<String> {
let s: String = cs.get(from..from + len)?.iter().map(|(_, c)| *c).collect();
Some(s.trim_start_matches('0').to_string())
};
let (n, d) = (digits_of(i, numerator)?, digits_of(i + numerator + 1, denominator)?);
let proper = !d.is_empty() && (n.len(), n.as_str()) < (d.len(), d.as_str());
let value = |t: &str| if t.is_empty() { Some(0.0) } else { t.parse::<f64>().ok() };
if !proper {
return None;
}
Some((value(&n)? / value(&d)?, end))
}
fn lex(s: &str) -> Result<Vec<Tok>, DocError> {
Ok(lex_spans(s)?.into_iter().map(|(t, _, _)| t).collect())
}
@@ -965,6 +1011,29 @@ mod tests {
assert_eq!(ev("\"", Kind::Length).ok(), ev("in", Kind::Length).ok());
}
#[test]
fn mixed_numbers() {
// #1802: `2 1/8"` is 2.125 in, as imperial sizes are written, not 2 × 1/8 in.
assert_eq!(ev("2 1/8\"", Kind::Length).unwrap(), 2.125 * 25.4);
assert_eq!(ev("2 1/8 in", Kind::Length).unwrap(), 2.125 * 25.4);
assert_eq!(ev("3 7/16\"", Kind::Length).unwrap(), 3.4375 * 25.4);
assert_eq!(ev("10 1/2mm", Kind::Length).unwrap(), 10.5);
assert_eq!(ev("width + 1 1/2", Kind::Length).unwrap(), 41.5);
assert_eq!(ev("max(2 1/8, 3)", Kind::Unitless).unwrap(), 3.0);
// Inside parentheses a comma after the fraction separates arguments; outside, a comma
// between digits is a decimal comma, which continues the number (as before).
assert_eq!(ev("max(2 1/8,1)", Kind::Unitless).unwrap(), 2.125);
assert_eq!(ev("2 1/8,5", Kind::Unitless).unwrap(), 2.0 / 8.5);
// Long numerators and denominators are compared as digits, not as rounded floats.
assert!((ev("2 9007199254740992/9007199254740993", Kind::Unitless).unwrap() - 3.0).abs() < 1e-12);
assert_eq!(ev("2 007/8", Kind::Unitless).unwrap(), 2.875);
assert_eq!(ev("-2 1/4", Kind::Unitless).unwrap(), -2.25);
// Not a proper fraction of whole numbers after a whole number: as before.
for (s, v) in [("2 3/2", 3.0), ("2.5 1/2", 1.25), ("2 1/8.5", 2.0 / 8.5), ("2 1 / 8", 0.25), ("2 1/0", f64::INFINITY), ("1/8", 0.125)] {
assert_eq!(ev(s, Kind::Unitless).ok(), Some(v).filter(|v| v.is_finite()), "{s}");
}
}
#[test]
fn arithmetic_and_units() {
assert_eq!(ev("10", Kind::Length).unwrap(), 10.0);
+3 -1
View File
@@ -19,7 +19,9 @@ Expressions are unit-aware: `2 * width + 5 mm`, `angle / 2`, `sqrt(area)`, `1 in
`+ - * / ^`, parentheses, `sin cos tan asin acos atan atan2 sqrt abs min max floor ceil round mod
trunc` and unit conversion. A decimal comma works too, outside parentheses: `2,5 mm` is
`2.5 mm` (inside a function's parentheses the comma separates its arguments, so write
`max(2.5, 3)` there). A length where an angle is expected (or the other way round) is an
`max(2.5, 3)` there). A mixed number is one value, as imperial sizes are written: `2 1/8"` and
`3 7/16 in` are 2.125 in and 3.4375 in (a whole number, a space, then a proper fraction of whole
numbers). A length where an angle is expected (or the other way round) is an
error, not a silent conversion. Cycles are reported with their path (`a → b → a`), renaming a
parameter updates every expression, dimension and feature input that uses it, and a parameter
in use can't be deleted (the error lists its users).