mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-28 20:56:54 -06:00
Merge PR #1495: XLSX: preserve carriage returns in saved text
Conflicts: - crates/xlsx/src/xml.rs: kept both appended tests (surrogate pairs, carriage returns) - docs/file-format-parity.md: kept main's header and log rows, added this PR's change phrase and row once
This commit is contained in:
@@ -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();
|
||||
|
||||
+13
-3
@@ -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<char> = 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");
|
||||
|
||||
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user