dragoman 0.5.14

Server for scholarly metadata in commonmeta format stored in a SQLite database.
//! Plain text from an HTML fragment, for the places that take only text: the
//! `og:description` and other link-preview tags.
//!
//! Abstracts arrive as HTML, and sometimes HTML that was escaped again on its
//! way into a deposit — `<strong> Cite as:` — so one pass of decoding
//! turns up more markup. Tags are dropped, `<script>` and `<style>` with their
//! contents, entities decoded, and the passes repeat until nothing changes.
//! Escaping the result once for an attribute is then all the page needs; the
//! fragment escaped as it came showed `&lt;strong&gt;` in every preview.

/// How many rounds of decoding to undo at most: once for the HTML, once more
/// for a deposit that escaped it again, and a margin.
const MAX_PASSES: usize = 4;

/// The text of an HTML fragment, on one line.
pub fn plain_text(html: &str) -> String {
    let mut text = html.to_string();
    for _ in 0..MAX_PASSES {
        let next = decode_entities(&strip_tags(&text));
        if next == text {
            break;
        }
        text = next;
    }
    text.split_whitespace().collect::<Vec<_>>().join(" ")
}

/// `text` cut to at most `max` characters at a word boundary, with an
/// ellipsis when anything was cut.
pub fn truncate(text: &str, max: usize) -> String {
    if text.chars().count() <= max {
        return text.to_string();
    }
    let cut: String = text.chars().take(max).collect();
    let cut = match cut.rfind(' ') {
        Some(i) if i > max / 2 => &cut[..i],
        _ => cut.as_str(),
    };
    format!("{}…", cut.trim_end_matches([',', ';', ':', '.', ' ']))
}

/// The fragment without its tags; `<script>` and `<style>` elements go with
/// their contents. A space stands in for each tag, so words on either side of
/// one do not run together.
fn strip_tags(html: &str) -> String {
    let mut out = String::with_capacity(html.len());
    let mut rest = html;
    while let Some(start) = rest.find('<') {
        out.push_str(&rest[..start]);
        let tag = &rest[start..];
        let Some(end) = tag.find('>') else {
            // A lone `<` is text.
            out.push_str(tag);
            return out;
        };
        let name: String = tag[1..end]
            .trim_start_matches('/')
            .chars()
            .take_while(|c| c.is_ascii_alphanumeric())
            .collect::<String>()
            .to_ascii_lowercase();
        rest = &tag[end + 1..];
        if (name == "script" || name == "style") && !tag[1..].starts_with('/') {
            let close = format!("</{name}");
            rest = match rest.to_ascii_lowercase().find(&close) {
                Some(i) => rest[i..].find('>').map_or("", |j| &rest[i + j + 1..]),
                None => "",
            };
        }
        out.push(' ');
    }
    out.push_str(rest);
    out
}

/// The text with its character references decoded: the named ones abstracts
/// use, and every numeric one.
fn decode_entities(text: &str) -> String {
    let mut out = String::with_capacity(text.len());
    let mut rest = text;
    while let Some(start) = rest.find('&') {
        out.push_str(&rest[..start]);
        let entity = &rest[start..];
        let decoded = entity.find(';').filter(|&end| end <= 10).and_then(|end| {
            let name = &entity[1..end];
            let ch = match name {
                "amp" => Some('&'),
                "lt" => Some('<'),
                "gt" => Some('>'),
                "quot" => Some('"'),
                "apos" => Some('\''),
                "nbsp" => Some(' '),
                "ndash" => Some('–'),
                "mdash" => Some('—'),
                "hellip" => Some('…'),
                "lsquo" => Some('‘'),
                "rsquo" => Some('’'),
                "ldquo" => Some('“'),
                "rdquo" => Some('”'),
                _ => name
                    .strip_prefix("#x")
                    .or_else(|| name.strip_prefix("#X"))
                    .and_then(|hex| u32::from_str_radix(hex, 16).ok())
                    .or_else(|| name.strip_prefix('#').and_then(|dec| dec.parse().ok()))
                    .and_then(char::from_u32),
            }?;
            Some((ch, end))
        });
        match decoded {
            Some((ch, end)) => {
                out.push(ch);
                rest = &entity[end + 1..];
            }
            None => {
                out.push('&');
                rest = &entity[1..];
            }
        }
    }
    out.push_str(rest);
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn an_abstract_escaped_twice_comes_out_as_text() {
        // From the Crossref deposit of 10.59347/w366z-egx85.
        let abstract_ = "&lt;strong&gt; Cite as: &lt;/strong&gt; Geoffrey Bilder (2025, April 2). \
            &lt;i&gt; Bots Behaving Badly &lt;/i&gt; . Retrieved from https://doi.org/10.59347/w366z-egx85 \
            navigator.clipboard.writeText(t).then(() =&amp;gt; { alert('copied') })";
        assert_eq!(
            plain_text(abstract_),
            "Cite as: Geoffrey Bilder (2025, April 2). Bots Behaving Badly . Retrieved from \
             https://doi.org/10.59347/w366z-egx85 navigator.clipboard.writeText(t).then(() => { alert('copied') })"
        );
    }

    #[test]
    fn tags_go_and_scripts_go_with_their_contents() {
        assert_eq!(
            plain_text(
                "<p>One<br/>two</p><script type=\"text/javascript\">alert('x <b>')</script><STYLE>p{}</STYLE> three"
            ),
            "One two three"
        );
        assert_eq!(plain_text("x < y and a & b"), "x < y and a & b");
        assert_eq!(
            plain_text("&#8220;quoted&#x201D; &amp;amp; caf&eacute;"),
            "“quoted” & café".replace("é", "&eacute;")
        );
    }

    #[test]
    fn truncate_cuts_at_a_word_and_marks_the_cut() {
        assert_eq!(truncate("short", 10), "short");
        assert_eq!(truncate("one two three four", 12), "one two…");
        assert_eq!(truncate("one, two, three", 9), "one, two…");
    }
}