diff --git a/crates/js/src/formcalc.rs b/crates/js/src/formcalc.rs index cfad619938..06ff21f438 100644 --- a/crates/js/src/formcalc.rs +++ b/crates/js/src/formcalc.rs @@ -245,4 +245,42 @@ mod tests { assert_eq!(num_text(-1e20), "-100000000000000000000"); assert_eq!(num_text(f64::MAX).len(), 309); } + + #[test] + fn decode_xml_and_html_entities() { + let eval = |script: &str| { + let root = XfaNode::default(); + let out = run_formcalc(script, &XfaEvent::default(), &XfaDoc::default(), &root, Limits::default()); + assert!(out.error.is_none(), "{:?}", out.error); + out.result.unwrap_or_default() + }; + + // `'` in both xml and html + assert_eq!(eval(r#"Decode("It's <ok> & "fine"", "xml")"#), "It's & \"fine\""); + assert_eq!(eval(r#"Decode("It's", "html")"#), "It's"); + + // Decimal and hex numeric character references (case-insensitive #x/#X and hex digits, leading zeros) + assert_eq!(eval(r#"Decode("ABCDe€😀", "xml")"#), "ABCDe€😀"); + assert_eq!(eval(r#"Decode("ABCé", "html")"#), "ABCé"); + + // Invalid / surrogate / out-of-range / zero / malformed numeric references stay literal without panicking + assert_eq!( + eval(r#"Decode("� � � � � &#; &#x; A G; &;", "xml")"#), + "� � � � � &#; &#x; A G; &;" + ); + + // Single-pass decoding: `&lt;` and `&lt;` must decode to `<`, never `<` + assert_eq!(eval(r#"Decode("&lt; &#65; &lt; &apos;", "xml")"#), "< A < '"); + assert_eq!(eval(r#"Decode("&nbsp; &eacute;", "html")"#), "  é"); + + // HTML named entities decode in "html" mode but remain literal in "xml" mode + assert_eq!(eval(r#"Decode("café ©€—αΩ♥", "html")"#), "café\u{00A0}©€—αΩ♥"); + assert_eq!(eval(r#"Decode("café ©€", "xml")"#), "café ©€"); + // Case sensitivity of HTML named entities (`É` vs `é`, `″` vs `′`, `‡` vs `†`) + assert_eq!(eval(r#"Decode("Éé″′‡†&bogus;", "html")"#), "Éé″′‡†&bogus;"); + // Non-ASCII characters after `&` or `&#` and long runs of `&` without `;` + assert_eq!(eval(r#"Decode("&é; &#é; &#xé; &é", "html")"#), "&é; &#é; &#xé; &é"); + let amps = "&".repeat(5_000); + assert_eq!(eval(&format!(r#"Decode("{amps}'", "xml")"#)), format!("{amps}'")); + } } diff --git a/crates/js/src/formcalc/builtins.rs b/crates/js/src/formcalc/builtins.rs index c3b7c7f1f2..e9e3bf90be 100644 --- a/crates/js/src/formcalc/builtins.rs +++ b/crates/js/src/formcalc/builtins.rs @@ -2,7 +2,7 @@ use super::ast::Expr; use super::dates::{MAX_DAYS, civil_from_days, days_from_civil, format_date, parse_date}; use super::parser::parse; use super::value::{Val, fixed, is_numeric_text, parse_num}; -use super::words::word_num; +use super::words::{decode_entities, word_num}; use super::{Interp, MAX_STRING, R, STRING_TOO_LONG, XfaKind, is_limit}; impl Interp<'_> { @@ -200,7 +200,8 @@ impl Interp<'_> { let kind = if args.len() > 1 { self.arg_str(args, 1)?.to_ascii_lowercase() } else { "url".into() }; Val::Str(match (lower.as_str(), kind.as_str()) { ("encode", "html" | "xml") => s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """), - ("decode", "html" | "xml") => s.replace("<", "<").replace(">", ">").replace(""", "\"").replace("&", "&"), + ("decode", "xml") => decode_entities(&s, false), + ("decode", "html") => decode_entities(&s, true), ("encode", _) => s .bytes() .map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }) diff --git a/crates/js/src/formcalc/words.rs b/crates/js/src/formcalc/words.rs index d0d119abb5..a6a5141920 100644 --- a/crates/js/src/formcalc/words.rs +++ b/crates/js/src/formcalc/words.rs @@ -73,3 +73,140 @@ pub(super) fn word_num(n: f64, cents: bool) -> String { } out } + +/// Decode XML (`html == false`) or HTML (`html == true`) character and entity references in a +/// single pass so `&lt;` becomes `<` rather than `<`, and invalid or unrecognized +/// references remain literal without panicking. +/// +/// When `html` is `false`, only the five predefined XML entities (`<`, `>`, `&`, +/// `"`, `'`) and numeric character references (`&#...;`, `&#x...;`, `&#X...;`) are +/// decoded. When `html` is `true`, W3C HTML 4 / XHTML 1.0 named entities are decoded as well. +pub(super) fn decode_entities(s: &str, html: bool) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(at) = rest.find('&') { + out.push_str(rest.get(..at).unwrap_or_default()); + rest = rest.get(at..).unwrap_or_default(); + match entity_at(rest, html) { + Some((c, len)) => { + out.push(c); + rest = rest.get(len..).unwrap_or_default(); + } + None => { + out.push('&'); + rest = rest.get(1..).unwrap_or_default(); + } + } + } + out.push_str(rest); + out +} + +/// Parse a character reference starting at the leading `&` of `s`, returning the decoded `char` +/// and the byte length of the reference (including `&` and `;`). +fn entity_at(s: &str, html: bool) -> Option<(char, usize)> { + if let Some(body) = s.strip_prefix("&#") { + let (radix, digits, prefix_len) = match body.strip_prefix(['x', 'X']) { + Some(hex) => (16u32, hex, 3usize), + None => (10u32, body, 2usize), + }; + let mut value = 0u32; + let mut any_digit = false; + for (i, c) in digits.char_indices() { + match c.to_digit(radix) { + Some(d) => { + any_digit = true; + value = value.checked_mul(radix)?.checked_add(d)?; + if value > 0x10_FFFF { + return None; + } + } + None if c == ';' && any_digit && value != 0 => { + return Some((char::from_u32(value)?, prefix_len + i + 1)); + } + None => return None, + } + } + return None; + } + let body = s.strip_prefix('&')?; + let semi = body.bytes().take(9).position(|b| b == b';')?; + let name = body.get(..semi).filter(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_alphanumeric()))?; + let ch = match name { + "lt" => '<', + "gt" => '>', + "amp" => '&', + "quot" => '"', + "apos" => '\'', + _ if html => html_named_entity(name)?, + _ => return None, + }; + Some((ch, 1 + semi + 1)) +} + +/// Look up a W3C HTML 4 / XHTML 1.0 named entity (excluding the five XML entities). +fn html_named_entity(name: &str) -> Option { + if let Some(idx) = LATIN1_ENTITIES.iter().position(|&n| n == name) { + return char::from_u32(0xA0 + idx as u32); + } + HTML_ENTITIES.iter().find(|&&(n, _)| n == name).map(|&(_, c)| c) +} + +/// ISO-8859-1 named entities in code-point order `U+00A0..=U+00FF`. Entries must remain ordered +/// by successive code points starting at `0xA0` and must not be reordered alphabetically, since +/// [`html_named_entity`] maps each entry's index to `0xA0 + idx as u32`. +#[rustfmt::skip] +const LATIN1_ENTITIES: [&str; 96] = [ + "nbsp", "iexcl", "cent", "pound", "curren", "yen", "brvbar", "sect", "uml", "copy", "ordf", "laquo", "not", "shy", "reg", "macr", + "deg", "plusmn", "sup2", "sup3", "acute", "micro", "para", "middot", "cedil", "sup1", "ordm", "raquo", "frac14", "frac12", "frac34", "iquest", + "Agrave", "Aacute", "Acirc", "Atilde", "Auml", "Aring", "AElig", "Ccedil", "Egrave", "Eacute", "Ecirc", "Euml", "Igrave", "Iacute", "Icirc", "Iuml", + "ETH", "Ntilde", "Ograve", "Oacute", "Ocirc", "Otilde", "Ouml", "times", "Oslash", "Ugrave", "Uacute", "Ucirc", "Uuml", "Yacute", "THORN", "szlig", + "agrave", "aacute", "acirc", "atilde", "auml", "aring", "aelig", "ccedil", "egrave", "eacute", "ecirc", "euml", "igrave", "iacute", "icirc", "iuml", + "eth", "ntilde", "ograve", "oacute", "ocirc", "otilde", "ouml", "divide", "oslash", "ugrave", "uacute", "ucirc", "uuml", "yacute", "thorn", "yuml", +]; + +#[rustfmt::skip] +const HTML_ENTITIES: &[(&str, char)] = &[ + ("OElig", '\u{0152}'), ("oelig", '\u{0153}'), ("Scaron", '\u{0160}'), ("scaron", '\u{0161}'), ("Yuml", '\u{0178}'), ("fnof", '\u{0192}'), ("circ", '\u{02C6}'), ("tilde", '\u{02DC}'), + ("Alpha", '\u{0391}'), ("Beta", '\u{0392}'), ("Gamma", '\u{0393}'), ("Delta", '\u{0394}'), ("Epsilon", '\u{0395}'), ("Zeta", '\u{0396}'), ("Eta", '\u{0397}'), ("Theta", '\u{0398}'), + ("Iota", '\u{0399}'), ("Kappa", '\u{039A}'), ("Lambda", '\u{039B}'), ("Mu", '\u{039C}'), ("Nu", '\u{039D}'), ("Xi", '\u{039E}'), ("Omicron", '\u{039F}'), ("Pi", '\u{03A0}'), + ("Rho", '\u{03A1}'), ("Sigma", '\u{03A3}'), ("Tau", '\u{03A4}'), ("Upsilon", '\u{03A5}'), ("Phi", '\u{03A6}'), ("Chi", '\u{03A7}'), ("Psi", '\u{03A8}'), ("Omega", '\u{03A9}'), + ("alpha", '\u{03B1}'), ("beta", '\u{03B2}'), ("gamma", '\u{03B3}'), ("delta", '\u{03B4}'), ("epsilon", '\u{03B5}'), ("zeta", '\u{03B6}'), ("eta", '\u{03B7}'), ("theta", '\u{03B8}'), + ("iota", '\u{03B9}'), ("kappa", '\u{03BA}'), ("lambda", '\u{03BB}'), ("mu", '\u{03BC}'), ("nu", '\u{03BD}'), ("xi", '\u{03BE}'), ("omicron", '\u{03BF}'), ("pi", '\u{03C0}'), + ("rho", '\u{03C1}'), ("sigmaf", '\u{03C2}'), ("sigma", '\u{03C3}'), ("tau", '\u{03C4}'), ("upsilon", '\u{03C5}'), ("phi", '\u{03C6}'), ("chi", '\u{03C7}'), ("psi", '\u{03C8}'), + ("omega", '\u{03C9}'), ("thetasym", '\u{03D1}'), ("upsih", '\u{03D2}'), ("piv", '\u{03D6}'), ("ensp", '\u{2002}'), ("emsp", '\u{2003}'), ("thinsp", '\u{2009}'), ("zwnj", '\u{200C}'), + ("zwj", '\u{200D}'), ("lrm", '\u{200E}'), ("rlm", '\u{200F}'), ("ndash", '\u{2013}'), ("mdash", '\u{2014}'), ("lsquo", '\u{2018}'), ("rsquo", '\u{2019}'), ("sbquo", '\u{201A}'), + ("ldquo", '\u{201C}'), ("rdquo", '\u{201D}'), ("bdquo", '\u{201E}'), ("dagger", '\u{2020}'), ("Dagger", '\u{2021}'), ("bull", '\u{2022}'), ("hellip", '\u{2026}'), ("permil", '\u{2030}'), + ("prime", '\u{2032}'), ("Prime", '\u{2033}'), ("lsaquo", '\u{2039}'), ("rsaquo", '\u{203A}'), ("oline", '\u{203E}'), ("frasl", '\u{2044}'), ("euro", '\u{20AC}'), ("image", '\u{2111}'), + ("weierp", '\u{2118}'), ("real", '\u{211C}'), ("trade", '\u{2122}'), ("alefsym", '\u{2135}'), ("larr", '\u{2190}'), ("uarr", '\u{2191}'), ("rarr", '\u{2192}'), ("darr", '\u{2193}'), + ("harr", '\u{2194}'), ("crarr", '\u{21B5}'), ("lArr", '\u{21D0}'), ("uArr", '\u{21D1}'), ("rArr", '\u{21D2}'), ("dArr", '\u{21D3}'), ("hArr", '\u{21D4}'), ("forall", '\u{2200}'), + ("part", '\u{2202}'), ("exist", '\u{2203}'), ("empty", '\u{2205}'), ("nabla", '\u{2207}'), ("isin", '\u{2208}'), ("notin", '\u{2209}'), ("ni", '\u{220B}'), ("prod", '\u{220F}'), + ("sum", '\u{2211}'), ("minus", '\u{2212}'), ("lowast", '\u{2217}'), ("radic", '\u{221A}'), ("prop", '\u{221D}'), ("infin", '\u{221E}'), ("ang", '\u{2220}'), ("and", '\u{2227}'), + ("or", '\u{2228}'), ("cap", '\u{2229}'), ("cup", '\u{222A}'), ("int", '\u{222B}'), ("there4", '\u{2234}'), ("sim", '\u{223C}'), ("cong", '\u{2245}'), ("asymp", '\u{2248}'), + ("ne", '\u{2260}'), ("equiv", '\u{2261}'), ("le", '\u{2264}'), ("ge", '\u{2265}'), ("sub", '\u{2282}'), ("sup", '\u{2283}'), ("nsub", '\u{2284}'), ("sube", '\u{2286}'), + ("supe", '\u{2287}'), ("oplus", '\u{2295}'), ("otimes", '\u{2297}'), ("perp", '\u{22A5}'), ("sdot", '\u{22C5}'), ("lceil", '\u{2308}'), ("rceil", '\u{2309}'), ("lfloor", '\u{230A}'), + ("rfloor", '\u{230B}'), ("lang", '\u{2329}'), ("rang", '\u{232A}'), ("loz", '\u{25CA}'), ("spades", '\u{2660}'), ("clubs", '\u{2663}'), ("hearts", '\u{2665}'), ("diams", '\u{2666}'), +]; + +#[cfg(test)] +mod tests { + use super::{HTML_ENTITIES, LATIN1_ENTITIES, decode_entities}; + + #[test] + fn every_listed_html_entity_decodes_including_eight_byte_boundary() { + for (i, &name) in LATIN1_ENTITIES.iter().enumerate() { + let expected = char::from_u32(0xA0 + i as u32).unwrap().to_string(); + let input = format!("&{name};"); + assert_eq!(decode_entities(&input, true), expected, "entity {name}"); + assert_eq!(decode_entities(&input, false), input, "xml mode leaves {name} literal"); + } + for &(name, ch) in HTML_ENTITIES { + assert!(name.len() <= 8, "entity name {name} exceeds 8-byte parser limit"); + let input = format!("&{name};"); + assert_eq!(decode_entities(&input, true), ch.to_string(), "entity {name}"); + assert_eq!(decode_entities(&input, false), input, "xml mode leaves {name} literal"); + } + assert_eq!(decode_entities("ϑ", true), "\u{03D1}"); + assert_eq!(decode_entities("&thetasymx;", true), "&thetasymx;"); + } +}