mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-08 03:54:35 +00:00
M3: copy with formatting: review fixes (spaces, family names, caps, wraps)
- Spaces HTML would collapse keep their width across runs, at a
paragraph's start and after a break.
- font_family returns the families to try: spaced, then as spelt
('Sim Sun','SimSun'); a hyphen splits only before a style word, so
MS-Gothic stays whole.
- clip_html reports its cut; empty paragraphs count against the cap;
the span cap is checked before a paragraph opens; ClipBuilder's
Default starts a paragraph; a negative size clamps to 1pt.
- RichCopy::truncated: a capped copy is marked, the clipboard then puts
plain text only, and text_extract sets html_truncated. No HTML for any
page after the shared budget runs out.
- A style change at a line end is a wrap, not a paragraph; the block
hint is skipped when a smaller block overlaps it; no editor reports
styles_incomplete.
- A column selection copies plain text only.
Each new test fails with its fix reverted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
This commit is contained in:
1 parent
ecb5cae460
commit
7a7103c414
8 files changed
+349
-98
No files matched your search
@@ -230,14 +230,16 @@ 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);
|
||||
// `spent`: the budget ran out, so later pages get no HTML. `cut`: some page's HTML is
|
||||
// missing part of its text, by the budget or by the copy's own caps.
|
||||
let (mut html_left, mut spent, mut cut) = (html_budget, false, 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, 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 {
|
||||
if !spent {
|
||||
let glyphs: Vec<usize> = match rect {
|
||||
Some(r) => t.glyphs_in(r),
|
||||
None => (0..t.glyphs.len()).collect(),
|
||||
@@ -246,19 +248,20 @@ impl Automation {
|
||||
match html_left.checked_sub(copy.html.len()) {
|
||||
Some(left) => html_left = left,
|
||||
None => {
|
||||
html_truncated = true;
|
||||
spent = true;
|
||||
copy = pdfcraft_engine::RichCopy::default();
|
||||
}
|
||||
}
|
||||
}
|
||||
cut |= spent || copy.truncated;
|
||||
page["html"] = json!(copy.html);
|
||||
page["styles_incomplete"] = json!(copy.styles_incomplete || html_truncated);
|
||||
page["styles_incomplete"] = json!(copy.styles_incomplete || spent);
|
||||
}
|
||||
out.push(page);
|
||||
}
|
||||
let mut result = json!({ "pages": out });
|
||||
if html {
|
||||
result["html_truncated"] = json!(html_truncated);
|
||||
result["html_truncated"] = json!(cut);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
@@ -570,4 +570,15 @@ fn text_extract_html_has_one_budget_across_pages() {
|
||||
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());
|
||||
// A smaller page after the one that overflowed still gets none: "Page 1" fits what "Page 10"
|
||||
// did not.
|
||||
let id = search_document(&mut a, 10);
|
||||
let args = json!({ "doc": id.0, "pages": [10, 1], "format": "html" });
|
||||
let full = a.text_extract(&Args(&args)).unwrap();
|
||||
let (ten, one) = (full["pages"][0]["html"].as_str().unwrap().len(), full["pages"][1]["html"].as_str().unwrap().len());
|
||||
assert!(one < ten, "{full}");
|
||||
let capped = a.text_extract_within(&Args(&args), ten - 1).unwrap();
|
||||
assert_eq!(capped["pages"][0]["html"], "");
|
||||
assert_eq!(capped["pages"][1]["html"], "", "no HTML after the budget ran out: {capped}");
|
||||
assert_eq!(capped["pages"][1]["styles_incomplete"], true);
|
||||
}
|
||||
@@ -8918,27 +8918,15 @@ fn a_deleted_image_is_not_kept_by_reduce_or_optimize() {
|
||||
/// page is turned with /Rotate 90.
|
||||
fn table_pdf() -> Vec<u8> {
|
||||
let body = "BT /F1 12 Tf 20 250 Td (Name) Tj 130 0 Td (Qty) Tj -130 -20 Td (Apple) Tj 130 0 Td (12) Tj -130 -20 Td (Pear) Tj 130 0 Td (7) Tj ET";
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [4 0 R 5 0 R] /Count 2 /MediaBox [0 0 300 300] >>".to_string(),
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /Rotate 90 /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>".to_string(),
|
||||
format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||
}
|
||||
let xref = out.len();
|
||||
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
out
|
||||
let stream = format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len());
|
||||
pdf_of(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [4 0 R 5 0 R] /Count 2 /MediaBox [0 0 300 300] >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Rotate 90 /Contents 6 0 R /Resources << /Font << /F1 3 0 R >> >> >>",
|
||||
&stream,
|
||||
])
|
||||
}
|
||||
|
||||
/// Issue #740: `text_extract` with `rect` takes what Column select takes, in the displayed-page
|
||||
@@ -9006,6 +8994,27 @@ fn text_extract_as_html_keeps_the_formatting() {
|
||||
let e = a.call("text_extract", &json!({ "doc": doc, "format": bad })).unwrap_err();
|
||||
assert!(matches!(e, ToolError::InvalidArgs(_)), "{bad}: {e}");
|
||||
}
|
||||
// Bold, italic, colour and size come through end to end.
|
||||
let body = "BT /F1 18 Tf 20 250 Td (Big) Tj ET BT /F2 12 Tf 20 200 Td (Strong) Tj ET \
|
||||
BT /F3 12 Tf 20 150 Td (Slanted) Tj ET BT 0 0 1 rg /F1 12 Tf 20 100 Td (Blue) Tj ET";
|
||||
let stream = format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len());
|
||||
let styled = pdf_of(&[
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 300] >>",
|
||||
"<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R /F3 7 0 R >> >> >>",
|
||||
&stream,
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>",
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Oblique >>",
|
||||
]);
|
||||
std::fs::write(dir.join("styled.pdf"), styled).unwrap();
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "styled.pdf" }))["doc"].as_u64().unwrap();
|
||||
let page = &ok(&mut a, "text_extract", json!({ "doc": doc, "format": "html" }))["pages"][0];
|
||||
let html = page["html"].as_str().unwrap();
|
||||
assert!(html.contains("font-size:18pt\">Big"), "{html}");
|
||||
assert!(html.contains("<strong>Strong</strong>"), "{html}");
|
||||
assert!(html.contains("<em>Slanted</em>"), "{html}");
|
||||
assert!(html.contains("color:#0000FF\">Blue"), "{html}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+137
-16
@@ -23,9 +23,11 @@ pub struct RichCopy {
|
||||
pub text: ClipText,
|
||||
/// The HTML fragment for the clipboard (empty when nothing was selected).
|
||||
pub html: String,
|
||||
/// Styles were left out: reading stopped at its limits on this page, or matching glyphs to
|
||||
/// blocks ran out of steps.
|
||||
/// Styles were left out: the document has no editor to read them, reading stopped at its
|
||||
/// limits on this page, or matching glyphs to blocks ran out of steps.
|
||||
pub styles_incomplete: bool,
|
||||
/// A cap left part of the selection out of `text` and `html`; the plain text has all of it.
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
struct StyledBlock {
|
||||
@@ -44,25 +46,28 @@ impl crate::Document {
|
||||
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 nested = vec![None; blocks.len()];
|
||||
let mut b = ClipBuilder::new();
|
||||
// The previous glyph's line and block, and whether it was a hyphen.
|
||||
let mut prev: Option<(usize, Option<usize>, bool)> = None;
|
||||
// The previous glyph's line, block and box, and whether it was a hyphen.
|
||||
let mut prev: Option<(usize, Option<usize>, [f32; 4], 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).unwrap_or_else(|| {
|
||||
let hint = prev.and_then(|(_, b, _, _)| b).filter(|&k| !holds_smaller(&blocks, &mut nested, k, &mut steps));
|
||||
let found = block_at(&blocks, g.rect, hint, &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 let Some((prev_line, prev_block, prev_rect, hyphen)) = prev {
|
||||
if line != prev_line {
|
||||
if found == prev_block && found.is_some() {
|
||||
let style = |k: Option<usize>| k.and_then(|k| blocks.get(k)).map(|s| &s.style);
|
||||
if found.is_none() && prev_block.is_none() {
|
||||
b.line_break();
|
||||
} else if found == prev_block || wraps(style(prev_block), style(found), prev_rect, g.rect) {
|
||||
// A paragraph wrapping: one space, none after a hyphen.
|
||||
if !hyphen {
|
||||
b.space();
|
||||
}
|
||||
} else if found.is_none() && prev_block.is_none() {
|
||||
b.line_break();
|
||||
} else {
|
||||
b.paragraph();
|
||||
}
|
||||
@@ -74,16 +79,16 @@ impl crate::Document {
|
||||
if !b.push(&g.text, style.unwrap_or(&ClipStyle::default())) {
|
||||
break;
|
||||
}
|
||||
prev = Some((line, found, g.text.ends_with('-')));
|
||||
prev = Some((line, found, g.rect, g.text.ends_with('-')));
|
||||
}
|
||||
let text = b.finish();
|
||||
Ok(RichCopy { html: clip_html(&text), text, styles_incomplete: incomplete })
|
||||
Ok(finish(b, incomplete))
|
||||
}
|
||||
|
||||
/// `page`'s reading blocks in view space with their style, and whether reading stopped at
|
||||
/// its limits.
|
||||
fn styled_blocks(&self, page: usize) -> (Vec<StyledBlock>, bool) {
|
||||
let (Some(editor), Some(info)) = (self.editor.as_ref(), self.info.pages.get(page)) else { return (Vec::new(), false) };
|
||||
let Some(info) = self.info.pages.get(page) else { return (Vec::new(), false) };
|
||||
let Some(editor) = self.editor.as_ref() else { return (Vec::new(), true) };
|
||||
let Ok(reading) = pdfcraft_edit::reading_blocks_checked(&editor.cos, page) else { return (Vec::new(), true) };
|
||||
let unit = display_unit(info);
|
||||
let blocks = reading
|
||||
@@ -93,7 +98,7 @@ impl crate::Document {
|
||||
.map(|b| {
|
||||
let (bold, italic) = weight_and_slant(&b.base_font);
|
||||
let style = ClipStyle {
|
||||
family: pdfcraft_export::font_family(&b.base_font),
|
||||
families: pdfcraft_export::font_family(&b.base_font),
|
||||
size: b.size * unit,
|
||||
bold: b.bold || bold,
|
||||
italic: b.italic || italic,
|
||||
@@ -113,8 +118,56 @@ fn view_rect(info: &PageInfo, r: [f64; 4]) -> [f32; 4] {
|
||||
[a[0].min(b[0]), a[1].min(b[1]), a[0].max(b[0]), a[1].max(b[1])]
|
||||
}
|
||||
|
||||
/// 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; `None` when the steps ran out first.
|
||||
/// The copy `b` collected, with its HTML; `truncated` when a cap cut the text or the HTML.
|
||||
fn finish(b: ClipBuilder, styles_incomplete: bool) -> RichCopy {
|
||||
let text = b.finish();
|
||||
let (html, cut) = clip_html(&text);
|
||||
RichCopy { html, truncated: text.truncated || cut, text, styles_incomplete }
|
||||
}
|
||||
|
||||
/// Whether the next line starts inside the same paragraph although its first glyph is in another
|
||||
/// block: blocks split where the style changes, so a style change at a line's end is still a
|
||||
/// wrap when the size stays and the lines sit no further apart than their own height allows.
|
||||
/// Two blocks of the same style, a change of size, or a gap of over half a line are paragraphs.
|
||||
fn wraps(prev: Option<&ClipStyle>, next: Option<&ClipStyle>, prev_rect: [f32; 4], rect: [f32; 4]) -> bool {
|
||||
let (Some(prev), Some(next)) = (prev, next) else { return false };
|
||||
if prev == next || (prev.size - next.size).abs() > 0.01 {
|
||||
return false;
|
||||
}
|
||||
let height = (prev_rect[3] - prev_rect[1]).max(rect[3] - rect[1]);
|
||||
let gap = rect[1] - prev_rect[3];
|
||||
height > 0.0 && gap.is_finite() && gap <= height / 2.0
|
||||
}
|
||||
|
||||
/// Whether a block smaller than block `k` overlaps it, so a glyph inside `k` may belong to that
|
||||
/// one instead. Worked out once per block (`nested` keeps it), at a step per block tested; when
|
||||
/// the steps run out the answer is yes, so the full search decides.
|
||||
fn holds_smaller(blocks: &[StyledBlock], nested: &mut [Option<bool>], k: usize, steps: &mut usize) -> bool {
|
||||
if let Some(Some(known)) = nested.get(k) {
|
||||
return *known;
|
||||
}
|
||||
let Some(outer) = blocks.get(k) else { return true };
|
||||
let area = |r: &[f32; 4]| (r[2] - r[0]) * (r[3] - r[1]);
|
||||
let overlaps = |r: &[f32; 4]| r[0] <= outer.rect[2] && r[2] >= outer.rect[0] && r[1] <= outer.rect[3] && r[3] >= outer.rect[1];
|
||||
let mut found = false;
|
||||
for (j, b) in blocks.iter().enumerate() {
|
||||
if *steps == 0 {
|
||||
return true;
|
||||
}
|
||||
*steps -= 1;
|
||||
if j != k && overlaps(&b.rect) && area(&b.rect) < area(&outer.rect) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(slot) = nested.get_mut(k) {
|
||||
*slot = Some(found);
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// The block whose box holds the centre of `glyph` (view space): `hint` first (a block no
|
||||
/// smaller one overlaps), then the 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;
|
||||
@@ -144,6 +197,8 @@ fn block_at(blocks: &[StyledBlock], glyph: [f32; 4], hint: Option<usize>, steps:
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use pdfcraft_export::{ClipBuilder, ClipStyle};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A heading in bold, a body paragraph of two lines with HTML-special characters, and a red
|
||||
@@ -153,6 +208,11 @@ mod tests {
|
||||
BT /F2 12 Tf 20 200 Td (Body text) Tj ET \
|
||||
BT 1 0 0 rg /F2 12 Tf 90 200 Td (red) Tj ET \
|
||||
BT /F2 12 Tf 20 186 Td (a<b & c) Tj ET";
|
||||
page_with(content)
|
||||
}
|
||||
|
||||
/// A one-page PDF drawing `content` with `/F1` Helvetica-Bold and `/F2` Helvetica.
|
||||
fn page_with(content: &[u8]) -> Vec<u8> {
|
||||
pdfcraft_testkit::Pdf::new()
|
||||
.obj("<< /Type /Catalog /Pages 2 0 R >>")
|
||||
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 300 300] >>")
|
||||
@@ -228,6 +288,67 @@ mod tests {
|
||||
assert!(!page_less.styles_incomplete);
|
||||
}
|
||||
|
||||
fn copy_all(s: &Session, id: crate::DocId) -> super::RichCopy {
|
||||
let text = page_text(s, id);
|
||||
let all: Vec<usize> = (0..text.glyphs.len()).collect();
|
||||
s.get(id).unwrap().rich_copy(0, &text, &all).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_style_change_at_a_line_end_stays_in_its_paragraph() {
|
||||
let content = b"BT /F2 12 Tf 20 200 Td (plain then) Tj ET \
|
||||
BT /F1 12 Tf 85 200 Td (bold) Tj ET \
|
||||
BT /F2 12 Tf 20 186 Td (continues here) Tj ET \
|
||||
BT /F2 12 Tf 20 120 Td (Another paragraph) Tj ET";
|
||||
let mut s = Session::new();
|
||||
let id = s.open("wrap.pdf", None, Arc::new(page_with(content)), None).unwrap();
|
||||
let copy = copy_all(&s, id);
|
||||
let text = |p: &Vec<pdfcraft_export::ClipSpan>| p.iter().map(|s| s.text.as_str()).collect::<String>();
|
||||
let paragraphs: Vec<String> = copy.text.paragraphs.iter().map(text).collect();
|
||||
assert!(paragraphs.iter().any(|p| p.contains("bold") && p.contains("continues")), "{paragraphs:?}");
|
||||
assert!(paragraphs.iter().any(|p| p.contains("Another") && !p.contains("continues")), "a wide gap is still a paragraph: {paragraphs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_styled_word_inside_a_paragraph_keeps_its_style() {
|
||||
let content = b"BT /F2 12 Tf 20 200 Td (first row of the text) Tj ET \
|
||||
BT /F2 12 Tf 20 186 Td (second) Tj ET \
|
||||
BT /F1 12 Tf 62 186 Td (bold) Tj ET \
|
||||
BT /F2 12 Tf 92 186 Td (row) Tj ET";
|
||||
let mut s = Session::new();
|
||||
let id = s.open("nested.pdf", None, Arc::new(page_with(content)), None).unwrap();
|
||||
let copy = copy_all(&s, id);
|
||||
let spans: Vec<_> = copy.text.paragraphs.iter().flatten().collect();
|
||||
assert!(spans.iter().any(|s| s.text.contains("bold") && s.style.bold), "{spans:?}");
|
||||
assert!(spans.iter().any(|s| s.text.contains("second") && !s.style.bold), "{spans:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_capped_copy_is_marked_truncated() {
|
||||
let (plain, bold) = (ClipStyle::default(), ClipStyle { bold: true, ..ClipStyle::default() });
|
||||
let mut b = ClipBuilder::new();
|
||||
for k in 0..=pdfcraft_export::MAX_CLIP_SPANS {
|
||||
b.push("x", if k % 2 == 0 { &plain } else { &bold });
|
||||
}
|
||||
let copy = super::finish(b, false);
|
||||
assert!(copy.truncated && copy.text.truncated);
|
||||
let mut b = ClipBuilder::new();
|
||||
b.push("x", &plain);
|
||||
assert!(!super::finish(b, false).truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_an_editor_the_styles_are_incomplete() {
|
||||
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();
|
||||
s.doc_mut(id).unwrap().editor = None;
|
||||
let copy = s.get(id).unwrap().rich_copy(0, &text, &all).unwrap();
|
||||
assert!(copy.styles_incomplete && !copy.html.contains("font-size"), "{}", copy.html);
|
||||
assert!(!copy.truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_with_formatting_is_refused_when_copying_is_withheld() {
|
||||
let mut cos = pdfcraft_cos::Document::open(Arc::new(styled_page())).unwrap();
|
||||
|
||||
+136
-54
@@ -19,8 +19,8 @@ const MAX_FAMILY_CHARS: usize = 64;
|
||||
/// How a span looks.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ClipStyle {
|
||||
/// The family a word processor knows ([`font_family`]); `None` keeps the target's own.
|
||||
pub family: Option<String>,
|
||||
/// Family names to try in order ([`font_family`]); empty keeps the target's own.
|
||||
pub families: Vec<String>,
|
||||
/// Size in points; zero or not finite leaves the target's size, and any other is written
|
||||
/// within 1 to 1000.
|
||||
pub size: f64,
|
||||
@@ -46,7 +46,7 @@ pub struct ClipText {
|
||||
|
||||
/// Builds a [`ClipText`] piece by piece, merging pieces of equal style into one span and
|
||||
/// stopping at the caps.
|
||||
#[derive(Debug, Default)]
|
||||
#[derive(Debug)]
|
||||
pub struct ClipBuilder {
|
||||
out: ClipText,
|
||||
/// The separator owed before the next piece in this paragraph (a space or a line break).
|
||||
@@ -56,9 +56,15 @@ pub struct ClipBuilder {
|
||||
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 { new_paragraph: true, ..Self::default() }
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// A word space before the next piece (dropped at the start of a paragraph).
|
||||
@@ -91,6 +97,11 @@ impl ClipBuilder {
|
||||
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;
|
||||
@@ -103,10 +114,6 @@ impl ClipBuilder {
|
||||
last.text.push_str(text);
|
||||
}
|
||||
last => {
|
||||
if self.spans >= MAX_CLIP_SPANS {
|
||||
self.out.truncated = true;
|
||||
return false;
|
||||
}
|
||||
// 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);
|
||||
@@ -124,16 +131,22 @@ impl ClipBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
/// The family a word processor knows, from a font's `/BaseFont`: subset tag and style suffix
|
||||
/// removed, `PSMT`/`MT`/`PS` dropped, camel case spaced (`ABCDEF+TimesNewRomanPSMT` →
|
||||
/// `Times New Roman`). Only letters, digits, spaces, `-`, `_` and `.` are kept, so the name
|
||||
/// cannot leave its CSS string. `None` when nothing is left.
|
||||
pub fn font_family(base_font: &str) -> Option<String> {
|
||||
/// 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<String> {
|
||||
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 family = name.split_once([',', '-']).map_or(name, |(family, _)| family);
|
||||
let name = name.split_once(',').map_or(name, |(family, _)| family);
|
||||
let family = match name.split_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;
|
||||
@@ -142,11 +155,24 @@ pub fn font_family(base_font: &str) -> Option<String> {
|
||||
spaced.push(' ');
|
||||
}
|
||||
prev_lower = c.is_ascii_lowercase();
|
||||
spaced.push(c);
|
||||
spaced.push(if c == '-' { ' ' } else { c });
|
||||
}
|
||||
safe_family(&spaced)
|
||||
let spelt: String = family.chars().take(MAX_FAMILY_CHARS).collect();
|
||||
let mut out: Vec<String> = 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<String> {
|
||||
@@ -155,47 +181,57 @@ fn safe_family(family: &str) -> Option<String> {
|
||||
(!kept.is_empty()).then(|| kept.to_owned())
|
||||
}
|
||||
|
||||
/// The copied text as an HTML fragment: one `<p>` per paragraph, a `<span>` per run with its
|
||||
/// family, size and colour inline, bold as `<strong>` and italic as `<em>` (so a target that
|
||||
/// drops style attributes still keeps them). Text is escaped; a line break is `<br>` and a run
|
||||
/// of spaces keeps its width. Never longer than [`MAX_CLIP_HTML_BYTES`]: a span that would pass
|
||||
/// it ends the fragment.
|
||||
pub fn clip_html(text: &ClipText) -> String {
|
||||
/// The copied text as an HTML fragment, and whether [`MAX_CLIP_HTML_BYTES`] cut it: one `<p>`
|
||||
/// per paragraph, a `<span>` per run with its families, size and colour inline, bold as
|
||||
/// `<strong>` and italic as `<em>` (so a target that drops style attributes still keeps them).
|
||||
/// Text is escaped; a line break is `<br>`, 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 = "<p style=\"margin:0\">";
|
||||
const CLOSE: &str = "</p>\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);
|
||||
if out.len().saturating_add(p.len()).saturating_add(s.len()).saturating_add(CLOSE.len()) > MAX_CLIP_HTML_BYTES {
|
||||
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;
|
||||
return (out, true);
|
||||
}
|
||||
p.push_str(&s);
|
||||
}
|
||||
p.push_str(CLOSE);
|
||||
out.push_str(&p);
|
||||
}
|
||||
out
|
||||
(out, false)
|
||||
}
|
||||
|
||||
fn span_html(span: &ClipSpan) -> String {
|
||||
/// 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();
|
||||
if let Some(f) = st.family.as_deref().and_then(safe_family) {
|
||||
css.push(format!("font-family:'{f}'"));
|
||||
let families: Vec<String> = 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 {
|
||||
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);
|
||||
let mut body = text_html(&span.text, space_before);
|
||||
if st.italic {
|
||||
body = format!("<em>{body}</em>");
|
||||
}
|
||||
@@ -205,23 +241,23 @@ fn span_html(span: &ClipSpan) -> String {
|
||||
if css.is_empty() { body } else { format!("<span style=\"{}\">{body}</span>", esc(&css.join(";"))) }
|
||||
}
|
||||
|
||||
/// Escape for HTML text in one pass; `\n` becomes `<br>` and a run of spaces keeps its width
|
||||
/// with ` `. Dropped: what [`esc`] drops (C0 controls), C1 controls, a carriage return
|
||||
/// Escape for HTML text in one pass; `\n` becomes `<br>`, 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) -> String {
|
||||
fn text_html(text: &str, space_before: &mut bool) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut prev_space = false;
|
||||
for c in text.chars().filter(|c| clip_char(*c)) {
|
||||
match c {
|
||||
'\n' => out.push_str("<br>"),
|
||||
' ' if prev_space => 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),
|
||||
}
|
||||
prev_space = c == ' ';
|
||||
*space_before = matches!(c, ' ' | '\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -242,7 +278,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn style(size: f64, bold: bool, italic: bool, color: [f64; 3]) -> ClipStyle {
|
||||
ClipStyle { family: font_family("ABCDEF+Helvetica-Bold"), size, bold, italic, color }
|
||||
ClipStyle { families: font_family("ABCDEF+Helvetica-Bold"), size, bold, italic, color }
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -261,7 +297,8 @@ mod tests {
|
||||
let clip = b.finish();
|
||||
assert_eq!(clip.paragraphs.len(), 2);
|
||||
assert_eq!(clip.paragraphs.get(1).map(Vec::len), Some(2), "{clip:?}");
|
||||
let html = clip_html(&clip);
|
||||
let (html, cut) = clip_html(&clip);
|
||||
assert!(!cut);
|
||||
assert_eq!(
|
||||
html,
|
||||
"<p style=\"margin:0\"><span style=\"font-family:'Helvetica';font-size:24pt\"><strong>Title</strong></span></p>\n\
|
||||
@@ -272,10 +309,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn special_characters_are_escaped_and_cannot_leave_the_style() {
|
||||
let st = ClipStyle { family: Some("Evil';}</style><script>\\\n".into()), size: f64::NAN, ..ClipStyle::default() };
|
||||
let st = ClipStyle { families: vec!["Evil';}</style><script>\\\n".into()], size: f64::NAN, ..ClipStyle::default() };
|
||||
let mut b = ClipBuilder::new();
|
||||
b.push("<a href=\"x\">&\u{0}\u{1b}</a> two\nline", &st);
|
||||
let html = clip_html(&b.finish());
|
||||
let (html, _) = clip_html(&b.finish());
|
||||
assert_eq!(
|
||||
html,
|
||||
"<p style=\"margin:0\"><span style=\"font-family:'Evilstylescript'\"><a href="x">&</a> two<br>line</span></p>\n"
|
||||
@@ -286,35 +323,65 @@ mod tests {
|
||||
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");
|
||||
assert_eq!(clip_html(&b.finish()).0, "<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")] {
|
||||
for (size, want) in [(1e300, "1000"), (f64::MAX, "1000"), (2e306, "1000"), (0.2, "1"), (9.96, "10"), (-12.0, "1"), (f64::MIN, "1")] {
|
||||
let st = ClipStyle { size, ..ClipStyle::default() };
|
||||
let mut b = ClipBuilder::new();
|
||||
b.push("x", &st);
|
||||
let html = clip_html(&b.finish());
|
||||
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"));
|
||||
assert_eq!(font_family("Arial,BoldItalic").as_deref(), Some("Arial"));
|
||||
assert_eq!(font_family("Arial-BoldMT").as_deref(), Some("Arial"));
|
||||
assert_eq!(font_family("abcdef+Foo").as_deref(), Some("abcdefFoo"), "a lower-case prefix is not a subset tag");
|
||||
assert_eq!(font_family(""), None);
|
||||
assert_eq!(font_family("+'\"\\;"), None);
|
||||
assert!(font_family(&"Ab".repeat(10_000)).is_some_and(|f| f.chars().count() <= MAX_FAMILY_CHARS * 2));
|
||||
let f = |b: &str| font_family(b);
|
||||
assert_eq!(f("ABCDEF+TimesNewRomanPSMT"), ["Times New Roman", "TimesNewRoman"]);
|
||||
assert_eq!(f("Arial,BoldItalic"), ["Arial"]);
|
||||
assert_eq!(f("Arial-BoldMT"), ["Arial"]);
|
||||
assert_eq!(f("Helvetica-Oblique"), ["Helvetica"]);
|
||||
assert_eq!(f("MS-Gothic"), ["MS Gothic", "MS-Gothic"], "a hyphen before a family word stays in the name");
|
||||
assert_eq!(f("SimSun"), ["Sim Sun", "SimSun"], "the name as spelt is tried too");
|
||||
assert_eq!(f("abcdef+Foo"), ["abcdefFoo"], "a lower-case prefix is not a subset tag");
|
||||
assert!(f("").is_empty());
|
||||
assert!(f("+'\"\\;").is_empty());
|
||||
assert!(f(&"Ab".repeat(10_000)).iter().all(|f| f.chars().count() <= MAX_FAMILY_CHARS * 2));
|
||||
let st = ClipStyle { families: f("SimSun"), ..ClipStyle::default() };
|
||||
let mut b = ClipBuilder::new();
|
||||
b.push("x", &st);
|
||||
assert_eq!(clip_html(&b.finish()).0, "<p style=\"margin:0\"><span style=\"font-family:'Sim Sun','SimSun'\">x</span></p>\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaces_html_would_collapse_keep_their_width() {
|
||||
let (a, c) = (ClipStyle::default(), ClipStyle { bold: true, ..ClipStyle::default() });
|
||||
let mut b = ClipBuilder::new();
|
||||
b.push(" lead a ", &a);
|
||||
b.push(" b", &c);
|
||||
b.line_break();
|
||||
b.push(" c", &a);
|
||||
assert_eq!(
|
||||
clip_html(&b.finish()).0,
|
||||
"<p style=\"margin:0\"> lead a <strong> b<br></strong> c</p>\n",
|
||||
"at a paragraph's start, across runs and after a break"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_default_builder_starts_a_paragraph() {
|
||||
let mut b = ClipBuilder::default();
|
||||
assert!(b.push("x", &ClipStyle::default()));
|
||||
assert_eq!(b.finish().paragraphs.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_huge_selections_are_bounded() {
|
||||
let b = ClipBuilder::new();
|
||||
assert_eq!(clip_html(&b.finish()), "");
|
||||
assert_eq!(clip_html(&b.finish()), (String::new(), false));
|
||||
let mut b = ClipBuilder::new();
|
||||
b.space();
|
||||
assert!(b.push("", &ClipStyle::default()));
|
||||
@@ -332,19 +399,34 @@ mod tests {
|
||||
let clip = b.finish();
|
||||
assert!(clip.truncated);
|
||||
assert_eq!(kept, MAX_CLIP_SPANS);
|
||||
// Exactly at the cap, a new paragraph is refused before it is opened.
|
||||
let mut b = ClipBuilder::new();
|
||||
for i in 0..MAX_CLIP_SPANS {
|
||||
assert!(b.push("x", if i % 2 == 0 { &a } else { &c }));
|
||||
}
|
||||
b.paragraph();
|
||||
assert!(!b.push("x", &a));
|
||||
let clip = b.finish();
|
||||
assert!(clip.truncated);
|
||||
assert_eq!(clip.paragraphs.len(), 1, "no empty paragraph past the cap");
|
||||
// One style: the text cap stops it.
|
||||
let mut b = ClipBuilder::new();
|
||||
let chunk = "y".repeat(1 << 16);
|
||||
// Bounded, so the test fails rather than runs on when the cap is gone.
|
||||
let pushes = (0..MAX_CLIP_TEXT_BYTES / chunk.len() + 2).take_while(|_| b.push(&chunk, &a)).count();
|
||||
assert!(pushes <= MAX_CLIP_TEXT_BYTES / chunk.len(), "the cap stopped it: {pushes}");
|
||||
assert_eq!(pushes, MAX_CLIP_TEXT_BYTES / chunk.len(), "the cap stopped it");
|
||||
let clip = b.finish();
|
||||
assert!(clip.truncated);
|
||||
assert!(clip.paragraphs.iter().flatten().map(|s| s.text.len()).sum::<usize>() <= MAX_CLIP_TEXT_BYTES);
|
||||
// HTML: escaping grows text up to sixfold; the writer still stops at its cap.
|
||||
let big = ClipText { paragraphs: vec![vec![ClipSpan { text: "<".repeat(1 << 20), style: a.clone() }]; 8], truncated: false };
|
||||
let html = clip_html(&big);
|
||||
let (html, cut) = clip_html(&big);
|
||||
assert!(cut, "the cut is reported");
|
||||
assert!(html.len() <= MAX_CLIP_HTML_BYTES && html.ends_with("</p>\n"), "{}", html.len());
|
||||
assert!(html.len() > MAX_CLIP_HTML_BYTES / 2);
|
||||
// Empty paragraphs count against the cap too.
|
||||
let empty = ClipText { paragraphs: vec![Vec::new(); MAX_CLIP_HTML_BYTES / 20], truncated: false };
|
||||
let (html, cut) = clip_html(&empty);
|
||||
assert!(cut && html.len() <= MAX_CLIP_HTML_BYTES, "{}", html.len());
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,8 @@ impl PdfCraftApp {
|
||||
.get(i)
|
||||
.and_then(DocView::selected_glyphs)
|
||||
.and_then(|(page, layer, glyphs)| self.session.get(id)?.rich_copy(page, &layer, &glyphs).ok())
|
||||
// A capped copy would paste less than the plain text holds; paste that instead.
|
||||
.filter(|copy| !copy.truncated)
|
||||
.map(|copy| copy.html)
|
||||
.filter(|html| !html.is_empty())
|
||||
} else {
|
||||
|
||||
@@ -94,7 +94,8 @@ impl DocView {
|
||||
}
|
||||
|
||||
/// The selection's page, its text layer and the selected glyphs in copy order, for Copy with
|
||||
/// formatting. `None` after Select All of the document, which copies plain text.
|
||||
/// formatting. `None` after Select All of the document and for a column selection, which
|
||||
/// copy plain text (a column's rows and tab-separated cells are not paragraphs).
|
||||
pub(crate) fn selected_glyphs(&self) -> Option<(usize, Arc<PageText>, Vec<usize>)> {
|
||||
if self.all_text {
|
||||
return None;
|
||||
@@ -103,7 +104,7 @@ impl DocView {
|
||||
let t = self.texts.get(&s.page)?;
|
||||
let glyphs = match s.span {
|
||||
Span::Run { anchor, head } => Selection::range(anchor, head).take(t.glyphs.len()).collect(),
|
||||
Span::Column { rect } => t.glyphs_in(rect),
|
||||
Span::Column { .. } => return None,
|
||||
};
|
||||
Some((s.page, t.clone(), glyphs))
|
||||
}
|
||||
|
||||
@@ -146,3 +146,25 @@ fn alt_drag_with_the_highlighter_marks_only_the_column() {
|
||||
let marks: Vec<(&str, usize)> = doc.info.annotations.iter().map(|a| (a.subtype.as_str(), a.quads.len())).collect();
|
||||
assert_eq!(marks, [("Highlight", 4)], "Name, Qty, Apple and 12, each its own quad");
|
||||
}
|
||||
|
||||
/// A column's rows and tab-separated cells are not paragraphs, so Copy puts the plain text on
|
||||
/// the clipboard and no HTML, even where the clipboard would take it.
|
||||
#[test]
|
||||
fn a_column_selection_copies_plain_text_only() {
|
||||
let offered = std::sync::Arc::new(std::sync::Mutex::new(0usize));
|
||||
let seen = offered.clone();
|
||||
let mut h = harness(move |app| {
|
||||
app.system_clipboard = true;
|
||||
app.html_clipboard_override = Some(Box::new(move |_, _| {
|
||||
*seen.lock().unwrap() += 1;
|
||||
true
|
||||
}));
|
||||
});
|
||||
let (from, to) = top_two_rows(&h);
|
||||
drag(&mut h, from, to, Modifiers::ALT);
|
||||
assert!(h.state_mut().execute("edit.copy"));
|
||||
h.step();
|
||||
assert_eq!(*offered.lock().unwrap(), 0, "no HTML for a column selection");
|
||||
let copied = h.output().platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(t) if t == "Name\tQty\nApple\t12"));
|
||||
assert!(copied, "the plain text goes out the usual way");
|
||||
}
|
||||
Reference in new issue
Block a user