diff --git a/frontend/src/app.css b/frontend/src/app.css index 163bff8..2111085 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -653,6 +653,15 @@ button.ghost:hover:not(:disabled) { overflow-wrap: anywhere; } +/* + * A grid whose track is wider than a narrow screen. + * + * `minmax(28rem, 1fr)` makes a track that is at least 28rem wide, so on a + * phone the column stays 448 pixels wide and the page scrolls sideways. The + * `min(…, 100%)` lets the track shrink to the width it actually has, so a + * narrow screen gets one narrow column rather than one wide one. + */ + /* * A card on the interface index shows what a crate is, in one or two lines. * @@ -712,7 +721,7 @@ button.ghost:hover:not(:disabled) { } .grid.metrics { - grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(min(11rem, 100%), 1fr)); } .metric { @@ -987,7 +996,7 @@ tbody tr:hover { .scalars { display: grid; - grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(min(15rem, 100%), 1fr)); gap: 0.6rem 1.5rem; margin: 1rem 0; } @@ -1123,7 +1132,7 @@ tbody tr:hover { items stays a reasonable height. */ .item-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(28rem, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(min(28rem, 100%), 1fr)); gap: 0.6rem; align-items: start; } @@ -1229,7 +1238,7 @@ tbody tr:hover { .crate-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(min(20rem, 100%), 1fr)); gap: 0.75rem; margin-top: 1rem; } @@ -1455,7 +1464,7 @@ tbody tr:hover { .form-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(min(16rem, 100%), 1fr)); gap: 0.9rem 1.25rem; } diff --git a/src/docs/mathml.rs b/src/docs/mathml.rs index 18b3b9a..fa63c44 100644 --- a/src/docs/mathml.rs +++ b/src/docs/mathml.rs @@ -516,6 +516,13 @@ fn emit(node: &Node) -> String { (Some(single), None) if single.is_ascii_punctuation() => { format!("{}", escape(text)) } + // An operator written with its spaces, as the ` - ` between + // two terms. It is dropped by the browser for the same reason + // as a single one, which loses the sign of a difference: `1 - + // e^2` came out as `1e^2`. + _ if is_operator_text(text) => { + format!("{}", escape(text.trim())) + } _ => escape(text), } } @@ -613,20 +620,27 @@ pub fn render_plain(text: &str) -> Option { } // A block qualifies as a formula when at least one of its lines is an - // equation. Brackets in these documents are a matrix or a vector, which - // this renderer does not lay out, so such a block stays a code block. - if lines - .iter() - .any(|line| line.contains('[') || line.contains(']')) - || !lines.iter().any(|line| line.contains('=')) - { + // equation. A line that turns out to be a matrix or a vector is shown as + // the text it was written, and does not stop the equations beside it from + // being set as formulas. + if !lines.iter().any(|line| line.contains('=')) { return None; } let mut rows = String::new(); let mut read = 0; for line in &lines { - let (equation, note) = split_trailing_note(line); + // A bracket in these documents is a matrix or a vector, which this + // renderer does not lay out. Such a line is shown as the text it was + // written, and the equations beside it are still set as formulas: a + // block often gives an equation and then the vector it acts on. + if line.contains('[') || line.contains(']') { + rows.push_str(""); + rows.push_str(&escape(&clean_note(line))); + rows.push_str(""); + continue; + } + let (equation, note) = split_trailing_note(line, reads_whole); let tokens = tokenize_ascii(equation); let mut cursor = 0; let nodes = parse_sum(&tokens, &mut cursor); @@ -676,30 +690,52 @@ fn clean_note(note: &str) -> String { note.split_whitespace().collect::>().join(" ") } +/// Reports whether a line reads out as a formula in its entirety. +fn reads_whole(line: &str) -> bool { + let tokens = tokenize_ascii(line); + let mut cursor = 0; + parse_sum(&tokens, &mut cursor); + cursor == tokens.len() +} + /// Splits a trailing parenthetical note from an equation. /// -/// A note is a phrase rather than part of the equation, so -/// `N = a / sqrt(1 - e^2 sin^2 phi) (prime vertical radius)` is an equation -/// with a note and not an equation with a bracket. A parenthesis holding a -/// space is read as a note, because the expressions in these documents do not -/// write a space inside their own brackets. -fn split_trailing_note(line: &str) -> (&str, Option<&str>) { - if !line.ends_with(')') { - return (line, None); +/// A note is a phrase about the equation, as in `(prime vertical radius)`, +/// and the authors wrote it out of the way of the equation: it starts in a +/// column of its own, several spaces along. That spacing is what tells it +/// apart from a bracket that belongs to the equation, such as the +/// `(cos theta)` in `gamma = k * R^alpha / (cos theta)`, which follows a +/// single space. +/// +/// The spacing alone is not trusted: the rest of the line must also read out +/// as an expression without the note. A parenthesis of several words reads as +/// a product of names, so `prime vertical radius` on its own would be +/// accepted by the reader; what marks it is that it is set apart. +fn split_trailing_note(line: &str, reads: impl Fn(&str) -> bool) -> (&str, Option<&str>) { + let trimmed = line.trim_end(); + if !trimmed.ends_with(')') { + return (trimmed, None); } - match line.rfind('(') { - Some(start) if start > 0 => { - let inside = &line[start + 1..line.len() - 1]; - let is_phrase = inside.contains(' ') - && !inside.contains(['=', '/', '^', '_', '(', '+']) - && !inside.chars().any(|c| c.is_ascii_digit()); - if is_phrase { - return (&line[..start], Some(inside)); - } - (line, None) - } - _ => (line, None), + let Some(start) = trimmed.rfind('(') else { + return (trimmed, None); + }; + if start == 0 { + return (trimmed, None); } + // The gap before the parenthesis, counted back to the last non-space. + let gap = trimmed[..start] + .chars() + .rev() + .take_while(|c| c.is_whitespace()) + .count(); + if gap < 2 { + return (trimmed, None); + } + let inside = &trimmed[start + 1..trimmed.len() - 1]; + if !inside.contains(' ') || !reads(trimmed[..start].trim_end()) { + return (trimmed, None); + } + (trimmed[..start].trim_end(), Some(inside)) } /// One unit of a plain formula. @@ -772,6 +808,20 @@ fn tokenize_ascii(text: &str) -> Vec { tokens } +/// Reports whether a piece of text is an operator written with its spacing. +/// +/// The reader builds these for the operators between two terms, so they hold +/// spaces around a punctuation mark and nothing else. +fn is_operator_text(text: &str) -> bool { + let trimmed = text.trim(); + // The minus the reader writes is the typographic one, U+2212, so the + // test is for anything that is neither a letter, a digit nor a space. + !trimmed.is_empty() + && trimmed + .chars() + .all(|character| !character.is_alphanumeric() && !character.is_whitespace()) +} + /// The functions the documents write in plain ASCII. fn ascii_function(name: &str) -> bool { matches!( @@ -799,15 +849,33 @@ fn ascii_function(name: &str) -> bool { ) } +/// Parses until a closing vertical bar, which ends an absolute value. +/// +/// The bar is not a bracket as such: it closes only the bar that opened the +/// run, and the tokens between are an ordinary expression. +fn parse_sum_until_bar(tokens: &[Ascii], cursor: &mut usize) -> Vec { + let mut row = parse_product(tokens, cursor, true); + while let Some(Ascii::Sign(operator @ ('+' | '-'))) = tokens.get(*cursor) { + let operator = *operator; + *cursor += 1; + let right = parse_product(tokens, cursor, true); + row.push(Node::Text( + if operator == '+' { " + " } else { " − " }.to_string(), + )); + row.extend(right); + } + row +} + /// Parses a sum: factors joined by `+`, `-`, `=` or a comparison. fn parse_sum(tokens: &[Ascii], cursor: &mut usize) -> Vec { - let mut row = parse_product(tokens, cursor); + let mut row = parse_product(tokens, cursor, false); while let Some(Ascii::Sign(operator @ ('+' | '-' | '=' | '<' | '>' | ','))) = tokens.get(*cursor) { let operator = *operator; *cursor += 1; - let right = parse_product(tokens, cursor); + let right = parse_product(tokens, cursor, false); row.push(Node::Text(match operator { '=' => " = ".to_string(), '<' => " < ".to_string(), @@ -829,7 +897,12 @@ fn parse_sum(tokens: &[Ascii], cursor: &mut usize) -> Vec { /// `2 f` or `mu (2/r_1 - 1/a_t)`, so a factor that follows another with /// nothing between them continues the product. The star, where it is written, /// is dropped. -fn parse_product(tokens: &[Ascii], cursor: &mut usize) -> Vec { +/// `inside_bars` stops the loop at a closing vertical bar. +/// +/// Inside an absolute value a bar is the end of the expression, while outside +/// one it opens the next factor of a product, as the `| x|` in +/// `| f'(x) | * | x|`. +fn parse_product(tokens: &[Ascii], cursor: &mut usize, inside_bars: bool) -> Vec { let mut row = parse_factor(tokens, cursor); loop { match tokens.get(*cursor).cloned() { @@ -852,6 +925,9 @@ fn parse_product(tokens: &[Ascii], cursor: &mut usize) -> Vec { Some(Ascii::Word(_)) | Some(Ascii::Number(_)) | Some(Ascii::Sign('(')) => { row.extend(parse_factor(tokens, cursor)); } + Some(Ascii::Sign('|')) if !inside_bars => { + row.extend(parse_factor(tokens, cursor)); + } _ => return row, } } @@ -870,13 +946,15 @@ fn parse_factor(tokens: &[Ascii], cursor: &mut usize) -> Vec { // A brace holds a group without showing brackets; a parenthesis // shows them, because it is written in the source. if open == '{' { - vec![Node::Row(inner)] + vec![attach_power(Node::Row(inner), tokens, cursor)] } else { - vec![Node::Row(vec![ + let group = Node::Row(vec![ Node::Text("(".to_string()), Node::Row(inner), Node::Text(")".to_string()), - ])] + ]); + // A bracketed group may be raised, as in `(4 pi d f / c)^2`. + vec![attach_power(group, tokens, cursor)] } } // A word in a script that is not the Latin one is a label written in @@ -923,6 +1001,23 @@ fn parse_factor(tokens: &[Ascii], cursor: &mut usize) -> Vec { let base = ascii_atom(&number, tokens, cursor); vec![base] } + // A vertical bar opens an absolute value, which the documents write + // as `| f'(x) |` and as `|r|^3`. The closing bar is written with + // its partner, because which bar closes depends on the nesting. + Some(Ascii::Sign('|')) => { + *cursor += 1; + let inner = parse_sum_until_bar(tokens, cursor); + if matches!(tokens.get(*cursor), Some(Ascii::Sign('|'))) { + *cursor += 1; + } + // The magnitude may be raised, as in `|r|^3`. + let magnitude = Node::Row(vec![ + Node::Text("|".to_string()), + Node::Row(inner), + Node::Text("|".to_string()), + ]); + vec![attach_power(magnitude, tokens, cursor)] + } other => { // Any other token is read past, so that the parse always makes // progress rather than stalling on it. @@ -1202,4 +1297,67 @@ mod tests { fn a_block_with_no_readable_equation_is_left_alone() { assert!(render_plain("leap(y) = (y mod 4 == 0) and (y mod 100 != 0").is_none()); } + + #[test] + fn an_absolute_value_may_hold_a_product() { + // `| f'(x) | * | x| / | f(x) |` is one expression: a bar inside it + // opens the next factor rather than closing the one before. + let html = render_plain("kappa = | f'(x) | * | x| / | f(x) |").expect("a formula"); + assert!(!html.contains("\u{2212}"), + "the minus was lost: {html}" + ); + assert!(html.contains("="), "the equals was lost: {html}"); + } + + #[test] + fn an_equals_between_two_equations_is_kept() { + let html = render_plain("r = p / (1 + e cos nu), p = a (1 - e^2)").expect("a formula"); + assert!(html.contains("="), "{html}"); + assert!(html.contains("\u{2212}"), "{html}"); + } }