Give every piece of a formula an element, and check the page for it

A formula is written as plain text and translated to MathML. Text left
bare inside a row is not drawn by the browser, so a token without an
element disappears: every multi-digit number did. `a = floor((14 - month)
/ 12)` came out as `a = floor ( ( - month ) )`, and `year + 4800` as
`year + `. The markup looked reasonable at a glance, and the numbers were
simply gone.

The classification now covers every kind of text and has no bare output.
A number of any length is `mn`; one letter of any script is an identifier,
so Greek stays a variable rather than becoming upright text; an operator,
including one written with its spacing, is `mo`; a space is an explicit
`mspace`; and anything else is `mtext` rather than nothing.

A test walks every document in both languages and compares the characters
the page would show against the characters the formula was written with.
It found three more formulas dropping a sign, and it now guards the whole
set: ninety-six formula blocks, checked on every run rather than by
looking at a page. Only letters and digits are compared, because the
other characters in a formula source are notation — `^`, `_`, `*`,
`/`, braces, and the word `sqrt`, which is drawn as a radical sign.
This commit is contained in:
dev 2026-10-02 10:42:51 +08:00
parent 196727c778
commit 4957368aa3
3 changed files with 229 additions and 50 deletions

View File

@ -490,42 +490,63 @@ fn parse_command(name: &str, tokens: &[Token], cursor: &mut usize) -> Vec<Node>
}
}
/// Writes a piece of text as the MathML element its kind calls for.
///
/// Every branch produces an element, and the last one produces `<mtext>`.
/// 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 "<mspace width=\"0.25em\"/>".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!("<mi>{}</mi>", 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!("<mn>{}</mn>", 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!("<mi>{}</mi>", 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!("<mo>{}</mo>", 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!("<mtext>{}</mtext>", 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!("<mn>{}</mn>", 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!("<mi>{}</mi>", 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!("<mo>{}</mo>", 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!("<mo>{}</mo>", escape(text.trim()))
}
_ => escape(text),
}
}
Node::Text(text) => classify(text),
Node::Function(label) => format!("<mi>{}</mi>", 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<Ascii> {
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!(

View File

@ -25,7 +25,7 @@ fn a_power_becomes_a_superscript() {
fn a_group_holds_several_tokens() {
assert_eq!(
mathml("x^{12}"),
"<msup><mrow><mi>x</mi></mrow><mrow>12</mrow></msup>"
"<msup><mrow><mi>x</mi></mrow><mrow><mn>12</mn></mrow></msup>"
);
}
@ -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"), "<mtext>\\notacommand</mtext>");
}
#[test]
@ -55,7 +57,7 @@ fn a_comma_is_kept() {
// space, which is what a comma is for.
assert_eq!(
mathml("\\alpha, \\beta"),
"<mrow><mi>α</mi><mo>,</mo> <mi>β</mi></mrow>"
"<mrow><mi>α</mi><mo>,</mo><mspace width=\"0.25em\"/><mi>β</mi></mrow>"
);
}
@ -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("<mtext"), "a note was read: {html}");
assert!(!html.contains("math-note"), "a note was read: {html}");
}
#[test]
@ -200,7 +202,7 @@ fn a_trailing_bracket_of_the_equation_is_not_a_note() {
// `(cos theta)` is part of the equation even though it holds only
// words; the whole line reads out, so there is no note.
let html = render_plain("gamma = k * R^alpha / (cos theta)").expect("a formula");
assert!(!html.contains("<mtext"), "a note was read: {html}");
assert!(!html.contains("math-note"), "a note was read: {html}");
}
#[test]
@ -209,7 +211,7 @@ fn a_bracketed_group_is_not_mistaken_for_a_note() {
// is a phrase and has none.
let html = render_plain("H = k * T / (m * g)").expect("a formula");
assert!(html.contains("<mfrac"), "{html}");
assert!(!html.contains("<mtext"), "a note was read: {html}");
assert!(!html.contains("math-note"), "a note was read: {html}");
}
#[test]
@ -242,3 +244,55 @@ fn an_equals_between_two_equations_is_kept() {
assert!(html.contains("<mo>=</mo>"), "{html}");
assert!(html.contains("<mo>\u{2212}</mo>"), "{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!("<mn>{number}</mn>")),
"{number} has no element: {html}"
);
}
// Each number is inside an element; text without one is not drawn.
assert!(html.matches("<mn>").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("<mn>2400000.5</mn>"), "{html}");
assert!(html.contains("<mn>1.5e-3</mn>"), "{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("<mtable").nth(1).unwrap_or_default();
// A run of text is drawn only when the element just opened holds text.
// A container such as a row or a cell does not, so text there is lost.
for piece in body.split('<').skip(1) {
let mut parts = piece.splitn(2, '>');
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}"
);
}
}

118
tests/formula_rendering.rs Normal file
View File

@ -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<String> {
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("</span>")
.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")
);
}