mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 04:22:20 +00:00
fix(js): decode ', numeric, and HTML named character references in FormCalc Decode (#6538)
* fix(js): decode ', numeric, and HTML named character references in FormCalc Decode * test(js): verify all HTML named entities and 8-byte name boundary (#6464) * docs(js): remove duplicate decode_entities summary and document LATIN1_ENTITIES order (#6464)
This commit is contained in:
1 parent
90def111c9
commit
ca21968e64
3 files changed
+178
-2
No files matched your search
@@ -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 <ok> & \"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}'"));
|
||||
}
|
||||
}
|
||||
@@ -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}") })
|
||||
|
||||
@@ -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<char> {
|
||||
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;");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user