diff --git a/src/docs/mathml.rs b/src/docs/mathml.rs index 41994a6..f42cdeb 100644 --- a/src/docs/mathml.rs +++ b/src/docs/mathml.rs @@ -490,42 +490,63 @@ fn parse_command(name: &str, tokens: &[Token], cursor: &mut usize) -> Vec } } +/// Writes a piece of text as the MathML element its kind calls for. +/// +/// Every branch produces an element, and the last one produces ``. +/// Text left bare inside a row is dropped by the browser, which loses part of +/// a formula without any sign that something is missing: a multi-digit number +/// such as the 4800 in `year + 4800` simply did not appear, and neither did +/// the name `delta_v` before it was given an element. +fn classify(text: &str) -> String { + if text.is_empty() { + return String::new(); + } + let first = text.chars().next().unwrap_or(' '); + let all = |predicate: fn(char) -> bool| text.chars().all(predicate); + + // A space is a space. It is written as an explicit one rather than as + // text, because a run of spaces between two elements carries no glyph. + if all(char::is_whitespace) { + return "".to_string(); + } + // A variable is one letter, whatever script it is written in. Greek is + // set as an identifier so that it is drawn as a variable rather than as + // upright text. + if text.chars().count() == 1 && first.is_alphabetic() { + return format!("{}", escape(text)); + } + + // A number starts with a digit. It may hold a decimal point or an + // exponent, as in `1.5` or `1e-3`. + if first.is_ascii_digit() + && all(|c| c.is_ascii_digit() || c == '.' || c == 'e' || c == 'E' || c == '-' || c == '+') + { + return format!("{}", escape(text)); + } + // A name of Latin letters is an identifier, whether it is one letter or + // several, as in `a` or `delta_v`. + if all(|c| c.is_ascii_alphabetic()) { + return format!("{}", escape(text)); + } + // An operator, including one written with its spacing, as in ` = `. + // The spaces are the operator's own width, so they are trimmed before + // the test rather than counted against it. + let trimmed = text.trim(); + if !trimmed.is_empty() && trimmed.chars().all(|c| !c.is_alphanumeric()) { + return format!("{}", escape(trimmed)); + } + // Anything else is text: a label in another script, or a phrase. It is + // set upright and kept whole rather than being read as variables. + format!("{}", escape(text)) +} + /// Writes a node as MathML. fn emit(node: &Node) -> String { match node { // MathML distinguishes an identifier, a number and operators, and a // browser uses the element to choose the font: an upright digit in a // serif face reads as a number rather than as a variable. - Node::Text(text) => { - let mut characters = text.chars(); - match (characters.next(), characters.next()) { - (Some(single), None) if single.is_numeric() => { - format!("{}", escape(text)) - } - // A name of more than one letter is an identifier too. Bare - // text inside a row is dropped by the browser, which is why - // a left-hand side such as `delta_v` was showing as nothing at - // all rather than as its name. - _ if text.chars().all(|c| c.is_alphabetic()) => { - format!("{}", escape(text)) - } - // An operator is written as one rather than as bare text. A - // browser sets text inside a row as ordinary glyphs with no - // spacing around it, so `rev/day` came out as `revday`; an - // operator carries its own spacing and cannot be swallowed. - (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), - } - } + Node::Text(text) => classify(text), Node::Function(label) => format!("{}", escape(label)), Node::Row(children) => match children.len() { // A row of one is the child itself; an extra pair of brackets @@ -808,20 +829,6 @@ 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!( diff --git a/tests/docs_mathml.rs b/tests/docs_mathml.rs index a1366e2..c16b908 100644 --- a/tests/docs_mathml.rs +++ b/tests/docs_mathml.rs @@ -25,7 +25,7 @@ fn a_power_becomes_a_superscript() { fn a_group_holds_several_tokens() { assert_eq!( mathml("x^{12}"), - "x12" + "x12" ); } @@ -45,7 +45,9 @@ fn a_greek_letter_is_translated() { #[test] fn an_unknown_command_is_kept_as_written() { // Losing part of a formula would be worse than showing it plainly. - assert_eq!(mathml("\\notacommand"), "\\notacommand"); + // Text is given an element even when it is not understood, because text + // without one is not drawn at all. + assert_eq!(mathml("\\notacommand"), "\\notacommand"); } #[test] @@ -55,7 +57,7 @@ fn a_comma_is_kept() { // space, which is what a comma is for. assert_eq!( mathml("\\alpha, \\beta"), - "α, β" + "α,β" ); } @@ -186,7 +188,7 @@ 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("="), "{html}"); assert!(html.contains("\u{2212}"), "{html}"); } +#[test] +fn a_number_of_several_digits_is_kept() { + // Only single digits were given an element, and text without one is + // dropped by the browser, so `14`, `4800` and `32045` vanished from + // every formula that held them. + let html = render_plain("JDN = day + floor((153 m + 2) / 5) + 365 y").expect("a formula"); + for number in ["153", "2", "5", "365"] { + assert!( + html.contains(&format!("{number}")), + "{number} has no element: {html}" + ); + } + // Each number is inside an element; text without one is not drawn. + assert!(html.matches("").count() >= 4, "{html}"); +} + +#[test] +fn a_decimal_and_an_exponent_are_single_numbers() { + let html = render_plain("x = 2400000.5 + 1.5e-3").expect("a formula"); + assert!(html.contains("2400000.5"), "{html}"); + assert!(html.contains("1.5e-3"), "{html}"); +} + +#[test] +fn a_row_holds_elements_rather_than_text() { + // Bare text inside a row is dropped by the browser, so a number or a name + // has to be given an element. The end to end check compares the + // characters the reader sees against the source; this one states the + // shape that makes that true, so a failure points at the cause. + let html = render_plain("a = floor((14 - month) / 12)").expect("a formula"); + let body = html.split("'); + let tag = parts.next().unwrap_or_default(); + let after = parts.next().unwrap_or_default(); + let text: String = after.chars().take_while(|c| *c != '<').collect(); + if text.is_empty() { + continue; + } + let name = tag + .trim_start_matches('/') + .split(' ') + .next() + .unwrap_or_default(); + assert!( + matches!(name, "mi" | "mo" | "mn" | "mtext"), + "text {text:?} sits in <{name}>, which does not draw it: {html}" + ); + } +} diff --git a/tests/formula_rendering.rs b/tests/formula_rendering.rs new file mode 100644 index 0000000..a8853a0 --- /dev/null +++ b/tests/formula_rendering.rs @@ -0,0 +1,118 @@ +//! Checks what a formula shows against what it was written as. +//! +//! A formula is written as plain text in the document and translated to +//! MathML. The translation can drop a token without dropping the formula: +//! the result still looks like a formula and is wrong. That happened here — +//! the 4800 in `year + 4800` was written as bare text inside a row, which the +//! browser does not draw, so every multi-digit number vanished from the +//! rendered page while the markup looked reasonable. +//! +//! The check is therefore on the characters rather than on the structure: +//! every letter and digit of the source must appear in the text the page +//! would show. It runs over every document rather than over a chosen sample, +//! because the defect is per formula and a sample would have missed most of +//! them. +//! +//! Only letters and digits are compared. The other characters in a formula +//! source are notation rather than content: `^` and `_` say what to raise or +//! lower, `*` says two things are multiplied, `/` says one is divided by the +//! other, and braces group. All of them are expected to disappear from the +//! page, because the layout says what they said. A number or a name has no +//! such excuse, so a missing one is a defect. +//! +//! `sqrt` is notation of the same kind: it is drawn as the radical sign, so +//! its letters are gone by design and are not missed. Every other function +//! name is drawn as its own letters, as in `sin` and `ln`. + +use apsis_book::docs::document::Language; + +/// Returns the text a reader sees, with the markup removed. +/// +/// MathML draws an element's text and drops text that has no element, so +/// keeping only what is inside an element is what the browser shows. +fn visible(html: &str) -> String { + let mut out = String::new(); + let mut depth: usize = 0; + let mut in_tag = false; + let mut characters = html.chars().peekable(); + while let Some(character) = characters.next() { + match character { + '<' => { + in_tag = true; + if characters.peek() == Some(&'/') { + depth = depth.saturating_sub(1); + } else { + depth += 1; + } + } + '>' => in_tag = false, + _ if !in_tag && depth > 0 => out.push(character), + _ => {} + } + } + out +} + +/// Returns the formula sources of one document, in order. +fn formula_blocks(html: &str) -> Vec { + let mut found = Vec::new(); + let mut rest = html; + while let Some(start) = rest.find("data-tex=\"") { + let after = &rest[start + "data-tex=\"".len()..]; + if let Some(end) = after.find('"') { + found.push(after[..end].to_string()); + } + rest = after; + } + found +} + +#[test] +fn every_formula_shows_every_character_it_was_written_with() { + let documents = apsis_book::docs::load_documents(); + let mut checked = 0; + let mut failures = Vec::new(); + + for document in &documents { + for language in [Language::English, Language::Chinese] { + let html = &document.variant(language).html; + for source in formula_blocks(html) { + checked += 1; + // The source as written, without the spaces that only set it + // out on the page. + // `sqrt` is drawn as the radical sign, so its letters are + // notation of the same kind as the operators. + let written: String = source + .replace("sqrt", "") + .chars() + .filter(|c| c.is_alphanumeric()) + .collect(); + // The part of the page that is the formula itself. + let start = html.find(&format!("data-tex=\"{source}\"")).unwrap_or(0); + let end = html[start..] + .find("") + .map(|e| start + e) + .unwrap_or(html.len()); + let shown: String = visible(&html[start..end]) + .chars() + .filter(|c| c.is_alphanumeric()) + .collect(); + let missing: String = written.chars().filter(|c| !shown.contains(*c)).collect(); + if !missing.is_empty() { + failures.push(format!( + "{}: 公式少了字符 \"{}\":写入 {},显示 {}", + document.id, missing, written, shown + )); + } + } + } + } + + assert!(checked > 50, "只检查到 {checked} 个公式,取样过少"); + assert!( + failures.is_empty(), + "{} 个公式渲染后缺少字符:\n{}", + failures.len(), + failures.join("\n") + ); +}