diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs index ad6a819027..b3439719a2 100644 --- a/crates/automation/src/pages.rs +++ b/crates/automation/src/pages.rs @@ -20,6 +20,10 @@ pub(crate) const FIND_MAX_WARNINGS: usize = 100; pub(crate) const FIND_MAX_WARNING_BYTES: usize = 1024; +/// HTML that one `text_extract` with `format: "html"` returns across all its pages; a page past +/// it gets none and the result says `html_truncated`. +const MAX_EXTRACT_HTML_BYTES: usize = 32 << 20; + impl Automation { // ---- pages and text ---------------------------------------------------------------------- @@ -201,6 +205,11 @@ impl Automation { } pub(crate) fn text_extract(&mut self, a: &Args) -> Result { + self.text_extract_within(a, MAX_EXTRACT_HTML_BYTES) + } + + /// [`Self::text_extract`] with at most `html_budget` bytes of HTML across all pages. + pub(crate) fn text_extract_within(&mut self, a: &Args, html_budget: usize) -> Result { let html = match a.opt_str("format")?.unwrap_or("text") { "text" => false, "html" => true, @@ -221,21 +230,37 @@ impl Automation { }; let doc = self.doc(a)?; let mut out = Vec::with_capacity(pages.len()); + let (mut html_left, mut html_truncated) = (html_budget, false); for (&p, t) in pages.iter().zip(texts) { let mut page = json!({ "page": p + 1, "text": text(&t) }); if html { - // Copy with formatting: the same glyphs as an HTML fragment. - let glyphs: Vec = match rect { - Some(r) => t.glyphs_in(r), - None => (0..t.glyphs.len()).collect(), - }; - let copy = doc.rich_copy(p, &t, &glyphs).map_err(failed)?; + // Copy with formatting: the same glyphs as an HTML fragment, within one budget for + // the whole call; a page past it gets none and the call says so. + let mut copy = pdfcraft_engine::RichCopy::default(); + if !html_truncated { + let glyphs: Vec = match rect { + Some(r) => t.glyphs_in(r), + None => (0..t.glyphs.len()).collect(), + }; + copy = doc.rich_copy(p, &t, &glyphs).map_err(failed)?; + match html_left.checked_sub(copy.html.len()) { + Some(left) => html_left = left, + None => { + html_truncated = true; + copy = pdfcraft_engine::RichCopy::default(); + } + } + } page["html"] = json!(copy.html); - page["styles_incomplete"] = json!(copy.styles_incomplete); + page["styles_incomplete"] = json!(copy.styles_incomplete || html_truncated); } out.push(page); } - Ok(json!({ "pages": out })) + let mut result = json!({ "pages": out }); + if html { + result["html_truncated"] = json!(html_truncated); + } + Ok(result) } /// Matches in document order, extracting page text in growing batches and stopping as soon as diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index fd4efa79da..ed1a0ecb81 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -550,3 +550,24 @@ fn tool_file_reads_refuse_inputs_exceeding_512_mib() { } let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn text_extract_html_has_one_budget_across_pages() { + let mut a = Automation::new(); + let id = search_document(&mut a, 3); + let args = json!({ "doc": id.0, "format": "html" }); + let full = a.text_extract(&Args(&args)).unwrap(); + assert_eq!(full["html_truncated"], false); + let sizes: Vec = full["pages"].as_array().unwrap().iter().map(|p| p["html"].as_str().unwrap().len()).collect(); + assert!(sizes.iter().all(|n| *n > 0), "{full}"); + // Room for two pages: the third gets no HTML, and the call says so. + let capped = a.text_extract_within(&Args(&args), sizes[0] + sizes[1] + sizes[2] - 1).unwrap(); + let pages = capped["pages"].as_array().unwrap(); + assert_eq!(capped["html_truncated"], true); + assert_eq!(pages[1]["html"], full["pages"][1]["html"]); + assert_eq!(pages[2]["html"], ""); + assert_eq!(pages[2]["styles_incomplete"], true); + assert_eq!(pages[2]["text"], "Page 3", "the plain text is not capped by the HTML budget"); + // Plain text alone says nothing about HTML. + assert!(a.text_extract(&Args(&json!({ "doc": id.0 }))).unwrap().get("html_truncated").is_none()); +} diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index dae824cf5b..6bfb0dec29 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -195,7 +195,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc(), "pages": pages("to count (default: all)"), "exclude_tables": { "type": "boolean" }, "exclude_code": { "type": "boolean" }, "exclude_images": { "type": "boolean" } }), &["doc"])), - t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab. With format \"html\", each page also carries html, the same text as Copy with formatting puts on the clipboard (paragraphs, bold, italic, size, colour and font family), and styles_incomplete when some styles had to be left out.") + t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab. With format \"html\", each page also carries html, the same text as Copy with formatting puts on the clipboard (paragraphs, bold, italic, size, colour and font family), and styles_incomplete when some styles had to be left out. The HTML of one call is capped at 32 MiB in total; pages past the cap carry empty html with styles_incomplete, and the result has html_truncated.") .ro() .cmd("edit.column_select") .with(schema( diff --git a/crates/engine/src/rich_copy.rs b/crates/engine/src/rich_copy.rs index 99c2938350..c1d6f8e9b6 100644 --- a/crates/engine/src/rich_copy.rs +++ b/crates/engine/src/rich_copy.rs @@ -37,16 +37,22 @@ impl crate::Document { /// Selected `glyphs` of `page` (indices into `text`, in copy order) with their formatting. /// Refused when the document does not allow copying. pub fn rich_copy(&self, page: usize, text: &PageText, glyphs: &[usize]) -> Result { + self.rich_copy_within(page, text, glyphs, MAX_MATCH_STEPS) + } + + /// [`Self::rich_copy`] with `steps` block tests to match glyphs to blocks. + fn rich_copy_within(&self, page: usize, text: &PageText, glyphs: &[usize], mut steps: usize) -> Result { self.check_copying()?; let (blocks, mut incomplete) = self.styled_blocks(page); let mut b = ClipBuilder::new(); - let mut steps = MAX_MATCH_STEPS; // The previous glyph's line and block, and whether it was a hyphen. let mut prev: Option<(usize, Option, bool)> = None; for &i in glyphs { let (Some(g), Some(&line)) = (text.glyphs.get(i), text.line_of.get(i)) else { continue }; - let found = block_at(&blocks, g.rect, prev.and_then(|(_, b, _)| b), &mut steps); - incomplete |= steps == 0; + let found = block_at(&blocks, g.rect, prev.and_then(|(_, b, _)| b), &mut steps).unwrap_or_else(|| { + incomplete = true; + None + }); let line = usize::try_from(line).unwrap_or(usize::MAX); if let Some((prev_line, prev_block, hyphen)) = prev { if line != prev_line { @@ -108,8 +114,8 @@ fn view_rect(info: &PageInfo, r: [f64; 4]) -> [f32; 4] { } /// The block whose box holds the centre of `glyph` (view space): `hint` first, then the -/// smallest that holds it. Each block tested costs a step; with none left, no block. -fn block_at(blocks: &[StyledBlock], glyph: [f32; 4], hint: Option, steps: &mut usize) -> Option { +/// smallest that holds it. Each block tested costs a step; `None` when the steps ran out first. +fn block_at(blocks: &[StyledBlock], glyph: [f32; 4], hint: Option, steps: &mut usize) -> Option> { let (x, y) = ((glyph[0] + glyph[2]) / 2.0, (glyph[1] + glyph[3]) / 2.0); let holds = |r: &[f32; 4]| x >= r[0] - SLACK && x <= r[2] + SLACK && y >= r[1] - SLACK && y <= r[3] + SLACK; let area = |r: &[f32; 4]| (r[2] - r[0]) * (r[3] - r[1]); @@ -118,7 +124,7 @@ fn block_at(blocks: &[StyledBlock], glyph: [f32; 4], hint: Option, steps: { *steps -= 1; if blocks.get(k).is_some_and(|b| holds(&b.rect)) { - return Some(k); + return Some(Some(k)); } } let mut best: Option<(usize, f32)> = None; @@ -131,7 +137,7 @@ fn block_at(blocks: &[StyledBlock], glyph: [f32; 4], hint: Option, steps: best = Some((k, area(&b.rect))); } } - best.map(|(k, _)| k) + Some(best.map(|(k, _)| k)) } #[cfg(test)] @@ -198,6 +204,30 @@ mod tests { assert!(past.html.is_empty()); } + #[test] + fn running_out_of_match_steps_copies_the_rest_unstyled() { + let mut s = Session::new(); + let id = s.open("styled.pdf", None, Arc::new(styled_page()), None).unwrap(); + let text = page_text(&s, id); + let all: Vec = (0..text.glyphs.len()).collect(); + let doc = s.get(id).unwrap(); + let full = doc.rich_copy(0, &text, &all).unwrap(); + // Enough for the first glyphs only: the text is all there, the rest has no style. + let short = doc.rich_copy_within(0, &text, &all, 8).unwrap(); + assert!(short.styles_incomplete); + let plain = |c: &super::RichCopy| c.text.paragraphs.iter().flatten().map(|s| s.text.clone()).collect::(); + assert_eq!(plain(&short).replace([' ', '\n'], ""), plain(&full).replace([' ', '\n'], "")); + let last = short.text.paragraphs.last().and_then(|p| p.last()).unwrap(); + assert_eq!(last.style, pdfcraft_export::ClipStyle::default(), "{last:?}"); + assert!(short.html.starts_with("

