//! Copy with formatting: selected text as paragraphs of styled spans, written as an HTML //! fragment for the clipboard (inline styles, so a word processor keeps font, size, weight, //! slant and colour when it pastes). use crate::as_html::allowed; use crate::{esc, hex}; /// Spans kept for one copy; text past it is left out and [`ClipText::truncated`] is set. pub const MAX_CLIP_SPANS: usize = 20_000; /// Text bytes kept for one copy (before escaping). pub const MAX_CLIP_TEXT_BYTES: usize = 2 << 20; /// The HTML written for one copy never passes this many bytes. pub const MAX_CLIP_HTML_BYTES: usize = 16 << 20; /// Font sizes are written within this range (points), so a hostile size stays a font size. const CLIP_SIZE_PT: std::ops::RangeInclusive = 1.0..=1000.0; /// A family name longer than this is not a family name. const MAX_FAMILY_CHARS: usize = 64; /// How a span looks. #[derive(Clone, Debug, Default, PartialEq)] pub struct ClipStyle { /// Family names to try in order ([`font_family`]); empty keeps the target's own. pub families: Vec, /// Size in points; zero or not finite leaves the target's size, and any other is written /// within 1 to 1000. pub size: f64, pub bold: bool, pub italic: bool, /// Fill colour as RGB in 0..=1. pub color: [f64; 3], } /// A run of text in one style. #[derive(Clone, Debug, PartialEq)] pub struct ClipSpan { pub text: String, pub style: ClipStyle, } /// The copied text: paragraphs of spans, and whether a cap left some out. #[derive(Clone, Debug, Default, PartialEq)] pub struct ClipText { pub paragraphs: Vec>, pub truncated: bool, } /// Builds a [`ClipText`] piece by piece, merging pieces of equal style into one span and /// stopping at the caps. #[derive(Debug)] pub struct ClipBuilder { out: ClipText, /// The separator owed before the next piece in this paragraph (a space or a line break). pending: Option, new_paragraph: bool, spans: usize, bytes: usize, } impl Default for ClipBuilder { fn default() -> Self { Self { out: ClipText::default(), pending: None, new_paragraph: true, spans: 0, bytes: 0 } } } impl ClipBuilder { pub fn new() -> Self { Self::default() } /// A word space before the next piece (dropped at the start of a paragraph). pub fn space(&mut self) { self.pending.get_or_insert(' '); } /// A line break inside the paragraph before the next piece. pub fn line_break(&mut self) { self.pending = Some('\n'); } /// The next piece starts a new paragraph. pub fn paragraph(&mut self) { self.new_paragraph = true; self.pending = None; } /// Append `text` in `style`. Returns `false` once a cap is reached; nothing more is kept. pub fn push(&mut self, text: &str, style: &ClipStyle) -> bool { if self.out.truncated { return false; } if text.is_empty() { return true; } let sep = if self.new_paragraph { None } else { self.pending }; let need = text.len().saturating_add(sep.map_or(0, char::len_utf8)); let Some(bytes) = self.bytes.checked_add(need).filter(|b| *b <= MAX_CLIP_TEXT_BYTES) else { self.out.truncated = true; return false; }; let new_span = self.new_paragraph || self.out.paragraphs.last().and_then(|p| p.last()).is_none_or(|last| last.style != *style); if new_span && self.spans >= MAX_CLIP_SPANS { self.out.truncated = true; return false; } if self.new_paragraph { self.out.paragraphs.push(Vec::new()); self.new_paragraph = false; } self.pending = None; let Some(spans) = self.out.paragraphs.last_mut() else { return false }; match spans.last_mut() { Some(last) if last.style == *style => { last.text.extend(sep); last.text.push_str(text); } last => { // A separator belongs to the span before it, so a new style starts at its text. if let (Some(last), Some(sep)) = (last, sep) { last.text.push(sep); } spans.push(ClipSpan { text: text.to_owned(), style: style.clone() }); self.spans += 1; } } self.bytes = bytes; true } pub fn finish(self) -> ClipText { self.out } } /// The family names to try for a font's `/BaseFont`, best first: subset tag and style suffix /// removed, `PSMT`/`MT`/`PS` dropped. The first has camel case and hyphens spaced /// (`ABCDEF+TimesNewRomanPSMT` → `Times New Roman`, `MS-Gothic` → `MS Gothic`); the second, /// when it differs, is the name as spelt (`SimSun`). A hyphen ends the family only before a /// style word (`Arial-BoldMT`). Only letters, digits, spaces, `-`, `_` and `.` are kept, so a /// name cannot leave its CSS string. Empty when nothing is left. pub fn font_family(base_font: &str) -> Vec { let name = match base_font.split_once('+') { Some((tag, rest)) if tag.len() == 6 && tag.bytes().all(|b| b.is_ascii_uppercase()) => rest, _ => base_font, }; let name = name.split_once(',').map_or(name, |(family, _)| family); let family = match name.rsplit_once('-') { Some((family, style)) if STYLE_WORDS.iter().any(|w| style.starts_with(w)) => family, _ => name, }; let family = family.strip_suffix("PSMT").or_else(|| family.strip_suffix("MT")).or_else(|| family.strip_suffix("PS")).unwrap_or(family); let mut spaced = String::with_capacity(family.len().min(MAX_FAMILY_CHARS * 2)); let mut prev_lower = false; for c in family.chars().take(MAX_FAMILY_CHARS) { if prev_lower && c.is_ascii_uppercase() { spaced.push(' '); } prev_lower = c.is_ascii_lowercase(); spaced.push(if c == '-' { ' ' } else { c }); } let spelt: String = family.chars().take(MAX_FAMILY_CHARS).collect(); let mut out: Vec = Vec::with_capacity(2); for f in [safe_family(&spaced), safe_family(&spelt)].into_iter().flatten() { if !out.contains(&f) { out.push(f); } } out } /// What may follow a hyphen in a `/BaseFont` when the rest names the style, not the family. const STYLE_WORDS: &[&str] = &[ "Bold", "Italic", "Oblique", "Regular", "Roman", "Light", "Medium", "Semi", "Demi", "Black", "Heavy", "Thin", "Book", "Cond", "Extra", "Ultra", "Narrow", "Plain", "Normal", "It", "Bd", "Lt", "Md", "Blk", "MT", "PS", ]; /// `family` with only letters, digits, spaces, `-`, `_` and `.`, so it cannot leave a CSS /// string; `None` when nothing is left. fn safe_family(family: &str) -> Option { let kept: String = family.chars().filter(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.')).take(MAX_FAMILY_CHARS * 2).collect(); let kept = kept.trim(); (!kept.is_empty()).then(|| kept.to_owned()) } /// The copied text as an HTML fragment, and whether [`MAX_CLIP_HTML_BYTES`] cut it: one `

` /// per paragraph, a `` per run with its families, size and colour inline, bold as /// `` and italic as `` (so a target that drops style attributes still keeps them). /// Text is escaped; a line break is `
`, and a space HTML would collapse (after another /// space, at a paragraph's start or after a break, across runs too) is ` `. Never longer /// than the cap: a paragraph or run that would pass it ends the fragment. pub fn clip_html(text: &ClipText) -> (String, bool) { const OPEN: &str = "

"; const CLOSE: &str = "

\n"; let fits = |len: usize, more: usize| len.saturating_add(more).saturating_add(CLOSE.len()) <= MAX_CLIP_HTML_BYTES; let mut out = String::new(); for spans in &text.paragraphs { if !fits(out.len(), OPEN.len()) { return (out, true); } let mut p = String::from(OPEN); // A paragraph's first space would collapse. let mut space_before = true; for span in spans { let s = span_html(span, &mut space_before); if !fits(out.len().saturating_add(p.len()), s.len()) { if p.len() > OPEN.len() { out.push_str(&p); out.push_str(CLOSE); } return (out, true); } p.push_str(&s); } p.push_str(CLOSE); out.push_str(&p); } (out, false) } /// One run as HTML. `space_before` says whether the text before it ended in a space or a break, /// and is left saying so for the next run. fn span_html(span: &ClipSpan, space_before: &mut bool) -> String { let st = &span.style; let mut css = Vec::new(); let families: Vec = st.families.iter().filter_map(|f| safe_family(f)).map(|f| format!("'{f}'")).collect(); if !families.is_empty() { css.push(format!("font-family:{}", families.join(","))); } if st.size.is_finite() && st.size != 0.0 { css.push(format!("font-size:{}pt", num(st.size.clamp(*CLIP_SIZE_PT.start(), *CLIP_SIZE_PT.end())))); } if let Some(h) = hex(st.color) { css.push(format!("color:#{h}")); } let mut body = text_html(&span.text, space_before); if st.italic { body = format!("{body}"); } if st.bold { body = format!("{body}"); } if css.is_empty() { body } else { format!("{body}", esc(&css.join(";"))) } } /// Escape for HTML text in one pass; `\n` becomes `
`, and a space after a space or a break /// (`space_before` carries that in and out) is ` `, so a run of spaces keeps its width. /// Dropped: what [`esc`] drops (C0 controls), C1 controls, a carriage return /// and the bidirectional overrides and isolates, which would reorder the pasted text. fn text_html(text: &str, space_before: &mut bool) -> String { let mut out = String::with_capacity(text.len()); for c in text.chars().filter(|c| clip_char(*c)) { match c { '\n' => out.push_str("
"), ' ' if *space_before => out.push_str(" "), '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), c => out.push(c), } *space_before = matches!(c, ' ' | '\n'); } out } /// Whether `c` may go into the clipboard HTML's text. fn clip_char(c: char) -> bool { allowed(c) && !matches!(c, '\r' | '\u{80}'..='\u{9F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}') } /// A size with at most one decimal and no trailing `.0`. fn num(v: f64) -> String { let r = (v * 10.0).round() / 10.0; if r.fract() == 0.0 { format!("{r:.0}") } else { format!("{r:.1}") } } #[cfg(test)] mod tests { use super::*; fn style(size: f64, bold: bool, italic: bool, color: [f64; 3]) -> ClipStyle { ClipStyle { families: font_family("ABCDEF+Helvetica-Bold"), size, bold, italic, color } } #[test] fn heading_body_and_coloured_span_keep_their_formatting() { let mut b = ClipBuilder::new(); let heading = style(24.0, true, false, [0.0; 3]); let body = style(11.5, false, false, [0.0; 3]); let red = style(11.5, false, true, [1.0, 0.0, 0.0]); b.push("Title", &heading); b.paragraph(); b.push("Body", &body); b.space(); b.push("text", &body); b.space(); b.push("red", &red); let clip = b.finish(); assert_eq!(clip.paragraphs.len(), 2); assert_eq!(clip.paragraphs.get(1).map(Vec::len), Some(2), "{clip:?}"); let (html, cut) = clip_html(&clip); assert!(!cut); assert_eq!( html, "

Title

\n\

Body text \ red

\n" ); } #[test] fn special_characters_are_escaped_and_cannot_leave_the_style() { let st = ClipStyle { families: vec!["Evil';}