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:
KenM76andClaude Opus 5.5 committed 2026-11-04 11:58:06 -05:00
1 parent 8717a085ff
commit 352355ab77
14 files changed
+718 -17

No files matched your search

+21 -1
View File
@@ -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 }))
}
+3 -2
View File
@@ -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"],
)),
+30
View File
@@ -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};
+18 -6
View File
@@ -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),
}
+2
View File
@@ -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};
+220
View File
@@ -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 = &copy.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&lt;b &amp; 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:?}");
}
}
+322
View File
@@ -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 `&nbsp;`.
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 == ' ';
}
}
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'\">&lt;a href=&quot;x&quot;&gt;&amp;&lt;/a&gt; &nbsp;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);
}
}
+2
View File
@@ -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};
+1
View File
@@ -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,
+22 -1
View File
@@ -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.
+15
View File
@@ -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();
+3
View File
@@ -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>,
+54 -2
View File
@@ -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| {
+5 -5
View File
@@ -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"