diff --git a/crates/xlsx/src/tests/roundtrip.rs b/crates/xlsx/src/tests/roundtrip.rs index 7c7c19056..e35fb1a94 100644 --- a/crates/xlsx/src/tests/roundtrip.rs +++ b/crates/xlsx/src/tests/roundtrip.rs @@ -694,6 +694,47 @@ fn empty_workbook_roundtrip() { assert!(crate::sniff(&bytes) == crate::Format::Xlsx); } +#[test] +fn cell_text_carriage_returns_survive_xlsx_roundtrip() { + let mut wb = Workbook::new(); + let values = [("A1", "Line 1\r\nLine 2"), ("A2", "Part A\rPart B"), ("A3", "Item 3\nItem 4"), ("A4", "literal _x000D_ marker")]; + let sheet = wb.sheet_mut(0).unwrap(); + for (address, text) in values { + sheet.set_value(at(address), Value::text(text)); + } + let mut formula = Cell::formula(Formula::new("A1")); + formula.value = Value::text("Cached\r\ntext"); + sheet.cells.set(at("B1"), formula); + sheet.comments.insert( + at("A1"), + Comment { + author: "Tester".into(), + text: "Note\r\ntext".into(), + replies: vec![], + threaded: false, + resolved: false, + visible: false, + dates: vec![], + }, + ); + + let bytes = write_xlsx(&wb).unwrap(); + let mut package = Package::open(&bytes).unwrap(); + let shared_strings = String::from_utf8(package.read("xl/sharedStrings.xml").unwrap().unwrap()).unwrap(); + assert!(shared_strings.contains("Line 1_x000D_\nLine 2"), "CR must be encoded before XML parsing"); + assert!(shared_strings.contains("Part A_x000D_Part B")); + assert!(!shared_strings.contains('\r'), "raw CR is normalized to LF by XML parsers"); + let sheet_xml = String::from_utf8(package.read("xl/worksheets/sheet1.xml").unwrap().unwrap()).unwrap(); + assert!(sheet_xml.contains("Cached_x000D_\ntext"), "formula caches also use ST_Xstring"); + + let (back, _) = read_xlsx(&bytes).unwrap(); + for (address, text) in values { + assert_eq!(back.sheets[0].value(at(address)), Value::text(text), "{address} changed on save/reopen"); + } + assert_eq!(back.sheets[0].value(at("B1")), Value::text("Cached\r\ntext")); + assert_eq!(back.sheets[0].comments.get(&at("A1")).map(|c| c.text.as_str()), Some("Note\r\ntext")); +} + #[test] fn writing_repairs_invalid_models() { let mut wb = Workbook::new(); diff --git a/crates/xlsx/src/xml.rs b/crates/xlsx/src/xml.rs index f27927638..815851feb 100644 --- a/crates/xlsx/src/xml.rs +++ b/crates/xlsx/src/xml.rs @@ -253,6 +253,7 @@ pub fn esc(s: &str) -> String { '&' => o.push_str("&"), '<' => o.push_str("<"), '>' => o.push_str(">"), + '\r' => o.push_str(" "), c if is_xml_char(c) => o.push(c), _ => {} } @@ -283,15 +284,16 @@ fn is_xml_char(c: char) -> bool { matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}') } -/// ST_Xstring encoding for cell text: characters XML can't carry become `_xHHHH_`, and literal -/// `_xHHHH_` sequences are protected with `_x005F_`. The result still needs [`esc`]. +/// ST_Xstring encoding for cell text: characters XML can't carry and carriage returns become +/// `_xHHHH_`, and literal `_xHHHH_` sequences are protected with `_x005F_`. The result still needs +/// [`esc`]. pub fn encode_xstring(s: &str) -> String { let mut o = String::with_capacity(s.len()); let chars: Vec = s.chars().collect(); for (i, &c) in chars.iter().enumerate() { if c == '_' && looks_like_escape(&chars, i) { o.push_str("_x005F_"); - } else if !is_xml_char(c) || (c.is_control() && !matches!(c, '\t' | '\n' | '\r')) { + } else if !is_xml_char(c) || (c.is_control() && !matches!(c, '\t' | '\n')) { o.push_str(&format!("_x{:04X}_", c as u32 & 0xFFFF)); } else { o.push(c); @@ -417,6 +419,8 @@ mod tests { #[test] fn xstring() { assert_eq!(decode_xstring("a_x000D_b"), "a\rb"); + assert_eq!(encode_xstring("a\r\nb\rc"), "a_x000D_\nb_x000D_c"); + assert_eq!(decode_xstring(&encode_xstring("a\r\nb\rc")), "a\r\nb\rc"); assert_eq!(encode_xstring("a\u{1}b"), "a_x0001_b"); assert_eq!(decode_xstring(&encode_xstring("_x0041_")), "_x0041_"); assert_eq!(decode_xstring("_x00"), "_x00"); @@ -435,6 +439,12 @@ mod tests { assert_eq!(decode_xstring("_xD83D__x005F_xDE00_"), "_xD83D__xDE00_"); } + #[test] + fn text_escaping_preserves_carriage_returns() { + assert_eq!(esc("a\rb"), "a b"); + assert_eq!(esc_attr("a\rb"), "a b"); + } + #[test] fn numbers() { assert_eq!(num(1.0), "1"); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5a7588986..562d4bfcb 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** minor (.xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-19 · **Change:** minor (XLSX text keeps carriage returns on save and reopen; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests. Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h** @@ -92,6 +92,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here. | 2026-10-18 | trivial | XLSX PivotTables: `rowGrandTotals` is the Grand Total row and `colGrandTotals` the Grand Total column. Both were read and written the other way round, which a GridCraft round trip hid, so an Excel file with only one grand total opened showing the other | | 2026-10-19 | minor | HTML (Save as Web Page and clipboard) marks right-to-left sheets and right-to-left cell text with `dir` | | 2026-10-14 | trivial | Formula parser: text longer than Excel's 8,192 characters, and postfix chains (`1%%%…`, `f(1)(1)…`) past the 256-level nesting limit, are parse errors (the cell keeps its formula text and cached value) instead of trees deep enough to overflow the stack when a hostile file is opened, saved or dropped | +| 2026-10-15 | minor | XLSX text preserves CR and CRLF across save/reopen; shared strings use `_x000D_` and other XML text uses ` ` (#1488) | | 2026-10-12 | trivial | XLSX chart sheets are kept on save (#727) | | 2026-10-12 | trivial | CSV and TSV export is capped at 512 MiB and refuses a larger used range with an error (#528) | | 2026-10-12 | minor | `.xls` import reads the calculation settings (CalcMode, CalcIter, CalcCount, CalcDelta, CalcPrecision), so circular models calculate instead of showing `#CIRC!` (#724) |