Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: umya-spreadsheet

on:
push:
branches: ["*"]
branches: ["**"]
pull_request:
branches: ["*"]
branches: ["**"]

env:
CARGO_TERM_COLOR: always
Expand Down
29 changes: 25 additions & 4 deletions src/helper/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,35 @@ macro_rules! from_err {
/// quick-xml reports references as separate `GeneralRef` events, so a text
/// event alone would read `R&D` as `R` and `D`. Readers take the raw
/// element content with `Reader::read_text_into` and unescape it here.
/// Malformed input keeps the raw text instead of panicking.
/// CDATA content stays literal; malformed spans keep their raw text.
pub(crate) fn unescape_xml_text(e: &quick_xml::events::BytesText<'_>) -> String {
let decoded = match e.decode() {
Ok(decoded) => decoded,
Err(_) => String::from_utf8_lossy(e),
};
match quick_xml::escape::unescape(&decoded) {
Ok(unescaped) => unescaped.into_owned(),
Err(_) => decoded.into_owned(),
let mut remaining = decoded.as_ref();
let mut output = String::with_capacity(remaining.len());
while let Some(start) = remaining.find("<![CDATA[") {
let prefix = &remaining[..start];
output.push_str(&quick_xml::escape::unescape(prefix).unwrap_or(prefix.into()));
let content = &remaining[start + 9..];
let Some(end) = content.find("]]>") else {
return decoded.into_owned();
};
output.push_str(&content[..end]);
remaining = &content[end + 3..];
}
output.push_str(&quick_xml::escape::unescape(remaining).unwrap_or(remaining.into()));
output
}

#[cfg(test)]
mod tests {
#[test]
fn malformed_text_keeps_the_existing_tolerant_behavior() {
for text in ["R&broken", "<![CDATA[unfinished", "before &unknown; after"] {
let raw = quick_xml::events::BytesText::from_escaped(text);
assert_eq!(super::unescape_xml_text(&raw), text);
}
}
}
76 changes: 76 additions & 0 deletions tests/cdata_text.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
use std::io::{Cursor, Read, Write};

use umya_spreadsheet::{reader, writer};
use zip::{write::SimpleFileOptions, ZipArchive, ZipWriter};

fn workbook_with_header_text(content: &str) -> Vec<u8> {
let mut book = umya_spreadsheet::new_file();
let sheet = book.get_sheet_mut(&0).unwrap();
sheet.get_cell_mut("A1").set_value("CDATA regression");
sheet
.get_header_footer_mut()
.get_odd_header_mut()
.set_value("HEADER_PLACEHOLDER");
sheet
.get_header_footer_mut()
.get_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 [
(
"&amp;CResearch &amp; Development",
"&CResearch & Development",
),
(
"<![CDATA[&C&BResearch & Development&B]]>",
"&C&BResearch & Development&B",
),
(
"&amp;LBefore <![CDATA[&amp; literal <tag>]]> &#65;<![CDATA[&Rafter]]>",
"&LBefore &amp; literal <tag> A&Rafter",
),
(
"<![CDATA[]]>&amp;C<![CDATA[One]]><![CDATA[Two]]>",
"&COneTwo",
),
("&lt;![CDATA[ordinary]]&gt;", "<![CDATA[ordinary]]>"),
] {
let bytes = workbook_with_header_text(xml);
let book = reader::xlsx::read_reader(Cursor::new(bytes), true).unwrap();
let header_footer = book.get_sheet(&0).unwrap().get_header_footer();
assert_eq!(
header_footer.get_odd_header().get_value(),
expected,
"{xml}"
);
assert_eq!(
header_footer.get_odd_footer().get_value(),
expected,
"{xml}"
);
}
}
Loading