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:
parent
196727c778
commit
4957368aa3
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue