diff --git a/crates/engine/src/cmd/richclip.rs b/crates/engine/src/cmd/richclip.rs index 69f70686..a28a7662 100644 --- a/crates/engine/src/cmd/richclip.rs +++ b/crates/engine/src/cmd/richclip.rs @@ -265,20 +265,22 @@ fn decode_text(raw: &str) -> String { break; }; let entity = &rest[1..end]; - rest = &rest[end + 1..]; - match entity { - "amp" => out.push('&'), - "lt" => out.push('<'), - "gt" => out.push('>'), - "quot" => out.push('"'), - "apos" => out.push('\''), - "nbsp" => out.push(' '), - _ => { - let ch = entity.strip_prefix('#').and_then(numeric_char); - if let Some(c) = ch { - out.push(c); - } - } + let decoded = match entity { + "amp" => Some('&'), + "lt" => Some('<'), + "gt" => Some('>'), + "quot" => Some('"'), + "apos" => Some('\''), + "nbsp" => Some(' '), + _ => entity.strip_prefix('#').and_then(numeric_char), + }; + if let Some(c) = decoded { + out.push(c); + rest = &rest[end + 1..]; + } else { + // Not a well-formed entity (e.g. a bare "R&D"): keep the '&' as typed. + out.push('&'); + rest = &rest[1..]; } } if !rest.is_empty() && !rest.starts_with('&') { @@ -327,6 +329,14 @@ mod tests { assert!(last.props.underline.is_some()); } + #[test] + fn bare_ampersand_keeps_text_up_to_next_semicolon() { + let text = |html: &str| html_to_body(html).unwrap().text(); + assert_eq!(text("

R&D costs; up

"), "R&D costs; up"); + assert_eq!(text("

AT&T; Q&A

"), "AT&T; Q&A"); + assert_eq!(text("

A & B & C & D

"), "A & B & C & D"); + } + #[test] fn pages_style_and_escapes() { let html = "

A & B

C

";