T"), "{}", short.html); + // No steps at all: nothing styled, still no panic. + let none = doc.rich_copy_within(0, &text, &all, 0).unwrap(); + assert!(none.styles_incomplete && !none.html.contains("font-size"), "{}", none.html); + // A page without blocks to match needs no steps. + let page_less = doc.rich_copy_within(5, &text, &all, 0).unwrap(); + assert!(!page_less.styles_incomplete); + } + #[test] fn copy_with_formatting_is_refused_when_copying_is_withheld() { let mut cos = pdfcraft_cos::Document::open(Arc::new(styled_page())).unwrap(); diff --git a/crates/export/src/clip.rs b/crates/export/src/clip.rs index 0b214c2bcd..65a25c17a1 100644 --- a/crates/export/src/clip.rs +++ b/crates/export/src/clip.rs @@ -2,6 +2,7 @@ //! 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. @@ -10,6 +11,8 @@ pub const MAX_CLIP_SPANS: usize = 20_000; 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; @@ -18,7 +21,8 @@ const MAX_FAMILY_CHARS: usize = 64; pub struct ClipStyle { /// The family a word processor knows ([`font_family`]); `None` keeps the target's own. pub family: Option, - /// Size in points; zero or not finite leaves the target's size. + /// 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, @@ -186,7 +190,7 @@ fn span_html(span: &ClipSpan) -> String { css.push(format!("font-family:'{f}'")); } if st.size.is_finite() && st.size > 0.0 { - css.push(format!("font-size:{}pt", num(st.size))); + 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}")); @@ -201,28 +205,32 @@ fn span_html(span: &ClipSpan) -> String { if css.is_empty() { body } else { format!("{body}", esc(&css.join(";"))) } } -/// Escape for HTML text (control characters dropped by [`esc`]); `\n` becomes `
` and a -/// run of spaces keeps its width with ` `. +/// Escape for HTML text in one pass; `\n` becomes `
` and a run of spaces keeps its width +/// with ` `. 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) -> String { let mut out = String::with_capacity(text.len()); let mut prev_space = false; - for (k, line) in text.split('\n').enumerate() { - if k > 0 { - out.push_str("
"); - prev_space = false; - } - for c in line.chars() { - if c == ' ' && prev_space { - out.push_str(" "); - } else { - out.push_str(&esc(c.encode_utf8(&mut [0; 4]))); - } - prev_space = c == ' '; + for c in text.chars().filter(|c| clip_char(*c)) { + match c { + '\n' => out.push_str("
"), + ' ' if prev_space => out.push_str(" "), + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + c => out.push(c), } + prev_space = c == ' '; } 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; @@ -274,6 +282,24 @@ mod tests { ); } + #[test] + fn invisible_controls_and_bidi_overrides_are_dropped() { + let mut b = ClipBuilder::new(); + b.push("a\u{85}b\u{9F}c\rd\u{202E}e\u{2066}f\u{2069}g\u{202A}h\u{2067}i\tj", &ClipStyle::default()); + assert_eq!(clip_html(&b.finish()), "

abcdefghi\tj

\n"); + } + + #[test] + fn font_sizes_stay_within_range() { + for (size, want) in [(1e300, "1000"), (f64::MAX, "1000"), (2e306, "1000"), (0.2, "1"), (9.96, "10")] { + let st = ClipStyle { size, ..ClipStyle::default() }; + let mut b = ClipBuilder::new(); + b.push("x", &st); + let html = clip_html(&b.finish()); + assert_eq!(html, format!("

x

\n"), "{size}"); + } + } + #[test] fn families_read_like_a_word_processor_names_them() { assert_eq!(font_family("ABCDEF+TimesNewRomanPSMT").as_deref(), Some("Times New Roman")); diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 39e93018d3..e6392a85c5 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -233,8 +233,10 @@ impl PdfCraftApp { last_snapshot: None, last_copied_image: None, last_copied_file: None, - last_copied_html: None, system_clipboard: true, + html_clipboard_override: None, + #[cfg(not(target_arch = "wasm32"))] + os_clipboard: None, clipboard_override: None, attach_override: None, duplicate_draft: None, diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs index e03f71d277..ff740ba8d0 100644 --- a/crates/ui-egui/src/app/clipboard.rs +++ b/crates/ui-egui/src/app/clipboard.rs @@ -124,32 +124,47 @@ impl PdfCraftApp { return true; } let Some(text) = v.selected_text().filter(|text| !text.is_empty()) else { return false }; - let glyphs = v.selected_glyphs(); if !self.may_copy(id) { return false; } - self.last_copied_html = glyphs - .and_then(|(page, layer, glyphs)| self.session.get(id)?.rich_copy(page, &layer, &glyphs).ok()) - .map(|copy| copy.html) - .filter(|html| !html.is_empty()); - if !self.put_rich_text(&text) { + // Built only when there is a clipboard to take it. + let html = if self.takes_html() { + self.views + .get(i) + .and_then(DocView::selected_glyphs) + .and_then(|(page, layer, glyphs)| self.session.get(id)?.rich_copy(page, &layer, &glyphs).ok()) + .map(|copy| copy.html) + .filter(|html| !html.is_empty()) + } else { + None + }; + if !html.is_some_and(|html| self.put_rich_text(&html, &text)) { ctx.copy_text(text); } true } - /// Put [`Self::last_copied_html`] on the system clipboard with `text` as its plain - /// alternative (Copy with formatting). `false` when it wasn't (no HTML, the system clipboard - /// is off or failed, the web build), so the caller copies the plain text the usual way. - fn put_rich_text(&self, text: &str) -> bool { + /// Whether Copy with formatting has a clipboard to write: the system clipboard is on, and + /// this isn't the web build (or a test stands in for the clipboard). + fn takes_html(&self) -> bool { + self.system_clipboard && (self.html_clipboard_override.is_some() || cfg!(not(target_arch = "wasm32"))) + } + + /// Put `html` on the clipboard with `text` as its plain alternative, in one set (egui has no + /// HTML output, and its own text copy at the end of the frame would replace the HTML). + /// `false` when it wasn't, so the caller copies the plain text the usual way. + fn put_rich_text(&mut self, html: &str, text: &str) -> bool { + if let Some(set) = self.html_clipboard_override.as_mut() { + return set(html, text); + } #[cfg(not(target_arch = "wasm32"))] - if self.system_clipboard - && let Some(html) = self.last_copied_html.as_deref() { - return crate::os_clipboard::with(|c| c.set().html(html, Some(text))).is_ok(); + crate::os_clipboard::with_kept(&mut self.os_clipboard, |c| c.set().html(html, Some(text))).is_ok() + } + #[cfg(target_arch = "wasm32")] + { + false } - let _ = text; - false } /// The clipboard text of a whole-document copy, once its pages are read; dropped with the diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1f85ce697f..a47897f021 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -249,6 +249,10 @@ struct PendingOpen { display_override: Option<(canvas::PageLayout, bool)>, } +/// Puts `(html, plain_text)` on a clipboard; `false` when it couldn't, and plain-text Copy is +/// used instead. +pub type HtmlClipboard = Box bool>; + pub struct PdfCraftApp { /// Tools side and pane widths; which panes are open is `left_open` and `right`. pub side_panels: side_panels::SidePanels, @@ -563,10 +567,14 @@ pub struct PdfCraftApp { /// The last image copied in its original format (file name, bytes), also put on the /// system clipboard as a file when `system_clipboard` is on. pub last_copied_file: Option<(String, Vec)>, - /// The HTML of the last text copy (Copy with formatting), also put on the system clipboard - /// next to the plain text when `system_clipboard` is on. - pub last_copied_html: Option, pub system_clipboard: bool, + /// Where Copy with formatting puts its HTML and the plain alternative instead of the system + /// clipboard, when `system_clipboard` is on (tests). + pub html_clipboard_override: Option, + /// The system clipboard Copy with formatting writes to, kept open so that X11 goes on + /// serving the copy after the frame. + #[cfg(not(target_arch = "wasm32"))] + os_clipboard: Option, /// What ⌘V reads instead of the system clipboard (tests). pub clipboard_override: Option, /// Attach file: the file to attach instead of asking (tests, automation). diff --git a/crates/ui-egui/src/os_clipboard.rs b/crates/ui-egui/src/os_clipboard.rs index 9f68bd957b..64b01fdf3d 100644 --- a/crates/ui-egui/src/os_clipboard.rs +++ b/crates/ui-egui/src/os_clipboard.rs @@ -17,6 +17,21 @@ pub(crate) fn with(f: impl FnOnce(&mut arboard::Clipboard) -> Result( + kept: &mut Option, + f: impl FnOnce(&mut arboard::Clipboard) -> Result, +) -> Result { + let _turn = LOCK.lock().unwrap_or_else(PoisonError::into_inner); + let clipboard = match kept { + Some(clipboard) => clipboard, + None => kept.insert(arboard::Clipboard::new()?), + }; + f(clipboard) +} + /// Nonempty text supersedes the app's cached image, even if another clipboard format also exists. pub(crate) fn has_text() -> bool { with(|cb| cb.get_text()).is_ok_and(|text| !text.is_empty()) diff --git a/crates/ui-egui/tests/it/text_select.rs b/crates/ui-egui/tests/it/text_select.rs index 79c2cf1d05..6c50af0f3c 100644 --- a/crates/ui-egui/tests/it/text_select.rs +++ b/crates/ui-egui/tests/it/text_select.rs @@ -302,28 +302,77 @@ fn selected_text_copy_floating_button_honours_permissions() { } } -/// Copy with formatting: Copy also produces the selection as HTML (font, size) for the system -/// clipboard, next to the plain text, and a new copy replaces it. +/// What the stand-in for the system clipboard was given: `(html, plain text)` per copy. +type HtmlSets = std::sync::Arc>>; + +/// The system clipboard on, through a stand-in that takes or refuses Copy with formatting. +fn html_clipboard(app: &mut PdfCraftApp, takes: bool) -> HtmlSets { + let sets = HtmlSets::default(); + let seen = sets.clone(); + app.system_clipboard = true; + app.html_clipboard_override = Some(Box::new(move |html, text| { + seen.lock().unwrap().push((html.to_owned(), text.to_owned())); + takes + })); + sets +} + +fn any_copy_text(h: &Harness<'static, PdfCraftApp>) -> bool { + h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(_))) +} + +/// Copy with formatting: Copy puts the selection as HTML (font, size) and its plain text on the +/// system clipboard in one set, and doesn't also copy the text through egui, which would +/// replace the HTML. A new copy replaces it. #[test] fn selected_text_copy_keeps_the_formatting_as_html() { - let (mut h, at) = harness(|app| app.system_clipboard = false); + let (mut h, at) = harness(|_| {}); + let sets = html_clipboard(h.state_mut(), true); click(&mut h, at); click(&mut h, at); assert!(h.state_mut().execute("edit.copy")); h.step(); - assert!(copied(&h, "quick"), "the plain text still goes out"); assert_eq!( - h.state().last_copied_html.as_deref(), - Some("

quick

\n") + sets.lock().unwrap().as_slice(), + [("

quick

\n".to_owned(), "quick".to_owned())] ); + assert!(!any_copy_text(&h), "egui's own text copy would replace the HTML"); // Three clicks: the line, a new copy. h.run_steps(60); for _ in 0..3 { click(&mut h, at); } assert!(h.state_mut().execute("edit.copy")); - let html = h.state().last_copied_html.clone().unwrap_or_default(); - assert!(html.contains(">The quick brown fox"), "{html}"); + let last = sets.lock().unwrap().last().cloned().unwrap_or_default(); + assert!(last.0.contains(">The quick brown fox"), "{last:?}"); + assert_eq!(last.1, "The quick brown fox"); +} + +/// When the system clipboard refuses the HTML, Copy still copies the plain text. +#[test] +fn selected_text_copy_falls_back_to_plain_text_when_html_is_refused() { + let (mut h, at) = harness(|_| {}); + let sets = html_clipboard(h.state_mut(), false); + click(&mut h, at); + click(&mut h, at); + assert!(h.state_mut().execute("edit.copy")); + h.step(); + assert_eq!(sets.lock().unwrap().len(), 1, "the HTML was offered"); + assert!(copied(&h, "quick"), "the plain text goes out the usual way"); +} + +/// With the system clipboard off, no HTML is built and the plain text goes out as before. +#[test] +fn selected_text_copy_without_system_clipboard_builds_no_html() { + let (mut h, at) = harness(|_| {}); + let sets = html_clipboard(h.state_mut(), true); + h.state_mut().system_clipboard = false; + click(&mut h, at); + click(&mut h, at); + assert!(h.state_mut().execute("edit.copy")); + h.step(); + assert!(sets.lock().unwrap().is_empty()); + assert!(copied(&h, "quick")); } /// A document that withholds copying gives neither the text nor its HTML. @@ -340,17 +389,15 @@ fn selected_text_copy_with_formatting_honours_permissions() { }) .unwrap(); let bytes = pdfcraft_cos::write_full(&doc, &Default::default()).unwrap(); - let (mut h, at) = harness_with(bytes, |app| { - app.submit_password(Some("pw".into())); - app.system_clipboard = false; - }); + let (mut h, at) = harness_with(bytes, |app| app.submit_password(Some("pw".into()))); + let sets = html_clipboard(h.state_mut(), true); click(&mut h, at); click(&mut h, at); assert_eq!(h.state().views[0].selected_text().as_deref(), Some("quick")); assert!(!h.state_mut().execute("edit.copy")); h.step(); - assert!(!copied(&h, "quick")); - assert_eq!(h.state().last_copied_html, None); + assert!(!any_copy_text(&h)); + assert!(sets.lock().unwrap().is_empty()); } #[test] diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 19e98fbf90..fd5b2491e0 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1574,7 +1574,7 @@ milestone = "M3" status = "partial" commands = ["edit.copy"] tools = ["text_extract"] -evidence = ["crates/export/src/clip.rs::heading_body_and_coloured_span_keep_their_formatting", "crates/export/src/clip.rs::special_characters_are_escaped_and_cannot_leave_the_style", "crates/export/src/clip.rs::empty_and_huge_selections_are_bounded", "crates/engine/src/rich_copy.rs::copy_keeps_headings_body_and_colour_as_html", "crates/engine/src/rich_copy.rs::copy_with_formatting_is_refused_when_copying_is_withheld", "crates/automation/tests/it/automation.rs::text_extract_as_html_keeps_the_formatting", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_keeps_the_formatting_as_html", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_with_formatting_honours_permissions"] +evidence = ["crates/export/src/clip.rs::heading_body_and_coloured_span_keep_their_formatting", "crates/export/src/clip.rs::special_characters_are_escaped_and_cannot_leave_the_style", "crates/export/src/clip.rs::empty_and_huge_selections_are_bounded", "crates/export/src/clip.rs::invisible_controls_and_bidi_overrides_are_dropped", "crates/export/src/clip.rs::font_sizes_stay_within_range", "crates/engine/src/rich_copy.rs::copy_keeps_headings_body_and_colour_as_html", "crates/engine/src/rich_copy.rs::copy_with_formatting_is_refused_when_copying_is_withheld", "crates/engine/src/rich_copy.rs::running_out_of_match_steps_copies_the_rest_unstyled", "crates/automation/tests/it/automation.rs::text_extract_as_html_keeps_the_formatting", "crates/automation/src/tests.rs::text_extract_html_has_one_budget_across_pages", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_keeps_the_formatting_as_html", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_falls_back_to_plain_text_when_html_is_refused", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_with_formatting_honours_permissions"] notes = "Copy of a page-text selection also puts HTML on the system clipboard (paragraphs, bold, italic, size, colour, font family; plain text as the alternative), and text_extract format html returns the same. Missing: RTF, underline and strike-through, tables, Select All of the whole document (plain text only), the web build (plain text only), and styles for glyphs outside the page's reading blocks (copied unstyled)" [[feature]]