mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-08 03:54:35 +00:00
M10: copy keeps formatting as HTML (Copy with formatting)
Copying selected page text now also puts an HTML fragment on the system clipboard, so a paste into a word processor or mail client keeps paragraphs, bold, italic, size, colour and font family. The plain text is unchanged and stays on the clipboard as the alternative. - export: clip.rs, the clipboard model (ClipText of paragraphs of styled spans), its builder with caps (20 000 spans, 2 MiB of text, 16 MiB of HTML), font_family (subset tag and style suffixes off, camel case spaced) and clip_html. Family names are reduced to letters, digits, space, '-', '_' and '.', so a font name cannot leave the CSS string; text goes through esc, which drops control characters. - engine: Document::rich_copy(page, text, glyphs). Glyphs (view space) take the style of the reading block their centre lies in; blocks are style-homogeneous. A line wrap inside a block is a space (none after a hyphen), a change of block a new paragraph. Refused when the document does not allow copying. compare.rs's display unit and weight/slant inference are shared, not duplicated. - automation: text_extract takes format "text" (default) or "html"; html adds each page's html and styles_incomplete, for the whole page or only what rect takes. Other values are InvalidArgs. - ui-egui: Copy of a run or column selection writes HTML plus the plain text in one arboard set (desktop only); the web build and Select All of the whole document stay plain text. last_copied_html records what was produced, for tests. - parity: view.copy-with-layout is partial; notes list what is missing (RTF, underline/strike-through, tables, Select All, web, glyphs outside reading blocks). Formatting inference and the HTML serialiser are adapted from pdfcer (MIT, same author) at 540b00ef1a405d1baa67c92f3858535037c7cde8. 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
8717a085ff
commit
352355ab77
14 files changed
+718
-17
No files matched your search
@@ -201,6 +201,11 @@ impl Automation {
|
||||
}
|
||||
|
||||
pub(crate) fn text_extract(&mut self, a: &Args) -> Result<Value> {
|
||||
let html = match a.opt_str("format")?.unwrap_or("text") {
|
||||
"text" => false,
|
||||
"html" => true,
|
||||
other => return Err(ToolError::InvalidArgs(format!("format must be \"text\" or \"html\", not {other:?}"))),
|
||||
};
|
||||
let doc = self.doc_to_copy(a)?;
|
||||
let id = doc.id;
|
||||
let pages = match a.opt_ints("pages")? {
|
||||
@@ -214,7 +219,22 @@ impl Automation {
|
||||
Some(r) => t.column_text(&t.glyphs_in(r)),
|
||||
None => t.plain_text(),
|
||||
};
|
||||
let out: Vec<Value> = pages.iter().zip(texts).map(|(p, t)| json!({ "page": p + 1, "text": text(&t) })).collect();
|
||||
let doc = self.doc(a)?;
|
||||
let mut out = Vec::with_capacity(pages.len());
|
||||
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)?;
|
||||
page["html"] = json!(copy.html);
|
||||
page["styles_incomplete"] = json!(copy.styles_incomplete);
|
||||
}
|
||||
out.push(page);
|
||||
}
|
||||
Ok(json!({ "pages": out }))
|
||||
}
|
||||
|
||||
|
||||
@@ -195,13 +195,14 @@ 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.")
|
||||
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.")
|
||||
.ro()
|
||||
.cmd("edit.column_select")
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(), "pages": pages("to extract (default: all)"),
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Column select: [x0, y0, x1, y1] in points from the top-left of the displayed page; a glyph counts when its centre is inside." }
|
||||
"rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "Column select: [x0, y0, x1, y1] in points from the top-left of the displayed page; a glyph counts when its centre is inside." },
|
||||
"format": { "type": "string", "enum": ["text", "html"], "description": "text (default) or html: also return each page's text as formatted HTML." }
|
||||
}),
|
||||
&["doc"],
|
||||
)),
|
||||
|
||||
@@ -2420,6 +2420,7 @@ fn protecting_through_tools() {
|
||||
// Copying wasn't allowed either (#568): no text or images come out, though search works.
|
||||
for (tool, args) in [
|
||||
("text_extract", json!({ "doc": re })),
|
||||
("text_extract", json!({ "doc": re, "format": "html" })),
|
||||
("text_word_count", json!({ "doc": re })),
|
||||
("text_paragraphs", json!({ "doc": re, "page": 1 })),
|
||||
("doc_export_text", json!({ "doc": re, "path": "out.txt" })),
|
||||
@@ -8978,6 +8979,35 @@ fn text_extract_with_a_rect_takes_one_column() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy with formatting, headless: `format: "html"` adds each page's text as the HTML fragment
|
||||
/// Copy puts on the clipboard, for the whole page or only what `rect` takes.
|
||||
#[test]
|
||||
fn text_extract_as_html_keeps_the_formatting() {
|
||||
let dir = workdir("rich-copy");
|
||||
std::fs::write(dir.join("table.pdf"), table_pdf()).unwrap();
|
||||
let mut a = auto(&dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({ "path": "table.pdf" }))["doc"].as_u64().unwrap();
|
||||
let page = &ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1], "format": "html" }))["pages"][0];
|
||||
let html = page["html"].as_str().unwrap();
|
||||
assert!(html.starts_with("<p style=\"margin:0\"><span style=\"font-family:'Helvetica';font-size:12pt\">Name"), "{html}");
|
||||
assert!(html.contains("Pear") && html.contains("Qty"), "{html}");
|
||||
assert_eq!(page["text"], "Name\nApple\nPear\nQty\n12\n7", "the plain text is unchanged");
|
||||
assert_eq!(page["styles_incomplete"], false);
|
||||
// Plain text by default: no html key.
|
||||
assert!(ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1] }))["pages"][0].get("html").is_none());
|
||||
// Only what the rect takes; nothing inside gives empty HTML.
|
||||
let column = ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1], "rect": [120, 0, 300, 300], "format": "html" }));
|
||||
let html = column["pages"][0]["html"].as_str().unwrap();
|
||||
assert!(html.contains("Qty") && !html.contains("Name"), "{html}");
|
||||
let none = ok(&mut a, "text_extract", json!({ "doc": doc, "pages": [1], "rect": [0, 0, 5, 5], "format": "html" }));
|
||||
assert_eq!(none["pages"][0]["html"], "");
|
||||
// An unknown format is refused.
|
||||
for bad in [json!("rtf"), json!(1)] {
|
||||
let e = a.call("text_extract", &json!({ "doc": doc, "format": bad })).unwrap_err();
|
||||
assert!(matches!(e, ToolError::InvalidArgs(_)), "{bad}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_visual_region_edit_preview_save_reopen_and_undo_through_tools() {
|
||||
use pdfcraft_cos::{Dict, Document, Object, Stream, write_incremental};
|
||||
|
||||
@@ -187,6 +187,20 @@ pub struct OfficeExport {
|
||||
/// `Err(false)`: it can't be encoded).
|
||||
type PreparedImage = Result<(&'static str, Arc<[u8]>), bool>;
|
||||
|
||||
/// Displayed points per user-space unit on `info`'s page (`/UserUnit`), 1 when the page has no
|
||||
/// usable size.
|
||||
pub(crate) fn display_unit(info: &pdfcraft_render::PageInfo) -> f64 {
|
||||
let crop_width = f64::from(info.crop[2] - info.crop[0]);
|
||||
let display_width = f64::from(if info.rotation.is_multiple_of(180) { info.width } else { info.height });
|
||||
if crop_width > 0.0 && crop_width.is_finite() && display_width.is_finite() { display_width / crop_width } else { 1.0 }
|
||||
}
|
||||
|
||||
/// Bold and italic as a font's name says them (`Arial-BoldItalicMT`).
|
||||
pub(crate) fn weight_and_slant(base_font: &str) -> (bool, bool) {
|
||||
let f = base_font.to_ascii_lowercase();
|
||||
(f.contains("bold") || f.contains("black") || f.contains("heavy"), f.contains("italic") || f.contains("oblique"))
|
||||
}
|
||||
|
||||
impl crate::Document {
|
||||
/// The pages as paragraphs and images (for Word, HTML and RTF export), including what
|
||||
/// form XObjects draw.
|
||||
@@ -228,9 +242,7 @@ impl crate::Document {
|
||||
let b = info.user_to_view(r[2] as f32, r[3] as f32);
|
||||
[a[0].min(b[0]) as f64, (info.height - a[1].max(b[1])) as f64, a[0].max(b[0]) as f64, (info.height - a[1].min(b[1])) as f64]
|
||||
};
|
||||
let crop_width = f64::from(info.crop[2] - info.crop[0]);
|
||||
let display_width = f64::from(if info.rotation % 180 == 0 { info.width } else { info.height });
|
||||
let unit = if crop_width > 0.0 && crop_width.is_finite() && display_width.is_finite() { display_width / crop_width } else { 1.0 };
|
||||
let unit = display_unit(info);
|
||||
let reading = pdfcraft_edit::reading_blocks_checked(cos, i).unwrap_or_default();
|
||||
let forms_unread = reading.truncated;
|
||||
let blocks = reading
|
||||
@@ -238,7 +250,7 @@ impl crate::Document {
|
||||
.into_iter()
|
||||
.filter(|b| !b.text.trim().is_empty())
|
||||
.map(|b| {
|
||||
let f = b.base_font.to_ascii_lowercase();
|
||||
let (bold, italic) = weight_and_slant(&b.base_font);
|
||||
// `text` is trimmed; the editor text keeps the source's own spaces.
|
||||
let space_around = [b.editor_text.starts_with(char::is_whitespace), b.editor_text.ends_with(char::is_whitespace)];
|
||||
pdfcraft_export::Block {
|
||||
@@ -246,8 +258,8 @@ impl crate::Document {
|
||||
text: b.text,
|
||||
rect: visible_rect(b.rect),
|
||||
size: b.size * unit,
|
||||
bold: f.contains("bold") || f.contains("black") || f.contains("heavy"),
|
||||
italic: f.contains("italic") || f.contains("oblique"),
|
||||
bold,
|
||||
italic,
|
||||
color: b.color,
|
||||
rotation: b.rotation - f64::from(info.rotation),
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ mod pictures;
|
||||
pub mod print_raster;
|
||||
pub mod printcheck;
|
||||
mod protection;
|
||||
mod rich_copy;
|
||||
mod scan_colours;
|
||||
mod scan_fields;
|
||||
mod session;
|
||||
@@ -66,6 +67,7 @@ pub use links::{
|
||||
MAX_REMOTE_DOCUMENT_BYTES, RemoteDocumentPathError, read_remote_document, remote_document_path_is_absolute, resolve_remote_document_path,
|
||||
};
|
||||
pub use pdfcraft_edit::strip_subset_tag;
|
||||
pub use rich_copy::RichCopy;
|
||||
pub use signature_image::{ImageSignaturePreview, SignatureImage};
|
||||
pub use signature_scan::{PhotoRect, ScanError, SignaturePhoto, Turn};
|
||||
pub use stamp_preview::{CommentLayer, StampPreview};
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
//! Copy with formatting: the selected glyphs of a page as paragraphs of styled spans and an
|
||||
//! HTML fragment ([`pdfcraft_export::clip_html`]) for the clipboard, next to the plain text.
|
||||
//!
|
||||
//! Glyphs come from the text layer (view space); style comes from the page's reading blocks,
|
||||
//! which never mix styles, mapped into view space. A glyph takes the style of the block its
|
||||
//! centre lies in; a glyph in no block is copied unstyled.
|
||||
|
||||
use pdfcraft_export::{ClipBuilder, ClipStyle, ClipText, clip_html};
|
||||
use pdfcraft_render::{PageInfo, PageText};
|
||||
|
||||
use crate::EditError;
|
||||
use crate::compare::{display_unit, weight_and_slant};
|
||||
|
||||
/// Block tests spent matching glyphs to blocks for one copy; past it the rest is unstyled.
|
||||
const MAX_MATCH_STEPS: usize = 20_000_000;
|
||||
/// How far outside a block's box (points) a glyph centre still belongs to it.
|
||||
const SLACK: f32 = 0.5;
|
||||
|
||||
/// Selected text with its formatting.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct RichCopy {
|
||||
/// The paragraphs and spans.
|
||||
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.
|
||||
pub styles_incomplete: bool,
|
||||
}
|
||||
|
||||
struct StyledBlock {
|
||||
rect: [f32; 4],
|
||||
style: ClipStyle,
|
||||
}
|
||||
|
||||
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.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 line = usize::try_from(line).unwrap_or(usize::MAX);
|
||||
if let Some((prev_line, prev_block, hyphen)) = prev {
|
||||
if line != prev_line {
|
||||
if found == prev_block && found.is_some() {
|
||||
// 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();
|
||||
}
|
||||
} else if text.space_before.get(i).copied().unwrap_or(false) {
|
||||
b.space();
|
||||
}
|
||||
}
|
||||
let style = found.and_then(|k| blocks.get(k)).map(|s| &s.style);
|
||||
if !b.push(&g.text, style.unwrap_or(&ClipStyle::default())) {
|
||||
break;
|
||||
}
|
||||
prev = Some((line, found, g.text.ends_with('-')));
|
||||
}
|
||||
let text = b.finish();
|
||||
Ok(RichCopy { html: clip_html(&text), text, styles_incomplete: 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 Ok(reading) = pdfcraft_edit::reading_blocks_checked(&editor.cos, page) else { return (Vec::new(), true) };
|
||||
let unit = display_unit(info);
|
||||
let blocks = reading
|
||||
.blocks
|
||||
.into_iter()
|
||||
.filter(|b| !b.text.trim().is_empty())
|
||||
.map(|b| {
|
||||
let (bold, italic) = weight_and_slant(&b.base_font);
|
||||
let style = ClipStyle {
|
||||
family: pdfcraft_export::font_family(&b.base_font),
|
||||
size: b.size * unit,
|
||||
bold: b.bold || bold,
|
||||
italic: b.italic || italic,
|
||||
color: b.color,
|
||||
};
|
||||
StyledBlock { rect: view_rect(info, b.rect), style }
|
||||
})
|
||||
.collect();
|
||||
(blocks, reading.truncated)
|
||||
}
|
||||
}
|
||||
|
||||
/// A user-space box as a view-space box (points from the displayed page's top-left, y down).
|
||||
fn view_rect(info: &PageInfo, r: [f64; 4]) -> [f32; 4] {
|
||||
let a = info.user_to_view(r[0] as f32, r[1] as f32);
|
||||
let b = info.user_to_view(r[2] as f32, r[3] as f32);
|
||||
[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; with none left, no block.
|
||||
fn block_at(blocks: &[StyledBlock], glyph: [f32; 4], hint: Option<usize>, steps: &mut usize) -> 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]);
|
||||
if let Some(k) = hint
|
||||
&& *steps > 0
|
||||
{
|
||||
*steps -= 1;
|
||||
if blocks.get(k).is_some_and(|b| holds(&b.rect)) {
|
||||
return Some(k);
|
||||
}
|
||||
}
|
||||
let mut best: Option<(usize, f32)> = None;
|
||||
for (k, b) in blocks.iter().enumerate() {
|
||||
if *steps == 0 {
|
||||
return None;
|
||||
}
|
||||
*steps -= 1;
|
||||
if holds(&b.rect) && best.is_none_or(|(_, a)| area(&b.rect) < a) {
|
||||
best = Some((k, area(&b.rect)));
|
||||
}
|
||||
}
|
||||
best.map(|(k, _)| k)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A heading in bold, a body paragraph of two lines with HTML-special characters, and a red
|
||||
/// word on the body's first line.
|
||||
fn styled_page() -> Vec<u8> {
|
||||
let content = b"BT /F1 24 Tf 20 250 Td (Title) Tj ET \
|
||||
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";
|
||||
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] >>")
|
||||
.obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R /Resources << /Font << /F1 5 0 R /F2 6 0 R >> >> >>")
|
||||
.stream("", content)
|
||||
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>")
|
||||
.obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
|
||||
.build()
|
||||
}
|
||||
|
||||
fn page_text(s: &Session, id: crate::DocId) -> Arc<pdfcraft_render::PageText> {
|
||||
let doc = s.get(id).unwrap();
|
||||
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default());
|
||||
let request = pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() };
|
||||
r.render(request).text.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_keeps_headings_body_and_colour_as_html() {
|
||||
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 copy = s.get(id).unwrap().rich_copy(0, &text, &all).unwrap();
|
||||
let html = ©.html;
|
||||
assert!(!copy.styles_incomplete);
|
||||
assert!(
|
||||
html.starts_with("<p style=\"margin:0\"><span style=\"font-family:'Helvetica';font-size:24pt\"><strong>Title</strong></span></p>\n"),
|
||||
"{html}"
|
||||
);
|
||||
assert!(html.contains("font-size:12pt\">Body text "), "{html}");
|
||||
assert!(html.contains("font-size:12pt;color:#FF0000\">red</span>"), "{html}");
|
||||
assert!(html.contains("a<b & c"), "{html}");
|
||||
assert!(!html.contains("<b &"), "{html}");
|
||||
assert_eq!(copy.text.paragraphs.len(), html.matches("<p ").count());
|
||||
assert!(copy.text.paragraphs.len() >= 2, "{html}");
|
||||
// The heading and the body are separate paragraphs; the red word shares the body's.
|
||||
let body = copy.text.paragraphs.iter().find(|p| p.iter().any(|s| s.text.contains("Body"))).unwrap();
|
||||
assert!(body.iter().any(|s| s.text == "red" && s.style.color == [1.0, 0.0, 0.0]), "{body:?}");
|
||||
|
||||
// A part of the selection: only those glyphs.
|
||||
let part = s.get(id).unwrap().rich_copy(0, &text, &all[..5]).unwrap();
|
||||
assert_eq!(part.text.paragraphs.len(), 1);
|
||||
assert!(part.html.contains("<strong>Title</strong>"), "{}", part.html);
|
||||
// Nothing selected, and indices past the text: an empty copy, not a panic.
|
||||
let none = s.get(id).unwrap().rich_copy(0, &text, &[]).unwrap();
|
||||
assert!(none.html.is_empty() && none.text.paragraphs.is_empty());
|
||||
let past = s.get(id).unwrap().rich_copy(7, &text, &[usize::MAX, text.glyphs.len()]).unwrap();
|
||||
assert!(past.html.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copy_with_formatting_is_refused_when_copying_is_withheld() {
|
||||
let mut cos = pdfcraft_cos::Document::open(Arc::new(styled_page())).unwrap();
|
||||
cos.set_encryption(&pdfcraft_cos::NewEncryption {
|
||||
algorithm: pdfcraft_cos::Algorithm::Aes256,
|
||||
user_password: "",
|
||||
owner_password: "owner",
|
||||
permissions: 0b0100, // print only
|
||||
encrypt_metadata: true,
|
||||
seed: [7; 32],
|
||||
})
|
||||
.unwrap();
|
||||
let bytes = Arc::new(pdfcraft_cos::write_full(&cos, &Default::default()).unwrap());
|
||||
let mut s = Session::new();
|
||||
let id = s.open("locked.pdf", None, bytes, None).unwrap();
|
||||
let text = pdfcraft_render::PageText { glyphs: Vec::new(), line_of: Vec::new(), space_before: Vec::new() };
|
||||
let err = s.get(id).unwrap().rich_copy(0, &text, &[]).unwrap_err();
|
||||
assert!(matches!(err, crate::EditError::NotPermitted(_)), "{err:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
//! Copy with formatting: selected text as paragraphs of styled spans, written as an HTML
|
||||
//! fragment for the clipboard (inline styles, so a word processor keeps font, size, weight,
|
||||
//! slant and colour when it pastes).
|
||||
|
||||
use crate::{esc, hex};
|
||||
|
||||
/// Spans kept for one copy; text past it is left out and [`ClipText::truncated`] is set.
|
||||
pub const MAX_CLIP_SPANS: usize = 20_000;
|
||||
/// Text bytes kept for one copy (before escaping).
|
||||
pub const MAX_CLIP_TEXT_BYTES: usize = 2 << 20;
|
||||
/// The HTML written for one copy never passes this many bytes.
|
||||
pub const MAX_CLIP_HTML_BYTES: usize = 16 << 20;
|
||||
/// A family name longer than this is not a family name.
|
||||
const MAX_FAMILY_CHARS: usize = 64;
|
||||
|
||||
/// How a span looks.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ClipStyle {
|
||||
/// 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.
|
||||
pub size: f64,
|
||||
pub bold: bool,
|
||||
pub italic: bool,
|
||||
/// Fill colour as RGB in 0..=1.
|
||||
pub color: [f64; 3],
|
||||
}
|
||||
|
||||
/// A run of text in one style.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ClipSpan {
|
||||
pub text: String,
|
||||
pub style: ClipStyle,
|
||||
}
|
||||
|
||||
/// The copied text: paragraphs of spans, and whether a cap left some out.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ClipText {
|
||||
pub paragraphs: Vec<Vec<ClipSpan>>,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// Builds a [`ClipText`] piece by piece, merging pieces of equal style into one span and
|
||||
/// stopping at the caps.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ClipBuilder {
|
||||
out: ClipText,
|
||||
/// The separator owed before the next piece in this paragraph (a space or a line break).
|
||||
pending: Option<char>,
|
||||
new_paragraph: bool,
|
||||
spans: usize,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl ClipBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self { new_paragraph: true, ..Self::default() }
|
||||
}
|
||||
|
||||
/// A word space before the next piece (dropped at the start of a paragraph).
|
||||
pub fn space(&mut self) {
|
||||
self.pending.get_or_insert(' ');
|
||||
}
|
||||
|
||||
/// A line break inside the paragraph before the next piece.
|
||||
pub fn line_break(&mut self) {
|
||||
self.pending = Some('\n');
|
||||
}
|
||||
|
||||
/// The next piece starts a new paragraph.
|
||||
pub fn paragraph(&mut self) {
|
||||
self.new_paragraph = true;
|
||||
self.pending = None;
|
||||
}
|
||||
|
||||
/// Append `text` in `style`. Returns `false` once a cap is reached; nothing more is kept.
|
||||
pub fn push(&mut self, text: &str, style: &ClipStyle) -> bool {
|
||||
if self.out.truncated {
|
||||
return false;
|
||||
}
|
||||
if text.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let sep = if self.new_paragraph { None } else { self.pending };
|
||||
let need = text.len().saturating_add(sep.map_or(0, char::len_utf8));
|
||||
let Some(bytes) = self.bytes.checked_add(need).filter(|b| *b <= MAX_CLIP_TEXT_BYTES) else {
|
||||
self.out.truncated = true;
|
||||
return false;
|
||||
};
|
||||
if self.new_paragraph {
|
||||
self.out.paragraphs.push(Vec::new());
|
||||
self.new_paragraph = false;
|
||||
}
|
||||
self.pending = None;
|
||||
let Some(spans) = self.out.paragraphs.last_mut() else { return false };
|
||||
match spans.last_mut() {
|
||||
Some(last) if last.style == *style => {
|
||||
last.text.extend(sep);
|
||||
last.text.push_str(text);
|
||||
}
|
||||
last => {
|
||||
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);
|
||||
}
|
||||
spans.push(ClipSpan { text: text.to_owned(), style: style.clone() });
|
||||
self.spans += 1;
|
||||
}
|
||||
}
|
||||
self.bytes = bytes;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn finish(self) -> ClipText {
|
||||
self.out
|
||||
}
|
||||
}
|
||||
|
||||
/// The family 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> {
|
||||
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 family = family.strip_suffix("PSMT").or_else(|| family.strip_suffix("MT")).or_else(|| family.strip_suffix("PS")).unwrap_or(family);
|
||||
let mut spaced = String::with_capacity(family.len().min(MAX_FAMILY_CHARS * 2));
|
||||
let mut prev_lower = false;
|
||||
for c in family.chars().take(MAX_FAMILY_CHARS) {
|
||||
if prev_lower && c.is_ascii_uppercase() {
|
||||
spaced.push(' ');
|
||||
}
|
||||
prev_lower = c.is_ascii_lowercase();
|
||||
spaced.push(c);
|
||||
}
|
||||
safe_family(&spaced)
|
||||
}
|
||||
|
||||
/// `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> {
|
||||
let kept: String = family.chars().filter(|c| c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '.')).take(MAX_FAMILY_CHARS * 2).collect();
|
||||
let kept = kept.trim();
|
||||
(!kept.is_empty()).then(|| kept.to_owned())
|
||||
}
|
||||
|
||||
/// The copied text as an HTML fragment: 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 {
|
||||
const OPEN: &str = "<p style=\"margin:0\">";
|
||||
const CLOSE: &str = "</p>\n";
|
||||
let mut out = String::new();
|
||||
for spans in &text.paragraphs {
|
||||
let mut p = String::from(OPEN);
|
||||
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 {
|
||||
if p.len() > OPEN.len() {
|
||||
out.push_str(&p);
|
||||
out.push_str(CLOSE);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
p.push_str(&s);
|
||||
}
|
||||
p.push_str(CLOSE);
|
||||
out.push_str(&p);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn span_html(span: &ClipSpan) -> 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}'"));
|
||||
}
|
||||
if st.size.is_finite() && st.size > 0.0 {
|
||||
css.push(format!("font-size:{}pt", num(st.size)));
|
||||
}
|
||||
if let Some(h) = hex(st.color) {
|
||||
css.push(format!("color:#{h}"));
|
||||
}
|
||||
let mut body = text_html(&span.text);
|
||||
if st.italic {
|
||||
body = format!("<em>{body}</em>");
|
||||
}
|
||||
if st.bold {
|
||||
body = format!("<strong>{body}</strong>");
|
||||
}
|
||||
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 ` `.
|
||||
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(" ");
|
||||
} else {
|
||||
out.push_str(&esc(c.encode_utf8(&mut [0; 4])));
|
||||
}
|
||||
prev_space = c == ' ';
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A size with at most one decimal and no trailing `.0`.
|
||||
fn num(v: f64) -> String {
|
||||
let r = (v * 10.0).round() / 10.0;
|
||||
if r.fract() == 0.0 { format!("{r:.0}") } else { format!("{r:.1}") }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn style(size: f64, bold: bool, italic: bool, color: [f64; 3]) -> ClipStyle {
|
||||
ClipStyle { family: font_family("ABCDEF+Helvetica-Bold"), size, bold, italic, color }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_body_and_coloured_span_keep_their_formatting() {
|
||||
let mut b = ClipBuilder::new();
|
||||
let heading = style(24.0, true, false, [0.0; 3]);
|
||||
let body = style(11.5, false, false, [0.0; 3]);
|
||||
let red = style(11.5, false, true, [1.0, 0.0, 0.0]);
|
||||
b.push("Title", &heading);
|
||||
b.paragraph();
|
||||
b.push("Body", &body);
|
||||
b.space();
|
||||
b.push("text", &body);
|
||||
b.space();
|
||||
b.push("red", &red);
|
||||
let clip = b.finish();
|
||||
assert_eq!(clip.paragraphs.len(), 2);
|
||||
assert_eq!(clip.paragraphs.get(1).map(Vec::len), Some(2), "{clip:?}");
|
||||
let html = clip_html(&clip);
|
||||
assert_eq!(
|
||||
html,
|
||||
"<p style=\"margin:0\"><span style=\"font-family:'Helvetica';font-size:24pt\"><strong>Title</strong></span></p>\n\
|
||||
<p style=\"margin:0\"><span style=\"font-family:'Helvetica';font-size:11.5pt\">Body text </span>\
|
||||
<span style=\"font-family:'Helvetica';font-size:11.5pt;color:#FF0000\"><em>red</em></span></p>\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[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 mut b = ClipBuilder::new();
|
||||
b.push("<a href=\"x\">&\u{0}\u{1b}</a> two\nline", &st);
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_huge_selections_are_bounded() {
|
||||
let b = ClipBuilder::new();
|
||||
assert_eq!(clip_html(&b.finish()), "");
|
||||
let mut b = ClipBuilder::new();
|
||||
b.space();
|
||||
assert!(b.push("", &ClipStyle::default()));
|
||||
assert!(b.finish().paragraphs.is_empty(), "nothing pushed, nothing kept");
|
||||
|
||||
// Alternating styles: the span cap stops it.
|
||||
let (a, c) = (style(10.0, false, false, [0.0; 3]), style(10.0, true, false, [0.0; 3]));
|
||||
let mut b = ClipBuilder::new();
|
||||
let mut kept = 0usize;
|
||||
for i in 0..MAX_CLIP_SPANS + 10 {
|
||||
if b.push("x", if i % 2 == 0 { &a } else { &c }) {
|
||||
kept += 1;
|
||||
}
|
||||
}
|
||||
let clip = b.finish();
|
||||
assert!(clip.truncated);
|
||||
assert_eq!(kept, MAX_CLIP_SPANS);
|
||||
// One style: the text cap stops it.
|
||||
let mut b = ClipBuilder::new();
|
||||
let chunk = "y".repeat(1 << 16);
|
||||
while b.push(&chunk, &a) {}
|
||||
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);
|
||||
assert!(html.len() <= MAX_CLIP_HTML_BYTES && html.ends_with("</p>\n"), "{}", html.len());
|
||||
assert!(html.len() > MAX_CLIP_HTML_BYTES / 2);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ mod as_docx;
|
||||
mod as_html;
|
||||
mod as_markdown;
|
||||
mod as_rtf;
|
||||
mod clip;
|
||||
mod layout;
|
||||
|
||||
pub use as_docx::docx;
|
||||
@@ -34,6 +35,7 @@ pub use as_markdown::markdown;
|
||||
#[cfg(test)]
|
||||
use as_markdown::{emphasis, paragraph};
|
||||
pub use as_rtf::rtf;
|
||||
pub use clip::{ClipBuilder, ClipSpan, ClipStyle, ClipText, MAX_CLIP_HTML_BYTES, MAX_CLIP_SPANS, MAX_CLIP_TEXT_BYTES, clip_html, font_family};
|
||||
pub use layout::{Block, Cell, Image, Page, Table};
|
||||
#[cfg(test)]
|
||||
use layout::{LOOKUP_STEPS, MAX_COLS, body_size};
|
||||
|
||||
@@ -233,6 +233,7 @@ impl PdfCraftApp {
|
||||
last_snapshot: None,
|
||||
last_copied_image: None,
|
||||
last_copied_file: None,
|
||||
last_copied_html: None,
|
||||
system_clipboard: true,
|
||||
clipboard_override: None,
|
||||
attach_override: None,
|
||||
|
||||
@@ -124,13 +124,34 @@ 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;
|
||||
}
|
||||
ctx.copy_text(text);
|
||||
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) {
|
||||
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 {
|
||||
#[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();
|
||||
}
|
||||
let _ = text;
|
||||
false
|
||||
}
|
||||
|
||||
/// The clipboard text of a whole-document copy, once its pages are read; dropped with the
|
||||
/// document it was copied from, and by any later copy (a text field's, a comment's), which
|
||||
/// it must not replace. Run at the end of the frame, so it sees this frame's copies.
|
||||
|
||||
@@ -93,6 +93,21 @@ impl DocView {
|
||||
Some(s.text(t)).filter(|x| !x.is_empty())
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub(crate) fn selected_glyphs(&self) -> Option<(usize, Arc<PageText>, Vec<usize>)> {
|
||||
if self.all_text {
|
||||
return None;
|
||||
}
|
||||
let s = self.selection?;
|
||||
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),
|
||||
};
|
||||
Some((s.page, t.clone(), glyphs))
|
||||
}
|
||||
|
||||
/// Select every page without navigating; shared by Organize and the Pages sidebar.
|
||||
pub fn select_all_pages(&mut self) -> bool {
|
||||
self.selected = (0..self.page_count).collect();
|
||||
|
||||
@@ -563,6 +563,9 @@ 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,
|
||||
/// What ⌘V reads instead of the system clipboard (tests).
|
||||
pub clipboard_override: Option<create_ui::Clip>,
|
||||
|
||||
@@ -193,7 +193,7 @@ fn copied(h: &Harness<'static, PdfCraftApp>, want: &str) -> bool {
|
||||
#[test]
|
||||
fn selected_text_copy_edit_and_context_menus() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let (mut h, at) = harness(|_| {});
|
||||
let (mut h, at) = harness(|app| app.system_clipboard = false);
|
||||
click(&mut h, at);
|
||||
click(&mut h, at);
|
||||
h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::Hand;
|
||||
@@ -245,7 +245,7 @@ fn escape_closes_an_open_menu_before_it_clears_the_selection() {
|
||||
|
||||
#[test]
|
||||
fn selected_text_copy_command_requires_selection() {
|
||||
let (mut h, at) = harness(|_| {});
|
||||
let (mut h, at) = harness(|app| app.system_clipboard = false);
|
||||
assert!(!h.state_mut().execute("edit.copy"));
|
||||
click(&mut h, at);
|
||||
click(&mut h, at);
|
||||
@@ -277,6 +277,7 @@ fn selected_text_copy_floating_button_honours_permissions() {
|
||||
app.open_bytes("no-copy.pdf", None, bytes).unwrap();
|
||||
app.submit_password(Some(password.into()));
|
||||
app.set_option("left", "closed").unwrap();
|
||||
app.system_clipboard = false;
|
||||
app
|
||||
});
|
||||
let mut loaded = false;
|
||||
@@ -301,6 +302,57 @@ 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.
|
||||
#[test]
|
||||
fn selected_text_copy_keeps_the_formatting_as_html() {
|
||||
let (mut h, at) = harness(|app| app.system_clipboard = false);
|
||||
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")
|
||||
);
|
||||
// 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}");
|
||||
}
|
||||
|
||||
/// A document that withholds copying gives neither the text nor its HTML.
|
||||
#[test]
|
||||
fn selected_text_copy_with_formatting_honours_permissions() {
|
||||
let mut doc = pdfcraft_cos::Document::open(std::sync::Arc::new(two_lines())).unwrap();
|
||||
doc.set_encryption(&pdfcraft_cos::NewEncryption {
|
||||
algorithm: pdfcraft_cos::Algorithm::Aes256,
|
||||
user_password: "pw",
|
||||
owner_password: "owner",
|
||||
permissions: -1 ^ 0b1_0000,
|
||||
encrypt_metadata: true,
|
||||
seed: [4; 32],
|
||||
})
|
||||
.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;
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_text_copy_preserves_unicode_and_page_clipboard() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
|
||||
|
||||
@@ -1571,11 +1571,11 @@ title = "Copy text keeping layout"
|
||||
area = "B"
|
||||
tier = "P2"
|
||||
milestone = "M3"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
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"]
|
||||
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]]
|
||||
id = "view.snapshot"
|
||||
|
||||
Reference in new issue
Block a user