Repository: tafia/quick-xml
Pull request #261 (2021-02-08 09:11:50)

add `*_with_custom_entities` versions of all `unescape_*\ methods
these versions expect an additional parameter:
a hashmap containing custom entity definitions.
A typical use-case is for the user to parse those entities definitions
from the DOCTYPE.
An example is provided: examples/custom_entities.rs .


Patch:
diff --git a/examples/custom_entities.rs b/examples/custom_entities.rs
new file mode 100644
index 0000000..b628094
--- /dev/null
+++ b/examples/custom_entities.rs
@@ -0,0 +1,71 @@
+//! This example demonstrate how custom entities can be extracted from the DOCTYPE!,
+//! and later use to decode text and attribute values.
+//!
+//! NB: this example is deliberately kept simple:
+//! * it assumes that the XML file is UTF-8 encoded (custom_entities must only contain UTF-8 data)
+//! * it only handles internal entities;
+//! * the regex in this example is simple but brittle;
+//! * it does not support the use of entities in entity declaration.
+
+extern crate quick_xml;
+extern crate regex;
+
+use quick_xml::events::Event;
+use quick_xml::Reader;
+use regex::bytes::Regex;
+use std::collections::HashMap;
+
+const DATA: &str = r#"
+
+    <?xml version="1.0"?>
+    <!DOCTYPE test [
+    <!ENTITY msg "hello world" >
+    ]>
+    <test label="&msg;">&msg;</test>
+
+"#;
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+    let mut reader = Reader::from_str(DATA);
+    reader.trim_text(true);
+
+    let mut buf = Vec::new();
+    let mut custom_entities = HashMap::new();
+    let entity_re = Regex::new(r#"<!ENTITY\s+([^ \t\r\n]+)\s+"([^"]*)"\s*>"#)?;
+
+    loop {
+        match reader.read_event(&mut buf) {
+            Ok(Event::DocType(ref e)) => {
+                for cap in entity_re.captures_iter(&e) {
+                    custom_entities.insert(cap[1].to_vec(), cap[2].to_vec());
+                }
+            }
+            Ok(Event::Start(ref e)) => match e.name() {
+                b"test" => println!(
+                    "attributes values: {:?}",
+                    e.attributes()
+                        .map(|a| a
+                            .unwrap()
+                            .unescape_and_decode_value_with_custom_entities(
+                                &reader,
+                                &custom_entities
+                            )
+                            .unwrap())
+                        .collect::<Vec<_>>()
+                ),
+                _ => (),
+            },
+            Ok(Event::Text(ref e)) => {
+                println!(
+                    "text value: {}",
+                    e.unescape_and_decode_with_custom_entities(&reader, &custom_entities)
+                        .unwrap()
+                );
+            }
+            Ok(Event::Eof) => break,
+            Err(e) => panic!("Error at position {}: {:?}", reader.buffer_position(), e),
+            _ => (),
+        }
+    }
+    Ok(())
+}
diff --git a/src/escapei.rs b/src/escapei.rs
index 04ca271..ad32aa9 100644
--- a/src/escapei.rs
+++ b/src/escapei.rs
@@ -2,6 +2,7 @@
 
 use memchr;
 use std::borrow::Cow;
+use std::collections::HashMap;
 
 #[derive(Debug)]
 pub enum EscapeError {
@@ -107,6 +108,32 @@ pub fn escape(raw: &[u8]) -> Cow<[u8]> {
 /// Unescape a `&[u8]` and replaces all xml escaped characters ('&...;') into their corresponding
 /// value
 pub fn unescape(raw: &[u8]) -> Result<Cow<[u8]>, EscapeError> {
+    do_unescape(raw, None)
+}
+
+/// Unescape a `&[u8]` and replaces all xml escaped characters ('&...;') into their corresponding
+/// value, using a dictionnary of custom entities.
+///
+/// # Pre-condition
+///
+/// The keys and values of `custom_entities`, if any, must be valid UTF-8.
+pub fn unescape_with<'a>(
+    raw: &'a [u8],
+    custom_entities: &HashMap<Vec<u8>, Vec<u8>>,
+) -> Result<Cow<'a, [u8]>, EscapeError> {
+    do_unescape(raw, Some(custom_entities))
+}
+
+/// Unescape a `&[u8]` and replaces all xml escaped characters ('&...;') into their corresponding
+/// value, using an optional dictionnary of custom entities.
+///
+/// # Pre-condition
+///
+/// The keys and values of `custom_entities`, if any, must be valid UTF-8.
+pub fn do_unescape<'a>(
+    raw: &'a [u8],
+    custom_entities: Option<&HashMap<Vec<u8>, Vec<u8>>>,
+) -> Result<Cow<'a, [u8]>, EscapeError> {
     let mut unescaped = None;
     let mut last_end = 0;
     let mut iter = memchr::memchr2_iter(b'&', b';', raw);
@@ -128,22 +155,27 @@ pub fn unescape(raw: &[u8]) -> Result<Cow<[u8]>, EscapeError> {
                     b"amp" => unescaped.push(b'&'),
                     b"apos" => unescaped.push(b'\''),
                     b"quot" => unescaped.push(b'\"'),
-                    bytes => {
-                        let code = if bytes.starts_with(b"#x") {
-                            parse_hexadecimal(&bytes[2..])
-                        } else if bytes.starts_with(b"#") {
-                            parse_decimal(&bytes[1..])
+                    bytes if bytes.starts_with(b"#") => {
+                        let bytes = &bytes[1..];
+                        let code = if bytes.starts_with(b"x") {
+                            parse_hexadecimal(&bytes[1..])
                         } else {
-                            Err(EscapeError::UnrecognizedSymbol(
-                                start + 1..end,
-                                String::from_utf8(bytes.to_vec()),
-                            ))
+                            parse_decimal(&bytes)
                         }?;
                         if code == 0 {
                             return Err(EscapeError::EntityWithNull(start..end));
                         }
                         push_utf8(unescaped, code);
                     }
+                    bytes => match custom_entities.and_then(|hm| hm.get(bytes)) {
+                        Some(value) => unescaped.extend_from_slice(&value),
+                        None => {
+                            return Err(EscapeError::UnrecognizedSymbol(
+                                start + 1..end,
+                                String::from_utf8(bytes.to_vec()),
+                            ))
+                        }
+                    },
                 }
 
                 #[cfg(feature = "escape-html")]
@@ -5532,22 +5564,27 @@ pub fn unescape(raw: &[u8]) -> Result<Cow<[u8]>, EscapeError> {
                         unescaped.push(b'\x1D');
                         unescaped.push(b'\x56');
                     }
-                    bytes => {
-                        let code = if bytes.starts_with(b"#x") {
-                            parse_hexadecimal(&bytes[2..])
-                        } else if bytes.starts_with(b"#") {
-                            parse_decimal(&bytes[1..])
+                    bytes if bytes.starts_with(b"#") => {
+                        let bytes = &bytes[1..];
+                        let code = if bytes.starts_with(b"x") {
+                            parse_hexadecimal(&bytes[1..])
                         } else {
-                            Err(EscapeError::UnrecognizedSymbol(
-                                start + 1..end,
-                                String::from_utf8(bytes.to_vec()),
-                            ))
+                            parse_decimal(&bytes)
                         }?;
                         if code == 0 {
                             return Err(EscapeError::EntityWithNull(start..end));
                         }
                         push_utf8(unescaped, code);
                     }
+                    bytes => match custom_entities.and_then(|hm| hm.get(bytes)) {
+                        Some(value) => unescaped.extend_from_slice(&value),
+                        None => {
+                            return Err(EscapeError::UnrecognizedSymbol(
+                                start + 1..end,
+                                String::from_utf8(bytes.to_vec()),
+                            ))
+                        }
+                    },
                 }
                 last_end = end + 1;
             }
@@ -5623,6 +5660,23 @@ fn test_unescape() {
     assert_eq!(&*unescape(b"&lt;test&gt;").unwrap(), b"<test>");
     assert_eq!(&*unescape(b"&#x30;").unwrap(), b"0");
     assert_eq!(&*unescape(b"&#48;").unwrap(), b"0");
+    assert!(unescape(b"&foo;").is_err());
+}
+
+#[test]
+fn test_unescape_with() {
+    let custom_entities = vec![(b"foo".to_vec(), b"BAR".to_vec())]
+        .into_iter()
+        .collect();
+    assert_eq!(&*unescape_with(b"test", &custom_entities).unwrap(), b"test");
+    assert_eq!(
+        &*unescape_with(b"&lt;test&gt;", &custom_entities).unwrap(),
+        b"<test>"
+    );
+    assert_eq!(&*unescape_with(b"&#x30;", &custom_entities).unwrap(), b"0");
+    assert_eq!(&*unescape_with(b"&#48;", &custom_entities).unwrap(), b"0");
+    assert_eq!(&*unescape_with(b"&foo;", &custom_entities).unwrap(), b"BAR");
+    assert!(unescape_with(b"&fop;", &custom_entities).is_err());
 }
 
 #[test]
diff --git a/src/events/attributes.rs b/src/events/attributes.rs
index bcdffdb..15f05ad 100644
--- a/src/events/attributes.rs
+++ b/src/events/attributes.rs
@@ -3,9 +3,10 @@
 //! Provides an iterator over attributes key/value pairs
 
 use errors::{Error, Result};
-use escape::{escape, unescape};
+use escape::{do_unescape, escape};
 use reader::{is_whitespace, Reader};
 use std::borrow::Cow;
+use std::collections::HashMap;
 use std::io::BufRead;
 use std::ops::Range;
 
@@ -92,8 +93,37 @@ impl<'a> Attribute<'a> {
     /// replaced with their unescaped equivalents such as `>`.
     ///
     /// This will allocate if the value contains any escape sequences.
+    ///
+    /// See also [`unescaped_value_with_custom_entities()`](#method.unescaped_value_with_custom_entities)
     pub fn unescaped_value(&self) -> Result<Cow<[u8]>> {
-        unescape(&*self.value).map_err(Error::EscapeError)
+        self.make_unescaped_value(None)
+    }
+
+    /// Returns the unescaped value, using custom entities.
+    ///
+    /// This is normally the value you are interested in. Escape sequences such as `&gt;` are
+    /// replaced with their unescaped equivalents such as `>`.
+    /// Additional entities can be provided in `custom_entities`.
+    ///
+    /// This will allocate if the value contains any escape sequences.
+    ///
+    /// See also [`unescaped_value()`](#method.unescaped_value)
+    ///
+    /// # Pre-condition
+    ///
+    /// The keys and values of `custom_entities`, if any, must be valid UTF-8.
+    pub fn unescaped_value_with_custom_entities(
+        &self,
+        custom_entities: &HashMap<Vec<u8>, Vec<u8>>,
+    ) -> Result<Cow<[u8]>> {
+        self.make_unescaped_value(Some(custom_entities))
+    }
+
+    fn make_unescaped_value(
+        &self,
+        custom_entities: Option<&HashMap<Vec<u8>, Vec<u8>>>,
+    ) -> Result<Cow<[u8]>> {
+        do_unescape(&*self.value, custom_entities).map_err(Error::EscapeError)
     }
 
     /// Decode then unescapes the value
@@ -106,14 +136,11 @@ impl<'a> Attribute<'a> {
     ///
     /// [`unescaped_value()`]: #method.unescaped_value
     /// [`Reader::decode()`]: ../../reader/struct.Reader.html#method.decode
-    #[cfg(feature = "encoding")]
     pub fn unescape_and_decode_value<B: BufRead>(&self, reader: &Reader<B>) -> Result<String> {
-        let decoded = reader.decode(&*self.value);
-        let unescaped = unescape(decoded.as_bytes()).map_err(Error::EscapeError)?;
-        String::from_utf8(unescaped.into_owned()).map_err(|e| Error::Utf8(e.utf8_error()))
+        self.do_unescape_and_decode_value(reader, None)
     }
 
-    /// Decode then unescapes the value
+    /// Decode then unescapes the value with custom entities
     ///
     /// This allocates a `String` in all cases. For performance reasons it might be a better idea to
     /// instead use one of:
@@ -121,12 +148,42 @@ impl<'a> Attribute<'a> {
     /// * [`Reader::decode()`], as it only allocates when the decoding can't be performed otherwise.
     /// * [`unescaped_value()`], as it doesn't allocate when no escape sequences are used.
     ///
-    /// [`unescaped_value()`]: #method.unescaped_value
+    /// [`unescaped_value_with_custom_entities()`]: #method.unescaped_value_with_custom_entities
     /// [`Reader::decode()`]: ../../reader/struct.Reader.html#method.decode
+    ///
+    /// # Pre-condition
+    ///
+    /// The keys and values of `custom_entities`, if any, must be valid UTF-8.
+    pub fn unescape_and_decode_value_with_custom_entities<B: BufRead>(
+        &self,
+        reader: &Reader<B>,
+        custom_entities: &HashMap<Vec<u8>, Vec<u8>>,
+    ) -> Result<String> {
+        self.do_unescape_and_decode_value(reader, Some(custom_entities))
+    }
+
+    /// The keys and values of `custom_entities`, if any, must be valid UTF-8.
+    #[cfg(feature = "encoding")]
+    fn do_unescape_and_decode_value<B: BufRead>(
+        &self,
+        reader: &Reader<B>,
+        custom_entities: Option<&HashMap<Vec<u8>, Vec<u8>>>,
+    ) -> Result<String> {
+        let decoded = reader.decode(&*self.value);
+        let unescaped =
+            do_unescape(decoded.as_bytes(), custom_entities).map_err(Error::EscapeError)?;
+        String::from_utf8(unescaped.into_owned()).map_err(|e| Error::Utf8(e.utf8_error()))
+    }
+
     #[cfg(not(feature = "encoding"))]
-    pub fn unescape_and_decode_value<B: BufRead>(&self, reader: &Reader<B>) -> Result<String> {
+    fn do_unescape_and_decode_value<B: BufRead>(
+        &self,
+        reader: &Reader<B>,
+        custom_entities: Option<&HashMap<Vec<u8>, Vec<u8>>>,
+    ) -> Result<String> {
         let decoded = reader.decode(&*self.value)?;
-        let unescaped = unescape(decoded.as_bytes()).map_err(Error::EscapeError)?;
+        let unescaped =
+            do_unescape(decoded.as_bytes(), custom_entities).map_err(Error::EscapeError)?;
         String::from_utf8(unescaped.into_owned()).map_err(|e| Error::Utf8(e.utf8_error()))
     }
 
@@ -142,9 +199,7 @@ impl<'a> Attribute<'a> {
         &self,
         reader: &mut Reader<B>,
     ) -> Result<String> {
-        let decoded = reader.decode_without_bom(&*self.value);
-        let unescaped = unescape(decoded.as_bytes()).map_err(Error::EscapeError)?;
-        String::from_utf8(unescaped.into_owned()).map_err(|e| Error::Utf8(e.utf8_error()))
+        self.do_unescape_and_decode_without_bom(reader, None)
     }
 
     /// helper method to unescape then decode self using the reader encoding
@@ -158,9 +213,71 @@ impl<'a> Attribute<'a> {
     pub fn unescape_and_decode_without_bom<B: BufRead>(
         &self,
         reader: &Reader<B>,
+    ) -> Result<String> {
+        self.do_unescape_and_decode_without_bom(reader, None)
+    }
+
+    /// helper method to unescape then decode self using the reader encoding with custom entities
+    /// but without BOM (Byte order mark)
+    ///
+    /// for performance reasons (could avoid allocating a `String`),
+    /// it might be wiser to manually use
+    /// 1. BytesText::unescaped()
+    /// 2. Reader::decode(...)
+    ///
+    /// # Pre-condition
+    ///
+    /// The keys and values of `custom_entities`, if any, must be valid UTF-8.
+    #[cfg(feature = "encoding")]
+    pub fn unescape_and_decode_without_bom_with_custom_entities<B: BufRead>(
+        &self,
+        reader: &mut Reader<B>,
+        custom_entities: &HashMap<Vec<u8>, Vec<u8>>,
+    ) -> Result<String> {
+        self.do_unescape_and_decode_without_bom(reader, Some(custom_entities))
+    }
+
+    /// helper method to unescape then decode self using the reader encoding with custom entities
+    /// but without BOM (Byte order mark)
+    ///
+    /// for performance reasons (could avoid allocating a `String`),
+    /// it might be wiser to manually use
+    /// 1. BytesText::unescaped()
+    /// 2. Reader::decode(...)
+    ///
+    /// # Pre-condition
+    ///
+    /// The keys and values of `custom_entities`, if any, must be valid UTF-8.
+    #[cfg(not(feature = "encoding"))]
+    pub fn unescape_and_decode_without_bom_with_custom_entities<B: BufRead>(
+        &self,
+        reader: &Reader<B>,
+        custom_entities: &HashMap<Vec<u8>, Vec<u8>>,
+    ) -> Result<String> {
+        self.do_unescape_and_decode_without_bom(reader, Some(custom_entities))
+    }
+
+    #[cfg(feature = "encoding")]
+    fn do_unescape_and_decode_without_bom<B: BufRead>(
+        &self,
+        reader: &mut Reader<B>,
+        custom_entities: Option<&HashMap<Vec<u8>, Vec<u8>>>,
+    ) -> Result<String> {
+        let decoded = reader.decode_without_bom(&*self.value);
+        let unescaped =
+            do_unescape(decoded.as_bytes(), custom_entities).map_err(Error::EscapeError)?;
+        String::from_utf8(unescaped.into_owned()).map_err(|e| Error::Utf8(e.utf8_error()))
+    }
+
+    #[cfg(not(feature = "encoding"))]
+    fn do_unescape_and_decode_without_bom<B: BufRead>(
+        &self,
+        reader: &Reader<B>,
+        custom_entities: Option<&HashMap<Vec<u8>, Vec<u8>>>,
     ) -> Result<String> {
         let decoded = reader.decode_without_bom(&*self.value)?;
-        let unescaped = unescape(decoded.as_bytes()).map_err(Error::EscapeError)?;
+        let unescaped =
+            do_unescape(decoded.as_bytes(), custom_entities).map_err(Error::EscapeError)?;
         String::from_utf8(unescaped.into_owned()).map_err(|e| Error::Utf8(e.utf8_error()))
     }
 }
diff --git a/src/events/mod.rs b/src/events/mod.rs
index d94ed3b..9342a93 100644
--- a/src/events/mod.rs
+++ b/src/events/mod.rs
@@ -5,13 +5,14 @@ pub mod attributes;
 #[cfg(feature = "encoding_rs")]
 use encoding_rs::Encoding;
 use std::borrow::Cow;
+use std::collections::HashMap;
 use std::io::BufRead;
 use std::ops::Deref;
 use std::str::from_utf8;
 
 use self::attributes::{Attribute, Attributes};
 use errors::{Error, Result};
-use escape::{escape, unescape};
+use escape::{do_unescape, escape};
 use reader::Reader;
 
 use memchr;
@@ -158,9 +159,38 @@ impl<'a> BytesStart<'a> {
     ///
     /// XML escape sequences like "`&lt;`" will be replaced by their unescaped characters like
     /// "`<`".
+    ///
+    /// See also [`unescaped_with_custom_entities()`](#method.unescaped_with_custom_entities)
     #[inline]
     pub fn unescaped(&self) -> Result<Cow<[u8]>> {
-        unescape(&*self.buf).map_err(Error::EscapeError)
+        self.make_unescaped(None)
+    }
+
+    /// Gets the unescaped tag name, using custom entities.
+    ///
+    /// XML escape sequences like "`&lt;`" will be replaced by their unescaped characters like
+    /// "`<`".
+    /// Additional entities can be provided in `custom_entities`.
+    ///
+    /// # Pre-condition
+    ///
+    /// The keys and values of `custom_entities`, if any, must be valid UTF-8.
+    ///
+    /// See also [`unescaped()`](#method.unescaped)
+    #[inline]
+    pub fn unescaped_with_custom_entities<'s>(
+        &'s self,
+        custom_entities: &HashMap<Vec<u8>, Vec<u8>>,
+    ) -> Result<Cow<'s, [u8]>> {
+        self.make_unescaped(Some(custom_entities))
+    }
+
+    #[inline]
+    fn make_unescaped<'s>(
+        &'s self,
+        custom_entities: Option<&HashMap<Vec<u8>, Vec<u8>>>,
+    ) -> Result<Cow<'s, [u8]>> {
+        do_unescape(&*self.buf, custom_entities).map_err(Error::EscapeError)
     }
 
     /// Returns an iterator over the attributes of this tag.
@@ -206,29 +236,57 @@ impl<'a> BytesStart<'a> {
     ///
     /// [`unescaped()`]: #method.unescaped
     /// [`Reader::decode()`]: ../reader/struct.Reader.html#method.decode
-    #[cfg(feature = "encoding")]
     #[inline]
     pub fn unescape_and_decode<B: BufRead>(&self, reader: &Reader<B>) -> Result<String> {
-        let decoded = reader.decode(&*self);
-        let unescaped = unescape(decoded.as_bytes()).map_err(Error::EscapeError)?;
-        String::from_utf8(unescaped.into_owned()).map_err(|e| Error::Utf8(e.utf8_error()))
+        self.do_unescape_and_decode_with_custom_entities(reader, None)
     }
 
-    /// Returns the unescaped and decoded string value.
+    /// Returns the unescaped and decoded string value with custom entities.
     ///
     /// This allocates a `String` in all cases. For performance reasons it might be a better idea to
     /// instead use one of:
     ///
-    /// * [`unescaped()`], as it doesn't allocate when no escape sequences are used.
+    /// * [`unescaped_with_custom_entities()`], as it doesn't allocate when no escape sequences are used.
     /// * [`Reader::decode()`], as it only allocates when the decoding can't be performed otherwise.
     ///
-    /// [`unescaped()`]: #method.unescaped
+    /// [`unescaped_with_custom_entities()`]: #method.unescaped_with_custom_entities
     /// [`Reader::decode()`]: ../reader/struct.Reader.html#method.decode
+    ///
+    /// # Pre-condition
+    ///
+    /// The keys and values of `custom_entities`, if any, must be valid UTF-8.
+    #[inline]
+    pub fn unescape_and_decode_with_custom_entities<B: BufRead>(
+        &self,
+        reader: &Reader<B>,
+        custom_entities: &HashMap<Vec<u8>, Vec<u8>>,
+    ) -> Result<String> {
+        self.do_unescape_and_decode_with_custom_entities(reader, Some(custom_entities))
+    }
+
+    #[cfg(feature = "encoding")]
+    #[inline]
+    fn do_unescape_and_decode_with_custom_entities<B: BufRead>(
+        &self,
+        reader: &Reader<B>,
+        custom_entities: Option<&HashMap<Vec<u8>, Vec<u8>>>,
+    ) -> Result<String> {
+        let decoded = reader.decode(&*self);
+        let unescaped =
+            do_unescape(decoded.as_bytes(), custom_entities).map_err(Error::EscapeError)?;
+        String::from_utf8(unescaped.into_owned()).map_err(|e| Error::Utf8(e.utf8_error()))
+    }
+
     #[cfg(not(feature = "encoding"))]
     #[inline]
-    pub fn unescape_and_decode<B: BufRead>(&self, reader: &Reader<B>) -> Result<String> {
+    fn do_unescape_and_decode_with_custom_entities<B: BufRead>(
+        &self,
+        reader: &Reader<B>,
+        custom_entities: Option<&HashMap<Vec<u8>, Vec<u8>>>,
+    ) -> Result<String> {
         let decoded = reader.decode(&*self)?;
-        let unescaped = unescape(decoded.as_bytes()).map_err(Error::EscapeError)?;
+        let unescaped =
+            do_unescape(decoded.as_bytes(), custom_entities).map_err(Error::EscapeError)?;
         String::from_utf8(unescaped.into_owned()).map_err(|e| Error::Utf8(e.utf8_error()))
     }
 
@@ -516,8 +574,35 @@ impl<'a> BytesText<'a> {
     ///
     /// Searches for '&' into content and try to escape the coded character if possible
     /// returns Malformed error with index within element if '&' is not followed by ';'
+    ///
+    /// See also [`unescaped_with_custom_entities()`](#method.unescaped_with_custom_entities)
     pub fn unescaped(&self) -> Result<Cow<[u8]>> {
-        unescape(self).map_err(Error::EscapeError)
+        self.make_unescaped(None)
+    }
+
+    /// gets escaped content with custom entities
+    ///
+    /// Searches for '&' into content and try to escape the coded character if possible
+    /// returns Malformed error with index within element if '&' is not followed by ';'
+    /// Additional entities can be provided in `custom_entities`.
+    ///
+    /// # Pre-condition
+    ///
+    /// The keys and values of `custom_entities`, if any, must be valid UTF-8.
+    ///
+    /// See also [`unescaped()`](#method.unescaped)
+    pub fn unescaped_with_custom_entities<'s>(
+        &'s self,
+        custom_entities: &HashMap<Vec<u8>, Vec<u8>>,
+    ) -> Result<Cow<'s, [u8]>> {
+        self.make_unescaped(Some(custom_entities))
+    }
+
+    fn make_unescaped<'s>(
+        &'s self,
+        custom_entities: Option<&HashMap<Vec<u8>, Vec<u8>>>,
+    ) -> Result<Cow<'s, [u8]>> {
+        do_unescape(self, custom_entities).map_err(Error::EscapeError)
     }
 
     /// helper method to unescape then decode self using the reader encoding
@@ -532,9 +617,7 @@ impl<'a> BytesText<'a> {
         &self,
         reader: &mut Reader<B>,
     ) -> Result<String> {
-        let decoded = reader.decode_without_bom(&*self);
-        let unescaped = unescape(decoded.as_bytes()).map_err(Error::EscapeError)?;
-        String::from_utf8(unescaped.into_owned()).map_err(|e| Error::Utf8(e.utf8_error()))
+        self.do_unescape_and_decode_without_bom(reader, None)
     }
 
     /// helper method to unescape then decode self using the reader encoding
@@ -548,9 +631,71 @@ impl<'a> BytesText<'a> {
     pub fn unescape_and_decode_without_bom<B: BufRead>(
         &self,
         reader: &Reader<B>,
+    ) -> Result<String> {
+        self.do_unescape_and_decode_without_bom(reader, None)
+    }
+
+    /// helper method to unescape then decode self using the reader encoding with custom entities
+    /// but without BOM (Byte order mark)
+    ///
+    /// for performance reasons (could avoid allocating a `String`),
+    /// it might be wiser to manually use
+    /// 1. BytesText::unescaped()
+    /// 2. Reader::decode(...)
+    ///
+    /// # Pre-condition
+    ///
+    /// The keys and values of `custom_entities`, if any, must be valid UTF-8.
+    #[cfg(feature = "encoding")]
+    pub fn unescape_and_decode_without_bom_with_custom_entities<B: BufRead>(
+        &self,
+        reader: &mut Reader<B>,
+        custom_entities: &HashMap<Vec<u8>, Vec<u8>>,
+    ) -> Result<String> {
+        self.do_unescape_and_decode_without_bom(reader, Some(custom_entities))
+    }
+
+    /// helper method to unescape then decode self using the reader encoding with custom entities
+    /// but without BOM (Byte order mark)
+    ///
+    /// for performance reasons (could avoid allocating a `String`),
+    /// it might be wiser to manually use
+    /// 1. BytesText::unescaped()
+    /// 2. Reader::decode(...)
+    ///
+    /// # Pre-condition
+    ///
+    /// The keys and values of `custom_entities`, if any, must be valid UTF-8.
+    #[cfg(not(feature = "encoding"))]
+    pub fn unescape_and_decode_without_bom_with_custom_entities<B: BufRead>(
+        &self,
+        reader: &Reader<B>,
+        custom_entities: &HashMap<Vec<u8>, Vec<u8>>,
+    ) -> Result<String> {
+        self.do_unescape_and_decode_without_bom(reader, Some(custom_entities))
+    }
+
+    #[cfg(feature = "encoding")]
+    fn do_unescape_and_decode_without_bom<B: BufRead>(
+        &self,
+        reader: &mut Reader<B>,
+        custom_entities: Option<&HashMap<Vec<u8>, Vec<u8>>>,
+    ) -> Result<String> {
+        let decoded = reader.decode_without_bom(&*self);
+        let unescaped =
+            do_unescape(decoded.as_bytes(), custom_entities).map_err(Error::EscapeError)?;
+        String::from_utf8(unescaped.into_owned()).map_err(|e| Error::Utf8(e.utf8_error()))
+    }
+
+    #[cfg(not(feature = "encoding"))]
+    fn do_unescape_and_decode_without_bom<B: BufRead>(
+        &self,
+        reader: &Reader<B>,
+        custom_entities: Option<&HashMap<Vec<u8>, Vec<u8>>>,
     ) -> Result<String> {
         let decoded = reader.decode_without_bom(&*self)?;
-        let unescaped = unescape(decoded.as_bytes()).map_err(Error::EscapeError)?;
+        let unescaped =
+            do_unescape(decoded.as_bytes(), custom_entities).map_err(Error::EscapeError)?;
         String::from_utf8(unescaped.into_owned()).map_err(|e| Error::Utf8(e.utf8_error()))
     }
 
@@ -560,23 +705,49 @@ impl<'a> BytesText<'a> {
     /// it might be wiser to manually use
     /// 1. BytesText::unescaped()
     /// 2. Reader::decode(...)
-    #[cfg(feature = "encoding")]
     pub fn unescape_and_decode<B: BufRead>(&self, reader: &Reader<B>) -> Result<String> {
-        let decoded = reader.decode(&*self);
-        let unescaped = unescape(decoded.as_bytes()).map_err(Error::EscapeError)?;
-        String::from_utf8(unescaped.into_owned()).map_err(|e| Error::Utf8(e.utf8_error()))
+        self.do_unescape_and_decode_with_custom_entities(reader, None)
     }
 
-    /// helper method to unescape then decode self using the reader encoding
+    /// helper method to unescape then decode self using the reader encoding with custom entities
     ///
     /// for performance reasons (could avoid allocating a `String`),
     /// it might be wiser to manually use
     /// 1. BytesText::unescaped()
     /// 2. Reader::decode(...)
+    ///
+    /// # Pre-condition
+    ///
+    /// The keys and values of `custom_entities`, if any, must be valid UTF-8.
+    pub fn unescape_and_decode_with_custom_entities<B: BufRead>(
+        &self,
+        reader: &Reader<B>,
+        custom_entities: &HashMap<Vec<u8>, Vec<u8>>,
+    ) -> Result<String> {
+        self.do_unescape_and_decode_with_custom_entities(reader, Some(custom_entities))
+    }
+
+    #[cfg(feature = "encoding")]
+    fn do_unescape_and_decode_with_custom_entities<B: BufRead>(
+        &self,
+        reader: &Reader<B>,
+        custom_entities: Option<&HashMap<Vec<u8>, Vec<u8>>>,
+    ) -> Result<String> {
+        let decoded = reader.decode(&*self);
+        let unescaped =
+            do_unescape(decoded.as_bytes(), custom_entities).map_err(Error::EscapeError)?;
+        String::from_utf8(unescaped.into_owned()).map_err(|e| Error::Utf8(e.utf8_error()))
+    }
+
     #[cfg(not(feature = "encoding"))]
-    pub fn unescape_and_decode<B: BufRead>(&self, reader: &Reader<B>) -> Result<String> {
+    fn do_unescape_and_decode_with_custom_entities<B: BufRead>(
+        &self,
+        reader: &Reader<B>,
+        custom_entities: Option<&HashMap<Vec<u8>, Vec<u8>>>,
+    ) -> Result<String> {
         let decoded = reader.decode(&*self)?;
-        let unescaped = unescape(decoded.as_bytes()).map_err(Error::EscapeError)?;
+        let unescaped =
+            do_unescape(decoded.as_bytes(), custom_entities).map_err(Error::EscapeError)?;
         String::from_utf8(unescaped.into_owned()).map_err(|e| Error::Utf8(e.utf8_error()))
     }
 
diff --git a/src/lib.rs b/src/lib.rs
index 726f9e7..4600c69 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -126,8 +126,8 @@ mod errors;
 mod escapei;
 pub mod escape {
     //! Manage xml character escapes
-    pub(crate) use escapei::EscapeError;
-    pub use escapei::{escape, unescape};
+    pub(crate) use escapei::{do_unescape, EscapeError};
+    pub use escapei::{escape, unescape, unescape_with};
 }
 pub mod events;
 mod reader;


Test patch:
diff --git a/tests/documents/html5-windows.txt b/tests/documents/html5-windows.txt
deleted file mode 100644
index ae6f701..0000000
--- a/tests/documents/html5-windows.txt
+++ /dev/null
@@ -1,8 +0,0 @@
-DocType( html)
-Characters(
-)
-StartElement(a, attr-error: error while parsing attribute at position 7: Attribute value must start with a quote.)
-Characters(Hey)
-EndElement(a)
-InvalidUtf8([13, 10, 38, 110, 98, 115, 112, 59, 13, 10]; invalid utf-8 sequence of 1 bytes from index 2)
-EndDocument
diff --git a/tests/unit_tests.rs b/tests/unit_tests.rs
index ecc098b..8893914 100644
--- a/tests/unit_tests.rs
+++ b/tests/unit_tests.rs
@@ -255,11 +255,6 @@ fn test_writer_indent() {
 
     let result = writer.into_inner().into_inner();
     // println!("{:?}", String::from_utf8_lossy(&result));
-
-    #[cfg(windows)]
-    assert!(result.into_iter().eq(txt.bytes().filter(|b| *b != 13)));
-
-    #[cfg(not(windows))]
     assert_eq!(result, txt.as_bytes());
 }
 
@@ -279,11 +274,6 @@ fn test_writer_indent_cdata() {
     }
 
     let result = writer.into_inner().into_inner();
-
-    #[cfg(windows)]
-    assert!(result.into_iter().eq(txt.bytes().filter(|b| *b != 13)));
-
-    #[cfg(not(windows))]
     assert_eq!(result, txt.as_bytes());
 }
 
diff --git a/tests/xmlrs_reader_tests.rs b/tests/xmlrs_reader_tests.rs
index 395efe3..19438d7 100644
--- a/tests/xmlrs_reader_tests.rs
+++ b/tests/xmlrs_reader_tests.rs
@@ -40,7 +40,7 @@ fn sample_2_full() {
     );
 }
 
-#[cfg(all(not(windows), feature = "escape-html"))]
+#[cfg(feature = "escape-html")]
 #[test]
 fn html5() {
     test(
@@ -50,17 +50,6 @@ fn html5() {
     );
 }
 
-#[cfg(all(windows, feature = "escape-html"))]
-#[test]
-fn html5() {
-    test(
-        include_bytes!("documents/html5.html"),
-        include_bytes!("documents/html5-windows.txt"),
-        false,
-    );
-}
-
-
 // #[test]
 // fn sample_3_short() {
 //     test(
@@ -432,23 +421,24 @@ impl<'a> Iterator for SpecIter<'a> {
         let start = self
             .0
             .iter()
-            .position(|b| !matches!(*b, b' ' | b'\r' | b'\n' | b'\t' | b'|' | b':' | b'0'..=b'9'))
+            .position(|b| match *b {
+                b' ' | b'\r' | b'\n' | b'\t' | b'|' | b':' => false,
+                b'0'..=b'9' => false,
+                _ => true,
+            })
             .unwrap_or(0);
-
-        if let Some(p) = self.0.windows(3).position(|w| w == b")\r\n") {
-            let (prev, next) = self.0.split_at(p + 1);
-            self.0 = &next[1..];
-            Some(from_utf8(&prev[start..]).expect("Error decoding to uft8"))
-        } else if let Some(p) = self.0.windows(2).position(|w| w == b")\n") {
+        if let Some(p) = self.0.windows(2).position(|w| w == b")\n") {
             let (prev, next) = self.0.split_at(p + 1);
             self.0 = next;
             Some(from_utf8(&prev[start..]).expect("Error decoding to uft8"))
-        } else if self.0.is_empty() {
-            None
         } else {
-            let p = self.0;
-            self.0 = &[];
-            Some(from_utf8(&p[start..]).unwrap())
+            if self.0.is_empty() {
+                None
+            } else {
+                let p = self.0;
+                self.0 = &[];
+                Some(from_utf8(&p[start..]).unwrap())
+            }
         }
     }
 }