From e54bd2ccb7d9b7c679701cf1c2eb8bdc9e9f95e5 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:35:04 +0200 Subject: [PATCH] Read a newline in pasted HTML text as a space Found while testing crates/engine/src/cmd/richclip.rs:257-283 (decode_text) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/richclip.rs | 53 ++++++++++++++++++++++++++++--- crates/engine/src/tests.rs | 19 +++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/richclip.rs b/crates/engine/src/cmd/richclip.rs index fc63314b..dc17474f 100644 --- a/crates/engine/src/cmd/richclip.rs +++ b/crates/engine/src/cmd/richclip.rs @@ -145,8 +145,13 @@ pub fn html_to_body(html: &str) -> Option { continue; } let end = bytes[i..].iter().position(|c| *c == b'<').map(|n| i + n).unwrap_or(bytes.len()); - let text = decode_text(&html[i..end]); + let mut text = decode_text(&html[i..end]); i = end; + // A space at the start of a paragraph, or after a space, does not render in HTML. + let after_space = paragraphs.last().and_then(|p| p.runs.last()).is_none_or(|r| r.text.ends_with(' ')); + if after_space { + text = text.trim_start_matches(' ').to_string(); + } if text.is_empty() { continue; } @@ -155,6 +160,7 @@ pub fn html_to_body(html: &str) -> Option { while paragraphs.last().is_some_and(Paragraph::is_empty) && paragraphs.len() > 1 { paragraphs.pop(); } + trim_end_space(&mut paragraphs); if paragraphs.iter().all(Paragraph::is_empty) { return None; } @@ -240,10 +246,19 @@ fn new_para(paragraphs: &mut Vec) { return; } if paragraphs.last().is_some_and(|p| !p.is_empty()) { + trim_end_space(paragraphs); paragraphs.push(Paragraph::default()); } } +/// Whitespace at the end of a paragraph does not render in HTML. +fn trim_end_space(paragraphs: &mut [Paragraph]) { + if let Some(run) = paragraphs.last_mut().and_then(|p| p.runs.last_mut()) { + let len = run.text.trim_end_matches(' ').len(); + run.text.truncate(len); + } +} + fn push_run(paragraphs: &mut [Paragraph], runs: &mut usize, text: String, bold: bool, italic: bool, underline: bool) { if *runs >= MAX_RUNS { return; @@ -263,14 +278,32 @@ fn push_run(paragraphs: &mut [Paragraph], runs: &mut usize, text: String, bold: *runs += 1; } +/// Text with each run of HTML whitespace (spaces, tabs, line breaks) collapsed to one space. +fn collapse_space(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut space = false; + for c in s.chars() { + if matches!(c, ' ' | '\t' | '\n' | '\r') { + if !space { + out.push(' '); + } + space = true; + } else { + out.push(c); + space = false; + } + } + out +} + fn decode_text(raw: &str) -> String { let mut out = String::new(); let mut rest = raw; while let Some(at) = rest.find('&') { - out.push_str(&rest[..at].replace(['\n', '\r'], "")); + out.push_str(&collapse_space(&rest[..at])); rest = &rest[at..]; let Some(end) = rest.find(';') else { - out.push_str(&rest.replace(['\n', '\r'], "")); + out.push_str(&collapse_space(rest)); break; }; let entity = &rest[1..end]; @@ -293,7 +326,7 @@ fn decode_text(raw: &str) -> String { } } if !rest.is_empty() && !rest.starts_with('&') { - out.push_str(&rest.replace(['\n', '\r'], "")); + out.push_str(&collapse_space(rest)); } out } @@ -371,6 +404,18 @@ mod tests { assert!(!same_words(&body.text(), "other")); } + #[test] + fn a_newline_in_html_text_is_a_space() { + assert_eq!(decode_text("Hello\nworld"), "Hello world"); + let body = html_to_body("

Hello\r\n world

").unwrap(); + assert_eq!(body.text(), "Hello world"); + let body = html_to_body("

\nHello\nworld\n

\n

next

").unwrap(); + assert_eq!(body.text(), "Hello world\nnext"); + assert!(same_words(&body.text(), "Hello world\nnext")); + assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true)); + assert_eq!(body.paragraphs[0].runs.last().unwrap().props.italic, Some(true)); + } + #[test] fn a_huge_fragment_is_refused() { let html = "x".repeat(MAX_HTML + 1); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index dd64b08c..e1d0f179 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -249,6 +249,25 @@ fn bold_copy_is_html_and_pages_html_pastes_bold() { assert_ne!(plain.paragraphs[0].runs[0].props.bold, Some(true)); } +#[test] +fn html_paste_with_a_source_newline_keeps_its_bold() { + let mut s = session(); + s.execute("edit.paste", &json!({"text": "Hello world", "html": "

Hello\nworld

"})).unwrap(); + let body = s + .doc() + .unwrap() + .current_slide() + .unwrap() + .shapes + .iter() + .find(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Hello world")) + .unwrap() + .text + .clone() + .unwrap(); + assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true), "{body:?}"); +} + #[test] fn group_ungroup_roundtrip_positions() { let mut s = session();