//! 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. //! //! A LaTeX command is notation of the same kind. `\sqrt` is drawn as the //! radical sign and `\Delta` as the Greek letter, so their letters are gone //! by design and are not missed. A command whose name is its own glyph, such //! as `\ln`, is drawn as those letters, so stripping every command only //! makes this check less strict about a function name and never hides a lost //! number. 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 content characters of a formula source. /// /// What is left out is notation: a command name, the name of an environment, /// the `&` that separates the cells of a matrix and the `\\` that separates /// its rows, and the braces that group. None of them is drawn as those /// characters, so none of them is expected on the page. What remains is the /// letters and digits, which are content and must all appear. fn content_characters(source: &str) -> String { // The attribute is HTML escaped, so a cell separator arrives as `&` // and would otherwise leave the letters of `amp` behind. // `sqrt` is written bare in the plain notation and with a backslash in // LaTeX; the bare form has to go as well, because it is drawn as a // radical sign rather than as its letters. let unescaped = source .replace("sqrt", "") .replace("&", "&") .replace("<", "<") .replace(">", ">") .replace(""", "\"") .replace("'", "'"); let mut out = String::with_capacity(unescaped.len()); let mut characters = unescaped.chars().peekable(); while let Some(character) = characters.next() { match character { // A command runs to the first character that is not a letter. // The name is captured so that `begin` and `end` can be told // apart from the rest, because those two carry the name of an // environment in braces after them. '\\' => { let mut name = String::new(); while let Some(next) = characters.peek().copied() { if !next.is_alphabetic() { break; } name.push(next); characters.next(); } if (name == "begin" || name == "end") && characters.peek() == Some(&'{') { characters.next(); for next in characters.by_ref() { if next == '}' { break; } } } } '&' | '{' | '}' | '^' | '_' | '/' | '*' | '$' | '~' => {} other => out.push(other), } } out.chars().filter(|c| c.is_alphanumeric()).collect() } /// 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 mut checked = 0; let mut failures = Vec::new(); // The concept documents, in both languages. let mut sources: Vec<(String, String)> = Vec::new(); for document in &apsis_book::docs::load_documents() { for language in [Language::English, Language::Chinese] { sources.push((document.id.clone(), document.variant(language).html.clone())); } } // And the interface documentation, which carries formulas of its own in // the doc comments of the crate it describes. Leaving it out would have // hidden every formula there. for krate in apsis_book::docs::load_api_catalog().crates { for item in &krate.items { // The doc comment is Markdown, and the service renders it with // the same renderer as a document. for text in [Some(&item.doc), item.doc_zh.as_ref()] .into_iter() .flatten() { sources.push(( format!("{}::{}", krate.name, item.name), apsis_book::docs::markdown::render(text), )); } } } for (id, html) in &sources { let html = html.as_str(); { for source in formula_blocks(html) { checked += 1; // The source as written, without the spaces that only set it // out on the page. let written = content_characters(&source); // 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!( "{id}: 公式少了字符 \"{missing}\":写入 {written},显示 {shown}" )); } } } } assert!(checked > 50, "只检查到 {checked} 个公式,取样过少"); assert!( failures.is_empty(), "{} 个公式渲染后缺少字符:\n{}", failures.len(), failures.join("\n") ); }