diff --git a/src/helper/utils.rs b/src/helper/utils.rs index 1995951a..a67dc682 100644 --- a/src/helper/utils.rs +++ b/src/helper/utils.rs @@ -16,9 +16,19 @@ pub(crate) fn md5_hash(input: impl AsRef<[u8]>) -> String { pub(crate) fn unescape_xml_text(e: &quick_xml::events::BytesText<'_>) -> String { let decoded = e.decode().unwrap(); - quick_xml::escape::unescape(decoded.as_ref()) - .unwrap() - .into_owned() + let mut remaining = decoded.as_ref(); + let mut output = String::with_capacity(remaining.len()); + // read_text_into retains XML markup. CDATA contains literal characters, + // including ampersands that must neither be decoded nor rejected. + while let Some(start) = remaining.find("").expect("unterminated CDATA section"); + output.push_str(&content[..end]); + remaining = &content[end + 3..]; + } + output.push_str(&quick_xml::escape::unescape(remaining).unwrap()); + output } /// A macro that implements the `From` trait for converting from one error type diff --git a/tests/cdata_text.rs b/tests/cdata_text.rs new file mode 100644 index 00000000..b7f00055 --- /dev/null +++ b/tests/cdata_text.rs @@ -0,0 +1,79 @@ +use std::io::{ + Cursor, + Read, + Write, +}; + +use umya_spreadsheet::{ + reader, + writer, +}; +use zip::{ + ZipArchive, + ZipWriter, + write::SimpleFileOptions, +}; + +fn workbook_with_header_text(content: &str) -> Vec { + let mut book = umya_spreadsheet::new_file(); + let sheet = book.sheet_mut(0).unwrap(); + sheet.cell_mut("A1").set_value("CDATA regression"); + sheet + .header_footer_mut() + .odd_header_mut() + .set_value("HEADER_PLACEHOLDER"); + sheet + .header_footer_mut() + .odd_footer_mut() + .set_value("FOOTER_PLACEHOLDER"); + let mut original = Vec::new(); + writer::xlsx::write_writer(&book, &mut original).unwrap(); + let mut source = ZipArchive::new(Cursor::new(original)).unwrap(); + let mut output = ZipWriter::new(Cursor::new(Vec::new())); + for index in 0..source.len() { + let mut part = source.by_index(index).unwrap(); + let mut bytes = Vec::new(); + part.read_to_end(&mut bytes).unwrap(); + if part.name() == "xl/worksheets/sheet1.xml" { + bytes = String::from_utf8(bytes) + .unwrap() + .replace("HEADER_PLACEHOLDER", content) + .replace("FOOTER_PLACEHOLDER", content) + .into_bytes(); + } + output + .start_file(part.name(), SimpleFileOptions::default()) + .unwrap(); + output.write_all(&bytes).unwrap(); + } + output.finish().unwrap().into_inner() +} + +#[test] +fn header_footer_text_preserves_cdata_and_decodes_only_outside_entities() { + for (xml, expected) in [ + ( + "&CResearch & Development", + "&CResearch & Development", + ), + ( + "", + "&C&BResearch & Development&B", + ), + ( + "&LBefore ]]> A", + "&LBefore & literal A&Rafter", + ), + ( + "&C", + "&COneTwo", + ), + ("<![CDATA[ordinary]]>", ""), + ] { + let bytes = workbook_with_header_text(xml); + let book = reader::xlsx::read_reader(Cursor::new(bytes), true).unwrap(); + let header_footer = book.sheet(0).unwrap().header_footer(); + assert_eq!(header_footer.odd_header().value(), expected, "{xml}"); + assert_eq!(header_footer.odd_footer().value(), expected, "{xml}"); + } +}