From fe965e758150adadfbc27cd154bc360675715c13 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 01:02:40 -0600 Subject: [PATCH 1/2] #2225: copy quotes only cells with a tab or line break Copy wrapped any cell that held a double quote in quotes (and doubled its quotes), so copying `say "hi"` into another app gave `"say ""hi"""`. Excel quotes only text with a tab or line break; a cell with just quotes goes on the clipboard as it is. Pasting text read a field that starts with a quote as quoted even when its closing quote doesn't end the field, so `"hi" there` (which Excel copies as it is) pasted as `hi there`. A field is now quoted only when its closing quote is followed by a tab, a line end or the end of the text; otherwise it is the text as written, as Excel pastes it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit/clipboard.rs | 10 ++++++++++ crates/engine/src/cmd/edit/tests.rs | 19 +++++++++++++++++++ crates/engine/src/display.rs | 4 +++- docs/ui-parity.md | 3 ++- 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs index 3a1cd0739..1490b082c 100644 --- a/crates/engine/src/cmd/edit/clipboard.rs +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -228,14 +228,23 @@ pub(crate) fn parse_tsv(text: &str) -> Vec> { let mut chars = text.chars().peekable(); let mut quoted = false; let mut at_start = true; + // A quoted field's text as it stands in the input, for a field that turns out not to be one. + let mut raw = String::new(); while let Some(c) = chars.next() { if quoted { + raw.push(c); if c == '"' { if chars.peek() == Some(&'"') { field.push('"'); + raw.push('"'); chars.next(); } else { quoted = false; + // A field is quoted only when its closing quote ends it, as Excel reads it: + // `"hi" there` is that text, quotes included. + if !matches!(chars.peek(), None | Some('\t' | '\n' | '\r')) { + field = std::mem::take(&mut raw); + } } } else { field.push(c); @@ -246,6 +255,7 @@ pub(crate) fn parse_tsv(text: &str) -> Vec> { '"' if at_start => { quoted = true; at_start = false; + raw = String::from('"'); } '\t' => { row.push(std::mem::take(&mut field)); diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index 3f3a2fb6d..5034646cd 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -307,6 +307,9 @@ fn tsv() { assert_eq!(parse_tsv("a\tb\n1\t2\n"), vec![vec!["a", "b"], vec!["1", "2"]]); assert_eq!(parse_tsv("\"x\ty\"\tz"), vec![vec!["x\ty", "z"]]); assert_eq!(parse_tsv("\"he said \"\"hi\"\"\""), vec![vec!["he said \"hi\""]]); + // #2225: a quote that doesn't close the field leaves it as written, as Excel pastes it. + assert_eq!(parse_tsv("\"hi\" there\tx\n"), vec![vec!["\"hi\" there", "x"]]); + assert_eq!(parse_tsv("say \"hi\"\n"), vec![vec!["say \"hi\""]]); } #[test] @@ -371,3 +374,19 @@ fn replace_case_insensitive_keeps_text_when_folding_moves_boundaries() { let sh = s.doc().unwrap().wb.active().unwrap(); assert_eq!(sh.value(CellRef::new(0, 0)), Value::text(text)); } + +#[test] +fn copy_quotes_only_cells_with_tabs_or_line_breaks() { + // #2225: a cell holding a quote was wrapped in quotes on copy; Excel quotes only text with a + // tab or line break, and pastes the result back as the same cells. + let mut s = Session::new(); + s.new_workbook(); + for (cell, input) in [("A1", "say \"hi\""), ("A2", "plain"), ("A3", "\"hi\" there"), ("A4", "x \"q\"\ny"), ("A5", "tab\tinside")] { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + let text = s.execute("edit.copy", json!({"range": "A1:A5"})).unwrap()["text"].as_str().unwrap().to_string(); + assert_eq!(text, "say \"hi\"\nplain\n\"hi\" there\n\"x \"\"q\"\"\ny\"\n\"tab\tinside\"\n"); + let rows = parse_tsv(&text); + let cells: Vec<&str> = rows.iter().map(|r| r[0].as_str()).collect(); + assert_eq!(cells, ["say \"hi\"", "plain", "\"hi\" there", "x \"q\"\ny", "tab\tinside"]); +} diff --git a/crates/engine/src/display.rs b/crates/engine/src/display.rs index 2189d6c64..06a0fa587 100644 --- a/crates/engine/src/display.rs +++ b/crates/engine/src/display.rs @@ -156,7 +156,9 @@ pub fn range_text(wb: &Workbook, sheet: usize, r: RangeRef) -> String { } first = false; let t = cell_text(wb, sh, CellRef::new(row, col)); - if t.contains(['\t', '\n', '"']) { + // Like Excel, only text with a tab or line break is quoted (its quotes doubled); + // `say "hi"` is copied as it is (#2225). + if t.contains(['\t', '\n', '\r']) { out.push('"'); out.push_str(&t.replace('"', "\"\"")); out.push('"'); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 125182d27..bf36ff04d 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** minor (copy quotes only cells with a tab or line break, as Excel does; earlier: RefEdit reference boxes in dialogs: Goal Seek, Solver and Data Analysis use them, the arrow keys select while a dialog is collapsed and F4 cycles a reference's anchors; earlier: Python cells: Python mode in the formula bar; earlier: Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4 Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md). **UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured @@ -237,6 +237,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | minor | Copy puts a cell that holds a quote on the clipboard as it is (`say "hi"`), quoting only text with a tab or line break, as Excel does; pasting text reads a field as quoted only when its closing quote ends it, so `"hi" there` pastes with its quotes (#2225) | | 2026-11-02 | minor | RefEdit reference boxes in dialogs: pick a range on the grid, collapse the dialog while selecting; Goal Seek, Solver (with the Model Range of Load/Save) and Data Analysis use them; while collapsed the arrow keys move the selection and Shift+arrow extends it, F4 cycles a reference's anchors, a click elsewhere keeps the box picking, a dialog opened over a picking one puts the selection back, OK stays on the sheet its command showed, and scripted cancel and confirm end a pick like the buttons; the dialog's sheet is followed through the recorded undo steps, so a sheet deleted and recreated under its name is not taken for it and an undo of the deletion brings it back; a box the dialog stops showing is no longer picked into | | 2026-11-02 | minor | Python mode edits a cell's code in the formula bar's code editor (Ctrl+Alt+Shift+P, `=PY` then Tab, editing a Python cell), with the PY badge beside fx; its keys apply in input order, also several in one frame, and Ctrl+Enter commits through `python.set` (empty code clears the cell), keeping the code and showing the command's message when the code is refused | | 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it | From 533c0dadafe4eefe246d0a3e0355bbd688698b39 Mon Sep 17 00:00:00 2001 From: Hector Villarreal Date: Tue, 3 Nov 2026 04:19:49 -0600 Subject: [PATCH 2/2] #2225: a field that only looks quoted keeps the tabs and line breaks in it Addresses CodeRabbit review on #3231, "Reparse consumed separators when a field is not quoted": parse_tsv read "ab"x as one quoted field, then fell back to its raw text without the separator it had consumed, so the cells "a, b"x and c pasted back as two. The new copy (which no longer quotes a cell holding only a quote) makes that text. A field whose closing quote doesn't end it, or that never closes, is now read again as unquoted text, so its tabs and line breaks separate cells. The text read again is bounded by the input's length, so hostile input stays linear. Test: a_field_that_is_not_really_quoted_keeps_its_separators. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit/clipboard.rs | 44 +++++++++++++++++++++---- crates/engine/src/cmd/edit/tests.rs | 22 +++++++++++++ 2 files changed, 59 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs index 1490b082c..0cd814ba0 100644 --- a/crates/engine/src/cmd/edit/clipboard.rs +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -226,24 +226,54 @@ pub(crate) fn parse_tsv(text: &str) -> Vec> { let mut row = Vec::new(); let mut field = String::new(); let mut chars = text.chars().peekable(); + // Text read again as unquoted, ahead of the rest of the input: a field that opened with a + // quote but turned out not to be quoted. + let mut replay: std::collections::VecDeque = std::collections::VecDeque::new(); + // Text is replayed at most as much as the input is long; past that, quotes are text, so + // hostile input full of fields that only look quoted reads in linear time. + let mut replay_budget = text.len(); let mut quoted = false; let mut at_start = true; // A quoted field's text as it stands in the input, for a field that turns out not to be one. let mut raw = String::new(); - while let Some(c) = chars.next() { + loop { + let c = match replay.pop_front().or_else(|| chars.next()) { + Some(c) => c, + // A quote that never closes: the field is its text as written. + None if quoted => { + quoted = false; + field.clear(); + field.push('"'); + replay_budget = replay_budget.saturating_sub(raw.len()); + replay.extend(raw.chars().skip(1)); + raw.clear(); + continue; + } + None => break, + }; if quoted { raw.push(c); if c == '"' { - if chars.peek() == Some(&'"') { + let next = replay.front().copied().or_else(|| chars.peek().copied()); + if next == Some('"') { field.push('"'); raw.push('"'); - chars.next(); + if replay.pop_front().is_none() { + chars.next(); + } } else { quoted = false; // A field is quoted only when its closing quote ends it, as Excel reads it: - // `"hi" there` is that text, quotes included. - if !matches!(chars.peek(), None | Some('\t' | '\n' | '\r')) { - field = std::mem::take(&mut raw); + // `"hi" there` is that text, quotes included, and a tab or line break inside + // it still ends the cell, as if the leading quote weren't there. + if !matches!(next, None | Some('\t' | '\n' | '\r')) { + field.clear(); + field.push('"'); + replay_budget = replay_budget.saturating_sub(raw.len()); + let rest: Vec = std::mem::take(&mut raw).chars().skip(1).collect(); + for ch in rest.into_iter().rev() { + replay.push_front(ch); + } } } } else { @@ -252,7 +282,7 @@ pub(crate) fn parse_tsv(text: &str) -> Vec> { continue; } match c { - '"' if at_start => { + '"' if at_start && replay_budget > 0 => { quoted = true; at_start = false; raw = String::from('"'); diff --git a/crates/engine/src/cmd/edit/tests.rs b/crates/engine/src/cmd/edit/tests.rs index 5034646cd..020592c56 100644 --- a/crates/engine/src/cmd/edit/tests.rs +++ b/crates/engine/src/cmd/edit/tests.rs @@ -390,3 +390,25 @@ fn copy_quotes_only_cells_with_tabs_or_line_breaks() { let cells: Vec<&str> = rows.iter().map(|r| r[0].as_str()).collect(); assert_eq!(cells, ["say \"hi\"", "plain", "\"hi\" there", "x \"q\"\ny", "tab\tinside"]); } + +#[test] +fn a_field_that_is_not_really_quoted_keeps_its_separators() { + // A field opening with a quote is quoted only if a closing quote ends it; when it doesn't (or + // never closes), the text is read as written, and the tabs and line breaks inside it still + // separate cells, as they would have without the leading quote. + assert_eq!(parse_tsv("\"a\tb\"x\tc"), vec![vec!["\"a", "b\"x", "c"]]); + assert_eq!(parse_tsv("\"a\tb\nc"), vec![vec!["\"a", "b"], vec!["c"]]); + assert_eq!(parse_tsv("\"a\tb\tc\n"), vec![vec!["\"a", "b", "c"]]); + // Cells copied without quotes (they hold no tab or line break) read back as the same cells. + let mut s = Session::new(); + s.new_workbook(); + for (cell, input) in [("A1", "'\"a"), ("B1", "b\"x"), ("C1", "c")] { + s.execute("cell.set", json!({"cell": cell, "input": input})).unwrap(); + } + let text = s.execute("edit.copy", json!({"range": "A1:C1"})).unwrap()["text"].as_str().unwrap().to_string(); + assert_eq!(parse_tsv(&text), vec![vec!["\"a", "b\"x", "c"]], "{text:?}"); + // Many fields that only look quoted: each is replayed at most once (the replay budget is the + // input's length), and every one is still its own cell. + let hostile = "\"x\t".repeat(50_000); + assert_eq!(parse_tsv(&hostile).first().map(Vec::len), Some(50_001)); +}