M3: copy with formatting keeps one clipboard open and is tested through a stand-in

Review fixes for Copy with formatting.

- ui-egui: the HTML copy goes through one arboard Clipboard, created on
  first use and kept in the app (os_clipboard::with_kept, under the same
  process-wide lock), instead of a fresh one per copy. On X11 the copied
  data is served only while its Clipboard lives, so a fresh one dropped
  at once could leave nothing to paste without a clipboard manager. A
  failed create or set falls back to egui's plain-text copy.
- ui-egui: html_clipboard_override stands in for the system clipboard in
  tests. With the system clipboard on, Copy sets HTML and plain text in
  one set and emits no CopyText; when the set is refused it emits
  CopyText with the plain text. The HTML is built only when a clipboard
  will take it (system clipboard on, not the web build). The
  last_copied_html field is gone.
- export: clipboard HTML text is escaped in one pass and also drops C1
  controls, a bare carriage return and the bidirectional overrides and
  isolates. Font sizes are written within 1 to 1000 pt.
- engine: the glyph-to-block step budget is a parameter, tested; the copy
  says styles_incomplete only when matching actually ran out of steps.
- automation: text_extract format "html" has one 32 MiB HTML budget
  across all pages; a page past it gets none, and the result carries
  html_truncated. The tool description says so.
- parity: the new tests are evidence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
This commit is contained in:
KenM76andClaude Opus 5.5 committed 2026-11-04 15:51:57 -05:00
1 parent 352355ab77
commit a955373428
11 files changed
+255 -66

No files matched your search

+33 -8
View File
@@ -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<Value> {
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<Value> {
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<usize> = 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<usize> = 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
+21
View File
@@ -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<usize> = 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());
}
+1 -1
View File
@@ -195,7 +195,7 @@ pub fn tools() -> Vec<ToolDef> {
.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(
+37 -7
View File
@@ -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<RichCopy, EditError> {
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<RichCopy, EditError> {
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<usize>, 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<usize>, steps: &mut usize) -> Option<usize> {
/// 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<usize>, steps: &mut usize) -> Option<Option<usize>> {
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<usize>, 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<usize>, 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<usize> = (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::<String>();
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("<p style=\"margin:0\"><span style=\"font-family:'Helvetica';font-size:24pt\"><strong>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();
+42 -16
View File
@@ -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<f64> = 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<String>,
/// 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!("<span style=\"{}\">{body}</span>", esc(&css.join(";"))) }
}
/// Escape for HTML text (control characters dropped by [`esc`]); `\n` becomes `<br>` and a
/// run of spaces keeps its width with `&nbsp;`.
/// Escape for HTML text in one pass; `\n` becomes `<br>` and a run of spaces keeps its width
/// with `&nbsp;`. 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("<br>");
prev_space = false;
}
for c in line.chars() {
if c == ' ' && prev_space {
out.push_str("&nbsp;");
} 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("<br>"),
' ' if prev_space => out.push_str("&nbsp;"),
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
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()), "<p style=\"margin:0\">abcdefghi\tj</p>\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!("<p style=\"margin:0\"><span style=\"font-size:{want}pt\">x</span></p>\n"), "{size}");
}
}
#[test]
fn families_read_like_a_word_processor_names_them() {
assert_eq!(font_family("ABCDEF+TimesNewRomanPSMT").as_deref(), Some("Times New Roman"));
+3 -1
View File
@@ -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,
+30 -15
View File
@@ -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
+11 -3
View File
@@ -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<dyn FnMut(&str, &str) -> 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<u8>)>,
/// 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<String>,
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<HtmlClipboard>,
/// 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<arboard::Clipboard>,
/// What ⌘V reads instead of the system clipboard (tests).
pub clipboard_override: Option<create_ui::Clip>,
/// Attach file: the file to attach instead of asking (tests, automation).
+15
View File
@@ -17,6 +17,21 @@ pub(crate) fn with<T>(f: impl FnOnce(&mut arboard::Clipboard) -> Result<T, arboa
f(&mut clipboard)
}
/// [`with`] on the clipboard kept in `kept`, created on first use, instead of a fresh one. On
/// X11 the copied data is served only while its `Clipboard` lives, so a copy made through a
/// kept clipboard stays pasteable after the call returns.
pub(crate) fn with_kept<T>(
kept: &mut Option<arboard::Clipboard>,
f: impl FnOnce(&mut arboard::Clipboard) -> Result<T, arboard::Error>,
) -> Result<T, arboard::Error> {
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())
+61 -14
View File
@@ -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<std::sync::Mutex<Vec<(String, String)>>>;
/// 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("<p style=\"margin:0\"><span style=\"font-family:'Helvetica';font-size:14pt\">quick</span></p>\n")
sets.lock().unwrap().as_slice(),
[("<p style=\"margin:0\"><span style=\"font-family:'Helvetica';font-size:14pt\">quick</span></p>\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</span>"), "{html}");
let last = sets.lock().unwrap().last().cloned().unwrap_or_default();
assert!(last.0.contains(">The quick brown fox</span>"), "{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]
+1 -1
View File
@@ -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]]