feat(math): exact evaluation of handwritten expressions (Issue #46, stage 1)

New module math.rs: solve() reads a recognised expression such as 2^4,
2,5 · 3 or (x + 3)² and returns its result as text (16, 7,5,
x² + 6x + 9). Own parser with exact fractions and polynomial expansion
instead of evalexpr, as implicit multiplication and expanding need it
anyway. Not used by the UI yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
haraldandClaude Opus 5.5 committed 2026-10-28 12:34:32 +01:00
1 parent 8af10fe542
commit cd490dd9a7
4 files changed
+659

No files matched your search

+1
View File
@@ -191,6 +191,7 @@ src/
├── storage.rs # Laden/Speichern (atomar), Notizbücher, Papierkorb, Speicherort
├── disk_watch.rs # Änderungen anderer Geräte übernehmen (Sync-Clients)
├── library.rs # Ordner der Notizbücher (library.json)
├── math.rs # Exaktes Ausrechnen handgeschriebener Formeln (Issue #46)
├── autosave.rs # Speichern im Hintergrund
├── export.rs # PDF-Export
├── page_export.rs # Seite als PNG/SVG, Auswahl als Bild
+1
View File
@@ -193,6 +193,7 @@ src/
├── storage.rs # Loading/saving (atomic), notebooks, trash, storage location
├── disk_watch.rs # Taking over changes from other devices (sync clients)
├── library.rs # Notebook folders (library.json)
├── math.rs # Exact evaluation of handwritten math (Issue #46)
├── autosave.rs # Saving in the background
├── export.rs # PDF export
├── page_export.rs # Page as PNG/SVG, selection as image
+1
View File
@@ -18,6 +18,7 @@ pub mod i18n;
pub mod image_layer;
pub mod import;
pub mod library;
pub mod math;
pub mod osk;
pub mod page_export;
pub mod paths;
+656
View File
@@ -0,0 +1,656 @@
//! Exact evaluation of handwritten math (Issue #46, stage 1).
//!
//! Takes the text of a recognised expression such as `2^4`, `2,5 · 3` or `(x + 3)²`
//! (optionally ending with `=`) and returns its result as text: `16`, `7,5` and
//! `x² + 6x + 9`. Numbers are exact fractions, so `1 : 3 · 3` gives `1`, and expressions
//! with variables are expanded into polynomials.
//!
//! The input is lenient towards how math is written by hand: decimal comma or point,
//! `·`, `×`, `*` and `:`, `÷`, `/`, superscript exponents, Unicode minus signs and implicit
//! multiplication (`2x`, `3(x + 1)`, `(a + b)(a − b)`).
use std::cmp::Ordering;
use std::collections::BTreeMap;
use std::fmt::Write as _;
/// Largest exponent for a polynomial that is not a plain number
const MAX_POLYNOMIAL_EXPONENT: u32 = 32;
/// Largest exponent for a plain number
const MAX_NUMBER_EXPONENT: u32 = 1000;
/// Results with more terms are not shown (they would not fit next to the `=` anyway)
const MAX_TERMS: usize = 64;
/// Terminating decimals with more digits after the separator are shown as fractions
const MAX_DECIMALS: u32 = 12;
/// Computes the result of `expression`, written for display after its `=` sign.
///
/// `decimal_comma` selects the decimal separator of the result. Returns `None` if the
/// expression cannot be read or computed (e.g. division by zero, a variable in a
/// denominator or overflow), and also if there is nothing to compute, i.e. the result would
/// just repeat the input (`5`, `x`, `2x`).
pub fn solve(expression: &str, decimal_comma: bool) -> Option<String> {
let tokens = tokenize(expression)?;
let ast = Parser::new(&tokens).parse()?;
if matches!(ast, Expr::Number(_) | Expr::Variable(_)) {
return None;
}
let result = ast.eval()?;
if result.terms.len() > MAX_TERMS {
return None;
}
let text = result.format(decimal_comma);
let compact = |s: &str| -> String {
s.chars()
.filter(|c| !c.is_whitespace())
.map(|c| if c == '−' { '-' } else { c })
.collect()
};
if compact(&text) == compact(expression.trim().trim_end_matches('=')) {
return None;
}
Some(text)
}
// --- Rational numbers ---------------------------------------------------------------------
/// Exact fraction `num / den` with `den > 0`, always reduced
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Rational {
num: i128,
den: i128,
}
fn gcd(mut a: i128, mut b: i128) -> i128 {
a = a.abs();
b = b.abs();
while b != 0 {
(a, b) = (b, a % b);
}
a
}
impl Rational {
const ZERO: Rational = Rational { num: 0, den: 1 };
const ONE: Rational = Rational { num: 1, den: 1 };
fn new(num: i128, den: i128) -> Option<Rational> {
// i128::MIN has no positive counterpart, which `gcd` and `neg` need
if den == 0 || num == i128::MIN || den == i128::MIN {
return None;
}
let g = gcd(num, den).max(1);
let (mut num, mut den) = (num / g, den / g);
if den < 0 {
num = num.checked_neg()?;
den = den.checked_neg()?;
}
Some(Rational { num, den })
}
fn integer(n: i128) -> Rational {
Rational { num: n, den: 1 }
}
fn is_zero(self) -> bool {
self.num == 0
}
fn add(self, other: Rational) -> Option<Rational> {
let g = gcd(self.den, other.den);
let den = (self.den / g).checked_mul(other.den)?;
let num = self
.num
.checked_mul(other.den / g)?
.checked_add(other.num.checked_mul(self.den / g)?)?;
Rational::new(num, den)
}
fn neg(self) -> Option<Rational> {
Some(Rational {
num: self.num.checked_neg()?,
den: self.den,
})
}
fn mul(self, other: Rational) -> Option<Rational> {
// Cross-reducing first keeps the intermediate products small
let g1 = gcd(self.num, other.den).max(1);
let g2 = gcd(other.num, self.den).max(1);
let num = (self.num / g1).checked_mul(other.num / g2)?;
let den = (self.den / g2).checked_mul(other.den / g1)?;
Rational::new(num, den)
}
fn recip(self) -> Option<Rational> {
Rational::new(self.den, self.num)
}
fn pow(self, exponent: i128) -> Option<Rational> {
if exponent.unsigned_abs() > MAX_NUMBER_EXPONENT as u128 {
return None;
}
let base = if exponent < 0 { self.recip()? } else { self };
let mut result = Rational::ONE;
for _ in 0..exponent.unsigned_abs() {
result = result.mul(base)?;
}
Some(result)
}
fn as_integer(self) -> Option<i128> {
(self.den == 1).then_some(self.num)
}
/// The number without its sign: as a terminating decimal if it has one with at most
/// [`MAX_DECIMALS`] digits, otherwise as a fraction `p/q`.
fn format_abs(self, decimal_comma: bool) -> String {
let num = self.num.unsigned_abs();
let den = self.den.unsigned_abs();
let whole = num / den;
let mut rest = num % den;
if rest == 0 {
return whole.to_string();
}
let mut reduced = den;
let mut decimals = 0;
for factor in [2, 5] {
while reduced.is_multiple_of(factor) {
reduced /= factor;
}
}
if reduced == 1 {
let mut digits = String::new();
while rest != 0 && decimals <= MAX_DECIMALS {
let Some(shifted) = rest.checked_mul(10) else {
break;
};
rest = shifted;
digits.push(char::from(b'0' + (rest / den) as u8));
rest %= den;
decimals += 1;
}
if rest == 0 {
let separator = if decimal_comma { ',' } else { '.' };
return format!("{whole}{separator}{digits}");
}
}
format!("{num}/{den}")
}
}
// --- Polynomials --------------------------------------------------------------------------
/// Product of variables with their exponents, e.g. `a²b` = `{a: 2, b: 1}`
type Monomial = BTreeMap<char, u32>;
/// Sum of monomials with exact coefficients; a plain number is the empty monomial
#[derive(Debug, Clone, PartialEq, Eq)]
struct Polynomial {
terms: BTreeMap<Monomial, Rational>,
}
impl Polynomial {
fn constant(value: Rational) -> Polynomial {
let mut terms = BTreeMap::new();
if !value.is_zero() {
terms.insert(Monomial::new(), value);
}
Polynomial { terms }
}
fn variable(name: char) -> Polynomial {
let mut terms = BTreeMap::new();
terms.insert(Monomial::from([(name, 1)]), Rational::ONE);
Polynomial { terms }
}
/// The value if this is a plain number
fn as_constant(&self) -> Option<Rational> {
match self.terms.len() {
0 => Some(Rational::ZERO),
1 => self.terms.get(&Monomial::new()).copied(),
_ => None,
}
}
fn add_term(&mut self, monomial: Monomial, coefficient: Rational) -> Option<()> {
let sum = match self.terms.get(&monomial) {
Some(existing) => existing.add(coefficient)?,
None => coefficient,
};
if sum.is_zero() {
self.terms.remove(&monomial);
} else {
self.terms.insert(monomial, sum);
}
Some(())
}
fn add(mut self, other: &Polynomial) -> Option<Polynomial> {
for (monomial, coefficient) in &other.terms {
self.add_term(monomial.clone(), *coefficient)?;
}
Some(self)
}
fn neg(&self) -> Option<Polynomial> {
let mut terms = BTreeMap::new();
for (monomial, coefficient) in &self.terms {
terms.insert(monomial.clone(), coefficient.neg()?);
}
Some(Polynomial { terms })
}
fn mul(&self, other: &Polynomial) -> Option<Polynomial> {
let mut product = Polynomial::constant(Rational::ZERO);
for (m1, c1) in &self.terms {
for (m2, c2) in &other.terms {
let mut monomial = m1.clone();
for (&name, &exponent) in m2 {
let e = monomial.entry(name).or_insert(0);
*e = e.checked_add(exponent)?;
}
product.add_term(monomial, c1.mul(*c2)?)?;
}
if product.terms.len() > MAX_TERMS * 4 {
return None;
}
}
Some(product)
}
fn div(&self, divisor: &Polynomial) -> Option<Polynomial> {
let factor = divisor.as_constant()?.recip()?;
self.mul(&Polynomial::constant(factor))
}
fn pow(&self, exponent: &Polynomial) -> Option<Polynomial> {
let exponent = exponent.as_constant()?.as_integer()?;
if let Some(base) = self.as_constant() {
return Some(Polynomial::constant(base.pow(exponent)?));
}
if !(0..=MAX_POLYNOMIAL_EXPONENT as i128).contains(&exponent) {
return None;
}
let mut result = Polynomial::constant(Rational::ONE);
for _ in 0..exponent {
result = result.mul(self)?;
}
Some(result)
}
/// E.g. `x² + 6x + 9`, `a² − b²`, `7,5`: highest degree first, then in alphabetical
/// order of the variables (`a² + 2ab + b²`).
fn format(&self, decimal_comma: bool) -> String {
if self.terms.is_empty() {
return "0".to_owned();
}
let degree = |m: &Monomial| m.values().sum::<u32>();
let mut terms: Vec<_> = self.terms.iter().collect();
terms.sort_by(|(a, _), (b, _)| degree(b).cmp(&degree(a)).then_with(|| graded_order(a, b)));
let mut out = String::new();
for (index, (monomial, coefficient)) in terms.into_iter().enumerate() {
let negative = coefficient.num < 0;
match (index, negative) {
(0, true) => out.push('−'),
(0, false) => {}
(_, true) => out.push_str(" − "),
(_, false) => out.push_str(" + "),
}
let magnitude = coefficient.format_abs(decimal_comma);
if monomial.is_empty() {
out.push_str(&magnitude);
continue;
}
if magnitude.contains('/') {
let _ = write!(out, "({magnitude})");
} else if magnitude != "1" {
out.push_str(&magnitude);
}
for (&name, &exponent) in monomial {
out.push(name);
if exponent > 1 {
out.push_str(&superscript(exponent));
}
}
}
out
}
}
/// Order within the same degree: the monomial with the higher power of the alphabetically
/// first variable comes first (`a²`, `ab`, `b²`).
fn graded_order(a: &Monomial, b: &Monomial) -> Ordering {
let names: std::collections::BTreeSet<char> = a.keys().chain(b.keys()).copied().collect();
for name in names {
let ea = a.get(&name).copied().unwrap_or(0);
let eb = b.get(&name).copied().unwrap_or(0);
match eb.cmp(&ea) {
Ordering::Equal => continue,
other => return other,
}
}
Ordering::Equal
}
const SUPERSCRIPT_DIGITS: [char; 10] = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹'];
fn superscript(n: u32) -> String {
n.to_string()
.chars()
.map(|d| SUPERSCRIPT_DIGITS[d.to_digit(10).unwrap_or(0) as usize])
.collect()
}
// --- Reading the expression ---------------------------------------------------------------
#[derive(Debug, Clone, PartialEq)]
enum Token {
Number(Rational),
Variable(char),
Plus,
Minus,
Times,
Divide,
Power,
Open,
Close,
}
fn tokenize(input: &str) -> Option<Vec<Token>> {
let input = input.trim().trim_end_matches('=').trim_end();
let chars: Vec<char> = input.chars().collect();
let mut tokens = Vec::new();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c.is_ascii_digit() {
let (value, next) = read_number(&chars, i)?;
tokens.push(Token::Number(value));
i = next;
continue;
}
if superscript_value(c).is_some() || c == '⁻' {
// A run of superscript characters is the exponent: x²³ = x^23, 2⁻¹ = 2^(-1)
let negative = c == '⁻';
let start = if negative { i + 1 } else { i };
let mut end = start;
let mut value: i128 = 0;
while let Some(digit) = chars.get(end).and_then(|&d| superscript_value(d)) {
value = value.checked_mul(10)?.checked_add(digit)?;
end += 1;
}
if end == start {
return None;
}
tokens.push(Token::Power);
if negative {
tokens.push(Token::Minus);
}
tokens.push(Token::Number(Rational::integer(value)));
i = end;
continue;
}
let token = match c {
_ if c.is_whitespace() => None,
'+' => Some(Token::Plus),
'-' | '−' | '–' => Some(Token::Minus),
'*' | '·' | '⋅' | '∙' | '×' => Some(Token::Times),
'/' | ':' | '÷' => Some(Token::Divide),
'^' => Some(Token::Power),
'(' | '[' | '{' => Some(Token::Open),
')' | ']' | '}' => Some(Token::Close),
_ if c.is_ascii_alphabetic() => Some(Token::Variable(c)),
_ => return None,
};
tokens.extend(token);
i += 1;
}
Some(tokens)
}
fn superscript_value(c: char) -> Option<i128> {
SUPERSCRIPT_DIGITS
.iter()
.position(|&d| d == c)
.map(|p| p as i128)
}
/// Reads `12`, `2,5` or `0.125` from `start`; returns the value and the index after it
fn read_number(chars: &[char], start: usize) -> Option<(Rational, usize)> {
let mut i = start;
let mut num: i128 = 0;
let mut den: i128 = 1;
let mut in_fraction = false;
while i < chars.len() {
let c = chars[i];
if let Some(digit) = c.to_digit(10) {
num = num.checked_mul(10)?.checked_add(digit as i128)?;
if in_fraction {
den = den.checked_mul(10)?;
}
} else if (c == ',' || c == '.')
&& !in_fraction
&& chars.get(i + 1).is_some_and(|d| d.is_ascii_digit())
{
in_fraction = true;
} else {
break;
}
i += 1;
}
Some((Rational::new(num, den)?, i))
}
#[derive(Debug, Clone, PartialEq)]
enum Expr {
Number(Rational),
Variable(char),
Neg(Box<Expr>),
Add(Box<Expr>, Box<Expr>),
Sub(Box<Expr>, Box<Expr>),
Mul(Box<Expr>, Box<Expr>),
Div(Box<Expr>, Box<Expr>),
Pow(Box<Expr>, Box<Expr>),
}
impl Expr {
fn eval(&self) -> Option<Polynomial> {
Some(match self {
Expr::Number(value) => Polynomial::constant(*value),
Expr::Variable(name) => Polynomial::variable(*name),
Expr::Neg(e) => e.eval()?.neg()?,
Expr::Add(a, b) => a.eval()?.add(&b.eval()?)?,
Expr::Sub(a, b) => a.eval()?.add(&b.eval()?.neg()?)?,
Expr::Mul(a, b) => a.eval()?.mul(&b.eval()?)?,
Expr::Div(a, b) => a.eval()?.div(&b.eval()?)?,
Expr::Pow(a, b) => a.eval()?.pow(&b.eval()?)?,
})
}
}
/// Recursive descent with the usual precedence: `+ −` < `· :` and implicit
/// multiplication < unary minus < `^` (right-associative, so `−2² = −4` and `2^3^2 = 2^9`).
struct Parser<'a> {
tokens: &'a [Token],
pos: usize,
}
impl<'a> Parser<'a> {
fn new(tokens: &'a [Token]) -> Self {
Parser { tokens, pos: 0 }
}
fn parse(mut self) -> Option<Expr> {
let expr = self.sum()?;
(self.pos == self.tokens.len()).then_some(expr)
}
fn peek(&self) -> Option<&Token> {
self.tokens.get(self.pos)
}
fn eat(&mut self, token: &Token) -> bool {
let found = self.peek() == Some(token);
if found {
self.pos += 1;
}
found
}
fn sum(&mut self) -> Option<Expr> {
let mut left = self.product()?;
loop {
if self.eat(&Token::Plus) {
left = Expr::Add(Box::new(left), Box::new(self.product()?));
} else if self.eat(&Token::Minus) {
left = Expr::Sub(Box::new(left), Box::new(self.product()?));
} else {
return Some(left);
}
}
}
fn product(&mut self) -> Option<Expr> {
let mut left = self.unary()?;
loop {
if self.eat(&Token::Times) {
left = Expr::Mul(Box::new(left), Box::new(self.unary()?));
} else if self.eat(&Token::Divide) {
left = Expr::Div(Box::new(left), Box::new(self.unary()?));
} else if matches!(self.peek(), Some(Token::Variable(_) | Token::Open)) {
// Implicit multiplication: 2x, 3(x + 1), (a + b)(a − b). Two numbers in a
// row are not multiplied, they are more likely a misread.
left = Expr::Mul(Box::new(left), Box::new(self.power()?));
} else {
return Some(left);
}
}
}
fn unary(&mut self) -> Option<Expr> {
if self.eat(&Token::Minus) {
return Some(Expr::Neg(Box::new(self.unary()?)));
}
if self.eat(&Token::Plus) {
return self.unary();
}
self.power()
}
fn power(&mut self) -> Option<Expr> {
let base = self.atom()?;
if self.eat(&Token::Power) {
return Some(Expr::Pow(Box::new(base), Box::new(self.unary()?)));
}
Some(base)
}
fn atom(&mut self) -> Option<Expr> {
let token = self.peek()?.clone();
self.pos += 1;
match token {
Token::Number(value) => Some(Expr::Number(value)),
Token::Variable(name) => Some(Expr::Variable(name)),
Token::Open => {
let inner = self.sum()?;
self.eat(&Token::Close).then_some(inner)
}
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn de(expression: &str) -> Option<String> {
solve(expression, true)
}
#[test]
fn arithmetic() {
assert_eq!(de("2^4 ="), Some("16".into()));
assert_eq!(de("2⁴="), Some("16".into()));
assert_eq!(de("1 + 2 · 3"), Some("7".into()));
assert_eq!(de("(1 + 2) × 3"), Some("9".into()));
assert_eq!(de("7 − 10"), Some("−3".into()));
assert_eq!(de("−2^2"), Some("−4".into()));
assert_eq!(de("(−2)²"), Some("4".into()));
assert_eq!(de("2^3^2"), Some("512".into()));
assert_eq!(de("10 : 4"), Some("2,5".into()));
assert_eq!(de("10 ÷ 4"), Some("2,5".into()));
assert_eq!(de("3 * [2 + 1]"), Some("9".into()));
}
#[test]
fn decimals_and_fractions() {
assert_eq!(de("2,5 · 3"), Some("7,5".into()));
assert_eq!(solve("2.5 * 3", false), Some("7.5".into()));
assert_eq!(de("0,1 + 0,2"), Some("0,3".into()));
assert_eq!(de("1 : 3"), Some("1/3".into()));
assert_eq!(de("1 : 3 · 3"), Some("1".into()));
assert_eq!(de("2^-2"), Some("0,25".into()));
assert_eq!(de("2⁻¹"), Some("0,5".into()));
assert_eq!(de("1 : 8"), Some("0,125".into()));
}
#[test]
fn polynomials() {
assert_eq!(de("(x + 3)^2 ="), Some("x² + 6x + 9".into()));
assert_eq!(de("(x+3)²"), Some("x² + 6x + 9".into()));
assert_eq!(de("(a + b)²"), Some("a² + 2ab + b²".into()));
assert_eq!(de("(a + b)(a − b)"), Some("a² − b²".into()));
assert_eq!(de("(a − b)²"), Some("a² − 2ab + b²".into()));
assert_eq!(de("2x + 3x"), Some("5x".into()));
assert_eq!(de("x · x"), Some("x²".into()));
assert_eq!(de("3(x + 1)"), Some("3x + 3".into()));
assert_eq!(de("(x + 1)^3"), Some("x³ + 3x² + 3x + 1".into()));
assert_eq!(de("(2x − 1)²"), Some("4x² − 4x + 1".into()));
assert_eq!(de("x − x"), Some("0".into()));
assert_eq!(de("(x + 1) : 2"), Some("0,5x + 0,5".into()));
assert_eq!(de("x : 3"), Some("(1/3)x".into()));
assert_eq!(de("−(x + 1)"), Some("−x − 1".into()));
assert_eq!(
de("(x+1)^10").map(|s| s.starts_with("x¹⁰ + 10x⁹")),
Some(true)
);
}
#[test]
fn nothing_to_compute() {
assert_eq!(de("5"), None);
assert_eq!(de("x ="), None);
assert_eq!(de("2x"), None);
assert_eq!(de("x² + 1"), None);
assert_eq!(de("2,5"), None);
}
#[test]
fn unreadable_or_not_computable() {
assert_eq!(de(""), None);
assert_eq!(de("="), None);
assert_eq!(de("1 +"), None);
assert_eq!(de("(1 + 2"), None);
assert_eq!(de("1 + 2)"), None);
assert_eq!(de("2 3"), None);
assert_eq!(de("1 : 0"), None);
assert_eq!(de("0^-1"), None);
assert_eq!(de("1 : x"), None);
assert_eq!(de("x^y"), None);
assert_eq!(de("x^0,5"), None);
assert_eq!(de("2 ? 3"), None);
assert_eq!(de("1,2,3"), None);
}
#[test]
fn overflow_and_limits() {
assert_eq!(de("10^100"), None);
assert_eq!(de("2^100000"), None);
assert_eq!(de("(x + 1)^33"), None);
assert_eq!(de("(a+b+c+d+e)^8"), None);
assert_eq!(de("99999999999999999999999999999999999999999999 + 1"), None);
assert_eq!(de("2^64"), Some("18446744073709551616".into()));
}
}